From 9684cef97b2d51691db0e33032fa19ff6ac263c9 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 23 Jul 2026 16:19:17 -0500 Subject: [PATCH 001/473] fix(strict_schema): reject empty additionalProperties mappings (#3927) --- src/agents/strict_schema.py | 5 ++++- tests/test_strict_schema.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 89f302f5d8..463cede791 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -90,7 +90,10 @@ def _ensure_strict_json_schema( elif ( typ == "object" and "additionalProperties" in json_schema - and json_schema["additionalProperties"] + # Compare with ``is not False`` rather than truthiness: OpenAPI/MCP schemas often use + # ``additionalProperties: {}`` (an empty schema meaning "allow anything"). That value is + # falsy in Python, so a truthiness check would silently leave a non-strict schema in place. + and json_schema["additionalProperties"] is not False ): raise UserError( "additionalProperties should not be set for object types. This could be because " diff --git a/tests/test_strict_schema.py b/tests/test_strict_schema.py index 0a43f78d78..b431fb39bb 100644 --- a/tests/test_strict_schema.py +++ b/tests/test_strict_schema.py @@ -56,6 +56,40 @@ def test_object_with_true_additional_properties(): ensure_strict_json_schema(schema) +def test_object_with_empty_dict_additional_properties(): + # OpenAPI/MCP schemas commonly use ``additionalProperties: {}`` to mean "allow anything". + # That empty mapping is falsy in Python, but it is still non-strict and must be rejected. + schema = { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": {}, + } + with pytest.raises(UserError): + ensure_strict_json_schema(schema) + + +def test_object_with_schema_additional_properties(): + # A non-empty additionalProperties schema is also non-strict and must be rejected. + schema = { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": {"type": "string"}, + } + with pytest.raises(UserError): + ensure_strict_json_schema(schema) + + +def test_object_with_false_additional_properties_is_allowed(): + schema = { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": False, + } + result = ensure_strict_json_schema(schema) + assert result["additionalProperties"] is False + assert result["required"] == ["a"] + + def test_array_items_processing_and_default_removal(): # When processing an array, the items schema is processed recursively. # Also, any "default": None should be removed. From cece04ce56b7272b243d624ccf143ad2752cb52f Mon Sep 17 00:00:00 2001 From: Dima Osipa <1094629+dimaosipa@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:39:28 -0700 Subject: [PATCH 002/473] fix(litellm): send logprobs=True when top_logprobs is set (#3929) --- src/agents/extensions/models/any_llm_model.py | 6 + src/agents/extensions/models/litellm_model.py | 29 +++++ tests/models/test_any_llm_model.py | 50 ++++++++ tests/models/test_litellm_logprobs.py | 118 ++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 tests/models/test_litellm_logprobs.py diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 95a0b86688..02790e5f39 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -731,6 +731,12 @@ async def _fetch_chat_response( extra_kwargs = self._build_chat_extra_kwargs(model_settings) extra_kwargs.pop("reasoning_effort", None) + # The Chat Completions API requires logprobs=True whenever top_logprobs is set. Defer to a + # caller-supplied logprobs (via extra_args, already merged into extra_kwargs) to avoid a + # duplicate-key collision. + if model_settings.top_logprobs is not None and "logprobs" not in extra_kwargs: + extra_kwargs["logprobs"] = True + ret = await self._get_provider().acompletion( model=self._provider_model, messages=converted_messages, diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 2b8735d9bf..df689699a2 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -333,12 +333,35 @@ async def get_response( else [] ) + # LiteLLM's Choices omits the logprobs attribute entirely when it was not requested, + # so access it defensively (mirrors the finish_reason handling above). + logprob_models = None + choice_logprobs = getattr(first_choice, "logprobs", None) if first_choice else None + if choice_logprobs is not None and getattr(choice_logprobs, "content", None): + logprob_models = ChatCmplHelpers.convert_logprobs_for_output_text( + choice_logprobs.content + ) + + if logprob_models: + self._attach_logprobs_to_output(items, logprob_models) + return ModelResponse( output=items, usage=usage, response_id=None, ) + def _attach_logprobs_to_output(self, output_items: list[Any], logprobs: list[Any]) -> None: + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + + for output_item in output_items: + if not isinstance(output_item, ResponseOutputMessage): + continue + for content in output_item.content: + if isinstance(content, ResponseOutputText): + content.logprobs = logprobs + return + async def stream_response( self, system_instructions: str | None, @@ -560,6 +583,12 @@ async def _fetch_response( # Prevent duplicate reasoning_effort kwargs when it was promoted to a top-level argument. extra_kwargs.pop("reasoning_effort", None) + # The Chat Completions API requires logprobs=True whenever top_logprobs is set. Defer to a + # caller-supplied logprobs (via extra_args, already merged into extra_kwargs) to avoid a + # duplicate-key collision. + if model_settings.top_logprobs is not None and "logprobs" not in extra_kwargs: + extra_kwargs["logprobs"] = True + ret = await litellm.acompletion( model=self.model, messages=converted_messages, diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 06f57abd7c..6950130eec 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -902,3 +902,53 @@ def test_any_llm_split_does_not_duplicate_content_or_thinking(monkeypatch) -> No # Tool calls are still split one-per-message. assert assistants[0]["tool_calls"][0]["id"] == "call_1" assert assistants[1]["tool_calls"][0]["id"] == "call_2" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_sets_logprobs_when_top_logprobs_set(monkeypatch) -> None: + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) + module, _ = _import_any_llm_module(monkeypatch, provider) + AnyLLMModel = module.AnyLLMModel + + model = AnyLLMModel(model="openrouter/openai/gpt-5.4-mini", api_key="k") + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(top_logprobs=2), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + # The Chat Completions API rejects top_logprobs unless logprobs is True. + assert provider.chat_calls[0]["top_logprobs"] == 2 + assert provider.chat_calls[0]["logprobs"] is True + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_omits_logprobs_when_top_logprobs_unset(monkeypatch) -> None: + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) + module, _ = _import_any_llm_module(monkeypatch, provider) + AnyLLMModel = module.AnyLLMModel + + model = AnyLLMModel(model="openrouter/openai/gpt-5.4-mini", api_key="k") + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert "logprobs" not in provider.chat_calls[0] diff --git a/tests/models/test_litellm_logprobs.py b/tests/models/test_litellm_logprobs.py new file mode 100644 index 0000000000..00354ab57e --- /dev/null +++ b/tests/models/test_litellm_logprobs.py @@ -0,0 +1,118 @@ +import litellm +import pytest +from litellm.types.utils import ( + ChatCompletionTokenLogprob, + ChoiceLogprobs, + Choices, + Message, + ModelResponse, + TopLogprob, + Usage, +) +from openai.types.responses import ResponseOutputMessage, ResponseOutputText + +from agents.extensions.models.litellm_model import LitellmModel +from agents.model_settings import ModelSettings +from agents.models.interface import ModelTracing + + +async def _capture_litellm_kwargs(monkeypatch, settings: ModelSettings) -> dict[str, object]: + captured: dict[str, object] = {} + + async def fake_acompletion(model, messages=None, **kwargs): + captured.update(kwargs) + msg = Message(role="assistant", content="ok") + choice = Choices(index=0, message=msg) + return ModelResponse(choices=[choice], usage=Usage(0, 0, 0)) + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + await LitellmModel(model="test-model").get_response( + system_instructions=None, + input=[], + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + ) + return captured + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_top_logprobs_sets_logprobs_flag(monkeypatch): + captured = await _capture_litellm_kwargs(monkeypatch, ModelSettings(top_logprobs=2)) + # The Chat Completions API rejects top_logprobs unless logprobs is True. + assert captured["top_logprobs"] == 2 + assert captured["logprobs"] is True + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_omits_logprobs_when_top_logprobs_unset(monkeypatch): + captured = await _capture_litellm_kwargs(monkeypatch, ModelSettings()) + assert "logprobs" not in captured + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_top_logprobs_with_extra_args_logprobs_does_not_collide(monkeypatch): + # Setting both top_logprobs and extra_args["logprobs"] must defer to the caller's logprobs + # rather than adding a duplicate that collides. + captured = await _capture_litellm_kwargs( + monkeypatch, ModelSettings(top_logprobs=2, extra_args={"logprobs": True}) + ) + assert captured["top_logprobs"] == 2 + assert captured["logprobs"] is True + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_preserves_returned_logprobs_in_output(monkeypatch): + """Returned token logprobs must be attached to ResponseOutputText.logprobs.""" + + async def fake_acompletion(model, messages=None, **kwargs): + message = Message(role="assistant", content="Hello") + logprobs = ChoiceLogprobs( + content=[ + ChatCompletionTokenLogprob( + token="Hello", + logprob=-0.25, + bytes=[72, 101, 108, 108, 111], + top_logprobs=[ + TopLogprob(token="Hello", logprob=-0.25, bytes=[72, 101, 108, 108, 111]), + TopLogprob(token="Hi", logprob=-1.5, bytes=[72, 105]), + ], + ) + ] + ) + choice = Choices(index=0, message=message, logprobs=logprobs) + return ModelResponse(choices=[choice], usage=Usage(0, 0, 0)) + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + response = await LitellmModel(model="test-model").get_response( + system_instructions=None, + input=[], + model_settings=ModelSettings(top_logprobs=2), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + ) + + texts = [ + content + for item in response.output + if isinstance(item, ResponseOutputMessage) + for content in item.content + if isinstance(content, ResponseOutputText) + ] + assert texts, "expected a ResponseOutputText in the output" + output_logprobs = texts[0].logprobs + assert output_logprobs is not None + assert len(output_logprobs) == 1 + assert output_logprobs[0].token == "Hello" + assert output_logprobs[0].logprob == -0.25 + assert [tlp.token for tlp in output_logprobs[0].top_logprobs] == ["Hello", "Hi"] From f78df37ee95f7c78d68689178ce5f8b4ba57f8ef Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Jul 2026 08:04:45 +0900 Subject: [PATCH 003/473] feat: consistently accept typed objects and dictionaries for SDK configuration (#3917) --- .../skills/implementation-strategy/SKILL.md | 2 + src/agents/__init__.py | 2 +- src/agents/_config.py | 4 +- src/agents/_config_coercion.py | 97 +++++++++++ src/agents/agent.py | 61 ++++++- .../memory/advanced_sqlite_session.py | 2 +- .../extensions/memory/async_sqlite_session.py | 16 +- src/agents/extensions/memory/dapr_session.py | 16 +- .../extensions/memory/mongodb_session.py | 16 +- src/agents/extensions/memory/redis_session.py | 16 +- .../extensions/memory/sqlalchemy_session.py | 16 +- .../memory/openai_conversations_session.py | 11 +- src/agents/memory/session_settings.py | 43 ++++- src/agents/memory/sqlite_session.py | 12 +- src/agents/model_settings.py | 128 +++++++++++++- src/agents/models/multi_provider.py | 4 +- .../models/openai_agent_registration.py | 22 ++- src/agents/models/openai_provider.py | 3 +- src/agents/responses_websocket_session.py | 13 +- src/agents/run.py | 15 +- src/agents/run_config.py | 159 +++++++++++++++++- src/agents/sandbox/config.py | 47 +++++- src/agents/sandbox/manifest.py | 15 +- src/agents/sandbox/sandbox_agent.py | 67 +++++++- src/agents/tool.py | 17 ++ src/agents/tool_context.py | 11 +- .../voice/models/openai_model_provider.py | 4 +- src/agents/voice/pipeline.py | 10 +- src/agents/voice/pipeline_config.py | 31 +++- .../memory/test_advanced_sqlite_session.py | 7 +- .../memory/test_async_sqlite_session.py | 7 +- tests/extensions/memory/test_dapr_session.py | 7 +- .../extensions/memory/test_mongodb_session.py | 6 +- tests/extensions/memory/test_redis_session.py | 11 +- .../memory/test_sqlalchemy_session.py | 7 +- tests/extensions/sandbox/test_blaxel.py | 21 +++ .../test_openai_conversations_session.py | 11 ++ tests/memory/test_session.py | 18 +- tests/model_settings/test_serialization.py | 49 ++++++ tests/models/test_agent_registration.py | 30 ++++ tests/models/test_kwargs_functionality.py | 41 +++++ tests/models/test_openai_chatcompletions.py | 50 +++++- .../test_openai_chatcompletions_stream.py | 90 ++++++++++ tests/models/test_openai_responses.py | 75 ++++++++- .../models/test_openai_responses_converter.py | 27 +++ .../test_responses_websocket_session.py | 31 +++- tests/sandbox/test_memory.py | 102 ++++++++++- .../sandbox/test_runtime_agent_preparation.py | 43 +++++ tests/test_agent_config.py | 67 +++++++- tests/test_run_config.py | 105 +++++++++++- tests/test_tool_context.py | 48 ++++++ tests/voice/test_pipeline.py | 81 +++++++++ 52 files changed, 1683 insertions(+), 111 deletions(-) create mode 100644 src/agents/_config_coercion.py diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index df697dd7d3..a18cf574f2 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -43,6 +43,8 @@ Use this skill before editing code when the task changes runtime behavior or any - When unsupported OpenAI API or provider-adapter behavior already has a released default path, avoid turning it into a default hard error unless the latest release boundary justifies that break. Prefer an opt-in strict mode such as `strict_feature_validation=True`, while keeping the default path compatible through warning, ignoring unsupported data, or a clearly non-empty placeholder. - For OpenAI API feature gaps, evaluate streaming and non-streaming paths together. Custom tool calls, multi-choice Chat Completions chunks, non-text tool outputs, and similar provider payload differences must not be strict in one path and permissive or malformed in the other. - When a change creates new public SDK behavior, do not expose it only through hard-coded module globals. Prefer an explicit public configuration object or parameter, preserve the existing default behavior when compatibility-sensitive, and make opt-in SDK defaults explicit. +- For SDK-owned public configuration, accept existing typed objects and equivalent dictionaries at the public input boundary while preserving the internal typed representation. Respect the owning model's validation and extra-field policy instead of recreating arbitrary third-party schema semantics. +- Keep model-specific settings inside the existing `model_settings` parameter. Preserve released constructor arguments, typed-object behavior, and provider request payloads when adding dictionary support. - Append new optional fields or constructor parameters to public dataclasses and constructors. Do not insert them before existing public fields unless you also provide a compatibility layer and regression coverage for the old positional call shape. - Treat threshold and quota values as part of the API design when they affect runtime behavior. Distinguish OpenAI platform quota-derived values from defensive SDK defaults; if the value is not anchored in a documented platform limit, avoid making it an unconditional default-on behavior. - Define `None` semantics deliberately for public configuration. For example, use separate meanings for "feature disabled or no SDK limit", "use SDK default limits", and "disable only this specific limit" rather than relying on implicit truthiness checks. diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 2ec18c2460..515916c2d1 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -314,7 +314,7 @@ def set_default_openai_responses_transport(transport: Literal["http", "websocket def set_default_openai_agent_registration( - config: OpenAIAgentRegistrationConfig | None, + config: OpenAIAgentRegistrationConfig | dict[str, Any] | None, ) -> None: """Set the default OpenAI agent registration config. diff --git a/src/agents/_config.py b/src/agents/_config.py index e5bdd3d0d7..846debd43b 100644 --- a/src/agents/_config.py +++ b/src/agents/_config.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Any, Literal from openai import AsyncOpenAI @@ -40,7 +40,7 @@ def set_default_openai_responses_transport(transport: Literal["http", "websocket def set_default_openai_agent_registration( - config: OpenAIAgentRegistrationConfig | None, + config: OpenAIAgentRegistrationConfig | dict[str, Any] | None, ) -> None: set_default_openai_agent_registration_config(config) diff --git a/src/agents/_config_coercion.py b/src/agents/_config_coercion.py new file mode 100644 index 0000000000..2d0722f3d6 --- /dev/null +++ b/src/agents/_config_coercion.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import fields, is_dataclass +from types import UnionType +from typing import Any, TypeVar, Union, cast, get_args, get_origin, get_type_hints + +from pydantic import AliasChoices, BaseModel + +ConfigT = TypeVar("ConfigT") +DataclassConfigT = TypeVar("DataclassConfigT") +PydanticConfigT = TypeVar("PydanticConfigT", bound=BaseModel) + + +def _declared_dataclass_type( + owner_type: type[Any], + field_name: str, + default_type: type[DataclassConfigT], +) -> type[DataclassConfigT]: + try: + annotation = get_type_hints(owner_type).get(field_name) + except (NameError, TypeError): + return default_type + + candidates = ( + get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,) + ) + for candidate in candidates: + if ( + isinstance(candidate, type) + and is_dataclass(candidate) + and issubclass(candidate, default_type) + ): + return candidate + return default_type + + +def _dataclass_input_values( + value: dict[str, Any], + config_type: type[Any], +) -> dict[str, Any]: + field_names = {config_field.name for config_field in fields(config_type)} + return {name: field_value for name, field_value in value.items() if name in field_names} + + +def coerce_dataclass_config( + value: ConfigT | dict[str, Any], + config_type: type[ConfigT], + *, + parameter_name: str, +) -> ConfigT: + """Normalize an SDK-owned dataclass configuration at its public input boundary.""" + if isinstance(value, config_type): + return value + if not isinstance(value, dict): + raise TypeError( + f"{parameter_name} must be a {config_type.__name__} instance or a dict, " + f"got {type(value).__name__}" + ) + + field_names = { + config_field.name for config_field in fields(cast(Any, config_type)) if config_field.init + } + unknown_fields = sorted(str(name) for name in value if name not in field_names) + if unknown_fields: + raise TypeError(f"Unknown {parameter_name} settings: {', '.join(unknown_fields)}") + return config_type(**value) + + +def coerce_pydantic_config( + value: PydanticConfigT | dict[str, Any], + config_type: type[PydanticConfigT], + *, + parameter_name: str, +) -> PydanticConfigT: + """Normalize an SDK-owned Pydantic configuration using its declared extra policy.""" + if isinstance(value, config_type): + return value + if not isinstance(value, dict): + raise TypeError( + f"{parameter_name} must be a {config_type.__name__} instance or a dict, " + f"got {type(value).__name__}" + ) + + if config_type.model_config.get("extra") != "allow": + accepted_fields: set[str] = set(config_type.model_fields) + for field_info in config_type.model_fields.values(): + if isinstance(field_info.validation_alias, str): + accepted_fields.add(field_info.validation_alias) + elif isinstance(field_info.validation_alias, AliasChoices): + accepted_fields.update( + alias for alias in field_info.validation_alias.choices if isinstance(alias, str) + ) + unknown_fields = sorted(str(name) for name in value if name not in accepted_fields) + if unknown_fields: + raise TypeError(f"Unknown {parameter_name} settings: {', '.join(unknown_fields)}") + + return config_type.model_validate(value) diff --git a/src/agents/agent.py b/src/agents/agent.py index e29d56801a..4f3c54a074 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -31,7 +31,7 @@ from .handoffs import Handoff from .logger import logger from .mcp import MCPUtil -from .model_settings import ModelSettings +from .model_settings import ModelSettings, _coerce_model_settings, _declared_model_settings_type from .models.default_models import ( get_default_model_settings, ) @@ -317,6 +317,8 @@ class Agent(AgentBase, Generic[TContext]): model_settings: ModelSettings = field(default_factory=get_default_model_settings) """Configures model-specific tuning parameters (e.g. temperature, top_p). + + Accepts a ``ModelSettings`` instance or a dictionary containing its fields. """ input_guardrails: list[InputGuardrail[TContext]] = field(default_factory=list) @@ -368,6 +370,39 @@ class Agent(AgentBase, Generic[TContext]): """Whether to reset the tool choice to the default value after a tool has been called. Defaults to True. This ensures that the agent doesn't enter an infinite loop of tool usage.""" + if TYPE_CHECKING: + + def __init__( + self, + name: str, + handoff_description: str | None = None, + tools: list[Tool] = ..., + mcp_servers: list[MCPServer] = ..., + mcp_config: MCPConfig = ..., + instructions: ( + str + | Callable[ + [RunContextWrapper[TContext], Agent[TContext]], + MaybeAwaitable[str], + ] + | None + ) = None, + prompt: Prompt | DynamicPromptFunction | None = None, + handoffs: list[Agent[Any] | Handoff[TContext, Any]] = ..., + model: str | Model | None = None, + model_settings: ModelSettings | dict[str, Any] = ..., + input_guardrails: list[InputGuardrail[TContext]] = ..., + output_guardrails: list[OutputGuardrail[TContext]] = ..., + output_type: type[Any] | AgentOutputSchemaBase | None = None, + hooks: AgentHooks[TContext] | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] + | StopAtTools + | ToolsToFinalOutputFunction + ) = "run_llm_again", + reset_tool_choice: bool = True, + ) -> None: ... + def __post_init__(self): from typing import get_origin @@ -424,11 +459,11 @@ def __post_init__(self): f"Agent model must be a string, Model, or None, got {type(self.model).__name__}" ) - if not isinstance(self.model_settings, ModelSettings): - raise TypeError( - f"Agent model_settings must be a ModelSettings instance, " - f"got {type(self.model_settings).__name__}" - ) + self.model_settings = _coerce_model_settings( + self.model_settings, + parameter_name="Agent model_settings", + model_settings_type=_declared_model_settings_type(type(self), "model_settings"), + ) if self.model is not None and self.model_settings == get_default_model_settings(): self.model_settings = _initial_model_settings_for_model(self.model) @@ -503,6 +538,13 @@ def clone(self, **kwargs: Any) -> Agent[TContext]: and _model_settings_match_implicit_model_defaults(self.model, self.model_settings) ): kwargs["model_settings"] = _initial_model_settings_for_model(kwargs["model"]) + if "model_settings" in kwargs: + kwargs["model_settings"] = _coerce_model_settings( + kwargs["model_settings"], + parameter_name="Agent model_settings", + model_settings_type=type(self.model_settings), + inherited_model_settings=self.model_settings, + ) return dataclasses.replace(self, **kwargs) def as_tool( @@ -515,7 +557,7 @@ def as_tool( is_enabled: bool | Callable[[RunContextWrapper[Any], AgentBase[Any]], MaybeAwaitable[bool]] = True, on_stream: Callable[[AgentToolStreamEvent], MaybeAwaitable[None]] | None = None, - run_config: RunConfig | None = None, + run_config: RunConfig | dict[str, Any] | None = None, max_turns: int | None = None, hooks: RunHooks[TContext] | None = None, previous_response_id: str | None = None, @@ -558,6 +600,11 @@ def as_tool( include_input_schema: Whether to include the full JSON schema in structured input. """ + if run_config is not None: + from .run_config import _coerce_run_config + + run_config = _coerce_run_config(run_config) + def _is_supported_parameters(value: Any) -> bool: if not isinstance(value, type): return False diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index 98a54e3123..2dd1e947fe 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -41,7 +41,7 @@ def __init__( db_path: str | Path = ":memory:", create_tables: bool = False, logger: logging.Logger | None = None, - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, **kwargs, ): """Initialize the AdvancedSQLiteSession. diff --git a/src/agents/extensions/memory/async_sqlite_session.py b/src/agents/extensions/memory/async_sqlite_session.py index 27a23b1cbe..63ae77081b 100644 --- a/src/agents/extensions/memory/async_sqlite_session.py +++ b/src/agents/extensions/memory/async_sqlite_session.py @@ -5,13 +5,17 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path -from typing import cast +from typing import Any, cast import aiosqlite from ...items import TResponseInputItem from ...memory import SessionABC -from ...memory.session_settings import SessionSettings, resolve_session_limit +from ...memory.session_settings import ( + SessionSettings, + coerce_session_settings, + resolve_session_limit, +) class AsyncSQLiteSession(SessionABC): @@ -30,7 +34,7 @@ def __init__( db_path: str | Path = ":memory:", sessions_table: str = "agent_sessions", messages_table: str = "agent_messages", - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, ): """Initialize the async SQLite session. @@ -44,7 +48,11 @@ def __init__( retrieving items. If None, uses default SessionSettings(). """ self.session_id = session_id - self.session_settings = session_settings or SessionSettings() + self.session_settings = ( + coerce_session_settings(session_settings) + if session_settings is not None + else SessionSettings() + ) self.db_path = db_path self.sessions_table = sessions_table self.messages_table = messages_table diff --git a/src/agents/extensions/memory/dapr_session.py b/src/agents/extensions/memory/dapr_session.py index 6ac68f6020..eaed2574f5 100644 --- a/src/agents/extensions/memory/dapr_session.py +++ b/src/agents/extensions/memory/dapr_session.py @@ -45,7 +45,11 @@ from ...items import TResponseInputItem from ...logger import logger from ...memory.session import SessionABC -from ...memory.session_settings import SessionSettings, resolve_session_limit +from ...memory.session_settings import ( + SessionSettings, + coerce_session_settings, + resolve_session_limit, +) # Type alias for consistency levels ConsistencyLevel = Literal["eventual", "strong"] @@ -72,7 +76,7 @@ def __init__( dapr_client: DaprClient, ttl: int | None = None, consistency: ConsistencyLevel = DAPR_CONSISTENCY_EVENTUAL, - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, ): """Initializes a new DaprSession. @@ -90,7 +94,11 @@ def __init__( default limit for retrieving items. If None, uses default SessionSettings(). """ self.session_id = session_id - self.session_settings = session_settings or SessionSettings() + self.session_settings = ( + coerce_session_settings(session_settings) + if session_settings is not None + else SessionSettings() + ) self._dapr_client = dapr_client self._state_store_name = state_store_name self._ttl = ttl @@ -109,7 +117,7 @@ def from_address( *, state_store_name: str, dapr_address: str = "localhost:50001", - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, **kwargs: Any, ) -> DaprSession: """Create a session from a Dapr sidecar address. diff --git a/src/agents/extensions/memory/mongodb_session.py b/src/agents/extensions/memory/mongodb_session.py index 07354577d6..98f7f26008 100644 --- a/src/agents/extensions/memory/mongodb_session.py +++ b/src/agents/extensions/memory/mongodb_session.py @@ -60,7 +60,11 @@ from ...items import TResponseInputItem from ...memory.session import SessionABC -from ...memory.session_settings import SessionSettings, resolve_session_limit +from ...memory.session_settings import ( + SessionSettings, + coerce_session_settings, + resolve_session_limit, +) # Identifies this library in the MongoDB handshake for server-side telemetry. _DRIVER_INFO = DriverInfo(name="openai-agents", version=_VERSION) @@ -110,7 +114,7 @@ def __init__( database: str = "agents", sessions_collection: str = "agent_sessions", messages_collection: str = "agent_messages", - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, ): """Initialize a new MongoDBSession. @@ -128,7 +132,11 @@ def __init__( is used (no item limit). """ self.session_id = session_id - self.session_settings = session_settings or SessionSettings() + self.session_settings = ( + coerce_session_settings(session_settings) + if session_settings is not None + else SessionSettings() + ) self._client = client self._owns_client = False @@ -153,7 +161,7 @@ def from_uri( uri: str, database: str = "agents", client_kwargs: dict[str, Any] | None = None, - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, **kwargs: Any, ) -> MongoDBSession: """Create a session from a MongoDB URI string. diff --git a/src/agents/extensions/memory/redis_session.py b/src/agents/extensions/memory/redis_session.py index 11e2dd838b..3ad261b28e 100644 --- a/src/agents/extensions/memory/redis_session.py +++ b/src/agents/extensions/memory/redis_session.py @@ -41,7 +41,11 @@ from ...items import TResponseInputItem from ...memory.session import SessionABC -from ...memory.session_settings import SessionSettings, resolve_session_limit +from ...memory.session_settings import ( + SessionSettings, + coerce_session_settings, + resolve_session_limit, +) class RedisSession(SessionABC): @@ -56,7 +60,7 @@ def __init__( redis_client: Redis, key_prefix: str = "agents:session", ttl: int | None = None, - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, ): """Initializes a new RedisSession. @@ -71,7 +75,11 @@ def __init__( default limit for retrieving items. If None, uses default SessionSettings(). """ self.session_id = session_id - self.session_settings = session_settings or SessionSettings() + self.session_settings = ( + coerce_session_settings(session_settings) + if session_settings is not None + else SessionSettings() + ) self._redis = redis_client self._key_prefix = key_prefix self._ttl = ttl @@ -90,7 +98,7 @@ def from_url( *, url: str, redis_kwargs: dict[str, Any] | None = None, - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, **kwargs: Any, ) -> RedisSession: """Create a session from a Redis URL string. diff --git a/src/agents/extensions/memory/sqlalchemy_session.py b/src/agents/extensions/memory/sqlalchemy_session.py index 89467ad2d2..3fc793d328 100644 --- a/src/agents/extensions/memory/sqlalchemy_session.py +++ b/src/agents/extensions/memory/sqlalchemy_session.py @@ -50,7 +50,11 @@ from ...items import TResponseInputItem from ...memory.session import SessionABC -from ...memory.session_settings import SessionSettings, resolve_session_limit +from ...memory.session_settings import ( + SessionSettings, + coerce_session_settings, + resolve_session_limit, +) class SQLAlchemySession(SessionABC): @@ -135,7 +139,7 @@ def __init__( create_tables: bool = False, sessions_table: str = "agent_sessions", messages_table: str = "agent_messages", - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, ensure_ascii: bool = True, ): """Initializes a new SQLAlchemySession. @@ -155,7 +159,11 @@ def __init__( session items to JSON. Defaults to True to preserve the historical storage format. """ self.session_id = session_id - self.session_settings = session_settings or SessionSettings() + self.session_settings = ( + coerce_session_settings(session_settings) + if session_settings is not None + else SessionSettings() + ) self._engine = engine self._ensure_ascii = ensure_ascii self._configure_sqlite_engine(engine) @@ -225,7 +233,7 @@ def from_url( *, url: str, engine_kwargs: dict[str, Any] | None = None, - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, **kwargs: Any, ) -> SQLAlchemySession: """Create a session from a database URL string. diff --git a/src/agents/memory/openai_conversations_session.py b/src/agents/memory/openai_conversations_session.py index 0220eccbb1..9114a7dea0 100644 --- a/src/agents/memory/openai_conversations_session.py +++ b/src/agents/memory/openai_conversations_session.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from typing import Any from openai import AsyncOpenAI @@ -8,7 +9,7 @@ from ..items import TResponseInputItem from .session import SessionABC -from .session_settings import SessionSettings, resolve_session_limit +from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit async def start_openai_conversations_session(openai_client: AsyncOpenAI | None = None) -> str: @@ -30,11 +31,15 @@ def __init__( *, conversation_id: str | None = None, openai_client: AsyncOpenAI | None = None, - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, ): self._session_id: str | None = conversation_id self._session_id_lock = asyncio.Lock() - self.session_settings = session_settings or SessionSettings() + self.session_settings = ( + coerce_session_settings(session_settings) + if session_settings is not None + else SessionSettings() + ) _openai_client = openai_client if _openai_client is None: _openai_client = get_default_openai_client() or AsyncOpenAI() diff --git a/src/agents/memory/session_settings.py b/src/agents/memory/session_settings.py index 03dfbd8d23..eb42f617f2 100644 --- a/src/agents/memory/session_settings.py +++ b/src/agents/memory/session_settings.py @@ -8,16 +8,22 @@ from pydantic.dataclasses import dataclass +from .._config_coercion import ( + _dataclass_input_values, + _declared_dataclass_type, + coerce_dataclass_config, +) + def resolve_session_limit( explicit_limit: int | None, - settings: SessionSettings | None, + settings: SessionSettings | dict[str, Any] | None, ) -> int | None: """Safely resolve the effective limit for session operations.""" if explicit_limit is not None: return explicit_limit if settings is not None: - return settings.limit + return coerce_session_settings(settings).limit return None @@ -32,16 +38,23 @@ class SessionSettings: limit: int | None = None """Maximum number of items to retrieve. If None, retrieves all items.""" - def resolve(self, override: SessionSettings | None) -> SessionSettings: + def resolve(self, override: SessionSettings | dict[str, Any] | None) -> SessionSettings: """Produce a new SessionSettings by overlaying any non-None values from the override on top of this instance.""" if override is None: return self + override_fields = ( + set(_dataclass_input_values(override, type(self))) + if isinstance(override, dict) + else None + ) + override = _coerce_session_settings(override, settings_type=type(self)) changes = { field.name: getattr(override, field.name) for field in fields(self) - if getattr(override, field.name) is not None + if (override_fields is None or field.name in override_fields) + and getattr(override, field.name) is not None } return replace(self, **changes) @@ -49,3 +62,25 @@ def resolve(self, override: SessionSettings | None) -> SessionSettings: def to_dict(self) -> dict[str, Any]: """Convert settings to a dictionary.""" return dataclasses.asdict(self) + + +def coerce_session_settings( + value: SessionSettings | dict[str, Any], +) -> SessionSettings: + """Normalize session settings while preserving existing typed instances.""" + return _coerce_session_settings(value, settings_type=SessionSettings) + + +def _coerce_session_settings( + value: SessionSettings | dict[str, Any], + *, + settings_type: type[SessionSettings], +) -> SessionSettings: + return coerce_dataclass_config(value, settings_type, parameter_name="session") + + +def _declared_session_settings_type( + owner_type: type[Any], + field_name: str, +) -> type[SessionSettings]: + return _declared_dataclass_type(owner_type, field_name, SessionSettings) diff --git a/src/agents/memory/sqlite_session.py b/src/agents/memory/sqlite_session.py index 3a69f9883a..b57f3ebf5a 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -7,11 +7,11 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar from ..items import TResponseInputItem from .session import SessionABC -from .session_settings import SessionSettings, resolve_session_limit +from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit class SQLiteSession(SessionABC): @@ -33,7 +33,7 @@ def __init__( db_path: str | Path = ":memory:", sessions_table: str = "agent_sessions", messages_table: str = "agent_messages", - session_settings: SessionSettings | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, ): """Initialize the SQLite session. @@ -47,7 +47,11 @@ def __init__( retrieving items. If None, uses default SessionSettings(). """ self.session_id = session_id - self.session_settings = session_settings or SessionSettings() + self.session_settings = ( + coerce_session_settings(session_settings) + if session_settings is not None + else SessionSettings() + ) self.db_path = db_path self.sessions_table = sessions_table self.messages_table = messages_table diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py index e35279b3c3..0d6c24b837 100644 --- a/src/agents/model_settings.py +++ b/src/agents/model_settings.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from dataclasses import fields, replace -from typing import Annotated, Any, Literal, TypeAlias, cast +from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, cast from openai import Omit as _Omit from openai._types import Body, Query @@ -13,6 +13,7 @@ from pydantic.dataclasses import dataclass from pydantic_core import core_schema +from ._config_coercion import _declared_dataclass_type, coerce_dataclass_config from .retry import ( ModelRetryBackoffInput, ModelRetryBackoffSettings, @@ -199,20 +200,58 @@ class ModelSettings: control which prompt prefixes are eligible for caching. """ - def resolve(self, override: ModelSettings | None) -> ModelSettings: + if TYPE_CHECKING: + + def __init__( + self, + temperature: float | None = None, + top_p: float | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + tool_choice: ToolChoice | dict[str, Any] = None, + parallel_tool_calls: bool | None = None, + truncation: Literal["auto", "disabled"] | None = None, + max_tokens: int | None = None, + reasoning: Reasoning | dict[str, Any] | None = None, + verbosity: Literal["low", "medium", "high"] | None = None, + metadata: dict[str, str] | None = None, + store: bool | None = None, + prompt_cache_retention: Literal["in_memory", "24h"] | None = None, + include_usage: bool | None = None, + response_include: list[ResponseIncludable | str] | None = None, + top_logprobs: int | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + extra_headers: Headers | None = None, + extra_args: dict[str, Any] | None = None, + retry: ModelRetrySettings | dict[str, Any] | None = None, + context_management: list[ContextManagement] | None = None, + prompt_cache_options: PromptCacheOptions | None = None, + ) -> None: ... + + def resolve(self, override: ModelSettings | dict[str, Any] | None) -> ModelSettings: """Produce a new ModelSettings by overlaying any non-None values from the override on top of this instance.""" if override is None: return self + override_fields = set(override) if isinstance(override, dict) else None + override = _coerce_model_settings( + override, + parameter_name="ModelSettings override", + model_settings_type=type(self), + ) changes = { field.name: getattr(override, field.name) for field in fields(self) - if getattr(override, field.name) is not None + if (override_fields is None or field.name in override_fields) + and getattr(override, field.name, None) is not None } # Handle extra_args merging specially - merge dictionaries instead of replacing. - if self.extra_args is not None or override.extra_args is not None: + if (override_fields is None or "extra_args" in override_fields) and ( + self.extra_args is not None or override.extra_args is not None + ): merged_args = {} if self.extra_args: merged_args.update(self.extra_args) @@ -220,7 +259,9 @@ def resolve(self, override: ModelSettings | None) -> ModelSettings: merged_args.update(override.extra_args) changes["extra_args"] = merged_args if merged_args else None - if self.retry is not None or override.retry is not None: + if (override_fields is None or "retry" in override_fields) and ( + self.retry is not None or override.retry is not None + ): changes["retry"] = _merge_retry_settings(self.retry, override.retry) return replace(self, **changes) @@ -234,6 +275,83 @@ def to_traceable_dict(self) -> dict[str, Any]: return {key: payload[key] for key in _TRACEABLE_MODEL_SETTING_FIELDS if key in payload} +def _coerce_model_settings( + value: ModelSettings | dict[str, Any], + *, + parameter_name: str, + model_settings_type: type[ModelSettings] = ModelSettings, + inherited_model_settings: ModelSettings | None = None, +) -> ModelSettings: + """Normalize SDK-owned model settings without changing existing typed instances.""" + del inherited_model_settings + if isinstance(value, ModelSettings): + return value + if not isinstance(value, dict): + raise TypeError( + f"{parameter_name} must be a ModelSettings instance or a dict, " + f"got {type(value).__name__}" + ) + + field_names = {model_field.name for model_field in fields(model_settings_type)} + unknown_fields = sorted(str(name) for name in value if name not in field_names) + if unknown_fields: + raise TypeError(f"Unknown model settings: {', '.join(unknown_fields)}") + + _validate_first_party_model_settings(value) + return coerce_dataclass_config(value, model_settings_type, parameter_name=parameter_name) + + +def _declared_model_settings_type( + owner_type: type[Any], + field_name: str, +) -> type[ModelSettings]: + return _declared_dataclass_type(owner_type, field_name, ModelSettings) + + +def _validate_first_party_model_settings(value: dict[str, Any]) -> None: + """Reject SDK-owned structured-setting typos while preserving OpenAI model extras.""" + + def validate_fields(payload: object, names: set[str], path: str) -> None: + if not isinstance(payload, Mapping): + return + unknown_fields = sorted(str(name) for name in payload if name not in names) + if unknown_fields: + raise TypeError(f"Unknown model settings in {path}: {', '.join(unknown_fields)}") + + validate_fields( + value.get("tool_choice"), + {model_field.name for model_field in fields(MCPToolChoice)}, + "tool_choice", + ) + retry = value.get("retry") + validate_fields( + retry, + {model_field.name for model_field in fields(ModelRetrySettings)}, + "retry", + ) + if isinstance(retry, Mapping): + validate_fields( + retry.get("backoff"), + {model_field.name for model_field in fields(ModelRetryBackoffSettings)}, + "retry.backoff", + ) + + context_management = value.get("context_management") + if isinstance(context_management, list | tuple): + for index, item in enumerate(context_management): + validate_fields( + item, + set(ContextManagement.__annotations__), + f"context_management[{index}]", + ) + + validate_fields( + value.get("prompt_cache_options"), + set(PromptCacheOptions.__annotations__), + "prompt_cache_options", + ) + + def _merge_retry_settings( inherited: ModelRetrySettings | None, override: ModelRetrySettings | None, diff --git a/src/agents/models/multi_provider.py b/src/agents/models/multi_provider.py index 4737bb8c0c..ccb644edc2 100644 --- a/src/agents/models/multi_provider.py +++ b/src/agents/models/multi_provider.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal, cast +from typing import Any, Literal, cast from openai import AsyncOpenAI @@ -87,7 +87,7 @@ def __init__( openai_websocket_base_url: str | None = None, openai_prefix_mode: MultiProviderOpenAIPrefixMode = "alias", unknown_prefix_mode: MultiProviderUnknownPrefixMode = "error", - openai_agent_registration: OpenAIAgentRegistrationConfig | None = None, + openai_agent_registration: OpenAIAgentRegistrationConfig | dict[str, Any] | None = None, openai_responses_websocket_options: OpenAIResponsesWebSocketOptions | None = None, openai_buffer_streamed_tool_calls: bool = False, ) -> None: diff --git a/src/agents/models/openai_agent_registration.py b/src/agents/models/openai_agent_registration.py index 12e62d8ba0..e0578739bc 100644 --- a/src/agents/models/openai_agent_registration.py +++ b/src/agents/models/openai_agent_registration.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from typing import Any +from .._config_coercion import coerce_dataclass_config + _ENV_HARNESS_ID = "OPENAI_AGENT_HARNESS_ID" OPENAI_HARNESS_ID_TRACE_METADATA_KEY = "agent_harness_id" @@ -22,10 +24,12 @@ class ResolvedOpenAIAgentRegistrationConfig: def set_default_openai_agent_registration_config( - config: OpenAIAgentRegistrationConfig | None, + config: OpenAIAgentRegistrationConfig | dict[str, Any] | None, ) -> None: global _default_agent_registration - _default_agent_registration = config + _default_agent_registration = ( + _coerce_openai_agent_registration_config(config) if config is not None else None + ) def get_default_openai_agent_registration_config() -> OpenAIAgentRegistrationConfig | None: @@ -33,8 +37,10 @@ def get_default_openai_agent_registration_config() -> OpenAIAgentRegistrationCon def resolve_openai_agent_registration_config( - config: OpenAIAgentRegistrationConfig | None, + config: OpenAIAgentRegistrationConfig | dict[str, Any] | None, ) -> ResolvedOpenAIAgentRegistrationConfig | None: + if config is not None: + config = _coerce_openai_agent_registration_config(config) default = get_default_openai_agent_registration_config() harness_id = _resolve_str( explicit=config.harness_id if config else None, @@ -46,6 +52,16 @@ def resolve_openai_agent_registration_config( return ResolvedOpenAIAgentRegistrationConfig(harness_id=harness_id) +def _coerce_openai_agent_registration_config( + config: OpenAIAgentRegistrationConfig | dict[str, Any], +) -> OpenAIAgentRegistrationConfig: + return coerce_dataclass_config( + config, + OpenAIAgentRegistrationConfig, + parameter_name="OpenAI agent registration", + ) + + def resolve_openai_harness_id_for_model_provider(model_provider: Any) -> str | None: """Return the configured harness ID for OpenAI-backed model providers.""" harness_id = _harness_id_from_model_provider(model_provider) diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index dd4b888cb2..cc88d14ef1 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -3,6 +3,7 @@ import asyncio import os import weakref +from typing import Any import httpx from openai import AsyncOpenAI, DefaultAsyncHttpxClient @@ -54,7 +55,7 @@ def __init__( use_responses: bool | None = None, use_responses_websocket: bool | None = None, strict_feature_validation: bool = False, - agent_registration: OpenAIAgentRegistrationConfig | None = None, + agent_registration: OpenAIAgentRegistrationConfig | dict[str, Any] | None = None, responses_websocket_options: OpenAIResponsesWebSocketOptions | None = None, buffer_streamed_tool_calls: bool = False, ) -> None: diff --git a/src/agents/responses_websocket_session.py b/src/agents/responses_websocket_session.py index 3d0f18137d..b1ac69d938 100644 --- a/src/agents/responses_websocket_session.py +++ b/src/agents/responses_websocket_session.py @@ -3,7 +3,7 @@ from collections.abc import AsyncIterator, Mapping from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from .agent import Agent from .items import TResponseInputItem @@ -16,7 +16,7 @@ from .models.openai_responses import OpenAIResponsesWebSocketOptions from .result import RunResult, RunResultStreaming from .run import Runner -from .run_config import RunConfig +from .run_config import RunConfig, _coerce_run_config from .run_state import RunState @@ -27,7 +27,16 @@ class ResponsesWebSocketSession: provider: OpenAIProvider run_config: RunConfig + if TYPE_CHECKING: + + def __init__( + self, + provider: OpenAIProvider, + run_config: RunConfig | dict[str, Any], + ) -> None: ... + def __post_init__(self) -> None: + object.__setattr__(self, "run_config", _coerce_run_config(self.run_config)) self._validate_provider_alignment() def _validate_provider_alignment(self) -> MultiProvider: diff --git a/src/agents/run.py b/src/agents/run.py index 3928fc52b5..04fe9c09a0 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -3,7 +3,7 @@ import asyncio import contextlib import warnings -from typing import cast +from typing import Any, cast from typing_extensions import Unpack @@ -42,6 +42,7 @@ ToolErrorFormatterArgs, ToolExecutionConfig, ToolNotFoundBehavior, + _coerce_run_config, ) from .run_context import RunContextWrapper, TContext from .run_error_handlers import RunErrorHandlers @@ -208,7 +209,7 @@ async def run( context: TContext | None = None, max_turns: int | None = DEFAULT_MAX_TURNS, hooks: RunHooks[TContext] | None = None, - run_config: RunConfig | None = None, + run_config: RunConfig | dict[str, Any] | None = None, error_handlers: RunErrorHandlers[TContext] | None = None, previous_response_id: str | None = None, auto_previous_response_id: bool = False, @@ -292,7 +293,7 @@ def run_sync( context: TContext | None = None, max_turns: int | None = DEFAULT_MAX_TURNS, hooks: RunHooks[TContext] | None = None, - run_config: RunConfig | None = None, + run_config: RunConfig | dict[str, Any] | None = None, error_handlers: RunErrorHandlers[TContext] | None = None, previous_response_id: str | None = None, auto_previous_response_id: bool = False, @@ -373,7 +374,7 @@ def run_streamed( context: TContext | None = None, max_turns: int | None = DEFAULT_MAX_TURNS, hooks: RunHooks[TContext] | None = None, - run_config: RunConfig | None = None, + run_config: RunConfig | dict[str, Any] | None = None, previous_response_id: str | None = None, auto_previous_response_id: bool = False, conversation_id: str | None = None, @@ -467,8 +468,7 @@ async def run( conversation_id = kwargs.get("conversation_id") session = kwargs.get("session") - if run_config is None: - run_config = RunConfig() + run_config = RunConfig() if run_config is None else _coerce_run_config(run_config) is_resumed_state = isinstance(input, RunState) run_state: RunState[TContext] | None = None @@ -1728,8 +1728,7 @@ def run_streamed( conversation_id = kwargs.get("conversation_id") session = kwargs.get("session") - if run_config is None: - run_config = RunConfig() + run_config = RunConfig() if run_config is None else _coerce_run_config(run_config) # Handle RunState input is_resumed_state = isinstance(input, RunState) diff --git a/src/agents/run_config.py b/src/agents/run_config.py index 08ee4cff9e..393e6dd039 100644 --- a/src/agents/run_config.py +++ b/src/agents/run_config.py @@ -5,14 +5,24 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic, Literal +from pydantic import TypeAdapter from typing_extensions import NotRequired, TypedDict +from ._config_coercion import ( + _declared_dataclass_type, + coerce_dataclass_config, + coerce_pydantic_config, +) from .guardrail import InputGuardrail, OutputGuardrail from .handoffs import HandoffHistoryMapper, HandoffInputFilter from .items import TResponseInputItem from .lifecycle import RunHooks from .memory import Session, SessionInputCallback, SessionSettings -from .model_settings import ModelSettings +from .memory.session_settings import ( + _coerce_session_settings, + _declared_session_settings_type, +) +from .model_settings import ModelSettings, _coerce_model_settings, _declared_model_settings_type from .models.interface import Model, ModelProvider from .models.multi_provider import MultiProvider from .run_context import TContext @@ -207,6 +217,86 @@ class SandboxRunConfig: Use `SandboxArchiveLimits()` to enable SDK defaults. """ + if TYPE_CHECKING: + + def __init__( + self, + client: BaseSandboxClient[Any] | None = None, + options: Any | None = None, + session: BaseSandboxSession | None = None, + session_state: SandboxSessionState | None = None, + manifest: Manifest | dict[str, Any] | None = None, + snapshot: SnapshotSpec | SnapshotBase | dict[str, Any] | None = None, + concurrency_limits: SandboxConcurrencyLimits | dict[str, Any] = ..., + archive_limits: SandboxArchiveLimits | dict[str, Any] | None = None, + ) -> None: ... + + def __post_init__(self) -> None: + if isinstance(self.manifest, dict): + from .sandbox.manifest import _coerce_manifest + + self.manifest = _coerce_manifest(self.manifest, parameter_name="sandbox.manifest") + if isinstance(self.snapshot, dict): + from .sandbox.snapshot import SnapshotBase, SnapshotSpecUnion + + if "id" in self.snapshot: + self.snapshot = SnapshotBase.parse(self.snapshot) + else: + self.snapshot = TypeAdapter(SnapshotSpecUnion).validate_python(self.snapshot) + if isinstance(self.options, dict) and self.client is not None: + from .sandbox.session.sandbox_client import BaseSandboxClientOptions + + options_type = BaseSandboxClientOptions._options_class_for_type(self.client.backend_id) + if options_type is not None: + options = self.options + explicit_type = options.get("type") + if explicit_type is not None and explicit_type != self.client.backend_id: + raise ValueError( + f"sandbox.options type `{explicit_type}` does not match selected " + f"sandbox client backend `{self.client.backend_id}`" + ) + if "type" not in options: + options = { + **options, + "type": options_type.model_fields["type"].default, + } + self.options = coerce_pydantic_config( + options, + options_type, + parameter_name="sandbox.options", + ) + elif self.client.backend_id == "blaxel": + from .extensions.sandbox.blaxel.sandbox import ( + BlaxelSandboxClient, + BlaxelSandboxClientOptions, + ) + + if isinstance(self.client, BlaxelSandboxClient): + self.options = coerce_dataclass_config( + self.options, + BlaxelSandboxClientOptions, + parameter_name="sandbox.options", + ) + self.concurrency_limits = coerce_dataclass_config( + self.concurrency_limits, + _declared_dataclass_type( + type(self), + "concurrency_limits", + SandboxConcurrencyLimits, + ), + parameter_name="sandbox.concurrency_limits", + ) + if self.archive_limits is not None: + self.archive_limits = coerce_dataclass_config( + self.archive_limits, + _declared_dataclass_type( + type(self), + "archive_limits", + SandboxArchiveLimits, + ), + parameter_name="sandbox.archive_limits", + ) + @dataclass class RunConfig: @@ -222,7 +312,7 @@ class RunConfig: model_settings: ModelSettings | None = None """Configure global model settings. Any non-null values will override the agent-specific model - settings. + settings. Accepts a ``ModelSettings`` instance or a dictionary containing its fields. """ handoff_input_filter: HandoffInputFilter | None = None @@ -339,6 +429,64 @@ class RunConfig: the run continue. """ + if TYPE_CHECKING: + + def __init__( + self, + model: str | Model | None = None, + model_provider: ModelProvider = ..., + model_settings: ModelSettings | dict[str, Any] | None = None, + handoff_input_filter: HandoffInputFilter | None = None, + nest_handoff_history: bool = False, + handoff_history_mapper: HandoffHistoryMapper | None = None, + input_guardrails: list[InputGuardrail[Any]] | None = None, + output_guardrails: list[OutputGuardrail[Any]] | None = None, + tracing_disabled: bool = False, + tracing: TracingConfig | None = None, + trace_include_sensitive_data: bool = ..., + workflow_name: str = "Agent workflow", + trace_id: str | None = None, + group_id: str | None = None, + trace_metadata: dict[str, Any] | None = None, + session_input_callback: SessionInputCallback | None = None, + call_model_input_filter: CallModelInputFilter | None = None, + tool_error_formatter: ToolErrorFormatter | None = None, + session_settings: SessionSettings | dict[str, Any] | None = None, + reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, + sandbox: SandboxRunConfig | dict[str, Any] | None = None, + tool_execution: ToolExecutionConfig | dict[str, Any] | None = None, + tool_not_found_behavior: ToolNotFoundBehavior = "raise_error", + ) -> None: ... + + def __post_init__(self) -> None: + if self.model_settings is not None: + self.model_settings = _coerce_model_settings( + self.model_settings, + parameter_name="RunConfig model_settings", + model_settings_type=_declared_model_settings_type(type(self), "model_settings"), + ) + if self.session_settings is not None: + self.session_settings = _coerce_session_settings( + self.session_settings, + settings_type=_declared_session_settings_type(type(self), "session_settings"), + ) + if self.sandbox is not None: + self.sandbox = coerce_dataclass_config( + self.sandbox, + _declared_dataclass_type(type(self), "sandbox", SandboxRunConfig), + parameter_name="run_config.sandbox", + ) + if self.tool_execution is not None: + self.tool_execution = coerce_dataclass_config( + self.tool_execution, + _declared_dataclass_type( + type(self), + "tool_execution", + ToolExecutionConfig, + ), + parameter_name="run_config.tool_execution", + ) + class RunOptions(TypedDict, Generic[TContext]): """Arguments for ``AgentRunner`` methods.""" @@ -352,7 +500,7 @@ class RunOptions(TypedDict, Generic[TContext]): hooks: NotRequired[RunHooks[TContext] | None] """Lifecycle hooks for the run.""" - run_config: NotRequired[RunConfig | None] + run_config: NotRequired[RunConfig | dict[str, Any] | None] """Run configuration.""" previous_response_id: NotRequired[str | None] @@ -371,6 +519,11 @@ class RunOptions(TypedDict, Generic[TContext]): """Error handlers keyed by error kind.""" +def _coerce_run_config(value: RunConfig | dict[str, Any]) -> RunConfig: + """Normalize run configuration dictionaries at public runner boundaries.""" + return coerce_dataclass_config(value, RunConfig, parameter_name="run_config") + + __all__ = [ "DEFAULT_MAX_TURNS", "CallModelData", diff --git a/src/agents/sandbox/config.py b/src/agents/sandbox/config.py index 206ed459f1..1e9dc4acd2 100644 --- a/src/agents/sandbox/config.py +++ b/src/agents/sandbox/config.py @@ -1,11 +1,15 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Final +from typing import TYPE_CHECKING, Any, Final from openai.types.shared import Reasoning -from ..model_settings import ModelSettings +from ..model_settings import ( + ModelSettings, + _coerce_model_settings, + _declared_model_settings_type, +) from ..models.interface import Model DEFAULT_PYTHON_SANDBOX_IMAGE: Final = "python:3.14-slim" @@ -47,7 +51,10 @@ class MemoryGenerateConfig: phase_one_model_settings: ModelSettings | None = field( default_factory=_default_memory_phase_one_model_settings ) - """Model settings used for phase-1 single-rollout extraction.""" + """Model settings used for phase-1 single-rollout extraction. + + Accepts a ``ModelSettings`` instance or a dictionary containing its fields. + """ phase_two_model: str | Model = "gpt-5.5" """Model used for phase-2 memory consolidation.""" @@ -55,7 +62,10 @@ class MemoryGenerateConfig: phase_two_model_settings: ModelSettings | None = field( default_factory=_default_memory_phase_two_model_settings ) - """Model settings used for phase-2 memory consolidation.""" + """Model settings used for phase-2 memory consolidation. + + Accepts a ``ModelSettings`` instance or a dictionary containing its fields. + """ extra_prompt: str | None = None """Optional developer-specific guidance appended to memory extraction and consolidation @@ -70,7 +80,36 @@ class MemoryGenerateConfig: evidence you actually want it to summarize. """ + if TYPE_CHECKING: + + def __init__( + self, + max_raw_memories_for_consolidation: int = 256, + phase_one_model: str | Model = "gpt-5.4-mini", + phase_one_model_settings: ModelSettings | dict[str, Any] | None = ..., + phase_two_model: str | Model = "gpt-5.5", + phase_two_model_settings: ModelSettings | dict[str, Any] | None = ..., + extra_prompt: str | None = None, + ) -> None: ... + def __post_init__(self) -> None: + if self.phase_one_model_settings is not None: + self.phase_one_model_settings = _coerce_model_settings( + self.phase_one_model_settings, + parameter_name="MemoryGenerateConfig.phase_one_model_settings", + model_settings_type=_declared_model_settings_type( + type(self), "phase_one_model_settings" + ), + ) + if self.phase_two_model_settings is not None: + self.phase_two_model_settings = _coerce_model_settings( + self.phase_two_model_settings, + parameter_name="MemoryGenerateConfig.phase_two_model_settings", + model_settings_type=_declared_model_settings_type( + type(self), "phase_two_model_settings" + ), + ) + if self.max_raw_memories_for_consolidation <= 0: raise ValueError( "MemoryGenerateConfig.max_raw_memories_for_consolidation must be greater than 0." diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index d4cc014870..9421694ecb 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -2,11 +2,12 @@ import asyncio from collections.abc import Iterator, Mapping from pathlib import Path, PurePath, PurePosixPath -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field, field_serializer, field_validator from typing_extensions import assert_never +from .._config_coercion import coerce_pydantic_config from .entries import BaseEntry, Dir, Mount, resolve_workspace_path from .errors import InvalidManifestPathError from .manifest_render import render_manifest_description @@ -256,3 +257,15 @@ def describe(self, depth: int | None = 1) -> str: coerce_rel_path=self._coerce_rel_path, depth=depth, ) + + +def _coerce_manifest(value: Manifest | dict[str, Any], *, parameter_name: str) -> Manifest: + """Normalize manifest dictionaries without granting untrusted host filesystem access.""" + if isinstance(value, dict) and "extra_path_grants" in value: + extra_path_grants = value["extra_path_grants"] + if not isinstance(extra_path_grants, list | tuple) or extra_path_grants: + raise TypeError( + f"{parameter_name}.extra_path_grants must be configured on a trusted " + "Manifest instance, not in a dictionary" + ) + return coerce_pydantic_config(value, Manifest, parameter_name=parameter_name) diff --git a/src/agents/sandbox/sandbox_agent.py b/src/agents/sandbox/sandbox_agent.py index 6021415428..82ccbba1f9 100644 --- a/src/agents/sandbox/sandbox_agent.py +++ b/src/agents/sandbox/sandbox_agent.py @@ -2,14 +2,29 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal +from .._config_coercion import coerce_pydantic_config from ..agent import Agent from ..run_context import RunContextWrapper, TContext from .capabilities import Capability from .capabilities.capabilities import Capabilities -from .manifest import Manifest +from .manifest import Manifest, _coerce_manifest from .types import User +if TYPE_CHECKING: + from ..agent import MCPConfig, StopAtTools, ToolsToFinalOutputFunction + from ..agent_output import AgentOutputSchemaBase + from ..guardrail import InputGuardrail, OutputGuardrail + from ..handoffs import Handoff + from ..lifecycle import AgentHooks + from ..mcp import MCPServer + from ..model_settings import ModelSettings + from ..models.interface import Model + from ..prompts import DynamicPromptFunction, Prompt + from ..tool import Tool + from ..util._types import MaybeAwaitable + @dataclass class SandboxAgent(Agent[TContext]): @@ -39,8 +54,58 @@ class SandboxAgent(Agent[TContext]): _sandbox_concurrency_guard: object | None = field(default=None, init=False, repr=False) + if TYPE_CHECKING: + + def __init__( + self, + name: str, + handoff_description: str | None = None, + tools: list[Tool] = ..., + mcp_servers: list[MCPServer] = ..., + mcp_config: MCPConfig = ..., + instructions: ( + str + | Callable[ + [RunContextWrapper[TContext], Agent[TContext]], + MaybeAwaitable[str], + ] + | None + ) = None, + prompt: Prompt | DynamicPromptFunction | None = None, + handoffs: list[Agent[Any] | Handoff[TContext, Any]] = ..., + model: str | Model | None = None, + model_settings: ModelSettings | dict[str, Any] = ..., + input_guardrails: list[InputGuardrail[TContext]] = ..., + output_guardrails: list[OutputGuardrail[TContext]] = ..., + output_type: type[Any] | AgentOutputSchemaBase | None = None, + hooks: AgentHooks[TContext] | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] + | StopAtTools + | ToolsToFinalOutputFunction + ) = "run_llm_again", + reset_tool_choice: bool = True, + default_manifest: Manifest | dict[str, Any] | None = None, + base_instructions: ( + str + | Callable[ + [RunContextWrapper[TContext], Agent[TContext]], + Awaitable[str | None] | str | None, + ] + | None + ) = None, + capabilities: Sequence[Capability] = ..., + run_as: User | dict[str, Any] | str | None = None, + ) -> None: ... + def __post_init__(self) -> None: super().__post_init__() + if isinstance(self.default_manifest, dict): + self.default_manifest = _coerce_manifest( + self.default_manifest, parameter_name="sandbox.default_manifest" + ) + if isinstance(self.run_as, dict): + self.run_as = coerce_pydantic_config(self.run_as, User, parameter_name="sandbox.run_as") if ( self.base_instructions is not None and not isinstance(self.base_instructions, str) diff --git a/src/agents/tool.py b/src/agents/tool.py index def9ea2e66..af9a6c3c5c 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -43,6 +43,7 @@ from typing_extensions import NotRequired, ParamSpec, TypedDict from . import _debug +from ._config_coercion import coerce_pydantic_config from ._tool_identity import ( get_explicit_function_tool_namespace, tool_qualified_name, @@ -735,6 +736,22 @@ class WebSearchTool: indexed-only behavior where supported. """ + if TYPE_CHECKING: + + def __init__( + self, + user_location: UserLocation | None = None, + filters: WebSearchToolFilters | dict[str, Any] | None = None, + search_context_size: Literal["low", "medium", "high"] = "medium", + external_web_access: bool | None = None, + ) -> None: ... + + def __post_init__(self) -> None: + if isinstance(self.filters, dict): + self.filters = coerce_pydantic_config( + self.filters, WebSearchToolFilters, parameter_name="web search filters" + ) + @property def name(self): return "web_search" diff --git a/src/agents/tool_context.py b/src/agents/tool_context.py index 75947630cf..b9c753c79a 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -68,7 +68,7 @@ def __init__( *, tool_namespace: str | None = None, agent: AgentBase[Any] | None = None, - run_config: RunConfig | None = None, + run_config: RunConfig | dict[str, Any] | None = None, turn_input: list[TResponseInputItem] | None = None, _approvals: dict[str, _ApprovalRecord] | None = None, tool_input: Any | None = None, @@ -102,7 +102,12 @@ def __init__( else get_tool_call_namespace(tool_call) ) self.agent = agent - self.run_config = run_config + if run_config is not None: + from .run_config import _coerce_run_config + + self.run_config = _coerce_run_config(run_config) + else: + self.run_config = None # Internal adapter hook used to attach SDK-only custom data to the emitted output item. self._custom_data: dict[str, Any] | None = None @@ -122,7 +127,7 @@ def from_agent_context( tool_name: str | None = None, tool_arguments: str | None = None, tool_namespace: str | None = None, - run_config: RunConfig | None = None, + run_config: RunConfig | dict[str, Any] | None = None, ) -> ToolContext: """ Create a ToolContext from a RunContextWrapper. diff --git a/src/agents/voice/models/openai_model_provider.py b/src/agents/voice/models/openai_model_provider.py index b992f9b4ad..2736afbf57 100644 --- a/src/agents/voice/models/openai_model_provider.py +++ b/src/agents/voice/models/openai_model_provider.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + import httpx from openai import AsyncOpenAI, DefaultAsyncHttpxClient @@ -41,7 +43,7 @@ def __init__( openai_client: AsyncOpenAI | None = None, organization: str | None = None, project: str | None = None, - agent_registration: OpenAIAgentRegistrationConfig | None = None, + agent_registration: OpenAIAgentRegistrationConfig | dict[str, Any] | None = None, ) -> None: """Create a new OpenAI voice model provider. diff --git a/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py index 745f0faafb..21220c9921 100644 --- a/src/agents/voice/pipeline.py +++ b/src/agents/voice/pipeline.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +from typing import Any +from .._config_coercion import coerce_dataclass_config from ..exceptions import UserError from ..logger import logger from ..tracing import TraceCtxManager @@ -25,7 +27,7 @@ def __init__( workflow: VoiceWorkflowBase, stt_model: STTModel | str | None = None, tts_model: TTSModel | str | None = None, - config: VoicePipelineConfig | None = None, + config: VoicePipelineConfig | dict[str, Any] | None = None, ): """Create a new voice pipeline. @@ -43,7 +45,11 @@ def __init__( self.tts_model = tts_model if isinstance(tts_model, TTSModel) else None self._stt_model_name = stt_model if isinstance(stt_model, str) else None self._tts_model_name = tts_model if isinstance(tts_model, str) else None - self.config = config or VoicePipelineConfig() + self.config = ( + coerce_dataclass_config(config, VoicePipelineConfig, parameter_name="voice.pipeline") + if config is not None + else VoicePipelineConfig() + ) async def run(self, audio_input: AudioInput | StreamedAudioInput) -> StreamedAudioResult: """Run the voice pipeline. diff --git a/src/agents/voice/pipeline_config.py b/src/agents/voice/pipeline_config.py index eed2ab6940..35c55d093a 100644 --- a/src/agents/voice/pipeline_config.py +++ b/src/agents/voice/pipeline_config.py @@ -1,8 +1,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any +from .._config_coercion import _declared_dataclass_type, coerce_dataclass_config from ..tracing import TracingConfig from ..tracing.util import gen_group_id from .model import STTModelSettings, TTSModelSettings, VoiceModelProvider @@ -48,3 +49,31 @@ class VoicePipelineConfig: tts_settings: TTSModelSettings = field(default_factory=TTSModelSettings) """The settings to use for the TTS model.""" + + if TYPE_CHECKING: + + def __init__( + self, + model_provider: VoiceModelProvider = ..., + tracing_disabled: bool = False, + tracing: TracingConfig | None = None, + trace_include_sensitive_data: bool = True, + trace_include_sensitive_audio_data: bool = True, + workflow_name: str = "Voice Agent", + group_id: str = ..., + trace_metadata: dict[str, Any] | None = None, + stt_settings: STTModelSettings | dict[str, Any] = ..., + tts_settings: TTSModelSettings | dict[str, Any] = ..., + ) -> None: ... + + def __post_init__(self) -> None: + self.stt_settings = coerce_dataclass_config( + self.stt_settings, + _declared_dataclass_type(type(self), "stt_settings", STTModelSettings), + parameter_name="voice.stt", + ) + self.tts_settings = coerce_dataclass_config( + self.tts_settings, + _declared_dataclass_type(type(self), "tts_settings", TTSModelSettings), + parameter_name="voice.tts", + ) diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 28d8f3f6a9..2472a8bf12 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -1619,17 +1619,18 @@ async def test_session_settings_default(): session.close() -async def test_session_settings_constructor(): +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"]) +async def test_session_settings_constructor(use_dictionary: bool): """Test passing session_settings via constructor.""" from agents.memory import SessionSettings session = AdvancedSQLiteSession( session_id="constructor_settings_test", create_tables=True, - session_settings=SessionSettings(limit=5), + session_settings={"limit": 5} if use_dictionary else SessionSettings(limit=5), ) - assert session.session_settings is not None + assert isinstance(session.session_settings, SessionSettings) assert session.session_settings.limit == 5 session.close() diff --git a/tests/extensions/memory/test_async_sqlite_session.py b/tests/extensions/memory/test_async_sqlite_session.py index 7269951829..6ab3d9feb4 100644 --- a/tests/extensions/memory/test_async_sqlite_session.py +++ b/tests/extensions/memory/test_async_sqlite_session.py @@ -151,14 +151,15 @@ async def test_async_sqlite_session_session_settings_default(): await session.close() -async def test_async_sqlite_session_session_settings_constructor(): +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"]) +async def test_async_sqlite_session_session_settings_constructor(use_dictionary: bool): """Test passing session_settings via constructor.""" session = AsyncSQLiteSession( "async_constructor_settings", - session_settings=SessionSettings(limit=5), + session_settings={"limit": 5} if use_dictionary else SessionSettings(limit=5), ) - assert session.session_settings is not None + assert isinstance(session.session_settings, SessionSettings) assert session.session_settings.limit == 5 await session.close() diff --git a/tests/extensions/memory/test_dapr_session.py b/tests/extensions/memory/test_dapr_session.py index 9766f35d40..dd49173a19 100644 --- a/tests/extensions/memory/test_dapr_session.py +++ b/tests/extensions/memory/test_dapr_session.py @@ -894,7 +894,8 @@ async def test_session_settings_default(fake_dapr_client: FakeDaprClient): await session.close() -async def test_session_settings_constructor(fake_dapr_client: FakeDaprClient): +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"]) +async def test_session_settings_constructor(fake_dapr_client: FakeDaprClient, use_dictionary: bool): """Test passing session_settings via constructor.""" from agents.memory import SessionSettings @@ -902,11 +903,11 @@ async def test_session_settings_constructor(fake_dapr_client: FakeDaprClient): session_id="settings_test", state_store_name="statestore", dapr_client=fake_dapr_client, # type: ignore[arg-type] - session_settings=SessionSettings(limit=5), + session_settings={"limit": 5} if use_dictionary else SessionSettings(limit=5), ) try: - assert session.session_settings is not None + assert isinstance(session.session_settings, SessionSettings) assert session.session_settings.limit == 5 finally: await session.close() diff --git a/tests/extensions/memory/test_mongodb_session.py b/tests/extensions/memory/test_mongodb_session.py index cd7954e3ae..98cfc2654d 100644 --- a/tests/extensions/memory/test_mongodb_session.py +++ b/tests/extensions/memory/test_mongodb_session.py @@ -396,15 +396,17 @@ async def test_get_items_limit_exceeds_count(session: MongoDBSession) -> None: assert len(result) == 1 -async def test_session_settings_limit_used_as_default() -> None: +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"]) +async def test_session_settings_limit_used_as_default(use_dictionary: bool) -> None: """session_settings.limit is applied when no explicit limit is given.""" MongoDBSession._init_state.clear() s = MongoDBSession( "ls-test", client=FakeAsyncMongoClient(), # type: ignore[arg-type] database="agents_test", - session_settings=SessionSettings(limit=2), + session_settings={"limit": 2} if use_dictionary else SessionSettings(limit=2), ) + assert isinstance(s.session_settings, SessionSettings) await s.add_items([{"role": "user", "content": str(i)} for i in range(5)]) result = await s.get_items() diff --git a/tests/extensions/memory/test_redis_session.py b/tests/extensions/memory/test_redis_session.py index b5011cdd4d..0cc4c07d8b 100644 --- a/tests/extensions/memory/test_redis_session.py +++ b/tests/extensions/memory/test_redis_session.py @@ -840,7 +840,8 @@ async def test_session_settings_default(): await session.close() -async def test_session_settings_constructor(): +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"]) +async def test_session_settings_constructor(use_dictionary: bool): """Test passing session_settings via constructor.""" from agents.memory import SessionSettings @@ -849,15 +850,17 @@ async def test_session_settings_constructor(): session_id="settings_test", redis_client=fake_redis, key_prefix="test:", - session_settings=SessionSettings(limit=5), + session_settings={"limit": 5} if use_dictionary else SessionSettings(limit=5), ) else: session = RedisSession.from_url( - "settings_test", url=REDIS_URL, session_settings=SessionSettings(limit=5) + "settings_test", + url=REDIS_URL, + session_settings={"limit": 5} if use_dictionary else SessionSettings(limit=5), ) try: - assert session.session_settings is not None + assert isinstance(session.session_settings, SessionSettings) assert session.session_settings.limit == 5 finally: await session.close() diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index 091f88a482..c75d7d6141 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -836,7 +836,8 @@ async def test_session_settings_default(): assert session.session_settings.limit is None -async def test_session_settings_from_url(): +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"]) +async def test_session_settings_from_url(use_dictionary: bool): """Test passing session_settings via from_url.""" from agents.memory import SessionSettings @@ -844,10 +845,10 @@ async def test_session_settings_from_url(): "from_url_settings_test", url=DB_URL, create_tables=True, - session_settings=SessionSettings(limit=5), + session_settings={"limit": 5} if use_dictionary else SessionSettings(limit=5), ) - assert session.session_settings is not None + assert isinstance(session.session_settings, SessionSettings) assert session.session_settings.limit == 5 diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 2e77fb8f80..8f189bcd4d 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -14,6 +14,7 @@ import pytest from pydantic import ValidationError +from agents.run_config import SandboxRunConfig from agents.sandbox import Manifest, SandboxPathGrant from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.errors import ( @@ -766,6 +767,26 @@ async def test_create(self, monkeypatch: pytest.MonkeyPatch) -> None: session = await client.create(options=options) assert session is not None + @pytest.mark.asyncio + async def test_create_with_dictionary_run_config_options( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + + monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance) + + client = mod.BlaxelSandboxClient(token="test-token") + config = SandboxRunConfig( + client=client, + options={"name": "dict-options", "timeouts": {"exec_timeout_s": 120}}, + ) + + assert isinstance(config.options, mod.BlaxelSandboxClientOptions) + session = await client.create(options=config.options) + + assert isinstance(session.state, mod.BlaxelSandboxSessionState) + assert session.state.timeouts.exec_timeout_s == 120 + @pytest.mark.asyncio async def test_create_with_image(self, monkeypatch: pytest.MonkeyPatch) -> None: from agents.extensions.sandbox.blaxel import sandbox as mod diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index b2b62950fe..2e241b88b8 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -553,3 +553,14 @@ def test_session_settings_constructor(self, mock_openai_client): assert session.session_settings is not None assert session.session_settings.limit == 5 + + def test_session_settings_constructor_normalizes_dictionary(self, mock_openai_client): + from agents.memory import SessionSettings + + session = OpenAIConversationsSession( + openai_client=mock_openai_client, + session_settings={"limit": 0}, + ) + + assert isinstance(session.session_settings, SessionSettings) + assert session.session_settings.limit == 0 diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index f9cc324d2e..d727991f7d 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -7,7 +7,7 @@ import pytest -from agents import Agent, RunConfig, Runner, SQLiteSession, TResponseInputItem +from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession, TResponseInputItem from tests.fake_model import FakeModel from tests.test_responses import get_text_message @@ -694,6 +694,22 @@ async def test_session_settings_constructor(): session.close() +@pytest.mark.asyncio +async def test_session_settings_constructor_normalizes_dictionary() -> None: + session = SQLiteSession("dictionary_settings_test", session_settings={"limit": 0}) + + assert isinstance(session.session_settings, SessionSettings) + assert session.session_settings.limit == 0 + assert session.session_settings.resolve({"limit": 4}).limit == 4 + + session.close() + + +def test_session_settings_rejects_unknown_dictionary_fields() -> None: + with pytest.raises(TypeError, match="Unknown session settings: limitt"): + SQLiteSession("invalid_settings_test", session_settings={"limitt": 1}) + + @pytest.mark.asyncio async def test_get_items_uses_session_settings_limit(): """Test that get_items uses session_settings.limit as default.""" diff --git a/tests/model_settings/test_serialization.py b/tests/model_settings/test_serialization.py index ea59dc55f2..073801bd11 100644 --- a/tests/model_settings/test_serialization.py +++ b/tests/model_settings/test_serialization.py @@ -1,6 +1,7 @@ import json from dataclasses import fields +import pytest from openai.types.shared import Reasoning from pydantic import TypeAdapter from pydantic_core import to_json @@ -30,6 +31,54 @@ def test_basic_serialization() -> None: verify_serialization(model_settings) +def test_model_settings_direct_constructor_preserves_openai_reasoning_extensions() -> None: + settings = ModelSettings( + reasoning={"context": "all_turns", "future_reasoning_option": "enabled"} + ) + + assert isinstance(settings.reasoning, Reasoning) + assert settings.reasoning.context == "all_turns" + assert settings.reasoning.model_extra == {"future_reasoning_option": "enabled"} + + +def test_model_settings_dictionary_override_preserves_omitted_values() -> None: + settings = ModelSettings( + temperature=0.5, + reasoning=Reasoning.model_validate( + {"context": "all_turns", "future_reasoning_option": "enabled"} + ), + retry=ModelRetrySettings(max_retries=2), + ) + + resolved = settings.resolve({"temperature": 0.0}) + + assert resolved.temperature == 0.0 + assert resolved.reasoning is settings.reasoning + assert resolved.retry is settings.retry + + +def test_model_settings_dictionary_override_merges_retry_settings() -> None: + settings = ModelSettings( + retry=ModelRetrySettings( + max_retries=2, + backoff=ModelRetryBackoffSettings(initial_delay=0.1, jitter=True), + ) + ) + + resolved = settings.resolve({"retry": {"max_retries": 0, "backoff": {"jitter": False}}}) + + assert resolved.retry is not None + assert resolved.retry.max_retries == 0 + assert isinstance(resolved.retry.backoff, ModelRetryBackoffSettings) + assert resolved.retry.backoff.initial_delay == 0.1 + assert resolved.retry.backoff.jitter is False + + +def test_model_settings_dictionary_override_rejects_unknown_fields() -> None: + with pytest.raises(TypeError, match="Unknown model settings: temperatur"): + ModelSettings().resolve({"temperatur": 0.5}) + + def test_mcp_tool_choice_serialization() -> None: """Tests whether ModelSettings with MCPToolChoice can be serialized to a JSON string.""" # First, lets create a ModelSettings instance diff --git a/tests/models/test_agent_registration.py b/tests/models/test_agent_registration.py index 4741db8b64..c22f69319a 100644 --- a/tests/models/test_agent_registration.py +++ b/tests/models/test_agent_registration.py @@ -17,6 +17,7 @@ from agents.models.openai_provider import OpenAIProvider from agents.run_internal.agent_runner_helpers import resolve_trace_settings from agents.tracing import agent_span, trace +from agents.voice.models.openai_model_provider import OpenAIVoiceModelProvider def test_agent_registration_config_precedence(monkeypatch: pytest.MonkeyPatch) -> None: @@ -91,6 +92,35 @@ def test_agent_registration_provider_constructor_config() -> None: assert multi_provider.openai_provider.agent_registration.harness_id == "provider-harness" +def test_agent_registration_provider_constructors_normalize_dictionaries() -> None: + config = {"harness_id": "dictionary-harness"} + openai_provider = OpenAIProvider(agent_registration=config) + multi_provider = MultiProvider(openai_agent_registration=config) + voice_provider = OpenAIVoiceModelProvider(agent_registration=config) + + assert openai_provider.agent_registration is not None + assert openai_provider.agent_registration.harness_id == "dictionary-harness" + assert multi_provider.openai_provider.agent_registration is not None + assert multi_provider.openai_provider.agent_registration.harness_id == "dictionary-harness" + assert voice_provider.agent_registration is not None + assert voice_provider.agent_registration.harness_id == "dictionary-harness" + + +def test_default_agent_registration_normalizes_dictionary() -> None: + set_default_openai_agent_registration({"harness_id": "dictionary-default"}) + try: + resolved = resolve_openai_agent_registration_config(None) + assert resolved is not None + assert resolved.harness_id == "dictionary-default" + finally: + set_default_openai_agent_registration(None) + + +def test_agent_registration_rejects_unknown_dictionary_fields() -> None: + with pytest.raises(TypeError, match="Unknown OpenAI agent registration settings: harness_idd"): + OpenAIProvider(agent_registration={"harness_idd": "invalid"}) + + def test_harness_id_resolves_private_agent_registration() -> None: class Provider: _agent_registration = OpenAIAgentRegistrationConfig(harness_id="private-harness") diff --git a/tests/models/test_kwargs_functionality.py b/tests/models/test_kwargs_functionality.py index dc641a75d2..3b8a7cc65d 100644 --- a/tests/models/test_kwargs_functionality.py +++ b/tests/models/test_kwargs_functionality.py @@ -1,3 +1,5 @@ +from typing import Any + import httpx import litellm import pytest @@ -9,6 +11,7 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.completion_usage import CompletionUsage +from agents import Agent from agents.extensions.models.litellm_model import LitellmModel from agents.model_settings import ModelSettings from agents.models._retry_runtime import provider_managed_retries_disabled @@ -66,6 +69,44 @@ async def fake_acompletion(model, messages=None, **kwargs): assert captured["temperature"] == 0.5 +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["model-settings", "dictionary"]) +async def test_litellm_normalizes_dictionary_agent_model_settings( + monkeypatch, use_dictionary: bool +): + captured: dict[str, object] = {} + + async def fake_acompletion(model, messages=None, **kwargs): + captured.update(kwargs) + message = Message(role="assistant", content="test response") + return ModelResponse(choices=[Choices(index=0, message=message)], usage=Usage(0, 0, 0)) + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + settings: dict[str, Any] = {"temperature": 0.0, "reasoning": {"effort": "low"}} + model = LitellmModel(model="test-model") + agent = Agent( + name="test", + model=model, + model_settings=settings if use_dictionary else ModelSettings(**settings), + ) + + await model.get_response( + system_instructions=None, + input="test input", + model_settings=agent.model_settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + ) + + assert captured["temperature"] == 0.0 + assert captured["reasoning_effort"] == "low" + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_openai_chatcompletions_kwargs_forwarded(monkeypatch): diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 0d2d4e8ed4..7bebfbc6e4 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -70,7 +70,7 @@ def _minimal_chat_completion(content: str = "ok") -> ChatCompletion: async def _run_chat_completions_model_with_custom_base_url( - model_settings: ModelSettings | None = None, + model_settings: ModelSettings | dict[str, Any] | None = None, ) -> dict[str, Any]: class DummyCompletions: def __init__(self) -> None: @@ -787,6 +787,54 @@ def test_chat_completions_rejects_responses_only_reasoning_settings_in_strict_mo ) +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["model-settings", "dictionary"]) +async def test_chat_completions_requests_normalize_dictionary_agent_settings( + use_dictionary: bool, +) -> None: + settings: dict[str, Any] = { + "reasoning": {"effort": "high"}, + "prompt_cache_options": {"mode": "explicit", "ttl": "30m"}, + "prompt_cache_retention": "24h", + "verbosity": "low", + "store": False, + "temperature": 0.3, + "top_p": 1.0, + "frequency_penalty": 0.0, + "presence_penalty": 0.0, + "max_tokens": 64, + "parallel_tool_calls": False, + "extra_headers": {"x-model-settings-parity": "preserved"}, + "extra_query": {"model_settings_parity": "verified"}, + "extra_body": {"prompt_cache_key": "extra-body-cache-key"}, + "retry": { + "max_retries": 0, + "backoff": {"initial_delay": 0.0, "jitter": False}, + }, + } + kwargs = await _run_chat_completions_model_with_custom_base_url( + model_settings=settings if use_dictionary else ModelSettings(**settings) + ) + + assert kwargs["reasoning_effort"] == "high" + assert kwargs["prompt_cache_options"] == settings["prompt_cache_options"] + assert kwargs["prompt_cache_retention"] == "24h" + assert kwargs["verbosity"] == "low" + assert kwargs["store"] is False + assert kwargs["temperature"] == 0.3 + assert kwargs["top_p"] == 1.0 + assert kwargs["frequency_penalty"] == 0.0 + assert kwargs["presence_penalty"] == 0.0 + assert kwargs["max_tokens"] == 64 + assert "max_output_tokens" not in kwargs + assert kwargs["parallel_tool_calls"] is False + assert kwargs["extra_headers"]["x-model-settings-parity"] == "preserved" + assert kwargs["extra_query"] == {"model_settings_parity": "verified"} + assert kwargs["extra_body"] == {"prompt_cache_key": "extra-body-cache-key"} + assert "retry" not in kwargs + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_custom_base_url_prompt_cache_key_uses_model_settings_only() -> None: diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 75919a6a11..90b01572ac 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator from typing import Any, cast +import httpx import pytest from openai.types.chat.chat_completion import ChatCompletion, Choice as ChatCompletionChoice from openai.types.chat.chat_completion_chunk import ( @@ -99,6 +100,95 @@ async def _collect_buffered_tool_call_chunks( ] +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["model-settings", "dictionary"]) +async def test_stream_response_forwards_dictionary_agent_model_settings( + use_dictionary: bool, +) -> None: + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="gpt-5.4-mini", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta(role="assistant", content="ok"), + finish_reason="stop", + ) + ], + ) + + class DummyCompletions: + def __init__(self) -> None: + self.kwargs: dict[str, Any] = {} + + async def create(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: + self.kwargs = kwargs + return _completion_stream(chunk) + + class DummyClient: + def __init__(self, completions: DummyCompletions) -> None: + self.chat = type("_Chat", (), {"completions": completions})() + self.base_url = httpx.URL("https://api.openai.com/v1/") + + completions = DummyCompletions() + model = OpenAIChatCompletionsModel( + model="gpt-5.4-mini", openai_client=cast(Any, DummyClient(completions)) + ) + settings: dict[str, Any] = { + "reasoning": {"effort": "low"}, + "prompt_cache_options": {"mode": "explicit", "ttl": "30m"}, + "prompt_cache_retention": "24h", + "verbosity": "low", + "store": False, + "temperature": 0.0, + "top_p": 1.0, + "frequency_penalty": 0.0, + "presence_penalty": 0.0, + "max_tokens": 64, + "parallel_tool_calls": False, + "include_usage": False, + } + agent = Agent( + name="test", + model=model, + model_settings=settings if use_dictionary else ModelSettings(**settings), + ) + + events = [ + event + async for event in model.stream_response( + system_instructions=None, + input="hi", + model_settings=agent.model_settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + assert any(event.type == "response.completed" for event in events) + assert completions.kwargs["reasoning_effort"] == "low" + assert completions.kwargs["prompt_cache_options"] == settings["prompt_cache_options"] + assert completions.kwargs["prompt_cache_retention"] == "24h" + assert completions.kwargs["verbosity"] == "low" + assert completions.kwargs["store"] is False + assert completions.kwargs["temperature"] == 0.0 + assert completions.kwargs["top_p"] == 1.0 + assert completions.kwargs["frequency_penalty"] == 0.0 + assert completions.kwargs["presence_penalty"] == 0.0 + assert completions.kwargs["max_tokens"] == 64 + assert completions.kwargs["parallel_tool_calls"] is False + assert completions.kwargs["stream"] is True + assert completions.kwargs["stream_options"] == {"include_usage": False} + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_stream_response_yields_events_for_text_content(monkeypatch) -> None: diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 2b4ca111be..00c6a4cf50 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -45,7 +45,7 @@ async def _run_responses_model_with_custom_base_url( - model_settings: ModelSettings | None = None, + model_settings: ModelSettings | dict[str, Any] | None = None, ) -> dict[str, Any]: class DummyResponses: def __init__(self) -> None: @@ -934,6 +934,56 @@ def test_build_response_create_kwargs_includes_gpt_5_6_request_controls(): assert kwargs["previous_response_id"] == "resp-previous" +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["model-settings", "dictionary"]) +async def test_responses_requests_normalize_dictionary_agent_settings(use_dictionary: bool) -> None: + settings: dict[str, Any] = { + "reasoning": {"effort": "low", "context": "all_turns"}, + "context_management": [{"type": "compaction", "compact_threshold": 200000}], + "prompt_cache_options": {"mode": "explicit", "ttl": "30m"}, + "prompt_cache_retention": "24h", + "store": False, + "metadata": {"request": "example"}, + "temperature": 0.0, + "top_p": 1.0, + "frequency_penalty": 0.0, + "presence_penalty": 0.0, + "max_tokens": 64, + "parallel_tool_calls": False, + "extra_headers": {"x-model-settings-parity": "preserved"}, + "extra_query": {"model_settings_parity": "verified"}, + "extra_body": {"prompt_cache_key": "extra-body-cache-key"}, + "retry": { + "max_retries": 0, + "backoff": {"initial_delay": 0.0, "jitter": False}, + }, + } + kwargs = await _run_responses_model_with_custom_base_url( + model_settings=settings if use_dictionary else ModelSettings(**settings) + ) + + assert isinstance(kwargs["reasoning"], Reasoning) + assert kwargs["reasoning"].effort == "low" + assert kwargs["reasoning"].context == "all_turns" + assert kwargs["context_management"] == settings["context_management"] + assert kwargs["prompt_cache_options"] == settings["prompt_cache_options"] + assert kwargs["prompt_cache_retention"] == "24h" + assert kwargs["store"] is False + assert kwargs["metadata"] == {"request": "example"} + assert kwargs["temperature"] == 0.0 + assert kwargs["top_p"] == 1.0 + assert kwargs["max_output_tokens"] == 64 + assert "max_tokens" not in kwargs + assert kwargs["parallel_tool_calls"] is False + assert kwargs["extra_headers"]["x-model-settings-parity"] == "preserved" + assert kwargs["extra_query"] == {"model_settings_parity": "verified"} + assert kwargs["extra_body"] == {"prompt_cache_key": "extra-body-cache-key"} + assert "retry" not in kwargs + assert "frequency_penalty" not in kwargs + assert "presence_penalty" not in kwargs + + @pytest.mark.allow_call_model_methods def test_build_response_create_kwargs_rejects_duplicate_prompt_cache_options_extra_args(): client = DummyWSClient() @@ -1777,13 +1827,22 @@ async def fake_open( monkeypatch.setattr(model, "_open_websocket_connection", fake_open) + configured_agent = Agent( + name="configured", + model=model, + model_settings={ + "reasoning": {"mode": "pro", "effort": "max", "context": "all_turns"}, + "context_management": [{"type": "compaction", "compact_threshold": 200000}], + "prompt_cache_options": {"mode": "explicit", "ttl": "30m"}, + "prompt_cache_retention": "24h", + "store": False, + "metadata": {"request": "example"}, + }, + ) first = await model.get_response( system_instructions=None, input="hi", - model_settings=ModelSettings( - reasoning=Reasoning(mode="pro", effort="max", context="all_turns"), - prompt_cache_options={"mode": "explicit", "ttl": "30m"}, - ), + model_settings=configured_agent.model_settings, tools=[], output_schema=None, handoffs=[], @@ -1815,6 +1874,12 @@ async def fake_open( "mode": "explicit", "ttl": "30m", } + assert ws.sent_messages[0]["context_management"] == [ + {"type": "compaction", "compact_threshold": 200000} + ] + assert ws.sent_messages[0]["prompt_cache_retention"] == "24h" + assert ws.sent_messages[0]["store"] is False + assert ws.sent_messages[0]["metadata"] == {"request": "example"} assert ws.sent_messages[1]["type"] == "response.create" assert ws.sent_messages[1]["stream"] is True assert ws.sent_messages[1]["previous_response_id"] == "resp-1" diff --git a/tests/models/test_openai_responses_converter.py b/tests/models/test_openai_responses_converter.py index e1c8069ec9..cef2c8b81b 100644 --- a/tests/models/test_openai_responses_converter.py +++ b/tests/models/test_openai_responses_converter.py @@ -27,6 +27,7 @@ import pytest from openai import omit +from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters from pydantic import BaseModel from agents import ( @@ -468,6 +469,32 @@ def test_convert_tools_includes_explicit_false_external_web_access() -> None: ] +@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"]) +def test_web_search_filters_preserve_existing_provider_payload(use_dictionary: bool) -> None: + filters = {"allowed_domains": ["example.com"]} + tool = WebSearchTool( + filters=filters if use_dictionary else WebSearchToolFilters.model_validate(filters) + ) + + assert isinstance(tool.filters, WebSearchToolFilters) + converted = Converter.convert_tools([tool], handoffs=[], model="gpt-5.4") + assert converted.tools == [ + { + "type": "web_search", + "filters": filters, + "user_location": None, + "search_context_size": "medium", + } + ] + + +def test_web_search_filters_preserve_openai_forward_compatible_fields() -> None: + tool = WebSearchTool(filters={"future_filter": ["example.com"]}) + + assert tool.filters is not None + assert tool.filters.model_extra == {"future_filter": ["example.com"]} + + def test_convert_tools_uses_preview_computer_payload_for_preview_model() -> None: comp_tool = ComputerTool(computer=DummyComputer()) diff --git a/tests/models/test_responses_websocket_session.py b/tests/models/test_responses_websocket_session.py index c1272da156..fc2d339756 100644 --- a/tests/models/test_responses_websocket_session.py +++ b/tests/models/test_responses_websocket_session.py @@ -2,7 +2,7 @@ import pytest -from agents import Agent, responses_websocket_session +from agents import Agent, ResponsesWebSocketSession, RunConfig, responses_websocket_session from agents.models.multi_provider import MultiProvider from agents.models.openai_provider import OpenAIProvider @@ -17,6 +17,35 @@ async def test_responses_websocket_session_builds_shared_run_config(): assert ws.run_config.model_provider.openai_provider is ws.provider +def test_responses_websocket_session_normalizes_dictionary_run_config() -> None: + provider = MultiProvider(openai_api_key="test") + + session = ResponsesWebSocketSession( + provider=provider.openai_provider, + run_config={ + "model_provider": provider, + "model_settings": {"temperature": 0.0, "retry": {"max_retries": 0}}, + }, + ) + + assert isinstance(session.run_config, RunConfig) + assert session.run_config.model_provider is provider + assert session.run_config.model_settings is not None + assert session.run_config.model_settings.temperature == 0.0 + assert session.run_config.model_settings.retry is not None + assert session.run_config.model_settings.retry.max_retries == 0 + + +def test_responses_websocket_session_rejects_unknown_dictionary_run_config_fields() -> None: + provider = MultiProvider(openai_api_key="test") + + with pytest.raises(TypeError, match="Unknown run_config settings: tracin_disabled"): + ResponsesWebSocketSession( + provider=provider.openai_provider, + run_config={"model_provider": provider, "tracin_disabled": True}, + ) + + @pytest.mark.asyncio async def test_responses_websocket_session_preserves_openai_prefix_routing(monkeypatch): captured: dict[str, object] = {} diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index 5eb843de2d..c917dacd6d 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -2,9 +2,10 @@ import io import json +from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, cast +from typing import Any, cast, get_type_hints import pytest from openai.types.responses import ResponseCustomToolCall, ResponseFunctionToolCall @@ -16,6 +17,7 @@ import agents.sandbox.memory.phase_one as phase_one_module from agents import ( Agent, + ModelSettings, ReasoningItem, RunConfig, Runner, @@ -70,6 +72,17 @@ from tests.utils.hitl import make_shell_call +@dataclass +class _DeclaredProviderModelSettings(ModelSettings): + provider_field: str | None = None + + +@dataclass +class _DeclaredProviderMemoryGenerateConfig(MemoryGenerateConfig): + phase_one_model_settings: _DeclaredProviderModelSettings | None = None + phase_two_model_settings: _DeclaredProviderModelSettings | None = None + + class _DeleteTrackingUnixLocalSandboxClient(UnixLocalSandboxClient): def __init__(self) -> None: super().__init__() @@ -696,6 +709,93 @@ def test_memory_generate_config_accepts_renamed_limit_field() -> None: assert config.max_raw_memories_for_consolidation == 123 +def test_memory_generate_config_normalizes_dictionary_model_settings() -> None: + config = MemoryGenerateConfig( + phase_one_model_settings={ + "reasoning": {"effort": "low"}, + "retry": {"max_retries": 0}, + }, + phase_two_model_settings={"temperature": 0.0, "store": False}, + ) + + assert isinstance(config.phase_one_model_settings, ModelSettings) + assert config.phase_one_model_settings.reasoning is not None + assert config.phase_one_model_settings.reasoning.effort == "low" + assert config.phase_one_model_settings.retry is not None + assert config.phase_one_model_settings.retry.max_retries == 0 + assert isinstance(config.phase_two_model_settings, ModelSettings) + assert config.phase_two_model_settings.temperature == 0.0 + assert config.phase_two_model_settings.store is False + + +def test_memory_generate_config_subclass_uses_declared_model_settings_types() -> None: + config = cast(Any, _DeclaredProviderMemoryGenerateConfig)( + phase_one_model_settings={"provider_field": "phase-one"}, + phase_two_model_settings={"provider_field": "phase-two"}, + ) + + assert isinstance(config.phase_one_model_settings, _DeclaredProviderModelSettings) + assert config.phase_one_model_settings.provider_field == "phase-one" + assert isinstance(config.phase_two_model_settings, _DeclaredProviderModelSettings) + assert config.phase_two_model_settings.provider_field == "phase-two" + + +def test_memory_generate_config_model_settings_field_types_describe_normalized_values() -> None: + type_hints = get_type_hints(MemoryGenerateConfig) + + assert type_hints["phase_one_model_settings"] == ModelSettings | None + assert type_hints["phase_two_model_settings"] == ModelSettings | None + + +def test_memory_generate_config_preserves_typed_model_settings() -> None: + phase_one_settings = ModelSettings(reasoning={"effort": "low"}) + phase_two_settings = ModelSettings(temperature=0.2) + config = MemoryGenerateConfig( + phase_one_model_settings=phase_one_settings, + phase_two_model_settings=phase_two_settings, + ) + + assert config.phase_one_model_settings is phase_one_settings + assert config.phase_two_model_settings is phase_two_settings + + +@pytest.mark.parametrize( + "field_name", + ["phase_one_model_settings", "phase_two_model_settings"], +) +def test_memory_generate_config_preserves_forward_compatible_reasoning_settings( + field_name: str, +) -> None: + settings: dict[str, Any] = {field_name: {"reasoning": {"future_reasoning_option": "enabled"}}} + + config = MemoryGenerateConfig(**settings) + model_settings = getattr(config, field_name) + + assert model_settings is not None + assert model_settings.reasoning is not None + assert model_settings.reasoning.model_extra == {"future_reasoning_option": "enabled"} + + +@pytest.mark.parametrize( + "field_name", + ["phase_one_model_settings", "phase_two_model_settings"], +) +def test_memory_generate_config_rejects_invalid_model_settings(field_name: str) -> None: + settings: dict[str, Any] = {field_name: "invalid"} + with pytest.raises( + TypeError, + match=f"MemoryGenerateConfig.{field_name} must be a ModelSettings instance or a dict", + ): + MemoryGenerateConfig(**settings) + + +def test_memory_generate_config_preserves_disabled_model_settings() -> None: + config = MemoryGenerateConfig(phase_one_model_settings=None, phase_two_model_settings=None) + + assert config.phase_one_model_settings is None + assert config.phase_two_model_settings is None + + def test_memory_generate_config_rejects_too_many_raw_memories() -> None: with pytest.raises( ValueError, diff --git a/tests/sandbox/test_runtime_agent_preparation.py b/tests/sandbox/test_runtime_agent_preparation.py index eff4a3131a..c532f7e990 100644 --- a/tests/sandbox/test_runtime_agent_preparation.py +++ b/tests/sandbox/test_runtime_agent_preparation.py @@ -17,6 +17,49 @@ from agents.sandbox.manifest import Manifest from agents.sandbox.sandbox_agent import SandboxAgent from agents.sandbox.session.base_sandbox_session import BaseSandboxSession +from agents.sandbox.types import User + + +def test_sandbox_agent_normalizes_first_party_dictionary_configuration() -> None: + agent = SandboxAgent( + name="sandbox", + model_settings={"reasoning": {"context": "all_turns"}}, + default_manifest={"root": "/workspace"}, + run_as={"name": "agent"}, + ) + + assert agent.model_settings.reasoning is not None + assert agent.model_settings.reasoning.context == "all_turns" + assert isinstance(agent.default_manifest, Manifest) + assert isinstance(agent.run_as, User) + assert agent.run_as.name == "agent" + + +def test_sandbox_agent_rejects_untrusted_manifest_path_grants() -> None: + with pytest.raises( + TypeError, + match=( + r"sandbox\.default_manifest\.extra_path_grants must be configured " + r"on a trusted Manifest" + ), + ): + SandboxAgent(name="sandbox", default_manifest={"extra_path_grants": [{"path": "/tmp"}]}) + + +@pytest.mark.parametrize( + "manifest", + [ + Manifest(root="/workspace").model_dump(), + Manifest(root="/workspace").model_dump(mode="json"), + ], +) +def test_sandbox_agent_accepts_serialized_manifest_without_path_grants( + manifest: dict[str, Any], +) -> None: + agent = SandboxAgent(name="sandbox", default_manifest=manifest) + + assert isinstance(agent.default_manifest, Manifest) + assert agent.default_manifest.extra_path_grants == () class _Capability: diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index ad77eeb3e2..f935cfd7a7 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -1,9 +1,13 @@ +from typing import Any + import pytest +from openai.types.shared import Reasoning from pydantic import BaseModel from agents import Agent, AgentOutputSchema, Handoff, RunContextWrapper, handoff from agents.lifecycle import AgentHooksBase from agents.model_settings import ModelSettings +from agents.retry import ModelRetryBackoffSettings from agents.run_internal.run_loop import get_handoffs, get_output_schema @@ -216,11 +220,66 @@ def test_list_field_validation(self): def test_model_settings_validation(self): """Test model_settings validation - prevents runtime errors""" - # Valid case + # Typed settings and SDK-owned dictionaries are both valid. Agent(name="test", model_settings=ModelSettings()) + agent = Agent(name="test", model_settings={"temperature": 0.25}) + + assert isinstance(agent.model_settings, ModelSettings) + assert agent.model_settings.temperature == 0.25 - # Invalid case that could cause runtime issues + # Invalid values are rejected before model execution. with pytest.raises( - TypeError, match="Agent model_settings must be a ModelSettings instance" + TypeError, match="Agent model_settings must be a ModelSettings instance or a dict" ): - Agent(name="test", model_settings={}) # type: ignore + Agent(name="test", model_settings="invalid") # type: ignore[arg-type] + + +def test_agent_model_settings_dictionary_preserves_openai_reasoning_extensions() -> None: + agent = Agent( + name="test", + model_settings={ + "reasoning": {"context": "all_turns", "future_reasoning_option": "enabled"}, + "context_management": [{"type": "compaction", "compact_threshold": 244800}], + "retry": {"max_retries": 0, "backoff": {"jitter": False}}, + }, + ) + + assert isinstance(agent.model_settings.reasoning, Reasoning) + assert agent.model_settings.reasoning.context == "all_turns" + assert agent.model_settings.reasoning.model_extra == {"future_reasoning_option": "enabled"} + assert agent.model_settings.context_management == [ + {"type": "compaction", "compact_threshold": 244800} + ] + assert agent.model_settings.retry is not None + assert agent.model_settings.retry.max_retries == 0 + assert isinstance(agent.model_settings.retry.backoff, ModelRetryBackoffSettings) + assert agent.model_settings.retry.backoff.jitter is False + + +@pytest.mark.parametrize( + ("settings", "message"), + [ + ({"temperatur": 0.2}, "Unknown model settings: temperatur"), + ({"retry": {"max_retry": 2}}, "Unknown model settings in retry: max_retry"), + ( + {"retry": {"backoff": {"initial_delai": 1}}}, + "Unknown model settings in retry.backoff: initial_delai", + ), + ( + {"context_management": [{"type": "compaction", "compact_threshold_typo": 1}]}, + r"Unknown model settings in context_management\[0\]: compact_threshold_typo", + ), + ], +) +def test_agent_rejects_unknown_first_party_dictionary_model_settings( + settings: dict[str, Any], message: str +) -> None: + with pytest.raises(TypeError, match=message): + Agent(name="test", model_settings=settings) + + +@pytest.mark.parametrize("setting_name", ["reasoning", "context_management", "temperature"]) +def test_agent_does_not_promote_model_settings_to_constructor(setting_name: str) -> None: + arguments: dict[str, Any] = {setting_name: None} + with pytest.raises(TypeError, match=f"unexpected keyword argument '{setting_name}'"): + Agent(name="test", **arguments) diff --git a/tests/test_run_config.py b/tests/test_run_config.py index e3f78ae88f..7b99b649f2 100644 --- a/tests/test_run_config.py +++ b/tests/test_run_config.py @@ -2,9 +2,19 @@ import pytest -from agents import Agent, RunConfig, Runner, ToolExecutionConfig, ToolNotFoundBehavior +from agents import ( + Agent, + RunConfig, + Runner, + SessionSettings, + ToolExecutionConfig, + ToolNotFoundBehavior, +) from agents.model_settings import ModelSettings from agents.models.interface import Model, ModelProvider +from agents.run_config import SandboxConcurrencyLimits, SandboxRunConfig +from agents.sandbox.manifest import Manifest +from agents.sandbox.snapshot import NoopSnapshotSpec from .fake_model import FakeModel from .test_responses import get_text_message @@ -24,6 +34,99 @@ def get_model(self, model_name: str | None) -> Model: return self.model_to_return +def test_run_config_normalizes_first_party_dictionary_settings() -> None: + config = RunConfig( + model_settings={"reasoning": {"context": "all_turns"}, "temperature": 0.0}, + session_settings={"limit": 5}, + tool_execution={"max_function_tool_concurrency": 2}, + sandbox={ + "manifest": {"root": "/workspace"}, + "snapshot": {"type": "noop"}, + "concurrency_limits": {"manifest_entries": 3}, + }, + ) + + assert isinstance(config.model_settings, ModelSettings) + assert config.model_settings.reasoning is not None + assert config.model_settings.reasoning.context == "all_turns" + assert config.model_settings.temperature == 0.0 + assert isinstance(config.session_settings, SessionSettings) + assert config.session_settings.limit == 5 + assert isinstance(config.tool_execution, ToolExecutionConfig) + assert config.tool_execution.max_function_tool_concurrency == 2 + assert isinstance(config.sandbox, SandboxRunConfig) + assert isinstance(config.sandbox.manifest, Manifest) + assert isinstance(config.sandbox.snapshot, NoopSnapshotSpec) + assert isinstance(config.sandbox.concurrency_limits, SandboxConcurrencyLimits) + assert config.sandbox.concurrency_limits.manifest_entries == 3 + + +def test_run_config_preserves_typed_configuration_instances() -> None: + settings = ModelSettings(temperature=0.2) + session_settings = SessionSettings(limit=3) + config = RunConfig(model_settings=settings, session_settings=session_settings) + + assert config.model_settings is settings + assert config.session_settings is session_settings + + +def test_run_config_rejects_untrusted_manifest_path_grants() -> None: + with pytest.raises( + TypeError, + match=r"sandbox\.manifest\.extra_path_grants must be configured on a trusted Manifest", + ): + RunConfig(sandbox={"manifest": {"extra_path_grants": [{"path": "/tmp"}]}}) + + +@pytest.mark.parametrize( + "manifest", + [ + Manifest(root="/workspace").model_dump(), + Manifest(root="/workspace").model_dump(mode="json"), + ], +) +def test_run_config_accepts_serialized_manifest_without_path_grants( + manifest: dict[str, object], +) -> None: + config = RunConfig(sandbox={"manifest": manifest}) + + assert config.sandbox is not None + assert isinstance(config.sandbox.manifest, Manifest) + assert config.sandbox.manifest.extra_path_grants == () + + +@pytest.mark.parametrize( + ("settings", "message"), + [ + ({"model_settings": {"temperatur": 0.2}}, "Unknown model settings: temperatur"), + ({"session_settings": {"limitt": 2}}, "Unknown session settings: limitt"), + ( + {"tool_execution": {"max_function_tool_concurrenc": 2}}, + "Unknown run_config.tool_execution settings: max_function_tool_concurrenc", + ), + ], +) +def test_run_config_rejects_unknown_first_party_dictionary_fields( + settings: dict[str, object], message: str +) -> None: + with pytest.raises(TypeError, match=message): + RunConfig(**settings) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_runner_accepts_dictionary_run_configuration() -> None: + model = FakeModel(initial_output=[get_text_message("done")]) + agent = Agent(name="test", model=model) + + result = await Runner.run( + agent, + "hello", + run_config={"model_settings": {"temperature": 0.0}}, + ) + + assert result.final_output == "done" + + @pytest.mark.asyncio async def test_model_provider_on_run_config_is_used_for_agent_model_name() -> None: """ diff --git a/tests/test_tool_context.py b/tests/test_tool_context.py index 5f1f9c1976..05f4a8a859 100644 --- a/tests/test_tool_context.py +++ b/tests/test_tool_context.py @@ -95,6 +95,35 @@ def test_tool_context_constructor_accepts_agent_keyword() -> None: assert tool_ctx.agent is agent +def test_tool_context_constructor_normalizes_dictionary_run_config() -> None: + tool_ctx: ToolContext[dict[str, object]] = ToolContext( + context={}, + tool_name="my_tool", + tool_call_id="call-2", + tool_arguments="{}", + run_config={ + "tracing_disabled": True, + "model_settings": {"temperature": 0.0}, + }, + ) + + assert isinstance(tool_ctx.run_config, RunConfig) + assert tool_ctx.run_config.tracing_disabled is True + assert tool_ctx.run_config.model_settings is not None + assert tool_ctx.run_config.model_settings.temperature == 0.0 + + +def test_tool_context_constructor_rejects_unknown_dictionary_run_config_fields() -> None: + with pytest.raises(TypeError, match="Unknown run_config settings: tracin_disabled"): + ToolContext( + context={}, + tool_name="my_tool", + tool_call_id="call-2", + tool_arguments="{}", + run_config={"tracin_disabled": True}, + ) + + def test_tool_context_constructor_infers_namespace_from_tool_call() -> None: tool_call = ResponseFunctionToolCall( type="function_call", @@ -221,6 +250,25 @@ def test_tool_context_from_agent_context_prefers_explicit_run_config() -> None: assert tool_ctx.run_config is explicit_run_config +def test_tool_context_from_agent_context_normalizes_dictionary_run_config() -> None: + tool_call = ResponseFunctionToolCall( + type="function_call", + name="test_tool", + call_id="call-1", + arguments="{}", + ) + + tool_ctx = ToolContext.from_agent_context( + make_context_wrapper(), + tool_call_id="call-1", + tool_call=tool_call, + run_config={"tracing_disabled": True}, + ) + + assert isinstance(tool_ctx.run_config, RunConfig) + assert tool_ctx.run_config.tracing_disabled is True + + @pytest.mark.asyncio async def test_invoke_function_tool_passes_plain_run_context_when_requested() -> None: captured_context: RunContextWrapper[str] | None = None diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index c60dbf6161..d6f97bb0eb 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass, field +from typing import Any import numpy as np import numpy.typing as npt @@ -13,6 +15,7 @@ from agents.voice import ( AudioInput, StreamedAudioResult, + STTModelSettings, TTSModelSettings, VoicePipeline, VoicePipelineConfig, @@ -27,6 +30,22 @@ pass +@dataclass +class _ProviderSTTModelSettings(STTModelSettings): + provider_language: str | None = None + + +@dataclass +class _ProviderTTSModelSettings(TTSModelSettings): + provider_voice: str | None = None + + +@dataclass +class _ProviderVoicePipelineConfig(VoicePipelineConfig): + stt_settings: _ProviderSTTModelSettings = field(default_factory=_ProviderSTTModelSettings) + tts_settings: _ProviderTTSModelSettings = field(default_factory=_ProviderTTSModelSettings) + + def test_streamed_audio_result_odd_length_buffer_int16() -> None: result = StreamedAudioResult( FakeTTS(), @@ -40,6 +59,68 @@ def test_streamed_audio_result_odd_length_buffer_int16() -> None: assert transformed.tolist() == [1] +def test_voice_pipeline_config_normalizes_dictionary_settings() -> None: + config = VoicePipelineConfig( + stt_settings={"language": "ja", "temperature": 0.0}, + tts_settings={"voice": "alloy", "buffer_size": 1}, + ) + + assert isinstance(config.stt_settings, STTModelSettings) + assert config.stt_settings.language == "ja" + assert config.stt_settings.temperature == 0.0 + assert isinstance(config.tts_settings, TTSModelSettings) + assert config.tts_settings.voice == "alloy" + assert config.tts_settings.buffer_size == 1 + + +def test_voice_pipeline_config_subclass_uses_declared_settings_types() -> None: + config = _ProviderVoicePipelineConfig( + stt_settings={"provider_language": "ja"}, # type: ignore[arg-type] + tts_settings={"provider_voice": "voice"}, # type: ignore[arg-type] + ) + + assert isinstance(config.stt_settings, _ProviderSTTModelSettings) + assert config.stt_settings.provider_language == "ja" + assert isinstance(config.tts_settings, _ProviderTTSModelSettings) + assert config.tts_settings.provider_voice == "voice" + + +@pytest.mark.parametrize( + ("settings", "message"), + [ + ({"stt_settings": {"languge": "ja"}}, "Unknown voice.stt settings: languge"), + ({"tts_settings": {"voce": "alloy"}}, "Unknown voice.tts settings: voce"), + ], +) +def test_voice_pipeline_config_rejects_unknown_dictionary_settings( + settings: dict[str, Any], message: str +) -> None: + with pytest.raises(TypeError, match=message): + VoicePipelineConfig(**settings) + + +@pytest.mark.asyncio +async def test_voicepipeline_normalizes_nested_dictionary_config() -> None: + fake_stt = FakeSTT(["first"]) + fake_tts = FakeTTS() + pipeline = VoicePipeline( + workflow=FakeWorkflow([["out_1"]]), + stt_model=fake_stt, + tts_model=fake_tts, + config={ + "stt_settings": {"language": "ja"}, + "tts_settings": {"voice": "alloy", "buffer_size": 1}, + }, + ) + + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + events, audio_chunks = await extract_events(result) + + assert isinstance(pipeline.config, VoicePipelineConfig) + assert events == ["turn_started", "audio", "turn_ended", "session_ended"] + await fake_tts.verify_audio("out_1", audio_chunks[0]) + + def test_streamed_audio_result_odd_length_buffer_float32() -> None: result = StreamedAudioResult( FakeTTS(), From 80f9fa5d9cf5ba1a512f6b5b9e609edabd6c229f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Jul 2026 08:54:17 +0900 Subject: [PATCH 004/473] fix: improve AnyLLM and LiteLLM provider compatibility (#3930) --- src/agents/extensions/models/any_llm_model.py | 25 ++++++++-- src/agents/extensions/models/litellm_model.py | 5 ++ tests/models/test_any_llm_model.py | 46 +++++++++++++++++++ tests/models/test_litellm_extra_body.py | 38 +++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 02790e5f39..60d60da338 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -981,9 +981,28 @@ def _clone_provider_without_retries(self, provider: Any) -> Any: def _normalize_response(self, response: Any) -> Response: if isinstance(response, Response): return response - if isinstance(response, BaseModel): - return Response.model_validate(response.model_dump()) - return Response.model_validate(response) + + payload = response.model_dump() if isinstance(response, BaseModel) else response + if isinstance(payload, dict): + usage = payload.get("usage") + if isinstance(usage, dict): + input_tokens_details = usage.get("input_tokens_details") + if ( + isinstance(input_tokens_details, dict) + and "cache_write_tokens" not in input_tokens_details + ): + payload = { + **payload, + "usage": { + **usage, + "input_tokens_details": { + **input_tokens_details, + "cache_write_tokens": 0, + }, + }, + } + + return Response.model_validate(payload) def _normalize_chat_completion_response(self, response: Any) -> ChatCompletion: if isinstance(response, ChatCompletion): diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index df689699a2..b29e9d2565 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -574,6 +574,11 @@ async def _fetch_response( if model_settings.extra_args: extra_kwargs.update(model_settings.extra_args) + if converted_tools: + # SDK tools are already converted to ordinary function tools, so LiteLLM's proxy-only + # MCP discovery would add unsupported server dependencies without handling them. + extra_kwargs.setdefault("_skip_mcp_handler", True) + if should_disable_provider_managed_retries(): # Preserve provider-managed retries on the first attempt, but make runner retries the # sole retry layer by forcing LiteLLM's retry knobs off on replay attempts. diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 6950130eec..a8d155a389 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -214,6 +214,18 @@ class GenericChatCompletionPayload(BaseModel): usage: Any +class GenericResponsesPayload(BaseModel): + id: str + created_at: float + model: str + object: str + output: list[Any] + parallel_tool_calls: bool + tool_choice: Any + tools: list[Any] + usage: Any + + async def _empty_chat_stream() -> AsyncIterator[ChatCompletionChunk]: if False: yield ChatCompletionChunk( @@ -410,6 +422,40 @@ async def test_any_llm_responses_path_is_used_when_supported(monkeypatch) -> Non assert response.output[0].content[0].text == "Hello" +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("payload_type", ["dict", "basemodel"]) +async def test_any_llm_responses_path_defaults_missing_cache_write_tokens( + monkeypatch: pytest.MonkeyPatch, payload_type: str +) -> None: + response_payload = _response("Hello").model_dump() + response_payload["usage"]["input_tokens_details"].pop("cache_write_tokens") + response: Any = response_payload + if payload_type == "basemodel": + response = GenericResponsesPayload.model_validate(response_payload) + + provider = FakeAnyLLMProvider(supports_responses=True, responses_response=response) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model="openai/gpt-5.4-mini") + + normalized = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert normalized.output[0].content[0].text == "Hello" + assert normalized.usage.input_tokens_details.cache_write_tokens == 0 + assert "cache_write_tokens" not in response_payload["usage"]["input_tokens_details"] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_any_llm_can_force_chat_completions_when_responses_are_supported(monkeypatch) -> None: diff --git a/tests/models/test_litellm_extra_body.py b/tests/models/test_litellm_extra_body.py index b7940c05df..948a8cf192 100644 --- a/tests/models/test_litellm_extra_body.py +++ b/tests/models/test_litellm_extra_body.py @@ -4,6 +4,7 @@ import pytest from litellm.types.utils import Choices, Message, ModelResponse, Usage +from agents import function_tool from agents.extensions.models.litellm_model import LitellmModel from agents.model_settings import ModelSettings from agents.models.interface import ModelTracing @@ -48,6 +49,43 @@ async def fake_acompletion(model, messages=None, **kwargs): assert "foo" not in captured +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("override", [None, False]) +async def test_function_tools_skip_litellm_proxy_mcp_discovery(monkeypatch, override): + captured: dict[str, object] = {} + + async def fake_acompletion(model, messages=None, **kwargs): + captured.update(kwargs) + msg = Message(role="assistant", content="ok") + choice = Choices(index=0, message=msg) + return ModelResponse(choices=[choice], usage=Usage(0, 0, 0)) + + @function_tool + def lookup() -> str: + """Return a deterministic result.""" + return "ok" + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + settings = ModelSettings( + extra_args={"_skip_mcp_handler": override} if override is not None else None + ) + model = LitellmModel(model="test-model") + + await model.get_response( + system_instructions=None, + input=[], + model_settings=settings, + tools=[lookup], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + ) + + assert captured["_skip_mcp_handler"] is (True if override is None else override) + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_extra_body_reasoning_effort_is_promoted(monkeypatch): From 4c251ff7795d8c9619e8311ea600e7e071b449d0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Jul 2026 10:09:59 +0900 Subject: [PATCH 005/473] fix: improve provider compatibility and preserve session history on retries (#3931) --- src/agents/extensions/models/any_llm_model.py | 46 ++++++- src/agents/run_internal/run_loop.py | 4 +- tests/models/test_any_llm_model.py | 129 ++++++++++++++++++ tests/test_agent_runner.py | 46 +++++++ 4 files changed, 222 insertions(+), 3 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 60d60da338..72f930dbab 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -731,6 +731,25 @@ async def _fetch_chat_response( extra_kwargs = self._build_chat_extra_kwargs(model_settings) extra_kwargs.pop("reasoning_effort", None) + headers = self._merge_headers(model_settings) + if self._provider_name in {"gemini", "vertexai"}: + http_options = extra_kwargs.get("http_options") + if isinstance(http_options, BaseModel): + existing_headers = getattr(http_options, "headers", None) or {} + extra_kwargs["http_options"] = http_options.model_copy( + update={"headers": {**existing_headers, **headers}} + ) + elif isinstance(http_options, dict): + existing_headers = http_options.get("headers") or {} + extra_kwargs["http_options"] = { + **http_options, + "headers": {**existing_headers, **headers}, + } + elif http_options is None: + extra_kwargs["http_options"] = {"headers": headers} + else: + extra_kwargs["extra_headers"] = headers + # The Chat Completions API requires logprobs=True whenever top_logprobs is set. Defer to a # caller-supplied logprobs (via extra_args, already merged into extra_kwargs) to avoid a # duplicate-key collision. @@ -753,7 +772,6 @@ async def _fetch_chat_response( stream_options=stream_options, reasoning_effort=reasoning_effort, top_logprobs=model_settings.top_logprobs, - extra_headers=self._merge_headers(model_settings), **extra_kwargs, ) @@ -959,6 +977,8 @@ def _get_provider(self) -> Any: api_key=self.api_key, api_base=self.base_url, ) + if self._provider_name in {"gemini", "vertexai"}: + self._normalize_google_tool_result_roles(base_provider) self._provider_cache[False] = base_provider if disable_provider_retries: @@ -968,6 +988,30 @@ def _get_provider(self) -> Any: return base_provider + @staticmethod + def _normalize_google_tool_result_roles(provider: Any) -> None: + convert_completion_params = getattr(provider, "_convert_completion_params", None) + if not callable(convert_completion_params): + return + + def convert_with_supported_tool_result_roles(*args: Any, **kwargs: Any) -> Any: + converted = convert_completion_params(*args, **kwargs) + contents = converted.get("contents") + if not isinstance(contents, list): + return converted + + converted["contents"] = [ + content.model_copy(update={"role": "user"}) + if isinstance(content, BaseModel) and getattr(content, "role", None) == "function" + else {**content, "role": "user"} + if isinstance(content, dict) and content.get("role") == "function" + else content + for content in contents + ] + return converted + + provider._convert_completion_params = convert_with_supported_tool_result_roles + def _clone_provider_without_retries(self, provider: Any) -> Any: client = getattr(provider, "client", None) with_options = getattr(client, "with_options", None) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index aa68ad8f17..fa60d2299e 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1955,9 +1955,9 @@ async def get_new_response( model_settings = model_settings_with_prompt_cache_key(model_settings, prompt_cache_key) async def rewind_model_request() -> None: - items_to_rewind = session_items_to_rewind if session_items_to_rewind is not None else [] - await rewind_session_items(session, items_to_rewind, server_conversation_tracker) if server_conversation_tracker is not None: + items_to_rewind = session_items_to_rewind if session_items_to_rewind is not None else [] + await rewind_session_items(session, items_to_rewind, server_conversation_tracker) server_conversation_tracker.rewind_input(filtered.input) with model_run_context(tool_use_tracker): diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index a8d155a389..c87477cd60 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -272,6 +272,135 @@ async def test_user_agent_header_any_llm_chat(override_ua: str | None, monkeypat assert provider.chat_calls[0]["extra_headers"]["User-Agent"] == expected_ua +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_name", ["gemini", "vertexai"]) +@pytest.mark.parametrize("options_type", ["unset", "dictionary", "model"]) +async def test_any_llm_google_chat_headers_use_http_options( + monkeypatch: pytest.MonkeyPatch, provider_name: str, options_type: str +) -> None: + class HttpOptions(BaseModel): + headers: dict[str, str] + timeout: int + + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model=f"{provider_name}/gemini-2.5-flash") + + extra_args: dict[str, Any] = {} + configured_options: dict[str, Any] | HttpOptions | None = None + if options_type == "dictionary": + configured_options = {"headers": {"X-Existing": "existing"}, "timeout": 1000} + extra_args["http_options"] = configured_options + elif options_type == "model": + configured_options = HttpOptions(headers={"X-Existing": "existing"}, timeout=1000) + extra_args["http_options"] = configured_options + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings( + extra_args=extra_args, + extra_headers={"X-Test-Header": "test"}, + ), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + call = provider.chat_calls[0] + assert "extra_headers" not in call + http_options = call["http_options"] + if isinstance(http_options, BaseModel): + http_options = http_options.model_dump() + assert http_options["headers"]["User-Agent"] == f"Agents/Python {__version__}" + assert http_options["headers"]["X-Test-Header"] == "test" + if configured_options is not None: + assert http_options["headers"]["X-Existing"] == "existing" + assert http_options["timeout"] == 1000 + if isinstance(configured_options, BaseModel): + assert configured_options.headers == {"X-Existing": "existing"} + else: + assert configured_options["headers"] == {"X-Existing": "existing"} + + +@pytest.mark.parametrize("provider_name", ["gemini", "vertexai"]) +@pytest.mark.parametrize("content_type", ["model", "dictionary"]) +def test_any_llm_google_provider_normalizes_function_result_roles( + monkeypatch: pytest.MonkeyPatch, provider_name: str, content_type: str +) -> None: + class GoogleContent(BaseModel): + role: str + parts: list[dict[str, Any]] + + tool_result: dict[str, Any] = { + "role": "function", + "parts": [{"function_response": {"name": "get_weather", "response": {"result": "sunny"}}}], + } + original_tool_result: GoogleContent | dict[str, Any] + if content_type == "model": + original_tool_result = GoogleContent.model_validate(tool_result) + else: + original_tool_result = tool_result + + class GoogleProvider(FakeAnyLLMProvider): + @staticmethod + def _convert_completion_params(*args: Any, **kwargs: Any) -> dict[str, Any]: + return { + "model": "gemini-3.6-flash", + "contents": [ + GoogleContent(role="user", parts=[{"text": "Check the weather."}]), + original_tool_result, + GoogleContent(role="model", parts=[{"text": "Done."}]), + ], + } + + provider = GoogleProvider(supports_responses=False) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model=f"{provider_name}/gemini-3.6-flash") + + converted = model._get_provider()._convert_completion_params(object()) + contents = converted["contents"] + + assert [ + item.role if isinstance(item, GoogleContent) else item["role"] for item in contents + ] == [ + "user", + "user", + "model", + ] + normalized_tool_result = contents[1] + if isinstance(normalized_tool_result, BaseModel): + normalized_tool_result = normalized_tool_result.model_dump() + assert normalized_tool_result["parts"] == tool_result["parts"] + assert ( + original_tool_result.role + if isinstance(original_tool_result, GoogleContent) + else original_tool_result["role"] + ) == "function" + + +def test_any_llm_non_google_provider_does_not_normalize_function_result_roles( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class NonGoogleProvider(FakeAnyLLMProvider): + @staticmethod + def _convert_completion_params(*args: Any, **kwargs: Any) -> dict[str, Any]: + return {"contents": [{"role": "function", "parts": [{"result": "ok"}]}]} + + provider = NonGoogleProvider(supports_responses=False) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model="openrouter/google/gemini-3.6-flash") + + converted = model._get_provider()._convert_completion_params(object()) + + assert converted["contents"][0]["role"] == "function" + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_any_llm_chat_path_is_used_when_responses_are_unsupported(monkeypatch) -> None: diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index e93e111464..12160d886e 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -2608,6 +2608,52 @@ async def test_conversation_lock_rewind_skips_when_no_snapshot() -> None: assert session.pop_calls == 0 +@pytest.mark.asyncio +@pytest.mark.parametrize("session_backend", ["memory", "sqlite"]) +async def test_non_streamed_model_retry_does_not_rewind_committed_session_input( + tmp_path: Path, session_backend: str +) -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ), + [get_text_message("done")], + ] + ) + agent = Agent( + name="test", + model=model, + model_settings=ModelSettings( + retry=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ) + ), + ) + session: CountingSession | SQLiteSession + if session_backend == "sqlite": + session = SQLiteSession("retry-session", tmp_path / "retry.sqlite3") + await session.add_items([get_text_input_item("previous")]) + else: + session = CountingSession(history=[get_text_input_item("previous")]) + + try: + result = await Runner.run(agent, input="test", session=session) + saved_items = await session.get_items() + finally: + if isinstance(session, SQLiteSession): + session.close() + + assert result.final_output == "done" + assert [item.get("role") for item in saved_items] == ["user", "user", "assistant"] + assert [item.get("content") for item in saved_items[:2]] == ["previous", "test"] + if isinstance(session, CountingSession): + assert session.pop_calls == 0 + + @pytest.mark.asyncio async def test_get_new_response_uses_agent_retry_settings() -> None: model = FakeModel() From aa3ac378b9875ec2492debe44ad9b9e96de6308e Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Fri, 24 Jul 2026 06:41:50 +0530 Subject: [PATCH 006/473] fix(chatcmpl): surface content-filter refusals when buffering streamed tool calls (#3897) --- src/agents/models/chatcmpl_stream_handler.py | 6 + .../test_openai_chatcompletions_stream.py | 254 ++++++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index f365461a06..b960c6542d 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -377,6 +377,12 @@ async def buffer_tool_call_stream( if has_passthrough_output: passthrough_choices.append(choice) + elif choice.finish_reason == "content_filter": + # A content-filtered choice ends the stream with an empty delta, so it + # would otherwise be dropped here and the handler would never see the + # finish_reason it needs to synthesize the refusal. Forward a + # delta-stripped copy so buffering semantics are unchanged. + passthrough_choices.append(choice.model_copy(update={"delta": ChoiceDelta()})) if passthrough_choices or chunk.usage is not None: yield chunk.model_copy(update={"choices": passthrough_choices}) diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 90b01572ac..b435f12982 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -2836,3 +2836,257 @@ async def patched_fetch_response(self, *args, **kwargs): assert isinstance(completed_event.response.output[0], ResponseFunctionToolCall) assert isinstance(completed_event.response.output[1], ResponseFunctionToolCall) assert isinstance(completed_event.response.output[2], ResponseOutputMessage) + + +async def _buffered_stream_events(monkeypatch, chunks: list[ChatCompletionChunk]) -> list[Any]: + """Run the given chunks through the Chat Completions model with tool-call + buffering enabled, returning the streamed events.""" + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for chunk in chunks: + yield chunk + + async def patched_fetch_response(self, *args, **kwargs): + return _empty_response(), fake_stream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider( + use_responses=False, + buffer_streamed_tool_calls=True, + ).get_model("gpt-4") + + return [ + event + async for event in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_buffered_stream_synthesizes_refusal_on_content_filter(monkeypatch) -> None: + """With tool-call buffering enabled, a stream that terminates with + finish_reason == "content_filter" and no emitted content must still + synthesize a ResponseOutputRefusal. + + The buffering layer only forwarded choices whose delta carried output, so the + terminal empty-delta chunk was dropped before the handler could see the + finish_reason, turning a safety block into a silently empty turn. + """ + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(role="assistant", content=""))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="content_filter")], + usage=CompletionUsage(completion_tokens=0, prompt_tokens=7, total_tokens=7), + ) + + output_events = await _buffered_stream_events(monkeypatch, [chunk1, chunk2]) + + types = [e.type for e in output_events] + assert "response.refusal.delta" in types + assert types[-1] == "response.completed" + + refusal_deltas = [e for e in output_events if e.type == "response.refusal.delta"] + assert refusal_deltas and refusal_deltas[0].delta + + # The assistant message is announced once and every opened part is closed. + assert types.count("response.output_item.added") == 1 + assert types.count("response.content_part.added") == types.count("response.content_part.done") + + # The empty "" content delta must not open a text content part. + assert "response.output_text.delta" not in types + added_parts = [e for e in output_events if e.type == "response.content_part.added"] + assert len(added_parts) == 1 + assert isinstance(added_parts[0].part, ResponseOutputRefusal) + + completed_event = output_events[-1] + assert isinstance(completed_event, ResponseCompletedEvent) + assistant_msg = completed_event.response.output[0] + assert isinstance(assistant_msg, ResponseOutputMessage) + assert len(assistant_msg.content) == 1 + refusal_part = assistant_msg.content[0] + assert isinstance(refusal_part, ResponseOutputRefusal) + assert refusal_part.refusal + + # Streamed content_index matches the refusal's position in the completed response. + assert added_parts[0].content_index == 0 + assert refusal_deltas[0].content_index == 0 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_buffered_stream_content_filter_does_not_clobber_text(monkeypatch) -> None: + """A content_filter finish_reason arriving after real text was streamed must + not synthesize a refusal, even with buffering enabled.""" + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="content_filter")], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=7, total_tokens=8), + ) + + output_events = await _buffered_stream_events(monkeypatch, [chunk1, chunk2]) + + assert "response.refusal.delta" not in [e.type for e in output_events] + completed_event = output_events[-1] + assert isinstance(completed_event, ResponseCompletedEvent) + assistant_msg = completed_event.response.output[0] + assert isinstance(assistant_msg, ResponseOutputMessage) + text_part = assistant_msg.content[0] + assert isinstance(text_part, ResponseOutputText) + assert text_part.text == "answer" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_buffered_stream_content_filter_refusal_after_reasoning(monkeypatch) -> None: + """A buffered content_filter turn preceded by reasoning still places the + synthesized refusal at content_index 0 of the assistant message, which is + output_index 1 (the reasoning item is a separate output item).""" + reasoning_delta = ChoiceDelta(role="assistant", content=None) + # reasoning_content is a provider extra field the handler reads via hasattr. + reasoning_delta.reasoning_content = "thinking..." # type: ignore[attr-defined] + chunk_reasoning = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=reasoning_delta)], + ) + chunk_empty = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content=""))], + ) + chunk_filter = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="content_filter")], + usage=CompletionUsage(completion_tokens=0, prompt_tokens=7, total_tokens=7), + ) + + output_events = await _buffered_stream_events( + monkeypatch, [chunk_reasoning, chunk_empty, chunk_filter] + ) + + completed_event = output_events[-1] + assert isinstance(completed_event, ResponseCompletedEvent) + completed_resp = completed_event.response + assert isinstance(completed_resp.output[0], ResponseReasoningItem) + assistant_msg = completed_resp.output[1] + assert isinstance(assistant_msg, ResponseOutputMessage) + assert len(assistant_msg.content) == 1 + assert isinstance(assistant_msg.content[0], ResponseOutputRefusal) + + added = [ + e + for e in output_events + if e.type == "response.content_part.added" and isinstance(e.part, ResponseOutputRefusal) + ] + deltas = [e for e in output_events if e.type == "response.refusal.delta"] + assert len(added) == 1 + assert added[0].content_index == 0 + assert added[0].output_index == 1 + assert deltas and all(d.content_index == 0 and d.output_index == 1 for d in deltas) + assert "response.output_text.delta" not in [e.type for e in output_events] + + +def _chunk_with(choices: list[Choice], usage: CompletionUsage | None = None): + return ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=choices, + usage=usage, + ) + + +@pytest.mark.asyncio +async def test_buffer_tool_call_stream_forwards_content_filter_finish_reason() -> None: + """The buffering layer must forward a content-filtered terminal choice even + though its delta is empty, so the finish_reason reaches the handler instead + of being swallowed. The delta is stripped, preserving buffering semantics.""" + chunks = [ + _chunk_with([Choice(index=0, delta=ChoiceDelta(content=""))]), + _chunk_with([Choice(index=0, delta=ChoiceDelta(), finish_reason="content_filter")]), + ] + + async def source() -> AsyncIterator[ChatCompletionChunk]: + for chunk in chunks: + yield chunk + + buffered = [c async for c in ChatCmplStreamHandler.buffer_tool_call_stream(source())] + + terminal_choices = [ + choice + for chunk in buffered + for choice in chunk.choices + if choice.finish_reason == "content_filter" + ] + assert len(terminal_choices) == 1 + # The forwarded copy carries no delta output. + assert not ChatCmplStreamHandler._delta_has_passthrough_output(terminal_choices[0].delta) + + +@pytest.mark.asyncio +async def test_buffer_tool_call_stream_does_not_duplicate_tool_calls_finish() -> None: + """finish_reason == "tool_calls" is still emitted only by the synthesized + buffered chunk, so the terminal choice is not forwarded twice.""" + tool_call_delta = ChoiceDeltaToolCall( + index=0, + id="tool-id", + function=ChoiceDeltaToolCallFunction(name="my_func", arguments='{"a": 1}'), + type="function", + ) + chunks = [ + _chunk_with([Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta]))]), + _chunk_with([Choice(index=0, delta=ChoiceDelta(), finish_reason="tool_calls")]), + ] + + async def source() -> AsyncIterator[ChatCompletionChunk]: + for chunk in chunks: + yield chunk + + buffered = [c async for c in ChatCmplStreamHandler.buffer_tool_call_stream(source())] + + finish_choices = [ + choice + for chunk in buffered + for choice in chunk.choices + if choice.finish_reason == "tool_calls" + ] + assert len(finish_choices) == 1 + assert finish_choices[0].delta.tool_calls From 658bfc488b6e5c320d3d1f561b5ba6445d0d8d41 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Jul 2026 12:09:31 +0900 Subject: [PATCH 007/473] fix: encode structured programmatic tool errors as JSON (#3932) --- src/agents/run_internal/items.py | 30 +++++++++++- src/agents/run_internal/tool_execution.py | 25 +++++++++- src/agents/run_internal/turn_resolution.py | 1 + tests/test_programmatic_tool_calling.py | 54 ++++++++++++++++++---- tests/test_run_internal_items.py | 47 +++++++++++++++++++ 5 files changed, 144 insertions(+), 13 deletions(-) diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index 3ae8980840..bc2f623d3c 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -75,6 +75,7 @@ "deduplicate_input_items", "deduplicate_input_items_preferring_latest", "strip_internal_input_item_metadata", + "function_tool_error_output", "function_rejection_item", "shell_rejection_item", "apply_patch_rejection_item", @@ -733,22 +734,49 @@ def deduplicate_input_items_preferring_latest( return list(reversed(deduplicate_input_items(list(reversed(items))))) +def function_tool_error_output( + tool_call: Any, + output: Any, + *, + output_json_schema: dict[str, Any] | None, +) -> Any: + """Encode SDK-generated programmatic tool errors as provider-compatible JSON objects.""" + if output_json_schema is None or not isinstance(output, str): + return output + + if isinstance(tool_call, dict): + caller = tool_call.get("caller") + else: + caller = getattr(tool_call, "caller", None) + caller_type = caller.get("type") if isinstance(caller, dict) else getattr(caller, "type", None) + if caller_type != "program": + return output + + return json.dumps({"error": output}, ensure_ascii=False, separators=(",", ":")) + + def function_rejection_item( agent: Any, tool_call: Any, *, rejection_message: str = REJECTION_MESSAGE, + output_json_schema: dict[str, Any] | None = None, scope_id: str | None = None, tool_origin: Any = None, ) -> ToolCallOutputItem: """Build a ToolCallOutputItem representing a rejected function tool call.""" if isinstance(tool_call, ResponseFunctionToolCall): drop_agent_tool_run_result(tool_call, scope_id=scope_id) + provider_output = function_tool_error_output( + tool_call, + rejection_message, + output_json_schema=output_json_schema, + ) return ToolCallOutputItem( output=rejection_message, raw_item=ItemHelpers.tool_call_output_item( tool_call, - rejection_message, + provider_output, ), agent=agent, tool_origin=tool_origin, diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index b8e5b301f3..3eea93d8d6 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -104,6 +104,7 @@ extract_mcp_request_id, extract_mcp_request_id_from_run, function_rejection_item, + function_tool_error_output, ) from .run_steps import ToolRunFunction from .tool_use_tracker import AgentToolUseTracker @@ -1729,6 +1730,7 @@ async def _maybe_execute_tool_approval( self.public_agent, tool_call, rejection_message=rejected_message, + output_json_schema=func_tool.output_json_schema, scope_id=self.tool_state_scope_id, tool_origin=get_function_tool_origin(func_tool), ), @@ -1778,6 +1780,7 @@ async def _maybe_execute_tool_approval( self.public_agent, tool_call, rejection_message=rejection_message, + output_json_schema=func_tool.output_json_schema, scope_id=self.tool_state_scope_id, tool_origin=get_function_tool_origin(func_tool), ), @@ -1891,9 +1894,18 @@ async def _invoke_tool_and_run_post_invoke( bypass_output_schema = bypass_output_schema or (output_guardrail_result.is_rejection) if bypass_output_schema: self.schema_bypassed_tool_runs.add(id(task_state.tool_run)) + provider_result = ( + function_tool_error_output( + tool_call, + final_result, + output_json_schema=func_tool.output_json_schema, + ) + if bypass_output_schema + else final_result + ) raw_output_item = ItemHelpers.tool_call_output_item( tool_call, - final_result, + provider_result, output_json_schema=None if bypass_output_schema else func_tool.output_json_schema, output_type_adapter=None if bypass_output_schema else func_tool._output_type_adapter, ) @@ -2012,11 +2024,20 @@ def _build_function_tool_results(self) -> list[FunctionToolResult]: run_item: RunItem | None if not nested_interruptions: + provider_result = ( + function_tool_error_output( + tool_run.tool_call, + result, + output_json_schema=tool_run.function_tool.output_json_schema, + ) + if bypass_output_schema + else result + ) run_item = ToolCallOutputItem( output=result, raw_item=ItemHelpers.tool_call_output_item( tool_run.tool_call, - result, + provider_result, output_json_schema=( None if bypass_output_schema diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 3fb41b0596..f275f2e857 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -1051,6 +1051,7 @@ async def _record_function_rejection( public_agent, tool_call, rejection_message=rejection_message, + output_json_schema=function_tool.output_json_schema, scope_id=tool_state_scope_id, tool_origin=get_function_tool_origin(function_tool), ) diff --git a/tests/test_programmatic_tool_calling.py b/tests/test_programmatic_tool_calling.py index 13beeacc82..d7830a28bf 100644 --- a/tests/test_programmatic_tool_calling.py +++ b/tests/test_programmatic_tool_calling.py @@ -1934,7 +1934,7 @@ def lookup_inventory(sku: str) -> InventoryOutput: assert result.final_output == "request rejected" function_outputs = _function_output_raw_items(result) assert len(function_outputs) == 1 - assert function_outputs[0]["output"] == "inventory lookup blocked" + assert json.loads(function_outputs[0]["output"]) == {"error": "inventory lookup blocked"} assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER @@ -1964,8 +1964,9 @@ async def lookup_inventory(sku: str) -> InventoryOutput: assert result.final_output == "request timed out" function_outputs = _function_output_raw_items(result) assert len(function_outputs) == 1 - assert isinstance(function_outputs[0]["output"], str) - assert "timed out" in function_outputs[0]["output"].lower() + timeout_output = json.loads(function_outputs[0]["output"]) + assert isinstance(timeout_output, dict) + assert "timed out" in timeout_output["error"].lower() assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER @@ -2001,12 +2002,23 @@ def lookup_inventory(sku: str) -> InventoryOutput: assert result.final_output == "request rejected" function_outputs = _function_output_raw_items(result) assert len(function_outputs) == 1 - assert function_outputs[0]["output"] == "inventory result blocked" + assert json.loads(function_outputs[0]["output"]) == {"error": "inventory result blocked"} assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER @pytest.mark.asyncio -async def test_typed_programmatic_tool_preserves_approval_rejection() -> None: +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +@pytest.mark.parametrize("serialize_state", [False, True], ids=["in-memory", "serialized"]) +@pytest.mark.parametrize( + "rejection_message", + [None, 'Denied: "東京"'], + ids=["default-rejection", "custom-rejection"], +) +async def test_typed_programmatic_tool_preserves_approval_rejection( + streaming: bool, + serialize_state: bool, + rejection_message: str | None, +) -> None: model = FakeModel() model.add_multiple_turn_outputs( [ @@ -2024,18 +2036,40 @@ def lookup_inventory(sku: str) -> InventoryOutput: model=model, tools=[ProgrammaticToolCallingTool(), lookup_inventory], ) - first_result = await Runner.run(agent, "Check inventory") + first_result: Any + if streaming: + first_result = Runner.run_streamed(agent, "Check inventory") + async for _event in first_result.stream_events(): + pass + else: + first_result = await Runner.run(agent, "Check inventory") assert len(first_result.interruptions) == 1 state = first_result.to_state() - state.reject(first_result.interruptions[0]) - result = await Runner.run(agent, state) + if serialize_state: + state = await RunState.from_json(agent, state.to_json()) + state.reject(state.get_interruptions()[0], rejection_message=rejection_message) + result: Any + if streaming: + result = Runner.run_streamed(agent, state) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, state) assert result.final_output == "request rejected" function_outputs = _function_output_raw_items(result) assert len(function_outputs) == 1 - assert function_outputs[0]["output"] == "Tool execution was not approved." + expected_message = rejection_message or "Tool execution was not approved." + assert json.loads(function_outputs[0]["output"]) == {"error": expected_message} assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER + assert model.last_turn_args is not None + replayed_output = next( + item + for item in model.last_turn_args["input"] + if isinstance(item, dict) and item.get("type") == "function_call_output" + ) + assert json.loads(replayed_output["output"]) == {"error": expected_message} @pytest.mark.asyncio @@ -2199,7 +2233,7 @@ def lookup_inventory(sku: str) -> InventoryOutput: assert result.final_output == "request rejected" function_outputs = _function_output_raw_items(result) assert len(function_outputs) == 1 - assert function_outputs[0]["output"] == "inventory lookup blocked" + assert json.loads(function_outputs[0]["output"]) == {"error": "inventory lookup blocked"} assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index 235560c67c..d58830092c 100644 --- a/tests/test_run_internal_items.py +++ b/tests/test_run_internal_items.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +import json from typing import Any, cast import pytest @@ -9,6 +10,7 @@ ResponseToolSearchCall, ResponseToolSearchOutputItem, ) +from openai.types.responses.response_function_tool_call import CallerProgram from openai.types.responses.response_reasoning_item import ResponseReasoningItem from agents import Agent @@ -27,6 +29,51 @@ from agents.run_internal import items as run_items +@pytest.mark.parametrize("mapping_call", [False, True], ids=["typed-call", "mapping-call"]) +def test_programmatic_structured_tool_errors_are_encoded_as_json_objects( + mapping_call: bool, +) -> None: + caller = {"type": "program", "caller_id": "program-42"} + tool_call: Any + if mapping_call: + tool_call = {"type": "function_call", "call_id": "call-42", "caller": caller} + else: + tool_call = ResponseFunctionToolCall( + type="function_call", + call_id="call-42", + name="lookup", + arguments="{}", + caller=CallerProgram(type="program", caller_id="program-42"), + ) + + output = run_items.function_tool_error_output( + tool_call, + 'Rejected: "東京"', + output_json_schema={"type": "object"}, + ) + + assert json.loads(output) == {"error": 'Rejected: "東京"'} + + +@pytest.mark.parametrize("caller", [None, {"type": "direct"}], ids=["no-caller", "direct"]) +@pytest.mark.parametrize("has_schema", [False, True], ids=["untyped", "typed"]) +def test_direct_function_tool_errors_preserve_plain_text( + caller: dict[str, str] | None, + has_schema: bool, +) -> None: + tool_call: dict[str, Any] = {"type": "function_call", "call_id": "call-42"} + if caller is not None: + tool_call["caller"] = caller + + output = run_items.function_tool_error_output( + tool_call, + "Request rejected.", + output_json_schema={"type": "object"} if has_schema else None, + ) + + assert output == "Request rejected." + + def test_drop_orphan_function_calls_preserves_non_mapping_entries() -> None: payload: list[Any] = [ cast(TResponseInputItem, "plain-text-input"), From 5d620569a78b84f7d8153ea72e13eb654431e4a6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Jul 2026 15:39:28 +0900 Subject: [PATCH 008/473] feat: add packaged live integration and provider regression coverage (#3936) --- .agents/skills/integration-tests/SKILL.md | 64 +++ .../integration-tests/agents/openai.yaml | 4 + .github/scripts/run_integration_tests.py | 234 ++++++++ Makefile | 56 ++ integration_tests/README.md | 28 + integration_tests/conftest.py | 233 ++++++++ .../hosted/test_code_interpreter.py | 73 +++ .../hosted/test_local_tool_families.py | 203 +++++++ integration_tests/hosted/test_mcp.py | 158 ++++++ integration_tests/hosted/test_multi_agent.py | 69 +++ .../hosted/test_programmatic_tool_calling.py | 291 ++++++++++ integration_tests/hosted/test_tool_search.py | 124 +++++ integration_tests/hosted/test_web_search.py | 31 ++ .../openai/test_approval_resume.py | 172 ++++++ .../openai/test_chat_completions.py | 104 ++++ .../openai/test_execution_controls.py | 492 +++++++++++++++++ integration_tests/openai/test_guardrails.py | 192 +++++++ integration_tests/openai/test_handoffs.py | 111 ++++ .../openai/test_model_settings.py | 109 ++++ integration_tests/openai/test_responses.py | 189 +++++++ integration_tests/openai/test_retry.py | 80 +++ integration_tests/openai/test_sessions.py | 310 +++++++++++ integration_tests/openai/test_tracing.py | 109 ++++ integration_tests/openai/test_websocket.py | 72 +++ .../packaging/test_distribution_contents.py | 179 ++++++ .../packaging/test_optional_extras.py | 61 +++ .../packaging/test_provider_selection.py | 258 +++++++++ integration_tests/providers/test_any_llm.py | 177 ++++++ integration_tests/providers/test_litellm.py | 189 +++++++ integration_tests/pytest.ini | 16 + integration_tests/realtime/test_realtime.py | 515 ++++++++++++++++++ .../voice/test_voice_pipeline.py | 262 +++++++++ pyproject.toml | 9 + 33 files changed, 5174 insertions(+) create mode 100644 .agents/skills/integration-tests/SKILL.md create mode 100644 .agents/skills/integration-tests/agents/openai.yaml create mode 100644 .github/scripts/run_integration_tests.py create mode 100644 integration_tests/README.md create mode 100644 integration_tests/conftest.py create mode 100644 integration_tests/hosted/test_code_interpreter.py create mode 100644 integration_tests/hosted/test_local_tool_families.py create mode 100644 integration_tests/hosted/test_mcp.py create mode 100644 integration_tests/hosted/test_multi_agent.py create mode 100644 integration_tests/hosted/test_programmatic_tool_calling.py create mode 100644 integration_tests/hosted/test_tool_search.py create mode 100644 integration_tests/hosted/test_web_search.py create mode 100644 integration_tests/openai/test_approval_resume.py create mode 100644 integration_tests/openai/test_chat_completions.py create mode 100644 integration_tests/openai/test_execution_controls.py create mode 100644 integration_tests/openai/test_guardrails.py create mode 100644 integration_tests/openai/test_handoffs.py create mode 100644 integration_tests/openai/test_model_settings.py create mode 100644 integration_tests/openai/test_responses.py create mode 100644 integration_tests/openai/test_retry.py create mode 100644 integration_tests/openai/test_sessions.py create mode 100644 integration_tests/openai/test_tracing.py create mode 100644 integration_tests/openai/test_websocket.py create mode 100644 integration_tests/packaging/test_distribution_contents.py create mode 100644 integration_tests/packaging/test_optional_extras.py create mode 100644 integration_tests/packaging/test_provider_selection.py create mode 100644 integration_tests/providers/test_any_llm.py create mode 100644 integration_tests/providers/test_litellm.py create mode 100644 integration_tests/pytest.ini create mode 100644 integration_tests/realtime/test_realtime.py create mode 100644 integration_tests/voice/test_voice_pipeline.py diff --git a/.agents/skills/integration-tests/SKILL.md b/.agents/skills/integration-tests/SKILL.md new file mode 100644 index 0000000000..1866d674cb --- /dev/null +++ b/.agents/skills/integration-tests/SKILL.md @@ -0,0 +1,64 @@ +--- +name: integration-tests +description: Run the packaged OpenAI Agents Python SDK integration tests from clean wheel and source-distribution environments. Use for release readiness, live OpenAI regression checks, package import compatibility, optional-extra validation, or when asked to run integration tests after examples-auto-run. +--- + +# Integration Tests + +## Overview + +Run the release-oriented integration suite against the exact wheel and source distribution produced by `uv build`. The runner installs both artifacts into isolated environments and validates supported imports, optional extras, OpenAI model adapters, hosted tools, Realtime, and voice workflows. + +## Execution requirements + +- Fresh isolated environments download optional dependencies from PyPI and connect to the configured API providers. +- When the execution environment requires approval for package downloads or configured provider connections, request elevated command execution (`sandbox_permissions=require_escalated`). Retry with the required network permissions before classifying a connectivity failure as an SDK regression. + +## Release workflow + +Run this command from the repository root: + +```bash +env UV_DEFAULT_INDEX=https://pypi.org/simple \ + OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS=1 \ + OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS=0 \ + make integration-tests-release +``` + +- Use the release profile as the default whenever `$integration-tests` is invoked without a narrower request. +- Use OpenRouter as the standard multi-provider gateway. Add provider-specific direct connections only when the user explicitly requests that additional credential matrix. +- Use existing `OPENAI_API_KEY` and `OPENROUTER_API_KEY` values without printing them. Missing optional service configuration may skip capability-specific tests unless strict mode was explicitly requested. +- The command rebuilds the wheel and source distribution, creates isolated virtual environments, checks public imports and optional dependencies, and runs the release-oriented live suites. +- Do not run watch mode, modify source files, create a branch, commit, push, or open a pull request as part of this skill. + +## Paired release validation + +When the user requests both pre-release checks, run `$examples-auto-run` first and follow that skill's required per-example behavioral validation. Then run the command above and report the examples and integration outcomes separately. Invoking `$integration-tests` alone does not implicitly start the examples suite. + +## Focused commands + +Use a focused target only when the user specifically asks to narrow the run: + +```bash +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-packaging +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-core +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-providers +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-hosted +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-realtime +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-voice +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-extras +``` + +For the minimum supported Python package boundary, use: + +```bash +env UV_DEFAULT_INDEX=https://pypi.org/simple \ + OPENAI_AGENTS_INTEGRATION_PYTHON=3.10 \ + make integration-tests-packaging +``` + +Nightly and manual profiles include additional capability-specific or higher-cost checks. Run them only when explicitly requested; use the configured OpenRouter matrix by default and include direct providers only when explicitly selected. + +## Reporting + +Report the final pass, fail, skip, and deselection counts for each isolated environment. If a command fails, identify the exact profile, package environment, failing test, and actionable error. Separate product regressions from missing credentials, unsupported hosted features, dependency installation failures, and execution-environment restrictions. diff --git a/.agents/skills/integration-tests/agents/openai.yaml b/.agents/skills/integration-tests/agents/openai.yaml new file mode 100644 index 0000000000..cd918c14f2 --- /dev/null +++ b/.agents/skills/integration-tests/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Integration Tests" + short_description: "Run packaged Python SDK integration tests" + default_prompt: "Use $integration-tests to run the packaged Python SDK integration suite." diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py new file mode 100644 index 0000000000..28aa259d71 --- /dev/null +++ b/.github/scripts/run_integration_tests.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +WORKSPACE = ROOT / ".tmp" / "integration-tests" +DIST = WORKSPACE / "dist" +TESTS = ROOT / "integration_tests" +EXTRAS = "any-llm,litellm,realtime,voice" +OPTIONAL_EXTRAS = ( + "any-llm", + "litellm", + "realtime", + "voice", + "sqlalchemy", + "encrypt", + "redis", + "viz", + "s3", +) +PROFILES = ( + "packaging", + "core", + "providers", + "realtime", + "voice", + "hosted", + "extras", + "full", + "release", + "nightly", + "manual", +) + + +def run(command: list[str], *, env: dict[str, str] | None = None) -> None: + print(f"[integration] {' '.join(command)}", flush=True) + subprocess.run(command, cwd=ROOT, env=env, check=True) + + +def build_distributions() -> tuple[Path, Path]: + DIST.mkdir(parents=True, exist_ok=True) + run(["uv", "build", "--out-dir", str(DIST)]) + wheels = sorted(DIST.glob("openai_agents-*.whl"), key=lambda path: path.stat().st_mtime) + sdists = sorted(DIST.glob("openai_agents-*.tar.gz"), key=lambda path: path.stat().st_mtime) + if not wheels or not sdists: + raise RuntimeError("uv build did not produce both an openai-agents wheel and sdist.") + return wheels[-1], sdists[-1] + + +def _any_llm_provider_extras( + *, external_providers_enabled: bool, direct_providers_enabled: bool +) -> list[str]: + provider_extras: set[str] = set() + configured_models = os.environ.get("OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS", "") + for model in configured_models.split(","): + provider = model.strip().partition("/")[0] + if provider in {"anthropic", "openrouter"}: + provider_extras.add(provider) + elif provider in {"gemini", "google"}: + provider_extras.add("gemini") + + if external_providers_enabled: + if direct_providers_enabled and os.environ.get("ANTHROPIC_API_KEY"): + provider_extras.add("anthropic") + if direct_providers_enabled and ( + os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") + ): + provider_extras.add("gemini") + if os.environ.get("OPENROUTER_API_KEY"): + provider_extras.add("openrouter") + + return sorted(provider_extras) + + +def create_environment( + name: str, distribution: Path, *, extras: bool = False, optional_extra: str | None = None +) -> Path: + environment = WORKSPACE / name + venv_command = ["uv", "venv", "--clear", str(environment)] + if python_version := os.environ.get("OPENAI_AGENTS_INTEGRATION_PYTHON"): + venv_command.extend(["--python", python_version]) + run(venv_command) + python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + selected_extra = EXTRAS if extras else optional_extra + requirement = f"{distribution}[{selected_extra}]" if selected_extra else str(distribution) + requirements = [requirement, "pytest", "pytest-asyncio", "pytest-timeout"] + external_providers_enabled = os.environ.get( + "OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "" + ).lower() in {"1", "true", "yes"} + direct_providers_enabled = os.environ.get( + "OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS", "" + ).lower() in {"1", "true", "yes"} + if extras: + any_llm_extras = _any_llm_provider_extras( + external_providers_enabled=external_providers_enabled, + direct_providers_enabled=direct_providers_enabled, + ) + if any_llm_extras: + requirements.append(f"any-llm-sdk[{','.join(any_llm_extras)}]") + proxy_values = [ + os.environ.get(name, "") + for name in ( + "ALL_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "http_proxy", + "https_proxy", + ) + ] + if any(value.lower().startswith("socks") for value in proxy_values): + requirements.append("httpx[socks]") + run(["uv", "pip", "install", "--python", str(python), *requirements]) + return python + + +def run_suite( + python: Path, + wheel: Path, + sdist: Path, + *, + selection: str, + environment_kind: str, +) -> None: + child_env = dict(os.environ) + child_env.pop("PYTHONPATH", None) + if child_env.get("OPENAI_AGENTS_INTEGRATION_DISABLE_PROXY", "").lower() in { + "1", + "true", + "yes", + }: + for variable in ( + "ALL_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "http_proxy", + "https_proxy", + ): + child_env.pop(variable, None) + child_env["PYTHONNOUSERSITE"] = "1" + child_env["OPENAI_AGENTS_INTEGRATION_WHEEL"] = str(wheel) + child_env["OPENAI_AGENTS_INTEGRATION_SDIST"] = str(sdist) + child_env["OPENAI_AGENTS_INTEGRATION_ENVIRONMENT"] = environment_kind + if environment_kind.startswith("extra-"): + child_env["OPENAI_AGENTS_INTEGRATION_EXTRA"] = environment_kind.removeprefix("extra-") + if not os.environ.get("OPENAI_AGENTS_INTEGRATION_ENABLE_TRACING"): + child_env["OPENAI_AGENTS_DISABLE_TRACING"] = "1" + command = [ + str(python), + "-I", + "-m", + "pytest", + "-c", + str(TESTS / "pytest.ini"), + str(TESTS), + "-v", + "--tb=short", + "-m", + selection, + ] + run(command, env=child_env) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run packaged openai-agents integration tests.") + parser.add_argument("--profile", choices=PROFILES, default="full") + parser.add_argument( + "--all", + action="store_true", + help="Include configured direct Anthropic and Gemini providers alongside OpenRouter.", + ) + args = parser.parse_args() + if args.all: + os.environ["OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS"] = "1" + os.environ["OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS"] = "1" + wheel, sdist = build_distributions() + print(f"[integration] wheel={wheel.name} sdist={sdist.name} profile={args.profile}") + + if args.profile in {"packaging", "core", "hosted", "full", "release", "nightly", "manual"}: + python = create_environment("core", wheel) + selections = { + "packaging": "packaging", + "core": "packaging or core", + "hosted": "packaging or hosted", + "full": "packaging or ((core or hosted) and not nightly and not manual)", + "release": "packaging or ((core or hosted) and not nightly and not manual)", + "nightly": "packaging or ((core or hosted) and not manual)", + "manual": "packaging or core or hosted", + } + run_suite( + python, + wheel, + sdist, + selection=selections[args.profile], + environment_kind="core", + ) + + if args.profile in {"providers", "realtime", "voice", "full", "release", "nightly", "manual"}: + python = create_environment("extended", wheel, extras=True) + if args.profile in {"full", "release"}: + selection = "(providers or realtime or voice) and not nightly and not manual" + elif args.profile == "nightly": + selection = "(providers or realtime or voice) and not manual" + elif args.profile == "manual": + selection = "providers or realtime or voice" + else: + selection = args.profile + run_suite( + python, + wheel, + sdist, + selection=selection, + environment_kind="extended", + ) + + if args.profile in {"packaging", "full", "release", "nightly", "manual"}: + python = create_environment("sdist", sdist) + run_suite(python, wheel, sdist, selection="packaging", environment_kind="sdist") + + if args.profile in {"extras", "full", "release", "nightly", "manual"}: + for optional_extra in OPTIONAL_EXTRAS: + environment_kind = f"extra-{optional_extra}" + python = create_environment(environment_kind, wheel, optional_extra=optional_extra) + run_suite(python, wheel, sdist, selection="extras", environment_kind=environment_kind) + + +if __name__ == "__main__": + main() diff --git a/Makefile b/Makefile index e0f2b64383..daa1745f56 100644 --- a/Makefile +++ b/Makefile @@ -55,6 +55,62 @@ tests-parallel: tests-serial: uv run pytest -m serial +.PHONY: integration-tests +integration-tests: + uv run python .github/scripts/run_integration_tests.py --profile full $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-release +integration-tests-release: + uv run python .github/scripts/run_integration_tests.py --profile release $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-nightly +integration-tests-nightly: + uv run python .github/scripts/run_integration_tests.py --profile nightly $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-manual +integration-tests-manual: + uv run python .github/scripts/run_integration_tests.py --profile manual $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-packaging +integration-tests-packaging: + uv run python .github/scripts/run_integration_tests.py --profile packaging + +.PHONY: integration-tests-core +integration-tests-core: + uv run python .github/scripts/run_integration_tests.py --profile core + +.PHONY: integration-tests-providers +integration-tests-providers: + uv run python .github/scripts/run_integration_tests.py --profile providers $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-providers-external +integration-tests-providers-external: + OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS=1 uv run python .github/scripts/run_integration_tests.py --profile providers $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-providers-all +integration-tests-providers-all: + uv run python .github/scripts/run_integration_tests.py --profile providers --all + +.PHONY: --all +--all: + @: + +.PHONY: integration-tests-realtime +integration-tests-realtime: + uv run python .github/scripts/run_integration_tests.py --profile realtime + +.PHONY: integration-tests-voice +integration-tests-voice: + uv run python .github/scripts/run_integration_tests.py --profile voice + +.PHONY: integration-tests-hosted +integration-tests-hosted: + uv run python .github/scripts/run_integration_tests.py --profile hosted + +.PHONY: integration-tests-extras +integration-tests-extras: + uv run python .github/scripts/run_integration_tests.py --profile extras + .PHONY: coverage coverage: diff --git a/integration_tests/README.md b/integration_tests/README.md new file mode 100644 index 0000000000..4d5db77f50 --- /dev/null +++ b/integration_tests/README.md @@ -0,0 +1,28 @@ +# Packaged live integration tests + +These tests exercise the exact wheel produced by `uv build` after installing it into clean virtual environments. The `integration_tests/` directory, repository automation metadata, and local dependency/type-checking caches are excluded from published distributions. + +Run the complete release-oriented matrix with: + + export UV_DEFAULT_INDEX=https://pypi.org/simple + make integration-tests + +`make integration-tests-release` runs the same release-safe matrix explicitly. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. + +Invoke the repository-local `$integration-tests` skill to run the release profile with configured OpenRouter-backed provider checks. OpenRouter provides a single configured gateway for the standard multi-provider matrix; provider-specific direct connections are optional extensions selected explicitly. When a release review also requires runnable examples, run `$examples-auto-run` first and then `$integration-tests`. + +Set `OPENAI_API_KEY` for live OpenAI calls. Override `OPENAI_AGENTS_INTEGRATION_MODEL`, `OPENAI_AGENTS_INTEGRATION_REALTIME_MODEL`, `OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS`, and `OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS` when testing different models or configured providers. Provider model lists contain comma-separated adapter model names and require the credentials matching each selected provider. Set `OPENAI_AGENTS_INTEGRATION_MCP_SERVER_URL` to use another trusted DeepWiki-compatible hosted MCP server that exposes the `ask_question` tool and can answer questions about the `openai/openai-agents-python` repository. + +Run `make integration-tests-providers-external` with `OPENROUTER_API_KEY` to exercise current OpenAI, Anthropic, and Google models through one provider gateway. To extend the matrix with separately configured direct-provider credentials, use `make integration-tests-providers-external -- --all`, `make integration-tests-providers-all`, or `uv run python .github/scripts/run_integration_tests.py --profile providers --all`. Set `ANTHROPIC_API_KEY` and `GEMINI_API_KEY` or `GOOGLE_API_KEY` for the direct providers you want to include. Override `OPENAI_AGENTS_INTEGRATION_ANTHROPIC_MODEL`, `OPENAI_AGENTS_INTEGRATION_GEMINI_MODEL`, or the comma-separated `OPENAI_AGENTS_INTEGRATION_OPENROUTER_MODELS` to select provider models. + +The default general model is `gpt-5.6`, while LiteLLM function-tool cases use the Chat Completions-native `openai/gpt-4.1-mini`. This avoids LiteLLM's separate Responses API bridge and keeps the adapter regression focused on its actual Chat Completions contract. + +When the host requires a SOCKS proxy, the runner installs `httpx[socks]` as a test-harness dependency without changing the SDK's published requirements. Set `OPENAI_AGENTS_INTEGRATION_DISABLE_PROXY=1` when the selected environment should connect without inherited proxy settings. + +Set `OPENAI_AGENTS_INTEGRATION_STRICT=1` to fail rather than skip when a requested live feature is not configured. Integration tests never run as part of ordinary `make tests`. + +Each live test has a 75-second timeout so a stalled provider connection cannot block a release review indefinitely. + +Set `OPENAI_AGENTS_INTEGRATION_PYTHON` to choose the Python interpreter used for isolated environments. For example, `OPENAI_AGENTS_INTEGRATION_PYTHON=3.10 make integration-tests-packaging` verifies the minimum supported Python package and import boundary; use Python 3.11 or newer for the full adapter matrix because the AnyLLM extra requires Python 3.11. + +The release suite also covers canonical and supported legacy public-import identity, client-side handoffs, nested agents as tools, custom and shell tools, namespaced tool search, approval/rejection plus serialized `RunState` resume, durable SQLite sessions, explicit and server-managed conversation continuation, controlled retries, input/output and tool guardrails, explicit prompt caching, structured streaming output, provider token logprobs, hosted web search/MCP approval, hosted multi-agent streaming, programmatic-tool streaming/handoffs, multi-turn Realtime history, usage, handoffs, agent updates, voice failure propagation, and independent installation of each selected optional dependency group. The nightly profile adds extended approval matrices, parallel tool concurrency, stateless reasoning replay, reusable Responses WebSocket sessions, collected trace trees, streamed provider tool calls, Realtime audio/guardrails, and streamed-input voice pipelines. diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py new file mode 100644 index 0000000000..9eeeb2d7d6 --- /dev/null +++ b/integration_tests/conftest.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import importlib +import os +import sys +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import pytest + +LIVE_MARKERS = frozenset({"core", "providers", "realtime", "voice", "hosted"}) + + +@dataclass(frozen=True) +class ExternalProvider: + name: str + model: str + api_key_name: str + + @property + def api_key(self) -> str: + return os.environ[self.api_key_name] + + +def _external_providers_enabled() -> bool: + return os.environ.get("OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "").lower() in { + "1", + "true", + "yes", + } + + +def _direct_providers_enabled() -> bool: + return os.environ.get("OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS", "").lower() in { + "1", + "true", + "yes", + } + + +def _external_providers() -> list[ExternalProvider]: + if not _external_providers_enabled(): + return [] + + providers: list[ExternalProvider] = [] + if os.environ.get("OPENROUTER_API_KEY"): + configured = os.environ.get( + "OPENAI_AGENTS_INTEGRATION_OPENROUTER_MODELS", + "openai/gpt-5.6-luna,anthropic/claude-sonnet-5,google/gemini-3.6-flash", + ) + for model in configured.split(","): + if model.strip(): + providers.append( + ExternalProvider( + name=f"openrouter-{model.strip().replace('/', '-')}", + model=f"openrouter/{model.strip()}", + api_key_name="OPENROUTER_API_KEY", + ) + ) + + if _direct_providers_enabled(): + if os.environ.get("ANTHROPIC_API_KEY"): + providers.append( + ExternalProvider( + name="anthropic", + model="anthropic/" + + os.environ.get( + "OPENAI_AGENTS_INTEGRATION_ANTHROPIC_MODEL", "claude-sonnet-5" + ), + api_key_name="ANTHROPIC_API_KEY", + ) + ) + + gemini_key = "GEMINI_API_KEY" if os.environ.get("GEMINI_API_KEY") else "GOOGLE_API_KEY" + if os.environ.get(gemini_key): + providers.append( + ExternalProvider( + name="gemini", + model="gemini/" + + os.environ.get("OPENAI_AGENTS_INTEGRATION_GEMINI_MODEL", "gemini-3.6-flash"), + api_key_name=gemini_key, + ) + ) + + return providers + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "external_provider" not in metafunc.fixturenames: + return + + providers = _external_providers() + if providers: + metafunc.parametrize("external_provider", providers, ids=[item.name for item in providers]) + return + + metafunc.parametrize("external_provider", [None], ids=["unconfigured"]) + + +def _strict() -> bool: + return os.environ.get("OPENAI_AGENTS_INTEGRATION_STRICT", "").lower() in { + "1", + "true", + "yes", + } + + +def skip_or_fail(reason: str) -> None: + if _strict(): + pytest.fail(reason) + pytest.skip(reason) + + +def _provider_model_credentials(model: str) -> tuple[str, ...]: + provider = model.partition("/")[0] + if provider == "openrouter": + return ("OPENROUTER_API_KEY",) + if provider == "anthropic": + return ("ANTHROPIC_API_KEY",) + if provider in {"gemini", "google"}: + return ("GEMINI_API_KEY", "GOOGLE_API_KEY") + return ("OPENAI_API_KEY",) + + +def _has_provider_credential(credential: str) -> bool: + value = os.environ.get(credential) + if credential == "OPENAI_API_KEY": + return value not in {None, "", "test_key", "fake-for-tests"} + return bool(value) + + +def pytest_runtest_setup(item: pytest.Item) -> None: + if not any(item.get_closest_marker(marker) for marker in LIVE_MARKERS): + return + if item.get_closest_marker("providers"): + fixture_names = getattr(item, "fixturenames", ()) + if "external_provider" in fixture_names: + callspec = getattr(item, "callspec", None) + provider = getattr(callspec, "params", {}).get("external_provider") + if provider is None: + if _external_providers_enabled(): + skip_or_fail( + "External provider coverage requires OPENROUTER_API_KEY or explicitly " + "enabled direct-provider credentials." + ) + pytest.skip( + "Enable external provider coverage and set OPENROUTER_API_KEY, " + "or explicitly include configured direct providers." + ) + return + for fixture_name, environment_name in ( + ("any_llm_models", "OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS"), + ("litellm_models", "OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS"), + ): + if fixture_name not in fixture_names: + continue + configured_models = os.environ.get(environment_name, "") + if not configured_models.strip(): + break + for model in configured_models.split(","): + if not model.strip(): + continue + credentials = _provider_model_credentials(model.strip()) + if not any(_has_provider_credential(credential) for credential in credentials): + skip_or_fail( + f"Set {' or '.join(credentials)} to execute configured provider " + f"model {model.strip()!r}." + ) + return + if os.environ.get("OPENAI_API_KEY") in {None, "", "test_key", "fake-for-tests"}: + skip_or_fail("Set a real OPENAI_API_KEY to execute live integration tests.") + + +@pytest.fixture(scope="session", autouse=True) +def verify_installed_sdk() -> Iterator[None]: + agents = importlib.import_module("agents") + if agents.__file__ is None: + pytest.fail("agents does not expose an installed module path.") + installed_path = Path(agents.__file__).resolve() + environment = Path(sys.prefix).resolve() + if not installed_path.is_relative_to(environment): + pytest.fail(f"agents resolved outside the isolated environment: {installed_path}") + if "site-packages" not in installed_path.parts: + pytest.fail(f"agents did not resolve from an installed distribution: {installed_path}") + yield + + +@pytest.fixture(scope="session") +def integration_model() -> str: + return os.environ.get("OPENAI_AGENTS_INTEGRATION_MODEL", "gpt-5.6") + + +@pytest.fixture(scope="session") +def integration_realtime_model() -> str: + return os.environ.get("OPENAI_AGENTS_INTEGRATION_REALTIME_MODEL", "gpt-realtime-2.1") + + +@pytest.fixture(scope="session") +def any_llm_models(integration_model: str) -> list[str]: + configured = os.environ.get("OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS", "") + return [model.strip() for model in configured.split(",") if model.strip()] or [ + f"openai/{integration_model}" + ] + + +@pytest.fixture(scope="session") +def litellm_models() -> list[str]: + configured = os.environ.get("OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS", "") + return [model.strip() for model in configured.split(",") if model.strip()] or [ + "openai/gpt-4.1-mini" + ] + + +@pytest.fixture(scope="session") +async def integration_pcm_audio() -> bytes: + from openai import AsyncOpenAI + + client = AsyncOpenAI() + audio = bytearray() + request = client.audio.speech.with_streaming_response.create( + model="gpt-4o-mini-tts", + voice="alloy", + input="Please say the words packaged voice ready.", + response_format="pcm", + ) + async with request as response: + async for chunk in response.iter_bytes(): + audio.extend(chunk) + + if len(audio) % 2: + audio.append(0) + return bytes(audio) diff --git a/integration_tests/hosted/test_code_interpreter.py b/integration_tests/hosted/test_code_interpreter.py new file mode 100644 index 0000000000..b102ed891c --- /dev/null +++ b/integration_tests/hosted/test_code_interpreter.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import pytest +from openai.types.responses import ResponseReasoningItem + +from agents import Agent, CodeInterpreterTool, RunConfig, Runner +from agents.items import ToolCallItem + +pytestmark = pytest.mark.hosted + + +async def test_code_interpreter_reasoning_items_survive_follow_up_replay( + integration_model: str, +) -> None: + agent = Agent( + name="Packaged code interpreter agent", + model=integration_model, + instructions=( + "Before using any tools, reason through conditional arithmetic to determine which " + "calculation is required. Then use code interpreter for the calculation and " + "answer with RESULT:." + ), + tools=[ + CodeInterpreterTool( + tool_config={"type": "code_interpreter", "container": {"type": "auto"}} + ) + ], + model_settings={ + "max_tokens": 1024, + "reasoning": {"effort": "medium", "summary": "auto"}, + "response_include": ["reasoning.encrypted_content"], + "store": False, + }, + ) + first = await Runner.run( + agent, + "First determine whether the remainder of 4837 multiplied by 8291 divided by 97 " + "is odd. If it is odd, use the code interpreter to calculate 273 * 312821 + 1782; " + "otherwise calculate 19 * 83. Respond only with RESULT:.", + run_config=RunConfig(tracing_disabled=True, reasoning_item_id_policy="omit"), + ) + expected = str(273 * 312821 + 1782) + assert expected in str(first.final_output) + assert any( + isinstance(item, ToolCallItem) + and getattr(item.raw_item, "type", None) == "code_interpreter_call" + for item in first.new_items + ) + + reasoning_items = [ + output + for response in first.raw_responses + for output in response.output + if isinstance(output, ResponseReasoningItem) + ] + assert reasoning_items, [ + getattr(output, "type", type(output).__name__) + for response in first.raw_responses + for output in response.output + ] + + follow_up = first.to_input_list(mode="normalized") + replayed_reasoning = [item for item in follow_up if item.get("type") == "reasoning"] + assert len(replayed_reasoning) == len(reasoning_items) + assert all(isinstance(item.get("encrypted_content"), str) for item in replayed_reasoning) + follow_up.append({"role": "user", "content": "Repeat the calculated result exactly."}) + second = await Runner.run( + agent, + follow_up, + run_config=RunConfig(tracing_disabled=True, reasoning_item_id_policy="omit"), + ) + + assert expected in str(second.final_output) diff --git a/integration_tests/hosted/test_local_tool_families.py b/integration_tests/hosted/test_local_tool_families.py new file mode 100644 index 0000000000..495e86990d --- /dev/null +++ b/integration_tests/hosted/test_local_tool_families.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agents import ( + Agent, + CustomTool, + ModelSettings, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + RunState, + ShellCommandRequest, + ShellTool, + ToolCallOutputItem, +) +from agents.tool_context import ToolContext + +pytestmark = pytest.mark.hosted + + +@pytest.mark.parametrize( + "streaming", + [False, pytest.param(True, marks=pytest.mark.nightly)], + ids=["nonstreaming", "streaming"], +) +async def test_custom_tools_preserve_raw_string_inputs_and_outputs( + integration_model: str, + streaming: bool, +) -> None: + raw_inputs: list[str] = [] + + async def format_release_word(_context: ToolContext[Any], raw_input: str) -> str: + raw_inputs.append(raw_input) + return raw_input.strip().upper() + + custom = CustomTool( + name="format_release_word", + description="Convert the raw release word to uppercase.", + on_invoke_tool=format_release_word, + ) + agent = Agent( + name="Packaged raw custom tool agent", + model=integration_model, + instructions=( + "Call format_release_word with exactly the raw string amber, " + "then reply exactly CUSTOM:AMBER." + ), + tools=[custom], + model_settings=ModelSettings(tool_choice="required", max_tokens=256), + ) + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed(agent, "Format the release word.", run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "Format the release word.", run_config=config) + + outputs = [item for item in result.new_items if isinstance(item, ToolCallOutputItem)] + assert len(raw_inputs) == 1 + assert raw_inputs[0].strip() == "amber" + assert result.final_output == "CUSTOM:AMBER" + assert len(outputs) == 1 + assert isinstance(outputs[0].raw_item, dict) + assert outputs[0].raw_item["type"] == "custom_tool_call_output" + + +@pytest.mark.nightly +@pytest.mark.parametrize("approved", [False, True], ids=["rejected", "approved"]) +async def test_custom_tool_approval_survives_serialized_resume( + integration_model: str, + approved: bool, +) -> None: + calls: list[str] = [] + + async def publish_release(_context: ToolContext[Any], raw_input: str) -> str: + calls.append(raw_input) + return "CUSTOM_APPROVED" + + custom = CustomTool( + name="publish_release_note", + description="Publish the raw release note after operator approval.", + on_invoke_tool=publish_release, + needs_approval=True, + ) + agent = Agent( + name="Packaged approval-gated custom tool agent", + model=integration_model, + instructions=( + "Call publish_release_note with the raw string amber. If approved reply exactly " + "CUSTOM_APPROVED; if rejected reply exactly CUSTOM_REJECTED." + ), + tools=[custom], + model_settings=ModelSettings(tool_choice="required", max_tokens=320), + ) + config = RunConfig(tracing_disabled=True) + first = await Runner.run(agent, "Publish the release note.", run_config=config, max_turns=5) + assert len(first.interruptions) == 1 + + state = await RunState.from_json(agent, first.to_state().to_json()) + if approved: + state.approve(state.get_interruptions()[0]) + else: + state.reject(state.get_interruptions()[0], rejection_message="Publication was declined.") + + resumed = await Runner.run(agent, state, run_config=config, max_turns=5) + outputs = [item for item in resumed.new_items if isinstance(item, ToolCallOutputItem)] + assert calls == (["amber"] if approved else []) + assert resumed.final_output == ("CUSTOM_APPROVED" if approved else "CUSTOM_REJECTED") + assert any( + isinstance(item.raw_item, dict) and item.raw_item.get("type") == "custom_tool_call_output" + for item in outputs + ) + + +@pytest.mark.parametrize( + "streaming", + [False, pytest.param(True, marks=pytest.mark.nightly)], + ids=["nonstreaming", "streaming"], +) +async def test_local_shell_tools_execute_only_the_supplied_safe_harness( + integration_model: str, + streaming: bool, +) -> None: + requested_commands: list[list[str]] = [] + + def execute_shell(request: ShellCommandRequest) -> str: + requested_commands.append(request.data.action.commands) + return "SHELL_CHECKPOINT_READY" + + agent = Agent( + name="Packaged local shell tool agent", + model=integration_model, + instructions=( + "Call the shell tool with exactly the command echo release, " + "then reply exactly SHELL_READY." + ), + tools=[ShellTool(executor=execute_shell)], + model_settings=ModelSettings(tool_choice="required", max_tokens=256), + ) + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed(agent, "Check the release with shell.", run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "Check the release with shell.", run_config=config) + + outputs = [item for item in result.new_items if isinstance(item, ToolCallOutputItem)] + assert requested_commands == [["echo release"]] + assert result.final_output == "SHELL_READY" + assert len(outputs) == 1 + assert isinstance(outputs[0].raw_item, dict) + assert outputs[0].raw_item["type"] == "shell_call_output" + + +@pytest.mark.nightly +@pytest.mark.parametrize("approved", [False, True], ids=["rejected", "approved"]) +async def test_local_shell_approval_survives_serialized_resume( + integration_model: str, + approved: bool, +) -> None: + requested_commands: list[list[str]] = [] + + def execute_shell(request: ShellCommandRequest) -> str: + requested_commands.append(request.data.action.commands) + return "SHELL_APPROVED" + + agent = Agent( + name="Packaged approval-gated shell agent", + model=integration_model, + instructions=( + "Call the shell tool with exactly the command echo release. If approved reply " + "exactly SHELL_APPROVED; if rejected reply exactly SHELL_REJECTED." + ), + tools=[ShellTool(executor=execute_shell, needs_approval=True)], + model_settings=ModelSettings(tool_choice="required", max_tokens=320), + ) + config = RunConfig(tracing_disabled=True) + first = await Runner.run(agent, "Check the release with shell.", run_config=config, max_turns=5) + assert len(first.interruptions) == 1 + + state = await RunState.from_json(agent, first.to_state().to_json()) + if approved: + state.approve(state.get_interruptions()[0]) + else: + state.reject(state.get_interruptions()[0], rejection_message="Shell access was declined.") + + resumed = await Runner.run(agent, state, run_config=config, max_turns=5) + assert requested_commands == ([["echo release"]] if approved else []) + assert resumed.final_output == ("SHELL_APPROVED" if approved else "SHELL_REJECTED") + assert any( + isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "shell_call_output" + for item in resumed.new_items + ) diff --git a/integration_tests/hosted/test_mcp.py b/integration_tests/hosted/test_mcp.py new file mode 100644 index 0000000000..9b14a00ef3 --- /dev/null +++ b/integration_tests/hosted/test_mcp.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import os + +import pytest + +from agents import Agent, HostedMCPTool, ModelSettings, RunConfig, Runner, RunState +from agents.items import ( + MCPApprovalRequestItem, + MCPApprovalResponseItem, + MCPListToolsItem, + ToolCallItem, +) +from agents.model_settings import MCPToolChoice + +pytestmark = pytest.mark.hosted + + +async def test_hosted_mcp_lists_and_calls_a_trusted_remote_server(integration_model: str) -> None: + server_url = os.environ.get( + "OPENAI_AGENTS_INTEGRATION_MCP_SERVER_URL", "https://mcp.deepwiki.com/mcp" + ) + agent = Agent( + name="Packaged hosted MCP agent", + model=integration_model, + instructions=( + "Use the DeepWiki MCP server to identify the main programming language of " + "openai/openai-agents-python." + ), + model_settings={"max_tokens": 768}, + tools=[ + HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "packaged_deepwiki", + "server_url": server_url, + "require_approval": "never", + } + ) + ], + ) + result = await Runner.run( + agent, + "Which language is the openai/openai-agents-python repository mainly written in?", + run_config=RunConfig(tracing_disabled=True), + max_turns=5, + ) + + assert "python" in str(result.final_output).lower() + assert any(isinstance(item, MCPListToolsItem) for item in result.new_items) + assert any( + isinstance(item, ToolCallItem) and getattr(item.raw_item, "type", None) == "mcp_call" + for item in result.new_items + ) + + +async def test_hosted_mcp_approval_survives_serialized_pause_and_resume( + integration_model: str, +) -> None: + server_url = os.environ.get( + "OPENAI_AGENTS_INTEGRATION_MCP_SERVER_URL", "https://mcp.deepwiki.com/mcp" + ) + agent = Agent( + name="Packaged hosted MCP approval agent", + model=integration_model, + instructions="Use the DeepWiki MCP server to answer the repository language question.", + model_settings=ModelSettings( + max_tokens=768, + tool_choice=MCPToolChoice(server_label="packaged_mcp_approval", name="ask_question"), + ), + tools=[ + HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "packaged_mcp_approval", + "server_url": server_url, + "require_approval": "always", + } + ) + ], + ) + first = await Runner.run( + agent, + "Which language is the openai/openai-agents-python repository mainly written in?", + run_config=RunConfig(tracing_disabled=True), + max_turns=6, + ) + + assert len(first.interruptions) == 1 + assert any(isinstance(item, MCPApprovalRequestItem) for item in first.new_items) + state = await RunState.from_json(agent, first.to_state().to_json()) + state.approve(state.get_interruptions()[0]) + resumed = await Runner.run( + agent, + state, + run_config=RunConfig( + tracing_disabled=True, + model_settings=ModelSettings(tool_choice="auto"), + ), + max_turns=6, + ) + + assert "python" in str(resumed.final_output).lower() + assert any(isinstance(item, MCPApprovalResponseItem) for item in resumed.new_items) + + +@pytest.mark.nightly +async def test_hosted_mcp_rejection_survives_serialized_pause_and_resume( + integration_model: str, +) -> None: + server_url = os.environ.get( + "OPENAI_AGENTS_INTEGRATION_MCP_SERVER_URL", "https://mcp.deepwiki.com/mcp" + ) + agent = Agent( + name="Packaged hosted MCP rejection agent", + model=integration_model, + instructions=( + "Use the DeepWiki MCP server to answer the repository language question. " + "If the request is rejected, reply exactly MCP_REJECTED." + ), + model_settings=ModelSettings( + max_tokens=512, + tool_choice=MCPToolChoice(server_label="packaged_mcp_rejection", name="ask_question"), + ), + tools=[ + HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "packaged_mcp_rejection", + "server_url": server_url, + "require_approval": "always", + "allowed_tools": ["ask_question"], + } + ) + ], + ) + + first = await Runner.run( + agent, + "What is the main repository language?", + run_config=RunConfig(tracing_disabled=True), + max_turns=6, + ) + assert len(first.interruptions) == 1 + restored = await RunState.from_json(agent, first.to_state().to_json()) + restored.reject(restored.get_interruptions()[0], rejection_message="Remote access declined.") + resumed = await Runner.run( + agent, + restored, + run_config=RunConfig( + tracing_disabled=True, + model_settings=ModelSettings(tool_choice="auto"), + ), + max_turns=6, + ) + + assert resumed.final_output == "MCP_REJECTED" + assert any(isinstance(item, MCPApprovalResponseItem) for item in resumed.new_items) diff --git a/integration_tests/hosted/test_multi_agent.py b/integration_tests/hosted/test_multi_agent.py new file mode 100644 index 0000000000..a20b6eb7f5 --- /dev/null +++ b/integration_tests/hosted/test_multi_agent.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import os +from typing import Any + +import pytest + +from agents import Agent, RunConfig, Runner, RunResult, RunResultStreaming +from agents.decorators import tool +from agents.extensions.experimental.hosted_multi_agent import ( + HostedMultiAgentConfig, + OpenAIHostedMultiAgentModel, + get_hosted_agent_metadata, +) +from agents.tool_context import ToolContext + +pytestmark = pytest.mark.hosted + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_hosted_multi_agent_preserves_subagent_tool_callers(streaming: bool) -> None: + model_name = os.environ.get("OPENAI_AGENTS_INTEGRATION_HOSTED_MODEL", "gpt-5.6-sol") + proposals = {"alpha": 6, "beta": 8} + callers: set[str] = set() + call_ids: set[str] = set() + + @tool + def inspect_proposal(ctx: ToolContext[Any], proposal: str) -> dict[str, object]: + """Return deterministic details for one proposal.""" + metadata = get_hosted_agent_metadata(ctx) + callers.add(metadata.agent_name if metadata else "/root") + call_ids.add(ctx.tool_call_id) + return {"proposal": proposal, "estimated_weeks": proposals[proposal]} + + agent = Agent( + name="Packaged hosted coordinator", + model=OpenAIHostedMultiAgentModel( + model=model_name, + config=HostedMultiAgentConfig(max_concurrent_subagents=2), + ), + instructions=( + "Create two subagents. Have one inspect proposal alpha and the other inspect " + "proposal beta. Each subagent must call inspect_proposal before you compare them." + ), + tools=[inspect_proposal], + ) + result: RunResult | RunResultStreaming + if streaming: + streamed = Runner.run_streamed( + agent, + "Compare proposal alpha and proposal beta.", + run_config=RunConfig(tracing_disabled=True), + max_turns=6, + ) + event_types = [event.type async for event in streamed.stream_events()] + assert "raw_response_event" in event_types + result = streamed + else: + result = await Runner.run( + agent, + "Compare proposal alpha and proposal beta.", + run_config=RunConfig(tracing_disabled=True), + max_turns=6, + ) + + assert result.final_output + assert len(call_ids) == 2 + assert len(callers) >= 2 + assert "/root" not in callers diff --git a/integration_tests/hosted/test_programmatic_tool_calling.py b/integration_tests/hosted/test_programmatic_tool_calling.py new file mode 100644 index 0000000000..48cf76c667 --- /dev/null +++ b/integration_tests/hosted/test_programmatic_tool_calling.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest +from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_output_item import Program +from pydantic import BaseModel + +from agents import ( + Agent, + ModelSettings, + ProgrammaticToolCallingTool, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + RunState, + ToolCallItem, + ToolCallOutputItem, + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, + handoff, +) +from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail +from agents.extensions.handoff_filters import remove_all_tools +from agents.handoffs import HandoffInputData + +pytestmark = pytest.mark.hosted + + +class InventoryResult(BaseModel): + units: int + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_programmatic_tool_calling_retains_program_owned_calls_and_output( + integration_model: str, streaming: bool +) -> None: + calls: list[str] = [] + + @tool(allowed_callers=["programmatic"]) + def read_inventory(sku: str) -> InventoryResult: + """Return the deterministic available units for an item.""" + calls.append(sku) + return InventoryResult(units={"alpha": 7, "beta": 11}[sku]) + + agent = Agent( + name="Packaged programmatic tool agent", + model=integration_model, + instructions=( + "Use Programmatic Tool Calling. Generate a JavaScript program that calls " + "read_inventory('alpha') and read_inventory('beta') with Promise.all, adds the " + "units fields from their returned objects, and returns the result. Then answer " + "exactly TOTAL:18." + ), + model_settings=ModelSettings(tool_choice="programmatic_tool_calling", max_tokens=1024), + tools=[read_inventory, ProgrammaticToolCallingTool()], + ) + result: RunResult | RunResultStreaming + if streaming: + streamed = Runner.run_streamed( + agent, + "Calculate the total inventory.", + run_config=RunConfig(tracing_disabled=True), + max_turns=5, + ) + async for _event in streamed.stream_events(): + pass + result = streamed + else: + result = await Runner.run( + agent, + "Calculate the total inventory.", + run_config=RunConfig(tracing_disabled=True), + max_turns=5, + ) + program_calls = [ + item.raw_item + for item in result.new_items + if isinstance(item, ToolCallItem) + and isinstance(item.raw_item, ResponseFunctionToolCall) + and item.raw_item.caller is not None + and item.raw_item.caller.type == "program" + ] + + assert sorted(calls) == ["alpha", "beta"] + assert len(program_calls) == 2 + assert any( + isinstance(item, ToolCallItem) and isinstance(item.raw_item, Program) + for item in result.new_items + ) + assert any( + isinstance(item, ToolCallOutputItem) + and getattr(item.raw_item, "type", None) == "program_output" + for item in result.new_items + ) + assert result.final_output == "TOTAL:18" + + +async def test_programmatic_tool_history_survives_a_filtered_handoff( + integration_model: str, +) -> None: + calls: list[str] = [] + handoff_filter_inputs: list[tuple[HandoffInputData, HandoffInputData]] = [] + + def capture_filtered_handoff(input_data: HandoffInputData) -> HandoffInputData: + filtered = remove_all_tools(input_data) + handoff_filter_inputs.append((input_data, filtered)) + return filtered + + @tool(allowed_callers=["programmatic"]) + def inspect_inventory(sku: str) -> InventoryResult: + """Return deterministic inventory details to the hosted program.""" + calls.append(sku) + return InventoryResult(units=18) + + specialist = Agent( + name="Packaged program summary specialist", + model=integration_model, + instructions="Reply with exactly FILTERED_PROGRAM_HANDOFF_OK.", + model_settings={"max_tokens": 256}, + ) + coordinator = Agent( + name="Packaged program handoff coordinator", + model=integration_model, + instructions=( + "First use Programmatic Tool Calling to run inspect_inventory('alpha'). " + "After the program returns, immediately transfer to the summary specialist." + ), + tools=[inspect_inventory, ProgrammaticToolCallingTool()], + handoffs=[handoff(specialist, input_filter=capture_filtered_handoff)], + model_settings={"max_tokens": 1024}, + ) + result = await Runner.run( + coordinator, + "Inspect alpha with a program, then transfer the answer.", + run_config=RunConfig(tracing_disabled=True, nest_handoff_history=True), + max_turns=7, + ) + + assert calls == ["alpha"] + assert result.final_output == "FILTERED_PROGRAM_HANDOFF_OK" + assert result.last_agent is specialist + assert len(handoff_filter_inputs) == 1 + original_input, filtered_input = handoff_filter_inputs[0] + assert any( + isinstance(item, ToolCallItem | ToolCallOutputItem) + for item in (*original_input.pre_handoff_items, *original_input.new_items) + ) + assert not any( + isinstance(item, ToolCallItem | ToolCallOutputItem) + for item in (*filtered_input.pre_handoff_items, *filtered_input.new_items) + ) + assert any( + isinstance(output, Program) + for response in result.raw_responses + for output in response.output + ) + + +@pytest.mark.nightly +@pytest.mark.parametrize("approved", [False, True], ids=["rejected", "approved"]) +async def test_programmatic_tool_approval_preserves_caller_across_serialized_resume( + integration_model: str, approved: bool +) -> None: + calls: list[str] = [] + + @tool(allowed_callers=["programmatic"], needs_approval=True) + def approve_inventory(sku: str) -> InventoryResult: + """Read inventory only after the program's tool call is approved.""" + calls.append(sku) + return InventoryResult(units=18) + + agent = Agent( + name="Packaged programmatic approval agent", + model=integration_model, + instructions=( + "Use Programmatic Tool Calling to invoke approve_inventory('alpha'). " + "If it succeeds reply exactly PROGRAM_APPROVED; if it is rejected reply " + "exactly PROGRAM_REJECTED." + ), + model_settings=ModelSettings(tool_choice="programmatic_tool_calling", max_tokens=1024), + tools=[approve_inventory, ProgrammaticToolCallingTool()], + ) + config = RunConfig(tracing_disabled=True) + + first = await Runner.run(agent, "Read the protected inventory.", run_config=config, max_turns=6) + assert len(first.interruptions) == 1 + state = await RunState.from_json(agent, first.to_state().to_json()) + interruption = state.get_interruptions()[0] + if approved: + state.approve(interruption) + else: + state.reject(interruption, rejection_message="Inventory access was rejected.") + + resumed = await Runner.run(agent, state, run_config=config, max_turns=6) + outputs = [item for item in resumed.new_items if isinstance(item, ToolCallOutputItem)] + + assert calls == (["alpha"] if approved else []) + assert outputs + if approved: + assert resumed.final_output == "PROGRAM_APPROVED" + else: + rejected_item = next( + item + for item in outputs + if isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "function_call_output" + ) + assert rejected_item.output == "Inventory access was rejected." + assert json.loads(cast(dict[str, Any], rejected_item.raw_item)["output"]) == { + "error": "Inventory access was rejected." + } + callers = [ + cast(dict[str, Any], item.raw_item).get("caller") + if isinstance(item.raw_item, dict) + else getattr(item.raw_item, "caller", None) + for item in outputs + ] + assert any( + (caller.get("type") if isinstance(caller, dict) else getattr(caller, "type", None)) + == "program" + for caller in callers + ) + + +@pytest.mark.nightly +@pytest.mark.parametrize("rejection_stage", ["input", "output"]) +async def test_programmatic_structured_tool_guardrail_errors_are_valid_json( + integration_model: str, rejection_stage: str +) -> None: + calls: list[str] = [] + rejection_message = f"Inventory {rejection_stage} was rejected." + + @tool_input_guardrail + def inspect_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + if rejection_stage == "input": + return ToolGuardrailFunctionOutput.reject_content(rejection_message) + return ToolGuardrailFunctionOutput.allow() + + @tool_output_guardrail + def inspect_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + if rejection_stage == "output": + return ToolGuardrailFunctionOutput.reject_content(rejection_message) + return ToolGuardrailFunctionOutput.allow() + + @tool( + allowed_callers=["programmatic"], + tool_input_guardrails=[inspect_input], + tool_output_guardrails=[inspect_output], + ) + def inspect_inventory(sku: str) -> InventoryResult: + """Read inventory after both programmatic tool guardrails allow the request.""" + calls.append(sku) + return InventoryResult(units=18) + + agent = Agent( + name="Packaged programmatic guardrail rejection agent", + model=integration_model, + instructions=( + "Use Programmatic Tool Calling. Write a JavaScript program that calls " + "inspect_inventory('alpha') and returns the resulting error field when present. " + "After the program finishes, reply exactly PROGRAM_GUARDRAIL_REJECTED." + ), + model_settings=ModelSettings(tool_choice="programmatic_tool_calling", max_tokens=1024), + tools=[inspect_inventory, ProgrammaticToolCallingTool()], + ) + + result = await Runner.run( + agent, + "Inspect the guarded inventory and report its rejection.", + run_config=RunConfig(tracing_disabled=True), + max_turns=6, + ) + rejected_item = next( + item + for item in result.new_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "function_call_output" + ) + + assert calls == ([] if rejection_stage == "input" else ["alpha"]) + assert rejected_item.output == rejection_message + assert json.loads(cast(dict[str, Any], rejected_item.raw_item)["output"]) == { + "error": rejection_message + } + assert result.final_output == "PROGRAM_GUARDRAIL_REJECTED" diff --git a/integration_tests/hosted/test_tool_search.py b/integration_tests/hosted/test_tool_search.py new file mode 100644 index 0000000000..55482c9809 --- /dev/null +++ b/integration_tests/hosted/test_tool_search.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import pytest + +from agents import ( + Agent, + ModelSettings, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + ToolCallItem, + ToolCallOutputItem, + ToolSearchCallItem, + ToolSearchOutputItem, + ToolSearchTool, + tool_namespace, +) +from agents.decorators import tool + +pytestmark = pytest.mark.hosted + + +@pytest.mark.parametrize( + "streaming", + [False, pytest.param(True, marks=pytest.mark.nightly)], + ids=["nonstreaming", "streaming"], +) +async def test_tool_search_loads_and_executes_a_deferred_namespaced_tool( + integration_model: str, streaming: bool +) -> None: + calls: list[str] = [] + + @tool(defer_loading=True) + def lookup_customer(customer_id: str) -> str: + """Find the customer's release readiness status.""" + calls.append(customer_id) + return "READY" + + namespaced = tool_namespace( + name="customer_support", + description="Look up customer release readiness and support records.", + tools=[lookup_customer], + ) + agent = Agent( + name="Packaged deferred tool search agent", + model=integration_model, + instructions=( + "Find the customer support tool, call lookup_customer with customer_id='customer-42', " + "and then reply with exactly SEARCH_READY." + ), + tools=[*namespaced, ToolSearchTool()], + model_settings=ModelSettings(max_tokens=512, parallel_tool_calls=False), + ) + result: RunResult | RunResultStreaming + if streaming: + streamed = Runner.run_streamed( + agent, + "Find and run the deferred customer lookup.", + run_config=RunConfig(tracing_disabled=True), + max_turns=5, + ) + events = [event async for event in streamed.stream_events()] + assert any(event.type == "raw_response_event" for event in events) + result = streamed + else: + result = await Runner.run( + agent, + "Find and run the deferred customer lookup.", + run_config=RunConfig(tracing_disabled=True), + max_turns=5, + ) + + assert calls == ["customer-42"] + assert result.final_output == "SEARCH_READY" + assert any(isinstance(item, ToolSearchCallItem) for item in result.new_items) + assert any(isinstance(item, ToolSearchOutputItem) for item in result.new_items) + assert any(isinstance(item, ToolCallItem) for item in result.new_items) + assert any(isinstance(item, ToolCallOutputItem) for item in result.new_items) + + +async def test_tool_search_routes_identically_named_tools_by_namespace( + integration_model: str, +) -> None: + calls: list[str] = [] + + @tool(name_override="lookup", defer_loading=True) + def lookup_billing(customer_id: str) -> str: + """Look up the customer's billing status.""" + calls.append(f"billing:{customer_id}") + return "BILLING_READY" + + @tool(name_override="lookup", defer_loading=True) + def lookup_shipping(customer_id: str) -> str: + """Look up the customer's package shipping status.""" + calls.append(f"shipping:{customer_id}") + return "SHIPPING_READY" + + agent = Agent( + name="Packaged namespaced tool routing agent", + model=integration_model, + instructions=( + "Find the shipping namespace tool named lookup and call it exactly once with " + "customer_id='customer-42'. Do not use billing. Reply exactly SHIPPING_READY." + ), + tools=[ + *tool_namespace(name="billing", description="Billing records", tools=[lookup_billing]), + *tool_namespace( + name="shipping", description="Package shipping records", tools=[lookup_shipping] + ), + ToolSearchTool(), + ], + model_settings=ModelSettings(max_tokens=512, parallel_tool_calls=False), + ) + + result = await Runner.run( + agent, + "Check the customer's shipping status.", + run_config=RunConfig(tracing_disabled=True), + max_turns=5, + ) + + assert calls == ["shipping:customer-42"] + assert result.final_output == "SHIPPING_READY" diff --git a/integration_tests/hosted/test_web_search.py b/integration_tests/hosted/test_web_search.py new file mode 100644 index 0000000000..98ec547b57 --- /dev/null +++ b/integration_tests/hosted/test_web_search.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pytest + +from agents import Agent, RunConfig, Runner, ToolCallItem, WebSearchTool + +pytestmark = pytest.mark.hosted + + +async def test_web_search_emits_provider_owned_call_items(integration_model: str) -> None: + agent = Agent( + name="Packaged web search agent", + model=integration_model, + instructions=( + "Search the web before answering. Identify the organization that publishes " + "the OpenAI Agents Python SDK, then answer with only OPENAI." + ), + model_settings={"max_tokens": 768}, + tools=[WebSearchTool()], + ) + result = await Runner.run( + agent, + "Search for the official openai-agents-python GitHub repository publisher.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output.strip().upper() == "OPENAI" + assert any( + isinstance(item, ToolCallItem) and getattr(item.raw_item, "type", None) == "web_search_call" + for item in result.new_items + ) diff --git a/integration_tests/openai/test_approval_resume.py b/integration_tests/openai/test_approval_resume.py new file mode 100644 index 0000000000..850fb76d18 --- /dev/null +++ b/integration_tests/openai/test_approval_resume.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agents import ( + Agent, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + RunState, + SQLiteSession, + ToolCallOutputItem, +) +from agents.decorators import tool + +pytestmark = pytest.mark.core + + +@pytest.mark.parametrize("approved", [False, True], ids=["rejected", "approved"]) +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_tool_approval_survives_serialized_state_and_resume( + integration_model: str, approved: bool, streaming: bool +) -> None: + calls: list[str] = [] + + @tool(needs_approval=True) + def perform_action(action: str) -> str: + """Perform the deterministic action only after explicit approval.""" + calls.append(action) + return "completed" + + agent = Agent( + name="Packaged approval agent", + model=integration_model, + instructions=( + "Call perform_action with action='deploy'. If the tool succeeds, reply exactly " + "APPROVED. If the tool is rejected, reply exactly REJECTED." + ), + tools=[perform_action], + model_settings={"max_tokens": 384}, + ) + config = RunConfig(tracing_disabled=True) + first: RunResult | RunResultStreaming + resumed: RunResult | RunResultStreaming + + if streaming: + first_stream = Runner.run_streamed(agent, "Perform the deployment.", run_config=config) + async for _event in first_stream.stream_events(): + pass + first = first_stream + else: + first = await Runner.run(agent, "Perform the deployment.", run_config=config) + + assert len(first.interruptions) == 1 + interruption = first.interruptions[0] + assert interruption.name == "perform_action" + state_json = first.to_state().to_json() + restored = await RunState.from_json(agent, state_json) + restored_interruption = restored.get_interruptions()[0] + + if approved: + restored.approve(restored_interruption) + else: + restored.reject(restored_interruption, rejection_message="The operator rejected deploy.") + + if streaming: + resumed_stream = Runner.run_streamed(agent, restored, run_config=config) + async for _event in resumed_stream.stream_events(): + pass + resumed = resumed_stream + else: + resumed = await Runner.run(agent, restored, run_config=config) + + assert resumed.final_output == ("APPROVED" if approved else "REJECTED") + assert calls == (["deploy"] if approved else []) + assert any(isinstance(item, ToolCallOutputItem) for item in resumed.new_items) + + +async def test_approval_resume_preserves_durable_sqlite_tool_history( + integration_model: str, tmp_path: Path +) -> None: + calls: list[str] = [] + + @tool(needs_approval=True) + def confirm_release(version: str) -> str: + """Confirm a release after its approval decision is restored.""" + calls.append(version) + return "approved" + + agent = Agent( + name="Packaged durable approval agent", + model=integration_model, + instructions="Call confirm_release with version='1.0', then reply RELEASE_APPROVED.", + model_settings={"max_tokens": 384}, + tools=[confirm_release], + ) + session = SQLiteSession("packaged-approval", tmp_path / "approval.sqlite3") + config = RunConfig(tracing_disabled=True) + try: + first = await Runner.run( + agent, + "Approve the release.", + session=session, + run_config=config, + ) + restored = await RunState.from_json(agent, first.to_state().to_json()) + restored.approve(restored.get_interruptions()[0]) + resumed = await Runner.run(agent, restored, session=session, run_config=config) + saved_items = await session.get_items() + finally: + session.close() + + assert calls == ["1.0"] + assert resumed.final_output == "RELEASE_APPROVED" + assert sum(item.get("role") == "user" for item in saved_items) == 1 + assert sum(item.get("type") == "function_call_output" for item in saved_items) == 1 + + +async def test_parallel_tool_approvals_preserve_mixed_decisions_after_serialization( + integration_model: str, tmp_path: Path +) -> None: + calls: list[str] = [] + + @tool(needs_approval=True) + def approve_release(version: str) -> str: + """Approve a deterministic release version.""" + calls.append(f"release:{version}") + return "release-approved" + + @tool(needs_approval=True) + def notify_customer(customer: str) -> str: + """Notify a deterministic customer.""" + calls.append(f"customer:{customer}") + return "customer-notified" + + agent = Agent( + name="Packaged mixed approval agent", + model=integration_model, + instructions=( + "In the same turn, call approve_release with version='1.0' and notify_customer " + "with customer='customer-42'. After their approval decisions, reply exactly " + "MIXED_APPROVAL_READY." + ), + model_settings={"max_tokens": 512, "parallel_tool_calls": True}, + tools=[approve_release, notify_customer], + ) + session = SQLiteSession("packaged-mixed-approval", tmp_path / "mixed-approval.sqlite3") + config = RunConfig(tracing_disabled=True) + try: + first = await Runner.run( + agent, "Perform both requested actions.", session=session, run_config=config + ) + assert len(first.interruptions) == 2 + restored = await RunState.from_json(agent, first.to_state().to_json()) + for interruption in restored.get_interruptions(): + if interruption.name == "approve_release": + restored.approve(interruption) + else: + restored.reject(interruption, rejection_message="Customer notification declined.") + resumed = await Runner.run(agent, restored, session=session, run_config=config) + stored = await session.get_items() + finally: + session.close() + + assert calls == ["release:1.0"] + assert resumed.final_output == "MIXED_APPROVAL_READY" + assert sum(item.get("role") == "user" for item in stored) == 1 + outputs = [item for item in stored if item.get("type") == "function_call_output"] + assert len(outputs) == 2 diff --git a/integration_tests/openai/test_chat_completions.py b/integration_tests/openai/test_chat_completions.py new file mode 100644 index 0000000000..8a54eeb561 --- /dev/null +++ b/integration_tests/openai/test_chat_completions.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from openai import AsyncOpenAI +from pydantic import BaseModel + +from agents import Agent, RunConfig, Runner, RunResult, RunResultStreaming +from agents.decorators import tool +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel + +pytestmark = pytest.mark.core + + +class ChatCompletionStatus(BaseModel): + status: str + checkpoints: list[int] + + +@pytest.mark.parametrize("dictionary", [False, True], ids=["typed", "dictionary"]) +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_chat_completions_tools_settings_and_usage( + integration_model: str, dictionary: bool, streaming: bool +) -> None: + from agents import ModelSettings + + calls: list[str] = [] + + @tool + def package_status(package: str) -> str: + """Return a deterministic package status.""" + calls.append(package) + return "ready" + + values: dict[str, Any] = { + "reasoning": {"effort": "none"}, + "include_usage": True, + "extra_args": {"max_completion_tokens": 512}, + } + settings = values if dictionary else ModelSettings(**values) + agent = Agent( + name="Packaged Chat Completions agent", + model=OpenAIChatCompletionsModel( + model=integration_model, + openai_client=AsyncOpenAI(), + ), + instructions=( + "Call package_status exactly once with package='openai-agents', then reply " + "exactly CHAT_READY." + ), + model_settings=settings, + tools=[package_status], + ) + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + + if streaming: + result = Runner.run_streamed(agent, "Check the package.", run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "Check the package.", run_config=config) + + assert calls == ["openai-agents"] + assert result.final_output == "CHAT_READY" + assert result.context_wrapper.usage.total_tokens > 0 + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_chat_completions_preserves_typed_structured_output( + integration_model: str, + streaming: bool, +) -> None: + agent = Agent( + name="Packaged structured Chat Completions agent", + model=OpenAIChatCompletionsModel( + model=integration_model, + openai_client=AsyncOpenAI(), + ), + instructions="Return status CHAT_STRUCTURED_READY and checkpoints [2, 4, 8].", + output_type=ChatCompletionStatus, + model_settings={"reasoning": {"effort": "none"}, "include_usage": True}, + ) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed( + agent, + "Return the requested typed release status.", + run_config=RunConfig(tracing_disabled=True), + ) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run( + agent, + "Return the requested typed release status.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == ChatCompletionStatus( + status="CHAT_STRUCTURED_READY", checkpoints=[2, 4, 8] + ) + assert result.context_wrapper.usage.total_tokens > 0 diff --git a/integration_tests/openai/test_execution_controls.py b/integration_tests/openai/test_execution_controls.py new file mode 100644 index 0000000000..f0807fa7a5 --- /dev/null +++ b/integration_tests/openai/test_execution_controls.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, cast + +import pytest +from openai.types.responses import ResponseReasoningItem + +from agents import ( + Agent, + AgentHookContext, + ModelSettings, + RunConfig, + RunContextWrapper, + RunErrorHandlerInput, + RunErrorHandlerResult, + RunHooks, + Runner, + RunResult, + RunResultStreaming, + SQLiteSession, + Tool, + ToolCallOutputItem, + ToolExecutionConfig, +) +from agents.decorators import tool +from agents.items import ModelResponse, TResponseInputItem +from agents.run_config import CallModelData, ModelInputData + +pytestmark = pytest.mark.core + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_stop_on_first_tool_avoids_a_follow_up_model_request( + integration_model: str, + streaming: bool, +) -> None: + calls: list[str] = [] + + @tool + def resolve_checkpoint(checkpoint: str) -> str: + """Return the requested release checkpoint directly.""" + calls.append(checkpoint) + return "STOP_ON_FIRST_TOOL_READY" + + agent = Agent( + name="Packaged stop-on-tool agent", + model=integration_model, + instructions="Call resolve_checkpoint exactly once with checkpoint='release'.", + tools=[resolve_checkpoint], + tool_use_behavior="stop_on_first_tool", + model_settings={"max_tokens": 256, "tool_choice": "required"}, + ) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed( + agent, + "Return the checkpoint through the tool.", + run_config=RunConfig(tracing_disabled=True), + ) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run( + agent, + "Return the checkpoint through the tool.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert calls == ["release"] + assert result.final_output == "STOP_ON_FIRST_TOOL_READY" + assert result.context_wrapper.usage.requests == 1 + assert len(result.raw_responses) == 1 + + +@pytest.mark.nightly +@pytest.mark.parametrize("max_concurrency", [1, 2], ids=["sequential", "bounded-parallel"]) +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_parallel_function_tools_preserve_order_and_sdk_concurrency_limits( + integration_model: str, + max_concurrency: int, + streaming: bool, +) -> None: + active_calls = 0 + peak_concurrency = 0 + + async def run_checkpoint(name: str) -> str: + nonlocal active_calls, peak_concurrency + active_calls += 1 + peak_concurrency = max(peak_concurrency, active_calls) + try: + await asyncio.sleep(0.08) + return name.upper() + finally: + active_calls -= 1 + + @tool + async def checkpoint_alpha(checkpoint: str) -> str: + """Return the alpha release checkpoint.""" + return await run_checkpoint(checkpoint) + + @tool + async def checkpoint_beta(checkpoint: str) -> str: + """Return the beta release checkpoint.""" + return await run_checkpoint(checkpoint) + + settings = ModelSettings( + tool_choice="required", + parallel_tool_calls=True, + max_tokens=512, + ) + agent = Agent( + name="Packaged bounded tool concurrency agent", + model=integration_model, + instructions=( + "In the same turn, call checkpoint_alpha with checkpoint='alpha' and " + "checkpoint_beta with checkpoint='beta'. After both tools finish reply exactly " + "CONCURRENCY_READY." + ), + tools=[checkpoint_alpha, checkpoint_beta], + model_settings=settings, + ) + config = RunConfig( + tracing_disabled=True, + tool_execution=ToolExecutionConfig(max_function_tool_concurrency=max_concurrency), + ) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed(agent, "Run both release checkpoints.", run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "Run both release checkpoints.", run_config=config) + + outputs = [item.output for item in result.new_items if isinstance(item, ToolCallOutputItem)] + + assert result.final_output == "CONCURRENCY_READY" + assert outputs == ["ALPHA", "BETA"] + assert peak_concurrency == max_concurrency + assert result.context_wrapper.usage.requests == 2 + assert settings.tool_choice == "required" + + +async def test_session_merge_and_model_input_filter_have_distinct_persistence_boundaries( + integration_model: str, + tmp_path: Path, +) -> None: + callback_inputs: list[tuple[int, str]] = [] + filter_inputs: list[str] = [] + agent = Agent( + name="Packaged session input filtering agent", + model=integration_model, + instructions="Remember user-provided release words and reply exactly as requested.", + model_settings={"max_tokens": 256}, + ) + session = SQLiteSession("packaged-filtered-session", tmp_path / "filtered.sqlite3") + + try: + await Runner.run( + agent, + "Remember the release word JASPER and reply only STORED.", + session=session, + run_config=RunConfig(tracing_disabled=True), + ) + + def merge_session_input( + history: list[TResponseInputItem], + new_input: list[TResponseInputItem], + ) -> list[TResponseInputItem]: + callback_inputs.append((len(history), str(new_input[0].get("content")))) + rewritten = cast( + TResponseInputItem, + { + "role": "user", + "content": "What release word did I provide? Reply only with that word.", + }, + ) + return [*history, rewritten] + + def filter_model_input(data: CallModelData[Any]) -> ModelInputData: + latest = data.model_data.input[-1] + filter_inputs.append(str(latest.get("content"))) + return ModelInputData( + input=data.model_data.input, + instructions=(data.model_data.instructions or "") + + " Prefix the remembered word with FILTERED: and reply with nothing else.", + ) + + result = await Runner.run( + agent, + "PLACEHOLDER_NEW_INPUT", + session=session, + run_config=RunConfig( + tracing_disabled=True, + session_input_callback=merge_session_input, + call_model_input_filter=filter_model_input, + ), + ) + persisted = await session.get_items() + finally: + session.close() + + assert len(callback_inputs) == 1 + assert callback_inputs[0][0] >= 2 + assert callback_inputs[0][1] == "PLACEHOLDER_NEW_INPUT" + assert filter_inputs == ["What release word did I provide? Reply only with that word."] + assert result.final_output == "FILTERED:JASPER" + assert any("What release word" in str(item.get("content", "")) for item in persisted) + assert not any("PLACEHOLDER_NEW_INPUT" in str(item.get("content", "")) for item in persisted) + + +@pytest.mark.nightly +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_stateless_reasoning_replay_preserves_encrypted_content_when_returned( + integration_model: str, + streaming: bool, +) -> None: + stored_words: list[str] = [] + + @tool + def remember_word(word: str) -> str: + """Store the release word and return a deterministic acknowledgement.""" + stored_words.append(word) + return "WORD_STORED" + + agent = Agent( + name="Packaged stateless reasoning replay agent", + model=integration_model, + instructions=( + "Calculate the requested arithmetic before calling remember_word. Use the word " + "AMBER when the result is odd and COBALT when it is even. Once the tool succeeds " + "reply exactly STORED. Answer follow-up questions using the previous tool result." + ), + tools=[remember_word], + model_settings=ModelSettings( + store=False, + reasoning={"effort": "medium", "summary": "auto"}, + response_include=["reasoning.encrypted_content"], + max_tokens=1024, + ), + ) + config = RunConfig(tracing_disabled=True, reasoning_item_id_policy="omit") + first = await Runner.run( + agent, + "What is the remainder when 4837 multiplied by 8291 is divided by 97? " + "Follow the parity rule, call remember_word, and then reply only STORED.", + run_config=config, + ) + reasoning_items = [ + item + for response in first.raw_responses + for item in response.output + if isinstance(item, ResponseReasoningItem) + ] + assert reasoning_items, "The stateless response did not contain any reasoning items." + replay = first.to_input_list(mode="normalized") + replayed_reasoning = [item for item in replay if item.get("type") == "reasoning"] + replay.append( + cast( + TResponseInputItem, + {"role": "user", "content": "What release word did I provide? Reply only AMBER."}, + ) + ) + + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed(agent, replay, run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, replay, run_config=config) + + assert all(isinstance(item.encrypted_content, str) for item in reasoning_items) + assert len(replayed_reasoning) == len(reasoning_items) + assert all(isinstance(item.get("encrypted_content"), str) for item in replayed_reasoning) + assert all("id" not in item for item in replayed_reasoning) + assert first.context_wrapper.usage.requests == 2 + assert result.context_wrapper.usage.requests == 1 + assert stored_words == ["AMBER"] + assert result.final_output == "AMBER" + + +@pytest.mark.parametrize("use_session", [False, True], ids=["without-session", "sqlite-session"]) +async def test_cancel_after_turn_resumes_without_repeating_function_tools( + integration_model: str, + use_session: bool, +) -> None: + calls: list[str] = [] + + @tool + def checkpoint(value: str) -> str: + """Record one deterministic cancellation checkpoint.""" + calls.append(value) + return "CANCEL_CHECKPOINT_READY" + + session = SQLiteSession("packaged-cancel-after-turn") if use_session else None + agent = Agent( + name="Packaged streamed cancellation agent", + model=integration_model, + instructions=( + "Call checkpoint with value='release'. After the tool returns, reply exactly " + "CANCEL_RESUMED_READY." + ), + tools=[checkpoint], + model_settings={"max_tokens": 256}, + ) + config = RunConfig(tracing_disabled=True) + + try: + result = Runner.run_streamed( + agent, + "Run the release checkpoint.", + session=session, + run_config=config, + ) + async for event in result.stream_events(): + if getattr(event, "name", None) == "tool_called": + result.cancel(mode="after_turn") + + replay = result.to_input_list(mode="normalized") + resumed = await Runner.run(result.last_agent, replay, run_config=config) + persisted = await session.get_items() if session is not None else [] + finally: + if session is not None: + session.close() + + assert result.final_output is None + assert result.is_complete + assert calls == ["release"] + assert resumed.final_output == "CANCEL_RESUMED_READY" + assert result.context_wrapper.usage.requests == 1 + assert resumed.context_wrapper.usage.requests == 1 + if use_session: + assert any(item.get("type") == "function_call_output" for item in persisted) + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_max_turn_error_handler_preserves_tool_side_effects_and_history( + integration_model: str, + streaming: bool, +) -> None: + calls: list[str] = [] + handled: list[str] = [] + + @tool + def release_checkpoint(value: str) -> str: + """Return a release checkpoint before the model exceeds its turn limit.""" + calls.append(value) + return "CHECKPOINT_RECORDED" + + def handle_max_turns(data: RunErrorHandlerInput[Any]) -> RunErrorHandlerResult: + handled.append(type(data.error).__name__) + return RunErrorHandlerResult(final_output="MAX_TURNS_RECOVERED", include_in_history=False) + + session = SQLiteSession(f"packaged-max-turn-recovery-{streaming}") + agent = Agent( + name="Packaged max-turn recovery agent", + model=integration_model, + instructions="Call release_checkpoint with value='release', then explain the result.", + tools=[release_checkpoint], + model_settings={"tool_choice": "required", "max_tokens": 256}, + ) + config = RunConfig(tracing_disabled=True) + + try: + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed( + agent, + "Run the release checkpoint.", + session=session, + run_config=config, + max_turns=1, + error_handlers={"max_turns": handle_max_turns}, + ) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run( + agent, + "Run the release checkpoint.", + session=session, + run_config=config, + max_turns=1, + error_handlers={"max_turns": handle_max_turns}, + ) + persisted = await session.get_items() + finally: + session.close() + + assert calls == ["release"] + assert handled == ["MaxTurnsExceeded"] + assert result.final_output == "MAX_TURNS_RECOVERED" + assert any(item.get("type") == "function_call_output" for item in persisted) + assert not any("MAX_TURNS_RECOVERED" in str(item) for item in persisted) + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_live_run_hooks_preserve_model_and_function_tool_event_order( + integration_model: str, + streaming: bool, +) -> None: + observed: list[str] = [] + + class RecordingHooks(RunHooks[Any]): + async def on_agent_start(self, context: AgentHookContext[Any], agent: Agent[Any]) -> None: + del context, agent + observed.append("agent_start") + + async def on_agent_end( + self, + context: AgentHookContext[Any], + agent: Agent[Any], + output: Any, + ) -> None: + del context, agent, output + observed.append("agent_end") + + async def on_llm_start( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + system_prompt: str | None, + input_items: list[TResponseInputItem], + ) -> None: + del context, agent, system_prompt, input_items + observed.append("model_start") + + async def on_llm_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + response: ModelResponse, + ) -> None: + del context, agent, response + observed.append("model_end") + + async def on_tool_start( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + ) -> None: + del context, agent, tool + observed.append("tool_start") + + async def on_tool_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + result: object, + ) -> None: + del context, agent, tool, result + observed.append("tool_end") + + @tool + def inspect_release(value: str) -> str: + """Inspect a release checkpoint for lifecycle hook ordering.""" + observed.append(f"tool_call:{value}") + return "HOOK_CHECKPOINT_READY" + + agent = Agent( + name="Packaged run hooks agent", + model=integration_model, + instructions=("Call inspect_release with value='release', then reply exactly HOOKS_READY."), + tools=[inspect_release], + model_settings={"max_tokens": 256}, + ) + hooks = RecordingHooks() + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed(agent, "Inspect the release.", hooks=hooks, run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "Inspect the release.", hooks=hooks, run_config=config) + + assert result.final_output == "HOOKS_READY" + assert observed == [ + "agent_start", + "model_start", + "model_end", + "tool_start", + "tool_call:release", + "tool_end", + "model_start", + "model_end", + "agent_end", + ] diff --git a/integration_tests/openai/test_guardrails.py b/integration_tests/openai/test_guardrails.py new file mode 100644 index 0000000000..7d5235e2a4 --- /dev/null +++ b/integration_tests/openai/test_guardrails.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrailTripwireTriggered, + OutputGuardrailTripwireTriggered, + RunConfig, + RunContextWrapper, + Runner, + ToolExecutionConfig, + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, +) +from agents.decorators import ( + input_guardrail, + output_guardrail, + tool, + tool_input_guardrail, + tool_output_guardrail, +) + +pytestmark = pytest.mark.core + + +@pytest.mark.parametrize("blocked", [False, True], ids=["accepted", "blocked"]) +async def test_output_guardrails_validate_real_model_results( + integration_model: str, blocked: bool +) -> None: + inspected: list[str] = [] + + @output_guardrail + async def inspect_result( + context: RunContextWrapper[Any], agent: Agent[Any], output: str + ) -> GuardrailFunctionOutput: + del context, agent + inspected.append(output) + return GuardrailFunctionOutput( + output_info={"checked": True}, + tripwire_triggered=blocked, + ) + + agent = Agent( + name="Packaged output guardrail agent", + model=integration_model, + instructions="Reply with exactly GUARDED_RESULT.", + output_guardrails=[inspect_result], + model_settings={"max_tokens": 256}, + ) + if blocked: + with pytest.raises(OutputGuardrailTripwireTriggered): + await Runner.run( + agent, + "Return the deterministic guarded result.", + run_config=RunConfig(tracing_disabled=True), + ) + else: + result = await Runner.run( + agent, + "Return the deterministic guarded result.", + run_config=RunConfig(tracing_disabled=True), + ) + assert result.final_output == "GUARDED_RESULT" + + assert inspected == ["GUARDED_RESULT"] + + +@pytest.mark.parametrize("blocked", [False, True], ids=["accepted", "blocked"]) +async def test_input_guardrails_validate_live_run_requests( + integration_model: str, blocked: bool +) -> None: + inspected: list[str] = [] + + @input_guardrail + async def inspect_input( + context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[Any] + ) -> GuardrailFunctionOutput: + del context, agent + inspected.append(str(input)) + return GuardrailFunctionOutput(output_info={"checked": True}, tripwire_triggered=blocked) + + agent = Agent( + name="Packaged input guardrail agent", + model=integration_model, + instructions="Reply with exactly INPUT_GUARDRAIL_READY.", + input_guardrails=[inspect_input], + model_settings={"max_tokens": 256}, + ) + if blocked: + with pytest.raises(InputGuardrailTripwireTriggered): + await Runner.run( + agent, + "Check the input guardrail.", + run_config=RunConfig(tracing_disabled=True), + ) + else: + result = await Runner.run( + agent, + "Check the input guardrail.", + run_config=RunConfig(tracing_disabled=True), + ) + assert result.final_output == "INPUT_GUARDRAIL_READY" + + assert inspected == ["Check the input guardrail."] + + +async def test_tool_input_and_output_guardrails_preserve_live_execution_order( + integration_model: str, +) -> None: + observed: list[str] = [] + + @tool_input_guardrail + def inspect_input(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + observed.append(f"input:{data.context.tool_name}") + return ToolGuardrailFunctionOutput.allow() + + @tool_output_guardrail + def inspect_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + observed.append(f"output:{data.output}") + return ToolGuardrailFunctionOutput.allow() + + @tool( + tool_input_guardrails=[inspect_input], + tool_output_guardrails=[inspect_output], + ) + def guarded_lookup(value: int) -> str: + """Look up a deterministic guarded value.""" + observed.append(f"tool:{value}") + return "guarded-ready" + + agent = Agent( + name="Packaged tool guardrail agent", + model=integration_model, + instructions="Call guarded_lookup with value 42 and then reply TOOL_GUARDRAILS_READY.", + tools=[guarded_lookup], + model_settings={"max_tokens": 384}, + ) + + result = await Runner.run( + agent, + "Use the guarded lookup.", + run_config=RunConfig( + tracing_disabled=True, + tool_execution=ToolExecutionConfig(pre_approval_tool_input_guardrails=True), + ), + ) + + assert result.final_output == "TOOL_GUARDRAILS_READY" + assert observed == ["input:guarded_lookup", "tool:42", "output:guarded-ready"] + + +@pytest.mark.parametrize("blocked", [False, True], ids=["accepted", "blocked"]) +async def test_streaming_output_guardrails_validate_live_model_results( + integration_model: str, blocked: bool +) -> None: + inspected: list[str] = [] + + @output_guardrail + async def inspect_result( + context: RunContextWrapper[Any], agent: Agent[Any], output: str + ) -> GuardrailFunctionOutput: + del context, agent + inspected.append(output) + return GuardrailFunctionOutput(output_info={"checked": True}, tripwire_triggered=blocked) + + agent = Agent( + name="Packaged streamed output guardrail agent", + model=integration_model, + instructions="Reply with exactly STREAM_GUARDED_RESULT.", + output_guardrails=[inspect_result], + model_settings={"max_tokens": 256}, + ) + result = Runner.run_streamed( + agent, + "Return the deterministic streamed guarded result.", + run_config=RunConfig(tracing_disabled=True), + ) + if blocked: + with pytest.raises(OutputGuardrailTripwireTriggered): + async for _event in result.stream_events(): + pass + else: + async for _event in result.stream_events(): + pass + assert result.final_output == "STREAM_GUARDED_RESULT" + + assert inspected == ["STREAM_GUARDED_RESULT"] diff --git a/integration_tests/openai/test_handoffs.py b/integration_tests/openai/test_handoffs.py new file mode 100644 index 0000000000..35758011d2 --- /dev/null +++ b/integration_tests/openai/test_handoffs.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from agents import ( + Agent, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + ToolCallItem, + ToolCallOutputItem, + handoff, +) +from agents.decorators import tool +from agents.extensions.handoff_filters import remove_all_tools + +pytestmark = pytest.mark.core + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +@pytest.mark.parametrize("nested", [False, True], ids=["flat-history", "nested-history"]) +async def test_client_side_handoff_preserves_tool_ownership_and_filtered_history( + integration_model: str, streaming: bool, nested: bool +) -> None: + calls: list[str] = [] + + @tool + def lookup_ticket(ticket: str) -> str: + """Return the deterministic status for a support ticket.""" + calls.append(ticket) + return "resolved" + + specialist = Agent( + name="Packaged support specialist", + model=integration_model, + instructions=( + "Call lookup_ticket exactly once with ticket='CASE-42', then answer " + "exactly HANDOFF_RESOLVED." + ), + tools=[lookup_ticket], + model_settings={"max_tokens": 512}, + ) + coordinator = Agent( + name="Packaged handoff coordinator", + model=integration_model, + instructions="Immediately transfer this support ticket to the support specialist.", + handoffs=[handoff(specialist, input_filter=remove_all_tools)], + model_settings={"max_tokens": 512}, + ) + config = RunConfig(tracing_disabled=True, nest_handoff_history=nested) + result: RunResult | RunResultStreaming + + if streaming: + streamed = Runner.run_streamed( + coordinator, "Resolve support ticket CASE-42.", run_config=config + ) + event_types = [event.type async for event in streamed.stream_events()] + assert "agent_updated_stream_event" in event_types + result = streamed + else: + result = await Runner.run(coordinator, "Resolve support ticket CASE-42.", run_config=config) + + assert calls == ["CASE-42"] + assert result.final_output == "HANDOFF_RESOLVED" + assert result.last_agent is specialist + assert any( + isinstance(item, ToolCallItem) and item.agent is specialist for item in result.new_items + ) + assert any( + isinstance(item, ToolCallOutputItem) and item.agent is specialist + for item in result.new_items + ) + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_nested_agent_as_tool_runs_against_the_installed_distribution( + integration_model: str, streaming: bool +) -> None: + worker = Agent( + name="Packaged nested worker", + model=integration_model, + instructions="Reply with exactly INNER:42.", + model_settings={"max_tokens": 256}, + ) + coordinator = Agent( + name="Packaged nested coordinator", + model=integration_model, + instructions="Call ask_worker, then reply exactly OUTER:42.", + model_settings={"max_tokens": 384}, + tools=[ + worker.as_tool( + tool_name="ask_worker", + tool_description="Ask the nested worker for the deterministic answer.", + ) + ], + ) + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + + if streaming: + streamed = Runner.run_streamed(coordinator, "Use the nested worker.", run_config=config) + async for _event in streamed.stream_events(): + pass + result = streamed + else: + result = await Runner.run(coordinator, "Use the nested worker.", run_config=config) + + assert result.final_output == "OUTER:42" + assert any(isinstance(item, ToolCallItem) for item in result.new_items) + assert any(isinstance(item, ToolCallOutputItem) for item in result.new_items) diff --git a/integration_tests/openai/test_model_settings.py b/integration_tests/openai/test_model_settings.py new file mode 100644 index 0000000000..0796e3a6e8 --- /dev/null +++ b/integration_tests/openai/test_model_settings.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from openai.resources.responses import AsyncResponses +from openai.types.shared import Reasoning + +from agents import Agent, ModelSettings, RunConfig, Runner +from agents.retry import ModelRetryBackoffSettings, ModelRetrySettings + +pytestmark = pytest.mark.core + + +@pytest.fixture +def captured_response_requests(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + requests: list[dict[str, Any]] = [] + original_create = AsyncResponses.create + + async def capture_request(responses: AsyncResponses, *args: Any, **kwargs: Any) -> Any: + requests.append(kwargs) + return await original_create(responses, *args, **kwargs) + + monkeypatch.setattr(AsyncResponses, "create", capture_request) + return requests + + +@pytest.mark.parametrize("dictionary", [False, True], ids=["typed", "dictionary"]) +async def test_agent_model_settings_reach_the_live_responses_api( + integration_model: str, dictionary: bool, captured_response_requests: list[dict[str, Any]] +) -> None: + settings: ModelSettings | dict[str, Any] + if dictionary: + settings = {"reasoning": {"effort": "low"}, "max_tokens": 256} + else: + settings = ModelSettings(reasoning=Reasoning(effort="low"), max_tokens=256) + + agent = Agent( + name="Packaged settings agent", + model=integration_model, + instructions="Reply with exactly PACKAGED_SETTINGS_OK.", + model_settings=settings, + ) + result = await Runner.run(agent, "Confirm the packaged settings path.") + + assert isinstance(agent.model_settings, ModelSettings) + assert result.final_output == "PACKAGED_SETTINGS_OK" + assert result.context_wrapper.usage.total_tokens > 0 + assert len(captured_response_requests) == 1 + assert captured_response_requests[0]["max_output_tokens"] == 256 + assert captured_response_requests[0]["reasoning"].effort == "low" + + +@pytest.mark.parametrize("dictionary", [False, True], ids=["typed", "dictionary"]) +async def test_run_config_model_settings_reach_the_live_responses_api( + integration_model: str, dictionary: bool, captured_response_requests: list[dict[str, Any]] +) -> None: + settings: ModelSettings | dict[str, Any] + if dictionary: + settings = {"reasoning": {"effort": "low"}, "max_tokens": 256} + else: + settings = ModelSettings(reasoning=Reasoning(effort="low"), max_tokens=256) + + config = RunConfig(model_settings=settings, tracing_disabled=True) + agent = Agent( + name="Packaged run configuration agent", + model=integration_model, + instructions="Reply with exactly RUN_CONFIG_OK.", + ) + result = await Runner.run(agent, "Confirm the packaged run configuration.", run_config=config) + + assert isinstance(config.model_settings, ModelSettings) + assert result.final_output == "RUN_CONFIG_OK" + assert len(captured_response_requests) == 1 + assert captured_response_requests[0]["max_output_tokens"] == 256 + assert captured_response_requests[0]["reasoning"].effort == "low" + + +async def test_nested_retry_settings_and_clone_dictionaries_reach_the_api( + integration_model: str, +) -> None: + agent = Agent( + name="Packaged nested settings agent", + model=integration_model, + instructions="Reply with exactly NESTED_SETTINGS_OK.", + model_settings={ + "max_tokens": 256, + "reasoning": {"effort": "low"}, + "retry": { + "max_retries": 0, + "backoff": {"initial_delay": 0.0}, + }, + }, + ) + assert isinstance(agent.model_settings.retry, ModelRetrySettings) + assert isinstance(agent.model_settings.retry.backoff, ModelRetryBackoffSettings) + cloned = agent.clone( + model_settings={ + "max_tokens": 256, + "reasoning": {"effort": "low"}, + "retry": {"max_retries": 0, "backoff": {"initial_delay": 0.0}}, + } + ) + result = await Runner.run(cloned, "Confirm provider-specific settings normalization.") + + assert isinstance(cloned.model_settings, ModelSettings) + assert isinstance(cloned.model_settings.retry, ModelRetrySettings) + assert isinstance(cloned.model_settings.retry.backoff, ModelRetryBackoffSettings) + assert result.final_output == "NESTED_SETTINGS_OK" diff --git a/integration_tests/openai/test_responses.py b/integration_tests/openai/test_responses.py new file mode 100644 index 0000000000..c67effea7b --- /dev/null +++ b/integration_tests/openai/test_responses.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from openai.resources.responses import AsyncResponses +from pydantic import BaseModel + +from agents import ( + Agent, + ModelSettings, + RunConfig, + Runner, + RunResult, + RunResultStreaming, +) +from agents.decorators import tool +from agents.items import ToolCallItem, ToolCallOutputItem + +pytestmark = pytest.mark.core + + +class StructuredStatus(BaseModel): + status: str + value: int + + +class NestedStructuredStatus(BaseModel): + result: StructuredStatus + note: str | None = None + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_responses_function_tools_preserve_calls_outputs_and_usage( + integration_model: str, streaming: bool +) -> None: + called: list[int] = [] + + @tool + def double_number(value: int) -> int: + """Double the supplied number.""" + called.append(value) + return value * 2 + + agent = Agent( + name="Packaged Responses tool agent", + model=integration_model, + instructions="Call double_number with value 21, then reply exactly RESULT:42.", + tools=[double_number], + model_settings=ModelSettings(max_tokens=512), + ) + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + + if streaming: + result = Runner.run_streamed(agent, "Use the tool now.", run_config=config) + events = [event async for event in result.stream_events()] + assert any(event.type == "raw_response_event" for event in events) + else: + result = await Runner.run(agent, "Use the tool now.", run_config=config) + + assert called == [21] + assert result.final_output == "RESULT:42" + assert any(isinstance(item, ToolCallItem) for item in result.new_items) + assert any(isinstance(item, ToolCallOutputItem) for item in result.new_items) + assert result.context_wrapper.usage.total_tokens > 0 + + +async def test_responses_structured_output_is_deserialized_from_the_installed_wheel( + integration_model: str, +) -> None: + agent = Agent( + name="Packaged structured output agent", + model=integration_model, + instructions="Return status READY and value 42.", + output_type=StructuredStatus, + model_settings={"max_tokens": 256}, + ) + result = await Runner.run( + agent, + "Return the requested structured result.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert isinstance(result.final_output, StructuredStatus) + assert result.final_output.status == "READY" + assert result.final_output.value == 42 + + +async def test_previous_response_id_preserves_server_managed_conversation( + integration_model: str, +) -> None: + agent = Agent( + name="Packaged server conversation agent", + model=integration_model, + model_settings={"max_tokens": 256}, + ) + first = await Runner.run( + agent, + "Remember that the secret verification word is ORCHID. Reply only STORED.", + run_config=RunConfig(tracing_disabled=True), + ) + assert first.last_response_id is not None + + second = await Runner.run( + agent, + "What verification word did I ask you to remember? Reply with only that word.", + previous_response_id=first.last_response_id, + run_config=RunConfig(tracing_disabled=True), + ) + + assert second.final_output.strip().upper() == "ORCHID" + assert second.last_response_id != first.last_response_id + + +async def test_streaming_structured_output_preserves_nested_optional_fields( + integration_model: str, +) -> None: + agent = Agent( + name="Packaged streamed structured output agent", + model=integration_model, + instructions="Return result status READY, result value 42, and note null.", + output_type=NestedStructuredStatus, + model_settings={"max_tokens": 384}, + ) + + result = Runner.run_streamed( + agent, + "Return the nested structured status.", + run_config=RunConfig(tracing_disabled=True), + ) + event_types = [event.type async for event in result.stream_events()] + + assert isinstance(result.final_output, NestedStructuredStatus) + assert result.final_output.result == StructuredStatus(status="READY", value=42) + assert result.final_output.note is None + assert "raw_response_event" in event_types + + +async def test_explicit_prompt_cache_settings_reach_the_live_responses_api( + integration_model: str, monkeypatch: pytest.MonkeyPatch +) -> None: + captured_requests: list[dict[str, Any]] = [] + original_create = AsyncResponses.create + + async def capture_request(responses: AsyncResponses, *args: Any, **kwargs: Any) -> Any: + captured_requests.append(kwargs) + return await original_create(responses, *args, **kwargs) + + monkeypatch.setattr(AsyncResponses, "create", capture_request) + prefix = " ".join(f"release-checkpoint-{index}" for index in range(1100)) + agent = Agent( + name="Packaged prompt caching agent", + model=integration_model, + instructions="Reply with exactly PROMPT_CACHE_READY.", + model_settings=ModelSettings( + max_tokens=128, + prompt_cache_options={"mode": "explicit", "ttl": "30m"}, + extra_args={"prompt_cache_key": "packaged-integration-explicit-cache"}, + ), + ) + request_input: list[Any] = [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": prefix, + "prompt_cache_breakpoint": {"mode": "explicit"}, + }, + {"type": "input_text", "text": "Reply with PROMPT_CACHE_READY."}, + ], + } + ] + + result = await Runner.run( + agent, + request_input, + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "PROMPT_CACHE_READY" + assert result.context_wrapper.usage.input_tokens > 0 + assert len(captured_requests) == 1 + assert captured_requests[0]["prompt_cache_options"] == {"mode": "explicit", "ttl": "30m"} + assert captured_requests[0]["prompt_cache_key"] == "packaged-integration-explicit-cache" + assert captured_requests[0]["input"][0]["content"][0]["prompt_cache_breakpoint"] == { + "mode": "explicit" + } diff --git a/integration_tests/openai/test_retry.py b/integration_tests/openai/test_retry.py new file mode 100644 index 0000000000..85a0c1e480 --- /dev/null +++ b/integration_tests/openai/test_retry.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import httpx +import pytest +from openai import APIConnectionError, AsyncOpenAI + +from agents import ( + Agent, + ModelRetrySettings, + ModelSettings, + OpenAIResponsesModel, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + SQLiteSession, + retry_policies, +) + +pytestmark = pytest.mark.core + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_retry_reaches_real_api_without_rewinding_session_input( + integration_model: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, streaming: bool +) -> None: + model = OpenAIResponsesModel(model=integration_model, openai_client=AsyncOpenAI()) + original_fetch = model._fetch_response + attempts = 0 + + async def fail_once(*args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise APIConnectionError( + message="Controlled integration-test transport failure.", + request=httpx.Request("POST", "https://api.openai.com/v1/responses"), + ) + return await original_fetch(*args, **kwargs) + + monkeypatch.setattr(model, "_fetch_response", fail_once) + agent = Agent( + name="Packaged real retry agent", + model=model, + instructions="Reply with exactly RETRY_RECOVERED.", + model_settings=ModelSettings( + max_tokens=256, + retry=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0.0}, + policy=retry_policies.network_error(), + ), + ), + ) + session = SQLiteSession("packaged-retry", tmp_path / "retry.sqlite3") + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + + try: + if streaming: + streamed = Runner.run_streamed( + agent, "Recover exactly once.", session=session, run_config=config + ) + async for _event in streamed.stream_events(): + pass + result = streamed + else: + result = await Runner.run( + agent, "Recover exactly once.", session=session, run_config=config + ) + session_items = await session.get_items() + finally: + session.close() + + assert attempts == 2 + assert result.final_output == "RETRY_RECOVERED" + assert [item.get("role") for item in session_items] == ["user", "assistant"] diff --git a/integration_tests/openai/test_sessions.py b/integration_tests/openai/test_sessions.py new file mode 100644 index 0000000000..73d3132815 --- /dev/null +++ b/integration_tests/openai/test_sessions.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import pytest +from openai import AsyncOpenAI + +from agents import ( + Agent, + ModelSettings, + OpenAIConversationsSession, + OpenAIResponsesCompactionSession, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + SQLiteSession, +) +from agents.decorators import tool + +pytestmark = pytest.mark.core + + +async def test_sqlite_session_persists_tool_history_across_reopened_instances( + integration_model: str, tmp_path: Path +) -> None: + calls: list[str] = [] + + @tool + def lookup_codeword(label: str) -> str: + """Look up the deterministic secret codeword.""" + calls.append(label) + return "MARIGOLD" + + agent = Agent( + name="Packaged SQLite session agent", + model=integration_model, + instructions="Use lookup_codeword when requested and remember its result.", + model_settings={"max_tokens": 384}, + tools=[lookup_codeword], + ) + database = tmp_path / "conversation.sqlite3" + session = SQLiteSession("packaged-live-session", database) + config = RunConfig(tracing_disabled=True) + try: + first = await Runner.run( + agent, + "Use lookup_codeword with label='release' and reply only STORED.", + session=session, + run_config=config, + ) + assert first.final_output == "STORED" + assert calls == ["release"] + finally: + session.close() + + reopened = SQLiteSession("packaged-live-session", database) + try: + second = await Runner.run( + agent, + "What exact codeword did the tool return? Answer with that word only.", + session=reopened, + run_config=config, + ) + saved_items = await reopened.get_items() + finally: + reopened.close() + + assert second.final_output.strip().upper() == "MARIGOLD" + assert calls == ["release"] + assert any(item.get("type") == "function_call_output" for item in saved_items) + + +async def test_explicit_input_replay_preserves_a_real_response_history( + integration_model: str, +) -> None: + agent = Agent( + name="Packaged explicit replay agent", + model=integration_model, + model_settings={"max_tokens": 256}, + ) + config = RunConfig(tracing_disabled=True) + first = await Runner.run( + agent, + "Remember that the verification number is 907. Reply only STORED.", + run_config=config, + ) + replay = first.to_input_list() + assert any( + item.get("role") == "user" and "verification number is 907" in str(item.get("content")) + for item in replay + ) + second = await Runner.run( + agent, + replay + + [ + { + "role": "user", + "content": "What verification number did I provide? Reply with only the number.", + } + ], + run_config=config, + ) + + assert second.final_output.strip() == "907" + + +async def test_streamed_previous_response_id_continues_server_managed_history( + integration_model: str, +) -> None: + agent = Agent( + name="Packaged streamed continuation agent", + model=integration_model, + model_settings={"max_tokens": 256}, + ) + config = RunConfig(tracing_disabled=True) + first = await Runner.run( + agent, + "Remember that the state token is IVORY. Reply only STORED.", + run_config=config, + ) + assert first.last_response_id is not None + + second = Runner.run_streamed( + agent, + "What state token did I provide? Answer with only the token.", + previous_response_id=first.last_response_id, + run_config=config, + ) + async for _event in second.stream_events(): + pass + + assert second.final_output.strip().upper() == "IVORY" + assert second.last_response_id != first.last_response_id + + +async def test_openai_conversation_id_preserves_server_owned_state( + integration_model: str, +) -> None: + client = AsyncOpenAI() + conversation = await client.conversations.create() + agent = Agent( + name="Packaged OpenAI conversation agent", + model=integration_model, + model_settings={"max_tokens": 256}, + ) + config = RunConfig(tracing_disabled=True) + + try: + await Runner.run( + agent, + "Remember the project color is CERULEAN. Reply only STORED.", + conversation_id=conversation.id, + run_config=config, + ) + second = await Runner.run( + agent, + "What is the project color? Reply with only the color.", + conversation_id=conversation.id, + run_config=config, + ) + finally: + await client.conversations.delete(conversation.id) + + assert second.final_output.strip().upper() == "CERULEAN" + + +async def test_auto_previous_response_id_preserves_tool_output_across_turns( + integration_model: str, +) -> None: + calls: list[str] = [] + + @tool + def read_checkpoint(name: str) -> str: + """Read a deterministic server-managed continuation checkpoint.""" + calls.append(name) + return "CHECKPOINT:84" + + agent = Agent( + name="Packaged automatic continuation agent", + model=integration_model, + instructions=( + "Call read_checkpoint with name='release', then reply exactly AUTO_CONTINUATION:84." + ), + model_settings={"max_tokens": 384}, + tools=[read_checkpoint], + ) + result = await Runner.run( + agent, + "Read the release checkpoint.", + auto_previous_response_id=True, + run_config=RunConfig(tracing_disabled=True), + ) + + assert calls == ["release"] + assert result.final_output == "AUTO_CONTINUATION:84" + assert result.last_response_id is not None + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_openai_conversations_session_preserves_server_managed_history( + integration_model: str, + streaming: bool, +) -> None: + session = OpenAIConversationsSession(session_settings={"limit": 20}) + agent = Agent( + name="Packaged OpenAI Conversations session agent", + model=integration_model, + instructions="Remember user-provided release words and follow exact output instructions.", + model_settings={"max_tokens": 192}, + ) + config = RunConfig(tracing_disabled=True) + + try: + first = await Runner.run( + agent, + "Remember the release word COBALT and reply only STORED.", + session=session, + run_config=config, + ) + second: RunResult | RunResultStreaming + if streaming: + second = Runner.run_streamed( + agent, + "What release word did I give you? Reply with that word only.", + session=session, + run_config=config, + ) + async for _event in second.stream_events(): + pass + else: + second = await Runner.run( + agent, + "What release word did I give you? Reply with that word only.", + session=session, + run_config=config, + ) + items = await session.get_items() + finally: + await session.clear_session() + + assert first.final_output == "STORED" + assert second.final_output == "COBALT" + assert len(items) >= 4 + assert any("COBALT" in str(item) for item in items) + + +@pytest.mark.nightly +@pytest.mark.parametrize( + ("compaction_mode", "store"), + [("auto", False), ("previous_response_id", True)], + ids=["stateless-input", "stored-previous-response"], +) +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_responses_compaction_preserves_history_across_owner_modes( + integration_model: str, + compaction_mode: Literal["auto", "previous_response_id"], + store: bool, + streaming: bool, +) -> None: + underlying = SQLiteSession(f"packaged-compaction-{compaction_mode}-{streaming}") + compacted = OpenAIResponsesCompactionSession( + session_id=underlying.session_id, + underlying_session=underlying, + model=integration_model, + compaction_mode=compaction_mode, + should_trigger_compaction=lambda context: bool(context["compaction_candidate_items"]), + ) + agent = Agent( + name="Packaged Responses compaction agent", + model=integration_model, + instructions="Remember user-provided release words and follow exact output instructions.", + model_settings=ModelSettings(store=store, max_tokens=192), + ) + config = RunConfig(tracing_disabled=True) + + try: + first = await Runner.run( + agent, + "Remember the release word JASPER and reply only STORED.", + session=compacted, + run_config=config, + ) + first_items = await underlying.get_items() + second: RunResult | RunResultStreaming + if streaming: + second = Runner.run_streamed( + agent, + "What release word did I give you? Reply with that word only.", + session=compacted, + run_config=config, + ) + async for _event in second.stream_events(): + pass + else: + second = await Runner.run( + agent, + "What release word did I give you? Reply with that word only.", + session=compacted, + run_config=config, + ) + second_items = await underlying.get_items() + finally: + underlying.close() + + assert first.final_output == "STORED" + assert second.final_output == "JASPER" + assert any(item.get("type") == "compaction" for item in first_items) + assert any(item.get("type") == "compaction" for item in second_items) diff --git a/integration_tests/openai/test_tracing.py b/integration_tests/openai/test_tracing.py new file mode 100644 index 0000000000..7212d185d1 --- /dev/null +++ b/integration_tests/openai/test_tracing.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any, cast + +import pytest + +from agents import ( + Agent, + RunConfig, + Runner, + RunResult, + RunResultStreaming, + Span, + Trace, + TracingProcessor, + set_trace_processors, + set_tracing_disabled, +) +from agents.decorators import tool +from agents.tracing import get_trace_provider + +pytestmark = [pytest.mark.core, pytest.mark.nightly] + + +class CollectingTraceProcessor(TracingProcessor): + def __init__(self) -> None: + self.started_traces: list[Trace] = [] + self.finished_traces: list[Trace] = [] + self.started_spans: list[Span[Any]] = [] + self.finished_spans: list[Span[Any]] = [] + + def on_trace_start(self, trace: Trace) -> None: + self.started_traces.append(trace) + + def on_trace_end(self, trace: Trace) -> None: + self.finished_traces.append(trace) + + def on_span_start(self, span: Span[Any]) -> None: + self.started_spans.append(span) + + def on_span_end(self, span: Span[Any]) -> None: + self.finished_spans.append(span) + + def shutdown(self) -> None: + return None + + def force_flush(self) -> None: + return None + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_live_model_and_tool_spans_finish_without_exposing_sensitive_data( + integration_model: str, monkeypatch: pytest.MonkeyPatch, streaming: bool +) -> None: + calls: list[str] = [] + + @tool + def inspect_secret(value: str) -> str: + """Inspect a deterministic sensitive verification value.""" + calls.append(value) + return "TRACE_READY" + + agent = Agent( + name="Packaged traced agent", + model=integration_model, + instructions=( + "Call inspect_secret with value='secret-token-42', then reply with exactly TRACE_READY." + ), + tools=[inspect_secret], + model_settings={"max_tokens": 384}, + ) + processor = CollectingTraceProcessor() + provider = cast(Any, get_trace_provider()) + original_processors = list(provider._multi_processor._processors) + original_env_disabled = provider._env_disabled + original_manual_disabled = provider._manual_disabled + original_disabled = provider._disabled + monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "0") + set_trace_processors([processor]) + set_tracing_disabled(False) + result: RunResult | RunResultStreaming + try: + config = RunConfig( + tracing_disabled=False, + trace_include_sensitive_data=False, + workflow_name="Packaged tracing compatibility", + ) + if streaming: + result = Runner.run_streamed(agent, "Inspect the secret.", run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "Inspect the secret.", run_config=config) + finally: + set_trace_processors(original_processors) + provider._env_disabled = original_env_disabled + provider._manual_disabled = original_manual_disabled + provider._disabled = original_disabled + + assert calls == ["secret-token-42"] + assert result.final_output == "TRACE_READY" + assert len(processor.started_traces) == len(processor.finished_traces) == 1 + assert len(processor.started_spans) == len(processor.finished_spans) + span_types = {span.span_data.type for span in processor.finished_spans} + assert "agent" in span_types + assert "response" in span_types + assert "function" in span_types + assert all(span.ended_at is not None for span in processor.finished_spans) + assert all("secret-token-42" not in str(span.export()) for span in processor.finished_spans) diff --git a/integration_tests/openai/test_websocket.py b/integration_tests/openai/test_websocket.py new file mode 100644 index 0000000000..949d00bfdd --- /dev/null +++ b/integration_tests/openai/test_websocket.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import pytest + +from agents import ( + Agent, + ModelSettings, + ToolCallOutputItem, + responses_websocket_session, +) +from agents.decorators import tool +from agents.models.openai_responses import OpenAIResponsesWSModel + +pytestmark = [pytest.mark.core, pytest.mark.nightly] + + +async def test_responses_websocket_session_reuses_a_connection_across_tool_turns( + integration_model: str, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[str] = [] + opened_connections: list[Any] = [] + original_open = OpenAIResponsesWSModel._open_websocket_connection + + async def capture_connection( + model: OpenAIResponsesWSModel, + url: str, + headers: Mapping[str, str], + *, + connect_timeout: float | None, + ) -> Any: + connection = await original_open(model, url, headers, connect_timeout=connect_timeout) + opened_connections.append(connection) + return connection + + monkeypatch.setattr(OpenAIResponsesWSModel, "_open_websocket_connection", capture_connection) + + @tool + def lookup_checkpoint(name: str) -> str: + """Return a deterministic websocket checkpoint.""" + calls.append(name) + return "WEBSOCKET:42" + + agent = Agent( + name="Packaged Responses websocket agent", + model=integration_model, + instructions=( + "When asked to check a checkpoint, call lookup_checkpoint with name='release'. " + "For a confirmation request, reply exactly WEBSOCKET_CONFIRMED." + ), + tools=[lookup_checkpoint], + model_settings=ModelSettings(max_tokens=384), + ) + + async with responses_websocket_session() as session: + first = await session.run( + agent, + "Check the checkpoint and include WEBSOCKET:42 in your answer.", + ) + second = session.run_streamed(agent, "Reply with exactly WEBSOCKET_CONFIRMED.") + event_types = [event.type async for event in second.stream_events()] + + assert calls == ["release"] + assert "WEBSOCKET:42" in str(first.final_output) + assert any(isinstance(item, ToolCallOutputItem) for item in first.new_items) + assert second.final_output == "WEBSOCKET_CONFIRMED" + assert "raw_response_event" in event_types + assert first.context_wrapper.usage.total_tokens > 0 + assert second.context_wrapper.usage.total_tokens > 0 + assert len(opened_connections) == 1 diff --git a/integration_tests/packaging/test_distribution_contents.py b/integration_tests/packaging/test_distribution_contents.py new file mode 100644 index 0000000000..b965aac5b4 --- /dev/null +++ b/integration_tests/packaging/test_distribution_contents.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import importlib +import importlib.metadata +import importlib.util +import os +import sys +import tarfile +import warnings +import zipfile +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.packaging + + +def test_wheel_excludes_integration_tests_and_contains_runtime_modules() -> None: + wheel = Path(os.environ["OPENAI_AGENTS_INTEGRATION_WHEEL"]) + with zipfile.ZipFile(wheel) as archive: + members = archive.namelist() + + assert not any(Path(member).parts[0] == "integration_tests" for member in members) + assert "agents/py.typed" in members + assert "agents/realtime/session.py" in members + assert "agents/voice/pipeline.py" in members + assert "agents/extensions/models/any_llm_model.py" in members + assert "agents/extensions/models/litellm_model.py" in members + assert "agents/extensions/experimental/hosted_multi_agent/model.py" in members + + +def test_source_distribution_excludes_repository_automation_and_local_caches() -> None: + source_distribution = Path(os.environ["OPENAI_AGENTS_INTEGRATION_SDIST"]) + with tarfile.open(source_distribution, "r:gz") as archive: + members = archive.getnames() + + assert not any( + len(Path(member).parts) > 1 + and ( + Path(member).parts[1] in {".agents", ".github", "integration_tests"} + or Path(member).parts[1].startswith((".tmp", ".uv")) + ) + for member in members + ) + assert any(member.endswith("/src/agents/py.typed") for member in members) + + +def test_installed_distribution_advertises_expected_optional_extras() -> None: + distribution = importlib.metadata.distribution("openai-agents") + extras = set(distribution.metadata.get_all("Provides-Extra") or []) + + assert { + "any-llm", + "encrypt", + "litellm", + "realtime", + "redis", + "s3", + "sqlalchemy", + "viz", + "voice", + }.issubset(extras) + assert distribution.version + + +@pytest.mark.parametrize( + "module_name", + [ + "agents", + "agents.models.openai_responses", + "agents.models.openai_chatcompletions", + "agents.decorators", + "agents.guardrail", + "agents.handoffs", + "agents.memory", + "agents.model_settings", + "agents.realtime", + "agents.responses_websocket_session", + "agents.run", + "agents.run_config", + "agents.tool", + "agents.tool_guardrails", + "agents.tracing", + "agents.extensions.experimental.hosted_multi_agent", + ], +) +def test_public_runtime_modules_import_from_the_distribution(module_name: str) -> None: + module = importlib.import_module(module_name) + + assert module.__file__ is not None + assert "site-packages" in Path(module.__file__).parts + + +@pytest.mark.parametrize( + ("module_name", "export_name", "canonical_module", "canonical_name"), + [ + ("agents.decorators", "function_tool", "agents", "function_tool"), + ("agents.decorators", "tool", "agents", "function_tool"), + ("agents.decorators", "input_guardrail", "agents", "input_guardrail"), + ("agents.decorators", "output_guardrail", "agents", "output_guardrail"), + ("agents.decorators", "tool_input_guardrail", "agents", "tool_input_guardrail"), + ("agents.decorators", "tool_output_guardrail", "agents", "tool_output_guardrail"), + ("agents.agent", "Agent", "agents", "Agent"), + ("agents.run", "Runner", "agents", "Runner"), + ("agents.run_config", "RunConfig", "agents", "RunConfig"), + ("agents.model_settings", "ModelSettings", "agents", "ModelSettings"), + ("agents.guardrail", "input_guardrail", "agents", "input_guardrail"), + ("agents.tool", "function_tool", "agents", "function_tool"), + ("agents.tool_guardrails", "tool_input_guardrail", "agents", "tool_input_guardrail"), + ("agents.memory", "SQLiteSession", "agents", "SQLiteSession"), + ("agents.memory.sqlite_session", "SQLiteSession", "agents", "SQLiteSession"), + ( + "agents.responses_websocket_session", + "ResponsesWebSocketSession", + "agents", + "ResponsesWebSocketSession", + ), + ("agents.tracing", "TracingProcessor", "agents", "TracingProcessor"), + ( + "agents.realtime.model_events", + "RealtimeModelUsageEvent", + "agents.realtime", + "RealtimeModelUsageEvent", + ), + ], +) +def test_supported_import_paths_resolve_to_canonical_runtime_objects( + module_name: str, + export_name: str, + canonical_module: str, + canonical_name: str, +) -> None: + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + module = importlib.import_module(module_name) + canonical = importlib.import_module(canonical_module) + actual = getattr(module, export_name) + + assert actual is getattr(canonical, canonical_name) + assert not any(isinstance(warning.message, DeprecationWarning) for warning in captured) + + +def test_decorators_module_exports_supported_runtime_aliases() -> None: + decorators = importlib.import_module("agents.decorators") + + assert decorators.__all__ == [ + "function_tool", + "input_guardrail", + "output_guardrail", + "tool", + "tool_input_guardrail", + "tool_output_guardrail", + ] + assert decorators.tool is decorators.function_tool + + def legacy_status() -> str: + """Return the supported legacy decorator status.""" + return "LEGACY_DECORATOR_READY" + + decorated_status = decorators.tool(legacy_status) + assert decorated_status.name == "legacy_status" + + +@pytest.mark.parametrize( + ("module_name", "dependency_name", "expected_extra"), + [ + ("agents.extensions.models.any_llm_model", "any_llm", "any-llm"), + ("agents.extensions.models.litellm_model", "litellm", "litellm"), + ], +) +def test_optional_provider_modules_fail_with_actionable_install_guidance( + module_name: str, dependency_name: str, expected_extra: str +) -> None: + if importlib.util.find_spec(dependency_name) is not None: + pytest.skip(f"{dependency_name} is already installed in this isolated environment.") + + sys.modules.pop(module_name, None) + with pytest.raises(ImportError, match=rf"openai-agents\[{expected_extra}\]"): + importlib.import_module(module_name) diff --git a/integration_tests/packaging/test_optional_extras.py b/integration_tests/packaging/test_optional_extras.py new file mode 100644 index 0000000000..45e586a5d3 --- /dev/null +++ b/integration_tests/packaging/test_optional_extras.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib +import os +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.extras + + +def test_requested_optional_extra_imports_from_its_standalone_environment() -> None: + optional_extra = os.environ["OPENAI_AGENTS_INTEGRATION_EXTRA"] + module_names = { + "any-llm": "agents.extensions.models.any_llm_model", + "encrypt": "agents.extensions.memory.encrypt_session", + "litellm": "agents.extensions.models.litellm_model", + "realtime": "agents.realtime", + "redis": "agents.extensions.memory.redis_session", + "s3": "boto3", + "sqlalchemy": "agents.extensions.memory.sqlalchemy_session", + "viz": "agents.extensions.visualization", + "voice": "agents.voice", + } + module = importlib.import_module(module_names[optional_extra]) + + assert module.__file__ is not None + assert "site-packages" in Path(module.__file__).parts + + +@pytest.mark.parametrize( + ("optional_extra", "package_symbol", "module_name", "module_symbol"), + [ + ( + "encrypt", + "EncryptedSession", + "agents.extensions.memory.encrypt_session", + "EncryptedSession", + ), + ("redis", "RedisSession", "agents.extensions.memory.redis_session", "RedisSession"), + ( + "sqlalchemy", + "SQLAlchemySession", + "agents.extensions.memory.sqlalchemy_session", + "SQLAlchemySession", + ), + ], +) +def test_memory_extra_lazy_exports_resolve_to_the_installed_backend( + optional_extra: str, + package_symbol: str, + module_name: str, + module_symbol: str, +) -> None: + if os.environ["OPENAI_AGENTS_INTEGRATION_EXTRA"] != optional_extra: + pytest.skip(f"This environment does not include the {optional_extra} extra.") + + memory = importlib.import_module("agents.extensions.memory") + module = importlib.import_module(module_name) + + assert getattr(memory, package_symbol) is getattr(module, module_symbol) diff --git a/integration_tests/packaging/test_provider_selection.py b/integration_tests/packaging/test_provider_selection.py new file mode 100644 index 0000000000..496487414a --- /dev/null +++ b/integration_tests/packaging/test_provider_selection.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import runpy +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import pytest +from conftest import _external_providers, pytest_runtest_setup + +pytestmark = pytest.mark.packaging + + +def _configure_provider_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-test-key") + monkeypatch.setenv("GEMINI_API_KEY", "gemini-test-key") + monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-test-key") + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_OPENROUTER_MODELS", raising=False) + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_GEMINI_MODEL", raising=False) + + +def test_external_provider_coverage_is_explicitly_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + _configure_provider_credentials(monkeypatch) + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", raising=False) + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS", raising=False) + + assert _external_providers() == [] + + +@pytest.mark.parametrize( + "credential_name", ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] +) +def test_external_provider_tests_do_not_require_an_openai_api_key( + monkeypatch: pytest.MonkeyPatch, + credential_name: str, +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv(credential_name, "provider-test-key") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + item = SimpleNamespace( + fixturenames=["external_provider"], + callspec=SimpleNamespace(params={"external_provider": object()}), + get_closest_marker=lambda name: name == "providers", + ) + + pytest_runtest_setup(cast(pytest.Item, item)) + + +def test_openai_backed_provider_tests_require_an_openai_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "provider-test-key") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + item = SimpleNamespace( + fixturenames=["integration_model"], + get_closest_marker=lambda name: name == "providers", + ) + + with pytest.raises(pytest.fail.Exception, match="Set a real OPENAI_API_KEY"): + pytest_runtest_setup(cast(pytest.Item, item)) + + +@pytest.mark.parametrize( + ("fixture_name", "model_environment", "model_name", "credential_name"), + [ + ( + "any_llm_models", + "OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS", + "openrouter/openai/gpt-5.6-luna", + "OPENROUTER_API_KEY", + ), + ( + "any_llm_models", + "OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS", + "anthropic/claude-sonnet-5", + "ANTHROPIC_API_KEY", + ), + ( + "any_llm_models", + "OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS", + "gemini/gemini-3.6-flash", + "GEMINI_API_KEY", + ), + ( + "litellm_models", + "OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS", + "openrouter/google/gemini-3.6-flash", + "OPENROUTER_API_KEY", + ), + ( + "litellm_models", + "OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS", + "gemini/gemini-3.6-flash", + "GOOGLE_API_KEY", + ), + ], +) +def test_configured_provider_models_use_provider_specific_credentials( + monkeypatch: pytest.MonkeyPatch, + fixture_name: str, + model_environment: str, + model_name: str, + credential_name: str, +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv(model_environment, model_name) + monkeypatch.setenv(credential_name, "provider-test-key") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + item = SimpleNamespace( + fixturenames=[fixture_name], + get_closest_marker=lambda name: name == "providers", + ) + + pytest_runtest_setup(cast(pytest.Item, item)) + + +def test_configured_provider_models_require_their_own_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS", "openrouter/openai/gpt-5.6-luna") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + item = SimpleNamespace( + fixturenames=["any_llm_models"], + get_closest_marker=lambda name: name == "providers", + ) + + with pytest.raises(pytest.fail.Exception, match="Set OPENROUTER_API_KEY"): + pytest_runtest_setup(cast(pytest.Item, item)) + + +def test_configured_openai_provider_rejects_placeholder_api_keys( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test_key") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS", "openai/gpt-4.1-mini") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + item = SimpleNamespace( + fixturenames=["litellm_models"], + get_closest_marker=lambda name: name == "providers", + ) + + with pytest.raises(pytest.fail.Exception, match="Set OPENAI_API_KEY"): + pytest_runtest_setup(cast(pytest.Item, item)) + + +def test_strict_mode_requires_requested_external_provider_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _configure_provider_credentials(monkeypatch) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS", raising=False) + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "1") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + + assert _external_providers() == [] + item = SimpleNamespace( + fixturenames=["external_provider"], + callspec=SimpleNamespace(params={"external_provider": None}), + get_closest_marker=lambda name: name == "providers", + ) + + with pytest.raises(pytest.fail.Exception, match="External provider coverage requires"): + pytest_runtest_setup(cast(pytest.Item, item)) + + +@pytest.mark.parametrize( + ("model", "expected_extra"), + [ + ("anthropic/claude-sonnet-5", "anthropic"), + ("gemini/gemini-3.6-flash", "gemini"), + ("google/gemini-3.6-flash", "gemini"), + ("openrouter/openai/gpt-5.6-luna", "openrouter"), + ], +) +def test_configured_any_llm_models_install_provider_extras_without_external_matrix( + monkeypatch: pytest.MonkeyPatch, model: str, expected_extra: str +) -> None: + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS", model) + runner_path = Path(__file__).resolve().parents[2] / ".github/scripts/run_integration_tests.py" + runner = runpy.run_path(str(runner_path)) + + assert runner["_any_llm_provider_extras"]( + external_providers_enabled=False, direct_providers_enabled=False + ) == [expected_extra] + + +def test_strict_mode_does_not_require_unrequested_external_providers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _configure_provider_credentials(monkeypatch) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", raising=False) + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + + assert _external_providers() == [] + + +def test_strict_mode_accepts_explicit_direct_provider_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _configure_provider_credentials(monkeypatch) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "1") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS", "1") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "1") + + providers = _external_providers() + + assert [provider.name for provider in providers] == ["anthropic"] + + +def test_external_provider_coverage_defaults_to_current_openrouter_models( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _configure_provider_credentials(monkeypatch) + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "1") + monkeypatch.delenv("OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS", raising=False) + + providers = _external_providers() + + assert [provider.name for provider in providers] == [ + "openrouter-openai-gpt-5.6-luna", + "openrouter-anthropic-claude-sonnet-5", + "openrouter-google-gemini-3.6-flash", + ] + assert [provider.model for provider in providers] == [ + "openrouter/openai/gpt-5.6-luna", + "openrouter/anthropic/claude-sonnet-5", + "openrouter/google/gemini-3.6-flash", + ] + + +def test_all_provider_coverage_adds_explicit_direct_provider_models( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _configure_provider_credentials(monkeypatch) + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "1") + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS", "1") + + providers = _external_providers() + + assert [provider.name for provider in providers] == [ + "openrouter-openai-gpt-5.6-luna", + "openrouter-anthropic-claude-sonnet-5", + "openrouter-google-gemini-3.6-flash", + "anthropic", + "gemini", + ] + assert [provider.model for provider in providers[-2:]] == [ + "anthropic/claude-sonnet-5", + "gemini/gemini-3.6-flash", + ] diff --git a/integration_tests/providers/test_any_llm.py b/integration_tests/providers/test_any_llm.py new file mode 100644 index 0000000000..5291032d29 --- /dev/null +++ b/integration_tests/providers/test_any_llm.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agents import Agent, ModelSettings, RunConfig, Runner, RunResult, RunResultStreaming +from agents.decorators import tool + +pytestmark = pytest.mark.providers + + +@pytest.mark.parametrize("dictionary", [False, True], ids=["typed", "dictionary"]) +async def test_any_llm_configured_providers_execute_real_function_tools( + any_llm_models: list[str], dictionary: bool +) -> None: + from agents.extensions.models.any_llm_model import AnyLLMModel + + calls: list[int] = [] + + @tool + def lookup_number(value: int) -> int: + """Return the supplied deterministic number.""" + calls.append(value) + return value + + for model_name in any_llm_models: + calls.clear() + settings: ModelSettings | dict[str, Any] + settings = {"max_tokens": 512} if dictionary else ModelSettings(max_tokens=512) + agent = Agent( + name="Packaged AnyLLM agent", + model=AnyLLMModel(model=model_name), + instructions="Call lookup_number with 42 and then reply exactly ANY_LLM:42.", + model_settings=settings, + tools=[lookup_number], + ) + result = await Runner.run( + agent, + "Use the number tool.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert calls == [42], model_name + assert result.final_output == "ANY_LLM:42", model_name + assert result.context_wrapper.usage.total_tokens > 0, model_name + + +@pytest.mark.parametrize("api", ["responses", "chat_completions"]) +async def test_any_llm_openai_supports_both_api_families(integration_model: str, api: str) -> None: + from agents.extensions.models.any_llm_model import AnyLLMModel + + agent = Agent( + name="Packaged AnyLLM API selector", + model=AnyLLMModel(model=f"openai/{integration_model}", api=api), # type: ignore[arg-type] + instructions="Reply with exactly ANY_LLM_API_OK.", + model_settings={"max_tokens": 256}, + ) + result = await Runner.run( + agent, + "Confirm the selected provider API.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "ANY_LLM_API_OK" + + +@pytest.mark.filterwarnings( + "ignore:Inheritance class AiohttpClientSession from ClientSession is discouraged:" + r"DeprecationWarning:google\.genai\._api_client" +) +async def test_any_llm_major_external_providers_execute_function_tools( + external_provider: Any, +) -> None: + from agents.extensions.models.any_llm_model import AnyLLMModel + + calls: list[str] = [] + + @tool + def provider_status(provider: str) -> str: + """Return the deterministic provider readiness status.""" + calls.append(provider) + return "ready" + + agent = Agent( + name="Packaged AnyLLM external provider agent", + model=AnyLLMModel(model=external_provider.model, api_key=external_provider.api_key), + instructions=( + "Call provider_status exactly once with provider='external', " + "then reply exactly PROVIDER_READY." + ), + model_settings={"max_tokens": 512}, + tools=[provider_status], + ) + result = await Runner.run( + agent, + "Check the provider with its function tool.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert calls == ["external"] + assert result.final_output == "PROVIDER_READY" + assert result.context_wrapper.usage.total_tokens > 0 + + +@pytest.mark.nightly +@pytest.mark.filterwarnings( + "ignore:Inheritance class AiohttpClientSession from ClientSession is discouraged:" + r"DeprecationWarning:google\.genai\._api_client" +) +async def test_any_llm_external_provider_streams_function_tool_results( + external_provider: Any, +) -> None: + from agents.extensions.models.any_llm_model import AnyLLMModel + + calls: list[str] = [] + + @tool + def check_streaming_provider(provider: str) -> str: + """Return the provider's deterministic streaming readiness.""" + calls.append(provider) + return "ready" + + agent = Agent( + name="Packaged AnyLLM streaming external provider agent", + model=AnyLLMModel(model=external_provider.model, api_key=external_provider.api_key), + instructions=( + "Call check_streaming_provider exactly once with provider='external', " + "then reply exactly STREAMING_PROVIDER_READY." + ), + model_settings={"max_tokens": 512}, + tools=[check_streaming_provider], + ) + result = Runner.run_streamed( + agent, + "Check the streamed external provider function tool.", + run_config=RunConfig(tracing_disabled=True), + ) + event_types = [event.type async for event in result.stream_events()] + + assert calls == ["external"] + assert result.final_output == "STREAMING_PROVIDER_READY" + assert "raw_response_event" in event_types + assert result.context_wrapper.usage.total_tokens > 0 + + +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_any_llm_chat_completions_preserves_real_token_logprobs( + streaming: bool, +) -> None: + from agents.extensions.models.any_llm_model import AnyLLMModel + + agent = Agent( + name="Packaged AnyLLM token logprob agent", + model=AnyLLMModel(model="openai/gpt-4.1-mini", api="chat_completions"), + instructions="Reply with exactly BLUE.", + model_settings=ModelSettings(top_logprobs=2, max_tokens=32), + ) + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed(agent, "What color is the sky? Reply BLUE.", run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "What color is the sky? Reply BLUE.", run_config=config) + + texts = [ + content + for response in result.raw_responses + for item in response.output + for content in getattr(item, "content", []) + if getattr(content, "type", None) == "output_text" + ] + assert result.final_output == "BLUE" + assert texts + assert any(getattr(content, "logprobs", None) for content in texts) diff --git a/integration_tests/providers/test_litellm.py b/integration_tests/providers/test_litellm.py new file mode 100644 index 0000000000..fd1d2d371b --- /dev/null +++ b/integration_tests/providers/test_litellm.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agents import Agent, ModelSettings, RunConfig, Runner, RunResult, RunResultStreaming +from agents.decorators import tool + +pytestmark = pytest.mark.providers + + +@pytest.mark.parametrize("dictionary", [False, True], ids=["typed", "dictionary"]) +async def test_litellm_configured_providers_execute_real_function_tools( + litellm_models: list[str], dictionary: bool +) -> None: + from agents.extensions.models.litellm_model import LitellmModel + + calls: list[str] = [] + + @tool + def lookup_package(name: str) -> str: + """Return the deterministic package health.""" + calls.append(name) + return "healthy" + + for model_name in litellm_models: + calls.clear() + settings: ModelSettings | dict[str, Any] + values: dict[str, Any] = {"max_tokens": 512} + settings = values if dictionary else ModelSettings(**values) + agent = Agent( + name="Packaged LiteLLM agent", + model=LitellmModel(model=model_name), + instructions=( + "Call lookup_package with name='openai-agents', then reply exactly LITELLM_OK." + ), + model_settings=settings, + tools=[lookup_package], + ) + result = await Runner.run( + agent, + "Check the installed package.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert calls == ["openai-agents"], model_name + assert result.final_output == "LITELLM_OK", model_name + assert result.context_wrapper.usage.total_tokens > 0, model_name + + +@pytest.mark.filterwarnings( + "ignore:Accessing the 'model_(computed_)?fields' attribute on the instance is deprecated:" + "pydantic.warnings.PydanticDeprecatedSince211:" + r"litellm\.litellm_core_utils\.model_response_utils" +) +async def test_litellm_streaming_preserves_real_provider_usage(integration_model: str) -> None: + from agents.extensions.models.litellm_model import LitellmModel + + agent = Agent( + name="Packaged LiteLLM streaming agent", + model=LitellmModel(model=f"openai/{integration_model}"), + instructions="Reply with exactly LITELLM_STREAM_OK.", + model_settings={"max_tokens": 256}, + ) + result = Runner.run_streamed( + agent, + "Confirm the streaming provider path.", + run_config=RunConfig(tracing_disabled=True), + ) + async for _event in result.stream_events(): + pass + + assert result.final_output == "LITELLM_STREAM_OK" + assert result.context_wrapper.usage.total_tokens > 0 + + +async def test_litellm_major_external_providers_execute_function_tools( + external_provider: Any, +) -> None: + from agents.extensions.models.litellm_model import LitellmModel + + calls: list[str] = [] + + @tool + def provider_status(provider: str) -> str: + """Return the deterministic provider readiness status.""" + calls.append(provider) + return "ready" + + agent = Agent( + name="Packaged LiteLLM external provider agent", + model=LitellmModel(model=external_provider.model, api_key=external_provider.api_key), + instructions=( + "Call provider_status exactly once with provider='external', " + "then reply exactly PROVIDER_READY." + ), + model_settings={"max_tokens": 512}, + tools=[provider_status], + ) + result = await Runner.run( + agent, + "Check the provider with its function tool.", + run_config=RunConfig(tracing_disabled=True), + ) + + assert calls == ["external"] + assert result.final_output == "PROVIDER_READY" + assert result.context_wrapper.usage.total_tokens > 0 + + +@pytest.mark.nightly +@pytest.mark.filterwarnings( + "ignore:Accessing the 'model_(computed_)?fields' attribute on the instance is deprecated:" + "pydantic.warnings.PydanticDeprecatedSince211:" + r"litellm\.litellm_core_utils\.model_response_utils" +) +async def test_litellm_external_provider_streams_function_tool_results( + external_provider: Any, +) -> None: + from agents.extensions.models.litellm_model import LitellmModel + + calls: list[str] = [] + + @tool + def check_streaming_provider(provider: str) -> str: + """Return the provider's deterministic streaming readiness.""" + calls.append(provider) + return "ready" + + agent = Agent( + name="Packaged LiteLLM streaming external provider agent", + model=LitellmModel(model=external_provider.model, api_key=external_provider.api_key), + instructions=( + "Call check_streaming_provider exactly once with provider='external', " + "then reply exactly STREAMING_PROVIDER_READY." + ), + model_settings={"max_tokens": 512, "include_usage": True}, + tools=[check_streaming_provider], + ) + result = Runner.run_streamed( + agent, + "Check the streamed external provider function tool.", + run_config=RunConfig(tracing_disabled=True), + ) + event_types = [event.type async for event in result.stream_events()] + + assert calls == ["external"] + assert result.final_output == "STREAMING_PROVIDER_READY" + assert "raw_response_event" in event_types + assert result.context_wrapper.usage.total_tokens > 0 + + +@pytest.mark.filterwarnings( + "ignore:Accessing the 'model_(computed_)?fields' attribute on the instance is deprecated:" + "pydantic.warnings.PydanticDeprecatedSince211:" + r"litellm\.litellm_core_utils\.model_response_utils" +) +@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +async def test_litellm_preserves_real_token_logprobs( + streaming: bool, +) -> None: + from agents.extensions.models.litellm_model import LitellmModel + + agent = Agent( + name="Packaged LiteLLM token logprob agent", + model=LitellmModel(model="openai/gpt-4.1-mini"), + instructions="Reply with exactly BLUE.", + model_settings=ModelSettings(top_logprobs=2, max_tokens=32), + ) + config = RunConfig(tracing_disabled=True) + result: RunResult | RunResultStreaming + if streaming: + result = Runner.run_streamed(agent, "What color is the sky? Reply BLUE.", run_config=config) + async for _event in result.stream_events(): + pass + else: + result = await Runner.run(agent, "What color is the sky? Reply BLUE.", run_config=config) + + texts = [ + content + for response in result.raw_responses + for item in response.output + for content in getattr(item, "content", []) + if getattr(content, "type", None) == "output_text" + ] + assert result.final_output == "BLUE" + assert texts + assert any(getattr(content, "logprobs", None) for content in texts) diff --git a/integration_tests/pytest.ini b/integration_tests/pytest.ini new file mode 100644 index 0000000000..ab59a65e6e --- /dev/null +++ b/integration_tests/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session +timeout = 75 +testpaths = . +markers = + packaging: Distribution contents and installed-package boundaries. + extras: Independently installed optional dependency groups. + core: Live OpenAI Responses and Chat Completions coverage. + providers: Live AnyLLM and LiteLLM provider-adapter coverage. + realtime: Live OpenAI Realtime WebSocket coverage. + voice: Live OpenAI speech-to-text and text-to-speech coverage. + hosted: Live hosted MCP, multi-agent, and programmatic tool coverage. + nightly: Extended integration coverage selected by the nightly and manual profiles. + manual: Expensive or externally provisioned integration coverage selected manually. diff --git a/integration_tests/realtime/test_realtime.py b/integration_tests/realtime/test_realtime.py new file mode 100644 index 0000000000..daa7c4ffb2 --- /dev/null +++ b/integration_tests/realtime/test_realtime.py @@ -0,0 +1,515 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from agents import GuardrailFunctionOutput, ToolGuardrailFunctionOutput, ToolInputGuardrailData +from agents.decorators import output_guardrail, tool, tool_input_guardrail +from agents.realtime import ( + AssistantMessageItem, + AssistantText, + InputAudio, + RealtimeAgent, + RealtimeGuardrailTripped, + RealtimeHandoffEvent, + RealtimeHistoryAdded, + RealtimeHistoryUpdated, + RealtimeModelUsageEvent, + RealtimeRawModelEvent, + RealtimeRunner, + RealtimeToolApprovalRequired, + UserMessageItem, +) + +pytestmark = pytest.mark.realtime + + +async def test_realtime_text_session_completes_and_updates_history( + integration_realtime_model: str, +) -> None: + agent = RealtimeAgent( + name="Packaged realtime agent", + instructions="Reply with exactly REALTIME_READY.", + ) + runner = RealtimeRunner(agent) + observed_events: list[str] = [] + assistant_text: list[str] = [] + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + await session.send_message("Confirm the realtime connection.") + + async def receive() -> None: + async for event in session: + observed_events.append(event.type) + if isinstance(event, RealtimeHistoryAdded | RealtimeHistoryUpdated): + items = ( + [event.item] if isinstance(event, RealtimeHistoryAdded) else event.history + ) + for item in items: + if not isinstance(item, AssistantMessageItem): + continue + assistant_text.extend( + content.text + for content in item.content + if isinstance(content, AssistantText) and content.text + ) + if event.type == "agent_end": + return + + await asyncio.wait_for(receive(), timeout=45) + + assert "agent_start" in observed_events + assert "agent_end" in observed_events + assert any("REALTIME_READY" in text for text in assistant_text) + + +async def test_realtime_function_tool_emits_start_and_end_events( + integration_realtime_model: str, +) -> None: + calls: list[str] = [] + + @tool + def lookup_city(city: str) -> str: + """Return a deterministic city status.""" + calls.append(city) + return "sunny" + + agent = RealtimeAgent( + name="Packaged realtime tool agent", + instructions="Call lookup_city with Tokyo, then reply with TOKYO_SUNNY.", + tools=[lookup_city], + ) + runner = RealtimeRunner(agent) + observed_events: list[str] = [] + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + await session.send_message("What is the weather in Tokyo? Use the function tool.") + + async def receive() -> None: + async for event in session: + observed_events.append(event.type) + if event.type == "agent_end" and "tool_end" in observed_events: + return + + await asyncio.wait_for(receive(), timeout=60) + + assert calls == ["Tokyo"] + assert "tool_start" in observed_events + assert "tool_end" in observed_events + + +async def test_realtime_session_preserves_history_across_text_turns( + integration_realtime_model: str, +) -> None: + agent = RealtimeAgent( + name="Packaged realtime conversation agent", + instructions="Remember user-provided verification words and answer concisely.", + ) + runner = RealtimeRunner(agent) + assistant_text: list[str] = [] + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + + async def receive_turn() -> None: + async for event in session: + if isinstance(event, RealtimeHistoryAdded | RealtimeHistoryUpdated): + items = ( + [event.item] if isinstance(event, RealtimeHistoryAdded) else event.history + ) + for item in items: + if isinstance(item, AssistantMessageItem): + assistant_text.extend( + content.text + for content in item.content + if isinstance(content, AssistantText) and content.text + ) + if event.type == "agent_end": + return + + await session.send_message("Remember the verification word SIERRA. Reply only STORED.") + await asyncio.wait_for(receive_turn(), timeout=45) + await session.send_message("What was the verification word? Reply only with that word.") + await asyncio.wait_for(receive_turn(), timeout=45) + + assert any("SIERRA" in text.upper() for text in assistant_text) + + +async def test_realtime_usage_events_accumulate_once_per_completed_turn( + integration_realtime_model: str, +) -> None: + agent = RealtimeAgent( + name="Packaged realtime usage agent", + instructions="Reply with exactly REALTIME_USAGE_READY.", + ) + runner = RealtimeRunner(agent) + observed_usage: list[int] = [] + completed_totals: list[int] = [] + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + + async def receive_turn() -> None: + async for event in session: + if isinstance(event, RealtimeRawModelEvent) and isinstance( + event.data, RealtimeModelUsageEvent + ): + observed_usage.append(event.data.usage.total_tokens) + if event.type == "agent_end": + completed_totals.append(event.info.context.usage.total_tokens) + return + + await session.send_message("Confirm realtime usage for turn one.") + await asyncio.wait_for(receive_turn(), timeout=45) + await session.send_message("Confirm realtime usage for turn two.") + await asyncio.wait_for(receive_turn(), timeout=45) + + assert len(observed_usage) == 2 + assert all(value > 0 for value in observed_usage) + assert completed_totals == [observed_usage[0], sum(observed_usage)] + + +async def test_realtime_handoff_updates_the_active_agent( + integration_realtime_model: str, +) -> None: + specialist = RealtimeAgent( + name="Packaged realtime specialist", + instructions="Reply with exactly REALTIME_HANDOFF_READY.", + ) + coordinator = RealtimeAgent( + name="Packaged realtime coordinator", + instructions="Immediately transfer to the packaged realtime specialist.", + handoffs=[specialist], + ) + runner = RealtimeRunner(coordinator) + handoffs: list[RealtimeHandoffEvent] = [] + ended_agents: list[str] = [] + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + await session.send_message("Transfer me to the specialist.") + + async def receive() -> None: + async for event in session: + if isinstance(event, RealtimeHandoffEvent): + handoffs.append(event) + if event.type == "agent_end": + ended_agents.append(event.agent.name) + if event.agent is specialist: + return + + await asyncio.wait_for(receive(), timeout=60) + + assert len(handoffs) == 1 + assert handoffs[0].from_agent is coordinator + assert handoffs[0].to_agent is specialist + assert specialist.name in ended_agents + + +async def test_realtime_update_agent_replaces_instructions_and_tool_dispatch( + integration_realtime_model: str, +) -> None: + calls: list[str] = [] + + @tool + def replacement_checkpoint(checkpoint: str) -> str: + """Resolve the replacement agent's release checkpoint.""" + calls.append(checkpoint) + return "REALTIME_UPDATED_READY" + + initial = RealtimeAgent( + name="Packaged initial realtime agent", + instructions="Reply only INITIAL_AGENT_ACTIVE.", + ) + replacement = RealtimeAgent( + name="Packaged replacement realtime agent", + instructions=( + "Call replacement_checkpoint with checkpoint='updated', " + "then reply exactly REALTIME_UPDATED_READY." + ), + tools=[replacement_checkpoint], + ) + runner = RealtimeRunner(initial, config={"async_tool_calls": False}) + ended_agents: list[str] = [] + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + await session.update_agent(replacement) + await session.send_message( + "You must call replacement_checkpoint with checkpoint='updated'. " + "Do not reply before calling the function." + ) + + async def receive() -> None: + async for event in session: + if event.type == "agent_end": + ended_agents.append(event.agent.name) + if event.agent is replacement: + if not calls and len(ended_agents) == 1: + await session.send_message( + "Call replacement_checkpoint now with checkpoint='updated'." + ) + continue + return + + await asyncio.wait_for(receive(), timeout=60) + + assert calls == ["updated"] + assert ended_agents + assert all(name == replacement.name for name in ended_agents) + + +@pytest.mark.nightly +@pytest.mark.parametrize("approved", [False, True], ids=["rejected", "approved"]) +async def test_realtime_function_tool_approval_controls_side_effects( + integration_realtime_model: str, + approved: bool, +) -> None: + calls: list[str] = [] + approvals: list[str] = [] + + @tool(needs_approval=True) + def publish_checkpoint(checkpoint: str) -> str: + """Publish a release checkpoint only after approval.""" + calls.append(checkpoint) + return "REALTIME_APPROVED" + + agent = RealtimeAgent( + name="Packaged realtime approval agent", + instructions=( + "Call publish_checkpoint with checkpoint='release'. If it succeeds reply " + "REALTIME_APPROVED. If it is rejected reply REALTIME_REJECTED." + ), + tools=[publish_checkpoint], + ) + runner = RealtimeRunner(agent) + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + await session.send_message("Publish the release checkpoint with the tool.") + + async def receive() -> None: + async for event in session: + if isinstance(event, RealtimeToolApprovalRequired): + approvals.append(event.call_id) + if approved: + await session.approve_tool_call(event.call_id) + else: + await session.reject_tool_call( + event.call_id, + rejection_message="Publishing the checkpoint was rejected.", + ) + if approved and event.type == "tool_end" and approvals: + return + if not approved and event.type == "agent_end" and approvals: + return + + await asyncio.wait_for(receive(), timeout=60) + + assert len(approvals) == 1 + assert calls == (["release"] if approved else []) + + +@pytest.mark.nightly +async def test_realtime_accepts_committed_pcm_audio_input( + integration_realtime_model: str, integration_pcm_audio: bytes +) -> None: + agent = RealtimeAgent( + name="Packaged realtime audio input agent", + instructions="Respond to the user's speech with exactly REALTIME_AUDIO_READY.", + ) + runner = RealtimeRunner(agent) + assistant_text: list[str] = [] + received_audio = False + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + await session.send_audio(integration_pcm_audio, commit=True) + await session.send_message("Respond to the committed user audio.") + + async def receive() -> None: + nonlocal received_audio + async for event in session: + if isinstance(event, RealtimeHistoryAdded | RealtimeHistoryUpdated): + items = ( + [event.item] if isinstance(event, RealtimeHistoryAdded) else event.history + ) + for item in items: + if isinstance(item, UserMessageItem): + received_audio = received_audio or any( + isinstance(content, InputAudio) for content in item.content + ) + if isinstance(item, AssistantMessageItem): + assistant_text.extend( + content.text + for content in item.content + if isinstance(content, AssistantText) and content.text + ) + if event.type == "agent_end": + return + + await asyncio.wait_for(receive(), timeout=60) + + assert received_audio + assert any("REALTIME_AUDIO_READY" in text for text in assistant_text) + + +@pytest.mark.nightly +async def test_realtime_output_guardrails_interrupt_audio_transcripts( + integration_realtime_model: str, +) -> None: + inspected: list[str] = [] + + @output_guardrail + async def reject_release_output( + _context: object, _agent: object, text: str + ) -> GuardrailFunctionOutput: + inspected.append(text) + return GuardrailFunctionOutput(output_info={"blocked": True}, tripwire_triggered=True) + + agent = RealtimeAgent( + name="Packaged guarded realtime output agent", + instructions="Reply with exactly BLOCKED_RELEASE_CONTENT.", + output_guardrails=[reject_release_output], + ) + runner = RealtimeRunner( + agent, + config={ + "output_guardrails": [reject_release_output], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + tripped: list[RealtimeGuardrailTripped] = [] + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["audio"], + } + } + ) as session: + await session.send_message("Return the blocked release content.") + + async def receive() -> None: + async for event in session: + if isinstance(event, RealtimeGuardrailTripped): + tripped.append(event) + return + + await asyncio.wait_for(receive(), timeout=45) + + assert len(tripped) == 1 + assert len(inspected) == 1 + assert tripped[0].message == inspected[0] + assert tripped[0].guardrail_results[0].output.tripwire_triggered + + +@pytest.mark.nightly +@pytest.mark.parametrize("pre_approval", [False, True], ids=["after-approval", "before-approval"]) +async def test_realtime_tool_input_guardrails_control_approval_and_execution( + integration_realtime_model: str, + pre_approval: bool, +) -> None: + approvals: list[str] = [] + calls: list[str] = [] + checks: list[str] = [] + + @tool_input_guardrail + def block_checkpoint(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + checks.append(data.context.tool_name) + return ToolGuardrailFunctionOutput.reject_content("Release execution was blocked.") + + @tool(needs_approval=True, tool_input_guardrails=[block_checkpoint]) + def guarded_checkpoint(value: str) -> str: + """Execute a release checkpoint only when its guardrail allows it.""" + calls.append(value) + return "CHECKPOINT_READY" + + agent = RealtimeAgent( + name="Packaged guarded realtime tool agent", + instructions=( + "Call guarded_checkpoint with value='release'. If blocked, reply exactly " + "REALTIME_TOOL_BLOCKED." + ), + tools=[guarded_checkpoint], + ) + runner = RealtimeRunner( + agent, + config={"tool_execution": {"pre_approval_tool_input_guardrails": pre_approval}}, + ) + + async with await runner.run( + model_config={ + "initial_model_settings": { + "model_name": integration_realtime_model, + "output_modalities": ["text"], + } + } + ) as session: + await session.send_message("Execute the protected release checkpoint.") + + async def receive() -> None: + async for event in session: + if isinstance(event, RealtimeToolApprovalRequired): + approvals.append(event.call_id) + await session.approve_tool_call(event.call_id) + if event.type == "agent_end" and checks: + return + + await asyncio.wait_for(receive(), timeout=60) + + assert calls == [] + assert checks == ["guarded_checkpoint"] + assert len(approvals) == (0 if pre_approval else 1) diff --git a/integration_tests/voice/test_voice_pipeline.py b/integration_tests/voice/test_voice_pipeline.py new file mode 100644 index 0000000000..60b4af9e4d --- /dev/null +++ b/integration_tests/voice/test_voice_pipeline.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import pytest + +from agents import Agent + +pytestmark = pytest.mark.voice + + +@pytest.mark.parametrize("audio_dtype", ["int16", "float32"]) +async def test_static_voice_pipeline_transcribes_and_synthesizes_without_audio_devices( + integration_model: str, + integration_pcm_audio: bytes, + audio_dtype: str, +) -> None: + import numpy as np + + from agents.voice import ( + AudioInput, + SingleAgentVoiceWorkflow, + SingleAgentWorkflowCallbacks, + VoicePipeline, + VoiceStreamEventAudio, + VoiceStreamEventLifecycle, + ) + + pcm_audio = np.frombuffer(integration_pcm_audio, dtype=np.int16).copy() + audio = ( + pcm_audio.astype(np.float32) / np.float32(32767.0) + if audio_dtype == "float32" + else pcm_audio + ) + original_audio = audio.copy() + transcriptions: list[str] = [] + + class RecordingWorkflowCallbacks(SingleAgentWorkflowCallbacks): + def on_run(self, workflow: SingleAgentVoiceWorkflow, transcription: str) -> None: + transcriptions.append(transcription) + + agent: Agent[Any] = Agent( + name="Packaged voice workflow agent", + model=integration_model, + instructions="Reply with exactly VOICE READY.", + model_settings={"max_tokens": 256}, + ) + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow(agent, callbacks=RecordingWorkflowCallbacks()), + config={ + "tracing_disabled": True, + "stt_settings": {"language": "en"}, + "tts_settings": {"voice": "alloy"}, + }, + ) + result = await pipeline.run(AudioInput(buffer=audio)) + lifecycle: list[str] = [] + audio_chunks = 0 + async for event in result.stream(): + if isinstance(event, VoiceStreamEventLifecycle): + lifecycle.append(event.event) + elif isinstance(event, VoiceStreamEventAudio) and event.data is not None: + audio_chunks += 1 + + assert audio.size > 0 + np.testing.assert_array_equal(audio, original_audio) + assert len(transcriptions) == 1 + assert all(word in transcriptions[0].lower() for word in ("packaged", "voice", "ready")) + assert audio_chunks > 0 + assert lifecycle == ["turn_started", "turn_ended", "session_ended"] + + +@pytest.mark.nightly +@pytest.mark.parametrize("audio_dtype", ["int16", "float32"]) +async def test_streamed_voice_pipeline_transcribes_chunked_input_and_runs_a_function_tool( + integration_model: str, + integration_pcm_audio: bytes, + audio_dtype: str, +) -> None: + import numpy as np + from openai import AsyncOpenAI + + from agents.decorators import tool + from agents.voice import ( + AudioInput, + SingleAgentVoiceWorkflow, + StreamedAudioInput, + StreamedTranscriptionSession, + STTModel, + STTModelSettings, + VoicePipeline, + VoiceStreamEventAudio, + VoiceStreamEventLifecycle, + ) + + class BoundedTranscriptionSession(StreamedTranscriptionSession): + def __init__(self, audio_input: StreamedAudioInput, client: AsyncOpenAI) -> None: + self.audio_input = audio_input + self.client = client + self.closed = False + + async def transcribe_turns(self) -> AsyncIterator[str]: + buffers: list[Any] = [] + while True: + chunk = await self.audio_input.queue.get() + if chunk is None: + break + buffers.append(chunk) + response = await self.client.audio.transcriptions.create( + model="gpt-4o-mini-transcribe", + file=AudioInput(buffer=np.concatenate(buffers)).to_audio_file(), + ) + yield response.text + + async def close(self) -> None: + self.closed = True + + class BoundedLiveSTTModel(STTModel): + def __init__(self) -> None: + self.client = AsyncOpenAI() + self.session: BoundedTranscriptionSession | None = None + + @property + def model_name(self) -> str: + return "gpt-4o-mini-transcribe" + + async def transcribe( + self, + input: AudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> str: + del settings, trace_include_sensitive_data, trace_include_sensitive_audio_data + response = await self.client.audio.transcriptions.create( + model=self.model_name, + file=input.to_audio_file(), + ) + return response.text + + async def create_session( + self, + input: StreamedAudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> StreamedTranscriptionSession: + del settings, trace_include_sensitive_data, trace_include_sensitive_audio_data + self.session = BoundedTranscriptionSession(input, self.client) + return self.session + + calls: list[str] = [] + + @tool + def voice_status(value: str) -> str: + """Return a deterministic streamed voice readiness status.""" + calls.append(value) + return "ready" + + stt_model = BoundedLiveSTTModel() + agent: Agent[Any] = Agent( + name="Packaged streamed voice workflow agent", + model=integration_model, + instructions=( + "Call voice_status with value='streamed', then reply exactly STREAMED_VOICE_READY." + ), + tools=[voice_status], + model_settings={"max_tokens": 384}, + ) + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow(agent), + stt_model=stt_model, + config={"tracing_disabled": True, "tts_settings": {"voice": "alloy"}}, + ) + streamed_input = StreamedAudioInput() + pcm_audio = np.frombuffer(integration_pcm_audio, dtype=np.int16).copy() + audio = ( + pcm_audio.astype(np.float32) / np.float32(32767.0) + if audio_dtype == "float32" + else pcm_audio + ) + original_audio = audio.copy() + midpoint = len(audio) // 2 + await streamed_input.add_audio(audio[:midpoint]) + await streamed_input.add_audio(audio[midpoint:]) + await streamed_input.add_audio(None) + + result = await pipeline.run(streamed_input) + lifecycle: list[str] = [] + audio_chunks = 0 + + async def consume() -> None: + nonlocal audio_chunks + async for event in result.stream(): + if isinstance(event, VoiceStreamEventLifecycle): + lifecycle.append(event.event) + elif isinstance(event, VoiceStreamEventAudio) and event.data is not None: + audio_chunks += 1 + + await asyncio.wait_for(consume(), timeout=65) + + assert calls == ["streamed"] + np.testing.assert_array_equal(audio, original_audio) + assert audio_chunks > 0 + assert lifecycle == ["turn_started", "turn_ended", "session_ended"] + assert stt_model.session is not None and stt_model.session.closed + + +async def test_voice_pipeline_surfaces_tts_failures_without_hanging( + integration_model: str, + integration_pcm_audio: bytes, +) -> None: + import numpy as np + + from agents.voice import ( + AudioInput, + SingleAgentVoiceWorkflow, + TTSModel, + TTSModelSettings, + VoicePipeline, + VoiceStreamEventLifecycle, + ) + from agents.voice.events import VoiceStreamEventError + + class FailingTTSModel(TTSModel): + @property + def model_name(self) -> str: + return "failing-packaged-tts" + + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + del text, settings + raise RuntimeError("Packaged TTS synthesis failed.") + yield b"" # pragma: no cover + + agent: Agent[Any] = Agent( + name="Packaged failing voice workflow agent", + model=integration_model, + instructions="Reply with exactly VOICE_FAILURE_READY.", + model_settings={"max_tokens": 128}, + ) + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow(agent), + tts_model=FailingTTSModel(), + config={"tracing_disabled": True}, + ) + audio = np.frombuffer(integration_pcm_audio, dtype=np.int16).copy() + result = await pipeline.run(AudioInput(buffer=audio)) + observed: list[str] = [] + + async def consume() -> None: + async for event in result.stream(): + if isinstance(event, VoiceStreamEventLifecycle): + observed.append(event.event) + elif isinstance(event, VoiceStreamEventError): + observed.append("error") + + with pytest.raises(RuntimeError, match="Packaged TTS synthesis failed"): + await asyncio.wait_for(consume(), timeout=25) + + assert observed[0] == "turn_started" diff --git a/pyproject.toml b/pyproject.toml index cd02c21583..fe22fa070d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,15 @@ agents = { workspace = true } requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build] +exclude = [ + "/.agents", + "/.github", + "/.tmp*", + "/.uv*", + "/integration_tests", +] + [tool.hatch.build.targets.wheel] packages = ["src/agents"] From 5c7f145a0d21c0e591fe83e00346165be6519822 Mon Sep 17 00:00:00 2001 From: Cheemi Date: Fri, 24 Jul 2026 18:29:33 +0800 Subject: [PATCH 009/473] docs: fix streamed audio API rendering (#3940) --- src/agents/voice/input.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/input.py b/src/agents/voice/input.py index 6097ee7bbf..c39172c79a 100644 --- a/src/agents/voice/input.py +++ b/src/agents/voice/input.py @@ -81,11 +81,11 @@ class StreamedAudioInput: def __init__(self): self.queue: asyncio.Queue[npt.NDArray[np.int16 | np.float32] | None] = asyncio.Queue() - async def add_audio(self, audio: npt.NDArray[np.int16 | np.float32] | None): + async def add_audio(self, audio: npt.NDArray[np.int16 | np.float32] | None) -> None: """Adds more audio data to the stream. Args: audio: The audio data to add. Must be a numpy array of int16 or float32 or None. - If None passed, it indicates the end of the stream. + If None passed, it indicates the end of the stream. """ await self.queue.put(audio) From 59763339cba674392e0c88b9dfcc0bc7c169681e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 00:36:48 +0900 Subject: [PATCH 010/473] fix: harden sensitive runtime logging (#3938) --- .../skills/sensitive-logging-audit/SKILL.md | 78 ++++ .../agents/openai.yaml | 4 + .../references/redaction-validation.md | 63 +++ .../scripts/inventory_logging.py | 360 ++++++++++++++++ .../scripts/test_inventory.py | 195 +++++++++ src/agents/agent.py | 16 +- .../experimental/codex/codex_tool.py | 18 +- .../memory/advanced_sqlite_session.py | 67 ++- src/agents/extensions/memory/dapr_session.py | 4 +- .../extensions/sandbox/blaxel/mounts.py | 8 +- .../extensions/sandbox/blaxel/sandbox.py | 25 +- .../extensions/sandbox/cloudflare/sandbox.py | 33 +- .../extensions/sandbox/daytona/sandbox.py | 3 +- src/agents/extensions/sandbox/e2b/sandbox.py | 37 +- .../extensions/sandbox/modal/sandbox.py | 11 +- src/agents/logger.py | 241 +++++++++++ src/agents/mcp/_logging.py | 52 +++ src/agents/mcp/manager.py | 33 +- src/agents/mcp/server.py | 111 +++-- src/agents/mcp/util.py | 51 ++- .../openai_responses_compaction_session.py | 20 +- src/agents/models/openai_chatcompletions.py | 12 +- src/agents/models/openai_responses.py | 42 +- src/agents/realtime/agent.py | 8 +- src/agents/realtime/openai_realtime.py | 4 +- src/agents/realtime/session.py | 64 ++- src/agents/result.py | 8 +- src/agents/run.py | 12 +- src/agents/run_internal/model_retry.py | 4 +- src/agents/run_internal/run_loop.py | 28 +- .../run_internal/session_persistence.py | 34 +- src/agents/run_internal/tool_execution.py | 43 +- src/agents/run_internal/turn_resolution.py | 18 +- src/agents/run_state.py | 6 +- src/agents/sandbox/memory/manager.py | 9 +- src/agents/sandbox/runtime.py | 7 +- src/agents/sandbox/sandboxes/unix_local.py | 17 +- src/agents/sandbox/session/manager.py | 31 +- src/agents/tool.py | 4 +- src/agents/tracing/processors.py | 42 +- src/agents/tracing/provider.py | 113 ++++- src/agents/util/_error_tracing.py | 3 + src/agents/voice/pipeline.py | 14 +- src/agents/voice/result.py | 8 +- .../experiemental/codex/test_codex_tool.py | 31 +- .../memory/test_advanced_sqlite_session.py | 96 ++++- tests/extensions/sandbox/test_blaxel.py | 40 +- tests/extensions/sandbox/test_cloudflare.py | 34 +- tests/extensions/sandbox/test_e2b.py | 40 +- tests/mcp/test_mcp_server_manager.py | 96 +++++ tests/mcp/test_mcp_util.py | 34 +- tests/mcp/test_tool_filtering.py | 33 ++ ...est_openai_responses_compaction_session.py | 9 +- tests/realtime/test_agent.py | 24 ++ tests/realtime/test_session.py | 121 +++++- tests/sandbox/test_memory.py | 58 ++- tests/sandbox/test_runtime.py | 31 +- tests/sandbox/test_session_manager.py | 50 +++ tests/test_agent_as_tool.py | 36 +- tests/test_agent_runner.py | 40 ++ tests/test_agent_runner_streamed.py | 68 +++ tests/test_computer_tool_lifecycle.py | 28 ++ tests/test_error_logging_redaction.py | 390 +++++++++++++++++- tests/test_trace_processor.py | 41 +- tests/tracing/test_tracing_env_disable.py | 22 + tests/voice/test_pipeline.py | 155 ++++++- 66 files changed, 3060 insertions(+), 378 deletions(-) create mode 100644 .agents/skills/sensitive-logging-audit/SKILL.md create mode 100644 .agents/skills/sensitive-logging-audit/agents/openai.yaml create mode 100644 .agents/skills/sensitive-logging-audit/references/redaction-validation.md create mode 100644 .agents/skills/sensitive-logging-audit/scripts/inventory_logging.py create mode 100644 .agents/skills/sensitive-logging-audit/scripts/test_inventory.py create mode 100644 src/agents/mcp/_logging.py diff --git a/.agents/skills/sensitive-logging-audit/SKILL.md b/.agents/skills/sensitive-logging-audit/SKILL.md new file mode 100644 index 0000000000..ca150f4240 --- /dev/null +++ b/.agents/skills/sensitive-logging-audit/SKILL.md @@ -0,0 +1,78 @@ +--- +name: sensitive-logging-audit +description: Audit and fix sensitive-data exposure through Python runtime logging in openai-agents-python. Use when reviewing logging, print, warnings, stderr, traceback, MCP names, model or tool exceptions, redaction flags, or any diagnostic path that may retain user data. +--- + +# Sensitive Logging Audit + +## Objective + +Find candidate output sinks, trace their values manually, fix demonstrated leaks at shared runtime boundaries, and prove redaction with adversarial tests. + +The collector is only a syntax-based search aid. It does not resolve Python aliases or control flow, certify policy guards, or prove that an absent candidate is safe. + +## Workflow + +### 1. Establish the review surface + +- Work in the current checkout and preserve unrelated changes. +- Read `src/agents/_debug.py`, `src/agents/logger.py`, and the affected callers. +- Treat exception messages, arguments, tracebacks, causes, contexts, notes, names, URLs, and arbitrary values as potentially sensitive. +- Read [the Python redaction validation matrix](references/redaction-validation.md). + +Run the collector tests, then collect candidates: + +```bash +uv run python .agents/skills/sensitive-logging-audit/scripts/test_inventory.py +uv run python .agents/skills/sensitive-logging-audit/scripts/inventory_logging.py \ + --format json --output /tmp/sensitive-logging-candidates.json +``` + +The report intentionally contains no `policy`, `safe`, or guard classification. + +### 2. Supplement the collector with source search + +The collector does not follow assignments such as `emit = logger.error`. Search the source directly and inspect aliases, callbacks, wrappers, and reflective dispatch: + +```bash +rg -n '\.(debug|info|warning|warn|error|exception|critical|fatal|log)\b' src/agents +rg -n '\b(print|pprint|pp|warn|warn_explicit|write|writelines|print_exc|print_exception)\b' src/agents +rg -n 'DONT_LOG_(MODEL|TOOL)_DATA|log_(model|tool|model_and_tool)_action' src/agents +``` + +Do not turn collector coverage or a textual guard into a security conclusion. Trace producers and callers. + +### 3. Classify manually + +Assign each reviewed path one disposition: + +- `model`: model requests, responses, Realtime events, or derived values. +- `tool`: tool arguments, outputs, MCP data, tool events, or derived values. +- `model+tool`: either class may reach the sink. +- `operational`: demonstrated to contain only non-sensitive SDK metadata. +- `intentional-output`: explicitly user-facing output rather than diagnostics. +- `uncertain`: source tracing is incomplete. + +Record evidence in the audit report. The script does not validate or inherit dispositions. + +### 4. Fix runtime boundaries + +Before changing runtime behavior, use `$implementation-strategy`. + +- Check the relevant `_debug.DONT_LOG_MODEL_DATA` and `_debug.DONT_LOG_TOOL_DATA` flags before formatting or inspecting sensitive values. +- Redact mixed model/tool values when either flag disables data logging. +- In redacted mode, emit a fixed message and omit sensitive `args`, `extra`, and `exc_info`. +- Build diagnostic-only context lazily so redacted mode never reads it. +- Preserve useful diagnostics when sensitive-data logging is explicitly enabled. +- Keep logging failure from changing fallback, cleanup, event, rejection, or cancellation behavior. +- For MCP URLs, remove credentials, query parameters, and fragments in diagnostic mode; never use sanitized names as a substitute for fixed redacted messages. + +### 5. Prove caller behavior + +Add tests at every changed caller boundary. Inspect the complete `LogRecord`, not only rendered text. Test both redacted policies, diagnostic mode, hostile objects, exception chains, and the caller's observable fallback or cleanup behavior as applicable. + +### 6. Re-run and close out + +Re-run the collector, the manual searches, focused tests, and applicable repository gates. Use `$code-change-verification` for runtime or test changes and `$pr-draft-summary` when required. + +Report candidate counts as search coverage only. Lead with confirmed leaks fixed, retained intentional output, reviewed uncertainty, and verification results. Never report a clean collector result as proof that no sensitive logging path exists. diff --git a/.agents/skills/sensitive-logging-audit/agents/openai.yaml b/.agents/skills/sensitive-logging-audit/agents/openai.yaml new file mode 100644 index 0000000000..1f0e17b1bd --- /dev/null +++ b/.agents/skills/sensitive-logging-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sensitive Logging Audit" + short_description: "Audit and fix sensitive Python logging paths" + default_prompt: "Use $sensitive-logging-audit to inventory, verify, and fix sensitive logging leaks in this repository." diff --git a/.agents/skills/sensitive-logging-audit/references/redaction-validation.md b/.agents/skills/sensitive-logging-audit/references/redaction-validation.md new file mode 100644 index 0000000000..35b2ab6cba --- /dev/null +++ b/.agents/skills/sensitive-logging-audit/references/redaction-validation.md @@ -0,0 +1,63 @@ +# Python sensitive logging validation + +The collector reports syntactic logging and raw-output candidates. It does not resolve aliases, prove receiver types, evaluate guards, classify payloads, or support a completeness claim. Review candidates together with direct source searches and runtime tests. + +## Required validation matrix + +Test every changed sensitive caller boundary in both redacted and diagnostic modes. Use a unique sentinel for each source and inspect both rendered output and the complete `LogRecord`. + +| Case | Model flag | Tool flag | Value | Required assertion | +| --- | --- | --- | --- | --- | +| Model redaction | on | off | `Exception(secret)` | No sentinel or exception object remains in the record | +| Tool redaction | off | on | `Exception(secret)` | No sentinel or exception object remains in the record | +| Both redacted | on | on | model and tool values | Neither sentinel remains anywhere in the record | +| Diagnostic mode | off | off | ordinary exception | Existing diagnostic detail and traceback behavior remain | +| Hostile string | applicable | applicable | object whose `__str__` raises or returns a secret | Logging does not fail or reveal the secret | +| Hostile repr | applicable | applicable | object whose `__repr__` raises or returns a secret | Logging does not fail or reveal the secret | +| Hostile class access | applicable | applicable | exception overriding `__getattribute__` | Redacted logging does not inspect the exception | +| Exception chain | applicable | applicable | `__cause__`, `__context__`, notes, or `ExceptionGroup` containing secrets | No chained secret is attached or rendered | +| Supplemental arguments | applicable | applicable | fixed message plus secret formatting argument | Formatting arguments are omitted in redacted mode | +| Extra payload | applicable | applicable | `extra={"detail": secret}` | Secret `LogRecord` attributes are omitted | +| Traceback payload | applicable | applicable | `exc_info=True` or an exception tuple | `exc_info` and `exc_text` are absent in redacted mode | +| MCP server or tool name | tool | on | path token or custom-name sentinel | Log uses a fixed message and does not read or attach the name | +| URL-derived MCP name | tool | off | URL credentials, query, and fragment | Log retains only scheme, host, port, and path; the runtime value is unchanged | + +Also test the observable caller behavior after logging. Redaction is incorrect if it prevents a fallback result, cleanup, event emission, rejection, or cancellation from completing. + +## Inspect the full LogRecord + +Do not assert only against `caplog.text` or a mock call converted to a string. In redacted mode, inspect at least: + +- `record.msg` +- `record.args` +- `record.exc_info` +- `record.exc_text` +- values added through `record.__dict__` +- the final output of a real `logging.Formatter` + +The sensitive object itself must not remain attached even when its string representation is absent. A custom handler or exporter may inspect raw record fields. + +## Review procedure + +1. Run the collector against all of `src/agents`. +2. Run the supplemental `rg` searches from `SKILL.md` and inspect aliases and dynamic dispatch. +3. Review raw output and ambiguous receivers first. +4. Review caught values, `logger.exception`, `exc_info`, `extra`, and formatting arguments. +5. Trace model, tool, Realtime, MCP, session, sandbox, voice, tracing, and cleanup values to their producers. +6. Classify intentional output separately from diagnostics; do not silently exempt `print` or warnings. +7. Add focused tests at every changed caller boundary. +8. Re-run the collector and source searches after the fix. + +An empty or unchanged collector report is not proof of safety. Assignment aliases, monkey-patched methods, dynamically installed handlers, non-constant reflection, and arbitrary runtime data flow require manual inspection. + +## Audit report expectations + +For each confirmed or uncertain path, record: + +- The source location and value producer. +- The manual disposition: `model`, `tool`, `model+tool`, `operational`, `intentional-output`, or `uncertain`. +- Concrete evidence for the disposition. +- The fix or reason for retaining the path. +- The caller-level regression test, when behavior changed. + +Do not reuse a disposition solely because a fingerprint or call text is unchanged. diff --git a/.agents/skills/sensitive-logging-audit/scripts/inventory_logging.py b/.agents/skills/sensitive-logging-audit/scripts/inventory_logging.py new file mode 100644 index 0000000000..da2bea1f20 --- /dev/null +++ b/.agents/skills/sensitive-logging-audit/scripts/inventory_logging.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import re +import sys +from collections import Counter +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +LOG_METHODS = { + "critical", + "debug", + "error", + "exception", + "fatal", + "info", + "log", + "warn", + "warning", +} +POLICY_HELPERS = { + "log_model_action_debug", + "log_model_action_error", + "log_model_action_warning", + "log_model_and_tool_action_debug", + "log_model_and_tool_action_error", + "log_model_and_tool_action_warning", + "log_tool_action_debug", + "log_tool_action_error", + "log_tool_action_warning", +} +RAW_OUTPUT_METHODS = { + "pp", + "pprint", + "print", + "print_exc", + "print_exception", + "warn", + "warn_explicit", + "write", + "writelines", +} +CALLBACK_KEYWORDS = {"callback", "handler"} + + +@dataclass(frozen=True) +class Candidate: + fingerprint: str + file: str + line: int + column: int + kind: str + method: str + context: str + call: str + reason: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def normalize_path(path: str | Path) -> str: + return str(path).replace("\\", "/") + + +def collect_source_files(roots: Sequence[str | Path]) -> list[Path]: + files: set[Path] = set() + for root_value in roots: + root = Path(root_value).resolve() + if root.is_file(): + if root.suffix == ".py": + files.add(root) + continue + if not root.is_dir(): + raise FileNotFoundError(f"Inventory root does not exist: {root_value}") + for path in root.rglob("*.py"): + relative_parts = path.relative_to(root).parts + if any(part.startswith(".") or part == "__pycache__" for part in relative_parts): + continue + files.add(path.resolve()) + return sorted(files) + + +def normalize_node(node: ast.AST, source: str) -> str: + segment = ast.get_source_segment(source, node) + if segment is None: + segment = ast.dump(node, annotate_fields=True, include_attributes=False) + return re.sub(r"\s+", " ", segment).strip() + + +def dotted_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + receiver = dotted_name(node.value) + return f"{receiver}.{node.attr}" if receiver else node.attr + return None + + +def terminal_name(node: ast.AST) -> str | None: + name = dotted_name(node) + return name.rsplit(".", 1)[-1] if name else None + + +def make_parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]: + return {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + + +def scope_context(node: ast.AST, parents: Mapping[ast.AST, ast.AST]) -> str: + parts: list[str] = [] + current = parents.get(node) + while current is not None: + if isinstance(current, ast.ClassDef): + parts.append(f"class:{current.name}") + elif isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef): + parts.append(f"function:{current.name}") + elif isinstance(current, ast.Lambda): + parts.append("lambda") + current = parents.get(current) + return ">".join(reversed(parts)) or "" + + +def callback_arguments(call: ast.Call) -> Iterable[tuple[ast.AST, str | None]]: + yield from ((argument, None) for argument in call.args) + yield from ( + (keyword.value, keyword.arg) for keyword in call.keywords if keyword.arg is not None + ) + + +def looks_like_callback(node: ast.AST, keyword: str | None) -> bool: + method = terminal_name(node) + if method not in LOG_METHODS: + return False + if keyword is not None and ( + keyword.startswith("on_") + or keyword.endswith(("_callback", "_handler")) + or keyword in CALLBACK_KEYWORDS + ): + return True + if not isinstance(node, ast.Attribute): + return False + receiver = dotted_name(node.value) + receiver_name = receiver.rsplit(".", 1)[-1].lower() if receiver else "" + return receiver_name in {"log", "logger"} or receiver_name.endswith(("_log", "_logger")) + + +def selected_getattr_method(call: ast.Call) -> str | None: + if terminal_name(call.func) != "getattr" or len(call.args) < 2: + return None + attribute = call.args[1] + if not isinstance(attribute, ast.Constant) or not isinstance(attribute.value, str): + return None + if attribute.value in LOG_METHODS | RAW_OUTPUT_METHODS: + return attribute.value + return None + + +def classify_call(call: ast.Call) -> tuple[str, str, str] | None: + qualified_method = dotted_name(call.func) + method = terminal_name(call.func) + if method in POLICY_HELPERS: + return ( + "policy-helper-call", + method, + "Known redaction helper; review the caller's data classification and fixed message.", + ) + if method in LOG_METHODS and not ( + method == "warn" and qualified_method in {"warn", "warnings.warn"} + ): + return ( + "logging-call-candidate", + method, + "Logging-like method name; inspect the receiver and every attached value.", + ) + if method in RAW_OUTPUT_METHODS: + return ( + "raw-output-call-candidate", + method, + "Direct-output method name; verify its destination and whether values " + "can be sensitive.", + ) + selected = selected_getattr_method(call) + if selected is not None: + return ( + "getattr-sink-candidate", + selected, + "Constant getattr selects an output-like method; trace the receiver and later uses.", + ) + return None + + +def inventory_source(source: str, file_path: str = "fixture.py") -> list[Candidate]: + normalized_path = normalize_path(file_path) + tree = ast.parse(source, filename=normalized_path) + parents = make_parent_map(tree) + candidates: list[Candidate] = [] + recorded: set[tuple[int, str, str]] = set() + + def record(node: ast.AST, kind: str, method: str, call: str, reason: str) -> None: + key = (id(node), kind, method) + if key in recorded: + return + recorded.add(key) + line = getattr(node, "lineno", 1) + column = getattr(node, "col_offset", 0) + 1 + context = scope_context(node, parents) + fingerprint = hashlib.sha256( + f"{normalized_path}\0{line}\0{column}\0{kind}\0{method}\0{call}".encode() + ).hexdigest()[:12] + candidates.append( + Candidate( + fingerprint=fingerprint, + file=normalized_path, + line=line, + column=column, + kind=kind, + method=method, + context=context, + call=call, + reason=reason, + ) + ) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + classification = classify_call(node) + if classification is not None: + kind, method, reason = classification + record(node, kind, method, normalize_node(node, source), reason) + for argument, keyword in callback_arguments(node): + if not looks_like_callback(argument, keyword): + continue + method = terminal_name(argument) + if method is None: + continue + record( + argument, + "logging-callback-candidate", + method, + normalize_node(argument, source), + "Logging-like callable passed to a callback-shaped argument; inspect " + "registration and payloads.", + ) + + candidates.sort(key=lambda item: (item.file, item.line, item.column, item.kind, item.method)) + return candidates + + +def summarize(candidates: Sequence[Candidate]) -> dict[str, int]: + kinds = Counter(candidate.kind for candidate in candidates) + return { + "totalCandidates": len(candidates), + "loggingCalls": kinds["logging-call-candidate"], + "rawOutputCalls": kinds["raw-output-call-candidate"], + "policyHelperCalls": kinds["policy-helper-call"], + "getattrSelections": kinds["getattr-sink-candidate"], + "callbackReferences": kinds["logging-callback-candidate"], + } + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Collect syntactic Python logging and raw-output candidates for manual review." + ) + ) + parser.add_argument("roots", nargs="*", default=["src/agents"]) + parser.add_argument("--format", choices=("json", "markdown"), default="markdown") + parser.add_argument("--summary-only", action="store_true") + parser.add_argument("--output", type=Path) + return parser.parse_args(argv) + + +def build_report(args: argparse.Namespace) -> dict[str, Any]: + cwd = Path.cwd().resolve() + candidates: list[Candidate] = [] + for path in collect_source_files(args.roots): + try: + display_path = path.relative_to(cwd) + except ValueError: + display_path = path + source = path.read_text(encoding="utf-8") + try: + candidates.extend(inventory_source(source, str(display_path))) + except SyntaxError as error: + raise SyntaxError( + f"Failed to parse {display_path}:{error.lineno}: {error.msg}" + ) from error + + report: dict[str, Any] = { + "contract": ( + "Syntactic candidates only. Manual review and runtime tests are required; " + "absence from this report is not proof of safety." + ), + "summary": summarize(candidates), + } + if not args.summary_only: + report["candidates"] = [candidate.to_dict() for candidate in candidates] + return report + + +def render_markdown(report: Mapping[str, Any], summary_only: bool) -> str: + summary = report["summary"] + lines = [ + "# Sensitive logging candidates", + "", + f"> {report['contract']}", + "", + f"- Total candidates: {summary['totalCandidates']}", + f"- Logging calls: {summary['loggingCalls']}", + f"- Raw-output calls: {summary['rawOutputCalls']}", + f"- Policy-helper calls: {summary['policyHelperCalls']}", + f"- Constant getattr selections: {summary['getattrSelections']}", + f"- Callback references: {summary['callbackReferences']}", + ] + if not summary_only: + lines.extend( + [ + "", + "| Location | Kind | Method | Context | Fingerprint |", + "| --- | --- | --- | --- | --- |", + ] + ) + for candidate in report.get("candidates", []): + location = f"{candidate['file']}:{candidate['line']}" + lines.append( + f"| {location} | {candidate['kind']} | {candidate['method']} | " + f"{candidate['context']} | {candidate['fingerprint']} |" + ) + return "\n".join(lines) + "\n" + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + try: + report = build_report(args) + output = ( + json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.format == "json" + else render_markdown(report, args.summary_only) + ) + if args.output: + args.output.write_text(output, encoding="utf-8") + else: + sys.stdout.write(output) + return 0 + except (OSError, SyntaxError, ValueError, json.JSONDecodeError) as error: + print(f"Sensitive logging candidate collection failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/sensitive-logging-audit/scripts/test_inventory.py b/.agents/skills/sensitive-logging-audit/scripts/test_inventory.py new file mode 100644 index 0000000000..44a6b42a1a --- /dev/null +++ b/.agents/skills/sensitive-logging-audit/scripts/test_inventory.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from inventory_logging import collect_source_files, inventory_source, summarize + + +class InventoryTests(unittest.TestCase): + def test_collects_direct_logging_calls_without_certifying_receivers(self) -> None: + candidates = inventory_source( + """ +from logging import error + +logger.debug("ready") +logger.error("failed: %s", secret) +error(secret) +task.exception() +""" + ) + + self.assertEqual( + [(item.kind, item.method) for item in candidates], + [ + ("logging-call-candidate", "debug"), + ("logging-call-candidate", "error"), + ("logging-call-candidate", "error"), + ("logging-call-candidate", "exception"), + ], + ) + + def test_collects_policy_helpers_without_claiming_their_callers_are_safe(self) -> None: + candidates = inventory_source( + """ +from agents.logger import log_model_action_error + +log_model_action_error(logger, "failed", error) +agents.logger.log_model_and_tool_action_warning(logger, "failed", error) +""" + ) + + self.assertEqual( + [(item.kind, item.method) for item in candidates], + [ + ("policy-helper-call", "log_model_action_error"), + ("policy-helper-call", "log_model_and_tool_action_warning"), + ], + ) + + def test_collects_raw_output_method_names(self) -> None: + candidates = inventory_source( + """ +import os +import pprint +import sys +import traceback +import warnings + +print(secret) +pprint.pp(secret) +warnings.warn(secret) +sys.stderr.buffer.write(secret_bytes) +sys.stdout.writelines([secret]) +traceback.print_exception(error) +os.write(2, secret_bytes) +""" + ) + + self.assertEqual( + [(item.kind, item.method) for item in candidates], + [ + ("raw-output-call-candidate", "print"), + ("raw-output-call-candidate", "pp"), + ("raw-output-call-candidate", "warn"), + ("raw-output-call-candidate", "write"), + ("raw-output-call-candidate", "writelines"), + ("raw-output-call-candidate", "print_exception"), + ("raw-output-call-candidate", "write"), + ], + ) + + def test_collects_constant_getattr_sink_selections(self) -> None: + candidates = inventory_source( + """ +emit = getattr(logger, "error") +writer = builtins.getattr(stream, "write") +ignored = getattr(logger, method_name) +""" + ) + + self.assertEqual( + [(item.kind, item.method) for item in candidates], + [ + ("getattr-sink-candidate", "error"), + ("getattr-sink-candidate", "write"), + ], + ) + + def test_collects_obvious_logging_callbacks(self) -> None: + candidates = inventory_source( + """ +register(log.warning) +register(on_error=service.error) +register(result=request.error) +""" + ) + + self.assertEqual( + [(item.kind, item.method, item.call) for item in candidates], + [ + ("logging-callback-candidate", "warning", "log.warning"), + ("logging-callback-candidate", "error", "service.error"), + ], + ) + + def test_keeps_the_output_schema_free_of_security_certification(self) -> None: + candidate = inventory_source('logger.error("failed", secret)')[0].to_dict() + + self.assertEqual( + set(candidate), + { + "fingerprint", + "file", + "line", + "column", + "kind", + "method", + "context", + "call", + "reason", + }, + ) + self.assertNotIn("policy", candidate) + self.assertNotIn("safe", candidate) + + def test_reports_enclosing_scope_as_review_context(self) -> None: + candidate = inventory_source( + """ +class Worker: + def report(self): + logger.error(secret) +""" + )[0] + + self.assertEqual(candidate.context, "class:Worker>function:report") + + def test_does_not_claim_to_follow_assignment_aliases(self) -> None: + candidates = inventory_source( + """ +emit = logger.error +emit(secret) +""" + ) + + self.assertEqual(candidates, []) + + def test_summary_counts_only_syntactic_candidate_categories(self) -> None: + candidates = inventory_source( + """ +logger.error(secret) +print(secret) +log_tool_action_error(logger, "failed", error) +register(on_error=service.error) +getattr(logger, "warning") +""" + ) + + self.assertEqual( + summarize(candidates), + { + "totalCandidates": 5, + "loggingCalls": 1, + "rawOutputCalls": 1, + "policyHelperCalls": 1, + "getattrSelections": 1, + "callbackReferences": 1, + }, + ) + + def test_collect_source_files_filters_hidden_children_relative_to_root(self) -> None: + with TemporaryDirectory(prefix=".hidden-parent-") as directory: + root = Path(directory) / "scan" + root.mkdir() + visible = root / "visible.py" + visible.write_text("print('visible')\n") + hidden = root / ".cache" + hidden.mkdir() + (hidden / "hidden.py").write_text("print('hidden')\n") + + self.assertEqual(collect_source_files([root]), [visible.resolve()]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/agents/agent.py b/src/agents/agent.py index 4f3c54a074..f5977d9e54 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -29,7 +29,7 @@ from .exceptions import ModelBehaviorError, UserError from .guardrail import InputGuardrail, OutputGuardrail from .handoffs import Handoff -from .logger import logger +from .logger import log_model_and_tool_action_error, logger from .mcp import MCPUtil from .model_settings import ModelSettings, _coerce_model_settings, _declared_model_settings_type from .models.default_models import ( @@ -854,10 +854,16 @@ async def _run_handler(payload: AgentToolStreamEvent) -> None: maybe_result = stream_handler(payload) if inspect.isawaitable(maybe_result): await maybe_result - except Exception: - logger.exception( - "Error while handling on_stream event for agent tool %s.", - self.name, + except Exception as exc: + + def diagnostic_extra() -> dict[str, object]: + return {"agent_name": self.name} + + log_model_and_tool_action_error( + logger, + "Error while handling an agent tool on_stream event", + exc, + diagnostic_extra=diagnostic_extra, ) async def dispatch_stream_events() -> None: diff --git a/src/agents/extensions/experimental/codex/codex_tool.py b/src/agents/extensions/experimental/codex/codex_tool.py index 534245dbd1..2c252e3d00 100644 --- a/src/agents/extensions/experimental/codex/codex_tool.py +++ b/src/agents/extensions/experimental/codex/codex_tool.py @@ -17,7 +17,7 @@ from agents import _debug from agents.exceptions import ModelBehaviorError, UserError -from agents.logger import logger +from agents.logger import log_model_and_tool_action_error, log_tool_action_error, logger from agents.models import _openai_shared from agents.run_context import RunContextWrapper from agents.strict_schema import ensure_strict_json_schema @@ -952,8 +952,12 @@ def _try_store_thread_id_in_run_context_after_error( try: _store_thread_id_in_run_context(ctx, key, thread_id) - except Exception: - logger.exception("Failed to store Codex thread id in run context after error.") + except Exception as exc: + log_tool_action_error( + logger, + "Failed to store Codex thread id in run context after error", + exc, + ) def _set_pydantic_context_value(context: BaseModel, key: str, value: str) -> bool: @@ -1047,8 +1051,12 @@ async def _run_handler(payload: CodexToolStreamEvent) -> None: maybe_result = on_stream(payload) if inspect.isawaitable(maybe_result): await maybe_result - except Exception: - logger.exception("Error while handling Codex on_stream event.") + except Exception as exc: + log_model_and_tool_action_error( + logger, + "Error while handling Codex on_stream event", + exc, + ) async def _dispatch() -> None: assert event_queue is not None diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index 2dd1e947fe..822c123570 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -11,8 +11,14 @@ from agents.result import RunResult from agents.usage import Usage +from ... import _debug from ..._tool_identity import is_reserved_synthetic_tool_namespace, tool_qualified_name from ...items import TResponseInputItem +from ...logger import ( + log_model_action_error, + log_model_action_warning, + log_model_and_tool_action_error, +) from ...memory import SQLiteSession from ...memory.session_settings import SessionSettings, resolve_session_limit @@ -172,9 +178,11 @@ def _add_items_sync(): self._insert_items(conn, items) self._insert_structure_metadata(conn, items) conn.commit() - except Exception: + except Exception as exc: conn.rollback() - self._logger.exception("Failed to add items for session %s", self.session_id) + log_model_and_tool_action_error( + self._logger, "Failed to add session items", exc + ) raise await asyncio.to_thread(_add_items_sync) @@ -462,7 +470,16 @@ async def store_run_usage(self, result: RunResult) -> None: turn_anchor=turn_anchor, ) except Exception as e: - self._logger.error("Failed to store usage for session %s: %s", self.session_id, e) + + def diagnostic_extra() -> dict[str, object]: + return {"session_id": self.session_id} + + log_model_action_error( + self._logger, + "Failed to store session usage", + e, + diagnostic_extra=diagnostic_extra, + ) def _capture_current_turn(self) -> tuple[int, str, int | None]: """Return (current_turn, branch_id, turn_anchor) in one locked read. @@ -581,15 +598,19 @@ def _add_structure_sync(): try: await asyncio.to_thread(_add_structure_sync) - except Exception: - self._logger.exception( - "Failed to add structure metadata for session %s", self.session_id + except Exception as exc: + log_model_and_tool_action_error( + self._logger, + "Failed to add session structure metadata", + exc, ) # Try to clean up any orphaned messages to maintain consistency. try: await self._cleanup_orphaned_messages() - except Exception: - self._logger.exception("Failed to cleanup orphaned messages") + except Exception as cleanup_exc: + log_model_and_tool_action_error( + self._logger, "Failed to cleanup orphaned session messages", cleanup_exc + ) raise def _insert_structure_metadata( @@ -870,13 +891,21 @@ def _validate_turn(): old_branch = self._current_branch_id await asyncio.to_thread(self._commit_branch_pointer, branch_name, generation) - self._logger.debug( - "Created branch '%s' from turn %s ('%s') in '%s'", - branch_name, - turn_number, - turn_content, - old_branch, - ) + if _debug.DONT_LOG_MODEL_DATA: + self._logger.debug( + "Created branch '%s' from turn %s in '%s'", + branch_name, + turn_number, + old_branch, + ) + else: + self._logger.debug( + "Created branch '%s' from turn %s ('%s') in '%s'", + branch_name, + turn_number, + turn_content, + old_branch, + ) return branch_name async def create_branch_from_content( @@ -1580,7 +1609,9 @@ def _update_sync(): try: input_details_json = json.dumps(usage_data.input_tokens_details.__dict__) except (TypeError, ValueError) as e: - self._logger.warning("Failed to serialize input tokens details: %s", e) + log_model_action_warning( + self._logger, "Failed to serialize input token details", e + ) input_details_json = None if ( @@ -1590,7 +1621,9 @@ def _update_sync(): try: output_details_json = json.dumps(usage_data.output_tokens_details.__dict__) except (TypeError, ValueError) as e: - self._logger.warning("Failed to serialize output tokens details: %s", e) + log_model_action_warning( + self._logger, "Failed to serialize output token details", e + ) output_details_json = None with closing(conn.cursor()) as cursor: diff --git a/src/agents/extensions/memory/dapr_session.py b/src/agents/extensions/memory/dapr_session.py index eaed2574f5..e923940f11 100644 --- a/src/agents/extensions/memory/dapr_session.py +++ b/src/agents/extensions/memory/dapr_session.py @@ -43,7 +43,7 @@ ) from ...items import TResponseInputItem -from ...logger import logger +from ...logger import log_model_and_tool_action_error, logger from ...memory.session import SessionABC from ...memory.session_settings import ( SessionSettings, @@ -461,5 +461,5 @@ async def ping(self) -> bool: ) return True except Exception: - logger.error("Dapr connection failed: %s", initial_error) + log_model_and_tool_action_error(logger, "Dapr connection failed", initial_error) return False diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index 1476a1eb5d..d31a61edb0 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import Any, Literal +from ....logger import log_tool_action_warning from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountConfigError @@ -668,7 +669,12 @@ async def _detach_drive(sandbox: Any, mount_path: str) -> None: try: await drives.unmount(mount_path) except Exception as e: - logger.warning("drive detach failed for %s (non-fatal): %s", mount_path, e) + log_tool_action_warning( + logger, + "Drive detach failed (non-fatal)", + e, + diagnostic_extra=lambda: {"mount_path": mount_path}, + ) __all__ = [ diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index 02c8e87b38..02d41a9712 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -29,6 +29,7 @@ from pydantic import BaseModel, Field +from ....logger import log_tool_action_debug, log_tool_action_warning from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecTimeoutError, @@ -453,7 +454,9 @@ async def start(self) -> None: } ) except Exception as e: - logger.debug("workspace root mkdir failed (will retry during materialization): %s", e) + log_tool_action_debug( + logger, "Workspace root mkdir failed; retrying during materialization", e + ) await super().start() async def stop(self) -> None: @@ -467,7 +470,7 @@ async def shutdown(self) -> None: # When pause_on_exit is True the sandbox is kept alive. Blaxel # automatically resumes it on the next connection. except Exception as e: - logger.warning("sandbox delete failed during shutdown: %s", e) + log_tool_action_warning(logger, "Sandbox delete failed during shutdown", e) async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path: return await self._validate_remote_path_access(path, for_write=for_write) @@ -626,7 +629,7 @@ async def running(self) -> bool: await asyncio.wait_for(self._sandbox.fs.ls("/"), timeout=10.0) return True except Exception as e: - logger.debug("sandbox health check failed: %s", e) + log_tool_action_debug(logger, "Sandbox health check failed", e) return False # -- workspace persistence ----------------------------------------------- @@ -690,7 +693,7 @@ async def persist_workspace(self) -> io.IOBase: "rm", "-f", "--", tar_path, timeout=self.state.timeouts.cleanup_s ) except Exception as e: - logger.debug("persist cleanup rm failed (non-fatal): %s", e) + log_tool_action_debug(logger, "Persist cleanup failed (non-fatal)", e) remount_error: WorkspaceArchiveReadError | None = None for mount_entry, mount_path in reversed(unmounted_mounts): @@ -764,7 +767,7 @@ async def hydrate_workspace(self, data: io.IOBase) -> None: "rm", "-f", "--", tar_path, timeout=self.state.timeouts.cleanup_s ) except Exception as e: - logger.debug("hydrate cleanup rm failed (non-fatal): %s", e) + log_tool_action_debug(logger, "Hydrate cleanup failed (non-fatal)", e) # -- PTY ----------------------------------------------------------------- @@ -947,7 +950,7 @@ async def _pty_ws_reader(self, entry: _BlaxelPtySessionEntry) -> None: ): break except Exception as e: - logger.debug("PTY ws reader terminated with error: %s", e) + log_tool_action_debug(logger, "PTY WebSocket reader terminated with an error", e) finally: entry.done = True entry.output_notify.set() @@ -1018,14 +1021,14 @@ async def _terminate_pty_entry(self, entry: _BlaxelPtySessionEntry) -> None: try: await entry.ws.close() except Exception as e: - logger.debug("PTY ws close error (non-fatal): %s", e) + log_tool_action_debug(logger, "PTY WebSocket close failed (non-fatal)", e) if entry.http_session is not None: try: await entry.http_session.close() except Exception as e: - logger.debug("PTY http session close error (non-fatal): %s", e) + log_tool_action_debug(logger, "PTY HTTP session close failed (non-fatal)", e) except Exception as e: - logger.debug("PTY entry termination error (non-fatal): %s", e) + log_tool_action_debug(logger, "PTY entry termination failed (non-fatal)", e) # --------------------------------------------------------------------------- @@ -1126,7 +1129,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: try: await inner.shutdown() except Exception as e: - logger.warning("shutdown error during delete (non-fatal): %s", e) + log_tool_action_warning(logger, "Shutdown failed during delete (non-fatal)", e) return session async def resume( @@ -1152,7 +1155,7 @@ async def resume( blaxel_sandbox = await SandboxInstance.get(state.sandbox_name) reconnected = True except Exception as e: - logger.debug("sandbox get() failed, will recreate: %s", e) + log_tool_action_debug(logger, "Sandbox lookup failed; recreating", e) if not reconnected or blaxel_sandbox is None: create_config = _build_create_config( diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index a3f94ec591..ab492ff823 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -29,6 +29,8 @@ import aiohttp +from .... import _debug +from ....logger import log_tool_action_debug from ....sandbox.errors import ( ConfigurationError, ErrorCode, @@ -693,13 +695,16 @@ async def _shutdown_backend(self) -> None: async with http.delete(url) as resp: if resp.status < 400 or resp.status == 404: return - detail = await _read_cloudflare_response_body(resp) - logger.debug( - "Failed to delete Cloudflare sandbox on shutdown: %s", - _cloudflare_http_error_message("DELETE /sandbox", resp.status, detail), - ) - except Exception: - logger.debug("Failed to delete Cloudflare sandbox on shutdown", exc_info=True) + if _debug.DONT_LOG_TOOL_DATA: + logger.debug("Failed to delete Cloudflare sandbox on shutdown") + else: + detail = await _read_cloudflare_response_body(resp) + logger.debug( + "Failed to delete Cloudflare sandbox on shutdown: %s", + _cloudflare_http_error_message("DELETE /sandbox", resp.status, detail), + ) + except Exception as exc: + log_tool_action_debug(logger, "Failed to delete Cloudflare sandbox on shutdown", exc) async def _after_shutdown(self) -> None: await self._close_http() @@ -846,7 +851,10 @@ async def _pump_ws_output(self, entry: _CloudflarePtyProcessEntry) -> None: try: payload = json.loads(msg.data) except json.JSONDecodeError: - logger.debug("Ignoring non-JSON PTY text frame: %s", msg.data) + if _debug.DONT_LOG_TOOL_DATA: + logger.debug("Ignoring non-JSON PTY text frame") + else: + logger.debug("Ignoring non-JSON PTY text frame: %s", msg.data) continue msg_type = payload.get("type") @@ -859,7 +867,10 @@ async def _pump_ws_output(self, entry: _CloudflarePtyProcessEntry) -> None: entry.output_notify.set() break if msg_type == "error": - logger.warning("Cloudflare PTY error frame: %s", payload.get("message")) + if _debug.DONT_LOG_TOOL_DATA: + logger.warning("Cloudflare PTY error frame") + else: + logger.warning("Cloudflare PTY error frame: %s", payload.get("message")) entry.output_closed.set() entry.output_notify.set() break @@ -875,8 +886,8 @@ async def _pump_ws_output(self, entry: _CloudflarePtyProcessEntry) -> None: break except asyncio.CancelledError: raise - except Exception: - logger.debug("Cloudflare PTY pump ended with an exception", exc_info=True) + except Exception as exc: + log_tool_action_debug(logger, "Cloudflare PTY pump ended with an exception", exc) entry.output_closed.set() entry.output_notify.set() diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py index 294afc6cf3..988d5a2778 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -26,6 +26,7 @@ from pydantic import BaseModel, Field +from ....logger import log_tool_action_debug from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecTimeoutError, @@ -1335,7 +1336,7 @@ async def resume( await daytona_sandbox.start(timeout=state.start_timeout) reconnected = True except Exception as e: - logger.debug("daytona sandbox get() failed, will recreate: %s", e) + log_tool_action_debug(logger, "Daytona sandbox lookup failed; recreating", e) if not reconnected or daytona_sandbox is None: params = await self._build_create_params( diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py index 324d08bfdf..ecbe8bc0bf 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -34,6 +34,7 @@ from pydantic import BaseModel, Field +from ....logger import log_tool_action_warning from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecNonZeroError, @@ -858,6 +859,12 @@ async def _after_start_failed(self) -> None: async def _shutdown_backend(self) -> None: # Best-effort kill of the remote sandbox. + def diagnostic_extra() -> dict[str, object]: + return { + "sandbox_id": self.state.sandbox_id, + "pause_on_exit": self.state.pause_on_exit, + } + try: if self.state.pause_on_exit: await _sandbox_pause(self._sandbox) @@ -865,33 +872,27 @@ async def _shutdown_backend(self) -> None: await _sandbox_kill(self._sandbox) except Exception as e: if self.state.pause_on_exit: - logger.warning( + log_tool_action_warning( + logger, "Failed to pause E2B sandbox on shutdown; falling back to kill.", - extra={ - "sandbox_id": self.state.sandbox_id, - "pause_on_exit": self.state.pause_on_exit, - }, - exc_info=e, + e, + diagnostic_extra=diagnostic_extra, ) try: await _sandbox_kill(self._sandbox) except Exception as kill_exc: - logger.warning( + log_tool_action_warning( + logger, "Failed to kill E2B sandbox after pause fallback failure.", - extra={ - "sandbox_id": self.state.sandbox_id, - "pause_on_exit": self.state.pause_on_exit, - }, - exc_info=kill_exc, + kill_exc, + diagnostic_extra=diagnostic_extra, ) else: - logger.warning( + log_tool_action_warning( + logger, "Failed to kill E2B sandbox on shutdown.", - extra={ - "sandbox_id": self.state.sandbox_id, - "pause_on_exit": self.state.pause_on_exit, - }, - exc_info=e, + e, + diagnostic_extra=diagnostic_extra, ) async def _exec_internal( diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index 930d9f8b59..b70f840119 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -33,6 +33,7 @@ from modal.config import config as modal_config from modal.container_process import ContainerProcess +from ....logger import log_tool_action_warning from ....sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from ....sandbox.entries import Mount from ....sandbox.errors import ( @@ -1327,8 +1328,9 @@ async def restore_ephemeral_paths() -> WorkspaceArchiveReadError | None: if not rm_out.ok(): cleanup_restore_error = await restore_ephemeral_paths() if cleanup_restore_error is not None: - logger.warning( - "Failed to restore Modal ephemeral paths after cleanup failure: %s", + log_tool_action_warning( + logger, + "Failed to restore Modal ephemeral paths after cleanup failure", cleanup_restore_error, ) raise WorkspaceArchiveReadError( @@ -1352,8 +1354,9 @@ async def restore_ephemeral_paths() -> WorkspaceArchiveReadError | None: except Exception as e: restore_error = await restore_ephemeral_paths() if restore_error is not None: - logger.warning( - "Failed to restore Modal ephemeral paths after snapshot failure: %s", + log_tool_action_warning( + logger, + "Failed to restore Modal ephemeral paths after snapshot failure", restore_error, ) raise WorkspaceArchiveReadError( diff --git a/src/agents/logger.py b/src/agents/logger.py index bd81a82716..18b8670680 100644 --- a/src/agents/logger.py +++ b/src/agents/logger.py @@ -1,3 +1,244 @@ import logging +from collections.abc import Callable, Mapping +from types import TracebackType + +from . import _debug logger = logging.getLogger("openai.agents") + +_DiagnosticExtra = Callable[[], Mapping[str, object]] +_DIAGNOSTIC_CONTEXT_FIELD = "openai_agents_diagnostic_context" + + +def _exception_info( + exc: BaseException, +) -> tuple[type[BaseException], BaseException, TracebackType | None]: + """Build logging exception info without evaluating exception truthiness.""" + traceback = BaseException.__getattribute__(exc, "__traceback__") + return type(exc), exc, traceback + + +def _log_record_extra(diagnostic_extra: _DiagnosticExtra | None) -> dict[str, object] | None: + if diagnostic_extra is None: + return None + try: + return {_DIAGNOSTIC_CONTEXT_FIELD: dict(diagnostic_extra())} + except Exception: + return None + + +def _log_action_error( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + redact: bool, + stacklevel: int, + diagnostic_extra: _DiagnosticExtra | None, +) -> None: + """Log an action failure without inspecting a redacted exception.""" + if redact: + target_logger.error("%s", message, stacklevel=stacklevel) + else: + target_logger.error( + "%s: %s", + message, + exc, + exc_info=_exception_info(exc), + extra=_log_record_extra(diagnostic_extra), + stacklevel=stacklevel, + ) + + +def _log_action_at_level( + log_method: Callable[..., None], + message: str, + exc: BaseException, + *, + redact: bool, + stacklevel: int, + diagnostic_extra: _DiagnosticExtra | None, +) -> None: + """Log an action failure at a caller-selected level.""" + if redact: + log_method("%s", message, stacklevel=stacklevel) + else: + log_method( + "%s: %s", + message, + exc, + exc_info=_exception_info(exc), + extra=_log_record_extra(diagnostic_extra), + stacklevel=stacklevel, + ) + + +def log_model_action_error( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Log a model-data failure according to the model logging policy.""" + _log_action_error( + target_logger, + message, + exc, + redact=_debug.DONT_LOG_MODEL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_model_action_debug( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Debug-log a model-data failure according to the model logging policy.""" + _log_action_at_level( + target_logger.debug, + message, + exc, + redact=_debug.DONT_LOG_MODEL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_model_action_warning( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Warning-log a model-data failure according to the model logging policy.""" + _log_action_at_level( + target_logger.warning, + message, + exc, + redact=_debug.DONT_LOG_MODEL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_tool_action_error( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Log a tool-data failure according to the tool logging policy.""" + _log_action_error( + target_logger, + message, + exc, + redact=_debug.DONT_LOG_TOOL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_tool_action_debug( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Debug-log a tool-data failure according to the tool logging policy.""" + _log_action_at_level( + target_logger.debug, + message, + exc, + redact=_debug.DONT_LOG_TOOL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_tool_action_warning( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Warning-log a tool-data failure according to the tool logging policy.""" + _log_action_at_level( + target_logger.warning, + message, + exc, + redact=_debug.DONT_LOG_TOOL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_model_and_tool_action_error( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Log a mixed model/tool-data failure only when both data policies allow it.""" + _log_action_error( + target_logger, + message, + exc, + redact=_debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_model_and_tool_action_debug( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Debug-log a mixed-data failure only when both data policies allow it.""" + _log_action_at_level( + target_logger.debug, + message, + exc, + redact=_debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) + + +def log_model_and_tool_action_warning( + target_logger: logging.Logger, + message: str, + exc: BaseException, + *, + stacklevel: int = 3, + diagnostic_extra: _DiagnosticExtra | None = None, +) -> None: + """Warning-log a mixed-data failure only when both data policies allow it.""" + _log_action_at_level( + target_logger.warning, + message, + exc, + redact=_debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA, + stacklevel=stacklevel, + diagnostic_extra=diagnostic_extra, + ) diff --git a/src/agents/mcp/_logging.py b/src/agents/mcp/_logging.py new file mode 100644 index 0000000000..ffb6df97b5 --- /dev/null +++ b/src/agents/mcp/_logging.py @@ -0,0 +1,52 @@ +from typing import Protocol +from urllib.parse import urlsplit, urlunsplit + +from .. import _debug + +_URL_DERIVED_NAME_PREFIXES = ("sse: ", "streamable_http: ", "streamable-http: ") + + +class _MCPServerNameSource(Protocol): + @property + def name(self) -> str: ... + + +def get_mcp_server_log_name(name: str) -> str: + """Remove URL credentials, query parameters, and fragments from MCP log names.""" + prefix = next( + (candidate for candidate in _URL_DERIVED_NAME_PREFIXES if name.startswith(candidate)), + "", + ) + candidate = name[len(prefix) :] if prefix else name + + try: + parsed = urlsplit(candidate) + except ValueError: + if prefix or candidate.lower().startswith(("http://", "https://")): + return f"{prefix}" + return name + + if parsed.scheme not in {"http", "https"}: + return name + + try: + hostname = parsed.hostname + port = parsed.port + except ValueError: + return f"{prefix}" + + if not parsed.netloc or not hostname or any(character.isspace() for character in hostname): + return f"{prefix}" + + host = f"[{hostname}]" if ":" in hostname else hostname + if port is not None: + host = f"{host}:{port}" + sanitized = urlunsplit((parsed.scheme, host, parsed.path, "", "")) + return f"{prefix}{sanitized}" + + +def get_mcp_server_log_message(message: str, server: _MCPServerNameSource) -> str: + """Build an MCP log message without reading the server name in redacted mode.""" + if _debug.DONT_LOG_TOOL_DATA: + return message + return f"{message} '{get_mcp_server_log_name(server.name)}'" diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index 235816819d..b8838be3e3 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -6,7 +6,8 @@ from dataclasses import dataclass from typing import Any -from ..logger import logger +from ..logger import log_tool_action_debug, log_tool_action_error, logger +from ._logging import get_mcp_server_log_message from .server import MCPServer @@ -260,10 +261,18 @@ async def cleanup_all(self) -> None: except asyncio.CancelledError as exc: if not self.suppress_cancelled_error: raise - logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc) + log_tool_action_debug( + logger, + get_mcp_server_log_message("Cleanup cancelled for MCP server", server), + exc, + ) self.errors[server] = exc except Exception as exc: - logger.exception("Failed to cleanup MCP server '%s': %s", server.name, exc) + log_tool_action_error( + logger, + get_mcp_server_log_message("Failed to cleanup MCP server", server), + exc, + ) self.errors[server] = exc async def _run_with_timeout( @@ -305,7 +314,11 @@ def _refresh_active_servers(self) -> None: self._active_servers = list(self._all_servers) def _record_failure(self, server: MCPServer, exc: BaseException, phase: str) -> None: - logger.exception("Failed to %s MCP server '%s': %s", phase, server.name, exc) + log_tool_action_error( + logger, + get_mcp_server_log_message(f"Failed to {phase} MCP server", server), + exc, + ) if server not in self._failed_server_set: self.failed_servers.append(server) self._failed_server_set.add(server) @@ -343,10 +356,18 @@ async def _cleanup_servers(self, servers: Iterable[MCPServer]) -> None: except asyncio.CancelledError as exc: if not self.suppress_cancelled_error: raise - logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc) + log_tool_action_debug( + logger, + get_mcp_server_log_message("Cleanup cancelled for MCP server", server), + exc, + ) self.errors[server] = exc except Exception as exc: - logger.exception("Failed to cleanup MCP server '%s': %s", server.name, exc) + log_tool_action_error( + logger, + get_mcp_server_log_message("Failed to cleanup MCP server", server), + exc, + ) self.errors[server] = exc async def _connect_all_parallel(self, servers: list[MCPServer]) -> None: diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 2681606b5e..4f8f89c293 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -38,11 +38,18 @@ ) from typing_extensions import NotRequired, TypedDict +from .. import _debug from ..exceptions import UserError -from ..logger import logger +from ..logger import ( + log_tool_action_debug, + log_tool_action_error, + log_tool_action_warning, + logger, +) from ..run_context import RunContextWrapper from ..tool import ToolErrorFunction from ..util._types import MaybeAwaitable +from ._logging import get_mcp_server_log_message, get_mcp_server_log_name from .util import ( HttpClientFactory, MCPToolCustomDataExtractor, @@ -119,10 +126,11 @@ async def _handle_post_request(self, ctx: Any) -> None: try: await super()._handle_post_request(ctx) - except httpx.HTTPError: - logger.warning( + except httpx.HTTPError as exc: + log_tool_action_warning( + logger, "Ignoring initialized notification HTTP failure", - exc_info=True, + exc, ) return @@ -161,7 +169,13 @@ async def _streamablehttp_client_with_transport( async with client: async with anyio.create_task_group() as tg: try: - logger.debug("Connecting to StreamableHTTP endpoint: %s", url) + if _debug.DONT_LOG_TOOL_DATA: + logger.debug("Connecting to StreamableHTTP endpoint") + else: + logger.debug( + "Connecting to StreamableHTTP endpoint: %s", + get_mcp_server_log_name(url), + ) def start_get_stream() -> None: tg.start_soon(transport.handle_get_stream, client, read_stream_writer) @@ -691,12 +705,15 @@ async def _apply_dynamic_tool_filter( if should_include: filtered_tools.append(tool) except Exception as e: - logger.error( - "Error applying tool filter to tool '%s' on server '%s': %s", - tool.name, - self.name, - e, - ) + if _debug.DONT_LOG_TOOL_DATA: + message = "Error applying MCP tool filter" + else: + server_name = get_mcp_server_log_name(self.name) + message = ( + f"Error applying MCP tool filter to tool '{tool.name}' " + f"on server '{server_name}'" + ) + log_tool_action_error(logger, message, e) # On error, exclude the tool for safety continue @@ -818,16 +835,20 @@ async def connect(self): if isinstance(cleanup_error, RuntimeError) and "cancel scope" in str( cleanup_error ): - logger.debug( - "Ignoring cancel scope error during cleanup of MCP server '%s': %s", - self.name, + log_tool_action_debug( + logger, + get_mcp_server_log_message( + "Ignoring cancel scope error during cleanup of MCP server", self + ), cleanup_error, ) else: # Log other cleanup errors but don't raise - original error is more # important - logger.warning( - "Error during cleanup of MCP server '%s': %s", self.name, cleanup_error + log_tool_action_warning( + logger, + get_mcp_server_log_message("Error during cleanup of MCP server", self), + cleanup_error, ) async def list_tools( @@ -1005,7 +1026,11 @@ async def cleanup(self): try: await self.exit_stack.aclose() except asyncio.CancelledError as e: - logger.debug("Cleanup cancelled for MCP server '%s': %s", self.name, e) + log_tool_action_debug( + logger, + get_mcp_server_log_message("Cleanup cancelled for MCP server", self), + e, + ) raise except BaseExceptionGroup as eg: # Extract HTTP errors from ExceptionGroup raised during cleanup @@ -1031,9 +1056,11 @@ async def cleanup(self): raise UserError(error_message) from http_error else: # Normal teardown - log but don't raise - logger.warning( - "HTTP error during cleanup of MCP server '%s': %s", - self.name, + log_tool_action_warning( + logger, + get_mcp_server_log_message( + "HTTP error during cleanup of MCP server", self + ), http_error, ) elif connect_error: @@ -1041,9 +1068,11 @@ async def cleanup(self): error_message += "Could not reach the server." raise UserError(error_message) from connect_error else: - logger.warning( - "Connection error during cleanup of MCP server '%s': %s", - self.name, + log_tool_action_warning( + logger, + get_mcp_server_log_message( + "Connection error during cleanup of MCP server", self + ), connect_error, ) elif timeout_error: @@ -1051,9 +1080,11 @@ async def cleanup(self): error_message += "Connection timeout." raise UserError(error_message) from timeout_error else: - logger.warning( - "Timeout error during cleanup of MCP server '%s': %s", - self.name, + log_tool_action_warning( + logger, + get_mcp_server_log_message( + "Timeout error during cleanup of MCP server", self + ), timeout_error, ) else: @@ -1063,16 +1094,36 @@ async def cleanup(self): for exc in eg.exceptions ) if has_cancel_scope_error: - logger.debug("Ignoring cancel scope error during cleanup: %s", eg) + log_tool_action_debug( + logger, + get_mcp_server_log_message( + "Ignoring cancel scope error during cleanup of MCP server", self + ), + eg, + ) else: - logger.error("Error cleaning up server: %s", eg) + log_tool_action_error( + logger, + get_mcp_server_log_message("Error cleaning up MCP server", self), + eg, + ) except Exception as e: # Suppress RuntimeError about cancel scopes - this is a known issue with the MCP # library when background tasks fail during async generator cleanup if isinstance(e, RuntimeError) and "cancel scope" in str(e): - logger.debug("Ignoring cancel scope error during cleanup: %s", e) + log_tool_action_debug( + logger, + get_mcp_server_log_message( + "Ignoring cancel scope error during cleanup of MCP server", self + ), + e, + ) else: - logger.error("Error cleaning up server: %s", e) + log_tool_action_error( + logger, + get_mcp_server_log_message("Error cleaning up MCP server", self), + e, + ) finally: self.session = None self._get_session_id = None diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 2cb0a5595b..049b4561ba 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -23,7 +23,7 @@ from mcp.shared.exceptions import McpError as _McpError except ImportError: # pragma: no cover – mcp is optional on Python < 3.10 _McpError = None # type: ignore[assignment, misc] -from ..logger import logger +from ..logger import log_tool_action_error, logger from ..run_context import RunContextWrapper from ..strict_schema import ensure_strict_json_schema from ..tool import ( @@ -42,6 +42,7 @@ from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span from ..util._custom_data import maybe_extract_custom_data from ..util._types import MaybeAwaitable +from ._logging import get_mcp_server_log_message, get_mcp_server_log_name if TYPE_CHECKING: ToolOutputItem = ToolOutputTextDict | ToolOutputImageDict @@ -546,7 +547,10 @@ def to_function_tool( schema = ensure_strict_json_schema(copy.deepcopy(schema)) is_strict = True except Exception as e: - logger.info("Error converting MCP schema to strict mode: %s", e) + if _debug.DONT_LOG_TOOL_DATA: + logger.info("Error converting MCP schema to strict mode") + else: + logger.info("Error converting MCP schema to strict mode: %s", e) needs_approval: ( bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]] @@ -671,7 +675,7 @@ async def invoke_mcp_tool( if json_decode_error is not None: error_message = f"Invalid JSON input for tool {tool_name_for_display}" if _debug.DONT_LOG_TOOL_DATA: - logger.debug(error_message) + logger.debug("Invalid JSON input for MCP tool") raise ModelBehaviorError(error_message) else: error_message = f"{error_message}: {input_json}" @@ -684,7 +688,7 @@ async def invoke_mcp_tool( ) if _debug.DONT_LOG_TOOL_DATA: - logger.debug("Invoking MCP tool %s", tool_name_for_display) + logger.debug("Invoking MCP tool") else: logger.debug("Invoking MCP tool %s with input %s", tool_name_for_display, input_json) @@ -725,41 +729,30 @@ async def invoke_mcp_tool( # will surface the message as a structured error result; callers who set # failure_error_function=None will have the error raised as documented. if _debug.DONT_LOG_TOOL_DATA: - logger.warning( - "MCP tool %s on server '%s' returned an error.", - tool_name_for_display, - server.name, - ) + logger.warning("MCP tool returned an error.") else: + server_log_name = get_mcp_server_log_name(server.name) error_text = e.error.message if hasattr(e, "error") and e.error else str(e) logger.warning( "MCP tool %s on server '%s' returned an error: %s", tool_name_for_display, - server.name, + server_log_name, error_text, ) raise - if _debug.DONT_LOG_TOOL_DATA: - logger.error( - "Error invoking MCP tool %s on server '%s': %s", - tool_name_for_display, - server.name, - e.__class__.__name__, - ) - else: - logger.error( - "Error invoking MCP tool %s on server '%s': %s", - tool_name_for_display, - server.name, - e, + log_message = "Error invoking MCP tool" + if not _debug.DONT_LOG_TOOL_DATA: + log_message = get_mcp_server_log_message( + f"Error invoking MCP tool {tool_name_for_display} on server", server ) + log_tool_action_error(logger, log_message, e) raise AgentsException( f"Error invoking MCP tool {tool_name_for_display} on server '{server.name}': {e}" ) from e if _debug.DONT_LOG_TOOL_DATA: - logger.debug("MCP tool %s completed.", tool_name_for_display) + logger.debug("MCP tool completed.") else: logger.debug("MCP tool %s returned %s", tool_name_for_display, result) @@ -811,8 +804,12 @@ async def invoke_mcp_tool( "server": server.name, } else: - logger.warning( - "Current span is not a FunctionSpanData, skipping tool output: %s", current_span - ) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.warning("Current span is not a FunctionSpanData; skipping tool output") + else: + logger.warning( + "Current span is not a FunctionSpanData, skipping tool output: %s", + current_span, + ) return tool_output diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index 2ec40663db..8263a81036 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -7,6 +7,7 @@ from openai import AsyncOpenAI from ..items import TResponseInputItem +from ..logger import log_model_and_tool_action_warning from ..models._openai_shared import get_default_openai_client from ..run_internal.items import normalize_input_items_for_api from .openai_conversations_session import OpenAIConversationsSession @@ -273,10 +274,11 @@ async def _restore_underlying_session_items_after_failed_clear( ) -> None: try: current_items = await self._get_all_underlying_session_items() - except Exception: - logger.warning( + except Exception as inspection_error: + log_model_and_tool_action_warning( + logger, "Failed to inspect session history after compaction replacement clear failed.", - exc_info=True, + inspection_error, ) return @@ -299,15 +301,17 @@ async def _restore_underlying_session_items( await self.underlying_session.clear_session() if previous_items: await self.underlying_session.add_items(list(previous_items)) - except Exception: - logger.warning( + except Exception as restore_error: + log_model_and_tool_action_warning( + logger, "Failed to restore session history after compaction replacement failed.", - exc_info=True, + restore_error, ) return - logger.warning( - "Restored previous session history after compaction replacement failed: %s", + log_model_and_tool_action_warning( + logger, + "Restored previous session history after compaction replacement failed", replacement_error, ) diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 408e8c8ad5..b7f9d8e00a 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -25,7 +25,7 @@ from ..exceptions import ModelBehaviorError, UserError from ..handoffs import Handoff from ..items import ModelResponse, TResponseInputItem, TResponseStreamEvent -from ..logger import logger +from ..logger import log_model_action_debug, logger from ..retry import ModelRetryAdvice, ModelRetryAdviceRequest from ..tool import Tool from ..tracing import generation_span @@ -149,7 +149,9 @@ def _consume_background_cleanup_task_result(task: asyncio.Task[Any]) -> None: except asyncio.CancelledError: pass except Exception as exc: - logger.debug("Background stream cleanup failed after cancellation: %s", exc) + log_model_action_debug( + logger, "Background stream cleanup failed after cancellation", exc + ) def _validate_official_openai_input_content_types( self, request_input: str | list[TResponseInputItem] @@ -387,8 +389,10 @@ async def stream_response( await self._maybe_aclose_async_iterator(stream) except Exception as exc: if yielded_terminal_event: - logger.debug( - "Ignoring stream cleanup error after terminal event: %s", exc + log_model_action_debug( + logger, + "Ignoring stream cleanup error after terminal event", + exc, ) else: raise diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 55c78d0245..ff17cea4a0 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -50,7 +50,7 @@ from ..exceptions import ModelBehaviorError, UserError from ..handoffs import Handoff from ..items import ItemHelpers, ModelResponse, TResponseInputItem -from ..logger import logger +from ..logger import log_model_action_debug, log_model_action_error, logger from ..model_settings import MCPToolChoice from ..retry import ModelRetryAdvice, ModelRetryAdviceRequest from ..tool import ( @@ -300,7 +300,9 @@ async def _cleanup_after_exhaustion(self) -> None: await self._cleanup_once() except Exception as exc: if self._yielded_terminal_event: - logger.debug("Ignoring stream cleanup error after terminal event: %s", exc) + log_model_action_debug( + logger, "Ignoring stream cleanup error after terminal event", exc + ) return raise @@ -452,7 +454,9 @@ def _consume_background_cleanup_task_result(task: asyncio.Task[Any]) -> None: except asyncio.CancelledError: pass except Exception as exc: - logger.debug("Background stream cleanup failed after cancellation: %s", exc) + log_model_action_debug( + logger, "Background stream cleanup failed after cancellation", exc + ) async def get_response( self, @@ -506,19 +510,16 @@ async def get_response( SpanError( message="Error getting response", data={ - "error": str(e) if tracing.include_data() else e.__class__.__name__, + "error": str(e) + if tracing.include_data() + else "Error details are redacted.", }, ) ) - request_id = getattr(e, "request_id", None) - if _debug.DONT_LOG_MODEL_DATA: - logger.error( - "Error getting response: %s. (request_id: %s)", - e.__class__.__name__, - request_id, - ) - else: - logger.error("Error getting response: %s. (request_id: %s)", e, request_id) + message = "Error getting response" + if not _debug.DONT_LOG_MODEL_DATA: + message = f"{message} (request_id: {getattr(e, 'request_id', None)})" + log_model_action_error(logger, message, e) raise return ModelResponse( @@ -603,8 +604,10 @@ async def stream_response( await self._maybe_aclose_async_iterator(stream) except Exception as exc: if yielded_terminal_event: - logger.debug( - "Ignoring stream cleanup error after terminal event: %s", exc + log_model_action_debug( + logger, + "Ignoring stream cleanup error after terminal event", + exc, ) else: raise @@ -624,14 +627,13 @@ async def stream_response( SpanError( message="Error streaming response", data={ - "error": str(e) if tracing.include_data() else e.__class__.__name__, + "error": str(e) + if tracing.include_data() + else "Error details are redacted.", }, ) ) - if _debug.DONT_LOG_MODEL_DATA: - logger.error("Error streaming response: %s", e.__class__.__name__) - else: - logger.error("Error streaming response: %s", e) + log_model_action_error(logger, "Error streaming response", e) raise @overload diff --git a/src/agents/realtime/agent.py b/src/agents/realtime/agent.py index 38c77619cc..fe2f9e7200 100644 --- a/src/agents/realtime/agent.py +++ b/src/agents/realtime/agent.py @@ -8,6 +8,7 @@ from agents.prompts import Prompt +from .. import _debug from ..agent import AgentBase from ..guardrail import OutputGuardrail from ..handoffs import Handoff @@ -125,6 +126,11 @@ async def get_system_prompt(self, run_context: RunContextWrapper[TContext]) -> s else: return cast(str, self.instructions(run_context, self)) elif self.instructions is not None: - logger.error("Instructions must be a string or a function, got %s", self.instructions) + if _debug.DONT_LOG_MODEL_DATA: + logger.error("Instructions must be a string or a function") + else: + logger.error( + "Instructions must be a string or a function, got %s", self.instructions + ) return None diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 7623c7ee18..f36aafffa4 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -199,7 +199,9 @@ def _server_event_validation_summary(error: BaseException) -> str: if isinstance(error, pydantic.ValidationError): return f"{error.error_count()} validation error(s)" - return error.__class__.__name__ + if not _debug.DONT_LOG_MODEL_DATA: + return type(error).__name__ + return "validation failed" def _server_event_identity(event: Any) -> tuple[Any, Any]: diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 66dc72be5b..0a985c0387 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -5,11 +5,13 @@ import inspect import json from collections.abc import AsyncIterator, Sequence +from functools import partial from typing import Any, cast from pydantic import BaseModel from typing_extensions import assert_never +from .. import _debug from .._tool_identity import ( FunctionToolLookupKey, get_function_tool_lookup_key_for_tool, @@ -19,7 +21,12 @@ from ..exceptions import ToolInputGuardrailTripwireTriggered, UserError from ..handoffs import Handoff from ..items import ToolApprovalItem -from ..logger import logger +from ..logger import ( + log_model_action_error, + log_model_and_tool_action_warning, + log_tool_action_error, + logger, +) from ..run_config import ToolErrorFormatterArgs from ..run_context import RunContextWrapper, TContext from ..tool import DEFAULT_APPROVAL_REJECTION_MESSAGE, FunctionTool, Tool, invoke_function_tool @@ -86,6 +93,18 @@ class _RealtimeSessionClosedSentinel: _BACKGROUND_TASK_CANCEL_GRACE_SECONDS = 1.0 +def _guardrail_diagnostic_extra(guardrail: Any) -> dict[str, object]: + try: + return {"guardrail_name": guardrail.get_name()} + except Exception: + try: + guardrail_type = type(guardrail.guardrail_function) + type_name = f"{guardrail_type.__module__}.{guardrail_type.__qualname__}" + except Exception: + type_name = "unknown" + return {"guardrail_type": type_name} + + def _serialize_tool_output(output: Any) -> str: """Serialize structured tool outputs to JSON when possible.""" if isinstance(output, str): @@ -476,8 +495,8 @@ async def on_event(self, event: RealtimeModelEvent) -> None: if new_content: incoming_item = incoming_item.model_copy(update={"content": new_content}) - except Exception: - logger.error("Error merging transcripts", exc_info=True) + except Exception as exc: + log_model_action_error(logger, "Error merging transcripts", exc) pass self._history = self._get_new_history(self._history, incoming_item) @@ -810,18 +829,21 @@ async def _resolve_approval_rejection_message(self, *, tool: FunctionTool, call_ ) message = await maybe_message if inspect.isawaitable(maybe_message) else maybe_message except Exception as exc: - logger.error("Tool error formatter failed for %s: %s", tool.name, exc) + log_tool_action_error(logger, "Tool error formatter failed", exc) return REJECTION_MESSAGE if message is None: return REJECTION_MESSAGE if not isinstance(message, str): - logger.error( - "Tool error formatter returned non-string for %s: %s", - tool.name, - type(message).__name__, - ) + if _debug.DONT_LOG_TOOL_DATA: + logger.error("Tool error formatter returned a non-string value") + else: + logger.error( + "Tool error formatter returned non-string for %s: %s", + tool.name, + type(message).__name__, + ) return REJECTION_MESSAGE return message @@ -1312,13 +1334,12 @@ async def _run_output_guardrails(self, text: str, response_id: str) -> bool: if result.output.tripwire_triggered: triggered_results.append(result) except Exception as exc: - logger.warning( - "Output guardrail %r raised %s: %s; skipping it.", - guardrail.get_name(), - type(exc).__name__, + log_model_and_tool_action_warning( + logger, + "Output guardrail raised an exception; skipping it", exc, + diagnostic_extra=partial(_guardrail_diagnostic_extra, guardrail), ) - logger.debug("Output guardrail failure details.", exc_info=True) continue if triggered_results: @@ -1432,11 +1453,14 @@ def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None: return if isinstance(exception, _PendingToolOutputSendError): - logger.warning( - "Realtime tool output send failed for call %s; cached output will be retried", - exception.call_id, - exc_info=exception, - ) + if _debug.DONT_LOG_TOOL_DATA: + logger.warning("Realtime tool output send failed; cached output will be retried") + else: + logger.warning( + "Realtime tool output send failed for call %s; cached output will be retried", + exception.call_id, + exc_info=exception, + ) self._put_event_nowait( RealtimeError( info=self._event_info, @@ -1449,7 +1473,7 @@ def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None: ) return - logger.exception("Realtime tool call task failed", exc_info=exception) + log_tool_action_error(logger, "Realtime tool call task failed", exception) if self._stored_exception is None: self._stored_exception = exception diff --git a/src/agents/result.py b/src/agents/result.py index f63a8e7f68..7bccccec91 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -28,7 +28,7 @@ ToolApprovalItem, TResponseInputItem, ) -from .logger import logger +from .logger import log_tool_action_warning, logger from .run_context import RunContextWrapper from .run_internal.items import ( NestedHistoryOwnedItemRef, @@ -661,8 +661,10 @@ async def _cleanup_once() -> None: try: await sandbox_cleanup() except Exception as error: - logger.warning( - "Failed to clean up sandbox resources after streamed run: %s", error + log_tool_action_warning( + logger, + "Failed to clean up sandbox resources after streamed run", + error, ) task = asyncio.create_task(_cleanup_once()) diff --git a/src/agents/run.py b/src/agents/run.py index 04fe9c09a0..bb07bd2554 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -27,7 +27,7 @@ TResponseInputItem, ) from .lifecycle import RunHooks -from .logger import logger +from .logger import log_model_and_tool_action_warning, log_tool_action_warning, logger from .memory import Session from .result import RunResult, RunResultStreaming from .run_config import ( @@ -1606,10 +1606,14 @@ def _finalize_result(result: RunResult) -> RunResult: terminal_metadata=terminal_metadata_for_exception(run_exception), ) except Exception as error: - logger.warning("Failed to enqueue sandbox memory after run: %s", error) + log_model_and_tool_action_warning( + logger, "Failed to enqueue sandbox memory after run", error + ) sandbox_resume_state = await sandbox_runtime.cleanup() except Exception as error: - logger.warning("Failed to clean up sandbox resources after run: %s", error) + log_tool_action_warning( + logger, "Failed to clean up sandbox resources after run", error + ) else: if completed_result is not None: completed_result._sandbox_resume_state = sandbox_resume_state @@ -1619,7 +1623,7 @@ def _finalize_result(result: RunResult) -> RunResult: try: await dispose_resolved_computers(run_context=context_wrapper) except Exception as error: - logger.warning("Failed to dispose computers after run: %s", error) + log_tool_action_warning(logger, "Failed to dispose computers after run", error) if current_span: current_span.finish(reset_current=True) if current_task_span: diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index aa41d07e6b..4e37139329 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -10,7 +10,7 @@ from openai import APIConnectionError, APITimeoutError, BadRequestError from ..items import ModelResponse, TResponseStreamEvent -from ..logger import logger +from ..logger import log_model_action_debug, logger from ..models._retry_runtime import ( get_error_code as _get_error_code, get_request_id as _get_request_id, @@ -246,7 +246,7 @@ async def _close_async_iterator_quietly(iterator: Any | None) -> None: try: await _close_async_iterator(iterator) except Exception as exc: - logger.debug("Ignoring retry stream cleanup error: %s", exc) + log_model_action_debug(logger, "Ignoring retry stream cleanup error", exc) def _get_stream_event_type(event: TResponseStreamEvent) -> str | None: diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index fa60d2299e..4873ed542d 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -9,6 +9,7 @@ import dataclasses as _dc import json from collections.abc import Awaitable, Callable, Mapping +from functools import partial from typing import Any, TypeVar, cast from openai.types.responses import ( @@ -56,7 +57,13 @@ coerce_tool_search_output_raw_item, ) from ..lifecycle import RunHooks -from ..logger import logger +from ..logger import ( + log_model_action_error, + log_model_action_warning, + log_model_and_tool_action_debug, + log_tool_action_warning, + logger, +) from ..memory import Session from ..models._response_terminal import ( response_error_event_failure_error, @@ -270,7 +277,11 @@ async def cleanup_models_after_run(tool_use_tracker: AgentToolUseTracker) -> Non try: await model._cleanup_on_run_end(tool_use_tracker) except Exception as error: - logger.warning("Failed to clean up model resources after run: %s", error) + log_model_action_warning(logger, "Failed to clean up model resources after run", error) + + +def _agent_diagnostic_extra(agent: Agent[Any]) -> dict[str, object]: + return {"agent_name": agent.name} def _should_attach_generic_agent_error(exc: Exception) -> bool: @@ -398,8 +409,8 @@ async def _run_output_guardrails_for_stream( raise except asyncio.CancelledError: raise - except Exception: - logger.error("Unexpected error in output guardrails", exc_info=True) + except Exception as exc: + log_model_action_error(logger, "Unexpected error in output guardrails", exc) raise @@ -1302,13 +1313,16 @@ async def _save_stream_items_without_count( if first_trigger is not None: raise InputGuardrailTripwireTriggered(first_trigger) except Exception as e: - logger.debug( - "Error in streamed_result finalize for agent %s - %s", current_agent.name, e + log_model_and_tool_action_debug( + logger, + "Error finalizing streamed result", + e, + diagnostic_extra=partial(_agent_diagnostic_extra, current_agent), ) try: await dispose_resolved_computers(run_context=context_wrapper) except Exception as error: - logger.warning("Failed to dispose computers after streamed run: %s", error) + log_tool_action_warning(logger, "Failed to dispose computers after streamed run", error) if current_span: current_span.finish(reset_current=True) if current_task_span: diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 2c599445e7..b4c98d2747 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -13,9 +13,14 @@ from collections.abc import Sequence from typing import Any, cast +from .. import _debug from ..exceptions import UserError from ..items import HandoffOutputItem, ItemHelpers, RunItem, ToolCallOutputItem, TResponseInputItem -from ..logger import logger +from ..logger import ( + log_model_and_tool_action_debug, + log_model_and_tool_action_warning, + logger, +) from ..memory import ( OpenAIResponsesCompactionArgs, Session, @@ -542,8 +547,9 @@ async def rewind_session_items( len(target_serializations), ) - for i, target in enumerate(target_serializations): - logger.debug("Rewind target %d (first 300 chars): %s", i, target[:300]) + if not (_debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA): + for i, target in enumerate(target_serializations): + logger.debug("Rewind target %d (first 300 chars): %s", i, target[:300]) snapshot_serializations = target_serializations.copy() rewound = await _rewind_session_tail_suffix( @@ -554,7 +560,7 @@ async def rewind_session_items( mismatch_warning=( "Skipping session rewind because the current tail does not match the retry-owned suffix" ), - pop_failure_warning="Failed to rewind session item: %s", + pop_failure_warning="Failed to rewind session item", ) if not rewound: return @@ -571,7 +577,7 @@ async def rewind_session_items( try: latest_items = await session.get_items(limit=1) except Exception as exc: - logger.debug("Failed to peek session items while rewinding: %s", exc) + log_model_and_tool_action_debug(logger, "Failed to peek session items while rewinding", exc) return if not latest_items: @@ -584,7 +590,9 @@ async def rewind_session_items( try: session_items = await session.get_items() except Exception as exc: - logger.debug("Failed to inspect session tail while stripping stray items: %s", exc) + log_model_and_tool_action_debug( + logger, "Failed to inspect session tail while stripping stray items", exc + ) return stray_serializations = _collect_retry_owned_tail_serializations( @@ -609,7 +617,7 @@ async def rewind_session_items( "Skipping stray session cleanup because the current tail no longer matches " "retry-owned conversation items" ), - pop_failure_warning="Failed to strip stray session item: %s", + pop_failure_warning="Failed to strip stray session item", ) @@ -633,7 +641,9 @@ async def wait_for_session_cleanup( try: tail_items = await session.get_items(limit=window) except Exception as exc: - logger.debug("Failed to verify session cleanup (attempt %d): %s", attempt + 1, exc) + log_model_and_tool_action_debug( + logger, f"Failed to verify session cleanup (attempt {attempt + 1})", exc + ) await asyncio.sleep(0.1 * (attempt + 1)) continue @@ -764,7 +774,7 @@ async def _rewind_session_tail_suffix( try: tail_items = await session.get_items(limit=len(expected_serializations)) except Exception as exc: - logger.warning(pop_failure_warning, exc) + log_model_and_tool_action_warning(logger, pop_failure_warning, exc) return False if len(tail_items) != len(expected_serializations): @@ -791,7 +801,7 @@ async def _rewind_session_tail_suffix( result = await result except Exception as exc: await _restore_popped_session_items(session, popped_items) - logger.warning(pop_failure_warning, exc) + log_model_and_tool_action_warning(logger, pop_failure_warning, exc) return False if result is None: @@ -827,7 +837,9 @@ async def _restore_popped_session_items( if inspect.isawaitable(result): await result except Exception as exc: - logger.warning("Failed to restore session items after a rewind mismatch: %s", exc) + log_model_and_tool_action_warning( + logger, "Failed to restore session items after a rewind mismatch", exc + ) def _collect_retry_owned_tail_serializations( diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 3eea93d8d6..c38bdfe4e5 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -58,7 +58,7 @@ ToolApprovalItem, ToolCallOutputItem, ) -from ..logger import logger +from ..logger import log_tool_action_error as _log_tool_action_error, logger from ..model_settings import ModelSettings from ..run_config import RunConfig, ToolErrorFormatterArgs from ..run_context import RunContextWrapper @@ -1041,17 +1041,29 @@ def format_shell_error(error: Exception | BaseException | Any) -> str: return repr(error) -def log_tool_action_error(message: str, exc: Exception | BaseException) -> None: +def _tool_name_diagnostic_extra(tool_name: str) -> dict[str, object]: + return {"tool_name": tool_name} + + +def log_tool_action_error( + message: str, + exc: Exception | BaseException, + *, + diagnostic_extra: Callable[[], Mapping[str, object]] | None = None, +) -> None: """Log a tool-action failure without leaking tool data. Tool exceptions can embed tool call arguments or output, so the exception is redacted by default (matching ``_debug.DONT_LOG_TOOL_DATA``). The full exception and traceback are logged only when tool-data logging is explicitly enabled. """ - if _debug.DONT_LOG_TOOL_DATA: - logger.error("%s: %s", message, exc.__class__.__name__) - else: - logger.error("%s: %s", message, exc, exc_info=True) + _log_tool_action_error( + logger, + message, + exc, + stacklevel=4, + diagnostic_extra=diagnostic_extra, + ) async def with_tool_function_span( @@ -1201,18 +1213,25 @@ async def resolve_approval_rejection_message( ) message = await maybe_message if inspect.isawaitable(maybe_message) else maybe_message except Exception as exc: - log_tool_action_error(f"Tool error formatter failed for {tool_name}", exc) + log_tool_action_error( + "Tool error formatter failed", + exc, + diagnostic_extra=functools.partial(_tool_name_diagnostic_extra, tool_name), + ) return REJECTION_MESSAGE if message is None: return REJECTION_MESSAGE if not isinstance(message, str): - logger.error( - "Tool error formatter returned non-string for %s: %s", - tool_name, - type(message).__name__, - ) + if _debug.DONT_LOG_TOOL_DATA: + logger.error("Tool error formatter returned a non-string value") + else: + logger.error( + "Tool error formatter returned non-string for %s: %s", + tool_name, + type(message).__name__, + ) return REJECTION_MESSAGE return message diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index f275f2e857..2f54478e32 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -29,6 +29,7 @@ ) from openai.types.responses.response_reasoning_item import ResponseReasoningItem +from .. import _debug from .._mcp_tool_metadata import collect_mcp_list_tools_metadata from .._tool_identity import ( build_function_tool_lookup_map, @@ -72,7 +73,7 @@ coerce_tool_search_output_raw_item, ) from ..lifecycle import RunHooks -from ..logger import logger +from ..logger import log_tool_action_error, logger from ..run_config import RunConfig, ToolErrorFormatterArgs from ..run_context import AgentHookContext, RunContextWrapper, TContext from ..run_error_handlers import RunErrorHandlers @@ -252,18 +253,21 @@ async def _resolve_tool_not_found_message( ) message = await maybe_message if inspect.isawaitable(maybe_message) else maybe_message except Exception as exc: - logger.error("Tool error formatter failed for missing tool %s: %s", tool_name, exc) + log_tool_action_error(logger, "Tool error formatter failed for missing tool", exc) return default_message if message is None: return default_message if not isinstance(message, str): - logger.error( - "Tool error formatter returned non-string for missing tool %s: %s", - tool_name, - type(message).__name__, - ) + if _debug.DONT_LOG_TOOL_DATA: + logger.error("Tool error formatter returned a non-string value for a missing tool") + else: + logger.error( + "Tool error formatter returned non-string for missing tool %s: %s", + tool_name, + type(message).__name__, + ) return default_message return message diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 41c2d6f018..ef9cdc6c76 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -81,7 +81,7 @@ coerce_tool_search_call_raw_item, coerce_tool_search_output_raw_item, ) -from .logger import logger +from .logger import log_model_and_tool_action_warning, logger from .run_context import RunContextWrapper from .run_internal.items import ( NestedHistoryOwnedItemRef, @@ -3752,7 +3752,9 @@ def _resolve_agent_info( except UserError: raise except Exception as e: - logger.warning("Failed to deserialize item of type %s: %s", item_type, e) + log_model_and_tool_action_warning( + logger, f"Failed to deserialize item of type {item_type}", e + ) continue return result diff --git a/src/agents/sandbox/memory/manager.py b/src/agents/sandbox/memory/manager.py index 28025466dc..9919d8035b 100644 --- a/src/agents/sandbox/memory/manager.py +++ b/src/agents/sandbox/memory/manager.py @@ -10,6 +10,7 @@ from ...exceptions import UserError from ...items import TResponseInputItem +from ...logger import log_model_and_tool_action_error from ...run_config import RunConfig, SandboxRunConfig from ..capabilities.memory import Memory from ..config import MemoryGenerateConfig @@ -150,8 +151,8 @@ async def _worker(self) -> None: if queue_item is _STOP: return await self._process_rollout_file(str(queue_item)) - except Exception: - logger.exception("Sandbox memory worker failed") + except Exception as exc: + log_model_and_tool_action_error(logger, "Sandbox memory worker failed", exc) finally: self._queue.task_done() @@ -227,8 +228,8 @@ async def _run_phase_two(self) -> None: selection=selection, run_config=self._memory_run_config(), ) - except Exception: - logger.exception("Sandbox memory phase 2 failed") + except Exception as exc: + log_model_and_tool_action_error(logger, "Sandbox memory phase 2 failed", exc) return await self._storage.write_phase_two_selection(selected_items=selection.selected) self._pending_phase_two_rollout_ids = [ diff --git a/src/agents/sandbox/runtime.py b/src/agents/sandbox/runtime.py index d273a54411..0378323a63 100644 --- a/src/agents/sandbox/runtime.py +++ b/src/agents/sandbox/runtime.py @@ -9,6 +9,7 @@ from ..agent import Agent from ..exceptions import UserError from ..items import TResponseInputItem +from ..logger import log_model_and_tool_action_warning from ..result import RunResult, RunResultStreaming from ..run_config import RunConfig from ..run_context import RunContextWrapper, TContext @@ -106,8 +107,10 @@ async def _cleanup_and_store() -> None: input_override=_stream_memory_input_override(result), ) except Exception as error: - logger.warning( - "Failed to enqueue sandbox memory after streamed run: %s", error + log_model_and_tool_action_warning( + logger, + "Failed to enqueue sandbox memory after streamed run", + error, ) payload = await self.cleanup() result._sandbox_resume_state = payload diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index a6aff578b9..a2bfeac2ba 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -24,9 +24,11 @@ from collections.abc import Mapping, Sequence from contextlib import suppress from dataclasses import dataclass, field +from functools import partial from pathlib import Path from typing import Literal, cast +from ...logger import log_tool_action_warning from ..errors import ( ExecNonZeroError, ExecTimeoutError, @@ -75,6 +77,10 @@ logger = logging.getLogger(__name__) +def _mount_path_diagnostic_extra(mount_path: Path) -> dict[str, object]: + return {"mount_path": str(mount_path)} + + def _close_fd_quietly(fd: int) -> None: with suppress(OSError): os.close(fd) @@ -1129,12 +1135,13 @@ async def delete(self, session: SandboxSession) -> SandboxSession: for mount_entry, mount_path in inner.state.manifest.ephemeral_mount_targets(): try: await mount_entry.unmount(inner, mount_path, Path("/")) - except Exception: + except Exception as exc: unmount_failed = True - logger.warning( - "Failed to unmount UnixLocal workspace mount before deleting root: %s", - mount_path, - exc_info=True, + log_tool_action_warning( + logger, + "Failed to unmount UnixLocal workspace mount before deleting root", + exc, + diagnostic_extra=partial(_mount_path_diagnostic_extra, mount_path), ) if unmount_failed: return session diff --git a/src/agents/sandbox/session/manager.py b/src/agents/sandbox/session/manager.py index 125765e65b..1248ce19b9 100644 --- a/src/agents/sandbox/session/manager.py +++ b/src/agents/sandbox/session/manager.py @@ -4,6 +4,7 @@ import logging from collections.abc import Sequence +from ...logger import log_tool_action_error from ..errors import OpName from .events import EventPayloadPolicy, SandboxSessionEvent, SandboxSessionFinishEvent from .sinks import ChainedSink, EventSink @@ -104,8 +105,8 @@ async def _run() -> None: if sink.mode == "sync": try: await _run() - except Exception: - self._handle_sink_error(sink, event) + except Exception as exc: + self._handle_sink_error(sink, event, exc) elif sink.mode == "async": if sink.on_error == "raise": await _run() @@ -114,8 +115,8 @@ async def _run() -> None: async def _task() -> None: try: await _run() - except Exception: - self._handle_sink_error(sink, event) + except Exception as exc: + self._handle_sink_error(sink, event, exc) task = asyncio.create_task(_task()) # Track background deliveries so the task is kept alive and can be discarded once done. @@ -126,8 +127,8 @@ async def _task() -> None: async def _task() -> None: try: await _run() - except Exception: - self._handle_sink_error(sink, event, force_no_raise=True) + except Exception as exc: + self._handle_sink_error(sink, event, exc, force_no_raise=True) task = asyncio.create_task(_task()) # Same bookkeeping as async mode, but failures are always swallowed after logging. @@ -146,16 +147,26 @@ async def _deliver_chained(self, sink: EventSink, event: SandboxSessionEvent) -> """ try: await sink.handle(event) - except Exception: + except Exception as exc: force_no_raise = sink.mode == "best_effort" - self._handle_sink_error(sink, event, force_no_raise=force_no_raise) + self._handle_sink_error(sink, event, exc, force_no_raise=force_no_raise) def _handle_sink_error( - self, sink: EventSink, event: SandboxSessionEvent, *, force_no_raise: bool = False + self, + sink: EventSink, + event: SandboxSessionEvent, + exc: Exception, + *, + force_no_raise: bool = False, ) -> None: if force_no_raise or sink.on_error in ("log", "ignore"): if sink.on_error == "log": - logger.exception("sandbox event sink failed (ignored): %s", type(sink).__name__) + log_tool_action_error( + logger, + "Sandbox event sink failed (ignored)", + exc, + diagnostic_extra=lambda: {"sink_type": type(sink).__name__}, + ) return raise RuntimeError( "sandbox event sink failed: " diff --git a/src/agents/tool.py b/src/agents/tool.py index af9a6c3c5c..eb6c0a3645 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -54,7 +54,7 @@ from .editor import ApplyPatchEditor, ApplyPatchOperation from .exceptions import ModelBehaviorError, ToolTimeoutError, UserError from .function_schema import DocstringStyle, function_schema -from .logger import logger +from .logger import log_tool_action_warning, logger from .run_context import RunContextWrapper from .strict_schema import ensure_strict_json_schema from .tool_context import ToolContext @@ -885,7 +885,7 @@ async def dispose_resolved_computers(*, run_context: RunContextWrapper[Any]) -> if inspect.isawaitable(result): await result except Exception as exc: - logger.warning("Failed to dispose computer for run context: %s", exc) + log_tool_action_warning(logger, "Failed to dispose computer for run context", exc) @dataclass diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 776939a180..6c68a2673f 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -13,7 +13,12 @@ import httpx -from ..logger import logger +from .. import _debug +from ..logger import ( + log_model_and_tool_action_error, + log_model_and_tool_action_warning, + logger, +) from .processor_interface import TracingExporter, TracingProcessor from .spans import Span from .traces import Trace @@ -24,6 +29,12 @@ class ConsoleSpanExporter(TracingExporter): def export(self, items: list[Trace | Span[Any]]) -> None: for item in items: + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + if isinstance(item, Trace): + print("[Exporter] Export trace. Trace data is redacted.") + else: + print("[Exporter] Export span. Span data is redacted.") + continue if isinstance(item, Trace): print(f"[Exporter] Export trace_id={item.trace_id}, name={item.name}") else: @@ -173,11 +184,17 @@ def _export_with_deadline(self, items: list[Trace | Span[Any]], deadline: float # If the response is a client error (4xx), we won't retry if 400 <= response.status_code < 500: - logger.error( - "[non-fatal] Tracing client error %s: %s", - response.status_code, - response.text, - ) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.error( + "[non-fatal] Tracing client error %s. Response data is redacted.", + response.status_code, + ) + else: + logger.error( + "[non-fatal] Tracing client error %s: %s", + response.status_code, + response.text, + ) break # For 5xx or other unexpected codes, treat it as transient and retry @@ -186,7 +203,9 @@ def _export_with_deadline(self, items: list[Trace | Span[Any]], deadline: float ) except httpx.RequestError as exc: # Network or other I/O error, we'll retry - logger.warning("[non-fatal] Tracing: request failed: %s", exc) + log_model_and_tool_action_warning( + logger, "[non-fatal] Tracing request failed", exc + ) # If we reach here, we need to retry or give up if attempt >= self.max_retries: @@ -690,10 +709,13 @@ def _export_batches(self, force: bool = False, deadline: float | None = None): else: self._exporter.export(items_to_export) except Exception as exc: - logger.error( - "[non-fatal] Tracing: exporter raised %s; dropping batch of %d items", + log_model_and_tool_action_error( + logger, + ( + "[non-fatal] Tracing exporter failed; " + f"dropping batch of {len(items_to_export)} items" + ), exc, - len(items_to_export), ) diff --git a/src/agents/tracing/provider.py b/src/agents/tracing/provider.py index a5f439dceb..57817642f8 100644 --- a/src/agents/tracing/provider.py +++ b/src/agents/tracing/provider.py @@ -6,11 +6,14 @@ import time import uuid from abc import ABC, abstractmethod +from collections.abc import Callable from datetime import datetime, timezone +from functools import partial from inspect import Parameter, signature from typing import Any, cast -from ..logger import logger +from .. import _debug +from ..logger import log_model_and_tool_action_error, logger from .config import TracingConfig from .processor_interface import TracingProcessor from .scope import Scope @@ -18,7 +21,7 @@ from .traces import NoOpTrace, Trace, TraceImpl -def _safe_debug(message: str) -> None: +def _safe_debug(message: str | Callable[[], str]) -> None: """Best-effort debug logging that tolerates closed streams during shutdown.""" def _has_closed_stream_handler(log: logging.Logger) -> bool: @@ -37,12 +40,24 @@ def _has_closed_stream_handler(log: logging.Logger) -> bool: # Avoid emitting debug logs when any handler already owns a closed stream. if _has_closed_stream_handler(logger): return - logger.debug(message) + logger.debug(message() if callable(message) else message) except Exception: # Avoid noisy shutdown errors when the underlying stream is already closed. return +def _processor_diagnostic_extra(processor: TracingProcessor) -> dict[str, object]: + processor_type = type(processor) + processor_identity = ( + f"{processor_type.__module__}.{processor_type.__qualname__}@{id(processor):x}" + ) + return {"trace_processor": processor_identity} + + +def _processor_shutdown_message(processor: TracingProcessor) -> str: + return f"Shutting down trace processor {processor}" + + def _remaining_timeout(deadline: float | None) -> float | None: if deadline is None: return None @@ -107,7 +122,12 @@ def on_trace_start(self, trace: Trace) -> None: try: processor.on_trace_start(trace) except Exception as e: - logger.error("Error in trace processor %s during on_trace_start: %s", processor, e) + log_model_and_tool_action_error( + logger, + "Error in trace processor during on_trace_start", + e, + diagnostic_extra=partial(_processor_diagnostic_extra, processor), + ) def on_trace_end(self, trace: Trace) -> None: """ @@ -117,7 +137,12 @@ def on_trace_end(self, trace: Trace) -> None: try: processor.on_trace_end(trace) except Exception as e: - logger.error("Error in trace processor %s during on_trace_end: %s", processor, e) + log_model_and_tool_action_error( + logger, + "Error in trace processor during on_trace_end", + e, + diagnostic_extra=partial(_processor_diagnostic_extra, processor), + ) def on_span_start(self, span: Span[Any]) -> None: """ @@ -127,7 +152,12 @@ def on_span_start(self, span: Span[Any]) -> None: try: processor.on_span_start(span) except Exception as e: - logger.error("Error in trace processor %s during on_span_start: %s", processor, e) + log_model_and_tool_action_error( + logger, + "Error in trace processor during on_span_start", + e, + diagnostic_extra=partial(_processor_diagnostic_extra, processor), + ) def on_span_end(self, span: Span[Any]) -> None: """ @@ -137,7 +167,12 @@ def on_span_end(self, span: Span[Any]) -> None: try: processor.on_span_end(span) except Exception as e: - logger.error("Error in trace processor %s during on_span_end: %s", processor, e) + log_model_and_tool_action_error( + logger, + "Error in trace processor during on_span_end", + e, + diagnostic_extra=partial(_processor_diagnostic_extra, processor), + ) def shutdown(self, timeout: float | None = None) -> None: """ @@ -145,7 +180,10 @@ def shutdown(self, timeout: float | None = None) -> None: """ deadline = None if timeout is None else time.monotonic() + timeout for processor in self._processors: - _safe_debug(f"Shutting down trace processor {processor}") + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + _safe_debug("Shutting down trace processor") + else: + _safe_debug(partial(_processor_shutdown_message, processor)) try: processor_timeout = _remaining_timeout(deadline) if processor_timeout is not None and processor_timeout <= 0: @@ -158,7 +196,12 @@ def shutdown(self, timeout: float | None = None) -> None: else: processor.shutdown() except Exception as e: - logger.error("Error shutting down trace processor %s: %s", processor, e) + log_model_and_tool_action_error( + logger, + "Error shutting down trace processor", + e, + diagnostic_extra=partial(_processor_diagnostic_extra, processor), + ) def force_flush(self): """ @@ -168,7 +211,12 @@ def force_flush(self): try: processor.force_flush() except Exception as e: - logger.error("Error flushing trace processor %s: %s", processor, e) + log_model_and_tool_action_error( + logger, + "Error flushing trace processor", + e, + diagnostic_extra=partial(_processor_diagnostic_extra, processor), + ) class TraceProvider(ABC): @@ -337,12 +385,18 @@ def create_trace( """ self._refresh_disabled_flag() if self._disabled or disabled: - logger.debug("Tracing is disabled. Not creating trace %s", name) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.debug("Tracing is disabled. Not creating trace") + else: + logger.debug("Tracing is disabled. Not creating trace %s", name) return NoOpTrace() trace_id = trace_id or self.gen_trace_id() - logger.debug("Creating trace %s with id %s", name, trace_id) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.debug("Creating trace with id %s", trace_id) + else: + logger.debug("Creating trace %s with id %s", name, trace_id) return TraceImpl( name=name, @@ -367,7 +421,10 @@ def create_span( tracing_api_key: str | None = None trace_metadata: dict[str, Any] | None = None if self._disabled or disabled: - logger.debug("Tracing is disabled. Not creating span %s", span_data) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.debug("Tracing is disabled. Not creating span") + else: + logger.debug("Tracing is disabled. Not creating span %s", span_data) return NoOpSpan(span_data) if _is_noop_id(span_id): logger.debug("Span id is no-op, returning NoOpSpan") @@ -383,9 +440,14 @@ def create_span( ) return NoOpSpan(span_data) elif _is_noop_trace(current_trace) or _is_noop_span(current_span): - logger.debug( - "Parent %s or %s is no-op, returning NoOpSpan", current_span, current_trace - ) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.debug("Current trace parent is no-op, returning NoOpSpan") + else: + logger.debug( + "Parent %s or %s is no-op, returning NoOpSpan", + current_span, + current_trace, + ) return NoOpSpan(span_data) parent_id = current_span.span_id if current_span else None @@ -396,7 +458,10 @@ def create_span( elif isinstance(parent, Trace): if _is_noop_trace(parent): - logger.debug("Parent %s is no-op, returning NoOpSpan", parent) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.debug("Parent trace is no-op, returning NoOpSpan") + else: + logger.debug("Parent %s is no-op, returning NoOpSpan", parent) return NoOpSpan(span_data) trace_id = parent.trace_id parent_id = None @@ -405,14 +470,20 @@ def create_span( trace_metadata = getattr(parent, "metadata", None) elif isinstance(parent, Span): if _is_noop_span(parent): - logger.debug("Parent %s is no-op, returning NoOpSpan", parent) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.debug("Parent span is no-op, returning NoOpSpan") + else: + logger.debug("Parent %s is no-op, returning NoOpSpan", parent) return NoOpSpan(span_data) parent_id = parent.span_id trace_id = parent.trace_id tracing_api_key = parent.tracing_api_key trace_metadata = parent.trace_metadata - logger.debug("Creating span %s with id %s", span_data, span_id) + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.debug("Creating span with id %s", span_id) + else: + logger.debug("Creating span %s with id %s", span_data, span_id) return SpanImpl( trace_id=trace_id, @@ -433,7 +504,7 @@ def force_flush(self) -> None: try: self._multi_processor.force_flush() except Exception as e: - logger.error("Error flushing trace provider: %s", e) + log_model_and_tool_action_error(logger, "Error flushing trace provider", e) def shutdown(self, timeout: float | None = None) -> None: self._refresh_disabled_flag() @@ -444,4 +515,4 @@ def shutdown(self, timeout: float | None = None) -> None: _safe_debug("Shutting down trace provider") self._multi_processor.shutdown(timeout=timeout) except Exception as e: - logger.error("Error shutting down trace provider: %s", e) + log_model_and_tool_action_error(logger, "Error shutting down trace provider", e) diff --git a/src/agents/util/_error_tracing.py b/src/agents/util/_error_tracing.py index 0bd2d99e90..7f714482a5 100644 --- a/src/agents/util/_error_tracing.py +++ b/src/agents/util/_error_tracing.py @@ -1,5 +1,6 @@ from typing import Any +from .. import _debug from ..logger import logger from ..tracing import Span, SpanError, get_current_span @@ -24,5 +25,7 @@ def attach_error_to_current_span(error: SpanError) -> None: span = get_current_span() if span: attach_error_to_span(span, error) + elif _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + logger.warning("No active span; trace error was not attached") else: logger.warning("No span to add error %s to", error) diff --git a/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py index 21220c9921..da2bdceafd 100644 --- a/src/agents/voice/pipeline.py +++ b/src/agents/voice/pipeline.py @@ -5,7 +5,11 @@ from .._config_coercion import coerce_dataclass_config from ..exceptions import UserError -from ..logger import logger +from ..logger import ( + log_model_and_tool_action_error, + log_model_and_tool_action_warning, + logger, +) from ..tracing import TraceCtxManager from .input import AudioInput, StreamedAudioInput from .model import STTModel, TTSModel @@ -109,7 +113,7 @@ async def stream_events(): await output._turn_done() await output._done() except Exception as e: - logger.error("Error processing single turn: %s", e) + log_model_and_tool_action_error(logger, "Error processing single voice turn", e) await output._add_error(e) raise e @@ -135,7 +139,9 @@ async def process_turns(): async for intro_text in self.workflow.on_start(): await output._add_text(intro_text) except Exception as e: - logger.warning("on_start() failed: %s", e) + log_model_and_tool_action_warning( + logger, "Voice workflow on_start failed", e + ) transcription_session = await self._get_stt_model().create_session( audio_input, @@ -150,7 +156,7 @@ async def process_turns(): await output._add_text(text_event) await output._turn_done() except Exception as e: - logger.error("Error processing turns: %s", e) + log_model_and_tool_action_error(logger, "Error processing voice turns", e) await output._add_error(e) raise e finally: diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 2f4b24433b..709c829779 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -7,7 +7,7 @@ from typing import Any from ..exceptions import UserError -from ..logger import logger +from ..logger import log_model_action_error, log_model_and_tool_action_error, logger from ..tracing import Span, SpeechGroupSpanData, speech_group_span, speech_span from ..tracing.util import time_iso from ..util._error_tracing import get_trace_error @@ -192,7 +192,7 @@ async def _stream_audio( }, } ) - logger.error("Error streaming audio: %s", e) + log_model_action_error(logger, "Error streaming voice audio", e) # Signal completion for whole session because of error await local_queue.put(VoiceStreamEventLifecycle(event="session_ended")) @@ -308,7 +308,9 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: break if isinstance(event, VoiceStreamEventError): self._stored_exception = event.error - logger.error("Error processing output: %s", event.error) + log_model_and_tool_action_error( + logger, "Error processing voice output", event.error + ) break if event is None: break diff --git a/tests/extensions/experiemental/codex/test_codex_tool.py b/tests/extensions/experiemental/codex/test_codex_tool.py index 9bf650816b..3aeb7db73d 100644 --- a/tests/extensions/experiemental/codex/test_codex_tool.py +++ b/tests/extensions/experiemental/codex/test_codex_tool.py @@ -14,6 +14,7 @@ from openai.types.responses import ResponseFunctionToolCall from pydantic import BaseModel, ConfigDict +import agents._debug as _debug from agents import Agent, function_tool from agents.exceptions import ModelBehaviorError, UserError from agents.extensions.experimental.codex import ( @@ -1802,7 +1803,13 @@ async def test_replaced_codex_tool_preserves_codex_collision_markers() -> None: @pytest.mark.asyncio -async def test_codex_tool_consume_events_with_on_stream_error() -> None: +async def test_codex_tool_consume_events_with_on_stream_error( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + secret = "SECRET_CODEX_STREAM_PAYLOAD" events = [ { "type": "item.started", @@ -1865,7 +1872,7 @@ async def event_stream(): def on_stream(payload: CodexToolStreamEvent) -> None: callbacks.append(payload.event.type) if payload.event.type == "item.started": - raise RuntimeError("boom") + raise RuntimeError(secret) context = ToolContext( context=None, @@ -1874,20 +1881,22 @@ def on_stream(payload: CodexToolStreamEvent) -> None: tool_arguments="{}", ) - with trace("codex-test"): - response, usage, thread_id = await codex_tool_module._consume_events( - event_stream(), - {"inputs": [{"type": "text", "text": "hello"}]}, - context, - SimpleNamespace(id="thread-1"), - on_stream, - 64, - ) + with caplog.at_level("ERROR", logger="openai.agents"): + with trace("codex-test"): + response, usage, thread_id = await codex_tool_module._consume_events( + event_stream(), + {"inputs": [{"type": "text", "text": "hello"}]}, + context, + SimpleNamespace(id="thread-1"), + on_stream, + 64, + ) assert response == "done" assert usage == Usage(input_tokens=1, cached_input_tokens=0, output_tokens=1) assert thread_id == "thread-1" assert "item.started" in callbacks + assert secret not in caplog.text @pytest.mark.asyncio diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 2472a8bf12..1e1973ba39 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -3,17 +3,19 @@ import asyncio import contextlib import json +import logging import tempfile import threading from pathlib import Path from typing import Any, cast -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest pytest.importorskip("sqlalchemy") # Skip tests if SQLAlchemy is not installed from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails +import agents._debug as _debug from agents import Agent, Runner, TResponseInputItem, function_tool from agents.extensions.memory import AdvancedSQLiteSession from agents.result import RunResult @@ -154,6 +156,32 @@ async def test_advanced_session_basic_functionality(agent: Agent): session.close() +@pytest.mark.parametrize("redacted", [True, False]) +async def test_create_branch_logging_respects_model_data_policy(monkeypatch, redacted: bool): + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + mock_logger = Mock() + session = AdvancedSQLiteSession( + session_id="advanced_branch_logging", + create_tables=True, + logger=mock_logger, + ) + secret = "SECRET_BRANCH_TURN_CONTENT" + + try: + await session.add_items( + [ + {"role": "user", "content": secret}, + {"role": "assistant", "content": "response"}, + ] + ) + await session.create_branch_from_turn(1, "branch") + + logged = str(mock_logger.debug.call_args) + assert (secret not in logged) is redacted + finally: + session.close() + + async def test_advanced_session_respects_custom_table_names(): """AdvancedSQLiteSession should consistently use configured table names.""" session = AdvancedSQLiteSession( @@ -1438,6 +1466,72 @@ async def test_error_handling_in_usage_tracking(usage_data: Usage): await session.store_run_usage(run_result) +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(True, False), (False, True), (False, False)], +) +async def test_usage_tracking_failure_identity_follows_model_data_policy( + usage_data: Usage, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + model_redacted: bool, + tool_redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + session_id = "SECRET_USAGE_SESSION_ID" + test_logger = logging.getLogger("advanced-sqlite-usage-failure") + session = AdvancedSQLiteSession( + session_id=session_id, + create_tables=True, + logger=test_logger, + ) + secret = "SECRET_USAGE_FAILURE" + run_result = create_mock_run_result(usage_data) + + original_record_factory = logging.getLogRecordFactory() + + def application_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord: + record = original_record_factory(*args, **kwargs) + record.session_id = "APPLICATION_SESSION_ID" + return record + + logging.setLogRecordFactory(application_record_factory) + try: + with ( + patch.object( + session, + "_update_turn_usage_internal", + side_effect=RuntimeError(secret), + ), + caplog.at_level(logging.ERROR, logger=test_logger.name), + ): + await session.store_run_usage(run_result) + finally: + logging.setLogRecordFactory(original_record_factory) + + record = next( + record + for record in caplog.records + if "Failed to store session usage" in record.getMessage() + ) + assert record.__dict__["session_id"] == "APPLICATION_SESSION_ID" + if model_redacted: + assert record.msg == "%s" + assert record.args == ("Failed to store session usage",) + assert record.exc_info is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert secret not in caplog.text + assert session_id not in caplog.text + else: + assert record.__dict__["openai_agents_diagnostic_context"] == {"session_id": session_id} + assert record.exc_info is not None + assert record.exc_info[1] is not None + assert secret in caplog.text + + session.close() + + async def test_advanced_tool_name_extraction(): """Test advanced tool name extraction for different tool types.""" session_id = "advanced_tool_names_test" diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 8f189bcd4d..97d0168ab3 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -3,6 +3,7 @@ import asyncio import io import json +import logging import tarfile import time import uuid @@ -14,6 +15,7 @@ import pytest from pydantic import ValidationError +import agents._debug as _debug from agents.run_config import SandboxRunConfig from agents.sandbox import Manifest, SandboxPathGrant from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE @@ -3539,13 +3541,45 @@ async def test_detach_drive_success(self) -> None: assert sandbox.drives.unmount_calls == ["/mnt/data"] @pytest.mark.asyncio - async def test_detach_drive_error_logged_not_raised(self) -> None: + @pytest.mark.parametrize("redacted", [True, False], ids=["redacted", "diagnostic"]) + async def test_detach_drive_error_logged_not_raised( + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + redacted: bool, + ) -> None: from agents.extensions.sandbox.blaxel.mounts import _detach_drive + mount_path = "/mnt/SECRET_DRIVE_PATH" + error = RuntimeError("SECRET_UNMOUNT_ERROR") sandbox = _FakeSandboxInstance() - sandbox.drives.unmount_error = RuntimeError("unmount failed") + sandbox.drives.unmount_error = error + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + caplog.set_level(logging.WARNING) + # Should not raise; error is logged. - await _detach_drive(sandbox, "/mnt/data") + await _detach_drive(sandbox, mount_path) + + record = next( + record + for record in caplog.records + if "Drive detach failed" in logging.Formatter().format(record) + ) + if redacted: + assert record.msg == "%s" + assert record.args == ("Drive detach failed (non-fatal)",) + assert record.exc_info is None + assert record.exc_text is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert error not in record.__dict__.values() + rendered = logging.Formatter().format(record) + assert mount_path not in rendered + assert "SECRET_UNMOUNT_ERROR" not in rendered + else: + assert record.__dict__["openai_agents_diagnostic_context"] == {"mount_path": mount_path} + assert record.exc_info is not None + assert record.exc_info[1] is error + assert "SECRET_UNMOUNT_ERROR" in logging.Formatter().format(record) @pytest.mark.asyncio async def test_detach_drive_no_drives_api(self) -> None: diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index 84e5ae9f39..c0a32f13de 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -50,6 +50,7 @@ def __init__(self, status: int = 200, json_body: Any = None, raw_body: bytes = b self.status = status self._json_body = json_body self._raw_body = raw_body + self.read_calls = 0 async def json(self, *, content_type: str | None = None) -> Any: _ = content_type @@ -58,6 +59,7 @@ async def json(self, *, content_type: str | None = None) -> Any: return json.loads(self._raw_body) async def read(self) -> bytes: + self.read_calls += 1 if self._json_body is not None: return json.dumps(self._json_body).encode() return self._raw_body @@ -1535,31 +1537,33 @@ def delete(self, url: str, **kwargs: Any) -> Any: @pytest.mark.asyncio -async def test_cloudflare_shutdown_logs_delete_response_details( +@pytest.mark.parametrize("redacted", [True, False]) +async def test_cloudflare_shutdown_logs_respect_tool_data_policy( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + redacted: bool, ) -> None: - """Verify that DELETE response bodies are kept when shutdown cleanup fails.""" + """Verify that DELETE response bodies follow the tool-data logging policy.""" import logging - sess = _make_session( - fake_http=_FakeHttp( - { - "DELETE /v1/sandbox/": _FakeResponse( - status=502, - json_body={ - "error": "pool error: Failed to start container", - "code": "pool_error", - }, - ) - } - ) + monkeypatch.setattr("agents._debug.DONT_LOG_TOOL_DATA", redacted) + + response = _FakeResponse( + status=502, + json_body={ + "error": "pool error: Failed to start container", + "code": "pool_error", + }, ) + sess = _make_session(fake_http=_FakeHttp({"DELETE /v1/sandbox/": response})) with caplog.at_level(logging.DEBUG, logger="agents.extensions.sandbox.cloudflare.sandbox"): await sess._shutdown_backend() - assert any( + has_detail = any( "DELETE /sandbox failed: HTTP 502: pool_error: pool error: Failed to start container" in r.message for r in caplog.records ) + assert has_detail is not redacted + assert response.read_calls == (0 if redacted else 1) diff --git a/tests/extensions/sandbox/test_e2b.py b/tests/extensions/sandbox/test_e2b.py index d6a4ac33fa..f830546517 100644 --- a/tests/extensions/sandbox/test_e2b.py +++ b/tests/extensions/sandbox/test_e2b.py @@ -2189,11 +2189,15 @@ async def test_e2b_stop_terminates_live_pty_sessions() -> None: @pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) async def test_e2b_shutdown_logs_pause_failure_and_falls_back_to_kill( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + redacted: bool, ) -> None: + monkeypatch.setattr("agents._debug.DONT_LOG_TOOL_DATA", redacted) sandbox = _FakeE2BSandbox() - sandbox.pause_error = RuntimeError("pause failed") + sandbox.pause_error = RuntimeError("SECRET_E2B_PAUSE_FAILURE") state = E2BSandboxSessionState( session_id=uuid.uuid4(), manifest=Manifest(root="/workspace"), @@ -2211,12 +2215,24 @@ async def test_e2b_shutdown_logs_pause_failure_and_falls_back_to_kill( assert sandbox.pause_calls == 1 assert sandbox.kill_calls == 1 assert "Failed to pause E2B sandbox on shutdown; falling back to kill." in caplog.text + assert ("SECRET_E2B_PAUSE_FAILURE" not in caplog.text) is redacted + record = caplog.records[-1] + assert ("openai_agents_diagnostic_context" in record.__dict__) is not redacted + if not redacted: + assert record.__dict__["openai_agents_diagnostic_context"] == { + "sandbox_id": sandbox.sandbox_id, + "pause_on_exit": True, + } @pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) async def test_e2b_shutdown_logs_kill_failure_after_pause_fallback( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + redacted: bool, ) -> None: + monkeypatch.setattr("agents._debug.DONT_LOG_TOOL_DATA", redacted) sandbox = _FakeE2BSandbox() sandbox.pause_error = RuntimeError("pause failed") sandbox.kill_error = RuntimeError("kill failed") @@ -2237,10 +2253,23 @@ async def test_e2b_shutdown_logs_kill_failure_after_pause_fallback( assert sandbox.pause_calls == 1 assert sandbox.kill_calls == 1 assert "Failed to kill E2B sandbox after pause fallback failure." in caplog.text + record = caplog.records[-1] + assert ("openai_agents_diagnostic_context" in record.__dict__) is not redacted + if not redacted: + assert record.__dict__["openai_agents_diagnostic_context"] == { + "sandbox_id": sandbox.sandbox_id, + "pause_on_exit": True, + } @pytest.mark.asyncio -async def test_e2b_shutdown_logs_direct_kill_failure(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize("redacted", [True, False]) +async def test_e2b_shutdown_logs_direct_kill_failure( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + redacted: bool, +) -> None: + monkeypatch.setattr("agents._debug.DONT_LOG_TOOL_DATA", redacted) sandbox = _FakeE2BSandbox() sandbox.kill_error = RuntimeError("kill failed") state = E2BSandboxSessionState( @@ -2260,6 +2289,13 @@ async def test_e2b_shutdown_logs_direct_kill_failure(caplog: pytest.LogCaptureFi assert sandbox.pause_calls == 0 assert sandbox.kill_calls == 1 assert "Failed to kill E2B sandbox on shutdown." in caplog.text + record = caplog.records[-1] + assert ("openai_agents_diagnostic_context" in record.__dict__) is not redacted + if not redacted: + assert record.__dict__["openai_agents_diagnostic_context"] == { + "sandbox_id": sandbox.sandbox_id, + "pause_on_exit": False, + } @pytest.mark.asyncio diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index ccf026e7ee..f1f769eb6f 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -1,4 +1,5 @@ import asyncio +import logging from typing import Any, cast import pytest @@ -12,7 +13,9 @@ Tool as MCPTool, ) +from agents import _debug from agents.mcp import MCPServer, MCPServerManager +from agents.mcp._logging import get_mcp_server_log_name from agents.run_context import RunContextWrapper @@ -121,6 +124,21 @@ async def read_resource(self, uri: str) -> ReadResourceResult: return ReadResourceResult(contents=[]) +class SensitiveNamedServer(FlakyServer): + def __init__(self, name: str) -> None: + super().__init__(failures=1) + self._name = name + self.name_reads = 0 + + @property + def name(self) -> str: + self.name_reads += 1 + return self._name + + async def connect(self) -> None: + raise RuntimeError("SECRET_MCP_CONNECT_ERROR") + + class CleanupAwareServer(MCPServer): def __init__(self) -> None: super().__init__() @@ -172,6 +190,84 @@ async def read_resource(self, uri: str) -> ReadResourceResult: return ReadResourceResult(contents=[]) +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("ordinary-server", "ordinary-server"), + ( + "sse: https://user:password@example.test/events?token=secret#fragment", + "sse: https://example.test/events", + ), + ( + "streamable_http: https://example.test/mcp?token=secret", + "streamable_http: https://example.test/mcp", + ), + ( + "streamable_http: https://user:password@example.test:8443/mcp?token=secret", + "streamable_http: https://example.test:8443/mcp", + ), + ("streamable_http: https://[::1]:8000/mcp", "streamable_http: https://[::1]:8000/mcp"), + ( + "streamable-http: https://example.test/mcp#secret", + "streamable-http: https://example.test/mcp", + ), + ( + "streamable_http: https://user:password@[invalid/mcp?token=secret", + "streamable_http: ", + ), + ( + "streamable_http: https://user:password/mcp?token=secret", + "streamable_http: ", + ), + ("https://user:password@example.test/mcp?token=secret", "https://example.test/mcp"), + ("https://user:password@[invalid/mcp?token=secret", ""), + ("stdio: python server.py?token=secret", "stdio: python server.py?token=secret"), + ], +) +def test_get_mcp_server_log_name(name: str, expected: str) -> None: + assert get_mcp_server_log_name(name) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) +@pytest.mark.parametrize( + ("server_name", "diagnostic_sentinel", "always_hidden"), + [ + ( + "streamable_http: https://SECRET_CREDENTIAL@example.test/" + "SECRET_MCP_PATH?token=SECRET_MCP_QUERY#SECRET_MCP_FRAGMENT", + "SECRET_MCP_PATH", + ("SECRET_CREDENTIAL", "SECRET_MCP_QUERY", "SECRET_MCP_FRAGMENT"), + ), + ( + "SECRET_CUSTOM_MCP_SERVER_NAME", + "SECRET_CUSTOM_MCP_SERVER_NAME", + (), + ), + ], +) +async def test_manager_sanitizes_url_derived_server_names_in_failure_logs( + monkeypatch, + caplog, + redacted: bool, + server_name: str, + diagnostic_sentinel: str, + always_hidden: tuple[str, ...], +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + server = SensitiveNamedServer(server_name) + manager = MCPServerManager([server]) + + with caplog.at_level(logging.ERROR, logger="openai.agents"): + await manager.connect_all() + + assert (diagnostic_sentinel not in caplog.text) is redacted + assert server.name_reads == (0 if redacted else 1) + for sentinel in always_hidden: + assert sentinel not in caplog.text + assert ("SECRET_MCP_CONNECT_ERROR" not in caplog.text) is redacted + + class CancelledServer(MCPServer): def __init__(self) -> None: super().__init__() diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 7ddf9f9068..e1a06f17eb 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -677,7 +677,7 @@ async def test_mcp_invoke_bad_json_errors(caplog: pytest.LogCaptureFixture): with pytest.raises(ModelBehaviorError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "not_json") - assert "Invalid JSON input for tool test_tool_1" in caplog.text + assert "Invalid JSON input for MCP tool" in caplog.text @pytest.mark.asyncio @@ -807,7 +807,7 @@ async def test_mcp_invocation_crash_causes_error(caplog: pytest.LogCaptureFixtur with pytest.raises(AgentsException): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") - assert "Error invoking MCP tool test_tool_1" in caplog.text + assert "Error invoking MCP tool" in caplog.text class SecretCrashingFakeMCPServer(FakeMCPServer): @@ -837,15 +837,17 @@ async def test_mcp_invocation_crash_redacts_error_when_dont_log_tool_data( caplog.set_level(logging.DEBUG) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) - server = SecretCrashingFakeMCPServer() - server.add_tool("test_tool_1", {}) + server = SecretCrashingFakeMCPServer(server_name="SECRET_CUSTOM_MCP_SERVER") + server.add_tool("SECRET_MCP_TOOL_NAME", {}) ctx = RunContextWrapper(context=None) - tool = MCPTool(name="test_tool_1", inputSchema={}) + tool = MCPTool(name="SECRET_MCP_TOOL_NAME", inputSchema={}) with pytest.raises(AgentsException): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") - assert "Error invoking MCP tool test_tool_1" in caplog.text + assert "Error invoking MCP tool" in caplog.text + assert "SECRET_CUSTOM_MCP_SERVER" not in caplog.text + assert "SECRET_MCP_TOOL_NAME" not in caplog.text assert "SECRET_CRASH_123" not in caplog.text @@ -856,7 +858,12 @@ async def test_mcp_invocation_crash_includes_error_when_tool_logging_enabled( caplog.set_level(logging.DEBUG) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - server = SecretCrashingFakeMCPServer() + server = SecretCrashingFakeMCPServer( + server_name=( + "streamable_http: https://SECRET_CREDENTIAL@example.test/" + "SECRET_MCP_PATH?token=SECRET_MCP_QUERY" + ) + ) server.add_tool("test_tool_1", {}) ctx = RunContextWrapper(context=None) tool = MCPTool(name="test_tool_1", inputSchema={}) @@ -865,6 +872,9 @@ async def test_mcp_invocation_crash_includes_error_when_tool_logging_enabled( await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") assert "SECRET_CRASH_123" in caplog.text + assert "SECRET_MCP_PATH" in caplog.text + assert "SECRET_CREDENTIAL" not in caplog.text + assert "SECRET_MCP_QUERY" not in caplog.text @pytest.mark.asyncio @@ -874,15 +884,17 @@ async def test_mcp_tool_returned_error_redacts_message_when_dont_log_tool_data( caplog.set_level(logging.DEBUG) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) - server = McpErrorFakeMCPServer() - server.add_tool("test_tool_1", {}) + server = McpErrorFakeMCPServer(server_name="SECRET_CUSTOM_MCP_SERVER") + server.add_tool("SECRET_MCP_TOOL_NAME", {}) ctx = RunContextWrapper(context=None) - tool = MCPTool(name="test_tool_1", inputSchema={}) + tool = MCPTool(name="SECRET_MCP_TOOL_NAME", inputSchema={}) with pytest.raises(McpError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") - assert "MCP tool test_tool_1 on server" in caplog.text + assert "MCP tool returned an error" in caplog.text + assert "SECRET_CUSTOM_MCP_SERVER" not in caplog.text + assert "SECRET_MCP_TOOL_NAME" not in caplog.text assert "SECRET_MCP_123" not in caplog.text diff --git a/tests/mcp/test_tool_filtering.py b/tests/mcp/test_tool_filtering.py index 0127df806c..3da58756a6 100644 --- a/tests/mcp/test_tool_filtering.py +++ b/tests/mcp/test_tool_filtering.py @@ -9,6 +9,7 @@ import pytest from mcp import Tool as MCPTool +import agents._debug as _debug from agents import Agent from agents.mcp import ToolFilterContext, create_static_tool_filter from agents.run_context import RunContextWrapper @@ -181,6 +182,38 @@ def error_prone_filter(context: ToolFilterContext, tool: MCPTool) -> bool: assert {t.name for t in tools} == {"good_tool", "another_good_tool"} +@pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) +async def test_dynamic_filter_error_logging_preserves_identity_only_in_diagnostic_mode( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + server = FakeMCPServer( + server_name=( + "streamable_http: https://SECRET_CREDENTIAL@example.test/" + "SECRET_SERVER_PATH?token=SECRET_QUERY" + ) + ) + server.add_tool("SECRET_TOOL_NAME", {}) + + def failing_filter(context: ToolFilterContext, tool: MCPTool) -> bool: + raise ValueError("SECRET_FILTER_ERROR") + + server.tool_filter = failing_filter + + with caplog.at_level("ERROR", logger="openai.agents"): + tools = await server.list_tools(create_test_context(), create_test_agent()) + + assert tools == [] + assert "SECRET_CREDENTIAL" not in caplog.text + assert "SECRET_QUERY" not in caplog.text + assert ("SECRET_TOOL_NAME" in caplog.text) is not redacted + assert ("SECRET_SERVER_PATH" in caplog.text) is not redacted + assert ("SECRET_FILTER_ERROR" in caplog.text) is not redacted + + # === Integration Tests === diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index fe893cf88a..16b7ce8718 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -8,6 +8,7 @@ import pytest +import agents._debug as _debug from agents import Agent, Runner from agents.items import TResponseInputItem from agents.memory import ( @@ -706,9 +707,12 @@ async def clear_session(self) -> None: assert failing_session.add_calls == 1 @pytest.mark.asyncio + @pytest.mark.parametrize("redacted", [True, False]) async def test_run_compaction_reraises_replacement_error_when_restore_fails( - self, caplog: pytest.LogCaptureFixture + self, monkeypatch, caplog: pytest.LogCaptureFixture, redacted: bool ) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) history: list[TResponseInputItem] = [ cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}), ] @@ -730,7 +734,7 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: if self.add_calls == 1: await super().add_items(items[:1]) raise RuntimeError("replacement failed") - raise RuntimeError("restore failed") + raise RuntimeError("SECRET_COMPACTION_RESTORE_FAILURE") async def clear_session(self) -> None: self.clear_calls += 1 @@ -758,6 +762,7 @@ async def clear_session(self) -> None: assert ( "Failed to restore session history after compaction replacement failed." in caplog.text ) + assert ("SECRET_COMPACTION_RESTORE_FAILURE" not in caplog.text) is redacted assert failing_session.clear_calls == 2 assert failing_session.add_calls == 2 diff --git a/tests/realtime/test_agent.py b/tests/realtime/test_agent.py index 7ac5cbe359..bc2a4c408c 100644 --- a/tests/realtime/test_agent.py +++ b/tests/realtime/test_agent.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any +from unittest.mock import patch import pytest @@ -29,6 +30,29 @@ def _instructions(ctx, agt) -> str: assert instructions == "Dynamic" +@pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) +async def test_mutated_invalid_instructions_respect_model_data_policy( + monkeypatch, redacted: bool +) -> None: + class SensitiveInstructions: + def __str__(self) -> str: + return "SECRET_REALTIME_INSTRUCTIONS" + + __repr__ = __str__ + + agent = RealtimeAgent(name="test") + agent.instructions = SensitiveInstructions() # type: ignore[assignment] + monkeypatch.setattr("agents.realtime.agent._debug.DONT_LOG_MODEL_DATA", redacted) + + with patch("agents.realtime.agent.logger") as mock_logger: + prompt = await agent.get_system_prompt(RunContextWrapper(context=None)) + + assert prompt is None + logged = str(mock_logger.error.call_args) + assert ("SECRET_REALTIME_INSTRUCTIONS" not in logged) is redacted + + def test_post_init_rejects_invalid_field_types() -> None: with pytest.raises(TypeError, match="RealtimeAgent name must be a string"): RealtimeAgent(name=1) # type: ignore[arg-type] diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 3211f2358d..8d69916505 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -1,6 +1,7 @@ import asyncio import dataclasses import json +import logging import threading from typing import Any, cast from unittest.mock import AsyncMock, Mock, PropertyMock, patch @@ -8,6 +9,7 @@ import pytest from pydantic import BaseModel, ConfigDict +import agents._debug as _debug from agents.exceptions import ToolTimeoutError, UserError from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail from agents.handoffs import Handoff @@ -576,6 +578,7 @@ class _FakeAudio: @pytest.mark.asyncio async def test_item_updated_merge_exception_path_logs_error(monkeypatch): + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) model = _DummyModel() agent = RealtimeAgent(name="agent") session = RealtimeSession(model, agent, None) @@ -594,8 +597,7 @@ async def test_item_updated_merge_exception_path_logs_error(monkeypatch): with patch("agents.realtime.session.logger") as mock_logger: await session.on_event(RealtimeModelItemUpdatedEvent(item=incoming)) - # error branch should be hit - assert mock_logger.error.called + mock_logger.error.assert_called_once_with("%s", "Error merging transcripts", stacklevel=3) @pytest.mark.asyncio @@ -3119,6 +3121,31 @@ async def test_reject_pending_tool_call_uses_run_level_formatter( for ev in events ) + @pytest.mark.asyncio + async def test_rejection_formatter_error_is_redacted( + self, monkeypatch, mock_model, mock_agent, mock_function_tool + ): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + + def fail_formatter(_args): + raise ValueError("SECRET_REALTIME_TOOL_FORMATTER") + + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"tool_error_formatter": fail_formatter}, + ) + + with patch("agents.realtime.session.logger") as mock_logger: + message = await session._resolve_approval_rejection_message( + tool=mock_function_tool, + call_id="call_reject_error", + ) + + assert message + mock_logger.error.assert_called_once_with("%s", "Tool error formatter failed", stacklevel=3) + @pytest.mark.asyncio async def test_reject_pending_tool_call_prefers_explicit_message( self, mock_model, mock_agent, mock_function_tool @@ -3491,6 +3518,96 @@ def guardrail_func(context, agent, output): return OutputGuardrail(guardrail_function=guardrail_func, name="safe_guardrail") + @pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(True, False), (False, True), (False, False)], + ids=["model_redacted", "tool_redacted", "diagnostic"], + ) + @pytest.mark.asyncio + async def test_output_guardrail_failure_follows_both_data_policies( + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + mock_model: RealtimeModel, + model_redacted: bool, + tool_redacted: bool, + ) -> None: + error = RuntimeError("SECRET_REALTIME_GUARDRAIL_ERROR") + + async def failing_guardrail(context, agent, output): + _ = context, agent, output + raise error + + guardrail = OutputGuardrail( + guardrail_function=failing_guardrail, + name="SECRET_REALTIME_GUARDRAIL_NAME", + ) + agent = RealtimeAgent(name="agent", output_guardrails=[guardrail]) + session = RealtimeSession(mock_model, agent, None) + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + + with caplog.at_level(logging.DEBUG, logger="openai.agents"): + triggered = await session._run_output_guardrails("model text", "response-id") + + assert triggered is False + records = [ + record + for record in caplog.records + if "Output guardrail raised an exception" in record.getMessage() + ] + assert len(records) == 1 + record = records[0] + redacted = model_redacted or tool_redacted + if redacted: + assert record.msg == "%s" + assert record.args == ("Output guardrail raised an exception; skipping it",) + assert record.exc_info is None + assert record.exc_text is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert error not in record.__dict__.values() + rendered = logging.Formatter().format(record) + assert "SECRET_REALTIME_GUARDRAIL_ERROR" not in rendered + assert "SECRET_REALTIME_GUARDRAIL_NAME" not in rendered + else: + context = record.__dict__["openai_agents_diagnostic_context"] + assert context == {"guardrail_name": "SECRET_REALTIME_GUARDRAIL_NAME"} + assert record.exc_info is not None + assert record.exc_info[1] is error + assert "SECRET_REALTIME_GUARDRAIL_ERROR" in logging.Formatter().format(record) + + @pytest.mark.asyncio + async def test_output_guardrail_failure_tolerates_missing_callable_name( + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + mock_model: RealtimeModel, + ) -> None: + class _FailingGuardrailCallable: + async def __call__(self, context, agent, output): + _ = context, agent, output + raise RuntimeError("SECRET_UNNAMED_GUARDRAIL_ERROR") + + guardrail = OutputGuardrail(guardrail_function=_FailingGuardrailCallable()) + agent = RealtimeAgent(name="agent", output_guardrails=[guardrail]) + session = RealtimeSession(mock_model, agent, None) + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + triggered = await session._run_output_guardrails("model text", "response-id") + + assert triggered is False + records = [ + record + for record in caplog.records + if "Output guardrail raised an exception" in record.getMessage() + ] + assert len(records) == 1 + context = records[0].__dict__["openai_agents_diagnostic_context"] + assert context["guardrail_type"].endswith("._FailingGuardrailCallable") + assert records[0].exc_info is not None + @pytest.mark.asyncio async def test_transcript_delta_triggers_guardrail_at_threshold( self, mock_model, mock_agent, triggered_guardrail diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index c917dacd6d..fa6c3d4bca 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -2,6 +2,7 @@ import io import json +import logging from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -12,6 +13,7 @@ from openai.types.responses.response_output_message import ResponseOutputMessage from openai.types.responses.response_reasoning_item import ResponseReasoningItem +import agents._debug as _debug import agents.sandbox.capabilities.memory as memory_module import agents.sandbox.memory.manager as memory_manager_module import agents.sandbox.memory.phase_one as phase_one_module @@ -32,7 +34,7 @@ ToolApprovalItem, TResponseOutputItem, ) -from agents.result import RunResultStreaming +from agents.result import RunResult, RunResultStreaming from agents.run import _sandbox_memory_input from agents.run_context import RunContextWrapper from agents.sandbox import ( @@ -1495,15 +1497,31 @@ async def test_sandbox_memory_unregisters_manager_on_session_close() -> None: await client.delete(session) +@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"]) +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(True, False), (False, True), (False, False)], + ids=["model_redacted", "tool_redacted", "diagnostic"], +) @pytest.mark.asyncio -async def test_sandbox_memory_enqueue_failure_still_cleans_up_owned_session( +async def test_sandbox_memory_enqueue_failure_follows_both_data_policies( monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + streamed: bool, + model_redacted: bool, + tool_redacted: bool, ) -> None: + secret = "SECRET_SANDBOX_MEMORY_PAYLOAD" + error = RuntimeError(secret) + async def _raise_write_rollout(*args: Any, **kwargs: Any) -> Path: _ = args, kwargs - raise RuntimeError("write_rollout failed") + raise error monkeypatch.setattr(memory_manager_module, "write_rollout", _raise_write_rollout) + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + caplog.set_level(logging.WARNING) client = _DeleteTrackingUnixLocalSandboxClient() agent = SandboxAgent( @@ -1513,16 +1531,40 @@ async def _raise_write_rollout(*args: Any, **kwargs: Any) -> Path: capabilities=[_memory_config()], ) - result = await Runner.run( - agent, - "hello", - run_config=RunConfig(sandbox=SandboxRunConfig(client=client)), - ) + run_config = RunConfig(sandbox=SandboxRunConfig(client=client)) + result: RunResult | RunResultStreaming + if streamed: + result = Runner.run_streamed(agent, "hello", run_config=run_config) + async for _ in result.stream_events(): + pass + expected_message = "Failed to enqueue sandbox memory after streamed run" + else: + result = await Runner.run(agent, "hello", run_config=run_config) + expected_message = "Failed to enqueue sandbox memory after run" assert result.final_output == "done" assert len(client.deleted_roots) == 1 assert not client.deleted_roots[0].exists() + record = next( + record + for record in caplog.records + if expected_message in logging.Formatter().format(record) + ) + redacted = model_redacted or tool_redacted + if redacted: + assert record.msg == "%s" + assert record.args == (expected_message,) + assert record.exc_info is None + assert record.exc_text is None + assert error not in record.__dict__.values() + assert secret not in logging.Formatter().format(record) + else: + assert record.args == (expected_message, error) + assert record.exc_info is not None + assert record.exc_info[1] is error + assert secret in logging.Formatter().format(record) + @pytest.mark.asyncio async def test_sandbox_memory_marks_interrupted_runs_in_phase_one_prompt() -> None: diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 9f46ef02cc..0a76aed396 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -3,6 +3,7 @@ import asyncio import io import json +import logging import os import re import shutil @@ -18,6 +19,7 @@ from openai.types.responses.response_output_item import LocalShellCall, LocalShellCallAction from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary +import agents._debug as _debug import agents.sandbox.runtime_agent_preparation as runtime_agent_preparation_module from agents import Agent, AgentHooks, LocalShellTool, RunHooks, Runner, function_tool from agents.exceptions import InputGuardrailTripwireTriggered, UserError @@ -2115,14 +2117,17 @@ async def _fake_unmount( assert order == [root / "outer" / "child", root / "outer"] +@pytest.mark.parametrize("redacted", [True, False], ids=["redacted", "diagnostic"]) @pytest.mark.asyncio async def test_unix_local_client_delete_skips_rmtree_when_unmount_fails( monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + redacted: bool, ) -> None: client = UnixLocalSandboxClient() manifest = _unix_local_manifest( entries={ - "remote": S3Mount( + "SECRET_REMOTE_MOUNT": S3Mount( bucket="bucket", mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), ), @@ -2139,7 +2144,7 @@ async def _failing_unmount( base_dir: Path, ) -> None: _ = (self, session, dest, base_dir) - raise RuntimeError("busy") + raise RuntimeError("SECRET_UNMOUNT_ERROR") def _fake_rmtree(path: Path, ignore_errors: bool = False) -> None: _ = (path, ignore_errors) @@ -2148,12 +2153,34 @@ def _fake_rmtree(path: Path, ignore_errors: bool = False) -> None: monkeypatch.setattr(S3Mount, "unmount", _failing_unmount) monkeypatch.setattr(shutil, "rmtree", _fake_rmtree) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + caplog.set_level(logging.WARNING) await client.delete(session) assert rmtree_called is False assert workspace_root.exists() + record = next( + record + for record in caplog.records + if "Failed to unmount UnixLocal workspace mount" in logging.Formatter().format(record) + ) + mount_path = str(workspace_root / "SECRET_REMOTE_MOUNT") + if redacted: + assert record.msg == "%s" + assert record.args == ("Failed to unmount UnixLocal workspace mount before deleting root",) + assert record.exc_info is None + assert record.exc_text is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert mount_path not in logging.Formatter().format(record) + assert "SECRET_UNMOUNT_ERROR" not in logging.Formatter().format(record) + else: + assert record.__dict__["openai_agents_diagnostic_context"] == {"mount_path": mount_path} + assert record.exc_info is not None + assert record.exc_info[1] is not None + assert "SECRET_UNMOUNT_ERROR" in logging.Formatter().format(record) + shutil.rmtree(workspace_root, ignore_errors=True) diff --git a/tests/sandbox/test_session_manager.py b/tests/sandbox/test_session_manager.py index 67891b74c8..a6f5c6d70f 100644 --- a/tests/sandbox/test_session_manager.py +++ b/tests/sandbox/test_session_manager.py @@ -1,11 +1,13 @@ from __future__ import annotations import asyncio +import logging import uuid from pathlib import Path import pytest +import agents._debug as _debug from agents.sandbox.manifest import Manifest from agents.sandbox.runtime_session_manager import SandboxRuntimeSessionManager from agents.sandbox.sandboxes.unix_local import ( @@ -193,6 +195,54 @@ async def handle(self, event: SandboxSessionEvent) -> None: await instrumentation.emit(event) +@pytest.mark.parametrize("redacted", [True, False], ids=["redacted", "diagnostic"]) +@pytest.mark.asyncio +async def test_logged_sink_failure_conditionally_includes_sink_type( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + redacted: bool, +) -> None: + class _FailingLogSink(_EventSink): + async def handle(self, event: SandboxSessionEvent) -> None: + _ = event + raise RuntimeError("SECRET_SINK_ERROR") + + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + caplog.set_level(logging.ERROR) + instrumentation = Instrumentation(sinks=[_FailingLogSink(mode="sync", on_error="log")]) + event = SandboxSessionFinishEvent( + session_id=uuid.uuid4(), + seq=1, + op="running", + span_id="span_running", + ok=True, + duration_ms=0.0, + ) + + await instrumentation.emit(event) + + record = next( + record + for record in caplog.records + if "Sandbox event sink failed" in logging.Formatter().format(record) + ) + if redacted: + assert record.msg == "%s" + assert record.args == ("Sandbox event sink failed (ignored)",) + assert record.exc_info is None + assert record.exc_text is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert "_FailingLogSink" not in logging.Formatter().format(record) + assert "SECRET_SINK_ERROR" not in logging.Formatter().format(record) + else: + assert record.__dict__["openai_agents_diagnostic_context"] == { + "sink_type": "_FailingLogSink" + } + assert record.exc_info is not None + assert record.exc_info[1] is not None + assert "SECRET_SINK_ERROR" in logging.Formatter().format(record) + + def test_session_manager_uses_custom_snapshot_spec_without_resolving_default( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index c5cc123034..3872bbb8f6 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -13,6 +13,7 @@ from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall from pydantic import BaseModel, Field +import agents._debug as _debug from agents import ( Agent, AgentBase, @@ -2404,10 +2405,21 @@ async def _invoke_tool() -> Any: @pytest.mark.asyncio +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(True, False), (False, True), (False, False)], +) async def test_agent_as_tool_streaming_handler_exception_does_not_fail_call( monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + model_redacted: bool, + tool_redacted: bool, ) -> None: - agent = Agent(name="handler_error_agent") + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + agent_name = "SECRET_HANDLER_ERROR_AGENT" + agent = Agent(name=agent_name) + secret = "SECRET_AGENT_STREAM_PAYLOAD" class DummyStreamingResult: def __init__(self) -> None: @@ -2427,7 +2439,7 @@ async def stream_events(self): ) def bad_handler(event: AgentToolStreamEvent) -> None: - raise RuntimeError("boom") + raise RuntimeError(secret) tool_call = ResponseFunctionToolCall( id="call_bad", @@ -2450,9 +2462,27 @@ def bad_handler(event: AgentToolStreamEvent) -> None: tool_call=tool_call, ) - output = await tool.on_invoke_tool(tool_context, '{"input": "go"}') + with caplog.at_level("ERROR", logger="openai.agents"): + output = await tool.on_invoke_tool(tool_context, '{"input": "go"}') assert output == "ok" + record = next( + record + for record in caplog.records + if "Error while handling an agent tool on_stream event" in record.getMessage() + ) + if model_redacted or tool_redacted: + assert record.msg == "%s" + assert record.args == ("Error while handling an agent tool on_stream event",) + assert record.exc_info is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert secret not in caplog.text + assert agent_name not in caplog.text + else: + assert record.__dict__["openai_agents_diagnostic_context"] == {"agent_name": agent_name} + assert record.exc_info is not None + assert record.exc_info[1] is not None + assert secret in caplog.text @pytest.mark.asyncio diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 12160d886e..ab6485045d 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -17,6 +17,7 @@ from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from typing_extensions import TypedDict +import agents._debug as _debug from agents import ( Agent, GuardrailFunctionOutput, @@ -2779,6 +2780,45 @@ async def test_rewind_handles_id_stripped_sessions() -> None: assert session.saved_items == [] +@pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) +async def test_rewind_debug_logging_respects_model_and_tool_policies( + monkeypatch, redacted: bool +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + secret = "SECRET_REWIND_SESSION_CONTENT" + session = IdStrippingSession() + item = cast( + TResponseInputItem, + {"id": "message-1", "type": "message", "role": "user", "content": secret}, + ) + await session.add_items([item]) + + with patch("agents.run_internal.session_persistence.logger") as mock_logger: + await rewind_session_items(session, [item]) + + logged = str(mock_logger.debug.call_args_list) + assert (secret not in logged) is redacted + + +@pytest.mark.asyncio +async def test_rewind_failure_uses_placeholder_free_shared_logger_message() -> None: + class FailingTailSession(SimpleListSession): + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + raise RuntimeError("tail failure") + + item = cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hi"}) + session = FailingTailSession(history=[item]) + + with patch( + "agents.run_internal.session_persistence.log_model_and_tool_action_warning" + ) as mock_warning: + await rewind_session_items(session, [item]) + + assert mock_warning.call_args.args[1] == "Failed to rewind session item" + + @pytest.mark.asyncio async def test_rewind_skips_mismatched_tail_suffix() -> None: target = cast(TResponseInputItem, {"type": "message", "role": "user", "content": "target"}) diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 85b0c56bc5..b2f9e6e027 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -2,6 +2,7 @@ import asyncio import json +import logging from typing import Any, cast import httpx @@ -17,6 +18,7 @@ from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from typing_extensions import TypedDict +import agents._debug as _debug from agents import ( Agent, GuardrailFunctionOutput, @@ -1188,6 +1190,72 @@ def guardrail_function( pass +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(True, False), (False, True), (False, False)], + ids=["model_redacted", "tool_redacted", "diagnostic"], +) +@pytest.mark.asyncio +async def test_streamed_finalizer_failure_follows_both_data_policies( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + model_redacted: bool, + tool_redacted: bool, +) -> None: + async def safe_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], input: Any + ) -> GuardrailFunctionOutput: + _ = context, agent, input + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + error = RuntimeError("SECRET_STREAM_FINALIZER_ERROR") + + async def fail_finalizer(_result: Any) -> bool: + raise error + + monkeypatch.setattr( + run_loop, + "input_guardrail_tripwire_triggered_for_stream", + fail_finalizer, + ) + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + agent_name = "SECRET_STREAM_AGENT_NAME" + agent = Agent( + name=agent_name, + input_guardrails=[InputGuardrail(guardrail_function=safe_guardrail)], + model=FakeModel(initial_output=[get_text_message("done")]), + ) + + with caplog.at_level(logging.DEBUG, logger="openai.agents"): + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + + assert result.final_output == "done" + record = next( + record + for record in caplog.records + if "Error finalizing streamed result" in record.getMessage() + ) + redacted = model_redacted or tool_redacted + if redacted: + assert record.msg == "%s" + assert record.args == ("Error finalizing streamed result",) + assert record.exc_info is None + assert record.exc_text is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + rendered = logging.Formatter().format(record) + assert agent_name not in rendered + assert "SECRET_STREAM_FINALIZER_ERROR" not in rendered + else: + context = record.__dict__["openai_agents_diagnostic_context"] + assert context == {"agent_name": agent_name} + assert record.exc_info is not None + assert record.exc_info[1] is error + assert "SECRET_STREAM_FINALIZER_ERROR" in logging.Formatter().format(record) + + @pytest.mark.asyncio async def test_input_guardrail_streamed_does_not_save_assistant_message_to_session(): async def guardrail_function( diff --git a/tests/test_computer_tool_lifecycle.py b/tests/test_computer_tool_lifecycle.py index 860dcef9b7..bbb0e04baf 100644 --- a/tests/test_computer_tool_lifecycle.py +++ b/tests/test_computer_tool_lifecycle.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging from typing import Any, cast from unittest.mock import AsyncMock @@ -11,6 +12,7 @@ ResponseComputerToolCall, ) +import agents._debug as _debug from agents import ( Agent, ComputerProvider, @@ -68,6 +70,32 @@ def drag(self, path: list[tuple[int, int]]) -> None: return None +@pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) +async def test_dispose_computer_failure_respects_tool_data_policy( + monkeypatch, caplog, redacted: bool +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + + async def dispose(**_kwargs: Any) -> None: + raise RuntimeError("SECRET_COMPUTER_DISPOSE_FAILURE") + + tool = ComputerTool( + computer=ComputerProvider[FakeComputer]( + create=AsyncMock(return_value=FakeComputer()), + dispose=dispose, + ) + ) + ctx = RunContextWrapper(context=None) + await resolve_computer(tool=tool, run_context=ctx) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + await dispose_resolved_computers(run_context=ctx) + + assert "Failed to dispose computer for run context" in caplog.text + assert ("SECRET_COMPUTER_DISPOSE_FAILURE" not in caplog.text) is redacted + + def _make_message(text: str) -> ResponseOutputMessage: return ResponseOutputMessage( id="msg-1", diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index 5942e6cd5b..4f08056915 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -9,8 +9,15 @@ from __future__ import annotations import logging +import pickle +import threading +from logging.handlers import QueueHandler +from pathlib import Path +from queue import SimpleQueue +from typing import Any from unittest.mock import patch +import httpx import pytest from openai import AsyncOpenAI @@ -23,16 +30,111 @@ RunContextWrapper, trace, ) +from agents.logger import ( + log_model_action_debug, + log_model_action_error, + log_model_action_warning, + log_model_and_tool_action_debug, + log_model_and_tool_action_error, + log_model_and_tool_action_warning, + log_tool_action_debug, + log_tool_action_error as log_shared_tool_action_error, + log_tool_action_warning, +) from agents.run_internal.tool_execution import ( log_tool_action_error, resolve_approval_rejection_message, ) +from agents.tracing.processor_interface import TracingProcessor +from agents.tracing.provider import SynchronousMultiTracingProcessor +from agents.tracing.spans import Span +from agents.tracing.traces import Trace _SECRET = "super secret prompt content" +class _RecordingHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +class _HostileException(Exception): + def __str__(self) -> str: + raise AssertionError("redacted logging inspected __str__") + + def __repr__(self) -> str: + raise AssertionError("redacted logging inspected __repr__") + + def __getattribute__(self, name: str): + if name in {"__class__", "__traceback__"}: + raise AssertionError(f"redacted logging inspected {name}") + return super().__getattribute__(name) + + +class _TruthinessException(Exception): + def __init__(self, *, truthy: bool) -> None: + super().__init__("diagnostic failure") + self.truthy = truthy + self.bool_calls = 0 + + def __bool__(self) -> bool: + self.bool_calls += 1 + if self.truthy: + raise AssertionError("logging inspected exception truthiness") + return False + + +class _FailingTracingProcessor(TracingProcessor): + def __init__(self) -> None: + self.str_calls = 0 + self.lock = threading.Lock() + + def __str__(self) -> str: + self.str_calls += 1 + return "SECRET_TRACE_PROCESSOR_ID" + + def _fail(self) -> None: + raise ValueError(_SECRET) + + def on_trace_start(self, trace: Trace) -> None: + self._fail() + + def on_trace_end(self, trace: Trace) -> None: + self._fail() + + def on_span_start(self, span: Span[Any]) -> None: + self._fail() + + def on_span_end(self, span: Span[Any]) -> None: + self._fail() + + def shutdown(self) -> None: + self._fail() + + def force_flush(self) -> None: + self._fail() + + +def _emit_shared_error_for_location(test_logger, helper) -> None: + helper(test_logger, "Fixed operational message", ValueError("failure")) + + +def _emit_tool_execution_error_for_location() -> None: + log_tool_action_error("Fixed operational message", ValueError("failure")) + + def _responses_model() -> OpenAIResponsesModel: - return OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + return OpenAIResponsesModel( + model="test-model", + openai_client=AsyncOpenAI( + api_key="test", + http_client=httpx.AsyncClient(trust_env=False), + ), + ) @pytest.mark.allow_call_model_methods @@ -63,7 +165,8 @@ async def raise_fetch(*args, **kwargs): mock_logger.error.assert_called_once() logged = str(mock_logger.error.call_args) assert _SECRET not in logged - assert "ValueError" in logged + assert "ValueError" not in logged + assert "Error getting response" in logged @pytest.mark.allow_call_model_methods @@ -124,7 +227,8 @@ async def raise_fetch(*args, **kwargs): mock_logger.error.assert_called_once() logged = str(mock_logger.error.call_args) assert _SECRET not in logged - assert "ValueError" in logged + assert "ValueError" not in logged + assert "Error streaming response" in logged def test_log_tool_action_error_redacts_by_default(monkeypatch) -> None: @@ -134,13 +238,261 @@ def test_log_tool_action_error_redacts_by_default(monkeypatch) -> None: log_tool_action_error("Shell executor failed", ValueError("rm -rf /secret/path")) mock_logger.error.assert_called_once() - logged = str(mock_logger.error.call_args) - assert "/secret/path" not in logged - assert "ValueError" in logged + assert mock_logger.error.call_args.args == ("%s", "Shell executor failed") # No traceback either, since it can embed the same sensitive data. assert mock_logger.error.call_args.kwargs.get("exc_info") in (None, False) +@pytest.mark.parametrize( + ("helper", "model_flag", "tool_flag"), + [ + (log_model_action_error, True, False), + (log_model_action_debug, True, False), + (log_model_action_warning, True, False), + (log_tool_action_debug, False, True), + (log_shared_tool_action_error, False, True), + (log_tool_action_warning, False, True), + (log_model_and_tool_action_error, True, False), + (log_model_and_tool_action_error, False, True), + (log_model_and_tool_action_debug, True, False), + (log_model_and_tool_action_warning, False, True), + ], +) +def test_shared_error_helpers_do_not_inspect_or_attach_redacted_exceptions( + monkeypatch, + helper, + model_flag: bool, + tool_flag: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_flag) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_flag) + test_logger = logging.Logger("sensitive-logging-redacted") + handler = _RecordingHandler() + test_logger.addHandler(handler) + hostile = _HostileException() + + helper(test_logger, "Fixed operational message", hostile) + + assert len(handler.records) == 1 + record = handler.records[0] + assert record.msg == "%s" + assert record.args == ("Fixed operational message",) + 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 message" + + +def test_shared_error_helper_preserves_diagnostics_when_enabled(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + test_logger = logging.Logger("sensitive-logging-diagnostic") + handler = _RecordingHandler() + test_logger.addHandler(handler) + error = ValueError(_SECRET) + + log_shared_tool_action_error(test_logger, "Tool failed", error) + + record = handler.records[0] + assert isinstance(record.args, tuple) + assert error in record.args + assert record.exc_info is not None + assert record.exc_info[1] is error + assert _SECRET in logging.Formatter().format(record) + + +@pytest.mark.parametrize( + "helper", + [log_shared_tool_action_error, log_tool_action_warning], +) +@pytest.mark.parametrize("truthy", [False, True], ids=["falsey", "hostile_bool"]) +def test_shared_error_helpers_do_not_evaluate_exception_truthiness( + monkeypatch, + helper, + truthy: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + test_logger = logging.Logger("sensitive-logging-exception-truthiness") + handler = _RecordingHandler() + test_logger.addHandler(handler) + error = _TruthinessException(truthy=truthy) + + try: + raise error + except _TruthinessException: + helper(test_logger, "Tool failed", error) + + record = handler.records[0] + assert error.bool_calls == 0 + assert record.exc_info is not None + assert record.exc_info[0] is type(error) + assert record.exc_info[1] is error + assert record.exc_info[2] is error.__traceback__ + + +@pytest.mark.parametrize("redacted", [True, False]) +def test_shared_error_helper_conditionally_attaches_diagnostic_extra( + monkeypatch, redacted: bool +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + test_logger = logging.Logger("sensitive-logging-diagnostic-extra") + handler = _RecordingHandler() + test_logger.addHandler(handler) + extra_calls = 0 + + def diagnostic_extra() -> dict[str, object]: + nonlocal extra_calls + extra_calls += 1 + return {"sandbox_id": _SECRET} + + log_tool_action_warning( + test_logger, + "Tool failed", + ValueError("failure"), + diagnostic_extra=diagnostic_extra, + ) + + record = handler.records[0] + assert extra_calls == (0 if redacted else 1) + assert ("openai_agents_diagnostic_context" in record.__dict__) is not redacted + if not redacted: + assert record.__dict__["openai_agents_diagnostic_context"] == {"sandbox_id": _SECRET} + + +def test_shared_error_helper_ignores_diagnostic_extra_failure(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + test_logger = logging.Logger("sensitive-logging-diagnostic-extra-failure") + handler = _RecordingHandler() + test_logger.addHandler(handler) + error = RuntimeError("original failure") + + def diagnostic_extra() -> dict[str, object]: + raise AttributeError("metadata failure") + + log_tool_action_warning( + test_logger, + "Tool failed", + error, + diagnostic_extra=diagnostic_extra, + ) + + record = handler.records[0] + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert record.exc_info is not None + assert record.exc_info[1] is error + assert "original failure" in logging.Formatter().format(record) + + +@pytest.mark.parametrize( + "operation", + [ + "on_trace_start", + "on_trace_end", + "on_span_start", + "on_span_end", + "force_flush", + "shutdown", + ], +) +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(True, False), (False, True), (False, False)], +) +def test_trace_processor_failure_identity_follows_both_data_policies( + monkeypatch, + operation: str, + model_redacted: bool, + tool_redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + test_logger = logging.Logger("sensitive-logging-trace-processor", level=logging.DEBUG) + test_logger.propagate = False + handler = _RecordingHandler() + test_logger.addHandler(handler) + failing = _FailingTracingProcessor() + multi = SynchronousMultiTracingProcessor() + multi.add_tracing_processor(failing) + + with patch("agents.tracing.provider.logger", test_logger): + if operation.startswith(("on_trace", "on_span")): + getattr(multi, operation)(object()) + else: + getattr(multi, operation)() + + record = next(record for record in handler.records if record.levelno == logging.ERROR) + redacted = model_redacted or tool_redacted + if redacted: + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert failing not in record.__dict__.values() + assert record.exc_info is None + assert _SECRET not in logging.Formatter().format(record) + assert failing.str_calls == 0 + else: + processor_identity = record.__dict__["openai_agents_diagnostic_context"]["trace_processor"] + assert isinstance(processor_identity, str) + assert type(failing).__module__ in processor_identity + assert type(failing).__qualname__ in processor_identity + assert f"{id(failing):x}" in processor_identity + prepared = QueueHandler(SimpleQueue()).prepare(record) + pickle.dumps(prepared) + assert record.exc_info is not None + assert record.exc_info[1] is not None + assert _SECRET in logging.Formatter().format(record) + + +@pytest.mark.parametrize( + "helper", + [log_shared_tool_action_error, log_tool_action_warning], +) +def test_shared_error_helpers_preserve_direct_caller_location(monkeypatch, helper) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + test_logger = logging.Logger("sensitive-logging-location") + handler = _RecordingHandler() + test_logger.addHandler(handler) + + _emit_shared_error_for_location(test_logger, helper) + + record = handler.records[0] + assert Path(record.pathname).resolve() == Path(__file__).resolve() + assert record.funcName == "_emit_shared_error_for_location" + + +def test_tool_execution_error_helper_preserves_external_caller_location(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + test_logger = logging.Logger("sensitive-logging-wrapped-location") + handler = _RecordingHandler() + test_logger.addHandler(handler) + + with patch("agents.run_internal.tool_execution.logger", test_logger): + _emit_tool_execution_error_for_location() + + record = handler.records[0] + assert Path(record.pathname).resolve() == Path(__file__).resolve() + assert record.funcName == "_emit_tool_execution_error_for_location" + + +def test_shared_error_helper_drops_exception_chains_and_notes(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + test_logger = logging.Logger("sensitive-logging-chain") + handler = _RecordingHandler() + test_logger.addHandler(handler) + cause = ValueError(f"{_SECRET} cause") + error = RuntimeError(f"{_SECRET} outer") + error.__cause__ = cause + if hasattr(error, "add_note"): + error.add_note(f"{_SECRET} note") + else: + error.__notes__ = [f"{_SECRET} note"] + + log_model_action_error(test_logger, "Model failed", error) + + record = handler.records[0] + assert record.exc_info is None + assert record.exc_text is None + assert error not in record.__dict__.values() + assert _SECRET not in logging.Formatter().format(record) + + def test_log_tool_action_error_logs_full_when_tool_data_enabled(monkeypatch) -> None: monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) @@ -150,7 +502,11 @@ def test_log_tool_action_error_logs_full_when_tool_data_enabled(monkeypatch) -> mock_logger.error.assert_called_once() logged = str(mock_logger.error.call_args) assert "/secret/path" in logged - assert mock_logger.error.call_args.kwargs.get("exc_info") is True + exc_info = mock_logger.error.call_args.kwargs.get("exc_info") + assert isinstance(exc_info, tuple) + assert exc_info[0] is ValueError + assert isinstance(exc_info[1], ValueError) + assert exc_info[2] is None @pytest.mark.asyncio @@ -161,16 +517,24 @@ async def test_approval_rejection_formatter_error_redacts_exception(monkeypatch, def boom(_args): raise ValueError("formatter blew up SECRET_FMT_123") + tool_name = "SECRET_FORMATTER_TOOL_NAME" result = await resolve_approval_rejection_message( context_wrapper=RunContextWrapper(context=None), run_config=RunConfig(tool_error_formatter=boom), tool_type="function", - tool_name="my_tool", + tool_name=tool_name, call_id="call_1", ) assert isinstance(result, str) and result - assert "Tool error formatter failed for my_tool" in caplog.text + record = next( + record for record in caplog.records if "Tool error formatter failed" in record.getMessage() + ) + assert record.msg == "%s" + assert record.args == ("Tool error formatter failed",) + assert record.exc_info is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert tool_name not in caplog.text assert "SECRET_FMT_123" not in caplog.text @@ -184,12 +548,18 @@ async def test_approval_rejection_formatter_error_logs_full_when_enabled( def boom(_args): raise ValueError("formatter blew up SECRET_FMT_123") + tool_name = "diagnostic_tool" await resolve_approval_rejection_message( context_wrapper=RunContextWrapper(context=None), run_config=RunConfig(tool_error_formatter=boom), tool_type="function", - tool_name="my_tool", + tool_name=tool_name, call_id="call_1", ) + record = next( + record for record in caplog.records if "Tool error formatter failed" in record.getMessage() + ) + assert record.__dict__["openai_agents_diagnostic_context"] == {"tool_name": tool_name} + assert record.exc_info is not None assert "SECRET_FMT_123" in caplog.text diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index c0d8898599..ae34114a30 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -12,9 +12,10 @@ import httpx import pytest +import agents._debug as _debug from agents.tracing import flush_traces, get_trace_provider from agents.tracing.processor_interface import TracingExporter, TracingProcessor -from agents.tracing.processors import BackendSpanExporter, BatchTraceProcessor +from agents.tracing.processors import BackendSpanExporter, BatchTraceProcessor, ConsoleSpanExporter from agents.tracing.provider import DefaultTraceProvider, TraceProvider from agents.tracing.span_data import AgentSpanData from agents.tracing.spans import Span, SpanImpl @@ -45,6 +46,20 @@ def get_trace(processor: TracingProcessor) -> TraceImpl: ) +@pytest.mark.parametrize("redacted", [True, False]) +def test_console_span_exporter_respects_data_policy(monkeypatch, capsys, redacted: bool) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + span = get_span(mock_processor()) + span.span_data.name = "SECRET_CONSOLE_SPAN" + + ConsoleSpanExporter().export([span]) + + output = capsys.readouterr().out + assert ("SECRET_CONSOLE_SPAN" not in output) is redacted + assert "Export span" in output + + @pytest.fixture def mocked_exporter(): exporter = MagicMock() @@ -438,17 +453,31 @@ def test_backend_span_exporter_2xx_success(mock_client): @patch("httpx.Client") -def test_backend_span_exporter_4xx_client_error(mock_client): - mock_response = MagicMock() - mock_response.status_code = 400 - mock_response.text = "Bad Request" +@pytest.mark.parametrize("redacted", [True, False]) +def test_backend_span_exporter_4xx_client_error(mock_client, monkeypatch, caplog, redacted: bool): + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + + class Response: + status_code = 400 + text_reads = 0 + + @property + def text(self) -> str: + self.text_reads += 1 + return "SECRET_TRACE_RESPONSE_BODY" + + mock_response = Response() mock_client.return_value.post.return_value = mock_response exporter = BackendSpanExporter(api_key="test_key") - exporter.export([get_span(mock_processor())]) + with caplog.at_level(logging.ERROR, logger="openai.agents"): + exporter.export([get_span(mock_processor())]) # 4xx should not be retried mock_client.return_value.post.assert_called_once() + assert ("SECRET_TRACE_RESPONSE_BODY" not in caplog.text) is redacted + assert mock_response.text_reads == (0 if redacted else 1) exporter.close() diff --git a/tests/tracing/test_tracing_env_disable.py b/tests/tracing/test_tracing_env_disable.py index aa2fd93f20..e49b11ea2f 100644 --- a/tests/tracing/test_tracing_env_disable.py +++ b/tests/tracing/test_tracing_env_disable.py @@ -1,5 +1,8 @@ import logging +import pytest + +import agents._debug as _debug from agents.tracing.provider import DefaultTraceProvider from agents.tracing.scope import Scope from agents.tracing.span_data import AgentSpanData @@ -17,6 +20,25 @@ def test_env_read_on_first_use(monkeypatch): assert isinstance(trace, NoOpTrace) +@pytest.mark.parametrize("redacted", [True, False]) +def test_disabled_span_logging_respects_data_policy(monkeypatch, caplog, redacted: bool): + class SensitiveAgentSpanData(AgentSpanData): + def __repr__(self) -> str: + return "SECRET_SPAN_NAME" + + monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "1") + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + provider = DefaultTraceProvider() + + with caplog.at_level(logging.DEBUG, logger="openai.agents"): + span = provider.create_span(SensitiveAgentSpanData(name="agent")) + + assert isinstance(span, NoOpSpan) + assert ("SECRET_SPAN_NAME" not in caplog.text) is redacted + assert "Tracing is disabled. Not creating span" in caplog.text + + def test_env_cached_after_first_use(monkeypatch): """Env flag is cached after the first trace and later env changes do not flip it.""" monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "0") diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index d6f97bb0eb..45db259929 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging from dataclasses import dataclass, field from typing import Any @@ -8,6 +9,7 @@ import numpy.typing as npt import pytest +import agents._debug as _debug from agents import trace from tests.testing_processor import fetch_events, fetch_span_errors @@ -437,10 +439,24 @@ async def run(self, _: str): yield "out_1" +class _FailingWorkflow(FakeWorkflow): + def __init__(self, error: BaseException): + super().__init__() + self.error = error + + async def run(self, _: str): + raise self.error + yield "" # pragma: no cover + + class _OnStartYieldThenFailWorkflow(FakeWorkflow): + def __init__(self, outputs: list[list[str]], error: BaseException | None = None): + super().__init__(outputs) + self.error = error or RuntimeError("boom") + async def on_start(self): yield "intro" - raise RuntimeError("boom") + raise self.error @pytest.mark.asyncio @@ -493,3 +509,140 @@ async def test_voicepipeline_multi_turn_on_start_exception_does_not_abort() -> N assert events[-1] == "session_ended" assert "error" not in events + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted", "redacted"), + [ + (True, False, True), + (False, True, True), + (False, False, False), + ], +) +async def test_voice_on_start_errors_apply_model_and_tool_logging_policies( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + model_redacted: bool, + tool_redacted: bool, + redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + cause = ValueError("SECRET_VOICE_ON_START_TOOL_PAYLOAD") + error = RuntimeError("Voice startup failed") + error.__cause__ = cause + pipeline = VoicePipeline( + workflow=_OnStartYieldThenFailWorkflow([["out_1"]], error), + stt_model=FakeSTT(["first"]), + tts_model=FakeTTS(), + ) + streamed_audio_input = await FakeStreamedAudioInput.get(count=1) + caplog.set_level(logging.WARNING, logger="openai.agents") + + result = await pipeline.run(streamed_audio_input) + events, _ = await extract_events(result) + + assert events[-1] == "session_ended" + records = [ + record + for record in caplog.records + if record.name == "openai.agents" + and ( + record.msg + in { + "Voice workflow on_start failed", + "Voice workflow on_start failed: %s", + } + or ( + isinstance(record.args, tuple) + and record.args + and record.args[0] == "Voice workflow on_start failed" + ) + ) + ] + assert len(records) == 1 + record = records[0] + if redacted: + assert record.msg == "%s" + assert record.args == ("Voice workflow on_start failed",) + assert record.exc_info is None + assert record.exc_text is None + assert error not in record.__dict__.values() + assert cause not in record.__dict__.values() + assert "SECRET_VOICE_ON_START_TOOL_PAYLOAD" not in logging.Formatter().format(record) + else: + assert record.msg == "%s: %s" + assert isinstance(record.args, tuple) + assert error in record.args + assert record.exc_info is not None + assert record.exc_info[1] is error + assert "SECRET_VOICE_ON_START_TOOL_PAYLOAD" in logging.Formatter().format(record) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted", "redacted"), + [ + (True, False, True), + (False, True, True), + (False, False, False), + ], +) +async def test_voice_workflow_errors_apply_model_and_tool_logging_policies( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + streamed: bool, + model_redacted: bool, + tool_redacted: bool, + redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + error = RuntimeError("SECRET_VOICE_TOOL_PAYLOAD") + pipeline = VoicePipeline( + workflow=_FailingWorkflow(error), + stt_model=FakeSTT(["first"]), + tts_model=FakeTTS(), + ) + audio_input = ( + await FakeStreamedAudioInput.get(count=1) + if streamed + else AudioInput(buffer=np.zeros(2, dtype=np.int16)) + ) + caplog.set_level(logging.ERROR, logger="openai.agents") + + result = await pipeline.run(audio_input) + with pytest.raises(RuntimeError, match="SECRET_VOICE_TOOL_PAYLOAD"): + await extract_events(result) + assert result.text_generation_task is not None + assert result.text_generation_task.exception() is error + + expected_pipeline_message = ( + "Error processing voice turns" if streamed else "Error processing single voice turn" + ) + records = [ + record + for record in caplog.records + if record.name == "openai.agents" + and isinstance(record.args, tuple) + and record.args + and record.args[0] in {expected_pipeline_message, "Error processing voice output"} + ] + assert len(records) == 2 + for record in records: + if redacted: + assert record.msg == "%s" + assert record.args in { + (expected_pipeline_message,), + ("Error processing voice output",), + } + assert record.exc_info is None + assert "SECRET_VOICE_TOOL_PAYLOAD" not in logging.Formatter().format(record) + else: + assert record.msg == "%s: %s" + assert isinstance(record.args, tuple) + assert error in record.args + assert record.exc_info is not None + assert record.exc_info[1] is error From 5a74e5104d5168e896cf7a55df65aa09793dd1c0 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 24 Jul 2026 17:01:19 -0500 Subject: [PATCH 011/473] fix: await async callable-object dynamic instructions (#3942) --- src/agents/agent.py | 12 ++++---- src/agents/realtime/agent.py | 15 ++++++---- tests/realtime/test_agent.py | 24 +++++++++++++++ tests/test_agent_config.py | 7 +++++ tests/test_agent_instructions_signature.py | 34 ++++++++++++++++++++++ 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index f5977d9e54..e5b61aaa71 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -1003,11 +1003,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/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/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/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""" From a6d577c99ddcb2fd0972bfad790e3e48d3483adf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 07:26:53 +0900 Subject: [PATCH 012/473] fix: await async callable-object callbacks (#3944) --- src/agents/realtime/handoffs.py | 14 ++++---- src/agents/run_internal/turn_resolution.py | 12 +++---- tests/realtime/test_realtime_handoffs.py | 28 ++++++++++++++++ tests/test_tool_use_behavior.py | 37 ++++++++++++++++++++++ 4 files changed, 75 insertions(+), 16 deletions(-) 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/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 2f54478e32..e55436c295 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/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/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.""" From fc0c8d33f2951f473d775a01289d38389e792a94 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 08:05:35 +0900 Subject: [PATCH 013/473] fix: isolate unit tests from ambient proxy settings (#3945) --- tests/conftest.py | 30 +++++++++++++++++++++++++++-- tests/test_test_environment.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/test_test_environment.py 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/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" From e16ba7ea8a41c2db9a125de221295003c454ed75 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 09:31:28 +0900 Subject: [PATCH 014/473] chore: update AGENTS.md and code change/reiew skill details --- .../skills/implementation-strategy/SKILL.md | 37 ++++++++++++++----- AGENTS.md | 13 ++++++- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index a18cf574f2..221c23d5ed 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -1,25 +1,34 @@ --- name: implementation-strategy -description: Decide how to implement runtime and API changes in openai-agents-python before editing code. Use when a task changes exported APIs, runtime behavior, serialized state, tests, or docs and you need to choose the compatibility boundary, whether shims or migrations are warranted, and when unreleased interfaces can be rewritten directly. +description: Decide how to implement or review runtime and API changes in openai-agents-python. Use when a task changes or reviews exported APIs, runtime behavior, serialized state, tests, or docs and you need to choose the compatibility boundary, the smallest coherent implementation, whether shims or migrations are warranted, and when unreleased interfaces can be rewritten directly. --- # Implementation Strategy ## Overview -Use this skill before editing code when the task changes runtime behavior or anything that might look like a compatibility concern. The goal is to keep implementations simple while protecting real released contracts. +Use this skill before editing or reviewing code when the task changes runtime behavior or anything that might look like a compatibility concern. The goal is to keep implementations and review requests focused while protecting real released contracts. ## Quick start -1. Identify the surface you are changing: released public API, unreleased branch-local API, internal helper, persisted schema, wire protocol, CLI/config/env surface, or docs/examples only. -2. Determine the latest release boundary from `origin` first, and only fall back to local tags when remote tags are unavailable: +1. Identify the surface you are changing or reviewing: released public API, unreleased branch-local API, internal helper, persisted schema, wire protocol, CLI/config/env surface, or docs/examples only. +2. Define the concrete required outcome, supported behavior that must remain, and work that is outside the current task. +3. Determine the latest release boundary from `origin` first, and only fall back to local tags when remote tags are unavailable: ```bash BASE_TAG="$(.agents/skills/final-release-review/scripts/find_latest_release_tag.sh origin 'v*' 2>/dev/null || git tag -l 'v*' --sort=-v:refname | head -n1)" echo "$BASE_TAG" ``` -3. Judge breaking-change risk against that latest release tag, not against unreleased branch churn or post-tag changes already on `main`. If the command fell back to local tags, treat the result as potentially stale and say so. -4. Prefer the simplest implementation that satisfies the current task. Update callers, tests, docs, and examples directly instead of preserving superseded unreleased interfaces. -5. Add a compatibility layer only when there is a concrete released consumer, an otherwise supported durable external state boundary that requires it, or when the user explicitly asks for a migration path. +4. Judge breaking-change risk against that latest release tag, not against unreleased branch churn or post-tag changes already on `main`. If the command fell back to local tags, treat the result as potentially stale and say so. +5. Apply the scope and simplicity rules below to choose the implementation or review recommendation. +6. Add a compatibility layer only when there is a concrete released consumer, an otherwise supported durable external state boundary that requires it, or when the user explicitly asks for a migration path. + +## Scope and simplicity rules + +- Make the smallest coherent change that fully satisfies the current task and preserves required supported behavior. +- Prefer existing patterns and direct implementations. Add a new abstraction, general-purpose helper, configuration knob, dependency, compatibility layer, feature flag, or parallel code path only when a concrete current requirement or supported contract needs it. +- Trace only the code paths being changed and the contracts they rely on. Expand the investigation or implementation only when concrete evidence or validation exposes another required path. +- Keep root-cause fixes within the requested boundary. Leave unrelated refactors, cleanup, feature work, and pre-existing failures out of the patch; report them separately when they materially affect the result. +- Add focused tests for the required behavior and realistic regression paths. Do not generalize production code or test infrastructure for hypothetical future cases without evidence. ## Compatibility boundary rules @@ -33,11 +42,18 @@ Use this skill before editing code when the task changes runtime behavior or any ## Default implementation stance -- Prefer deletion or replacement over aliases, overloads, shims, feature flags, and dual-write logic when the old shape is unreleased. -- Do not preserve a confusing abstraction just because it exists in the current branch diff. +- Prefer deletion or direct replacement over aliases, overloads, shims, feature flags, and dual-write logic when the old shape is unreleased. - If review feedback claims a change is breaking, verify it against the latest release tag and actual external impact before accepting the feedback. - If a change truly crosses the latest released contract boundary, call that out explicitly in the ExecPlan, release notes context, and user-facing summary. +## Applying this skill during review + +- Establish the requested outcome and compatibility boundary before judging whether the implementation is too narrow or too broad. +- Treat complexity as an actionable finding only when specific added machinery is not needed by the current task, a released contract, supported durable state, or a verified runtime or platform risk. Name that machinery and recommend the smallest safe removal or replacement. +- Do not request abstractions, configuration, dependencies, compatibility work, or extensibility for hypothetical future consumers. +- Keep unrelated cleanup and pre-existing problems out of blocking findings. Report them separately only when they are useful to the maintainer. +- Require a broader refactor only when concrete evidence shows that the focused change would otherwise be incorrect, unsafe, incompatible, or materially harder to maintain. + ## SDK-specific decision rules - When unsupported OpenAI API or provider-adapter behavior already has a released default path, avoid turning it into a default hard error unless the latest release boundary justifies that break. Prefer an opt-in strict mode such as `strict_feature_validation=True`, while keeping the default path compatible through warning, ignoring unsupported data, or a clearly non-empty placeholder. @@ -53,6 +69,7 @@ Use this skill before editing code when the task changes runtime behavior or any - The change would alter behavior shipped in the latest release tag. - The change would modify durable external data, protocol formats, or serialized state. +- The correct solution would materially expand beyond the requested outcome or require unrelated architectural work. - The user explicitly asked for backward compatibility, deprecation, or migration support. ## Output expectations @@ -61,3 +78,5 @@ When this skill materially affects the implementation approach, state the decisi - `Compatibility boundary: latest release tag v0.x.y; branch-local interface rewrite, no shim needed.` - `Compatibility boundary: released RunState schema; preserve compatibility and add migration coverage.` +- `Scope decision: direct change using existing patterns; no new abstraction or adjacent cleanup needed.` +- `Review decision: the added compatibility path has no released or supported consumer; replace it with the direct implementation.` diff --git a/AGENTS.md b/AGENTS.md index 07248efa9b..5da3d17f8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This guide helps new contributors get started with the OpenAI Agents Python repo 1. [Policies & Mandatory Rules](#policies--mandatory-rules) 2. [Project Structure Guide](#project-structure-guide) 3. [Operation Guide](#operation-guide) +4. [Code Review Rules](#code-review-rules) ## Policies & Mandatory Rules @@ -32,7 +33,7 @@ When working on OpenAI API or OpenAI platform integrations in this repo (Respons #### `$implementation-strategy` -Before changing runtime code, exported APIs, external configuration, persisted schemas, wire protocols, or other user-facing behavior, use `$implementation-strategy` to decide the compatibility boundary and implementation shape. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. +Before changing or reviewing runtime code, exported APIs, external configuration, persisted schemas, wire protocols, or other user-facing behavior, use `$implementation-strategy` to decide the compatibility boundary and implementation shape. During review, use it before requesting compatibility layers, migrations, new abstractions, or broader refactors. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. #### `$pr-draft-summary` @@ -234,7 +235,15 @@ make tests - Run `make format`, `make lint`, `make typecheck`, and `make tests` before marking work ready. - Commit messages should be concise and written in the imperative mood. Small, focused commits are preferred. -### Review Process & What Reviewers Look For +## Code Review Rules + +- Use `$implementation-strategy` to establish the requested outcome and latest released compatibility boundary before judging implementation scope or architecture. +- Treat added complexity as an actionable finding only when specific machinery is not required by the task, a released contract, supported durable state, or a verified runtime or platform risk. Identify the unnecessary machinery and recommend the smallest safe removal or direct replacement. +- Do not request speculative abstractions, general-purpose helpers, configuration knobs, dependencies, compatibility layers, feature flags, parallel code paths, or extensibility for hypothetical future consumers. +- Keep findings scoped to the patch. Do not block on unrelated cleanup, pre-existing bugs, or optional refactors; report them separately when useful. +- Require a broader refactor only when concrete evidence shows the focused change would otherwise be incorrect, unsafe, incompatible, or materially harder to maintain. + +### Baseline review expectations - ✅ Checks pass (`make format`, `make lint`, `make typecheck`, `make tests`). - ✅ Tests cover new behavior and edge cases. From 14ad7e1c62446cc809144e9d8b30b4b047aa9fdb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 09:47:04 +0900 Subject: [PATCH 015/473] fix: redact Realtime and RunState diagnostics (#3948) --- src/agents/logger.py | 22 +++ src/agents/realtime/openai_realtime.py | 35 ++--- src/agents/run_state.py | 50 +++++-- tests/realtime/test_openai_realtime.py | 137 ++++++++++++++---- tests/test_error_logging_redaction.py | 190 +++++++++++++++++++++++++ 5 files changed, 374 insertions(+), 60 deletions(-) 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/openai_realtime.py b/src/agents/realtime/openai_realtime.py index f36aafffa4..141606246a 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_state.py b/src/agents/run_state.py index ef9cdc6c76..b5bc887297 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, @@ -3556,6 +3560,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]: @@ -3591,9 +3607,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"] @@ -3682,11 +3708,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 @@ -3753,7 +3782,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/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/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", [ From d9d623098e879baf904e869416486f7cc2b93ac8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 10:20:51 +0900 Subject: [PATCH 016/473] fix: reject unsupported streamed STT audio dtypes (#3950) --- src/agents/voice/models/openai_stt.py | 4 +++- tests/voice/test_openai_stt.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) 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/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", From f45d6e2610105934ecb00dc38ee721a67258fa82 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 12:12:15 +0900 Subject: [PATCH 017/473] chore: update final-release-review skill details --- .agents/skills/final-release-review/SKILL.md | 37 +++++++++---- .../references/review-checklist.md | 53 +++++++++++++++++-- 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/.agents/skills/final-release-review/SKILL.md b/.agents/skills/final-release-review/SKILL.md index d2c546f971..5836308bc0 100644 --- a/.agents/skills/final-release-review/SKILL.md +++ b/.agents/skills/final-release-review/SKILL.md @@ -26,8 +26,9 @@ The review must be stable and actionable: avoid variance between runs by using e git log --oneline --reverse "${BASE_TAG}".."${TARGET}" git diff --name-status "${BASE_TAG}"..."${TARGET}" ``` -5. Deep review using `references/review-checklist.md` to spot breaking changes, regressions, and improvement chances. -6. Capture findings and call the release gate: ship/block with conditions; propose focused tests for risky areas. +5. Use the broad signals in `references/review-checklist.md` to find breaking-change, regression, and release-polish candidates. +6. Prove or dismiss each candidate with a BASE-versus-TARGET contract comparison and the owning SDK invariant from `.agents/references/README.md`. +7. Report only actionable findings and call the release gate: ship/block with concrete conditions. ## Deterministic gate policy @@ -42,7 +43,7 @@ The review must be stable and actionable: avoid variance between runs by using e - Large diff size, broad refactor, or many touched files. - "Could regress" risk statements without concrete evidence. - Not running tests locally. -- If evidence is incomplete, issue **🟢 GREEN LIGHT TO SHIP** with targeted validation follow-ups instead of `BLOCKED`. +- If evidence is incomplete, do not block. Report a validation action only when the diff establishes a concrete unresolved risk; otherwise omit the candidate. ## Workflow @@ -52,28 +53,42 @@ The review must be stable and actionable: avoid variance between runs by using e - Keep the working tree clean to avoid diff noise. - **Assumptions** - Assume the target commit (default `origin/main` tip) has already passed `$code-change-verification` in CI unless the user says otherwise. - - Do not block a release solely because you did not run tests locally; focus on concrete behavioral or API risks. + - Treat repository unit tests, lint, formatting, type checking, and coverage as CI evidence, not as the release audit. Do not rerun them by default. + - Do not block a release solely because you did not rerun CI checks locally; focus on concrete behavioral, compatibility, packaging, or API risks. - Release policy: routine releases use patch versions; use minor only for breaking changes or major feature additions. Major versions are reserved until the 1.0 release. - **Map the diff** - Use `--stat`, `--dirstat`, and `--name-status` outputs to spot hot directories and file types. - For suspicious files, prefer `git diff --word-diff BASE...TARGET -- `. - Note any deleted or newly added tests, config, migrations, or scripts. -- **Analyze risk** - - Walk through the categories in `references/review-checklist.md` (breaking changes, regression clues, improvement opportunities). - - When you suspect a risk, cite the specific file/commit and explain the behavioral impact. +- **Discover candidates** + - Walk through all categories in `references/review-checklist.md` (breaking changes, regression clues, improvement opportunities). Keep this broad scan so refactors, error handling, concurrency, dependencies, docs drift, and missing coverage remain visible. + - Read changed tests to understand the intended behavior, exercised branches, and missing invariants. A changed or missing test is a clue, not a finding by itself. +- **Audit contract deltas** + - Compare BASE and TARGET rather than reviewing TARGET in isolation. + - For public APIs, compare exports, import identity, signatures, constructor and dataclass field order, defaults, enums, and documented behavior. + - For package metadata, compare supported Python versions, dependencies, optional extras, distribution contents, and import behavior from the built artifacts. + - For persisted state, schemas, protocols, config, and environment variables, identify the released durable boundary and verify backward-read or migration behavior where required. + - Route each changed runtime area through the owning reference in `.agents/references/README.md`. Trace the affected value, state, item, or side effect across all required downstream surfaces instead of stopping at the edited function. + - Check only the relevant symmetry and failure axes: streaming/non-streaming, sync/async, fresh/resumed, client/server-managed state, success/error/cancellation, sequential/concurrent, and normal/repeated cleanup. +- **Prove findings** + - Promote a candidate to a finding only when the diff shows a concrete contract violation, a reachable supported-path regression, or a release-polish gap with user impact. + - If static evidence cannot resolve a concrete semantic question, use the smallest public-path or installed-artifact probe that can. Prefer the same scenario against BASE and TARGET so environment failures and pre-existing behavior are separated from regressions. + - Do not run repository unit-test slices merely to accumulate passing evidence. Run a focused test only when reproducing a specific failure or when no more direct contract, artifact, or runtime probe is available. + - When you confirm a risk, cite the specific file/commit and explain the behavioral impact. - For every finding, include all of: `Evidence`, `Impact`, and `Action`. - Severity calibration: - **🟢 LOW**: low blast radius or clearly covered behavior; no release gate impact. - **🟡 MODERATE**: plausible user-facing regression signal; needs validation but not a confirmed blocker. - **🔴 HIGH**: confirmed or strongly evidenced release-blocking issue. - - Suggest minimal, high-signal validation commands (targeted tests or linters) instead of generic reruns when time is tight. + - Attach a validation action only to a concrete unresolved risk. Give the smallest command or task and a pass condition; do not add generic follow-up checks. - Breaking changes do not automatically require a BLOCKED release call when they are already covered by an appropriate version bump and migration/upgrade notes; only block when the bump is missing/mismatched (e.g., patch bump) or when the breaking change introduces unresolved risk. - **Form a recommendation** - State BASE_TAG and TARGET explicitly. - Provide a concise diff summary (key directories/files and counts). - - List: breaking-change candidates, probable regressions/bugs, improvement opportunities, missing release notes/migrations. + - List only substantiated breaking changes, regressions/bugs, improvement opportunities, and missing release notes/migrations. Do not turn every audit clue into a report item. - Recommend ship/block and the exact checks needed to unblock if blocking. If a breaking change is properly versioned (minor/major), you may still recommend a GREEN LIGHT TO SHIP while calling out the change. Use emoji and boldface in the release call to make the gate obvious. - If you cannot provide a concrete unblock checklist item, do not use `BLOCKED`. + - Do not include routine command results, pass counts, skips, deselections, or a validation-status inventory. Mention a validation limitation only when it materially changes a specific finding or the release call. ## Output format (required) @@ -114,10 +129,10 @@ https://github.com/openai/openai-agents-python/compare/... 2. ... ### Notes: -- +- ``` -If no risks are found, include a "No material risks identified" line under Risk assessment and still provide a ship call. If you did not run local verification, do not add a verification status section or use it as a release blocker; note any assumptions briefly in Notes. If the report is not blocked, omit the `Unblock checklist` section. +If no risks are found, include a "No material risks identified" line under Risk assessment and still provide a ship call. Do not add a verification-status section or report routine check results. If the report is not blocked, omit the `Unblock checklist` section. ### Resources diff --git a/.agents/skills/final-release-review/references/review-checklist.md b/.agents/skills/final-release-review/references/review-checklist.md index 3cd5d4d2a6..76b2e2bbf0 100644 --- a/.agents/skills/final-release-review/references/review-checklist.md +++ b/.agents/skills/final-release-review/references/review-checklist.md @@ -20,7 +20,7 @@ - Large refactor or high file count. - Speculative risk without evidence. - Not running tests locally. -- If uncertain, keep gate green and provide focused follow-up checks. +- If uncertain, keep the gate green. Add a focused follow-up only when it resolves a concrete risk already identified in the diff. ## Actionability contract @@ -28,8 +28,52 @@ - `Evidence`: specific file/commit/diff/test signal. - `Impact`: one-sentence user or runtime effect. - `Action`: concrete command/task with pass criteria. +- A candidate becomes a finding only when it has a concrete contract violation, a reachable supported path, or a release-polish gap with user impact. +- Changed tests, missing tests, diff size, and risky patterns are discovery signals; they are not findings without contract or runtime evidence. - A `BLOCKED` report must contain an `Unblock checklist` with at least one executable item. -- If no executable unblock item exists, do not block; downgrade to green with follow-up checks. +- If no executable unblock item exists, do not block. Keep the gate green and include an action only for a concrete unresolved risk. + +## Two-stage audit + +### Stage 1: broad discovery + +Use all of the existing breaking-change, regression, dependency, documentation, and improvement signals below. The goal is high recall: collect plausible candidates without prematurely reporting them. + +Read changed tests as behavioral documentation. Identify the intended outcome, covered branches, deleted assertions, new skips, and missing failure paths, but do not rerun repository unit tests merely to accumulate passing evidence. + +### Stage 2: contract and invariant proof + +For each candidate: + +1. Compare the released BASE behavior or contract with TARGET. Do not infer compatibility from TARGET alone. +2. Identify the owning SDK boundary using `.agents/references/README.md`. +3. Trace the changed value, state, item, identity, or side effect across every downstream consumer required by that boundary. +4. Check the relevant paired paths and failure modes. +5. Promote the candidate to a finding only when this trace establishes concrete impact. + +Use these contract comparisons when relevant: + +| Changed surface | BASE-versus-TARGET audit | +|---|---| +| Public API | Exports, import identity, signatures, positional parameter order, dataclass field order, defaults, enums, and documented behavior | +| Runner and run items | Provider output, result items, semantic stream events, session history, replay, handoffs, and `RunState` | +| Tool execution | Planning, approvals, guardrails, invocation, hooks, output conversion, persistence, cancellation, and cleanup | +| Conversation and sessions | First turn, follow-up, retry, filtering, handoff, compaction, interruption, and resume | +| Model and provider adapters | Model/settings resolution, request conversion, streaming terminals, provider data, errors, retries, and transport ownership | +| Persisted schemas and config | Serialized shape, version support, backward reads, migrations, defaults, environment variables, and wire compatibility | +| Package boundary | Supported Python versions, dependencies, extras, distribution contents, public imports, and built wheel/sdist behavior | + +Select only the axes implicated by the diff: + +- streaming versus non-streaming; +- sync versus async; +- fresh execution versus serialized resume; +- client-managed versus server-managed state; +- success, exception, and cancellation; +- sequential versus concurrent execution; +- normal, partial-failure, and repeated cleanup. + +If static inspection cannot resolve a concrete semantic question, run the smallest public-path or installed-artifact probe that can. Prefer an identical BASE and TARGET scenario. A focused unit test is a fallback for reproducing a specific failure, not the default release validation. ## Breaking change signals @@ -59,7 +103,8 @@ - BASE tag and TARGET ref used for the diff; confirm tags fetched. - High-level diff stats and key directories touched. -- Concrete files/commits that indicate breaking changes or risk, with brief rationale. -- Tests or commands suggested to validate suspected risks (include pass criteria). +- Only concrete, actionable findings with evidence, impact, affected files, and action. +- A validation command or task only when it resolves a specific finding; include its pass criteria. - Explicit release gate call (ship/block) with conditions to unblock. - `Unblock checklist` section when (and only when) gate is `BLOCKED`. +- Do not report routine command results, pass counts, skips, deselections, or a validation-status inventory. From c549a825ae1df3b0b3c7c87b396eda6c0ea713b4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 25 Jul 2026 13:31:27 +0900 Subject: [PATCH 018/473] chore: update final-release-review skill details --- .agents/skills/final-release-review/SKILL.md | 18 ++++++++++++------ .../references/review-checklist.md | 12 +++++++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.agents/skills/final-release-review/SKILL.md b/.agents/skills/final-release-review/SKILL.md index 5836308bc0..ced1118919 100644 --- a/.agents/skills/final-release-review/SKILL.md +++ b/.agents/skills/final-release-review/SKILL.md @@ -44,6 +44,7 @@ The review must be stable and actionable: avoid variance between runs by using e - "Could regress" risk statements without concrete evidence. - Not running tests locally. - If evidence is incomplete, do not block. Report a validation action only when the diff establishes a concrete unresolved risk; otherwise omit the candidate. +- A green gate must still explain the important release surfaces that were audited. Do not collapse a behavior-impacting release into a bare "No material risks identified" result. ## Workflow @@ -72,6 +73,9 @@ The review must be stable and actionable: avoid variance between runs by using e - Check only the relevant symmetry and failure axes: streaming/non-streaming, sync/async, fresh/resumed, client/server-managed state, success/error/cancellation, sequential/concurrent, and normal/repeated cleanup. - **Prove findings** - Promote a candidate to a finding only when the diff shows a concrete contract violation, a reachable supported-path regression, or a release-polish gap with user impact. + - Also retain substantiated non-blocking release considerations when they explain an intentional default change, public API or package expansion, durable schema transition, trace/logging behavior change, or other user-visible contract that is safe but important for release consumers to understand. + - For a green gate, report at least one such consideration whenever the diff changes runtime behavior, public APIs, package support, persisted schemas, protocols, configuration defaults, observability, or documented user workflows. Normally report two to five, grouped by contract rather than by directory. + - Assign **🟢 LOW** to a verified, correctly versioned, non-blocking consideration. Use neutral titles that describe the contract change; do not imply that a safe intentional change is a defect. - If static evidence cannot resolve a concrete semantic question, use the smallest public-path or installed-artifact probe that can. Prefer the same scenario against BASE and TARGET so environment failures and pre-existing behavior are separated from regressions. - Do not run repository unit-test slices merely to accumulate passing evidence. Run a focused test only when reproducing a specific failure or when no more direct contract, artifact, or runtime probe is available. - When you confirm a risk, cite the specific file/commit and explain the behavioral impact. @@ -80,12 +84,12 @@ The review must be stable and actionable: avoid variance between runs by using e - **🟢 LOW**: low blast radius or clearly covered behavior; no release gate impact. - **🟡 MODERATE**: plausible user-facing regression signal; needs validation but not a confirmed blocker. - **🔴 HIGH**: confirmed or strongly evidenced release-blocking issue. - - Attach a validation action only to a concrete unresolved risk. Give the smallest command or task and a pass condition; do not add generic follow-up checks. + - Every reported item needs a concrete next step and pass condition. For an unresolved risk, give the smallest validation or fix. For a verified LOW consideration, use a release-handoff task such as preserving exact migration, opt-out, compatibility, or supported-version wording in generated release notes. Do not invent additional code or test work merely to populate the report. - Breaking changes do not automatically require a BLOCKED release call when they are already covered by an appropriate version bump and migration/upgrade notes; only block when the bump is missing/mismatched (e.g., patch bump) or when the breaking change introduces unresolved risk. - **Form a recommendation** - State BASE_TAG and TARGET explicitly. - Provide a concise diff summary (key directories/files and counts). - - List only substantiated breaking changes, regressions/bugs, improvement opportunities, and missing release notes/migrations. Do not turn every audit clue into a report item. + - List substantiated breaking changes, regressions/bugs, improvement opportunities, missing release notes/migrations, and the most important verified non-blocking contract changes. Do not turn every audit clue or touched directory into a report item. - Recommend ship/block and the exact checks needed to unblock if blocking. If a breaking change is properly versioned (minor/major), you may still recommend a GREEN LIGHT TO SHIP while calling out the change. Use emoji and boldface in the release call to make the gate obvious. - If you cannot provide a concrete unblock checklist item, do not use `BLOCKED`. - Do not include routine command results, pass counts, skips, deselections, or a validation-status inventory. Mention a validation limitation only when it materially changes a specific finding or the release call. @@ -98,7 +102,7 @@ Use the following report structure in every response produced by this skill. Be Always use the fixed repository URL in the Diff section (`https://github.com/openai/openai-agents-python/compare/...`). Do not use `${GITHUB_REPOSITORY}` or any other template variable. Format risk levels as bold emoji labels: **🟢 LOW**, **🟡 MODERATE**, **🔴 HIGH**. -Every risk finding must contain an actionable next step. If the report uses `**🔴 BLOCKED**`, include an `Unblock checklist` section with at least one concrete command/task and a pass condition. +Every Risk assessment item must contain an actionable next step. If the report uses `**🔴 BLOCKED**`, include an `Unblock checklist` section with at least one concrete command/task and a pass condition. ``` ### Release readiness review ( -> TARGET ) @@ -116,11 +120,11 @@ https://github.com/openai/openai-agents-python/compare/... - ### Risk assessment (ordered by impact): -1) **** +1) **** - Risk: **<🟢 LOW | 🟡 MODERATE | 🔴 HIGH>**. - Evidence: - Files: - - Action: + - Action: 2) ... ### Unblock checklist (required when Release call is BLOCKED): @@ -132,7 +136,9 @@ https://github.com/openai/openai-agents-python/compare/... - ``` -If no risks are found, include a "No material risks identified" line under Risk assessment and still provide a ship call. Do not add a verification-status section or report routine check results. If the report is not blocked, omit the `Unblock checklist` section. +For a green gate, the Risk assessment must still itemize the important verified release considerations as **🟢 LOW** when the diff has behavior, API, package, schema, protocol, configuration, observability, or user-workflow impact. Do not use "No material risks identified" as the sole Risk assessment for such a release. That fallback is allowed only when the diff has no reportable contract or user-facing surface, such as a metadata-only release. Do not add a verification-status section or report routine check results. If the report is not blocked, omit the `Unblock checklist` section. + +Typical green items include a correctly versioned default change with its exact opt-in or opt-out path, a durable schema bump with backward-read behavior, an optional-extra or supported-version expansion that retains compatibility, or a tracing change with an explicit opt-out. Keep each item tied to consumer impact and a release-handoff pass condition. ### Resources diff --git a/.agents/skills/final-release-review/references/review-checklist.md b/.agents/skills/final-release-review/references/review-checklist.md index 76b2e2bbf0..8368fd6e1c 100644 --- a/.agents/skills/final-release-review/references/review-checklist.md +++ b/.agents/skills/final-release-review/references/review-checklist.md @@ -21,17 +21,22 @@ - Speculative risk without evidence. - Not running tests locally. - If uncertain, keep the gate green. Add a focused follow-up only when it resolves a concrete risk already identified in the diff. +- A green gate is not an empty audit. Itemize the most important verified release considerations when the diff changes behavior, APIs, packages, schemas, defaults, observability, or user workflows. ## Actionability contract -- Every risk finding should include: +- Every risk finding or non-blocking release consideration should include: - `Evidence`: specific file/commit/diff/test signal. - `Impact`: one-sentence user or runtime effect. - `Action`: concrete command/task with pass criteria. - A candidate becomes a finding only when it has a concrete contract violation, a reachable supported path, or a release-polish gap with user impact. +- A verified intentional change may become a **🟢 LOW** release consideration when it defines a contract users must understand, such as a default flip, new trace behavior, public API expansion, supported-version widening, or durable schema transition. +- For a green gate with behavior or contract impact, include at least one consideration and normally two to five. Group related changes by consumer impact rather than listing files or commits individually. +- For a resolved LOW consideration, the action may be a release-handoff check: retain exact compatibility, migration, opt-out, or configuration wording in generated release notes and state the pass condition. - Changed tests, missing tests, diff size, and risky patterns are discovery signals; they are not findings without contract or runtime evidence. - A `BLOCKED` report must contain an `Unblock checklist` with at least one executable item. -- If no executable unblock item exists, do not block. Keep the gate green and include an action only for a concrete unresolved risk. +- If no executable unblock item exists, do not block. Keep the gate green; use validation or fix actions for unresolved risks and release-handoff checks for resolved LOW considerations. +- Do not use "No material risks identified" as the sole Risk assessment when the diff has reportable behavior or contract changes. Reserve it for metadata-only or otherwise non-reportable release diffs. ## Two-stage audit @@ -103,8 +108,9 @@ If static inspection cannot resolve a concrete semantic question, run the smalle - BASE tag and TARGET ref used for the diff; confirm tags fetched. - High-level diff stats and key directories touched. -- Only concrete, actionable findings with evidence, impact, affected files, and action. +- Concrete, actionable findings plus the most important verified non-blocking release considerations, each with evidence, impact, affected files, and action. - A validation command or task only when it resolves a specific finding; include its pass criteria. +- For a resolved LOW consideration, a precise generated-release-note or migration-wording check with a pass condition is sufficient; do not manufacture code changes or redundant tests. - Explicit release gate call (ship/block) with conditions to unblock. - `Unblock checklist` section when (and only when) gate is `BLOCKED`. - Do not report routine command results, pass counts, skips, deselections, or a validation-status inventory. From c1b423749e2bf8ca5f89cad13e2a144c9683a6ee Mon Sep 17 00:00:00 2001 From: TheSaiEaranti Date: Sat, 25 Jul 2026 00:20:22 -0500 Subject: [PATCH 019/473] fix: include device nodes when parsing ls output (#3951) --- src/agents/sandbox/util/parse_utils.py | 25 ++++++++++++--- tests/sandbox/test_parse_utils.py | 44 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) 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/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", [ From 6eb779d9397085ab61358dc5b3a47436c9419d54 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 26 Jul 2026 08:06:43 +0900 Subject: [PATCH 020/473] fix: support async callable objects as function tools (#3949) --- src/agents/_tool_identity.py | 13 + src/agents/tool.py | 154 ++++++- tests/test_function_tool.py | 49 +++ tests/test_function_tool_decorator.py | 536 +++++++++++++++++++++++- tests/test_programmatic_tool_calling.py | 50 +++ 5 files changed, 796 insertions(+), 6 deletions(-) diff --git a/src/agents/_tool_identity.py b/src/agents/_tool_identity.py index af41093ff2..1dae29a9fe 100644 --- a/src/agents/_tool_identity.py +++ b/src/agents/_tool_identity.py @@ -18,6 +18,19 @@ NamedToolLookupKey = FunctionToolLookupKey | str +def validate_function_tool_fallback_name(name: str) -> str: + """Return an API-safe generated tool name or require an explicit override.""" + if 1 <= len(name) <= 64 and all( + char.isascii() and (char.isalnum() or char in {"_", "-"}) for char in name + ): + return name + raise UserError( + f"Cannot derive a function tool name from callable class {name!r}. Generated names must " + "contain only ASCII letters, digits, underscores, or hyphens and be at most 64 " + "characters. Pass name_override to function_tool()." + ) + + class SerializedFunctionToolLookupKey(TypedDict, total=False): """Serialized representation of a function-tool lookup key.""" diff --git a/src/agents/tool.py b/src/agents/tool.py index eb6c0a3645..0e96e4fee8 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -4,14 +4,16 @@ import asyncio import copy import dataclasses +import functools import inspect import json import math +import typing import weakref from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field from enum import Enum -from types import UnionType +from types import FunctionType, UnionType from typing import ( TYPE_CHECKING, Annotated, @@ -40,20 +42,21 @@ from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters from openai.types.responses.web_search_tool_param import UserLocation from pydantic import BaseModel, TypeAdapter, ValidationError, model_validator -from typing_extensions import NotRequired, ParamSpec, TypedDict +from typing_extensions import NotRequired, ParamSpec, Self, TypeAliasType, TypedDict from . import _debug from ._config_coercion import coerce_pydantic_config from ._tool_identity import ( get_explicit_function_tool_namespace, tool_qualified_name, + validate_function_tool_fallback_name, validate_function_tool_lookup_configuration, validate_function_tool_namespace_shape, ) from .computer import AsyncComputer, Computer from .editor import ApplyPatchEditor, ApplyPatchOperation from .exceptions import ModelBehaviorError, ToolTimeoutError, UserError -from .function_schema import DocstringStyle, function_schema +from .function_schema import DocstringStyle, function_schema, generate_func_documentation from .logger import log_tool_action_warning, logger from .run_context import RunContextWrapper from .strict_schema import ensure_strict_json_schema @@ -2196,6 +2199,143 @@ def _validate_function_tool_output( ) from error +def _normalize_function_tool_callable( + func: ToolFunction[...], + docstring_style: DocstringStyle | None, + name_override: str | None, +) -> tuple[ToolFunction[...], str | None]: + """Adapt one plain callable instance to the existing function-tool pipeline.""" + if isinstance(func, functools.partial): + raise UserError( + "Unsupported callable object: function_tool does not infer functools.partial " + "contracts. Use an explicit wrapper function." + ) + if inspect.isroutine(func) or inspect.isclass(func): + return func, None + + try: + instance_vars = vars(func) + except TypeError: + instance_vars = {} + missing = object() + if ( + inspect.getattr_static(func, "__wrapped__", missing) is not missing + or inspect.getattr_static(func, "__signature__", missing) is not missing + or "__annotations__" in instance_vars + or "__annotate__" in instance_vars + ): + raise UserError( + "Unsupported callable wrapper: function_tool only infers plain callable instances. " + "Use an explicit wrapper function." + ) + + call_owner = next( + (owner for owner in type(func).__mro__ if "__call__" in owner.__dict__), + None, + ) + if call_owner is None: + raise UserError("Unsupported callable object: no inspectable __call__ method was found.") + call_descriptor = call_owner.__dict__["__call__"] + if ( + not isinstance(call_descriptor, FunctionType) + or hasattr(call_descriptor, "__wrapped__") + or hasattr(call_descriptor, "__signature__") + ): + raise UserError( + "Unsupported callable object: function_tool supports instances with a plain " + "__call__ method. Use an explicit wrapper function for partials, decorated methods, " + "built-in callables, or custom descriptors." + ) + if getattr(call_owner, "__type_params__", ()) or getattr(call_owner, "__parameters__", ()): + raise UserError( + "Unsupported generic callable object: use an explicit wrapper function with concrete " + "parameter and return annotations." + ) + + call_method = cast(Callable[..., Any], call_descriptor.__get__(func, type(func))) + signature = inspect.signature(call_method) + globalns = dict(getattr(call_method, "__globals__", {})) + localns = dict(vars(call_owner)) + localns[call_owner.__name__] = call_owner + try: + type_hints = get_type_hints( + call_method, + globalns=globalns, + localns=localns, + include_extras=True, + ) + except (NameError, TypeError) as error: + raise UserError( + "Unsupported callable object annotations: use an explicit wrapper function with " + "annotations resolvable from its module." + ) from error + + native_self = getattr(typing, "Self", Self) + native_alias_type = getattr(typing, "TypeAliasType", TypeAliasType) + alias_types = (TypeAliasType, native_alias_type) + + def contains_specialized_annotation(annotation: Any) -> bool: + origin = get_origin(annotation) + if ( + isinstance(annotation, (TypeVar, *alias_types)) + or isinstance(origin, alias_types) + or annotation in (Self, native_self) + ): + return True + return any(contains_specialized_annotation(arg) for arg in get_args(annotation)) + + if any(contains_specialized_annotation(annotation) for annotation in type_hints.values()): + raise UserError( + "Unsupported generic or aliased callable object annotations: use an explicit wrapper " + "function with concrete parameter and return annotations." + ) + for name, parameter in signature.parameters.items(): + annotation = type_hints.get(name, parameter.annotation) + if annotation is inspect.Signature.empty: + continue + plain_annotation = _unwrap_annotated_type(annotation) + origin = get_origin(plain_annotation) or plain_annotation + if origin is RunContextWrapper or origin is ToolContext: + raise UserError( + "Unsupported callable object context parameter: use an explicit wrapper function " + "to receive RunContextWrapper or ToolContext." + ) + + if inspect.iscoroutinefunction(call_method): + + async def async_adapter(*args: Any, **kwargs: Any) -> Any: + return await call_method(*args, **kwargs) + + adapter: Callable[..., Any] = async_adapter + else: + + def sync_adapter(*args: Any, **kwargs: Any) -> Any: + return call_method(*args, **kwargs) + + adapter = sync_adapter + + adapter_metadata = cast(Any, adapter) + fallback_name = type(func).__name__ + adapter_metadata.__name__ = ( + fallback_name if name_override else validate_function_tool_fallback_name(fallback_name) + ) + class_doc = inspect.getdoc(type(func)) + call_doc = inspect.cleandoc(call_descriptor.__doc__) if call_descriptor.__doc__ else None + adapter_metadata.__doc__ = call_doc or class_doc + adapter_metadata.__annotations__ = { + name: annotation + for name, annotation in type_hints.items() + if name == "return" or name in signature.parameters + } + adapter_metadata.__signature__ = signature + class_description = ( + generate_func_documentation(type(func), docstring_style).description + if class_doc and call_doc + else None + ) + return cast("ToolFunction[...]", adapter), class_description + + @overload def function_tool( func: ToolFunction[...], @@ -2330,11 +2470,17 @@ def function_tool( """ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: + the_func, callable_description = _normalize_function_tool_callable( + the_func, + docstring_style, + name_override, + ) is_sync_function_tool = not inspect.iscoroutinefunction(the_func) schema = function_schema( func=the_func, name_override=name_override, - description_override=description_override, + description_override=description_override + or (callable_description if use_docstring_info else None), docstring_style=docstring_style, use_docstring_info=use_docstring_info, strict_json_schema=strict_mode, diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index 60ae2558cc..496d808b17 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -844,6 +844,55 @@ def echo(value: str) -> str: assert "SECRET_TOKEN_123" in caplog.text +@pytest.mark.asyncio +async def test_function_tool_argument_logging_excludes_live_context( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + context_secret = "CONTEXT_SECRET_SENTINEL" + model_argument = "MODEL_ARGUMENT_SENTINEL" + + class SensitiveContext: + def __repr__(self) -> str: + return context_secret + + def echo(ctx: ToolContext[Any], value: str) -> str: + assert isinstance(ctx.context, SensitiveContext) + return value + + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + tool = function_tool(echo) + live_context = ToolContext( + SensitiveContext(), + tool_name=tool.name, + tool_call_id="sensitive-context", + tool_arguments=json.dumps({"value": model_argument}), + ) + + with caplog.at_level(logging.DEBUG, logger="openai.agents"): + assert ( + await tool.on_invoke_tool( + live_context, + json.dumps({"value": model_argument}), + ) + == model_argument + ) + + records = [ + record for record in caplog.records if record.msg == "Tool call args: %s, kwargs: %s" + ] + assert len(records) == 1 + record = records[0] + assert model_argument in logging.Formatter().format(record) + assert context_secret not in repr(record.__dict__) + assert isinstance(record.args, tuple) + logged_args, logged_kwargs = record.args + assert isinstance(logged_args, list) + assert isinstance(logged_kwargs, dict) + assert live_context not in logged_args + assert live_context not in logged_kwargs.values() + + @pytest.mark.asyncio async def test_default_failure_error_function_survives_deepcopy() -> None: def boom() -> None: diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py index 008374cbf3..09f90d1773 100644 --- a/tests/test_function_tool_decorator.py +++ b/tests/test_function_tool_decorator.py @@ -1,12 +1,21 @@ +from __future__ import annotations + import asyncio +import functools import inspect import json -from typing import Any +import operator +import sys +from collections.abc import Callable +from types import ModuleType +from typing import Annotated, Any, Generic, TypeVar, cast import pytest from inline_snapshot import snapshot +from pydantic import BaseModel +from typing_extensions import Self -from agents import function_tool +from agents import UserError, function_tool from agents.run_context import RunContextWrapper from agents.tool_context import ToolContext @@ -22,6 +31,9 @@ def ctx_wrapper() -> ToolContext[DummyContext]: ) +CallableValueT = TypeVar("CallableValueT") + + @function_tool def sync_no_context_no_args() -> str: return "test_1" @@ -263,6 +275,526 @@ def test_decorator_timeout_configuration_is_applied() -> None: assert timeout_configured_tool.timeout_error_function is sync_error_handler +@pytest.mark.asyncio +async def test_async_callable_object_works_as_bare_function_tool() -> None: + class AsyncCallable: + """Double a value. + + Args: + value: The value to double. + """ + + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, value: int) -> int: + self.calls += 1 + await asyncio.sleep(0) + return value * 2 + + handler = AsyncCallable() + tool = function_tool(handler) + + assert tool.name == "AsyncCallable" + assert tool.description == "Double a value." + assert tool.params_json_schema["properties"]["value"] == { + "description": "The value to double.", + "title": "Value", + "type": "integer", + } + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 8 + assert handler.calls == 1 + + +@pytest.mark.asyncio +async def test_slotted_async_callable_object_works_as_function_tool() -> None: + class AsyncCallable: + __slots__ = () + + async def __call__(self, value: int) -> int: + return value * 2 + + tool = function_tool(AsyncCallable()) + + assert tool.params_json_schema["properties"]["value"]["type"] == "integer" + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 8 + + +@pytest.mark.asyncio +async def test_callable_object_uses_call_docstring_when_class_docstring_missing() -> None: + class AsyncCallable: + async def __call__(self, value: int) -> int: + """Double a value. + + Args: + value: The value to double. + """ + return value * 2 + + tool = function_tool(AsyncCallable()) + + assert tool.description == "Double a value." + assert tool.params_json_schema["properties"]["value"] == { + "description": "The value to double.", + "title": "Value", + "type": "integer", + } + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 8 + + +def test_callable_object_combines_class_summary_with_call_parameter_docs() -> None: + class AsyncCallable: + """Configure a reusable multiplier.""" + + async def __call__(self, value: Annotated[int, "Annotated fallback."]) -> int: + """Multiply a value. + + Args: + value: The value supplied to this invocation. + """ + return value * 2 + + tool = function_tool(AsyncCallable()) + + assert tool.description == "Configure a reusable multiplier." + assert tool.params_json_schema["properties"]["value"] == { + "description": "The value supplied to this invocation.", + "title": "Value", + "type": "integer", + } + + +@pytest.mark.parametrize("class_name", ["Café", "A" * 65]) +def test_callable_object_requires_override_for_invalid_fallback_name(class_name: str) -> None: + async def call(self: Any, value: int) -> int: + return value + + handler = type(class_name, (), {"__call__": call})() + + with pytest.raises(UserError, match="Pass name_override"): + function_tool(handler) + + assert function_tool(handler, name_override="safe_name").name == "safe_name" + + +@pytest.mark.asyncio +async def test_async_callable_object_works_with_configured_function_tool() -> None: + class AsyncCallable: + async def __call__(self, value: int) -> int: + return value + 1 + + configured_function_tool = function_tool( + name_override="increment", + description_override="Increment a value.", + timeout=1, + ) + tool = configured_function_tool(AsyncCallable()) + + assert tool.name == "increment" + assert tool.description == "Increment a value." + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 5 + + +@pytest.mark.asyncio +async def test_callable_object_invokes_the_resolved_call_method( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Handler: + async def __call__(self, value: int) -> int: + return value + 1 + + handler = Handler() + tool = function_tool(handler) + + async def replacement(self: Handler, value: int) -> int: + return value + 100 + + monkeypatch.setattr(Handler, "__call__", replacement) + + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 5 + + +@pytest.mark.asyncio +async def test_sync_callable_object_preserves_awaitable_result() -> None: + class AwaitableReturningCallable: + def __init__(self) -> None: + self.calls = 0 + + def __call__(self, value: int) -> Any: + self.calls += 1 + + async def result() -> int: + return value * 3 + + return result() + + handler = AwaitableReturningCallable() + tool = function_tool(handler) + + returned = await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') + assert inspect.isawaitable(returned) + assert handler.calls == 1 + assert await returned == 12 + + +@pytest.mark.asyncio +async def test_sync_function_preserves_awaitable_result() -> None: + async def result() -> int: + return 12 + + awaitable = result() + + def handler() -> Any: + return awaitable + + tool = function_tool(handler) + + returned = await tool.on_invoke_tool(ctx_wrapper(), "{}") + assert returned is awaitable + assert await returned == 12 + + +def test_callable_contract_rejects_unknown_call_descriptor() -> None: + class CustomDescriptor: + def __get__(self, instance: Any, owner: type[Any]) -> Callable[..., Any]: + return lambda value: value + + class Handler: + __call__ = CustomDescriptor() + + with pytest.raises(UserError, match="Unsupported callable object"): + function_tool(Handler()) + + +@pytest.mark.parametrize( + "shape", + [ + "partial", + "partialmethod", + "staticmethod", + "classmethod", + "decorated-call", + "update-wrapper", + "published-annotations", + "published-annotate", + "custom-signature", + "method-signature", + "local-annotation", + "singledispatchmethod", + "builtin", + "nested-wrapper", + "context", + "keyword-only-context", + "variadic-context", + "context-with-kwargs", + "generic", + "generic-signature", + "self", + "pydantic-generic", + pytest.param( + "pep695-generic", + marks=pytest.mark.skipif( + sys.version_info < (3, 12), + reason="PEP 695 requires Python 3.12", + ), + ), + pytest.param( + "pep695-context-alias", + marks=pytest.mark.skipif( + sys.version_info < (3, 12), + reason="PEP 695 requires Python 3.12", + ), + ), + ], +) +def test_unsupported_callable_shapes_require_explicit_wrappers(shape: str) -> None: + async def target(value: int) -> int: + return value + + if shape == "partial": + handler: Any = functools.partial(target, 1) + elif shape == "partialmethod": + + class PartialMethodHandler: + __call__ = functools.partialmethod(target, 1) + + handler = PartialMethodHandler() + elif shape == "staticmethod": + + class StaticMethodHandler: + __call__ = staticmethod(target) + + handler = StaticMethodHandler() + elif shape == "classmethod": + + class ClassMethodHandler: + __call__: Any = classmethod(cast(Any, target)) + + handler = ClassMethodHandler() + elif shape == "decorated-call": + + class DecoratedCallHandler: + @functools.wraps(target) + async def __call__(self, *args: Any, **kwargs: Any) -> int: + return await target(*args, **kwargs) + + handler = DecoratedCallHandler() + elif shape == "update-wrapper": + + class UpdatedWrapper: + def __init__(self, wrapped: Any) -> None: + self.wrapped = wrapped + functools.update_wrapper(self, wrapped) + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self.wrapped(*args, **kwargs) + + handler = UpdatedWrapper(target) + elif shape == "published-annotations": + + class PublishedAnnotationsHandler: + def __init__(self) -> None: + self.__annotations__ = {"value": int, "return": int} + + async def __call__(self, value: int) -> int: + return value + + handler = PublishedAnnotationsHandler() + elif shape == "published-annotate": + + class PublishedAnnotateHandler: + def __init__(self) -> None: + self.__annotate__ = lambda _format: {"value": int, "return": int} + + async def __call__(self, value: int) -> int: + return value + + handler = PublishedAnnotateHandler() + elif shape == "custom-signature": + + class CustomSignatureHandler: + __signature__ = inspect.Signature( + [ + inspect.Parameter( + "value", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=int, + ) + ] + ) + + async def __call__(self, *args: Any, **kwargs: Any) -> int: + return cast(int, args[0]) + + handler = CustomSignatureHandler() + elif shape == "method-signature": + + class MethodSignatureHandler: + async def __call__(self, value: int) -> int: + return value + + cast(Any, MethodSignatureHandler.__call__).__signature__ = inspect.Signature( + [ + inspect.Parameter( + "value", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=int, + ) + ] + ) + handler = MethodSignatureHandler() + elif shape == "local-annotation": + + class LocalPayload(BaseModel): + value: int + + class LocalAnnotationHandler: + async def __call__(self, value: LocalPayload) -> int: + return value.value + + handler = LocalAnnotationHandler() + elif shape == "singledispatchmethod": + + class SingleDispatchHandler: + __call__ = functools.singledispatchmethod(target) + + handler = SingleDispatchHandler() + elif shape == "builtin": + handler = operator.itemgetter(0) + elif shape == "nested-wrapper": + + class NestedHandler: + async def __call__(self, value: int) -> int: + return value + + class NestedWrapper: + def __init__(self, wrapped: Any) -> None: + self.wrapped = wrapped + functools.update_wrapper(self, wrapped) + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self.wrapped(*args, **kwargs) + + handler = NestedWrapper(NestedHandler()) + elif shape == "context": + + class ContextHandler: + async def __call__(self, ctx: ToolContext[Any], value: int) -> int: + return value + + handler = ContextHandler() + elif shape == "keyword-only-context": + + class KeywordOnlyContextHandler: + async def __call__(self, *, ctx: ToolContext[Any], value: int) -> int: + return value + + handler = KeywordOnlyContextHandler() + elif shape == "variadic-context": + + class VariadicContextHandler: + async def __call__(self, *ctx: ToolContext[Any]) -> int: + return len(ctx) + + handler = VariadicContextHandler() + elif shape == "context-with-kwargs": + + class ContextWithKwargsHandler: + async def __call__(self, ctx: ToolContext[Any], **kwargs: Any) -> int: + return len(kwargs) + + handler = ContextWithKwargsHandler() + elif shape == "generic": + + class GenericHandler(Generic[CallableValueT]): + async def __call__(self, value: CallableValueT) -> CallableValueT: + return value + + handler = GenericHandler[int]() + elif shape == "generic-signature": + + class GenericSignatureHandler(Generic[CallableValueT]): + __signature__ = inspect.Signature( + [ + inspect.Parameter( + "value", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation="CallableValueT", + ) + ] + ) + + async def __call__(self, *args: Any, **kwargs: Any) -> CallableValueT: + return cast(CallableValueT, args[0]) + + handler = GenericSignatureHandler[int]() + elif shape == "self": + + class SelfHandler: + async def __call__(self, other: Self) -> Self: + return other + + handler = SelfHandler() + elif shape == "pydantic-generic": + + class PydanticGenericHandler(BaseModel, Generic[CallableValueT]): + async def __call__(self, value: CallableValueT) -> CallableValueT: + return value + + handler = PydanticGenericHandler[int]() + elif shape == "pep695-generic": + namespace: dict[str, Any] = {} + exec( + "from __future__ import annotations\n" + "class Handler[T]:\n" + " async def __call__(self, value: T) -> T:\n" + " return value\n", + namespace, + ) + handler = namespace["Handler"][int]() + elif shape == "pep695-context-alias": + namespace = {"Any": Any, "ToolContext": ToolContext} + exec( + "type LiveContext = ToolContext[Any]\n" + "class AliasContextHandler:\n" + " async def __call__(self, ctx: LiveContext, value: int) -> int:\n" + " return value\n", + namespace, + ) + handler = namespace["AliasContextHandler"]() + else: + raise AssertionError(f"Unhandled shape: {shape}") + + with pytest.raises( + UserError, + match="explicit wrapper function|Unsupported generic|annotations resolvable", + ): + function_tool(handler) + + +@pytest.mark.asyncio +async def test_callable_object_resolves_class_scoped_call_annotations() -> None: + class BaseHandler: + class Payload(BaseModel): + value: int + + async def __call__(self, payload: Payload) -> int: + return payload.value + + class Handler(BaseHandler): + pass + + tool = function_tool(Handler()) + + assert tool.params_json_schema["properties"]["payload"] == {"$ref": "#/$defs/Payload"} + assert await tool.on_invoke_tool(ctx_wrapper(), '{"payload": {"value": 4}}') == 4 + + +def test_inherited_callable_resolves_defining_module_annotations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + base_module_name = "tests._callable_base_module" + subclass_module_name = "tests._callable_subclass_module" + base_module = ModuleType(base_module_name) + subclass_module = ModuleType(subclass_module_name) + monkeypatch.setitem(sys.modules, base_module_name, base_module) + monkeypatch.setitem(sys.modules, subclass_module_name, subclass_module) + + exec( + "from __future__ import annotations\n" + "from pydantic import BaseModel\n" + "class Payload(BaseModel):\n" + " value: int\n" + "class BaseHandler:\n" + " async def __call__(self, payload: Payload) -> int:\n" + " return payload.value\n", + base_module.__dict__, + ) + subclass_module.__dict__["BaseHandler"] = base_module.__dict__["BaseHandler"] + exec( + "from __future__ import annotations\nclass Handler(BaseHandler):\n pass\n", + subclass_module.__dict__, + ) + + tool = function_tool(subclass_module.__dict__["Handler"]()) + + assert tool.params_json_schema["properties"]["payload"]["$ref"] == "#/$defs/Payload" + + +@pytest.mark.asyncio +async def test_callable_object_ignores_class_state_annotations() -> None: + class Handler: + value: str + + async def __call__(self, value: int) -> int: + return value * 2 + + tool = function_tool(Handler()) + + assert tool.params_json_schema["properties"]["value"]["type"] == "integer" + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 8 + + def test_function_tool_timeout_arguments_are_keyword_only() -> None: signature = inspect.signature(function_tool) diff --git a/tests/test_programmatic_tool_calling.py b/tests/test_programmatic_tool_calling.py index d7830a28bf..3236a7fbc0 100644 --- a/tests/test_programmatic_tool_calling.py +++ b/tests/test_programmatic_tool_calling.py @@ -2,6 +2,8 @@ import asyncio import json +import sys +from collections.abc import Awaitable, Coroutine from dataclasses import dataclass from typing import Annotated, Any, Literal, cast @@ -83,6 +85,11 @@ class InventoryOutput(BaseModel): available_units: int +class InventoryAwaitable(Awaitable[InventoryOutput]): + def __await__(self) -> Any: + raise NotImplementedError + + class InventoryDict(TypedDict): sku: str available_units: int @@ -260,6 +267,49 @@ def dataclass_tool() -> InventoryData: assert dataclass_tool.output_json_schema["additionalProperties"] is False +@pytest.mark.parametrize( + "return_annotation", + [ + Awaitable[InventoryOutput], + Coroutine[Any, Any, InventoryOutput], + InventoryAwaitable, + Awaitable[InventoryOutput] | InventoryOutput, + Awaitable, + Coroutine, + ], +) +def test_sync_callable_does_not_infer_through_awaitable_output( + return_annotation: Any, +) -> None: + class LookupInventory: + def __call__(self) -> Any: + raise AssertionError("The handler must not run during tool construction.") + + cast(Any, LookupInventory.__call__).__annotations__["return"] = return_annotation + + with pytest.raises(UserError, match="programmatic function tool return annotation"): + function_tool(LookupInventory(), allowed_callers=["programmatic"]) + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12") +def test_sync_callable_does_not_infer_through_pep695_awaitable_alias() -> None: + namespace = { + "Any": Any, + "Awaitable": Awaitable, + "InventoryOutput": InventoryOutput, + } + exec( + "type OutputAwaitable = Awaitable[InventoryOutput]\n" + "class LookupInventory:\n" + " def __call__(self) -> OutputAwaitable:\n" + " raise AssertionError\n", + namespace, + ) + + with pytest.raises(UserError, match="explicit wrapper function"): + function_tool(namespace["LookupInventory"](), allowed_callers=["programmatic"]) + + def test_function_tool_treats_annotated_plain_returns_as_untyped() -> None: @function_tool(allowed_callers=["programmatic"]) def string_tool() -> Annotated[str, "plain string"]: From c55c99b61ea1abf0d617e8184959357e5787a6bb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 26 Jul 2026 08:20:37 +0900 Subject: [PATCH 021/473] chore: update AGENTS.md and code change/reiew skill details --- .../skills/implementation-strategy/SKILL.md | 79 ++++++++++++++++--- AGENTS.md | 15 +++- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index 221c23d5ed..4d03d3e2f6 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -7,28 +7,66 @@ description: Decide how to implement or review runtime and API changes in openai ## Overview -Use this skill before editing or reviewing code when the task changes runtime behavior or anything that might look like a compatibility concern. The goal is to keep implementations and review requests focused while protecting real released contracts. +Use this skill before editing or reviewing code when the task changes runtime behavior, an externally visible interface, or data that must remain usable across releases, processes, or machines. The goal is to keep implementations and review requests focused while protecting behavior and data formats the project has committed to support. ## Quick start 1. Identify the surface you are changing or reviewing: released public API, unreleased branch-local API, internal helper, persisted schema, wire protocol, CLI/config/env surface, or docs/examples only. -2. Define the concrete required outcome, supported behavior that must remain, and work that is outside the current task. -3. Determine the latest release boundary from `origin` first, and only fall back to local tags when remote tags are unavailable: +2. Determine the latest release tag to use as the compatibility baseline from `origin` first, and only fall back to local tags when remote tags are unavailable: ```bash BASE_TAG="$(.agents/skills/final-release-review/scripts/find_latest_release_tag.sh origin 'v*' 2>/dev/null || git tag -l 'v*' --sort=-v:refname | head -n1)" echo "$BASE_TAG" ``` -4. Judge breaking-change risk against that latest release tag, not against unreleased branch churn or post-tag changes already on `main`. If the command fell back to local tags, treat the result as potentially stale and say so. -5. Apply the scope and simplicity rules below to choose the implementation or review recommendation. -6. Add a compatibility layer only when there is a concrete released consumer, an otherwise supported durable external state boundary that requires it, or when the user explicitly asks for a migration path. +3. Write an implementation scope contract before coding: the required behavior, compatibility requirements, intentionally unsupported cases and their failure behavior, and an already-supported alternative for those cases or that none exists. +4. Identify the nearest existing implementation pipeline and the functions, types, or modules that are the source of truth for each affected concern. Prefer adapting the required input into that pipeline over creating parallel schema, metadata, validation, naming, or execution machinery. +5. Judge breaking-change risk against the latest release tag, not against unreleased branch churn or post-tag changes already on `main`. If the command fell back to local tags, treat the result as potentially stale and say so. +6. Apply the scope and simplicity rules below, including the complexity reset triggers, before choosing the implementation or review recommendation. +7. Add a compatibility layer only when the old interface or behavior shipped in the latest release and must remain usable, an explicitly supported durable data format requires it, or the user explicitly asks for a migration path. ## Scope and simplicity rules -- Make the smallest coherent change that fully satisfies the current task and preserves required supported behavior. +- Make the smallest coherent change that fully satisfies the current task and preserves the behavior identified in the compatibility requirements. - Prefer existing patterns and direct implementations. Add a new abstraction, general-purpose helper, configuration knob, dependency, compatibility layer, feature flag, or parallel code path only when a concrete current requirement or supported contract needs it. +- Do not equate accepting a broad Python or third-party protocol type with supporting every representable implementation shape. State exactly which call shapes and behaviors are supported. +- Prefer adapting the required case into the existing source-of-truth path. Do not create a second resolver or contract for schema, documentation, validation, identity, or invocation when the existing path can consume a normalized adapter. +- Require every new piece of state, classification, branching, or metadata to have one source of truth and to satisfy one stated requirement. A cache of inferred facts that can disagree with the runtime object is a strong signal to simplify. - Trace only the code paths being changed and the contracts they rely on. Expand the investigation or implementation only when concrete evidence or validation exposes another required path. - Keep root-cause fixes within the requested boundary. Leave unrelated refactors, cleanup, feature work, and pre-existing failures out of the patch; report them separately when they materially affect the result. -- Add focused tests for the required behavior and realistic regression paths. Do not generalize production code or test infrastructure for hypothetical future cases without evidence. +- Add focused tests for the required behavior, behavior matching the nearest existing path, and one representative case for each intentionally unsupported category. Do not turn a matrix of language-feature permutations into a product contract merely because those permutations can be constructed. + +## Implementation scope contract + +An implementation scope contract is a short, updateable engineering decision record, not a new public API promise. Record these four items in the plan or working notes before implementation, and update them before widening or narrowing the implementation: + +1. **Required behavior:** The smallest user-visible scenario that must work. +2. **Compatibility requirements:** Behavior from the latest release or an explicitly supported durable boundary that must remain unchanged, plus any user-approved migration or deprecation requirement for behavior that will change. +3. **Intentionally unsupported cases:** Specific nearby inputs or call shapes the implementation will reject instead of inferring or emulating, including where and how rejection occurs. +4. **Supported alternative:** An already-supported wrapper, explicit override, adapter, configuration, or lower-level API users can choose for an intentionally unsupported case. State `none` when no such alternative exists. + +If the intentionally unsupported cases cannot be stated clearly, do not start by adding a general resolver. First define a narrower behavior contract. If no adequate supported alternative exists, add one only when the task requires it; do not invent one speculatively. + +## Complexity reset triggers + +Stop extending the current implementation, discard assumptions introduced by the current patch, and redesign from the original requirement when any of these signals appears: + +- Review fixes repeatedly add cases formed by combining the same independent dimensions, such as wrappers, descriptors, generic specialization, binding modes, context injection, sync/async classification, or provider variants. +- The patch begins to interpret a host language or third-party reflection protocol rather than implement the requested SDK behavior. +- Schema generation, documentation, validation, naming, and invocation depend on separately inferred representations that can drift apart. +- A narrow feature requires new state objects, cached modes, recursive resolution, or changes across otherwise unrelated subsystems. +- Most new tests enumerate permutations of implementation mechanics rather than the promised user-facing contract. +- The implementation keeps growing after each review cycle while the original required scenario remains small. + +When a trigger fires: + +1. Stop addressing comments one by one. +2. Group all findings by root cause and identify the unsupported dimensions they expose. +3. Re-read the original request and list the behavior from the latest release that must remain compatible. +4. Compare the complete diff with the merge base of the intended target branch, or with the latest release tag when it is the compatibility baseline, not only with the previous review revision. +5. Delete or directly replace branch-local machinery that is not required. Unreleased code and its tests are not sunk costs. +6. Narrow the supported contract and reject intentionally unsupported cases before side effects occur. Point to an already-supported alternative when one exists. +7. Rebuild the regression suite around the required behavior, behavior matching the nearest existing implementation path, and one representative test for each intentionally unsupported category instead of every possible composition. + +Do not wait for the user or reviewer to request this reset when the signals are already present. ## Compatibility boundary rules @@ -43,17 +81,37 @@ Use this skill before editing or reviewing code when the task changes runtime be ## Default implementation stance - Prefer deletion or direct replacement over aliases, overloads, shims, feature flags, and dual-write logic when the old shape is unreleased. +- Prefer clearly listed unsupported cases over a partial generalization. An actionable error plus an existing wrapper or override is often safer than incomplete protocol emulation. +- Treat a branch-local implementation as disposable. Test coverage proves behavior; it does not make the current architecture worth preserving. - If review feedback claims a change is breaking, verify it against the latest release tag and actual external impact before accepting the feedback. -- If a change truly crosses the latest released contract boundary, call that out explicitly in the ExecPlan, release notes context, and user-facing summary. +- If a change alters behavior or a data format shipped in the latest release, call that out explicitly in the ExecPlan, release notes context, and user-facing summary. ## Applying this skill during review - Establish the requested outcome and compatibility boundary before judging whether the implementation is too narrow or too broad. - Treat complexity as an actionable finding only when specific added machinery is not needed by the current task, a released contract, supported durable state, or a verified runtime or platform risk. Name that machinery and recommend the smallest safe removal or replacement. - Do not request abstractions, configuration, dependencies, compatibility work, or extensibility for hypothetical future consumers. +- Classify related comments together before implementing them. If each comment finds a new combination of the same dimensions, treat the abstraction itself as the finding. +- Ask whether each disputed case belongs to the implementation scope contract. A reproducible edge case is not automatically a required supported case. +- Evaluate convergence: a good fix reduces ambiguity and the number of behavior combinations the implementation must infer; a fix that adds inferred combinations without a stated requirement is moving in the wrong direction. +- Review the complete branch diff from the merge base of the intended target branch, or from the latest release tag when it is the compatibility baseline. Do not let small incremental fixes hide a large accumulated design. - Keep unrelated cleanup and pre-existing problems out of blocking findings. Report them separately only when they are useful to the maintainer. - Require a broader refactor only when concrete evidence shows that the focused change would otherwise be incorrect, unsafe, incompatible, or materially harder to maintain. +## Pre-handoff effectiveness check + +Before declaring the design complete, answer all of these with concrete evidence: + +- Can the required behavior be described without naming internal helper types or reflection mechanics? +- Does the implementation reuse the nearest existing pipeline rather than maintain a parallel interpretation? +- Can every new abstraction, state field, and branch be mapped to the implementation scope contract or a verified compatibility or security requirement? +- Is each intentionally unsupported neighboring case rejected before side effects occur, with an already-supported alternative identified when one exists? +- Do tests cover the required behavior, behavior matching the nearest released implementation path, and one representative case per intentionally unsupported category without making every constructible permutation supported? +- After reviewing the complete diff from the merge base of the intended target branch, would removing any new machinery leave the required behavior intact? If yes, remove it. +- If the latest review comments were applied as a batch, does the new design shrink the future review surface rather than create more combinations? + +If any answer is no, continue the strategy review before adding more implementation. + ## SDK-specific decision rules - When unsupported OpenAI API or provider-adapter behavior already has a released default path, avoid turning it into a default hard error unless the latest release boundary justifies that break. Prefer an opt-in strict mode such as `strict_feature_validation=True`, while keeping the default path compatible through warning, ignoring unsupported data, or a clearly non-empty placeholder. @@ -70,6 +128,7 @@ Use this skill before editing or reviewing code when the task changes runtime be - The change would alter behavior shipped in the latest release tag. - The change would modify durable external data, protocol formats, or serialized state. - The correct solution would materially expand beyond the requested outcome or require unrelated architectural work. +- A complexity reset trigger fires and the narrower replacement would change an already released contract rather than branch-local code. - The user explicitly asked for backward compatibility, deprecation, or migration support. ## Output expectations @@ -79,4 +138,6 @@ When this skill materially affects the implementation approach, state the decisi - `Compatibility boundary: latest release tag v0.x.y; branch-local interface rewrite, no shim needed.` - `Compatibility boundary: released RunState schema; preserve compatibility and add migration coverage.` - `Scope decision: direct change using existing patterns; no new abstraction or adjacent cleanup needed.` +- `Implementation scope contract: support X; preserve Y; reject Z before side effects; use supported alternative W, or none exists.` +- `Complexity reset: repeated edge-case combinations show the approach is too broad; redesign from the original requirement instead of adding another branch.` - `Review decision: the added compatibility path has no released or supported consumer; replace it with the direct implementation.` diff --git a/AGENTS.md b/AGENTS.md index 5da3d17f8b..7f5859afe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ When working on OpenAI API or OpenAI platform integrations in this repo (Respons #### `$implementation-strategy` -Before changing or reviewing runtime code, exported APIs, external configuration, persisted schemas, wire protocols, or other user-facing behavior, use `$implementation-strategy` to decide the compatibility boundary and implementation shape. During review, use it before requesting compatibility layers, migrations, new abstractions, or broader refactors. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. +Before changing or reviewing runtime code, exported APIs, external configuration, persisted schemas, wire protocols, or other user-facing behavior, use `$implementation-strategy` to decide the compatibility boundary and implementation shape. Before coding, write an implementation scope contract that states the required behavior, compatibility requirements, intentionally unsupported cases and their failure behavior, and an already-supported alternative for those cases or that none exists. Treat this contract as a short, updateable engineering decision record, not as a new public API promise. During review, use the skill before requesting compatibility layers, migrations, new abstractions, or broader refactors. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. #### `$pr-draft-summary` @@ -49,6 +49,17 @@ Work in the user's current checkout and on the current branch by default. If the If isolation or a different checkout is needed, explain why and ask the user before changing Git state. This requirement also applies when another rule or workflow recommends a linked worktree: stop and request approval instead of choosing or creating one automatically. +### Scope Discipline and Complexity Reset + +- Implement the narrowest explicitly stated set of behaviors that satisfies the request. Do not interpret every shape accepted by a host-language protocol, third-party library, or reflection API unless those shapes are required by the task or behavior shipped in the latest release. +- Prefer adapting the required case into an existing pipeline over creating a parallel contract, resolver, execution path, or source of truth. Continue to derive schema, validation, naming, documentation, and invocation from the existing source-of-truth functions, types, or modules. +- Every new abstraction, state field, cached classification, compatibility branch, or dispatch mode must map to a stated requirement, released contract, durable boundary, or verified runtime risk. Remove it if that mapping cannot be stated concretely. +- Treat repeated review findings that combine the same independent dimensions (for example wrappers, descriptors, generics, binding modes, context injection, sync/async modes, or provider variants) as evidence that the supported behavior is underspecified or the current abstraction is too broad, not as a queue of cases to patch one by one. +- When that signal appears, stop extending the current design. Re-read the original requirement, group all findings by root cause, compare the complete diff with the merge base of the intended target branch or with the latest release tag when it is the compatibility baseline, and replace branch-local machinery with a narrower contract. Existing unreleased code and tests are not sunk costs. Perform this reset proactively; do not wait for the user or reviewer to request it. +- Prefer an actionable error during construction or validation, before invocation or other side effects, and an existing supported alternative (for example a wrapper function, explicit override, or typed adapter) over partially emulating a broad protocol. Do not add another alternative when an adequate supported one already exists. +- A growing diff is not itself proof of overengineering, but unexpected cross-module spread, duplicated metadata, combinatorial tests, or repeated special cases requires restarting the design review from the original requirement before more code is added. +- Before handoff, verify that the patch has one source of truth per concern, tests the required behavior and intentionally unsupported cases, and does not accidentally make every constructible combination part of the supported SDK behavior. + ### ExecPlans Call out compatibility risk early in your plan only when the change affects behavior shipped in the latest release tag or a released or explicitly supported durable external state boundary, and confirm the approach before implementing changes that could impact users. @@ -240,6 +251,8 @@ make tests - Use `$implementation-strategy` to establish the requested outcome and latest released compatibility boundary before judging implementation scope or architecture. - Treat added complexity as an actionable finding only when specific machinery is not required by the task, a released contract, supported durable state, or a verified runtime or platform risk. Identify the unnecessary machinery and recommend the smallest safe removal or direct replacement. - Do not request speculative abstractions, general-purpose helpers, configuration knobs, dependencies, compatibility layers, feature flags, parallel code paths, or extensibility for hypothetical future consumers. +- Do not process a sequence of related review comments as independent local fixes when they expose the same missing boundary. Classify them together, decide whether the disputed shapes belong to the supported contract, and prefer one narrowing redesign over accumulating branches. +- Review the complete diff from the merge base of the intended target branch, or from the latest release tag when it is the compatibility baseline, not only the latest incremental fix. Passing tests do not justify branch-local machinery that no longer matches the original requirement. - Keep findings scoped to the patch. Do not block on unrelated cleanup, pre-existing bugs, or optional refactors; report them separately when useful. - Require a broader refactor only when concrete evidence shows the focused change would otherwise be incorrect, unsafe, incompatible, or materially harder to maintain. From 99e88c14db33ec85e3e7ca1ad09e972934b56b5e Mon Sep 17 00:00:00 2001 From: TheSaiEaranti Date: Sat, 25 Jul 2026 18:29:45 -0500 Subject: [PATCH 022/473] fix: preserve *args/**kwargs docstring descriptions in tool schemas (#3956) --- src/agents/function_schema.py | 5 +++- tests/test_function_schema.py | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index bcd295f29d..6e9573a0c6 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -233,7 +233,10 @@ def generate_func_documentation( ) param_descriptions: dict[str, str] = { - param.name: param.description + # Google and NumPy style docstrings write variadic parameters with their + # stars ("*args:", "**kwargs:") and griffe returns those names verbatim. + # Strip the stars so lookups by the signature parameter name succeed. + param.name.lstrip("*"): param.description for section in parsed if section.kind == DocstringSectionKind.parameters for param in section.value diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 674411d1f1..c51c340dfc 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -1008,3 +1008,59 @@ def test_google_docstring_after_section_body_matches_blank_line_form(): assert fixed.description == control.description assert fixed.params_json_schema["properties"] == control.params_json_schema["properties"] + + +def starred_args_google_function(x: int, *numbers: float, **kwargs: str) -> str: + """Add numbers to a base. + + Args: + x: The base value. + *numbers: The numbers to add. + **kwargs: Extra options. + """ + return f"{x} {numbers} {kwargs}" + + +def starred_args_numpy_function(x: int, *numbers: float, **kwargs: str) -> str: + """Add numbers to a base. + + Parameters + ---------- + x : int + The base value. + *numbers : float + The numbers to add. + **kwargs : str + Extra options. + """ + return f"{x} {numbers} {kwargs}" + + +def starred_args_sphinx_function(x: int, *numbers: float, **kwargs: str) -> str: + """Add numbers to a base. + + :param x: The base value. + :param numbers: The numbers to add. + :param kwargs: Extra options. + """ + return f"{x} {numbers} {kwargs}" + + +@pytest.mark.parametrize( + "func,style", + [ + (starred_args_google_function, "google"), + (starred_args_numpy_function, "numpy"), + (starred_args_sphinx_function, "sphinx"), + ], +) +def test_variadic_param_descriptions_preserved(func, style): + """Google and NumPy style docstrings write variadic parameters with their stars + ("*numbers:", "**kwargs:"). The parsed descriptions must still attach to the bare + signature names in the JSON schema, matching the sphinx form.""" + fs = function_schema(func, docstring_style=style, strict_json_schema=False) + + properties = fs.params_json_schema.get("properties", {}) + assert properties["x"]["description"] == "The base value." + assert properties["numbers"]["description"] == "The numbers to add." + assert properties["kwargs"]["description"] == "Extra options." From 5aff70faebce1d3e49a6796c7f2e6ec00a019681 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 26 Jul 2026 08:51:30 +0900 Subject: [PATCH 023/473] fix: reuse the verbose stdout logging handler (#3957) --- src/agents/__init__.py | 25 +++++- tests/test_agents_logging.py | 163 ++++++++++++++++++++++++++++++++++- 2 files changed, 182 insertions(+), 6 deletions(-) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 515916c2d1..6c2def39e3 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -1,5 +1,6 @@ import logging import sys +import threading from typing import TYPE_CHECKING, Any, Literal from openai import AsyncOpenAI @@ -332,11 +333,29 @@ def set_default_openai_harness(harness_id: str | None) -> None: _config.set_default_openai_harness(harness_id) -def enable_verbose_stdout_logging(): +_verbose_stdout_handler: "logging.StreamHandler[Any] | None" = None +_verbose_stdout_handler_lock = threading.Lock() + + +def enable_verbose_stdout_logging() -> None: """Enables verbose logging to stdout. This is useful for debugging.""" + global _verbose_stdout_handler + logger = logging.getLogger("openai.agents") - logger.setLevel(logging.DEBUG) - logger.addHandler(logging.StreamHandler(sys.stdout)) + with _verbose_stdout_handler_lock: + logger.setLevel(logging.DEBUG) + stream = sys.stdout if sys.stdout is not None else sys.stderr + + if _verbose_stdout_handler is None: + _verbose_stdout_handler = logging.StreamHandler(stream) + else: + _verbose_stdout_handler.acquire() + try: + _verbose_stdout_handler.stream = stream + finally: + _verbose_stdout_handler.release() + + logger.addHandler(_verbose_stdout_handler) __all__ = [ diff --git a/tests/test_agents_logging.py b/tests/test_agents_logging.py index c63fe3d0e3..60792c108d 100644 --- a/tests/test_agents_logging.py +++ b/tests/test_agents_logging.py @@ -1,13 +1,170 @@ from __future__ import annotations +import io import logging +import sys +import threading +from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor +from typing import Any +import pytest + +import agents from agents import enable_verbose_stdout_logging -def test_enable_verbose_stdout_logging_attaches_handler() -> None: +@pytest.fixture +def agents_logger(monkeypatch: pytest.MonkeyPatch) -> Generator[logging.Logger, None, None]: logger = logging.getLogger("openai.agents") + original_handlers = logger.handlers[:] + original_level = logger.level logger.handlers.clear() + monkeypatch.setattr(agents, "_verbose_stdout_handler", None) + + try: + yield logger + finally: + added_handlers = [ + handler for handler in logger.handlers if handler not in original_handlers + ] + logger.handlers[:] = original_handlers + logger.setLevel(original_level) + for handler in added_handlers: + handler.close() + + +def test_enable_verbose_stdout_logging_reuses_its_handler( + agents_logger: logging.Logger, + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", stdout) + enable_verbose_stdout_logging() - assert logger.handlers - logger.handlers.clear() + handler = agents_logger.handlers[0] + enable_verbose_stdout_logging() + agents_logger.debug("debug message") + + assert agents_logger.handlers == [handler] + assert stdout.getvalue() == "debug message\n" + + +def test_enable_verbose_stdout_logging_preserves_application_handler( + agents_logger: logging.Logger, + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", stdout) + application_handler = logging.StreamHandler(stdout) + application_handler.setLevel(logging.WARNING) + agents_logger.addHandler(application_handler) + + enable_verbose_stdout_logging() + agents_logger.debug("debug message") + + assert agents_logger.handlers[0] is application_handler + assert application_handler.level == logging.WARNING + assert len(agents_logger.handlers) == 2 + assert stdout.getvalue() == "debug message\n" + + +def test_enable_verbose_stdout_logging_follows_replaced_stdout( + agents_logger: logging.Logger, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first_stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", first_stdout) + enable_verbose_stdout_logging() + handler = agents_logger.handlers[0] + agents_logger.debug("first message") + assert first_stdout.getvalue() == "first message\n" + first_stdout.close() + + second_stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", second_stdout) + enable_verbose_stdout_logging() + agents_logger.debug("second message") + + assert agents_logger.handlers == [handler] + assert second_stdout.getvalue() == "second message\n" + + +def test_enable_verbose_stdout_logging_serializes_handler_initialization( + agents_logger: logging.Logger, + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", stdout) + original_stream_handler = logging.StreamHandler + original_add_handler = logging.Logger.addHandler + constructor_count = 0 + constructor_count_lock = threading.Lock() + second_constructor_started = threading.Event() + first_handler_attached = threading.Event() + start_barrier = threading.Barrier(3) + + def coordinated_stream_handler(stream: Any = None) -> logging.StreamHandler[Any]: + nonlocal constructor_count + with constructor_count_lock: + constructor_count += 1 + current_constructor = constructor_count + + handler = original_stream_handler(stream) + if current_constructor == 1: + second_constructor_started.wait(timeout=0.2) + else: + second_constructor_started.set() + first_handler_attached.wait(timeout=0.2) + return handler + + def tracking_add_handler( + logger: logging.Logger, + handler: logging.Handler, + ) -> None: + original_add_handler(logger, handler) + if logger is agents_logger: + first_handler_attached.set() + + def enable_logging() -> None: + start_barrier.wait(timeout=1) + enable_verbose_stdout_logging() + + monkeypatch.setattr(logging, "StreamHandler", coordinated_stream_handler) + monkeypatch.setattr(logging.Logger, "addHandler", tracking_add_handler) + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(enable_logging) for _ in range(2)] + start_barrier.wait(timeout=1) + for future in futures: + future.result(timeout=1) + + monkeypatch.setattr(logging, "StreamHandler", original_stream_handler) + monkeypatch.setattr(logging.Logger, "addHandler", original_add_handler) + agents_logger.debug("debug message") + + assert constructor_count == 1 + assert len(agents_logger.handlers) == 1 + assert stdout.getvalue() == "debug message\n" + + +def test_enable_verbose_stdout_logging_falls_back_to_stderr( + agents_logger: logging.Logger, + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdout = io.StringIO() + stderr = io.StringIO() + monkeypatch.setattr(sys, "stdout", stdout) + monkeypatch.setattr(sys, "stderr", stderr) + enable_verbose_stdout_logging() + handler = agents_logger.handlers[0] + assert isinstance(handler, logging.StreamHandler) + + monkeypatch.setattr(sys, "stdout", None) + enable_verbose_stdout_logging() + agents_logger.debug("debug message") + + assert agents_logger.handlers == [handler] + assert handler.stream is stderr + assert stdout.getvalue() == "" + assert stderr.getvalue() == "debug message\n" From 117bd1bb9abb77087de0aa56ac26bab40cd6c802 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 26 Jul 2026 18:50:10 +0900 Subject: [PATCH 024/473] fix: preserve callable function tool compatibility (#3959) --- src/agents/tool.py | 126 +++++++++++++++++--------- tests/test_function_tool_decorator.py | 76 +++++++++++++--- 2 files changed, 143 insertions(+), 59 deletions(-) diff --git a/src/agents/tool.py b/src/agents/tool.py index 0e96e4fee8..babf56d6ee 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -21,6 +21,8 @@ Concatenate, Generic, Literal, + ParamSpecArgs, + ParamSpecKwargs, Protocol, TypeVar, Union, @@ -42,7 +44,13 @@ from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters from openai.types.responses.web_search_tool_param import UserLocation from pydantic import BaseModel, TypeAdapter, ValidationError, model_validator -from typing_extensions import NotRequired, ParamSpec, Self, TypeAliasType, TypedDict +from typing_extensions import ( + NotRequired, + ParamSpec, + Self, + TypeAliasType, + TypedDict, +) from . import _debug from ._config_coercion import coerce_pydantic_config @@ -2199,10 +2207,64 @@ def _validate_function_tool_output( ) from error +def _validate_function_tool_callable_annotations( + signature: inspect.Signature, + type_hints: dict[str, Any], +) -> None: + """Reject unsupported callable object annotations before tool invocation.""" + native_self = getattr(typing, "Self", Self) + native_alias_type = getattr(typing, "TypeAliasType", TypeAliasType) + alias_types = (TypeAliasType, native_alias_type) + generic_types = (TypeVar, ParamSpec, ParamSpecArgs, ParamSpecKwargs) + + def contains_specialized_annotation(annotation: Any) -> bool: + origin = get_origin(annotation) + if ( + isinstance(annotation, (*generic_types, *alias_types)) + or isinstance(origin, (*generic_types, *alias_types)) + or annotation in (Self, native_self) + ): + return True + return any(contains_specialized_annotation(arg) for arg in get_args(annotation)) + + contract_annotations = [ + type_hints.get(name, parameter.annotation) + for name, parameter in signature.parameters.items() + ] + contract_annotations.append(type_hints.get("return", signature.return_annotation)) + if any( + annotation is not inspect.Signature.empty and contains_specialized_annotation(annotation) + for annotation in contract_annotations + ): + raise UserError( + "Unsupported generic or aliased callable object annotations: use an explicit wrapper " + "function with concrete parameter and return annotations." + ) + + for index, (name, parameter) in enumerate(signature.parameters.items()): + annotation = type_hints.get(name, parameter.annotation) + if annotation is inspect.Signature.empty: + continue + plain_annotation = _unwrap_annotated_type(annotation) + origin = get_origin(plain_annotation) or plain_annotation + if origin is not RunContextWrapper and origin is not ToolContext: + continue + if index == 0 and parameter.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ): + continue + raise UserError( + "Unsupported callable object context parameter: RunContextWrapper or ToolContext " + "must be the first positional parameter. Use an explicit wrapper function." + ) + + def _normalize_function_tool_callable( func: ToolFunction[...], docstring_style: DocstringStyle | None, name_override: str | None, + use_docstring_info: bool, ) -> tuple[ToolFunction[...], str | None]: """Adapt one plain callable instance to the existing function-tool pipeline.""" if isinstance(func, functools.partial): @@ -2236,6 +2298,11 @@ def _normalize_function_tool_callable( if call_owner is None: raise UserError("Unsupported callable object: no inspectable __call__ method was found.") call_descriptor = call_owner.__dict__["__call__"] + if getattr(call_owner, "__type_params__", ()) or getattr(call_owner, "__parameters__", ()): + raise UserError( + "Unsupported generic callable object: use an explicit wrapper function with concrete " + "parameter and return annotations." + ) if ( not isinstance(call_descriptor, FunctionType) or hasattr(call_descriptor, "__wrapped__") @@ -2246,11 +2313,6 @@ def _normalize_function_tool_callable( "__call__ method. Use an explicit wrapper function for partials, decorated methods, " "built-in callables, or custom descriptors." ) - if getattr(call_owner, "__type_params__", ()) or getattr(call_owner, "__parameters__", ()): - raise UserError( - "Unsupported generic callable object: use an explicit wrapper function with concrete " - "parameter and return annotations." - ) call_method = cast(Callable[..., Any], call_descriptor.__get__(func, type(func))) signature = inspect.signature(call_method) @@ -2270,36 +2332,7 @@ def _normalize_function_tool_callable( "annotations resolvable from its module." ) from error - native_self = getattr(typing, "Self", Self) - native_alias_type = getattr(typing, "TypeAliasType", TypeAliasType) - alias_types = (TypeAliasType, native_alias_type) - - def contains_specialized_annotation(annotation: Any) -> bool: - origin = get_origin(annotation) - if ( - isinstance(annotation, (TypeVar, *alias_types)) - or isinstance(origin, alias_types) - or annotation in (Self, native_self) - ): - return True - return any(contains_specialized_annotation(arg) for arg in get_args(annotation)) - - if any(contains_specialized_annotation(annotation) for annotation in type_hints.values()): - raise UserError( - "Unsupported generic or aliased callable object annotations: use an explicit wrapper " - "function with concrete parameter and return annotations." - ) - for name, parameter in signature.parameters.items(): - annotation = type_hints.get(name, parameter.annotation) - if annotation is inspect.Signature.empty: - continue - plain_annotation = _unwrap_annotated_type(annotation) - origin = get_origin(plain_annotation) or plain_annotation - if origin is RunContextWrapper or origin is ToolContext: - raise UserError( - "Unsupported callable object context parameter: use an explicit wrapper function " - "to receive RunContextWrapper or ToolContext." - ) + _validate_function_tool_callable_annotations(signature, type_hints) if inspect.iscoroutinefunction(call_method): @@ -2319,20 +2352,24 @@ def sync_adapter(*args: Any, **kwargs: Any) -> Any: adapter_metadata.__name__ = ( fallback_name if name_override else validate_function_tool_fallback_name(fallback_name) ) - class_doc = inspect.getdoc(type(func)) - call_doc = inspect.cleandoc(call_descriptor.__doc__) if call_descriptor.__doc__ else None - adapter_metadata.__doc__ = call_doc or class_doc adapter_metadata.__annotations__ = { name: annotation for name, annotation in type_hints.items() if name == "return" or name in signature.parameters } adapter_metadata.__signature__ = signature - class_description = ( - generate_func_documentation(type(func), docstring_style).description - if class_doc and call_doc - else None - ) + if not use_docstring_info: + adapter_metadata.__doc__ = None + class_description = None + else: + class_doc = inspect.getdoc(type(func)) + call_doc = inspect.cleandoc(call_descriptor.__doc__) if call_descriptor.__doc__ else None + adapter_metadata.__doc__ = call_doc or class_doc + class_description = ( + generate_func_documentation(type(func), docstring_style).description + if class_doc and call_doc + else None + ) return cast("ToolFunction[...]", adapter), class_description @@ -2474,6 +2511,7 @@ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: the_func, docstring_style, name_override, + use_docstring_info, ) is_sync_function_tool = not inspect.iscoroutinefunction(the_func) schema = function_schema( diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py index 09f90d1773..d388b3b4cd 100644 --- a/tests/test_function_tool_decorator.py +++ b/tests/test_function_tool_decorator.py @@ -395,6 +395,25 @@ async def __call__(self, value: int) -> int: assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 5 +@pytest.mark.asyncio +async def test_configured_async_callable_ignores_annotated_class_state() -> None: + class AsyncCallable: + value: str + factor: int + + def __init__(self, factor: int) -> None: + self.factor = factor + + async def __call__(self, value: int) -> int: + return value * self.factor + + tool = function_tool(AsyncCallable(3), name_override="multiply") + + assert list(tool.params_json_schema["properties"]) == ["value"] + assert tool.params_json_schema["properties"]["value"]["type"] == "integer" + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 12 + + @pytest.mark.asyncio async def test_callable_object_invokes_the_resolved_call_method( monkeypatch: pytest.MonkeyPatch, @@ -454,6 +473,40 @@ def handler() -> Any: assert await returned == 12 +@pytest.mark.asyncio +async def test_async_callable_object_preserves_positional_context() -> None: + class Handler: + async def __call__(self, ctx: ToolContext[Any], value: int) -> str: + return f"{ctx.tool_name}:{value}" + + tool = function_tool(Handler(), name_override="handler") + + assert list(tool.params_json_schema["properties"]) == ["value"] + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == "dummy:4" + + +@pytest.mark.asyncio +async def test_callable_docstring_opt_out_does_not_read_dynamic_doc() -> None: + class RaisingDoc: + def __get__(self, instance: Any, owner: type[Any] | None = None) -> str: + raise AssertionError("The callable docstring should not be read.") + + class Handler: + def __call__(self, value: int) -> int: + return value * 2 + + cast(Any, Handler).__doc__ = RaisingDoc() + tool = function_tool( + Handler(), + name_override="handler", + use_docstring_info=False, + ) + + assert tool.description == "" + assert tool.params_json_schema["properties"]["value"]["type"] == "integer" + assert await tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 8 + + def test_callable_contract_rejects_unknown_call_descriptor() -> None: class CustomDescriptor: def __get__(self, instance: Any, owner: type[Any]) -> Callable[..., Any]: @@ -483,10 +536,9 @@ class Handler: "singledispatchmethod", "builtin", "nested-wrapper", - "context", "keyword-only-context", "variadic-context", - "context-with-kwargs", + "non-first-context", "generic", "generic-signature", "self", @@ -511,8 +563,9 @@ def test_unsupported_callable_shapes_require_explicit_wrappers(shape: str) -> No async def target(value: int) -> int: return value + handler: Any if shape == "partial": - handler: Any = functools.partial(target, 1) + handler = functools.partial(target, 1) elif shape == "partialmethod": class PartialMethodHandler: @@ -636,13 +689,6 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: return self.wrapped(*args, **kwargs) handler = NestedWrapper(NestedHandler()) - elif shape == "context": - - class ContextHandler: - async def __call__(self, ctx: ToolContext[Any], value: int) -> int: - return value - - handler = ContextHandler() elif shape == "keyword-only-context": class KeywordOnlyContextHandler: @@ -657,13 +703,13 @@ async def __call__(self, *ctx: ToolContext[Any]) -> int: return len(ctx) handler = VariadicContextHandler() - elif shape == "context-with-kwargs": + elif shape == "non-first-context": - class ContextWithKwargsHandler: - async def __call__(self, ctx: ToolContext[Any], **kwargs: Any) -> int: - return len(kwargs) + class NonFirstContextHandler: + async def __call__(self, value: int, ctx: ToolContext[Any]) -> int: + return value - handler = ContextWithKwargsHandler() + handler = NonFirstContextHandler() elif shape == "generic": class GenericHandler(Generic[CallableValueT]): From f663a06aea23c859be8e8555c005ac912bd58337 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 26 Jul 2026 19:02:25 +0900 Subject: [PATCH 025/473] chore: tighten implementation strategy review guidance --- .../skills/implementation-strategy/SKILL.md | 140 ++++++++---------- .../agents/openai.yaml | 2 +- AGENTS.md | 9 +- 3 files changed, 68 insertions(+), 83 deletions(-) diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index 4d03d3e2f6..3715a9daf4 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -1,15 +1,11 @@ --- name: implementation-strategy -description: Decide how to implement or review runtime and API changes in openai-agents-python. Use when a task changes or reviews exported APIs, runtime behavior, serialized state, tests, or docs and you need to choose the compatibility boundary, the smallest coherent implementation, whether shims or migrations are warranted, and when unreleased interfaces can be rewritten directly. +description: Choose compatibility-aware scope for runtime and API changes in openai-agents-python. Use before initial implementation and each review-feedback batch to decide whether to patch, reset the design, preserve compatibility, or reject unsupported cases. --- # Implementation Strategy -## Overview - -Use this skill before editing or reviewing code when the task changes runtime behavior, an externally visible interface, or data that must remain usable across releases, processes, or machines. The goal is to keep implementations and review requests focused while protecting behavior and data formats the project has committed to support. - -## Quick start +## Workflow 1. Identify the surface you are changing or reviewing: released public API, unreleased branch-local API, internal helper, persisted schema, wire protocol, CLI/config/env surface, or docs/examples only. 2. Determine the latest release tag to use as the compatibility baseline from `origin` first, and only fall back to local tags when remote tags are unavailable: @@ -17,103 +13,92 @@ Use this skill before editing or reviewing code when the task changes runtime be BASE_TAG="$(.agents/skills/final-release-review/scripts/find_latest_release_tag.sh origin 'v*' 2>/dev/null || git tag -l 'v*' --sort=-v:refname | head -n1)" echo "$BASE_TAG" ``` -3. Write an implementation scope contract before coding: the required behavior, compatibility requirements, intentionally unsupported cases and their failure behavior, and an already-supported alternative for those cases or that none exists. + Report a local-tag fallback as potentially stale. +3. Record the implementation scope contract below before coding. 4. Identify the nearest existing implementation pipeline and the functions, types, or modules that are the source of truth for each affected concern. Prefer adapting the required input into that pipeline over creating parallel schema, metadata, validation, naming, or execution machinery. -5. Judge breaking-change risk against the latest release tag, not against unreleased branch churn or post-tag changes already on `main`. If the command fell back to local tags, treat the result as potentially stale and say so. -6. Apply the scope and simplicity rules below, including the complexity reset triggers, before choosing the implementation or review recommendation. -7. Add a compatibility layer only when the old interface or behavior shipped in the latest release and must remain usable, an explicitly supported durable data format requires it, or the user explicitly asks for a migration path. - -## Scope and simplicity rules - -- Make the smallest coherent change that fully satisfies the current task and preserves the behavior identified in the compatibility requirements. -- Prefer existing patterns and direct implementations. Add a new abstraction, general-purpose helper, configuration knob, dependency, compatibility layer, feature flag, or parallel code path only when a concrete current requirement or supported contract needs it. -- Do not equate accepting a broad Python or third-party protocol type with supporting every representable implementation shape. State exactly which call shapes and behaviors are supported. -- Prefer adapting the required case into the existing source-of-truth path. Do not create a second resolver or contract for schema, documentation, validation, identity, or invocation when the existing path can consume a normalized adapter. -- Require every new piece of state, classification, branching, or metadata to have one source of truth and to satisfy one stated requirement. A cache of inferred facts that can disagree with the runtime object is a strong signal to simplify. -- Trace only the code paths being changed and the contracts they rely on. Expand the investigation or implementation only when concrete evidence or validation exposes another required path. -- Keep root-cause fixes within the requested boundary. Leave unrelated refactors, cleanup, feature work, and pre-existing failures out of the patch; report them separately when they materially affect the result. -- Add focused tests for the required behavior, behavior matching the nearest existing path, and one representative case for each intentionally unsupported category. Do not turn a matrix of language-feature permutations into a product contract merely because those permutations can be constructed. +5. Choose the smallest coherent change using the core decision rules. Add compatibility machinery only for a required supported boundary. +6. Before editing each review-feedback batch, run the review gate against the complete branch diff, not only the latest revision. +7. Before handoff, run the effectiveness check. If any answer is no, revise the design. ## Implementation scope contract -An implementation scope contract is a short, updateable engineering decision record, not a new public API promise. Record these four items in the plan or working notes before implementation, and update them before widening or narrowing the implementation: +Record these four items in the plan or working notes, and update them before widening or narrowing the implementation: 1. **Required behavior:** The smallest user-visible scenario that must work. -2. **Compatibility requirements:** Behavior from the latest release or an explicitly supported durable boundary that must remain unchanged, plus any user-approved migration or deprecation requirement for behavior that will change. -3. **Intentionally unsupported cases:** Specific nearby inputs or call shapes the implementation will reject instead of inferring or emulating, including where and how rejection occurs. -4. **Supported alternative:** An already-supported wrapper, explicit override, adapter, configuration, or lower-level API users can choose for an intentionally unsupported case. State `none` when no such alternative exists. +2. **Compatibility requirements:** Supported released behavior or a durable boundary that must remain usable. +3. **Intentionally unsupported cases:** Nearby inputs or shapes to reject, including when and how rejection occurs. +4. **Supported alternative:** An existing wrapper, override, adapter, configuration, or lower-level API; state `none` when absent. If the intentionally unsupported cases cannot be stated clearly, do not start by adding a general resolver. First define a narrower behavior contract. If no adequate supported alternative exists, add one only when the task requires it; do not invent one speculatively. -## Complexity reset triggers +A released-version reproducer proves reachability, not support. Treat the exact shape as a compatibility requirement only when intentionally covered by public documentation, examples, tests, or typing; required by a durable boundary; or backed by concrete user reliance or maintainer intent. Otherwise record the risk and prefer early rejection with an existing supported alternative. -Stop extending the current implementation, discard assumptions introduced by the current patch, and redesign from the original requirement when any of these signals appears: +## Review-feedback gate -- Review fixes repeatedly add cases formed by combining the same independent dimensions, such as wrappers, descriptors, generic specialization, binding modes, context injection, sync/async classification, or provider variants. -- The patch begins to interpret a host language or third-party reflection protocol rather than implement the requested SDK behavior. -- Schema generation, documentation, validation, naming, and invocation depend on separately inferred representations that can drift apart. -- A narrow feature requires new state objects, cached modes, recursive resolution, or changes across otherwise unrelated subsystems. -- Most new tests enumerate permutations of implementation mechanics rather than the promised user-facing contract. -- The implementation keeps growing after each review cycle while the original required scenario remains small. +Repeat this gate before editing each new feedback batch: -When a trigger fires: +```text +Review checkpoint: +- Root cause and required behavior: +- Compatibility evidence and unsupported cases: +- Source of truth: +- Behavior-space change: narrows / unchanged / widens +- Action: focused patch / complexity reset / reject as unsupported +``` -1. Stop addressing comments one by one. -2. Group all findings by root cause and identify the unsupported dimensions they expose. -3. Re-read the original request and list the behavior from the latest release that must remain compatible. -4. Compare the complete diff with the merge base of the intended target branch, or with the latest release tag when it is the compatibility baseline, not only with the previous review revision. -5. Delete or directly replace branch-local machinery that is not required. Unreleased code and its tests are not sunk costs. -6. Narrow the supported contract and reject intentionally unsupported cases before side effects occur. Point to an already-supported alternative when one exists. -7. Rebuild the regression suite around the required behavior, behavior matching the nearest existing implementation path, and one representative test for each intentionally unsupported category instead of every possible composition. +Classify each finding as a required-behavior defect, supported compatibility requirement, another combination of the same implementation dimensions, or unrelated issue. Widening the behavior space requires new contract evidence. -Do not wait for the user or reviewer to request this reset when the signals are already present. +If a second related finding would add another condition, protocol hop, compatibility case, or test permutation to the same abstraction, stop patching and run the complexity reset. Continue only when concrete evidence puts the exact case in the required or supported contract. -## Compatibility boundary rules +Example: if successive findings require traversing a direct wrapper, partial, nested wrapper, descriptor, and bound method, do not add another hop. Unless arbitrary wrapper graphs are supported, retain the required plain callable behavior and reject ambiguous wrappers before invocation. -- Released public API or documented external behavior: preserve compatibility or provide an explicit migration path. -- Persisted schema, serialized state, wire protocol, CLI flags, environment variables, and externally consumed config: treat as compatibility-sensitive when they are part of the latest release or when the repo explicitly intends to preserve them across commits, processes, or machines. -- Python-specific durable surfaces such as `RunState`, session persistence, exported dataclass constructor order, and documented model/provider configuration should be treated as compatibility-sensitive when they were part of the latest release tag or are explicitly supported as a shared durability boundary. -- Interface changes introduced only on the current branch: not a compatibility target. Rewrite them directly. -- Interface changes present on `main` but added after the latest release tag: not a semver breaking change by themselves. Rewrite them directly unless they already define a released or explicitly supported durable external state boundary. -- Internal helpers, private types, same-branch tests, fixtures, and examples: update them directly instead of adding adapters. -- Unreleased persisted schema versions on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. When you do that, update the support set and tests together so the boundary is explicit. +## Core decision rules -## Default implementation stance +- Preserve released public APIs, documented behavior, and supported durable boundaries, or provide an explicit migration path. +- Rewrite branch-local interfaces, internal helpers, same-branch tests, and post-release additions on `main` directly unless they already define a supported durable boundary. +- Unreleased persisted schema versions may be renumbered or squashed when intermediate snapshots are intentionally unsupported; update the support set and tests together. +- Do not equate a broad Python or third-party protocol with support for every representable shape. +- Prefer the nearest existing pipeline and one source of truth for schema, documentation, validation, identity, and invocation. +- Add abstractions, state, classifications, branches, configuration, dependencies, or parallel paths only for a stated requirement, supported contract, or verified risk. +- Prefer deletion or direct replacement for unreleased code. Treat branch-local implementation and tests as disposable. +- Prefer an actionable construction- or validation-time error plus an existing alternative over partial protocol emulation. +- Keep unrelated refactors and pre-existing failures out of the patch. +- Test the required behavior, the nearest supported path, and one representative case per unsupported category rather than every constructible permutation. +- Call out changes to supported released behavior or durable formats in the plan and handoff. -- Prefer deletion or direct replacement over aliases, overloads, shims, feature flags, and dual-write logic when the old shape is unreleased. -- Prefer clearly listed unsupported cases over a partial generalization. An actionable error plus an existing wrapper or override is often safer than incomplete protocol emulation. -- Treat a branch-local implementation as disposable. Test coverage proves behavior; it does not make the current architecture worth preserving. -- If review feedback claims a change is breaking, verify it against the latest release tag and actual external impact before accepting the feedback. -- If a change alters behavior or a data format shipped in the latest release, call that out explicitly in the ExecPlan, release notes context, and user-facing summary. +## Complexity reset -## Applying this skill during review +Stop extending the current design when: -- Establish the requested outcome and compatibility boundary before judging whether the implementation is too narrow or too broad. -- Treat complexity as an actionable finding only when specific added machinery is not needed by the current task, a released contract, supported durable state, or a verified runtime or platform risk. Name that machinery and recommend the smallest safe removal or replacement. -- Do not request abstractions, configuration, dependencies, compatibility work, or extensibility for hypothetical future consumers. -- Classify related comments together before implementing them. If each comment finds a new combination of the same dimensions, treat the abstraction itself as the finding. -- Ask whether each disputed case belongs to the implementation scope contract. A reproducible edge case is not automatically a required supported case. -- Evaluate convergence: a good fix reduces ambiguity and the number of behavior combinations the implementation must infer; a fix that adds inferred combinations without a stated requirement is moving in the wrong direction. -- Review the complete branch diff from the merge base of the intended target branch, or from the latest release tag when it is the compatibility baseline. Do not let small incremental fixes hide a large accumulated design. -- Keep unrelated cleanup and pre-existing problems out of blocking findings. Report them separately only when they are useful to the maintainer. -- Require a broader refactor only when concrete evidence shows that the focused change would otherwise be incorrect, unsafe, incompatible, or materially harder to maintain. +- Related findings keep combining the same dimensions, such as wrappers, descriptors, generics, binding, context injection, sync/async classification, or provider variants. +- The patch interprets a host-language or third-party protocol, or separately infers representations that can drift. +- A narrow requirement needs recursive resolution, cached modes, new state, or unrelated subsystem changes. +- Tests enumerate mechanics or the full diff keeps growing while the required scenario remains small. -## Pre-handoff effectiveness check +When a trigger fires: + +1. Stop addressing comments one by one. +2. Group findings by root cause and re-read the original requirement and scope contract. +3. Compare the complete diff with the intended merge base or latest release tag. +4. Delete unnecessary branch-local machinery, narrow the contract, and reject unsupported cases before side effects. +5. Rebuild tests around required behavior and representative unsupported categories. + +Do not wait for the user or reviewer to request this reset when the signals are already present. + +## Effectiveness check Before declaring the design complete, answer all of these with concrete evidence: - Can the required behavior be described without naming internal helper types or reflection mechanics? - Does the implementation reuse the nearest existing pipeline rather than maintain a parallel interpretation? -- Can every new abstraction, state field, and branch be mapped to the implementation scope contract or a verified compatibility or security requirement? -- Is each intentionally unsupported neighboring case rejected before side effects occur, with an already-supported alternative identified when one exists? -- Do tests cover the required behavior, behavior matching the nearest released implementation path, and one representative case per intentionally unsupported category without making every constructible permutation supported? -- After reviewing the complete diff from the merge base of the intended target branch, would removing any new machinery leave the required behavior intact? If yes, remove it. -- If the latest review comments were applied as a batch, does the new design shrink the future review surface rather than create more combinations? - -If any answer is no, continue the strategy review before adding more implementation. +- Does every new abstraction and branch map to the scope contract or a verified risk? +- Are unsupported neighboring cases rejected before side effects with an existing alternative identified? +- Do the complete diff and tests cover the contract without making every constructible permutation supported? +- Does the latest review revision shrink or preserve the behavior space rather than widen it without evidence? ## SDK-specific decision rules +- Treat released `RunState`, session persistence, and other explicitly durable serialized state as compatibility-sensitive across commits, processes, and machines. - When unsupported OpenAI API or provider-adapter behavior already has a released default path, avoid turning it into a default hard error unless the latest release boundary justifies that break. Prefer an opt-in strict mode such as `strict_feature_validation=True`, while keeping the default path compatible through warning, ignoring unsupported data, or a clearly non-empty placeholder. - For OpenAI API feature gaps, evaluate streaming and non-streaming paths together. Custom tool calls, multi-choice Chat Completions chunks, non-text tool outputs, and similar provider payload differences must not be strict in one path and permissive or malformed in the other. - When a change creates new public SDK behavior, do not expose it only through hard-coded module globals. Prefer an explicit public configuration object or parameter, preserve the existing default behavior when compatibility-sensitive, and make opt-in SDK defaults explicit. @@ -125,10 +110,10 @@ If any answer is no, continue the strategy review before adding more implementat ## When to stop and confirm -- The change would alter behavior shipped in the latest release tag. +- The change would alter supported behavior shipped in the latest release tag, or concrete evidence shows material reliance on behavior that the release incidentally accepted. - The change would modify durable external data, protocol formats, or serialized state. - The correct solution would materially expand beyond the requested outcome or require unrelated architectural work. -- A complexity reset trigger fires and the narrower replacement would change an already released contract rather than branch-local code. +- A complexity reset trigger fires and the narrower replacement would change an already released supported contract rather than branch-local code. - The user explicitly asked for backward compatibility, deprecation, or migration support. ## Output expectations @@ -136,8 +121,5 @@ If any answer is no, continue the strategy review before adding more implementat When this skill materially affects the implementation approach, state the decision briefly in your reasoning or handoff, for example: - `Compatibility boundary: latest release tag v0.x.y; branch-local interface rewrite, no shim needed.` -- `Compatibility boundary: released RunState schema; preserve compatibility and add migration coverage.` -- `Scope decision: direct change using existing patterns; no new abstraction or adjacent cleanup needed.` - `Implementation scope contract: support X; preserve Y; reject Z before side effects; use supported alternative W, or none exists.` - `Complexity reset: repeated edge-case combinations show the approach is too broad; redesign from the original requirement instead of adding another branch.` -- `Review decision: the added compatibility path has no released or supported consumer; replace it with the direct implementation.` diff --git a/.agents/skills/implementation-strategy/agents/openai.yaml b/.agents/skills/implementation-strategy/agents/openai.yaml index 9a64342d19..bce8346568 100644 --- a/.agents/skills/implementation-strategy/agents/openai.yaml +++ b/.agents/skills/implementation-strategy/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Implementation Strategy" short_description: "Choose a compatibility-aware implementation plan" - default_prompt: "Use $implementation-strategy to choose the implementation approach and compatibility boundary before editing runtime code." + default_prompt: "Use $implementation-strategy before initial runtime or API edits and each review-feedback batch to check the full diff, supported contract, and convergence before patching." diff --git a/AGENTS.md b/AGENTS.md index 7f5859afe5..0b88af1898 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,9 @@ When working on OpenAI API or OpenAI platform integrations in this repo (Respons #### `$implementation-strategy` -Before changing or reviewing runtime code, exported APIs, external configuration, persisted schemas, wire protocols, or other user-facing behavior, use `$implementation-strategy` to decide the compatibility boundary and implementation shape. Before coding, write an implementation scope contract that states the required behavior, compatibility requirements, intentionally unsupported cases and their failure behavior, and an already-supported alternative for those cases or that none exists. Treat this contract as a short, updateable engineering decision record, not as a new public API promise. During review, use the skill before requesting compatibility layers, migrations, new abstractions, or broader refactors. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. +Before changing or reviewing runtime code, exported APIs, external configuration, persisted schemas, wire protocols, or other user-facing behavior, use `$implementation-strategy` to decide the compatibility boundary and implementation shape. Before coding, write an implementation scope contract that states the required behavior, compatibility requirements, intentionally unsupported cases and their failure behavior, and an already-supported alternative for those cases or that none exists. Treat this contract as a short, updateable engineering decision record, not as a new public API promise. During review, use the skill before requesting compatibility layers, migrations, new abstractions, or broader refactors. + +Repeat the skill before editing each new review-feedback batch; an earlier strategy decision is stale when a comment would widen the supported contract or add another compatibility branch, resolver condition, or test permutation. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. #### `$pr-draft-summary` @@ -51,11 +53,12 @@ If isolation or a different checkout is needed, explain why and ask the user bef ### Scope Discipline and Complexity Reset -- Implement the narrowest explicitly stated set of behaviors that satisfies the request. Do not interpret every shape accepted by a host-language protocol, third-party library, or reflection API unless those shapes are required by the task or behavior shipped in the latest release. +- Implement the narrowest explicitly stated set of behaviors that satisfies the request. Do not interpret every shape accepted by a host-language protocol, third-party library, or reflection API unless those shapes are required by the task or supported behavior shipped in the latest release. - Prefer adapting the required case into an existing pipeline over creating a parallel contract, resolver, execution path, or source of truth. Continue to derive schema, validation, naming, documentation, and invocation from the existing source-of-truth functions, types, or modules. - Every new abstraction, state field, cached classification, compatibility branch, or dispatch mode must map to a stated requirement, released contract, durable boundary, or verified runtime risk. Remove it if that mapping cannot be stated concretely. -- Treat repeated review findings that combine the same independent dimensions (for example wrappers, descriptors, generics, binding modes, context injection, sync/async modes, or provider variants) as evidence that the supported behavior is underspecified or the current abstraction is too broad, not as a queue of cases to patch one by one. +- Treat a second related review finding that would add another condition, protocol hop, compatibility case, or test permutation to the same abstraction as a mandatory complexity-reset checkpoint, not another item to patch. Continue the design only when concrete evidence shows that the additional case belongs to the supported contract. - When that signal appears, stop extending the current design. Re-read the original requirement, group all findings by root cause, compare the complete diff with the merge base of the intended target branch or with the latest release tag when it is the compatibility baseline, and replace branch-local machinery with a narrower contract. Existing unreleased code and tests are not sunk costs. Perform this reset proactively; do not wait for the user or reviewer to request it. +- A released-version reproducer proves reachability, not a supported contract. Verify the exact shape against documentation, tests, examples, intentional public typing, explicit maintainer intent, or concrete user reliance before adding compatibility machinery. - Prefer an actionable error during construction or validation, before invocation or other side effects, and an existing supported alternative (for example a wrapper function, explicit override, or typed adapter) over partially emulating a broad protocol. Do not add another alternative when an adequate supported one already exists. - A growing diff is not itself proof of overengineering, but unexpected cross-module spread, duplicated metadata, combinatorial tests, or repeated special cases requires restarting the design review from the original requirement before more code is added. - Before handoff, verify that the patch has one source of truth per concern, tests the required behavior and intentionally unsupported cases, and does not accidentally make every constructible combination part of the supported SDK behavior. From da14e70b3abaa3fd4fe68c1b22b8fac80ef608ec Mon Sep 17 00:00:00 2001 From: Ali Adnan <165782963+AAliKKhan@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:57:55 +0500 Subject: [PATCH 026/473] fix: use last_agent property in pretty_print_run_result_streaming to avoid None crash (#3965) --- src/agents/util/_pretty_print.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/util/_pretty_print.py b/src/agents/util/_pretty_print.py index 51fcb9b677..9af5a3a1de 100644 --- a/src/agents/util/_pretty_print.py +++ b/src/agents/util/_pretty_print.py @@ -53,7 +53,7 @@ def pretty_print_run_error_details(result: "RunErrorDetails") -> str: def pretty_print_run_result_streaming(result: "RunResultStreaming") -> str: output = "RunResultStreaming:" - output += f'\n- Current agent: Agent(name="{result.current_agent.name}", ...)' + output += f'\n- Current agent: Agent(name="{result.last_agent.name}", ...)' output += f"\n- Current turn: {result.current_turn}" output += f"\n- Max turns: {result.max_turns}" output += f"\n- Is complete: {result.is_complete}" From 3da2465a447d9656da121a4251f7c9e4dedd24b4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Jul 2026 06:58:12 +0900 Subject: [PATCH 027/473] test: add unit tests covering #3965 --- tests/test_result_cast.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_result_cast.py b/tests/test_result_cast.py index a97bb3eb24..5b74e0d34f 100644 --- a/tests/test_result_cast.py +++ b/tests/test_result_cast.py @@ -230,7 +230,7 @@ def test_run_result_release_agents_is_idempotent() -> None: _ = result.last_agent -def test_run_result_streaming_release_agents_releases_current_agent() -> None: +def test_run_result_streaming_release_agents_uses_weakref_until_agent_is_collected() -> None: agent = Agent(name="streaming-agent") streaming_result = RunResultStreaming( input="stream", @@ -252,6 +252,8 @@ def test_run_result_streaming_release_agents_releases_current_agent() -> None: streaming_result.release_agents(release_new_items=False) + assert 'Current agent: Agent(name="streaming-agent", ...)' in str(streaming_result) + agent_ref = weakref.ref(agent) del agent gc.collect() From 045b9cac71b8fbcc5a52cb10f05cf12f7e8cda45 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Jul 2026 07:27:14 +0900 Subject: [PATCH 028/473] chore: update FastAPI test dependency (#3974) --- uv.lock | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 25e6e79adb..c48b99c074 100644 --- a/uv.lock +++ b/uv.lock @@ -156,6 +156,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -992,16 +1001,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.116.1" +version = "0.139.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, ] [[package]] From a335b32024883fce51d3453228949edaa8da4e49 Mon Sep 17 00:00:00 2001 From: TheSaiEaranti Date: Sun, 26 Jul 2026 19:18:40 -0500 Subject: [PATCH 029/473] fix: compare inspect sentinels by identity in function_schema (#3961) --- src/agents/function_schema.py | 10 +++--- tests/test_function_schema.py | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 6e9573a0c6..26d6d1f3b8 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -355,7 +355,7 @@ def function_schema( first_name, first_param = params[0] # Prefer the evaluated type hint if available ann = type_hints.get(first_name, first_param.annotation) - if ann != inspect._empty: + if ann is not inspect._empty: origin = get_origin(ann) or ann if origin is RunContextWrapper or origin is ToolContext: takes_context = True # Mark that the function takes context @@ -367,7 +367,7 @@ def function_schema( # For parameters other than the first, raise error if any use RunContextWrapper or ToolContext. for name, param in params[1:]: ann = type_hints.get(name, param.annotation) - if ann != inspect._empty: + if ann is not inspect._empty: origin = get_origin(ann) or ann if origin is RunContextWrapper or origin is ToolContext: raise UserError( @@ -385,7 +385,7 @@ def function_schema( default = param.default # If there's no type hint, assume `Any` - if ann == inspect._empty: + if ann is inspect._empty: ann = Any # If a docstring param description exists, use it @@ -439,12 +439,12 @@ def function_schema( field_info_from_annotated, description=field_description or field_info_from_annotated.description, ) - if default != inspect._empty and not isinstance(default, FieldInfo): + if default is not inspect._empty and not isinstance(default, FieldInfo): merged = FieldInfo.merge_field_infos(merged, default=default) elif isinstance(default, FieldInfo): merged = FieldInfo.merge_field_infos(merged, default) fields[name] = (ann, merged) - elif default == inspect._empty: + elif default is inspect._empty: # Required field fields[name] = ( ann, diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index c51c340dfc..bdecfc0605 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -1064,3 +1064,68 @@ def test_variadic_param_descriptions_preserved(func, style): assert properties["x"]["description"] == "The base value." assert properties["numbers"]["description"] == "The numbers to add." assert properties["kwargs"]["description"] == "Extra options." + + +class _ElementwiseEqual: + """Mimics numpy-array equality: ``==`` returns a container whose truthiness raises.""" + + # Annotated ``Any`` like numpy's own stubs: elementwise ``__eq__`` does not + # return ``bool``. + def __eq__(self, other: object) -> Any: + return _ElementwiseEqual() + + def __ne__(self, other: object) -> Any: + return _ElementwiseEqual() + + def __bool__(self) -> bool: + raise ValueError("The truth value of an elementwise comparison is ambiguous.") + + def __hash__(self) -> int: + return 0 + + +class _AlwaysEqual: + """A default value whose ``__eq__`` answers True for anything, including sentinels.""" + + def __eq__(self, other: object) -> bool: + return True + + def __ne__(self, other: object) -> bool: + return False + + def __hash__(self) -> int: + return 0 + + +_ELEMENTWISE_DEFAULT = _ElementwiseEqual() +_ALWAYS_EQUAL_DEFAULT = _AlwaysEqual() + + +def test_default_with_elementwise_eq_does_not_crash(): + """Defaults must be compared to the inspect sentinel by identity: a numpy-style + default whose ``==`` returns a non-boolean container used to crash schema creation.""" + + def score(x: int, weights: Any = _ELEMENTWISE_DEFAULT) -> int: + return x + + fs = function_schema(score, strict_json_schema=False) + assert "weights" not in fs.params_json_schema.get("required", []) + + parsed = fs.params_pydantic_model(x=1) + args, kwargs = fs.to_call_args(parsed) + assert isinstance((args + list(kwargs.values()))[-1], _ElementwiseEqual) + + +def test_default_with_always_true_eq_stays_optional(): + """A default whose ``__eq__`` answers True used to be mistaken for the no-default + sentinel, silently marking the parameter required and discarding the default.""" + + def strip(text: str, punctuation: Any = _ALWAYS_EQUAL_DEFAULT) -> str: + return text + + fs = function_schema(strip, strict_json_schema=False) + assert fs.params_json_schema.get("required", []) == ["text"] + + parsed = fs.params_pydantic_model(text="hi") + args, kwargs = fs.to_call_args(parsed) + assert isinstance((args + list(kwargs.values()))[-1], _AlwaysEqual) From fe41cc39e00396aa77bad216fb35bc6f515cfb5f Mon Sep 17 00:00:00 2001 From: Ali Adnan <165782963+AAliKKhan@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:19:44 +0500 Subject: [PATCH 030/473] fix: use last_agent property in RunResultStreaming._create_error_details (#3967) --- src/agents/result.py | 13 ++++++--- tests/test_result_cast.py | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index 7bccccec91..fa4a9ef664 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -860,13 +860,20 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]: if self._stored_exception: raise self._stored_exception - def _create_error_details(self) -> RunErrorDetails: - """Return a `RunErrorDetails` object considering the current attributes of the class.""" + def _create_error_details(self) -> RunErrorDetails | None: + """Return a `RunErrorDetails` object considering the current attributes of the class. + Returns ``None`` when the current agent can no longer be resolved, preserving the + original terminal exception. + """ + try: + last_agent = self.last_agent + except AgentsException: + return None return RunErrorDetails( input=self.input, new_items=self.new_items, raw_responses=self.raw_responses, - last_agent=self.current_agent, + last_agent=last_agent, context_wrapper=self.context_wrapper, input_guardrail_results=self.input_guardrail_results, output_guardrail_results=self.output_guardrail_results, diff --git a/tests/test_result_cast.py b/tests/test_result_cast.py index 5b74e0d34f..61631fcea0 100644 --- a/tests/test_result_cast.py +++ b/tests/test_result_cast.py @@ -302,6 +302,63 @@ def test_run_result_agent_tool_invocation_returns_immutable_metadata() -> None: cast(Any, invocation).tool_name = "other" +def test_run_result_streaming_create_error_details_with_retained_weakref() -> None: + agent = Agent(name="error-detail-agent") + streaming_result = RunResultStreaming( + input="error", + new_items=[], + raw_responses=[], + final_output=None, + input_guardrail_results=[], + output_guardrail_results=[], + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + context_wrapper=RunContextWrapper(context=None), + current_agent=agent, + current_turn=0, + max_turns=1, + _current_agent_output_schema=None, + trace=None, + interruptions=[], + ) + + streaming_result.release_agents() + details = streaming_result._create_error_details() + assert details is not None + assert details.last_agent.name == "error-detail-agent" + + +def test_run_result_streaming_create_error_details_with_collected_weakref() -> None: + agent = Agent(name="collected-agent") + streaming_result = RunResultStreaming( + input="error", + new_items=[], + raw_responses=[], + final_output=None, + input_guardrail_results=[], + output_guardrail_results=[], + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + context_wrapper=RunContextWrapper(context=None), + current_agent=agent, + current_turn=0, + max_turns=1, + _current_agent_output_schema=None, + trace=None, + interruptions=[], + ) + + streaming_result.release_agents() + agent_ref = weakref.ref(agent) + del agent + gc.collect() + + assert agent_ref() is None + # last_agent raises AgentsException, so _create_error_details should return None + details = streaming_result._create_error_details() + assert details is None + + def test_run_result_streaming_agent_tool_invocation_returns_metadata() -> None: agent = Agent(name="streaming-tool-agent") tool_ctx = ToolContext( From da82ee786ad5968f52001f271a9144bf000aa039 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Jul 2026 09:56:07 +0900 Subject: [PATCH 031/473] fix: retry pre-response WebSocket overload errors (#3978) --- src/agents/models/openai_responses.py | 9 +++ tests/models/test_openai_responses.py | 85 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index ff17cea4a0..55808eda10 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -1031,6 +1031,15 @@ def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice replay_safety="safe", reason=str(request.error), ) + if ( + isinstance(request.error, ResponsesWebSocketError) + and request.error.event_type == "error" + and request.error.code == "server_is_overloaded" + ): + return ModelRetryAdvice( + suggested=True, + reason=str(request.error), + ) return super().get_retry_advice(request) def _get_ws_request_lock(self) -> asyncio.Lock: diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 00c6a4cf50..bd555fdcd8 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -4009,6 +4009,91 @@ def test_websocket_get_retry_advice_allows_stateless_receive_timeout_retry() -> assert advice.replay_safety is None +@pytest.mark.allow_call_model_methods +@pytest.mark.parametrize("previous_response_id", [None, "resp_prev"]) +def test_websocket_get_retry_advice_marks_pre_response_overload_retryable( + previous_response_id: str | None, +) -> None: + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + error = ResponsesWebSocketError( + { + "type": "error", + "error": { + "type": "service_unavailable_error", + "code": "server_is_overloaded", + "message": "Our servers are currently overloaded. Please try again later.", + }, + } + ) + + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=False, + previous_response_id=previous_response_id, + ) + ) + + assert advice is not None + assert advice.suggested is True + assert advice.replay_safety is None + + +@pytest.mark.allow_call_model_methods +def test_websocket_get_retry_advice_keeps_partial_overload_unsafe() -> None: + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + error = ResponsesWebSocketError( + { + "type": "error", + "error": { + "type": "service_unavailable_error", + "code": "server_is_overloaded", + "message": "Our servers are currently overloaded. Please try again later.", + }, + } + ) + setattr(error, "_openai_agents_ws_replay_safety", "unsafe") # noqa: B010 + setattr(error, "_openai_agents_ws_response_started", True) # noqa: B010 + + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=False, + ) + ) + + assert advice is not None + assert advice.suggested is False + assert advice.replay_safety == "unsafe" + + +@pytest.mark.allow_call_model_methods +def test_websocket_get_retry_advice_ignores_other_pre_response_error_codes() -> None: + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + error = ResponsesWebSocketError( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "invalid_request", + "message": "Invalid request.", + }, + } + ) + + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=False, + ) + ) + + assert advice is None + + def test_get_client_disables_provider_managed_retries_when_requested() -> None: class DummyClient: def __init__(self): From a2d82707d94bfcf2ffbcc62ea9746c5fb183804f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:50:31 +0900 Subject: [PATCH 032/473] Release 0.19.0 (#3874) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fe22fa070d..b41334ea89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.18.3" +version = "0.19.0" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index c48b99c074..7bc73f5b8a 100644 --- a/uv.lock +++ b/uv.lock @@ -2437,7 +2437,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.18.3" +version = "0.19.0" source = { editable = "." } dependencies = [ { name = "griffelib" }, From ac1206294c4597a0f8ae51cb92d061d66df730c0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Jul 2026 07:51:20 +0900 Subject: [PATCH 033/473] docs: updates for 0.19.0 (#3872) --- docs/agents.md | 27 +- docs/context.md | 10 +- docs/examples.md | 1 + docs/guardrails.md | 10 +- docs/handoffs.md | 2 +- docs/human_in_the_loop.md | 14 +- docs/ja/agents.md | 125 ++++--- docs/ja/context.md | 10 +- docs/ja/examples.md | 153 ++++---- docs/ja/guardrails.md | 10 +- docs/ja/handoffs.md | 78 ++-- docs/ja/human_in_the_loop.md | 112 +++--- docs/ja/models/index.md | 262 ++++++------- docs/ja/quickstart.md | 5 +- docs/ja/realtime/guide.md | 4 +- docs/ja/release.md | 127 ++++--- docs/ja/results.md | 165 +++++---- docs/ja/running_agents.md | 242 ++++++------ docs/ja/sandbox/clients.md | 6 +- docs/ja/streaming.md | 49 +-- docs/ja/tools.md | 367 +++++++++++-------- docs/ja/tracing.md | 88 ++--- docs/ja/visualization.md | 5 +- docs/ja/voice/quickstart.md | 16 +- docs/ko/agents.md | 137 +++---- docs/ko/context.md | 10 +- docs/ko/examples.md | 101 ++--- docs/ko/guardrails.md | 10 +- docs/ko/handoffs.md | 68 ++-- docs/ko/human_in_the_loop.md | 112 +++--- docs/ko/models/index.md | 255 ++++++------- docs/ko/quickstart.md | 5 +- docs/ko/realtime/guide.md | 4 +- docs/ko/release.md | 111 +++--- docs/ko/results.md | 149 +++++--- docs/ko/running_agents.md | 204 +++++------ docs/ko/sandbox/clients.md | 6 +- docs/ko/streaming.md | 69 ++-- docs/ko/tools.md | 323 +++++++++------- docs/ko/tracing.md | 94 ++--- docs/ko/visualization.md | 5 +- docs/ko/voice/quickstart.md | 16 +- docs/models/index.md | 9 +- docs/quickstart.md | 5 +- docs/realtime/guide.md | 4 +- docs/ref/extensions/sandbox/vercel/mounts.md | 3 + docs/ref/run_internal/tool_caller.md | 3 + docs/ref/sandbox/session/pty_output.md | 3 + docs/release.md | 13 + docs/results.md | 31 +- docs/running_agents.md | 6 +- docs/sandbox/clients.md | 4 +- docs/streaming.md | 7 +- docs/tools.md | 98 ++++- docs/visualization.md | 5 +- docs/voice/quickstart.md | 17 +- docs/zh/agents.md | 133 +++---- docs/zh/context.md | 10 +- docs/zh/examples.md | 133 +++---- docs/zh/guardrails.md | 10 +- docs/zh/handoffs.md | 80 ++-- docs/zh/human_in_the_loop.md | 110 +++--- docs/zh/models/index.md | 284 +++++++------- docs/zh/quickstart.md | 5 +- docs/zh/realtime/guide.md | 4 +- docs/zh/release.md | 103 +++--- docs/zh/results.md | 183 +++++---- docs/zh/running_agents.md | 270 +++++++------- docs/zh/sandbox/clients.md | 6 +- docs/zh/streaming.md | 43 ++- docs/zh/tools.md | 315 +++++++++------- docs/zh/tracing.md | 84 ++--- docs/zh/visualization.md | 5 +- docs/zh/voice/quickstart.md | 16 +- 74 files changed, 3023 insertions(+), 2546 deletions(-) create mode 100644 docs/ref/extensions/sandbox/vercel/mounts.md create mode 100644 docs/ref/run_internal/tool_caller.md create mode 100644 docs/ref/sandbox/session/pty_output.md diff --git a/docs/agents.md b/docs/agents.md index 1ab5ec2ec3..f5946643bb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -45,9 +45,10 @@ The most common properties of an agent are: | `reset_tool_choice` | no | Reset `tool_choice` after a tool call (default: `True`) to avoid tool-use loops. See [Forcing tool use](#forcing-tool-use). | ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -330,9 +331,10 @@ Supplying a list of tools doesn't always mean the LLM will use a tool. You can f When you are using OpenAI Responses tool search, named tool choices are more limited: you cannot target bare namespace names or deferred-only tools with `tool_choice`, and `tool_choice="tool_search"` does not target [`ToolSearchTool`][agents.tool.ToolSearchTool]. In those cases, prefer `auto` or `required`. See [Hosted tool search](tools.md#hosted-tool-search) for the Responses-specific constraints. ```python -from agents import Agent, function_tool, ModelSettings +from agents import Agent, ModelSettings +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -353,9 +355,10 @@ The `tool_use_behavior` parameter in the `Agent` configuration controls how tool - `"stop_on_first_tool"`: The output of the first tool call is used as the final response, without further LLM processing. ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -371,15 +374,16 @@ agent = Agent( - `StopAtTools(stop_at_tool_names=[...])`: Stops if any specified tool is called, using its output as the final response. ```python -from agents import Agent, function_tool +from agents import Agent from agents.agent import StopAtTools +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" -@function_tool +@tool def sum_numbers(a: int, b: int) -> int: """Adds two numbers.""" return a + b @@ -395,11 +399,12 @@ agent = Agent( - `ToolsToFinalOutputFunction`: A custom function that processes tool results and decides whether to stop or continue with the LLM. ```python -from agents import Agent, function_tool, FunctionToolResult, RunContextWrapper +from agents import Agent, FunctionToolResult, RunContextWrapper from agents.agent import ToolsToFinalOutputResult +from agents.decorators import tool from typing import List, Any -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" diff --git a/docs/context.md b/docs/context.md index dfdd32484e..0ba5a99659 100644 --- a/docs/context.md +++ b/docs/context.md @@ -48,14 +48,15 @@ Conversation state is a separate concern. Use `result.to_input_list()`, `session import asyncio from dataclasses import dataclass -from agents import Agent, RunContextWrapper, Runner, function_tool +from agents import Agent, RunContextWrapper, Runner +from agents.decorators import tool @dataclass class UserInfo: # (1)! name: str uid: int -@function_tool +@tool async def fetch_user_age(wrapper: RunContextWrapper[UserInfo]) -> str: # (2)! """Fetch the age of the user. Call this function to get user's age information.""" return f"The user {wrapper.context.name} is 47 years old" @@ -97,7 +98,8 @@ For this, you can use the [`ToolContext`][agents.tool_context.ToolContext] class ```python from typing import Annotated from pydantic import BaseModel, Field -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.tool_context import ToolContext class WeatherContext(BaseModel): @@ -108,7 +110,7 @@ class Weather(BaseModel): temperature_range: str = Field(description="The temperature range in Celsius") conditions: str = Field(description="The weather conditions") -@function_tool +@tool def get_weather(ctx: ToolContext[WeatherContext], city: Annotated[str, "The city to get the weather for"]) -> Weather: print(f"[debug] Tool context: (name: {ctx.tool_name}, call_id: {ctx.tool_call_id}, args: {ctx.tool_arguments})") return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") diff --git a/docs/examples.md b/docs/examples.md index 231736c4fd..54f605499f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -124,6 +124,7 @@ Check out a variety of sample implementations of the SDK in the examples section - Hosted container shell with skill references (`examples/tools/container_shell_skill_reference.py`) - Local shell with local skills (`examples/tools/local_shell_skill.py`) - Tool search with namespaces and deferred tools (`examples/tools/tool_search.py`) + - Programmatic Tool Calling with concurrent structured tool calls (`examples/tools/programmatic_tool_calling.py`) - Computer use - Image generation - Experimental Codex tool workflows (`examples/tools/codex.py`) diff --git a/docs/guardrails.md b/docs/guardrails.md index ac7b0bcc72..5caa814c3e 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -79,8 +79,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -136,8 +136,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -192,10 +192,8 @@ from agents import ( Agent, Runner, ToolGuardrailFunctionOutput, - function_tool, - tool_input_guardrail, - tool_output_guardrail, ) +from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail @tool_input_guardrail def block_secrets(data): @@ -215,7 +213,7 @@ def redact_output(data): return ToolGuardrailFunctionOutput.allow() -@function_tool( +@tool( tool_input_guardrails=[block_secrets], tool_output_guardrails=[redact_output], ) diff --git a/docs/handoffs.md b/docs/handoffs.md index 324cde21f4..88e95abbad 100644 --- a/docs/handoffs.md +++ b/docs/handoffs.md @@ -112,7 +112,7 @@ When a handoff occurs, it's as though the new agent takes over the conversation, - `input_items`: optional items to forward to the next agent instead of `new_items`, allowing you to filter model input while keeping `new_items` intact for session history. - `run_context`: the active [`RunContextWrapper`][agents.run_context.RunContextWrapper] at the time the handoff was invoked. -Nested handoffs are available as an opt-in beta and are disabled by default while we stabilize them. When you enable [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history], the runner collapses the prior transcript into a single assistant summary message and wraps it in a `` block that keeps appending new turns when multiple handoffs happen during the same run. You can provide your own mapping function via [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] to replace the generated message without writing a full `input_filter`. The opt-in only applies when neither the handoff nor the run supplies an explicit `input_filter`, so existing code that already customizes the payload (including the examples in this repository) keeps its current behavior without changes. You can override the nesting behaviour for a single handoff by passing `nest_handoff_history=True` or `False` to [`handoff(...)`][agents.handoffs.handoff], which sets [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]. If you just need to change the wrapper text for the generated summary, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] (and optionally [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]) before running your agents. +Nested handoffs are available as an opt-in beta and are disabled by default while we stabilize them. When you enable [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history], the runner compacts summarizable history into ordered assistant summary segments while preserving lossless message items in their original positions. Each generated summary segment uses the `` wrapper, and later handoffs flatten earlier generated segments before rebuilding the ordered transcript. Sessions, `RunState`, and `RunResult.to_input_list()` track exact message occurrences moved into this SDK-default history so those occurrences are not appended twice; separate identical messages are still preserved. You can provide your own mapping function via [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] to return the exact list of input items for the next agent instead of using the built-in segmentation. The opt-in only applies when neither the handoff nor the run supplies an explicit `input_filter`, so existing code that already customizes the payload (including the examples in this repository) keeps its current behavior without changes. You can override the nesting behaviour for a single handoff by passing `nest_handoff_history=True` or `False` to [`handoff(...)`][agents.handoffs.handoff], which sets [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]. If you just need to change the wrapper text for generated summary segments, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] (and optionally [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]) before running your agents. If both the handoff and the active [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] define a filter, the per-handoff [`input_filter`][agents.handoffs.Handoff.input_filter] takes precedence for that specific handoff. diff --git a/docs/human_in_the_loop.md b/docs/human_in_the_loop.md index 76cd9154c3..9e12ccaed7 100644 --- a/docs/human_in_the_loop.md +++ b/docs/human_in_the_loop.md @@ -12,11 +12,14 @@ This page focuses on the manual approval flow via `interruptions`. If your app c Set `needs_approval` to `True` to always require approval or provide an async function that decides per call. The callable receives the run context, parsed tool parameters, and the tool call ID. +Callable approval rules fail closed when the SDK cannot safely inspect the arguments. If the arguments are malformed JSON, are valid JSON but not an object (for example, `null` or a list), or contain non-standard constants such as `NaN`, `Infinity`, or `-Infinity`, the callable is not invoked and the call requires manual approval. This behavior is the same for Runner and Realtime tool calls. + ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(needs_approval=True) +@tool(needs_approval=True) async def cancel_order(order_id: int) -> str: return f"Cancelled order {order_id}" @@ -25,7 +28,7 @@ async def requires_review(_ctx, params, _call_id) -> bool: return "refund" in params.get("subject", "").lower() -@function_tool(needs_approval=requires_review) +@tool(needs_approval=requires_review) async def send_email(subject: str, body: str) -> str: return f"Sent '{subject}'" @@ -106,14 +109,15 @@ import asyncio import json from pathlib import Path -from agents import Agent, Runner, RunState, function_tool +from agents import Agent, Runner, RunState +from agents.decorators import tool async def needs_oakland_approval(_ctx, params, _call_id) -> bool: return "Oakland" in params.get("city", "") -@function_tool(needs_approval=needs_oakland_approval) +@tool(needs_approval=needs_oakland_approval) async def get_temperature(city: str) -> str: return f"The temperature in {city} is 20° Celsius" diff --git a/docs/ja/agents.md b/docs/ja/agents.md index 8ce787be4e..078e6fe6fd 100644 --- a/docs/ja/agents.md +++ b/docs/ja/agents.md @@ -4,26 +4,26 @@ search: --- # エージェント -エージェントは、アプリの中核となる構成要素です。エージェントは、指示、ツール、およびハンドオフ、ガードレール、structured outputs などのオプションのランタイム動作を設定した大規模言語モデル(LLM)です。 +エージェントは、アプリの中核となる構成要素です。エージェントとは、指示、ツール、およびハンドオフ、ガードレール、structured outputsなどのオプションのランタイム動作を設定した大規模言語モデル(LLM)です。 -単一の通常の `Agent` を定義またはカスタマイズする場合は、このページを参照してください。複数のエージェントをどのように連携させるかを決める場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。マニフェストで定義されたファイルとサンドボックスネイティブの機能を備えた隔離ワークスペース内でエージェントを実行する場合は、[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 +単一のシンプルな `Agent` を定義またはカスタマイズする場合は、このページを使用してください。複数のエージェントをどのように連携させるかを検討している場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。マニフェストで定義されたファイルとサンドボックスネイティブの機能を備えた分離ワークスペース内でエージェントを実行する場合は、[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 -SDK は、OpenAI モデルに対してデフォルトで Responses API を使用しますが、ここで重要なのはオーケストレーションです。`Agent` と `Runner` により、SDK がターン、ツール、ガードレール、ハンドオフ、セッションを管理できます。このループを自分で制御したい場合は、代わりに Responses API を直接使用してください。 +SDK は、OpenAI モデルに対してデフォルトで Responses API を使用しますが、ここで重要なのはオーケストレーションです。`Agent` と `Runner` を組み合わせることで、SDK がターン、ツール、ガードレール、ハンドオフ、セッションを管理できます。このループを自分で管理する場合は、代わりに Responses API を直接使用してください。 ## 次のガイドの選択 -このページをエージェント定義のハブとして使用してください。次に必要な判断に対応するガイドへ進んでください。 +このページをエージェント定義のハブとして使用してください。次に行う判断に対応する関連ガイドへ進んでください。 | 目的 | 次に読むガイド | | --- | --- | | モデルまたはプロバイダーの設定を選択する | [モデル](models/index.md) | | エージェントに機能を追加する | [ツール](tools.md) | -| 実際のリポジトリ、ドキュメント一式、または隔離ワークスペースでエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md) | -| マネージャー形式のオーケストレーションとハンドオフのどちらを使用するか決める | [エージェントオーケストレーション](multi_agent.md) | +| 実際のリポジトリ、ドキュメント一式、または分離ワークスペースでエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md) | +| マネージャー方式のオーケストレーションとハンドオフのどちらを使用するか決定する | [エージェントオーケストレーション](multi_agent.md) | | ハンドオフの動作を設定する | [ハンドオフ](handoffs.md) | | ターンの実行、イベントのストリーミング、または会話状態の管理を行う | [エージェントの実行](running_agents.md) | | 最終出力、実行項目、または再開可能な状態を確認する | [実行結果](results.md) | -| ローカル依存関係とランタイム状態を共有する | [コンテキスト管理](context.md) | +| ローカルの依存関係とランタイム状態を共有する | [コンテキスト管理](context.md) | ## 基本設定 @@ -31,27 +31,28 @@ SDK は、OpenAI モデルに対してデフォルトで Responses API を使用 | プロパティ | 必須 | 説明 | | --- | --- | --- | -| `name` | はい | 人が判読できるエージェント名です。 | -| `instructions` | いいえ | システムプロンプトまたは動的指示コールバックです。強く推奨します。[動的な指示](#dynamic-instructions)を参照してください。 | -| `prompt` | いいえ | OpenAI Responses API のプロンプト設定です。静的なプロンプトオブジェクトまたは関数を受け取ります。[プロンプトテンプレート](#prompt-templates)を参照してください。 | +| `name` | はい | 人間が読めるエージェント名です。 | +| `instructions` | いいえ | システムプロンプトまたは動的な指示のコールバックです。使用を強く推奨します。[動的な指示](#dynamic-instructions)を参照してください。 | +| `prompt` | いいえ | OpenAI Responses API のプロンプト設定です。静的なプロンプトオブジェクトまたは関数を受け入れます。[プロンプトテンプレート](#prompt-templates)を参照してください。 | | `handoff_description` | いいえ | このエージェントがハンドオフ先として提示される際に公開される短い説明です。 | | `handoffs` | いいえ | 会話を専門エージェントに委任します。[ハンドオフ](handoffs.md)を参照してください。 | | `model` | いいえ | 使用する LLM です。[モデル](models/index.md)を参照してください。 | | `model_settings` | いいえ | `temperature`、`top_p`、`tool_choice` などのモデル調整パラメーターです。 | | `tools` | いいえ | エージェントが呼び出せるツールです。[ツール](tools.md)を参照してください。 | -| `mcp_servers` | いいえ | エージェント向けの MCP ベースのツールです。[MCP ガイド](mcp.md)を参照してください。 | +| `mcp_servers` | いいえ | エージェント用の MCP ベースのツールです。[MCP ガイド](mcp.md)を参照してください。 | | `mcp_config` | いいえ | 厳密なスキーマ変換や MCP エラーの形式設定など、MCP ツールの準備方法を詳細に調整します。[MCP ガイド](mcp.md#agent-level-mcp-configuration)を参照してください。 | | `input_guardrails` | いいえ | このエージェントチェーンへの最初のユーザー入力に対して実行されるガードレールです。[ガードレール](guardrails.md)を参照してください。 | | `output_guardrails` | いいえ | このエージェントの最終出力に対して実行されるガードレールです。[ガードレール](guardrails.md)を参照してください。 | | `output_type` | いいえ | プレーンテキストの代わりに使用する構造化された出力型です。[出力型](#output-types)を参照してください。 | | `hooks` | いいえ | エージェントスコープのライフサイクルコールバックです。[ライフサイクルイベント(フック)](#lifecycle-events-hooks)を参照してください。 | -| `tool_use_behavior` | いいえ | ツールの実行結果をモデルに戻すか、実行を終了するかを制御します。[ツール使用時の動作](#tool-use-behavior)を参照してください。 | +| `tool_use_behavior` | いいえ | ツールの実行結果をモデルへ戻してループを継続するか、実行を終了するかを制御します。[ツール使用時の動作](#tool-use-behavior)を参照してください。 | | `reset_tool_choice` | いいえ | ツール使用のループを回避するため、ツール呼び出し後に `tool_choice` をリセットします(デフォルト: `True`)。[ツール使用の強制](#forcing-tool-use)を参照してください。 | ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -64,15 +65,15 @@ agent = Agent( ) ``` -このセクションの内容はすべて `Agent` に適用されます。`SandboxAgent` は同じ考え方を基盤とし、ワークスペーススコープの実行向けに `default_manifest`、`base_instructions`、`capabilities`、`run_as` を追加します。[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 +このセクションの内容はすべて `Agent` に適用されます。`SandboxAgent` は同じ考え方を基盤とし、ワークスペースをスコープとする実行向けに `default_manifest`、`base_instructions`、`capabilities`、`run_as` を追加します。[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 ## プロンプトテンプレート -`prompt` を設定することで、OpenAI プラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API を使用する OpenAI モデルで機能します。 +`prompt` を設定すると、OpenAI プラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API を使用する OpenAI モデルで機能します。 使用するには、次の手順を実行してください。 -1. https://platform.openai.com/playground/prompts に移動します。 +1. https://platform.openai.com/playground/prompts にアクセスします。 2. `poem_style` という新しいプロンプト変数を作成します。 3. 次の内容でシステムプロンプトを作成します。 @@ -127,9 +128,9 @@ result = await Runner.run( ## コンテキスト -エージェントは `context` 型についてジェネリックです。コンテキストは依存性注入のための仕組みです。コンテキストは、作成して `Runner.run()` に渡すオブジェクトであり、すべてのエージェント、ツール、ハンドオフなどに渡されます。また、エージェント実行に必要な依存関係と状態をまとめる役割を果たします。任意の Python オブジェクトをコンテキストとして指定できます。 +エージェントの `context` 型はジェネリックです。コンテキストは依存性注入のための仕組みです。作成したオブジェクトを `Runner.run()` に渡すと、すべてのエージェント、ツール、ハンドオフなどに渡され、エージェント実行に必要な依存関係と状態をまとめて保持します。任意の Python オブジェクトをコンテキストとして指定できます。 -`RunContextWrapper` の完全なインターフェース、共有使用量の追跡、ネストされた `tool_input`、シリアライズに関する注意事項については、[コンテキストガイド](context.md)を参照してください。 +`RunContextWrapper` の全機能、共有の使用量追跡、ネストされた `tool_input`、シリアライズに関する注意事項については、[コンテキストガイド](context.md)を参照してください。 ```python from dataclasses import dataclass @@ -155,7 +156,7 @@ agent = Agent[UserContext]( ## 出力型 -デフォルトでは、エージェントはプレーンテキスト(つまり `str`)を出力します。エージェントに特定の型の出力を生成させる場合は、`output_type` パラメーターを使用できます。一般的には [Pydantic](https://docs.pydantic.dev/) オブジェクトを使用しますが、Pydantic の [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) でラップできる任意の型(dataclass、リスト、TypedDict など)をサポートしています。 +デフォルトでは、エージェントはプレーンテキスト(つまり `str`)の出力を生成します。エージェントに特定の型の出力を生成させる場合は、`output_type` パラメーターを使用できます。一般的には [Pydantic](https://docs.pydantic.dev/) オブジェクトを使用しますが、Pydantic の [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) でラップできる任意の型(データクラス、リスト、TypedDict など)をサポートしています。 ```python from pydantic import BaseModel @@ -176,20 +177,20 @@ agent = Agent( !!! note - `output_type` を渡すと、通常のプレーンテキスト応答の代わりに [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) を使用するようモデルに指示します。 + `output_type` を渡すと、通常のプレーンテキストレスポンスではなく、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)を使用するようモデルに指示します。 ## マルチエージェントシステムの設計パターン -マルチエージェントシステムの設計方法は数多くありますが、一般的に適用できるパターンとして、主に次の二つがあります。 +マルチエージェントシステムの設計方法は多数ありますが、一般的に広く適用できる次の 2 つのパターンがよく使用されます。 -1. マネージャー(agents as tools): 中央のマネージャー/オーケストレーターが、専門のサブエージェントをツールとして呼び出し、会話の制御を維持します。 -2. ハンドオフ: 対等なエージェントが、会話を引き継ぐ専門エージェントに制御をハンドオフします。これは分散型のパターンです。 +1. マネージャー(agents as tools): 中央のマネージャーまたはオーケストレーターが、専門のサブエージェントをツールとして呼び出し、会話の制御を維持します。 +2. ハンドオフ: 対等なエージェントが、会話を引き継ぐ専門エージェントへ制御をハンドオフします。これは分散型の方式です。 詳細については、[エージェント構築の実践ガイド](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)を参照してください。 ### マネージャー(agents as tools) -`customer_facing_agent` がすべてのユーザーとのやり取りを処理し、ツールとして公開された専門のサブエージェントを呼び出します。詳しくは、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 +`customer_facing_agent` はすべてのユーザー対応を処理し、ツールとして公開された専門のサブエージェントを呼び出します。詳しくは、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 ```python from agents import Agent @@ -218,7 +219,7 @@ customer_facing_agent = Agent( ### ハンドオフ -ハンドオフとは、エージェントが処理を委任できるサブエージェントです。ハンドオフが発生すると、委任先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに優れたモジュール型の専門エージェントを構築できます。詳しくは、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 +ハンドオフとは、エージェントが処理を委任できるサブエージェントです。ハンドオフが発生すると、委任先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに優れたモジュール式の専門エージェントを構築できます。詳しくは、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 ```python from agents import Agent @@ -256,26 +257,26 @@ agent = Agent[UserContext]( ## ライフサイクルイベント(フック) -エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生したときに、イベントのログ記録、データの事前取得、使用量の記録を行えます。 +エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生した際に、イベントのログ記録、データの事前取得、使用量の記録を行えます。 -フックには二つのスコープがあります。 +フックには次の 2 つのスコープがあります。 -- [`RunHooks`][agents.lifecycle.RunHooks] は、他のエージェントへのハンドオフを含む `Runner.run(...)` 呼び出し全体を監視します。 -- [`AgentHooks`][agents.lifecycle.AgentHooks] は、`agent.hooks` を介して特定のエージェントインスタンスに関連付けられます。 +- [`RunHooks`][agents.lifecycle.RunHooks] は、他のエージェントへのハンドオフを含む `Runner.run(...)` の呼び出し全体を監視します。 +- [`AgentHooks`][agents.lifecycle.AgentHooks] は、`agent.hooks` を介して特定のエージェントインスタンスに関連付けられます。 -コールバックのコンテキストも、イベントに応じて変わります。 +コールバックのコンテキストもイベントによって異なります。 -- エージェントの開始/終了フックは [`AgentHookContext`][agents.run_context.AgentHookContext] を受け取ります。これは元のコンテキストをラップし、共有された実行使用量の状態を保持します。 -- LLM、ツール、ハンドオフのフックは [`RunContextWrapper`][agents.run_context.RunContextWrapper] を受け取ります。 +- エージェントの開始/終了フックは、元のコンテキストをラップして共有の実行使用量状態を保持する [`AgentHookContext`][agents.run_context.AgentHookContext] を受け取ります。 +- LLM、ツール、ハンドオフの各フックは [`RunContextWrapper`][agents.run_context.RunContextWrapper] を受け取ります。 -一般的なフックの実行タイミングは次のとおりです。 +一般的なフックのタイミングは次のとおりです。 -- `on_agent_start` / `on_agent_end`: 特定のエージェントが最終出力の生成を開始または完了したとき。 -- `on_llm_start` / `on_llm_end`: 各モデル呼び出しの直前と直後。 +- `on_agent_start` / `on_agent_end`: 特定のエージェントが最終出力の生成を開始または完了するとき。 +- `on_llm_start` / `on_llm_end`: 各モデル呼び出しの直前と直後。 - `on_tool_start` / `on_tool_end`: 各ローカルツール呼び出しの前後。関数ツールの場合、フックの `context` は通常 `ToolContext` であるため、`tool_call_id` などのツール呼び出しメタデータを確認できます。 -- `on_handoff`: 制御がエージェント間で移動したとき。 +- `on_handoff`: 制御があるエージェントから別のエージェントへ移るとき。 -ワークフロー全体に単一の監視処理を設定する場合は `RunHooks` を使用し、特定のエージェントにカスタムの副作用が必要な場合は `AgentHooks` を使用してください。 +ワークフロー全体を 1 つのオブザーバーで監視する場合は `RunHooks` を使用し、1 つのエージェントに独自の副作用が必要な場合は `AgentHooks` を使用します。 ```python from agents import Agent, RunHooks, Runner @@ -297,15 +298,15 @@ result = await Runner.run(agent, "Explain quines", hooks=LoggingHooks()) print(result.final_output) ``` -すべてのコールバックについては、[ライフサイクル API リファレンス](ref/lifecycle.md)を参照してください。 +コールバックの全機能については、[ライフサイクル API リファレンス](ref/lifecycle.md)を参照してください。 ## ガードレール -ガードレールを使用すると、エージェントの実行と並行してユーザー入力に対するチェックや検証を実行し、エージェントの出力が生成された後にその出力を検証できます。たとえば、ユーザー入力とエージェント出力の関連性を確認できます。詳しくは、[ガードレール](guardrails.md)のドキュメントを参照してください。 +ガードレールを使用すると、エージェントの実行と並行してユーザー入力のチェック/検証を行い、エージェントの出力が生成された後にその出力をチェック/検証できます。たとえば、ユーザー入力とエージェント出力が関連性のある内容かどうかを確認できます。詳しくは、[ガードレール](guardrails.md)のドキュメントを参照してください。 -## エージェントのクローン/コピー +## エージェントのクローン/コピー -エージェントの `clone()` メソッドを使用すると、Agent を複製し、必要に応じて任意のプロパティを変更できます。 +エージェントの `clone()` メソッドを使用すると、エージェントを複製し、必要に応じて任意のプロパティを変更できます。 ```python pirate_agent = Agent( @@ -322,19 +323,20 @@ robot_agent = pirate_agent.clone( ## ツール使用の強制 -ツールのリストを指定しても、LLM が必ずツールを使用するとは限りません。[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] を設定することで、ツールの使用を強制できます。有効な値は次のとおりです。 +ツールのリストを指定しても、LLM が必ずツールを使用するとは限りません。[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] を設定すると、ツールの使用を強制できます。有効な値は次のとおりです。 1. `auto`: ツールを使用するかどうかを LLM が判断できます。 -2. `required`: LLM にツールの使用を必須としますが、使用するツールは LLM が適切に判断できます。 +2. `required`: LLM にツールの使用を必須とします。ただし、使用するツールは LLM が適切に判断できます。 3. `none`: LLM がツールを _使用しない_ ことを必須とします。 -4. `my_tool` などの特定の文字列: LLM にその特定のツールの使用を必須とします。 +4. `my_tool` などの特定の文字列を設定すると、LLM にその特定のツールの使用を必須とします。 -OpenAI Responses のツール検索を使用する場合、名前付きツール選択肢にはより多くの制限があります。`tool_choice` では、単独の名前空間名や遅延専用ツールを指定できず、`tool_choice="tool_search"` で [`ToolSearchTool`][agents.tool.ToolSearchTool] を指定することもできません。このような場合は、`auto` または `required` を使用してください。Responses 固有の制約については、[ホステッドツール検索](tools.md#hosted-tool-search)を参照してください。 +OpenAI Responses のツール検索を使用する場合、名前を指定したツール選択にはより多くの制限があります。`tool_choice` では、未修飾の名前空間名や遅延専用ツールを対象にできず、`tool_choice="tool_search"` で [`ToolSearchTool`][agents.tool.ToolSearchTool] を対象にすることもできません。このような場合は、`auto` または `required` を優先してください。Responses 固有の制約については、[ホスト型ツール検索](tools.md#hosted-tool-search)を参照してください。 ```python -from agents import Agent, function_tool, ModelSettings +from agents import Agent, ModelSettings +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -351,13 +353,14 @@ agent = Agent( `Agent` 設定の `tool_use_behavior` パラメーターは、ツール出力の処理方法を制御します。 -- `"run_llm_again"`: デフォルトです。ツールを実行し、LLM がその実行結果を処理して最終応答を生成します。 -- `"stop_on_first_tool"`: 最初のツール呼び出しの出力を、LLM による追加処理を行わずに最終応答として使用します。 +- `"run_llm_again"`: デフォルトです。ツールを実行し、LLM がその実行結果を処理して最終レスポンスを生成します。 +- `"stop_on_first_tool"`: 最初のツール呼び出しの出力を、追加の LLM 処理を行わずに最終レスポンスとして使用します。 ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -370,18 +373,19 @@ agent = Agent( ) ``` -- `StopAtTools(stop_at_tool_names=[...])`: 指定したツールのいずれかが呼び出された場合、その出力を最終応答として使用して停止します。 +- `StopAtTools(stop_at_tool_names=[...])`: 指定したツールのいずれかが呼び出された場合に停止し、その出力を最終レスポンスとして使用します。 ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.agent import StopAtTools -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" -@function_tool +@tool def sum_numbers(a: int, b: int) -> int: """Adds two numbers.""" return a + b @@ -394,14 +398,15 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`: ツールの実行結果を処理し、停止するか LLM で処理を続行するかを決定するカスタム関数です。 +- `ToolsToFinalOutputFunction`: ツールの実行結果を処理し、停止するか LLM での処理を継続するかを決定するカスタム関数です。 ```python -from agents import Agent, function_tool, FunctionToolResult, RunContextWrapper +from agents import Agent, FunctionToolResult, RunContextWrapper +from agents.decorators import tool from agents.agent import ToolsToFinalOutputResult from typing import List, Any -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -432,4 +437,4 @@ agent = Agent( !!! note - 無限ループを防ぐため、フレームワークはツール呼び出し後に `tool_choice` を自動的に `"auto"` にリセットします。この動作は [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice] で設定できます。無限ループが発生するのは、ツールの実行結果が LLM に送信され、その後 `tool_choice` によって LLM が再度ツール呼び出しを生成し、この処理が際限なく繰り返されるためです。 \ No newline at end of file + 無限ループを防ぐため、フレームワークはツール呼び出し後に `tool_choice` を自動的に「auto」へリセットします。この動作は [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice] で設定できます。無限ループが発生する理由は、ツールの実行結果が LLM に送信され、`tool_choice` によって LLM が別のツール呼び出しを生成し、この処理が延々と繰り返されるためです。 \ No newline at end of file diff --git a/docs/ja/context.md b/docs/ja/context.md index 3185eee160..ba2bffc485 100644 --- a/docs/ja/context.md +++ b/docs/ja/context.md @@ -52,14 +52,15 @@ search: import asyncio from dataclasses import dataclass -from agents import Agent, RunContextWrapper, Runner, function_tool +from agents import Agent, RunContextWrapper, Runner +from agents.decorators import tool @dataclass class UserInfo: # (1)! name: str uid: int -@function_tool +@tool async def fetch_user_age(wrapper: RunContextWrapper[UserInfo]) -> str: # (2)! """Fetch the age of the user. Call this function to get user's age information.""" return f"The user {wrapper.context.name} is 47 years old" @@ -101,7 +102,8 @@ if __name__ == "__main__": ```python from typing import Annotated from pydantic import BaseModel, Field -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.tool_context import ToolContext class WeatherContext(BaseModel): @@ -112,7 +114,7 @@ class Weather(BaseModel): temperature_range: str = Field(description="The temperature range in Celsius") conditions: str = Field(description="The weather conditions") -@function_tool +@tool def get_weather(ctx: ToolContext[WeatherContext], city: Annotated[str, "The city to get the weather for"]) -> Weather: print(f"[debug] Tool context: (name: {ctx.tool_name}, call_id: {ctx.tool_call_id}, args: {ctx.tool_arguments})") return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") diff --git a/docs/ja/examples.md b/docs/ja/examples.md index 859b16a2f0..28c464934e 100644 --- a/docs/ja/examples.md +++ b/docs/ja/examples.md @@ -4,133 +4,134 @@ search: --- # コード例 -[リポジトリ](https://github.com/openai/openai-agents-python/tree/main/examples) のコード例セクションで、SDK のさまざまな実装サンプルをご覧ください。コード例は複数のカテゴリーに整理され、それぞれ異なるパターンと機能を示します。 +[リポジトリ](https://github.com/openai/openai-agents-python/tree/main/examples)の examples セクションでは、SDK のさまざまな実装例を確認できます。コード例は、各種パターンや機能を示す複数のカテゴリーに分類されています。 ## カテゴリー -- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** このカテゴリーのコード例では、次のような一般的なエージェント設計パターンを示します。 +- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** このカテゴリーのコード例では、以下のような一般的なエージェント設計パターンを示します。 - 決定論的ワークフロー - Agents as tools - - ストリーミングイベントを使用する Agents as tools (`examples/agent_patterns/agents_as_tools_streaming.py`) - - 構造化入力パラメーターを使用する Agents as tools (`examples/agent_patterns/agents_as_tools_structured.py`) + - ストリーミングイベントを使用する Agents as tools(`examples/agent_patterns/agents_as_tools_streaming.py`) + - 構造化入力パラメーターを使用する Agents as tools(`examples/agent_patterns/agents_as_tools_structured.py`) - エージェントの並列実行 - - 条件付きのツール使用 - - 異なる動作でのツール使用の強制 (`examples/agent_patterns/forcing_tool_use.py`) + - 条件付きツール使用 + - 異なる動作によるツール使用の強制(`examples/agent_patterns/forcing_tool_use.py`) - 入出力ガードレール - 判定役としての LLM - ルーティング - ストリーミングガードレール - - ツール承認と状態のシリアル化を伴う人間参加型フロー (`examples/agent_patterns/human_in_the_loop.py`) - - ストリーミングを伴う人間参加型フロー (`examples/agent_patterns/human_in_the_loop_stream.py`) - - 承認フロー向けのカスタム拒否メッセージ (`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) + - ツール承認と状態のシリアライズを伴うヒューマンインザループ(`examples/agent_patterns/human_in_the_loop.py`) + - ストリーミングを伴うヒューマンインザループ(`examples/agent_patterns/human_in_the_loop_stream.py`) + - 承認フロー用のカスタム拒否メッセージ(`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) -- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** これらのコード例では、次のような SDK の基本機能を紹介します。 +- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** これらのコード例では、以下のような SDK の基本機能を紹介します。 - - Hello world のコード例(デフォルトモデル、GPT-5、オープンウェイトモデル) + - Hello World のコード例(デフォルトモデル、GPT-5、オープンウェイトモデル) - エージェントのライフサイクル管理 - - 実行フックとエージェントフックのライフサイクルのコード例 (`examples/basic/lifecycle_example.py`) - - 動的システムプロンプト - - 基本的なツールの使用 (`examples/basic/tools.py`) - - ツールの入出力ガードレール (`examples/basic/tool_guardrails.py`) - - 画像形式のツール出力 (`examples/basic/image_tool_output.py`) - - ストリーミング出力(テキスト、アイテム、関数呼び出しの引数) - - ターン間で共有されるセッションヘルパーを使用する Responses WebSocket トランスポート (`examples/basic/stream_ws.py`) + - 実行フックとエージェントフックのライフサイクルのコード例(`examples/basic/lifecycle_example.py`) + - 動的なシステムプロンプト + - 基本的なツール使用(`examples/basic/tools.py`) + - ツールの入出力ガードレール(`examples/basic/tool_guardrails.py`) + - 画像ツールの出力(`examples/basic/image_tool_output.py`) + - 出力のストリーミング(テキスト、項目、関数呼び出しの引数) + - ターン間で共有セッションヘルパーを使用する Responses WebSocket トランスポート(`examples/basic/stream_ws.py`) - プロンプトテンプレート - - ファイル処理(ローカルおよびリモート、画像および PDF) + - ファイル処理(ローカルとリモート、画像と PDF) - 使用量の追跡 - - Runner が管理する再試行設定 (`examples/basic/retry.py`) - - サードパーティー製アダプターを介して Runner が管理する再試行 (`examples/basic/retry_litellm.py`) - - 厳密でない出力型 + - Runner が管理する再試行設定(`examples/basic/retry.py`) + - サードパーティ製アダプターを介して Runner が管理する再試行(`examples/basic/retry_litellm.py`) + - 非厳密な出力型 - 以前のレスポンス ID の使用 - **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 航空会社向けカスタマーサービスシステムのコード例です。 -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** エージェントとツールを使用した、金融データ分析向けの構造化された調査ワークフローを示す金融調査エージェントです。 +- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 財務データ分析用のエージェントとツールを活用した構造化リサーチワークフローを示す、財務リサーチエージェントです。 -- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** メッセージフィルタリングを伴うエージェントのハンドオフの実践的なコード例です。以下が含まれます。 +- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** メッセージフィルタリングを伴うエージェントのハンドオフの実践的なコード例です。以下が含まれます: - - メッセージフィルターのコード例 (`examples/handoffs/message_filter.py`) - - ストリーミングを伴うメッセージフィルター (`examples/handoffs/message_filter_streaming.py`) + - メッセージフィルターのコード例(`examples/handoffs/message_filter.py`) + - ストリーミングを伴うメッセージフィルター(`examples/handoffs/message_filter_streaming.py`) -- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API でホスト型 MCP (Model Context Protocol) を使用する方法を示すコード例です。以下が含まれます。 +- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API でホスト型 MCP(Model Context Protocol)を使用する方法を示すコード例です。以下が含まれます: - - 承認不要のシンプルなホスト型 MCP (`examples/hosted_mcp/simple.py`) - - Google Calendar などの MCP コネクター (`examples/hosted_mcp/connectors.py`) - - 中断ベースの承認を使用する人間参加型フロー (`examples/hosted_mcp/human_in_the_loop.py`) - - MCP ツール呼び出しの承認時コールバック (`examples/hosted_mcp/on_approval.py`) + - 承認なしのシンプルなホスト型 MCP(`examples/hosted_mcp/simple.py`) + - Google Calendar などの MCP コネクター(`examples/hosted_mcp/connectors.py`) + - 割り込みベースの承認を伴うヒューマンインザループ(`examples/hosted_mcp/human_in_the_loop.py`) + - MCP ツール呼び出し用の承認時コールバック(`examples/hosted_mcp/on_approval.py`) -- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** MCP (Model Context Protocol) を使用してエージェントを構築する方法を学びます。以下が含まれます。 +- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** MCP(Model Context Protocol)を使用してエージェントを構築する方法を学べます。以下が含まれます: - ファイルシステムのコード例 - Git のコード例 - MCP プロンプトサーバーのコード例 - - SSE (Server-Sent Events) のコード例 - - SSE リモートサーバー接続 (`examples/mcp/sse_remote_example`) + - SSE(Server-Sent Events)のコード例 + - SSE リモートサーバー接続(`examples/mcp/sse_remote_example`) - Streamable HTTP のコード例 - - Streamable HTTP リモート接続 (`examples/mcp/streamable_http_remote_example`) - - Streamable HTTP 向けのカスタム HTTP クライアントファクトリー (`examples/mcp/streamablehttp_custom_client_example`) - - `MCPUtil.get_all_function_tools` を使用したすべての MCP ツールの事前取得 (`examples/mcp/get_all_mcp_tools_example`) - - FastAPI と組み合わせた MCPServerManager (`examples/mcp/manager_example`) - - MCP ツールのフィルタリング (`examples/mcp/tool_filter_example`) + - Streamable HTTP リモート接続(`examples/mcp/streamable_http_remote_example`) + - Streamable HTTP 用のカスタム HTTP クライアントファクトリー(`examples/mcp/streamablehttp_custom_client_example`) + - `MCPUtil.get_all_function_tools` を使用したすべての MCP ツールの事前取得(`examples/mcp/get_all_mcp_tools_example`) + - FastAPI と MCPServerManager(`examples/mcp/manager_example`) + - MCP ツールのフィルタリング(`examples/mcp/tool_filter_example`) -- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** エージェント向けのさまざまなメモリ実装のコード例です。以下が含まれます。 +- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** エージェント向けのさまざまなメモリ実装のコード例です。以下が含まれます: - SQLite セッションストレージ - 高度な SQLite セッションストレージ - Redis セッションストレージ - SQLAlchemy セッションストレージ - - Dapr ステートストアのセッションストレージ - - 暗号化されたセッションストレージ + - Dapr ステートストアセッションストレージ + - 暗号化セッションストレージ - OpenAI Conversations セッションストレージ - Responses 圧縮セッションストレージ - - `ModelSettings(store=False)` を使用したステートレスな Responses 圧縮 (`examples/memory/compaction_session_stateless_example.py`) - - ファイルベースのセッションストレージ (`examples/memory/file_session.py`) - - 人間参加型フローを伴うファイルベースのセッション (`examples/memory/file_hitl_example.py`) - - 人間参加型フローを伴う SQLite インメモリセッション (`examples/memory/memory_session_hitl_example.py`) - - 人間参加型フローを伴う OpenAI Conversations セッション (`examples/memory/openai_session_hitl_example.py`) - - セッションをまたぐ HITL の承認/拒否シナリオ (`examples/memory/hitl_session_scenario.py`) + - `ModelSettings(store=False)` を使用したステートレスな Responses 圧縮(`examples/memory/compaction_session_stateless_example.py`) + - ファイルベースのセッションストレージ(`examples/memory/file_session.py`) + - ヒューマンインザループを伴うファイルベースのセッション(`examples/memory/file_hitl_example.py`) + - ヒューマンインザループを伴う SQLite インメモリセッション(`examples/memory/memory_session_hitl_example.py`) + - ヒューマンインザループを伴う OpenAI Conversations セッション(`examples/memory/openai_session_hitl_example.py`) + - セッションをまたぐ HITL の承認/拒否シナリオ(`examples/memory/hitl_session_scenario.py`) -- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** カスタムプロバイダーやサードパーティー製アダプターなど、OpenAI 以外のモデルを SDK で使用する方法を紹介します。 +- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** カスタムプロバイダーやサードパーティ製アダプターを含め、OpenAI 以外のモデルを SDK で使用する方法を確認できます。 -- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** SDK を使用してリアルタイム体験を構築する方法を示すコード例です。以下が含まれます。 +- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** SDK を使用してリアルタイム体験を構築する方法を示すコード例です。以下が含まれます: - - 構造化されたテキストメッセージと画像メッセージを使用する Web アプリケーションパターン - - コマンドラインでの音声ループと再生処理 - - WebSocket を介した Twilio Media Streams 統合 - - Realtime Calls API のアタッチフローを使用した Twilio SIP 統合 + - 構造化されたテキストメッセージと画像メッセージを扱う Web アプリケーションパターン + - コマンドラインの音声ループと再生処理 + - WebSocket を介した Twilio Media Streams 連携 + - Realtime Calls API のアタッチフローを使用する Twilio SIP 連携 -- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 推論コンテンツの扱い方を示すコード例です。以下が含まれます。 +- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 推論コンテンツの扱い方を示すコード例です。以下が含まれます: - - Runner API を使用した推論コンテンツ、ストリーミングおよび非ストリーミング (`examples/reasoning_content/runner_example.py`) - - OpenRouter 経由の OSS モデルを使用した推論コンテンツ (`examples/reasoning_content/gpt_oss_stream.py`) - - 基本的な推論コンテンツのコード例 (`examples/reasoning_content/main.py`) + - Runner API での推論コンテンツ(ストリーミングと非ストリーミング)(`examples/reasoning_content/runner_example.py`) + - OpenRouter を介した OSS モデルでの推論コンテンツ(`examples/reasoning_content/gpt_oss_stream.py`) + - 基本的な推論コンテンツのコード例(`examples/reasoning_content/main.py`) -- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 複雑なマルチエージェント調査ワークフローを示す、シンプルなディープリサーチのクローンです。 +- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 複雑なマルチエージェントのリサーチワークフローを示す、シンプルなディープリサーチのクローンです。 -- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 分離されたワークスペースでエージェントを実行するためのコード例です。以下が含まれます。 +- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 分離されたワークスペースでエージェントを実行するためのコード例です。以下が含まれます: - - 基本的なサンドボックスエージェントのセットアップ (`examples/sandbox/basic.py`) + - 基本的なサンドボックスエージェントのセットアップ(`examples/sandbox/basic.py`) - Unix ローカルおよび Docker サンドボックスのライフサイクルのコード例 - - サンドボックスを利用したハンドオフ (`examples/sandbox/handoffs.py`) - - サンドボックスのメモリとスナップショットからの再開 (`examples/sandbox/memory.py`) - - ツールとして公開されるサンドボックスエージェント (`examples/sandbox/sandbox_agents_as_tools.py`) + - サンドボックスを使用するハンドオフ(`examples/sandbox/handoffs.py`) + - サンドボックスのメモリとスナップショットからの再開(`examples/sandbox/memory.py`) + - ツールとして公開されるサンドボックスエージェント(`examples/sandbox/sandbox_agents_as_tools.py`) -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** OpenAI がホストするツールや実験的な Codex ツール機能の実装方法を学びます。以下が含まれます。 +- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** OpenAI がホストするツールや、以下のような試験的な Codex ツール機能の実装方法を学べます: - - Web 検索、およびフィルター付き Web 検索 + - Web 検索とフィルター付き Web 検索 - ファイル検索 - Code interpreter - - ファイル編集と承認を伴うパッチ適用ツール (`examples/tools/apply_patch.py`) - - 承認コールバックを伴うシェルツールの実行 (`examples/tools/shell.py`) - - 中断ベースの人間参加型承認を伴うシェルツール (`examples/tools/shell_human_in_the_loop.py`) - - インラインスキルを備えたホスト型コンテナシェル (`examples/tools/container_shell_inline_skill.py`) - - スキル参照を備えたホスト型コンテナシェル (`examples/tools/container_shell_skill_reference.py`) - - ローカルスキルを備えたローカルシェル (`examples/tools/local_shell_skill.py`) - - 名前空間と遅延ツールを使用したツール検索 (`examples/tools/tool_search.py`) + - ファイル編集と承認を伴うパッチ適用ツール(`examples/tools/apply_patch.py`) + - 承認コールバックを伴うシェルツールの実行(`examples/tools/shell.py`) + - ヒューマンインザループによる割り込みベースの承認を伴うシェルツール(`examples/tools/shell_human_in_the_loop.py`) + - インラインスキルを使用するホスト型コンテナーシェル(`examples/tools/container_shell_inline_skill.py`) + - スキル参照を使用するホスト型コンテナーシェル(`examples/tools/container_shell_skill_reference.py`) + - ローカルスキルを使用するローカルシェル(`examples/tools/local_shell_skill.py`) + - 名前空間と遅延ツールを使用するツール検索(`examples/tools/tool_search.py`) + - 構造化ツール呼び出しを並行実行するプログラムによるツール呼び出し(`examples/tools/programmatic_tool_calling.py`) - コンピュータ操作 - 画像生成 - - 実験的な Codex ツールワークフロー (`examples/tools/codex.py`) - - 実験的な Codex の同一スレッドワークフロー (`examples/tools/codex_same_thread.py`) + - 試験的な Codex ツールワークフロー(`examples/tools/codex.py`) + - 試験的な Codex の同一スレッドワークフロー(`examples/tools/codex_same_thread.py`) -- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** TTS および STT モデルを使用した音声エージェントのコード例をご覧ください。ストリーミング音声のコード例も含まれます。 \ No newline at end of file +- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** OpenAI の TTS モデルと STT モデルを使用する音声エージェントのコード例を確認できます。音声ストリーミングのコード例も含まれます。 \ No newline at end of file diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index 87fe167591..4768eca7b4 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -83,8 +83,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -140,8 +140,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -196,10 +196,8 @@ from agents import ( Agent, Runner, ToolGuardrailFunctionOutput, - function_tool, - tool_input_guardrail, - tool_output_guardrail, ) +from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail @tool_input_guardrail def block_secrets(data): @@ -219,7 +217,7 @@ def redact_output(data): return ToolGuardrailFunctionOutput.allow() -@function_tool( +@tool( tool_input_guardrails=[block_secrets], tool_output_guardrails=[redact_output], ) diff --git a/docs/ja/handoffs.md b/docs/ja/handoffs.md index 582ff10322..cb2bc81411 100644 --- a/docs/ja/handoffs.md +++ b/docs/ja/handoffs.md @@ -4,21 +4,21 @@ search: --- # ハンドオフ -ハンドオフにより、エージェントはタスクを別のエージェントに委任できます。これは、異なるエージェントがそれぞれ別の領域を専門とするシナリオで特に役立ちます。たとえば、カスタマーサポートアプリには、注文ステータス、返金、 FAQ などのタスクをそれぞれ専門に扱うエージェントがあるかもしれません。 +ハンドオフを使用すると、エージェントはタスクを別のエージェントに委任できます。これは、異なるエージェントがそれぞれ異なる領域に特化しているシナリオで特に役立ちます。たとえば、カスタマーサポートアプリでは、注文状況、返金、よくある質問などのタスクを、それぞれ専任のエージェントが処理できます。 -ハンドオフは LLM に対してツールとして表現されます。そのため、 `Refund Agent` という名前のエージェントへのハンドオフがある場合、そのツールは `transfer_to_refund_agent` と呼ばれます。 +ハンドオフは、LLM に対してツールとして表現されます。そのため、`Refund Agent` という名前のエージェントへのハンドオフがある場合、そのツールは `transfer_to_refund_agent` と呼ばれます。 ## ハンドオフの作成 -すべてのエージェントには [`handoffs`][agents.agent.Agent.handoffs] パラメーターがあり、 `Agent` を直接受け取ることも、ハンドオフをカスタマイズする `Handoff` オブジェクトを受け取ることもできます。 +すべてのエージェントには [`handoffs`][agents.agent.Agent.handoffs] パラメーターがあり、`Agent` を直接受け取ることも、ハンドオフをカスタマイズする `Handoff` オブジェクトを受け取ることもできます。 -通常の `Agent` インスタンスを渡す場合、その [`handoff_description`][agents.agent.Agent.handoff_description] (設定されている場合)がデフォルトのツール説明に追加されます。完全な `handoff()` オブジェクトを書かずに、そのハンドオフをモデルが選ぶべきタイミングを示唆するために使用してください。 +`Agent` インスタンスをそのまま渡した場合、その [`handoff_description`][agents.agent.Agent.handoff_description] が設定されていれば、デフォルトのツール説明に追加されます。完全な `handoff()` オブジェクトを記述せずに、モデルがそのハンドオフを選択すべきタイミングを示すために使用できます。 -Agents SDK が提供する [`handoff()`][agents.handoffs.handoff] 関数を使用してハンドオフを作成できます。この関数では、必要に応じた上書きや入力フィルターとともに、引き渡し先のエージェントを指定できます。 +Agents SDK が提供する [`handoff()`][agents.handoffs.handoff] 関数を使用して、ハンドオフを作成できます。この関数では、ハンドオフ先のエージェントに加えて、任意のオーバーライドや入力フィルターを指定できます。 ### 基本的な使用法 -シンプルなハンドオフを作成する方法は次のとおりです。 +簡単なハンドオフは、次のように作成できます。 ```python from agents import Agent, handoff @@ -30,22 +30,22 @@ refund_agent = Agent(name="Refund agent") triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)]) ``` -1. エージェントを直接使用することも( `billing_agent` のように)、 `handoff()` 関数を使用することもできます。 +1. エージェントを直接使用することも(`billing_agent` のように)、`handoff()` 関数を使用することもできます。 ### `handoff()` 関数によるハンドオフのカスタマイズ [`handoff()`][agents.handoffs.handoff] 関数を使用すると、さまざまな項目をカスタマイズできます。 -- `agent`: 処理を引き渡す先のエージェントです。 -- `tool_name_override`: デフォルトでは `Handoff.default_tool_name()` 関数が使用され、 `transfer_to_` に解決されます。これは上書きできます。 -- `tool_description_override`: `Handoff.default_tool_description()` から得られるデフォルトのツール説明を上書きします。 -- `on_handoff`: ハンドオフが呼び出されたときに実行されるコールバック関数です。ハンドオフが呼び出されることが分かった時点ですぐにデータ取得を開始する、といった用途に便利です。この関数はエージェントコンテキストを受け取り、任意で LLM が生成した入力も受け取れます。入力データは `input_type` パラメーターによって制御されます。 -- `input_type`: ハンドオフツール呼び出し引数のスキーマです。設定されている場合、解析されたペイロードが `on_handoff` に渡されます。 -- `input_filter`: これにより、次のエージェントが受け取る入力をフィルタリングできます。詳細は以下を参照してください。 -- `is_enabled`: ハンドオフが有効かどうかです。これはブール値、またはブール値を返す関数にでき、実行時にハンドオフを動的に有効化または無効化できます。 -- `nest_handoff_history`: RunConfig レベルの `nest_handoff_history` 設定に対する、呼び出しごとの任意の上書きです。 `None` の場合は、アクティブな実行設定で定義された値が代わりに使用されます。 +- `agent`: ハンドオフ先となるエージェントです。 +- `tool_name_override`: デフォルトでは `Handoff.default_tool_name()` 関数が使用され、`transfer_to_` に解決されます。これはオーバーライドできます。 +- `tool_description_override`: `Handoff.default_tool_description()` のデフォルトのツール説明をオーバーライドします。 +- `on_handoff`: ハンドオフが呼び出されたときに実行されるコールバック関数です。ハンドオフが呼び出されることが判明した時点で、データ取得を開始する場合などに役立ちます。この関数はエージェントコンテキストを受け取り、必要に応じて LLM が生成した入力も受け取れます。入力データは `input_type` パラメーターによって制御されます。 +- `input_type`: ハンドオフのツール呼び出し引数のスキーマです。設定すると、解析済みのペイロードが `on_handoff` に渡されます。 +- `input_filter`: 次のエージェントが受け取る入力をフィルタリングできます。詳細は以下を参照してください。 +- `is_enabled`: ハンドオフが有効かどうかを指定します。真偽値、または真偽値を返す関数を指定でき、実行時にハンドオフを動的に有効化または無効化できます。 +- `nest_handoff_history`: `RunConfig` レベルの `nest_handoff_history` 設定を呼び出し単位でオーバーライドする任意の設定です。`None` の場合は、代わりにアクティブな実行設定で定義されている値が使用されます。 -[`handoff()`][agents.handoffs.handoff] ヘルパーは、渡された特定の `agent` に常に制御を移します。複数の宛先候補がある場合は、宛先ごとに 1 つのハンドオフを登録し、モデルにその中から選ばせてください。独自のハンドオフコードが呼び出し時にどのエージェントを返すかを決定する必要がある場合にのみ、カスタム [`Handoff`][agents.handoffs.Handoff] を使用してください。 +[`handoff()`][agents.handoffs.handoff] ヘルパーは、渡された特定の `agent` に常に制御を移します。複数の移行先が考えられる場合は、移行先ごとにハンドオフを 1 つ登録し、モデルに選択させてください。呼び出し時にどのエージェントを返すかを独自のハンドオフコードで決定する必要がある場合にのみ、カスタムの [`Handoff`][agents.handoffs.Handoff] を使用してください。 ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## ハンドオフ入力 -状況によっては、 LLM がハンドオフを呼び出すときに何らかのデータを提供してほしい場合があります。たとえば、「エスカレーションエージェント」へのハンドオフを想像してみてください。ログに記録できるように、モデルに理由を提供してほしい場合があります。 +状況によっては、LLM がハンドオフを呼び出す際に、何らかのデータを提供するようにしたい場合があります。たとえば、「エスカレーションエージェント」へのハンドオフを想定します。ログに記録できるよう、モデルに理由を提供させることができます。 ```python from pydantic import BaseModel @@ -87,44 +87,44 @@ handoff_obj = handoff( ) ``` -`input_type` は、ハンドオフツール呼び出し自体の引数を表します。 SDK はそのスキーマをハンドオフツールの `parameters` としてモデルに公開し、返された JSON をローカルで検証して、解析済みの値を `on_handoff` に渡します。 +`input_type` は、ハンドオフのツール呼び出し自体の引数を記述します。SDK はそのスキーマをハンドオフツールの `parameters` としてモデルに公開し、返された JSON をローカルで検証して、解析済みの値を `on_handoff` に渡します。 -これは次のエージェントのメイン入力を置き換えるものではなく、別の宛先を選択するものでもありません。 [`handoff()`][agents.handoffs.handoff] ヘルパーは引き続き、ラップした特定のエージェントへ転送し、受け取り側のエージェントは [`input_filter`][agents.handoffs.Handoff.input_filter] またはネストされたハンドオフ履歴設定で変更しない限り、引き続き会話履歴を参照します。 +これは次のエージェントのメイン入力を置き換えるものではなく、別の移行先を選択するものでもありません。[`handoff()`][agents.handoffs.handoff] ヘルパーは引き続き、ラップした特定のエージェントに制御を移し、受け取る側のエージェントは、[`input_filter`][agents.handoffs.Handoff.input_filter] またはネストされたハンドオフ履歴の設定で変更しない限り、引き続き会話履歴を参照できます。 -`input_type` は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] とも別のものです。ローカルにすでにあるアプリケーション状態や依存関係ではなく、ハンドオフ時にモデルが決定するメタデータには `input_type` を使用してください。 +`input_type` は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] とも別のものです。`input_type` は、すでにローカルに存在するアプリケーションの状態や依存関係ではなく、ハンドオフ時にモデルが決定するメタデータに使用してください。 -### `input_type` の使用タイミング +### `input_type` の使用場面 -ハンドオフに `reason` 、 `language` 、 `priority` 、 `summary` など、モデルが生成する小さなメタデータが必要な場合に `input_type` を使用してください。たとえば、トリアージエージェントは `{ "reason": "duplicate_charge", "priority": "high" }` とともに返金エージェントへハンドオフでき、返金エージェントが引き継ぐ前に `on_handoff` でそのメタデータをログに記録したり永続化したりできます。 +ハンドオフに `reason`、`language`、`priority`、`summary` など、モデルが生成する少量のメタデータが必要な場合は、`input_type` を使用します。たとえば、トリアージエージェントは `{ "reason": "duplicate_charge", "priority": "high" }` を指定して返金エージェントにハンドオフでき、返金エージェントが引き継ぐ前に、`on_handoff` でそのメタデータをログに記録したり永続化したりできます。 -目的が異なる場合は、別の仕組みを選んでください。 +目的が異なる場合は、別の仕組みを選択してください。 -- 既存のアプリケーション状態と依存関係は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] に置いてください。[コンテキストガイド](context.md)を参照してください。 -- 受け取り側のエージェントが参照する履歴を変更したい場合は、 [`input_filter`][agents.handoffs.Handoff.input_filter] 、 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 、または [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] を使用してください。 -- 複数の専門エージェント候補がある場合は、宛先ごとに 1 つのハンドオフを登録してください。 `input_type` は選択されたハンドオフにメタデータを追加できますが、宛先間の振り分けは行いません。 -- 会話を引き渡さずに、ネストされた専門エージェントに構造化入力を渡したい場合は、 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool] を優先してください。[ツール](tools.md#structured-input-for-tool-agents)を参照してください。 +- 既存のアプリケーションの状態と依存関係は、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] に格納します。[コンテキストガイド](context.md)を参照してください。 +- 受け取る側のエージェントが参照する履歴を変更する場合は、[`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]、または [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] を使用します。 +- 複数の専門エージェントが移行先の候補となる場合は、移行先ごとにハンドオフを 1 つ登録します。`input_type` は選択されたハンドオフにメタデータを追加できますが、移行先を振り分けるものではありません。 +- 会話を移行せず、ネストされた専門エージェントに構造化された入力を渡す場合は、[`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool] を使用することを推奨します。[ツール](tools.md#structured-input-for-tool-agents)を参照してください。 ## 入力フィルター -ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、以前の会話履歴全体を参照できるようになります。これを変更したい場合は、 [`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、 [`HandoffInputData`][agents.handoffs.HandoffInputData] を通じて既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 +ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、それまでの会話履歴全体を参照できるようになります。これを変更する場合は、[`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、[`HandoffInputData`][agents.handoffs.HandoffInputData] を介して既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 -[`HandoffInputData`][agents.handoffs.HandoffInputData] には次が含まれます。 +[`HandoffInputData`][agents.handoffs.HandoffInputData] には、以下が含まれます。 -- `input_history`: `Runner.run(...)` が開始する前の入力履歴です。 -- `pre_handoff_items`: ハンドオフが呼び出されたエージェントターンより前に生成されたアイテムです。 -- `new_items`: ハンドオフ呼び出しとハンドオフ出力アイテムを含む、現在のターン中に生成されたアイテムです。 -- `input_items`: セッション履歴用に `new_items` をそのまま保ちながらモデル入力をフィルタリングできるよう、 `new_items` の代わりに次のエージェントへ転送する任意のアイテムです。 +- `input_history`: `Runner.run(...)` が開始される前の入力履歴です。 +- `pre_handoff_items`: ハンドオフが呼び出されたエージェントターンより前に生成された項目です。 +- `new_items`: ハンドオフ呼び出しとハンドオフ出力項目を含む、現在のターン中に生成された項目です。 +- `input_items`: `new_items` の代わりに次のエージェントへ転送する任意の項目です。セッション履歴では `new_items` をそのまま維持しながら、モデル入力をフィルタリングできます。 - `run_context`: ハンドオフが呼び出された時点でアクティブな [`RunContextWrapper`][agents.run_context.RunContextWrapper] です。 -ネストされたハンドオフはオプトインのベータとして利用でき、安定化が進むまではデフォルトで無効です。 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] を有効にすると、ランナーは以前の会話記録を 1 つの assistant 要約メッセージにまとめ、それを `` ブロックで包みます。このブロックには、同じ実行中に複数のハンドオフが発生した場合に新しいターンが追加され続けます。 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] を通じて独自のマッピング関数を提供し、完全な `input_filter` を書くことなく、生成されたメッセージを置き換えることができます。このオプトインは、ハンドオフと実行のどちらも明示的な `input_filter` を指定していない場合にのみ適用されます。そのため、ペイロードをすでにカスタマイズしている既存のコード(このリポジトリ内のコード例を含む)は、変更なしで現在の動作を維持します。単一のハンドオフに対してネスト動作を上書きするには、 [`handoff(...)`][agents.handoffs.handoff] に `nest_handoff_history=True` または `False` を渡します。これにより [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] が設定されます。生成された要約のラッパーテキストだけを変更したい場合は、エージェントを実行する前に [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出してください(必要に応じて [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] も呼び出せます)。 +ネストされたハンドオフは、オプトインのベータ機能として利用できますが、安定化を進めている間はデフォルトで無効になっています。[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] を有効にすると、ランナーは要約可能な履歴を順序付けられたアシスタント要約セグメントに圧縮する一方で、情報を失わないメッセージ項目を元の位置に保持します。生成される各要約セグメントでは `` ラッパーが使用され、後続のハンドオフでは、順序付きの会話記録を再構築する前に、以前に生成されたセグメントがフラット化されます。セッション、`RunState`、および `RunResult.to_input_list()` は、この SDK のデフォルト履歴に移されたメッセージの各出現を正確に追跡するため、それらが二重に追加されることはありません。一方、内容が同一でも別個のメッセージは引き続き保持されます。組み込みのセグメント化を使用せず、次のエージェントに渡す入力項目の正確なリストを返す独自のマッピング関数を、[`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] で指定できます。このオプトイン設定は、ハンドオフと実行のどちらにも明示的な `input_filter` が指定されていない場合にのみ適用されます。そのため、このリポジトリ内のコード例を含め、ペイロードをすでにカスタマイズしている既存のコードでは、変更せずに現在の動作が維持されます。単一のハンドオフに対してネスト動作をオーバーライドするには、[`handoff(...)`][agents.handoffs.handoff] に `nest_handoff_history=True` または `False` を渡します。これにより、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] が設定されます。生成される要約セグメントのラッパーテキストのみを変更する場合は、エージェントを実行する前に [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。必要に応じて、[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] も呼び出せます。 -ハンドオフとアクティブな [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] の両方がフィルターを定義している場合、その特定のハンドオフではハンドオフごとの [`input_filter`][agents.handoffs.Handoff.input_filter] が優先されます。 +ハンドオフとアクティブな [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] の両方でフィルターが定義されている場合、その特定のハンドオフでは、ハンドオフ単位の [`input_filter`][agents.handoffs.Handoff.input_filter] が優先されます。 !!! note - ハンドオフは単一の実行内にとどまります。入力ガードレールは引き続きチェーン内の最初のエージェントにのみ適用され、出力ガードレールは最終出力を生成するエージェントにのみ適用されます。ワークフロー内の各カスタム関数ツール呼び出しの周囲でチェックが必要な場合は、ツールガードレールを使用してください。 + ハンドオフは単一の実行内で行われます。入力ガードレールは引き続きチェーン内の最初のエージェントにのみ適用され、出力ガードレールは最終出力を生成するエージェントにのみ適用されます。ワークフロー内の各カスタム関数ツール呼び出しをチェックする必要がある場合は、ツールガードレールを使用してください。 -一般的なパターン(たとえば、履歴からすべてのツール呼び出しを削除するなど)がいくつかあり、 [`agents.extensions.handoff_filters`][] に実装されています。 +履歴からすべてのツール呼び出しを削除するなど、いくつかの一般的なパターンがあり、[`agents.extensions.handoff_filters`][] に実装されています。 ```python from agents import Agent, handoff @@ -138,11 +138,11 @@ handoff_obj = handoff( ) ``` -1. これにより、 `FAQ agent` が呼び出されたときに、履歴からすべてのツールが自動的に削除されます。 +1. これにより、`FAQ agent` が呼び出されたときに、履歴からすべてのツールが自動的に削除されます。 ## 推奨プロンプト -LLM がハンドオフを適切に理解できるように、エージェントにハンドオフに関する情報を含めることを推奨します。推奨されるプレフィックスを [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] に用意しています。または、 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] を呼び出して、推奨データをプロンプトに自動的に追加できます。 +LLM がハンドオフを適切に理解できるよう、エージェントにハンドオフに関する情報を含めることを推奨します。[`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] に推奨プレフィックスが用意されています。または、[`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] を呼び出して、推奨情報をプロンプトに自動的に追加できます。 ```python from agents import Agent diff --git a/docs/ja/human_in_the_loop.md b/docs/ja/human_in_the_loop.md index 938a6a0987..db48eafb09 100644 --- a/docs/ja/human_in_the_loop.md +++ b/docs/ja/human_in_the_loop.md @@ -2,25 +2,28 @@ search: exclude: true --- -# ヒューマンインザループ +# ヒューマン・イン・ザ・ループ -ヒューマンインザループ (HITL) フローを使用すると、人が慎重な扱いが必要なツール呼び出しを承認または拒否するまで、エージェントの実行を一時停止できます。ツールは承認が必要なタイミングを宣言し、実行結果は保留中の承認を中断として提示し、`RunState` によって判定後に実行をシリアライズして再開できます。 +人間が承認または拒否するまでエージェントの実行を一時停止するには、ヒューマン・イン・ザ・ループ (HITL) フローを使用します。ツールは承認が必要となる条件を宣言し、実行結果では保留中の承認が中断として提示されます。また、`RunState` を使用すると、決定後に実行をシリアライズして再開できます。 -その承認の提示先は実行全体であり、現在のトップレベルのエージェントに限定されません。同じパターンは、ツールが現在のエージェントに属する場合、ハンドオフを通じて到達したエージェントに属する場合、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] 実行に属する場合にも適用されます。ネストされた `Agent.as_tool()` の場合でも、中断は外側の実行に提示されるため、外側の `RunState` で承認または拒否し、元のトップレベルの実行を再開します。 +この承認の適用範囲は実行全体であり、現在のトップレベルエージェントだけに限定されません。ツールが現在のエージェントに属する場合、ハンドオフ先のエージェントに属する場合、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行に属する場合でも、同じパターンが適用されます。ネストされた `Agent.as_tool()` の場合も、中断は外側の実行に提示されるため、外側の `RunState` で承認または拒否し、元のトップレベル実行を再開します。 -`Agent.as_tool()` では、承認が 2 つの異なるレイヤーで発生する可能性があります。エージェントツール自体が `Agent.as_tool(..., needs_approval=...)` によって承認を要求でき、ネストされたエージェント内のツールも、ネストされた実行が開始した後に独自の承認を要求できます。どちらも同じ外側の実行の中断フローを通じて処理されます。 +`Agent.as_tool()` では、承認が 2 つの異なるレイヤーで発生する可能性があります。エージェントツール自体が `Agent.as_tool(..., needs_approval=...)` による承認を必要とする場合と、ネストされた実行の開始後に、ネストされたエージェント内のツールが独自の承認を要求する場合です。どちらも、外側の実行における同じ中断フローで処理されます。 -このページでは、`interruptions` を介した手動承認フローに焦点を当てます。アプリがコード内で判定できる場合、一部のツールタイプはプログラムによる承認コールバックにも対応しているため、実行を一時停止せずに続行できます。 +このページでは、`interruptions` を使用する手動承認フローに焦点を当てます。アプリケーションがコード内で判断できる場合、一部のツールタイプではプログラムによる承認コールバックもサポートされており、実行を一時停止せずに続行できます。 ## 承認が必要なツールの指定 -常に承認を要求するには `needs_approval` を `True` に設定するか、呼び出しごとに判定する async 関数を指定します。この呼び出し可能オブジェクトは、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 +常に承認を要求するには、`needs_approval` を `True` に設定します。または、呼び出しごとに判断する非同期関数を指定します。この呼び出し可能オブジェクトは、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 + +SDK が引数を安全に検査できない場合、呼び出し可能な承認ルールは安全側に倒れ、承認を必須とします。引数が不正な JSON である場合、有効な JSON でもオブジェクトではない場合(たとえば、`null` やリスト)、または `NaN`、`Infinity`、`-Infinity` などの非標準定数が含まれる場合、呼び出し可能オブジェクトは実行されず、その呼び出しには手動承認が必要です。この動作は、Runner と Realtime のツール呼び出しで同じです。 ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(needs_approval=True) +@tool(needs_approval=True) async def cancel_order(order_id: int) -> str: return f"Cancelled order {order_id}" @@ -29,7 +32,7 @@ async def requires_review(_ctx, params, _call_id) -> bool: return "refund" in params.get("subject", "").lower() -@function_tool(needs_approval=requires_review) +@tool(needs_approval=requires_review) async def send_email(subject: str, body: str) -> str: return f"Sent '{subject}'" @@ -41,28 +44,28 @@ agent = Agent( ) ``` -`needs_approval` は、[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]、[`ApplyPatchTool`][agents.tool.ApplyPatchTool] で利用できます。ローカル MCP サーバーも、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] の `require_approval` を通じて承認に対応しています。ホスト型 MCP サーバーは、`tool_config={"require_approval": "always"}` と任意の `on_approval_request` コールバックを設定した [`HostedMCPTool`][agents.tool.HostedMCPTool] によって承認に対応します。Shell と apply_patch ツールは、中断を提示せずに自動承認または自動拒否したい場合に `on_approval` コールバックを受け付けます。 +`needs_approval` は、[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]、[`ApplyPatchTool`][agents.tool.ApplyPatchTool] で使用できます。ローカル MCP サーバーも、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] の `require_approval` を通じて承認をサポートします。ホスト型 MCP サーバーでは、[`HostedMCPTool`][agents.tool.HostedMCPTool] に `tool_config={"require_approval": "always"}` とオプションの `on_approval_request` コールバックを指定することで、承認をサポートします。中断を提示せずに自動承認または自動拒否する場合、Shell および apply_patch ツールは `on_approval` コールバックを受け取ります。 ## 承認フローの仕組み -1. モデルがツール呼び出しを出力すると、ランナーはその承認ルール (`needs_approval`、`require_approval`、またはホスト型 MCP の同等機能) を評価します。 -2. そのツール呼び出しの承認判定がすでに [`RunContextWrapper`][agents.run_context.RunContextWrapper] に保存されている場合、ランナーは確認を求めずに処理を続行します。呼び出しごとの承認は特定の呼び出し ID にスコープされます。そのツールに対する今後の呼び出しに、実行の残りの間同じ判定を保持するには、`always_approve=True` または `always_reject=True` を渡します。 -3. それ以外の場合、実行は一時停止し、`RunResult.interruptions` (または `RunResultStreaming.interruptions`) に、`agent.name`、`tool_name`、`arguments` などの詳細を含む [`ToolApprovalItem`][agents.items.ToolApprovalItem] エントリが入ります。これには、ハンドオフ後やネストされた `Agent.as_tool()` 実行内で発生した承認も含まれます。 -4. 実行結果を `result.to_state()` で `RunState` に変換し、`state.approve(...)` または `state.reject(...)` を呼び出してから、`Runner.run(agent, state)` または `Runner.run_streamed(agent, state)` で再開します。ここで `agent` は、その実行における元のトップレベルのエージェントです。 -5. 再開された実行は中断した場所から続行し、新しい承認が必要になった場合はこのフローに再び入ります。 +1. モデルがツール呼び出しを出力すると、ランナーはその承認ルール(`needs_approval`、`require_approval`、またはホスト型 MCP における同等の設定)を評価します。 +2. そのツール呼び出しに対する承認決定が [`RunContextWrapper`][agents.run_context.RunContextWrapper] にすでに保存されている場合、ランナーは確認を求めずに続行します。呼び出し単位の承認は、特定の呼び出し ID に限定されます。実行の残りの期間、そのツールに対する今後の呼び出しにも同じ決定を適用するには、`always_approve=True` または `always_reject=True` を渡します。 +3. それ以外の場合、実行は一時停止し、`RunResult.interruptions`(または `RunResultStreaming.interruptions`)には、`agent.name`、`tool_name`、`arguments` などの詳細を含む [`ToolApprovalItem`][agents.items.ToolApprovalItem] エントリが格納されます。これには、ハンドオフ後またはネストされた `Agent.as_tool()` の実行内で要求された承認も含まれます。 +4. `result.to_state()` を使用して実行結果を `RunState` に変換し、`state.approve(...)` または `state.reject(...)` を呼び出した後、`Runner.run(agent, state)` または `Runner.run_streamed(agent, state)` で再開します。ここで `agent` は、その実行における元のトップレベルエージェントです。 +5. 再開された実行は中断箇所から続行され、新たな承認が必要になった場合は、このフローに再度入ります。 -`always_approve=True` または `always_reject=True` で作成された固定判定は実行状態に保存されるため、後で同じ一時停止中の実行を再開するときに `state.to_string()` / `RunState.from_string(...)` および `state.to_json()` / `RunState.from_json(...)` を使っても保持されます。 +`always_approve=True` または `always_reject=True` によって固定化された決定は実行状態に保存されるため、同じ一時停止中の実行を後で再開する際、`state.to_string()` / `RunState.from_string(...)` および `state.to_json()` / `RunState.from_json(...)` を使用しても保持されます。 -すべての保留中承認を同じ 1 回の処理で解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認が混在する場合があります。一部の項目だけを承認または拒否した後に再実行すると、解決済みの呼び出しは続行でき、未解決のものは `interruptions` に残って実行を再び一時停止します。 +1 回の処理ですべての保留中の承認を解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認が混在することがあります。一部の項目のみを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決の項目は `interruptions` に残り、実行は再び一時停止します。 ## カスタム拒否メッセージ -既定では、拒否されたツール呼び出しは SDK 標準の拒否テキストを実行内に返します。このメッセージは 2 つのレイヤーでカスタマイズできます。 +デフォルトでは、拒否されたツール呼び出しに対して、SDK の標準拒否テキストが実行に返されます。このメッセージは、次の 2 つのレイヤーでカスタマイズできます。 -- 実行全体のフォールバック: 実行全体で承認拒否に対するモデルに見える既定メッセージを制御するには、[`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter] を設定します。 -- 呼び出しごとのオーバーライド: 特定の拒否されたツール呼び出しだけに異なるメッセージを提示したい場合は、`state.reject(...)` に `rejection_message=...` を渡します。 +- 実行全体のフォールバック:[`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter] を設定すると、実行全体における承認拒否について、モデルに表示されるデフォルトメッセージを制御できます。 +- 呼び出し単位のオーバーライド:特定の拒否されたツール呼び出しに別のメッセージを返す場合は、`state.reject(...)` に `rejection_message=...` を渡します。 -両方が指定されている場合、呼び出しごとの `rejection_message` が実行全体のフォーマッターより優先されます。 +両方が指定されている場合、呼び出し単位の `rejection_message` が実行全体のフォーマッターより優先されます。 ```python from agents import RunConfig, ToolErrorFormatterArgs @@ -83,41 +86,42 @@ state.reject( ) ``` -両方のレイヤーをまとめて示す完全なコード例については、[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py) を参照してください。 +両方のレイヤーを組み合わせた完全なコード例については、[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py) を参照してください。 -## 自動承認判定 +## 自動承認の決定 -手動の `interruptions` は最も汎用的なパターンですが、唯一の方法ではありません。 +手動の `interruptions` は最も一般的なパターンですが、唯一の方法ではありません。 -- ローカルの [`ShellTool`][agents.tool.ShellTool] と [`ApplyPatchTool`][agents.tool.ApplyPatchTool] は、`on_approval` を使用してコード内で即座に承認または拒否できます。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] は、`tool_config={"require_approval": "always"}` と `on_approval_request` を組み合わせて、同じ種類のプログラムによる判定を行えます。 -- 通常の [`function_tool`][agents.tool.function_tool] ツールと [`Agent.as_tool()`][agents.agent.Agent.as_tool] は、このページの手動中断フローを使用します。 +- ローカルの [`ShellTool`][agents.tool.ShellTool] と [`ApplyPatchTool`][agents.tool.ApplyPatchTool] では、`on_approval` を使用してコード内で即座に承認または拒否できます。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] では、`tool_config={"require_approval": "always"}` と `on_approval_request` を組み合わせて、同様にプログラムで決定できます。 +- 通常の [`function_tool`][agents.tool.function_tool] ツールと [`Agent.as_tool()`][agents.agent.Agent.as_tool] では、このページで説明する手動中断フローを使用します。 -これらのコールバックが判定を返すと、人間の応答を待って一時停止することなく実行が続行されます。Realtime および音声セッション API については、[Realtime ガイド](realtime/guide.md) の承認フローを参照してください。 +これらのコールバックが決定を返すと、人間の応答を待って一時停止することなく実行が続行されます。Realtime および音声セッション API については、[Realtime ガイド](realtime/guide.md)の承認フローを参照してください。 ## ストリーミングとセッション -同じ中断フローはストリーミング実行でも機能します。ストリーミング実行が一時停止した後は、イテレーターが終了するまで [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] を消費し続け、[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] を確認して解決し、再開後の出力もストリーミングし続けたい場合は [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] で再開します。このパターンのストリーミング版については、[ストリーミング](streaming.md) を参照してください。 +同じ中断フローは、ストリーミング実行でも機能します。ストリーミング実行が一時停止した後、イテレーターが完了するまで [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] を消費し続け、[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] を確認して各項目を解決します。再開後の出力も引き続きストリーミングする場合は、[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] で再開します。このパターンのストリーミング版については、[ストリーミング](streaming.md)を参照してください。 -セッションも使用している場合は、`RunState` から再開するときに同じセッションインスタンスを渡し続けるか、同じバッキングストアを指す別のセッションオブジェクトを渡します。これにより、再開されたターンは同じ保存済み会話履歴に追加されます。セッションのライフサイクル詳細については、[セッション](sessions/index.md) を参照してください。 +セッションも使用している場合は、`RunState` から再開するときに同じセッションインスタンスを渡し続けるか、同じバックエンドストアを参照する別のセッションオブジェクトを渡します。これにより、再開後のターンが、保存済みの同じ会話履歴に追加されます。セッションのライフサイクルの詳細については、[セッション](sessions/index.md)を参照してください。 -## 例: 一時停止・承認・再開 +## 一時停止、承認、再開の例 -以下のスニペットは JavaScript の HITL ガイドと同じ流れです。ツールに承認が必要な場合に一時停止し、状態をディスクに永続化して再読み込みし、判定を収集した後に再開します。 +以下のスニペットは、JavaScript の HITL ガイドと同じ流れを示しています。ツールに承認が必要な場合に一時停止し、状態をディスクに永続化して再読み込みし、決定を取得した後に再開します。 ```python import asyncio import json from pathlib import Path -from agents import Agent, Runner, RunState, function_tool +from agents import Agent, Runner, RunState +from agents.decorators import tool async def needs_oakland_approval(_ctx, params, _call_id) -> bool: return "Oakland" in params.get("city", "") -@function_tool(needs_approval=needs_oakland_approval) +@tool(needs_approval=needs_oakland_approval) async def get_temperature(city: str) -> str: return f"The temperature in {city} is 20° Celsius" @@ -167,35 +171,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -この例では、`prompt_approval` は `input()` を使用し、`run_in_executor(...)` で実行されるため同期的です。承認の取得元がすでに非同期である場合 (たとえば、HTTP リクエストや非同期データベースクエリ)、代わりに `async def` 関数を使用して直接 `await` できます。 +この例では、`prompt_approval` は `input()` を使用し、`run_in_executor(...)` で実行されるため、同期関数です。承認元がすでに非同期である場合(たとえば、HTTP リクエストや非同期データベースクエリ)、`async def` 関数を使用し、直接 `await` できます。 -承認を待つ間に出力をストリーミングするには、`Runner.run_streamed` を呼び出し、完了するまで `result.stream_events()` を消費してから、上記と同じ `result.to_state()` と再開手順に従います。 +承認を待機しながら出力をストリーミングするには、`Runner.run_streamed` を呼び出し、完了するまで `result.stream_events()` を消費した後、上記と同じ `result.to_state()` および再開の手順に従います。 ## リポジトリのパターンとコード例 -- **ストリーミング承認**: `examples/agent_patterns/human_in_the_loop_stream.py` は、`stream_events()` を最後まで読み出し、その後 `Runner.run_streamed(agent, state)` で再開する前に保留中のツール呼び出しを承認する方法を示します。 -- **カスタム拒否テキスト**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` は、承認が拒否された場合に、実行レベルの `tool_error_formatter` と呼び出しごとの `rejection_message` オーバーライドを組み合わせる方法を示します。 -- **ツールとしてのエージェントの承認**: `Agent.as_tool(..., needs_approval=...)` は、委譲されたエージェントタスクにレビューが必要な場合に同じ中断フローを適用します。ネストされた中断も外側の実行に提示されるため、ネストされたエージェントではなく元のトップレベルのエージェントを再開してください。 -- **ローカル shell と apply_patch ツール**: `ShellTool` と `ApplyPatchTool` も `needs_approval` に対応しています。将来の呼び出しに備えて判定をキャッシュするには、`state.approve(interruption, always_approve=True)` または `state.reject(..., always_reject=True)` を使用します。自動判定には `on_approval` を指定します (`examples/tools/shell.py` を参照)。手動判定には中断を処理します (`examples/tools/shell_human_in_the_loop.py` を参照)。ホスト型 shell 環境は `needs_approval` または `on_approval` に対応していません。[ツールガイド](tools.md) を参照してください。 -- **ローカル MCP サーバー**: MCP ツール呼び出しを制御するには、`MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` の `require_approval` を使用します (`examples/mcp/get_all_mcp_tools_example/main.py` と `examples/mcp/tool_filter_example/main.py` を参照)。 -- **ホスト型 MCP サーバー**: HITL を強制するには、`HostedMCPTool` で `require_approval` を `"always"` に設定し、必要に応じて自動承認または拒否のために `on_approval_request` を指定します (`examples/hosted_mcp/human_in_the_loop.py` と `examples/hosted_mcp/on_approval.py` を参照)。信頼済みサーバーには `"never"` を使用します (`examples/hosted_mcp/simple.py`)。 -- **セッションとメモリ**: セッションを `Runner.run` に渡すと、承認と会話履歴が複数ターンにわたって保持されます。SQLite と OpenAI Conversations のセッション版は、`examples/memory/memory_session_hitl_example.py` と `examples/memory/openai_session_hitl_example.py` にあります。 -- **Realtime エージェント**: Realtime デモでは、`RealtimeSession` の `approve_tool_call` / `reject_tool_call` を介してツール呼び出しを承認または拒否する WebSocket メッセージを公開しています (サーバー側ハンドラーについては `examples/realtime/app/server.py`、API サーフェスについては [Realtime ガイド](realtime/guide.md#tool-approvals) を参照)。 +- **ストリーミング承認**: `examples/agent_patterns/human_in_the_loop_stream.py` は、`stream_events()` を最後まで消費し、保留中のツール呼び出しを承認してから、`Runner.run_streamed(agent, state)` で再開する方法を示します。 +- **カスタム拒否テキスト**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` は、承認が拒否された場合に、実行レベルの `tool_error_formatter` と呼び出し単位の `rejection_message` オーバーライドを組み合わせる方法を示します。 +- **エージェントツールの承認**: `Agent.as_tool(..., needs_approval=...)` は、委任されたエージェントタスクにレビューが必要な場合も、同じ中断フローを適用します。ネストされた中断も外側の実行に提示されるため、ネストされたエージェントではなく、元のトップレベルエージェントを再開してください。 +- **ローカルの Shell および apply_patch ツール**: `ShellTool` と `ApplyPatchTool` も `needs_approval` をサポートします。今後の呼び出しに対する決定をキャッシュするには、`state.approve(interruption, always_approve=True)` または `state.reject(..., always_reject=True)` を使用します。自動決定には `on_approval` を指定し(`examples/tools/shell.py` を参照)、手動決定には中断を処理します(`examples/tools/shell_human_in_the_loop.py` を参照)。ホスト型 Shell 環境は `needs_approval` または `on_approval` をサポートしていません。[ツールガイド](tools.md)を参照してください。 +- **ローカル MCP サーバー**: `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` の `require_approval` を使用して、MCP ツール呼び出しを承認対象として制御します(`examples/mcp/get_all_mcp_tools_example/main.py` および `examples/mcp/tool_filter_example/main.py` を参照)。 +- **ホスト型 MCP サーバー**: HITL を強制するには、`HostedMCPTool` の `require_approval` を `"always"` に設定します。必要に応じて、自動承認または自動拒否のために `on_approval_request` を指定できます(`examples/hosted_mcp/human_in_the_loop.py` および `examples/hosted_mcp/on_approval.py` を参照)。信頼できるサーバーには `"never"` を使用します(`examples/hosted_mcp/simple.py`)。 +- **セッションとメモリ**: 承認と会話履歴を複数のターンにわたって保持するには、`Runner.run` にセッションを渡します。SQLite および OpenAI Conversations のセッション版は、`examples/memory/memory_session_hitl_example.py` と `examples/memory/openai_session_hitl_example.py` にあります。 +- **Realtime エージェント**: Realtime デモでは、`RealtimeSession` の `approve_tool_call` / `reject_tool_call` を介してツール呼び出しを承認または拒否する WebSocket メッセージを公開しています(サーバー側のハンドラーについては `examples/realtime/app/server.py`、API の仕様については [Realtime ガイド](realtime/guide.md#tool-approvals)を参照)。 ## 長時間にわたる承認 -`RunState` は耐久性を持つように設計されています。`state.to_json()` または `state.to_string()` を使用して保留中の作業をデータベースまたはキューに保存し、後で `RunState.from_json(...)` または `RunState.from_string(...)` で再作成します。 +`RunState` は、永続的に使用できるよう設計されています。`state.to_json()` または `state.to_string()` を使用して保留中の作業をデータベースやキューに保存し、後から `RunState.from_json(...)` または `RunState.from_string(...)` で復元できます。 -便利なシリアライズオプション: +便利なシリアライズオプションは次のとおりです。 -- `context_serializer`: 非マッピングのコンテキストオブジェクトのシリアライズ方法をカスタマイズします。 -- `context_deserializer`: `RunState.from_json(...)` または `RunState.from_string(...)` で状態を読み込むときに、非マッピングのコンテキストオブジェクトを再構築します。 -- `strict_context=True`: コンテキストがすでにマッピングであるか、適切なシリアライザー / デシリアライザーを指定している場合を除き、シリアライズまたはデシリアライズを失敗させます。 -- `context_override`: 状態を読み込むときに、シリアライズされたコンテキストを置き換えます。これは、元のコンテキストオブジェクトを復元したくない場合に便利ですが、すでにシリアライズ済みのペイロードからそのコンテキストを削除するわけではありません。 -- `include_tracing_api_key=True`: 再開された作業で同じ認証情報を使ってトレースのエクスポートを継続する必要がある場合、シリアライズされたトレースペイロードにトレーシング API キーを含めます。 +- `context_serializer`: マッピングではないコンテキストオブジェクトのシリアライズ方法をカスタマイズします。 +- `context_deserializer`: `RunState.from_json(...)` または `RunState.from_string(...)` で状態を読み込む際に、マッピングではないコンテキストオブジェクトを再構築します。 +- `strict_context=True`: コンテキストがすでにマッピングであるか、適切なシリアライザーまたはデシリアライザーが指定されていない限り、シリアライズまたはデシリアライズを失敗させます。 +- `context_override`: 状態の読み込み時に、シリアライズされたコンテキストを置き換えます。元のコンテキストオブジェクトを復元したくない場合に便利ですが、すでにシリアライズ済みのペイロードからそのコンテキストを削除するものではありません。 +- `include_tracing_api_key=True`: 再開した作業で同じ認証情報を使用してトレースのエクスポートを継続する必要がある場合、シリアライズされたトレースペイロードにトレーシング API キーを含めます。 -シリアライズされた実行状態には、アプリのコンテキストに加えて、承認、使用量、シリアライズ済みの `tool_input`、ネストされた agent-as-tool の再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK 管理のランタイムメタデータが含まれます。シリアライズされた状態を保存または送信する予定がある場合は、`RunContextWrapper.context` を永続化データとして扱い、状態と一緒に移動させる意図がある場合を除き、そこにシークレットを置かないでください。 +シリアライズされた実行状態には、アプリケーションのコンテキストに加え、承認、使用量、シリアライズされた `tool_input`、ネストされたエージェントツール実行の再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK が管理するランタイムメタデータが含まれます。シリアライズされた状態を保存または送信する場合は、`RunContextWrapper.context` を永続化対象データとして扱い、状態とともに意図的に保持または送信したい場合を除き、そこに機密情報を保存しないでください。 -## 保留中タスクのバージョニング +## 保留中タスクのバージョン管理 -承認がしばらく保留される可能性がある場合は、エージェント定義または SDK のバージョンマーカーを、シリアライズされた状態と一緒に保存してください。これにより、モデル、プロンプト、ツール定義が変更された場合の非互換性を避けるために、対応するコードパスへデシリアライズ処理を振り分けられます。 \ No newline at end of file +承認が長期間保留される可能性がある場合は、シリアライズされた状態とともに、エージェント定義または SDK のバージョンマーカーを保存してください。これにより、モデル、プロンプト、ツール定義が変更された場合でも、デシリアライズ処理を対応するコードパスに振り分け、非互換性を回避できます。 \ No newline at end of file diff --git a/docs/ja/models/index.md b/docs/ja/models/index.md index d781836606..36c98a6569 100644 --- a/docs/ja/models/index.md +++ b/docs/ja/models/index.md @@ -4,36 +4,36 @@ search: --- # モデル -Agents SDK は、すぐに利用できる OpenAI モデルを次の 2 つの形態でサポートしています。 +Agents SDK には、OpenAI モデルを利用するための次の 2 種類のサポートが標準で用意されています。 - **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] - [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] ## モデル設定の選択 -設定に合う最もシンプルな方法から始めてください。 +まずは、設定に適した最もシンプルな方法を選択してください。 -| 目的 | 推奨される方法 | 詳細 | +| 実現したいこと | 推奨方法 | 詳細 | | --- | --- | --- | -| OpenAI モデルのみを使用する | Responses モデルのパスでデフォルトの OpenAI プロバイダーを使用する | [OpenAI モデル](#openai-models) | -| WebSocket トランスポート経由で OpenAI Responses API を使用する | Responses モデルのパスを維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | +| OpenAI モデルのみを使用する | Responses モデルの経路でデフォルトの OpenAI プロバイダーを使用する | [OpenAI モデル](#openai-models) | +| WebSocket トランスポート経由で OpenAI Responses API を使用する | Responses モデルの経路を維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | | OpenAI がホストするサブエージェントを使用する | 実験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | | OpenAI 以外のプロバイダーを 1 つ使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI 以外のモデル](#non-openai-models) | -| エージェント間でモデルまたはプロバイダーを混在させる | 実行単位またはエージェント単位でプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの混在](#mixing-models-in-one-workflow)および[プロバイダーをまたいだモデルの混在](#mixing-models-across-providers) | -| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses のパスで `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | -| OpenAI 以外または複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポート対象のベータ版アダプターを比較し、リリース予定のプロバイダーパスを検証する | [サードパーティ製アダプター](#third-party-adapters) | +| エージェント間でモデルやプロバイダーを混在させる | 実行単位またはエージェント単位でプロバイダーを選択し、機能の違いを確認する | [1 つのワークフロー内でのモデルの混在](#mixing-models-in-one-workflow)および[プロバイダー間でのモデルの混在](#mixing-models-across-providers) | +| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses の経路で `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | +| OpenAI 以外のプロバイダー、または複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポート対象のベータ版アダプターを比較し、リリース予定のプロバイダー経路を検証する | [サードパーティ製アダプター](#third-party-adapters) | ## OpenAI モデル -OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーでモデル名の文字列を使用し、Responses モデルのパスを維持することを推奨します。 +OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルの経路を維持する方法を推奨します。 -`Agent` の初期化時にモデルを指定しない場合、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` と `verbosity="low"` を設定した [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。利用できる場合は、明示的な `model_settings` を維持しつつ、より高品質な `gpt-5.6-sol` をエージェントに設定することを推奨します。 +`Agent` の初期化時にモデルを指定しない場合は、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` および `verbosity="low"` が設定された [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。利用できる場合は、明示的な `model_settings` を維持しながら、より高い品質を得るためにエージェントを `gpt-5.6-sol` に設定することを推奨します。 `gpt-5.6-sol` などの別のモデルへ切り替える場合、エージェントを設定する方法は 2 つあります。 ### デフォルトモデル -まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用する場合は、エージェントを実行する前に `OPENAI_DEFAULT_MODEL` 環境変数を設定します。 +まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用するには、エージェントを実行する前に `OPENAI_DEFAULT_MODEL` 環境変数を設定します。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 モデル -この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースに最適な設定が適用されます。デフォルトモデルの推論労力を調整するには、独自の `ModelSettings` を渡します。 +この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースで最適に動作する設定が適用されます。デフォルトモデルの推論エフォートを調整するには、独自の `ModelSettings` を渡します。 ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -レイテンシーを下げるには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 +レイテンシーを低くするには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 -GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、永続化された推論コンテキスト、および `"max"` 労力レベルもサポートします。これらの制御は Responses API のパスで利用できます。 +GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、永続化された推論コンテキスト、および `"max"` エフォートレベルもサポートします。これらの制御は Responses API の経路で使用できます。 ```python from openai.types.shared import Reasoning @@ -96,37 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされる労力レベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` 労力には Responses API を使用してください。Chat Completions アダプターは、警告を出してモードとコンテキストを無視します。この警告をエラーにするには、OpenAI プロバイダーで `strict_feature_validation=True` を設定してください。 +`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされるエフォートレベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` エフォートには Responses API を使用してください。Chat Completions アダプターは警告を出してモードとコンテキストを無視します。この警告をエラーにするには、OpenAI プロバイダーで `strict_feature_validation=True` を設定してください。 -`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の会話、または以前の推論項目の再送によって会話を維持してください。ステートレスな `store=False` 呼び出しでは、レスポンスに `reasoning.encrypted_content` を含め、次のリクエストでそれらの推論項目を再送してください。 +`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の会話、または以前の推論項目の再送によって会話を保持してください。ステートレスな `store=False` 呼び出しでは、レスポンスに `reasoning.encrypted_content` を含め、次のリクエストでそれらの推論項目を再送してください。 #### ComputerTool のモデル選択 -エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれている場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは、GA の組み込み `computer` ツールが使用されます。一方、明示的な `computer-use-preview` リクエストでは、従来の `computer_use_preview` ペイロードが維持されます。 +エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれている場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは GA 版の組み込み `computer` ツールが使用され、明示的な `computer-use-preview` リクエストでは従来の `computer_use_preview` ペイロードが維持されます。 -主な例外は、プロンプト管理の呼び出しです。プロンプトテンプレートがモデルを所有し、SDK がリクエストから `model` を省略する場合、SDK はプロンプトに固定されているモデルを推測しないよう、デフォルトでプレビュー互換のコンピューターペイロードを使用します。このフローで GA のパスを維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制してください。 +主な例外は、プロンプトで管理される呼び出しです。プロンプトテンプレート側でモデルが指定され、SDK がリクエストから `model` を省略する場合、SDK はプロンプトに固定されたモデルを推測しないよう、プレビュー互換のコンピューターペイロードをデフォルトで使用します。このフローで GA 版の経路を維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制します。 -[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は通常の関数名として引き続き動作します。 -プレビュー互換のリクエストでは、`environment` と表示寸法を事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエスト送信前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 +プレビュー互換のリクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具象 `Computer` または `AsyncComputer` インスタンスを渡すか、リクエスト送信前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 #### GPT-5 以外のモデル -カスタム `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK は任意のモデルと互換性のある汎用の `ModelSettings` に戻します。 +カスタム `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はあらゆるモデルと互換性のある汎用 `ModelSettings` に戻します。 -### Responses 専用のツール検索機能 +### Responses 専用のツール機能 -次のツール機能は、OpenAI Responses モデルでのみサポートされています。 +次のツール機能は、OpenAI Responses モデルでのみサポートされます。 - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` およびその他の遅延読み込み対応 Responses ツールサーフェス +- `@function_tool(defer_loading=True)` および遅延読み込みを使用するその他の Responses ツールサーフェス +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`、および `tool_choice="programmatic_tool_calling"` -これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、単独の名前空間名や遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませてください。設定の詳細と現在の制約については、[ツール](../tools.md#hosted-tool-search)を参照してください。 +これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、単独の名前空間名や遅延読み込み専用の関数名を強制する代わりに、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませてください。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 ### Responses WebSocket トランスポート -デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI を利用するモデルでは、WebSocket トランスポートを明示的に有効化できます。 +デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI を基盤とするモデルを使用する場合は、WebSocket トランスポートをオプトインで有効にできます。 #### 基本設定 @@ -136,9 +137,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデルに影響します。これには `"gpt-5.6-sol"` などのモデル名の文字列も含まれます。 +これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデルに影響します。`"gpt-5.6-sol"` などの文字列のモデル名も含まれます。 -トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions を使用し続けます。`RunConfig(model_provider=...)` を渡す場合、グローバルデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 +トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具象 [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions を使用します。`RunConfig(model_provider=...)` を渡した場合、グローバルデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 #### プロバイダー単位または実行単位の設定 @@ -163,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI を利用するプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAI の設定でハーネス ID などのプロバイダー単位の登録メタデータが必要な場合に使用する高度なオプションです。 +OpenAI を基盤とするプロバイダーでは、オプションのエージェント登録設定も使用できます。これは、ハーネス ID など、プロバイダー単位の登録メタデータを OpenAI の設定で必要とする場合の高度なオプションです。 ```python from agents import ( @@ -187,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider` による高度なルーティング +#### `MultiProvider` を使用した高度なルーティング -プレフィックスベースのモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を混在させる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定してください。 +プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を混在させる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 -`MultiProvider` は、次の 2 つの従来からのデフォルト動作を維持します。 +`MultiProvider` は、従来からの次の 2 つのデフォルト動作を維持します。 - `openai/...` は OpenAI プロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 -- 不明なプレフィックスは、そのまま渡されるのではなく `UserError` を発生させます。 +- 不明なプレフィックスはそのまま渡されず、`UserError` が発生します。 -リテラルの名前空間付きモデル ID を要求する OpenAI 互換エンドポイントに OpenAI プロバイダーを接続する場合は、パススルー動作を明示的に有効にしてください。WebSocket を有効にした設定では、`MultiProvider` でも `openai_use_responses_websocket=True` を維持してください。 +リテラルの名前空間付きモデル ID を必要とする OpenAI 互換エンドポイントへ OpenAI プロバイダーを接続する場合は、パススルー動作を明示的に有効にしてください。WebSocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を設定したままにします。 ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -222,9 +223,9 @@ result = await Runner.run( ) ``` -バックエンドがリテラルの `openai/...` 文字列を要求する場合は、`openai_prefix_mode="model_id"` を使用してください。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を要求する場合は、`unknown_prefix_mode="model_id"` を使用してください。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも機能します。この例では、このセクションで説明しているトランスポート設定の一部であるため、WebSocket を有効にしたままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 +バックエンドがリテラルの `openai/...` 文字列を必要とする場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を必要とする場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも使用できます。この例では、このセクションで説明するトランスポート設定の一部であるため、WebSocket を有効にしたままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも使用できます。 -`MultiProvider` を介してルーティングする際に同じプロバイダー単位の登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡してください。基盤となる OpenAI プロバイダーへ転送されます。 +`MultiProvider` 経由でルーティングする際に同じプロバイダー単位の登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡すと、基盤となる OpenAI プロバイダーへ転送されます。 カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 @@ -232,15 +233,15 @@ result = await Runner.run( - これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions や OpenAI 以外のプロバイダーには、それらが Responses WebSocket `/responses` エンドポイントをサポートしていない限り適用されません。 - 環境にまだインストールされていない場合は、`websockets` パッケージをインストールしてください。 -- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンのワークフローで、ターン間およびネストされたエージェントのツール呼び出し間で同じ WebSocket 接続を再利用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 -- 長い推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズしてください。遅延した pong フレームを許容するには `ping_timeout` を増やすか、ping を有効にしたままハートビートタイムアウトを無効にするには `ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを優先してください。 -- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後で長時間稼働するエージェントプロセスや、メモリに制約のあるコンテナでは、`responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定して、メッセージ単位のメモリ使用量に上限を設けてください。 +- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンにわたり同じ WebSocket 接続を再利用するワークフローでは、ネストされたエージェントをツールとして使用する呼び出しも含め、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 +- 長時間の推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズしてください。遅延した pong フレームを許容するには `ping_timeout` を増やすか、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを選択してください。 +- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後で動作する長寿命のエージェントプロセスや、メモリ制約のあるコンテナーでは、`responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定して、メッセージ単位のメモリ使用量に上限を設けてください。 ### ホスト型マルチエージェント(実験的) OpenAI Responses API のホスト型マルチエージェントベータでは、GPT-5.6 のルートモデルがサーバーでホストされるサブエージェントを作成し、連携させることができます。Agents SDK は通常の `Runner` を引き続き使用できます。ホスト型オーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 -この統合は実験的であり、ローカル関数の出力を `response.inject` によってアクティブなホスト型エージェントへ返せるよう、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開するベータビルドを含む `openai[realtime]>=2.45.0` が必要です。インターフェースとベータ項目のスキーマは、一般提供前に変更される可能性があります。 +この統合は実験的であり、ローカル関数の出力を `response.inject` によってアクティブなホスト型エージェントへ返せるよう、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開するベータビルドを含む `openai[realtime]>=2.45.0` が必要です。インターフェースとベータ版の項目スキーマは、一般提供前に変更される可能性があります。 #### モデルの設定 @@ -257,22 +258,22 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` が指定されていない場合、モデルはデフォルトの OpenAI クライアントを使用します。`max_concurrent_subagents` を省略すると、サービスのデフォルト値が使用されます。 +`OpenAIHostedMultiAgentModel` を構築すると、`multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` が指定されていない場合、モデルはデフォルトの OpenAI クライアントを使用します。`max_concurrent_subagents` を省略した場合は、サービスのデフォルトが使用されます。 #### ローカル関数ツール -すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは Responses API が決定します。通常の SDK Runner が関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスへ注入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、Runner の通常のガードレール、フック、失敗変換が引き続き適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエスト送信前に拒否されます。 +すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスへ注入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、Runner の通常のガードレール、フック、および失敗変換が引き続き適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 -ツールで呼び出し元を認識したログ記録または認可が必要な場合は、`get_hosted_agent_metadata()` を使用してください。 +ツールで呼び出し元を考慮したログ記録や認可が必要な場合は、`get_hosted_agent_metadata()` を使用します。 ```python from typing import Any -from agents import function_tool +from agents.decorators import tool from agents.extensions.experimental.hosted_multi_agent import get_hosted_agent_metadata from agents.tool_context import ToolContext -@function_tool +@tool def lookup_document(ctx: ToolContext[Any], section: str) -> str: metadata = get_hosted_agent_metadata(ctx) caller = metadata.agent_name if metadata else "unknown" @@ -280,50 +281,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -ホスト型エージェントの名前は観測用メタデータであり、ローカルのルーティング機構ではありません。SDK から提供される呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、必要な認可をツール実行前または実行中にアプリケーションコードで適用してください。このモデルで `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 +ホスト型エージェント名は観測用メタデータであり、ローカルのルーティングメカニズムではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、必要な認可をツール実行前または実行中にアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えて送受信されます。 #### 出力とストリーミングの動作 -フェーズが `final_answer` で、`/root` に帰属するメッセージのみが通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションの記録を高レベルの `RunResult` から除外します。SDK がそれらの記録をローカル関数として実行することはありません。 +フェーズが `final_answer` で、`/root` に帰属するメッセージのみが通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がこれらのレコードをローカル関数として実行することはありません。 -raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含む、ベータ版 Responses イベントが引き続き公開されます。関数呼び出しの準備ができると、アダプターは 1 つのアクティブなプロバイダーレスポンスを SDK から見える論理的なモデルターンへ分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。帰属情報を調べるには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用してください。 +raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含む、ベータ版の Responses イベントが引き続き公開されます。関数呼び出しの準備が整うと、アダプターは 1 つのアクティブなプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。帰属を確認するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用してください。 #### SDK オーケストレーションとの関係 -ホスト型マルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 +ホスト型マルチエージェントは、SDK のハンドオフおよび agents-as-tools とは異なります。 -- ホスト型マルチエージェントは、OpenAI サービス上でサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 -- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この実験的モデルを使用する場合、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が生じるため、ハンドオフは拒否されます。 -- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、ツールの公開範囲を慎重に評価してください。 +- ホスト型マルチエージェントは、OpenAI サービス上にサブエージェントを作成します。アプリケーションがこれらのサブエージェントを作成またはスケジュールすることはありません。 +- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この実験的モデルを使用する場合、すべてのホスト型エージェントが同じハンドオフツールを受け取って所有権の競合が生じるため、ハンドオフは拒否されます。 +- agents-as-tools は引き続き使用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 -#### 現在の制限 +#### 現在の制限事項 -実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版でサポートされていません。ただし、サービスが各ホスト型エージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 +実験的モデルでは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドが拒否されます。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスが各ホスト型エージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 -1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に所有できるアクティブなホスト型レスポンスは最大 1 つです。ローカル関数の出力を待機中に実行を中断した場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 +1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に所有できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待機している間に実行を放棄する場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 -基盤となる Responses API ベータの動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 +基盤となる Responses API ベータ版の動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 ## OpenAI 以外のモデル -OpenAI 以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくてもこれで十分です。各パターンのコード例は [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 +OpenAI 以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくても十分です。各パターンのコード例は [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 ### OpenAI 以外のプロバイダーの統合方法 | 方法 | 使用する状況 | 適用範囲 | | --- | --- | --- | | [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI 互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行単位 | -| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェント単位 | -| サードパーティ製アダプター | 組み込みの方法では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合 | [サードパーティ製アダプター](#third-party-adapters)を参照 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを 1 回の実行に適用する場合 | 実行単位 | +| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具象モデルオブジェクトが必要な場合 | エージェント単位 | +| サードパーティ製アダプター | 組み込みの経路では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合 | [サードパーティ製アダプター](#third-party-adapters)を参照 | -次の組み込み方法を使用して、その他の LLM プロバイダーを統合できます。 +次の組み込みの経路を使用して、他の LLM プロバイダーを統合できます。 -1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。LLM プロバイダーに OpenAI 互換 API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで使用します。これにより、「この実行のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 -3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスでモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせて使用できます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 +1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーが OpenAI 互換 API エンドポイントを備え、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで適用されます。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 +3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスにモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせて使用できます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 -`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` を使用してトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 +`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` でトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -338,11 +339,11 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - これらのコード例では Chat Completions API/モデルを使用しています。これは、多くの LLM プロバイダーがまだ Responses API をサポートしていないためです。LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 + これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。使用する LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 -## 1 つのワークフローでのモデルの混在 +## 1 つのワークフロー内でのモデルの混在 -単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する場合、次のいずれかの方法で特定のモデルを選択できます。 +1 つのワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する際は、次のいずれかの方法で特定のモデルを選択できます。 1. モデル名を渡します。 2. 任意のモデル名と、その名前を Model インスタンスへマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡します。 @@ -350,7 +351,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、2 つの形式ではサポートされる機能とツールが異なるため、各ワークフローで単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 + SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、この 2 つの形式ではサポートする機能とツールのセットが異なるため、ワークフローごとに 1 つのモデル形式を使用することを推奨します。ワークフローで複数のモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 ```python import asyncio @@ -406,22 +407,22 @@ english_agent = Agent( ## OpenAI Responses の高度な設定 -OpenAI Responses のパスでより詳細な制御が必要な場合は、`ModelSettings` から始めてください。 +OpenAI Responses の経路を使用していて、より詳細な制御が必要な場合は、まず `ModelSettings` を使用してください。 ### 一般的な高度な `ModelSettings` オプション -OpenAI Responses API を使用する場合、複数のリクエストフィールドには対応する `ModelSettings` フィールドがすでに用意されているため、それらに `extra_args` を使用する必要はありません。 +OpenAI Responses API を使用する場合、複数のリクエストフィールドにはすでに対応する `ModelSettings` フィールドがあるため、それらに `extra_args` を使用する必要はありません。 -- `parallel_tool_calls`: 同じターンで複数のツール呼び出しを許可または禁止します。 -- `truncation`: コンテキストが上限を超える場合に失敗する代わりに、Responses API が最も古い会話項目を削除できるよう、`"auto"` を設定します。 -- `store`: 生成されたレスポンスを、後で取得できるようサーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに影響します。 +- `parallel_tool_calls`: 同じターン内で複数のツール呼び出しを許可または禁止します。 +- `truncation`: コンテキストが上限を超える場合に失敗する代わりに、Responses API が最も古い会話項目を削除できるようにするには、`"auto"` を設定します。 +- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローで重要です。 - `context_management`: `compact_threshold` を使用した Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 -- `prompt_cache_retention`: 以前のモデルファミリー向けの保持期間延長を設定します。たとえば、 - `"24h"` を指定します。 +- `prompt_cache_retention`: 以前のモデルファミリー向けの延長保持期間を、たとえば + `"24h"` で設定します。 - `prompt_cache_options`: 暗黙的または明示的なプロンプトキャッシュを選択し、GPT-5.6 では `"30m"` のキャッシュ TTL を設定します。 -- `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードをリクエストします。 -- `top_logprobs`: 出力テキストの上位トークン logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 -- `retry`: モデル呼び出しに対する Runner 管理の再試行設定を有効にします。[Runner 管理の再試行](#runner-managed-retries)を参照してください。 +- `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードを要求します。 +- `top_logprobs`: 出力テキストについて上位トークンの logprobs を要求します。SDK は `message.output_text.logprobs` も自動的に追加します。 +- `retry`: モデル呼び出しに対して Runner が管理する再試行設定をオプトインで有効にします。[Runner が管理する再試行](#runner-managed-retries)を参照してください。 ```python from agents import Agent, ModelSettings @@ -441,7 +442,7 @@ research_agent = Agent( ) ``` -明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストでそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、ファイルのコンテンツ部分にあるブレークポイントを維持します。 +明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストにそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、ファイルの各コンテンツ部分に設定されたブレークポイントを保持します。 ```python from agents import Runner @@ -467,19 +468,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーでも引き続き利用できます。 -`ModelSettings` の直接フィールドと同じキーを -`extra_args` で併用しないでください。 +`prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーで引き続き利用できます。 +`ModelSettings` の直接フィールドと、`extra_args` 内の同じキーを併用しないでください。 -`store=False` を設定すると、Responses API はそのレスポンスを後からサーバー側で取得できるよう保持しません。これはステートレスまたはゼロデータ保持形式のフローに便利ですが、通常はレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、最後のレスポンスが保存されていない場合、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] はデフォルトの `"auto"` 圧縮パスを入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 +`store=False` を設定すると、Responses API は後でサーバー側から取得できるようにそのレスポンスを保持しません。これは、ステートレスまたはゼロデータ保持形式のフローに便利ですが、通常はレスポンス ID を再利用する機能が、代わりにローカルで管理される状態へ依存する必要があることも意味します。たとえば、最後のレスポンスが保存されていない場合、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] はデフォルトの `"auto"` 圧縮経路を入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -サーバー側の圧縮は [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を出力できます。`OpenAIResponsesCompactionSession` はターン間で独立した `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 +サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間でスタンドアロンの `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 ### `extra_args` の受け渡し -SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用してください。 +SDK がトップレベルでまだ直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 -また、OpenAI の Responses API を使用する場合、[その他にもいくつかのオプションパラメーターがあります](https://platform.openai.com/docs/api-reference/responses/create)(`user`、`service_tier` など)。トップレベルで利用できない場合は、`extra_args` を使用してそれらを渡すこともできます。同じリクエストフィールドを `ModelSettings` の直接フィールドでも設定しないでください。 +また、OpenAI の Responses API を使用する場合、[その他にもいくつかのオプションパラメーターがあります](https://platform.openai.com/docs/api-reference/responses/create)(`user`、`service_tier` など)。トップレベルで利用できない場合は、`extra_args` を使用して渡すこともできます。同じリクエストフィールドを `ModelSettings` の直接フィールドでも設定しないでください。 ```python from agents import Agent, ModelSettings @@ -495,9 +495,9 @@ english_agent = Agent( ) ``` -## Runner 管理の再試行 +## Runner が管理する再試行 -再試行は実行時にのみ適用され、明示的な有効化が必要です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 +再試行はランタイム専用であり、オプトインです。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は通常のモデルリクエストを再試行しません。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -529,66 +529,66 @@ agent = Agent(
-| フィールド | 型 | 注記 | +| フィールド | 型 | 注意事項 | | --- | --- | --- | | `max_retries` | `int | None` | 最初のリクエスト後に許可される再試行回数です。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合のデフォルトの遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみに上限を設定します。ポリシーが返す明示的な遅延や retry-after ヒントには上限を設定しません。 | -| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドは実行時専用で、シリアライズされません。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合のデフォルトの遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみを制限します。ポリシーが返す明示的な遅延や retry-after ヒントは制限しません。 | +| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドはランタイム専用であり、シリアライズされません。 |
再試行ポリシーは、次の情報を持つ [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 -- `attempt` と `max_retries`: 試行回数を考慮して判断できます。 -- `stream`: ストリーミングと非ストリーミングの動作を分岐できます。 -- `error`: raw の内容を確認できます。 +- `attempt` と `max_retries`: 試行回数を考慮した判断を行えます。 +- `stream`: ストリーミングと非ストリーミングで動作を分岐できます。 +- `error`: raw の情報を確認できます。 - `normalized`: `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの正規化された情報です。 - `provider_advice`: 基盤となるモデルアダプターが再試行に関する指針を提供できる場合に設定されます。 ポリシーは、次のいずれかを返せます。 -- 単純な再試行判断を示す `True` / `False` -- 遅延の上書きまたは診断理由の付加が必要な場合の [`RetryDecision`][agents.retry.RetryDecision] +- 単純に再試行するかどうかを決定する `True`/`False` +- 遅延を上書きしたり診断上の理由を付加したりする場合の [`RetryDecision`][agents.retry.RetryDecision] -SDK は、`retry_policies` でそのまま使用できるヘルパーを公開しています。 +SDK は、すぐに使用できるヘルパーを `retry_policies` で公開しています。 | ヘルパー | 動作 | | --- | --- | | `retry_policies.never()` | 常に再試行しません。 | | `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行に関する指針に従います。 | -| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウトに一致します。 | +| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウト障害に一致します。 | | `retry_policies.http_status([...])` | 選択した HTTP ステータスコードに一致します。 | -| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合のみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` による上限は適用されません。 | +| `retry_policies.retry_after()` | retry-after ヒントが利用できる場合のみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` はその値を制限しません。 | | `retry_policies.any(...)` | ネストされたポリシーのいずれかが再試行を選択した場合に再試行します。 | | `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合のみ再試行します。 | -ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の基本要素です。これは、プロバイダーが区別できる場合に、プロバイダーによる拒否とリプレイ安全性の承認を維持するためです。 +ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の基本要素です。これは、プロバイダーが区別できる場合に、プロバイダーによる拒否判断とリプレイ安全性の承認を維持するためです。 -##### 安全境界 +##### 安全性の境界 -一部の失敗は自動的に再試行されません。 +一部の障害は自動的に再試行されません。 - 中断エラー -- プロバイダーの指針によりリプレイが安全でないと判断されたリクエスト +- プロバイダーの指針でリプレイが安全でないと判断されたリクエスト - 出力がすでに開始され、リプレイが安全でなくなるストリーミング実行 -`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より保守的に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` などのプロバイダーに依存しない条件だけでは不十分です。再試行ポリシーには、通常は `retry_policies.provider_suggested()` を通じて、プロバイダーからのリプレイ安全性の承認を含める必要があります。 +`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` などのプロバイダーに依存しない述語だけでは不十分です。再試行ポリシーには、通常 `retry_policies.provider_suggested()` を通じて、プロバイダーによるリプレイ安全性の承認を含める必要があります。 ##### Runner とエージェントのマージ動作 `retry` は、Runner レベルとエージェントレベルの `ModelSettings` 間でディープマージされます。 -- エージェントは `retry.max_retries` のみを上書きし、Runner の `policy` を継承できます。 +- エージェントは `retry.max_retries` のみを上書きしながら、Runner の `policy` を継承できます。 - エージェントは `retry.backoff` の一部のみを上書きし、Runner の他のバックオフフィールドを維持できます。 -- `policy` は実行時専用であるため、シリアライズされた `ModelSettings` では `max_retries` と `backoff` は維持されますが、コールバック自体は省略されます。 +- `policy` はランタイム専用であるため、シリアライズされた `ModelSettings` には `max_retries` と `backoff` が保持されますが、コールバック自体は含まれません。 -より詳しいコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを利用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 +より詳細なコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを使用する再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 ## OpenAI 以外のプロバイダーのトラブルシューティング ### トレーシングクライアントエラー 401 -トレーシングに関連するエラーが発生する場合、トレースが OpenAI サーバーへアップロードされる一方で、OpenAI API キーがないことが原因です。これを解決するには、次の 3 つの方法があります。 +トレーシングに関連するエラーが発生する場合、トレースが OpenAI サーバーへアップロードされる一方で、OpenAI API キーが設定されていないことが原因です。これを解決する方法は 3 つあります。 1. トレーシングを完全に無効にします: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] 2. トレーシング用の OpenAI キーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 @@ -596,14 +596,14 @@ SDK は、`retry_policies` でそのまま使用できるヘルパーを公開 ### Responses API のサポート -SDK はデフォルトで Responses API を使用しますが、その他の多くの LLM プロバイダーはまだサポートしていません。その結果、404 などの問題が発生することがあります。解決するには、次の 2 つの方法があります。 +SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだサポートしていません。その結果、404 エラーまたは同様の問題が発生する場合があります。解決する方法は 2 つあります。 1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数を使用して `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] を使用します。コード例は[こちら](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)にあります。 ### Chat Completions の互換性オプション -Chat Completions を介してルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、プロンプト、テキストのみではないツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に破棄して互換性を維持します。開発中にこれらの不一致を即座に失敗させる場合は、OpenAI プロバイダーで厳格な機能検証を有効にしてください。 +Chat Completions 経由でルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、プロンプト、テキストのみではないツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に削除して互換性を維持します。開発中にこのような不一致を即座に失敗させたい場合は、OpenAI プロバイダーで厳格な機能検証を有効にします。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -621,9 +621,9 @@ result = await Runner.run( ) ``` -[`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡してください。 +[`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡します。 -一部の OpenAI 互換 Chat Completions プロバイダーは、SDK が増分処理するには信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされたツール呼び出しのバッファリングを有効にし、プロバイダーのストリーム完了後にのみ SDK がツール呼び出しを出力するようにしてください。 +一部の OpenAI 互換 Chat Completions プロバイダーは、SDK が増分処理するには信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされたツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが完了した後にのみ SDK がツール呼び出しを生成するようにします。 ```python from agents import OpenAIProvider @@ -634,11 +634,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider] では、`openai_buffer_streamed_tool_calls=True` を使用してください。 +[`MultiProvider`][agents.MultiProvider] では、`openai_buffer_streamed_tool_calls=True` を使用します。 ### structured outputs のサポート -一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。これにより、次のようなエラーが発生することがあります。 +一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。その場合、次のようなエラーが発生することがあります。 ``` @@ -646,42 +646,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていますが、出力に使用する `json_schema` を指定できません。現在、この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを利用することを推奨します。そうしない場合、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 +これは一部のモデルプロバイダーの制約です。JSON 出力には対応していますが、出力に使用する `json_schema` を指定できません。この問題は修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーの使用を推奨します。そうしないと、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 -## プロバイダーをまたいだモデルの混在 +## プロバイダー間でのモデルの混在 -モデルプロバイダー間の機能差を把握しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホスト型のファイル検索と Web 検索をサポートしていますが、その他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 +モデルプロバイダー間の機能差を認識しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、およびホスト型のファイル検索と Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限事項に注意してください。 -- `tools` を理解しないプロバイダーへ、サポートされていない `tools` を送信しないでください -- テキストのみを扱うモデルを呼び出す前に、マルチモーダル入力を除外してください -- 構造化 JSON 出力をサポートしていないプロバイダーは、不正な JSON を生成する場合があることに注意してください。 +- 理解できないプロバイダーへ、サポートされていない `tools` を送信しないでください +- テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください +- 構造化 JSON 出力をサポートしていないプロバイダーは、無効な JSON を生成する場合があることに注意してください。 ## サードパーティ製アダプター -SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティ製アダプターを使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] のパスを優先してください。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる必要がある場合、または組み込みの方法では提供されない、アダプター管理のプロバイダーカバレッジやルーティングが必要な場合に使用します。アダプターは SDK と上流のモデルプロバイダーの間に別の互換性レイヤーを追加するため、機能サポートやリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 +SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティ製アダプターを使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] の経路を選択してください。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる場合や、組み込みの経路では提供されないアダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合に使用します。アダプターによって SDK と上流のモデルプロバイダーの間に互換性レイヤーが追加されるため、機能のサポート状況とリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 ### Any-LLM -Any-LLM のサポートは、Any-LLM が管理するプロバイダーカバレッジまたはルーティングが必要な場合に向けて、ベストエフォートのベータ版として含まれています。 +Any-LLM が管理するプロバイダーカバレッジまたはルーティングが必要な場合に向けて、Any-LLM のサポートはベストエフォートのベータ版として提供されています。 -上流のプロバイダーパスに応じて、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 +上流のプロバイダー経路によっては、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 -Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡してください。 +Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールしてから、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用する、`AnyLLMModel` を直接インスタンス化する、または実行スコープで `AnyLLMProvider` を使用することができます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡します。 -Any-LLM は引き続きサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能の不足は SDK ではなく、上流の Any-LLM によって定義されます。上流のプロバイダーが使用量メトリクスを返す場合、それらは自動的に伝播されます。ただし、ストリーミングされる Chat Completions バックエンドでは、使用量チャンクを出力する前に `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +Any-LLM は引き続きサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能上の不足は、SDK ではなく上流の Any-LLM によって定義されます。上流のプロバイダーが使用量メトリクスを返す場合、それらは自動的に伝播されます。ただし、ストリーミングを行う Chat Completions バックエンドでは、使用量チャンクを生成するために `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 ### LiteLLM -LiteLLM のサポートは、LiteLLM 固有のプロバイダーカバレッジまたはルーティングが必要な場合に向けて、ベストエフォートのベータ版として含まれています。 +LiteLLM 固有のプロバイダーカバレッジまたはルーティングが必要な場合に向けて、LiteLLM のサポートはベストエフォートのベータ版として提供されています。 -LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールし、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` モデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 +LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールしてから、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` モデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 -一部の LiteLLM ベースのプロバイダーでは、デフォルトで SDK の使用量メトリクスが設定されません。使用量レポートが必要な場合は、`ModelSettings(include_usage=True)` を渡してください。また、structured outputs、ツール呼び出し、使用量レポート、アダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +LiteLLM を基盤とする一部のプロバイダーは、デフォルトでは SDK の使用量メトリクスを設定しません。使用量レポートが必要な場合は、`ModelSettings(include_usage=True)` を渡してください。また、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 -LiteLLM がレスポンスオブジェクトに対して Pydantic シリアライザーの警告を出す場合は、LiteLLM アダプターをインポートする前に、SDK の互換性パッチを明示的に有効化できます。 +LiteLLM がレスポンスオブジェクトに対する Pydantic シリアライザー警告を生成する場合は、LiteLLM アダプターをインポートする前に、SDK の互換性パッチをオプトインで有効にできます。 ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -このパッチはデフォルトで無効になっており、`1` または `true` の値でのみ有効になります。プライベートな LiteLLM ロギングヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。プライベートな LiteLLM API に依存するため、LiteLLM をアップグレードする際には再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file +このパッチはデフォルトで無効であり、値が `1` または `true` の場合にのみ有効になります。このパッチは LiteLLM の非公開ログヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。LiteLLM の非公開 API に依存しているため、LiteLLM をアップグレードするときは再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file diff --git a/docs/ja/quickstart.md b/docs/ja/quickstart.md index 5de90e7264..4af9a86d96 100644 --- a/docs/ja/quickstart.md +++ b/docs/ja/quickstart.md @@ -114,10 +114,11 @@ if __name__ == "__main__": ```python import asyncio -from agents import Agent, Runner, function_tool +from agents import Agent, Runner +from agents.decorators import tool -@function_tool +@tool def history_fun_fact() -> str: """Return a short history fact.""" return "Sharks are older than trees." diff --git a/docs/ja/realtime/guide.md b/docs/ja/realtime/guide.md index c3df3f3812..d32c8af370 100644 --- a/docs/ja/realtime/guide.md +++ b/docs/ja/realtime/guide.md @@ -214,10 +214,10 @@ async for event in session: Realtime エージェントは、ライブ会話中の関数ツールをサポートしています。 ```python -from agents import function_tool +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Get current weather for a city.""" return f"The weather in {city} is sunny, 72F." diff --git a/docs/ja/release.md b/docs/ja/release.md index d41831d1e1..df9b34379b 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -4,38 +4,51 @@ search: --- # リリースプロセス/変更履歴 -このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングをわずかに変更した方式に従います。先頭の `0` は、SDK が依然として急速に進化していることを示します。各構成要素は次のように更新します。 +このプロジェクトでは、`0.Y.Z` 形式のセマンティックバージョニングを一部変更して使用しています。先頭の `0` は、SDK が現在も急速に進化していることを示します。各要素は次のように更新します。 -## マイナー(`Y`)バージョン +## マイナー (`Y`) バージョン -ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を増やします。たとえば、`0.0.x` から `0.1.x` への移行には、破壊的変更が含まれる可能性があります。 +ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を上げます。たとえば、`0.0.x` から `0.1.x` への変更には、破壊的変更が含まれる可能性があります。 -破壊的変更を避けたい場合は、プロジェクトでバージョンを `0.0.x` に固定することを推奨します。 +破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することを推奨します。 -## パッチ(`Z`)バージョン +## パッチ (`Z`) バージョン -破壊的でない変更の場合は、`Z` を増やします。 +破壊的でない変更では、`Z` を上げます。 - バグ修正 - 新機能 - 非公開インターフェースの変更 - ベータ機能の更新 -## 破壊的変更履歴 +## 破壊的変更の変更履歴 + +### 0.19.0 + +このマイナーリリースでは、破壊的変更を **導入していません** 。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域であるプログラムによるツール呼び出しを反映したものです。 + +主な変更点: + +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。これにより、対応する OpenAI Responses モデルは、利用可能なツールを連携させる JavaScript を生成できます。ツールごとの `allowed_callers`、関数ツールの構造化された出力、Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。設定と制約については、[プログラムによるツール呼び出し](tools.md#programmatic-tool-calling)を参照してください。 +- 公開モジュール `agents.decorators` と、既存の関数およびガードレール用デコレーターに加えて短い別名 `@tool` を追加しました。関数ツールで非同期 callable オブジェクトもサポートするようになりました。 +- エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの SDK 設定で、型付き設定オブジェクトまたは辞書を一貫して受け付けるようになり、不明な設定も検証されます。 +- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシングのエラーおよび診断ログを強化し、有用なデバッグ情報を維持しながら、機密性の高い生のペイロードが露出しないようにしました。 +- AnyLLM、LiteLLM、Chat Completions との互換性を改善し、モデルの再試行時にセッション履歴を維持するとともに、レスポンス開始前に発生する WebSocket の過負荷エラーを再試行するようにしました。 +- `VercelCloudBucketMountStrategy` を使用した、[Vercel サンドボックス向けの作成時限定 S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントを含むセッションでは、ワークスペースの永続化からバケットの内容が除外され、動的なマウント変更やセッションの再開は意図的にサポートされません。 ### 0.18.0 -このマイナーリリースには、破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを目的としています。 +このマイナーリリースでは、破壊的変更を **導入していません** 。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを反映したものです。 -主な変更点: +主な変更点: -- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime セットアップでは追加の設定なしで最新の推奨モデルが使用されるようになりました。 +- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime 設定で追加構成なしに最新の推奨モデルが使用されるようになりました。 ### 0.17.0 -このバージョンでは、サンドボックスでローカルソースを実体化する際、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` は実体化先の `base_dir` 内に保持されます。`base_dir` は、マニフェストの適用時点における SDK プロセスの現在の作業ディレクトリです。相対パスのローカルソースはそのディレクトリを基準に解決され、絶対パスのローカルソースは、すでにそのディレクトリ内にあるか、明示的な許可の対象である必要があります。これにより、ローカルアーティファクトの境界に関する問題が解消されますが、そのベースディレクトリ外にある信頼済みのホストファイルやディレクトリを意図的にサンドボックスワークスペースへコピーするアプリケーションには影響する可能性があります。 +このバージョンでは、ソースパスが `Manifest.extra_path_grants` の対象でない限り、サンドボックスでローカルソースを実体化する際に `LocalFile.src` と `LocalDir.src` が実体化先の `base_dir` 内に維持されます。`base_dir` は、マニフェストが適用された時点における SDK プロセスの現在の作業ディレクトリです。相対パスのローカルソースはそのディレクトリを基準に解決されますが、絶対パスのローカルソースは、あらかじめそのディレクトリ内または明示的に許可されたパス内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼済みのホストファイルまたはディレクトリを、そのベースディレクトリの外部からサンドボックスワークスペースへ意図的にコピーするアプリケーションには影響する可能性があります。 -移行するには、`SandboxPathGrant` を使用してマニフェストレベルで信頼済みのホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 +移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みのホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 ```python from pathlib import Path @@ -62,11 +75,11 @@ manifest = Manifest( ) ``` -`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可設定を追加しないでください。 +`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可設定を作成しないでください。 ### 0.16.0 -このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルト設定が含まれるようになりました。 +このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙のデフォルトモデル設定に `reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルト設定が含まれるようになりました。 以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に指定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 @@ -74,16 +87,16 @@ manifest = Manifest( agent = Agent(name="Assistant", model="gpt-4.1") ``` -主な変更点: +主な変更点: -- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の上限を無効にできるようになりました。 -- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、プロバイダー支援型のすべてのサンドボックス実装において、絶対パスのシンボリックリンク先を含め、アーカイブルート外を指すシンボリックリンクを含む tar アーカイブが拒否されるようになりました。 +- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の制限を無効化できるようになりました。 +- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、およびプロバイダーを利用するすべてのサンドボックス実装において、絶対パスをリンク先とするシンボリックリンクを含め、アーカイブルートの外部を指すシンボリックリンクを含む tar アーカイブが拒否されるようになりました。 ### 0.15.0 -このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` になるまで実行ループが再試行されたりする代わりに、`ModelRefusalError` として明示的に公開されるようになりました。 +このバージョンでは、モデルの拒否応答が空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` に達するまで実行ループが再試行されたりするのではなく、`ModelRefusalError` として明示的に通知されるようになりました。 -これは、拒否のみを含むモデル応答が `final_output == ""` で完了することを想定していたコードに影響します。例外を送出せずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 +これは、拒否応答のみを含むモデルレスポンスが以前は `final_output == ""` で完了すると想定していたコードに影響します。例外を発生させずに拒否応答を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 ```python result = Runner.run_sync( @@ -93,94 +106,94 @@ result = Runner.run_sync( ) ``` -structured outputs を使用するエージェントでは、ハンドラーからエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 +structured outputs エージェントの場合、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 ### 0.14.0 -このマイナーリリースには、破壊的変更は **ありません**。ただし、主要な新しいベータ機能領域である Sandbox エージェントと、ローカル環境、コンテナ環境、ホスト環境で使用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されています。 +このマイナーリリースでは、破壊的変更を **導入していません** 。ただし、サンドボックスエージェントという大規模な新しいベータ機能領域に加え、ローカル環境、コンテナ環境、ホスト環境でそれらを使用するために必要なランタイム、バックエンド、ドキュメントのサポートを追加しています。 -主な変更点: +主な変更点: -- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースを追加しました。これにより、エージェントは、ファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開機能を備えた永続的で隔離されたワークスペース内で作業できます。 -- `UnixLocalSandboxClient` と `DockerSandboxClient` により、ローカル開発およびコンテナ開発向けのサンドボックス実行バックエンドを追加しました。また、オプションの追加パッケージを通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合も追加しました。 -- サンドボックスのメモリサポートを追加し、以降の実行で以前の実行から得た知見を再利用できるようになりました。段階的な情報開示、複数ターンのグループ化、設定可能な分離境界、および S3 支援型ワークフローを含む永続化メモリのコード例が用意されています。 -- ローカルおよび合成ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files のリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットを使用した再開フローを含む、より包括的なワークスペースおよび再開モデルを追加しました。 -- `examples/sandbox/` 以下に、サンドボックスに関する多数のコード例とチュートリアルを追加しました。スキルを使用したコーディングタスク、ハンドオフ、メモリ、プロバイダー固有のセットアップに加え、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドのワークフローを扱っています。 -- サンドボックス対応のセッション準備、機能のバインド、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーのデフォルト設定、機密性の高い MCP 出力をより安全に秘匿する機能により、コアランタイムとトレーシングスタックを拡張しました。 +- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースを追加しました。これにより、エージェントはファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開サポートを備えた、永続的で隔離されたワークスペース内で作業できます。 +- `UnixLocalSandboxClient` と `DockerSandboxClient` を使用するローカルおよびコンテナ化された開発向けのサンドボックス実行バックエンドに加え、オプションの追加依存関係を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合を追加しました。 +- 将来の実行で過去の実行から得た知見を再利用できるようにするサンドボックスメモリのサポートを追加しました。段階的開示、マルチターンのグループ化、構成可能な分離境界に加え、S3 を利用するワークフローを含む永続化メモリのコード例も提供します。 +- ローカルおよび合成ワークスペースエントリー、S3/R2/GCS/Azure Blob Storage/S3 Files 向けのリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットを使用する再開フローを含む、より包括的なワークスペースおよび再開モデルを追加しました。 +- `examples/sandbox/` 配下に、スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有の設定、コードレビュー、データルーム QA、Web サイトの複製などのエンドツーエンドのワークフローを扱う、多数のサンドボックス用コード例とチュートリアルを追加しました。 +- サンドボックスを考慮したセッション準備、機能のバインディング、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーのデフォルト設定、機密性の高い MCP 出力をより安全に秘匿する機能により、コアランタイムとトレーシングスタックを拡張しました。 ### 0.13.0 -このマイナーリリースには、破壊的変更は **ありません**。ただし、注目すべき Realtime のデフォルト更新、新しい MCP 機能、ランタイムの安定性向上が含まれています。 +このマイナーリリースでは、破壊的変更を **導入していません** 。ただし、Realtime のデフォルト設定に関する重要な更新、新しい MCP 機能、ランタイムの安定性に関する修正が含まれています。 -主な変更点: +主な変更点: -- デフォルトの WebSocket Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェントのセットアップでは追加の設定なしで新しいモデルが使用されるようになりました。 -- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開されるようになりました。また、`MCPServerStreamableHttp` で `session_id` が公開されるようになり、再接続後やステートレスワーカー間でストリーミング可能な HTTP セッションを再開できるようになりました。 -- Chat Completions 統合で、`should_replay_reasoning_content` を通じて推論コンテンツのリプレイをオプトインできるようになりました。これにより、LiteLLM/DeepSeek などのアダプターにおいて、プロバイダー固有の推論やツール呼び出しの継続性が向上します。 -- `SQLAlchemySession` での最初の書き込みの競合、推論の除去後に孤立したアシスタントメッセージ ID を含む圧縮リクエスト、`remove_all_tools()` の実行後も MCP/推論項目が残る問題、関数ツールのバッチ実行機構における競合状態など、ランタイムとセッションに関する複数のエッジケースを修正しました。 +- WebSocket 用のデフォルト Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェント設定で追加構成なしに新しいモデルが使用されるようになりました。 +- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開されるようになりました。また、`MCPServerStreamableHttp` で `session_id` が公開されるようになり、ストリーミング可能な HTTP セッションを再接続後またはステートレスワーカー間で再開できるようになりました。 +- Chat Completions 統合では、`should_replay_reasoning_content` を使用して推論内容の再生を任意で有効化できるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論/ツール呼び出しの連続性が向上しました。 +- `SQLAlchemySession` への同時初回書き込み、推論内容の除去後に孤立したアシスタントメッセージ ID を含む圧縮リクエスト、`remove_all_tools()` の実行後に残る MCP/推論項目、関数ツールのバッチ実行処理における競合状態など、複数のランタイムおよびセッションのエッジケースを修正しました。 ### 0.12.0 -このマイナーリリースには、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 +このマイナーリリースでは、破壊的変更を **導入していません** 。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 ### 0.11.0 -このマイナーリリースには、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 +このマイナーリリースでは、破壊的変更を **導入していません** 。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 ### 0.10.0 -このマイナーリリースには、破壊的変更は **ありません**。ただし、OpenAI Responses のユーザー向けに重要な新機能領域である、Responses API の WebSocket トランスポートサポートが含まれています。 +このマイナーリリースでは、破壊的変更を **導入していません** 。ただし、OpenAI Responses のユーザー向けに、Responses API の WebSocket トランスポートをサポートする重要な新機能領域が含まれています。 -主な変更点: +主な変更点: -- OpenAI Responses モデルに WebSocket トランスポートのサポートを追加しました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 -- 複数ターンの実行間で、共有の WebSocket 対応プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 -- ストリーミング、ツール、承認、フォローアップターンを扱う、新しい WebSocket ストリーミングのコード例(`examples/basic/stream_ws.py`)を追加しました。 +- OpenAI Responses モデルに WebSocket トランスポートのサポートを追加しました。これはオプトインであり、HTTP が引き続きデフォルトのトランスポートです。 +- 複数ターンの実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 +- ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングのコード例 (`examples/basic/stream_ws.py`) を追加しました。 ### 0.9.0 -このバージョンでは、Python 3.9 のサポートを終了しました。このメジャーバージョンは 3 か月前に EOL を迎えています。より新しいランタイムバージョンにアップグレードしてください。 +このバージョンでは、Python 3.9 のメジャーバージョンが 3 か月前に EOL を迎えたため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンにアップグレードしてください。 -さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に絞り込まれました。通常、この変更によって破壊的な問題が生じることはありませんが、コードがより広範なユニオン型に依存している場合は、調整が必要になる可能性があります。 +さらに、`Agent#as_tool()` メソッドの戻り値に対する型ヒントが、`Tool` から `FunctionTool` に限定されました。通常、この変更が破壊的な問題を引き起こすことはありませんが、コードがより広範なユニオン型に依存している場合は、調整が必要になる可能性があります。 ### 0.8.0 -このバージョンでは、ランタイム動作に関する次の 2 つの変更により、移行作業が必要になる場合があります。 +このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる可能性があります。 -- **同期** Python 呼び出し可能オブジェクトをラップする関数ツールは、イベントループのスレッド上で実行される代わりに、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールのロジックがスレッドローカル状態やスレッドアフィニティを持つリソースに依存している場合は、非同期ツール実装へ移行するか、ツールのコード内でスレッドアフィニティを明示してください。 -- ローカル MCP ツールの失敗処理が設定可能になり、デフォルトの動作では、実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返す場合があります。即時失敗の動作に依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` の値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 +- Python の **同期** 呼び出し可能オブジェクトをラップする関数ツールは、イベントループのスレッド上で実行されるのではなく、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールのロジックがスレッドローカルな状態または特定のスレッドに依存するリソースを使用している場合は、非同期ツール実装に移行するか、ツールコード内でスレッドアフィニティを明示してください。 +- ローカル MCP ツールの失敗処理を構成できるようになり、デフォルト動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返す場合があります。即時失敗の動作に依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 ### 0.7.0 このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかあります。 -- ネストされたハンドオフ履歴は **オプトイン** になりました(デフォルトでは無効です)。v0.6.x のデフォルトのネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 -- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルト設定で指定されていた従来の `"low"` から `"none"` に変更されました。プロンプトまたは品質/コスト特性が `"low"` に依存している場合は、`model_settings` で明示的に設定してください。 +- ネストされたハンドオフ履歴が **オプトイン** になりました。デフォルトでは無効です。v0.6.x のデフォルトのネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 +- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルト設定で以前使用されていた `"low"` から `"none"` に変更されました。プロンプトまたは品質/コストのプロファイルが `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 ### 0.6.0 -このバージョンでは、デフォルトのハンドオフ履歴が、生のユーザー/アシスタントのターンを公開する代わりに、1 件のアシスタントメッセージにまとめられるようになりました。これにより、後続のエージェントに簡潔で予測可能な要約が提供されます -- 既存の単一メッセージ形式のハンドオフ記録は、デフォルトで `` ブロックの前に "For context, here is the conversation so far between the user and the previous agent:" という文言から始まるようになり、後続のエージェントに明確なラベル付きの要約が提供されます +このバージョンでは、デフォルトのハンドオフ履歴が、未加工のユーザー/アシスタントのターンを公開する代わりに、単一のアシスタントメッセージへまとめられるようになり、後続のエージェントに簡潔で予測可能な要約を提供します +- 既存の単一メッセージ形式のハンドオフトランスクリプトは、デフォルトで `` ブロックの前に "For context, here is the conversation so far between the user and the previous agent:" という文言を付けて開始するようになり、後続のエージェントが明確なラベル付きの要約を受け取れるようになりました ### 0.5.0 -このバージョンでは、目に見える破壊的変更は導入されていませんが、新機能と内部の重要な更新がいくつか含まれています。 +このバージョンでは、外部から確認できる破壊的変更はありませんが、新機能と内部実装上の重要な更新がいくつか含まれています。 -- `RealtimeRunner` に [SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートを追加しました -- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックを大幅に改訂しました +- `RealtimeRunner` で [SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートを追加しました +- Python 3.14 との互換性を確保するため、`Runner#run_sync` の内部ロジックを大幅に改訂しました ### 0.4.0 -このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x バージョンはサポートされなくなりました。この SDK とともに openai v2.x を使用してください。 +このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x 系はサポートされなくなりました。この SDK とともに openai v2.x 系を使用してください。 ### 0.3.0 -このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA 版)に移行しました。 +このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA 版)へ移行します。 ### 0.2.0 -このバージョンでは、以前は引数として `Agent` を受け取っていた箇所の一部が、代わりに `AgentBase` を受け取るようになりました。たとえば、MCP サーバーの `list_tools()` 呼び出しが該当します。これは純粋に型付け上の変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正するだけです。 +このバージョンでは、以前は `Agent` を引数として受け取っていた一部の箇所が、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、MCP サーバーの `list_tools()` 呼び出しが該当します。これは型に関する変更のみであり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 ### 0.1.0 -このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` を継承するすべてのクラスに、これらのパラメーターを追加する必要があります。 \ No newline at end of file +このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` を継承するすべてのクラスに、これらのパラメーターを追加する必要があります。 diff --git a/docs/ja/results.md b/docs/ja/results.md index 1ea67846df..e495ebcec1 100644 --- a/docs/ja/results.md +++ b/docs/ja/results.md @@ -4,95 +4,124 @@ search: --- # 実行結果 -`Runner.run` メソッドを呼び出すと、次の 2 つの実行結果型のいずれかを受け取ります。 +`Runner.run` メソッドを呼び出すと、次の 2 種類の実行結果のいずれかを受け取ります。 -- `Runner.run(...)` または `Runner.run_sync(...)` からの [`RunResult`][agents.result.RunResult] -- `Runner.run_streamed(...)` からの [`RunResultStreaming`][agents.result.RunResultStreaming] +- `Runner.run(...)` または `Runner.run_sync(...)` から返される [`RunResult`][agents.result.RunResult] +- `Runner.run_streamed(...)` から返される [`RunResultStreaming`][agents.result.RunResultStreaming] -どちらも [`RunResultBase`][agents.result.RunResultBase] を継承しており、`final_output`、`new_items`、`last_agent`、`raw_responses`、`to_state()` などの共通の実行結果サーフェスを公開します。 +どちらも [`RunResultBase`][agents.result.RunResultBase] を継承しており、`final_output`、`new_items`、`last_agent`、`raw_responses`、`to_state()` などの共通の実行結果インターフェースを公開します。 -`RunResultStreaming` は、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御機能を追加します。 +`RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御機能が追加されています。 -## 適切な実行結果サーフェスの選択 +## 適切な実行結果インターフェースの選択 -ほとんどのアプリケーションでは、いくつかの実行結果プロパティまたはヘルパーだけで十分です。 +ほとんどのアプリケーションでは、少数の実行結果プロパティまたはヘルパーのみが必要です。 -| 必要なもの... | 使用するもの | +| 必要なもの | 使用するもの | | --- | --- | | ユーザーに表示する最終回答 | `final_output` | -| 完全なローカルトランスクリプトを含む、リプレイ可能な次ターン入力リスト | `to_input_list()` | -| エージェント、ツール、ハンドオフ、承認メタデータを含む詳細な実行項目 | `new_items` | -| 通常、次のユーザーターンを処理すべきエージェント | `last_agent` | -| `previous_response_id` による OpenAI Responses API チェーン | `last_response_id` | -| 保留中の承認と再開可能なスナップショット | `interruptions` and `to_state()` | +| ローカルの完全なトランスクリプトを含む、再実行可能な次ターンの入力リスト | `to_input_list()` | +| エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な実行項目 | `new_items` | +| 通常、次のユーザーターンを処理するエージェント | `last_agent` | +| `previous_response_id` を使用した OpenAI Responses API のチェーン | `last_response_id` | +| 保留中の承認と再開可能なスナップショット | `interruptions` と `to_state()` | | 現在のネストされた `Agent.as_tool()` 呼び出しに関するメタデータ | `agent_tool_invocation` | -| raw モデル呼び出しまたはガードレール診断 | `raw_responses` and the guardrail result arrays | +| raw モデル呼び出しまたはガードレールの診断 | `raw_responses` とガードレールの実行結果配列 | ## 最終出力 -[`final_output`][agents.result.RunResultBase.final_output] プロパティには、最後に実行されたエージェントの最終出力が含まれます。これは次のいずれかです。 +[`final_output`][agents.result.RunResultBase.final_output] プロパティには、最後に実行されたエージェントの最終出力が格納されます。これは次のいずれかです。 - 最後のエージェントに `output_type` が定義されていなかった場合は `str` -- 最後のエージェントに出力型が定義されていた場合は `last_agent.output_type` 型のオブジェクト -- 承認中断で一時停止した場合など、最終出力が生成される前に実行が停止した場合は `None` +- 最後のエージェントに出力型が定義されていた場合は、`last_agent.output_type` 型のオブジェクト +- 承認待ちの中断で一時停止した場合など、最終出力が生成される前に実行が停止した場合は `None` !!! note - `final_output` は `Any` として型付けされています。ハンドオフによって、どのエージェントが実行を終了するかが変わる可能性があるため、SDK は考えられる出力型の全体集合を静的に把握できません。 + `final_output` の型は `Any` です。ハンドオフによって実行を完了するエージェントが変わる可能性があるため、SDK は考えられる出力型の完全な集合を静的に把握できません。 ストリーミングモードでは、ストリームの処理が完了するまで `final_output` は `None` のままです。イベントごとのフローについては、[ストリーミング](streaming.md)を参照してください。 -## 入力、次ターン履歴、新規項目 +## 入力、次ターンの履歴、新規項目 -これらのサーフェスは、それぞれ異なる問いに対応します。 +これらのインターフェースは、それぞれ異なる目的に対応します。 -| プロパティまたはヘルパー | 含まれる内容 | 最適な用途 | +| プロパティまたはヘルパー | 格納される内容 | 最適な用途 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基本入力です。ハンドオフ入力フィルターが履歴を書き換えた場合、実行が継続されたフィルター済み入力がここに反映されます。 | この実行が実際に入力として使用した内容の監査 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として見たビューです。デフォルトの `mode="preserve_all"` は、`new_items` から変換された完全な履歴を保持します。`mode="normalized"` は、ハンドオフフィルタリングによってモデル履歴が書き換えられた場合に、正規の継続入力を優先します。 | 手動のチャットループ、クライアント管理の会話状態、プレーンな項目履歴の確認 | -| [`new_items`][agents.result.RunResultBase.new_items] | エージェント、ツール、ハンドオフ、承認メタデータを含む詳細な [`RunItem`][agents.items.RunItem] ラッパーです。 | ログ、UI、監査、デバッグ | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行内の各モデル呼び出しからの raw [`ModelResponse`][agents.items.ModelResponse] オブジェクトです。 | プロバイダーレベルの診断または raw レスポンスの確認 | +| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基本入力。ハンドオフ入力フィルターが履歴を書き換えた場合は、実行の続行に使用されたフィルター済みの入力が反映されます。 | この実行で実際に使用された入力の監査 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として表したもの。デフォルトの `mode="preserve_all"` では、`new_items` から変換された履歴が維持されます。ただし、SDK デフォルトのネストされたハンドオフ履歴へすでに移された同一のセッション項目は、再度追加されません。`mode="normalized"` では、ハンドオフのフィルタリングによってモデル履歴が書き換えられた場合、正規の継続入力が優先されます。 | 手動のチャットループ、クライアント管理の会話状態、プレーンな項目履歴の確認 | +| [`new_items`][agents.result.RunResultBase.new_items] | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な [`RunItem`][agents.items.RunItem] ラッパー。 | ログ、UI、監査、デバッグ | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行中の各モデル呼び出しから得られた raw [`ModelResponse`][agents.items.ModelResponse] オブジェクト。 | プロバイダーレベルの診断または raw レスポンスの確認 | 実際には、次のように使い分けます。 -- 実行のプレーンな入力項目ビューが必要な場合は、`to_input_list()` を使用します。 -- ハンドオフフィルタリングまたはネストされたハンドオフ履歴の書き換え後に、次の `Runner.run(..., input=...)` 呼び出しに渡す正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 -- SDK に履歴の読み込みと保存を任せたい場合は、[`session=...`](sessions/index.md) を使用します。 -- `conversation_id` または `previous_response_id` を使って OpenAI のサーバー管理状態を使用している場合、通常は `to_input_list()` を再送信する代わりに、新しいユーザー入力のみを渡して保存済み ID を再利用します。 -- ログ、UI、監査向けに完全な変換済み履歴が必要な場合は、デフォルトの `to_input_list()` モードまたは `new_items` を使用します。 +- 実行をプレーンな入力項目として確認する場合は、`to_input_list()` を使用します。 +- ハンドオフのフィルタリングやネストされたハンドオフ履歴の書き換え後、次の `Runner.run(..., input=...)` 呼び出しに使用する正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 +- SDK に履歴の読み込みと保存を任せる場合は、[`session=...`](sessions/index.md) を使用します。 +- `conversation_id` または `previous_response_id` を使って OpenAI のサーバー管理状態を使用している場合、通常は `to_input_list()` を再送せず、新しいユーザー入力のみを渡して保存済みの ID を再利用します。 +- ログ、UI、監査のために変換済みの完全な履歴が必要な場合は、デフォルトモードの `to_input_list()` または `new_items` を使用します。 -JavaScript SDK とは異なり、Python ではモデル形式の差分のみを表す個別の `output` プロパティは公開されません。SDK メタデータが必要な場合は `new_items` を使用し、raw モデルペイロードが必要な場合は `raw_responses` を確認してください。 +SDK デフォルトのネストされたハンドオフ履歴でメッセージ項目がそのまま保持される場合、Sessions、`RunState`、`to_input_list()` は、内容で重複排除するのではなく、所有対象となる個々の出現を追跡します。個別に発生した同一のメッセージは別々のものとして維持され、すでに所有されている出現のみが再度追加されないように処理されます。 -コンピュータツールのリプレイは、raw Responses ペイロードの形状に従います。プレビューモデルの `computer_call` 項目は単一の `action` を保持しますが、`gpt-5.5` のコンピュータ呼び出しではバッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] は、モデルが生成した形状をそのまま保持するため、手動リプレイ、一時停止/再開フロー、保存済みトランスクリプトは、プレビュー版と GA 版の両方のコンピュータツール呼び出しで引き続き機能します。ローカル実行結果は引き続き `new_items` 内の `computer_call_output` 項目として表示されます。 +JavaScript SDK とは異なり、Python ではモデル形式の差分のみを表す独立した `output` プロパティは公開されていません。SDK のメタデータが必要な場合は `new_items` を使用し、raw モデルペイロードが必要な場合は `raw_responses` を確認してください。 + +コンピュータツールの再実行では、raw Responses ペイロードの形式が使用されます。プレビューモデルの `computer_call` 項目は単一の `action` を保持しますが、`gpt-5.5` のコンピュータ呼び出しはバッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] は、モデルが生成した形式をそのまま維持するため、手動の再実行、一時停止と再開のフロー、保存済みトランスクリプトは、プレビュー版と GA 版の両方のコンピュータツール呼び出しで引き続き機能します。ローカルでの実行結果は、引き続き `new_items` 内の `computer_call_output` 項目として表示されます。 ### 新規項目 -[`new_items`][agents.result.RunResultBase.new_items] は、実行中に何が起きたかを最も詳細に確認できるビューです。一般的な項目型は次のとおりです。 +[`new_items`][agents.result.RunResultBase.new_items] を使用すると、実行中に起きたことを最も詳細に確認できます。一般的な項目型は次のとおりです。 + +- アシスタントメッセージを表す [`MessageOutputItem`][agents.items.MessageOutputItem] +- 推論項目を表す [`ReasoningItem`][agents.items.ReasoningItem] +- Responses のツール検索リクエストと読み込まれたツール検索結果を表す [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] と [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- ツール呼び出しとその実行結果を表す [`ToolCallItem`][agents.items.ToolCallItem] と [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- 承認のために一時停止したツール呼び出しを表す [`ToolApprovalItem`][agents.items.ToolApprovalItem] +- ホスト型 MCP の承認とツールカタログを表す [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem]、[`MCPListToolsItem`][agents.items.MCPListToolsItem] +- ハンドオフリクエストと完了した転送を表す [`HandoffCallItem`][agents.items.HandoffCallItem] と [`HandoffOutputItem`][agents.items.HandoffOutputItem] + +エージェントとの関連付け、ツール出力、ハンドオフ境界、承認境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 -- アシスタントメッセージ用の [`MessageOutputItem`][agents.items.MessageOutputItem] -- 推論項目用の [`ReasoningItem`][agents.items.ReasoningItem] -- Responses のツール検索リクエストと、ロードされたツール検索の実行結果用の [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] および [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- ツール呼び出しとその実行結果用の [`ToolCallItem`][agents.items.ToolCallItem] および [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 承認待ちで一時停止したツール呼び出し用の [`ToolApprovalItem`][agents.items.ToolApprovalItem] -- ハンドオフリクエストと完了済みの引き継ぎ用の [`HandoffCallItem`][agents.items.HandoffCallItem] および [`HandoffOutputItem`][agents.items.HandoffOutputItem] +ホスト型ツール検索を使用する場合、モデルが生成した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を、該当ターンで読み込まれた名前空間、関数、ホスト型 MCP サーバーを確認するには `ToolSearchOutputItem.raw_item` を調べます。 -エージェントの関連付け、ツール出力、ハンドオフの境界、承認の境界が必要な場合は、常に `to_input_list()` よりも `new_items` を選択してください。 +プログラムによるツール呼び出し (Programmatic Tool Calling) では、生成された `program` は `ToolCallItem` であり、そのプログラムが所有する通常の子ツール呼び出しも `ToolCallItem` エントリです。また、対応する `program_output` は `ToolCallOutputItem` です。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外で、それぞれ `MCPApprovalRequestItem` エントリと `MCPListToolsItem` エントリになります。 + +raw 項目には、型付きの Responses オブジェクトまたはマッピングを使用できます。特に、プログラムが所有する shell 呼び出しと apply-patch 呼び出しではマッピングが使用されます。マッピングでも安全な次の検査パターンを使用してください。 + +```python +from collections.abc import Mapping + + +def raw_field(item, name): + raw_item = item.raw_item + if isinstance(raw_item, Mapping): + return raw_item.get(name) + return getattr(raw_item, name, None) + + +raw_type = raw_field(item, "type") +caller = raw_field(item, "caller") +caller_id = ( + caller.get("caller_id") + if isinstance(caller, Mapping) + else getattr(caller, "caller_id", None) +) +``` -ホスト型ツール検索を使用する場合、モデルが生成した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を確認し、そのターンでどの名前空間、関数、またはホスト型 MCP サーバーがロードされたかを確認するには `ToolSearchOutputItem.raw_item` を確認してください。 +プログラムが所有する子呼び出しでは、`caller` の型は `program` で、`caller_id` は親プログラムの呼び出しを識別します。 -## 会話の継続または再開 +## 会話の続行または再開 ### 次ターンのエージェント -[`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが含まれます。これは多くの場合、ハンドオフ後の次のユーザーターンで再利用するのに最適なエージェントです。 +[`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが格納されます。多くの場合、ハンドオフ後の次のユーザーターンで再利用するエージェントとして最適です。 -ストリーミングモードでは、[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が実行の進行に合わせて更新されるため、ストリームが終了する前にハンドオフを観察できます。 +ストリーミングモードでは、実行の進行に合わせて [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が更新されるため、ストリームが完了する前にハンドオフを確認できます。 ### 中断と実行状態 -ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接呼び出されたツール、ハンドオフ後に到達したツール、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] 実行によって発生した承認が含まれることがあります。 +ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接使用されたツール、ハンドオフ後に到達したツール、ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって発生した承認が含まれる場合があります。 -[`to_state()`][agents.result.RunResult.to_state] を呼び出して、再開可能な [`RunState`][agents.run_state.RunState] を取得し、保留中の項目を承認または拒否してから、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 +[`to_state()`][agents.result.RunResult.to_state] を呼び出して再開可能な [`RunState`][agents.run_state.RunState] を取得し、保留中の項目を承認または拒否してから、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 ```python from agents import Agent, Runner @@ -107,17 +136,17 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了してから `result.interruptions` を確認し、`result.to_state()` から再開してください。承認フロー全体については、[ヒューマンインザループ](human_in_the_loop.md)を参照してください。 +ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了し、その後で `result.interruptions` を確認して `result.to_state()` から再開します。承認フローの全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 -### サーバー管理の継続 +### サーバー管理による継続 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API チェーンを継続したい場合は、次のターンで `previous_response_id` として渡してください。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行で得られた最新のモデルレスポンス ID です。OpenAI Responses API のチェーンを継続する場合は、次のターンで `previous_response_id` として渡します。 -すでに `to_input_list()`、`session`、または `conversation_id` で会話を継続している場合、通常は `last_response_id` は不要です。複数ステップの実行におけるすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 +すでに `to_input_list()`、`session`、`conversation_id` を使用して会話を継続している場合、通常は `last_response_id` は必要ありません。複数ステップの実行に含まれるすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 ## ツールとしてのエージェントのメタデータ -実行結果がネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] 実行に由来する場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は外側のツール呼び出しに関する変更不可のメタデータを公開します。 +ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から実行結果が返された場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は外側のツール呼び出しに関する不変のメタデータを公開します。 - `tool_name` - `tool_call_id` @@ -125,41 +154,41 @@ if result.interruptions: 通常のトップレベル実行では、`agent_tool_invocation` は `None` です。 -これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、または生の引数が必要になることがある `custom_output_extractor` 内で特に便利です。関連する `Agent.as_tool()` パターンについては、[ツール](tools.md)を参照してください。 +これは特に `custom_output_extractor` 内で役立ちます。ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、raw 引数が必要になる場合があるためです。関連する `Agent.as_tool()` のパターンについては、[ツール](tools.md)を参照してください。 -そのネストされた実行のパース済み構造化入力も必要な場合は、`context_wrapper.tool_input` を読み取ってください。これは、ネストされたツール入力に対して [`RunState`][agents.run_state.RunState] が汎用的にシリアライズするフィールドです。一方、`agent_tool_invocation` は、現在のネストされた呼び出しに対するライブの実行結果アクセサーです。 +そのネストされた実行の解析済み構造化入力も必要な場合は、`context_wrapper.tool_input` を参照してください。これは [`RunState`][agents.run_state.RunState] がネストされたツール入力として汎用的にシリアライズするフィールドです。一方、`agent_tool_invocation` は現在のネストされた呼び出しに対する実行結果のライブアクセサーです。 ## ストリーミングのライフサイクルと診断 -[`RunResultStreaming`][agents.result.RunResultStreaming] は、上記と同じ実行結果サーフェスを継承しますが、ストリーミング固有の制御機能を追加します。 +[`RunResultStreaming`][agents.result.RunResultStreaming] は前述の実行結果インターフェースを継承し、さらにストリーミング固有の次の制御機能を追加します。 -- セマンティックなストリームイベントを消費するための [`stream_events()`][agents.result.RunResultStreaming.stream_events] -- 実行途中でアクティブなエージェントを追跡するための [`current_agent`][agents.result.RunResultStreaming.current_agent] +- 意味レベルのストリームイベントを消費するための [`stream_events()`][agents.result.RunResultStreaming.stream_events] +- 実行中にアクティブなエージェントを追跡するための [`current_agent`][agents.result.RunResultStreaming.current_agent] - ストリーミング実行が完全に終了したかどうかを確認するための [`is_complete`][agents.result.RunResultStreaming.is_complete] -- 実行を即時または現在のターン後に停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] +- 実行を即座に、または現在のターンの完了後に停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] -非同期イテレーターが終了するまで `stream_events()` を消費し続けてください。そのイテレーターが終了するまで、ストリーミング実行は完了していません。また、`final_output`、`interruptions`、`raw_responses` などの要約プロパティや、セッション永続化の副作用は、目に見える最後のトークンが到着した後もまだ確定中の場合があります。 +非同期イテレーターが終了するまで `stream_events()` を消費し続けてください。そのイテレーターが終了するまでストリーミング実行は完了していません。また、最後の可視トークンが到着した後も、`final_output`、`interruptions`、`raw_responses` などの要約プロパティや、セッション永続化の副作用が確定処理中である可能性があります。 -`cancel()` を呼び出した場合は、キャンセルとクリーンアップが正しく完了できるように、`stream_events()` を消費し続けてください。 +`cancel()` を呼び出した場合も、キャンセルとクリーンアップが正しく完了するよう、`stream_events()` を引き続き消費してください。 -Python では、ストリーミング用の個別の `completed` プロミスや `error` プロパティは公開されません。終端的なストリーミング失敗は `stream_events()` から例外が送出されることで表面化し、`is_complete` は実行が終端状態に到達したかどうかを反映します。 +Python では、ストリーミング用の独立した `completed` Promise や `error` プロパティは公開されていません。ストリーミングの終端エラーは `stream_events()` から例外が送出されることで通知され、`is_complete` は実行が終端状態に達したかどうかを示します。 -### raw レスポンス +### Raw レスポンス -[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが含まれます。複数ステップの実行では、ハンドオフをまたいだり、モデル/ツール/モデルのサイクルが繰り返されたりする場合など、複数のレスポンスが生成されることがあります。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが格納されます。複数ステップの実行では、ハンドオフやモデル、ツール、モデルの反復サイクルなどにより、複数のレスポンスが生成されることがあります。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリの ID にすぎません。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリに含まれる ID にすぎません。 ### ガードレールの実行結果 -エージェントレベルのガードレールは、[`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] および [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] として公開されます。 +エージェントレベルのガードレールは、[`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] と [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] として公開されます。 -ツールガードレールは、[`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] および [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] として個別に公開されます。 +ツールのガードレールは、[`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] と [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] として個別に公開されます。 -これらの配列は実行全体で蓄積されるため、判定のログ記録、追加のガードレールメタデータの保存、または実行がブロックされた理由のデバッグに役立ちます。 +これらの配列には実行全体の情報が蓄積されるため、判断内容のログ記録、追加のガードレールメタデータの保存、実行がブロックされた理由のデバッグに役立ちます。 ### コンテキストと使用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、アプリのコンテキストと、承認、使用量、ネストされた `tool_input` など SDK が管理するランタイムメタデータを公開します。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、アプリケーションのコンテキストと、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータをまとめて公開します。 -使用量は `context_wrapper.usage` で追跡されます。ストリーミング実行では、ストリームの最終チャンクが処理されるまで、使用量の合計値が遅れて反映される場合があります。ラッパーの完全な形状と永続化に関する注意事項については、[コンテキスト管理](context.md)を参照してください。 \ No newline at end of file +使用量は `context_wrapper.usage` で追跡されます。ストリーミング実行では、ストリームの最後のチャンクが処理されるまで使用量の合計値の反映が遅れる場合があります。ラッパーの完全な形式と永続化に関する注意事項については、[コンテキスト管理](context.md)を参照してください。 \ No newline at end of file diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index 43811d0429..040ced2f0f 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -7,8 +7,8 @@ search: [`Runner`][agents.run.Runner] クラスを使用してエージェントを実行できます。次の 3 つの方法があります。 1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行し、[`RunResult`][agents.result.RunResult] を返します。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドで、内部的には `.run()` を実行します。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。ストリーミングモードで LLM を呼び出し、受信したイベントをそのままストリーミングします。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドで、内部的に `.run()` を実行します。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントをそのままストリーミングします。 ```python from agents import Agent, Runner @@ -29,40 +29,40 @@ async def main(): ### エージェントループ -`Runner` の run メソッドを使用する場合、開始エージェントと入力を渡します。入力には次のものを使用できます。 +`Runner` の run メソッドを使用する際は、開始エージェントと入力を渡します。入力には次のものを指定できます。 -- 文字列(ユーザーメッセージとして扱われます) -- OpenAI Responses API 形式の入力項目のリスト -- 中断された実行を再開する場合は [`RunState`][agents.run_state.RunState] +- 文字列(ユーザーメッセージとして扱われます) +- OpenAI Responses API 形式の入力項目のリスト +- 中断された実行を再開する場合は [`RunState`][agents.run_state.RunState] -その後、runner はループを実行します。 +Runner は次のループを実行します。 -1. 現在のエージェントに対して、現在の入力で LLM を呼び出します。 +1. 現在のエージェントについて、現在の入力を使用して LLM を呼び出します。 2. LLM が出力を生成します。 - 1. LLM が `final_output` を返した場合、ループを終了して実行結果を返します。 + 1. LLM が `final_output` を返した場合、ループを終了し、実行結果を返します。 2. LLM がハンドオフを行った場合、現在のエージェントと入力を更新し、ループを再実行します。 - 3. LLM がツール呼び出しを生成した場合、そのツール呼び出しを実行して結果を追加し、ループを再実行します。 + 3. LLM がツール呼び出しを生成した場合、そのツール呼び出しを実行し、実行結果を追加して、ループを再実行します。 3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note - LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが存在しないことです。 + LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力を生成し、ツール呼び出しが存在しないことです。 ### ストリーミング -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントを取得するには、`.stream_events()` を呼び出します。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 #### Responses WebSocket トランスポート(オプションのヘルパー) -OpenAI Responses の websocket トランスポートを有効にしても、通常の `Runner` API を引き続き使用できます。接続を再利用するには websocket セッションヘルパーの使用を推奨しますが、必須ではありません。 +OpenAI Responses WebSocket トランスポートを有効にしても、通常の `Runner` API を引き続き使用できます。接続を再利用するには WebSocket セッションヘルパーの使用を推奨しますが、必須ではありません。 -これは websocket トランスポート上の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 +これは WebSocket トランスポート経由の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 -トランスポートの選択ルール、および具体的なモデルオブジェクトやカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)を参照してください。 +トランスポートの選択ルールや、具体的なモデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)を参照してください。 -##### パターン 1:セッションヘルパーなし(動作可能) +##### パターン 1:セッションヘルパーなし(動作可) -websocket トランスポートのみが必要で、共有プロバイダーやセッションを SDK に管理させる必要がない場合に使用します。 +WebSocket トランスポートのみが必要で、SDK に共有プロバイダー/セッションを管理させる必要がない場合に使用します。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -このパターンは、単一の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 +このパターンは単一の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 ##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) -複数の実行で websocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。これには、同じ `run_config` を継承する、ネストされた Agents-as-tools 呼び出しも含まれます。 +複数の実行間で、WebSocket 対応のプロバイダーと `RunConfig` を共有する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、ネストされたエージェントのツールとしての呼び出しも対象です。 ```python import asyncio @@ -119,50 +119,50 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果の処理を完了してください。websocket リクエストが進行中の状態でコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 +コンテキストを終了する前に、ストリーミングされた実行結果の取得を完了してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 -長時間の推論ターンで websocket の keepalive タイムアウトが発生する場合は、`ping_timeout` を大きくするか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。websocket のレイテンシよりも信頼性が重要な実行では、HTTP/SSE トランスポートを使用してください。 +長時間の推論ターンで WebSocket のキープアライブがタイムアウトする場合は、`ping_timeout` を大きくするか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket のレイテンシよりも信頼性が重要な実行では、HTTP/SSE トランスポートを使用してください。 ### 実行設定 -`run_config` パラメーターを使用すると、エージェントの実行に関するグローバル設定を構成できます。 +`run_config` パラメーターを使用すると、エージェントの実行に関する一部のグローバル設定を構成できます。 -#### 一般的な実行設定カテゴリー +#### 一般的な実行設定のカテゴリー -各エージェントの定義を変更せず、単一の実行に対する動作を上書きするには、`RunConfig` を使用します。 +各エージェントの定義を変更せずに、単一の実行に対する動作を上書きするには、`RunConfig` を使用します。 -##### モデル、プロバイダー、セッションのデフォルト設定 +##### モデル、プロバイダー、セッションのデフォルト -- [`model`][agents.run.RunConfig.model]:各 Agent に設定されている `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 -- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 -- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 -- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト設定(たとえば、`SessionSettings(limit=...)`)を上書きします。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各ターンの前に新しいユーザー入力をセッション履歴とマージする方法をカスタマイズします。コールバックは同期または非同期にできます。 +- [`model`][agents.run.RunConfig.model]:各 Agent に設定された `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 +- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 +- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` や `top_p` を設定できます。 +- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際、セッションレベルのデフォルト(たとえば、`SessionSettings(limit=...)`)を上書きします。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各ターンの前に新しいユーザー入力をセッション履歴へ統合する方法をカスタマイズします。コールバックは同期でも非同期でもかまいません。 ##### ガードレール、ハンドオフ、モデル入力の整形 -- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力ガードレールまたは出力ガードレールのリストです。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフにフィルターがまだ設定されていない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信される入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントを参照してください。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、それまでのトランスクリプトを単一の assistant メッセージにまとめる、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、raw トランスクリプトをそのまま渡すには `False` のままにします。[Runner の各メソッド][agents.run.Runner]は、`RunConfig` が渡されなかった場合に自動的に作成するため、クイックスタートとコード例ではデフォルトで無効のままです。また、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を使用してこの設定を上書きできます。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` を有効にした際に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。次のエージェントに転送する入力項目の正確なリストを返す必要があり、完全なハンドオフフィルターを記述することなく、組み込みの要約を置き換えられます。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の短縮やシステムプロンプトの挿入に使用できます。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:runner が以前の出力を次のターンのモデル入力に変換するときに、推論項目 ID を保持するか省略するかを制御します。 +- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフにフィルターが設定されていない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントへ送信する入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントを参照してください。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、元の位置にあるメッセージ項目を欠損なく保持しながら、要約可能な履歴を順序付きの assistant 要約セグメントへ圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、未加工のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、`RunResult.to_input_list()` は、SDK のデフォルトのネスト履歴に同一のメッセージ出現箇所がすでに含まれている場合、そのメッセージを二重に追加しません。一方、内容が同一でも別個のメッセージは保持されます。すべての [Runner メソッド][agents.run.Runner]は、`RunConfig` が渡されなかった場合に自動で作成するため、クイックスタートとコード例ではデフォルトで無効のままとなり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を使用してこの設定を上書きできます。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` を有効にした場合に、正規化されたトランスクリプト(履歴 + ハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを記述することなく、組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデルを呼び出す直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の短縮やシステムプロンプトの挿入に使用できます。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力へ変換する際に、推論項目 ID を保持するか省略するかを制御します。 ##### トレーシングと可観測性 -- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 -- [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密である可能性があるデータをトレースに含めるかどうかを設定します。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 -- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 +- [`tracing`][agents.run.RunConfig.tracing]:実行単位のトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密情報である可能性のあるデータをトレースに含めるかどうかを設定します。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 +- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 -##### ツールの実行、承認、エラー動作 +##### ツール実行、承認、ツールエラーの動作 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行する関数ツールの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが出力した、解決できない関数ツール呼び出しを runner が処理する方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから参照可能なエラー出力を返すようオプトインできます。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから参照可能なツールエラーメッセージをカスタマイズします。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:一度に実行する関数ツール数の制限など、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した未解決の関数ツール呼び出しを Runner が処理する方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから確認できるエラー出力を返すようオプトインできます。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインされたツール未検出時の出力など、モデルから確認できるツールエラーメッセージをカスタマイズします。 -ネストされたハンドオフは、オプトインのベータ機能として利用できます。トランスクリプトをまとめる動作を有効にするには、`RunConfig(nest_handoff_history=True)` を渡すか、特定のハンドオフに対して `handoff(..., nest_handoff_history=True)` を設定します。raw トランスクリプトを保持する場合(デフォルト)は、フラグを設定しないか、必要に応じて会話をそのまま転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタム mapper を記述せずに、生成される要約で使用されるラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します。 +ネストされたハンドオフは、オプトインのベータ機能として利用できます。`RunConfig(nest_handoff_history=True)` を渡して順序付きトランスクリプト圧縮を有効にするか、`handoff(..., nest_handoff_history=True)` を設定して特定のハンドオフに対して有効にします。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、欠損のないメッセージ項目の前後に、生成された assistant 要約セグメントを配置します。未加工のトランスクリプトを保持する場合(デフォルト)は、フラグを設定しないか、必要な形式で会話をそのまま転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せず、生成される要約セグメントで使用されるラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。デフォルトに戻すには、[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を使用します。 #### 実行設定の詳細 @@ -187,17 +187,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを出力すると、SDK は出力されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツールの数を制限するには、整数値を設定します。 +`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成した場合、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツールの数を制限するには、整数値を設定します。 -これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別のものです。`parallel_tool_calls` は、モデルが 1 回のレスポンスで複数のツール呼び出しを出力できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがツール呼び出しを出力した後に、SDK がローカル関数ツール呼び出しを実行する方法を制御します。 +これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別の設定です。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがツール呼び出しを生成した後、SDK がローカル関数ツール呼び出しをどのように実行するかを制御します。 -`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、実行は最初に一時停止し、ツール入力ガードレールは承認後の実行直前にのみ実行されます。保留中の承認による中断が発生する前に関数ツールの入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックに合格した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間依存のチェックは実行前に再検証されます。 +`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後、実行直前にのみ実行されます。保留中の承認による中断が生成される前に関数ツールの入力ガードレールを実行するには、`True` に設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再実行されるため、時間依存のチェックは実行前に再検証されます。 ##### `tool_not_found_behavior` -デフォルトでは、モデルが現在のエージェントで使用可能な関数ツールのいずれにも一致しない関数ツール呼び出しを出力すると、runner は `ModelBehaviorError` を発生させます。 +デフォルトでは、モデルが現在のエージェントで使用可能ないずれの関数ツールにも一致しない関数ツール呼び出しを生成すると、Runner は `ModelBehaviorError` を発生させます。 -実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は解決できないツール呼び出しに対する `function_call_output` を追加し、モデルを再実行します。これにより、モデルは使用可能なツールを選択するか、そのツールを使用せずに回答できます。 +実行を復旧可能な状態に保つには、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK が未解決のツール呼び出しに対する `function_call_output` を追加し、モデルを再実行します。これにより、モデルは使用可能なツールを選択するか、そのツールを使用せずに回答できます。 ```python from agents import Agent, RunConfig, Runner @@ -211,20 +211,20 @@ result = await Runner.run( ) ``` -現在、このオプションは解決できない関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードには、既存のエラー動作が引き続き適用されます。 +現在、このオプションは未解決の関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、既存のエラー動作が引き続き使用されます。 ##### `tool_error_formatter` -SDK がモデルから参照可能なツールエラー出力を作成する際にモデルへ返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 +SDK がモデルから確認できるツールエラー出力を作成する際、モデルへ返されるメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 -formatter は、次の内容を含む [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 +フォーマッターは、次の内容を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 -- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリーです。 -- `tool_type`:ツールのランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)です。 -- `tool_name`:ツール名です。 -- `call_id`:ツール呼び出し ID です。 -- `default_message`:モデルから参照可能な SDK のデフォルトメッセージです。 -- `run_context`:アクティブな実行コンテキストのラッパーです。 +- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリーです。 +- `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)です。 +- `tool_name`:ツール名です。 +- `call_id`:ツール呼び出し ID です。 +- `default_message`:モデルから確認できる SDK のデフォルトメッセージです。 +- `run_context`:アクティブな実行コンテキストラッパーです。 メッセージを置き換えるには文字列を返し、SDK のデフォルトを使用するには `None` を返します。 @@ -253,52 +253,52 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` は、runner が履歴を次のターンへ引き継ぐ際に、推論項目を次のターンのモデル入力へ変換する方法を制御します(たとえば、`RunResult.to_input_list()` またはセッションに基づく実行を使用する場合)。 +`reasoning_item_id_policy` は、Runner が履歴を引き継ぐ際(たとえば、`RunResult.to_input_list()` やセッションを使用する実行の場合)に、推論項目を次のターンのモデル入力へ変換する方法を制御します。 -- `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 -- `"omit"`:生成される次のターンの入力から推論項目 ID を削除します。 +- `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 +- `"omit"`:生成される次のターンの入力から推論項目 ID を削除します。 -`"omit"` は主に、推論項目が `id` を伴って送信される一方で、必須の後続項目がない場合に発生する Responses API の 400 エラー群に対する、オプトインの緩和策として使用します(例:`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` は主に、推論項目が `id` 付きで送信される一方、後続の必須項目がない場合に発生する Responses API の 400 エラーの一種に対する、オプトインの緩和策として使用します(例:`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -これは、SDK が以前の出力から後続入力を構築し、推論項目 ID が保持される一方で、プロバイダーがその ID と対応する後続項目とのペアを維持するよう要求する場合に、複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミング/非ストリーミングの後続ターン、再開パスが含まれます。 +これは、SDK が以前の出力から後続入力を構築する複数ターンのエージェント実行で発生する可能性があります。対象には、セッションの永続化、サーバー管理の会話差分、ストリーミング/非ストリーミングの後続ターン、再開パスが含まれます。このとき推論項目 ID が保持されていても、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合があります。 -`reasoning_item_id_policy="omit"` を設定すると、推論内容を保持しながら推論項目の `id` を削除できます。これにより、SDK が生成する後続入力で、その API の不変条件に抵触することを回避できます。 +`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持しながら推論項目の `id` が削除されるため、SDK が生成する後続入力でこの API の不変条件に抵触することを回避できます。 適用範囲に関する注意事項: -- これは、SDK が後続入力を構築するときに生成または転送する推論項目のみを変更します。 -- ユーザーが指定した初期入力項目は書き換えません。 -- このポリシーの適用後でも、`call_model_input_filter` によって意図的に推論 ID を再導入できます。 +- これは、SDK が後続入力を構築する際に生成または転送する推論項目のみを変更します。 +- ユーザーが指定した初期入力項目は書き換えません。 +- `call_model_input_filter` では、このポリシーの適用後に意図的に推論 ID を再導入できます。 ## 状態と会話の管理 ### メモリ戦略の選択 -状態を次のターンへ引き継ぐ一般的な方法は 4 つあります。 +次のターンへ状態を引き継ぐ一般的な方法は 4 つあります。 | 戦略 | 状態の保存場所 | 最適な用途 | 次のターンで渡すもの | | --- | --- | --- | --- | | `result.to_input_list()` | アプリのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | | `session` | ストレージと SDK | 永続的なチャット状態、再開可能な実行、カスタムストア | 同じ `session` インスタンス、または同じストアを参照する別のインスタンス | | `conversation_id` | OpenAI Conversations API | ワーカーやサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | -| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに行う軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | +| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに行う、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | -`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両方のレイヤーを意図的に調整している場合を除き、コンテキストが重複する可能性があります。 +`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両レイヤーを意図的に整合させない限り、コンテキストが重複する可能性があります。 !!! note - セッションの永続化と、サーバー管理の会話設定 + 同じ実行内で、セッションの永続化とサーバー管理の会話設定 (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)を - 同じ実行で組み合わせることはできません。呼び出しごとにいずれか 1 つの方法を選択してください。 + 組み合わせることはできません。呼び出しごとに 1 つの方法を選択してください。 ### 会話/チャットスレッド -いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があり、それに伴って LLM が 1 回以上呼び出されますが、これはチャット会話における論理的な 1 ターンを表します。次に例を示します。 +いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があります(したがって、LLM が 1 回以上呼び出される可能性があります)が、チャット会話では論理的に 1 回のターンを表します。たとえば、次のようになります。 1. ユーザーターン:ユーザーがテキストを入力します。 -2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 +2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行し、2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーに何を表示するかを選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力のみを表示することもできます。いずれの場合も、ユーザーが続けて質問した場合は、run メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力のみを表示することもできます。いずれの場合でも、ユーザーが続けて質問する可能性があり、その場合は run メソッドを再度呼び出せます。 #### 手動による会話管理 @@ -326,7 +326,7 @@ async def main(): #### セッションによる自動会話管理 -より簡単な方法として、[Sessions](sessions/index.md) を使用すると、`.to_input_list()` を手動で呼び出すことなく、会話履歴を自動的に処理できます。 +より簡単な方法として、`.to_input_list()` を手動で呼び出さずに会話履歴を自動管理するには、[Sessions](sessions/index.md) を使用できます。 ```python from agents import Agent, Runner, SQLiteSession, trace @@ -352,22 +352,22 @@ async def main(): Sessions は次の処理を自動的に行います。 -- 各実行の前に会話履歴を取得します。 -- 各実行の後に新しいメッセージを保存します。 -- セッション ID ごとに個別の会話を維持します。 +- 各実行の前に会話履歴を取得します。 +- 各実行の後に新しいメッセージを保存します。 +- セッション ID ごとに個別の会話を維持します。 詳細については、[Sessions のドキュメント](sessions/index.md)を参照してください。 #### サーバー管理の会話 -`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能にサーバー側で会話状態を管理させることもできます。これにより、過去のすべてのメッセージを毎回手動で再送信することなく、会話履歴を維持できます。以下のいずれかのサーバー管理方式では、各リクエストで新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)を参照してください。 +`to_input_list()` や `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能を使用してサーバー側で会話状態を管理することもできます。これにより、過去のすべてのメッセージを手動で再送信せずに会話履歴を保持できます。以下のいずれのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)を参照してください。 -OpenAI は、ターンをまたいで状態を追跡する 2 つの方法を提供します。 +OpenAI では、ターン間で状態を追跡するために 2 つの方法を提供しています。 ##### 1. `conversation_id` の使用 -まず OpenAI Conversations API を使用して会話を作成し、その後の各呼び出しで ID を再利用します。 +最初に OpenAI Conversations API を使用して会話を作成し、その後のすべての呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -390,7 +390,7 @@ async def main(): ##### 2. `previous_response_id` の使用 -もう 1 つの方法は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的に関連付けます。 +もう 1 つの方法は **レスポンスチェイニング** です。この方式では、各ターンを前のターンのレスポンス ID に明示的にリンクします。 ```python from agents import Agent, Runner @@ -415,30 +415,32 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開した場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を保持するため、再開したターンは同じサーバー管理の会話内で継続されます。 +実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開した場合、SDK は保存済みの `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を維持するため、再開されたターンは同じサーバー管理の会話内で続行されます。 -`conversation_id` と `previous_response_id` は同時に使用できません。システム間で共有できる名前付き会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 +`conversation_id` と `previous_response_id` は相互排他的です。システム間で共有できる名前付きの会話リソースが必要な場合は、`conversation_id` を使用します。ターンから次のターンへの最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 !!! note - SDK は `conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話を使用する実行では、同じ準備済み項目を問題なく再送信できるよう、再試行前に内部の - conversation tracker の入力を巻き戻します。 + SDK は、`conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の + 会話実行では、再試行前に内部の会話トラッカー入力を巻き戻し、準備済みの同じ項目を + 正常に再送信できるようにします。 - ローカルのセッションに基づく実行(`conversation_id`、`previous_response_id`、 - `auto_previous_response_id` のいずれとも組み合わせられません)でも、SDK は再試行後に - 履歴項目が重複することを抑えるため、直近に永続化された入力項目のロールバックを可能な範囲で行います。 + ローカルのセッションベースの実行(`conversation_id`、`previous_response_id`、 + `auto_previous_response_id` のいずれとも組み合わせられません)では、SDK は再試行後に + 履歴項目が重複するのを減らすため、直近で永続化された入力項目のロールバックも + ベストエフォートで実行します。 - この互換性維持のための再試行は、`ModelSettings.retry` を設定していない場合でも行われます。 - モデルリクエストに対する、より広範なオプトインの再試行動作については、[Runner 管理の再試行](models/index.md#runner-managed-retries)を参照してください。 + この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも実行されます。 + モデルリクエストに対する、より広範なオプトインの再試行動作については、 + [Runner 管理の再試行](models/index.md#runner-managed-retries)を参照してください。 ## フックとカスタマイズ -### モデル呼び出しの入力フィルター +### モデル呼び出し入力フィルター -モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、および統合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 +モデルを呼び出す直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、統合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 -戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須で、入力項目のリストでなければなりません。それ以外の形式を返すと `UserError` が発生します。 +戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。それ以外の形式を返すと、`UserError` が発生します。 ```python from agents import Agent, Runner, RunConfig @@ -457,19 +459,19 @@ result = Runner.run_sync( ) ``` -runner は準備済み入力リストのコピーをフックに渡すため、呼び出し元の元のリストを直接変更することなく、短縮、置換、並べ替えを行えます。 +Runner は準備済みの入力リストのコピーをフックへ渡すため、呼び出し元の元のリストを直接変更することなく、短縮、置換、並べ替えを行えます。 -セッションを使用している場合、`call_model_input_filter` はセッション履歴がすでに読み込まれ、現在のターンとマージされた後に実行されます。この前段階のマージ処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 +セッションを使用している場合、`call_model_input_filter` は、セッション履歴がすでに読み込まれ、現在のターンと統合された後に実行されます。それ以前の統合ステップ自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 -`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再現ではなく、新しいターンの差分のみをすでに表している場合があります。返された項目だけが、そのサーバー管理の継続処理に送信済みとしてマークされます。 +`conversation_id`、`previous_response_id`、または `auto_previous_response_id` で OpenAI のサーバー管理の会話状態を使用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体を再現したものではなく、新しいターンの差分のみをすでに表している可能性があります。返した項目のみが、そのサーバー管理の継続処理で送信済みとして記録されます。 -機密データの編集、長い履歴の短縮、追加のシステムガイダンスの挿入を行うには、`run_config` を介して実行ごとにフックを設定します。 +機密データの秘匿、長い履歴の短縮、追加のシステムガイダンスの挿入を行うには、`run_config` を通じて実行ごとにフックを設定します。 ## エラーと復旧 ### エラーハンドラー -すべての `Runner` エントリーポイントは、エラー種別をキーとする dict の `error_handlers` を受け取ります。サポートされているキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 +すべての `Runner` エントリポイントは、エラー種別をキーとする辞書 `error_handlers` を受け取ります。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 ```python from agents import ( @@ -498,7 +500,7 @@ result = Runner.run_sync( print(result.final_output) ``` -モデルメッセージがエージェントの structured な `output_type` に対する検証に失敗した場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと、復旧を行いません。フォールバックがない場合、空でないレスポンスの検証失敗では引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 +モデルメッセージがエージェントの structured な `output_type` に対する検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してその値を検証します。モデル呼び出しの再試行や、ツールによる副作用の再実行は行いません。`None` を返すと復旧を行いません。フォールバックがない場合、空でないレスポンスの検証エラーでは引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 ```python from pydantic import BaseModel @@ -530,9 +532,9 @@ result = Runner.run_sync( print(result.final_output) ``` -フォールバック出力を会話履歴に追加しない場合は、`include_in_history=False` を設定します。 +フォールバック出力を会話履歴へ追加しない場合は、`include_in_history=False` を設定します。 -モデルによる拒否が発生した際に、`ModelRefusalError` で実行を終了する代わりにアプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 +モデルの拒否によって `ModelRefusalError` で実行を終了する代わりに、アプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 ```python from pydantic import BaseModel @@ -564,35 +566,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続的な実行の統合とヒューマンインザループ +## 永続実行との統合と Human-in-the-loop -ツール承認の一時停止/再開パターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から参照してください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 +ツール承認の一時停止/再開パターンについては、専用の [Human-in-the-loop ガイド](human_in_the_loop.md)を最初に参照してください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを対象としています。 ### Dapr -Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、ヒューマンインザループをサポートし、障害から自動的に復旧する、永続的かつ長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの使用を開始するには、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)を参照してください。 +Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、Human-in-the-loop をサポートし、障害から自動的に復旧する、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの使用を開始するには、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)を参照してください。 ### Temporal -Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモについては、[この動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)を参照してください。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)から確認できます。 +Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、Human-in-the-loop タスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)です。 ### Restate -Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを利用できます。この統合では、Restate の単一バイナリランタイムが依存関係として必要です。また、エージェントをプロセス/コンテナまたはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)を参照してください。 +Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実現できます。この統合では、Restate の単一バイナリランタイムが依存関係として必要であり、エージェントをプロセス/コンテナまたはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)を参照してください。 ### DBOS -Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方に対応しています。この統合に必要なのは、SQLite または Postgres データベースのみです。詳細については、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)を参照してください。 +Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、Human-in-the-loop ワークフロー、ハンドオフをサポートしています。また、同期メソッドと非同期メソッドの両方をサポートしています。この統合に必要なのは、SQLite または Postgres データベースのみです。詳細については、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)を参照してください。 ## 例外 SDK は特定の状況で例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 -- [`AgentsException`][agents.exceptions.AgentsException]:SDK 内で発生するすべての例外の基底クラスです。他のすべての具体的な例外は、この汎用型から派生します。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドに渡された `max_turns` 制限を超えた場合に発生する例外です。指定された対話ターン数以内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生する例外です。これには次のものが含まれます。 - - 不正な JSON:特に特定の `output_type` が定義されている場合に、モデルがツール呼び出しまたは直接出力で不正な JSON 構造を返した場合です。 - - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合です。 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定されたタイムアウトを超え、そのツールが `timeout_behavior="raise_exception"` を使用している場合に発生する例外です。 -- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードを作成しているユーザーが、SDK の使用時に誤りを犯した場合に発生する例外です。通常は、不適切なコード実装、無効な設定、SDK API の誤用によって発生します。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:それぞれ、入力ガードレールまたは出力ガードレールの条件が満たされた場合に発生する例外です。入力ガードレールは処理前に受信メッセージをチェックし、出力ガードレールは配信前にエージェントの最終レスポンスをチェックします。 +- [`AgentsException`][agents.exceptions.AgentsException]:SDK 内で発生するすべての例外の基底クラスです。他のすべての具体的な例外は、この汎用型から派生します。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドへ渡された `max_turns` の制限を超えた場合に発生します。これは、指定された対話ターン数以内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。これには次のものが含まれます。 + - 不正な形式の JSON:特に特定の `output_type` が定義されている場合に、モデルがツール呼び出しまたは直接出力で不正な形式の JSON 構造を提供した場合です。 + - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合です。 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定されたタイムアウトを超え、そのツールが `timeout_behavior="raise_exception"` を使用している場合に発生します。 +- [`UserError`][agents.exceptions.UserError]:SDK を使用してコードを記述しているユーザーが、SDK の使用中に誤りを犯した場合に発生します。通常は、不正なコード実装、無効な設定、SDK API の誤用が原因です。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:それぞれ、入力ガードレールまたは出力ガードレールの条件が満たされた場合に発生します。入力ガードレールは処理前に受信メッセージを確認し、出力ガードレールは配信前にエージェントの最終レスポンスを確認します。 diff --git a/docs/ja/sandbox/clients.md b/docs/ja/sandbox/clients.md index ee9e831f80..3edc20aea0 100644 --- a/docs/ja/sandbox/clients.md +++ b/docs/ja/sandbox/clients.md @@ -117,7 +117,7 @@ run_config = RunConfig( | `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` による `rclone` ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | | `E2BSandboxClient` | `E2BCloudBucketMountStrategy` による `rclone` ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | | `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` による `rclone` ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | -| `VercelSandboxClient` | 現時点ではホスト型固有のマウント戦略は公開されていません。代わりにマニフェストファイル、リポジトリ、またはその他のワークスペース入力を使用してください。 | +| `VercelSandboxClient` | `VercelCloudBucketMountStrategy` と `S3Mount` による、作成時限定の S3 および S3 互換バケットマウントをサポートします。マウントを含むセッションは再開できず、インライン認証情報を使用するには `allow_s3_credential_exposure=True` が必要です。 | @@ -134,8 +134,8 @@ run_config = RunConfig( | `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | -| `VercelSandboxClient` | - | - | - | - | - | - | +| `VercelSandboxClient` | ✓ | - | - | - | - | - | -実行可能なコード例をさらに見るには、ローカル、コーディング、メモリ、ハンドオフ、エージェント合成パターンについては [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) を、ホスト型サンドボックスクライアントについては [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) を参照してください。 \ No newline at end of file +実行可能なコード例をさらに見るには、ローカル、コーディング、メモリ、ハンドオフ、エージェント合成パターンについては [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) を、ホスト型サンドボックスクライアントについては [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) を参照してください。 diff --git a/docs/ja/streaming.md b/docs/ja/streaming.md index 62f7e42169..6c60153e26 100644 --- a/docs/ja/streaming.md +++ b/docs/ja/streaming.md @@ -4,19 +4,19 @@ search: --- # ストリーミング -ストリーミングにより、エージェントの実行が進むにつれて更新を購読できます。これは、エンドユーザーに進捗状況の更新や部分的なレスポンスを表示する場合に役立ちます。 +ストリーミングを使用すると、エージェントの実行中に更新を購読できます。エンドユーザーに進捗状況の更新や部分的なレスポンスを表示する場合に役立ちます。 -ストリーミングするには、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を呼び出します。これにより [`RunResultStreaming`][agents.result.RunResultStreaming] が返されます。`result.stream_events()` を呼び出すと、以下で説明する [`StreamEvent`][agents.stream_events.StreamEvent] オブジェクトの非同期ストリームが得られます。 +ストリーミングするには、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を呼び出します。これにより、[`RunResultStreaming`][agents.result.RunResultStreaming] が返されます。`result.stream_events()` を呼び出すと、以下で説明する [`StreamEvent`][agents.stream_events.StreamEvent] オブジェクトの非同期ストリームが返されます。 -非同期イテレーターが終了するまで、`result.stream_events()` を消費し続けてください。ストリーミング実行は、イテレーターが終了するまで完了しません。また、セッションの永続化、承認の記録管理、履歴の圧縮などの後処理は、最後の可視トークンが到着した後に完了する場合があります。ループが終了すると、`result.is_complete` は最終的な実行状態を反映します。 +非同期イテレーターが終了するまで、`result.stream_events()` を消費し続けてください。ストリーミング実行は、イテレーターが終了するまで完了しません。また、セッションの永続化、承認の記録管理、履歴の圧縮などの後処理は、最後に表示されるトークンが到着した後に完了する場合があります。ループが終了すると、`result.is_complete` に最終的な実行状態が反映されます。 ## raw レスポンスイベント -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] は、LLM から直接渡される raw イベントです。これらは OpenAI Responses API 形式であり、各イベントには型(`response.created`、`response.output_text.delta` など)とデータがあります。これらのイベントは、レスポンスメッセージが生成され次第、ユーザーにストリーミングしたい場合に役立ちます。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] は、LLM から直接渡される raw イベントです。これらは OpenAI Responses API 形式であり、各イベントには型(`response.created`、`response.output_text.delta` など)とデータがあります。これらのイベントは、生成されたレスポンスメッセージをすぐにユーザーへストリーミングする場合に役立ちます。 -コンピュータツールの raw イベントは、保存された実行結果と同じ preview と GA の区別を維持します。Preview フローでは、1 つの `action` を持つ `computer_call` アイテムをストリーミングします。一方、`gpt-5.5` では、バッチ化された `actions[]` を持つ `computer_call` アイテムをストリーミングできます。高レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] サーフェスは、このために特別なコンピュータ専用イベント名を追加しません。どちらの形式も引き続き `tool_called` として表面化し、スクリーンショットの実行結果は `computer_call_output` アイテムをラップする `tool_output` として返されます。 +コンピュータツールの raw イベントでは、保存された結果と同様に、プレビュー版と GA 版が区別されます。プレビュー版のフローでは、1 つの `action` を持つ `computer_call` 項目がストリーミングされます。一方、`gpt-5.5` では、バッチ化された `actions[]` を持つ `computer_call` 項目をストリーミングできます。上位レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] インターフェースでは、このためにコンピュータ専用の特別なイベント名は追加されません。どちらの形式も引き続き `tool_called` として公開され、スクリーンショットの結果は `computer_call_output` 項目をラップする `tool_output` として返されます。 -たとえば、これは LLM によって生成されたテキストをトークンごとに出力します。 +たとえば、次の例では LLM が生成したテキストをトークン単位で出力します。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## ストリーミングと承認 -ストリーミングは、ツール承認のために一時停止する実行と互換性があります。ツールに承認が必要な場合、`result.stream_events()` は終了し、保留中の承認は [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。`result.to_state()` を使って実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 +ストリーミングは、ツールの承認のために一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` が終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,25 +57,25 @@ if result.interruptions: pass ``` -一時停止/再開の完全なウォークスルーについては、[human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +一時停止と再開の詳しい手順については、[human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 -## 現在のターン後のストリーミングのキャンセル +## 現在のターン終了後のストリーミングキャンセル -途中でストリーミング実行を停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、これにより実行はすぐに停止します。停止する前に現在のターンを正常に完了させるには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 +ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行は直ちに停止します。停止する前に現在のターンを正常に完了させるには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 -ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後の可視トークンの後も、SDK がセッションアイテムを永続化したり、承認状態を確定したり、履歴を圧縮したりしている場合があります。 +ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後に表示されるトークンの後も、SDK がセッション項目を永続化したり、承認状態を確定したり、履歴を圧縮したりしている可能性があります。 -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で継続しており、`cancel(mode="after_turn")` がツールターンの後で停止した場合は、すぐに新しいユーザーターンを追加するのではなく、その正規化された入力で `result.last_agent` を再実行して、未完了のターンを継続してください。 -- ストリーミング実行がツール承認のために停止した場合、それを新しいターンとして扱わないでください。ストリームの読み出しを最後まで完了し、`result.interruptions` を確認して、代わりに `result.to_state()` から再開してください。 -- 次のモデル呼び出しの前に、取得したセッション履歴と新しいユーザー入力をどのようにマージするかをカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。そこで新しいターンのアイテムを書き換えた場合、その書き換え後のバージョンがそのターンとして永続化されます。 +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で処理を継続している場合に、`cancel(mode="after_turn")` がツールターンの後で停止したときは、新しいユーザーターンをすぐに追加するのではなく、正規化された入力で `result.last_agent` を再実行して、その未完了のターンを継続してください。 +- ストリーミング実行がツールの承認のために停止した場合は、それを新しいターンとして扱わないでください。ストリームを最後まで消費し、`result.interruptions` を確認して、`result.to_state()` から再開してください。 +- 次回のモデル呼び出し前に、取得したセッション履歴と新しいユーザー入力をどのように統合するかをカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。そのコールバック内で新しいターンの項目を書き換えた場合、そのターンでは書き換え後のバージョンが永続化されます。 -## 実行アイテムイベントとエージェントイベント +## 実行項目イベントとエージェントイベント -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より高レベルのイベントです。アイテムが完全に生成されたタイミングを通知します。これにより、各トークン単位ではなく、「メッセージが生成された」「ツールが実行された」などのレベルで進捗更新を送信できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(例: ハンドオフの結果として)に更新を提供します。 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より上位レベルのイベントです。項目が完全に生成された時点を通知します。これにより、各トークン単位ではなく、「メッセージが生成された」「ツールが実行された」などの単位で進捗状況の更新を送信できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(ハンドオフの結果など)に更新を提供します。 -### 実行アイテムイベント名 +### 実行項目のイベント名 -`RunItemStreamEvent.name` は、固定された一連のセマンティックなイベント名を使用します。 +`RunItemStreamEvent.name` では、次の固定された一連の意味的イベント名を使用します。 - `message_output_created` - `handoff_requested` @@ -89,18 +89,21 @@ if result.interruptions: - `mcp_approval_response` - `mcp_list_tools` -`handoff_occured` は、後方互換性のため意図的にスペルミスのままになっています。 +`handoff_occured` は、後方互換性のために意図的にスペルミスのままになっています。 -ホストされたツール検索を使用する場合、モデルがツール検索リクエストを発行すると `tool_search_called` が送出され、Responses API が読み込まれたサブセットを返すと `tool_search_output_created` が送出されます。 +ホスト型ツール検索を使用すると、モデルがツール検索リクエストを発行したときに `tool_search_called` が生成され、Responses API が読み込まれたサブセットを返したときに `tool_search_output_created` が生成されます。 -たとえば、これは raw イベントを無視し、更新をユーザーにストリーミングします。 +プログラムによるツール呼び出しでは、生成された `program` と、プログラムが所有する通常の子ツール呼び出しに対して `tool_called` が生成されます。子ツールの出力と対応する `program_output` に対しては、`tool_output` が生成されます。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外です。これらはそれぞれ、[`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] と [`MCPListToolsItem`][agents.items.MCPListToolsItem] をラップする `mcp_approval_requested` および `mcp_list_tools` として生成されます。残りの項目を区別するには、raw 項目の `type` を確認してください。また、プログラムが所有する子呼び出しには `caller` も含まれ、その型は `program` で、呼び出し元 ID によって親プログラムが識別されます。 + +たとえば、次の例では raw イベントを無視し、ユーザーへの更新をストリーミングします。 ```python import asyncio import random -from agents import Agent, ItemHelpers, Runner, function_tool +from agents import Agent, ItemHelpers, Runner +from agents.decorators import tool -@function_tool +@tool def how_many_jokes() -> int: return random.randint(1, 10) diff --git a/docs/ja/tools.md b/docs/ja/tools.md index 080394416d..636ec9d5d6 100644 --- a/docs/ja/tools.md +++ b/docs/ja/tools.md @@ -4,42 +4,44 @@ search: --- # ツール -ツールを使用すると、データの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作など、エージェントがさまざまなアクションを実行できます。SDK は、次の 5 つのカテゴリーをサポートしています。 +ツールを使用すると、エージェントはデータの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作などのアクションを実行できます。SDK は 5 つのカテゴリーをサポートしています。 -- OpenAI がホストするツール:OpenAI のサーバー上でモデルとともに実行されます。 -- ローカル/ランタイム実行ツール:`ComputerTool` と `ApplyPatchTool` は常にユーザーの環境で実行され、`ShellTool` はローカルまたはホスト型コンテナで実行できます。 -- Function Calling:任意の Python 関数をツールとしてラップします。 -- Agents as tools:完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 -- 実験的機能:Codex ツール:ツール呼び出しから、ワークスペースにスコープされた Codex タスクを実行します。 +- OpenAI がホストするツール: OpenAI のサーバー上でモデルとともに実行されます。 +- ローカル/ランタイム実行ツール: `ComputerTool` と `ApplyPatchTool` は常にご利用の環境で実行され、`ShellTool` はローカルまたはホスト型コンテナで実行できます。 +- Function Calling: 任意の Python 関数をツールとしてラップします。 +- Agents as tools: 完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 +- 実験的機能: Codex ツール: ツール呼び出しからワークスペーススコープの Codex タスクを実行します。 ## ツールタイプの選択 -このページをカタログとして使用し、制御するランタイムに該当するセクションへ移動してください。 +このページをカタログとして利用し、ご自身が制御するランタイムに該当するセクションへ移動してください。 | 目的 | 参照先 | | --- | --- | | OpenAI が管理するツール(Web 検索、ファイル検索、Code Interpreter、ホスト型 MCP、画像生成)を使用する | [ホスト型ツール](#hosted-tools) | -| ツール検索を使用して、大規模なツール群の読み込みをランタイムまで延期する | [ホスト型ツール検索](#hosted-tool-search) | -| 独自のプロセスまたは環境でツールを実行する | [ローカルランタイムツール](#local-runtime-tools) | +| ツール検索を使用して、大規模なツール群の読み込みをランタイムまで遅延させる | [ホスト型ツール検索](#hosted-tool-search) | +| 生成された JavaScript から複数のツール呼び出しを調整する | [プログラムによるツール呼び出し](#programmatic-tool-calling) | +| ご自身のプロセスまたは環境でツールを実行する | [ローカルランタイムツール](#local-runtime-tools) | | Python 関数をツールとしてラップする | [関数ツール](#function-tools) | -| ハンドオフを行わず、あるエージェントから別のエージェントを呼び出す | [Agents as tools](#agents-as-tools) | -| エージェントから、ワークスペースにスコープされた Codex タスクを実行する | [実験的機能:Codex ツール](#experimental-codex-tool) | +| ハンドオフを行わずに、あるエージェントから別のエージェントを呼び出せるようにする | [Agents as tools](#agents-as-tools) | +| エージェントからワークスペーススコープの Codex タスクを実行する | [実験的機能: Codex ツール](#experimental-codex-tool) | ## ホスト型ツール -[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合、OpenAI はいくつかの組み込みツールを提供します。 +OpenAI は、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合に、いくつかの組み込みツールを提供しています。 - [`WebSearchTool`][agents.tool.WebSearchTool] を使用すると、エージェントは Web を検索できます。 -- [`FileSearchTool`][agents.tool.FileSearchTool] を使用すると、OpenAI のベクトルストアから情報を取得できます。 +- [`FileSearchTool`][agents.tool.FileSearchTool] を使用すると、OpenAI ベクトルストアから情報を取得できます。 - [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] を使用すると、LLM はサンドボックス環境でコードを実行できます。 - [`HostedMCPTool`][agents.tool.HostedMCPTool] は、リモート MCP サーバーのツールをモデルに公開します。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] は、プロンプトから画像を生成します。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルは遅延読み込みされるツール、名前空間、またはホスト型 MCP サーバーをオンデマンドで読み込めます。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルは遅延読み込みされたツール、名前空間、またはホスト型 MCP サーバーを必要に応じて読み込めます。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルは生成された JavaScript から対象ツールを調整できます。 -ホスト型検索の高度なオプション: +ホスト型検索の高度なオプション: -- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。 -- `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートします。 +- `FileSearchTool` は、`vector_store_ids` および `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートしています。 +- `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートしています。 ```python from agents import Agent, FileSearchTool, Runner, WebSearchTool @@ -62,17 +64,18 @@ async def main(): ### ホスト型ツール検索 -ツール検索を使用すると、OpenAI Responses モデルは大規模なツール群の読み込みをランタイムまで延期できるため、現在のターンに必要なサブセットのみをモデルが読み込みます。多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールを事前に公開せずにツールスキーマのトークンを削減したい場合に便利です。 +ツール検索を使用すると、OpenAI Responses モデルは大規模なツール群の読み込みをランタイムまで遅延させ、現在のターンに必要なサブセットのみを読み込めます。これは、多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールを事前に公開せずにツールスキーマのトークン数を削減したい場合に便利です。 -エージェントを構築する時点で候補ツールがすでに判明している場合は、ホスト型ツール検索から始めてください。アプリケーション側で読み込む内容を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしていますが、標準の `Runner` はそのモードを自動実行しません。 +エージェントを構築する時点で候補ツールがすでに判明している場合は、ホスト型ツール検索から始めてください。アプリケーション側で読み込む対象を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしていますが、標準の `Runner` はこのモードを自動実行しません。 ```python from typing import Annotated -from agents import Agent, Runner, ToolSearchTool, function_tool, tool_namespace +from agents import Agent, Runner, ToolSearchTool, tool_namespace +from agents.decorators import tool -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_customer_profile( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -80,7 +83,7 @@ def get_customer_profile( return f"profile for {customer_id}" -@function_tool(defer_loading=True) +@tool(defer_loading=True) def list_open_orders( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -106,24 +109,77 @@ result = await Runner.run(agent, "Look up customer_42 and list their open orders print(result.final_output) ``` -注意事項: +注意事項: - ホスト型ツール検索は、OpenAI Responses モデルでのみ利用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 -- エージェントに遅延読み込み対象を設定する場合は、`ToolSearchTool()` を 1 つだけ追加してください。 +- エージェントで遅延読み込み対象を設定する場合は、`ToolSearchTool()` を 1 つだけ追加してください。 - 検索可能な対象には、`@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])`、`HostedMCPTool(tool_config={..., "defer_loading": True})` が含まれます。 -- 遅延読み込みされる関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成でも `ToolSearchTool()` を使用すると、モデルが適切なグループをオンデマンドで読み込めます。 -- `tool_namespace()` は、共有の名前空間名と説明の下に `FunctionTool` インスタンスをまとめます。通常、`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に最適です。 +- 遅延読み込みされる関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成でも、モデルが適切なグループを必要に応じて読み込めるよう、`ToolSearchTool()` を使用できます。 +- `tool_namespace()` は、複数の `FunctionTool` インスタンスを共通の名前空間名と説明の下にグループ化します。これは通常、`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に最適です。 - OpenAI の公式ベストプラクティスガイダンスは、[可能な限り名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことです。 -- 可能であれば、個別に遅延読み込みされる関数を多数使用するのではなく、名前空間またはホスト型 MCP サーバーを優先してください。通常、その方がモデルにとって優れた高レベルの検索対象となり、トークンをより効果的に節約できます。 -- 名前空間では、即時利用可能なツールと遅延読み込みされるツールを混在させられます。`defer_loading=True` が指定されていないツールは即座に呼び出し可能なままであり、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 -- 目安として、各名前空間は比較的小さく保ち、関数を 10 個未満にすることが理想的です。 -- 名前付きの `tool_choice` では、単独の名前空間名や遅延読み込み専用ツールを対象にできません。`auto`、`required`、または実際にトップレベルで呼び出し可能なツール名を使用してください。 -- `ToolSearchTool(execution="client")` は、Responses を手動でオーケストレーションするためのものです。モデルがクライアント実行型の `tool_search_call` を出力した場合、標準の `Runner` はそれを実行せずに例外を発生させます。 -- ツール検索のアクティビティは、専用の項目型およびイベント型として [`RunResult.new_items`](results.md#new-items) と [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 -- 名前空間による読み込みとトップレベルの遅延ツールの両方を扱う、実行可能な完全なコード例については、`examples/tools/tool_search.py` を参照してください。 -- 公式プラットフォームガイド:[ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 +- 可能な場合は、個別に遅延読み込みされる多数の関数よりも、名前空間またはホスト型 MCP サーバーを優先してください。通常、モデルにとってより適切な高レベルの検索対象となり、トークンも効率的に節約できます。 +- 名前空間には、即時利用可能なツールと遅延読み込みされるツールを混在させられます。`defer_loading=True` が指定されていないツールはすぐに呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 +- 目安として、各名前空間は比較的小さく保ち、理想的には関数を 10 個未満にしてください。 +- 名前付きの `tool_choice` では、名前空間名そのものや遅延読み込みのみのツールを対象にできません。`auto`、`required`、または実際に呼び出し可能な最上位ツールの名前を使用してください。 +- `ToolSearchTool(execution="client")` は、Responses の手動オーケストレーション用です。モデルがクライアント実行型の `tool_search_call` を生成した場合、標準の `Runner` はそれを実行する代わりに例外を発生させます。 +- ツール検索のアクティビティは、専用のアイテムおよびイベントタイプとして、[`RunResult.new_items`](results.md#new-items) と [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 +- 名前空間を使用した読み込みと最上位の遅延ツールの両方を扱う、実行可能な完全なコード例については、`examples/tools/tool_search.py` を参照してください。 +- 公式プラットフォームガイド: [ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 -### ホスト型コンテナシェルとスキル +### プログラムによるツール呼び出し + +プログラムによるツール呼び出しを使用すると、サポート対象の OpenAI Responses モデルは、対象ツールを呼び出してその出力を組み合わせ、1 つの結果をモデルに返す JavaScript を生成できます。これは、ツール呼び出しごとにモデルとのラウンドトリップを行わずに、ループ、分岐、並列呼び出し、中間計算を活用できる、範囲が限定されたワークフローに役立ちます。 + +生成されたプログラムは、新しいホスト型 V8 環境で実行されます。Node.js API、ファイルシステムやネットワークへのアクセス、永続的なプロセスは利用できません。プログラムが操作できるのは、明示的に許可したツールのみです。 + +```python +from pydantic import BaseModel + +from agents import ( + Agent, + ModelSettings, + ProgrammaticToolCallingTool, + Runner, +) +from agents.decorators import tool + + +class InventoryOutput(BaseModel): + sku: str + available_units: int + + +@tool(allowed_callers=["programmatic"]) +def get_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + +agent = Agent( + name="Inventory planner", + model="gpt-5.6", + model_settings=ModelSettings(tool_choice="programmatic_tool_calling"), + tools=[get_inventory, ProgrammaticToolCallingTool()], +) + +result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it.") +print(result.final_output) +``` + +注意事項: + +- プログラムによるツール呼び出しは、サポート対象の OpenAI Responses モデルでのみ利用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 +- エージェントには、`ProgrammaticToolCallingTool()` を最大 1 つ追加できます。エージェントは、プログラムから呼び出し可能なツール、`ToolSearchTool()`、またはプロンプトで管理されるツール群のうち、少なくとも 1 つも公開する必要があります。 +- `allowed_callers` は、ツールを呼び出す方法を制御します。省略すると、モデルからの直接呼び出しのみが許可されます。プログラムからのみアクセスできるようにするには `["programmatic"]`、両方を許可するには `["direct", "programmatic"]` を使用してください。 +- オプトインできる SDK のツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数ツール、カスタムツール、シェルツール、パッチ適用ツールでは、`allowed_callers` を直接指定できます。ホスト型 MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定してください。 +- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、データクラスなどの構造化された戻り値アノテーションが自動的に厳密なオブジェクト出力スキーマとなり、値がプログラムに返される前に検証されます。関数に利用可能なアノテーションがない場合は `output_type=...` を使用し、厳密なオブジェクトスキーマがすでにある場合は、より低レベルのエスケープハッチである `output_json_schema={...}` を使用してください。`output_type` と `output_json_schema` は同時に使用できません。単純な `str`、`Any`、`None` の戻り値には型が付けられません。 +- プログラムが所有する SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入力および出力ガードレール、フック、タイムアウト、同時実行数の制限、再試行、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 +- 承認が重要なツールや影響の大きいツールは、通常、直接呼び出しとして維持する方が適しています。これにより、大規模なプログラムの一部になる前に、各アクションを人が確認できます。プログラムが所有する呼び出しが承認待ちで一時停止した場合は、通常どおり `RunState` を通じて中断を解決し、元の実行を再開してください。 +- プログラムによるツール呼び出しは、[ホスト型ツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムが遅延ツールを呼び出すには、その前にモデルがツールを読み込む必要があります。 +- `program` アイテムと、プログラムが所有する子呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。確認方法の詳細については、[実行結果](results.md#new-items)および[ストリーミング](streaming.md#run-item-event-names)を参照してください。 +- 同時実行による在庫計画の完全なコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 +- 公式プラットフォームガイド: [プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 + +### ホスト型コンテナシェル + スキル `ShellTool` は、OpenAI がホストするコンテナでの実行もサポートしています。ローカルランタイムではなく、管理されたコンテナ内でモデルにシェルコマンドを実行させたい場合は、このモードを使用してください。 @@ -158,52 +214,52 @@ result = await Runner.run( print(result.final_output) ``` -後続の実行で既存のコンテナを再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 +既存のコンテナを後続の実行で再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 -注意事項: +注意事項: - ホスト型シェルは、Responses API のシェルツールを通じて利用できます。 - `container_auto` はリクエスト用のコンテナをプロビジョニングし、`container_reference` は既存のコンテナを再利用します。 -- `container_auto` には、`file_ids` と `memory_limit` も指定できます。 -- `environment.skills` は、スキル参照およびインラインスキルバンドルを受け付けます。 +- `container_auto` には、`file_ids` と `memory_limit` も含められます。 +- `environment.skills` は、スキルへの参照とインラインスキルバンドルを受け付けます。 - ホスト型環境では、`ShellTool` に `executor`、`needs_approval`、`on_approval` を設定しないでください。 -- `network_policy` は、`disabled` モードと `allowlist` モードをサポートします。 -- 許可リストモードでは、`network_policy.domain_secrets` を使用して、ドメインにスコープされたシークレットを名前で注入できます。 -- 完全なコード例については、`examples/tools/container_shell_skill_reference.py` と `examples/tools/container_shell_inline_skill.py` を参照してください。 -- OpenAI プラットフォームガイド:[シェル](https://platform.openai.com/docs/guides/tools-shell)および[スキル](https://platform.openai.com/docs/guides/tools-skills)。 +- `network_policy` は、`disabled` モードと `allowlist` モードをサポートしています。 +- 許可リストモードでは、`network_policy.domain_secrets` を使用して、ドメインスコープのシークレットを名前で注入できます。 +- 完全なコード例については、`examples/tools/container_shell_skill_reference.py` および `examples/tools/container_shell_inline_skill.py` を参照してください。 +- OpenAI プラットフォームガイド: [シェル](https://platform.openai.com/docs/guides/tools-shell)および[スキル](https://platform.openai.com/docs/guides/tools-skills)。 ## ローカルランタイムツール -ローカルランタイムツールは、モデルのレスポンス自体の外部で実行されます。モデルは引き続き呼び出すタイミングを決定しますが、実際の処理はアプリケーションまたは設定された実行環境が行います。 +ローカルランタイムツールは、モデルのレスポンス自体の外部で実行されます。呼び出すタイミングは引き続きモデルが決定しますが、実際の処理はご利用のアプリケーションまたは設定済みの実行環境が行います。 -`ComputerTool` と `ApplyPatchTool` には、ユーザーが提供するローカル実装が常に必要です。`ShellTool` は両方のモードに対応しています。管理された実行が必要な場合は上記のホスト型コンテナ設定を使用し、独自のプロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用してください。 +`ComputerTool` と `ApplyPatchTool` には、常にご自身で用意したローカル実装が必要です。`ShellTool` は両方のモードに対応しています。管理された実行を使用する場合は前述のホスト型コンテナ設定を使用し、ご自身のプロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用してください。 -ローカルランタイムツールを使用するには、実装を提供する必要があります。 +ローカルランタイムツールでは、実装を用意する必要があります。 -- [`ComputerTool`][agents.tool.ComputerTool]:GUI/ブラウザーの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 -- [`ShellTool`][agents.tool.ShellTool]:ローカル実行とホスト型コンテナ実行の両方に対応する最新のシェルツールです。 -- [`LocalShellTool`][agents.tool.LocalShellTool]:従来のローカルシェル統合です。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:差分をローカルに適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 -- `ShellTool(environment={"type": "local", "skills": [...]})` を使用すると、ローカルシェルスキルを利用できます。 +- [`ComputerTool`][agents.tool.ComputerTool]: GUI/ブラウザの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 +- [`ShellTool`][agents.tool.ShellTool]: ローカル実行とホスト型コンテナ実行の両方に対応する最新のシェルツールです。 +- [`LocalShellTool`][agents.tool.LocalShellTool]: 従来のローカルシェル統合です。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 差分をローカルに適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 +- ローカルシェルスキルは、`ShellTool(environment={"type": "local", "skills": [...]})` で利用できます。 ### ComputerTool と Responses のコンピュータツール -`ComputerTool` は引き続きローカルハーネスです。[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供すると、SDK がそのハーネスを OpenAI Responses API のコンピュータ機能にマッピングします。 +`ComputerTool` は引き続きローカルハーネスです。ご自身で [`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供し、SDK がそのハーネスを OpenAI Responses API のコンピュータ操作インターフェースにマッピングします。 -明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストでは、SDK は GA の組み込みツールペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルでは、プレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` が引き続き使用されます。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)で説明されているプラットフォーム移行に対応しています。 +明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストでは、SDK は GA 版の組み込みツールペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルでは、プレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` が引き続き使用されます。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)で説明されているプラットフォーム移行に対応しています。 -- モデル:`computer-use-preview` -> `gpt-5.5` -- ツールセレクター:`computer_use_preview` -> `computer` -- コンピュータ呼び出しの形式:`computer_call` ごとに 1 つの `action` -> `computer_call` 上の一括 `actions[]` -- 切り詰め:プレビューパスでは `ModelSettings(truncation="auto")` が必須 -> GA パスでは不要 +- モデル: `computer-use-preview` -> `gpt-5.5` +- ツールセレクター: `computer_use_preview` -> `computer` +- コンピュータ呼び出しの形式: `computer_call` ごとに 1 つの `action` -> `computer_call` 上のバッチ化された `actions[]` +- 切り詰め: プレビューパスでは `ModelSettings(truncation="auto")` が必須 -> GA パスでは不要 -SDK は、実際の Responses リクエストで有効なモデルに基づいて、そのワイヤ形式を選択します。プロンプトテンプレートを使用しており、モデルがプロンプト側で指定されているためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示したままにするか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 +SDK は、実際の Responses リクエストにおける有効なモデルから、この通信形式を選択します。プロンプトテンプレートを使用していて、プロンプト側でモデルを指定するためリクエストから `model` が省略される場合、`model="gpt-5.5"` を明示したままにするか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 -[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` がない場合、これらの文字列は通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに対応する組み込みセレクターへ正規化されます。`ComputerTool` がない場合、これらの文字列は通常の関数名と同様に動作します。 -`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーによって提供される場合、この違いは重要です。GA の `computer` ペイロードでは、シリアライズ時に `environment` や表示サイズが不要なため、未解決のファクトリーでも問題ありません。一方、プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるよう、解決済みの `Computer` または `AsyncComputer` インスタンスが必要です。 +この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーによって提供される場合に重要です。GA の `computer` ペイロードでは、シリアライズ時に `environment` や画面サイズが不要なため、未解決のファクトリーでも問題ありません。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるよう、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 -ランタイムでは、どちらのパスも同じローカルハーネスを使用します。プレビューのレスポンスは、単一の `action` を持つ `computer_call` 項目を出力します。`gpt-5.5` は一括の `actions[]` を出力でき、SDK は `computer_call_output` スクリーンショット項目を生成する前に、それらを順番に実行します。Playwright ベースの実行可能なハーネスについては、`examples/tools/computer_use.py` を参照してください。 +ランタイムでは、どちらのパスも同じローカルハーネスを使用します。プレビューのレスポンスでは、単一の `action` を持つ `computer_call` アイテムが生成されます。`gpt-5.5` ではバッチ化された `actions[]` が生成される場合があり、SDK は `computer_call_output` のスクリーンショットアイテムを生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -247,30 +303,31 @@ agent = Agent( ## 関数ツール -任意の Python 関数をツールとして使用できます。Agents SDK がツールを自動的にセットアップします。 +任意の Python 関数をツールとして使用できます。Agents SDK がツールを自動的に設定します。 -- ツール名には Python 関数の名前が使用されます(または名前を指定できます) -- ツールの説明には関数の docstring が使用されます(または説明を指定できます) +- ツール名には Python 関数の名前が使用されます(名前を指定することもできます) +- ツールの説明は関数の docstring から取得されます(説明を指定することもできます) - 関数入力のスキーマは、関数の引数から自動的に作成されます -- 無効にしない限り、各入力の説明は関数の docstring から取得されます +- 無効化されていない限り、各入力の説明は関数の docstring から取得されます -Python の `inspect` モジュールを使用して関数シグネチャを抽出し、[`griffe`](https://mkdocstrings.github.io/griffe/) で docstring を解析し、`pydantic` でスキーマを作成します。 +Python の `inspect` モジュールを使用して関数シグネチャを抽出し、さらに [`griffe`](https://mkdocstrings.github.io/griffe/) で docstring を解析し、`pydantic` でスキーマを作成します。 -OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は、`ToolSearchTool()` が読み込むまで関数ツールを非表示にします。[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 +OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は `ToolSearchTool()` によって読み込まれるまで関数ツールを非表示にします。また、関連する関数ツールを [`tool_namespace()`][agents.tool.tool_namespace] でグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 ```python import json from typing_extensions import TypedDict, Any -from agents import Agent, FunctionTool, RunContextWrapper, function_tool +from agents import Agent, FunctionTool, RunContextWrapper +from agents.decorators import tool class Location(TypedDict): lat: float long: float -@function_tool # (1)! +@tool # (1)! async def fetch_weather(location: Location) -> str: # (2)! """Fetch the weather for a given location. @@ -282,7 +339,7 @@ async def fetch_weather(location: Location) -> str: return "sunny" -@function_tool(name_override="fetch_data") # (3)! +@tool(name_override="fetch_data") # (3)! def read_file(ctx: RunContextWrapper[Any], path: str, directory: str | None = None) -> str: """Read the contents of a file. @@ -308,12 +365,12 @@ for tool in agent.tools: ``` -1. 関数の引数には任意の Python 型を使用でき、関数は同期または非同期にできます。 -2. docstring が存在する場合、説明および引数の説明を取得するために使用されます -3. 関数は必要に応じて `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring スタイルなどをオーバーライドすることもできます。 -4. デコレートした関数をツールのリストに渡せます。 +1. 関数の引数には任意の Python 型を使用でき、関数は同期または非同期のどちらでもかまいません。 +2. docstring が存在する場合は、説明と引数の説明を取得するために使用されます。 +3. 関数は、必要に応じて `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring のスタイルなどを上書きすることもできます。 +4. デコレートされた関数をツールのリストに渡せます。 -??? note "出力の展開表示" +??? note "出力を表示するには展開してください" ``` fetch_weather @@ -385,20 +442,20 @@ for tool in agent.tools: ### 関数ツールからの画像またはファイルの返却 -テキスト出力に加えて、1 つまたは複数の画像やファイルを関数ツールの出力として返せます。そのためには、次のいずれかを返します。 +テキスト出力に加えて、関数ツールの出力として 1 つまたは複数の画像やファイルを返すことができます。そのためには、次のいずれかを返します。 -- 画像:[`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) -- ファイル:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- テキスト:文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- 画像: [`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) +- ファイル: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) +- テキスト: 文字列、文字列に変換可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### カスタム関数ツール -Python 関数をツールとして使用したくない場合もあります。必要に応じて、[`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。次の項目を指定する必要があります。 +Python 関数をツールとして使用したくない場合もあります。その場合は、必要に応じて [`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。以下を指定する必要があります。 - `name` - `description` -- `params_json_schema`:引数の JSON スキーマ -- `on_invoke_tool`:[ツールコンテキスト][agents.tool_context.ToolContext]と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数 +- `params_json_schema`。引数の JSON スキーマです +- `on_invoke_tool`。[`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数です。 ```python from typing import Any @@ -433,43 +490,44 @@ tool = FunctionTool( ### 引数と docstring の自動解析 -前述のように、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールおよび個々の引数の説明を抽出します。これに関する注意事項は次のとおりです。 +前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと個々の引数の説明を抽出します。これに関する注意事項は次のとおりです。 1. シグネチャの解析は `inspect` モジュールを使用して行われます。型アノテーションを使用して引数の型を把握し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートしています。 -2. docstring の解析には `griffe` を使用します。サポートされる docstring 形式は `google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートであり、`function_tool` の呼び出し時に明示的に設定できます。`use_docstring_info` を `False` に設定して、docstring の解析を無効にすることもできます。 +2. docstring の解析には `griffe` を使用します。サポートされている docstring 形式は `google`、`sphinx`、`numpy` です。docstring の形式は自動検出を試みますが、ベストエフォートであるため、`function_tool` を呼び出す際に明示的に設定することもできます。また、`use_docstring_info` を `False` に設定して、docstring の解析を無効にすることもできます。Google スタイルの docstring では、要約テキストの直後に空行を挟まずに配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーで受け付けられます。 スキーマ抽出のコードは [`agents.function_schema`][] にあります。 ### Pydantic Field による引数の制約と説明 -Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用して、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値ベースの形式(`arg: int = Field(..., ge=1)`)と `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)の両方がサポートされます。生成される JSON スキーマとバリデーションには、これらの制約が含まれます。 +Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用して、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値を使用する形式(`arg: int = Field(..., ge=1)`)と `Annotated` を使用する形式(`arg: Annotated[int, Field(..., ge=1)]`)の両方がサポートされています。生成される JSON スキーマと検証には、これらの制約が含まれます。 ```python from typing import Annotated from pydantic import Field -from agents import function_tool +from agents.decorators import tool # Default-based form -@function_tool +@tool def score_a(score: int = Field(..., ge=0, le=100, description="Score from 0 to 100")) -> str: return f"Score recorded: {score}" # Annotated form -@function_tool +@tool def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score from 0 to 100")]) -> str: return f"Score recorded: {score}" ``` ### 関数ツールのタイムアウト -`@function_tool(timeout=...)` を使用して、非同期関数ツールの呼び出しごとにタイムアウトを設定できます。 +`@function_tool(timeout=...)` を使用すると、非同期関数ツールの呼び出しごとにタイムアウトを設定できます。 ```python import asyncio -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(timeout=2.0) +@tool(timeout=2.0) async def slow_lookup(query: str) -> str: await asyncio.sleep(10) return f"Result for {query}" @@ -482,20 +540,21 @@ agent = Agent( ) ``` -タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから確認できるタイムアウトメッセージ(例:`Tool 'slow_lookup' timed out after 2 seconds.`)を送信します。 +タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから確認できるタイムアウトメッセージ(例: `Tool 'slow_lookup' timed out after 2 seconds.`)が送信されます。 タイムアウト処理は次のように制御できます。 -- `timeout_behavior="error_as_result"`(デフォルト):モデルが復旧できるよう、タイムアウトメッセージをモデルに返します。 -- `timeout_behavior="raise_exception"`:[`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 -- `timeout_error_function=...`:`error_as_result` を使用する場合のタイムアウトメッセージをカスタマイズします。 +- `timeout_behavior="error_as_result"`(デフォルト): モデルが回復できるように、タイムアウトメッセージをモデルへ返します。 +- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 +- `timeout_error_function=...`: `error_as_result` を使用する場合のタイムアウトメッセージをカスタマイズします。 ```python import asyncio -from agents import Agent, Runner, ToolTimeoutError, function_tool +from agents import Agent, Runner, ToolTimeoutError +from agents.decorators import tool -@function_tool(timeout=1.5, timeout_behavior="raise_exception") +@tool(timeout=1.5, timeout_behavior="raise_exception") async def slow_tool() -> str: await asyncio.sleep(5) return "done" @@ -511,18 +570,19 @@ except ToolTimeoutError as e: !!! note - タイムアウト設定は、非同期の `@function_tool` ハンドラーでのみサポートされます。 + タイムアウト設定は、非同期の `@function_tool` ハンドラーでのみサポートされています。 ### 関数ツールでのエラー処理 -`@function_tool` を使用して関数ツールを作成する場合、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 +`@function_tool` を使用して関数ツールを作成する際に、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 -- デフォルトでは(何も渡さない場合)、エラーが発生したことを LLM に通知する `default_tool_error_function` が実行されます。 -- 独自のエラー関数を渡した場合は、代わりにその関数が実行され、レスポンスが LLM に送信されます。 -- 明示的に `None` を渡した場合、ツール呼び出しのエラーは再度発生し、ユーザー側で処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` になる可能性があります。 +- デフォルトでは(何も渡さない場合)、エラーが発生したことを LLM に伝える `default_tool_error_function` が実行されます。 +- 独自のエラー関数を渡した場合は、その関数が代わりに実行され、レスポンスが LLM に送信されます。 +- 明示的に `None` を渡した場合、ツール呼び出しのエラーは再度発生し、ご自身で処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` などが発生する可能性があります。 ```python -from agents import function_tool, RunContextWrapper +from agents import RunContextWrapper +from agents.decorators import tool from typing import Any def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) -> str: @@ -530,7 +590,7 @@ def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) print(f"A tool call failed with the following error: {error}") return "An internal server error occurred. Please try again later." -@function_tool(failure_error_function=my_custom_error_function) +@tool(failure_error_function=my_custom_error_function) def get_user_profile(user_id: str) -> str: """Fetches a user profile from a mock API. This function demonstrates a 'flaky' or failing API call. @@ -542,11 +602,11 @@ def get_user_profile(user_id: str) -> str: ``` -`FunctionTool` オブジェクトを手動で作成する場合、`on_invoke_tool` 関数内でエラーを処理する必要があります。 +`FunctionTool` オブジェクトを手動で作成する場合は、`on_invoke_tool` 関数内でエラーを処理する必要があります。 ## Agents as tools -ワークフローによっては、制御をハンドオフする代わりに、中央のエージェントで特化型エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 +一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントで専門的なエージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 ```python import asyncio @@ -592,12 +652,15 @@ if __name__ == "__main__": ### ツールエージェントのカスタマイズ -`agent.as_tool` 関数は、エージェントを簡単にツールへ変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` による構造化入力もサポートします。 +`agent.as_tool` 関数は、エージェントを簡単にツールへ変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートしています。また、`parameters`、`input_builder`、`include_input_schema` を使用した構造化入力もサポートしています。 -状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は自動的には継承されません。クライアント管理の履歴を親実行とネストされた実行の間で共有するには、同じ `session` を両方に明示的に渡してください。`Runner.run` と同様に、ネストされた実行には 1 つの状態戦略を選択してください。クライアント管理の `session`、または `previous_response_id` や `conversation_id` を使用したサーバー管理の継続のいずれかです。 +状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は自動的には継承されません。クライアント管理の履歴を親実行とネストされた実行の間で共有するには、両方に同じ `session` を明示的に渡してください。`Runner.run` と同様に、ネストされた実行では、クライアント管理の `session`、または `previous_response_id` か `conversation_id` を使用したサーバー管理の継続のいずれか 1 つの状態管理方式を選択してください。 ```python -@function_tool +from agents.decorators import tool + + +@tool async def run_my_agent() -> str: """A tool that runs the agent with custom configs""" @@ -615,13 +678,13 @@ async def run_my_agent() -> str: ### ツールエージェントの構造化入力 -デフォルトでは、`Agent.as_tool()` は単一の文字列入力(`{"input": "..."}`)を想定しますが、`parameters`(Pydantic モデルまたは dataclass 型)を渡すことで構造化スキーマを公開できます。 +デフォルトでは、`Agent.as_tool()` は単一の文字列入力(`{"input": "..."}`)を想定しますが、`parameters`(Pydantic モデルまたはデータクラス型)を渡すことで、構造化スキーマを公開できます。 -追加オプション: +追加オプション: - `include_input_schema=True` を指定すると、生成されるネストされた入力に完全な JSON Schema が含まれます。 - `input_builder=...` を使用すると、構造化されたツール引数をネストされたエージェント入力へ変換する方法を完全にカスタマイズできます。 -- `RunContextWrapper.tool_input` には、ネストされた実行コンテキスト内で解析済みの構造化ペイロードが格納されます。 +- `RunContextWrapper.tool_input` には、ネストされた実行コンテキスト内で解析済みの構造化ペイロードが含まれます。 ```python from pydantic import BaseModel, Field @@ -645,15 +708,15 @@ translator_tool = translator_agent.as_tool( ### ツールエージェントの承認ゲート -`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行は一時停止し、保留中の項目が `result.interruptions` に表示されます。次に `result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出した後に再開します。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行は一時停止し、保留中のアイテムが `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開します。一時停止/再開の完全なパターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 ### カスタム出力の抽出 -場合によっては、中央のエージェントに返す前に、ツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 +場合によっては、中央のエージェントへ返す前にツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 - サブエージェントのチャット履歴から特定の情報(JSON ペイロードなど)を抽出する。 - エージェントの最終回答を変換または再フォーマットする(Markdown をプレーンテキストや CSV に変換するなど)。 -- 出力を検証する。または、エージェントのレスポンスが欠落しているか不正な形式の場合にフォールバック値を提供する。 +- 出力を検証するか、エージェントのレスポンスが欠落している、または形式が不正な場合にフォールバック値を提供する。 これは、`as_tool` メソッドに `custom_output_extractor` 引数を指定することで実現できます。 @@ -674,11 +737,11 @@ json_tool = data_agent.as_tool( ) ``` -カスタム抽出関数内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、または raw 引数が必要な場合に便利です。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 +カスタム抽出関数内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果の後処理時に、外側のツール名、呼び出し ID、または raw 引数が必要な場合に役立ちます。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 ### ネストされたエージェント実行のストリーミング -`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが出力するストリーミングイベントを受け取りながら、ストリームの完了後に最終出力を返せます。 +`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが生成するストリーミングイベントを受信しながら、ストリームの完了後に最終出力を返せます。 ```python from agents import AgentToolStreamEvent @@ -696,17 +759,17 @@ billing_agent_tool = billing_agent.as_tool( ) ``` -想定される動作: +想定される動作: -- イベント型は `StreamEvent["type"]` と同様です:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 +- イベントタイプは `StreamEvent["type"]` と同様に、`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event` です。 - `on_stream` を指定すると、ネストされたエージェントは自動的にストリーミングモードで実行され、最終出力を返す前にストリームが最後まで処理されます。 -- ハンドラーは同期または非同期にできます。各イベントは到着した順に渡されます。 -- モデルのツール呼び出し経由でツールが呼び出された場合、`tool_call` が存在します。直接呼び出した場合は `None` になることがあります。 +- ハンドラーは同期または非同期にできます。各イベントは到着順に配信されます。 +- モデルのツール呼び出しを通じてツールが呼び出された場合は、`tool_call` が存在します。直接呼び出した場合は `None` のままになることがあります。 - 実行可能な完全なサンプルについては、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 -### 条件付きツール有効化 +### 条件付きのツール有効化 -`is_enabled` パラメーターを使用して、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的にフィルタリングできます。 +`is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的にフィルタリングできます。 ```python import asyncio @@ -754,8 +817,8 @@ orchestrator = Agent( ) async def main(): - context = RunContextWrapper(LanguageContext(language_preference="french_spanish")) - result = await Runner.run(orchestrator, "How are you?", context=context.context) + context = LanguageContext(language_preference="french_spanish") + result = await Runner.run(orchestrator, "How are you?", context=context) print(result.final_output) asyncio.run(main()) @@ -763,22 +826,22 @@ asyncio.run(main()) `is_enabled` パラメーターは、次の値を受け付けます。 -- **ブール値**:`True`(常に有効)または `False`(常に無効) -- **呼び出し可能な関数**:`(context, agent)` を受け取り、ブール値を返す関数 -- **非同期関数**:複雑な条件ロジックのための非同期関数 +- **ブール値**: `True`(常に有効)または `False`(常に無効) +- **呼び出し可能な関数**: `(context, agent)` を受け取り、ブール値を返す関数 +- **非同期関数**: 複雑な条件ロジック用の非同期関数 -無効なツールはランタイムで LLM から完全に非表示になるため、次の用途に役立ちます。 +無効化されたツールはランタイムで LLM から完全に非表示になるため、次の用途に役立ちます。 -- ユーザー権限に基づく機能制御 -- 環境固有のツール可用性(開発環境と本番環境) +- ユーザー権限に基づく機能ゲーティング +- 環境固有のツール利用可否(開発環境と本番環境) - 異なるツール設定の A/B テスト - ランタイム状態に基づく動的なツールフィルタリング -## 実験的機能:Codex ツール +## 実験的機能: Codex ツール -`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペースにスコープされたタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。この機能は実験的であり、変更される可能性があります。 +`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペーススコープのタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。この機能は実験的であり、変更される可能性があります。 -メインエージェントが現在の実行を離れることなく、範囲が限定されたワークスペースタスクを Codex に委任する場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合、その名前は `codex` であるか、`codex_` で始まる必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 +現在の実行を離れずに、メインエージェントから Codex へ範囲が限定されたワークスペースタスクを委任したい場合に使用してください。デフォルトのツール名は `codex` です。カスタム名を設定する場合は、`codex` または `codex_` で始まる名前にする必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 ```python from agents import Agent @@ -807,33 +870,33 @@ agent = Agent( ) ``` -まず、次のオプショングループから設定してください。 +まず、次のオプショングループを確認してください。 -- 実行対象:`sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定してください。 -- スレッドのデフォルト:`default_thread_options=ThreadOptions(...)` は、モデル、推論強度、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 -- ターンのデフォルト:`default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` やオプションのキャンセル用 `signal` など、ターンごとの動作を設定します。 -- ツール I/O:ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` 項目を少なくとも 1 つ含める必要があります。`output_schema` を使用すると、構造化された Codex レスポンスを必須にできます。 +- 実行対象: `sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定してください。 +- スレッドのデフォルト設定: `default_thread_options=ThreadOptions(...)` は、モデル、推論強度、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 +- ターンのデフォルト設定: `default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` や任意のキャンセル用 `signal` など、ターンごとの動作を設定します。 +- ツールの入出力: ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` アイテムを少なくとも 1 つ含める必要があります。`output_schema` を使用すると、構造化された Codex レスポンスを必須にできます。 -スレッドの再利用と永続化は、別々に制御されます。 +スレッドの再利用と永続化は、個別に制御されます。 - `persist_session=True` は、同じツールインスタンスへの繰り返し呼び出しで 1 つの Codex スレッドを再利用します。 -- `use_run_context_thread_id=True` は、同じ変更可能なコンテキストオブジェクトを共有する複数の実行にわたり、実行コンテキスト内にスレッド ID を保存して再利用します。 +- `use_run_context_thread_id=True` は、同じ変更可能なコンテキストオブジェクトを共有する複数の実行にわたって、実行コンテキストにスレッド ID を保存して再利用します。 - スレッド ID の優先順位は、呼び出しごとの `thread_id`、実行コンテキストのスレッド ID(有効な場合)、設定済みの `thread_id` オプションの順です。 -- デフォルトの実行コンテキストキーは、`name="codex"` の場合は `codex_thread_id`、`name="codex_"` の場合は `codex_thread_id_` です。`run_context_thread_id_key` でオーバーライドできます。 +- デフォルトの実行コンテキストキーは、`name="codex"` の場合は `codex_thread_id`、`name="codex_"` の場合は `codex_thread_id_` です。`run_context_thread_id_key` で上書きできます。 -ランタイム設定: +ランタイム設定: -- 認証:`CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 -- ランタイム:`codex_options.base_url` は CLI のベース URL をオーバーライドします。 -- バイナリ解決:CLI のパスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、見つからなければ同梱のベンダーバイナリを使用します。 -- 環境:`codex_options.env` は、サブプロセス環境を完全に制御します。指定した場合、サブプロセスは `os.environ` を継承しません。 -- ストリーム制限:`codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの制限を制御します。有効範囲は `65536` から `67108864` で、デフォルトは `8388608` です。 -- ストリーミング:`on_stream` は、スレッド/ターンのライフサイクルイベントと項目イベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` の項目更新)を受け取ります。 -- 出力:実行結果には `response`、`usage`、`thread_id` が含まれます。使用量は `RunContextWrapper.usage` に追加されます。 +- 認証: `CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 +- ランタイム: `codex_options.base_url` は CLI のベース URL を上書きします。 +- バイナリの解決: CLI のパスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、見つからなければ同梱のベンダーバイナリへフォールバックします。 +- 環境: `codex_options.env` は、サブプロセス環境を完全に制御します。これを指定した場合、サブプロセスは `os.environ` を継承しません。 +- ストリーム制限: `codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの制限を制御します。有効範囲は `65536` から `67108864` で、デフォルトは `8388608` です。 +- ストリーミング: `on_stream` は、スレッド/ターンのライフサイクルイベントとアイテムイベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` のアイテム更新)を受信します。 +- 出力: 実行結果には `response`、`usage`、`thread_id` が含まれ、使用量は `RunContextWrapper.usage` に追加されます。 -リファレンス: +リファレンス: - [Codex ツール API リファレンス](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions リファレンス](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions リファレンス](ref/extensions/experimental/codex/turn_options.md) -- 実行可能な完全なサンプルについては、`examples/tools/codex.py` と `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file +- 実行可能な完全なサンプルについては、`examples/tools/codex.py` および `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file diff --git a/docs/ja/tracing.md b/docs/ja/tracing.md index b6d27ec849..c132d7f62b 100644 --- a/docs/ja/tracing.md +++ b/docs/ja/tracing.md @@ -4,29 +4,29 @@ search: --- # トレーシング -Agents SDK には組み込みのトレーシング機能が含まれており、エージェントの実行中に発生するイベント(LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベント)を包括的に記録します。[トレースダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境および本番環境でワークフローをデバッグ、可視化、監視できます。 +Agents SDK には組み込みのトレーシング機能があり、エージェント実行中のイベント(LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらには発生したカスタムイベントまで)を包括的に記録します。[トレースダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境と本番環境の両方でワークフローをデバッグ、可視化、監視できます。 !!!note トレーシングはデフォルトで有効です。一般的な無効化方法は次の 3 つです。 1. 環境変数 `OPENAI_AGENTS_DISABLE_TRACING=1` を設定して、トレーシングをグローバルに無効化できます - 2. [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用して、コード内でトレーシングをグローバルに無効化できます - 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定して、単一の実行に対するトレーシングを無効化できます + 2. コード内で [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用して、トレーシングをグローバルに無効化できます + 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定して、1 回の実行に対するトレーシングを無効化できます -***OpenAI の API を使用し、ゼロデータ保持(ZDR)ポリシーの下で運用している組織では、トレーシングを利用できません。*** +***OpenAI の API を使用し、Zero Data Retention(ZDR)ポリシーの下で運用している組織では、トレーシングを利用できません。*** ## トレースとスパン -- **トレース**は、「ワークフロー」の単一のエンドツーエンド処理を表します。トレースはスパンで構成され、次のプロパティがあります。 - - `workflow_name`: 論理的なワークフローまたはアプリです。たとえば、「コード生成」や「カスタマーサービス」です。 - - `trace_id`: トレースの一意な ID です。指定しない場合は自動的に生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 - - `group_id`: 同じ会話に由来する複数のトレースを関連付けるための、オプションのグループ ID です。たとえば、チャットスレッド ID を使用できます。 +- **トレース**は、「ワークフロー」における単一のエンドツーエンド操作を表します。トレースはスパンで構成され、次のプロパティがあります。 + - `workflow_name`: 論理的なワークフローまたはアプリです。たとえば「コード生成」や「カスタマーサービス」です。 + - `trace_id`: トレースの一意な ID です。指定しない場合は自動生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 + - `group_id`: 同じ会話の複数のトレースを関連付けるための、省略可能なグループ ID です。たとえば、チャットスレッド ID を使用できます。 - `disabled`: True の場合、トレースは記録されません。 - - `metadata`: トレースのオプションのメタデータです。 -- **スパン**は、開始時刻と終了時刻を持つ処理を表します。スパンには次のものがあります。 - - `started_at` と `ended_at` のタイムスタンプ。 - - `trace_id`。所属するトレースを表します + - `metadata`: トレース用の省略可能なメタデータです。 +- **スパン**は、開始時刻と終了時刻を持つ操作を表します。スパンには次の情報があります。 + - `started_at` および `ended_at` のタイムスタンプ。 + - `trace_id`。そのスパンが属するトレースを表します - `parent_id`。このスパンの親スパン(存在する場合)を指します - `span_data`。スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 @@ -35,20 +35,20 @@ Agents SDK には組み込みのトレーシング機能が含まれており、 デフォルトでは、SDK は次の項目をトレースします。 - `Runner.{run, run_sync, run_streamed}()` 全体が `trace()` でラップされます。 -- Runner の各呼び出しが `task_span()` でラップされます。 -- モデルの各ターンが `turn_span()` でラップされます。 +- 各 Runner 呼び出しが `task_span()` でラップされます。 +- 各モデルターンが `turn_span()` でラップされます。 - エージェントが実行されるたびに、`agent_span()` でラップされます - LLM 生成が `generation_span()` でラップされます -- 関数ツールの各呼び出しが `function_span()` でラップされます +- 各関数ツール呼び出しが `function_span()` でラップされます - ガードレールが `guardrail_span()` でラップされます - ハンドオフが `handoff_span()` でラップされます - 音声入力(音声テキスト変換)が `transcription_span()` でラップされます - 音声出力(テキスト音声変換)が `speech_span()` でラップされます -- 関連する音声スパンは、`speech_group_span()` の子になる場合があります +- 関連する音声スパンは、`speech_group_span()` の子として配置される場合があります -デフォルトでは、トレースの名前は「Agent workflow」です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して、名前やその他のプロパティを構成できます。 +デフォルトでは、トレースの名前は「Agent workflow」です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して名前やその他のプロパティを設定することもできます。 -よりコンパクトな階層にしたい場合は、実行時にタスクスパンとターンスパンの自動作成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、カスタムの各スパンは引き続き記録されます。 +よりコンパクトな階層にするには、実行時のタスクスパンとターンスパンの自動生成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、カスタムの各スパンは引き続き記録されます。 ```python from agents import RunConfig, Runner @@ -60,11 +60,11 @@ result = await Runner.run( ) ``` -さらに、[カスタムトレースプロセッサー](#custom-tracing-processors)を設定して、トレースを別の送信先へ送ることもできます(置き換え先または追加の送信先として)。 +さらに、[カスタムトレースプロセッサー](#custom-tracing-processors)を設定して、トレースを別の送信先に送ることもできます(既定の送信先の代替、または追加の送信先として)。 ## 長時間実行ワーカーと即時エクスポート -デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのしきい値に達した時点で、それより早くバックグラウンドでトレースをエクスポートします。また、プロセスの終了時に最後のフラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなど、長時間実行されるワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 +デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのトリガー値に達した時点で、それより早くバックグラウンドでトレースをエクスポートします。また、プロセス終了時には最後のフラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 作業単位の終了時に即時配信を保証する必要がある場合は、トレースコンテキストの終了後に [`flush_traces()`][agents.tracing.flush_traces] を呼び出します。 @@ -103,7 +103,7 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンがエクスポートされるまで処理をブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 +[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンのエクスポートが完了するまでブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 ## 上位レベルのトレース @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. `Runner.run` の 2 回の呼び出しが `with trace()` でラップされているため、個別の実行によって 2 つのトレースが作成されるのではなく、全体のトレースの一部になります。 +1. `Runner.run` の 2 回の呼び出しが `with trace()` でラップされているため、個々の実行で 2 つのトレースが作成されるのではなく、全体のトレースの一部になります。 ## トレースの作成 -[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始および終了する必要があります。これには次の 2 つの方法があります。 +[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始して終了する必要があります。これには次の 2 つの方法があります。 -1. **推奨**: トレースをコンテキストマネージャーとして、つまり `with trace(...) as my_trace` の形式で使用します。これにより、適切なタイミングでトレースが自動的に開始および終了します。 +1. **推奨**: `with trace(...) as my_trace` のように、トレースをコンテキストマネージャーとして使用します。これにより、適切なタイミングでトレースが自動的に開始・終了されます。 2. [`trace.start()`][agents.tracing.Trace.start] と [`trace.finish()`][agents.tracing.Trace.finish] を手動で呼び出すこともできます。 -現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に動作します。トレースを手動で開始または終了する場合、現在のトレースを更新するには、`start()`/`finish()` に `mark_as_current` と `reset_current` を渡す必要があります。 +現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始・終了する場合は、現在のトレースを更新するために、`start()` / `finish()` に `mark_as_current` と `reset_current` を渡す必要があります。 ## スパンの作成 -さまざまな [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を利用できます。 +各種 [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を使用できます。 -スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの下にネストされます。 +スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、現在の最も近いスパンの下にネストされます。 ## 機密データ -一部のスパンは、機密性の高い可能性があるデータを取得する場合があります。 +一部のスパンは、機密性のある可能性があるデータをキャプチャする場合があります。 -`generation_span()` は LLM 生成の入力と出力を保存し、`function_span()` は関数呼び出しの入力と出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータの取得を無効化できます。 +`generation_span()` は LLM 生成の入力と出力を保存し、`function_span()` は関数呼び出しの入力と出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用してデータのキャプチャを無効化できます。 -同様に、音声スパンには、デフォルトで入力音声と出力音声の base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を構成することで、この音声データの取得を無効化できます。 +同様に、音声スパンにはデフォルトで、入力音声と出力音声の Base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定することで、この音声データのキャプチャを無効化できます。 -デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定することで、コードを使用せずにデフォルト値を設定できます。 +デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定することで、コードを変更せずにデフォルト値を設定できます。 ## カスタムトレーシングプロセッサー トレーシングの上位レベルのアーキテクチャは次のとおりです。 -- 初期化時に、トレースの作成を担当するグローバルな [`TraceProvider`][agents.tracing.setup.TraceProvider] を作成します。 -- [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を使用して `TraceProvider` を構成します。`BatchTraceProcessor` は、トレースとスパンをバッチで [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、`BackendSpanExporter` がスパンとトレースをバッチで OpenAI バックエンドにエクスポートします。 +- 初期化時に、トレースの作成を担うグローバルな [`TraceProvider`][agents.tracing.setup.TraceProvider] を作成します。 +- `TraceProvider` に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。これは、トレースとスパンをバッチ単位で [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、`BackendSpanExporter` がスパンとトレースをバッチ単位で OpenAI バックエンドにエクスポートします。 -このデフォルト設定をカスタマイズして、トレースを別のバックエンドや追加のバックエンドへ送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 +このデフォルト設定をカスタマイズして、代替または追加のバックエンドにトレースを送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備が整ったトレースとスパンを受け取る **追加の** トレースプロセッサーを追加できます。これにより、OpenAI のバックエンドへトレースを送信する処理に加えて、独自の処理を実行できます。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで **置き換える** ことができます。この場合、OpenAI バックエンドへ送信する `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備が整ったトレースとスパンを受信する**追加の**トレースプロセッサーを追加できます。これにより、OpenAI バックエンドへのトレース送信に加えて、独自の処理を実行できます。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで**置き換える**ことができます。この場合、その処理を行う `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 ## OpenAI 以外のモデルでのトレーシング -OpenAI 以外のモデルで OpenAI API キーを使用すると、トレーシングを無効化せずに、OpenAI のトレースダッシュボードで無料のトレーシングを有効にできます。アダプターの選択とセットアップに関する注意事項については、モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションを参照してください。 +OpenAI API キーを OpenAI 以外のモデルで使用すると、トレーシングを無効化することなく、OpenAI のトレースダッシュボードで無料のトレーシングを有効にできます。アダプターの選択とセットアップ時の注意事項については、モデルガイドの[サードパーティ製アダプター](models/index.md#third-party-adapters)セクションを参照してください。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -単一の実行にのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡してください。 +1 回の実行にのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡してください。 ```python from agents import Runner, RunConfig @@ -197,21 +197,21 @@ await Runner.run( ) ``` -## 追加の注意事項 -- OpenAI のトレースダッシュボードで、トレースを無料で表示できます。 +## 補足事項 +- OpenAI のトレースダッシュボードで無料のトレースを確認できます。 ## エコシステム統合 -次のコミュニティおよびベンダー統合は、OpenAI Agents SDK のトレーシングインターフェースをサポートしています。 +以下のコミュニティおよびベンダー統合は、OpenAI Agents SDK のトレーシングインターフェースをサポートしています。 -### 外部トレーシングプロセッサーの一覧 +### 外部トレーシングプロセッサー一覧 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) - [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) -- [MLflow(セルフホスト/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow(Databricks ホスト)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) +- [MLflow (self-hosted/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow (Databricks hosted)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) diff --git a/docs/ja/visualization.md b/docs/ja/visualization.md index b3226044ed..28d86feb9b 100644 --- a/docs/ja/visualization.md +++ b/docs/ja/visualization.md @@ -28,11 +28,12 @@ pip install "openai-agents[viz]" ```python import os -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph -@function_tool +@tool def get_weather(city: str) -> str: return f"The weather in {city} is sunny." diff --git a/docs/ja/voice/quickstart.md b/docs/ja/voice/quickstart.md index 9cd40857cb..1aa7592b77 100644 --- a/docs/ja/voice/quickstart.md +++ b/docs/ja/voice/quickstart.md @@ -53,15 +53,13 @@ graph LR ```python import random -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") @@ -132,10 +130,8 @@ import random import numpy as np import sounddevice as sd -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.voice import ( AudioInput, SingleAgentVoiceWorkflow, @@ -144,7 +140,7 @@ from agents.voice import ( from agents.extensions.handoff_prompt import prompt_with_handoff_instructions -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") diff --git a/docs/ko/agents.md b/docs/ko/agents.md index ad7c47eb62..270f5504d8 100644 --- a/docs/ko/agents.md +++ b/docs/ko/agents.md @@ -4,21 +4,21 @@ search: --- # 에이전트 -에이전트는 앱의 핵심 구성 요소입니다. 에이전트는 instructions, tools와 핸드오프, 가드레일, structured outputs 등의 선택적 런타임 동작으로 구성된 대규모 언어 모델(LLM)입니다. +에이전트는 앱의 핵심 구성 요소입니다. 에이전트는 instructions, tools, 그리고 핸드오프, 가드레일, structured outputs 같은 선택적 런타임 동작으로 구성된 대규모 언어 모델(LLM)입니다. -이 페이지는 하나의 일반 `Agent`를 정의하거나 사용자 지정할 때 사용합니다. 여러 에이전트의 협업 방식을 결정하려면 [에이전트 오케스트레이션](multi_agent.md)을 참조하세요. 에이전트를 매니페스트에 정의된 파일과 샌드박스 네이티브 기능이 있는 격리된 작업공간에서 실행해야 한다면 [샌드박스 에이전트 개념](sandbox/guide.md)을 참조하세요. +하나의 일반 `Agent`를 정의하거나 사용자 지정하려면 이 페이지를 사용하세요. 여러 에이전트의 협업 방식을 결정하려면 [에이전트 오케스트레이션](multi_agent.md)을 읽어보세요. 에이전트가 매니페스트에 정의된 파일과 샌드박스 네이티브 기능을 갖춘 격리된 워크스페이스에서 실행되어야 한다면 [샌드박스 에이전트 개념](sandbox/guide.md)을 읽어보세요. -SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기서의 차이점은 오케스트레이션입니다. `Agent`와 `Runner`를 함께 사용하면 SDK가 턴, 도구, 가드레일, 핸드오프, 세션을 대신 관리합니다. 이 루프를 직접 관리하려면 Responses API를 직접 사용하세요. +SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기서 중요한 차이는 오케스트레이션입니다. `Agent`와 `Runner`를 함께 사용하면 SDK가 턴, 도구, 가드레일, 핸드오프, 세션을 대신 관리합니다. 이 루프를 직접 제어하려면 Responses API를 직접 사용하세요. ## 다음 가이드 선택 -이 페이지를 에이전트 정의를 위한 중심 가이드로 사용하세요. 다음으로 내려야 할 결정에 해당하는 관련 가이드로 이동할 수 있습니다. +이 페이지를 에이전트 정의의 허브로 사용하세요. 다음으로 내려야 할 결정에 맞는 관련 가이드로 이동하세요. -| 원하는 작업 | 다음 가이드 | +| 원하는 작업 | 다음으로 읽을 문서 | | --- | --- | | 모델 또는 제공자 설정 선택 | [모델](models/index.md) | | 에이전트에 기능 추가 | [도구](tools.md) | -| 실제 리포지토리, 문서 묶음 또는 격리된 작업공간에서 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) | +| 실제 저장소, 문서 번들 또는 격리된 워크스페이스에서 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) | | 관리자 방식 오케스트레이션과 핸드오프 중 선택 | [에이전트 오케스트레이션](multi_agent.md) | | 핸드오프 동작 구성 | [핸드오프](handoffs.md) | | 턴 실행, 이벤트 스트리밍 또는 대화 상태 관리 | [에이전트 실행](running_agents.md) | @@ -27,31 +27,32 @@ SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기 ## 기본 구성 -에이전트의 가장 일반적인 속성은 다음과 같습니다. +에이전트에서 가장 일반적으로 사용하는 속성은 다음과 같습니다. -| 속성 | 필수 | 설명 | +| 속성 | 필수 여부 | 설명 | | --- | --- | --- | -| `name` | 예 | 사람이 읽을 수 있는 에이전트 이름입니다. | -| `instructions` | 아니요 | 시스템 프롬프트 또는 동적 지침 콜백입니다. 사용을 적극 권장합니다. [동적 지침](#dynamic-instructions)을 참조하세요. | -| `prompt` | 아니요 | OpenAI Responses API 프롬프트 구성입니다. 정적 프롬프트 객체 또는 함수를 허용합니다. [프롬프트 템플릿](#prompt-templates)을 참조하세요. | -| `handoff_description` | 아니요 | 이 에이전트가 핸드오프 대상으로 제공될 때 노출되는 짧은 설명입니다. | +| `name` | 예 | 사람이 읽을 수 있는 에이전트 이름 | +| `instructions` | 아니요 | 시스템 프롬프트 또는 동적 instructions 콜백. 사용을 강력히 권장합니다. [동적 instructions](#dynamic-instructions)를 참조하세요. | +| `prompt` | 아니요 | OpenAI Responses API 프롬프트 구성. 정적 프롬프트 객체 또는 함수를 받습니다. [프롬프트 템플릿](#prompt-templates)을 참조하세요. | +| `handoff_description` | 아니요 | 이 에이전트가 핸드오프 대상으로 제공될 때 노출되는 간단한 설명 | | `handoffs` | 아니요 | 대화를 전문 에이전트에게 위임합니다. [핸드오프](handoffs.md)를 참조하세요. | | `model` | 아니요 | 사용할 LLM입니다. [모델](models/index.md)을 참조하세요. | -| `model_settings` | 아니요 | `temperature`, `top_p`, `tool_choice` 등의 모델 조정 매개변수입니다. | +| `model_settings` | 아니요 | `temperature`, `top_p`, `tool_choice` 같은 모델 조정 매개변수 | | `tools` | 아니요 | 에이전트가 호출할 수 있는 도구입니다. [도구](tools.md)를 참조하세요. | | `mcp_servers` | 아니요 | 에이전트용 MCP 기반 도구입니다. [MCP 가이드](mcp.md)를 참조하세요. | -| `mcp_config` | 아니요 | 엄격한 스키마 변환, MCP 실패 형식 지정 등 MCP 도구를 준비하는 방식을 세부 조정합니다. [MCP 가이드](mcp.md#agent-level-mcp-configuration)를 참조하세요. | -| `input_guardrails` | 아니요 | 이 에이전트 체인의 첫 번째 사용자 입력에 대해 실행되는 가드레일입니다. [가드레일](guardrails.md)을 참조하세요. | -| `output_guardrails` | 아니요 | 이 에이전트의 최종 출력에 대해 실행되는 가드레일입니다. [가드레일](guardrails.md)을 참조하세요. | -| `output_type` | 아니요 | 일반 텍스트 대신 사용할 구조화된 출력 유형입니다. [출력 유형](#output-types)을 참조하세요. | +| `mcp_config` | 아니요 | 엄격한 스키마 변환 및 MCP 실패 형식 지정 등 MCP 도구가 준비되는 방식을 세부 조정합니다. [MCP 가이드](mcp.md#agent-level-mcp-configuration)를 참조하세요. | +| `input_guardrails` | 아니요 | 이 에이전트 체인의 첫 번째 사용자 입력에 실행되는 가드레일입니다. [가드레일](guardrails.md)을 참조하세요. | +| `output_guardrails` | 아니요 | 이 에이전트의 최종 출력에 실행되는 가드레일입니다. [가드레일](guardrails.md)을 참조하세요. | +| `output_type` | 아니요 | 일반 텍스트 대신 사용할 구조화된 출력 타입입니다. [출력 타입](#output-types)을 참조하세요. | | `hooks` | 아니요 | 에이전트 범위의 수명 주기 콜백입니다. [수명 주기 이벤트(훅)](#lifecycle-events-hooks)를 참조하세요. | -| `tool_use_behavior` | 아니요 | 도구 결과를 모델에 다시 전달할지, 아니면 실행을 종료할지 제어합니다. [도구 사용 동작](#tool-use-behavior)을 참조하세요. | +| `tool_use_behavior` | 아니요 | 도구 결과를 모델로 다시 전달할지 또는 실행을 종료할지 제어합니다. [도구 사용 동작](#tool-use-behavior)을 참조하세요. | | `reset_tool_choice` | 아니요 | 도구 사용 루프를 방지하기 위해 도구 호출 후 `tool_choice`를 재설정합니다(기본값: `True`). [도구 사용 강제](#forcing-tool-use)를 참조하세요. | ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -64,7 +65,7 @@ agent = Agent( ) ``` -이 섹션의 모든 내용은 `Agent`에 적용됩니다. `SandboxAgent`는 동일한 개념을 기반으로 하며, 작업공간 범위 실행을 위해 `default_manifest`, `base_instructions`, `capabilities`, `run_as`를 추가합니다. [샌드박스 에이전트 개념](sandbox/guide.md)을 참조하세요. +이 섹션의 모든 내용은 `Agent`에 적용됩니다. `SandboxAgent`는 동일한 개념을 기반으로 하며, 워크스페이스 범위 실행을 위한 `default_manifest`, `base_instructions`, `capabilities`, `run_as`를 추가합니다. [샌드박스 에이전트 개념](sandbox/guide.md)을 참조하세요. ## 프롬프트 템플릿 @@ -72,15 +73,15 @@ agent = Agent( 사용 방법은 다음과 같습니다. -1. https://platform.openai.com/playground/prompts 로 이동합니다 +1. https://platform.openai.com/playground/prompts 로 이동합니다. 2. 새 프롬프트 변수 `poem_style`을 생성합니다. -3. 다음 내용으로 시스템 프롬프트를 생성합니다. +3. 다음 콘텐츠로 시스템 프롬프트를 생성합니다. ``` Write a poem in {{poem_style}} ``` -4. `--prompt-id` 플래그를 사용해 예제를 실행합니다. +4. `--prompt-id` 플래그를 사용하여 예제를 실행합니다. ```python from agents import Agent @@ -95,7 +96,7 @@ agent = Agent( ) ``` -실행 시점에 프롬프트를 동적으로 생성할 수도 있습니다. +런타임에 프롬프트를 동적으로 생성할 수도 있습니다. ```python from dataclasses import dataclass @@ -127,9 +128,9 @@ result = await Runner.run( ## 컨텍스트 -에이전트는 `context` 타입에 대해 제네릭입니다. 컨텍스트는 종속성 주입 도구입니다. 컨텍스트는 사용자가 생성하여 `Runner.run()`에 전달하는 객체이며, 모든 에이전트, 도구, 핸드오프 등에 전달되고 에이전트 실행에 필요한 종속성과 상태를 모아 두는 역할을 합니다. 모든 Python 객체를 컨텍스트로 제공할 수 있습니다. +에이전트는 `context` 타입에 대해 제네릭입니다. 컨텍스트는 종속성 주입 도구입니다. 컨텍스트는 사용자가 생성하여 `Runner.run()`에 전달하는 객체이며, 모든 에이전트, 도구, 핸드오프 등에 전달되어 에이전트 실행에 필요한 종속성과 상태를 담는 역할을 합니다. 어떤 Python 객체든 컨텍스트로 제공할 수 있습니다. -전체 `RunContextWrapper` 인터페이스, 공유 사용량 추적, 중첩된 `tool_input`, 직렬화 유의 사항은 [컨텍스트 가이드](context.md)를 참조하세요. +전체 `RunContextWrapper` 인터페이스, 공유 사용량 추적, 중첩된 `tool_input`, 직렬화 시 주의 사항은 [컨텍스트 가이드](context.md)를 참조하세요. ```python from dataclasses import dataclass @@ -153,9 +154,9 @@ agent = Agent[UserContext]( ) ``` -## 출력 유형 +## 출력 타입 -기본적으로 에이전트는 일반 텍스트, 즉 `str` 출력을 생성합니다. 에이전트가 특정 유형의 출력을 생성하게 하려면 `output_type` 매개변수를 사용할 수 있습니다. 일반적으로 [Pydantic](https://docs.pydantic.dev/) 객체를 사용하지만, Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)로 래핑할 수 있는 모든 유형을 지원합니다. 여기에는 데이터클래스, 리스트, TypedDict 등이 포함됩니다. +기본적으로 에이전트는 일반 텍스트(즉, `str`) 출력을 생성합니다. 에이전트가 특정 타입의 출력을 생성하도록 하려면 `output_type` 매개변수를 사용할 수 있습니다. 일반적으로 [Pydantic](https://docs.pydantic.dev/) 객체를 사용하지만, 데이터 클래스, 목록, TypedDict 등 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)로 래핑할 수 있는 모든 타입을 지원합니다. ```python from pydantic import BaseModel @@ -176,14 +177,14 @@ agent = Agent( !!! note - `output_type`을 전달하면 모델은 일반 텍스트 응답 대신 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 사용합니다. + `output_type`을 전달하면 모델이 일반적인 일반 텍스트 응답 대신 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 사용하도록 지정합니다. -## 멀티 에이전트 시스템 설계 패턴 +## 다중 에이전트 시스템 설계 패턴 -멀티 에이전트 시스템을 설계하는 방법은 다양하지만, 일반적으로 폭넓게 적용할 수 있는 다음 두 가지 패턴이 사용됩니다. +다중 에이전트 시스템을 설계하는 방법은 다양하지만, 일반적으로 폭넓게 적용할 수 있는 다음 두 가지 패턴이 사용됩니다. -1. 관리자(agents as tools): 중앙 관리자/오케스트레이터가 전문 하위 에이전트를 도구로 호출하고 대화를 계속 제어합니다. -2. 핸드오프: 동료 에이전트가 대화를 이어받는 전문 에이전트에게 제어권을 핸드오프합니다. 이는 분산형 방식입니다. +1. 관리자(agents as tools): 중앙 관리자 또는 오케스트레이터가 전문 하위 에이전트를 도구로 호출하고 대화 제어권을 유지합니다. +2. 핸드오프: 동등한 위치의 에이전트가 대화 제어권을 전문 에이전트에게 넘깁니다. 이는 분산형 방식입니다. 자세한 내용은 [에이전트 구축 실무 가이드](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)를 참조하세요. @@ -218,7 +219,7 @@ customer_facing_agent = Agent( ### 핸드오프 -핸드오프는 에이전트가 작업을 위임할 수 있는 하위 에이전트입니다. 핸드오프가 발생하면 위임받은 에이전트가 대화 기록을 전달받아 대화를 이어받습니다. 이 패턴을 사용하면 단일 작업에 특화된 모듈식 전문 에이전트를 구성할 수 있습니다. 자세한 내용은 [핸드오프](handoffs.md) 문서를 참조하세요. +핸드오프는 에이전트가 작업을 위임할 수 있는 하위 에이전트입니다. 핸드오프가 발생하면 위임받은 에이전트가 대화 기록을 전달받아 대화를 이어갑니다. 이 패턴을 사용하면 단일 작업에 뛰어난 모듈식 전문 에이전트를 구현할 수 있습니다. 자세한 내용은 [핸드오프](handoffs.md) 문서를 참조하세요. ```python from agents import Agent @@ -237,9 +238,9 @@ triage_agent = Agent( ) ``` -## 동적 지침 +## 동적 instructions -대부분의 경우 에이전트를 생성할 때 지침을 제공할 수 있습니다. 하지만 함수를 통해 동적 지침을 제공할 수도 있습니다. 함수는 에이전트와 컨텍스트를 전달받고 프롬프트를 반환해야 합니다. 일반 함수와 `async` 함수 모두 사용할 수 있습니다. +대부분의 경우 에이전트를 생성할 때 instructions를 제공할 수 있습니다. 하지만 함수를 통해 동적 instructions를 제공할 수도 있습니다. 이 함수는 에이전트와 컨텍스트를 받아 프롬프트를 반환해야 합니다. 일반 함수와 `async` 함수를 모두 사용할 수 있습니다. ```python def dynamic_instructions( @@ -260,22 +261,22 @@ agent = Agent[UserContext]( 훅의 범위는 두 가지입니다. -- [`RunHooks`][agents.lifecycle.RunHooks]는 다른 에이전트로의 핸드오프를 포함하여 전체 `Runner.run(...)` 호출을 관찰합니다. +- [`RunHooks`][agents.lifecycle.RunHooks]는 다른 에이전트로의 핸드오프를 포함한 전체 `Runner.run(...)` 호출을 관찰합니다. - [`AgentHooks`][agents.lifecycle.AgentHooks]는 `agent.hooks`를 통해 특정 에이전트 인스턴스에 연결됩니다. 콜백 컨텍스트도 이벤트에 따라 달라집니다. -- 에이전트 시작/종료 훅은 원래 컨텍스트를 래핑하고 공유 실행 사용량 상태를 전달하는 [`AgentHookContext`][agents.run_context.AgentHookContext]를 받습니다. +- 에이전트 시작/종료 훅은 [`AgentHookContext`][agents.run_context.AgentHookContext]를 받습니다. 이 컨텍스트는 원래 컨텍스트를 래핑하고 공유 실행 사용량 상태를 포함합니다. - LLM, 도구, 핸드오프 훅은 [`RunContextWrapper`][agents.run_context.RunContextWrapper]를 받습니다. -일반적인 훅 호출 시점은 다음과 같습니다. +일반적인 훅 실행 시점은 다음과 같습니다. - `on_agent_start` / `on_agent_end`: 특정 에이전트가 최종 출력 생성을 시작하거나 완료할 때 -- `on_llm_start` / `on_llm_end`: 각 모델 호출의 직전과 직후 -- `on_tool_start` / `on_tool_end`: 각 로컬 도구 호출의 직전과 직후. 함수 도구의 경우 훅 `context`는 일반적으로 `ToolContext`이므로 `tool_call_id` 같은 도구 호출 메타데이터를 검사할 수 있습니다. -- `on_handoff`: 제어권이 한 에이전트에서 다른 에이전트로 이동할 때 +- `on_llm_start` / `on_llm_end`: 각 모델 호출 직전과 직후 +- `on_tool_start` / `on_tool_end`: 각 로컬 도구 호출 직전과 직후. 함수 도구의 경우 훅 `context`는 일반적으로 `ToolContext`이므로 `tool_call_id` 같은 도구 호출 메타데이터를 검사할 수 있습니다. +- `on_handoff`: 한 에이전트에서 다른 에이전트로 제어권이 이동할 때 -전체 워크플로에 단일 관찰자를 사용하려면 `RunHooks`를 사용하고, 특정 에이전트에 사용자 지정 부수 효과가 필요하면 `AgentHooks`를 사용하세요. +전체 워크플로를 관찰하는 단일 관찰자가 필요하면 `RunHooks`를 사용하고, 특정 에이전트에 사용자 지정 부수 효과가 필요하면 `AgentHooks`를 사용하세요. ```python from agents import Agent, RunHooks, Runner @@ -301,11 +302,11 @@ print(result.final_output) ## 가드레일 -가드레일을 사용하면 에이전트 실행과 병렬로 사용자 입력에 대한 검사/검증을 실행하고, 에이전트 출력이 생성된 후 해당 출력도 검사할 수 있습니다. 예를 들어 사용자 입력과 에이전트 출력의 관련성을 검사할 수 있습니다. 자세한 내용은 [가드레일](guardrails.md) 문서를 참조하세요. +가드레일을 사용하면 에이전트가 실행되는 동안 사용자 입력에 대한 검사와 검증을 병렬로 수행하고, 에이전트 출력이 생성된 후 해당 출력도 검사할 수 있습니다. 예를 들어 사용자 입력과 에이전트 출력이 관련성이 있는지 확인할 수 있습니다. 자세한 내용은 [가드레일](guardrails.md) 문서를 참조하세요. ## 에이전트 복제/복사 -에이전트에서 `clone()` 메서드를 사용하면 에이전트를 복제하고 원하는 속성을 선택적으로 변경할 수 있습니다. +에이전트의 `clone()` 메서드를 사용하면 Agent를 복제하고 원하는 속성을 선택적으로 변경할 수 있습니다. ```python pirate_agent = Agent( @@ -322,19 +323,20 @@ robot_agent = pirate_agent.clone( ## 도구 사용 강제 -도구 목록을 제공한다고 해서 LLM이 항상 도구를 사용하는 것은 아닙니다. [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]를 설정하면 도구 사용을 강제할 수 있습니다. 유효한 값은 다음과 같습니다. +도구 목록을 제공하더라도 LLM이 항상 도구를 사용하는 것은 아닙니다. [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]를 설정하여 도구 사용을 강제할 수 있습니다. 유효한 값은 다음과 같습니다. -1. `auto`: LLM이 도구 사용 여부를 결정할 수 있습니다. -2. `required`: LLM이 도구를 사용하도록 요구하지만, 어떤 도구를 사용할지는 지능적으로 결정할 수 있습니다. -3. `none`: LLM이 도구를 _사용하지 않도록_ 요구합니다. -4. `my_tool` 같은 특정 문자열을 설정하면 LLM이 해당 도구를 사용하도록 요구합니다. +1. `auto`: 도구 사용 여부를 LLM이 결정할 수 있습니다. +2. `required`: LLM이 도구를 사용해야 합니다. 단, 어떤 도구를 사용할지는 지능적으로 결정할 수 있습니다. +3. `none`: LLM이 도구를 _사용하지 않도록_ 강제합니다. +4. `my_tool` 같은 특정 문자열을 설정하면 LLM이 해당 도구를 사용하도록 강제합니다. -OpenAI Responses 도구 검색을 사용할 때는 이름이 지정된 도구 선택에 더 많은 제한이 있습니다. `tool_choice`로 단독 네임스페이스 이름이나 지연 전용 도구를 지정할 수 없으며, `tool_choice="tool_search"`는 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 대상으로 하지 않습니다. 이러한 경우에는 `auto` 또는 `required`를 사용하는 것이 좋습니다. Responses 전용 제약 조건은 [호스티드 툴 검색](tools.md#hosted-tool-search)을 참조하세요. +OpenAI Responses 도구 검색을 사용할 때는 이름이 지정된 도구 선택에 더 많은 제약이 있습니다. `tool_choice`를 사용하여 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없으며, `tool_choice="tool_search"`는 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 대상으로 하지 않습니다. 이러한 경우에는 `auto` 또는 `required`를 사용하는 것이 좋습니다. Responses 관련 제약 조건은 [호스티드 툴 검색](tools.md#hosted-tool-search)을 참조하세요. ```python -from agents import Agent, function_tool, ModelSettings +from agents import Agent, ModelSettings +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -349,15 +351,16 @@ agent = Agent( ## 도구 사용 동작 -`Agent` 구성의 `tool_use_behavior` 매개변수는 도구 출력을 처리하는 방식을 제어합니다. +`Agent` 구성의 `tool_use_behavior` 매개변수는 도구 출력의 처리 방식을 제어합니다. -- `"run_llm_again"`: 기본값입니다. 도구를 실행한 후 LLM이 결과를 처리하여 최종 응답을 생성합니다. -- `"stop_on_first_tool"`: 추가적인 LLM 처리 없이 첫 번째 도구 호출의 출력을 최종 응답으로 사용합니다. +- `"run_llm_again"`: 기본값입니다. 도구를 실행하고 LLM이 결과를 처리하여 최종 응답을 생성합니다. +- `"stop_on_first_tool"`: 추가 LLM 처리 없이 첫 번째 도구 호출의 출력을 최종 응답으로 사용합니다. ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -370,18 +373,19 @@ agent = Agent( ) ``` -- `StopAtTools(stop_at_tool_names=[...])`: 지정된 도구 중 하나가 호출되면 해당 출력을 최종 응답으로 사용하고 중지합니다. +- `StopAtTools(stop_at_tool_names=[...])`: 지정된 도구 중 하나라도 호출되면 중지하고 해당 출력을 최종 응답으로 사용합니다. ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.agent import StopAtTools -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" -@function_tool +@tool def sum_numbers(a: int, b: int) -> int: """Adds two numbers.""" return a + b @@ -397,11 +401,12 @@ agent = Agent( - `ToolsToFinalOutputFunction`: 도구 결과를 처리하고 LLM을 중지할지 계속 실행할지 결정하는 사용자 지정 함수입니다. ```python -from agents import Agent, function_tool, FunctionToolResult, RunContextWrapper +from agents import Agent, FunctionToolResult, RunContextWrapper +from agents.decorators import tool from agents.agent import ToolsToFinalOutputResult from typing import List, Any -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -432,4 +437,4 @@ agent = Agent( !!! note - 무한 루프를 방지하기 위해 프레임워크는 도구 호출 후 `tool_choice`를 자동으로 "auto"로 재설정합니다. 이 동작은 [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]를 통해 구성할 수 있습니다. 무한 루프가 발생하는 이유는 도구 결과가 LLM에 전달된 후 `tool_choice`로 인해 LLM이 다시 도구 호출을 생성하는 과정이 무한히 반복되기 때문입니다. \ No newline at end of file + 무한 루프를 방지하기 위해 프레임워크는 도구 호출 후 `tool_choice`를 자동으로 "auto"로 재설정합니다. 이 동작은 [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]를 통해 구성할 수 있습니다. 무한 루프가 발생하는 이유는 도구 결과가 LLM으로 전송된 후 `tool_choice`로 인해 LLM이 또 다른 도구 호출을 생성하고 이 과정이 무한히 반복되기 때문입니다. \ No newline at end of file diff --git a/docs/ko/context.md b/docs/ko/context.md index e0d4b6c0ef..eae5dedf19 100644 --- a/docs/ko/context.md +++ b/docs/ko/context.md @@ -52,14 +52,15 @@ search: import asyncio from dataclasses import dataclass -from agents import Agent, RunContextWrapper, Runner, function_tool +from agents import Agent, RunContextWrapper, Runner +from agents.decorators import tool @dataclass class UserInfo: # (1)! name: str uid: int -@function_tool +@tool async def fetch_user_age(wrapper: RunContextWrapper[UserInfo]) -> str: # (2)! """Fetch the age of the user. Call this function to get user's age information.""" return f"The user {wrapper.context.name} is 47 years old" @@ -101,7 +102,8 @@ if __name__ == "__main__": ```python from typing import Annotated from pydantic import BaseModel, Field -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.tool_context import ToolContext class WeatherContext(BaseModel): @@ -112,7 +114,7 @@ class Weather(BaseModel): temperature_range: str = Field(description="The temperature range in Celsius") conditions: str = Field(description="The weather conditions") -@function_tool +@tool def get_weather(ctx: ToolContext[WeatherContext], city: Annotated[str, "The city to get the weather for"]) -> Weather: print(f"[debug] Tool context: (name: {ctx.tool_name}, call_id: {ctx.tool_call_id}, args: {ctx.tool_arguments})") return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") diff --git a/docs/ko/examples.md b/docs/ko/examples.md index 173d599a58..949f933983 100644 --- a/docs/ko/examples.md +++ b/docs/ko/examples.md @@ -2,40 +2,40 @@ search: exclude: true --- -# 코드 예제 +# 예제 -[리포지토리](https://github.com/openai/openai-agents-python/tree/main/examples)의 코드 예제 섹션에서 SDK의 다양한 샘플 구현을 확인해 보세요. 코드 예제는 다양한 패턴과 기능을 보여 주는 여러 카테고리로 구성되어 있습니다. +[리포지토리](https://github.com/openai/openai-agents-python/tree/main/examples)의 examples 섹션에서 다양한 SDK 샘플 구현을 확인해 보세요. 예제는 서로 다른 패턴과 기능을 보여 주는 여러 카테고리로 구성되어 있습니다. ## 카테고리 -- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** 이 카테고리의 코드 예제는 다음과 같은 일반적인 에이전트 설계 패턴을 보여 줍니다 +- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** 이 카테고리의 예제는 다음과 같은 일반적인 에이전트 설계 패턴을 보여 줍니다. - 결정론적 워크플로 - Agents as tools - - 스트리밍 이벤트를 사용하는 Agents as tools (`examples/agent_patterns/agents_as_tools_streaming.py`) - - 구조화된 입력 매개변수를 사용하는 Agents as tools (`examples/agent_patterns/agents_as_tools_structured.py`) + - 스트리밍 이벤트가 포함된 Agents as tools (`examples/agent_patterns/agents_as_tools_streaming.py`) + - 구조화된 입력 매개변수가 포함된 Agents as tools (`examples/agent_patterns/agents_as_tools_structured.py`) - 병렬 에이전트 실행 - 조건부 도구 사용 - - 다양한 동작으로 도구 사용 강제 (`examples/agent_patterns/forcing_tool_use.py`) - - 입력/출력 가드레일 - - 판정자로서의 LLM + - 서로 다른 동작으로 도구 사용 강제 (`examples/agent_patterns/forcing_tool_use.py`) + - 입출력 가드레일 + - 평가자로서의 LLM - 라우팅 - 스트리밍 가드레일 - 도구 승인 및 상태 직렬화를 사용하는 휴먼인더루프 (HITL) (`examples/agent_patterns/human_in_the_loop.py`) - 스트리밍을 사용하는 휴먼인더루프 (HITL) (`examples/agent_patterns/human_in_the_loop_stream.py`) - - 승인 흐름을 위한 사용자 정의 거부 메시지 (`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) + - 승인 흐름을 위한 사용자 지정 거부 메시지 (`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) -- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** 이 코드 예제는 다음과 같은 SDK의 기본 기능을 보여 줍니다 +- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** 이 예제는 다음과 같은 SDK의 기본 기능을 보여 줍니다. - - Hello world 코드 예제(기본 모델, GPT-5, 오픈 웨이트 모델) + - Hello world 예제(기본 모델, GPT-5, 오픈 웨이트 모델) - 에이전트 수명 주기 관리 - - 실행 훅 및 에이전트 훅 수명 주기 코드 예제 (`examples/basic/lifecycle_example.py`) + - 실행 훅 및 에이전트 훅 수명 주기 예제 (`examples/basic/lifecycle_example.py`) - 동적 시스템 프롬프트 - 기본적인 도구 사용 (`examples/basic/tools.py`) - - 도구 입력/출력 가드레일 (`examples/basic/tool_guardrails.py`) + - 도구 입출력 가드레일 (`examples/basic/tool_guardrails.py`) - 이미지 도구 출력 (`examples/basic/image_tool_output.py`) - 스트리밍 출력(텍스트, 항목, 함수 호출 인수) - - 여러 턴에서 공유 세션 헬퍼를 사용하는 Responses WebSocket 전송 (`examples/basic/stream_ws.py`) + - 여러 턴에서 공유 세션 도우미를 사용하는 Responses WebSocket 전송 (`examples/basic/stream_ws.py`) - 프롬프트 템플릿 - 파일 처리(로컬 및 원격, 이미지 및 PDF) - 사용량 추적 @@ -44,81 +44,81 @@ search: - 비엄격 출력 유형 - 이전 응답 ID 사용 -- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 항공사를 위한 고객 서비스 시스템 코드 예제입니다. +- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 항공사를 위한 고객 서비스 시스템 예제입니다. -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 금융 데이터 분석을 위한 에이전트와 도구를 사용하여 구조화된 리서치 워크플로를 보여 주는 금융 리서치 에이전트입니다. +- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 금융 데이터 분석용 에이전트와 도구를 활용한 구조화된 리서치 워크플로를 보여 주는 금융 리서치 에이전트입니다. -- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** 메시지 필터링을 사용하는 에이전트 핸드오프의 실용적인 코드 예제는 다음과 같습니다: +- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** 메시지 필터링을 사용하는 에이전트 핸드오프의 실용적인 예제는 다음과 같습니다. - - 메시지 필터 코드 예제 (`examples/handoffs/message_filter.py`) + - 메시지 필터 예제 (`examples/handoffs/message_filter.py`) - 스트리밍을 사용하는 메시지 필터 (`examples/handoffs/message_filter_streaming.py`) -- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API에서 호스티드 MCP(Model Context Protocol)를 사용하는 방법을 보여 주는 코드 예제는 다음과 같습니다: +- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API와 함께 호스티드 MCP(Model Context Protocol)를 사용하는 방법을 보여 주는 예제는 다음과 같습니다. - 승인이 없는 간단한 호스티드 MCP (`examples/hosted_mcp/simple.py`) - Google Calendar와 같은 MCP 커넥터 (`examples/hosted_mcp/connectors.py`) - 인터럽션(중단 처리) 기반 승인을 사용하는 휴먼인더루프 (HITL) (`examples/hosted_mcp/human_in_the_loop.py`) - MCP 도구 호출을 위한 승인 시 콜백 (`examples/hosted_mcp/on_approval.py`) -- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** 다음을 포함하여 MCP(Model Context Protocol)로 에이전트를 구축하는 방법을 알아봅니다: +- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** 다음을 포함하여 MCP(Model Context Protocol)로 에이전트를 구축하는 방법을 알아봅니다. - - 파일 시스템 코드 예제 - - Git 코드 예제 - - MCP 프롬프트 서버 코드 예제 - - SSE(Server-Sent Events) 코드 예제 + - 파일 시스템 예제 + - Git 예제 + - MCP 프롬프트 서버 예제 + - SSE(Server-Sent Events) 예제 - SSE 원격 서버 연결 (`examples/mcp/sse_remote_example`) - - 스트리밍 가능한 HTTP 코드 예제 - - 스트리밍 가능한 HTTP 원격 연결 (`examples/mcp/streamable_http_remote_example`) - - 스트리밍 가능한 HTTP를 위한 사용자 정의 HTTP 클라이언트 팩토리 (`examples/mcp/streamablehttp_custom_client_example`) + - Streamable HTTP 예제 + - Streamable HTTP 원격 연결 (`examples/mcp/streamable_http_remote_example`) + - Streamable HTTP용 사용자 지정 HTTP 클라이언트 팩토리 (`examples/mcp/streamablehttp_custom_client_example`) - `MCPUtil.get_all_function_tools`를 사용하여 모든 MCP 도구 미리 가져오기 (`examples/mcp/get_all_mcp_tools_example`) - - FastAPI와 함께 사용하는 MCPServerManager (`examples/mcp/manager_example`) + - FastAPI를 사용하는 MCPServerManager (`examples/mcp/manager_example`) - MCP 도구 필터링 (`examples/mcp/tool_filter_example`) -- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** 에이전트를 위한 다양한 메모리 구현 코드 예제는 다음과 같습니다: +- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** 에이전트를 위한 다양한 메모리 구현 예제는 다음과 같습니다. - - SQLite 세션 저장소 - - 고급 SQLite 세션 저장소 - - Redis 세션 저장소 - - SQLAlchemy 세션 저장소 - - Dapr 상태 저장소 기반 세션 저장소 - - 암호화된 세션 저장소 - - OpenAI Conversations 세션 저장소 - - Responses 압축 세션 저장소 + - SQLite 세션 스토리지 + - 고급 SQLite 세션 스토리지 + - Redis 세션 스토리지 + - SQLAlchemy 세션 스토리지 + - Dapr 상태 저장소 세션 스토리지 + - 암호화된 세션 스토리지 + - OpenAI Conversations 세션 스토리지 + - Responses 압축 세션 스토리지 - `ModelSettings(store=False)`를 사용하는 무상태 Responses 압축 (`examples/memory/compaction_session_stateless_example.py`) - - 파일 기반 세션 저장소 (`examples/memory/file_session.py`) + - 파일 기반 세션 스토리지 (`examples/memory/file_session.py`) - 휴먼인더루프 (HITL)를 사용하는 파일 기반 세션 (`examples/memory/file_hitl_example.py`) - 휴먼인더루프 (HITL)를 사용하는 SQLite 인메모리 세션 (`examples/memory/memory_session_hitl_example.py`) - 휴먼인더루프 (HITL)를 사용하는 OpenAI Conversations 세션 (`examples/memory/openai_session_hitl_example.py`) - 여러 세션에 걸친 HITL 승인/거부 시나리오 (`examples/memory/hitl_session_scenario.py`) -- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** 사용자 정의 제공업체와 서드 파티 어댑터를 포함하여 SDK에서 OpenAI 이외의 모델을 사용하는 방법을 살펴봅니다. +- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** 사용자 지정 제공업체와 서드 파티 어댑터를 포함하여 SDK에서 OpenAI 이외의 모델을 사용하는 방법을 살펴봅니다. -- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** SDK를 사용하여 실시간 경험을 구축하는 방법을 보여 주는 코드 예제는 다음과 같습니다: +- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** SDK를 사용해 실시간 환경을 구축하는 방법을 보여 주는 예제는 다음과 같습니다. - 구조화된 텍스트 및 이미지 메시지를 사용하는 웹 애플리케이션 패턴 - 명령줄 오디오 루프 및 재생 처리 - WebSocket을 통한 Twilio Media Streams 통합 - Realtime Calls API 연결 흐름을 사용하는 Twilio SIP 통합 -- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 추론 콘텐츠를 사용하는 방법을 보여 주는 코드 예제는 다음과 같습니다: +- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 추론 콘텐츠를 다루는 방법을 보여 주는 예제는 다음과 같습니다. - - Runner API에서 스트리밍 및 비스트리밍 방식으로 사용하는 추론 콘텐츠 (`examples/reasoning_content/runner_example.py`) - - OpenRouter를 통해 OSS 모델에서 사용하는 추론 콘텐츠 (`examples/reasoning_content/gpt_oss_stream.py`) - - 기본 추론 콘텐츠 코드 예제 (`examples/reasoning_content/main.py`) + - Runner API를 사용하는 스트리밍 및 비스트리밍 추론 콘텐츠 (`examples/reasoning_content/runner_example.py`) + - OpenRouter를 통해 OSS 모델을 사용하는 추론 콘텐츠 (`examples/reasoning_content/gpt_oss_stream.py`) + - 기본 추론 콘텐츠 예제 (`examples/reasoning_content/main.py`) -- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 복잡한 다중 에이전트 리서치 워크플로를 보여 주는 간단한 딥 리서치 클론입니다. +- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 복잡한 멀티 에이전트 리서치 워크플로를 보여 주는 간단한 딥 리서치 클론입니다. -- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 격리된 작업 공간에서 에이전트를 실행하는 코드 예제는 다음과 같습니다: +- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 격리된 작업 공간에서 에이전트를 실행하는 예제는 다음과 같습니다. - 기본 샌드박스 에이전트 설정 (`examples/sandbox/basic.py`) - - Unix 로컬 및 Docker 샌드박스 수명 주기 코드 예제 + - Unix 로컬 및 Docker 샌드박스 수명 주기 예제 - 샌드박스 기반 핸드오프 (`examples/sandbox/handoffs.py`) - 샌드박스 메모리 및 스냅샷 재개 (`examples/sandbox/memory.py`) - 도구로 노출된 샌드박스 에이전트 (`examples/sandbox/sandbox_agents_as_tools.py`) -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** 다음과 같은 OpenAI 호스트하는 도구와 실험적 Codex 도구를 구현하는 방법을 알아봅니다: +- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** 다음과 같은 OpenAI 호스트하는 도구 및 실험적 Codex 도구를 구현하는 방법을 알아봅니다. - - 웹 검색 및 필터를 사용하는 웹 검색 + - 웹 검색 및 필터가 적용된 웹 검색 - 파일 검색 - Code interpreter - 파일 편집 및 승인을 지원하는 패치 적용 도구 (`examples/tools/apply_patch.py`) @@ -128,9 +128,10 @@ search: - 스킬 참조를 사용하는 호스티드 컨테이너 셸 (`examples/tools/container_shell_skill_reference.py`) - 로컬 스킬을 사용하는 로컬 셸 (`examples/tools/local_shell_skill.py`) - 네임스페이스 및 지연된 도구를 사용하는 도구 검색 (`examples/tools/tool_search.py`) + - 동시 구조화 도구 호출을 사용하는 프로그래밍 방식 도구 호출 (`examples/tools/programmatic_tool_calling.py`) - 컴퓨터 사용 - 이미지 생성 - 실험적 Codex 도구 워크플로 (`examples/tools/codex.py`) - 실험적 Codex 동일 스레드 워크플로 (`examples/tools/codex_same_thread.py`) -- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** 스트리밍 음성 코드 예제를 포함하여 TTS 및 STT 모델을 사용하는 음성 에이전트 코드 예제를 살펴봅니다. \ No newline at end of file +- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** 스트리밍 음성 예제를 포함하여 TTS 및 STT 모델을 사용하는 음성 에이전트 예제를 살펴봅니다. \ No newline at end of file diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index 7accfba743..e14d338b5d 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -83,8 +83,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -140,8 +140,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -196,10 +196,8 @@ from agents import ( Agent, Runner, ToolGuardrailFunctionOutput, - function_tool, - tool_input_guardrail, - tool_output_guardrail, ) +from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail @tool_input_guardrail def block_secrets(data): @@ -219,7 +217,7 @@ def redact_output(data): return ToolGuardrailFunctionOutput.allow() -@function_tool( +@tool( tool_input_guardrails=[block_secrets], tool_output_guardrails=[redact_output], ) diff --git a/docs/ko/handoffs.md b/docs/ko/handoffs.md index fb1ea1bf4e..9c330759a4 100644 --- a/docs/ko/handoffs.md +++ b/docs/ko/handoffs.md @@ -4,21 +4,21 @@ search: --- # 핸드오프 -핸드오프를 사용하면 에이전트가 다른 에이전트에게 작업을 위임할 수 있습니다. 이는 서로 다른 에이전트가 각기 다른 영역을 전문으로 하는 시나리오에서 특히 유용합니다. 예를 들어 고객 지원 앱에는 주문 상태, 환불, FAQ 등의 작업을 각각 전담하는 에이전트가 있을 수 있습니다. +핸드오프를 사용하면 에이전트가 작업을 다른 에이전트에 위임할 수 있습니다. 이는 서로 다른 에이전트가 각기 다른 영역을 전문적으로 처리하는 시나리오에서 특히 유용합니다. 예를 들어 고객 지원 앱에는 주문 상태, 환불, FAQ 등의 작업을 각각 전문적으로 처리하는 에이전트가 있을 수 있습니다. -핸드오프는 LLM에 도구로 표시됩니다. 따라서 `Refund Agent`라는 에이전트로 핸드오프가 있으면 도구는 `transfer_to_refund_agent`라고 호출됩니다. +핸드오프는 LLM에 도구로 표현됩니다. 따라서 `Refund Agent`라는 에이전트로 핸드오프하는 경우 도구의 이름은 `transfer_to_refund_agent`가 됩니다. ## 핸드오프 생성 -모든 에이전트에는 [`handoffs`][agents.agent.Agent.handoffs] 매개변수가 있으며, 이 매개변수는 `Agent`를 직접 받거나 핸드오프를 사용자 지정하는 `Handoff` 객체를 받을 수 있습니다. +모든 에이전트에는 [`handoffs`][agents.agent.Agent.handoffs] 매개변수가 있으며, `Agent`를 직접 받거나 핸드오프를 사용자 지정하는 `Handoff` 객체를 받을 수 있습니다. -일반 `Agent` 인스턴스를 전달하면 해당 [`handoff_description`][agents.agent.Agent.handoff_description](설정된 경우)이 기본 도구 설명에 추가됩니다. 전체 `handoff()` 객체를 작성하지 않고도 모델이 해당 핸드오프를 선택해야 하는 시점을 힌트로 제공하는 데 사용하세요. +일반 `Agent` 인스턴스를 전달하면 해당 인스턴스의 [`handoff_description`][agents.agent.Agent.handoff_description]이 설정된 경우 기본 도구 설명에 추가됩니다. 완전한 `handoff()` 객체를 작성하지 않고도 모델이 언제 해당 핸드오프를 선택해야 하는지 알려주는 데 사용할 수 있습니다. -Agents SDK에서 제공하는 [`handoff()`][agents.handoffs.handoff] 함수를 사용하여 핸드오프를 만들 수 있습니다. 이 함수로 핸드오프할 에이전트와 선택적 재정의 및 입력 필터를 지정할 수 있습니다. +Agents SDK에서 제공하는 [`handoff()`][agents.handoffs.handoff] 함수를 사용하여 핸드오프를 생성할 수 있습니다. 이 함수로 핸드오프할 에이전트와 선택적 재정의 및 입력 필터를 지정할 수 있습니다. ### 기본 사용법 -간단한 핸드오프를 만드는 방법은 다음과 같습니다: +다음과 같이 간단한 핸드오프를 생성할 수 있습니다. ```python from agents import Agent, handoff @@ -30,22 +30,22 @@ refund_agent = Agent(name="Refund agent") triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)]) ``` -1. 에이전트를 직접 사용할 수도 있고(`billing_agent`처럼), `handoff()` 함수를 사용할 수도 있습니다. +1. 에이전트를 직접 사용할 수도 있고(`billing_agent`의 경우처럼), `handoff()` 함수를 사용할 수도 있습니다. ### `handoff()` 함수를 통한 핸드오프 사용자 지정 [`handoff()`][agents.handoffs.handoff] 함수를 사용하면 여러 항목을 사용자 지정할 수 있습니다. -- `agent`: 핸드오프 대상 에이전트입니다. -- `tool_name_override`: 기본적으로 `Handoff.default_tool_name()` 함수가 사용되며, 이는 `transfer_to_`으로 해석됩니다. 이를 재정의할 수 있습니다. -- `tool_description_override`: `Handoff.default_tool_description()`의 기본 도구 설명을 재정의합니다. -- `on_handoff`: 핸드오프가 호출될 때 실행되는 콜백 함수입니다. 핸드오프가 호출된다는 사실을 알게 되는 즉시 일부 데이터 가져오기를 시작하는 등의 작업에 유용합니다. 이 함수는 에이전트 컨텍스트를 받으며, 선택적으로 LLM이 생성한 입력도 받을 수 있습니다. 입력 데이터는 `input_type` 매개변수로 제어됩니다. +- `agent`: 핸드오프할 대상 에이전트입니다. +- `tool_name_override`: 기본적으로 `transfer_to_`으로 해석되는 `Handoff.default_tool_name()` 함수가 사용됩니다. 이를 재정의할 수 있습니다. +- `tool_description_override`: `Handoff.default_tool_description()`의 기본 도구 설명을 재정의합니다 +- `on_handoff`: 핸드오프가 호출될 때 실행되는 콜백 함수입니다. 핸드오프가 호출된다는 사실을 확인하는 즉시 데이터 가져오기와 같은 작업을 시작하는 데 유용합니다. 이 함수는 에이전트 컨텍스트를 받으며, 선택적으로 LLM이 생성한 입력도 받을 수 있습니다. 입력 데이터는 `input_type` 매개변수로 제어됩니다. - `input_type`: 핸드오프 도구 호출 인수의 스키마입니다. 설정하면 파싱된 페이로드가 `on_handoff`에 전달됩니다. -- `input_filter`: 이를 통해 다음 에이전트가 받는 입력을 필터링할 수 있습니다. 자세한 내용은 아래를 참고하세요. -- `is_enabled`: 핸드오프가 활성화되어 있는지 여부입니다. 불리언이거나 불리언을 반환하는 함수일 수 있으며, 런타임에 핸드오프를 동적으로 활성화하거나 비활성화할 수 있습니다. -- `nest_handoff_history`: RunConfig 수준의 `nest_handoff_history` 설정에 대한 호출별 선택적 재정의입니다. `None`이면 활성 실행 구성에 정의된 값이 대신 사용됩니다. +- `input_filter`: 다음 에이전트가 받는 입력을 필터링할 수 있습니다. 자세한 내용은 아래를 참조하세요. +- `is_enabled`: 핸드오프의 활성화 여부입니다. 불리언 또는 불리언을 반환하는 함수일 수 있으므로 런타임에 핸드오프를 동적으로 활성화하거나 비활성화할 수 있습니다. +- `nest_handoff_history`: RunConfig 수준의 `nest_handoff_history` 설정을 호출별로 재정의하는 선택적 항목입니다. `None`이면 활성 실행 구성에 정의된 값이 대신 사용됩니다. -[`handoff()`][agents.handoffs.handoff] 헬퍼는 항상 전달한 특정 `agent`로 제어권을 넘깁니다. 가능한 목적지가 여러 개라면 목적지마다 하나의 핸드오프를 등록하고 모델이 그중에서 선택하도록 하세요. 자체 핸드오프 코드가 호출 시점에 어떤 에이전트를 반환할지 결정해야 하는 경우에만 사용자 지정 [`Handoff`][agents.handoffs.Handoff]를 사용하세요. +[`handoff()`][agents.handoffs.handoff] 헬퍼는 항상 전달한 특정 `agent`로 제어권을 이전합니다. 가능한 대상이 여러 개인 경우 대상마다 하나의 핸드오프를 등록하고 모델이 그중에서 선택하게 하세요. 호출 시 자체 핸드오프 코드에서 반환할 에이전트를 결정해야 하는 경우에만 사용자 지정 [`Handoff`][agents.handoffs.Handoff]를 사용하세요. ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## 핸드오프 입력 -특정 상황에서는 LLM이 핸드오프를 호출할 때 일부 데이터를 제공하도록 하고 싶을 수 있습니다. 예를 들어 "에스컬레이션 에이전트"로 핸드오프한다고 가정해 보겠습니다. 로그로 남길 수 있도록 모델이 사유를 제공하길 원할 수 있습니다. +특정 상황에서는 LLM이 핸드오프를 호출할 때 일부 데이터를 제공하도록 할 수 있습니다. 예를 들어 "에스컬레이션 에이전트"로 핸드오프한다고 가정해 보겠습니다. 모델이 사유를 제공하도록 하여 이를 기록할 수 있습니다. ```python from pydantic import BaseModel @@ -87,44 +87,44 @@ handoff_obj = handoff( ) ``` -`input_type`은 핸드오프 도구 호출 자체의 인수를 설명합니다. SDK는 해당 스키마를 핸드오프 도구의 `parameters`로 모델에 노출하고, 반환된 JSON을 로컬에서 검증한 뒤 파싱된 값을 `on_handoff`에 전달합니다. +`input_type`은 핸드오프 도구 호출 자체의 인수를 설명합니다. SDK는 해당 스키마를 핸드오프 도구의 `parameters`로 모델에 노출하고, 반환된 JSON을 로컬에서 검증한 후 파싱된 값을 `on_handoff`에 전달합니다. -이는 다음 에이전트의 기본 입력을 대체하지 않으며, 다른 목적지를 선택하지도 않습니다. [`handoff()`][agents.handoffs.handoff] 헬퍼는 여전히 래핑한 특정 에이전트로 전달하며, [`input_filter`][agents.handoffs.Handoff.input_filter] 또는 중첩 핸드오프 기록 설정으로 변경하지 않는 한 수신 에이전트는 여전히 대화 기록을 보게 됩니다. +이는 다음 에이전트의 기본 입력을 대체하지 않으며 다른 대상을 선택하지도 않습니다. [`handoff()`][agents.handoffs.handoff] 헬퍼는 여전히 래핑한 특정 에이전트로 제어권을 이전하며, [`input_filter`][agents.handoffs.Handoff.input_filter] 또는 중첩된 핸드오프 기록 설정으로 변경하지 않는 한 수신 에이전트는 계속 대화 기록을 볼 수 있습니다. -`input_type`은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]와도 별개입니다. 로컬에 이미 있는 애플리케이션 상태나 의존성이 아니라, 핸드오프 시점에 모델이 결정하는 메타데이터에 `input_type`을 사용하세요. +또한 `input_type`은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]와 별개입니다. 로컬에 이미 있는 애플리케이션 상태나 종속성이 아니라, 모델이 핸드오프 시점에 결정하는 메타데이터에 `input_type`을 사용하세요. ### `input_type` 사용 시점 -핸드오프에 `reason`, `language`, `priority`, `summary`와 같은 작은 규모의 모델 생성 메타데이터가 필요할 때 `input_type`을 사용하세요. 예를 들어 분류 에이전트는 `{ "reason": "duplicate_charge", "priority": "high" }`와 함께 환불 에이전트로 핸드오프할 수 있으며, `on_handoff`는 환불 에이전트가 이어받기 전에 해당 메타데이터를 로그로 남기거나 영속화할 수 있습니다. +핸드오프에 `reason`, `language`, `priority`, `summary`처럼 모델이 생성한 소량의 메타데이터가 필요한 경우 `input_type`을 사용하세요. 예를 들어 분류 에이전트는 `{ "reason": "duplicate_charge", "priority": "high" }`와 함께 환불 에이전트로 핸드오프할 수 있으며, 환불 에이전트가 작업을 이어받기 전에 `on_handoff`가 해당 메타데이터를 기록하거나 저장할 수 있습니다. -목표가 다를 경우에는 다른 메커니즘을 선택하세요: +목적이 다르다면 다른 메커니즘을 선택하세요. -- 기존 애플리케이션 상태와 의존성은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 넣으세요. [컨텍스트 가이드](context.md)를 참고하세요. -- 수신 에이전트가 보게 되는 기록을 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter], [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 또는 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 사용하세요. -- 가능한 전문 에이전트가 여러 개라면 목적지마다 하나의 핸드오프를 등록하세요. `input_type`은 선택된 핸드오프에 메타데이터를 추가할 수 있지만, 목적지 간 라우팅을 수행하지는 않습니다. -- 대화를 이전하지 않고 중첩된 전문 에이전트에 구조화된 입력을 제공하려면 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]를 우선 사용하세요. [도구](tools.md#structured-input-for-tool-agents)를 참고하세요. +- 기존 애플리케이션 상태와 종속성은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 넣으세요. [컨텍스트 가이드](context.md)를 참조하세요. +- 수신 에이전트에 표시되는 기록을 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter], [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 또는 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 사용하세요. +- 가능한 전문 에이전트가 여러 개인 경우 대상마다 하나의 핸드오프를 등록하세요. `input_type`은 선택된 핸드오프에 메타데이터를 추가할 수 있지만 대상 간 디스패치를 수행하지는 않습니다. +- 대화를 이전하지 않고 중첩된 전문 에이전트에 구조화된 입력을 제공하려면 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]을 사용하는 것이 좋습니다. [도구](tools.md#structured-input-for-tool-agents)를 참조하세요. ## 입력 필터 -핸드오프가 발생하면 새 에이전트가 대화를 이어받는 것과 같으며, 이전 대화 기록 전체를 볼 수 있습니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]를 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받는 함수이며, 새 `HandoffInputData`를 반환해야 합니다. +핸드오프가 발생하면 새 에이전트가 대화를 이어받아 이전의 전체 대화 기록을 볼 수 있게 됩니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]를 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받고 새로운 `HandoffInputData`를 반환해야 하는 함수입니다. -[`HandoffInputData`][agents.handoffs.HandoffInputData]에는 다음이 포함됩니다: +[`HandoffInputData`][agents.handoffs.HandoffInputData]에는 다음이 포함됩니다. - `input_history`: `Runner.run(...)`이 시작되기 전의 입력 기록입니다. - `pre_handoff_items`: 핸드오프가 호출된 에이전트 턴 이전에 생성된 항목입니다. -- `new_items`: 현재 턴 중 생성된 항목이며, 핸드오프 호출과 핸드오프 출력 항목을 포함합니다. -- `input_items`: `new_items` 대신 다음 에이전트에 전달할 선택적 항목입니다. 이를 통해 세션 기록용으로 `new_items`는 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. +- `new_items`: 핸드오프 호출 및 핸드오프 출력 항목을 포함하여 현재 턴 중에 생성된 항목입니다. +- `input_items`: `new_items` 대신 다음 에이전트로 전달할 선택적 항목입니다. 세션 기록에서 `new_items`를 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. - `run_context`: 핸드오프가 호출된 시점의 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper]입니다. -중첩 핸드오프는 명시적으로 활성화해야 하는 베타 기능으로 제공되며, 안정화하는 동안 기본적으로 비활성화되어 있습니다. [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]를 활성화하면 러너는 이전 대화 기록을 하나의 어시스턴트 요약 메시지로 압축하고, 동일한 실행 중 여러 핸드오프가 발생할 때 새 턴을 계속 추가하는 `` 블록으로 감쌉니다. 전체 `input_filter`를 작성하지 않고도 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 통해 자체 매핑 함수를 제공하여 생성된 메시지를 대체할 수 있습니다. 이 명시적 활성화는 핸드오프와 실행 모두 명시적 `input_filter`를 제공하지 않는 경우에만 적용되므로, 이미 페이로드를 사용자 지정하는 기존 코드(이 저장소의 코드 예제를 포함)는 변경 없이 현재 동작을 유지합니다. 단일 핸드오프에 대해서는 [`handoff(...)`][agents.handoffs.handoff]에 `nest_handoff_history=True` 또는 `False`를 전달하여 중첩 동작을 재정의할 수 있으며, 이는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 설정합니다. 생성된 요약의 래퍼 텍스트만 변경하면 된다면, 에이전트를 실행하기 전에 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요(그리고 선택적으로 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]도 호출할 수 있습니다). +중첩된 핸드오프는 선택적으로 활성화할 수 있는 베타 기능이며, 안정화가 진행되는 동안 기본적으로 비활성화되어 있습니다. [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]를 활성화하면 러너는 무손실 메시지 항목을 원래 위치에 보존하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축합니다. 생성된 각 요약 세그먼트에는 `` 래퍼가 사용되며, 이후의 핸드오프는 순서가 지정된 대화 기록을 다시 구성하기 전에 이전에 생성된 세그먼트를 평면화합니다. 세션, `RunState`, `RunResult.to_input_list()`는 동일한 항목이 두 번 추가되지 않도록 이 SDK 기본 기록으로 이동된 정확한 메시지 발생 항목을 추적합니다. 별개의 동일한 메시지는 계속 보존됩니다. [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 통해 자체 매핑 함수를 제공하면 기본 제공 세분화 기능을 사용하는 대신 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환할 수 있습니다. 이 선택적 기능은 핸드오프와 실행 어느 쪽에도 명시적인 `input_filter`가 없는 경우에만 적용되므로, 이미 페이로드를 사용자 지정하는 기존 코드(이 저장소의 코드 예제 포함)는 변경 없이 현재 동작을 유지합니다. [`handoff(...)`][agents.handoffs.handoff]에 `nest_handoff_history=True` 또는 `False`를 전달하여 단일 핸드오프의 중첩 동작을 재정의할 수 있으며, 이 값은 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 설정합니다. 생성된 요약 세그먼트의 래퍼 텍스트만 변경하려면 에이전트를 실행하기 전에 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 필요에 따라 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]도 호출할 수 있습니다. -핸드오프와 활성 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]가 모두 필터를 정의하는 경우, 해당 특정 핸드오프에는 핸드오프별 [`input_filter`][agents.handoffs.Handoff.input_filter]가 우선합니다. +핸드오프와 활성 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]가 모두 필터를 정의한 경우, 해당 핸드오프에는 핸드오프별 [`input_filter`][agents.handoffs.Handoff.input_filter]가 우선 적용됩니다. !!! note - 핸드오프는 단일 실행 내에 머뭅니다. 입력 가드레일은 여전히 체인의 첫 번째 에이전트에만 적용되고, 출력 가드레일은 최종 출력을 생성하는 에이전트에만 적용됩니다. 워크플로 내부의 각 사용자 지정 함수 도구 호출에 대한 검사가 필요할 때는 도구 가드레일을 사용하세요. + 핸드오프는 단일 실행 내에서 유지됩니다. 입력 가드레일은 여전히 체인의 첫 번째 에이전트에만 적용되고 출력 가드레일은 최종 출력을 생성하는 에이전트에만 적용됩니다. 워크플로 내의 각 사용자 지정 함수 도구 호출 전후에 검사가 필요한 경우 도구 가드레일을 사용하세요. -몇 가지 일반적인 패턴(예: 기록에서 모든 도구 호출 제거)은 [`agents.extensions.handoff_filters`][]에 구현되어 있습니다 +기록에서 모든 도구 호출을 제거하는 것과 같은 몇 가지 일반적인 패턴은 [`agents.extensions.handoff_filters`][]에 구현되어 있습니다 ```python from agents import Agent, handoff @@ -142,7 +142,7 @@ handoff_obj = handoff( ## 권장 프롬프트 -LLM이 핸드오프를 올바르게 이해하도록 하려면, 에이전트에 핸드오프 관련 정보를 포함하는 것을 권장합니다. 제안된 접두사는 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][]에 있으며, 또는 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][]를 호출하여 프롬프트에 권장 데이터를 자동으로 추가할 수 있습니다. +LLM이 핸드오프를 올바르게 이해하도록 하려면 에이전트에 핸드오프 관련 정보를 포함하는 것이 좋습니다. [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][]에 권장 접두사가 있으며, [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][]를 호출하여 프롬프트에 권장 내용을 자동으로 추가할 수도 있습니다. ```python from agents import Agent diff --git a/docs/ko/human_in_the_loop.md b/docs/ko/human_in_the_loop.md index c883e64868..bd1e4602db 100644 --- a/docs/ko/human_in_the_loop.md +++ b/docs/ko/human_in_the_loop.md @@ -4,23 +4,26 @@ search: --- # 휴먼인더루프 (HITL) -휴먼인더루프 (HITL) 흐름을 사용해 사람이 민감한 도구 호출을 승인하거나 거부할 때까지 에이전트 실행을 일시 중지합니다. 도구는 승인이 필요한 시점을 선언하고, 실행 결과는 보류 중인 승인을 인터럽션(중단 처리)으로 노출하며, `RunState`를 사용하면 결정이 내려진 뒤 실행을 직렬화하고 재개할 수 있습니다. +휴먼인더루프 (HITL) 흐름을 사용하면 사람이 민감한 도구 호출을 승인하거나 거부할 때까지 에이전트 실행을 일시 중지할 수 있습니다. 도구는 승인이 필요한 시점을 선언하고, 실행 결과는 대기 중인 승인을 인터럽션(중단 처리)으로 표시하며, `RunState`를 사용하면 결정이 내려진 후 실행을 직렬화하고 재개할 수 있습니다. -이 승인 처리는 실행 전체 범위에 적용되며, 현재 최상위 에이전트로 제한되지 않습니다. 도구가 현재 에이전트에 속한 경우, 핸드오프로 도달한 에이전트에 속한 경우, 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에 속한 경우에도 같은 패턴이 적용됩니다. 중첩된 `Agent.as_tool()`의 경우에도 인터럽션(중단 처리)은 외부 실행에 노출되므로, 외부 `RunState`에서 승인하거나 거부한 뒤 원래 최상위 실행을 재개합니다. +이 승인 적용 범위는 현재 최상위 에이전트로 제한되지 않고 전체 실행에 적용됩니다. 도구가 현재 에이전트에 속한 경우, 핸드오프를 통해 도달한 에이전트에 속한 경우, 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에 속한 경우 모두 동일한 패턴이 적용됩니다. 중첩된 `Agent.as_tool()`의 경우에도 인터럽션(중단 처리)은 외부 실행에 표시되므로, 외부 `RunState`에서 이를 승인하거나 거부한 다음 원래의 최상위 실행을 재개합니다. -`Agent.as_tool()`에서는 두 가지 계층에서 승인이 발생할 수 있습니다. 에이전트 도구 자체가 `Agent.as_tool(..., needs_approval=...)`를 통해 승인을 요구할 수 있고, 중첩된 에이전트 내부의 도구가 중첩 실행이 시작된 뒤 자체 승인 요청을 나중에 발생시킬 수 있습니다. 둘 다 동일한 외부 실행 인터럽션(중단 처리) 흐름을 통해 처리됩니다. +`Agent.as_tool()`을 사용할 때는 두 계층에서 승인이 발생할 수 있습니다. 에이전트 도구 자체가 `Agent.as_tool(..., needs_approval=...)`을 통해 승인을 요구할 수 있으며, 중첩된 실행이 시작된 후 중첩된 에이전트 내부의 도구가 자체 승인을 요청할 수도 있습니다. 두 경우 모두 동일한 외부 실행의 인터럽션(중단 처리) 흐름을 통해 처리됩니다. -이 페이지는 `interruptions`를 통한 수동 승인 흐름에 중점을 둡니다. 앱이 코드로 결정을 내릴 수 있다면, 일부 도구 유형은 실행을 일시 중지하지 않고 계속 진행할 수 있도록 프로그래밍 방식 승인 콜백도 지원합니다. +이 페이지에서는 `interruptions`를 통한 수동 승인 흐름에 중점을 둡니다. 애플리케이션이 코드에서 결정할 수 있다면 일부 도구 유형은 프로그래밍 방식의 승인 콜백도 지원하므로 실행을 일시 중지하지 않고 계속할 수 있습니다. ## 승인이 필요한 도구 표시 -항상 승인을 요구하려면 `needs_approval`을 `True`로 설정하거나, 호출마다 결정하는 비동기 함수를 제공합니다. 호출 가능한 함수는 실행 컨텍스트, 파싱된 도구 매개변수, 도구 호출 ID를 받습니다. +항상 승인을 요구하려면 `needs_approval`을 `True`로 설정하고, 호출마다 결정하려면 비동기 함수를 제공합니다. 호출 가능 객체는 실행 컨텍스트, 파싱된 도구 매개변수, 도구 호출 ID를 받습니다. + +SDK가 인수를 안전하게 검사할 수 없는 경우 호출 가능 승인 규칙은 승인 필요 상태로 안전하게 실패합니다. 인수가 잘못된 JSON이거나, 유효한 JSON이지만 객체가 아닌 경우(예: `null` 또는 목록), 혹은 `NaN`, `Infinity`, `-Infinity` 같은 비표준 상수를 포함하는 경우 호출 가능 객체는 실행되지 않으며 해당 호출에는 수동 승인이 필요합니다. 이 동작은 Runner 및 Realtime 도구 호출에서 동일합니다. ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(needs_approval=True) +@tool(needs_approval=True) async def cancel_order(order_id: int) -> str: return f"Cancelled order {order_id}" @@ -29,7 +32,7 @@ async def requires_review(_ctx, params, _call_id) -> bool: return "refund" in params.get("subject", "").lower() -@function_tool(needs_approval=requires_review) +@tool(needs_approval=requires_review) async def send_email(subject: str, body: str) -> str: return f"Sent '{subject}'" @@ -41,26 +44,26 @@ agent = Agent( ) ``` -`needs_approval`은 [`function_tool`][agents.tool.function_tool], [`Agent.as_tool`][agents.agent.Agent.as_tool], [`ShellTool`][agents.tool.ShellTool], [`ApplyPatchTool`][agents.tool.ApplyPatchTool]에서 사용할 수 있습니다. 로컬 MCP 서버도 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]의 `require_approval`을 통해 승인을 지원합니다. 호스티드 MCP 서버는 `tool_config={"require_approval": "always"}`와 선택적 `on_approval_request` 콜백을 사용해 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 승인을 지원합니다. 셸 및 apply_patch 도구는 인터럽션(중단 처리)을 노출하지 않고 자동 승인 또는 자동 거부하려는 경우 `on_approval` 콜백을 허용합니다. +`needs_approval`은 [`function_tool`][agents.tool.function_tool], [`Agent.as_tool`][agents.agent.Agent.as_tool], [`ShellTool`][agents.tool.ShellTool], [`ApplyPatchTool`][agents.tool.ApplyPatchTool]에서 사용할 수 있습니다. 로컬 MCP 서버도 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]의 `require_approval`을 통해 승인을 지원합니다. 호스티드 MCP 서버는 `tool_config={"require_approval": "always"}` 및 선택적 `on_approval_request` 콜백과 함께 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 사용하여 승인을 지원합니다. 인터럽션(중단 처리)을 표시하지 않고 자동으로 승인하거나 거부하려면 셸 및 apply_patch 도구에 `on_approval` 콜백을 전달할 수 있습니다. -## 승인 흐름의 작동 방식 +## 승인 흐름 -1. 모델이 도구 호출을 내보내면, 러너는 해당 승인 규칙(`needs_approval`, `require_approval` 또는 호스티드 MCP 대응 기능)을 평가합니다. -2. 해당 도구 호출에 대한 승인 결정이 이미 [`RunContextWrapper`][agents.run_context.RunContextWrapper]에 저장되어 있으면, 러너는 프롬프트를 표시하지 않고 진행합니다. 호출별 승인은 특정 호출 ID 범위로 한정됩니다. 실행의 나머지 동안 해당 도구에 대한 이후 호출에도 같은 결정을 유지하려면 `always_approve=True` 또는 `always_reject=True`를 전달합니다. -3. 그렇지 않으면 실행이 일시 중지되고 `RunResult.interruptions`(또는 `RunResultStreaming.interruptions`)에 `agent.name`, `tool_name`, `arguments` 같은 세부 정보가 포함된 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 항목이 들어갑니다. 여기에는 핸드오프 이후 또는 중첩된 `Agent.as_tool()` 실행 내부에서 발생한 승인도 포함됩니다. -4. `result.to_state()`로 결과를 `RunState`로 변환하고, `state.approve(...)` 또는 `state.reject(...)`를 호출한 다음, `Runner.run(agent, state)` 또는 `Runner.run_streamed(agent, state)`로 재개합니다. 여기서 `agent`는 해당 실행의 원래 최상위 에이전트입니다. -5. 재개된 실행은 중단된 지점부터 계속 진행되며, 새 승인이 필요하면 이 흐름으로 다시 들어갑니다. +1. 모델이 도구 호출을 생성하면 러너는 해당 승인 규칙(`needs_approval`, `require_approval` 또는 이에 상응하는 호스티드 MCP 설정)을 평가합니다. +2. 해당 도구 호출의 승인 결정이 이미 [`RunContextWrapper`][agents.run_context.RunContextWrapper]에 저장되어 있으면 러너는 확인을 요청하지 않고 계속 진행합니다. 호출별 승인은 특정 호출 ID에 한정됩니다. 실행의 나머지 부분에서 해당 도구에 대한 향후 호출에도 동일한 결정을 유지하려면 `always_approve=True` 또는 `always_reject=True`를 전달합니다. +3. 그렇지 않으면 실행이 일시 중지되고 `RunResult.interruptions`(또는 `RunResultStreaming.interruptions`)에 `agent.name`, `tool_name`, `arguments` 등의 세부 정보가 포함된 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 항목이 들어갑니다. 여기에는 핸드오프 후 또는 중첩된 `Agent.as_tool()` 실행 내부에서 발생한 승인도 포함됩니다. +4. `result.to_state()`를 사용하여 결과를 `RunState`로 변환하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 다음, 실행의 원래 최상위 에이전트인 `agent`와 함께 `Runner.run(agent, state)` 또는 `Runner.run_streamed(agent, state)`를 사용하여 재개합니다. +5. 재개된 실행은 중단된 지점부터 계속되며 새로운 승인이 필요하면 이 흐름으로 다시 진입합니다. -`always_approve=True` 또는 `always_reject=True`로 생성된 고정 결정은 실행 상태에 저장되므로, 나중에 같은 일시 중지된 실행을 재개할 때 `state.to_string()` / `RunState.from_string(...)` 및 `state.to_json()` / `RunState.from_json(...)` 이후에도 유지됩니다. +`always_approve=True` 또는 `always_reject=True`로 생성된 지속적 결정은 실행 상태에 저장되므로, 나중에 동일한 일시 중지된 실행을 재개할 때 `state.to_string()` / `RunState.from_string(...)` 및 `state.to_json()` / `RunState.from_json(...)`을 거쳐도 유지됩니다. -보류 중인 모든 승인을 같은 단계에서 해결할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 섞여 있을 수 있습니다. 일부 항목만 승인하거나 거부한 뒤 다시 실행하면, 해결된 호출은 계속 진행될 수 있고 해결되지 않은 호출은 `interruptions`에 남아 실행을 다시 일시 중지합니다. +대기 중인 모든 승인을 한 번에 처리할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 함께 포함될 수 있습니다. 일부 항목만 승인하거나 거부한 후 다시 실행하면 처리된 호출은 계속 진행되고, 처리되지 않은 호출은 `interruptions`에 남아 실행을 다시 일시 중지합니다. ## 사용자 지정 거부 메시지 -기본적으로 거부된 도구 호출은 SDK의 표준 거부 텍스트를 실행으로 다시 반환합니다. 이 메시지는 두 계층에서 사용자 지정할 수 있습니다. +기본적으로 거부된 도구 호출은 SDK의 표준 거부 텍스트를 실행에 반환합니다. 다음 두 계층에서 이 메시지를 사용자 지정할 수 있습니다. -- 실행 전체 폴백: 전체 실행에서 승인 거부에 대해 모델에 표시되는 기본 메시지를 제어하려면 [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]를 설정합니다. -- 호출별 재정의: 특정 거부된 도구 호출 하나에 다른 메시지를 노출하려면 `state.reject(...)`에 `rejection_message=...`를 전달합니다. +- 실행 전체의 대체 설정: 전체 실행에서 승인 거부 시 모델에 표시되는 기본 메시지를 제어하려면 [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]를 설정합니다. +- 호출별 재정의: 특정 거부 도구 호출 하나에 다른 메시지를 표시하려면 `state.reject(...)`에 `rejection_message=...`를 전달합니다. 둘 다 제공되면 호출별 `rejection_message`가 실행 전체 포매터보다 우선합니다. @@ -83,41 +86,42 @@ state.reject( ) ``` -두 계층을 함께 보여 주는 전체 코드 예제는 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)를 참고하세요. +두 계층을 함께 보여주는 전체 예제는 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)를 참조하세요. ## 자동 승인 결정 -수동 `interruptions`가 가장 일반적인 패턴이지만, 유일한 방식은 아닙니다. +수동 `interruptions`가 가장 일반적인 패턴이지만 유일한 방법은 아닙니다. -- 로컬 [`ShellTool`][agents.tool.ShellTool] 및 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]은 코드에서 즉시 승인하거나 거부하기 위해 `on_approval`을 사용할 수 있습니다. -- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 같은 유형의 프로그래밍 방식 결정을 위해 `tool_config={"require_approval": "always"}`를 `on_approval_request`와 함께 사용할 수 있습니다. -- 일반 [`function_tool`][agents.tool.function_tool] 도구와 [`Agent.as_tool()`][agents.agent.Agent.as_tool]는 이 페이지의 수동 인터럽션(중단 처리) 흐름을 사용합니다. +- 로컬 [`ShellTool`][agents.tool.ShellTool] 및 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]은 `on_approval`을 사용하여 코드에서 즉시 승인하거나 거부할 수 있습니다. +- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 동일한 종류의 프로그래밍 방식 결정을 위해 `tool_config={"require_approval": "always"}`와 `on_approval_request`를 함께 사용할 수 있습니다. +- 일반 [`function_tool`][agents.tool.function_tool] 도구 및 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 이 페이지의 수동 인터럽션(중단 처리) 흐름을 사용합니다. -이러한 콜백이 결정을 반환하면, 실행은 사람의 응답을 기다리기 위해 일시 중지하지 않고 계속됩니다. Realtime 및 음성 세션 API의 경우 [Realtime 가이드](realtime/guide.md)의 승인 흐름을 참고하세요. +이러한 콜백이 결정을 반환하면 사람의 응답을 기다리기 위해 일시 중지하지 않고 실행을 계속합니다. Realtime 및 음성 세션 API의 경우 [Realtime 가이드](realtime/guide.md)의 승인 흐름을 참조하세요. -## 스트리밍과 세션 +## 스트리밍 및 세션 -동일한 인터럽션(중단 처리) 흐름은 스트리밍 실행에서도 작동합니다. 스트리밍 실행이 일시 중지된 뒤에는 반복자가 끝날 때까지 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events]를 계속 소비하고, [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]를 검사해 해결한 다음, 재개된 출력도 계속 스트리밍되게 하려면 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]로 재개합니다. 이 패턴의 스트리밍 버전은 [스트리밍](streaming.md)을 참고하세요. +동일한 인터럽션(중단 처리) 흐름이 스트리밍 실행에서도 작동합니다. 스트리밍 실행이 일시 중지된 후 반복자가 완료될 때까지 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events]를 계속 소비하고, [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]를 검사하여 처리한 다음, 재개된 출력도 계속 스트리밍하려면 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]로 재개합니다. 이 패턴의 스트리밍 버전은 [스트리밍](streaming.md)을 참조하세요. -세션도 함께 사용하는 경우 `RunState`에서 재개할 때 같은 세션 인스턴스를 계속 전달하거나, 동일한 백킹 스토어를 가리키는 다른 세션 객체를 전달합니다. 그러면 재개된 턴이 동일하게 저장된 대화 기록에 추가됩니다. 세션 수명 주기 세부 정보는 [세션](sessions/index.md)을 참고하세요. +세션도 사용하고 있다면 `RunState`에서 재개할 때 동일한 세션 인스턴스를 계속 전달하거나, 동일한 백엔드 저장소를 가리키는 다른 세션 객체를 전달합니다. 그러면 재개된 턴이 저장된 동일한 대화 기록에 추가됩니다. 세션 수명 주기에 대한 자세한 내용은 [세션](sessions/index.md)을 참조하세요. -## 예제: 일시 중지, 승인, 재개 +## 예제: 일시 중지, 승인 및 재개 -아래 스니펫은 JavaScript HITL 가이드와 동일한 흐름을 따릅니다. 도구에 승인이 필요할 때 일시 중지하고, 상태를 디스크에 저장한 뒤, 다시 로드하고, 결정을 수집한 후 재개합니다. +아래 코드 조각은 JavaScript HITL 가이드와 동일한 흐름을 보여줍니다. 도구에 승인이 필요할 때 일시 중지하고, 상태를 디스크에 저장한 후 다시 불러오며, 결정을 받은 뒤 실행을 재개합니다. ```python import asyncio import json from pathlib import Path -from agents import Agent, Runner, RunState, function_tool +from agents import Agent, Runner, RunState +from agents.decorators import tool async def needs_oakland_approval(_ctx, params, _call_id) -> bool: return "Oakland" in params.get("city", "") -@function_tool(needs_approval=needs_oakland_approval) +@tool(needs_approval=needs_oakland_approval) async def get_temperature(city: str) -> str: return f"The temperature in {city} is 20° Celsius" @@ -167,35 +171,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -이 예제에서 `prompt_approval`은 `input()`을 사용하고 `run_in_executor(...)`로 실행되기 때문에 동기 함수입니다. 승인 소스가 이미 비동기인 경우(예: HTTP 요청 또는 비동기 데이터베이스 쿼리), 대신 `async def` 함수를 사용하고 직접 `await`할 수 있습니다. +이 예제에서 `prompt_approval`은 `input()`을 사용하고 `run_in_executor(...)`로 실행되므로 동기 함수입니다. 승인 소스가 이미 비동기 방식인 경우(예: HTTP 요청 또는 비동기 데이터베이스 쿼리) `async def` 함수를 사용하고 직접 `await`할 수 있습니다. -승인을 기다리는 동안 출력을 스트리밍하려면 `Runner.run_streamed`를 호출하고, 완료될 때까지 `result.stream_events()`를 소비한 다음, 위에 표시된 것과 동일한 `result.to_state()` 및 재개 단계를 따릅니다. +승인을 기다리는 동안 출력을 스트리밍하려면 `Runner.run_streamed`를 호출하고, 완료될 때까지 `result.stream_events()`를 소비한 다음 위에 표시된 것과 동일한 `result.to_state()` 및 재개 단계를 따릅니다. -## 리포지토리 패턴과 코드 예제 +## 저장소 패턴 및 예제 -- **스트리밍 승인**: `examples/agent_patterns/human_in_the_loop_stream.py`는 `stream_events()`를 모두 소비한 다음, `Runner.run_streamed(agent, state)`로 재개하기 전에 보류 중인 도구 호출을 승인하는 방법을 보여 줍니다. -- **사용자 지정 거부 텍스트**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py`는 승인이 거부될 때 실행 수준 `tool_error_formatter`와 호출별 `rejection_message` 재정의를 결합하는 방법을 보여 줍니다. -- **도구로 사용하는 에이전트 승인**: `Agent.as_tool(..., needs_approval=...)`는 위임된 에이전트 작업에 검토가 필요할 때 동일한 인터럽션(중단 처리) 흐름을 적용합니다. 중첩된 인터럽션(중단 처리)은 여전히 외부 실행에 노출되므로, 중첩된 에이전트가 아니라 원래 최상위 에이전트를 재개합니다. -- **로컬 셸 및 apply_patch 도구**: `ShellTool` 및 `ApplyPatchTool`도 `needs_approval`을 지원합니다. 향후 호출에 대한 결정을 캐시하려면 `state.approve(interruption, always_approve=True)` 또는 `state.reject(..., always_reject=True)`를 사용합니다. 자동 결정을 위해서는 `on_approval`을 제공하세요(`examples/tools/shell.py` 참고). 수동 결정을 위해서는 인터럽션(중단 처리)을 처리하세요(`examples/tools/shell_human_in_the_loop.py` 참고). 호스티드 셸 환경은 `needs_approval` 또는 `on_approval`을 지원하지 않습니다. [도구 가이드](tools.md)를 참고하세요. -- **로컬 MCP 서버**: MCP 도구 호출을 제한하려면 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp`에서 `require_approval`을 사용합니다(`examples/mcp/get_all_mcp_tools_example/main.py` 및 `examples/mcp/tool_filter_example/main.py` 참고). -- **호스티드 MCP 서버**: HITL을 강제하려면 `HostedMCPTool`에서 `require_approval`을 `"always"`로 설정하고, 선택적으로 자동 승인 또는 거부를 위해 `on_approval_request`를 제공합니다(`examples/hosted_mcp/human_in_the_loop.py` 및 `examples/hosted_mcp/on_approval.py` 참고). 신뢰할 수 있는 서버에는 `"never"`를 사용합니다(`examples/hosted_mcp/simple.py`). -- **세션과 메모리**: 승인과 대화 기록이 여러 턴 동안 유지되도록 `Runner.run`에 세션을 전달합니다. SQLite 및 OpenAI Conversations 세션 변형은 `examples/memory/memory_session_hitl_example.py` 및 `examples/memory/openai_session_hitl_example.py`에 있습니다. -- **실시간 에이전트**: 실시간 데모는 `RealtimeSession`의 `approve_tool_call` / `reject_tool_call`을 통해 도구 호출을 승인하거나 거부하는 WebSocket 메시지를 노출합니다. 서버 측 핸들러는 `examples/realtime/app/server.py`를, API 인터페이스는 [Realtime 가이드](realtime/guide.md#tool-approvals)를 참고하세요. +- **스트리밍 승인**: `examples/agent_patterns/human_in_the_loop_stream.py`는 `stream_events()`를 모두 소비한 다음 대기 중인 도구 호출을 승인하고 `Runner.run_streamed(agent, state)`로 재개하는 방법을 보여줍니다. +- **사용자 지정 거부 텍스트**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py`는 승인이 거부될 때 실행 수준의 `tool_error_formatter`와 호출별 `rejection_message` 재정의를 결합하는 방법을 보여줍니다. +- **Agents as tools 승인**: `Agent.as_tool(..., needs_approval=...)`은 위임된 에이전트 작업에 검토가 필요할 때 동일한 인터럽션(중단 처리) 흐름을 적용합니다. 중첩된 인터럽션(중단 처리)도 외부 실행에 표시되므로 중첩된 에이전트가 아닌 원래의 최상위 에이전트를 재개합니다. +- **로컬 셸 및 apply_patch 도구**: `ShellTool`과 `ApplyPatchTool`도 `needs_approval`을 지원합니다. 향후 호출을 위해 결정을 캐시하려면 `state.approve(interruption, always_approve=True)` 또는 `state.reject(..., always_reject=True)`를 사용합니다. 자동 결정에는 `on_approval`을 제공하고(`examples/tools/shell.py` 참조), 수동 결정에는 인터럽션(중단 처리)을 처리합니다(`examples/tools/shell_human_in_the_loop.py` 참조). 호스티드 셸 환경은 `needs_approval` 또는 `on_approval`을 지원하지 않습니다. [도구 가이드](tools.md)를 참조하세요. +- **로컬 MCP 서버**: MCP 도구 호출을 제어하려면 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp`에서 `require_approval`을 사용합니다(`examples/mcp/get_all_mcp_tools_example/main.py` 및 `examples/mcp/tool_filter_example/main.py` 참조). +- **호스티드 MCP 서버**: HITL을 강제하려면 `HostedMCPTool`의 `require_approval`을 `"always"`로 설정하고, 필요에 따라 자동 승인 또는 거부를 위한 `on_approval_request`를 제공합니다(`examples/hosted_mcp/human_in_the_loop.py` 및 `examples/hosted_mcp/on_approval.py` 참조). 신뢰할 수 있는 서버에는 `"never"`를 사용합니다(`examples/hosted_mcp/simple.py`). +- **세션 및 메모리**: 승인 및 대화 기록이 여러 턴에 걸쳐 유지되도록 `Runner.run`에 세션을 전달합니다. SQLite 및 OpenAI Conversations 세션 변형은 `examples/memory/memory_session_hitl_example.py` 및 `examples/memory/openai_session_hitl_example.py`에 있습니다. +- **실시간 에이전트**: Realtime 데모는 `RealtimeSession`의 `approve_tool_call` / `reject_tool_call`을 통해 도구 호출을 승인하거나 거부하는 WebSocket 메시지를 제공합니다. 서버 측 핸들러는 `examples/realtime/app/server.py`를, API 인터페이스는 [Realtime 가이드](realtime/guide.md#tool-approvals)를 참조하세요. ## 장기 실행 승인 -`RunState`는 내구성을 갖도록 설계되었습니다. `state.to_json()` 또는 `state.to_string()`을 사용해 보류 중인 작업을 데이터베이스나 큐에 저장하고, 나중에 `RunState.from_json(...)` 또는 `RunState.from_string(...)`으로 다시 생성합니다. +`RunState`는 지속성을 갖도록 설계되었습니다. `state.to_json()` 또는 `state.to_string()`을 사용하여 대기 중인 작업을 데이터베이스나 큐에 저장하고, 나중에 `RunState.from_json(...)` 또는 `RunState.from_string(...)`을 사용하여 다시 생성합니다. -유용한 직렬화 옵션: +유용한 직렬화 옵션은 다음과 같습니다. -- `context_serializer`: 비매핑 컨텍스트 객체가 직렬화되는 방식을 사용자 지정합니다. -- `context_deserializer`: `RunState.from_json(...)` 또는 `RunState.from_string(...)`으로 상태를 로드할 때 비매핑 컨텍스트 객체를 다시 빌드합니다. -- `strict_context=True`: 컨텍스트가 이미 매핑이거나 적절한 serializer/deserializer를 제공한 경우가 아니면 직렬화 또는 역직렬화에 실패합니다. -- `context_override`: 상태를 로드할 때 직렬화된 컨텍스트를 대체합니다. 원래 컨텍스트 객체를 복원하고 싶지 않을 때 유용하지만, 이미 직렬화된 페이로드에서 해당 컨텍스트를 제거하지는 않습니다. -- `include_tracing_api_key=True`: 재개된 작업이 동일한 자격 증명으로 트레이스를 계속 내보내야 하는 경우, 직렬화된 트레이스 페이로드에 트레이싱 API 키를 포함합니다. +- `context_serializer`: 매핑이 아닌 컨텍스트 객체가 직렬화되는 방식을 사용자 지정합니다. +- `context_deserializer`: `RunState.from_json(...)` 또는 `RunState.from_string(...)`으로 상태를 불러올 때 매핑이 아닌 컨텍스트 객체를 다시 구성합니다. +- `strict_context=True`: 컨텍스트가 이미 매핑이거나 적절한 직렬화 도구/역직렬화 도구를 제공한 경우가 아니면 직렬화 또는 역직렬화가 실패하도록 합니다. +- `context_override`: 상태를 불러올 때 직렬화된 컨텍스트를 교체합니다. 원래의 컨텍스트 객체를 복원하지 않으려는 경우 유용하지만, 이미 직렬화된 페이로드에서 해당 컨텍스트를 제거하지는 않습니다. +- `include_tracing_api_key=True`: 재개된 작업이 동일한 자격 증명으로 트레이스를 계속 내보내야 하는 경우 직렬화된 트레이스 페이로드에 트레이싱 API 키를 포함합니다. -직렬화된 실행 상태에는 앱 컨텍스트와 함께 승인, 사용량, 직렬화된 `tool_input`, 중첩된 agent-as-tool 재개, 트레이스 메타데이터, 서버 관리 대화 설정 같은 SDK 관리 런타임 메타데이터가 포함됩니다. 직렬화된 상태를 저장하거나 전송하려는 경우 `RunContextWrapper.context`를 영속화된 데이터로 취급하고, 상태와 함께 이동하기를 의도한 경우가 아니라면 그 안에 비밀 정보를 두지 마세요. +직렬화된 실행 상태에는 애플리케이션 컨텍스트뿐 아니라 승인, 사용량, 직렬화된 `tool_input`, 중첩된 Agents as tools 재개 정보, 트레이스 메타데이터, 서버에서 관리하는 대화 설정 등 SDK가 관리하는 런타임 메타데이터가 포함됩니다. 직렬화된 상태를 저장하거나 전송하려는 경우 `RunContextWrapper.context`를 영구 저장되는 데이터로 취급하고, 의도적으로 상태와 함께 전달하려는 경우가 아니라면 비밀 정보를 넣지 마세요. -## 보류 중인 작업 버전 관리 +## 대기 중인 작업의 버전 관리 -승인이 한동안 대기할 수 있다면, 에이전트 정의 또는 SDK의 버전 표시자를 직렬화된 상태와 함께 저장하세요. 그러면 모델, 프롬프트 또는 도구 정의가 변경될 때 비호환성을 피하기 위해 역직렬화를 일치하는 코드 경로로 라우팅할 수 있습니다. \ No newline at end of file +승인이 장시간 대기할 수 있다면 직렬화된 상태와 함께 에이전트 정의 또는 SDK의 버전 마커를 저장합니다. 그러면 모델, 프롬프트 또는 도구 정의가 변경될 때 비호환성을 방지하도록 역직렬화를 일치하는 코드 경로로 라우팅할 수 있습니다. \ No newline at end of file diff --git a/docs/ko/models/index.md b/docs/ko/models/index.md index 47a7ca30fc..e54bb82b6e 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -4,43 +4,43 @@ search: --- # 모델 -Agents SDK는 두 가지 방식으로 OpenAI 모델을 즉시 사용할 수 있도록 지원합니다. +Agents SDK는 다음 두 가지 유형의 OpenAI 모델을 기본 지원합니다. - **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] - [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] ## 모델 설정 선택 -설정에 맞는 가장 간단한 방식부터 시작하세요. +설정에 맞는 가장 간단한 방법부터 시작하세요. -| 수행하려는 작업 | 권장 방식 | 자세히 보기 | +| 수행하려는 작업 | 권장 방법 | 자세히 알아보기 | | --- | --- | --- | -| OpenAI 모델만 사용 | 기본 OpenAI 공급자와 Responses 모델 경로 사용 | [OpenAI 모델](#openai-models) | +| OpenAI 모델만 사용 | Responses 모델 경로와 함께 기본 OpenAI 공급자 사용 | [OpenAI 모델](#openai-models) | | WebSocket 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 WebSocket 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | -| OpenAI 호스트 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | +| OpenAI 호스트 서브에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | | OpenAI 이외의 공급자 하나 사용 | 기본 제공 공급자 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | -| 에이전트별로 모델 또는 공급자 혼합 | 실행별 또는 에이전트별로 공급자를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [여러 공급자의 모델 혼합](#mixing-models-across-providers) | +| 에이전트 간에 모델 또는 공급자 혼합 | 실행별 또는 에이전트별로 공급자를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [공급자 간 모델 혼합](#mixing-models-across-providers) | | 고급 OpenAI Responses 요청 설정 조정 | OpenAI Responses 경로에서 `ModelSettings` 사용 | [고급 OpenAI Responses 설정](#advanced-openai-responses-settings) | -| OpenAI 이외의 공급자 또는 혼합 공급자 라우팅을 위한 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 배포할 공급자 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | +| OpenAI 이외의 공급자 또는 혼합 공급자 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 배포하려는 공급자 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | ## OpenAI 모델 -OpenAI만 사용하는 대부분의 앱에는 기본 OpenAI 공급자와 문자열 모델 이름을 사용하면서 Responses 모델 경로를 유지하는 방식을 권장합니다. +OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 공급자와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 것이 좋습니다. -`Agent`를 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본 모델은 지연 시간이 짧은 에이전트 워크플로를 위해 `reasoning.effort="none"` 및 `verbosity="low"`가 적용된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 액세스 권한이 있다면 명시적인 `model_settings`를 유지하면서 더 높은 품질을 위해 에이전트 모델을 `gpt-5.6-sol`로 설정하는 것이 좋습니다. +`Agent`를 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위해 `reasoning.effort="none"` 및 `verbosity="low"`가 설정된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 액세스 권한이 있다면 명시적인 `model_settings`를 유지하면서 더 높은 품질을 얻을 수 있도록 에이전트를 `gpt-5.6-sol`로 설정하는 것이 좋습니다. -`gpt-5.6-sol`과 같은 다른 모델로 전환하려면 두 가지 방법으로 에이전트를 구성할 수 있습니다. +`gpt-5.6-sol` 같은 다른 모델로 전환하려면 두 가지 방법으로 에이전트를 구성할 수 있습니다. ### 기본 모델 -먼저, 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정합니다. +먼저 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -둘째, `RunConfig`를 통해 실행의 기본 모델을 설정할 수 있습니다. 에이전트에 모델을 설정하지 않으면 해당 실행의 모델이 사용됩니다. +둘째, `RunConfig`를 통해 실행의 기본 모델을 설정할 수 있습니다. 에이전트에 모델을 설정하지 않으면 이 실행의 모델이 사용됩니다. ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 모델 -이 방식으로 `gpt-5.6-sol`과 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`를 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 적용됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`를 전달합니다. +이 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`를 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 적용됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`를 전달하세요. ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -지연 시간을 줄이려면 GPT-5 모델에서 `reasoning.effort="none"`을 사용하는 것이 좋습니다. +지연 시간을 줄이려면 GPT-5 모델에 `reasoning.effort="none"`을 사용하는 것이 좋습니다. -GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 유지되는 추론 컨텍스트, `"max"` 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. +GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 유지되는 추론 컨텍스트, `"max"` 추론 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. ```python from openai.types.shared import Reasoning @@ -96,37 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode`와 `reasoning.context`는 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 인터페이스에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고와 함께 모드와 컨텍스트를 무시합니다. 해당 경고를 오류로 전환하려면 OpenAI 공급자에서 `strict_feature_validation=True`를 설정하세요. +`reasoning.mode`와 `reasoning.context`는 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 추론 수준은 모델과 API 표면에 따라 다릅니다. GPT-5.6의 `"max"` 추론 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고와 함께 모드와 컨텍스트를 무시합니다. 해당 경고를 오류로 전환하려면 OpenAI 공급자에서 `strict_feature_validation=True`를 설정하세요. -`context="all_turns"`를 사용할 때는 `previous_response_id`, 서버 측 대화 또는 이전 추론 항목 재생을 통해 대화를 유지하세요. 상태 비저장 `store=False` 호출에서는 응답에 `reasoning.encrypted_content`를 포함하고 다음 요청에서 해당 추론 항목을 재생하세요. +`context="all_turns"`를 사용할 때는 `previous_response_id`, 서버 측 대화 또는 이전 추론 항목 재생을 통해 대화를 보존하세요. 상태 비저장 `store=False` 호출의 경우 응답에 `reasoning.encrypted_content`를 포함하고 다음 요청에서 해당 추론 항목을 재생하세요. #### ComputerTool 모델 선택 -에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청에 적용되는 모델에 따라 SDK가 전송할 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 정식 출시된 기본 제공 `computer` 도구를 사용하는 반면, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. +에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청의 유효 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 정식 출시된 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. -프롬프트가 관리하는 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 소유하여 SDK가 요청에서 `model`을 생략하면, 프롬프트가 고정한 모델을 추측하지 않도록 SDK는 미리보기 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 정식 출시 경로를 유지하려면 요청에 `model="gpt-5.5"`를 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 정식 출시 선택기를 강제하세요. +프롬프트 관리형 호출이 주요 예외입니다. 프롬프트 템플릿이 모델을 소유하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 어떤 모델을 고정하는지 추측하지 않도록 프리뷰 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 정식 출시 경로를 유지하려면 요청에 `model="gpt-5.5"`를 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 정식 출시 선택기를 강제하세요. -[`ComputerTool`][agents.tool.ComputerTool]이 등록된 경우 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`는 적용되는 요청 모델에 맞는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 해당 문자열은 계속 일반 함수 이름처럼 동작합니다. +[`ComputerTool`][agents.tool.ComputerTool]이 등록된 경우 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`는 유효 요청 모델에 맞는 기본 제공 선택기로 정규화됩니다. `ComputerTool`이 등록되지 않은 경우 이러한 문자열은 일반 함수 이름처럼 계속 동작합니다. -미리보기 호환 요청은 `environment`와 디스플레이 크기를 미리 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩터리를 사용하는 프롬프트 관리 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 정식 출시 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. +프리뷰 호환 요청은 `environment`와 디스플레이 크기를 사전에 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩터리를 사용하는 프롬프트 관리형 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 정식 출시 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. #### GPT-5 이외의 모델 -사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. +사용자 지정 `model_settings` 없이 GPT-5가 아닌 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. -### Responses 전용 도구 검색 기능 +### Responses 전용 도구 기능 다음 도구 기능은 OpenAI Responses 모델에서만 지원됩니다. - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 인터페이스 +- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 표면 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers`, `tool_choice="programmatic_tool_calling"` -이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 단순 네임스페이스 이름이나 지연 전용 함수 이름을 강제하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 사항은 [도구](../tools.md#hosted-tool-search)를 참조하세요. +이러한 기능은 Chat Completions 모델과 Responses가 아닌 백엔드에서 거부됩니다. 지연 로딩 도구를 사용하는 경우 에이전트에 `ToolSearchTool()`을 추가하고, 네임스페이스 이름이나 지연 로딩 전용 함수 이름을 직접 강제하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 조건은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. ### Responses WebSocket 전송 -기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 WebSocket 전송을 활성화할 수 있습니다. +기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 WebSocket 전송을 사용하도록 설정할 수 있습니다. #### 기본 설정 @@ -136,9 +137,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이 설정은 기본 OpenAI 공급자가 해석하는 OpenAI Responses 모델에 적용되며, `"gpt-5.6-sol"`과 같은 문자열 모델 이름도 포함됩니다. +이는 기본 OpenAI 공급자가 해석하는 OpenAI Responses 모델에 영향을 줍니다. 여기에는 `"gpt-5.6-sol"` 같은 문자열 모델 이름도 포함됩니다. -전송 방식은 SDK가 모델 이름을 모델 인스턴스로 해석할 때 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 객체의 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 유지합니다. `RunConfig(model_provider=...)`를 전달하면 전역 기본값 대신 해당 공급자가 전송 방식 선택을 제어합니다. +전송 방식은 SDK가 모델 이름을 모델 인스턴스로 해석할 때 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 정해져 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 유지합니다. `RunConfig(model_provider=...)`를 전달하면 전역 기본값 대신 해당 공급자가 전송 방식을 선택합니다. #### 공급자 또는 실행 수준 설정 @@ -163,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI 기반 공급자는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID와 같은 공급자 수준 등록 메타데이터를 요구하는 경우를 위한 고급 옵션입니다. +OpenAI 기반 공급자는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID 같은 공급자 수준의 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. ```python from agents import ( @@ -189,14 +190,14 @@ result = await Runner.run( #### `MultiProvider`를 사용한 고급 라우팅 -접두사 기반 모델 라우팅이 필요한 경우(예: 한 번의 실행에서 `openai/...`와 `any-llm/...` 모델 이름을 혼합하는 경우) [`MultiProvider`][agents.MultiProvider]를 사용하고 해당 위치에서 `openai_use_responses_websocket=True`를 설정하세요. +접두사 기반 모델 라우팅이 필요한 경우(예: 한 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]를 사용하고 여기에서 `openai_use_responses_websocket=True`를 설정하세요. -`MultiProvider`는 기존의 두 가지 기본 동작을 유지합니다. +`MultiProvider`는 다음 두 가지 기존 기본 동작을 유지합니다. -- `openai/...`는 OpenAI 공급자의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`로 라우팅됩니다. +- `openai/...`는 OpenAI 공급자의 별칭으로 취급되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`로 라우팅됩니다. - 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`를 발생시킵니다. -리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 OpenAI 공급자에 지정하는 경우, 통과 동작을 명시적으로 활성화하세요. WebSocket이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`를 유지하세요. +OpenAI 공급자가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키는 경우 통과 동작을 명시적으로 활성화하세요. WebSocket이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`를 유지하세요. ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -222,29 +223,29 @@ result = await Runner.run( ) ``` -백엔드가 리터럴 `openai/...` 문자열을 요구할 때는 `openai_prefix_mode="model_id"`를 사용하세요. 백엔드가 `openrouter/openai/gpt-4.1-mini`와 같은 다른 네임스페이스 모델 ID를 요구할 때는 `unknown_prefix_mode="model_id"`를 사용하세요. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화한 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. +백엔드가 리터럴 `openai/...` 문자열을 요구할 때는 `openai_prefix_mode="model_id"`를 사용하세요. 백엔드가 `openrouter/openai/gpt-4.1-mini` 같은 다른 네임스페이스 모델 ID를 요구할 때는 `unknown_prefix_mode="model_id"`를 사용하세요. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화된 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. -`MultiProvider`를 통해 라우팅하면서 동일한 공급자 수준 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`를 전달하면 기본 OpenAI 공급자에 전달됩니다. +`MultiProvider`를 통해 라우팅하면서 동일한 공급자 수준 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`를 전달하면 기본 OpenAI 공급자로 전달됩니다. 사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 WebSocket 전송에도 호환되는 WebSocket `/responses` 엔드포인트가 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. #### 참고 사항 -- 이는 WebSocket 전송을 통한 Responses API이며 [Realtime API](../realtime/guide.md)가 아닙니다. Chat Completions 또는 OpenAI 이외의 공급자가 Responses WebSocket `/responses` 엔드포인트를 지원하지 않는 한 해당 항목에는 적용되지 않습니다. -- 환경에 `websockets` 패키지가 아직 없다면 설치하세요. -- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 직접 사용할 수 있습니다. 여러 턴과 중첩된 에이전트 도구 호출 전반에서 동일한 WebSocket 연결을 재사용하려는 멀티턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 도우미를 권장합니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. -- 추론 턴이 길거나 네트워크 지연이 급증하는 경우 `responses_websocket_options`로 WebSocket 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping을 활성화한 상태로 하트비트 시간 제한을 비활성화하려면 `ping_timeout=None`을 설정하세요. WebSocket 지연 시간보다 안정성이 더 중요할 때는 HTTP/SSE 전송을 권장합니다. -- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 장기간 실행되는 에이전트 프로세스나 메모리가 제한된 컨테이너에서는 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`를 설정하세요. +- 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않으며, Responses WebSocket `/responses` 엔드포인트를 지원하지 않는 OpenAI 이외의 공급자에도 적용되지 않습니다. +- 환경에 아직 `websockets` 패키지가 없다면 설치하세요. +- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 직접 사용할 수 있습니다. 여러 턴과 중첩된 에이전트 도구 호출에서 동일한 WebSocket 연결을 재사용하려는 멀티턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 도우미를 사용하는 것이 좋습니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. +- 긴 추론 턴이나 지연 시간이 급증하는 네트워크에서는 `responses_websocket_options`를 사용하여 WebSocket 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화된 상태로 유지하면서 하트비트 시간 제한을 비활성화하려면 `ping_timeout=None`을 설정하세요. WebSocket 지연 시간보다 안정성이 더 중요하다면 HTTP/SSE 전송을 사용하는 것이 좋습니다. +- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 실행되는 수명이 긴 에이전트 프로세스나 메모리가 제한된 컨테이너에서는 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`를 설정하세요. ### 호스티드 멀티 에이전트(실험적) -OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 하위 에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 기존 `Runner`를 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되고 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. +OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 서브에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`를 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되며, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. -이 통합은 실험적이며, 로컬 함수 출력을 `response.inject`를 사용하여 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`를 노출하는 베타 빌드를 포함하여 `openai[realtime]>=2.45.0`이 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. +이 통합은 실험적이며, 로컬 함수 출력을 `response.inject`를 통해 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`를 노출하는 베타 빌드를 포함하여 `openai[realtime]>=2.45.0`이 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. #### 모델 구성 -실험적 모듈에서 모델을 가져와 SDK `Agent`에 할당합니다. +실험적 모듈에서 모델을 가져와 SDK `Agent`에 할당하세요. ```python from agents import Agent @@ -257,22 +258,22 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`가 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`가 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`를 생략하면 서비스 기본값이 사용됩니다. +`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`가 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`가 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`가 생략되면 서비스 기본값이 사용됩니다. #### 로컬 함수 도구 -모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. Responses API가 함수를 호출할 호스티드 에이전트를 결정합니다. 일반 SDK Runner가 함수를 로컬에서 실행하고 동일한 호출 ID가 포함된 `function_call_output`을 활성 WebSocket 응답에 삽입하므로, 서비스가 원래 호스티드 호출자를 재개할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 훅, 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`가 아닌 함수 도구는 요청이 전송되기 전에 거부됩니다. +모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. 어떤 호스티드 에이전트가 함수를 호출할지는 Responses API가 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 포함된 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 그러면 서비스가 원래 호스티드 호출자를 재개할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 훅, 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`가 아닌 함수 도구는 요청을 보내기 전에 거부됩니다. -도구에서 호출자별 로깅이나 권한 부여가 필요할 때는 `get_hosted_agent_metadata()`를 사용하세요. +도구에 호출자 인식 로깅이나 권한 부여가 필요한 경우 `get_hosted_agent_metadata()`를 사용하세요. ```python from typing import Any -from agents import function_tool +from agents.decorators import tool from agents.extensions.experimental.hosted_multi_agent import get_hosted_agent_metadata from agents.tool_context import ToolContext -@function_tool +@tool def lookup_document(ctx: ToolContext[Any], section: str) -> str: metadata = get_hosted_agent_metadata(ctx) caller = metadata.agent_name if metadata else "unknown" @@ -280,50 +281,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID를 사용하여 출력을 라우팅하세요. 부수 효과가 있는 도구에서는 해당 호출 ID를 멱등성 키로 사용하고, 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. +호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID를 사용하여 출력을 라우팅하세요. 부작용이 있는 도구의 경우 해당 호출 ID를 멱등성 키로 사용하고, 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. #### 출력 및 스트리밍 동작 -`final_answer` 단계에서 `/root`에 귀속된 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 상위 수준 `RunResult`에서 제외합니다. SDK는 이러한 레코드를 로컬 함수로 실행하지 않습니다. +`final_answer` 단계에서 `/root`에 귀속된 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 서브에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 해당 레코드를 로컬 함수로 실행하지 않습니다. -원문 스트리밍에서는 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트가 계속 노출됩니다. 어댑터는 함수 호출이 준비되면 활성 공급자 응답 하나를 SDK에 표시되는 논리적 모델 턴으로 나눈 다음, Runner가 출력을 생성한 후 동일한 공급자 응답을 재개합니다. 귀속 정보를 확인하려면 원문 호스티드 항목이나 `ToolContext`와 함께 `get_hosted_agent_metadata()`를 사용하세요. +원문 스트리밍에서는 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트가 계속 노출됩니다. 어댑터는 함수 호출이 준비되면 하나의 활성 공급자 응답을 SDK에 표시되는 논리적 모델 턴으로 나누고, Runner가 출력을 생성한 후 동일한 공급자 응답을 재개합니다. 귀속 정보를 확인하려면 원문 호스티드 항목 또는 `ToolContext`와 함께 `get_hosted_agent_metadata()`를 사용하세요. #### SDK 오케스트레이션과의 관계 -호스티드 멀티 에이전트는 SDK 핸드오프 및 agents-as-tools와 별개입니다. +호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. -- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 이러한 하위 에이전트를 생성하거나 예약하지 않습니다. -- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 이 실험적 모델을 사용할 때는 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 핸드오프가 거부됩니다. -- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 클라이언트 측 및 서버 측 오케스트레이션이 중첩됩니다. 추가되는 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. +- 호스티드 멀티 에이전트는 OpenAI 서비스에서 서브에이전트를 생성합니다. 애플리케이션은 해당 서브에이전트를 생성하거나 예약하지 않습니다. +- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 거부됩니다. +- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. #### 현재 제한 사항 -실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공한 `multi_agent` 또는 `betas` 재정의를 거부합니다. Responses `/compact` 엔드포인트는 베타에서 지원되지 않지만, 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`는 사용할 수 있습니다. +실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공한 `multi_agent` 또는 `betas` 재정의를 거부합니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`를 사용할 수는 있지만, Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. -하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 동시에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행을 중단한 경우 `await model.close()`를 호출하여 WebSocket을 해제하세요. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. +하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행을 중단하는 경우 `await model.close()`를 호출하여 WebSocket을 해제하세요. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. -기반 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)를 참조하세요. +기본 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)를 참조하세요. ## OpenAI 이외의 모델 -OpenAI 이외의 공급자가 필요하면 SDK의 기본 제공 공급자 통합 지점부터 시작하세요. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. +OpenAI 이외의 공급자가 필요한 경우 SDK의 기본 제공 공급자 통합 지점으로 시작하세요. 대부분의 설정에서는 서드 파티 어댑터를 추가하지 않아도 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. ### OpenAI 이외의 공급자 통합 방식 | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트를 대부분 또는 모든 에이전트의 기본값으로 사용해야 할 때 | 전역 기본값 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 공급자를 단일 실행에 적용해야 할 때 | 실행별 | -| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 다른 공급자 또는 구체적인 모델 객체가 필요할 때 | 에이전트별 | -| 서드 파티 어댑터 | 기본 제공 경로가 제공하지 않는 어댑터 관리형 공급자 지원 범위 또는 라우팅이 필요할 때 | [서드 파티 어댑터](#third-party-adapters) 참조 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 대부분 또는 모든 에이전트에 하나의 OpenAI 호환 엔드포인트를 기본값으로 사용해야 하는 경우 | 전역 기본값 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 공급자를 단일 실행에 적용해야 하는 경우 | 실행별 | +| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 서로 다른 공급자 또는 구체적인 모델 객체가 필요한 경우 | 에이전트별 | +| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 어댑터 관리형 공급자 지원 범위 또는 라우팅이 필요한 경우 | [서드 파티 어댑터](#third-party-adapters) 참조 | 다음 기본 제공 경로를 사용하여 다른 LLM 공급자를 통합할 수 있습니다. -1. [`set_default_openai_client`][agents.set_default_openai_client]는 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우에 유용합니다. LLM 공급자에 OpenAI 호환 API 엔드포인트가 있고 `base_url`과 `api_key`를 설정할 수 있는 경우를 위한 방식입니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. -2. [`ModelProvider`][agents.models.interface.ModelProvider]는 `Runner.run` 수준에서 사용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 공급자를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. -3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 공급자를 조합하여 사용할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. +1. [`set_default_openai_client`][agents.set_default_openai_client]는 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우에 유용합니다. LLM 공급자가 OpenAI 호환 API 엔드포인트를 제공하고 `base_url`과 `api_key`를 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. +2. [`ModelProvider`][agents.models.interface.ModelProvider]는 `Runner.run` 수준에서 적용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 공급자를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. +3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 공급자를 조합할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. -`platform.openai.com`의 API 키가 없는 경우 `set_tracing_disabled()`를 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것이 좋습니다. +`platform.openai.com`에서 발급한 API 키가 없는 경우 `set_tracing_disabled()`를 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것이 좋습니다. ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -338,19 +339,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 이 예제에서는 많은 LLM 공급자가 아직 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 공급자가 Responses API를 지원한다면 Responses 사용을 권장합니다. + 이 예제에서는 아직 많은 LLM 공급자가 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 공급자가 Responses API를 지원한다면 Responses를 사용하는 것이 좋습니다. ## 하나의 워크플로에서 모델 혼합 -단일 워크플로에서 에이전트마다 서로 다른 모델을 사용해야 할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. +단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 중 한 가지 방법으로 특정 모델을 선택할 수 있습니다. 1. 모델 이름 전달 2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider] 전달 -3. [`Model`][agents.models.interface.Model] 구현을 직접 제공 +3. [`Model`][agents.models.interface.Model] 구현 직접 제공 !!! note - SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]과 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식이 서로 다른 기능과 도구 집합을 지원하므로 각 워크플로에서는 하나의 모델 형식만 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용하는 모든 기능을 양쪽 모두에서 사용할 수 있는지 확인하세요. + SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]과 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식이 서로 다른 기능 및 도구 세트를 지원하므로 워크플로마다 하나의 모델 형식을 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 조합해야 한다면 사용하는 모든 기능을 두 형식에서 모두 사용할 수 있는지 확인하세요. ```python import asyncio @@ -391,7 +392,7 @@ if __name__ == "__main__": 1. OpenAI 모델의 이름을 직접 설정합니다. 2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. -에이전트에 사용할 모델을 더 세부적으로 구성하려면 temperature와 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.models.interface.ModelSettings]를 전달할 수 있습니다. +에이전트에 사용되는 모델을 추가로 구성하려면 temperature 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.models.interface.ModelSettings]를 전달할 수 있습니다. ```python from agents import Agent, ModelSettings @@ -406,22 +407,22 @@ english_agent = Agent( ## 고급 OpenAI Responses 설정 -OpenAI Responses 경로를 사용하면서 더 많은 제어가 필요할 때는 `ModelSettings`부터 시작하세요. +OpenAI Responses 경로에서 더 세밀한 제어가 필요한 경우 `ModelSettings`부터 사용하세요. ### 일반적인 고급 `ModelSettings` 옵션 -OpenAI Responses API를 사용할 때는 여러 요청 필드에 직접 대응하는 `ModelSettings` 필드가 이미 있으므로 이러한 필드에 `extra_args`를 사용할 필요가 없습니다. +OpenAI Responses API를 사용하는 경우 여러 요청 필드가 이미 직접적인 `ModelSettings` 필드로 제공되므로 해당 필드에 `extra_args`를 사용할 필요가 없습니다. - `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출을 허용하거나 금지합니다. -- `truncation`: 컨텍스트가 넘칠 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 제거하도록 `"auto"`를 설정합니다. +- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 삭제하도록 `"auto"`를 설정합니다. - `store`: 생성된 응답을 나중에 검색할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로와 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. - `context_management`: `compact_threshold`를 사용하는 Responses 압축과 같은 서버 측 컨텍스트 처리를 구성합니다. -- `prompt_cache_retention`: 이전 모델 계열의 연장된 보존 기간을 구성합니다. 예를 들어 - `"24h"`를 사용합니다. -- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고 GPT-5.6의 경우 `"30m"` 캐시 TTL을 구성합니다. -- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content`와 같이 더 풍부한 응답 페이로드를 요청합니다. -- `top_logprobs`: 출력 텍스트의 상위 토큰 로그 확률을 요청합니다. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. -- `retry`: 모델 호출에 Runner가 관리하는 재시도 설정을 사용합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. +- `prompt_cache_retention`: 이전 모델 계열의 연장된 보존 기간을 구성합니다. 예를 들면 + `"24h"`입니다. +- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고, GPT-5.6의 경우 `"30m"` 캐시 TTL을 구성합니다. +- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 같은 더 풍부한 응답 페이로드를 요청합니다. +- `top_logprobs`: 출력 텍스트에 대해 상위 토큰 logprobs를 요청합니다. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. +- `retry`: 모델 호출에 대해 Runner 관리형 재시도 설정을 사용하도록 선택합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. ```python from agents import Agent, ModelSettings @@ -441,7 +442,7 @@ research_agent = Agent( ) ``` -명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드가 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 유지합니다. +명시적 프롬프트 캐싱을 사용할 때는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 보존합니다. ```python from agents import Runner @@ -467,18 +468,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention`은 기존 보존 제어를 사용하는 이전 모델 계열에서도 계속 사용할 수 있습니다. -직접적인 `ModelSettings` 필드와 `extra_args`의 동일한 키를 함께 사용하지 마세요. +`prompt_cache_retention`은 기존 보존 제어를 사용하는 이전 모델 계열에서 계속 사용할 수 있습니다. +직접적인 `ModelSettings` 필드와 `extra_args`에 동일한 키를 함께 사용하지 마세요. -`store=False`를 설정하면 Responses API는 해당 응답을 나중에 서버 측에서 검색할 수 있도록 유지하지 않습니다. 이는 상태 비저장 또는 데이터 보존이 없는 형태의 흐름에 유용하지만, 응답 ID를 재사용하는 기능이 로컬에서 관리하는 상태에 의존해야 함을 의미하기도 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않았을 때 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. +`store=False`를 설정하면 Responses API는 해당 응답을 나중에 서버 측에서 검색할 수 있도록 유지하지 않습니다. 이는 상태 비저장 또는 데이터 비보존 형태의 흐름에 유용하지만, 일반적으로 응답 ID를 재사용하는 기능이 로컬에서 관리되는 상태에 의존해야 함을 의미합니다. 예를 들어 마지막 응답이 저장되지 않은 경우 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. -서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`는 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. +서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`는 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립 실행형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. ### `extra_args` 전달 -SDK가 아직 최상위 수준에 직접 노출하지 않은 공급자별 요청 필드나 최신 요청 필드가 필요한 경우 `extra_args`를 사용하세요. +SDK가 아직 최상위 수준에서 직접 노출하지 않는 공급자별 요청 필드나 최신 요청 필드가 필요한 경우 `extra_args`를 사용하세요. -또한 OpenAI의 Responses API를 사용할 때 [몇 가지 다른 선택적 매개변수](https://platform.openai.com/docs/api-reference/responses/create)(예: `user`, `service_tier` 등)를 사용할 수 있습니다. 최상위 수준에서 사용할 수 없다면 `extra_args`를 사용하여 전달할 수도 있습니다. 동일한 요청 필드를 직접적인 `ModelSettings` 필드를 통해 함께 설정하지 마세요. +또한 OpenAI의 Responses API를 사용할 때는 [몇 가지 다른 선택적 매개변수](https://platform.openai.com/docs/api-reference/responses/create)(예: `user`, `service_tier` 등)를 사용할 수 있습니다. 최상위 수준에서 사용할 수 없다면 `extra_args`를 통해 전달할 수도 있습니다. 동일한 요청 필드를 직접적인 `ModelSettings` 필드를 통해 함께 설정하지 마세요. ```python from agents import Agent, ModelSettings @@ -528,40 +529,40 @@ agent = Agent(
-| 필드 | 타입 | 참고 사항 | +| 필드 | 유형 | 참고 사항 | | --- | --- | --- | | `max_retries` | `int | None` | 최초 요청 이후 허용되는 재시도 횟수입니다. | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`는 계산된 백오프 지연만 제한합니다. 정책이나 retry-after 힌트가 반환한 명시적 지연은 제한하지 않습니다. | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적인 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`는 계산된 이 백오프 지연만 제한합니다. 정책이 반환한 명시적 지연이나 retry-after 힌트는 제한하지 않습니다. | | `policy` | `RetryPolicy | None` | 재시도 여부를 결정하는 콜백입니다. 이 필드는 런타임 전용이며 직렬화되지 않습니다. |
재시도 정책은 다음 항목이 포함된 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. -- 시도 횟수를 고려하여 결정할 수 있도록 제공되는 `attempt`와 `max_retries` -- 스트리밍과 비스트리밍 동작을 분기할 수 있도록 제공되는 `stream` +- 시도 횟수에 따라 결정을 내릴 수 있도록 제공되는 `attempt`와 `max_retries` +- 스트리밍 및 비스트리밍 동작을 분기할 수 있도록 제공되는 `stream` - 원문 검사를 위한 `error` -- `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort`와 같이 정규화된 정보가 포함된 `normalized` -- 기반 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용되는 `provider_advice` +- `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort` 같은 정규화된 정보가 포함된 `normalized` +- 기본 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용되는 `provider_advice` 정책은 다음 중 하나를 반환할 수 있습니다. - 간단한 재시도 결정을 위한 `True` / `False` -- 지연 시간을 재정의하거나 진단 사유를 첨부하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] +- 지연 시간을 재정의하거나 진단 사유를 첨부하려는 경우 사용하는 [`RetryDecision`][agents.retry.RetryDecision] -SDK는 `retry_policies`에서 바로 사용할 수 있는 도우미를 제공합니다. +SDK는 `retry_policies`에서 바로 사용할 수 있는 도우미를 내보냅니다. | 도우미 | 동작 | | --- | --- | | `retry_policies.never()` | 항상 재시도하지 않습니다. | -| `retry_policies.provider_suggested()` | 사용 가능한 경우 공급자의 재시도 지침을 따릅니다. | +| `retry_policies.provider_suggested()` | 공급자의 재시도 지침이 있으면 이를 따릅니다. | | `retry_policies.network_error()` | 일시적인 전송 및 시간 제한 실패와 일치합니다. | -| `retry_policies.http_status([...])` | 선택한 HTTP 상태 코드와 일치합니다. | -| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 도우미는 retry-after 값을 명시적 정책 지연으로 처리하므로 `backoff.max_delay`가 이를 제한하지 않습니다. | +| `retry_policies.http_status([...])` | 선택된 HTTP 상태 코드와 일치합니다. | +| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 도우미는 retry-after 값을 명시적인 정책 지연으로 취급하므로 `backoff.max_delay`가 이를 제한하지 않습니다. | | `retry_policies.any(...)` | 중첩된 정책 중 하나라도 재시도를 선택하면 재시도합니다. | -| `retry_policies.all(...)` | 중첩된 모든 정책이 재시도를 선택한 경우에만 재시도합니다. | +| `retry_policies.all(...)` | 중첩된 모든 정책이 재시도를 선택할 때만 재시도합니다. | -정책을 조합할 때는 공급자가 구분할 수 있는 거부와 재생 안전성 승인을 유지하므로 `provider_suggested()`가 가장 안전한 첫 번째 기본 구성 요소입니다. +정책을 조합할 때 `provider_suggested()`는 가장 안전한 첫 번째 기본 구성 요소입니다. 공급자가 이를 구분할 수 있는 경우 공급자의 거부 결정과 재생 안전성 승인을 보존하기 때문입니다. ##### 안전 경계 @@ -569,16 +570,16 @@ SDK는 `retry_policies`에서 바로 사용할 수 있는 도우미를 제공합 - 중단 오류 - 공급자 지침에서 재생이 안전하지 않다고 표시한 요청 -- 재생이 안전하지 않을 정도로 출력이 이미 시작된 스트리밍 실행 +- 재생이 안전하지 않게 되는 방식으로 출력이 이미 시작된 스트리밍 실행 -`previous_response_id` 또는 `conversation_id`를 사용하는 상태 유지형 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()`나 `http_status([500])`와 같은 비공급자 조건만으로는 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`를 통한 공급자의 재생 안전 승인이 포함되어야 합니다. +`previous_response_id` 또는 `conversation_id`를 사용하는 상태 유지형 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 공급자 외부 조건만으로 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`를 통한 공급자의 재생 안전 승인이 포함되어야 합니다. -##### Runner와 에이전트의 병합 동작 +##### Runner 및 에이전트 병합 동작 -`retry`는 Runner 수준과 에이전트 수준의 `ModelSettings` 사이에서 깊은 병합이 적용됩니다. +`retry`는 Runner 수준과 에이전트 수준의 `ModelSettings` 간에 심층 병합됩니다. - 에이전트는 `retry.max_retries`만 재정의하면서 Runner의 `policy`를 상속할 수 있습니다. -- 에이전트는 `retry.backoff`의 일부만 재정의하면서 Runner의 다른 백오프 필드를 유지할 수 있습니다. +- 에이전트는 `retry.backoff`의 일부만 재정의하고 Runner의 나머지 백오프 필드를 유지할 수 있습니다. - `policy`는 런타임 전용이므로 직렬화된 `ModelSettings`에는 `max_retries`와 `backoff`가 유지되지만 콜백 자체는 생략됩니다. 더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py)와 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. @@ -587,22 +588,22 @@ SDK는 `retry_policies`에서 바로 사용할 수 있는 도우미를 제공합 ### 트레이싱 클라이언트 오류 401 -트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되지만 OpenAI API 키가 없기 때문입니다. 이를 해결하는 방법은 세 가지입니다. +트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. 1. 트레이싱 완전히 비활성화: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] -2. 트레이싱용 OpenAI 키 설정: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급된 키여야 합니다. +2. 트레이싱용 OpenAI 키 설정: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급한 키여야 합니다. 3. OpenAI 이외의 트레이스 프로세서 사용. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. ### Responses API 지원 -SDK는 기본적으로 Responses API를 사용하지만, 다른 많은 LLM 공급자는 아직 이를 지원하지 않습니다. 그 결과 404 또는 이와 유사한 문제가 발생할 수 있습니다. 이를 해결하는 방법은 두 가지입니다. +SDK는 기본적으로 Responses API를 사용하지만, 아직 많은 다른 LLM 공급자가 이를 지원하지 않습니다. 그 결과 404 또는 이와 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. -1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]를 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY`와 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. +1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]를 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY`와 `OPENAI_BASE_URL`을 설정하는 경우 작동합니다. 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. ### Chat Completions 호환성 옵션 -Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, 프롬프트 또는 텍스트 전용이 아닌 도구 출력과 같이 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 자동으로 삭제하여 호환성을 유지합니다. 개발 중 이러한 불일치가 즉시 실패하도록 하려면 OpenAI 공급자에서 엄격한 기능 검증을 활성화하세요. +Chat Completions를 통해 라우팅하면 SDK는 `previous_response_id`, `conversation_id`, 프롬프트 또는 텍스트 전용이 아닌 도구 출력과 같이 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 별도의 알림 없이 제거하여 호환성을 유지합니다. 개발 중 이러한 불일치가 발생하면 즉시 실패하도록 하려면 OpenAI 공급자에서 엄격한 기능 검증을 활성화하세요. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -622,7 +623,7 @@ result = await Runner.run( [`MultiProvider`][agents.MultiProvider]를 사용하는 경우 대신 `openai_strict_feature_validation=True`를 전달하세요. -일부 OpenAI 호환 Chat Completions 공급자는 점진적인 SDK 처리에 충분히 안정적이지 않은 청크로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 공급자 스트림이 완료된 후에만 SDK가 도구 호출을 내보내도록 하세요. +일부 OpenAI 호환 Chat Completions 공급자는 증분 SDK 처리에 충분히 안정적이지 않은 청크 단위로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 공급자 스트림이 완료된 후에만 SDK가 도구 호출을 내보내도록 하세요. ```python from agents import OpenAIProvider @@ -633,11 +634,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider]에서는 `openai_buffer_streamed_tool_calls=True`를 사용하세요. +[`MultiProvider`][agents.MultiProvider]에는 `openai_buffer_streamed_tool_calls=True`를 사용하세요. ### structured outputs 지원 -일부 모델 공급자는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이로 인해 때때로 다음과 유사한 오류가 발생합니다. +일부 모델 공급자는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 지원하지 않습니다. 이로 인해 다음과 유사한 오류가 발생하기도 합니다. ``` @@ -645,37 +646,37 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -이는 일부 모델 공급자의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`를 지정하도록 허용하지 않습니다. 이 문제를 해결하기 위해 작업하고 있지만 JSON 스키마 출력을 지원하는 공급자를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. +이는 일부 모델 공급자의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`는 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 공급자를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. -## 여러 공급자의 모델 혼합 +## 공급자 간 모델 혼합 -모델 공급자 간 기능 차이를 알고 있어야 하며, 그렇지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 공급자는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. +모델 공급자 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 공급자는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. -- 지원하지 않는 `tools`를 이해하지 못하는 공급자에게 보내지 마세요 +- 이해하지 못하는 공급자에 지원되지 않는 `tools`를 보내지 마세요 - 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요 -- 구조화된 JSON 출력을 지원하지 않는 공급자는 때때로 유효하지 않은 JSON을 생성할 수 있다는 점에 유의하세요. +- 구조화된 JSON 출력을 지원하지 않는 공급자는 때때로 잘못된 JSON을 생성할 수 있다는 점에 유의하세요. ## 서드 파티 어댑터 -SDK의 기본 제공 공급자 통합 지점만으로 충분하지 않을 때만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM이나 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 권장합니다. 서드 파티 어댑터는 OpenAI 모델과 OpenAI 이외의 공급자를 결합해야 하거나, 기본 제공 경로가 제공하지 않는 어댑터 관리형 공급자 지원 범위 또는 라우팅이 필요한 경우를 위한 것입니다. 어댑터는 SDK와 업스트림 모델 공급자 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 공급자마다 다를 수 있습니다. 현재 SDK에는 최선 지원 방식의 베타 어댑터 통합으로 Any-LLM과 LiteLLM이 포함되어 있습니다. +SDK의 기본 제공 공급자 통합 지점만으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM이나 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 공급자와 결합해야 하거나, 기본 제공 경로에서 제공하지 않는 어댑터 관리형 공급자 지원 범위 또는 라우팅이 필요한 경우를 위한 것입니다. 어댑터는 SDK와 업스트림 모델 공급자 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계는 공급자에 따라 달라질 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선형 베타 어댑터 통합으로 포함되어 있습니다. ### Any-LLM -Any-LLM 지원은 Any-LLM이 관리하는 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 지원 방식의 베타로 제공됩니다. +Any-LLM 지원은 Any-LLM 관리형 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. 업스트림 공급자 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 공급자별 호환성 계층을 사용할 수 있습니다. -Any-LLM이 필요한 경우 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)부터 시작하세요. [`MultiProvider`][agents.MultiProvider]와 함께 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`를 사용할 수 있습니다. 모델 인터페이스를 명시적으로 고정해야 하는 경우 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`를 전달하세요. +Any-LLM이 필요한 경우 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)에서 시작하세요. [`MultiProvider`][agents.MultiProvider]와 함께 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`를 사용할 수 있습니다. 모델 표면을 명시적으로 고정해야 하는 경우 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`를 전달하세요. -Any-LLM은 서드 파티 어댑터 계층이므로 공급자 종속성과 기능 차이는 SDK가 아니라 업스트림 Any-LLM에서 정의됩니다. 업스트림 공급자가 사용량 지표를 반환하면 자동으로 전달되지만, 스트리밍 Chat Completions 백엔드는 사용량 청크를 내보내기 전에 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 전용 동작을 사용하는 경우 배포할 정확한 공급자 백엔드를 검증하세요. +Any-LLM은 계속 서드 파티 어댑터 계층으로 유지되므로 공급자 종속성과 기능 차이는 SDK가 아니라 업스트림 Any-LLM에 의해 정의됩니다. 업스트림 공급자가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드에서 사용량 청크를 내보내려면 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses별 동작에 의존하는 경우 배포하려는 정확한 공급자 백엔드를 검증하세요. ### LiteLLM -LiteLLM 지원은 LiteLLM별 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 지원 방식의 베타로 제공됩니다. +LiteLLM 지원은 LiteLLM별 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. -LiteLLM이 필요한 경우 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)부터 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. +LiteLLM이 필요한 경우 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)에서 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. -일부 LiteLLM 기반 공급자는 기본적으로 SDK 사용량 지표를 채우지 않습니다. 사용량 보고가 필요한 경우 `ModelSettings(include_usage=True)`를 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작을 사용하는 경우 배포할 정확한 공급자 백엔드를 검증하세요. +일부 LiteLLM 기반 공급자는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요한 경우 `ModelSettings(include_usage=True)`를 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 공급자 백엔드를 검증하세요. LiteLLM이 응답 객체에 대해 Pydantic 직렬화 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. @@ -683,4 +684,4 @@ LiteLLM이 응답 객체에 대해 Pydantic 직렬화 경고를 내보내는 경 export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -이 패치는 기본적으로 비활성화되어 있으며 `1` 또는 `true` 값에서만 활성화됩니다. 비공개 LiteLLM 로깅 도우미를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로, 일반적인 직렬화 설정이 아니라 특정 문제를 위한 우회 방법으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고, 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file +이 패치는 기본적으로 비활성화되어 있으며 값이 `1` 또는 `true`일 때만 활성화됩니다. 비공개 LiteLLM 로깅 도우미를 래핑하여 특정 LiteLLM 응답 직렬화 경고를 억제하므로 일반적인 직렬화 설정이 아니라 한정된 해결 방법으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고, 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file diff --git a/docs/ko/quickstart.md b/docs/ko/quickstart.md index c543ad7139..462687d1f9 100644 --- a/docs/ko/quickstart.md +++ b/docs/ko/quickstart.md @@ -114,10 +114,11 @@ if __name__ == "__main__": ```python import asyncio -from agents import Agent, Runner, function_tool +from agents import Agent, Runner +from agents.decorators import tool -@function_tool +@tool def history_fun_fact() -> str: """Return a short history fact.""" return "Sharks are older than trees." diff --git a/docs/ko/realtime/guide.md b/docs/ko/realtime/guide.md index a5cb4d2aa3..dd7724063d 100644 --- a/docs/ko/realtime/guide.md +++ b/docs/ko/realtime/guide.md @@ -214,10 +214,10 @@ async for event in session: Realtime agents는 라이브 대화 중 함수 도구를 지원합니다. ```python -from agents import function_tool +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Get current weather for a city.""" return f"The weather in {city} is sunny, 72F." diff --git a/docs/ko/release.md b/docs/ko/release.md index 7241421328..3d86d00252 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -4,38 +4,51 @@ search: --- # 릴리스 프로세스/변경 로그 -이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 시맨틱 버저닝을 따릅니다. 맨 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가합니다. +이 프로젝트는 `0.Y.Z` 형식을 사용하는 약간 수정된 시맨틱 버저닝을 따릅니다. 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. ## 마이너(`Y`) 버전 -베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 있는 경우 마이너 버전 `Y`를 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경할 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. +베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨뜨리는 변경 사항**이 있을 경우 마이너 버전 `Y`를 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨뜨리는 변경 사항이 포함될 수 있습니다. -호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. +호환성을 깨뜨리는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것을 권장합니다. ## 패치(`Z`) 버전 -호환성을 깨지 않는 변경 사항에는 `Z`를 증가시킵니다. +호환성을 깨뜨리지 않는 변경 사항에는 `Z`를 증가시킵니다. - 버그 수정 - 새로운 기능 - 비공개 인터페이스 변경 - 베타 기능 업데이트 -## 호환성을 깨는 변경 사항 기록 +## 호환성을 깨뜨리는 변경 로그 + +### 0.19.0 + +이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새 기능 영역인 프로그래매틱 도구 호출(Programmatic Tool Calling)을 반영합니다. + +주요 내용: + +- 지원되는 OpenAI Responses 모델이 적격한 도구를 조정하는 JavaScript를 생성할 수 있게 해주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 추가했습니다. 도구별 `allowed_callers`, 구조화된 함수 도구 출력, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState` 통합을 지원합니다. 설정 및 제약 조건은 [프로그래매틱 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. +- 기존 함수 및 가드레일 데코레이터와 함께 공개 `agents.decorators` 모듈과 더 짧은 `@tool` 별칭을 추가했습니다. 이제 함수 도구에서 비동기 callable 객체도 지원합니다. +- 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인의 SDK 설정에서 타입이 지정된 설정 객체 또는 딕셔너리를 일관되게 허용하며, 알 수 없는 설정도 검증합니다. +- 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅을 강화하여 유용한 디버깅 컨텍스트를 유지하면서 민감한 원본 페이로드가 노출되지 않도록 했습니다. +- AnyLLM, LiteLLM 및 Chat Completions 호환성을 개선하고, 모델 재시도 시 세션 기록을 보존하며, 응답이 시작되기 전에 발생하는 WebSocket 과부하 오류를 재시도하도록 했습니다. +- `VercelCloudBucketMountStrategy`를 사용하는 [Vercel 샌드박스용 생성 시점 전용 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)를 추가했습니다. 마운트가 포함된 세션에서는 워크스페이스 영속화 시 버킷 콘텐츠를 제외하며, 동적 마운트 변경과 세션 재개를 의도적으로 지원하지 않습니다. ### 0.18.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만을 위한 것입니다. +이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만을 반영합니다. 주요 내용: -- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서는 별도 구성 없이 최신 권장 모델을 사용합니다. +- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새 Realtime 설정에서 별도의 구성 없이 최신 권장 모델을 사용합니다. ### 0.17.0 -이 버전에서는 소스 경로에 `Manifest.extra_path_grants`가 적용되지 않는 한, 샌드박스 로컬 소스 구체화 과정에서 `LocalFile.src`와 `LocalDir.src`가 구체화 `base_dir` 내부에 유지됩니다. 매니페스트가 적용될 때 `base_dir`은 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 해당 디렉터리 내부 또는 명시적으로 권한이 부여된 경로 아래에 있어야 합니다. 이를 통해 로컬 아티팩트 경계 문제가 해결되지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 의도적으로 샌드박스 작업 공간에 복사하는 애플리케이션에는 영향을 줄 수 있습니다. +이 버전에서는 샌드박스 로컬 소스 구체화 시 소스 경로가 `Manifest.extra_path_grants`에 포함되지 않는 한 `LocalFile.src`와 `LocalDir.src`가 구체화 `base_dir` 내에 유지됩니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 해당 디렉터리 내부 또는 명시적 허용 범위 아래에 있어야 합니다. 이는 로컬 아티팩트 경계 문제를 해결하지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 의도적으로 샌드박스 작업 공간에 복사하는 애플리케이션에는 영향을 줄 수 있습니다. -마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`를 사용해 신뢰할 수 있는 호스트 루트에 권한을 부여하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우에는 가급적 읽기 전용으로 설정하세요. +마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`를 사용하여 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스가 해당 파일을 읽기만 하면 되는 경우에는 읽기 전용으로 설정하는 것이 좋습니다. ```python from pathlib import Path @@ -62,11 +75,11 @@ manifest = Manifest( ) ``` -`extra_path_grants`는 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 신뢰할 수 없는 다른 매니페스트 입력으로 권한 부여 항목을 채우지 마세요. +`extra_path_grants`를 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 매니페스트 입력을 사용하여 허용 범위를 채우지 마세요. ### 0.16.0 -이 버전부터 SDK 기본 모델이 `gpt-4.1` 대신 `gpt-5.4-mini`로 변경되었습니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새로운 기본 모델이 GPT-5 모델이므로, 암시적 기본 모델 설정에는 이제 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. +이 버전에서는 SDK 기본 모델이 `gpt-4.1`에서 `gpt-5.4-mini`로 변경되었습니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본 모델이 GPT-5 모델이므로 암시적인 기본 모델 설정에 이제 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. 이전 기본 모델 동작을 유지해야 한다면 에이전트 또는 실행 구성에서 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. @@ -77,13 +90,13 @@ agent = Agent(name="Assistant", model="gpt-4.1") 주요 내용: - 이제 `Runner.run`, `Runner.run_sync`, `Runner.run_streamed`에서 `max_turns=None`을 지정하여 턴 제한을 비활성화할 수 있습니다. -- 이제 로컬, Docker 및 공급자 기반 샌드박스 구현 전체에서 샌드박스 작업 공간 하이드레이션이 절대 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. +- 이제 로컬, Docker 및 공급자 기반 샌드박스 구현 전반에서 샌드박스 작업 공간 하이드레이션이 절대 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. ### 0.15.0 -이 버전부터 모델 거부는 빈 텍스트 출력으로 처리되거나 structured outputs에서 실행 루프가 `MaxTurnsExceeded`에 도달할 때까지 재시도되도록 하는 대신, `ModelRefusalError`로 명시적으로 노출됩니다. +이 버전에서는 모델 거부가 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`에 도달할 때까지 재시도하게 하는 대신, 이제 `ModelRefusalError`로 명시적으로 노출됩니다. -이는 이전에 거부만 포함된 모델 응답이 `final_output == ""` 상태로 완료될 것으로 예상한 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. +이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. ```python result = Runner.run_sync( @@ -93,94 +106,94 @@ result = Runner.run_sync( ) ``` -structured outputs 에이전트의 경우 핸들러는 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. +structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 스키마과 일치하는 값을 반환할 수 있으며, SDK는 이를 다른 실행 오류 핸들러의 최종 출력과 동일하게 검증합니다. ### 0.14.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주요 신규 베타 기능 영역인 샌드박스 에이전트와 더불어 로컬, 컨테이너 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. +이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없지만**, 새로운 주요 베타 기능 영역인 샌드박스 에이전트와 이를 로컬, 컨테이너화 및 호스팅 환경에서 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. 주요 내용: -- `SandboxAgent`, `Manifest`, `SandboxRunConfig`를 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스가 추가되어, 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 지원을 갖춘 영구 격리 작업 공간 안에서 작업할 수 있습니다. -- `UnixLocalSandboxClient`와 `DockerSandboxClient`를 통해 로컬 및 컨테이너 기반 개발용 샌드박스 실행 백엔드가 추가되었으며, 선택적 추가 패키지를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, Vercel의 호스팅 공급자 통합도 추가되었습니다. -- 향후 실행에서 이전 실행에서 얻은 교훈을 재사용할 수 있도록 샌드박스 메모리 지원이 추가되었습니다. 여기에는 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계 및 S3 기반 워크플로를 포함한 영구 메모리 코드 예제가 포함됩니다. -- 로컬 및 합성 작업 공간 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하여 더 광범위한 작업 공간 및 재개 모델이 추가되었습니다. -- `examples/sandbox/` 아래에 기술을 활용한 코딩 작업, 핸드오프, 메모리, 공급자별 설정과 코드 검토, 데이터룸 QA, 웹사이트 복제 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제와 튜토리얼이 추가되었습니다. -- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더 안전한 민감 MCP 출력 마스킹을 통해 핵심 런타임과 트레이싱 스택이 확장되었습니다. +- `SandboxAgent`, `Manifest`, `SandboxRunConfig`를 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스를 추가하여 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 지원이 포함된 영구 격리 작업 공간에서 작업할 수 있도록 했습니다. +- `UnixLocalSandboxClient`와 `DockerSandboxClient`를 통한 로컬 및 컨테이너화 개발용 샌드박스 실행 백엔드와 선택적 추가 패키지를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel의 호스팅 공급자 통합을 추가했습니다. +- 이후 실행에서 이전 실행의 학습 내용을 재사용할 수 있도록 샌드박스 메모리 지원을 추가했으며, 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계, S3 기반 워크플로를 포함한 영구 메모리 예제를 제공합니다. +- 로컬 및 합성 작업 공간 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하는 더욱 폭넓은 작업 공간 및 재개 모델을 추가했습니다. +- `examples/sandbox/` 아래에 기술을 활용한 코딩 작업, 핸드오프, 메모리, 공급자별 설정과 코드 리뷰, 데이터룸 QA, 웹사이트 복제 등의 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제와 튜토리얼을 추가했습니다. +- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더 안전한 민감한 MCP 출력 마스킹 기능으로 핵심 런타임과 트레이싱 스택을 확장했습니다. ### 0.13.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정 사항이 포함되어 있습니다. +이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정 사항이 포함되어 있습니다. 주요 내용: -- 이제 기본 WebSocket Realtime 모델은 `gpt-realtime-1.5`이므로, 새로운 Realtime 에이전트 설정에서는 별도 구성 없이 더 새로운 모델을 사용합니다. -- 이제 `MCPServer`에서 `list_resources()`, `list_resource_templates()`, `read_resource()`를 제공하며, `MCPServerStreamableHttp`에서는 `session_id`를 제공하므로 재연결이나 상태 비저장 워커 간에 스트리밍 가능 HTTP 세션을 재개할 수 있습니다. -- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`를 통해 추론 콘텐츠 재실행을 선택적으로 활성화할 수 있어 LiteLLM/DeepSeek 같은 어댑터에서 공급자별 추론/도구 호출 연속성이 향상됩니다. -- `SQLAlchemySession`의 동시 첫 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남겨 두는 `remove_all_tools()`, 함수 도구 배치 실행기의 경쟁 상태를 포함하여 여러 런타임 및 세션 경계 사례를 수정했습니다. +- 기본 WebSocket Realtime 모델이 이제 `gpt-realtime-1.5`이므로, 새 Realtime 에이전트 설정에서 별도의 구성 없이 더 최신 모델을 사용합니다. +- 이제 `MCPServer`에서 `list_resources()`, `list_resource_templates()`, `read_resource()`를 제공하며, `MCPServerStreamableHttp`에서 `session_id`를 제공하므로 재연결 또는 무상태 워커 간에 스트리밍 가능한 HTTP 세션을 재개할 수 있습니다. +- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`를 통해 추론 콘텐츠 재생을 선택적으로 활성화할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 공급자별 추론 및 도구 호출 연속성이 개선됩니다. +- `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, 함수 도구 배치 실행기의 경합 상태 등 여러 런타임 및 세션 엣지 케이스를 수정했습니다. ### 0.12.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. +이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. ### 0.11.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. +이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. ### 0.10.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, OpenAI Responses 사용자를 위한 중요한 신규 기능 영역인 Responses API의 WebSocket 전송 지원이 포함되어 있습니다. +이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함되어 있습니다. 주요 내용: -- OpenAI Responses 모델에 대한 WebSocket 전송 지원이 추가되었습니다(선택적 활성화 방식이며 HTTP가 계속 기본 전송 방식입니다). -- 멀티턴 실행에서 WebSocket을 지원하는 공유 공급자와 `RunConfig`를 재사용할 수 있도록 `responses_websocket_session()` 헬퍼 / `ResponsesWebSocketSession`이 추가되었습니다. -- 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 코드 예제(`examples/basic/stream_ws.py`)가 추가되었습니다. +- OpenAI Responses 모델에 대한 WebSocket 전송 지원을 추가했습니다. 이 기능은 선택적으로 활성화하며 HTTP가 기본 전송 방식으로 유지됩니다. +- 멀티턴 실행 간에 WebSocket을 지원하는 공유 공급자와 `RunConfig`를 재사용할 수 있도록 `responses_websocket_session()` 도우미/`ResponsesWebSocketSession`을 추가했습니다. +- 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 예제(`examples/basic/stream_ws.py`)를 추가했습니다. ### 0.9.0 -이 버전부터 Python 3.9는 더 이상 지원되지 않습니다. 해당 메이저 버전은 3개월 전에 EOL에 도달했습니다. 더 새로운 런타임 버전으로 업그레이드하세요. +이 버전에서는 주요 버전이 3개월 전에 지원 종료(EOL)에 도달함에 따라 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. -또한 `Agent#as_tool()` 메서드에서 반환되는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁혀졌습니다. 이 변경으로 일반적으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니온 타입에 의존하는 경우 일부 조정이 필요할 수 있습니다. +또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁혀졌습니다. 이 변경으로 일반적으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니언 타입에 의존한다면 일부 조정이 필요할 수 있습니다. ### 0.8.0 -이 버전에서는 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. +이 버전에서는 다음 두 가지 런타임 동작 변경 사항으로 인해 마이그레이션 작업이 필요할 수 있습니다. -- **동기식** Python 호출 가능 객체를 래핑하는 함수 도구는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`를 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태나 스레드 종속 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시하세요. -- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작에서는 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 빠른 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로, 명시적 핸들러가 있는 각 로컬 MCP 서버에 `failure_error_function=None`을 설정하세요. +- **동기식** Python callable을 래핑하는 함수 도구는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`를 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태나 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시하세요. +- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 노출되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로, 명시적 핸들러가 있는 각 로컬 MCP 서버에서 `failure_error_function=None`을 설정하세요. ### 0.7.0 이 버전에는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경 사항이 있습니다. -- 이제 중첩 핸드오프 기록은 **선택적 활성화** 방식입니다(기본적으로 비활성화됨). v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`를 명시적으로 설정하세요. -- `gpt-5.1` / `gpt-5.2`의 기본 `reasoning.effort`가 SDK 기본값으로 구성되던 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트나 품질/비용 특성이 `"low"`에 의존했다면 `model_settings`에서 명시적으로 설정하세요. +- 이제 중첩 핸드오프 기록은 **선택적 활성화** 방식이며 기본적으로 비활성화됩니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`를 명시적으로 설정하세요. +- `gpt-5.1`/`gpt-5.2`의 기본 `reasoning.effort`가 SDK 기본값으로 구성된 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트 또는 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 명시적으로 설정하세요. ### 0.6.0 -이 버전부터 기본 핸드오프 기록은 원문 사용자/어시스턴트 턴을 노출하는 대신 하나의 어시스턴트 메시지로 패키징되어 후속 에이전트에 간결하고 예측 가능한 요약을 제공합니다 -- 이제 기존의 단일 메시지 핸드오프 대화 내용은 기본적으로 `` 블록 앞에 "For context, here is the conversation so far between the user and the previous agent:"로 시작하므로 후속 에이전트가 명확하게 표시된 요약을 받습니다 +이 버전에서는 원문 사용자/어시스턴트 턴을 노출하는 대신 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여 다운스트림 에이전트에 간결하고 예측 가능한 요약을 제공합니다. +- 이제 기존의 단일 메시지 핸드오프 대화 기록은 기본적으로 `` 블록 앞에 "참고를 위해 사용자와 이전 에이전트 간의 지금까지 대화 내용을 제공합니다:"라는 문구로 시작하므로, 다운스트림 에이전트가 명확히 표시된 요약을 받습니다. ### 0.5.0 -이 버전에는 외부에 드러나는 호환성을 깨는 변경 사항이 없지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되어 있습니다. +이 버전에는 눈에 띄는 호환성을 깨뜨리는 변경 사항이 없지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되어 있습니다. -- [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리할 수 있도록 `RealtimeRunner` 지원 추가 -- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직을 대폭 수정 +- `RealtimeRunner`가 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리할 수 있도록 지원을 추가했습니다. +- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직을 대폭 수정했습니다. ### 0.4.0 -이 버전부터 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전은 더 이상 지원되지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. +이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전을 더 이상 지원하지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. ### 0.3.0 -이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 전환됩니다. +이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. ### 0.2.0 -이 버전에서는 이전에 `Agent`를 인수로 받던 몇몇 위치에서 이제 `AgentBase`를 인수로 받습니다. MCP 서버의 `list_tools()` 호출이 그 예입니다. 이는 타입 지정만 변경된 것이며, 계속해서 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`를 `AgentBase`로 바꿔 타입 오류를 수정하면 됩니다. +이 버전에서는 이전에 `Agent`를 인수로 받던 일부 위치가 이제 대신 `AgentBase`를 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 호출이 이에 해당합니다. 이는 순수한 타입 변경이며, 여전히 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`를 `AgentBase`로 교체하여 타입 오류를 수정하기만 하면 됩니다. ### 0.1.0 -이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context`와 `agent`라는 두 개의 새로운 매개변수가 추가되었습니다. `MCPServer`를 상속하는 모든 클래스에 이 매개변수를 추가해야 합니다. \ No newline at end of file +이 버전에서는 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에 `run_context`와 `agent`라는 두 개의 새로운 매개변수가 추가되었습니다. `MCPServer`를 서브클래싱하는 모든 클래스에 이 매개변수를 추가해야 합니다. diff --git a/docs/ko/results.md b/docs/ko/results.md index 583abd54d6..1d51706ab8 100644 --- a/docs/ko/results.md +++ b/docs/ko/results.md @@ -4,95 +4,124 @@ search: --- # 결과 -`Runner.run` 메서드를 호출하면 두 가지 결과 타입 중 하나를 받습니다. +`Runner.run` 메서드를 호출하면 다음 두 결과 타입 중 하나를 받습니다. -- `Runner.run(...)` 또는 `Runner.run_sync(...)`의 [`RunResult`][agents.result.RunResult] -- `Runner.run_streamed(...)`의 [`RunResultStreaming`][agents.result.RunResultStreaming] +- `Runner.run(...)` 또는 `Runner.run_sync(...)`에서 [`RunResult`][agents.result.RunResult] +- `Runner.run_streamed(...)`에서 [`RunResultStreaming`][agents.result.RunResultStreaming] -둘 다 [`RunResultBase`][agents.result.RunResultBase]를 상속하며, `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()`와 같은 공통 결과 접근 지점을 제공합니다. +둘 다 [`RunResultBase`][agents.result.RunResultBase]를 상속하며, `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()`와 같은 공통 결과 인터페이스를 제공합니다. -`RunResultStreaming`은 [`stream_events()`][agents.result.RunResultStreaming.stream_events], [`current_agent`][agents.result.RunResultStreaming.current_agent], [`is_complete`][agents.result.RunResultStreaming.is_complete], [`cancel(...)`][agents.result.RunResultStreaming.cancel] 같은 스트리밍 전용 제어 기능을 추가합니다. +`RunResultStreaming`에는 [`stream_events()`][agents.result.RunResultStreaming.stream_events], [`current_agent`][agents.result.RunResultStreaming.current_agent], [`is_complete`][agents.result.RunResultStreaming.is_complete], [`cancel(...)`][agents.result.RunResultStreaming.cancel]과 같은 스트리밍 전용 제어 기능이 추가됩니다. -## 적절한 결과 접근 지점 선택 +## 적절한 결과 인터페이스 선택 대부분의 애플리케이션에는 몇 가지 결과 속성이나 헬퍼만 필요합니다. -| 필요한 항목 | 사용 | +| 필요한 항목 | 사용 대상 | | --- | --- | -| 사용자에게 보여줄 최종 답변 | `final_output` | -| 전체 로컬 대화 기록이 포함된, 재생 가능한 다음 턴 입력 목록 | `to_input_list()` | -| 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 실행 항목 | `new_items` | +| 사용자에게 표시할 최종 답변 | `final_output` | +| 전체 로컬 대화 기록이 포함되어 다음 턴 재실행에 바로 사용할 수 있는 입력 목록 | `to_input_list()` | +| 에이전트, 도구, 핸드오프 및 승인 메타데이터가 포함된 상세 실행 항목 | `new_items` | | 일반적으로 다음 사용자 턴을 처리해야 하는 에이전트 | `last_agent` | -| `previous_response_id`를 사용한 OpenAI Responses API 체이닝 | `last_response_id` | +| `previous_response_id`를 사용하는 OpenAI Responses API 체인 연결 | `last_response_id` | | 대기 중인 승인 및 재개 가능한 스냅샷 | `interruptions` 및 `to_state()` | -| 현재 중첩 `Agent.as_tool()` 호출에 대한 메타데이터 | `agent_tool_invocation` | +| 현재 중첩된 `Agent.as_tool()` 호출에 관한 메타데이터 | `agent_tool_invocation` | | 원문 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | ## 최종 출력 -[`final_output`][agents.result.RunResultBase.final_output] 속성에는 마지막으로 실행된 에이전트의 최종 출력이 포함됩니다. 이는 다음 중 하나입니다. +[`final_output`][agents.result.RunResultBase.final_output] 속성에는 마지막으로 실행된 에이전트의 최종 출력이 포함됩니다. 다음 중 하나입니다. -- 마지막 에이전트에 `output_type`이 정의되어 있지 않았다면 `str` -- 마지막 에이전트에 출력 타입이 정의되어 있었다면 `last_agent.output_type` 타입의 객체 -- 예를 들어 승인 인터럽션(중단 처리)에서 일시 중지되어 최종 출력이 생성되기 전에 실행이 중단된 경우 `None` +- 마지막 에이전트에 `output_type`이 정의되지 않은 경우 `str` +- 마지막 에이전트에 출력 타입이 정의된 경우 `last_agent.output_type` 타입의 객체 +- 승인 인터럽션(중단 처리)으로 일시 중지된 경우처럼 최종 출력이 생성되기 전에 실행이 중단된 경우 `None` !!! note - `final_output`은 `Any` 타입으로 지정되어 있습니다. 핸드오프는 어떤 에이전트가 실행을 완료할지 바꿀 수 있으므로, SDK는 가능한 출력 타입의 전체 집합을 정적으로 알 수 없습니다. + `final_output`의 타입은 `Any`입니다. 핸드오프에 따라 실행을 완료하는 에이전트가 달라질 수 있으므로 SDK는 가능한 모든 출력 타입을 정적으로 알 수 없습니다. -스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`이 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참고하세요. +스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`이 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참조하세요. ## 입력, 다음 턴 기록 및 새 항목 -이 접근 지점들은 서로 다른 질문에 답합니다. +이 인터페이스들은 서로 다른 질문에 답합니다. | 속성 또는 헬퍼 | 포함 내용 | 적합한 용도 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성한 경우, 실행이 이어서 사용한 필터링된 입력을 반영합니다. | 이 실행이 실제로 입력으로 사용한 내용 감사 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행의 입력 항목 뷰입니다. 기본 `mode="preserve_all"`은 `new_items`에서 변환된 전체 기록을 유지합니다. `mode="normalized"`는 핸드오프 필터링이 모델 기록을 다시 작성할 때 표준 이어가기 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리 대화 상태, 일반 항목 기록 검사 | -| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사, 디버깅 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 나온 원문 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 프로바이더 수준 진단 또는 원문 응답 검사 | +| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성한 경우, 실행이 계속될 때 사용한 필터링된 입력이 반영됩니다. | 이 실행에서 실제로 사용한 입력 감사 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행을 입력 항목 형태로 보여 줍니다. 기본 `mode="preserve_all"`은 `new_items`에서 변환된 기록을 유지하지만, SDK 기본 중첩 핸드오프 기록으로 이미 이동된 동일한 세션 항목 인스턴스는 두 번째로 추가하지 않습니다. `mode="normalized"`는 핸드오프 필터링이 모델 기록을 다시 작성할 때 표준 연속 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리형 대화 상태 및 일반 항목 기록 검사 | +| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프 및 승인 메타데이터가 포함된 상세 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사 및 디버깅 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 얻은 원문 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 제공자 수준 진단 또는 원문 응답 검사 | 실제로는 다음과 같이 사용합니다. -- 실행의 일반 입력 항목 뷰가 필요할 때는 `to_input_list()`를 사용합니다. -- 핸드오프 필터링 또는 중첩 핸드오프 기록 재작성 이후 다음 `Runner.run(..., input=...)` 호출에 사용할 표준 로컬 입력이 필요할 때는 `to_input_list(mode="normalized")`를 사용합니다. -- SDK가 기록을 로드하고 저장해 주기를 원할 때는 [`session=...`](sessions/index.md)을 사용합니다. -- `conversation_id` 또는 `previous_response_id`와 함께 OpenAI 서버 관리 상태를 사용하는 경우, 보통 `to_input_list()`를 다시 보내는 대신 새 사용자 입력만 전달하고 저장된 ID를 재사용합니다. -- 로그, UI, 감사에 사용할 변환된 전체 기록이 필요할 때는 기본 `to_input_list()` 모드 또는 `new_items`를 사용합니다. +- 실행을 일반 입력 항목 형태로 확인하려면 `to_input_list()`를 사용합니다. +- 핸드오프 필터링이나 중첩 핸드오프 기록 재작성 후 다음 `Runner.run(..., input=...)` 호출에 사용할 표준 로컬 입력이 필요하면 `to_input_list(mode="normalized")`를 사용합니다. +- SDK가 기록을 로드하고 저장하도록 하려면 [`session=...`](sessions/index.md)을 사용합니다. +- `conversation_id` 또는 `previous_response_id`를 사용하여 OpenAI 서버 관리형 상태를 이용하는 경우에는 일반적으로 `to_input_list()`를 다시 보내는 대신 새 사용자 입력만 전달하고 저장된 ID를 재사용합니다. +- 로그, UI 또는 감사에 필요한 전체 변환 기록이 필요하면 기본 `to_input_list()` 모드 또는 `new_items`를 사용합니다. -JavaScript SDK와 달리 Python은 모델 형태의 델타만을 위한 별도의 `output` 속성을 노출하지 않습니다. SDK 메타데이터가 필요할 때는 `new_items`를 사용하고, 원문 모델 페이로드가 필요할 때는 `raw_responses`를 검사하세요. +SDK 기본 중첩 핸드오프 기록이 메시지 항목을 그대로 보존하는 경우, 세션, `RunState`, `to_input_list()`는 콘텐츠를 기준으로 중복을 제거하는 대신 정확히 소유된 항목 인스턴스를 추적합니다. 별도로 발생한 동일한 메시지는 별도 항목으로 유지되며, 이미 소유된 항목 인스턴스만 두 번째로 추가되지 않습니다. -컴퓨터 도구 재생은 원문 Responses 페이로드 형태를 따릅니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 보존하고, `gpt-5.5` 컴퓨터 호출은 배치된 `actions[]`를 보존할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list]와 [`RunState`][agents.run_state.RunState]는 모델이 생성한 형태를 그대로 유지하므로, 수동 재생, 일시 중지/재개 흐름, 저장된 대화 기록이 프리뷰 및 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 여전히 `new_items`의 `computer_call_output` 항목으로 나타납니다. +JavaScript SDK와 달리 Python은 모델 형식의 델타만을 위한 별도의 `output` 속성을 제공하지 않습니다. SDK 메타데이터가 필요하면 `new_items`를 사용하고, 원문 모델 페이로드가 필요하면 `raw_responses`를 검사하세요. + +컴퓨터 도구 재실행은 원문 Responses 페이로드 형식을 따릅니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 유지하는 반면, `gpt-5.5` 컴퓨터 호출은 배치된 `actions[]`를 유지할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list]와 [`RunState`][agents.run_state.RunState]는 모델이 생성한 형식을 그대로 유지하므로 수동 재실행, 일시 중지/재개 흐름 및 저장된 대화 기록이 프리뷰와 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 계속해서 `new_items`에 `computer_call_output` 항목으로 표시됩니다. ### 새 항목 -[`new_items`][agents.result.RunResultBase.new_items]는 실행 중 발생한 일을 가장 풍부하게 보여줍니다. 일반적인 항목 타입은 다음과 같습니다. +[`new_items`][agents.result.RunResultBase.new_items]는 실행 중 발생한 일을 가장 상세하게 보여 줍니다. 일반적인 항목 타입은 다음과 같습니다. - 어시스턴트 메시지용 [`MessageOutputItem`][agents.items.MessageOutputItem] - 추론 항목용 [`ReasoningItem`][agents.items.ReasoningItem] -- Responses 도구 검색 요청 및 로드된 도구 검색 결과용 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 및 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- 도구 호출 및 그 결과용 [`ToolCallItem`][agents.items.ToolCallItem] 및 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- Responses 도구 검색 요청 및 로드된 도구 검색 결과용 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem]과 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- 도구 호출 및 그 결과용 [`ToolCallItem`][agents.items.ToolCallItem]과 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] - 승인을 위해 일시 중지된 도구 호출용 [`ToolApprovalItem`][agents.items.ToolApprovalItem] -- 핸드오프 요청 및 완료된 전달용 [`HandoffCallItem`][agents.items.HandoffCallItem] 및 [`HandoffOutputItem`][agents.items.HandoffOutputItem] +- 호스티드 MCP 승인 및 도구 카탈로그용 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem], [`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem], [`MCPListToolsItem`][agents.items.MCPListToolsItem] +- 핸드오프 요청 및 완료된 전달용 [`HandoffCallItem`][agents.items.HandoffCallItem]과 [`HandoffOutputItem`][agents.items.HandoffOutputItem] + +에이전트 연결 정보, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 `to_input_list()` 대신 `new_items`를 선택하세요. + +호스티드 도구 검색을 사용하는 경우, 모델이 생성한 검색 요청을 확인하려면 `ToolSearchCallItem.raw_item`을 검사하고 해당 턴에 로드된 네임스페이스, 함수 또는 호스티드 MCP 서버를 확인하려면 `ToolSearchOutputItem.raw_item`을 검사하세요. -에이전트 연결, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 항상 `to_input_list()`보다 `new_items`를 선택하세요. +Programmatic Tool Calling을 사용하면 생성된 `program`은 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출도 `ToolCallItem` 항목이며, 이에 대응하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외로, 각각 `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. + +원문 항목은 타입이 지정된 Responses 객체이거나 매핑일 수 있습니다. 특히 프로그램 소유의 셸 및 패치 적용 호출은 매핑을 사용합니다. 매핑에도 안전한 검사 패턴을 사용하세요. + +```python +from collections.abc import Mapping + + +def raw_field(item, name): + raw_item = item.raw_item + if isinstance(raw_item, Mapping): + return raw_item.get(name) + return getattr(raw_item, name, None) + + +raw_type = raw_field(item, "type") +caller = raw_field(item, "caller") +caller_id = ( + caller.get("caller_id") + if isinstance(caller, Mapping) + else getattr(caller, "caller_id", None) +) +``` -호스티드 툴 검색을 사용할 때는 모델이 내보낸 검색 요청을 보려면 `ToolSearchCallItem.raw_item`을 검사하고, 해당 턴에 어떤 네임스페이스, 함수 또는 호스티드 MCP 서버가 로드되었는지 보려면 `ToolSearchOutputItem.raw_item`을 검사하세요. +프로그램 소유의 하위 호출에서 `caller`의 타입은 `program`이며, `caller_id`는 상위 프로그램 호출을 식별합니다. ## 대화 계속 또는 재개 ### 다음 턴 에이전트 -[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 이는 핸드오프 후 다음 사용자 턴에 재사용하기 가장 좋은 에이전트인 경우가 많습니다. +[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 핸드오프 후 다음 사용자 턴에 재사용하기에 가장 적합한 에이전트인 경우가 많습니다. -스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로, 스트림이 끝나기 전에 핸드오프를 관찰할 수 있습니다. +스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로 스트림이 완료되기 전에 핸드오프를 관찰할 수 있습니다. ### 인터럽션(중단 처리) 및 실행 상태 -도구에 승인이 필요한 경우, 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. +도구에 승인이 필요한 경우, 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 호출된 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. -[`to_state()`][agents.result.RunResult.to_state]를 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]를 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`로 재개하세요. +[`to_state()`][agents.result.RunResult.to_state]를 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]를 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`으로 재개합니다. ```python from agents import Agent, Runner @@ -107,59 +136,59 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -스트리밍 실행의 경우 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 소비를 완료한 다음, `result.interruptions`를 검사하고 `result.to_state()`에서 재개하세요. 전체 승인 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)를 참고하세요. +스트리밍 실행에서는 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events]를 끝까지 소비한 다음 `result.interruptions`를 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)를 참조하세요. -### 서버 관리 지속 +### 서버 관리형 연속 실행 -[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 나온 최신 모델 응답 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이를 `previous_response_id`로 다시 전달하세요. +[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 얻은 최신 모델 응답 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이를 `previous_response_id`로 다시 전달합니다. -이미 `to_input_list()`, `session` 또는 `conversation_id`로 대화를 계속하고 있다면 보통 `last_response_id`가 필요하지 않습니다. 다단계 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`를 검사하세요. +이미 `to_input_list()`, `session` 또는 `conversation_id`를 사용하여 대화를 계속하고 있다면 일반적으로 `last_response_id`가 필요하지 않습니다. 여러 단계 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`를 검사하세요. -## Agent-as-tool 메타데이터 +## 도구로 사용되는 에이전트의 메타데이터 -결과가 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 나온 경우, [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 외부 도구 호출에 대한 불변 메타데이터를 노출합니다. +중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 나온 경우, [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 외부 도구 호출에 관한 변경 불가능한 메타데이터를 제공합니다. - `tool_name` - `tool_call_id` - `tool_arguments` -일반적인 최상위 실행에서는 `agent_tool_invocation`이 `None`입니다. +일반적인 최상위 실행에서 `agent_tool_invocation`은 `None`입니다. -이는 특히 `custom_output_extractor` 내부에서 유용합니다. 중첩 결과를 후처리하는 동안 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 수 있기 때문입니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참고하세요. +이는 중첩된 결과를 후처리하면서 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 수 있는 `custom_output_extractor` 내부에서 특히 유용합니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참조하세요. -해당 중첩 실행의 파싱된 구조화 입력도 필요하다면 `context_wrapper.tool_input`을 읽으세요. 이는 [`RunState`][agents.run_state.RunState]가 중첩 도구 입력을 위해 일반화하여 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출에 대한 라이브 결과 접근자입니다. +해당 중첩 실행에 대해 파싱된 구조화 입력도 필요하면 `context_wrapper.tool_input`을 읽으세요. 이는 [`RunState`][agents.run_state.RunState]가 중첩 도구 입력을 범용 방식으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출을 위한 실시간 결과 접근자입니다. ## 스트리밍 수명 주기 및 진단 -[`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 접근 지점을 상속하지만, 스트리밍 전용 제어 기능을 추가합니다. +[`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 인터페이스를 상속하면서 다음과 같은 스트리밍 전용 제어 기능을 추가합니다. - 의미론적 스트림 이벤트를 소비하는 [`stream_events()`][agents.result.RunResultStreaming.stream_events] - 실행 중 활성 에이전트를 추적하는 [`current_agent`][agents.result.RunResultStreaming.current_agent] -- 스트리밍 실행이 완전히 끝났는지 확인하는 [`is_complete`][agents.result.RunResultStreaming.is_complete] +- 스트리밍 실행이 완전히 완료되었는지 확인하는 [`is_complete`][agents.result.RunResultStreaming.is_complete] - 실행을 즉시 또는 현재 턴 이후에 중지하는 [`cancel(...)`][agents.result.RunResultStreaming.cancel] -비동기 이터레이터가 끝날 때까지 `stream_events()`를 계속 소비하세요. 해당 이터레이터가 종료되기 전까지 스트리밍 실행은 완료된 것이 아니며, `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 영속화 부수 효과는 마지막으로 보이는 토큰이 도착한 뒤에도 아직 확정되는 중일 수 있습니다. +비동기 이터레이터가 끝날 때까지 `stream_events()`를 계속 소비하세요. 이 이터레이터가 종료되기 전에는 스트리밍 실행이 완료된 것이 아니며, 마지막으로 표시되는 토큰이 도착한 후에도 `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 영속화 부수 효과가 아직 처리 중일 수 있습니다. -`cancel()`을 호출한 경우에도 취소와 정리가 올바르게 완료될 수 있도록 `stream_events()`를 계속 소비하세요. +`cancel()`을 호출한 경우에도 취소 및 정리가 올바르게 완료될 수 있도록 `stream_events()`를 계속 소비하세요. -Python은 별도의 스트리밍 `completed` 프라미스나 `error` 속성을 노출하지 않습니다. 최종 스트리밍 실패는 `stream_events()`에서 예외가 발생하는 방식으로 표면화되며, `is_complete`는 실행이 종료 상태에 도달했는지 여부를 반영합니다. +Python은 스트리밍용으로 별도의 `completed` 프로미스나 `error` 속성을 제공하지 않습니다. 스트리밍을 종료시키는 오류는 `stream_events()`에서 예외를 발생시키는 방식으로 노출되며, `is_complete`는 실행이 종료 상태에 도달했는지를 나타냅니다. ### 원문 응답 -[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 원문 모델 응답이 포함됩니다. 다단계 실행은 예를 들어 핸드오프 또는 반복되는 모델/도구/모델 사이클을 거치며 둘 이상의 응답을 생성할 수 있습니다. +[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 원문 모델 응답이 포함됩니다. 여러 단계 실행에서는 핸드오프나 반복되는 모델/도구/모델 주기 등으로 인해 둘 이상의 응답이 생성될 수 있습니다. -[`last_response_id`][agents.result.RunResultBase.last_response_id]는 `raw_responses`의 마지막 항목에서 가져온 ID일 뿐입니다. +[`last_response_id`][agents.result.RunResultBase.last_response_id]는 `raw_responses`의 마지막 항목에 있는 ID일 뿐입니다. ### 가드레일 결과 에이전트 수준 가드레일은 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 및 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results]로 노출됩니다. -도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도로 노출됩니다. +도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도 노출됩니다. -이 배열들은 실행 전반에 걸쳐 누적되므로, 결정 사항을 기록하거나 추가 가드레일 메타데이터를 저장하거나 실행이 차단된 이유를 디버깅하는 데 유용합니다. +이 배열들은 실행 전반에 걸쳐 누적되므로 판단 기록, 추가 가드레일 메타데이터 저장 또는 실행이 차단된 이유를 디버깅하는 데 유용합니다. ### 컨텍스트 및 사용량 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper]는 승인, 사용량, 중첩 `tool_input` 같은 SDK 관리 런타임 메타데이터와 함께 앱 컨텍스트를 노출합니다. +[`context_wrapper`][agents.result.RunResultBase.context_wrapper]는 승인, 사용량, 중첩된 `tool_input`과 같은 SDK 관리형 런타임 메타데이터와 함께 앱 컨텍스트를 제공합니다. -사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행의 경우 스트림의 최종 청크가 처리될 때까지 사용량 합계가 지연될 수 있습니다. 전체 래퍼 형태와 지속성 관련 주의 사항은 [컨텍스트 관리](context.md)를 참고하세요. \ No newline at end of file +사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행에서는 스트림의 마지막 청크가 처리될 때까지 사용량 합계 반영이 늦어질 수 있습니다. 전체 래퍼 구조 및 영속성 관련 주의 사항은 [컨텍스트 관리](context.md)를 참조하세요. \ No newline at end of file diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index 076f47d6b4..13572fac0b 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -6,9 +6,9 @@ search: [`Runner`][agents.run.Runner] 클래스를 통해 에이전트를 실행할 수 있습니다. 다음 3가지 옵션이 있습니다. -1. [`Runner.run()`][agents.run.Runner.run]: 비동기 방식으로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. +1. [`Runner.run()`][agents.run.Runner.run]: 비동기적으로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. 2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 메서드이며 내부적으로 `.run()`을 실행합니다. -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기 방식으로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. 스트리밍 모드로 LLM을 호출하고, 이벤트가 수신되는 즉시 스트리밍합니다. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기적으로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하며, 이벤트를 수신하는 즉시 스트리밍합니다. ```python from agents import Agent, Runner @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -자세한 내용은 [결과 가이드](results.md)를 참고하세요. +자세한 내용은 [결과 가이드](results.md)를 참조하세요. ## Runner 수명 주기 및 구성 ### 에이전트 루프 -`Runner`의 실행 메서드를 사용할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. +`Runner`에서 실행 메서드를 사용할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. - 문자열(사용자 메시지로 처리) - OpenAI Responses API 형식의 입력 항목 목록 -- 인터럽션(중단 처리)된 실행을 재개할 때의 [`RunState`][agents.run_state.RunState] +- 인터럽션(중단 처리)된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState] -그런 다음 Runner가 다음 루프를 실행합니다. +그런 다음 Runner는 다음과 같이 루프를 실행합니다. 1. 현재 입력을 사용하여 현재 에이전트의 LLM을 호출합니다. 2. LLM이 출력을 생성합니다. 1. LLM이 `final_output`을 반환하면 루프를 종료하고 결과를 반환합니다. - 2. LLM이 핸드오프를 수행하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. + 2. LLM이 핸드오프를 수행하면 현재 에이전트와 입력을 업데이트한 후 루프를 다시 실행합니다. 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. 3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 기준은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. + LLM 출력이 "최종 출력"으로 간주되는 기준은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 경우입니다. ### 스트리밍 -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 비롯한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트를 받으려면 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참고하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 포함하여 실행에 대한 전체 정보가 담깁니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송(선택적 도우미) +#### Responses WebSocket 전송(선택적 헬퍼) -OpenAI Responses WebSocket 전송을 활성화해도 기존 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 WebSocket 세션 도우미를 사용하는 것이 권장되지만 필수는 아닙니다. +OpenAI Responses 웹소켓 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 웹소켓 세션 헬퍼를 사용하는 것이 권장되지만 필수는 아닙니다. -이는 WebSocket 전송을 통한 Responses API이며, [Realtime API](realtime/guide.md)가 아닙니다. +이는 웹소켓 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. -전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 제공자에 관한 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참고하세요. +전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 공급자 관련 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 도우미 미사용 +##### 패턴 1: 세션 헬퍼 미사용(지원됨) -WebSocket 전송만 필요하고 SDK가 공유 제공자나 세션을 관리할 필요가 없을 때 사용합니다. +웹소켓 전송만 사용하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`를 반복적으로 호출하면 동일한 `RunConfig` / 제공자 인스턴스를 직접 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. +이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. -##### 패턴 2: `responses_websocket_session()` 사용(다중 턴 재사용에 권장) +##### 패턴 2: `responses_websocket_session()` 사용(여러 턴에서 재사용 시 권장) -동일한 `run_config`를 상속하는 중첩된 에이전트 도구 호출을 포함하여 여러 실행에서 WebSocket을 지원하는 공유 제공자와 `RunConfig`를 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. +여러 실행에서 웹소켓을 지원하는 공유 공급자와 `RunConfig`를 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 도구로서의 에이전트 호출도 포함됩니다. ```python import asyncio @@ -119,56 +119,56 @@ async def main(): asyncio.run(main()) ``` -컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 소비하세요. WebSocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. +컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 사용해야 합니다. 웹소켓 요청이 아직 진행 중인 상태에서 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. -긴 추론 턴에서 WebSocket 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. WebSocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. +긴 추론 턴에서 웹소켓 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. 웹소켓 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. ### 실행 구성 -`run_config` 매개변수를 사용하면 에이전트 실행의 일부 전역 설정을 구성할 수 있습니다. +`run_config` 매개변수를 사용하면 에이전트 실행에 대한 일부 전역 설정을 구성할 수 있습니다. #### 일반적인 실행 구성 카테고리 각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`를 사용하세요. -##### 모델, 제공자 및 세션 기본값 +##### 모델, 공급자 및 세션 기본값 -- [`model`][agents.run.RunConfig.model]: 각 Agent에 설정된 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. -- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 제공자이며 기본값은 OpenAI입니다. +- [`model`][agents.run.RunConfig.model]: 각 에이전트의 `model` 설정과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. +- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며, 기본값은 OpenAI입니다. - [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`를 설정할 수 있습니다. - [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 턴 전에 새로운 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 턴 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. ##### 가드레일, 핸드오프 및 모델 입력 구성 - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 입력 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 수정할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참고하세요. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 이전 트랜스크립트를 단일 어시스턴트 메시지로 축약하는 선택적 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화되어 있습니다. 활성화하려면 `True`로 설정하고, 원문 트랜스크립트를 그대로 전달하려면 `False`로 두세요. [Runner 메서드][agents.run.Runner]는 `RunConfig`를 전달하지 않으면 자동으로 생성하므로 빠른 시작과 예제에서는 기본적으로 비활성화된 상태를 유지하며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속해서 이 설정보다 우선합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 선택할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 다음 에이전트로 전달할 정확한 입력 항목 목록을 반환해야 하므로, 전체 핸드오프 필터를 작성하지 않고도 기본 제공 요약을 대체할 수 있습니다. -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 수정하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 필터가 이미 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 무손실 메시지 항목은 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 선택적 베타 기능입니다. 중첩 핸드오프의 안정화가 진행되는 동안에는 기본적으로 비활성화됩니다. 활성화하려면 `True`로 설정하고 원문 트랜스크립트를 그대로 전달하려면 `False`로 유지하세요. Sessions, `RunState`, `RunResult.to_input_list()`는 SDK 기본 중첩 기록이 이미 소유한 동일한 메시지 인스턴스를 두 번 추가하지 않으면서 별개의 동일한 메시지는 유지합니다. [Runner 메서드][agents.run.Runner]는 `RunConfig`를 전달하지 않으면 모두 자동으로 생성하므로 빠른 시작과 코드 예제에서는 기본적으로 이 기능이 비활성화되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 활성화할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고 기본 제공 순차 요약 세그먼트를 대체하려면 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델을 호출하기 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 주입할 수 있습니다. - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 유지할지 생략할지 제어합니다. ##### 트레이싱 및 관측 가능성 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에서 [트레이싱](tracing.md)을 비활성화할 수 있습니다. - [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]를 전달합니다. -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출 입력/출력과 같이 잠재적으로 민감한 데이터를 포함할지 구성합니다. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: LLM 및 도구 호출의 입력/출력과 같이 잠재적으로 민감한 데이터를 트레이스에 포함할지 구성합니다. - [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`은 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. ##### 도구 실행, 승인 및 도구 오류 동작 -- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행되는 함수 도구 수를 제한하는 등 로컬 도구 호출의 SDK 측 실행 동작을 구성합니다. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출을 확인할 수 없을 때 Runner가 이를 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`가 발생하며, 대신 모델에 표시되는 오류 출력을 반환하도록 선택할 수 있습니다. -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 선택적 도구 미발견 출력과 같이 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행되는 함수 도구 수 제한과 같이 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성했지만 해결할 수 없는 함수 도구 호출을 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`가 발생하며, 대신 모델에 표시되는 오류 출력을 반환하도록 선택할 수 있습니다. +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 선택적으로 활성화한 도구를 찾을 수 없음 출력과 같이 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. -중첩된 핸드오프는 선택적 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`를 전달하거나 특정 핸드오프에서 `handoff(..., nest_handoff_history=True)`를 설정하여 축약된 트랜스크립트 동작을 활성화하세요. 원문 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 대화를 필요한 방식 그대로 전달하는 `handoff_input_filter` 또는 `handoff_history_mapper`를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 기본값으로 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]를 호출합니다. +중첩 핸드오프는 선택적 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`를 전달하여 순차 트랜스크립트 압축을 활성화하거나, 특정 핸드오프에서 활성화하려면 `handoff(..., nest_handoff_history=True)`를 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하지 않고 생성된 어시스턴트 요약 세그먼트를 무손실 메시지 항목 주위에 배치합니다. 원문 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 필요한 방식으로 대화를 정확히 전달하는 `handoff_input_filter` 또는 `handoff_history_mapper`를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에서 사용하는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 기본값으로 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]를 호출하세요. #### 실행 구성 세부 정보 ##### `tool_execution` -로컬 함수 도구의 동시 실행 수 제한과 같이 단일 실행에서 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. +실행 중 로컬 함수 도구의 동시 실행 수 제한과 같이 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -187,17 +187,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에 여러 함수 도구 호출을 생성하면 SDK가 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 수를 제한하려면 정수 값을 설정하세요. +`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행할 수 있는 로컬 함수 도구 수를 제한하려면 정숫값을 설정하세요. -이는 제공자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지를 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. +이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지를 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 로컬 함수 도구 호출을 생성한 후 SDK가 이를 실행하는 방식을 제어합니다. -`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요한 경우 실행이 먼저 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 생성되기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 동일한 입력 가드레일을 다시 실행하므로, 시간에 민감한 검사가 실행 전에 다시 검증됩니다. +`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 먼저 실행이 일시 중지되고, 승인이 완료된 후 실행 직전에만 도구 입력 가드레일이 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출에도 승인 후 동일한 입력 가드레일이 다시 실행되므로, 시간에 민감한 검사가 실행 전에 다시 검증됩니다. ##### `tool_not_found_behavior` -기본적으로 모델이 현재 에이전트에서 사용할 수 있는 함수 도구와 일치하지 않는 함수 도구 호출을 생성하면 Runner가 `ModelBehaviorError`를 발생시킵니다. +기본적으로 모델이 현재 에이전트에서 사용 가능한 함수 도구와 일치하지 않는 함수 도구 호출을 생성하면 Runner에서 `ModelBehaviorError`가 발생합니다. -실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`로 설정하세요. 이 모드에서는 SDK가 확인할 수 없는 도구 호출에 대한 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구 없이 응답할 수 있습니다. +실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서 SDK는 해결되지 않은 도구 호출에 대한 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. ```python from agents import Agent, RunConfig, Runner @@ -211,19 +211,19 @@ result = await Runner.run( ) ``` -현재 이 옵션은 확인할 수 없는 함수 도구 호출에만 적용됩니다. 그 밖의 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 해결되지 않은 함수 도구 호출에만 적용됩니다. 그 밖의 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. ##### `tool_error_formatter` SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`를 사용하세요. -포매터는 다음 필드가 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. +포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. - `kind`: `"approval_rejected"` 또는 `"tool_not_found"`과 같은 오류 카테고리 - `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`) - `tool_name`: 도구 이름 - `call_id`: 도구 호출 ID -- `default_message`: 모델에 표시되는 SDK의 기본 메시지 +- `default_message`: 모델에 표시되는 SDK 기본 메시지 - `run_context`: 활성 실행 컨텍스트 래퍼 메시지를 대체하려면 문자열을 반환하고, SDK 기본값을 사용하려면 `None`을 반환하세요. @@ -253,52 +253,52 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`는 Runner가 기록을 다음 턴으로 전달할 때 추론 항목이 다음 턴의 모델 입력으로 변환되는 방식을 제어합니다(예: `RunResult.to_input_list()` 또는 세션 기반 실행을 사용하는 경우). +`reasoning_item_id_policy`는 Runner가 기록을 다음 턴으로 전달할 때 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. 예를 들어 `RunResult.to_input_list()`를 사용하거나 세션 기반 실행을 사용할 때 적용됩니다. - `None` 또는 `"preserve"`(기본값): 추론 항목 ID 유지 - `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID 제거 -주로 추론 항목이 `id`와 함께 전송되지만 필수 후속 항목 없이 전송되어 발생하는 Responses API 400 오류 유형에 대한 선택적 완화책으로 `"omit"`을 사용하세요(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`). +추론 항목이 `id`와 함께 전송되지만 필수 후속 항목은 없는 경우 발생하는 Responses API 400 오류 유형을 선택적으로 완화하려면 주로 `"omit"`을 사용하세요. 예를 들면 `Item 'rs_...' of type 'reasoning' was provided without its required following item.` 오류가 있습니다. -SDK가 이전 출력에서 후속 입력을 구성하는 다중 턴 에이전트 실행에서 이런 문제가 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리형 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID는 유지되지만 제공자는 해당 ID가 대응하는 후속 항목과 계속 쌍을 이루도록 요구할 수 있습니다. +이 문제는 여러 턴의 에이전트 실행에서 SDK가 이전 출력으로 후속 입력을 구성할 때 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리형 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID는 유지되지만 공급자가 해당 ID와 대응하는 후속 항목이 함께 유지되도록 요구할 수 있습니다. -`reasoning_item_id_policy="omit"`으로 설정하면 추론 콘텐츠는 유지하되 추론 항목의 `id`를 제거하여 SDK가 생성한 후속 입력에서 해당 API 불변 조건이 위반되는 것을 방지합니다. +`reasoning_item_id_policy="omit"`을 설정하면 추론 내용은 유지하면서 추론 항목의 `id`를 제거하므로 SDK가 생성한 후속 입력에서 해당 API 불변 조건을 위반하지 않습니다. 적용 범위 참고 사항: - SDK가 후속 입력을 구성할 때 생성하거나 전달하는 추론 항목만 변경합니다. - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. -- 이 정책이 적용된 후에도 `call_model_input_filter`가 의도적으로 추론 ID를 다시 추가할 수 있습니다. +- 이 정책이 적용된 후에도 `call_model_input_filter`를 통해 의도적으로 추론 ID를 다시 추가할 수 있습니다. ## 상태 및 대화 관리 ### 메모리 전략 선택 -다음 턴에 상태를 전달하는 일반적인 방법은 네 가지입니다. +상태를 다음 턴으로 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | +| 전략 | 상태가 저장되는 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | | --- | --- | --- | --- | -| `result.to_input_list()` | 애플리케이션 메모리 | 작은 채팅 루프, 완전한 수동 제어, 모든 제공자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | -| `session` | 스토리지 및 SDK | 지속적인 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 동일한 저장소를 가리키는 다른 인스턴스 | -| `conversation_id` | OpenAI Conversations API | 작업자 또는 서비스 간에 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 전달 | +| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`에서 반환된 목록과 다음 사용자 메시지 | +| `session` | 자체 스토리지 및 SDK | 지속되는 채팅 상태, 재개 가능한 실행, 사용자 지정 스토어 | 동일한 `session` 인스턴스 또는 동일한 스토어를 가리키는 다른 인스턴스 | +| `conversation_id` | OpenAI Conversations API | 여러 워커 또는 서비스에서 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 전달 | | `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 실행 | `result.last_response_id`와 새 사용자 턴만 전달 | -`result.to_input_list()`와 `session`은 클라이언트 관리형입니다. `conversation_id`와 `previous_response_id`는 OpenAI 관리형이며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화별로 하나의 지속성 전략을 선택하세요. 클라이언트 관리형 기록과 OpenAI 관리형 상태를 혼합하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. +`result.to_input_list()`와 `session`은 클라이언트에서 관리합니다. `conversation_id`와 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용하는 경우에만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 두 계층을 의도적으로 조정하지 않는 한 클라이언트 관리형 기록과 OpenAI 관리형 상태를 혼합하면 컨텍스트가 중복될 수 있습니다. !!! note - 세션 지속성은 동일한 실행에서 서버 관리형 대화 설정 - (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 함께 사용할 수 - 없습니다. 호출마다 하나의 방식을 선택하세요. + 세션 지속성은 같은 실행에서 서버 관리형 대화 설정 + (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 + 함께 사용할 수 없습니다. 호출마다 한 가지 접근 방식을 선택하세요. -### 대화 및 채팅 스레드 +### 대화/채팅 스레드 -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행되어 하나 이상의 LLM 호출이 발생할 수 있지만, 채팅 대화에서는 단일 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며, 그에 따라 하나 이상의 LLM 호출이 발생할 수 있습니다. 하지만 이는 채팅 대화에서 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. -1. 사용자 턴: 사용자가 텍스트 입력 -2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 뒤 두 번째 에이전트로 핸드오프하고, 두 번째 에이전트가 추가 도구를 실행한 다음 출력을 생성 +1. 사용자 턴: 사용자가 텍스트를 입력합니다. +2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트가 추가 도구를 실행한 다음 출력을 생성합니다. -에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 쪽이든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. +에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 쪽이든 사용자가 후속 질문을 할 수 있으며, 이 경우 실행 메서드를 다시 호출할 수 있습니다. #### 수동 대화 관리 @@ -324,9 +324,9 @@ async def main(): # California ``` -#### 세션을 통한 자동 대화 관리 +#### 세션을 사용한 자동 대화 관리 -더 간단한 방법으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 직접 호출하지 않고도 대화 기록을 자동으로 처리할 수 있습니다. +더 간단한 접근 방식으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 수동으로 호출하지 않고 대화 기록을 자동으로 처리할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession, trace @@ -352,18 +352,18 @@ async def main(): Sessions는 다음 작업을 자동으로 수행합니다. -- 각 실행 전에 대화 기록 검색 +- 각 실행 전에 대화 기록 가져오기 - 각 실행 후 새 메시지 저장 -- 서로 다른 세션 ID에 대해 별도의 대화 유지 +- 서로 다른 세션 ID별로 별도의 대화 유지 -자세한 내용은 [Sessions 문서](sessions/index.md)를 참고하세요. +자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. #### 서버 관리형 대화 -`to_input_list()` 또는 `Sessions`를 사용해 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 직접 다시 보내지 않고도 대화 기록을 유지할 수 있습니다. 아래의 서버 관리형 방식 중 하나를 사용할 때는 각 요청에 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참고하세요. +`to_input_list()` 또는 `Sessions`를 사용해 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 매번 수동으로 다시 전송하지 않고도 대화 기록을 유지할 수 있습니다. 아래 서버 관리형 접근 방식 중 하나를 사용할 때는 각 요청에 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. -OpenAI는 여러 턴에 걸쳐 상태를 추적하는 두 가지 방법을 제공합니다. +OpenAI는 여러 턴에서 상태를 추적하는 두 가지 방법을 제공합니다. ##### 1. `conversation_id` 사용 @@ -415,28 +415,28 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하면 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리형 대화에서 계속됩니다. +실행이 승인을 위해 일시 중지된 후 [`RunState`][agents.run_state.RunState]에서 재개하는 경우, SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴은 동일한 서버 관리형 대화에서 계속됩니다. -`conversation_id`와 `previous_response_id`는 상호 배타적입니다. 여러 시스템에서 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`를 사용하세요. +`conversation_id`와 `previous_response_id`는 함께 사용할 수 없습니다. 여러 시스템에서 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`를 사용하세요. !!! note SDK는 `conversation_locked` 오류를 백오프 방식으로 자동 재시도합니다. 서버 관리형 - 대화 실행에서는 재시도 전에 내부 대화 추적기의 입력을 되돌려 동일하게 준비된 - 항목을 문제없이 다시 전송할 수 있도록 합니다. + 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌리므로 준비된 동일한 + 항목을 문제없이 다시 전송할 수 있습니다. - `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용할 수 없는 - 로컬 세션 기반 실행에서도 SDK는 최근에 저장된 입력 항목을 최선의 방식으로 - 롤백하여 재시도 후 기록 항목의 중복을 줄입니다. + 로컬 세션 기반 실행(`conversation_id`, `previous_response_id` 또는 + `auto_previous_response_id`와 함께 사용할 수 없음)에서도 SDK는 최근에 저장된 + 입력 항목을 최선의 방식으로 롤백하여 재시도 후 기록 항목이 중복되는 것을 줄입니다. 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 - 대해 더 광범위한 선택적 재시도 동작을 사용하려면 [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참고하세요. + 대한 더 광범위한 선택적 재시도 동작은 [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. ## 훅 및 사용자 지정 ### 모델 호출 입력 필터 -모델 호출 직전에 모델 입력을 수정하려면 `call_model_input_filter`를 사용하세요. 이 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(존재하는 경우 세션 기록 포함)을 받고 새로운 `ModelInputData`를 반환합니다. +모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(세션 기록이 있는 경우 포함)을 받고 새 `ModelInputData`를 반환합니다. 반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`가 발생합니다. @@ -457,19 +457,19 @@ result = Runner.run_sync( ) ``` -Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원래 목록을 제자리에서 변경하지 않고도 항목을 줄이거나 대체하거나 순서를 변경할 수 있습니다. +Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원래 목록을 직접 변경하지 않고 목록을 줄이거나 대체하거나 순서를 변경할 수 있습니다. -세션을 사용하는 경우 세션 기록을 이미 불러와 현재 턴과 병합한 후 `call_model_input_filter`가 실행됩니다. 이보다 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. +세션을 사용하는 경우 `call_model_input_filter`는 세션 기록을 이미 불러와 현재 턴과 병합한 후 실행됩니다. 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. -`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`를 사용하여 OpenAI 서버 관리형 대화 상태를 사용하는 경우 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 이 페이로드는 이전 기록의 전체 재생이 아니라 이미 새 턴의 델타만 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리형 연속 실행에서 전송된 것으로 표시됩니다. +`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 OpenAI 서버 관리형 대화 상태를 사용하는 경우, 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록의 전체 재전송이 아니라 새 턴의 델타만 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리형 연속 실행에 전송된 것으로 표시됩니다. -민감한 데이터를 제거하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 삽입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. +민감한 데이터를 제거하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 주입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. ## 오류 및 복구 -### 오류 처리기 +### 오류 핸들러 -모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 허용합니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. +모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. ```python from agents import ( @@ -498,7 +498,7 @@ result = Runner.run_sync( print(result.final_output) ``` -모델 메시지가 에이전트의 구조화된 `output_type`에 대해 검증되지 않거나 모델이 구조화된 최종 메시지를 반환하지 않을 때 `"invalid_final_output"`을 사용하세요. 처리기는 애플리케이션별 대체 값을 반환할 수 있으며, SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지는 않습니다. `None`을 반환하면 복구를 수행하지 않습니다. 대체 값이 없으면 비어 있지 않은 응답의 검증 실패에서는 계속 `ModelBehaviorError`가 발생하고, 비어 있는 구조화된 응답에는 기존 다음 턴 동작이 유지됩니다. +모델 메시지가 에이전트의 구조화된 `output_type`에 대한 검증을 통과하지 못하거나 모델이 구조화된 최종 메시지를 반환하지 않을 때는 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 값을 반환할 수 있으며, SDK는 동일한 `output_type`을 기준으로 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지 않습니다. `None`을 반환하면 복구를 수행하지 않습니다. 대체 값이 없으면 비어 있지 않은 응답의 검증 실패 시 계속 `ModelBehaviorError`가 발생하고, 비어 있는 구조화된 응답에는 기존의 다음 턴 동작이 유지됩니다. ```python from pydantic import BaseModel @@ -530,9 +530,9 @@ result = Runner.run_sync( print(result.final_output) ``` -대체 출력을 대화 기록에 추가하지 않으려면 `include_in_history=False`로 설정하세요. +대체 출력을 대화 기록에 추가하지 않으려면 `include_in_history=False`를 설정하세요. -모델 거부 시 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 값을 생성하려면 `"model_refusal"`을 사용하세요. +모델 거부로 인해 `ModelRefusalError`를 발생시키는 대신 애플리케이션별 대체 값을 생성해야 할 때는 `"model_refusal"`을 사용하세요. ```python from pydantic import BaseModel @@ -564,35 +564,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 내구성 실행 통합 및 휴먼인더루프 (HITL) +## 지속 실행 통합 및 휴먼인더루프 (HITL) -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참고하세요. 아래 통합은 실행이 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 실행이 장시간 대기, 재시도 또는 프로세스 재시작에 걸쳐 이어질 수 있는 지속적인 오케스트레이션을 위한 것입니다. ### Dapr -Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 휴먼인더루프 (HITL) 지원과 함께 장애에서 자동으로 복구되는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr 및 OpenAI 에이전트 사용은 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. +Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 휴먼인더루프 (HITL)를 지원하고 장애로부터 자동으로 복구되는 지속적인 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr 및 OpenAI 에이전트 시작 방법은 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 확인하세요. ### Temporal -Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함하여 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. 장기 실행 작업을 완료하기 위해 Temporal과 Agents SDK가 함께 작동하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 볼 수 있습니다. +Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함한 지속적인 장기 실행 워크플로를 실행할 수 있습니다. 장기 실행 작업을 완료하기 위해 Temporal과 Agents SDK가 함께 작동하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. ### Restate -Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 실행할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 필요로 하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참고하세요. +Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함한 경량의 지속적인 에이전트를 사용할 수 있습니다. 이 통합에는 Restate의 단일 바이너리 런타임이 종속성으로 필요하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. ### DBOS -Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 장애와 재시작 후에도 진행 상태를 보존하는 신뢰할 수 있는 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents) 및 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참고하세요. +Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 장애와 재시작 중에도 진행 상황을 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. ## 예외 -SDK는 특정 상황에서 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에 있습니다. 개요는 다음과 같습니다. +SDK는 특정한 경우 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에 있습니다. 개요는 다음과 같습니다. -- [`AgentsException`][agents.exceptions.AgentsException]: SDK 내에서 발생하는 모든 예외의 기본 클래스입니다. 다른 모든 특정 예외가 파생되는 일반 유형입니다. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과하면 이 예외가 발생합니다. 지정된 상호작용 턴 수 안에 에이전트가 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`으로 설정하세요. -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못한 출력이나 유효하지 않은 출력을 생성하면 이 예외가 발생합니다. 다음과 같은 경우가 포함될 수 있습니다. - - 잘못된 형식의 JSON: 특히 특정 `output_type`이 정의된 경우 모델이 도구 호출 또는 직접 출력에서 잘못된 형식의 JSON 구조를 제공하는 경우 - - 예상하지 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못하는 경우 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 제한 시간을 초과하고 도구에서 `timeout_behavior="raise_exception"`을 사용하는 경우 이 예외가 발생합니다. -- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성한 사람이 SDK를 사용하는 중 오류를 범하면 이 예외가 발생합니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 잘못된 사용으로 인해 발생합니다. -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 각각 입력 가드레일 또는 출력 가드레일의 조건이 충족되면 이 예외가 발생합니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. +- [`AgentsException`][agents.exceptions.AgentsException]: SDK 내에서 발생하는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 유형입니다. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과할 때 발생하는 예외입니다. 에이전트가 지정된 상호작용 턴 수 내에 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`을 설정하세요. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 잘못된 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 경우가 포함될 수 있습니다. + - 잘못된 형식의 JSON: 특히 특정 `output_type`이 정의된 경우, 모델이 도구 호출 또는 직접 출력에서 잘못된 형식의 JSON 구조를 제공할 때 + - 예상하지 못한 도구 관련 오류: 모델이 예상된 방식으로 도구를 사용하지 못할 때 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 제한 시간을 초과하고 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생하는 예외입니다. +- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성하는 사람이 SDK 사용 중 오류를 범했을 때 발생하는 예외입니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 잘못된 사용으로 인해 발생합니다. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 각각 입력 가드레일 또는 출력 가드레일의 조건이 충족될 때 발생하는 예외입니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. diff --git a/docs/ko/sandbox/clients.md b/docs/ko/sandbox/clients.md index b5cd21677c..137cdd8319 100644 --- a/docs/ko/sandbox/clients.md +++ b/docs/ko/sandbox/clients.md @@ -117,7 +117,7 @@ run_config = RunConfig( | `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy`로 rclone 기반 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용하세요. | | `E2BSandboxClient` | `E2BCloudBucketMountStrategy`로 rclone 기반 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용하세요. | | `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy`로 rclone 기반 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용하세요. | -| `VercelSandboxClient` | 현재 노출된 호스티드 전용 마운트 전략은 없습니다. 대신 매니페스트 파일, 리포지토리 또는 기타 워크스페이스 입력을 사용하세요. | +| `VercelSandboxClient` | `VercelCloudBucketMountStrategy`와 `S3Mount`를 사용한 생성 시점 전용 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트가 포함된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True`가 필요합니다. | @@ -134,8 +134,8 @@ run_config = RunConfig( | `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | -| `VercelSandboxClient` | - | - | - | - | - | - | +| `VercelSandboxClient` | ✓ | - | - | - | - | - | -실행 가능한 더 많은 예제는 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴에 대해 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)를, 호스티드 샌드박스 클라이언트에 대해 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)를 둘러보세요. \ No newline at end of file +실행 가능한 더 많은 예제는 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴에 대해 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)를, 호스티드 샌드박스 클라이언트에 대해 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)를 둘러보세요. diff --git a/docs/ko/streaming.md b/docs/ko/streaming.md index 063d0e5391..1b6e3cef38 100644 --- a/docs/ko/streaming.md +++ b/docs/ko/streaming.md @@ -4,19 +4,19 @@ search: --- # 스트리밍 -스트리밍을 사용하면 에이전트 실행이 진행되는 동안 업데이트를 구독할 수 있습니다. 이는 최종 사용자에게 진행 상황 업데이트와 부분 응답을 보여주는 데 유용할 수 있습니다. +스트리밍을 사용하면 에이전트 실행이 진행되는 동안 업데이트를 구독할 수 있습니다. 이는 최종 사용자에게 진행 상황 업데이트와 부분 응답을 표시할 때 유용합니다. -스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 호출하면 되며, 이는 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. `result.stream_events()`를 호출하면 아래에 설명된 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림을 얻을 수 있습니다. +스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 호출하여 [`RunResultStreaming`][agents.result.RunResultStreaming]을 받을 수 있습니다. `result.stream_events()`를 호출하면 아래에 설명된 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림을 받을 수 있습니다. -비동기 이터레이터가 종료될 때까지 `result.stream_events()`를 계속 소비하세요. 스트리밍 실행은 이터레이터가 끝나기 전까지 완료되지 않으며, 세션 영속화, 승인 기록 관리, 히스토리 압축과 같은 후처리는 마지막으로 보이는 토큰이 도착한 뒤에 완료될 수 있습니다. 루프가 종료되면 `result.is_complete`는 최종 실행 상태를 반영합니다. +비동기 이터레이터가 완료될 때까지 `result.stream_events()`를 계속 소비해야 합니다. 이터레이터가 종료되기 전까지 스트리밍 실행은 완료된 것이 아니며, 세션 영구 저장, 승인 상태 기록, 기록 압축과 같은 후처리는 표시되는 마지막 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. ## 원문 응답 이벤트 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent]는 LLM에서 직접 전달되는 원문 이벤트입니다. OpenAI Responses API 형식이므로 각 이벤트에는 `response.created`, `response.output_text.delta` 등과 같은 타입과 데이터가 있습니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent]는 LLM에서 직접 전달되는 원문 이벤트입니다. 이 이벤트는 OpenAI Responses API 형식이므로 각 이벤트에는 유형(예: `response.created`, `response.output_text.delta` 등)과 데이터가 있습니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. -컴퓨터 도구 원문 이벤트는 저장된 결과와 동일하게 preview-vs-GA 구분을 유지합니다. Preview 흐름은 하나의 `action`이 있는 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 배치된 `actions[]`가 있는 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 표면은 이를 위해 컴퓨터 전용의 특별한 이벤트 이름을 추가하지 않습니다. 두 형태 모두 여전히 `tool_called`로 표면화되며, 스크린샷 결과는 `computer_call_output` 항목을 래핑한 `tool_output`으로 반환됩니다. +컴퓨터 도구의 원문 이벤트는 저장된 결과와 동일하게 프리뷰와 GA를 구분합니다. 프리뷰 흐름은 하나의 `action`이 포함된 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 일괄 처리된 `actions[]`가 포함된 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 인터페이스는 이를 위해 컴퓨터 전용 이벤트 이름을 별도로 추가하지 않습니다. 두 형식 모두 여전히 `tool_called`로 제공되며, 스크린샷 결과는 `computer_call_output` 항목을 감싼 `tool_output`으로 반환됩니다. -예를 들어, 다음은 LLM이 생성한 텍스트를 토큰 단위로 출력합니다. +예를 들어 다음 코드는 LLM이 생성한 텍스트를 토큰 단위로 출력합니다. ```python import asyncio @@ -39,9 +39,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 스트리밍 및 승인 +## 스트리밍과 승인 -스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요한 경우 `result.stream_events()`가 종료되고 대기 중인 승인은 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. `result.to_state()`를 사용해 결과를 [`RunState`][agents.run_state.RunState]로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`로 재개하세요. +스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요한 경우 `result.stream_events()`가 완료되고 대기 중인 승인은 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 제공됩니다. `result.to_state()`를 사용하여 결과를 [`RunState`][agents.run_state.RunState]로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`로 재개합니다. ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,50 +57,53 @@ if result.interruptions: pass ``` -전체 일시 중지/재개 과정을 보려면 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참조하세요. +전체 일시 중지 및 재개 과정은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참조하세요. ## 현재 턴 이후 스트리밍 취소 -스트리밍 실행을 중간에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출하세요. 기본적으로 이는 실행을 즉시 중지합니다. 중지하기 전에 현재 턴이 깔끔하게 끝나도록 하려면 대신 `result.cancel(mode="after_turn")`을 호출하세요. +스트리밍 실행을 도중에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출합니다. 기본적으로 실행이 즉시 중지됩니다. 중지하기 전에 현재 턴이 정상적으로 완료되도록 하려면 대신 `result.cancel(mode="after_turn")`을 호출합니다. -스트리밍된 실행은 `result.stream_events()`가 종료되기 전까지 완료되지 않습니다. 마지막으로 보이는 토큰 이후에도 SDK가 여전히 세션 항목을 영속화하거나, 승인 상태를 최종화하거나, 히스토리를 압축하고 있을 수 있습니다. +`result.stream_events()`가 완료되기 전까지 스트리밍 실행은 완료된 것이 아닙니다. 표시되는 마지막 토큰 이후에도 SDK가 세션 항목을 영구 저장하거나, 승인 상태를 마무리하거나, 기록을 압축하고 있을 수 있습니다. -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하고 있고, `cancel(mode="after_turn")`가 도구 턴 이후에 중지되는 경우, 즉시 새 사용자 턴을 추가하는 대신 해당 정규화된 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 턴을 계속 진행하세요. -- 스트리밍된 실행이 도구 승인 때문에 중지된 경우 이를 새 턴으로 취급하지 마세요. 스트림 소비를 완료하고, `result.interruptions`를 검사한 뒤, `result.to_state()`에서 재개하세요. -- 다음 모델 호출 전에 가져온 세션 히스토리와 새 사용자 입력이 병합되는 방식을 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 여기에서 새 턴 항목을 다시 작성하면, 다시 작성된 버전이 해당 턴에 대해 영속화됩니다. +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하고 있으며 `cancel(mode="after_turn")`이 도구 턴 이후에 중지된 경우, 곧바로 새로운 사용자 턴을 추가하지 말고 정규화된 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 턴을 이어서 진행합니다. +- 스트리밍 실행이 도구 승인을 위해 중지된 경우 이를 새 턴으로 취급하지 마세요. 스트림 소비를 끝까지 완료하고 `result.interruptions`를 확인한 후 `result.to_state()`에서 재개합니다. +- 다음 모델 호출 전에 가져온 세션 기록과 새 사용자 입력이 병합되는 방식을 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용합니다. 여기에서 새 턴 항목을 다시 작성하면 해당 턴에는 다시 작성된 버전이 영구 저장됩니다. -## 실행 항목 이벤트 및 에이전트 이벤트 +## 실행 항목 이벤트와 에이전트 이벤트 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 더 높은 수준의 이벤트입니다. 항목이 완전히 생성되었을 때 알려줍니다. 이를 통해 각 토큰 대신 "메시지 생성됨", "도구 실행됨" 등의 수준에서 진행 상황 업데이트를 푸시할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과) 업데이트를 제공합니다. +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 상위 수준 이벤트입니다. 항목 생성이 완전히 끝났을 때 이를 알려 줍니다. 따라서 각 토큰이 아니라 "메시지 생성 완료", "도구 실행 완료" 등의 수준에서 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과로 변경될 때) 업데이트를 제공합니다. ### 실행 항목 이벤트 이름 -`RunItemStreamEvent.name`은 고정된 의미론적 이벤트 이름 집합을 사용합니다. +`RunItemStreamEvent.name`은 다음과 같이 고정된 의미론적 이벤트 이름 집합을 사용합니다. -- `message_output_created` -- `handoff_requested` -- `handoff_occured` -- `tool_called` -- `tool_search_called` -- `tool_search_output_created` -- `tool_output` -- `reasoning_item_created` -- `mcp_approval_requested` -- `mcp_approval_response` -- `mcp_list_tools` +- `message_output_created` +- `handoff_requested` +- `handoff_occured` +- `tool_called` +- `tool_search_called` +- `tool_search_output_created` +- `tool_output` +- `reasoning_item_created` +- `mcp_approval_requested` +- `mcp_approval_response` +- `mcp_list_tools` -`handoff_occured`는 이전 버전과의 호환성을 위해 의도적으로 철자가 틀리게 작성되었습니다. +`handoff_occured`는 이전 버전과의 호환성을 위해 의도적으로 철자가 잘못 표기되어 있습니다. -호스티드 툴 검색을 사용하면 모델이 도구 검색 요청을 발행할 때 `tool_search_called`가 발생하고, Responses API가 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 발생합니다. +호스티드 툴 검색을 사용하면 모델이 도구 검색 요청을 보낼 때 `tool_search_called`가 발생하고, Responses API가 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 발생합니다. -예를 들어, 다음은 원문 이벤트를 무시하고 사용자에게 업데이트를 스트리밍합니다. +프로그래밍 방식 도구 호출(Programmatic Tool Calling)에서는 생성된 `program`과 일반적인 프로그램 소유 하위 도구 호출에 대해 `tool_called`가 발생합니다. 하위 도구 출력과 이에 대응하는 `program_output`에는 `tool_output`이 발생합니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외입니다. 이 항목들은 각각 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]과 [`MCPListToolsItem`][agents.items.MCPListToolsItem]을 감싼 `mcp_approval_requested` 및 `mcp_list_tools`로 발생합니다. 나머지 항목을 구분하려면 원문 항목의 `type`을 확인하세요. 프로그램 소유 하위 호출에는 유형이 `program`이고 호출자 ID로 상위 프로그램을 식별하는 `caller`도 포함됩니다. + +예를 들어 다음 코드는 원문 이벤트를 무시하고 사용자에게 업데이트를 스트리밍합니다. ```python import asyncio import random -from agents import Agent, ItemHelpers, Runner, function_tool +from agents import Agent, ItemHelpers, Runner +from agents.decorators import tool -@function_tool +@tool def how_many_jokes() -> int: return random.randint(1, 10) diff --git a/docs/ko/tools.md b/docs/ko/tools.md index 6c947876fe..97cfb7a177 100644 --- a/docs/ko/tools.md +++ b/docs/ko/tools.md @@ -4,41 +4,43 @@ search: --- # 도구 -도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용 등의 작업을 수행할 수 있습니다. SDK는 다음 다섯 가지 카테고리를 지원합니다. +도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용 등의 작업을 수행할 수 있습니다. SDK는 다음과 같은 다섯 가지 카테고리를 지원합니다. - OpenAI 호스티드 툴: OpenAI 서버에서 모델과 함께 실행됩니다. -- 로컬/런타임 실행 도구: `ComputerTool`과 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`은 로컬 또는 호스팅된 컨테이너에서 실행할 수 있습니다. -- Function Calling: 모든 Python 함수를 도구로 래핑합니다. +- 로컬/런타임 실행 도구: `ComputerTool`과 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`은 로컬 또는 호스티드 컨테이너에서 실행될 수 있습니다. +- Function calling: 모든 Python 함수를 도구로 래핑합니다. - Agents as tools: 전체 핸드오프 없이 에이전트를 호출 가능한 도구로 노출합니다. -- 실험적 기능: Codex 도구: 도구 호출을 통해 작업 공간 범위의 Codex 작업을 실행합니다. +- 실험적 기능: Codex 도구: 도구 호출에서 워크스페이스 범위의 Codex 작업을 실행합니다. ## 도구 유형 선택 -이 페이지를 카탈로그로 활용한 다음, 사용자가 제어하는 런타임에 해당하는 섹션으로 이동하세요. +이 페이지를 카탈로그로 활용한 다음, 제어하는 런타임과 일치하는 섹션으로 이동하세요. | 원하는 작업 | 시작 위치 | | --- | --- | -| OpenAI 관리형 도구 사용(웹 검색, 파일 검색, Code Interpreter, 호스팅된 MCP, 이미지 생성) | [호스티드 툴](#hosted-tools) | -| 도구 검색을 사용하여 대규모 도구 집합을 런타임까지 지연 | [호스티드 툴 검색](#hosted-tool-search) | +| OpenAI 관리형 도구 사용(웹 검색, 파일 검색, Code Interpreter, 호스티드 MCP, 이미지 생성) | [호스티드 툴](#hosted-tools) | +| 도구 검색을 사용하여 대규모 도구 표면을 런타임까지 지연 | [호스티드 툴 검색](#hosted-tool-search) | +| 생성된 JavaScript에서 여러 도구 호출 조정 | [프로그래매틱 도구 호출](#programmatic-tool-calling) | | 자체 프로세스 또는 환경에서 도구 실행 | [로컬 런타임 도구](#local-runtime-tools) | | Python 함수를 도구로 래핑 | [함수 도구](#function-tools) | | 핸드오프 없이 한 에이전트가 다른 에이전트를 호출하도록 설정 | [Agents as tools](#agents-as-tools) | -| 에이전트에서 작업 공간 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | +| 에이전트에서 워크스페이스 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | ## 호스티드 툴 -OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 몇 가지 기본 제공 도구를 제공합니다. +OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 다음과 같은 몇 가지 기본 제공 도구를 제공합니다. - [`WebSearchTool`][agents.tool.WebSearchTool]을 사용하면 에이전트가 웹을 검색할 수 있습니다. - [`FileSearchTool`][agents.tool.FileSearchTool]을 사용하면 OpenAI 벡터 스토어에서 정보를 검색할 수 있습니다. - [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool]을 사용하면 LLM이 샌드박스 환경에서 코드를 실행할 수 있습니다. - [`HostedMCPTool`][agents.tool.HostedMCPTool]은 원격 MCP 서버의 도구를 모델에 노출합니다. - [`ImageGenerationTool`][agents.tool.ImageGenerationTool]은 프롬프트에서 이미지를 생성합니다. -- [`ToolSearchTool`][agents.tool.ToolSearchTool]을 사용하면 모델이 지연된 도구, 네임스페이스 또는 호스팅된 MCP 서버를 필요할 때 로드할 수 있습니다. +- [`ToolSearchTool`][agents.tool.ToolSearchTool]을 사용하면 모델이 지연된 도구, 네임스페이스 또는 호스티드 MCP 서버를 필요할 때 로드할 수 있습니다. +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 사용하면 모델이 생성된 JavaScript에서 사용 가능한 도구를 조정할 수 있습니다. -고급 호스팅 검색 옵션: +고급 호스티드 검색 옵션: -- `FileSearchTool`은 `vector_store_ids`와 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. +- `FileSearchTool`은 `vector_store_ids` 및 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. - `WebSearchTool`은 `filters`, `user_location`, `search_context_size`를 지원합니다. ```python @@ -62,17 +64,18 @@ async def main(): ### 호스티드 툴 검색 -도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 집합의 로드를 런타임까지 지연하여 현재 턴에 필요한 일부만 로드할 수 있습니다. 함수 도구, 네임스페이스 그룹 또는 호스팅된 MCP 서버가 많고 모든 도구를 미리 노출하지 않으면서 도구 스키마 토큰을 줄이려는 경우에 유용합니다. +도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 표면을 런타임까지 지연하므로, 모델은 현재 턴에 필요한 하위 집합만 로드합니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많고 모든 도구를 미리 노출하지 않으면서 도구 스키마 토큰을 줄이고자 할 때 유용합니다. -에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 툴 검색부터 사용하세요. 애플리케이션에서 로드할 항목을 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행형 도구 검색도 지원하지만, 표준 `Runner`는 이 모드를 자동으로 실행하지 않습니다. +에이전트를 구축할 때 후보 도구가 이미 정해져 있다면 호스티드 툴 검색으로 시작하세요. 애플리케이션에서 로드할 항목을 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행 도구 검색도 지원하지만, 표준 `Runner`는 이 모드를 자동으로 실행하지 않습니다. ```python from typing import Annotated -from agents import Agent, Runner, ToolSearchTool, function_tool, tool_namespace +from agents import Agent, Runner, ToolSearchTool, tool_namespace +from agents.decorators import tool -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_customer_profile( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -80,7 +83,7 @@ def get_customer_profile( return f"profile for {customer_id}" -@function_tool(defer_loading=True) +@tool(defer_loading=True) def list_open_orders( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -108,24 +111,77 @@ print(result.final_output) 알아둘 사항: -- 호스티드 툴 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원에는 `openai>=2.25.0`이 필요합니다. -- 에이전트에 지연 로딩 대상을 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. -- 검색 가능한 대상에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`가 포함됩니다. +- 호스티드 툴 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원 여부는 `openai>=2.25.0`에 따라 달라집니다. +- 에이전트에서 지연 로딩 표면을 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. +- 검색 가능한 표면에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`가 포함됩니다. - 지연 로딩 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 구성에서도 모델이 필요할 때 적절한 그룹을 로드하도록 `ToolSearchTool()`을 사용할 수 있습니다. -- `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름과 설명 아래에 그룹화합니다. 일반적으로 `crm`, `billing`, `shipping`처럼 관련 도구가 많은 경우에 가장 적합합니다. -- OpenAI의 공식 모범 사례 지침은 [가능하면 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. -- 가능하면 개별적으로 지연된 함수를 많이 사용하는 대신 네임스페이스 또는 호스팅된 MCP 서버를 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 대상을 제공하고 토큰도 더 많이 절약합니다. -- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`가 없는 도구는 즉시 호출할 수 있으며, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. -- 경험상 각 네임스페이스는 비교적 작게 유지하며, 이상적으로는 함수 수를 10개 미만으로 유지하세요. -- 이름이 지정된 `tool_choice`는 네임스페이스 이름 자체나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 사용하세요. -- `ToolSearchTool(execution="client")`는 수동 Responses 오케스트레이션용입니다. 모델이 클라이언트 실행형 `tool_search_call`을 내보내면 표준 `Runner`는 이를 대신 실행하지 않고 오류를 발생시킵니다. -- 도구 검색 활동은 [`RunResult.new_items`](results.md#new-items)와 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 전용 항목 및 이벤트 유형으로 표시됩니다. -- 네임스페이스 로딩과 최상위 지연 도구를 모두 다루는 실행 가능한 전체 코드 예제는 `examples/tools/tool_search.py`를 참고하세요. +- `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름 및 설명 아래에 그룹화합니다. 일반적으로 `crm`, `billing`, `shipping`처럼 관련 도구가 많은 경우 가장 적합합니다. +- OpenAI의 공식 모범 사례 지침은 [가능한 경우 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. +- 가능하면 개별적으로 지연된 여러 함수보다 네임스페이스나 호스티드 MCP 서버를 우선 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 표면을 제공하고 토큰을 더 많이 절약할 수 있습니다. +- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`가 없는 도구는 즉시 호출할 수 있지만, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. +- 일반적으로 각 네임스페이스를 비교적 작게 유지하고, 가급적 함수 수를 10개 미만으로 제한하세요. +- 이름이 지정된 `tool_choice`는 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 사용하세요. +- `ToolSearchTool(execution="client")`는 수동 Responses 오케스트레이션용입니다. 모델이 클라이언트에서 실행되는 `tool_search_call`을 내보내면 표준 `Runner`는 이를 대신 실행하지 않고 예외를 발생시킵니다. +- 도구 검색 활동은 전용 항목 및 이벤트 유형과 함께 [`RunResult.new_items`](results.md#new-items) 및 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 표시됩니다. +- 네임스페이스 기반 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 코드 예제는 `examples/tools/tool_search.py`를 참조하세요. - 공식 플랫폼 가이드: [도구 검색](https://developers.openai.com/api/docs/guides/tools-tool-search) -### 호스팅된 컨테이너 셸 및 스킬 +### 프로그래매틱 도구 호출 -`ShellTool`은 OpenAI 호스팅 컨테이너 실행도 지원합니다. 로컬 런타임 대신 관리형 컨테이너에서 모델이 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. +프로그래매틱 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 그 출력을 결합하며, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델 왕복을 수행하지 않고도 루프, 분기, 병렬 호출 또는 중간 계산을 활용하는 범위가 제한된 워크플로에 유용합니다. + +생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. 이 환경에는 Node.js API, 파일 시스템 또는 네트워크 액세스, 영구 프로세스가 없습니다. 프로그램은 명시적으로 허용한 도구와만 상호 작용할 수 있습니다. + +```python +from pydantic import BaseModel + +from agents import ( + Agent, + ModelSettings, + ProgrammaticToolCallingTool, + Runner, +) +from agents.decorators import tool + + +class InventoryOutput(BaseModel): + sku: str + available_units: int + + +@tool(allowed_callers=["programmatic"]) +def get_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + +agent = Agent( + name="Inventory planner", + model="gpt-5.6", + model_settings=ModelSettings(tool_choice="programmatic_tool_calling"), + tools=[get_inventory, ProgrammaticToolCallingTool()], +) + +result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it.") +print(result.final_output) +``` + +알아둘 사항: + +- 프로그래매틱 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. `ProgrammaticToolCallingTool()` 및 `tool_choice="programmatic_tool_calling"`은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. +- 에이전트에는 `ProgrammaticToolCallingTool()`을 최대 하나만 추가하세요. 에이전트는 프로그래밍 방식으로 호출 가능한 도구, `ToolSearchTool()` 또는 프롬프트로 관리되는 도구 표면 중 하나 이상도 노출해야 합니다. +- `allowed_callers`는 도구를 호출할 수 있는 방식을 제어합니다. 생략하면 모델의 직접 호출만 허용됩니다. 프로그램에서만 액세스하려면 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`을 사용하세요. +- 이 기능을 선택적으로 사용할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸 및 패치 적용 도구는 `allowed_callers`를 직접 노출합니다. 호스티드 MCP와 Code Interpreter의 경우 `tool_config` 내부에 `allowed_callers`를 설정하세요. +- `@function_tool(allowed_callers=[...])`의 경우 Pydantic 모델, TypedDict 또는 데이터 클래스와 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 값이 프로그램에 반환되기 전에 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없다면 `output_type=...`을 사용하고, 엄격한 객체 스키마가 이미 있다면 하위 수준의 우회 수단인 `output_json_schema={...}`를 사용하세요. `output_type`과 `output_json_schema`는 함께 사용할 수 없습니다. 일반 `str`, `Any`, `None` 반환은 타입이 지정되지 않은 상태로 유지됩니다. +- 프로그램 소유 SDK 도구에서도 일반적인 Runner 수명 주기가 계속 사용됩니다. 도구 입력 및 출력 가드레일, 훅, 시간 제한, 동시성 제한, 재시도, 승인, 세션, `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출의 프로그램 호출자 관계를 유지합니다. +- 승인이 필요하거나 영향이 큰 도구는 일반적으로 직접 호출로 유지하는 것이 좋습니다. 그러면 더 큰 프로그램의 일부가 되기 전에 사람이 각 작업을 검토할 수 있습니다. 프로그램 소유 호출이 승인을 위해 일시 중지되면 `RunState`를 통해 인터럽션(중단 처리)을 해결하고 평소와 같이 원래 실행을 재개하세요. +- 프로그래매틱 도구 호출은 [호스티드 툴 검색](#hosted-tool-search)과 함께 사용할 수 있습니다. 생성된 프로그램이 지연된 도구를 호출하려면 먼저 모델이 해당 도구를 로드해야 합니다. +- `program` 항목과 프로그램 소유 하위 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 일치하는 `program_output`은 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items) 및 [스트리밍](streaming.md#run-item-event-names)을 참조하세요. +- 완전한 동시 실행 재고 계획 코드 예제는 `examples/tools/programmatic_tool_calling.py`를 참조하세요. +- 공식 플랫폼 가이드: [프로그래매틱 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) + +### 호스티드 컨테이너 셸 + 스킬 + +`ShellTool`은 OpenAI 호스티드 컨테이너 실행도 지원합니다. 로컬 런타임 대신 관리형 컨테이너에서 모델이 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -162,48 +218,48 @@ print(result.final_output) 알아둘 사항: -- 호스팅된 셸은 Responses API 셸 도구를 통해 사용할 수 있습니다. -- `container_auto`는 요청에 사용할 컨테이너를 프로비저닝하고, `container_reference`는 기존 컨테이너를 재사용합니다. -- `container_auto`에는 `file_ids`와 `memory_limit`도 포함할 수 있습니다. -- `environment.skills`는 스킬 참조와 인라인 스킬 번들을 허용합니다. -- 호스팅된 환경에서는 `ShellTool`에 `executor`, `needs_approval`, `on_approval`을 설정하지 마세요. +- 호스티드 셸은 Responses API 셸 도구를 통해 사용할 수 있습니다. +- `container_auto`는 요청을 위한 컨테이너를 프로비저닝하고, `container_reference`는 기존 컨테이너를 재사용합니다. +- `container_auto`에는 `file_ids` 및 `memory_limit`도 포함할 수 있습니다. +- `environment.skills`는 스킬 참조 및 인라인 스킬 번들을 허용합니다. +- 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval`, `on_approval`을 설정하지 마세요. - `network_policy`는 `disabled` 및 `allowlist` 모드를 지원합니다. -- 허용 목록 모드에서는 `network_policy.domain_secrets`가 이름을 기준으로 도메인 범위의 보안 비밀을 주입할 수 있습니다. -- 전체 코드 예제는 `examples/tools/container_shell_skill_reference.py`와 `examples/tools/container_shell_inline_skill.py`를 참고하세요. +- 허용 목록 모드에서는 `network_policy.domain_secrets`가 이름을 통해 도메인 범위의 비밀 값을 주입할 수 있습니다. +- 완전한 코드 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참조하세요. - OpenAI 플랫폼 가이드: [셸](https://platform.openai.com/docs/guides/tools-shell) 및 [스킬](https://platform.openai.com/docs/guides/tools-skills) ## 로컬 런타임 도구 -로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델은 여전히 도구를 호출할 시점을 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. +로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 도구를 호출할 시점을 계속 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. -`ComputerTool`과 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`은 두 모드를 모두 지원합니다. 관리형 실행이 필요하면 위의 호스팅된 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. +`ComputerTool`과 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`은 두 모드를 모두 지원합니다. 관리형 실행을 사용하려면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. -로컬 런타임 도구에는 사용자가 구현을 제공해야 합니다. +로컬 런타임 도구에는 다음 구현을 제공해야 합니다. -- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 활성화하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현합니다. -- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스팅된 컨테이너 실행을 모두 지원하는 최신 셸 도구입니다. -- [`LocalShellTool`][agents.tool.LocalShellTool]: 기존 로컬 셸 통합입니다. -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현합니다. -- 로컬 셸 스킬은 `ShellTool(environment={"type": "local", "skills": [...]})`과 함께 사용할 수 있습니다. +- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 사용하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현하세요. +- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행을 모두 지원하는 최신 셸 도구 +- [`LocalShellTool`][agents.tool.LocalShellTool]: 레거시 로컬 셸 통합 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현하세요. +- 로컬 셸 스킬은 `ShellTool(environment={"type": "local", "skills": [...]})`을 통해 사용할 수 있습니다. -### ComputerTool과 Responses 컴퓨터 도구 +### ComputerTool 및 Responses 컴퓨터 도구 -`ComputerTool`은 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API의 컴퓨터 인터페이스에 매핑합니다. +`ComputerTool`은 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API의 컴퓨터 표면에 매핑합니다. -명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델은 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`를 계속 사용합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. +명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 정식 출시(GA)된 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델은 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`를 유지합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. - 모델: `computer-use-preview` -> `gpt-5.5` - 도구 선택자: `computer_use_preview` -> `computer` -- 컴퓨터 호출 형식: `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` +- 컴퓨터 호출 형태: `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` - 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 불필요 -SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하며 프롬프트가 모델을 지정하기 때문에 요청에서 `model`을 생략하는 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않으면 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. +SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하며 프롬프트가 모델을 소유하기 때문에 요청에서 `model`을 생략하는 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않으면 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. [`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 모두 허용되며 유효 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`이 없으면 이러한 문자열은 여전히 일반 함수 이름처럼 동작합니다. -이 차이는 `ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 기반으로 할 때 중요합니다. GA `computer` 페이로드는 직렬화 시점에 `environment`나 화면 크기가 필요하지 않으므로 확인되지 않은 팩토리도 사용할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`를 전송할 수 있도록 확인된 `Computer` 또는 `AsyncComputer` 인스턴스가 여전히 필요합니다. +`ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 기반으로 할 때는 이 차이가 중요합니다. GA `computer` 페이로드는 직렬화 시 `environment` 또는 크기 정보가 필요하지 않으므로 확인되지 않은 팩토리도 사용할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`를 전송할 수 있도록 확인된 `Computer` 또는 `AsyncComputer` 인스턴스가 여전히 필요합니다. -런타임에서는 두 경로 모두 동일한 로컬 하네스를 사용합니다. 프리뷰 응답은 단일 `action`이 포함된 `computer_call` 항목을 내보냅니다. `gpt-5.5`는 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`를 참고하세요. +런타임에서 두 경로는 모두 동일한 로컬 하네스를 계속 사용합니다. 프리뷰 응답은 단일 `action`이 포함된 `computer_call` 항목을 내보냅니다. `gpt-5.5`는 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 해당 작업을 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`를 참조하세요. ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -249,28 +305,29 @@ agent = Agent( 모든 Python 함수를 도구로 사용할 수 있습니다. Agents SDK가 도구를 자동으로 설정합니다. -- 도구 이름은 Python 함수의 이름이 됩니다. 또는 이름을 직접 지정할 수 있습니다. -- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 지정할 수 있습니다. +- 도구 이름은 Python 함수 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다. +- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 제공할 수 있습니다. - 함수 입력의 스키마는 함수 인수에서 자동으로 생성됩니다. - 비활성화하지 않는 한 각 입력의 설명은 함수의 docstring에서 가져옵니다. -Python의 `inspect` 모듈을 사용하여 함수 시그니처를 추출하고, [`griffe`](https://mkdocstrings.github.io/griffe/)를 사용하여 docstring을 파싱하며, `pydantic`을 사용하여 스키마를 생성합니다. +함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하고, docstring을 파싱하기 위해 [`griffe`](https://mkdocstrings.github.io/griffe/)를, 스키마 생성을 위해 `pydantic`을 함께 사용합니다. -OpenAI Responses 모델을 사용할 때 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`이 로드할 때까지 함수 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정 및 제약 조건은 [호스티드 툴 검색](#hosted-tool-search)을 참고하세요. +OpenAI Responses 모델을 사용하는 경우 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`이 함수 도구를 로드할 때까지 해당 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정 및 제약 조건은 [호스티드 툴 검색](#hosted-tool-search)을 참조하세요. ```python import json from typing_extensions import TypedDict, Any -from agents import Agent, FunctionTool, RunContextWrapper, function_tool +from agents import Agent, FunctionTool, RunContextWrapper +from agents.decorators import tool class Location(TypedDict): lat: float long: float -@function_tool # (1)! +@tool # (1)! async def fetch_weather(location: Location) -> str: # (2)! """Fetch the weather for a given location. @@ -282,7 +339,7 @@ async def fetch_weather(location: Location) -> str: return "sunny" -@function_tool(name_override="fetch_data") # (3)! +@tool(name_override="fetch_data") # (3)! def read_file(ctx: RunContextWrapper[Any], path: str, directory: str | None = None) -> str: """Read the contents of a file. @@ -308,12 +365,12 @@ for tool in agent.tools: ``` -1. 모든 Python 유형을 함수 인수로 사용할 수 있으며, 함수는 동기 또는 비동기일 수 있습니다. -2. Docstring이 있으면 설명과 인수 설명을 가져오는 데 사용됩니다. -3. 함수는 선택적으로 `context`를 받을 수 있으며, 이 경우 반드시 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의 값도 설정할 수 있습니다. -4. 데코레이트된 함수를 도구 목록에 전달할 수 있습니다. +1. 모든 Python 타입을 함수 인수로 사용할 수 있으며, 함수는 동기식 또는 비동기식일 수 있습니다. +2. docstring이 있으면 설명 및 인수 설명을 가져오는 데 사용됩니다. +3. 함수는 선택적으로 `context`를 받을 수 있습니다. 이 인수는 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의도 설정할 수 있습니다. +4. 데코레이팅된 함수를 도구 목록에 전달할 수 있습니다. -??? note "출력 펼쳐 보기" +??? note "출력을 확인하려면 펼치기" ``` fetch_weather @@ -383,9 +440,9 @@ for tool in agent.tools: } ``` -### 함수 도구에서 이미지 또는 파일 반환 +### 함수 도구의 이미지 또는 파일 반환 -텍스트 출력뿐 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 이를 위해 다음 중 하나를 반환할 수 있습니다. +텍스트 출력뿐만 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 이를 위해 다음 항목 중 하나를 반환할 수 있습니다. - 이미지: [`ToolOutputImage`][agents.tool.ToolOutputImage] 또는 TypedDict 버전인 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict] - 파일: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent] 또는 TypedDict 버전인 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict] @@ -393,12 +450,12 @@ for tool in agent.tools: ### 사용자 지정 함수 도구 -Python 함수를 도구로 사용하고 싶지 않은 경우도 있습니다. 원한다면 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. +Python 함수를 도구로 사용하고 싶지 않은 경우도 있습니다. 원하는 경우 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. - `name` - `description` - 인수의 JSON 스키마인 `params_json_schema` -- [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형식의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` +- [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형태의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` ```python from typing import Any @@ -433,43 +490,44 @@ tool = FunctionTool( ### 자동 인수 및 docstring 파싱 -앞서 설명한 것처럼 함수 시그니처를 자동으로 파싱하여 도구의 스키마를 추출하고, docstring을 파싱하여 도구 및 개별 인수의 설명을 추출합니다. 관련 참고 사항은 다음과 같습니다. +앞서 설명했듯이 도구의 스키마를 추출하기 위해 함수 시그니처를 자동으로 파싱하고, 도구와 개별 인수의 설명을 추출하기 위해 docstring을 파싱합니다. 이에 관한 참고 사항은 다음과 같습니다. -1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용하여 인수 유형을 파악하고, 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 유형, Pydantic 모델, TypedDict 등을 포함한 대부분의 유형을 지원합니다. -2. `griffe`를 사용하여 docstring을 파싱합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 시도하지만 이는 최선형 방식이며, `function_tool`을 호출할 때 형식을 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. +1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용하여 인수의 타입을 파악하고 전체 스키마를 나타내는 Pydantic 모델을 동적으로 구축합니다. Python 기본 타입, Pydantic 모델, TypedDict 등을 포함한 대부분의 타입을 지원합니다. +2. docstring 파싱에는 `griffe`를 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동 감지하려고 시도하지만 이는 최선형 방식이며, `function_tool`을 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. Google 스타일 docstring의 경우 파서는 요약 텍스트 바로 뒤에 빈 줄 없이 오는 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 허용합니다. 스키마 추출 코드는 [`agents.function_schema`][]에 있습니다. -### Pydantic Field를 통한 인수 제약 및 설명 +### Pydantic Field를 사용한 인수 제약 및 설명 -Pydantic의 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/)를 사용하여 도구 인수에 제약 조건(예: 숫자의 최솟값/최댓값, 문자열의 길이 또는 패턴)과 설명을 추가할 수 있습니다. Pydantic과 마찬가지로 기본값 기반 형식(`arg: int = Field(..., ge=1)`)과 `Annotated` 형식(`arg: Annotated[int, Field(..., ge=1)]`)을 모두 지원합니다. 생성된 JSON 스키마와 유효성 검사에는 이러한 제약 조건이 포함됩니다. +Pydantic의 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/)를 사용하여 도구 인수에 제약 조건(예: 숫자의 최솟값/최댓값, 문자열의 길이 또는 패턴)과 설명을 추가할 수 있습니다. Pydantic과 마찬가지로 기본값 기반 형식(`arg: int = Field(..., ge=1)`)과 `Annotated` 형식(`arg: Annotated[int, Field(..., ge=1)]`)을 모두 지원합니다. 생성된 JSON 스키마와 검증에는 이러한 제약 조건이 포함됩니다. ```python from typing import Annotated from pydantic import Field -from agents import function_tool +from agents.decorators import tool # Default-based form -@function_tool +@tool def score_a(score: int = Field(..., ge=0, le=100, description="Score from 0 to 100")) -> str: return f"Score recorded: {score}" # Annotated form -@function_tool +@tool def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score from 0 to 100")]) -> str: return f"Score recorded: {score}" ``` -### 함수 도구 타임아웃 +### 함수 도구 시간 제한 -`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구에 호출별 타임아웃을 설정할 수 있습니다. +`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구의 호출별 시간 제한을 설정할 수 있습니다. ```python import asyncio -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(timeout=2.0) +@tool(timeout=2.0) async def slow_lookup(query: str) -> str: await asyncio.sleep(10) return f"Result for {query}" @@ -482,20 +540,21 @@ agent = Agent( ) ``` -타임아웃에 도달하면 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델에 표시되는 타임아웃 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. +시간 제한에 도달하면 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델에 표시되는 시간 제한 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. -타임아웃 처리를 제어할 수 있습니다. +시간 제한 처리는 다음과 같이 제어할 수 있습니다. -- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 타임아웃 메시지를 반환합니다. +- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 시간 제한 메시지를 반환합니다. - `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]를 발생시키고 실행을 실패 처리합니다. -- `timeout_error_function=...`: `error_as_result`를 사용할 때 타임아웃 메시지를 사용자 지정합니다. +- `timeout_error_function=...`: `error_as_result`를 사용할 때 시간 제한 메시지를 사용자 지정합니다. ```python import asyncio -from agents import Agent, Runner, ToolTimeoutError, function_tool +from agents import Agent, Runner, ToolTimeoutError +from agents.decorators import tool -@function_tool(timeout=1.5, timeout_behavior="raise_exception") +@tool(timeout=1.5, timeout_behavior="raise_exception") async def slow_tool() -> str: await asyncio.sleep(5) return "done" @@ -511,18 +570,19 @@ except ToolTimeoutError as e: !!! note - 타임아웃 구성은 비동기 `@function_tool` 핸들러에서만 지원됩니다. + 시간 제한 구성은 비동기 `@function_tool` 핸들러에서만 지원됩니다. ### 함수 도구의 오류 처리 -`@function_tool`을 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이는 도구 호출이 비정상 종료될 경우 LLM에 오류 응답을 제공하는 함수입니다. +`@function_tool`을 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이 함수는 도구 호출이 비정상 종료될 경우 LLM에 오류 응답을 제공합니다. -- 기본적으로, 즉 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`을 실행합니다. -- 자체 오류 함수를 전달하면 해당 함수를 대신 실행하고 응답을 LLM에 전송합니다. -- 명시적으로 `None`을 전달하면 모든 도구 호출 오류가 다시 발생하므로 직접 처리해야 합니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`, 코드가 비정상 종료된 경우 `UserError` 등이 발생할 수 있습니다. +- 기본적으로 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`이 실행됩니다. +- 자체 오류 함수를 전달하면 해당 함수가 대신 실행되고 응답이 LLM에 전송됩니다. +- `None`을 명시적으로 전달하면 도구 호출 오류가 다시 발생하므로 사용자가 처리할 수 있습니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`가 될 수 있고, 코드가 비정상 종료된 경우 `UserError`가 될 수 있습니다. ```python -from agents import function_tool, RunContextWrapper +from agents import RunContextWrapper +from agents.decorators import tool from typing import Any def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) -> str: @@ -530,7 +590,7 @@ def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) print(f"A tool call failed with the following error: {error}") return "An internal server error occurred. Please try again later." -@function_tool(failure_error_function=my_custom_error_function) +@tool(failure_error_function=my_custom_error_function) def get_user_profile(user_id: str) -> str: """Fetches a user profile from a mock API. This function demonstrates a 'flaky' or failing API call. @@ -546,7 +606,7 @@ def get_user_profile(user_id: str) -> str: ## Agents as tools -일부 워크플로에서는 제어를 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. +일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. ```python import asyncio @@ -592,12 +652,15 @@ if __name__ == "__main__": ### 도구 에이전트 사용자 지정 -`agent.as_tool` 함수는 에이전트를 도구로 쉽게 변환할 수 있는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval` 등의 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`를 사용하는 구조화된 입력도 지원합니다. +`agent.as_tool` 함수는 에이전트를 도구로 쉽게 변환할 수 있는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`를 사용하는 구조화된 입력도 지원합니다. -상태 옵션은 도구 호출로 시작되는 중첩 에이전트 실행을 구성합니다. 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리형 기록을 공유하려면 동일한 `session`을 양쪽에 명시적으로 전달하세요. `Runner.run`과 마찬가지로 중첩 실행에는 하나의 상태 전략을 선택하세요. 클라이언트 관리형 `session` 또는 `previous_response_id`나 `conversation_id`를 통한 서버 관리형 이어가기 중 하나를 사용합니다. +상태 옵션은 도구 호출로 시작된 중첩 에이전트 실행을 구성하며, 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리형 기록을 공유하려면 동일한 `session`을 두 실행 모두에 명시적으로 전달하세요. `Runner.run`과 마찬가지로 중첩 실행에는 하나의 상태 전략을 선택하세요. 클라이언트 관리형 `session`을 사용하거나 `previous_response_id` 또는 `conversation_id`를 통한 서버 관리형 연속 실행을 사용해야 합니다. ```python -@function_tool +from agents.decorators import tool + + +@tool async def run_my_agent() -> str: """A tool that runs the agent with custom configs""" @@ -615,13 +678,13 @@ async def run_my_agent() -> str: ### 도구 에이전트의 구조화된 입력 -기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 기대하지만, `parameters`에 Pydantic 모델 또는 dataclass 유형을 전달하여 구조화된 스키마를 노출할 수 있습니다. +기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 예상하지만, `parameters`에 Pydantic 모델 또는 데이터 클래스 타입을 전달하여 구조화된 스키마를 노출할 수 있습니다. 추가 옵션: - `include_input_schema=True`는 생성된 중첩 입력에 전체 JSON 스키마를 포함합니다. -- `input_builder=...`를 사용하면 구조화된 도구 인수를 중첩 에이전트 입력으로 변환하는 방식을 완전히 사용자 지정할 수 있습니다. -- `RunContextWrapper.tool_input`에는 중첩 실행 컨텍스트 내부에서 파싱된 구조화 페이로드가 포함됩니다. +- `input_builder=...`를 사용하면 구조화된 도구 인수가 중첩 에이전트 입력으로 변환되는 방식을 완전히 사용자 지정할 수 있습니다. +- `RunContextWrapper.tool_input`은 중첩 실행 컨텍스트 내부에 파싱된 구조화 페이로드를 포함합니다. ```python from pydantic import BaseModel, Field @@ -641,19 +704,19 @@ translator_tool = translator_agent.as_tool( ) ``` -실행 가능한 전체 코드 예제는 `examples/agent_patterns/agents_as_tools_structured.py`를 참고하세요. +완전한 실행 가능 코드 예제는 `examples/agent_patterns/agents_as_tools_structured.py`를 참조하세요. ### 도구 에이전트의 승인 게이트 -`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요하면 실행이 일시 중지되고 보류 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`를 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 실행을 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참고하세요. +`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요한 경우 실행이 일시 중지되고 보류 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`를 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 실행을 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참조하세요. ### 사용자 지정 출력 추출 -경우에 따라 도구 에이전트의 출력을 중앙 에이전트에 반환하기 전에 수정할 수 있습니다. 다음과 같은 경우에 유용합니다. +경우에 따라 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 상황에서 유용합니다. - 하위 에이전트의 채팅 기록에서 특정 정보(예: JSON 페이로드)를 추출 -- 에이전트의 최종 답변을 변환하거나 형식 변경(예: Markdown을 일반 텍스트 또는 CSV로 변환) -- 출력의 유효성을 검사하거나 에이전트 응답이 없거나 형식이 잘못된 경우 대체 값 제공 +- 에이전트의 최종 답변을 변환하거나 형식을 변경(예: Markdown을 일반 텍스트 또는 CSV로 변환) +- 출력을 검증하거나 에이전트 응답이 없거나 형식이 잘못된 경우 대체 값 제공 `as_tool` 메서드에 `custom_output_extractor` 인수를 제공하여 이를 수행할 수 있습니다. @@ -674,9 +737,9 @@ json_tool = data_agent.as_tool( ) ``` -사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 중첩된 결과를 후처리하면서 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참고하세요. +사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 이는 중첩된 결과를 후처리하는 동안 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참조하세요. -### 중첩 에이전트 실행 스트리밍 +### 중첩 에이전트 실행의 스트리밍 `as_tool`에 `on_stream` 콜백을 전달하면 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하면서도 스트림이 완료된 후 최종 출력을 반환할 수 있습니다. @@ -698,11 +761,11 @@ billing_agent_tool = billing_agent.as_tool( 예상 동작: -- 이벤트 유형은 `StreamEvent["type"]`의 `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event`와 동일합니다. -- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드에서 실행되고, 최종 출력을 반환하기 전에 스트림이 모두 처리됩니다. -- 핸들러는 동기 또는 비동기일 수 있으며, 각 이벤트는 도착하는 순서대로 전달됩니다. +- 이벤트 유형은 `StreamEvent["type"]`을 따릅니다: `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event` +- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드에서 실행되고, 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. +- 핸들러는 동기식 또는 비동기식일 수 있으며, 각 이벤트는 도착하는 순서대로 전달됩니다. - 모델 도구 호출을 통해 도구가 호출되면 `tool_call`이 존재합니다. 직접 호출에서는 `None`일 수 있습니다. -- 실행 가능한 전체 샘플은 `examples/agent_patterns/agents_as_tools_streaming.py`를 참고하세요. +- 완전한 실행 가능 코드 예제는 `examples/agent_patterns/agents_as_tools_streaming.py`를 참조하세요. ### 조건부 도구 활성화 @@ -754,8 +817,8 @@ orchestrator = Agent( ) async def main(): - context = RunContextWrapper(LanguageContext(language_preference="french_spanish")) - result = await Runner.run(orchestrator, "How are you?", context=context.context) + context = LanguageContext(language_preference="french_spanish") + result = await Runner.run(orchestrator, "How are you?", context=context) print(result.final_output) asyncio.run(main()) @@ -767,18 +830,18 @@ asyncio.run(main()) - **호출 가능 함수**: `(context, agent)`를 받아 불리언 값을 반환하는 함수 - **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 -비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 용도로 유용합니다. +비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 용도에 유용합니다. - 사용자 권한에 따른 기능 게이팅 - 환경별 도구 가용성(개발 환경과 프로덕션 환경) -- 서로 다른 도구 구성에 대한 A/B 테스트 +- 서로 다른 도구 구성의 A/B 테스트 - 런타임 상태에 따른 동적 도구 필터링 ## 실험적 기능: Codex 도구 -`codex_tool`은 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 작업 공간 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있게 합니다. 이 기능은 실험적이며 변경될 수 있습니다. +`codex_tool`은 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있도록 합니다. 이 기능은 실험적이며 변경될 수 있습니다. -기본 에이전트가 현재 실행을 벗어나지 않고 범위가 제한된 작업 공간 작업을 Codex에 위임하도록 하려면 이 도구를 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 이름은 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함된 경우 각 도구는 고유한 이름을 사용해야 합니다. +현재 실행을 벗어나지 않고 기본 에이전트가 범위가 제한된 워크스페이스 작업을 Codex에 위임하도록 하려면 이 도구를 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함된 경우 각 도구는 고유한 이름을 사용해야 합니다. ```python from agents import Agent @@ -809,31 +872,31 @@ agent = Agent( 다음 옵션 그룹부터 시작하세요. -- 실행 범위: `sandbox_mode`와 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`를 설정하세요. -- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 노력 수준, 승인 정책, 추가 디렉터리, 네트워크 액세스, 웹 검색 모드를 구성합니다. 기존 `web_search_enabled`보다 `web_search_mode`를 우선 사용하세요. +- 실행 표면: `sandbox_mode` 및 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고, 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`를 설정하세요. +- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 강도, 승인 정책, 추가 디렉터리, 네트워크 액세스 및 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`를 우선 사용하세요. - 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal`과 같은 턴별 동작을 구성합니다. -- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }` 형식의 `inputs` 항목이 하나 이상 포함되어야 합니다. `output_schema`를 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. +- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }`가 포함된 `inputs` 항목이 하나 이상 있어야 합니다. `output_schema`를 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. -스레드 재사용과 지속성은 별도의 제어 항목입니다. +스레드 재사용과 영속성은 별도의 제어 항목입니다. -- `persist_session=True`는 동일한 도구 인스턴스를 반복 호출할 때 하나의 Codex 스레드를 재사용합니다. +- `persist_session=True`는 동일한 도구 인스턴스에 대한 반복 호출에서 하나의 Codex 스레드를 재사용합니다. - `use_run_context_thread_id=True`는 동일한 변경 가능 컨텍스트 객체를 공유하는 여러 실행에서 실행 컨텍스트에 스레드 ID를 저장하고 재사용합니다. - 스레드 ID의 우선순위는 호출별 `thread_id`, 실행 컨텍스트 스레드 ID(활성화된 경우), 구성된 `thread_id` 옵션 순입니다. - 기본 실행 컨텍스트 키는 `name="codex"`일 때 `codex_thread_id`이고, `name="codex_"`일 때 `codex_thread_id_`입니다. `run_context_thread_id_key`를 사용하여 재정의할 수 있습니다. 런타임 구성: -- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`를 전달합니다. +- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`를 전달하세요. - 런타임: `codex_options.base_url`은 CLI 기본 URL을 재정의합니다. -- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`를 설정합니다. 그렇지 않으면 SDK는 `PATH`에서 `codex`를 찾은 다음 번들로 제공되는 벤더 바이너리를 대신 사용합니다. +- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`를 설정하세요. 그렇지 않으면 SDK는 `PATH`에서 `codex`를 확인한 후 번들로 제공되는 벤더 바이너리를 대체 경로로 사용합니다. - 환경: `codex_options.env`는 하위 프로세스 환경을 완전히 제어합니다. 이 옵션이 제공되면 하위 프로세스는 `os.environ`을 상속하지 않습니다. -- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`~`67108864`이며 기본값은 `8388608`입니다. +- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`~`67108864`이며, 기본값은 `8388608`입니다. - 스트리밍: `on_stream`은 스레드/턴 수명 주기 이벤트와 항목 이벤트(`reasoning`, `command_execution`, `mcp_tool_call`, `file_change`, `web_search`, `todo_list`, `error` 항목 업데이트)를 수신합니다. - 출력: 결과에는 `response`, `usage`, `thread_id`가 포함되며, 사용량은 `RunContextWrapper.usage`에 추가됩니다. 참조: -- [Codex 도구 API 레퍼런스](ref/extensions/experimental/codex/codex_tool.md) -- [ThreadOptions 레퍼런스](ref/extensions/experimental/codex/thread_options.md) -- [TurnOptions 레퍼런스](ref/extensions/experimental/codex/turn_options.md) -- 실행 가능한 전체 샘플은 `examples/tools/codex.py`와 `examples/tools/codex_same_thread.py`를 참고하세요. \ No newline at end of file +- [Codex 도구 API 참조](ref/extensions/experimental/codex/codex_tool.md) +- [ThreadOptions 참조](ref/extensions/experimental/codex/thread_options.md) +- [TurnOptions 참조](ref/extensions/experimental/codex/turn_options.md) +- 완전한 실행 가능 코드 예제는 `examples/tools/codex.py` 및 `examples/tools/codex_same_thread.py`를 참조하세요. \ No newline at end of file diff --git a/docs/ko/tracing.md b/docs/ko/tracing.md index c68e0f3930..9e28e7b3b7 100644 --- a/docs/ko/tracing.md +++ b/docs/ko/tracing.md @@ -4,51 +4,51 @@ search: --- # 트레이싱 -Agents SDK에는 기본 제공 트레이싱 기능이 포함되어 있어 에이전트 실행 중 발생하는 이벤트(LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지)를 포괄적으로 기록합니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고 시각화하며 모니터링할 수 있습니다. +Agents SDK에는 에이전트 실행 중 발생하는 LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지 포괄적으로 기록하는 트레이싱 기능이 기본 제공됩니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고 시각화하며 모니터링할 수 있습니다. !!!note - 트레이싱은 기본적으로 활성화되어 있습니다. 일반적으로 다음 세 가지 방법으로 비활성화할 수 있습니다. + 트레이싱은 기본적으로 활성화되어 있습니다. 다음과 같은 세 가지 일반적인 방법으로 비활성화할 수 있습니다. - 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역적으로 비활성화할 수 있습니다. - 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]를 사용하여 트레이싱을 전역적으로 비활성화할 수 있습니다. - 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다. + 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역으로 비활성화할 수 있습니다 + 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]를 사용하여 트레이싱을 전역으로 비활성화할 수 있습니다 + 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다 ***OpenAI API를 사용하면서 제로 데이터 보존(Zero Data Retention, ZDR) 정책에 따라 운영되는 조직에서는 트레이싱을 사용할 수 없습니다.*** ## 트레이스와 스팬 -- **트레이스**는 하나의 "워크플로"에 대한 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 갖습니다. - - `workflow_name`: 논리적 워크플로 또는 앱입니다. 예를 들면 "코드 생성" 또는 "고객 서비스"입니다. +- **트레이스**는 하나의 "워크플로"에서 수행되는 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 갖습니다. + - `workflow_name`: 논리적 워크플로나 앱입니다. 예를 들어 "코드 생성" 또는 "고객 서비스"입니다. - `trace_id`: 트레이스의 고유 ID입니다. 전달하지 않으면 자동으로 생성됩니다. 형식은 `trace_<32_alphanumeric>`이어야 합니다. - - `group_id`: 동일한 대화의 여러 트레이스를 연결하기 위한 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. + - `group_id`: 동일한 대화의 여러 트레이스를 연결하는 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. - `disabled`: True이면 트레이스가 기록되지 않습니다. - `metadata`: 트레이스의 선택적 메타데이터입니다. -- **스팬**은 시작 시간과 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음 항목이 있습니다. +- **스팬**은 시작 및 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음 항목이 있습니다. - `started_at` 및 `ended_at` 타임스탬프 - 자신이 속한 트레이스를 나타내는 `trace_id` - - 이 스팬의 상위 스팬을 가리키는 `parent_id`(있는 경우) + - 이 스팬의 부모 스팬을 가리키는 `parent_id`(있는 경우) - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트에 관한 정보가 포함되고, `GenerationSpanData`에는 LLM 생성에 관한 정보가 포함됩니다. ## 기본 트레이싱 SDK는 기본적으로 다음 항목을 트레이싱합니다. -- 전체 `Runner.{run, run_sync, run_streamed}()`이 `trace()`로 래핑됩니다. -- 각 실행기 호출이 `task_span()`으로 래핑됩니다. -- 각 모델 턴이 `turn_span()`으로 래핑됩니다. -- 에이전트가 실행될 때마다 `agent_span()`으로 래핑됩니다. -- LLM 생성이 `generation_span()`으로 래핑됩니다. -- 각 함수 도구 호출이 `function_span()`으로 래핑됩니다. -- 가드레일이 `guardrail_span()`으로 래핑됩니다. -- 핸드오프가 `handoff_span()`으로 래핑됩니다. -- 오디오 입력(음성 텍스트 변환)이 `transcription_span()`으로 래핑됩니다. -- 오디오 출력(텍스트 음성 변환)이 `speech_span()`으로 래핑됩니다. -- 관련 오디오 스팬은 `speech_group_span()` 아래의 하위 스팬으로 구성될 수 있습니다. +- 전체 `Runner.{run, run_sync, run_streamed}()`은 `trace()`로 래핑됩니다. +- 각 러너 호출은 `task_span()`으로 래핑됩니다. +- 각 모델 턴은 `turn_span()`으로 래핑됩니다. +- 에이전트가 실행될 때마다 `agent_span()`으로 래핑됩니다 +- LLM 생성은 `generation_span()`으로 래핑됩니다 +- 각 함수 도구 호출은 `function_span()`으로 래핑됩니다 +- 가드레일은 `guardrail_span()`으로 래핑됩니다 +- 핸드오프는 `handoff_span()`으로 래핑됩니다 +- 오디오 입력(음성-텍스트 변환)은 `transcription_span()`으로 래핑됩니다 +- 오디오 출력(텍스트-음성 변환)은 `speech_span()`으로 래핑됩니다 +- 관련 오디오 스팬은 `speech_group_span()` 아래에 배치될 수 있습니다 -기본 트레이스 이름은 "에이전트 워크플로"입니다. `trace`를 사용하는 경우 이 이름을 설정하거나, [`RunConfig`][agents.run.RunConfig]를 사용하여 이름과 기타 속성을 구성할 수 있습니다. +기본적으로 트레이스의 이름은 "Agent workflow"입니다. `trace`를 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]를 사용하여 이름과 기타 속성을 구성할 수도 있습니다. -더 간결한 계층 구조가 필요하다면 실행에 대한 자동 작업 및 턴 스팬을 비활성화하세요. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. +더 간결한 계층 구조를 원한다면 실행에 대한 자동 태스크 및 턴 스팬을 비활성화합니다. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -또한 트레이스를 다른 대상으로 전송하도록 [사용자 지정 트레이스 프로세서](#custom-tracing-processors)를 설정할 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 추가할 수 있습니다. +또한 [사용자 지정 트레이스 프로세서](#custom-tracing-processors)를 설정하여 트레이스를 다른 대상으로 보낼 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 추가할 수 있습니다. ## 장기 실행 워커와 즉시 내보내기 -기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 메모리 내 큐가 크기 트리거에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 작업과 같은 장기 실행 워커에서는 별도 코드 없이도 일반적으로 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후 트레이스 대시보드에 표시되지 않을 수 있습니다. +기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 메모리 내 큐가 크기 트리거에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 태스크와 같은 장기 실행 워커에서는 일반적으로 추가 코드 없이 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후 트레이스 대시보드에 표시되지 않을 수 있습니다. -작업 단위가 끝날 때 즉시 전송되는 것을 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]를 호출하세요. +작업 단위가 끝날 때 즉시 전달되도록 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]를 호출합니다. ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces]는 현재 버퍼링된 트레이스와 스팬이 내보내질 때까지 실행을 차단합니다. 따라서 부분적으로 생성된 트레이스가 플러시되지 않도록 `trace()`가 종료된 후 호출하세요. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. +[`flush_traces()`][agents.tracing.flush_traces]는 현재 버퍼링된 트레이스와 스팬을 모두 내보낼 때까지 실행을 차단하므로, 일부만 생성된 트레이스가 플러시되지 않도록 `trace()`가 종료된 후 호출합니다. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. ## 상위 수준 트레이스 -경우에 따라 여러 `run()` 호출을 하나의 트레이스에 포함하고 싶을 수 있습니다. 전체 코드를 `trace()`로 래핑하면 됩니다. +여러 `run()` 호출을 하나의 트레이스에 포함하려는 경우가 있습니다. 전체 코드를 `trace()`로 래핑하면 됩니다. ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 두 `Runner.run` 호출이 `with trace()`로 래핑되어 있으므로, 두 개의 트레이스를 생성하는 대신 개별 실행이 전체 트레이스에 포함됩니다. +1. 두 `Runner.run` 호출이 `with trace()`로 래핑되므로, 각 실행이 별도의 트레이스 두 개를 생성하는 대신 전체 트레이스의 일부가 됩니다. ## 트레이스 생성 [`trace()`][agents.tracing.trace] 함수를 사용하여 트레이스를 생성할 수 있습니다. 트레이스는 시작하고 종료해야 합니다. 다음 두 가지 방법을 사용할 수 있습니다. 1. **권장**: 트레이스를 컨텍스트 관리자로 사용합니다. 즉, `with trace(...) as my_trace`를 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. -2. [`trace.start()`][agents.tracing.Trace.start]와 [`trace.finish()`][agents.tracing.Trace.finish]를 직접 호출할 수도 있습니다. +2. [`trace.start()`][agents.tracing.Trace.start]와 [`trace.finish()`][agents.tracing.Trace.finish]를 수동으로 호출할 수도 있습니다. -현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하거나 종료하는 경우 현재 트레이스를 업데이트하려면 `start()`/`finish()`에 `mark_as_current`와 `reset_current`를 전달해야 합니다. +현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 수동으로 시작하거나 종료하는 경우 현재 트레이스를 업데이트하려면 `start()`/`finish()`에 `mark_as_current`와 `reset_current`를 전달해야 합니다. ## 스팬 생성 -다양한 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 직접 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적하기 위한 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. +여러 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 수동으로 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적할 수 있도록 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. -스팬은 자동으로 현재 트레이스의 일부가 되며, Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적되는 가장 가까운 현재 스팬 아래에 중첩됩니다. +스팬은 자동으로 현재 트레이스에 포함되며, Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적되는 가장 가까운 현재 스팬 아래에 중첩됩니다. ## 민감한 데이터 -일부 스팬은 잠재적으로 민감한 데이터를 캡처할 수 있습니다. +특정 스팬에는 민감할 수 있는 데이터가 캡처될 수 있습니다. -`generation_span()`은 LLM 생성의 입력과 출력을 저장하고, `function_span()`은 함수 호출의 입력과 출력을 저장합니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터 캡처를 비활성화할 수 있습니다. +`generation_span()`은 LLM 생성의 입출력을 저장하고, `function_span()`은 함수 호출의 입출력을 저장합니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. -마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 Base64 인코딩된 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터 캡처를 비활성화할 수 있습니다. +마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. -기본적으로 `trace_include_sensitive_data`는 `True`입니다. 코드를 사용하지 않고 기본값을 설정하려면 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 됩니다. +기본적으로 `trace_include_sensitive_data`는 `True`입니다. 코드 없이 기본값을 설정하려면 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 됩니다. ## 사용자 지정 트레이싱 프로세서 트레이싱의 상위 수준 아키텍처는 다음과 같습니다. -- 초기화 시 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.setup.TraceProvider]를 생성합니다. -- 트레이스와 스팬을 일괄 처리하여 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]로 보내는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]로 `TraceProvider`를 구성합니다. `BackendSpanExporter`는 스팬과 트레이스를 OpenAI 백엔드로 일괄 내보냅니다. +- 초기화할 때 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.setup.TraceProvider]를 생성합니다. +- [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]에 트레이스와 스팬을 배치로 전송하는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]로 `TraceProvider`를 구성합니다. `BackendSpanExporter`는 스팬과 트레이스를 OpenAI 백엔드로 배치 단위로 내보냅니다. -대체 또는 추가 백엔드로 트레이스를 보내거나 내보내기 동작을 수정하는 등 이 기본 설정을 사용자 지정하는 방법은 두 가지입니다. +트레이스를 대체 또는 추가 백엔드로 전송하거나 내보내기 동작을 변경하는 등 기본 설정을 사용자 지정하려면 다음 두 가지 방법을 사용할 수 있습니다. -1. [`add_trace_processor()`][agents.tracing.add_trace_processor]를 사용하면 준비되는 트레이스와 스팬을 수신할 **추가** 트레이스 프로세서를 등록할 수 있습니다. 이를 통해 OpenAI 백엔드로 트레이스를 보내는 동시에 자체 처리를 수행할 수 있습니다. -2. [`set_trace_processors()`][agents.tracing.set_trace_processors]를 사용하면 기본 프로세서를 자체 트레이스 프로세서로 **대체**할 수 있습니다. 이 경우 트레이스를 전송하는 `TracingProcessor`를 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. +1. [`add_trace_processor()`][agents.tracing.add_trace_processor]를 사용하면 준비되는 트레이스와 스팬을 수신하는 **추가** 트레이스 프로세서를 추가할 수 있습니다. 따라서 OpenAI 백엔드로 트레이스를 전송하는 동시에 자체 처리를 수행할 수 있습니다. +2. [`set_trace_processors()`][agents.tracing.set_trace_processors]를 사용하면 기본 프로세서를 자체 트레이스 프로세서로 **교체**할 수 있습니다. 이 경우 OpenAI 백엔드로 전송하는 `TracingProcessor`를 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. -## OpenAI 이외 모델의 트레이싱 +## OpenAI 이외 모델을 사용한 트레이싱 -OpenAI 이외 모델에서 OpenAI API 키를 사용하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 사용할 수 있습니다. 어댑터 선택과 설정 시 유의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참조하세요. +OpenAI 이외 모델에 OpenAI API 키를 사용하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 활성화할 수 있습니다. 어댑터 선택 및 설정 시 주의 사항은 모델 가이드의 [서드파티 어댑터](models/index.md#third-party-adapters) 섹션을 참고하세요. ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -단일 실행에만 다른 트레이싱 키가 필요하다면 전역 내보내기를 변경하는 대신 `RunConfig`를 통해 전달하세요. +단일 실행에만 다른 트레이싱 키가 필요한 경우 전역 내보내기를 변경하는 대신 `RunConfig`를 통해 전달합니다. ```python from agents import Runner, RunConfig @@ -201,9 +201,9 @@ await Runner.run( - OpenAI 트레이스 대시보드에서 무료 트레이스를 확인할 수 있습니다. -## 생태계 통합 +## 에코시스템 통합 -다음 커뮤니티 및 벤더 통합은 OpenAI Agents SDK 트레이싱 인터페이스를 지원합니다. +다음 커뮤니티 및 공급업체 통합은 OpenAI Agents SDK의 트레이싱 인터페이스를 지원합니다. ### 외부 트레이싱 프로세서 목록 diff --git a/docs/ko/visualization.md b/docs/ko/visualization.md index 9493ab665c..87fd0821f7 100644 --- a/docs/ko/visualization.md +++ b/docs/ko/visualization.md @@ -28,11 +28,12 @@ pip install "openai-agents[viz]" ```python import os -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph -@function_tool +@tool def get_weather(city: str) -> str: return f"The weather in {city} is sunny." diff --git a/docs/ko/voice/quickstart.md b/docs/ko/voice/quickstart.md index 4d0742e1d7..6a6a525da4 100644 --- a/docs/ko/voice/quickstart.md +++ b/docs/ko/voice/quickstart.md @@ -53,15 +53,13 @@ graph LR ```python import random -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") @@ -132,10 +130,8 @@ import random import numpy as np import sounddevice as sd -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.voice import ( AudioInput, SingleAgentVoiceWorkflow, @@ -144,7 +140,7 @@ from agents.voice import ( from agents.extensions.handoff_prompt import prompt_with_handoff_instructions -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") diff --git a/docs/models/index.md b/docs/models/index.md index 930a7a1418..2c274ae141 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -110,15 +110,16 @@ Preview-compatible requests must serialize `environment` and display dimensions If you pass a non–GPT-5 model name without custom `model_settings`, the SDK reverts to generic `ModelSettings` compatible with any model. -### Responses-only tool search features +### Responses-only tool features The following tool features are supported only with OpenAI Responses models: - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] - `@function_tool(defer_loading=True)` and other deferred-loading Responses tool surfaces +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers`, and `tool_choice="programmatic_tool_calling"` -These features are rejected on Chat Completions models and on non-Responses backends. When you use deferred-loading tools, add `ToolSearchTool()` to the agent and let the model load tools through `auto` or `required` tool choice instead of forcing bare namespace names or deferred-only function names. See [Tools](../tools.md#hosted-tool-search) for the setup details and current constraints. +These features are rejected on Chat Completions models and on non-Responses backends. When you use deferred-loading tools, add `ToolSearchTool()` to the agent and let the model load tools through `auto` or `required` tool choice instead of forcing bare namespace names or deferred-only function names. See [Hosted tool search](../tools.md#hosted-tool-search) and [Programmatic Tool Calling](../tools.md#programmatic-tool-calling) for setup details and current constraints. ### Responses WebSocket transport @@ -264,11 +265,11 @@ Use `get_hosted_agent_metadata()` when a tool needs caller-aware logging or auth ```python from typing import Any -from agents import function_tool +from agents.decorators import tool from agents.extensions.experimental.hosted_multi_agent import get_hosted_agent_metadata from agents.tool_context import ToolContext -@function_tool +@tool def lookup_document(ctx: ToolContext[Any], section: str) -> str: metadata = get_hosted_agent_metadata(ctx) caller = metadata.agent_name if metadata else "unknown" diff --git a/docs/quickstart.md b/docs/quickstart.md index 68b37c441d..ed3534d3db 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -110,10 +110,11 @@ You can give an agent tools to look up information or perform actions. ```python import asyncio -from agents import Agent, Runner, function_tool +from agents import Agent, Runner +from agents.decorators import tool -@function_tool +@tool def history_fun_fact() -> str: """Return a short history fact.""" return "Sharks are older than trees." diff --git a/docs/realtime/guide.md b/docs/realtime/guide.md index ca32d254e3..47ac550a76 100644 --- a/docs/realtime/guide.md +++ b/docs/realtime/guide.md @@ -210,10 +210,10 @@ The Twilio example in [`examples/realtime/twilio/twilio_handler.py`](https://git Realtime agents support function tools during live conversations: ```python -from agents import function_tool +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Get current weather for a city.""" return f"The weather in {city} is sunny, 72F." diff --git a/docs/ref/extensions/sandbox/vercel/mounts.md b/docs/ref/extensions/sandbox/vercel/mounts.md new file mode 100644 index 0000000000..ff0a47f72b --- /dev/null +++ b/docs/ref/extensions/sandbox/vercel/mounts.md @@ -0,0 +1,3 @@ +# `Mounts` + +::: agents.extensions.sandbox.vercel.mounts diff --git a/docs/ref/run_internal/tool_caller.md b/docs/ref/run_internal/tool_caller.md new file mode 100644 index 0000000000..778a45f159 --- /dev/null +++ b/docs/ref/run_internal/tool_caller.md @@ -0,0 +1,3 @@ +# `Tool Caller` + +::: agents.run_internal.tool_caller diff --git a/docs/ref/sandbox/session/pty_output.md b/docs/ref/sandbox/session/pty_output.md new file mode 100644 index 0000000000..e9a17d2f6f --- /dev/null +++ b/docs/ref/sandbox/session/pty_output.md @@ -0,0 +1,3 @@ +# `Pty Output` + +::: agents.sandbox.session.pty_output diff --git a/docs/release.md b/docs/release.md index 112765535e..472bf5db39 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,6 +19,19 @@ We will increment `Z` for non-breaking changes: ## Breaking change changelog +### 0.19.0 + +This minor release does **not** introduce a breaking change. The minor version bump reflects a significant new OpenAI Responses feature area: Programmatic Tool Calling. + +Highlights: + +- Added [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], which lets supported OpenAI Responses models generate JavaScript to coordinate eligible tools. It supports per-tool `allowed_callers`, structured function-tool outputs, and integration with Runner streaming, guardrails, approvals, sessions, and `RunState`. See [Programmatic Tool Calling](tools.md#programmatic-tool-calling) for setup and constraints. +- Added the public `agents.decorators` module and the shorter `@tool` alias alongside the existing function and guardrail decorators. Function tools now also support async callable objects. +- SDK configuration now consistently accepts either typed settings objects or dictionaries across agents, runs, models, sessions, sandboxes, and voice pipelines, with validation for unknown settings. +- Hardened error and diagnostic logging across models, tools, MCP, Realtime, sessions, sandboxes, and tracing to avoid exposing raw sensitive payloads while preserving useful debugging context. +- Improved AnyLLM, LiteLLM, and Chat Completions compatibility, preserved session history across model retries, and added retries for WebSocket overloads that occur before a response starts. +- Added [create-time-only S3 mounts for Vercel sandboxes](sandbox/clients.md#mounts-and-remote-storage) through `VercelCloudBucketMountStrategy`. Mounted sessions exclude bucket contents from workspace persistence and intentionally do not support dynamic mount changes or session resume. + ### 0.18.0 This minor release does **not** introduce a breaking change. The minor version bump is for the Realtime agents default model update only. diff --git a/docs/results.md b/docs/results.md index bec6eda01c..1e1aa86a66 100644 --- a/docs/results.md +++ b/docs/results.md @@ -45,7 +45,7 @@ These surfaces answer different questions: | Property or helper | What it contains | Best for | | --- | --- | --- | | [`input`][agents.result.RunResultBase.input] | The base input for this run segment. If a handoff input filter rewrote the history, this reflects the filtered input the run continued with. | Auditing what this run actually used as input | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | An input-item view of the run. The default `mode="preserve_all"` keeps the full converted history from `new_items`; `mode="normalized"` prefers canonical continuation input when handoff filtering rewrites model history. | Manual chat loops, client-managed conversation state, and plain-item history inspection | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | An input-item view of the run. The default `mode="preserve_all"` keeps the converted history from `new_items`, except it does not append an exact session item occurrence already moved into SDK-default nested handoff history a second time; `mode="normalized"` prefers canonical continuation input when handoff filtering rewrites model history. | Manual chat loops, client-managed conversation state, and plain-item history inspection | | [`new_items`][agents.result.RunResultBase.new_items] | Rich [`RunItem`][agents.items.RunItem] wrappers with agent, tool, handoff, and approval metadata. | Logs, UIs, audits, and debugging | | [`raw_responses`][agents.result.RunResultBase.raw_responses] | Raw [`ModelResponse`][agents.items.ModelResponse] objects from each model call in the run. | Provider-level diagnostics or raw response inspection | @@ -57,6 +57,8 @@ In practice: - If you are using OpenAI server-managed state with `conversation_id` or `previous_response_id`, usually pass only the new user input and reuse the stored ID instead of resending `to_input_list()`. - Use the default `to_input_list()` mode or `new_items` when you need the full converted history for logs, UIs, or audits. +When SDK-default nested handoff history preserves a message item verbatim, Sessions, `RunState`, and `to_input_list()` track the exact owned occurrence rather than deduplicating by content. Identical messages that occurred separately remain separate; only the already-owned occurrence is kept from being appended a second time. + Unlike the JavaScript SDK, Python does not expose a separate `output` property for the model-shaped delta only. Use `new_items` when you need SDK metadata, or inspect `raw_responses` when you need the raw model payloads. Computer-tool replay follows the raw Responses payload shape. Preview-model `computer_call` items preserve a single `action`, while `gpt-5.5` computer calls can preserve batched `actions[]`. [`to_input_list()`][agents.result.RunResultBase.to_input_list] and [`RunState`][agents.run_state.RunState] keep whichever shape the model produced, so manual replay, pause/resume flows, and stored transcripts continue to work across both preview and GA computer-tool calls. Local execution results still appear as `computer_call_output` items in `new_items`. @@ -70,12 +72,39 @@ Computer-tool replay follows the raw Responses payload shape. Preview-model `com - [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] and [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] for Responses tool search requests and loaded tool-search results - [`ToolCallItem`][agents.items.ToolCallItem] and [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] for tool calls and their results - [`ToolApprovalItem`][agents.items.ToolApprovalItem] for tool calls that paused for approval +- [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem], [`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem], and [`MCPListToolsItem`][agents.items.MCPListToolsItem] for hosted MCP approvals and tool catalogs - [`HandoffCallItem`][agents.items.HandoffCallItem] and [`HandoffOutputItem`][agents.items.HandoffOutputItem] for handoff requests and completed transfers Choose `new_items` over `to_input_list()` whenever you need agent associations, tool outputs, handoff boundaries, or approval boundaries. When you use hosted tool search, inspect `ToolSearchCallItem.raw_item` to see the search request the model emitted, and `ToolSearchOutputItem.raw_item` to see which namespaces, functions, or hosted MCP servers were loaded for that turn. +With Programmatic Tool Calling, the generated `program` is a `ToolCallItem`, ordinary child tool calls owned by that program are also `ToolCallItem` entries, and the matching `program_output` is a `ToolCallOutputItem`. Program-owned hosted MCP `mcp_approval_request` and `mcp_list_tools` items are exceptions: they become `MCPApprovalRequestItem` and `MCPListToolsItem` entries. + +Raw items can be typed Responses objects or mappings. In particular, program-owned shell and apply-patch calls use mappings. Use a mapping-safe inspection pattern: + +```python +from collections.abc import Mapping + + +def raw_field(item, name): + raw_item = item.raw_item + if isinstance(raw_item, Mapping): + return raw_item.get(name) + return getattr(raw_item, name, None) + + +raw_type = raw_field(item, "type") +caller = raw_field(item, "caller") +caller_id = ( + caller.get("caller_id") + if isinstance(caller, Mapping) + else getattr(caller, "caller_id", None) +) +``` + +For a program-owned child call, `caller` has type `program`, and `caller_id` identifies the parent program call. + ## Continue or resume the conversation ### Next-turn agent diff --git a/docs/running_agents.md b/docs/running_agents.md index 98033988a7..866c50bec9 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -139,8 +139,8 @@ Use `RunConfig` to override behavior for a single run without changing each agen - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: A list of input or output guardrails to include on all runs. - [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: A global input filter to apply to all handoffs, if the handoff doesn't already have one. The input filter allows you to edit the inputs that are sent to the new agent. See the documentation in [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] for more details. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: Opt-in beta that collapses the prior transcript into a single assistant message before invoking the next agent. This is disabled by default while we stabilize nested handoffs; set to `True` to enable or leave `False` to pass through the raw transcript. All [Runner methods][agents.run.Runner] automatically create a `RunConfig` when you do not pass one, so the quickstarts and examples keep the default off, and any explicit [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] callbacks continue to override it. Individual handoffs can override this setting via [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: Optional callable that receives the normalized transcript (history + handoff items) whenever you opt in to `nest_handoff_history`. It must return the exact list of input items to forward to the next agent, allowing you to replace the built-in summary without writing a full handoff filter. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: Opt-in beta that compacts summarizable history into ordered assistant summary segments while preserving lossless message items in their original positions before invoking the next agent. This is disabled by default while we stabilize nested handoffs; set to `True` to enable or leave `False` to pass through the raw transcript. Sessions, `RunState`, and `RunResult.to_input_list()` avoid appending an exact message occurrence twice when the SDK-default nested history already owns it, while preserving separate identical messages. All [Runner methods][agents.run.Runner] automatically create a `RunConfig` when you do not pass one, so the quickstarts and examples keep the default off, and any explicit [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] callbacks continue to override it. Individual handoffs can override this setting via [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: Optional callable that receives the normalized transcript (history + handoff items) whenever you opt in to `nest_handoff_history`. It must return the exact list of input items to forward to the next agent, replacing the built-in ordered summary segments without writing a full handoff filter. - [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: Hook to edit the fully prepared model input (instructions and input items) immediately before the model call, e.g., to trim history or inject a system prompt. - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Control whether reasoning item IDs are preserved or omitted when the runner converts prior outputs into next-turn model input. @@ -158,7 +158,7 @@ Use `RunConfig` to override behavior for a single run without changing each agen - [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: Configure how the runner handles unresolved function tool calls emitted by the model. The default raises `ModelBehaviorError`; opt in to return a model-visible error output instead. - [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: Customize model-visible tool error messages, such as approval rejections and opt-in tool-not-found outputs. -Nested handoffs are available as an opt-in beta. Enable the collapsed-transcript behavior by passing `RunConfig(nest_handoff_history=True)` or set `handoff(..., nest_handoff_history=True)` to turn it on for a specific handoff. If you prefer to keep the raw transcript (the default), leave the flag unset or provide a `handoff_input_filter` (or `handoff_history_mapper`) that forwards the conversation exactly as you need. To change the wrapper text used in the generated summary without writing a custom mapper, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] (and [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] to restore the defaults). +Nested handoffs are available as an opt-in beta. Enable ordered transcript compaction by passing `RunConfig(nest_handoff_history=True)` or set `handoff(..., nest_handoff_history=True)` to turn it on for a specific handoff. The built-in mapper places generated assistant summary segments around lossless message items instead of collapsing the whole transcript into one message. If you prefer to keep the raw transcript (the default), leave the flag unset or provide a `handoff_input_filter` (or `handoff_history_mapper`) that forwards the conversation exactly as you need. To change the wrapper text used in generated summary segments without writing a custom mapper, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] (and [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] to restore the defaults). #### Run config details diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index bd21da63d3..60614261a8 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -113,7 +113,7 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac | `DaytonaSandboxClient` | Supports rclone-backed cloud storage mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | | `E2BSandboxClient` | Supports rclone-backed cloud storage mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | | `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | -| `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. | +| `VercelSandboxClient` | Supports create-time-only S3 and S3-compatible bucket mounts with `VercelCloudBucketMountStrategy` on `S3Mount`; mounted sessions cannot be resumed, and inline credentials require `allow_s3_credential_exposure=True`. | @@ -130,7 +130,7 @@ The table below summarizes which remote storage entries each backend can mount d | `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | -| `VercelSandboxClient` | - | - | - | - | - | - | +| `VercelSandboxClient` | ✓ | - | - | - | - | - | diff --git a/docs/streaming.md b/docs/streaming.md index ad0cd9e620..0d82d64a31 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -89,14 +89,17 @@ If you are manually continuing from [`result.to_input_list(mode="normalized")`][ When you use hosted tool search, `tool_search_called` is emitted when the model issues a tool-search request and `tool_search_output_created` is emitted when the Responses API returns the loaded subset. +With Programmatic Tool Calling, `tool_called` is emitted for the generated `program` and for ordinary program-owned child tool calls. `tool_output` is emitted for child tool outputs and the matching `program_output`. Program-owned hosted MCP `mcp_approval_request` and `mcp_list_tools` items are exceptions: they are emitted as `mcp_approval_requested` and `mcp_list_tools`, wrapping [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] and [`MCPListToolsItem`][agents.items.MCPListToolsItem], respectively. Inspect the raw item's `type` to distinguish the remaining items; program-owned child calls also carry a `caller` whose type is `program` and whose caller ID identifies the parent program. + For example, this will ignore raw events and stream updates to the user. ```python import asyncio import random -from agents import Agent, ItemHelpers, Runner, function_tool +from agents import Agent, ItemHelpers, Runner +from agents.decorators import tool -@function_tool +@tool def how_many_jokes() -> int: return random.randint(1, 10) diff --git a/docs/tools.md b/docs/tools.md index 42c1ff22b0..14d5ef0d6b 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -16,6 +16,7 @@ Use this page as a catalog, then jump to the section that matches the runtime yo | --- | --- | | Use OpenAI-managed tools (web search, file search, code interpreter, hosted MCP, image generation) | [Hosted tools](#hosted-tools) | | Defer large tool surfaces until runtime with tool search | [Hosted tool search](#hosted-tool-search) | +| Coordinate several tool calls from generated JavaScript | [Programmatic Tool Calling](#programmatic-tool-calling) | | Run tools in your own process or environment | [Local runtime tools](#local-runtime-tools) | | Wrap Python functions as tools | [Function tools](#function-tools) | | Let one agent call another without a handoff | [Agents as tools](#agents-as-tools) | @@ -31,6 +32,7 @@ OpenAI offers a few built-in tools when using the [`OpenAIResponsesModel`][agent - The [`HostedMCPTool`][agents.tool.HostedMCPTool] exposes a remote MCP server's tools to the model. - The [`ImageGenerationTool`][agents.tool.ImageGenerationTool] generates images from a prompt. - The [`ToolSearchTool`][agents.tool.ToolSearchTool] lets the model load deferred tools, namespaces, or hosted MCP servers on demand. +- The [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] lets the model coordinate eligible tools from generated JavaScript. Advanced hosted search options: @@ -65,10 +67,11 @@ Start with hosted tool search when the candidate tools are already known when yo ```python from typing import Annotated -from agents import Agent, Runner, ToolSearchTool, function_tool, tool_namespace +from agents import Agent, Runner, ToolSearchTool, tool_namespace +from agents.decorators import tool -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_customer_profile( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -76,7 +79,7 @@ def get_customer_profile( return f"profile for {customer_id}" -@function_tool(defer_loading=True) +@tool(defer_loading=True) def list_open_orders( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -119,6 +122,60 @@ What to know: - See `examples/tools/tool_search.py` for complete runnable examples covering both namespaced loading and top-level deferred tools. - Official platform guide: [Tool search](https://developers.openai.com/api/docs/guides/tools-tool-search). +### Programmatic Tool Calling + +Programmatic Tool Calling lets a supported OpenAI Responses model generate JavaScript that calls eligible tools, combines their outputs, and returns one result to the model. It is useful for bounded workflows that benefit from loops, branching, parallel calls, or intermediate calculations without a model round trip after every tool call. + +The generated program runs in a fresh hosted V8 environment. It does not have Node.js APIs, filesystem or network access, or a persistent process. The program can interact only with tools that you explicitly allow. + +```python +from pydantic import BaseModel + +from agents import ( + Agent, + ModelSettings, + ProgrammaticToolCallingTool, + Runner, +) +from agents.decorators import tool + + +class InventoryOutput(BaseModel): + sku: str + available_units: int + + +@tool(allowed_callers=["programmatic"]) +def get_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + +agent = Agent( + name="Inventory planner", + model="gpt-5.6", + model_settings=ModelSettings(tool_choice="programmatic_tool_calling"), + tools=[get_inventory, ProgrammaticToolCallingTool()], +) + +result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it.") +print(result.final_output) +``` + +What to know: + +- Programmatic Tool Calling is available only with supported OpenAI Responses models. `ProgrammaticToolCallingTool()` and `tool_choice="programmatic_tool_calling"` are rejected by Chat Completions models and non-Responses backends. +- Add at most one `ProgrammaticToolCallingTool()` to an agent. The agent must also expose at least one programmatically callable tool, a `ToolSearchTool()` backed by a namespace, deferred function, or deferred hosted MCP server, or an opaque prompt-managed tool surface. A bare `ToolSearchTool()` without a searchable surface is rejected. +- `allowed_callers` controls how a tool may be invoked. Omitting it allows direct model calls only. Use `["programmatic"]` for program-only access or `["direct", "programmatic"]` to allow both. +- SDK tool types that can opt in are `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, and `CodeInterpreterTool`. Function, custom, shell, and apply-patch tools expose `allowed_callers` directly. For hosted MCP and code interpreter, set `allowed_callers` inside `tool_config`. +- For `@function_tool(allowed_callers=[...])`, a structured return annotation such as a Pydantic model, TypedDict, or dataclass automatically becomes a strict object output schema and is validated before the value is returned to the program. Use `output_type=...` when the function has no usable annotation, or the lower-level `output_json_schema={...}` escape hatch when you already have a strict object schema. `output_type` and `output_json_schema` are mutually exclusive. Plain `str`, `Any`, and `None` returns remain untyped. For a schema-backed program-owned call, the default failure formatter is disabled because its free-form text does not satisfy the output schema. A handler exception therefore propagates unless you provide a custom `failure_error_function` that returns schema-conforming JSON. +- Program-owned SDK tools still use the normal Runner lifecycle. Tool input and output guardrails, hooks, timeouts, concurrency limits, approvals, sessions, and `RunState` pause/resume behavior continue to apply, and the SDK preserves each child call's program caller relationship. +- Model-request retries use a stricter replay-safety boundary whenever `ProgrammaticToolCallingTool()` is present, even before a program executes. The SDK disables provider-managed retries and WebSocket pre-event retries for these requests. A Runner retry policy retries only when provider advice explicitly marks the replay safe; `retry_policies.network_error()` by itself does not override this boundary. +- Approval-sensitive or high-impact tools are usually better kept as direct calls so a person can review each action before it becomes part of a larger program. If a program-owned call pauses for approval, resolve the interruption through `RunState` and resume the original run as usual. +- Programmatic Tool Calling can be combined with [hosted tool search](#hosted-tool-search). The model must load deferred tools before a generated program can call them. +- A `program` item and its ordinary program-owned child tool calls appear as [`ToolCallItem`][agents.items.ToolCallItem] entries. The matching `program_output` appears as a [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]. Hosted MCP approval requests and tool catalogs use specialized MCP items and stream events instead. See [Results](results.md#new-items) and [Streaming](streaming.md#run-item-event-names) for inspection details. +- See `examples/tools/programmatic_tool_calling.py` for a complete concurrent inventory-planning example. +- Official platform guide: [Programmatic Tool Calling](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling). + ### Hosted container shell + skills `ShellTool` also supports OpenAI-hosted container execution. Use this mode when you want the model to run shell commands in a managed container instead of your local runtime. @@ -259,14 +316,15 @@ import json from typing_extensions import TypedDict, Any -from agents import Agent, FunctionTool, RunContextWrapper, function_tool +from agents import Agent, FunctionTool, RunContextWrapper +from agents.decorators import tool class Location(TypedDict): lat: float long: float -@function_tool # (1)! +@tool # (1)! async def fetch_weather(location: Location) -> str: # (2)! """Fetch the weather for a given location. @@ -278,7 +336,7 @@ async def fetch_weather(location: Location) -> str: return "sunny" -@function_tool(name_override="fetch_data") # (3)! +@tool(name_override="fetch_data") # (3)! def read_file(ctx: RunContextWrapper[Any], path: str, directory: str | None = None) -> str: """Read the contents of a file. @@ -432,7 +490,7 @@ tool = FunctionTool( As mentioned before, we automatically parse the function signature to extract the schema for the tool, and we parse the docstring to extract descriptions for the tool and for individual arguments. Some notes on that: 1. The signature parsing is done via the `inspect` module. We use type annotations to understand the types for the arguments, and dynamically build a Pydantic model to represent the overall schema. It supports most types, including Python primitives, Pydantic models, TypedDicts, and more. -2. We use `griffe` to parse docstrings. Supported docstring formats are `google`, `sphinx` and `numpy`. We attempt to automatically detect the docstring format, but this is best-effort and you can explicitly set it when calling `function_tool`. You can also disable docstring parsing by setting `use_docstring_info` to `False`. +2. We use `griffe` to parse docstrings. Supported docstring formats are `google`, `sphinx` and `numpy`. We attempt to automatically detect the docstring format, but this is best-effort and you can explicitly set it when calling `function_tool`. You can also disable docstring parsing by setting `use_docstring_info` to `False`. For Google-style docstrings, the parser also accepts an `Args:`, `Arguments:`, `Params:`, or `Parameters:` section immediately after summary text without an intervening blank line. The code for the schema extraction lives in [`agents.function_schema`][]. @@ -443,15 +501,15 @@ You can use Pydantic's [`Field`](https://docs.pydantic.dev/latest/concepts/field ```python from typing import Annotated from pydantic import Field -from agents import function_tool +from agents.decorators import tool # Default-based form -@function_tool +@tool def score_a(score: int = Field(..., ge=0, le=100, description="Score from 0 to 100")) -> str: return f"Score recorded: {score}" # Annotated form -@function_tool +@tool def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score from 0 to 100")]) -> str: return f"Score recorded: {score}" ``` @@ -462,10 +520,11 @@ You can set per-call timeouts for async function tools with `@function_tool(time ```python import asyncio -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(timeout=2.0) +@tool(timeout=2.0) async def slow_lookup(query: str) -> str: await asyncio.sleep(10) return f"Result for {query}" @@ -488,10 +547,11 @@ You can control timeout handling: ```python import asyncio -from agents import Agent, Runner, ToolTimeoutError, function_tool +from agents import Agent, Runner, ToolTimeoutError +from agents.decorators import tool -@function_tool(timeout=1.5, timeout_behavior="raise_exception") +@tool(timeout=1.5, timeout_behavior="raise_exception") async def slow_tool() -> str: await asyncio.sleep(5) return "done" @@ -518,7 +578,8 @@ When you create a function tool via `@function_tool`, you can pass a `failure_er - If you explicitly pass `None`, then any tool call errors will be re-raised for you to handle. This could be a `ModelBehaviorError` if the model produced invalid JSON, or a `UserError` if your code crashed, etc. ```python -from agents import function_tool, RunContextWrapper +from agents import RunContextWrapper +from agents.decorators import tool from typing import Any def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) -> str: @@ -526,7 +587,7 @@ def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) print(f"A tool call failed with the following error: {error}") return "An internal server error occurred. Please try again later." -@function_tool(failure_error_function=my_custom_error_function) +@tool(failure_error_function=my_custom_error_function) def get_user_profile(user_id: str) -> str: """Fetches a user profile from a mock API. This function demonstrates a 'flaky' or failing API call. @@ -593,7 +654,10 @@ The `agent.as_tool` function is a convenience method to make it easy to turn an The state options configure the nested agent run started by the tool call; the parent run's conversation state is not inherited automatically. To share client-managed history between the parent and nested runs, explicitly pass the same `session` to both. As with `Runner.run`, choose one state strategy for the nested run: a client-managed `session`, or server-managed continuation through `previous_response_id` or `conversation_id`. ```python -@function_tool +from agents.decorators import tool + + +@tool async def run_my_agent() -> str: """A tool that runs the agent with custom configs""" diff --git a/docs/visualization.md b/docs/visualization.md index acac9e6403..c3fa6c8da6 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -24,11 +24,12 @@ You can generate an agent visualization using the `draw_graph` function. This fu ```python import os -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph -@function_tool +@tool def get_weather(city: str) -> str: return f"The weather in {city} is sunny." diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index ec0f571a34..125c14998e 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -49,15 +49,12 @@ First, let's set up some Agents. This should feel familiar to you if you've buil ```python import random -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions - -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") @@ -128,10 +125,8 @@ import random import numpy as np import sounddevice as sd -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.voice import ( AudioInput, SingleAgentVoiceWorkflow, @@ -140,7 +135,7 @@ from agents.voice import ( from agents.extensions.handoff_prompt import prompt_with_handoff_instructions -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") diff --git a/docs/zh/agents.md b/docs/zh/agents.md index 4babc27135..9fbe88c1dd 100644 --- a/docs/zh/agents.md +++ b/docs/zh/agents.md @@ -4,54 +4,55 @@ search: --- # 智能体 -智能体是应用中的核心构建模块。智能体是一个配置了指令、工具以及任务转移、安全防护措施和structured outputs等可选运行时行为的大语言模型(LLM)。 +智能体是应用中的核心构建块。智能体是配置了指令、工具以及可选运行时行为(例如任务转移、安全防护措施和 structured outputs)的大语言模型(LLM)。 -当你需要定义或自定义单个普通`Agent`时,请使用本页面。如果你正在考虑多个智能体应如何协作,请阅读[智能体编排](multi_agent.md)。如果智能体应在具有清单定义文件和沙箱原生能力的隔离工作区中运行,请阅读[沙箱智能体概念](sandbox/guide.md)。 +当你希望定义或自定义单个普通`Agent`时,请使用本页面。如果你正在决定多个智能体应如何协作,请阅读[智能体编排](multi_agent.md)。如果智能体应在具有清单定义文件和沙箱原生能力的隔离工作区中运行,请阅读[沙箱智能体概念](sandbox/guide.md)。 -对于OpenAI模型,SDK默认使用Responses API,但这里的区别在于编排:`Agent`与`Runner`让SDK为你管理轮次、工具、安全防护措施、任务转移和会话。如果你想自行控制该循环,请改为直接使用Responses API。 +对于OpenAI模型,SDK默认使用 Responses API,但这里的区别在于编排方式:`Agent`加`Runner`可让 SDK 代你管理轮次、工具、安全防护措施、任务转移和会话。如果你希望自行管理该循环,请直接使用 Responses API。 ## 后续指南选择 -将本页面用作智能体定义的入口。根据你接下来需要作出的决策,前往相应的邻近指南。 +请将本页面作为定义智能体的中心入口。根据下一步需要做出的决策,前往相应的相邻指南。 -| 如果你想要…… | 接下来阅读 | +| 如果你希望…… | 接下来阅读 | | --- | --- | | 选择模型或提供商配置 | [模型](models/index.md) | | 为智能体添加能力 | [工具](tools.md) | | 让智能体针对真实代码仓库、文档包或隔离工作区运行 | [沙箱智能体快速入门](sandbox_agents.md) | -| 在管理器式编排与任务转移之间作出选择 | [智能体编排](multi_agent.md) | +| 在管理器式编排和任务转移之间做出选择 | [智能体编排](multi_agent.md) | | 配置任务转移行为 | [任务转移](handoffs.md) | -| 运行轮次、流式传输事件或管理会话状态 | [运行智能体](running_agents.md) | +| 运行轮次、流式传输事件或管理对话状态 | [运行智能体](running_agents.md) | | 检查最终输出、运行项或可恢复状态 | [结果](results.md) | | 共享本地依赖项和运行时状态 | [上下文管理](context.md) | -## 基础配置 +## 基本配置 智能体最常见的属性包括: -| 属性 | 必需 | 描述 | +| 属性 | 必需 | 说明 | | --- | --- | --- | -| `name` | 是 | 人类可读的智能体名称。 | +| `name` | 是 | 易于理解的智能体名称。 | | `instructions` | 否 | 系统提示词或动态指令回调。强烈建议设置。请参阅[动态指令](#dynamic-instructions)。 | -| `prompt` | 否 | OpenAI Responses API提示词配置。接受静态提示词对象或函数。请参阅[提示词模板](#prompt-templates)。 | -| `handoff_description` | 否 | 当此智能体作为任务转移目标提供时公开的简短描述。 | -| `handoffs` | 否 | 将会话委派给专业智能体。请参阅[任务转移](handoffs.md)。 | +| `prompt` | 否 | OpenAI Responses API 提示词配置。接受静态提示词对象或函数。请参阅[提示词模板](#prompt-templates)。 | +| `handoff_description` | 否 | 当此智能体作为任务转移目标提供时展示的简短说明。 | +| `handoffs` | 否 | 将对话委派给专业智能体。请参阅[任务转移](handoffs.md)。 | | `model` | 否 | 要使用的LLM。请参阅[模型](models/index.md)。 | | `model_settings` | 否 | 模型调优参数,例如`temperature`、`top_p`和`tool_choice`。 | | `tools` | 否 | 智能体可以调用的工具。请参阅[工具](tools.md)。 | -| `mcp_servers` | 否 | 由MCP支持的智能体工具。请参阅[MCP指南](mcp.md)。 | -| `mcp_config` | 否 | 微调MCP工具的准备方式,例如严格模式转换和MCP失败格式。请参阅[MCP指南](mcp.md#agent-level-mcp-configuration)。 | -| `input_guardrails` | 否 | 针对此智能体链的首个用户输入运行的安全防护措施。请参阅[安全防护措施](guardrails.md)。 | +| `mcp_servers` | 否 | 智能体使用的 MCP 支持工具。请参阅[MCP 指南](mcp.md)。 | +| `mcp_config` | 否 | 微调 MCP 工具的准备方式,例如严格模式的 schema 转换和 MCP 失败信息格式。请参阅[MCP 指南](mcp.md#agent-level-mcp-configuration)。 | +| `input_guardrails` | 否 | 针对此智能体链首次用户输入运行的安全防护措施。请参阅[安全防护措施](guardrails.md)。 | | `output_guardrails` | 否 | 针对此智能体最终输出运行的安全防护措施。请参阅[安全防护措施](guardrails.md)。 | -| `output_type` | 否 | 使用结构化输出类型,而不是纯文本。请参阅[输出类型](#output-types)。 | -| `hooks` | 否 | 智能体作用域的生命周期回调。请参阅[生命周期事件(钩子)](#lifecycle-events-hooks)。 | -| `tool_use_behavior` | 否 | 控制工具结果是返回模型继续处理,还是结束运行。请参阅[工具使用行为](#tool-use-behavior)。 | -| `reset_tool_choice` | 否 | 在工具调用后重置`tool_choice`(默认值:`True`),以避免工具使用循环。请参阅[强制使用工具](#forcing-tool-use)。 | +| `output_type` | 否 | 使用结构化输出类型,而非纯文本。请参阅[输出类型](#output-types)。 | +| `hooks` | 否 | 作用于智能体范围的生命周期回调。请参阅[生命周期事件(钩子)](#lifecycle-events-hooks)。 | +| `tool_use_behavior` | 否 | 控制工具结果是返回模型继续处理,还是结束本次运行。请参阅[工具使用行为](#tool-use-behavior)。 | +| `reset_tool_choice` | 否 | 在工具调用后重置`tool_choice`(默认值:`True`),以避免工具使用循环。请参阅[工具的强制使用](#forcing-tool-use)。 | ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -64,13 +65,13 @@ agent = Agent( ) ``` -本节中的所有内容都适用于`Agent`。`SandboxAgent`基于相同理念构建,并额外提供`default_manifest`、`base_instructions`、`capabilities`和`run_as`,用于限定在工作区范围内的运行。请参阅[沙箱智能体概念](sandbox/guide.md)。 +本节中的所有内容都适用于`Agent`。`SandboxAgent`基于相同理念构建,并额外添加了`default_manifest`、`base_instructions`、`capabilities`和`run_as`,用于工作区范围的运行。请参阅[沙箱智能体概念](sandbox/guide.md)。 ## 提示词模板 -你可以通过设置`prompt`引用在OpenAI平台中创建的提示词模板。此功能适用于通过Responses API使用OpenAI模型的情况。 +你可以通过设置`prompt`来引用在OpenAI平台中创建的提示词模板。此功能适用于使用 Responses API 的OpenAI模型。 -请按以下步骤使用: +使用步骤如下: 1. 前往 https://platform.openai.com/playground/prompts 2. 创建一个新的提示词变量`poem_style`。 @@ -95,7 +96,7 @@ agent = Agent( ) ``` -你还可以在运行时动态生成提示词: +你也可以在运行时动态生成提示词: ```python from dataclasses import dataclass @@ -127,9 +128,9 @@ result = await Runner.run( ## 上下文 -智能体的`context`类型支持泛型。上下文是一种依赖注入工具:它是你创建并传递给`Runner.run()`的对象,会被传递给每个智能体、工具、任务转移等,并作为智能体运行所需依赖项和状态的集合。你可以提供任意Python对象作为上下文。 +智能体的`context`类型是泛型。上下文是一种依赖注入工具:它是由你创建并传递给`Runner.run()`的对象,随后会被传递给每个智能体、工具和任务转移等,并作为智能体运行所需依赖项和状态的集合。你可以将任意 Python 对象作为上下文提供。 -请阅读[上下文指南](context.md),了解完整的`RunContextWrapper`接口、共享用量跟踪、嵌套的`tool_input`以及序列化注意事项。 +有关完整的`RunContextWrapper`功能、共享使用量追踪、嵌套`tool_input`以及序列化注意事项,请阅读[上下文指南](context.md)。 ```python from dataclasses import dataclass @@ -155,7 +156,7 @@ agent = Agent[UserContext]( ## 输出类型 -默认情况下,智能体生成纯文本(即`str`)输出。如果你希望智能体生成特定类型的输出,可以使用`output_type`参数。常见做法是使用[Pydantic](https://docs.pydantic.dev/)对象,但我们支持能够封装在Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)中的任何类型,包括数据类、列表、TypedDict等。 +默认情况下,智能体生成纯文本(即`str`)输出。如果你希望智能体生成特定类型的输出,可以使用`output_type`参数。常见选择是使用[Pydantic](https://docs.pydantic.dev/)对象,但我们支持任何可封装在 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)中的类型,例如数据类、列表、TypedDict 等。 ```python from pydantic import BaseModel @@ -176,20 +177,20 @@ agent = Agent( !!! note - 传入`output_type`会指示模型使用[structured outputs](https://platform.openai.com/docs/guides/structured-outputs),而不是常规纯文本响应。 + 当你传入`output_type`时,即表示要求模型使用[structured outputs](https://platform.openai.com/docs/guides/structured-outputs),而不是常规的纯文本响应。 ## 多智能体系统设计模式 -多智能体系统有许多设计方式,但我们通常会看到两种广泛适用的模式: +多智能体系统有许多设计方式,但我们通常会看到两种具有广泛适用性的模式: -1. 管理器(agents as tools):中央管理器/编排器将专业子智能体作为工具调用,并保留对会话的控制权。 -2. 任务转移:对等智能体将控制权转移给接管会话的专业智能体。这是一种去中心化模式。 +1. 管理器(agents as tools):由中央管理器/编排器将专业子智能体作为工具调用,并保留对话控制权。 +2. 任务转移:对等智能体将控制权转移给接管对话的专业智能体。这是一种去中心化模式。 -有关更多详细信息,请参阅我们的[智能体构建实用指南](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)。 +有关更多详细信息,请参阅[构建智能体的实用指南](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)。 ### 管理器(agents as tools) -`customer_facing_agent`负责处理所有用户交互,并调用作为工具公开的专业子智能体。请在[工具](tools.md#agents-as-tools)文档中了解更多信息。 +`customer_facing_agent`负责处理所有用户交互,并调用以工具形式公开的专业子智能体。请在[工具](tools.md#agents-as-tools)文档中了解更多信息。 ```python from agents import Agent @@ -218,7 +219,7 @@ customer_facing_agent = Agent( ### 任务转移 -任务转移是智能体可以委派给的子智能体。发生任务转移时,被委派的智能体会接收会话历史记录并接管会话。此模式支持模块化的专业智能体,让其专注并擅长单一任务。请在[任务转移](handoffs.md)文档中了解更多信息。 +任务转移是智能体可以委派任务的子智能体。发生任务转移时,被委派的智能体会接收对话历史记录并接管对话。此模式支持模块化的专业智能体,使其能够出色完成单一任务。请在[任务转移](handoffs.md)文档中了解更多信息。 ```python from agents import Agent @@ -239,7 +240,7 @@ triage_agent = Agent( ## 动态指令 -在大多数情况下,你可以在创建智能体时提供指令。不过,你也可以通过函数提供动态指令。该函数会接收智能体和上下文,并且必须返回提示词。普通函数和`async`函数均可使用。 +大多数情况下,你可以在创建智能体时提供指令。不过,你也可以通过函数提供动态指令。该函数将接收智能体和上下文,并且必须返回提示词。普通函数和`async`函数均可使用。 ```python def dynamic_instructions( @@ -256,26 +257,26 @@ agent = Agent[UserContext]( ## 生命周期事件(钩子) -有时,你需要观察智能体的生命周期。例如,你可能希望在特定事件发生时记录事件日志、预取数据或记录用量。 +有时,你可能希望观察智能体的生命周期。例如,你可能希望在特定事件发生时记录事件日志、预取数据或记录使用量。 -钩子有两个作用域: +钩子有两种作用域: -- [`RunHooks`][agents.lifecycle.RunHooks]观察整个`Runner.run(...)`调用,包括向其他智能体进行的任务转移。 -- [`AgentHooks`][agents.lifecycle.AgentHooks]通过`agent.hooks`附加到特定智能体实例。 +- [`RunHooks`][agents.lifecycle.RunHooks]观察整个`Runner.run(...)`调用,包括向其他智能体的任务转移。 +- [`AgentHooks`][agents.lifecycle.AgentHooks]通过`agent.hooks`附加到特定的智能体实例。 回调上下文也会因事件而异: -- 智能体开始/结束钩子接收[`AgentHookContext`][agents.run_context.AgentHookContext],它封装原始上下文并携带共享的运行用量状态。 +- 智能体开始/结束钩子接收[`AgentHookContext`][agents.run_context.AgentHookContext],它会封装你的原始上下文,并携带共享的运行使用量状态。 - LLM、工具和任务转移钩子接收[`RunContextWrapper`][agents.run_context.RunContextWrapper]。 典型的钩子触发时机: -- `on_agent_start` / `on_agent_end`:特定智能体开始或完成最终输出的生成时。 +- `on_agent_start` / `on_agent_end`:特定智能体开始或完成最终输出生成时。 - `on_llm_start` / `on_llm_end`:每次模型调用前后立即触发。 - `on_tool_start` / `on_tool_end`:每次本地工具调用前后触发。对于工具调用,钩子的`context`通常是`ToolContext`,因此你可以检查`tool_call_id`等工具调用元数据。 - `on_handoff`:控制权从一个智能体转移到另一个智能体时。 -如果你希望使用单个观察者监控整个工作流,请使用`RunHooks`;如果某个智能体需要自定义副作用,请使用`AgentHooks`。 +如果你希望使用单个观察器监控整个工作流,请使用`RunHooks`;如果某个智能体需要自定义副作用,请使用`AgentHooks`。 ```python from agents import Agent, RunHooks, Runner @@ -297,15 +298,15 @@ result = await Runner.run(agent, "Explain quines", hooks=LoggingHooks()) print(result.final_output) ``` -有关完整的回调接口,请参阅[生命周期API参考](ref/lifecycle.md)。 +有关完整的回调功能,请参阅[生命周期 API 参考](ref/lifecycle.md)。 ## 安全防护措施 -安全防护措施允许你在智能体运行的同时,并行检查/验证用户输入,并在智能体生成输出后检查其输出。例如,你可以检查用户输入和智能体输出的相关性。请在[安全防护措施](guardrails.md)文档中了解更多信息。 +安全防护措施允许你在智能体运行的同时并行检查/验证用户输入,并在智能体生成输出后检查其输出。例如,你可以筛查用户输入和智能体输出的相关性。请在[安全防护措施](guardrails.md)文档中了解更多信息。 ## 智能体的克隆/复制 -通过对智能体使用`clone()`方法,你可以复制智能体,并可选择更改任意属性。 +通过对智能体使用`clone()`方法,你可以复制一个智能体,并可选择更改任意属性。 ```python pirate_agent = Agent( @@ -320,21 +321,22 @@ robot_agent = pirate_agent.clone( ) ``` -## 强制使用工具 +## 工具的强制使用 提供工具列表并不总是意味着LLM会使用工具。你可以通过设置[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]强制使用工具。有效值包括: -1. `auto`,允许LLM决定是否使用工具。 -2. `required`,要求LLM使用工具(但它可以智能地决定使用哪个工具)。 +1. `auto`,允许LLM自行决定是否使用工具。 +2. `required`,要求LLM使用工具(但可以智能地决定使用哪个工具)。 3. `none`,要求LLM_不_使用工具。 4. 设置特定字符串,例如`my_tool`,要求LLM使用该特定工具。 -使用OpenAI Responses工具搜索时,具名工具选择的限制更多:你不能通过`tool_choice`将裸命名空间名称或仅延迟加载的工具设为目标,并且`tool_choice="tool_search"`不会以[`ToolSearchTool`][agents.tool.ToolSearchTool]为目标。在这些情况下,建议使用`auto`或`required`。有关Responses特有的限制,请参阅[托管工具搜索](tools.md#hosted-tool-search)。 +使用 OpenAI Responses 工具搜索时,按名称指定工具的选择方式受到更多限制:你不能通过`tool_choice`指定裸命名空间名称或仅延迟加载的工具,而且`tool_choice="tool_search"`不会指定[`ToolSearchTool`][agents.tool.ToolSearchTool]。在这些情况下,建议使用`auto`或`required`。有关 Responses 特有的限制,请参阅[托管工具搜索](tools.md#hosted-tool-search)。 ```python -from agents import Agent, function_tool, ModelSettings +from agents import Agent, ModelSettings +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -351,13 +353,14 @@ agent = Agent( `Agent`配置中的`tool_use_behavior`参数控制工具输出的处理方式: -- `"run_llm_again"`:默认行为。执行工具后,由LLM处理结果并生成最终响应。 -- `"stop_on_first_tool"`:将首次工具调用的输出用作最终响应,不再由LLM进一步处理。 +- `"run_llm_again"`:默认行为。运行工具后,由LLM处理结果并生成最终响应。 +- `"stop_on_first_tool"`:将第一个工具调用的输出用作最终响应,不再由LLM进一步处理。 ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -373,15 +376,16 @@ agent = Agent( - `StopAtTools(stop_at_tool_names=[...])`:如果调用了任何指定工具,则停止运行,并将其输出用作最终响应。 ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.agent import StopAtTools -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" -@function_tool +@tool def sum_numbers(a: int, b: int) -> int: """Adds two numbers.""" return a + b @@ -394,14 +398,15 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`:处理工具结果并决定是停止还是继续调用LLM的自定义函数。 +- `ToolsToFinalOutputFunction`:用于处理工具结果,并决定是停止还是交由LLM继续处理的自定义函数。 ```python -from agents import Agent, function_tool, FunctionToolResult, RunContextWrapper +from agents import Agent, FunctionToolResult, RunContextWrapper +from agents.decorators import tool from agents.agent import ToolsToFinalOutputResult from typing import List, Any -@function_tool +@tool def get_weather(city: str) -> str: """Returns weather info for the specified city.""" return f"The weather in {city} is sunny" @@ -432,4 +437,4 @@ agent = Agent( !!! note - 为防止无限循环,框架会在工具调用后自动将`tool_choice`重置为"auto"。此行为可通过[`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]进行配置。出现无限循环的原因是,工具结果会发送给LLM,而LLM随后又会因`tool_choice`生成另一个工具调用,如此无限循环。 \ No newline at end of file + 为防止无限循环,框架会在工具调用后自动将`tool_choice`重置为“auto”。此行为可通过[`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]配置。之所以会发生无限循环,是因为工具结果会发送给LLM,随后LLM由于`tool_choice`而再次生成工具调用,如此无限重复。 \ No newline at end of file diff --git a/docs/zh/context.md b/docs/zh/context.md index 9fcfd96b88..c8f273f046 100644 --- a/docs/zh/context.md +++ b/docs/zh/context.md @@ -52,14 +52,15 @@ search: import asyncio from dataclasses import dataclass -from agents import Agent, RunContextWrapper, Runner, function_tool +from agents import Agent, RunContextWrapper, Runner +from agents.decorators import tool @dataclass class UserInfo: # (1)! name: str uid: int -@function_tool +@tool async def fetch_user_age(wrapper: RunContextWrapper[UserInfo]) -> str: # (2)! """Fetch the age of the user. Call this function to get user's age information.""" return f"The user {wrapper.context.name} is 47 years old" @@ -101,7 +102,8 @@ if __name__ == "__main__": ```python from typing import Annotated from pydantic import BaseModel, Field -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.tool_context import ToolContext class WeatherContext(BaseModel): @@ -112,7 +114,7 @@ class Weather(BaseModel): temperature_range: str = Field(description="The temperature range in Celsius") conditions: str = Field(description="The weather conditions") -@function_tool +@tool def get_weather(ctx: ToolContext[WeatherContext], city: Annotated[str, "The city to get the weather for"]) -> Weather: print(f"[debug] Tool context: (name: {ctx.tool_name}, call_id: {ctx.tool_call_id}, args: {ctx.tool_arguments})") return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") diff --git a/docs/zh/examples.md b/docs/zh/examples.md index 42279e20b7..7dc394e948 100644 --- a/docs/zh/examples.md +++ b/docs/zh/examples.md @@ -4,77 +4,77 @@ search: --- # 代码示例 -请查看[代码仓库](https://github.com/openai/openai-agents-python/tree/main/examples)的 examples 部分,了解 SDK 的各种示例实现。这些示例分为多个目录,展示了不同的模式和功能。 +请查看[仓库](https://github.com/openai/openai-agents-python/tree/main/examples)的 examples 目录,了解 SDK 的各种示例实现。这些代码示例分为多个目录,展示了不同的模式和功能。 ## 目录 -- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):**此目录中的示例展示了常见的智能体设计模式,例如 +- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** 此目录中的代码示例展示了常见的智能体设计模式,例如 - 确定性工作流 - Agents as tools - - 具有流式事件的Agents as tools(`examples/agent_patterns/agents_as_tools_streaming.py`) - - 具有结构化输入参数的Agents as tools(`examples/agent_patterns/agents_as_tools_structured.py`) + - 具备流式传输事件的Agents as tools(`examples/agent_patterns/agents_as_tools_streaming.py`) + - 具备结构化输入参数的Agents as tools(`examples/agent_patterns/agents_as_tools_structured.py`) - 并行执行智能体 - - 按条件使用工具 + - 有条件地使用工具 - 以不同的行为强制使用工具(`examples/agent_patterns/forcing_tool_use.py`) - 输入/输出安全防护措施 - - 由 LLM 充当评判者 + - LLM作为评审 - 路由 - - 流式安全防护措施 - - 采用工具审批和状态序列化的人机协同(`examples/agent_patterns/human_in_the_loop.py`) - - 采用流式传输的人机协同(`examples/agent_patterns/human_in_the_loop_stream.py`) + - 流式传输安全防护措施 + - 通过工具审批和状态序列化实现人机协同(`examples/agent_patterns/human_in_the_loop.py`) + - 通过流式传输实现人机协同(`examples/agent_patterns/human_in_the_loop_stream.py`) - 审批流程的自定义拒绝消息(`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) -- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):**这些示例展示了 SDK 的基础功能,例如 +- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** 这些代码示例展示了 SDK 的基础功能,例如 - - Hello world 示例(默认模型、GPT-5、开放权重模型) + - Hello world代码示例(默认模型、GPT-5、开放权重模型) - 智能体生命周期管理 - - 运行钩子和智能体钩子的生命周期示例(`examples/basic/lifecycle_example.py`) + - 运行钩子和智能体钩子的生命周期代码示例(`examples/basic/lifecycle_example.py`) - 动态系统提示词 - - 基本工具使用方式(`examples/basic/tools.py`) + - 基础工具使用(`examples/basic/tools.py`) - 工具输入/输出安全防护措施(`examples/basic/tool_guardrails.py`) - 图像工具输出(`examples/basic/image_tool_output.py`) - - 流式输出(文本、项目、函数调用参数) - - 使用跨轮次共享会话辅助程序的 Responses WebSocket 传输(`examples/basic/stream_ws.py`) + - 流式传输输出(文本、项目、函数调用参数) + - 使用跨轮次共享会话辅助工具的 Responses WebSocket 传输(`examples/basic/stream_ws.py`) - 提示词模板 - - 文件处理(本地和远程、图像和 PDF) - - 使用量追踪 + - 文件处理(本地和远程文件、图像和 PDF) + - 用量追踪 - 由 Runner 管理的重试设置(`examples/basic/retry.py`) - - 通过第三方适配器进行由 Runner 管理的重试(`examples/basic/retry_litellm.py`) + - 通过第三方适配器实现由 Runner 管理的重试(`examples/basic/retry_litellm.py`) - 非严格输出类型 - - 前一个响应 ID 的使用方式 + - 上一响应 ID 的使用 -- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):**航空公司客户服务系统示例。 +- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 航空公司客户服务系统代码示例。 -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):**金融研究智能体,展示了使用智能体和工具进行金融数据分析的结构化研究工作流。 +- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 一个金融研究智能体,展示了使用智能体和工具进行金融数据分析的结构化研究工作流。 -- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):**包含消息筛选的智能体任务转移实用示例,包括: +- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** 包含消息过滤功能的智能体任务转移实用代码示例,包括: - - 消息筛选器示例(`examples/handoffs/message_filter.py`) - - 采用流式传输的消息筛选器(`examples/handoffs/message_filter_streaming.py`) + - 消息过滤器代码示例(`examples/handoffs/message_filter.py`) + - 采用流式传输的消息过滤器(`examples/handoffs/message_filter_streaming.py`) -- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):**展示如何将托管式 MCP(Model Context Protocol)与 OpenAI Responses API 配合使用的示例,包括: +- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** 展示如何结合OpenAI Responses API 使用托管式MCP(Model Context Protocol)的代码示例,包括: - - 无需审批的简单托管式 MCP(`examples/hosted_mcp/simple.py`) - - Google Calendar 等 MCP 连接器(`examples/hosted_mcp/connectors.py`) - - 采用基于中断审批的人机协同(`examples/hosted_mcp/human_in_the_loop.py`) - - MCP 工具调用的审批时回调(`examples/hosted_mcp/on_approval.py`) + - 无需审批的简单托管式MCP(`examples/hosted_mcp/simple.py`) + - Google Calendar 等MCP连接器(`examples/hosted_mcp/connectors.py`) + - 通过基于中断的审批实现人机协同(`examples/hosted_mcp/human_in_the_loop.py`) + - MCP工具调用的审批回调(`examples/hosted_mcp/on_approval.py`) -- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):**了解如何使用 MCP(Model Context Protocol)构建智能体,包括: +- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** 了解如何使用MCP(Model Context Protocol)构建智能体,包括: - - 文件系统示例 - - Git 示例 - - MCP 提示词服务示例 - - SSE(服务器发送事件)示例 + - 文件系统代码示例 + - Git 代码示例 + - MCP提示词服务代码示例 + - SSE(服务发送事件)代码示例 - SSE 远程服务连接(`examples/mcp/sse_remote_example`) - - 可流式传输的 HTTP 示例 - - 可流式传输的 HTTP 远程连接(`examples/mcp/streamable_http_remote_example`) + - 可流式传输 HTTP 代码示例 + - 可流式传输 HTTP 远程连接(`examples/mcp/streamable_http_remote_example`) - 用于可流式传输 HTTP 的自定义 HTTP 客户端工厂(`examples/mcp/streamablehttp_custom_client_example`) - - 使用 `MCPUtil.get_all_function_tools` 预取所有 MCP 工具(`examples/mcp/get_all_mcp_tools_example`) - - 搭配 FastAPI 使用 MCPServerManager(`examples/mcp/manager_example`) - - MCP 工具筛选(`examples/mcp/tool_filter_example`) + - 使用 `MCPUtil.get_all_function_tools` 预取全部MCP工具(`examples/mcp/get_all_mcp_tools_example`) + - 结合 FastAPI 使用MCPServerManager(`examples/mcp/manager_example`) + - MCP工具过滤(`examples/mcp/tool_filter_example`) -- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):**不同智能体记忆实现的示例,包括: +- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** 智能体不同内存实现的代码示例,包括: - SQLite 会话存储 - 高级 SQLite 会话存储 @@ -82,55 +82,56 @@ search: - SQLAlchemy 会话存储 - Dapr 状态存储会话存储 - 加密会话存储 - - OpenAI Conversations 会话存储 + - OpenAI Conversations会话存储 - Responses 压缩会话存储 - 使用 `ModelSettings(store=False)` 的无状态 Responses 压缩(`examples/memory/compaction_session_stateless_example.py`) - 基于文件的会话存储(`examples/memory/file_session.py`) - - 采用人机协同的基于文件会话(`examples/memory/file_hitl_example.py`) - - 采用人机协同的 SQLite 内存会话(`examples/memory/memory_session_hitl_example.py`) - - 采用人机协同的 OpenAI Conversations 会话(`examples/memory/openai_session_hitl_example.py`) + - 支持人机协同的基于文件的会话(`examples/memory/file_hitl_example.py`) + - 支持人机协同的 SQLite 内存会话(`examples/memory/memory_session_hitl_example.py`) + - 支持人机协同的OpenAI Conversations会话(`examples/memory/openai_session_hitl_example.py`) - 跨会话的 HITL 审批/拒绝场景(`examples/memory/hitl_session_scenario.py`) -- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):**探索如何将非 OpenAI 模型与 SDK 配合使用,包括自定义提供商和第三方适配器。 +- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** 探索如何在 SDK 中使用非OpenAI模型,包括自定义提供商和第三方适配器。 -- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):**展示如何使用 SDK 构建实时体验的示例,包括: +- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** 展示如何使用 SDK 构建实时体验的代码示例,包括: - 使用结构化文本和图像消息的 Web 应用模式 - 命令行音频循环和播放处理 - 通过 WebSocket 集成 Twilio Media Streams - - 使用 Realtime Calls API 附加流程集成 Twilio SIP + - 使用 Realtime Calls API 附加流程的 Twilio SIP 集成 -- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):**展示如何处理推理内容的示例,包括: +- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 展示如何处理推理内容的代码示例,包括: - - 通过 Runner API 处理推理内容,包括流式和非流式方式(`examples/reasoning_content/runner_example.py`) + - 使用 Runner API 处理推理内容,支持流式传输与非流式传输(`examples/reasoning_content/runner_example.py`) - 通过 OpenRouter 使用 OSS 模型处理推理内容(`examples/reasoning_content/gpt_oss_stream.py`) - - 基本推理内容示例(`examples/reasoning_content/main.py`) + - 基础推理内容代码示例(`examples/reasoning_content/main.py`) -- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):**简单的深度研究复刻版本,展示了复杂的多智能体研究工作流。 +- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 简单的深度研究复刻项目,展示了复杂的多智能体研究工作流。 -- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):**在隔离工作区中运行智能体的示例,包括: +- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 在隔离工作区中运行智能体的代码示例,包括: - - 基本沙箱智能体设置(`examples/sandbox/basic.py`) - - Unix 本地和 Docker 沙箱生命周期示例 - - 由沙箱支持的任务转移(`examples/sandbox/handoffs.py`) - - 沙箱记忆和快照恢复(`examples/sandbox/memory.py`) + - 基础沙箱智能体设置(`examples/sandbox/basic.py`) + - Unix 本地沙箱和 Docker 沙箱的生命周期代码示例 + - 基于沙箱的任务转移(`examples/sandbox/handoffs.py`) + - 沙箱内存和快照恢复(`examples/sandbox/memory.py`) - 作为工具公开的沙箱智能体(`examples/sandbox/sandbox_agents_as_tools.py`) -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):**了解如何实现由OpenAI托管的工具和实验性 Codex 工具,例如: +- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** 了解如何实现由OpenAI托管的工具和实验性 Codex 工具功能,例如: - - 网络检索和带筛选条件的网络检索 + - 网络检索以及带筛选条件的网络检索 - 文件检索 - Code interpreter - 具备文件编辑和审批功能的补丁应用工具(`examples/tools/apply_patch.py`) - - 具有审批回调的 Shell 工具执行(`examples/tools/shell.py`) - - 采用基于中断的人机协同审批的 Shell 工具(`examples/tools/shell_human_in_the_loop.py`) - - 具有内联技能的托管容器 Shell(`examples/tools/container_shell_inline_skill.py`) - - 具有技能引用的托管容器 Shell(`examples/tools/container_shell_skill_reference.py`) - - 具有本地技能的本地 Shell(`examples/tools/local_shell_skill.py`) - - 使用命名空间和延迟工具的工具搜索(`examples/tools/tool_search.py`) + - 使用审批回调执行 Shell 工具(`examples/tools/shell.py`) + - 通过基于中断的审批实现人机协同的 Shell 工具(`examples/tools/shell_human_in_the_loop.py`) + - 具备内联技能的托管容器 Shell(`examples/tools/container_shell_inline_skill.py`) + - 具备技能引用的托管容器 Shell(`examples/tools/container_shell_skill_reference.py`) + - 具备本地技能的本地 Shell(`examples/tools/local_shell_skill.py`) + - 具备命名空间和延迟加载工具的工具搜索(`examples/tools/tool_search.py`) + - 支持并发结构化工具调用的程序化工具调用(`examples/tools/programmatic_tool_calling.py`) - 计算机操作 - 图像生成 - 实验性 Codex 工具工作流(`examples/tools/codex.py`) - - 实验性 Codex 同线程工作流(`examples/tools/codex_same_thread.py`) + - 实验性 Codex 同一线程工作流(`examples/tools/codex_same_thread.py`) -- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):**查看使用我们的 TTS 和 STT 模型构建语音智能体的示例,包括流式语音示例。 \ No newline at end of file +- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** 查看使用我们的 TTS 和 STT 模型构建语音智能体的代码示例,包括流式语音代码示例。 \ No newline at end of file diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index 8574057e4a..730e832a40 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -83,8 +83,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -140,8 +140,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -196,10 +196,8 @@ from agents import ( Agent, Runner, ToolGuardrailFunctionOutput, - function_tool, - tool_input_guardrail, - tool_output_guardrail, ) +from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail @tool_input_guardrail def block_secrets(data): @@ -219,7 +217,7 @@ def redact_output(data): return ToolGuardrailFunctionOutput.allow() -@function_tool( +@tool( tool_input_guardrails=[block_secrets], tool_output_guardrails=[redact_output], ) diff --git a/docs/zh/handoffs.md b/docs/zh/handoffs.md index b6c9f1997c..d6e45e5eab 100644 --- a/docs/zh/handoffs.md +++ b/docs/zh/handoffs.md @@ -4,21 +4,21 @@ search: --- # 任务转移 -任务转移允许一个智能体将任务委派给另一个智能体。这在不同智能体专精于不同领域的场景中特别有用。例如,一个客户支持应用可能有多个智能体,分别专门处理订单状态、退款、常见问题等任务。 +任务转移允许一个智能体将任务委派给另一个智能体。这在不同智能体分别专注于不同领域的场景中尤其有用。例如,客户支持应用可能包含多个智能体,分别专门处理订单状态、退款、常见问题等任务。 -对 LLM 而言,任务转移表示为工具。因此,如果有一个任务转移到名为 `Refund Agent` 的智能体,对应的工具会被命名为 `transfer_to_refund_agent`。 +任务转移会以工具的形式呈现给LLM。因此,如果要将任务转移给名为 `Refund Agent` 的智能体,该工具将被命名为 `transfer_to_refund_agent`。 ## 任务转移的创建 -所有智能体都有一个 [`handoffs`][agents.agent.Agent.handoffs] 参数,它既可以直接接收一个 `Agent`,也可以接收一个用于自定义任务转移的 `Handoff` 对象。 +所有智能体都有一个 [`handoffs`][agents.agent.Agent.handoffs] 参数,它既可以直接接受 `Agent`,也可以接受用于自定义任务转移的 `Handoff` 对象。 -如果传入普通的 `Agent` 实例,它们的 [`handoff_description`][agents.agent.Agent.handoff_description](如果已设置)会附加到默认工具描述之后。可用它来提示模型何时应选择该任务转移,而无需编写完整的 `handoff()` 对象。 +如果传入普通的 `Agent` 实例,其 [`handoff_description`][agents.agent.Agent.handoff_description](如果已设置)将附加到默认工具描述中。可以使用它来提示模型何时应选择该任务转移,而无须编写完整的 `handoff()` 对象。 -你可以使用 Agents SDK 提供的 [`handoff()`][agents.handoffs.handoff] 函数来创建任务转移。该函数允许你指定要转移到的智能体,并可选择指定覆盖项和输入过滤器。 +你可以使用Agents SDK提供的 [`handoff()`][agents.handoffs.handoff] 函数创建任务转移。此函数允许你指定任务要转移到的智能体,以及可选的覆盖项和输入过滤器。 ### 基本用法 -下面是创建一个简单任务转移的方法: +以下是创建简单任务转移的方法: ```python from agents import Agent, handoff @@ -32,20 +32,20 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun 1. 你可以直接使用智能体(如 `billing_agent`),也可以使用 `handoff()` 函数。 -### 通过 `handoff()` 函数进行的任务转移自定义 +### 通过 `handoff()` 函数自定义任务转移 -[`handoff()`][agents.handoffs.handoff] 函数允许你自定义相关内容。 +[`handoff()`][agents.handoffs.handoff] 函数允许你自定义任务转移。 -- `agent`: 这是任务将被转移到的智能体。 -- `tool_name_override`: 默认情况下会使用 `Handoff.default_tool_name()` 函数,它会解析为 `transfer_to_`。你可以覆盖它。 -- `tool_description_override`: 覆盖来自 `Handoff.default_tool_description()` 的默认工具描述 -- `on_handoff`: 在任务转移被调用时执行的回调函数。这对于在确认任务转移被调用后立即启动某些数据获取等操作很有用。该函数会接收智能体上下文,也可以选择接收 LLM 生成的输入。输入数据由 `input_type` 参数控制。 -- `input_type`: 任务转移工具调用参数的 schema。设置后,解析后的负载会传递给 `on_handoff`。 -- `input_filter`: 它允许你过滤下一个智能体接收到的输入。更多信息见下文。 -- `is_enabled`: 任务转移是否启用。它可以是一个布尔值,也可以是返回布尔值的函数,从而允许你在运行时动态启用或禁用任务转移。 -- `nest_handoff_history`: 对 RunConfig 级别 `nest_handoff_history` 设置的可选单次调用覆盖。如果为 `None`,则改用当前活动运行配置中定义的值。 +- `agent`:任务将转移到的智能体。 +- `tool_name_override`:默认使用 `Handoff.default_tool_name()` 函数,其结果为 `transfer_to_`。你可以覆盖此设置。 +- `tool_description_override`:覆盖来自 `Handoff.default_tool_description()` 的默认工具描述。 +- `on_handoff`:调用任务转移时执行的回调函数。它适用于在确认调用任务转移后立即启动数据获取等操作。此函数接收智能体上下文,也可以选择接收LLM生成的输入。输入数据由 `input_type` 参数控制。 +- `input_type`:任务转移工具调用参数的架构。设置后,解析后的有效负载会传递给 `on_handoff`。 +- `input_filter`:用于过滤下一个智能体接收的输入。更多信息请参见下文。 +- `is_enabled`:是否启用任务转移。它可以是布尔值,也可以是返回布尔值的函数,因此你可以在运行时动态启用或禁用任务转移。 +- `nest_handoff_history`:对 RunConfig 级别 `nest_handoff_history` 设置的可选单次调用覆盖。如果为 `None`,则改用当前运行配置中定义的值。 -[`handoff()`][agents.handoffs.handoff] 辅助函数始终会将控制权转移给你传入的特定 `agent`。如果有多个可能的目标,请为每个目标注册一个任务转移,并让模型在它们之间选择。仅当你自己的任务转移代码必须在调用时决定返回哪个智能体时,才使用自定义 [`Handoff`][agents.handoffs.Handoff]。 +[`handoff()`][agents.handoffs.handoff] 辅助函数始终会将控制权转移给你传入的特定 `agent`。如果存在多个可能的目标,请为每个目标注册一个任务转移,并让模型从中选择。只有当你自己的任务转移代码必须在调用时决定返回哪个智能体时,才应使用自定义的 [`Handoff`][agents.handoffs.Handoff]。 ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## 任务转移输入 -在某些情况下,你希望 LLM 在调用任务转移时提供一些数据。例如,设想有一个转移到“Escalation agent”的任务转移。你可能希望模型提供一个原因,以便你记录它。 +在某些情况下,你希望LLM在调用任务转移时提供一些数据。例如,假设要将任务转移给一个“升级处理智能体”。你可能希望模型提供原因,以便将其记录下来。 ```python from pydantic import BaseModel @@ -87,44 +87,44 @@ handoff_obj = handoff( ) ``` -`input_type` 描述任务转移工具调用本身的参数。SDK 会将该 schema 作为任务转移工具的 `parameters` 暴露给模型,在本地验证返回的 JSON,并将解析后的值传递给 `on_handoff`。 +`input_type` 描述任务转移工具调用本身的参数。SDK会将该架构作为任务转移工具的 `parameters` 提供给模型,在本地验证返回的 JSON,并将解析后的值传递给 `on_handoff`。 -它不会替换下一个智能体的主输入,也不会选择不同的目标。[`handoff()`][agents.handoffs.handoff] 辅助函数仍然会转移到你包装的特定智能体,并且接收方智能体仍会看到对话历史,除非你通过 [`input_filter`][agents.handoffs.Handoff.input_filter] 或嵌套任务转移历史设置对其进行更改。 +它不会替换下一个智能体的主要输入,也不会选择其他目标。[`handoff()`][agents.handoffs.handoff] 辅助函数仍会将任务转移给你封装的特定智能体,而接收任务的智能体仍会看到对话历史记录,除非你使用 [`input_filter`][agents.handoffs.Handoff.input_filter] 或嵌套任务转移历史记录设置对其进行更改。 -`input_type` 也与 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] 分离。请将 `input_type` 用于模型在任务转移时决定的元数据,而不是用于你本地已有的应用状态或依赖项。 +`input_type` 也独立于 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。请将 `input_type` 用于模型在任务转移时决定的元数据,而不是你在本地已有的应用状态或依赖项。 -### `input_type` 的使用场景 +### `input_type` 的适用场景 -当任务转移需要一小段模型生成的元数据时,请使用 `input_type`,例如 `reason`、`language`、`priority` 或 `summary`。例如,分诊智能体可以通过 `{ "reason": "duplicate_charge", "priority": "high" }` 转移给退款智能体,`on_handoff` 可以在退款智能体接管之前记录或持久化这些元数据。 +当任务转移需要少量由模型生成的元数据(例如 `reason`、`language`、`priority` 或 `summary`)时,请使用 `input_type`。例如,分流智能体可以将任务转移给退款智能体,同时附带 `{ "reason": "duplicate_charge", "priority": "high" }`;在退款智能体接管任务前,`on_handoff` 可以记录或持久化这些元数据。 -当目标不同时,请选择其他机制: +如果目标不同,请选择其他机制: -- 将现有的应用状态和依赖项放入 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。参见[上下文指南](context.md)。 -- 如果你想更改接收方智能体看到的历史,请使用 [`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 或 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]。 -- 如果有多个可能的专家智能体,请为每个目标注册一个任务转移。`input_type` 可以向所选任务转移添加元数据,但不会在不同目标之间进行分派。 -- 如果你希望为嵌套专家提供结构化输入而不转移对话,请优先使用 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]。参见[工具](tools.md#structured-input-for-tool-agents)。 +- 将现有应用状态和依赖项放入 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。请参阅[上下文指南](context.md)。 +- 如果要更改接收任务的智能体所看到的历史记录,请使用 [`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 或 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]。 +- 如果存在多个可能的专业智能体,请为每个目标注册一个任务转移。`input_type` 可以向选定的任务转移添加元数据,但不会在不同目标之间进行分派。 +- 如果你希望向嵌套的专业智能体提供结构化输入,而不转移对话,请优先使用 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]。请参阅[工具](tools.md#structured-input-for-tool-agents)。 ## 输入过滤器 -当发生任务转移时,就像新的智能体接管了对话,并且能够看到之前的完整对话历史。如果你想更改这一点,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回一个新的 `HandoffInputData`。 +发生任务转移时,新智能体就像接管了对话一样,可以看到此前的完整对话历史记录。如果要更改这一行为,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回新的 `HandoffInputData`。 [`HandoffInputData`][agents.handoffs.HandoffInputData] 包括: -- `input_history`: `Runner.run(...)` 启动前的输入历史。 -- `pre_handoff_items`: 在调用任务转移的智能体轮次之前生成的项目。 -- `new_items`: 当前轮次期间生成的项目,包括任务转移调用和任务转移输出项目。 -- `input_items`: 可选项目,用于转发给下一个智能体以替代 `new_items`,允许你过滤模型输入,同时保持 `new_items` 完整以用于会话历史。 -- `run_context`: 调用任务转移时处于活动状态的 [`RunContextWrapper`][agents.run_context.RunContextWrapper]。 +- `input_history`:`Runner.run(...)` 启动前的输入历史记录。 +- `pre_handoff_items`:调用任务转移的智能体轮次之前生成的项目。 +- `new_items`:当前轮次中生成的项目,包括任务转移调用和任务转移输出项目。 +- `input_items`:可选项目,用于代替 `new_items` 转发给下一个智能体,让你能够过滤模型输入,同时保持 `new_items` 不变以用于会话历史记录。 +- `run_context`:调用任务转移时处于活动状态的 [`RunContextWrapper`][agents.run_context.RunContextWrapper]。 -嵌套任务转移作为可选择启用的 beta 功能提供,在我们稳定它们之前默认处于禁用状态。当你启用 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 时,运行器会将先前的转录折叠为一条助手摘要消息,并将其包装在 `` 块中;当同一次运行中发生多次任务转移时,该块会持续追加新的轮次。你可以通过 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] 提供自己的映射函数,以替换生成的消息,而无需编写完整的 `input_filter`。只有当任务转移和运行都没有提供显式 `input_filter` 时,该选择启用项才会生效,因此已经自定义负载的现有代码(包括此仓库中的代码示例)会保持当前行为而无需更改。你可以通过向 [`handoff(...)`][agents.handoffs.handoff] 传递 `nest_handoff_history=True` 或 `False` 来覆盖单个任务转移的嵌套行为,这会设置 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]。如果你只需要更改生成摘要的包装文本,请在运行智能体之前调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并可选择调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers])。 +嵌套任务转移是一项可选择启用的 Beta 功能;在我们对其进行稳定化期间,默认处于禁用状态。启用 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 后,运行器会将可总结的历史记录压缩为按顺序排列的助手摘要片段,同时将无损消息项目保留在其原始位置。每个生成的摘要片段都使用 `` 包装器;后续任务转移会先展平之前生成的片段,然后再重新构建有序的对话记录。会话、`RunState` 和 `RunResult.to_input_list()` 会追踪已移入此 SDK 默认历史记录中的确切消息实例,从而避免重复附加这些实例;内容相同但彼此独立的消息仍会保留。你可以通过 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] 提供自己的映射函数,以返回下一个智能体所需的确切输入项目列表,而不使用内置分段机制。仅当任务转移和运行均未提供显式 `input_filter` 时,此可选功能才会生效,因此,已经自定义有效负载的现有代码(包括此代码库中的代码示例)无须更改即可保持当前行为。你可以通过向 [`handoff(...)`][agents.handoffs.handoff] 传入 `nest_handoff_history=True` 或 `False`,为单次任务转移覆盖嵌套行为;这会设置 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]。如果只需更改所生成摘要片段的包装器文本,请在运行智能体之前调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](还可选择调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers])。 -如果任务转移和活动的 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] 都定义了过滤器,则对于该特定任务转移,逐任务转移的 [`input_filter`][agents.handoffs.Handoff.input_filter] 优先。 +如果任务转移和当前 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] 都定义了过滤器,则对于该次特定的任务转移,任务转移级别的 [`input_filter`][agents.handoffs.Handoff.input_filter] 优先。 !!! note - 任务转移会保持在单次运行内。输入安全防护措施仍然只应用于链中的第一个智能体,输出安全防护措施只应用于生成最终输出的智能体。当你需要围绕工作流中每个自定义函数工具调用进行检查时,请使用工具安全防护措施。 + 任务转移始终在单次运行内进行。输入安全防护措施仍然仅适用于链中的第一个智能体,而输出安全防护措施仅适用于生成最终输出的智能体。如果需要对工作流中的每次自定义函数工具调用执行检查,请使用工具安全防护措施。 -有一些常见模式(例如从历史中移除所有工具调用)已经在 [`agents.extensions.handoff_filters`][] 中为你实现。 +有一些常见模式(例如从历史记录中移除所有工具调用),[`agents.extensions.handoff_filters`][] 已为你实现这些模式。 ```python from agents import Agent, handoff @@ -138,11 +138,11 @@ handoff_obj = handoff( ) ``` -1. 当调用 `FAQ agent` 时,这会自动从历史中移除所有工具。 +1. 调用 `FAQ agent` 时,这会自动从历史记录中移除所有工具。 ## 推荐提示词 -为确保 LLM 正确理解任务转移,我们建议在你的智能体中包含有关任务转移的信息。我们在 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] 中提供了建议的前缀,或者你可以调用 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] 来自动将推荐数据添加到你的提示词中。 +为了确保LLM正确理解任务转移,我们建议在智能体中加入有关任务转移的信息。我们在 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] 中提供了建议的前缀,你也可以调用 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][],自动向提示词添加建议的数据。 ```python from agents import Agent diff --git a/docs/zh/human_in_the_loop.md b/docs/zh/human_in_the_loop.md index 6e79517367..7891388fb4 100644 --- a/docs/zh/human_in_the_loop.md +++ b/docs/zh/human_in_the_loop.md @@ -2,25 +2,28 @@ search: exclude: true --- -# 人在环路 +# 人工介入 -使用人在环路(HITL)流程暂停智能体执行,直到有人批准或拒绝敏感的工具调用。工具会声明自身何时需要审批,运行结果会以中断的形式呈现待处理审批,而 `RunState` 可让你在做出决策后序列化并恢复运行。 +使用人工介入(HITL)流程暂停智能体执行,直到相关人员批准或拒绝敏感的工具调用。工具会声明何时需要审批,运行结果会以中断形式呈现待处理的审批,而`RunState`则允许你在作出决定后序列化并恢复运行。 -该审批入口覆盖整个运行,而不局限于当前顶层智能体。无论工具属于当前智能体、通过任务转移到达的智能体,还是嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 执行,均适用同一模式。在嵌套 `Agent.as_tool()` 的情况下,中断仍会在外层运行中呈现,因此你需要在外层 `RunState` 上批准或拒绝它,并恢复原始的顶层运行。 +该审批机制适用于整个运行,并不限于当前顶层智能体。无论工具属于当前智能体、通过任务转移到达的智能体,还是嵌套的[`Agent.as_tool()`][agents.agent.Agent.as_tool]执行,都适用相同的模式。在嵌套的`Agent.as_tool()`场景中,中断仍会出现在外层运行中,因此你需要在外层`RunState`上批准或拒绝它,然后恢复原始顶层运行。 -使用 `Agent.as_tool()` 时,审批可能发生在两个不同层级:智能体工具本身可以通过 `Agent.as_tool(..., needs_approval=...)` 要求审批,而嵌套智能体内部的工具也可以在嵌套运行开始后再发起自己的审批。两者都通过同一个外层运行中断流程处理。 +使用`Agent.as_tool()`时,审批可能发生在两个不同层级:智能体工具本身可以通过`Agent.as_tool(..., needs_approval=...)`要求审批,而嵌套智能体内的工具也可以在嵌套运行开始后发起自己的审批。两者都通过同一个外层运行中断流程处理。 -本页重点介绍通过 `interruptions` 进行的手动审批流程。如果你的应用能够在代码中做出决策,某些工具类型也支持程序化审批回调,使运行无需暂停即可继续。 +本页重点介绍通过`interruptions`实现的手动审批流程。如果你的应用能够通过代码作出决定,某些工具类型也支持程序化审批回调,使运行无需暂停即可继续。 -## 需审批工具的标记 +## 需要审批的工具标记 -将 `needs_approval` 设置为 `True` 可始终要求审批,或提供一个异步函数按每次调用做出决定。该可调用对象会接收运行上下文、解析后的工具参数以及工具调用 ID。 +将`needs_approval`设置为`True`可始终要求审批,也可以提供一个异步函数来逐次决定。该可调用对象会接收运行上下文、已解析的工具参数和工具调用 ID。 + +当 SDK 无法安全检查参数时,可调用审批规则会采用失败关闭策略。如果参数是格式错误的 JSON、是有效 JSON 但并非对象(例如`null`或列表),或者包含`NaN`、`Infinity`或`-Infinity`等非标准常量,则不会调用该可调用对象,而是要求手动审批。Runner 和 Realtime 工具调用的行为相同。 ```python -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(needs_approval=True) +@tool(needs_approval=True) async def cancel_order(order_id: int) -> str: return f"Cancelled order {order_id}" @@ -29,7 +32,7 @@ async def requires_review(_ctx, params, _call_id) -> bool: return "refund" in params.get("subject", "").lower() -@function_tool(needs_approval=requires_review) +@tool(needs_approval=requires_review) async def send_email(subject: str, body: str) -> str: return f"Sent '{subject}'" @@ -41,28 +44,28 @@ agent = Agent( ) ``` -`needs_approval` 可用于 [`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]。本地 MCP 服务也支持通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse] 和 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 上的 `require_approval` 进行审批。托管 MCP 服务通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 支持审批,可配合 `tool_config={"require_approval": "always"}` 以及可选的 `on_approval_request` 回调使用。如果你想在不呈现中断的情况下自动批准或自动拒绝,Shell 和 apply_patch 工具可接受 `on_approval` 回调。 +[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]和[`ApplyPatchTool`][agents.tool.ApplyPatchTool]均支持`needs_approval`。本地MCP服务也支持通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]和[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]上的`require_approval`进行审批。托管式MCP服务通过[`HostedMCPTool`][agents.tool.HostedMCPTool]支持审批,可设置`tool_config={"require_approval": "always"}`,并可选择提供`on_approval_request`回调。如果希望自动批准或自动拒绝,而不呈现中断,Shell 和 apply_patch 工具可接受`on_approval`回调。 ## 审批流程机制 -1. 当模型发出工具调用时,运行器会评估其审批规则(`needs_approval`、`require_approval` 或托管 MCP 的等价机制)。 -2. 如果该工具调用的审批决策已经存储在 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 中,运行器会无需提示而继续。逐调用审批的作用域限定在特定调用 ID;传入 `always_approve=True` 或 `always_reject=True` 可在该运行剩余期间,对该工具未来的调用持久化相同决策。 -3. 否则,执行会暂停,并且 `RunResult.interruptions`(或 `RunResultStreaming.interruptions`)会包含 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 条目,其中包含 `agent.name`、`tool_name` 和 `arguments` 等详细信息。这也包括任务转移后或嵌套 `Agent.as_tool()` 执行中发起的审批。 -4. 使用 `result.to_state()` 将结果转换为 `RunState`,调用 `state.approve(...)` 或 `state.reject(...)`,然后使用 `Runner.run(agent, state)` 或 `Runner.run_streamed(agent, state)` 恢复,其中 `agent` 是该运行的原始顶层智能体。 -5. 恢复后的运行会从暂停处继续,并会在需要新的审批时重新进入此流程。 +1. 当模型发出工具调用时,运行器会评估其审批规则(`needs_approval`、`require_approval`或托管式MCP的对应设置)。 +2. 如果该工具调用的审批决定已经存储在[`RunContextWrapper`][agents.run_context.RunContextWrapper]中,运行器会直接继续而不发出提示。单次调用审批仅适用于特定调用 ID;传入`always_approve=True`或`always_reject=True`,可在本次运行剩余期间对该工具之后的调用持续应用同一决定。 +3. 否则,执行会暂停,`RunResult.interruptions`(或`RunResultStreaming.interruptions`)中会包含[`ToolApprovalItem`][agents.items.ToolApprovalItem]条目,其中提供`agent.name`、`tool_name`和`arguments`等详细信息。这也包括任务转移后或嵌套`Agent.as_tool()`执行中发起的审批。 +4. 使用`result.to_state()`将结果转换为`RunState`,调用`state.approve(...)`或`state.reject(...)`,然后通过`Runner.run(agent, state)`或`Runner.run_streamed(agent, state)`恢复运行,其中`agent`是本次运行的原始顶层智能体。 +5. 恢复后的运行会从暂停处继续,并在需要新审批时重新进入此流程。 -使用 `always_approve=True` 或 `always_reject=True` 创建的持久决策会存储在运行状态中,因此当你稍后恢复同一个已暂停运行时,它们会在 `state.to_string()` / `RunState.from_string(...)` 和 `state.to_json()` / `RunState.from_json(...)` 的序列化/反序列化之后仍然有效。 +使用`always_approve=True`或`always_reject=True`创建的持久决定会存储在运行状态中,因此当你之后恢复同一暂停运行时,这些决定会通过`state.to_string()` / `RunState.from_string(...)`和`state.to_json()` / `RunState.from_json(...)`保留下来。 -你不需要在同一轮处理里解决所有待处理审批。`interruptions` 可以包含普通工具调用、托管 MCP 审批以及嵌套 `Agent.as_tool()` 审批的混合项。如果你只批准或拒绝其中一部分条目后再次运行,已处理的调用可以继续,而未处理的调用会继续留在 `interruptions` 中并使运行再次暂停。 +你无需在同一次处理中解决所有待审批项。`interruptions`中可以同时包含常规工具调用、托管式MCP审批和嵌套的`Agent.as_tool()`审批。如果你仅批准或拒绝其中部分项目后重新运行,已处理的调用可以继续,而未处理的项目仍会保留在`interruptions`中并再次暂停运行。 ## 自定义拒绝消息 默认情况下,被拒绝的工具调用会将 SDK 的标准拒绝文本返回到运行中。你可以在两个层级自定义该消息: -- 运行范围回退:设置 [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter],以控制整个运行中审批拒绝时默认对模型可见的消息。 -- 逐调用覆盖:当你希望某个特定被拒绝的工具调用呈现不同消息时,向 `state.reject(...)` 传入 `rejection_message=...`。 +- 运行级后备设置:设置[`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter],以控制整个运行中审批被拒绝时默认向模型显示的消息。 +- 单次调用覆盖:当你希望某个被拒绝的特定工具调用呈现不同消息时,将`rejection_message=...`传给`state.reject(...)`。 -如果两者都提供,逐调用的 `rejection_message` 优先于运行范围格式化器。 +如果两者均已提供,则单次调用的`rejection_message`优先于运行级格式化器。 ```python from agents import RunConfig, ToolErrorFormatterArgs @@ -83,41 +86,42 @@ state.reject( ) ``` -请参阅 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py),其中提供了同时展示这两个层级的完整示例。 +有关同时展示这两个层级的完整代码示例,请参阅[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)。 ## 自动审批决策 -手动 `interruptions` 是最通用的模式,但并不是唯一模式: +手动`interruptions`是最通用的模式,但并非唯一选择: -- 本地 [`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool] 可以使用 `on_approval` 在代码中立即批准或拒绝。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] 可以将 `tool_config={"require_approval": "always"}` 与 `on_approval_request` 结合使用,以实现同类程序化决策。 -- 普通 [`function_tool`][agents.tool.function_tool] 工具和 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 使用本页的手动中断流程。 +- 本地[`ShellTool`][agents.tool.ShellTool]和[`ApplyPatchTool`][agents.tool.ApplyPatchTool]可以使用`on_approval`在代码中立即批准或拒绝。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool]可以将`tool_config={"require_approval": "always"}`与`on_approval_request`结合使用,实现同类程序化决策。 +- 普通[`function_tool`][agents.tool.function_tool]工具和[`Agent.as_tool()`][agents.agent.Agent.as_tool]使用本页介绍的手动中断流程。 -当这些回调返回决策时,运行会继续,而不会暂停等待人工响应。对于 Realtime 和语音会话 API,请参阅 [Realtime 指南](realtime/guide.md) 中的审批流程。 +当这些回调返回决定时,运行会继续,而无需暂停以等待人工响应。对于 Realtime 和语音会话 API,请参阅[Realtime 指南](realtime/guide.md)中的审批流程。 ## 流式传输与会话 -同一个中断流程也适用于流式传输运行。流式运行暂停后,持续消费 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events],直到迭代器结束,检查 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions],处理它们,并在你希望恢复后的输出继续流式传输时使用 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] 恢复。请参阅 [流式传输](streaming.md),了解此模式的流式版本。 +相同的中断流程也适用于流式传输运行。流式运行暂停后,继续消费[`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events],直到迭代器结束;然后检查[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions],处理其中的中断。如果希望恢复后的输出继续进行流式传输,请使用[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]恢复。有关此模式的流式传输版本,请参阅[流式传输](streaming.md)。 -如果你还使用会话,在从 `RunState` 恢复时请继续传入同一个会话实例,或传入另一个指向同一后端存储的会话对象。恢复后的轮次会追加到同一份已存储的对话历史中。有关会话生命周期的详细信息,请参阅 [会话](sessions/index.md)。 +如果你还在使用会话,从`RunState`恢复时应继续传入同一个会话实例,或者传入指向同一底层存储的另一个会话对象。恢复后的轮次会追加到同一份已存储对话历史中。有关会话生命周期的详细信息,请参阅[会话](sessions/index.md)。 ## 示例:暂停、批准与恢复 -下面的代码片段与 JavaScript HITL 指南相对应:它会在工具需要审批时暂停,将状态持久化到磁盘,重新加载它,并在收集决策后恢复。 +以下代码片段与 JavaScript HITL 指南中的流程一致:当工具需要审批时暂停运行,将状态持久化到磁盘,重新加载状态,并在获得决定后恢复运行。 ```python import asyncio import json from pathlib import Path -from agents import Agent, Runner, RunState, function_tool +from agents import Agent, Runner, RunState +from agents.decorators import tool async def needs_oakland_approval(_ctx, params, _call_id) -> bool: return "Oakland" in params.get("city", "") -@function_tool(needs_approval=needs_oakland_approval) +@tool(needs_approval=needs_oakland_approval) async def get_temperature(city: str) -> str: return f"The temperature in {city} is 20° Celsius" @@ -167,35 +171,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -在此示例中,`prompt_approval` 是同步的,因为它使用 `input()`,并通过 `run_in_executor(...)` 执行。如果你的审批来源本身已经是异步的(例如 HTTP 请求或异步数据库查询),则可以改用 `async def` 函数并直接 `await` 它。 +在此代码示例中,`prompt_approval`是同步函数,因为它使用`input()`,并通过`run_in_executor(...)`执行。如果你的审批来源已经是异步的(例如 HTTP 请求或异步数据库查询),则可以使用`async def`函数并直接对其使用`await`。 -若要在等待审批期间流式传输输出,请调用 `Runner.run_streamed`,消费 `result.stream_events()` 直到完成,然后按照上面展示的相同 `result.to_state()` 和恢复步骤操作。 +若要在等待审批时以流式传输方式输出,请调用`Runner.run_streamed`,消费`result.stream_events()`直至完成,然后按照上文所示执行相同的`result.to_state()`和恢复步骤。 ## 仓库模式与代码示例 -- **流式传输审批**: `examples/agent_patterns/human_in_the_loop_stream.py` 展示如何消费完 `stream_events()`,然后在使用 `Runner.run_streamed(agent, state)` 恢复之前批准待处理的工具调用。 -- **自定义拒绝文本**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` 展示在审批被拒绝时,如何将运行级别的 `tool_error_formatter` 与逐调用的 `rejection_message` 覆盖结合使用。 -- **作为工具的智能体审批**: 当委派的智能体任务需要审核时,`Agent.as_tool(..., needs_approval=...)` 会应用同一个中断流程。嵌套中断仍会在外层运行中呈现,因此应恢复原始顶层智能体,而不是嵌套智能体。 -- **本地 shell 和 apply_patch 工具**: `ShellTool` 和 `ApplyPatchTool` 也支持 `needs_approval`。使用 `state.approve(interruption, always_approve=True)` 或 `state.reject(..., always_reject=True)` 缓存该决策以供未来调用使用。对于自动决策,请提供 `on_approval`(见 `examples/tools/shell.py`);对于手动决策,请处理中断(见 `examples/tools/shell_human_in_the_loop.py`)。托管 shell 环境不支持 `needs_approval` 或 `on_approval`;请参阅[工具指南](tools.md)。 -- **本地 MCP 服务**: 使用 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` 上的 `require_approval` 为 MCP 工具调用设置审批门禁(见 `examples/mcp/get_all_mcp_tools_example/main.py` 和 `examples/mcp/tool_filter_example/main.py`)。 -- **托管 MCP 服务**: 在 `HostedMCPTool` 上将 `require_approval` 设置为 `"always"` 以强制使用 HITL,并可选择提供 `on_approval_request` 来自动批准或拒绝(见 `examples/hosted_mcp/human_in_the_loop.py` 和 `examples/hosted_mcp/on_approval.py`)。对受信任的服务使用 `"never"`(`examples/hosted_mcp/simple.py`)。 -- **会话与记忆**: 向 `Runner.run` 传入会话,使审批和对话历史能够跨多轮保留。SQLite 和 OpenAI Conversations 会话变体位于 `examples/memory/memory_session_hitl_example.py` 和 `examples/memory/openai_session_hitl_example.py`。 -- **Realtime 智能体**: Realtime 演示公开了 WebSocket 消息,可在 `RealtimeSession` 上通过 `approve_tool_call` / `reject_tool_call` 批准或拒绝工具调用(服务端处理程序见 `examples/realtime/app/server.py`,API 接口见 [Realtime 指南](realtime/guide.md#tool-approvals))。 +- **流式传输审批**:`examples/agent_patterns/human_in_the_loop_stream.py`展示了如何读取完`stream_events()`,然后批准待处理的工具调用,再通过`Runner.run_streamed(agent, state)`恢复运行。 +- **自定义拒绝文本**:`examples/agent_patterns/human_in_the_loop_custom_rejection.py`展示了审批被拒绝时,如何将运行级`tool_error_formatter`与单次调用的`rejection_message`覆盖结合使用。 +- **智能体作为工具的审批**:当委托的智能体任务需要审核时,`Agent.as_tool(..., needs_approval=...)`会应用相同的中断流程。嵌套中断仍会出现在外层运行中,因此应恢复原始顶层智能体,而不是嵌套智能体。 +- **本地 shell 和 apply_patch 工具**:`ShellTool`和`ApplyPatchTool`也支持`needs_approval`。使用`state.approve(interruption, always_approve=True)`或`state.reject(..., always_reject=True)`,可为之后的调用缓存该决定。对于自动决策,请提供`on_approval`(参阅`examples/tools/shell.py`);对于手动决策,请处理中断(参阅`examples/tools/shell_human_in_the_loop.py`)。托管 shell 环境不支持`needs_approval`或`on_approval`;请参阅[工具指南](tools.md)。 +- **本地MCP服务**:使用`MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp`上的`require_approval`为MCP工具调用设置审批门槛(参阅`examples/mcp/get_all_mcp_tools_example/main.py`和`examples/mcp/tool_filter_example/main.py`)。 +- **托管式MCP服务**:将`HostedMCPTool`上的`require_approval`设置为`"always"`,可强制启用 HITL;也可以提供`on_approval_request`以自动批准或拒绝(参阅`examples/hosted_mcp/human_in_the_loop.py`和`examples/hosted_mcp/on_approval.py`)。对于可信服务,请使用`"never"`(`examples/hosted_mcp/simple.py`)。 +- **会话与记忆**:将会话传给`Runner.run`,使审批和对话历史能够跨多个轮次保留。SQLite 和 OpenAI Conversations 会话变体位于`examples/memory/memory_session_hitl_example.py`和`examples/memory/openai_session_hitl_example.py`中。 +- **Realtime智能体**:Realtime 演示提供了 WebSocket 消息,可通过`RealtimeSession`上的`approve_tool_call` / `reject_tool_call`批准或拒绝工具调用(有关服务端处理程序,请参阅`examples/realtime/app/server.py`;有关 API 接口,请参阅[Realtime 指南](realtime/guide.md#tool-approvals))。 -## 长时间审批 +## 长时审批 -`RunState` 被设计为可持久化。使用 `state.to_json()` 或 `state.to_string()` 将待处理工作存储在数据库或队列中,并稍后使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 重新创建它。 +`RunState`采用持久化设计。使用`state.to_json()`或`state.to_string()`将待处理工作存储在数据库或队列中,之后再通过`RunState.from_json(...)`或`RunState.from_string(...)`重新创建。 -有用的序列化选项: +实用的序列化选项: -- `context_serializer`:自定义非映射上下文对象的序列化方式。 -- `context_deserializer`:在使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 加载状态时,重建非映射上下文对象。 -- `strict_context=True`:除非上下文本身已经是映射,或你提供了相应的序列化器/反序列化器,否则序列化或反序列化会失败。 -- `context_override`:加载状态时替换已序列化的上下文。这在你不想还原原始上下文对象时很有用,但它不会从已经序列化的载荷中移除该上下文。 -- `include_tracing_api_key=True`:当你需要恢复后的工作继续使用相同凭据导出追踪时,在序列化的追踪载荷中包含追踪 API 密钥。 +- `context_serializer`:自定义非映射类型上下文对象的序列化方式。 +- `context_deserializer`:使用`RunState.from_json(...)`或`RunState.from_string(...)`加载状态时,重新构建非映射类型上下文对象。 +- `strict_context=True`:除非上下文本身已是映射类型,或者你提供了相应的序列化器/反序列化器,否则序列化或反序列化会失败。 +- `context_override`:加载状态时替换已序列化的上下文。当你不想恢复原始上下文对象时,此选项非常有用,但它不会从已序列化的有效载荷中移除该上下文。 +- `include_tracing_api_key=True`:当你需要恢复后的工作继续使用相同凭据导出追踪数据时,将追踪 API 密钥包含在已序列化的追踪有效载荷中。 -序列化后的运行状态包括你的应用上下文,以及 SDK 管理的运行时元数据,例如审批、用量、序列化的 `tool_input`、嵌套的智能体作为工具的恢复信息、追踪元数据,以及由服务管理的对话设置。如果你计划存储或传输序列化状态,请将 `RunContextWrapper.context` 视为持久化数据,并避免在那里放置密钥等敏感信息,除非你有意让它们随状态一起传递。 +已序列化的运行状态包含应用上下文,以及由 SDK 管理的运行时元数据,例如审批、使用量、已序列化的`tool_input`、嵌套的智能体工具恢复信息、追踪元数据和服务端管理的对话设置。如果你计划存储或传输已序列化状态,应将`RunContextWrapper.context`视为持久化数据,并避免在其中放置机密信息,除非你明确希望这些信息随状态一同传递。 -## 待处理任务的版本控制 +## 待处理任务版本管理 -如果审批可能搁置一段时间,请在序列化状态旁同时存储智能体定义或 SDK 的版本标记。然后,你可以将反序列化路由到匹配的代码路径,以避免模型、提示词或工具定义发生变化时的不兼容。 \ No newline at end of file +如果审批可能会搁置一段时间,请将智能体定义或 SDK 的版本标记与已序列化状态一起存储。之后,你可以将反序列化路由到匹配的代码路径,以避免模型、提示词或工具定义发生变化时出现不兼容问题。 \ No newline at end of file diff --git a/docs/zh/models/index.md b/docs/zh/models/index.md index a372c08a11..cca42ce28a 100644 --- a/docs/zh/models/index.md +++ b/docs/zh/models/index.md @@ -4,32 +4,32 @@ search: --- # 模型 -Agents SDK 原生支持两种形式的 OpenAI 模型: +Agents SDK 原生支持两种 OpenAI 模型: - **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API。 - [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API。 ## 模型配置选择 -请从最适合您配置的最简单路径开始: +从最符合您配置的简单方案开始: -| 如果您希望…… | 推荐路径 | 更多信息 | +| 如果您想要…… | 推荐方案 | 更多信息 | | --- | --- | --- | | 仅使用 OpenAI 模型 | 使用默认 OpenAI 提供商和 Responses 模型路径 | [OpenAI 模型](#openai-models) | | 通过 websocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径并启用 websocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | -| 使用由 OpenAI 托管的子智能体 | 使用实验性的托管式多智能体模型 | [托管式多智能体](#hosted-multi-agent-experimental) | +| 使用由 OpenAI 托管的子智能体 | 使用实验性的托管多智能体模型 | [托管多智能体](#hosted-multi-agent-experimental) | | 使用一个非 OpenAI 提供商 | 从内置的提供商集成点开始 | [非 OpenAI 模型](#non-openai-models) | -| 在不同智能体之间混用模型或提供商 | 按运行或按智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | -| 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径中使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | -| 使用第三方适配器进行非 OpenAI 或混合提供商路由 | 比较受支持的 Beta 版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | +| 在多个智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | +| 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径上使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | +| 使用第三方适配器进行非 OpenAI 或混合提供商路由 | 比较受支持的测试版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | ## OpenAI 模型 -对于大多数仅使用 OpenAI 的应用,推荐使用字符串形式的模型名称和默认 OpenAI 提供商,并继续使用 Responses 模型路径。 +对于大多数仅使用 OpenAI 的应用,推荐方案是将字符串模型名称与默认 OpenAI 提供商结合使用,并保持使用 Responses 模型路径。 -初始化 `Agent` 时,如果未指定模型,将使用默认模型。当前默认模型为 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并设置 `reasoning.effort="none"` 和 `verbosity="low"`,适用于低延迟智能体工作流。如果您拥有访问权限,我们建议将智能体设置为 `gpt-5.6-sol`,以获得更高质量,同时显式设置 `model_settings`。 +初始化 `Agent` 时如果未指定模型,将使用默认模型。目前的默认模型是 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并使用 `reasoning.effort="none"` 和 `verbosity="low"`,适合低延迟智能体工作流。如果您拥有访问权限,我们建议将智能体设置为 `gpt-5.6-sol`,以便在保留显式 `model_settings` 的同时获得更高质量。 -如果希望切换到 `gpt-5.6-sol` 等其他模型,可以通过两种方式配置智能体。 +如果要切换到 `gpt-5.6-sol` 等其他模型,可以通过两种方式配置智能体。 ### 默认模型 @@ -40,7 +40,7 @@ export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -其次,可以通过 `RunConfig` 为一次运行设置默认模型。如果未给智能体设置模型,则会使用此次运行的模型。 +其次,可以通过 `RunConfig` 为一次运行设置默认模型。如果未为智能体设置模型,则会使用本次运行的模型。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 模型 -以这种方式使用任何 GPT-5 模型(例如 `gpt-5.6-sol`)时,SDK 会应用默认的 `ModelSettings`。这些设置最适合大多数使用场景。要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: +以这种方式使用 `gpt-5.6-sol` 等任意 GPT-5 模型时,SDK 会应用默认的 `ModelSettings`。这些设置最适合大多数用例。要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -为了降低延迟,建议对 GPT-5 模型使用 `reasoning.effort="none"`。 +为降低延迟,建议对 GPT-5 模型使用 `reasoning.effort="none"`。 -GPT-5.6 还通过现有的 `reasoning` 设置支持推理模式、持久化推理上下文和 `"max"` 强度级别。这些控制项可用于 Responses API 路径: +GPT-5.6 还通过现有的 `reasoning` 设置支持推理模式、持久化推理上下文和 `"max"` 强度级别。这些控制项可在 Responses API 路径上使用: ```python from openai.types.shared import Reasoning @@ -96,39 +96,40 @@ agent = Agent( ) ``` -`reasoning.mode` 和 `reasoning.context` 是仅限 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,支持的强度级别取决于模型和 API 接口。若要使用 GPT-5.6 的 `"max"` 强度,请使用 Responses API。Chat Completions 适配器会忽略模式和上下文并发出警告;可在 OpenAI 提供商上设置 `strict_feature_validation=True`,将该警告转为错误。 +`reasoning.mode` 和 `reasoning.context` 是 Responses 专用设置。Chat Completions 仅使用 `reasoning.effort`,支持的强度级别取决于模型和 API 接口。请使用 Responses API 实现 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;在 OpenAI 提供商上设置 `strict_feature_validation=True` 可将该警告转换为错误。 -使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端对话或重放先前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中包含 `reasoning.encrypted_content`,并在下一次请求中重放这些推理项。 +使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端对话或重放先前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中包含 `reasoning.encrypted_content`,并在下一次请求时重放这些推理项。 #### ComputerTool 模型选择 -如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],实际 Responses 请求所使用的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求仍使用较旧的 `computer_use_preview` 载荷。 +如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],实际 Responses 请求中的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求则继续使用旧版 `computer_use_preview` 载荷。 -由提示词管理的调用是主要例外。如果提示词模板指定了模型,且 SDK 从请求中省略 `model`,SDK 会默认使用与预览版兼容的计算机载荷,以避免猜测提示词固定的是哪个模型。若要在此流程中继续使用正式发布路径,可在请求中显式指定 `model="gpt-5.5"`,或通过 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版本。 +由提示词管理的调用是主要例外。如果提示词模板决定模型且 SDK 在请求中省略 `model`,SDK 将默认使用与预览版兼容的计算机载荷,以避免猜测提示词固定了哪个模型。要在此流程中继续使用正式发布路径,可以在请求中显式设置 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版本。 -注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与有效请求模型相匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串仍会像普通函数名称一样工作。 +注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与有效请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串仍会像普通函数名称一样工作。 -与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此,使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂、由提示词管理的流程应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制使用正式发布版本选择器。完整迁移详情请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 +与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂的提示词管理流程应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制选择正式发布版本。完整迁移详情请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 #### 非 GPT-5 模型 -如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复使用与任何模型兼容的通用 `ModelSettings`。 +如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复为与任意模型兼容的通用 `ModelSettings`。 -### 仅限 Responses 的工具搜索功能 +### Responses 专用工具功能 以下工具功能仅受 OpenAI Responses 模型支持: - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] - `@function_tool(defer_loading=True)` 和其他延迟加载的 Responses 工具接口 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers` 和 `tool_choice="programmatic_tool_calling"` -Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请向智能体添加 `ToolSearchTool()`,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不要强制指定裸命名空间名称或仅限延迟加载的函数名称。配置详情和当前限制请参阅[工具](../tools.md#hosted-tool-search)。 +Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请将 `ToolSearchTool()` 添加到智能体,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制指定不带限定的命名空间名称或仅支持延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 ### Responses WebSocket 传输 -默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用由 OpenAI 支持的模型时,您可以选择启用 websocket 传输。 +默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用由 OpenAI 支持的模型时,可以选择启用 websocket 传输。 -#### 基础配置 +#### 基本配置 ```python from agents import set_default_openai_responses_transport @@ -136,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响由默认 OpenAI 提供商解析的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串形式的模型名称。 +这会影响由默认 OpenAI 提供商解析的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 -SDK 将模型名称解析为模型实例时,会完成传输方式的选择。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已经固定:[‌`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 websocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 继续使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商控制传输方式的选择,而不是使用全局默认设置。 +SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已经固定:[​​`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 websocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 继续使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,将由该提供商控制传输方式选择,而不是全局默认设置。 #### 提供商级或运行级配置 -您还可以按提供商或按运行配置 websocket 传输: +还可以按提供商或按运行配置 websocket 传输: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -189,14 +190,14 @@ result = await Runner.run( #### 使用 `MultiProvider` 的高级路由 -如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 +如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider] 并在其中设置 `openai_use_responses_websocket=True`。 `MultiProvider` 保留了两个历史默认行为: - `openai/...` 被视为 OpenAI 提供商的别名,因此 `openai/gpt-4.1` 会作为模型 `gpt-4.1` 进行路由。 -- 未知前缀会引发 `UserError`,而不会直接透传。 +- 未知前缀会引发 `UserError`,而不是直接透传。 -将 OpenAI 提供商指向需要字面量命名空间模型 ID 的 OpenAI 兼容端点时,请显式启用透传行为。在启用 websocket 的配置中,还应在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: +当 OpenAI 提供商指向需要字面命名空间模型 ID 的 OpenAI 兼容端点时,请显式启用透传行为。在启用 websocket 的配置中,也应在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -222,25 +223,25 @@ result = await Runner.run( ) ``` -当后端要求字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端要求其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项同样适用于 websocket 传输之外的 `MultiProvider`;此示例保持启用 websocket,因为它属于本节所述传输配置的一部分。相同选项也可用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 +当后端需要字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端需要其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项也适用于 websocket 传输之外的 `MultiProvider`;本示例保持启用 websocket,因为它是本节所述传输配置的一部分。[`responses_websocket_session()`][agents.responses_websocket_session] 也提供相同选项。 -如果通过 `MultiProvider` 路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,该配置将转发给底层 OpenAI 提供商。 +如果通过 `MultiProvider` 进行路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它将被转发到底层 OpenAI 提供商。 -如果使用自定义 OpenAI 兼容端点或代理,websocket 传输还要求存在兼容的 websocket `/responses` 端点。在这些配置中,您可能需要显式设置 `websocket_base_url`。 +如果使用自定义 OpenAI 兼容端点或代理,websocket 传输还需要兼容的 websocket `/responses` 端点。在这些配置中,您可能需要显式设置 `websocket_base_url`。 #### 注意事项 -- 这是通过 websocket 传输的 Responses API,并非 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions 或非 OpenAI 提供商,除非这些提供商支持 Responses websocket `/responses` 端点。 -- 如果您的环境中尚未安装 `websockets` 软件包,请进行安装。 -- 启用 websocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次以及嵌套的智能体工具调用复用同一 websocket 连接的多轮工作流,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助函数。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 -- 对于耗时较长的推理轮次或存在延迟峰值的网络,请使用 `responses_websocket_options` 自定义 websocket 保活行为。增大 `ping_timeout` 可容忍延迟的 pong 帧,也可设置 `ping_timeout=None` 以禁用心跳超时,同时保持 ping 启用。当可靠性比 websocket 延迟更重要时,优先使用 HTTP/SSE 传输。 -- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于代理之后长期运行的智能体进程或内存受限容器,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`,以限制每条消息的内存使用量。 +- 这是通过 websocket 传输的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions 或非 OpenAI 提供商,除非它们支持 Responses websocket `/responses` 端点。 +- 如果环境中尚未安装 `websockets` 软件包,请进行安装。 +- 启用 websocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望在多个轮次以及嵌套的“智能体作为工具”调用之间复用同一 websocket 连接的多轮工作流,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助函数。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 +- 对于长时间推理轮次或延迟突增的网络,请使用 `responses_websocket_options` 自定义 websocket 保活行为。增大 `ping_timeout` 可容忍延迟的 pong 帧,或者设置 `ping_timeout=None`,在保持启用 ping 的同时禁用心跳超时。当可靠性比 websocket 延迟更重要时,请优先使用 HTTP/SSE 传输。 +- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于位于代理之后或资源受限容器中的长期运行智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`,以限制每条消息的内存用量。 -### 托管式多智能体(实验性) +### 托管多智能体(实验性) -OpenAI Responses API 托管式多智能体 Beta 版允许 GPT-5.6 根模型创建并协调由服务托管的子智能体。Agents SDK 可以继续使用其常规 `Runner`:托管编排保留在服务端,而开发者定义的工具调用则在您的应用中执行。 +OpenAI Responses API 托管多智能体测试版允许 GPT-5.6 根模型创建和协调由服务托管的子智能体。Agents SDK 可以继续使用其常规 `Runner`:托管编排保留在服务端,而开发者定义的工具调用在您的应用中执行。 -此集成为实验性功能,使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活动的托管智能体。它要求使用 `openai[realtime]>=2.45.0`,其中包括公开 `client.beta.responses.connect` 的 Beta 版本。该接口和 Beta 项架构可能会在正式发布前发生变化。 +此集成为实验性功能,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活跃的托管智能体。它要求安装 `openai[realtime]>=2.45.0`,其中包括公开 `client.beta.responses.connect` 的测试版。该接口和测试版项目架构可能会在正式发布前发生变化。 #### 模型配置 @@ -257,22 +258,22 @@ agent = Agent( ) ``` -构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则该模型会使用默认 OpenAI 客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 +构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则模型会使用默认 OpenAI 客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 #### 本地工具调用 -所有托管智能体共享为请求配置的模型和工具。由 Responses API 决定哪个托管智能体调用函数。常规 SDK Runner 会在本地执行函数,并将具有相同调用 ID 的 `function_call_output` 注入活动的 WebSocket 响应,从而让服务恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:发送请求前,任何 `needs_approval` 设置不为 `False` 的工具调用都会被拒绝。 +所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 会在本地执行函数,并通过相同的调用 ID 将 `function_call_output` 注入活跃的 WebSocket 响应,让服务可以恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的工具调用都会在请求发送前被拒绝。 当工具需要感知调用方的日志记录或授权时,请使用 `get_hosted_agent_metadata()`: ```python from typing import Any -from agents import function_tool +from agents.decorators import tool from agents.extensions.experimental.hosted_multi_agent import get_hosted_agent_metadata from agents.tool_context import ToolContext -@function_tool +@tool def lookup_document(ctx: ToolContext[Any], section: str) -> str: metadata = get_hosted_agent_metadata(ctx) caller = metadata.agent_name if metadata else "unknown" @@ -280,50 +281,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -托管智能体名称是观测元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于会产生副作用的工具,请将该调用 ID 用作幂等键,并在工具执行前或执行期间通过应用代码强制实施任何必要的授权;不要对该模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 +托管智能体名称是观测元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行前或执行期间通过应用代码实施所有必要的授权;请勿对此模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 #### 输出与流式传输行为 -只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为常规最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管编排记录;SDK 永远不会将这些记录作为本地函数执行。 +只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为普通最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管编排记录;SDK 绝不会将这些记录作为本地函数执行。 -原始流式传输仍会公开 Beta Responses 事件,包括托管输出项和 `response.inject.created` 确认。函数调用就绪时,适配器会将一个活动的提供商响应划分为 SDK 可见的逻辑模型轮次;Runner 生成输出后,再恢复同一个提供商响应。使用 `get_hosted_agent_metadata()` 以及原始托管项或 `ToolContext` 可以检查归属信息。 +原始流式传输仍会公开测试版 Responses 事件,包括托管输出项和 `response.inject.created` 确认。函数调用就绪时,适配器会将一个活跃的提供商响应拆分成 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。请对原始托管项或 `ToolContext` 使用 `get_hosted_agent_metadata()` 来检查归属信息。 #### 与 SDK 编排的关系 -托管式多智能体与 SDK 任务转移和 agents-as-tools 相互独立: +托管多智能体不同于 SDK 任务转移和 Agents-as-tools: -- 托管式多智能体在 OpenAI 服务上创建子智能体。您的应用不会创建或调度这些子智能体。 -- SDK 任务转移会更改活动的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,这将造成所有权冲突。 -- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请谨慎评估由此增加的延迟、成本和工具暴露范围。 +- 托管多智能体在 OpenAI 服务上创建子智能体。您的应用不会创建或调度这些子智能体。 +- SDK 任务转移会更改当前活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,这将导致所有权冲突。 +- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估由此增加的延迟、成本和工具暴露。 #### 当前限制 -实验性模型会拒绝 `reasoning.summary`、`max_tool_calls`,以及调用方提供的 `multi_agent` 或 `betas` 覆盖值。Beta 版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 +该实验性模型会拒绝 `reasoning.summary`、`max_tool_calls` 以及调用方提供的 `multi_agent` 或 `betas` 覆盖。测试版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 -一个 `OpenAIHostedMultiAgentModel` 实例同时最多拥有一个活动的托管响应。如果在等待本地函数输出时放弃运行,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复正在进行的托管响应。 +一个 `OpenAIHostedMultiAgentModel` 实例最多只能拥有一个活跃的托管响应。如果运行在等待本地函数输出时被放弃,请调用 `await model.close()` 以释放其 WebSocket。目前不支持在其他进程或事件循环中恢复进行中的托管响应。 -有关底层 Responses API Beta 行为,请参阅 [OpenAI 多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式传输和流式传输的 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 +有关底层 Responses API 测试版行为,请参阅 [OpenAI 多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 ## 非 OpenAI 模型 -如果需要非 OpenAI 提供商,请从 SDK 的内置提供商集成点开始。在许多配置中,无需添加第三方适配器即可满足需求。各种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 +如果需要非 OpenAI 提供商,请从 SDK 的内置提供商集成点开始。在许多配置中,这已经足够,无需添加第三方适配器。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 -### 非 OpenAI 提供商的集成方式 +### 非 OpenAI 提供商集成方式 -| 方法 | 适用情况 | 作用域 | +| 方式 | 适用场景 | 作用域 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 应将一个 OpenAI 兼容端点设为大多数或所有智能体的默认端点 | 全局默认 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应应用于单次运行 | 按运行 | -| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同的提供商或具体模型对象 | 按智能体 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 一个 OpenAI 兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应应用于单次运行 | 每次运行 | +| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同提供商或具体模型对象 | 每个智能体 | | 第三方适配器 | 您需要由适配器管理的提供商覆盖范围或内置路径未提供的路由 | 请参阅[第三方适配器](#third-party-adapters) | -可以通过以下内置路径集成其他 LLM 提供商: +可以使用以下内置路径集成其他 LLM 提供商: -1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望在全局范围内使用 `AsyncOpenAI` 实例作为 LLM 客户端的情况。该方式适用于 LLM 提供商具有 OpenAI 兼容 API 端点,并且您可以设置 `base_url` 和 `api_key` 的情况。可配置的代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] 在 `Runner.run` 层级生效。这允许您指定“为本次运行中的所有智能体使用自定义模型提供商”。可配置的代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 -3. [`Agent.model`][agents.agent.Agent.model] 允许您在特定 Agent 实例上指定模型。这样即可为不同智能体灵活搭配不同提供商。可配置的代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 +1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望全局使用 `AsyncOpenAI` 实例作为 LLM 客户端的情况。这适用于 LLM 提供商具有 OpenAI 兼容 API 端点,并且您可以设置 `base_url` 和 `api_key` 的情况。可配置代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 层级。这样您就可以指定“本次运行中的所有智能体都使用自定义模型提供商”。可配置代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 +3. [`Agent.model`][agents.agent.Agent.model] 允许在特定 Agent 实例上指定模型。这使您能够为不同智能体混合搭配不同提供商。可配置代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 -如果您没有来自 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或设置[其他追踪进程](../tracing.md)。 +如果您没有来自 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或配置[其他追踪进程](../tracing.md)。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -338,19 +339,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持 Responses API,建议使用 Responses。 + 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持它,我们建议使用 Responses。 -## 单一工作流中的模型混用 +## 在一个工作流中混用模型 -在单个工作流中,您可能希望为每个智能体使用不同的模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: +在单个工作流中,您可能希望每个智能体使用不同模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: 1. 传入模型名称。 -2. 传入任意模型名称以及能够将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 +2. 传入任意模型名称和一个能够将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 3. 直接提供 [`Model`][agents.models.interface.Model] 实现。 !!! note - 尽管我们的 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 形式,但我们建议每个工作流使用单一模型形式,因为这两种形式支持的功能和工具集合不同。如果您的工作流需要混用不同模型形式,请确保您使用的所有功能均受两者支持。 + 虽然我们的 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 形式,但建议每个工作流使用一种模型形式,因为这两种形式支持的功能和工具集合不同。如果工作流需要混合搭配不同模型形式,请确保您使用的所有功能都同时受到两者支持。 ```python import asyncio @@ -391,7 +392,7 @@ if __name__ == "__main__": 1. 直接设置 OpenAI 模型的名称。 2. 提供 [`Model`][agents.models.interface.Model] 实现。 -如果希望进一步配置智能体所使用的模型,可以传入 [`ModelSettings`][agents.models.interface.ModelSettings],它提供 temperature 等可选模型配置参数。 +如果要进一步配置智能体使用的模型,可以传入 [`ModelSettings`][agents.models.interface.ModelSettings],其中提供 temperature 等可选模型配置参数。 ```python from agents import Agent, ModelSettings @@ -406,22 +407,22 @@ english_agent = Agent( ## 高级 OpenAI Responses 设置 -使用 OpenAI Responses 路径并需要更多控制时,请先从 `ModelSettings` 开始。 +当您使用 OpenAI Responses 路径并需要更多控制时,请从 `ModelSettings` 开始。 ### 常用高级 `ModelSettings` 选项 -使用 OpenAI Responses API 时,多个请求字段已经有对应的直接 `ModelSettings` 字段,因此无需通过 `extra_args` 设置。 +使用 OpenAI Responses API 时,多个请求字段已具有直接对应的 `ModelSettings` 字段,因此无需通过 `extra_args` 传递。 -- `parallel_tool_calls`:允许或禁止在同一轮次中进行多次工具调用。 -- `truncation`:设置为 `"auto"`,让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是让请求失败。 -- `store`:控制是否将生成的响应存储在服务端,以供后续检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 +- `parallel_tool_calls`:允许或禁止在同一轮次中进行多个工具调用。 +- `truncation`:设置为 `"auto"`,可让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是使请求失败。 +- `store`:控制是否在服务端存储生成的响应,以供后续检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 - `context_management`:配置服务端上下文处理,例如使用 `compact_threshold` 进行 Responses 压缩。 -- `prompt_cache_retention`:为较早的模型系列配置延长保留时间,例如 +- `prompt_cache_retention`:为早期模型系列配置延长的保留期,例如 使用 `"24h"`。 -- `prompt_cache_options`:选择隐式或显式提示词缓存;对于 GPT-5.6,还可以配置 `"30m"` 缓存 TTL。 +- `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置 `"30m"` 缓存 TTL。 - `response_include`:请求更丰富的响应载荷,例如 `web_search_call.action.sources`、`file_search_call.results` 或 `reasoning.encrypted_content`。 -- `top_logprobs`:请求输出文本的最高概率 token 对数概率。SDK 还会自动添加 `message.output_text.logprobs`。 -- `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅 [Runner 管理的重试](#runner-managed-retries)。 +- `top_logprobs`:请求输出文本的高概率词元 logprobs。SDK 还会自动添加 `message.output_text.logprobs`。 +- `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅[由 Runner 管理的重试](#runner-managed-retries)。 ```python from agents import Agent, ModelSettings @@ -441,7 +442,7 @@ research_agent = Agent( ) ``` -使用显式提示词缓存时,请在可复用前缀结束处的内容部分添加断点。相同的 `ModelSettings.prompt_cache_options` 字段会透传给 Responses 和 Chat Completions 请求,而 Chat Completions 转换器会保留文本、图像、音频和文件内容部分中的断点。 +使用显式提示词缓存时,请在可复用前缀结束处的内容部分添加断点。同一个 `ModelSettings.prompt_cache_options` 字段会在 Responses 和 Chat Completions 请求中透传,而 Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 ```python from agents import Runner @@ -467,19 +468,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention` 仍适用于使用旧版 -保留控制的较早模型系列。请勿同时通过直接 `ModelSettings` 字段和 -`extra_args` 设置相同的键。 +对于使用旧版保留控制的早期模型系列,`prompt_cache_retention` 仍然可用。请勿将直接的 `ModelSettings` 字段与 +`extra_args` 中的同名键结合使用。 -设置 `store=False` 后,Responses API 不会保留该响应以供日后在服务端检索。这适用于无状态或零数据保留风格的流程,但也意味着原本可以复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当最后一个响应未存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 +设置 `store=False` 后,Responses API 不会保留该响应以供后续服务端检索。这对于无状态或零数据保留类型的流程很有用,但也意味着原本会复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当上一个响应未被存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将其默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 -服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每个 Responses API 请求一起发送,当渲染后的上下文超过阈值时,API 可以在响应中生成压缩项。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 +服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每次 Responses API 请求一起发送,当渲染后的上下文超过阈值时,API 可以将压缩项作为响应的一部分发出。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 -### `extra_args` 的传递 +### `extra_args` 传递 -当需要 SDK 尚未直接在顶层公开的提供商特定请求字段或较新的请求字段时,请使用 `extra_args`。 +当您需要 SDK 尚未在顶层直接公开的提供商特定字段或较新的请求字段时,请使用 `extra_args`。 -此外,使用 OpenAI 的 Responses API 时,[还有一些其他可选参数](https://platform.openai.com/docs/api-reference/responses/create),例如 `user`、`service_tier` 等。如果这些参数在顶层不可用,也可以通过 `extra_args` 传入。请勿同时通过直接 `ModelSettings` 字段设置相同的请求字段。 +此外,使用 OpenAI 的 Responses API 时,[还有一些其他可选参数](https://platform.openai.com/docs/api-reference/responses/create),例如 `user`、`service_tier` 等。如果顶层没有这些参数,也可以通过 `extra_args` 传递。请勿同时通过直接的 `ModelSettings` 字段设置同一个请求字段。 ```python from agents import Agent, ModelSettings @@ -495,9 +495,9 @@ english_agent = Agent( ) ``` -## Runner 管理的重试 +## 由 Runner 管理的重试 -重试仅在运行时生效,并且需要选择启用。除非您设置 `ModelSettings(retry=...)` 且重试策略决定进行重试,否则 SDK 不会重试常规模型请求。 +重试仅在运行时生效,并且需要主动启用。除非设置 `ModelSettings(retry=...)` 且重试策略选择重试,否则 SDK 不会重试常规模型请求。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -525,85 +525,85 @@ agent = Agent( ) ``` -`ModelRetrySettings` 有三个字段: +`ModelRetrySettings` 包含三个字段:
| 字段 | 类型 | 说明 | | --- | --- | --- | | `max_retries` | `int | None` | 初始请求之后允许的重试次数。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 策略决定重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制此处计算出的退避延迟,不会限制策略返回的显式延迟或 retry-after 提示。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略选择重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制由此计算得出的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | | `policy` | `RetryPolicy | None` | 决定是否重试的回调。此字段仅在运行时生效,不会被序列化。 |
-重试策略会接收一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: +重试策略会收到一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: -- `attempt` 和 `max_retries`,以便根据尝试次数作出决策。 -- `stream`,以便区分流式与非流式行为。 -- `error`,用于检查原始错误。 +- `attempt` 和 `max_retries`,以便根据尝试次数做出决策。 +- `stream`,以便在流式和非流式行为之间进行分支。 +- `error`,用于原始检查。 - `normalized` 事实,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 -- 当底层模型适配器能够提供重试指导时使用的 `provider_advice`。 +- `provider_advice`,在底层模型适配器能够提供重试指导时使用。 -策略可以返回: +策略可以返回以下任一种结果: -- `True` / `False`,用于作出简单的重试决定。 -- 当您希望覆盖延迟或附加诊断原因时,返回 [`RetryDecision`][agents.retry.RetryDecision]。 +- `True` / `False`,用于简单的重试决策。 +- [`RetryDecision`][agents.retry.RetryDecision],用于覆盖延迟或附加诊断原因。 SDK 在 `retry_policies` 中导出了现成的辅助函数: | 辅助函数 | 行为 | | --- | --- | -| `retry_policies.never()` | 始终不启用重试。 | -| `retry_policies.provider_suggested()` | 在提供商给出重试建议时遵循其建议。 | -| `retry_policies.network_error()` | 匹配暂时性传输故障和超时故障。 | +| `retry_policies.never()` | 始终不重试。 | +| `retry_policies.provider_suggested()` | 在有可用信息时遵循提供商的重试建议。 | +| `retry_policies.network_error()` | 匹配暂时性传输失败和超时失败。 | | `retry_policies.http_status([...])` | 匹配选定的 HTTP 状态码。 | -| `retry_policies.retry_after()` | 仅在存在 retry-after 提示时重试,并使用其延迟时间。此辅助函数会将 retry-after 值视为显式策略延迟,因此 `backoff.max_delay` 不会限制它。 | -| `retry_policies.any(...)` | 任一嵌套策略启用重试时进行重试。 | -| `retry_policies.all(...)` | 仅当所有嵌套策略均启用重试时才进行重试。 | +| `retry_policies.retry_after()` | 仅当存在 retry-after 提示时重试,并使用该延迟。此辅助函数将 retry-after 值视为显式策略延迟,因此不受 `backoff.max_delay` 限制。 | +| `retry_policies.any(...)` | 任何嵌套策略选择重试时进行重试。 | +| `retry_policies.all(...)` | 仅当所有嵌套策略都选择重试时进行重试。 | -组合策略时,`provider_suggested()` 是最安全的首选基础组件,因为当提供商能够区分这些情况时,它会保留提供商的否决意见和重放安全审批。 +组合策略时,`provider_suggested()` 是最安全的首选基本组件,因为当提供商能够识别否决条件和重放安全批准时,它会保留这些信息。 ##### 安全边界 -某些失败永远不会自动重试: +某些失败绝不会自动重试: - 中止错误。 - 提供商建议将重放标记为不安全的请求。 -- 已经开始输出,且重放会造成安全风险的流式运行。 +- 输出已经开始,且重放会产生不安全结果的流式运行。 -使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求也会以更保守的方式处理。对于这些请求,仅使用 `network_error()` 或 `http_status([500])` 等非提供商判断条件并不足够。重试策略应包含来自提供商的重放安全审批,通常通过 `retry_policies.provider_suggested()` 实现。 +使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求也会受到更保守的处理。对于这些请求,仅使用 `network_error()` 或 `http_status([500])` 等非提供商谓词并不足够。重试策略应包含提供商对重放安全性的批准,通常通过 `retry_policies.provider_suggested()` 实现。 ##### Runner 与智能体的合并行为 Runner 级和智能体级 `ModelSettings` 之间会对 `retry` 进行深度合并: -- 智能体可以只覆盖 `retry.max_retries`,同时仍继承 Runner 的 `policy`。 -- 智能体可以只覆盖 `retry.backoff` 的一部分,并保留 Runner 中同级的其他退避字段。 +- 智能体可以仅覆盖 `retry.max_retries`,同时继承 Runner 的 `policy`。 +- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留 Runner 中其他同级退避字段。 - `policy` 仅在运行时生效,因此序列化后的 `ModelSettings` 会保留 `max_retries` 和 `backoff`,但省略回调本身。 -更完整的代码示例请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[基于适配器的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 +更多完整代码示例请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[基于适配器的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 ## 非 OpenAI 提供商故障排除 -### 追踪客户端 401 错误 +### 追踪客户端错误 401 -如果遇到与追踪相关的错误,原因是追踪数据会上传到 OpenAI 服务,而您没有 OpenAI API 密钥。可以通过以下三种方式解决: +如果遇到与追踪相关的错误,这是因为追踪数据会上传到 OpenAI 服务,而您没有 OpenAI API 密钥。可以通过以下三种方式解决: -1. 完全禁用追踪:[`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. 为追踪设置 OpenAI 密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,且必须来自 [platform.openai.com](https://platform.openai.com/)。 +1. 完全禁用追踪:[​​`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 +2. 为追踪设置 OpenAI 密钥:[​​`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 3. 使用非 OpenAI 追踪进程。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 ### Responses API 支持 -SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持该 API。因此,您可能会遇到 404 或类似问题。可以通过以下两种方式解决: +SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,您可能会遇到 404 或类似问题。可以通过以下两种方式解决: -1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,则可使用此方式。 -2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)提供了相关代码示例。 +1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方法有效。 +2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。相关代码示例请参阅[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 ### Chat Completions 兼容性选项 -通过 Chat Completions 进行路由时,SDK 会静默丢弃 Chat Completions 无法发送的仅限 Responses 字段,例如 `previous_response_id`、`conversation_id`、提示词或非纯文本工具输出,以保持兼容性。如果希望在开发过程中快速暴露这些不匹配问题,请在 OpenAI 提供商上启用严格功能验证: +通过 Chat Completions 进行路由时,SDK 会通过静默丢弃 Chat Completions 无法发送的 Responses 专用字段来保持兼容性,例如 `previous_response_id`、`conversation_id`、提示词或并非纯文本的工具输出。如果希望这些不匹配问题在开发期间快速失败,请在 OpenAI 提供商上启用严格功能验证: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -623,7 +623,7 @@ result = await Runner.run( 如果使用 [`MultiProvider`][agents.MultiProvider],请改为传入 `openai_strict_feature_validation=True`。 -某些 OpenAI 兼容的 Chat Completions 提供商会以分块形式流式传输工具调用增量,但这些分块不够可靠,无法由 SDK 进行增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后生成工具调用: +某些 OpenAI 兼容的 Chat Completions 提供商会以分块方式流式传输工具调用增量,而这些分块不足以支持可靠的 SDK 增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后发出工具调用: ```python from agents import OpenAIProvider @@ -638,7 +638,7 @@ provider = OpenAIProvider( ### structured outputs 支持 -某些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会产生如下错误: +部分模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: ``` @@ -646,42 +646,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -这是某些模型提供商的不足之处:它们支持 JSON 输出,但不允许您指定用于输出的 `json_schema`。我们正在开发修复方案,但建议依赖支持 JSON Schema 输出的提供商,否则您的应用经常会因 JSON 格式错误而中断。 +这是部分模型提供商的不足之处:它们支持 JSON 输出,但不允许指定用于输出的 `json_schema`。我们正在修复此问题,但建议依赖支持 JSON 架构输出的提供商,否则您的应用通常会因为 JSON 格式错误而中断。 -## 跨提供商的模型混用 +## 跨提供商混用模型 -您需要了解不同模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI 支持 structured outputs、多模态输入、托管式文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: +您需要了解模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI 支持 structured outputs、多模态输入以及托管文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: -- 不要向无法理解相应 `tools` 的提供商发送不受支持的 `tools` -- 调用纯文本模型前,请过滤掉多模态输入 -- 请注意,不支持结构化 JSON 输出的提供商有时会生成无效 JSON。 +- 不要向无法理解的提供商发送其不支持的 `tools` +- 调用仅支持文本的模型之前,请过滤掉多模态输入 +- 请注意,不支持结构化 JSON 输出的提供商偶尔会生成无效 JSON。 ## 第三方适配器 -只有在 SDK 的内置提供商集成点无法满足需求时,才应使用第三方适配器。如果此 SDK 仅使用 OpenAI 模型,请优先使用内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI 模型与非 OpenAI 提供商组合使用,或需要由适配器管理的提供商覆盖范围或内置路径未提供的路由。适配器会在 SDK 与上游模型提供商之间增加一层兼容层,因此不同提供商的功能支持和请求语义可能有所不同。SDK 当前以尽力支持的 Beta 版适配器集成形式包含 Any-LLM 和 LiteLLM。 +仅当 SDK 的内置提供商集成点无法满足需求时,才使用第三方适配器。如果您仅通过此 SDK 使用 OpenAI 模型,请优先使用内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI 模型与非 OpenAI 提供商结合使用,或需要由适配器管理的提供商覆盖范围或内置路径未提供的路由。适配器会在 SDK 与上游模型提供商之间添加额外的兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的测试版集成形式提供 Any-LLM 和 LiteLLM 适配器。 ### Any-LLM -对于需要由 Any-LLM 管理提供商覆盖范围或路由的情况,我们以尽力支持的 Beta 版形式提供 Any-LLM 支持。 +Any-LLM 支持以尽力支持的测试版形式提供,适用于需要由 Any-LLM 管理提供商覆盖范围或路由的情况。 -根据上游提供商路径,Any-LLM 可能会使用 Responses API、Chat Completions 兼容 API 或提供商特定的兼容层。 +根据上游提供商路径,Any-LLM 可能使用 Responses API、Chat Completions 兼容 API 或提供商特定的兼容层。 -如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。您可以通过 [`MultiProvider`][agents.MultiProvider] 使用 `any-llm/...` 模型名称,直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 +如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 结合使用、直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 -Any-LLM 仍然属于第三方适配器层,因此提供商依赖项和能力缺口由上游 Any-LLM 而非 SDK 定义。当上游提供商返回使用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会生成使用量数据块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 特定行为,请验证您计划部署的具体提供商后端。 +Any-LLM 仍属于第三方适配器层,因此提供商依赖项和功能缺口由上游 Any-LLM 定义,而不是由 SDK 定义。当上游提供商返回使用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会发出使用量数据块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 ### LiteLLM -对于需要 LiteLLM 特定提供商覆盖范围或路由的情况,我们以尽力支持的 Beta 版形式提供 LiteLLM 支持。 +LiteLLM 支持以尽力支持的测试版形式提供,适用于需要 LiteLLM 特定提供商覆盖范围或路由的情况。 -如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。您可以使用 `litellm/...` 模型名称,也可以直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 +如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。可以使用 `litellm/...` 模型名称,或直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 -某些由 LiteLLM 支持的提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器特定的路由行为,请验证您计划部署的具体提供商后端。 +部分由 LiteLLM 支持的提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 -如果 LiteLLM 针对响应对象生成 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器前选择启用 SDK 的兼容性补丁: +如果 LiteLLM 对响应对象发出 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器前选择启用 SDK 的兼容性补丁: ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -该补丁默认禁用,只有值为 `1` 或 `true` 时才会启用。它通过封装 LiteLLM 的私有日志辅助函数来抑制特定类型的 LiteLLM 响应序列化警告,因此应将其视为针对性解决方法,而不是通用序列化设置。由于该补丁依赖 LiteLLM 的私有 API,升级 LiteLLM 时请再次验证;上游警告不再出现后,请移除该环境变量。 \ No newline at end of file +该补丁默认禁用,仅当值为 `1` 或 `true` 时才启用。它通过包装 LiteLLM 的私有日志辅助函数,抑制特定类别的 LiteLLM 响应序列化警告,因此应将其视为有针对性的解决方法,而不是通用序列化设置。由于它依赖 LiteLLM 的私有 API,升级 LiteLLM 时请重新验证,并在上游不再出现该警告后移除该环境变量。 \ No newline at end of file diff --git a/docs/zh/quickstart.md b/docs/zh/quickstart.md index 56afce88a8..fba6d12718 100644 --- a/docs/zh/quickstart.md +++ b/docs/zh/quickstart.md @@ -114,10 +114,11 @@ if __name__ == "__main__": ```python import asyncio -from agents import Agent, Runner, function_tool +from agents import Agent, Runner +from agents.decorators import tool -@function_tool +@tool def history_fun_fact() -> str: """Return a short history fact.""" return "Sharks are older than trees." diff --git a/docs/zh/realtime/guide.md b/docs/zh/realtime/guide.md index b706132217..74491e1d88 100644 --- a/docs/zh/realtime/guide.md +++ b/docs/zh/realtime/guide.md @@ -214,10 +214,10 @@ async for event in session: 实时智能体支持在实时对话期间使用工具调用: ```python -from agents import function_tool +from agents.decorators import tool -@function_tool +@tool def get_weather(city: str) -> str: """Get current weather for a city.""" return f"The weather in {city} is sunny, 72F." diff --git a/docs/zh/release.md b/docs/zh/release.md index 43e3599e6e..d045a87e8e 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -4,13 +4,13 @@ search: --- # 发布流程/变更日志 -本项目采用略作修改的语义化版本控制,版本格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各组成部分按以下规则递增: +本项目采用略作修改的语义化版本控制,格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各部分按以下方式递增: ## 次版本(`Y`) -对于任何未标记为 beta 的公共接口,如果存在**破坏性变更**,我们将递增次版本号 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 +对于未标记为 beta 的任何公共接口所发生的**破坏性变更**,我们将递增次版本号 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 -如果不希望遇到破坏性变更,建议在项目中将版本锁定为 `0.0.x`。 +如果您不希望遇到破坏性变更,建议在项目中将版本固定为 `0.0.x`。 ## 补丁版本(`Z`) @@ -23,19 +23,32 @@ search: ## 破坏性变更日志 +### 0.19.0 + +此次次版本发布**不**引入破坏性变更。次版本号的提升反映了 OpenAI Responses 的一个重要新功能领域:程序化工具调用。 + +亮点: + +- 新增 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],支持的 OpenAI Responses 模型可借此生成 JavaScript 来协调符合条件的工具。它支持针对每个工具的 `allowed_callers`、结构化函数工具输出,并与 Runner 流式传输、安全防护措施、审批、会话及 `RunState` 集成。有关设置和限制,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 +- 新增公共 `agents.decorators` 模块和更简洁的 `@tool` 别名,同时保留现有的函数及安全防护措施装饰器。函数工具现在还支持异步可调用对象。 +- 智能体、运行、模型、会话、沙箱和语音管线的 SDK 配置现在都能以一致的方式接受类型化设置对象或字典,并验证未知设置。 +- 加强了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误及诊断日志,在保留有用调试上下文的同时,避免暴露原始敏感载荷。 +- 改进了 AnyLLM、LiteLLM 和 Chat Completions 兼容性,在模型重试时保留会话历史,并对响应开始前发生的 WebSocket 过载错误进行重试。 +- 新增了通过 `VercelCloudBucketMountStrategy` 实现的[Vercel 沙箱创建时专用 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。包含挂载的会话会在工作区持久化时排除存储桶内容,并且有意不支持动态挂载更改或会话恢复。 + ### 0.18.0 -此次次版本发布**没有**引入破坏性变更。递增次版本号仅用于更新 Realtime智能体的默认模型。 +此次次版本发布**不**引入破坏性变更。次版本号的提升仅用于 Realtime 智能体默认模型更新。 亮点: -- Realtime智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 配置无需额外设置即可使用最新推荐模型。 +- Realtime 智能体现在默认使用 `gpt-realtime-2.1`,因此新的 Realtime 配置无须额外设置即可使用最新的推荐模型。 ### 0.17.0 -在此版本中,沙箱本地源实体化会将 `LocalFile.src` 和 `LocalDir.src` 限制在实体化的 `base_dir` 内,除非源路径包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源路径从该目录解析,而绝对本地源路径必须已位于该目录内或显式授权的路径下。这修复了本地产物边界问题,但可能影响有意将该基础目录之外受信任的主机文件或目录复制到沙箱工作区的应用程序。 +在此版本中,沙箱本地源材料化会将 `LocalFile.src` 和 `LocalDir.src` 限制在材料化 `base_dir` 内,除非源路径已包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源路径从该目录解析,而绝对本地源路径必须已位于该目录内或显式授权的路径下。此变更修复了本地产物边界问题,但可能会影响有意将该基础目录之外的可信主机文件或目录复制到沙箱工作区的应用。 -如需迁移,请使用 `SandboxPathGrant` 在清单级别授权受信任的主机根目录;如果沙箱只需读取这些文件,最好将其设为只读: +如需迁移,请使用 `SandboxPathGrant` 在清单级别授权可信主机根目录;如果沙箱只需读取这些文件,最好将授权设为只读: ```python from pathlib import Path @@ -62,13 +75,13 @@ manifest = Manifest( ) ``` -请将 `extra_path_grants` 视为受信任的应用程序配置。除非应用程序已批准相关主机路径,否则不要根据模型输出或其他不受信任的清单输入填充授权项。 +请将 `extra_path_grants` 视为可信的应用配置。除非您的应用已批准相关主机路径,否则不要使用模型输出或其他不可信的清单输入来填充授权。 ### 0.16.0 -在此版本中,SDK 默认模型已从 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含 GPT-5 的默认值,例如 `reasoning.effort="none"` 和 `verbosity="low"`。 +在此版本中,SDK 默认模型已从 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包括 GPT-5 的默认值,例如 `reasoning.effort="none"` 和 `verbosity="low"`。 -如果需要保留之前的默认模型行为,请在智能体或运行配置中显式设置模型,或者设置 `OPENAI_DEFAULT_MODEL` 环境变量: +如果需要保留此前的默认模型行为,请在智能体或运行配置中显式设置模型,或设置 `OPENAI_DEFAULT_MODEL` 环境变量: ```python agent = Agent(name="Assistant", model="gpt-4.1") @@ -77,13 +90,13 @@ agent = Agent(name="Assistant", model="gpt-4.1") 亮点: - `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 -- 在本地、Docker 和由提供商支持的沙箱实现中,沙箱工作区填充现在会拒绝包含指向归档根目录之外的符号链接(包括绝对符号链接目标)的 tar 归档。 +- 在本地、Docker 和提供商支持的沙箱实现中,沙箱工作区数据填充现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 ### 0.15.0 -在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于 structured outputs,也不会再导致运行循环持续重试,直至出现 `MaxTurnsExceeded`。 +在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于 structured outputs,也不再导致运行循环不断重试直至触发 `MaxTurnsExceeded`。 -这会影响此前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要在不引发异常的情况下处理拒绝,请提供 `model_refusal` 运行错误处理程序: +这会影响此前预期仅含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不引发异常,请提供 `model_refusal` 运行错误处理程序: ```python result = Runner.run_sync( @@ -93,81 +106,81 @@ result = Runner.run_sync( ) ``` -对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出模式匹配的值,SDK 将像验证其他运行错误处理程序的最终输出一样对其进行验证。 +对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样对其进行验证。 ### 0.14.0 -此次次版本发布**没有**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 +此次次版本发布**不**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用该功能所需的运行时、后端和文档支持。 亮点: -- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化的隔离工作区中处理文件、目录、Git 仓库、挂载、快照,并支持恢复。 -- 新增通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 支持本地及容器化开发的沙箱执行后端,并通过可选扩展集成 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 等托管提供商。 -- 新增沙箱记忆支持,使后续运行可以复用先前运行中的经验,并支持渐进式披露、多轮分组、可配置的隔离边界,以及包含 S3 后端工作流的持久化记忆代码示例。 -- 新增更广泛的工作区和恢复模型,包括本地及合成工作区条目、用于 S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 -- 在 `examples/sandbox/` 下新增大量沙箱代码示例和教程,涵盖结合技能、任务转移和记忆的编码任务、特定提供商的配置,以及代码审查、数据室问答和网站克隆等端到端工作流。 -- 扩展核心运行时和追踪技术栈,新增沙箱感知的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出脱敏。 +- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化隔离工作区中处理文件、目录、Git 仓库、挂载、快照,并支持恢复。 +- 通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 新增用于本地和容器化开发的沙箱执行后端,并通过可选附加依赖提供 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 的托管提供商集成。 +- 新增沙箱记忆支持,使未来运行可以复用先前运行中的经验,并提供渐进式披露、多轮分组、可配置的隔离边界,以及包含 S3 支持工作流的持久化记忆代码示例。 +- 新增更全面的工作区和恢复模型,包括本地与合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照执行的恢复流程。 +- 在 `examples/sandbox/` 下新增大量沙箱代码示例和教程,涵盖使用技能的编码任务、任务转移、记忆、特定提供商配置,以及代码审查、数据室问答和网站克隆等端到端工作流。 +- 扩展核心运行时和追踪栈,加入可感知沙箱的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出脱敏。 ### 0.13.0 -此次次版本发布**没有**引入破坏性变更,但包含一项值得关注的 Realtime 默认设置更新、新的 MCP 功能以及运行时稳定性修复。 +此次次版本发布**不**引入破坏性变更,但包含一项值得注意的 Realtime 默认设置更新,以及新的 MCP 功能和运行时稳定性修复。 亮点: -- 默认 websocket Realtime 模型现在是 `gpt-realtime-1.5`,因此新的 Realtime智能体配置无需额外设置即可使用更新的模型。 -- `MCPServer` 现在公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在公开 `session_id`,从而使可流式传输的 HTTP 会话能够在重新连接或无状态工作进程之间恢复。 -- Chat Completions集成现在可以通过 `should_replay_reasoning_content` 选择启用推理内容重放,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理/工具调用连续性。 -- 修复多个运行时和会话边界情况,包括 `SQLAlchemySession` 中并发的首次写入、移除推理内容后包含孤立助手消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项,以及工具调用批量执行器中的竞态条件。 +- 默认的 websocket Realtime 模型现在是 `gpt-realtime-1.5`,因此新的 Realtime 智能体配置无须额外设置即可使用更新的模型。 +- `MCPServer` 现在公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在公开 `session_id`,以便可流式传输的 HTTP 会话在重新连接后或无状态工作进程之间恢复。 +- Chat Completions 集成现在可以通过 `should_replay_reasoning_content` 选择启用推理内容重放,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理/工具调用连续性。 +- 修复了多个运行时和会话边界情况,包括 `SQLAlchemySession` 中并发执行的首次写入、移除推理内容后包含孤立助手消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项,以及工具调用批量执行器中的竞态问题。 ### 0.12.0 -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 +此次次版本发布**不**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 ### 0.11.0 -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 +此次次版本发布**不**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 ### 0.10.0 -此次次版本发布**没有**引入破坏性变更,但为 OpenAI Responses用户新增了一个重要功能领域:Responses API 的 websocket 传输支持。 +此次次版本发布**不**引入破坏性变更,但包含一项面向 OpenAI Responses 用户的重要新功能:Responses API 的 websocket 传输支持。 亮点: -- 新增对 OpenAI Responses模型的 websocket 传输支持(需选择启用;HTTP 仍为默认传输方式)。 -- 新增 `responses_websocket_session()` 辅助函数 / `ResponsesWebSocketSession`,用于在多轮运行中复用支持 websocket 的共享提供商和 `RunConfig`。 +- 新增 OpenAI Responses 模型的 websocket 传输支持(需选择启用;HTTP 仍是默认传输方式)。 +- 新增 `responses_websocket_session()` 辅助函数/`ResponsesWebSocketSession`,用于在多轮运行中复用支持 websocket 的共享提供商和 `RunConfig`。 - 新增 websocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 ### 0.9.0 -在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前终止生命周期。请升级到更新的运行时版本。 +在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前结束生命周期(EOL)。请升级到更新的运行时版本。 -此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会导致破坏性问题,但如果代码依赖更宽泛的联合类型,可能需要进行一些调整。 +此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会引发破坏性问题,但如果您的代码依赖更宽泛的联合类型,可能需要进行一些调整。 ### 0.8.0 -在此版本中,两项运行时行为变更可能需要迁移: +在此版本中,两项运行时行为变更可能需要进行迁移: -- 包装**同步** Python 可调用对象的工具调用现在通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中显式指定线程亲和性。 -- 本地 MCP 工具的故障处理现在可配置,默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务级别的 `failure_error_function` 值会覆盖智能体级别的设置,因此请在每个具有显式处理程序的本地 MCP服务上设置 `failure_error_function=None`。 +- 封装**同步** Python 可调用对象的工具调用现在通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果您的工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中显式指定线程亲和性。 +- 本地 MCP 工具失败处理现在可配置,且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果您依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务级别的 `failure_error_function` 值会覆盖智能体级别的设置,因此请在每个具有显式处理程序的本地 MCP 服务上设置 `failure_error_function=None`。 ### 0.7.0 -在此版本中,有几项行为变更可能会影响现有应用程序: +在此版本中,有几项行为变更可能会影响现有应用: -- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果依赖 v0.6.x 默认的嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 -- `gpt-5.1` / `gpt-5.2` 的默认 `reasoning.effort` 已从 SDK 默认设置所配置的 `"low"` 更改为 `"none"`。如果提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置该值。 +- 嵌套任务转移历史现在需要**选择启用**(默认禁用)。如果您依赖 v0.6.x 的默认嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 +- `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(此前由 SDK 默认值配置为 `"low"`)。如果您的提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置。 ### 0.6.0 -在此版本中,默认任务转移历史记录现在会打包为一条助手消息,而不再公开原始的用户/助手轮次,从而为下游智能体提供简洁且可预测的回顾 -- 现有的单消息任务转移记录现在默认在 `` 块之前以“作为上下文,以下是用户与前一个智能体之间截至目前的对话:”开头,从而为下游智能体提供带有清晰标签的回顾 +在此版本中,默认任务转移历史现在会打包到单条助手消息中,而不再公开原始的用户/助手轮次,从而为下游智能体提供简洁且可预测的回顾 +- 现有的单消息任务转移对话记录现在默认在 `` 块之前以“For context, here is the conversation so far between the user and the previous agent:”开头,以便为下游智能体提供带有明确标签的回顾 ### 0.5.0 此版本未引入任何可见的破坏性变更,但包含新功能和几项重要的底层更新: - 新增对 `RealtimeRunner` 处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持 -- 为兼容 Python 3.14,对 `Runner#run_sync` 的内部逻辑进行了重大修订 +- 为兼容 Python 3.14,对 `Runner#run_sync` 的内部逻辑进行了大幅修改 ### 0.4.0 @@ -175,12 +188,12 @@ result = Runner.run_sync( ### 0.3.0 -在此版本中,Realtime API支持迁移到 gpt-realtime 模型及其 API 接口(GA 版本)。 +在此版本中,Realtime API 支持迁移到 gpt-realtime 模型及其 API 接口(GA 版本)。 ### 0.2.0 -在此版本中,一些过去接受 `Agent` 作为参数的位置现在改为接受 `AgentBase`。例如,MCP服务中的 `list_tools()` 调用。这仅是类型变更,仍会收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase` 以修复类型错误。 +在此版本中,少数此前接受 `Agent` 作为参数的位置现在改为接受 `AgentBase`。例如,MCP 服务中的 `list_tools()` 调用。这纯粹是类型层面的变更,您仍会收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase` 以修复类型错误。 ### 0.1.0 -在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增两个参数:`run_context` 和 `agent`。需要将这些参数添加到所有继承 `MCPServer` 的类中。 \ No newline at end of file +在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。您需要将这些参数添加到任何继承 `MCPServer` 的类中。 diff --git a/docs/zh/results.md b/docs/zh/results.md index b4fbe23dd4..5be8048b37 100644 --- a/docs/zh/results.md +++ b/docs/zh/results.md @@ -6,93 +6,122 @@ search: 调用 `Runner.run` 方法时,你会收到以下两种结果类型之一: -- 来自 `Runner.run(...)` 或 `Runner.run_sync(...)` 的 [`RunResult`][agents.result.RunResult] -- 来自 `Runner.run_streamed(...)` 的 [`RunResultStreaming`][agents.result.RunResultStreaming] +- [`RunResult`][agents.result.RunResult],来自 `Runner.run(...)` 或 `Runner.run_sync(...)` +- [`RunResultStreaming`][agents.result.RunResultStreaming],来自 `Runner.run_streamed(...)` -二者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者公开了共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 +两者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者提供共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 -`RunResultStreaming` 增加了流式传输专用控制项,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 +`RunResultStreaming` 还添加了流式传输专用控制功能,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 -## 合适的结果接口 +## 结果接口的选择 大多数应用只需要少数几个结果属性或辅助方法: -| 如果你需要... | 使用 | +| 如果需要…… | 使用 | | --- | --- | -| 展示给用户的最终答案 | `final_output` | -| 可用于重放的下一轮输入列表,包含完整本地转录记录 | `to_input_list()` | -| 包含智能体、工具、任务转移和审批元数据的丰富运行条目 | `new_items` | +| 向用户显示最终答案 | `final_output` | +| 包含完整本地对话记录、可用于重放的下一轮输入列表 | `to_input_list()` | +| 包含智能体、工具、任务转移和审批元数据的丰富运行项 | `new_items` | | 通常应处理下一轮用户输入的智能体 | `last_agent` | -| 使用 `previous_response_id` 进行 OpenAI Responses API 链接 | `last_response_id` | -| 待处理审批和可恢复快照 | `interruptions` 和 `to_state()` | +| 使用 `previous_response_id` 串联 OpenAI Responses API | `last_response_id` | +| 待处理的审批和可恢复快照 | `interruptions` 和 `to_state()` | | 当前嵌套 `Agent.as_tool()` 调用的元数据 | `agent_tool_invocation` | -| 原始模型调用或安全防护措施诊断 | `raw_responses` 和安全防护措施结果数组 | +| 原始模型调用或安全防护措施诊断信息 | `raw_responses` 和安全防护措施结果数组 | ## 最终输出 -[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后运行的智能体的最终输出。它可能是: +[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后运行的智能体所生成的最终输出。它可能是: -- 一个 `str`,如果最后一个智能体未定义 `output_type` -- `last_agent.output_type` 类型的对象,如果最后一个智能体定义了输出类型 -- `None`,如果运行在产生最终输出之前停止,例如因审批中断而暂停 +- `str`,如果最后一个智能体未定义 `output_type` +- `last_agent.output_type` 类型的对象,如果最后一个智能体定义了输出类型 +- `None`,如果运行在生成最终输出之前停止,例如因审批中断而暂停 !!! note - `final_output` 的类型为 `Any`。任务转移可能会改变哪个智能体结束运行,因此 SDK 无法静态得知所有可能的输出类型。 + `final_output` 的类型标注为 `Any`。任务转移可能会改变最终完成运行的智能体,因此 SDK 无法静态确定所有可能的输出类型。 -在流式传输模式下,`final_output` 会保持为 `None`,直到流处理完成。有关逐事件流程,请参阅[流式传输](streaming.md)。 +在流式传输模式下,`final_output` 会一直保持为 `None`,直到流处理完成。有关逐事件处理流程,请参阅[流式传输](streaming.md)。 -## 输入、下一轮历史记录和新条目 +## 输入、下一轮历史记录和新项目 -这些接口回答的是不同问题: +这些接口分别回答不同的问题: | 属性或辅助方法 | 包含的内容 | 最适合 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | 此运行片段的基础输入。如果任务转移输入过滤器重写了历史记录,这里会反映运行继续时所使用的过滤后输入。 | 审计此运行实际使用了什么作为输入 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 运行的输入条目视图。默认 `mode="preserve_all"` 会保留来自 `new_items` 的完整转换后历史记录;`mode="normalized"` 会在任务转移过滤重写模型历史记录时优先使用规范续接输入。 | 手动聊天循环、客户端管理的对话状态,以及普通条目历史记录检查 | -| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 包装器。 | 日志、UI、审计和调试 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供方级诊断或原始响应检查 | +| [`input`][agents.result.RunResultBase.input] | 此运行片段的基础输入。如果任务转移输入过滤器重写了历史记录,此属性会反映运行继续执行时所使用的过滤后输入。 | 审计此运行实际使用的输入 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 运行的输入项视图。默认的 `mode="preserve_all"` 会保留从 `new_items` 转换而来的历史记录,但不会再次追加已被移入 SDK 默认嵌套任务转移历史记录的同一会话项;当任务转移过滤重写模型历史记录时,`mode="normalized"` 会优先使用规范化的延续输入。 | 手动聊天循环、由客户端管理的对话状态和普通项目历史记录检查 | +| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 包装对象。 | 日志、UI、审计和调试 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供方级别的诊断或原始响应检查 | -实践中: +实际使用时: -- 当你想要运行的普通输入条目视图时,使用 `to_input_list()`。 -- 当你希望在任务转移过滤或嵌套任务转移历史记录重写后,为下一次 `Runner.run(..., input=...)` 调用获得规范本地输入时,使用 `to_input_list(mode="normalized")`。 -- 当你希望 SDK 为你加载和保存历史记录时,使用 [`session=...`](sessions/index.md)。 -- 如果你使用带有 `conversation_id` 或 `previous_response_id` 的 OpenAI服务管理状态,通常只传递新的用户输入,并复用已存储的 ID,而不是重新发送 `to_input_list()`。 -- 当你需要用于日志、UI 或审计的完整转换后历史记录时,使用默认的 `to_input_list()` 模式或 `new_items`。 +- 当你需要运行的普通输入项视图时,使用 `to_input_list()`。 +- 当你需要在任务转移过滤或嵌套任务转移历史记录重写后,将规范化本地输入用于下一次 `Runner.run(..., input=...)` 调用时,使用 `to_input_list(mode="normalized")`。 +- 当你希望 SDK 自动加载和保存历史记录时,使用 [`session=...`](sessions/index.md)。 +- 如果你正在使用通过 `conversation_id` 或 `previous_response_id` 实现的 OpenAI 服务端托管状态,通常只需传入新的用户输入并复用已存储的 ID,而不必重新发送 `to_input_list()`。 +- 当你需要用于日志、UI 或审计的完整转换后历史记录时,使用默认的 `to_input_list()` 模式或 `new_items`。 -与 JavaScript SDK 不同,Python 不会公开一个单独的 `output` 属性来仅表示模型形态的增量。当你需要 SDK 元数据时,使用 `new_items`;当你需要原始模型载荷时,检查 `raw_responses`。 +当 SDK 默认的嵌套任务转移历史记录逐字保留某个消息项时,Sessions、`RunState` 和 `to_input_list()` 会追踪该项由其拥有的确切实例,而不是按内容进行去重。分别出现的相同消息仍会保持独立;只有已被拥有的实例不会被再次追加。 -计算机工具重放遵循原始 Responses 载荷结构。预览模型的 `computer_call` 条目会保留单个 `action`,而 `gpt-5.5` 计算机调用可以保留批处理的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的任一结构,因此手动重放、暂停/恢复流程和已存储的转录记录都能继续适用于预览版和 GA 计算机工具调用。本地执行结果仍会以 `computer_call_output` 条目的形式出现在 `new_items` 中。 +与 JavaScript SDK 不同,Python 不会为仅包含模型形态增量的内容提供单独的 `output` 属性。当你需要 SDK 元数据时,请使用 `new_items`;当你需要原始模型载荷时,请检查 `raw_responses`。 -### 新条目 +计算机工具重放遵循原始 Responses 载荷结构。预览模型的 `computer_call` 项会保留单个 `action`,而 `gpt-5.5` 的计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的结构,因此手动重放、暂停/恢复流程和存储的对话记录都能同时适用于预览版和正式版(GA)的计算机工具调用。本地执行结果仍会作为 `computer_call_output` 项出现在 `new_items` 中。 -[`new_items`][agents.result.RunResultBase.new_items] 为你提供运行期间所发生事件的最丰富视图。常见条目类型包括: +### 新项目 -- 用于助手消息的 [`MessageOutputItem`][agents.items.MessageOutputItem] -- 用于推理条目的 [`ReasoningItem`][agents.items.ReasoningItem] -- 用于 Responses 工具搜索请求和已加载工具搜索结果的 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 与 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- 用于工具调用及其结果的 [`ToolCallItem`][agents.items.ToolCallItem] 与 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 用于因审批而暂停的工具调用的 [`ToolApprovalItem`][agents.items.ToolApprovalItem] -- 用于任务转移请求和已完成转移的 [`HandoffCallItem`][agents.items.HandoffCallItem] 与 [`HandoffOutputItem`][agents.items.HandoffOutputItem] +[`new_items`][agents.result.RunResultBase.new_items] 提供运行期间所发生事件的最丰富视图。常见的项目类型包括: -每当你需要智能体关联、工具输出、任务转移边界或审批边界时,应选择 `new_items` 而不是 `to_input_list()`。 +- 用于助手消息的 [`MessageOutputItem`][agents.items.MessageOutputItem] +- 用于推理项目的 [`ReasoningItem`][agents.items.ReasoningItem] +- 用于 Responses 工具搜索请求和已加载工具搜索结果的 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 和 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- 用于工具调用及其结果的 [`ToolCallItem`][agents.items.ToolCallItem] 和 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- 用于因等待审批而暂停的工具调用的 [`ToolApprovalItem`][agents.items.ToolApprovalItem] +- 用于托管 MCP 审批和工具目录的 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem] +- 用于任务转移请求和已完成转移的 [`HandoffCallItem`][agents.items.HandoffCallItem] 和 [`HandoffOutputItem`][agents.items.HandoffOutputItem] -使用托管工具搜索时,检查 `ToolSearchCallItem.raw_item` 可查看模型发出的搜索请求,检查 `ToolSearchOutputItem.raw_item` 可查看该轮次加载了哪些命名空间、函数或托管 MCP 服务。 +只要你需要智能体关联信息、工具输出、任务转移边界或审批边界,就应选择 `new_items` 而不是 `to_input_list()`。 -## 对话的继续或恢复 +使用托管工具搜索时,检查 `ToolSearchCallItem.raw_item` 可查看模型发出的搜索请求,检查 `ToolSearchOutputItem.raw_item` 可查看该轮加载了哪些命名空间、函数或托管 MCP 服务。 + +使用程序化工具调用时,生成的 `program` 是一个 `ToolCallItem`,归该程序所有的普通子工具调用也会作为 `ToolCallItem` 项,而匹配的 `program_output` 则是一个 `ToolCallOutputItem`。程序拥有的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 项属于例外:它们会分别成为 `MCPApprovalRequestItem` 和 `MCPListToolsItem` 项。 + +原始项目可以是带类型的 Responses 对象,也可以是映射。特别是,程序拥有的 shell 和 apply-patch 调用会使用映射。请使用对映射安全的检查模式: + +```python +from collections.abc import Mapping + + +def raw_field(item, name): + raw_item = item.raw_item + if isinstance(raw_item, Mapping): + return raw_item.get(name) + return getattr(raw_item, name, None) + + +raw_type = raw_field(item, "type") +caller = raw_field(item, "caller") +caller_id = ( + caller.get("caller_id") + if isinstance(caller, Mapping) + else getattr(caller, "caller_id", None) +) +``` + +对于程序拥有的子调用,`caller` 的类型为 `program`,而 `caller_id` 用于标识父程序调用。 + +## 对话的继续与恢复 ### 下一轮智能体 -[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。在任务转移之后,这通常是下一轮用户输入最适合复用的智能体。 +[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。在任务转移后,它通常是下一轮用户输入中最适合复用的智能体。 -在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行进展而更新,因此你可以在流结束前观察任务转移。 +在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行推进而更新,因此你可以在流结束之前观察任务转移。 -### 中断和运行状态 +### 中断与运行状态 -如果工具需要审批,待处理审批会通过 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 暴露。这可以包括由直接工具引发、由任务转移后到达的工具引发,或由嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行引发的审批。 +如果某个工具需要审批,待处理的审批会通过 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 提供。其中可能包括直接工具发起的审批、任务转移后访问的工具发起的审批,或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行发起的审批。 -调用 [`to_state()`][agents.result.RunResult.to_state] 可捕获可恢复的 [`RunState`][agents.run_state.RunState],批准或拒绝待处理条目,然后使用 `Runner.run(...)` 或 `Runner.run_streamed(...)` 恢复。 +调用 [`to_state()`][agents.result.RunResult.to_state] 可获取可恢复的 [`RunState`][agents.run_state.RunState],审批或拒绝待处理项目,然后使用 `Runner.run(...)` 或 `Runner.run_streamed(...)` 恢复运行。 ```python from agents import Agent, Runner @@ -107,59 +136,59 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -对于流式传输运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions` 并从 `result.to_state()` 恢复。有关完整审批流程,请参阅[人在回路](human_in_the_loop.md)。 +对于流式运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions`,并从 `result.to_state()` 恢复。有关完整的审批流程,请参阅[人在回路](human_in_the_loop.md)。 -### 服务管理的续接 +### 服务端管理的延续 -[`last_response_id`][agents.result.RunResultBase.last_response_id] 是运行中最新的模型响应 ID。当你想继续 OpenAI Responses API 链时,在下一轮将它作为 `previous_response_id` 传回。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] 是此次运行中最新模型响应的 ID。如果希望继续串联 OpenAI Responses API,请在下一轮将其作为 `previous_response_id` 传回。 -如果你已经使用 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步运行中的每个模型响应,请改为检查 `raw_responses`。 +如果你已经通过 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 -## 智能体作为工具的元数据 +## 智能体工具元数据 -当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会公开关于外层工具调用的不可变元数据: +当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会提供关于外层工具调用的不可变元数据: -- `tool_name` -- `tool_call_id` -- `tool_arguments` +- `tool_name` +- `tool_call_id` +- `tool_arguments` -对于普通顶层运行,`agent_tool_invocation` 为 `None`。 +对于普通的顶层运行,`agent_tool_invocation` 为 `None`。 -这在 `custom_output_extractor` 内尤其有用,因为在对嵌套结果进行后处理时,你可能需要外层工具名称、调用 ID 或原始参数。有关周围的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 +这在 `custom_output_extractor` 中尤其有用,因为在对嵌套结果进行后处理时,你可能需要外层工具名称、调用 ID 或原始参数。有关相关的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 -如果你还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 用于以通用方式序列化嵌套工具输入的字段,而 `agent_tool_invocation` 是当前嵌套调用的实时结果访问器。 +如果还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 为嵌套工具输入进行通用序列化的字段,而 `agent_tool_invocation` 是当前嵌套调用的实时结果访问接口。 -## 流式传输生命周期和诊断 +## 流式传输生命周期与诊断 -[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同结果接口,但增加了流式传输专用控制项: +[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同的结果接口,同时添加了流式传输专用控制功能: -- [`stream_events()`][agents.result.RunResultStreaming.stream_events] 用于消费语义流事件 -- [`current_agent`][agents.result.RunResultStreaming.current_agent] 用于在运行过程中跟踪活跃智能体 -- [`is_complete`][agents.result.RunResultStreaming.is_complete] 用于查看流式传输运行是否已完全结束 -- [`cancel(...)`][agents.result.RunResultStreaming.cancel] 用于立即停止运行,或在当前轮次结束后停止运行 +- [`stream_events()`][agents.result.RunResultStreaming.stream_events],用于消费语义流事件 +- [`current_agent`][agents.result.RunResultStreaming.current_agent],用于在运行过程中追踪活动智能体 +- [`is_complete`][agents.result.RunResultStreaming.is_complete],用于查看流式运行是否已完全结束 +- [`cancel(...)`][agents.result.RunResultStreaming.cancel],用于立即停止运行或在当前轮结束后停止运行 -持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式传输运行才算完成;并且在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在收尾。 +持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式运行才算完成;在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在处理中。 -如果调用 `cancel()`,请继续消费 `stream_events()`,以便取消和清理能够正确完成。 +如果调用 `cancel()`,请继续消费 `stream_events()`,以确保取消和清理操作能够正确完成。 -Python 不会公开单独的流式 `completed` promise 或 `error` 属性。终止性流式传输失败会通过 `stream_events()` 抛出异常来呈现,而 `is_complete` 反映运行是否已到达其终止状态。 +Python 不提供单独的流式 `completed` promise 或 `error` 属性。流式传输的终止性故障会通过 `stream_events()` 抛出,而 `is_complete` 则反映运行是否已到达终止状态。 ### 原始响应 -[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步运行可能会生成多个响应,例如跨任务转移或重复的模型/工具/模型循环。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能会生成多个响应,例如跨任务转移或重复的模型/工具/模型循环。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] 只是 `raw_responses` 最后一项中的 ID。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] 只是 `raw_responses` 中最后一个条目的 ID。 ### 安全防护措施结果 -智能体级安全防护措施通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 暴露。 +智能体级安全防护措施通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 提供。 -工具安全防护措施则分别通过 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 和 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] 暴露。 +工具安全防护措施则通过 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 和 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] 单独提供。 -这些数组会在运行过程中累积,因此它们适用于记录决策、存储额外的安全防护措施元数据,或调试运行为何被阻止。 +这些数组会在整个运行期间持续累积,因此可用于记录决策、存储额外的安全防护措施元数据,或调试运行被阻止的原因。 -### 上下文和用量 +### 上下文与用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 会公开你的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套 `tool_input`。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 提供应用上下文以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 -用量在 `context_wrapper.usage` 上跟踪。对于流式传输运行,用量总计可能会滞后,直到流的最终分块处理完毕。有关完整的包装器结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file +用量记录在 `context_wrapper.usage` 中。对于流式运行,在处理完流的最后几个数据块之前,用量总计可能会有所滞后。有关完整的包装对象结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index ce7a5f95aa..56db97e333 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -2,13 +2,13 @@ search: exclude: true --- -# 运行智能体 +# 智能体运行 -你可以通过 [`Runner`][agents.run.Runner] 类运行智能体。你有 3 种选择: +你可以通过[`Runner`][agents.run.Runner]类运行智能体。有以下 3 种方式: -1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回 [`RunResult`][agents.result.RunResult]。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其内部只是运行 `.run()`。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 +1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回[`RunResult`][agents.result.RunResult]。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,底层仅调用`.run()`。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回[`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 ```python from agents import Agent, Runner @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -请在[结果指南](results.md)中了解更多信息。 +更多信息请参阅[结果指南](results.md)。 ## Runner 生命周期与配置 ### 智能体循环 -使用 `Runner` 中的运行方法时,你需要传入一个起始智能体和输入。输入可以是: +使用`Runner`中的运行方法时,需要传入起始智能体和输入。输入可以是: -- 字符串(被视为用户消息), -- OpenAI Responses API 格式的输入项列表,或 -- 恢复已中断的运行时使用的 [`RunState`][agents.run_state.RunState]。 +- 字符串(视为用户消息), +- OpenAI Responses API格式的输入项列表,或 +- 恢复中断的运行时使用的[`RunState`][agents.run_state.RunState]。 -随后,运行器将执行循环: +随后,Runner 会运行一个循环: -1. 我们使用当前输入为当前智能体调用 LLM。 +1. 使用当前输入调用当前智能体的 LLM。 2. LLM 生成输出。 - 1. 如果 LLM 返回 `final_output`,循环结束并返回结果。 - 2. 如果 LLM 执行任务转移,我们会更新当前智能体和输入,然后重新运行循环。 - 3. 如果 LLM 生成工具调用,我们会运行这些工具调用、追加结果,然后重新运行循环。 -3. 如果超过传入的 `max_turns`,我们会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 + 1. 如果 LLM 返回`final_output`,循环结束并返回结果。 + 2. 如果 LLM 执行任务转移,则更新当前智能体和输入,然后重新运行循环。 + 3. 如果 LLM 生成工具调用,则运行这些工具调用、追加结果,然后重新运行循环。 +3. 如果超过传入的`max_turns`,则抛出[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]异常。传入`max_turns=None`可禁用此轮次限制。 !!! note - 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了具有所需类型的文本输出,并且没有工具调用。 + 判断 LLM 输出是否为“最终输出”的规则是:它生成了所需类型的文本输出,并且不存在工具调用。 ### 流式传输 -流式传输让你可以在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含此次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。请在[流式传输指南](streaming.md)中了解更多信息。 +流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming]将包含此次运行的完整信息,包括生成的所有新输出。你可以调用`.stream_events()`获取流式事件。更多信息请参阅[流式传输指南](streaming.md)。 #### Responses WebSocket 传输(可选辅助工具) -如果启用 OpenAI Responses WebSocket 传输,你仍然可以继续使用常规的 `Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 +如果启用 OpenAI Responses WebSocket 传输,你仍可继续使用常规`Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 -这是通过 WebSocket 传输使用 Responses API,而不是 [Realtime API](realtime/guide.md)。 +这是通过 WebSocket 传输使用 Responses API,而不是[Realtime API](realtime/guide.md)。 -有关传输方式的选择规则,以及使用具体模型对象或自定义提供商时的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 +有关传输选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 ##### 模式 1:不使用会话辅助工具(可用) -如果你只想使用 WebSocket 传输,并且不需要 SDK 为你管理共享提供商或会话,请使用此方式。 +如果你只需要 WebSocket 传输,并且不需要 SDK 为你管理共享提供商/会话,请使用此模式。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -此模式适用于单次运行。如果你重复调用 `Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个 `RunConfig` / 提供商实例,否则每次运行都可能重新连接。 +此模式适用于单次运行。如果反复调用`Runner.run()` / `Runner.run_streamed()`,每次运行都可能重新连接,除非你手动复用同一个`RunConfig` / 提供商实例。 -##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) +##### 模式 2:使用`responses_websocket_session()`(推荐用于多轮复用) -如果你希望在多次运行中共享支持 WebSocket 的提供商和 `RunConfig`,请使用 [`responses_websocket_session()`][agents.responses_websocket_session](包括继承同一 `run_config` 的嵌套“智能体作为工具”调用)。 +如果希望在多次运行之间共享支持 WebSocket 的提供商和`RunConfig`,请使用[`responses_websocket_session()`][agents.responses_websocket_session],这也包括继承同一`run_config`的嵌套“智能体作为工具”调用。 ```python import asyncio @@ -121,54 +121,54 @@ asyncio.run(main()) 请在上下文退出前完成流式结果的消费。如果在 WebSocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 -如果较长的推理轮次触发 WebSocket keepalive 超时,请增大 `ping_timeout`,或设置 `ping_timeout=None` 以禁用心跳超时。对于可靠性比 WebSocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 +如果较长的推理轮次触发 WebSocket 保活超时,请增大`ping_timeout`,或将`ping_timeout=None`设置为禁用心跳超时。对于可靠性比 WebSocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 ### 运行配置 -`run_config` 参数让你可以为智能体运行配置一些全局设置: +`run_config`参数可用于配置智能体运行的一些全局设置: -#### 常用运行配置目录 +#### 常见运行配置目录 -使用 `RunConfig` 可以覆盖单次运行的行为,而无需更改每个智能体的定义。 +使用`RunConfig`可覆盖单次运行的行为,而无需更改各个智能体的定义。 -##### 模型、提供商和会话默认值 +##### 模型、提供商和会话默认设置 -- [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个智能体的 `model` 设置。 -- [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 -- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,你可以设置全局 `temperature` 或 `top_p`。 -- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认值(例如 `SessionSettings(limit=...)`)。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每轮开始前将新用户输入与会话历史记录合并的方式。该回调可以是同步或异步的。 +- [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个智能体的`model`设置。 +- [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为OpenAI。 +- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,可以设置全局`temperature`或`top_p`。 +- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认设置(例如`SessionSettings(limit=...)`)。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每轮开始前将新用户输入与会话历史记录合并的方式。该回调可以是同步或异步的。 ##### 安全防护措施、任务转移和模型输入调整 -- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要包含在所有运行中的输入或输出安全防护措施列表。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:应用于所有任务转移的全局输入过滤器,前提是该任务转移尚未设置过滤器。输入过滤器允许你编辑发送给新智能体的输入。更多详情请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 的文档。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:选择启用的测试版功能,在调用下一个智能体之前,将此前的对话记录折叠为一条助手消息。为了在稳定嵌套任务转移功能期间保持兼容,该功能默认禁用;设置为 `True` 可启用,保留为 `False` 则会原样传递原始对话记录。未传入 `RunConfig` 时,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,并且任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。单个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:可选的可调用对象。当你选择启用 `nest_handoff_history` 时,它会接收规范化后的对话记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的准确输入项列表,使你无需编写完整的任务转移过滤器即可替换内置摘要。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型之前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如裁剪历史记录或注入系统提示词。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制运行器将之前的输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 +- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要包含在所有运行中的输入或输出安全防护措施列表。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:应用于所有任务转移的全局输入过滤器,前提是该任务转移尚未设置过滤器。输入过滤器允许你编辑发送给新智能体的输入。更多详细信息请参阅[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]的文档。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选择启用的 Beta 功能。在调用下一个智能体之前,它会将可摘要的历史记录压缩为有序的助手摘要片段,同时将无损消息项保留在其原始位置。在我们完善嵌套任务转移期间,此功能默认禁用;设置为`True`可启用,保持`False`则会直接传递原始记录。Sessions、`RunState`和`RunResult.to_input_list()`会避免重复追加完全相同的消息实例(当 SDK 默认的嵌套历史记录已包含该消息时),同时保留彼此独立但内容相同的消息。如果未传入`RunConfig`,所有[Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,而任何显式的[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]回调仍会覆盖此设置。单个任务转移可通过[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]覆盖此设置。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:可选的可调用对象。当你选择启用`nest_handoff_history`时,它会接收规范化的记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的确切输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如裁剪历史记录或注入系统提示词。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 Runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 ##### 追踪与可观测性 -- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许你为整个运行禁用[追踪](tracing.md)。 -- [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],以覆盖追踪导出设置,例如每次运行的追踪 API 密钥。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在的敏感数据,例如 LLM 和工具调用的输入/输出。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行中的追踪。 -- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许为整个运行禁用[追踪](tracing.md)。 +- [`tracing`][agents.run.RunConfig.tracing]:传入[`TracingConfig`][agents.tracing.TracingConfig]可覆盖追踪导出设置,例如每次运行所使用的追踪 API 密钥。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪中是否包含潜在敏感数据,例如 LLM 和工具调用的输入/输出。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。建议至少设置`workflow_name`。组 ID 是可选字段,可用于关联多次运行的追踪。 +- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 ##### 工具执行、审批和工具错误行为 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置 SDK 端对本地工具调用的执行行为,例如限制同时运行的工具调用数量。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置运行器如何处理模型发出的、无法解析的工具调用。默认行为会引发 `ModelBehaviorError`;你也可以选择改为返回模型可见的错误输出。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝和选择启用的“工具未找到”输出。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 端的执行行为,例如限制同时运行的工具调用数量。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置 Runner 如何处理模型生成但无法解析的工具调用。默认行为是抛出`ModelBehaviorError`;也可选择改为返回模型可见的错误输出。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝,以及选择启用后返回的工具未找到输出。 -嵌套任务转移是一项可选择启用的测试版功能。传入 `RunConfig(nest_handoff_history=True)` 可启用折叠对话记录行为,也可以设置 `handoff(..., nest_handoff_history=True)`,仅为特定任务转移启用该行为。如果希望保留原始对话记录(默认行为),请不要设置该标志,或者提供一个按照你的具体需求转发对话的 `handoff_input_filter`(或 `handoff_history_mapper`)。如果只想更改生成摘要时使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并可调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 恢复默认设置)。 +嵌套任务转移是一项可选择启用的 Beta 功能。传入`RunConfig(nest_handoff_history=True)`可启用有序记录压缩,也可以设置`handoff(..., nest_handoff_history=True)`,仅为特定任务转移启用此功能。内置映射器会在无损消息项前后放置生成的助手摘要片段,而不是将整个记录压缩为一条消息。如果希望保留原始记录(默认行为),请不要设置该标志,或者提供一个`handoff_input_filter`(或`handoff_history_mapper`),按需准确转发对话。如果想更改生成摘要片段时使用的包装文本,而不编写自定义映射器,请调用[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]可恢复默认值)。 #### 运行配置详情 ##### `tool_execution` -如果你想配置 SDK 端对本地工具调用的行为,例如限制单次运行中的本地工具调用并发数,请使用 `tool_execution`。 +如果希望配置本地工具调用在 SDK 端的行为,例如限制单次运行中本地工具调用的并发量,请使用`tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -187,17 +187,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中发出多个工具调用时,SDK 会启动所有已发出的本地工具调用。将其设置为整数值,可限制这些本地工具调用同时运行的数量。 +`max_function_tool_concurrency=None`会保留默认行为:当模型在一轮中生成多个工具调用时,SDK 会启动所有已生成的本地工具调用。将其设置为整数值,可限制同时运行的本地工具调用数量。 -这与提供商端的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地工具调用后,SDK 如何执行这些调用。 +这与提供商端的[`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]相互独立。`parallel_tool_calls`控制是否允许模型在单个响应中生成多个工具调用。`tool_execution.max_function_tool_concurrency`控制模型生成这些调用后,SDK 如何执行本地工具调用。 -`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果工具调用需要审批,运行会先暂停,工具输入安全防护措施仅在审批通过后、执行前立即运行。如果希望工具输入安全防护措施在发出待审批中断之前运行,请将其设置为 `True`。通过此次审批前检查的调用仍会在审批后再次运行相同的输入安全防护措施,以便在执行前重新验证时效性检查。 +`pre_approval_tool_input_guardrails=False`会保留默认审批流程:如果工具调用需要审批,运行会先暂停,工具输入安全防护措施仅在审批完成后、执行前立即运行。如果希望工具调用输入安全防护措施在发出待审批中断之前运行,请将其设置为`True`。通过此次审批前检查的调用,在审批后仍会再次运行相同的输入安全防护措施,从而在执行前重新验证时效性检查。 ##### `tool_not_found_behavior` -默认情况下,如果模型发出的工具调用与当前智能体可用的任何工具调用都不匹配,运行器会引发 `ModelBehaviorError`。 +默认情况下,如果模型生成的工具调用与当前智能体可用的任何工具调用都不匹配,Runner 会抛出`ModelBehaviorError`。 -如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,使模型能够选择可用工具,或者在不使用该工具的情况下作答。 +如果希望运行仍可恢复,请设置`tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为未解析的工具调用追加`function_call_output`,并再次运行模型,使模型可以选择可用工具,或在不使用该工具的情况下回答。 ```python from agents import Agent, RunConfig, Runner @@ -211,22 +211,22 @@ result = await Runner.run( ) ``` -此选项目前仅适用于无法解析的工具调用。其他无效工具载荷仍会沿用现有的错误处理行为。 +此选项目前仅适用于未解析的工具调用。其他无效工具载荷仍沿用其现有的错误处理行为。 ##### `tool_error_formatter` -使用 `tool_error_formatter` 可以自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 +当 SDK 创建模型可见的工具错误输出时,可使用`tool_error_formatter`自定义返回给模型的消息。 -格式化器接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: +格式化程序会接收包含以下字段的[`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]: -- `kind`:错误目录,例如 `"approval_rejected"` 或 `"tool_not_found"`。 -- `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"` 或 `"custom"`)。 -- `tool_name`:工具名称。 -- `call_id`:工具调用 ID。 -- `default_message`:SDK 默认的模型可见消息。 -- `run_context`:当前活动运行上下文的包装器。 +- `kind`:错误目录,例如`"approval_rejected"`或`"tool_not_found"`。 +- `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`或`"custom"`)。 +- `tool_name`:工具名称。 +- `call_id`:工具调用 ID。 +- `default_message`:SDK 默认的模型可见消息。 +- `run_context`:当前运行上下文包装器。 -返回字符串可替换该消息;返回 `None` 则使用 SDK 默认消息。 +返回字符串可替换该消息,返回`None`则使用 SDK 默认值。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -253,56 +253,56 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` 控制运行器向后传递历史记录时,如何将推理项转换为下一轮模型输入(例如使用 `RunResult.to_input_list()` 或由会话支持的运行时)。 +`reasoning_item_id_policy`控制 Runner 向前传递历史记录时,如何将推理项转换为下一轮模型输入(例如使用`RunResult.to_input_list()`或由会话支持的运行时)。 -- `None` 或 `"preserve"`(默认):保留推理项 ID。 -- `"omit"`:从生成的下一轮输入中移除推理项 ID。 +- `None`或`"preserve"`(默认):保留推理项 ID。 +- `"omit"`:从生成的下一轮输入中移除推理项 ID。 -`"omit"` 主要作为一种选择启用的缓解措施,用于处理一类 Responses API 400 错误:推理项附带 `id` 发送,但缺少必需的后续项(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"`主要用于选择启用针对一类 Responses API 400 错误的缓解措施:推理项带有`id`发送,但没有必需的后续项(例如`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -这种情况可能发生在多轮智能体运行中:SDK 根据之前的输出构造后续输入(包括会话持久化、由服务管理的对话增量、流式/非流式后续轮次以及恢复路径),推理项 ID 被保留,但提供商要求该 ID 必须始终与其对应的后续项配对。 +在多轮智能体运行中,如果 SDK 根据先前的输出构造后续输入(包括会话持久化、由服务端管理的对话增量、流式/非流式后续轮次和恢复路径),并且保留了推理项 ID,但提供商要求该 ID 必须与其对应的后续项配对,就可能发生这种情况。 -设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量约束。 +设置`reasoning_item_id_policy="omit"`会保留推理内容,但移除推理项的`id`,从而避免在 SDK 生成的后续输入中触发该 API 不变量。 适用范围说明: -- 此设置仅会更改 SDK 构建后续输入时生成或转发的推理项。 -- 它不会改写用户提供的初始输入项。 -- 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 +- 这只会更改 SDK 构建后续输入时生成/转发的推理项。 +- 它不会重写用户提供的初始输入项。 +- 应用此策略后,`call_model_input_filter`仍可有意重新引入推理 ID。 ## 状态与对话管理 -### 记忆策略的选择 +### 记忆策略选择 -将状态带入下一轮通常有四种方式: +有四种常见方式可以将状态传递到下一轮: -| 策略 | 状态存储位置 | 最适合 | 下一轮传入的内容 | +| 策略 | 状态存储位置 | 最适用场景 | 下一轮传入的内容 | | --- | --- | --- | --- | -| `result.to_input_list()` | 你的应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()` 返回的列表以及下一条用户消息 | -| `session` | 你的存储与 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | -| `conversation_id` | OpenAI Conversations API | 希望在多个工作进程或服务之间共享的具名服务端对话 | 同一个 `conversation_id`,以及仅包含新的用户轮次 | -| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务端延续 | `result.last_response_id`,以及仅包含新的用户轮次 | +| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任意提供商 | `result.to_input_list()`返回的列表,加上下一条用户消息 | +| `session` | 你的存储加 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个`session`实例,或指向同一存储的另一个实例 | +| `conversation_id` | OpenAI Conversations API | 希望在不同工作进程或服务间共享的命名服务端对话 | 同一个`conversation_id`,加上仅包含新用户轮次的内容 | +| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务端托管延续 | `result.last_response_id`,加上仅包含新用户轮次的内容 | -`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅适用于使用 OpenAI Responses API 的情况。在大多数应用中,应为每个对话选择一种持久化策略。混合使用客户端管理的历史记录与 OpenAI 管理的状态可能会导致上下文重复,除非你有意协调这两个层级。 +`result.to_input_list()`和`session`由客户端管理。`conversation_id`和`previous_response_id`由OpenAI管理,并且仅适用于使用 OpenAI Responses API的情况。对于大多数应用程序,每个对话应选择一种持久化策略。混合使用客户端管理的历史记录和OpenAI管理的状态可能导致上下文重复,除非你有意协调这两个层级。 !!! note - 同一次运行中,会话持久化不能与服务端管理的对话设置 - (`conversation_id`、`previous_response_id` 或 `auto_previous_response_id`) - 结合使用。每次调用请选择一种方式。 + 同一次运行中,无法同时使用会话持久化与服务端管理的对话设置 + (`conversation_id`、`previous_response_id`或`auto_previous_response_id`)。 + 每次调用请选择一种方式。 ### 对话/聊天线程 -调用任意运行方法都可能导致一个或多个智能体运行(因此会进行一次或多次 LLM 调用),但这在聊天对话中表示一个逻辑轮次。例如: +调用任一运行方法都可能导致一个或多个智能体运行(因此会进行一次或多次 LLM 调用),但在聊天对话中,这表示一个逻辑轮次。例如: 1. 用户轮次:用户输入文本 -2. 运行器运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,随后生成输出。 +2. Runner 运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 -智能体运行结束后,你可以选择向用户展示哪些内容。例如,可以向用户展示智能体生成的每个新项目,也可以只展示最终输出。无论选择哪种方式,用户之后都可能提出后续问题,此时你可以再次调用运行方法。 +智能体运行结束时,你可以选择向用户展示哪些内容。例如,可以向用户展示智能体生成的每个新项目,也可以只展示最终输出。无论采用哪种方式,用户都可能继续提出后续问题,此时可以再次调用运行方法。 #### 手动对话管理 -你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法获取下一轮输入,从而手动管理对话历史记录: +你可以使用[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list]方法手动管理对话历史记录,以获取下一轮的输入: ```python from agents import Agent, Runner, trace @@ -326,7 +326,7 @@ async def main(): #### 使用会话自动管理对话 -若要采用更简单的方式,可以使用 [Sessions](sessions/index.md) 自动处理对话历史记录,而无需手动调用 `.to_input_list()`: +如果希望采用更简单的方法,可以使用[Sessions](sessions/index.md)自动处理对话历史记录,而无需手动调用`.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -352,22 +352,22 @@ async def main(): Sessions 会自动: -- 在每次运行前检索对话历史记录 -- 在每次运行后存储新消息 -- 为不同的会话 ID 维护彼此独立的对话 +- 在每次运行前检索对话历史记录 +- 在每次运行后存储新消息 +- 为不同的会话 ID 维护独立对话 -更多详情请参阅 [Sessions 文档](sessions/index.md)。 +更多详细信息请参阅[Sessions 文档](sessions/index.md)。 #### 服务端管理的对话 -除了在本地使用 `to_input_list()` 或 `Sessions` 处理对话状态,你也可以让 OpenAI 对话状态功能在服务端管理对话状态。这样无需手动重新发送所有历史消息,即可保留对话历史记录。使用下述任一服务端管理方式时,每次请求仅传入新一轮的输入,并复用已保存的 ID。更多详情请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 +你也可以让OpenAI对话状态功能在服务端管理对话状态,而不是使用`to_input_list()`或`Sessions`在本地处理。这样无需手动重新发送所有历史消息,即可保留对话历史记录。使用下述任一服务端管理方式时,每次请求仅传入新轮次的输入,并复用已保存的 ID。更多详细信息请参阅[OpenAI对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 -OpenAI 提供两种跨轮次追踪状态的方式: +OpenAI提供两种跨轮次追踪状态的方式: -##### 1. 使用 `conversation_id` +##### 1. 使用`conversation_id` -首先使用 OpenAI Conversations API 创建一个对话,然后在后续每次调用中复用其 ID: +首先使用 OpenAI Conversations API创建对话,然后在后续每次调用中复用其 ID: ```python from agents import Agent, Runner @@ -388,9 +388,9 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -##### 2. 使用 `previous_response_id` +##### 2. 使用`previous_response_id` -另一种选择是**响应链式连接**,其中每一轮都会显式关联上一轮的响应 ID。 +另一种选择是**响应链接**,其中每一轮都显式链接到上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -415,30 +415,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,使恢复后的轮次继续使用同一个由服务端管理的对话。 +如果运行因审批而暂停,并且你从[`RunState`][agents.run_state.RunState]恢复,SDK 会保留已保存的`conversation_id` / `previous_response_id` / `auto_previous_response_id`设置,使恢复后的轮次继续使用同一个服务端管理的对话。 -`conversation_id` 和 `previous_response_id` 互斥。如果你需要一个可跨系统共享的具名对话资源,请使用 `conversation_id`。如果你需要在轮次之间使用最轻量的 Responses API 延续基本组件,请使用 `previous_response_id`。 +`conversation_id`和`previous_response_id`互斥。如果需要可跨系统共享的命名对话资源,请使用`conversation_id`。如果需要从一轮到下一轮最轻量的 Responses API延续基本组件,请使用`previous_response_id`。 !!! note - SDK 会使用退避机制自动重试 `conversation_locked` 错误。在由服务端管理的 - 对话运行中,SDK 会在重试前回退内部对话追踪器的输入,以便能够干净地重新发送 + SDK 会自动以退避方式重试`conversation_locked`错误。在服务端管理的 + 对话运行中,它会在重试前回退内部对话跟踪器输入,以便干净地重新发送 相同的已准备项目。 - 在基于本地会话的运行中(不能与 `conversation_id`、 - `previous_response_id` 或 `auto_previous_response_id` 结合使用),SDK 还会尽力 - 回滚最近持久化的输入项,以减少重试后出现的重复历史记录条目。 + 在基于本地会话的运行中(无法与`conversation_id`、 + `previous_response_id`或`auto_previous_response_id`结合使用),SDK 还会尽最大努力 + 回滚最近持久化的输入项,以减少重试后重复的历史记录条目。 - 即使你没有配置 `ModelSettings.retry`,也会进行此兼容性重试。有关 - 更广泛、可选择启用的模型请求重试行为,请参阅[由 Runner 管理的重试](models/index.md#runner-managed-retries)。 + 即使未配置`ModelSettings.retry`,也会进行此兼容性重试。有关 + 更广泛、可选择启用的模型请求重试行为,请参阅[Runner 管理的重试](models/index.md#runner-managed-retries)。 ## 钩子与自定义 ### 模型调用输入过滤器 -使用 `call_model_input_filter` 可以在模型调用之前编辑模型输入。该钩子会接收当前智能体、上下文和合并后的输入项(包括存在会话时的会话历史记录),并返回新的 `ModelInputData`。 +使用`call_model_input_filter`可在调用模型前编辑模型输入。该钩子会接收当前智能体、上下文和合并后的输入项(包括存在的会话历史记录),并返回新的`ModelInputData`。 -返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填项,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 +返回值必须是[`ModelInputData`][agents.run.ModelInputData]对象。其`input`字段为必填项,并且必须是输入项列表。返回任何其他结构都会抛出`UserError`。 ```python from agents import Agent, Runner, RunConfig @@ -457,19 +457,19 @@ result = Runner.run_sync( ) ``` -运行器会将已准备输入列表的副本传给该钩子,因此你可以裁剪、替换或重新排序该列表,而无需就地修改调用方的原始列表。 +Runner 会将已准备输入列表的副本传递给钩子,因此你可以裁剪、替换或重新排序该列表,而不会原地修改调用方的原始列表。 -如果你正在使用会话,`call_model_input_filter` 会在会话历史记录加载完毕并与当前轮次合并后运行。如果希望自定义更早的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 +如果使用会话,`call_model_input_filter`会在会话历史记录已加载并与当前轮次合并后运行。如果希望自定义此前的合并步骤本身,请使用[`session_input_callback`][agents.run.RunConfig.session_input_callback]。 -如果你通过 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 使用 OpenAI 服务端管理的对话状态,该钩子会针对下一次 Responses API 调用的已准备载荷运行。该载荷可能已经仅表示新一轮的增量,而不是完整重放此前的历史记录。只有你返回的项目才会被标记为已针对该服务端管理的延续发送。 +如果使用带有`conversation_id`、`previous_response_id`或`auto_previous_response_id`的OpenAI服务端管理对话状态,该钩子会针对下一次 Responses API调用所准备的载荷运行。该载荷可能已经仅表示新轮次的增量,而不是完整重放先前的历史记录。只有你返回的项目才会被标记为已针对该服务端管理的延续发送。 -可以通过 `run_config` 为每次运行设置该钩子,以遮盖敏感数据、裁剪过长的历史记录或注入额外的系统指导。 +通过`run_config`为每次运行设置该钩子,以编辑敏感数据、裁剪过长的历史记录或注入额外的系统指导。 ## 错误与恢复 ### 错误处理程序 -所有 `Runner` 入口点都接受 `error_handlers`,这是一个以错误类型为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果你希望返回受控的最终输出,而不是让运行以相应错误结束,请使用这些键。 +所有`Runner`入口点都接受`error_handlers`,这是一个以错误类型为键的字典。支持的键包括`"max_turns"`、`"model_refusal"`和`"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些处理程序。 ```python from agents import ( @@ -498,7 +498,7 @@ result = Runner.run_sync( print(result.final_output) ``` -当模型消息无法通过智能体结构化 `output_type` 的验证,或者模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。处理程序可以返回应用特定的后备值,SDK 会根据同一个 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有后备值,非空验证失败仍会引发 `ModelBehaviorError`,而空的结构化响应会保留现有的下一轮行为。 +当模型消息无法通过智能体结构化`output_type`的验证,或模型未返回结构化最终消息时,请使用`"invalid_final_output"`。处理程序可以返回应用程序特定的回退值,SDK 会使用相同的`output_type`对其进行验证。它不会重试模型调用,也不会重新执行任何工具副作用。返回`None`表示放弃恢复。如果没有回退值,非空验证失败仍会抛出`ModelBehaviorError`,而空的结构化响应则保留现有的下一轮行为。 ```python from pydantic import BaseModel @@ -530,9 +530,9 @@ result = Runner.run_sync( print(result.final_output) ``` -如果不希望将后备输出追加到对话历史记录,请设置 `include_in_history=False`。 +如果不希望将回退输出追加到对话历史记录,请设置`include_in_history=False`。 -如果希望模型拒绝时生成应用特定的后备值,而不是让运行以 `ModelRefusalError` 结束,请使用 `"model_refusal"`。 +如果希望模型拒绝时生成应用程序特定的回退值,而不是以`ModelRefusalError`结束运行,请使用`"model_refusal"`。 ```python from pydantic import BaseModel @@ -564,35 +564,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人在回路 +## 持久执行集成与人工介入 -对于工具审批的暂停/恢复模式,请先参阅专门的[人在回路指南](human_in_the_loop.md)。以下集成适用于持久编排,即运行可能经历长时间等待、重试或进程重启的情况。 +有关工具审批暂停/恢复模式,请先参阅专门的[人工介入指南](human_in_the_loop.md)。以下集成适用于持久编排,运行过程可能包含长时间等待、重试或进程重启。 ### Dapr -你可以使用 Agents SDK 的 [Dapr](https://dapr.io) Diagrid 集成来运行持久、长时间运行的智能体,这些智能体支持人在回路,并能自动从故障中恢复。Dapr 是一个供应商中立的 [CNCF](https://cncf.io) 工作流编排器。请从[这里](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI智能体。 +你可以使用Agents SDK的[Dapr](https://dapr.io) Diagrid 集成来运行持久、长时间运行的智能体;这些智能体支持人工介入,并能自动从故障中恢复。Dapr 是一个与供应商无关的[CNCF](https://cncf.io)工作流编排器。点击[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和OpenAI智能体。 ### Temporal -你可以使用 Agents SDK 的 [Temporal](https://temporal.io/) 集成来运行持久、长时间运行的工作流,包括人在回路任务。你可以[在此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并[在此查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 +你可以使用Agents SDK的[Temporal](https://temporal.io/)集成来运行持久、长时间运行的工作流,包括人工介入任务。可在[此视频](https://www.youtube.com/watch?v=fFBZqzT4DD8)中观看 Temporal 与Agents SDK协同完成长时间运行任务的演示,并可在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 ### Restate -你可以使用 Agents SDK 的 [Restate](https://restate.dev/) 集成来运行轻量、持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务函数运行。更多详情请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 +你可以使用Agents SDK的[Restate](https://restate.dev/)集成来实现轻量级、持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单一二进制运行时,并支持将智能体作为进程/容器或 Serverless 函数运行。更多详细信息请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 ### DBOS -你可以使用 Agents SDK 的 [DBOS](https://dbos.dev/) 集成来运行可靠的智能体,使其能够在故障和重启时保留进度。它支持长时间运行的智能体、人在回路工作流和任务转移,也同时支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。更多详情请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 +你可以使用Agents SDK的[DBOS](https://dbos.dev/)集成运行可靠的智能体,在发生故障和重启时保留进度。它支持长时间运行的智能体、人工介入工作流和任务转移,也支持同步和异步方法。该集成仅需要 SQLite 或 Postgres 数据库。更多详细信息请查看集成[仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 ## 异常 -SDK 会在某些情况下引发异常。完整列表请参阅 [`agents.exceptions`][]。概述如下: +SDK 会在特定情况下抛出异常。完整列表位于[`agents.exceptions`][]中。概览如下: -- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 内引发的所有异常的基类。它是一种通用类型,所有其他特定异常都派生自该类型。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。它表示智能体无法在指定的交互轮次数内完成任务。设置 `max_turns=None` 可禁用该限制。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。其中可能包括: - - 格式错误的 JSON:模型为工具调用或直接输出提供了格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 时。 - - 意外的工具相关故障:模型未能按预期方式使用工具。 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会引发此异常。 -- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,会引发此异常。这通常是由代码实现不正确、配置无效或误用 SDK API 导致的。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:分别在满足输入安全防护措施或输出安全防护措施的触发条件时引发。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 +- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 内抛出的所有异常的基类。它是一种通用类型,所有其他特定异常均派生自该类。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给`Runner.run`、`Runner.run_sync`或`Runner.run_streamed`方法的`max_turns`限制时,会抛出此异常。它表示智能体无法在指定的交互轮数内完成任务。设置`max_turns=None`可禁用该限制。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。这可能包括: + - JSON 格式错误:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定`output_type`的情况下。 + - 意外的工具相关故障:模型未按预期方式使用工具 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当工具调用超过其配置的超时时间,并且该工具使用`timeout_behavior="raise_exception"`时,会抛出此异常。 +- [`UserError`][agents.exceptions.UserError]:当你(编写使用 SDK 的代码的人)在使用 SDK 时出错,会抛出此异常。这通常由错误的代码实现、无效配置或误用 SDK API 导致。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:分别在满足输入安全防护措施或输出安全防护措施的条件时抛出此异常。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 diff --git a/docs/zh/sandbox/clients.md b/docs/zh/sandbox/clients.md index baa659e8d1..e8002b455d 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -117,7 +117,7 @@ run_config = RunConfig( | `DaytonaSandboxClient` | 支持通过`DaytonaCloudBucketMountStrategy`进行由 rclone 支持的云存储挂载;可将其与`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`和`BoxMount`配合使用。 | | `E2BSandboxClient` | 支持通过`E2BCloudBucketMountStrategy`进行由 rclone 支持的云存储挂载;可将其与`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`和`BoxMount`配合使用。 | | `RunloopSandboxClient` | 支持通过`RunloopCloudBucketMountStrategy`进行由 rclone 支持的云存储挂载;可将其与`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`和`BoxMount`配合使用。 | -| `VercelSandboxClient` | 目前未暴露特定于托管环境的挂载策略。请改用清单文件、仓库或其他工作区输入。 | +| `VercelSandboxClient` | 支持通过 `VercelCloudBucketMountStrategy` 和 `S3Mount` 创建仅在沙箱创建时配置的 S3 及 S3 兼容存储桶挂载。包含挂载的会话无法恢复,使用内联凭证时必须设置 `allow_s3_credential_exposure=True`。 | @@ -134,8 +134,8 @@ run_config = RunConfig( | `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | -| `VercelSandboxClient` | - | - | - | - | - | - | +| `VercelSandboxClient` | ✓ | - | - | - | - | - | -如需更多可运行代码示例,请浏览[examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),了解本地、代码编写、记忆、任务转移和智能体组合模式;并浏览[examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions),了解托管沙盒客户端。 \ No newline at end of file +如需更多可运行代码示例,请浏览[examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),了解本地、代码编写、记忆、任务转移和智能体组合模式;并浏览[examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions),了解托管沙盒客户端。 diff --git a/docs/zh/streaming.md b/docs/zh/streaming.md index edf6989847..cc3a9dd821 100644 --- a/docs/zh/streaming.md +++ b/docs/zh/streaming.md @@ -4,19 +4,19 @@ search: --- # 流式传输 -流式传输让你能够订阅智能体运行过程中的更新。这对于向最终用户展示进度更新和部分响应非常有用。 +流式传输允许你在智能体运行期间订阅其更新。这对于向最终用户展示进度更新和部分响应非常有用。 -若要进行流式传输,可以调用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed],它会返回一个 [`RunResultStreaming`][agents.result.RunResultStreaming]。调用 `result.stream_events()` 会得到一个由 [`StreamEvent`][agents.stream_events.StreamEvent] 对象组成的异步流,这些对象将在下文介绍。 +要使用流式传输,可以调用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed],它会返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。调用 `result.stream_events()` 会得到由 [`StreamEvent`][agents.stream_events.StreamEvent] 对象组成的异步流,下文将对其进行说明。 -请持续消费 `result.stream_events()`,直到异步迭代器结束。只有当迭代器结束时,一次流式运行才算完成;会话持久化、审批记账或历史压缩等后处理可能会在最后一个可见 token 到达后才完成。当循环退出时,`result.is_complete` 会反映最终运行状态。 +应持续消费 `result.stream_events()`,直到异步迭代器结束。流式运行只有在迭代器结束后才算完成;会话持久化、审批记录处理或历史记录压缩等后处理操作,可能会在最后一个可见 token 到达后才完成。循环退出时,`result.is_complete` 会反映运行的最终状态。 ## 原始响应事件 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 是直接从 LLM 传递过来的原始事件。它们采用 OpenAI Responses API 格式,这意味着每个事件都有一个类型(例如 `response.created`、`response.output_text.delta` 等)和数据。如果你希望在响应消息生成后立即将其流式传输给用户,这些事件会很有用。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 是直接从 LLM 传递的原始事件。它们采用 OpenAI Responses API格式,这意味着每个事件都有类型(例如 `response.created`、`response.output_text.delta` 等)和数据。如果你希望在响应消息生成后立即以流式方式发送给用户,这些事件会非常有用。 -计算机工具原始事件会保留与已存储结果相同的 Preview 与 GA 区分。Preview 流会流式传输带有一个 `action` 的 `computer_call` 项,而 `gpt-5.5` 可以流式传输带有批量 `actions[]` 的 `computer_call` 项。更高层级的 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 表面不会为此添加特殊的仅限计算机的事件名称:两种形态仍然都会以 `tool_called` 的形式呈现,截图结果则会以 `tool_output` 的形式返回,并包装一个 `computer_call_output` 项。 +计算机工具的原始事件与已存储结果一样,会保留预览版与正式版(GA)之间的区别。预览版流程会流式传输包含单个 `action` 的 `computer_call` 项,而 `gpt-5.5` 可以流式传输包含批量 `actions[]` 的 `computer_call` 项。更高层级的 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 接口不会为此添加计算机工具专用的特殊事件名称:两种形式仍然都以 `tool_called` 呈现,而截图结果则以 `tool_output` 返回,其中封装了一个 `computer_call_output` 项。 -例如,这将逐个 token 输出 LLM 生成的文本。 +例如,以下代码会逐 token 输出 LLM 生成的文本。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## 流式传输与审批 -流式传输与会暂停以等待工具审批的运行兼容。如果某个工具需要审批,`result.stream_events()` 会结束,待处理的审批会通过 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 暴露。使用 `result.to_state()` 将结果转换为 [`RunState`][agents.run_state.RunState],批准或拒绝该中断,然后使用 `Runner.run_streamed(...)` 恢复运行。 +流式传输兼容因等待工具审批而暂停的运行。如果某个工具需要审批,`result.stream_events()` 会结束,待处理的审批将通过 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 提供。使用 `result.to_state()` 将结果转换为 [`RunState`][agents.run_state.RunState],批准或拒绝中断项,然后通过 `Runner.run_streamed(...)` 恢复运行。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,21 +57,21 @@ if result.interruptions: pass ``` -有关完整的暂停/恢复演练,请参阅 [human-in-the-loop 指南](human_in_the_loop.md)。 +有关完整的暂停/恢复流程,请参阅[人工介入指南](human_in_the_loop.md)。 -## 当前轮次后的流式传输取消 +## 当前轮次结束后的流式传输取消 -如果需要在中途停止一次流式运行,请调用 [`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。若要让当前轮次在停止前干净地完成,请改为调用 `result.cancel(mode="after_turn")`。 +如果需要中途停止流式运行,请调用 [`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。若要让当前轮次完整结束后再停止,请改为调用 `result.cancel(mode="after_turn")`。 -在 `result.stream_events()` 完成之前,流式运行并未完成。在最后一个可见 token 之后,SDK 可能仍在持久化会话项、最终确定审批状态或压缩历史。 +流式运行只有在 `result.stream_events()` 结束后才算完成。在最后一个可见 token 到达后,SDK 可能仍在持久化会话项、完成审批状态处理或压缩历史记录。 -如果你正在从 [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] 手动继续,并且 `cancel(mode="after_turn")` 在一次工具轮次后停止,请通过使用该规范化输入重新运行 `result.last_agent` 来继续那个未完成的轮次,而不是立即追加一个新的用户轮次。 -- 如果流式运行因工具审批而停止,不要将其视为一个新轮次。请先完全消费流,检查 `result.interruptions`,然后改为从 `result.to_state()` 恢复。 -- 使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 来自定义在下一次模型调用之前,如何合并检索到的会话历史与新的用户输入。如果你在那里重写新轮次项,那么被重写的版本就是该轮次会持久化的内容。 +如果你要手动基于 [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] 继续运行,并且 `cancel(mode="after_turn")` 在某个工具轮次后停止,请使用该规范化输入重新运行 `result.last_agent`,以继续尚未完成的轮次,而不要立即追加新的用户轮次。 +- 如果流式运行因等待工具审批而停止,请勿将其视为新的轮次。应完整消费流、检查 `result.interruptions`,然后从 `result.to_state()` 恢复运行。 +- 使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 自定义在下一次模型调用前,如何合并检索到的会话历史记录与新的用户输入。如果你在此处重写了新轮次中的项目,该轮次将持久化重写后的版本。 ## 运行项事件与智能体事件 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在某个项完全生成后通知你。这使你可以按“消息已生成”“工具已运行”等级别向用户推送进度更新,而不是按每个 token 推送。类似地,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会在当前智能体发生变化时(例如由于任务转移)向你提供更新。 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在某个项目完全生成后通知你。这样,你就可以按“消息已生成”“工具已运行”等粒度向用户推送进度更新,而不必逐 token 更新。类似地,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会在当前智能体发生变化时向你提供更新(例如,由任务转移引起的变化)。 ### 运行项事件名称 @@ -89,18 +89,21 @@ if result.interruptions: - `mcp_approval_response` - `mcp_list_tools` -`handoff_occured` 为了向后兼容而有意拼写错误。 +为保持向后兼容,`handoff_occured` 有意保留了拼写错误。 -当你使用托管工具搜索时,模型发出工具搜索请求时会发出 `tool_search_called`,Responses API 返回已加载的子集时会发出 `tool_search_output_created`。 +使用托管工具搜索时,模型发出工具搜索请求会触发 `tool_search_called`,而 Responses API 返回已加载的子集时会触发 `tool_search_output_created`。 -例如,这将忽略原始事件,并将更新流式传输给用户。 +使用程序化工具调用时,生成的 `program` 和由程序管理的普通子工具调用都会触发 `tool_called`。子工具输出以及相应的 `program_output` 会触发 `tool_output`。由程序管理的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 项属于例外:它们分别以 `mcp_approval_requested` 和 `mcp_list_tools` 的形式触发,并分别封装 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem]。可以检查原始项目的 `type` 来区分其他项目;由程序管理的子调用还带有一个 `caller`,其类型为 `program`,并且其调用方 ID 用于标识父程序。 + +例如,以下代码会忽略原始事件,并以流式方式向用户发送更新。 ```python import asyncio import random -from agents import Agent, ItemHelpers, Runner, function_tool +from agents import Agent, ItemHelpers, Runner +from agents.decorators import tool -@function_tool +@tool def how_many_jokes() -> int: return random.randint(1, 10) diff --git a/docs/zh/tools.md b/docs/zh/tools.md index 457ff86c77..e195a8bb2d 100644 --- a/docs/zh/tools.md +++ b/docs/zh/tools.md @@ -4,37 +4,39 @@ search: --- # 工具 -工具让智能体能够执行操作,例如获取数据、运行代码、调用外部 API,甚至操作计算机。SDK 支持五个目录: +工具让智能体能够执行操作:例如获取数据、运行代码、调用外部 API,甚至使用计算机。SDK 支持五个目录: - 由OpenAI托管的工具:与模型一起在OpenAI服务上运行。 - 本地/运行时执行工具:`ComputerTool` 和 `ApplyPatchTool` 始终在你的环境中运行,而 `ShellTool` 可以在本地或托管容器中运行。 - Function calling:将任意 Python 函数封装为工具。 -- Agents as tools:将智能体公开为可调用工具,而无需执行完整的任务转移。 -- 实验性功能:Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 +- Agents as tools:将智能体公开为可调用工具,而无需完整的任务转移。 +- 实验性 Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 ## 工具类型选择 -将本页面作为目录使用,然后跳转到与你所控制的运行时相匹配的部分。 +将本页面用作目录,然后跳转到与你所控制运行时相匹配的章节。 | 如果你想要…… | 从这里开始 | | --- | --- | -| 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管式MCP、图像生成) | [托管工具](#hosted-tools) | -| 使用工具搜索将大型工具集合延迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | -| 在你自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | +| 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管MCP、图像生成) | [托管工具](#hosted-tools) | +| 使用工具搜索将大型工具集合推迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | +| 通过生成的 JavaScript 协调多个工具调用 | [编程式工具调用](#programmatic-tool-calling) | +| 在自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | | 将 Python 函数封装为工具 | [工具调用](#function-tools) | -| 让一个智能体调用另一个智能体而不执行任务转移 | [Agents as tools](#agents-as-tools) | -| 从智能体运行限定于工作区的 Codex 任务 | [实验性功能:Codex 工具](#experimental-codex-tool) | +| 让一个智能体在不进行任务转移的情况下调用另一个智能体 | [Agents as tools](#agents-as-tools) | +| 从智能体运行限定于工作区的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | ## 托管工具 -使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI提供了一些内置工具: +使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI 提供了一些内置工具: -- [`WebSearchTool`][agents.tool.WebSearchTool] 让智能体能够检索网络。 -- [`FileSearchTool`][agents.tool.FileSearchTool] 支持从你的OpenAI向量存储中检索信息。 -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 让LLM能够在沙箱环境中执行代码。 +- [`WebSearchTool`][agents.tool.WebSearchTool] 让智能体能够进行网络检索。 +- [`FileSearchTool`][agents.tool.FileSearchTool] 允许从你的 OpenAI 向量存储中检索信息。 +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 让 LLM 能够在沙盒环境中执行代码。 - [`HostedMCPTool`][agents.tool.HostedMCPTool] 将远程MCP服务的工具公开给模型。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] 根据提示词生成图像。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] 让模型能够按需加载延迟加载的工具、命名空间或托管式MCP服务。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] 让模型能够按需加载延迟加载的工具、命名空间或托管MCP服务。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 让模型能够通过生成的 JavaScript 协调符合条件的工具。 高级托管搜索选项: @@ -62,17 +64,18 @@ async def main(): ### 托管工具搜索 -工具搜索让 OpenAI Responses 模型可以将大型工具集合延迟到运行时加载,使模型仅加载当前轮次所需的子集。当你拥有大量工具调用、命名空间组或托管式MCP服务,并且希望在不预先公开每个工具的情况下减少工具模式所占的 token 时,此功能非常有用。 +工具搜索让 OpenAI Responses 模型能够将大型工具集合推迟到运行时加载,使模型仅加载当前轮次所需的子集。当你有大量工具调用、命名空间组或托管MCP服务,并且希望在不预先公开每个工具的情况下减少工具架构所占的 token 时,这非常有用。 -如果构建智能体时已经知道候选工具,请从托管工具搜索开始。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 +如果候选工具在构建智能体时已经确定,请优先使用托管工具搜索。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 ```python from typing import Annotated -from agents import Agent, Runner, ToolSearchTool, function_tool, tool_namespace +from agents import Agent, Runner, ToolSearchTool, tool_namespace +from agents.decorators import tool -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_customer_profile( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -80,7 +83,7 @@ def get_customer_profile( return f"profile for {customer_id}" -@function_tool(defer_loading=True) +@tool(defer_loading=True) def list_open_orders( customer_id: Annotated[str, "The customer ID to look up."], ) -> str: @@ -108,24 +111,77 @@ print(result.final_output) 注意事项: -- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 的支持依赖于 `openai>=2.25.0`。 +- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 支持依赖于 `openai>=2.25.0`。 - 在智能体上配置延迟加载的工具集合时,只添加一个 `ToolSearchTool()`。 - 可搜索的工具集合包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 - 延迟加载的工具调用必须与 `ToolSearchTool()` 配合使用。仅包含命名空间的设置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 -- `tool_namespace()` 将多个 `FunctionTool` 实例归入一个具有共享名称和描述的命名空间。当你有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最佳选择。 -- OpenAI的官方最佳实践指南是[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 -- 如果可能,优先使用命名空间或托管式MCP服务,而不是许多单独延迟加载的函数。它们通常能为模型提供更好的高层搜索界面,并节省更多 token。 -- 命名空间可以混合包含立即可用和延迟加载的工具。未设置 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟工具会通过工具搜索加载。 +- `tool_namespace()` 将 `FunctionTool` 实例归入一个具有共享名称和描述的命名空间。当你有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最合适的方式。 +- OpenAI 的官方最佳实践指南是[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 +- 在可能的情况下,优先使用命名空间或托管MCP服务,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高级搜索界面,并节省更多 token。 +- 命名空间可以混合包含立即可用和延迟加载的工具。没有 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟加载工具则通过工具搜索加载。 - 根据经验,每个命名空间应保持相对精简,最好少于 10 个函数。 -- 具名 `tool_choice` 无法指定单独的命名空间名称或仅支持延迟加载的工具。应优先使用 `auto`、`required` 或实际的顶层可调用工具名称。 +- 具名 `tool_choice` 不能以单独的命名空间名称或仅延迟加载的工具为目标。请优先使用 `auto`、`required` 或真正的顶层可调用工具名称。 - `ToolSearchTool(execution="client")` 用于手动编排 Responses。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会引发异常,而不会替你执行。 -- 工具搜索活动会显示在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中,并使用专门的条目类型和事件类型。 -- 有关涵盖命名空间加载和顶层延迟工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 +- 工具搜索活动会出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中,并具有专用的项目和事件类型。 +- 有关命名空间加载和顶层延迟加载工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 - 官方平台指南:[工具搜索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 -### 托管容器 Shell 与技能 +### 编程式工具调用 -`ShellTool` 还支持在OpenAI托管的容器中执行。当你希望模型在托管容器中而不是本地运行时中执行 Shell 命令时,请使用此模式。 +编程式工具调用让受支持的 OpenAI Responses 模型能够生成 JavaScript,以调用符合条件的工具、组合其输出,并向模型返回一个结果。它适用于范围明确的工作流,这些工作流可受益于循环、分支、并行调用或中间计算,而无需在每次工具调用后都与模型往返交互。 + +生成的程序在全新的托管 V8 环境中运行。它不具备 Node.js API、文件系统或网络访问权限,也不是持久进程。该程序只能与明确允许的工具交互。 + +```python +from pydantic import BaseModel + +from agents import ( + Agent, + ModelSettings, + ProgrammaticToolCallingTool, + Runner, +) +from agents.decorators import tool + + +class InventoryOutput(BaseModel): + sku: str + available_units: int + + +@tool(allowed_callers=["programmatic"]) +def get_inventory(sku: str) -> InventoryOutput: + return InventoryOutput(sku=sku, available_units=42) + + +agent = Agent( + name="Inventory planner", + model="gpt-5.6", + model_settings=ModelSettings(tool_choice="programmatic_tool_calling"), + tools=[get_inventory, ProgrammaticToolCallingTool()], +) + +result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it.") +print(result.final_output) +``` + +注意事项: + +- 编程式工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 +- 一个智能体最多只能添加一个 `ProgrammaticToolCallingTool()`。该智能体还必须公开至少一个可通过编程方式调用的工具、一个 `ToolSearchTool()`,或由提示词管理的工具集合。 +- `allowed_callers` 控制工具的调用方式。省略该参数时,仅允许模型直接调用。使用 `["programmatic"]` 可仅允许程序访问,使用 `["direct", "programmatic"]` 则允许两种方式。 +- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、shell 和补丁应用工具直接公开 `allowed_callers`。对于托管MCP和 Code Interpreter,请在 `tool_config` 中设置 `allowed_callers`。 +- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或 dataclass 等结构化返回注解会自动转换为严格的对象输出架构,并在值返回给程序之前进行验证。如果函数没有可用的注解,请使用 `output_type=...`;如果你已有严格的对象架构,则可使用较低层级的 `output_json_schema={...}` 作为替代方案。`output_type` 和 `output_json_schema` 互斥。返回普通 `str`、`Any` 和 `None` 时仍不指定类型。 +- 由程序拥有的 SDK 工具仍使用常规 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、重试、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,并且 SDK 会保留每个子调用与程序调用方之间的关系。 +- 对审批敏感或影响较大的工具通常更适合作为直接调用保留,以便人员在每项操作成为大型程序的一部分之前进行审查。如果由程序拥有的调用因审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 +- 编程式工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。生成的程序必须先由模型加载延迟工具,然后才能调用它们。 +- `program` 项目及其由程序拥有的子调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 +- 有关完整的并发库存规划代码示例,请参阅 `examples/tools/programmatic_tool_calling.py`。 +- 官方平台指南:[编程式工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 + +### 托管容器 shell 与技能 + +`ShellTool` 还支持在OpenAI托管的容器中执行。当你希望模型在托管容器中运行 shell 命令,而不是在本地运行时中运行时,请使用此模式。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -162,48 +218,48 @@ print(result.final_output) 注意事项: -- 托管 Shell 通过 Responses API 的 Shell 工具提供。 -- `container_auto` 为请求创建容器;`container_reference` 复用现有容器。 +- 托管 shell 可通过 Responses API 的 shell 工具使用。 +- `container_auto` 为请求预配容器;`container_reference` 复用现有容器。 - `container_auto` 还可以包含 `file_ids` 和 `memory_limit`。 - `environment.skills` 接受技能引用和内联技能包。 -- 使用托管环境时,不要在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 +- 使用托管环境时,请勿在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 - `network_policy` 支持 `disabled` 和 `allowlist` 模式。 -- 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于特定域名的密钥。 +- 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于域的密钥。 - 有关完整代码示例,请参阅 `examples/tools/container_shell_skill_reference.py` 和 `examples/tools/container_shell_inline_skill.py`。 -- OpenAI平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 +- OpenAI 平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 ## 本地运行时工具 -本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用或配置的执行环境完成。 +本地运行时工具在模型响应本身之外执行。模型仍然决定何时调用它们,但实际工作由你的应用或配置的执行环境完成。 -`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 横跨两种模式:如果需要托管执行,请使用上面的托管容器配置;如果希望命令在你自己的进程中运行,请使用下面的本地运行时配置。 +`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 横跨两种模式:如果需要托管执行,请使用上述托管容器配置;如果希望命令在你自己的进程中运行,请使用下述本地运行时配置。 本地运行时工具要求你提供实现: - [`ComputerTool`][agents.tool.ComputerTool]:实现 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 接口,以启用 GUI/浏览器自动化。 -- [`ShellTool`][agents.tool.ShellTool]:同时用于本地执行和托管容器执行的最新 Shell 工具。 -- [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 Shell 集成。 +- [`ShellTool`][agents.tool.ShellTool]:适用于本地执行和托管容器执行的最新 shell 工具。 +- [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 shell 集成。 - [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor],以便在本地应用差异。 -- 使用 `ShellTool(environment={"type": "local", "skills": [...]})` 可以提供本地 Shell 技能。 +- 本地 shell 技能可通过 `ShellTool(environment={"type": "local", "skills": [...]})` 使用。 -### ComputerTool 与 Responses 计算机操作工具 +### ComputerTool 与 Responses 计算机工具 -`ComputerTool` 仍然是本地执行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该执行框架映射到 OpenAI Responses API 的计算机操作界面。 +`ComputerTool` 仍然是一个本地执行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该执行框架映射到 OpenAI Responses API 的计算机操作界面。 -对于显式的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式版内置工具载荷 `{"type": "computer"}`。较旧的 `computer-use-preview` 模型会继续使用预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与OpenAI[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中描述的平台迁移一致: +对于明确的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布版内置工具载荷 `{"type": "computer"}`。较旧的 `computer-use-preview` 模型则继续使用预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI 的[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中所述的平台迁移一致: - 模型:`computer-use-preview` -> `gpt-5.5` - 工具选择器:`computer_use_preview` -> `computer` -- 计算机调用结构:每个 `computer_call` 一个 `action` -> `computer_call` 上批量的 `actions[]` -- 截断:预览版路径要求设置 `ModelSettings(truncation="auto")` -> 正式版路径不要求 +- 计算机调用结构:每个 `computer_call` 包含一个 `action` -> `computer_call` 上批量的 `actions[]` +- 截断:预览版路径要求使用 `ModelSettings(truncation="auto")` -> 正式发布版路径不要求 -SDK 会根据实际 Responses 请求中的有效模型选择相应的传输格式。如果你使用提示词模板,并且由于模型由提示词指定而使请求省略了 `model`,SDK 会保留兼容预览版的计算机操作载荷,除非你明确保留 `model="gpt-5.5"`,或通过 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制使用正式版选择器。 +SDK 会根据实际 Responses 请求中的有效模型选择相应的传输结构。如果你使用提示词模板,并且由于模型由提示词指定而使请求省略 `model`,SDK 会继续使用兼容预览版的计算机载荷;除非你明确保留 `model="gpt-5.5"`,或通过 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制使用正式发布版选择器。 -存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 都会被接受,并规范化为与有效请求模型匹配的内置选择器。如果不存在 `ComputerTool`,这些字符串仍会像普通函数名称一样处理。 +存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 均会被接受,并规范化为与有效请求模型匹配的内置选择器。不存在 `ComputerTool` 时,这些字符串仍然会像普通函数名称一样处理。 -当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别十分重要。正式版 `computer` 载荷在序列化时不需要 `environment` 或尺寸,因此工厂尚未解析也没有问题。兼容预览版的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 可以发送 `environment`、`display_width` 和 `display_height`。 +当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别很重要。正式发布版 `computer` 载荷在序列化时不需要 `environment` 或尺寸,因此工厂尚未解析也没有问题。兼容预览版的序列化仍然需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 可以发送 `environment`、`display_width` 和 `display_height`。 -在运行时,两条路径仍使用相同的本地执行框架。预览版响应会发出包含单个 `action` 的 `computer_call` 条目;`gpt-5.5` 可以发出批量的 `actions[]`,SDK 会按顺序执行这些操作,然后生成 `computer_call_output` 截图条目。有关基于 Playwright 的可运行执行框架,请参阅 `examples/tools/computer_use.py`。 +在运行时,两条路径仍然使用同一个本地执行框架。预览版响应会发出包含单个 `action` 的 `computer_call` 项目;`gpt-5.5` 可以发出批量的 `actions[]`,SDK 会按顺序执行这些操作,然后生成一个 `computer_call_output` 截图项目。有关基于 Playwright 的可运行执行框架,请参阅 `examples/tools/computer_use.py`。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -249,12 +305,12 @@ agent = Agent( 你可以将任意 Python 函数用作工具。Agents SDK 会自动设置该工具: -- 工具名称将是 Python 函数的名称(你也可以自行提供名称) -- 工具描述将取自函数的文档字符串(你也可以自行提供描述) -- 函数输入的模式会根据函数参数自动创建 +- 工具名称将是 Python 函数的名称(也可以自行提供名称) +- 工具描述将取自函数的文档字符串(也可以自行提供描述) +- 函数输入的架构会根据函数参数自动创建 - 除非禁用,否则每个输入的描述都取自函数的文档字符串 -我们使用 Python 的 `inspect` 模块提取函数签名,同时使用 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,并使用 `pydantic` 创建模式。 +我们使用 Python 的 `inspect` 模块提取函数签名,使用 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,并使用 `pydantic` 创建架构。 使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏工具调用,直到 `ToolSearchTool()` 将其加载。你还可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关工具调用进行分组。有关完整设置和限制,请参阅[托管工具搜索](#hosted-tool-search)。 @@ -263,14 +319,15 @@ import json from typing_extensions import TypedDict, Any -from agents import Agent, FunctionTool, RunContextWrapper, function_tool +from agents import Agent, FunctionTool, RunContextWrapper +from agents.decorators import tool class Location(TypedDict): lat: float long: float -@function_tool # (1)! +@tool # (1)! async def fetch_weather(location: Location) -> str: # (2)! """Fetch the weather for a given location. @@ -282,7 +339,7 @@ async def fetch_weather(location: Location) -> str: return "sunny" -@function_tool(name_override="fetch_data") # (3)! +@tool(name_override="fetch_data") # (3)! def read_file(ctx: RunContextWrapper[Any], path: str, directory: str | None = None) -> str: """Read the contents of a file. @@ -308,10 +365,10 @@ for tool in agent.tools: ``` -1. 你可以使用任意 Python 类型作为函数参数,并且函数可以是同步或异步函数。 -2. 如果存在文档字符串,则会使用它来获取函数描述和参数描述。 +1. 你可以使用任意 Python 类型作为函数参数,并且函数可以是同步或异步的。 +2. 如果存在文档字符串,则会使用它来获取描述和参数描述 3. 函数可以选择接收 `context`(必须是第一个参数)。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 -4. 你可以将装饰后的函数传入工具列表。 +4. 你可以将经过装饰的函数传递给工具列表。 ??? note "展开以查看输出" @@ -385,20 +442,20 @@ for tool in agent.tools: ### 从工具调用返回图像或文件 -除了返回文本输出,你还可以将一个或多个图像或文件作为工具调用的输出返回。为此,你可以返回以下任意内容: +除了返回文本输出之外,你还可以返回一个或多个图像或文件作为工具调用的输出。为此,可以返回以下任意内容: -- 图像:[`ToolOutputImage`][agents.tool.ToolOutputImage](或 TypedDict 版本 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) -- 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- 文本:字符串、可转换为字符串的对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- 图像:[`ToolOutputImage`][agents.tool.ToolOutputImage](或其 TypedDict 版本 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) +- 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或其 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) +- 文本:字符串、可转换为字符串的对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或其 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### 自定义工具调用 -有时,你可能不想将 Python 函数用作工具。如果愿意,你可以直接创建 [`FunctionTool`][agents.tool.FunctionTool]。你需要提供: +有时,你可能不想将 Python 函数用作工具。如果愿意,可以直接创建 [`FunctionTool`][agents.tool.FunctionTool]。你需要提供: - `name` - `description` -- `params_json_schema`,即参数的 JSON 模式 -- `on_invoke_tool`,这是一个异步函数,接收 [`ToolContext`][agents.tool_context.ToolContext] 和采用 JSON 字符串形式的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 +- `params_json_schema`,即参数的 JSON 架构 +- `on_invoke_tool`,它是一个异步函数,接收 [`ToolContext`][agents.tool_context.ToolContext] 和 JSON 字符串形式的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 ```python from typing import Any @@ -433,29 +490,29 @@ tool = FunctionTool( ### 参数与文档字符串的自动解析 -如前所述,我们会自动解析函数签名以提取工具模式,并解析文档字符串以提取工具及各个参数的描述。相关注意事项如下: +如前所述,我们会自动解析函数签名以提取工具架构,并解析文档字符串以提取工具和各个参数的描述。相关注意事项如下: -1. 签名解析通过 `inspect` 模块完成。我们使用类型注解理解参数类型,并动态构建 Pydantic 模型来表示整体模式。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 -2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这只能尽力而为,你可以在调用 `function_tool` 时显式设置格式。也可以将 `use_docstring_info` 设置为 `False`,以禁用文档字符串解析。 +1. 签名解析通过 `inspect` 模块完成。我们使用类型注解了解参数类型,并动态构建 Pydantic 模型来表示整体架构。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 +2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这只是尽力而为;你可以在调用 `function_tool` 时明确设置格式。也可以将 `use_docstring_info` 设置为 `False` 来禁用文档字符串解析。对于 Google 风格的文档字符串,解析器还接受紧跟在摘要文本之后且中间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 章节。 -模式提取代码位于 [`agents.function_schema`][]。 +架构提取代码位于 [`agents.function_schema`][]。 ### 使用 Pydantic Field 约束和描述参数 -你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,支持两种形式:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 模式和验证都会包含这些约束。 +你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 相同,两种形式都受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 架构和验证均包含这些约束。 ```python from typing import Annotated from pydantic import Field -from agents import function_tool +from agents.decorators import tool # Default-based form -@function_tool +@tool def score_a(score: int = Field(..., ge=0, le=100, description="Score from 0 to 100")) -> str: return f"Score recorded: {score}" # Annotated form -@function_tool +@tool def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score from 0 to 100")]) -> str: return f"Score recorded: {score}" ``` @@ -466,10 +523,11 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr ```python import asyncio -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool -@function_tool(timeout=2.0) +@tool(timeout=2.0) async def slow_lookup(query: str) -> str: await asyncio.sleep(10) return f"Result for {query}" @@ -482,7 +540,7 @@ agent = Agent( ) ``` -达到超时时间时,默认行为是 `timeout_behavior="error_as_result"`,它会发送一条模型可见的超时消息(例如 `Tool 'slow_lookup' timed out after 2 seconds.`)。 +达到超时时间时,默认行为是 `timeout_behavior="error_as_result"`,它会发送模型可见的超时消息(例如 `Tool 'slow_lookup' timed out after 2 seconds.`)。 你可以控制超时处理方式: @@ -492,10 +550,11 @@ agent = Agent( ```python import asyncio -from agents import Agent, Runner, ToolTimeoutError, function_tool +from agents import Agent, Runner, ToolTimeoutError +from agents.decorators import tool -@function_tool(timeout=1.5, timeout_behavior="raise_exception") +@tool(timeout=1.5, timeout_behavior="raise_exception") async def slow_tool() -> str: await asyncio.sleep(5) return "done" @@ -515,14 +574,15 @@ except ToolTimeoutError as e: ### 工具调用中的错误处理 -通过 `@function_tool` 创建工具调用时,你可以传入 `failure_error_function`。如果工具调用崩溃,该函数会向LLM提供错误响应。 +通过 `@function_tool` 创建工具调用时,可以传入 `failure_error_function`。当工具调用崩溃时,此函数会向 LLM 提供错误响应。 -- 默认情况下(即不传入任何内容时),它会运行 `default_tool_error_function`,告知LLM发生了错误。 -- 如果传入你自己的错误函数,则会改为运行该函数,并将响应发送给LLM。 -- 如果显式传入 `None`,则会重新引发任何工具调用错误,供你处理。如果模型生成了无效 JSON,这可能是 `ModelBehaviorError`;如果你的代码崩溃,则可能是 `UserError` 等。 +- 默认情况下(即未传入任何内容时),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 +- 如果传入自己的错误函数,则会改为运行该函数,并将响应发送给 LLM。 +- 如果明确传入 `None`,则任何工具调用错误都会重新引发,由你处理。如果模型生成了无效 JSON,这可能是 `ModelBehaviorError`;如果你的代码崩溃,则可能是 `UserError`,等等。 ```python -from agents import function_tool, RunContextWrapper +from agents import RunContextWrapper +from agents.decorators import tool from typing import Any def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) -> str: @@ -530,7 +590,7 @@ def my_custom_error_function(context: RunContextWrapper[Any], error: Exception) print(f"A tool call failed with the following error: {error}") return "An internal server error occurred. Please try again later." -@function_tool(failure_error_function=my_custom_error_function) +@tool(failure_error_function=my_custom_error_function) def get_user_profile(user_id: str) -> str: """Fetches a user profile from a mock API. This function demonstrates a 'flaky' or failing API call. @@ -542,11 +602,11 @@ def get_user_profile(user_id: str) -> str: ``` -如果你手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内处理错误。 +如果手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内部处理错误。 ## Agents as tools -在某些工作流中,你可能希望由一个中央智能体编排由多个专用智能体组成的网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 +在某些工作流中,你可能希望由一个中心智能体编排专用智能体网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 ```python import asyncio @@ -592,12 +652,15 @@ if __name__ == "__main__": ### 工具智能体自定义 -`agent.as_tool` 函数是一种便捷方法,可轻松将智能体转换为工具。它支持常见的运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 +`agent.as_tool` 函数是一种便捷方法,可以轻松地将智能体转换为工具。它支持 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval` 等常见运行时选项。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 -状态选项用于配置由工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请显式向两者传入相同的 `session`。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:由客户端管理的 `session`,或通过 `previous_response_id` 或 `conversation_id` 在服务端管理的延续。 +状态选项用于配置由工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请明确向两者传入相同的 `session`。与 `Runner.run` 一样,应为嵌套运行选择一种状态策略:使用由客户端管理的 `session`,或通过 `previous_response_id` 或 `conversation_id` 在服务端管理延续状态。 ```python -@function_tool +from agents.decorators import tool + + +@tool async def run_my_agent() -> str: """A tool that runs the agent with custom configs""" @@ -615,11 +678,11 @@ async def run_my_agent() -> str: ### 工具智能体的结构化输入 -默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或数据类类型)公开结构化模式。 +默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或 dataclass 类型)公开结构化架构。 其他选项: -- `include_input_schema=True` 在生成的嵌套输入中包含完整的 JSON Schema。 +- `include_input_schema=True` 会在生成的嵌套输入中包含完整的 JSON Schema。 - `input_builder=...` 让你可以完全自定义如何将结构化工具参数转换为嵌套智能体输入。 - `RunContextWrapper.tool_input` 包含嵌套运行上下文中已解析的结构化载荷。 @@ -643,15 +706,15 @@ translator_tool = translator_agent.as_tool( 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_structured.py`。 -### 工具智能体的审批关卡 +### 工具智能体的审批门控 -`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理条目会出现在 `result.interruptions` 中;然后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人工介入指南](human_in_the_loop.md)。 +`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理项目将显示在 `result.interruptions` 中;然后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人工介入指南](human_in_the_loop.md)。 ### 自定义输出提取 -在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中央智能体。以下情况可能适合这样做: +在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中心智能体。以下情况可能会需要这样做: -- 从子智能体的聊天历史记录中提取特定信息(例如 JSON 载荷)。 +- 从子智能体的聊天历史中提取特定信息(例如 JSON 载荷)。 - 转换或重新格式化智能体的最终答案(例如将 Markdown 转换为纯文本或 CSV)。 - 验证输出,或在智能体响应缺失或格式错误时提供回退值。 @@ -674,11 +737,11 @@ json_tool = data_agent.as_tool( ) ``` -在自定义提取器中,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你在后处理嵌套结果时需要外层工具名称、调用 ID 或原始参数,此属性非常有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 +在自定义提取器内部,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在对嵌套结果进行后处理时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 ### 嵌套智能体运行的流式传输 -向 `as_tool` 传入 `on_stream` 回调,即可监听嵌套智能体发出的流式事件,同时在流结束后仍返回其最终输出。 +向 `as_tool` 传入 `on_stream` 回调,以侦听嵌套智能体发出的流式传输事件,同时仍在流完成后返回其最终输出。 ```python from agents import AgentToolStreamEvent @@ -699,14 +762,14 @@ billing_agent_tool = billing_agent.as_tool( 预期行为: - 事件类型与 `StreamEvent["type"]` 一致:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 -- 提供 `on_stream` 会自动以流式传输模式运行嵌套智能体,并在返回最终输出前耗尽整个流。 -- 处理程序可以是同步或异步的;每个事件都会按照到达顺序传递。 -- 通过模型工具调用来调用工具时会提供 `tool_call`;直接调用时,它可能为 `None`。 -- 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 +- 提供 `on_stream` 会自动以流式传输模式运行嵌套智能体,并在返回最终输出前耗尽该流。 +- 处理程序可以是同步或异步的;每个事件都会按到达顺序传递。 +- 通过模型工具调用来调用工具时,会提供 `tool_call`;直接调用时,其值可能为 `None`。 +- 有关完整的可运行示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 -### 工具的条件启用 +### 条件式工具启用 -你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样,你就可以根据上下文、用户偏好或运行时条件,动态筛选LLM可用的工具。 +你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选可供 LLM 使用的工具。 ```python import asyncio @@ -754,8 +817,8 @@ orchestrator = Agent( ) async def main(): - context = RunContextWrapper(LanguageContext(language_preference="french_spanish")) - result = await Runner.run(orchestrator, "How are you?", context=context.context) + context = LanguageContext(language_preference="french_spanish") + result = await Runner.run(orchestrator, "How are you?", context=context) print(result.final_output) asyncio.run(main()) @@ -767,18 +830,18 @@ asyncio.run(main()) - **可调用函数**:接收 `(context, agent)` 并返回布尔值的函数 - **异步函数**:用于复杂条件逻辑的异步函数 -禁用的工具会在运行时对LLM完全隐藏,因此适用于: +禁用的工具在运行时对 LLM 完全隐藏,因此适用于: -- 根据用户权限控制功能 -- 特定环境的工具可用性(开发环境与生产环境) +- 根据用户权限进行功能门控 +- 特定环境下的工具可用性(开发环境与生产环境) - 对不同工具配置进行 A/B 测试 - 根据运行时状态动态筛选工具 -## 实验性功能:Codex 工具 +## 实验性 Codex 工具 -`codex_tool` 封装了 Codex CLI,使智能体能够在工具调用期间运行限定于工作区的任务(Shell、文件编辑、MCP工具)。此功能处于实验阶段,可能会发生变化。 +`codex_tool` 封装 Codex CLI,使智能体能够在工具调用期间运行限定于工作区的任务(shell、文件编辑、MCP工具)。此功能为实验性功能,可能会发生变化。 -当你希望主智能体将范围明确的工作区任务委派给 Codex,同时不退出当前运行时,请使用此工具。默认工具名称为 `codex`。如果设置自定义名称,则该名称必须是 `codex` 或以 `codex_` 开头。当智能体包含多个 Codex 工具时,每个工具必须使用唯一名称。 +当你希望主智能体在不离开当前运行的情况下,将范围明确的工作区任务委托给 Codex 时,请使用它。默认情况下,工具名称为 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当智能体包含多个 Codex 工具时,每个工具必须使用唯一名称。 ```python from agents import Agent @@ -807,33 +870,33 @@ agent = Agent( ) ``` -请从以下选项组开始: +可从以下选项组开始: -- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以在哪里操作。请将两者配合使用;如果工作目录不在 Git 仓库内,请设置 `skip_git_repo_check=True`。 -- 线程默认值:`default_thread_options=ThreadOptions(...)` 用于配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。应优先使用 `web_search_mode`,而不是旧版的 `web_search_enabled`。 -- 轮次默认值:`default_turn_options=TurnOptions(...)` 用于配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 -- 工具输入/输出:工具调用必须至少包含一个 `inputs` 条目,其格式为 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }`。`output_schema` 可用于要求 Codex 提供结构化响应。 +- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以在何处操作。请配合设置这两个选项;当工作目录不在 Git 仓库内时,请设置 `skip_git_repo_check=True`。 +- 线程默认值:`default_thread_options=ThreadOptions(...)` 配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。请优先使用 `web_search_mode`,而不是旧版的 `web_search_enabled`。 +- 轮次默认值:`default_turn_options=TurnOptions(...)` 配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 +- 工具输入/输出:工具调用必须至少包含一个 `inputs` 项目,其格式为 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }`。`output_schema` 让你可以要求 Codex 返回结构化响应。 线程复用和持久化是独立的控制项: - `persist_session=True` 会让对同一工具实例的重复调用复用一个 Codex 线程。 -- `use_run_context_thread_id=True` 会在运行上下文中存储并复用线程 ID,适用于共享同一可变上下文对象的多次运行。 -- 线程 ID 的优先级依次为:每次调用的 `thread_id`、运行上下文线程 ID(如果启用),然后是已配置的 `thread_id` 选项。 -- 对于 `name="codex"`,默认运行上下文键为 `codex_thread_id`;对于 `name="codex_"`,则为 `codex_thread_id_`。可以使用 `run_context_thread_id_key` 覆盖它。 - +- `use_run_context_thread_id=True` 会在运行上下文中存储并复用线程 ID,适用于共享同一可变上下文对象的多个运行。 +- 线程 ID 的优先级依次为:每次调用的 `thread_id`、运行上下文线程 ID(如果已启用),然后是已配置的 `thread_id` 选项。 +- 对于 `name="codex"`,默认运行上下文键为 `codex_thread_id`;对于 `name="codex_"`,则为 `codex_thread_id_`。可使用 `run_context_thread_id_key` 覆盖该键。 + 运行时配置: -- 身份验证:设置 `CODEX_API_KEY`(首选)或 `OPENAI_API_KEY`,或传入 `codex_options={"api_key": "..."}`。 -- 运行时:`codex_options.base_url` 会覆盖 CLI 的基础 URL。 +- 身份验证:设置 `CODEX_API_KEY`(首选)或 `OPENAI_API_KEY`,或者传入 `codex_options={"api_key": "..."}`。 +- 运行时:`codex_options.base_url` 会覆盖 CLI 基础 URL。 - 二进制文件解析:设置 `codex_options.codex_path_override`(或 `CODEX_PATH`)以固定 CLI 路径。否则,SDK 会先从 `PATH` 中解析 `codex`,然后回退到捆绑的供应商二进制文件。 - 环境:`codex_options.env` 完全控制子进程环境。提供该选项时,子进程不会继承 `os.environ`。 - 流限制:`codex_options.codex_subprocess_stream_limit_bytes`(或 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)控制 stdout/stderr 读取器限制。有效范围为 `65536` 到 `67108864`;默认值为 `8388608`。 -- 流式传输:`on_stream` 接收线程/轮次生命周期事件和条目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 和 `error` 条目更新)。 -- 输出:结果包括 `response`、`usage` 和 `thread_id`;使用量会添加到 `RunContextWrapper.usage`。 +- 流式传输:`on_stream` 接收线程/轮次生命周期事件和项目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 和 `error` 项目更新)。 +- 输出:结果包括 `response`、`usage` 和 `thread_id`;用量会添加到 `RunContextWrapper.usage`。 -参考资料: +参考: - [Codex 工具 API 参考](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions 参考](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions 参考](ref/extensions/experimental/codex/turn_options.md) -- 有关完整的可运行代码示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file +- 有关完整的可运行示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file diff --git a/docs/zh/tracing.md b/docs/zh/tracing.md index 3236eb3e4d..ecb464f076 100644 --- a/docs/zh/tracing.md +++ b/docs/zh/tracing.md @@ -4,51 +4,51 @@ search: --- # 追踪 -Agents SDK内置追踪功能,可在智能体运行期间收集全面的事件记录,包括 LLM生成、工具调用、任务转移、安全防护措施,甚至是发生的自定义事件。使用[追踪控制面板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化和监控工作流。 +Agents SDK 内置追踪功能,可收集智能体运行期间发生的各类事件的完整记录:LLM生成、工具调用、任务转移、安全防护措施,甚至包括发生的自定义事件。借助[追踪控制面板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化和监控工作流。 !!!note - 追踪默认启用。你可以通过以下三种常用方式将其禁用: + 追踪默认启用。你可以通过以下三种常见方式禁用: 1. 设置环境变量 `OPENAI_AGENTS_DISABLE_TRACING=1`,在全局范围内禁用追踪 2. 在代码中使用 [`set_tracing_disabled(True)`][agents.set_tracing_disabled],在全局范围内禁用追踪 - 3. 将 [`agents.run.RunConfig.tracing_disabled`][] 设置为 `True`,为单次运行禁用追踪 + 3. 将 [`agents.run.RunConfig.tracing_disabled`][] 设置为 `True`,针对单次运行禁用追踪 -***对于使用OpenAI API且遵循零数据保留(Zero Data Retention,ZDR)政策的组织,追踪功能不可用。*** +***对于使用OpenAI API 且采用零数据保留(ZDR)政策的组织,追踪功能不可用。*** ## 追踪与跨度 -- **追踪**表示一次“工作流”的端到端操作,由多个跨度组成。追踪具有以下属性: - - `workflow_name`:逻辑工作流或应用。例如,“代码生成”或“客户服务”。 - - `trace_id`:追踪的唯一 ID。如果未传入,则自动生成。格式必须为 `trace_<32_alphanumeric>`。 - - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,可以使用聊天线程 ID。 +- **追踪**表示一次端到端的“工作流”操作。它们由多个跨度组成。追踪具有以下属性: + - `workflow_name`:逻辑工作流或应用。例如“代码生成”或“客户服务”。 + - `trace_id`:追踪的唯一 ID。如果未传入,则会自动生成。格式必须为 `trace_<32_alphanumeric>`。 + - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,你可以使用聊天线程 ID。 - `disabled`:如果为 True,则不会记录该追踪。 - `metadata`:追踪的可选元数据。 -- **跨度**表示具有开始和结束时间的操作。跨度具有: +- **跨度**表示具有开始和结束时间的操作。跨度具有以下属性: - `started_at` 和 `ended_at` 时间戳。 - - `trace_id`,表示其所属的追踪 - - `parent_id`,指向该跨度的父跨度(如果有) - - `span_data`,即有关该跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM生成的信息,等等。 + - `trace_id`,表示它们所属的追踪 + - `parent_id`,指向该跨度的父跨度(如果存在) + - `span_data`,即有关该跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM生成的信息,依此类推。 ## 默认追踪 默认情况下,SDK 会追踪以下内容: -- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在一个 `trace()` 中。 -- 每次运行器调用都封装在一个 `task_span()` 中。 -- 每个模型轮次都封装在一个 `turn_span()` 中。 -- 每次智能体运行时,都封装在 `agent_span()` 中 +- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在 `trace()` 中。 +- 每次运行器调用都封装在 `task_span()` 中。 +- 每个模型轮次都封装在 `turn_span()` 中。 +- 每次智能体运行都封装在 `agent_span()` 中 - LLM生成封装在 `generation_span()` 中 -- 每次工具调用都封装在 `function_span()` 中 +- 每次函数工具调用都封装在 `function_span()` 中 - 安全防护措施封装在 `guardrail_span()` 中 - 任务转移封装在 `handoff_span()` 中 - 音频输入(语音转文本)封装在 `transcription_span()` 中 - 音频输出(文本转语音)封装在 `speech_span()` 中 -- 相关的音频跨度可以作为子跨度归入 `speech_group_span()` 中 +- 相关的音频跨度可以将 `speech_group_span()` 作为父跨度 -默认情况下,追踪名为“智能体工作流”。使用 `trace` 时可以设置此名称,也可以使用 [`RunConfig`][agents.run.RunConfig] 配置名称及其他属性。 +默认情况下,追踪名称为“Agent workflow”。使用 `trace` 时可以设置此名称,也可以通过 [`RunConfig`][agents.run.RunConfig] 配置名称和其他属性。 -如果需要更紧凑的层级结构,可以为某次运行禁用自动任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 +如果你希望层次结构更紧凑,可以针对某次运行禁用自动任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -此外,你可以设置[自定义追踪进程](#custom-tracing-processors),将追踪推送到其他目标位置(作为替代目标或辅助目标)。 +此外,你还可以设置[自定义追踪进程](#custom-tracing-processors),将追踪发送到其他目标(作为替代目标或次要目标)。 ## 长时间运行的工作进程与即时导出 -默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出一次追踪,或在内存队列达到其大小阈值时提前导出,并在进程退出时执行最后一次刷新。在 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时间运行的工作进程中,这意味着追踪通常无需任何额外代码即可自动导出,但它们可能不会在每个作业完成后立即显示在追踪控制面板中。 +默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出一次追踪,或者在内存队列达到其大小触发阈值时提前导出,并且还会在进程退出时执行最后一次刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时间运行的工作进程,这意味着通常无需任何额外代码即可自动导出追踪,但它们可能不会在每个作业完成后立即显示在追踪控制面板中。 -如果需要保证在工作单元结束时立即交付,请在追踪上下文退出后调用 [`flush_traces()`][agents.tracing.flush_traces]。 +如果需要保证在一个工作单元结束时立即交付,请在追踪上下文退出后调用 [`flush_traces()`][agents.tracing.flush_traces]。 ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此请在 `trace()` 关闭后调用它,以避免刷新尚未完整构建的追踪。如果默认导出延迟可以接受,则可以跳过此调用。 +[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此应在 `trace()` 关闭后调用,以避免刷新尚未完整构建的追踪。如果可以接受默认的导出延迟,则可以跳过此调用。 ## 高层级追踪 -有时,你可能希望多次调用 `run()` 时将其纳入同一个追踪。可以通过将整个代码封装在 `trace()` 中来实现。 +有时,你可能希望多次调用 `run()` 时将其纳入同一个追踪。为此,可以将整个代码封装在 `trace()` 中。 ```python from agents import Agent, Runner, trace @@ -122,20 +122,20 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 由于两次 `Runner.run` 调用都封装在 `with trace()` 中,因此各次运行将成为整体追踪的一部分,而不会创建两个追踪。 +1. 由于两次 `Runner.run` 调用都封装在 `with trace()` 中,因此各次运行将成为整体追踪的一部分,而不是创建两个追踪。 ## 追踪创建 -可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。你有以下两种方式: +你可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。你有以下两种方式: 1. **推荐**:将追踪用作上下文管理器,即 `with trace(...) as my_trace`。这会在适当的时间自动启动和结束追踪。 2. 也可以手动调用 [`trace.start()`][agents.tracing.Trace.start] 和 [`trace.finish()`][agents.tracing.Trace.finish]。 -当前追踪通过 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它能够自动支持并发。如果手动启动或结束追踪,则需要将 `mark_as_current` 和 `reset_current` 传递给 `start()`/`finish()`,以更新当前追踪。 +当前追踪通过 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它会自动支持并发。如果手动启动或结束追踪,则需要将 `mark_as_current` 和 `reset_current` 传递给 `start()`/`finish()`,以更新当前追踪。 ## 跨度创建 -可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。通常无需手动创建跨度。可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 +你可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。通常不需要手动创建跨度。你可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 跨度会自动成为当前追踪的一部分,并嵌套在最近的当前跨度下;当前跨度通过 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 @@ -143,28 +143,28 @@ async def main(): 某些跨度可能会捕获潜在的敏感数据。 -`generation_span()` 会存储 LLM生成的输入和输出,`function_span()` 会存储函数调用的输入和输出。这些内容可能包含敏感数据,因此可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获这些数据。 +`generation_span()` 会存储 LLM生成的输入和输出,`function_span()` 会存储函数调用的输入和输出。这些内容可能包含敏感数据,因此你可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获此类数据。 -同样,默认情况下,音频跨度包含输入和输出音频的 base64 编码 PCM 数据。可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] 禁止捕获这些音频数据。 +同样,默认情况下,音频跨度会包含输入和输出音频的 base64 编码 PCM 数据。你可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] 禁止捕获这些音频数据。 -默认情况下,`trace_include_sensitive_data` 为 `True`。无需修改代码,只需在运行应用之前将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,即可设置默认值。 +默认情况下,`trace_include_sensitive_data` 为 `True`。你可以在运行应用前,将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,从而在不修改代码的情况下设置默认值。 ## 自定义追踪进程 -追踪的高层架构如下: +追踪的高层级架构如下: - 初始化时,我们会创建一个全局 [`TraceProvider`][agents.tracing.setup.TraceProvider],负责创建追踪。 -- 我们为 `TraceProvider` 配置一个 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],它会将追踪和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者再将跨度和追踪分批导出到OpenAI后端。 +- 我们使用 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 配置 `TraceProvider`,由它将追踪和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者再将跨度和追踪分批导出到OpenAI后端。 -如果要自定义此默认设置、将追踪发送到其他或额外的后端,或修改导出器行为,有以下两种选择: +若要自定义此默认设置,将追踪发送到其他或额外的后端,或修改导出器行为,你有以下两种选择: -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 可用于添加一个**额外的**追踪进程,该进程将在追踪和跨度准备就绪时接收它们。这样,除了将追踪发送到OpenAI后端外,还可以执行自己的处理。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 可用于使用自己的追踪进程**替换**默认进程。这意味着,除非加入一个执行发送操作的 `TracingProcessor`,否则追踪不会发送到OpenAI后端。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 允许添加一个**额外的**追踪进程,它会在追踪和跨度就绪时接收它们。这样,除了将追踪发送到OpenAI后端之外,你还可以执行自己的处理。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许使用你自己的追踪进程**替换**默认进程。这意味着,除非你加入能够将追踪发送到OpenAI后端的 `TracingProcessor`,否则追踪不会发送到OpenAI后端。 ## 非OpenAI模型追踪 -可以将OpenAI API 密钥与非OpenAI模型搭配使用,从而在OpenAI追踪控制面板中启用免费追踪,而无需禁用追踪。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 +你可以对非OpenAI模型使用 OpenAI API 密钥,从而在 OpenAI追踪控制面板中启用免费追踪,而无需禁用追踪。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -如果仅需要为单次运行使用不同的追踪密钥,请通过 `RunConfig` 传入该密钥,而不要更改全局导出器。 +如果只需要为单次运行使用其他追踪密钥,请通过 `RunConfig` 传入,而不要更改全局导出器。 ```python from agents import Runner, RunConfig @@ -197,13 +197,13 @@ await Runner.run( ) ``` -## 附加说明 -- 可在OpenAI追踪控制面板中查看免费追踪。 +## 补充说明 +- 可在 OpenAI追踪控制面板中查看免费追踪。 ## 生态系统集成 -以下社区和供应商集成支持OpenAI Agents SDK追踪接口。 +以下社区和供应商集成支持 OpenAI Agents SDK 的追踪接口。 ### 外部追踪进程列表 diff --git a/docs/zh/visualization.md b/docs/zh/visualization.md index 8b3af573fd..0ca7bbc69f 100644 --- a/docs/zh/visualization.md +++ b/docs/zh/visualization.md @@ -28,11 +28,12 @@ pip install "openai-agents[viz]" ```python import os -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph -@function_tool +@tool def get_weather(city: str) -> str: return f"The weather in {city} is sunny." diff --git a/docs/zh/voice/quickstart.md b/docs/zh/voice/quickstart.md index c962ed0446..808e9b8b42 100644 --- a/docs/zh/voice/quickstart.md +++ b/docs/zh/voice/quickstart.md @@ -53,15 +53,13 @@ graph LR ```python import random -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") @@ -132,10 +130,8 @@ import random import numpy as np import sounddevice as sd -from agents import ( - Agent, - function_tool, -) +from agents import Agent +from agents.decorators import tool from agents.voice import ( AudioInput, SingleAgentVoiceWorkflow, @@ -144,7 +140,7 @@ from agents.voice import ( from agents.extensions.handoff_prompt import prompt_with_handoff_instructions -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") From 73a2cd56d395eaa350a04638563a441e7f9cefec Mon Sep 17 00:00:00 2001 From: Joshua Nwachinemere <217677783+dk3yyyy@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:55:59 +0100 Subject: [PATCH 034/473] fix: retry pre-response WebSocket server errors (#3991) --- src/agents/models/openai_responses.py | 5 +- tests/models/test_openai_responses.py | 74 +++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 55808eda10..006aa203a1 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -1034,7 +1034,10 @@ def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice if ( isinstance(request.error, ResponsesWebSocketError) and request.error.event_type == "error" - and request.error.code == "server_is_overloaded" + and ( + request.error.code == "server_is_overloaded" + or (request.error.error_type == "server_error" and request.error.code is None) + ) ): return ModelRetryAdvice( suggested=True, diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index bd555fdcd8..bc1d33c64b 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -4041,15 +4041,81 @@ def test_websocket_get_retry_advice_marks_pre_response_overload_retryable( @pytest.mark.allow_call_model_methods -def test_websocket_get_retry_advice_keeps_partial_overload_unsafe() -> None: +@pytest.mark.parametrize("previous_response_id", [None, "resp_prev"]) +def test_websocket_get_retry_advice_marks_pre_response_server_error_retryable( + previous_response_id: str | None, +) -> None: model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) error = ResponsesWebSocketError( { "type": "error", "error": { - "type": "service_unavailable_error", - "code": "server_is_overloaded", - "message": "Our servers are currently overloaded. Please try again later.", + "type": "server_error", + "code": None, + "message": "Sorry, something went wrong.", + }, + } + ) + + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=True, + previous_response_id=previous_response_id, + ) + ) + + assert advice is not None + assert advice.suggested is True + assert advice.replay_safety is None + + +@pytest.mark.allow_call_model_methods +def test_websocket_get_retry_advice_does_not_override_non_transient_error_code() -> None: + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + error = ResponsesWebSocketError( + { + "type": "error", + "error": { + "type": "server_error", + "code": "invalid_request_error", + "message": "Invalid request.", + }, + } + ) + + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=True, + ) + ) + + assert advice is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.parametrize( + ("error_type", "code"), + [ + ("service_unavailable_error", "server_is_overloaded"), + ("server_error", None), + ], +) +def test_websocket_get_retry_advice_keeps_partial_transient_error_unsafe( + error_type: str, + code: str | None, +) -> None: + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + error = ResponsesWebSocketError( + { + "type": "error", + "error": { + "type": error_type, + "code": code, + "message": "Transient provider error.", }, } ) From 0eb4780dbba0d24064a7d199ced276370c1613a6 Mon Sep 17 00:00:00 2001 From: ShawnSiao Date: Tue, 28 Jul 2026 06:56:17 +0800 Subject: [PATCH 035/473] docs: fix a URL in sandbox example code (#3987) --- .../extensions/daytona/usaspending_text2sql/schema/glossary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/glossary.md b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/glossary.md index 89a6c4f1f5..7e4bb7f64f 100644 --- a/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/glossary.md +++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/glossary.md @@ -854,7 +854,7 @@ A unique identifier assigned to a federal contract, purchase order, basic orderi **Official definition:** The unique identifier of the specific award being reported. -[Read more in the Federal Acquisition Regulation](https://www.acquisition.gov/far/html/Subpart%204_16.html). +[Read more in the Federal Acquisition Regulation](https://www.acquisition.gov/far/subpart-4.16). ## Product or Service Code (PSC) From e42482470ae605d8f5ab28c964cd82f3d9e05b0f Mon Sep 17 00:00:00 2001 From: ShawnSiao Date: Tue, 28 Jul 2026 06:56:31 +0800 Subject: [PATCH 036/473] docs: repair tracing integration links (#3986) --- docs/tracing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index 88adfd1854..2fb493ad82 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -205,7 +205,7 @@ The following community and vendor integrations support the OpenAI Agents SDK tr - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) -- [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) +- [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) - [MLflow (self-hosted/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) - [MLflow (Databricks hosted)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) @@ -225,7 +225,7 @@ The following community and vendor integrations support the OpenAI Agents SDK tr - [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) - [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) - [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/languages/integrations#openai-agents-sdk) +- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) From b2f0344e92a2eaafad1a987bb32c06e249217c7f Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 27 Jul 2026 17:56:49 -0500 Subject: [PATCH 037/473] fix(tracing): enforce max_batch_size during force_flush and shutdown in BatchTraceProcessor (#3985) --- src/agents/tracing/processors.py | 18 ++++++++---------- tests/test_trace_processor.py | 12 +++++------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 6c68a2673f..051bef46cf 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -642,13 +642,13 @@ def shutdown(self, timeout: float | None = None): ) else: # No background thread: process any remaining items synchronously. - self._export_batches(force=True, deadline=deadline) + self._export_batches(deadline=deadline) def force_flush(self): """ Forces an immediate flush of all queued spans. """ - self._export_batches(force=True) + self._export_batches() def _run(self): while not self._shutdown_event.is_set(): @@ -657,7 +657,7 @@ def _run(self): # If it's time for a scheduled flush or queue is above the trigger threshold if current_time >= self._next_export_time or queue_size >= self._export_trigger_size: - self._export_batches(force=False) + self._export_batches() # Reset the next scheduled flush time self._next_export_time = time.time() + self._schedule_delay else: @@ -665,11 +665,11 @@ def _run(self): time.sleep(0.2) # Final drain after shutdown - self._export_batches(force=True, deadline=self._shutdown_deadline) + self._export_batches(deadline=self._shutdown_deadline) - def _export_batches(self, force: bool = False, deadline: float | None = None): - """Drains the queue and exports in batches. If force=True, export everything. - Otherwise, export up to `max_batch_size` repeatedly until the queue is completely empty. + def _export_batches(self, deadline: float | None = None): + """Drains the queue and exports in batches of up to `max_batch_size` until the queue + is completely empty. """ with self._export_lock: while True: @@ -682,9 +682,7 @@ def _export_batches(self, force: bool = False, deadline: float | None = None): items_to_export: list[Span[Any] | Trace] = [] # Gather a batch of spans up to max_batch_size - while not self._queue.empty() and ( - force or len(items_to_export) < self._max_batch_size - ): + while not self._queue.empty() and len(items_to_export) < self._max_batch_size: try: items_to_export.append(self._queue.get_nowait()) except queue.Empty: diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index ae34114a30..1b580c928b 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -133,15 +133,13 @@ def test_batch_trace_processor_force_flush(mocked_exporter): processor.force_flush() - # Ensure exporter.export was called with all items - # Because max_batch_size=2, it may have been called multiple times - total_exported = 0 - for call_args in mocked_exporter.export.call_args_list: - batch = call_args[0][0] # first positional arg to export() is the items list - total_exported += len(batch) + # Ensure exporter.export was called with all items in batches respecting max_batch_size=2 + exported_batches = [call_args[0][0] for call_args in mocked_exporter.export.call_args_list] + total_exported = sum(len(batch) for batch in exported_batches) - # We pushed 3 items; ensure they all got exported + # We pushed 3 items; ensure they all got exported across 2 batches (sizes 2 and 1) assert total_exported == 3 + assert [len(batch) for batch in exported_batches] == [2, 1] processor.shutdown() From 5804bd039f2c92c9b93b3b8e8037c9e8ef9906eb Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Tue, 28 Jul 2026 04:27:04 +0530 Subject: [PATCH 038/473] fix(run): cancel the parallel input-guardrail task when the model turn fails (#3982) --- src/agents/run.py | 28 +++++++++--- tests/test_guardrails.py | 98 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index bb07bd2554..d0e3f9ee56 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1266,14 +1266,17 @@ def _finalize_result(result: RunResult) -> RunResult: ) if parallel_guardrails: + guardrail_task = asyncio.create_task( + run_input_guardrails( + starting_agent, + parallel_guardrails, + copy_input_items(original_input), + context_wrapper, + ) + ) try: parallel_results, turn_result = await asyncio.gather( - run_input_guardrails( - starting_agent, - parallel_guardrails, - copy_input_items(original_input), - context_wrapper, - ), + guardrail_task, model_task, ) except InputGuardrailTripwireTriggered: @@ -1292,6 +1295,19 @@ def _finalize_result(result: RunResult) -> RunResult: ) ) raise + except BaseException: + # A non-tripwire failure (the model turn raising, or a + # guardrail raising a non-tripwire error) propagates from + # gather without cancelling the sibling task. Cancel and drain + # whichever side is still pending so it is not left running + # after the run has failed and its exception is not swallowed. + for pending_task in (guardrail_task, model_task): + if not pending_task.done(): + pending_task.cancel() + await asyncio.gather( + guardrail_task, model_task, return_exceptions=True + ) + raise else: turn_result = await model_task diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index 511c342ca5..08e57e67b1 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -627,6 +627,104 @@ async def slow_get_response(*args, **kwargs): assert model_cancelled.is_set() is False +@pytest.mark.asyncio +async def test_model_error_cancels_parallel_input_guardrail_task(): + """A non-tripwire model failure must cancel the still-running guardrail task. + + Without cancellation the guardrail task is orphaned and keeps running after + ``Runner.run`` has already raised. + """ + guardrail_started = asyncio.Event() + guardrail_cancelled = asyncio.Event() + guardrail_finished = asyncio.Event() + + @input_guardrail(run_in_parallel=True) + async def slow_parallel_check( + ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + guardrail_started.set() + try: + await asyncio.sleep(LONG_DELAY) + guardrail_finished.set() + return GuardrailFunctionOutput( + output_info="parallel_ok", + tripwire_triggered=False, + ) + except asyncio.CancelledError: + guardrail_cancelled.set() + raise + + model = FakeModel() + + async def boom_get_response(*args, **kwargs): + # Only blow up once the guardrail is genuinely mid-flight. + await asyncio.wait_for(guardrail_started.wait(), timeout=1) + raise RuntimeError("model boom") + + agent = Agent( + name="model_error_agent", + input_guardrails=[slow_parallel_check], + model=model, + ) + + with patch.object(model, "get_response", side_effect=boom_get_response): + with pytest.raises(RuntimeError, match="model boom"): + await Runner.run(agent, "trigger guardrail") + + # By the time Runner.run returns, the guardrail task must already be + # cancelled rather than left running to completion in the background. + assert guardrail_started.is_set() is True + assert guardrail_cancelled.is_set() is True + assert guardrail_finished.is_set() is False + + +@pytest.mark.asyncio +async def test_parallel_guardrail_non_tripwire_error_not_swallowed(): + """A non-tripwire error raised inside a parallel guardrail must propagate. + + It should also cancel the in-flight model task rather than leave it running. + """ + model_started = asyncio.Event() + model_cancelled = asyncio.Event() + model_finished = asyncio.Event() + + @input_guardrail(run_in_parallel=True) + async def raising_parallel_check( + ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + await asyncio.wait_for(model_started.wait(), timeout=1) + raise ValueError("guardrail boom") + + model = FakeModel() + original_get_response = model.get_response + + async def slow_get_response(*args, **kwargs): + model_started.set() + try: + await asyncio.sleep(LONG_DELAY) + return await original_get_response(*args, **kwargs) + except asyncio.CancelledError: + model_cancelled.set() + raise + finally: + model_finished.set() + + agent = Agent( + name="guardrail_error_agent", + input_guardrails=[raising_parallel_check], + model=model, + ) + model.set_next_output([get_text_message("should_not_finish")]) + + with patch.object(model, "get_response", side_effect=slow_get_response): + with pytest.raises(ValueError, match="guardrail boom"): + await Runner.run(agent, "trigger guardrail") + + await asyncio.wait_for(model_finished.wait(), timeout=1) + assert model_started.is_set() is True + assert model_cancelled.is_set() is True + + @pytest.mark.asyncio async def test_parallel_guardrail_may_not_prevent_tool_execution_streaming(): tool_was_executed = False From a6ce52d255bf853c82df5ae19ff1482113e3128b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Jul 2026 07:57:19 +0900 Subject: [PATCH 039/473] docs: use decorators module throughout examples (#3946) --- AGENTS.md | 1 + .../agent_patterns/agents_as_tools_conditional.py | 4 ++-- .../agent_patterns/agents_as_tools_streaming.py | 11 +++++++++-- examples/agent_patterns/forcing_tool_use.py | 4 ++-- examples/agent_patterns/hosted_multi_agent_beta.py | 8 ++++++-- examples/agent_patterns/human_in_the_loop.py | 11 ++++++++--- .../human_in_the_loop_custom_rejection.py | 4 ++-- examples/agent_patterns/human_in_the_loop_stream.py | 10 +++++++--- examples/agent_patterns/input_guardrails.py | 2 +- examples/agent_patterns/output_guardrails.py | 2 +- examples/basic/agent_lifecycle_example.py | 6 +++--- examples/basic/image_tool_output.py | 10 ++++++++-- examples/basic/lifecycle_example.py | 6 +++--- examples/basic/stream_function_call_args.py | 10 +++++++--- examples/basic/stream_items.py | 9 +++++++-- examples/basic/stream_ws.py | 6 +++--- examples/basic/tool_guardrails.py | 10 ++++++---- examples/basic/tools.py | 8 ++++++-- examples/basic/usage_tracking.py | 9 +++++++-- examples/customer_service/main.py | 8 +++----- examples/handoffs/message_filter.py | 11 +++++++++-- examples/handoffs/message_filter_streaming.py | 11 +++++++++-- examples/memory/advanced_sqlite_session_example.py | 8 ++++++-- examples/memory/file_hitl_example.py | 8 ++++++-- examples/memory/hitl_session_scenario.py | 13 ++++++++++--- examples/memory/memory_session_hitl_example.py | 9 +++++++-- examples/memory/openai_session_hitl_example.py | 9 +++++++-- examples/model_providers/any_llm_auto.py | 10 ++++++++-- examples/model_providers/any_llm_provider.py | 9 +++++++-- examples/model_providers/custom_example_agent.py | 10 ++++++++-- examples/model_providers/custom_example_global.py | 4 ++-- examples/model_providers/custom_example_provider.py | 4 ++-- examples/model_providers/litellm_auto.py | 10 ++++++++-- examples/model_providers/litellm_provider.py | 9 +++++++-- examples/realtime/app/agent.py | 10 ++++------ examples/realtime/cli/demo.py | 4 ++-- examples/realtime/twilio/twilio_handler.py | 6 +++--- examples/realtime/twilio_sip/agents.py | 8 +++----- .../daytona/usaspending_text2sql/sql_capability.py | 4 ++-- examples/sandbox/extensions/runloop/capabilities.py | 11 ++++++++--- examples/sandbox/healthcare_support/tools.py | 11 ++++++----- examples/sandbox/sandbox_agent_with_tools.py | 5 +++-- examples/sandbox/sandbox_agents_as_tools.py | 9 +++++++-- examples/tools/programmatic_tool_calling.py | 8 ++++---- examples/tools/tool_search.py | 12 ++++++------ examples/voice/static/main.py | 5 +++-- examples/voice/streamed/my_workflow.py | 9 +++++++-- 47 files changed, 246 insertions(+), 120 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b88af1898..d84898011c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ Treat the parameter and dataclass field order of exported runtime APIs as a comp - Documentation is published to the live site, so coordinate SDK behavior changes and docs carefully. If docs describe behavior that is not released yet, either delay the docs change until the SDK release is available or split it into a follow-up PR. - Treat runnable docs snippets as API compatibility checks. Before adding OpenAI API, provider, Responses, Realtime, WebSocket, or SDK constructor examples, verify the shown arguments and call shape against the actual implementation. +- When adding or updating code in `examples/` or runnable `docs/` snippets, import Agents SDK decorators from `agents.decorators`. Prefer `tool` over `function_tool`; keep non-decorator SDK imports on their existing public import paths. - Do not let untrusted sandbox manifests opt themselves out of host filesystem or base-directory boundaries. Escape hatches for local source materialization must be controlled by trusted application code at the call site, not by serialized manifest data. - When documenting sandbox or security grants, verify the actual implementation path enforces the grant or boundary. Do not claim a grant applies to `LocalDir`, `LocalFile`, archive extraction, or other materialization paths unless those paths actually consult it. - When redacting OpenAI tool, MCP, model, or provider payloads, consider traceback display, exception chaining, `__context__`, logs, and telemetry. Suppressing display with `raise ... from None` is not enough if the original exception object still carries sensitive input data. diff --git a/examples/agent_patterns/agents_as_tools_conditional.py b/examples/agent_patterns/agents_as_tools_conditional.py index 526b508a67..1d7fc43d7e 100644 --- a/examples/agent_patterns/agents_as_tools_conditional.py +++ b/examples/agent_patterns/agents_as_tools_conditional.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from agents import Agent, AgentBase, ModelSettings, RunContextWrapper, Runner, trace -from agents.tool import function_tool +from agents.decorators import tool from examples.auto_mode import confirm_with_fallback, input_with_fallback, is_auto_mode """ @@ -27,7 +27,7 @@ def european_enabled(ctx: RunContextWrapper[AppContext], agent: AgentBase) -> bo return ctx.context.language_preference == "european" -@function_tool(needs_approval=True) +@tool(needs_approval=True) async def get_user_name() -> str: print("Getting the user's name...") return "Kaz" diff --git a/examples/agent_patterns/agents_as_tools_streaming.py b/examples/agent_patterns/agents_as_tools_streaming.py index 2eeda99897..d8fe6995a8 100644 --- a/examples/agent_patterns/agents_as_tools_streaming.py +++ b/examples/agent_patterns/agents_as_tools_streaming.py @@ -1,9 +1,16 @@ import asyncio -from agents import Agent, AgentToolStreamEvent, ModelSettings, Runner, function_tool, trace +from agents import ( + Agent, + AgentToolStreamEvent, + ModelSettings, + Runner, + trace, +) +from agents.decorators import tool -@function_tool( +@tool( name_override="billing_status_checker", description_override="Answer questions about customer billing status.", ) diff --git a/examples/agent_patterns/forcing_tool_use.py b/examples/agent_patterns/forcing_tool_use.py index 576b37d826..2c8c27cb8a 100644 --- a/examples/agent_patterns/forcing_tool_use.py +++ b/examples/agent_patterns/forcing_tool_use.py @@ -13,8 +13,8 @@ Runner, ToolsToFinalOutputFunction, ToolsToFinalOutputResult, - function_tool, ) +from agents.decorators import tool from examples.auto_mode import is_auto_mode """ @@ -43,7 +43,7 @@ class Weather(BaseModel): conditions: str -@function_tool +@tool def get_weather(city: str) -> Weather: print("[debug] get_weather called") return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind") diff --git a/examples/agent_patterns/hosted_multi_agent_beta.py b/examples/agent_patterns/hosted_multi_agent_beta.py index 07c19faca8..04ebf40c0b 100644 --- a/examples/agent_patterns/hosted_multi_agent_beta.py +++ b/examples/agent_patterns/hosted_multi_agent_beta.py @@ -6,7 +6,11 @@ from collections.abc import Mapping from typing import Any -from agents import Agent, Runner, function_tool +from agents import ( + Agent, + Runner, +) +from agents.decorators import tool from agents.extensions.experimental.hosted_multi_agent import ( OpenAIHostedMultiAgentModel, get_hosted_agent_metadata, @@ -19,7 +23,7 @@ } -@function_tool +@tool def get_proposal(ctx: ToolContext[Any], proposal: str) -> dict[str, object]: """Return deterministic details for one proposal.""" metadata = get_hosted_agent_metadata(ctx) diff --git a/examples/agent_patterns/human_in_the_loop.py b/examples/agent_patterns/human_in_the_loop.py index e95cb145c6..d438b772b5 100644 --- a/examples/agent_patterns/human_in_the_loop.py +++ b/examples/agent_patterns/human_in_the_loop.py @@ -11,11 +11,16 @@ import json from pathlib import Path -from agents import Agent, Runner, RunState, function_tool +from agents import ( + Agent, + Runner, + RunState, +) +from agents.decorators import tool from examples.auto_mode import confirm_with_fallback -@function_tool +@tool async def get_weather(city: str) -> str: """Get the weather for a given city. @@ -33,7 +38,7 @@ async def _needs_temperature_approval(_ctx, params, _call_id) -> bool: return "Oakland" in params.get("city", "") -@function_tool( +@tool( # Dynamic approval: only require approval for Oakland needs_approval=_needs_temperature_approval ) diff --git a/examples/agent_patterns/human_in_the_loop_custom_rejection.py b/examples/agent_patterns/human_in_the_loop_custom_rejection.py index 3f54a7f5c0..597f695427 100644 --- a/examples/agent_patterns/human_in_the_loop_custom_rejection.py +++ b/examples/agent_patterns/human_in_the_loop_custom_rejection.py @@ -16,8 +16,8 @@ RunConfig, Runner, ToolErrorFormatterArgs, - function_tool, ) +from agents.decorators import tool from examples.auto_mode import confirm_with_fallback @@ -29,7 +29,7 @@ async def tool_error_formatter(args: ToolErrorFormatterArgs[None]) -> str | None return "Publish action was canceled because approval was rejected." -@function_tool(needs_approval=True) +@tool(needs_approval=True) async def publish_announcement(title: str, body: str) -> str: """Simulate publishing an announcement to users.""" return f"Published announcement '{title}' with body: {body}" diff --git a/examples/agent_patterns/human_in_the_loop_stream.py b/examples/agent_patterns/human_in_the_loop_stream.py index 16d8b30d67..e56083b511 100644 --- a/examples/agent_patterns/human_in_the_loop_stream.py +++ b/examples/agent_patterns/human_in_the_loop_stream.py @@ -10,7 +10,11 @@ import asyncio -from agents import Agent, Runner, function_tool +from agents import ( + Agent, + Runner, +) +from agents.decorators import tool from examples.auto_mode import confirm_with_fallback @@ -19,7 +23,7 @@ async def _needs_temperature_approval(_ctx, params, _call_id) -> bool: return "Oakland" in params.get("city", "") -@function_tool( +@tool( # Dynamic approval: only require approval for Oakland needs_approval=_needs_temperature_approval ) @@ -35,7 +39,7 @@ async def get_temperature(city: str) -> str: return f"The temperature in {city} is 20° Celsius" -@function_tool +@tool async def get_weather(city: str) -> str: """Get the weather for a given city. diff --git a/examples/agent_patterns/input_guardrails.py b/examples/agent_patterns/input_guardrails.py index d3af80f7a6..92a934dc66 100644 --- a/examples/agent_patterns/input_guardrails.py +++ b/examples/agent_patterns/input_guardrails.py @@ -11,8 +11,8 @@ RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail from examples.auto_mode import input_with_fallback, is_auto_mode """ diff --git a/examples/agent_patterns/output_guardrails.py b/examples/agent_patterns/output_guardrails.py index 526a08521d..e18671baba 100644 --- a/examples/agent_patterns/output_guardrails.py +++ b/examples/agent_patterns/output_guardrails.py @@ -11,8 +11,8 @@ OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail """ This example shows how to use output guardrails. diff --git a/examples/basic/agent_lifecycle_example.py b/examples/basic/agent_lifecycle_example.py index 260cb39252..e8ff3cef5e 100644 --- a/examples/basic/agent_lifecycle_example.py +++ b/examples/basic/agent_lifecycle_example.py @@ -11,8 +11,8 @@ RunContextWrapper, Runner, Tool, - function_tool, ) +from agents.decorators import tool from examples.auto_mode import input_with_fallback, is_auto_mode @@ -62,7 +62,7 @@ async def on_tool_end( ### -@function_tool +@tool def random_number(max: int) -> int: """ Generate a random number from 0 to max (inclusive). @@ -79,7 +79,7 @@ def random_number(max: int) -> int: return random.randint(0, max) -@function_tool +@tool def multiply_by_two(x: int) -> int: """Simple multiplication by two.""" return x * 2 diff --git a/examples/basic/image_tool_output.py b/examples/basic/image_tool_output.py index 460ac1fe11..f091bb21a4 100644 --- a/examples/basic/image_tool_output.py +++ b/examples/basic/image_tool_output.py @@ -1,13 +1,19 @@ import asyncio -from agents import Agent, Runner, ToolOutputImage, ToolOutputImageDict, function_tool +from agents import ( + Agent, + Runner, + ToolOutputImage, + ToolOutputImageDict, +) +from agents.decorators import tool return_typed_dict = True URL = "https://images.unsplash.com/photo-1505761671935-60b3a7427bad?auto=format&fit=crop&w=400&q=80" -@function_tool +@tool def fetch_random_image() -> ToolOutputImage | ToolOutputImageDict: """Fetch a random image.""" diff --git a/examples/basic/lifecycle_example.py b/examples/basic/lifecycle_example.py index 744fd86462..ee97ec50ca 100644 --- a/examples/basic/lifecycle_example.py +++ b/examples/basic/lifecycle_example.py @@ -13,8 +13,8 @@ Runner, Tool, Usage, - function_tool, ) +from agents.decorators import tool from agents.items import ModelResponse, TResponseInputItem from agents.tool_context import ToolContext from examples.auto_mode import input_with_fallback @@ -112,13 +112,13 @@ async def on_handoff( ### -@function_tool +@tool def random_number(max: int) -> int: """Generate a random number from 0 to max (inclusive).""" return random.randint(0, max) -@function_tool +@tool def multiply_by_two(x: int) -> int: """Return x times two.""" return x * 2 diff --git a/examples/basic/stream_function_call_args.py b/examples/basic/stream_function_call_args.py index 969c4ed4e9..a59c86fea4 100644 --- a/examples/basic/stream_function_call_args.py +++ b/examples/basic/stream_function_call_args.py @@ -3,16 +3,20 @@ from openai.types.responses import ResponseFunctionCallArgumentsDeltaEvent -from agents import Agent, Runner, function_tool +from agents import ( + Agent, + Runner, +) +from agents.decorators import tool -@function_tool +@tool def write_file(filename: Annotated[str, "Name of the file"], content: str) -> str: """Write content to a file.""" return f"File {filename} written successfully" -@function_tool +@tool def create_config( project_name: Annotated[str, "Project name"], version: Annotated[str, "Project version"], diff --git a/examples/basic/stream_items.py b/examples/basic/stream_items.py index bf8a1e2bbf..0d7e4a43c1 100644 --- a/examples/basic/stream_items.py +++ b/examples/basic/stream_items.py @@ -1,10 +1,15 @@ import asyncio import random -from agents import Agent, ItemHelpers, Runner, function_tool +from agents import ( + Agent, + ItemHelpers, + Runner, +) +from agents.decorators import tool -@function_tool +@tool def how_many_jokes() -> int: """Return a random integer of jokes to tell between 1 and 10 (inclusive).""" return random.randint(1, 10) diff --git a/examples/basic/stream_ws.py b/examples/basic/stream_ws.py index a2d795b488..902a76e07b 100644 --- a/examples/basic/stream_ws.py +++ b/examples/basic/stream_ws.py @@ -28,14 +28,14 @@ Agent, ModelSettings, ResponsesWebSocketSession, - function_tool, responses_websocket_session, trace, ) +from agents.decorators import tool from examples.auto_mode import confirm_with_fallback -@function_tool +@tool def lookup_order(order_id: str) -> dict[str, Any]: """Return deterministic order data for the demo.""" orders = { @@ -69,7 +69,7 @@ def lookup_order(order_id: str) -> dict[str, Any]: ) -@function_tool(needs_approval=True) +@tool(needs_approval=True) def submit_refund(order_id: str, amount: float, reason: str) -> dict[str, Any]: """Create a refund request. This tool requires approval.""" ticket = "RF-1001" if order_id == "ORD-1001" else f"RF-{order_id[-4:]}" diff --git a/examples/basic/tool_guardrails.py b/examples/basic/tool_guardrails.py index 4e9949473c..4669401537 100644 --- a/examples/basic/tool_guardrails.py +++ b/examples/basic/tool_guardrails.py @@ -8,19 +8,21 @@ ToolInputGuardrailData, ToolOutputGuardrailData, ToolOutputGuardrailTripwireTriggered, - function_tool, +) +from agents.decorators import ( + tool, tool_input_guardrail, tool_output_guardrail, ) -@function_tool +@tool def send_email(to: str, subject: str, body: str) -> str: """Send an email to the specified recipient.""" return f"Email sent to {to} with subject '{subject}'" -@function_tool +@tool def get_user_data(user_id: str) -> dict[str, str]: """Get user data by ID.""" # Simulate returning sensitive data @@ -33,7 +35,7 @@ def get_user_data(user_id: str) -> dict[str, str]: } -@function_tool +@tool def get_contact_info(user_id: str) -> dict[str, str]: """Get contact info by ID.""" return { diff --git a/examples/basic/tools.py b/examples/basic/tools.py index 2052d9427d..3a465bb705 100644 --- a/examples/basic/tools.py +++ b/examples/basic/tools.py @@ -3,7 +3,11 @@ from pydantic import BaseModel, Field -from agents import Agent, Runner, function_tool +from agents import ( + Agent, + Runner, +) +from agents.decorators import tool class Weather(BaseModel): @@ -12,7 +16,7 @@ class Weather(BaseModel): conditions: str = Field(description="The weather conditions") -@function_tool +@tool def get_weather(city: Annotated[str, "The city to get the weather for"]) -> Weather: """Get the current weather information for a specified city.""" print("[debug] get_weather called") diff --git a/examples/basic/usage_tracking.py b/examples/basic/usage_tracking.py index a5154d6e76..1425124e83 100644 --- a/examples/basic/usage_tracking.py +++ b/examples/basic/usage_tracking.py @@ -2,7 +2,12 @@ from pydantic import BaseModel -from agents import Agent, Runner, Usage, function_tool +from agents import ( + Agent, + Runner, + Usage, +) +from agents.decorators import tool class Weather(BaseModel): @@ -11,7 +16,7 @@ class Weather(BaseModel): conditions: str -@function_tool +@tool def get_weather(city: str) -> Weather: """Get the current weather information for a specified city.""" return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") diff --git a/examples/customer_service/main.py b/examples/customer_service/main.py index 13055a1527..1da067a3a7 100644 --- a/examples/customer_service/main.py +++ b/examples/customer_service/main.py @@ -16,10 +16,10 @@ ToolCallItem, ToolCallOutputItem, TResponseInputItem, - function_tool, handoff, trace, ) +from agents.decorators import tool from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX from examples.auto_mode import input_with_fallback, is_auto_mode @@ -36,9 +36,7 @@ class AirlineAgentContext(BaseModel): ### TOOLS -@function_tool( - name_override="faq_lookup_tool", description_override="Lookup frequently asked questions." -) +@tool(name_override="faq_lookup_tool", description_override="Lookup frequently asked questions.") async def faq_lookup_tool(question: str) -> str: question_lower = question.lower() if any( @@ -64,7 +62,7 @@ async def faq_lookup_tool(question: str) -> str: return "I'm sorry, I don't know the answer to that question." -@function_tool +@tool async def update_seat( context: RunContextWrapper[AirlineAgentContext], confirmation_number: str, new_seat: str ) -> str: diff --git a/examples/handoffs/message_filter.py b/examples/handoffs/message_filter.py index 20460d3ac0..ce519cf913 100644 --- a/examples/handoffs/message_filter.py +++ b/examples/handoffs/message_filter.py @@ -3,12 +3,19 @@ import json import random -from agents import Agent, HandoffInputData, Runner, function_tool, handoff, trace +from agents import ( + Agent, + HandoffInputData, + Runner, + handoff, + trace, +) +from agents.decorators import tool from agents.extensions import handoff_filters from agents.models import is_gpt_5_default -@function_tool +@tool def random_number_tool(max: int) -> int: """Return a random integer between 0 and the given maximum.""" return random.randint(0, max) diff --git a/examples/handoffs/message_filter_streaming.py b/examples/handoffs/message_filter_streaming.py index 604c5d1d60..4652d61574 100644 --- a/examples/handoffs/message_filter_streaming.py +++ b/examples/handoffs/message_filter_streaming.py @@ -3,12 +3,19 @@ import json import random -from agents import Agent, HandoffInputData, Runner, function_tool, handoff, trace +from agents import ( + Agent, + HandoffInputData, + Runner, + handoff, + trace, +) +from agents.decorators import tool from agents.extensions import handoff_filters from agents.models import is_gpt_5_default -@function_tool +@tool def random_number_tool(max: int) -> int: """Return a random integer between 0 and the given maximum.""" return random.randint(0, max) diff --git a/examples/memory/advanced_sqlite_session_example.py b/examples/memory/advanced_sqlite_session_example.py index 492fb06afd..89e2066e77 100644 --- a/examples/memory/advanced_sqlite_session_example.py +++ b/examples/memory/advanced_sqlite_session_example.py @@ -8,11 +8,15 @@ import asyncio -from agents import Agent, Runner, function_tool +from agents import ( + Agent, + Runner, +) +from agents.decorators import tool from agents.extensions.memory import AdvancedSQLiteSession -@function_tool +@tool async def get_weather(city: str) -> str: if city.strip().lower() == "new york": return f"The weather in {city} is cloudy." diff --git a/examples/memory/file_hitl_example.py b/examples/memory/file_hitl_example.py index eb68c62d9d..26d4365a1e 100644 --- a/examples/memory/file_hitl_example.py +++ b/examples/memory/file_hitl_example.py @@ -11,7 +11,11 @@ import json from typing import Any -from agents import Agent, Runner, function_tool +from agents import ( + Agent, + Runner, +) +from agents.decorators import tool from agents.run_context import RunContextWrapper from agents.run_state import RunState from examples.auto_mode import confirm_with_fallback, input_with_fallback, is_auto_mode @@ -121,7 +125,7 @@ def create_lookup_customer_profile_tool( directory: dict[str, str], missing_customer_message: str = "No customer found for that id.", ): - @function_tool( + @tool( name_override="lookup_customer_profile", description_override="Look up stored profile details for a customer by their internal id.", needs_approval=True, diff --git a/examples/memory/hitl_session_scenario.py b/examples/memory/hitl_session_scenario.py index e53f8a580c..2ff5bf47f9 100644 --- a/examples/memory/hitl_session_scenario.py +++ b/examples/memory/hitl_session_scenario.py @@ -15,7 +15,14 @@ from openai.types.shared import Reasoning -from agents import Agent, Model, ModelSettings, OpenAIConversationsSession, Runner, function_tool +from agents import ( + Agent, + Model, + ModelSettings, + OpenAIConversationsSession, + Runner, +) +from agents.decorators import tool from agents.items import TResponseInputItem from .file_session import FileSession @@ -38,7 +45,7 @@ def tool_output_for(name: str, message: str) -> str: raise ValueError(f"Unknown tool name: {name}") -@function_tool( +@tool( name_override=TOOL_ECHO, description_override="Echoes back the provided query after approval.", needs_approval=True, @@ -48,7 +55,7 @@ def approval_echo(query: str) -> str: return tool_output_for(TOOL_ECHO, query) -@function_tool( +@tool( name_override=TOOL_NOTE, description_override="Records the provided query after approval.", needs_approval=True, diff --git a/examples/memory/memory_session_hitl_example.py b/examples/memory/memory_session_hitl_example.py index 73d7e3ae03..5bc5ab0397 100644 --- a/examples/memory/memory_session_hitl_example.py +++ b/examples/memory/memory_session_hitl_example.py @@ -8,7 +8,12 @@ import asyncio -from agents import Agent, Runner, SQLiteSession, function_tool +from agents import ( + Agent, + Runner, + SQLiteSession, +) +from agents.decorators import tool from examples.auto_mode import confirm_with_fallback, input_with_fallback, is_auto_mode @@ -17,7 +22,7 @@ async def _needs_approval(_ctx, _params, _call_id) -> bool: return True -@function_tool(needs_approval=_needs_approval) +@tool(needs_approval=_needs_approval) def get_weather(location: str) -> str: """Get weather for a location. diff --git a/examples/memory/openai_session_hitl_example.py b/examples/memory/openai_session_hitl_example.py index 8024e30f66..86a4e1c885 100644 --- a/examples/memory/openai_session_hitl_example.py +++ b/examples/memory/openai_session_hitl_example.py @@ -8,7 +8,12 @@ import asyncio -from agents import Agent, OpenAIConversationsSession, Runner, function_tool +from agents import ( + Agent, + OpenAIConversationsSession, + Runner, +) +from agents.decorators import tool from examples.auto_mode import confirm_with_fallback, input_with_fallback, is_auto_mode @@ -17,7 +22,7 @@ async def _needs_approval(_ctx, _params, _call_id) -> bool: return True -@function_tool(needs_approval=_needs_approval) +@tool(needs_approval=_needs_approval) def get_weather(location: str) -> str: """Get weather for a location. diff --git a/examples/model_providers/any_llm_auto.py b/examples/model_providers/any_llm_auto.py index 3a6bc8ba76..e328705128 100644 --- a/examples/model_providers/any_llm_auto.py +++ b/examples/model_providers/any_llm_auto.py @@ -4,7 +4,13 @@ from pydantic import BaseModel -from agents import Agent, ModelSettings, Runner, function_tool, set_tracing_disabled +from agents import ( + Agent, + ModelSettings, + Runner, + set_tracing_disabled, +) +from agents.decorators import tool """This example uses the built-in any-llm routing through OpenRouter. @@ -14,7 +20,7 @@ set_tracing_disabled(disabled=True) -@function_tool +@tool def get_weather(city: str): print(f"[debug] getting weather for {city}") return f"The weather in {city} is sunny." diff --git a/examples/model_providers/any_llm_provider.py b/examples/model_providers/any_llm_provider.py index 931efb11d6..8b31d509f6 100644 --- a/examples/model_providers/any_llm_provider.py +++ b/examples/model_providers/any_llm_provider.py @@ -3,7 +3,12 @@ import asyncio import os -from agents import Agent, Runner, function_tool, set_tracing_disabled +from agents import ( + Agent, + Runner, + set_tracing_disabled, +) +from agents.decorators import tool from agents.extensions.models.any_llm_model import AnyLLMModel """This example uses the AnyLLMModel directly. @@ -17,7 +22,7 @@ set_tracing_disabled(disabled=True) -@function_tool +@tool def get_weather(city: str): print(f"[debug] getting weather for {city}") return f"The weather in {city} is sunny." diff --git a/examples/model_providers/custom_example_agent.py b/examples/model_providers/custom_example_agent.py index f10865c4d5..d65ac1e674 100644 --- a/examples/model_providers/custom_example_agent.py +++ b/examples/model_providers/custom_example_agent.py @@ -3,7 +3,13 @@ from openai import AsyncOpenAI -from agents import Agent, OpenAIChatCompletionsModel, Runner, function_tool, set_tracing_disabled +from agents import ( + Agent, + OpenAIChatCompletionsModel, + Runner, + set_tracing_disabled, +) +from agents.decorators import tool BASE_URL = os.getenv("EXAMPLE_BASE_URL") or "" API_KEY = os.getenv("EXAMPLE_API_KEY") or "" @@ -32,7 +38,7 @@ # Runner.run(agent, ..., run_config=RunConfig(model_provider=PROVIDER)) -@function_tool +@tool def get_weather(city: str): print(f"[debug] getting weather for {city}") return f"The weather in {city} is sunny." diff --git a/examples/model_providers/custom_example_global.py b/examples/model_providers/custom_example_global.py index ae9756d37a..a1dc842418 100644 --- a/examples/model_providers/custom_example_global.py +++ b/examples/model_providers/custom_example_global.py @@ -6,11 +6,11 @@ from agents import ( Agent, Runner, - function_tool, set_default_openai_api, set_default_openai_client, set_tracing_disabled, ) +from agents.decorators import tool BASE_URL = os.getenv("EXAMPLE_BASE_URL") or "" API_KEY = os.getenv("EXAMPLE_API_KEY") or "" @@ -41,7 +41,7 @@ set_tracing_disabled(disabled=True) -@function_tool +@tool def get_weather(city: str): print(f"[debug] getting weather for {city}") return f"The weather in {city} is sunny." diff --git a/examples/model_providers/custom_example_provider.py b/examples/model_providers/custom_example_provider.py index 4e59019864..cbd30954b3 100644 --- a/examples/model_providers/custom_example_provider.py +++ b/examples/model_providers/custom_example_provider.py @@ -12,9 +12,9 @@ OpenAIChatCompletionsModel, RunConfig, Runner, - function_tool, set_tracing_disabled, ) +from agents.decorators import tool BASE_URL = os.getenv("EXAMPLE_BASE_URL") or "" API_KEY = os.getenv("EXAMPLE_API_KEY") or "" @@ -48,7 +48,7 @@ def get_model(self, model_name: str | None) -> Model: CUSTOM_MODEL_PROVIDER = CustomModelProvider() -@function_tool +@tool def get_weather(city: str): print(f"[debug] getting weather for {city}") return f"The weather in {city} is sunny." diff --git a/examples/model_providers/litellm_auto.py b/examples/model_providers/litellm_auto.py index 3b30a3ecb9..9e40a338d1 100644 --- a/examples/model_providers/litellm_auto.py +++ b/examples/model_providers/litellm_auto.py @@ -4,7 +4,13 @@ from pydantic import BaseModel -from agents import Agent, ModelSettings, Runner, function_tool, set_tracing_disabled +from agents import ( + Agent, + ModelSettings, + Runner, + set_tracing_disabled, +) +from agents.decorators import tool """This example uses the built-in support for LiteLLM through OpenRouter. @@ -17,7 +23,7 @@ # logging.basicConfig(level=logging.DEBUG) -@function_tool +@tool def get_weather(city: str): print(f"[debug] getting weather for {city}") return f"The weather in {city} is sunny." diff --git a/examples/model_providers/litellm_provider.py b/examples/model_providers/litellm_provider.py index d9e7db7734..8c93484436 100644 --- a/examples/model_providers/litellm_provider.py +++ b/examples/model_providers/litellm_provider.py @@ -3,7 +3,12 @@ import asyncio import os -from agents import Agent, Runner, function_tool, set_tracing_disabled +from agents import ( + Agent, + Runner, + set_tracing_disabled, +) +from agents.decorators import tool from agents.extensions.models.litellm_model import LitellmModel """This example uses the LitellmModel directly, to hit any model provider. @@ -18,7 +23,7 @@ set_tracing_disabled(disabled=True) -@function_tool +@tool def get_weather(city: str): print(f"[debug] getting weather for {city}") return f"The weather in {city} is sunny." diff --git a/examples/realtime/app/agent.py b/examples/realtime/app/agent.py index e83564e279..e470fbdc1c 100644 --- a/examples/realtime/app/agent.py +++ b/examples/realtime/app/agent.py @@ -1,6 +1,6 @@ import asyncio -from agents import function_tool +from agents.decorators import tool from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX from agents.realtime import RealtimeAgent, realtime_handoff @@ -11,9 +11,7 @@ ### TOOLS -@function_tool( - name_override="faq_lookup_tool", description_override="Lookup frequently asked questions." -) +@tool(name_override="faq_lookup_tool", description_override="Lookup frequently asked questions.") async def faq_lookup_tool(question: str) -> str: # Simulate a slow API call await asyncio.sleep(3) @@ -36,7 +34,7 @@ async def faq_lookup_tool(question: str) -> str: return "I'm sorry, I don't know the answer to that question." -@function_tool(needs_approval=True) +@tool(needs_approval=True) async def update_seat(confirmation_number: str, new_seat: str) -> str: """ Update the seat for a given confirmation number. @@ -48,7 +46,7 @@ async def update_seat(confirmation_number: str, new_seat: str) -> str: return f"Updated seat to {new_seat} for confirmation number {confirmation_number}" -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather in a city.""" return f"The weather in {city} is sunny." diff --git a/examples/realtime/cli/demo.py b/examples/realtime/cli/demo.py index 4a55df4f8c..e0eeccb7c8 100644 --- a/examples/realtime/cli/demo.py +++ b/examples/realtime/cli/demo.py @@ -7,7 +7,7 @@ import numpy as np import sounddevice as sd -from agents import function_tool +from agents.decorators import tool from agents.realtime import ( RealtimeAgent, RealtimePlaybackTracker, @@ -34,7 +34,7 @@ # logger.logger.setLevel(logging.ERROR) -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather in a city.""" return f"The weather in {city} is sunny." diff --git a/examples/realtime/twilio/twilio_handler.py b/examples/realtime/twilio/twilio_handler.py index 727d9fa700..8e6938ba2a 100644 --- a/examples/realtime/twilio/twilio_handler.py +++ b/examples/realtime/twilio/twilio_handler.py @@ -10,7 +10,7 @@ from fastapi import WebSocket -from agents import function_tool +from agents.decorators import tool from agents.realtime import ( RealtimeAgent, RealtimePlaybackTracker, @@ -20,13 +20,13 @@ ) -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather in a city.""" return f"The weather in {city} is sunny." -@function_tool +@tool def get_current_time() -> str: """Get the current time.""" return f"The current time is {datetime.now().strftime('%H:%M:%S')}" diff --git a/examples/realtime/twilio_sip/agents.py b/examples/realtime/twilio_sip/agents.py index 1afb3eb449..5e36decf2a 100644 --- a/examples/realtime/twilio_sip/agents.py +++ b/examples/realtime/twilio_sip/agents.py @@ -4,7 +4,7 @@ import asyncio -from agents import function_tool +from agents.decorators import tool from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX from agents.realtime import RealtimeAgent, realtime_handoff @@ -14,9 +14,7 @@ WELCOME_MESSAGE = "Hello, this is ABC customer service. How can I help you today?" -@function_tool( - name_override="faq_lookup_tool", description_override="Lookup frequently asked questions." -) +@tool(name_override="faq_lookup_tool", description_override="Lookup frequently asked questions.") async def faq_lookup_tool(question: str) -> str: """Fetch FAQ answers for the caller.""" @@ -32,7 +30,7 @@ async def faq_lookup_tool(question: str) -> str: return "I'm not sure about that. Let me transfer you back to the triage agent." -@function_tool +@tool async def update_customer_record(customer_id: str, note: str) -> str: """Record a short note about the caller.""" diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py b/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py index 2b736197e4..94a2273cf5 100644 --- a/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py +++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py @@ -141,9 +141,9 @@ async def run_sql(query: str, limit: int | None = None) -> str: return output.strip() if output.strip() else "Query returned no results." - from agents.tool import function_tool as _function_tool + from agents.decorators import tool as _tool - return _function_tool(run_sql, name_override="run_sql") + return _tool(run_sql, name_override="run_sql") class SqlCapability(Capability): diff --git a/examples/sandbox/extensions/runloop/capabilities.py b/examples/sandbox/extensions/runloop/capabilities.py index cf3a923588..17d1fff4a9 100644 --- a/examples/sandbox/extensions/runloop/capabilities.py +++ b/examples/sandbox/extensions/runloop/capabilities.py @@ -17,7 +17,12 @@ from openai.types.responses import ResponseTextDeltaEvent from pydantic import BaseModel -from agents import Agent, ModelSettings, Runner, function_tool +from agents import ( + Agent, + ModelSettings, + Runner, +) +from agents.decorators import tool from agents.run import RunConfig from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig @@ -507,7 +512,7 @@ def _build_resource_query_tools( ) -> tuple[list[Any], dict[str, RunloopResourceQueryResult]]: query_results: dict[str, RunloopResourceQueryResult] = {} - @function_tool + @tool async def query_runloop_secret(name: str) -> RunloopResourceQueryResult: """Query whether a Runloop secret exists by name and return non-sensitive metadata.""" @@ -515,7 +520,7 @@ async def query_runloop_secret(name: str) -> RunloopResourceQueryResult: query_results["secret"] = result return result - @function_tool + @tool async def query_runloop_network_policy(name: str) -> RunloopResourceQueryResult: """Query whether a Runloop network policy exists by name and return basic metadata.""" diff --git a/examples/sandbox/healthcare_support/tools.py b/examples/sandbox/healthcare_support/tools.py index 571485e208..ad3657ba29 100644 --- a/examples/sandbox/healthcare_support/tools.py +++ b/examples/sandbox/healthcare_support/tools.py @@ -6,7 +6,8 @@ from dataclasses import dataclass, field from typing import Any -from agents import RunContextWrapper, function_tool +from agents import RunContextWrapper +from agents.decorators import tool from examples.sandbox.healthcare_support.data import HealthcareSupportDataStore from examples.sandbox.healthcare_support.models import ScenarioCase @@ -32,7 +33,7 @@ async def emit(self, event_name: str, **payload: Any) -> None: ) -@function_tool(name_override="patient_info_lookup") +@tool(name_override="patient_info_lookup") def lookup_patient( context: RunContextWrapper[HealthcareSupportContext], patient_id: str | None = None, @@ -47,7 +48,7 @@ def lookup_patient( ) -@function_tool(name_override="insurance_eligibility_lookup") +@tool(name_override="insurance_eligibility_lookup") def lookup_insurance_eligibility( context: RunContextWrapper[HealthcareSupportContext], payer: str | None = None, @@ -62,7 +63,7 @@ def lookup_insurance_eligibility( ) -@function_tool(name_override="appointment_referral_status_lookup") +@tool(name_override="appointment_referral_status_lookup") def lookup_referral_status( context: RunContextWrapper[HealthcareSupportContext], referral_id: str | None = None, @@ -83,7 +84,7 @@ async def _needs_human_approval( return not context.context.human_handoff_approved -@function_tool(name_override="route_to_human_queue", needs_approval=_needs_human_approval) +@tool(name_override="route_to_human_queue", needs_approval=_needs_human_approval) def route_to_human_queue( context: RunContextWrapper[HealthcareSupportContext], queue: str, diff --git a/examples/sandbox/sandbox_agent_with_tools.py b/examples/sandbox/sandbox_agent_with_tools.py index f115488d52..ff4af5fc67 100644 --- a/examples/sandbox/sandbox_agent_with_tools.py +++ b/examples/sandbox/sandbox_agent_with_tools.py @@ -13,7 +13,8 @@ import sys from pathlib import Path -from agents import Runner, function_tool +from agents import Runner +from agents.decorators import tool from agents.mcp import MCPServerStdio from agents.run import RunConfig from agents.sandbox import SandboxAgent, SandboxRunConfig @@ -32,7 +33,7 @@ ) -@function_tool +@tool def get_discount_approval_path(discount_percent: int) -> str: """Return the approver required for a proposed discount percentage.""" if discount_percent <= 10: diff --git a/examples/sandbox/sandbox_agents_as_tools.py b/examples/sandbox/sandbox_agents_as_tools.py index d09f9620fa..65c96d22ea 100644 --- a/examples/sandbox/sandbox_agents_as_tools.py +++ b/examples/sandbox/sandbox_agents_as_tools.py @@ -16,7 +16,12 @@ from openai.types.shared import Reasoning from pydantic import BaseModel, Field -from agents import Agent, ModelSettings, Runner, function_tool +from agents import ( + Agent, + ModelSettings, + Runner, +) +from agents.decorators import tool from agents.run import RunConfig from agents.sandbox import SandboxAgent, SandboxRunConfig from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient @@ -69,7 +74,7 @@ async def _structured_tool_output_extractor(result) -> str: return str(final_output) -@function_tool +@tool def get_discount_approval_rule(discount_percent: int) -> str: """Return the internal approver required for a proposed discount.""" if discount_percent <= 10: diff --git a/examples/tools/programmatic_tool_calling.py b/examples/tools/programmatic_tool_calling.py index 8248970bcd..9e4d8d153f 100644 --- a/examples/tools/programmatic_tool_calling.py +++ b/examples/tools/programmatic_tool_calling.py @@ -11,8 +11,8 @@ ProgrammaticToolCallingTool, Runner, ToolCallItem, - function_tool, ) +from agents.decorators import tool Sku = Literal["desk-lamp", "ergonomic-keyboard", "usb-c-dock"] @@ -50,21 +50,21 @@ class InboundUnitsOutput(BaseModel): inbound_units: int -@function_tool(allowed_callers=["programmatic"]) +@tool(allowed_callers=["programmatic"]) def get_inventory(sku: Sku) -> InventoryOutput: """Return the currently available units for one SKU.""" print(f"[tool] get_inventory({sku})") return InventoryOutput(sku=sku, available_units=inventory[sku]) -@function_tool(allowed_callers=["programmatic"]) +@tool(allowed_callers=["programmatic"]) def get_weekly_demand(sku: Sku) -> WeeklyDemandOutput: """Return forecast demand for one SKU for the next seven days.""" print(f"[tool] get_weekly_demand({sku})") return WeeklyDemandOutput(sku=sku, forecast_units=weekly_demand[sku]) -@function_tool(allowed_callers=["programmatic"]) +@tool(allowed_callers=["programmatic"]) def get_inbound_units(sku: Sku) -> InboundUnitsOutput: """Return units already scheduled to arrive for one SKU.""" print(f"[tool] get_inbound_units({sku})") diff --git a/examples/tools/tool_search.py b/examples/tools/tool_search.py index 102c220c56..08d9f607f1 100644 --- a/examples/tools/tool_search.py +++ b/examples/tools/tool_search.py @@ -9,10 +9,10 @@ ModelSettings, Runner, ToolSearchTool, - function_tool, tool_namespace, trace, ) +from agents.decorators import tool CUSTOMER_PROFILES = { "customer_42": { @@ -42,7 +42,7 @@ } -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_customer_profile( customer_id: Annotated[str, "The CRM customer identifier to look up."], ) -> str: @@ -50,7 +50,7 @@ def get_customer_profile( return json.dumps(CUSTOMER_PROFILES[customer_id], indent=2) -@function_tool(defer_loading=True) +@tool(defer_loading=True) def list_open_orders( customer_id: Annotated[str, "The CRM customer identifier to look up."], ) -> str: @@ -58,7 +58,7 @@ def list_open_orders( return json.dumps(OPEN_ORDERS.get(customer_id, []), indent=2) -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_invoice_status( invoice_id: Annotated[str, "The invoice identifier to look up."], ) -> str: @@ -66,7 +66,7 @@ def get_invoice_status( return INVOICE_STATUSES.get(invoice_id, "unknown") -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_shipping_eta( tracking_number: Annotated[str, "The shipment tracking number to look up."], ) -> str: @@ -74,7 +74,7 @@ def get_shipping_eta( return SHIPPING_ETAS.get(tracking_number, "unavailable") -@function_tool(defer_loading=True) +@tool(defer_loading=True) def get_shipping_credit_balance( customer_id: Annotated[str, "The customer account identifier to look up."], ) -> str: diff --git a/examples/voice/static/main.py b/examples/voice/static/main.py index 69297e3e82..e5688bc3e7 100644 --- a/examples/voice/static/main.py +++ b/examples/voice/static/main.py @@ -3,7 +3,8 @@ import numpy as np -from agents import Agent, function_tool +from agents import Agent +from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions from agents.voice import ( AudioInput, @@ -30,7 +31,7 @@ """ -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") diff --git a/examples/voice/streamed/my_workflow.py b/examples/voice/streamed/my_workflow.py index cabafa7c55..ddf27b5ec3 100644 --- a/examples/voice/streamed/my_workflow.py +++ b/examples/voice/streamed/my_workflow.py @@ -1,12 +1,17 @@ import random from collections.abc import AsyncIterator, Callable -from agents import Agent, Runner, TResponseInputItem, function_tool +from agents import ( + Agent, + Runner, + TResponseInputItem, +) +from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions from agents.voice import VoiceWorkflowBase, VoiceWorkflowHelper -@function_tool +@tool def get_weather(city: str) -> str: """Get the weather for a given city.""" print(f"[debug] get_weather called with city: {city}") From 88bfb18c2fc8265e6c9ea798877d03d21d88ddc6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Jul 2026 07:57:35 +0900 Subject: [PATCH 040/473] fix: redact Realtime audio format diagnostics (#3992) --- src/agents/realtime/audio_formats.py | 15 +++- tests/realtime/test_audio_formats_unit.py | 98 +++++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/agents/realtime/audio_formats.py b/src/agents/realtime/audio_formats.py index 15bd9953b4..a028c736e8 100644 --- a/src/agents/realtime/audio_formats.py +++ b/src/agents/realtime/audio_formats.py @@ -10,6 +10,7 @@ RealtimeAudioFormats, ) +from .. import _debug from ..logger import logger @@ -25,6 +26,8 @@ def to_realtime_audio_format( format = AudioPCMU(type="audio/pcmu") elif input_audio_format in ["g711_alaw", "audio/pcma", "pcma"]: format = AudioPCMA(type="audio/pcma") + elif _debug.DONT_LOG_MODEL_DATA: + logger.debug("Unknown input audio format") else: logger.debug("Unknown input_audio_format: %s", input_audio_format) elif isinstance(input_audio_format, Mapping): @@ -37,15 +40,21 @@ def to_realtime_audio_format( elif rate is None: pcm_rate = 24000 else: - logger.debug( - "Unknown pcm rate in input_audio_format mapping: %s", input_audio_format - ) + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Unknown PCM rate in input audio format mapping") + else: + logger.debug( + "Unknown pcm rate in input_audio_format mapping: %s", + input_audio_format, + ) pcm_rate = 24000 format = AudioPCM(type="audio/pcm", rate=pcm_rate) elif fmt_type == "audio/pcmu": format = AudioPCMU(type="audio/pcmu") elif fmt_type == "audio/pcma": format = AudioPCMA(type="audio/pcma") + elif _debug.DONT_LOG_MODEL_DATA: + logger.debug("Unknown input audio format mapping") else: logger.debug("Unknown input_audio_format mapping: %s", input_audio_format) else: diff --git a/tests/realtime/test_audio_formats_unit.py b/tests/realtime/test_audio_formats_unit.py index bbd1b6f746..52a9028228 100644 --- a/tests/realtime/test_audio_formats_unit.py +++ b/tests/realtime/test_audio_formats_unit.py @@ -1,5 +1,10 @@ +import logging +from typing import Any + +import pytest from openai.types.realtime.realtime_audio_formats import AudioPCM, AudioPCMA, AudioPCMU +from agents import _debug from agents.realtime.audio_formats import to_realtime_audio_format @@ -51,3 +56,96 @@ def test_to_realtime_audio_format_from_mapping(): assert alaw.type == "audio/pcma" assert to_realtime_audio_format({"type": "audio/unknown", "rate": 8000}) is None + + +@pytest.mark.parametrize("tool_data_redacted", [False, True]) +@pytest.mark.parametrize( + ("input_audio_format", "expected_message", "expected_type"), + [ + ("format-secret", "Unknown input audio format", None), + ( + {"type": "audio/pcm", "rate": "rate-secret"}, + "Unknown PCM rate in input audio format mapping", + AudioPCM, + ), + ( + {"type": "format-secret", "nested": "mapping-secret"}, + "Unknown input audio format mapping", + None, + ), + ], +) +def test_to_realtime_audio_format_redacts_unknown_values( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tool_data_redacted: bool, + input_audio_format: Any, + expected_message: str, + expected_type: type[AudioPCM] | None, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_data_redacted) + caplog.set_level(logging.DEBUG, logger="openai.agents") + + result = to_realtime_audio_format(input_audio_format) + + if expected_type is None: + assert result is None + else: + assert isinstance(result, expected_type) + record = caplog.records[-1] + assert record.msg == expected_message + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert all(value is not input_audio_format for value in record.__dict__.values()) + assert logging.Formatter().format(record) == expected_message + + +@pytest.mark.parametrize("tool_data_redacted", [False, True]) +def test_to_realtime_audio_format_preserves_diagnostic_mapping( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tool_data_redacted: bool, +) -> None: + input_audio_format = {"type": "format-secret", "nested": "mapping-secret"} + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_data_redacted) + caplog.set_level(logging.DEBUG, logger="openai.agents") + + assert to_realtime_audio_format(input_audio_format) is None + + record = caplog.records[-1] + assert record.msg == "Unknown input_audio_format mapping: %s" + assert record.args is input_audio_format + assert record.exc_info is None + assert record.exc_text is None + assert "format-secret" in logging.Formatter().format(record) + assert "mapping-secret" in logging.Formatter().format(record) + + +def test_to_realtime_audio_format_redaction_does_not_render_hostile_mapping( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + class HostileMapping(dict[str, object]): + def __str__(self) -> str: + raise AssertionError("redacted logging must not call __str__") + + def __repr__(self) -> str: + raise AssertionError("redacted logging must not call __repr__") + + input_audio_format = HostileMapping(type="unknown") + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + caplog.set_level(logging.DEBUG, logger="openai.agents") + + assert to_realtime_audio_format(input_audio_format) is None + + record = caplog.records[-1] + assert record.msg == "Unknown input audio format mapping" + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert all(value is not input_audio_format for value in record.__dict__.values()) + assert logging.Formatter().format(record) == "Unknown input audio format mapping" From 421deb75061c6dc4e5c8ee2352ef2390413906da Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Jul 2026 08:10:53 +0900 Subject: [PATCH 041/473] docs: update translated pages --- docs/ja/tracing.md | 94 ++++++++++++++++----------------- docs/ko/tracing.md | 84 +++++++++++++++--------------- docs/ref/decorators.md | 3 ++ docs/zh/tracing.md | 114 ++++++++++++++++++++--------------------- 4 files changed, 149 insertions(+), 146 deletions(-) create mode 100644 docs/ref/decorators.md diff --git a/docs/ja/tracing.md b/docs/ja/tracing.md index c132d7f62b..3cc65a61e7 100644 --- a/docs/ja/tracing.md +++ b/docs/ja/tracing.md @@ -4,51 +4,51 @@ search: --- # トレーシング -Agents SDK には組み込みのトレーシング機能があり、エージェント実行中のイベント(LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらには発生したカスタムイベントまで)を包括的に記録します。[トレースダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境と本番環境の両方でワークフローをデバッグ、可視化、監視できます。 +Agents SDK にはトレーシングが組み込まれており、エージェントの実行中に発生するイベント(LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベント)を包括的に記録します。[Traces ダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境および本番環境でワークフローをデバッグ、可視化、監視できます。 !!!note - トレーシングはデフォルトで有効です。一般的な無効化方法は次の 3 つです。 + トレーシングはデフォルトで有効です。一般的な次の 3 つの方法で無効にできます。 - 1. 環境変数 `OPENAI_AGENTS_DISABLE_TRACING=1` を設定して、トレーシングをグローバルに無効化できます - 2. コード内で [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用して、トレーシングをグローバルに無効化できます - 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定して、1 回の実行に対するトレーシングを無効化できます + 1. 環境変数 `OPENAI_AGENTS_DISABLE_TRACING=1` を設定すると、トレーシングをグローバルに無効化できます + 2. コード内で [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用すると、トレーシングをグローバルに無効化できます + 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定すると、単一の実行についてトレーシングを無効化できます ***OpenAI の API を使用し、Zero Data Retention(ZDR)ポリシーの下で運用している組織では、トレーシングを利用できません。*** ## トレースとスパン - **トレース**は、「ワークフロー」における単一のエンドツーエンド操作を表します。トレースはスパンで構成され、次のプロパティがあります。 - - `workflow_name`: 論理的なワークフローまたはアプリです。たとえば「コード生成」や「カスタマーサービス」です。 + - `workflow_name`: 論理的なワークフローまたはアプリです。たとえば、「コード生成」や「カスタマーサービス」です。 - `trace_id`: トレースの一意な ID です。指定しない場合は自動生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 - - `group_id`: 同じ会話の複数のトレースを関連付けるための、省略可能なグループ ID です。たとえば、チャットスレッド ID を使用できます。 + - `group_id`: 同じ会話の複数のトレースを関連付けるための、オプションのグループ ID です。たとえば、チャットスレッド ID を使用できます。 - `disabled`: True の場合、トレースは記録されません。 - - `metadata`: トレース用の省略可能なメタデータです。 + - `metadata`: トレースのオプションのメタデータです。 - **スパン**は、開始時刻と終了時刻を持つ操作を表します。スパンには次の情報があります。 - `started_at` および `ended_at` のタイムスタンプ。 - - `trace_id`。そのスパンが属するトレースを表します - - `parent_id`。このスパンの親スパン(存在する場合)を指します - - `span_data`。スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 + - `trace_id`: スパンが属するトレースを表します + - `parent_id`: このスパンの親スパン(存在する場合)を指します + - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 ## デフォルトのトレーシング デフォルトでは、SDK は次の項目をトレースします。 - `Runner.{run, run_sync, run_streamed}()` 全体が `trace()` でラップされます。 -- 各 Runner 呼び出しが `task_span()` でラップされます。 +- 各ランナー呼び出しが `task_span()` でラップされます。 - 各モデルターンが `turn_span()` でラップされます。 - エージェントが実行されるたびに、`agent_span()` でラップされます -- LLM 生成が `generation_span()` でラップされます -- 各関数ツール呼び出しが `function_span()` でラップされます -- ガードレールが `guardrail_span()` でラップされます -- ハンドオフが `handoff_span()` でラップされます -- 音声入力(音声テキスト変換)が `transcription_span()` でラップされます -- 音声出力(テキスト音声変換)が `speech_span()` でラップされます -- 関連する音声スパンは、`speech_group_span()` の子として配置される場合があります +- LLM 生成は `generation_span()` でラップされます +- 各関数ツール呼び出しは `function_span()` でラップされます +- ガードレールは `guardrail_span()` でラップされます +- ハンドオフは `handoff_span()` でラップされます +- 音声入力(音声テキスト変換)は `transcription_span()` でラップされます +- 音声出力(テキスト音声変換)は `speech_span()` でラップされます +- 関連する音声スパンは `speech_group_span()` の配下に配置される場合があります -デフォルトでは、トレースの名前は「Agent workflow」です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して名前やその他のプロパティを設定することもできます。 +デフォルトでは、トレース名は「Agent workflow」です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して名前やその他のプロパティを設定することもできます。 -よりコンパクトな階層にするには、実行時のタスクスパンとターンスパンの自動生成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、カスタムの各スパンは引き続き記録されます。 +よりコンパクトな階層にする場合は、その実行についてタスクスパンとターンスパンの自動作成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、カスタムの各スパンは引き続き記録されます。 ```python from agents import RunConfig, Runner @@ -60,11 +60,11 @@ result = await Runner.run( ) ``` -さらに、[カスタムトレースプロセッサー](#custom-tracing-processors)を設定して、トレースを別の送信先に送ることもできます(既定の送信先の代替、または追加の送信先として)。 +さらに、[カスタムトレーシングプロセッサー](#custom-tracing-processors)を設定して、トレースを別の送信先に送信できます(置き換え先または追加の送信先として使用できます)。 ## 長時間実行ワーカーと即時エクスポート -デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのトリガー値に達した時点で、それより早くバックグラウンドでトレースをエクスポートします。また、プロセス終了時には最後のフラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 +デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのしきい値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセス終了時には最後のフラッシュを実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後には Traces ダッシュボードに表示されない場合があります。 作業単位の終了時に即時配信を保証する必要がある場合は、トレースコンテキストの終了後に [`flush_traces()`][agents.tracing.flush_traces] を呼び出します。 @@ -103,7 +103,7 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンのエクスポートが完了するまでブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 +[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンのエクスポートが完了するまでブロックします。そのため、構築途中のトレースをフラッシュしないように、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題がない場合は、この呼び出しを省略できます。 ## 上位レベルのトレース @@ -122,30 +122,30 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. `Runner.run` の 2 回の呼び出しが `with trace()` でラップされているため、個々の実行で 2 つのトレースが作成されるのではなく、全体のトレースの一部になります。 +1. 2 回の `Runner.run` 呼び出しが `with trace()` でラップされているため、個別の実行によって 2 つのトレースが作成されるのではなく、全体のトレースに含まれます。 ## トレースの作成 [`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始して終了する必要があります。これには次の 2 つの方法があります。 -1. **推奨**: `with trace(...) as my_trace` のように、トレースをコンテキストマネージャーとして使用します。これにより、適切なタイミングでトレースが自動的に開始・終了されます。 +1. **推奨**: `with trace(...) as my_trace` のように、トレースをコンテキストマネージャーとして使用します。これにより、適切なタイミングでトレースが自動的に開始および終了します。 2. [`trace.start()`][agents.tracing.Trace.start] と [`trace.finish()`][agents.tracing.Trace.finish] を手動で呼び出すこともできます。 -現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始・終了する場合は、現在のトレースを更新するために、`start()` / `finish()` に `mark_as_current` と `reset_current` を渡す必要があります。 +現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始/終了する場合は、現在のトレースを更新するために、`start()`/`finish()` に `mark_as_current` と `reset_current` を渡す必要があります。 ## スパンの作成 -各種 [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を使用できます。 +各種 [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を利用できます。 -スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、現在の最も近いスパンの下にネストされます。 +スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの下にネストされます。 ## 機密データ -一部のスパンは、機密性のある可能性があるデータをキャプチャする場合があります。 +特定のスパンでは、機密性の高い可能性があるデータが取得される場合があります。 -`generation_span()` は LLM 生成の入力と出力を保存し、`function_span()` は関数呼び出しの入力と出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用してデータのキャプチャを無効化できます。 +`generation_span()` は LLM 生成の入力/出力を保存し、`function_span()` は関数呼び出しの入力/出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータの取得を無効にできます。 -同様に、音声スパンにはデフォルトで、入力音声と出力音声の Base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定することで、この音声データのキャプチャを無効化できます。 +同様に、音声スパンには、デフォルトで入出力音声の base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定すると、この音声データの取得を無効にできます。 デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定することで、コードを変更せずにデフォルト値を設定できます。 @@ -153,18 +153,18 @@ async def main(): トレーシングの上位レベルのアーキテクチャは次のとおりです。 -- 初期化時に、トレースの作成を担うグローバルな [`TraceProvider`][agents.tracing.setup.TraceProvider] を作成します。 -- `TraceProvider` に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。これは、トレースとスパンをバッチ単位で [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、`BackendSpanExporter` がスパンとトレースをバッチ単位で OpenAI バックエンドにエクスポートします。 +- 初期化時に、トレースの作成を担うグローバルな [`TraceProvider`][agents.tracing.provider.TraceProvider] を作成します。 +- [`TraceProvider`][agents.tracing.provider.TraceProvider] に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。このプロセッサーはトレース/スパンをバッチ単位で [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、同エクスポーターがスパンとトレースをバッチ単位で OpenAI バックエンドにエクスポートします。 -このデフォルト設定をカスタマイズして、代替または追加のバックエンドにトレースを送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 +このデフォルト設定をカスタマイズし、トレースを代替または追加のバックエンドに送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備が整ったトレースとスパンを受信する**追加の**トレースプロセッサーを追加できます。これにより、OpenAI バックエンドへのトレース送信に加えて、独自の処理を実行できます。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで**置き換える**ことができます。この場合、その処理を行う `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備ができたトレースとスパンを受け取る**追加の**トレースプロセッサーを追加できます。これにより、トレースを OpenAI のバックエンドへ送信する処理に加えて、独自の処理も実行できます。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで**置き換える**ことができます。この場合、OpenAI バックエンドへ送信する `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 ## OpenAI 以外のモデルでのトレーシング -OpenAI API キーを OpenAI 以外のモデルで使用すると、トレーシングを無効化することなく、OpenAI のトレースダッシュボードで無料のトレーシングを有効にできます。アダプターの選択とセットアップ時の注意事項については、モデルガイドの[サードパーティ製アダプター](models/index.md#third-party-adapters)セクションを参照してください。 +OpenAI 以外のモデルでも OpenAI API キーを使用すれば、トレーシングを無効にすることなく、OpenAI Traces ダッシュボードで無料のトレーシングを有効にできます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティ製アダプター](models/index.md#third-party-adapters)セクションを参照してください。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -1 回の実行にのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡してください。 +単一の実行にのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡してください。 ```python from agents import Runner, RunConfig @@ -197,21 +197,21 @@ await Runner.run( ) ``` -## 補足事項 -- OpenAI のトレースダッシュボードで無料のトレースを確認できます。 +## 追加情報 +- OpenAI Traces ダッシュボードで無料のトレースを確認できます。 ## エコシステム統合 -以下のコミュニティおよびベンダー統合は、OpenAI Agents SDK のトレーシングインターフェースをサポートしています。 +以下のコミュニティおよびベンダーの統合は、OpenAI Agents SDK のトレーシングインターフェースをサポートしています。 ### 外部トレーシングプロセッサー一覧 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) -- [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) -- [MLflow (self-hosted/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow (Databricks hosted)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) +- [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) +- [MLflow(セルフホスト/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow(Databricks ホスト)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) @@ -229,9 +229,9 @@ await Runner.run( - [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) - [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) - [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/languages/integrations#openai-agents-sdk) +- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) - [Latitude](https://docs.latitude.so/telemetry/frameworks/openai-agents) -- [DProvenanceKit](https://dprovenance.dev/openai-agents/) +- [DProvenanceKit](https://dprovenance.dev/openai-agents/) \ No newline at end of file diff --git a/docs/ko/tracing.md b/docs/ko/tracing.md index 9e28e7b3b7..2a03cbf13d 100644 --- a/docs/ko/tracing.md +++ b/docs/ko/tracing.md @@ -4,30 +4,30 @@ search: --- # 트레이싱 -Agents SDK에는 에이전트 실행 중 발생하는 LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지 포괄적으로 기록하는 트레이싱 기능이 기본 제공됩니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고 시각화하며 모니터링할 수 있습니다. +Agents SDK에는 에이전트 실행 중 발생하는 LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지 포괄적으로 기록하는 트레이싱 기능이 내장되어 있습니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고, 시각화하고, 모니터링할 수 있습니다. !!!note 트레이싱은 기본적으로 활성화되어 있습니다. 다음과 같은 세 가지 일반적인 방법으로 비활성화할 수 있습니다. - 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역으로 비활성화할 수 있습니다 - 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]를 사용하여 트레이싱을 전역으로 비활성화할 수 있습니다 + 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역적으로 비활성화할 수 있습니다 + 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]를 사용하여 트레이싱을 전역적으로 비활성화할 수 있습니다 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다 -***OpenAI API를 사용하면서 제로 데이터 보존(Zero Data Retention, ZDR) 정책에 따라 운영되는 조직에서는 트레이싱을 사용할 수 없습니다.*** +***OpenAI API를 사용하며 제로 데이터 보존(Zero Data Retention, ZDR) 정책에 따라 운영되는 조직에서는 트레이싱을 사용할 수 없습니다.*** ## 트레이스와 스팬 -- **트레이스**는 하나의 "워크플로"에서 수행되는 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 갖습니다. - - `workflow_name`: 논리적 워크플로나 앱입니다. 예를 들어 "코드 생성" 또는 "고객 서비스"입니다. - - `trace_id`: 트레이스의 고유 ID입니다. 전달하지 않으면 자동으로 생성됩니다. 형식은 `trace_<32_alphanumeric>`이어야 합니다. - - `group_id`: 동일한 대화의 여러 트레이스를 연결하는 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. +- **트레이스**는 하나의 "워크플로"에 대한 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 갖습니다. + - `workflow_name`: 논리적 워크플로 또는 앱입니다. 예를 들면 "코드 생성"이나 "고객 서비스"입니다. + - `trace_id`: 트레이스의 고유 ID입니다. 값을 전달하지 않으면 자동으로 생성됩니다. 형식은 `trace_<32_alphanumeric>`이어야 합니다. + - `group_id`: 동일한 대화의 여러 트레이스를 연결하기 위한 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. - `disabled`: True이면 트레이스가 기록되지 않습니다. - - `metadata`: 트레이스의 선택적 메타데이터입니다. -- **스팬**은 시작 및 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음 항목이 있습니다. + - `metadata`: 트레이스의 선택적 메타데이터 +- **스팬**은 시작 및 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음이 포함됩니다. - `started_at` 및 `ended_at` 타임스탬프 - - 자신이 속한 트레이스를 나타내는 `trace_id` - - 이 스팬의 부모 스팬을 가리키는 `parent_id`(있는 경우) + - 소속된 트레이스를 나타내는 `trace_id` + - 이 스팬의 상위 스팬을 가리키는 `parent_id`(있는 경우) - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트에 관한 정보가 포함되고, `GenerationSpanData`에는 LLM 생성에 관한 정보가 포함됩니다. ## 기본 트레이싱 @@ -42,13 +42,13 @@ SDK는 기본적으로 다음 항목을 트레이싱합니다. - 각 함수 도구 호출은 `function_span()`으로 래핑됩니다 - 가드레일은 `guardrail_span()`으로 래핑됩니다 - 핸드오프는 `handoff_span()`으로 래핑됩니다 -- 오디오 입력(음성-텍스트 변환)은 `transcription_span()`으로 래핑됩니다 -- 오디오 출력(텍스트-음성 변환)은 `speech_span()`으로 래핑됩니다 +- 오디오 입력(음성 텍스트 변환)은 `transcription_span()`으로 래핑됩니다 +- 오디오 출력(텍스트 음성 변환)은 `speech_span()`으로 래핑됩니다 - 관련 오디오 스팬은 `speech_group_span()` 아래에 배치될 수 있습니다 기본적으로 트레이스의 이름은 "Agent workflow"입니다. `trace`를 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]를 사용하여 이름과 기타 속성을 구성할 수도 있습니다. -더 간결한 계층 구조를 원한다면 실행에 대한 자동 태스크 및 턴 스팬을 비활성화합니다. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. +더 간결한 계층 구조가 필요하다면 실행 시 자동 태스크 및 턴 스팬을 비활성화하세요. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -또한 [사용자 지정 트레이스 프로세서](#custom-tracing-processors)를 설정하여 트레이스를 다른 대상으로 보낼 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 추가할 수 있습니다. +또한 [사용자 지정 트레이싱 프로세서](#custom-tracing-processors)를 설정하여 트레이스를 다른 대상으로 보낼 수 있습니다. 이 대상은 기존 대상을 대체하거나 보조 대상으로 사용할 수 있습니다. ## 장기 실행 워커와 즉시 내보내기 -기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 메모리 내 큐가 크기 트리거에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 태스크와 같은 장기 실행 워커에서는 일반적으로 추가 코드 없이 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후 트레이스 대시보드에 표시되지 않을 수 있습니다. +기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 인메모리 큐가 크기 임계값에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 태스크와 같은 장기 실행 워커에서는 별도의 코드 없이도 일반적으로 트레이스가 자동으로 내보내집니다. 다만 각 작업이 완료된 직후 트레이스 대시보드에 나타나지 않을 수 있습니다. -작업 단위가 끝날 때 즉시 전달되도록 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]를 호출합니다. +작업 단위가 끝날 때 즉시 전달되도록 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]를 호출하세요. ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces]는 현재 버퍼링된 트레이스와 스팬을 모두 내보낼 때까지 실행을 차단하므로, 일부만 생성된 트레이스가 플러시되지 않도록 `trace()`가 종료된 후 호출합니다. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. +[`flush_traces()`][agents.tracing.flush_traces]는 현재 버퍼링된 트레이스와 스팬을 모두 내보낼 때까지 차단하므로, 일부만 구성된 트레이스가 플러시되지 않도록 `trace()`가 종료된 후 호출하세요. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. ## 상위 수준 트레이스 -여러 `run()` 호출을 하나의 트레이스에 포함하려는 경우가 있습니다. 전체 코드를 `trace()`로 래핑하면 됩니다. +여러 `run()` 호출을 하나의 트레이스에 포함해야 할 때가 있습니다. 전체 코드를 `trace()`로 래핑하면 됩니다. ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 두 `Runner.run` 호출이 `with trace()`로 래핑되므로, 각 실행이 별도의 트레이스 두 개를 생성하는 대신 전체 트레이스의 일부가 됩니다. +1. 두 `Runner.run` 호출이 `with trace()`로 래핑되어 있으므로, 두 개의 트레이스를 생성하는 대신 각 실행이 전체 트레이스의 일부가 됩니다. ## 트레이스 생성 [`trace()`][agents.tracing.trace] 함수를 사용하여 트레이스를 생성할 수 있습니다. 트레이스는 시작하고 종료해야 합니다. 다음 두 가지 방법을 사용할 수 있습니다. -1. **권장**: 트레이스를 컨텍스트 관리자로 사용합니다. 즉, `with trace(...) as my_trace`를 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. -2. [`trace.start()`][agents.tracing.Trace.start]와 [`trace.finish()`][agents.tracing.Trace.finish]를 수동으로 호출할 수도 있습니다. +1. **권장 방식**: `with trace(...) as my_trace`와 같이 트레이스를 컨텍스트 관리자로 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. +2. [`trace.start()`][agents.tracing.Trace.start]와 [`trace.finish()`][agents.tracing.Trace.finish]를 직접 호출할 수도 있습니다. 현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 수동으로 시작하거나 종료하는 경우 현재 트레이스를 업데이트하려면 `start()`/`finish()`에 `mark_as_current`와 `reset_current`를 전달해야 합니다. ## 스팬 생성 -여러 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 수동으로 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적할 수 있도록 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. +다양한 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 직접 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적할 수 있도록 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. -스팬은 자동으로 현재 트레이스에 포함되며, Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적되는 가장 가까운 현재 스팬 아래에 중첩됩니다. +스팬은 자동으로 현재 트레이스에 포함되며 가장 가까운 현재 스팬 아래에 중첩됩니다. 현재 스팬은 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. ## 민감한 데이터 -특정 스팬에는 민감할 수 있는 데이터가 캡처될 수 있습니다. +일부 스팬은 잠재적으로 민감한 데이터를 캡처할 수 있습니다. -`generation_span()`은 LLM 생성의 입출력을 저장하고, `function_span()`은 함수 호출의 입출력을 저장합니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. +`generation_span()`은 LLM 생성의 입력과 출력을 저장하고, `function_span()`은 함수 호출의 입력과 출력을 저장합니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. -마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. +마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 Base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. -기본적으로 `trace_include_sensitive_data`는 `True`입니다. 코드 없이 기본값을 설정하려면 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 됩니다. +기본적으로 `trace_include_sensitive_data`는 `True`입니다. 코드를 변경하지 않고 기본값을 설정하려면 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 됩니다. ## 사용자 지정 트레이싱 프로세서 트레이싱의 상위 수준 아키텍처는 다음과 같습니다. -- 초기화할 때 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.setup.TraceProvider]를 생성합니다. -- [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]에 트레이스와 스팬을 배치로 전송하는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]로 `TraceProvider`를 구성합니다. `BackendSpanExporter`는 스팬과 트레이스를 OpenAI 백엔드로 배치 단위로 내보냅니다. +- 초기화 시 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.provider.TraceProvider]를 생성합니다. +- 트레이스와 스팬을 배치 단위로 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]에 전송하는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]를 사용하여 `TraceProvider`를 구성합니다. `BackendSpanExporter`는 스팬과 트레이스를 배치 단위로 OpenAI 백엔드에 내보냅니다. -트레이스를 대체 또는 추가 백엔드로 전송하거나 내보내기 동작을 변경하는 등 기본 설정을 사용자 지정하려면 다음 두 가지 방법을 사용할 수 있습니다. +트레이스를 대체 또는 추가 백엔드로 전송하거나 익스포터 동작을 변경하는 등 이 기본 설정을 사용자 지정하려면 다음 두 가지 방법을 사용할 수 있습니다. -1. [`add_trace_processor()`][agents.tracing.add_trace_processor]를 사용하면 준비되는 트레이스와 스팬을 수신하는 **추가** 트레이스 프로세서를 추가할 수 있습니다. 따라서 OpenAI 백엔드로 트레이스를 전송하는 동시에 자체 처리를 수행할 수 있습니다. -2. [`set_trace_processors()`][agents.tracing.set_trace_processors]를 사용하면 기본 프로세서를 자체 트레이스 프로세서로 **교체**할 수 있습니다. 이 경우 OpenAI 백엔드로 전송하는 `TracingProcessor`를 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. +1. [`add_trace_processor()`][agents.tracing.add_trace_processor]를 사용하면 준비된 트레이스와 스팬을 수신할 **추가** 트레이싱 프로세서를 등록할 수 있습니다. 이를 통해 트레이스를 OpenAI 백엔드에 전송하는 것과 별도로 자체 처리를 수행할 수 있습니다. +2. [`set_trace_processors()`][agents.tracing.set_trace_processors]를 사용하면 기본 프로세서를 자체 트레이싱 프로세서로 **교체**할 수 있습니다. 이 경우 OpenAI 백엔드로 전송하는 `TracingProcessor`를 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. -## OpenAI 이외 모델을 사용한 트레이싱 +## 비 OpenAI 모델을 사용한 트레이싱 -OpenAI 이외 모델에 OpenAI API 키를 사용하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 활성화할 수 있습니다. 어댑터 선택 및 설정 시 주의 사항은 모델 가이드의 [서드파티 어댑터](models/index.md#third-party-adapters) 섹션을 참고하세요. +비 OpenAI 모델과 함께 OpenAI API 키를 사용하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 활성화할 수 있습니다. 어댑터 선택 및 설정 시 유의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참고하세요. ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -단일 실행에만 다른 트레이싱 키가 필요한 경우 전역 내보내기를 변경하는 대신 `RunConfig`를 통해 전달합니다. +단일 실행에만 다른 트레이싱 키가 필요한 경우 전역 익스포터를 변경하지 말고 `RunConfig`를 통해 전달하세요. ```python from agents import Runner, RunConfig @@ -201,15 +201,15 @@ await Runner.run( - OpenAI 트레이스 대시보드에서 무료 트레이스를 확인할 수 있습니다. -## 에코시스템 통합 +## 생태계 통합 -다음 커뮤니티 및 공급업체 통합은 OpenAI Agents SDK의 트레이싱 인터페이스를 지원합니다. +다음 커뮤니티 및 공급업체 통합은 OpenAI Agents SDK 트레이싱 인터페이스를 지원합니다. ### 외부 트레이싱 프로세서 목록 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) -- [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) +- [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) - [MLflow (자체 호스팅/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) - [MLflow (Databricks 호스팅)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) @@ -229,9 +229,9 @@ await Runner.run( - [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) - [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) - [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/languages/integrations#openai-agents-sdk) +- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) - [Latitude](https://docs.latitude.so/telemetry/frameworks/openai-agents) -- [DProvenanceKit](https://dprovenance.dev/openai-agents/) +- [DProvenanceKit](https://dprovenance.dev/openai-agents/) \ No newline at end of file diff --git a/docs/ref/decorators.md b/docs/ref/decorators.md new file mode 100644 index 0000000000..8ce6edb454 --- /dev/null +++ b/docs/ref/decorators.md @@ -0,0 +1,3 @@ +# `Decorators` + +::: agents.decorators diff --git a/docs/zh/tracing.md b/docs/zh/tracing.md index ecb464f076..db2cd31f26 100644 --- a/docs/zh/tracing.md +++ b/docs/zh/tracing.md @@ -4,51 +4,51 @@ search: --- # 追踪 -Agents SDK 内置追踪功能,可收集智能体运行期间发生的各类事件的完整记录:LLM生成、工具调用、任务转移、安全防护措施,甚至包括发生的自定义事件。借助[追踪控制面板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化和监控工作流。 +Agents SDK内置追踪功能,可在智能体运行期间收集完整的事件记录,包括LLM生成、工具调用、任务转移、安全防护措施,乃至发生的自定义事件。使用[追踪记录仪表板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化和监控工作流。 !!!note - 追踪默认启用。你可以通过以下三种常见方式禁用: + 默认启用追踪。你可以通过以下三种常见方式将其禁用: - 1. 设置环境变量 `OPENAI_AGENTS_DISABLE_TRACING=1`,在全局范围内禁用追踪 - 2. 在代码中使用 [`set_tracing_disabled(True)`][agents.set_tracing_disabled],在全局范围内禁用追踪 - 3. 将 [`agents.run.RunConfig.tracing_disabled`][] 设置为 `True`,针对单次运行禁用追踪 + 1. 设置环境变量`OPENAI_AGENTS_DISABLE_TRACING=1`,在全局禁用追踪 + 2. 在代码中使用[`set_tracing_disabled(True)`][agents.set_tracing_disabled],在全局禁用追踪 + 3. 将[`agents.run.RunConfig.tracing_disabled`][]设置为`True`,针对单次运行禁用追踪 -***对于使用OpenAI API 且采用零数据保留(ZDR)政策的组织,追踪功能不可用。*** +***对于使用OpenAI API并遵循零数据保留(ZDR)政策的组织,追踪功能不可用。*** ## 追踪与跨度 -- **追踪**表示一次端到端的“工作流”操作。它们由多个跨度组成。追踪具有以下属性: +- **追踪**表示一次“工作流”的端到端操作。追踪由多个跨度组成,并具有以下属性: - `workflow_name`:逻辑工作流或应用。例如“代码生成”或“客户服务”。 - - `trace_id`:追踪的唯一 ID。如果未传入,则会自动生成。格式必须为 `trace_<32_alphanumeric>`。 - - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,你可以使用聊天线程 ID。 + - `trace_id`:追踪的唯一 ID。如果未传入,则会自动生成。格式必须为`trace_<32_alphanumeric>`。 + - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,可以使用聊天线程 ID。 - `disabled`:如果为 True,则不会记录该追踪。 - `metadata`:追踪的可选元数据。 - **跨度**表示具有开始和结束时间的操作。跨度具有以下属性: - - `started_at` 和 `ended_at` 时间戳。 - - `trace_id`,表示它们所属的追踪 - - `parent_id`,指向该跨度的父跨度(如果存在) - - `span_data`,即有关该跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM生成的信息,依此类推。 + - `started_at`和`ended_at`时间戳。 + - `trace_id`,表示其所属的追踪 + - `parent_id`,指向此跨度的父跨度(如果有) + - `span_data`,即有关跨度的信息。例如,`AgentSpanData`包含有关智能体的信息,`GenerationSpanData`包含有关LLM生成的信息,依此类推。 ## 默认追踪 默认情况下,SDK 会追踪以下内容: -- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在 `trace()` 中。 -- 每次运行器调用都封装在 `task_span()` 中。 -- 每个模型轮次都封装在 `turn_span()` 中。 -- 每次智能体运行都封装在 `agent_span()` 中 -- LLM生成封装在 `generation_span()` 中 -- 每次函数工具调用都封装在 `function_span()` 中 -- 安全防护措施封装在 `guardrail_span()` 中 -- 任务转移封装在 `handoff_span()` 中 -- 音频输入(语音转文本)封装在 `transcription_span()` 中 -- 音频输出(文本转语音)封装在 `speech_span()` 中 -- 相关的音频跨度可以将 `speech_group_span()` 作为父跨度 +- 整个`Runner.{run, run_sync, run_streamed}()`都会封装在`trace()`中。 +- 每次运行器调用都会封装在`task_span()`中。 +- 每个模型轮次都会封装在`turn_span()`中。 +- 每次智能体运行都会封装在`agent_span()`中 +- LLM生成都会封装在`generation_span()`中 +- 每次函数工具调用都会封装在`function_span()`中 +- 安全防护措施都会封装在`guardrail_span()`中 +- 任务转移都会封装在`handoff_span()`中 +- 音频输入(语音转文本)都会封装在`transcription_span()`中 +- 音频输出(文本转语音)都会封装在`speech_span()`中 +- 相关的音频跨度可以将`speech_group_span()`设为父级 -默认情况下,追踪名称为“Agent workflow”。使用 `trace` 时可以设置此名称,也可以通过 [`RunConfig`][agents.run.RunConfig] 配置名称和其他属性。 +默认情况下,追踪名称为“Agent workflow”。使用`trace`时可以设置此名称,也可以通过[`RunConfig`][agents.run.RunConfig]配置名称及其他属性。 -如果你希望层次结构更紧凑,可以针对某次运行禁用自动任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 +如果希望使用更紧凑的层级结构,可以针对某次运行禁用自动创建的任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -此外,你还可以设置[自定义追踪进程](#custom-tracing-processors),将追踪发送到其他目标(作为替代目标或次要目标)。 +此外,你还可以设置[自定义追踪进程](#custom-tracing-processors),将追踪推送到其他目标位置,以替代原目标位置或作为辅助目标位置。 ## 长时间运行的工作进程与即时导出 -默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出一次追踪,或者在内存队列达到其大小触发阈值时提前导出,并且还会在进程退出时执行最后一次刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时间运行的工作进程,这意味着通常无需任何额外代码即可自动导出追踪,但它们可能不会在每个作业完成后立即显示在追踪控制面板中。 +默认的[`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]每隔几秒在后台导出追踪;当内存队列达到其大小触发阈值时,会提前导出;进程退出时,还会执行最后一次刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时间运行的工作进程,这意味着通常无需任何额外代码即可自动导出追踪,但每项作业完成后,它们可能不会立即显示在追踪记录仪表板中。 -如果需要保证在一个工作单元结束时立即交付,请在追踪上下文退出后调用 [`flush_traces()`][agents.tracing.flush_traces]。 +如果需要确保在一个工作单元结束时立即交付,请在追踪上下文退出后调用[`flush_traces()`][agents.tracing.flush_traces]。 ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此应在 `trace()` 关闭后调用,以避免刷新尚未完整构建的追踪。如果可以接受默认的导出延迟,则可以跳过此调用。 +[`flush_traces()`][agents.tracing.flush_traces]会阻塞,直到当前已缓冲的追踪和跨度全部导出。因此,请在`trace()`关闭后调用它,以避免刷新尚未构建完成的追踪。如果默认导出延迟可以接受,则可以跳过此调用。 ## 高层级追踪 -有时,你可能希望多次调用 `run()` 时将其纳入同一个追踪。为此,可以将整个代码封装在 `trace()` 中。 +有时,你可能希望多次调用`run()`都属于同一个追踪。为此,可以将整个代码封装在`trace()`中。 ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 由于两次 `Runner.run` 调用都封装在 `with trace()` 中,因此各次运行将成为整体追踪的一部分,而不是创建两个追踪。 +1. 由于对`Runner.run`的两次调用都封装在`with trace()`中,因此各次运行将成为整体追踪的一部分,而不会创建两个追踪。 -## 追踪创建 +## 追踪的创建 -你可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。你有以下两种方式: +可以使用[`trace()`][agents.tracing.trace]函数创建追踪。追踪需要启动和结束,有以下两种方式: -1. **推荐**:将追踪用作上下文管理器,即 `with trace(...) as my_trace`。这会在适当的时间自动启动和结束追踪。 -2. 也可以手动调用 [`trace.start()`][agents.tracing.Trace.start] 和 [`trace.finish()`][agents.tracing.Trace.finish]。 +1. **推荐**:将追踪用作上下文管理器,即`with trace(...) as my_trace`。这会在适当的时间自动启动和结束追踪。 +2. 也可以手动调用[`trace.start()`][agents.tracing.Trace.start]和[`trace.finish()`][agents.tracing.Trace.finish]。 -当前追踪通过 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它会自动支持并发。如果手动启动或结束追踪,则需要将 `mark_as_current` 和 `reset_current` 传递给 `start()`/`finish()`,以更新当前追踪。 +当前追踪通过 Python 的[`contextvar`](https://docs.python.org/3/library/contextvars.html)进行跟踪,这意味着它可以自动适配并发场景。如果手动启动或结束追踪,则需要将`mark_as_current`和`reset_current`传递给`start()`/`finish()`,以更新当前追踪。 -## 跨度创建 +## 跨度的创建 -你可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。通常不需要手动创建跨度。你可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 +可以使用各种[`*_span()`][agents.tracing.create]方法创建跨度。通常不需要手动创建跨度。可以使用[`custom_span()`][agents.tracing.custom_span]函数跟踪自定义跨度信息。 -跨度会自动成为当前追踪的一部分,并嵌套在最近的当前跨度下;当前跨度通过 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 +跨度会自动成为当前追踪的一部分,并嵌套在最近的当前跨度之下;当前跨度通过 Python 的[`contextvar`](https://docs.python.org/3/library/contextvars.html)进行跟踪。 ## 敏感数据 某些跨度可能会捕获潜在的敏感数据。 -`generation_span()` 会存储 LLM生成的输入和输出,`function_span()` 会存储函数调用的输入和输出。这些内容可能包含敏感数据,因此你可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获此类数据。 +`generation_span()`会存储LLM生成的输入/输出,`function_span()`会存储函数调用的输入/输出。这些内容可能包含敏感数据,因此可以通过[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]禁用此类数据的捕获。 -同样,默认情况下,音频跨度会包含输入和输出音频的 base64 编码 PCM 数据。你可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] 禁止捕获这些音频数据。 +同样,默认情况下,音频跨度包含以 base64 编码的输入和输出音频 PCM 数据。可以通过配置[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]禁用对此类音频数据的捕获。 -默认情况下,`trace_include_sensitive_data` 为 `True`。你可以在运行应用前,将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,从而在不修改代码的情况下设置默认值。 +默认情况下,`trace_include_sensitive_data`为`True`。无需修改代码,只需在运行应用前将`OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA`环境变量导出为`true/1`或`false/0`,即可设置默认值。 ## 自定义追踪进程 -追踪的高层级架构如下: +追踪功能的高层架构如下: -- 初始化时,我们会创建一个全局 [`TraceProvider`][agents.tracing.setup.TraceProvider],负责创建追踪。 -- 我们使用 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 配置 `TraceProvider`,由它将追踪和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者再将跨度和追踪分批导出到OpenAI后端。 +- 初始化时,会创建全局[`TraceProvider`][agents.tracing.provider.TraceProvider],负责创建追踪。 +- 我们会为`TraceProvider`配置一个[`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],由它将追踪/跨度分批发送到[`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter];后者会将跨度和追踪分批导出到OpenAI后端。 -若要自定义此默认设置,将追踪发送到其他或额外的后端,或修改导出器行为,你有以下两种选择: +若要自定义此默认设置,将追踪发送到其他或更多后端,或修改导出器行为,有以下两种方式: -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 允许添加一个**额外的**追踪进程,它会在追踪和跨度就绪时接收它们。这样,除了将追踪发送到OpenAI后端之外,你还可以执行自己的处理。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许使用你自己的追踪进程**替换**默认进程。这意味着,除非你加入能够将追踪发送到OpenAI后端的 `TracingProcessor`,否则追踪不会发送到OpenAI后端。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor]允许添加一个**额外的**追踪进程,它会在追踪和跨度准备就绪时接收它们。这样,除了将追踪发送到OpenAI后端之外,还可以执行自己的处理。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors]允许使用自己的追踪进程**替换**默认进程。这意味着,除非包含一个负责发送到OpenAI后端的`TracingProcessor`,否则追踪不会发送到OpenAI后端。 -## 非OpenAI模型追踪 +## 非OpenAI模型的追踪 -你可以对非OpenAI模型使用 OpenAI API 密钥,从而在 OpenAI追踪控制面板中启用免费追踪,而无需禁用追踪。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 +可以将OpenAI API 密钥与非OpenAI模型配合使用,从而在OpenAI追踪记录仪表板中启用免费追踪,而无需禁用追踪。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -如果只需要为单次运行使用其他追踪密钥,请通过 `RunConfig` 传入,而不要更改全局导出器。 +如果仅需为单次运行使用不同的追踪密钥,请通过`RunConfig`传入,而不要更改全局导出器。 ```python from agents import Runner, RunConfig @@ -198,18 +198,18 @@ await Runner.run( ``` ## 补充说明 -- 可在 OpenAI追踪控制面板中查看免费追踪。 +- 可在OpenAI追踪记录仪表板中查看免费的追踪记录。 ## 生态系统集成 -以下社区和供应商集成支持 OpenAI Agents SDK 的追踪接口。 +以下社区和供应商集成支持OpenAI Agents SDK追踪接口。 ### 外部追踪进程列表 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) -- [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) +- [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) - [MLflow(自托管/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) - [MLflow(Databricks 托管)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) @@ -229,9 +229,9 @@ await Runner.run( - [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) - [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) - [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/languages/integrations#openai-agents-sdk) +- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) - [Latitude](https://docs.latitude.so/telemetry/frameworks/openai-agents) -- [DProvenanceKit](https://dprovenance.dev/openai-agents/) +- [DProvenanceKit](https://dprovenance.dev/openai-agents/) \ No newline at end of file From a6cb92244211dc3845e738b2125c684906e59824 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Jul 2026 15:04:21 +0900 Subject: [PATCH 042/473] fix: redact Blaxel unmount paths in logging (#3993) --- .../extensions/sandbox/blaxel/mounts.py | 30 ++++++++++--- tests/extensions/sandbox/test_blaxel.py | 45 ++++++++++++++++++- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index d31a61edb0..dba5ecbe40 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import Any, Literal +from .... import _debug from ....logger import log_tool_action_warning from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase @@ -407,18 +408,37 @@ async def _unmount_bucket(session: BaseSandboxSession, mount_path: str) -> None: result = await _exec(session, f"fusermount -u {path}") if result.exit_code == 0: return - logger.debug("fusermount failed for %s (exit %d), trying umount", mount_path, result.exit_code) + if _debug.DONT_LOG_TOOL_DATA: + logger.debug("fusermount failed (exit %d), trying umount", result.exit_code) + else: + logger.debug( + "fusermount failed for %s (exit %d), trying umount", + mount_path, + result.exit_code, + ) # Fallback to regular umount. result = await _exec(session, f"umount {path}") if result.exit_code == 0: return - logger.debug("umount failed for %s (exit %d), trying lazy umount", mount_path, result.exit_code) + if _debug.DONT_LOG_TOOL_DATA: + logger.debug("umount failed (exit %d), trying lazy umount", result.exit_code) + else: + logger.debug( + "umount failed for %s (exit %d), trying lazy umount", + mount_path, + result.exit_code, + ) # Last resort: lazy unmount. result = await _exec(session, f"umount -l {path}") if result.exit_code != 0: - logger.warning( - "all unmount attempts failed for %s (last exit %d)", mount_path, result.exit_code - ) + if _debug.DONT_LOG_TOOL_DATA: + logger.warning("all unmount attempts failed (last exit %d)", result.exit_code) + else: + logger.warning( + "all unmount attempts failed for %s (last exit %d)", + mount_path, + result.exit_code, + ) # --------------------------------------------------------------------------- diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 97d0168ab3..fb4fda63a0 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -3628,18 +3628,59 @@ def test_drive_strategy_build_docker_volume_returns_none(self) -> None: class TestUnmountBucketLogging: @pytest.mark.asyncio - async def test_unmount_all_attempts_fail_logs_warning(self) -> None: + @pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(False, True), (True, True), (False, False), (True, False)], + ) + async def test_unmount_all_attempts_follow_tool_data_policy( + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + model_redacted: bool, + tool_redacted: bool, + ) -> None: from agents.extensions.sandbox.blaxel.mounts import _unmount_bucket + mount_path = "/mnt/SECRET_BUCKET_PATH" session = _FakeMountSession() session._next_results = [ _FakeExecResultForMount(exit_code=1), # fusermount fails _FakeExecResultForMount(exit_code=1), # umount fails _FakeExecResultForMount(exit_code=1), # umount -l fails ] + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + caplog.set_level(logging.DEBUG, logger="agents.extensions.sandbox.blaxel.mounts") + # Should not raise, just log warning. - await _unmount_bucket(session, "/mnt/test") # type: ignore[arg-type] + await _unmount_bucket(session, mount_path) # type: ignore[arg-type] + assert len(session.exec_calls) == 3 + records = [ + record + for record in caplog.records + if record.name == "agents.extensions.sandbox.blaxel.mounts" + ] + assert len(records) == 3 + assert [record.levelno for record in records] == [ + logging.DEBUG, + logging.DEBUG, + logging.WARNING, + ] + for record in records: + assert record.exc_info is None + assert record.exc_text is None + assert all(value is not mount_path for value in record.__dict__.values()) + + rendered = [logging.Formatter().format(record) for record in records] + if tool_redacted: + assert all(mount_path not in message for message in rendered) + assert [record.args for record in records] == [(1,), (1,), (1,)] + else: + assert all(mount_path in message for message in rendered) + for record in records: + assert isinstance(record.args, tuple) + assert mount_path in record.args # --------------------------------------------------------------------------- From 65db9a7eacd9d1ecadcd9f7bf5dd703b717fb54d Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 28 Jul 2026 01:14:12 -0500 Subject: [PATCH 043/473] fix(run): allow empty streamed model input (#3995) --- src/agents/run_internal/run_loop.py | 2 -- tests/test_agent_runner_streamed.py | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 4873ed542d..168d646876 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1488,8 +1488,6 @@ def _tool_search_fingerprint(raw_item: Any) -> str: # Track only the items actually sent after call_model_input_filter runs. Retry helpers # explicitly rewind this state before replaying a failed request. server_conversation_tracker.mark_input_as_sent(filtered.input) - if not filtered.input and server_conversation_tracker is None: - raise RuntimeError("Prepared model input is empty") await asyncio.gather( hooks.on_llm_start(context_wrapper, public_agent, filtered.instructions, filtered.input), diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index b2f9e6e027..2ba360d2ef 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -140,6 +140,20 @@ async def test_simple_first_run(): assert len(result.to_input_list()) == 3, "should have original input and generated item" +@pytest.mark.asyncio +async def test_empty_list_input_reaches_model(): + model = FakeModel() + agent = Agent(name="test", model=model) + model.set_next_output([get_text_message("first")]) + + result = Runner.run_streamed(agent, input=[]) + async for _ in result.stream_events(): + pass + + assert result.final_output == "first" + assert model.last_turn_args["input"] == [] + + @pytest.mark.asyncio async def test_streamed_tool_not_found_behavior_returns_error_to_model() -> None: model = FakeModel() From ba58983a178c772c96fb501a61b5e9940028dc06 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 28 Jul 2026 01:17:07 -0500 Subject: [PATCH 044/473] fix(models): route falsey mapped providers (#3996) --- src/agents/models/multi_provider.py | 5 ++++- tests/models/test_map.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/agents/models/multi_provider.py b/src/agents/models/multi_provider.py index ccb644edc2..41cbeef69f 100644 --- a/src/agents/models/multi_provider.py +++ b/src/agents/models/multi_provider.py @@ -204,7 +204,10 @@ def _resolve_prefixed_model( ) -> tuple[ModelProvider, str | None]: # Explicit provider_map entries are the least surprising routing mechanism, so they always # win over the built-in OpenAI alias and unknown-prefix fallback behavior. - if self.provider_map and (provider := self.provider_map.get_provider(prefix)): + if ( + self.provider_map is not None + and (provider := self.provider_map.get_provider(prefix)) is not None + ): return provider, stripped_model_name if prefix in {"litellm", "any-llm"}: diff --git a/tests/models/test_map.py b/tests/models/test_map.py index 6bba822d13..20f1cf1469 100644 --- a/tests/models/test_map.py +++ b/tests/models/test_map.py @@ -198,6 +198,27 @@ def get_model(self, model_name): assert captured_model["value"] == "gpt-4o" +def test_provider_map_routes_to_falsey_provider(): + captured_model: dict[str, Any] = {} + expected_model = object() + + class FalseyProvider: + def __bool__(self) -> bool: + return False + + def get_model(self, model_name: str | None): + captured_model["value"] = model_name + return expected_model + + provider_map = MultiProviderMap() + provider_map.add_provider("custom", cast(Any, FalseyProvider())) + + result = MultiProvider(provider_map=provider_map).get_model("custom/test-model") + + assert result is expected_model + assert captured_model["value"] == "test-model" + + def test_multi_provider_rejects_invalid_prefix_modes(): bad_openai_prefix_mode: Any = "invalid" bad_unknown_prefix_mode: Any = "invalid" From f1becff0b8e6785934d29de2e63418d58dcb93a2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Jul 2026 15:08:39 +0900 Subject: [PATCH 045/473] docs: update missing info --- docs/models/index.md | 4 ++++ docs/release.md | 2 +- docs/running_agents.md | 2 ++ mkdocs.yml | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/models/index.md b/docs/models/index.md index 2c274ae141..819e02582a 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -232,6 +232,8 @@ If you use a custom OpenAI-compatible endpoint or proxy, websocket transport als - You can use [`Runner.run_streamed()`][agents.run.Runner.run_streamed] directly after enabling websocket transport. For multi-turn workflows where you want to reuse the same websocket connection across turns (and nested agent-as-tool calls), the [`responses_websocket_session()`][agents.responses_websocket_session] helper is recommended. See the [Running agents](../running_agents.md) guide and [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py). - For long reasoning turns or networks with latency spikes, customize websocket keepalive behavior with `responses_websocket_options`. Increase `ping_timeout` to tolerate delayed pong frames, or set `ping_timeout=None` to disable heartbeat timeouts while keeping pings enabled. Prefer HTTP/SSE transport when reliability is more important than websocket latency. - By default the SDK disables the incoming message-size limit (`max_size=None`). For long-lived agent processes behind proxies or in memory-constrained containers, set `responses_websocket_options={"max_size": 8 * 1024 * 1024}` to bound per-message memory usage. +- The [Responses API WebSocket service](https://developers.openai.com/api/docs/guides/websocket-mode) processes one response at a time on each connection and limits each connection to 60 minutes. Open a new connection after that limit; use multiple connections when you need parallel runs. +- The service keeps only the most recent response in connection-local memory. A failed `4xx` or `5xx` turn evicts the referenced `previous_response_id`. After reconnecting, a stored response can still be continued when available, but `store=False` and ZDR flows have no persisted fallback. Start a new chain with `previous_response_id=None` and send the full input context, or rebuild that context from locally managed session state. ### Hosted multi-agent (experimental) @@ -496,6 +498,8 @@ english_agent = Agent( Retries are runtime-only and opt in. The SDK does not retry general model requests unless you set `ModelSettings(retry=...)` and your retry policy chooses to retry. +On the Responses websocket transport, `retry_policies.provider_suggested()` recognizes pre-response overload frames and code-less `server_error` frames as retry suggestions. This does not enable retries by itself: you still need `ModelRetrySettings`, and the normal replay-safety checks still apply. If any response event has already arrived, the SDK does not replay the request. + ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies diff --git a/docs/release.md b/docs/release.md index 472bf5db39..468575e64f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -29,7 +29,7 @@ Highlights: - Added the public `agents.decorators` module and the shorter `@tool` alias alongside the existing function and guardrail decorators. Function tools now also support async callable objects. - SDK configuration now consistently accepts either typed settings objects or dictionaries across agents, runs, models, sessions, sandboxes, and voice pipelines, with validation for unknown settings. - Hardened error and diagnostic logging across models, tools, MCP, Realtime, sessions, sandboxes, and tracing to avoid exposing raw sensitive payloads while preserving useful debugging context. -- Improved AnyLLM, LiteLLM, and Chat Completions compatibility, preserved session history across model retries, and added retries for WebSocket overloads that occur before a response starts. +- Improved AnyLLM, LiteLLM, and Chat Completions compatibility, preserved session history across model retries, and added provider retry guidance for WebSocket overloads that occur before a response starts so opt-in Runner retry policies can act when replay is permitted. - Added [create-time-only S3 mounts for Vercel sandboxes](sandbox/clients.md#mounts-and-remote-storage) through `VercelCloudBucketMountStrategy`. Mounted sessions exclude bucket contents from workspace persistence and intentionally do not support dynamic mount changes or session resume. ### 0.18.0 diff --git a/docs/running_agents.md b/docs/running_agents.md index 866c50bec9..593d73e768 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -117,6 +117,8 @@ asyncio.run(main()) Finish consuming streamed results before the context exits. Exiting the context while a websocket request is still in flight may force-close the shared connection. +The service processes one response at a time on each websocket connection and limits a connection to 60 minutes. The helper reuses the connection but does not remove those constraints. After a reconnect, `store=False` and ZDR flows cannot recover an uncached `previous_response_id`; start a new chain with full input context or rebuild it from locally managed session state. See the [Responses WebSocket transport notes](models/index.md#responses-websocket-transport) for the full recovery behavior. + If long reasoning turns hit websocket keepalive timeouts, increase `ping_timeout` or set `ping_timeout=None` to disable heartbeat timeouts. Use HTTP/SSE transport for runs where reliability matters more than websocket latency. ### Run config diff --git a/mkdocs.yml b/mkdocs.yml index c38e747653..dd4aa2f33a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,6 +122,7 @@ plugins: - Memory: ref/memory.md - REPL: ref/repl.md - Tools: ref/tool.md + - Decorators: ref/decorators.md - Tool context: ref/tool_context.md - Results: ref/result.md - Streaming events: ref/stream_events.md From bb3d64e74d9b92831aaa7e10cecbb0bfd6fa50c1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Jul 2026 15:32:46 +0900 Subject: [PATCH 046/473] docs: update translated pages --- docs/ja/models/index.md | 262 ++++++++++++++++++------------------ docs/ja/release.md | 126 ++++++++--------- docs/ja/running_agents.md | 212 ++++++++++++++--------------- docs/ko/models/index.md | 275 ++++++++++++++++++------------------- docs/ko/release.md | 114 ++++++++-------- docs/ko/running_agents.md | 200 +++++++++++++-------------- docs/zh/models/index.md | 276 +++++++++++++++++++------------------- docs/zh/release.md | 120 ++++++++--------- docs/zh/running_agents.md | 224 ++++++++++++++++--------------- 9 files changed, 913 insertions(+), 896 deletions(-) diff --git a/docs/ja/models/index.md b/docs/ja/models/index.md index 36c98a6569..a649fa1cdb 100644 --- a/docs/ja/models/index.md +++ b/docs/ja/models/index.md @@ -4,43 +4,43 @@ search: --- # モデル -Agents SDK には、OpenAI モデルを利用するための次の 2 種類のサポートが標準で用意されています。 +Agents SDK は、すぐに利用できる OpenAI モデルを 2 種類サポートしています。 - **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] - [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] ## モデル設定の選択 -まずは、設定に適した最もシンプルな方法を選択してください。 +まず、設定に適した最もシンプルな方法を選択してください。 -| 実現したいこと | 推奨方法 | 詳細 | +| 実現したいこと | 推奨される方法 | 詳細 | | --- | --- | --- | | OpenAI モデルのみを使用する | Responses モデルの経路でデフォルトの OpenAI プロバイダーを使用する | [OpenAI モデル](#openai-models) | -| WebSocket トランスポート経由で OpenAI Responses API を使用する | Responses モデルの経路を維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | -| OpenAI がホストするサブエージェントを使用する | 実験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | +| websocket トランスポート経由で OpenAI Responses API を使用する | Responses モデルの経路を維持し、websocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | +| OpenAI がホストするサブエージェントを使用する | 試験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | | OpenAI 以外のプロバイダーを 1 つ使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI 以外のモデル](#non-openai-models) | -| エージェント間でモデルやプロバイダーを混在させる | 実行単位またはエージェント単位でプロバイダーを選択し、機能の違いを確認する | [1 つのワークフロー内でのモデルの混在](#mixing-models-in-one-workflow)および[プロバイダー間でのモデルの混在](#mixing-models-across-providers) | +| エージェント間でモデルまたはプロバイダーを組み合わせる | 実行単位またはエージェント単位でプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[プロバイダーをまたぐモデルの組み合わせ](#mixing-models-across-providers) | | OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses の経路で `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | -| OpenAI 以外のプロバイダー、または複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポート対象のベータ版アダプターを比較し、リリース予定のプロバイダー経路を検証する | [サードパーティ製アダプター](#third-party-adapters) | +| OpenAI 以外のプロバイダーまたは複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポート対象のベータ版アダプターを比較し、リリース予定のプロバイダー経路を検証する | [サードパーティ製アダプター](#third-party-adapters) | ## OpenAI モデル -OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルの経路を維持する方法を推奨します。 +OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルの経路を維持することを推奨します。 -`Agent` の初期化時にモデルを指定しない場合は、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` および `verbosity="low"` が設定された [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。利用できる場合は、明示的な `model_settings` を維持しながら、より高い品質を得るためにエージェントを `gpt-5.6-sol` に設定することを推奨します。 +`Agent` の初期化時にモデルを指定しない場合、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` と `verbosity="low"` を設定した [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。利用可能な場合は、明示的な `model_settings` を維持しながら、品質向上のためにエージェントを `gpt-5.6-sol` に設定することを推奨します。 -`gpt-5.6-sol` などの別のモデルへ切り替える場合、エージェントを設定する方法は 2 つあります。 +`gpt-5.6-sol` などの別のモデルに切り替える場合、エージェントを設定する方法は 2 つあります。 ### デフォルトモデル -まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用するには、エージェントを実行する前に `OPENAI_DEFAULT_MODEL` 環境変数を設定します。 +まず、カスタムモデルが設定されていないすべてのエージェントで特定のモデルを一貫して使用するには、エージェントを実行する前に `OPENAI_DEFAULT_MODEL` 環境変数を設定します。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -次に、`RunConfig` を使用して実行のデフォルトモデルを設定できます。エージェントにモデルを設定していない場合、この実行のモデルが使用されます。 +次に、`RunConfig` を使用して実行のデフォルトモデルを設定できます。エージェントにモデルを設定しない場合、その実行のモデルが使用されます。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 モデル -この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースで最適に動作する設定が適用されます。デフォルトモデルの推論エフォートを調整するには、独自の `ModelSettings` を渡します。 +この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースに最適な設定が適用されます。デフォルトモデルの推論エフォートを調整するには、独自の `ModelSettings` を渡します。 ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -レイテンシーを低くするには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 +レイテンシーを低減するには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 -GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、永続化された推論コンテキスト、および `"max"` エフォートレベルもサポートします。これらの制御は Responses API の経路で使用できます。 +GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、永続化された推論コンテキスト、および `"max"` エフォートレベルもサポートします。これらの制御は Responses API の経路で利用できます。 ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされるエフォートレベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` エフォートには Responses API を使用してください。Chat Completions アダプターは警告を出してモードとコンテキストを無視します。この警告をエラーにするには、OpenAI プロバイダーで `strict_feature_validation=True` を設定してください。 +`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions は `reasoning.effort` のみを使用し、サポートされるエフォートレベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` エフォートには Responses API を使用してください。Chat Completions アダプターは警告を出してモードとコンテキストを無視します。その警告をエラーにするには、OpenAI プロバイダーで `strict_feature_validation=True` を設定します。 -`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の会話、または以前の推論項目の再送によって会話を保持してください。ステートレスな `store=False` 呼び出しでは、レスポンスに `reasoning.encrypted_content` を含め、次のリクエストでそれらの推論項目を再送してください。 +`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の会話、または以前の推論項目の再送により会話を維持します。ステートレスな `store=False` 呼び出しでは、レスポンスに `reasoning.encrypted_content` を含め、次のリクエストでそれらの推論項目を再送してください。 #### ComputerTool のモデル選択 -エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれている場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは GA 版の組み込み `computer` ツールが使用され、明示的な `computer-use-preview` リクエストでは従来の `computer_use_preview` ペイロードが維持されます。 +エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは GA の組み込み `computer` ツールが使用されますが、明示的な `computer-use-preview` リクエストでは従来の `computer_use_preview` ペイロードが維持されます。 -主な例外は、プロンプトで管理される呼び出しです。プロンプトテンプレート側でモデルが指定され、SDK がリクエストから `model` を省略する場合、SDK はプロンプトに固定されたモデルを推測しないよう、プレビュー互換のコンピューターペイロードをデフォルトで使用します。このフローで GA 版の経路を維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制します。 +主な例外は、プロンプトによって管理される呼び出しです。プロンプトテンプレートがモデルを保持し、SDK がリクエストから `model` を省略する場合、プロンプトに固定されたモデルを SDK が推測しないように、プレビュー互換のコンピューターペイロードがデフォルトで使用されます。このフローで GA の経路を維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制します。 -[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は通常の関数名として引き続き動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに対応する組み込みセレクターに正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 -プレビュー互換のリクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具象 `Computer` または `AsyncComputer` インスタンスを渡すか、リクエスト送信前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 +プレビュー互換リクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエスト送信前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 #### GPT-5 以外のモデル -カスタム `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はあらゆるモデルと互換性のある汎用 `ModelSettings` に戻します。 +カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK は任意のモデルと互換性のある汎用的な `ModelSettings` に戻ります。 ### Responses 専用のツール機能 -次のツール機能は、OpenAI Responses モデルでのみサポートされます。 +次のツール機能は、OpenAI Responses モデルでのみサポートされています。 - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` および遅延読み込みを使用するその他の Responses ツールサーフェス +- `@function_tool(defer_loading=True)` およびその他の遅延読み込み対応 Responses ツールサーフェス - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`、および `tool_choice="programmatic_tool_calling"` -これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、単独の名前空間名や遅延読み込み専用の関数名を強制する代わりに、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませてください。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 +これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、修飾されていない名前空間名や遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 ### Responses WebSocket トランスポート -デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI を基盤とするモデルを使用する場合は、WebSocket トランスポートをオプトインで有効にできます。 +デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI ベースのモデルを使用する場合は、websocket トランスポートを有効にできます。 #### 基本設定 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデルに影響します。`"gpt-5.6-sol"` などの文字列のモデル名も含まれます。 +これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデル(`"gpt-5.6-sol"` などの文字列モデル名を含む)に影響します。 -トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具象 [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions を使用します。`RunConfig(model_provider=...)` を渡した場合、グローバルデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 +トランスポートの選択は、SDK がモデル名をモデルインスタンスに解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は websocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡す場合、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 #### プロバイダー単位または実行単位の設定 -プロバイダー単位または実行単位で WebSocket トランスポートを設定することもできます。 +プロバイダー単位または実行単位で websocket トランスポートを設定することもできます。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI を基盤とするプロバイダーでは、オプションのエージェント登録設定も使用できます。これは、ハーネス ID など、プロバイダー単位の登録メタデータを OpenAI の設定で必要とする場合の高度なオプションです。 +OpenAI ベースのプロバイダーでは、オプションのエージェント登録設定も使用できます。これは、OpenAI の設定でハーネス ID などのプロバイダー単位の登録メタデータが必要な場合に使用する高度なオプションです。 ```python from agents import ( @@ -188,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider` を使用した高度なルーティング +#### `MultiProvider` による高度なルーティング -プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を混在させる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 +プレフィックスベースのモデルルーティングが必要な場合(たとえば、1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合)、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 -`MultiProvider` は、従来からの次の 2 つのデフォルト動作を維持します。 +`MultiProvider` には、歴史的なデフォルトが 2 つあります。 - `openai/...` は OpenAI プロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 -- 不明なプレフィックスはそのまま渡されず、`UserError` が発生します。 +- 不明なプレフィックスは、そのまま渡されるのではなく `UserError` を発生させます。 -リテラルの名前空間付きモデル ID を必要とする OpenAI 互換エンドポイントへ OpenAI プロバイダーを接続する場合は、パススルー動作を明示的に有効にしてください。WebSocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を設定したままにします。 +OpenAI プロバイダーを、リテラルの名前空間付きモデル ID を必要とする OpenAI 互換エンドポイントに接続する場合は、パススルー動作を明示的に有効にします。websocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を設定してください。 ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,29 +223,31 @@ result = await Runner.run( ) ``` -バックエンドがリテラルの `openai/...` 文字列を必要とする場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を必要とする場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも使用できます。この例では、このセクションで説明するトランスポート設定の一部であるため、WebSocket を有効にしたままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも使用できます。 +バックエンドがリテラルの `openai/...` 文字列を必要とする場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を必要とする場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、websocket トランスポート外の `MultiProvider` でも機能します。この例では、このセクションで説明しているトランスポート設定の一部であるため、websocket を有効なままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 -`MultiProvider` 経由でルーティングする際に同じプロバイダー単位の登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡すと、基盤となる OpenAI プロバイダーへ転送されます。 +`MultiProvider` を通じてルーティングする際に、同じプロバイダー単位の登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡すと、基盤となる OpenAI プロバイダーに転送されます。 -カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 +カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、websocket トランスポートにも互換性のある websocket `/responses` エンドポイントが必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 #### 注意事項 -- これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions や OpenAI 以外のプロバイダーには、それらが Responses WebSocket `/responses` エンドポイントをサポートしていない限り適用されません。 -- 環境にまだインストールされていない場合は、`websockets` パッケージをインストールしてください。 -- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンにわたり同じ WebSocket 接続を再利用するワークフローでは、ネストされたエージェントをツールとして使用する呼び出しも含め、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 -- 長時間の推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズしてください。遅延した pong フレームを許容するには `ping_timeout` を増やすか、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを選択してください。 -- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後で動作する長寿命のエージェントプロセスや、メモリ制約のあるコンテナーでは、`responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定して、メッセージ単位のメモリ使用量に上限を設けてください。 +- これは websocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions や OpenAI 以外のプロバイダーには、Responses websocket `/responses` エンドポイントをサポートしていない限り適用されません。 +- 環境にまだ存在しない場合は、`websockets` パッケージをインストールしてください。 +- websocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンのワークフローで、ターン間(およびネストされた Agents-as-tools 呼び出し)に同じ websocket 接続を再利用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 +- 長時間の推論ターンやレイテンシーの急増が発生するネットワークでは、`responses_websocket_options` を使用して websocket のキープアライブ動作をカスタマイズします。遅延した pong フレームを許容するには `ping_timeout` を増やすか、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。websocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを選択してください。 +- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後にある長時間稼働エージェントプロセスや、メモリが制限されたコンテナでは、`responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定して、メッセージ単位のメモリ使用量に上限を設けます。 +- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。この制限に達したら新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用します。 +- サービスは、接続ローカルのメモリに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、参照された `previous_response_id` が削除されます。再接続後も、保存済みのレスポンスが利用可能であれば続行できますが、`store=False` および ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` を使用して新しいチェーンを開始し、完全な入力コンテキストを送信するか、ローカルで管理されるセッション状態からそのコンテキストを再構築してください。 -### ホスト型マルチエージェント(実験的) +### ホスト型マルチエージェント(試験的) -OpenAI Responses API のホスト型マルチエージェントベータでは、GPT-5.6 のルートモデルがサーバーでホストされるサブエージェントを作成し、連携させることができます。Agents SDK は通常の `Runner` を引き続き使用できます。ホスト型オーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 +OpenAI Responses API のホスト型マルチエージェントベータでは、GPT-5.6 のルートモデルがサーバーでホストされるサブエージェントを作成して調整できます。Agents SDK は通常の `Runner` を引き続き使用できます。ホスト型オーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 -この統合は実験的であり、ローカル関数の出力を `response.inject` によってアクティブなホスト型エージェントへ返せるよう、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開するベータビルドを含む `openai[realtime]>=2.45.0` が必要です。インターフェースとベータ版の項目スキーマは、一般提供前に変更される可能性があります。 +この統合は試験的であり、ローカル関数の出力を `response.inject` によってアクティブなホスト型エージェントへ返せるように、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開するベータビルドを含む `openai[realtime]>=2.45.0` が必要です。インターフェースとベータ版の項目スキーマは、一般提供前に変更される可能性があります。 #### モデルの設定 -実験的モジュールからモデルをインポートし、SDK の `Agent` に割り当てます。 +試験的モジュールからモデルをインポートし、SDK の `Agent` に割り当てます。 ```python from agents import Agent @@ -262,9 +264,9 @@ agent = Agent( #### ローカル関数ツール -すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスへ注入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、Runner の通常のガードレール、フック、および失敗変換が引き続き適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 +すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは、Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスに注入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、Runner の通常のガードレール、フック、および失敗変換が引き続き適用されます。SDK ツールの承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 -ツールで呼び出し元を考慮したログ記録や認可が必要な場合は、`get_hosted_agent_metadata()` を使用します。 +ツールで呼び出し元を考慮したログ記録または認可が必要な場合は、`get_hosted_agent_metadata()` を使用します。 ```python from typing import Any @@ -281,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -ホスト型エージェント名は観測用メタデータであり、ローカルのルーティングメカニズムではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、必要な認可をツール実行前または実行中にアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えて送受信されます。 +ホスト型エージェントの名前は観測用メタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、ツールの実行前または実行中に、必要な認可をアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 #### 出力とストリーミングの動作 -フェーズが `final_answer` で、`/root` に帰属するメッセージのみが通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がこれらのレコードをローカル関数として実行することはありません。 +`final_answer` フェーズを持ち、`/root` に帰属するメッセージのみが通常の最終メッセージになります。試験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がそれらのレコードをローカル関数として実行することはありません。 -raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含む、ベータ版の Responses イベントが引き続き公開されます。関数呼び出しの準備が整うと、アダプターは 1 つのアクティブなプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。帰属を確認するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用してください。 +raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含む、ベータ版 Responses イベントが引き続き公開されます。アダプターは、関数呼び出しの準備が整った時点で、アクティブな 1 つのプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。帰属情報を確認するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用します。 #### SDK オーケストレーションとの関係 -ホスト型マルチエージェントは、SDK のハンドオフおよび agents-as-tools とは異なります。 +ホスト型マルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 -- ホスト型マルチエージェントは、OpenAI サービス上にサブエージェントを作成します。アプリケーションがこれらのサブエージェントを作成またはスケジュールすることはありません。 -- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この実験的モデルを使用する場合、すべてのホスト型エージェントが同じハンドオフツールを受け取って所有権の競合が生じるため、ハンドオフは拒否されます。 -- agents-as-tools は引き続き使用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 +- ホスト型マルチエージェントは、OpenAI サービス上でサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 +- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この試験的モデルを使用する場合は、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が発生するため、ハンドオフは拒否されます。 +- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 #### 現在の制限事項 -実験的モデルでは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドが拒否されます。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスが各ホスト型エージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 +試験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスがホスト型エージェントごとのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 -1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に所有できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待機している間に実行を放棄する場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 +1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に保持できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待機している間に実行を中止した場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 基盤となる Responses API ベータ版の動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 ## OpenAI 以外のモデル -OpenAI 以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくても十分です。各パターンのコード例は [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 +OpenAI 以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくても、これで十分です。各パターンのコード例は [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 ### OpenAI 以外のプロバイダーの統合方法 | 方法 | 使用する状況 | 適用範囲 | | --- | --- | --- | | [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI 互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを 1 回の実行に適用する場合 | 実行単位 | -| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具象モデルオブジェクトが必要な場合 | エージェント単位 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行単位 | +| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェント単位 | | サードパーティ製アダプター | 組み込みの経路では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合 | [サードパーティ製アダプター](#third-party-adapters)を参照 | 次の組み込みの経路を使用して、他の LLM プロバイダーを統合できます。 -1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーが OpenAI 互換 API エンドポイントを備え、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで適用されます。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 -3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスにモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせて使用できます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 +1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーに OpenAI 互換の API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで使用します。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 +3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスにモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせられます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 -`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` でトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 +`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` を使用してトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -339,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。使用する LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 + これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 -## 1 つのワークフロー内でのモデルの混在 +## 1 つのワークフローでのモデルの組み合わせ -1 つのワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する際は、次のいずれかの方法で特定のモデルを選択できます。 +単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する際は、次のいずれかの方法で特定のモデルを選択できます。 1. モデル名を渡します。 -2. 任意のモデル名と、その名前を Model インスタンスへマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡します。 +2. 任意のモデル名と、その名前を Model インスタンスにマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡します。 3. [`Model`][agents.models.interface.Model] の実装を直接指定します。 !!! note - SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、この 2 つの形式ではサポートする機能とツールのセットが異なるため、ワークフローごとに 1 つのモデル形式を使用することを推奨します。ワークフローで複数のモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 + SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、この 2 つの形式ではサポートされる機能とツールが異なるため、ワークフローごとに 1 つのモデル形式を使用することを推奨します。ワークフローで複数のモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用可能であることを確認してください。 ```python import asyncio @@ -392,7 +394,7 @@ if __name__ == "__main__": 1. OpenAI モデルの名前を直接設定します。 2. [`Model`][agents.models.interface.Model] の実装を指定します。 -エージェントで使用するモデルをさらに設定する場合は、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.models.interface.ModelSettings] を渡せます。 +エージェントで使用するモデルをさらに設定するには、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。 ```python from agents import Agent, ModelSettings @@ -411,18 +413,18 @@ OpenAI Responses の経路を使用していて、より詳細な制御が必要 ### 一般的な高度な `ModelSettings` オプション -OpenAI Responses API を使用する場合、複数のリクエストフィールドにはすでに対応する `ModelSettings` フィールドがあるため、それらに `extra_args` を使用する必要はありません。 +OpenAI Responses API を使用する場合、いくつかのリクエストフィールドには対応する `ModelSettings` フィールドがすでに用意されているため、それらに `extra_args` を使用する必要はありません。 - `parallel_tool_calls`: 同じターン内で複数のツール呼び出しを許可または禁止します。 -- `truncation`: コンテキストが上限を超える場合に失敗する代わりに、Responses API が最も古い会話項目を削除できるようにするには、`"auto"` を設定します。 -- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローで重要です。 +- `truncation`: コンテキストが上限を超える場合に失敗させるのではなく、Responses API に最も古い会話項目を削除させるには、`"auto"` を設定します。 +- `store`: 生成されたレスポンスを後から取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに影響します。 - `context_management`: `compact_threshold` を使用した Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 -- `prompt_cache_retention`: 以前のモデルファミリー向けの延長保持期間を、たとえば - `"24h"` で設定します。 +- `prompt_cache_retention`: 以前のモデルファミリー向けに、たとえば + `"24h"` を使用して保持期間の延長を設定します。 - `prompt_cache_options`: 暗黙的または明示的なプロンプトキャッシュを選択し、GPT-5.6 では `"30m"` のキャッシュ TTL を設定します。 -- `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードを要求します。 -- `top_logprobs`: 出力テキストについて上位トークンの logprobs を要求します。SDK は `message.output_text.logprobs` も自動的に追加します。 -- `retry`: モデル呼び出しに対して Runner が管理する再試行設定をオプトインで有効にします。[Runner が管理する再試行](#runner-managed-retries)を参照してください。 +- `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードをリクエストします。 +- `top_logprobs`: 出力テキストの上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 +- `retry`: モデル呼び出しに対する Runner 管理の再試行設定を有効にします。[Runner 管理の再試行](#runner-managed-retries)を参照してください。 ```python from agents import Agent, ModelSettings @@ -442,7 +444,7 @@ research_agent = Agent( ) ``` -明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストにそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、ファイルの各コンテンツ部分に設定されたブレークポイントを保持します。 +明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾にあるコンテンツ部分へブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses および Chat Completions のリクエストに渡され、Chat Completions コンバーターはテキスト、画像、音声、ファイルの各コンテンツ部分にあるブレークポイントを維持します。 ```python from agents import Runner @@ -468,18 +470,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーで引き続き利用できます。 +`prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーでも引き続き利用できます。 `ModelSettings` の直接フィールドと、`extra_args` 内の同じキーを併用しないでください。 -`store=False` を設定すると、Responses API は後でサーバー側から取得できるようにそのレスポンスを保持しません。これは、ステートレスまたはゼロデータ保持形式のフローに便利ですが、通常はレスポンス ID を再利用する機能が、代わりにローカルで管理される状態へ依存する必要があることも意味します。たとえば、最後のレスポンスが保存されていない場合、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] はデフォルトの `"auto"` 圧縮経路を入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 +`store=False` を設定すると、Responses API はそのレスポンスを後からサーバー側で取得できるようには保持しません。これはステートレスまたはゼロデータ保持形式のフローに役立ちますが、通常であればレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、最後のレスポンスが保存されなかった場合、デフォルトの `"auto"` 圧縮経路を入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間でスタンドアロンの `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 +サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間で独立した `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 ### `extra_args` の受け渡し -SDK がトップレベルでまだ直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 +SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 -また、OpenAI の Responses API を使用する場合、[その他にもいくつかのオプションパラメーターがあります](https://platform.openai.com/docs/api-reference/responses/create)(`user`、`service_tier` など)。トップレベルで利用できない場合は、`extra_args` を使用して渡すこともできます。同じリクエストフィールドを `ModelSettings` の直接フィールドでも設定しないでください。 +また、OpenAI の Responses API を使用する場合、[その他のオプションパラメーターもいくつかあります](https://platform.openai.com/docs/api-reference/responses/create)(例: `user`、`service_tier` など)。トップレベルで利用できない場合は、`extra_args` を使用してこれらを渡すこともできます。同じリクエストフィールドを `ModelSettings` の直接フィールドでも設定しないでください。 ```python from agents import Agent, ModelSettings @@ -495,9 +497,11 @@ english_agent = Agent( ) ``` -## Runner が管理する再試行 +## Runner 管理の再試行 -再試行はランタイム専用であり、オプトインです。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は通常のモデルリクエストを再試行しません。 +再試行は実行時のみ有効で、明示的な有効化が必要です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 + +Responses websocket トランスポートでは、`retry_policies.provider_suggested()` はレスポンス前の過負荷フレームと、コードのない `server_error` フレームを再試行の提案として認識します。これだけでは再試行は有効になりません。引き続き `ModelRetrySettings` が必要で、通常の再送安全性チェックも適用されます。レスポンスイベントが 1 つでも到着した後は、SDK はリクエストを再送しません。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -532,63 +536,63 @@ agent = Agent( | フィールド | 型 | 注意事項 | | --- | --- | --- | | `max_retries` | `int | None` | 最初のリクエスト後に許可される再試行回数です。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合のデフォルトの遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみを制限します。ポリシーが返す明示的な遅延や retry-after ヒントは制限しません。 | -| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドはランタイム専用であり、シリアライズされません。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合の、デフォルトの遅延戦略です。`backoff.max_delay` は、この方法で計算されるバックオフ遅延のみを制限します。ポリシーが返す明示的な遅延や retry-after ヒントは制限しません。 | +| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドは実行時専用であり、シリアライズされません。 | 再試行ポリシーは、次の情報を持つ [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 -- `attempt` と `max_retries`: 試行回数を考慮した判断を行えます。 -- `stream`: ストリーミングと非ストリーミングで動作を分岐できます。 -- `error`: raw の情報を確認できます。 +- `attempt` と `max_retries`: 試行回数を考慮した判断に使用できます。 +- `stream`: ストリーミングと非ストリーミングの動作を分岐できます。 +- `error`: raw の内容を確認できます。 - `normalized`: `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの正規化された情報です。 -- `provider_advice`: 基盤となるモデルアダプターが再試行に関する指針を提供できる場合に設定されます。 +- `provider_advice`: 基盤となるモデルアダプターが再試行のガイダンスを提供できる場合に設定されます。 -ポリシーは、次のいずれかを返せます。 +ポリシーは次のいずれかを返せます。 -- 単純に再試行するかどうかを決定する `True`/`False` -- 遅延を上書きしたり診断上の理由を付加したりする場合の [`RetryDecision`][agents.retry.RetryDecision] +- 単純に再試行を判断する `True`/`False` +- 遅延を上書きしたり診断用の理由を付加したりする場合の [`RetryDecision`][agents.retry.RetryDecision] -SDK は、すぐに使用できるヘルパーを `retry_policies` で公開しています。 +SDK は、`retry_policies` で既製のヘルパーを公開しています。 | ヘルパー | 動作 | | --- | --- | | `retry_policies.never()` | 常に再試行しません。 | -| `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行に関する指針に従います。 | -| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウト障害に一致します。 | +| `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行アドバイスに従います。 | +| `retry_policies.network_error()` | 一時的なトランスポート障害とタイムアウトに一致します。 | | `retry_policies.http_status([...])` | 選択した HTTP ステータスコードに一致します。 | -| `retry_policies.retry_after()` | retry-after ヒントが利用できる場合のみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` はその値を制限しません。 | +| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合にのみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` はその値を制限しません。 | | `retry_policies.any(...)` | ネストされたポリシーのいずれかが再試行を選択した場合に再試行します。 | -| `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合のみ再試行します。 | +| `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合にのみ再試行します。 | -ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の基本要素です。これは、プロバイダーが区別できる場合に、プロバイダーによる拒否判断とリプレイ安全性の承認を維持するためです。 +ポリシーを組み合わせる場合、プロバイダーが拒否判断と再送安全性の承認を区別できるときにそれらを維持するため、最初の構成要素としては `provider_suggested()` が最も安全です。 ##### 安全性の境界 一部の障害は自動的に再試行されません。 -- 中断エラー -- プロバイダーの指針でリプレイが安全でないと判断されたリクエスト -- 出力がすでに開始され、リプレイが安全でなくなるストリーミング実行 +- 中止エラー +- プロバイダーのアドバイスで再送が安全でないと判断されたリクエスト +- 出力がすでに開始され、再送が安全でなくなるストリーミング実行 -`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` などのプロバイダーに依存しない述語だけでは不十分です。再試行ポリシーには、通常 `retry_policies.provider_suggested()` を通じて、プロバイダーによるリプレイ安全性の承認を含める必要があります。 +`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` など、プロバイダーに依存しない述語だけでは不十分です。再試行ポリシーには、通常は `retry_policies.provider_suggested()` を使用して、プロバイダーによる再送安全性の承認を含める必要があります。 ##### Runner とエージェントのマージ動作 -`retry` は、Runner レベルとエージェントレベルの `ModelSettings` 間でディープマージされます。 +`retry` は、Runner レベルとエージェントレベルの `ModelSettings` の間でディープマージされます。 - エージェントは `retry.max_retries` のみを上書きしながら、Runner の `policy` を継承できます。 -- エージェントは `retry.backoff` の一部のみを上書きし、Runner の他のバックオフフィールドを維持できます。 -- `policy` はランタイム専用であるため、シリアライズされた `ModelSettings` には `max_retries` と `backoff` が保持されますが、コールバック自体は含まれません。 +- エージェントは `retry.backoff` の一部のみを上書きしながら、Runner の他のバックオフフィールドを維持できます。 +- `policy` は実行時専用であるため、シリアライズされた `ModelSettings` は `max_retries` と `backoff` を保持しますが、コールバック自体は省略します。 -より詳細なコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを使用する再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 +より完全なコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターベースの再試行コード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 ## OpenAI 以外のプロバイダーのトラブルシューティング ### トレーシングクライアントエラー 401 -トレーシングに関連するエラーが発生する場合、トレースが OpenAI サーバーへアップロードされる一方で、OpenAI API キーが設定されていないことが原因です。これを解決する方法は 3 つあります。 +トレーシングに関連するエラーが発生する場合、トレースが OpenAI サーバーにアップロードされる一方で、OpenAI API キーがないことが原因です。これを解決するには、次の 3 つの方法があります。 1. トレーシングを完全に無効にします: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] 2. トレーシング用の OpenAI キーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 @@ -596,14 +600,14 @@ SDK は、すぐに使用できるヘルパーを `retry_policies` で公開し ### Responses API のサポート -SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだサポートしていません。その結果、404 エラーまたは同様の問題が発生する場合があります。解決する方法は 2 つあります。 +SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだ対応していません。その結果、404 エラーまたは同様の問題が発生する場合があります。これを解決するには、次の 2 つの方法があります。 -1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数を使用して `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 +1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数で `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] を使用します。コード例は[こちら](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)にあります。 ### Chat Completions の互換性オプション -Chat Completions 経由でルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、プロンプト、テキストのみではないツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に削除して互換性を維持します。開発中にこのような不一致を即座に失敗させたい場合は、OpenAI プロバイダーで厳格な機能検証を有効にします。 +Chat Completions 経由でルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、プロンプト、テキストのみではないツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に削除することで互換性を維持します。開発中にこのような不一致を即座にエラーにするには、OpenAI プロバイダーで厳格な機能検証を有効にします。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -623,7 +627,7 @@ result = await Runner.run( [`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡します。 -一部の OpenAI 互換 Chat Completions プロバイダーは、SDK が増分処理するには信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされたツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが完了した後にのみ SDK がツール呼び出しを生成するようにします。 +一部の OpenAI 互換 Chat Completions プロバイダーは、SDK が段階的に処理するには信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされたツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが完了した後にのみ SDK がツール呼び出しを生成するようにします。 ```python from agents import OpenAIProvider @@ -646,42 +650,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -これは一部のモデルプロバイダーの制約です。JSON 出力には対応していますが、出力に使用する `json_schema` を指定できません。この問題は修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーの使用を推奨します。そうしないと、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 +これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていますが、出力に使用する `json_schema` を指定できません。この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートしているプロバイダーを使用することを推奨します。そうしない場合、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 -## プロバイダー間でのモデルの混在 +## プロバイダーをまたぐモデルの組み合わせ -モデルプロバイダー間の機能差を認識しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、およびホスト型のファイル検索と Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限事項に注意してください。 +モデルプロバイダー間の機能差を把握しておかないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホスト型のファイル検索と Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 -- 理解できないプロバイダーへ、サポートされていない `tools` を送信しないでください +- 対応していないプロバイダーへ、サポートされていない `tools` を送信しないでください - テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください - 構造化 JSON 出力をサポートしていないプロバイダーは、無効な JSON を生成する場合があることに注意してください。 ## サードパーティ製アダプター -SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティ製アダプターを使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] の経路を選択してください。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる場合や、組み込みの経路では提供されないアダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合に使用します。アダプターによって SDK と上流のモデルプロバイダーの間に互換性レイヤーが追加されるため、機能のサポート状況とリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 +サードパーティ製アダプターは、SDK の組み込みプロバイダー統合ポイントだけでは不十分な場合にのみ使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] の経路を推奨します。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる必要がある場合や、組み込みの経路では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合に使用します。アダプターは SDK と上流のモデルプロバイダーの間に互換性レイヤーを追加するため、サポートされる機能とリクエストのセマンティクスはプロバイダーによって異なる場合があります。現在、SDK にはベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 ### Any-LLM -Any-LLM が管理するプロバイダーカバレッジまたはルーティングが必要な場合に向けて、Any-LLM のサポートはベストエフォートのベータ版として提供されています。 +Any-LLM のサポートは、Any-LLM が管理するプロバイダーカバレッジまたはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 -上流のプロバイダー経路によっては、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 +上流のプロバイダー経路に応じて、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 -Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールしてから、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用する、`AnyLLMModel` を直接インスタンス化する、または実行スコープで `AnyLLMProvider` を使用することができます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡します。 +Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡します。 -Any-LLM は引き続きサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能上の不足は、SDK ではなく上流の Any-LLM によって定義されます。上流のプロバイダーが使用量メトリクスを返す場合、それらは自動的に伝播されます。ただし、ストリーミングを行う Chat Completions バックエンドでは、使用量チャンクを生成するために `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +Any-LLM はサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能差は SDK ではなく、上流の Any-LLM によって定義されます。上流のプロバイダーが使用量指標を返す場合、それらは自動的に伝播されます。ただし、ストリーミングされる Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 ### LiteLLM -LiteLLM 固有のプロバイダーカバレッジまたはルーティングが必要な場合に向けて、LiteLLM のサポートはベストエフォートのベータ版として提供されています。 +LiteLLM のサポートは、LiteLLM 固有のプロバイダーカバレッジまたはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 -LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールしてから、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` モデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 +LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールし、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` モデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 -LiteLLM を基盤とする一部のプロバイダーは、デフォルトでは SDK の使用量メトリクスを設定しません。使用量レポートが必要な場合は、`ModelSettings(include_usage=True)` を渡してください。また、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +LiteLLM ベースの一部のプロバイダーは、デフォルトでは SDK の使用量指標を設定しません。使用量レポートが必要な場合は、`ModelSettings(include_usage=True)` を渡してください。また、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 -LiteLLM がレスポンスオブジェクトに対する Pydantic シリアライザー警告を生成する場合は、LiteLLM アダプターをインポートする前に、SDK の互換性パッチをオプトインで有効にできます。 +LiteLLM がレスポンスオブジェクトに対する Pydantic シリアライザー警告を生成する場合は、LiteLLM アダプターをインポートする前に、SDK の互換性パッチを有効にできます。 ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -このパッチはデフォルトで無効であり、値が `1` または `true` の場合にのみ有効になります。このパッチは LiteLLM の非公開ログヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。LiteLLM の非公開 API に依存しているため、LiteLLM をアップグレードするときは再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file +このパッチはデフォルトでは無効で、値が `1` または `true` の場合にのみ有効になります。プライベートな LiteLLM ロギングヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。プライベートな LiteLLM API に依存しているため、LiteLLM をアップグレードする際には再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file diff --git a/docs/ja/release.md b/docs/ja/release.md index df9b34379b..6383f4a619 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -6,15 +6,15 @@ search: このプロジェクトでは、`0.Y.Z` 形式のセマンティックバージョニングを一部変更して使用しています。先頭の `0` は、SDK が現在も急速に進化していることを示します。各要素は次のように更新します。 -## マイナー (`Y`) バージョン +## マイナー(`Y`)バージョン -ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を上げます。たとえば、`0.0.x` から `0.1.x` への変更には、破壊的変更が含まれる可能性があります。 +ベータとしてマークされていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を上げます。たとえば、`0.0.x` から `0.1.x` への移行には、破壊的変更が含まれる可能性があります。 -破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することを推奨します。 +破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 -## パッチ (`Z`) バージョン +## パッチ(`Z`)バージョン -破壊的でない変更では、`Z` を上げます。 +破壊的変更ではない次の変更については、`Z` を上げます。 - バグ修正 - 新機能 @@ -25,30 +25,30 @@ search: ### 0.19.0 -このマイナーリリースでは、破壊的変更を **導入していません** 。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域であるプログラムによるツール呼び出しを反映したものです。 +このマイナーリリースでは、破壊的変更は **ありません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域であるプログラマティックツール呼び出しを反映したものです。 -主な変更点: +注目点: -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。これにより、対応する OpenAI Responses モデルは、利用可能なツールを連携させる JavaScript を生成できます。ツールごとの `allowed_callers`、関数ツールの構造化された出力、Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。設定と制約については、[プログラムによるツール呼び出し](tools.md#programmatic-tool-calling)を参照してください。 -- 公開モジュール `agents.decorators` と、既存の関数およびガードレール用デコレーターに加えて短い別名 `@tool` を追加しました。関数ツールで非同期 callable オブジェクトもサポートするようになりました。 -- エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの SDK 設定で、型付き設定オブジェクトまたは辞書を一貫して受け付けるようになり、不明な設定も検証されます。 -- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシングのエラーおよび診断ログを強化し、有用なデバッグ情報を維持しながら、機密性の高い生のペイロードが露出しないようにしました。 -- AnyLLM、LiteLLM、Chat Completions との互換性を改善し、モデルの再試行時にセッション履歴を維持するとともに、レスポンス開始前に発生する WebSocket の過負荷エラーを再試行するようにしました。 -- `VercelCloudBucketMountStrategy` を使用した、[Vercel サンドボックス向けの作成時限定 S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントを含むセッションでは、ワークスペースの永続化からバケットの内容が除外され、動的なマウント変更やセッションの再開は意図的にサポートされません。 +- サポート対象の OpenAI Responses モデルが JavaScript を生成して、利用可能なツールを連携できるようにする [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。ツールごとの `allowed_callers`、構造化された関数ツール出力、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。セットアップと制約については、[プログラマティックツール呼び出し](tools.md#programmatic-tool-calling)を参照してください。 +- 公開 `agents.decorators` モジュールと、既存の関数およびガードレール用デコレーターに加えて、より短い `@tool` エイリアスを追加しました。関数ツールは、非同期 callable オブジェクトもサポートするようになりました。 +- SDK の設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプライン全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け入れるようになり、不明な設定に対する検証も追加されました。 +- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体のエラーおよび診断ログを強化し、有用なデバッグコンテキストを維持しながら、機密性の高い raw ペイロードが公開されることを防止しました。 +- AnyLLM、LiteLLM、Chat Completions との互換性を改善し、モデルの再試行をまたいでセッション履歴を維持するようにしました。また、レスポンス開始前に発生する WebSocket 過負荷に対するプロバイダー再試行のガイダンスを追加し、再実行が許可されている場合に、オプトインの Runner 再試行ポリシーが機能できるようにしました。 +- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックス向けの作成時限定 S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外されます。また、動的なマウント変更やセッション再開は意図的にサポートされていません。 ### 0.18.0 -このマイナーリリースでは、破壊的変更を **導入していません** 。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを反映したものです。 +このマイナーリリースでは、破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデルの更新のみを反映したものです。 -主な変更点: +注目点: -- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime 設定で追加構成なしに最新の推奨モデルが使用されるようになりました。 +- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime セットアップでは、追加設定なしで最新の推奨モデルが使用されるようになりました。 ### 0.17.0 -このバージョンでは、ソースパスが `Manifest.extra_path_grants` の対象でない限り、サンドボックスでローカルソースを実体化する際に `LocalFile.src` と `LocalDir.src` が実体化先の `base_dir` 内に維持されます。`base_dir` は、マニフェストが適用された時点における SDK プロセスの現在の作業ディレクトリです。相対パスのローカルソースはそのディレクトリを基準に解決されますが、絶対パスのローカルソースは、あらかじめそのディレクトリ内または明示的に許可されたパス内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼済みのホストファイルまたはディレクトリを、そのベースディレクトリの外部からサンドボックスワークスペースへ意図的にコピーするアプリケーションには影響する可能性があります。 +このバージョンでは、サンドボックスのローカルソースを実体化する際、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` が実体化先の `base_dir` 内に維持されます。`base_dir` は、Manifest が適用された時点での SDK プロセスの現在の作業ディレクトリです。相対的なローカルソースはそのディレクトリを基準に解決され、絶対パスのローカルソースは、あらかじめそのディレクトリ内または明示的に許可された範囲内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼済みのホストファイルやディレクトリを、そのベースディレクトリの外部からサンドボックスワークスペースへ意図的にコピーするアプリケーションには影響する可能性があります。 -移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みのホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 +移行するには、Manifest レベルで `SandboxPathGrant` を使用して信頼済みのホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 ```python from pathlib import Path @@ -75,28 +75,28 @@ manifest = Manifest( ) ``` -`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可設定を作成しないでください。 +`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できない Manifest 入力から許可設定を追加しないでください。 ### 0.16.0 -このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙のデフォルトモデル設定に `reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルト設定が含まれるようになりました。 +このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントおよび実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルト値が含まれるようになりました。 -以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に指定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 +以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に設定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 ```python agent = Agent(name="Assistant", model="gpt-4.1") ``` -主な変更点: +注目点: -- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の制限を無効化できるようになりました。 -- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、およびプロバイダーを利用するすべてのサンドボックス実装において、絶対パスをリンク先とするシンボリックリンクを含め、アーカイブルートの外部を指すシンボリックリンクを含む tar アーカイブが拒否されるようになりました。 +- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の上限を無効化できるようになりました。 +- ローカル、Docker、プロバイダー提供のサンドボックス実装全体で、サンドボックスワークスペースのハイドレーション時に、絶対パスのシンボリックリンク先を含め、アーカイブのルート外を指すシンボリックリンクを含む tar アーカイブが拒否されるようになりました。 ### 0.15.0 -このバージョンでは、モデルの拒否応答が空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` に達するまで実行ループが再試行されたりするのではなく、`ModelRefusalError` として明示的に通知されるようになりました。 +このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` になるまで実行ループが再試行されたりする代わりに、`ModelRefusalError` として明示的に通知されるようになりました。 -これは、拒否応答のみを含むモデルレスポンスが以前は `final_output == ""` で完了すると想定していたコードに影響します。例外を発生させずに拒否応答を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 +これは、拒否のみのモデルレスポンスが `final_output == ""` で完了することを期待していたコードに影響します。例外を発生させずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 ```python result = Runner.run_sync( @@ -106,85 +106,85 @@ result = Runner.run_sync( ) ``` -structured outputs エージェントの場合、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 +structured outputs を使用するエージェントでは、ハンドラーがエージェントの出力スキーマに一致する値を返すことができ、SDK はほかの実行エラーハンドラーの最終出力と同様に検証します。 ### 0.14.0 -このマイナーリリースでは、破壊的変更を **導入していません** 。ただし、サンドボックスエージェントという大規模な新しいベータ機能領域に加え、ローカル環境、コンテナ環境、ホスト環境でそれらを使用するために必要なランタイム、バックエンド、ドキュメントのサポートを追加しています。 +このマイナーリリースでは破壊的変更は **ありません** が、主要な新しいベータ機能領域であるサンドボックスエージェントに加え、ローカル、コンテナ化、ホスト環境全体で使用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されています。 -主な変更点: +注目点: -- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースを追加しました。これにより、エージェントはファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開サポートを備えた、永続的で隔離されたワークスペース内で作業できます。 -- `UnixLocalSandboxClient` と `DockerSandboxClient` を使用するローカルおよびコンテナ化された開発向けのサンドボックス実行バックエンドに加え、オプションの追加依存関係を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合を追加しました。 -- 将来の実行で過去の実行から得た知見を再利用できるようにするサンドボックスメモリのサポートを追加しました。段階的開示、マルチターンのグループ化、構成可能な分離境界に加え、S3 を利用するワークフローを含む永続化メモリのコード例も提供します。 -- ローカルおよび合成ワークスペースエントリー、S3/R2/GCS/Azure Blob Storage/S3 Files 向けのリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットを使用する再開フローを含む、より包括的なワークスペースおよび再開モデルを追加しました。 -- `examples/sandbox/` 配下に、スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有の設定、コードレビュー、データルーム QA、Web サイトの複製などのエンドツーエンドのワークフローを扱う、多数のサンドボックス用コード例とチュートリアルを追加しました。 -- サンドボックスを考慮したセッション準備、機能のバインディング、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーのデフォルト設定、機密性の高い MCP 出力をより安全に秘匿する機能により、コアランタイムとトレーシングスタックを拡張しました。 +- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースを追加しました。これにより、エージェントは、ファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開機能を備えた永続的な分離ワークスペース内で作業できます。 +- `UnixLocalSandboxClient` と `DockerSandboxClient` によるローカルおよびコンテナ化された開発向けのサンドボックス実行バックエンドに加え、オプションの extras を通じて Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合を追加しました。 +- サンドボックスメモリのサポートを追加し、段階的開示、複数ターンのグループ化、設定可能な分離境界、S3 を利用するワークフローを含む永続化メモリのコード例により、今後の実行で過去の実行から得られた知見を再利用できるようにしました。 +- ローカルおよび合成ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files 向けのリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットを使用する再開フローなど、より広範なワークスペースおよび再開モデルを追加しました。 +- `examples/sandbox/` 配下に多数のサンドボックスのコード例とチュートリアルを追加しました。スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有のセットアップ、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドワークフローを扱っています。 +- サンドボックス対応のセッション準備、機能のバインディング、状態のシリアル化、統合トレーシング、プロンプトキャッシュキーのデフォルト値、機密性の高い MCP 出力をより安全に秘匿する処理により、コアランタイムとトレーシングスタックを拡張しました。 ### 0.13.0 -このマイナーリリースでは、破壊的変更を **導入していません** 。ただし、Realtime のデフォルト設定に関する重要な更新、新しい MCP 機能、ランタイムの安定性に関する修正が含まれています。 +このマイナーリリースでは破壊的変更は **ありません** が、注目すべき Realtime のデフォルト設定の更新、新しい MCP 機能、ランタイムの安定性向上が含まれています。 -主な変更点: +注目点: -- WebSocket 用のデフォルト Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェント設定で追加構成なしに新しいモデルが使用されるようになりました。 -- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開されるようになりました。また、`MCPServerStreamableHttp` で `session_id` が公開されるようになり、ストリーミング可能な HTTP セッションを再接続後またはステートレスワーカー間で再開できるようになりました。 -- Chat Completions 統合では、`should_replay_reasoning_content` を使用して推論内容の再生を任意で有効化できるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論/ツール呼び出しの連続性が向上しました。 -- `SQLAlchemySession` への同時初回書き込み、推論内容の除去後に孤立したアシスタントメッセージ ID を含む圧縮リクエスト、`remove_all_tools()` の実行後に残る MCP/推論項目、関数ツールのバッチ実行処理における競合状態など、複数のランタイムおよびセッションのエッジケースを修正しました。 +- デフォルトの WebSocket Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェントのセットアップでは、追加設定なしでより新しいモデルが使用されるようになりました。 +- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開されるようになりました。また、`MCPServerStreamableHttp` で `session_id` が公開され、ストリーミング可能な HTTP セッションを再接続後やステートレスワーカー間で再開できるようになりました。 +- Chat Completions 統合では、`should_replay_reasoning_content` を通じて推論コンテンツのリプレイをオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論およびツール呼び出しの継続性が向上しました。 +- `SQLAlchemySession` における最初の書き込みの同時実行、推論除去後に孤立したアシスタントメッセージ ID を含む圧縮リクエスト、`remove_all_tools()` の実行後に残る MCP/推論項目、関数ツールのバッチ実行機構における競合状態など、ランタイムおよびセッションの複数のエッジケースを修正しました。 ### 0.12.0 -このマイナーリリースでは、破壊的変更を **導入していません** 。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 +このマイナーリリースでは、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 ### 0.11.0 -このマイナーリリースでは、破壊的変更を **導入していません** 。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 +このマイナーリリースでは、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 ### 0.10.0 -このマイナーリリースでは、破壊的変更を **導入していません** 。ただし、OpenAI Responses のユーザー向けに、Responses API の WebSocket トランスポートをサポートする重要な新機能領域が含まれています。 +このマイナーリリースでは破壊的変更は **ありません** が、OpenAI Responses のユーザー向けの重要な新機能領域として、Responses API の WebSocket トランスポート対応が含まれています。 -主な変更点: +注目点: -- OpenAI Responses モデルに WebSocket トランスポートのサポートを追加しました。これはオプトインであり、HTTP が引き続きデフォルトのトランスポートです。 -- 複数ターンの実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 -- ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングのコード例 (`examples/basic/stream_ws.py`) を追加しました。 +- OpenAI Responses モデルに WebSocket トランスポート対応を追加しました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 +- 複数ターンの実行間で、共有の WebSocket 対応プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 +- ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングのコード例(`examples/basic/stream_ws.py`)を追加しました。 ### 0.9.0 -このバージョンでは、Python 3.9 のメジャーバージョンが 3 か月前に EOL を迎えたため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンにアップグレードしてください。 +このバージョンでは、Python 3.9 のメジャーバージョンが 3 か月前に EOL に達したため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンにアップグレードしてください。 -さらに、`Agent#as_tool()` メソッドの戻り値に対する型ヒントが、`Tool` から `FunctionTool` に限定されました。通常、この変更が破壊的な問題を引き起こすことはありませんが、コードがより広範なユニオン型に依存している場合は、調整が必要になる可能性があります。 +さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に限定されました。通常、この変更が破壊的な問題を引き起こすことはありませんが、コードがより広範なユニオン型に依存している場合は、調整が必要になる可能性があります。 ### 0.8.0 -このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる可能性があります。 +このバージョンでは、ランタイムの動作に関する次の 2 つの変更により、移行作業が必要になる可能性があります。 -- Python の **同期** 呼び出し可能オブジェクトをラップする関数ツールは、イベントループのスレッド上で実行されるのではなく、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールのロジックがスレッドローカルな状態または特定のスレッドに依存するリソースを使用している場合は、非同期ツール実装に移行するか、ツールコード内でスレッドアフィニティを明示してください。 -- ローカル MCP ツールの失敗処理を構成できるようになり、デフォルト動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返す場合があります。即時失敗の動作に依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 +- **同期** Python callable をラップする関数ツールは、イベントループスレッド上で実行される代わりに、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールのロジックがスレッドローカル状態やスレッドアフィンなリソースに依存している場合は、非同期ツール実装へ移行するか、ツールコード内でスレッドアフィニティを明示してください。 +- ローカル MCP ツールの失敗処理が設定可能になり、デフォルトの動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返す場合があります。フェイルファストのセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` の値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 ### 0.7.0 このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかあります。 -- ネストされたハンドオフ履歴が **オプトイン** になりました。デフォルトでは無効です。v0.6.x のデフォルトのネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 -- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルト設定で以前使用されていた `"low"` から `"none"` に変更されました。プロンプトまたは品質/コストのプロファイルが `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 +- ネストされたハンドオフ履歴は **オプトイン** になりました(デフォルトでは無効)。v0.6.x でデフォルトだったネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 +- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルト設定で構成されていた以前のデフォルト値 `"low"` から `"none"` に変更されました。プロンプトまたは品質/コスト特性が `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 ### 0.6.0 -このバージョンでは、デフォルトのハンドオフ履歴が、未加工のユーザー/アシスタントのターンを公開する代わりに、単一のアシスタントメッセージへまとめられるようになり、後続のエージェントに簡潔で予測可能な要約を提供します -- 既存の単一メッセージ形式のハンドオフトランスクリプトは、デフォルトで `` ブロックの前に "For context, here is the conversation so far between the user and the previous agent:" という文言を付けて開始するようになり、後続のエージェントが明確なラベル付きの要約を受け取れるようになりました +このバージョンでは、デフォルトのハンドオフ履歴が、raw なユーザー/アシスタントのターンを公開する代わりに、単一のアシスタントメッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます +- 既存の単一メッセージによるハンドオフ記録は、デフォルトで `` ブロックの前に "For context, here is the conversation so far between the user and the previous agent:" から始まるようになり、後続のエージェントが明確にラベル付けされた要約を受け取れるようになりました ### 0.5.0 -このバージョンでは、外部から確認できる破壊的変更はありませんが、新機能と内部実装上の重要な更新がいくつか含まれています。 +このバージョンでは、目に見える破壊的変更は導入されていませんが、新機能と内部実装に関するいくつかの重要な更新が含まれています。 -- `RealtimeRunner` で [SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートを追加しました -- Python 3.14 との互換性を確保するため、`Runner#run_sync` の内部ロジックを大幅に改訂しました +- `RealtimeRunner` が [SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理できるようになりました +- Python 3.14 との互換性のために、`Runner#run_sync` の内部ロジックを大幅に改訂しました ### 0.4.0 -このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x 系はサポートされなくなりました。この SDK とともに openai v2.x 系を使用してください。 +このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x バージョンはサポートされなくなりました。この SDK と併用する場合は、openai v2.x を使用してください。 ### 0.3.0 @@ -192,8 +192,8 @@ structured outputs エージェントの場合、ハンドラーはエージェ ### 0.2.0 -このバージョンでは、以前は `Agent` を引数として受け取っていた一部の箇所が、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、MCP サーバーの `list_tools()` 呼び出しが該当します。これは型に関する変更のみであり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 +このバージョンでは、以前は `Agent` を引数として受け取っていたいくつかの箇所が、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、MCP サーバーの `list_tools()` 呼び出しです。これは純粋に型付けのみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正するだけです。 ### 0.1.0 -このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` を継承するすべてのクラスに、これらのパラメーターを追加する必要があります。 +このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` を継承するすべてのクラスに、これらのパラメーターを追加する必要があります。 \ No newline at end of file diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index 040ced2f0f..1d115dcb6a 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -4,11 +4,11 @@ search: --- # エージェントの実行 -[`Runner`][agents.run.Runner] クラスを使用してエージェントを実行できます。次の 3 つの方法があります。 +[`Runner`][agents.run.Runner] クラスを介してエージェントを実行できます。次の 3 つの方法があります。 1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行し、[`RunResult`][agents.result.RunResult] を返します。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドで、内部的に `.run()` を実行します。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントをそのままストリーミングします。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドであり、内部で `.run()` を実行します。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントを順次ストリーミングします。 ```python from agents import Agent, Runner @@ -29,28 +29,28 @@ async def main(): ### エージェントループ -`Runner` の run メソッドを使用する際は、開始エージェントと入力を渡します。入力には次のものを指定できます。 +`Runner` の run メソッドを使用する際は、開始エージェントと入力を渡します。入力には次のものを使用できます。 - 文字列(ユーザーメッセージとして扱われます) - OpenAI Responses API 形式の入力項目のリスト - 中断された実行を再開する場合は [`RunState`][agents.run_state.RunState] -Runner は次のループを実行します。 +その後、Runner はループを実行します。 -1. 現在のエージェントについて、現在の入力を使用して LLM を呼び出します。 +1. 現在のエージェントに対し、現在の入力を使用して LLM を呼び出します。 2. LLM が出力を生成します。 - 1. LLM が `final_output` を返した場合、ループを終了し、実行結果を返します。 + 1. LLM が `final_output` を返した場合、ループは終了し、実行結果を返します。 2. LLM がハンドオフを行った場合、現在のエージェントと入力を更新し、ループを再実行します。 - 3. LLM がツール呼び出しを生成した場合、そのツール呼び出しを実行し、実行結果を追加して、ループを再実行します。 -3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 + 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行し、実行結果を追加して、ループを再実行します。 +3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外が発生します。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note - LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力を生成し、ツール呼び出しが存在しないことです。 + LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが存在しないことです。 ### ストリーミング -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントを取得するには、`.stream_events()` を呼び出します。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 #### Responses WebSocket トランスポート(オプションのヘルパー) @@ -58,11 +58,11 @@ OpenAI Responses WebSocket トランスポートを有効にしても、通常 これは WebSocket トランスポート経由の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 -トランスポートの選択ルールや、具体的なモデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)を参照してください。 +トランスポートの選択ルールと、具象モデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)を参照してください。 -##### パターン 1:セッションヘルパーなし(動作可) +##### パターン 1:セッションヘルパーなし(利用可能) -WebSocket トランスポートのみが必要で、SDK に共有プロバイダー/セッションを管理させる必要がない場合に使用します。 +WebSocket トランスポートのみが必要で、SDK に共有プロバイダーやセッションを管理させる必要がない場合に使用します。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -このパターンは単一の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 +このパターンは、単一の実行には適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行のたびに再接続される可能性があります。 ##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) -複数の実行間で、WebSocket 対応のプロバイダーと `RunConfig` を共有する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、ネストされたエージェントのツールとしての呼び出しも対象です。 +複数の実行で WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。これには、同じ `run_config` を継承する、ネストされた「ツールとしてのエージェント」の呼び出しも含まれます。 ```python import asyncio @@ -119,9 +119,11 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果の取得を完了してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 +コンテキストを終了する前に、ストリーミングされた実行結果を最後まで取得してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制終了される可能性があります。 -長時間の推論ターンで WebSocket のキープアライブがタイムアウトする場合は、`ping_timeout` を大きくするか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket のレイテンシよりも信頼性が重要な実行では、HTTP/SSE トランスポートを使用してください。 +サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約をなくすものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。完全な復旧動作については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)を参照してください。 + +長時間の推論ターンで WebSocket の keepalive タイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket のレイテンシーよりも信頼性が重要な実行には、HTTP/SSE トランスポートを使用してください。 ### 実行設定 @@ -129,46 +131,46 @@ asyncio.run(main()) #### 一般的な実行設定のカテゴリー -各エージェントの定義を変更せずに、単一の実行に対する動作を上書きするには、`RunConfig` を使用します。 +各エージェントの定義を変更せずに単一の実行の動作を上書きするには、`RunConfig` を使用します。 ##### モデル、プロバイダー、セッションのデフォルト -- [`model`][agents.run.RunConfig.model]:各 Agent に設定された `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 -- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 -- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` や `top_p` を設定できます。 -- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際、セッションレベルのデフォルト(たとえば、`SessionSettings(limit=...)`)を上書きします。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各ターンの前に新しいユーザー入力をセッション履歴へ統合する方法をカスタマイズします。コールバックは同期でも非同期でもかまいません。 +- [`model`][agents.run.RunConfig.model]:各 Agent に設定された `model` に関係なく、使用するグローバル LLM モデルを設定できます。 +- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAIです。 +- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 +- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(たとえば `SessionSettings(limit=...)`)を上書きします。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各ターンの前に新しいユーザー入力をセッション履歴とマージする方法をカスタマイズします。コールバックは同期または非同期にできます。 ##### ガードレール、ハンドオフ、モデル入力の整形 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフにフィルターが設定されていない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントへ送信する入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントを参照してください。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、元の位置にあるメッセージ項目を欠損なく保持しながら、要約可能な履歴を順序付きの assistant 要約セグメントへ圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、未加工のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、`RunResult.to_input_list()` は、SDK のデフォルトのネスト履歴に同一のメッセージ出現箇所がすでに含まれている場合、そのメッセージを二重に追加しません。一方、内容が同一でも別個のメッセージは保持されます。すべての [Runner メソッド][agents.run.Runner]は、`RunConfig` が渡されなかった場合に自動で作成するため、クイックスタートとコード例ではデフォルトで無効のままとなり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を使用してこの設定を上書きできます。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` を有効にした場合に、正規化されたトランスクリプト(履歴 + ハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを記述することなく、組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデルを呼び出す直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の短縮やシステムプロンプトの挿入に使用できます。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに独自のフィルターがまだない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信する入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントを参照してください。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、損失のないメッセージ項目を元の位置に保持しながら、要約可能な履歴を順序付きの assistant 要約セグメントへ圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、raw のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、および `RunResult.to_input_list()` は、SDK デフォルトのネストされた履歴がすでに所有している完全に同一のメッセージ出現箇所を重複して追加しない一方、別々に存在する同一メッセージは保持します。明示的に渡さなかった場合、すべての [Runner メソッド][agents.run.Runner]は自動的に `RunConfig` を作成するため、クイックスタートとコード例ではデフォルトで無効のままになり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を介してこの設定を上書きできます。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインした場合に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを作成することなく、組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴を削減したり、システムプロンプトを挿入したりできます。 - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力へ変換する際に、推論項目 ID を保持するか省略するかを制御します。 ##### トレーシングと可観測性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 -- [`tracing`][agents.run.RunConfig.tracing]:実行単位のトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密情報である可能性のあるデータをトレースに含めるかどうかを設定します。 +- [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密性の高い可能性があるデータをトレースに含めるかどうかを構成します。 - [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 ##### ツール実行、承認、ツールエラーの動作 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:一度に実行する関数ツール数の制限など、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した未解決の関数ツール呼び出しを Runner が処理する方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから確認できるエラー出力を返すようオプトインできます。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインされたツール未検出時の出力など、モデルから確認できるツールエラーメッセージをカスタマイズします。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行する関数ツールの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を構成します。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した未解決の関数ツール呼び出しを Runner が処理する方法を構成します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから参照可能なエラー出力を返すようオプトインできます。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから参照可能なツールエラーメッセージをカスタマイズします。 -ネストされたハンドオフは、オプトインのベータ機能として利用できます。`RunConfig(nest_handoff_history=True)` を渡して順序付きトランスクリプト圧縮を有効にするか、`handoff(..., nest_handoff_history=True)` を設定して特定のハンドオフに対して有効にします。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、欠損のないメッセージ項目の前後に、生成された assistant 要約セグメントを配置します。未加工のトランスクリプトを保持する場合(デフォルト)は、フラグを設定しないか、必要な形式で会話をそのまま転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せず、生成される要約セグメントで使用されるラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。デフォルトに戻すには、[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を使用します。 +ネストされたハンドオフは、オプトインのベータ機能として利用できます。`RunConfig(nest_handoff_history=True)` を渡して順序付きトランスクリプトの圧縮を有効にするか、特定のハンドオフで有効にするために `handoff(..., nest_handoff_history=True)` を設定します。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、損失のないメッセージ項目の前後に、生成された assistant 要約セグメントを配置します。raw のトランスクリプトを保持する場合(デフォルト)は、フラグを未設定のままにするか、必要な形式で会話を正確に転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを作成せずに、生成された要約セグメントで使用されるラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 #### 実行設定の詳細 ##### `tool_execution` -実行時のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 +実行中のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を構成する場合は、`tool_execution` を使用します。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -189,15 +191,15 @@ result = await Runner.run( `max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成した場合、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツールの数を制限するには、整数値を設定します。 -これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別の設定です。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがツール呼び出しを生成した後、SDK がローカル関数ツール呼び出しをどのように実行するかを制御します。 +これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別のものです。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがローカル関数ツール呼び出しを生成した後、それらを SDK がどのように実行するかを制御します。 -`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後、実行直前にのみ実行されます。保留中の承認による中断が生成される前に関数ツールの入力ガードレールを実行するには、`True` に設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再実行されるため、時間依存のチェックは実行前に再検証されます。 +`pre_approval_tool_input_guardrails=False` は、デフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、承認後の実行直前にのみツール入力ガードレールが実行されます。保留中の承認による中断が生成される前に関数ツールの入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックに合格した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間に依存するチェックは実行前に再検証されます。 ##### `tool_not_found_behavior` -デフォルトでは、モデルが現在のエージェントで使用可能ないずれの関数ツールにも一致しない関数ツール呼び出しを生成すると、Runner は `ModelBehaviorError` を発生させます。 +デフォルトでは、現在のエージェントが利用できるどの関数ツールにも一致しない関数ツール呼び出しをモデルが生成した場合、Runner は `ModelBehaviorError` を発生させます。 -実行を復旧可能な状態に保つには、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK が未解決のツール呼び出しに対する `function_call_output` を追加し、モデルを再実行します。これにより、モデルは使用可能なツールを選択するか、そのツールを使用せずに回答できます。 +実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は未解決のツール呼び出しに対する `function_call_output` を追加し、モデルを再度実行します。これにより、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 ```python from agents import Agent, RunConfig, Runner @@ -211,20 +213,20 @@ result = await Runner.run( ) ``` -現在、このオプションは未解決の関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、既存のエラー動作が引き続き使用されます。 +現在、このオプションは未解決の関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、引き続き既存のエラー動作が使用されます。 ##### `tool_error_formatter` -SDK がモデルから確認できるツールエラー出力を作成する際、モデルへ返されるメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 +SDK がモデルから参照可能なツールエラー出力を作成する際に、モデルへ返されるメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 -フォーマッターは、次の内容を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 +フォーマッターは、次の情報を含む [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 -- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリーです。 -- `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)です。 -- `tool_name`:ツール名です。 -- `call_id`:ツール呼び出し ID です。 -- `default_message`:モデルから確認できる SDK のデフォルトメッセージです。 -- `run_context`:アクティブな実行コンテキストラッパーです。 +- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリー。 +- `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)。 +- `tool_name`:ツール名。 +- `call_id`:ツール呼び出し ID。 +- `default_message`:SDK のデフォルトの、モデルから参照可能なメッセージ。 +- `run_context`:アクティブな実行コンテキストラッパー。 メッセージを置き換えるには文字列を返し、SDK のデフォルトを使用するには `None` を返します。 @@ -253,52 +255,52 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` は、Runner が履歴を引き継ぐ際(たとえば、`RunResult.to_input_list()` やセッションを使用する実行の場合)に、推論項目を次のターンのモデル入力へ変換する方法を制御します。 +`reasoning_item_id_policy` は、Runner が履歴を次のターンへ引き継ぐ際に、推論項目を次のターンのモデル入力へ変換する方法を制御します(たとえば、`RunResult.to_input_list()` またはセッションを利用した実行を使用する場合)。 - `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 -- `"omit"`:生成される次のターンの入力から推論項目 ID を削除します。 +- `"omit"`:生成された次のターンの入力から推論項目 ID を削除します。 -`"omit"` は主に、推論項目が `id` 付きで送信される一方、後続の必須項目がない場合に発生する Responses API の 400 エラーの一種に対する、オプトインの緩和策として使用します(例:`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` は主に、推論項目が `id` を伴って送信されたものの、必須の後続項目がない場合に発生する一連の Responses API 400 エラーに対する、オプトインの緩和策として使用します(たとえば、`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -これは、SDK が以前の出力から後続入力を構築する複数ターンのエージェント実行で発生する可能性があります。対象には、セッションの永続化、サーバー管理の会話差分、ストリーミング/非ストリーミングの後続ターン、再開パスが含まれます。このとき推論項目 ID が保持されていても、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合があります。 +これは、SDK が以前の出力から後続入力を構築する複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。このとき、推論項目 ID は保持されているものの、プロバイダーがその ID と対応する後続項目とのペアを維持するよう要求する場合があります。 -`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持しながら推論項目の `id` が削除されるため、SDK が生成する後続入力でこの API の不変条件に抵触することを回避できます。 +`reasoning_item_id_policy="omit"` を設定すると、推論内容を保持しながら推論項目の `id` を削除します。これにより、SDK が生成する後続入力で、この API の不変条件に抵触することを回避できます。 適用範囲に関する注意事項: -- これは、SDK が後続入力を構築する際に生成または転送する推論項目のみを変更します。 +- これは、SDK が後続入力を構築する際に、SDK によって生成または転送される推論項目のみを変更します。 - ユーザーが指定した初期入力項目は書き換えません。 -- `call_model_input_filter` では、このポリシーの適用後に意図的に推論 ID を再導入できます。 +- このポリシーが適用された後でも、`call_model_input_filter` によって意図的に推論 ID を再導入できます。 ## 状態と会話の管理 ### メモリ戦略の選択 -次のターンへ状態を引き継ぐ一般的な方法は 4 つあります。 +状態を次のターンへ引き継ぐ一般的な方法は 4 つあります。 | 戦略 | 状態の保存場所 | 最適な用途 | 次のターンで渡すもの | | --- | --- | --- | --- | | `result.to_input_list()` | アプリのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | | `session` | ストレージと SDK | 永続的なチャット状態、再開可能な実行、カスタムストア | 同じ `session` インスタンス、または同じストアを参照する別のインスタンス | | `conversation_id` | OpenAI Conversations API | ワーカーやサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | -| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに行う、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | +| `previous_response_id` | OpenAI Responses API | 会話リソースを作成しない、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | -`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両レイヤーを意図的に整合させない限り、コンテキストが重複する可能性があります。 +`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。両方のレイヤーを意図的に調整している場合を除き、クライアント管理の履歴と OpenAI管理の状態を混在させると、コンテキストが重複する可能性があります。 !!! note - 同じ実行内で、セッションの永続化とサーバー管理の会話設定 - (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)を + 同じ実行内で、セッションの永続化をサーバー管理の会話設定 + (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)と 組み合わせることはできません。呼び出しごとに 1 つの方法を選択してください。 -### 会話/チャットスレッド +### 会話とチャットスレッド -いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があります(したがって、LLM が 1 回以上呼び出される可能性があります)が、チャット会話では論理的に 1 回のターンを表します。たとえば、次のようになります。 +いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される(したがって、LLM が 1 回以上呼び出される)可能性がありますが、チャット会話における 1 つの論理ターンを表します。たとえば、次のようになります。 -1. ユーザーターン:ユーザーがテキストを入力します。 +1. ユーザーターン:ユーザーがテキストを入力します 2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行し、2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力のみを表示することもできます。いずれの場合でも、ユーザーが続けて質問する可能性があり、その場合は run メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力のみを表示することもできます。どちらの場合でも、ユーザーが追加の質問をする可能性があり、その場合は run メソッドを再度呼び出せます。 #### 手動による会話管理 @@ -326,7 +328,7 @@ async def main(): #### セッションによる自動会話管理 -より簡単な方法として、`.to_input_list()` を手動で呼び出さずに会話履歴を自動管理するには、[Sessions](sessions/index.md) を使用できます。 +より簡単な方法として、[Sessions](sessions/index.md) を使用すると、`.to_input_list()` を手動で呼び出さずに会話履歴を自動的に処理できます。 ```python from agents import Agent, Runner, SQLiteSession, trace @@ -350,24 +352,24 @@ async def main(): # California ``` -Sessions は次の処理を自動的に行います。 +Sessions は以下を自動的に実行します。 -- 各実行の前に会話履歴を取得します。 -- 各実行の後に新しいメッセージを保存します。 -- セッション ID ごとに個別の会話を維持します。 +- 各実行の前に会話履歴を取得します +- 各実行の後に新しいメッセージを保存します +- セッション ID ごとに個別の会話を維持します 詳細については、[Sessions のドキュメント](sessions/index.md)を参照してください。 #### サーバー管理の会話 -`to_input_list()` や `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能を使用してサーバー側で会話状態を管理することもできます。これにより、過去のすべてのメッセージを手動で再送信せずに会話履歴を保持できます。以下のいずれのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)を参照してください。 +`to_input_list()` または `Sessions` を使用してローカルで会話状態を処理する代わりに、OpenAIの会話状態機能によってサーバー側で会話状態を管理することもできます。これにより、過去のすべてのメッセージを手動で再送信することなく、会話履歴を保持できます。以下のいずれかのサーバー管理方式では、各リクエストで新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAIの会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)を参照してください。 -OpenAI では、ターン間で状態を追跡するために 2 つの方法を提供しています。 +OpenAIは、ターンをまたいで状態を追跡する 2 つの方法を提供します。 ##### 1. `conversation_id` の使用 -最初に OpenAI Conversations API を使用して会話を作成し、その後のすべての呼び出しでその ID を再利用します。 +まず OpenAI Conversations API を使用して会話を作成し、以降のすべての呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -390,7 +392,7 @@ async def main(): ##### 2. `previous_response_id` の使用 -もう 1 つの方法は **レスポンスチェイニング** です。この方式では、各ターンを前のターンのレスポンス ID に明示的にリンクします。 +もう 1 つの選択肢は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的に関連付けます。 ```python from agents import Agent, Runner @@ -415,30 +417,28 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開した場合、SDK は保存済みの `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を維持するため、再開されたターンは同じサーバー管理の会話内で続行されます。 +実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開した場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を保持するため、再開されたターンは同じサーバー管理の会話で継続されます。 -`conversation_id` と `previous_response_id` は相互排他的です。システム間で共有できる名前付きの会話リソースが必要な場合は、`conversation_id` を使用します。ターンから次のターンへの最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 +`conversation_id` と `previous_response_id` は相互排他的です。システム間で共有できる名前付きの会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 !!! note SDK は、`conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話実行では、再試行前に内部の会話トラッカー入力を巻き戻し、準備済みの同じ項目を - 正常に再送信できるようにします。 + 会話実行では、再試行前に内部の会話追跡用入力を巻き戻し、準備済みの同じ項目を + 問題なく再送信できるようにします。 ローカルのセッションベースの実行(`conversation_id`、`previous_response_id`、 - `auto_previous_response_id` のいずれとも組み合わせられません)では、SDK は再試行後に - 履歴項目が重複するのを減らすため、直近で永続化された入力項目のロールバックも - ベストエフォートで実行します。 + `auto_previous_response_id` のいずれとも組み合わせられません)では、SDK は再試行後の + 履歴項目の重複を減らすため、直近に永続化された入力項目のロールバックもベストエフォートで行います。 - この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも実行されます。 - モデルリクエストに対する、より広範なオプトインの再試行動作については、 - [Runner 管理の再試行](models/index.md#runner-managed-retries)を参照してください。 + この互換性のための再試行は、`ModelSettings.retry` を構成していない場合でも行われます。モデルリクエストに対する + より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 ## フックとカスタマイズ -### モデル呼び出し入力フィルター +### モデル呼び出しの入力フィルター -モデルを呼び出す直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、統合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 +モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。フックは、現在のエージェント、コンテキスト、結合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。それ以外の形式を返すと、`UserError` が発生します。 @@ -459,19 +459,19 @@ result = Runner.run_sync( ) ``` -Runner は準備済みの入力リストのコピーをフックへ渡すため、呼び出し元の元のリストを直接変更することなく、短縮、置換、並べ替えを行えます。 +Runner は準備済みの入力リストのコピーをフックへ渡すため、呼び出し元の元のリストをその場で変更せずに、項目を削減、置換、または並べ替えられます。 -セッションを使用している場合、`call_model_input_filter` は、セッション履歴がすでに読み込まれ、現在のターンと統合された後に実行されます。それ以前の統合ステップ自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 +セッションを使用している場合、`call_model_input_filter` は、セッション履歴がすでに読み込まれ、現在のターンとマージされた後に実行されます。この前段階のマージ処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 -`conversation_id`、`previous_response_id`、または `auto_previous_response_id` で OpenAI のサーバー管理の会話状態を使用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体を再現したものではなく、新しいターンの差分のみをすでに表している可能性があります。返した項目のみが、そのサーバー管理の継続処理で送信済みとして記録されます。 +`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAIのサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再現ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続用に送信済みとしてマークされます。 -機密データの秘匿、長い履歴の短縮、追加のシステムガイダンスの挿入を行うには、`run_config` を通じて実行ごとにフックを設定します。 +機密データの秘匿化、長い履歴の削減、または追加のシステムガイダンスの挿入を行うには、`run_config` を介して実行ごとにフックを設定します。 ## エラーと復旧 ### エラーハンドラー -すべての `Runner` エントリポイントは、エラー種別をキーとする辞書 `error_handlers` を受け取ります。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 +すべての `Runner` エントリーポイントは、エラー種別をキーとする dict である `error_handlers` を受け取ります。サポートされるキーは、`"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 ```python from agents import ( @@ -500,7 +500,7 @@ result = Runner.run_sync( print(result.final_output) ``` -モデルメッセージがエージェントの structured な `output_type` に対する検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してその値を検証します。モデル呼び出しの再試行や、ツールによる副作用の再実行は行いません。`None` を返すと復旧を行いません。フォールバックがない場合、空でないレスポンスの検証エラーでは引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 +モデルメッセージがエージェントの structured `output_type` に対して検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を行いません。フォールバックがない場合、空でない検証エラーでは引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 ```python from pydantic import BaseModel @@ -532,9 +532,9 @@ result = Runner.run_sync( print(result.final_output) ``` -フォールバック出力を会話履歴へ追加しない場合は、`include_in_history=False` を設定します。 +フォールバック出力を会話履歴に追加しない場合は、`include_in_history=False` を設定します。 -モデルの拒否によって `ModelRefusalError` で実行を終了する代わりに、アプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 +モデルによる拒否時に `ModelRefusalError` で実行を終了する代わりに、アプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 ```python from pydantic import BaseModel @@ -566,35 +566,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続実行との統合と Human-in-the-loop +## 永続実行の統合と Human-in-the-loop -ツール承認の一時停止/再開パターンについては、専用の [Human-in-the-loop ガイド](human_in_the_loop.md)を最初に参照してください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを対象としています。 +ツール承認の一時停止と再開のパターンについては、専用の [Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。以下の統合は、長い待機、再試行、またはプロセスの再起動にまたがる可能性がある実行を永続的にオーケストレーションするためのものです。 ### Dapr -Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、Human-in-the-loop をサポートし、障害から自動的に復旧する、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの使用を開始するには、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)を参照してください。 +Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、Human-in-the-loop をサポートし、障害から自動的に復旧する、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAIエージェントの使用を[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 ### Temporal -Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、Human-in-the-loop タスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)です。 +Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、Human-in-the-loop タスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)です。 ### Restate -Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実現できます。この統合では、Restate の単一バイナリランタイムが依存関係として必要であり、エージェントをプロセス/コンテナまたはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)を参照してください。 +Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実行できます。この統合には、依存関係として Restate の単一バイナリランタイムが必要であり、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)を参照してください。 ### DBOS -Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、Human-in-the-loop ワークフロー、ハンドオフをサポートしています。また、同期メソッドと非同期メソッドの両方をサポートしています。この統合に必要なのは、SQLite または Postgres データベースのみです。詳細については、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)を参照してください。 +Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、Human-in-the-loop ワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは、SQLite または Postgres データベースのみです。詳細については、統合の [repo](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)を参照してください。 ## 例外 -SDK は特定の状況で例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 +SDK は特定の場合に例外を発生させます。完全なリストは [`agents.exceptions`][] にあります。概要は次のとおりです。 - [`AgentsException`][agents.exceptions.AgentsException]:SDK 内で発生するすべての例外の基底クラスです。他のすべての具体的な例外は、この汎用型から派生します。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドへ渡された `max_turns` の制限を超えた場合に発生します。これは、指定された対話ターン数以内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドに渡された `max_turns` の制限を超えた場合に発生します。指定された対話ターン数以内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 - [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。これには次のものが含まれます。 - - 不正な形式の JSON:特に特定の `output_type` が定義されている場合に、モデルがツール呼び出しまたは直接出力で不正な形式の JSON 構造を提供した場合です。 - - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合です。 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定されたタイムアウトを超え、そのツールが `timeout_behavior="raise_exception"` を使用している場合に発生します。 -- [`UserError`][agents.exceptions.UserError]:SDK を使用してコードを記述しているユーザーが、SDK の使用中に誤りを犯した場合に発生します。通常は、不正なコード実装、無効な設定、SDK API の誤用が原因です。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:それぞれ、入力ガードレールまたは出力ガードレールの条件が満たされた場合に発生します。入力ガードレールは処理前に受信メッセージを確認し、出力ガードレールは配信前にエージェントの最終レスポンスを確認します。 + - 不正な JSON:モデルがツール呼び出しまたは直接出力で不正な JSON 構造を提供した場合。特に、特定の `output_type` が定義されている場合に該当します。 + - 予期しないツール関連の障害:モデルが想定どおりにツールを使用できなかった場合 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが構成されたタイムアウトを超え、そのツールで `timeout_behavior="raise_exception"` が使用されている場合に発生します。 +- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードの作成者が、SDK の使用中に誤りを犯した場合に発生します。通常、コード実装の誤り、無効な設定、または SDK API の誤用が原因です。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:それぞれ、入力ガードレールまたは出力ガードレールの条件が満たされた場合に発生します。入力ガードレールは処理前の受信メッセージをチェックし、出力ガードレールは配信前のエージェントの最終レスポンスをチェックします。 \ No newline at end of file diff --git a/docs/ko/models/index.md b/docs/ko/models/index.md index e54bb82b6e..d970c6d27d 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -4,36 +4,36 @@ search: --- # 모델 -Agents SDK는 다음 두 가지 유형의 OpenAI 모델을 기본 지원합니다. +Agents SDK는 다음 두 가지 방식으로 OpenAI 모델을 즉시 사용할 수 있도록 지원합니다. - **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] - [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] ## 모델 설정 선택 -설정에 맞는 가장 간단한 방법부터 시작하세요. +설정에 맞는 가장 간단한 방식부터 시작하세요. -| 수행하려는 작업 | 권장 방법 | 자세히 알아보기 | +| 원하는 작업 | 권장 방식 | 자세히 보기 | | --- | --- | --- | -| OpenAI 모델만 사용 | Responses 모델 경로와 함께 기본 OpenAI 공급자 사용 | [OpenAI 모델](#openai-models) | -| WebSocket 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 WebSocket 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | -| OpenAI 호스트 서브에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | -| OpenAI 이외의 공급자 하나 사용 | 기본 제공 공급자 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | -| 에이전트 간에 모델 또는 공급자 혼합 | 실행별 또는 에이전트별로 공급자를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [공급자 간 모델 혼합](#mixing-models-across-providers) | +| OpenAI 모델만 사용 | Responses 모델 경로에서 기본 OpenAI 프로바이더 사용 | [OpenAI 모델](#openai-models) | +| 웹소켓 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 웹소켓 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | +| OpenAI 호스트 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | +| OpenAI 이외의 단일 프로바이더 사용 | 기본 제공 프로바이더 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | +| 에이전트 간에 모델 또는 프로바이더 혼합 | 실행별 또는 에이전트별로 프로바이더를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [여러 프로바이더의 모델 혼합](#mixing-models-across-providers) | | 고급 OpenAI Responses 요청 설정 조정 | OpenAI Responses 경로에서 `ModelSettings` 사용 | [고급 OpenAI Responses 설정](#advanced-openai-responses-settings) | -| OpenAI 이외의 공급자 또는 혼합 공급자 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 배포하려는 공급자 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | +| OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 출시할 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | ## OpenAI 모델 -OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 공급자와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 것이 좋습니다. +OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 것이 좋습니다. -`Agent`를 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위해 `reasoning.effort="none"` 및 `verbosity="low"`가 설정된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 액세스 권한이 있다면 명시적인 `model_settings`를 유지하면서 더 높은 품질을 얻을 수 있도록 에이전트를 `gpt-5.6-sol`로 설정하는 것이 좋습니다. +`Agent`를 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위해 `reasoning.effort="none"` 및 `verbosity="low"`로 설정된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 사용할 수 있다면 명시적인 `model_settings`를 유지하면서 더 높은 품질을 제공하는 `gpt-5.6-sol`로 에이전트를 설정하는 것이 좋습니다. -`gpt-5.6-sol` 같은 다른 모델로 전환하려면 두 가지 방법으로 에이전트를 구성할 수 있습니다. +`gpt-5.6-sol`과 같은 다른 모델로 전환하려면 두 가지 방법으로 에이전트를 구성할 수 있습니다. ### 기본 모델 -먼저 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. +첫째, 사용자 지정 모델이 설정되지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 모델 -이 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`를 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 적용됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`를 전달하세요. +이 방식으로 `gpt-5.6-sol`과 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`를 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 적용됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`를 전달하세요. ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -지연 시간을 줄이려면 GPT-5 모델에 `reasoning.effort="none"`을 사용하는 것이 좋습니다. +지연 시간을 줄이려면 GPT-5 모델에서 `reasoning.effort="none"`을 사용하는 것이 좋습니다. -GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 유지되는 추론 컨텍스트, `"max"` 추론 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. +GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 영구 저장되는 추론 컨텍스트, `"max"` 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. ```python from openai.types.shared import Reasoning @@ -96,23 +96,23 @@ agent = Agent( ) ``` -`reasoning.mode`와 `reasoning.context`는 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 추론 수준은 모델과 API 표면에 따라 다릅니다. GPT-5.6의 `"max"` 추론 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고와 함께 모드와 컨텍스트를 무시합니다. 해당 경고를 오류로 전환하려면 OpenAI 공급자에서 `strict_feature_validation=True`를 설정하세요. +`reasoning.mode`와 `reasoning.context`는 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 인터페이스에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고와 함께 모드와 컨텍스트를 무시합니다. 이 경고를 오류로 전환하려면 OpenAI 프로바이더에서 `strict_feature_validation=True`를 설정하세요. -`context="all_turns"`를 사용할 때는 `previous_response_id`, 서버 측 대화 또는 이전 추론 항목 재생을 통해 대화를 보존하세요. 상태 비저장 `store=False` 호출의 경우 응답에 `reasoning.encrypted_content`를 포함하고 다음 요청에서 해당 추론 항목을 재생하세요. +`context="all_turns"`를 사용할 때는 `previous_response_id`, 서버 측 대화 또는 이전 추론 항목의 재실행을 통해 대화를 보존하세요. 상태를 유지하지 않는 `store=False` 호출에서는 응답에 `reasoning.encrypted_content`를 포함하고 다음 요청에서 해당 추론 항목을 다시 전달하세요. #### ComputerTool 모델 선택 -에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청의 유효 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 정식 출시된 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. +에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청의 유효 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 정식 출시된 기본 제공 `computer` 도구를 사용하지만, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. -프롬프트 관리형 호출이 주요 예외입니다. 프롬프트 템플릿이 모델을 소유하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 어떤 모델을 고정하는지 추측하지 않도록 프리뷰 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 정식 출시 경로를 유지하려면 요청에 `model="gpt-5.5"`를 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 정식 출시 선택기를 강제하세요. +프롬프트 관리형 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 소유하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 고정한 모델을 추측하지 않도록 프리뷰 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 정식 출시 경로를 유지하려면 요청에 `model="gpt-5.5"`를 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 정식 출시 선택기를 강제로 지정하세요. -[`ComputerTool`][agents.tool.ComputerTool]이 등록된 경우 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`는 유효 요청 모델에 맞는 기본 제공 선택기로 정규화됩니다. `ComputerTool`이 등록되지 않은 경우 이러한 문자열은 일반 함수 이름처럼 계속 동작합니다. +등록된 [`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 유효 요청 모델과 일치하는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. -프리뷰 호환 요청은 `environment`와 디스플레이 크기를 사전에 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩터리를 사용하는 프롬프트 관리형 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 정식 출시 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. +프리뷰 호환 요청은 `environment`와 디스플레이 크기를 사전에 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리형 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 정식 출시 선택기를 강제로 지정해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. #### GPT-5 이외의 모델 -사용자 지정 `model_settings` 없이 GPT-5가 아닌 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. +사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. ### Responses 전용 도구 기능 @@ -120,14 +120,14 @@ agent = Agent( - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 표면 +- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 인터페이스 - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers`, `tool_choice="programmatic_tool_calling"` -이러한 기능은 Chat Completions 모델과 Responses가 아닌 백엔드에서 거부됩니다. 지연 로딩 도구를 사용하는 경우 에이전트에 `ToolSearchTool()`을 추가하고, 네임스페이스 이름이나 지연 로딩 전용 함수 이름을 직접 강제하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 조건은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. +이러한 기능은 Chat Completions 모델 및 Responses가 아닌 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 단독 네임스페이스 이름이나 지연 전용 함수 이름을 강제로 지정하는 대신 `auto` 또는 `required` 도구 선택을 통해 모델이 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 사항은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래매틱 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. ### Responses WebSocket 전송 -기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 WebSocket 전송을 사용하도록 설정할 수 있습니다. +기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 웹소켓 전송을 선택적으로 활성화할 수 있습니다. #### 기본 설정 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 공급자가 해석하는 OpenAI Responses 모델에 영향을 줍니다. 여기에는 `"gpt-5.6-sol"` 같은 문자열 모델 이름도 포함됩니다. +이는 기본 OpenAI 프로바이더가 결정하는 OpenAI Responses 모델에 적용되며, `"gpt-5.6-sol"`과 같은 문자열 모델 이름도 포함됩니다. -전송 방식은 SDK가 모델 이름을 모델 인스턴스로 해석할 때 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 정해져 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 유지합니다. `RunConfig(model_provider=...)`를 전달하면 전역 기본값 대신 해당 공급자가 전송 방식을 선택합니다. +전송 방식은 SDK가 모델 이름을 모델 인스턴스로 결정할 때 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 웹소켓을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 계속 사용합니다. `RunConfig(model_provider=...)`를 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식 선택을 제어합니다. -#### 공급자 또는 실행 수준 설정 +#### 프로바이더 또는 실행 수준 설정 -공급자별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. +프로바이더별 또는 실행별로 웹소켓 전송을 구성할 수도 있습니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI 기반 공급자는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID 같은 공급자 수준의 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. +OpenAI 기반 프로바이더는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID와 같은 프로바이더 수준 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. ```python from agents import ( @@ -190,14 +190,14 @@ result = await Runner.run( #### `MultiProvider`를 사용한 고급 라우팅 -접두사 기반 모델 라우팅이 필요한 경우(예: 한 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]를 사용하고 여기에서 `openai_use_responses_websocket=True`를 설정하세요. +접두사 기반 모델 라우팅이 필요한 경우(예: 한 번의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]를 사용하고 여기에서 `openai_use_responses_websocket=True`를 설정하세요. `MultiProvider`는 다음 두 가지 기존 기본 동작을 유지합니다. -- `openai/...`는 OpenAI 공급자의 별칭으로 취급되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`로 라우팅됩니다. +- `openai/...`는 OpenAI 프로바이더의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`로 라우팅됩니다. - 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`를 발생시킵니다. -OpenAI 공급자가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키는 경우 통과 동작을 명시적으로 활성화하세요. WebSocket이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`를 유지하세요. +OpenAI 프로바이더가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키도록 설정하는 경우, 명시적으로 통과 동작을 활성화하세요. 웹소켓이 활성화된 설정에서는 `MultiProvider`에서도 `openai_use_responses_websocket=True`를 유지하세요. ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,25 +223,27 @@ result = await Runner.run( ) ``` -백엔드가 리터럴 `openai/...` 문자열을 요구할 때는 `openai_prefix_mode="model_id"`를 사용하세요. 백엔드가 `openrouter/openai/gpt-4.1-mini` 같은 다른 네임스페이스 모델 ID를 요구할 때는 `unknown_prefix_mode="model_id"`를 사용하세요. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화된 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. +백엔드가 리터럴 `openai/...` 문자열을 요구하는 경우 `openai_prefix_mode="model_id"`를 사용하세요. 백엔드가 `openrouter/openai/gpt-4.1-mini`와 같은 다른 네임스페이스 모델 ID를 요구하는 경우 `unknown_prefix_mode="model_id"`를 사용하세요. 이러한 옵션은 웹소켓 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 웹소켓을 활성화된 상태로 유지합니다. 같은 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. -`MultiProvider`를 통해 라우팅하면서 동일한 공급자 수준 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`를 전달하면 기본 OpenAI 공급자로 전달됩니다. +`MultiProvider`를 통해 라우팅하면서 동일한 프로바이더 수준 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`를 전달하면 내부 OpenAI 프로바이더로 전달됩니다. -사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 WebSocket 전송에도 호환되는 WebSocket `/responses` 엔드포인트가 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. +사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 웹소켓 전송에는 호환되는 웹소켓 `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. #### 참고 사항 -- 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않으며, Responses WebSocket `/responses` 엔드포인트를 지원하지 않는 OpenAI 이외의 공급자에도 적용되지 않습니다. -- 환경에 아직 `websockets` 패키지가 없다면 설치하세요. -- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 직접 사용할 수 있습니다. 여러 턴과 중첩된 에이전트 도구 호출에서 동일한 WebSocket 연결을 재사용하려는 멀티턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 도우미를 사용하는 것이 좋습니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. -- 긴 추론 턴이나 지연 시간이 급증하는 네트워크에서는 `responses_websocket_options`를 사용하여 WebSocket 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화된 상태로 유지하면서 하트비트 시간 제한을 비활성화하려면 `ping_timeout=None`을 설정하세요. WebSocket 지연 시간보다 안정성이 더 중요하다면 HTTP/SSE 전송을 사용하는 것이 좋습니다. -- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 실행되는 수명이 긴 에이전트 프로세스나 메모리가 제한된 컨테이너에서는 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`를 설정하세요. +- 이는 [Realtime API](../realtime/guide.md)가 아니라 웹소켓 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않으며, Responses 웹소켓 `/responses` 엔드포인트를 지원하지 않는 OpenAI 이외의 프로바이더에도 적용되지 않습니다. +- 환경에 아직 없다면 `websockets` 패키지를 설치하세요. +- 웹소켓 전송을 활성화한 직후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 직접 사용할 수 있습니다. 여러 턴에 걸쳐 동일한 웹소켓 연결을 재사용하려는 워크플로에서는 중첩된 Agents-as-tools 호출을 포함하여 [`responses_websocket_session()`][agents.responses_websocket_session] 도우미를 사용하는 것이 좋습니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. +- 추론 턴이 길거나 네트워크 지연이 급증하는 경우 `responses_websocket_options`를 사용하여 웹소켓 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화된 상태로 유지하면서 하트비트 시간 제한을 비활성화하려면 `ping_timeout=None`을 설정하세요. 웹소켓 지연 시간보다 안정성이 더 중요하면 HTTP/SSE 전송을 사용하세요. +- SDK는 기본적으로 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 장기간 실행되는 에이전트 프로세스나 메모리가 제한된 컨테이너에서는 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`를 설정하여 메시지별 메모리 사용량을 제한하세요. +- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 각 연결을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 여세요. 병렬 실행이 필요한 경우 여러 연결을 사용하세요. +- 서비스는 연결 로컬 메모리에 가장 최근 응답만 유지합니다. 실패한 `4xx` 또는 `5xx` 턴은 참조된 `previous_response_id`를 제거합니다. 재연결 후에도 저장된 응답은 사용할 수 있는 경우 계속 이어갈 수 있지만, `store=False` 및 ZDR 흐름에는 영구 저장된 대체 수단이 없습니다. `previous_response_id=None`으로 새 체인을 시작하고 전체 입력 컨텍스트를 보내거나 로컬에서 관리하는 세션 상태를 사용하여 해당 컨텍스트를 다시 구성하세요. ### 호스티드 멀티 에이전트(실험적) -OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 서브에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`를 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되며, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. +OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 하위 에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 기존 `Runner`를 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 이루어지고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. -이 통합은 실험적이며, 로컬 함수 출력을 `response.inject`를 통해 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`를 노출하는 베타 빌드를 포함하여 `openai[realtime]>=2.45.0`이 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. +이 통합은 실험적이며, 로컬 함수 출력을 `response.inject`를 통해 활성 호스티드 에이전트로 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`를 제공하는 베타 빌드를 포함한 `openai[realtime]>=2.45.0`이 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. #### 모델 구성 @@ -262,9 +264,9 @@ agent = Agent( #### 로컬 함수 도구 -모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. 어떤 호스티드 에이전트가 함수를 호출할지는 Responses API가 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 포함된 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 그러면 서비스가 원래 호스티드 호출자를 재개할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 훅, 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`가 아닌 함수 도구는 요청을 보내기 전에 거부됩니다. +모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. Responses API는 어떤 호스티드 에이전트가 함수를 호출할지 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 있는 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 그러면 서비스가 원래의 호스티드 호출자를 재개할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 훅, 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`가 아닌 함수 도구는 요청이 전송되기 전에 거부됩니다. -도구에 호출자 인식 로깅이나 권한 부여가 필요한 경우 `get_hosted_agent_metadata()`를 사용하세요. +도구에 호출자를 인식하는 로깅이나 권한 부여가 필요한 경우 `get_hosted_agent_metadata()`를 사용하세요. ```python from typing import Any @@ -281,48 +283,48 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID를 사용하여 출력을 라우팅하세요. 부작용이 있는 도구의 경우 해당 호출 ID를 멱등성 키로 사용하고, 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. +호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID를 사용하여 출력을 라우팅하세요. 부작용이 있는 도구에서는 해당 호출 ID를 멱등성 키로 사용하고, 도구 실행 전이나 도중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. #### 출력 및 스트리밍 동작 -`final_answer` 단계에서 `/root`에 귀속된 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 서브에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 해당 레코드를 로컬 함수로 실행하지 않습니다. +`final_answer` 단계가 있는 `/root`의 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 해당 레코드를 로컬 함수로 실행하지 않습니다. -원문 스트리밍에서는 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트가 계속 노출됩니다. 어댑터는 함수 호출이 준비되면 하나의 활성 공급자 응답을 SDK에 표시되는 논리적 모델 턴으로 나누고, Runner가 출력을 생성한 후 동일한 공급자 응답을 재개합니다. 귀속 정보를 확인하려면 원문 호스티드 항목 또는 `ToolContext`와 함께 `get_hosted_agent_metadata()`를 사용하세요. +원문 스트리밍에서는 호스티드 출력 항목과 `response.inject.created` 확인 응답을 포함한 베타 Responses 이벤트가 계속 노출됩니다. 어댑터는 함수 호출이 준비되면 활성 프로바이더 응답 하나를 SDK에 표시되는 논리적 모델 턴으로 나누고, Runner가 출력을 생성한 후 동일한 프로바이더 응답을 재개합니다. 원문 호스티드 항목 또는 `ToolContext`와 함께 `get_hosted_agent_metadata()`를 사용하여 출처를 확인하세요. #### SDK 오케스트레이션과의 관계 -호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. +호스티드 멀티 에이전트는 SDK 핸드오프 및 agents-as-tools와 별개입니다. -- 호스티드 멀티 에이전트는 OpenAI 서비스에서 서브에이전트를 생성합니다. 애플리케이션은 해당 서브에이전트를 생성하거나 예약하지 않습니다. -- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 거부됩니다. +- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 해당 하위 에이전트를 생성하거나 예약하지 않습니다. +- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 이 실험적 모델을 사용하면 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 핸드오프가 거부됩니다. - Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. #### 현재 제한 사항 -실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공한 `multi_agent` 또는 `betas` 재정의를 거부합니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`를 사용할 수는 있지만, Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. +실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공한 `multi_agent` 또는 `betas` 재정의를 거부합니다. Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 다만 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`는 사용할 수 있습니다. -하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행을 중단하는 경우 `await model.close()`를 호출하여 WebSocket을 해제하세요. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. +하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단된 경우 `await model.close()`를 호출하여 WebSocket을 해제하세요. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. 기본 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)를 참조하세요. ## OpenAI 이외의 모델 -OpenAI 이외의 공급자가 필요한 경우 SDK의 기본 제공 공급자 통합 지점으로 시작하세요. 대부분의 설정에서는 서드 파티 어댑터를 추가하지 않아도 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. +OpenAI 이외의 프로바이더가 필요한 경우 SDK의 기본 제공 프로바이더 통합 지점부터 시작하세요. 많은 설정에서 서드 파티 어댑터를 추가하지 않아도 이것만으로 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. -### OpenAI 이외의 공급자 통합 방식 +### OpenAI 이외의 프로바이더 통합 방식 | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 대부분 또는 모든 에이전트에 하나의 OpenAI 호환 엔드포인트를 기본값으로 사용해야 하는 경우 | 전역 기본값 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 공급자를 단일 실행에 적용해야 하는 경우 | 실행별 | -| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 서로 다른 공급자 또는 구체적인 모델 객체가 필요한 경우 | 에이전트별 | -| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 어댑터 관리형 공급자 지원 범위 또는 라우팅이 필요한 경우 | [서드 파티 어댑터](#third-party-adapters) 참조 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트를 대부분 또는 모든 에이전트의 기본값으로 사용해야 하는 경우 | 전역 기본값 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 프로바이더를 단일 실행에 적용해야 하는 경우 | 실행별 | +| [`Agent.model`][agents.agent.Agent.model] | 서로 다른 에이전트에 서로 다른 프로바이더 또는 구체적인 모델 객체가 필요한 경우 | 에이전트별 | +| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 어댑터 관리형 프로바이더 지원 범위 또는 라우팅이 필요한 경우 | [서드 파티 어댑터](#third-party-adapters) 참조 | -다음 기본 제공 경로를 사용하여 다른 LLM 공급자를 통합할 수 있습니다. +다음과 같은 기본 제공 경로를 사용하여 다른 LLM 프로바이더를 통합할 수 있습니다. -1. [`set_default_openai_client`][agents.set_default_openai_client]는 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우에 유용합니다. LLM 공급자가 OpenAI 호환 API 엔드포인트를 제공하고 `base_url`과 `api_key`를 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. -2. [`ModelProvider`][agents.models.interface.ModelProvider]는 `Runner.run` 수준에서 적용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 공급자를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. -3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 공급자를 조합할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. +1. [`set_default_openai_client`][agents.set_default_openai_client]는 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우 유용합니다. 이는 LLM 프로바이더에 OpenAI 호환 API 엔드포인트가 있고 `base_url`과 `api_key`를 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. +2. [`ModelProvider`][agents.models.interface.ModelProvider]는 `Runner.run` 수준에 적용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 프로바이더를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. +3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 프로바이더를 조합하여 사용할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. `platform.openai.com`에서 발급한 API 키가 없는 경우 `set_tracing_disabled()`를 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것이 좋습니다. @@ -339,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 이 예제에서는 아직 많은 LLM 공급자가 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 공급자가 Responses API를 지원한다면 Responses를 사용하는 것이 좋습니다. + 이 예제에서는 많은 LLM 프로바이더가 아직 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. 사용 중인 LLM 프로바이더가 Responses를 지원한다면 Responses를 사용하는 것이 좋습니다. ## 하나의 워크플로에서 모델 혼합 -단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 중 한 가지 방법으로 특정 모델을 선택할 수 있습니다. +단일 워크플로 내에서 에이전트별로 서로 다른 모델을 사용해야 할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고 복잡한 작업에는 더 크고 강력한 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. 1. 모델 이름 전달 2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider] 전달 -3. [`Model`][agents.models.interface.Model] 구현 직접 제공 +3. [`Model`][agents.models.interface.Model] 구현을 직접 제공 !!! note - SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]과 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식이 서로 다른 기능 및 도구 세트를 지원하므로 워크플로마다 하나의 모델 형식을 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 조합해야 한다면 사용하는 모든 기능을 두 형식에서 모두 사용할 수 있는지 확인하세요. + SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]과 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만 두 형식이 서로 다른 기능과 도구 집합을 지원하므로 각 워크플로에서 하나의 모델 형식을 사용하는 것이 좋습니다. 워크플로에 모델 형식 혼합이 필요한 경우 사용 중인 모든 기능을 두 형식 모두에서 사용할 수 있는지 확인하세요. ```python import asyncio @@ -392,7 +394,7 @@ if __name__ == "__main__": 1. OpenAI 모델의 이름을 직접 설정합니다. 2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. -에이전트에 사용되는 모델을 추가로 구성하려면 temperature 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.models.interface.ModelSettings]를 전달할 수 있습니다. +에이전트에 사용되는 모델을 더 세부적으로 구성하려면 temperature와 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.model_settings.ModelSettings]를 전달할 수 있습니다. ```python from agents import Agent, ModelSettings @@ -407,22 +409,22 @@ english_agent = Agent( ## 고급 OpenAI Responses 설정 -OpenAI Responses 경로에서 더 세밀한 제어가 필요한 경우 `ModelSettings`부터 사용하세요. +OpenAI Responses 경로에서 더 많은 제어가 필요하면 `ModelSettings`부터 사용하세요. ### 일반적인 고급 `ModelSettings` 옵션 -OpenAI Responses API를 사용하는 경우 여러 요청 필드가 이미 직접적인 `ModelSettings` 필드로 제공되므로 해당 필드에 `extra_args`를 사용할 필요가 없습니다. +OpenAI Responses API를 사용할 때 여러 요청 필드에는 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 `extra_args`를 사용할 필요가 없습니다. -- `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출을 허용하거나 금지합니다. -- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 삭제하도록 `"auto"`를 설정합니다. +- `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출 허용 또는 금지 +- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 삭제하도록 `"auto"` 설정 - `store`: 생성된 응답을 나중에 검색할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로와 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. -- `context_management`: `compact_threshold`를 사용하는 Responses 압축과 같은 서버 측 컨텍스트 처리를 구성합니다. -- `prompt_cache_retention`: 이전 모델 계열의 연장된 보존 기간을 구성합니다. 예를 들면 - `"24h"`입니다. -- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고, GPT-5.6의 경우 `"30m"` 캐시 TTL을 구성합니다. -- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 같은 더 풍부한 응답 페이로드를 요청합니다. -- `top_logprobs`: 출력 텍스트에 대해 상위 토큰 logprobs를 요청합니다. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. -- `retry`: 모델 호출에 대해 Runner 관리형 재시도 설정을 사용하도록 선택합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. +- `context_management`: `compact_threshold`를 사용하는 Responses 압축 등 서버 측 컨텍스트 처리 구성 +- `prompt_cache_retention`: 이전 모델 계열에 대한 연장 보존 구성(예: + `"24h"`) +- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고 GPT-5.6에서는 `"30m"` 캐시 TTL 구성 +- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 등 더 풍부한 응답 페이로드 요청 +- `top_logprobs`: 출력 텍스트의 상위 토큰 로그 확률 요청. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. +- `retry`: 모델 호출에 Runner 관리형 재시도 설정을 선택적으로 활성화합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. ```python from agents import Agent, ModelSettings @@ -442,7 +444,7 @@ research_agent = Agent( ) ``` -명시적 프롬프트 캐싱을 사용할 때는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 보존합니다. +명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 유지합니다. ```python from agents import Runner @@ -468,18 +470,19 @@ result = await Runner.run( ) ``` -`prompt_cache_retention`은 기존 보존 제어를 사용하는 이전 모델 계열에서 계속 사용할 수 있습니다. -직접적인 `ModelSettings` 필드와 `extra_args`에 동일한 키를 함께 사용하지 마세요. +`prompt_cache_retention`은 기존 보존 제어를 사용하는 이전 모델 계열에서 계속 사용할 수 +있습니다. 직접적인 `ModelSettings` 필드와 동일한 키를 `extra_args`에서 함께 +사용하지 마세요. -`store=False`를 설정하면 Responses API는 해당 응답을 나중에 서버 측에서 검색할 수 있도록 유지하지 않습니다. 이는 상태 비저장 또는 데이터 비보존 형태의 흐름에 유용하지만, 일반적으로 응답 ID를 재사용하는 기능이 로컬에서 관리되는 상태에 의존해야 함을 의미합니다. 예를 들어 마지막 응답이 저장되지 않은 경우 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. +`store=False`를 설정하면 Responses API는 해당 응답을 나중에 서버 측에서 검색할 수 있도록 보관하지 않습니다. 이는 상태 비저장 또는 데이터 미보존 방식의 흐름에 유용하지만, 응답 ID를 재사용할 수 있었던 기능이 대신 로컬에서 관리하는 상태에 의존해야 한다는 의미이기도 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않은 경우 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. -서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`는 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립 실행형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. +서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`는 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. ### `extra_args` 전달 -SDK가 아직 최상위 수준에서 직접 노출하지 않는 공급자별 요청 필드나 최신 요청 필드가 필요한 경우 `extra_args`를 사용하세요. +SDK가 아직 최상위 수준에서 직접 노출하지 않는 프로바이더별 또는 최신 요청 필드가 필요할 때 `extra_args`를 사용하세요. -또한 OpenAI의 Responses API를 사용할 때는 [몇 가지 다른 선택적 매개변수](https://platform.openai.com/docs/api-reference/responses/create)(예: `user`, `service_tier` 등)를 사용할 수 있습니다. 최상위 수준에서 사용할 수 없다면 `extra_args`를 통해 전달할 수도 있습니다. 동일한 요청 필드를 직접적인 `ModelSettings` 필드를 통해 함께 설정하지 마세요. +또한 OpenAI의 Responses API를 사용할 때 [몇 가지 다른 선택적 매개변수](https://platform.openai.com/docs/api-reference/responses/create)(예: `user`, `service_tier` 등)가 있습니다. 최상위 수준에서 사용할 수 없는 경우 `extra_args`를 사용하여 전달할 수도 있습니다. 직접적인 `ModelSettings` 필드를 통해 동일한 요청 필드를 함께 설정하지 마세요. ```python from agents import Agent, ModelSettings @@ -497,7 +500,9 @@ english_agent = Agent( ## Runner 관리형 재시도 -재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`를 설정하고 재시도 정책이 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. +재시도는 런타임 전용이며 선택적으로 활성화됩니다. `ModelSettings(retry=...)`를 설정하고 재시도 정책에서 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. + +Responses 웹소켓 전송에서 `retry_policies.provider_suggested()`는 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`가 필요하며 일반적인 재실행 안전성 검사도 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -525,85 +530,85 @@ agent = Agent( ) ``` -`ModelRetrySettings`에는 세 가지 필드가 있습니다. +`ModelRetrySettings`에는 세 개의 필드가 있습니다.
-| 필드 | 유형 | 참고 사항 | +| 필드 | 유형 | 참고 | | --- | --- | --- | | `max_retries` | `int | None` | 최초 요청 이후 허용되는 재시도 횟수입니다. | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적인 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`는 계산된 이 백오프 지연만 제한합니다. 정책이 반환한 명시적 지연이나 retry-after 힌트는 제한하지 않습니다. | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`는 이렇게 계산된 백오프 지연에만 상한을 적용합니다. 정책이 반환한 명시적 지연이나 retry-after 힌트에는 상한을 적용하지 않습니다. | | `policy` | `RetryPolicy | None` | 재시도 여부를 결정하는 콜백입니다. 이 필드는 런타임 전용이며 직렬화되지 않습니다. |
재시도 정책은 다음 항목이 포함된 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. -- 시도 횟수에 따라 결정을 내릴 수 있도록 제공되는 `attempt`와 `max_retries` +- 시도 횟수를 고려하여 결정할 수 있도록 제공되는 `attempt` 및 `max_retries` - 스트리밍 및 비스트리밍 동작을 분기할 수 있도록 제공되는 `stream` - 원문 검사를 위한 `error` -- `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort` 같은 정규화된 정보가 포함된 `normalized` -- 기본 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용되는 `provider_advice` +- `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort`와 같은 정규화된 정보가 포함된 `normalized` +- 내부 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용되는 `provider_advice` 정책은 다음 중 하나를 반환할 수 있습니다. - 간단한 재시도 결정을 위한 `True` / `False` -- 지연 시간을 재정의하거나 진단 사유를 첨부하려는 경우 사용하는 [`RetryDecision`][agents.retry.RetryDecision] +- 지연 시간을 재정의하거나 진단 사유를 첨부하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] -SDK는 `retry_policies`에서 바로 사용할 수 있는 도우미를 내보냅니다. +SDK는 `retry_policies`에 즉시 사용할 수 있는 도우미를 제공합니다. | 도우미 | 동작 | | --- | --- | | `retry_policies.never()` | 항상 재시도하지 않습니다. | -| `retry_policies.provider_suggested()` | 공급자의 재시도 지침이 있으면 이를 따릅니다. | +| `retry_policies.provider_suggested()` | 프로바이더 재시도 지침이 있으면 이를 따릅니다. | | `retry_policies.network_error()` | 일시적인 전송 및 시간 제한 실패와 일치합니다. | -| `retry_policies.http_status([...])` | 선택된 HTTP 상태 코드와 일치합니다. | -| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 도우미는 retry-after 값을 명시적인 정책 지연으로 취급하므로 `backoff.max_delay`가 이를 제한하지 않습니다. | +| `retry_policies.http_status([...])` | 선택한 HTTP 상태 코드와 일치합니다. | +| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 도우미는 retry-after 값을 명시적 정책 지연으로 처리하므로 `backoff.max_delay`가 상한을 적용하지 않습니다. | | `retry_policies.any(...)` | 중첩된 정책 중 하나라도 재시도를 선택하면 재시도합니다. | -| `retry_policies.all(...)` | 중첩된 모든 정책이 재시도를 선택할 때만 재시도합니다. | +| `retry_policies.all(...)` | 모든 중첩 정책이 재시도를 선택할 때만 재시도합니다. | -정책을 조합할 때 `provider_suggested()`는 가장 안전한 첫 번째 기본 구성 요소입니다. 공급자가 이를 구분할 수 있는 경우 공급자의 거부 결정과 재생 안전성 승인을 보존하기 때문입니다. +정책을 조합할 때는 `provider_suggested()`가 가장 안전한 첫 번째 기본 구성 요소입니다. 프로바이더가 거부 및 재실행 안전성 승인을 구분할 수 있는 경우 이를 보존하기 때문입니다. ##### 안전 경계 일부 실패는 자동으로 재시도되지 않습니다. - 중단 오류 -- 공급자 지침에서 재생이 안전하지 않다고 표시한 요청 -- 재생이 안전하지 않게 되는 방식으로 출력이 이미 시작된 스트리밍 실행 +- 프로바이더 지침에서 재실행이 안전하지 않다고 표시한 요청 +- 출력이 이미 시작되어 재실행이 안전하지 않은 스트리밍 실행 -`previous_response_id` 또는 `conversation_id`를 사용하는 상태 유지형 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 공급자 외부 조건만으로 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`를 통한 공급자의 재생 안전 승인이 포함되어야 합니다. +`previous_response_id` 또는 `conversation_id`를 사용하는 상태 유지형 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()`나 `http_status([500])`와 같은 비프로바이더 조건만으로 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`를 통한 프로바이더의 재실행 안전성 승인이 포함되어야 합니다. ##### Runner 및 에이전트 병합 동작 -`retry`는 Runner 수준과 에이전트 수준의 `ModelSettings` 간에 심층 병합됩니다. +`retry`는 Runner 수준 및 에이전트 수준의 `ModelSettings` 간에 깊은 병합이 적용됩니다. -- 에이전트는 `retry.max_retries`만 재정의하면서 Runner의 `policy`를 상속할 수 있습니다. -- 에이전트는 `retry.backoff`의 일부만 재정의하고 Runner의 나머지 백오프 필드를 유지할 수 있습니다. +- 에이전트가 `retry.max_retries`만 재정의하고 Runner의 `policy`를 계속 상속할 수 있습니다. +- 에이전트가 `retry.backoff`의 일부만 재정의하고 Runner의 나머지 백오프 필드를 유지할 수 있습니다. - `policy`는 런타임 전용이므로 직렬화된 `ModelSettings`에는 `max_retries`와 `backoff`가 유지되지만 콜백 자체는 생략됩니다. -더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py)와 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. +더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. -## OpenAI 이외의 공급자 문제 해결 +## OpenAI 이외의 프로바이더 문제 해결 ### 트레이싱 클라이언트 오류 401 -트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. +트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되는데 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. -1. 트레이싱 완전히 비활성화: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] +1. 트레이싱을 완전히 비활성화: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] 2. 트레이싱용 OpenAI 키 설정: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급한 키여야 합니다. 3. OpenAI 이외의 트레이스 프로세서 사용. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. ### Responses API 지원 -SDK는 기본적으로 Responses API를 사용하지만, 아직 많은 다른 LLM 공급자가 이를 지원하지 않습니다. 그 결과 404 또는 이와 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. +SDK는 기본적으로 Responses API를 사용하지만 다른 많은 LLM 프로바이더는 아직 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. -1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]를 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY`와 `OPENAI_BASE_URL`을 설정하는 경우 작동합니다. -2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. +1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] 호출. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. +2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 사용. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. ### Chat Completions 호환성 옵션 -Chat Completions를 통해 라우팅하면 SDK는 `previous_response_id`, `conversation_id`, 프롬프트 또는 텍스트 전용이 아닌 도구 출력과 같이 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 별도의 알림 없이 제거하여 호환성을 유지합니다. 개발 중 이러한 불일치가 발생하면 즉시 실패하도록 하려면 OpenAI 공급자에서 엄격한 기능 검증을 활성화하세요. +Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, 프롬프트 또는 텍스트 전용이 아닌 도구 출력 등 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 자동으로 삭제하여 호환성을 유지합니다. 개발 중 이러한 불일치를 즉시 실패로 처리하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화하세요. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -623,7 +628,7 @@ result = await Runner.run( [`MultiProvider`][agents.MultiProvider]를 사용하는 경우 대신 `openai_strict_feature_validation=True`를 전달하세요. -일부 OpenAI 호환 Chat Completions 공급자는 증분 SDK 처리에 충분히 안정적이지 않은 청크 단위로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 공급자 스트림이 완료된 후에만 SDK가 도구 호출을 내보내도록 하세요. +일부 OpenAI 호환 Chat Completions 프로바이더는 점진적 SDK 처리에 충분히 신뢰할 수 없는 청크 형태로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 프로바이더 스트림이 완료된 후에만 SDK가 도구 호출을 내보내도록 하세요. ```python from agents import OpenAIProvider @@ -634,11 +639,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider]에는 `openai_buffer_streamed_tool_calls=True`를 사용하세요. +[`MultiProvider`][agents.MultiProvider]에서는 `openai_buffer_streamed_tool_calls=True`를 사용하세요. -### structured outputs 지원 +### Structured outputs 지원 -일부 모델 공급자는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 지원하지 않습니다. 이로 인해 다음과 유사한 오류가 발생하기도 합니다. +일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 지원하지 않습니다. 이 경우 때때로 다음과 같은 오류가 발생합니다. ``` @@ -646,42 +651,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -이는 일부 모델 공급자의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`는 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 공급자를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. +이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`를 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만 JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 JSON 때문에 앱이 자주 중단될 수 있습니다. -## 공급자 간 모델 혼합 +## 여러 프로바이더의 모델 혼합 -모델 공급자 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 공급자는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. +모델 프로바이더 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. -- 이해하지 못하는 공급자에 지원되지 않는 `tools`를 보내지 마세요 -- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요 -- 구조화된 JSON 출력을 지원하지 않는 공급자는 때때로 잘못된 JSON을 생성할 수 있다는 점에 유의하세요. +- 이해하지 못하는 프로바이더에 지원되지 않는 `tools`를 전송하지 마세요. +- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. +- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 잘못된 JSON을 생성할 수 있다는 점에 유의하세요. ## 서드 파티 어댑터 -SDK의 기본 제공 공급자 통합 지점만으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM이나 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 공급자와 결합해야 하거나, 기본 제공 경로에서 제공하지 않는 어댑터 관리형 공급자 지원 범위 또는 라우팅이 필요한 경우를 위한 것입니다. 어댑터는 SDK와 업스트림 모델 공급자 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계는 공급자에 따라 달라질 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선형 베타 어댑터 통합으로 포함되어 있습니다. +SDK의 기본 제공 프로바이더 통합 지점만으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 프로바이더와 결합하거나, 기본 제공 경로가 제공하지 않는 어댑터 관리형 프로바이더 지원 범위 또는 라우팅이 필요한 경우에 사용합니다. 어댑터는 SDK와 상위 모델 프로바이더 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 프로바이더별로 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선 지원 방식의 베타 어댑터 통합으로 포함되어 있습니다. ### Any-LLM -Any-LLM 지원은 Any-LLM 관리형 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. +Any-LLM이 관리하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 Any-LLM 지원이 최선 지원 방식의 베타로 포함되어 있습니다. -업스트림 공급자 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 공급자별 호환성 계층을 사용할 수 있습니다. +상위 프로바이더 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 프로바이더별 호환성 계층을 사용할 수 있습니다. -Any-LLM이 필요한 경우 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)에서 시작하세요. [`MultiProvider`][agents.MultiProvider]와 함께 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`를 사용할 수 있습니다. 모델 표면을 명시적으로 고정해야 하는 경우 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`를 전달하세요. +Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 후 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)부터 시작하세요. [`MultiProvider`][agents.MultiProvider]에서 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`를 사용할 수 있습니다. 모델 인터페이스를 명시적으로 고정해야 하는 경우 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`를 전달하세요. -Any-LLM은 계속 서드 파티 어댑터 계층으로 유지되므로 공급자 종속성과 기능 차이는 SDK가 아니라 업스트림 Any-LLM에 의해 정의됩니다. 업스트림 공급자가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드에서 사용량 청크를 내보내려면 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses별 동작에 의존하는 경우 배포하려는 정확한 공급자 백엔드를 검증하세요. +Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 차이는 SDK가 아닌 Any-LLM 상위 계층에서 정의합니다. 상위 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드가 사용량 청크를 내보내려면 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 전용 동작에 의존하는 경우 배포하려는 정확한 프로바이더 백엔드를 검증하세요. ### LiteLLM -LiteLLM 지원은 LiteLLM별 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. +LiteLLM별 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 LiteLLM 지원이 최선 지원 방식의 베타로 포함되어 있습니다. -LiteLLM이 필요한 경우 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)에서 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. +LiteLLM이 필요하면 `openai-agents[litellm]`을 설치한 후 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)부터 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. -일부 LiteLLM 기반 공급자는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요한 경우 `ModelSettings(include_usage=True)`를 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 공급자 백엔드를 검증하세요. +일부 LiteLLM 기반 프로바이더는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요한 경우 `ModelSettings(include_usage=True)`를 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. -LiteLLM이 응답 객체에 대해 Pydantic 직렬화 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. +LiteLLM이 응답 객체에 대한 Pydantic 직렬화 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 선택적으로 활성화할 수 있습니다. ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -이 패치는 기본적으로 비활성화되어 있으며 값이 `1` 또는 `true`일 때만 활성화됩니다. 비공개 LiteLLM 로깅 도우미를 래핑하여 특정 LiteLLM 응답 직렬화 경고를 억제하므로 일반적인 직렬화 설정이 아니라 한정된 해결 방법으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고, 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file +이 패치는 기본적으로 비활성화되며 `1` 또는 `true` 값에 대해서만 활성화됩니다. 비공개 LiteLLM 로깅 도우미를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로 일반 직렬화 설정이 아닌 특정 문제를 위한 우회책으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고 상위 계층에서 더 이상 경고가 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file diff --git a/docs/ko/release.md b/docs/ko/release.md index 3d86d00252..300b87637a 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -4,51 +4,51 @@ search: --- # 릴리스 프로세스/변경 로그 -이 프로젝트는 `0.Y.Z` 형식을 사용하는 약간 수정된 시맨틱 버저닝을 따릅니다. 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. +이 프로젝트는 `0.Y.Z` 형식을 사용하는 약간 변형된 시맨틱 버저닝을 따릅니다. 맨 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. ## 마이너(`Y`) 버전 -베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨뜨리는 변경 사항**이 있을 경우 마이너 버전 `Y`를 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨뜨리는 변경 사항이 포함될 수 있습니다. +베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 발생하면 마이너 버전 `Y`를 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경할 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. -호환성을 깨뜨리는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것을 권장합니다. +호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. ## 패치(`Z`) 버전 -호환성을 깨뜨리지 않는 변경 사항에는 `Z`를 증가시킵니다. +하위 호환성을 유지하는 다음 변경 사항에는 `Z`를 증가시킵니다. - 버그 수정 - 새로운 기능 - 비공개 인터페이스 변경 - 베타 기능 업데이트 -## 호환성을 깨뜨리는 변경 로그 +## 호환성을 깨는 변경 사항의 변경 로그 ### 0.19.0 -이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새 기능 영역인 프로그래매틱 도구 호출(Programmatic Tool Calling)을 반영합니다. +이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 상향은 OpenAI Responses의 주요 신규 기능 영역인 프로그래밍 방식 도구 호출(Programmatic Tool Calling)을 반영합니다. 주요 내용: -- 지원되는 OpenAI Responses 모델이 적격한 도구를 조정하는 JavaScript를 생성할 수 있게 해주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 추가했습니다. 도구별 `allowed_callers`, 구조화된 함수 도구 출력, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState` 통합을 지원합니다. 설정 및 제약 조건은 [프로그래매틱 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. -- 기존 함수 및 가드레일 데코레이터와 함께 공개 `agents.decorators` 모듈과 더 짧은 `@tool` 별칭을 추가했습니다. 이제 함수 도구에서 비동기 callable 객체도 지원합니다. -- 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인의 SDK 설정에서 타입이 지정된 설정 객체 또는 딕셔너리를 일관되게 허용하며, 알 수 없는 설정도 검증합니다. -- 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅을 강화하여 유용한 디버깅 컨텍스트를 유지하면서 민감한 원본 페이로드가 노출되지 않도록 했습니다. -- AnyLLM, LiteLLM 및 Chat Completions 호환성을 개선하고, 모델 재시도 시 세션 기록을 보존하며, 응답이 시작되기 전에 발생하는 WebSocket 과부하 오류를 재시도하도록 했습니다. -- `VercelCloudBucketMountStrategy`를 사용하는 [Vercel 샌드박스용 생성 시점 전용 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)를 추가했습니다. 마운트가 포함된 세션에서는 워크스페이스 영속화 시 버킷 콘텐츠를 제외하며, 동적 마운트 변경과 세션 재개를 의도적으로 지원하지 않습니다. +- 지원되는 OpenAI Responses 모델이 대상 도구를 조정하는 JavaScript를 생성할 수 있게 해주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 추가했습니다. 도구별 `allowed_callers`, 구조화된 함수 도구 출력, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState`와의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래밍 방식 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. +- 공개 `agents.decorators` 모듈과 기존 함수 및 가드레일 데코레이터보다 짧은 `@tool` 별칭을 추가했습니다. 이제 함수 도구는 비동기 호출 가능 객체도 지원합니다. +- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인 전반에서 타입이 지정된 설정 객체나 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. +- 유용한 디버깅 컨텍스트는 유지하면서 민감한 원문 페이로드가 노출되지 않도록 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅을 강화했습니다. +- AnyLLM, LiteLLM 및 Chat Completions 호환성을 개선하고, 모델 재시도 중에도 세션 기록을 유지하도록 했으며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 공급자 재시도 지침을 추가했습니다. 이를 통해 요청 재생이 허용되는 경우 명시적으로 활성화한 Runner 재시도 정책이 작동할 수 있습니다. +- `VercelCloudBucketMountStrategy`를 통해 [Vercel 샌드박스에서 생성 시에만 사용할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)를 추가했습니다. 마운트된 세션에서는 버킷 내용이 워크스페이스 영속화 대상에서 제외되며, 의도적으로 동적 마운트 변경이나 세션 재개를 지원하지 않습니다. ### 0.18.0 -이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만을 반영합니다. +이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 상향은 Realtime agents의 기본 모델 업데이트만 반영합니다. 주요 내용: -- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새 Realtime 설정에서 별도의 구성 없이 최신 권장 모델을 사용합니다. +- 이제 Realtime agents는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서는 별도의 구성 없이 최신 권장 모델을 사용합니다. ### 0.17.0 -이 버전에서는 샌드박스 로컬 소스 구체화 시 소스 경로가 `Manifest.extra_path_grants`에 포함되지 않는 한 `LocalFile.src`와 `LocalDir.src`가 구체화 `base_dir` 내에 유지됩니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 해당 디렉터리 내부 또는 명시적 허용 범위 아래에 있어야 합니다. 이는 로컬 아티팩트 경계 문제를 해결하지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 의도적으로 샌드박스 작업 공간에 복사하는 애플리케이션에는 영향을 줄 수 있습니다. +이 버전에서는 샌드박스 로컬 소스 구체화 시 소스 경로가 `Manifest.extra_path_grants`의 적용 대상이 아닌 한 `LocalFile.src`와 `LocalDir.src`를 구체화 `base_dir` 내부로 제한합니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 경로 로컬 소스는 이 디렉터리를 기준으로 해석되며, 절대 경로 로컬 소스는 이미 이 디렉터리 내부에 있거나 명시적으로 권한이 부여된 경로 아래에 있어야 합니다. 이 변경으로 로컬 아티팩트 경계 문제가 해결되지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에는 영향을 줄 수 있습니다. -마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`를 사용하여 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스가 해당 파일을 읽기만 하면 되는 경우에는 읽기 전용으로 설정하는 것이 좋습니다. +마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`를 사용하여 신뢰할 수 있는 호스트 루트에 권한을 부여하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우에는 읽기 전용 권한을 사용하는 것이 좋습니다. ```python from pathlib import Path @@ -75,13 +75,13 @@ manifest = Manifest( ) ``` -`extra_path_grants`를 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 매니페스트 입력을 사용하여 허용 범위를 채우지 마세요. +`extra_path_grants`는 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 신뢰할 수 없는 다른 매니페스트 입력으로 권한을 채우면 안 됩니다. ### 0.16.0 -이 버전에서는 SDK 기본 모델이 `gpt-4.1`에서 `gpt-5.4-mini`로 변경되었습니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본 모델이 GPT-5 모델이므로 암시적인 기본 모델 설정에 이제 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. +이 버전에서는 SDK 기본 모델이 `gpt-4.1`에서 `gpt-5.4-mini`로 변경되었습니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새로운 기본 모델이 GPT-5 모델이므로 암시적 기본 모델 설정에도 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. -이전 기본 모델 동작을 유지해야 한다면 에이전트 또는 실행 구성에서 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. +이전의 기본 모델 동작을 유지해야 한다면 에이전트나 실행 구성에서 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. ```python agent = Agent(name="Assistant", model="gpt-4.1") @@ -89,14 +89,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 주요 내용: -- 이제 `Runner.run`, `Runner.run_sync`, `Runner.run_streamed`에서 `max_turns=None`을 지정하여 턴 제한을 비활성화할 수 있습니다. -- 이제 로컬, Docker 및 공급자 기반 샌드박스 구현 전반에서 샌드박스 작업 공간 하이드레이션이 절대 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. +- 이제 `Runner.run`, `Runner.run_sync` 및 `Runner.run_streamed`에서 `max_turns=None`을 지정하여 턴 제한을 비활성화할 수 있습니다. +- 이제 로컬, Docker 및 공급자 기반 샌드박스 구현 전반에서 샌드박스 워크스페이스 하이드레이션이 절대 경로 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. ### 0.15.0 -이 버전에서는 모델 거부가 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`에 도달할 때까지 재시도하게 하는 대신, 이제 `ModelRefusalError`로 명시적으로 노출됩니다. +이 버전에서는 모델 거부 응답이 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`에 도달할 때까지 재시도하게 만드는 대신, 이제 `ModelRefusalError`로 명시적으로 노출됩니다. -이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. +이는 이전에 거부 응답만 포함된 모델 응답이 `final_output == ""`인 상태로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부 응답을 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. ```python result = Runner.run_sync( @@ -106,94 +106,94 @@ result = Runner.run_sync( ) ``` -structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 스키마과 일치하는 값을 반환할 수 있으며, SDK는 이를 다른 실행 오류 핸들러의 최종 출력과 동일하게 검증합니다. +구조화된 출력을 사용하는 에이전트의 경우 핸들러가 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. ### 0.14.0 -이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없지만**, 새로운 주요 베타 기능 영역인 샌드박스 에이전트와 이를 로컬, 컨테이너화 및 호스팅 환경에서 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. +이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 새로운 주요 베타 기능 영역인 샌드박스 에이전트(Sandbox Agents)와 이를 로컬, 컨테이너화 및 호스팅 환경 전반에서 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. 주요 내용: -- `SandboxAgent`, `Manifest`, `SandboxRunConfig`를 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스를 추가하여 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 지원이 포함된 영구 격리 작업 공간에서 작업할 수 있도록 했습니다. -- `UnixLocalSandboxClient`와 `DockerSandboxClient`를 통한 로컬 및 컨테이너화 개발용 샌드박스 실행 백엔드와 선택적 추가 패키지를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel의 호스팅 공급자 통합을 추가했습니다. -- 이후 실행에서 이전 실행의 학습 내용을 재사용할 수 있도록 샌드박스 메모리 지원을 추가했으며, 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계, S3 기반 워크플로를 포함한 영구 메모리 예제를 제공합니다. -- 로컬 및 합성 작업 공간 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하는 더욱 폭넓은 작업 공간 및 재개 모델을 추가했습니다. -- `examples/sandbox/` 아래에 기술을 활용한 코딩 작업, 핸드오프, 메모리, 공급자별 설정과 코드 리뷰, 데이터룸 QA, 웹사이트 복제 등의 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제와 튜토리얼을 추가했습니다. -- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더 안전한 민감한 MCP 출력 마스킹 기능으로 핵심 런타임과 트레이싱 스택을 확장했습니다. +- `SandboxAgent`, `Manifest` 및 `SandboxRunConfig`를 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스를 추가하여 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 기능을 갖춘 영구 격리 워크스페이스 내부에서 작업할 수 있도록 했습니다. +- `UnixLocalSandboxClient` 및 `DockerSandboxClient`를 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드를 추가하고, 선택적 추가 종속성을 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel의 호스팅 공급자 통합을 추가했습니다. +- 이후 실행에서 이전 실행의 교훈을 재사용할 수 있도록 샌드박스 메모리 지원을 추가했습니다. 여기에는 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계 및 S3 기반 워크플로를 포함한 영속 메모리 예제가 포함됩니다. +- 로컬 및 합성 워크스페이스 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하여 워크스페이스 및 재개 모델을 확장했습니다. +- `examples/sandbox/` 아래에 스킬, 핸드오프, 메모리를 사용하는 코딩 작업, 공급자별 설정, 코드 리뷰, 데이터룸 QA 및 웹사이트 복제와 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제와 튜토리얼을 추가했습니다. +- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더욱 안전한 민감한 MCP 출력 마스킹을 통해 핵심 런타임과 트레이싱 스택을 확장했습니다. ### 0.13.0 -이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정 사항이 포함되어 있습니다. +이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정 사항이 포함되었습니다. 주요 내용: -- 기본 WebSocket Realtime 모델이 이제 `gpt-realtime-1.5`이므로, 새 Realtime 에이전트 설정에서 별도의 구성 없이 더 최신 모델을 사용합니다. -- 이제 `MCPServer`에서 `list_resources()`, `list_resource_templates()`, `read_resource()`를 제공하며, `MCPServerStreamableHttp`에서 `session_id`를 제공하므로 재연결 또는 무상태 워커 간에 스트리밍 가능한 HTTP 세션을 재개할 수 있습니다. -- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`를 통해 추론 콘텐츠 재생을 선택적으로 활성화할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 공급자별 추론 및 도구 호출 연속성이 개선됩니다. -- `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, 함수 도구 배치 실행기의 경합 상태 등 여러 런타임 및 세션 엣지 케이스를 수정했습니다. +- 이제 기본 WebSocket Realtime 모델은 `gpt-realtime-1.5`이므로, 새로운 Realtime 에이전트 설정에서는 별도의 구성 없이 더 최신 모델을 사용합니다. +- 이제 `MCPServer`는 `list_resources()`, `list_resource_templates()` 및 `read_resource()`를 제공하며, `MCPServerStreamableHttp`는 `session_id`를 제공하므로 스트리밍 가능 HTTP 세션을 재연결하거나 상태 비저장 워커 간에 재개할 수 있습니다. +- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`를 통해 추론 콘텐츠 재생을 선택적으로 활성화할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 공급자별 추론/도구 호출 연속성이 향상됩니다. +- `SQLAlchemySession`에서 동시에 수행되는 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, `remove_all_tools()`가 MCP/추론 항목을 남기는 문제, 함수 도구 배치 실행기의 경합 조건을 포함하여 여러 런타임 및 세션 경계 사례를 수정했습니다. ### 0.12.0 -이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. +이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 참조하세요. ### 0.11.0 -이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. +이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 참조하세요. ### 0.10.0 -이번 마이너 릴리스에는 호환성을 깨뜨리는 변경 사항이 **없지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함되어 있습니다. +이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, OpenAI Responses 사용자를 위한 주요 신규 기능 영역인 Responses API의 WebSocket 전송 지원이 포함되었습니다. 주요 내용: -- OpenAI Responses 모델에 대한 WebSocket 전송 지원을 추가했습니다. 이 기능은 선택적으로 활성화하며 HTTP가 기본 전송 방식으로 유지됩니다. -- 멀티턴 실행 간에 WebSocket을 지원하는 공유 공급자와 `RunConfig`를 재사용할 수 있도록 `responses_websocket_session()` 도우미/`ResponsesWebSocketSession`을 추가했습니다. +- OpenAI Responses 모델에 대한 WebSocket 전송 지원을 추가했습니다. 이는 선택적으로 활성화할 수 있으며, HTTP는 계속 기본 전송 방식으로 사용됩니다. +- 멀티턴 실행 전반에서 공유 WebSocket 지원 공급자와 `RunConfig`를 재사용할 수 있도록 `responses_websocket_session()` 헬퍼와 `ResponsesWebSocketSession`을 추가했습니다. - 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 예제(`examples/basic/stream_ws.py`)를 추가했습니다. ### 0.9.0 -이 버전에서는 주요 버전이 3개월 전에 지원 종료(EOL)에 도달함에 따라 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. +이 버전에서는 Python 3.9가 더 이상 지원되지 않습니다. 해당 메이저 버전이 3개월 전에 지원 종료(EOL)에 도달했기 때문입니다. 더 최신 런타임 버전으로 업그레이드하세요. -또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁혀졌습니다. 이 변경으로 일반적으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니언 타입에 의존한다면 일부 조정이 필요할 수 있습니다. +또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁아졌습니다. 이 변경은 일반적으로 호환성을 깨는 문제를 일으키지 않지만, 코드에서 더 넓은 유니온 타입에 의존하는 경우 일부 조정이 필요할 수 있습니다. ### 0.8.0 -이 버전에서는 다음 두 가지 런타임 동작 변경 사항으로 인해 마이그레이션 작업이 필요할 수 있습니다. +이 버전에서는 다음 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. -- **동기식** Python callable을 래핑하는 함수 도구는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`를 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태나 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시하세요. -- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 노출되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로, 명시적 핸들러가 있는 각 로컬 MCP 서버에서 `failure_error_function=None`을 설정하세요. +- **동기식** Python 호출 가능 객체를 래핑하는 함수 도구는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`를 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태나 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시적으로 지정하세요. +- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 빠른 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로, 명시적 핸들러가 있는 각 로컬 MCP 서버에서 `failure_error_function=None`을 설정하세요. ### 0.7.0 -이 버전에는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경 사항이 있습니다. +이 버전에서는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작이 변경되었습니다. -- 이제 중첩 핸드오프 기록은 **선택적 활성화** 방식이며 기본적으로 비활성화됩니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`를 명시적으로 설정하세요. -- `gpt-5.1`/`gpt-5.2`의 기본 `reasoning.effort`가 SDK 기본값으로 구성된 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트 또는 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 명시적으로 설정하세요. +- 중첩 핸드오프 기록은 이제 **선택적 활성화 방식**이며 기본적으로 비활성화됩니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`를 명시적으로 설정하세요. +- `gpt-5.1` / `gpt-5.2`의 기본 `reasoning.effort`가 SDK 기본값으로 구성되었던 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트나 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. ### 0.6.0 -이 버전에서는 원문 사용자/어시스턴트 턴을 노출하는 대신 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여 다운스트림 에이전트에 간결하고 예측 가능한 요약을 제공합니다. -- 이제 기존의 단일 메시지 핸드오프 대화 기록은 기본적으로 `` 블록 앞에 "참고를 위해 사용자와 이전 에이전트 간의 지금까지 대화 내용을 제공합니다:"라는 문구로 시작하므로, 다운스트림 에이전트가 명확히 표시된 요약을 받습니다. +이 버전에서는 기본 핸드오프 기록이 사용자/어시스턴트 턴 원문을 노출하는 대신 단일 어시스턴트 메시지로 묶이므로 다운스트림 에이전트에 간결하고 예측 가능한 요약을 제공합니다 +- 기존 단일 메시지 핸드오프 기록은 이제 기본적으로 `` 블록 앞에서 "For context, here is the conversation so far between the user and the previous agent:"로 시작하므로 다운스트림 에이전트가 명확한 레이블이 지정된 요약을 받습니다 ### 0.5.0 -이 버전에는 눈에 띄는 호환성을 깨뜨리는 변경 사항이 없지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되어 있습니다. +이 버전에는 눈에 보이는 호환성을 깨는 변경 사항이 없지만, 새로운 기능과 몇 가지 중요한 내부 업데이트가 포함되었습니다. -- `RealtimeRunner`가 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리할 수 있도록 지원을 추가했습니다. +- `RealtimeRunner`에서 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리할 수 있도록 지원을 추가했습니다. - Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직을 대폭 수정했습니다. ### 0.4.0 -이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전을 더 이상 지원하지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. +이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전이 더 이상 지원되지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. ### 0.3.0 -이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. +이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 전환됩니다. ### 0.2.0 -이 버전에서는 이전에 `Agent`를 인수로 받던 일부 위치가 이제 대신 `AgentBase`를 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 호출이 이에 해당합니다. 이는 순수한 타입 변경이며, 여전히 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`를 `AgentBase`로 교체하여 타입 오류를 수정하기만 하면 됩니다. +이 버전에서는 이전에 `Agent`를 인수로 받던 몇몇 부분이 이제 `AgentBase`를 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 호출이 이에 해당합니다. 이는 순수한 타입 변경이며, 계속해서 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`를 `AgentBase`로 바꿔 타입 오류를 수정하면 됩니다. ### 0.1.0 -이 버전에서는 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에 `run_context`와 `agent`라는 두 개의 새로운 매개변수가 추가되었습니다. `MCPServer`를 서브클래싱하는 모든 클래스에 이 매개변수를 추가해야 합니다. +이 버전에서는 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에 `run_context`와 `agent`라는 새로운 매개변수 두 개가 추가되었습니다. `MCPServer`를 상속하는 모든 클래스에 이러한 매개변수를 추가해야 합니다. \ No newline at end of file diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index 13572fac0b..81ecbe8efc 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -6,9 +6,9 @@ search: [`Runner`][agents.run.Runner] 클래스를 통해 에이전트를 실행할 수 있습니다. 다음 3가지 옵션이 있습니다. -1. [`Runner.run()`][agents.run.Runner.run]: 비동기적으로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 메서드이며 내부적으로 `.run()`을 실행합니다. -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기적으로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하며, 이벤트를 수신하는 즉시 스트리밍합니다. +1. [`Runner.run()`][agents.run.Runner.run]: 비동기 방식으로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 방식의 메서드이며 내부적으로 `.run()`을 실행합니다. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기 방식으로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고 이벤트가 수신되는 대로 스트리밍합니다. ```python from agents import Agent, Runner @@ -29,40 +29,40 @@ async def main(): ### 에이전트 루프 -`Runner`에서 실행 메서드를 사용할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. +`Runner`의 실행 메서드를 사용할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. - 문자열(사용자 메시지로 처리) - OpenAI Responses API 형식의 입력 항목 목록 - 인터럽션(중단 처리)된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState] -그런 다음 Runner는 다음과 같이 루프를 실행합니다. +그런 다음 러너는 루프를 실행합니다. 1. 현재 입력을 사용하여 현재 에이전트의 LLM을 호출합니다. 2. LLM이 출력을 생성합니다. 1. LLM이 `final_output`을 반환하면 루프를 종료하고 결과를 반환합니다. 2. LLM이 핸드오프를 수행하면 현재 에이전트와 입력을 업데이트한 후 루프를 다시 실행합니다. 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. -3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. +3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외를 발생시킵니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 기준은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 경우입니다. + LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 타입의 텍스트 출력을 생성하고 도구 호출이 없는 경우입니다. ### 스트리밍 -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 포함하여 실행에 대한 전체 정보가 담깁니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 포함한 전체 실행 정보가 담깁니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송(선택적 헬퍼) +#### Responses WebSocket 전송(선택적 도우미) -OpenAI Responses 웹소켓 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 웹소켓 세션 헬퍼를 사용하는 것이 권장되지만 필수는 아닙니다. +OpenAI Responses websocket 전송을 활성화해도 일반적인 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 websocket 세션 도우미를 사용하는 것이 권장되지만 필수는 아닙니다. -이는 웹소켓 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. +이는 websocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. 전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 공급자 관련 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 헬퍼 미사용(지원됨) +##### 패턴 1: 세션 도우미 미사용(작동함) -웹소켓 전송만 사용하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. +websocket 전송만 필요하고 SDK가 공유 공급자나 세션을 관리할 필요가 없을 때 사용하세요. ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. +이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`를 반복적으로 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. ##### 패턴 2: `responses_websocket_session()` 사용(여러 턴에서 재사용 시 권장) -여러 실행에서 웹소켓을 지원하는 공유 공급자와 `RunConfig`를 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 도구로서의 에이전트 호출도 포함됩니다. +여러 실행에서 websocket을 지원하는 공유 공급자와 `RunConfig`를 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 에이전트 도구 호출도 포함됩니다. ```python import asyncio @@ -119,13 +119,15 @@ async def main(): asyncio.run(main()) ``` -컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 사용해야 합니다. 웹소켓 요청이 아직 진행 중인 상태에서 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. +컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 소비하세요. websocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. -긴 추론 턴에서 웹소켓 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. 웹소켓 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. +서비스는 각 websocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간은 60분으로 제한됩니다. 도우미는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`를 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용하여 체인을 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. + +긴 추론 턴에서 websocket 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. ### 실행 구성 -`run_config` 매개변수를 사용하면 에이전트 실행에 대한 일부 전역 설정을 구성할 수 있습니다. +`run_config` 매개변수를 사용하면 에이전트 실행의 일부 전역 설정을 구성할 수 있습니다. #### 일반적인 실행 구성 카테고리 @@ -133,42 +135,42 @@ asyncio.run(main()) ##### 모델, 공급자 및 세션 기본값 -- [`model`][agents.run.RunConfig.model]: 각 에이전트의 `model` 설정과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. -- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며, 기본값은 OpenAI입니다. +- [`model`][agents.run.RunConfig.model]: 각 Agent의 `model` 설정과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. +- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며 기본값은 OpenAI입니다. - [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`를 설정할 수 있습니다. -- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 턴 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. +- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 검색할 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 턴 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기 방식일 수 있습니다. ##### 가드레일, 핸드오프 및 모델 입력 구성 - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 필터가 이미 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 무손실 메시지 항목은 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 선택적 베타 기능입니다. 중첩 핸드오프의 안정화가 진행되는 동안에는 기본적으로 비활성화됩니다. 활성화하려면 `True`로 설정하고 원문 트랜스크립트를 그대로 전달하려면 `False`로 유지하세요. Sessions, `RunState`, `RunResult.to_input_list()`는 SDK 기본 중첩 기록이 이미 소유한 동일한 메시지 인스턴스를 두 번 추가하지 않으면서 별개의 동일한 메시지는 유지합니다. [Runner 메서드][agents.run.Runner]는 `RunConfig`를 전달하지 않으면 모두 자동으로 생성하므로 빠른 시작과 코드 예제에서는 기본적으로 이 기능이 비활성화되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 활성화할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고 기본 제공 순차 요약 세그먼트를 대체하려면 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델을 호출하기 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 주입할 수 있습니다. -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 유지할지 생략할지 제어합니다. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 필터가 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 무손실 메시지 항목을 원래 위치에 보존하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 선택적 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화되어 있습니다. 활성화하려면 `True`로 설정하고, 원문 트랜스크립트를 그대로 전달하려면 `False`로 두세요. Sessions, `RunState`, `RunResult.to_input_list()`는 SDK 기본 중첩 기록에 이미 포함된 정확히 동일한 메시지 인스턴스를 두 번 추가하지 않으면서 별개의 동일한 메시지는 보존합니다. 모든 [Runner 메서드][agents.run.Runner]는 `RunConfig`를 전달하지 않으면 자동으로 생성하므로 빠른 시작과 예제에서는 기본적으로 비활성화 상태가 유지되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 활성화할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 수신하는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순서형 요약 세그먼트를 대체할 수 있도록 다음 에이전트에 전달할 입력 항목의 정확한 목록을 반환해야 합니다. +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: 러너가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. ##### 트레이싱 및 관측 가능성 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에서 [트레이싱](tracing.md)을 비활성화할 수 있습니다. - [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]를 전달합니다. -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: LLM 및 도구 호출의 입력/출력과 같이 잠재적으로 민감한 데이터를 트레이스에 포함할지 구성합니다. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출의 입력/출력과 같이 잠재적으로 민감한 데이터를 포함할지 구성합니다. - [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`은 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. ##### 도구 실행, 승인 및 도구 오류 동작 -- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행되는 함수 도구 수 제한과 같이 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성했지만 해결할 수 없는 함수 도구 호출을 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`가 발생하며, 대신 모델에 표시되는 오류 출력을 반환하도록 선택할 수 있습니다. -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 선택적으로 활성화한 도구를 찾을 수 없음 출력과 같이 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: 동시에 실행할 함수 도구 수 제한과 같은 로컬 도구 호출의 SDK 측 실행 동작을 구성합니다. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 확인 불가능한 함수 도구 호출을 러너가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`를 발생시키며, 대신 모델에 표시되는 오류 출력을 반환하도록 선택할 수 있습니다. +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 선택적으로 활성화된 도구 미발견 출력과 같이 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. -중첩 핸드오프는 선택적 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`를 전달하여 순차 트랜스크립트 압축을 활성화하거나, 특정 핸드오프에서 활성화하려면 `handoff(..., nest_handoff_history=True)`를 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하지 않고 생성된 어시스턴트 요약 세그먼트를 무손실 메시지 항목 주위에 배치합니다. 원문 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 필요한 방식으로 대화를 정확히 전달하는 `handoff_input_filter` 또는 `handoff_history_mapper`를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에서 사용하는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 기본값으로 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]를 호출하세요. +중첩된 핸드오프는 선택적 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`를 전달하여 순서형 트랜스크립트 압축을 활성화하거나 `handoff(..., nest_handoff_history=True)`로 설정하여 특정 핸드오프에서 활성화하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하는 대신 생성된 어시스턴트 요약 세그먼트를 무손실 메시지 항목 주변에 배치합니다. 원문 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 대화를 필요한 형태 그대로 전달하는 `handoff_input_filter` 또는 `handoff_history_mapper`를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트의 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]를 호출합니다. #### 실행 구성 세부 정보 ##### `tool_execution` -실행 중 로컬 함수 도구의 동시 실행 수 제한과 같이 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. +실행 중 로컬 함수 도구의 동시 실행 수 제한과 같은 로컬 함수 도구의 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -187,17 +189,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행할 수 있는 로컬 함수 도구 수를 제한하려면 정숫값을 설정하세요. +`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 수를 제한하려면 정숫값을 설정하세요. -이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지를 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 로컬 함수 도구 호출을 생성한 후 SDK가 이를 실행하는 방식을 제어합니다. +이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. -`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 먼저 실행이 일시 중지되고, 승인이 완료된 후 실행 직전에만 도구 입력 가드레일이 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출에도 승인 후 동일한 입력 가드레일이 다시 실행되므로, 시간에 민감한 검사가 실행 전에 다시 검증됩니다. +`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요한 경우 실행이 먼저 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 작동합니다. 보류 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 승인 전 검사를 통과한 호출에도 승인 후 동일한 입력 가드레일이 다시 적용되므로, 시간에 민감한 검사는 실행 전에 다시 검증됩니다. ##### `tool_not_found_behavior` -기본적으로 모델이 현재 에이전트에서 사용 가능한 함수 도구와 일치하지 않는 함수 도구 호출을 생성하면 Runner에서 `ModelBehaviorError`가 발생합니다. +기본적으로 모델이 현재 에이전트에서 사용할 수 있는 함수 도구와 일치하지 않는 함수 도구 호출을 생성하면 러너는 `ModelBehaviorError`를 발생시킵니다. -실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서 SDK는 해결되지 않은 도구 호출에 대한 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. +실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`로 설정하세요. 이 모드에서 SDK는 확인 불가능한 도구 호출에 대한 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. ```python from agents import Agent, RunConfig, Runner @@ -211,15 +213,15 @@ result = await Runner.run( ) ``` -현재 이 옵션은 해결되지 않은 함수 도구 호출에만 적용됩니다. 그 밖의 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 확인 불가능한 함수 도구 호출에만 적용됩니다. 그 외의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. ##### `tool_error_formatter` SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`를 사용하세요. -포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. +포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 수신합니다. -- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`과 같은 오류 카테고리 +- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`와 같은 오류 카테고리 - `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`) - `tool_name`: 도구 이름 - `call_id`: 도구 호출 ID @@ -253,56 +255,56 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`는 Runner가 기록을 다음 턴으로 전달할 때 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. 예를 들어 `RunResult.to_input_list()`를 사용하거나 세션 기반 실행을 사용할 때 적용됩니다. +`reasoning_item_id_policy`는 러너가 기록을 다음 턴으로 전달할 때 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. 예를 들어 `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때 적용됩니다. - `None` 또는 `"preserve"`(기본값): 추론 항목 ID 유지 - `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID 제거 -추론 항목이 `id`와 함께 전송되지만 필수 후속 항목은 없는 경우 발생하는 Responses API 400 오류 유형을 선택적으로 완화하려면 주로 `"omit"`을 사용하세요. 예를 들면 `Item 'rs_...' of type 'reasoning' was provided without its required following item.` 오류가 있습니다. +`"omit"`은 주로 추론 항목이 `id`와 함께 전송되지만 필수 후속 항목 없이 전송되어 발생하는 Responses API 400 오류 유형을 완화하기 위한 선택적 설정입니다. 예를 들면 `Item 'rs_...' of type 'reasoning' was provided without its required following item.` 오류가 있습니다. -이 문제는 여러 턴의 에이전트 실행에서 SDK가 이전 출력으로 후속 입력을 구성할 때 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리형 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID는 유지되지만 공급자가 해당 ID와 대응하는 후속 항목이 함께 유지되도록 요구할 수 있습니다. +이는 SDK가 이전 출력에서 후속 입력을 구성하는 여러 턴의 에이전트 실행에서 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 추론 항목 ID는 보존되지만 공급자가 해당 ID를 관련 후속 항목과 쌍으로 유지하도록 요구할 때 발생합니다. -`reasoning_item_id_policy="omit"`을 설정하면 추론 내용은 유지하면서 추론 항목의 `id`를 제거하므로 SDK가 생성한 후속 입력에서 해당 API 불변 조건을 위반하지 않습니다. +`reasoning_item_id_policy="omit"`으로 설정하면 추론 콘텐츠는 유지하지만 추론 항목의 `id`는 제거하므로 SDK가 생성한 후속 입력에서 해당 API 불변 조건이 위반되는 것을 방지할 수 있습니다. 적용 범위 참고 사항: - SDK가 후속 입력을 구성할 때 생성하거나 전달하는 추론 항목만 변경합니다. - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. -- 이 정책이 적용된 후에도 `call_model_input_filter`를 통해 의도적으로 추론 ID를 다시 추가할 수 있습니다. +- 이 정책이 적용된 후에도 `call_model_input_filter`가 의도적으로 추론 ID를 다시 추가할 수 있습니다. ## 상태 및 대화 관리 ### 메모리 전략 선택 -상태를 다음 턴으로 전달하는 일반적인 방법은 네 가지입니다. +다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태가 저장되는 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | +| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달할 항목 | | --- | --- | --- | --- | -| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`에서 반환된 목록과 다음 사용자 메시지 | -| `session` | 자체 스토리지 및 SDK | 지속되는 채팅 상태, 재개 가능한 실행, 사용자 지정 스토어 | 동일한 `session` 인스턴스 또는 동일한 스토어를 가리키는 다른 인스턴스 | -| `conversation_id` | OpenAI Conversations API | 여러 워커 또는 서비스에서 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 전달 | -| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 실행 | `result.last_response_id`와 새 사용자 턴만 전달 | +| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | +| `session` | 자체 스토리지 및 SDK | 지속형 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 같은 저장소를 가리키는 다른 인스턴스 | +| `conversation_id` | OpenAI Conversations API | 작업자 또는 서비스 간에 공유할 명명된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 전달 | +| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리 연속 처리 | `result.last_response_id`와 새 사용자 턴만 전달 | -`result.to_input_list()`와 `session`은 클라이언트에서 관리합니다. `conversation_id`와 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용하는 경우에만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 두 계층을 의도적으로 조정하지 않는 한 클라이언트 관리형 기록과 OpenAI 관리형 상태를 혼합하면 컨텍스트가 중복될 수 있습니다. +`result.to_input_list()`와 `session`은 클라이언트에서 관리합니다. `conversation_id`와 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 클라이언트 관리 기록과 OpenAI 관리 상태를 혼합하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. !!! note - 세션 지속성은 같은 실행에서 서버 관리형 대화 설정 + 세션 지속성은 동일한 실행에서 서버 관리 대화 설정 (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 - 함께 사용할 수 없습니다. 호출마다 한 가지 접근 방식을 선택하세요. + 함께 사용할 수 없습니다. 호출마다 하나의 접근 방식을 선택하세요. -### 대화/채팅 스레드 +### 대화 및 채팅 스레드 -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며, 그에 따라 하나 이상의 LLM 호출이 발생할 수 있습니다. 하지만 이는 채팅 대화에서 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행되고 이에 따라 하나 이상의 LLM 호출이 발생할 수 있지만, 이는 채팅 대화에서 논리적으로 하나의 턴을 나타냅니다. 예를 들면 다음과 같습니다. 1. 사용자 턴: 사용자가 텍스트를 입력합니다. -2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트가 추가 도구를 실행한 다음 출력을 생성합니다. +2. 러너 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트가 추가 도구를 실행하고 출력을 생성합니다. -에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 쪽이든 사용자가 후속 질문을 할 수 있으며, 이 경우 실행 메서드를 다시 호출할 수 있습니다. +에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 경우든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. #### 수동 대화 관리 -[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 메서드를 사용해 다음 턴의 입력을 가져오는 방식으로 대화 기록을 수동으로 관리할 수 있습니다. +[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 메서드로 다음 턴의 입력을 가져와 대화 기록을 수동으로 관리할 수 있습니다. ```python from agents import Agent, Runner, trace @@ -352,18 +354,18 @@ async def main(): Sessions는 다음 작업을 자동으로 수행합니다. -- 각 실행 전에 대화 기록 가져오기 +- 각 실행 전에 대화 기록 검색 - 각 실행 후 새 메시지 저장 -- 서로 다른 세션 ID별로 별도의 대화 유지 +- 서로 다른 세션 ID의 대화를 별도로 유지 자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. -#### 서버 관리형 대화 +#### 서버 관리 대화 -`to_input_list()` 또는 `Sessions`를 사용해 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 매번 수동으로 다시 전송하지 않고도 대화 기록을 유지할 수 있습니다. 아래 서버 관리형 접근 방식 중 하나를 사용할 때는 각 요청에 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. +`to_input_list()` 또는 `Sessions`를 사용하여 로컬에서 처리하는 대신 OpenAI 대화 상태 기능을 통해 서버 측에서 대화 상태를 관리할 수도 있습니다. 이를 사용하면 이전의 모든 메시지를 수동으로 다시 전송하지 않고 대화 기록을 보존할 수 있습니다. 아래의 서버 관리 접근 방식 중 하나를 사용하는 경우 각 요청에는 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. -OpenAI는 여러 턴에서 상태를 추적하는 두 가지 방법을 제공합니다. +OpenAI는 여러 턴에 걸쳐 상태를 추적하는 두 가지 방법을 제공합니다. ##### 1. `conversation_id` 사용 @@ -415,28 +417,28 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -실행이 승인을 위해 일시 중지된 후 [`RunState`][agents.run_state.RunState]에서 재개하는 경우, SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴은 동일한 서버 관리형 대화에서 계속됩니다. +실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하면 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리 대화에서 계속됩니다. -`conversation_id`와 `previous_response_id`는 함께 사용할 수 없습니다. 여러 시스템에서 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`를 사용하세요. +`conversation_id`와 `previous_response_id`는 상호 배타적입니다. 시스템 간에 공유할 수 있는 명명된 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 턴 사이를 연결하는 가장 가벼운 Responses API 기본 구성 요소가 필요하면 `previous_response_id`를 사용하세요. !!! note - SDK는 `conversation_locked` 오류를 백오프 방식으로 자동 재시도합니다. 서버 관리형 - 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌리므로 준비된 동일한 - 항목을 문제없이 다시 전송할 수 있습니다. + SDK는 `conversation_locked` 오류를 백오프와 함께 자동으로 재시도합니다. 서버 관리 + 대화 실행에서는 재시도 전에 내부 대화 추적기의 입력을 되돌려 + 준비된 동일 항목을 문제없이 다시 전송할 수 있도록 합니다. - 로컬 세션 기반 실행(`conversation_id`, `previous_response_id` 또는 - `auto_previous_response_id`와 함께 사용할 수 없음)에서도 SDK는 최근에 저장된 - 입력 항목을 최선의 방식으로 롤백하여 재시도 후 기록 항목이 중복되는 것을 줄입니다. + `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용할 수 없는 + 로컬 세션 기반 실행에서는 SDK가 최근에 지속된 입력 항목을 가능한 범위에서 + 롤백하여 재시도 후 기록 항목의 중복을 줄입니다. - 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 - 대한 더 광범위한 선택적 재시도 동작은 [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. + 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 대한 + 더 광범위한 선택적 재시도 동작은 [Runner 관리 재시도](models/index.md#runner-managed-retries)를 참조하세요. ## 훅 및 사용자 지정 ### 모델 호출 입력 필터 -모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(세션 기록이 있는 경우 포함)을 받고 새 `ModelInputData`를 반환합니다. +모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 이 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 수신하고 새로운 `ModelInputData`를 반환합니다. 반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`가 발생합니다. @@ -457,19 +459,19 @@ result = Runner.run_sync( ) ``` -Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원래 목록을 직접 변경하지 않고 목록을 줄이거나 대체하거나 순서를 변경할 수 있습니다. +러너는 준비된 입력 목록의 사본을 훅에 전달하므로 호출자의 원래 목록을 제자리에서 변경하지 않고도 항목을 줄이거나 교체하거나 순서를 변경할 수 있습니다. -세션을 사용하는 경우 `call_model_input_filter`는 세션 기록을 이미 불러와 현재 턴과 병합한 후 실행됩니다. 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. +세션을 사용하는 경우 `call_model_input_filter`는 세션 기록이 이미 로드되어 현재 턴과 병합된 후에 실행됩니다. 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. -`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 OpenAI 서버 관리형 대화 상태를 사용하는 경우, 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록의 전체 재전송이 아니라 새 턴의 델타만 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리형 연속 실행에 전송된 것으로 표시됩니다. +OpenAI의 서버 관리 대화 상태를 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용하는 경우 이 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록 전체를 다시 전달하는 대신 새 턴의 델타만 이미 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리 연속 처리에 전송된 것으로 표시됩니다. -민감한 데이터를 제거하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 주입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. +민감한 데이터를 삭제하거나 긴 기록을 줄이거나 추가 시스템 지침을 삽입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. ## 오류 및 복구 ### 오류 핸들러 -모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. +모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 허용합니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. ```python from agents import ( @@ -498,7 +500,7 @@ result = Runner.run_sync( print(result.final_output) ``` -모델 메시지가 에이전트의 구조화된 `output_type`에 대한 검증을 통과하지 못하거나 모델이 구조화된 최종 메시지를 반환하지 않을 때는 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 값을 반환할 수 있으며, SDK는 동일한 `output_type`을 기준으로 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지 않습니다. `None`을 반환하면 복구를 수행하지 않습니다. 대체 값이 없으면 비어 있지 않은 응답의 검증 실패 시 계속 `ModelBehaviorError`가 발생하고, 비어 있는 구조화된 응답에는 기존의 다음 턴 동작이 유지됩니다. +모델 메시지가 에이전트의 구조화된 `output_type`에 대해 유효성 검사를 통과하지 못하거나 모델이 구조화된 최종 메시지를 반환하지 않을 때는 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 출력을 반환할 수 있으며, SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지 않습니다. `None`을 반환하면 복구하지 않습니다. 대체 출력 없이 비어 있지 않은 값의 유효성 검사에 실패하면 계속해서 `ModelBehaviorError`가 발생하며, 비어 있는 구조화된 응답에는 기존의 다음 턴 동작이 유지됩니다. ```python from pydantic import BaseModel @@ -530,9 +532,9 @@ result = Runner.run_sync( print(result.final_output) ``` -대체 출력을 대화 기록에 추가하지 않으려면 `include_in_history=False`를 설정하세요. +대체 출력을 대화 기록에 추가하지 않으려면 `include_in_history=False`로 설정하세요. -모델 거부로 인해 `ModelRefusalError`를 발생시키는 대신 애플리케이션별 대체 값을 생성해야 할 때는 `"model_refusal"`을 사용하세요. +모델의 거부로 실행을 `ModelRefusalError`와 함께 종료하는 대신 애플리케이션별 대체 출력을 생성해야 할 때는 `"model_refusal"`을 사용하세요. ```python from pydantic import BaseModel @@ -564,35 +566,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 지속 실행 통합 및 휴먼인더루프 (HITL) +## 내구성 실행 통합 및 휴먼인더루프 (HITL) -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 실행이 장시간 대기, 재시도 또는 프로세스 재시작에 걸쳐 이어질 수 있는 지속적인 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 실행에 긴 대기, 재시도 또는 프로세스 재시작이 포함될 수 있는 내구성 오케스트레이션을 위한 것입니다. ### Dapr -Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 휴먼인더루프 (HITL)를 지원하고 장애로부터 자동으로 복구되는 지속적인 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr 및 OpenAI 에이전트 시작 방법은 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 확인하세요. +Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 휴먼인더루프 (HITL)를 지원하면서 장애에서 자동으로 복구되는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. ### Temporal -Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함한 지속적인 장기 실행 워크플로를 실행할 수 있습니다. 장기 실행 작업을 완료하기 위해 Temporal과 Agents SDK가 함께 작동하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. +Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함하여 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. ### Restate -Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함한 경량의 지속적인 에이전트를 사용할 수 있습니다. 이 통합에는 Restate의 단일 바이너리 런타임이 종속성으로 필요하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. +Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있도록 지원합니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. ### DBOS -Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 장애와 재시작 중에도 진행 상황을 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. +Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 장애 및 재시작 시에도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. ## 예외 -SDK는 특정한 경우 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에 있습니다. 개요는 다음과 같습니다. +SDK는 특정한 경우에 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. -- [`AgentsException`][agents.exceptions.AgentsException]: SDK 내에서 발생하는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 유형입니다. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과할 때 발생하는 예외입니다. 에이전트가 지정된 상호작용 턴 수 내에 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`을 설정하세요. -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 잘못된 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 경우가 포함될 수 있습니다. - - 잘못된 형식의 JSON: 특히 특정 `output_type`이 정의된 경우, 모델이 도구 호출 또는 직접 출력에서 잘못된 형식의 JSON 구조를 제공할 때 - - 예상하지 못한 도구 관련 오류: 모델이 예상된 방식으로 도구를 사용하지 못할 때 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 제한 시간을 초과하고 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생하는 예외입니다. -- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성하는 사람이 SDK 사용 중 오류를 범했을 때 발생하는 예외입니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 잘못된 사용으로 인해 발생합니다. -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 각각 입력 가드레일 또는 출력 가드레일의 조건이 충족될 때 발생하는 예외입니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. +- [`AgentsException`][agents.exceptions.AgentsException]: SDK 내에서 발생하는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 타입입니다. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과하면 발생하는 예외입니다. 에이전트가 지정된 상호작용 턴 수 안에 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`으로 설정하세요. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상치 못한 출력이나 유효하지 않은 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 경우가 포함될 수 있습니다. + - 잘못된 형식의 JSON: 모델이 도구 호출이나 직접 출력에서 잘못된 형식의 JSON 구조를 제공하는 경우로, 특히 특정 `output_type`이 정의되어 있을 때 발생합니다. + - 예상치 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못하는 경우 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 해당 도구가 `timeout_behavior="raise_exception"`을 사용하는 경우 발생하는 예외입니다. +- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드 작성자가 SDK 사용 중 오류를 범하면 발생하는 예외입니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 잘못된 사용으로 인해 발생합니다. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 각각 입력 가드레일 또는 출력 가드레일의 조건이 충족될 때 발생하는 예외입니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. \ No newline at end of file diff --git a/docs/zh/models/index.md b/docs/zh/models/index.md index cca42ce28a..fa4dfd01f5 100644 --- a/docs/zh/models/index.md +++ b/docs/zh/models/index.md @@ -4,43 +4,43 @@ search: --- # 模型 -Agents SDK 原生支持两种 OpenAI 模型: +Agents SDK 原生支持两种形式的OpenAI模型: -- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API。 -- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API。 +- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用OpenAI API。 +- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用OpenAI API。 -## 模型配置选择 +## 模型设置选择 -从最符合您配置的简单方案开始: +请从最符合您设置的最简单路径开始: -| 如果您想要…… | 推荐方案 | 更多信息 | +| 如果您希望…… | 推荐路径 | 更多信息 | | --- | --- | --- | -| 仅使用 OpenAI 模型 | 使用默认 OpenAI 提供商和 Responses 模型路径 | [OpenAI 模型](#openai-models) | -| 通过 websocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径并启用 websocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | -| 使用由 OpenAI 托管的子智能体 | 使用实验性的托管多智能体模型 | [托管多智能体](#hosted-multi-agent-experimental) | -| 使用一个非 OpenAI 提供商 | 从内置的提供商集成点开始 | [非 OpenAI 模型](#non-openai-models) | -| 在多个智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | +| 仅使用OpenAI模型 | 使用默认OpenAI提供商和 Responses 模型路径 | [OpenAI模型](#openai-models) | +| 通过 WebSocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径并启用 WebSocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | +| 使用由OpenAI托管的子智能体 | 使用实验性的托管多智能体模型 | [托管多智能体](#hosted-multi-agent-experimental) | +| 使用一个非OpenAI提供商 | 从内置的提供商集成点开始 | [非OpenAI模型](#non-openai-models) | +| 在不同智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | | 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径上使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | -| 使用第三方适配器进行非 OpenAI 或混合提供商路由 | 比较受支持的测试版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | +| 使用第三方适配器实现非OpenAI或混合提供商路由 | 比较受支持的 Beta 版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | -## OpenAI 模型 +## OpenAI模型 -对于大多数仅使用 OpenAI 的应用,推荐方案是将字符串模型名称与默认 OpenAI 提供商结合使用,并保持使用 Responses 模型路径。 +对于大多数仅使用OpenAI的应用,推荐使用默认OpenAI提供商的字符串模型名称,并继续使用 Responses 模型路径。 -初始化 `Agent` 时如果未指定模型,将使用默认模型。目前的默认模型是 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并使用 `reasoning.effort="none"` 和 `verbosity="low"`,适合低延迟智能体工作流。如果您拥有访问权限,我们建议将智能体设置为 `gpt-5.6-sol`,以便在保留显式 `model_settings` 的同时获得更高质量。 +如果初始化 `Agent` 时未指定模型,则会使用默认模型。目前的默认模型是 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并设置 `reasoning.effort="none"` 和 `verbosity="low"`,适用于低延迟智能体工作流。如果您拥有访问权限,我们建议将智能体设置为 `gpt-5.6-sol`,以便在保持显式 `model_settings` 的同时获得更高质量。 如果要切换到 `gpt-5.6-sol` 等其他模型,可以通过两种方式配置智能体。 ### 默认模型 -首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 +首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -其次,可以通过 `RunConfig` 为一次运行设置默认模型。如果未为智能体设置模型,则会使用本次运行的模型。 +其次,您可以通过 `RunConfig` 为一次运行设置默认模型。如果没有为智能体设置模型,则会使用此次运行的模型。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 模型 -以这种方式使用 `gpt-5.6-sol` 等任意 GPT-5 模型时,SDK 会应用默认的 `ModelSettings`。这些设置最适合大多数用例。要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: +以这种方式使用任何 GPT-5 模型(例如 `gpt-5.6-sol`)时,SDK 会应用默认的 `ModelSettings`。它会采用最适合大多数用例的设置。要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: ```python from openai.types.shared import Reasoning @@ -75,7 +75,7 @@ my_agent = Agent( ) ``` -为降低延迟,建议对 GPT-5 模型使用 `reasoning.effort="none"`。 +为了降低延迟,建议对 GPT-5 模型使用 `reasoning.effort="none"`。 GPT-5.6 还通过现有的 `reasoning` 设置支持推理模式、持久化推理上下文和 `"max"` 强度级别。这些控制项可在 Responses API 路径上使用: @@ -96,25 +96,25 @@ agent = Agent( ) ``` -`reasoning.mode` 和 `reasoning.context` 是 Responses 专用设置。Chat Completions 仅使用 `reasoning.effort`,支持的强度级别取决于模型和 API 接口。请使用 Responses API 实现 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;在 OpenAI 提供商上设置 `strict_feature_validation=True` 可将该警告转换为错误。 +`reasoning.mode` 和 `reasoning.context` 是仅限 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,支持的强度级别取决于模型和 API 接口。请使用 Responses API 启用 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;在OpenAI提供商上设置 `strict_feature_validation=True` 可将该警告转换为错误。 -使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端对话或重放先前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中包含 `reasoning.encrypted_content`,并在下一次请求时重放这些推理项。 +使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端对话或重放先前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中包含 `reasoning.encrypted_content`,并在下一个请求中重放这些推理项。 #### ComputerTool 模型选择 -如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],实际 Responses 请求中的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求则继续使用旧版 `computer_use_preview` 载荷。 +如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],实际 Responses 请求所使用的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布版内置 `computer` 工具,而显式的 `computer-use-preview` 请求继续使用较旧的 `computer_use_preview` 载荷。 -由提示词管理的调用是主要例外。如果提示词模板决定模型且 SDK 在请求中省略 `model`,SDK 将默认使用与预览版兼容的计算机载荷,以避免猜测提示词固定了哪个模型。要在此流程中继续使用正式发布路径,可以在请求中显式设置 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版本。 +由提示词管理的调用是主要例外。如果提示词模板指定模型,而 SDK 从请求中省略 `model`,SDK 会默认使用与预览版兼容的计算机载荷,以免猜测提示词固定了哪个模型。要在此流程中继续使用正式发布版路径,请在请求中显式指定 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版。 注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与有效请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串仍会像普通函数名称一样工作。 -与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂的提示词管理流程应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制选择正式发布版本。完整迁移详情请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 +与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此,使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂且由提示词管理的流程应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制选择正式发布版。完整迁移详情请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 #### 非 GPT-5 模型 -如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复为与任意模型兼容的通用 `ModelSettings`。 +如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复使用与任何模型兼容的通用 `ModelSettings`。 -### Responses 专用工具功能 +### 仅限 Responses 的工具功能 以下工具功能仅受 OpenAI Responses 模型支持: @@ -123,13 +123,13 @@ agent = Agent( - `@function_tool(defer_loading=True)` 和其他延迟加载的 Responses 工具接口 - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers` 和 `tool_choice="programmatic_tool_calling"` -Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请将 `ToolSearchTool()` 添加到智能体,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制指定不带限定的命名空间名称或仅支持延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 +Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请向智能体添加 `ToolSearchTool()`,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制指定单独的命名空间名称或仅限延迟加载的函数名称。有关设置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 ### Responses WebSocket 传输 -默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用由 OpenAI 支持的模型时,可以选择启用 websocket 传输。 +默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用OpenAI支持的模型时,您可以选择启用 WebSocket 传输。 -#### 基本配置 +#### 基础设置 ```python from agents import set_default_openai_responses_transport @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响由默认 OpenAI 提供商解析的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 +这会影响由默认OpenAI提供商解析的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 -SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已经固定:[​​`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 websocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 继续使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,将由该提供商控制传输方式选择,而不是全局默认设置。 +SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已固定:[​​`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 仍使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商控制传输方式选择,而非全局默认设置。 -#### 提供商级或运行级配置 +#### 提供商级或运行级设置 -还可以按提供商或按运行配置 websocket 传输: +您也可以按提供商或按运行配置 WebSocket 传输: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -由 OpenAI 支持的提供商还接受可选的智能体注册配置。这是一项高级选项,适用于 OpenAI 配置需要提供商级注册元数据(例如测试框架 ID)的情况。 +OpenAI支持的提供商还接受可选的智能体注册配置。这是一个高级选项,适用于您的OpenAI设置需要提供商级注册元数据(例如 harness ID)的情况。 ```python from agents import ( @@ -190,14 +190,14 @@ result = await Runner.run( #### 使用 `MultiProvider` 的高级路由 -如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider] 并在其中设置 `openai_use_responses_websocket=True`。 +如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 -`MultiProvider` 保留了两个历史默认行为: +`MultiProvider` 保留了两个历史默认设置: -- `openai/...` 被视为 OpenAI 提供商的别名,因此 `openai/gpt-4.1` 会作为模型 `gpt-4.1` 进行路由。 -- 未知前缀会引发 `UserError`,而不是直接透传。 +- `openai/...` 被视为OpenAI提供商的别名,因此 `openai/gpt-4.1` 会作为模型 `gpt-4.1` 进行路由。 +- 未知前缀会引发 `UserError`,而不会被直接透传。 -当 OpenAI 提供商指向需要字面命名空间模型 ID 的 OpenAI 兼容端点时,请显式启用透传行为。在启用 websocket 的配置中,也应在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: +如果将OpenAI提供商指向需要字面量命名空间模型 ID 的OpenAI兼容端点,请显式选择透传行为。在启用 WebSocket 的设置中,也请在 `MultiProvider` 上保持 `openai_use_responses_websocket=True`: ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,25 +223,27 @@ result = await Runner.run( ) ``` -当后端需要字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端需要其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项也适用于 websocket 传输之外的 `MultiProvider`;本示例保持启用 websocket,因为它是本节所述传输配置的一部分。[`responses_websocket_session()`][agents.responses_websocket_session] 也提供相同选项。 +当后端需要字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端需要其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项也适用于 WebSocket 传输之外的 `MultiProvider`;此示例保持启用 WebSocket,因为它属于本节所述的传输设置。同样的选项也可用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 -如果通过 `MultiProvider` 进行路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它将被转发到底层 OpenAI 提供商。 +如果通过 `MultiProvider` 路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发给底层OpenAI提供商。 -如果使用自定义 OpenAI 兼容端点或代理,websocket 传输还需要兼容的 websocket `/responses` 端点。在这些配置中,您可能需要显式设置 `websocket_base_url`。 +如果使用自定义OpenAI兼容端点或代理,WebSocket 传输还需要兼容的 WebSocket `/responses` 端点。在这些设置中,您可能需要显式设置 `websocket_base_url`。 #### 注意事项 -- 这是通过 websocket 传输的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions 或非 OpenAI 提供商,除非它们支持 Responses websocket `/responses` 端点。 +- 这是通过 WebSocket 传输使用的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions 或非OpenAI提供商,除非它们支持 Responses WebSocket `/responses` 端点。 - 如果环境中尚未安装 `websockets` 软件包,请进行安装。 -- 启用 websocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望在多个轮次以及嵌套的“智能体作为工具”调用之间复用同一 websocket 连接的多轮工作流,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助函数。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 -- 对于长时间推理轮次或延迟突增的网络,请使用 `responses_websocket_options` 自定义 websocket 保活行为。增大 `ping_timeout` 可容忍延迟的 pong 帧,或者设置 `ping_timeout=None`,在保持启用 ping 的同时禁用心跳超时。当可靠性比 websocket 延迟更重要时,请优先使用 HTTP/SSE 传输。 -- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于位于代理之后或资源受限容器中的长期运行智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`,以限制每条消息的内存用量。 +- 启用 WebSocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次复用同一 WebSocket 连接的多轮工作流,包括嵌套的智能体即工具调用,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助函数。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 +- 对于耗时较长的推理轮次或存在延迟峰值的网络,请使用 `responses_websocket_options` 自定义 WebSocket 保活行为。增大 `ping_timeout` 可容忍延迟的 pong 帧,也可以设置 `ping_timeout=None`,在保持启用 ping 的同时禁用心跳超时。当可靠性比 WebSocket 延迟更重要时,优先使用 HTTP/SSE 传输。 +- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于代理之后长期运行的智能体进程,或内存受限容器中的智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}` 以限制每条消息的内存使用量。 +- [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每条连接上一次处理一个响应,并将每条连接的持续时间限制为 60 分钟。达到该限制后,请打开新连接;需要并行运行时,请使用多个连接。 +- 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会清除引用的 `previous_response_id`。重新连接后,如果存储的响应仍然可用,则仍可继续处理;但 `store=False` 和 ZDR 流程没有持久化回退机制。请使用 `previous_response_id=None` 启动新链并发送完整输入上下文,或根据本地管理的会话状态重建该上下文。 ### 托管多智能体(实验性) -OpenAI Responses API 托管多智能体测试版允许 GPT-5.6 根模型创建和协调由服务托管的子智能体。Agents SDK 可以继续使用其常规 `Runner`:托管编排保留在服务端,而开发者定义的工具调用在您的应用中执行。 +OpenAI Responses API 托管多智能体 Beta 版允许 GPT-5.6 根模型创建并协调服务端托管的子智能体。Agents SDK 可以继续使用常规 `Runner`:托管编排保留在服务端,而开发者定义的工具调用则在您的应用程序中执行。 -此集成为实验性功能,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活跃的托管智能体。它要求安装 `openai[realtime]>=2.45.0`,其中包括公开 `client.beta.responses.connect` 的测试版。该接口和测试版项目架构可能会在正式发布前发生变化。 +此集成为实验性功能,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活跃的托管智能体。它需要 `openai[realtime]>=2.45.0`,其中包括公开 `client.beta.responses.connect` 的 Beta 版本。其接口和 Beta 项目架构可能会在正式发布前发生变化。 #### 模型配置 @@ -258,11 +260,11 @@ agent = Agent( ) ``` -构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则模型会使用默认 OpenAI 客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 +构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则该模型使用默认OpenAI客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 #### 本地工具调用 -所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 会在本地执行函数,并通过相同的调用 ID 将 `function_call_output` 注入活跃的 WebSocket 响应,让服务可以恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的工具调用都会在请求发送前被拒绝。 +所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 会在本地执行函数,并使用相同的调用 ID 将 `function_call_output` 注入活跃的 WebSocket 响应中,以便服务恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的工具调用都会在发送请求前被拒绝。 当工具需要感知调用方的日志记录或授权时,请使用 `get_hosted_agent_metadata()`: @@ -281,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -托管智能体名称是观测元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行前或执行期间通过应用代码实施所有必要的授权;请勿对此模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 +托管智能体名称属于观测性元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间通过应用程序代码实施任何必要的授权;不要对此模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 -#### 输出与流式传输行为 +#### 输出和流式传输行为 只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为普通最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管编排记录;SDK 绝不会将这些记录作为本地函数执行。 -原始流式传输仍会公开测试版 Responses 事件,包括托管输出项和 `response.inject.created` 确认。函数调用就绪时,适配器会将一个活跃的提供商响应拆分成 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。请对原始托管项或 `ToolContext` 使用 `get_hosted_agent_metadata()` 来检查归属信息。 +原始流式传输仍会公开 Beta Responses 事件,包括托管输出项和 `response.inject.created` 确认。当函数调用就绪时,适配器会将一个活跃的提供商响应划分为 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。请对原始托管项或 `ToolContext` 使用 `get_hosted_agent_metadata()` 来检查归属信息。 #### 与 SDK 编排的关系 托管多智能体不同于 SDK 任务转移和 Agents-as-tools: -- 托管多智能体在 OpenAI 服务上创建子智能体。您的应用不会创建或调度这些子智能体。 -- SDK 任务转移会更改当前活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,这将导致所有权冲突。 -- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估由此增加的延迟、成本和工具暴露。 +- 托管多智能体在OpenAI服务上创建子智能体。您的应用程序不会创建或调度这些子智能体。 +- SDK 任务转移会更改当前活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,从而导致所有权冲突。 +- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请仔细评估额外的延迟、成本和工具暴露。 #### 当前限制 -该实验性模型会拒绝 `reasoning.summary`、`max_tool_calls` 以及调用方提供的 `multi_agent` 或 `betas` 覆盖。测试版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 +实验性模型会拒绝 `reasoning.summary`、`max_tool_calls`,以及调用方提供的 `multi_agent` 或 `betas` 覆盖设置。Beta 版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 -一个 `OpenAIHostedMultiAgentModel` 实例最多只能拥有一个活跃的托管响应。如果运行在等待本地函数输出时被放弃,请调用 `await model.close()` 以释放其 WebSocket。目前不支持在其他进程或事件循环中恢复进行中的托管响应。 +一个 `OpenAIHostedMultiAgentModel` 实例一次最多拥有一个活跃的托管响应。如果运行在等待本地函数输出时被放弃,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复正在进行的托管响应。 -有关底层 Responses API 测试版行为,请参阅 [OpenAI 多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 +有关底层 Responses API Beta 版行为,请参阅 [OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式传输和流式传输的 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 -## 非 OpenAI 模型 +## 非OpenAI模型 -如果需要非 OpenAI 提供商,请从 SDK 的内置提供商集成点开始。在许多配置中,这已经足够,无需添加第三方适配器。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 +如果需要非OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多设置中,无需添加第三方适配器即可满足需求。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 -### 非 OpenAI 提供商集成方式 +### 非OpenAI提供商的集成方式 -| 方式 | 适用场景 | 作用域 | +| 方法 | 适用情况 | 作用域 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 一个 OpenAI 兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应应用于单次运行 | 每次运行 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 一个OpenAI兼容端点应成为大多数或所有智能体的默认端点 | 全局默认 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应适用于单次运行 | 每次运行 | | [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同提供商或具体模型对象 | 每个智能体 | -| 第三方适配器 | 您需要由适配器管理的提供商覆盖范围或内置路径未提供的路由 | 请参阅[第三方适配器](#third-party-adapters) | +| 第三方适配器 | 需要由适配器管理的提供商覆盖范围或内置路径不提供的路由 | 请参阅[第三方适配器](#third-party-adapters) | -可以使用以下内置路径集成其他 LLM 提供商: +您可以通过以下内置路径集成其他 LLM 提供商: -1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望全局使用 `AsyncOpenAI` 实例作为 LLM 客户端的情况。这适用于 LLM 提供商具有 OpenAI 兼容 API 端点,并且您可以设置 `base_url` 和 `api_key` 的情况。可配置代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 层级。这样您就可以指定“本次运行中的所有智能体都使用自定义模型提供商”。可配置代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 -3. [`Agent.model`][agents.agent.Agent.model] 允许在特定 Agent 实例上指定模型。这使您能够为不同智能体混合搭配不同提供商。可配置代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 +1. 如果希望在全局范围内使用 `AsyncOpenAI` 实例作为 LLM 客户端,[`set_default_openai_client`][agents.set_default_openai_client] 会很有用。这适用于 LLM 提供商具有OpenAI兼容 API 端点,且您可以设置 `base_url` 和 `api_key` 的情况。可配置代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 层级。这让您可以指定“此次运行中的所有智能体都使用自定义模型提供商”。可配置代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 +3. [`Agent.model`][agents.agent.Agent.model] 允许您在特定 Agent 实例上指定模型。这样可以为不同智能体灵活混用不同提供商。可配置代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 -如果您没有来自 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或配置[其他追踪进程](../tracing.md)。 +如果您没有来自 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或设置[其他追踪进程](../tracing.md)。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -339,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持它,我们建议使用 Responses。 + 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持 Responses API,我们建议使用 Responses。 -## 在一个工作流中混用模型 +## 单个工作流中的模型混用 -在单个工作流中,您可能希望每个智能体使用不同模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: +在单个工作流中,您可能希望为每个智能体使用不同的模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: 1. 传入模型名称。 -2. 传入任意模型名称和一个能够将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 +2. 传入任意模型名称,以及可将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 3. 直接提供 [`Model`][agents.models.interface.Model] 实现。 !!! note - 虽然我们的 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 形式,但建议每个工作流使用一种模型形式,因为这两种形式支持的功能和工具集合不同。如果工作流需要混合搭配不同模型形式,请确保您使用的所有功能都同时受到两者支持。 + 虽然 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 两种形式,但我们建议每个工作流只使用一种模型形式,因为两者支持的功能和工具集合不同。如果工作流需要混用不同模型形式,请确保您使用的所有功能均受两者支持。 ```python import asyncio @@ -389,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 直接设置 OpenAI 模型的名称。 +1. 直接设置OpenAI模型的名称。 2. 提供 [`Model`][agents.models.interface.Model] 实现。 -如果要进一步配置智能体使用的模型,可以传入 [`ModelSettings`][agents.models.interface.ModelSettings],其中提供 temperature 等可选模型配置参数。 +如果希望进一步配置智能体使用的模型,可以传入 [`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 ```python from agents import Agent, ModelSettings @@ -407,21 +409,21 @@ english_agent = Agent( ## 高级 OpenAI Responses 设置 -当您使用 OpenAI Responses 路径并需要更多控制时,请从 `ModelSettings` 开始。 +使用 OpenAI Responses 路径且需要更多控制时,请从 `ModelSettings` 开始。 ### 常用高级 `ModelSettings` 选项 -使用 OpenAI Responses API 时,多个请求字段已具有直接对应的 `ModelSettings` 字段,因此无需通过 `extra_args` 传递。 +使用 OpenAI Responses API 时,多个请求字段已经有对应的直接 `ModelSettings` 字段,因此无需通过 `extra_args` 传入。 -- `parallel_tool_calls`:允许或禁止在同一轮次中进行多个工具调用。 -- `truncation`:设置为 `"auto"`,可让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是使请求失败。 -- `store`:控制是否在服务端存储生成的响应,以供后续检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 +- `parallel_tool_calls`:允许或禁止在同一轮中进行多个工具调用。 +- `truncation`:设置为 `"auto"`,让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是使请求失败。 +- `store`:控制生成的响应是否存储在服务端,以供后续检索。这对依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程十分重要。 - `context_management`:配置服务端上下文处理,例如使用 `compact_threshold` 进行 Responses 压缩。 -- `prompt_cache_retention`:为早期模型系列配置延长的保留期,例如 +- `prompt_cache_retention`:为较早的模型系列配置延长保留时间,例如 使用 `"24h"`。 - `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置 `"30m"` 缓存 TTL。 - `response_include`:请求更丰富的响应载荷,例如 `web_search_call.action.sources`、`file_search_call.results` 或 `reasoning.encrypted_content`。 -- `top_logprobs`:请求输出文本的高概率词元 logprobs。SDK 还会自动添加 `message.output_text.logprobs`。 +- `top_logprobs`:请求输出文本的最高概率词元 logprobs。SDK 还会自动添加 `message.output_text.logprobs`。 - `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅[由 Runner 管理的重试](#runner-managed-retries)。 ```python @@ -442,7 +444,7 @@ research_agent = Agent( ) ``` -使用显式提示词缓存时,请在可复用前缀结束处的内容部分添加断点。同一个 `ModelSettings.prompt_cache_options` 字段会在 Responses 和 Chat Completions 请求中透传,而 Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 +使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。同一个 `ModelSettings.prompt_cache_options` 字段会透传给 Responses 和 Chat Completions 请求,Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 ```python from agents import Runner @@ -468,18 +470,18 @@ result = await Runner.run( ) ``` -对于使用旧版保留控制的早期模型系列,`prompt_cache_retention` 仍然可用。请勿将直接的 `ModelSettings` 字段与 -`extra_args` 中的同名键结合使用。 +对于使用旧版保留控制的较早模型系列,`prompt_cache_retention` 仍然可用。不要将直接的 `ModelSettings` 字段与 +`extra_args` 中的相同键组合使用。 -设置 `store=False` 后,Responses API 不会保留该响应以供后续服务端检索。这对于无状态或零数据保留类型的流程很有用,但也意味着原本会复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当上一个响应未被存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将其默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 +设置 `store=False` 时,Responses API 不会保留该响应以供之后在服务端检索。这适用于无状态或零数据保留类型的流程,但也意味着原本会复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当最后一个响应未被存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将其默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 -服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每次 Responses API 请求一起发送,当渲染后的上下文超过阈值时,API 可以将压缩项作为响应的一部分发出。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 +服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每个 Responses API 请求发送,当渲染后的上下文超过阈值时,API 可以在响应中生成压缩项。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 ### `extra_args` 传递 -当您需要 SDK 尚未在顶层直接公开的提供商特定字段或较新的请求字段时,请使用 `extra_args`。 +当您需要 SDK 尚未在顶层直接公开的提供商特定请求字段或较新的请求字段时,请使用 `extra_args`。 -此外,使用 OpenAI 的 Responses API 时,[还有一些其他可选参数](https://platform.openai.com/docs/api-reference/responses/create),例如 `user`、`service_tier` 等。如果顶层没有这些参数,也可以通过 `extra_args` 传递。请勿同时通过直接的 `ModelSettings` 字段设置同一个请求字段。 +此外,使用OpenAI的 Responses API 时,[还可以使用其他一些可选参数](https://platform.openai.com/docs/api-reference/responses/create),例如 `user`、`service_tier` 等。如果顶层没有这些参数,也可以使用 `extra_args` 传入。不要同时通过直接的 `ModelSettings` 字段设置同一个请求字段。 ```python from agents import Agent, ModelSettings @@ -497,7 +499,9 @@ english_agent = Agent( ## 由 Runner 管理的重试 -重试仅在运行时生效,并且需要主动启用。除非设置 `ModelSettings(retry=...)` 且重试策略选择重试,否则 SDK 不会重试常规模型请求。 +重试仅在运行时生效,并且需要主动启用。除非您设置 `ModelSettings(retry=...)` 且重试策略选择重试,否则 SDK 不会重试常规模型请求。 + +在 Responses WebSocket 传输上,`retry_policies.provider_suggested()` 会将响应前的过载帧和无代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:您仍需要 `ModelRetrySettings`,并且常规重放安全检查仍然适用。如果已经收到任何响应事件,SDK 将不会重放请求。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -532,78 +536,78 @@ agent = Agent( | 字段 | 类型 | 说明 | | --- | --- | --- | | `max_retries` | `int | None` | 初始请求之后允许的重试次数。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略选择重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制由此计算得出的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制此处计算出的退避延迟。它不会限制策略返回的显式延迟或 retry-after 提示。 | | `policy` | `RetryPolicy | None` | 决定是否重试的回调。此字段仅在运行时生效,不会被序列化。 | 重试策略会收到一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: -- `attempt` 和 `max_retries`,以便根据尝试次数做出决策。 -- `stream`,以便在流式和非流式行为之间进行分支。 +- `attempt` 和 `max_retries`,便于根据尝试次数做出决策。 +- `stream`,便于针对流式传输和非流式传输行为采用不同分支。 - `error`,用于原始检查。 -- `normalized` 事实,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 -- `provider_advice`,在底层模型适配器能够提供重试指导时使用。 +- 规范化信息,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 +- 当底层模型适配器能够提供重试指导时的 `provider_advice`。 策略可以返回以下任一种结果: -- `True` / `False`,用于简单的重试决策。 +- `True` / `False`,表示简单的重试决定。 - [`RetryDecision`][agents.retry.RetryDecision],用于覆盖延迟或附加诊断原因。 -SDK 在 `retry_policies` 中导出了现成的辅助函数: +SDK 在 `retry_policies` 上导出了现成的辅助函数: | 辅助函数 | 行为 | | --- | --- | | `retry_policies.never()` | 始终不重试。 | -| `retry_policies.provider_suggested()` | 在有可用信息时遵循提供商的重试建议。 | -| `retry_policies.network_error()` | 匹配暂时性传输失败和超时失败。 | +| `retry_policies.provider_suggested()` | 在提供商提供重试建议时遵循该建议。 | +| `retry_policies.network_error()` | 匹配暂时性传输和超时故障。 | | `retry_policies.http_status([...])` | 匹配选定的 HTTP 状态码。 | -| `retry_policies.retry_after()` | 仅当存在 retry-after 提示时重试,并使用该延迟。此辅助函数将 retry-after 值视为显式策略延迟,因此不受 `backoff.max_delay` 限制。 | -| `retry_policies.any(...)` | 任何嵌套策略选择重试时进行重试。 | -| `retry_policies.all(...)` | 仅当所有嵌套策略都选择重试时进行重试。 | +| `retry_policies.retry_after()` | 仅在存在 retry-after 提示时重试,并使用该延迟。此辅助函数会将 retry-after 值视为显式策略延迟,因此 `backoff.max_delay` 不会对其进行限制。 | +| `retry_policies.any(...)` | 任一嵌套策略选择重试时进行重试。 | +| `retry_policies.all(...)` | 仅当所有嵌套策略都选择重试时才进行重试。 | -组合策略时,`provider_suggested()` 是最安全的首选基本组件,因为当提供商能够识别否决条件和重放安全批准时,它会保留这些信息。 +组合策略时,`provider_suggested()` 是最安全的首选基础组件,因为当提供商可以区分否决和重放安全批准时,它会保留这些信息。 ##### 安全边界 -某些失败绝不会自动重试: +某些故障绝不会自动重试: - 中止错误。 - 提供商建议将重放标记为不安全的请求。 -- 输出已经开始,且重放会产生不安全结果的流式运行。 +- 已开始输出且重放会带来安全风险的流式传输运行。 -使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求也会受到更保守的处理。对于这些请求,仅使用 `network_error()` 或 `http_status([500])` 等非提供商谓词并不足够。重试策略应包含提供商对重放安全性的批准,通常通过 `retry_policies.provider_suggested()` 实现。 +使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求也会得到更保守的处理。对于这些请求,仅使用 `network_error()` 或 `http_status([500])` 等非提供商判断条件还不够。重试策略应包含来自提供商的重放安全批准,通常通过 `retry_policies.provider_suggested()` 实现。 ##### Runner 与智能体的合并行为 Runner 级和智能体级 `ModelSettings` 之间会对 `retry` 进行深度合并: - 智能体可以仅覆盖 `retry.max_retries`,同时继承 Runner 的 `policy`。 -- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留 Runner 中其他同级退避字段。 +- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留 Runner 中同级的其他退避字段。 - `policy` 仅在运行时生效,因此序列化后的 `ModelSettings` 会保留 `max_retries` 和 `backoff`,但省略回调本身。 -更多完整代码示例请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[基于适配器的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 +有关更完整的代码示例,请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[由适配器支持的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 -## 非 OpenAI 提供商故障排除 +## 非OpenAI提供商故障排除 ### 追踪客户端错误 401 -如果遇到与追踪相关的错误,这是因为追踪数据会上传到 OpenAI 服务,而您没有 OpenAI API 密钥。可以通过以下三种方式解决: +如果遇到与追踪相关的错误,这是因为追踪数据会上传到OpenAI服务,而您没有OpenAI API 密钥。您可以通过以下三种方式解决: -1. 完全禁用追踪:[​​`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. 为追踪设置 OpenAI 密钥:[​​`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 -3. 使用非 OpenAI 追踪进程。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 +1. 完全禁用追踪:[`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 +2. 为追踪设置OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 +3. 使用非OpenAI追踪进程。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 ### Responses API 支持 -SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,您可能会遇到 404 或类似问题。可以通过以下两种方式解决: +SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,您可能会遇到 404 或类似问题。您可以通过以下两种方式解决: -1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方法有效。 +1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方式有效。 2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。相关代码示例请参阅[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 ### Chat Completions 兼容性选项 -通过 Chat Completions 进行路由时,SDK 会通过静默丢弃 Chat Completions 无法发送的 Responses 专用字段来保持兼容性,例如 `previous_response_id`、`conversation_id`、提示词或并非纯文本的工具输出。如果希望这些不匹配问题在开发期间快速失败,请在 OpenAI 提供商上启用严格功能验证: +通过 Chat Completions 进行路由时,SDK 会静默丢弃 Chat Completions 无法发送且仅限 Responses 的字段,例如 `previous_response_id`、`conversation_id`、提示词或非纯文本工具输出,从而保持兼容性。如果希望在开发期间遇到这些不匹配时立即失败,请在OpenAI提供商上启用严格功能验证: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -623,7 +627,7 @@ result = await Runner.run( 如果使用 [`MultiProvider`][agents.MultiProvider],请改为传入 `openai_strict_feature_validation=True`。 -某些 OpenAI 兼容的 Chat Completions 提供商会以分块方式流式传输工具调用增量,而这些分块不足以支持可靠的 SDK 增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后发出工具调用: +某些OpenAI兼容 Chat Completions 提供商会将工具调用增量分块进行流式传输,但这些分块不够可靠,无法供 SDK 进行增量处理。在这种情况下,请启用流式传输工具调用缓冲,使 SDK 仅在提供商流结束后生成工具调用: ```python from agents import OpenAIProvider @@ -638,7 +642,7 @@ provider = OpenAIProvider( ### structured outputs 支持 -部分模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: +某些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: ``` @@ -646,42 +650,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -这是部分模型提供商的不足之处:它们支持 JSON 输出,但不允许指定用于输出的 `json_schema`。我们正在修复此问题,但建议依赖支持 JSON 架构输出的提供商,否则您的应用通常会因为 JSON 格式错误而中断。 +这是某些模型提供商的不足之处:它们支持 JSON 输出,但不允许您指定输出所使用的 `json_schema`。我们正在研究相应的修复方案,但建议依赖支持 JSON schema 输出的提供商,否则您的应用往往会因格式错误的 JSON 而中断。 -## 跨提供商混用模型 +## 跨提供商的模型混用 -您需要了解模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI 支持 structured outputs、多模态输入以及托管文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: +您需要注意不同模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI支持 structured outputs、多模态输入以及托管文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: -- 不要向无法理解的提供商发送其不支持的 `tools` -- 调用仅支持文本的模型之前,请过滤掉多模态输入 -- 请注意,不支持结构化 JSON 输出的提供商偶尔会生成无效 JSON。 +- 不要向无法理解不受支持 `tools` 的提供商发送这些工具 +- 在调用纯文本模型之前过滤掉多模态输入 +- 请注意,不支持结构化 JSON 输出的提供商有时会生成无效 JSON。 ## 第三方适配器 -仅当 SDK 的内置提供商集成点无法满足需求时,才使用第三方适配器。如果您仅通过此 SDK 使用 OpenAI 模型,请优先使用内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI 模型与非 OpenAI 提供商结合使用,或需要由适配器管理的提供商覆盖范围或内置路径未提供的路由。适配器会在 SDK 与上游模型提供商之间添加额外的兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的测试版集成形式提供 Any-LLM 和 LiteLLM 适配器。 +只有在 SDK 的内置提供商集成点不足以满足需求时,才应使用第三方适配器。如果您仅通过此 SDK 使用OpenAI模型,请优先使用内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将OpenAI模型与非OpenAI提供商组合使用,或需要由适配器管理的提供商覆盖范围或内置路径不提供的路由时。适配器会在 SDK 与上游模型提供商之间增加一层兼容性,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的 Beta 版集成形式提供 Any-LLM 和 LiteLLM 适配器。 ### Any-LLM -Any-LLM 支持以尽力支持的测试版形式提供,适用于需要由 Any-LLM 管理提供商覆盖范围或路由的情况。 +对于需要由 Any-LLM 管理提供商覆盖范围或路由的情况,SDK 以尽力支持的 Beta 版形式提供 Any-LLM 支持。 根据上游提供商路径,Any-LLM 可能使用 Responses API、Chat Completions 兼容 API 或提供商特定的兼容层。 -如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 结合使用、直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 +如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。您可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 配合使用、直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 -Any-LLM 仍属于第三方适配器层,因此提供商依赖项和功能缺口由上游 Any-LLM 定义,而不是由 SDK 定义。当上游提供商返回使用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会发出使用量数据块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 +Any-LLM 仍是第三方适配器层,因此提供商依赖项和功能缺口由上游 Any-LLM 而非 SDK 定义。当上游提供商返回使用量指标时,这些指标会自动传播,但流式传输 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会生成使用量数据块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 ### LiteLLM -LiteLLM 支持以尽力支持的测试版形式提供,适用于需要 LiteLLM 特定提供商覆盖范围或路由的情况。 +对于需要 LiteLLM 特定提供商覆盖范围或路由的情况,SDK 以尽力支持的 Beta 版形式提供 LiteLLM 支持。 -如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。可以使用 `litellm/...` 模型名称,或直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 +如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。您可以使用 `litellm/...` 模型名称,或直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 -部分由 LiteLLM 支持的提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 +某些 LiteLLM 支持的提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 -如果 LiteLLM 对响应对象发出 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器前选择启用 SDK 的兼容性补丁: +如果 LiteLLM 为响应对象发出 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器之前选择启用 SDK 的兼容性补丁: ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -该补丁默认禁用,仅当值为 `1` 或 `true` 时才启用。它通过包装 LiteLLM 的私有日志辅助函数,抑制特定类别的 LiteLLM 响应序列化警告,因此应将其视为有针对性的解决方法,而不是通用序列化设置。由于它依赖 LiteLLM 的私有 API,升级 LiteLLM 时请重新验证,并在上游不再出现该警告后移除该环境变量。 \ No newline at end of file +该补丁默认禁用,仅在值为 `1` 或 `true` 时启用。它通过包装 LiteLLM 的私有日志辅助函数,抑制特定类别的 LiteLLM 响应序列化警告,因此应将其视为有针对性的临时解决方案,而不是通用序列化设置。由于它依赖 LiteLLM 的私有 API,升级 LiteLLM 时请重新验证该补丁,并在上游警告不再出现时移除该环境变量。 \ No newline at end of file diff --git a/docs/zh/release.md b/docs/zh/release.md index d045a87e8e..b5416a10d9 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -4,51 +4,51 @@ search: --- # 发布流程/变更日志 -本项目采用略作修改的语义化版本控制,格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各部分按以下方式递增: +本项目采用略作修改的语义化版本控制,版本格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各组成部分按以下规则递增: ## 次版本(`Y`) -对于未标记为 beta 的任何公共接口所发生的**破坏性变更**,我们将递增次版本号 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 +对于任何未标记为 beta 的公共接口,如果发生**破坏性变更**,我们将递增次版本 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 -如果您不希望遇到破坏性变更,建议在项目中将版本固定为 `0.0.x`。 +如果不希望引入破坏性变更,建议在项目中将版本锁定为 `0.0.x`。 -## 补丁版本(`Z`) +## 修订版本(`Z`) 对于非破坏性变更,我们将递增 `Z`: -- Bug 修复 -- 新功能 -- 私有接口变更 -- beta 功能更新 +- Bug 修复 +- 新功能 +- 私有接口变更 +- beta 功能更新 ## 破坏性变更日志 ### 0.19.0 -此次次版本发布**不**引入破坏性变更。次版本号的提升反映了 OpenAI Responses 的一个重要新功能领域:程序化工具调用。 +此次次版本发布**没有**引入破坏性变更。次版本号的提升反映了 OpenAI Responses 的一个重要新功能领域:程序化工具调用。 亮点: -- 新增 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],支持的 OpenAI Responses 模型可借此生成 JavaScript 来协调符合条件的工具。它支持针对每个工具的 `allowed_callers`、结构化函数工具输出,并与 Runner 流式传输、安全防护措施、审批、会话及 `RunState` 集成。有关设置和限制,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 -- 新增公共 `agents.decorators` 模块和更简洁的 `@tool` 别名,同时保留现有的函数及安全防护措施装饰器。函数工具现在还支持异步可调用对象。 -- 智能体、运行、模型、会话、沙箱和语音管线的 SDK 配置现在都能以一致的方式接受类型化设置对象或字典,并验证未知设置。 -- 加强了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误及诊断日志,在保留有用调试上下文的同时,避免暴露原始敏感载荷。 -- 改进了 AnyLLM、LiteLLM 和 Chat Completions 兼容性,在模型重试时保留会话历史,并对响应开始前发生的 WebSocket 过载错误进行重试。 -- 新增了通过 `VercelCloudBucketMountStrategy` 实现的[Vercel 沙箱创建时专用 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。包含挂载的会话会在工作区持久化时排除存储桶内容,并且有意不支持动态挂载更改或会话恢复。 +- 新增 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],允许受支持的 OpenAI Responses 模型生成 JavaScript,以协调符合条件的工具。它支持按工具配置 `allowed_callers`、结构化工具调用输出,并可与 Runner 流式传输、安全防护措施、审批、会话和 `RunState` 集成。有关设置方式和限制,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 +- 新增公共 `agents.decorators` 模块,并在现有工具调用和安全防护措施装饰器之外,增加了更简短的 `@tool` 别名。工具调用现在也支持异步可调用对象。 +- SDK 配置现在统一支持在智能体、运行、模型、会话、沙箱和语音流水线中使用类型化设置对象或字典,并会验证未知设置。 +- 加强了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误及诊断日志记录,以避免暴露原始敏感载荷,同时保留有用的调试上下文。 +- 改进了 AnyLLM、LiteLLM 和 Chat Completions兼容性,在模型重试期间保留会话历史,并针对响应开始前发生的 WebSocket 过载新增了提供商重试指引,以便在允许重放时,由选择启用的 Runner 重试策略进行处理。 +- 通过 `VercelCloudBucketMountStrategy` 新增了[仅可在创建时使用的 Vercel 沙箱 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。挂载了存储桶的会话不会将存储桶内容纳入工作区持久化,并且有意不支持动态更改挂载或恢复会话。 ### 0.18.0 -此次次版本发布**不**引入破坏性变更。次版本号的提升仅用于 Realtime 智能体默认模型更新。 +此次次版本发布**没有**引入破坏性变更。次版本号的提升仅用于 Realtime智能体默认模型更新。 亮点: -- Realtime 智能体现在默认使用 `gpt-realtime-2.1`,因此新的 Realtime 配置无须额外设置即可使用最新的推荐模型。 +- Realtime智能体现在默认使用 `gpt-realtime-2.1` 模型,因此新的 Realtime 设置无需额外配置即可使用最新的推荐模型。 ### 0.17.0 -在此版本中,沙箱本地源材料化会将 `LocalFile.src` 和 `LocalDir.src` 限制在材料化 `base_dir` 内,除非源路径已包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源路径从该目录解析,而绝对本地源路径必须已位于该目录内或显式授权的路径下。此变更修复了本地产物边界问题,但可能会影响有意将该基础目录之外的可信主机文件或目录复制到沙箱工作区的应用。 +在此版本中,沙箱本地源物化会将 `LocalFile.src` 和 `LocalDir.src` 限制在物化 `base_dir` 内,除非源路径已包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源路径从该目录解析,而绝对本地源路径必须已位于该目录内或某个明确授权的目录下。此变更修复了本地工件边界问题,但可能会影响有意将该基础目录以外的可信主机文件或目录复制到沙箱工作区的应用。 -如需迁移,请使用 `SandboxPathGrant` 在清单级别授权可信主机根目录;如果沙箱只需读取这些文件,最好将授权设为只读: +迁移时,请使用 `SandboxPathGrant` 在清单级别授权可信主机根目录;如果沙箱只需读取这些文件,最好将其设置为只读: ```python from pathlib import Path @@ -75,13 +75,13 @@ manifest = Manifest( ) ``` -请将 `extra_path_grants` 视为可信的应用配置。除非您的应用已批准相关主机路径,否则不要使用模型输出或其他不可信的清单输入来填充授权。 +请将 `extra_path_grants` 视为可信的应用配置。除非应用已批准相应主机路径,否则不要根据模型输出或其他不可信的清单输入填充授权。 ### 0.16.0 -在此版本中,SDK 默认模型已从 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包括 GPT-5 的默认值,例如 `reasoning.effort="none"` 和 `verbosity="low"`。 +在此版本中,SDK 默认模型由 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含 GPT-5 的默认值,例如 `reasoning.effort="none"` 和 `verbosity="low"`。 -如果需要保留此前的默认模型行为,请在智能体或运行配置中显式设置模型,或设置 `OPENAI_DEFAULT_MODEL` 环境变量: +如果需要保留之前的默认模型行为,请在智能体或运行配置中显式设置模型,或者设置 `OPENAI_DEFAULT_MODEL` 环境变量: ```python agent = Agent(name="Assistant", model="gpt-4.1") @@ -89,14 +89,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 亮点: -- `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 -- 在本地、Docker 和提供商支持的沙箱实现中,沙箱工作区数据填充现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 +- `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 +- 对于本地、Docker 和由提供商支持的沙箱实现,沙箱工作区初始化现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 ### 0.15.0 -在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于 structured outputs,也不再导致运行循环不断重试直至触发 `MaxTurnsExceeded`。 +在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于structured outputs,也不会再导致运行循环持续重试,直至触发 `MaxTurnsExceeded`。 -这会影响此前预期仅含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不引发异常,请提供 `model_refusal` 运行错误处理程序: +这会影响之前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。如需在不抛出异常的情况下处理拒绝,请提供 `model_refusal` 运行错误处理程序: ```python result = Runner.run_sync( @@ -106,81 +106,81 @@ result = Runner.run_sync( ) ``` -对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样对其进行验证。 +对于使用structured outputs的智能体,处理程序可以返回符合智能体输出架构的值,SDK 将像验证其他运行错误处理程序的最终输出一样对其进行验证。 ### 0.14.0 -此次次版本发布**不**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用该功能所需的运行时、后端和文档支持。 +此次次版本发布**没有**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 亮点: -- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化隔离工作区中处理文件、目录、Git 仓库、挂载、快照,并支持恢复。 -- 通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 新增用于本地和容器化开发的沙箱执行后端,并通过可选附加依赖提供 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 的托管提供商集成。 -- 新增沙箱记忆支持,使未来运行可以复用先前运行中的经验,并提供渐进式披露、多轮分组、可配置的隔离边界,以及包含 S3 支持工作流的持久化记忆代码示例。 -- 新增更全面的工作区和恢复模型,包括本地与合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照执行的恢复流程。 -- 在 `examples/sandbox/` 下新增大量沙箱代码示例和教程,涵盖使用技能的编码任务、任务转移、记忆、特定提供商配置,以及代码审查、数据室问答和网站克隆等端到端工作流。 -- 扩展核心运行时和追踪栈,加入可感知沙箱的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出脱敏。 +- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化的隔离工作区内处理文件、目录、Git 仓库、挂载和快照,并支持恢复。 +- 新增通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 实现的本地及容器化开发沙箱执行后端,并通过可选扩展提供 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 的托管提供商集成。 +- 新增沙箱记忆支持,使后续运行可以复用此前运行中的经验,并支持渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆代码示例。 +- 新增更广泛的工作区和恢复模型,包括本地及合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 +- 在 `examples/sandbox/` 下新增大量沙箱代码示例和教程,涵盖使用技能完成编码任务、任务转移、记忆、提供商特定设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 +- 扩展了核心运行时和追踪技术栈,新增沙箱感知的会话准备、能力绑定、状态序列化、统一追踪、提示缓存键默认值,以及更安全的敏感MCP输出遮蔽。 ### 0.13.0 -此次次版本发布**不**引入破坏性变更,但包含一项值得注意的 Realtime 默认设置更新,以及新的 MCP 功能和运行时稳定性修复。 +此次次版本发布**没有**引入破坏性变更,但包含一项值得注意的 Realtime 默认设置更新,以及新的MCP能力和运行时稳定性修复。 亮点: -- 默认的 websocket Realtime 模型现在是 `gpt-realtime-1.5`,因此新的 Realtime 智能体配置无须额外设置即可使用更新的模型。 -- `MCPServer` 现在公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在公开 `session_id`,以便可流式传输的 HTTP 会话在重新连接后或无状态工作进程之间恢复。 -- Chat Completions 集成现在可以通过 `should_replay_reasoning_content` 选择启用推理内容重放,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理/工具调用连续性。 -- 修复了多个运行时和会话边界情况,包括 `SQLAlchemySession` 中并发执行的首次写入、移除推理内容后包含孤立助手消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项,以及工具调用批量执行器中的竞态问题。 +- 默认 WebSocket Realtime 模型现在是 `gpt-realtime-1.5`,因此新的 Realtime智能体设置无需额外配置即可使用较新的模型。 +- `MCPServer` 现在公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在公开 `session_id`,因此可流式 HTTP 会话可在重新连接后或无状态工作进程之间恢复。 +- Chat Completions集成现在可以通过 `should_replay_reasoning_content` 选择启用推理内容重放,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理及工具调用连续性。 +- 修复了多项运行时和会话边界情况,包括 `SQLAlchemySession` 中并发首次写入、移除推理内容后压缩请求包含孤立的助手消息 ID、`remove_all_tools()` 遗留MCP/推理项,以及工具调用批处理执行器中的竞态问题。 ### 0.12.0 -此次次版本发布**不**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 +此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 ### 0.11.0 -此次次版本发布**不**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 +此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 ### 0.10.0 -此次次版本发布**不**引入破坏性变更,但包含一项面向 OpenAI Responses 用户的重要新功能:Responses API 的 websocket 传输支持。 +此次次版本发布**没有**引入破坏性变更,但为 OpenAI Responses用户新增了一个重要功能领域:Responses API 的 WebSocket 传输支持。 亮点: -- 新增 OpenAI Responses 模型的 websocket 传输支持(需选择启用;HTTP 仍是默认传输方式)。 -- 新增 `responses_websocket_session()` 辅助函数/`ResponsesWebSocketSession`,用于在多轮运行中复用支持 websocket 的共享提供商和 `RunConfig`。 -- 新增 websocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 +- 新增 OpenAI Responses模型的 WebSocket 传输支持(需选择启用;HTTP 仍是默认传输方式)。 +- 新增 `responses_websocket_session()` 辅助函数/`ResponsesWebSocketSession`,用于在多轮运行之间复用支持 WebSocket 的共享提供商和 `RunConfig`。 +- 新增 WebSocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 ### 0.9.0 -在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前结束生命周期(EOL)。请升级到更新的运行时版本。 +在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前结束生命周期(EOL)。请升级到较新的运行时版本。 -此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会引发破坏性问题,但如果您的代码依赖更宽泛的联合类型,可能需要进行一些调整。 +此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 缩窄为 `FunctionTool`。此变更通常不会导致破坏性问题,但如果代码依赖更宽泛的联合类型,则可能需要进行相应调整。 ### 0.8.0 -在此版本中,两项运行时行为变更可能需要进行迁移: +在此版本中,两项运行时行为变更可能需要执行迁移: -- 封装**同步** Python 可调用对象的工具调用现在通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果您的工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中显式指定线程亲和性。 -- 本地 MCP 工具失败处理现在可配置,且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果您依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务级别的 `failure_error_function` 值会覆盖智能体级别的设置,因此请在每个具有显式处理程序的本地 MCP 服务上设置 `failure_error_function=None`。 +- 封装**同步** Python 可调用对象的工具调用现在通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中显式指定线程亲和性。 +- 本地MCP工具的失败处理现在可配置,并且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务级 `failure_error_function` 值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地MCP服务上设置 `failure_error_function=None`。 ### 0.7.0 在此版本中,有几项行为变更可能会影响现有应用: -- 嵌套任务转移历史现在需要**选择启用**(默认禁用)。如果您依赖 v0.6.x 的默认嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 -- `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(此前由 SDK 默认值配置为 `"low"`)。如果您的提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置。 +- 嵌套任务转移历史现在需要**选择启用**(默认禁用)。如果依赖 v0.6.x 默认的嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 +- `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已从 SDK 默认值所配置的 `"low"` 更改为 `"none"`。如果提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置。 ### 0.6.0 -在此版本中,默认任务转移历史现在会打包到单条助手消息中,而不再公开原始的用户/助手轮次,从而为下游智能体提供简洁且可预测的回顾 -- 现有的单消息任务转移对话记录现在默认在 `` 块之前以“For context, here is the conversation so far between the user and the previous agent:”开头,以便为下游智能体提供带有明确标签的回顾 +在此版本中,默认任务转移历史现在会封装为一条助手消息,而不再公开原始用户/助手轮次,从而为下游智能体提供简洁、可预测的回顾 +- 现有的单消息任务转移对话记录现在默认会在 `` 块之前以“For context, here is the conversation so far between the user and the previous agent:”开头,从而为下游智能体提供带有清晰标签的回顾 ### 0.5.0 -此版本未引入任何可见的破坏性变更,但包含新功能和几项重要的底层更新: +此版本未引入任何可见的破坏性变更,但新增了功能,并对内部实现进行了几项重要更新: -- 新增对 `RealtimeRunner` 处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持 -- 为兼容 Python 3.14,对 `Runner#run_sync` 的内部逻辑进行了大幅修改 +- 新增 `RealtimeRunner` 对处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持 +- 大幅调整了 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 ### 0.4.0 @@ -188,12 +188,12 @@ result = Runner.run_sync( ### 0.3.0 -在此版本中,Realtime API 支持迁移到 gpt-realtime 模型及其 API 接口(GA 版本)。 +在此版本中,Realtime API支持迁移至 gpt-realtime 模型及其 API 接口(正式发布版本)。 ### 0.2.0 -在此版本中,少数此前接受 `Agent` 作为参数的位置现在改为接受 `AgentBase`。例如,MCP 服务中的 `list_tools()` 调用。这纯粹是类型层面的变更,您仍会收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase` 以修复类型错误。 +在此版本中,少数原本将 `Agent` 作为参数的位置现在改为使用 `AgentBase`。例如,MCP服务中的 `list_tools()` 调用。这纯粹是类型层面的变更,实际仍会收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase` 以修复类型错误。 ### 0.1.0 -在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。您需要将这些参数添加到任何继承 `MCPServer` 的类中。 +在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。任何继承 `MCPServer` 的类都需要添加这些参数。 \ No newline at end of file diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index 56db97e333..72ef2a62e4 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -4,11 +4,11 @@ search: --- # 智能体运行 -你可以通过[`Runner`][agents.run.Runner]类运行智能体。有以下 3 种方式: +你可以通过[`Runner`][agents.run.Runner]类运行智能体。共有 3 种方式: 1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回[`RunResult`][agents.result.RunResult]。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,底层仅调用`.run()`。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回[`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,底层直接运行`.run()`。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回[`RunResultStreaming`][agents.result.RunResultStreaming]。它会以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 ```python from agents import Agent, Runner @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -更多信息请参阅[结果指南](results.md)。 +有关更多信息,请参阅[结果指南](results.md)。 -## Runner 生命周期与配置 +## 运行器生命周期与配置 ### 智能体循环 -使用`Runner`中的运行方法时,需要传入起始智能体和输入。输入可以是: +使用`Runner`中的运行方法时,你需要传入一个起始智能体和输入。输入可以是: -- 字符串(视为用户消息), -- OpenAI Responses API格式的输入项列表,或 +- 字符串(作为用户消息处理), +- OpenAI Responses API 格式的输入项列表,或 - 恢复中断的运行时使用的[`RunState`][agents.run_state.RunState]。 -随后,Runner 会运行一个循环: +随后,运行器会执行一个循环: -1. 使用当前输入调用当前智能体的 LLM。 +1. 使用当前输入为当前智能体调用 LLM。 2. LLM 生成输出。 1. 如果 LLM 返回`final_output`,循环结束并返回结果。 - 2. 如果 LLM 执行任务转移,则更新当前智能体和输入,然后重新运行循环。 - 3. 如果 LLM 生成工具调用,则运行这些工具调用、追加结果,然后重新运行循环。 -3. 如果超过传入的`max_turns`,则抛出[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]异常。传入`max_turns=None`可禁用此轮次限制。 + 2. 如果 LLM 执行任务转移,我们会更新当前智能体和输入,然后重新运行循环。 + 3. 如果 LLM 生成工具调用,我们会运行这些工具调用、追加结果,然后重新运行循环。 +3. 如果超过传入的`max_turns`,则引发[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]异常。传入`max_turns=None`可禁用此轮次限制。 !!! note - 判断 LLM 输出是否为“最终输出”的规则是:它生成了所需类型的文本输出,并且不存在工具调用。 + 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了所需类型的文本输出,并且没有工具调用。 ### 流式传输 -流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming]将包含此次运行的完整信息,包括生成的所有新输出。你可以调用`.stream_events()`获取流式事件。更多信息请参阅[流式传输指南](streaming.md)。 +流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming]将包含此次运行的完整信息,包括生成的所有新输出。你可以调用`.stream_events()`获取流式事件。有关更多信息,请参阅[流式传输指南](streaming.md)。 #### Responses WebSocket 传输(可选辅助工具) -如果启用 OpenAI Responses WebSocket 传输,你仍可继续使用常规`Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 +如果启用 OpenAI Responses WebSocket 传输,你仍可继续使用常规的`Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 这是通过 WebSocket 传输使用 Responses API,而不是[Realtime API](realtime/guide.md)。 -有关传输选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 +有关传输方式选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 ##### 模式 1:不使用会话辅助工具(可用) -如果你只需要 WebSocket 传输,并且不需要 SDK 为你管理共享提供商/会话,请使用此模式。 +如果你只希望使用 WebSocket 传输,且不需要 SDK 为你管理共享的提供商或会话,请使用此模式。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -此模式适用于单次运行。如果反复调用`Runner.run()` / `Runner.run_streamed()`,每次运行都可能重新连接,除非你手动复用同一个`RunConfig` / 提供商实例。 +此模式适用于单次运行。如果反复调用`Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个`RunConfig` / 提供商实例,否则每次运行都可能重新连接。 -##### 模式 2:使用`responses_websocket_session()`(推荐用于多轮复用) +##### 模式 2:使用`responses_websocket_session()`(建议用于多轮复用) -如果希望在多次运行之间共享支持 WebSocket 的提供商和`RunConfig`,请使用[`responses_websocket_session()`][agents.responses_websocket_session],这也包括继承同一`run_config`的嵌套“智能体作为工具”调用。 +如果希望在多次运行之间共享支持 WebSocket 的提供商和`RunConfig`,请使用[`responses_websocket_session()`][agents.responses_websocket_session],这也包括继承相同`run_config`的嵌套智能体工具调用。 ```python import asyncio @@ -119,56 +119,58 @@ async def main(): asyncio.run(main()) ``` -请在上下文退出前完成流式结果的消费。如果在 WebSocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 +请在上下文退出前完成对流式结果的消费。如果 WebSocket 请求仍在处理中便退出上下文,可能会强制关闭共享连接。 -如果较长的推理轮次触发 WebSocket 保活超时,请增大`ping_timeout`,或将`ping_timeout=None`设置为禁用心跳超时。对于可靠性比 WebSocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 +该服务在每个 WebSocket 连接上一次处理一个响应,并将单个连接限制为 60 分钟。辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False`和 ZDR 流程无法恢复未缓存的`previous_response_id`;请使用完整输入上下文启动新链,或根据本地管理的会话状态进行重建。有关完整的恢复行为,请参阅[Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 + +如果较长的推理轮次触发 WebSocket 保活超时,请增大`ping_timeout`,或设置`ping_timeout=None`以禁用心跳超时。对于可靠性比 WebSocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 ### 运行配置 -`run_config`参数可用于配置智能体运行的一些全局设置: +`run_config`参数允许你为智能体运行配置一些全局设置: -#### 常见运行配置目录 +#### 常用运行配置类别 -使用`RunConfig`可覆盖单次运行的行为,而无需更改各个智能体的定义。 +使用`RunConfig`可在不更改各个智能体定义的情况下,覆盖单次运行的行为。 -##### 模型、提供商和会话默认设置 +##### 模型、提供商和会话默认值 - [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个智能体的`model`设置。 -- [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为OpenAI。 -- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,可以设置全局`temperature`或`top_p`。 -- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认设置(例如`SessionSettings(limit=...)`)。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每轮开始前将新用户输入与会话历史记录合并的方式。该回调可以是同步或异步的。 +- [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 +- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,你可以设置全局`temperature`或`top_p`。 +- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认值(例如`SessionSettings(limit=...)`)。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用会话时,自定义每轮开始前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 ##### 安全防护措施、任务转移和模型输入调整 -- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要包含在所有运行中的输入或输出安全防护措施列表。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:应用于所有任务转移的全局输入过滤器,前提是该任务转移尚未设置过滤器。输入过滤器允许你编辑发送给新智能体的输入。更多详细信息请参阅[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]的文档。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选择启用的 Beta 功能。在调用下一个智能体之前,它会将可摘要的历史记录压缩为有序的助手摘要片段,同时将无损消息项保留在其原始位置。在我们完善嵌套任务转移期间,此功能默认禁用;设置为`True`可启用,保持`False`则会直接传递原始记录。Sessions、`RunState`和`RunResult.to_input_list()`会避免重复追加完全相同的消息实例(当 SDK 默认的嵌套历史记录已包含该消息时),同时保留彼此独立但内容相同的消息。如果未传入`RunConfig`,所有[Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,而任何显式的[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]回调仍会覆盖此设置。单个任务转移可通过[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]覆盖此设置。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:可选的可调用对象。当你选择启用`nest_handoff_history`时,它会接收规范化的记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的确切输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如裁剪历史记录或注入系统提示词。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 Runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 +- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要在所有运行中包含的输入或输出安全防护措施列表。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未设置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详细信息,请参阅[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]文档。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选择启用的 Beta 功能,在调用下一个智能体前,将可摘要的历史记录压缩为按序排列的助手摘要片段,同时在原始位置保留无损消息项。在我们稳定嵌套任务转移功能期间,此功能默认禁用;将其设置为`True`可启用,保留为`False`则会原样传递原始记录。当 SDK 默认的嵌套历史记录已包含某条消息时,会话、`RunState`和`RunResult.to_input_list()`会避免重复追加该消息的同一次出现,同时仍保留彼此独立但内容相同的消息。如果你未传入`RunConfig`,所有[运行器方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例中的该默认功能仍处于关闭状态,而任何显式的[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]回调仍会覆盖它。各个任务转移可以通过[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]覆盖此设置。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:选择启用`nest_handoff_history`时调用的可选函数,它会接收规范化的记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的准确输入项列表,在无需编写完整任务转移过滤器的情况下,替换内置的按序摘要片段。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如修剪历史记录或注入系统提示词。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制运行器将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 ##### 追踪与可观测性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许为整个运行禁用[追踪](tracing.md)。 -- [`tracing`][agents.run.RunConfig.tracing]:传入[`TracingConfig`][agents.tracing.TracingConfig]可覆盖追踪导出设置,例如每次运行所使用的追踪 API 密钥。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪中是否包含潜在敏感数据,例如 LLM 和工具调用的输入/输出。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。建议至少设置`workflow_name`。组 ID 是可选字段,可用于关联多次运行的追踪。 +- [`tracing`][agents.run.RunConfig.tracing]:传入[`TracingConfig`][agents.tracing.TracingConfig]以覆盖追踪导出设置,例如每次运行的追踪 API 密钥。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在敏感数据,例如 LLM 和工具调用的输入/输出。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置`workflow_name`。组 ID 是一个可选字段,用于关联多次运行中的追踪。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 ##### 工具执行、审批和工具错误行为 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 端的执行行为,例如限制同时运行的工具调用数量。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置 Runner 如何处理模型生成但无法解析的工具调用。默认行为是抛出`ModelBehaviorError`;也可选择改为返回模型可见的错误输出。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝,以及选择启用后返回的工具未找到输出。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用的 SDK 端执行行为,例如限制同时运行的工具调用数量。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置运行器如何处理模型生成但无法解析的工具调用。默认行为是引发`ModelBehaviorError`;你可以选择改为返回模型可见的错误输出。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批拒绝和选择启用的“工具未找到”输出。 -嵌套任务转移是一项可选择启用的 Beta 功能。传入`RunConfig(nest_handoff_history=True)`可启用有序记录压缩,也可以设置`handoff(..., nest_handoff_history=True)`,仅为特定任务转移启用此功能。内置映射器会在无损消息项前后放置生成的助手摘要片段,而不是将整个记录压缩为一条消息。如果希望保留原始记录(默认行为),请不要设置该标志,或者提供一个`handoff_input_filter`(或`handoff_history_mapper`),按需准确转发对话。如果想更改生成摘要片段时使用的包装文本,而不编写自定义映射器,请调用[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]可恢复默认值)。 +嵌套任务转移是一项可选择启用的 Beta 功能。传入`RunConfig(nest_handoff_history=True)`可启用按序记录压缩,也可设置`handoff(..., nest_handoff_history=True)`,仅为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段置于无损消息项周围,而不是将整个记录压缩成一条消息。如果你希望保留原始记录(默认行为),请勿设置此标志,或提供一个根据需要准确转发对话的`handoff_input_filter`(或`handoff_history_mapper`)。如需更改生成的摘要片段中使用的包装文本,而不编写自定义映射器,请调用[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并使用[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]恢复默认设置)。 #### 运行配置详情 ##### `tool_execution` -如果希望配置本地工具调用在 SDK 端的行为,例如限制单次运行中本地工具调用的并发量,请使用`tool_execution`。 +如果希望配置本地工具调用的 SDK 端行为,例如限制某次运行中的本地工具调用并发数,请使用`tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -187,17 +189,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`会保留默认行为:当模型在一轮中生成多个工具调用时,SDK 会启动所有已生成的本地工具调用。将其设置为整数值,可限制同时运行的本地工具调用数量。 +`max_function_tool_concurrency=None`会保留默认行为:当模型在一轮中生成多个工具调用时,SDK 会启动所有已生成的本地工具调用。设置一个整数值,可以限制同时运行的本地工具调用数量。 -这与提供商端的[`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]相互独立。`parallel_tool_calls`控制是否允许模型在单个响应中生成多个工具调用。`tool_execution.max_function_tool_concurrency`控制模型生成这些调用后,SDK 如何执行本地工具调用。 +这与提供商端的[`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]相互独立。`parallel_tool_calls`控制是否允许模型在单个响应中生成多个工具调用。`tool_execution.max_function_tool_concurrency`控制模型生成工具调用后,SDK 如何执行本地工具调用。 -`pre_approval_tool_input_guardrails=False`会保留默认审批流程:如果工具调用需要审批,运行会先暂停,工具输入安全防护措施仅在审批完成后、执行前立即运行。如果希望工具调用输入安全防护措施在发出待审批中断之前运行,请将其设置为`True`。通过此次审批前检查的调用,在审批后仍会再次运行相同的输入安全防护措施,从而在执行前重新验证时效性检查。 +`pre_approval_tool_input_guardrails=False`会保留默认审批流程:如果工具调用需要审批,运行会先暂停,并且仅在审批通过后、执行前立即运行工具输入安全防护措施。如果希望在发出待审批中断前运行工具调用输入安全防护措施,请将其设置为`True`。通过此次审批前检查的调用仍会在审批通过后再次运行相同的输入安全防护措施,以便在执行前重新验证时效性检查。 ##### `tool_not_found_behavior` -默认情况下,如果模型生成的工具调用与当前智能体可用的任何工具调用都不匹配,Runner 会抛出`ModelBehaviorError`。 +默认情况下,如果模型生成的工具调用与当前智能体可用的任何工具调用都不匹配,运行器会引发`ModelBehaviorError`。 -如果希望运行仍可恢复,请设置`tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为未解析的工具调用追加`function_call_output`,并再次运行模型,使模型可以选择可用工具,或在不使用该工具的情况下回答。 +如果希望运行仍可恢复,请设置`tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为无法解析的工具调用追加一个`function_call_output`,然后再次运行模型,使模型能够选择可用工具,或在不使用该工具的情况下作答。 ```python from agents import Agent, RunConfig, Runner @@ -211,13 +213,13 @@ result = await Runner.run( ) ``` -此选项目前仅适用于未解析的工具调用。其他无效工具载荷仍沿用其现有的错误处理行为。 +目前,此选项仅适用于无法解析的工具调用。其他无效工具负载仍会使用其现有错误处理行为。 ##### `tool_error_formatter` 当 SDK 创建模型可见的工具错误输出时,可使用`tool_error_formatter`自定义返回给模型的消息。 -格式化程序会接收包含以下字段的[`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]: +格式化器接收包含以下字段的[`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]: - `kind`:错误目录,例如`"approval_rejected"`或`"tool_not_found"`。 - `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`或`"custom"`)。 @@ -253,20 +255,20 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`控制 Runner 向前传递历史记录时,如何将推理项转换为下一轮模型输入(例如使用`RunResult.to_input_list()`或由会话支持的运行时)。 +当运行器向后传递历史记录时(例如使用`RunResult.to_input_list()`或基于会话的运行),`reasoning_item_id_policy`控制如何将推理项转换为下一轮模型输入。 -- `None`或`"preserve"`(默认):保留推理项 ID。 +- `None`或`"preserve"`(默认值):保留推理项 ID。 - `"omit"`:从生成的下一轮输入中移除推理项 ID。 -`"omit"`主要用于选择启用针对一类 Responses API 400 错误的缓解措施:推理项带有`id`发送,但没有必需的后续项(例如`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"`主要用于选择性缓解一类 Responses API 400 错误:发送的推理项带有`id`,但缺少后续必需项(例如`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -在多轮智能体运行中,如果 SDK 根据先前的输出构造后续输入(包括会话持久化、由服务端管理的对话增量、流式/非流式后续轮次和恢复路径),并且保留了推理项 ID,但提供商要求该 ID 必须与其对应的后续项配对,就可能发生这种情况。 +在多轮智能体运行中,当 SDK 根据先前输出构建后续输入时,可能会发生这种情况,其中包括会话持久化、服务管理的对话增量、流式传输/非流式传输的后续轮次,以及恢复路径。如果推理项 ID 被保留,但提供商要求该 ID 必须与其对应的后续项配对,就会触发此错误。 -设置`reasoning_item_id_policy="omit"`会保留推理内容,但移除推理项的`id`,从而避免在 SDK 生成的后续输入中触发该 API 不变量。 +设置`reasoning_item_id_policy="omit"`会保留推理内容,但移除推理项的`id`,从而避免 SDK 生成的后续输入触发该 API 不变量。 -适用范围说明: +作用范围说明: -- 这只会更改 SDK 构建后续输入时生成/转发的推理项。 +- 这仅会更改 SDK 在构建后续输入时生成或转发的推理项。 - 它不会重写用户提供的初始输入项。 - 应用此策略后,`call_model_input_filter`仍可有意重新引入推理 ID。 @@ -274,31 +276,31 @@ result = Runner.run_sync( ### 记忆策略选择 -有四种常见方式可以将状态传递到下一轮: +将状态带入下一轮通常有四种方式: -| 策略 | 状态存储位置 | 最适用场景 | 下一轮传入的内容 | +| 策略 | 状态存储位置 | 最适合 | 下一轮传入的内容 | | --- | --- | --- | --- | -| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任意提供商 | `result.to_input_list()`返回的列表,加上下一条用户消息 | +| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()`返回的列表加上下一条用户消息 | | `session` | 你的存储加 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个`session`实例,或指向同一存储的另一个实例 | -| `conversation_id` | OpenAI Conversations API | 希望在不同工作进程或服务间共享的命名服务端对话 | 同一个`conversation_id`,加上仅包含新用户轮次的内容 | -| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务端托管延续 | `result.last_response_id`,加上仅包含新用户轮次的内容 | +| `conversation_id` | OpenAI Conversations API | 希望在工作进程或服务之间共享的具名服务端对话 | 相同的`conversation_id`加上新的用户轮次 | +| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务管理续接 | `result.last_response_id`加上新的用户轮次 | -`result.to_input_list()`和`session`由客户端管理。`conversation_id`和`previous_response_id`由OpenAI管理,并且仅适用于使用 OpenAI Responses API的情况。对于大多数应用程序,每个对话应选择一种持久化策略。混合使用客户端管理的历史记录和OpenAI管理的状态可能导致上下文重复,除非你有意协调这两个层级。 +`result.to_input_list()`和`session`由客户端管理。`conversation_id`和`previous_response_id`由OpenAI管理,并且仅适用于使用 OpenAI Responses API 的情况。在大多数应用中,请为每个对话选择一种持久化策略。除非你有意协调这两个层级,否则混用客户端管理的历史记录和OpenAI管理的状态可能导致上下文重复。 !!! note - 同一次运行中,无法同时使用会话持久化与服务端管理的对话设置 - (`conversation_id`、`previous_response_id`或`auto_previous_response_id`)。 - 每次调用请选择一种方式。 + 在同一次运行中,会话持久化不能与服务管理的对话设置 + (`conversation_id`、`previous_response_id`或`auto_previous_response_id`) + 结合使用。每次调用请选择一种方式。 ### 对话/聊天线程 -调用任一运行方法都可能导致一个或多个智能体运行(因此会进行一次或多次 LLM 调用),但在聊天对话中,这表示一个逻辑轮次。例如: +调用任何运行方法都可能导致一个或多个智能体运行(因此会进行一次或多次 LLM 调用),但它表示聊天对话中的一个逻辑轮次。例如: 1. 用户轮次:用户输入文本 -2. Runner 运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 +2. 运行器运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体,第二个智能体运行更多工具,然后生成输出。 -智能体运行结束时,你可以选择向用户展示哪些内容。例如,可以向用户展示智能体生成的每个新项目,也可以只展示最终输出。无论采用哪种方式,用户都可能继续提出后续问题,此时可以再次调用运行方法。 +智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项,也可以只显示最终输出。无论采用哪种方式,用户之后都可能提出后续问题,此时可以再次调用运行方法。 #### 手动对话管理 @@ -324,9 +326,9 @@ async def main(): # California ``` -#### 使用会话自动管理对话 +#### 使用会话的自动对话管理 -如果希望采用更简单的方法,可以使用[Sessions](sessions/index.md)自动处理对话历史记录,而无需手动调用`.to_input_list()`: +如需更简单的方法,可以使用[会话](sessions/index.md)自动处理对话历史记录,而无需手动调用`.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -350,24 +352,24 @@ async def main(): # California ``` -Sessions 会自动: +会话会自动: - 在每次运行前检索对话历史记录 - 在每次运行后存储新消息 -- 为不同的会话 ID 维护独立对话 +- 为不同的会话 ID 维护独立的对话 -更多详细信息请参阅[Sessions 文档](sessions/index.md)。 +有关更多详细信息,请参阅[会话文档](sessions/index.md)。 -#### 服务端管理的对话 +#### 服务管理的对话 -你也可以让OpenAI对话状态功能在服务端管理对话状态,而不是使用`to_input_list()`或`Sessions`在本地处理。这样无需手动重新发送所有历史消息,即可保留对话历史记录。使用下述任一服务端管理方式时,每次请求仅传入新轮次的输入,并复用已保存的 ID。更多详细信息请参阅[OpenAI对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 +你也可以让OpenAI对话状态功能在服务端管理对话状态,而不是在本地使用`to_input_list()`或`Sessions`进行处理。这样便可保留对话历史记录,而无需手动重新发送所有过去的消息。使用以下任一服务管理方式时,每次请求仅传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅[OpenAI对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 OpenAI提供两种跨轮次追踪状态的方式: ##### 1. 使用`conversation_id` -首先使用 OpenAI Conversations API创建对话,然后在后续每次调用中复用其 ID: +首先使用 OpenAI Conversations API 创建对话,然后在后续每次调用中复用其 ID: ```python from agents import Agent, Runner @@ -390,7 +392,7 @@ async def main(): ##### 2. 使用`previous_response_id` -另一种选择是**响应链接**,其中每一轮都显式链接到上一轮的响应 ID。 +另一种方式是**响应链式衔接**,即每个轮次都显式链接到上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -415,30 +417,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -如果运行因审批而暂停,并且你从[`RunState`][agents.run_state.RunState]恢复,SDK 会保留已保存的`conversation_id` / `previous_response_id` / `auto_previous_response_id`设置,使恢复后的轮次继续使用同一个服务端管理的对话。 +如果运行暂停以等待审批,并且你从[`RunState`][agents.run_state.RunState]恢复运行,SDK 会保留已保存的`conversation_id` / `previous_response_id` / `auto_previous_response_id`设置,以便恢复后的轮次继续使用同一个服务管理的对话。 -`conversation_id`和`previous_response_id`互斥。如果需要可跨系统共享的命名对话资源,请使用`conversation_id`。如果需要从一轮到下一轮最轻量的 Responses API延续基本组件,请使用`previous_response_id`。 +`conversation_id`和`previous_response_id`互斥。如果需要可在不同系统间共享的具名对话资源,请使用`conversation_id`。如果需要最轻量的 Responses API 基本组件来续接相邻轮次,请使用`previous_response_id`。 !!! note - SDK 会自动以退避方式重试`conversation_locked`错误。在服务端管理的 - 对话运行中,它会在重试前回退内部对话跟踪器输入,以便干净地重新发送 - 相同的已准备项目。 + SDK 会通过退避机制自动重试`conversation_locked`错误。在服务管理的 + 对话运行中,它会在重试前回退内部对话追踪器的输入,以便清晰地重新发送 + 相同的已准备项。 在基于本地会话的运行中(无法与`conversation_id`、 - `previous_response_id`或`auto_previous_response_id`结合使用),SDK 还会尽最大努力 - 回滚最近持久化的输入项,以减少重试后重复的历史记录条目。 + `previous_response_id`或`auto_previous_response_id`结合使用),SDK 还会尽力 + 回滚最近持久化的输入项,以减少重试后出现重复的历史记录条目。 - 即使未配置`ModelSettings.retry`,也会进行此兼容性重试。有关 - 更广泛、可选择启用的模型请求重试行为,请参阅[Runner 管理的重试](models/index.md#runner-managed-retries)。 + 即使没有配置`ModelSettings.retry`,也会执行此兼容性重试。有关模型请求中 + 更广泛的可选择启用重试行为,请参阅[运行器管理的重试](models/index.md#runner-managed-retries)。 ## 钩子与自定义 ### 模型调用输入过滤器 -使用`call_model_input_filter`可在调用模型前编辑模型输入。该钩子会接收当前智能体、上下文和合并后的输入项(包括存在的会话历史记录),并返回新的`ModelInputData`。 +使用`call_model_input_filter`可在模型调用前编辑模型输入。该钩子接收当前智能体、上下文和合并后的输入项(包括存在的会话历史记录),并返回新的`ModelInputData`。 -返回值必须是[`ModelInputData`][agents.run.ModelInputData]对象。其`input`字段为必填项,并且必须是输入项列表。返回任何其他结构都会抛出`UserError`。 +返回值必须是[`ModelInputData`][agents.run.ModelInputData]对象。其`input`字段为必填项,并且必须是输入项列表。返回任何其他结构都会引发`UserError`。 ```python from agents import Agent, Runner, RunConfig @@ -457,19 +459,19 @@ result = Runner.run_sync( ) ``` -Runner 会将已准备输入列表的副本传递给钩子,因此你可以裁剪、替换或重新排序该列表,而不会原地修改调用方的原始列表。 +运行器会将已准备输入列表的副本传给该钩子,因此你可以修剪、替换或重新排序,而不会就地修改调用方的原始列表。 -如果使用会话,`call_model_input_filter`会在会话历史记录已加载并与当前轮次合并后运行。如果希望自定义此前的合并步骤本身,请使用[`session_input_callback`][agents.run.RunConfig.session_input_callback]。 +如果使用会话,`call_model_input_filter`会在会话历史记录加载完毕并与当前轮次合并后运行。如果希望自定义更早的合并步骤本身,请使用[`session_input_callback`][agents.run.RunConfig.session_input_callback]。 -如果使用带有`conversation_id`、`previous_response_id`或`auto_previous_response_id`的OpenAI服务端管理对话状态,该钩子会针对下一次 Responses API调用所准备的载荷运行。该载荷可能已经仅表示新轮次的增量,而不是完整重放先前的历史记录。只有你返回的项目才会被标记为已针对该服务端管理的延续发送。 +如果通过`conversation_id`、`previous_response_id`或`auto_previous_response_id`使用OpenAI服务管理的对话状态,该钩子会在为下一次 Responses API 调用准备的负载上运行。该负载可能已经只表示新轮次的增量,而不是对先前历史记录的完整重放。只有你返回的项才会被标记为已发送,用于该服务管理的续接。 -通过`run_config`为每次运行设置该钩子,以编辑敏感数据、裁剪过长的历史记录或注入额外的系统指导。 +可通过`run_config`为每次运行设置该钩子,以遮盖敏感数据、修剪过长的历史记录,或注入额外的系统指导。 ## 错误与恢复 ### 错误处理程序 -所有`Runner`入口点都接受`error_handlers`,这是一个以错误类型为键的字典。支持的键包括`"max_turns"`、`"model_refusal"`和`"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些处理程序。 +所有`Runner`入口点均接受`error_handlers`,它是一个以错误类型为键的字典。支持的键为`"max_turns"`、`"model_refusal"`和`"invalid_final_output"`。如果希望返回受控的最终输出,而不是因相应错误而结束运行,请使用这些处理程序。 ```python from agents import ( @@ -498,7 +500,7 @@ result = Runner.run_sync( print(result.final_output) ``` -当模型消息无法通过智能体结构化`output_type`的验证,或模型未返回结构化最终消息时,请使用`"invalid_final_output"`。处理程序可以返回应用程序特定的回退值,SDK 会使用相同的`output_type`对其进行验证。它不会重试模型调用,也不会重新执行任何工具副作用。返回`None`表示放弃恢复。如果没有回退值,非空验证失败仍会抛出`ModelBehaviorError`,而空的结构化响应则保留现有的下一轮行为。 +当模型消息无法通过智能体的结构化`output_type`验证,或模型未返回结构化最终消息时,请使用`"invalid_final_output"`。处理程序可以返回应用特定的回退值,SDK 会根据相同的`output_type`对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回`None`表示放弃恢复。如果没有回退值,非空验证失败仍会引发`ModelBehaviorError`,而空的结构化响应会保留现有的下一轮行为。 ```python from pydantic import BaseModel @@ -532,7 +534,7 @@ print(result.final_output) 如果不希望将回退输出追加到对话历史记录,请设置`include_in_history=False`。 -如果希望模型拒绝时生成应用程序特定的回退值,而不是以`ModelRefusalError`结束运行,请使用`"model_refusal"`。 +当模型拒绝应生成应用特定的回退值,而不是以`ModelRefusalError`结束运行时,请使用`"model_refusal"`。 ```python from pydantic import BaseModel @@ -566,33 +568,33 @@ print(result.final_output) ## 持久执行集成与人工介入 -有关工具审批暂停/恢复模式,请先参阅专门的[人工介入指南](human_in_the_loop.md)。以下集成适用于持久编排,运行过程可能包含长时间等待、重试或进程重启。 +有关工具审批的暂停/恢复模式,请先参阅专门的[人工介入指南](human_in_the_loop.md)。以下集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 ### Dapr -你可以使用Agents SDK的[Dapr](https://dapr.io) Diagrid 集成来运行持久、长时间运行的智能体;这些智能体支持人工介入,并能自动从故障中恢复。Dapr 是一个与供应商无关的[CNCF](https://cncf.io)工作流编排器。点击[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和OpenAI智能体。 +你可以使用 Agents SDK 的[Dapr](https://dapr.io) Diagrid 集成,运行持久的长时间运行智能体。这些智能体支持人工介入,并可从故障中自动恢复。Dapr 是一个供应商中立的[CNCF](https://cncf.io)工作流编排器。可从[这里](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和OpenAI智能体。 ### Temporal -你可以使用Agents SDK的[Temporal](https://temporal.io/)集成来运行持久、长时间运行的工作流,包括人工介入任务。可在[此视频](https://www.youtube.com/watch?v=fFBZqzT4DD8)中观看 Temporal 与Agents SDK协同完成长时间运行任务的演示,并可在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 +你可以使用 Agents SDK 的[Temporal](https://temporal.io/)集成来运行持久的长时间运行工作流,包括人工介入任务。可在[此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并可在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 ### Restate -你可以使用Agents SDK的[Restate](https://restate.dev/)集成来实现轻量级、持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单一二进制运行时,并支持将智能体作为进程/容器或 Serverless 函数运行。更多详细信息请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 +你可以使用 Agents SDK 的[Restate](https://restate.dev/)集成来构建轻量且持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务函数运行。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 ### DBOS -你可以使用Agents SDK的[DBOS](https://dbos.dev/)集成运行可靠的智能体,在发生故障和重启时保留进度。它支持长时间运行的智能体、人工介入工作流和任务转移,也支持同步和异步方法。该集成仅需要 SQLite 或 Postgres 数据库。更多详细信息请查看集成[仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 +你可以使用 Agents SDK 的[DBOS](https://dbos.dev/)集成来运行可靠的智能体,并在故障和重启时保留进度。它支持长时间运行智能体、人工介入工作流和任务转移,同时支持同步和异步方法。该集成仅需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 ## 异常 -SDK 会在特定情况下抛出异常。完整列表位于[`agents.exceptions`][]中。概览如下: +SDK 会在特定情况下引发异常。完整列表请参阅[`agents.exceptions`][]。概述如下: -- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 内抛出的所有异常的基类。它是一种通用类型,所有其他特定异常均派生自该类。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给`Runner.run`、`Runner.run_sync`或`Runner.run_streamed`方法的`max_turns`限制时,会抛出此异常。它表示智能体无法在指定的交互轮数内完成任务。设置`max_turns=None`可禁用该限制。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。这可能包括: - - JSON 格式错误:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定`output_type`的情况下。 +- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 内部引发的所有异常的基类。它是一种通用类型,所有其他特定异常均派生自该类型。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给`Runner.run`、`Runner.run_sync`或`Runner.run_streamed`方法的`max_turns`限制时,会引发此异常。它表示智能体无法在指定的交互轮次数内完成任务。设置`max_turns=None`可禁用此限制。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。可能包括: + - 格式错误的 JSON:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定`output_type`的情况下。 - 意外的工具相关故障:模型未按预期方式使用工具 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当工具调用超过其配置的超时时间,并且该工具使用`timeout_behavior="raise_exception"`时,会抛出此异常。 -- [`UserError`][agents.exceptions.UserError]:当你(编写使用 SDK 的代码的人)在使用 SDK 时出错,会抛出此异常。这通常由错误的代码实现、无效配置或误用 SDK API 导致。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:分别在满足输入安全防护措施或输出安全防护措施的条件时抛出此异常。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当工具调用超过其配置的超时时间,并且工具使用`timeout_behavior="raise_exception"`时,会引发此异常。 +- [`UserError`][agents.exceptions.UserError]:当你(编写使用 SDK 的代码的人员)在使用 SDK 时出错,会引发此异常。这通常是由不正确的代码实现、无效配置或误用 SDK API 导致的。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:当分别满足输入安全防护措施或输出安全防护措施的条件时,会引发此异常。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file From 35c880541592c242e54079bbe0588f0b33967125 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 28 Jul 2026 18:10:12 -0500 Subject: [PATCH 047/473] fix(memory): count valid SQLite session items for positive limits (#4001) --- .../extensions/memory/async_sqlite_session.py | 73 +++++++++++++------ src/agents/memory/sqlite_session.py | 73 ++++++++++++------- .../memory/test_async_sqlite_session.py | 27 +++++++ tests/memory/test_session.py | 61 ++++++++++++++++ 4 files changed, 183 insertions(+), 51 deletions(-) diff --git a/src/agents/extensions/memory/async_sqlite_session.py b/src/agents/extensions/memory/async_sqlite_session.py index 63ae77081b..7094e1a0f0 100644 --- a/src/agents/extensions/memory/async_sqlite_session.py +++ b/src/agents/extensions/memory/async_sqlite_session.py @@ -127,6 +127,16 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: session_limit = resolve_session_limit(limit, self.session_settings) + def _decode_rows(rows: list[Any]) -> list[TResponseInputItem]: + items: list[TResponseInputItem] = [] + for (message_data,) in rows: + try: + item = json.loads(message_data) + items.append(item) + except json.JSONDecodeError: + continue + return items + async with self._locked_connection() as conn: if session_limit is None: cursor = await conn.execute( @@ -137,32 +147,47 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: """, (self.session_id,), ) - else: - cursor = await conn.execute( - f""" - SELECT message_data FROM {self.messages_table} - WHERE session_id = ? - ORDER BY id DESC - LIMIT ? - """, - (self.session_id, session_limit), - ) - + rows = list(await cursor.fetchall()) + await cursor.close() + return _decode_rows(rows) + + if session_limit > 0: + # Expand the fetch window when corrupt rows sit among the newest entries so + # limit counts valid conversation items, matching EncryptedSession and pop_item. + window = session_limit + while True: + cursor = await conn.execute( + f""" + SELECT message_data FROM {self.messages_table} + WHERE session_id = ? + ORDER BY id DESC + LIMIT ? + """, + (self.session_id, window), + ) + rows = list(await cursor.fetchall()) + await cursor.close() + items = _decode_rows(rows[::-1]) + if len(items) >= session_limit: + return items[-session_limit:] + if len(rows) < window: + return items + window *= 2 + + # Preserve historical non-positive LIMIT semantics (including SQLite's + # unlimited behavior for negative values). + cursor = await conn.execute( + f""" + SELECT message_data FROM {self.messages_table} + WHERE session_id = ? + ORDER BY id DESC + LIMIT ? + """, + (self.session_id, session_limit), + ) rows = list(await cursor.fetchall()) await cursor.close() - - if session_limit is not None: - rows = rows[::-1] - - items: list[TResponseInputItem] = [] - for (message_data,) in rows: - try: - item = json.loads(message_data) - items.append(item) - except json.JSONDecodeError: - continue - - return items + return _decode_rows(rows[::-1]) async def add_items(self, items: list[TResponseInputItem]) -> None: """Add new items to the conversation history. diff --git a/src/agents/memory/sqlite_session.py b/src/agents/memory/sqlite_session.py index b57f3ebf5a..29ba270298 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -215,6 +215,17 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: """ session_limit = resolve_session_limit(limit, self.session_settings) + def _decode_rows(rows: list[Any]) -> list[TResponseInputItem]: + items: list[TResponseInputItem] = [] + for (message_data,) in rows: + try: + item = json.loads(message_data) + items.append(item) + except (json.JSONDecodeError, TypeError): + # Skip invalid JSON entries + continue + return items + def _get_items_sync(): with self._locked_connection() as conn: if session_limit is None: @@ -227,34 +238,42 @@ def _get_items_sync(): """, (self.session_id,), ) - else: - # Fetch the latest N items in chronological order - cursor = conn.execute( - f""" - SELECT message_data FROM {self.messages_table} - WHERE session_id = ? - ORDER BY id DESC - LIMIT ? - """, - (self.session_id, session_limit), - ) - - rows = cursor.fetchall() - - # Reverse to get chronological order when using DESC - if session_limit is not None: - rows = list(reversed(rows)) + return _decode_rows(cursor.fetchall()) - items = [] - for (message_data,) in rows: - try: - item = json.loads(message_data) - items.append(item) - except (json.JSONDecodeError, TypeError): - # Skip invalid JSON entries - continue - - return items + if session_limit > 0: + # Expand the fetch window when corrupt rows sit among the newest entries so + # limit counts valid conversation items, matching EncryptedSession and pop_item. + window = session_limit + while True: + cursor = conn.execute( + f""" + SELECT message_data FROM {self.messages_table} + WHERE session_id = ? + ORDER BY id DESC + LIMIT ? + """, + (self.session_id, window), + ) + rows = cursor.fetchall() + items = _decode_rows(list(reversed(rows))) + if len(items) >= session_limit: + return items[-session_limit:] + if len(rows) < window: + return items + window *= 2 + + # Preserve historical non-positive LIMIT semantics (including SQLite's + # unlimited behavior for negative values). + cursor = conn.execute( + f""" + SELECT message_data FROM {self.messages_table} + WHERE session_id = ? + ORDER BY id DESC + LIMIT ? + """, + (self.session_id, session_limit), + ) + return _decode_rows(list(reversed(cursor.fetchall()))) return await asyncio.to_thread(_get_items_sync) diff --git a/tests/extensions/memory/test_async_sqlite_session.py b/tests/extensions/memory/test_async_sqlite_session.py index 6ab3d9feb4..2a9a21936b 100644 --- a/tests/extensions/memory/test_async_sqlite_session.py +++ b/tests/extensions/memory/test_async_sqlite_session.py @@ -141,6 +141,33 @@ async def test_async_sqlite_session_get_items_limit(): await session.close() +async def test_async_sqlite_session_get_items_limit_skips_corrupt_newest_rows(): + """limit counts valid items, expanding past corrupt newest rows.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "async_limit_corrupt.db" + session = AsyncSQLiteSession("async_limit_corrupt", db_path) + + await session.add_items( + [ + {"role": "user", "content": "valid 0"}, + {"role": "assistant", "content": "valid 1"}, + {"role": "user", "content": "valid 2"}, + ] + ) + + conn = await session._get_connection() + await conn.execute( + f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)", + (session.session_id, "not valid json {{{"), + ) + await conn.commit() + + limited = await session.get_items(limit=2) + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + + await session.close() + + async def test_async_sqlite_session_session_settings_default(): """Test that session_settings defaults to empty SessionSettings.""" session = AsyncSQLiteSession("async_default_settings") diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index d727991f7d..ade1b32314 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -430,6 +430,67 @@ async def test_sqlite_session_get_items_with_limit(): session.close() +@pytest.mark.asyncio +async def test_sqlite_session_get_items_limit_skips_corrupt_newest_rows(): + """limit counts valid items, expanding past corrupt newest rows.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "test_limit_corrupt.db" + session = SQLiteSession("limit_corrupt", db_path) + + await session.add_items( + [ + {"role": "user", "content": "valid 0"}, + {"role": "assistant", "content": "valid 1"}, + {"role": "user", "content": "valid 2"}, + ] + ) + + with session._locked_connection() as conn: + conn.execute( + f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)", + (session.session_id, "not valid json {{{"), + ) + conn.commit() + + # Newest row is corrupt; limit=2 should still return the two latest valid items. + limited = await session.get_items(limit=2) + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + + session.close() + + +@pytest.mark.asyncio +async def test_sqlite_session_get_items_session_settings_limit_skips_corrupt_rows(): + """session_settings.limit also counts valid items when newest rows are corrupt.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "test_settings_limit_corrupt.db" + session = SQLiteSession( + "settings_limit_corrupt", + db_path, + session_settings=SessionSettings(limit=2), + ) + + await session.add_items( + [ + {"role": "user", "content": "valid 0"}, + {"role": "assistant", "content": "valid 1"}, + {"role": "user", "content": "valid 2"}, + ] + ) + + with session._locked_connection() as conn: + conn.execute( + f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)", + (session.session_id, "not valid json {{{"), + ) + conn.commit() + + limited = await session.get_items() + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + + session.close() + + @pytest.mark.parametrize("runner_method", ["run", "run_sync", "run_streamed"]) @pytest.mark.asyncio async def test_session_memory_appends_list_input_by_default(runner_method): From e8311b45886edcede59aecc539821a95586369e5 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 28 Jul 2026 18:17:36 -0500 Subject: [PATCH 048/473] fix(run): count streamed retries when terminal usage is missing (#4002) --- src/agents/run_internal/run_loop.py | 18 +++-- tests/test_agent_runner_streamed.py | 105 +++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 168d646876..f2d2961d2c 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1603,13 +1603,17 @@ async def rewind_model_request() -> None: # the terminal response output empty. Preserve those items so the runner can # resolve the completed step correctly. terminal_response.output = list(streamed_response_output) - usage = ( - apply_retry_attempt_usage( - _response_usage_to_usage(terminal_response.usage), - stream_failed_retry_attempts[0], - ) - if terminal_response.usage - else Usage() + # Always fold retry attempts into usage, even when the terminal response omits + # provider usage (common for some Chat Completions / LiteLLM streams). Skipping + # apply_retry_attempt_usage here would drop failed-attempt accounting and diverge + # from the non-streaming get_response_with_retry path. + usage = apply_retry_attempt_usage( + ( + _response_usage_to_usage(terminal_response.usage) + if terminal_response.usage + else Usage() + ), + stream_failed_retry_attempts[0], ) final_response = ModelResponse( output=terminal_response.output, diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 2ba360d2ef..0b2aca1146 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -3,6 +3,7 @@ import asyncio import json import logging +from collections.abc import AsyncIterator from typing import Any, cast import httpx @@ -40,12 +41,20 @@ handoff, retry_policies, ) -from agents.items import RunItem, ToolApprovalItem, TResponseInputItem +from agents.items import ( + ModelResponse, + RunItem, + ToolApprovalItem, + TResponseInputItem, + TResponseStreamEvent, +) from agents.memory.openai_conversations_session import OpenAIConversationsSession +from agents.models.interface import Model, ModelTracing from agents.run import RunConfig from agents.run_internal import run_loop from agents.run_internal.run_loop import QueueCompleteSentinel from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, StreamEvent +from agents.tool import Tool from agents.usage import Usage from .fake_model import FakeModel, get_response_obj @@ -385,6 +394,100 @@ async def test_streamed_run_preserves_request_usage_entries_after_retry() -> Non assert usage.request_usage_entries[1].total_tokens == 15 +class _RetryThenMissingUsageModel(Model): + """Stream a successful retry whose terminal Response omits usage data.""" + + def __init__(self) -> None: + self.calls = 0 + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: Any, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: Any | None, + ) -> ModelResponse: + self.calls += 1 + if self.calls == 1: + raise APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ) + return ModelResponse( + output=[get_text_message("done")], + usage=Usage(requests=1), + response_id="resp-missing-usage", + ) + + async def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: Any, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: Any | None, + ) -> AsyncIterator[TResponseStreamEvent]: + self.calls += 1 + if self.calls == 1: + raise APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ) + response = get_response_obj([get_text_message("done")]) + response.usage = None + yield ResponseCompletedEvent( + type="response.completed", + response=response, + sequence_number=0, + ) + + +@pytest.mark.asyncio +async def test_streamed_run_counts_retry_attempts_when_terminal_usage_missing() -> None: + """Retry accounting must survive successful streams that omit Response.usage. + + Non-OpenAI chat-completions adapters (e.g. LiteLLM) can complete a stream without a usage + chunk, leaving ``Response.usage`` as ``None``. Failed retry attempts must still be counted, + matching the non-streaming ``apply_retry_attempt_usage`` path. + """ + + model = _RetryThenMissingUsageModel() + agent = Agent( + name="test", + model=model, + model_settings=ModelSettings( + retry=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ) + ), + ) + + result = Runner.run_streamed(agent, input="test") + async for _ in result.stream_events(): + pass + + usage = result.context_wrapper.usage + assert model.calls == 2 + assert usage.requests == 2 + assert len(usage.request_usage_entries) == 2 + assert usage.request_usage_entries[0].total_tokens == 0 + assert usage.request_usage_entries[1].total_tokens == 0 + + @pytest.mark.asyncio async def test_streamed_model_retry_does_not_rewind_committed_session_input() -> None: model = FakeModel() From 3142f3ace3af8c42ce4329a2694cb80f27447d32 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 28 Jul 2026 22:53:57 -0500 Subject: [PATCH 049/473] fix(run): cancel streamed models when input guardrails fail (#4004) --- src/agents/run_internal/guardrails.py | 11 ++- tests/test_guardrails.py | 112 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/agents/run_internal/guardrails.py b/src/agents/run_internal/guardrails.py index e54f6aaf53..4a19eabf5f 100644 --- a/src/agents/run_internal/guardrails.py +++ b/src/agents/run_internal/guardrails.py @@ -16,6 +16,7 @@ from ..run_context import RunContextWrapper, TContext from ..tracing import Span, SpanError, guardrail_span from ..util import _error_tracing +from .run_steps import QueueCompleteSentinel __all__ = [ "run_single_input_guardrail", @@ -95,11 +96,19 @@ async def run_input_guardrails_with_queue( _error_tracing.attach_error_to_current_span(span_error) break queue.put_nowait(result) - except BaseException: + except BaseException as error: for t in guardrail_tasks: if not t.done(): t.cancel() await asyncio.gather(*guardrail_tasks, return_exceptions=True) + if ( + isinstance(error, Exception) + and asyncio.current_task() is streamed_result._input_guardrails_task + and not streamed_result.is_complete + ): + if streamed_result.run_loop_task and not streamed_result.run_loop_task.done(): + streamed_result.run_loop_task.cancel() + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) raise streamed_result.input_guardrail_results = ( diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index 08e57e67b1..49ed36faa5 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -26,6 +26,7 @@ from .fake_model import FakeModel from .test_responses import get_function_tool_call, get_text_message +from .testing_processor import fetch_events SHORT_DELAY = 0.01 MEDIUM_DELAY = 0.03 @@ -725,6 +726,117 @@ async def slow_get_response(*args, **kwargs): assert model_cancelled.is_set() is True +@pytest.mark.asyncio +async def test_parallel_guardrail_error_cancels_streaming_model(): + model_started = asyncio.Event() + model_cancelled = asyncio.Event() + model_finished = asyncio.Event() + + @input_guardrail(run_in_parallel=True) + async def raising_parallel_check( + ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + await asyncio.wait_for(model_started.wait(), timeout=1) + raise ValueError("guardrail boom") + + model = FakeModel() + + async def blocking_stream_response(*args, **kwargs): + model_started.set() + try: + await asyncio.Event().wait() + yield + except asyncio.CancelledError: + model_cancelled.set() + raise + finally: + model_finished.set() + + agent = Agent( + name="streaming_guardrail_error_agent", + input_guardrails=[raising_parallel_check], + model=model, + ) + + async def consume_stream() -> None: + async for _event in result.stream_events(): + pass + + with patch.object(model, "stream_response", side_effect=blocking_stream_response): + result = Runner.run_streamed(agent, "trigger guardrail") + with pytest.raises(ValueError, match="guardrail boom"): + await asyncio.wait_for(consume_stream(), timeout=1) + + await asyncio.wait_for(model_finished.wait(), timeout=1) + assert model_started.is_set() is True + assert model_cancelled.is_set() is True + + +@pytest.mark.asyncio +async def test_model_error_before_guardrail_error_preserves_stream_finalization(): + model_cleanup_started = asyncio.Event() + allow_model_cleanup = asyncio.Event() + model_cleanup_finished = asyncio.Event() + model_cleanup_cancelled = asyncio.Event() + raise_guardrail_error = asyncio.Event() + + @input_guardrail(run_in_parallel=True) + async def raising_parallel_check( + ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + await asyncio.wait_for(model_cleanup_started.wait(), timeout=1) + await asyncio.wait_for(raise_guardrail_error.wait(), timeout=1) + raise ValueError("guardrail boom") + + class BlockingCleanupFakeModel(FakeModel): + async def _cleanup_on_run_end(self, owner: object) -> None: + model_cleanup_started.set() + try: + await allow_model_cleanup.wait() + model_cleanup_finished.set() + except asyncio.CancelledError: + model_cleanup_cancelled.set() + raise + + model = BlockingCleanupFakeModel(tracing_enabled=True) + model.set_next_output(RuntimeError("model boom")) + + agent = Agent( + name="streaming_model_error_agent", + input_guardrails=[raising_parallel_check], + model=model, + ) + + result = Runner.run_streamed(agent, "trigger model error") + + await asyncio.wait_for(model_cleanup_started.wait(), timeout=1) + assert result.is_complete is True + raise_guardrail_error.set() + + guardrail_task = result._input_guardrails_task + assert guardrail_task is not None + + async def wait_until_guardrail_task_finishes() -> None: + while not guardrail_task.done(): + await asyncio.sleep(0) + + await asyncio.wait_for(wait_until_guardrail_task_finishes(), timeout=1) + allow_model_cleanup.set() + + with pytest.raises(ValueError, match="guardrail boom"): + async for _event in result.stream_events(): + pass + + assert model_cleanup_finished.is_set() is True + assert model_cleanup_cancelled.is_set() is False + assert result.run_loop_task is not None + assert result.run_loop_task.done() is True + assert result.run_loop_task.cancelled() is False + assert isinstance(result.run_loop_exception, RuntimeError) + assert str(result.run_loop_exception) == "model boom" + assert fetch_events()[-1] == "trace_end" + + @pytest.mark.asyncio async def test_parallel_guardrail_may_not_prevent_tool_execution_streaming(): tool_was_executed = False From 71aa44e4b9b900c0a3bf94800383d08a571e57e4 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Wed, 29 Jul 2026 00:02:37 -0500 Subject: [PATCH 050/473] Preserve zero Blobfuse attribute cache timeout (#4006) --- src/agents/sandbox/entries/mounts/patterns.py | 4 +++- tests/sandbox/test_mounts.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/entries/mounts/patterns.py b/src/agents/sandbox/entries/mounts/patterns.py index e9f6a3751a..c5335d6dc9 100644 --- a/src/agents/sandbox/entries/mounts/patterns.py +++ b/src/agents/sandbox/entries/mounts/patterns.py @@ -271,7 +271,9 @@ def to_text(self) -> str: ] ) - attr_cache_timeout = self.attr_cache_timeout_sec or 7200 + attr_cache_timeout = ( + self.attr_cache_timeout_sec if self.attr_cache_timeout_sec is not None else 7200 + ) lines.extend( [ "attr_cache:", diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index 28f597d272..d1375a41f8 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -1249,6 +1249,29 @@ async def test_blobfuse_generated_config_is_written_owner_only() -> None: ] +@pytest.mark.asyncio +async def test_blobfuse_generated_config_preserves_zero_attr_cache_timeout() -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + session = _GeneratedConfigApplySession(session_id=session_id) + pattern = FuseMountPattern(attr_cache_timeout_sec=0) + + await pattern.apply( + session, + Path("/workspace/mnt"), + FuseMountConfig( + account="acct", + container="container", + endpoint=None, + identity_client_id=None, + account_key="secret", + mount_type="azure_blob_mount", + read_only=True, + ), + ) + + assert b"attr_cache:\n timeout-sec: 0\n" in session.write_calls[0][1] + + @pytest.mark.asyncio async def test_blobfuse_cache_path_must_be_relative_to_workspace() -> None: with pytest.raises(MountConfigError) as exc_info: From 3f45d9e56fccf51747f7a3e891b4ec99a5a09aad Mon Sep 17 00:00:00 2001 From: Henry Su Date: Wed, 29 Jul 2026 00:10:11 -0500 Subject: [PATCH 051/473] fix: honor falsey input builders in agent-tools (#4007) --- src/agents/agent_tool_input.py | 6 ++-- tests/test_agent_as_tool.py | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/agents/agent_tool_input.py b/src/agents/agent_tool_input.py index 19a81e62e6..992752e9c9 100644 --- a/src/agents/agent_tool_input.py +++ b/src/agents/agent_tool_input.py @@ -83,11 +83,11 @@ async def resolve_agent_tool_input( input_builder: StructuredToolInputBuilder | None = None, ) -> str | list[TResponseInputItem]: """Resolve structured tool input into a string or list of input items.""" - should_build_structured_input = bool( - input_builder or (schema_info and (schema_info.summary or schema_info.json_schema)) + should_build_structured_input = input_builder is not None or bool( + schema_info and (schema_info.summary or schema_info.json_schema) ) if should_build_structured_input: - builder = input_builder or default_tool_input_builder + builder = input_builder if input_builder is not None else default_tool_input_builder result = builder( { "params": params, diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index 3872bbb8f6..c027191c18 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -1052,6 +1052,66 @@ async def fake_run( assert builder_calls[0]["json_schema"] is None +@pytest.mark.asyncio +async def test_agent_as_tool_supports_falsey_callable_input_builder( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class TranslationInput(BaseModel): + text: str + + custom_items = [{"role": "user", "content": "custom input"}] + + class FalseyInputBuilder: + def __bool__(self) -> bool: + return False + + def __call__(self, _options: StructuredToolInputBuilderOptions): + return custom_items + + agent = Agent(name="builder_agent") + tool = agent.as_tool( + tool_name="builder_tool", + tool_description="Builder tool", + parameters=TranslationInput, + input_builder=FalseyInputBuilder(), + ) + captured: dict[str, Any] = {} + + class DummyResult: + def __init__(self) -> None: + self.final_output = "ok" + + async def fake_run( + cls, + starting_agent, + input, + *, + context, + max_turns, + hooks, + run_config, + previous_response_id, + conversation_id, + session, + ): + captured["input"] = input + return DummyResult() + + monkeypatch.setattr(Runner, "run", classmethod(fake_run)) + + args = {"text": "hola"} + tool_context = ToolContext( + context=None, + tool_name="builder_tool", + tool_call_id="call_builder", + tool_arguments=json.dumps(args), + ) + + await tool.on_invoke_tool(tool_context, json.dumps(args)) + + assert captured["input"] == custom_items + + @pytest.mark.asyncio async def test_agent_as_tool_rejects_invalid_builder_output() -> None: """Invalid builder output should surface as a tool error.""" From e75cdd2e2c76f7930d894c6f46174cb091fc724f Mon Sep 17 00:00:00 2001 From: Henry Su Date: Wed, 29 Jul 2026 00:42:30 -0500 Subject: [PATCH 052/473] fix: cancel sibling enablement checks on failure (#4005) --- src/agents/agent.py | 3 +- src/agents/realtime/_tool_filtering.py | 4 +- src/agents/realtime/handoffs.py | 4 +- src/agents/realtime/openai_realtime.py | 3 +- src/agents/realtime/session.py | 7 +- src/agents/run_internal/tool_execution.py | 5 +- src/agents/run_internal/turn_preparation.py | 4 +- src/agents/util/_asyncio_tasks.py | 44 ++++++++ tests/realtime/test_realtime_handoffs.py | 37 +++++++ .../realtime/test_realtime_model_settings.py | 102 ++++++++++++++++++ tests/realtime/test_session.py | 74 +++++++++++++ tests/test_function_tool.py | 38 +++++++ tests/test_handoff_tool.py | 37 +++++++ tests/test_run_step_execution.py | 51 +++++++++ 14 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 src/agents/util/_asyncio_tasks.py diff --git a/src/agents/agent.py b/src/agents/agent.py index e5b61aaa71..c4899b2a8b 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -55,6 +55,7 @@ ) from .tool_context import ToolContext from .util import _transforms +from .util._asyncio_tasks import gather_with_cancel from .util._types import MaybeAwaitable if TYPE_CHECKING: @@ -259,7 +260,7 @@ async def _check_tool_enabled(tool: Tool) -> bool: return bool(await res) return bool(res) - results = await asyncio.gather(*(_check_tool_enabled(t) for t in self.tools)) + results = await gather_with_cancel(*(_check_tool_enabled(t) for t in self.tools)) enabled: list[Tool] = [t for t, ok in zip(self.tools, results, strict=False) if ok] all_tools: list[Tool] = prune_orphaned_tool_search_tools([*mcp_tools, *enabled]) _validate_codex_tool_name_collisions(all_tools) diff --git a/src/agents/realtime/_tool_filtering.py b/src/agents/realtime/_tool_filtering.py index ed1847d573..841d830b98 100644 --- a/src/agents/realtime/_tool_filtering.py +++ b/src/agents/realtime/_tool_filtering.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import inspect from collections.abc import Iterable from typing import Any @@ -8,6 +7,7 @@ from ..agent import AgentBase from ..run_context import RunContextWrapper from ..tool import FunctionTool, Tool +from ..util._asyncio_tasks import gather_with_cancel async def filter_enabled_tools( @@ -29,7 +29,7 @@ async def _check_tool_enabled(tool: Tool) -> bool: return bool(await result) return bool(result) - results = await asyncio.gather(*(_check_tool_enabled(tool) for tool in tools_list)) + results = await gather_with_cancel(*(_check_tool_enabled(tool) for tool in tools_list)) return [tool for tool, ok in zip(tools_list, results, strict=False) if ok] diff --git a/src/agents/realtime/handoffs.py b/src/agents/realtime/handoffs.py index 7cc150d631..a2026772ee 100644 --- a/src/agents/realtime/handoffs.py +++ b/src/agents/realtime/handoffs.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import inspect from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, Any, cast, overload @@ -14,6 +13,7 @@ from ..strict_schema import ensure_strict_json_schema from ..tracing.spans import SpanError from ..util import _error_tracing, _json +from ..util._asyncio_tasks import gather_with_cancel from ..util._types import MaybeAwaitable from . import RealtimeAgent @@ -44,7 +44,7 @@ async def _check_handoff_enabled(handoff_obj: Handoff[Any, Any]) -> bool: return await result return result - results = await asyncio.gather(*(_check_handoff_enabled(h) for h in handoffs_list)) + results = await gather_with_cancel(*(_check_handoff_enabled(h) for h in handoffs_list)) return [h for h, ok in zip(handoffs_list, results, strict=False) if ok] diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 141606246a..a8b50686db 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -95,6 +95,7 @@ ensure_function_tool_supports_responses_only_features, ensure_tool_choice_supports_backend, ) +from agents.util._asyncio_tasks import gather_with_cancel from agents.util._types import MaybeAwaitable from .. import _debug @@ -445,7 +446,7 @@ async def _build_model_settings_from_agent( if agent.prompt is not None: updated_settings["prompt"] = agent.prompt - instructions, tools, handoffs = await asyncio.gather( + instructions, tools, handoffs = await gather_with_cancel( agent.get_system_prompt(context_wrapper), agent.get_all_tools(context_wrapper), _collect_enabled_handoffs(agent, context_wrapper), diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 0a985c0387..c1f689e468 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -33,6 +33,7 @@ from ..tool_context import ToolContext from ..tool_guardrails import ToolInputGuardrailData from ..util._approvals import evaluate_needs_approval_setting, parse_function_tool_arguments +from ..util._asyncio_tasks import gather_with_cancel from ._tool_filtering import filter_enabled_tools from ._tool_validation import validate_realtime_tool_names from .agent import RealtimeAgent @@ -1576,7 +1577,7 @@ async def _resolve_dispatch_snapshot( ): return self._current_dispatch_snapshot - tools, handoffs = await asyncio.gather( + tools, handoffs = await gather_with_cancel( agent.get_all_tools(self._context_wrapper), self._get_handoffs(agent, self._context_wrapper), ) @@ -1586,7 +1587,7 @@ async def _filter_enabled_dispatch_snapshot( self, snapshot: _RealtimeDispatchSnapshot, ) -> _RealtimeDispatchSnapshot: - tools, handoffs = await asyncio.gather( + tools, handoffs = await gather_with_cancel( filter_enabled_tools(snapshot.tools, self._context_wrapper, snapshot.agent), filter_enabled_handoffs(snapshot.handoffs, self._context_wrapper, snapshot.agent), ) @@ -1607,7 +1608,7 @@ async def _get_updated_model_settings_from_agent( if agent.prompt is not None: updated_settings["prompt"] = agent.prompt - instructions, tools, handoffs = await asyncio.gather( + instructions, tools, handoffs = await gather_with_cancel( agent.get_system_prompt(self._context_wrapper), agent.get_all_tools(self._context_wrapper), self._get_handoffs(agent, self._context_wrapper), diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index c38bdfe4e5..4918083651 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -93,6 +93,7 @@ from ..tracing import Span, SpanError, function_span, get_current_trace from ..util import _coro, _error_tracing from ..util._approvals import evaluate_needs_approval_setting, parse_function_tool_arguments +from ..util._asyncio_tasks import gather_with_cancel from ..util._custom_data import maybe_extract_custom_data, merge_custom_data from ..util._tool_errors import get_trace_tool_error from ..util._types import MaybeAwaitable @@ -581,7 +582,9 @@ async def _check_tool_enabled(tool: FunctionTool) -> bool: if not function_tools: return [] - enabled_results = await asyncio.gather(*(_check_tool_enabled(tool) for tool in function_tools)) + enabled_results = await gather_with_cancel( + *(_check_tool_enabled(tool) for tool in function_tools) + ) return [tool for tool, enabled in zip(function_tools, enabled_results, strict=False) if enabled] diff --git a/src/agents/run_internal/turn_preparation.py b/src/agents/run_internal/turn_preparation.py index 53d11faa6c..da531afb6a 100644 --- a/src/agents/run_internal/turn_preparation.py +++ b/src/agents/run_internal/turn_preparation.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import inspect from typing import Any @@ -18,6 +17,7 @@ from ..tool import Tool from ..tracing import SpanError from ..util import _error_tracing +from ..util._asyncio_tasks import gather_with_cancel __all__ = [ "validate_run_hooks", @@ -111,7 +111,7 @@ async def check_handoff_enabled(handoff_obj: Handoff) -> bool: return bool(await res) return bool(res) - results = await asyncio.gather(*(check_handoff_enabled(h) for h in handoffs)) + results = await gather_with_cancel(*(check_handoff_enabled(h) for h in handoffs)) enabled: list[Handoff] = [h for h, ok in zip(handoffs, results, strict=False) if ok] return enabled diff --git a/src/agents/util/_asyncio_tasks.py b/src/agents/util/_asyncio_tasks.py new file mode 100644 index 0000000000..b134b3d24e --- /dev/null +++ b/src/agents/util/_asyncio_tasks.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable +from typing import Any, TypeVar, overload + +T = TypeVar("T") +T1 = TypeVar("T1") +T2 = TypeVar("T2") +T3 = TypeVar("T3") + + +@overload +async def gather_with_cancel( + awaitable_1: Awaitable[T1], + awaitable_2: Awaitable[T2], + /, +) -> tuple[T1, T2]: ... + + +@overload +async def gather_with_cancel( + awaitable_1: Awaitable[T1], + awaitable_2: Awaitable[T2], + awaitable_3: Awaitable[T3], + /, +) -> tuple[T1, T2, T3]: ... + + +@overload +async def gather_with_cancel(*awaitables: Awaitable[T]) -> tuple[T, ...]: ... + + +async def gather_with_cancel(*awaitables: Awaitable[Any]) -> tuple[Any, ...]: + """Gather awaitables, cancelling and draining siblings when one raises.""" + tasks = [asyncio.ensure_future(awaitable) for awaitable in awaitables] + try: + return tuple(await asyncio.gather(*tasks)) + except BaseException: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise diff --git a/tests/realtime/test_realtime_handoffs.py b/tests/realtime/test_realtime_handoffs.py index 4c5fc6e800..652f5de613 100644 --- a/tests/realtime/test_realtime_handoffs.py +++ b/tests/realtime/test_realtime_handoffs.py @@ -12,6 +12,7 @@ from agents import Agent from agents.exceptions import ModelBehaviorError, UserError from agents.realtime import RealtimeAgent, realtime_handoff +from agents.realtime.handoffs import collect_enabled_handoffs from agents.run_context import RunContextWrapper @@ -46,6 +47,42 @@ def test_realtime_handoff_with_custom_params(): assert handoff_obj.is_enabled is False +@pytest.mark.asyncio +async def test_collect_enabled_handoffs_cancels_sibling_checks_on_error() -> None: + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + slow_finished = asyncio.Event() + + async def slow_enabled(_ctx: RunContextWrapper[Any], _agent: RealtimeAgent[Any]) -> bool: + slow_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + slow_cancelled.set() + raise + finally: + slow_finished.set() + return True + + async def failing_enabled(_ctx: RunContextWrapper[Any], _agent: RealtimeAgent[Any]) -> bool: + await slow_started.wait() + raise RuntimeError("enablement failed") + + parent = RealtimeAgent( + name="parent", + handoffs=[ + realtime_handoff(RealtimeAgent(name="slow"), is_enabled=slow_enabled), + realtime_handoff(RealtimeAgent(name="failing"), is_enabled=failing_enabled), + ], + ) + + with pytest.raises(RuntimeError, match="enablement failed"): + await collect_enabled_handoffs(parent, RunContextWrapper(None)) + + assert slow_cancelled.is_set() + assert slow_finished.is_set() + + @pytest.mark.asyncio async def test_realtime_handoff_execution(): """Test that realtime handoff returns the correct agent.""" diff --git a/tests/realtime/test_realtime_model_settings.py b/tests/realtime/test_realtime_model_settings.py index b20c98311e..c009649abb 100644 --- a/tests/realtime/test_realtime_model_settings.py +++ b/tests/realtime/test_realtime_model_settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import Any, cast from unittest.mock import AsyncMock @@ -9,7 +10,9 @@ ) from openai.types.realtime.session_update_event import SessionUpdateEvent +from agents.agent import AgentBase from agents.handoffs import Handoff +from agents.realtime._tool_filtering import filter_enabled_tools from agents.realtime.agent import RealtimeAgent from agents.realtime.config import RealtimeRunConfig, RealtimeSessionModelSettings from agents.realtime.handoffs import realtime_handoff @@ -40,6 +43,52 @@ def _disabled_billing_realtime_tool(*, is_enabled: Any = False) -> FunctionTool: ) +def _agent_with_cross_group_enablement_failure() -> tuple[ + RealtimeAgent[Any], asyncio.Event, asyncio.Event +]: + handoff_started = asyncio.Event() + handoff_cancelled = asyncio.Event() + handoff_finished = asyncio.Event() + + async def failing_tool_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase[Any]) -> bool: + await handoff_started.wait() + raise RuntimeError("tool enablement failed") + + async def blocking_handoff_enabled( + _ctx: RunContextWrapper[Any], _agent: RealtimeAgent[Any] + ) -> bool: + handoff_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + handoff_cancelled.set() + raise + finally: + handoff_finished.set() + return True + + return ( + RealtimeAgent( + name="parent", + tools=[ + function_tool( + lambda: "failing", + name_override="failing_tool", + is_enabled=failing_tool_enabled, + ) + ], + handoffs=[ + realtime_handoff( + RealtimeAgent(name="blocking"), + is_enabled=blocking_handoff_enabled, + ) + ], + ), + handoff_cancelled, + handoff_finished, + ) + + @pytest.mark.asyncio async def test_collect_enabled_handoffs_filters_disabled() -> None: parent = RealtimeAgent(name="parent") @@ -56,6 +105,59 @@ async def test_collect_enabled_handoffs_filters_disabled() -> None: assert enabled[0].agent_name == "child_enabled" +@pytest.mark.asyncio +async def test_filter_enabled_tools_cancels_sibling_checks_on_error() -> None: + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + slow_finished = asyncio.Event() + + async def slow_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase[Any]) -> bool: + slow_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + slow_cancelled.set() + raise + finally: + slow_finished.set() + return True + + async def failing_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase[Any]) -> bool: + await slow_started.wait() + raise RuntimeError("enablement failed") + + slow_tool = function_tool(lambda: "slow", is_enabled=slow_enabled) + failing_tool = function_tool(lambda: "failing", is_enabled=failing_enabled) + agent = RealtimeAgent(name="parent") + + with pytest.raises(RuntimeError, match="enablement failed"): + await filter_enabled_tools( + [slow_tool, failing_tool], + RunContextWrapper(None), + agent, + ) + + assert slow_cancelled.is_set() + assert slow_finished.is_set() + + +@pytest.mark.asyncio +async def test_build_model_settings_cancels_cross_group_enablement_on_error() -> None: + agent, handoff_cancelled, handoff_finished = _agent_with_cross_group_enablement_failure() + + with pytest.raises(RuntimeError, match="tool enablement failed"): + await _build_model_settings_from_agent( + agent=agent, + context_wrapper=RunContextWrapper(None), + base_settings={}, + starting_settings=None, + run_config=None, + ) + + assert handoff_cancelled.is_set() + assert handoff_finished.is_set() + + @pytest.mark.asyncio async def test_build_model_settings_from_agent_merges_agent_fields(monkeypatch: pytest.MonkeyPatch): agent = RealtimeAgent(name="root", prompt={"id": "prompt-id"}) diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 8d69916505..b492d44035 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict import agents._debug as _debug +from agents.agent import AgentBase from agents.exceptions import ToolTimeoutError, UserError from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail from agents.handoffs import Handoff @@ -151,6 +152,47 @@ def _disabled_billing_tool(*, is_enabled: Any = False) -> FunctionTool: ) +def _agent_with_cross_group_enablement_failure() -> tuple[ + RealtimeAgent[Any], asyncio.Event, asyncio.Event +]: + handoff_started = asyncio.Event() + handoff_cancelled = asyncio.Event() + handoff_finished = asyncio.Event() + + async def failing_tool_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase[Any]) -> bool: + await handoff_started.wait() + raise RuntimeError("tool enablement failed") + + async def blocking_handoff_enabled( + _ctx: RunContextWrapper[Any], _agent: RealtimeAgent[Any] + ) -> bool: + handoff_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + handoff_cancelled.set() + raise + finally: + handoff_finished.set() + return True + + return ( + RealtimeAgent( + name="parent", + tools=[ + function_tool( + lambda: "failing", + name_override="failing_tool", + is_enabled=failing_tool_enabled, + ) + ], + handoffs=[_disabled_billing_handoff(is_enabled=blocking_handoff_enabled)], + ), + handoff_cancelled, + handoff_finished, + ) + + @pytest.mark.asyncio async def test_property_and_send_helpers_and_enter_alias(): model = _DummyModel() @@ -744,6 +786,38 @@ async def is_enabled(ctx, agent): assert len(enabled) == 2 +@pytest.mark.parametrize( + "boundary", + [ + "resolve_dispatch_snapshot", + "filter_enabled_dispatch_snapshot", + "get_updated_model_settings", + ], +) +@pytest.mark.asyncio +async def test_realtime_session_boundaries_cancel_cross_group_enablement_on_error( + boundary: str, +) -> None: + agent, handoff_cancelled, handoff_finished = _agent_with_cross_group_enablement_failure() + session = RealtimeSession(_DummyModel(), agent, None) + + with pytest.raises(RuntimeError, match="tool enablement failed"): + if boundary == "resolve_dispatch_snapshot": + await session._resolve_dispatch_snapshot(agent, None) + elif boundary == "filter_enabled_dispatch_snapshot": + settings = cast( + RealtimeSessionModelSettings, + {"tools": agent.tools, "handoffs": agent.handoffs}, + ) + snapshot = session._dispatch_snapshot_from_settings(agent, settings) + await session._filter_enabled_dispatch_snapshot(snapshot) + else: + await session._get_updated_model_settings_from_agent(None, agent) + + assert handoff_cancelled.is_set() + assert handoff_finished.is_set() + + @pytest.mark.asyncio async def test_updated_model_settings_ignores_disabled_handoff_name_conflict(): tool = function_tool(lambda: "ok", name_override="transfer_to_billing") diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index 496d808b17..08aae36584 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -498,6 +498,44 @@ async def third_tool_on_invoke_tool(ctx: RunContextWrapper[Any], args: str) -> s assert tools_with_ctx[1].name == "third_tool" +@pytest.mark.asyncio +async def test_get_all_tools_cancels_sibling_enablement_checks_on_error() -> None: + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + slow_finished = asyncio.Event() + + async def slow_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase) -> bool: + slow_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + slow_cancelled.set() + raise + finally: + slow_finished.set() + return True + + async def failing_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase) -> bool: + await slow_started.wait() + raise RuntimeError("enablement failed") + + @function_tool(is_enabled=slow_enabled) + def slow_tool() -> str: + return "slow" + + @function_tool(is_enabled=failing_enabled) + def failing_tool() -> str: + return "failing" + + agent = Agent(name="t", tools=[slow_tool, failing_tool]) + + with pytest.raises(RuntimeError, match="enablement failed"): + await agent.get_all_tools(RunContextWrapper(None)) + + assert slow_cancelled.is_set() + assert slow_finished.is_set() + + @pytest.mark.asyncio async def test_get_all_tools_preserves_explicit_tool_search_when_deferred_tools_are_disabled(): async def deferred_enabled(ctx: RunContextWrapper[BoolCtx], agent: AgentBase) -> bool: diff --git a/tests/test_handoff_tool.py b/tests/test_handoff_tool.py index a0ec6c9bfa..051c725c17 100644 --- a/tests/test_handoff_tool.py +++ b/tests/test_handoff_tool.py @@ -1,3 +1,4 @@ +import asyncio import inspect import json import logging @@ -470,6 +471,42 @@ async def test_handoff_is_enabled_filtering_integration(): assert "agent_2" not in agent_names +@pytest.mark.asyncio +async def test_get_handoffs_cancels_sibling_enablement_checks_on_error() -> None: + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + slow_finished = asyncio.Event() + + async def slow_enabled(_ctx: RunContextWrapper[Any], _agent: Agent[Any]) -> bool: + slow_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + slow_cancelled.set() + raise + finally: + slow_finished.set() + return True + + async def failing_enabled(_ctx: RunContextWrapper[Any], _agent: Agent[Any]) -> bool: + await slow_started.wait() + raise RuntimeError("enablement failed") + + parent = Agent( + name="parent", + handoffs=[ + handoff(Agent(name="slow"), is_enabled=slow_enabled), + handoff(Agent(name="failing"), is_enabled=failing_enabled), + ], + ) + + with pytest.raises(RuntimeError, match="enablement failed"): + await get_handoffs(parent, RunContextWrapper(None)) + + assert slow_cancelled.is_set() + assert slow_finished.is_set() + + @pytest.mark.asyncio async def test_handoff_is_enabled_sync_callable_false_filters_handoff(): target_agent = Agent(name="target") diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 555befc489..6f7cbf07e4 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -1527,6 +1527,57 @@ def record_side_effect() -> str: assert sibling_tool_invocations == 0 +@pytest.mark.asyncio +async def test_function_tool_enablement_error_cancels_sibling_checks_before_execution() -> None: + slow_check_count = 0 + failing_check_count = 0 + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + slow_finished = asyncio.Event() + + async def slow_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase[Any]) -> bool: + nonlocal slow_check_count + slow_check_count += 1 + if slow_check_count == 1: + return True + slow_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + slow_cancelled.set() + raise + finally: + slow_finished.set() + return True + + async def failing_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase[Any]) -> bool: + nonlocal failing_check_count + failing_check_count += 1 + if failing_check_count == 1: + return True + await slow_started.wait() + raise RuntimeError("enablement failed") + + slow_tool = function_tool(lambda: "slow", name_override="slow_tool", is_enabled=slow_enabled) + failing_tool = function_tool( + lambda: "failing", + name_override="failing_tool", + is_enabled=failing_enabled, + ) + agent = Agent(name="test", tools=[slow_tool, failing_tool]) + response = ModelResponse( + output=[get_function_tool_call("slow_tool", "{}", call_id="call-1")], + usage=Usage(), + response_id=None, + ) + + with pytest.raises(RuntimeError, match="enablement failed"): + await get_execute_result(agent, response) + + assert slow_cancelled.is_set() + assert slow_finished.is_set() + + @pytest.mark.asyncio async def test_execute_function_tool_calls_allows_non_agent_function_tool() -> None: @function_tool(name_override="synthetic_tool") From 1dddc0d1e2b596da711de39717b9b4b14bb2fd3a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 29 Jul 2026 15:58:53 +0900 Subject: [PATCH 053/473] feat(sandbox): support native host paths in path grants (#4009) --- .../extensions/sandbox/blaxel/sandbox.py | 3 +- .../extensions/sandbox/cloudflare/sandbox.py | 3 +- .../extensions/sandbox/daytona/sandbox.py | 3 +- src/agents/extensions/sandbox/e2b/sandbox.py | 3 +- .../extensions/sandbox/modal/sandbox.py | 3 +- .../extensions/sandbox/runloop/sandbox.py | 3 +- .../extensions/sandbox/vercel/sandbox.py | 3 +- src/agents/sandbox/capabilities/skills.py | 13 +- src/agents/sandbox/entries/artifacts.py | 4 +- src/agents/sandbox/runtime_session_manager.py | 61 +++- src/agents/sandbox/sandboxes/docker.py | 106 ++++++- src/agents/sandbox/sandboxes/unix_local.py | 21 +- .../sandbox/session/base_sandbox_session.py | 5 +- src/agents/sandbox/session/sandbox_client.py | 30 +- .../sandbox/session/sandbox_session_state.py | 104 ++++++- src/agents/sandbox/workspace_paths.py | 83 +++++- .../capabilities/test_shell_capability.py | 18 +- .../capabilities/test_skills_capability.py | 45 +++ tests/sandbox/test_docker.py | 259 ++++++++++++++++- tests/sandbox/test_entries.py | 29 ++ tests/sandbox/test_runtime.py | 264 +++++++++++++++++- tests/sandbox/test_session_state_roundtrip.py | 229 ++++++++++++++- tests/sandbox/test_unix_local.py | 33 +++ tests/sandbox/test_workspace_paths.py | 81 +++++- 24 files changed, 1362 insertions(+), 44 deletions(-) diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index 02d41a9712..5eb88ab1ee 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -1145,6 +1145,7 @@ async def resume( """ if not isinstance(state, BlaxelSandboxSessionState): raise TypeError("BlaxelSandboxClient.resume expects a BlaxelSandboxSessionState") + state.assert_path_grants_rebound() SandboxInstance = _import_blaxel_sdk() blaxel_sandbox = None @@ -1179,7 +1180,7 @@ async def resume( return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return BlaxelSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, BlaxelSandboxSessionState) # --------------------------------------------------------------------------- diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index ab492ff823..c8881fd8a9 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -1491,6 +1491,7 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: raise TypeError( "CloudflareSandboxClient.resume expects a CloudflareSandboxSessionState" ) + state.assert_path_grants_rebound() inner = CloudflareSandboxSession.from_state( state, exec_timeout_s=self._exec_timeout_s, @@ -1503,7 +1504,7 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return CloudflareSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, CloudflareSandboxSessionState) async def _request_sandbox_id( self, diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py index 988d5a2778..388685c61e 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -1326,6 +1326,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, DaytonaSandboxSessionState): raise TypeError("DaytonaSandboxClient.resume expects a DaytonaSandboxSessionState") + state.assert_path_grants_rebound() daytona_sandbox = None reconnected = False @@ -1357,7 +1358,7 @@ async def resume( return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return DaytonaSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, DaytonaSandboxSessionState) __all__ = [ diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py index ecbe8bc0bf..036f136657 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -1770,6 +1770,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, E2BSandboxSessionState): raise TypeError("E2BSandboxClient.resume expects an E2BSandboxSessionState") + state.assert_path_grants_rebound() sandbox_type = _coerce_sandbox_type(state.sandbox_type) SandboxClass = _import_sandbox_class(sandbox_type) @@ -1817,7 +1818,7 @@ async def resume( return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return E2BSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, E2BSandboxSessionState) __all__ = [ diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index b70f840119..b4ae929749 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -2171,6 +2171,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, ModalSandboxSessionState): raise TypeError("ModalSandboxClient.resume expects a ModalSandboxSessionState") + state.assert_path_grants_rebound() inner = ModalSandboxSession.from_state(state) reconnected = await inner._ensure_sandbox() if reconnected: @@ -2178,4 +2179,4 @@ async def resume( return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return ModalSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, ModalSandboxSessionState) diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py index c8d5a660b2..53a8bea05e 100644 --- a/src/agents/extensions/sandbox/runloop/sandbox.py +++ b/src/agents/extensions/sandbox/runloop/sandbox.py @@ -1673,6 +1673,7 @@ async def resume( """ if not isinstance(state, RunloopSandboxSessionState): raise TypeError("RunloopSandboxClient.resume expects a RunloopSandboxSessionState") + state.assert_path_grants_rebound() devbox = None reconnected = False @@ -1717,4 +1718,4 @@ async def resume( return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return RunloopSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, RunloopSandboxSessionState) diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 28f9bbafc8..4da5fb4164 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -1414,6 +1414,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: async def resume(self, state: SandboxSessionState) -> SandboxSession: if not isinstance(state, VercelSandboxSessionState): raise TypeError("VercelSandboxClient.resume expects a VercelSandboxSessionState") + state.assert_path_grants_rebound() if state.s3_mounts_non_resumable or _vercel_s3_mounts(state.manifest): raise MountConfigError( message=( @@ -1482,7 +1483,7 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return VercelSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, VercelSandboxSessionState) __all__ = [ diff --git a/src/agents/sandbox/capabilities/skills.py b/src/agents/sandbox/capabilities/skills.py index 01b5b203ad..dabfdb1cfb 100644 --- a/src/agents/sandbox/capabilities/skills.py +++ b/src/agents/sandbox/capabilities/skills.py @@ -509,7 +509,9 @@ class Skills(Capability): skills_path: str = Field(default=".agents") _skills_metadata: list[SkillMetadata] | None = PrivateAttr(default=None) - _skills_metadata_cache_key: tuple[tuple[str, bool], ...] | None = PrivateAttr(default=None) + _skills_metadata_cache_key: tuple[tuple[str, bool, str | None], ...] | None = PrivateAttr( + default=None + ) @field_validator("skills", mode="before") @classmethod @@ -762,10 +764,15 @@ async def _skill_metadata(self, manifest: Manifest) -> list[SkillMetadata]: self._skills_metadata_cache_key = cache_key return self._skills_metadata - def _metadata_cache_key(self, manifest: Manifest) -> tuple[tuple[str, bool], ...]: + def _metadata_cache_key( + self, + manifest: Manifest, + ) -> tuple[tuple[str, bool, str | None], ...]: if self.lazy_from is None: return () - return tuple((grant.path, grant.read_only) for grant in manifest.extra_path_grants) + return tuple( + (grant.path, grant.read_only, grant.host_path) for grant in manifest.extra_path_grants + ) async def instructions(self, manifest: Manifest) -> str | None: skills = await self._skill_metadata(manifest) diff --git a/src/agents/sandbox/entries/artifacts.py b/src/agents/sandbox/entries/artifacts.py index 7412126260..225a482bad 100644 --- a/src/agents/sandbox/entries/artifacts.py +++ b/src/agents/sandbox/entries/artifacts.py @@ -24,7 +24,7 @@ ) from ..materialization import MaterializedFile, gather_in_order from ..types import ExecResult, User -from ..workspace_paths import SandboxPathGrant +from ..workspace_paths import SandboxPathGrant, sandbox_path_grant_host_path from .base import BaseEntry if TYPE_CHECKING: @@ -276,7 +276,7 @@ def _matching_source_grant( source_grants: tuple[SandboxPathGrant, ...], ) -> SandboxPathGrant | None: for grant in source_grants: - grant_root = _absolute_without_symlink_resolution(Path(grant.path)) + grant_root = _absolute_without_symlink_resolution(sandbox_path_grant_host_path(grant)) try: src_input.relative_to(grant_root) return grant diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index 1d98f7337b..c3a4fda9ad 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -327,6 +327,10 @@ async def _create_resources( ) if resumed_payload is not None: explicit_state = client.deserialize_session_state(resumed_payload) + explicit_state = SandboxSessionState._mark_persisted_path_grants( + explicit_state, + payload=resumed_payload, + ) resume_from_run_state = True if explicit_state is not None: @@ -334,6 +338,7 @@ async def _create_resources( agent=agent, capabilities=capabilities, session_state=explicit_state, + trusted_manifest=self._resolve_trusted_resume_manifest(agent=agent), ) span_cm = ( custom_span( @@ -519,6 +524,14 @@ def _resolve_manifest( return sandbox_config.manifest return agent.default_manifest + def _resolve_trusted_resume_manifest( + self, + *, + agent: SandboxAgent[TContext], + ) -> Manifest | None: + sandbox_config = self._require_sandbox_config() + return sandbox_config.manifest or agent.default_manifest + @staticmethod def _process_manifest( capabilities: list[Capability], @@ -554,6 +567,11 @@ def _process_live_session_manifest( if processed_manifest is None or processed_manifest == current_manifest: return _LiveSessionManifestUpdate(processed_manifest=None, entries_to_apply=[]) + cls._validate_live_session_host_path_grants( + current_manifest=current_manifest, + processed_manifest=processed_manifest, + ) + entries_to_apply: list[tuple[Path, BaseEntry]] = [] if running: cls._validate_running_live_session_manifest_update( @@ -577,6 +595,34 @@ def _process_live_session_manifest( entries_to_apply=entries_to_apply, ) + @staticmethod + def _host_path_grant_topology( + manifest: Manifest, + ) -> tuple[tuple[str, bool, str | None], ...]: + mounted_targets = { + grant.path for grant in manifest.extra_path_grants if grant.host_path is not None + } + return tuple( + (grant.path, grant.read_only, grant.host_path) + for grant in manifest.extra_path_grants + if grant.path in mounted_targets + ) + + @classmethod + def _validate_live_session_host_path_grants( + cls, + *, + current_manifest: Manifest, + processed_manifest: Manifest, + ) -> None: + if cls._host_path_grant_topology(current_manifest) != cls._host_path_grant_topology( + processed_manifest + ): + raise ValueError( + "Injected sandbox sessions do not support capability changes to host-backed " + "`manifest.extra_path_grants`; use a fresh session or a session_state resume flow." + ) + @classmethod def _validate_running_live_session_manifest_update( cls, @@ -724,15 +770,26 @@ def _process_resumed_state_manifest( agent: SandboxAgent[TContext], capabilities: list[Capability], session_state: SandboxSessionState, + trusted_manifest: Manifest | None, ) -> SandboxSessionState: + resume_manifest = session_state.manifest + if session_state.path_grants_require_rebind and trusted_manifest is not None: + resume_manifest = resume_manifest.model_copy( + update={ + "extra_path_grants": tuple( + grant.model_copy() for grant in trusted_manifest.extra_path_grants + ) + }, + ) processed_manifest = cls._process_manifest( capabilities, - session_state.manifest, + resume_manifest, run_as_user=cls._agent_run_as_user(agent), ) if processed_manifest is None: return session_state - return session_state.model_copy(update={"manifest": processed_manifest}) + processed_state = session_state.model_copy(update={"manifest": processed_manifest}) + return processed_state.rebind_persisted_path_grants(processed_manifest) @staticmethod def _agent_run_as_user(agent: SandboxAgent[Any]) -> User | None: diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 4aec8bb6f8..ac7100399b 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -3,6 +3,7 @@ import hashlib import io import logging +import os import re import socket import tarfile @@ -74,6 +75,7 @@ coerce_posix_path, posix_path_as_path, posix_path_for_error, + sandbox_path_grant_host_path, sandbox_path_str, ) @@ -1468,6 +1470,7 @@ async def create( image = options.image session_id = uuid.uuid4() manifest = manifest or Manifest() + _validate_docker_path_grants(manifest) container = await self._create_container( image, @@ -1534,8 +1537,12 @@ async def resume( ) -> SandboxSession: if not isinstance(state, DockerSandboxSessionState): raise TypeError("DockerSandboxClient.resume expects a DockerSandboxSessionState") + state.assert_path_grants_rebound() + _validate_docker_path_grants(state.manifest) container = self.get_container(state.container_id) reused_existing_container = container is not None + if container is not None: + _assert_existing_container_path_grants_match(container, state.manifest) if container is None: container = await self._create_container( state.image, @@ -1557,7 +1564,7 @@ async def resume( return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return DockerSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, DockerSandboxSessionState) async def _create_container( self, @@ -1567,6 +1574,8 @@ async def _create_container( exposed_ports: tuple[int, ...] = (), session_id: uuid.UUID | None = None, ) -> Container: + if manifest is not None: + _validate_docker_path_grants(manifest) # create image if it does not exist if not self.image_exists(image): repo, tag = parse_repository_tag(image) @@ -1660,6 +1669,18 @@ def _build_docker_volume_mounts( ) -> list[DockerSDKMount]: mounts: list[DockerSDKMount] = [] + for grant in manifest.extra_path_grants: + if grant.host_path is None: + continue + mounts.append( + DockerSDKMount( + target=grant.path, + source=str(sandbox_path_grant_host_path(grant)), + type="bind", + read_only=grant.read_only, + ) + ) + for artifact, mount_path in _docker_volume_mounts_for_manifest(manifest): driver_config = artifact.mount_strategy.build_docker_volume_driver_config(artifact) assert driver_config is not None @@ -1677,6 +1698,89 @@ def _build_docker_volume_mounts( return mounts +def _validate_docker_path_grants(manifest: Manifest) -> None: + root = coerce_posix_path(manifest.root) + seen_targets: set[str] = set() + explicit_targets: set[str] = set() + volume_targets = { + coerce_posix_path(mount_path).as_posix() + for _artifact, mount_path in _docker_volume_mounts_for_manifest(manifest) + } + for grant in manifest.extra_path_grants: + target = coerce_posix_path(grant.path) + target_str = target.as_posix() + if target_str in seen_targets and ( + grant.host_path is not None or target_str in explicit_targets + ): + raise ValueError(f"duplicate Docker sandbox path grant target: {grant.path}") + seen_targets.add(target_str) + if grant.host_path is None: + continue + explicit_targets.add(target_str) + sandbox_path_grant_host_path(grant) + if target == root or root in target.parents or target in root.parents: + raise ValueError( + "Docker sandbox path grant host_path target must be outside " + f"the workspace root: {grant.path}" + ) + if target_str in volume_targets: + raise ValueError( + f"Docker sandbox path grant target conflicts with a manifest mount: {grant.path}" + ) + + +def _assert_existing_container_path_grants_match( + container: Container, + manifest: Manifest, +) -> None: + container.reload() + raw_mounts = container.attrs.get("Mounts") + mounts = raw_mounts if isinstance(raw_mounts, list) else [] + expected_grants = { + grant.path: grant for grant in manifest.extra_path_grants if grant.host_path is not None + } + actual_bind_mounts: dict[str, list[dict[object, object]]] = {} + for mount in mounts: + if not isinstance(mount, dict) or mount.get("Type") != "bind": + continue + destination = mount.get("Destination") + if not isinstance(destination, str): + raise ValueError( + "Existing Docker sandbox has a bind mount without a valid destination; " + "create a fresh sandbox session" + ) + actual_bind_mounts.setdefault(destination, []).append(mount) + + unexpected_targets = sorted(set(actual_bind_mounts) - set(expected_grants)) + if unexpected_targets: + raise ValueError( + "Existing Docker sandbox has bind mounts that are not present in the current " + f"trusted manifest: {', '.join(unexpected_targets)}; create a fresh sandbox session" + ) + + for grant in expected_grants.values(): + target_mounts = actual_bind_mounts.get(grant.path, []) + if len(target_mounts) != 1: + raise ValueError( + "Existing Docker sandbox path grant mount does not match the current trusted " + f"manifest for {grant.path!r}; create a fresh sandbox session" + ) + expected_source = os.path.normcase( + os.path.normpath(str(sandbox_path_grant_host_path(grant))) + ) + mount = target_mounts[0] + raw_source = mount.get("Source") + source = ( + os.path.normcase(os.path.normpath(raw_source)) if isinstance(raw_source, str) else None + ) + read_only = mount.get("RW") is False + if source != expected_source or read_only != grant.read_only: + raise ValueError( + "Existing Docker sandbox path grant mount does not match the current trusted " + f"manifest for {grant.path!r}; create a fresh sandbox session" + ) + + def _docker_volume_names_for_manifest( manifest: Manifest, *, diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index a2bfeac2ba..615987da5a 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -177,6 +177,7 @@ async def _apply_manifest( only_ephemeral: bool = False, provision_accounts: bool = True, ) -> MaterializationResult: + _assert_unix_local_host_path_grants_unsupported(self.state.manifest) if self.state.manifest.users or self.state.manifest.groups: raise ValueError( "UnixLocalSandboxSession does not support manifest users or groups because " @@ -1100,6 +1101,8 @@ async def create( options: UnixLocalSandboxClientOptions | None = None, ) -> SandboxSession: resolved_options = options or UnixLocalSandboxClientOptions() + if manifest is not None: + _assert_unix_local_host_path_grants_unsupported(manifest) # For local execution, runner-created sessions should always get an isolated temp root # unless the caller explicitly chose a custom host path. workspace_root_owned = False @@ -1159,8 +1162,24 @@ async def resume( ) -> SandboxSession: if not isinstance(state, UnixLocalSandboxSessionState): raise TypeError("UnixLocalSandboxClient.resume expects a UnixLocalSandboxSessionState") + state.assert_path_grants_rebound() + _assert_unix_local_host_path_grants_unsupported(state.manifest) inner = UnixLocalSandboxSession.from_state(state) return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: - return UnixLocalSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, UnixLocalSandboxSessionState) + + +def _assert_unix_local_host_path_grants_unsupported(manifest: Manifest) -> None: + grant = next( + (grant for grant in manifest.extra_path_grants if grant.host_path is not None), + None, + ) + if grant is None: + return + raise ValueError( + "UnixLocalSandboxClient does not support sandbox path grant host_path " + f"for {grant.path!r}; omit host_path when both paths are the same or use " + "DockerSandboxClient" + ) diff --git a/src/agents/sandbox/session/base_sandbox_session.py b/src/agents/sandbox/session/base_sandbox_session.py index ab22940734..e497c610b9 100644 --- a/src/agents/sandbox/session/base_sandbox_session.py +++ b/src/agents/sandbox/session/base_sandbox_session.py @@ -204,7 +204,7 @@ class BaseSandboxSession(abc.ABC): _runtime_helpers_installed: set[PurePath] | None = None _runtime_helper_cache_key: object = _RUNTIME_HELPER_CACHE_KEY_UNSET _workspace_path_policy_cache: ( - tuple[str, tuple[tuple[str, bool], ...], WorkspacePathPolicy] | None + tuple[str, tuple[tuple[str, bool, str | None], ...], WorkspacePathPolicy] | None ) = None # True when start() is reusing a backend whose workspace files may still be present. # This controls whether start() can avoid a full manifest apply for non-snapshot resumes. @@ -742,7 +742,8 @@ async def _ensure_runtime_helpers(self) -> None: def _workspace_path_policy(self) -> WorkspacePathPolicy: root = self.state.manifest.root grants_key = tuple( - (grant.path, grant.read_only) for grant in self.state.manifest.extra_path_grants + (grant.path, grant.read_only, grant.host_path) + for grant in self.state.manifest.extra_path_grants ) cached = self._workspace_path_policy_cache if cached is not None and cached[0] == root and cached[1] == grants_key: diff --git a/src/agents/sandbox/session/sandbox_client.py b/src/agents/sandbox/session/sandbox_client.py index 5a95dc24af..fe92fae55e 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -11,7 +11,10 @@ from .dependencies import Dependencies from .manager import Instrumentation from .sandbox_session import SandboxSession -from .sandbox_session_state import SandboxSessionState +from .sandbox_session_state import ( + REDACTED_HOST_PATH_GRANT_PATHS_KEY, + SandboxSessionState, +) SandboxClientOptionsClass = type["BaseSandboxClientOptions"] ClientOptionsT = TypeVar("ClientOptionsT") @@ -172,7 +175,30 @@ async def resume( def serialize_session_state(self, state: SandboxSessionState) -> dict[str, object]: """Serialize backend-specific sandbox state into a JSON-compatible payload.""" - return state.model_dump(mode="json") + redacted_paths = set(state.path_grants_require_rebind) + persistent_grants = [] + for grant in state.manifest.extra_path_grants: + if grant.host_path is not None: + redacted_paths.add(grant.path) + continue + persistent_grants.append(grant) + + persistent_manifest = state.manifest.model_copy( + update={"extra_path_grants": tuple(persistent_grants)}, + ) + persistent_state = state.model_copy(update={"manifest": persistent_manifest}) + payload = cast(dict[str, object], persistent_state.model_dump(mode="json")) + if redacted_paths: + payload[REDACTED_HOST_PATH_GRANT_PATHS_KEY] = sorted(redacted_paths) + return payload + + @staticmethod + def _deserialize_session_state_payload( + payload: dict[str, object], + state_class: type[SandboxSessionState], + ) -> SandboxSessionState: + state = state_class.model_validate(payload) + return SandboxSessionState._mark_persisted_path_grants(state, payload=payload) @abc.abstractmethod def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: diff --git a/src/agents/sandbox/session/sandbox_session_state.py b/src/agents/sandbox/session/sandbox_session_state.py index 80bffd2826..f5f38583e1 100644 --- a/src/agents/sandbox/session/sandbox_session_state.py +++ b/src/agents/sandbox/session/sandbox_session_state.py @@ -4,12 +4,21 @@ from collections.abc import Iterable from typing import Any, ClassVar, Literal, get_args, get_origin -from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny, field_validator, model_serializer +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + SerializeAsAny, + field_validator, + model_serializer, +) from ..manifest import Manifest from ..snapshot import SnapshotBase SessionStateClass = type["SandboxSessionState"] +REDACTED_HOST_PATH_GRANT_PATHS_KEY = "__openai_agents_redacted_host_path_grant_paths" class SandboxSessionState(BaseModel): @@ -24,6 +33,11 @@ class SandboxSessionState(BaseModel): workspace_root_ready: bool = False _subclass_registry: ClassVar[dict[str, SessionStateClass]] = {} + _path_grants_require_rebind: tuple[str, ...] = PrivateAttr(default=()) + + @property + def path_grants_require_rebind(self) -> tuple[str, ...]: + return self._path_grants_require_rebind @classmethod def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: @@ -70,10 +84,96 @@ def parse(cls, payload: object) -> SandboxSessionState: if subclass is None: raise ValueError(f"unknown sandbox session state type `{state_type}`") - return subclass.model_validate(payload) + return cls._mark_persisted_path_grants( + subclass.model_validate(payload), + payload=payload, + ) raise TypeError("session state payload must be a SandboxSessionState or dict") + @classmethod + def _mark_persisted_path_grants( + cls, + state: SandboxSessionState, + *, + payload: dict[str, object], + ) -> SandboxSessionState: + redacted_value = payload.get(REDACTED_HOST_PATH_GRANT_PATHS_KEY) + marker_paths = ( + tuple(path for path in redacted_value if isinstance(path, str)) + if isinstance(redacted_value, list | tuple) + else () + ) + serialized_host_path_grant_paths = tuple( + grant.path for grant in state.manifest.extra_path_grants if grant.host_path is not None + ) + persistent_grants = tuple( + grant for grant in state.manifest.extra_path_grants if grant.host_path is None + ) + sanitized_manifest = state.manifest.model_copy( + update={"extra_path_grants": persistent_grants}, + ) + marked = state.model_copy(update={"manifest": sanitized_manifest}) + marked._path_grants_require_rebind = tuple( + dict.fromkeys( + ( + *state.path_grants_require_rebind, + *marker_paths, + *serialized_host_path_grant_paths, + ) + ) + ) + return marked + + def rebind_persisted_path_grants( + self, + trusted_manifest: Manifest | None, + ) -> SandboxSessionState: + """Replace persisted path grants with grants from current trusted configuration.""" + + if not self.path_grants_require_rebind: + return self + if trusted_manifest is None: + raise ValueError( + "Sandbox session state contains path grants that require a current trusted " + "manifest before resume" + ) + + trusted_host_path_grant_paths = { + grant.path + for grant in trusted_manifest.extra_path_grants + if grant.host_path is not None + } + missing_host_paths = [ + path + for path in self.path_grants_require_rebind + if path not in trusted_host_path_grant_paths + ] + if missing_host_paths: + raise ValueError( + "Sandbox session state requires current trusted host_path values for these " + f"path grants: {', '.join(missing_host_paths)}" + ) + + rebound_manifest = self.manifest.model_copy( + update={ + "extra_path_grants": tuple( + grant.model_copy() for grant in trusted_manifest.extra_path_grants + ) + }, + ) + rebound = self.model_copy(update={"manifest": rebound_manifest}) + rebound._path_grants_require_rebind = () + return rebound + + def assert_path_grants_rebound(self) -> None: + if not self.path_grants_require_rebind: + return + raise ValueError( + "Sandbox session state path grants must be rebound from a current trusted manifest " + "before resume; resume through Runner with SandboxRunConfig.manifest" + ) + @model_serializer(mode="wrap") def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: data: dict[str, Any] = handler(self) diff --git a/src/agents/sandbox/workspace_paths.py b/src/agents/sandbox/workspace_paths.py index 048ed4aa9b..4cdc49fefc 100644 --- a/src/agents/sandbox/workspace_paths.py +++ b/src/agents/sandbox/workspace_paths.py @@ -1,10 +1,12 @@ from __future__ import annotations +import os import posixpath +import re from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath from typing import Literal, cast -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from .errors import InvalidManifestPathError, WorkspaceArchiveWriteError @@ -70,11 +72,16 @@ def _native_path_from_windows_absolute(path: PureWindowsPath) -> Path | None: class SandboxPathGrant(BaseModel): - """Extra absolute path access outside the sandbox workspace.""" + """Extra absolute path access outside the sandbox workspace. + + ``path`` is the POSIX path visible inside the sandbox. ``host_path`` is an optional + native host source used for local materialization and Docker bind mounts. + """ path: str read_only: bool = False description: str | None = None + host_path: str | None = Field(default=None, exclude_if=lambda value: value is None) @field_validator("path", mode="before") @classmethod @@ -100,7 +107,75 @@ def _validate_path(cls, value: str) -> str: _raise_if_filesystem_root(path) return path.as_posix() - raise ValueError("sandbox path grant path must be absolute") + raise ValueError("sandbox path grant path must be POSIX absolute") + + @field_validator("host_path", mode="before") + @classmethod + def _coerce_host_path(cls, value: object) -> str | None: + if value is None: + return None + if isinstance(value, PurePath): + return str(value) + if isinstance(value, str): + return value + raise ValueError("sandbox path grant host_path must be a string or Path") + + @field_validator("host_path") + @classmethod + def _validate_host_path(cls, value: str | None) -> str | None: + if value is None: + return None + if value.startswith(("\\\\", "//")): + raise ValueError("sandbox path grant host_path does not support UNC or device paths") + if any(part == ".." for part in re.split(r"[\\/]", value)): + raise ValueError("sandbox path grant host_path must not contain parent segments") + + windows_path = PureWindowsPath(value) + if windows_path.is_absolute(): + if not re.fullmatch(r"[A-Za-z]:", windows_path.drive): + raise ValueError( + "sandbox path grant host_path does not support UNC or device paths" + ) + _raise_if_filesystem_root(windows_path) + return str(windows_path) + + posix_path = PurePosixPath(posixpath.normpath(value)) + if posix_path.is_absolute(): + _raise_if_filesystem_root(posix_path) + return posix_path.as_posix() + + raise ValueError("sandbox path grant host_path must be an absolute host path") + + @model_validator(mode="after") + def _validate_split_path_grant(self) -> SandboxPathGrant: + if self.host_path is not None and windows_absolute_path(self.path) is not None: + raise ValueError( + "sandbox path grant path must be POSIX absolute when host_path is configured" + ) + return self + + +def sandbox_path_grant_host_path(grant: SandboxPathGrant) -> Path: + """Return and validate the native host path used by a sandbox path grant.""" + + raw_path = grant.host_path if grant.host_path is not None else grant.path + native_path = Path(raw_path) + if grant.host_path is not None and not native_path.is_absolute(): + raise ValueError( + f"sandbox path grant host_path must be absolute on the current host: {raw_path}" + ) + if ( + grant.host_path is not None + and os.name == "nt" + and not re.fullmatch(r"[A-Za-z]:", PureWindowsPath(raw_path).drive) + ): + raise ValueError( + f"sandbox path grant host_path must be drive-qualified on Windows: {raw_path}" + ) + _raise_if_filesystem_root(native_path) + resolved_path = native_path.resolve(strict=False) + _raise_if_filesystem_root(resolved_path, resolved=True) + return resolved_path class WorkspacePathPolicy: @@ -319,7 +394,7 @@ def _matching_grant( matches: list[tuple[SandboxPathGrant, PurePath]] = [] for grant in self._extra_path_grants: grant_root: PurePath = ( - Path(grant.path).resolve(strict=False) + sandbox_path_grant_host_path(grant).resolve(strict=False) if resolve_roots else coerce_posix_path(grant.path) ) diff --git a/tests/sandbox/capabilities/test_shell_capability.py b/tests/sandbox/capabilities/test_shell_capability.py index 533ff62f1f..2802c97345 100644 --- a/tests/sandbox/capabilities/test_shell_capability.py +++ b/tests/sandbox/capabilities/test_shell_capability.py @@ -512,7 +512,7 @@ async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell( ) @pytest.mark.asyncio - async def test_exec_command_tool_allows_extra_path_grant_workdir( + async def test_exec_command_tool_allows_split_path_grant_workdir( self, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -520,7 +520,13 @@ async def test_exec_command_tool_allows_extra_path_grant_workdir( session = _ShellSession( Manifest( root="/workspace", - extra_path_grants=(SandboxPathGrant(path="/tmp", read_only=True),), + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path="/native/shared-data", + read_only=True, + ), + ), ) ) capability.bind(session) @@ -536,20 +542,20 @@ async def test_exec_command_tool_allows_extra_path_grant_workdir( cast(ToolContext[object], None), ExecCommandArgs( cmd="pwd", - workdir="/tmp", + workdir="/mnt/shared-data", shell="/bin/bash", login=False, ).model_dump_json(), ) - assert session.exec_calls == [("cd /tmp && pwd", 10.0, ["/bin/bash", "-c"])] + assert session.exec_calls == [("cd /mnt/shared-data && pwd", 10.0, ["/bin/bash", "-c"])] assert ( output == "Chunk ID: 111111\n" "Wall time: 0.2500 seconds\n" "Process exited with code 7\n" "Output:\n" - "stdout: cd /tmp && pwd\n" - "stderr: cd /tmp && pwd" + "stdout: cd /mnt/shared-data && pwd\n" + "stderr: cd /mnt/shared-data && pwd" ) @pytest.mark.asyncio diff --git a/tests/sandbox/capabilities/test_skills_capability.py b/tests/sandbox/capabilities/test_skills_capability.py index 0d3ec69085..2eca35d219 100644 --- a/tests/sandbox/capabilities/test_skills_capability.py +++ b/tests/sandbox/capabilities/test_skills_capability.py @@ -776,3 +776,48 @@ async def test_lazy_metadata_cache_is_reset_on_bind(self, tmp_path: Path) -> Non "- cached-skill: old description (file: .agents/dynamic-skill)" in second_instructions ) assert "- cached-skill: new description (file: .agents/dynamic-skill)" in third_instructions + + @pytest.mark.asyncio + async def test_lazy_metadata_cache_is_invalidated_when_host_path_changes( + self, + tmp_path: Path, + ) -> None: + src_root = tmp_path / "skills" + skill_dir = src_root / "dynamic-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: cached-skill\ndescription: cached description\n---\n# Skill\n", + encoding="utf-8", + ) + other_root = tmp_path / "other-skills" + other_root.mkdir() + capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root))) + + first_instructions = await capability.instructions( + Manifest( + root="/workspace", + extra_path_grants=( + SandboxPathGrant( + path="/mnt/skills", + host_path=str(src_root), + ), + ), + ) + ) + second_instructions = await capability.instructions( + Manifest( + root="/workspace", + extra_path_grants=( + SandboxPathGrant( + path="/mnt/skills", + host_path=str(other_root), + ), + ), + ) + ) + + assert first_instructions is not None + assert ( + "- cached-skill: cached description (file: .agents/dynamic-skill)" in first_instructions + ) + assert second_instructions is None diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index 142044ddf2..005dbefd34 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -1404,19 +1404,21 @@ async def test_docker_normalize_path_preserves_safe_leaf_symlink_path(tmp_path: @pytest.mark.asyncio -async def test_docker_read_allows_extra_path_grant(tmp_path: Path) -> None: +async def test_docker_read_uses_sandbox_target_for_split_path_grant(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" extra_root = host_root / "tmp" + native_source = tmp_path / "native-source" workspace.mkdir(parents=True) extra_root.mkdir(parents=True) + native_source.mkdir() (extra_root / "result.txt").write_text("scratch output", encoding="utf-8") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", - extra_path_grants=(SandboxPathGrant(path="/tmp"),), + extra_path_grants=(SandboxPathGrant(path="/tmp", host_path=str(native_source)),), ), ) @@ -1736,6 +1738,141 @@ async def test_docker_create_container_publishes_exposed_ports( ] +@pytest.mark.asyncio +async def test_docker_create_container_mounts_explicit_host_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + host_path = tmp_path / "shared-data" + host_path.mkdir() + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(host_path), + read_only=True, + ), + ) + ) + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + created = await client._create_container( + DEFAULT_PYTHON_SANDBOX_IMAGE, + manifest=manifest, + ) + + assert created is container + mounts = cast(list[dict[str, object]], docker_client.containers.calls[0]["mounts"]) + assert mounts == [ + { + "Target": "/mnt/shared-data", + "Source": str(host_path), + "Type": "bind", + "ReadOnly": True, + } + ] + + +@pytest.mark.asyncio +async def test_docker_create_container_keeps_path_only_grant_unmounted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest(extra_path_grants=(SandboxPathGrant(path="/tmp", read_only=True),)) + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + await client._create_container( + DEFAULT_PYTHON_SANDBOX_IMAGE, + manifest=manifest, + ) + + assert "mounts" not in docker_client.containers.calls[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("explicit_first", [False, True]) +async def test_docker_rejects_duplicate_target_shared_by_split_and_path_only_grants( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + explicit_first: bool, +) -> None: + path_only = SandboxPathGrant(path="/mnt/shared-data") + explicit = SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + ) + grants = (explicit, path_only) if explicit_first else (path_only, explicit) + client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient())) + image_lookups = 0 + + def _image_exists(_image: str) -> bool: + nonlocal image_lookups + image_lookups += 1 + return True + + monkeypatch.setattr(client, "image_exists", _image_exists) + + with pytest.raises(ValueError, match="duplicate Docker sandbox path grant target"): + await client._create_container( + DEFAULT_PYTHON_SANDBOX_IMAGE, + manifest=Manifest(extra_path_grants=grants), + ) + + assert image_lookups == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("root", "target"), + [ + ("/workspace", "/workspace/shared-data"), + ("/workspace/project", "/workspace"), + ], + ids=["target-inside-workspace", "target-contains-workspace"], +) +async def test_docker_rejects_host_path_target_overlapping_workspace_before_image_lookup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + root: str, + target: str, +) -> None: + client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient())) + image_lookups = 0 + + def _image_exists(_image: str) -> bool: + nonlocal image_lookups + image_lookups += 1 + return True + + monkeypatch.setattr(client, "image_exists", _image_exists) + + with pytest.raises( + ValueError, + match="host_path target must be outside the workspace root", + ): + await client._create_container( + DEFAULT_PYTHON_SANDBOX_IMAGE, + manifest=Manifest( + root=root, + extra_path_grants=( + SandboxPathGrant( + path=target, + host_path=str(tmp_path), + ), + ), + ), + ) + + assert image_lookups == 0 + + @pytest.mark.asyncio async def test_docker_create_container_mounts_s3_with_volume_driver_ignoring_mount_pattern( monkeypatch: pytest.MonkeyPatch, @@ -2395,12 +2532,16 @@ def __init__( container_id: str = "container", workspace_exists: bool = False, published_ports: dict[str, list[dict[str, str]] | None] | None = None, + mounts: list[dict[str, object]] | None = None, ) -> None: self.status = status self.id = container_id self.exec_calls: list[dict[str, object]] = [] self._workspace_exists = workspace_exists - self.attrs = {"NetworkSettings": {"Ports": published_ports or {}}} + self.attrs = { + "NetworkSettings": {"Ports": published_ports or {}}, + "Mounts": mounts or [], + } def reload(self) -> None: return @@ -2842,6 +2983,118 @@ async def test_docker_resume_preserves_workspace_readiness_from_state() -> None: assert not_ready_session._inner.should_provision_manifest_accounts_on_resume() is False +@pytest.mark.asyncio +async def test_docker_resume_requires_existing_host_mount_to_match_trusted_state( + tmp_path: Path, +) -> None: + host_path = tmp_path / "shared-data" + host_path.mkdir() + manifest = Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(host_path), + read_only=True, + ), + ) + ) + matching_client = DockerSandboxClient( + docker_client=_ResumeDockerClient( + _ResumeContainer( + status="running", + mounts=[ + { + "Type": "bind", + "Source": str(host_path), + "Destination": "/mnt/shared-data", + "RW": False, + } + ], + ) + ) + ) + state = DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ) + + await matching_client.resume(state) + + mismatched_client = DockerSandboxClient( + docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) + ) + with pytest.raises(ValueError, match="does not match the current trusted manifest"): + await mismatched_client.resume(state) + + +@pytest.mark.asyncio +async def test_docker_resume_rejects_stale_bind_mount_for_path_only_grant( + tmp_path: Path, +) -> None: + host_path = tmp_path / "shared-data" + host_path.mkdir() + client = DockerSandboxClient( + docker_client=_ResumeDockerClient( + _ResumeContainer( + status="running", + mounts=[ + { + "Type": "bind", + "Source": str(host_path), + "Destination": "/mnt/shared-data", + "RW": True, + } + ], + ) + ) + ) + state = DockerSandboxSessionState( + manifest=Manifest( + extra_path_grants=(SandboxPathGrant(path="/mnt/shared-data"),), + ), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ) + + with pytest.raises(ValueError, match="not present in the current trusted manifest"): + await client.resume(state) + + +@pytest.mark.asyncio +async def test_docker_resume_rejects_bind_mount_when_persisted_grants_are_removed( + tmp_path: Path, +) -> None: + host_path = tmp_path / "shared-data" + host_path.mkdir() + client = DockerSandboxClient( + docker_client=_ResumeDockerClient( + _ResumeContainer( + status="running", + mounts=[ + { + "Type": "bind", + "Source": str(host_path), + "Destination": "/mnt/shared-data", + "RW": True, + } + ], + ) + ) + ) + state = DockerSandboxSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ) + + with pytest.raises(ValueError, match="not present in the current trusted manifest"): + await client.resume(state) + + @pytest.mark.asyncio async def test_docker_resume_resets_workspace_readiness_when_container_is_recreated( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/sandbox/test_entries.py b/tests/sandbox/test_entries.py index d00d008137..410bcdb0c7 100644 --- a/tests/sandbox/test_entries.py +++ b/tests/sandbox/test_entries.py @@ -281,6 +281,35 @@ async def test_local_file_allows_extra_path_granted_source_outside_base_dir( assert session.writes[Path("/workspace/copied.txt")] == b"secret" +@pytest.mark.asyncio +async def test_local_file_uses_host_path_for_source_grant(tmp_path: Path) -> None: + base = tmp_path / "base" + outside = tmp_path / "outside" + base.mkdir() + outside.mkdir() + (outside / "secret.txt").write_text("secret", encoding="utf-8") + session = _RecordingSession( + Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(outside), + read_only=True, + ), + ) + ), + ) + + result = await LocalFile(src=outside / "secret.txt").apply( + session, + Path("/workspace/copied.txt"), + base, + ) + + assert result[0].path == Path("/workspace/copied.txt") + assert session.writes[Path("/workspace/copied.txt")] == b"secret" + + @pytest.mark.asyncio async def test_local_file_rejects_source_outside_extra_path_grants(tmp_path: Path) -> None: base = tmp_path / "base" diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 0a76aed396..59a64ff643 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -732,6 +732,7 @@ class _ManifestMutationCapability(Capability): type: str = "manifest-mutation" rel_path: str content: bytes + process_calls: int def __init__(self, *, rel_path: str = "cap.txt", content: bytes = b"capability") -> None: super().__init__( @@ -741,11 +742,13 @@ def __init__(self, *, rel_path: str = "cap.txt", content: bytes = b"capability") { "rel_path": rel_path, "content": content, + "process_calls": 0, }, ), ) def process_manifest(self, manifest: Manifest) -> Manifest: + self.process_calls += 1 manifest.entries[self.rel_path] = File(content=self.content) return manifest @@ -761,6 +764,23 @@ def process_manifest(self, manifest: Manifest) -> Manifest: return manifest +class _ManifestPathGrantsCapability(Capability): + type: str = "manifest-path-grants" + grants: tuple[SandboxPathGrant, ...] + process_calls: int + + def __init__(self, grants: tuple[SandboxPathGrant, ...]) -> None: + super().__init__( + type="manifest-path-grants", + **cast(Any, {"grants": grants, "process_calls": 0}), + ) + + def process_manifest(self, manifest: Manifest) -> Manifest: + self.process_calls += 1 + manifest.extra_path_grants = self.grants + return manifest + + class _ProcessContextSessionCapability(Capability): type: str = "process-context-session" bound_session: BaseSandboxSession | None = None @@ -3189,7 +3209,12 @@ async def test_session_manager_reapplies_capability_manifest_mutations_on_resume ) -> None: client = _FakeClient(_FakeSession(Manifest())) capability = _ManifestMutationCapability() - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent( + name="worker", + model=FakeModel(), + instructions="Worker.", + default_manifest=Manifest(), + ) session_state = TestSessionState( manifest=Manifest(), snapshot=NoopSnapshot(id="resume"), @@ -3243,6 +3268,188 @@ async def test_session_manager_reapplies_capability_manifest_mutations_on_resume assert session.state.manifest.entries["cap.txt"] == File(content=b"capability") assert client.resume_state is not None assert client.resume_state.manifest.entries["cap.txt"] == File(content=b"capability") + assert capability.process_calls == 1 + + +@pytest.mark.asyncio +async def test_session_manager_rebinds_persisted_path_grants_from_current_manifest( + tmp_path: Path, +) -> None: + trusted_manifest = Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + read_only=True, + ), + ) + ) + client = _FakeClient(_FakeSession(Manifest())) + agent = SandboxAgent( + name="worker", + model=FakeModel(), + instructions="Worker.", + default_manifest=trusted_manifest, + ) + session_state = TestSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + serialized_state = client.serialize_session_state(session_state) + run_state = cast( + RunState[Any, Agent[Any]], + RunState( + context=RunContextWrapper(context={}), + original_input="hello", + starting_agent=agent, + ), + ) + run_state._current_agent = agent + run_state._sandbox = { + "backend_id": client.backend_id, + "current_agent_key": agent.name, + "current_agent_name": agent.name, + "session_state": serialized_state, + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": serialized_state, + } + }, + } + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}), + run_state=run_state, + ) + + manager.acquire_agent(agent) + await manager.ensure_session( + agent=agent, + capabilities=[], + is_resumed_state=True, + ) + + assert client.resume_state is not None + assert client.resume_state.manifest.extra_path_grants == trusted_manifest.extra_path_grants + assert client.resume_state.path_grants_require_rebind == () + + +@pytest.mark.asyncio +async def test_session_manager_rebinds_capability_host_path_grant_once( + tmp_path: Path, +) -> None: + host_grant = SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + read_only=True, + ) + capability = _ManifestPathGrantsCapability((host_grant,)) + client = _FakeClient(_FakeSession(Manifest())) + agent = SandboxAgent( + name="worker", + model=FakeModel(), + instructions="Worker.", + default_manifest=Manifest(), + ) + session_state = TestSessionState( + manifest=Manifest(extra_path_grants=(host_grant,)), + snapshot=NoopSnapshot(id="resume"), + ) + serialized_state = client.serialize_session_state(session_state) + run_state = cast( + RunState[Any, Agent[Any]], + RunState( + context=RunContextWrapper(context={}), + original_input="hello", + starting_agent=agent, + ), + ) + run_state._current_agent = agent + run_state._sandbox = { + "backend_id": client.backend_id, + "current_agent_key": agent.name, + "current_agent_name": agent.name, + "session_state": serialized_state, + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": serialized_state, + } + }, + } + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}), + run_state=run_state, + ) + + manager.acquire_agent(agent) + await manager.ensure_session( + agent=agent, + capabilities=[capability], + is_resumed_state=True, + ) + + assert capability.process_calls == 1 + assert client.resume_state is not None + assert client.resume_state.manifest.extra_path_grants == (host_grant,) + assert client.resume_state.path_grants_require_rebind == () + + +@pytest.mark.asyncio +async def test_session_manager_rejects_unmarked_serialized_host_path( + tmp_path: Path, +) -> None: + client = _FakeClient(_FakeSession(Manifest())) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + serialized_state = TestSessionState( + manifest=Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + ), + ) + ), + snapshot=NoopSnapshot(id="resume"), + ).model_dump(mode="json") + run_state = cast( + RunState[Any, Agent[Any]], + RunState( + context=RunContextWrapper(context={}), + original_input="hello", + starting_agent=agent, + ), + ) + run_state._current_agent = agent + run_state._sandbox = { + "backend_id": client.backend_id, + "current_agent_key": agent.name, + "current_agent_name": agent.name, + "session_state": serialized_state, + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": serialized_state, + } + }, + } + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}), + run_state=run_state, + ) + + manager.acquire_agent(agent) + with pytest.raises(ValueError, match="requires current trusted host_path"): + await manager.ensure_session( + agent=agent, + capabilities=[], + is_resumed_state=True, + ) + + assert client.resume_state is None @pytest.mark.asyncio @@ -3373,6 +3580,61 @@ async def test_session_manager_starts_stopped_injected_session_with_manifest_mut assert payload is None +@pytest.mark.asyncio +@pytest.mark.parametrize( + "processed_grants", + [ + ( + SandboxPathGrant( + path="/mnt/shared-data", + host_path="/native/new", + read_only=True, + ), + ), + ( + SandboxPathGrant(path="/mnt/shared-data"), + SandboxPathGrant( + path="/mnt/shared-data", + host_path="/native/old", + read_only=True, + ), + ), + ], + ids=["changed-host-source", "mixed-duplicate-target"], +) +async def test_session_manager_rejects_stopped_injected_session_host_mount_changes( + processed_grants: tuple[SandboxPathGrant, ...], +) -> None: + current_grants = ( + SandboxPathGrant( + path="/mnt/shared-data", + host_path="/native/old", + read_only=True, + ), + ) + live_session = _LiveSessionDeltaRecorder( + Manifest(extra_path_grants=current_grants), + ) + capability = _ManifestPathGrantsCapability(processed_grants) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=live_session), + run_state=None, + ) + + manager.acquire_agent(agent) + with pytest.raises(ValueError, match="host-backed `manifest.extra_path_grants`"): + await manager.ensure_session( + agent=agent, + capabilities=[capability], + is_resumed_state=False, + ) + + assert live_session.start_calls == 0 + assert live_session.state.manifest.extra_path_grants == current_grants + + @pytest.mark.asyncio async def test_session_manager_materializes_running_injected_session_manifest_mutation() -> None: live_session = _LiveSessionDeltaRecorder(Manifest()) diff --git a/tests/sandbox/test_session_state_roundtrip.py b/tests/sandbox/test_session_state_roundtrip.py index 39a8499f4d..2800a14c78 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -7,17 +7,23 @@ from __future__ import annotations +import io import json import uuid from pathlib import Path -from typing import ClassVar, Literal +from typing import ClassVar, Literal, cast import pytest -from pydantic import ValidationError +from pydantic import ConfigDict, ValidationError, field_serializer, field_validator -from agents.sandbox import Manifest -from agents.sandbox.session import SandboxSessionState -from agents.sandbox.snapshot import LocalSnapshot +from agents.sandbox import Manifest, SandboxPathGrant +from agents.sandbox.session import ( + BaseSandboxClient, + Dependencies, + SandboxSession, + SandboxSessionState, +) +from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot, SnapshotBase # --------------------------------------------------------------------------- # Test-only stubs @@ -45,6 +51,75 @@ class _SimpleSessionState(SandboxSessionState): type: Literal["simple-roundtrip"] = "simple-roundtrip" +class _RoundTripClient(BaseSandboxClient[None]): + backend_id = "roundtrip" + supports_default_options = True + + def __init__(self) -> None: + self.resume_state: SandboxSessionState | None = None + + async def create( + self, + *, + snapshot: object | None = None, + manifest: Manifest | None = None, + options: None = None, + ) -> SandboxSession: + _ = (snapshot, manifest, options) + raise AssertionError("create() is not used by round-trip tests") + + async def delete(self, session: SandboxSession) -> SandboxSession: + raise AssertionError("delete() is not used by round-trip tests") + + async def resume(self, state: SandboxSessionState) -> SandboxSession: + state.assert_path_grants_rebound() + self.resume_state = state + return cast(SandboxSession, object()) + + def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: + return self._deserialize_session_state_payload(payload, _SimpleSessionState) + + +class _NonCopyable: + def __deepcopy__(self, memo: dict[int, object]) -> object: + _ = memo + raise RuntimeError("not copyable") + + +class _SerializableNonCopyableSnapshot(SnapshotBase): + __test__ = False + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + type: Literal["serializable-noncopyable-roundtrip"] = "serializable-noncopyable-roundtrip" + token: _NonCopyable + + @field_serializer("token") + def _serialize_token(self, value: _NonCopyable) -> str: + _ = value + return "token" + + @field_validator("token", mode="before") + @classmethod + def _parse_token(cls, value: object) -> object: + return _NonCopyable() if value == "token" else value + + async def persist( + self, + data: io.IOBase, + *, + dependencies: Dependencies | None = None, + ) -> None: + _ = (data, dependencies) + + async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase: + _ = dependencies + raise FileNotFoundError(Path("")) + + async def restorable(self, *, dependencies: Dependencies | None = None) -> bool: + _ = dependencies + return False + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -195,3 +270,147 @@ def test_exposed_ports_reject_invalid_values(self, raw_ports: object, message: s custom_field="my-value", exposed_ports=raw_ports, # type: ignore[arg-type] ) + + def test_client_serialization_redacts_host_paths_and_rebinds_from_trusted_manifest( + self, + tmp_path: Path, + ) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + read_only=True, + ), + ) + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = client.serialize_session_state(state) + encoded = json.dumps(payload) + + assert str(tmp_path) not in encoded + assert payload["__openai_agents_redacted_host_path_grant_paths"] == ["/mnt/shared-data"] + manifest_payload = payload["manifest"] + assert isinstance(manifest_payload, dict) + assert manifest_payload["extra_path_grants"] == [] + restored = client.deserialize_session_state(payload) + assert restored.manifest.extra_path_grants == () + assert restored.path_grants_require_rebind == ("/mnt/shared-data",) + + rebound = restored.rebind_persisted_path_grants(trusted_manifest) + + assert rebound.manifest.extra_path_grants == trusted_manifest.extra_path_grants + assert rebound.path_grants_require_rebind == () + assert restored.manifest.extra_path_grants == () + + @pytest.mark.asyncio + async def test_path_only_grants_preserve_direct_client_resume_roundtrip(self) -> None: + client = _RoundTripClient() + manifest = Manifest( + extra_path_grants=( + SandboxPathGrant(path="/mnt/shared-data", read_only=True), + SandboxPathGrant(path="/mnt/shared-data", read_only=False), + ) + ) + state = _SimpleSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + restored = client.deserialize_session_state(client.serialize_session_state(state)) + await client.resume(restored) + + assert restored.path_grants_require_rebind == () + assert client.resume_state is not None + assert client.resume_state.manifest.extra_path_grants == manifest.extra_path_grants + + def test_client_state_roundtrip_does_not_deepcopy_extension_state(self) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + extra_path_grants=(SandboxPathGrant(path="/mnt/shared-data"),), + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=_SerializableNonCopyableSnapshot( + id="snapshot", + token=_NonCopyable(), + ), + ) + + payload = client.serialize_session_state(state) + + assert payload["snapshot"] == { + "type": "serializable-noncopyable-roundtrip", + "id": "snapshot", + "token": "token", + } + restored = client.deserialize_session_state(payload) + rebound = restored.rebind_persisted_path_grants(trusted_manifest) + snapshot = rebound.snapshot + assert isinstance(snapshot, _SerializableNonCopyableSnapshot) + assert isinstance(snapshot.token, _NonCopyable) + + @pytest.mark.asyncio + async def test_removed_redaction_marker_does_not_restore_host_backed_grant( + self, + tmp_path: Path, + ) -> None: + client = _RoundTripClient() + state = _SimpleSessionState( + manifest=Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + ), + ) + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = client.serialize_session_state(state) + payload.pop("__openai_agents_redacted_host_path_grant_paths", None) + + restored = client.deserialize_session_state(payload) + await client.resume(restored) + + assert restored.path_grants_require_rebind == () + assert client.resume_state is not None + assert client.resume_state.manifest.extra_path_grants == () + + @pytest.mark.asyncio + async def test_deserialization_discards_unmarked_serialized_host_path( + self, + tmp_path: Path, + ) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + ), + ) + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json")) + + restored = client.deserialize_session_state(payload) + + assert restored.manifest.extra_path_grants == () + assert restored.path_grants_require_rebind == ("/mnt/shared-data",) + with pytest.raises(ValueError, match="must be rebound"): + await client.resume(restored) + + rebound = restored.rebind_persisted_path_grants(trusted_manifest) + await client.resume(rebound) + + assert client.resume_state is rebound + assert rebound.manifest.extra_path_grants == trusted_manifest.extra_path_grants diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 2863ffe76e..c2fdf65e32 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -8,6 +8,7 @@ import pytest +from agents.sandbox import SandboxPathGrant from agents.sandbox.errors import PtySessionNotFoundError from agents.sandbox.manifest import Manifest from agents.sandbox.sandboxes.unix_local import ( @@ -40,6 +41,38 @@ async def _exec_internal( return ExecResult(stdout=b"", stderr=b"", exit_code=0) +@pytest.mark.asyncio +async def test_unix_local_rejects_host_path_before_creating_workspace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _unexpected_mkdtemp(*args: object, **kwargs: object) -> str: + raise AssertionError(f"unexpected mkdtemp call: {args!r} {kwargs!r}") + + monkeypatch.setattr( + "agents.sandbox.sandboxes.unix_local.tempfile.mkdtemp", + _unexpected_mkdtemp, + ) + client = UnixLocalSandboxClient() + + with pytest.raises( + ValueError, + match="UnixLocalSandboxClient does not support sandbox path grant host_path", + ): + await client.create( + manifest=Manifest( + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + ), + ) + ), + snapshot=None, + options=None, + ) + + class TestUnixLocalPty: @pytest.mark.asyncio async def test_tty_fd_close_is_owned_without_blocking_termination( diff --git a/tests/sandbox/test_workspace_paths.py b/tests/sandbox/test_workspace_paths.py index 934f04583d..e5d5cde093 100644 --- a/tests/sandbox/test_workspace_paths.py +++ b/tests/sandbox/test_workspace_paths.py @@ -15,6 +15,7 @@ WorkspacePathPolicy, coerce_posix_path, posix_path_as_path, + sandbox_path_grant_host_path, ) PathInput = str | PurePath @@ -403,7 +404,55 @@ def test_extra_path_grant_accepts_native_windows_drive_absolute_path( grant = SandboxPathGrant(path=str(tmp_path)) - assert Path(grant.path).is_absolute() + assert grant.path == str(tmp_path) + + +def test_split_path_grant_rejects_native_windows_sandbox_path(tmp_path: Path) -> None: + if not Path(PureWindowsPath("C:/tmp")).is_absolute(): + pytest.skip("Windows drive paths are not native absolute paths on this host") + + with pytest.raises( + ValidationError, + match="sandbox path grant path must be POSIX absolute when host_path is configured", + ): + SandboxPathGrant(path=str(tmp_path), host_path=str(tmp_path / "source")) + + +def test_extra_path_grant_normalizes_distinct_host_path() -> None: + grant = SandboxPathGrant( + path="/mnt/shared-data", + host_path="C:/Users/example/shared-data", + read_only=True, + ) + + assert grant.path == "/mnt/shared-data" + assert grant.host_path == "C:\\Users\\example\\shared-data" + assert grant.read_only is True + + +@pytest.mark.parametrize( + ("host_path", "message"), + [ + ("relative/path", "must be an absolute host path"), + ("/", "must not be filesystem root"), + ("/srv/../secret", "must not contain parent segments"), + ("//server/share", "does not support UNC or device paths"), + ("\\\\server\\share", "does not support UNC or device paths"), + ("C:\\", "must not be filesystem root"), + ], +) +def test_extra_path_grant_rejects_unsupported_host_paths( + host_path: str, + message: str, +) -> None: + with pytest.raises(ValidationError, match=message): + SandboxPathGrant(path="/mnt/shared-data", host_path=host_path) + + +def test_extra_path_grant_preserves_host_path_whitespace() -> None: + grant = SandboxPathGrant(path="/mnt/shared-data", host_path="/srv/shared ") + + assert grant.host_path == "/srv/shared " def test_extra_path_grant_rules_reject_windows_drive_absolute_path() -> None: @@ -536,9 +585,9 @@ def test_extra_path_grant_rejects_relative_path() -> None: assert error == { "type": "value_error", "loc": ("path",), - "msg": "Value error, sandbox path grant path must be absolute", + "msg": "Value error, sandbox path grant path must be POSIX absolute", "input": "tmp", - "ctx": {"error": "sandbox path grant path must be absolute"}, + "ctx": {"error": "sandbox path grant path must be POSIX absolute"}, } @@ -592,3 +641,29 @@ def test_host_io_rejects_extra_path_grant_symlink_to_root(tmp_path: Path) -> Non policy.normalize_path(root_alias / "etc" / "passwd", resolve_symlinks=True) assert str(exc_info.value) == "sandbox path grant path must not resolve to filesystem root" + + +def test_host_path_grant_rejects_symlink_to_root(tmp_path: Path) -> None: + root_alias = tmp_path / "root-alias" + os.symlink(Path("/"), root_alias, target_is_directory=True) + grant = SandboxPathGrant(path="/mnt/shared-data", host_path=str(root_alias)) + + with pytest.raises( + ValueError, + match="sandbox path grant path must not resolve to filesystem root", + ): + sandbox_path_grant_host_path(grant) + + +def test_host_path_grant_returns_validated_resolved_source(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + source_alias = tmp_path / "source-alias" + os.symlink(source, source_alias, target_is_directory=True) + grant = SandboxPathGrant(path="/mnt/shared-data", host_path=str(source_alias)) + + resolved_source = sandbox_path_grant_host_path(grant) + source_alias.unlink() + os.symlink(Path("/"), source_alias, target_is_directory=True) + + assert resolved_source == source.resolve() From ddc39d0e54c92dfda4700cc9c43d6e00b5041e17 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:42:45 +0900 Subject: [PATCH 054/473] Release 0.19.1 (#4010) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b41334ea89..36b81d71ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.19.0" +version = "0.19.1" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 7bc73f5b8a..41ad6efd8c 100644 --- a/uv.lock +++ b/uv.lock @@ -2437,7 +2437,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.19.0" +version = "0.19.1" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 42963a255950c22bc892b2e9fb8fbe50f9e665ac Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 30 Jul 2026 07:57:01 +0900 Subject: [PATCH 055/473] docs: updates for v0.19.1 release --- docs/sandbox/clients.md | 2 ++ docs/sandbox/guide.md | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index 60614261a8..45b90f04c4 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -33,6 +33,8 @@ For most users, start with one of these two sandbox clients: Unix-local is the easiest way to start developing against a local filesystem. Move to Docker or a hosted provider when you need stronger environment isolation or production-style parity. +`SandboxPathGrant.host_path` is Docker-only and maps a host path to a different POSIX path inside the container. Unix-local supports only same-path grants. See [Manifest path grants](guide.md#manifest) for details. + To switch from Unix-local to Docker, keep the agent definition the same and change only the run config: ```python diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md index cb9a356a72..2e6e84a5d7 100644 --- a/docs/sandbox/guide.md +++ b/docs/sandbox/guide.md @@ -250,6 +250,8 @@ manifest = Manifest( ) ``` +Set `host_path` when Docker should bind-mount a different absolute host path at the absolute POSIX `path` inside the container. `UnixLocalSandboxClient` supports only path-only grants, where both paths are the same, and rejects `host_path`. Use `read_only=True` for host data the sandbox should not modify, or use `LocalFile` or `LocalDir` when a copy is sufficient. + Treat manifests that contain `extra_path_grants` as trusted configuration. Do not load grants from model output or other untrusted payloads unless your application has already approved those host paths. Snapshots and `persist_workspace()` still include only the workspace root. Extra granted paths are runtime access, not durable workspace state. @@ -645,6 +647,8 @@ run_config = RunConfig( Use this when sandbox state lives in your own storage or job system and you want `Runner` to resume from it directly. See [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) for the serialize/deserialize flow. +Session-state serialization omits native `host_path` values. To resume host-backed grants, provide the current trusted manifest through `SandboxRunConfig.manifest` or `agent.default_manifest`; otherwise resume fails before the sandbox starts. Never derive host paths from serialized or other untrusted input. + ### Start from a snapshot Seed a new sandbox from saved files and artifacts: From 992abf763d24881bab55663de6a93cf58f1c6118 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 30 Jul 2026 08:12:09 +0900 Subject: [PATCH 056/473] docs: update translated pages --- docs/ja/sandbox/guide.md | 322 +++++++++++++++---------------- docs/ko/sandbox/guide.md | 352 +++++++++++++++++----------------- docs/zh/sandbox/clients.md | 84 +++++---- docs/zh/sandbox/guide.md | 374 +++++++++++++++++++------------------ 4 files changed, 573 insertions(+), 559 deletions(-) diff --git a/docs/ja/sandbox/guide.md b/docs/ja/sandbox/guide.md index 732005dd9a..242f74389c 100644 --- a/docs/ja/sandbox/guide.md +++ b/docs/ja/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "ベータ機能" - サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 + サンドボックスエージェントはベータ版です。一般提供の開始前に、API の詳細、デフォルト値、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果を発揮します。**サンドボックスエージェント**は、特化したツールやシェルコマンドを使用して、大規模なドキュメントセットの検索や操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために利用できる永続的なワークスペースをモデルに提供します。Agents SDK のサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。また、適切なファイルをファイルシステム上に配置し、サンドボックスをオーケストレーションすることで、大規模なタスクの開始、停止、再開を容易に行えます。 +最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果的に機能します。 **サンドボックスエージェント** は、専用ツールやシェルコマンドを使用して、大規模なドキュメントセットの検索と操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために使用できる永続的なワークスペースをモデルに提供します。Agents SDK のサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。適切なファイルをファイルシステム上に配置し、サンドボックスをオーケストレーションして、大規模なタスクを容易に開始、停止、再開できます。 -エージェントが必要とするデータを中心にワークスペースを定義します。GitHub リポジトリ、ローカルのファイルやディレクトリ、合成タスクファイル、S3 や Azure Blob Storage などのリモートファイルシステム、および提供するその他のサンドボックス入力から開始できます。 +エージェントに必要なデータを中心にワークスペースを定義します。GitHub リポジトリ、ローカルのファイルとディレクトリ、合成されたタスクファイル、S3 や Azure Blob Storage などのリモートファイルシステム、および指定したその他のサンドボックス入力から開始できます。
-![コンピューティング機能を備えたサンドボックスエージェントハーネス](../assets/images/harness_with_compute.png) +![コンピュート機能を備えたサンドボックスエージェントのハーネス](../assets/images/harness_with_compute.png)
-`SandboxAgent` も引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントインターフェースを維持し、通常の `Runner` API を通じて実行されます。異なるのは実行境界です。 +`SandboxAgent` も引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントのインターフェースを維持し、通常の `Runner` API を通じて実行されます。異なるのは実行境界です。 -- `SandboxAgent` はエージェント自体を定義します。通常のエージェント設定に加えて、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト、およびファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能を含みます。 -- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースの初期内容とレイアウトを宣言します。 -- サンドボックスセッションは、コマンドが実行され、ファイルが変更される稼働中の分離環境です。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションを直接注入する、シリアライズされたサンドボックスセッション状態から再接続する、サンドボックスクライアントを通じて新しいサンドボックスセッションを作成するなど、実行がサンドボックスセッションを取得する方法を決定します。 -- 保存されたサンドボックス状態とスナップショットにより、後続の実行は以前の作業へ再接続したり、保存された内容を使用して新しいサンドボックスセッションを初期化したりできます。 +- `SandboxAgent` はエージェント自体を定義します。これには、通常のエージェント設定に加え、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト値と、ファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能が含まれます。 +- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースで求められる初期コンテンツとレイアウトを宣言します。 +- サンドボックスセッションは、コマンドが実行され、ファイルが変更される、稼働中の分離環境です。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、実行でそのサンドボックスセッションを取得する方法を決定します。たとえば、直接注入する、シリアライズされたサンドボックスセッション状態から再接続する、サンドボックスクライアントを通じて新しいサンドボックスセッションを作成する、などです。 +- 保存されたサンドボックス状態とスナップショットにより、後続の実行で以前の作業に再接続したり、保存されたコンテンツを使用して新しいサンドボックスセッションを初期化したりできます。 -`Manifest` は新規セッション用のワークスペース契約であり、稼働中のすべてのサンドボックスに関する完全な信頼できる情報源ではありません。実行時に有効となるワークスペースは、再利用されたサンドボックスセッション、シリアライズされたサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合があります。 +`Manifest` は新規セッションのワークスペースに関する契約であり、稼働中のすべてのサンドボックスに対する完全な信頼できる情報源ではありません。実行で有効になるワークスペースは、再利用されたサンドボックスセッション、シリアライズされたサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合もあります。 -このページでは、「サンドボックスセッション」はサンドボックスクライアントによって管理される稼働中の実行環境を意味します。これは、[セッション](../sessions/index.md)で説明されている SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 +このページ全体で「サンドボックスセッション」とは、サンドボックスクライアントが管理する稼働中の実行環境を意味します。これは、[セッション](../sessions/index.md)で説明している SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 -外側のランタイムは、引き続き承認、トレーシング、ハンドオフ、再開時の記録管理を担います。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担います。この分担は、このモデルの中核をなす要素です。 +外側のランタイムは、引き続き承認、トレーシング、ハンドオフ、再開用の記録を管理します。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を管理します。この分担はモデルの中核部分です。 -### 各要素の連携 +### 各要素の関係 -サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備して稼働中のサンドボックスセッションにバインドし、後続の実行のために状態を保存できます。 +サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備し、稼働中のサンドボックスセッションにバインドし、後続の実行用に状態を保存できます。 ```mermaid flowchart LR @@ -50,175 +50,175 @@ flowchart LR sandbox --> saved ``` -サンドボックス固有のデフォルトは `SandboxAgent` に保持します。実行ごとのサンドボックスセッションの選択は `SandboxRunConfig` に保持します。 +サンドボックス固有のデフォルト値は `SandboxAgent` に保持します。実行ごとのサンドボックスセッションの選択は `SandboxRunConfig` に保持します。 -ライフサイクルは、次の 3 段階で考えます。 +ライフサイクルは、次の 3 つのフェーズで考えます。 -1. `SandboxAgent`、`Manifest`、機能を使用して、エージェントと新規ワークスペースの契約を定義します。 -2. サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を `Runner` に渡して実行します。 -3. ランナーが管理する `RunState`、明示的なサンドボックスの `session_state`、または保存済みワークスペースのスナップショットから、後で処理を継続します。 +1. `SandboxAgent`、`Manifest`、各種機能を使用して、エージェントと新規ワークスペースに関する契約を定義します。 +2. `Runner` に、サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を指定して、実行を開始します。 +3. ランナーが管理する `RunState`、明示的なサンドボックスの `session_state`、または保存されたワークスペーススナップショットから後で続行します。 -シェルアクセスがたまにしか使用しないツールの 1 つにすぎない場合は、[ツールガイド](../tools.md)のホステッドシェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 +シェルアクセスが時折使用するツールの 1 つにすぎない場合は、[ツールガイド](../tools.md)のホステッドシェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 -## 使用に適した状況 +## 使用に適したケース サンドボックスエージェントは、次のようなワークスペース中心のワークフローに適しています。 -- コーディングとデバッグ。たとえば、GitHub リポジトリの Issue レポートに対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 -- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォームの下書きを作成する場合 -- ファイルを根拠とするレビューや分析。たとえば、回答前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 -- 分離されたマルチエージェントパターン。たとえば、各レビュアーやコーディング用サブエージェントに専用のワークスペースを提供する場合 -- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正し、後で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 +- コーディングとデバッグ。たとえば、GitHub リポジトリの問題報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 +- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォーム案を作成する場合 +- ファイルに基づくレビューや分析。たとえば、回答する前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 +- 分離されたマルチエージェントパターン。たとえば、各レビュー担当エージェントやコーディングサブエージェントに専用のワークスペースを割り当てる場合 +- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正して後で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 -ファイルや稼働中のファイルシステムへのアクセスが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスがたまにしか使用しない機能の 1 つであればホステッドシェルを追加し、ワークスペース境界自体が機能の一部であればサンドボックスエージェントを使用します。 +ファイルや稼働状態を維持するファイルシステムへのアクセスが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスが時折使用する機能の 1 つにすぎない場合はホステッドシェルを追加し、ワークスペース境界自体が機能の一部である場合はサンドボックスエージェントを使用してください。 ## サンドボックスクライアントの選択 -macOS または Linux でのローカル開発には、`UnixLocalSandboxClient` から始めてください。Windows では、代わりに `DockerSandboxClient` またはホスト型プロバイダーを使用します。サポートされているどのプラットフォームでも、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に移行し、プロバイダー管理の実行が必要な場合はホスト型プロバイダーに移行してください。 +macOS または Linux でのローカル開発には、`UnixLocalSandboxClient` から始めてください。Windows では、代わりに `DockerSandboxClient` またはホステッドプロバイダーを使用してください。サポートされているどのプラットフォームでも、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に移行し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーに移行してください。 -ほとんどの場合、`SandboxAgent` の定義はそのまま維持し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 内のサンドボックスクライアントとそのオプションのみを変更します。ローカル、Docker、ホステッド、リモートマウントのオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +ほとんどの場合、`SandboxAgent` の定義は同じままで、サンドボックスクライアントとそのオプションのみを [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] で変更します。ローカル、Docker、ホステッド、リモートマウントの各オプションについては、[サンドボックスクライアント](clients.md)を参照してください。 ## 中核要素
-| レイヤー | 主な SDK 要素 | 回答する内容 | +| レイヤー | 主な SDK 要素 | 回答する問い | | --- | --- | --- | -| エージェント定義 | `SandboxAgent`、`Manifest`、機能 | どのエージェントを実行し、どの新規セッション用ワークスペース契約から開始するか? | +| エージェント定義 | `SandboxAgent`、`Manifest`、各種機能 | どのエージェントを実行し、どの新規セッション用ワークスペース契約から開始するか? | | サンドボックス実行 | `SandboxRunConfig`、サンドボックスクライアント、稼働中のサンドボックスセッション | この実行は稼働中のサンドボックスセッションをどのように取得し、作業はどこで実行されるか? | -| 保存済みサンドボックス状態 | `RunState` のサンドボックスペイロード、`session_state`、スナップショット | このワークフローは以前のサンドボックス作業へどのように再接続し、保存された内容から新しいサンドボックスセッションをどのように初期化するか? | +| 保存されたサンドボックス状態 | `RunState` のサンドボックスペイロード、`session_state`、スナップショット | このワークフローは、以前のサンドボックス作業にどのように再接続するか、または保存されたコンテンツから新しいサンドボックスセッションをどのように初期化するか? |
-主な SDK 要素は、次のようにこれらのレイヤーに対応します。 +主な SDK 要素は、これらのレイヤーに次のように対応します。
-| 要素 | 担当範囲 | 確認すべき内容 | +| 要素 | 管理対象 | 確認する問い | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を実行し、どのデフォルト設定を引き継ぐべきか? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッション用ワークスペースのファイルとフォルダー | 実行開始時に、どのファイルとフォルダーがファイルシステム上に存在すべきか? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、指示フラグメント、ランタイム動作をこのエージェントに付与すべきか? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションの取得元 | この実行では、サンドボックスセッションを注入、再開、作成のいずれで取得すべきか? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を行い、どのデフォルト値を引き継ぐべきか? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッション用ワークスペースのファイルとフォルダー | 実行開始時に、ファイルシステム上にどのファイルとフォルダーが存在すべきか? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、instructions の断片、またはランタイム動作をこのエージェントに関連付けるべきか? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションのソース | この実行では、サンドボックスセッションを注入、再開、作成のどれにするか? | | [`RunState`][agents.run_state.RunState] | ランナーが管理する保存済みサンドボックス状態 | 以前のランナー管理ワークフローを再開し、そのサンドボックス状態を自動的に引き継いでいるか? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部で既にシリアライズしたサンドボックス状態から再開するか? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション用に保存されたワークスペース内容 | 新しいサンドボックスセッションを保存済みのファイルや成果物から開始するか? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部ですでにシリアライズしたサンドボックス状態から再開するか? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション用に保存されたワークスペースコンテンツ | 新しいサンドボックスセッションを、保存されたファイルと成果物から開始するか? |
実用的な設計順序は次のとおりです。 -1. `Manifest` で新規セッション用ワークスペース契約を定義します。 -2. `SandboxAgent` でエージェントを定義します。 +1. `Manifest` を使用して、新規セッションのワークスペースに関する契約を定義します。 +2. `SandboxAgent` を使用してエージェントを定義します。 3. 組み込みまたはカスタムの機能を追加します。 4. `RunConfig(sandbox=SandboxRunConfig(...))` で、各実行がサンドボックスセッションを取得する方法を決定します。 -## サンドボックス実行の準備 +## サンドボックス実行の準備方法 実行時に、ランナーはその定義を具体的なサンドボックス対応の実行へ変換します。 -1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡した場合、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 -2. 実行で有効となるワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合、その既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは一時的なマニフェストオーバーライドまたは `agent.default_manifest` から開始します。このため、`Manifest` だけでは、すべての実行における最終的な稼働中ワークスペースは定義されません。 -3. 機能が生成されたマニフェストを処理できるようにします。これにより、最終的なエージェントの準備前に、機能がファイル、マウント、その他のワークスペース単位の動作を追加できます。 -4. 最終的な指示を固定された順序で構築します。SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、機能の指示フラグメント、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 -5. 機能ツールを稼働中のサンドボックスセッションにバインドし、準備済みのエージェントを通常の `Runner` API を通じて実行します。 +1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡した場合は、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 +2. 実行で有効になるワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは 1 回限りのマニフェストオーバーライドまたは `agent.default_manifest` から開始します。このため、`Manifest` だけでは、すべての実行における最終的な稼働中のワークスペースは定義されません。 +3. 各機能が、生成されたマニフェストを処理できるようにします。これにより、最終的なエージェントが準備される前に、各機能がファイル、マウント、その他のワークスペーススコープの動作を追加できます。 +4. 固定された順序で最終的な instructions を構築します。まず SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、機能の instructions 断片、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 +5. 機能のツールを稼働中のサンドボックスセッションにバインドし、準備されたエージェントを通常の `Runner` API を通じて実行します。 -サンドボックス化によって、ターンの意味は変わりません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックス操作ではありません。サンドボックス側の操作とターンの間に固定された 1 対 1 の対応関係はありません。一部の作業はサンドボックス実行レイヤー内に留まる場合がありますが、他の操作ではツールの結果、承認、または別のモデルステップを必要とするその他の状態が返されます。実用上は、サンドボックスで作業が行われた後、エージェントランタイムが別のモデル応答を必要とする場合にのみ、追加のターンが消費されます。 +サンドボックス化によってターンの意味が変わることはありません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックス操作ではありません。サンドボックス側の操作とターンの間には、固定された 1 対 1 の対応関係はありません。一部の作業はサンドボックス実行レイヤー内に留まる場合がありますが、他のアクションでは、別のモデルステップを必要とするツール結果、承認、その他の状態が返されます。実用上の原則として、サンドボックス作業の発生後にエージェントランタイムが別のモデル応答を必要とする場合にのみ、次のターンが消費されます。 -これらの準備手順があるため、`SandboxAgent` を設計する際には、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` が主なサンドボックス固有の検討事項になります。 +これらの準備手順があるため、`SandboxAgent` を設計する際には、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` が、検討すべき主なサンドボックス固有のオプションです。 ## `SandboxAgent` のオプション -通常の `Agent` フィールドに加えて、次のサンドボックス固有オプションがあります。 +通常の `Agent` フィールドに加えて、次のサンドボックス固有のオプションがあります。
| オプション | 最適な用途 | | --- | --- | -| `default_manifest` | ランナーが作成する新しいサンドボックスセッション用のデフォルトワークスペース。 | +| `default_manifest` | ランナーが作成する新しいサンドボックスセッションのデフォルトワークスペース。 | | `instructions` | SDK のサンドボックスプロンプトの後に追加される、ロール、ワークフロー、成功条件。 | | `base_instructions` | SDK のサンドボックスプロンプトを置き換える高度なエスケープハッチ。 | | `capabilities` | このエージェントとともに引き継ぐサンドボックスネイティブのツールと動作。 | -| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けサンドボックスツールで使用するユーザー ID。 | +| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けのサンドボックスツールに使用するユーザー ID。 |
-サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェントではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に設定します。 +サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェント上ではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に属します。 ### `default_manifest` -`default_manifest` は、ランナーがこのエージェント用に新しいサンドボックスセッションを作成するときに使用するデフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。エージェントが通常、開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 +`default_manifest` は、ランナーがこのエージェント用に新しいサンドボックスセッションを作成するときに使用されるデフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。エージェントが通常、作業開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 -これはデフォルトにすぎません。実行時に `SandboxRunConfig(manifest=...)` でオーバーライドでき、再利用または再開されたサンドボックスセッションは既存のワークスペース状態を維持します。 +これはデフォルトにすぎません。実行では `SandboxRunConfig(manifest=...)` を使用してオーバーライドでき、再利用または再開されたサンドボックスセッションでは既存のワークスペース状態が維持されます。 ### `instructions` と `base_instructions` -異なるプロンプト間でも維持すべき短いルールには、`instructions` を使用します。`SandboxAgent` では、これらの指示は SDK のサンドボックス基本プロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しながら、独自のロール、ワークフロー、成功条件を追加できます。 +異なるプロンプトでも維持すべき短いルールには `instructions` を使用します。`SandboxAgent` では、これらの instructions が SDK のサンドボックスベースプロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しながら、独自のロール、ワークフロー、成功条件を追加できます。 -SDK のサンドボックス基本プロンプトを置き換える場合にのみ、`base_instructions` を使用してください。ほとんどのエージェントでは設定しないでください。 +SDK のサンドボックスベースプロンプトを置き換える場合にのみ、`base_instructions` を使用してください。ほとんどのエージェントでは設定すべきではありません。
-| 設定先 | 用途 | 例 | +| 配置先 | 用途 | 例 | | --- | --- | --- | -| `instructions` | エージェントの安定したロール、ワークフロールール、成功条件。 | 「オンボーディング書類を確認してから、ハンドオフしてください」、「最終ファイルを `output/` に書き込んでください」。 | -| `base_instructions` | SDK のサンドボックス基本プロンプト全体の置き換え。 | カスタムの低レベルサンドボックスラッパープロンプト。 | -| ユーザープロンプト | この実行に固有のリクエスト。 | 「このワークスペースを要約してください」。 | -| マニフェスト内のワークスペースファイル | 長いタスク仕様、リポジトリローカルの指示、範囲を限定した参考資料。 | `repo/task.md`、ドキュメントバンドル、サンプル資料。 | +| `instructions` | エージェントの安定したロール、ワークフロールール、成功条件。 | 「オンボーディング文書を確認してから、ハンドオフしてください。」、「最終ファイルを `output/` に書き込んでください。」 | +| `base_instructions` | SDK のサンドボックスベースプロンプトの完全な置き換え。 | カスタムの低レベルサンドボックスラッパープロンプト。 | +| ユーザープロンプト | この実行に対する 1 回限りのリクエスト。 | 「このワークスペースを要約してください。」 | +| マニフェスト内のワークスペースファイル | 長いタスク仕様、リポジトリローカルの instructions、または範囲を限定した参照資料。 | `repo/task.md`、ドキュメントバンドル、サンプル資料一式。 |
`instructions` の適切な使用例は次のとおりです。 -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY の状態が重要な場合に、エージェントを単一の対話型プロセス内に維持します。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスレビュアーが確認後にユーザーへ直接回答することを禁止します。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的な記入済みファイルが実際に `output/` に配置されることを必須とします。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY の状態が重要な場合に、エージェントを 1 つの対話型プロセス内に維持します。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスのレビュー担当エージェントが確認後にユーザーへ直接回答することを禁止します。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的な記入済みファイルが実際に `output/` に配置されることを必須にします。 - [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) では、正確な検証コマンドを固定し、ワークスペースルート相対のパッチパスを明確にします。 -ユーザーの一時的なタスクを `instructions` にコピーすること、マニフェストに置くべき長い参考資料を埋め込むこと、組み込み機能が既に注入するツールドキュメントを繰り返すこと、モデルが実行時に必要としないローカルインストールの注意事項を混在させることは避けてください。 +ユーザーの 1 回限りのタスクを `instructions` にコピーすること、マニフェストに含めるべき長い参照資料を埋め込むこと、組み込み機能がすでに注入するツールドキュメントを再記述すること、モデルが実行時に必要としないローカルインストール手順を混在させることは避けてください。 -`instructions` を省略しても、SDK はデフォルトのサンドボックスプロンプトを含めます。低レベルのラッパーにはこれで十分ですが、ほとんどのユーザー向けエージェントでは、明示的な `instructions` も指定する必要があります。 +`instructions` を省略しても、SDK にはデフォルトのサンドボックスプロンプトが含まれます。これは低レベルのラッパーには十分ですが、ユーザー向けエージェントのほとんどでは、引き続き明示的な `instructions` を指定する必要があります。 ### `capabilities` -機能は、サンドボックスネイティブの動作を `SandboxAgent` に付与します。実行開始前にワークスペースを形成し、サンドボックス固有の指示を追加し、稼働中のサンドボックスセッションにバインドされるツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 +機能は、サンドボックスネイティブの動作を `SandboxAgent` に関連付けます。実行開始前にワークスペースを構成し、サンドボックス固有の instructions を追加し、稼働中のサンドボックスセッションにバインドするツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 組み込み機能には次のものがあります。
-| 機能 | 追加する状況 | 注記 | +| 機能 | 追加する場合 | 備考 | | --- | --- | --- | | `Shell` | エージェントにシェルアクセスが必要な場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 対話をサポートする場合は `write_stdin` も追加します。 | | `Filesystem` | エージェントがファイルを編集したり、ローカル画像を確認したりする必要がある場合。 | `apply_patch` と `view_image` を追加します。パッチパスはワークスペースルート相対です。 | -| `Skills` | サンドボックス内でスキルの検出とマテリアライズを行う場合。 | `.agents` または `.agents/skills` を手動でマウントするよりも、こちらを推奨します。`Skills` がスキルのインデックス作成とサンドボックスへのマテリアライズを行います。 | -| `Memory` | 後続の実行でメモリ成果物を読み取り、または生成する必要がある場合。 | `Shell` が必要です。ライブ更新には `Filesystem` も必要です。 | +| `Skills` | サンドボックス内でスキルの検出と実体化を行う場合。 | `.agents` または `.agents/skills` を手動でマウントするより、こちらを推奨します。`Skills` がスキルをインデックス化し、サンドボックス内に実体化します。 | +| `Memory` | 後続の実行でメモリ成果物を読み取る、または生成する必要がある場合。 | `Shell` が必要です。ライブ更新には `Filesystem` も必要です。 | | `Compaction` | 長時間実行されるフローで、コンパクション項目の後にコンテキストを削減する必要がある場合。 | モデルのサンプリングと入力処理を調整します。 |
-デフォルトでは、`SandboxAgent.capabilities` は `Capabilities.default()` を使用し、これには `Filesystem()`、`Shell()`、`Compaction()` が含まれます。`capabilities=[...]` を渡すと、そのリストがデフォルトを置き換えるため、引き続き必要なデフォルト機能を含めてください。 +デフォルトでは、`SandboxAgent.capabilities` は `Capabilities.default()` を使用し、これには `Filesystem()`、`Shell()`、`Compaction()` が含まれます。`capabilities=[...]` を渡すと、そのリストによってデフォルトが置き換えられるため、引き続き使用するデフォルト機能を含めてください。 -スキルについては、マテリアライズ方法に応じてソースを選択します。 +スキルでは、実体化の方法に応じてソースを選択します。 -- `Skills(lazy_from=LocalDirLazySkillSource(...))` は、モデルが最初にインデックスを検出し、必要なものだけを読み込めるため、大規模なローカルスキルディレクトリに適したデフォルトです。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージまたはワークスペース内にのみ存在するパスではなく、元のホスト側スキルディレクトリを渡してください。 +- `Skills(lazy_from=LocalDirLazySkillSource(...))` は、モデルが最初にインデックスを検出し、必要なものだけを読み込めるため、大規模なローカルスキルディレクトリの適切なデフォルトです。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージまたはワークスペース内にのみ存在するパスではなく、元のホスト側のスキルディレクトリを渡してください。 - `Skills(from_=LocalDir(src=...))` は、事前にステージングする小規模なローカルバンドルに適しています。 - `Skills(from_=GitRepo(repo=..., ref=...))` は、スキル自体をリポジトリから取得する場合に適しています。 -`LocalDir.src` は SDK ホスト上のソースパスです。`skills_path` はサンドボックスワークスペース内の相対的な配置先パスであり、`load_skill` の呼び出し時にスキルがステージングされます。 +`LocalDir.src` は SDK ホスト上のソースパスです。`skills_path` は、`load_skill` の呼び出し時にスキルがステージングされる、サンドボックスワークスペース内の相対的な宛先パスです。 -スキルが既に `.agents/skills//SKILL.md` のような場所に存在する場合は、そのソースルートを `LocalDir(...)` に指定し、引き続き `Skills(...)` を使用して公開してください。別のサンドボックス内レイアウトに依存する既存のワークスペース契約がない限り、デフォルトの `skills_path=".agents"` を維持してください。 +スキルがすでに `.agents/skills//SKILL.md` のような場所にディスク上で存在する場合は、`LocalDir(...)` でそのソースルートを指定し、引き続き `Skills(...)` を使用して公開してください。既存のワークスペース契約が別のサンドボックス内レイアウトに依存していない限り、デフォルトの `skills_path=".agents"` を維持してください。 -要件に適合する場合は、組み込み機能を優先してください。組み込み機能では対応できないサンドボックス固有のツールや指示インターフェースが必要な場合にのみ、カスタム機能を作成します。 +適合する場合は組み込み機能を優先してください。組み込み機能では対応できない、サンドボックス固有のツールまたは instructions のインターフェースが必要な場合にのみ、カスタム機能を作成してください。 ## 概念 ### マニフェスト -[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッション用のワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーやグループの定義、ワークスペース外にある特定の絶対パスへのアクセス許可を行えます。 +[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーまたはグループの定義、ワークスペース外の特定の絶対パスへのアクセス許可を行えます。 -マニフェストエントリのパスはワークスペース相対です。絶対パスにしたり、`..` を使用してワークスペース外へ移動したりすることはできません。これにより、ローカル、Docker、ホステッドクライアント間でワークスペース契約の移植性が保たれます。 +マニフェストエントリのパスはワークスペース相対です。絶対パスを指定したり、`..` を使用してワークスペース外へ移動したりすることはできません。これにより、ローカル、Docker、ホステッドの各クライアント間でワークスペース契約の移植性が維持されます。 作業開始前にエージェントが必要とする素材には、マニフェストエントリを使用します。 @@ -227,21 +227,21 @@ SDK のサンドボックス基本プロンプトを置き換える場合にの | マニフェストエントリ | 用途 | | --- | --- | | `File`、`Dir` | 小規模な合成入力、補助ファイル、出力ディレクトリ。 | -| `LocalFile`、`LocalDir` | サンドボックス内にマテリアライズするホストのファイルまたはディレクトリ。 | +| `LocalFile`、`LocalDir` | サンドボックス内に実体化するホストのファイルまたはディレクトリ。 | | `GitRepo` | ワークスペースに取得するリポジトリ。 | | `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` などのマウント | サンドボックス内に表示する外部ストレージ。 | -`Dir` は、合成の子要素からサンドボックスワークスペース内にディレクトリを作成するか、出力先を作成します。ホストのファイルシステムから読み取るものではありません。既存のホストディレクトリをサンドボックスワークスペースへコピーする場合は、`LocalDir` を使用します。 +`Dir` は、合成された子要素から、または出力先としてサンドボックスワークスペース内にディレクトリを作成します。ホストファイルシステムから読み取るものではありません。既存のホストディレクトリをサンドボックスワークスペースにコピーする場合は、`LocalDir` を使用してください。 -`LocalFile.src` と `LocalDir.src` は、デフォルトでは SDK プロセスの作業ディレクトリを基準に解決されます。ソースは、`extra_path_grants` の対象でない限り、その基準ディレクトリ内に置く必要があります。これにより、ローカルソースのマテリアライズは、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に保たれます。 +`LocalFile.src` と `LocalDir.src` は、デフォルトでは SDK プロセスの作業ディレクトリを基準に解決されます。ソースは、`extra_path_grants` の対象でない限り、そのベースディレクトリ内に留まる必要があります。これにより、ローカルソースの実体化が、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に維持されます。 マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。マウントオプションとプロバイダーのサポートについては、[サンドボックスクライアント](clients.md#mounts-and-remote-storage)を参照してください。 -適切なマニフェスト設計では通常、ワークスペース契約を必要最小限に保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、指示内では `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールでファイルを編集する場合、パッチパスはシェルの `workdir` ではなく、サンドボックスワークスペースのルートからの相対パスであることに注意してください。 +適切なマニフェスト設計では通常、ワークスペース契約を狭く保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、instructions では `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールでファイルを編集する場合、パッチパスはシェルの `workdir` ではなく、サンドボックスワークスペースのルートを基準とすることに注意してください。 -エージェントがワークスペース外の具体的な絶対パスを必要とする場合、またはマニフェストが SDK プロセスの作業ディレクトリ外にある信頼済みローカルソースをコピーする必要がある場合にのみ、`extra_path_grants` を使用してください。例としては、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内にマテリアライズする生成済みスキルディレクトリなどがあります。許可は、ローカルソースのマテリアライズ、SDK のファイル API、およびバックエンドがファイルシステムポリシーを適用できる場合のシェル実行に適用されます。 +エージェントがワークスペース外の具体的な絶対パスを必要とする場合、またはマニフェストが SDK プロセスの作業ディレクトリ外にある信頼済みのローカルソースをコピーする必要がある場合にのみ、`extra_path_grants` を使用してください。例として、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内に実体化する生成済みスキルディレクトリなどがあります。許可は、ローカルソースの実体化、SDK のファイル API、およびバックエンドがファイルシステムポリシーを適用できる場合のシェル実行に適用されます。 ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,15 +254,17 @@ manifest = Manifest( ) ``` -`extra_path_grants` を含むマニフェストは、信頼済み設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないペイロードから許可設定を読み込まないでください。 +Docker がコンテナ内の絶対 POSIX `path` に別の絶対ホストパスをバインドマウントする必要がある場合は、`host_path` を設定します。`UnixLocalSandboxClient` は、両方のパスが同一であるパスのみの許可に対応し、`host_path` を拒否します。サンドボックスによる変更を禁止するホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用してください。 + +`extra_path_grants` を含むマニフェストは、信頼済みの設定として扱ってください。アプリケーションが対象のホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスはランタイムアクセスであり、永続的なワークスペース状態ではありません。 ### 権限 -`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。対象となるのはサンドボックスがマテリアライズするファイルであり、モデルの権限、承認ポリシー、API 認証情報ではありません。 +`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これはサンドボックスが実体化するファイルに関するものであり、モデルの権限、承認ポリシー、API 認証情報に関するものではありません。 -デフォルトでは、マニフェストエントリは所有者が読み取り、書き込み、実行可能であり、グループおよびその他のユーザーは読み取り、実行可能です。ステージングされたファイルを非公開、読み取り専用、または実行可能にする必要がある場合は、これをオーバーライドします。 +デフォルトでは、マニフェストエントリは所有者による読み取り、書き込み、実行が可能で、グループとその他のユーザーによる読み取り、実行が可能です。ステージングされたファイルを非公開、読み取り専用、または実行可能にする場合は、これをオーバーライドします。 ```python from agents.sandbox import FileMode, Permissions @@ -278,9 +280,9 @@ private_notes = File( ) ``` -`Permissions` は、所有者、グループ、その他のユーザーごとに個別のビットを保存し、さらにエントリがディレクトリかどうかも保持します。直接構築するか、`Permissions.from_str(...)` でモード文字列から解析するか、`Permissions.from_mode(...)` で OS モードから生成できます。 +`Permissions` は、所有者、グループ、その他のユーザーごとに個別のビットを保持し、エントリがディレクトリであるかどうかも保持します。直接構築するか、`Permissions.from_str(...)` を使用してモード文字列から解析するか、`Permissions.from_mode(...)` を使用して OS モードから導出できます。 -ユーザーは、サンドボックス内で作業を実行できる ID です。その ID をサンドボックス内に存在させる場合は `User` をマニフェストに追加し、シェルコマンド、ファイル読み取り、パッチなどのモデル向けサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指している場合、ランナーがそのユーザーを有効なマニフェストに自動的に追加します。 +ユーザーは、サンドボックス内で作業を実行できる ID です。その ID をサンドボックス内に存在させる場合は、マニフェストに `User` を追加します。次に、シェルコマンド、ファイル読み取り、パッチなど、モデル向けのサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指している場合、ランナーが有効なマニフェストにそのユーザーを追加します。 ```python from agents import Runner @@ -332,13 +334,13 @@ result = await Runner.run( ) ``` -ファイル単位の共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` のユーザーはサンドボックスネイティブの操作を誰が実行するかを制御し、`Permissions` はサンドボックスがワークスペースをマテリアライズした後、そのユーザーがどのファイルを読み取り、書き込み、実行できるかを制御します。 +ファイルレベルの共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` ユーザーはサンドボックスネイティブのアクションを実行するユーザーを制御し、`Permissions` はサンドボックスがワークスペースを実体化した後、そのユーザーが読み取り、書き込み、実行できるファイルを制御します。 ### SnapshotSpec -`SnapshotSpec` は、新しいサンドボックスセッションが保存済みワークスペース内容を復元する場所、および内容を再び永続化する場所を指定します。これはサンドボックスワークスペースのスナップショットポリシーであり、`session_state` は特定のサンドボックスバックエンドを再開するためにシリアライズされた接続状態です。 +`SnapshotSpec` は、保存済みのワークスペースコンテンツをどこから新しいサンドボックスセッションに復元し、どこへ永続化するかを指定します。これはサンドボックスワークスペースのスナップショットポリシーです。一方、`session_state` は、特定のサンドボックスバックエンドを再開するためにシリアライズされた接続状態です。 -ローカルの永続スナップショットには `LocalSnapshotSpec` を使用し、アプリがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。高度な呼び出し元は、ワークスペーススナップショットの永続化が不要な場合に、これを明示的に使用できます。 +ローカルの永続スナップショットには `LocalSnapshotSpec` を使用し、アプリケーションがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。ワークスペーススナップショットの永続化を望まない高度な呼び出し元は、これを明示的に使用することもできます。 ```python from pathlib import Path @@ -355,13 +357,13 @@ run_config = RunConfig( ) ``` -ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントはそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットが復元可能であれば、実行を続行する前にサンドボックスが保存済みワークスペース内容を復元します。クリーンアップ時には、ランナー所有のサンドボックスセッションがワークスペースをアーカイブし、スナップショットを通じて再び永続化します。 +ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントはそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、実行が続行される前に、サンドボックスは保存済みのワークスペースコンテンツを復元します。クリーンアップ時には、ランナーが所有するサンドボックスセッションがワークスペースをアーカイブし、スナップショットを通じて再度永続化します。 -`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存先を使用しようとします。設定できない場合は、何もしないスナップショットへフォールバックします。マウントされたパスと一時パスは、永続的なワークスペース内容としてスナップショットにコピーされません。 +`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存場所を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時的なパスは、永続的なワークスペースコンテンツとしてスナップショットにコピーされません。 ### サンドボックスのライフサイクル -ライフサイクルには、**SDK 所有**と**開発者所有**の 2 つのモードがあります。 +ライフサイクルには、 **SDK 所有** と **開発者所有** の 2 つのモードがあります。
@@ -389,7 +391,7 @@ sequenceDiagram
-サンドボックスが 1 回の実行中のみ存在すればよい場合は、SDK 所有のライフサイクルを使用します。`client`、任意の `manifest`、任意の `snapshot`、クライアントの `options` を渡します。ランナーがサンドボックスを作成または再開して起動し、エージェントを実行し、スナップショット対応のワークスペース状態を永続化し、サンドボックスを停止して、ランナー所有リソースをクライアントにクリーンアップさせます。 +サンドボックスが 1 回の実行中だけ存続すればよい場合は、SDK 所有のライフサイクルを使用します。`client`、任意の `manifest`、任意の `snapshot`、クライアントの `options` を渡します。ランナーはサンドボックスを作成または再開し、起動してエージェントを実行し、スナップショットに基づくワークスペース状態を永続化し、サンドボックスをシャットダウンして、ランナーが所有するリソースをクライアントにクリーンアップさせます。 ```python result = await Runner.run( @@ -401,7 +403,7 @@ result = await Runner.run( ) ``` -サンドボックスを事前に作成する場合、稼働中の 1 つのサンドボックスを複数回の実行で再利用する場合、実行後にファイルを確認する場合、自分で作成したサンドボックスでストリーミングする場合、またはクリーンアップのタイミングを厳密に決定する場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中のサンドボックスを使用しますが、自動的には閉じません。 +サンドボックスを事前に作成する場合、稼働中の 1 つのサンドボックスを複数の実行で再利用する場合、実行後にファイルを確認する場合、自分で作成したサンドボックス上でストリーミングする場合、またはクリーンアップのタイミングを厳密に決める場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中のサンドボックスを使用しますが、自動的には閉じません。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -412,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 +通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリケーションでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 ```python sandbox = await client.create( @@ -433,22 +435,22 @@ finally: await sandbox.aclose() ``` -`stop()` はスナップショット対応のワークスペース内容を永続化するだけで、サンドボックスを破棄しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースを停止して、セッション単位の依存関係を閉じます。 +`stop()` は、スナップショットに基づくワークスペースコンテンツを永続化するだけで、サンドボックスを破棄しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースをシャットダウンして、セッションスコープの依存関係を閉じます。 ## `SandboxRunConfig` のオプション [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションの取得元と、新しいセッションの初期化方法を決定する実行ごとのオプションを保持します。 -### サンドボックスの取得元 +### サンドボックスのソース -次のオプションは、ランナーがサンドボックスセッションを再利用、再開、作成のいずれで取得するかを決定します。 +次のオプションは、ランナーがサンドボックスセッションを再利用、再開、作成のいずれにするかを決定します。
-| オプション | 使用する状況 | 注記 | +| オプション | 使用する場合 | 備考 | | --- | --- | --- | | `client` | ランナーにサンドボックスセッションの作成、再開、クリーンアップを任せる場合。 | 稼働中のサンドボックス `session` を指定しない限り必須です。 | -| `session` | 稼働中のサンドボックスセッションを既に自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中のサンドボックスセッションを再利用します。 | +| `session` | 稼働中のサンドボックスセッションをすでに自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中のサンドボックスセッションを再利用します。 | | `session_state` | シリアライズされたサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。ランナーは、その明示的な状態から所有セッションとして再開します。 |
@@ -456,41 +458,41 @@ finally: 実際には、ランナーは次の順序でサンドボックスセッションを解決します。 1. `run_config.sandbox.session` を注入した場合、その稼働中のサンドボックスセッションを直接再利用します。 -2. それ以外で、実行を `RunState` から再開する場合は、保存済みのサンドボックスセッション状態を再開します。 +2. それ以外で、実行が `RunState` から再開される場合は、保存されたサンドボックスセッション状態を再開します。 3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的にシリアライズされたサンドボックスセッション状態から再開します。 4. それ以外の場合、ランナーは新しいサンドボックスセッションを作成します。その新しいセッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 ### 新規セッションの入力 -次のオプションは、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 +次のオプションは、ランナーが新しいサンドボックスセッションを作成する場合にのみ関係します。
-| オプション | 使用する状況 | 注記 | +| オプション | 使用する場合 | 備考 | | --- | --- | --- | -| `manifest` | 新規セッションのワークスペースを一時的にオーバーライドする場合。 | 省略時は `agent.default_manifest` にフォールバックします。 | -| `snapshot` | 新しいサンドボックスセッションをスナップショットから初期化する場合。 | 再開に似たフローやリモートスナップショットクライアントに役立ちます。 | -| `options` | サンドボックスクライアントが作成時オプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウト、同様のクライアント固有設定でよく使用します。 | +| `manifest` | 新規セッション用ワークスペースを 1 回限りでオーバーライドする場合。 | 省略すると `agent.default_manifest` にフォールバックします。 | +| `snapshot` | 新しいサンドボックスセッションをスナップショットから初期化する場合。 | 再開に似たフローやリモートスナップショットクライアントに便利です。 | +| `options` | サンドボックスクライアントが作成時のオプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウト、および同様のクライアント固有設定でよく使用します。 |
-### マテリアライズの制御 +### 実体化の制御 -`concurrency_limits` は、サンドボックスのマテリアライズ処理を並列実行できる量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。いずれかの値を `None` に設定すると、その制限のみが無効になります。 +`concurrency_limits` は、並列実行できるサンドボックス実体化作業の量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。特定の制限を無効にするには、対応する値を `None` に設定します。 -`archive_limits` は、アーカイブ展開に対する SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定します。アーカイブに対してより厳密なリソース制御が必要な場合は、`SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` などの明示的な値を渡します。SDK のアーカイブリソース制限がないデフォルト動作を維持するには `archive_limits=None` のままにし、特定の制限だけを無効にするには個別のフィールドを `None` に設定します。 +`archive_limits` は、アーカイブ抽出に対する SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定し、アーカイブでより厳密なリソース制御が必要な場合は、`SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` のように明示的な値を渡します。SDK のアーカイブリソース制限を設けないデフォルト動作を維持するには `archive_limits=None` のままにし、個別の制限だけを無効にするには対応するフィールドを `None` に設定します。 次の点に注意してください。 -- 新規セッション: `manifest=` と `snapshot=` は、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 -- 再開とスナップショット: `session_state=` は以前にシリアライズされたサンドボックス状態へ再接続しますが、`snapshot=` は保存済みワークスペース内容から新しいサンドボックスセッションを初期化します。 -- クライアント固有オプション: `options=` はサンドボックスクライアントによって異なります。Docker および多くのホステッドクライアントでは必須です。 -- 注入された稼働中セッション: 実行中のサンドボックス `session` を渡した場合、機能によるマニフェスト更新で、互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリ型の置き換え、マウントエントリの追加または変更はできません。 -- ランナー API: `SandboxAgent` の実行でも、通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 +- 新規セッション:`manifest=` と `snapshot=` は、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 +- 再開とスナップショット:`session_state=` は以前にシリアライズされたサンドボックス状態へ再接続します。一方、`snapshot=` は保存されたワークスペースコンテンツから新しいサンドボックスセッションを初期化します。 +- クライアント固有のオプション:`options=` はサンドボックスクライアントによって異なります。Docker と多くのホステッドクライアントでは必須です。 +- 注入された稼働中のセッション:実行中のサンドボックス `session` を渡した場合、機能によるマニフェスト更新で、互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリタイプの置き換え、マウントエントリの追加または変更はできません。 +- ランナー API:`SandboxAgent` の実行でも、通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 -## 完全なコード例: コーディングタスク +## 完全な例:コーディングタスク -次のコーディング形式のコード例は、デフォルトの出発点として適しています。 +このコーディング形式の例は、デフォルトの出発点として適しています。 ```python import asyncio @@ -569,19 +571,19 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)を参照してください。このコード例では、Unix ローカル実行で決定論的に検証できるように、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリでは、もちろん Python、JavaScript、その他の任意の言語を使用できます。 +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。この例では、Unix ローカル実行で決定論的に検証できるように、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリは、もちろん Python、JavaScript、その他の任意のものを使用できます。 ## 一般的なパターン -上記の完全なコード例から始めてください。多くの場合、同じ `SandboxAgent` をそのまま維持し、サンドボックスクライアント、サンドボックスセッションの取得元、またはワークスペースの取得元のみを変更できます。 +上記の完全な例から始めてください。多くの場合、同じ `SandboxAgent` をそのまま維持し、サンドボックスクライアント、サンドボックスセッションのソース、またはワークスペースのソースだけを変更できます。 ### サンドボックスクライアントの切り替え -エージェント定義は同じまま維持し、実行設定のみを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +エージェント定義は同じままにして、実行設定だけを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 ### ワークスペースのオーバーライド -エージェント定義は同じまま維持し、新規セッション用マニフェストのみを差し替えます。 +エージェント定義は同じままにして、新規セッションのマニフェストだけを入れ替えます。 ```python from agents.run import RunConfig @@ -601,7 +603,7 @@ run_config = RunConfig( ) ``` -エージェントを再構築せずに、同じエージェントロールを異なるリポジトリ、資料、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディングコード例では、一時的なオーバーライドではなく `default_manifest` を使用して同じパターンを示しています。 +エージェントを再構築せずに、同じエージェントのロールを異なるリポジトリ、資料一式、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディング例では、1 回限りのオーバーライドではなく `default_manifest` を使用して同じパターンを示しています。 ### サンドボックスセッションの注入 @@ -626,11 +628,11 @@ async with sandbox: ) ``` -実行後にワークスペースを確認する場合、または起動済みのサンドボックスセッションでストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)および[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)を参照してください。 +実行後にワークスペースを確認する場合、またはすでに起動済みのサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) と [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 ### セッション状態からの再開 -`RunState` の外部でサンドボックス状態を既にシリアライズしている場合は、ランナーにその状態から再接続させます。 +`RunState` の外部でサンドボックス状態をすでにシリアライズしている場合は、その状態からランナーを再接続させます。 ```python from agents.run import RunConfig @@ -647,11 +649,13 @@ run_config = RunConfig( ) ``` -サンドボックス状態を独自のストレージやジョブシステムに保存し、`Runner` でその状態から直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)を参照してください。 +サンドボックス状態が独自のストレージまたはジョブシステムにあり、`Runner` でそこから直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) を参照してください。 + +セッション状態のシリアライズでは、ネイティブの `host_path` 値が省略されます。ホストに基づく許可を再開するには、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` で指定してください。指定しない場合、サンドボックスの起動前に再開が失敗します。シリアライズされた入力やその他の信頼できない入力からホストパスを導出しないでください。 ### スナップショットからの開始 -保存済みのファイルや成果物から新しいサンドボックスを初期化します。 +保存済みのファイルと成果物から新しいサンドボックスを初期化します。 ```python from pathlib import Path @@ -668,11 +672,11 @@ run_config = RunConfig( ) ``` -新しい実行を `agent.default_manifest` だけでなく、保存済みワークスペース内容から開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)を参照してください。 +新しい実行を `agent.default_manifest` だけでなく、保存済みのワークスペースコンテンツから開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 ### Git からのスキル読み込み -ローカルスキルソースを、リポジトリを使用するソースに置き換えます。 +ローカルのスキルソースを、リポジトリに基づくソースと入れ替えます。 ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -683,11 +687,11 @@ capabilities = Capabilities.default() + [ ] ``` -スキルバンドルに独自のリリースサイクルがある場合や、複数のサンドボックス間で共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)を参照してください。 +スキルバンドルに独自のリリースサイクルがある場合、または複数のサンドボックス間で共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 ### ツールとしての公開 -ツールエージェントは、独自のサンドボックス境界を使用するか、親実行の稼働中サンドボックスを再利用できます。再利用は、高速な読み取り専用の探索エージェントに役立ちます。別のサンドボックスを作成、ハイドレート、スナップショット化するコストをかけずに、親が使用しているワークスペースをそのまま確認できます。 +ツールエージェントには、独自のサンドボックス境界を与えることも、親実行の稼働中のサンドボックスを再利用させることもできます。再利用は、高速な読み取り専用の探索エージェントに便利です。別のサンドボックスを作成、初期化、スナップショット化するコストをかけずに、親が使用しているものと同一のワークスペースを確認できます。 ```python from agents import Runner @@ -769,7 +773,7 @@ async with sandbox: ) ``` -ここでは、親エージェントが `coordinator` として実行され、探索用ツールエージェントが同じ稼働中サンドボックスセッション内で `explorer` として実行されます。`pricing_packet/` のエントリは `other` ユーザーが読み取れるため、探索エージェントはすばやく確認できますが、書き込み権限はありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループのみが利用できるため、探索エージェントを読み取り専用に維持しながら、親は最終成果物を書き込めます。 +ここでは、親エージェントが `coordinator` として実行され、探索用ツールエージェントが同じ稼働中のサンドボックスセッション内で `explorer` として実行されます。`pricing_packet/` のエントリは `other` ユーザーが読み取り可能なため、探索エージェントは迅速に確認できますが、書き込み権限はありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループだけが利用できるため、探索エージェントを読み取り専用のまま維持しながら、親は最終成果物を書き込めます。 ツールエージェントに実際の分離が必要な場合は、独自のサンドボックス `RunConfig` を指定します。 @@ -797,11 +801,11 @@ rollout_agent.as_tool( ) ``` -ツールエージェントが自由に変更を行う場合、信頼できないコマンドを実行する場合、または異なるバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)を参照してください。 +ツールエージェントが自由に変更を加える場合、信頼できないコマンドを実行する場合、または異なるバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 ### ローカルツールおよび MCP との組み合わせ -サンドボックスワークスペースを維持しながら、同じエージェントで通常のツールも使用できます。 +同じエージェントで通常のツールも使用しながら、サンドボックスワークスペースを維持します。 ```python from agents.sandbox import SandboxAgent @@ -816,46 +820,46 @@ agent = SandboxAgent( ) ``` -ワークスペースの確認がエージェントの作業の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)を参照してください。 +ワークスペースの確認がエージェントの作業の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 ## メモリ -後続のサンドボックスエージェント実行で以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは SDK の会話用 `Session` メモリとは別のものです。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 +将来のサンドボックスエージェントの実行で、以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは SDK の会話用 `Session` メモリとは異なります。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 -設定、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 +セットアップ、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 ## 構成パターン -単一エージェントのパターンを理解したら、次の設計上の検討事項は、より大きなシステムのどこにサンドボックス境界を配置するかです。 +単一エージェントのパターンを理解した後は、より大規模なシステムのどこにサンドボックス境界を配置するかが次の設計上の問いになります。 サンドボックスエージェントは、引き続き SDK の他の要素と組み合わせられます。 -- [ハンドオフ](../handoffs.md): ドキュメント量の多い作業を、サンドボックスを使用しない受付エージェントからサンドボックスレビュアーへ引き継ぎます。 -- [Agents as tools](../tools.md#agents-as-tools): 複数のサンドボックスエージェントをツールとして公開します。通常は、各 `Agent.as_tool(...)` 呼び出しに `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡し、各ツールに独自のサンドボックス境界を割り当てます。 -- [MCP](../mcp.md) と通常の関数ツール: サンドボックス機能は、`mcp_servers` および通常の Python ツールと共存できます。 -- [エージェントの実行](../running_agents.md): サンドボックス実行でも通常の `Runner` API を使用します。 +- [ハンドオフ](../handoffs.md):ドキュメント量の多い作業を、サンドボックスを使用しない受付エージェントからサンドボックスのレビュー担当エージェントに引き継ぎます。 +- [Agents as tools](../tools.md#agents-as-tools):複数のサンドボックスエージェントをツールとして公開します。通常は、各 `Agent.as_tool(...)` 呼び出しで `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡し、各ツールに独自のサンドボックス境界を割り当てます。 +- [MCP](../mcp.md) と通常の関数ツール:サンドボックス機能は、`mcp_servers` や通常の Python ツールと共存できます。 +- [エージェントの実行](../running_agents.md):サンドボックス実行でも通常の `Runner` API を使用します。 -特に一般的なのは、次の 2 つのパターンです。 +特に一般的なパターンは次の 2 つです。 -- サンドボックスを使用しないエージェントが、ワークスペースの分離を必要とするワークフロー部分だけをサンドボックスエージェントへハンドオフする -- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は `Agent.as_tool(...)` 呼び出しごとに個別のサンドボックス `RunConfig` を指定して、各ツールに独自の分離ワークスペースを割り当てる +- ワークフローのうちワークスペースの分離が必要な部分だけを、サンドボックスを使用しないエージェントからサンドボックスエージェントへハンドオフする +- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は各 `Agent.as_tool(...)` 呼び出しに個別のサンドボックス `RunConfig` を指定して、各ツールに独自の分離ワークスペースを割り当てる ### ターンとサンドボックス実行 -ハンドオフと、エージェントをツールとして使用する呼び出しは、分けて説明すると理解しやすくなります。 +ハンドオフとエージェントをツールとして呼び出す場合は、分けて説明すると理解しやすくなります。 -ハンドオフでは、トップレベルの実行とトップレベルのターンループは引き続き 1 つです。アクティブなエージェントは変わりますが、実行がネストされるわけではありません。サンドボックスを使用しない受付エージェントがサンドボックスレビュアーへハンドオフすると、同じ実行内の次のモデル呼び出しはサンドボックスエージェント用に準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフによって、同じ実行の次のターンを担当するエージェントが変わります。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)を参照してください。 +ハンドオフでは、トップレベルの実行とトップレベルのターンループは引き続き 1 つです。アクティブなエージェントは変わりますが、実行がネストされるわけではありません。サンドボックスを使用しない受付エージェントがサンドボックスのレビュー担当エージェントにハンドオフすると、同じ実行内の次のモデル呼び出しがサンドボックスエージェント用に準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフは、同じ実行の次のターンをどのエージェントが担当するかを変更します。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) を参照してください。 -`Agent.as_tool(...)` では関係が異なります。外側のオーケストレーターは、ツールを呼び出すかどうかの決定に外側の 1 ターンを使用し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認、通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合も、複数ターンかかる場合もあります。外側のオーケストレーターから見ると、これらの作業はすべて 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増えることはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)を参照してください。 +`Agent.as_tool(...)` では、関係が異なります。外側のオーケストレーターは、外側の 1 ターンを使用してツールの呼び出しを決定し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認、および通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合も、複数ターンを要する場合もあります。外側のオーケストレーターから見ると、これらの作業はすべて 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増加することはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 -承認の動作も同様に分かれます。 +承認の動作も同じ区分に従います。 -- ハンドオフでは、サンドボックスエージェントが同じ実行内のアクティブなエージェントになるため、承認は同じトップレベルの実行に維持されます。 -- `Agent.as_tool(...)` では、サンドボックスのツールエージェント内で発生した承認も外側の実行に表示されますが、保存されたネスト済み実行状態から取得され、外側の実行の再開時にネストされたサンドボックス実行を再開します。 +- ハンドオフでは、サンドボックスエージェントがその実行のアクティブなエージェントになるため、承認は同じトップレベルの実行に留まります +- `Agent.as_tool(...)` では、サンドボックスのツールエージェント内で発生した承認も外側の実行に公開されますが、保存されたネスト済み実行状態から取得され、外側の実行が再開されるとネストされたサンドボックス実行も再開されます ## 関連資料 -- [クイックスタート](../sandbox_agents.md): サンドボックスエージェントを 1 つ実行します。 -- [サンドボックスクライアント](clients.md): ローカル、Docker、ホステッド、マウントのオプションを選択します。 -- [エージェントメモリ](memory.md): 以前のサンドボックス実行から得た学習内容を保存し、再利用します。 -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成のパターン。 +- [クイックスタート](../sandbox_agents.md):サンドボックスエージェントを 1 つ実行します。 +- [サンドボックスクライアント](clients.md):ローカル、Docker、ホステッド、マウントの各オプションを選択します。 +- [エージェントメモリ](memory.md):以前のサンドボックス実行から得た学習内容を保持し、再利用します。 +- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成の各パターン。 \ No newline at end of file diff --git a/docs/ko/sandbox/guide.md b/docs/ko/sandbox/guide.md index b6947a1663..aeb2deaaac 100644 --- a/docs/ko/sandbox/guide.md +++ b/docs/ko/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 기능입니다. 정식 출시 전까지 API 세부 사항, 기본값 및 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 수 있습니다. -최신 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특화된 도구와 셸 명령을 사용하여 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 아티팩트를 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 에이전트가 사용자를 대신해 작업할 수 있는 영구 워크스페이스를 모델에 제공합니다. Agents SDK의 샌드박스 에이전트를 사용하면 에이전트를 샌드박스 환경과 연결하여 손쉽게 실행할 수 있으며, 적절한 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모 작업을 쉽게 시작, 중지, 재개할 수 있습니다. +현대적인 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특수 도구와 셸 명령을 사용하여 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 아티팩트를 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 에이전트가 사용자를 대신해 작업할 수 있는 영구 작업 공간을 모델에 제공합니다. Agents SDK의 샌드박스 에이전트를 사용하면 샌드박스 환경과 결합된 에이전트를 쉽게 실행할 수 있으며, 필요한 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모로 작업을 쉽게 시작, 중지, 재개할 수 있습니다. -에이전트에 필요한 데이터를 중심으로 워크스페이스를 정의합니다. 워크스페이스는 GitHub 저장소, 로컬 파일과 디렉터리, 합성 작업 파일, S3 또는 Azure Blob Storage 같은 원격 파일 시스템, 그리고 사용자가 제공하는 기타 샌드박스 입력으로 시작할 수 있습니다. +에이전트에 필요한 데이터를 중심으로 작업 공간을 정의합니다. GitHub 저장소, 로컬 파일 및 디렉터리, 합성 작업 파일, S3 또는 Azure Blob Storage 같은 원격 파일 시스템 및 사용자가 제공하는 기타 샌드박스 입력에서 시작할 수 있습니다.
-![컴퓨팅 환경을 포함한 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png) +![컴퓨팅 기능이 포함된 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png)
-`SandboxAgent`도 여전히 `Agent`입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일, 훅과 같은 일반적인 에이전트 인터페이스를 그대로 유지하며, 일반적인 `Runner` API를 통해 실행됩니다. 달라지는 부분은 실행 경계입니다. +`SandboxAgent`도 여전히 `Agent`입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일, 훅과 같은 일반적인 에이전트 인터페이스를 유지하며, 일반적인 `Runner` API를 통해 실행됩니다. 달라지는 점은 실행 경계입니다. -- `SandboxAgent`는 에이전트 자체를 정의합니다. 일반적인 에이전트 구성에 더해 `default_manifest`, `base_instructions`, `run_as` 같은 샌드박스 전용 기본값과 파일 시스템 도구, 셸 액세스, 스킬, 메모리 또는 압축 같은 기능을 포함합니다. -- `Manifest`는 파일, 저장소, 마운트, 환경을 포함하여 새 샌드박스 워크스페이스에 필요한 초기 콘텐츠와 레이아웃을 선언합니다. -- 샌드박스 세션은 명령이 실행되고 파일이 변경되는 실제 격리 환경입니다. -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 샌드박스 세션을 직접 주입하거나, 직렬화된 샌드박스 세션 상태로 다시 연결하거나, 샌드박스 클라이언트를 통해 새 샌드박스 세션을 생성하는 등 실행에서 샌드박스 세션을 가져오는 방식을 결정합니다. -- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행에서 이전 작업에 다시 연결하거나, 저장된 콘텐츠를 바탕으로 새 샌드박스 세션을 초기화할 수 있습니다. +- `SandboxAgent`는 에이전트 자체를 정의합니다. 일반적인 에이전트 구성뿐 아니라 `default_manifest`, `base_instructions`, `run_as` 같은 샌드박스별 기본값과 파일 시스템 도구, 셸 액세스, 스킬, 메모리 또는 압축 같은 기능도 포함합니다. +- `Manifest`는 파일, 저장소, 마운트, 환경을 포함하여 새 샌드박스 작업 공간의 원하는 초기 콘텐츠와 레이아웃을 선언합니다. +- 샌드박스 세션은 명령이 실행되고 파일이 변경되는 활성 격리 환경입니다. +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 실행에서 샌드박스 세션을 가져오는 방법을 결정합니다. 예를 들어 세션을 직접 주입하거나, 직렬화된 샌드박스 세션 상태에서 다시 연결하거나, 샌드박스 클라이언트를 통해 새 샌드박스 세션을 생성할 수 있습니다. +- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행에서 이전 작업에 다시 연결하거나 저장된 콘텐츠를 기반으로 새 샌드박스 세션을 시작할 수 있습니다. -`Manifest`는 새 세션의 워크스페이스 계약이며, 모든 실제 샌드박스에 관한 완전한 정보의 원천은 아닙니다. 실행의 유효 워크스페이스는 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택한 스냅샷에서 가져올 수도 있습니다. +`Manifest`는 새 세션의 작업 공간 계약이며, 모든 활성 샌드박스에 대한 완전한 정보 원본은 아닙니다. 실행의 실질적인 작업 공간은 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택한 스냅샷에서 가져올 수도 있습니다. -이 페이지에서 "샌드박스 세션"은 샌드박스 클라이언트가 관리하는 실제 실행 환경을 의미합니다. 이는 [세션](../sessions/index.md)에서 설명하는 SDK의 대화형 [`Session`][agents.memory.session.Session] 인터페이스와 다릅니다. +이 페이지에서 "샌드박스 세션"은 샌드박스 클라이언트가 관리하는 활성 실행 환경을 의미합니다. 이는 [세션](../sessions/index.md)에서 설명하는 SDK의 대화형 [`Session`][agents.memory.session.Session] 인터페이스와 다릅니다. -외부 런타임은 계속해서 승인, 트레이싱, 핸드오프, 재개 관련 기록을 담당합니다. 샌드박스 세션은 명령, 파일 변경, 환경 격리를 담당합니다. 이러한 역할 분리는 모델의 핵심 요소입니다. +외부 런타임은 계속해서 승인, 트레이싱, 핸드오프 및 재개 관련 기록 관리를 담당합니다. 샌드박스 세션은 명령, 파일 변경 및 환경 격리를 담당합니다. 이러한 역할 분리는 이 모델의 핵심 요소입니다. -### 구성 요소 간 연계 +### 구성 요소의 관계 -샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 실제 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. +샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 활성 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. ```mermaid flowchart LR @@ -50,43 +50,43 @@ flowchart LR sandbox --> saved ``` -샌드박스 전용 기본값은 `SandboxAgent`에 둡니다. 실행별 샌드박스 세션 선택 사항은 `SandboxRunConfig`에 둡니다. +샌드박스별 기본값은 `SandboxAgent`에 유지합니다. 실행별 샌드박스 세션 선택 사항은 `SandboxRunConfig`에 유지합니다. -수명 주기는 다음 세 단계로 생각할 수 있습니다. +수명 주기는 다음 세 단계로 나눌 수 있습니다. -1. `SandboxAgent`, `Manifest`, 기능을 사용하여 에이전트와 새 워크스페이스 계약을 정의합니다. +1. `SandboxAgent`, `Manifest` 및 기능을 사용해 에이전트와 새 작업 공간 계약을 정의합니다. 2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig`를 `Runner`에 제공하여 실행합니다. -3. 러너가 관리하는 `RunState`, 명시적인 샌드박스 `session_state` 또는 저장된 워크스페이스 스냅샷에서 나중에 작업을 이어갑니다. +3. 러너가 관리하는 `RunState`, 명시적인 샌드박스 `session_state` 또는 저장된 작업 공간 스냅샷에서 나중에 작업을 계속합니다. -셸 액세스가 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](../tools.md)의 호스티드 셸부터 사용하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. +셸 액세스가 가끔 사용하는 도구 중 하나에 불과하다면 [도구 가이드](../tools.md)의 호스티드 셸로 시작하세요. 작업 공간 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. ## 사용 시점 -샌드박스 에이전트는 다음과 같은 워크스페이스 중심 워크플로에 적합합니다. +샌드박스 에이전트는 다음과 같은 작업 공간 중심 워크플로에 적합합니다. -- 코딩과 디버깅(예: GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 특정 테스트 실행) -- 문서 처리와 편집(예: 사용자의 재무 문서에서 정보를 추출하고 작성된 세금 양식 초안 생성) -- 파일에 기반한 검토 또는 분석(예: 답변하기 전에 온보딩 패킷, 생성된 보고서 또는 아티팩트 번들 확인) -- 격리된 멀티 에이전트 패턴(예: 각 검토자나 코딩 하위 에이전트에 자체 워크스페이스 제공) -- 여러 단계로 구성된 워크스페이스 작업(예: 한 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개) +- 코딩 및 디버깅(예: GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 대상 테스트 실행) +- 문서 처리 및 편집(예: 사용자의 재무 문서에서 정보를 추출하고 작성된 세금 양식 초안 생성) +- 파일 기반 검토 또는 분석(예: 답변 전 온보딩 문서 묶음, 생성된 보고서 또는 아티팩트 번들 확인) +- 격리된 멀티 에이전트 패턴(예: 각 검토자 또는 코딩 하위 에이전트에 자체 작업 공간 제공) +- 여러 단계로 이루어진 작업 공간 작업(예: 한 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개) -파일이나 지속적으로 변경되는 파일 시스템에 액세스할 필요가 없다면 `Agent`를 계속 사용하세요. 셸 액세스가 가끔 필요한 기능일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. +파일이나 지속적으로 변경되는 파일 시스템에 액세스할 필요가 없다면 계속 `Agent`를 사용하세요. 셸 액세스가 가끔 필요한 기능에 불과하다면 호스티드 셸을 추가하고, 작업 공간 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. ## 샌드박스 클라이언트 선택 -macOS 또는 Linux의 로컬 개발에는 `UnixLocalSandboxClient`로 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 공급자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리나 이미지 일관성이 필요하다면 `DockerSandboxClient`로 전환하고, 공급자가 관리하는 실행이 필요하다면 호스티드 공급자로 전환하세요. +macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 제공자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리 또는 이미지 일관성이 필요하면 `DockerSandboxClient`로 전환하고, 제공자가 관리하는 실행이 필요하면 호스티드 제공자로 전환하세요. -대부분의 경우 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 해당 옵션만 변경하면 `SandboxAgent` 정의는 그대로 유지됩니다. 로컬, Docker, 호스티드, 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. +대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 해당 옵션만 변경합니다. 로컬, Docker, 호스티드 및 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. ## 핵심 구성 요소
-| 계층 | 주요 SDK 구성 요소 | 답하는 질문 | +| 계층 | 주요 SDK 구성 요소 | 답변하는 질문 | | --- | --- | --- | -| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트를 실행하며, 어떤 새 세션 워크스페이스 계약에서 시작해야 합니까? | -| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트, 실제 샌드박스 세션 | 이 실행은 실제 샌드박스 세션을 어떻게 가져오며, 작업은 어디에서 실행됩니까? | -| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state`, 스냅샷 | 이 워크플로는 이전 샌드박스 작업에 어떻게 다시 연결하거나 저장된 콘텐츠에서 새 샌드박스 세션을 초기화합니까? | +| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트가 실행되며, 어떤 새 세션 작업 공간 계약에서 시작해야 하는가? | +| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트 및 활성 샌드박스 세션 | 이 실행은 어떻게 활성 샌드박스 세션을 가져오며, 작업은 어디에서 실행되는가? | +| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state` 및 스냅샷 | 이 워크플로는 이전 샌드박스 작업에 어떻게 다시 연결하거나 저장된 콘텐츠를 기반으로 새 샌드박스 세션을 시작하는가? |
@@ -94,131 +94,131 @@ macOS 또는 Linux의 로컬 개발에는 `UnixLocalSandboxClient`로 시작하
-| 구성 요소 | 담당 범위 | 확인할 질문 | +| 구성 요소 | 담당 영역 | 확인할 질문 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트는 무엇을 해야 하며, 어떤 기본값을 함께 유지해야 합니까? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션의 워크스페이스 파일과 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 합니까? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 이 에이전트에 어떤 도구, 지침 조각 또는 런타임 동작을 연결해야 합니까? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트와 샌드박스 세션 소스 | 이 실행은 샌드박스 세션을 주입, 재개 또는 생성해야 합니까? | -| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 러너가 관리하던 이전 워크플로를 재개하면서 해당 샌드박스 상태를 자동으로 이어가고 있습니까? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태로부터 재개하려고 합니까? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위한 저장된 워크스페이스 콘텐츠 | 새 샌드박스 세션을 저장된 파일과 아티팩트에서 시작해야 합니까? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트는 무엇을 해야 하며, 어떤 기본값을 함께 유지해야 하는가? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션 작업 공간의 파일 및 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 하는가? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 어떤 도구, 지침 조각 또는 런타임 동작을 이 에이전트에 연결해야 하는가? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트 및 샌드박스 세션 소스 | 이 실행은 샌드박스 세션을 주입, 재개 또는 생성해야 하는가? | +| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 러너가 관리하던 이전 워크플로를 재개하고 그 샌드박스 상태를 자동으로 이어갈 것인가? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태로부터 재개할 것인가? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위한 저장된 작업 공간 콘텐츠 | 새 샌드박스 세션을 저장된 파일과 아티팩트에서 시작할 것인가? |
실용적인 설계 순서는 다음과 같습니다. -1. `Manifest`로 새 세션의 워크스페이스 계약을 정의합니다. +1. `Manifest`로 새 세션 작업 공간 계약을 정의합니다. 2. `SandboxAgent`로 에이전트를 정의합니다. -3. 기본 제공 또는 사용자 정의 기능을 추가합니다. -4. `RunConfig(sandbox=SandboxRunConfig(...))`에서 각 실행이 샌드박스 세션을 가져오는 방식을 결정합니다. +3. 기본 제공 또는 사용자 지정 기능을 추가합니다. +4. 각 실행이 `RunConfig(sandbox=SandboxRunConfig(...))`에서 샌드박스 세션을 가져오는 방법을 결정합니다. ## 샌드박스 실행 준비 과정 실행 시 러너는 해당 정의를 구체적인 샌드박스 기반 실행으로 변환합니다. -1. `SandboxRunConfig`에서 샌드박스 세션을 확인합니다. `session=...`을 전달하면 해당 실제 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...`를 사용하여 세션을 생성하거나 재개합니다. -2. 실행에 사용할 유효 워크스페이스 입력을 결정합니다. 실행에서 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest`에서 시작합니다. 따라서 `Manifest`만으로 모든 실행의 최종 실제 워크스페이스가 정의되지는 않습니다. -3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트를 준비하기 전에 기능이 파일, 마운트 또는 기타 워크스페이스 범위 동작을 추가할 수 있습니다. -4. 고정된 순서로 최종 지침을 구성합니다. 먼저 SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`를 사용하고, 이어서 `instructions`, 기능 지침 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리를 추가합니다. -5. 기능 도구를 실제 샌드박스 세션에 바인딩하고 일반적인 `Runner` API를 통해 준비된 에이전트를 실행합니다. +1. `SandboxRunConfig`에서 샌드박스 세션을 확인합니다. `session=...`을 전달하면 해당 활성 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...`를 사용하여 세션을 생성하거나 재개합니다. +2. 실행에 실질적으로 적용할 작업 공간 입력을 결정합니다. 실행이 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest`에서 시작합니다. 이 때문에 `Manifest`만으로는 모든 실행의 최종 활성 작업 공간을 정의할 수 없습니다. +3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트를 준비하기 전에 기능이 파일, 마운트 또는 기타 작업 공간 범위의 동작을 추가할 수 있습니다. +4. 고정된 순서로 최종 지침을 구성합니다. 먼저 SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`를 사용하고, 이어서 `instructions`, 기능의 지침 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리를 추가합니다. +5. 기능 도구를 활성 샌드박스 세션에 바인딩하고 일반적인 `Runner` API를 통해 준비된 에이전트를 실행합니다. -샌드박스 사용은 턴의 의미를 바꾸지 않습니다. 턴은 여전히 단일 셸 명령이나 샌드박스 작업이 아니라 모델 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내부에서 처리될 수 있지만, 다른 작업은 추가 모델 단계가 필요한 도구 결과, 승인 또는 기타 상태를 반환합니다. 실용적인 원칙으로는 샌드박스 작업이 발생한 후 에이전트 런타임에서 추가 모델 응답이 필요할 때만 턴이 하나 더 사용됩니다. +샌드박싱은 턴의 의미를 변경하지 않습니다. 턴은 여전히 하나의 셸 명령이나 샌드박스 작업이 아니라 모델 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내부에서 처리될 수 있지만, 다른 작업은 또 다른 모델 단계가 필요한 도구 결과, 승인 또는 기타 상태를 반환할 수 있습니다. 실용적인 원칙으로, 샌드박스 작업이 수행된 후 에이전트 런타임에 또 다른 모델 응답이 필요할 때만 턴이 하나 더 소비됩니다. -이러한 준비 단계 때문에 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as`가 `SandboxAgent`를 설계할 때 고려해야 할 주요 샌드박스 전용 옵션입니다. +이러한 준비 단계 때문에 `SandboxAgent`를 설계할 때 고려해야 할 주요 샌드박스별 옵션은 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as`입니다. ## `SandboxAgent` 옵션 -일반적인 `Agent` 필드에 추가되는 샌드박스 전용 옵션은 다음과 같습니다. +다음은 일반적인 `Agent` 필드에 추가되는 샌드박스별 옵션입니다.
-| 옵션 | 적합한 용도 | +| 옵션 | 가장 적합한 용도 | | --- | --- | -| `default_manifest` | 러너가 생성하는 새 샌드박스 세션의 기본 워크스페이스 | -| `instructions` | SDK 샌드박스 프롬프트 뒤에 추가되는 역할, 워크플로, 성공 기준 | -| `base_instructions` | SDK 샌드박스 프롬프트를 대체하는 고급 우회 수단 | -| `capabilities` | 이 에이전트와 함께 유지되어야 하는 샌드박스 네이티브 도구와 동작 | -| `run_as` | 셸 명령, 파일 읽기, 패치 등 모델에 노출되는 샌드박스 도구의 사용자 ID | +| `default_manifest` | 러너가 생성하는 새 샌드박스 세션의 기본 작업 공간 | +| `instructions` | SDK 샌드박스 프롬프트 뒤에 추가되는 역할, 워크플로 및 성공 기준 | +| `base_instructions` | SDK 샌드박스 프롬프트를 대체하는 고급 이스케이프 해치 | +| `capabilities` | 이 에이전트와 함께 유지되어야 하는 샌드박스 네이티브 도구 및 동작 | +| `run_as` | 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구의 사용자 ID |
-샌드박스 클라이언트 선택, 샌드박스 세션 재사용, 매니페스트 재정의, 스냅샷 선택은 에이전트가 아니라 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에 속합니다. +샌드박스 클라이언트 선택, 샌드박스 세션 재사용, 매니페스트 재정의 및 스냅샷 선택은 에이전트가 아니라 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에 속합니다. ### `default_manifest` -`default_manifest`는 러너가 이 에이전트의 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest]입니다. 에이전트가 일반적으로 시작할 때 갖추어야 할 파일, 저장소, 보조 자료, 출력 디렉터리, 마운트에 사용합니다. +`default_manifest`는 러너가 이 에이전트를 위해 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest]입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리 및 마운트에 사용하세요. -이는 기본값일 뿐입니다. 실행에서 `SandboxRunConfig(manifest=...)`로 재정의할 수 있으며, 재사용하거나 재개한 샌드박스 세션은 기존 워크스페이스 상태를 유지합니다. +이는 기본값일 뿐입니다. 실행에서 `SandboxRunConfig(manifest=...)`를 사용해 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 작업 공간 상태를 유지합니다. -### `instructions`와 `base_instructions` +### `instructions` 및 `base_instructions` -여러 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`를 사용하세요. `SandboxAgent`에서는 이러한 지침이 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로, 성공 기준을 추가할 수 있습니다. +여러 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`를 사용하세요. `SandboxAgent`에서 이러한 지침은 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 역할, 워크플로 및 성공 기준을 추가할 수 있습니다. -SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions`를 사용하세요. 대부분의 에이전트에서는 설정하지 않는 것이 좋습니다. +SDK 샌드박스 기본 프롬프트를 교체하려는 경우에만 `base_instructions`를 사용하세요. 대부분의 에이전트에서는 이를 설정하지 않는 것이 좋습니다.
| 배치 위치 | 용도 | 예시 | | --- | --- | --- | -| `instructions` | 에이전트의 일관된 역할, 워크플로 규칙, 성공 기준 | "온보딩 문서를 검사한 다음 핸드오프하세요.", "최종 파일을 `output/`에 작성하세요." | -| `base_instructions` | SDK 샌드박스 기본 프롬프트의 전체 대체 | 사용자 정의 저수준 샌드박스 래퍼 프롬프트 | -| 사용자 프롬프트 | 이 실행을 위한 일회성 요청 | "이 워크스페이스를 요약하세요." | -| 매니페스트의 워크스페이스 파일 | 더 긴 작업 명세, 저장소 로컬 지침 또는 범위가 한정된 참조 자료 | `repo/task.md`, 문서 번들, 샘플 패킷 | +| `instructions` | 에이전트의 안정적인 역할, 워크플로 규칙 및 성공 기준 | "온보딩 문서를 검사한 다음 핸드오프하세요.", "최종 파일을 `output/`에 작성하세요." | +| `base_instructions` | SDK 샌드박스 기본 프롬프트의 완전한 대체 | 사용자 지정 저수준 샌드박스 래퍼 프롬프트 | +| 사용자 프롬프트 | 이 실행을 위한 일회성 요청 | "이 작업 공간을 요약하세요." | +| 매니페스트의 작업 공간 파일 | 더 긴 작업 명세, 저장소 로컬 지침 또는 범위가 제한된 참고 자료 | `repo/task.md`, 문서 번들, 샘플 문서 묶음 |
-`instructions`를 효과적으로 사용하는 예시는 다음과 같습니다. +`instructions`의 적절한 사용 예시는 다음과 같습니다. -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py)는 PTY 상태가 중요한 경우 에이전트가 하나의 대화형 프로세스에서 작업하도록 합니다. +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py)는 PTY 상태가 중요할 때 에이전트를 하나의 대화형 프로세스에 유지합니다. - [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)는 샌드박스 검토자가 검사 후 사용자에게 직접 답변하지 못하도록 합니다. -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)는 최종 작성 파일이 실제로 `output/`에 저장되도록 요구합니다. -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)는 정확한 검증 명령을 지정하고 워크스페이스 루트 기준 패치 경로를 명확히 설명합니다. +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)는 작성이 완료된 최종 파일이 실제로 `output/`에 저장되도록 요구합니다. +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)는 정확한 검증 명령을 고정하고 작업 공간 루트 기준 패치 경로를 명확히 설명합니다. -사용자의 일회성 작업을 `instructions`에 복사하거나, 매니페스트에 속하는 긴 참조 자료를 포함하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 반복하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. +사용자의 일회성 작업을 `instructions`에 복사하거나, 매니페스트에 속해야 하는 긴 참고 자료를 포함하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 반복하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. -`instructions`를 생략해도 SDK는 기본 샌드박스 프롬프트를 포함합니다. 저수준 래퍼에는 이것으로 충분하지만, 대부분의 사용자 대상 에이전트는 명시적인 `instructions`도 제공해야 합니다. +`instructions`를 생략해도 SDK는 기본 샌드박스 프롬프트를 포함합니다. 저수준 래퍼에는 이것만으로 충분하지만, 대부분의 사용자 대상 에이전트는 여전히 명시적인 `instructions`를 제공해야 합니다. ### `capabilities` -기능은 샌드박스 네이티브 동작을 `SandboxAgent`에 연결합니다. 실행이 시작되기 전에 워크스페이스를 구성하고, 샌드박스 전용 지침을 추가하며, 실제 샌드박스 세션에 바인딩되는 도구를 노출하고, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. +기능은 샌드박스 네이티브 동작을 `SandboxAgent`에 연결합니다. 실행이 시작되기 전에 작업 공간을 구성하고, 샌드박스별 지침을 추가하고, 활성 샌드박스 세션에 바인딩되는 도구를 노출하고, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. 기본 제공 기능은 다음과 같습니다.
-| 기능 | 추가 시점 | 참고 | +| 기능 | 추가 시점 | 참고 사항 | | --- | --- | --- | -| `Shell` | 에이전트에 셸 액세스가 필요할 때 | `exec_command`를 추가하며, 샌드박스 클라이언트가 PTY 상호작용을 지원하면 `write_stdin`도 추가합니다. | -| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 할 때 | `apply_patch`와 `view_image`를 추가하며, 패치 경로는 워크스페이스 루트 기준입니다. | -| `Skills` | 샌드박스에서 스킬 검색과 구체화를 사용하려고 할 때 | `.agents` 또는 `.agents/skills`를 수동으로 마운트하는 대신 이를 사용하는 것이 좋습니다. `Skills`가 스킬의 인덱스를 생성하고 샌드박스에 구체화합니다. | -| `Memory` | 후속 실행에서 메모리 아티팩트를 읽거나 생성해야 할 때 | `Shell`이 필요하며, 실시간 업데이트에는 `Filesystem`도 필요합니다. | -| `Compaction` | 장기 실행 흐름에서 압축 항목 이후 컨텍스트 축소가 필요할 때 | 모델 샘플링과 입력 처리를 조정합니다. | +| `Shell` | 에이전트에 셸 액세스가 필요한 경우 | `exec_command`를 추가하며, 샌드박스 클라이언트가 PTY 상호 작용을 지원하는 경우 `write_stdin`도 추가합니다. | +| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 하는 경우 | `apply_patch` 및 `view_image`를 추가합니다. 패치 경로는 작업 공간 루트를 기준으로 합니다. | +| `Skills` | 샌드박스에서 스킬 탐색 및 구체화를 사용하려는 경우 | `.agents` 또는 `.agents/skills`를 수동으로 마운트하는 대신 이를 사용하는 것이 좋습니다. `Skills`가 스킬을 인덱싱하고 샌드박스에 구체화합니다. | +| `Memory` | 후속 실행에서 메모리 아티팩트를 읽거나 생성해야 하는 경우 | `Shell`이 필요하며, 실시간 업데이트에는 `Filesystem`도 필요합니다. | +| `Compaction` | 장기 실행 흐름에서 압축 항목 후 컨텍스트를 축소해야 하는 경우 | 모델 샘플링 및 입력 처리를 조정합니다. |
-기본적으로 `SandboxAgent.capabilities`는 `Filesystem()`, `Shell()`, `Compaction()`을 포함하는 `Capabilities.default()`를 사용합니다. `capabilities=[...]`를 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용하려는 기본 기능을 모두 포함하세요. +기본적으로 `SandboxAgent.capabilities`는 `Filesystem()`, `Shell()`, `Compaction()`을 포함하는 `Capabilities.default()`를 사용합니다. `capabilities=[...]`를 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용하려는 기본 기능을 포함하세요. -스킬은 원하는 구체화 방식에 따라 소스를 선택하세요. +스킬의 경우 원하는 구체화 방식에 따라 소스를 선택하세요. -- `Skills(lazy_from=LocalDirLazySkillSource(...))`는 모델이 먼저 인덱스를 검색하고 필요한 항목만 로드할 수 있으므로 규모가 큰 로컬 스킬 디렉터리에 적합한 기본 선택입니다. -- `LocalDirLazySkillSource(source=LocalDir(src=...))`는 SDK 프로세스가 실행 중인 파일 시스템에서 읽습니다. 샌드박스 이미지나 워크스페이스 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. -- `Skills(from_=LocalDir(src=...))`는 사전에 스테이징하려는 소규모 로컬 번들에 더 적합합니다. +- `Skills(lazy_from=LocalDirLazySkillSource(...))`는 모델이 먼저 인덱스를 탐색하고 필요한 항목만 로드할 수 있으므로 규모가 큰 로컬 스킬 디렉터리에 적합한 기본 선택입니다. +- `LocalDirLazySkillSource(source=LocalDir(src=...))`는 SDK 프로세스가 실행되는 파일 시스템에서 읽습니다. 샌드박스 이미지나 작업 공간 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. +- `Skills(from_=LocalDir(src=...))`는 미리 스테이징하려는 소규모 로컬 번들에 더 적합합니다. - `Skills(from_=GitRepo(repo=..., ref=...))`는 스킬 자체를 저장소에서 가져와야 할 때 적합합니다. -`LocalDir.src`는 SDK 호스트의 소스 경로입니다. `skills_path`는 `load_skill`을 호출할 때 스킬이 스테이징되는 샌드박스 워크스페이스 내부의 상대 대상 경로입니다. +`LocalDir.src`는 SDK 호스트의 소스 경로입니다. `skills_path`는 `load_skill`이 호출될 때 스킬이 스테이징되는 샌드박스 작업 공간 내부의 상대 대상 경로입니다. -스킬이 이미 `.agents/skills//SKILL.md` 같은 경로의 디스크에 있다면 `LocalDir(...)`가 해당 소스 루트를 가리키도록 하고, 계속 `Skills(...)`를 사용하여 노출하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 워크스페이스 계약이 없다면 기본값인 `skills_path=".agents"`를 유지하세요. +스킬이 이미 `.agents/skills//SKILL.md` 같은 디스크 경로에 있다면 `LocalDir(...)`이 해당 소스 루트를 가리키도록 하고, 스킬을 노출할 때는 계속 `Skills(...)`를 사용하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 작업 공간 계약이 없다면 기본 `skills_path=".agents"`를 유지하세요. -적합한 기본 제공 기능이 있으면 이를 우선 사용하세요. 기본 제공 기능이 다루지 않는 샌드박스 전용 도구나 지침 인터페이스가 필요한 경우에만 사용자 정의 기능을 작성하세요. +적합한 기본 제공 기능이 있다면 이를 우선 사용하세요. 기본 제공 기능이 지원하지 않는 샌드박스별 도구 또는 지침 인터페이스가 필요할 때만 사용자 지정 기능을 작성하세요. ## 개념 ### 매니페스트 -[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root`를 설정하고, 파일과 디렉터리를 선언하며, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자나 그룹을 정의하며, 워크스페이스 외부의 특정 절대 경로에 대한 액세스를 허용할 수 있습니다. +[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 작업 공간을 설명합니다. 작업 공간 `root`를 설정하고, 파일과 디렉터리를 선언하고, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자 또는 그룹을 정의하고, 작업 공간 외부의 특정 절대 경로에 대한 액세스를 허용할 수 있습니다. -매니페스트 항목의 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..`로 워크스페이스를 벗어날 수 없으므로, 로컬, Docker, 호스티드 클라이언트 간에 워크스페이스 계약의 이식성을 유지할 수 있습니다. +매니페스트 항목 경로는 작업 공간 기준 상대 경로입니다. 절대 경로를 사용할 수 없으며 `..`을 사용해 작업 공간을 벗어날 수도 없습니다. 따라서 로컬, Docker 및 호스티드 클라이언트 간에 작업 공간 계약의 이식성을 유지할 수 있습니다. 작업을 시작하기 전에 에이전트에 필요한 자료에는 매니페스트 항목을 사용하세요. @@ -228,20 +228,20 @@ SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instr | --- | --- | | `File`, `Dir` | 소규모 합성 입력, 보조 파일 또는 출력 디렉터리 | | `LocalFile`, `LocalDir` | 샌드박스에 구체화해야 하는 호스트 파일 또는 디렉터리 | -| `GitRepo` | 워크스페이스로 가져와야 하는 저장소 | -| `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 같은 마운트 | 샌드박스 내부에 표시되어야 하는 외부 스토리지 | +| `GitRepo` | 작업 공간으로 가져와야 하는 저장소 | +| `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 같은 마운트 | 샌드박스 내부에 표시해야 하는 외부 스토리지 | -`Dir`은 합성 자식 항목으로 샌드박스 워크스페이스 내부에 디렉터리를 생성하거나 출력 위치를 만듭니다. 호스트 파일 시스템에서는 읽지 않습니다. 기존 호스트 디렉터리를 샌드박스 워크스페이스로 복사해야 할 때는 `LocalDir`을 사용하세요. +`Dir`은 합성 하위 항목으로 샌드박스 작업 공간 내부에 디렉터리를 생성하거나 출력 위치를 생성합니다. 호스트 파일 시스템에서 읽지는 않습니다. 기존 호스트 디렉터리를 샌드박스 작업 공간으로 복사해야 할 때는 `LocalDir`을 사용하세요. -기본적으로 `LocalFile.src`와 `LocalDir.src`는 SDK 프로세스 작업 디렉터리를 기준으로 확인됩니다. `extra_path_grants`에 포함되지 않는 한 소스는 해당 기본 디렉터리 아래에 있어야 합니다. 이를 통해 로컬 소스 구체화가 나머지 샌드박스 매니페스트와 동일한 호스트 경로 신뢰 경계 안에 유지됩니다. +기본적으로 `LocalFile.src` 및 `LocalDir.src`는 SDK 프로세스의 작업 디렉터리를 기준으로 해석됩니다. 소스가 `extra_path_grants`에 포함되지 않는 한 해당 기본 디렉터리 아래에 있어야 합니다. 이를 통해 로컬 소스 구체화가 나머지 샌드박스 매니페스트와 동일한 호스트 경로 신뢰 경계 내부에서 이루어집니다. -마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 설명합니다. 마운트 옵션과 공급자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참고하세요. +마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방법을 설명합니다. 마운트 옵션과 제공자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참조하세요. -좋은 매니페스트 설계는 일반적으로 워크스페이스 계약의 범위를 좁게 유지하고, 긴 작업 절차는 `repo/task.md` 같은 워크스페이스 파일에 배치하며, 지침에서는 `repo/task.md` 또는 `output/report.md`처럼 워크스페이스 기준 상대 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집하는 경우, 패치 경로는 셸 `workdir`이 아니라 샌드박스 워크스페이스 루트를 기준으로 한다는 점을 기억하세요. +좋은 매니페스트 설계는 일반적으로 작업 공간 계약을 좁게 유지하고, 긴 작업 절차를 `repo/task.md` 같은 작업 공간 파일에 배치하며, 지침에서 `repo/task.md` 또는 `output/report.md` 같은 작업 공간 상대 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집하는 경우 패치 경로는 셸 `workdir`이 아니라 샌드박스 작업 공간 루트를 기준으로 한다는 점에 유의하세요. -에이전트에 워크스페이스 외부의 구체적인 절대 경로가 필요하거나, 매니페스트가 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 하는 경우에만 `extra_path_grants`를 사용하세요. 예를 들어 임시 도구 출력을 위한 `/tmp`, 읽기 전용 런타임을 위한 `/opt/toolchain`, 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리 등이 있습니다. 백엔드에서 파일 시스템 정책을 적용할 수 있는 경우 권한 부여는 로컬 소스 구체화, SDK 파일 API, 셸 실행에 적용됩니다. +에이전트가 작업 공간 외부의 구체적인 절대 경로를 필요로 하거나 매니페스트가 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 하는 경우에만 `extra_path_grants`를 사용하세요. 예를 들어 임시 도구 출력용 `/tmp`, 읽기 전용 런타임용 `/opt/toolchain`, 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리가 있습니다. 백엔드에서 파일 시스템 정책을 적용할 수 있는 경우 권한 부여는 로컬 소스 구체화, SDK 파일 API 및 셸 실행에 적용됩니다. ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,15 +254,17 @@ manifest = Manifest( ) ``` -`extra_path_grants`가 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션이 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. +Docker가 컨테이너 내부의 절대 POSIX `path`에 다른 절대 호스트 경로를 바인드 마운트해야 하는 경우 `host_path`를 설정하세요. `UnixLocalSandboxClient`는 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path`를 거부합니다. 샌드박스가 수정해서는 안 되는 호스트 데이터에는 `read_only=True`를 사용하고, 복사본으로 충분하다면 `LocalFile` 또는 `LocalDir`을 사용하세요. -스냅샷과 `persist_workspace()`에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스이며, 영구 워크스페이스 상태가 아닙니다. +`extra_path_grants`가 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. + +스냅샷과 `persist_workspace()`에는 여전히 작업 공간 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스이며, 영구적인 작업 공간 상태가 아닙니다. ### 권한 -`Permissions`는 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스에서 구체화하는 파일에 관한 것이며, 모델 권한, 승인 정책 또는 API 자격 증명과는 관련이 없습니다. +`Permissions`는 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스가 구체화하는 파일에 관한 것으로, 모델 권한, 승인 정책 또는 API 자격 증명과는 관련이 없습니다. -기본적으로 매니페스트 항목은 소유자가 읽고 쓰고 실행할 수 있으며, 그룹과 기타 사용자는 읽고 실행할 수 있습니다. 스테이징된 파일을 비공개, 읽기 전용 또는 실행 가능 상태로 지정해야 한다면 이를 재정의하세요. +기본적으로 매니페스트 항목은 소유자가 읽기/쓰기/실행할 수 있고 그룹 및 기타 사용자가 읽기/실행할 수 있습니다. 스테이징된 파일이 비공개, 읽기 전용 또는 실행 가능해야 할 때 이 설정을 재정의하세요. ```python from agents.sandbox import FileMode, Permissions @@ -278,9 +280,9 @@ private_notes = File( ) ``` -`Permissions`는 항목이 디렉터리인지 여부와 함께 소유자, 그룹, 기타 사용자의 비트를 각각 저장합니다. 직접 구성하거나, `Permissions.from_str(...)`로 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)`로 OS 모드에서 파생할 수 있습니다. +`Permissions`는 소유자, 그룹 및 기타 사용자 비트를 각각 저장하며, 해당 항목이 디렉터리인지 여부도 저장합니다. 직접 생성하거나, `Permissions.from_str(...)`을 사용해 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)`를 사용해 OS 모드에서 파생할 수 있습니다. -사용자는 샌드박스에서 작업을 실행할 수 있는 ID입니다. 샌드박스에 특정 ID가 존재해야 한다면 매니페스트에 `User`를 추가하고, 셸 명령, 파일 읽기, 패치 같은 모델에 노출되는 샌드박스 도구가 해당 사용자로 실행되어야 한다면 `SandboxAgent.run_as`를 설정하세요. `run_as`가 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 유효 매니페스트에 자동으로 추가합니다. +사용자는 작업을 실행할 수 있는 샌드박스 ID입니다. 해당 ID가 샌드박스에 존재하도록 하려면 매니페스트에 `User`를 추가하고, 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구를 해당 사용자로 실행해야 한다면 `SandboxAgent.run_as`를 설정하세요. `run_as`가 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 실질적인 매니페스트에 자동으로 추가합니다. ```python from agents import Runner @@ -332,13 +334,13 @@ result = await Runner.run( ) ``` -파일 수준 공유 규칙도 필요하다면 사용자를 매니페스트 그룹 및 항목의 `group` 메타데이터와 결합하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하며, `Permissions`는 샌드박스가 워크스페이스를 구체화한 후 해당 사용자가 어떤 파일을 읽고, 쓰고, 실행할 수 있는지 제어합니다. +파일 수준의 공유 규칙도 필요하다면 사용자와 매니페스트 그룹 및 항목의 `group` 메타데이터를 함께 사용하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하며, `Permissions`는 샌드박스가 작업 공간을 구체화한 후 해당 사용자가 어떤 파일을 읽고, 쓰고, 실행할 수 있는지 제어합니다. ### SnapshotSpec -`SnapshotSpec`은 새 샌드박스 세션에서 저장된 워크스페이스 콘텐츠를 복원할 위치와 다시 영구 저장할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state`는 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. +`SnapshotSpec`은 새 샌드박스 세션에 저장된 작업 공간 콘텐츠를 복원할 위치와 다시 영구 저장할 위치를 지정합니다. 이는 샌드박스 작업 공간의 스냅샷 정책이며, `session_state`는 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. -로컬 영구 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 앱에서 원격 스냅샷 클라이언트를 제공하는 경우 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷 설정을 사용할 수 없으면 대체 수단으로 아무 작업도 하지 않는 스냅샷이 사용되며, 워크스페이스 스냅샷 영속성을 원하지 않는 고급 호출자는 이를 명시적으로 사용할 수도 있습니다. +로컬 영구 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 애플리케이션에서 원격 스냅샷 클라이언트를 제공하는 경우 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷을 설정할 수 없을 때는 무작업 스냅샷을 대체 수단으로 사용하며, 작업 공간 스냅샷을 영구 저장하지 않으려는 고급 호출자는 이를 명시적으로 사용할 수도 있습니다. ```python from pathlib import Path @@ -355,13 +357,13 @@ run_config = RunConfig( ) ``` -러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작 시 스냅샷을 복원할 수 있다면 실행이 계속되기 전에 샌드박스가 저장된 워크스페이스 콘텐츠를 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 워크스페이스를 아카이브하고 스냅샷을 통해 다시 영구 저장합니다. +러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작 시 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 샌드박스가 저장된 작업 공간 콘텐츠를 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 작업 공간을 보관하고 스냅샷을 통해 다시 영구 저장합니다. -`snapshot`을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 설정할 수 없다면 아무 작업도 하지 않는 스냅샷을 대신 사용합니다. 마운트된 경로와 임시 경로는 영구 워크스페이스 콘텐츠로 스냅샷에 복사되지 않습니다. +`snapshot`을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 시도합니다. 이를 설정할 수 없으면 무작업 스냅샷으로 대체합니다. 마운트된 경로와 임시 경로는 영구 작업 공간 콘텐츠로 스냅샷에 복사되지 않습니다. ### 샌드박스 수명 주기 -수명 주기에는 **SDK 소유**와 **개발자 소유**라는 두 가지 모드가 있습니다. +수명 주기 모드는 **SDK 소유**와 **개발자 소유** 두 가지입니다.
@@ -389,7 +391,7 @@ sequenceDiagram
-샌드박스가 한 번의 실행 동안만 유지되어도 된다면 SDK 소유 수명 주기를 사용하세요. `client`, 선택적 `manifest`, 선택적 `snapshot`, 클라이언트 `options`를 전달하면 러너가 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 영구 저장하고, 샌드박스를 종료한 다음, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. +샌드박스가 한 번의 실행 동안만 유지되면 되는 경우 SDK 소유 수명 주기를 사용하세요. `client`, 선택적 `manifest`, 선택적 `snapshot` 및 클라이언트 `options`를 전달하면 러너가 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 작업 공간 상태를 영구 저장하고, 샌드박스를 종료한 후 클라이언트가 러너 소유 리소스를 정리하도록 합니다. ```python result = await Runner.run( @@ -401,7 +403,7 @@ result = await Runner.run( ) ``` -샌드박스를 미리 생성하거나, 여러 실행에서 하나의 실제 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스를 통해 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너가 해당 실제 샌드박스를 사용하지만 대신 닫지는 않습니다. +샌드박스를 미리 생성하거나, 여러 실행에서 하나의 활성 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너가 해당 활성 샌드박스를 사용하지만 대신 닫지는 않습니다. ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -412,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -일반적으로 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 앱에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. +일반적으로는 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 애플리케이션에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. ```python sandbox = await client.create( @@ -433,32 +435,32 @@ finally: await sandbox.aclose() ``` -`stop()`은 스냅샷 기반 워크스페이스 콘텐츠만 영구 저장하며 샌드박스를 해제하지 않습니다. `aclose()`는 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. +`stop()`은 스냅샷 기반 작업 공간 콘텐츠만 영구 저장하며 샌드박스를 해제하지 않습니다. `aclose()`는 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. ## `SandboxRunConfig` 옵션 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 샌드박스 세션을 가져오는 위치와 새 세션을 초기화하는 방식을 결정하는 실행별 옵션을 보유합니다. +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 샌드박스 세션의 출처와 새 세션 초기화 방법을 결정하는 실행별 옵션을 보유합니다. ### 샌드박스 소스 -다음 옵션은 러너가 샌드박스 세션을 재사용, 재개 또는 생성할지 결정합니다. +다음 옵션은 러너가 샌드박스 세션을 재사용, 재개 또는 생성해야 하는지 결정합니다.
-| 옵션 | 사용 시점 | 참고 | +| 옵션 | 사용 시점 | 참고 사항 | | --- | --- | --- | -| `client` | 러너가 샌드박스 세션을 생성, 재개, 정리하도록 하려는 경우 | 실제 샌드박스 `session`을 제공하지 않는 한 필수입니다. | -| `session` | 실제 샌드박스 세션을 이미 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 실제 샌드박스 세션을 재사용합니다. | -| `session_state` | 직렬화된 샌드박스 세션 상태는 있지만 실제 샌드박스 세션 객체는 없는 경우 | `client`가 필요하며, 러너는 해당 명시적 상태에서 소유 세션으로 재개합니다. | +| `client` | 러너가 샌드박스 세션을 생성, 재개 및 정리하도록 하려는 경우 | 활성 샌드박스 `session`을 제공하지 않는 한 필수입니다. | +| `session` | 활성 샌드박스 세션을 이미 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 활성 샌드박스 세션을 재사용합니다. | +| `session_state` | 직렬화된 샌드박스 세션 상태는 있지만 활성 샌드박스 세션 객체는 없는 경우 | `client`가 필요하며, 러너는 명시된 상태에서 소유 세션으로 재개합니다. |
-실제로 러너는 다음 순서로 샌드박스 세션을 결정합니다. +실제로 러너는 다음 순서로 샌드박스 세션을 확인합니다. -1. `run_config.sandbox.session`을 주입하면 해당 실제 샌드박스 세션을 직접 재사용합니다. -2. 그렇지 않고 `RunState`에서 실행을 재개하는 경우 저장된 샌드박스 세션 상태를 재개합니다. -3. 그렇지 않고 `run_config.sandbox.session_state`를 전달하면 해당 명시적인 직렬화된 샌드박스 세션 상태에서 재개합니다. -4. 그렇지 않으면 러너가 새 샌드박스 세션을 생성합니다. 새 세션에서는 제공된 경우 `run_config.sandbox.manifest`를 사용하고, 그렇지 않으면 `agent.default_manifest`를 사용합니다. +1. `run_config.sandbox.session`을 주입하면 해당 활성 샌드박스 세션을 직접 재사용합니다. +2. 그렇지 않고 실행이 `RunState`에서 재개되는 경우 저장된 샌드박스 세션 상태를 재개합니다. +3. 그렇지 않고 `run_config.sandbox.session_state`를 전달하면 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. +4. 그렇지 않으면 러너가 새 샌드박스 세션을 생성합니다. 새 세션에는 제공된 경우 `run_config.sandbox.manifest`를 사용하고, 제공되지 않으면 `agent.default_manifest`를 사용합니다. ### 새 세션 입력 @@ -466,31 +468,31 @@ finally:
-| 옵션 | 사용 시점 | 참고 | +| 옵션 | 사용 시점 | 참고 사항 | | --- | --- | --- | -| `manifest` | 일회성 새 세션 워크스페이스 재정의가 필요한 경우 | 생략하면 `agent.default_manifest`를 사용합니다. | -| `snapshot` | 새 샌드박스 세션을 스냅샷에서 초기화해야 하는 경우 | 재개와 유사한 흐름이나 원격 스냅샷 클라이언트에 유용합니다. | -| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 유사한 클라이언트별 설정에 자주 사용됩니다. | +| `manifest` | 새 세션 작업 공간을 일회성으로 재정의하려는 경우 | 생략하면 `agent.default_manifest`로 대체됩니다. | +| `snapshot` | 스냅샷을 기반으로 새 샌드박스 세션을 시작해야 하는 경우 | 재개와 유사한 흐름 또는 원격 스냅샷 클라이언트에 유용합니다. | +| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 유사한 클라이언트별 설정에서 흔히 사용됩니다. |
### 구체화 제어 -`concurrency_limits`는 동시에 실행할 수 있는 샌드박스 구체화 작업량을 제어합니다. 대규모 매니페스트나 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요하다면 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`를 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None`으로 설정하세요. +`concurrency_limits`는 병렬로 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요한 경우 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`를 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None`으로 설정하세요. -`archive_limits`는 아카이브 추출에 대한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임곗값을 활성화하려면 `archive_limits=SandboxArchiveLimits()`를 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요하면 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한이 없는 기본 동작을 유지하려면 `archive_limits=None`으로 두고, 특정 제한만 비활성화하려면 개별 필드를 `None`으로 설정하세요. +`archive_limits`는 아카이브 추출에 대한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임계값을 활성화하려면 `archive_limits=SandboxArchiveLimits()`를 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요하면 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한이 없는 기본 동작을 유지하려면 `archive_limits=None`으로 두고, 개별 제한만 비활성화하려면 해당 필드를 `None`으로 설정하세요. -다음과 같은 사항에 유의해야 합니다. +다음과 같은 몇 가지 사항에 유의해야 합니다. -- 새 세션: `manifest=`와 `snapshot=`은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. -- 재개와 스냅샷의 차이: `session_state=`는 이전에 직렬화된 샌드박스 상태에 다시 연결하지만, `snapshot=`은 저장된 워크스페이스 콘텐츠에서 새 샌드박스 세션을 초기화합니다. -- 클라이언트별 옵션: `options=`는 샌드박스 클라이언트에 따라 달라지며, Docker와 다수의 호스티드 클라이언트에서 필수입니다. -- 주입된 실제 세션: 실행 중인 샌드박스 `session`을 전달하면 기능이 주도하는 매니페스트 업데이트로 호환되는 비마운트 항목을 추가할 수 있습니다. 그러나 `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups`를 변경하거나, 기존 항목을 제거하거나, 항목 유형을 대체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. -- 러너 API: `SandboxAgent` 실행도 일반적인 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API를 사용합니다. +- 새 세션: `manifest=` 및 `snapshot=`은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. +- 재개와 스냅샷의 차이: `session_state=`는 이전에 직렬화된 샌드박스 상태에 다시 연결하는 반면, `snapshot=`은 저장된 작업 공간 콘텐츠를 기반으로 새 샌드박스 세션을 시작합니다. +- 클라이언트별 옵션: `options=`는 샌드박스 클라이언트에 따라 달라집니다. Docker 및 많은 호스티드 클라이언트에서 필수입니다. +- 주입된 활성 세션: 실행 중인 샌드박스 `session`을 전달하면 기능 기반 매니페스트 업데이트를 통해 호환되는 비마운트 항목을 추가할 수 있습니다. 하지만 `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups`를 변경하거나, 기존 항목을 제거하거나, 항목 유형을 교체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. +- 러너 API: `SandboxAgent` 실행은 계속 일반적인 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API를 사용합니다. ## 전체 예제: 코딩 작업 -다음 코딩 스타일 예제는 기본 출발점으로 적합합니다. +다음 코딩 스타일 예제는 기본 시작점으로 적합합니다. ```python import asyncio @@ -569,17 +571,17 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제는 Unix 로컬 실행 전반에서 결정론적으로 검증할 수 있도록 소규모 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 기타 어떤 언어로 작성되어도 됩니다. +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참조하세요. 이 예제는 Unix 로컬 실행에서 결정론적으로 검증할 수 있도록 간단한 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 어떤 언어로도 구성할 수 있습니다. -## 일반적인 패턴 +## 일반 패턴 -위의 전체 예제에서 시작하세요. 대부분의 경우 동일한 `SandboxAgent`를 그대로 유지하면서 샌드박스 클라이언트, 샌드박스 세션 소스 또는 워크스페이스 소스만 변경할 수 있습니다. +위의 전체 예제에서 시작하세요. 많은 경우 샌드박스 클라이언트, 샌드박스 세션 소스 또는 작업 공간 소스만 변경하면서 동일한 `SandboxAgent`를 그대로 유지할 수 있습니다. ### 샌드박스 클라이언트 전환 -에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 일관성이 필요하면 Docker를 사용하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자를 사용하세요. 예제와 공급자 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. +에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리 또는 이미지 일관성이 필요하면 Docker를 사용하고, 제공자가 관리하는 실행을 원하면 호스티드 제공자를 사용하세요. 예제와 제공자 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. -### 워크스페이스 재정의 +### 작업 공간 재정의 에이전트 정의는 그대로 유지하고 새 세션 매니페스트만 교체하세요. @@ -601,11 +603,11 @@ run_config = RunConfig( ) ``` -에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 서로 다른 저장소, 패킷 또는 작업 번들에 적용하려면 이 방식을 사용하세요. 위에서 검증한 코딩 예제는 일회성 재정의 대신 `default_manifest`를 사용하여 동일한 패턴을 보여줍니다. +에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 서로 다른 저장소, 문서 묶음 또는 작업 번들에 실행하려면 이 방식을 사용하세요. 위의 검증된 코딩 예제에서는 일회성 재정의 대신 `default_manifest`를 사용해 동일한 패턴을 보여 줍니다. ### 샌드박스 세션 주입 -수명 주기를 명시적으로 제어하거나, 실행 후 검사하거나, 출력을 복사해야 한다면 실제 샌드박스 세션을 주입하세요. +수명 주기를 명시적으로 제어하거나, 실행 후 검사하거나, 출력을 복사해야 한다면 활성 샌드박스 세션을 주입하세요. ```python from agents import Runner @@ -626,11 +628,11 @@ async with sandbox: ) ``` -실행 후 워크스페이스를 검사하거나 이미 시작된 샌드박스 세션을 통해 스트리밍하려면 이 방식을 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)와 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참고하세요. +실행 후 작업 공간을 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려면 이 방식을 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 및 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. ### 세션 상태에서 재개 -`RunState` 외부에서 샌드박스 상태를 이미 직렬화했다면 러너가 해당 상태에 다시 연결하도록 하세요. +`RunState` 외부에서 샌드박스 상태를 이미 직렬화했다면 러너가 해당 상태에서 다시 연결하도록 하세요. ```python from agents.run import RunConfig @@ -647,11 +649,13 @@ run_config = RunConfig( ) ``` -샌드박스 상태를 자체 스토리지나 작업 시스템에 저장하고 있으며 `Runner`가 해당 상태에서 직접 재개하도록 하려면 이 방식을 사용하세요. 직렬화/역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참고하세요. +샌드박스 상태가 자체 스토리지나 작업 시스템에 있고 `Runner`가 해당 상태에서 직접 재개하도록 하려면 이 방식을 사용하세요. 직렬화/역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참조하세요. + +세션 상태 직렬화에서는 네이티브 `host_path` 값이 생략됩니다. 호스트 기반 권한 부여를 재개하려면 현재 신뢰할 수 있는 매니페스트를 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`를 통해 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 입력 또는 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생해서는 안 됩니다. ### 스냅샷에서 시작 -저장된 파일과 아티팩트로 새 샌드박스를 초기화합니다. +저장된 파일과 아티팩트를 기반으로 새 샌드박스를 시작하세요. ```python from pathlib import Path @@ -668,11 +672,11 @@ run_config = RunConfig( ) ``` -새 실행이 `agent.default_manifest`만 사용하는 대신 저장된 워크스페이스 콘텐츠에서 시작해야 한다면 이 방식을 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참고하세요. +새 실행이 `agent.default_manifest`만이 아니라 저장된 작업 공간 콘텐츠에서 시작해야 할 때 이 방식을 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참조하세요. ### Git에서 스킬 로드 -로컬 스킬 소스를 저장소 기반 소스로 교체합니다. +로컬 스킬 소스를 저장소 기반 소스로 교체하세요. ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -683,11 +687,11 @@ capabilities = Capabilities.default() + [ ] ``` -스킬 번들에 자체 릴리스 주기가 있거나 여러 샌드박스에서 공유해야 한다면 이 방식을 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참고하세요. +스킬 번들에 자체 릴리스 주기가 있거나 여러 샌드박스에서 공유해야 할 때 이 방식을 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참조하세요. ### 도구로 노출 -도구 에이전트는 자체 샌드박스 경계를 사용하거나 상위 실행의 실제 샌드박스를 재사용할 수 있습니다. 빠른 읽기 전용 탐색기 에이전트에는 재사용 방식이 유용합니다. 다른 샌드박스를 생성하고, 초기 콘텐츠를 채우고, 스냅샷을 만드는 비용 없이 상위 에이전트가 사용하는 정확한 워크스페이스를 검사할 수 있습니다. +도구 에이전트에는 자체 샌드박스 경계를 제공하거나 상위 실행의 활성 샌드박스를 재사용할 수 있습니다. 빠른 읽기 전용 탐색 에이전트에는 재사용이 유용합니다. 다른 샌드박스를 생성하고, 구성하고, 스냅샷으로 저장하는 비용 없이 상위 에이전트가 사용하는 정확한 작업 공간을 검사할 수 있습니다. ```python from agents import Runner @@ -769,7 +773,7 @@ async with sandbox: ) ``` -여기에서 상위 에이전트는 `coordinator`로 실행되고, 탐색기 도구 에이전트는 동일한 실제 샌드박스 세션 내에서 `explorer`로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색기가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자/그룹만 사용할 수 있으므로, 상위 에이전트는 최종 아티팩트를 작성할 수 있고 탐색기는 읽기 전용으로 유지됩니다. +여기서 상위 에이전트는 `coordinator`로 실행되고, 탐색 도구 에이전트는 동일한 활성 샌드박스 세션 내부에서 `explorer`로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색 에이전트가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자/그룹만 사용할 수 있으므로, 탐색 에이전트가 읽기 전용으로 유지되는 동안 상위 에이전트가 최종 아티팩트를 작성할 수 있습니다. 도구 에이전트에 실제 격리가 필요하다면 자체 샌드박스 `RunConfig`를 제공하세요. @@ -797,11 +801,11 @@ rollout_agent.as_tool( ) ``` -도구 에이전트가 자유롭게 변경하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드/이미지를 사용해야 한다면 별도의 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. +도구 에이전트가 자유롭게 변경 작업을 수행하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드/이미지를 사용해야 한다면 별도의 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참조하세요. -### 로컬 도구 및 MCP와 결합 +### 로컬 도구 및 MCP와의 결합 -샌드박스 워크스페이스를 유지하면서 동일한 에이전트에서 일반 도구도 사용할 수 있습니다. +샌드박스 작업 공간을 유지하면서 동일한 에이전트에서 일반 도구도 사용하세요. ```python from agents.sandbox import SandboxAgent @@ -816,46 +820,46 @@ agent = SandboxAgent( ) ``` -워크스페이스 검사가 에이전트 작업의 일부일 뿐이라면 이 방식을 사용하세요. [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)를 참고하세요. +작업 공간 검사가 에이전트 작업의 일부에 불과할 때 이 방식을 사용하세요. [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)를 참조하세요. ## 메모리 -향후 샌드박스 에이전트 실행이 이전 실행에서 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제하고, 이후 실행에서 해당 파일을 읽을 수 있도록 합니다. +향후 샌드박스 에이전트 실행이 이전 실행에서 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 메모리는 학습한 내용을 샌드박스 작업 공간 내부의 파일로 정제하며, 이후 실행에서 해당 파일을 읽을 수 있습니다. -설정, 읽기/생성 동작, 멀티턴 대화, 레이아웃 격리는 [에이전트 메모리](memory.md)를 참고하세요. +설정, 읽기/생성 동작, 멀티턴 대화 및 레이아웃 격리는 [에이전트 메모리](memory.md)를 참조하세요. ## 구성 패턴 -단일 에이전트 패턴을 이해한 다음에는 더 큰 시스템에서 샌드박스 경계를 어디에 둘지 결정해야 합니다. +단일 에이전트 패턴을 이해한 후에는 더 큰 시스템에서 샌드박스 경계를 어디에 둘 것인지 결정해야 합니다. -샌드박스 에이전트는 SDK의 나머지 기능과 계속 함께 구성할 수 있습니다. +샌드박스 에이전트는 계속 SDK의 나머지 요소와 결합할 수 있습니다. -- [핸드오프](../handoffs.md): 샌드박스를 사용하지 않는 접수 에이전트에서 문서 중심 작업을 샌드박스 검토자에게 전달합니다. +- [핸드오프](../handoffs.md): 샌드박스를 사용하지 않는 접수 에이전트에서 문서 중심 작업을 샌드박스 검토자에게 핸드오프합니다. - [Agents as tools](../tools.md#agents-as-tools): 여러 샌드박스 에이전트를 도구로 노출합니다. 일반적으로 각 `Agent.as_tool(...)` 호출에 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`를 전달하여 각 도구에 자체 샌드박스 경계를 제공합니다. -- [MCP](../mcp.md)와 일반 함수 도구: 샌드박스 기능은 `mcp_servers` 및 일반 Python 도구와 함께 사용할 수 있습니다. +- [MCP](../mcp.md) 및 일반 함수 도구: 샌드박스 기능은 `mcp_servers` 및 일반 Python 도구와 함께 사용할 수 있습니다. - [에이전트 실행](../running_agents.md): 샌드박스 실행도 일반적인 `Runner` API를 사용합니다. -특히 일반적인 두 가지 패턴은 다음과 같습니다. +특히 다음 두 가지 패턴이 일반적입니다. -- 샌드박스를 사용하지 않는 에이전트가 워크스페이스 격리가 필요한 워크플로 부분만 샌드박스 에이전트에 핸드오프 -- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하고, 일반적으로 각 `Agent.as_tool(...)` 호출에 별도의 샌드박스 `RunConfig`를 사용하여 각 도구에 자체 격리 워크스페이스 제공 +- 작업 공간 격리가 필요한 워크플로 부분에만 샌드박스를 사용하지 않는 에이전트가 샌드박스 에이전트로 핸드오프하는 패턴 +- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하고, 일반적으로 각 `Agent.as_tool(...)` 호출에 별도의 샌드박스 `RunConfig`를 사용하여 각 도구에 자체 격리 작업 공간을 제공하는 패턴 ### 턴과 샌드박스 실행 -핸드오프와 에이전트 도구 호출은 별도로 설명하는 것이 이해에 도움이 됩니다. +핸드오프와 `Agent.as_tool(...)` 호출을 구분해 설명하면 이해하기 쉽습니다. -핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프가 유지됩니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면, 동일한 실행의 다음 모델 호출이 샌드박스 에이전트용으로 준비되며 해당 샌드박스 에이전트가 다음 턴을 수행합니다. 즉, 핸드오프는 동일한 실행의 다음 턴을 담당하는 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참고하세요. +핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프가 있습니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면 동일한 실행의 다음 모델 호출이 샌드박스 에이전트용으로 준비되며, 해당 샌드박스 에이전트가 다음 턴을 수행합니다. 즉, 핸드오프는 동일한 실행의 다음 턴을 담당할 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참조하세요. -`Agent.as_tool(...)`에서는 관계가 다릅니다. 외부 오케스트레이터는 외부 턴 하나를 사용하여 도구 호출을 결정하고, 해당 도구 호출은 샌드박스 에이전트의 중첩 실행을 시작합니다. 중첩 실행에는 자체 턴 루프, `max_turns`, 승인, 일반적으로 자체 샌드박스 `RunConfig`가 있습니다. 중첩 턴 하나로 완료될 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터의 관점에서는 이 모든 작업이 하나의 도구 호출 뒤에서 이루어지므로, 중첩 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. +`Agent.as_tool(...)`에서는 관계가 다릅니다. 외부 오케스트레이터는 하나의 외부 턴을 사용해 도구 호출을 결정하고, 해당 도구 호출은 샌드박스 에이전트의 중첩 실행을 시작합니다. 중첩 실행에는 자체 턴 루프, `max_turns`, 승인 및 일반적으로 자체 샌드박스 `RunConfig`가 있습니다. 하나의 중첩 턴에서 완료될 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터의 관점에서는 이 모든 작업이 여전히 한 번의 도구 호출 뒤에서 수행되므로 중첩된 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참조하세요. 승인 동작도 동일한 구분을 따릅니다. - 핸드오프에서는 샌드박스 에이전트가 해당 실행의 활성 에이전트가 되므로 승인이 동일한 최상위 실행에 유지됩니다. -- `Agent.as_tool(...)`에서는 샌드박스 도구 에이전트 내부에서 발생한 승인도 외부 실행에 표시되지만, 저장된 중첩 실행 상태에서 가져오며 외부 실행이 재개될 때 중첩 샌드박스 실행을 재개합니다. +- `Agent.as_tool(...)`에서는 샌드박스 도구 에이전트 내부에서 발생한 승인도 외부 실행에 표시되지만, 저장된 중첩 실행 상태에서 발생하며 외부 실행이 재개될 때 중첩된 샌드박스 실행을 재개합니다. ## 추가 자료 - [빠른 시작](../sandbox_agents.md): 샌드박스 에이전트 하나를 실행합니다. -- [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드, 마운트 옵션을 선택합니다. -- [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 얻은 학습 내용을 보존하고 재사용합니다. -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 실행 가능한 로컬, 코딩, 메모리, 핸드오프, 에이전트 구성 패턴입니다. +- [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드 및 마운트 옵션을 선택합니다. +- [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 학습한 내용을 보존하고 재사용합니다. +- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 실행 가능한 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴입니다. \ No newline at end of file diff --git a/docs/zh/sandbox/clients.md b/docs/zh/sandbox/clients.md index e8002b455d..2c965962de 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -4,40 +4,42 @@ search: --- # 沙盒客户端 -使用本页选择沙盒任务应在哪里运行。大多数情况下,`SandboxAgent`定义保持不变,而沙盒客户端和客户端特定选项会在[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]中变化。 +使用本页面选择沙盒任务的运行位置。在大多数情况下,`SandboxAgent` 定义保持不变,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙盒客户端和客户端专属选项。 -!!! warning "Beta 功能" +!!! warning "Beta 测试功能" - 沙盒智能体处于 Beta 阶段。在正式发布前,API 的细节、默认值和支持的功能可能会发生变化;未来也会陆续提供更高级的功能。 + 沙盒智能体目前处于 Beta 测试阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,未来还将逐步提供更多高级功能。 ## 决策指南
-| 目标 | 起点 | 原因 | +| 目标 | 首选 | 原因 | | --- | --- | --- | -| 在 macOS 或 Linux 上进行最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于进行简单的本地文件系统开发。 | -| 基本容器隔离 | `DockerSandboxClient` | 使用特定镜像在 Docker 中运行任务。 | -| 托管执行或生产风格隔离 | 一个托管沙盒客户端 | 将工作区边界移动到由提供商管理的环境中。 | +| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于在本地文件系统上开发。 | +| 基本的容器隔离 | `DockerSandboxClient` | 使用指定镜像在 Docker 内运行任务。 | +| 托管执行或生产环境级隔离 | 托管沙盒客户端 | 将工作区边界迁移至由服务提供商管理的环境。 |
## 本地客户端 -对于大多数用户,请从以下两个沙盒客户端之一开始: +对于大多数用户,建议从以下两个沙盒客户端之一开始:
-| 客户端 | 安装 | 选择场景 | 代码示例 | +| 客户端 | 安装 | 适用场景 | 代码示例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上进行最快的本地迭代。适合作为本地开发的默认选择。 | [Unix-local 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 你需要容器隔离,或需要特定镜像来保持本地环境一致性。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上实现最快的本地迭代。适合作为本地开发的默认选择。 | [Unix 本地入门代码示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 需要容器隔离,或使用指定镜像以确保本地环境的一致性。 | [Docker 入门代码示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local 是开始基于本地文件系统进行开发的最简单方式。当你需要更强的环境隔离或生产风格的一致性时,再迁移到 Docker 或托管提供商。 +Unix 本地模式是在本地文件系统上开始开发的最简单方式。当需要更强的环境隔离或与生产环境保持一致时,可以迁移到 Docker 或托管服务提供商。 -要从 Unix-local 切换到 Docker,请保持智能体定义不变,只更改运行配置: +`SandboxPathGrant.host_path` 仅适用于 Docker,用于将主机路径映射到容器内的另一个 POSIX 路径。Unix 本地模式仅支持相同路径的授权。有关详细信息,请参阅[清单路径授权](guide.md#manifest)。 + +要从 Unix 本地模式切换到 Docker,请保持智能体定义不变,仅更改运行配置: ```python from docker import from_env as docker_from_env @@ -54,41 +56,41 @@ run_config = RunConfig( ) ``` -当你需要容器隔离或镜像一致性时使用此方式。参见[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 +当需要容器隔离或镜像一致性时,请使用此方式。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 ## 挂载与远程存储 -挂载条目描述要暴露哪些存储;挂载策略描述沙盒后端如何附加该存储。请从`agents.sandbox.entries`导入内置挂载条目和通用策略。托管提供商策略可从`agents.extensions.sandbox`或提供商特定的扩展包获得。 +挂载条目用于描述要公开的存储;挂载策略用于描述沙盒后端如何连接该存储。可从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管服务提供商的策略可从 `agents.extensions.sandbox` 或服务提供商专属扩展包中获取。 -常见挂载选项: +常用挂载选项: -- `mount_path`: 存储在沙盒中的显示位置。相对路径会在清单根目录下解析;绝对路径按原样使用。 -- `read_only`: 默认值为`True`。仅当沙盒需要写回挂载的存储时,才设置为`False`。 -- `mount_strategy`: 必填。使用同时匹配挂载条目和沙盒后端的策略。 +- `mount_path`:存储在沙盒中的显示位置。相对路径基于清单根目录解析;绝对路径则按原样使用。 +- `read_only`:默认为 `True`。仅当沙盒需要将内容写回已挂载存储时,才将其设置为 `False`。 +- `mount_strategy`:必填。应使用同时兼容挂载条目和沙盒后端的策略。 -挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不是将已挂载的远程存储复制到保存的工作区中。 +挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不会将已挂载的远程存储复制到保存的工作区中。 -通用本地/容器策略: +通用本地和容器策略:
-| 策略或模式 | 适用场景 | 说明 | +| 策略或模式 | 适用场景 | 备注 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙盒镜像可以运行`rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern`可以在`fuse`模式或`nfs`模式下运行。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像包含`mount-s3`,并且你需要 Mountpoint 风格的 S3 或 S3 兼容访问。 | 支持`S3Mount`和`GCSMount`。 | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像包含`blobfuse2`并支持 FUSE。 | 支持`AzureBlobMount`。 | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像包含`mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持`S3FilesMount`。 | -| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前附加由卷驱动支持的挂载。 | 仅限 Docker。S3、GCS、R2、Azure Blob 和 Box 支持`rclone`;S3 和 GCS 还支持`mountpoint`。 | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙盒镜像能够运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可以在 `fuse` 模式或 `nfs` 模式下运行。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像包含 `mount-s3`,并且需要以 Mountpoint 方式访问 S3 或 S3 兼容存储。 | 支持 `S3Mount` 和 `GCSMount`。 | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像包含 `blobfuse2` 并支持 FUSE。 | 支持 `AzureBlobMount`。 | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像包含 `mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | +| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前连接由卷驱动支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 支持 `rclone`;S3 和 GCS 还支持 `mountpoint`。 |
## 支持的托管平台 -当你需要托管环境时,通常可以沿用相同的`SandboxAgent`定义,只在[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]中更改沙盒客户端。 +当需要托管环境时,通常可以继续使用相同的 `SandboxAgent` 定义,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙盒客户端。 -如果你使用的是已发布的 SDK,而不是此仓库的检出版本,请通过匹配的软件包 extra 安装沙盒客户端依赖。 +如果使用已发布的 SDK,而不是此代码仓库的检出版本,请通过对应的软件包额外依赖安装沙盒客户端依赖项。 -有关提供商特定的设置说明,以及仓库中已提交的扩展代码示例链接,请参见[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。 +有关特定服务提供商的设置说明,以及代码仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。
@@ -104,24 +106,24 @@ run_config = RunConfig(
-托管沙盒客户端会提供特定于提供商的挂载策略。请选择最适合你的存储提供商的后端和挂载策略: +托管沙盒客户端会提供服务提供商专属的挂载策略。请选择最适合相应存储服务提供商的后端和挂载策略:
| 后端 | 挂载说明 | | --- | --- | -| Docker | 支持将`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`和`S3FilesMount`与`InContainerMountStrategy`、`DockerVolumeMountStrategy`等本地策略配合使用。 | -| `ModalSandboxClient` | 支持在`S3Mount`、`R2Mount`和经过 HMAC 认证的`GCSMount`上使用`ModalCloudBucketMountStrategy`进行 Modal 云存储桶挂载。你可以使用内联凭据或命名的 Modal Secret。 | -| `CloudflareSandboxClient` | 支持在`S3Mount`、`R2Mount`和经过 HMAC 认证的`GCSMount`上使用`CloudflareBucketMountStrategy`进行 Cloudflare 存储桶挂载。 | -| `BlaxelSandboxClient` | 支持在`S3Mount`、`R2Mount`和`GCSMount`上使用`BlaxelCloudBucketMountStrategy`进行云存储桶挂载。还支持使用来自`agents.extensions.sandbox.blaxel`的`BlaxelDriveMount`和`BlaxelDriveMountStrategy`实现持久化 Blaxel Drives。 | -| `DaytonaSandboxClient` | 支持通过`DaytonaCloudBucketMountStrategy`进行由 rclone 支持的云存储挂载;可将其与`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`和`BoxMount`配合使用。 | -| `E2BSandboxClient` | 支持通过`E2BCloudBucketMountStrategy`进行由 rclone 支持的云存储挂载;可将其与`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`和`BoxMount`配合使用。 | -| `RunloopSandboxClient` | 支持通过`RunloopCloudBucketMountStrategy`进行由 rclone 支持的云存储挂载;可将其与`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`和`BoxMount`配合使用。 | -| `VercelSandboxClient` | 支持通过 `VercelCloudBucketMountStrategy` 和 `S3Mount` 创建仅在沙箱创建时配置的 S3 及 S3 兼容存储桶挂载。包含挂载的会话无法恢复,使用内联凭证时必须设置 `allow_s3_credential_exposure=True`。 | +| Docker | 支持通过 `InContainerMountStrategy` 和 `DockerVolumeMountStrategy` 等本地策略挂载 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` 和 `S3FilesMount`。 | +| `ModalSandboxClient` | 支持通过 `ModalCloudBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和使用 HMAC 身份验证的 `GCSMount` 上挂载 Modal 云存储桶。可以使用内联凭据或具名 Modal Secret。 | +| `CloudflareSandboxClient` | 支持通过 `CloudflareBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和使用 HMAC 身份验证的 `GCSMount` 上挂载 Cloudflare 存储桶。 | +| `BlaxelSandboxClient` | 支持通过 `BlaxelCloudBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和 `GCSMount` 上挂载云存储桶。还支持使用 `agents.extensions.sandbox.blaxel` 中的 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 挂载持久化 Blaxel Drive。 | +| `DaytonaSandboxClient` | 支持通过 `DaytonaCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `E2BSandboxClient` | 支持通过 `E2BCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `RunloopSandboxClient` | 支持通过 `RunloopCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `VercelSandboxClient` | 支持通过 `VercelCloudBucketMountStrategy`,在 `S3Mount` 上挂载仅能在创建时配置的 S3 和 S3 兼容存储桶;已挂载存储的会话无法恢复,并且使用内联凭据时必须设置 `allow_s3_credential_exposure=True`。 |
-下表总结了每个后端可以直接挂载哪些远程存储条目。 +下表汇总了每个后端可以直接挂载的远程存储条目。
@@ -138,4 +140,4 @@ run_config = RunConfig(
-如需更多可运行代码示例,请浏览[examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),了解本地、代码编写、记忆、任务转移和智能体组合模式;并浏览[examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions),了解托管沙盒客户端。 +如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),了解本地运行、编码、记忆、任务转移和智能体组合模式;还可浏览 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions),查看托管沙盒客户端。 \ No newline at end of file diff --git a/docs/zh/sandbox/guide.md b/docs/zh/sandbox/guide.md index 4e242a3d50..a7c853b3fa 100644 --- a/docs/zh/sandbox/guide.md +++ b/docs/zh/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "Beta 功能" - 沙盒智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认值和支持的功能可能会发生变化,未来也将提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认设置和支持的功能可能会发生变化,并且随着时间推移还会提供更多高级功能。 -现代智能体在能够操作文件系统中的真实文件时效果最佳。**沙盒智能体**可以使用专用工具和 shell 命令搜索及处理大型文档集、编辑文件、生成产物并运行命令。沙盒为模型提供了一个持久工作区,智能体可以在其中代您完成工作。Agents SDK 中的沙盒智能体可帮助您轻松运行与沙盒环境配套的智能体,便于将正确的文件放入文件系统并编排沙盒,从而轻松地大规模启动、停止和恢复任务。 +现代智能体在能够操作文件系统中的真实文件时效果最佳。**沙箱智能体**可以利用专用工具和 shell 命令检索和处理大型文档集、编辑文件、生成工件并运行命令。沙箱为模型提供持久工作区,智能体可在其中代表您完成工作。Agents SDK 中的沙箱智能体可帮助您轻松运行与沙箱环境配套的智能体,方便在文件系统中准备所需文件,并编排沙箱,从而轻松地大规模启动、停止和恢复任务。 -您可以围绕智能体所需的数据定义工作区。工作区可以从 GitHub 仓库、本地文件和目录、合成任务文件、S3 或 Azure Blob Storage 等远程文件系统,以及您提供的其他沙盒输入开始构建。 +您可以围绕智能体所需的数据定义工作区。工作区可以从 GitHub 仓库、本地文件和目录、合成任务文件、S3 或 Azure Blob Storage 等远程文件系统,以及您提供的其他沙箱输入开始构建。
-![带计算环境的沙盒智能体框架](../assets/images/harness_with_compute.png) +![带计算环境的沙箱智能体执行框架](../assets/images/harness_with_compute.png)
-`SandboxAgent` 仍然是一个 `Agent`。它保留了常规的智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规的 `Runner` API 运行。变化之处在于执行边界: +`SandboxAgent` 仍然是一个 `Agent`。它保留了常规智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规的 `Runner` API 运行。变化在于执行边界: -- `SandboxAgent` 定义智能体本身:包括常规智能体配置,以及 `default_manifest`、`base_instructions`、`run_as` 等沙盒专用默认值,还有文件系统工具、shell 访问、技能、记忆或压缩等能力。 -- `Manifest` 声明全新沙盒工作区所需的初始内容和布局,包括文件、仓库、挂载和环境。 -- 沙盒会话是命令运行和文件发生变化的实时隔离环境。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定一次运行如何获得该沙盒会话,例如直接注入会话、根据序列化的沙盒会话状态重新连接,或通过沙盒客户端创建全新的沙盒会话。 -- 保存的沙盒状态和快照允许后续运行重新连接到之前的工作,或使用保存的内容初始化全新的沙盒会话。 +- `SandboxAgent` 定义智能体本身:包括常规智能体配置,以及 `default_manifest`、`base_instructions`、`run_as` 等沙箱专用默认设置,还有文件系统工具、shell 访问、技能、记忆或压缩等能力。 +- `Manifest` 声明新沙箱工作区所需的初始内容和布局,包括文件、仓库、挂载和环境。 +- 沙箱会话是运行命令和修改文件的实时隔离环境。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定运行如何获得该沙箱会话,例如直接注入会话、从已序列化的沙箱会话状态重新连接,或通过沙箱客户端创建新的沙箱会话。 +- 保存的沙箱状态和快照让后续运行能够重新连接到先前的工作,或使用已保存的内容初始化新的沙箱会话。 -`Manifest` 是全新会话的工作区契约,而不是每个实时沙盒全部状态的唯一事实来源。一次运行的有效工作区也可能来自复用的沙盒会话、序列化的沙盒会话状态,或运行时选择的快照。 +`Manifest` 是新会话的工作区约定,而不是每个实时沙箱的完整事实来源。一次运行的实际工作区也可以来自复用的沙箱会话、已序列化的沙箱会话状态,或运行时选择的快照。 -在本页中,“沙盒会话”是指由沙盒客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中介绍的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 +在本页中,“沙箱会话”是指由沙箱客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中所述的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 -外层运行时仍负责审批、追踪、任务转移和恢复记录。沙盒会话负责命令、文件变更和环境隔离。这种职责划分是该模型的核心组成部分。 +外层运行时仍负责审批、追踪、任务转移和恢复记录。沙箱会话负责命令、文件变更和环境隔离。这种职责划分是该模型的核心组成部分。 -### 组件协作方式 +### 各组成部分的协作方式 -一次沙盒运行会将智能体定义与每次运行的沙盒配置结合起来。运行器会准备智能体,将其绑定到实时沙盒会话,并可保存状态供后续运行使用。 +沙箱运行将智能体定义与每次运行的沙箱配置结合起来。运行器会准备智能体,将其绑定到实时沙箱会话,并可保存状态以供后续运行使用。 ```mermaid flowchart LR @@ -50,138 +50,138 @@ flowchart LR sandbox --> saved ``` -沙盒专用默认值保留在 `SandboxAgent` 上。每次运行的沙盒会话选项则保留在 `SandboxRunConfig` 中。 +沙箱专用默认设置保留在 `SandboxAgent` 上。每次运行的沙箱会话选项保留在 `SandboxRunConfig` 中。 可以将生命周期分为三个阶段: -1. 使用 `SandboxAgent`、`Manifest` 和能力定义智能体及全新工作区契约。 -2. 通过向 `Runner` 提供可注入、恢复或创建沙盒会话的 `SandboxRunConfig` 来执行一次运行。 -3. 稍后从运行器管理的 `RunState`、显式沙盒 `session_state` 或保存的工作区快照继续运行。 +1. 使用 `SandboxAgent`、`Manifest` 和能力定义智能体以及新工作区约定。 +2. 通过向 `Runner` 提供 `SandboxRunConfig` 来执行运行,由其注入、恢复或创建沙箱会话。 +3. 稍后从运行器管理的 `RunState`、显式沙箱 `session_state` 或已保存的工作区快照继续运行。 -如果 shell 访问只是偶尔使用的工具,请从[工具指南](../tools.md)中的托管 shell 开始。当工作区隔离、沙盒客户端选择或沙盒会话恢复行为属于整体设计的一部分时,再使用沙盒智能体。 +如果只是偶尔需要将 shell 访问作为一种工具,请从[工具指南](../tools.md)中的托管 shell 开始。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,请使用沙箱智能体。 ## 适用场景 -沙盒智能体非常适合以工作区为中心的工作流,例如: +沙箱智能体非常适合以工作区为中心的工作流,例如: - 编码和调试,例如针对 GitHub 仓库中的问题报告编排自动修复并运行针对性测试 -- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完成的税务表单草稿 -- 基于文件的审查或分析,例如在回答前检查入职资料包、生成的报告或产物包 -- 隔离式多智能体模式,例如为每个审查智能体或编码子智能体提供独立工作区 -- 多步骤工作区任务,例如在一次运行中修复错误,稍后再添加回归测试,或从快照或沙盒会话状态恢复 +- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完成的税表草稿 +- 基于文件的审核或分析,例如在回答前检查入职资料包、生成的报告或工件包 +- 隔离的多智能体模式,例如为每个审核智能体或编码子智能体提供独立工作区 +- 多步骤工作区任务,例如在一次运行中修复错误,之后再添加回归测试,或从快照或沙箱会话状态恢复 -如果不需要访问文件或持续存在的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔使用的能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙盒智能体。 +如果不需要访问文件或持续存在的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔使用的一项能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 -## 沙盒客户端的选择 +## 沙箱客户端的选择 -在 macOS 或 Linux 上进行本地开发时,首先使用 `UnixLocalSandboxClient`。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,当需要容器隔离或镜像一致性时,请改用 `DockerSandboxClient`;当需要由提供商管理执行环境时,请改用托管提供商。 +在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,如果需要容器隔离或镜像一致性,请转用 `DockerSandboxClient`;如果需要由提供商管理执行,请转用托管提供商。 -大多数情况下,`SandboxAgent` 定义保持不变,只需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙盒客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙盒客户端](clients.md)。 +大多数情况下,`SandboxAgent` 定义保持不变,只需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙箱客户端](clients.md)。 -## 核心组件 +## 核心组成部分
-| 层级 | 主要 SDK 组件 | 解答的问题 | +| 层级 | 主要 SDK 组成部分 | 解答的问题 | | --- | --- | --- | -| 智能体定义 | `SandboxAgent`、`Manifest`、能力 | 将运行哪个智能体,以及它应从什么样的全新会话工作区契约开始? | -| 沙盒执行 | `SandboxRunConfig`、沙盒客户端和实时沙盒会话 | 此次运行如何获得实时沙盒会话,以及工作在哪里执行? | -| 保存的沙盒状态 | `RunState` 沙盒有效负载、`session_state` 和快照 | 此工作流如何重新连接到之前的沙盒工作,或根据保存的内容初始化全新的沙盒会话? | +| 智能体定义 | `SandboxAgent`、`Manifest`、能力 | 将运行什么智能体,它应从什么新会话工作区约定开始? | +| 沙箱执行 | `SandboxRunConfig`、沙箱客户端和实时沙箱会话 | 本次运行如何获得实时沙箱会话,工作在哪里执行? | +| 保存的沙箱状态 | `RunState` 沙箱载荷、`session_state` 和快照 | 此工作流如何重新连接到先前的沙箱工作,或使用已保存的内容初始化新的沙箱会话? |
-主要 SDK 组件与这些层级的对应关系如下: +主要 SDK 组成部分与这些层级的对应关系如下:
-| 组件 | 负责的内容 | 应提出的问题 | +| 组成部分 | 负责的内容 | 应提出的问题 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应执行什么任务,以及哪些默认值应随其一同使用? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 全新会话的工作区文件和文件夹 | 运行开始时,文件系统中应存在哪些文件和文件夹? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 沙盒原生行为 | 哪些工具、指令片段或运行时行为应附加到此智能体? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 每次运行的沙盒客户端和沙盒会话来源 | 此次运行应注入、恢复还是创建沙盒会话? | -| [`RunState`][agents.run_state.RunState] | 由运行器管理的已保存沙盒状态 | 我是否正在恢复之前由运行器管理的工作流,并自动延续其沙盒状态? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙盒会话状态 | 我是否希望从已在 `RunState` 外部序列化的沙盒状态恢复? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于全新沙盒会话的已保存工作区内容 | 新的沙盒会话是否应从保存的文件和产物开始? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应执行什么操作,哪些默认设置应随它一起使用? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新会话工作区的文件和文件夹 | 运行开始时,文件系统中应存在哪些文件和文件夹? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 沙箱原生行为 | 应为此智能体附加哪些工具、指令片段或运行时行为? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 每次运行的沙箱客户端和沙箱会话来源 | 本次运行应注入、恢复还是创建沙箱会话? | +| [`RunState`][agents.run_state.RunState] | 运行器管理的已保存沙箱状态 | 我是否正在恢复先前由运行器管理的工作流,并自动将其沙箱状态延续下去? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙箱会话状态 | 我是否希望从已在 `RunState` 外部序列化的沙箱状态恢复? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于新沙箱会话的已保存工作区内容 | 新沙箱会话是否应从已保存的文件和工件开始? |
-实用的设计顺序如下: +实际的设计顺序如下: -1. 使用 `Manifest` 定义全新会话工作区契约。 +1. 使用 `Manifest` 定义新会话工作区约定。 2. 使用 `SandboxAgent` 定义智能体。 3. 添加内置或自定义能力。 -4. 在 `RunConfig(sandbox=SandboxRunConfig(...))` 中决定每次运行应如何获取沙盒会话。 +4. 决定每次运行应如何在 `RunConfig(sandbox=SandboxRunConfig(...))` 中获取其沙箱会话。 -## 沙盒运行的准备过程 +## 沙箱运行的准备过程 -运行时,运行器会将该定义转换为由具体沙盒支持的运行: +运行时,运行器会将该定义转换为由沙箱支持的具体运行: -1. 它会从 `SandboxRunConfig` 解析沙盒会话。如果传入 `session=...`,则复用该实时沙盒会话。否则,它会使用 `client=...` 创建或恢复会话。 -2. 它会确定此次运行的有效工作区输入。如果此次运行注入或恢复了沙盒会话,则以现有沙盒状态为准。否则,运行器将从一次性清单覆盖项或 `agent.default_manifest` 开始。这正是仅凭 `Manifest` 无法定义每次运行最终实时工作区的原因。 -3. 它会让各项能力处理生成的清单。通过这种方式,能力可以在最终智能体准备完成前添加文件、挂载或其他工作区范围的行为。 -4. 它会按固定顺序构建最终指令:SDK 的默认沙盒提示词;如果显式覆盖,则使用 `base_instructions`;随后依次加入 `instructions`、能力指令片段、所有远程挂载策略文本,最后加入渲染后的文件系统树。 -5. 它会将能力工具绑定到实时沙盒会话,并通过常规 `Runner` API 运行准备好的智能体。 +1. 它从 `SandboxRunConfig` 解析沙箱会话。如果传入 `session=...`,则复用该实时沙箱会话。否则,它使用 `client=...` 创建或恢复会话。 +2. 它确定本次运行的实际工作区输入。如果运行注入或恢复沙箱会话,则以现有沙箱状态为准。否则,运行器会从一次性清单覆盖项或 `agent.default_manifest` 开始。这就是为什么仅靠 `Manifest` 无法定义每次运行的最终实时工作区。 +3. 它让能力处理生成的清单。这样,能力就可以在最终智能体准备完成前添加文件、挂载或其他工作区范围内的行为。 +4. 它按固定顺序构建最终指令:首先是 SDK 的默认沙箱提示词,或在您显式覆盖时使用 `base_instructions`;然后是 `instructions`;接着是能力指令片段;之后是任何远程挂载策略文本;最后是渲染后的文件系统树。 +5. 它将能力工具绑定到实时沙箱会话,并通过常规 `Runner` API 运行准备好的智能体。 -沙盒不会改变一轮交互的含义。一轮仍然是一个模型步骤,而不是一条 shell 命令或一次沙盒操作。沙盒侧操作与轮次之间没有固定的 1:1 对应关系:有些工作可能始终位于沙盒执行层内,而其他操作会返回工具结果、审批或其他需要额外模型步骤的状态。作为实用原则,只有在沙盒工作完成后,智能体运行时需要模型再次响应时,才会消耗新的一轮。 +沙箱不会改变轮次的含义。一个轮次仍是一个模型步骤,而不是单条 shell 命令或单个沙箱操作。沙箱侧操作与轮次之间不存在固定的 1:1 映射:部分工作可能保留在沙箱执行层内,而其他操作则会返回工具结果、审批或其他需要额外模型步骤的状态。实际而言,只有在完成沙箱工作后,智能体运行时还需要另一个模型响应时,才会消耗额外轮次。 -这些准备步骤说明了为什么在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 是需要重点考虑的沙盒专用选项。 +这些准备步骤说明了为什么在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 是需要重点考虑的沙箱专用选项。 ## `SandboxAgent` 选项 -除常规 `Agent` 字段外,还提供以下沙盒专用选项: +除常规 `Agent` 字段之外,还提供以下沙箱专用选项:
| 选项 | 最佳用途 | | --- | --- | -| `default_manifest` | 由运行器创建的全新沙盒会话所使用的默认工作区。 | -| `instructions` | 追加在 SDK 沙盒提示词后的额外角色、工作流和成功标准。 | -| `base_instructions` | 用于替换 SDK 沙盒提示词的高级逃生舱机制。 | -| `capabilities` | 应随此智能体一同使用的沙盒原生工具和行为。 | -| `run_as` | 面向模型的沙盒工具所使用的用户身份,例如 shell 命令、文件读取和补丁操作。 | +| `default_manifest` | 运行器创建的新沙箱会话所使用的默认工作区。 | +| `instructions` | 追加在 SDK 沙箱提示词之后的额外角色、工作流和成功标准。 | +| `base_instructions` | 用于替换 SDK 沙箱提示词的高级应急选项。 | +| `capabilities` | 应随此智能体一起使用的沙箱原生工具和行为。 | +| `run_as` | 面向模型的沙箱工具所使用的用户身份,例如 shell 命令、文件读取和补丁。 |
-沙盒客户端选择、沙盒会话复用、清单覆盖和快照选择应放在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中,而不是智能体上。 +沙箱客户端选择、沙箱会话复用、清单覆盖和快照选择应放在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中,而不是智能体上。 ### `default_manifest` -`default_manifest` 是运行器为此智能体创建全新沙盒会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。它适用于智能体通常应以其为起点的文件、仓库、辅助材料、输出目录和挂载。 +`default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。可使用它指定智能体通常应在启动时具备的文件、仓库、辅助材料、输出目录和挂载。 -这只是默认值。一次运行可以使用 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙盒会话会保留其现有工作区状态。 +这只是默认设置。运行可以通过 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 ### `instructions` 和 `base_instructions` -使用 `instructions` 设置应在不同提示词之间保持不变的简短规则。在 `SandboxAgent` 中,这些指令会追加到 SDK 的沙盒基础提示词之后,因此您可以保留内置沙盒指导,并添加自己的角色、工作流和成功标准。 +对于应在不同提示词下保持不变的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会追加到 SDK 的沙箱基础提示词之后,因此您可以保留内置沙箱指导,并添加自己的角色、工作流和成功标准。 -仅当希望替换 SDK 的沙盒基础提示词时,才使用 `base_instructions`。大多数智能体不应设置它。 +仅当您希望替换 SDK 的沙箱基础提示词时,才使用 `base_instructions`。大多数智能体都不应设置它。
| 放置位置 | 用途 | 示例 | | --- | --- | --- | | `instructions` | 智能体的稳定角色、工作流规则和成功标准。 | “检查入职文档,然后进行任务转移。”、“将最终文件写入 `output/`。” | -| `base_instructions` | 完整替换 SDK 的沙盒基础提示词。 | 自定义底层沙盒包装器提示词。 | -| 用户提示词 | 此次运行的一次性请求。 | “总结此工作区。” | -| 清单中的工作区文件 | 更长的任务规范、仓库本地指令或范围受限的参考材料。 | `repo/task.md`、文档包、样本资料包。 | +| `base_instructions` | 完整替换 SDK 的沙箱基础提示词。 | 自定义底层沙箱包装器提示词。 | +| 用户提示词 | 本次运行的一次性请求。 | “总结此工作区。” | +| 清单中的工作区文件 | 较长的任务规范、仓库本地指令或范围有限的参考资料。 | `repo/task.md`、文档包、样本资料包。 |
`instructions` 的良好用法包括: -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 会在 PTY 状态很重要时,让智能体始终在同一个交互式进程中运行。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙盒审查智能体在检查后直接回答用户。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写完成的文件必须实际写入 `output/`。 -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定确切的验证命令,并明确补丁路径相对于工作区根目录。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 在 PTY 状态很重要时,让智能体始终处于同一个交互式进程中。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙箱审核智能体在检查后直接回答用户。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写的文件实际写入 `output/`。 +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定准确的验证命令,并明确补丁路径是相对于工作区根目录的。 -应避免将用户的一次性任务复制到 `instructions`,避免嵌入应放在清单中的长篇参考材料,避免重复内置能力已注入的工具文档,也不要混入模型在运行时不需要的本地安装说明。 +请避免将用户的一次性任务复制到 `instructions` 中、嵌入本应放入清单的长篇参考资料、重复内置能力已经注入的工具文档,或混入模型在运行时并不需要的本地安装说明。 -如果省略 `instructions`,SDK 仍会包含默认沙盒提示词。这对于底层包装器已经足够,但大多数面向用户的智能体仍应提供明确的 `instructions`。 +如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。对于底层包装器而言,这已经足够,但大多数面向用户的智能体仍应提供明确的 `instructions`。 ### `capabilities` -能力会将沙盒原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、追加沙盒专用指令、公开绑定到实时沙盒会话的工具,以及调整该智能体的模型行为或输入处理。 +能力会将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、追加沙箱专用指令、公开绑定到实时沙箱会话的工具,并调整该智能体的模型行为或输入处理方式。 内置能力包括: @@ -189,59 +189,59 @@ flowchart LR | 能力 | 添加时机 | 说明 | | --- | --- | --- | -| `Shell` | 智能体需要 shell 访问。 | 添加 `exec_command`;当沙盒客户端支持 PTY 交互时,还会添加 `write_stdin`。 | +| `Shell` | 智能体需要 shell 访问。 | 添加 `exec_command`;当沙箱客户端支持 PTY 交互时,还会添加 `write_stdin`。 | | `Filesystem` | 智能体需要编辑文件或检查本地图像。 | 添加 `apply_patch` 和 `view_image`;补丁路径相对于工作区根目录。 | -| `Skills` | 希望在沙盒中发现并具现化技能。 | 应优先使用此能力,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您索引技能并将其具现化到沙盒中。 | -| `Memory` | 后续运行应读取或生成记忆产物。 | 需要 `Shell`;实时更新还需要 `Filesystem`。 | -| `Compaction` | 长时间运行的流程需要在压缩项后裁剪上下文。 | 调整模型采样和输入处理。 | +| `Skills` | 您希望在沙箱中发现并具现化技能。 | 应优先使用它,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您将技能编入索引并具现化到沙箱中。 | +| `Memory` | 后续运行应读取或生成记忆工件。 | 需要 `Shell`;实时更新还需要 `Filesystem`。 | +| `Compaction` | 长时间运行的流程需要在压缩项之后裁剪上下文。 | 调整模型采样和输入处理。 | -默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包括 `Filesystem()`、`Shell()` 和 `Compaction()`。如果传入 `capabilities=[...]`,该列表会替换默认值,因此请将仍需使用的默认能力包含在内。 +默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包括 `Filesystem()`、`Shell()` 和 `Compaction()`。如果传入 `capabilities=[...]`,该列表会替换默认列表,因此请包含仍希望使用的所有默认能力。 -对于技能,请根据所需的具现化方式选择来源: +对于技能,请根据希望采用的具现化方式选择来源: - `Skills(lazy_from=LocalDirLazySkillSource(...))` 是较大本地技能目录的良好默认选择,因为模型可以先发现索引,然后仅加载所需内容。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从 SDK 进程运行所在的文件系统读取内容。请传入原始主机侧技能目录,而不是仅存在于沙盒镜像或工作区内的路径。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从运行 SDK 进程的文件系统读取内容。请传入原始主机端技能目录,而不是仅存在于沙箱镜像或工作区中的路径。 - `Skills(from_=LocalDir(src=...))` 更适合希望预先暂存的小型本地技能包。 -- 当技能本身应来自仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 更合适。 +- 当技能本身应来自仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 是合适的选择。 -`LocalDir.src` 是 SDK 主机上的源路径。`skills_path` 是沙盒工作区内的相对目标路径,在调用 `load_skill` 时,技能会被暂存到该位置。 +`LocalDir.src` 是 SDK 主机上的源路径。`skills_path` 是沙箱工作区中的相对目标路径,调用 `load_skill` 时,技能会暂存到该路径。 -如果技能已位于磁盘上的 `.agents/skills//SKILL.md` 等路径下,请将 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 将其公开。除非现有工作区契约依赖其他沙盒内布局,否则请保留默认的 `skills_path=".agents"`。 +如果您的技能已存储在磁盘上的 `.agents/skills//SKILL.md` 等位置,请将 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 公开这些技能。除非现有工作区约定依赖其他沙箱内布局,否则请保留默认的 `skills_path=".agents"`。 -当内置能力能够满足需求时,应优先使用内置能力。仅当需要内置能力未涵盖的沙盒专用工具或指令接口时,才编写自定义能力。 +如果内置能力能够满足需求,应优先使用它们。只有在需要内置能力未涵盖的沙箱专用工具或指令接口时,才编写自定义能力。 ## 概念 ### 清单 -[`Manifest`][agents.sandbox.manifest.Manifest] 描述全新沙盒会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 +[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 -清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 逃逸工作区,因此工作区契约可以在本地、Docker 和托管客户端之间保持可移植性。 +清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 逸出工作区,这可使工作区约定在本地、Docker 和托管客户端之间保持可移植性。 -使用清单条目提供智能体开始工作前所需的材料: +使用清单条目指定智能体在开始工作前所需的材料:
| 清单条目 | 用途 | | --- | --- | | `File`、`Dir` | 小型合成输入、辅助文件或输出目录。 | -| `LocalFile`、`LocalDir` | 应具现化到沙盒中的主机文件或目录。 | +| `LocalFile`、`LocalDir` | 应具现化到沙箱中的主机文件或目录。 | | `GitRepo` | 应提取到工作区中的仓库。 | -| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` 等挂载 | 应显示在沙盒内的外部存储。 | +| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` 等挂载 | 应显示在沙箱内的外部存储。 |
-`Dir` 会根据合成子项在沙盒工作区内创建目录,或创建一个输出位置;它不会从主机文件系统读取内容。如果需要将现有主机目录复制到沙盒工作区,请使用 `LocalDir`。 +`Dir` 会根据合成子项在沙箱工作区内创建目录,或创建一个输出位置;它不会从主机文件系统读取内容。现有主机目录需要复制到沙箱工作区时,请使用 `LocalDir`。 -默认情况下,`LocalFile.src` 和 `LocalDir.src` 相对于 SDK 进程的工作目录进行解析。除非源路径已包含在 `extra_path_grants` 中,否则它必须位于该基础目录下。这样可以让本地源材料的具现化与沙盒清单的其他部分保持在同一个主机路径信任边界内。 +默认情况下,`LocalFile.src` 和 `LocalDir.src` 会相对于 SDK 进程的工作目录进行解析。源必须位于该基础目录下,除非它包含在 `extra_path_grants` 中。这样可确保本地源材料的具现化与沙箱清单的其余部分位于相同的主机路径信任边界内。 -挂载条目描述要公开哪些存储;挂载策略则描述沙盒后端如何附加这些存储。有关挂载选项和提供商支持,请参阅[沙盒客户端](clients.md#mounts-and-remote-storage)。 +挂载条目描述要公开的存储;挂载策略描述沙箱后端如何附加该存储。有关挂载选项和提供商支持,请参阅[沙箱客户端](clients.md#mounts-and-remote-storage)。 -良好的清单设计通常意味着保持工作区契约精简,将较长的任务步骤放入 `repo/task.md` 等工作区文件,并在指令中使用工作区相对路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 能力的 `apply_patch` 工具编辑文件,请记住,补丁路径相对于沙盒工作区根目录,而不是 shell 的 `workdir`。 +良好的清单设计通常意味着保持工作区约定精简,将较长的任务流程放入 `repo/task.md` 等工作区文件,并在指令中使用相对工作区路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 能力的 `apply_patch` 工具编辑文件,请记住,补丁路径相对于沙箱工作区根目录,而不是 shell 的 `workdir`。 -仅当智能体需要工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录外的可信本地源时,才使用 `extra_path_grants`。示例包括:用于临时工具输出的 `/tmp`、用作只读运行时的 `/opt/toolchain`,或应具现化到沙盒中的已生成技能目录。授权适用于本地源具现化、SDK 文件 API,以及后端可以执行文件系统策略的 shell 执行: +仅当智能体需要访问工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录之外的受信任本地源时,才使用 `extra_path_grants`。例如,用于临时工具输出的 `/tmp`、用于只读运行时的 `/opt/toolchain`,或应具现化到沙箱中的已生成技能目录。授权适用于本地源具现化、SDK 文件 API,以及后端能够实施文件系统策略时的 shell 执行: ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,15 +254,17 @@ manifest = Manifest( ) ``` -应将包含 `extra_path_grants` 的清单视为可信配置。除非应用已经批准这些主机路径,否则不要从模型输出或其他不可信有效负载中加载授权。 +当 Docker 应将其他绝对主机路径绑定挂载到容器内的绝对 POSIX `path` 时,请设置 `host_path`。`UnixLocalSandboxClient` 仅支持两个路径相同的纯路径授权,并会拒绝 `host_path`。对于沙箱不应修改的主机数据,请使用 `read_only=True`;如果复制即可满足需求,请使用 `LocalFile` 或 `LocalDir`。 + +请将包含 `extra_path_grants` 的清单视为受信任配置。除非应用程序已经批准这些主机路径,否则请勿从模型输出或其他不受信任的载荷加载授权。 快照和 `persist_workspace()` 仍然只包含工作区根目录。额外授权的路径属于运行时访问权限,而不是持久工作区状态。 ### 权限 -`Permissions` 控制清单条目的文件系统权限。它作用于沙盒具现化的文件,而不是模型权限、审批策略或 API 凭据。 +`Permissions` 控制清单条目的文件系统权限。它针对沙箱具现化的文件,而不是模型权限、审批策略或 API 凭据。 -默认情况下,清单条目的所有者拥有读取、写入和执行权限,组和其他用户拥有读取和执行权限。当暂存文件应为私有、只读或可执行时,请覆盖此设置: +默认情况下,清单条目的所有者具有读取、写入和执行权限,组和其他用户具有读取和执行权限。当暂存文件应为私有、只读或可执行时,请覆盖此设置: ```python from agents.sandbox import FileMode, Permissions @@ -278,9 +280,9 @@ private_notes = File( ) ``` -`Permissions` 会分别存储所有者、组和其他用户的权限位,以及该条目是否为目录。您可以直接构建它,使用 `Permissions.from_str(...)` 从模式字符串解析,或使用 `Permissions.from_mode(...)` 从操作系统模式派生。 +`Permissions` 分别存储所有者、组和其他用户的权限位,以及条目是否为目录。您可以直接构建它,通过 `Permissions.from_str(...)` 从模式字符串解析,或通过 `Permissions.from_mode(...)` 从操作系统模式派生。 -用户是可以在沙盒中执行工作的身份。当希望某个身份存在于沙盒中时,请向清单添加 `User`;当 shell 命令、文件读取和补丁等面向模型的沙盒工具应以该用户身份运行时,请设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到有效清单中。 +用户是可在沙箱中执行工作的身份。当您希望某个身份存在于沙箱中时,请将 `User` 添加到清单;然后,当 shell 命令、文件读取和补丁等面向模型的沙箱工具应以该用户身份运行时,设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到实际清单中。 ```python from agents import Runner @@ -332,13 +334,13 @@ result = await Runner.run( ) ``` -如果还需要文件级共享规则,请将用户与清单组及条目的 `group` 元数据结合使用。`run_as` 用户控制由谁执行沙盒原生操作;`Permissions` 则控制沙盒完成工作区具现化后,该用户可以读取、写入或执行哪些文件。 +如果还需要文件级共享规则,请将用户与清单组以及条目 `group` 元数据结合使用。`run_as` 用户控制由谁执行沙箱原生操作;`Permissions` 控制沙箱具现化工作区后,该用户可以读取、写入或执行哪些文件。 ### SnapshotSpec -`SnapshotSpec` 指定全新沙盒会话应从何处恢复保存的工作区内容,以及应将内容持久化回何处。它是沙盒工作区的快照策略,而 `session_state` 是用于恢复特定沙盒后端的序列化连接状态。 +`SnapshotSpec` 指定新沙箱会话应从哪里恢复已保存的工作区内容,以及应将其持久化回哪里。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 -对于本地持久快照,请使用 `LocalSnapshotSpec`;当应用提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。当无法设置本地快照时,会使用空操作快照作为后备;当高级调用方不需要工作区快照持久化时,也可以显式使用空操作快照。 +对于本地持久快照,请使用 `LocalSnapshotSpec`;当应用程序提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。本地快照设置不可用时,会使用空操作快照作为回退;当高级调用方不希望持久化工作区快照时,也可以显式使用空操作快照。 ```python from pathlib import Path @@ -355,11 +357,11 @@ run_config = RunConfig( ) ``` -当运行器创建全新沙盒会话时,沙盒客户端会为该会话构建一个快照实例。启动时,如果快照可恢复,沙盒会在运行继续前恢复保存的工作区内容。清理时,由运行器拥有的沙盒会话会归档工作区,并通过快照将其持久化回去。 +当运行器创建新沙箱会话时,沙箱客户端会为该会话构建快照实例。启动时,如果快照可恢复,沙箱会先恢复已保存的工作区内容,然后再继续运行。清理时,运行器拥有的沙箱会话会归档工作区,并通过快照将其持久化。 -如果省略 `snapshot`,运行时会在可行时尝试使用默认本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 +如果省略 `snapshot`,运行时会尽可能尝试使用默认本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 -### 沙盒生命周期 +### 沙箱生命周期 生命周期有两种模式:**SDK 所有**和**开发者所有**。 @@ -389,7 +391,7 @@ sequenceDiagram -当沙盒只需在一次运行期间存在时,请使用 SDK 所有的生命周期。传入 `client`、可选的 `manifest`、可选的 `snapshot` 和客户端 `options`;运行器会创建或恢复沙盒、启动沙盒、运行智能体、持久化由快照支持的工作区状态、关闭沙盒,并让客户端清理由运行器拥有的资源。 +当沙箱只需在一次运行期间存在时,请使用 SDK 所有的生命周期。传入 `client`、可选的 `manifest`、可选的 `snapshot` 和客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、关闭沙箱,并让客户端清理运行器拥有的资源。 ```python result = await Runner.run( @@ -401,7 +403,7 @@ result = await Runner.run( ) ``` -当您希望提前创建沙盒、在多次运行间复用同一个实时沙盒、在运行后检查文件、通过自己创建的沙盒进行流式传输,或精确决定清理时机时,请使用开发者所有的生命周期。传入 `session=...` 会让运行器使用该实时沙盒,但不会代您关闭它。 +当您希望提前创建沙箱、在多次运行间复用同一个实时沙箱、在运行后检查文件、通过自行创建的沙箱进行流式传输,或准确决定何时进行清理时,请使用开发者所有的生命周期。传入 `session=...` 会让运行器使用该实时沙箱,但运行器不会替您关闭它。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -412,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -通常应使用上下文管理器:它在进入时启动沙盒,并在退出时运行会话清理生命周期。如果应用无法使用上下文管理器,请直接调用生命周期方法: +上下文管理器是常见用法:进入时启动沙箱,退出时运行会话清理生命周期。如果您的应用无法使用上下文管理器,请直接调用生命周期方法: ```python sandbox = await client.create( @@ -433,64 +435,64 @@ finally: await sandbox.aclose() ``` -`stop()` 只会持久化由快照支持的工作区内容;它不会销毁沙盒。`aclose()` 是完整的会话清理路径:它会运行停止前钩子、调用 `stop()`、关闭沙盒资源并关闭会话范围的依赖项。 +`stop()` 只会持久化由快照支持的工作区内容;它不会拆除沙箱。`aclose()` 是完整的会话清理路径:它会运行停止前钩子、调用 `stop()`、关闭沙箱资源,并关闭会话范围内的依赖项。 ## `SandboxRunConfig` 选项 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 保存每次运行的选项,用于决定沙盒会话的来源,以及应如何初始化全新会话。 +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 保存每次运行的选项,这些选项决定沙箱会话的来源,以及应如何初始化新会话。 -### 沙盒来源 +### 沙箱来源 -以下选项决定运行器应复用、恢复还是创建沙盒会话: +以下选项决定运行器应复用、恢复还是创建沙箱会话:
-| 选项 | 使用时机 | 说明 | +| 选项 | 适用场景 | 说明 | | --- | --- | --- | -| `client` | 希望运行器代您创建、恢复和清理沙盒会话。 | 除非提供实时沙盒 `session`,否则为必需项。 | -| `session` | 已经自行创建了实时沙盒会话。 | 调用方拥有生命周期;运行器复用该实时沙盒会话。 | -| `session_state` | 拥有序列化的沙盒会话状态,但没有实时沙盒会话对象。 | 需要 `client`;运行器将根据该显式状态,以拥有会话的方式进行恢复。 | +| `client` | 您希望运行器代您创建、恢复和清理沙箱会话。 | 除非提供实时沙箱 `session`,否则为必需项。 | +| `session` | 您已经自行创建了实时沙箱会话。 | 调用方拥有生命周期;运行器会复用该实时沙箱会话。 | +| `session_state` | 您拥有已序列化的沙箱会话状态,但没有实时沙箱会话对象。 | 需要 `client`;运行器会从该显式状态恢复为其拥有的会话。 |
-实际使用中,运行器会按以下顺序解析沙盒会话: +实际使用中,运行器按以下顺序解析沙箱会话: -1. 如果注入 `run_config.sandbox.session`,则直接复用该实时沙盒会话。 -2. 否则,如果此次运行正在从 `RunState` 恢复,则恢复其中存储的沙盒会话状态。 -3. 否则,如果传入 `run_config.sandbox.session_state`,运行器将根据该显式序列化的沙盒会话状态进行恢复。 -4. 否则,运行器会创建全新的沙盒会话。对于该全新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 +1. 如果注入 `run_config.sandbox.session`,则直接复用该实时沙箱会话。 +2. 否则,如果运行正在从 `RunState` 恢复,则恢复其中存储的沙箱会话状态。 +3. 否则,如果传入 `run_config.sandbox.session_state`,运行器会从该显式序列化的沙箱会话状态恢复。 +4. 否则,运行器会创建新的沙箱会话。对于该新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 -### 全新会话输入 +### 新会话输入 -以下选项仅在运行器创建全新沙盒会话时有效: +以下选项仅在运行器创建新沙箱会话时有效:
-| 选项 | 使用时机 | 说明 | +| 选项 | 适用场景 | 说明 | | --- | --- | --- | -| `manifest` | 希望一次性覆盖全新会话的工作区。 | 省略时回退到 `agent.default_manifest`。 | -| `snapshot` | 全新沙盒会话应由快照初始化。 | 适用于类似恢复的流程或远程快照客户端。 | -| `options` | 沙盒客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时和类似的客户端专用设置。 | +| `manifest` | 您希望对新会话工作区进行一次性覆盖。 | 省略时回退到 `agent.default_manifest`。 | +| `snapshot` | 新沙箱会话应从快照初始化。 | 适用于类似恢复的流程或远程快照客户端。 | +| `options` | 沙箱客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时及类似的客户端专用设置。 |
### 具现化控制 -`concurrency_limits` 控制可并行运行的沙盒具现化工作量。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用对应限制。 +`concurrency_limits` 控制可以并行运行的沙箱具现化工作量。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用相应限制。 -`archive_limits` 控制 SDK 侧对归档提取的资源检查。设置 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可以传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持默认行为,即不设置 SDK 归档资源限制;也可以将单个字段设置为 `None`,仅禁用对应限制。 +`archive_limits` 控制 SDK 端对归档提取的资源检查。设置 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可以传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持不设 SDK 归档资源限制的默认行为;也可以将单个字段设置为 `None`,仅禁用该项限制。 需要注意以下几点: -- 全新会话:`manifest=` 和 `snapshot=` 仅在运行器创建全新沙盒会话时生效。 -- 恢复与快照:`session_state=` 会重新连接到之前序列化的沙盒状态,而 `snapshot=` 会根据保存的工作区内容初始化新的沙盒会话。 -- 客户端专用选项:`options=` 取决于沙盒客户端;Docker 和许多托管客户端都需要该选项。 -- 注入的实时会话:如果传入正在运行的沙盒 `session`,由能力驱动的清单更新可以添加兼容的非挂载条目。但它们不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;不能移除现有条目;不能替换条目类型;也不能添加或更改挂载条目。 -- 运行器 API:`SandboxAgent` 仍使用常规的 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API 执行。 +- 新会话:`manifest=` 和 `snapshot=` 仅在运行器创建新沙箱会话时适用。 +- 恢复与快照:`session_state=` 重新连接到先前序列化的沙箱状态,而 `snapshot=` 使用已保存的工作区内容初始化新的沙箱会话。 +- 客户端专用选项:`options=` 取决于沙箱客户端;Docker 和许多托管客户端都要求提供它。 +- 注入的实时会话:如果传入正在运行的沙箱 `session`,由能力驱动的清单更新可以添加兼容的非挂载条目。它们不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;不能删除现有条目;不能替换条目类型;也不能添加或更改挂载条目。 +- 运行器 API:`SandboxAgent` 执行仍使用常规的 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API。 ## 完整示例:编码任务 -以下编码风格示例是一个良好的默认起点: +以下编码风格示例是很好的默认起点: ```python import asyncio @@ -569,19 +571,19 @@ if __name__ == "__main__": ) ``` -请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 shell 的微型仓库,因此可以在 Unix 本地运行中以确定性方式验证该示例。您的实际任务仓库当然可以使用 Python、JavaScript 或任何其他语言。 +请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用基于 shell 的微型仓库,因此可以在 Unix 本地运行中以确定性方式验证该示例。当然,您的实际任务仓库可以使用 Python、JavaScript 或任何其他技术。 ## 常见模式 -请从上面的完整示例开始。很多情况下,您可以保持同一个 `SandboxAgent` 不变,只更改沙盒客户端、沙盒会话来源或工作区来源。 +请从上面的完整示例开始。在许多情况下,可以保持同一个 `SandboxAgent` 不变,只更改沙箱客户端、沙箱会话来源或工作区来源。 -### 沙盒客户端切换 +### 沙箱客户端的切换 -保持智能体定义不变,仅更改运行配置。当需要容器隔离或镜像一致性时,请使用 Docker;当需要由提供商管理执行时,请使用托管提供商。有关代码示例和提供商选项,请参阅[沙盒客户端](clients.md)。 +保持智能体定义不变,只更改运行配置。当您需要容器隔离或镜像一致性时,请使用 Docker;当您需要由提供商管理执行时,请使用托管提供商。有关示例和提供商选项,请参阅[沙箱客户端](clients.md)。 -### 工作区覆盖 +### 工作区的覆盖 -保持智能体定义不变,仅替换全新会话清单: +保持智能体定义不变,只替换新会话清单: ```python from agents.run import RunConfig @@ -601,11 +603,11 @@ run_config = RunConfig( ) ``` -当同一个智能体角色需要针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例使用 `default_manifest` 而非一次性覆盖,但展示了相同模式。 +当同一个智能体角色应针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了使用 `default_manifest` 而非一次性覆盖项的相同模式。 -### 沙盒会话注入 +### 沙箱会话的注入 -当需要显式生命周期控制、运行后检查或输出复制时,请注入实时沙盒会话: +当您需要显式控制生命周期、在运行后检查或复制输出时,请注入实时沙箱会话: ```python from agents import Runner @@ -626,11 +628,11 @@ async with sandbox: ) ``` -当希望在运行后检查工作区,或通过已启动的沙盒会话进行流式传输时,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 +当您希望在运行后检查工作区,或通过已经启动的沙箱会话进行流式传输时,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 -### 会话状态恢复 +### 从会话状态恢复 -如果已在 `RunState` 外部序列化沙盒状态,可以让运行器根据该状态重新连接: +如果您已经在 `RunState` 外部序列化了沙箱状态,请让运行器从该状态重新连接: ```python from agents.run import RunConfig @@ -647,11 +649,13 @@ run_config = RunConfig( ) ``` -当沙盒状态存储在您自己的存储系统或作业系统中,并希望 `Runner` 直接从中恢复时,请使用此模式。有关序列化和反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 +当沙箱状态保存在您自己的存储或作业系统中,并且希望 `Runner` 直接从中恢复时,请使用此模式。有关序列化和反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 + +会话状态序列化会省略原生 `host_path` 值。要恢复由主机支持的授权,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单;否则,恢复会在沙箱启动前失败。切勿从序列化输入或其他不受信任的输入派生主机路径。 -### 快照初始化 +### 从快照启动 -使用保存的文件和产物初始化新沙盒: +使用已保存的文件和工件初始化新沙箱: ```python from pathlib import Path @@ -668,11 +672,11 @@ run_config = RunConfig( ) ``` -当全新运行应从保存的工作区内容开始,而不是仅从 `agent.default_manifest` 开始时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 +当新运行应从已保存的工作区内容开始,而不是仅使用 `agent.default_manifest` 时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 -### Git 技能加载 +### 从 Git 加载技能 -将本地技能源替换为由仓库支持的技能源: +将本地技能来源替换为仓库支持的来源: ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -683,11 +687,11 @@ capabilities = Capabilities.default() + [ ] ``` -当技能包有自己的发布周期,或应在多个沙盒间共享时,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 +当技能包有自己的发布周期,或应在多个沙箱之间共享时,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 -### 工具公开 +### 作为工具公开 -工具智能体既可以获得自己的沙盒边界,也可以复用父级运行中的实时沙盒。复用适合快速、只读的探索智能体:它可以检查父智能体正在使用的确切工作区,而无需承担创建、填充或快照另一个沙盒的开销。 +工具智能体既可以拥有自己的沙箱边界,也可以复用父运行中的实时沙箱。对于快速的只读探索智能体,复用很有用:它可以检查父智能体正在使用的确切工作区,而无需承担创建、填充或快照另一个沙箱的开销。 ```python from agents import Runner @@ -769,9 +773,9 @@ async with sandbox: ) ``` -此处,父智能体以 `coordinator` 身份运行,探索工具智能体则在同一个实时沙盒会话中以 `explorer` 身份运行。`pricing_packet/` 条目允许 `other` 用户读取,因此探索智能体可以快速检查这些条目,但没有写入权限位。`work/` 目录仅对协调智能体的用户和组可用,因此父智能体可以写入最终产物,而探索智能体保持只读状态。 +此处,父智能体以 `coordinator` 身份运行,探索工具智能体则在同一个实时沙箱会话中以 `explorer` 身份运行。`pricing_packet/` 条目可由 `other` 用户读取,因此探索智能体可以快速检查这些条目,但没有写入权限位。`work/` 目录仅对协调智能体的用户/组可用,因此父智能体可以写入最终工件,而探索智能体保持只读。 -当工具智能体需要真正隔离时,请为其提供独立的沙盒 `RunConfig`: +当工具智能体需要真正隔离时,请为其提供独立的沙箱 `RunConfig`: ```python from docker import from_env as docker_from_env @@ -797,11 +801,11 @@ rollout_agent.as_tool( ) ``` -当工具智能体应自由修改内容、运行不可信命令或使用不同的后端或镜像时,请使用独立沙盒。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +当工具智能体应自由修改内容、运行不受信任的命令或使用不同后端/镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -### 本地工具与 MCP 组合 +### 与本地工具及 MCP 的组合 -在保留沙盒工作区的同时,仍可在同一个智能体上使用普通工具: +在保留沙箱工作区的同时,仍可在同一个智能体上使用常规工具: ```python from agents.sandbox import SandboxAgent @@ -820,42 +824,42 @@ agent = SandboxAgent( ## 记忆 -当未来的沙盒智能体运行应从之前的运行中学习时,请使用 `Memory` 能力。记忆不同于 SDK 的对话式 `Session` 记忆:它会将经验提炼为沙盒工作区中的文件,后续运行便可读取这些文件。 +当未来的沙箱智能体运行应从先前运行中学习时,请使用 `Memory` 能力。记忆与 SDK 的对话式 `Session` 记忆不同:它会将经验提炼到沙箱工作区内的文件中,供后续运行读取。 -有关设置、读取和生成行为、多轮对话及布局隔离,请参阅[智能体记忆](memory.md)。 +有关设置、读取/生成行为、多轮对话和布局隔离,请参阅[智能体记忆](memory.md)。 ## 组合模式 -明确单智能体模式后,下一个设计问题是沙盒边界在更大系统中应处于什么位置。 +明确单智能体模式后,下一个设计问题是沙箱边界在更大系统中应位于何处。 -沙盒智能体仍可与 SDK 的其他部分组合: +沙箱智能体仍可与 SDK 的其他部分组合: -- [任务转移](../handoffs.md):将文档密集型工作从非沙盒接收智能体转移给沙盒审查智能体。 -- [Agents as tools](../tools.md#agents-as-tools):将多个沙盒智能体公开为工具,通常通过在每次 `Agent.as_tool(...)` 调用中传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,使每个工具拥有自己的沙盒边界。 -- [MCP](../mcp.md) 和普通工具调用:沙盒能力可以与 `mcp_servers` 和普通 Python 工具共存。 -- [运行智能体](../running_agents.md):沙盒运行仍使用常规 `Runner` API。 +- [任务转移](../handoffs.md):将文档密集型工作从非沙箱接收智能体转移给沙箱审核智能体。 +- [Agents as tools](../tools.md#agents-as-tools):将多个沙箱智能体作为工具公开,通常是在每次调用 `Agent.as_tool(...)` 时传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,从而让每个工具拥有自己的沙箱边界。 +- [MCP](../mcp.md) 和常规工具调用:沙箱能力可以与 `mcp_servers` 和普通 Python 工具共存。 +- [运行智能体](../running_agents.md):沙箱运行仍使用常规 `Runner` API。 以下两种模式尤其常见: -- 非沙盒智能体只在工作流中需要工作区隔离的部分,将任务转移给沙盒智能体 -- 编排智能体将多个沙盒智能体公开为工具,通常为每次 `Agent.as_tool(...)` 调用分别提供沙盒 `RunConfig`,使每个工具拥有独立的隔离工作区 +- 非沙箱智能体仅针对工作流中需要工作区隔离的部分,将任务转移给沙箱智能体 +- 编排智能体将多个沙箱智能体作为工具公开,通常为每次 `Agent.as_tool(...)` 调用提供独立的沙箱 `RunConfig`,从而让每个工具获得自己的隔离工作区 -### 轮次与沙盒运行 +### 轮次与沙箱运行 -分别说明任务转移和智能体工具调用会更清晰。 +分别说明任务转移和智能体工具调用会更容易理解。 -使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活动智能体会发生变化,但运行不会变为嵌套运行。如果非沙盒接收智能体将任务转移给沙盒审查智能体,则同一次运行中的下一次模型调用会为沙盒智能体进行准备,并由该沙盒智能体执行下一轮。换言之,任务转移会改变同一次运行中下一轮的负责智能体。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 +使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活动智能体会发生变化,但运行不会变成嵌套运行。如果非沙箱接收智能体将任务转移给沙箱审核智能体,则同一运行中的下一次模型调用会针对沙箱智能体进行准备,而该沙箱智能体会成为执行下一轮的智能体。换言之,任务转移会改变由哪个智能体负责同一次运行的下一轮。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 -使用 `Agent.as_tool(...)` 时,关系有所不同。外层编排智能体使用外层运行的一轮来决定调用工具,而该工具调用会为沙盒智能体启动嵌套运行。嵌套运行拥有自己的轮次循环、`max_turns`、审批,通常也拥有自己的沙盒 `RunConfig`。它可能在一个嵌套轮次内完成,也可能需要多个轮次。从外层编排智能体的角度看,所有这些工作仍位于一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +使用 `Agent.as_tool(...)` 时,关系有所不同。外层编排智能体使用一个外层轮次来决定调用工具,该工具调用会为沙箱智能体启动嵌套运行。嵌套运行拥有自己的轮次循环、`max_turns`、审批,并且通常拥有自己的沙箱 `RunConfig`。它可能在一个嵌套轮次内完成,也可能需要多个轮次。从外层编排智能体的角度看,所有这些工作仍隐藏在一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -审批行为遵循相同的职责划分: +审批行为也遵循相同的区分: -- 使用任务转移时,审批仍位于同一个顶层运行中,因为沙盒智能体现在是该运行中的活动智能体 -- 使用 `Agent.as_tool(...)` 时,沙盒工具智能体内部触发的审批仍会显示在外层运行中,但它们来自保存的嵌套运行状态,并在外层运行恢复时恢复嵌套沙盒运行 +- 使用任务转移时,审批保留在同一个顶层运行中,因为沙箱智能体现已成为该运行中的活动智能体 +- 使用 `Agent.as_tool(...)` 时,沙箱工具智能体内部触发的审批仍会显示在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 ## 延伸阅读 -- [快速入门](../sandbox_agents.md):运行第一个沙盒智能体。 -- [沙盒客户端](clients.md):选择本地、Docker、托管和挂载选项。 -- [智能体记忆](memory.md):保留并复用之前沙盒运行中的经验。 -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):可运行的本地、编码、记忆、任务转移和智能体组合模式。 +- [快速入门](../sandbox_agents.md):运行一个沙箱智能体。 +- [沙箱客户端](clients.md):选择本地、Docker、托管和挂载选项。 +- [智能体记忆](memory.md):保留和复用先前沙箱运行中的经验。 +- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):可运行的本地、编码、记忆、任务转移和智能体组合模式。 \ No newline at end of file From 9e1564e00635bbd9896fae3b14ca52612beb2655 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 30 Jul 2026 22:55:09 +0900 Subject: [PATCH 057/473] fix(mcp): redact URL credentials from SDK errors (#4015) Co-authored-by: Dima Osipa <1094629+dimaosipa@users.noreply.github.com> --- src/agents/mcp/server.py | 323 ++++++++---- src/agents/mcp/util.py | 6 +- tests/mcp/test_mcp_util.py | 44 ++ tests/mcp/test_server_errors.py | 471 +++++++++++++++++- .../test_streamable_http_client_factory.py | 48 ++ 5 files changed, 799 insertions(+), 93 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 4f8f89c293..941eece7bd 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -8,7 +8,7 @@ from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from datetime import timedelta from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeVar, Union, cast import anyio import httpx @@ -102,6 +102,54 @@ class RequireApprovalObject(TypedDict, total=False): T = TypeVar("T") +def _safe_transport_cause(http_error: Exception) -> Exception | None: + """Keep a transport exception only when its HTTPX URLs need no sanitization.""" + if not isinstance(http_error, httpx.HTTPStatusError | httpx.RequestError): + return http_error + + request_urls: list[str] = [] + try: + request_urls.append(str(http_error.request.url)) + except RuntimeError: + pass + + if isinstance(http_error, httpx.HTTPStatusError): + for response in [*http_error.response.history, http_error.response]: + try: + response_url = response.request.url + except RuntimeError: + return None + + request_urls.append(str(response_url)) + redirect_location = response.headers.get("location") + if redirect_location is not None: + try: + request_urls.append(str(response_url.join(redirect_location))) + except (httpx.InvalidURL, ValueError): + return None + + return http_error if all(get_mcp_server_log_name(url) == url for url in request_urls) else None + + +def _first_unsafe_transport_error(http_errors: list[Exception]) -> Exception | None: + """Return the first transport error whose HTTPX URLs require sanitization.""" + return next((error for error in http_errors if _safe_transport_cause(error) is None), None) + + +def _log_transport_warning(message: str, http_error: Exception) -> None: + """Log a transport failure without attaching credential-bearing request URLs.""" + if _debug.DONT_LOG_TOOL_DATA: + log_tool_action_warning(logger, message, http_error) + return + + safe_error = _safe_transport_cause(http_error) + if safe_error is None: + logger.warning("%s", message, stacklevel=3) + return + + log_tool_action_warning(logger, message, safe_error) + + def _create_default_streamable_http_client( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, @@ -127,8 +175,7 @@ async def _handle_post_request(self, ctx: Any) -> None: try: await super()._handle_post_request(ctx) except httpx.HTTPError as exc: - log_tool_action_warning( - logger, + _log_transport_warning( "Ignoring initialized notification HTTP failure", exc, ) @@ -290,6 +337,11 @@ def name(self) -> str: """A readable name for the server.""" pass + @property + def _error_name(self) -> str: + """Return a diagnostic server name with URL credentials removed.""" + return get_mcp_server_log_name(self.name) + @abc.abstractmethod async def cleanup(self): """Cleanup the server. For example, this might mean closing a subprocess or @@ -355,7 +407,7 @@ async def list_resources(self, cursor: str | None = None) -> ListResourcesResult unimplemented; it will raise :exc:`NotImplementedError` at call time. """ raise NotImplementedError( - f"MCP server '{self.name}' does not support list_resources. " + f"MCP server '{self._error_name}' does not support list_resources. " "Override this method in your server implementation." ) @@ -377,7 +429,7 @@ async def list_resource_templates( call time. """ raise NotImplementedError( - f"MCP server '{self.name}' does not support list_resource_templates. " + f"MCP server '{self._error_name}' does not support list_resource_templates. " "Override this method in your server implementation." ) @@ -393,7 +445,7 @@ async def read_resource(self, uri: str) -> ReadResourceResult: :exc:`NotImplementedError` at call time. """ raise NotImplementedError( - f"MCP server '{self.name}' does not support read_resource. " + f"MCP server '{self._error_name}' does not support read_resource. " "Override this method in your server implementation." ) @@ -737,23 +789,22 @@ def invalidate_tools_cache(self): """Invalidate the tools cache.""" self._cache_dirty = True - def _extract_http_error_from_exception(self, e: BaseException) -> Exception | None: - """Extract HTTP error from exception or ExceptionGroup.""" - if isinstance(e, httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException): - return e + def _extract_http_errors_from_exception(self, e: BaseException) -> list[Exception]: + """Extract all HTTP errors from an exception or nested ExceptionGroup.""" + if isinstance(e, httpx.HTTPStatusError | httpx.RequestError): + return [e] - # Recursively check ExceptionGroups for HTTP errors if isinstance(e, BaseExceptionGroup): + http_errors: list[Exception] = [] for exc in e.exceptions: - result = self._extract_http_error_from_exception(exc) - if result is not None: - return result + http_errors.extend(self._extract_http_errors_from_exception(exc)) + return http_errors - return None + return [] - def _raise_user_error_for_http_error(self, http_error: Exception) -> None: - """Raise appropriate UserError for HTTP error.""" - error_message = f"Failed to connect to MCP server '{self.name}': " + def _user_error_for_http_error(self, http_error: Exception) -> UserError: + """Build a UserError from safe HTTP diagnostics.""" + error_message = f"Failed to connect to MCP server '{self._error_name}': " if isinstance(http_error, httpx.HTTPStatusError): error_message += f"HTTP error {http_error.response.status_code} ({http_error.response.reason_phrase})" # noqa: E501 @@ -763,7 +814,17 @@ def _raise_user_error_for_http_error(self, http_error: Exception) -> None: elif isinstance(http_error, httpx.TimeoutException): error_message += "Connection timeout." - raise UserError(error_message) from http_error + elif isinstance(http_error, httpx.RequestError): + error_message += "Request failed." + + return UserError(error_message) + + @staticmethod + def _raise_mapped_transport_error(error: UserError, cause: Exception | None) -> NoReturn: + """Raise a mapped transport error without retaining unsafe URL data.""" + if cause is None: + raise error from None + raise error from cause async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: attempts = 0 @@ -780,6 +841,8 @@ async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: async def connect(self): """Connect to the server.""" connection_succeeded = False + connection_error: UserError | None = None + connection_cause: Exception | None = None try: transport = await self.exit_stack.enter_async_context(self.create_streams()) # streamablehttp_client returns (read, write, get_session_id) @@ -804,22 +867,25 @@ async def connect(self): self.session = session connection_succeeded = True except Exception as e: - # Try to extract HTTP error from exception or ExceptionGroup - http_error = self._extract_http_error_from_exception(e) - if http_error: - self._raise_user_error_for_http_error(http_error) - - # For CancelledError, preserve cancellation semantics - don't wrap it. - # If it's masking an HTTP error, cleanup() will extract and raise UserError. - if isinstance(e, asyncio.CancelledError): + http_errors = self._extract_http_errors_from_exception(e) + if not http_errors: raise - # For HTTP-related errors, wrap them - if isinstance(e, httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException): - self._raise_user_error_for_http_error(e) + unsafe_http_error = _first_unsafe_transport_error(http_errors) + http_error = unsafe_http_error or http_errors[0] + connection_cause = None if unsafe_http_error is not None else http_error + maps_safe_error = isinstance( + http_error, + httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException, + ) + if connection_cause is not None and not maps_safe_error: + raise - # For other errors, re-raise as-is (don't wrap non-HTTP errors) - raise + connection_error = self._user_error_for_http_error(http_error) + if connection_cause is None: + http_errors.clear() + del http_error + del unsafe_http_error finally: # Always attempt cleanup on error, but suppress cleanup errors that mask the original if not connection_succeeded: @@ -851,6 +917,9 @@ async def connect(self): cleanup_error, ) + if connection_error is not None: + self._raise_mapped_transport_error(connection_error, connection_cause) + async def list_tools( self, run_context: RunContextWrapper[Any] | None = None, @@ -862,6 +931,8 @@ async def list_tools( session = self.session assert session is not None + transport_error: UserError | None = None + transport_cause: Exception | None = None try: # Return from cache if caching is enabled, we have tools, and the cache is not dirty if self.cache_tools_list and not self._cache_dirty and self._tools_list: @@ -882,14 +953,32 @@ async def list_tools( return filtered_tools except httpx.HTTPStatusError as e: status_code = e.response.status_code - raise UserError( - f"Failed to list tools from MCP server '{self.name}': HTTP error {status_code}" - ) from e - except httpx.ConnectError as e: - raise UserError( - f"Failed to list tools from MCP server '{self.name}': Connection lost. " - f"The server may have disconnected." - ) from e + transport_error = UserError( + f"Failed to list tools from MCP server '{self._error_name}': " + f"HTTP error {status_code}" + ) + transport_cause = _safe_transport_cause(e) + except httpx.RequestError as e: + transport_cause = _safe_transport_cause(e) + if transport_cause is not None and not isinstance(e, httpx.ConnectError): + raise + if isinstance(e, httpx.ConnectError): + transport_error = UserError( + f"Failed to list tools from MCP server '{self._error_name}': Connection lost. " + f"The server may have disconnected." + ) + elif isinstance(e, httpx.TimeoutException): + transport_error = UserError( + f"Failed to list tools from MCP server '{self._error_name}': " + "Connection timeout." + ) + else: + transport_error = UserError( + f"Failed to list tools from MCP server '{self._error_name}': Request failed." + ) + + assert transport_error is not None + self._raise_mapped_transport_error(transport_error, transport_cause) async def call_tool( self, @@ -903,6 +992,8 @@ async def call_tool( session = self.session assert session is not None + transport_error: UserError | None = None + transport_cause: Exception | None = None try: self._validate_required_parameters(tool_name=tool_name, arguments=arguments) if meta is None: @@ -918,15 +1009,33 @@ async def call_tool( ) except httpx.HTTPStatusError as e: status_code = e.response.status_code - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" - ) from e - except httpx.ConnectError as e: - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': Connection lost. " - f"The server may have disconnected." - ) from e + ) + transport_cause = _safe_transport_cause(e) + except httpx.RequestError as e: + transport_cause = _safe_transport_cause(e) + if transport_cause is not None and not isinstance(e, httpx.ConnectError): + raise + if isinstance(e, httpx.ConnectError): + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " + "Connection lost. The server may have disconnected." + ) + elif isinstance(e, httpx.TimeoutException): + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " + "Connection timeout." + ) + else: + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " + "Request failed." + ) + + assert transport_error is not None + self._raise_mapped_transport_error(transport_error, transport_cause) def _validate_required_parameters( self, tool_name: str, arguments: dict[str, Any] | None @@ -949,7 +1058,7 @@ def _validate_required_parameters( arguments_to_validate = arguments else: raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "arguments must be an object." ) @@ -958,7 +1067,7 @@ def _validate_required_parameters( if missing: missing_text = ", ".join(sorted(missing)) raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"missing required parameters: {missing_text}" ) @@ -1022,6 +1131,8 @@ async def cleanup(self): # During normal teardown (via __aexit__), log but don't raise to avoid # masking the original exception. is_failed_connection_cleanup = self.session is None + cleanup_error: UserError | None = None + cleanup_cause: Exception | None = None try: await self.exit_stack.aclose() @@ -1038,7 +1149,6 @@ async def cleanup(self): http_error = None connect_error = None timeout_error = None - error_message = f"Failed to connect to MCP server '{self.name}': " for exc in eg.exceptions: if isinstance(exc, httpx.HTTPStatusError): @@ -1047,17 +1157,19 @@ async def cleanup(self): connect_error = exc elif isinstance(exc, httpx.TimeoutException): timeout_error = exc + del exc # Only raise HTTP errors if we're cleaning up after a failed connection. # During normal teardown, log them instead. if http_error: if is_failed_connection_cleanup: - error_message += f"HTTP error {http_error.response.status_code} ({http_error.response.reason_phrase})" # noqa: E501 - raise UserError(error_message) from http_error + cleanup_error = self._user_error_for_http_error(http_error) + cleanup_cause = _safe_transport_cause(http_error) + if cleanup_cause is None: + http_error = None else: # Normal teardown - log but don't raise - log_tool_action_warning( - logger, + _log_transport_warning( get_mcp_server_log_message( "HTTP error during cleanup of MCP server", self ), @@ -1065,11 +1177,12 @@ async def cleanup(self): ) elif connect_error: if is_failed_connection_cleanup: - error_message += "Could not reach the server." - raise UserError(error_message) from connect_error + cleanup_error = self._user_error_for_http_error(connect_error) + cleanup_cause = _safe_transport_cause(connect_error) + if cleanup_cause is None: + connect_error = None else: - log_tool_action_warning( - logger, + _log_transport_warning( get_mcp_server_log_message( "Connection error during cleanup of MCP server", self ), @@ -1077,11 +1190,12 @@ async def cleanup(self): ) elif timeout_error: if is_failed_connection_cleanup: - error_message += "Connection timeout." - raise UserError(error_message) from timeout_error + cleanup_error = self._user_error_for_http_error(timeout_error) + cleanup_cause = _safe_transport_cause(timeout_error) + if cleanup_cause is None: + timeout_error = None else: - log_tool_action_warning( - logger, + _log_transport_warning( get_mcp_server_log_message( "Timeout error during cleanup of MCP server", self ), @@ -1128,6 +1242,9 @@ async def cleanup(self): self.session = None self._get_session_id = None + if cleanup_error is not None: + self._raise_mapped_transport_error(cleanup_error, cleanup_cause) + class MCPServerStdioParams(TypedDict): """Mirrors `mcp.client.stdio.StdioServerParameters`, but lets you pass params without another @@ -1654,6 +1771,8 @@ async def call_tool( if not self.session: raise UserError("Server not initialized. Make sure you call `connect()` first.") + transport_error: UserError | None = None + transport_cause: Exception | None = None try: self._validate_required_parameters(tool_name=tool_name, arguments=arguments) retries_used = 0 @@ -1690,34 +1809,70 @@ async def call_tool( first_attempt = False except httpx.HTTPStatusError as e: status_code = e.response.status_code - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" - ) from e - except httpx.ConnectError as e: - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': Connection lost. " - f"The server may have disconnected." - ) from e + ) + transport_cause = _safe_transport_cause(e) + except httpx.RequestError as e: + transport_cause = _safe_transport_cause(e) + if transport_cause is not None and not isinstance(e, httpx.ConnectError): + raise + if isinstance(e, httpx.ConnectError): + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " + "Connection lost. The server may have disconnected." + ) + elif isinstance(e, httpx.TimeoutException): + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " + "Connection timeout." + ) + else: + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " + "Request failed." + ) except BaseExceptionGroup as e: - http_error = self._extract_http_error_from_exception(e) + http_errors = self._extract_http_errors_from_exception(e) + if not http_errors: + raise + + unsafe_http_error = _first_unsafe_transport_error(http_errors) + http_error = unsafe_http_error or http_errors[0] + transport_cause = None if unsafe_http_error is not None else http_error if isinstance(http_error, httpx.HTTPStatusError): status_code = http_error.response.status_code - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" - ) from http_error - if isinstance(http_error, httpx.ConnectError): - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + ) + elif isinstance(http_error, httpx.ConnectError): + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection lost. The server may have disconnected." - ) from http_error - if isinstance(http_error, httpx.TimeoutException): - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + ) + elif isinstance(http_error, httpx.TimeoutException): + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection timeout." - ) from http_error - raise + ) + elif isinstance(http_error, httpx.RequestError): + if transport_cause is not None: + raise + transport_error = UserError( + f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " + "Request failed." + ) + else: + raise + if transport_cause is None: + http_errors.clear() + del http_error + del unsafe_http_error + + assert transport_error is not None + self._raise_mapped_transport_error(transport_error, transport_cause) @property def name(self) -> str: diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 049b4561ba..f52ae996c5 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -705,7 +705,8 @@ async def invoke_mcp_tool( finished_task = done.pop() if finished_task.cancelled(): raise MCPToolCancellationError( - f"Failed to call tool '{tool.name}' on MCP server '{server.name}': " + f"Failed to call tool '{tool.name}' on MCP server " + f"'{get_mcp_server_log_name(server.name)}': " "tool execution was cancelled." ) result = finished_task.result() @@ -748,7 +749,8 @@ async def invoke_mcp_tool( ) log_tool_action_error(logger, log_message, e) raise AgentsException( - f"Error invoking MCP tool {tool_name_for_display} on server '{server.name}': {e}" + f"Error invoking MCP tool {tool_name_for_display} on server " + f"'{get_mcp_server_log_name(server.name)}': {e}" ) from e if _debug.DONT_LOG_TOOL_DATA: diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index e1a06f17eb..213ecf2ad0 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -774,6 +774,11 @@ async def call_tool( return await super().call_tool(tool_name, arguments, meta=meta) +_CREDENTIALED_SERVER_NAME = ( + "sse: https://user:s3cr3t_pw@mcp.example.com/sse?api_key=SECRET_QS_KEY#SECRET_FRAGMENT" +) + + class CleanupOnCancelFakeMCPServer(FakeMCPServer): def __init__(self, cleanup_finished: asyncio.Event): super().__init__() @@ -943,6 +948,45 @@ async def test_mcp_tool_inner_cancellation_becomes_tool_error(): assert "tool execution was cancelled" in result +@pytest.mark.asyncio +async def test_mcp_tool_inner_cancellation_sanitizes_url_derived_server_name(): + server = CancelledFakeMCPServer(server_name=_CREDENTIALED_SERVER_NAME) + server.add_tool("cancel_tool", {}) + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="cancel_tool", inputSchema={}) + + with pytest.raises(MCPToolCancellationError) as exc_info: + await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}") + + message = str(exc_info.value) + assert "cancel_tool" in message + assert "mcp.example.com/sse" in message + assert "s3cr3t_pw" not in message + assert "SECRET_QS_KEY" not in message + assert "SECRET_FRAGMENT" not in message + + +@pytest.mark.asyncio +async def test_mcp_tool_generic_error_sanitizes_only_url_derived_server_name(): + server = CrashingFakeMCPServer(server_name=_CREDENTIALED_SERVER_NAME) + server.add_tool("crashing_tool", {}) + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="crashing_tool", inputSchema={}) + + with pytest.raises(AgentsException) as exc_info: + await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}") + + message = str(exc_info.value) + assert "crashing_tool" in message + assert "mcp.example.com/sse" in message + assert "Crash!" in message + assert "s3cr3t_pw" not in message + assert "SECRET_QS_KEY" not in message + assert "SECRET_FRAGMENT" not in message + assert isinstance(exc_info.value.__cause__, Exception) + assert str(exc_info.value.__cause__) == "Crash!" + + @pytest.mark.asyncio async def test_mcp_tool_inner_cancellation_still_becomes_tool_error_with_prior_cancel_state(): current_task = asyncio.current_task() diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index 48335dfb47..d33308dfb6 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -1,13 +1,19 @@ import builtins +import logging import sys -from unittest.mock import MagicMock, patch +import traceback +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest -from agents import Agent +from agents import Agent, _debug from agents.exceptions import UserError -from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession +from agents.mcp.server import ( + MCPServerSse, + MCPServerStreamableHttp, + _MCPServerWithClientSession, +) from agents.run_context import RunContextWrapper # Handle Python version compatibility for ExceptionGroups @@ -17,6 +23,46 @@ BaseExceptionGroup = builtins.BaseExceptionGroup +_CREDENTIALED_URL = ( + "https://user:s3cr3t_pw@mcp.example.com/sse?api_key=SECRET_QS_KEY#SECRET_FRAGMENT" +) +_URL_SECRETS = ("user", "s3cr3t_pw", "SECRET_QS_KEY", "SECRET_FRAGMENT") +_SAFE_URL = "https://mcp.example.com/sse" + + +def _assert_url_credentials_hidden(error: BaseException) -> None: + rendered = "".join(traceback.format_exception(error)) + for secret in _URL_SECRETS: + assert secret not in str(error) + assert secret not in rendered + assert error.__cause__ is None + assert error.__context__ is None + + +def _assert_not_retained_in_traceback_locals(error: BaseException, sensitive_value: object) -> None: + current = error.__traceback__ + while current is not None: + if current.tb_frame.f_code.co_filename.endswith("/src/agents/mcp/server.py"): + assert all(value is not sensitive_value for value in current.tb_frame.f_locals.values()) + current = current.tb_next + + +def _assert_url_credentials_hidden_from_log_record(record: logging.LogRecord) -> None: + rendered = logging.Formatter("%(levelname)s %(message)s").format(record) + attached_values = repr( + { + "msg": record.msg, + "args": record.args, + "exc_info": record.exc_info, + "exc_text": record.exc_text, + "extra": record.__dict__, + } + ) + for secret in _URL_SECRETS: + assert secret not in rendered + assert secret not in attached_values + + class CrashingClientSessionServer(_MCPServerWithClientSession): def __init__(self): super().__init__(cache_tools_list=False, client_session_timeout_seconds=5) @@ -59,19 +105,27 @@ async def test_not_calling_connect_causes_error(): @pytest.mark.asyncio -async def test_call_tool_nested_exception_group_mapping(): +@pytest.mark.parametrize( + ("url", "retains_cause"), + [ + ("http://fake-mcp-server", True), + (_CREDENTIALED_URL, False), + ], +) +async def test_call_tool_nested_exception_group_mapping(url: str, retains_cause: bool): """ Regression test ensuring that nested ExceptionGroups containing HTTP errors are recursively extracted and mapped to a UserError in call_tool(). """ # 1. Initialize the server with mock streamable parameters - server = MCPServerStreamableHttp(params={"url": "http://fake-mcp-server"}) + server = MCPServerStreamableHttp(params={"url": url}) # 2. Simulate an active connection by mocking the session object server.session = MagicMock() # 3. Construct a nested ExceptionGroup hierarchy containing a connection error - http_error = httpx.ConnectError("Network unreachable") + request = httpx.Request("POST", url) + http_error = httpx.ConnectError("Network unreachable", request=request) inner_group = BaseExceptionGroup("inner_failures", [http_error]) outer_group = BaseExceptionGroup("outer_failures", [inner_group]) @@ -82,4 +136,407 @@ async def test_call_tool_nested_exception_group_mapping(): # 6. Verify that the user-facing message is mapped correctly based on the root cause assert "Connection lost" in str(exc_info.value) - assert exc_info.value.__cause__ is http_error + if retains_cause: + assert exc_info.value.__cause__ is http_error + else: + assert "mcp.example.com/sse" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_traceback_locals(exc_info.value, http_error) + + +def _mixed_request_error_group( + later_url: str, +) -> tuple[BaseExceptionGroup, httpx.ReadError, httpx.ConnectError]: + safe_error = httpx.ReadError( + "safe read failed", + request=httpx.Request("GET", _SAFE_URL), + ) + later_error = httpx.ConnectError( + "later connection failed", + request=httpx.Request("GET", later_url), + ) + nested_group = BaseExceptionGroup("later failures", [later_error]) + return BaseExceptionGroup("mixed failures", [safe_error, nested_group]), safe_error, later_error + + +@pytest.mark.asyncio +async def test_connect_checks_every_request_error_before_preserving_exception_group(): + server = MCPServerSse(params={"url": _SAFE_URL}) + error_group, _, unsafe_error = _mixed_request_error_group(_CREDENTIALED_URL) + + with patch.object(server, "create_streams", side_effect=error_group): + with pytest.raises(UserError) as exc_info: + await server.connect() + + assert "Could not reach the server" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_traceback_locals(exc_info.value, error_group) + _assert_not_retained_in_traceback_locals(exc_info.value, unsafe_error) + + +@pytest.mark.asyncio +async def test_call_tool_checks_every_request_error_before_preserving_exception_group(): + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + server.session = MagicMock() + server.max_retry_attempts = 0 + error_group, _, unsafe_error = _mixed_request_error_group(_CREDENTIALED_URL) + + with patch.object(server, "_call_tool_with_isolated_retry", side_effect=error_group): + with pytest.raises(UserError) as exc_info: + await server.call_tool("test_tool", {}) + + assert "Connection lost" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_traceback_locals(exc_info.value, error_group) + _assert_not_retained_in_traceback_locals(exc_info.value, unsafe_error) + + +@pytest.mark.asyncio +async def test_connect_preserves_exception_group_when_every_request_error_is_safe(): + server = MCPServerSse(params={"url": _SAFE_URL}) + error_group, _, _ = _mixed_request_error_group(_SAFE_URL) + + with patch.object(server, "create_streams", side_effect=error_group): + with pytest.raises(BaseExceptionGroup) as exc_info: + await server.connect() + + assert exc_info.value is error_group + + +@pytest.mark.parametrize("server_type", [MCPServerSse, MCPServerStreamableHttp]) +def test_error_name_sanitizes_url_derived_names_without_changing_runtime_name(server_type): + server = server_type(params={"url": _CREDENTIALED_URL}) + + assert server.name.endswith(_CREDENTIALED_URL) + assert server._error_name.endswith("https://mcp.example.com/sse") + + explicitly_named = server_type(params={"url": _CREDENTIALED_URL}, name="safe server") + assert explicitly_named._error_name == "safe server" + + +@pytest.mark.asyncio +async def test_connect_http_error_hides_url_credentials_from_exception_graph(): + server = MCPServerSse(params={"url": _CREDENTIALED_URL}) + request = httpx.Request("GET", _CREDENTIALED_URL) + http_error = httpx.HTTPStatusError( + "boom", + request=request, + response=httpx.Response(503, request=request), + ) + + with patch.object(server, "create_streams", side_effect=http_error): + with pytest.raises(UserError) as exc_info: + await server.connect() + + assert "mcp.example.com/sse" in str(exc_info.value) + assert "HTTP error 503 (Service Unavailable)" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_traceback_locals(exc_info.value, http_error) + + +@pytest.mark.asyncio +async def test_list_tools_http_error_hides_url_credentials_from_exception_graph(): + server = MCPServerSse(params={"url": _CREDENTIALED_URL}) + server.session = MagicMock() + request = httpx.Request("GET", _CREDENTIALED_URL) + http_error = httpx.HTTPStatusError( + "boom", request=request, response=httpx.Response(500, request=request) + ) + + with patch.object(server, "_run_with_retries", side_effect=http_error): + with pytest.raises(UserError) as exc_info: + await server.list_tools(None, None) + + assert "mcp.example.com/sse" in str(exc_info.value) + assert "HTTP error 500" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + + +@pytest.mark.asyncio +async def test_list_tools_http_error_hides_redirect_history_url_credentials(): + server = MCPServerSse(params={"url": _CREDENTIALED_URL}) + server.session = MagicMock() + final_request = httpx.Request("GET", "https://mcp.example.com/final") + redirect_response = httpx.Response( + 302, + request=httpx.Request("GET", _CREDENTIALED_URL), + ) + response = httpx.Response( + 500, + request=final_request, + history=[redirect_response], + ) + http_error = httpx.HTTPStatusError( + "boom", + request=final_request, + response=response, + ) + + with patch.object(server, "_run_with_retries", side_effect=http_error): + with pytest.raises(UserError) as exc_info: + await server.list_tools(None, None) + + assert "mcp.example.com/sse" in str(exc_info.value) + assert "HTTP error 500" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + + +@pytest.mark.asyncio +async def test_list_tools_http_error_hides_current_redirect_location_credentials(): + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + request = httpx.Request("GET", _SAFE_URL) + response = httpx.Response( + 302, + request=request, + headers={"location": _CREDENTIALED_URL}, + ) + with pytest.raises(httpx.HTTPStatusError) as http_error_info: + response.raise_for_status() + http_error = http_error_info.value + + with patch.object(server, "_run_with_retries", side_effect=http_error): + with pytest.raises(UserError) as exc_info: + await server.list_tools(None, None) + + assert "HTTP error 302" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_traceback_locals(exc_info.value, http_error) + + +@pytest.mark.asyncio +async def test_call_tool_connect_error_hides_url_credentials_from_exception_graph(): + server = MCPServerSse(params={"url": _CREDENTIALED_URL}) + server.session = MagicMock() + request = httpx.Request("POST", _CREDENTIALED_URL) + connect_error = httpx.ConnectError("down", request=request) + + with patch.object(server, "_run_with_retries", side_effect=connect_error): + with pytest.raises(UserError) as exc_info: + await server.call_tool("safe_tool", {}) + + assert "safe_tool" in str(exc_info.value) + assert "mcp.example.com/sse" in str(exc_info.value) + assert "Connection lost" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("server_type", [MCPServerSse, MCPServerStreamableHttp]) +@pytest.mark.parametrize( + ("url", "maps_to_user_error"), + [ + (_SAFE_URL, False), + (_CREDENTIALED_URL, True), + ], +) +async def test_list_tools_direct_timeout_only_maps_credentialed_urls( + server_type, url: str, maps_to_user_error: bool +): + server = server_type(params={"url": url}) + server.session = MagicMock() + timeout_error = httpx.ReadTimeout( + "timed out", + request=httpx.Request("GET", url), + ) + + with patch.object(server, "_run_with_retries", side_effect=timeout_error): + if maps_to_user_error: + with pytest.raises(UserError) as user_error_info: + await server.list_tools(None, None) + + assert "Connection timeout" in str(user_error_info.value) + _assert_url_credentials_hidden(user_error_info.value) + else: + with pytest.raises(httpx.ReadTimeout) as timeout_info: + await server.list_tools(None, None) + + assert timeout_info.value is timeout_error + + +@pytest.mark.asyncio +@pytest.mark.parametrize("server_type", [MCPServerSse, MCPServerStreamableHttp]) +@pytest.mark.parametrize( + ("url", "maps_to_user_error"), + [ + (_SAFE_URL, False), + (_CREDENTIALED_URL, True), + ], +) +async def test_call_tool_direct_timeout_only_maps_credentialed_urls( + server_type, url: str, maps_to_user_error: bool +): + server = server_type(params={"url": url}) + server.session = MagicMock() + server.max_retry_attempts = 0 + timeout_error = httpx.ReadTimeout( + "timed out", + request=httpx.Request("POST", url), + ) + retry_method = ( + "_call_tool_with_isolated_retry" + if server_type is MCPServerStreamableHttp + else "_run_with_retries" + ) + + with patch.object(server, retry_method, side_effect=timeout_error): + if maps_to_user_error: + with pytest.raises(UserError) as user_error_info: + await server.call_tool("safe_tool", {}) + + assert "Connection timeout" in str(user_error_info.value) + _assert_url_credentials_hidden(user_error_info.value) + else: + with pytest.raises(httpx.ReadTimeout) as timeout_info: + await server.call_tool("safe_tool", {}) + + assert timeout_info.value is timeout_error + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error_type", + [ + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, + httpx.ProxyError, + ], +) +@pytest.mark.parametrize( + ("url", "maps_to_user_error"), + [ + (_SAFE_URL, False), + (_CREDENTIALED_URL, True), + ], +) +async def test_list_tools_request_errors_only_map_credentialed_urls( + error_type, url: str, maps_to_user_error: bool +): + server = MCPServerSse(params={"url": url}) + server.session = MagicMock() + request_error = error_type( + "request failed", + request=httpx.Request("GET", url), + ) + + with patch.object(server, "_run_with_retries", side_effect=request_error): + if maps_to_user_error: + with pytest.raises(UserError) as user_error_info: + await server.list_tools(None, None) + + assert "Request failed" in str(user_error_info.value) + _assert_url_credentials_hidden(user_error_info.value) + _assert_not_retained_in_traceback_locals( + user_error_info.value, + request_error, + ) + else: + with pytest.raises(error_type) as request_error_info: + await server.list_tools(None, None) + + assert request_error_info.value is request_error + + +@pytest.mark.asyncio +@pytest.mark.parametrize("server_type", [MCPServerSse, MCPServerStreamableHttp]) +@pytest.mark.parametrize( + ("url", "maps_to_user_error"), + [ + (_SAFE_URL, False), + (_CREDENTIALED_URL, True), + ], +) +async def test_call_tool_request_error_only_maps_credentialed_urls( + server_type, url: str, maps_to_user_error: bool +): + server = server_type(params={"url": url}) + server.session = MagicMock() + server.max_retry_attempts = 0 + request_error = httpx.ReadError( + "request failed", + request=httpx.Request("POST", url), + ) + retry_method = ( + "_call_tool_with_isolated_retry" + if server_type is MCPServerStreamableHttp + else "_run_with_retries" + ) + + with patch.object(server, retry_method, side_effect=request_error): + if maps_to_user_error: + with pytest.raises(UserError) as user_error_info: + await server.call_tool("safe_tool", {}) + + assert "Request failed" in str(user_error_info.value) + _assert_url_credentials_hidden(user_error_info.value) + _assert_not_retained_in_traceback_locals( + user_error_info.value, + request_error, + ) + else: + with pytest.raises(httpx.ReadError) as request_error_info: + await server.call_tool("safe_tool", {}) + + assert request_error_info.value is request_error + + +@pytest.mark.asyncio +async def test_failed_connection_cleanup_hides_url_credentials_from_exception_graph(): + server = MCPServerSse(params={"url": _CREDENTIALED_URL}) + request = httpx.Request("GET", _CREDENTIALED_URL) + http_error = httpx.HTTPStatusError( + "boom", request=request, response=httpx.Response(502, request=request) + ) + cleanup_group = BaseExceptionGroup("cleanup failed", [http_error]) + + with patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)): + with pytest.raises(UserError) as exc_info: + await server.cleanup() + + assert "mcp.example.com/sse" in str(exc_info.value) + assert "HTTP error 502" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_traceback_locals(exc_info.value, http_error) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) +@pytest.mark.parametrize( + ("url", "safe_to_attach"), + [ + (_SAFE_URL, True), + (_CREDENTIALED_URL, False), + ], +) +async def test_normal_cleanup_only_logs_safe_transport_exceptions( + monkeypatch, + caplog, + redacted: bool, + url: str, + safe_to_attach: bool, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + server = MCPServerSse(params={"url": url}) + server.session = MagicMock() + timeout_error = httpx.ReadTimeout( + "timed out", + request=httpx.Request("GET", url), + ) + cleanup_group = BaseExceptionGroup("cleanup failed", [timeout_error]) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)), + caplog.at_level(logging.WARNING, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + if not redacted and safe_to_attach: + assert record.exc_info is not None + assert record.exc_info[1] is timeout_error + else: + assert record.exc_info is None + + if not safe_to_attach: + _assert_url_credentials_hidden_from_log_record(record) diff --git a/tests/mcp/test_streamable_http_client_factory.py b/tests/mcp/test_streamable_http_client_factory.py index 32f258b0f9..3e526db7b3 100644 --- a/tests/mcp/test_streamable_http_client_factory.py +++ b/tests/mcp/test_streamable_http_client_factory.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import logging from unittest.mock import MagicMock, patch import httpx @@ -11,6 +12,7 @@ from mcp.shared.message import SessionMessage from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest +from agents import _debug from agents.mcp import MCPServerStreamableHttp from agents.mcp.server import ( _create_default_streamable_http_client, @@ -313,6 +315,52 @@ async def handler(request: httpx.Request) -> httpx.Response: await read_stream_writer.aclose() +@pytest.mark.asyncio +@pytest.mark.parametrize("redacted", [True, False]) +async def test_initialized_notification_transport_exception_hides_url_credentials_in_log( + monkeypatch, + caplog, + redacted: bool, +): + url = "https://user:s3cr3t_pw@example.test/mcp?api_key=SECRET_QS_KEY#SECRET_FRAGMENT" + secrets = ("user", "s3cr3t_pw", "SECRET_QS_KEY", "SECRET_FRAGMENT") + + async def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=request) + + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + transport = _InitializedNotificationTolerantStreamableHTTPTransport(url) + read_stream_writer, _ = create_memory_object_stream[SessionMessage | Exception](0) + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + try: + ctx = MagicMock() + ctx.client = client + ctx.read_stream_writer = read_stream_writer + ctx.session_message = SessionMessage( + JSONRPCMessage( + JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + params={}, + ) + ) + ) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + await transport._handle_post_request(ctx) + finally: + await client.aclose() + await read_stream_writer.aclose() + + record = caplog.records[-1] + rendered = logging.Formatter("%(levelname)s %(message)s").format(record) + attached_values = repr(record.__dict__) + assert record.exc_info is None + for secret in secrets: + assert secret not in rendered + assert secret not in attached_values + + @pytest.mark.asyncio async def test_streamable_http_server_passes_ignore_initialized_notification_failure(): with patch("agents.mcp.server._streamablehttp_client_with_transport") as mock_client: From bdc19899934d011481ff511cbbe3808cffca82b8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 30 Jul 2026 23:33:38 +0900 Subject: [PATCH 058/473] fix(mcp): redact URL credentials from tracing and tool metadata (#4020) Co-authored-by: rajashidattapy --- src/agents/mcp/util.py | 13 ++++---- tests/mcp/test_mcp_tracing.py | 36 ++++++++++++++++++++++ tests/mcp/test_mcp_util.py | 56 +++++++++++++++++++++++++++++++++++ tests/test_tool_origin.py | 32 ++++++++++++++++++++ 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index f52ae996c5..af62873f6b 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -342,7 +342,7 @@ async def _list_tools_with_span( run_context: RunContextWrapper[Any], agent: AgentBase, ) -> list[MCPTool]: - with mcp_tools_span(server=server.name) as span: + with mcp_tools_span(server=get_mcp_server_log_name(server.name)) as span: tools = await server.list_tools(run_context, agent) span.span_data.result = [tool.name for tool in tools] return tools @@ -451,7 +451,7 @@ def _build_prefixed_tool_name_overrides( not depend on object identity or cross any serialization boundary. """ base_names = [ - cls._build_prefixed_tool_base_name(server.name, tool.name) + cls._build_prefixed_tool_base_name(get_mcp_server_log_name(server.name), tool.name) for _, server, tools in server_tool_batches for tool in tools ] @@ -459,9 +459,10 @@ def _build_prefixed_tool_name_overrides( candidates: list[_PrefixedToolNameCandidate] = [] for server_index, server, tools in server_tool_batches: + server_name = get_mcp_server_log_name(server.name) for tool_index, tool in enumerate(tools): - base_name = cls._build_prefixed_tool_base_name(server.name, tool.name) - seed = f"{server.name}\0{tool.name}" + base_name = cls._build_prefixed_tool_base_name(server_name, tool.name) + seed = f"{server_name}\0{tool.name}" force_hash = base_name_counts[base_name] > 1 or base_name in reserved_names initial_name = cls._shorten_tool_name(base_name, seed, force_hash=force_hash) candidates.append( @@ -571,7 +572,7 @@ def to_function_tool( mcp_title=resolve_mcp_tool_title(tool), tool_origin=ToolOrigin( type=ToolOriginType.MCP, - mcp_server_name=server.name, + mcp_server_name=get_mcp_server_log_name(server.name), ), ) return function_tool @@ -803,7 +804,7 @@ async def invoke_mcp_tool( ): current_span.span_data.output = tool_output current_span.span_data.mcp_data = { - "server": server.name, + "server": get_mcp_server_log_name(server.name), } else: if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: diff --git a/tests/mcp/test_mcp_tracing.py b/tests/mcp/test_mcp_tracing.py index b49a331464..7654ab948f 100644 --- a/tests/mcp/test_mcp_tracing.py +++ b/tests/mcp/test_mcp_tracing.py @@ -1,3 +1,5 @@ +import json + import pytest from inline_snapshot import snapshot @@ -272,3 +274,37 @@ async def test_mcp_tracing_redacts_output_when_sensitive_data_disabled(): } ] ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("trace_include_sensitive_data", [True, False]) +async def test_mcp_tracing_always_hides_url_credentials( + trace_include_sensitive_data: bool, +): + model = FakeModel() + server = FakeMCPServer( + server_name=( + "streamable_http: https://user:s3cr3t_pw@mcp.example.test:8443/mcp" + "?api_key=SECRET_QS_KEY#SECRET_FRAGMENT" + ) + ) + server.add_tool("search", {}) + agent = Agent(name="test", model=model, mcp_servers=[server]) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("search", "")], + [get_text_message("done")], + ] + ) + + await Runner.run( + agent, + input="trace_url_credentials", + run_config=RunConfig(trace_include_sensitive_data=trace_include_sensitive_data), + ) + + serialized_spans = json.dumps(fetch_normalized_spans()) + safe_server_name = "streamable_http: https://mcp.example.test:8443/mcp" + assert serialized_spans.count(safe_server_name) == 3 + for secret in ("user", "s3cr3t_pw", "SECRET_QS_KEY", "SECRET_FRAGMENT"): + assert secret not in serialized_spans diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 213ecf2ad0..5e88e6c579 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -43,6 +43,13 @@ class Bar(BaseModel): Baz = TypeAdapter(dict[str, str]) +_URL_DERIVED_SECRET_SERVER_NAME = ( + "streamable_http: https://user:s3cr3t_pw@mcp.example.test:8443/mcp" + "?api_key=SECRET_QS_KEY#SECRET_FRAGMENT" +) +_SANITIZED_SERVER_NAME = "streamable_http: https://mcp.example.test:8443/mcp" +_SERVER_URL_SECRETS = ("user", "s3cr3t_pw", "SECRET_QS_KEY", "SECRET_FRAGMENT") + def _convertible_schema() -> dict[str, Any]: schema = Foo.model_json_schema() @@ -210,6 +217,31 @@ def resolve_meta(context): assert captured_meta_context == {"server_name": "calendar", "tool_name": "search"} +@pytest.mark.asyncio +async def test_get_all_function_tools_hides_url_credentials_from_public_name_and_origin(): + server = FakeMCPServer(server_name=_URL_DERIVED_SECRET_SERVER_NAME) + server.add_tool("search", {}) + + tools = await MCPUtil.get_all_function_tools( + [server], + False, + RunContextWrapper(context=None), + Agent(name="test_agent", instructions="Test agent"), + include_server_in_tool_names=True, + ) + + assert len(tools) == 1 + function_tool = tools[0] + assert isinstance(function_tool, FunctionTool) + assert function_tool.name == ("mcp_streamable_http__https___mcp_example_test_8443_mcp__search") + assert function_tool._tool_origin is not None + assert function_tool._tool_origin.mcp_server_name == _SANITIZED_SERVER_NAME + assert server.name == _URL_DERIVED_SECRET_SERVER_NAME + for secret in _SERVER_URL_SECRETS: + assert secret not in function_tool.name + assert secret not in repr(function_tool._tool_origin) + + @pytest.mark.asyncio async def test_get_all_function_tools_prefixes_non_ascii_server_names_safely(): server = FakeMCPServer(server_name="天気サーバー") @@ -1218,6 +1250,30 @@ async def test_mcp_tool_graceful_error_handling(caplog: pytest.LogCaptureFixture ) +@pytest.mark.asyncio +async def test_mcp_default_tool_error_hides_url_credentials(): + server = SecretCrashingFakeMCPServer(server_name=_URL_DERIVED_SECRET_SERVER_NAME) + function_tool = MCPUtil.to_function_tool( + MCPTool(name="crashing_tool", inputSchema={}), + server, + convert_schemas_to_strict=False, + agent=Agent(name="test-agent"), + ) + tool_context = ToolContext( + context=None, + tool_name="crashing_tool", + tool_call_id="test_call_url_credentials", + tool_arguments="{}", + ) + + result = await function_tool.on_invoke_tool(tool_context, "{}") + + assert isinstance(result, str) + assert _SANITIZED_SERVER_NAME in result + for secret in _SERVER_URL_SECRETS: + assert secret not in result + + @pytest.mark.asyncio async def test_mcp_tool_timeout_handling(): """Test that MCP tool timeouts are handled gracefully. diff --git a/tests/test_tool_origin.py b/tests/test_tool_origin.py index 31ba25561b..969b089447 100644 --- a/tests/test_tool_origin.py +++ b/tests/test_tool_origin.py @@ -165,6 +165,38 @@ async def test_runner_attaches_local_mcp_tool_origin_to_call_and_output_items() assert _first_item(result.new_items, ToolCallOutputItem).tool_origin == expected +@pytest.mark.asyncio +async def test_local_mcp_tool_origin_hides_url_credentials_in_run_state() -> None: + raw_server_name = ( + "streamable_http: https://user:s3cr3t_pw@mcp.example.test:8443/mcp" + "?api_key=SECRET_QS_KEY#SECRET_FRAGMENT" + ) + safe_server_name = "streamable_http: https://mcp.example.test:8443/mcp" + model = FakeModel() + server = FakeMCPServer( + server_name=raw_server_name, + tools=[MCPTool(name="search_docs", inputSchema={})], + ) + agent = Agent(name="mcp-agent", model=model, mcp_servers=[server]) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("search_docs", json.dumps({}), call_id="call_search_docs")], + [get_text_message("done")], + ] + ) + + result = await Runner.run(agent, input="hello") + + expected = ToolOrigin(type=ToolOriginType.MCP, mcp_server_name=safe_server_name) + assert _first_item(result.new_items, ToolCallItem).tool_origin == expected + assert _first_item(result.new_items, ToolCallOutputItem).tool_origin == expected + serialized_state = json.dumps(result.to_state().to_json()) + assert safe_server_name in serialized_state + for secret in ("user", "s3cr3t_pw", "SECRET_QS_KEY", "SECRET_FRAGMENT"): + assert secret not in serialized_state + assert server.name == raw_server_name + + @pytest.mark.asyncio async def test_streamed_tool_call_item_includes_local_mcp_origin() -> None: model = FakeModel() From d188d153dc926208543707b347aab85b1864f43e Mon Sep 17 00:00:00 2001 From: Ali Adnan <165782963+AAliKKhan@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:05:02 +0500 Subject: [PATCH 059/473] fix: consolidate same-exception re-raises into bare raise across all modules (#4023) --- src/agents/mcp/server.py | 2 +- src/agents/run_internal/tool_execution.py | 2 +- src/agents/voice/models/openai_stt.py | 10 +++++----- src/agents/voice/pipeline.py | 4 ++-- src/agents/voice/result.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 941eece7bd..2b5a8a25fa 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -1798,7 +1798,7 @@ async def call_tool( if self.max_retry_attempts != -1 and retries_used >= self.max_retry_attempts: if exc.__cause__ is not None: raise exc.__cause__ from exc - raise exc + raise backoff = self.retry_backoff_seconds_base * (2 ** (retries_used - 1)) await asyncio.sleep(backoff) except Exception: diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 4918083651..fe3a388213 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -1690,7 +1690,7 @@ async def _run_single_tool( ) ) if isinstance(e, AgentsException): - raise e + raise raise UserError(f"Error running tool {func_tool.name}: {e}") from e if self.config.trace_include_sensitive_data: diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index d3be57ca52..64efdcb4f0 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -202,7 +202,7 @@ async def _setup_connection(self, ws: websockets.ClientConnection) -> None: raise wrapped_err from e except Exception as e: await self._output_queue.put(ErrorSentinel(e)) - raise e + raise await self._configure_session() @@ -252,7 +252,7 @@ async def _handle_events(self) -> None: break except Exception as e: await self._output_queue.put(ErrorSentinel(e)) - raise e + raise await self._output_queue.put(SessionCompleteSentinel()) async def _stream_audio( @@ -279,7 +279,7 @@ async def _stream_audio( break except Exception as e: await self._output_queue.put(ErrorSentinel(e)) - raise e + raise await asyncio.sleep(0) # yield control @@ -303,7 +303,7 @@ async def _process_websocket_connection(self) -> None: raise AgentsException("Listener task not initialized") except Exception as e: await self._output_queue.put(ErrorSentinel(e)) - raise e + raise def _check_errors(self) -> None: if self._connection_task and self._connection_task.done(): @@ -447,7 +447,7 @@ async def transcribe( data={}, ) ) - raise e + raise async def create_session( self, diff --git a/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py index da2bdceafd..7fc659b285 100644 --- a/src/agents/voice/pipeline.py +++ b/src/agents/voice/pipeline.py @@ -115,7 +115,7 @@ async def stream_events(): except Exception as e: log_model_and_tool_action_error(logger, "Error processing single voice turn", e) await output._add_error(e) - raise e + raise output._set_task(asyncio.create_task(stream_events())) return output @@ -158,7 +158,7 @@ async def process_turns(): except Exception as e: log_model_and_tool_action_error(logger, "Error processing voice turns", e) await output._add_error(e) - raise e + raise finally: if transcription_session is not None: await transcription_session.close() diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 709c829779..43443fb966 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -196,7 +196,7 @@ async def _stream_audio( # Signal completion for whole session because of error await local_queue.put(VoiceStreamEventLifecycle(event="session_ended")) - raise e + raise async def _add_text(self, text: str): await self._start_turn() From e76f8358b176b04e09f3c0c4ad8167f25ee032e8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 31 Jul 2026 00:14:28 +0900 Subject: [PATCH 060/473] chore: enable Ruff TRY201 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 36b81d71ef..602b531f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,7 @@ select = [ "RUF006", # unowned asyncio tasks "RUF012", # mutable class attributes without ClassVar "RUF100", # unused noqa directives + "TRY201", # needless exception name in raise statements "UP", # pyupgrade ] isort = { combine-as-imports = true, known-first-party = ["agents"] } From 80915c8c619874050ac27d354da6a31654584bf3 Mon Sep 17 00:00:00 2001 From: chinmayv095 Date: Thu, 30 Jul 2026 21:09:07 +0530 Subject: [PATCH 061/473] fix(memory): count valid SQLAlchemy and MongoDB session items for positive limits (#4032) --- .../extensions/memory/mongodb_session.py | 41 ++++++----- .../extensions/memory/sqlalchemy_session.py | 72 ++++++++++++------- .../extensions/memory/test_mongodb_session.py | 33 +++++++++ .../memory/test_sqlalchemy_session.py | 35 +++++++++ 4 files changed, 138 insertions(+), 43 deletions(-) diff --git a/src/agents/extensions/memory/mongodb_session.py b/src/agents/extensions/memory/mongodb_session.py index 98f7f26008..3886c7b853 100644 --- a/src/agents/extensions/memory/mongodb_session.py +++ b/src/agents/extensions/memory/mongodb_session.py @@ -277,25 +277,34 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: query = {"session_id": self.session_id} + async def _decode_docs(docs: list[Any]) -> list[TResponseInputItem]: + items: list[TResponseInputItem] = [] + for doc in docs: + try: + items.append(await self._deserialize_item(doc["message_data"])) + except (json.JSONDecodeError, KeyError, TypeError): + # Skip corrupted or malformed documents (including non-string BSON values). + continue + return items + if session_limit is None: cursor = self._messages.find(query).sort("seq", 1) - docs = await cursor.to_list() - else: - # Fetch the latest N documents in reverse order, then reverse the - # list to restore chronological order. - cursor = self._messages.find(query).sort("seq", -1).limit(session_limit) - docs = await cursor.to_list() - docs.reverse() + return await _decode_docs(await cursor.to_list()) - items: list[TResponseInputItem] = [] - for doc in docs: - try: - items.append(await self._deserialize_item(doc["message_data"])) - except (json.JSONDecodeError, KeyError, TypeError): - # Skip corrupted or malformed documents (including non-string BSON values). - continue - - return items + # Fetch the latest N documents in reverse order, then reverse the + # list to restore chronological order. Expand the fetch window when corrupt + # documents sit among the newest entries so limit counts valid conversation + # items, matching pop_item and the SQLite backends. + window = session_limit + while True: + cursor = self._messages.find(query).sort("seq", -1).limit(window) + docs = await cursor.to_list() + items = await _decode_docs(docs[::-1]) + if len(items) >= session_limit: + return items[-session_limit:] + if len(docs) < window: + return items + window *= 2 async def add_items(self, items: list[TResponseInputItem]) -> None: """Add new items to the conversation history. diff --git a/src/agents/extensions/memory/sqlalchemy_session.py b/src/agents/extensions/memory/sqlalchemy_session.py index 3fc793d328..977c25cfa4 100644 --- a/src/agents/extensions/memory/sqlalchemy_session.py +++ b/src/agents/extensions/memory/sqlalchemy_session.py @@ -35,6 +35,7 @@ Index, Integer, MetaData, + Select, String, Table, Text, @@ -300,6 +301,29 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: session_limit = resolve_session_limit(limit, self.session_settings) + async def _decode_rows(rows: list[str]) -> list[TResponseInputItem]: + items: list[TResponseInputItem] = [] + for raw in rows: + try: + items.append(await self._deserialize_item(raw)) + except json.JSONDecodeError: + # Skip corrupted rows + continue + return items + + def _latest_first_stmt(row_limit: int) -> Select[tuple[str]]: + # Use DESC + LIMIT to get the latest N + # then reverse later for chronological order. + return ( + select(self._messages.c.message_data) + .where(self._messages.c.session_id == self.session_id) + .order_by( + self._messages.c.created_at.desc(), + self._messages.c.id.desc(), + ) + .limit(row_limit) + ) + async with self._session_factory() as sess: if session_limit is None: stmt = ( @@ -310,33 +334,27 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: self._messages.c.id.asc(), ) ) - else: - stmt = ( - select(self._messages.c.message_data) - .where(self._messages.c.session_id == self.session_id) - # Use DESC + LIMIT to get the latest N - # then reverse later for chronological order. - .order_by( - self._messages.c.created_at.desc(), - self._messages.c.id.desc(), - ) - .limit(session_limit) - ) - - result = await sess.execute(stmt) - rows: list[str] = [row[0] for row in result.all()] - - if session_limit is not None: - rows.reverse() - - items: list[TResponseInputItem] = [] - for raw in rows: - try: - items.append(await self._deserialize_item(raw)) - except json.JSONDecodeError: - # Skip corrupted rows - continue - return items + result = await sess.execute(stmt) + return await _decode_rows([row[0] for row in result.all()]) + + if session_limit > 0: + # Expand the fetch window when corrupt rows sit among the newest entries so + # limit counts valid conversation items, matching pop_item and the SQLite + # backends. + window = session_limit + while True: + result = await sess.execute(_latest_first_stmt(window)) + rows: list[str] = [row[0] for row in result.all()] + items = await _decode_rows(rows[::-1]) + if len(items) >= session_limit: + return items[-session_limit:] + if len(rows) < window: + return items + window *= 2 + + # Preserve existing non-positive LIMIT semantics, which are dialect-defined. + result = await sess.execute(_latest_first_stmt(session_limit)) + return await _decode_rows([row[0] for row in result.all()][::-1]) async def add_items(self, items: list[TResponseInputItem]) -> None: """Add new items to the conversation history. diff --git a/tests/extensions/memory/test_mongodb_session.py b/tests/extensions/memory/test_mongodb_session.py index 98cfc2654d..d463f458ba 100644 --- a/tests/extensions/memory/test_mongodb_session.py +++ b/tests/extensions/memory/test_mongodb_session.py @@ -541,6 +541,39 @@ async def test_non_string_message_data_is_skipped(session: MongoDBSession) -> No assert items[0].get("content") == "valid" +async def test_get_items_limit_skips_corrupt_newest_docs(session: MongoDBSession) -> None: + """limit counts valid items, expanding past corrupt newest documents.""" + await session.add_items( + [ + {"role": "user", "content": "valid 0"}, + {"role": "assistant", "content": "valid 1"}, + {"role": "user", "content": "valid 2"}, + ] + ) + + # Inject a corrupt document with a higher seq so it sorts as "most recent". + bad_doc = { + "_id": FakeObjectId(), + "session_id": session.session_id, + "seq": 999, + "message_data": "not valid json {{{", + } + session._messages._docs[id(bad_doc["_id"])] = bad_doc + + limited = await session.get_items(limit=2) + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + + +async def test_get_items_limit_returns_fewer_when_history_exhausted( + session: MongoDBSession, +) -> None: + """Window expansion stops at the end of history instead of looping.""" + await session.add_items([{"role": "user", "content": "only valid"}]) + + retrieved = await session.get_items(limit=5) + assert [item.get("content") for item in retrieved] == ["only valid"] + + async def test_pop_item_skips_corrupt_most_recent(session: MongoDBSession) -> None: """pop_item must skip a corrupt most-recent document and return the next valid one.""" await session.add_items([{"role": "user", "content": "valid"}]) diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index c75d7d6141..25f3001a2e 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -256,6 +256,41 @@ async def test_pop_item_skips_corrupt_most_recent(): assert await session.get_items() == [] +async def test_get_items_limit_skips_corrupt_newest_rows(): + """limit counts valid items, expanding past corrupt newest rows.""" + session = SQLAlchemySession.from_url("limit_corrupt", url=DB_URL, create_tables=True) + + await session.add_items( + [ + {"role": "user", "content": "valid 0"}, + {"role": "assistant", "content": "valid 1"}, + {"role": "user", "content": "valid 2"}, + ] + ) + + await session._ensure_tables() + async with session._session_factory() as sess: + async with sess.begin(): + await sess.execute( + insert(session._messages).values( + {"session_id": session.session_id, "message_data": "not valid json {{{"} + ) + ) + + limited = await session.get_items(limit=2) + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + + +async def test_get_items_limit_returns_fewer_when_history_exhausted(): + """Window expansion stops at the end of history instead of looping.""" + session = SQLAlchemySession.from_url("limit_exhausted", url=DB_URL, create_tables=True) + + await session.add_items([{"role": "user", "content": "only valid"}]) + + retrieved = await session.get_items(limit=5) + assert [item.get("content") for item in retrieved] == ["only valid"] + + async def test_pop_item_returns_none_after_dropping_only_corrupt_rows(): """pop_item removes corrupt rows and returns None when no valid items remain.""" session = SQLAlchemySession.from_url("pop_only_corrupt", url=DB_URL, create_tables=True) From f2bd57bf0d3b09da51fabefad4199a520c4da934 Mon Sep 17 00:00:00 2001 From: chinmayv095 Date: Thu, 30 Jul 2026 21:09:21 +0530 Subject: [PATCH 062/473] fix(memory): count valid AdvancedSQLiteSession items for positive limits (#4031) --- .../memory/advanced_sqlite_session.py | 118 ++++++++---------- .../memory/test_advanced_sqlite_session.py | 53 ++++++++ 2 files changed, 103 insertions(+), 68 deletions(-) diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index 822c123570..cbf8f510f1 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -206,49 +206,15 @@ async def get_items( if branch_id is None: branch_id = self._current_branch_id - # Get all items for this branch - def _get_all_items_sync(): - """Synchronous helper to get all items for a branch.""" - with self._locked_connection() as conn: - with closing(conn.cursor()) as cursor: - if session_limit is None: - cursor.execute( - f""" - SELECT m.message_data - FROM {self.messages_table} m - JOIN message_structure s ON m.id = s.message_id - WHERE m.session_id = ? AND s.branch_id = ? - ORDER BY s.sequence_number ASC - """, - (self.session_id, branch_id), - ) - else: - cursor.execute( - f""" - SELECT m.message_data - FROM {self.messages_table} m - JOIN message_structure s ON m.id = s.message_id - WHERE m.session_id = ? AND s.branch_id = ? - ORDER BY s.sequence_number DESC - LIMIT ? - """, - (self.session_id, branch_id, session_limit), - ) - - rows = cursor.fetchall() - if session_limit is not None: - rows = list(reversed(rows)) - - items = [] - for (message_data,) in rows: - try: - item = json.loads(message_data) - items.append(item) - except json.JSONDecodeError: - continue - return items - - return await asyncio.to_thread(_get_all_items_sync) + def _decode_rows(rows: list[Any]) -> list[TResponseInputItem]: + items: list[TResponseInputItem] = [] + for (message_data,) in rows: + try: + item = json.loads(message_data) + items.append(item) + except json.JSONDecodeError: + continue + return items def _get_items_sync(): """Synchronous helper to get items for a specific branch.""" @@ -266,31 +232,47 @@ def _get_items_sync(): """, (self.session_id, branch_id), ) - else: - cursor.execute( - f""" - SELECT m.message_data - FROM {self.messages_table} m - JOIN message_structure s ON m.id = s.message_id - WHERE m.session_id = ? AND s.branch_id = ? - ORDER BY s.sequence_number DESC - LIMIT ? - """, - (self.session_id, branch_id, session_limit), - ) - - rows = cursor.fetchall() - if session_limit is not None: - rows = list(reversed(rows)) - - items = [] - for (message_data,) in rows: - try: - item = json.loads(message_data) - items.append(item) - except json.JSONDecodeError: - continue - return items + return _decode_rows(cursor.fetchall()) + + if session_limit > 0: + # Expand the fetch window when corrupt rows sit among the newest + # entries so limit counts valid conversation items, matching + # SQLiteSession.get_items and the inherited pop_item. + window = session_limit + while True: + cursor.execute( + f""" + SELECT m.message_data + FROM {self.messages_table} m + JOIN message_structure s ON m.id = s.message_id + WHERE m.session_id = ? AND s.branch_id = ? + ORDER BY s.sequence_number DESC + LIMIT ? + """, + (self.session_id, branch_id, window), + ) + rows = cursor.fetchall() + items = _decode_rows(list(reversed(rows))) + if len(items) >= session_limit: + return items[-session_limit:] + if len(rows) < window: + return items + window *= 2 + + # Preserve historical non-positive LIMIT semantics (including SQLite's + # unlimited behavior for negative values). + cursor.execute( + f""" + SELECT m.message_data + FROM {self.messages_table} m + JOIN message_structure s ON m.id = s.message_id + WHERE m.session_id = ? AND s.branch_id = ? + ORDER BY s.sequence_number DESC + LIMIT ? + """, + (self.session_id, branch_id, session_limit), + ) + return _decode_rows(list(reversed(cursor.fetchall()))) return await asyncio.to_thread(_get_items_sync) diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 1e1973ba39..ae1606f249 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -1782,6 +1782,59 @@ async def test_get_items_explicit_limit_overrides_session_settings(): session.close() +async def test_get_items_limit_skips_corrupt_newest_rows(): + """limit counts valid items, expanding past corrupt newest rows.""" + session = AdvancedSQLiteSession(session_id="limit_corrupt_test", create_tables=True) + + await session.add_items( + [ + {"role": "user", "content": "valid 0"}, + {"role": "assistant", "content": "valid 1"}, + {"role": "user", "content": "valid 2"}, + ] + ) + + # Append a corrupt newest row, with the branch structure the JOIN needs. + conn = session._get_connection() + cursor = conn.execute( + f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)", + (session.session_id, "not valid json {{{"), + ) + next_sequence = conn.execute( + "SELECT COALESCE(MAX(sequence_number), 0) + 1 FROM message_structure " + "WHERE session_id = ? AND branch_id = ?", + (session.session_id, "main"), + ).fetchone()[0] + conn.execute( + "INSERT INTO message_structure " + "(session_id, message_id, branch_id, sequence_number, message_type, " + "user_turn_number, branch_turn_number) VALUES (?, ?, ?, ?, ?, ?, ?)", + (session.session_id, cursor.lastrowid, "main", next_sequence, "user", 1, 1), + ) + conn.commit() + + limited = await session.get_items(limit=2) + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + + # The explicit-branch call resolves to the same rows. + limited_explicit = await session.get_items(limit=2, branch_id="main") + assert [item.get("content") for item in limited_explicit] == ["valid 1", "valid 2"] + + session.close() + + +async def test_get_items_limit_returns_fewer_when_history_exhausted(): + """Window expansion stops at the end of history instead of looping.""" + session = AdvancedSQLiteSession(session_id="limit_exhausted_test", create_tables=True) + + await session.add_items([{"role": "user", "content": "only valid"}]) + + retrieved = await session.get_items(limit=5) + assert [item.get("content") for item in retrieved] == ["only valid"] + + session.close() + + async def test_session_settings_resolve(): """Test SessionSettings.resolve() method.""" from agents.memory import SessionSettings From d3ea084b0b7f269b467d1ff818b9ab7e81dfcf57 Mon Sep 17 00:00:00 2001 From: King Star Date: Thu, 30 Jul 2026 23:40:00 +0800 Subject: [PATCH 063/473] fix(mcp): clean failed servers before reconnecting (#3939) --- src/agents/mcp/manager.py | 12 +++-- tests/mcp/test_mcp_server_manager.py | 80 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index b8838be3e3..1da667e2b8 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -234,7 +234,8 @@ async def reconnect(self, *, failed_only: bool = True) -> list[MCPServer]: If False, cleanup and retry all servers. """ if failed_only: - servers_to_retry = self._unique_servers(self.failed_servers) + failed_servers = self._unique_servers(self.failed_servers) + servers_to_retry = await self._cleanup_servers(failed_servers) else: await self.cleanup_all() servers_to_retry = list(self._all_servers) @@ -349,8 +350,10 @@ async def _cleanup_server(self, server: MCPServer) -> None: finally: self._connected_servers.discard(server) - async def _cleanup_servers(self, servers: Iterable[MCPServer]) -> None: - for server in reversed(list(servers)): + async def _cleanup_servers(self, servers: Iterable[MCPServer]) -> list[MCPServer]: + servers_list = list(servers) + cleaned_servers: set[MCPServer] = set() + for server in reversed(servers_list): try: await self._cleanup_server(server) except asyncio.CancelledError as exc: @@ -369,6 +372,9 @@ async def _cleanup_servers(self, servers: Iterable[MCPServer]) -> None: exc, ) self.errors[server] = exc + else: + cleaned_servers.add(server) + return [server for server in servers_list if server in cleaned_servers] async def _connect_all_parallel(self, servers: list[MCPServer]) -> None: tasks = [ diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index f1f769eb6f..d6a0830474 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -124,6 +124,36 @@ async def read_resource(self, uri: str) -> ReadResourceResult: return ReadResourceResult(contents=[]) +class PartialFailureServer(FlakyServer): + def __init__(self, *, fail_cleanup: bool = False) -> None: + super().__init__(failures=0) + self.fail_cleanup = fail_cleanup + self.cleanup_calls = 0 + self.resource_open = False + self._connect_task: asyncio.Task[object] | None = None + + @property + def name(self) -> str: + return "partial-failure" + + async def connect(self) -> None: + self.connect_calls += 1 + self._connect_task = asyncio.current_task() + if self.resource_open: + raise RuntimeError("connect called without cleanup") + self.resource_open = True + if self.connect_calls == 1: + raise RuntimeError("connect failed after opening resource") + + async def cleanup(self) -> None: + self.cleanup_calls += 1 + if asyncio.current_task() is not self._connect_task: + raise RuntimeError("Attempted to exit cancel scope in a different task") + if self.fail_cleanup: + raise RuntimeError("cleanup failed") + self.resource_open = False + + class SensitiveNamedServer(FlakyServer): def __init__(self, name: str) -> None: super().__init__(failures=1) @@ -400,6 +430,56 @@ async def test_manager_reconnect_failed_only() -> None: assert manager.failed_servers == [] +@pytest.mark.asyncio +@pytest.mark.parametrize("connect_in_parallel", [False, True]) +async def test_manager_reconnect_cleans_partial_failure_before_retry( + connect_in_parallel: bool, +) -> None: + healthy_server = CleanupAwareServer() + failed_server = PartialFailureServer() + manager = MCPServerManager( + [healthy_server, failed_server], connect_in_parallel=connect_in_parallel + ) + try: + await manager.connect_all() + + assert manager.active_servers == [healthy_server] + assert manager.failed_servers == [failed_server] + + await manager.reconnect() + + assert manager.active_servers == [healthy_server, failed_server] + assert manager.failed_servers == [] + assert failed_server not in manager.errors + assert failed_server.connect_calls == 2 + assert failed_server.cleanup_calls == 1 + assert failed_server.resource_open is True + assert healthy_server.connect_calls == 1 + assert healthy_server.cleanup_calls == 0 + finally: + await manager.cleanup_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connect_in_parallel", [False, True]) +async def test_manager_reconnect_does_not_retry_after_cleanup_failure( + connect_in_parallel: bool, +) -> None: + server = PartialFailureServer(fail_cleanup=True) + manager = MCPServerManager([server], connect_in_parallel=connect_in_parallel) + + await manager.connect_all() + await manager.reconnect() + + assert manager.active_servers == [] + assert manager.failed_servers == [server] + assert server.connect_calls == 1 + assert server.cleanup_calls == 1 + assert server.resource_open is True + assert str(manager.errors[server]) == "cleanup failed" + assert manager._workers == {} + + @pytest.mark.asyncio async def test_manager_reconnect_deduplicates_failures() -> None: server = FlakyServer(failures=2) From 974733eff567edeb8d550f042610fa9966efa5c2 Mon Sep 17 00:00:00 2001 From: chinmayv095 Date: Thu, 30 Jul 2026 21:26:42 +0530 Subject: [PATCH 064/473] fix(memory): count valid Redis and Dapr session items for positive limits (#4033) --- src/agents/extensions/memory/dapr_session.py | 18 +++++++- src/agents/extensions/memory/redis_session.py | 36 ++++++++++----- tests/extensions/memory/test_dapr_session.py | 36 +++++++++++++++ tests/extensions/memory/test_redis_session.py | 45 ++++++++++++++++++- 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/agents/extensions/memory/dapr_session.py b/src/agents/extensions/memory/dapr_session.py index e923940f11..17c6b39eb7 100644 --- a/src/agents/extensions/memory/dapr_session.py +++ b/src/agents/extensions/memory/dapr_session.py @@ -284,7 +284,23 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: if session_limit is not None: if session_limit <= 0: return [] - messages = messages[-session_limit:] + # Walk back from the newest entry so limit counts valid conversation items: + # a corrupt entry is skipped instead of spending the caller's budget, matching + # pop_item and the other session backends. + latest_first: list[TResponseInputItem] = [] + for msg in reversed(messages): + try: + if isinstance(msg, str): + item = await self._deserialize_item(msg) + else: + item = msg + except (json.JSONDecodeError, TypeError): + continue + latest_first.append(item) + if len(latest_first) == session_limit: + break + latest_first.reverse() + return latest_first items: list[TResponseInputItem] = [] for msg in messages: try: diff --git a/src/agents/extensions/memory/redis_session.py b/src/agents/extensions/memory/redis_session.py index 3ad261b28e..de650cf31e 100644 --- a/src/agents/extensions/memory/redis_session.py +++ b/src/agents/extensions/memory/redis_session.py @@ -165,17 +165,7 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: """ session_limit = resolve_session_limit(limit, self.session_settings) - async with self._lock: - if session_limit is None: - # Get all messages in chronological order - raw_messages = await self._redis.lrange(self._messages_key, 0, -1) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context - else: - if session_limit <= 0: - return [] - # Get the latest N messages (Redis list is ordered chronologically) - # Use negative indices to get from the end - Redis uses -N to -1 for last N items - raw_messages = await self._redis.lrange(self._messages_key, -session_limit, -1) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context - + async def _decode_messages(raw_messages: list[Any]) -> list[TResponseInputItem]: items: list[TResponseInputItem] = [] for raw_msg in raw_messages: try: @@ -189,9 +179,31 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: except (json.JSONDecodeError, UnicodeDecodeError): # Skip corrupted messages continue - return items + async with self._lock: + if session_limit is None: + # Get all messages in chronological order + raw_messages = await self._redis.lrange(self._messages_key, 0, -1) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context + return await _decode_messages(raw_messages) + + if session_limit <= 0: + return [] + + # Get the latest N messages (Redis list is ordered chronologically) + # Use negative indices to get from the end - Redis uses -N to -1 for last N items. + # Expand the fetch window when corrupt messages sit among the newest entries so + # limit counts valid conversation items, matching pop_item and the other backends. + window = session_limit + while True: + raw_messages = await self._redis.lrange(self._messages_key, -window, -1) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context + items = await _decode_messages(raw_messages) + if len(items) >= session_limit: + return items[-session_limit:] + if len(raw_messages) < window: + return items + window *= 2 + async def add_items(self, items: list[TResponseInputItem]) -> None: """Add new items to the conversation history. diff --git a/tests/extensions/memory/test_dapr_session.py b/tests/extensions/memory/test_dapr_session.py index dd49173a19..5b0276761c 100644 --- a/tests/extensions/memory/test_dapr_session.py +++ b/tests/extensions/memory/test_dapr_session.py @@ -396,6 +396,42 @@ async def test_pop_from_empty_session(fake_dapr_client: FakeDaprClient): await session.close() +async def test_get_items_limit_skips_corrupt_newest_entries(fake_dapr_client: FakeDaprClient): + """limit counts valid items, expanding past corrupt newest entries.""" + session = await _create_test_session(fake_dapr_client, "limit_corrupt") + + try: + serialized = [ + await session._serialize_item({"role": "user", "content": "valid 0"}), + await session._serialize_item({"role": "assistant", "content": "valid 1"}), + await session._serialize_item({"role": "user", "content": "valid 2"}), + "not valid json {{{", + ] + fake_dapr_client._state[session._messages_key] = json.dumps( + serialized, separators=(",", ":") + ).encode("utf-8") + + limited = await session.get_items(limit=2) + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + finally: + await session.close() + + +async def test_get_items_limit_returns_fewer_when_history_exhausted( + fake_dapr_client: FakeDaprClient, +): + """A limit larger than the stored history returns what exists.""" + session = await _create_test_session(fake_dapr_client, "limit_exhausted") + + try: + await session.add_items([{"role": "user", "content": "only valid"}]) + + retrieved = await session.get_items(limit=5) + assert [item.get("content") for item in retrieved] == ["only valid"] + finally: + await session.close() + + async def test_pop_item_skips_corrupt_most_recent(fake_dapr_client: FakeDaprClient): """pop_item skips corrupt newest entries and returns the next valid item.""" session = await _create_test_session(fake_dapr_client, "pop_corrupt") diff --git a/tests/extensions/memory/test_redis_session.py b/tests/extensions/memory/test_redis_session.py index 0cc4c07d8b..19fe133cc8 100644 --- a/tests/extensions/memory/test_redis_session.py +++ b/tests/extensions/memory/test_redis_session.py @@ -706,6 +706,49 @@ async def test_add_items_preserves_created_at_metadata(): await session.close() +async def test_get_items_limit_skips_corrupt_newest_messages(): + """limit counts valid items, expanding past corrupt newest messages.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for direct data manipulation") + + session = await _create_test_session("limit_corrupt_test") + + try: + await session.clear_session() + await session.add_items( + [ + {"role": "user", "content": "valid 0"}, + {"role": "assistant", "content": "valid 1"}, + {"role": "user", "content": "valid 2"}, + ] + ) + + # Append a corrupt newest message. + await _safe_rpush(fake_redis, session._messages_key, "not valid json {{{") + + limited = await session.get_items(limit=2) + assert [item.get("content") for item in limited] == ["valid 1", "valid 2"] + finally: + await session.close() + + +async def test_get_items_limit_returns_fewer_when_history_exhausted(): + """Window expansion stops at the end of history instead of looping.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for direct data manipulation") + + session = await _create_test_session("limit_exhausted_test") + + try: + await session.clear_session() + await session.add_items([{"role": "user", "content": "only valid"}]) + + retrieved = await session.get_items(limit=5) + assert [item.get("content") for item in retrieved] == ["only valid"] + finally: + await session.close() + + async def test_corrupted_data_handling(): """Test that corrupted JSON data is handled gracefully.""" if not USE_FAKE_REDIS: @@ -720,7 +763,7 @@ async def test_corrupted_data_handling(): await session.add_items([{"role": "user", "content": "valid message"}]) # Inject corrupted data directly into Redis - messages_key = "test:corruption_test:messages" + messages_key = session._messages_key # Add invalid JSON directly using the typed Redis client await _safe_rpush(fake_redis, messages_key, "invalid json data") From df0b4a2eb745da9dc549abef631e1af647685a03 Mon Sep 17 00:00:00 2001 From: Kaif Kohari Date: Fri, 31 Jul 2026 01:07:12 +0100 Subject: [PATCH 065/473] fix(extensions): keep tool parameters named like schema keywords in trimmer (#4036) --- src/agents/extensions/tool_output_trimmer.py | 8 +++ tests/extensions/test_tool_output_trimmer.py | 58 ++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/agents/extensions/tool_output_trimmer.py b/src/agents/extensions/tool_output_trimmer.py index d4e9e9ec42..39c0c42d3b 100644 --- a/src/agents/extensions/tool_output_trimmer.py +++ b/src/agents/extensions/tool_output_trimmer.py @@ -289,6 +289,14 @@ def _trim_json_schema(self, schema: dict[str, Any]) -> dict[str, Any]: """Remove verbose prose from a JSON schema while preserving its structure.""" trimmed_schema: dict[str, Any] = {} for key, value in schema.items(): + # Keys of a "properties" mapping are parameter names, not schema keywords, so + # they must survive even when they collide with the prose keywords below. + if key == "properties" and isinstance(value, dict): + trimmed_schema[key] = { + name: self._trim_json_schema(sub) if isinstance(sub, dict) else sub + for name, sub in value.items() + } + continue if key in {"description", "title", "$comment", "examples"}: continue if isinstance(value, dict): diff --git a/tests/extensions/test_tool_output_trimmer.py b/tests/extensions/test_tool_output_trimmer.py index 04a0a70728..8277b75ed4 100644 --- a/tests/extensions/test_tool_output_trimmer.py +++ b/tests/extensions/test_tool_output_trimmer.py @@ -365,6 +365,64 @@ def test_trims_tool_search_output_tool_definitions(self) -> None: assert trimmed_tools[0]["parameters"]["properties"]["customer_id"]["default"] == "cust_123" assert len(json.dumps(trimmed_tools, sort_keys=True)) < original_len + def test_keeps_tool_parameters_named_like_schema_keywords(self) -> None: + """Parameter names that collide with trimmed schema keywords must survive.""" + parameters = { + "type": "object", + "description": "schema prose " * 200, + "properties": { + "description": {"type": "string"}, + "title": {"type": "string"}, + "$comment": {"type": "string"}, + "examples": {"type": "string"}, + "query": {"type": "string"}, + }, + "required": ["description", "title", "$comment", "examples", "query"], + "additionalProperties": False, + } + items = [ + _user("q1"), + {"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "tickets"}}, + { + "type": "tool_search_output", + "call_id": "ts1", + "tools": [ + { + "type": "function", + "name": "create_ticket", + "description": "tool description " * 200, + "parameters": parameters, + } + ], + }, + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=60) + result = trimmer(_make_data(items)) + trimmed_item_dict = cast(dict[str, Any], result.input[2]) + trimmed_parameters = trimmed_item_dict["tools"][0]["parameters"] + + # Every declared parameter is still present, so `required` stays satisfiable. + assert sorted(trimmed_parameters["properties"]) == [ + "$comment", + "description", + "examples", + "query", + "title", + ] + assert not [ + name + for name in trimmed_parameters["required"] + if name not in trimmed_parameters["properties"] + ] + # Schema-level prose is still trimmed. + assert "description" not in trimmed_parameters + def test_trims_legacy_tool_search_output_results(self) -> None: """Legacy tool_search_output snapshots with free-text results should still trim.""" large = "x" * 2000 From 1058e842584042330d81fc14516773aac71ad400 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 31 Jul 2026 10:14:52 +0900 Subject: [PATCH 066/473] feat(tool): expose original callable through wrapped (#4038) --- src/agents/tool.py | 63 +++++++++++ tests/test_function_tool_decorator.py | 150 +++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/src/agents/tool.py b/src/agents/tool.py index babf56d6ee..314272d07a 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -198,6 +198,8 @@ class ApplyPatchToolCustomDataContext: CustomToolExecutor = Callable[[ToolContext[Any], str], MaybeAwaitable[Any]] CustomToolApprovalFunction = Callable[[RunContextWrapper[Any], str, str], MaybeAwaitable[bool]] _SYNC_FUNCTION_TOOL_MARKER = "__agents_sync_function_tool__" +_FUNCTION_TOOL_WRAPPED_CALLABLE_MARKER = "__agents_function_tool_wrapped_callable__" +_MISSING_FUNCTION_TOOL_WRAPPED_CALLABLE = object() _UNSET_FAILURE_ERROR_FUNCTION = object() @@ -391,6 +393,51 @@ class FunctionToolResult: """Nested agent run result (for agent-as-tool).""" +class _FunctionToolWrappedCallableDescriptor: + """Expose decorator callable metadata on instances without affecting class inspection.""" + + @overload + def __get__( + self, + instance: None, + owner: type[FunctionTool], + ) -> _FunctionToolWrappedCallableDescriptor: ... + + @overload + def __get__( + self, + instance: FunctionTool, + owner: type[FunctionTool] | None = None, + ) -> ToolFunction[...]: ... + + def __get__( + self, + instance: FunctionTool | None, + owner: type[FunctionTool] | None = None, + ) -> ToolFunction[...] | _FunctionToolWrappedCallableDescriptor: + """Return the callable passed to `function_tool`. + + Calling this callable directly bypasses the function-tool runtime pipeline, including JSON + schema validation, context injection, guardrails, timeouts, failure handling, and tracing. + + Raises: + AttributeError: If accessed on the class, if the tool was not created by + `function_tool`, or if its invoker was replaced. + """ + if instance is None: + raise AttributeError("FunctionTool classes have no wrapped Python callable") + if not isinstance(instance.on_invoke_tool, _FailureHandlingFunctionToolInvoker): + raise AttributeError("FunctionTool has no wrapped Python callable") + wrapped_callable = instance.on_invoke_tool._get_wrapped_callable() + if wrapped_callable is _MISSING_FUNCTION_TOOL_WRAPPED_CALLABLE: + raise AttributeError("FunctionTool has no wrapped Python callable") + return cast("ToolFunction[...]", wrapped_callable) + + def __set__(self, instance: FunctionTool, value: object) -> None: + """Reject replacement so wrapper metadata cannot diverge from runtime invocation.""" + raise AttributeError("FunctionTool.__wrapped__ is read-only") + + @dataclass class FunctionTool: """A tool that wraps a function. In most cases, you should use the `function_tool` helpers to @@ -527,6 +574,8 @@ def qualified_name(self) -> str: tool_qualified_name(self.name, get_explicit_function_tool_namespace(self)) or self.name ) + __wrapped__ = _FunctionToolWrappedCallableDescriptor() + def __post_init__(self): self.allowed_callers = _normalize_tool_allowed_callers( self.allowed_callers, @@ -572,6 +621,14 @@ def __init__( self._on_handled_error = on_handled_error self._function_tool = function_tool + def _get_wrapped_callable(self) -> object: + """Return wrapped-callable metadata from the invocation implementation, if present.""" + return getattr( + self._invoke_tool_impl, + _FUNCTION_TOOL_WRAPPED_CALLABLE_MARKER, + _MISSING_FUNCTION_TOOL_WRAPPED_CALLABLE, + ) + def __agents_bind_function_tool__( self, function_tool: FunctionTool ) -> _FailureHandlingFunctionToolInvoker: @@ -2507,6 +2564,7 @@ def function_tool( """ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: + original_callable = the_func the_func, callable_description = _normalize_function_tool_callable( the_func, docstring_style, @@ -2573,6 +2631,11 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: return result + setattr( + _on_invoke_tool_impl, + _FUNCTION_TOOL_WRAPPED_CALLABLE_MARKER, + original_callable, + ) function_tool = _build_wrapped_function_tool( name=schema.name, description=schema.description or "", diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py index d388b3b4cd..4818a15cd4 100644 --- a/tests/test_function_tool_decorator.py +++ b/tests/test_function_tool_decorator.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import copy +import dataclasses import functools import inspect import json @@ -15,7 +17,8 @@ from pydantic import BaseModel from typing_extensions import Self -from agents import UserError, function_tool +from agents import Agent, FunctionTool, UserError, function_tool +from agents.decorators import tool from agents.run_context import RunContextWrapper from agents.tool_context import ToolContext @@ -170,6 +173,151 @@ def test_function_tool_defer_loading(): assert deferred_lookup.defer_loading is True +def test_tool_exposes_original_callable_without_mutating_it() -> None: + def original(value: int) -> int: + """Increment a value.""" + return value + 1 + + original.__dict__["extra_metadata"] = "preserved" + original_dict = original.__dict__.copy() + original_name = original.__name__ + original_doc = original.__doc__ + original_signature = inspect.signature(original) + + wrapped_tool = tool(original) + + assert wrapped_tool.__wrapped__ is original + direct_callable = cast(Callable[[int], int], wrapped_tool.__wrapped__) + assert direct_callable(1) == 2 + assert not callable(wrapped_tool) + assert original.__dict__ == original_dict + assert original.__name__ == original_name + assert original.__doc__ == original_doc + assert inspect.signature(wrapped_tool.__wrapped__) == original_signature + + with pytest.raises(AttributeError): + cast(Any, wrapped_tool).__wrapped__ = original + + +def test_wrapped_callable_descriptor_is_hidden_on_function_tool_classes() -> None: + @dataclasses.dataclass(init=False) + class FunctionToolSubclass(FunctionTool): + pass + + assert not hasattr(FunctionTool, "__wrapped__") + assert not hasattr(FunctionToolSubclass, "__wrapped__") + + +def test_configured_tool_exposes_original_callable() -> None: + def original(value: int) -> int: + return value + 1 + + configured_tool = tool(name_override="increment") + wrapped_tool = configured_tool(original) + + assert wrapped_tool.__wrapped__ is original + assert wrapped_tool.name == "increment" + + +def test_wrapped_callable_identity_for_supported_function_shapes() -> None: + def sync_function(value: int) -> int: + return value + + async def async_function(value: int) -> int: + return value + + def context_function(ctx: ToolContext[Any], value: int) -> int: + return value + + class Handler: + def method(self, value: int) -> int: + return value + + bound_method = Handler().method + + for original in (sync_function, async_function, context_function, bound_method): + assert function_tool(original).__wrapped__ is original + + +@pytest.mark.asyncio +async def test_callable_instance_identity_survives_tool_clone_paths() -> None: + class Counter: + def __init__(self) -> None: + self.calls: list[int] = [] + + async def __call__(self, value: int) -> int: + self.calls.append(value) + return value + + counter = Counter() + wrapped_tool = function_tool(counter) + copied_tool = copy.copy(wrapped_tool) + deep_copied_tool = copy.deepcopy(wrapped_tool) + replaced_tool = dataclasses.replace(wrapped_tool, name="renamed") + + for cloned_tool in (wrapped_tool, copied_tool, deep_copied_tool, replaced_tool): + assert cloned_tool.__wrapped__ is counter + + direct_callable = cast(Callable[[int], Any], wrapped_tool.__wrapped__) + assert await direct_callable(1) == 1 + assert await copied_tool.on_invoke_tool(ctx_wrapper(), '{"value": 2}') == 2 + assert await deep_copied_tool.on_invoke_tool(ctx_wrapper(), '{"value": 3}') == 3 + assert await replaced_tool.on_invoke_tool(ctx_wrapper(), '{"value": 4}') == 4 + assert counter.calls == [1, 2, 3, 4] + + +def test_wrapped_callable_follows_standard_unwrap_chain() -> None: + def original(value: int) -> int: + return value + + @functools.wraps(original) + def intermediate(value: int) -> int: + return original(value) + + wrapped_tool = function_tool(intermediate) + + assert wrapped_tool.__wrapped__ is intermediate + assert inspect.unwrap(wrapped_tool.__wrapped__) is original + assert inspect.unwrap(cast(Callable[..., Any], wrapped_tool)) is original + + +def test_non_decorator_function_tools_have_no_wrapped_callable() -> None: + async def manual_invoker(ctx: ToolContext[Any], input_json: str) -> str: + return input_json + + manual_tool = FunctionTool( + name="manual", + description="", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=manual_invoker, + ) + agent_tool = Agent(name="Nested").as_tool( + tool_name="nested", + tool_description="Run the nested agent.", + ) + + for non_decorator_tool in (manual_tool, agent_tool): + assert not hasattr(non_decorator_tool, "__wrapped__") + with pytest.raises(AttributeError): + _ = non_decorator_tool.__wrapped__ + assert inspect.unwrap(cast(Callable[..., Any], non_decorator_tool)) is non_decorator_tool + + +def test_replacing_invoker_removes_wrapped_callable() -> None: + def original(value: int) -> int: + return value + + async def replacement(ctx: ToolContext[Any], input_json: str) -> str: + return input_json + + wrapped_tool = function_tool(original) + assert wrapped_tool.__wrapped__ is original + + wrapped_tool.on_invoke_tool = replacement + + assert not hasattr(wrapped_tool, "__wrapped__") + + @function_tool(strict_mode=False) def optional_param_function(a: int, b: int | None = None) -> str: if b is None: From 2cec48924bcd5f514091aaf6ae2a38683710437e Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 30 Jul 2026 21:01:27 -0500 Subject: [PATCH 067/473] fix(voice): propagate iterator cancellation (#4040) --- src/agents/voice/models/openai_stt.py | 6 ++++- src/agents/voice/result.py | 3 ++- tests/voice/test_openai_stt.py | 38 +++++++++++++++++++++++++++ tests/voice/test_pipeline.py | 37 ++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 64efdcb4f0..fa52e2ef9b 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -346,7 +346,11 @@ async def transcribe_turns(self) -> AsyncIterator[str]: try: turn = await self._output_queue.get() except asyncio.CancelledError: - break + if self._tracing_span: + self._end_turn("") + if self._websocket: + await self._websocket.close() + raise if ( turn is None diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 43443fb966..bc89a6c7e0 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -305,7 +305,8 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: try: event = await self._queue.get() except asyncio.CancelledError: - break + self._cleanup_tasks() + raise if isinstance(event, VoiceStreamEventError): self._stored_exception = event.error log_model_and_tool_action_error( diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index 090afa5806..e492d4dd91 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -57,6 +57,44 @@ def fake_time(increment: int): # ===== Tests ===== +@pytest.mark.asyncio +async def test_transcribe_turns_propagates_consumer_cancellation(monkeypatch) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + session._websocket = AsyncMock() + get_started = asyncio.Event() + never_finishes = asyncio.Event() + + async def wait_for_turn() -> str: + get_started.set() + await never_finishes.wait() + raise AssertionError("Unreachable") + + async def hold_connection_open() -> None: + await never_finishes.wait() + + monkeypatch.setattr(session._output_queue, "get", wait_for_turn) + monkeypatch.setattr(session, "_process_websocket_connection", hold_connection_open) + consumer = asyncio.ensure_future(anext(session.transcribe_turns())) + await get_started.wait() + consumer.cancel() + + try: + with pytest.raises(asyncio.CancelledError): + await consumer + session._websocket.close.assert_awaited_once() + finally: + await session.close() + if session._connection_task is not None: + await asyncio.gather(session._connection_task, return_exceptions=True) + + @pytest.mark.asyncio @pytest.mark.parametrize( ("trace_include_sensitive_data", "expected_error"), diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 45db259929..755764b16a 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -61,6 +61,43 @@ def test_streamed_audio_result_odd_length_buffer_int16() -> None: assert transformed.tolist() == [1] +@pytest.mark.asyncio +async def test_streamed_audio_result_propagates_consumer_cancellation(monkeypatch) -> None: + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + get_started = asyncio.Event() + never_finishes = asyncio.Event() + producer_started = asyncio.Event() + producer_stopped = asyncio.Event() + + async def wait_for_event() -> VoiceStreamEvent: + get_started.set() + await never_finishes.wait() + raise AssertionError("Unreachable") + + async def produce_events() -> None: + producer_started.set() + try: + await never_finishes.wait() + finally: + producer_stopped.set() + + producer = asyncio.create_task(produce_events()) + result._tasks.append(producer) + monkeypatch.setattr(result._queue, "get", wait_for_event) + consumer = asyncio.ensure_future(anext(result.stream())) + await asyncio.gather(get_started.wait(), producer_started.wait()) + consumer.cancel() + + with pytest.raises(asyncio.CancelledError): + await consumer + await producer_stopped.wait() + assert producer.cancelled() + + def test_voice_pipeline_config_normalizes_dictionary_settings() -> None: config = VoicePipelineConfig( stt_settings={"language": "ja", "temperature": 0.0}, From c510261cad3636eb5389ef7d937b6f00edce28ed Mon Sep 17 00:00:00 2001 From: Satvik Sawhney Date: Fri, 31 Jul 2026 10:44:52 +0530 Subject: [PATCH 068/473] fix(memory): enforce closed state in Redis and Dapr sessions (#4035) --- src/agents/extensions/memory/dapr_session.py | 82 ++++-- src/agents/extensions/memory/redis_session.py | 48 ++- tests/extensions/memory/test_dapr_session.py | 255 ++++++++++++++++ tests/extensions/memory/test_redis_session.py | 275 +++++++++++++++++- 4 files changed, 624 insertions(+), 36 deletions(-) diff --git a/src/agents/extensions/memory/dapr_session.py b/src/agents/extensions/memory/dapr_session.py index 17c6b39eb7..20b300ce3c 100644 --- a/src/agents/extensions/memory/dapr_session.py +++ b/src/agents/extensions/memory/dapr_session.py @@ -105,6 +105,8 @@ def __init__( self._consistency = consistency self._lock = asyncio.Lock() self._owns_client = False # Track if we own the Dapr client + self._closed = False + self._client_released = False # State keys self._messages_key = f"{self.session_id}:messages" @@ -258,6 +260,11 @@ async def _handle_concurrency_conflict(self, error: Exception, attempt: int) -> # Session protocol implementation # ------------------------------------------------------------------ + def _check_not_closed(self) -> None: + """Raise if the session has already been closed.""" + if self._closed: + raise RuntimeError("DaprSession is closed") + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: """Retrieve the conversation history for this session. @@ -271,6 +278,7 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: session_limit = resolve_session_limit(limit, self.session_settings) async with self._lock: + self._check_not_closed() # Get messages from state store with consistency level response = await self._dapr_client.get_state( store_name=self._state_store_name, @@ -319,10 +327,12 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: Args: items: List of input items to add to the history """ + self._check_not_closed() if not items: return async with self._lock: + self._check_not_closed() serialized_items: list[str] = [await self._serialize_item(item) for item in items] attempt = 0 while True: @@ -373,6 +383,7 @@ async def pop_item(self) -> TResponseInputItem | None: The most recent item if it exists, None if the session is empty """ async with self._lock: + self._check_not_closed() while True: attempt = 0 while True: @@ -413,6 +424,7 @@ async def pop_item(self) -> TResponseInputItem | None: async def clear_session(self) -> None: """Clear all items for this session.""" async with self._lock: + self._check_not_closed() # Delete messages and metadata keys await self._dapr_client.delete_state( store_name=self._state_store_name, @@ -430,11 +442,23 @@ async def close(self) -> None: """Close the Dapr client connection. Only closes the connection if this session owns the Dapr client - (i.e., created via from_address). If the client was injected externally, - the caller is responsible for managing its lifecycle. + (i.e., created via from_address). In that case the session becomes + terminal and subsequent operations raise RuntimeError. If the client was + injected externally, the caller is responsible for managing its lifecycle + and this is a no-op. + + The session is terminal from the first close attempt. If releasing the + client fails or is cancelled, operations still raise and a later close() + retries the unfinished cleanup. Once the client is released, repeated and + concurrent calls are safe no-ops. """ - if self._owns_client: - await self._dapr_client.close() + async with self._lock: + if not self._owns_client: + return + self._closed = True + if not self._client_released: + await self._dapr_client.close() + self._client_released = True async def __aenter__(self) -> DaprSession: """Enter async context manager.""" @@ -449,33 +473,39 @@ async def ping(self) -> bool: Returns: True if Dapr is reachable, False otherwise. + + Raises: + RuntimeError: If the session owns its client and has been closed. """ - try: - # First attempt a read; some stores may not be initialized yet. - await self._dapr_client.get_state( - store_name=self._state_store_name, - key="__ping__", - state_metadata=self._get_read_metadata(), - ) - return True - except Exception as initial_error: - # If relation/table is missing or store isn't initialized, - # attempt a write to initialize it, then read again. + async with self._lock: + # Checked outside the try block; the except clause below would swallow it. + self._check_not_closed() try: - await self._dapr_client.save_state( - store_name=self._state_store_name, - key="__ping__", - value="ok", - state_metadata=self._get_metadata(), - options=self._get_state_options(), - ) - # Read again after write. + # First attempt a read; some stores may not be initialized yet. await self._dapr_client.get_state( store_name=self._state_store_name, key="__ping__", state_metadata=self._get_read_metadata(), ) return True - except Exception: - log_model_and_tool_action_error(logger, "Dapr connection failed", initial_error) - return False + except Exception as initial_error: + # If relation/table is missing or store isn't initialized, + # attempt a write to initialize it, then read again. + try: + await self._dapr_client.save_state( + store_name=self._state_store_name, + key="__ping__", + value="ok", + state_metadata=self._get_metadata(), + options=self._get_state_options(), + ) + # Read again after write. + await self._dapr_client.get_state( + store_name=self._state_store_name, + key="__ping__", + state_metadata=self._get_read_metadata(), + ) + return True + except Exception: + log_model_and_tool_action_error(logger, "Dapr connection failed", initial_error) + return False diff --git a/src/agents/extensions/memory/redis_session.py b/src/agents/extensions/memory/redis_session.py index de650cf31e..de9efbf6c8 100644 --- a/src/agents/extensions/memory/redis_session.py +++ b/src/agents/extensions/memory/redis_session.py @@ -85,6 +85,8 @@ def __init__( self._ttl = ttl self._lock = asyncio.Lock() self._owns_client = False # Track if we own the Redis client + self._closed = False + self._client_released = False # Redis key patterns self._session_key = f"{self._key_prefix}:{self.session_id}" @@ -153,6 +155,11 @@ async def _set_ttl_if_configured(self, *keys: str) -> None: # Session protocol implementation # ------------------------------------------------------------------ + def _check_not_closed(self) -> None: + """Raise if the session has already been closed.""" + if self._closed: + raise RuntimeError("RedisSession is closed") + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: """Retrieve the conversation history for this session. @@ -182,6 +189,7 @@ async def _decode_messages(raw_messages: list[Any]) -> list[TResponseInputItem]: return items async with self._lock: + self._check_not_closed() if session_limit is None: # Get all messages in chronological order raw_messages = await self._redis.lrange(self._messages_key, 0, -1) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context @@ -210,10 +218,12 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: Args: items: List of input items to add to the history """ + self._check_not_closed() if not items: return async with self._lock: + self._check_not_closed() pipe = self._redis.pipeline() now = str(int(time.time())) @@ -248,6 +258,7 @@ async def pop_item(self) -> TResponseInputItem | None: The most recent item if it exists, None if the session is empty """ async with self._lock: + self._check_not_closed() while True: # Use RPOP to atomically remove and return the rightmost (most recent) item raw_msg = await self._redis.rpop(self._messages_key) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context @@ -269,6 +280,7 @@ async def pop_item(self) -> TResponseInputItem | None: async def clear_session(self) -> None: """Clear all items for this session.""" async with self._lock: + self._check_not_closed() # Delete all keys associated with this session await self._redis.delete( self._session_key, @@ -280,20 +292,38 @@ async def close(self) -> None: """Close the Redis connection. Only closes the connection if this session owns the Redis client - (i.e., created via from_url). If the client was injected externally, - the caller is responsible for managing its lifecycle. + (i.e., created via from_url). In that case the session becomes terminal + and subsequent operations raise RuntimeError. If the client was injected + externally, the caller is responsible for managing its lifecycle and + this is a no-op. + + The session is terminal from the first close attempt. If releasing the + client fails or is cancelled, operations still raise and a later close() + retries the unfinished cleanup. Once the client is released, repeated and + concurrent calls are safe no-ops. """ - if self._owns_client: - await self._redis.aclose() + async with self._lock: + if not self._owns_client: + return + self._closed = True + if not self._client_released: + await self._redis.aclose() + self._client_released = True async def ping(self) -> bool: """Test Redis connectivity. Returns: True if Redis is reachable, False otherwise. + + Raises: + RuntimeError: If the session owns its client and has been closed. """ - try: - await self._redis.ping() # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context - return True - except Exception: - return False + async with self._lock: + # Checked outside the try block; the except clause below would swallow it. + self._check_not_closed() + try: + await self._redis.ping() # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context + return True + except Exception: + return False diff --git a/tests/extensions/memory/test_dapr_session.py b/tests/extensions/memory/test_dapr_session.py index 5b0276761c..702cb7388c 100644 --- a/tests/extensions/memory/test_dapr_session.py +++ b/tests/extensions/memory/test_dapr_session.py @@ -1072,3 +1072,258 @@ async def test_runner_with_session_settings_override(fake_dapr_client: FakeDaprC assert len(history_items) == 2 finally: await session.close() + + +def _create_owned_dapr_session(session_id: str) -> DaprSession: + """Create a session that owns its client, mirroring the from_address path.""" + import unittest.mock + + with unittest.mock.patch( + "agents.extensions.memory.dapr_session.DaprClient", + return_value=FakeDaprClient(), + ): + return DaprSession.from_address(session_id, state_store_name="statestore") + + +async def test_dapr_session_closed_operations_raise_runtime_error(): + """Operations on a closed owned-client session must fail instead of using a closed client.""" + session = _create_owned_dapr_session("closed_state_test") + assert session._owns_client is True + await session.add_items([{"role": "user", "content": "before close"}]) + await session.close() + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.get_items() + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.add_items([{"role": "user", "content": "after close"}]) + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.pop_item() + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.clear_session() + + +async def test_dapr_session_closed_rejects_empty_add_items(): + """add_items([]) must not bypass the closed check through the empty-list fast path.""" + session = _create_owned_dapr_session("closed_empty_add_test") + await session.close() + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.add_items([]) + + +async def test_dapr_session_closed_after_context_manager_exit(): + """Leaving the async context manager closes an owned session, so later use must fail.""" + session = _create_owned_dapr_session("closed_context_manager_test") + + async with session: + await session.add_items([{"role": "user", "content": "inside context"}]) + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.get_items() + + +async def test_dapr_session_close_is_idempotent(): + """Repeated and concurrent close() calls must remain safe no-ops.""" + import asyncio + + session = _create_owned_dapr_session("close_idempotent_test") + + await asyncio.gather(session.close(), session.close()) + await session.close() + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.get_items() + + +async def test_dapr_session_failed_cleanup_is_terminal_and_retried(): + """A failed cleanup keeps the session terminal, and the next close() retries it.""" + import unittest.mock + + session = _create_owned_dapr_session("failed_cleanup_test") + attempts = 0 + real_close = session._dapr_client.close + + async def failing_then_succeeding_close(*args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + # Mirror a real client, which tears the channel down before surfacing the error. + await real_close(*args, **kwargs) + if attempts == 1: + raise OSError("channel shutdown error surfaced after teardown") + + with unittest.mock.patch.object(session._dapr_client, "close", failing_then_succeeding_close): + with pytest.raises(OSError, match="channel shutdown error surfaced after teardown"): + await session.close() + + assert session._closed is True + for operation in ( + session.get_items(), + session.add_items([{"role": "user", "content": "after failed cleanup"}]), + session.pop_item(), + session.clear_session(), + session.ping(), + ): + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await operation + + await session.close() + assert attempts == 2 + assert session._client_released is True + + await session.close() + assert attempts == 2 + + +async def test_dapr_session_cancelled_cleanup_is_terminal_and_retried(): + """A cancelled cleanup keeps the session terminal, and the next close() retries it.""" + import asyncio + import unittest.mock + from contextlib import suppress + + timeout = 5.0 + session = _create_owned_dapr_session("cancelled_cleanup_test") + attempts = 0 + entered_cleanup = asyncio.Event() + real_close = session._dapr_client.close + + async def hanging_then_succeeding_close(*args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + if attempts == 1: + entered_cleanup.set() + await asyncio.Event().wait() # Hang until cancelled. + return await real_close(*args, **kwargs) + + close_task: asyncio.Task[None] | None = None + try: + with unittest.mock.patch.object( + session._dapr_client, "close", hanging_then_succeeding_close + ): + close_task = asyncio.create_task(session.close()) + await asyncio.wait_for(entered_cleanup.wait(), timeout) + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + close_task = None + + assert session._closed is True + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.get_items() + + await asyncio.wait_for(session.close(), timeout) + assert attempts == 2 + assert session._client_released is True + finally: + if close_task is not None: + close_task.cancel() + with suppress(asyncio.CancelledError): + await close_task + + +async def test_dapr_session_ping_raises_after_close(): + """ping() must not read or write through a client this session already closed.""" + session = _create_owned_dapr_session("ping_after_close_test") + assert await session.ping() is True + await session.close() + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await session.ping() + + +async def test_dapr_session_close_is_noop_for_injected_client(): + """With an injected client, close() stays a no-op and the session remains usable.""" + client = FakeDaprClient() + session = DaprSession( + session_id="injected_client_noop_test", + state_store_name="statestore", + dapr_client=client, # type: ignore[arg-type] + ) + assert session._owns_client is False + + await session.add_items([{"role": "user", "content": "before close"}]) + await session.close() + + assert client._closed is False + await session.add_items([{"role": "user", "content": "after close"}]) + items = await session.get_items() + assert [item.get("content") for item in items] == ["before close", "after close"] + + assert await session.ping() is True + + other = DaprSession( + session_id="injected_client_noop_test", + state_store_name="statestore", + dapr_client=client, # type: ignore[arg-type] + ) + assert len(await other.get_items()) == 2 + + +async def test_dapr_session_operation_waiting_behind_close_raises(): + """An operation queued behind close() must fail rather than run after shutdown completes.""" + import asyncio + import unittest.mock + from contextlib import suppress + + from agents.memory.session_settings import resolve_session_limit + + timeout = 5.0 + blocked_probe = 0.1 + fake_client = FakeDaprClient() + close_holds_lock = asyncio.Event() # Set once close() is inside the session lock. + release_close = asyncio.Event() # Test-owned gate that lets the paused close() finish. + op_entered = asyncio.Event() # Set once the operation is past method entry. + real_close = fake_client.close + real_resolve = resolve_session_limit + + async def paused_close(*args: Any, **kwargs: Any) -> Any: + close_holds_lock.set() + await asyncio.wait_for(release_close.wait(), timeout) + return await real_close(*args, **kwargs) + + def entry_signalling_resolve(*args: Any, **kwargs: Any) -> Any: + # This runs immediately before the operation reaches the session lock. + op_entered.set() + return real_resolve(*args, **kwargs) + + with unittest.mock.patch( + "agents.extensions.memory.dapr_session.DaprClient", return_value=fake_client + ): + session = DaprSession.from_address("close_interleave_test", state_store_name="statestore") + assert session._owns_client is True + + close_task: asyncio.Task[None] | None = None + op_task: asyncio.Task[list[TResponseInputItem]] | None = None + try: + with unittest.mock.patch.object(fake_client, "close", paused_close): + close_task = asyncio.create_task(session.close()) + await asyncio.wait_for(close_holds_lock.wait(), timeout) + + # Start the operation while close() still holds the lock so it must queue behind it. + with unittest.mock.patch( + "agents.extensions.memory.dapr_session.resolve_session_limit", + entry_signalling_resolve, + ): + op_task = asyncio.create_task(session.get_items()) + await asyncio.wait_for(op_entered.wait(), timeout) + + # The operation is past method entry, so it can only be blocked on the session lock. + _, still_blocked = await asyncio.wait({op_task}, timeout=blocked_probe) + assert op_task in still_blocked + + release_close.set() + await asyncio.wait_for(close_task, timeout) + close_task = None + + with pytest.raises(RuntimeError, match="DaprSession is closed"): + await asyncio.wait_for(op_task, timeout) + op_task = None + finally: + release_close.set() + for task in (close_task, op_task): + if task is not None: + task.cancel() + with suppress(asyncio.CancelledError, RuntimeError): + await task diff --git a/tests/extensions/memory/test_redis_session.py b/tests/extensions/memory/test_redis_session.py index 19fe133cc8..e906387c0c 100644 --- a/tests/extensions/memory/test_redis_session.py +++ b/tests/extensions/memory/test_redis_session.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import cast +from typing import Any, cast import pytest @@ -1065,3 +1065,276 @@ async def test_runner_with_session_settings_override(): assert len(history_items) == 2 finally: await session.close() + + +def _create_owned_redis_session(session_id: str) -> RedisSession: + """Create a session that owns its client, mirroring the from_url path.""" + import unittest.mock + + import fakeredis.aioredis + + with unittest.mock.patch( + "agents.extensions.memory.redis_session.redis.from_url", + return_value=fakeredis.aioredis.FakeRedis(), + ): + return RedisSession.from_url(session_id, url="redis://localhost:6379/15") + + +async def test_redis_session_closed_operations_raise_runtime_error(): + """Operations on a closed owned-client session must fail instead of reconnecting.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for isolated close-state verification") + + session = _create_owned_redis_session("closed_state_test") + assert session._owns_client is True + await session.add_items([{"role": "user", "content": "before close"}]) + await session.close() + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.get_items() + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.add_items([{"role": "user", "content": "after close"}]) + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.pop_item() + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.clear_session() + + +async def test_redis_session_closed_rejects_empty_add_items(): + """add_items([]) must not bypass the closed check through the empty-list fast path.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for isolated close-state verification") + + session = _create_owned_redis_session("closed_empty_add_test") + await session.close() + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.add_items([]) + + +async def test_redis_session_close_is_idempotent(): + """Repeated and concurrent close() calls must remain safe no-ops.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for isolated close-state verification") + + import asyncio + + session = _create_owned_redis_session("close_idempotent_test") + + await asyncio.gather(session.close(), session.close()) + await session.close() + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.get_items() + + +async def test_redis_session_failed_cleanup_is_terminal_and_retried(): + """A failed cleanup keeps the session terminal, and the next close() retries it.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for isolated close-state verification") + + import unittest.mock + + session = _create_owned_redis_session("failed_cleanup_test") + attempts = 0 + real_aclose = session._redis.aclose + + async def failing_then_succeeding_aclose(*args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + # Mirror redis-py, which tears the pool down before surfacing the error. + await real_aclose(*args, **kwargs) + if attempts == 1: + raise OSError("disconnect error surfaced after pool teardown") + + with unittest.mock.patch.object(session._redis, "aclose", failing_then_succeeding_aclose): + with pytest.raises(OSError, match="disconnect error surfaced after pool teardown"): + await session.close() + + assert session._closed is True + for operation in ( + session.get_items(), + session.add_items([{"role": "user", "content": "after failed cleanup"}]), + session.pop_item(), + session.clear_session(), + session.ping(), + ): + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await operation + + await session.close() + assert attempts == 2 + assert session._client_released is True + + await session.close() + assert attempts == 2 + + +async def test_redis_session_cancelled_cleanup_is_terminal_and_retried(): + """A cancelled cleanup keeps the session terminal, and the next close() retries it.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for isolated close-state verification") + + import asyncio + import unittest.mock + from contextlib import suppress + + timeout = 5.0 + session = _create_owned_redis_session("cancelled_cleanup_test") + attempts = 0 + entered_cleanup = asyncio.Event() + real_aclose = session._redis.aclose + + async def hanging_then_succeeding_aclose(*args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + if attempts == 1: + entered_cleanup.set() + await asyncio.Event().wait() # Hang until cancelled. + return await real_aclose(*args, **kwargs) + + close_task: asyncio.Task[None] | None = None + try: + with unittest.mock.patch.object(session._redis, "aclose", hanging_then_succeeding_aclose): + close_task = asyncio.create_task(session.close()) + await asyncio.wait_for(entered_cleanup.wait(), timeout) + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + close_task = None + + assert session._closed is True + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.get_items() + + await asyncio.wait_for(session.close(), timeout) + assert attempts == 2 + assert session._client_released is True + finally: + if close_task is not None: + close_task.cancel() + with suppress(asyncio.CancelledError): + await close_task + + +async def test_redis_session_ping_raises_after_close(): + """ping() must not reopen a connection on a client this session already closed.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for isolated close-state verification") + + session = _create_owned_redis_session("ping_after_close_test") + assert await session.ping() is True + await session.close() + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await session.ping() + + +async def test_redis_session_close_is_noop_for_injected_client(): + """With an injected client, close() stays a no-op and the session remains usable.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for isolated close-state verification") + + import fakeredis.aioredis + + client = fakeredis.aioredis.FakeRedis() + session = RedisSession( + session_id="injected_client_noop_test", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + assert session._owns_client is False + + await session.add_items([{"role": "user", "content": "before close"}]) + await session.close() + + await session.add_items([{"role": "user", "content": "after close"}]) + items = await session.get_items() + assert [item.get("content") for item in items] == ["before close", "after close"] + + assert await session.ping() is True + + other = RedisSession( + session_id="injected_client_noop_test", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + assert len(await other.get_items()) == 2 + + await session.clear_session() + + +async def test_redis_session_operation_waiting_behind_close_raises(): + """An operation queued behind close() must fail rather than run after shutdown completes.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for controlled close interleaving") + + import asyncio + import unittest.mock + from contextlib import suppress + + import fakeredis.aioredis + + from agents.memory.session_settings import resolve_session_limit + + timeout = 5.0 + blocked_probe = 0.1 + fake_client = fakeredis.aioredis.FakeRedis() + close_holds_lock = asyncio.Event() # Set once close() is inside the session lock. + release_close = asyncio.Event() # Test-owned gate that lets the paused close() finish. + op_entered = asyncio.Event() # Set once the operation is past method entry. + real_aclose = fake_client.aclose + real_resolve = resolve_session_limit + + async def paused_aclose(*args: Any, **kwargs: Any) -> Any: + close_holds_lock.set() + await asyncio.wait_for(release_close.wait(), timeout) + return await real_aclose(*args, **kwargs) + + def entry_signalling_resolve(*args: Any, **kwargs: Any) -> Any: + # This runs immediately before the operation reaches the session lock. + op_entered.set() + return real_resolve(*args, **kwargs) + + with unittest.mock.patch( + "agents.extensions.memory.redis_session.redis.from_url", return_value=fake_client + ): + session = RedisSession.from_url("close_interleave_test", url="redis://localhost:6379/15") + assert session._owns_client is True + + close_task: asyncio.Task[None] | None = None + op_task: asyncio.Task[list[TResponseInputItem]] | None = None + try: + with unittest.mock.patch.object(fake_client, "aclose", paused_aclose): + close_task = asyncio.create_task(session.close()) + await asyncio.wait_for(close_holds_lock.wait(), timeout) + + # Start the operation while close() still holds the lock so it must queue behind it. + with unittest.mock.patch( + "agents.extensions.memory.redis_session.resolve_session_limit", + entry_signalling_resolve, + ): + op_task = asyncio.create_task(session.get_items()) + await asyncio.wait_for(op_entered.wait(), timeout) + + # The operation is past method entry, so it can only be blocked on the session lock. + _, still_blocked = await asyncio.wait({op_task}, timeout=blocked_probe) + assert op_task in still_blocked + + release_close.set() + await asyncio.wait_for(close_task, timeout) + close_task = None + + with pytest.raises(RuntimeError, match="RedisSession is closed"): + await asyncio.wait_for(op_task, timeout) + op_task = None + finally: + release_close.set() + for task in (close_task, op_task): + if task is not None: + task.cancel() + with suppress(asyncio.CancelledError, RuntimeError): + await task From 27fc1f440757b0979953bfe45929d5fe5af23c29 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 31 Jul 2026 14:15:37 +0900 Subject: [PATCH 069/473] fix(sandbox): preserve tagged EnvValue subclasses (#4039) Co-authored-by: maplexu --- src/agents/sandbox/manifest.py | 135 +++++++++- tests/extensions/sandbox/test_vercel.py | 4 +- tests/sandbox/test_compatibility_guards.py | 3 + tests/sandbox/test_manifest.py | 237 +++++++++++++++++- tests/sandbox/test_session_state_roundtrip.py | 67 +++++ 5 files changed, 438 insertions(+), 8 deletions(-) diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index 9421694ecb..7fc57ac413 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -1,10 +1,18 @@ import abc import asyncio +import inspect from collections.abc import Iterator, Mapping from pathlib import Path, PurePath, PurePosixPath -from typing import Any, Literal - -from pydantic import BaseModel, Field, field_serializer, field_validator +from typing import Any, ClassVar, Literal + +from pydantic import ( + BaseModel, + Field, + SerializeAsAny, + field_serializer, + field_validator, +) +from pydantic_core import PydanticSerializationError from typing_extensions import assert_never from .._config_coercion import coerce_pydantic_config @@ -41,27 +49,144 @@ ] +EnvValueClass = type["EnvValue"] + + # TODO (sdcoffey) env val from secret store class EnvValue(BaseModel, abc.ABC): + type: str = "" + _subclass_registry: ClassVar[dict[str, EnvValueClass]] = {} + @abc.abstractmethod async def resolve(self) -> str: ... + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: object) -> None: + super().__pydantic_init_subclass__(**kwargs) + + annotations = inspect.get_annotations(cls) + if "type" not in annotations: + return + + type_field = cls.model_fields.get("type") + type_default = type_field.default if type_field is not None else None + if not isinstance(type_default, str) or type_default == "": + return + + existing = EnvValue._subclass_registry.get(type_default) + if existing is not None and existing is not cls: + raise TypeError( + f"env value type `{type_default}` is already registered by {existing.__name__}" + ) + EnvValue._subclass_registry[type_default] = cls + + @classmethod + def parse(cls, payload: object) -> "EnvValue": + """Deserialize a mapping into the subclass registered under its `type` field. + + An existing `EnvValue` instance is returned unchanged. + """ + if isinstance(payload, EnvValue): + return payload + if not isinstance(payload, Mapping): + raise TypeError( + f"env value must be an EnvValue or mapping, got {type(payload).__name__}" + ) + + value = payload.get("value") + if set(payload) == {"value"} and isinstance(value, str): + return StrEnvValue(value=value) + + env_value_type = payload.get("type") + if not isinstance(env_value_type, str): + raise ValueError("env value mapping must include a string `type` field") + + env_value_class = EnvValue._subclass_registry.get(env_value_type) + if env_value_class is None: + known = ", ".join(sorted(EnvValue._subclass_registry)) or "" + raise ValueError( + f"Unknown env value type `{env_value_type}`. Registered types: {known}" + ) + return env_value_class.model_validate(dict(payload)) + class StrEnvValue(EnvValue): + type: Literal["str"] = "str" value: str async def resolve(self) -> str: return self.value +def _serialize_env_value_with_type(value: EnvValue, serialized: object) -> dict[str, Any]: + if EnvValue._subclass_registry.get(value.type) is not type(value): + raise PydanticSerializationError( + f"{type(value).__name__} must explicitly declare its own non-empty `type` " + "to be serialized" + ) + if not isinstance(serialized, Mapping): + raise PydanticSerializationError( + f"{type(value).__name__} serializer must return a mapping to preserve its `type`" + ) + + data = dict(serialized) + data["type"] = value.type + return data + + class EnvEntry(BaseModel): description: str | None = None ephemeral: bool = Field(default=False) - value: EnvValue + value: SerializeAsAny[EnvValue] + + @field_validator("value", mode="before") + @classmethod + def _parse_value(cls, value: object) -> EnvValue: + return EnvValue.parse(value) + + @field_serializer("value", mode="wrap") + def _serialize_value(self, value: EnvValue, handler: Any) -> dict[str, Any]: + return _serialize_env_value_with_type(value, handler(value)) + + +def _parse_environment_value(payload: object) -> "str | EnvValue | EnvEntry": + """Route one environment member to the shape it represents.""" + if isinstance(payload, str | EnvValue | EnvEntry): + return payload + if not isinstance(payload, Mapping): + raise TypeError( + f"environment value must be a str, EnvValue, or EnvEntry, got {type(payload).__name__}" + ) + if "type" in payload or isinstance(payload.get("value"), str): + return EnvValue.parse(payload) + return EnvEntry.model_validate(dict(payload)) class Environment(BaseModel): - value: dict[str, str | EnvValue | EnvEntry] = Field(default_factory=dict) + value: dict[str, str | SerializeAsAny[EnvValue] | EnvEntry] = Field(default_factory=dict) + + @field_validator("value", mode="before") + @classmethod + def _parse_value(cls, value: object) -> dict[str, "str | EnvValue | EnvEntry"]: + if not isinstance(value, Mapping): + raise ValueError(f"Environment mapping must be a mapping, got {type(value).__name__}") + return {key: _parse_environment_value(entry) for key, entry in value.items()} + + @field_serializer("value", mode="wrap") + def _serialize_value( + self, + values: dict[str, "str | EnvValue | EnvEntry"], + handler: Any, + ) -> dict[str, Any]: + serialized = handler(values) + if not isinstance(serialized, Mapping): + raise PydanticSerializationError("Environment serializer must return a mapping") + + data = dict(serialized) + for key, value in values.items(): + if isinstance(value, EnvValue) and key in data: + data[key] = _serialize_env_value_with_type(value, data[key]) + return data def normalized(self) -> dict[str, EnvEntry]: result: dict[str, EnvEntry] = {} diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index f3f634595b..0cac7d9969 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -1072,11 +1072,11 @@ async def test_vercel_s3_manifest_sanitization_preserves_typed_environment( ) assert serialized_environment == { "value": { - "DIRECT": {"value": "direct-value"}, + "DIRECT": {"type": "str", "value": "direct-value"}, "ENTRY": { "description": "typed entry", "ephemeral": True, - "value": {"value": "entry-value"}, + "value": {"type": "str", "value": "entry-value"}, }, } } diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index cd59a8303e..7ab0bf74ff 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -40,6 +40,7 @@ RcloneMountPattern, S3FilesMountPattern, ) +from agents.sandbox.manifest import EnvValue, StrEnvValue from agents.sandbox.session.sandbox_client import BaseSandboxClientOptions from agents.sandbox.session.sandbox_session_state import SandboxSessionState from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot, RemoteSnapshot, SnapshotBase @@ -891,6 +892,7 @@ def test_core_discriminator_type_strings_are_stable() -> None: S3FilesMountPattern: "s3files", InContainerMountStrategy: "in_container", DockerVolumeMountStrategy: "docker_volume", + StrEnvValue: "str", } for cls, expected_type in expected_types.items(): @@ -1001,6 +1003,7 @@ def test_core_discriminator_registries_parse_released_payload_shapes() -> None: MountStrategyBase.parse({"type": "docker_volume", "driver": "rclone"}), DockerVolumeMountStrategy, ) + assert isinstance(EnvValue.parse({"type": "str", "value": "env-value"}), StrEnvValue) @pytest.mark.asyncio diff --git a/tests/sandbox/test_manifest.py b/tests/sandbox/test_manifest.py index c8b3959219..40c3b3d9bb 100644 --- a/tests/sandbox/test_manifest.py +++ b/tests/sandbox/test_manifest.py @@ -1,6 +1,10 @@ +import json from pathlib import Path +from typing import Literal import pytest +from pydantic import model_serializer +from pydantic_core import PydanticSerializationError from agents.sandbox.entries import ( Dir, @@ -10,10 +14,31 @@ MountpointMountPattern, ) from agents.sandbox.errors import InvalidManifestPathError -from agents.sandbox.manifest import Manifest +from agents.sandbox.manifest import EnvEntry, Environment, EnvValue, Manifest, StrEnvValue from agents.sandbox.manifest_render import _truncate_manifest_description +class _SecretReferenceEnvValue(EnvValue): + type: Literal["test.secret_reference"] = "test.secret_reference" + key: str + + async def resolve(self) -> str: + return f"resolved-secret-for-{self.key}" + + +class _CustomSerializedEnvValue(EnvValue): + type: Literal["test.custom_serializer"] = "test.custom_serializer" + key: str + internal_value: str = "" + + async def resolve(self) -> str: + return self.internal_value + + @model_serializer + def _serialize_reference(self) -> dict[str, str]: + return {"key": self.key} + + def test_manifest_rejects_nested_child_paths_that_escape_workspace() -> None: manifest = Manifest( entries={ @@ -212,3 +237,213 @@ def test_manifest_description_truncation_preserves_unbounded_description() -> No description = "short" assert _truncate_manifest_description(description, None) == description + + +@pytest.mark.asyncio +async def test_manifest_round_trips_tagged_env_values_without_resolved_secrets() -> None: + manifest = Manifest( + environment=Environment( + value={ + "DIRECT": _SecretReferenceEnvValue(key="direct"), + "ENTRY": EnvEntry( + description="secret reference", + ephemeral=True, + value=_SecretReferenceEnvValue(key="entry"), + ), + } + ) + ) + + payload_json = manifest.model_dump_json() + payload = json.loads(payload_json) + + assert payload["environment"] == { + "value": { + "DIRECT": {"type": "test.secret_reference", "key": "direct"}, + "ENTRY": { + "description": "secret reference", + "ephemeral": True, + "value": {"type": "test.secret_reference", "key": "entry"}, + }, + } + } + assert "resolved-secret" not in payload_json + + restored = Manifest.model_validate_json(payload_json) + + assert type(restored.environment.value["DIRECT"]) is _SecretReferenceEnvValue + restored_entry = restored.environment.value["ENTRY"] + assert isinstance(restored_entry, EnvEntry) + assert type(restored_entry.value) is _SecretReferenceEnvValue + assert await restored.environment.resolve() == { + "DIRECT": "resolved-secret-for-direct", + "ENTRY": "resolved-secret-for-entry", + } + + +def test_manifest_preserves_type_from_env_value_custom_serializer() -> None: + manifest = Manifest( + environment=Environment( + value={ + "DIRECT": _CustomSerializedEnvValue( + key="direct", + internal_value="direct-secret", + ), + "ENTRY": EnvEntry( + value=_CustomSerializedEnvValue( + key="entry", + internal_value="entry-secret", + ) + ), + } + ) + ) + + payload = manifest.model_dump(mode="json") + serialized = json.dumps(payload) + + assert payload["environment"]["value"] == { + "DIRECT": {"type": "test.custom_serializer", "key": "direct"}, + "ENTRY": { + "description": None, + "ephemeral": False, + "value": {"type": "test.custom_serializer", "key": "entry"}, + }, + } + assert "direct-secret" not in serialized + assert "entry-secret" not in serialized + + restored = Manifest.model_validate(payload) + + assert type(restored.environment.value["DIRECT"]) is _CustomSerializedEnvValue + restored_entry = restored.environment.value["ENTRY"] + assert isinstance(restored_entry, EnvEntry) + assert type(restored_entry.value) is _CustomSerializedEnvValue + + +def test_manifest_round_trips_str_env_value() -> None: + manifest = Manifest( + environment=Environment(value={"PLAIN": "plain", "TYPED": StrEnvValue(value="typed")}) + ) + + payload = manifest.model_dump(mode="json") + restored = Manifest.model_validate(payload) + + assert payload["environment"] == { + "value": {"PLAIN": "plain", "TYPED": {"type": "str", "value": "typed"}} + } + assert restored.environment.value == { + "PLAIN": "plain", + "TYPED": StrEnvValue(value="typed"), + } + + +def test_manifest_reads_legacy_discriminator_free_str_env_values() -> None: + payload = { + "environment": { + "value": { + "DIRECT": {"value": "direct-value"}, + "ENTRY": { + "description": "typed entry", + "ephemeral": True, + "value": {"value": "entry-value"}, + }, + } + } + } + + restored = Manifest.model_validate(payload) + + assert restored.environment.value == { + "DIRECT": StrEnvValue(value="direct-value"), + "ENTRY": EnvEntry( + description="typed entry", + ephemeral=True, + value=StrEnvValue(value="entry-value"), + ), + } + + +def test_manifest_rejects_ambiguous_discriminator_free_env_values() -> None: + payload = { + "environment": { + "value": { + "AMBIGUOUS": {"value": "plain", "description": "not a legacy StrEnvValue"}, + } + } + } + + with pytest.raises(ValueError, match="must include a string `type` field"): + Manifest.model_validate(payload) + + +@pytest.mark.parametrize(("exclude_unset", "exclude_defaults"), [(True, False), (False, True)]) +def test_manifest_env_value_type_survives_narrowed_dumps( + exclude_unset: bool, + exclude_defaults: bool, +) -> None: + manifest = Manifest( + environment=Environment(value={"TOKEN": _SecretReferenceEnvValue(key="token")}) + ) + + payload = manifest.model_dump( + mode="json", + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + ) + + assert payload["environment"]["value"]["TOKEN"]["type"] == "test.secret_reference" + assert Manifest.model_validate(payload).environment == manifest.environment + + +def test_manifest_rejects_unknown_env_value_type() -> None: + payload = {"environment": {"value": {"TOKEN": {"type": "unknown.env.value"}}}} + + with pytest.raises(ValueError, match="Unknown env value type `unknown.env.value`"): + Manifest.model_validate(payload) + + +@pytest.mark.asyncio +async def test_untagged_env_value_imports_and_resolves_but_does_not_serialize() -> None: + class _UntaggedEnvValue(EnvValue): + key: str + + async def resolve(self) -> str: + return f"resolved-secret-for-{self.key}" + + value = _UntaggedEnvValue(key="token") + + assert await value.resolve() == "resolved-secret-for-token" + with pytest.raises( + PydanticSerializationError, + match="_UntaggedEnvValue must explicitly declare its own non-empty `type`", + ): + Manifest(environment=Environment(value={"TOKEN": value})).model_dump_json() + + +@pytest.mark.asyncio +async def test_inherited_env_value_tag_imports_and_resolves_but_does_not_serialize() -> None: + class _LabeledStrEnvValue(StrEnvValue): + label: str + + value = _LabeledStrEnvValue(value="plain", label="example") + + assert await value.resolve() == "plain" + with pytest.raises( + PydanticSerializationError, + match="_LabeledStrEnvValue must explicitly declare its own non-empty `type`", + ): + Manifest(environment=Environment(value={"VALUE": value})).model_dump_json() + + +def test_duplicate_env_value_type_registration_raises() -> None: + with pytest.raises( + TypeError, + match="already registered by _SecretReferenceEnvValue", + ): + + class _DuplicateSecretReferenceEnvValue(EnvValue): + type: Literal["test.secret_reference"] = "test.secret_reference" + + async def resolve(self) -> str: + return "unused" diff --git a/tests/sandbox/test_session_state_roundtrip.py b/tests/sandbox/test_session_state_roundtrip.py index 2800a14c78..cab98a9b12 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -17,6 +17,7 @@ from pydantic import ConfigDict, ValidationError, field_serializer, field_validator from agents.sandbox import Manifest, SandboxPathGrant +from agents.sandbox.manifest import EnvEntry, Environment, EnvValue, StrEnvValue from agents.sandbox.session import ( BaseSandboxClient, Dependencies, @@ -51,6 +52,15 @@ class _SimpleSessionState(SandboxSessionState): type: Literal["simple-roundtrip"] = "simple-roundtrip" +class _SecretReferenceEnvValue(EnvValue): + __test__ = False + type: Literal["test.session-secret-reference"] = "test.session-secret-reference" + key: str + + async def resolve(self) -> str: + return f"resolved-secret-for-{self.key}" + + class _RoundTripClient(BaseSandboxClient[None]): backend_id = "roundtrip" supports_default_options = True @@ -176,6 +186,63 @@ def test_type_survives_exclude_unset(self) -> None: assert "type" in dumped assert dumped["type"] == "stub-roundtrip" + @pytest.mark.asyncio + async def test_parse_restores_manifest_env_value_subclasses(self) -> None: + original = _StubSessionState( + session_id=uuid.UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"), + snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")), + manifest=Manifest( + environment=Environment( + value={ + "DIRECT": _SecretReferenceEnvValue(key="direct"), + "ENTRY": EnvEntry(value=_SecretReferenceEnvValue(key="entry")), + } + ) + ), + custom_field="my-value", + ) + + payload = original.model_dump(mode="json") + serialized = json.dumps(payload) + + assert "resolved-secret" not in serialized + + restored = SandboxSessionState.parse(payload) + restored_environment = restored.manifest.environment.value + + assert type(restored_environment["DIRECT"]) is _SecretReferenceEnvValue + restored_entry = restored_environment["ENTRY"] + assert isinstance(restored_entry, EnvEntry) + assert type(restored_entry.value) is _SecretReferenceEnvValue + assert await restored.manifest.environment.resolve() == { + "DIRECT": "resolved-secret-for-direct", + "ENTRY": "resolved-secret-for-entry", + } + + def test_parse_reads_legacy_discriminator_free_str_env_values(self) -> None: + payload = _make_session_state().model_dump(mode="json") + payload["manifest"]["environment"] = { + "value": { + "DIRECT": {"value": "direct-value"}, + "ENTRY": { + "description": "typed entry", + "ephemeral": True, + "value": {"value": "entry-value"}, + }, + } + } + + restored = SandboxSessionState.parse(payload) + + assert restored.manifest.environment.value == { + "DIRECT": StrEnvValue(value="direct-value"), + "ENTRY": EnvEntry( + description="typed entry", + ephemeral=True, + value=StrEnvValue(value="entry-value"), + ), + } + def test_model_dump_preserves_snapshot_subclass_fields(self) -> None: """model_dump() must preserve snapshot subclass fields (e.g. LocalSnapshot.base_path). From 0ffa36840cb812488738f6fc5be3d3a1f51397b7 Mon Sep 17 00:00:00 2001 From: Ali Adnan <165782963+AAliKKhan@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:23:33 +0500 Subject: [PATCH 070/473] fix(voice): break out of audio dispatch loop when a stream task signals session_ended (#4044) --- src/agents/voice/result.py | 2 ++ tests/voice/test_pipeline.py | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index bc89a6c7e0..ba3fb3f384 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -270,6 +270,8 @@ async def _dispatch_audio(self): if chunk.event == "turn_ended": self._finish_turn() break + if chunk.event == "session_ended": + return await self._queue.put(VoiceStreamEventLifecycle(event="session_ended")) async def _wait_for_completion(self): diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 755764b16a..25107c38e1 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -242,6 +242,52 @@ async def run(self, text: str, settings: TTSModelSettings): ] +@pytest.mark.asyncio +async def test_streamed_audio_dispatcher_handles_stream_failure() -> None: + """A failed _stream_audio task must not leave _dispatch_audio blocked forever.""" + + class FailingTTS(FakeTTS): + async def run(self, text: str, settings: TTSModelSettings): + del text, settings + raise RuntimeError("tts-failure") + yield b"" # pragma: no cover + + result = StreamedAudioResult( + FailingTTS(), + TTSModelSettings(), + VoicePipelineConfig(trace_include_sensitive_data=False), + ) + + await result._add_text("This is the first sentence. This is the second one.") + + with pytest.raises(RuntimeError, match="tts-failure"): + await result._turn_done() + + # The single-turn pipeline queues the error and re-raises without calling _done(), + # so _completed_session stays false. The dispatcher must return as soon as it + # forwards the session_ended sentinel from the failed segment instead of blocking + # on the dead queue (or spinning in the outer wait loop). + dispatcher_task = result._dispatcher_task + assert dispatcher_task is not None + await asyncio.wait_for(dispatcher_task, timeout=5.0) + assert dispatcher_task.done() + + # The failed segment's session_ended is forwarded once; the dispatcher's normal + # epilogue must not queue a second terminal event. + events: list[VoiceStreamEvent] = [] + while True: + try: + events.append(result._queue.get_nowait()) + except asyncio.QueueEmpty: + break + terminal_events = [ + event + for event in events + if isinstance(event, VoiceStreamEventLifecycle) and event.event == "session_ended" + ] + assert len(terminal_events) == 1 + + @pytest.mark.asyncio async def test_streamed_audio_result_synthesizes_short_custom_splitter_chunk() -> None: texts: list[str] = [] From 000a96b602889b00f7cfaa210c41e1a74be65272 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 31 Jul 2026 15:45:13 +0900 Subject: [PATCH 071/473] fix: nested MCP cleanup error redaction (#4049) --- src/agents/mcp/server.py | 86 +++++++-------- tests/mcp/test_server_errors.py | 178 +++++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 52 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 2b5a8a25fa..944d0d1738 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -1144,62 +1144,48 @@ async def cleanup(self): ) raise except BaseExceptionGroup as eg: - # Extract HTTP errors from ExceptionGroup raised during cleanup - # This happens when background tasks fail (e.g., HTTP errors) - http_error = None - connect_error = None - timeout_error = None - - for exc in eg.exceptions: - if isinstance(exc, httpx.HTTPStatusError): - http_error = exc - elif isinstance(exc, httpx.ConnectError): - connect_error = exc - elif isinstance(exc, httpx.TimeoutException): - timeout_error = exc - del exc - - # Only raise HTTP errors if we're cleaning up after a failed connection. - # During normal teardown, log them instead. - if http_error: - if is_failed_connection_cleanup: - cleanup_error = self._user_error_for_http_error(http_error) - cleanup_cause = _safe_transport_cause(http_error) - if cleanup_cause is None: - http_error = None - else: - # Normal teardown - log but don't raise - _log_transport_warning( - get_mcp_server_log_message( - "HTTP error during cleanup of MCP server", self - ), - http_error, - ) - elif connect_error: - if is_failed_connection_cleanup: - cleanup_error = self._user_error_for_http_error(connect_error) - cleanup_cause = _safe_transport_cause(connect_error) - if cleanup_cause is None: - connect_error = None - else: - _log_transport_warning( - get_mcp_server_log_message( - "Connection error during cleanup of MCP server", self + http_errors = self._extract_http_errors_from_exception(eg) + unsafe_http_error = _first_unsafe_transport_error(http_errors) + selected_http_error = unsafe_http_error + + if selected_http_error is None: + # Preserve legacy group diagnostics when HTTP errors are nested but safe. + for error_type in ( + httpx.HTTPStatusError, + httpx.ConnectError, + httpx.TimeoutException, + ): + selected_http_error = next( + ( + error + for error in reversed(eg.exceptions) + if isinstance(error, Exception) and isinstance(error, error_type) ), - connect_error, + None, ) - elif timeout_error: + if selected_http_error is not None: + break + + if selected_http_error is not None: if is_failed_connection_cleanup: - cleanup_error = self._user_error_for_http_error(timeout_error) - cleanup_cause = _safe_transport_cause(timeout_error) + cleanup_error = self._user_error_for_http_error(selected_http_error) + cleanup_cause = _safe_transport_cause(selected_http_error) if cleanup_cause is None: - timeout_error = None + http_errors.clear() + del selected_http_error + del unsafe_http_error else: + if isinstance(selected_http_error, httpx.HTTPStatusError): + cleanup_message = "HTTP error during cleanup of MCP server" + elif isinstance(selected_http_error, httpx.ConnectError): + cleanup_message = "Connection error during cleanup of MCP server" + elif isinstance(selected_http_error, httpx.TimeoutException): + cleanup_message = "Timeout error during cleanup of MCP server" + else: + cleanup_message = "Request error during cleanup of MCP server" _log_transport_warning( - get_mcp_server_log_message( - "Timeout error during cleanup of MCP server", self - ), - timeout_error, + get_mcp_server_log_message(cleanup_message, self), + selected_http_error, ) else: # No HTTP error found, suppress RuntimeError about cancel scopes diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index d33308dfb6..5f10da4ce7 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -1,3 +1,4 @@ +import asyncio import builtins import logging import sys @@ -63,6 +64,27 @@ def _assert_url_credentials_hidden_from_log_record(record: logging.LogRecord) -> assert secret not in attached_values +def _assert_not_retained_in_log_record( + record: logging.LogRecord, + sensitive_value: object, +) -> None: + pending: list[object] = [record.__dict__] + seen: set[int] = set() + + while pending: + value = pending.pop() + assert value is not sensitive_value + if id(value) in seen: + continue + seen.add(id(value)) + + if isinstance(value, dict): + pending.extend(value.keys()) + pending.extend(value.values()) + elif isinstance(value, list | tuple | set | frozenset): + pending.extend(value) + + class CrashingClientSessionServer(_MCPServerWithClientSession): def __init__(self): super().__init__(cache_tools_list=False, client_session_timeout_seconds=5) @@ -500,8 +522,26 @@ async def test_failed_connection_cleanup_hides_url_credentials_from_exception_gr _assert_not_retained_in_traceback_locals(exc_info.value, http_error) +@pytest.mark.asyncio +async def test_failed_connection_cleanup_checks_every_nested_transport_error(): + server = MCPServerSse(params={"url": _SAFE_URL}) + cleanup_group, _, unsafe_error = _mixed_request_error_group(_CREDENTIALED_URL) + + with patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)): + with pytest.raises(UserError) as exc_info: + await server.cleanup() + + assert "Could not reach the server" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_traceback_locals(exc_info.value, cleanup_group) + _assert_not_retained_in_traceback_locals(exc_info.value, unsafe_error) + assert server.session is None + assert server._get_session_id is None + + @pytest.mark.asyncio @pytest.mark.parametrize("redacted", [True, False]) +@pytest.mark.parametrize("nested", [False, True]) @pytest.mark.parametrize( ("url", "safe_to_attach"), [ @@ -513,6 +553,7 @@ async def test_normal_cleanup_only_logs_safe_transport_exceptions( monkeypatch, caplog, redacted: bool, + nested: bool, url: str, safe_to_attach: bool, ): @@ -523,7 +564,10 @@ async def test_normal_cleanup_only_logs_safe_transport_exceptions( "timed out", request=httpx.Request("GET", url), ) - cleanup_group = BaseExceptionGroup("cleanup failed", [timeout_error]) + inner_error: BaseException = timeout_error + if nested: + inner_error = BaseExceptionGroup("nested cleanup failed", [inner_error]) + cleanup_group = BaseExceptionGroup("cleanup failed", [inner_error]) with ( patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)), @@ -534,9 +578,139 @@ async def test_normal_cleanup_only_logs_safe_transport_exceptions( record = caplog.records[-1] if not redacted and safe_to_attach: assert record.exc_info is not None - assert record.exc_info[1] is timeout_error + if nested: + assert record.levelno == logging.ERROR + assert record.exc_info[1] is cleanup_group + else: + assert record.levelno == logging.WARNING + assert record.exc_info[1] is timeout_error else: assert record.exc_info is None + assert record.exc_text is None + _assert_not_retained_in_log_record(record, cleanup_group) + _assert_not_retained_in_log_record(record, timeout_error) if not safe_to_attach: _assert_url_credentials_hidden_from_log_record(record) + + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_normal_cleanup_preserves_safe_nested_group_diagnostics(monkeypatch, caplog): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + timeout_error = httpx.ReadTimeout( + "timed out", + request=httpx.Request("GET", _SAFE_URL), + ) + cleanup_group = BaseExceptionGroup( + "cleanup failed", + [ + ValueError("ordinary sibling failure"), + BaseExceptionGroup("nested cleanup failed", [timeout_error]), + ], + ) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)), + caplog.at_level(logging.ERROR, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + assert record.levelno == logging.ERROR + assert record.exc_info is not None + assert record.exc_info[1] is cleanup_group + assert "ordinary sibling failure" in logging.Formatter().format(record) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_normal_cleanup_checks_every_nested_transport_error_before_logging( + monkeypatch, + caplog, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + cleanup_group, _, unsafe_error = _mixed_request_error_group(_CREDENTIALED_URL) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)), + caplog.at_level(logging.WARNING, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + assert record.exc_info is None + assert record.exc_text is None + _assert_not_retained_in_log_record(record, cleanup_group) + _assert_not_retained_in_log_record(record, unsafe_error) + _assert_url_credentials_hidden_from_log_record(record) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_normal_cleanup_preserves_non_http_exception_group_logging(monkeypatch, caplog): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + cleanup_group = BaseExceptionGroup("cleanup failed", [ValueError("ordinary failure")]) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)), + caplog.at_level(logging.ERROR, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + assert record.exc_info is not None + assert record.exc_info[1] is cleanup_group + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_normal_cleanup_preserves_cancel_scope_suppression(monkeypatch, caplog): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + cleanup_group = BaseExceptionGroup( + "cleanup failed", + [RuntimeError("Attempted to exit cancel scope in a different task")], + ) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)), + caplog.at_level(logging.DEBUG, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + assert record.levelno == logging.DEBUG + assert record.exc_info is not None + assert record.exc_info[1] is cleanup_group + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_cleanup_propagates_cancellation_and_clears_session_state(): + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + + with patch.object( + server.exit_stack, + "aclose", + AsyncMock(side_effect=asyncio.CancelledError()), + ): + with pytest.raises(asyncio.CancelledError): + await server.cleanup() + + assert server.session is None + assert server._get_session_id is None From b5465c705e80746b9bcb080429905ade45b11d8d Mon Sep 17 00:00:00 2001 From: Gautam Sharma <148205237+GautamSharma99@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:01:08 +0530 Subject: [PATCH 072/473] fix(voice): block audio dispatcher while idle (#4061) --- src/agents/voice/result.py | 17 +++++++--- tests/voice/test_pipeline.py | 63 +++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index ba3fb3f384..15b196bf95 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -57,6 +57,7 @@ def __init__( self._ordered_tasks: deque[asyncio.Queue[VoiceStreamEvent | None]] = ( deque() ) # New: deque to hold local queues for each text segment + self._dispatcher_event = asyncio.Event() self._dispatcher_task: asyncio.Task[Any] | None = ( None # Task to dispatch audio chunks in order ) @@ -87,6 +88,10 @@ def _set_task(self, task: asyncio.Task[Any]): async def _add_error(self, error: Exception): await self._queue.put(VoiceStreamEventError(error)) + def _enqueue_audio_segment(self, local_queue: asyncio.Queue[VoiceStreamEvent | None]) -> None: + self._ordered_tasks.append(local_queue) + self._dispatcher_event.set() + def _transform_audio_buffer( self, buffer: list[bytes], output_dtype: npt.DTypeLike ) -> npt.NDArray[np.int16 | np.float32]: @@ -209,7 +214,7 @@ async def _add_text(self, text: str): if combined_sentences: local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() - self._ordered_tasks.append(local_queue) + self._enqueue_audio_segment(local_queue) self._tasks.append( asyncio.create_task(self._stream_audio(combined_sentences, local_queue)) ) @@ -219,7 +224,7 @@ async def _add_text(self, text: str): async def _turn_done(self): if self._text_buffer: local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() - self._ordered_tasks.append(local_queue) # Append the local queue for the final segment + self._enqueue_audio_segment(local_queue) self._tasks.append( asyncio.create_task( self._stream_audio(self._text_buffer, local_queue, finish_turn=True) @@ -228,7 +233,7 @@ async def _turn_done(self): self._text_buffer = "" elif self._started_processing_turn: local_queue = asyncio.Queue() - self._ordered_tasks.append(local_queue) + self._enqueue_audio_segment(local_queue) await local_queue.put(VoiceStreamEventLifecycle(event="turn_ended")) self._done_processing = True if self._dispatcher_task is None: @@ -249,6 +254,7 @@ def _finish_turn(self): async def _done(self): self._completed_session = True + self._dispatcher_event.set() await self._wait_for_completion() async def _dispatch_audio(self): @@ -257,7 +263,10 @@ async def _dispatch_audio(self): if len(self._ordered_tasks) == 0: if self._completed_session: break - await asyncio.sleep(0) + self._dispatcher_event.clear() + # Recheck state after clearing so a notification cannot be lost before waiting. + if len(self._ordered_tasks) == 0 and not self._completed_session: + await self._dispatcher_event.wait() continue local_queue = self._ordered_tasks.popleft() while True: diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 25107c38e1..0163ca7999 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -3,7 +3,7 @@ import asyncio import logging from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal import numpy as np import numpy.typing as npt @@ -288,6 +288,67 @@ async def run(self, text: str, settings: TTSModelSettings): assert len(terminal_events) == 1 +@pytest.mark.asyncio +async def test_streamed_audio_dispatcher_blocks_until_work_is_available() -> None: + """The dispatcher must block while idle without losing a pre-wait notification.""" + + class PausingEvent(asyncio.Event): + def __init__(self) -> None: + super().__init__() + self.wait_calls = 0 + self.wait_started = asyncio.Event() + self.second_wait_started = asyncio.Event() + self.allow_wait = asyncio.Event() + + async def wait(self) -> Literal[True]: + self.wait_calls += 1 + if self.wait_calls == 1: + self.wait_started.set() + await self.allow_wait.wait() + elif self.wait_calls == 2: + self.second_wait_started.set() + return await super().wait() + + def split_immediately(text: str) -> tuple[str, str]: + return text, "" + + fake_tts = FakeTTS() + result = StreamedAudioResult( + fake_tts, + TTSModelSettings(buffer_size=1, text_splitter=split_immediately), + VoicePipelineConfig(), + ) + dispatcher_event = PausingEvent() + result._dispatcher_event = dispatcher_event + dispatcher_task = asyncio.create_task(result._dispatch_audio()) + result._dispatcher_task = dispatcher_task + + try: + await asyncio.wait_for(dispatcher_event.wait_started.wait(), timeout=1.0) + for _ in range(10): + await asyncio.sleep(0) + assert dispatcher_event.wait_calls == 1 + assert not dispatcher_task.done() + + await result._add_text("ok") + assert dispatcher_event.is_set() + dispatcher_event.allow_wait.set() + await result._turn_done() + await asyncio.wait_for(dispatcher_event.second_wait_started.wait(), timeout=1.0) + await result._done() + finally: + dispatcher_event.allow_wait.set() + if not dispatcher_task.done(): + dispatcher_task.cancel() + await asyncio.gather(dispatcher_task, return_exceptions=True) + + events, audio_chunks = await extract_events(result) + + assert events == ["turn_started", "audio", "turn_ended", "session_ended"] + assert len(audio_chunks) == 1 + await fake_tts.verify_audio("ok", audio_chunks[0]) + + @pytest.mark.asyncio async def test_streamed_audio_result_synthesizes_short_custom_splitter_chunk() -> None: texts: list[str] = [] From 2a69638f0af8cb7ccd89406ec70fa112ee26b5a3 Mon Sep 17 00:00:00 2001 From: Gautam Sharma <148205237+GautamSharma99@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:01:51 +0530 Subject: [PATCH 073/473] fix(realtime): preserve raw server event payloads (#4062) --- src/agents/realtime/openai_realtime.py | 10 +++++---- tests/realtime/test_openai_realtime.py | 30 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index a8b50686db..6f0cb1ce41 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -1113,11 +1113,13 @@ async def _handle_ws_event(self, event: dict[str, Any]): try: if "previous_item_id" in event and event["previous_item_id"] is None: - event["previous_item_id"] = "" # TODO (rm) remove + validation_event = {**event, "previous_item_id": ""} # TODO (rm) remove + else: + validation_event = event validation_event = ( - _normalize_custom_voice_for_server_event_validation(event) - if _should_normalize_custom_voice_for_server_event(event) - else event + _normalize_custom_voice_for_server_event_validation(validation_event) + if _should_normalize_custom_voice_for_server_event(validation_event) + else validation_event ) parsed: AllRealtimeServerEvents = self._server_event_type_adapter.validate_python( validation_event diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 9e8618d8b9..54b5f1758d 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -17,6 +17,7 @@ from agents.realtime.model_events import ( RealtimeModelAudioEvent, RealtimeModelErrorEvent, + RealtimeModelRawServerEvent, RealtimeModelToolCallEvent, RealtimeModelUsageEvent, ) @@ -419,6 +420,35 @@ def mock_create_task_func(coro): class TestEventHandlingRobustness(TestOpenAIRealtimeWebSocketModel): """Test event parsing, validation, and error handling robustness.""" + @pytest.mark.asyncio + async def test_raw_event_preserves_null_previous_item_id(self, model): + """Validation compatibility must not mutate the retained raw server payload.""" + mock_listener = AsyncMock() + model.add_listener(mock_listener) + server_event = { + "type": "conversation.item.created", + "event_id": "event_1", + "previous_item_id": None, + "item": { + "id": "item_1", + "type": "message", + "status": "completed", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + } + + await model._handle_ws_event(server_event) + + assert mock_listener.on_event.call_count == 2 + raw_event = mock_listener.on_event.call_args_list[0][0][0] + assert isinstance(raw_event, RealtimeModelRawServerEvent) + assert raw_event.data is server_event + assert raw_event.data["previous_item_id"] is None + + item_updated_event = mock_listener.on_event.call_args_list[1][0][0] + assert item_updated_event.item.previous_item_id == "" + @pytest.mark.asyncio async def test_handle_malformed_json_logs_error_continues(self, model): """Test that malformed JSON emits error event but doesn't crash.""" From 2c6be96b5c6a4027e5cf33a505510d915411edb7 Mon Sep 17 00:00:00 2001 From: Gautam Sharma <148205237+GautamSharma99@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:06:17 +0530 Subject: [PATCH 074/473] fix(tracing): use monotonic export deadlines (#4063) --- src/agents/tracing/processors.py | 6 +-- tests/test_trace_processor.py | 79 ++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 051bef46cf..4545d2e176 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -573,7 +573,7 @@ def __init__( self._export_trigger_size = max(1, int(max_queue_size * export_trigger_ratio)) # Track when we next *must* perform a scheduled export - self._next_export_time = time.time() + self._schedule_delay + self._next_export_time = time.monotonic() + self._schedule_delay # We lazily start the background worker thread the first time a span/trace is queued. self._worker_thread: threading.Thread | None = None @@ -652,14 +652,14 @@ def force_flush(self): def _run(self): while not self._shutdown_event.is_set(): - current_time = time.time() + current_time = time.monotonic() queue_size = self._queue.qsize() # If it's time for a scheduled flush or queue is above the trigger threshold if current_time >= self._next_export_time or queue_size >= self._export_trigger_size: self._export_batches() # Reset the next scheduled flush time - self._next_export_time = time.time() + self._schedule_delay + self._next_export_time = time.monotonic() + self._schedule_delay else: # Sleep a short interval so we don't busy-wait. time.sleep(0.2) diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index 1b580c928b..7a8ae2a694 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -283,34 +283,65 @@ def export(self, items: list[Trace | Span[Any]]) -> None: assert exporter.call_count >= 3 -def test_batch_trace_processor_scheduled_export(mocked_exporter): - """ - Tests that items are automatically exported when the schedule_delay expires. - We mock time.time() so we can trigger the condition without waiting in real time. - """ - with patch("time.time") as mock_time: - base_time = 1000.0 - mock_time.return_value = base_time - - processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=1.0) - - processor.on_span_end(get_span(processor)) # queue size = 1 +@pytest.mark.parametrize( + ("adjusted_wall_time", "adjusted_monotonic_time", "expected_scheduled_exports"), + [ + (2000.0, 100.5, 0), + (0.0, 101.5, 1), + ], +) +def test_batch_trace_processor_schedule_uses_monotonic_clock( + mocked_exporter, + monkeypatch, + adjusted_wall_time: float, + adjusted_monotonic_time: float, + expected_scheduled_exports: int, +) -> None: + class ControlledTime: + def __init__(self) -> None: + self.wall_time = 1000.0 + self.monotonic_time = 100.0 + self.sleep_calls = 0 + + def time(self) -> float: + return self.wall_time + + def monotonic(self) -> float: + return self.monotonic_time + + def sleep(self, _seconds: float) -> None: + self.sleep_calls += 1 + if self.sleep_calls == 1: + self.wall_time = adjusted_wall_time + self.monotonic_time = adjusted_monotonic_time + else: + processor._shutdown_event.set() + + controlled_time = ControlledTime() + monkeypatch.setattr("agents.tracing.processors.time", controlled_time) + processor = BatchTraceProcessor( + exporter=mocked_exporter, + max_queue_size=100, + schedule_delay=1.0, + export_trigger_ratio=1.0, + ) + processor._queue.put_nowait(get_span(processor)) + scheduled_export = object() + export_deadlines: list[float | None | object] = [] - # Now artificially advance time beyond the next export time - mock_time.return_value = base_time + 2.0 # > base_time + schedule_delay - # Let the background thread run a bit - time.sleep(0.3) + def record_export(deadline: float | None | object = scheduled_export) -> None: + export_deadlines.append(deadline) + if sum(item is scheduled_export for item in export_deadlines) > 1: + processor._shutdown_event.set() - # Check that exporter.export was eventually called - # Because the background thread runs, we might need a small sleep - processor.shutdown() + monkeypatch.setattr(processor, "_export_batches", record_export) - total_exported = 0 - for call_args in mocked_exporter.export.call_args_list: - batch = call_args[0][0] - total_exported += len(batch) + processor._run() - assert total_exported == 1, "Item should be exported after scheduled delay" + assert sum(deadline is scheduled_export for deadline in export_deadlines) == ( + expected_scheduled_exports + ) + assert export_deadlines[-1] is None def test_flush_traces_delegates_to_default_trace_provider(): From a017105509f6b0eec8877e81d3c2d35ee5fe7151 Mon Sep 17 00:00:00 2001 From: LHMQ878 <72402929@cityu-dg.edu.cn> Date: Fri, 31 Jul 2026 17:37:59 +0800 Subject: [PATCH 075/473] fix(modal): reject ephemeral paths during tar hydration (#4045) --- .../extensions/sandbox/modal/sandbox.py | 8 +- tests/extensions/sandbox/test_modal.py | 189 ++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index b4ae929749..d3a3665885 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -1858,9 +1858,15 @@ async def _hydrate_workspace_via_tar(self, data: io.IOBase) -> None: raise WorkspaceArchiveWriteError(path=root, context={"reason": "non_bytes_tar_payload"}) try: + # `raw` is handed to `tar xf -` unchanged below, so validation cannot drop + # a member: whatever it waves through, the extractor writes. Skipping the + # ephemeral paths would therefore let a member under one of them past + # member-type and link validation and still create it, so reject them + # instead. The producing side already excludes these paths via + # `_persist_workspace_skip_relpaths()`. validate_tar_bytes( bytes(raw), - skip_rel_paths=self.state.manifest.ephemeral_persistence_paths(), + reject_rel_paths=self.state.manifest.ephemeral_persistence_paths(), allow_external_symlink_targets=False, ) except UnsafeTarMemberError as e: diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index f6f52a291c..a63582319c 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -18,6 +18,7 @@ from agents.sandbox import Manifest from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.entries import ( + Dir, File, GCSMount, InContainerMountStrategy, @@ -31,6 +32,7 @@ InvalidManifestPathError, MountConfigError, WorkspaceArchiveReadError, + WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, ) from agents.sandbox.files import EntryKind @@ -2999,6 +3001,193 @@ async def _fake_call_modal( assert sandbox.processes[0].stdin.drain_calls >= 2 +def _hydration_tar_bytes(*members: tarfile.TarInfo) -> io.BytesIO: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + for member in members: + if member.isreg(): + tar.addfile(member, io.BytesIO(b"x" * member.size)) + else: + tar.addfile(member) + buf.seek(0) + return buf + + +def _hydration_member(name: str, kind: str = "file", linkname: str = "") -> tarfile.TarInfo: + member = tarfile.TarInfo(name) + if kind == "file": + member.size = 1 + elif kind == "dir": + member.type = tarfile.DIRTYPE + elif kind == "symlink": + member.type = tarfile.SYMTYPE + member.linkname = linkname + elif kind == "hardlink": + member.type = tarfile.LNKTYPE + member.linkname = linkname + elif kind == "fifo": + member.type = tarfile.FIFOTYPE + elif kind == "chardev": + member.type = tarfile.CHRTYPE + else: # pragma: no cover - guards against a typo in a parametrize entry + raise AssertionError(f"unknown kind: {kind}") + return member + + +class _RecordingHydrationSandbox: + """A sandbox that records what it was asked to run, so a test can assert *nothing* ran.""" + + object_id = "sb-123" + + def __init__(self) -> None: + self.commands: list[tuple[object, ...]] = [] + self.payloads: list[bytes] = [] + self.exec = _with_aio(self._exec) + + def _exec(self, *command: object, **kwargs: object) -> object: + _ = kwargs + self.commands.append(command) + sandbox = self + + class _Stdin: + def write(self, data: bytes | bytearray | memoryview) -> None: + sandbox.payloads.append(bytes(data)) + + def write_eof(self) -> None: + return None + + def drain(self) -> None: + return None + + stdin = _Stdin() + _set_aio_attr(stdin, "drain", stdin.drain) + return types.SimpleNamespace( + stdin=stdin, + stderr=types.SimpleNamespace(read=_with_aio(lambda: b"")), + wait=_with_aio(lambda: 0), + ) + + +def _hydration_session( + modal_module: Any, + monkeypatch: pytest.MonkeyPatch, + sandbox: _RecordingHydrationSandbox, +) -> Any: + # An ephemeral *directory*, so `ephemeral_persistence_paths()` yields the prefix + # `logs`; an ephemeral file would yield only its own exact path. + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + root="/workspace", + entries={ + "main.py": File(content=b"print('hi')\n"), + "logs": Dir(ephemeral=True), + }, + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id=sandbox.object_id, + ) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) + + async def _fake_call_modal( + fn: Callable[..., object], + *args: object, + call_timeout: float | None = None, + **kwargs: object, + ) -> object: + _ = call_timeout + return fn(*args, **kwargs) + + monkeypatch.setattr(session, "_call_modal", _fake_call_modal) + return session + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("member", "reason"), + [ + # A member claiming a path the manifest owns as ephemeral. + ( + _hydration_member("logs/events.jsonl"), + "archive member overlaps protected path: logs", + ), + # Member types and links under the ephemeral prefix. Under `skip_rel_paths` + # these were never validated and `tar xf` created them anyway. + (_hydration_member("logs/pipe", "fifo"), "unsupported member type"), + (_hydration_member("logs/dev", "chardev"), "unsupported member type"), + ( + _hydration_member("logs/escape", "symlink", "/etc/passwd"), + "archive member overlaps protected path: logs", + ), + ( + _hydration_member("logs/link", "hardlink", "/etc/passwd"), + "hardlink member not allowed", + ), + # A traversal member normalizing under the protected prefix. + ( + _hydration_member("logs/../../etc/passwd"), + "parent traversal", + ), + ], +) +async def test_modal_hydrate_tar_rejects_protected_members_before_extracting( + monkeypatch: pytest.MonkeyPatch, member: tarfile.TarInfo, reason: str +) -> None: + """Hydration must reject before `tar xf`, because it extracts the bytes it validated. + + `_hydrate_workspace_via_tar` validates the buffer it then pipes to `tar xf -`, so + there is no point after validation at which a member can be dropped. Hence + `reject_rel_paths` rather than `skip_rel_paths`: skipping suppresses member-type + and link validation while still handing those members to tar. The assertion that + no command ran is what distinguishes rejecting before extraction from failing + after it. + """ + + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + sandbox = _RecordingHydrationSandbox() + session = _hydration_session(modal_module, monkeypatch, sandbox) + + payload = _hydration_tar_bytes(_hydration_member("main.py"), member) + + with pytest.raises(WorkspaceArchiveWriteError) as excinfo: + await session.hydrate_workspace(payload) + + assert excinfo.value.context["reason"] == reason + assert sandbox.commands == [] + assert sandbox.payloads == [] + + +@pytest.mark.asyncio +async def test_modal_hydrate_tar_accepts_an_archive_without_protected_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The control: rejecting the ephemeral paths must not reject valid snapshots. + + A snapshot from `persist_workspace` excludes the ephemeral paths, and symlinks + staying inside the archive are still accepted so virtualenvs remain restorable. + """ + + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + sandbox = _RecordingHydrationSandbox() + session = _hydration_session(modal_module, monkeypatch, sandbox) + + payload = _hydration_tar_bytes( + _hydration_member(".", "dir"), + _hydration_member("./main.py"), + _hydration_member("./.venv", "dir"), + _hydration_member("./.venv/lib64", "symlink", "lib"), + ) + raw = payload.getvalue() + + await session.hydrate_workspace(payload) + + assert sandbox.commands == [ + ("mkdir", "-p", "--", "/workspace"), + ("tar", "xf", "-", "-C", "/workspace"), + ] + assert b"".join(sandbox.payloads) == raw + + @pytest.mark.asyncio async def test_modal_snapshot_filesystem_restore_preserves_exposed_ports( monkeypatch: pytest.MonkeyPatch, From 5e2c00b726dd6048c49993349d9b98474ca9f5f8 Mon Sep 17 00:00:00 2001 From: Satvik Sawhney Date: Fri, 31 Jul 2026 18:42:04 +0530 Subject: [PATCH 076/473] fix(models): close the LiteLLM provider stream on exit (#4066) --- src/agents/extensions/models/litellm_model.py | 55 ++++- .../test_litellm_chatcompletions_stream.py | 210 +++++++++++++++++- 2 files changed, 256 insertions(+), 9 deletions(-) diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index b29e9d2565..69150e2535 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import inspect import json import os import time @@ -39,7 +41,7 @@ from ...agent_output import AgentOutputSchemaBase from ...handoffs import Handoff from ...items import ModelResponse, TResponseInputItem, TResponseStreamEvent -from ...logger import logger +from ...logger import log_model_action_debug, logger from ...model_settings import ModelSettings from ...models._openai_retry import get_openai_retry_advice from ...models._retry_runtime import should_disable_provider_managed_retries @@ -398,13 +400,22 @@ async def stream_response( ) final_response: Response | None = None - async for chunk in ChatCmplStreamHandler.handle_stream( - response, stream, model=self.model - ): - yield chunk - - if chunk.type == "response.completed": - final_response = chunk.response + close_stream_in_background = False + try: + async for chunk in ChatCmplStreamHandler.handle_stream( + response, stream, model=self.model + ): + yield chunk + + if chunk.type == "response.completed": + final_response = chunk.response + except asyncio.CancelledError: + close_stream_in_background = True + self._schedule_async_iterator_close(stream) + raise + finally: + if not close_stream_in_background: + await self._maybe_aclose(stream) if tracing.include_data() and final_response: span_generation.span_data.output = [final_response.model_dump()] @@ -833,6 +844,34 @@ def _remove_not_given(self, value: Any) -> Any: def _merge_headers(self, model_settings: ModelSettings): return {**HEADERS, **(model_settings.extra_headers or {}), **(HEADERS_OVERRIDE.get() or {})} + @staticmethod + async def _maybe_aclose(value: Any) -> None: + aclose = getattr(value, "aclose", None) + if callable(aclose): + await aclose() + return + + close = getattr(value, "close", None) + if callable(close): + result = close() + if inspect.isawaitable(result): + await result + + def _schedule_async_iterator_close(self, iterator: Any) -> None: + task = asyncio.create_task(self._maybe_aclose(iterator)) + task.add_done_callback(self._consume_background_cleanup_task_result) + + @staticmethod + def _consume_background_cleanup_task_result(task: asyncio.Task[Any]) -> None: + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as exc: + log_model_action_debug( + logger, "Background stream cleanup failed after cancellation", exc + ) + class LitellmConverter: @classmethod diff --git a/tests/models/test_litellm_chatcompletions_stream.py b/tests/models/test_litellm_chatcompletions_stream.py index f9cb605c59..bdb890a399 100644 --- a/tests/models/test_litellm_chatcompletions_stream.py +++ b/tests/models/test_litellm_chatcompletions_stream.py @@ -1,4 +1,6 @@ +import asyncio from collections.abc import AsyncIterator +from typing import Any, cast import pytest from openai.types.chat.chat_completion_chunk import ( @@ -27,8 +29,9 @@ from agents.extensions.models.litellm_model import LitellmModel from agents.extensions.models.litellm_provider import LitellmProvider +from agents.items import TResponseStreamEvent from agents.model_settings import ModelSettings -from agents.models.interface import ModelTracing +from agents.models.interface import Model, ModelTracing @pytest.mark.allow_call_model_methods @@ -695,3 +698,208 @@ async def patched_fetch_response(self, *args, **kwargs): assert deltas and all(d.content_index == 0 and d.output_index == 1 for d in deltas) # The empty "" delta still opens no text part. assert "response.output_text.delta" not in [e.type for e in output_events] + + +class _ClosableChatStream: + """A provider stream that records closes. + + This mirrors litellm's `CustomStreamWrapper`, which exposes `aclose` and no `close`. + """ + + def __init__(self, chunks: list[ChatCompletionChunk]) -> None: + self._chunks = list(chunks) + self.aclose_calls = 0 + + def __aiter__(self) -> "_ClosableChatStream": + return self + + async def __anext__(self) -> ChatCompletionChunk: + if not self._chunks: + raise StopAsyncIteration + return self._chunks.pop(0) + + async def aclose(self) -> None: + self.aclose_calls += 1 + + +class _BlockingChatStream(_ClosableChatStream): + """Yields its chunks and then blocks so the consumer can be cancelled mid-stream.""" + + def __init__(self, chunks: list[ChatCompletionChunk], blocked: asyncio.Event) -> None: + super().__init__(chunks) + self._blocked = blocked + + async def __anext__(self) -> ChatCompletionChunk: + if self._chunks: + return self._chunks.pop(0) + self._blocked.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + +class _SlowCloseChatStream(_ClosableChatStream): + """Blocks in `aclose` until released, mirroring a provider close that waits on transport I/O.""" + + def __init__( + self, + chunks: list[ChatCompletionChunk], + blocked: asyncio.Event, + release: asyncio.Event, + ) -> None: + super().__init__(chunks) + self._blocked = blocked + self._release = release + self.aclose_completed = 0 + + async def __anext__(self) -> ChatCompletionChunk: + if self._chunks: + return self._chunks.pop(0) + self._blocked.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def aclose(self) -> None: + self.aclose_calls += 1 + await self._release.wait() + self.aclose_completed += 1 + + +def _text_chunk(text: str) -> ChatCompletionChunk: + return ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content=text))], + ) + + +def _patch_fetch_response(monkeypatch, provider_stream: _ClosableChatStream) -> None: + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, provider_stream + + monkeypatch.setattr(LitellmModel, "_fetch_response", patched_fetch_response) + + +def _stream_response(model: Model) -> AsyncIterator[TResponseStreamEvent]: + return model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_closes_provider_stream_on_explicit_aclose(monkeypatch) -> None: + """Closing the returned generator early must release the provider stream.""" + provider_stream = _ClosableChatStream([_text_chunk("He"), _text_chunk("llo")]) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + + stream_agen = cast(Any, _stream_response(model)) + async for _event in stream_agen: + break + await stream_agen.aclose() + + assert provider_stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_closes_provider_stream_on_normal_exhaustion(monkeypatch) -> None: + """Consuming the stream to completion must also release the provider stream.""" + provider_stream = _ClosableChatStream([_text_chunk("He"), _text_chunk("llo")]) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + + async for _event in _stream_response(model): + pass + + assert provider_stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_closes_provider_stream_after_cancellation(monkeypatch) -> None: + """Cancelling the consumer unwinds into the `finally` and releases the provider stream. + + Closing the already-finished generator afterwards is a no-op, so the stream is closed once. + """ + blocked = asyncio.Event() + provider_stream = _BlockingChatStream([_text_chunk("He")], blocked) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + + stream_agen = cast(Any, _stream_response(model)) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(blocked.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + task.cancel() + + await stream_agen.aclose() + + assert provider_stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_does_not_block_cancellation_on_slow_close(monkeypatch) -> None: + """A provider close that waits on transport I/O must not delay cancellation.""" + blocked = asyncio.Event() + release = asyncio.Event() + provider_stream = _SlowCloseChatStream([_text_chunk("He")], blocked, release) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + + stream_agen = cast(Any, _stream_response(model)) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(blocked.wait(), timeout=5) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 0 + + release.set() + for _ in range(200): + if provider_stream.aclose_completed == 1: + break + await asyncio.sleep(0.01) + assert provider_stream.aclose_completed == 1 + finally: + release.set() + task.cancel() From c3f1781d56e8f1249a01674f18ba1f3e44a16dce Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 31 Jul 2026 22:56:36 +0900 Subject: [PATCH 077/473] fix: redact MCP prompt and resource transport errors (#4067) --- src/agents/mcp/server.py | 117 +++++++++++- src/agents/voice/result.py | 45 +++-- tests/mcp/test_server_errors.py | 306 ++++++++++++++++++++++++++++++++ tests/voice/test_pipeline.py | 45 +++++ 4 files changed, 490 insertions(+), 23 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 944d0d1738..35cf10ace8 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -101,6 +101,9 @@ class RequireApprovalObject(TypedDict, total=False): T = TypeVar("T") +_SAFE_EXCEPTION_GROUP_MESSAGE = "MCP request failed with additional errors." +_SAFE_EXCEPTION_MESSAGE = "An additional error occurred during the MCP request." + def _safe_transport_cause(http_error: Exception) -> Exception | None: """Keep a transport exception only when its HTTPX URLs need no sanitization.""" @@ -136,6 +139,37 @@ def _first_unsafe_transport_error(http_errors: list[Exception]) -> Exception | N return next((error for error in http_errors if _safe_transport_cause(error) is None), None) +def _is_http_transport_error(error: BaseException) -> bool: + """Return whether an exception is an HTTPX transport error.""" + return isinstance(error, httpx.HTTPStatusError | httpx.RequestError) + + +def _credential_safe_exception_group(error_group: BaseExceptionGroup) -> BaseExceptionGroup: + """Replace an exception group with a fixed-data graph that retains control semantics.""" + safe_exceptions = [ + _credential_safe_exception_group(error) + if isinstance(error, BaseExceptionGroup) + else _credential_safe_exception_leaf(error) + for error in error_group.exceptions + ] + return BaseExceptionGroup(_SAFE_EXCEPTION_GROUP_MESSAGE, safe_exceptions) + + +def _credential_safe_exception_leaf(error: BaseException) -> BaseException: + """Create a fixed-data replacement for one retained exception leaf.""" + if isinstance(error, asyncio.CancelledError): + return asyncio.CancelledError() + if isinstance(error, KeyboardInterrupt): + return KeyboardInterrupt() + if isinstance(error, SystemExit): + return SystemExit() + if isinstance(error, GeneratorExit): + return GeneratorExit() + if isinstance(error, Exception): + return RuntimeError(_SAFE_EXCEPTION_MESSAGE) + return BaseException(_SAFE_EXCEPTION_MESSAGE) + + def _log_transport_warning(message: str, http_error: Exception) -> None: """Log a transport failure without attaching credential-bearing request URLs.""" if _debug.DONT_LOG_TOOL_DATA: @@ -826,6 +860,64 @@ def _raise_mapped_transport_error(error: UserError, cause: Exception | None) -> raise error from None raise error from cause + def _user_error_for_request_operation( + self, + operation: str, + http_error: Exception, + ) -> UserError: + """Build a credential-safe error for an MCP request operation.""" + error_message = f"Failed to {operation} on MCP server '{self._error_name}': " + if isinstance(http_error, httpx.HTTPStatusError): + error_message += f"HTTP error {http_error.response.status_code}" + elif isinstance(http_error, httpx.ConnectError): + error_message += "Connection lost. The server may have disconnected." + elif isinstance(http_error, httpx.TimeoutException): + error_message += "Connection timeout." + else: + error_message += "Request failed." + return UserError(error_message) + + async def _run_request_with_transport_error_redaction( + self, + operation: str, + func: Callable[[], Awaitable[T]], + ) -> T: + """Run an MCP request without retaining credential-bearing HTTP errors.""" + transport_error: UserError | None = None + base_error_group: BaseExceptionGroup | None = None + try: + return await func() + except (httpx.HTTPStatusError, httpx.RequestError) as http_error: + transport_error = self._user_error_for_request_operation(operation, http_error) + except BaseExceptionGroup as error_group: + http_errors = self._extract_http_errors_from_exception(error_group) + if not http_errors: + raise + selected_http_error = http_errors[0] + http_group, remaining_group = error_group.split(_is_http_transport_error) + assert http_group is not None + mapped_transport_error = self._user_error_for_request_operation( + operation, + selected_http_error, + ) + if remaining_group is None: + transport_error = mapped_transport_error + else: + safe_remaining_group = _credential_safe_exception_group(remaining_group) + base_error_group = BaseExceptionGroup( + _SAFE_EXCEPTION_GROUP_MESSAGE, + [mapped_transport_error, *safe_remaining_group.exceptions], + ) + http_errors.clear() + del selected_http_error + del http_group + del remaining_group + + if base_error_group is not None: + raise base_error_group + assert transport_error is not None + self._raise_mapped_transport_error(transport_error, None) + async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: attempts = 0 while True: @@ -1079,7 +1171,10 @@ async def list_prompts( raise UserError("Server not initialized. Make sure you call `connect()` first.") session = self.session assert session is not None - return await self._maybe_serialize_request(lambda: session.list_prompts()) + return await self._run_request_with_transport_error_redaction( + "list prompts", + lambda: self._maybe_serialize_request(lambda: session.list_prompts()), + ) async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None @@ -1089,7 +1184,10 @@ async def get_prompt( raise UserError("Server not initialized. Make sure you call `connect()` first.") session = self.session assert session is not None - return await self._maybe_serialize_request(lambda: session.get_prompt(name, arguments)) + return await self._run_request_with_transport_error_redaction( + "get prompt", + lambda: self._maybe_serialize_request(lambda: session.get_prompt(name, arguments)), + ) async def list_resources(self, cursor: str | None = None) -> ListResourcesResult: """List the resources available on the server.""" @@ -1097,7 +1195,10 @@ async def list_resources(self, cursor: str | None = None) -> ListResourcesResult raise UserError("Server not initialized. Make sure you call `connect()` first.") session = self.session assert session is not None - return await self._maybe_serialize_request(lambda: session.list_resources(cursor)) + return await self._run_request_with_transport_error_redaction( + "list resources", + lambda: self._maybe_serialize_request(lambda: session.list_resources(cursor)), + ) async def list_resource_templates( self, cursor: str | None = None @@ -1107,7 +1208,10 @@ async def list_resource_templates( raise UserError("Server not initialized. Make sure you call `connect()` first.") session = self.session assert session is not None - return await self._maybe_serialize_request(lambda: session.list_resource_templates(cursor)) + return await self._run_request_with_transport_error_redaction( + "list resource templates", + lambda: self._maybe_serialize_request(lambda: session.list_resource_templates(cursor)), + ) async def read_resource(self, uri: str) -> ReadResourceResult: """Read the contents of a specific resource by URI. @@ -1122,7 +1226,10 @@ async def read_resource(self, uri: str) -> ReadResourceResult: assert session is not None from pydantic import AnyUrl - return await self._maybe_serialize_request(lambda: session.read_resource(AnyUrl(uri))) + return await self._run_request_with_transport_error_redaction( + "read resource", + lambda: self._maybe_serialize_request(lambda: session.read_resource(AnyUrl(uri))), + ) async def cleanup(self): """Cleanup the server.""" diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 15b196bf95..9e5641384d 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -289,18 +289,25 @@ async def _wait_for_completion(self): tasks.append(self._dispatcher_task) await asyncio.gather(*tasks) - def _cleanup_tasks(self): - self._finish_turn() + async def _cleanup_tasks(self): + current_task = asyncio.current_task() + tasks: list[asyncio.Task[Any]] = [] + seen: set[asyncio.Task[Any]] = set() + for task in [*self._tasks, self._dispatcher_task, self.text_generation_task]: + if task is None or task is current_task or task in seen: + continue + seen.add(task) + tasks.append(task) - for task in self._tasks: + for task in tasks: if not task.done(): task.cancel() - if self._dispatcher_task and not self._dispatcher_task.done(): - self._dispatcher_task.cancel() - - if self.text_generation_task and not self.text_generation_task.done(): - self.text_generation_task.cancel() + try: + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + finally: + self._finish_turn() def _check_errors(self): for task in self._tasks: @@ -316,7 +323,7 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: try: event = await self._queue.get() except asyncio.CancelledError: - self._cleanup_tasks() + await self._cleanup_tasks() raise if isinstance(event, VoiceStreamEventError): self._stored_exception = event.error @@ -333,15 +340,17 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: # On the normal completion path, let the producer task finish gracefully so any active # trace context can emit `trace_end` before we run cleanup. - if ( - saw_session_end - and self.text_generation_task is not None - and not self.text_generation_task.done() - ): - await asyncio.shield(self.text_generation_task) - - self._check_errors() - self._cleanup_tasks() + try: + if ( + saw_session_end + and self.text_generation_task is not None + and not self.text_generation_task.done() + ): + await asyncio.shield(self.text_generation_task) + + self._check_errors() + finally: + await self._cleanup_tasks() if self._stored_exception: raise self._stored_exception diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index 5f10da4ce7..b5ffc88406 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -29,6 +29,13 @@ ) _URL_SECRETS = ("user", "s3cr3t_pw", "SECRET_QS_KEY", "SECRET_FRAGMENT") _SAFE_URL = "https://mcp.example.com/sse" +_PROMPT_RESOURCE_OPERATIONS = [ + ("list_prompts", (), "list prompts"), + ("get_prompt", ("safe_prompt", None), "get prompt"), + ("list_resources", (None,), "list resources"), + ("list_resource_templates", (None,), "list resource templates"), + ("read_resource", ("file:///safe.txt",), "read resource"), +] def _assert_url_credentials_hidden(error: BaseException) -> None: @@ -48,6 +55,47 @@ def _assert_not_retained_in_traceback_locals(error: BaseException, sensitive_val current = current.tb_next +def _assert_not_retained_in_exception_graph( + error: BaseException, + sensitive_value: object, +) -> None: + pending: list[object] = [error] + seen: set[int] = set() + + while pending: + value = pending.pop() + assert value is not sensitive_value + if id(value) in seen: + continue + seen.add(id(value)) + + if isinstance(value, BaseException): + pending.extend(value.args) + if value.__cause__ is not None: + pending.append(value.__cause__) + if value.__context__ is not None: + pending.append(value.__context__) + pending.extend(getattr(value, "__notes__", ())) + pending.append(value.__dict__) + if isinstance(value, BaseExceptionGroup): + pending.extend(value.exceptions) + elif isinstance(value, dict): + pending.extend(value.keys()) + pending.extend(value.values()) + elif isinstance(value, list | tuple | set | frozenset): + pending.extend(value) + + +def _assert_url_credentials_hidden_from_traceback_locals(error: BaseException) -> None: + current = error.__traceback__ + while current is not None: + if current.tb_frame.f_code.co_filename.endswith("/src/agents/mcp/server.py"): + attached_values = repr(tuple(current.tb_frame.f_locals.values())) + for secret in _URL_SECRETS: + assert secret not in attached_values + current = current.tb_next + + def _assert_url_credentials_hidden_from_log_record(record: logging.LogRecord) -> None: rendered = logging.Formatter("%(levelname)s %(message)s").format(record) attached_values = repr( @@ -126,6 +174,264 @@ async def test_not_calling_connect_causes_error(): await server.call_tool("foo", {}) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "args", "operation"), + _PROMPT_RESOURCE_OPERATIONS, +) +@pytest.mark.parametrize("redacted", [True, False]) +async def test_prompt_and_resource_request_errors_hide_url_credentials( + monkeypatch, + caplog, + method_name: str, + args: tuple[object, ...], + operation: str, + redacted: bool, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + server = MCPServerStreamableHttp(params={"url": _CREDENTIALED_URL}) + request_error = httpx.ReadError( + "request failed", + request=httpx.Request("POST", _CREDENTIALED_URL), + ) + session = MagicMock() + setattr(session, method_name, AsyncMock(side_effect=request_error)) + server.session = session + + with caplog.at_level(logging.DEBUG, logger="openai.agents"): + with pytest.raises(UserError) as user_error_info: + await getattr(server, method_name)(*args) + + assert f"Failed to {operation}" in str(user_error_info.value) + assert "mcp.example.com/sse" in str(user_error_info.value) + assert "Request failed" in str(user_error_info.value) + assert not hasattr(user_error_info.value, "request") + _assert_url_credentials_hidden(user_error_info.value) + _assert_not_retained_in_traceback_locals(user_error_info.value, request_error) + _assert_url_credentials_hidden_from_traceback_locals(user_error_info.value) + assert not [record for record in caplog.records if record.name == "openai.agents"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "args", "_operation"), + _PROMPT_RESOURCE_OPERATIONS, +) +async def test_prompt_and_resource_request_errors_hide_attached_request_data( + method_name: str, + args: tuple[object, ...], + _operation: str, +): + session_secret = "SECRET_MCP_SESSION_ID" + body_secret = "SECRET_REQUEST_BODY" + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + request_error = httpx.ReadError( + "request failed", + request=httpx.Request( + "POST", + _SAFE_URL, + headers={"mcp-session-id": session_secret}, + content=body_secret, + ), + ) + session = MagicMock() + setattr(session, method_name, AsyncMock(side_effect=request_error)) + server.session = session + + with pytest.raises(UserError) as user_error_info: + await getattr(server, method_name)(*args) + + rendered = "".join(traceback.format_exception(user_error_info.value)) + assert session_secret not in rendered + assert body_secret not in rendered + assert user_error_info.value.__cause__ is None + assert user_error_info.value.__context__ is None + _assert_not_retained_in_traceback_locals(user_error_info.value, request_error) + _assert_not_retained_in_exception_graph(user_error_info.value, request_error) + + +@pytest.mark.asyncio +async def test_prompt_http_status_errors_hide_attached_response_data(): + request_body_secret = "SECRET_REQUEST_BODY" + response_header_secret = "SECRET_RESPONSE_COOKIE" + response_body_secret = "SECRET_RESPONSE_BODY" + history_body_secret = "SECRET_HISTORY_BODY" + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + request = httpx.Request("POST", _SAFE_URL, content=request_body_secret) + history_request = httpx.Request("POST", _SAFE_URL) + history_response = httpx.Response( + 307, + request=history_request, + headers={"set-cookie": history_body_secret}, + content=history_body_secret, + ) + response = httpx.Response( + 503, + request=request, + headers={"set-cookie": response_header_secret}, + content=response_body_secret, + history=[history_response], + ) + http_error = httpx.HTTPStatusError("boom", request=request, response=response) + session = MagicMock() + session.list_prompts = AsyncMock(side_effect=http_error) + server.session = session + + with pytest.raises(UserError) as user_error_info: + await server.list_prompts() + + rendered = "".join(traceback.format_exception(user_error_info.value)) + for secret in ( + request_body_secret, + response_header_secret, + response_body_secret, + history_body_secret, + ): + assert secret not in rendered + assert user_error_info.value.__cause__ is None + assert user_error_info.value.__context__ is None + _assert_not_retained_in_traceback_locals(user_error_info.value, http_error) + _assert_not_retained_in_exception_graph(user_error_info.value, http_error) + + +@pytest.mark.asyncio +async def test_prompt_request_http_status_hides_url_credentials(): + server = MCPServerStreamableHttp(params={"url": _CREDENTIALED_URL}) + request = httpx.Request("GET", _CREDENTIALED_URL) + http_error = httpx.HTTPStatusError( + "boom", + request=request, + response=httpx.Response(503, request=request), + ) + session = MagicMock() + session.list_prompts = AsyncMock(side_effect=http_error) + server.session = session + + with pytest.raises(UserError) as user_error_info: + await server.list_prompts() + + assert "HTTP error 503" in str(user_error_info.value) + _assert_url_credentials_hidden(user_error_info.value) + _assert_not_retained_in_traceback_locals(user_error_info.value, http_error) + _assert_url_credentials_hidden_from_traceback_locals(user_error_info.value) + + +@pytest.mark.asyncio +async def test_resource_request_nested_group_replaces_ordinary_siblings_safely(): + server = MCPServerStreamableHttp(params={"url": _CREDENTIALED_URL}) + request_error = httpx.ConnectError( + "connection failed", + request=httpx.Request("GET", _CREDENTIALED_URL), + ) + + ordinary_error = ValueError("ordinary sibling failure", request_error) + ordinary_error.__notes__ = [_CREDENTIALED_URL] + ordinary_error.unsafe_request = request_error # type: ignore[attr-defined] + error_group = BaseExceptionGroup( + "request failed", + [ + ordinary_error, + BaseExceptionGroup("transport failed", [request_error]), + ], + ) + session = MagicMock() + session.read_resource = AsyncMock(side_effect=error_group) + server.session = session + + with pytest.raises(BaseExceptionGroup) as error_group_info: + await server.read_resource("file:///safe.txt") + + propagated_group = error_group_info.value + assert len(propagated_group.exceptions) == 2 + propagated_transport_error, propagated_error = propagated_group.exceptions + assert isinstance(propagated_transport_error, UserError) + assert "Failed to read resource" in str(propagated_transport_error) + assert "Connection lost" in str(propagated_transport_error) + assert propagated_transport_error.__cause__ is None + assert propagated_transport_error.__context__ is None + assert isinstance(propagated_error, RuntimeError) + assert str(propagated_error) == "An additional error occurred during the MCP request." + assert id(propagated_error) != id(ordinary_error) + _assert_url_credentials_hidden(propagated_group) + _assert_not_retained_in_traceback_locals(propagated_group, error_group) + _assert_not_retained_in_traceback_locals(propagated_group, request_error) + _assert_not_retained_in_exception_graph(propagated_group, ordinary_error) + _assert_not_retained_in_exception_graph(propagated_group, request_error) + _assert_url_credentials_hidden_from_traceback_locals(propagated_group) + + +@pytest.mark.asyncio +async def test_resource_request_mixed_group_preserves_cancellation(): + server = MCPServerStreamableHttp(params={"url": _CREDENTIALED_URL}) + cancellation = asyncio.CancelledError("request cancelled") + request_error = httpx.ConnectError( + "connection failed", + request=httpx.Request("GET", _CREDENTIALED_URL), + ) + error_group: BaseExceptionGroup | None = None + + async def raise_mixed_group(uri: object) -> None: + del uri + nonlocal error_group + error_group = BaseExceptionGroup( + "request failed", + [cancellation, request_error], + ) + raise error_group + + session = MagicMock() + session.read_resource = raise_mixed_group + server.session = session + + with pytest.raises(BaseExceptionGroup) as error_group_info: + await server.read_resource("file:///safe.txt") + + propagated_group = error_group_info.value + assert len(propagated_group.exceptions) == 2 + propagated_transport_error, propagated_cancellation = propagated_group.exceptions + assert isinstance(propagated_transport_error, UserError) + assert "Failed to read resource" in str(propagated_transport_error) + assert "Connection lost" in str(propagated_transport_error) + assert propagated_transport_error.__cause__ is None + assert propagated_transport_error.__context__ is None + assert isinstance(propagated_cancellation, asyncio.CancelledError) + assert propagated_cancellation is not cancellation + _assert_url_credentials_hidden(propagated_group) + assert error_group is not None + _assert_not_retained_in_traceback_locals(propagated_group, error_group) + _assert_not_retained_in_traceback_locals(propagated_group, request_error) + _assert_not_retained_in_exception_graph(propagated_group, cancellation) + _assert_not_retained_in_exception_graph(propagated_group, request_error) + _assert_url_credentials_hidden_from_traceback_locals(propagated_group) + traceback_frames = [] + current = propagated_group.__traceback__ + while current is not None: + traceback_frames.append(current.tb_frame) + current = current.tb_next + assert all(frame.f_code.co_name != "raise_mixed_group" for frame in traceback_frames) + + +@pytest.mark.asyncio +async def test_resource_request_sanitizes_safe_url_nested_group(): + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + request_error = httpx.ConnectError( + "connection failed", + request=httpx.Request("GET", _SAFE_URL), + ) + error_group = BaseExceptionGroup("request failed", [request_error]) + session = MagicMock() + session.read_resource = AsyncMock(side_effect=error_group) + server.session = session + + with pytest.raises(UserError) as user_error_info: + await server.read_resource("file:///safe.txt") + + assert user_error_info.value.__cause__ is None + assert user_error_info.value.__context__ is None + _assert_not_retained_in_traceback_locals(user_error_info.value, error_group) + _assert_not_retained_in_exception_graph(user_error_info.value, request_error) + + @pytest.mark.asyncio @pytest.mark.parametrize( ("url", "retains_cause"), diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 0163ca7999..b76b51555a 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -288,6 +288,51 @@ async def run(self, text: str, settings: TTSModelSettings): assert len(terminal_events) == 1 +@pytest.mark.asyncio +async def test_voice_pipeline_awaits_task_cleanup_after_tts_failure() -> None: + """A public pipeline stream must await sibling task cleanup when TTS fails.""" + + second_segment_started = asyncio.Event() + second_segment_stopped = asyncio.Event() + + class FailingTTS(FakeTTS): + async def run(self, text: str, settings: TTSModelSettings): + del settings + if text == "first": + await second_segment_started.wait() + raise RuntimeError("tts-failure") + yield b"" # pragma: no cover + + second_segment_started.set() + try: + await asyncio.Event().wait() + finally: + second_segment_stopped.set() + + def split_immediately(text: str) -> tuple[str, str]: + return text, "" + + pipeline = VoicePipeline( + workflow=FakeWorkflow([["first", "second"]]), + stt_model=FakeSTT(["user input"]), + tts_model=FailingTTS(), + config=VoicePipelineConfig(tts_settings=TTSModelSettings(text_splitter=split_immediately)), + ) + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + + with pytest.raises(RuntimeError, match="tts-failure"): + async for _event in result.stream(): + pass + + assert second_segment_stopped.is_set() + assert all(task.done() for task in result._tasks) + assert result._dispatcher_task is not None + assert result._dispatcher_task.done() + assert result._tracing_span is None + assert result.text_generation_task is not None + assert result.text_generation_task.done() + + @pytest.mark.asyncio async def test_streamed_audio_dispatcher_blocks_until_work_is_available() -> None: """The dispatcher must block while idle without losing a pre-wait notification.""" From 78725420e8c2c3979dad332e83bd2ca7deca3d02 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 31 Jul 2026 16:47:20 -0500 Subject: [PATCH 078/473] fix(run): report input guardrail results when a tripwire aborts the run (#4071) --- src/agents/run.py | 14 ++- src/agents/run_internal/guardrails.py | 16 ++- tests/test_guardrails.py | 137 ++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index d0e3f9ee56..8c57f364da 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -797,16 +797,16 @@ def _finalize_result(result: RunResult) -> RunResult: g for g in all_input_guardrails if not g.run_in_parallel ] parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel] - sequential_results: list[InputGuardrailResult] = [] if sandbox_runtime.enabled and sequential_guardrails: # Blocking first-turn guardrails must run before sandbox prep so a tripwire # can prevent session creation, startup, or live-session mutation. try: - sequential_results = await run_input_guardrails( + await run_input_guardrails( starting_agent, sequential_guardrails, copy_input_items(original_input), context_wrapper, + input_guardrail_results, ) except InputGuardrailTripwireTriggered: session_input_items_for_persistence = ( @@ -1221,11 +1221,12 @@ def _finalize_result(result: RunResult) -> RunResult: if current_turn <= 1: try: if sequential_guardrails: - sequential_results = await run_input_guardrails( + await run_input_guardrails( starting_agent, sequential_guardrails, copy_input_items(original_input), context_wrapper, + input_guardrail_results, ) except InputGuardrailTripwireTriggered: session_input_items_for_persistence = ( @@ -1240,7 +1241,6 @@ def _finalize_result(result: RunResult) -> RunResult: ) raise - parallel_results: list[InputGuardrailResult] = [] model_task = asyncio.create_task( run_single_turn( bindings=current_bindings, @@ -1272,10 +1272,11 @@ def _finalize_result(result: RunResult) -> RunResult: parallel_guardrails, copy_input_items(original_input), context_wrapper, + input_guardrail_results, ) ) try: - parallel_results, turn_result = await asyncio.gather( + _, turn_result = await asyncio.gather( guardrail_task, model_task, ) @@ -1310,9 +1311,6 @@ def _finalize_result(result: RunResult) -> RunResult: raise else: turn_result = await model_task - - input_guardrail_results.extend(sequential_results) - input_guardrail_results.extend(parallel_results) else: turn_result = await run_single_turn( bindings=current_bindings, diff --git a/src/agents/run_internal/guardrails.py b/src/agents/run_internal/guardrails.py index 4a19eabf5f..1e5381acac 100644 --- a/src/agents/run_internal/guardrails.py +++ b/src/agents/run_internal/guardrails.py @@ -121,8 +121,14 @@ async def run_input_guardrails( guardrails: list[InputGuardrail[TContext]], input: str | list[TResponseInputItem], context: RunContextWrapper[TContext], + results_sink: list[InputGuardrailResult] | None = None, ) -> list[InputGuardrailResult]: - """Run input guardrails concurrently and raise on tripwires.""" + """Run input guardrails concurrently and raise on tripwires. + + Results are recorded into ``results_sink`` as each guardrail completes, including the + tripping result, so callers can report them even when this function raises. The streamed + path publishes the same results through `RunResultStreaming.input_guardrail_results`. + """ if not guardrails: return [] @@ -133,10 +139,16 @@ async def run_input_guardrails( guardrail_results: list[InputGuardrailResult] = [] + def record(result: InputGuardrailResult) -> None: + guardrail_results.append(result) + if results_sink is not None: + results_sink.append(result) + try: for done in asyncio.as_completed(guardrail_tasks): result = await done if result.output.tripwire_triggered: + record(result) for t in guardrail_tasks: t.cancel() await asyncio.gather(*guardrail_tasks, return_exceptions=True) @@ -147,7 +159,7 @@ async def run_input_guardrails( ) ) raise InputGuardrailTripwireTriggered(result) - guardrail_results.append(result) + record(result) except BaseException: # On any error (including a guardrail raising or the caller being cancelled), # cancel and await siblings so they don't leak past this function's return. diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index 49ed36faa5..af8d94cd48 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -1997,3 +1997,140 @@ async def raise_after_sibling_starts(ctx, agent, agent_output): assert sibling_cancelled.is_set(), "Sibling task should have been cancelled" assert not sibling_completed.is_set(), "Sibling task should not have completed" + + +def _ordered_input_guardrails( + *, second_triggers: bool, second_raises: bool = False, run_in_parallel: bool = False +) -> list[InputGuardrail[Any]]: + """Build two guardrails whose completion order is fixed by an explicit barrier.""" + first_done = asyncio.Event() + + async def first_fn( + context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + first_done.set() + return GuardrailFunctionOutput(output_info="passes", tripwire_triggered=False) + + async def second_fn( + context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + await first_done.wait() + if second_raises: + raise RuntimeError("guardrail exploded") + return GuardrailFunctionOutput(output_info="second", tripwire_triggered=second_triggers) + + return [ + InputGuardrail(guardrail_function=first_fn, name="passes", run_in_parallel=run_in_parallel), + InputGuardrail( + guardrail_function=second_fn, + name="raises" if second_raises else "trips", + run_in_parallel=run_in_parallel, + ), + ] + + +def _tripwire_agent(model: FakeModel, *, run_in_parallel: bool) -> Agent[Any]: + return Agent( + name="guardrail_results_agent", + model=model, + input_guardrails=_ordered_input_guardrails( + second_triggers=True, run_in_parallel=run_in_parallel + ), + ) + + +def _result_names(results: list[Any]) -> list[str]: + return [result.guardrail.get_name() for result in results] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("run_in_parallel", [False, True]) +async def test_input_guardrail_tripwire_reports_results(run_in_parallel: bool): + """Runner.run() reports every completed guardrail result on the raised tripwire.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + + with pytest.raises(InputGuardrailTripwireTriggered) as exc_info: + await Runner.run(_tripwire_agent(model, run_in_parallel=run_in_parallel), "test input") + + run_data = exc_info.value.run_data + assert run_data is not None + assert _result_names(run_data.input_guardrail_results) == ["passes", "trips"] + assert exc_info.value.guardrail_result.guardrail.get_name() == "trips" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("run_in_parallel", [False, True]) +async def test_input_guardrail_tripwire_reports_results_streamed(run_in_parallel: bool): + """The streamed path reports the same results, including on the streamed result object.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + + result = Runner.run_streamed( + _tripwire_agent(model, run_in_parallel=run_in_parallel), "test input" + ) + with pytest.raises(InputGuardrailTripwireTriggered) as exc_info: + async for _ in result.stream_events(): + pass + + run_data = exc_info.value.run_data + assert run_data is not None + assert _result_names(run_data.input_guardrail_results) == ["passes", "trips"] + assert _result_names(result.input_guardrail_results) == ["passes", "trips"] + + +def test_input_guardrail_tripwire_reports_results_sync(): + """Runner.run_sync() matches the async entry points.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + + with pytest.raises(InputGuardrailTripwireTriggered) as exc_info: + Runner.run_sync(_tripwire_agent(model, run_in_parallel=False), "test input") + + run_data = exc_info.value.run_data + assert run_data is not None + assert _result_names(run_data.input_guardrail_results) == ["passes", "trips"] + + +@pytest.mark.asyncio +async def test_input_guardrail_results_reported_on_success(): + """Passing guardrails still land on the successful result exactly once.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + agent = Agent( + name="guardrail_results_agent", + model=model, + input_guardrails=[ + InputGuardrail( + guardrail_function=get_sync_guardrail(triggers=False), + name="blocking", + run_in_parallel=False, + ), + InputGuardrail( + guardrail_function=get_sync_guardrail(triggers=False), + name="parallel", + run_in_parallel=True, + ), + ], + ) + + result = await Runner.run(agent, "test input") + + assert _result_names(result.input_guardrail_results) == ["blocking", "parallel"] + + +@pytest.mark.asyncio +async def test_input_guardrail_exception_reports_completed_results(): + """A guardrail raising a non-tripwire error still preserves earlier results.""" + + collected: list[Any] = [] + with pytest.raises(RuntimeError, match="guardrail exploded"): + await run_input_guardrails( + Agent(name="t"), + _ordered_input_guardrails(second_triggers=False, second_raises=True), + "test input", + RunContextWrapper(context=None), + collected, + ) + + assert _result_names(collected) == ["passes"] From c987929aef807391017fe4510ef97180c3daa6fa Mon Sep 17 00:00:00 2001 From: dfedoryshchev <64079946+dfedoryshchev@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:53:45 +0100 Subject: [PATCH 079/473] docs: correct the documented Chat Completions store default (#4074) --- src/agents/model_settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py index 0d6c24b837..93fa4112d5 100644 --- a/src/agents/model_settings.py +++ b/src/agents/model_settings.py @@ -143,7 +143,8 @@ class ModelSettings: store: bool | None = None """Whether to store the generated model response for later retrieval. For Responses API: automatically enabled when not specified. - For Chat Completions API: disabled when not specified.""" + For Chat Completions API: enabled when not specified for the official OpenAI API, and + omitted for other providers so their own default applies.""" prompt_cache_retention: Literal["in_memory", "24h"] | None = None """The retention policy for the prompt cache. Set to `24h` to enable extended From ff0e7866f1b8e8b88fd6d8f49bce0068ca92027f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 07:08:44 +0900 Subject: [PATCH 080/473] fix: preserve completed LiteLLM streams on cleanup failure (#4077) --- src/agents/extensions/models/litellm_model.py | 18 +++++- .../test_litellm_chatcompletions_stream.py | 59 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 69150e2535..d8430ccc69 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -401,21 +401,33 @@ async def stream_response( final_response: Response | None = None close_stream_in_background = False + yielded_terminal_event = False try: async for chunk in ChatCmplStreamHandler.handle_stream( response, stream, model=self.model ): - yield chunk - if chunk.type == "response.completed": final_response = chunk.response + yielded_terminal_event = True + + yield chunk except asyncio.CancelledError: close_stream_in_background = True self._schedule_async_iterator_close(stream) raise finally: if not close_stream_in_background: - await self._maybe_aclose(stream) + try: + await self._maybe_aclose(stream) + except Exception as exc: + if yielded_terminal_event: + log_model_action_debug( + logger, + "Ignoring stream cleanup error after terminal event", + exc, + ) + else: + raise if tracing.include_data() and final_response: span_generation.span_data.output = [final_response.model_dump()] diff --git a/tests/models/test_litellm_chatcompletions_stream.py b/tests/models/test_litellm_chatcompletions_stream.py index bdb890a399..0fdd711aa9 100644 --- a/tests/models/test_litellm_chatcompletions_stream.py +++ b/tests/models/test_litellm_chatcompletions_stream.py @@ -764,6 +764,14 @@ async def aclose(self) -> None: self.aclose_completed += 1 +class _FailingCloseChatStream(_ClosableChatStream): + """Raises from `aclose` after recording the cleanup attempt.""" + + async def aclose(self) -> None: + self.aclose_calls += 1 + raise RuntimeError("close-failure") + + def _text_chunk(text: str) -> ChatCompletionChunk: return ChatCompletionChunk( id="chunk-id", @@ -836,6 +844,57 @@ async def test_stream_response_closes_provider_stream_on_normal_exhaustion(monke assert provider_stream.aclose_calls == 1 +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_ignores_close_failure_after_terminal_event(monkeypatch) -> None: + """A completed response must remain successful when provider cleanup fails.""" + provider_stream = _FailingCloseChatStream([_text_chunk("Hello")]) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + + output_events = [event async for event in _stream_response(model)] + + assert output_events[-1].type == "response.completed" + assert provider_stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_ignores_close_failure_when_closed_at_terminal_event( + monkeypatch, +) -> None: + """Terminal state must be recorded before yielding the completed event.""" + provider_stream = _FailingCloseChatStream([_text_chunk("Hello")]) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + stream_agen = cast(Any, _stream_response(model)) + + async for event in stream_agen: + if event.type == "response.completed": + break + await stream_agen.aclose() + + assert provider_stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_propagates_close_failure_before_terminal_event(monkeypatch) -> None: + """Cleanup failures before completion remain observable by the caller.""" + provider_stream = _FailingCloseChatStream([_text_chunk("Hello")]) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + stream_agen = cast(Any, _stream_response(model)) + + first_event = await anext(stream_agen) + assert first_event.type == "response.created" + + with pytest.raises(RuntimeError, match="close-failure"): + await stream_agen.aclose() + + assert provider_stream.aclose_calls == 1 + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_stream_response_closes_provider_stream_after_cancellation(monkeypatch) -> None: From 18f658aaf4016147ffecd36f0c19044f3c0d9efb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 07:58:10 +0900 Subject: [PATCH 081/473] docs: stabilize healthcare_support example --- .../skills/prior-auth-packet-builder/SKILL.md | 24 ++- .../healthcare_support/support_agents.py | 33 ++-- examples/sandbox/healthcare_support/tools.py | 2 + .../sandbox/healthcare_support/workflow.py | 171 ++++++++++++++---- 4 files changed, 175 insertions(+), 55 deletions(-) diff --git a/examples/sandbox/healthcare_support/skills/prior-auth-packet-builder/SKILL.md b/examples/sandbox/healthcare_support/skills/prior-auth-packet-builder/SKILL.md index fd9fd17aad..47ae0035de 100644 --- a/examples/sandbox/healthcare_support/skills/prior-auth-packet-builder/SKILL.md +++ b/examples/sandbox/healthcare_support/skills/prior-auth-packet-builder/SKILL.md @@ -10,22 +10,30 @@ Use this skill when a case requires prior authorization review, referral validat ## Workflow 1. Inspect `case/scenario.json` and `case/transcript.txt`. -2. Use `rg` against `policies/` to find payer, prior auth, referral, imaging, and PPO guidance. +2. Search `policies/` for payer, prior auth, referral, imaging, and PPO guidance: + - Run the preferred search and fallback as one shell command: + `rg -n -i 'prior authorization|prior-auth|imaging|referral|billing|PPO|Blue Cross' policies || + grep -RniE 'prior authorization|prior-auth|imaging|referral|billing|PPO|Blue Cross' policies`. + - An `rg` launcher or bootstrap failure is not evidence that there are no policy matches. 3. Read only the most relevant policy files. -4. Create `output/policy_findings.md` with: - - case summary - - matched policy files - - prior auth determination - - referral determination - - missing information +4. Create `output/policy_findings.md` with these exact headings: + - `## Case summary` + - `## Matched policy files` + - `## Prior authorization` + - `## Referral` + - `## Missing information` + Cite each matched policy by its filename. 5. Create `output/human_review_checklist.md` with: - what a human reviewer should verify - what to tell the patient - what queue should own the case +6. Call `finalize_policy_packet` only after both artifacts exist. ## Rules -- Use targeted `rg` searches over broad file reads. +- Use targeted searches over broad file reads. +- Use the targeted `grep -RniE` fallback only when `rg` cannot complete the search. - Only cite policy files you actually inspected. - Keep outputs concise and operational. +- The workflow is complete only when `finalize_policy_packet` succeeds. - If referral status is pending and prior auth is unclear, recommend human review. diff --git a/examples/sandbox/healthcare_support/support_agents.py b/examples/sandbox/healthcare_support/support_agents.py index b458094ec3..0a02c3cebe 100644 --- a/examples/sandbox/healthcare_support/support_agents.py +++ b/examples/sandbox/healthcare_support/support_agents.py @@ -12,7 +12,6 @@ BenefitReview, CaseResolution, MemoryRecap, - SandboxPolicyPacket, ) from examples.sandbox.healthcare_support.tools import ( HealthcareSupportContext, @@ -49,13 +48,18 @@ You must: 1. Load and use the `prior-auth-packet-builder` skill. 2. Inspect the workspace with shell commands before writing anything. -3. Use `rg` against `policies/` for prior-auth, imaging, referral, billing, PPO, and Blue Cross - policy guidance. -4. Create `output/policy_findings.md` with the most relevant policy guidance. +3. Search `policies/` for prior-auth, imaging, referral, billing, PPO, and Blue Cross policy + guidance. Run the preferred `rg` search and portable fallback as one shell command: + `rg -n -i 'prior authorization|prior-auth|imaging|referral|billing|PPO|Blue Cross' policies || + grep -RniE 'prior authorization|prior-auth|imaging|referral|billing|PPO|Blue Cross' policies`. + Do not treat an `rg` launcher or bootstrap failure as an empty result. +4. Create `output/policy_findings.md` with the exact headings `## Case summary`, + `## Matched policy files`, `## Prior authorization`, `## Referral`, and + `## Missing information`. Cite each matched policy by its filename. 5. Create `output/human_review_checklist.md` with a short checklist for a human reviewer. -6. Set `human_review_recommended=true` only when the policy search or case input shows missing +6. Call `finalize_policy_packet` only after both files exist. This is the only way to finish. +7. Set `human_review_recommended=true` only when the policy search or case input shows missing authorization/referral details that should be reviewed by a human before responding. -7. Include the exact shell commands you ran in `shell_commands`. 8. Return only facts grounded in the files you inspected. """.strip() @@ -102,16 +106,22 @@ ) -def build_policy_sandbox_agent(*, skills_root: Path) -> SandboxAgent[HealthcareSupportContext]: +def build_policy_sandbox_agent( + *, + skills_root: Path, + finalize_policy_packet_tool: Tool, +) -> SandboxAgent[HealthcareSupportContext]: return SandboxAgent[HealthcareSupportContext]( name="HealthcarePolicySandboxAgent", model="gpt-5.6-sol", instructions=( POLICY_SANDBOX_PROMPT + "\n\n" - "Use `load_skill` before reading the skill file. Use `exec_command` with `pwd`, " - "`ls`, `cat`, and `rg` to inspect the sandbox workspace. Use `apply_patch` to create " - "`output/policy_findings.md` and `output/human_review_checklist.md`." + "First call `load_skill` for `prior-auth-packet-builder`, then read its `SKILL.md`. " + "Use `exec_command` with `pwd`, `ls`, `cat`, and the documented `rg || grep` search " + "command. Use one `apply_patch` call to create both `output/policy_findings.md` and " + "`output/human_review_checklist.md`. Then call `finalize_policy_packet`." ), + tools=[finalize_policy_packet_tool], capabilities=[ Shell(), Filesystem(), @@ -128,7 +138,8 @@ def build_policy_sandbox_agent(*, skills_root: Path) -> SandboxAgent[HealthcareS verbosity="low", tool_choice="required", ), - output_type=AgentOutputSchema(SandboxPolicyPacket, strict_json_schema=False), + reset_tool_choice=False, + tool_use_behavior={"stop_at_tool_names": ["finalize_policy_packet"]}, ) diff --git a/examples/sandbox/healthcare_support/tools.py b/examples/sandbox/healthcare_support/tools.py index ad3657ba29..dcf02f2edb 100644 --- a/examples/sandbox/healthcare_support/tools.py +++ b/examples/sandbox/healthcare_support/tools.py @@ -19,6 +19,8 @@ class HealthcareSupportContext: session_id: str = "" human_handoffs: list[dict[str, Any]] = field(default_factory=list) human_handoff_approved: bool = False + policy_skill_loaded: bool = False + policy_search_commands: list[str] = field(default_factory=list) emit_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None async def emit(self, event_name: str, **payload: Any) -> None: diff --git a/examples/sandbox/healthcare_support/workflow.py b/examples/sandbox/healthcare_support/workflow.py index 495d480766..328dda660b 100644 --- a/examples/sandbox/healthcare_support/workflow.py +++ b/examples/sandbox/healthcare_support/workflow.py @@ -18,6 +18,7 @@ gen_trace_id, trace, ) +from agents.decorators import tool from agents.run import RunConfig from agents.sandbox import Manifest, SandboxPathGrant, SandboxRunConfig from agents.sandbox.entries import Dir, File, LocalDir @@ -27,6 +28,7 @@ from examples.sandbox.healthcare_support.models import ( CaseResolution, MemoryRecap, + SandboxPolicyPacket, ScenarioCase, ) from examples.sandbox.healthcare_support.support_agents import ( @@ -46,6 +48,18 @@ ApprovalHandler = Callable[[dict[str, Any]], Awaitable[bool]] +REQUIRED_POLICY_ARTIFACTS = { + "human_review_checklist.md", + "policy_findings.md", +} +REQUIRED_POLICY_FINDINGS_HEADINGS = { + "## Case summary", + "## Matched policy files", + "## Missing information", + "## Prior authorization", + "## Referral", +} + class WorkflowHooks(RunHooks[HealthcareSupportContext]): async def on_agent_start( @@ -90,6 +104,27 @@ async def on_tool_end( result: object, ) -> None: tool_context = cast(ToolContext[HealthcareSupportContext], context) + if agent.name == "HealthcarePolicySandboxAgent": + if ( + tool.name == "load_skill" + and isinstance(result, dict) + and result.get("status") == "loaded" + ): + context.context.policy_skill_loaded = True + elif tool.name == "exec_command": + try: + arguments = json.loads(tool_context.tool_arguments or "{}") + except json.JSONDecodeError: + arguments = {} + command = arguments.get("cmd") + rendered_result = str(result) + if ( + isinstance(command, str) + and "rg " in command + and "grep -RniE" in command + and "Process exited with code 0" in rendered_result + ): + context.context.policy_search_commands.append(command) await context.context.emit( "tool_end", agent=agent.name, @@ -154,41 +189,98 @@ def _build_manifest(scenario: ScenarioCase) -> Manifest: async def _structured_tool_output_extractor(result: Any) -> str: final_output = result.final_output + if isinstance(final_output, str): + try: + final_output = SandboxPolicyPacket.model_validate_json(final_output) + except ValueError as exc: + raise RuntimeError("Sandbox policy agent did not finalize a policy packet.") from exc + if isinstance(final_output, SandboxPolicyPacket): + generated_names = {Path(path).name for path in final_output.generated_files} + missing_artifacts = REQUIRED_POLICY_ARTIFACTS - generated_names + if missing_artifacts: + missing = ", ".join(sorted(missing_artifacts)) + raise RuntimeError(f"Sandbox policy packet did not generate required files: {missing}") + if not final_output.matched_policy_files: + raise RuntimeError("Sandbox policy packet did not inspect any policy files.") + if not any( + "rg " in command or "grep " in command for command in final_output.shell_commands + ): + raise RuntimeError("Sandbox policy packet did not record a policy search command.") if isinstance(final_output, BaseModel): return json.dumps(final_output.model_dump(mode="json"), sort_keys=True) return str(final_output) -def _fallback_artifacts(*, scenario: ScenarioCase, resolution: CaseResolution) -> dict[str, str]: - policy_doc = f"""# Policy Findings - -## Case -{scenario.description} +async def _read_sandbox_text(sandbox: Any, path: Path) -> str: + handle = await sandbox.read(path) + try: + payload = handle.read() + finally: + handle.close() + if isinstance(payload, str): + return payload + return bytes(payload).decode("utf-8", errors="replace") -## Policy summary -{resolution.policy_summary} -## Next step -{resolution.next_step} -""" - checklist_doc = f"""# Human Review Checklist +def _build_finalize_policy_packet_tool( + *, + sandbox: Any, +) -> Tool: + async def packet_ready( + context: RunContextWrapper[HealthcareSupportContext], + _agent: Any, + ) -> bool: + if not context.context.policy_skill_loaded or not context.context.policy_search_commands: + return False + output_names = {Path(entry.path).name for entry in await sandbox.ls("output")} + return REQUIRED_POLICY_ARTIFACTS <= output_names + + @tool(is_enabled=packet_ready) + async def finalize_policy_packet( + context: RunContextWrapper[HealthcareSupportContext], + matched_policy_files: list[str], + policy_summary: str, + human_review_recommended: bool, + ) -> str: + """Validate completed policy artifacts and return their grounded packet summary.""" + policy_findings = await _read_sandbox_text(sandbox, Path("output/policy_findings.md")) + checklist = await _read_sandbox_text(sandbox, Path("output/human_review_checklist.md")) + + missing_headings = REQUIRED_POLICY_FINDINGS_HEADINGS - { + line.strip() for line in policy_findings.splitlines() + } + if missing_headings: + missing = ", ".join(sorted(missing_headings)) + raise RuntimeError(f"Policy findings artifact is missing required sections: {missing}") + if not checklist.strip(): + raise RuntimeError("Human review checklist artifact is empty.") + + known_policy_names = {path.name for path in POLICIES_ROOT.glob("*.md")} + matched_names = {Path(path).name for path in matched_policy_files} + if not matched_names or not matched_names <= known_policy_names: + raise RuntimeError("Policy packet includes unknown or missing policy files.") + if not all(name in policy_findings for name in matched_names): + raise RuntimeError("Policy findings artifact does not cite every matched policy file.") + + packet = SandboxPolicyPacket( + matched_policy_files=sorted(matched_names), + generated_files=[ + "output/human_review_checklist.md", + "output/policy_findings.md", + ], + shell_commands=list(context.context.policy_search_commands), + policy_summary=policy_summary, + human_review_recommended=human_review_recommended, + ) + return packet.model_dump_json() -- Confirm whether the request needs prior authorization for this service and payer. -- Verify referral state and any missing clinical or billing identifiers. -- Use this internal summary: {resolution.internal_summary} -- Patient-facing response: {resolution.patient_facing_response} -""" - return { - "policy_findings.md": policy_doc, - "human_review_checklist.md": checklist_doc, - } + return finalize_policy_packet async def _copy_output_files( *, sandbox: Any, scenario: ScenarioCase, - resolution: CaseResolution, ) -> list[dict[str, str]]: scenario_id = scenario.scenario_id destination_root = CACHE_ROOT / "output" / scenario_id @@ -220,19 +312,22 @@ async def _copy_output_files( "content": content, } - for filename, content in _fallback_artifacts( - scenario=scenario, - resolution=resolution, - ).items(): - if filename in copied_by_name: - continue - local_path = destination_root / filename - local_path.write_text(content, encoding="utf-8") - copied_by_name[filename] = { - "name": filename, - "path": str(local_path), - "content": content, - } + missing_artifacts = REQUIRED_POLICY_ARTIFACTS - set(copied_by_name) + if missing_artifacts: + missing = ", ".join(sorted(missing_artifacts)) + raise RuntimeError(f"Sandbox policy agent did not create required artifacts: {missing}") + + policy_findings = copied_by_name["policy_findings.md"]["content"] + missing_headings = REQUIRED_POLICY_FINDINGS_HEADINGS - { + line.strip() for line in policy_findings.splitlines() + } + if missing_headings: + missing = ", ".join(sorted(missing_headings)) + raise RuntimeError(f"Policy findings artifact is missing required sections: {missing}") + + policy_names = {path.name for path in POLICIES_ROOT.glob("*.md")} + if not any(name in policy_findings for name in policy_names): + raise RuntimeError("Policy findings artifact did not cite an inspected policy file.") return [copied_by_name[name] for name in sorted(copied_by_name)] @@ -316,6 +411,8 @@ async def run_healthcare_support_workflow( context.scenario = scenario context.human_handoffs.clear() context.human_handoff_approved = False + context.policy_skill_loaded = False + context.policy_search_commands.clear() await context.emit( "scenario_loaded", @@ -339,7 +436,10 @@ async def run_healthcare_support_workflow( workspace=["case/scenario.json", "case/transcript.txt", "policies/", "output/"], ) - policy_agent = build_policy_sandbox_agent(skills_root=SKILLS_ROOT) + policy_agent = build_policy_sandbox_agent( + skills_root=SKILLS_ROOT, + finalize_policy_packet_tool=_build_finalize_policy_packet_tool(sandbox=sandbox), + ) sandbox_policy_tool = policy_agent.as_tool( tool_name="sandbox_policy_packet", tool_description="Inspect policy files in a sandbox and generate support artifacts.", @@ -383,7 +483,6 @@ async def run_healthcare_support_workflow( copied_files = await _copy_output_files( sandbox=sandbox, scenario=scenario, - resolution=resolution, ) await context.emit("artifacts_ready", files=copied_files) From 49821c172d2c62b2e531c4c36e8d53afd6c004f8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 09:03:52 +0900 Subject: [PATCH 082/473] fix: redact direct MCP cleanup transport errors (#4078) --- src/agents/mcp/server.py | 315 +++++++++++++++--------- tests/mcp/test_server_errors.py | 424 ++++++++++++++++++++++++++++++-- 2 files changed, 599 insertions(+), 140 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 35cf10ace8..168a476e12 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -105,11 +105,10 @@ class RequireApprovalObject(TypedDict, total=False): _SAFE_EXCEPTION_MESSAGE = "An additional error occurred during the MCP request." -def _safe_transport_cause(http_error: Exception) -> Exception | None: - """Keep a transport exception only when its HTTPX URLs need no sanitization.""" - if not isinstance(http_error, httpx.HTTPStatusError | httpx.RequestError): - return http_error - +def _transport_error_urls_are_safe( + http_error: httpx.HTTPStatusError | httpx.RequestError, +) -> bool: + """Return whether one HTTPX exception contains only credential-safe URLs.""" request_urls: list[str] = [] try: request_urls.append(str(http_error.request.url)) @@ -121,7 +120,7 @@ def _safe_transport_cause(http_error: Exception) -> Exception | None: try: response_url = response.request.url except RuntimeError: - return None + return False request_urls.append(str(response_url)) redirect_location = response.headers.get("location") @@ -129,13 +128,43 @@ def _safe_transport_cause(http_error: Exception) -> Exception | None: try: request_urls.append(str(response_url.join(redirect_location))) except (httpx.InvalidURL, ValueError): - return None + return False - return http_error if all(get_mcp_server_log_name(url) == url for url in request_urls) else None + return all(get_mcp_server_log_name(url) == url for url in request_urls) + + +def _safe_transport_cause(http_error: Exception) -> Exception | None: + """Keep an unchained transport exception only when its HTTPX URLs are credential-safe.""" + if not isinstance(http_error, httpx.HTTPStatusError | httpx.RequestError): + return http_error + + if not _transport_error_urls_are_safe(http_error): + return None + if BaseException.__getattribute__(http_error, "__cause__") is not None: + return None + if BaseException.__getattribute__(http_error, "__context__") is not None: + return None + if BaseException.__getattribute__(http_error, "__dict__").get("__notes__"): + return None + + return http_error def _first_unsafe_transport_error(http_errors: list[Exception]) -> Exception | None: """Return the first transport error whose HTTPX URLs require sanitization.""" + return next( + ( + error + for error in http_errors + if isinstance(error, httpx.HTTPStatusError | httpx.RequestError) + and not _transport_error_urls_are_safe(error) + ), + None, + ) + + +def _first_unretainable_transport_error(http_errors: list[Exception]) -> Exception | None: + """Return the first transport error that cannot be retained as an exception cause.""" return next((error for error in http_errors if _safe_transport_cause(error) is None), None) @@ -184,6 +213,22 @@ def _log_transport_warning(message: str, http_error: Exception) -> None: log_tool_action_warning(logger, message, safe_error) +def _get_cleanup_transport_error_message(http_error: Exception) -> str: + """Return the cleanup warning message for an HTTPX transport failure.""" + if isinstance(http_error, httpx.HTTPStatusError): + return "HTTP error during cleanup of MCP server" + if isinstance(http_error, httpx.ConnectError): + return "Connection error during cleanup of MCP server" + if isinstance(http_error, httpx.TimeoutException): + return "Timeout error during cleanup of MCP server" + return "Request error during cleanup of MCP server" + + +def _log_cleanup_transport_warning(message: str) -> None: + """Log a fixed cleanup warning without retaining the transport exception.""" + logger.warning("%s", message, stacklevel=3) + + def _create_default_streamable_http_client( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, @@ -836,11 +881,45 @@ def _extract_http_errors_from_exception(self, e: BaseException) -> list[Exceptio return [] - def _user_error_for_http_error(self, http_error: Exception) -> UserError: + def _select_cleanup_transport_error(self, error: BaseException) -> Exception | None: + """Select a cleanup transport error for specialized handling.""" + unsafe_http_error = _first_unsafe_transport_error( + self._extract_http_errors_from_exception(error) + ) + if unsafe_http_error is not None: + return unsafe_http_error + + candidates = error.exceptions if isinstance(error, BaseExceptionGroup) else (error,) + for error_type in ( + httpx.HTTPStatusError, + httpx.ConnectError, + httpx.TimeoutException, + ): + selected_http_error = next( + ( + candidate + for candidate in reversed(candidates) + if isinstance(candidate, Exception) and isinstance(candidate, error_type) + ), + None, + ) + if selected_http_error is not None: + return selected_http_error + + return None + + def _user_error_for_http_error( + self, + http_error: Exception, + *, + include_http_reason_phrase: bool = True, + ) -> UserError: """Build a UserError from safe HTTP diagnostics.""" error_message = f"Failed to connect to MCP server '{self._error_name}': " if isinstance(http_error, httpx.HTTPStatusError): - error_message += f"HTTP error {http_error.response.status_code} ({http_error.response.reason_phrase})" # noqa: E501 + error_message += f"HTTP error {http_error.response.status_code}" + if include_http_reason_phrase: + error_message += f" ({http_error.response.reason_phrase})" elif isinstance(http_error, httpx.ConnectError): error_message += "Could not reach the server." @@ -935,6 +1014,8 @@ async def connect(self): connection_succeeded = False connection_error: UserError | None = None connection_cause: Exception | None = None + connection_exception: BaseException | None = None + cleanup_failure: BaseException | None = None try: transport = await self.exit_stack.enter_async_context(self.create_streams()) # streamablehttp_client returns (read, write, get_session_id) @@ -958,56 +1039,69 @@ async def connect(self): self.server_initialize_result = server_result self.session = session connection_succeeded = True - except Exception as e: - http_errors = self._extract_http_errors_from_exception(e) - if not http_errors: - raise + except BaseException as e: + if not isinstance(e, Exception): + connection_exception = e + else: + http_errors = self._extract_http_errors_from_exception(e) + if not http_errors: + connection_exception = e + else: + unsafe_http_error = _first_unretainable_transport_error(http_errors) + http_error = unsafe_http_error or http_errors[0] + connection_cause = _safe_transport_cause(http_error) + maps_safe_error = isinstance( + http_error, + httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException, + ) + if connection_cause is not None and not maps_safe_error: + connection_exception = e + connection_cause = None + else: + connection_error = self._user_error_for_http_error(http_error) + http_errors.clear() + del http_error + del unsafe_http_error + + # Run cleanup after leaving the connection exception handler so a cleanup UserError does + # not retain the pending connection failure as its implicit context. + if not connection_succeeded: + try: + await self.cleanup() + except UserError as e: + cleanup_failure = e + except Exception as cleanup_error: + # Suppress RuntimeError about cancel scopes during cleanup - this is a known + # issue with the MCP library's async generator cleanup and shouldn't mask the + # original error. + if isinstance(cleanup_error, RuntimeError) and "cancel scope" in str(cleanup_error): + logger.debug( + "%s", + get_mcp_server_log_message( + "Ignoring cancel scope error during cleanup of MCP server", self + ), + stacklevel=2, + ) + else: + # Log other cleanup errors but don't raise - original error is more important. + logger.warning( + "%s", + get_mcp_server_log_message("Error during cleanup of MCP server", self), + stacklevel=2, + ) + except BaseException as e: + cleanup_failure = e - unsafe_http_error = _first_unsafe_transport_error(http_errors) - http_error = unsafe_http_error or http_errors[0] - connection_cause = None if unsafe_http_error is not None else http_error - maps_safe_error = isinstance( - http_error, - httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException, - ) - if connection_cause is not None and not maps_safe_error: - raise + if cleanup_failure is not None: + connection_exception = None + connection_error = None + connection_cause = None + if isinstance(cleanup_failure, UserError): + self._raise_mapped_transport_error(cleanup_failure, None) + raise cleanup_failure - connection_error = self._user_error_for_http_error(http_error) - if connection_cause is None: - http_errors.clear() - del http_error - del unsafe_http_error - finally: - # Always attempt cleanup on error, but suppress cleanup errors that mask the original - if not connection_succeeded: - try: - await self.cleanup() - except UserError: - # Re-raise UserError from cleanup (contains the real HTTP error) - raise - except Exception as cleanup_error: - # Suppress RuntimeError about cancel scopes during cleanup - this is a known - # issue with the MCP library's async generator cleanup and shouldn't mask the - # original error - if isinstance(cleanup_error, RuntimeError) and "cancel scope" in str( - cleanup_error - ): - log_tool_action_debug( - logger, - get_mcp_server_log_message( - "Ignoring cancel scope error during cleanup of MCP server", self - ), - cleanup_error, - ) - else: - # Log other cleanup errors but don't raise - original error is more - # important - log_tool_action_warning( - logger, - get_mcp_server_log_message("Error during cleanup of MCP server", self), - cleanup_error, - ) + if connection_exception is not None: + raise connection_exception if connection_error is not None: self._raise_mapped_transport_error(connection_error, connection_cause) @@ -1239,7 +1333,6 @@ async def cleanup(self): # masking the original exception. is_failed_connection_cleanup = self.session is None cleanup_error: UserError | None = None - cleanup_cause: Exception | None = None try: await self.exit_stack.aclose() @@ -1250,70 +1343,60 @@ async def cleanup(self): e, ) raise - except BaseExceptionGroup as eg: - http_errors = self._extract_http_errors_from_exception(eg) - unsafe_http_error = _first_unsafe_transport_error(http_errors) - selected_http_error = unsafe_http_error - - if selected_http_error is None: - # Preserve legacy group diagnostics when HTTP errors are nested but safe. - for error_type in ( - httpx.HTTPStatusError, - httpx.ConnectError, - httpx.TimeoutException, - ): - selected_http_error = next( - ( - error - for error in reversed(eg.exceptions) - if isinstance(error, Exception) and isinstance(error, error_type) - ), - None, - ) - if selected_http_error is not None: - break - + except (BaseExceptionGroup, httpx.HTTPStatusError, httpx.RequestError) as e: + selected_http_error = self._select_cleanup_transport_error(e) if selected_http_error is not None: if is_failed_connection_cleanup: - cleanup_error = self._user_error_for_http_error(selected_http_error) - cleanup_cause = _safe_transport_cause(selected_http_error) - if cleanup_cause is None: - http_errors.clear() - del selected_http_error - del unsafe_http_error - else: - if isinstance(selected_http_error, httpx.HTTPStatusError): - cleanup_message = "HTTP error during cleanup of MCP server" - elif isinstance(selected_http_error, httpx.ConnectError): - cleanup_message = "Connection error during cleanup of MCP server" - elif isinstance(selected_http_error, httpx.TimeoutException): - cleanup_message = "Timeout error during cleanup of MCP server" - else: - cleanup_message = "Request error during cleanup of MCP server" - _log_transport_warning( - get_mcp_server_log_message(cleanup_message, self), + cleanup_error = self._user_error_for_http_error( selected_http_error, + include_http_reason_phrase=False, ) - else: - # No HTTP error found, suppress RuntimeError about cancel scopes - has_cancel_scope_error = any( - isinstance(exc, RuntimeError) and "cancel scope" in str(exc) - for exc in eg.exceptions - ) - if has_cancel_scope_error: - log_tool_action_debug( - logger, + del selected_http_error + else: + _log_cleanup_transport_warning( get_mcp_server_log_message( - "Ignoring cancel scope error during cleanup of MCP server", self - ), - eg, + _get_cleanup_transport_error_message(selected_http_error), self + ) ) - else: + elif isinstance(e, httpx.RequestError): + _log_cleanup_transport_warning( + get_mcp_server_log_message(_get_cleanup_transport_error_message(e), self) + ) + elif isinstance(e, BaseExceptionGroup): + http_errors = self._extract_http_errors_from_exception(e) + if http_errors: + safe_error_group = _credential_safe_exception_group(e) log_tool_action_error( logger, get_mcp_server_log_message("Error cleaning up MCP server", self), - eg, + safe_error_group, + ) + else: + # No HTTP error found, suppress RuntimeError about cancel scopes. + has_cancel_scope_error = any( + isinstance(exc, RuntimeError) and "cancel scope" in str(exc) + for exc in e.exceptions ) + if has_cancel_scope_error: + log_tool_action_debug( + logger, + get_mcp_server_log_message( + "Ignoring cancel scope error during cleanup of MCP server", self + ), + e, + ) + else: + log_tool_action_error( + logger, + get_mcp_server_log_message("Error cleaning up MCP server", self), + e, + ) + else: + log_tool_action_error( + logger, + get_mcp_server_log_message("Error cleaning up MCP server", self), + e, + ) except Exception as e: # Suppress RuntimeError about cancel scopes - this is a known issue with the MCP # library when background tasks fail during async generator cleanup @@ -1336,7 +1419,7 @@ async def cleanup(self): self._get_session_id = None if cleanup_error is not None: - self._raise_mapped_transport_error(cleanup_error, cleanup_cause) + self._raise_mapped_transport_error(cleanup_error, None) class MCPServerStdioParams(TypedDict): @@ -1931,9 +2014,9 @@ async def call_tool( if not http_errors: raise - unsafe_http_error = _first_unsafe_transport_error(http_errors) + unsafe_http_error = _first_unretainable_transport_error(http_errors) http_error = unsafe_http_error or http_errors[0] - transport_cause = None if unsafe_http_error is not None else http_error + transport_cause = _safe_transport_cause(http_error) if isinstance(http_error, httpx.HTTPStatusError): status_code = http_error.response.status_code transport_error = UserError( diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index b5ffc88406..fc4d3f2a56 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -126,7 +126,17 @@ def _assert_not_retained_in_log_record( continue seen.add(id(value)) - if isinstance(value, dict): + if isinstance(value, BaseException): + pending.extend(value.args) + if value.__cause__ is not None: + pending.append(value.__cause__) + if value.__context__ is not None: + pending.append(value.__context__) + pending.extend(getattr(value, "__notes__", ())) + pending.append(value.__dict__) + if isinstance(value, BaseExceptionGroup): + pending.extend(value.exceptions) + elif isinstance(value, dict): pending.extend(value.keys()) pending.extend(value.values()) elif isinstance(value, list | tuple | set | frozenset): @@ -487,6 +497,32 @@ def _mixed_request_error_group( return BaseExceptionGroup("mixed failures", [safe_error, nested_group]), safe_error, later_error +def _transport_error_with_sensitive_attachment( + attachment: str, +) -> tuple[httpx.ReadTimeout, object]: + safe_outer_error = httpx.ReadTimeout( + "outer timeout", + request=httpx.Request("GET", _SAFE_URL), + ) + if attachment == "http_context": + http_context = httpx.ReadError( + "inner read failed", + request=httpx.Request("GET", _CREDENTIALED_URL), + ) + sensitive_value: object = http_context + safe_outer_error.__context__ = http_context + elif attachment == "non_http_context": + non_http_context = ValueError(_CREDENTIALED_URL) + sensitive_value = non_http_context + safe_outer_error.__context__ = non_http_context + elif attachment == "note": + sensitive_value = _CREDENTIALED_URL + safe_outer_error.__dict__["__notes__"] = [sensitive_value] + else: + raise AssertionError(f"Unexpected attachment type: {attachment}") + return safe_outer_error, sensitive_value + + @pytest.mark.asyncio async def test_connect_checks_every_request_error_before_preserving_exception_group(): server = MCPServerSse(params={"url": _SAFE_URL}) @@ -519,6 +555,94 @@ async def test_call_tool_checks_every_request_error_before_preserving_exception_ _assert_not_retained_in_traceback_locals(exc_info.value, unsafe_error) +@pytest.mark.asyncio +async def test_connect_group_hides_sensitive_transport_error_context(): + server = MCPServerSse(params={"url": _SAFE_URL}) + transport_error, sensitive_value = _transport_error_with_sensitive_attachment( + "non_http_context" + ) + error_group = BaseExceptionGroup("connection failed", [transport_error]) + + with patch.object(server, "create_streams", side_effect=error_group): + with pytest.raises(UserError) as exc_info: + await server.connect() + + assert "Connection timeout" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, transport_error) + _assert_not_retained_in_exception_graph(exc_info.value, sensitive_value) + _assert_not_retained_in_traceback_locals(exc_info.value, error_group) + _assert_not_retained_in_traceback_locals(exc_info.value, transport_error) + _assert_not_retained_in_traceback_locals(exc_info.value, sensitive_value) + + +@pytest.mark.asyncio +async def test_call_tool_group_hides_sensitive_transport_error_context(): + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + server.session = MagicMock() + server.max_retry_attempts = 0 + transport_error, sensitive_value = _transport_error_with_sensitive_attachment( + "non_http_context" + ) + error_group = BaseExceptionGroup("tool call failed", [transport_error]) + + with patch.object(server, "_call_tool_with_isolated_retry", side_effect=error_group): + with pytest.raises(UserError) as exc_info: + await server.call_tool("test_tool", {}) + + assert "Connection timeout" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, transport_error) + _assert_not_retained_in_exception_graph(exc_info.value, sensitive_value) + _assert_not_retained_in_traceback_locals(exc_info.value, error_group) + _assert_not_retained_in_traceback_locals(exc_info.value, transport_error) + _assert_not_retained_in_traceback_locals(exc_info.value, sensitive_value) + + +@pytest.mark.asyncio +async def test_connect_group_checks_every_transport_error_attachment(): + server = MCPServerSse(params={"url": _SAFE_URL}) + error_group, _, later_error = _mixed_request_error_group(_SAFE_URL) + sensitive_value = ValueError(_CREDENTIALED_URL) + later_error.__context__ = sensitive_value + + with patch.object(server, "create_streams", side_effect=error_group): + with pytest.raises(UserError) as exc_info: + await server.connect() + + assert "Could not reach the server" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, error_group) + _assert_not_retained_in_exception_graph(exc_info.value, later_error) + _assert_not_retained_in_exception_graph(exc_info.value, sensitive_value) + _assert_not_retained_in_traceback_locals(exc_info.value, error_group) + _assert_not_retained_in_traceback_locals(exc_info.value, later_error) + _assert_not_retained_in_traceback_locals(exc_info.value, sensitive_value) + + +@pytest.mark.asyncio +async def test_call_tool_group_checks_every_transport_error_attachment(): + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + server.session = MagicMock() + server.max_retry_attempts = 0 + error_group, _, later_error = _mixed_request_error_group(_SAFE_URL) + sensitive_value = ValueError(_CREDENTIALED_URL) + later_error.__context__ = sensitive_value + + with patch.object(server, "_call_tool_with_isolated_retry", side_effect=error_group): + with pytest.raises(UserError) as exc_info: + await server.call_tool("test_tool", {}) + + assert "Connection lost" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, error_group) + _assert_not_retained_in_exception_graph(exc_info.value, later_error) + _assert_not_retained_in_exception_graph(exc_info.value, sensitive_value) + _assert_not_retained_in_traceback_locals(exc_info.value, error_group) + _assert_not_retained_in_traceback_locals(exc_info.value, later_error) + _assert_not_retained_in_traceback_locals(exc_info.value, sensitive_value) + + @pytest.mark.asyncio async def test_connect_preserves_exception_group_when_every_request_error_is_safe(): server = MCPServerSse(params={"url": _SAFE_URL}) @@ -810,22 +934,239 @@ async def test_call_tool_request_error_only_maps_credentialed_urls( @pytest.mark.asyncio -async def test_failed_connection_cleanup_hides_url_credentials_from_exception_graph(): +@pytest.mark.parametrize("grouped", [False, True]) +async def test_failed_connection_cleanup_hides_url_credentials_from_exception_graph( + grouped: bool, +): server = MCPServerSse(params={"url": _CREDENTIALED_URL}) request = httpx.Request("GET", _CREDENTIALED_URL) http_error = httpx.HTTPStatusError( "boom", request=request, response=httpx.Response(502, request=request) ) - cleanup_group = BaseExceptionGroup("cleanup failed", [http_error]) + cleanup_error: BaseException = http_error + if grouped: + cleanup_error = BaseExceptionGroup("cleanup failed", [http_error]) - with patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)): + with patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)): with pytest.raises(UserError) as exc_info: await server.cleanup() assert "mcp.example.com/sse" in str(exc_info.value) assert "HTTP error 502" in str(exc_info.value) _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, cleanup_error) + _assert_not_retained_in_exception_graph(exc_info.value, http_error) + _assert_not_retained_in_traceback_locals(exc_info.value, http_error) + + +@pytest.mark.asyncio +async def test_connect_preserves_original_error_when_cleanup_has_safe_generic_request_error( + monkeypatch, + caplog, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + server = MCPServerSse(params={"url": _SAFE_URL}) + connection_error = ValueError("original connection failure") + cleanup_error = httpx.ReadError( + "cleanup read failed", + request=httpx.Request("GET", _SAFE_URL), + ) + + with ( + patch.object(server, "create_streams", side_effect=connection_error), + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)), + caplog.at_level(logging.WARNING, logger="openai.agents"), + ): + with pytest.raises(ValueError) as exc_info: + await server.connect() + + assert exc_info.value is connection_error + record = caplog.records[-1] + assert record.exc_info is None + assert record.exc_text is None + _assert_not_retained_in_log_record(record, cleanup_error) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_connect_cleanup_mapped_error_omits_pending_connection_failure(): + server = MCPServerSse(params={"url": _SAFE_URL}) + connection_error = ValueError(_CREDENTIALED_URL) + cleanup_error = httpx.ReadTimeout( + "cleanup timed out", + request=httpx.Request("GET", _SAFE_URL), + ) + + with ( + patch.object(server, "create_streams", side_effect=connection_error), + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)), + ): + with pytest.raises(UserError) as exc_info: + await server.connect() + + assert "Connection timeout" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, connection_error) + _assert_not_retained_in_exception_graph(exc_info.value, cleanup_error) + _assert_not_retained_in_traceback_locals(exc_info.value, connection_error) + _assert_not_retained_in_traceback_locals(exc_info.value, cleanup_error) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("grouped", [False, True]) +async def test_normal_cleanup_hides_generic_request_error_context_from_log_record( + monkeypatch, + caplog, + grouped: bool, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + request_error = httpx.ReadError( + "cleanup read failed", + request=httpx.Request("GET", _SAFE_URL), + ) + sensitive_value = ValueError(_CREDENTIALED_URL) + request_error.__context__ = sensitive_value + cleanup_error: BaseException = request_error + if grouped: + cleanup_error = BaseExceptionGroup("cleanup failed", [request_error]) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)), + caplog.at_level(logging.WARNING, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + if grouped: + assert record.exc_info is not None + assert record.exc_info[1] is not cleanup_error + else: + assert record.exc_info is None + assert record.exc_text is None + _assert_not_retained_in_log_record(record, cleanup_error) + _assert_not_retained_in_log_record(record, request_error) + _assert_not_retained_in_log_record(record, sensitive_value) + _assert_url_credentials_hidden_from_log_record(record) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", ["message", "reason_phrase"]) +async def test_normal_cleanup_hides_transport_exception_payload_from_log_record( + monkeypatch, + caplog, + payload: str, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + request = httpx.Request("GET", _SAFE_URL) + if payload == "message": + cleanup_error: Exception = httpx.ReadTimeout(_CREDENTIALED_URL, request=request) + else: + cleanup_error = httpx.HTTPStatusError( + "cleanup failed", + request=request, + response=httpx.Response( + 502, + request=request, + extensions={"reason_phrase": _CREDENTIALED_URL.encode()}, + ), + ) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)), + caplog.at_level(logging.WARNING, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + assert record.exc_info is None + assert record.exc_text is None + _assert_not_retained_in_log_record(record, cleanup_error) + _assert_url_credentials_hidden_from_log_record(record) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("attachment", ["http_context", "non_http_context", "note"]) +async def test_failed_connection_cleanup_hides_sensitive_exception_attachments( + attachment: str, +): + server = MCPServerSse(params={"url": _SAFE_URL}) + cleanup_error, sensitive_value = _transport_error_with_sensitive_attachment(attachment) + + with patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)): + with pytest.raises(UserError) as exc_info: + await server.cleanup() + + assert "Connection timeout" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, cleanup_error) + _assert_not_retained_in_exception_graph(exc_info.value, sensitive_value) + _assert_not_retained_in_traceback_locals(exc_info.value, cleanup_error) + _assert_not_retained_in_traceback_locals(exc_info.value, sensitive_value) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_failed_connection_cleanup_hides_sensitive_transport_error_message(): + server = MCPServerSse(params={"url": _SAFE_URL}) + cleanup_error = httpx.ReadTimeout( + _CREDENTIALED_URL, + request=httpx.Request("GET", _SAFE_URL), + ) + + with patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)): + with pytest.raises(UserError) as exc_info: + await server.cleanup() + + assert "Connection timeout" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, cleanup_error) + _assert_not_retained_in_traceback_locals(exc_info.value, cleanup_error) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("grouped", [False, True]) +async def test_failed_connection_cleanup_omits_untrusted_http_reason_phrase(grouped: bool): + server = MCPServerSse(params={"url": _SAFE_URL}) + request = httpx.Request("GET", _SAFE_URL) + http_error = httpx.HTTPStatusError( + "boom", + request=request, + response=httpx.Response( + 502, + request=request, + extensions={"reason_phrase": _CREDENTIALED_URL.encode()}, + ), + ) + cleanup_error: BaseException = http_error + if grouped: + cleanup_error = BaseExceptionGroup("cleanup failed", [http_error]) + + with patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)): + with pytest.raises(UserError) as exc_info: + await server.cleanup() + + assert "HTTP error 502" in str(exc_info.value) + _assert_url_credentials_hidden(exc_info.value) + _assert_not_retained_in_exception_graph(exc_info.value, cleanup_error) + _assert_not_retained_in_exception_graph(exc_info.value, http_error) + _assert_not_retained_in_traceback_locals(exc_info.value, cleanup_error) _assert_not_retained_in_traceback_locals(exc_info.value, http_error) + assert server.session is None + assert server._get_session_id is None @pytest.mark.asyncio @@ -847,7 +1188,7 @@ async def test_failed_connection_cleanup_checks_every_nested_transport_error(): @pytest.mark.asyncio @pytest.mark.parametrize("redacted", [True, False]) -@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("exception_shape", ["direct", "grouped", "nested_group"]) @pytest.mark.parametrize( ("url", "safe_to_attach"), [ @@ -859,7 +1200,7 @@ async def test_normal_cleanup_only_logs_safe_transport_exceptions( monkeypatch, caplog, redacted: bool, - nested: bool, + exception_shape: str, url: str, safe_to_attach: bool, ): @@ -870,31 +1211,30 @@ async def test_normal_cleanup_only_logs_safe_transport_exceptions( "timed out", request=httpx.Request("GET", url), ) - inner_error: BaseException = timeout_error - if nested: - inner_error = BaseExceptionGroup("nested cleanup failed", [inner_error]) - cleanup_group = BaseExceptionGroup("cleanup failed", [inner_error]) + cleanup_error: BaseException = timeout_error + if exception_shape == "grouped": + cleanup_error = BaseExceptionGroup("cleanup failed", [timeout_error]) + elif exception_shape == "nested_group": + inner_group = BaseExceptionGroup("nested cleanup failed", [timeout_error]) + cleanup_error = BaseExceptionGroup("cleanup failed", [inner_group]) with ( - patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_group)), + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)), caplog.at_level(logging.WARNING, logger="openai.agents"), ): await server.cleanup() record = caplog.records[-1] - if not redacted and safe_to_attach: + if not redacted and safe_to_attach and exception_shape == "nested_group": assert record.exc_info is not None - if nested: - assert record.levelno == logging.ERROR - assert record.exc_info[1] is cleanup_group - else: - assert record.levelno == logging.WARNING - assert record.exc_info[1] is timeout_error + assert record.levelno == logging.ERROR + assert record.exc_info[1] is not cleanup_error else: assert record.exc_info is None assert record.exc_text is None - _assert_not_retained_in_log_record(record, cleanup_group) - _assert_not_retained_in_log_record(record, timeout_error) + + _assert_not_retained_in_log_record(record, cleanup_error) + _assert_not_retained_in_log_record(record, timeout_error) if not safe_to_attach: _assert_url_credentials_hidden_from_log_record(record) @@ -904,7 +1244,37 @@ async def test_normal_cleanup_only_logs_safe_transport_exceptions( @pytest.mark.asyncio -async def test_normal_cleanup_preserves_safe_nested_group_diagnostics(monkeypatch, caplog): +@pytest.mark.parametrize("redacted", [True, False]) +@pytest.mark.parametrize("attachment", ["http_context", "non_http_context", "note"]) +async def test_normal_cleanup_hides_sensitive_exception_attachments_from_log_record( + monkeypatch, + caplog, + redacted: bool, + attachment: str, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + server = MCPServerSse(params={"url": _SAFE_URL}) + server.session = MagicMock() + cleanup_error, sensitive_value = _transport_error_with_sensitive_attachment(attachment) + + with ( + patch.object(server.exit_stack, "aclose", AsyncMock(side_effect=cleanup_error)), + caplog.at_level(logging.WARNING, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + assert record.exc_info is None + assert record.exc_text is None + _assert_not_retained_in_log_record(record, cleanup_error) + _assert_not_retained_in_log_record(record, sensitive_value) + _assert_url_credentials_hidden_from_log_record(record) + assert server.session is None + assert server._get_session_id is None + + +@pytest.mark.asyncio +async def test_normal_cleanup_sanitizes_safe_nested_group_diagnostics(monkeypatch, caplog): monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) server = MCPServerSse(params={"url": _SAFE_URL}) server.session = MagicMock() @@ -912,10 +1282,11 @@ async def test_normal_cleanup_preserves_safe_nested_group_diagnostics(monkeypatc "timed out", request=httpx.Request("GET", _SAFE_URL), ) + ordinary_error = ValueError("ordinary sibling failure") cleanup_group = BaseExceptionGroup( "cleanup failed", [ - ValueError("ordinary sibling failure"), + ordinary_error, BaseExceptionGroup("nested cleanup failed", [timeout_error]), ], ) @@ -929,8 +1300,13 @@ async def test_normal_cleanup_preserves_safe_nested_group_diagnostics(monkeypatc record = caplog.records[-1] assert record.levelno == logging.ERROR assert record.exc_info is not None - assert record.exc_info[1] is cleanup_group - assert "ordinary sibling failure" in logging.Formatter().format(record) + assert record.exc_info[1] is not cleanup_group + rendered = logging.Formatter().format(record) + assert "An additional error occurred during the MCP request." in rendered + assert "ordinary sibling failure" not in rendered + _assert_not_retained_in_log_record(record, cleanup_group) + _assert_not_retained_in_log_record(record, ordinary_error) + _assert_not_retained_in_log_record(record, timeout_error) assert server.session is None assert server._get_session_id is None From a83930c2cbd4df6fc34b65367eda326b4b58df2f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 09:13:08 +0900 Subject: [PATCH 083/473] chore: run verification outside the Codex sandbox --- .../skills/code-change-verification/SKILL.md | 19 ++++++++++++++++++- .../agents/openai.yaml | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.agents/skills/code-change-verification/SKILL.md b/.agents/skills/code-change-verification/SKILL.md index 9c90e15d7c..6f6684a2db 100644 --- a/.agents/skills/code-change-verification/SKILL.md +++ b/.agents/skills/code-change-verification/SKILL.md @@ -12,13 +12,30 @@ Ensure work is only marked complete after formatting, linting, type checking, an ## Quick start 1. Keep this skill at `./.agents/skills/code-change-verification` so it loads automatically for the repository. -2. macOS/Linux: `bash .agents/skills/code-change-verification/scripts/run.sh`. +2. macOS/Linux: `env UV_DEFAULT_INDEX=https://pypi.org/simple bash .agents/skills/code-change-verification/scripts/run.sh`. 3. Windows: `powershell -ExecutionPolicy Bypass -File .agents/skills/code-change-verification/scripts/run.ps1`. 4. The scripts run `make format` first, then run `make lint`, `make typecheck`, and `make tests` in parallel with fail-fast semantics. 5. While the parallel steps are still running, the scripts emit periodic heartbeat updates so you can tell that work is still in progress. 6. If any command fails, fix the issue, rerun the script, and report the failing output. 7. Confirm completion only when all commands succeed with no remaining issues. +## Codex execution policy + +The full test suite exercises `UnixLocalSandboxSession`, which starts its own macOS sandbox. A +nested run inside the Codex filesystem sandbox fails with +`sandbox-exec: sandbox_apply: Operation not permitted` even when the implementation is correct. + +When Codex invokes the macOS/Linux verification command: + +- Use the exact command from Quick start so it matches the persistent command allow rule. +- Set `sandbox_permissions=require_escalated` on the first invocation. The matching allow rule makes + this non-interactive; it does not require a new user approval. +- Do not first run the suite inside the Codex sandbox and retry after the expected UnixLocal + failures. + +This execution policy changes only where the verification process runs. The SDK sandbox tests must +remain enabled and unchanged. + ## Environment setup The verification scripts assume repository dependencies are already installed. Do not run `make sync` as part of every verification pass; use it for a fresh checkout, after dependency files change, or when dependency resolution fails before the checks start. diff --git a/.agents/skills/code-change-verification/agents/openai.yaml b/.agents/skills/code-change-verification/agents/openai.yaml index 8ebf11e246..62d149ca5f 100644 --- a/.agents/skills/code-change-verification/agents/openai.yaml +++ b/.agents/skills/code-change-verification/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Code Change Verification" short_description: "Run the required local verification stack" - default_prompt: "Use $code-change-verification to run the required local verification stack and report any failures." + default_prompt: "Use $code-change-verification to run the required local verification stack. On macOS/Linux, invoke its canonical env command outside the Codex sandbox on the first attempt so nested UnixLocal sandbox tests run without an approval retry. Report any failures." From 16bc1f11e55c905285211cb1871af90230a67a94 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:05:04 +0900 Subject: [PATCH 084/473] Bump version to 0.19.2 (#4046) Co-authored-by: Kazuhiro Sera --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 602b531f88..dad974a2b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.19.1" +version = "0.19.2" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 41ad6efd8c..d37ff5665d 100644 --- a/uv.lock +++ b/uv.lock @@ -2437,7 +2437,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.19.1" +version = "0.19.2" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 07cdff5cde8f1ea02b7ea91bac8a638438c3ef03 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 10:20:09 +0900 Subject: [PATCH 085/473] docs: updates for v0.19.2 --- AGENTS.md | 1 + docs/guardrails.md | 2 ++ docs/mcp.md | 4 ++++ docs/sessions/index.md | 4 ++++ docs/tools.md | 2 ++ 5 files changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d84898011c..4fd6c51739 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,7 @@ Some tests rely on inline snapshots; see `tests/README.md` for details. Re-run ` - Type hints must pass `make typecheck`. - Write comments as full sentences ending with a period. - Imports are managed by Ruff and should stay sorted. +- Do not hard-wrap prose in Markdown or other non-code text files at a fixed column width. Keep each paragraph on one source line unless the file format or Markdown structure requires a line break, such as for lists, tables, blockquotes, or code fences. #### Mandatory local run order diff --git a/docs/guardrails.md b/docs/guardrails.md index 5caa814c3e..ace94942ea 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -66,6 +66,8 @@ See the code snippet below for details. If the input or output fails the guardrail, the Guardrail can signal this with a tripwire. As soon as we see a guardrail that has triggered the tripwires, we immediately raise a `{Input,Output}GuardrailTripwireTriggered` exception and halt the Agent execution. +The exception's `guardrail_result` identifies the guardrail that triggered the tripwire. For an input tripwire raised by the runner, `exception.run_data.input_guardrail_results` contains every input guardrail result completed before the run stopped, including the result that triggered the tripwire. The streamed result exposes the same accumulated results through `input_guardrail_results` after `stream_events()` raises. `run_data` can be `None` when an exception is raised outside a runner-managed execution path. + ## Implementing a guardrail You need to provide a function that receives input, and returns a [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]. In this example, we'll do this by running an Agent under the hood. diff --git a/docs/mcp.md b/docs/mcp.md index 925e3c6d9d..5c3b012b77 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9,6 +9,10 @@ context to language models. From the official documentation: The Agents Python SDK understands multiple MCP transports. This lets you reuse existing MCP servers or build your own to expose filesystem, HTTP, or connector backed tools to an agent. +!!! warning "Trust MCP servers before connecting" + + MCP tools can expose data from the model context and perform actions with the credentials you provide. Connect only to servers you trust, use least-privilege credentials, keep access tokens in authorization fields or headers rather than URLs, and require approval for sensitive operations. See the [OpenAI MCP security guidance](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety). + ## Choosing an MCP integration Before wiring an MCP server into an agent decide where the tool calls should execute and which transports you can reach. The matrix below summarises the options that the Python SDK supports. diff --git a/docs/sessions/index.md b/docs/sessions/index.md index 8916f85fab..dd66c087a4 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -361,8 +361,11 @@ session = RedisSession.from_url( url="redis://localhost:6379/0", ) result = await Runner.run(agent, "Hello", session=session) +await session.close() ``` +`from_url(...)` creates and owns the Redis client. After `close()`, the session is terminal and subsequent session operations raise `RuntimeError`; repeated or concurrent `close()` calls are safe. If your application already manages a Redis client, construct `RedisSession(...)` directly with `redis_client=...`. In that case, `close()` is a no-op and the caller retains both client ownership and session usability. + ### SQLAlchemy sessions Production-ready Agents SDK session persistence using any SQLAlchemy-supported database: @@ -411,6 +414,7 @@ async with DaprSession.from_address( Notes: - `from_address(...)` creates and owns the Dapr client for you. If your app already manages one, construct `DaprSession(...)` directly with `dapr_client=...`. +- Exiting the context or calling `close()` makes an owned-client session terminal; subsequent session operations raise `RuntimeError`, while repeated or concurrent `close()` calls are safe. With an injected client, `close()` is a no-op and the session remains usable. - Pass `ttl=...` to let the backing state store expire old session data automatically when the store supports TTL. - Pass `consistency=DAPR_CONSISTENCY_STRONG` when you need stronger read-after-write guarantees. - The Dapr Python SDK also checks the HTTP sidecar endpoint. In local development, start Dapr with `--dapr-http-port 3500` as well as the gRPC port used in `dapr_address`. diff --git a/docs/tools.md b/docs/tools.md index 14d5ef0d6b..06d8d544ed 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -307,6 +307,8 @@ You can use any Python function as a tool. The Agents SDK will set up the tool a - The schema for the function inputs is automatically created from the function's arguments - Descriptions for each input are taken from the docstring of the function, unless disabled +Tools created by `@tool` expose the original Python callable through the read-only `__wrapped__` attribute. This is useful for inspection and testing, but calling it directly bypasses the tool runtime pipeline, including schema validation, context injection, guardrails, timeouts, failure handling, and tracing. Hand-built `FunctionTool` instances do not expose `__wrapped__`. + We use Python's `inspect` module to extract the function signature, along with [`griffe`](https://mkdocstrings.github.io/griffe/) to parse docstrings and `pydantic` for schema creation. When you are using OpenAI Responses models, `@function_tool(defer_loading=True)` hides a function tool until `ToolSearchTool()` loads it. You can also group related function tools with [`tool_namespace()`][agents.tool.tool_namespace]. See [Hosted tool search](#hosted-tool-search) for the full setup and constraints. From c9b5d1ba3ffc00881cbab28359ddfcef63a7957c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 10:38:39 +0900 Subject: [PATCH 086/473] docs: update translated pages --- docs/ja/sandbox/clients.md | 86 +++++++------ docs/ja/sessions/index.md | 188 ++++++++++++++------------- docs/ja/tools.md | 249 +++++++++++++++++------------------ docs/ko/sandbox/clients.md | 86 +++++++------ docs/ko/sessions/index.md | 180 +++++++++++++------------- docs/ko/tools.md | 251 ++++++++++++++++++------------------ docs/zh/mcp.md | 147 ++++++++++----------- docs/zh/sandbox/clients.md | 82 ++++++------ docs/zh/sessions/index.md | 190 +++++++++++++-------------- docs/zh/tools.md | 257 +++++++++++++++++++------------------ 10 files changed, 872 insertions(+), 844 deletions(-) diff --git a/docs/ja/sandbox/clients.md b/docs/ja/sandbox/clients.md index 3edc20aea0..69a3224158 100644 --- a/docs/ja/sandbox/clients.md +++ b/docs/ja/sandbox/clients.md @@ -4,40 +4,42 @@ search: --- # サンドボックスクライアント -このページでは、サンドボックスでの作業をどこで実行するかを選択します。ほとんどの場合、`SandboxAgent` の定義は同じままにし、サンドボックスクライアントとクライアント固有のオプションを [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] で変更します。 +このページでは、サンドボックスでの処理を実行する場所を選択します。ほとんどの場合、`SandboxAgent` の定義はそのまま使用し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] のサンドボックスクライアントとクライアント固有のオプションのみを変更します。 !!! warning "ベータ機能" - サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト値、サポートされる機能が変更される可能性があります。また、時間とともにより高度な機能が追加される見込みです。 + サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -## 判断ガイド +## 選択ガイド
-| 目的 | まず使うもの | 理由 | +| 目的 | 最初に使用するもの | 理由 | | --- | --- | --- | -| macOS または Linux での最速のローカル反復 | `UnixLocalSandboxClient` | 追加インストール不要で、シンプルなローカルファイルシステム開発ができます。 | -| 基本的なコンテナ分離 | `DockerSandboxClient` | 特定のイメージを使って Docker 内で作業を実行します。 | -| ホスト型実行または本番環境スタイルの分離 | ホスト型サンドボックスクライアント | ワークスペース境界をプロバイダー管理環境へ移します。 | +| macOS または Linux で最速のローカル反復開発 | `UnixLocalSandboxClient` | 追加のインストールが不要で、ローカルファイルシステムを使用した開発が簡単です。 | +| 基本的なコンテナ分離 | `DockerSandboxClient` | 指定したイメージを使用して Docker 内で処理を実行します。 | +| ホスト環境での実行または本番環境相当の分離 | ホスト型サンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境へ移します。 |
## ローカルクライアント -ほとんどのユーザーは、これら 2 つのサンドボックスクライアントのいずれかから始めることをおすすめします。 +ほとんどのユーザーは、次の 2 つのサンドボックスクライアントのいずれかから開始することをお勧めします。
-| クライアント | インストール | 選ぶ場面 | 例 | +| クライアント | インストール | 適している状況 | コード例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカル反復が必要な場合。ローカル開発の既定として適しています。 | [Unix-local スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離、またはローカルで同等性を保つための特定のイメージが必要な場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカル反復開発を行う場合。ローカル開発に適したデフォルトです。 | [Unix-local スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離が必要な場合、またはローカル環境との整合性を保つために特定のイメージを使用する場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local は、ローカルファイルシステムに対して開発を始める最も簡単な方法です。より強い環境分離や本番環境スタイルの同等性が必要になったら、Docker またはホスト型プロバイダーへ移行してください。 +Unix-local は、ローカルファイルシステムを対象とした開発を開始する最も簡単な方法です。より強力な環境分離や本番環境相当の整合性が必要になった場合は、Docker またはホスト型プロバイダーへ移行してください。 -Unix-local から Docker に切り替えるには、エージェント定義は同じままにして、実行設定だけを変更します。 +`SandboxPathGrant.host_path` は Docker 専用であり、ホスト上のパスをコンテナ内の別の POSIX パスへマッピングします。Unix-local では、同一パスへの許可のみがサポートされます。詳細については、[マニフェストのパス許可](guide.md#manifest)を参照してください。 + +Unix-local から Docker へ切り替えるには、エージェント定義をそのまま維持し、実行設定のみを変更します。 ```python from docker import from_env as docker_from_env @@ -54,45 +56,45 @@ run_config = RunConfig( ) ``` -コンテナ分離またはイメージの同等性が必要な場合に使用してください。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 +コンテナ分離またはイメージの整合性が必要な場合に使用してください。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)を参照してください。 ## マウントとリモートストレージ -マウントエントリーはどのストレージを公開するかを表し、マウント戦略はサンドボックスバックエンドがそのストレージをどのようにアタッチするかを表します。組み込みのマウントエントリーと汎用戦略は `agents.sandbox.entries` からインポートします。ホスト型プロバイダーの戦略は `agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 +マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホスト型プロバイダー向けの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 -一般的なマウントオプション: +一般的なマウントオプションは次のとおりです。 -- `mount_path`: ストレージがサンドボックス内で表示される場所です。相対パスはマニフェストルート配下で解決され、絶対パスはそのまま使用されます。 -- `read_only`: 既定は `True` です。サンドボックスがマウントされたストレージへ書き戻す必要がある場合にのみ `False` に設定してください。 -- `mount_strategy`: 必須です。マウントエントリーとサンドボックスバックエンドの両方に合う戦略を使用してください。 +- `mount_path`: サンドボックス内でストレージが配置される場所です。相対パスはマニフェストのルートを基準に解決され、絶対パスはそのまま使用されます。 +- `read_only`: デフォルトは `True` です。サンドボックスからマウント済みストレージへ書き戻す必要がある場合にのみ、`False` に設定してください。 +- `mount_strategy`: 必須です。マウントエントリとサンドボックスバックエンドの両方に適合する戦略を使用してください。 -マウントは一時的なワークスペースエントリーとして扱われます。スナップショットと永続化のフローでは、マウントされたリモートストレージを保存済みワークスペースへコピーするのではなく、マウントされたパスをデタッチするかスキップします。 +マウントは、一時的なワークスペースエントリとして扱われます。スナップショットと永続化のフローでは、マウントされたリモートストレージを保存済みワークスペースへコピーする代わりに、マウント済みパスを切り離すかスキップします。 -汎用ローカル / コンテナ戦略: +汎用のローカル/コンテナ戦略は次のとおりです。
-| 戦略またはパターン | 使用する場面 | 備考 | +| 戦略またはパターン | 適している状況 | 注記 | | --- | --- | --- | | `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | サンドボックスイメージで `rclone` を実行できる場合。 | S3、GCS、R2、Azure Blob、Box をサポートします。`RcloneMountPattern` は `fuse` モードまたは `nfs` モードで実行できます。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` があり、Mountpoint スタイルの S3 または S3 互換アクセスが必要な場合。 | `S3Mount` と `GCSMount` をサポートします。 | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | イメージに `blobfuse2` があり、FUSE サポートがある場合。 | `AzureBlobMount` をサポートします。 | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | イメージに `mount.s3files` があり、既存の S3 Files マウントターゲットに到達できる場合。 | `S3FilesMount` をサポートします。 | -| `DockerVolumeMountStrategy(driver=...)` | Docker がコンテナ起動前にボリュームドライバー対応のマウントをアタッチする必要がある場合。 | Docker のみです。`rclone` は S3、GCS、R2、Azure Blob、Box をサポートし、`mountpoint` は S3 と GCS もサポートします。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` が含まれており、Mountpoint 形式で S3 または S3 互換ストレージへアクセスする場合。 | `S3Mount` と `GCSMount` をサポートします。 | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | イメージに `blobfuse2` と FUSE のサポートが含まれている場合。 | `AzureBlobMount` をサポートします。 | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | イメージに `mount.s3files` が含まれており、既存の S3 Files マウントターゲットへ接続できる場合。 | `S3FilesMount` をサポートします。 | +| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを使用したマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` をサポートし、S3 と GCS は `mountpoint` もサポートします。 |
-## サポートされるホスト型プラットフォーム +## サポート対象のホスト型プラットフォーム -ホスト型環境が必要な場合、通常は同じ `SandboxAgent` 定義をそのまま引き継ぎ、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントだけを変更します。 +ホスト型環境が必要な場合でも、通常は同じ `SandboxAgent` 定義をそのまま使用でき、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] のサンドボックスクライアントのみを変更します。 -このリポジトリのチェックアウトではなく公開されている SDK を使用している場合は、対応するパッケージ extra を通じてサンドボックスクライアントの依存関係をインストールしてください。 +このリポジトリのチェックアウトではなく公開版 SDK を使用している場合は、対応するパッケージの追加依存関係を通じてサンドボックスクライアントの依存関係をインストールしてください。 -プロバイダー固有のセットアップメモと、チェックイン済みの拡張コード例へのリンクについては、[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md) を参照してください。 +プロバイダー固有の設定に関する注記と、リポジトリに含まれる拡張機能のコード例へのリンクについては、[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)を参照してください。
-| クライアント | インストール | 例 | +| クライアント | インストール | コード例 | | --- | --- | --- | | `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel ランナー](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | | `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare ランナー](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | @@ -104,24 +106,24 @@ run_config = RunConfig(
-ホスト型サンドボックスクライアントは、プロバイダー固有のマウント戦略を公開します。ストレージプロバイダーに最も合うバックエンドとマウント戦略を選択してください。 +ホスト型サンドボックスクライアントは、プロバイダー固有のマウント戦略を提供します。ストレージプロバイダーに最も適したバックエンドとマウント戦略を選択してください。
| バックエンド | マウントに関する注記 | | --- | --- | -| Docker | `InContainerMountStrategy` や `DockerVolumeMountStrategy` などのローカル戦略で、`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` をサポートします。 | -| `ModalSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証済みの `GCSMount` で、`ModalCloudBucketMountStrategy` による Modal のクラウドバケットマウントをサポートします。インライン認証情報、または名前付きの Modal Secret を使用できます。 | -| `CloudflareSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証済みの `GCSMount` で、`CloudflareBucketMountStrategy` による Cloudflare バケットマウントをサポートします。 | -| `BlaxelSandboxClient` | `S3Mount`、`R2Mount`、`GCSMount` で、`BlaxelCloudBucketMountStrategy` によるクラウドバケットマウントをサポートします。`agents.extensions.sandbox.blaxel` の `BlaxelDriveMount` と `BlaxelDriveMountStrategy` による永続的な Blaxel Drives もサポートします。 | -| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` による `rclone` ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | -| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` による `rclone` ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | -| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` による `rclone` ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | -| `VercelSandboxClient` | `VercelCloudBucketMountStrategy` と `S3Mount` による、作成時限定の S3 および S3 互換バケットマウントをサポートします。マウントを含むセッションは再開できず、インライン認証情報を使用するには `allow_s3_credential_exposure=True` が必要です。 | +| Docker | `InContainerMountStrategy` や `DockerVolumeMountStrategy` などのローカル戦略を使用して、`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` をサポートします。 | +| `ModalSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証済みの `GCSMount` で、`ModalCloudBucketMountStrategy` を使用した Modal クラウドバケットのマウントをサポートします。インライン認証情報または名前付き Modal Secret を使用できます。 | +| `CloudflareSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証済みの `GCSMount` で、`CloudflareBucketMountStrategy` を使用した Cloudflare バケットのマウントをサポートします。 | +| `BlaxelSandboxClient` | `S3Mount`、`R2Mount`、`GCSMount` で、`BlaxelCloudBucketMountStrategy` を使用したクラウドバケットのマウントをサポートします。また、`agents.extensions.sandbox.blaxel` の `BlaxelDriveMount` と `BlaxelDriveMountStrategy` を使用した永続的な Blaxel Drive もサポートします。 | +| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` を使用した、rclone ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | +| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` を使用した、rclone ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | +| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` を使用した、rclone ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | +| `VercelSandboxClient` | `S3Mount` で `VercelCloudBucketMountStrategy` を使用した、作成時のみの S3 および S3 互換バケットのマウントをサポートします。マウントされたセッションは再開できません。また、インライン認証情報を使用するには `allow_s3_credential_exposure=True` が必要です。 |
-以下の表は、各バックエンドが直接マウントできるリモートストレージエントリーをまとめたものです。 +次の表は、各バックエンドが直接マウントできるリモートストレージエントリをまとめたものです。
@@ -138,4 +140,4 @@ run_config = RunConfig(
-実行可能なコード例をさらに見るには、ローカル、コーディング、メモリ、ハンドオフ、エージェント合成パターンについては [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) を、ホスト型サンドボックスクライアントについては [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) を参照してください。 +実行可能なコード例をさらに確認するには、ローカル、コーディング、メモリ、ハンドオフ、エージェント構成のパターンについては [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)を、ホスト型サンドボックスクライアントについては [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)を参照してください。 \ No newline at end of file diff --git a/docs/ja/sessions/index.md b/docs/ja/sessions/index.md index 7240850d21..cbe8753296 100644 --- a/docs/ja/sessions/index.md +++ b/docs/ja/sessions/index.md @@ -4,11 +4,11 @@ search: --- # セッション -Agents SDK は、複数のエージェント実行にまたがって会話履歴を自動的に維持する組み込みのセッションメモリを提供し、ターン間で `.to_input_list()` を手動で扱う必要をなくします。 +Agents SDK には、複数回のエージェント実行にわたって会話履歴を自動的に維持する組み込みのセッションメモリが用意されているため、ターン間で `.to_input_list()` を手動で処理する必要がありません。 -セッションは特定のセッションの会話履歴を保存し、明示的な手動メモリ管理を必要とせずに、エージェントがコンテキストを維持できるようにします。これは、エージェントに以前のやり取りを覚えておいてほしいチャットアプリケーションや複数ターンの会話を構築する場合に特に便利です。 +セッションは特定のセッションの会話履歴を保存し、明示的な手動メモリ管理を必要とせずに、エージェントがコンテキストを維持できるようにします。これは、エージェントに以前のやり取りを記憶させたいチャットアプリケーションや複数ターンの会話を構築する場合に特に便利です。 -SDK にクライアント側メモリを管理させたい場合は、セッションを使用します。セッションは、同じ実行内で `conversation_id`、`previous_response_id`、または `auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI のサーバー管理による継続を使用したい場合は、セッションを重ねて使うのではなく、それらの仕組みのいずれかを選択してください。 +SDK にクライアント側のメモリを管理させたい場合は、セッションを使用してください。同じ実行内で、セッションを `conversation_id`、`previous_response_id`、または `auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI サーバーが管理する継続機能を使用する場合は、セッションと重ねて使用せず、これらのメカニズムのいずれかを選択してください。 ## クイックスタート @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 同じセッションによる中断された実行の再開 -実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または同じバッキングストアを指す別のセッションインスタンス)で再開し、再開されたターンが同じ保存済み会話履歴を継続するようにします。 +承認待ちで実行が一時停止した場合は、再開されたターンが同じ保存済み会話履歴を継続できるように、同じセッションインスタンス、または同じバッキングストアを指す別のセッションインスタンスを使用して再開してください。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -63,31 +63,31 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## コアセッション動作 +## セッションの基本動作 -セッションメモリが有効な場合: +セッションメモリが有効な場合、次のように動作します。 -1. **各実行の前**: ランナーはセッションの会話履歴を自動的に取得し、入力アイテムの前に追加します。 -2. **各実行の後**: 実行中に生成されたすべての新しいアイテム(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 -3. **コンテキストの保持**: 同じセッションでの後続の各実行には完全な会話履歴が含まれるため、エージェントはコンテキストを維持できます。 +1. **各実行の前**: Runner はセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 +2. **各実行の後**: 実行中に生成されたすべての新しい項目(ユーザー入力、アシスタントの応答、ツール呼び出しなど)が、セッションに自動的に保存されます。 +3. **コンテキストの保持**: 同じセッションを使用する後続の各実行には完全な会話履歴が含まれるため、エージェントはコンテキストを維持できます。 -これにより、`.to_input_list()` を手動で呼び出したり、実行間の会話状態を管理したりする必要がなくなります。 +これにより、`.to_input_list()` を手動で呼び出し、実行間の会話状態を管理する必要がなくなります。 -## 履歴と新しい入力のマージ方法の制御 +## 履歴と新規入力のマージ制御 -セッションを渡すと、ランナーは通常、モデル入力を次のように準備します。 +セッションを渡すと、Runner は通常、モデル入力を次の順序で準備します。 1. セッション履歴(`session.get_items(...)` から取得) -2. 新しいターン入力 +2. 新しいターンの入力 -モデル呼び出しの前にこのマージ手順をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 +モデルを呼び出す前のこのマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 -- `history`: 取得されたセッション履歴(すでに入力アイテム形式に正規化済み) -- `new_input`: 現在のターンの新しい入力アイテム +- `history`: 取得したセッション履歴(入力項目形式に正規化済み) +- `new_input`: 現在のターンの新しい入力項目 -モデルに送信する最終的な入力アイテムのリストを返します。 +モデルに送信する最終的な入力項目のリストを返してください。 -コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストはそのターンのモデル入力を制御しますが、SDK は新しいターンに属するアイテムのみを永続化します。そのため、古い履歴を並べ替えたりフィルタリングしたりしても、古いセッションアイテムが新しい入力として再度保存されることはありません。 +コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストはそのターンのモデル入力を制御しますが、SDK が永続化するのは新しいターンに属する項目のみです。そのため、古い履歴を並べ替えたりフィルタリングしたりしても、古いセッション項目が新しい入力として再度保存されることはありません。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -セッションがアイテムを保存する方法を変更せずに、カスタムの枝刈り、並べ替え、または履歴の選択的な取り込みが必要な場合に使用します。モデル呼び出しの直前にさらに最終的な処理が必要な場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 +セッションが項目を保存する方法を変更せずに、履歴の独自の枝刈り、並べ替え、または選択的な追加が必要な場合に使用します。モデル呼び出しの直前に最終処理が必要な場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 ## 取得する履歴の制限 -各実行の前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 +各実行前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 -- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッションアイテムを取得します -- `SessionSettings(limit=N)`: 直近の `N` アイテムのみを取得します +- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッション項目を取得します +- `SessionSettings(limit=N)`: 最新の `N` 項目のみを取得します -これは、[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] を介して実行ごとに適用できます。 +これは、[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] を使用して実行ごとに適用できます。 ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` はその実行について `None` ではない値を上書きします。これは、セッションのデフォルト動作を変更せずに取得サイズを上限設定したい長い会話で便利です。 +セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` はその実行について、`None` 以外の値を上書きします。これは、セッションのデフォルト動作を変更せずに取得件数を制限したい長い会話で役立ちます。 ## メモリ操作 ### 基本操作 -セッションは、会話履歴を管理するためのいくつかの操作をサポートしています。 +セッションでは、会話履歴を管理するための複数の操作を使用できます。 ```python from agents import SQLiteSession @@ -165,9 +165,9 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 修正のための pop_item の使用 +### 修正での pop_item の使用 -`pop_item` メソッドは、会話内の最後のアイテムを取り消したり変更したりしたい場合に特に便利です。 +`pop_item` メソッドは、会話の最後の項目を取り消したり変更したりする場合に特に便利です。 ```python from agents import Agent, Runner, SQLiteSession @@ -198,32 +198,32 @@ print(f"Agent: {result.final_output}") ## 組み込みセッション実装 -SDK は、さまざまなユースケース向けに複数のセッション実装を提供しています。 +SDK には、さまざまなユースケースに対応する複数のセッション実装が用意されています。 ### 組み込みセッション実装の選択 -以下の詳細な例を読む前に、開始点を選ぶためにこの表を使用してください。 +以下の詳細な例を読む前に、この表を使用して開始点を選択してください。 -| セッションタイプ | 最適な用途 | 注記 | +| セッションタイプ | 最適な用途 | 備考 | | --- | --- | --- | -| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込み、軽量、ファイルバックまたはインメモリ | -| `AsyncSQLiteSession` | `aiosqlite` を使用した非同期 SQLite | 非同期ドライバー対応の拡張バックエンド | -| `RedisSession` | ワーカーやサービス間で共有するメモリ | 低レイテンシの分散デプロイに適しています | -| `SQLAlchemySession` | 既存データベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作します | -| `MongoDBSession` | すでに MongoDB を使用しているアプリ、またはマルチプロセスストレージが必要なアプリ | 非同期 pymongo;順序付け用のアトミックシーケンスカウンター | -| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブデプロイ | 複数のステートストアに加え、TTL と整合性制御をサポートします | -| `OpenAIConversationsSession` | OpenAI でのサーバー管理ストレージ | OpenAI Conversations API をバックエンドとする履歴 | -| `OpenAIResponsesCompactionSession` | 自動圧縮を伴う長い会話 | 別のセッションバックエンドをラップします | -| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析 | より多機能です。専用ページを参照してください | -| `EncryptedSession` | 別のセッション上での暗号化と TTL | ラッパーです。まず基盤となるバックエンドを選択してください | +| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量、ファイルベースまたはインメモリ | +| `AsyncSQLiteSession` | `aiosqlite` を使用する非同期 SQLite | 非同期ドライバーをサポートする拡張バックエンド | +| `RedisSession` | ワーカーやサービス間での共有メモリ | 低レイテンシーの分散デプロイに適しています | +| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作します | +| `MongoDBSession` | MongoDB をすでに使用しているアプリ、またはマルチプロセスストレージが必要なアプリ | 非同期 pymongo。順序付け用のアトミックなシーケンスカウンター | +| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数のステートストアに加え、TTL と整合性制御をサポートします | +| `OpenAIConversationsSession` | OpenAI でのサーバー管理ストレージ | OpenAI Conversations API を利用した履歴 | +| `OpenAIResponsesCompactionSession` | 自動コンパクションを使用する長い会話 | 別のセッションバックエンドをラップします | +| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析が必要な場合 | より多機能です。専用ページを参照してください | +| `EncryptedSession` | 別のセッションに暗号化と TTL を追加する場合 | ラッパーです。まず基盤となるバックエンドを選択してください | -一部の実装には、追加の詳細を含む専用ページがあります。それらは各サブセクション内でリンクされています。 +一部の実装には追加の詳細を説明する専用ページがあり、それぞれのサブセクション内にリンクがあります。 -ChatKit 用の Python サーバーを実装している場合は、ChatKit のスレッドとアイテムの永続化に `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアのドロップイン置き換えではありません。[ChatKit データストアの実装に関する `chatkit-python` ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 +ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドと項目の永続化に `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えることはできません。[ChatKit データストアの実装に関する `chatkit-python` ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 ### OpenAI Conversations API セッション -`OpenAIConversationsSession` を通じて [OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations)を使用します。 +`OpenAIConversationsSession` を通じて、[OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations)を使用します。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -257,11 +257,11 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses 圧縮セッション +### OpenAI Responses コンパクションセッション -Responses API(`responses.compact`)で保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターンの後に自動的に圧縮できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 +Responses API(`responses.compact`)を使用して保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターンの後に自動的にコンパクションを実行できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 -#### 一般的な使用方法(自動圧縮) +#### 一般的な使用方法(自動コンパクション) ```python from agents import Agent, Runner, SQLiteSession @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -デフォルトでは、候補しきい値に達すると各ターンの後に圧縮が実行されます。 +デフォルトでは、候補のしきい値に達すると、各ターンの後にコンパクションが実行されます。 -`compaction_mode="previous_response_id"` は、Responses API の応答 ID でターンをすでに連鎖させている場合に最も適しています。`compaction_mode="input"` は、代わりに現在のセッションアイテムから圧縮リクエストを再構築します。これは、応答チェーンが利用できない場合や、セッション内容を信頼できる情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な中で最も安全な選択肢を選びます。 +Responses API のレスポンス ID を使用してターンをすでに連結している場合は、`compaction_mode="previous_response_id"` が最適です。一方、`compaction_mode="input"` は、現在のセッション項目からコンパクションリクエストを再構築します。これは、レスポンスチェーンを利用できない場合や、セッション内容を信頼できる唯一の情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 -エージェントが `ModelSettings(store=False)` で実行される場合、Responses API は後で検索するための最後の応答を保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存するのではなく、入力ベースの圧縮にフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py) を参照してください。 +エージェントが `ModelSettings(store=False)` で実行される場合、Responses API は後で参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースのコンパクションにフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 -#### auto-compaction によるストリーミングのブロック +#### 自動コンパクションによるストリーミングのブロック -圧縮はセッション履歴をクリアして書き換えるため、SDK は実行完了とみなす前に圧縮の完了を待ちます。ストリーミングモードでは、圧縮が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 +コンパクションではセッション履歴がクリアされて書き換えられるため、SDK はコンパクションが完了するまで実行を完了と見なしません。ストリーミングモードでは、コンパクションの処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 -低レイテンシのストリーミングや高速なターン処理が必要な場合は、自動圧縮を無効にし、ターン間(またはアイドル時間中)に自分で `run_compaction()` を呼び出してください。独自の基準に基づいて、いつ圧縮を強制するかを決めることができます。 +低レイテンシーのストリーミングやターンの迅速な切り替えが必要な場合は、自動コンパクションを無効にし、ターン間またはアイドル時間中に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、コンパクションを強制するタイミングを決定できます。 ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 非同期 SQLite セッション -`aiosqlite` をバックエンドとする SQLite の永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 +`aiosqlite` を基盤とする SQLite 永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 ```bash pip install aiosqlite @@ -349,7 +349,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis セッション -複数のワーカーまたはサービス間で共有セッションメモリを使用するには、`RedisSession` を使用します。 +複数のワーカーまたはサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 ```bash pip install openai-agents[redis] @@ -365,11 +365,14 @@ session = RedisSession.from_url( url="redis://localhost:6379/0", ) result = await Runner.run(agent, "Hello", session=session) +await session.close() ``` +`from_url(...)` は Redis クライアントを作成して所有します。`close()` の後、セッションは終了状態になり、それ以降のセッション操作では `RuntimeError` が発生します。`close()` を繰り返し、または同時に呼び出しても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築してください。その場合、`close()` は何も行わず、呼び出し元がクライアントの所有権とセッションの利用可能性の両方を維持します。 + ### SQLAlchemy セッション -SQLAlchemy がサポートする任意のデータベースを使用した、本番対応の Agents SDK セッション永続化です。 +SQLAlchemy がサポートする任意のデータベースを使用した、本番環境向けの Agents SDK セッション永続化です。 ```python from agents.extensions.memory import SQLAlchemySession @@ -391,7 +394,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ### Dapr セッション -すでに Dapr サイドカーを実行している場合、またはエージェントコードを変更せずに異なるステートストアバックエンドへ移行できるセッションストレージが必要な場合は、`DaprSession` を使用します。 +Dapr サイドカーをすでに実行している場合、またはエージェントコードを変更せずに異なるステートストアバックエンド間で移行できるセッションストレージが必要な場合は、`DaprSession` を使用します。 ```bash pip install openai-agents[dapr] @@ -412,18 +415,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -注記: +注意事項: -- `from_address(...)` は Dapr クライアントを作成し、所有します。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を指定して `DaprSession(...)` を直接構築してください。 -- 基盤となるステートストアが TTL をサポートしている場合に古いセッションデータを自動的に期限切れにするには、`ttl=...` を渡します。 -- より強い read-after-write 保証が必要な場合は、`consistency=DAPR_CONSISTENCY_STRONG` を渡します。 -- Dapr Python SDK は HTTP サイドカーエンドポイントもチェックします。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` でも Dapr を起動してください。 -- ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py) を参照してください。 +- `from_address(...)` は Dapr クライアントを作成して所有します。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を指定して `DaprSession(...)` を直接構築してください。 +- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを使用するセッションは終了状態になります。それ以降のセッション操作では `RuntimeError` が発生しますが、`close()` を繰り返し、または同時に呼び出しても安全です。注入されたクライアントを使用する場合、`close()` は何も行わず、セッションは引き続き使用できます。 +- バッキングステートストアが TTL をサポートしている場合、古いセッションデータを自動的に期限切れにするには `ttl=...` を渡します。 +- 書き込み後の読み取りについて、より強い保証が必要な場合は `consistency=DAPR_CONSISTENCY_STRONG` を渡します。 +- Dapr Python SDK は HTTP サイドカーエンドポイントも確認します。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` を指定して Dapr を起動してください。 +- ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)を参照してください。 ### MongoDB セッション -すでに MongoDB を使用しているアプリケーション、または水平スケーラブルでマルチプロセス対応のセッションストレージが必要なアプリケーションには、`MongoDBSession` を使用します。 +MongoDB をすでに使用しているアプリケーション、または水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションでは、`MongoDBSession` を使用します。 ```bash pip install openai-agents[mongodb] @@ -446,12 +450,12 @@ print(result.final_output) await session.close() ``` -注記: +注意事項: -- `from_uri(...)` は `AsyncMongoClient` を作成し、所有し、`session.close()` で閉じます。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築してください。その場合、`session.close()` は no-op となり、ライフサイクルは呼び出し元が保持します。 -- ほかの変更なしに、`from_uri(...)` に `mongodb+srv://user:password@cluster.example.mongodb.net` URI を渡すことで [MongoDB Atlas](https://www.mongodb.com/products/platform) に接続できます。 -- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。各メッセージドキュメントは、同時実行の書き込み元やプロセスをまたいで順序を保持する単調増加の `seq` カウンターを持ちます。 -- 最初の実行前に接続性を確認するには、`await session.ping()` を使用します。 +- `from_uri(...)` は `AsyncMongoClient` を作成して所有し、`session.close()` で閉じます。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築してください。その場合、`session.close()` は何も行わず、ライフサイクルの管理は呼び出し元が引き続き行います。 +- `mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すことで、ほかに変更を加えずに [MongoDB Atlas](https://www.mongodb.com/products/platform) に接続できます。 +- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。各メッセージドキュメントには単調増加する `seq` カウンターが含まれ、同時に書き込む複数のライターやプロセス間でも順序が保持されます。 +- 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 ### 高度な SQLite セッション @@ -479,7 +483,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 ### 暗号化セッション -任意のセッション実装向けの透過的な暗号化ラッパーです。 +任意のセッション実装に対する透過的な暗号化ラッパーです。 ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -506,13 +510,13 @@ result = await Runner.run(agent, "Hello", session=session) ### その他のセッションタイプ -組み込みの選択肢はほかにもいくつかあります。`examples/memory/` と `extensions/memory/` 以下のソースコードを参照してください。 +ほかにもいくつかの組み込みオプションがあります。`examples/memory/` および `extensions/memory/` 配下のソースコードを参照してください。 ## 運用パターン ### セッション ID の命名 -会話を整理しやすい、意味のあるセッション ID を使用してください。 +会話の整理に役立つ、意味のあるセッション ID を使用してください。 - ユーザーベース: `"user_12345"` - スレッドベース: `"thread_abc123"` @@ -520,18 +524,18 @@ result = await Runner.run(agent, "Hello", session=session) ### メモリの永続化 -- 一時的な会話にはインメモリ SQLite(`SQLiteSession("session_id")`)を使用します -- 永続的な会話にはファイルベース SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します +- 一時的な会話には、インメモリ SQLite(`SQLiteSession("session_id")`)を使用します +- 永続的な会話には、ファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します - `aiosqlite` ベースの実装が必要な場合は、非同期 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)を使用します -- 共有された低レイテンシのセッションメモリには、Redis バックのセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します -- SQLAlchemy がサポートする既存データベースを持つ本番システムには、SQLAlchemy を利用したセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) を使用します -- すでに MongoDB を使用しているアプリケーション、またはマルチプロセスで水平スケーラブルなセッションストレージが必要なアプリケーションには、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します -- 組み込みのテレメトリ、トレーシング、データ分離を備えた 30 以上のデータベースバックエンドをサポートする本番クラウドネイティブデプロイには、Dapr ステートストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します +- 共有された低レイテンシーのセッションメモリには、Redis ベースのセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します +- SQLAlchemy がサポートする既存のデータベースを使用する本番システムには、SQLAlchemy ベースのセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)を使用します +- MongoDB をすでに使用しているアプリケーション、または水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションには、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します +- 組み込みのテレメトリー、トレーシング、データ分離を備え、30 種類以上のデータベースバックエンドをサポートする本番環境のクラウドネイティブなデプロイには、Dapr ステートストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します - OpenAI Conversations API に履歴を保存したい場合は、OpenAI がホストするストレージ(`OpenAIConversationsSession()`)を使用します -- 透過的な暗号化と TTL ベースの有効期限で任意のセッションをラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します -- より高度なユースケースでは、ほかの本番システム(たとえば Django)向けのカスタムセッションバックエンドの実装を検討してください +- 任意のセッションを透過的な暗号化と TTL ベースの有効期限でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します +- より高度なユースケースでは、ほかの本番システム(Django など)向けのカスタムセッションバックエンドの実装を検討してください -### 複数セッション +### 複数のセッション ```python from agents import Agent, Runner, SQLiteSession @@ -554,7 +558,7 @@ result2 = await Runner.run( ) ``` -### セッション共有 +### セッションの共有 ```python # Different agents can share the same session @@ -641,7 +645,7 @@ if __name__ == "__main__": ## カスタムセッション実装 -[`Session`][agents.memory.session.Session] プロトコルに従うクラスを作成することで、独自のセッションメモリを実装できます。 +[`Session`][agents.memory.session.Session] プロトコルに準拠するクラスを作成することで、独自のセッションメモリを実装できます。 ```python from agents.memory.session import SessionABC @@ -686,26 +690,26 @@ result = await Runner.run( ## コミュニティによるセッション実装 -コミュニティは追加のセッション実装を開発しています。 +コミュニティによって、追加のセッション実装が開発されています。 | パッケージ | 説明 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 任意の Django 対応データベース(PostgreSQL、MySQL、SQLite など)向けの Django ORM ベースのセッション | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)向けの Django ORM ベースのセッション | -セッション実装を構築した場合は、ぜひドキュメント PR を送ってここに追加してください。 +セッション実装を構築した場合は、ここに追加するためのドキュメント PR をぜひ送信してください。 ## API リファレンス 詳細な API ドキュメントについては、以下を参照してください。 -- [`Session`][agents.memory.session.Session] - プロトコルインターフェイス +- [`Session`][agents.memory.session.Session] - プロトコルインターフェース - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 実装 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 圧縮ラッパー +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API コンパクションラッパー - [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基本的な SQLite 実装 -- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` に基づく非同期 SQLite 実装 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis バックのセッション実装 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy を利用した実装 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB バックのセッション実装 +- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` ベースの非同期 SQLite 実装 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis ベースのセッション実装 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy ベースの実装 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB ベースのセッション実装 - [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr ステートストア実装 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析を備えた拡張 SQLite +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析機能を備えた拡張 SQLite - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッション向けの暗号化ラッパー \ No newline at end of file diff --git a/docs/ja/tools.md b/docs/ja/tools.md index 636ec9d5d6..339a4b7df8 100644 --- a/docs/ja/tools.md +++ b/docs/ja/tools.md @@ -6,42 +6,42 @@ search: ツールを使用すると、エージェントはデータの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作などのアクションを実行できます。SDK は 5 つのカテゴリーをサポートしています。 -- OpenAI がホストするツール: OpenAI のサーバー上でモデルとともに実行されます。 +- OpenAI がホストするツール: OpenAI のサーバー上でモデルと並行して実行されます。 - ローカル/ランタイム実行ツール: `ComputerTool` と `ApplyPatchTool` は常にご利用の環境で実行され、`ShellTool` はローカルまたはホスト型コンテナで実行できます。 - Function Calling: 任意の Python 関数をツールとしてラップします。 -- Agents as tools: 完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 -- 実験的機能: Codex ツール: ツール呼び出しからワークスペーススコープの Codex タスクを実行します。 +- Agents as tools: 完全なハンドオフを行わず、エージェントを呼び出し可能なツールとして公開します。 +- 試験的機能: Codex ツール: ツール呼び出しからワークスペーススコープの Codex タスクを実行します。 ## ツールタイプの選択 -このページをカタログとして利用し、ご自身が制御するランタイムに該当するセクションへ移動してください。 +このページをカタログとして使用し、管理するランタイムに該当するセクションへ移動してください。 | 目的 | 参照先 | | --- | --- | | OpenAI が管理するツール(Web 検索、ファイル検索、Code Interpreter、ホスト型 MCP、画像生成)を使用する | [ホスト型ツール](#hosted-tools) | -| ツール検索を使用して、大規模なツール群の読み込みをランタイムまで遅延させる | [ホスト型ツール検索](#hosted-tool-search) | +| ツール検索を使用して、大規模なツールサーフェスの読み込みをランタイムまで遅延する | [ホスト型ツール検索](#hosted-tool-search) | | 生成された JavaScript から複数のツール呼び出しを調整する | [プログラムによるツール呼び出し](#programmatic-tool-calling) | -| ご自身のプロセスまたは環境でツールを実行する | [ローカルランタイムツール](#local-runtime-tools) | +| 独自のプロセスまたは環境でツールを実行する | [ローカルランタイムツール](#local-runtime-tools) | | Python 関数をツールとしてラップする | [関数ツール](#function-tools) | | ハンドオフを行わずに、あるエージェントから別のエージェントを呼び出せるようにする | [Agents as tools](#agents-as-tools) | -| エージェントからワークスペーススコープの Codex タスクを実行する | [実験的機能: Codex ツール](#experimental-codex-tool) | +| エージェントからワークスペーススコープの Codex タスクを実行する | [試験的機能: Codex ツール](#experimental-codex-tool) | ## ホスト型ツール OpenAI は、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合に、いくつかの組み込みツールを提供しています。 -- [`WebSearchTool`][agents.tool.WebSearchTool] を使用すると、エージェントは Web を検索できます。 +- [`WebSearchTool`][agents.tool.WebSearchTool] を使用すると、エージェントが Web を検索できます。 - [`FileSearchTool`][agents.tool.FileSearchTool] を使用すると、OpenAI ベクトルストアから情報を取得できます。 -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] を使用すると、LLM はサンドボックス環境でコードを実行できます。 +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] を使用すると、LLM がサンドボックス環境でコードを実行できます。 - [`HostedMCPTool`][agents.tool.HostedMCPTool] は、リモート MCP サーバーのツールをモデルに公開します。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] は、プロンプトから画像を生成します。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルは遅延読み込みされたツール、名前空間、またはホスト型 MCP サーバーを必要に応じて読み込めます。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルは生成された JavaScript から対象ツールを調整できます。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルが遅延ツール、名前空間、またはホスト型 MCP サーバーをオンデマンドで読み込めます。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルが生成した JavaScript から対象ツールを調整できます。 ホスト型検索の高度なオプション: -- `FileSearchTool` は、`vector_store_ids` および `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートしています。 -- `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートしています。 +- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。 +- `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートします。 ```python from agents import Agent, FileSearchTool, Runner, WebSearchTool @@ -64,9 +64,9 @@ async def main(): ### ホスト型ツール検索 -ツール検索を使用すると、OpenAI Responses モデルは大規模なツール群の読み込みをランタイムまで遅延させ、現在のターンに必要なサブセットのみを読み込めます。これは、多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールを事前に公開せずにツールスキーマのトークン数を削減したい場合に便利です。 +ツール検索を使用すると、OpenAI Responses モデルは大規模なツールサーフェスの読み込みをランタイムまで遅延できるため、現在のターンに必要なサブセットのみを読み込みます。これは、多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールを事前に公開することなくツールスキーマのトークン数を削減したい場合に便利です。 -エージェントを構築する時点で候補ツールがすでに判明している場合は、ホスト型ツール検索から始めてください。アプリケーション側で読み込む対象を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしていますが、標準の `Runner` はこのモードを自動実行しません。 +エージェントを構築する時点で候補ツールがすでに判明している場合は、ホスト型ツール検索から始めてください。アプリケーションで読み込む対象を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしますが、標準の `Runner` はこのモードを自動実行しません。 ```python from typing import Annotated @@ -109,28 +109,28 @@ result = await Runner.run(agent, "Look up customer_42 and list their open orders print(result.final_output) ``` -注意事項: +留意事項: - ホスト型ツール検索は、OpenAI Responses モデルでのみ利用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 -- エージェントで遅延読み込み対象を設定する場合は、`ToolSearchTool()` を 1 つだけ追加してください。 -- 検索可能な対象には、`@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])`、`HostedMCPTool(tool_config={..., "defer_loading": True})` が含まれます。 -- 遅延読み込みされる関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成でも、モデルが適切なグループを必要に応じて読み込めるよう、`ToolSearchTool()` を使用できます。 -- `tool_namespace()` は、複数の `FunctionTool` インスタンスを共通の名前空間名と説明の下にグループ化します。これは通常、`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に最適です。 +- エージェントに遅延読み込みサーフェスを設定する場合は、`ToolSearchTool()` をちょうど 1 つ追加してください。 +- 検索可能なサーフェスには、`@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])`、`HostedMCPTool(tool_config={..., "defer_loading": True})` が含まれます。 +- 遅延読み込みを行う関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成でも、モデルが適切なグループをオンデマンドで読み込めるように `ToolSearchTool()` を使用できます。 +- `tool_namespace()` は、`FunctionTool` インスタンスを共通の名前空間名と説明の下にグループ化します。これは通常、`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に最適です。 - OpenAI の公式ベストプラクティスガイダンスは、[可能な限り名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことです。 -- 可能な場合は、個別に遅延読み込みされる多数の関数よりも、名前空間またはホスト型 MCP サーバーを優先してください。通常、モデルにとってより適切な高レベルの検索対象となり、トークンも効率的に節約できます。 -- 名前空間には、即時利用可能なツールと遅延読み込みされるツールを混在させられます。`defer_loading=True` が指定されていないツールはすぐに呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 -- 目安として、各名前空間は比較的小さく保ち、理想的には関数を 10 個未満にしてください。 -- 名前付きの `tool_choice` では、名前空間名そのものや遅延読み込みのみのツールを対象にできません。`auto`、`required`、または実際に呼び出し可能な最上位ツールの名前を使用してください。 -- `ToolSearchTool(execution="client")` は、Responses の手動オーケストレーション用です。モデルがクライアント実行型の `tool_search_call` を生成した場合、標準の `Runner` はそれを実行する代わりに例外を発生させます。 -- ツール検索のアクティビティは、専用のアイテムおよびイベントタイプとして、[`RunResult.new_items`](results.md#new-items) と [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 -- 名前空間を使用した読み込みと最上位の遅延ツールの両方を扱う、実行可能な完全なコード例については、`examples/tools/tool_search.py` を参照してください。 +- 可能な場合は、個別に遅延される多数の関数よりも、名前空間またはホスト型 MCP サーバーを優先してください。通常、これらはモデルに対してより適切な高レベルの検索サーフェスを提供し、トークンをより多く節約できます。 +- 名前空間には、即時ツールと遅延ツールを混在させられます。`defer_loading=True` が指定されていないツールは引き続き即座に呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 +- 目安として、各名前空間は比較的小さく保ち、10 個未満の関数にすることが理想的です。 +- 名前付きの `tool_choice` では、単独の名前空間名や遅延専用ツールを対象にできません。`auto`、`required`、または実際のトップレベルの呼び出し可能なツール名を使用することを推奨します。 +- `ToolSearchTool(execution="client")` は、Responses を手動でオーケストレーションするためのものです。モデルがクライアント実行型の `tool_search_call` を生成した場合、標準の `Runner` はそれを実行せずに例外を送出します。 +- ツール検索のアクティビティは、専用の項目タイプおよびイベントタイプとともに、[`RunResult.new_items`](results.md#new-items) と [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 +- 名前空間による読み込みとトップレベルの遅延ツールの両方を扱う、実行可能な完全なコード例については、`examples/tools/tool_search.py` を参照してください。 - 公式プラットフォームガイド: [ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 ### プログラムによるツール呼び出し -プログラムによるツール呼び出しを使用すると、サポート対象の OpenAI Responses モデルは、対象ツールを呼び出してその出力を組み合わせ、1 つの結果をモデルに返す JavaScript を生成できます。これは、ツール呼び出しごとにモデルとのラウンドトリップを行わずに、ループ、分岐、並列呼び出し、中間計算を活用できる、範囲が限定されたワークフローに役立ちます。 +プログラムによるツール呼び出しを使用すると、サポート対象の OpenAI Responses モデルが JavaScript を生成し、対象ツールを呼び出して、その出力を結合し、1 つの実行結果をモデルに返せます。各ツール呼び出し後にモデルとのラウンドトリップを行うことなく、ループ、分岐、並列呼び出し、中間計算を活用できる範囲の限定されたワークフローに便利です。 -生成されたプログラムは、新しいホスト型 V8 環境で実行されます。Node.js API、ファイルシステムやネットワークへのアクセス、永続的なプロセスは利用できません。プログラムが操作できるのは、明示的に許可したツールのみです。 +生成されたプログラムは、新しいホスト型 V8 環境で実行されます。Node.js API、ファイルシステムやネットワークへのアクセス、永続的なプロセスは使用できません。プログラムが操作できるのは、明示的に許可したツールのみです。 ```python from pydantic import BaseModel @@ -165,23 +165,24 @@ result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it. print(result.final_output) ``` -注意事項: +留意事項: - プログラムによるツール呼び出しは、サポート対象の OpenAI Responses モデルでのみ利用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 -- エージェントには、`ProgrammaticToolCallingTool()` を最大 1 つ追加できます。エージェントは、プログラムから呼び出し可能なツール、`ToolSearchTool()`、またはプロンプトで管理されるツール群のうち、少なくとも 1 つも公開する必要があります。 -- `allowed_callers` は、ツールを呼び出す方法を制御します。省略すると、モデルからの直接呼び出しのみが許可されます。プログラムからのみアクセスできるようにするには `["programmatic"]`、両方を許可するには `["direct", "programmatic"]` を使用してください。 -- オプトインできる SDK のツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数ツール、カスタムツール、シェルツール、パッチ適用ツールでは、`allowed_callers` を直接指定できます。ホスト型 MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定してください。 -- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、データクラスなどの構造化された戻り値アノテーションが自動的に厳密なオブジェクト出力スキーマとなり、値がプログラムに返される前に検証されます。関数に利用可能なアノテーションがない場合は `output_type=...` を使用し、厳密なオブジェクトスキーマがすでにある場合は、より低レベルのエスケープハッチである `output_json_schema={...}` を使用してください。`output_type` と `output_json_schema` は同時に使用できません。単純な `str`、`Any`、`None` の戻り値には型が付けられません。 -- プログラムが所有する SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入力および出力ガードレール、フック、タイムアウト、同時実行数の制限、再試行、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 -- 承認が重要なツールや影響の大きいツールは、通常、直接呼び出しとして維持する方が適しています。これにより、大規模なプログラムの一部になる前に、各アクションを人が確認できます。プログラムが所有する呼び出しが承認待ちで一時停止した場合は、通常どおり `RunState` を通じて中断を解決し、元の実行を再開してください。 -- プログラムによるツール呼び出しは、[ホスト型ツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムが遅延ツールを呼び出すには、その前にモデルがツールを読み込む必要があります。 -- `program` アイテムと、プログラムが所有する子呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。確認方法の詳細については、[実行結果](results.md#new-items)および[ストリーミング](streaming.md#run-item-event-names)を参照してください。 -- 同時実行による在庫計画の完全なコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 +- エージェントには `ProgrammaticToolCallingTool()` を最大 1 つ追加できます。また、エージェントはプログラムから呼び出し可能なツールを少なくとも 1 つ、名前空間、遅延関数、遅延されたホスト型 MCP サーバーに基づく `ToolSearchTool()`、または不透明なプロンプト管理ツールサーフェスを公開する必要があります。検索可能なサーフェスを伴わない単独の `ToolSearchTool()` は拒否されます。 +- `allowed_callers` は、ツールの呼び出し方法を制御します。省略すると、モデルからの直接呼び出しのみが許可されます。プログラム専用アクセスには `["programmatic"]`、両方を許可するには `["direct", "programmatic"]` を使用してください。 +- オプトインできる SDK ツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数、カスタム、シェル、パッチ適用の各ツールでは、`allowed_callers` を直接公開します。ホスト型 MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定してください。 +- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、dataclass などの構造化された戻り値アノテーションが、自動的に厳密なオブジェクト出力スキーマとなり、値がプログラムに返される前に検証されます。関数に使用可能なアノテーションがない場合は `output_type=...` を使用し、厳密なオブジェクトスキーマがすでにある場合は、より低レベルのエスケープハッチである `output_json_schema={...}` を使用してください。`output_type` と `output_json_schema` は相互排他的です。単純な `str`、`Any`、`None` の戻り値は型なしのままです。スキーマに基づくプログラム所有の呼び出しでは、自由形式のテキストが出力スキーマを満たさないため、デフォルトの失敗フォーマッターが無効になります。そのため、スキーマに準拠する JSON を返すカスタム `failure_error_function` を指定しない限り、ハンドラーの例外が伝播します。 +- プログラム所有の SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入力および出力ガードレール、フック、タイムアウト、同時実行数制限、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 +- `ProgrammaticToolCallingTool()` が存在する場合、プログラムが実行される前であっても、モデルリクエストの再試行にはより厳格なリプレイ安全性の境界が使用されます。SDK は、これらのリクエストに対するプロバイダー管理の再試行と WebSocket のイベント前再試行を無効にします。Runner の再試行ポリシーは、プロバイダーからの指示によってリプレイが安全であると明示的に示された場合にのみ再試行します。`retry_policies.network_error()` だけでは、この境界を上書きしません。 +- 承認が重要なツールや影響の大きいツールは、通常、直接呼び出しのままにすることを推奨します。これにより、大規模なプログラムの一部になる前に、各アクションを人が確認できます。プログラム所有の呼び出しが承認のために一時停止した場合は、通常どおり `RunState` を介して中断を解決し、元の実行を再開してください。 +- プログラムによるツール呼び出しは、[ホスト型ツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムが遅延ツールを呼び出す前に、モデルがそのツールを読み込む必要があります。 +- `program` 項目と、プログラムが所有する通常の子ツール呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。一方、ホスト型 MCP の承認リクエストとツールカタログでは、専用の MCP 項目およびストリームイベントが使用されます。確認方法の詳細については、[実行結果](results.md#new-items)および[ストリーミング](streaming.md#run-item-event-names)を参照してください。 +- 完全な並行在庫計画のコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 - 公式プラットフォームガイド: [プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 -### ホスト型コンテナシェル + スキル +### ホスト型コンテナシェルとスキル -`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートしています。ローカルランタイムではなく、管理されたコンテナ内でモデルにシェルコマンドを実行させたい場合は、このモードを使用してください。 +`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートします。ローカルランタイムではなく、管理されたコンテナ内でモデルにシェルコマンドを実行させる場合に、このモードを使用してください。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -214,52 +215,52 @@ result = await Runner.run( print(result.final_output) ``` -既存のコンテナを後続の実行で再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 +後続の実行で既存のコンテナを再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 -注意事項: +留意事項: - ホスト型シェルは、Responses API のシェルツールを通じて利用できます。 - `container_auto` はリクエスト用のコンテナをプロビジョニングし、`container_reference` は既存のコンテナを再利用します。 - `container_auto` には、`file_ids` と `memory_limit` も含められます。 -- `environment.skills` は、スキルへの参照とインラインスキルバンドルを受け付けます。 +- `environment.skills` は、スキル参照とインラインスキルバンドルを受け入れます。 - ホスト型環境では、`ShellTool` に `executor`、`needs_approval`、`on_approval` を設定しないでください。 -- `network_policy` は、`disabled` モードと `allowlist` モードをサポートしています。 -- 許可リストモードでは、`network_policy.domain_secrets` を使用して、ドメインスコープのシークレットを名前で注入できます。 -- 完全なコード例については、`examples/tools/container_shell_skill_reference.py` および `examples/tools/container_shell_inline_skill.py` を参照してください。 +- `network_policy` は、`disabled` モードと `allowlist` モードをサポートします。 +- 許可リストモードでは、`network_policy.domain_secrets` によって、名前を指定してドメインスコープのシークレットを挿入できます。 +- 完全なコード例については、`examples/tools/container_shell_skill_reference.py` と `examples/tools/container_shell_inline_skill.py` を参照してください。 - OpenAI プラットフォームガイド: [シェル](https://platform.openai.com/docs/guides/tools-shell)および[スキル](https://platform.openai.com/docs/guides/tools-skills)。 ## ローカルランタイムツール -ローカルランタイムツールは、モデルのレスポンス自体の外部で実行されます。呼び出すタイミングは引き続きモデルが決定しますが、実際の処理はご利用のアプリケーションまたは設定済みの実行環境が行います。 +ローカルランタイムツールは、モデルレスポンス自体の外部で実行されます。モデルは引き続き呼び出すタイミングを決定しますが、実際の処理はアプリケーションまたは設定済みの実行環境が行います。 -`ComputerTool` と `ApplyPatchTool` には、常にご自身で用意したローカル実装が必要です。`ShellTool` は両方のモードに対応しています。管理された実行を使用する場合は前述のホスト型コンテナ設定を使用し、ご自身のプロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用してください。 +`ComputerTool` と `ApplyPatchTool` には、常にご自身で用意したローカル実装が必要です。`ShellTool` は両方のモードに対応します。管理された実行が必要な場合は上記のホスト型コンテナ設定を使用し、独自のプロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用してください。 -ローカルランタイムツールでは、実装を用意する必要があります。 +ローカルランタイムツールには、実装を用意する必要があります。 -- [`ComputerTool`][agents.tool.ComputerTool]: GUI/ブラウザの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 +- [`ComputerTool`][agents.tool.ComputerTool]: GUI/ブラウザーの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 - [`ShellTool`][agents.tool.ShellTool]: ローカル実行とホスト型コンテナ実行の両方に対応する最新のシェルツールです。 - [`LocalShellTool`][agents.tool.LocalShellTool]: 従来のローカルシェル統合です。 - [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 差分をローカルに適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 - ローカルシェルスキルは、`ShellTool(environment={"type": "local", "skills": [...]})` で利用できます。 -### ComputerTool と Responses のコンピュータツール +### `ComputerTool` と Responses コンピュータツール -`ComputerTool` は引き続きローカルハーネスです。ご自身で [`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供し、SDK がそのハーネスを OpenAI Responses API のコンピュータ操作インターフェースにマッピングします。 +`ComputerTool` は引き続きローカルハーネスです。[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を用意すると、SDK がそのハーネスを OpenAI Responses API のコンピュータサーフェスにマッピングします。 -明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストでは、SDK は GA 版の組み込みツールペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルでは、プレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` が引き続き使用されます。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)で説明されているプラットフォーム移行に対応しています。 +明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストの場合、SDK は GA 組み込みツールのペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルでは、プレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` が引き続き使用されます。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)に記載されているプラットフォーム移行を反映しています。 - モデル: `computer-use-preview` -> `gpt-5.5` - ツールセレクター: `computer_use_preview` -> `computer` -- コンピュータ呼び出しの形式: `computer_call` ごとに 1 つの `action` -> `computer_call` 上のバッチ化された `actions[]` -- 切り詰め: プレビューパスでは `ModelSettings(truncation="auto")` が必須 -> GA パスでは不要 +- コンピュータ呼び出し形式: `computer_call` ごとに 1 つの `action` -> `computer_call` 上の一括 `actions[]` +- 切り詰め: プレビューパスでは `ModelSettings(truncation="auto")` が必要 -> GA パスでは不要 -SDK は、実際の Responses リクエストにおける有効なモデルから、この通信形式を選択します。プロンプトテンプレートを使用していて、プロンプト側でモデルを指定するためリクエストから `model` が省略される場合、`model="gpt-5.5"` を明示したままにするか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 +SDK は、実際の Responses リクエストで有効なモデルに基づいてワイヤー形式を選択します。プロンプトテンプレートを使用し、プロンプト側でモデルを保持しているためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 -[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに対応する組み込みセレクターへ正規化されます。`ComputerTool` がない場合、これらの文字列は通常の関数名と同様に動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け入れられ、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` がない場合、これらの文字列は引き続き通常の関数名として動作します。 -この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーによって提供される場合に重要です。GA の `computer` ペイロードでは、シリアライズ時に `environment` や画面サイズが不要なため、未解決のファクトリーでも問題ありません。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるよう、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 +この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーに基づく場合に重要です。GA の `computer` ペイロードでは、シリアライズ時に `environment` や寸法は不要なため、未解決のファクトリーでも問題ありません。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるように、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 -ランタイムでは、どちらのパスも同じローカルハーネスを使用します。プレビューのレスポンスでは、単一の `action` を持つ `computer_call` アイテムが生成されます。`gpt-5.5` ではバッチ化された `actions[]` が生成される場合があり、SDK は `computer_call_output` のスクリーンショットアイテムを生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 +ランタイムでは、両方のパスで同じローカルハーネスが引き続き使用されます。プレビューレスポンスは単一の `action` を含む `computer_call` 項目を生成します。`gpt-5.5` は一括の `actions[]` を生成でき、SDK は `computer_call_output` スクリーンショット項目を生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -308,11 +309,13 @@ agent = Agent( - ツール名には Python 関数の名前が使用されます(名前を指定することもできます) - ツールの説明は関数の docstring から取得されます(説明を指定することもできます) - 関数入力のスキーマは、関数の引数から自動的に作成されます -- 無効化されていない限り、各入力の説明は関数の docstring から取得されます +- 無効にしない限り、各入力の説明は関数の docstring から取得されます + +`@tool` で作成されたツールは、読み取り専用の `__wrapped__` 属性を通じて元の Python 呼び出し可能オブジェクトを公開します。これは検査やテストに便利ですが、直接呼び出すと、スキーマ検証、コンテキスト挿入、ガードレール、タイムアウト、失敗処理、トレーシングを含むツールランタイムパイプラインが回避されます。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 Python の `inspect` モジュールを使用して関数シグネチャを抽出し、さらに [`griffe`](https://mkdocstrings.github.io/griffe/) で docstring を解析し、`pydantic` でスキーマを作成します。 -OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は `ToolSearchTool()` によって読み込まれるまで関数ツールを非表示にします。また、関連する関数ツールを [`tool_namespace()`][agents.tool.tool_namespace] でグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 +OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は `ToolSearchTool()` が読み込むまで関数ツールを非表示にします。[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 ```python import json @@ -365,12 +368,12 @@ for tool in agent.tools: ``` -1. 関数の引数には任意の Python 型を使用でき、関数は同期または非同期のどちらでもかまいません。 -2. docstring が存在する場合は、説明と引数の説明を取得するために使用されます。 -3. 関数は、必要に応じて `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring のスタイルなどを上書きすることもできます。 +1. 任意の Python 型を関数の引数として使用でき、関数は同期または非同期にできます。 +2. docstring が存在する場合、説明と引数の説明の取得に使用されます。 +3. 関数はオプションで `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring スタイルなどのオーバーライドも設定できます。 4. デコレートされた関数をツールのリストに渡せます。 -??? note "出力を表示するには展開してください" +??? note "出力の表示" ``` fetch_weather @@ -442,20 +445,20 @@ for tool in agent.tools: ### 関数ツールからの画像またはファイルの返却 -テキスト出力に加えて、関数ツールの出力として 1 つまたは複数の画像やファイルを返すことができます。そのためには、次のいずれかを返します。 +テキスト出力に加えて、1 つまたは複数の画像やファイルを関数ツールの出力として返せます。そのためには、次のいずれかを返します。 - 画像: [`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) - ファイル: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- テキスト: 文字列、文字列に変換可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- テキスト: 文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### カスタム関数ツール -Python 関数をツールとして使用したくない場合もあります。その場合は、必要に応じて [`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。以下を指定する必要があります。 +Python 関数をツールとして使用したくない場合もあります。必要に応じて、[`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。次の項目を指定する必要があります。 - `name` - `description` -- `params_json_schema`。引数の JSON スキーマです -- `on_invoke_tool`。[`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数です。 +- 引数の JSON スキーマである `params_json_schema` +- [`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(たとえば、テキスト、構造化ツール出力オブジェクト、出力のリスト)を返す非同期関数である `on_invoke_tool` ```python from typing import Any @@ -490,16 +493,16 @@ tool = FunctionTool( ### 引数と docstring の自動解析 -前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと個々の引数の説明を抽出します。これに関する注意事項は次のとおりです。 +前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと各引数の説明を抽出します。留意事項は次のとおりです。 -1. シグネチャの解析は `inspect` モジュールを使用して行われます。型アノテーションを使用して引数の型を把握し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートしています。 -2. docstring の解析には `griffe` を使用します。サポートされている docstring 形式は `google`、`sphinx`、`numpy` です。docstring の形式は自動検出を試みますが、ベストエフォートであるため、`function_tool` を呼び出す際に明示的に設定することもできます。また、`use_docstring_info` を `False` に設定して、docstring の解析を無効にすることもできます。Google スタイルの docstring では、要約テキストの直後に空行を挟まずに配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーで受け付けられます。 +1. シグネチャの解析は、`inspect` モジュールを使用して行われます。型アノテーションを使用して引数の型を把握し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートしています。 +2. docstring の解析には `griffe` を使用します。サポートされる docstring 形式は、`google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートであり、`function_tool` を呼び出す際に明示的に設定できます。`use_docstring_info` を `False` に設定して、docstring の解析を無効にすることもできます。Google スタイルの docstring では、要約テキストの直後に空行を挟まずに配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーが受け入れます。 -スキーマ抽出のコードは [`agents.function_schema`][] にあります。 +スキーマ抽出のコードは、[`agents.function_schema`][] にあります。 ### Pydantic Field による引数の制約と説明 -Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用して、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値を使用する形式(`arg: int = Field(..., ge=1)`)と `Annotated` を使用する形式(`arg: Annotated[int, Field(..., ge=1)]`)の両方がサポートされています。生成される JSON スキーマと検証には、これらの制約が含まれます。 +Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用して、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値ベース(`arg: int = Field(..., ge=1)`)と `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)の両方の形式がサポートされます。生成される JSON スキーマと検証には、これらの制約が含まれます。 ```python from typing import Annotated @@ -519,7 +522,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr ### 関数ツールのタイムアウト -`@function_tool(timeout=...)` を使用すると、非同期関数ツールの呼び出しごとにタイムアウトを設定できます。 +`@function_tool(timeout=...)` を使用して、非同期関数ツールに呼び出し単位のタイムアウトを設定できます。 ```python import asyncio @@ -540,13 +543,13 @@ agent = Agent( ) ``` -タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから確認できるタイムアウトメッセージ(例: `Tool 'slow_lookup' timed out after 2 seconds.`)が送信されます。 +タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` であり、モデルから確認できるタイムアウトメッセージ(たとえば、`Tool 'slow_lookup' timed out after 2 seconds.`)を送信します。 タイムアウト処理は次のように制御できます。 -- `timeout_behavior="error_as_result"`(デフォルト): モデルが回復できるように、タイムアウトメッセージをモデルへ返します。 -- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 -- `timeout_error_function=...`: `error_as_result` を使用する場合のタイムアウトメッセージをカスタマイズします。 +- `timeout_behavior="error_as_result"`(デフォルト): モデルが復旧できるように、タイムアウトメッセージを返します。 +- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を送出し、実行を失敗させます。 +- `timeout_error_function=...`: `error_as_result` を使用する場合に、タイムアウトメッセージをカスタマイズします。 ```python import asyncio @@ -570,15 +573,15 @@ except ToolTimeoutError as e: !!! note - タイムアウト設定は、非同期の `@function_tool` ハンドラーでのみサポートされています。 + タイムアウト設定は、非同期の `@function_tool` ハンドラーでのみサポートされます。 -### 関数ツールでのエラー処理 +### 関数ツールのエラー処理 -`@function_tool` を使用して関数ツールを作成する際に、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 +`@function_tool` を使用して関数ツールを作成する場合、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 -- デフォルトでは(何も渡さない場合)、エラーが発生したことを LLM に伝える `default_tool_error_function` が実行されます。 -- 独自のエラー関数を渡した場合は、その関数が代わりに実行され、レスポンスが LLM に送信されます。 -- 明示的に `None` を渡した場合、ツール呼び出しのエラーは再度発生し、ご自身で処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` などが発生する可能性があります。 +- デフォルトでは(何も渡さない場合)、`default_tool_error_function` が実行され、エラーが発生したことを LLM に通知します。 +- 独自のエラー関数を渡した場合は、代わりにその関数が実行され、レスポンスが LLM に送信されます。 +- `None` を明示的に渡した場合、ツール呼び出しのエラーは再送出され、ご自身で処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` などになる可能性があります。 ```python from agents import RunContextWrapper @@ -606,7 +609,7 @@ def get_user_profile(user_id: str) -> str: ## Agents as tools -一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントで専門的なエージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 +一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントで専門エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 ```python import asyncio @@ -652,9 +655,9 @@ if __name__ == "__main__": ### ツールエージェントのカスタマイズ -`agent.as_tool` 関数は、エージェントを簡単にツールへ変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートしています。また、`parameters`、`input_builder`、`include_input_schema` を使用した構造化入力もサポートしています。 +`agent.as_tool` 関数は、エージェントを簡単にツールへ変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` などの一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` を使用した構造化入力もサポートします。 -状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は自動的には継承されません。クライアント管理の履歴を親実行とネストされた実行の間で共有するには、両方に同じ `session` を明示的に渡してください。`Runner.run` と同様に、ネストされた実行では、クライアント管理の `session`、または `previous_response_id` か `conversation_id` を使用したサーバー管理の継続のいずれか 1 つの状態管理方式を選択してください。 +状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は自動的には継承されません。クライアント管理の履歴を親実行とネストされた実行の間で共有するには、同じ `session` を両方に明示的に渡してください。`Runner.run` と同様に、ネストされた実行には 1 つの状態戦略を選択します。クライアント管理の `session`、または `previous_response_id` もしくは `conversation_id` を使用したサーバー管理の継続です。 ```python from agents.decorators import tool @@ -678,7 +681,7 @@ async def run_my_agent() -> str: ### ツールエージェントの構造化入力 -デフォルトでは、`Agent.as_tool()` は単一の文字列入力(`{"input": "..."}`)を想定しますが、`parameters`(Pydantic モデルまたはデータクラス型)を渡すことで、構造化スキーマを公開できます。 +デフォルトでは、`Agent.as_tool()` は単一の文字列入力(`{"input": "..."}`)を想定しますが、`parameters`(Pydantic モデルまたは dataclass 型)を渡すことで構造化スキーマを公開できます。 追加オプション: @@ -708,17 +711,17 @@ translator_tool = translator_agent.as_tool( ### ツールエージェントの承認ゲート -`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行は一時停止し、保留中のアイテムが `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開します。一時停止/再開の完全なパターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行が一時停止し、保留中の項目が `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開します。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 ### カスタム出力の抽出 -場合によっては、中央のエージェントへ返す前にツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 +場合によっては、中央のエージェントに返す前に、ツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 - サブエージェントのチャット履歴から特定の情報(JSON ペイロードなど)を抽出する。 -- エージェントの最終回答を変換または再フォーマットする(Markdown をプレーンテキストや CSV に変換するなど)。 -- 出力を検証するか、エージェントのレスポンスが欠落している、または形式が不正な場合にフォールバック値を提供する。 +- エージェントの最終回答を変換または再フォーマットする(Markdown をプレーンテキストまたは CSV に変換するなど)。 +- 出力を検証するか、エージェントのレスポンスが欠落している場合や形式が不正な場合にフォールバック値を提供する。 -これは、`as_tool` メソッドに `custom_output_extractor` 引数を指定することで実現できます。 +これを行うには、`as_tool` メソッドに `custom_output_extractor` 引数を指定します。 ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -737,11 +740,11 @@ json_tool = data_agent.as_tool( ) ``` -カスタム抽出関数内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果の後処理時に、外側のツール名、呼び出し ID、または raw 引数が必要な場合に役立ちます。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 +カスタム抽出関数内では、ネストされた [`RunResult`][agents.result.RunResult] によって [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] も公開されます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、raw 引数が必要な場合に便利です。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 ### ネストされたエージェント実行のストリーミング -`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが生成するストリーミングイベントを受信しながら、ストリームの完了後に最終出力を返せます。 +`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが生成するストリーミングイベントを受信しながら、ストリーム完了後に最終出力を返せます。 ```python from agents import AgentToolStreamEvent @@ -761,15 +764,15 @@ billing_agent_tool = billing_agent.as_tool( 想定される動作: -- イベントタイプは `StreamEvent["type"]` と同様に、`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event` です。 -- `on_stream` を指定すると、ネストされたエージェントは自動的にストリーミングモードで実行され、最終出力を返す前にストリームが最後まで処理されます。 +- イベントタイプは `StreamEvent["type"]` を反映します。`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event` です。 +- `on_stream` を指定すると、ネストされたエージェントが自動的にストリーミングモードで実行され、最終出力を返す前にストリームが最後まで処理されます。 - ハンドラーは同期または非同期にできます。各イベントは到着順に配信されます。 -- モデルのツール呼び出しを通じてツールが呼び出された場合は、`tool_call` が存在します。直接呼び出した場合は `None` のままになることがあります。 +- モデルのツール呼び出しによってツールが呼び出された場合、`tool_call` が存在します。直接呼び出しでは `None` の場合があります。 - 実行可能な完全なサンプルについては、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 -### 条件付きのツール有効化 +### 条件付きツール有効化 -`is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的にフィルタリングできます。 +`is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的に絞り込めます。 ```python import asyncio @@ -824,24 +827,24 @@ async def main(): asyncio.run(main()) ``` -`is_enabled` パラメーターは、次の値を受け付けます。 +`is_enabled` パラメーターは、次を受け入れます。 - **ブール値**: `True`(常に有効)または `False`(常に無効) - **呼び出し可能な関数**: `(context, agent)` を受け取り、ブール値を返す関数 -- **非同期関数**: 複雑な条件ロジック用の非同期関数 +- **非同期関数**: 複雑な条件ロジックに使用する非同期関数 -無効化されたツールはランタイムで LLM から完全に非表示になるため、次の用途に役立ちます。 +無効化されたツールはランタイムで LLM から完全に非表示になるため、次の用途に便利です。 -- ユーザー権限に基づく機能ゲーティング -- 環境固有のツール利用可否(開発環境と本番環境) +- ユーザー権限に基づく機能制限 +- 環境固有のツール可用性(開発環境と本番環境) - 異なるツール設定の A/B テスト - ランタイム状態に基づく動的なツールフィルタリング -## 実験的機能: Codex ツール +## 試験的機能: Codex ツール -`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペーススコープのタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。この機能は実験的であり、変更される可能性があります。 +`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペーススコープのタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。このサーフェスは試験的機能であり、変更される可能性があります。 -現在の実行を離れずに、メインエージェントから Codex へ範囲が限定されたワークスペースタスクを委任したい場合に使用してください。デフォルトのツール名は `codex` です。カスタム名を設定する場合は、`codex` または `codex_` で始まる名前にする必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 +メインエージェントが現在の実行を離れることなく、範囲の限定されたワークスペースタスクを Codex に委任する場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合は、`codex` または `codex_` で始まる名前にする必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 ```python from agents import Agent @@ -870,33 +873,33 @@ agent = Agent( ) ``` -まず、次のオプショングループを確認してください。 +最初に、次のオプショングループを確認してください。 -- 実行対象: `sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定してください。 -- スレッドのデフォルト設定: `default_thread_options=ThreadOptions(...)` は、モデル、推論強度、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 -- ターンのデフォルト設定: `default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` や任意のキャンセル用 `signal` など、ターンごとの動作を設定します。 -- ツールの入出力: ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` アイテムを少なくとも 1 つ含める必要があります。`output_schema` を使用すると、構造化された Codex レスポンスを必須にできます。 +- 実行サーフェス: `sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定してください。 +- スレッドのデフォルト: `default_thread_options=ThreadOptions(...)` は、モデル、推論の労力、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` の使用を推奨します。 +- ターンのデフォルト: `default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` やオプションのキャンセル用 `signal` など、ターン単位の動作を設定します。 +- ツール I/O: ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` 項目を少なくとも 1 つ含める必要があります。`output_schema` を使用すると、Codex に構造化されたレスポンスを要求できます。 -スレッドの再利用と永続化は、個別に制御されます。 +スレッドの再利用と永続化は、個別の制御項目です。 - `persist_session=True` は、同じツールインスタンスへの繰り返し呼び出しで 1 つの Codex スレッドを再利用します。 -- `use_run_context_thread_id=True` は、同じ変更可能なコンテキストオブジェクトを共有する複数の実行にわたって、実行コンテキストにスレッド ID を保存して再利用します。 -- スレッド ID の優先順位は、呼び出しごとの `thread_id`、実行コンテキストのスレッド ID(有効な場合)、設定済みの `thread_id` オプションの順です。 +- `use_run_context_thread_id=True` は、同じ可変コンテキストオブジェクトを共有する複数の実行にわたって、実行コンテキスト内にスレッド ID を保存して再利用します。 +- スレッド ID の優先順位は、呼び出し単位の `thread_id`、実行コンテキストのスレッド ID(有効な場合)、設定済みの `thread_id` オプションの順です。 - デフォルトの実行コンテキストキーは、`name="codex"` の場合は `codex_thread_id`、`name="codex_"` の場合は `codex_thread_id_` です。`run_context_thread_id_key` で上書きできます。 ランタイム設定: - 認証: `CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 - ランタイム: `codex_options.base_url` は CLI のベース URL を上書きします。 -- バイナリの解決: CLI のパスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、見つからなければ同梱のベンダーバイナリへフォールバックします。 +- バイナリーの解決: CLI パスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、見つからなければ同梱のベンダーバイナリーを使用します。 - 環境: `codex_options.env` は、サブプロセス環境を完全に制御します。これを指定した場合、サブプロセスは `os.environ` を継承しません。 - ストリーム制限: `codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの制限を制御します。有効範囲は `65536` から `67108864` で、デフォルトは `8388608` です。 -- ストリーミング: `on_stream` は、スレッド/ターンのライフサイクルイベントとアイテムイベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` のアイテム更新)を受信します。 -- 出力: 実行結果には `response`、`usage`、`thread_id` が含まれ、使用量は `RunContextWrapper.usage` に追加されます。 +- ストリーミング: `on_stream` は、スレッド/ターンのライフサイクルイベントと項目イベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` の項目更新)を受け取ります。 +- 出力: 実行結果には `response`、`usage`、`thread_id` が含まれます。使用量は `RunContextWrapper.usage` に追加されます。 リファレンス: - [Codex ツール API リファレンス](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions リファレンス](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions リファレンス](ref/extensions/experimental/codex/turn_options.md) -- 実行可能な完全なサンプルについては、`examples/tools/codex.py` および `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file +- 実行可能な完全なサンプルについては、`examples/tools/codex.py` と `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file diff --git a/docs/ko/sandbox/clients.md b/docs/ko/sandbox/clients.md index 137cdd8319..b1cc4a278a 100644 --- a/docs/ko/sandbox/clients.md +++ b/docs/ko/sandbox/clients.md @@ -4,21 +4,21 @@ search: --- # 샌드박스 클라이언트 -이 페이지를 사용하여 샌드박스 작업을 어디에서 실행할지 선택하세요. 대부분의 경우 `SandboxAgent` 정의는 그대로 두고, [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. +이 페이지를 사용하여 샌드박스 작업을 실행할 위치를 선택하세요. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. !!! warning "베타 기능" - 샌드박스 에이전트는 베타입니다. API의 세부 사항, 기본값, 지원 기능은 정식 출시 전 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 사항, 기본값, 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 예정입니다. -## 결정 가이드 +## 선택 가이드
-| 목표 | 시작 대상 | 이유 | +| 목표 | 시작 옵션 | 이유 | | --- | --- | --- | -| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치가 필요 없고, 로컬 파일 시스템 개발이 간단합니다. | -| 기본 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지로 Docker 내부에서 작업을 실행합니다. | -| 호스티드 실행 또는 프로덕션 스타일 격리 | 호스티드 샌드박스 클라이언트 | 워크스페이스 경계를 제공자가 관리하는 환경으로 이동합니다. | +| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치가 필요 없으며 로컬 파일 시스템에서 간단하게 개발할 수 있습니다. | +| 기본적인 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지가 적용된 Docker 내부에서 작업을 실행합니다. | +| 호스티드 실행 또는 프로덕션 수준의 격리 | 호스티드 샌드박스 클라이언트 | 워크스페이스 경계를 제공업체가 관리하는 환경으로 이동합니다. |
@@ -28,16 +28,18 @@ search:
-| 클라이언트 | 설치 | 선택 시점 | 예시 | +| 클라이언트 | 설치 | 선택하는 경우 | 예제 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠른 로컬 반복 개발이 필요할 때. 로컬 개발의 좋은 기본값입니다. | [Unix-local 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 컨테이너 격리 또는 로컬 환경과의 동등성을 위한 특정 이미지가 필요할 때. | [Docker 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠르게 로컬 반복 개발을 수행하려는 경우입니다. 로컬 개발을 위한 좋은 기본 옵션입니다. | [Unix 로컬 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 컨테이너 격리가 필요하거나 로컬 환경의 동등성을 위해 특정 이미지를 사용하려는 경우입니다. | [Docker 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리 또는 프로덕션 스타일의 동등성이 필요할 때 Docker나 호스티드 제공자로 이동하세요. +Unix 로컬은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리나 프로덕션 수준의 동등성이 필요할 때 Docker 또는 호스티드 제공업체로 전환하세요. -Unix-local에서 Docker로 전환하려면 에이전트 정의는 그대로 두고 실행 구성만 변경하세요. +`SandboxPathGrant.host_path` 는 Docker에서만 사용할 수 있으며 호스트 경로를 컨테이너 내부의 다른 POSIX 경로에 매핑합니다. Unix 로컬에서는 동일 경로 허용만 지원합니다. 자세한 내용은 [매니페스트 경로 허용](guide.md#manifest)을 참조하세요. + +Unix 로컬에서 Docker로 전환하려면 에이전트 정의는 그대로 유지하고 실행 구성만 변경합니다. ```python from docker import from_env as docker_from_env @@ -54,45 +56,45 @@ run_config = RunConfig( ) ``` -컨테이너 격리 또는 이미지 동등성이 필요할 때 사용하세요. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참고하세요. +컨테이너 격리 또는 이미지 동등성이 필요한 경우 이 방식을 사용하세요. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. -## 마운트와 원격 스토리지 +## 마운트 및 원격 스토리지 -마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 설명합니다. 기본 제공 마운트 항목과 일반 전략은 `agents.sandbox.entries`에서 가져오세요. 호스티드 제공자 전략은 `agents.extensions.sandbox` 또는 제공자별 확장 패키지에서 사용할 수 있습니다. +마운트 항목은 노출할 스토리지를 정의하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 정의합니다. 기본 제공 마운트 항목과 범용 전략은 `agents.sandbox.entries` 에서 가져옵니다. 호스티드 제공업체 전략은 `agents.extensions.sandbox` 또는 제공업체별 확장 패키지에서 사용할 수 있습니다. -일반적인 마운트 옵션: +일반적인 마운트 옵션은 다음과 같습니다. -- `mount_path`: 샌드박스에서 스토리지가 나타나는 위치입니다. 상대 경로는 매니페스트 루트 아래에서 해석되고, 절대 경로는 그대로 사용됩니다. -- `read_only`: 기본값은 `True`입니다. 샌드박스가 마운트된 스토리지에 다시 써야 할 때만 `False`로 설정하세요. -- `mount_strategy`: 필수입니다. 마운트 항목과 샌드박스 백엔드 모두에 맞는 전략을 사용하세요. +- `mount_path`: 샌드박스에서 스토리지가 표시되는 위치입니다. 상대 경로는 매니페스트 루트를 기준으로 해석되며, 절대 경로는 그대로 사용됩니다. +- `read_only`: 기본값은 `True` 입니다. 샌드박스가 마운트된 스토리지에 다시 기록해야 하는 경우에만 `False` 로 설정하세요. +- `mount_strategy`: 필수 항목입니다. 마운트 항목과 샌드박스 백엔드 모두에 적합한 전략을 사용하세요. -마운트는 임시 워크스페이스 항목으로 취급됩니다. 스냅샷 및 지속성 플로우는 마운트된 원격 스토리지를 저장된 워크스페이스로 복사하는 대신, 마운트된 경로를 분리하거나 건너뜁니다. +마운트는 임시 워크스페이스 항목으로 처리됩니다. 스냅샷 및 영속성 처리 과정에서는 마운트된 원격 스토리지를 저장된 워크스페이스에 복사하지 않고 마운트된 경로를 분리하거나 건너뜁니다. -일반 로컬/컨테이너 전략: +범용 로컬/컨테이너 전략은 다음과 같습니다.
-| 전략 또는 패턴 | 사용 시점 | 참고 사항 | +| 전략 또는 패턴 | 사용하는 경우 | 참고 사항 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone`을 실행할 수 있을 때. | S3, GCS, R2, Azure Blob, Box를 지원합니다. `RcloneMountPattern`은 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3`가 있고 Mountpoint 스타일의 S3 또는 S3 호환 액세스를 원할 때. | `S3Mount` 및 `GCSMount`를 지원합니다. | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2`와 FUSE 지원이 있을 때. | `AzureBlobMount`를 지원합니다. | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 이미지에 `mount.s3files`가 있고 기존 S3 Files 마운트 대상에 접근할 수 있을 때. | `S3FilesMount`를 지원합니다. | -| `DockerVolumeMountStrategy(driver=...)` | Docker가 컨테이너 시작 전에 볼륨 드라이버 기반 마운트를 연결해야 할 때. | Docker 전용입니다. S3, GCS, R2, Azure Blob, Box는 `rclone`을 지원하며, S3와 GCS는 `mountpoint`도 지원합니다. | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone` 을 실행할 수 있는 경우입니다. | S3, GCS, R2, Azure Blob, Box를 지원합니다. `RcloneMountPattern` 은 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3` 가 있고 Mountpoint 방식으로 S3 또는 S3 호환 스토리지에 액세스하려는 경우입니다. | `S3Mount` 및 `GCSMount` 를 지원합니다. | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2` 및 FUSE 지원이 있는 경우입니다. | `AzureBlobMount` 를 지원합니다. | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 이미지에 `mount.s3files` 가 있고 기존 S3 Files 마운트 대상에 접근할 수 있는 경우입니다. | `S3FilesMount` 를 지원합니다. | +| `DockerVolumeMountStrategy(driver=...)` | 컨테이너가 시작되기 전에 Docker가 볼륨 드라이버 기반 마운트를 연결해야 하는 경우입니다. | Docker 전용입니다. S3, GCS, R2, Azure Blob, Box는 `rclone` 을 지원하며, S3와 GCS는 `mountpoint` 도 지원합니다. |
## 지원되는 호스티드 플랫폼 -호스티드 환경이 필요한 경우 동일한 `SandboxAgent` 정의를 대개 그대로 사용할 수 있으며 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트만 변경하면 됩니다. +호스티드 환경이 필요한 경우에도 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용할 수 있으며 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에서 샌드박스 클라이언트만 변경하면 됩니다. -이 저장소 체크아웃 대신 배포된 SDK를 사용하는 경우, 일치하는 패키지 extra를 통해 샌드박스 클라이언트 종속성을 설치하세요. +이 저장소의 체크아웃 대신 배포된 SDK를 사용하는 경우, 해당 패키지 extra를 통해 샌드박스 클라이언트 의존성을 설치하세요. -제공자별 설정 참고 사항과 저장소에 포함된 확장 예제 링크는 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)를 참고하세요. +저장소에 포함된 확장 코드 예제에 대한 제공업체별 설정 참고 사항과 링크는 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)를 참조하세요.
-| 클라이언트 | 설치 | 예시 | +| 클라이언트 | 설치 | 예제 | | --- | --- | --- | | `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel 실행 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | | `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare 실행 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | @@ -104,24 +106,24 @@ run_config = RunConfig(
-호스티드 샌드박스 클라이언트는 제공자별 마운트 전략을 노출합니다. 사용 중인 스토리지 제공자에 가장 적합한 백엔드와 마운트 전략을 선택하세요. +호스티드 샌드박스 클라이언트는 제공업체별 마운트 전략을 제공합니다. 스토리지 제공업체에 가장 적합한 백엔드와 마운트 전략을 선택하세요.
| 백엔드 | 마운트 참고 사항 | | --- | --- | -| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy` 같은 로컬 전략으로 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount`를 지원합니다. | -| `ModalSandboxClient` | `S3Mount`, `R2Mount`, HMAC 인증 `GCSMount`에서 `ModalCloudBucketMountStrategy`로 Modal 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | -| `CloudflareSandboxClient` | `S3Mount`, `R2Mount`, HMAC 인증 `GCSMount`에서 `CloudflareBucketMountStrategy`로 Cloudflare 버킷 마운트를 지원합니다. | -| `BlaxelSandboxClient` | `S3Mount`, `R2Mount`, `GCSMount`에서 `BlaxelCloudBucketMountStrategy`로 클라우드 버킷 마운트를 지원합니다. 또한 `agents.extensions.sandbox.blaxel`의 `BlaxelDriveMount` 및 `BlaxelDriveMountStrategy`를 통해 영구 Blaxel Drives도 지원합니다. | -| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy`로 rclone 기반 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용하세요. | -| `E2BSandboxClient` | `E2BCloudBucketMountStrategy`로 rclone 기반 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용하세요. | -| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy`로 rclone 기반 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용하세요. | -| `VercelSandboxClient` | `VercelCloudBucketMountStrategy`와 `S3Mount`를 사용한 생성 시점 전용 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트가 포함된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True`가 필요합니다. | +| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy` 같은 로컬 전략을 사용하여 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 를 지원합니다. | +| `ModalSandboxClient` | `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount` 에서 `ModalCloudBucketMountStrategy` 를 사용하는 Modal 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | +| `CloudflareSandboxClient` | `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount` 에서 `CloudflareBucketMountStrategy` 를 사용하는 Cloudflare 버킷 마운트를 지원합니다. | +| `BlaxelSandboxClient` | `S3Mount`, `R2Mount`, `GCSMount` 에서 `BlaxelCloudBucketMountStrategy` 를 사용하는 클라우드 버킷 마운트를 지원합니다. 또한 `agents.extensions.sandbox.blaxel` 의 `BlaxelDriveMount` 및 `BlaxelDriveMountStrategy` 를 사용하여 영속적 Blaxel Drive를 지원합니다. | +| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` 를 사용하는 rclone 기반 클라우드 스토리지 마운트를 지원하며, `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount` 와 함께 사용할 수 있습니다. | +| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` 를 사용하는 rclone 기반 클라우드 스토리지 마운트를 지원하며, `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount` 와 함께 사용할 수 있습니다. | +| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` 를 사용하는 rclone 기반 클라우드 스토리지 마운트를 지원하며, `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount` 와 함께 사용할 수 있습니다. | +| `VercelSandboxClient` | `S3Mount` 에서 `VercelCloudBucketMountStrategy` 를 사용하는, 생성 시점에만 적용 가능한 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True` 가 필요합니다. |
-아래 표는 각 백엔드가 직접 마운트할 수 있는 원격 스토리지 항목을 요약합니다. +다음 표에는 각 백엔드가 직접 마운트할 수 있는 원격 스토리지 항목이 요약되어 있습니다.
@@ -138,4 +140,4 @@ run_config = RunConfig(
-실행 가능한 더 많은 예제는 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴에 대해 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)를, 호스티드 샌드박스 클라이언트에 대해 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)를 둘러보세요. +실행 가능한 더 많은 코드 예제를 보려면 로컬, 코딩, 메모리, 핸드오프, 에이전트 구성 패턴은 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)에서, 호스티드 샌드박스 클라이언트는 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)에서 확인하세요. \ No newline at end of file diff --git a/docs/ko/sessions/index.md b/docs/ko/sessions/index.md index ebda1f02b7..07d4ac8eb5 100644 --- a/docs/ko/sessions/index.md +++ b/docs/ko/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 세션 -Agents SDK는 여러 에이전트 실행 간 대화 기록을 자동으로 유지하는 기본 제공 세션 메모리를 제공하여, 턴 사이에 `.to_input_list()` 를 수동으로 처리할 필요를 없애줍니다. +Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 내장 세션 메모리를 제공하므로, 턴 사이에 `.to_input_list()`를 수동으로 처리할 필요가 없습니다. -세션은 특정 세션의 대화 기록을 저장하므로, 명시적인 수동 메모리 관리 없이도 에이전트가 컨텍스트를 유지할 수 있습니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. +세션은 특정 세션의 대화 기록을 저장하여, 명시적으로 메모리를 직접 관리하지 않아도 에이전트가 컨텍스트를 유지할 수 있게 합니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. -SDK가 클라이언트 측 메모리를 관리해 주기를 원할 때 세션을 사용하세요. 세션은 동일한 실행에서 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id` 와 함께 사용할 수 없습니다. OpenAI 서버 관리형 이어가기를 원한다면 세션을 그 위에 겹쳐 사용하지 말고 해당 메커니즘 중 하나를 선택하세요. +SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용하세요. 동일한 실행에서 세션을 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용할 수 없습니다. OpenAI 서버에서 관리하는 대화 연속성을 사용하려면 세션을 추가로 적용하지 말고 이러한 메커니즘 중 하나를 선택하세요. ## 빠른 시작 @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 동일한 세션으로 인터럽트된 실행 재개 +## 동일한 세션을 사용한 인터럽션된 실행 재개 -승인을 위해 실행이 일시 중지되는 경우, 재개된 턴이 동일한 저장된 대화 기록을 이어가도록 같은 세션 인스턴스(또는 같은 기반 저장소를 가리키는 다른 세션 인스턴스)로 재개하세요. +승인을 위해 실행이 일시 중지되면 동일한 세션 인스턴스 또는 동일한 기반 스토리지를 가리키는 다른 세션 인스턴스로 재개하여, 재개된 턴이 저장된 동일한 대화 기록을 이어가도록 하세요. ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -65,29 +65,29 @@ if result.interruptions: ## 핵심 세션 동작 -세션 메모리가 활성화되면 다음과 같이 동작합니다. +세션 메모리가 활성화된 경우: -1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 조회하여 입력 항목 앞에 추가합니다. +1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 가져와 입력 항목 앞에 추가합니다. 2. **각 실행 후**: 실행 중 생성된 모든 새 항목(사용자 입력, 어시스턴트 응답, 도구 호출 등)이 세션에 자동으로 저장됩니다. -3. **컨텍스트 보존**: 동일한 세션으로 이어지는 각 실행에는 전체 대화 기록이 포함되어 에이전트가 컨텍스트를 유지할 수 있습니다. +3. **컨텍스트 보존**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. -이를 통해 `.to_input_list()` 를 수동으로 호출하고 실행 간 대화 상태를 관리할 필요가 없어집니다. +따라서 `.to_input_list()`를 수동으로 호출하고 실행 사이의 대화 상태를 관리할 필요가 없습니다. -## 기록과 새 입력 병합 제어 +## 기록과 새 입력의 병합 방식 제어 -세션을 전달하면 러너는 일반적으로 모델 입력을 다음과 같이 준비합니다. +세션을 전달하면 일반적으로 러너는 다음 순서로 모델 입력을 준비합니다. -1. 세션 기록(`session.get_items(...)` 에서 조회) +1. 세션 기록(`session.get_items(...)`에서 가져옴) 2. 새 턴 입력 -모델 호출 전에 이 병합 단계를 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 을 사용하세요. 콜백은 두 개의 목록을 받습니다. +모델 호출 전에 이 병합 단계를 맞춤 설정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 콜백은 다음 두 목록을 받습니다. -- `history`: 조회된 세션 기록(이미 입력 항목 형식으로 정규화됨) +- `history`: 가져온 세션 기록(이미 입력 항목 형식으로 정규화됨) - `new_input`: 현재 턴의 새 입력 항목 -모델에 전송할 최종 입력 항목 목록을 반환하세요. +모델로 전송할 최종 입력 항목 목록을 반환하세요. -콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속한 항목만 지속 저장합니다. 따라서 이전 기록을 재정렬하거나 필터링해도 이전 세션 항목이 새 입력으로 다시 저장되지는 않습니다. +콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속하는 항목만 저장합니다. 따라서 이전 기록을 재정렬하거나 필터링해도 기존 세션 항목이 새로운 입력으로 다시 저장되지 않습니다. ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -세션이 항목을 저장하는 방식은 바꾸지 않으면서 기록에 대한 사용자 지정 가지치기, 재정렬 또는 선택적 포함이 필요할 때 사용하세요. 모델 호출 직전에 더 늦은 최종 처리 단계가 필요하다면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] 를 사용하세요. +세션의 항목 저장 방식을 변경하지 않으면서 기록을 맞춤형으로 정리하거나 재정렬하거나 선택적으로 포함해야 할 때 이 기능을 사용하세요. 모델 호출 직전에 최종 처리 단계가 더 필요하면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. -## 조회 기록 제한 +## 가져올 기록 제한 -각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings] 를 사용하세요. +각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]를 사용하세요. -- `SessionSettings(limit=None)` (기본값): 사용 가능한 모든 세션 항목 조회 -- `SessionSettings(limit=N)`: 가장 최근 `N` 개 항목만 조회 +- `SessionSettings(limit=None)`(기본값): 사용 가능한 모든 세션 항목을 가져옵니다 +- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 가져옵니다 -[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] 를 통해 실행별로 이를 적용할 수 있습니다. +[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]를 통해 실행별로 적용할 수 있습니다. ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -세션 구현이 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings` 는 해당 실행에 대해 `None` 이 아닌 값을 재정의합니다. 이는 긴 대화에서 세션의 기본 동작은 변경하지 않으면서 조회 크기를 제한하고 싶을 때 유용합니다. +세션 구현에서 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`는 해당 실행에서 `None`이 아닌 값을 재정의합니다. 이는 세션의 기본 동작을 변경하지 않고 가져올 기록의 크기를 제한하려는 긴 대화에 유용합니다. ## 메모리 작업 ### 기본 작업 -세션은 대화 기록 관리를 위한 여러 작업을 지원합니다. +세션은 대화 기록을 관리하기 위한 여러 작업을 지원합니다. ```python from agents import SQLiteSession @@ -167,7 +167,7 @@ await session.clear_session() ### 수정을 위한 pop_item 사용 -`pop_item` 메서드는 대화의 마지막 항목을 되돌리거나 수정하고 싶을 때 특히 유용합니다. +대화의 마지막 항목을 실행 취소하거나 수정하려는 경우 `pop_item` 메서드가 특히 유용합니다. ```python from agents import Agent, Runner, SQLiteSession @@ -196,34 +196,34 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 기본 제공 세션 구현 +## 내장 세션 구현 SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다. -### 기본 제공 세션 구현 선택 +### 내장 세션 구현 선택 -아래의 상세 예제를 읽기 전에 시작점을 고르는 데 이 표를 사용하세요. +아래의 상세한 예제를 읽기 전에 이 표를 참고하여 시작점을 선택하세요. | 세션 유형 | 적합한 용도 | 참고 | | --- | --- | --- | -| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 기본 제공, 경량, 파일 기반 또는 인메모리 | -| `AsyncSQLiteSession` | `aiosqlite` 기반 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | -| `RedisSession` | 여러 워커/서비스 간 공유 메모리 | 저지연 분산 배포에 적합 | -| `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 함께 동작 | -| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 스토리지가 필요한 앱 | 비동기 pymongo; 순서 보장을 위한 원자적 시퀀스 카운터 | -| `DaprSession` | Dapr 사이드카를 사용하는 클라우드 네이티브 배포 | 여러 상태 저장소와 TTL 및 일관성 제어 지원 | -| `OpenAIConversationsSession` | OpenAI의 서버 관리형 스토리지 | OpenAI Conversations API 기반 기록 | -| `OpenAIResponsesCompactionSession` | 자동 압축이 필요한 긴 대화 | 다른 세션 백엔드를 감싸는 래퍼 | -| `AdvancedSQLiteSession` | SQLite와 분기/분석 | 더 많은 기능 세트; 전용 페이지 참조 | -| `EncryptedSession` | 다른 세션 위에 암호화 + TTL 적용 | 래퍼; 먼저 하위 백엔드 선택 | +| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 내장형 경량 구현, 파일 기반 또는 인메모리 | +| `AsyncSQLiteSession` | `aiosqlite`를 사용하는 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | +| `RedisSession` | 여러 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | +| `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스에서 작동 | +| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 스토리지가 필요한 앱 | 비동기 pymongo 사용, 순서 보존을 위한 원자적 시퀀스 카운터 | +| `DaprSession` | Dapr 사이드카를 사용하는 클라우드 네이티브 배포 | 여러 상태 스토어와 TTL 및 일관성 제어 지원 | +| `OpenAIConversationsSession` | OpenAI에서 서버가 관리하는 스토리지 | OpenAI Conversations API 기반 기록 | +| `OpenAIResponsesCompactionSession` | 자동 압축을 사용하는 긴 대화 | 다른 세션 백엔드를 감싸는 래퍼 | +| `AdvancedSQLiteSession` | SQLite와 분기/분석 기능 | 더 많은 기능을 제공하며 전용 페이지 참고 | +| `EncryptedSession` | 다른 세션에 암호화와 TTL 추가 | 래퍼이므로 먼저 기반 백엔드 선택 필요 | -일부 구현에는 추가 세부 정보를 담은 전용 페이지가 있으며, 해당 하위 섹션에 링크되어 있습니다. +일부 구현에는 추가 세부 정보를 제공하는 전용 페이지가 있으며, 해당 하위 섹션에 링크되어 있습니다. -ChatKit용 Python 서버를 구현하는 경우, ChatKit의 스레드 및 항목 지속성을 위해 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession` 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만, ChatKit의 스토어를 그대로 대체할 수 있는 것은 아닙니다. [`chatkit-python` 의 ChatKit 데이터 스토어 구현 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참조하세요. +ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성에는 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만 ChatKit 스토어를 그대로 대체할 수는 없습니다. [`ChatKit 데이터 스토어 구현에 관한 chatkit-python 가이드`](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참고하세요. ### OpenAI Conversations API 세션 -`OpenAIConversationsSession` 을 통해 [OpenAI의 Conversations API](https://platform.openai.com/docs/api-reference/conversations)를 사용하세요. +`OpenAIConversationsSession`을 통해 [OpenAI의 Conversations API](https://platform.openai.com/docs/api-reference/conversations)를 사용하세요. ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -259,7 +259,7 @@ print(result.final_output) # "California" ### OpenAI Responses 압축 세션 -Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession` 을 사용하세요. 이 세션은 하위 세션을 감싸며, `should_trigger_compaction` 에 따라 각 턴 이후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession` 을 이것으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. +Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 구현은 기반 세션을 감싸며 `should_trigger_compaction`을 기준으로 각 턴 이후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 구현으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. #### 일반적인 사용법(자동 압축) @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -기본적으로 압축은 후보 임계값에 도달하면 각 턴 이후 실행됩니다. +기본적으로 압축 후보 임계값에 도달하면 각 턴 이후 압축이 실행됩니다. -`compaction_mode="previous_response_id"` 는 이미 Responses API 응답 ID로 턴을 체이닝하고 있을 때 가장 잘 동작합니다. `compaction_mode="input"` 은 대신 현재 세션 항목으로부터 압축 요청을 다시 구성합니다. 이는 응답 체인을 사용할 수 없거나 세션 내용을 신뢰할 수 있는 기준으로 삼고 싶을 때 유용합니다. 기본값인 `"auto"` 는 사용 가능한 가장 안전한 옵션을 선택합니다. +Responses API 응답 ID로 이미 턴을 연결하고 있다면 `compaction_mode="previous_response_id"`가 가장 적합합니다. 반면 `compaction_mode="input"`은 현재 세션 항목에서 압축 요청을 다시 구성하므로, 응답 체인을 사용할 수 없거나 세션 내용을 기준 데이터로 사용하려는 경우에 유용합니다. 기본값인 `"auto"`는 사용 가능한 옵션 중 가장 안전한 것을 선택합니다. -에이전트가 `ModelSettings(store=False)` 로 실행되는 경우, Responses API는 나중에 조회할 수 있도록 마지막 응답을 보관하지 않습니다. 이 무상태 구성에서는 기본 `"auto"` 모드가 `previous_response_id` 에 의존하지 않고 입력 기반 압축으로 폴백합니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)를 참조하세요. +에이전트가 `ModelSettings(store=False)`로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보관하지 않습니다. 이러한 무상태 구성에서는 기본 `"auto"` 모드가 `previous_response_id`에 의존하지 않고 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)를 참고하세요. -#### 스트리밍을 차단할 수 있는 자동 압축 +#### 자동 압축으로 인한 스트리밍 차단 -압축은 세션 기록을 지우고 다시 쓰므로, SDK는 실행이 완료된 것으로 간주하기 전에 압축이 끝나기를 기다립니다. 스트리밍 모드에서는 압축 작업이 무거운 경우 마지막 출력 토큰 이후에도 `run.stream_events()` 가 몇 초 동안 열린 상태로 남아 있을 수 있습니다. +압축은 세션 기록을 지우고 다시 작성하므로, SDK는 실행이 완료된 것으로 처리하기 전에 압축이 끝날 때까지 기다립니다. 스트리밍 모드에서는 압축 작업이 많은 경우 마지막 출력 토큰 이후에도 `run.stream_events()`가 몇 초 동안 열린 상태로 유지될 수 있습니다. -저지연 스트리밍이나 빠른 턴 전환을 원한다면 자동 압축을 비활성화하고 턴 사이(또는 유휴 시간)에 직접 `run_compaction()` 을 호출하세요. 자체 기준에 따라 언제 압축을 강제로 실행할지 결정할 수 있습니다. +지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요하면 자동 압축을 비활성화하고 턴 사이 또는 유휴 시간에 `run_compaction()`을 직접 호출하세요. 자체 기준에 따라 압축을 강제로 실행할 시점을 결정할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 비동기 SQLite 세션 -`aiosqlite` 기반 SQLite 지속성이 필요할 때 `AsyncSQLiteSession` 을 사용하세요. +`aiosqlite` 기반의 SQLite 영속성이 필요하면 `AsyncSQLiteSession`을 사용하세요. ```bash pip install aiosqlite @@ -349,7 +349,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis 세션 -여러 워커 또는 서비스 간 공유 세션 메모리에는 `RedisSession` 을 사용하세요. +여러 워커 또는 서비스에서 세션 메모리를 공유하려면 `RedisSession`을 사용하세요. ```bash pip install openai-agents[redis] @@ -365,11 +365,14 @@ session = RedisSession.from_url( url="redis://localhost:6379/0", ) result = await Runner.run(agent, "Hello", session=session) +await session.close() ``` +`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후에는 세션이 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`를 발생시킵니다. 반복적으로 또는 동시에 `close()`를 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하고 있다면 `redis_client=...`를 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`는 아무 작업도 하지 않으며 호출자가 클라이언트 소유권을 유지하고 세션도 계속 사용할 수 있습니다. + ### SQLAlchemy 세션 -SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션 환경에 적합한 Agents SDK 세션 지속성입니다. +SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션 수준의 Agents SDK 세션 영속성 구현입니다. ```python from agents.extensions.memory import SQLAlchemySession @@ -387,11 +390,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참조하세요. +자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참고하세요. ### Dapr 세션 -이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 여러 상태 저장소 백엔드 간에 이동할 수 있는 세션 스토리지를 원할 때 `DaprSession` 을 사용하세요. +이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 다양한 상태 스토어 백엔드 간에 이동할 수 있는 세션 스토리지가 필요하면 `DaprSession`을 사용하세요. ```bash pip install openai-agents[dapr] @@ -414,16 +417,17 @@ async with DaprSession.from_address( 참고: -- `from_address(...)` 는 Dapr 클라이언트를 생성하고 수명 주기를 관리합니다. 앱에서 이미 Dapr 클라이언트를 관리하고 있다면 `dapr_client=...` 로 `DaprSession(...)` 을 직접 생성하세요. -- 기반 상태 저장소가 TTL을 지원하는 경우 오래된 세션 데이터가 자동으로 만료되도록 `ttl=...` 을 전달하세요. -- 더 강한 쓰기 후 읽기 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG` 을 전달하세요. -- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발에서는 `dapr_address` 에서 사용하는 gRPC 포트뿐 아니라 `--dapr-http-port 3500` 도 함께 지정해 Dapr를 시작하세요. -- 로컬 컴포넌트와 문제 해결을 포함한 전체 설정 안내는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참조하세요. +- `from_address(...)`는 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리하고 있다면 `dapr_client=...`를 사용하여 `DaprSession(...)`을 직접 생성하세요. +- 컨텍스트에서 나가거나 `close()`를 호출하면 클라이언트를 소유한 세션은 종료 상태가 됩니다. 이후 세션 작업은 `RuntimeError`를 발생시키지만, 반복적으로 또는 동시에 `close()`를 호출해도 안전합니다. 주입된 클라이언트를 사용하면 `close()`는 아무 작업도 하지 않으며 세션을 계속 사용할 수 있습니다. +- 상태 스토어에서 TTL을 지원하는 경우 오래된 세션 데이터가 자동으로 만료되도록 하려면 `ttl=...`을 전달하세요. +- 쓰기 직후 읽기에 대해 더 강한 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG`을 전달하세요. +- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발 환경에서는 `dapr_address`에 사용되는 gRPC 포트뿐만 아니라 `--dapr-http-port 3500`도 지정하여 Dapr를 시작하세요. +- 로컬 구성 요소와 문제 해결 방법을 포함한 전체 설정 과정은 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참고하세요. ### MongoDB 세션 -이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 `MongoDBSession` 을 사용하세요. +이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 `MongoDBSession`을 사용하세요. ```bash pip install openai-agents[mongodb] @@ -448,14 +452,14 @@ await session.close() 참고: -- `from_uri(...)` 는 `AsyncMongoClient` 를 생성하고 수명 주기를 관리하며, `session.close()` 시 닫습니다. 애플리케이션에서 이미 클라이언트를 관리하고 있다면 `client=...` 로 `MongoDBSession(...)` 을 직접 생성하세요. 이 경우 `session.close()` 는 아무 작업도 하지 않으며 수명 주기는 호출자에게 남아 있습니다. -- 다른 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)` 에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결하세요. -- 두 개의 컬렉션이 사용되며, 두 이름 모두 `sessions_collection=` (기본값 `agent_sessions`) 및 `messages_collection=` (기본값 `agent_messages`) 로 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 각 메시지 문서는 단조 증가하는 `seq` 카운터를 포함하여 동시 작성자와 프로세스 간 순서를 보존합니다. -- 첫 실행 전에 연결을 확인하려면 `await session.ping()` 을 사용하세요. +- `from_uri(...)`는 `AsyncMongoClient`를 생성하고 소유하며 `session.close()` 호출 시 이를 닫습니다. 애플리케이션에서 이미 클라이언트를 관리하고 있다면 `client=...`를 사용하여 `MongoDBSession(...)`을 직접 생성하세요. 이 경우 `session.close()`는 아무 작업도 하지 않으며 수명 주기는 호출자가 관리합니다. +- 다른 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결할 수 있습니다. +- 두 개의 컬렉션이 사용되며, 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`)과 `messages_collection=`(기본값 `agent_messages`)을 통해 설정할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 각 메시지 문서에는 단조 증가하는 `seq` 카운터가 포함되어 동시 작성자와 여러 프로세스 간에도 순서를 보존합니다. +- 첫 실행 전에 연결을 확인하려면 `await session.ping()`을 사용하세요. ### 고급 SQLite 세션 -대화 분기, 사용량 분석, 구조화된 쿼리를 지원하는 향상된 SQLite 세션입니다. +대화 분기, 사용량 분석 및 구조화된 쿼리를 제공하는 향상된 SQLite 세션입니다. ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -475,11 +479,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참조하세요. +자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참고하세요. ### 암호화된 세션 -모든 세션 구현을 위한 투명한 암호화 래퍼입니다. +모든 세션 구현에 적용할 수 있는 투명한 암호화 래퍼입니다. ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -502,15 +506,15 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -자세한 문서는 [암호화된 세션](encrypted_session.md)을 참조하세요. +자세한 문서는 [암호화된 세션](encrypted_session.md)을 참고하세요. ### 기타 세션 유형 -기본 제공 옵션이 몇 가지 더 있습니다. `examples/memory/` 및 `extensions/memory/` 아래의 소스 코드를 참조하세요. +그 밖에도 몇 가지 내장 옵션이 있습니다. `examples/memory/`와 `extensions/memory/`의 소스 코드를 참고하세요. ## 운영 패턴 -### 세션 ID 명명 +### 세션 ID 명명법 대화를 정리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. @@ -518,18 +522,18 @@ result = await Runner.run(agent, "Hello", session=session) - 스레드 기반: `"thread_abc123"` - 컨텍스트 기반: `"support_ticket_456"` -### 메모리 지속성 +### 메모리 영속성 -- 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`) 사용 -- 지속 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) 사용 -- `aiosqlite` 기반 구현이 필요할 때는 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) 사용 -- 공유 저지연 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`) 사용 -- SQLAlchemy가 지원하는 기존 데이터베이스가 있는 프로덕션 시스템에는 SQLAlchemy 기반 세션(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) 사용 -- 이미 MongoDB를 사용하거나 다중 프로세스, 수평 확장 가능한 세션 스토리지가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) 사용 -- 기본 제공 텔레메트리, 트레이싱, 데이터 격리와 함께 30개 이상의 데이터베이스 백엔드를 지원하는 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 -- OpenAI Conversations API에 기록을 저장하고 싶다면 OpenAI가 호스팅하는 스토리지(`OpenAIConversationsSession()`) 사용 -- 투명한 암호화와 TTL 기반 만료로 모든 세션을 감싸려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 -- 더 고급 사용 사례에는 다른 프로덕션 시스템(예: Django)을 위한 사용자 지정 세션 백엔드 구현 고려 +- 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`)를 사용합니다 +- 영구 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)를 사용합니다 +- `aiosqlite` 기반 구현이 필요하면 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)를 사용합니다 +- 공유되는 저지연 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`)을 사용합니다 +- SQLAlchemy에서 지원하는 기존 데이터베이스가 있는 프로덕션 시스템에는 SQLAlchemy 기반 세션(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)을 사용합니다 +- 이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)을 사용합니다 +- 내장된 텔레메트리, 트레이싱 및 데이터 격리 기능과 30개 이상의 데이터베이스 백엔드를 지원하는 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 스토어 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)을 사용합니다 +- OpenAI Conversations API에 기록을 저장하려면 OpenAI 호스팅 스토리지(`OpenAIConversationsSession()`)를 사용합니다 +- 모든 세션에 투명한 암호화와 TTL 기반 만료를 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`)을 사용합니다 +- 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 맞춤형 세션 백엔드 구현을 고려합니다 ### 여러 세션 @@ -577,7 +581,7 @@ result2 = await Runner.run( ## 전체 예제 -세션 메모리가 동작하는 방식을 보여주는 전체 예제입니다. +다음은 세션 메모리의 실제 동작을 보여 주는 전체 예제입니다. ```python import asyncio @@ -639,9 +643,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 사용자 지정 세션 구현 +## 맞춤형 세션 구현 -[`Session`][agents.memory.session.Session] 프로토콜을 따르는 클래스를 만들어 자체 세션 메모리를 구현할 수 있습니다. +[`Session`][agents.memory.session.Session] 프로토콜을 따르는 클래스를 생성하여 자체 세션 메모리를 구현할 수 있습니다. ```python from agents.memory.session import SessionABC @@ -692,11 +696,11 @@ result = await Runner.run( |---------|-------------| | [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 위한 Django ORM 기반 세션 | -세션 구현을 만들었다면 이곳에 추가할 수 있도록 문서 PR을 자유롭게 제출해 주세요! +세션 구현을 개발했다면 여기에 추가할 수 있도록 문서 PR을 자유롭게 제출해 주세요! -## API 참조 +## API 레퍼런스 -자세한 API 문서는 다음을 참조하세요. +자세한 API 문서는 다음을 참고하세요. - [`Session`][agents.memory.session.Session] - 프로토콜 인터페이스 - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 구현 @@ -706,6 +710,6 @@ result = await Runner.run( - [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 기반 세션 구현 - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy 기반 구현 - [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 기반 세션 구현 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 상태 저장소 구현 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기와 분석을 지원하는 향상된 SQLite +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 상태 스토어 구현 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기 및 분석 기능을 갖춘 향상된 SQLite - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 모든 세션을 위한 암호화 래퍼 \ No newline at end of file diff --git a/docs/ko/tools.md b/docs/ko/tools.md index 97cfb7a177..420a1034ba 100644 --- a/docs/ko/tools.md +++ b/docs/ko/tools.md @@ -4,31 +4,31 @@ search: --- # 도구 -도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용 등의 작업을 수행할 수 있습니다. SDK는 다음과 같은 다섯 가지 카테고리를 지원합니다. +도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용 등의 작업을 수행할 수 있습니다. SDK는 다음 다섯 가지 카테고리를 지원합니다. -- OpenAI 호스티드 툴: OpenAI 서버에서 모델과 함께 실행됩니다. -- 로컬/런타임 실행 도구: `ComputerTool`과 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`은 로컬 또는 호스티드 컨테이너에서 실행될 수 있습니다. -- Function calling: 모든 Python 함수를 도구로 래핑합니다. +- 호스티드 OpenAI 도구: OpenAI 서버에서 모델과 함께 실행됩니다. +- 로컬/런타임 실행 도구: `ComputerTool`과 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`은 로컬 또는 호스티드 컨테이너에서 실행할 수 있습니다. +- 함수 호출: 모든 Python 함수를 도구로 래핑합니다. - Agents as tools: 전체 핸드오프 없이 에이전트를 호출 가능한 도구로 노출합니다. -- 실험적 기능: Codex 도구: 도구 호출에서 워크스페이스 범위의 Codex 작업을 실행합니다. +- 실험적 기능: Codex 도구: 도구 호출에서 작업 공간 범위의 Codex 작업을 실행합니다. ## 도구 유형 선택 -이 페이지를 카탈로그로 활용한 다음, 제어하는 런타임과 일치하는 섹션으로 이동하세요. +이 페이지를 카탈로그로 활용한 다음, 제어하는 런타임에 해당하는 섹션으로 이동하세요. -| 원하는 작업 | 시작 위치 | +| 원하는 작업 | 시작 지점 | | --- | --- | -| OpenAI 관리형 도구 사용(웹 검색, 파일 검색, Code Interpreter, 호스티드 MCP, 이미지 생성) | [호스티드 툴](#hosted-tools) | +| OpenAI 관리형 도구(웹 검색, 파일 검색, Code Interpreter, 호스티드 MCP, 이미지 생성) 사용 | [호스티드 툴](#hosted-tools) | | 도구 검색을 사용하여 대규모 도구 표면을 런타임까지 지연 | [호스티드 툴 검색](#hosted-tool-search) | -| 생성된 JavaScript에서 여러 도구 호출 조정 | [프로그래매틱 도구 호출](#programmatic-tool-calling) | +| 생성된 JavaScript에서 여러 도구 호출 조정 | [프로그래밍 방식 도구 호출](#programmatic-tool-calling) | | 자체 프로세스 또는 환경에서 도구 실행 | [로컬 런타임 도구](#local-runtime-tools) | | Python 함수를 도구로 래핑 | [함수 도구](#function-tools) | | 핸드오프 없이 한 에이전트가 다른 에이전트를 호출하도록 설정 | [Agents as tools](#agents-as-tools) | -| 에이전트에서 워크스페이스 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | +| 에이전트에서 작업 공간 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | ## 호스티드 툴 -OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 다음과 같은 몇 가지 기본 제공 도구를 제공합니다. +OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 몇 가지 기본 제공 도구를 제공합니다. - [`WebSearchTool`][agents.tool.WebSearchTool]을 사용하면 에이전트가 웹을 검색할 수 있습니다. - [`FileSearchTool`][agents.tool.FileSearchTool]을 사용하면 OpenAI 벡터 스토어에서 정보를 검색할 수 있습니다. @@ -40,7 +40,7 @@ OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponse 고급 호스티드 검색 옵션: -- `FileSearchTool`은 `vector_store_ids` 및 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. +- `FileSearchTool`은 `vector_store_ids`와 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. - `WebSearchTool`은 `filters`, `user_location`, `search_context_size`를 지원합니다. ```python @@ -64,9 +64,9 @@ async def main(): ### 호스티드 툴 검색 -도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 표면을 런타임까지 지연하므로, 모델은 현재 턴에 필요한 하위 집합만 로드합니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많고 모든 도구를 미리 노출하지 않으면서 도구 스키마 토큰을 줄이고자 할 때 유용합니다. +도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 표면의 로드를 런타임까지 지연하여 현재 턴에 필요한 일부 도구만 로드할 수 있습니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많고 모든 도구를 처음부터 노출하지 않으면서 도구 스키마 토큰을 줄이려는 경우에 유용합니다. -에이전트를 구축할 때 후보 도구가 이미 정해져 있다면 호스티드 툴 검색으로 시작하세요. 애플리케이션에서 로드할 항목을 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행 도구 검색도 지원하지만, 표준 `Runner`는 이 모드를 자동으로 실행하지 않습니다. +에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 툴 검색부터 사용하세요. 애플리케이션에서 로드할 항목을 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행형 도구 검색도 지원하지만, 표준 `Runner`는 이 모드를 자동으로 실행하지 않습니다. ```python from typing import Annotated @@ -112,25 +112,25 @@ print(result.final_output) 알아둘 사항: - 호스티드 툴 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원 여부는 `openai>=2.25.0`에 따라 달라집니다. -- 에이전트에서 지연 로딩 표면을 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. +- 에이전트에서 지연 로드 표면을 구성할 때 정확히 하나의 `ToolSearchTool()`을 추가하세요. - 검색 가능한 표면에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`가 포함됩니다. -- 지연 로딩 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 구성에서도 모델이 필요할 때 적절한 그룹을 로드하도록 `ToolSearchTool()`을 사용할 수 있습니다. -- `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름 및 설명 아래에 그룹화합니다. 일반적으로 `crm`, `billing`, `shipping`처럼 관련 도구가 많은 경우 가장 적합합니다. +- 지연 로드 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 설정에서도 모델이 필요할 때 적절한 그룹을 로드하도록 `ToolSearchTool()`을 사용할 수 있습니다. +- `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름과 설명 아래에 그룹화합니다. `crm`, `billing`, `shipping`처럼 서로 관련된 도구가 많은 경우 일반적으로 가장 적합합니다. - OpenAI의 공식 모범 사례 지침은 [가능한 경우 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. -- 가능하면 개별적으로 지연된 여러 함수보다 네임스페이스나 호스티드 MCP 서버를 우선 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 표면을 제공하고 토큰을 더 많이 절약할 수 있습니다. -- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`가 없는 도구는 즉시 호출할 수 있지만, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. -- 일반적으로 각 네임스페이스를 비교적 작게 유지하고, 가급적 함수 수를 10개 미만으로 제한하세요. +- 가능하면 개별적으로 지연되는 여러 함수보다 네임스페이스 또는 호스티드 MCP 서버를 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 표면을 제공하고 토큰을 더 많이 절약할 수 있습니다. +- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`가 없는 도구는 즉시 호출할 수 있으며, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. +- 일반적으로 각 네임스페이스는 비교적 작게 유지하며, 함수 수는 10개 미만이 이상적입니다. - 이름이 지정된 `tool_choice`는 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 사용하세요. -- `ToolSearchTool(execution="client")`는 수동 Responses 오케스트레이션용입니다. 모델이 클라이언트에서 실행되는 `tool_search_call`을 내보내면 표준 `Runner`는 이를 대신 실행하지 않고 예외를 발생시킵니다. -- 도구 검색 활동은 전용 항목 및 이벤트 유형과 함께 [`RunResult.new_items`](results.md#new-items) 및 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 표시됩니다. -- 네임스페이스 기반 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 코드 예제는 `examples/tools/tool_search.py`를 참조하세요. +- `ToolSearchTool(execution="client")`는 수동 Responses 오케스트레이션을 위한 것입니다. 모델이 클라이언트 실행형 `tool_search_call`을 내보내면 표준 `Runner`는 이를 대신 실행하지 않고 예외를 발생시킵니다. +- 도구 검색 활동은 [`RunResult.new_items`](results.md#new-items)와 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 전용 항목 및 이벤트 유형으로 표시됩니다. +- 네임스페이스 기반 로드와 최상위 지연 도구를 모두 다루는 완전한 실행 가능 코드 예제는 `examples/tools/tool_search.py`를 참조하세요. - 공식 플랫폼 가이드: [도구 검색](https://developers.openai.com/api/docs/guides/tools-tool-search) -### 프로그래매틱 도구 호출 +### 프로그래밍 방식 도구 호출 -프로그래매틱 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 그 출력을 결합하며, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델 왕복을 수행하지 않고도 루프, 분기, 병렬 호출 또는 중간 계산을 활용하는 범위가 제한된 워크플로에 유용합니다. +프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 출력을 결합하고, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델을 왕복하지 않고도 반복, 분기, 병렬 호출 또는 중간 계산을 활용할 수 있는 제한된 워크플로에 유용합니다. -생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. 이 환경에는 Node.js API, 파일 시스템 또는 네트워크 액세스, 영구 프로세스가 없습니다. 프로그램은 명시적으로 허용한 도구와만 상호 작용할 수 있습니다. +생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. Node.js API, 파일 시스템 또는 네트워크에 접근할 수 없으며 영구 프로세스도 제공되지 않습니다. 프로그램은 명시적으로 허용한 도구와만 상호 작용할 수 있습니다. ```python from pydantic import BaseModel @@ -167,21 +167,22 @@ print(result.final_output) 알아둘 사항: -- 프로그래매틱 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. `ProgrammaticToolCallingTool()` 및 `tool_choice="programmatic_tool_calling"`은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. -- 에이전트에는 `ProgrammaticToolCallingTool()`을 최대 하나만 추가하세요. 에이전트는 프로그래밍 방식으로 호출 가능한 도구, `ToolSearchTool()` 또는 프롬프트로 관리되는 도구 표면 중 하나 이상도 노출해야 합니다. -- `allowed_callers`는 도구를 호출할 수 있는 방식을 제어합니다. 생략하면 모델의 직접 호출만 허용됩니다. 프로그램에서만 액세스하려면 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`을 사용하세요. -- 이 기능을 선택적으로 사용할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸 및 패치 적용 도구는 `allowed_callers`를 직접 노출합니다. 호스티드 MCP와 Code Interpreter의 경우 `tool_config` 내부에 `allowed_callers`를 설정하세요. -- `@function_tool(allowed_callers=[...])`의 경우 Pydantic 모델, TypedDict 또는 데이터 클래스와 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 값이 프로그램에 반환되기 전에 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없다면 `output_type=...`을 사용하고, 엄격한 객체 스키마가 이미 있다면 하위 수준의 우회 수단인 `output_json_schema={...}`를 사용하세요. `output_type`과 `output_json_schema`는 함께 사용할 수 없습니다. 일반 `str`, `Any`, `None` 반환은 타입이 지정되지 않은 상태로 유지됩니다. -- 프로그램 소유 SDK 도구에서도 일반적인 Runner 수명 주기가 계속 사용됩니다. 도구 입력 및 출력 가드레일, 훅, 시간 제한, 동시성 제한, 재시도, 승인, 세션, `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출의 프로그램 호출자 관계를 유지합니다. -- 승인이 필요하거나 영향이 큰 도구는 일반적으로 직접 호출로 유지하는 것이 좋습니다. 그러면 더 큰 프로그램의 일부가 되기 전에 사람이 각 작업을 검토할 수 있습니다. 프로그램 소유 호출이 승인을 위해 일시 중지되면 `RunState`를 통해 인터럽션(중단 처리)을 해결하고 평소와 같이 원래 실행을 재개하세요. -- 프로그래매틱 도구 호출은 [호스티드 툴 검색](#hosted-tool-search)과 함께 사용할 수 있습니다. 생성된 프로그램이 지연된 도구를 호출하려면 먼저 모델이 해당 도구를 로드해야 합니다. -- `program` 항목과 프로그램 소유 하위 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 일치하는 `program_output`은 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items) 및 [스트리밍](streaming.md#run-item-event-names)을 참조하세요. -- 완전한 동시 실행 재고 계획 코드 예제는 `examples/tools/programmatic_tool_calling.py`를 참조하세요. -- 공식 플랫폼 가이드: [프로그래매틱 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) +- 프로그래밍 방식 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. `ProgrammaticToolCallingTool()`과 `tool_choice="programmatic_tool_calling"`은 Chat Completions 모델 및 Responses가 아닌 백엔드에서 거부됩니다. +- 에이전트에는 `ProgrammaticToolCallingTool()`을 최대 하나만 추가하세요. 에이전트는 프로그래밍 방식으로 호출할 수 있는 도구를 하나 이상 노출하거나, 네임스페이스, 지연 함수 또는 지연된 호스티드 MCP 서버를 기반으로 하는 `ToolSearchTool()`을 제공하거나, 불투명한 프롬프트 관리형 도구 표면을 제공해야 합니다. 검색 가능한 표면이 없는 단독 `ToolSearchTool()`은 거부됩니다. +- `allowed_callers`는 도구를 호출할 수 있는 방식을 제어합니다. 생략하면 모델의 직접 호출만 허용됩니다. 프로그램에서만 접근하도록 하려면 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`을 사용하세요. +- 이 기능을 선택적으로 사용할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸, 패치 적용 도구는 `allowed_callers`를 직접 노출합니다. 호스티드 MCP와 Code Interpreter의 경우 `tool_config` 내부에서 `allowed_callers`를 설정하세요. +- `@function_tool(allowed_callers=[...])`의 경우 Pydantic 모델, TypedDict 또는 dataclass와 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 값이 프로그램에 반환되기 전에 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없다면 `output_type=...`을 사용하고, 이미 엄격한 객체 스키마가 있다면 하위 수준의 우회 수단인 `output_json_schema={...}`를 사용하세요. `output_type`과 `output_json_schema`는 함께 사용할 수 없습니다. 일반 `str`, `Any`, `None` 반환은 유형이 지정되지 않은 상태로 유지됩니다. 스키마를 기반으로 하는 프로그램 소유 호출에서는 자유 형식 텍스트가 출력 스키마를 충족하지 않으므로 기본 실패 포매터가 비활성화됩니다. 따라서 스키마를 준수하는 JSON을 반환하는 사용자 지정 `failure_error_function`을 제공하지 않으면 핸들러 예외가 전파됩니다. +- 프로그램 소유 SDK 도구에도 일반적인 Runner 수명 주기가 그대로 적용됩니다. 도구 입력 및 출력 가드레일, 훅, 시간 제한, 동시성 제한, 승인, 세션, `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출과 프로그램 호출자의 관계를 보존합니다. +- `ProgrammaticToolCallingTool()`이 있으면 프로그램이 실행되기 전이라도 모델 요청 재시도에 더 엄격한 재실행 안전성 경계가 적용됩니다. SDK는 이러한 요청에 대해 제공자 관리형 재시도와 WebSocket 사전 이벤트 재시도를 비활성화합니다. Runner 재시도 정책은 제공자의 지침이 재실행해도 안전하다고 명시적으로 표시한 경우에만 재시도합니다. `retry_policies.network_error()`만으로는 이 경계를 재정의하지 않습니다. +- 승인에 민감하거나 영향이 큰 도구는 일반적으로 직접 호출로 유지하는 것이 좋습니다. 그러면 더 큰 프로그램의 일부가 되기 전에 각 작업을 사람이 검토할 수 있습니다. 프로그램 소유 호출이 승인을 위해 일시 중지되면 평소와 같이 `RunState`를 통해 인터럽션(중단 처리)을 해결하고 원래 실행을 재개하세요. +- 프로그래밍 방식 도구 호출은 [호스티드 툴 검색](#hosted-tool-search)과 함께 사용할 수 있습니다. 생성된 프로그램이 지연된 도구를 호출하려면 모델이 먼저 해당 도구를 로드해야 합니다. +- `program` 항목과 일반적인 프로그램 소유 하위 도구 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 이에 대응하는 `program_output`은 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 호스티드 MCP 승인 요청과 도구 카탈로그에는 대신 특수 MCP 항목과 스트림 이벤트가 사용됩니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items)와 [스트리밍](streaming.md#run-item-event-names)을 참조하세요. +- 완전한 동시성 재고 계획 코드 예제는 `examples/tools/programmatic_tool_calling.py`를 참조하세요. +- 공식 플랫폼 가이드: [프로그래밍 방식 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) ### 호스티드 컨테이너 셸 + 스킬 -`ShellTool`은 OpenAI 호스티드 컨테이너 실행도 지원합니다. 로컬 런타임 대신 관리형 컨테이너에서 모델이 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. +`ShellTool`은 OpenAI 호스티드 컨테이너 실행도 지원합니다. 모델이 로컬 런타임이 아닌 관리형 컨테이너에서 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -214,52 +215,52 @@ result = await Runner.run( print(result.final_output) ``` -이후 실행에서 기존 컨테이너를 재사용하려면 `environment={"type": "container_reference", "container_id": "cntr_..."}`를 설정하세요. +후속 실행에서 기존 컨테이너를 재사용하려면 `environment={"type": "container_reference", "container_id": "cntr_..."}`를 설정하세요. 알아둘 사항: - 호스티드 셸은 Responses API 셸 도구를 통해 사용할 수 있습니다. -- `container_auto`는 요청을 위한 컨테이너를 프로비저닝하고, `container_reference`는 기존 컨테이너를 재사용합니다. -- `container_auto`에는 `file_ids` 및 `memory_limit`도 포함할 수 있습니다. -- `environment.skills`는 스킬 참조 및 인라인 스킬 번들을 허용합니다. +- `container_auto`는 요청을 위한 컨테이너를 프로비저닝하며, `container_reference`는 기존 컨테이너를 재사용합니다. +- `container_auto`에는 `file_ids`와 `memory_limit`도 포함할 수 있습니다. +- `environment.skills`는 스킬 참조와 인라인 스킬 번들을 허용합니다. - 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval`, `on_approval`을 설정하지 마세요. - `network_policy`는 `disabled` 및 `allowlist` 모드를 지원합니다. -- 허용 목록 모드에서는 `network_policy.domain_secrets`가 이름을 통해 도메인 범위의 비밀 값을 주입할 수 있습니다. -- 완전한 코드 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참조하세요. +- 허용 목록 모드에서 `network_policy.domain_secrets`는 이름을 기준으로 도메인 범위의 보안 비밀을 주입할 수 있습니다. +- 완전한 코드 예제는 `examples/tools/container_shell_skill_reference.py`와 `examples/tools/container_shell_inline_skill.py`를 참조하세요. - OpenAI 플랫폼 가이드: [셸](https://platform.openai.com/docs/guides/tools-shell) 및 [스킬](https://platform.openai.com/docs/guides/tools-skills) ## 로컬 런타임 도구 -로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 도구를 호출할 시점을 계속 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. +로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 호출 시점은 여전히 모델이 결정하지만, 실제 작업은 애플리케이션이나 구성된 실행 환경에서 수행합니다. -`ComputerTool`과 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`은 두 모드를 모두 지원합니다. 관리형 실행을 사용하려면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. +`ComputerTool`과 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`은 두 모드를 모두 지원합니다. 관리형 실행을 원한다면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. -로컬 런타임 도구에는 다음 구현을 제공해야 합니다. +로컬 런타임 도구를 사용하려면 구현을 제공해야 합니다. -- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 사용하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현하세요. -- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행을 모두 지원하는 최신 셸 도구 -- [`LocalShellTool`][agents.tool.LocalShellTool]: 레거시 로컬 셸 통합 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현하세요. -- 로컬 셸 스킬은 `ShellTool(environment={"type": "local", "skills": [...]})`을 통해 사용할 수 있습니다. +- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 사용하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현합니다. +- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행을 모두 지원하는 최신 셸 도구입니다. +- [`LocalShellTool`][agents.tool.LocalShellTool]: 레거시 로컬 셸 통합입니다. +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현합니다. +- `ShellTool(environment={"type": "local", "skills": [...]})`을 사용하여 로컬 셸 스킬을 사용할 수 있습니다. -### ComputerTool 및 Responses 컴퓨터 도구 +### ComputerTool과 Responses 컴퓨터 도구 -`ComputerTool`은 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API의 컴퓨터 표면에 매핑합니다. +`ComputerTool`은 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 이 하네스를 OpenAI Responses API의 컴퓨터 표면에 매핑합니다. -명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 정식 출시(GA)된 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델은 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`를 유지합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. +명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델은 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`를 계속 사용합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션과 동일합니다. - 모델: `computer-use-preview` -> `gpt-5.5` - 도구 선택자: `computer_use_preview` -> `computer` -- 컴퓨터 호출 형태: `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` -- 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 불필요 +- 컴퓨터 호출 형식: 각 `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` +- 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 필요하지 않음 -SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하며 프롬프트가 모델을 소유하기 때문에 요청에서 `model`을 생략하는 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않으면 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. +SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하고 프롬프트가 모델을 소유하기 때문에 요청에서 `model`을 생략하면, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않는 한 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. -[`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 모두 허용되며 유효 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`이 없으면 이러한 문자열은 여전히 일반 함수 이름처럼 동작합니다. +[`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 모두 허용되며 유효한 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. -`ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 기반으로 할 때는 이 차이가 중요합니다. GA `computer` 페이로드는 직렬화 시 `environment` 또는 크기 정보가 필요하지 않으므로 확인되지 않은 팩토리도 사용할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`를 전송할 수 있도록 확인된 `Computer` 또는 `AsyncComputer` 인스턴스가 여전히 필요합니다. +`ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 기반으로 하는 경우 이 차이가 중요합니다. GA `computer` 페이로드는 직렬화 시점에 `environment` 또는 크기가 필요하지 않으므로 아직 해석되지 않은 팩토리도 사용할 수 있습니다. 프리뷰 호환 직렬화에서는 SDK가 `environment`, `display_width`, `display_height`를 전송할 수 있도록 해석된 `Computer` 또는 `AsyncComputer` 인스턴스가 필요합니다. -런타임에서 두 경로는 모두 동일한 로컬 하네스를 계속 사용합니다. 프리뷰 응답은 단일 `action`이 포함된 `computer_call` 항목을 내보냅니다. `gpt-5.5`는 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 해당 작업을 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`를 참조하세요. +런타임에서는 두 경로 모두 동일한 로컬 하네스를 계속 사용합니다. 프리뷰 응답은 하나의 `action`이 포함된 `computer_call` 항목을 내보냅니다. `gpt-5.5`는 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. Playwright 기반의 실행 가능한 하네스는 `examples/tools/computer_use.py`를 참조하세요. ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -305,14 +306,16 @@ agent = Agent( 모든 Python 함수를 도구로 사용할 수 있습니다. Agents SDK가 도구를 자동으로 설정합니다. -- 도구 이름은 Python 함수 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다. -- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 제공할 수 있습니다. -- 함수 입력의 스키마는 함수 인수에서 자동으로 생성됩니다. -- 비활성화하지 않는 한 각 입력의 설명은 함수의 docstring에서 가져옵니다. +- 도구 이름은 Python 함수의 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다 +- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 제공할 수 있습니다 +- 함수 입력 스키마는 함수의 인수에서 자동으로 생성됩니다 +- 비활성화하지 않는 한 각 입력에 대한 설명은 함수의 docstring에서 가져옵니다 -함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하고, docstring을 파싱하기 위해 [`griffe`](https://mkdocstrings.github.io/griffe/)를, 스키마 생성을 위해 `pydantic`을 함께 사용합니다. +`@tool`로 생성한 도구는 읽기 전용 `__wrapped__` 속성을 통해 원래 Python 호출 가능 객체를 노출합니다. 이는 검사 및 테스트에 유용하지만, 직접 호출하면 스키마 검증, 컨텍스트 주입, 가드레일, 시간 제한, 실패 처리, 트레이싱을 포함한 도구 런타임 파이프라인을 우회합니다. 직접 구성한 `FunctionTool` 인스턴스는 `__wrapped__`를 노출하지 않습니다. -OpenAI Responses 모델을 사용하는 경우 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`이 함수 도구를 로드할 때까지 해당 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정 및 제약 조건은 [호스티드 툴 검색](#hosted-tool-search)을 참조하세요. +함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하며, docstring을 파싱하기 위해 [`griffe`](https://mkdocstrings.github.io/griffe/)를 사용하고 스키마 생성에는 `pydantic`을 사용합니다. + +OpenAI Responses 모델을 사용할 때 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`이 로드할 때까지 함수 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정과 제약 조건은 [호스티드 툴 검색](#hosted-tool-search)을 참조하세요. ```python import json @@ -365,12 +368,12 @@ for tool in agent.tools: ``` -1. 모든 Python 타입을 함수 인수로 사용할 수 있으며, 함수는 동기식 또는 비동기식일 수 있습니다. -2. docstring이 있으면 설명 및 인수 설명을 가져오는 데 사용됩니다. -3. 함수는 선택적으로 `context`를 받을 수 있습니다. 이 인수는 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의도 설정할 수 있습니다. +1. 모든 Python 유형을 함수의 인수로 사용할 수 있으며, 함수는 동기식 또는 비동기식일 수 있습니다. +2. docstring이 있으면 설명과 인수 설명을 추출하는 데 사용됩니다. +3. 함수는 선택적으로 `context`를 받을 수 있습니다. 이 인수는 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의 항목도 설정할 수 있습니다. 4. 데코레이팅된 함수를 도구 목록에 전달할 수 있습니다. -??? note "출력을 확인하려면 펼치기" +??? note "출력을 보려면 펼치기" ``` fetch_weather @@ -442,20 +445,20 @@ for tool in agent.tools: ### 함수 도구의 이미지 또는 파일 반환 -텍스트 출력뿐만 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 이를 위해 다음 항목 중 하나를 반환할 수 있습니다. +텍스트 출력뿐만 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 다음 중 하나를 반환하면 됩니다. - 이미지: [`ToolOutputImage`][agents.tool.ToolOutputImage] 또는 TypedDict 버전인 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict] - 파일: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent] 또는 TypedDict 버전인 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict] -- 텍스트: 문자열, 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText] 또는 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] +- 텍스트: 문자열이나 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText] 또는 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] ### 사용자 지정 함수 도구 -Python 함수를 도구로 사용하고 싶지 않은 경우도 있습니다. 원하는 경우 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. +Python 함수를 도구로 사용하고 싶지 않은 경우도 있습니다. 원한다면 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. - `name` - `description` - 인수의 JSON 스키마인 `params_json_schema` -- [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형태의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` +- [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형식의 인수를 받고 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` ```python from typing import Any @@ -488,12 +491,12 @@ tool = FunctionTool( ) ``` -### 자동 인수 및 docstring 파싱 +### 인수 및 docstring 자동 파싱 -앞서 설명했듯이 도구의 스키마를 추출하기 위해 함수 시그니처를 자동으로 파싱하고, 도구와 개별 인수의 설명을 추출하기 위해 docstring을 파싱합니다. 이에 관한 참고 사항은 다음과 같습니다. +앞서 설명했듯이 도구의 스키마를 추출하기 위해 함수 시그니처를 자동으로 파싱하고, 도구 및 개별 인수의 설명을 추출하기 위해 docstring을 파싱합니다. 다음 사항을 참고하세요. -1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용하여 인수의 타입을 파악하고 전체 스키마를 나타내는 Pydantic 모델을 동적으로 구축합니다. Python 기본 타입, Pydantic 모델, TypedDict 등을 포함한 대부분의 타입을 지원합니다. -2. docstring 파싱에는 `griffe`를 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동 감지하려고 시도하지만 이는 최선형 방식이며, `function_tool`을 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. Google 스타일 docstring의 경우 파서는 요약 텍스트 바로 뒤에 빈 줄 없이 오는 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 허용합니다. +1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 유형 어노테이션을 사용하여 인수의 유형을 파악하고, 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 유형, Pydantic 모델, TypedDict 등을 포함한 대부분의 유형을 지원합니다. +2. docstring을 파싱하는 데 `griffe`를 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 시도하지만 완벽하지 않을 수 있으므로 `function_tool`을 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. Google 스타일 docstring의 경우 파서는 요약 텍스트 바로 다음에 빈 줄 없이 배치된 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 허용합니다. 스키마 추출 코드는 [`agents.function_schema`][]에 있습니다. @@ -540,13 +543,13 @@ agent = Agent( ) ``` -시간 제한에 도달하면 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델에 표시되는 시간 제한 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. +시간 제한에 도달하면 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델이 확인할 수 있는 시간 초과 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. -시간 제한 처리는 다음과 같이 제어할 수 있습니다. +시간 초과 처리를 제어할 수 있습니다. -- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 시간 제한 메시지를 반환합니다. +- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 시간 초과 메시지를 반환합니다. - `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]를 발생시키고 실행을 실패 처리합니다. -- `timeout_error_function=...`: `error_as_result`를 사용할 때 시간 제한 메시지를 사용자 지정합니다. +- `timeout_error_function=...`: `error_as_result`를 사용할 때 시간 초과 메시지를 사용자 지정합니다. ```python import asyncio @@ -574,11 +577,11 @@ except ToolTimeoutError as e: ### 함수 도구의 오류 처리 -`@function_tool`을 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이 함수는 도구 호출이 비정상 종료될 경우 LLM에 오류 응답을 제공합니다. +`@function_tool`을 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이 함수는 도구 호출이 실패하는 경우 LLM에 오류 응답을 제공합니다. -- 기본적으로 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`이 실행됩니다. -- 자체 오류 함수를 전달하면 해당 함수가 대신 실행되고 응답이 LLM에 전송됩니다. -- `None`을 명시적으로 전달하면 도구 호출 오류가 다시 발생하므로 사용자가 처리할 수 있습니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`가 될 수 있고, 코드가 비정상 종료된 경우 `UserError`가 될 수 있습니다. +- 기본적으로 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`을 실행합니다. +- 자체 오류 함수를 전달하면 해당 함수를 대신 실행하고 응답을 LLM에 전송합니다. +- 명시적으로 `None`을 전달하면 도구 호출 오류가 다시 발생하며 사용자가 직접 처리해야 합니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`, 코드가 실패한 경우 `UserError` 등이 발생할 수 있습니다. ```python from agents import RunContextWrapper @@ -606,7 +609,7 @@ def get_user_profile(user_id: str) -> str: ## Agents as tools -일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. +일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 agents as tools로 모델링하여 이를 구현할 수 있습니다. ```python import asyncio @@ -652,9 +655,9 @@ if __name__ == "__main__": ### 도구 에이전트 사용자 지정 -`agent.as_tool` 함수는 에이전트를 도구로 쉽게 변환할 수 있는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`를 사용하는 구조화된 입력도 지원합니다. +`agent.as_tool` 함수는 에이전트를 도구로 쉽게 변환할 수 있는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`를 통한 구조화된 입력도 지원합니다. -상태 옵션은 도구 호출로 시작된 중첩 에이전트 실행을 구성하며, 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리형 기록을 공유하려면 동일한 `session`을 두 실행 모두에 명시적으로 전달하세요. `Runner.run`과 마찬가지로 중첩 실행에는 하나의 상태 전략을 선택하세요. 클라이언트 관리형 `session`을 사용하거나 `previous_response_id` 또는 `conversation_id`를 통한 서버 관리형 연속 실행을 사용해야 합니다. +상태 옵션은 도구 호출로 시작되는 중첩 에이전트 실행을 구성하며, 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리형 기록을 공유하려면 동일한 `session`을 양쪽에 명시적으로 전달하세요. `Runner.run`과 마찬가지로 중첩 실행에는 하나의 상태 전략을 선택하세요. 클라이언트 관리형 `session`을 사용하거나 `previous_response_id` 또는 `conversation_id`를 통한 서버 관리형 연속 실행을 사용합니다. ```python from agents.decorators import tool @@ -678,12 +681,12 @@ async def run_my_agent() -> str: ### 도구 에이전트의 구조화된 입력 -기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 예상하지만, `parameters`에 Pydantic 모델 또는 데이터 클래스 타입을 전달하여 구조화된 스키마를 노출할 수 있습니다. +기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 예상하지만, `parameters`에 Pydantic 모델 또는 dataclass 유형을 전달하여 구조화된 스키마를 노출할 수 있습니다. 추가 옵션: -- `include_input_schema=True`는 생성된 중첩 입력에 전체 JSON 스키마를 포함합니다. -- `input_builder=...`를 사용하면 구조화된 도구 인수가 중첩 에이전트 입력으로 변환되는 방식을 완전히 사용자 지정할 수 있습니다. +- `include_input_schema=True`는 생성된 중첩 입력에 전체 JSON Schema를 포함합니다. +- `input_builder=...`를 사용하면 구조화된 도구 인수를 중첩 에이전트 입력으로 변환하는 방식을 완전히 사용자 지정할 수 있습니다. - `RunContextWrapper.tool_input`은 중첩 실행 컨텍스트 내부에 파싱된 구조화 페이로드를 포함합니다. ```python @@ -712,11 +715,11 @@ translator_tool = translator_agent.as_tool( ### 사용자 지정 출력 추출 -경우에 따라 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 상황에서 유용합니다. +특정한 경우 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 작업을 수행할 때 유용합니다. -- 하위 에이전트의 채팅 기록에서 특정 정보(예: JSON 페이로드)를 추출 -- 에이전트의 최종 답변을 변환하거나 형식을 변경(예: Markdown을 일반 텍스트 또는 CSV로 변환) -- 출력을 검증하거나 에이전트 응답이 없거나 형식이 잘못된 경우 대체 값 제공 +- 하위 에이전트의 채팅 기록에서 특정 정보(예: JSON 페이로드) 추출 +- 에이전트의 최종 답변 변환 또는 형식 변경(예: Markdown을 일반 텍스트나 CSV로 변환) +- 출력 검증 또는 에이전트의 응답이 누락되었거나 형식이 잘못된 경우 대체 값 제공 `as_tool` 메서드에 `custom_output_extractor` 인수를 제공하여 이를 수행할 수 있습니다. @@ -737,11 +740,11 @@ json_tool = data_agent.as_tool( ) ``` -사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 이는 중첩된 결과를 후처리하는 동안 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참조하세요. +사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 이는 중첩된 결과를 후처리할 때 외부 도구 이름, 호출 ID 또는 원문 인수가 필요한 경우 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참조하세요. -### 중첩 에이전트 실행의 스트리밍 +### 중첩 에이전트 실행 스트리밍 -`as_tool`에 `on_stream` 콜백을 전달하면 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하면서도 스트림이 완료된 후 최종 출력을 반환할 수 있습니다. +스트림이 완료되면 최종 출력을 반환하면서 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하려면 `on_stream` 콜백을 `as_tool`에 전달하세요. ```python from agents import AgentToolStreamEvent @@ -761,8 +764,8 @@ billing_agent_tool = billing_agent.as_tool( 예상 동작: -- 이벤트 유형은 `StreamEvent["type"]`을 따릅니다: `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event` -- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드에서 실행되고, 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. +- 이벤트 유형은 `StreamEvent["type"]`을 반영합니다: `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event` +- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드로 실행되고 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. - 핸들러는 동기식 또는 비동기식일 수 있으며, 각 이벤트는 도착하는 순서대로 전달됩니다. - 모델 도구 호출을 통해 도구가 호출되면 `tool_call`이 존재합니다. 직접 호출에서는 `None`일 수 있습니다. - 완전한 실행 가능 코드 예제는 `examples/agent_patterns/agents_as_tools_streaming.py`를 참조하세요. @@ -827,21 +830,21 @@ asyncio.run(main()) `is_enabled` 매개변수는 다음을 허용합니다. - **불리언 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) -- **호출 가능 함수**: `(context, agent)`를 받아 불리언 값을 반환하는 함수 +- **호출 가능 함수**: `(context, agent)`를 받고 불리언을 반환하는 함수 - **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 -비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 용도에 유용합니다. +비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 용도로 유용합니다. -- 사용자 권한에 따른 기능 게이팅 +- 사용자 권한 기반 기능 게이팅 - 환경별 도구 가용성(개발 환경과 프로덕션 환경) - 서로 다른 도구 구성의 A/B 테스트 -- 런타임 상태에 따른 동적 도구 필터링 +- 런타임 상태 기반 동적 도구 필터링 ## 실험적 기능: Codex 도구 -`codex_tool`은 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있도록 합니다. 이 기능은 실험적이며 변경될 수 있습니다. +`codex_tool`은 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 작업 공간 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있도록 합니다. 이 기능 표면은 실험적이며 변경될 수 있습니다. -현재 실행을 벗어나지 않고 기본 에이전트가 범위가 제한된 워크스페이스 작업을 Codex에 위임하도록 하려면 이 도구를 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함된 경우 각 도구는 고유한 이름을 사용해야 합니다. +메인 에이전트가 현재 실행을 벗어나지 않고 제한된 작업 공간 작업을 Codex에 위임하도록 하려면 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 이름은 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함된 경우 각 도구는 고유한 이름을 사용해야 합니다. ```python from agents import Agent @@ -872,31 +875,31 @@ agent = Agent( 다음 옵션 그룹부터 시작하세요. -- 실행 표면: `sandbox_mode` 및 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고, 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`를 설정하세요. -- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 강도, 승인 정책, 추가 디렉터리, 네트워크 액세스 및 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`를 우선 사용하세요. +- 실행 표면: `sandbox_mode`와 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고, 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`를 설정하세요. +- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 수준, 승인 정책, 추가 디렉터리, 네트워크 접근, 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`를 사용하세요. - 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal`과 같은 턴별 동작을 구성합니다. -- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }`가 포함된 `inputs` 항목이 하나 이상 있어야 합니다. `output_schema`를 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. +- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }` 형식의 `inputs` 항목이 하나 이상 포함되어야 합니다. `output_schema`를 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. -스레드 재사용과 영속성은 별도의 제어 항목입니다. +스레드 재사용과 지속성은 별도의 제어 항목입니다. -- `persist_session=True`는 동일한 도구 인스턴스에 대한 반복 호출에서 하나의 Codex 스레드를 재사용합니다. -- `use_run_context_thread_id=True`는 동일한 변경 가능 컨텍스트 객체를 공유하는 여러 실행에서 실행 컨텍스트에 스레드 ID를 저장하고 재사용합니다. -- 스레드 ID의 우선순위는 호출별 `thread_id`, 실행 컨텍스트 스레드 ID(활성화된 경우), 구성된 `thread_id` 옵션 순입니다. -- 기본 실행 컨텍스트 키는 `name="codex"`일 때 `codex_thread_id`이고, `name="codex_"`일 때 `codex_thread_id_`입니다. `run_context_thread_id_key`를 사용하여 재정의할 수 있습니다. +- `persist_session=True`는 동일한 도구 인스턴스를 반복 호출할 때 하나의 Codex 스레드를 재사용합니다. +- `use_run_context_thread_id=True`는 동일한 가변 컨텍스트 객체를 공유하는 여러 실행에서 스레드 ID를 실행 컨텍스트에 저장하고 재사용합니다. +- 스레드 ID 우선순위는 호출별 `thread_id`, 실행 컨텍스트 스레드 ID(활성화된 경우), 구성된 `thread_id` 옵션 순입니다. +- 기본 실행 컨텍스트 키는 `name="codex"`일 때 `codex_thread_id`이고, `name="codex_"`일 때 `codex_thread_id_`입니다. `run_context_thread_id_key`로 재정의할 수 있습니다. 런타임 구성: -- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`를 전달하세요. +- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`를 전달합니다. - 런타임: `codex_options.base_url`은 CLI 기본 URL을 재정의합니다. -- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`를 설정하세요. 그렇지 않으면 SDK는 `PATH`에서 `codex`를 확인한 후 번들로 제공되는 벤더 바이너리를 대체 경로로 사용합니다. -- 환경: `codex_options.env`는 하위 프로세스 환경을 완전히 제어합니다. 이 옵션이 제공되면 하위 프로세스는 `os.environ`을 상속하지 않습니다. -- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`~`67108864`이며, 기본값은 `8388608`입니다. +- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`를 설정합니다. 그렇지 않으면 SDK는 `PATH`에서 `codex`를 확인한 다음, 찾지 못하면 번들로 제공되는 벤더 바이너리를 사용합니다. +- 환경: `codex_options.env`는 하위 프로세스 환경을 완전히 제어합니다. 이를 제공하면 하위 프로세스가 `os.environ`을 상속하지 않습니다. +- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`에서 `67108864`이며, 기본값은 `8388608`입니다. - 스트리밍: `on_stream`은 스레드/턴 수명 주기 이벤트와 항목 이벤트(`reasoning`, `command_execution`, `mcp_tool_call`, `file_change`, `web_search`, `todo_list`, `error` 항목 업데이트)를 수신합니다. - 출력: 결과에는 `response`, `usage`, `thread_id`가 포함되며, 사용량은 `RunContextWrapper.usage`에 추가됩니다. 참조: -- [Codex 도구 API 참조](ref/extensions/experimental/codex/codex_tool.md) -- [ThreadOptions 참조](ref/extensions/experimental/codex/thread_options.md) -- [TurnOptions 참조](ref/extensions/experimental/codex/turn_options.md) -- 완전한 실행 가능 코드 예제는 `examples/tools/codex.py` 및 `examples/tools/codex_same_thread.py`를 참조하세요. \ No newline at end of file +- [Codex 도구 API 레퍼런스](ref/extensions/experimental/codex/codex_tool.md) +- [ThreadOptions 레퍼런스](ref/extensions/experimental/codex/thread_options.md) +- [TurnOptions 레퍼런스](ref/extensions/experimental/codex/turn_options.md) +- 완전한 실행 가능 코드 예제는 `examples/tools/codex.py`와 `examples/tools/codex_same_thread.py`를 참조하세요. \ No newline at end of file diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md index bda7df850a..5591c02205 100644 --- a/docs/zh/mcp.md +++ b/docs/zh/mcp.md @@ -4,31 +4,34 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)标准化了应用如何向语言模型公开工具和 -上下文。来自官方文档: +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)对应用程序向语言模型提供工具和上下文的方式进行了标准化。官方文档中的说明如下: -> MCP是一种开放协议,标准化了应用向LLM提供上下文的方式。可以把MCP想象成AI -> 应用的USB-C端口。正如USB-C提供了一种标准化方式,用于将你的设备连接到各种外设和配件,MCP -> 也提供了一种标准化方式,用于将AI模型连接到不同的数据源和工具。 +> MCP是一种开放协议,用于标准化应用程序向LLM提供上下文的方式。可以将MCP视为AI +> 应用程序的USB-C端口。正如USB-C提供了一种将设备连接到各种外围设备和配件的标准化方式,MCP +> 也提供了一种将AI模型连接到不同数据源和工具的标准化方式。 -Agents Python SDK支持多种MCP传输方式。这使你可以复用现有的MCP服务,或构建自己的服务,向智能体公开由文件系统、HTTP或连接器支持的工具。 +Agents Python SDK支持多种MCP传输方式。这样,你就可以复用现有MCP服务,也可以构建自己的MCP服务,从而向智能体提供由文件系统、HTTP或连接器支持的工具。 + +!!! warning "连接MCP服务前的信任要求" + + MCP工具可以公开模型上下文中的数据,并使用你提供的凭据执行操作。请仅连接你信任的服务、使用最小权限凭据、将访问令牌放在授权字段或请求头中而非URL中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 ## MCP集成方案选择 -在将MCP服务接入智能体之前,请先决定工具调用应在哪里执行,以及你可以访问哪些传输方式。下表总结了Python SDK支持的选项。 +在将MCP服务接入智能体之前,应先确定工具调用的执行位置,以及可访问的传输方式。下表总结了Python SDK支持的选项。 -| 你的需求 | 推荐选项 | +| 需求 | 推荐选项 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| 让OpenAI的Responses API代表模型调用可公开访问的MCP服务| 通过[`HostedMCPTool`][agents.tool.HostedMCPTool]使用**托管MCP服务工具** | +| 让OpenAI的Responses API代表模型调用可公开访问的MCP服务| 通过[`HostedMCPTool`][agents.tool.HostedMCPTool]使用**托管式MCP服务工具** | | 连接到你在本地或远程运行的Streamable HTTP服务 | 通过[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]使用**Streamable HTTP MCP服务** | -| 与实现HTTP with Server-Sent Events的服务通信 | 通过[`MCPServerSse`][agents.mcp.server.MCPServerSse]使用**HTTP with SSE MCP服务** | +| 与实现了采用服务端发送事件的HTTP协议的服务通信 | 通过[`MCPServerSse`][agents.mcp.server.MCPServerSse]使用**采用SSE的HTTP MCP服务** | | 启动本地进程并通过stdin/stdout通信 | 通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]使用**stdio MCP服务** | -下面各节会逐一介绍每个选项、如何配置它,以及何时优先选择某种传输方式。 +以下各节将逐一介绍每个选项、配置方式,以及何时应优先选择某种传输方式。 ## 智能体级MCP配置 -除了选择传输方式,你还可以通过设置`Agent.mcp_config`来调整MCP工具的准备方式。 +除了选择传输方式外,你还可以通过设置`Agent.mcp_config`来调整MCP工具的准备方式。 ```python from agents import Agent @@ -48,33 +51,33 @@ agent = Agent( ) ``` -说明: +注意事项: -- `convert_schemas_to_strict`是尽力而为的。如果某个schema无法转换,则使用原始schema。 -- `failure_error_function`控制MCP工具调用失败如何呈现给模型。 -- 当未设置`failure_error_function`时,SDK会使用默认的工具错误格式化器。 +- `convert_schemas_to_strict`采用尽力而为的方式。如果某个模式无法转换,则使用原始模式。 +- `failure_error_function`控制如何将MCP工具调用失败呈现给模型。 +- 未设置`failure_error_function`时,SDK会使用默认的工具错误格式化程序。 - 服务级`failure_error_function`会覆盖该服务的`Agent.mcp_config["failure_error_function"]`。 -- `include_server_in_tool_names`是可选启用项。启用后,每个本地MCP工具都会以确定性的、带服务前缀的名称公开给模型,这有助于在多个MCP服务发布同名工具时避免冲突。生成的名称是ASCII安全的,会保持在工具调用名称长度限制内,并避免与同一智能体上的现有本地工具调用和已启用的任务转移名称冲突。SDK仍会在原服务上调用原始的MCP工具名称。 +- `include_server_in_tool_names`需要选择启用。启用后,每个本地MCP工具都会以带有确定性服务前缀的名称提供给模型,有助于避免多个MCP服务发布同名工具时发生冲突。生成的名称符合ASCII安全要求,不会超过工具调用名称的长度限制,并会避开同一智能体中已有的本地工具调用名称和已启用的任务转移名称。SDK仍会在原始服务上调用原始MCP工具名称。 -## 跨传输方式的通用模式 +## 各传输方式的通用模式 选择传输方式后,大多数集成都需要做出相同的后续决策: -- 如何仅公开工具的一个子集([工具筛选](#tool-filtering))。 +- 如何仅公开工具的子集([工具筛选](#tool-filtering))。 - 服务是否还提供可复用的提示词([提示词](#prompts))。 - 是否应缓存`list_tools()`([缓存](#caching))。 -- MCP活动如何显示在追踪中([追踪](#tracing))。 +- MCP活动如何显示在追踪记录中([追踪](#tracing))。 -对于本地MCP服务(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的`_meta`载荷也是通用概念。Streamable HTTP一节展示了最完整的示例,同样的模式也适用于其他本地传输方式。 +对于本地MCP服务(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的`_meta`载荷也是通用概念。Streamable HTTP一节提供了最完整的示例,相同模式也适用于其他本地传输方式。 -## 1. 托管MCP服务工具 +## 1. 托管式MCP服务工具 -托管工具会把整个工具往返过程推送到OpenAI基础设施中执行。你的代码无需列出并调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool]会将服务标签(以及可选的连接器元数据)转发给Responses API。模型会列出远程服务的工具并调用它们,而无需额外回调到你的Python进程。托管工具目前适用于支持Responses API托管MCP集成的OpenAI模型。 +托管工具会将整个工具往返流程交由OpenAI的基础设施处理。你的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool]会将服务标签(以及可选的连接器元数据)转发给Responses API。模型会列出远程服务的工具并调用它们,无需额外回调你的Python进程。托管工具目前适用于支持Responses API托管式MCP集成的OpenAI模型。 -### 基础托管MCP工具 +### 基础托管式MCP工具 -通过向智能体的`tools`列表添加[`HostedMCPTool`][agents.tool.HostedMCPTool]来创建托管工具。`tool_config` -字典与发送给REST API的JSON保持一致: +将[`HostedMCPTool`][agents.tool.HostedMCPTool]添加到智能体的`tools`列表中,即可创建托管工具。`tool_config` +字典对应于你会发送给REST API的JSON: ```python import asyncio @@ -106,14 +109,14 @@ async def main() -> None: asyncio.run(main()) ``` -托管服务会自动公开其工具;你无需将其添加到`mcp_servers`。 +托管式服务会自动公开其工具;无需将其添加到`mcp_servers`。 -如果你希望托管工具搜索延迟加载托管MCP服务,请设置`tool_config["defer_loading"] = True`并将[`ToolSearchTool`][agents.tool.ToolSearchTool]添加到智能体。这仅在OpenAI Responses模型上受支持。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 +如果希望托管工具搜索延迟加载托管式MCP服务,请设置`tool_config["defer_loading"] = True`,并将[`ToolSearchTool`][agents.tool.ToolSearchTool]添加到智能体。此功能仅支持OpenAI Responses模型。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 -### 托管MCP结果的流式传输 +### 托管式MCP结果的流式传输 -托管工具支持结果流式传输,方式与工具调用完全相同。使用`Runner.run_streamed`在模型仍在工作时 -消费增量MCP输出: +托管工具对流式结果的支持方式与工具调用完全相同。使用`Runner.run_streamed`可以在模型仍在工作时 +接收增量MCP输出: ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -125,7 +128,7 @@ print(result.final_output) ### 可选审批流程 -如果某个服务可能执行敏感操作,你可以要求每次工具执行前都经过人工或程序化审批。在`tool_config`中使用单一策略(`"always"`、`"never"`)或将工具名称映射到策略的字典来配置`require_approval`。要在Python中做出决定,请提供`on_approval_request`回调。 +如果服务可以执行敏感操作,你可以要求在每次执行工具前进行人工或程序化审批。在`tool_config`中配置`require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。如需在Python中做出决定,请提供`on_approval_request`回调。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -153,11 +156,11 @@ agent = Agent( ) ``` -该回调可以是同步或异步的,并会在模型需要审批数据才能继续运行时被调用。 +该回调可以是同步或异步的,并且每当模型需要审批信息才能继续运行时都会被调用。 -### 连接器支持的托管服务 +### 连接器支持的托管式服务 -托管MCP还支持OpenAI连接器。无需指定`server_url`,而是提供`connector_id`和访问令牌。Responses API会处理身份验证,托管服务会公开连接器的工具。 +托管式MCP还支持OpenAI连接器。你无需指定`server_url`,只需提供`connector_id`和访问令牌。Responses API会处理身份验证,托管式服务则会公开连接器的工具。 ```python import os @@ -173,11 +176,11 @@ HostedMCPTool( ) ``` -完整可运行的托管工具代码示例——包括流式传输、审批和连接器——位于[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 +完整可运行的托管工具示例(包括流式传输、审批和连接器)位于[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 ## 2. Streamable HTTP MCP服务 -当你想自行管理网络连接时,请使用[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你控制传输方式,或希望在自己的基础设施中运行服务并保持低延迟时,Streamable HTTP服务是理想选择。 +如果希望自行管理网络连接,请使用[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你需要控制传输方式,或希望在自己的基础设施中运行服务并保持较低延迟时,Streamable HTTP服务是理想选择。 ```python import asyncio @@ -212,15 +215,15 @@ async def main() -> None: asyncio.run(main()) ``` -构造函数接受其他选项: +构造函数还接受以下选项: - `client_session_timeout_seconds`控制HTTP读取超时。 -- `use_structured_content`控制是否优先使用`tool_result.structured_content`而不是文本输出。 +- `use_structured_content`控制是否优先使用`tool_result.structured_content`而非文本输出。 - `max_retry_attempts`和`retry_backoff_seconds_base`为`list_tools()`和`call_tool()`添加自动重试。 -- `tool_filter`允许你仅公开工具的一个子集(参见[工具筛选](#tool-filtering))。 -- `require_approval`为本地MCP工具启用人在回路审批策略。 -- `failure_error_function`自定义模型可见的MCP工具失败消息;将其设置为`None`则改为抛出错误。 -- `tool_meta_resolver`会在`call_tool()`之前注入每次调用的MCP`_meta`载荷。 +- `tool_filter`用于仅公开工具的子集(请参阅[工具筛选](#tool-filtering))。 +- `require_approval`为本地MCP工具启用人工介入审批策略。 +- `failure_error_function`用于自定义模型可见的MCP工具失败消息;将其设置为`None`则改为抛出错误。 +- `tool_meta_resolver`会在`call_tool()`之前注入每次调用的MCP `_meta`载荷。 ### 本地MCP服务的审批策略 @@ -229,8 +232,8 @@ asyncio.run(main()) 支持的形式: - 对所有工具使用`"always"`或`"never"`。 -- `True` / `False`(等同于always/never)。 -- 按工具的映射,例如`{"delete_file": "always", "read_file": "never"}`。 +- `True` / `False`(分别等同于always/never)。 +- 按工具配置的映射,例如`{"delete_file": "always", "read_file": "never"}`。 - 分组对象:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 ```python @@ -242,11 +245,11 @@ async with MCPServerStreamableHttp( ... ``` -有关完整的暂停/恢复流程,请参阅[人在回路](human_in_the_loop.md)和`examples/mcp/get_all_mcp_tools_example/main.py`。 +有关完整的暂停/恢复流程,请参阅[人工介入](human_in_the_loop.md)和`examples/mcp/get_all_mcp_tools_example/main.py`。 ### 使用`tool_meta_resolver`的每次调用元数据 -当你的MCP服务期望在`_meta`中接收请求元数据(例如租户ID或追踪上下文)时,请使用`tool_meta_resolver`。下面的示例假定你将一个`dict`作为`context`传递给`Runner.run(...)`。 +当MCP服务要求在`_meta`中包含请求元数据(例如租户ID或追踪上下文)时,请使用`tool_meta_resolver`。以下示例假设你将`dict`作为`context`传递给`Runner.run(...)`。 ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -267,19 +270,19 @@ server = MCPServerStreamableHttp( ) ``` -如果你的运行上下文是Pydantic模型、dataclass或自定义类,请改用属性访问读取租户ID。 +如果运行上下文是Pydantic模型、数据类或自定义类,请改为通过属性访问读取租户ID。 -### MCP工具输出:文本和图像 +### MCP工具输出:文本与图像 -当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合的文本/图像响应会作为输出项列表转发,因此智能体可以像消费常规工具调用的图像输出一样消费MCP图像结果。 +当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合文本/图像响应会作为输出项列表转发,因此智能体可以像使用普通工具调用产生的图像输出一样使用MCP图像结果。 -## 3. HTTP with SSE MCP服务 +## 3. 采用SSE的HTTP MCP服务 !!! warning - MCP项目已弃用Server-Sent Events传输。对于新的集成,请优先选择Streamable HTTP或stdio;仅为旧服务保留SSE。 + MCP项目已弃用服务端发送事件传输方式。新集成应优先使用Streamable HTTP或stdio,仅为旧版服务保留SSE。 -如果MCP服务实现了HTTP with SSE传输,请实例化[`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,API与Streamable HTTP服务相同。 +如果MCP服务实现了采用SSE的HTTP传输方式,请实例化[`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其API与Streamable HTTP服务完全相同。 ```python @@ -308,7 +311,7 @@ async with MCPServerSse( ## 4. stdio MCP服务 -对于作为本地子进程运行的MCP服务,请使用[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会启动该进程、保持管道打开,并在上下文管理器退出时自动关闭它们。此选项适合快速概念验证,或服务仅公开命令行入口点的情况。 +对于作为本地子进程运行的MCP服务,请使用[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会启动进程、保持管道打开,并在退出上下文管理器时自动关闭它们。此选项适用于快速构建概念验证,或服务仅提供命令行入口点的情况。 ```python from pathlib import Path @@ -336,7 +339,7 @@ async with MCPServerStdio( ## 5. MCP服务管理器 -当你有多个MCP服务时,请使用`MCPServerManager`预先连接它们,并向你的智能体公开已连接的子集。有关构造函数选项和重新连接行为,请参阅[MCPServerManager API参考](ref/mcp/manager.md)。 +当你有多个MCP服务时,可使用`MCPServerManager`预先连接它们,并向智能体公开已连接的服务子集。有关构造函数选项和重新连接行为,请参阅[MCPServerManager API参考](ref/mcp/manager.md)。 ```python from agents import Agent, Runner @@ -360,18 +363,18 @@ async with MCPServerManager(servers) as manager: 关键行为: - 当`drop_failed_servers=True`(默认值)时,`active_servers`仅包含成功连接的服务。 -- 失败会记录在`failed_servers`和`errors`中。 -- 设置`strict=True`会在首次连接失败时抛出错误。 -- 调用`reconnect(failed_only=True)`以重试失败的服务,或调用`reconnect(failed_only=False)`以重启所有服务。 -- 使用`connect_timeout_seconds`、`cleanup_timeout_seconds`和`connect_in_parallel`来调整生命周期行为。 +- 失败信息会记录在`failed_servers`和`errors`中。 +- 设置`strict=True`可在第一次连接失败时抛出异常。 +- 调用`reconnect(failed_only=True)`可重试连接失败的服务,调用`reconnect(failed_only=False)`则会重启所有服务。 +- 使用`connect_timeout_seconds`、`cleanup_timeout_seconds`和`connect_in_parallel`调整生命周期行为。 -## 通用服务能力 +## 常见服务能力 -以下各节适用于各种MCP服务传输方式(确切API范围取决于服务类)。 +以下各节适用于各种MCP服务传输方式(确切的API接口取决于服务类)。 ## 工具筛选 -每个MCP服务都支持工具筛选器,以便你仅公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 +每个MCP服务都支持工具筛选,因此你可以仅公开智能体所需的函数。筛选既可以在构造时执行,也可以在每次运行时动态执行。 ### 静态工具筛选 @@ -397,7 +400,7 @@ filesystem_server = MCPServerStdio( ### 动态工具筛选 -对于更复杂的逻辑,请传入一个接收[`ToolFilterContext`][agents.mcp.ToolFilterContext]的可调用对象。该可调用对象可以是同步或异步的;当工具应被公开时返回`True`。 +对于更复杂的逻辑,请传入一个接收[`ToolFilterContext`][agents.mcp.ToolFilterContext]的可调用对象。该可调用对象可以是同步或异步的,并在应公开工具时返回`True`。 ```python from pathlib import Path @@ -421,15 +424,15 @@ async with MCPServerStdio( ... ``` -筛选上下文会公开当前活动的`run_context`、请求这些工具的`agent`以及`server_name`。 +筛选上下文会提供当前`run_context`、请求工具的`agent`以及`server_name`。 ## 提示词 MCP服务还可以提供用于动态生成智能体指令的提示词。支持提示词的服务会公开两个 方法: -- `list_prompts()`枚举可用的提示词模板。 -- `get_prompt(name, arguments)`获取一个具体提示词,可选带有参数。 +- `list_prompts()`列出可用的提示词模板。 +- `get_prompt(name, arguments)`获取具体提示词,并可选择附带参数。 ```python from agents import Agent @@ -449,19 +452,19 @@ agent = Agent( ## 缓存 -每次智能体运行都会在每个MCP服务上调用`list_tools()`。远程服务可能引入明显延迟,因此所有MCP服务类都公开了`cache_tools_list`选项。仅当你确信工具定义不会频繁变化时,才将其设置为`True`。要在之后强制获取新列表,请在服务实例上调用`invalidate_tools_cache()`。 +每次智能体运行都会在每个MCP服务上调用`list_tools()`。远程服务可能会引入明显延迟,因此所有MCP服务类都提供`cache_tools_list`选项。仅当你确信工具定义不会频繁变化时,才将其设置为`True`。如需之后强制获取最新列表,请在服务实例上调用`invalidate_tools_cache()`。 ## 追踪 [追踪](./tracing.md)会自动捕获MCP活动,包括: -1. 调用MCP服务以列出工具。 -2. 工具调用中的MCP相关信息。 +1. 为列出工具而对MCP服务发起的调用。 +2. 工具调用中与MCP相关的信息。 ![MCP追踪截图](../assets/images/mcp-tracing.jpg) ## 延伸阅读 -- [Model Context Protocol](https://modelcontextprotocol.io/) – 规范和设计指南。 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的stdio、SSE和Streamable HTTP示例代码。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 完整的托管MCP演示,包括审批和连接器。 \ No newline at end of file +- [Model Context Protocol](https://modelcontextprotocol.io/) – 规范与设计指南。 +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的stdio、SSE和Streamable HTTP代码示例。 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 包含审批和连接器的完整托管式MCP演示。 \ No newline at end of file diff --git a/docs/zh/sandbox/clients.md b/docs/zh/sandbox/clients.md index 2c965962de..3c57a36bfa 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -2,42 +2,42 @@ search: exclude: true --- -# 沙盒客户端 +# 沙箱客户端 -使用本页面选择沙盒任务的运行位置。在大多数情况下,`SandboxAgent` 定义保持不变,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙盒客户端和客户端专属选项。 +使用本页选择沙箱任务的运行位置。在大多数情况下,`SandboxAgent` 定义保持不变,只需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端及客户端特定选项。 -!!! warning "Beta 测试功能" +!!! warning "Beta 功能" - 沙盒智能体目前处于 Beta 测试阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,未来还将逐步提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,未来还将提供更多高级功能。 ## 决策指南
-| 目标 | 首选 | 原因 | +| 目标 | 首选方案 | 原因 | | --- | --- | --- | -| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于在本地文件系统上开发。 | -| 基本的容器隔离 | `DockerSandboxClient` | 使用指定镜像在 Docker 内运行任务。 | -| 托管执行或生产环境级隔离 | 托管沙盒客户端 | 将工作区边界迁移至由服务提供商管理的环境。 | +| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于使用本地文件系统进行开发。 | +| 基础容器隔离 | `DockerSandboxClient` | 使用指定镜像在 Docker 内运行任务。 | +| 托管执行或生产环境级隔离 | 托管沙箱客户端 | 将工作区边界迁移到由供应商管理的环境中。 |
## 本地客户端 -对于大多数用户,建议从以下两个沙盒客户端之一开始: +对于大多数用户,建议从以下两个沙箱客户端之一开始:
-| 客户端 | 安装 | 适用场景 | 代码示例 | +| 客户端 | 安装 | 适用场景 | 示例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上实现最快的本地迭代。适合作为本地开发的默认选择。 | [Unix 本地入门代码示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 需要容器隔离,或使用指定镜像以确保本地环境的一致性。 | [Docker 入门代码示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 无 | 需要在 macOS 或 Linux 上实现最快的本地迭代。适合作为本地开发的默认选择。 | [Unix 本地入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 需要容器隔离,或需要使用特定镜像以确保本地环境的一致性。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix 本地模式是在本地文件系统上开始开发的最简单方式。当需要更强的环境隔离或与生产环境保持一致时,可以迁移到 Docker 或托管服务提供商。 +Unix 本地模式是基于本地文件系统开始开发的最简便方式。当需要更强的环境隔离或与生产环境保持一致时,请迁移到 Docker 或托管供应商。 -`SandboxPathGrant.host_path` 仅适用于 Docker,用于将主机路径映射到容器内的另一个 POSIX 路径。Unix 本地模式仅支持相同路径的授权。有关详细信息,请参阅[清单路径授权](guide.md#manifest)。 +`SandboxPathGrant.host_path` 仅适用于 Docker,可将主机路径映射到容器内的另一个 POSIX 路径。Unix 本地模式仅支持相同路径的授权。有关详细信息,请参阅[清单路径授权](guide.md#manifest)。 要从 Unix 本地模式切换到 Docker,请保持智能体定义不变,仅更改运行配置: @@ -56,74 +56,74 @@ run_config = RunConfig( ) ``` -当需要容器隔离或镜像一致性时,请使用此方式。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 +当需要容器隔离或镜像一致性时,请使用此配置。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 ## 挂载与远程存储 -挂载条目用于描述要公开的存储;挂载策略用于描述沙盒后端如何连接该存储。可从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管服务提供商的策略可从 `agents.extensions.sandbox` 或服务提供商专属扩展包中获取。 +挂载条目描述要公开的存储;挂载策略描述沙箱后端如何附加该存储。请从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管供应商策略可从 `agents.extensions.sandbox` 或供应商特定的扩展包中获取。 常用挂载选项: -- `mount_path`:存储在沙盒中的显示位置。相对路径基于清单根目录解析;绝对路径则按原样使用。 -- `read_only`:默认为 `True`。仅当沙盒需要将内容写回已挂载存储时,才将其设置为 `False`。 -- `mount_strategy`:必填。应使用同时兼容挂载条目和沙盒后端的策略。 +- `mount_path`:存储在沙箱中的显示位置。相对路径基于清单根目录解析;绝对路径则按原样使用。 +- `read_only`:默认为 `True`。仅当沙箱需要将数据写回已挂载存储时,才将其设为 `False`。 +- `mount_strategy`:必填。请使用同时与挂载条目和沙箱后端匹配的策略。 挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不会将已挂载的远程存储复制到保存的工作区中。 -通用本地和容器策略: +通用本地/容器策略:
-| 策略或模式 | 适用场景 | 备注 | +| 策略或模式 | 适用场景 | 说明 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙盒镜像能够运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可以在 `fuse` 模式或 `nfs` 模式下运行。 | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙箱镜像可以运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可以在 `fuse` 模式或 `nfs` 模式下运行。 | | `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像包含 `mount-s3`,并且需要以 Mountpoint 方式访问 S3 或 S3 兼容存储。 | 支持 `S3Mount` 和 `GCSMount`。 | | `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像包含 `blobfuse2` 并支持 FUSE。 | 支持 `AzureBlobMount`。 | | `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像包含 `mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | -| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前连接由卷驱动支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 支持 `rclone`;S3 和 GCS 还支持 `mountpoint`。 | +| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前附加由卷驱动程序支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 支持 `rclone`;S3 和 GCS 还支持 `mountpoint`。 |
## 支持的托管平台 -当需要托管环境时,通常可以继续使用相同的 `SandboxAgent` 定义,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙盒客户端。 +当需要托管环境时,通常可以沿用同一个 `SandboxAgent` 定义,只需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端。 -如果使用已发布的 SDK,而不是此代码仓库的检出版本,请通过对应的软件包额外依赖安装沙盒客户端依赖项。 +如果使用已发布的 SDK,而不是当前仓库的检出版本,请通过匹配的软件包附加项安装沙箱客户端依赖项。 -有关特定服务提供商的设置说明,以及代码仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。 +有关供应商特定的设置说明及仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。
-| 客户端 | 安装 | 代码示例 | +| 客户端 | 安装 | 示例 | | --- | --- | --- | -| `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | -| `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | -| `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) | -| `E2BSandboxClient` | `openai-agents[e2b]` | [E2B 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) | -| `ModalSandboxClient` | `openai-agents[modal]` | [Modal 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) | -| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) | -| `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) | +| `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | +| `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | +| `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) | +| `E2BSandboxClient` | `openai-agents[e2b]` | [E2B 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) | +| `ModalSandboxClient` | `openai-agents[modal]` | [Modal 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) | +| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) | +| `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) |
-托管沙盒客户端会提供服务提供商专属的挂载策略。请选择最适合相应存储服务提供商的后端和挂载策略: +托管沙箱客户端提供供应商特定的挂载策略。请选择最适合存储供应商的后端和挂载策略:
| 后端 | 挂载说明 | | --- | --- | -| Docker | 支持通过 `InContainerMountStrategy` 和 `DockerVolumeMountStrategy` 等本地策略挂载 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` 和 `S3FilesMount`。 | -| `ModalSandboxClient` | 支持通过 `ModalCloudBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和使用 HMAC 身份验证的 `GCSMount` 上挂载 Modal 云存储桶。可以使用内联凭据或具名 Modal Secret。 | -| `CloudflareSandboxClient` | 支持通过 `CloudflareBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和使用 HMAC 身份验证的 `GCSMount` 上挂载 Cloudflare 存储桶。 | +| Docker | 支持将 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` 和 `S3FilesMount` 与 `InContainerMountStrategy`、`DockerVolumeMountStrategy` 等本地策略配合使用。 | +| `ModalSandboxClient` | 支持通过 `ModalCloudBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和采用 HMAC 身份验证的 `GCSMount` 上挂载 Modal 云存储桶。可以使用内联凭据或具名 Modal Secret。 | +| `CloudflareSandboxClient` | 支持通过 `CloudflareBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和采用 HMAC 身份验证的 `GCSMount` 上挂载 Cloudflare 存储桶。 | | `BlaxelSandboxClient` | 支持通过 `BlaxelCloudBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和 `GCSMount` 上挂载云存储桶。还支持使用 `agents.extensions.sandbox.blaxel` 中的 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 挂载持久化 Blaxel Drive。 | | `DaytonaSandboxClient` | 支持通过 `DaytonaCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | | `E2BSandboxClient` | 支持通过 `E2BCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | | `RunloopSandboxClient` | 支持通过 `RunloopCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `VercelSandboxClient` | 支持通过 `VercelCloudBucketMountStrategy`,在 `S3Mount` 上挂载仅能在创建时配置的 S3 和 S3 兼容存储桶;已挂载存储的会话无法恢复,并且使用内联凭据时必须设置 `allow_s3_credential_exposure=True`。 | +| `VercelSandboxClient` | 支持通过 `VercelCloudBucketMountStrategy`,在 `S3Mount` 上挂载仅能在创建时指定的 S3 和 S3 兼容存储桶;已挂载的会话无法恢复,并且使用内联凭据时需要设置 `allow_s3_credential_exposure=True`。 |
-下表汇总了每个后端可以直接挂载的远程存储条目。 +下表汇总了各后端可以直接挂载的远程存储条目。
@@ -140,4 +140,4 @@ run_config = RunConfig(
-如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),了解本地运行、编码、记忆、任务转移和智能体组合模式;还可浏览 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions),查看托管沙盒客户端。 \ No newline at end of file +如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),其中包含本地、编码、记忆、任务转移和智能体组合模式;托管沙箱客户端的代码示例请参阅 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)。 \ No newline at end of file diff --git a/docs/zh/sessions/index.md b/docs/zh/sessions/index.md index 96f0d6e45b..daf1213fda 100644 --- a/docs/zh/sessions/index.md +++ b/docs/zh/sessions/index.md @@ -4,13 +4,13 @@ search: --- # 会话 -Agents SDK 提供内置会话记忆,用于在多次智能体运行之间自动维护对话历史记录,从而无需在各轮之间手动处理 `.to_input_list()`。 +Agents SDK提供内置会话记忆,可在多次智能体运行之间自动维护对话历史,无需在轮次之间手动处理 `.to_input_list()`。 -会话会存储特定会话的对话历史记录,使智能体能够维护上下文,而无需显式的手动记忆管理。这对于构建聊天应用或多轮对话尤其有用,因为你希望智能体记住之前的交互。 +会话会存储特定会话的对话历史,使智能体无需显式的手动记忆管理即可维护上下文。这对于构建聊天应用或多轮对话尤其有用,因为你希望智能体记住之前的交互。 -当你希望 SDK 为你管理客户端侧记忆时,请使用会话。会话不能在同一次运行中与 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 结合使用。如果你希望改用由 OpenAI 服务端管理的延续机制,请选择其中一种机制,而不是在其上叠加会话。 +当你希望 SDK 为你管理客户端侧记忆时,请使用会话。在同一次运行中,会话不能与 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 结合使用。如果希望改用由OpenAI服务管理的延续机制,请选择其中一种机制,而不是在其上叠加会话。 -## 快速开始 +## 快速入门 ```python from agents import Agent, Runner, SQLiteSession @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 使用同一会话恢复中断的运行 -如果某次运行因等待批准而暂停,请使用同一个会话实例(或另一个指向同一后端存储的会话实例)来恢复它,以便恢复后的轮次继续使用同一份已存储的对话历史记录。 +如果运行因等待批准而暂停,请使用同一会话实例(或指向同一底层存储的另一个会话实例)恢复运行,以便恢复后的轮次继续使用同一份已存储对话历史。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -63,31 +63,31 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## 核心会话行为 +## 会话的核心行为 启用会话记忆后: -1. **每次运行之前**:运行器会自动检索该会话的对话历史记录,并将其前置到输入项中。 -2. **每次运行之后**:运行期间生成的所有新项(用户输入、助手响应、工具调用等)都会自动存储到会话中。 -3. **上下文保留**:同一会话的每次后续运行都会包含完整的对话历史记录,使智能体能够维护上下文。 +1. **每次运行前**:运行器会自动检索该会话的对话历史,并将其添加到输入条目之前。 +2. **每次运行后**:运行期间生成的所有新条目(用户输入、助手响应、工具调用等)都会自动存储到会话中。 +3. **上下文保留**:之后每次使用同一会话运行时,都会包含完整的对话历史,使智能体能够维护上下文。 -这消除了手动调用 `.to_input_list()` 并在运行之间管理对话状态的需要。 +这样便无需手动调用 `.to_input_list()`,也无需在运行之间管理对话状态。 ## 历史记录与新输入的合并控制 -当你传入会话时,运行器通常会按如下方式准备模型输入: +传入会话时,运行器通常会按以下顺序准备模型输入: -1. 会话历史记录(从 `session.get_items(...)` 检索) +1. 会话历史(从 `session.get_items(...)` 检索) 2. 新轮次输入 -使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 在模型调用之前自定义该合并步骤。回调会接收两个列表: +使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 可在调用模型之前自定义该合并步骤。回调会接收两个列表: -- `history`:检索到的会话历史记录(已规范化为输入项格式) -- `new_input`:当前轮次的新输入项 +- `history`:检索到的会话历史(已规范化为输入条目格式) +- `new_input`:当前轮次的新输入条目 -返回应发送给模型的最终输入项列表。 +返回应发送给模型的最终输入条目列表。 -回调接收的是这两个列表的副本,因此你可以安全地修改它们。返回的列表会控制该轮次的模型输入,但 SDK 仍然只会持久化属于新轮次的项。因此,对旧历史记录重新排序或过滤,并不会导致旧会话项被再次作为新输入保存。 +回调接收的是两个列表的副本,因此你可以安全地修改它们。返回的列表会控制该轮次的模型输入,但 SDK 仍只持久化属于新轮次的条目。因此,对旧历史记录进行重新排序或筛选,不会导致旧会话条目被再次保存为新输入。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -当你需要自定义裁剪、重新排序或选择性纳入历史记录,同时不改变会话存储项的方式时,请使用此功能。如果你需要在模型调用前立即进行更靠后的最终处理步骤,请使用[运行智能体指南](../running_agents.md)中的 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 +当你需要自定义历史记录的裁剪、重新排序或选择性纳入方式,同时又不改变会话存储条目的方式时,请使用此功能。如果需要在模型调用前立即执行后续的最终处理,请使用[运行智能体指南](../running_agents.md)中的 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 -## 检索历史记录的限制 +## 历史记录检索限制 -使用 [`SessionSettings`][agents.memory.SessionSettings] 控制每次运行前获取多少历史记录。 +使用 [`SessionSettings`][agents.memory.SessionSettings] 控制每次运行前获取的历史记录量。 -- `SessionSettings(limit=None)`(默认):检索所有可用的会话项 -- `SessionSettings(limit=N)`:仅检索最近的 `N` 个项 +- `SessionSettings(limit=None)`(默认):检索所有可用的会话条目 +- `SessionSettings(limit=N)`:仅检索最近的 `N` 个条目 -你可以通过 [`RunConfig.session_settings`][agents.run.RunConfig.session_settings] 按运行应用此设置: +可以通过 [`RunConfig.session_settings`][agents.run.RunConfig.session_settings] 将此设置应用于单次运行: ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -如果你的会话实现公开了默认会话设置,`RunConfig.session_settings` 会为该次运行覆盖任何非 `None` 的值。这对于长对话很有用:你可以在不改变会话默认行为的情况下限制检索大小。 +如果会话实现提供默认会话设置,`RunConfig.session_settings` 会在该次运行中覆盖所有非 `None` 值。这对于长对话非常有用,可以限制检索量而无需更改会话的默认行为。 ## 记忆操作 ### 基本操作 -会话支持用于管理对话历史记录的多种操作: +会话支持多种对话历史管理操作: ```python from agents import SQLiteSession @@ -167,7 +167,7 @@ await session.clear_session() ### 使用 pop_item 进行修正 -当你想撤销或修改对话中的最后一项时,`pop_item` 方法尤其有用: +当你想撤销或修改对话中的最后一个条目时,`pop_item` 方法尤其有用: ```python from agents import Agent, Runner, SQLiteSession @@ -198,32 +198,32 @@ print(f"Agent: {result.final_output}") ## 内置会话实现 -SDK 为不同用例提供了多个会话实现: +SDK 针对不同使用场景提供了多种会话实现: ### 内置会话实现的选择 -在阅读下方详细示例之前,使用此表选择一个起点。 +阅读下方详细示例之前,可使用此表选择起点。 -| 会话类型 | 适用场景 | 备注 | +| 会话类型 | 最适用场景 | 说明 | | --- | --- | --- | -| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可基于文件或内存 | -| `AsyncSQLiteSession` | 使用 `aiosqlite` 的异步 SQLite | 支持异步驱动的扩展后端 | -| `RedisSession` | 跨多个工作进程/服务的共享记忆 | 适合低延迟分布式部署 | +| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可由文件支持或在内存中运行 | +| `AsyncSQLiteSession` | 使用 `aiosqlite` 的异步 SQLite | 支持异步驱动程序的扩展后端 | +| `RedisSession` | 跨工作进程或服务共享记忆 | 适合低延迟分布式部署 | | `SQLAlchemySession` | 使用现有数据库的生产应用 | 适用于 SQLAlchemy 支持的数据库 | -| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;通过原子序列计数器保证顺序 | -| `DaprSession` | 带有 Dapr sidecar 的云原生部署 | 支持多种状态存储,以及 TTL 和一致性控制 | -| `OpenAIConversationsSession` | OpenAI 中由服务端管理的存储 | 基于 OpenAI Conversations API 的历史记录 | -| `OpenAIResponsesCompactionSession` | 带有自动压缩的长对话 | 另一个会话后端的包装器 | -| `AdvancedSQLiteSession` | SQLite 加分支/分析 | 功能集较重;请参阅专门页面 | -| `EncryptedSession` | 基于另一个会话的加密 + TTL | 包装器;请先选择底层后端 | +| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;使用原子序列计数器保持顺序 | +| `DaprSession` | 使用 Dapr sidecar 的云原生部署 | 支持多种状态存储,以及 TTL 和一致性控制 | +| `OpenAIConversationsSession` | 由OpenAI服务管理的存储 | 由OpenAI Conversations API 支持的历史记录 | +| `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 对另一种会话后端的封装 | +| `AdvancedSQLiteSession` | 支持分支和分析的 SQLite | 功能集更丰富;请参阅专门页面 | +| `EncryptedSession` | 在另一会话之上提供加密和 TTL | 封装器;请先选择底层后端 | -一些实现有包含更多详细信息的专门页面;这些页面已在其小节中以内联链接形式给出。 +某些实现拥有提供更多详细信息的专门页面;其链接位于对应小节中。 -如果你正在为 ChatKit 实现 Python 服务,请使用 `chatkit.store.Store` 实现来持久化 ChatKit 的线程和项。Agents SDK 会话(如 `SQLAlchemySession`)会管理 SDK 侧的对话历史记录,但它们不能直接替代 ChatKit 的 store。请参阅 [`chatkit-python` 关于实现 ChatKit 数据存储的指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 +如果你正在为 ChatKit 实现 Python 服务,请使用 `chatkit.store.Store` 实现来持久化 ChatKit 的线程和条目。`SQLAlchemySession` 等 Agents SDK会话用于管理 SDK 侧的对话历史,但不能直接替代 ChatKit 的存储。请参阅 [`chatkit-python` 中有关实现 ChatKit 数据存储的指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 ### OpenAI Conversations API 会话 -通过 `OpenAIConversationsSession` 使用 [OpenAI 的 Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 +通过 `OpenAIConversationsSession` 使用 [OpenAI的 Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -259,7 +259,7 @@ print(result.final_output) # "California" ### OpenAI Responses 压缩会话 -使用 `OpenAIResponsesCompactionSession` 通过 Responses API(`responses.compact`)压缩已存储的对话历史记录。它会包装一个底层会话,并可根据 `should_trigger_compaction` 在每轮之后自动压缩。不要用它包装 `OpenAIConversationsSession`;这两个功能以不同方式管理历史记录。 +使用 `OpenAIResponsesCompactionSession` 通过 Responses API(`responses.compact`)压缩已存储的对话历史。它会封装一个底层会话,并可根据 `should_trigger_compaction` 在每个轮次后自动执行压缩。不要用它封装 `OpenAIConversationsSession`;这两项功能管理历史记录的方式不同。 #### 典型用法(自动压缩) @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -默认情况下,一旦达到候选阈值,压缩会在每轮之后运行。 +默认情况下,一旦达到候选阈值,每个轮次后都会执行压缩。 -当你已经使用 Responses API 响应 ID 串联各轮时,`compaction_mode="previous_response_id"` 效果最好。`compaction_mode="input"` 则会基于当前会话项重建压缩请求,这在响应链不可用,或你希望以会话内容作为事实来源时很有用。默认的 `"auto"` 会选择可用的最安全选项。 +当你已经使用 Responses API 响应 ID 串联各轮次时,`compaction_mode="previous_response_id"` 效果最佳。`compaction_mode="input"` 则根据当前会话条目重新构建压缩请求,适用于响应链不可用,或你希望将会话内容作为权威数据源的情况。默认值 `"auto"` 会选择最安全的可用选项。 -如果你的智能体使用 `ModelSettings(store=False)` 运行,Responses API 不会保留最后一个响应以供之后查找。在这种无状态设置中,默认的 `"auto"` 模式会退回到基于输入的压缩,而不是依赖 `previous_response_id`。有关完整示例,请参阅 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 +如果智能体使用 `ModelSettings(store=False)` 运行,Responses API 不会保留最后一次响应供后续查询。在这种无状态配置中,默认的 `"auto"` 模式会改用基于输入的压缩,而不依赖 `previous_response_id`。完整示例请参阅 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 -#### 自动压缩对流式传输的阻塞 +#### 自动压缩造成的流式传输阻塞 -压缩会清空并重写会话历史记录,因此 SDK 会等待压缩完成后才将该运行视为完成。在流式传输模式下,这意味着如果压缩开销较大,在最后一个输出 token 之后,`run.stream_events()` 可能仍会保持打开数秒。 +压缩会清除并重写会话历史,因此 SDK 会等待压缩完成后,才将运行视为已完成。在流式传输模式下,如果压缩负载较重,这意味着最后一个输出 token 生成后,`run.stream_events()` 可能还会保持打开数秒。 -如果你希望低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲期间)自行调用 `run_compaction()`。你可以根据自己的标准决定何时强制压缩。 +如果你需要低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲时)自行调用 `run_compaction()`。你可以根据自己的条件决定何时强制执行压缩。 ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 异步 SQLite 会话 -当你希望使用由 `aiosqlite` 支持的 SQLite 持久化时,请使用 `AsyncSQLiteSession`。 +如果希望使用由 `aiosqlite` 支持的 SQLite 持久化,请使用 `AsyncSQLiteSession`。 ```bash pip install aiosqlite @@ -349,7 +349,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis 会话 -使用 `RedisSession` 在多个工作进程或服务之间共享会话记忆。 +使用 `RedisSession` 可在多个工作进程或服务之间共享会话记忆。 ```bash pip install openai-agents[redis] @@ -365,11 +365,14 @@ session = RedisSession.from_url( url="redis://localhost:6379/0", ) result = await Runner.run(agent, "Hello", session=session) +await session.close() ``` +`from_url(...)` 会创建并拥有 Redis 客户端。调用 `close()` 后,会话将进入终止状态,后续会话操作会引发 `RuntimeError`;重复或并发调用 `close()` 是安全的。如果应用已管理 Redis 客户端,请直接构造 `RedisSession(...)` 并传入 `redis_client=...`。在这种情况下,`close()` 不执行任何操作,调用方仍拥有客户端所有权,会话也仍可使用。 + ### SQLAlchemy 会话 -使用任何 SQLAlchemy 支持的数据库实现的生产就绪型 Agents SDK 会话持久化: +使用任何 SQLAlchemy 支持的数据库,为 Agents SDK提供可用于生产环境的会话持久化: ```python from agents.extensions.memory import SQLAlchemySession @@ -387,11 +390,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -请参阅 [SQLAlchemy 会话](sqlalchemy_session.md)了解详细文档。 +详细文档请参阅 [SQLAlchemy 会话](sqlalchemy_session.md)。 ### Dapr 会话 -当你已经运行 Dapr sidecar,或希望在不更改智能体代码的情况下,让会话存储能够在不同状态存储后端之间迁移时,请使用 `DaprSession`。 +如果你已运行 Dapr sidecar,或希望在不更改智能体代码的情况下,让会话存储可在不同状态存储后端之间迁移,请使用 `DaprSession`。 ```bash pip install openai-agents[dapr] @@ -412,18 +415,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -备注: +注意: -- `from_address(...)` 会为你创建并拥有 Dapr 客户端。如果你的应用已经管理了一个客户端,请直接使用 `dapr_client=...` 构造 `DaprSession(...)`。 -- 当底层状态存储支持 TTL 时,传入 `ttl=...` 可让它自动使旧会话数据过期。 -- 当你需要更强的写后读保证时,传入 `consistency=DAPR_CONSISTENCY_STRONG`。 -- Dapr Python SDK 还会检查 HTTP sidecar 端点。在本地开发中,启动 Dapr 时除了 `dapr_address` 中使用的 gRPC 端口外,还应使用 `--dapr-http-port 3500`。 -- 请参阅 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)获取完整设置演练,包括本地组件和故障排查。 +- `from_address(...)` 会为你创建并拥有 Dapr 客户端。如果应用已管理客户端,请直接构造 `DaprSession(...)` 并传入 `dapr_client=...`。 +- 退出上下文或调用 `close()` 会使拥有客户端的会话进入终止状态;后续会话操作会引发 `RuntimeError`,但重复或并发调用 `close()` 是安全的。使用注入的客户端时,`close()` 不执行任何操作,会话仍可使用。 +- 当底层状态存储支持 TTL 时,传入 `ttl=...` 可让其自动使旧会话数据过期。 +- 当需要更强的写后读保证时,传入 `consistency=DAPR_CONSISTENCY_STRONG`。 +- Dapr Python SDK 还会检查 HTTP sidecar 端点。在本地开发中,除了 `dapr_address` 使用的 gRPC 端口之外,还应使用 `--dapr-http-port 3500` 启动 Dapr。 +- 完整的设置演练(包括本地组件和故障排除)请参阅 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 ### MongoDB 会话 -对于已使用 MongoDB 或需要可水平扩展的多进程会话存储的应用,请使用 `MongoDBSession`。 +对于已使用 MongoDB,或需要可横向扩展的多进程会话存储的应用,请使用 `MongoDBSession`。 ```bash pip install openai-agents[mongodb] @@ -446,12 +450,12 @@ print(result.final_output) await session.close() ``` -备注: +注意: -- `from_uri(...)` 会创建并拥有 `AsyncMongoClient`,并在 `session.close()` 时关闭它。如果你的应用已经管理了一个客户端,请直接使用 `client=...` 构造 `MongoDBSession(...)`;在这种情况下,`session.close()` 不执行任何操作,生命周期由调用方管理。 -- 通过向 `from_uri(...)` 传入 `mongodb+srv://user:password@cluster.example.mongodb.net` URI,即可连接到 [MongoDB Atlas](https://www.mongodb.com/products/platform),无需其他更改。 -- 会使用两个集合,且二者名称都可通过 `sessions_collection=`(默认 `agent_sessions`)和 `messages_collection=`(默认 `agent_messages`)配置。首次使用时会自动创建索引。每个消息文档都带有一个单调递增的 `seq` 计数器,可在并发写入者和进程之间保持顺序。 -- 在首次运行之前,使用 `await session.ping()` 验证连接性。 +- `from_uri(...)` 会创建并拥有 `AsyncMongoClient`,并在调用 `session.close()` 时将其关闭。如果应用已管理客户端,请直接构造 `MongoDBSession(...)` 并传入 `client=...`;在这种情况下,`session.close()` 不执行任何操作,生命周期仍由调用方管理。 +- 若要连接到 [MongoDB Atlas](https://www.mongodb.com/products/platform),只需向 `from_uri(...)` 传入 `mongodb+srv://user:password@cluster.example.mongodb.net` URI,无需进行其他更改。 +- 系统会使用两个集合,二者的名称均可配置:通过 `sessions_collection=` 配置会话集合(默认值为 `agent_sessions`),通过 `messages_collection=` 配置消息集合(默认值为 `agent_messages`)。首次使用时会自动创建索引。每个消息文档都包含一个单调递增的 `seq` 计数器,可在并发写入方和进程之间保持顺序。 +- 在首次运行前,使用 `await session.ping()` 验证连接。 ### 高级 SQLite 会话 @@ -475,11 +479,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -请参阅 [高级 SQLite 会话](advanced_sqlite_session.md)了解详细文档。 +详细文档请参阅[高级 SQLite 会话](advanced_sqlite_session.md)。 ### 加密会话 -用于任何会话实现的透明加密包装器: +适用于任何会话实现的透明加密封装器: ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -502,17 +506,17 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -请参阅 [加密会话](encrypted_session.md)了解详细文档。 +详细文档请参阅[加密会话](encrypted_session.md)。 ### 其他会话类型 -还有一些其他内置选项。请参阅 `examples/memory/` 以及 `extensions/memory/` 下的源代码。 +此外还有一些内置选项。请参阅 `examples/memory/` 以及 `extensions/memory/` 下的源代码。 -## 操作模式 +## 运维模式 ### 会话 ID 命名 -使用有意义的会话 ID 来帮助你组织对话: +使用有意义的会话 ID 以便组织对话: - 基于用户:`"user_12345"` - 基于线程:`"thread_abc123"` @@ -520,18 +524,18 @@ result = await Runner.run(agent, "Hello", session=session) ### 记忆持久化 -- 使用内存 SQLite(`SQLiteSession("session_id")`)处理临时对话 -- 使用基于文件的 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)处理持久对话 -- 当你需要基于 `aiosqlite` 的实现时,使用异步 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) -- 使用基于 Redis 的会话(`RedisSession.from_url("session_id", url="redis://...")`)实现共享的低延迟会话记忆 -- 对于使用 SQLAlchemy 支持的现有数据库的生产系统,使用基于 SQLAlchemy 的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) -- 对于已使用 MongoDB 或需要多进程、可水平扩展会话存储的应用,使用 MongoDB 会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) -- 对于支持 30+ 数据库后端,并内置遥测、追踪和数据隔离的生产级云原生部署,使用 Dapr 状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) -- 当你希望将历史记录存储在 OpenAI Conversations API 中时,使用 OpenAI 托管存储(`OpenAIConversationsSession()`) -- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)为任何会话包装透明加密和基于 TTL 的过期机制 -- 对于更高级的用例,可以考虑为其他生产系统(例如 Django)实现自定义会话后端 +- 对于临时对话,使用内存 SQLite(`SQLiteSession("session_id")`) +- 对于持久化对话,使用基于文件的 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) +- 当需要基于 `aiosqlite` 的实现时,使用异步 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) +- 对于共享的低延迟会话记忆,使用 Redis 支持的会话(`RedisSession.from_url("session_id", url="redis://...")`) +- 对于拥有 SQLAlchemy 所支持现有数据库的生产系统,使用基于 SQLAlchemy 的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) +- 对于已使用 MongoDB,或需要多进程、可横向扩展会话存储的应用,使用 MongoDB 会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) +- 对于生产环境中的云原生部署,使用 Dapr 状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),它支持 30 多种数据库后端,并内置遥测、追踪和数据隔离功能 +- 如果希望将历史记录存储在 OpenAI Conversations API 中,请使用 OpenAI托管的存储(`OpenAIConversationsSession()`) +- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)为任意会话提供透明加密和基于 TTL 的过期机制 +- 对于更高级的使用场景,可以考虑为其他生产系统(例如 Django)实现自定义会话后端 -### 多个会话 +### 多会话 ```python from agents import Agent, Runner, SQLiteSession @@ -577,7 +581,7 @@ result2 = await Runner.run( ## 完整示例 -下面是展示会话记忆实际效果的完整示例: +以下完整示例展示了会话记忆的实际运作方式: ```python import asyncio @@ -641,7 +645,7 @@ if __name__ == "__main__": ## 自定义会话实现 -你可以创建一个遵循 [`Session`][agents.memory.session.Session] 协议的类来实现自己的会话记忆: +你可以创建遵循 [`Session`][agents.memory.session.Session] 协议的类,实现自己的会话记忆: ```python from agents.memory.session import SessionABC @@ -686,26 +690,26 @@ result = await Runner.run( ## 社区会话实现 -社区已开发出其他会话实现: +社区开发了其他会话实现: -| 包 | 描述 | +| 软件包 | 描述 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 基于 Django ORM 的会话,适用于任何 Django 支持的数据库(PostgreSQL、MySQL、SQLite 等) | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 适用于任何 Django 支持的数据库(PostgreSQL、MySQL、SQLite 等)的基于 Django ORM 的会话 | -如果你构建了一个会话实现,欢迎提交文档 PR,将它添加到这里! +如果你构建了会话实现,欢迎提交文档 PR,将其添加到此处! ## API 参考 -有关详细 API 文档,请参阅: +详细 API 文档请参阅: - [`Session`][agents.memory.session.Session] - 协议接口 - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 实现 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 压缩包装器 +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 压缩封装器 - [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础 SQLite 实现 - [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于 `aiosqlite` 的异步 SQLite 实现 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - 基于 Redis 的会话实现 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 支持的会话实现 - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 基于 SQLAlchemy 的实现 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - 基于 MongoDB 的会话实现 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 支持的会话实现 - [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状态存储实现 - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析的增强型 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 用于任何会话的加密包装器 \ No newline at end of file +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任意会话的加密封装器 \ No newline at end of file diff --git a/docs/zh/tools.md b/docs/zh/tools.md index e195a8bb2d..1d4d6d38fc 100644 --- a/docs/zh/tools.md +++ b/docs/zh/tools.md @@ -4,43 +4,43 @@ search: --- # 工具 -工具让智能体能够执行操作:例如获取数据、运行代码、调用外部 API,甚至使用计算机。SDK 支持五个目录: +工具让智能体能够执行操作,例如获取数据、运行代码、调用外部 API,甚至操作计算机。SDK 支持五个目录: -- 由OpenAI托管的工具:与模型一起在OpenAI服务上运行。 +- 由OpenAI托管的工具:与模型一同在OpenAI服务上运行。 - 本地/运行时执行工具:`ComputerTool` 和 `ApplyPatchTool` 始终在你的环境中运行,而 `ShellTool` 可以在本地或托管容器中运行。 -- Function calling:将任意 Python 函数封装为工具。 +- Function Calling:将任意 Python 函数封装为工具。 - Agents as tools:将智能体公开为可调用工具,而无需完整的任务转移。 -- 实验性 Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 +- 实验性功能:Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 ## 工具类型选择 -将本页面用作目录,然后跳转到与你所控制运行时相匹配的章节。 +可将本页面作为目录,然后跳转到与你所控制的运行时相匹配的部分。 | 如果你想要…… | 从这里开始 | | --- | --- | -| 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管MCP、图像生成) | [托管工具](#hosted-tools) | -| 使用工具搜索将大型工具集合推迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | -| 通过生成的 JavaScript 协调多个工具调用 | [编程式工具调用](#programmatic-tool-calling) | -| 在自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | +| 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管 MCP、图像生成) | [托管工具](#hosted-tools) | +| 通过工具搜索将大型工具集延迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | +| 通过生成的 JavaScript 协调多个工具调用 | [程序化工具调用](#programmatic-tool-calling) | +| 在你自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | | 将 Python 函数封装为工具 | [工具调用](#function-tools) | | 让一个智能体在不进行任务转移的情况下调用另一个智能体 | [Agents as tools](#agents-as-tools) | -| 从智能体运行限定于工作区的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | +| 从智能体运行限定于工作区的 Codex 任务 | [实验性功能:Codex 工具](#experimental-codex-tool) | ## 托管工具 -使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI 提供了一些内置工具: +使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI提供了一些内置工具: -- [`WebSearchTool`][agents.tool.WebSearchTool] 让智能体能够进行网络检索。 +- [`WebSearchTool`][agents.tool.WebSearchTool] 允许智能体检索网络。 - [`FileSearchTool`][agents.tool.FileSearchTool] 允许从你的 OpenAI 向量存储中检索信息。 -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 让 LLM 能够在沙盒环境中执行代码。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] 将远程MCP服务的工具公开给模型。 +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 允许 LLM 在沙盒环境中执行代码。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] 将远程 MCP 服务的工具公开给模型。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] 根据提示词生成图像。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] 让模型能够按需加载延迟加载的工具、命名空间或托管MCP服务。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 让模型能够通过生成的 JavaScript 协调符合条件的工具。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] 允许模型按需加载延迟加载的工具、命名空间或托管 MCP 服务。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 允许模型通过生成的 JavaScript 协调符合条件的工具。 高级托管搜索选项: -- 除了 `vector_store_ids` 和 `max_num_results`,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。 +- 除 `vector_store_ids` 和 `max_num_results` 外,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。 - `WebSearchTool` 支持 `filters`、`user_location` 和 `search_context_size`。 ```python @@ -64,9 +64,9 @@ async def main(): ### 托管工具搜索 -工具搜索让 OpenAI Responses 模型能够将大型工具集合推迟到运行时加载,使模型仅加载当前轮次所需的子集。当你有大量工具调用、命名空间组或托管MCP服务,并且希望在不预先公开每个工具的情况下减少工具架构所占的 token 时,这非常有用。 +工具搜索允许 OpenAI Responses 模型将大型工具集延迟到运行时加载,使模型仅加载当前轮次所需的工具子集。当你拥有大量工具调用、命名空间组或托管 MCP 服务,并希望在不预先公开所有工具的情况下减少工具模式所占用的 token 时,此功能非常有用。 -如果候选工具在构建智能体时已经确定,请优先使用托管工具搜索。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 +如果构建智能体时已经知道候选工具,请从托管工具搜索开始。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 ```python from typing import Annotated @@ -111,26 +111,26 @@ print(result.final_output) 注意事项: -- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 支持依赖于 `openai>=2.25.0`。 -- 在智能体上配置延迟加载的工具集合时,只添加一个 `ToolSearchTool()`。 -- 可搜索的工具集合包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 +- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 支持情况取决于 `openai>=2.25.0`。 +- 在智能体上配置延迟加载的工具集时,只添加一个 `ToolSearchTool()`。 +- 可搜索的工具集包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 - 延迟加载的工具调用必须与 `ToolSearchTool()` 配合使用。仅包含命名空间的设置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 -- `tool_namespace()` 将 `FunctionTool` 实例归入一个具有共享名称和描述的命名空间。当你有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最合适的方式。 -- OpenAI 的官方最佳实践指南是[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 -- 在可能的情况下,优先使用命名空间或托管MCP服务,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高级搜索界面,并节省更多 token。 -- 命名空间可以混合包含立即可用和延迟加载的工具。没有 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟加载工具则通过工具搜索加载。 -- 根据经验,每个命名空间应保持相对精简,最好少于 10 个函数。 -- 具名 `tool_choice` 不能以单独的命名空间名称或仅延迟加载的工具为目标。请优先使用 `auto`、`required` 或真正的顶层可调用工具名称。 -- `ToolSearchTool(execution="client")` 用于手动编排 Responses。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会引发异常,而不会替你执行。 -- 工具搜索活动会出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中,并具有专用的项目和事件类型。 -- 有关命名空间加载和顶层延迟加载工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 +- `tool_namespace()` 将多个 `FunctionTool` 实例归入一个共享的命名空间名称和描述下。当你拥有大量相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最佳选择。 +- OpenAI官方最佳实践指南建议[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 +- 如有可能,优先使用命名空间或托管 MCP 服务,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高层级搜索界面,并节省更多 token。 +- 命名空间可以混合包含立即可用和延迟加载的工具。没有 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟工具则通过工具搜索加载。 +- 根据经验,应让每个命名空间保持较小规模,最好少于 10 个函数。 +- 具名 `tool_choice` 不能以单独的命名空间名称或仅延迟加载的工具为目标。请优先使用 `auto`、`required` 或实际可在顶层调用的工具名称。 +- `ToolSearchTool(execution="client")` 用于手动编排 Responses。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会引发异常,而不会替你执行它。 +- 工具搜索活动会以专用条目和事件类型出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中。 +- 有关涵盖命名空间加载和顶层延迟工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 - 官方平台指南:[工具搜索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 -### 编程式工具调用 +### 程序化工具调用 -编程式工具调用让受支持的 OpenAI Responses 模型能够生成 JavaScript,以调用符合条件的工具、组合其输出,并向模型返回一个结果。它适用于范围明确的工作流,这些工作流可受益于循环、分支、并行调用或中间计算,而无需在每次工具调用后都与模型往返交互。 +程序化工具调用允许受支持的 OpenAI Responses 模型生成 JavaScript,以调用符合条件的工具、组合其输出,并向模型返回一个结果。它适用于边界明确的工作流,这些工作流能够受益于循环、分支、并行调用或中间计算,并且不需要在每次工具调用后都与模型往返交互。 -生成的程序在全新的托管 V8 环境中运行。它不具备 Node.js API、文件系统或网络访问权限,也不是持久进程。该程序只能与明确允许的工具交互。 +生成的程序在全新的托管 V8 环境中运行。它无法使用 Node.js API,不能访问文件系统或网络,也没有持久化进程。程序只能与明确允许的工具交互。 ```python from pydantic import BaseModel @@ -167,21 +167,22 @@ print(result.final_output) 注意事项: -- 编程式工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 -- 一个智能体最多只能添加一个 `ProgrammaticToolCallingTool()`。该智能体还必须公开至少一个可通过编程方式调用的工具、一个 `ToolSearchTool()`,或由提示词管理的工具集合。 -- `allowed_callers` 控制工具的调用方式。省略该参数时,仅允许模型直接调用。使用 `["programmatic"]` 可仅允许程序访问,使用 `["direct", "programmatic"]` 则允许两种方式。 -- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、shell 和补丁应用工具直接公开 `allowed_callers`。对于托管MCP和 Code Interpreter,请在 `tool_config` 中设置 `allowed_callers`。 -- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或 dataclass 等结构化返回注解会自动转换为严格的对象输出架构,并在值返回给程序之前进行验证。如果函数没有可用的注解,请使用 `output_type=...`;如果你已有严格的对象架构,则可使用较低层级的 `output_json_schema={...}` 作为替代方案。`output_type` 和 `output_json_schema` 互斥。返回普通 `str`、`Any` 和 `None` 时仍不指定类型。 -- 由程序拥有的 SDK 工具仍使用常规 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、重试、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,并且 SDK 会保留每个子调用与程序调用方之间的关系。 -- 对审批敏感或影响较大的工具通常更适合作为直接调用保留,以便人员在每项操作成为大型程序的一部分之前进行审查。如果由程序拥有的调用因审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 -- 编程式工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。生成的程序必须先由模型加载延迟工具,然后才能调用它们。 -- `program` 项目及其由程序拥有的子调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 +- 程序化工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 +- 一个智能体最多添加一个 `ProgrammaticToolCallingTool()`。智能体还必须公开至少一个可由程序调用的工具、一个由命名空间、延迟函数或延迟托管 MCP 服务支持的 `ToolSearchTool()`,或者一个由提示词管理的不透明工具集。没有可搜索工具集的单独 `ToolSearchTool()` 会被拒绝。 +- `allowed_callers` 控制工具的调用方式。省略它时,仅允许模型直接调用。使用 `["programmatic"]` 可限制为仅由程序访问,使用 `["direct", "programmatic"]` 则可同时允许两种方式。 +- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、Shell 和补丁应用工具直接公开 `allowed_callers`。对于托管 MCP 和 Code Interpreter,请在 `tool_config` 内设置 `allowed_callers`。 +- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或 dataclass 等结构化返回注解会自动转换为严格的对象输出模式,并在将值返回给程序之前进行验证。如果函数没有可用的注解,请使用 `output_type=...`;如果你已经拥有严格的对象模式,可使用更底层的 `output_json_schema={...}` 备用方式。`output_type` 与 `output_json_schema` 互斥。普通 `str`、`Any` 和 `None` 返回值仍不带类型。对于由模式支持且归程序所有的调用,默认失败格式化程序会被禁用,因为其自由格式文本不符合输出模式。因此,除非你提供返回符合模式的 JSON 的自定义 `failure_error_function`,否则处理程序异常将继续向上传播。 +- 归程序所有的 SDK 工具仍使用正常的 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,SDK 也会保留每个子调用与程序调用者之间的关系。 +- 只要存在 `ProgrammaticToolCallingTool()`,模型请求重试就会采用更严格的重放安全边界,即使程序尚未执行也是如此。SDK 会为这些请求禁用提供方管理的重试和 WebSocket 事件前重试。仅当提供方建议明确将重放标记为安全时,Runner 重试策略才会重试;单独使用 `retry_policies.network_error()` 不会覆盖此边界。 +- 涉及审批或影响较大的工具通常更适合作为直接调用,以便人工在每个操作成为大型程序的一部分之前进行审查。如果归程序所有的调用因等待审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 +- 程序化工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。模型必须先加载延迟工具,生成的程序才能调用它们。 +- `program` 条目及其普通的归程序所有的子工具调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。托管 MCP 审批请求和工具目录则使用专用的 MCP 条目和流式事件。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 - 有关完整的并发库存规划代码示例,请参阅 `examples/tools/programmatic_tool_calling.py`。 -- 官方平台指南:[编程式工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 +- 官方平台指南:[程序化工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 -### 托管容器 shell 与技能 +### 托管容器 Shell 与技能 -`ShellTool` 还支持在OpenAI托管的容器中执行。当你希望模型在托管容器中运行 shell 命令,而不是在本地运行时中运行时,请使用此模式。 +`ShellTool` 还支持在OpenAI托管的容器中执行。当你希望模型在托管容器中而不是本地运行时中执行 Shell 命令时,请使用此模式。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -214,52 +215,52 @@ result = await Runner.run( print(result.final_output) ``` -若要在后续运行中复用现有容器,请设置 `environment={"type": "container_reference", "container_id": "cntr_..."}`。 +要在后续运行中复用现有容器,请设置 `environment={"type": "container_reference", "container_id": "cntr_..."}`。 注意事项: -- 托管 shell 可通过 Responses API 的 shell 工具使用。 +- 托管 Shell 可通过 Responses API 的 Shell 工具使用。 - `container_auto` 为请求预配容器;`container_reference` 复用现有容器。 - `container_auto` 还可以包含 `file_ids` 和 `memory_limit`。 - `environment.skills` 接受技能引用和内联技能包。 - 使用托管环境时,请勿在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 - `network_policy` 支持 `disabled` 和 `allowlist` 模式。 -- 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于域的密钥。 +- 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于域名的密钥。 - 有关完整代码示例,请参阅 `examples/tools/container_shell_skill_reference.py` 和 `examples/tools/container_shell_inline_skill.py`。 -- OpenAI 平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 +- OpenAI平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 ## 本地运行时工具 -本地运行时工具在模型响应本身之外执行。模型仍然决定何时调用它们,但实际工作由你的应用或配置的执行环境完成。 +本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用或已配置的执行环境完成。 -`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 横跨两种模式:如果需要托管执行,请使用上述托管容器配置;如果希望命令在你自己的进程中运行,请使用下述本地运行时配置。 +`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 横跨两种模式:如果需要托管执行,请使用上面的托管容器配置;如果希望命令在你自己的进程中运行,请使用下面的本地运行时配置。 本地运行时工具要求你提供实现: - [`ComputerTool`][agents.tool.ComputerTool]:实现 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 接口,以启用 GUI/浏览器自动化。 -- [`ShellTool`][agents.tool.ShellTool]:适用于本地执行和托管容器执行的最新 shell 工具。 -- [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 shell 集成。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor],以便在本地应用差异。 -- 本地 shell 技能可通过 `ShellTool(environment={"type": "local", "skills": [...]})` 使用。 +- [`ShellTool`][agents.tool.ShellTool]:同时适用于本地执行和托管容器执行的最新 Shell 工具。 +- [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 Shell 集成。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor],以在本地应用差异。 +- 可通过 `ShellTool(environment={"type": "local", "skills": [...]})` 使用本地 Shell 技能。 ### ComputerTool 与 Responses 计算机工具 -`ComputerTool` 仍然是一个本地执行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该执行框架映射到 OpenAI Responses API 的计算机操作界面。 +`ComputerTool` 仍是本地运行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该运行框架映射到 OpenAI Responses API 的计算机操作界面。 -对于明确的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布版内置工具载荷 `{"type": "computer"}`。较旧的 `computer-use-preview` 模型则继续使用预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI 的[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中所述的平台迁移一致: +对于显式的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布版内置工具载荷 `{"type": "computer"}`。较旧的 `computer-use-preview` 模型仍使用预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中所述的平台迁移一致: - 模型:`computer-use-preview` -> `gpt-5.5` - 工具选择器:`computer_use_preview` -> `computer` -- 计算机调用结构:每个 `computer_call` 包含一个 `action` -> `computer_call` 上批量的 `actions[]` +- 计算机调用形式:每个 `computer_call` 包含一个 `action` -> `computer_call` 上的批量 `actions[]` - 截断:预览版路径要求使用 `ModelSettings(truncation="auto")` -> 正式发布版路径不要求 -SDK 会根据实际 Responses 请求中的有效模型选择相应的传输结构。如果你使用提示词模板,并且由于模型由提示词指定而使请求省略 `model`,SDK 会继续使用兼容预览版的计算机载荷;除非你明确保留 `model="gpt-5.5"`,或通过 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制使用正式发布版选择器。 +SDK 会根据实际 Responses 请求中的有效模型选择该传输格式。如果你使用提示词模板,并且由于模型由提示词指定而在请求中省略 `model`,SDK 会继续使用与预览版兼容的计算机载荷,除非你显式保留 `model="gpt-5.5"`,或者使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版选择器。 -存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 均会被接受,并规范化为与有效请求模型匹配的内置选择器。不存在 `ComputerTool` 时,这些字符串仍然会像普通函数名称一样处理。 +存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 都会被接受,并被规范化为与有效请求模型相匹配的内置选择器。不存在 `ComputerTool` 时,这些字符串仍会被视为普通函数名称。 -当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别很重要。正式发布版 `computer` 载荷在序列化时不需要 `environment` 或尺寸,因此工厂尚未解析也没有问题。兼容预览版的序列化仍然需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 可以发送 `environment`、`display_width` 和 `display_height`。 +当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别非常重要。正式发布版 `computer` 载荷在序列化时不需要 `environment` 或尺寸,因此工厂尚未解析也没有问题。与预览版兼容的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 发送 `environment`、`display_width` 和 `display_height`。 -在运行时,两条路径仍然使用同一个本地执行框架。预览版响应会发出包含单个 `action` 的 `computer_call` 项目;`gpt-5.5` 可以发出批量的 `actions[]`,SDK 会按顺序执行这些操作,然后生成一个 `computer_call_output` 截图项目。有关基于 Playwright 的可运行执行框架,请参阅 `examples/tools/computer_use.py`。 +在运行时,两条路径仍使用相同的本地运行框架。预览版响应会发出包含单个 `action` 的 `computer_call` 条目;`gpt-5.5` 可以发出批量 `actions[]`,SDK 会依次执行这些操作,然后生成 `computer_call_output` 屏幕截图条目。有关基于 Playwright 的可运行框架,请参阅 `examples/tools/computer_use.py`。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -305,14 +306,16 @@ agent = Agent( 你可以将任意 Python 函数用作工具。Agents SDK 会自动设置该工具: -- 工具名称将是 Python 函数的名称(也可以自行提供名称) -- 工具描述将取自函数的文档字符串(也可以自行提供描述) -- 函数输入的架构会根据函数参数自动创建 +- 工具名称将使用 Python 函数的名称(你也可以提供名称) +- 工具描述将取自函数的文档字符串(你也可以提供描述) +- 函数输入的模式会根据函数参数自动创建 - 除非禁用,否则每个输入的描述都取自函数的文档字符串 -我们使用 Python 的 `inspect` 模块提取函数签名,使用 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,并使用 `pydantic` 创建架构。 +由 `@tool` 创建的工具通过只读 `__wrapped__` 属性公开原始 Python 可调用对象。这对检查和测试很有用,但直接调用它会绕过工具运行时管线,包括模式验证、上下文注入、安全防护措施、超时、失败处理和追踪。手动构建的 `FunctionTool` 实例不公开 `__wrapped__`。 -使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏工具调用,直到 `ToolSearchTool()` 将其加载。你还可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关工具调用进行分组。有关完整设置和限制,请参阅[托管工具搜索](#hosted-tool-search)。 +我们使用 Python 的 `inspect` 模块提取函数签名,并结合 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,再使用 `pydantic` 创建模式。 + +使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏工具调用,直到 `ToolSearchTool()` 加载它。你也可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关的工具调用进行分组。有关完整设置和限制,请参阅[托管工具搜索](#hosted-tool-search)。 ```python import json @@ -365,10 +368,10 @@ for tool in agent.tools: ``` -1. 你可以使用任意 Python 类型作为函数参数,并且函数可以是同步或异步的。 -2. 如果存在文档字符串,则会使用它来获取描述和参数描述 +1. 你可以使用任意 Python 类型作为函数参数,函数可以是同步或异步函数。 +2. 如果存在文档字符串,则会使用它来获取描述和参数描述。 3. 函数可以选择接收 `context`(必须是第一个参数)。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 -4. 你可以将经过装饰的函数传递给工具列表。 +4. 你可以将经过装饰的函数传入工具列表。 ??? note "展开以查看输出" @@ -442,11 +445,11 @@ for tool in agent.tools: ### 从工具调用返回图像或文件 -除了返回文本输出之外,你还可以返回一个或多个图像或文件作为工具调用的输出。为此,可以返回以下任意内容: +除了返回文本输出外,你还可以将一个或多个图像或文件作为工具调用的输出返回。为此,你可以返回以下任意内容: -- 图像:[`ToolOutputImage`][agents.tool.ToolOutputImage](或其 TypedDict 版本 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) -- 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或其 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- 文本:字符串、可转换为字符串的对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或其 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- 图像:[`ToolOutputImage`][agents.tool.ToolOutputImage](或 TypedDict 版本 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) +- 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) +- 文本:字符串、可转换为字符串的对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### 自定义工具调用 @@ -454,8 +457,8 @@ for tool in agent.tools: - `name` - `description` -- `params_json_schema`,即参数的 JSON 架构 -- `on_invoke_tool`,它是一个异步函数,接收 [`ToolContext`][agents.tool_context.ToolContext] 和 JSON 字符串形式的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 +- `params_json_schema`,即参数的 JSON 模式 +- `on_invoke_tool`,即一个异步函数,它接收 [`ToolContext`][agents.tool_context.ToolContext] 和以 JSON 字符串形式提供的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 ```python from typing import Any @@ -490,16 +493,16 @@ tool = FunctionTool( ### 参数与文档字符串的自动解析 -如前所述,我们会自动解析函数签名以提取工具架构,并解析文档字符串以提取工具和各个参数的描述。相关注意事项如下: +如前所述,我们会自动解析函数签名以提取工具模式,并解析文档字符串以提取工具及各个参数的描述。相关注意事项如下: -1. 签名解析通过 `inspect` 模块完成。我们使用类型注解了解参数类型,并动态构建 Pydantic 模型来表示整体架构。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 -2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这只是尽力而为;你可以在调用 `function_tool` 时明确设置格式。也可以将 `use_docstring_info` 设置为 `False` 来禁用文档字符串解析。对于 Google 风格的文档字符串,解析器还接受紧跟在摘要文本之后且中间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 章节。 +1. 签名解析通过 `inspect` 模块完成。我们使用类型注解理解参数类型,并动态构建 Pydantic 模型来表示整体模式。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 +2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这属于尽力而为;你也可以在调用 `function_tool` 时显式设置格式。还可以将 `use_docstring_info` 设置为 `False`,以禁用文档字符串解析。对于 Google 风格的文档字符串,解析器还接受紧接在摘要文本之后且中间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 部分。 -架构提取代码位于 [`agents.function_schema`][]。 +模式提取代码位于 [`agents.function_schema`][]。 ### 使用 Pydantic Field 约束和描述参数 -你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 相同,两种形式都受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 架构和验证均包含这些约束。 +你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,两种形式均受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 模式和验证会包含这些约束。 ```python from typing import Annotated @@ -570,15 +573,15 @@ except ToolTimeoutError as e: !!! note - 仅异步 `@function_tool` 处理程序支持超时配置。 + 超时配置仅支持异步 `@function_tool` 处理程序。 -### 工具调用中的错误处理 +### 工具调用错误处理 -通过 `@function_tool` 创建工具调用时,可以传入 `failure_error_function`。当工具调用崩溃时,此函数会向 LLM 提供错误响应。 +通过 `@function_tool` 创建工具调用时,可以传入 `failure_error_function`。如果工具调用崩溃,该函数会向 LLM 提供错误响应。 -- 默认情况下(即未传入任何内容时),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 -- 如果传入自己的错误函数,则会改为运行该函数,并将响应发送给 LLM。 -- 如果明确传入 `None`,则任何工具调用错误都会重新引发,由你处理。如果模型生成了无效 JSON,这可能是 `ModelBehaviorError`;如果你的代码崩溃,则可能是 `UserError`,等等。 +- 默认情况下(即不传入任何内容时),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 +- 如果传入自定义错误函数,则会改为运行该函数,并将响应发送给 LLM。 +- 如果显式传入 `None`,任何工具调用错误都会重新引发,供你自行处理。如果模型生成了无效 JSON,这可能是 `ModelBehaviorError`;如果你的代码崩溃,则可能是 `UserError`,等等。 ```python from agents import RunContextWrapper @@ -602,11 +605,11 @@ def get_user_profile(user_id: str) -> str: ``` -如果手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内部处理错误。 +如果手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数中处理错误。 ## Agents as tools -在某些工作流中,你可能希望由一个中心智能体编排专用智能体网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 +在某些工作流中,你可能希望由一个中央智能体编排由多个专用智能体组成的网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 ```python import asyncio @@ -652,9 +655,9 @@ if __name__ == "__main__": ### 工具智能体自定义 -`agent.as_tool` 函数是一种便捷方法,可以轻松地将智能体转换为工具。它支持 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval` 等常见运行时选项。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 +`agent.as_tool` 函数是一种便捷方法,可轻松将智能体转换为工具。它支持常见运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 -状态选项用于配置由工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请明确向两者传入相同的 `session`。与 `Runner.run` 一样,应为嵌套运行选择一种状态策略:使用由客户端管理的 `session`,或通过 `previous_response_id` 或 `conversation_id` 在服务端管理延续状态。 +状态选项用于配置由工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请显式向两者传入相同的 `session`。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:使用由客户端管理的 `session`,或者通过 `previous_response_id` 或 `conversation_id` 在服务端延续。 ```python from agents.decorators import tool @@ -678,12 +681,12 @@ async def run_my_agent() -> str: ### 工具智能体的结构化输入 -默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或 dataclass 类型)公开结构化架构。 +默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或 dataclass 类型)公开结构化模式。 其他选项: - `include_input_schema=True` 会在生成的嵌套输入中包含完整的 JSON Schema。 -- `input_builder=...` 让你可以完全自定义如何将结构化工具参数转换为嵌套智能体输入。 +- `input_builder=...` 允许你完全自定义如何将结构化工具参数转换为嵌套智能体输入。 - `RunContextWrapper.tool_input` 包含嵌套运行上下文中已解析的结构化载荷。 ```python @@ -708,17 +711,17 @@ translator_tool = translator_agent.as_tool( ### 工具智能体的审批门控 -`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理项目将显示在 `result.interruptions` 中;然后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人工介入指南](human_in_the_loop.md)。 +`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理条目会出现在 `result.interruptions` 中;然后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人工介入指南](human_in_the_loop.md)。 ### 自定义输出提取 -在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中心智能体。以下情况可能会需要这样做: +在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中央智能体。以下情况可能会用到此功能: -- 从子智能体的聊天历史中提取特定信息(例如 JSON 载荷)。 +- 从子智能体的聊天历史记录中提取特定信息(例如 JSON 载荷)。 - 转换或重新格式化智能体的最终答案(例如将 Markdown 转换为纯文本或 CSV)。 -- 验证输出,或在智能体响应缺失或格式错误时提供回退值。 +- 验证输出,或者在智能体响应缺失或格式错误时提供回退值。 -你可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现: +可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现: ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -737,11 +740,11 @@ json_tool = data_agent.as_tool( ) ``` -在自定义提取器内部,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在对嵌套结果进行后处理时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 +在自定义提取器内部,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在后处理嵌套结果时获取外层工具名称、调用 ID 或原始参数,这一属性非常有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 ### 嵌套智能体运行的流式传输 -向 `as_tool` 传入 `on_stream` 回调,以侦听嵌套智能体发出的流式传输事件,同时仍在流完成后返回其最终输出。 +向 `as_tool` 传入 `on_stream` 回调,以监听嵌套智能体发出的流式事件,同时仍会在流完成后返回其最终输出。 ```python from agents import AgentToolStreamEvent @@ -762,12 +765,12 @@ billing_agent_tool = billing_agent.as_tool( 预期行为: - 事件类型与 `StreamEvent["type"]` 一致:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 -- 提供 `on_stream` 会自动以流式传输模式运行嵌套智能体,并在返回最终输出前耗尽该流。 +- 提供 `on_stream` 后,嵌套智能体会自动以流式传输模式运行,并在返回最终输出前读取完流。 - 处理程序可以是同步或异步的;每个事件都会按到达顺序传递。 -- 通过模型工具调用来调用工具时,会提供 `tool_call`;直接调用时,其值可能为 `None`。 -- 有关完整的可运行示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 +- 通过模型工具调用来调用工具时,会存在 `tool_call`;直接调用时,其值可能为 `None`。 +- 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 -### 条件式工具启用 +### 工具的条件启用 你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选可供 LLM 使用的工具。 @@ -830,18 +833,18 @@ asyncio.run(main()) - **可调用函数**:接收 `(context, agent)` 并返回布尔值的函数 - **异步函数**:用于复杂条件逻辑的异步函数 -禁用的工具在运行时对 LLM 完全隐藏,因此适用于: +禁用的工具在运行时对 LLM 完全不可见,因此此功能适用于: -- 根据用户权限进行功能门控 -- 特定环境下的工具可用性(开发环境与生产环境) +- 根据用户权限控制功能 +- 特定于环境的工具可用性(开发环境与生产环境) - 对不同工具配置进行 A/B 测试 - 根据运行时状态动态筛选工具 -## 实验性 Codex 工具 +## 实验性功能:Codex 工具 -`codex_tool` 封装 Codex CLI,使智能体能够在工具调用期间运行限定于工作区的任务(shell、文件编辑、MCP工具)。此功能为实验性功能,可能会发生变化。 +`codex_tool` 封装了 Codex CLI,使智能体能够在工具调用期间运行限定于工作区的任务(Shell、文件编辑、MCP 工具)。此功能为实验性功能,将来可能发生变化。 -当你希望主智能体在不离开当前运行的情况下,将范围明确的工作区任务委托给 Codex 时,请使用它。默认情况下,工具名称为 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当智能体包含多个 Codex 工具时,每个工具必须使用唯一名称。 +当你希望主智能体在不退出当前运行的情况下,将边界明确的工作区任务委派给 Codex 时,可以使用它。默认工具名称为 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当智能体包含多个 Codex 工具时,每个工具必须使用唯一名称。 ```python from agents import Agent @@ -872,31 +875,31 @@ agent = Agent( 可从以下选项组开始: -- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以在何处操作。请配合设置这两个选项;当工作目录不在 Git 仓库内时,请设置 `skip_git_repo_check=True`。 -- 线程默认值:`default_thread_options=ThreadOptions(...)` 配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。请优先使用 `web_search_mode`,而不是旧版的 `web_search_enabled`。 -- 轮次默认值:`default_turn_options=TurnOptions(...)` 配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 -- 工具输入/输出:工具调用必须至少包含一个 `inputs` 项目,其格式为 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }`。`output_schema` 让你可以要求 Codex 返回结构化响应。 +- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可操作的位置。请将两者配对使用;如果工作目录不在 Git 仓库中,请设置 `skip_git_repo_check=True`。 +- 线程默认值:`default_thread_options=ThreadOptions(...)` 用于配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。请优先使用 `web_search_mode`,而不是旧版 `web_search_enabled`。 +- 轮次默认值:`default_turn_options=TurnOptions(...)` 用于配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 +- 工具输入/输出:工具调用必须至少包含一个 `inputs` 条目,其形式为 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }`。`output_schema` 允许你要求 Codex 返回结构化响应。 + +线程复用和持久化是相互独立的控制项: -线程复用和持久化是独立的控制项: +- `persist_session=True` 会让对同一工具实例的重复调用复用同一个 Codex 线程。 +- `use_run_context_thread_id=True` 会在运行上下文中存储并复用线程 ID,适用于共享同一可变上下文对象的多次运行。 +- 线程 ID 的优先顺序为:单次调用的 `thread_id`、运行上下文线程 ID(如果启用),最后是已配置的 `thread_id` 选项。 +- 当 `name="codex"` 时,默认运行上下文键为 `codex_thread_id`;当 `name="codex_"` 时,则为 `codex_thread_id_`。可以使用 `run_context_thread_id_key` 覆盖它。 -- `persist_session=True` 会让对同一工具实例的重复调用复用一个 Codex 线程。 -- `use_run_context_thread_id=True` 会在运行上下文中存储并复用线程 ID,适用于共享同一可变上下文对象的多个运行。 -- 线程 ID 的优先级依次为:每次调用的 `thread_id`、运行上下文线程 ID(如果已启用),然后是已配置的 `thread_id` 选项。 -- 对于 `name="codex"`,默认运行上下文键为 `codex_thread_id`;对于 `name="codex_"`,则为 `codex_thread_id_`。可使用 `run_context_thread_id_key` 覆盖该键。 - 运行时配置: -- 身份验证:设置 `CODEX_API_KEY`(首选)或 `OPENAI_API_KEY`,或者传入 `codex_options={"api_key": "..."}`。 +- 身份验证:设置 `CODEX_API_KEY`(推荐)或 `OPENAI_API_KEY`,或者传入 `codex_options={"api_key": "..."}`。 - 运行时:`codex_options.base_url` 会覆盖 CLI 基础 URL。 - 二进制文件解析:设置 `codex_options.codex_path_override`(或 `CODEX_PATH`)以固定 CLI 路径。否则,SDK 会先从 `PATH` 中解析 `codex`,然后回退到捆绑的供应商二进制文件。 -- 环境:`codex_options.env` 完全控制子进程环境。提供该选项时,子进程不会继承 `os.environ`。 +- 环境:`codex_options.env` 完全控制子进程环境。提供该选项后,子进程不会继承 `os.environ`。 - 流限制:`codex_options.codex_subprocess_stream_limit_bytes`(或 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)控制 stdout/stderr 读取器限制。有效范围为 `65536` 到 `67108864`;默认值为 `8388608`。 -- 流式传输:`on_stream` 接收线程/轮次生命周期事件和项目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 和 `error` 项目更新)。 -- 输出:结果包括 `response`、`usage` 和 `thread_id`;用量会添加到 `RunContextWrapper.usage`。 +- 流式传输:`on_stream` 接收线程/轮次生命周期事件和条目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 和 `error` 条目更新)。 +- 输出:结果包括 `response`、`usage` 和 `thread_id`;使用量会添加到 `RunContextWrapper.usage`。 -参考: +参考资料: - [Codex 工具 API 参考](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions 参考](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions 参考](ref/extensions/experimental/codex/turn_options.md) -- 有关完整的可运行示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file +- 有关完整的可运行代码示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file From 21c88f582cdf7a8cbd96c33398eefe166a791d5c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 11:37:59 +0900 Subject: [PATCH 087/473] docs: improve the consistency of docs --- README.md | 74 ++++++++++++++++++++++++++++++++++++--------------- docs/index.md | 7 ++--- mkdocs.yml | 58 ++++++++++++++++++++-------------------- 3 files changed, 85 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 584e895292..53b111f2de 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,15 @@ The OpenAI Agents SDK is a lightweight yet powerful framework for building multi ### Core concepts: 1. [**Agents**](https://openai.github.io/openai-agents-python/agents): LLMs configured with instructions, tools, guardrails, and handoffs -1. [**Sandbox Agents**](https://openai.github.io/openai-agents-python/sandbox_agents): Agents preconfigured to work with a container to perform work over long time horizons. +1. [**Sandbox agents**](https://openai.github.io/openai-agents-python/sandbox_agents): Agents preconfigured to work with a container to perform work over long time horizons. +1. [**Realtime agents**](https://openai.github.io/openai-agents-python/realtime/quickstart/): Build powerful voice agents with `gpt-realtime-2.1` and full agent features +1. [**Voice agents**](https://openai.github.io/openai-agents-python/voice/quickstart/): Build voice pipelines that combine speech-to-text, an agent workflow, and text-to-speech 1. **[Agents as tools](https://openai.github.io/openai-agents-python/tools/#agents-as-tools) / [Handoffs](https://openai.github.io/openai-agents-python/handoffs/)**: Delegating to other agents for specific tasks 1. [**Tools**](https://openai.github.io/openai-agents-python/tools/): Various Tools let agents take actions (functions, MCP, hosted tools) 1. [**Guardrails**](https://openai.github.io/openai-agents-python/guardrails/): Configurable safety checks for input and output validation 1. [**Human in the loop**](https://openai.github.io/openai-agents-python/human_in_the_loop/): Built-in mechanisms for involving humans across agent runs 1. [**Sessions**](https://openai.github.io/openai-agents-python/sessions/): Automatic conversation history management across agent runs 1. [**Tracing**](https://openai.github.io/openai-agents-python/tracing/): Built-in tracking of agent runs, allowing you to view, debug and optimize your workflows -1. [**Realtime Agents**](https://openai.github.io/openai-agents-python/realtime/quickstart/): Build powerful voice agents with `gpt-realtime-2.1` and full agent features Explore the [examples](https://github.com/openai/openai-agents-python/tree/main/examples) directory to see the SDK in action, and read our [documentation](https://openai.github.io/openai-agents-python/) for more details. @@ -48,7 +49,26 @@ For voice support, install with the optional `voice` group: `uv add 'openai-agen ## Run your first agents -The SDK supports three primary ways to run agents. Set the `OPENAI_API_KEY` environment variable before running any of these examples. +The SDK supports four primary ways to run agents. Set the `OPENAI_API_KEY` environment variable before running any of these examples. + +### Run a text agent + +Use a text `Agent` for workflows that do not need a persistent realtime connection or a sandbox workspace. + +```python +from agents import Agent, Runner + +agent = Agent(name="Assistant", instructions="You are a helpful assistant") + +result = Runner.run_sync(agent, "Write a haiku about recursion in programming.") +print(result.final_output) + +# Code within the code, +# Functions calling themselves, +# Infinite loop's dance. +``` + +(_For Jupyter notebook users, see [hello_world_jupyter.ipynb](https://github.com/openai/openai-agents-python/blob/main/examples/basic/hello_world_jupyter.ipynb)_) ### Run a sandbox agent @@ -77,25 +97,6 @@ result = Runner.run_sync( print(result.final_output) ``` -### Run a text agent - -Use a text `Agent` for workflows that do not need a persistent realtime connection or a sandbox workspace. - -```python -from agents import Agent, Runner - -agent = Agent(name="Assistant", instructions="You are a helpful assistant") - -result = Runner.run_sync(agent, "Write a haiku about recursion in programming.") -print(result.final_output) - -# Code within the code, -# Functions calling themselves, -# Infinite loop's dance. -``` - -(_For Jupyter notebook users, see [hello_world_jupyter.ipynb](https://github.com/openai/openai-agents-python/blob/main/examples/basic/hello_world_jupyter.ipynb)_) - ### Run a realtime agent Use a [`RealtimeAgent`](https://openai.github.io/openai-agents-python/realtime/quickstart/) for low-latency, server-side voice and multimodal experiences over WebSocket. @@ -124,6 +125,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` +### Run a voice agent + +Use a [`VoicePipeline`](https://openai.github.io/openai-agents-python/voice/quickstart/) to turn audio into text, run an agent workflow, and stream generated speech. + +```python +import asyncio + +import numpy as np + +from agents import Agent +from agents.voice import AudioInput, SingleAgentVoiceWorkflow, VoicePipeline + + +async def main() -> None: + agent = Agent(name="Assistant", instructions="You are a helpful voice assistant.") + pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) + audio_input = AudioInput(buffer=np.zeros(24000 * 3, dtype=np.int16)) + + result = await pipeline.run(audio_input) + async for event in result.stream(): + if event.type == "voice_stream_event_audio": + # Forward or play event.data. + pass + + +if __name__ == "__main__": + asyncio.run(main()) +``` + Explore the [examples](https://github.com/openai/openai-agents-python/tree/main/examples) directory to see the SDK in action, and read our [documentation](https://openai.github.io/openai-agents-python/) for more details. ## Acknowledgements diff --git a/docs/index.md b/docs/index.md index 2c52670ce5..a660769cc7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,17 +17,18 @@ The SDK has two driving design principles: Here are the main features of the SDK: -- **Agent loop**: A built-in agent loop that handles tool invocation, sends results back to the LLM, and continues until the task is complete. +- **Agents**: Build agents with instructions, tools, guardrails, handoffs, and a built-in loop that continues until the task is complete. +- **Sandbox agents**: Run specialists inside real isolated workspaces with manifest-defined files, sandbox client choice, and resumable sandbox sessions. +- **Realtime agents**: Build powerful voice agents with `gpt-realtime-2.1`, automatic interruption detection, context management, guardrails, and more. +- **Voice agents**: Build voice pipelines that combine speech-to-text, an agent workflow, and text-to-speech. - **Python-first**: Use built-in language features to orchestrate and chain agents, rather than needing to learn new abstractions. - **Agents as tools / Handoffs**: A powerful mechanism for coordinating and delegating work across multiple agents. -- **Sandbox agents**: Run specialists inside real isolated workspaces with manifest-defined files, sandbox client choice, and resumable sandbox sessions. - **Guardrails**: Run input validation and safety checks in parallel with agent execution, and fail fast when checks do not pass. - **Function tools**: Turn any Python function into a tool with automatic schema generation and Pydantic-powered validation. - **MCP server tool calling**: Built-in MCP server tool integration that works the same way as function tools. - **Sessions**: A persistent memory layer for maintaining working context within an agent loop. - **Human in the loop**: Built-in mechanisms for involving humans across agent runs. - **Tracing**: Built-in tracing for visualizing, debugging, and monitoring workflows, with support for the OpenAI suite of evaluation, fine-tuning, and distillation tools. -- **Realtime Agents**: Build powerful voice agents with `gpt-realtime-2.1`, automatic interruption detection, context management, guardrails, and more. ## Agents SDK or Responses API? diff --git a/mkdocs.yml b/mkdocs.yml index dd4aa2f33a..7b94b8f943 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,14 @@ plugins: - Concepts: sandbox/guide.md - Sandbox clients: sandbox/clients.md - Agent memory: sandbox/memory.md + - Realtime agents: + - Quickstart: realtime/quickstart.md + - Transport: realtime/transport.md + - Guide: realtime/guide.md + - Voice agents: + - Quickstart: voice/quickstart.md + - Pipeline: voice/pipeline.md + - Tracing: voice/tracing.md - Models: models/index.md - Tools: tools.md - Guardrails: guardrails.md @@ -77,14 +85,6 @@ plugins: - Usage: usage.md - Model context protocol (MCP): mcp.md - Tracing: tracing.md - - Realtime agents: - - Quickstart: realtime/quickstart.md - - Transport: realtime/transport.md - - Guide: realtime/guide.md - - Voice agents: - - Quickstart: voice/quickstart.md - - Pipeline: voice/pipeline.md - - Tracing: voice/tracing.md - Agent visualization: visualization.md - REPL utility: repl.md - Examples: examples.md @@ -209,6 +209,13 @@ plugins: - 概念: sandbox/guide.md - Sandbox クライアント: sandbox/clients.md - エージェントメモリ: sandbox/memory.md + - リアルタイムエージェント: + - realtime/quickstart.md + - realtime/guide.md + - 音声エージェント: + - voice/quickstart.md + - voice/pipeline.md + - voice/tracing.md - モデル: models/index.md - tools.md - guardrails.md @@ -227,13 +234,6 @@ plugins: - usage.md - mcp.md - tracing.md - - リアルタイムエージェント: - - realtime/quickstart.md - - realtime/guide.md - - 音声エージェント: - - voice/quickstart.md - - voice/pipeline.md - - voice/tracing.md - visualization.md - repl.md - コード例: examples.md @@ -252,6 +252,13 @@ plugins: - 개념: sandbox/guide.md - 샌드박스 클라이언트: sandbox/clients.md - 에이전트 메모리: sandbox/memory.md + - 실시간 에이전트: + - realtime/quickstart.md + - realtime/guide.md + - 음성 에이전트: + - voice/quickstart.md + - voice/pipeline.md + - voice/tracing.md - 모델: models/index.md - tools.md - guardrails.md @@ -270,13 +277,6 @@ plugins: - usage.md - mcp.md - tracing.md - - 실시간 에이전트: - - realtime/quickstart.md - - realtime/guide.md - - 음성 에이전트: - - voice/quickstart.md - - voice/pipeline.md - - voice/tracing.md - visualization.md - repl.md - 코드 예제: examples.md @@ -295,6 +295,13 @@ plugins: - 概念: sandbox/guide.md - 沙箱客户端: sandbox/clients.md - 智能体记忆: sandbox/memory.md + - 实时智能体: + - realtime/quickstart.md + - realtime/guide.md + - 语音智能体: + - voice/quickstart.md + - voice/pipeline.md + - voice/tracing.md - 模型: models/index.md - tools.md - guardrails.md @@ -313,13 +320,6 @@ plugins: - usage.md - mcp.md - tracing.md - - 实时智能体: - - realtime/quickstart.md - - realtime/guide.md - - 语音智能体: - - voice/quickstart.md - - voice/pipeline.md - - voice/tracing.md - visualization.md - repl.md - 示例: examples.md From 3c7b56d838091a637d092db0d715d69e8fc737cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:07:23 +0900 Subject: [PATCH 088/473] chore(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#4080) --- .github/workflows/docs.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/release-pr.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/tests.yml | 10 +++++----- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c97d67c10e..69afcb3e97 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Determine docs-only push id: docs-only run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f6addf9ab..ea995cfabf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 with: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 2235cb33dc..919d23f06c 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 ref: main diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index a9e4e81af0..91225fb15a 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -27,7 +27,7 @@ jobs: exit 1 fi - name: Checkout merge commit - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 ref: ${{ github.event.pull_request.merge_commit_sha }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 306f16ddfe..1e33cbc14c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Detect code changes id: changes run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Detect code changes id: changes run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" @@ -80,7 +80,7 @@ jobs: OPENAI_API_KEY: fake-for-tests steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Detect code changes id: changes run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" @@ -113,7 +113,7 @@ jobs: OPENAI_API_KEY: fake-for-tests steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Detect code changes id: changes shell: bash @@ -141,7 +141,7 @@ jobs: OPENAI_API_KEY: fake-for-tests steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Detect docs changes id: changes run: ./.github/scripts/detect-changes.sh docs "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" From 0fe74a1194d74407cd343ee317f5b8abd3ba833a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:07:37 +0900 Subject: [PATCH 089/473] chore(deps): bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.2 (#4081) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ea995cfabf..084d2d7fe4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,4 +32,4 @@ jobs: - name: Build package run: uv build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 From 118b2ac8ee024cd6df8863227881a213a830cdf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:08:07 +0900 Subject: [PATCH 090/473] chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#4083) --- .github/workflows/release-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 91225fb15a..483cb17a16 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.merge_commit_sha }} - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: python-version: "3.11" - name: Configure git From bc3e93c3edcb4c37a7e4f61f235d808cdebdce7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:08:17 +0900 Subject: [PATCH 091/473] chore(deps): bump actions/stale from 10.3.0 to 11.0.0 (#4084) --- .github/workflows/issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index de9746908a..8e0adbad52 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -10,7 +10,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 with: days-before-issue-stale: 7 days-before-issue-close: 3 From 87425fae1c2a9a4327686f1fa36eef2aabffdc1d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 1 Aug 2026 19:12:31 +0900 Subject: [PATCH 092/473] chore: update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 39 +++++++++++++++++---- .github/ISSUE_TEMPLATE/feature_request.md | 4 +-- .github/ISSUE_TEMPLATE/model_provider.md | 42 +++++++++++++++++++---- .github/ISSUE_TEMPLATE/question.md | 4 +-- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1998fdbc41..6ed47976db 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,27 +2,52 @@ name: Bug report about: Report a bug title: '' -labels: bug +labels: '' assignees: '' --- ### Please read this first -- **Have you read the docs?**[Agents SDK docs](https://openai.github.io/openai-agents-python/) +- **Have you read the docs?** [Agents SDK docs](https://openai.github.io/openai-agents-python/) - **Have you searched for related issues?** Others may have faced similar issues. ### Describe the bug -A clear and concise description of what the bug is. + ### Debug information -- Agents SDK version: (e.g. `v0.0.3`) -- Python version (e.g. Python 3.14) +- Agents SDK version: +- Related library versions (optional, e.g. `any-llm`, `litellm`, or `pydantic`): +- Python version: +- Operating system: +- Model and model provider: +- Does the issue reproduce with the latest Agents SDK release? +- Does the issue occur consistently or intermittently? + +If an error occurred, include the full traceback and any relevant logs. Remove API keys, tokens, model input or output, and other sensitive information before posting. + + + +```text + +``` ### Repro steps -Ideally provide a minimal python script that can be run to reproduce the bug. +Ideally provide a minimal, self-contained Python script that can be run to reproduce the bug. + +```python +from agents import Agent, Runner + +agent = Agent( + name="Example agent", + instructions="...", + # Add the model and any other settings needed to reproduce the bug. +) +result = Runner.run_sync(agent, "...") +print(result.final_output) +``` ### Expected behavior -A clear and concise description of what you expected to happen. + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 73586eaacb..0f9037fd21 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -9,8 +9,8 @@ assignees: '' ### Please read this first -- **Have you read the docs?**[Agents SDK docs](https://openai.github.io/openai-agents-python/) +- **Have you read the docs?** [Agents SDK docs](https://openai.github.io/openai-agents-python/) - **Have you searched for related issues?** Others may have had similar requests ### Describe the feature -What is the feature you're requesting? How would it work? Please provide examples and details if possible. + diff --git a/.github/ISSUE_TEMPLATE/model_provider.md b/.github/ISSUE_TEMPLATE/model_provider.md index a4c7a18cc7..cc612aa9ab 100644 --- a/.github/ISSUE_TEMPLATE/model_provider.md +++ b/.github/ISSUE_TEMPLATE/model_provider.md @@ -2,25 +2,53 @@ name: Custom model providers about: Questions or bugs about using non-OpenAI models title: '' -labels: bug +labels: '' assignees: '' --- ### Please read this first -- **Have you read the custom model provider docs, including the 'Common issues' section?** [Model provider docs](https://openai.github.io/openai-agents-python/models/#using-other-llm-providers) +- **Have you read the custom model provider docs, including the troubleshooting section?** [Model provider docs](https://openai.github.io/openai-agents-python/models/#non-openai-models) - **Have you searched for related issues?** Others may have faced similar issues. ### Describe the question -A clear and concise description of what the question or bug is. + ### Debug information -- Agents SDK version: (e.g. `v0.0.3`) -- Python version (e.g. Python 3.14) +- Agents SDK version: +- Related library versions (optional, e.g. `any-llm`, `litellm`, or `pydantic`): +- Python version: +- Operating system: +- Model and model provider: +- Integration method (e.g. Any-LLM, LiteLLM, custom `ModelProvider`, or direct `Model` implementation): +- Does the issue reproduce with the latest Agents SDK release? +- Does the issue occur consistently or intermittently? + +If an error occurred, include the full traceback and any relevant logs. Remove API keys, tokens, model input or output, and other sensitive information before posting. + + + +```text + +``` ### Repro steps -Ideally provide a minimal python script that can be run to reproduce the issue. + +Ideally provide a minimal, self-contained Python script that can be run to reproduce the issue. + +```python +from agents import Agent, Runner + +agent = Agent( + name="Example agent", + instructions="...", + # Add the model provider, model, and any other settings needed to reproduce the issue. +) + +result = Runner.run_sync(agent, "...") +print(result.final_output) +``` ### Expected behavior -A clear and concise description of what you expected to happen. + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 6c639d72c5..8613074e60 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -9,8 +9,8 @@ assignees: '' ### Please read this first -- **Have you read the docs?**[Agents SDK docs](https://openai.github.io/openai-agents-python/) +- **Have you read the docs?** [Agents SDK docs](https://openai.github.io/openai-agents-python/) - **Have you searched for related issues?** Others may have had similar requests ### Question -Describe your question. Provide details if available. + From d5f51d3c7f672ad79f7c05a34173894f3203b5d4 Mon Sep 17 00:00:00 2001 From: "Sohail(Neel) Sarkar" Date: Sat, 1 Aug 2026 19:27:20 -0400 Subject: [PATCH 093/473] fix(agent-tools): honor falsey custom output extractors (#4088) --- src/agents/agent.py | 2 +- tests/test_agent_as_tool.py | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index c4899b2a8b..73bfbc2cda 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -939,7 +939,7 @@ async def dispatch_stream_events() -> None: scope_id=tool_state_scope_id, ) - if custom_output_extractor: + if custom_output_extractor is not None: return await custom_output_extractor(run_result) if run_result.final_output is not None and ( diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index c027191c18..a6fc37a411 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -409,6 +409,55 @@ async def extractor(result) -> str: assert output == "custom output" +@pytest.mark.asyncio +async def test_agent_as_tool_honors_falsey_custom_output_extractor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent = Agent(name="summarizer") + + class DummyResult: + final_output = "default output" + new_items: list[Any] = [] + interruptions: list[Any] = [] + + run_result = DummyResult() + + async def fake_run(cls, *args, **kwargs): + return run_result + + monkeypatch.setattr(Runner, "run", classmethod(fake_run)) + + class FalseyExtractor: + def __init__(self) -> None: + self.call_count = 0 + + def __bool__(self) -> bool: + return False + + async def __call__(self, result: Any) -> str: + assert result is run_result + self.call_count += 1 + return "custom output" + + extractor = FalseyExtractor() + tool = agent.as_tool( + tool_name="summary_tool", + tool_description="Summarize input", + custom_output_extractor=extractor, + ) + tool_context = ToolContext( + context=None, + tool_name="summary_tool", + tool_call_id="call_2", + tool_arguments='{"input": "summarize this"}', + ) + + output = await tool.on_invoke_tool(tool_context, '{"input": "summarize this"}') + + assert output == "custom output" + assert extractor.call_count == 1 + + @pytest.mark.asyncio async def test_agent_as_tool_fallback_uses_current_run_items_only( monkeypatch: pytest.MonkeyPatch, From 855255a662f0beed693752ee24bf4e4b20033143 Mon Sep 17 00:00:00 2001 From: Kaif Kohari Date: Sun, 2 Aug 2026 00:30:36 +0100 Subject: [PATCH 094/473] fix(run): report output guardrail results when a tripwire aborts the run (#4090) --- src/agents/run.py | 15 +++- src/agents/run_internal/guardrails.py | 16 +++- src/agents/run_internal/run_loop.py | 7 ++ tests/test_guardrails.py | 114 ++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 8c57f364da..48fa0bff17 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -20,6 +20,7 @@ ) from .guardrail import ( InputGuardrailResult, + OutputGuardrailResult, ) from .items import ( ItemHelpers, @@ -723,6 +724,9 @@ def _finalize_result(result: RunResult) -> RunResult: input_guardrail_results: list[InputGuardrailResult] = ( list(run_state._input_guardrail_results) if run_state is not None else [] ) + # Output guardrails run once, at the end of the run. Accumulate their results + # here so the failure handler below can report them on the raised exception. + output_guardrail_results: list[OutputGuardrailResult] = [] tool_input_guardrail_results: list[ToolInputGuardrailResult] = ( list(getattr(run_state, "_tool_input_guardrail_results", [])) if run_state is not None @@ -1002,12 +1006,13 @@ def _finalize_result(result: RunResult) -> RunResult: ) if isinstance(turn_result.next_step, NextStepFinalOutput): - output_guardrail_results = await run_output_guardrails( + await run_output_guardrails( current_agent.output_guardrails + (run_config.output_guardrails or []), current_agent, turn_result.next_step.output, context_wrapper, + output_guardrail_results, ) current_step = getattr(run_state, "_current_step", None) approvals_from_state = approvals_from_step(current_step) @@ -1140,11 +1145,12 @@ def _finalize_result(result: RunResult) -> RunResult: context_wrapper, validated_output, ) - output_guardrail_results = await run_output_guardrails( + await run_output_guardrails( current_agent.output_guardrails + (run_config.output_guardrails or []), current_agent, validated_output, context_wrapper, + output_guardrail_results, ) current_step = getattr(run_state, "_current_step", None) approvals_from_state = approvals_from_step(current_step) @@ -1446,12 +1452,13 @@ def _finalize_result(result: RunResult) -> RunResult: try: if isinstance(turn_result.next_step, NextStepFinalOutput): - output_guardrail_results = await run_output_guardrails( + await run_output_guardrails( current_agent.output_guardrails + (run_config.output_guardrails or []), current_agent, turn_result.next_step.output, context_wrapper, + output_guardrail_results, ) # Ensure starting_input is not None and not RunState @@ -1593,7 +1600,7 @@ def _finalize_result(result: RunResult) -> RunResult: last_agent=current_agent, context_wrapper=context_wrapper, input_guardrail_results=input_guardrail_results, - output_guardrail_results=[], + output_guardrail_results=output_guardrail_results, ) raise finally: diff --git a/src/agents/run_internal/guardrails.py b/src/agents/run_internal/guardrails.py index 1e5381acac..289d1a5ba8 100644 --- a/src/agents/run_internal/guardrails.py +++ b/src/agents/run_internal/guardrails.py @@ -177,8 +177,14 @@ async def run_output_guardrails( agent: Agent[TContext], agent_output: Any, context: RunContextWrapper[TContext], + results_sink: list[OutputGuardrailResult] | None = None, ) -> list[OutputGuardrailResult]: - """Run output guardrails in parallel and raise on tripwires.""" + """Run output guardrails in parallel and raise on tripwires. + + Results are recorded into ``results_sink`` as each guardrail completes, including the + tripping result, so callers can report them even when this function raises. This mirrors + `run_input_guardrails`. + """ if not guardrails: return [] @@ -189,10 +195,16 @@ async def run_output_guardrails( guardrail_results: list[OutputGuardrailResult] = [] + def record(result: OutputGuardrailResult) -> None: + guardrail_results.append(result) + if results_sink is not None: + results_sink.append(result) + try: for done in asyncio.as_completed(guardrail_tasks): result = await done if result.output.tripwire_triggered: + record(result) for t in guardrail_tasks: t.cancel() await asyncio.gather(*guardrail_tasks, return_exceptions=True) @@ -203,7 +215,7 @@ async def run_output_guardrails( ) ) raise OutputGuardrailTripwireTriggered(result) - guardrail_results.append(result) + record(result) except BaseException: # On any error (including a guardrail raising or the caller being cancelled), # cancel and await siblings so they don't leak past this function's return. diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index f2d2961d2c..95c5ba1dd8 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -394,18 +394,25 @@ async def _run_output_guardrails_for_stream( context_wrapper: RunContextWrapper[TContext], streamed_result: RunResultStreaming, ) -> list[Any]: + # Recorded as each guardrail completes so a tripwire still publishes the results that + # already finished, mirroring the non-streamed path. + completed_results: list[Any] = [] streamed_result._output_guardrails_task = asyncio.create_task( run_output_guardrails( agent.output_guardrails + (run_config.output_guardrails or []), agent, output, context_wrapper, + completed_results, ) ) try: return cast(list[Any], await streamed_result._output_guardrails_task) except OutputGuardrailTripwireTriggered: + streamed_result.output_guardrail_results = ( + streamed_result.output_guardrail_results + completed_results + ) raise except asyncio.CancelledError: raise diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index af8d94cd48..cc5f00db9c 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -13,6 +13,7 @@ InputGuardrail, InputGuardrailTripwireTriggered, OutputGuardrail, + OutputGuardrailTripwireTriggered, RunConfig, RunContextWrapper, Runner, @@ -2134,3 +2135,116 @@ async def test_input_guardrail_exception_reports_completed_results(): ) assert _result_names(collected) == ["passes"] + + +def _ordered_output_guardrails( + *, second_triggers: bool, second_raises: bool = False +) -> list[OutputGuardrail[Any]]: + """Build two output guardrails whose completion order is fixed by an explicit barrier.""" + first_done = asyncio.Event() + + async def first_fn( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any + ) -> GuardrailFunctionOutput: + first_done.set() + return GuardrailFunctionOutput(output_info="passes", tripwire_triggered=False) + + async def second_fn( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any + ) -> GuardrailFunctionOutput: + await first_done.wait() + if second_raises: + raise RuntimeError("guardrail exploded") + return GuardrailFunctionOutput(output_info="second", tripwire_triggered=second_triggers) + + return [ + OutputGuardrail(guardrail_function=first_fn, name="passes"), + OutputGuardrail(guardrail_function=second_fn, name="raises" if second_raises else "trips"), + ] + + +def _output_tripwire_agent(model: FakeModel) -> Agent[Any]: + return Agent( + name="output_guardrail_results_agent", + model=model, + output_guardrails=_ordered_output_guardrails(second_triggers=True), + ) + + +@pytest.mark.asyncio +async def test_output_guardrail_tripwire_reports_results(): + """Runner.run() reports every completed output guardrail result on the raised tripwire.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: + await Runner.run(_output_tripwire_agent(model), "test input") + + run_data = exc_info.value.run_data + assert run_data is not None + assert _result_names(run_data.output_guardrail_results) == ["passes", "trips"] + assert exc_info.value.guardrail_result.guardrail.get_name() == "trips" + + +@pytest.mark.asyncio +async def test_output_guardrail_tripwire_reports_results_streamed(): + """The streamed path reports the same results, including on the streamed result object.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + + result = Runner.run_streamed(_output_tripwire_agent(model), "test input") + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: + async for _ in result.stream_events(): + pass + + run_data = exc_info.value.run_data + assert run_data is not None + assert _result_names(run_data.output_guardrail_results) == ["passes", "trips"] + assert _result_names(result.output_guardrail_results) == ["passes", "trips"] + + +def test_output_guardrail_tripwire_reports_results_sync(): + """Runner.run_sync() matches the async entry points.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: + Runner.run_sync(_output_tripwire_agent(model), "test input") + + run_data = exc_info.value.run_data + assert run_data is not None + assert _result_names(run_data.output_guardrail_results) == ["passes", "trips"] + + +@pytest.mark.asyncio +async def test_output_guardrail_results_reported_on_success(): + """Passing output guardrails still land on the successful result exactly once.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + agent = Agent( + name="output_guardrail_results_agent", + model=model, + output_guardrails=_ordered_output_guardrails(second_triggers=False), + ) + + result = await Runner.run(agent, "test input") + + assert _result_names(result.output_guardrail_results) == ["passes", "trips"] + + +@pytest.mark.asyncio +async def test_output_guardrail_exception_reports_completed_results(): + """A guardrail raising a non-tripwire error still preserves earlier results.""" + from agents.run_internal.guardrails import run_output_guardrails + + collected: list[Any] = [] + with pytest.raises(RuntimeError, match="guardrail exploded"): + await run_output_guardrails( + _ordered_output_guardrails(second_triggers=False, second_raises=True), + Agent(name="t"), + "out", + RunContextWrapper(context=None), + collected, + ) + + assert _result_names(collected) == ["passes"] From 29e99da2512dc8c23fbbd22aa3ac9e3a7f19e28d Mon Sep 17 00:00:00 2001 From: Kaif Kohari Date: Sun, 2 Aug 2026 00:31:41 +0100 Subject: [PATCH 095/473] fix(chatcmpl): clear pending thinking blocks when flushing an assistant message (#4089) --- src/agents/models/chatcmpl_converter.py | 15 ++++-- .../models/test_anthropic_thinking_blocks.py | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 0ba5e13692..e03622e7a3 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -538,8 +538,8 @@ def items_to_messages( pending_reasoning_content: str | None = None # For DeepSeek reasoning_content normalized_base_url = base_url.rstrip("/") if base_url is not None else None - def flush_assistant_message(*, clear_pending_reasoning_content: bool = True) -> None: - nonlocal current_assistant_msg, pending_reasoning_content + def flush_assistant_message(*, clear_pending_reasoning: bool = True) -> None: + nonlocal current_assistant_msg, pending_reasoning_content, pending_thinking_blocks if current_assistant_msg is not None: # The API doesn't support empty arrays for tool_calls if not current_assistant_msg.get("tool_calls"): @@ -548,8 +548,13 @@ def flush_assistant_message(*, clear_pending_reasoning_content: bool = True) -> pending_reasoning_content = None result.append(current_assistant_msg) current_assistant_msg = None - elif clear_pending_reasoning_content: + elif clear_pending_reasoning: pending_reasoning_content = None + if clear_pending_reasoning: + # Thinking blocks belong to the assistant turn that produced them, so a + # reasoning item that is not directly followed by that turn's assistant + # message must not leak its signed blocks into a later one. + pending_thinking_blocks = None def apply_pending_reasoning_content( assistant_msg: ChatCompletionAssistantMessageParam, @@ -637,8 +642,8 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: # 3) response output message => assistant elif resp_msg := cls.maybe_response_output_message(item): # A reasoning item can be followed by an assistant message and then tool calls - # in the same turn, so preserve pending reasoning_content across this flush. - flush_assistant_message(clear_pending_reasoning_content=False) + # in the same turn, so preserve pending reasoning state across this flush. + flush_assistant_message(clear_pending_reasoning=False) new_asst = ChatCompletionAssistantMessageParam(role="assistant") contents = resp_msg["content"] diff --git a/tests/models/test_anthropic_thinking_blocks.py b/tests/models/test_anthropic_thinking_blocks.py index e55787730d..39986f1a2e 100644 --- a/tests/models/test_anthropic_thinking_blocks.py +++ b/tests/models/test_anthropic_thinking_blocks.py @@ -416,3 +416,51 @@ def test_anthropic_thinking_blocks_without_tool_calls(): assert ( second_content.get("text") == "The weather in Paris is sunny with a temperature of 22°C." ), "Text content should be preserved" + + +def test_thinking_blocks_do_not_leak_across_an_intervening_user_turn(): + """A reasoning item not followed by its own assistant message must not leak. + + When a turn produces extended thinking but no visible output, the only stored + output item is the reasoning item, so the next item in the history is the user's + next message. The signed thinking blocks belong to that earlier turn and must not + be prepended to a later assistant message. + """ + silent_turn = InternalChatCompletionMessage( + role="assistant", + content="", + reasoning_content="Nothing to say yet.", + thinking_blocks=[ + { + "type": "thinking", + "thinking": "Earlier private thinking.", + "signature": "EarlierTurnSignature", + } + ], + tool_calls=None, + ) + output_items = Converter.message_to_output_items(silent_turn) + assert [item.type for item in output_items] == ["reasoning"] + + history: list[dict[str, Any]] = [{"role": "user", "content": "first question"}] + history += [item.model_dump() for item in output_items] + history += [ + {"role": "user", "content": "second question"}, + { + "id": "msg_2", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "an answer", "annotations": []}], + }, + ] + + messages = Converter.items_to_messages( + history, # type: ignore[arg-type] + model="anthropic/claude-4-opus", + preserve_thinking_blocks=True, + ) + + assistant_messages = [msg for msg in messages if msg.get("role") == "assistant"] + assert len(assistant_messages) == 1 + assert assistant_messages[0].get("content") == "an answer" From a134f3a29869f3aa0e10e37447897046d18c2d23 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 2 Aug 2026 09:06:15 +0900 Subject: [PATCH 096/473] fix(mcp): auto-paginate tool and prompt listings (#4094) Co-authored-by: Akshay Sharma <68906315+akshay183@users.noreply.github.com> --- src/agents/mcp/server.py | 129 +++++++++++++++-- tests/mcp/servers/paginated.py | 78 ++++++++++ tests/mcp/test_caching.py | 37 ++++- tests/mcp/test_client_session_retries.py | 127 ++++++++++++++++- tests/mcp/test_mcp_pagination_integration.py | 63 +++++++++ tests/mcp/test_server_errors.py | 141 +++++++++++++++++++ 6 files changed, 561 insertions(+), 14 deletions(-) create mode 100644 tests/mcp/servers/paginated.py create mode 100644 tests/mcp/test_mcp_pagination_integration.py diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 168a476e12..4a04aa50e1 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -34,6 +34,8 @@ ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, + ListToolsResult, + PaginatedRequestParams, ReadResourceResult, ) from typing_extensions import NotRequired, TypedDict @@ -764,6 +766,27 @@ async def _maybe_serialize_request(self, func: Callable[[], Awaitable[T]]) -> T: async with self._request_lock: return await func() + async def _list_tools_page( + self, session: ClientSession, cursor: str | None = None + ) -> ListToolsResult: + return await self._maybe_serialize_request( + lambda: session.list_tools() + if cursor is None + else session.list_tools(params=PaginatedRequestParams(cursor=cursor)) + ) + + async def _list_prompts_page( + self, session: ClientSession, cursor: str | None = None + ) -> ListPromptsResult: + return await self._run_request_with_transport_error_redaction( + "list prompts", + lambda: self._maybe_serialize_request( + lambda: session.list_prompts() + if cursor is None + else session.list_prompts(params=PaginatedRequestParams(cursor=cursor)) + ), + ) + async def _apply_tool_filter( self, tools: list[MCPTool], @@ -1120,17 +1143,61 @@ async def list_tools( transport_error: UserError | None = None transport_cause: Exception | None = None try: + tools: list[MCPTool] # Return from cache if caching is enabled, we have tools, and the cache is not dirty if self.cache_tools_list and not self._cache_dirty and self._tools_list: tools = self._tools_list else: - # Fetch the tools from the server - result = await self._run_with_retries( - lambda: self._maybe_serialize_request(lambda: session.list_tools()) - ) - self._tools_list = result.tools + tools = [] + cursor: str | None = None + seen_cursors: set[str | None] = set() + + async def fetch_pages() -> bool: + nonlocal cursor + while True: + result = await self._list_tools_page(session, cursor) + tools.extend(result.tools) + seen_cursors.add(cursor) + next_cursor = result.nextCursor + if next_cursor is None: + return True + if next_cursor in seen_cursors: + return False + cursor = next_cursor + + pagination_complete = False + pagination_failure: BaseException | None = None + try: + pagination_complete = await self._run_with_retries(fetch_pages) + except BaseException as error: + if cursor is None: + raise + if isinstance(error, BaseExceptionGroup): + pagination_failure = _credential_safe_exception_group(error) + elif isinstance(error, Exception): + pagination_failure = self._user_error_for_request_operation( + "list tools", error + ) + else: + pagination_failure = _credential_safe_exception_leaf(error) + + if pagination_failure is not None or not pagination_complete: + cursor = None + seen_cursors.clear() + tools.clear() + del fetch_pages + if pagination_failure is not None: + raise pagination_failure from None + raise UserError( + f"MCP server '{self._error_name}' returned a repeated cursor while " + "listing tools." + ) from None + + cursor = None + seen_cursors.clear() + del fetch_pages + self._tools_list = tools self._cache_dirty = False - tools = self._tools_list # Filter tools based on tool_filter filtered_tools = tools @@ -1265,10 +1332,52 @@ async def list_prompts( raise UserError("Server not initialized. Make sure you call `connect()` first.") session = self.session assert session is not None - return await self._run_request_with_transport_error_redaction( - "list prompts", - lambda: self._maybe_serialize_request(lambda: session.list_prompts()), - ) + result = await self._list_prompts_page(session) + if result.nextCursor is None: + return result + + prompts = list(result.prompts) + cursor: str | None = result.nextCursor + seen_cursors: set[str | None] = {None} + pagination_failure: BaseException | None = None + repeated_cursor = False + page: ListPromptsResult | None = None + next_cursor: str | None = None + while cursor is not None: + try: + page = await self._list_prompts_page(session, cursor) + except BaseException as error: + if isinstance(error, BaseExceptionGroup): + pagination_failure = _credential_safe_exception_group(error) + elif isinstance(error, Exception): + pagination_failure = self._user_error_for_request_operation( + "list prompts", error + ) + else: + pagination_failure = _credential_safe_exception_leaf(error) + break + prompts.extend(page.prompts) + seen_cursors.add(cursor) + next_cursor = page.nextCursor + if next_cursor is not None and next_cursor in seen_cursors: + repeated_cursor = True + break + cursor = next_cursor + + if pagination_failure is not None or repeated_cursor: + cursor = None + seen_cursors.clear() + prompts.clear() + page = None + next_cursor = None + del result + if pagination_failure is not None: + raise pagination_failure from None + raise UserError( + f"MCP server '{self._error_name}' returned a repeated cursor while listing prompts." + ) from None + + return result.model_copy(update={"prompts": prompts, "nextCursor": None}) async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None diff --git a/tests/mcp/servers/paginated.py b/tests/mcp/servers/paginated.py new file mode 100644 index 0000000000..06e706324b --- /dev/null +++ b/tests/mcp/servers/paginated.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import anyio +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import ( + ListPromptsRequest, + ListPromptsResult, + ListToolsRequest, + ListToolsResult, + Prompt, + TextContent, + Tool, +) + +server = Server("paginated-test-server") + + +@server.list_tools() # type: ignore[misc] +async def list_tools(request: ListToolsRequest) -> ListToolsResult: + cursor = request.params.cursor if request.params is not None else None + if cursor is None: + return ListToolsResult( + tools=[ + Tool( + name="first_page_tool", + inputSchema={"type": "object", "properties": {}}, + ) + ], + nextCursor="", + ) + if cursor == "": + return ListToolsResult( + tools=[ + Tool( + name="second_page_tool", + inputSchema={"type": "object", "properties": {}}, + ) + ], + ) + raise ValueError(f"Unexpected tools cursor: {cursor}") + + +@server.list_prompts() # type: ignore[misc] +async def list_prompts(request: ListPromptsRequest) -> ListPromptsResult: + cursor = request.params.cursor if request.params is not None else None + if cursor is None: + return ListPromptsResult( + prompts=[Prompt(name="first_page_prompt")], + nextCursor="", + _meta={"page": "first"}, + ) + if cursor == "": + return ListPromptsResult( + prompts=[Prompt(name="second_page_prompt")], + _meta={"page": "second"}, + ) + raise ValueError(f"Unexpected prompts cursor: {cursor}") + + +@server.call_tool() # type: ignore[misc] +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[TextContent]: + if name not in {"first_page_tool", "second_page_tool"}: + raise ValueError(f"Unexpected tool: {name}") + return [TextContent(type="text", text=f"called:{name}")] + + +async def main() -> None: + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/tests/mcp/test_caching.py b/tests/mcp/test_caching.py index f31cdf9518..4465a5e057 100644 --- a/tests/mcp/test_caching.py +++ b/tests/mcp/test_caching.py @@ -1,7 +1,7 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, call, patch import pytest -from mcp.types import ListToolsResult, Tool as MCPTool +from mcp.types import ListToolsResult, PaginatedRequestParams, Tool as MCPTool from agents import Agent from agents.mcp import MCPServerStdio @@ -61,3 +61,36 @@ async def test_server_caching_works( # Without invalidating the cache, calling list_tools() again should return the cached value result_tools = await server.list_tools(run_context, agent) assert result_tools == tools + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_paginated_tools_are_cached_before_filtering( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + first_page_tool = MCPTool(name="first_page_tool", inputSchema={}) + second_page_tool = MCPTool(name="second_page_tool", inputSchema={}) + mock_list_tools.side_effect = [ + ListToolsResult(tools=[first_page_tool], nextCursor=""), + ListToolsResult(tools=[second_page_tool]), + ] + server = MCPServerStdio( + params={"command": tee}, + cache_tools_list=True, + tool_filter={"allowed_tool_names": ["second_page_tool"]}, + ) + + async with server: + filtered_tools = await server.list_tools() + cached_tools = server.cached_tools + filtered_tools_again = await server.list_tools() + + assert filtered_tools == [second_page_tool] + assert filtered_tools_again == [second_page_tool] + assert cached_tools == [first_page_tool, second_page_tool] + assert mock_list_tools.await_args_list == [ + call(), + call(params=PaginatedRequestParams(cursor="")), + ] diff --git a/tests/mcp/test_client_session_retries.py b/tests/mcp/test_client_session_retries.py index 4187e1afb0..c868791079 100644 --- a/tests/mcp/test_client_session_retries.py +++ b/tests/mcp/test_client_session_retries.py @@ -8,7 +8,15 @@ from anyio import ClosedResourceError from mcp import ClientSession, Tool as MCPTool from mcp.shared.exceptions import McpError -from mcp.types import CallToolResult, ErrorData, GetPromptResult, ListPromptsResult, ListToolsResult +from mcp.types import ( + CallToolResult, + ErrorData, + GetPromptResult, + ListPromptsResult, + ListToolsResult, + PaginatedRequestParams, + Prompt, +) from agents.exceptions import UserError from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession @@ -30,7 +38,7 @@ async def call_tool(self, tool_name, arguments, meta=None): raise RuntimeError("call_tool failure") return CallToolResult(content=[]) - async def list_tools(self): + async def list_tools(self, *, params: PaginatedRequestParams | None = None): self.list_tools_attempts += 1 if self.list_tools_attempts <= self.fail_list_tools: raise RuntimeError("list_tools failure") @@ -75,6 +83,121 @@ async def test_list_tools_unlimited_retries(): assert session.list_tools_attempts == 4 +class PaginatedRetrySession(DummySession): + def __init__(self): + super().__init__() + self.cursors: list[str | None] = [] + self.second_page_attempts = 0 + + async def list_tools(self, *, params: PaginatedRequestParams | None = None): + cursor = params.cursor if params is not None else None + self.cursors.append(cursor) + if cursor is None: + return ListToolsResult( + tools=[MCPTool(name="first_page_tool", inputSchema={})], + nextCursor="second-page", + ) + + self.second_page_attempts += 1 + if self.second_page_attempts == 1: + raise RuntimeError("second page failure") + return ListToolsResult(tools=[MCPTool(name="second_page_tool", inputSchema={})]) + + +@pytest.mark.asyncio +async def test_list_tools_retries_only_the_failed_page(): + session = PaginatedRetrySession() + server = DummyServer(session=session, retries=1) + + tools = await server.list_tools() + + assert [tool.name for tool in tools] == ["first_page_tool", "second_page_tool"] + assert session.cursors == [None, "second-page", "second-page"] + + +class SharedRetryBudgetSession(DummySession): + def __init__(self): + super().__init__() + self.cursors: list[str | None] = [] + + async def list_tools(self, *, params: PaginatedRequestParams | None = None): + cursor = params.cursor if params is not None else None + self.cursors.append(cursor) + if self.cursors == [None]: + raise RuntimeError("first page failure") + if cursor is None: + return ListToolsResult( + tools=[MCPTool(name="first_page_tool", inputSchema={})], + nextCursor="second-page", + ) + raise RuntimeError("second page failure") + + +@pytest.mark.asyncio +async def test_list_tools_shares_retry_budget_across_pages(): + session = SharedRetryBudgetSession() + server = DummyServer(session=session, retries=1) + + with pytest.raises(UserError, match="Failed to list tools.*Request failed"): + await server.list_tools() + + assert session.cursors == [None, None, "second-page"] + + +class RepeatedCursorSession(DummySession): + def __init__(self): + super().__init__() + self.tool_cursors: list[str | None] = [] + self.prompt_cursors: list[str | None] = [] + + async def list_tools(self, *, params: PaginatedRequestParams | None = None): + cursor = params.cursor if params is not None else None + self.tool_cursors.append(cursor) + if cursor is None: + return ListToolsResult( + tools=[MCPTool(name="first_page_tool", inputSchema={})], + nextCursor="tenant-secret-cursor", + ) + return ListToolsResult( + tools=[MCPTool(name="second_page_tool", inputSchema={})], + nextCursor="tenant-secret-cursor", + ) + + async def list_prompts( + self, *, params: PaginatedRequestParams | None = None + ) -> ListPromptsResult: + cursor = params.cursor if params is not None else None + self.prompt_cursors.append(cursor) + if cursor is None: + return ListPromptsResult( + prompts=[Prompt(name="first_page_prompt")], + nextCursor="tenant-secret-cursor", + _meta={"page": "first"}, + ) + return ListPromptsResult( + prompts=[Prompt(name="second_page_prompt")], + nextCursor="tenant-secret-cursor", + _meta={"page": "second"}, + ) + + +@pytest.mark.asyncio +async def test_paginated_lists_reject_a_repeated_cursor_without_caching_partial_tools(): + session = RepeatedCursorSession() + server = DummyServer(session=session, retries=1) + + with pytest.raises(UserError, match="repeated cursor while listing tools") as tools_error: + await server.list_tools() + with pytest.raises(UserError, match="repeated cursor while listing prompts") as prompts_error: + await server.list_prompts() + + assert server.cached_tools is None + assert session.tool_cursors == [None, "tenant-secret-cursor"] + assert session.prompt_cursors == [None, "tenant-secret-cursor"] + assert "tenant-secret-cursor" not in str(tools_error.value) + assert "tenant-secret-cursor" not in str(prompts_error.value) + + @pytest.mark.asyncio async def test_call_tool_validates_required_parameters_before_remote_call(): session = DummySession() diff --git a/tests/mcp/test_mcp_pagination_integration.py b/tests/mcp/test_mcp_pagination_integration.py new file mode 100644 index 0000000000..30e1f03b78 --- /dev/null +++ b/tests/mcp/test_mcp_pagination_integration.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from agents import Agent, Runner +from agents.mcp import MCPServerStdio + +from ..fake_model import FakeModel +from ..test_responses import get_function_tool_call, get_text_message + +PAGINATED_SERVER_PATH = Path(__file__).parent / "servers" / "paginated.py" + + +def create_paginated_server() -> MCPServerStdio: + return MCPServerStdio( + name="paginated-test-server", + params={ + "command": sys.executable, + "args": [str(PAGINATED_SERVER_PATH)], + }, + cache_tools_list=True, + ) + + +@pytest.mark.asyncio +async def test_stdio_server_auto_paginates_tools_and_prompts(): + async with create_paginated_server() as server: + tools = await server.list_tools() + prompts = await server.list_prompts() + + assert [tool.name for tool in tools] == ["first_page_tool", "second_page_tool"] + assert [prompt.name for prompt in prompts.prompts] == [ + "first_page_prompt", + "second_page_prompt", + ] + assert prompts.nextCursor is None + assert prompts.meta == {"page": "first"} + + +@pytest.mark.asyncio +async def test_agent_calls_tool_from_second_stdio_page(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("second_page_tool", "{}")], + [get_text_message("done")], + ] + ) + + async with create_paginated_server() as server: + result = await Runner.run( + Agent(name="test", model=model, mcp_servers=[server]), + input="Call the second-page tool.", + ) + + tool_outputs = [ + item.output for item in result.new_items if item.type == "tool_call_output_item" + ] + assert result.final_output == "done" + assert tool_outputs == [{"type": "text", "text": "called:second_page_tool"}] diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index fc4d3f2a56..1b1cd3e70a 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -7,6 +7,7 @@ import httpx import pytest +from mcp.types import ListPromptsResult, ListToolsResult from agents import Agent, _debug from agents.exceptions import UserError @@ -96,6 +97,18 @@ def _assert_url_credentials_hidden_from_traceback_locals(error: BaseException) - current = current.tb_next +def _assert_text_hidden_from_server_traceback_locals( + error: BaseException, + sensitive_text: str, +) -> None: + current = error.__traceback__ + while current is not None: + if current.tb_frame.f_code.co_filename.endswith("/src/agents/mcp/server.py"): + attached_values = repr(tuple(current.tb_frame.f_locals.values())) + assert sensitive_text not in attached_values + current = current.tb_next + + def _assert_url_credentials_hidden_from_log_record(record: logging.LogRecord) -> None: rendered = logging.Formatter("%(levelname)s %(message)s").format(record) attached_values = repr( @@ -326,6 +339,134 @@ async def test_prompt_request_http_status_hides_url_credentials(): _assert_url_credentials_hidden_from_traceback_locals(user_error_info.value) +def _paginated_list_result( + method_name: str, + next_cursor: str, +) -> ListToolsResult | ListPromptsResult: + if method_name == "list_tools": + return ListToolsResult(tools=[], nextCursor=next_cursor) + return ListPromptsResult(prompts=[], nextCursor=next_cursor) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method_name", ["list_tools", "list_prompts"]) +async def test_paginated_list_failure_does_not_retain_opaque_cursor(method_name: str): + cursor = "SECRET_OPAQUE_CURSOR" + failure_message = "SECRET_CONTINUATION_FAILURE" + continuation_error = RuntimeError(failure_message) + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + session = MagicMock() + setattr( + session, + method_name, + AsyncMock( + side_effect=[ + _paginated_list_result(method_name, cursor), + continuation_error, + ] + ), + ) + server.session = session + server.max_retry_attempts = 0 + + with pytest.raises(UserError) as user_error_info: + await getattr(server, method_name)() + + rendered = "".join(traceback.format_exception(user_error_info.value)) + assert "Request failed" in str(user_error_info.value) + assert cursor not in rendered + assert failure_message not in rendered + assert user_error_info.value.__cause__ is None + assert user_error_info.value.__context__ is None + _assert_not_retained_in_exception_graph(user_error_info.value, continuation_error) + _assert_text_hidden_from_server_traceback_locals(user_error_info.value, cursor) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method_name", ["list_tools", "list_prompts"]) +async def test_paginated_list_cycle_does_not_retain_opaque_cursor(method_name: str): + cursor = "SECRET_OPAQUE_CURSOR" + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + session = MagicMock() + setattr( + session, + method_name, + AsyncMock( + side_effect=[ + _paginated_list_result(method_name, cursor), + _paginated_list_result(method_name, cursor), + ] + ), + ) + server.session = session + server.max_retry_attempts = 0 + + with pytest.raises(UserError, match=f"repeated cursor while listing {method_name[5:]}") as info: + await getattr(server, method_name)() + + assert cursor not in "".join(traceback.format_exception(info.value)) + assert info.value.__cause__ is None + assert info.value.__context__ is None + _assert_text_hidden_from_server_traceback_locals(info.value, cursor) + if method_name == "list_tools": + assert server.cached_tools is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method_name", ["list_tools", "list_prompts"]) +async def test_paginated_list_cancellation_preserves_control_flow_without_cursor( + method_name: str, +): + cursor = "SECRET_OPAQUE_CURSOR" + cancellation = asyncio.CancelledError(cursor) + server = MCPServerStreamableHttp(params={"url": _SAFE_URL}) + session = MagicMock() + setattr( + session, + method_name, + AsyncMock( + side_effect=[ + _paginated_list_result(method_name, cursor), + cancellation, + ] + ), + ) + server.session = session + server.max_retry_attempts = 0 + + with pytest.raises(asyncio.CancelledError) as cancellation_info: + await getattr(server, method_name)() + + assert str(cancellation_info.value) == "" + assert cancellation_info.value.__cause__ is None + assert cancellation_info.value.__context__ is None + _assert_not_retained_in_exception_graph(cancellation_info.value, cancellation) + _assert_text_hidden_from_server_traceback_locals(cancellation_info.value, cursor) + + +@pytest.mark.asyncio +async def test_paginated_tools_clear_cursor_before_filter_failure(): + cursor = "SECRET_OPAQUE_CURSOR" + server = MCPServerStreamableHttp( + params={"url": _SAFE_URL}, + tool_filter=lambda context, tool: True, + ) + session = MagicMock() + session.list_tools = AsyncMock( + side_effect=[ + ListToolsResult(tools=[], nextCursor=cursor), + ListToolsResult(tools=[]), + ] + ) + server.session = session + + with pytest.raises(UserError, match="run_context and agent are required") as error_info: + await server.list_tools() + + assert cursor not in "".join(traceback.format_exception(error_info.value)) + _assert_text_hidden_from_server_traceback_locals(error_info.value, cursor) + + @pytest.mark.asyncio async def test_resource_request_nested_group_replaces_ordinary_siblings_safely(): server = MCPServerStreamableHttp(params={"url": _CREDENTIALED_URL}) From 98df4ea63d274315d124301f169220abeb02feb2 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 1 Aug 2026 20:01:49 -0500 Subject: [PATCH 097/473] fix(sandbox): remove apply_patch move source as the bound user (#4100) --- src/agents/sandbox/apply_patch.py | 2 +- .../capabilities/test_apply_patch_tool.py | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/apply_patch.py b/src/agents/sandbox/apply_patch.py index d85598f487..0262a64fed 100644 --- a/src/agents/sandbox/apply_patch.py +++ b/src/agents/sandbox/apply_patch.py @@ -97,7 +97,7 @@ async def apply_operation( moved_destination = self._session.normalize_path(moved_relative_path) await self._write_text(moved_destination, updated_text) if moved_destination != destination: - await self._session.rm(destination) + await self._session.rm(destination, user=self._user) moved_display_path = moved_relative_path.as_posix() return ApplyPatchResult( output=f"Updated {display_path}\nMoved {display_path} to {moved_display_path}" diff --git a/tests/sandbox/capabilities/test_apply_patch_tool.py b/tests/sandbox/capabilities/test_apply_patch_tool.py index bebb821213..450d2f8763 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -177,6 +177,55 @@ async def test_editor_runs_file_operations_as_bound_user(self) -> None: assert session.write_users == ["sandbox-user", "sandbox-user"] assert session.rm_users == ["sandbox-user"] + @pytest.mark.asyncio + async def test_editor_removes_moved_source_as_bound_user(self) -> None: + session = UserRecordingApplyPatchSession() + session.files[Path("/workspace/existing.txt")] = b"old\n" + tool = SandboxApplyPatchTool(session=session, user=User(name="sandbox-user")) + + result = await cast( + Awaitable[ApplyPatchResult], + tool.editor.update_file( + ApplyPatchOperation( + type="update_file", + path="existing.txt", + diff="@@\n-old\n+new\n", + move_to="moved.txt", + ) + ), + ) + + assert isinstance(result, ApplyPatchResult) + assert result.output == "Updated existing.txt\nMoved existing.txt to moved.txt" + assert session.read_users == ["sandbox-user"] + assert session.mkdir_users == ["sandbox-user"] + assert session.write_users == ["sandbox-user"] + # Removing the source path is part of the move, so it must run as the bound user too. + assert session.rm_users == ["sandbox-user"] + assert session.files[Path("/workspace/moved.txt")] == b"new\n" + assert Path("/workspace/existing.txt") not in session.files + + @pytest.mark.asyncio + async def test_editor_move_to_same_path_does_not_remove_the_file(self) -> None: + session = UserRecordingApplyPatchSession() + session.files[Path("/workspace/existing.txt")] = b"old\n" + tool = SandboxApplyPatchTool(session=session, user=User(name="sandbox-user")) + + await cast( + Awaitable[ApplyPatchResult], + tool.editor.update_file( + ApplyPatchOperation( + type="update_file", + path="existing.txt", + diff="@@\n-old\n+new\n", + move_to="existing.txt", + ) + ), + ) + + assert session.rm_users == [] + assert session.files[Path("/workspace/existing.txt")] == b"new\n" + @pytest.mark.asyncio async def test_custom_tool_input_create_update_move_delete(self) -> None: session = ApplyPatchSession() From 0db81169ccf5b5f63e4e3550ef189d8bdd42c117 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 2 Aug 2026 11:16:52 +0900 Subject: [PATCH 098/473] fix: define explicit zero-value contracts (#4101) --- .../references/local-mcp-server-lifecycle.md | 2 + .../references/model-provider-boundaries.md | 2 +- docs/mcp.md | 4 +- docs/tools.md | 4 +- .../usaspending_text2sql/sql_capability.py | 1 + examples/sandbox/misc/workspace_shell.py | 8 +- examples/tools/shell.py | 1 + examples/tools/shell_human_in_the_loop.py | 1 + src/agents/mcp/manager.py | 69 ++++++++++++----- src/agents/mcp/server.py | 61 ++++++++++++--- src/agents/models/openai_responses.py | 15 +++- src/agents/run_internal/tool_execution.py | 31 ++++++-- src/agents/sandbox/entries/mounts/patterns.py | 12 ++- src/agents/tool.py | 4 +- tests/mcp/test_mcp_server_manager.py | 77 ++++++++++++++++++- tests/mcp/test_server_errors.py | 66 ++++++++++++++++ .../models/test_openai_responses_converter.py | 41 ++++++++++ tests/sandbox/test_mounts.py | 44 +++++++++++ tests/test_shell_call_serialization.py | 45 +++++++++++ 19 files changed, 438 insertions(+), 50 deletions(-) diff --git a/.agents/references/local-mcp-server-lifecycle.md b/.agents/references/local-mcp-server-lifecycle.md index 71903e6979..1d67d6ddd9 100644 --- a/.agents/references/local-mcp-server-lifecycle.md +++ b/.agents/references/local-mcp-server-lifecycle.md @@ -7,6 +7,8 @@ Use this reference for changes to Python-managed MCP servers, `MCPServerManager` - A local `MCPServer` owns its transport, `ClientSession`, and `AsyncExitStack` from `connect()` through `cleanup()`. Partial connection failure still requires closing every context already entered. - Some MCP transports use AnyIO cancel scopes that require connection and cleanup in the same task. Do not wrap either operation in a helper that silently creates another task. - `MCPServerManager` preserves task affinity in sequential mode and uses one long-lived worker task per server in parallel mode. Timeouts must run inside that owning task; on Python versions without `asyncio.timeout()`, cancel the current worker task and translate only timer-originated cancellation to `TimeoutError`. +- `MCPServerManager` lifecycle timeouts are validated during construction and assignment. They accept positive finite seconds or `None` to disable the timeout. Reject zero rather than relying on `asyncio.timeout(0)`, whose immediate deadline can depend on whether the lifecycle coroutine yields control. Parallel workers receive the current timeout with each command instead of retaining a stale snapshot. +- `client_session_timeout_seconds` uses positive finite values representable by `datetime.timedelta` and at least one microsecond for MCP read timeouts. Both `None` and zero disable that timeout; reject other values during server construction instead of passing an immediate or invalid deadline to `ClientSession`. - Cleanup runs servers in reverse order and continues across ordinary cleanup failures. Cancellation suppression is an explicit manager policy; do not accidentally convert unrelated `BaseException` failures into recoverable connection errors. - Server cleanup must clear session and transport-visible state even when exit-stack cleanup raises, so the same server object can reconnect without exposing stale session handles or workers. diff --git a/.agents/references/model-provider-boundaries.md b/.agents/references/model-provider-boundaries.md index d9f95a5d76..eea57091f6 100644 --- a/.agents/references/model-provider-boundaries.md +++ b/.agents/references/model-provider-boundaries.md @@ -34,7 +34,7 @@ Validate capabilities at the adapter boundary where the resolved model and compl ## Provider Data and Terminal Semantics - Preserve provider-supplied string IDs, request IDs, usage, and opaque provider data when the public SDK contract exposes them. -- Normalize provider objects and mapping payloads without relying on truthiness for valid empty or zero values. +- Normalize provider objects and mapping payloads without relying on truthiness for valid empty or zero values. When a field intentionally treats zero like `None`, make that field-specific contract explicit in the normalization, documentation, and tests rather than applying a generic optional-number rule. - A transport stream ending is not automatically a successful model response. Responses `failed` and `incomplete` terminals, explicit error events, and a missing terminal payload must produce the documented failure behavior in both HTTP and websocket paths. - Keep semantically equivalent HTTP, websocket, streaming, and non-streaming paths aligned on final `ModelResponse`, errors, request IDs, and usage. diff --git a/docs/mcp.md b/docs/mcp.md index 5c3b012b77..2a24cabe7d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -214,7 +214,7 @@ asyncio.run(main()) The constructor accepts additional options: -- `client_session_timeout_seconds` controls HTTP read timeouts. +- `client_session_timeout_seconds` controls MCP ClientSession read timeouts. Positive finite values representable by `datetime.timedelta` and at least one microsecond set a finite timeout; `None` and `0` disable it. Other values are rejected when the server is constructed. - `use_structured_content` toggles whether `tool_result.structured_content` is preferred over textual output. - `max_retry_attempts` and `retry_backoff_seconds_base` add automatic retries for `list_tools()` and `call_tool()`. - `tool_filter` lets you expose only a subset of tools (see [Tool filtering](#tool-filtering)). @@ -363,7 +363,7 @@ Key behaviors: - Failures are tracked in `failed_servers` and `errors`. - Set `strict=True` to raise on the first connection failure. - Call `reconnect(failed_only=True)` to retry failed servers, or `reconnect(failed_only=False)` to restart all servers. -- Use `connect_timeout_seconds`, `cleanup_timeout_seconds`, and `connect_in_parallel` to tune lifecycle behavior. +- Set `connect_timeout_seconds`, `cleanup_timeout_seconds`, and `connect_in_parallel` to tune lifecycle behavior. Lifecycle timeouts accept positive finite seconds, or `None` to disable them, and are validated both during construction and assignment; zero is rejected because it would create an immediate deadline. ## Common server capabilities diff --git a/docs/tools.md b/docs/tools.md index 06d8d544ed..8829b40d03 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -36,7 +36,7 @@ OpenAI offers a few built-in tools when using the [`OpenAIResponsesModel`][agent Advanced hosted search options: -- `FileSearchTool` supports `filters`, `ranking_options`, and `include_search_results` in addition to `vector_store_ids` and `max_num_results`. +- `FileSearchTool` supports `filters`, `ranking_options`, and `include_search_results` in addition to `vector_store_ids` and `max_num_results`. Set `max_num_results` to an integer from 1 through 50; `None` or zero uses the provider default. - `WebSearchTool` supports `filters`, `user_location`, and `search_context_size`. ```python @@ -239,6 +239,8 @@ Local runtime tools require you to supply implementations: - [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: implement [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] to apply diffs locally. - Local shell skills are available with `ShellTool(environment={"type": "local", "skills": [...]})`. +Shell action timeouts use positive integer milliseconds for a finite timeout. The SDK treats both `0` and `None` as no explicit timeout before calling a local `ShellTool` executor because zero does not have a portable meaning across executor implementations; other values are rejected before executor invocation. This is specific to the timeout field: `max_output_length=0` remains a supported request for empty captured output. + ### ComputerTool and the Responses computer tool `ComputerTool` is still a local harness: you provide a [`Computer`][agents.computer.Computer] or [`AsyncComputer`][agents.computer.AsyncComputer] implementation, and the SDK maps that harness onto the OpenAI Responses API computer surface. diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py b/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py index 94a2273cf5..546d65fe2c 100644 --- a/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py +++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py @@ -119,6 +119,7 @@ async def run_sql(query: str, limit: int | None = None) -> str: Only read-only queries are allowed. limit: Optional display row limit override. """ + # Zero intentionally uses the configured default, just like None. display_limit = max(1, min(limit or max_display_rows, max_display_rows)) command = ( diff --git a/examples/sandbox/misc/workspace_shell.py b/examples/sandbox/misc/workspace_shell.py index 766167a535..191794ef8b 100644 --- a/examples/sandbox/misc/workspace_shell.py +++ b/examples/sandbox/misc/workspace_shell.py @@ -37,11 +37,9 @@ async def _execute_shell(self, request: ShellCommandRequest) -> ShellResult: if self._session is None: raise RuntimeError("Workspace shell is not bound to a sandbox session.") - timeout_s = ( - request.data.action.timeout_ms / 1000 - if request.data.action.timeout_ms is not None - else None - ) + # Zero is intentionally equivalent to no explicit Shell action timeout. + timeout_ms = request.data.action.timeout_ms + timeout_s = timeout_ms / 1000 if timeout_ms else None outputs: list[ShellCommandOutput] = [] for command in request.data.action.commands: result = await self._session.exec(command, timeout=timeout_s, shell=True) diff --git a/examples/tools/shell.py b/examples/tools/shell.py index 3ff6e6b3f0..a64478f012 100644 --- a/examples/tools/shell.py +++ b/examples/tools/shell.py @@ -42,6 +42,7 @@ async def __call__(self, request: ShellCommandRequest) -> ShellResult: ) timed_out = False try: + # Zero is intentionally equivalent to no explicit Shell action timeout. timeout = (action.timeout_ms or 0) / 1000 or None stdout_bytes, stderr_bytes = await asyncio.wait_for( proc.communicate(), timeout=timeout diff --git a/examples/tools/shell_human_in_the_loop.py b/examples/tools/shell_human_in_the_loop.py index 91653491c3..5a71510b1a 100644 --- a/examples/tools/shell_human_in_the_loop.py +++ b/examples/tools/shell_human_in_the_loop.py @@ -39,6 +39,7 @@ async def __call__(self, request: ShellCommandRequest) -> ShellResult: ) timed_out = False try: + # Zero is intentionally equivalent to no explicit Shell action timeout. timeout = (action.timeout_ms or 0) / 1000 or None stdout_bytes, stderr_bytes = await asyncio.wait_for( proc.communicate(), timeout=timeout diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index 1da667e2b8..59c1d7e56f 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import math from collections.abc import Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager from dataclasses import dataclass @@ -11,6 +12,21 @@ from .server import MCPServer +def _validate_lifecycle_timeout(timeout_seconds: float | None, *, field_name: str) -> float | None: + """Validate an MCP manager lifecycle timeout without changing its semantics.""" + if timeout_seconds is None: + return None + if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, int | float): + raise TypeError(f"{field_name} must be a positive number of seconds or None.") + try: + is_finite = math.isfinite(timeout_seconds) + except OverflowError: + is_finite = False + if not is_finite or timeout_seconds <= 0: + raise ValueError(f"{field_name} must be a positive finite number of seconds or None.") + return timeout_seconds + + @dataclass class _ServerCommand: action: str @@ -19,15 +35,8 @@ class _ServerCommand: class _ServerWorker: - def __init__( - self, - server: MCPServer, - connect_timeout_seconds: float | None, - cleanup_timeout_seconds: float | None, - ) -> None: + def __init__(self, server: MCPServer) -> None: self._server = server - self._connect_timeout_seconds = connect_timeout_seconds - self._cleanup_timeout_seconds = cleanup_timeout_seconds self._queue: asyncio.Queue[_ServerCommand] = asyncio.Queue() self._task = asyncio.create_task(self._run()) @@ -35,11 +44,11 @@ def __init__( def is_done(self) -> bool: return self._task.done() - async def connect(self) -> None: - await self._submit("connect", self._connect_timeout_seconds) + async def connect(self, timeout_seconds: float | None) -> None: + await self._submit("connect", timeout_seconds) - async def cleanup(self) -> None: - await self._submit("cleanup", self._cleanup_timeout_seconds) + async def cleanup(self, timeout_seconds: float | None) -> None: + await self._submit("cleanup", timeout_seconds) async def _submit(self, action: str, timeout_seconds: float | None) -> None: loop = asyncio.get_running_loop() @@ -75,6 +84,7 @@ async def _run_with_timeout_in_task( # Use an in-task timeout to preserve task affinity for MCP cleanup. # asyncio.wait_for creates a new Task on Python < 3.11, which breaks # libraries that require connect/cleanup in the same task (e.g. AnyIO cancel scopes). + timeout_seconds = _validate_lifecycle_timeout(timeout_seconds, field_name="timeout_seconds") if timeout_seconds is None: await func() return @@ -142,6 +152,9 @@ async def lifespan(app: FastAPI): `active_servers`. - `connect_in_parallel=True` uses a dedicated worker task per server to allow concurrent connects while preserving task affinity for cleanup. + - Lifecycle timeouts are validated during construction and assignment. They + accept positive finite seconds or `None` to disable the timeout. Zero is + rejected because it would create an immediate deadline. """ def __init__( @@ -180,6 +193,28 @@ def all_servers(self) -> list[MCPServer]: """Return all MCP servers managed by this instance.""" return list(self._all_servers) + @property + def connect_timeout_seconds(self) -> float | None: + """Return the lifecycle connect timeout.""" + return self._connect_timeout_seconds + + @connect_timeout_seconds.setter + def connect_timeout_seconds(self, timeout_seconds: float | None) -> None: + self._connect_timeout_seconds = _validate_lifecycle_timeout( + timeout_seconds, field_name="connect_timeout_seconds" + ) + + @property + def cleanup_timeout_seconds(self) -> float | None: + """Return the lifecycle cleanup timeout.""" + return self._cleanup_timeout_seconds + + @cleanup_timeout_seconds.setter + def cleanup_timeout_seconds(self, timeout_seconds: float | None) -> None: + self._cleanup_timeout_seconds = _validate_lifecycle_timeout( + timeout_seconds, field_name="cleanup_timeout_seconds" + ) + async def __aenter__(self) -> MCPServerManager: await self.connect_all() return self @@ -328,7 +363,7 @@ def _record_failure(self, server: MCPServer, exc: BaseException, phase: str) -> async def _run_connect(self, server: MCPServer) -> None: if self.connect_in_parallel: worker = self._get_worker(server) - await worker.connect() + await worker.connect(self.connect_timeout_seconds) else: await self._run_with_timeout(server.connect, self.connect_timeout_seconds) @@ -340,7 +375,7 @@ async def _cleanup_server(self, server: MCPServer) -> None: self._connected_servers.discard(server) return try: - await worker.cleanup() + await worker.cleanup(self.cleanup_timeout_seconds) finally: self._workers.pop(server, None) self._connected_servers.discard(server) @@ -409,11 +444,7 @@ async def _connect_all_parallel(self, servers: list[MCPServer]) -> None: def _get_worker(self, server: MCPServer) -> _ServerWorker: worker = self._workers.get(server) if worker is None or worker.is_done: - worker = _ServerWorker( - server=server, - connect_timeout_seconds=self.connect_timeout_seconds, - cleanup_timeout_seconds=self.cleanup_timeout_seconds, - ) + worker = _ServerWorker(server=server) self._workers[server] = worker return worker diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 4a04aa50e1..4ff1bf8c5a 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -3,6 +3,7 @@ import abc import asyncio import inspect +import math import sys from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager @@ -107,6 +108,33 @@ class RequireApprovalObject(TypedDict, total=False): _SAFE_EXCEPTION_MESSAGE = "An additional error occurred during the MCP request." +def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | None: + """Convert an MCP read timeout while intentionally treating zero as no timeout.""" + if timeout_seconds is None: + return None + if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, int | float): + raise TypeError("client_session_timeout_seconds must be a number of seconds or None.") + if timeout_seconds == 0: + return None + try: + is_finite = math.isfinite(timeout_seconds) + except OverflowError as error: + raise ValueError( + "client_session_timeout_seconds must fit in a datetime.timedelta." + ) from error + if not is_finite or timeout_seconds < 0: + raise ValueError("client_session_timeout_seconds must be zero or a positive finite value.") + if timeout_seconds < timedelta.resolution.total_seconds(): + raise ValueError("client_session_timeout_seconds must be zero or at least one microsecond.") + try: + timeout = timedelta(seconds=timeout_seconds) + except OverflowError as error: + raise ValueError( + "client_session_timeout_seconds must fit in a datetime.timedelta." + ) from error + return timeout + + def _transport_error_urls_are_safe( http_error: httpx.HTTPStatusError | httpx.RequestError, ) -> bool: @@ -708,7 +736,10 @@ def __init__( server will not change its tools list, because it can drastically improve latency (by avoiding a round-trip to the server every time). - client_session_timeout_seconds: the read timeout passed to the MCP ClientSession. + client_session_timeout_seconds: The MCP ClientSession read timeout. Positive finite + values representable by `datetime.timedelta` and at least one microsecond set a + timeout; `None` and `0` disable it. Other values are rejected during server + construction. tool_filter: The tool filter to use for filtering tools. use_structured_content: Whether to use `tool_result.structured_content` when calling an MCP tool. Defaults to False for backwards compatibility - most MCP servers still @@ -747,6 +778,9 @@ def __init__( self.cache_tools_list = cache_tools_list self.server_initialize_result: InitializeResult | None = None + # Validate during construction, then convert again when connecting in case callers mutate + # the public timeout attribute before a later connection attempt. + _client_session_read_timeout(client_session_timeout_seconds) self.client_session_timeout_seconds = client_session_timeout_seconds self.max_retry_attempts = max_retry_attempts self.retry_backoff_seconds_base = retry_backoff_seconds_base @@ -1034,6 +1068,7 @@ async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: async def connect(self): """Connect to the server.""" + read_timeout = _client_session_read_timeout(self.client_session_timeout_seconds) connection_succeeded = False connection_error: UserError | None = None connection_cause: Exception | None = None @@ -1052,9 +1087,7 @@ async def connect(self): ClientSession( read, write, - timedelta(seconds=self.client_session_timeout_seconds) - if self.client_session_timeout_seconds - else None, + read_timeout, message_handler=self.message_handler, ) ) @@ -1597,7 +1630,10 @@ def __init__( improve latency (by avoiding a round-trip to the server every time). name: A readable name for the server. If not provided, we'll create one from the command. - client_session_timeout_seconds: the read timeout passed to the MCP ClientSession. + client_session_timeout_seconds: The MCP ClientSession read timeout. Positive finite + values representable by `datetime.timedelta` and at least one microsecond set a + timeout; `None` and `0` disable it. Other values are rejected during server + construction. tool_filter: The tool filter to use for filtering tools. use_structured_content: Whether to use `tool_result.structured_content` when calling an MCP tool. Defaults to False for backwards compatibility - most MCP servers still @@ -1724,7 +1760,10 @@ def __init__( name: A readable name for the server. If not provided, we'll create one from the URL. - client_session_timeout_seconds: the read timeout passed to the MCP ClientSession. + client_session_timeout_seconds: The MCP ClientSession read timeout. Positive finite + values representable by `datetime.timedelta` and at least one microsecond set a + timeout; `None` and `0` disable it. Other values are rejected during server + construction. tool_filter: The tool filter to use for filtering tools. use_structured_content: Whether to use `tool_result.structured_content` when calling an MCP tool. Defaults to False for backwards compatibility - most MCP servers still @@ -1865,7 +1904,10 @@ def __init__( name: A readable name for the server. If not provided, we'll create one from the URL. - client_session_timeout_seconds: the read timeout passed to the MCP ClientSession. + client_session_timeout_seconds: The MCP ClientSession read timeout. Positive finite + values representable by `datetime.timedelta` and at least one microsecond set a + timeout; `None` and `0` disable it. Other values are rejected during server + construction. tool_filter: The tool filter to use for filtering tools. use_structured_content: Whether to use `tool_result.structured_content` when calling an MCP tool. Defaults to False for backwards compatibility - most MCP servers still @@ -1935,6 +1977,7 @@ def create_streams( @asynccontextmanager async def _isolated_client_session(self): + read_timeout = _client_session_read_timeout(self.client_session_timeout_seconds) async with AsyncExitStack() as exit_stack: transport = await exit_stack.enter_async_context(self.create_streams()) read, write, *_ = transport @@ -1942,9 +1985,7 @@ async def _isolated_client_session(self): ClientSession( read, write, - timedelta(seconds=self.client_session_timeout_seconds) - if self.client_session_timeout_seconds - else None, + read_timeout, message_handler=self.message_handler, ) ) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 006aa203a1..cafb168f4b 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -2071,8 +2071,19 @@ def _convert_tool( "type": "file_search", "vector_store_ids": tool.vector_store_ids, } - if tool.max_num_results: - file_search_tool_param["max_num_results"] = tool.max_num_results + if tool.max_num_results is not None: + if ( + isinstance(tool.max_num_results, bool) + or not isinstance(tool.max_num_results, int) + or not 0 <= tool.max_num_results <= 50 + ): + raise UserError( + "FileSearchTool max_num_results must be zero, an integer between 1 and 50, " + "or None." + ) + # Zero intentionally follows the released provider-default path, just like None. + if tool.max_num_results > 0: + file_search_tool_param["max_num_results"] = tool.max_num_results if tool.ranking_options: file_search_tool_param["ranking_options"] = tool.ranking_options if tool.filters: diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index fe3a388213..4663ca5897 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -664,12 +664,31 @@ def coerce_shell_call(tool_call: Any) -> ShellCallData: if not commands: raise ModelBehaviorError("Shell call action must include at least one command.") - timeout_value = ( - get_mapping_or_attr(action_payload, "timeout_ms") - or get_mapping_or_attr(action_payload, "timeoutMs") - or get_mapping_or_attr(action_payload, "timeout") - ) - timeout_ms = int(timeout_value) if isinstance(timeout_value, int | float) else None + # Zero intentionally follows the same alias fallback as None because it has no portable + # meaning across application-provided shell executors. + timeout_value = None + for candidate in ( + get_mapping_or_attr(action_payload, "timeout_ms"), + get_mapping_or_attr(action_payload, "timeoutMs"), + get_mapping_or_attr(action_payload, "timeout"), + ): + if candidate is None or ( + isinstance(candidate, int | float) + and not isinstance(candidate, bool) + and candidate == 0 + ): + continue + timeout_value = candidate + break + + if timeout_value is None: + timeout_ms = None + elif isinstance(timeout_value, bool) or not isinstance(timeout_value, int) or timeout_value < 0: + raise ModelBehaviorError( + "Shell call action timeout must be a positive integer in milliseconds, zero, or None." + ) + else: + timeout_ms = timeout_value max_length_value = get_mapping_or_attr(action_payload, "max_output_length") if max_length_value is None: diff --git a/src/agents/sandbox/entries/mounts/patterns.py b/src/agents/sandbox/entries/mounts/patterns.py index c5335d6dc9..6aeeea974b 100644 --- a/src/agents/sandbox/entries/mounts/patterns.py +++ b/src/agents/sandbox/entries/mounts/patterns.py @@ -171,11 +171,17 @@ class FuseMountPattern(MountPatternBase): log_level: str = Field(default="log_debug") cache_type: Literal["block_cache", "file_cache"] = Field(default="block_cache") cache_path: Path | None = None - cache_size_mb: int | None = None + cache_size_mb: int | None = Field( + default=None, + description="Cache size in MB. None or 0 uses the SDK default for the cache type.", + ) block_cache_block_size_mb: int = Field(default=16) block_cache_disk_timeout_sec: int = Field(default=3600) file_cache_timeout_sec: int = Field(default=120) - file_cache_max_size_mb: int | None = None + file_cache_max_size_mb: int | None = Field( + default=None, + description="File-cache maximum size in MB. None or 0 uses cache_size_mb.", + ) attr_cache_timeout_sec: int | None = None entry_cache_timeout_sec: int | None = None negative_entry_cache_timeout_sec: int | None = None @@ -361,6 +367,8 @@ async def apply( endpoint = fuse_config.endpoint or f"https://{account}.blob.core.windows.net" cache_type = self.cache_type + # Zero has no portable meaning across Blobfuse cache types, so the SDK intentionally uses + # its cache-type-specific default for both zero and None. cache_size_mb = self.cache_size_mb or (50_000 if cache_type == "block_cache" else 4_096) file_cache_max_size_mb = self.file_cache_max_size_mb or cache_size_mb blobfuse_config = self.BlobfuseConfig( diff --git a/src/agents/tool.py b/src/agents/tool.py index 314272d07a..6e4ed2bf26 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -766,7 +766,9 @@ class FileSearchTool: """The IDs of the vector stores to search.""" max_num_results: int | None = None - """The maximum number of results to return.""" + """The maximum number of results to return, from 1 through 50. None or zero uses the + provider default. + """ include_search_results: bool = False """Whether to include the search results in the output produced by the LLM.""" diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index d6a0830474..b92ca76a56 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from typing import Any, cast import pytest @@ -14,7 +15,7 @@ ) from agents import _debug -from agents.mcp import MCPServer, MCPServerManager +from agents.mcp import MCPServer, MCPServerManager, manager as manager_module from agents.mcp._logging import get_mcp_server_log_name from agents.run_context import RunContextWrapper @@ -384,6 +385,80 @@ async def cleanup(self) -> None: raise RuntimeError("cleanup failed") +@pytest.mark.parametrize("field_name", ["connect_timeout_seconds", "cleanup_timeout_seconds"]) +@pytest.mark.parametrize( + ("timeout_seconds", "error_type"), + [ + (True, TypeError), + ("1", TypeError), + (0, ValueError), + (-1, ValueError), + (float("nan"), ValueError), + (float("inf"), ValueError), + (10**400, ValueError), + ], +) +def test_manager_rejects_unsupported_lifecycle_timeouts( + field_name: str, + timeout_seconds: object, + error_type: type[Exception], +) -> None: + kwargs = {field_name: timeout_seconds} + + with pytest.raises(error_type, match=field_name): + MCPServerManager([], **kwargs) # type: ignore[arg-type] + + +def test_manager_validates_lifecycle_timeout_assignment() -> None: + manager = MCPServerManager( + [], + connect_timeout_seconds=1.5, + cleanup_timeout_seconds=None, + ) + + manager.connect_timeout_seconds = None + manager.cleanup_timeout_seconds = 2.5 + + assert manager.connect_timeout_seconds is None + assert manager.cleanup_timeout_seconds == 2.5 + with pytest.raises(ValueError, match="connect_timeout_seconds"): + manager.connect_timeout_seconds = 0 + assert manager.connect_timeout_seconds is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connect_in_parallel", [False, True]) +async def test_manager_uses_current_lifecycle_timeouts( + connect_in_parallel: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = TaskBoundServer() + observed_timeouts: list[float | None] = [] + + async def run_with_timeout( + func: Callable[[], Awaitable[Any]], timeout_seconds: float | None + ) -> None: + observed_timeouts.append(timeout_seconds) + await func() + + monkeypatch.setattr(manager_module, "_run_with_timeout_in_task", run_with_timeout) + manager = MCPServerManager( + [server], + connect_timeout_seconds=None, + cleanup_timeout_seconds=None, + connect_in_parallel=connect_in_parallel, + ) + manager.connect_timeout_seconds = 1.5 + await manager.connect_all() + + manager.cleanup_timeout_seconds = 2.5 + await manager.cleanup_all() + + assert server.cleaned is True + assert manager._workers == {} + assert observed_timeouts == [1.5, 2.5] + + @pytest.mark.asyncio async def test_manager_keeps_connect_and_cleanup_in_same_task() -> None: server = TaskBoundServer() diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index 1b1cd3e70a..f28947a936 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -3,6 +3,7 @@ import logging import sys import traceback +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -14,6 +15,7 @@ from agents.mcp.server import ( MCPServerSse, MCPServerStreamableHttp, + _client_session_read_timeout, _MCPServerWithClientSession, ) from agents.run_context import RunContextWrapper @@ -173,6 +175,43 @@ def name(self) -> str: return "crashing_client_session_server" +@pytest.mark.parametrize("timeout_seconds", [None, 0, 0.0]) +def test_client_session_read_timeout_treats_zero_as_disabled( + timeout_seconds: float | None, +) -> None: + assert _client_session_read_timeout(timeout_seconds) is None + + +@pytest.mark.parametrize("timeout_seconds", [0.000001, 2.5]) +def test_client_session_read_timeout_preserves_positive_value(timeout_seconds: float) -> None: + assert _client_session_read_timeout(timeout_seconds) == timedelta(seconds=timeout_seconds) + + +@pytest.mark.parametrize( + ("timeout_seconds", "error_type"), + [ + (True, TypeError), + ("5", TypeError), + (-1, ValueError), + (-0.5, ValueError), + (float("nan"), ValueError), + (float("inf"), ValueError), + (5e-7, ValueError), + (1e20, ValueError), + (10**400, ValueError), + ], +) +def test_server_rejects_unsupported_client_session_read_timeout_at_construction( + timeout_seconds: object, + error_type: type[Exception], +) -> None: + with pytest.raises(error_type, match="client_session_timeout_seconds"): + MCPServerSse( + params={"url": "https://mcp.example.com/sse"}, + client_session_timeout_seconds=timeout_seconds, # type: ignore[arg-type] + ) + + @pytest.mark.asyncio async def test_server_errors_cause_error_and_cleanup_called(): server = CrashingClientSessionServer() @@ -183,6 +222,33 @@ async def test_server_errors_cause_error_and_cleanup_called(): assert server.cleanup_called +@pytest.mark.asyncio +async def test_server_revalidates_mutated_timeout_before_creating_streams() -> None: + server = CrashingClientSessionServer() + server.client_session_timeout_seconds = 5e-7 + + with pytest.raises(ValueError, match="at least one microsecond"): + await server.connect() + + assert server.cleanup_called is False + + +@pytest.mark.asyncio +async def test_isolated_session_revalidates_mutated_timeout_before_creating_streams( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = MCPServerStreamableHttp(params={"url": "https://mcp.example.com/mcp"}) + create_streams = MagicMock() + monkeypatch.setattr(server, "create_streams", create_streams) + server.client_session_timeout_seconds = 5e-7 + + with pytest.raises(ValueError, match="at least one microsecond"): + async with server._isolated_client_session(): + raise AssertionError("context body should not run") + + create_streams.assert_not_called() + + @pytest.mark.asyncio async def test_not_calling_connect_causes_error(): server = CrashingClientSessionServer() diff --git a/tests/models/test_openai_responses_converter.py b/tests/models/test_openai_responses_converter.py index cef2c8b81b..42b5cb3671 100644 --- a/tests/models/test_openai_responses_converter.py +++ b/tests/models/test_openai_responses_converter.py @@ -452,6 +452,47 @@ def test_convert_tools_basic_types_and_includes(): Converter.convert_tools(tools=[comp_tool, comp_tool], handoffs=[]) +@pytest.mark.parametrize("max_num_results", [1, 50]) +def test_convert_file_search_tool_preserves_supported_result_limits( + max_num_results: int, +) -> None: + converted = Converter.convert_tools( + [FileSearchTool(vector_store_ids=["vs1"], max_num_results=max_num_results)], + handoffs=[], + ) + + file_params = next(tool for tool in converted.tools if tool["type"] == "file_search") + assert file_params.get("max_num_results") == max_num_results + + +@pytest.mark.parametrize("max_num_results", [None, 0]) +def test_convert_file_search_tool_omits_provider_default_result_limit( + max_num_results: int | None, +) -> None: + converted = Converter.convert_tools( + [FileSearchTool(vector_store_ids=["vs1"], max_num_results=max_num_results)], + handoffs=[], + ) + + file_params = next(tool for tool in converted.tools if tool["type"] == "file_search") + assert "max_num_results" not in file_params + + +@pytest.mark.parametrize("max_num_results", [-1, 51, True, 0.0, 1.5, "3"]) +def test_convert_file_search_tool_rejects_unsupported_result_limits( + max_num_results: object, +) -> None: + tool = FileSearchTool( + vector_store_ids=["vs1"], + max_num_results=max_num_results, # type: ignore[arg-type] + ) + + with pytest.raises( + UserError, match="max_num_results must be zero, an integer between 1 and 50" + ): + Converter.convert_tools([tool], handoffs=[]) + + def test_convert_tools_includes_explicit_false_external_web_access() -> None: web_tool = WebSearchTool(external_web_access=False) diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index d1375a41f8..c65d118fd4 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -1272,6 +1272,50 @@ async def test_blobfuse_generated_config_preserves_zero_attr_cache_timeout() -> assert b"attr_cache:\n timeout-sec: 0\n" in session.write_calls[0][1] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("pattern", "expected_config"), + [ + ( + FuseMountPattern(cache_size_mb=0), + b"block_cache:\n block-size-mb: 16\n mem-size-mb: 50000\n", + ), + ( + FuseMountPattern( + cache_type="file_cache", + cache_size_mb=0, + file_cache_max_size_mb=0, + ), + b"file_cache:\n path: /workspace/.sandbox-blobfuse-cache/" + b"12345678123456781234567812345678/acct/container\n" + b" timeout-sec: 120\n max-size-mb: 4096\n", + ), + ], +) +async def test_blobfuse_zero_cache_sizes_use_sdk_defaults( + pattern: FuseMountPattern, + expected_config: bytes, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + session = _GeneratedConfigApplySession(session_id=session_id) + + await pattern.apply( + session, + Path("/workspace/mnt"), + FuseMountConfig( + account="acct", + container="container", + endpoint=None, + identity_client_id=None, + account_key="secret", + mount_type="azure_blob_mount", + read_only=True, + ), + ) + + assert expected_config in session.write_calls[0][1] + + @pytest.mark.asyncio async def test_blobfuse_cache_path_must_be_relative_to_workspace() -> None: with pytest.raises(MountConfigError) as exc_info: diff --git a/tests/test_shell_call_serialization.py b/tests/test_shell_call_serialization.py index de6f81e865..2c0b4d9c35 100644 --- a/tests/test_shell_call_serialization.py +++ b/tests/test_shell_call_serialization.py @@ -23,6 +23,51 @@ def test_coerce_shell_call_reads_max_output_length() -> None: assert result.action.max_output_length == 512 +@pytest.mark.parametrize("timeout_key", ["timeout_ms", "timeoutMs", "timeout"]) +@pytest.mark.parametrize("timeout_value", [0, 0.0]) +def test_coerce_shell_call_treats_zero_timeout_as_unspecified( + timeout_key: str, + timeout_value: float, +) -> None: + tool_call = { + "call_id": "shell-zero-timeout", + "action": {"commands": ["ls"], timeout_key: timeout_value}, + } + + result = run_loop.coerce_shell_call(tool_call) + + assert result.action.timeout_ms is None + + +@pytest.mark.parametrize("timeout_key", ["timeout_ms", "timeoutMs", "timeout"]) +def test_coerce_shell_call_preserves_positive_timeout(timeout_key: str) -> None: + tool_call = { + "call_id": "shell-positive-timeout", + "action": {"commands": ["ls"], timeout_key: 250}, + } + + result = run_loop.coerce_shell_call(tool_call) + + assert result.action.timeout_ms == 250 + + +@pytest.mark.parametrize( + "timeout_value", + [-1, 0.5, False, "250"], +) +def test_coerce_shell_call_rejects_unsupported_timeout_values(timeout_value: object) -> None: + tool_call = { + "call_id": "shell-invalid-timeout", + "action": {"commands": ["ls"], "timeout_ms": timeout_value}, + } + + with pytest.raises( + ModelBehaviorError, + match="Shell call action timeout must be a positive integer", + ): + run_loop.coerce_shell_call(tool_call) + + def test_coerce_shell_call_requires_commands() -> None: tool_call = {"call_id": "shell-2", "action": {"commands": []}} with pytest.raises(ModelBehaviorError): From c94cebd37dc0eb11bb4f0f9f724c734a1586adbd Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 2 Aug 2026 11:50:20 +0900 Subject: [PATCH 099/473] docs: update translated pages --- docs/ja/guardrails.md | 72 +++++------ docs/ja/index.md | 89 ++++++------- docs/ja/mcp.md | 170 +++++++++++++------------ docs/ja/tools.md | 284 +++++++++++++++++++++--------------------- docs/ko/guardrails.md | 80 ++++++------ docs/ko/index.md | 85 ++++++------- docs/ko/mcp.md | 160 ++++++++++++------------ docs/ko/tools.md | 252 ++++++++++++++++++------------------- docs/zh/guardrails.md | 76 +++++------ docs/zh/index.md | 91 +++++++------- docs/zh/mcp.md | 159 +++++++++++------------ docs/zh/tools.md | 260 +++++++++++++++++++------------------- 12 files changed, 901 insertions(+), 877 deletions(-) diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index 4768eca7b4..30590ed091 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -4,75 +4,77 @@ search: --- # ガードレール -ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、顧客からのリクエストを支援するために非常に賢い(そのため低速で高コストな)モデルを使用するエージェントがあるとします。悪意のあるユーザーに、そのモデルへ数学の宿題を手伝わせたくはないはずです。そこで、高速 / 低コストなモデルでガードレールを実行できます。ガードレールが悪意のある使用を検出した場合、即座にエラーを送出して高価なモデルの実行を防ぎ、時間と費用を節約できます( **ブロッキングガードレールを使用する場合に限ります。並列ガードレールでは、ガードレールが完了する前に、高価なモデルがすでに実行を開始している可能性があります。詳細は下記の「実行モード」を参照してください** )。 +ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、非常に高性能である一方、低速でコストの高いモデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストのモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、直ちにエラーを発生させて高コストのモデルの実行を防ぎ、時間と費用を節約できます **(ブロッキングガードレールを使用する場合。並列ガードレールでは、ガードレールが完了する前に高コストのモデルがすでに実行を開始している可能性があります。詳細については、以下の「実行モード」を参照してください)** 。 -ガードレールには 2 種類あります。 +ガードレールには次の 2 種類があります。 -1. 入力ガードレールは、最初のユーザー入力に対して実行されます。 -2. 出力ガードレールは、最終的なエージェント出力に対して実行されます。 +1. 入力ガードレールは、最初のユーザー入力に対して実行されます +2. 出力ガードレールは、最終的なエージェント出力に対して実行されます ## ワークフローの境界 -ガードレールはエージェントとツールにアタッチされますが、すべてがワークフロー内の同じ時点で実行されるわけではありません。 +ガードレールはエージェントとツールに設定されますが、すべてがワークフロー内の同じ時点で実行されるわけではありません。 -- **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 -- **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 -- **ツールガードレール** は、すべてのカスタム関数ツール呼び出しで実行され、実行前に入力ガードレール、実行後に出力ガードレールが実行されます。 +- **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 +- **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 +- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前に、出力ガードレールは実行後に実行されます。 -マネージャー、ハンドオフ、または委任先の専門家を含むワークフローで、各カスタム関数ツール呼び出しの前後にチェックが必要な場合は、エージェントレベルの入力 / 出力ガードレールだけに頼るのではなく、ツールガードレールを使用してください。 +マネージャー、ハンドオフ、または委任されたスペシャリストを含むワークフローで、各カスタム関数ツールの呼び出し前後にチェックが必要な場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 ## 入力ガードレール -入力ガードレールは 3 ステップで実行されます。 +入力ガードレールは、次の 3 ステップで実行されます。 -1. まず、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 -2. 次に、ガードレール関数が実行され、 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] が生成されます。これはその後 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます。 -3. 最後に、 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が送出されるため、ユーザーに適切に応答したり、例外を処理したりできます。 +1. 最初に、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 +2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答するか、例外を処理できます。 !!! Note - 入力ガードレールはユーザー入力に対して実行されることを想定しているため、エージェントのガードレールは、そのエージェントが *最初の* エージェントである場合にのみ実行されます。なぜ `guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェント上にあるのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連していることが多いためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置しておくと可読性の面で役立ちます。 + 入力ガードレールはユーザー入力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最初の* エージェントである場合にのみ実行されます。なぜ `guardrails` プロパティを `Runner.run` に渡すのではなく、エージェントに設定するのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所にまとめると可読性が向上します。 ### 実行モード -入力ガードレールは 2 つの実行モードをサポートします。 +入力ガードレールは、次の 2 つの実行モードをサポートしています。 -- **並列実行** (デフォルト、 `run_in_parallel=True` ): ガードレールはエージェントの実行と並行して実行されます。両方が同時に開始されるため、レイテンシが最も良くなります。ただし、ガードレールが失敗した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 +- **並列実行** (デフォルト、`run_in_parallel=True`):ガードレールはエージェントの実行と並行して実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールが失敗した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 -- **ブロッキング実行** ( `run_in_parallel=False` ): ガードレールはエージェントが開始する *前に* 実行され、完了します。ガードレールのトリップワイヤーが発火した場合、エージェントは一切実行されないため、トークン消費とツール実行を防げます。これは、コスト最適化や、ツール呼び出しによる潜在的な副作用を避けたい場合に最適です。 +- **ブロッキング実行** (`run_in_parallel=False`):ガードレールは、エージェントが開始する *前に* 実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防げます。コストを最適化したい場合や、ツール呼び出しによる潜在的な副作用を避けたい場合に最適です。 ## 出力ガードレール -出力ガードレールは 3 ステップで実行されます。 +出力ガードレールは、次の 3 ステップで実行されます。 -1. まず、ガードレールはエージェントによって生成された出力を受け取ります。 -2. 次に、ガードレール関数が実行され、 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] が生成されます。これはその後 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます。 -3. 最後に、 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が送出されるため、ユーザーに適切に応答したり、例外を処理したりできます。 +1. 最初に、ガードレールはエージェントが生成した出力を受け取ります。 +2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答するか、例外を処理できます。 !!! Note - 出力ガードレールは最終的なエージェント出力に対して実行されることを想定しているため、エージェントのガードレールは、そのエージェントが *最後の* エージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連していることが多いためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置しておくと可読性の面で役立ちます。 + 出力ガードレールは最終的なエージェント出力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最後の* エージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所にまとめると可読性が向上します。 - 出力ガードレールは必ずエージェントの完了後に実行されるため、 `run_in_parallel` パラメーターはサポートしません。 + 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターはサポートしていません。 ## ツールガードレール -ツールガードレールは **関数ツール** をラップし、実行の前後でツール呼び出しを検証またはブロックできるようにします。これはツール自体に設定され、そのツールが呼び出されるたびに実行されます。 +ツールガードレールは **関数ツール** をラップし、実行前後にツール呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 -- 入力ツールガードレールはツール実行前に実行され、呼び出しをスキップしたり、出力をメッセージに置き換えたり、トリップワイヤーを送出したりできます。 -- 出力ツールガードレールはツール実行後に実行され、出力を置き換えたり、トリップワイヤーを送出したりできます。 -- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に実行されます。保留中の承認割り込みが発行される前にこれらの入力チェックを実行したい場合は、 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定してください。この承認前チェックに合格した呼び出しも、ツールが実行される前に、承認後に再度チェックされます。 -- ツールガードレールは、 [`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく、 SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール( `WebSearchTool` 、 `FileSearchTool` 、 `HostedMCPTool` 、 `CodeInterpreterTool` 、 `ImageGenerationTool` )と組み込み実行ツール( `ComputerTool` 、 `ShellTool` 、 `ApplyPatchTool` 、 `LocalShellTool` )もこのガードレールパイプラインを使用しません。また、 [`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 +- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、メッセージによる出力の置き換え、またはトリップワイヤーの作動が可能です。 +- 出力ツールガードレールはツールの実行後に実行され、出力の置き換え、またはトリップワイヤーの作動が可能です。 +- 関数ツールに承認が必要な場合、通常、入力ツールガードレールは承認後、実行の直前に実行されます。保留中の承認による中断が発生する前にこれらの入力チェックを実行する場合は、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定してください。この承認前チェックに合格した呼び出しも、承認後、ツールの実行前に再度チェックされます。 +- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)と組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)も、このガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 -詳細は、下記のコードスニペットを参照してください。 +詳細については、以下のコードスニペットを参照してください。 ## トリップワイヤー -入力または出力がガードレールに合格しなかった場合、ガードレールはトリップワイヤーでこれを知らせることができます。トリップワイヤーが発火したガードレールを検出した時点で、即座に `{Input,Output}GuardrailTripwireTriggered` 例外を送出し、エージェントの実行を停止します。 +入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーによってこれを通知できます。トリップワイヤーが作動したガードレールを検出すると、直ちに `{Input,Output}GuardrailTripwireTriggered` 例外が発生し、エージェントの実行が停止します。 + +例外の `guardrail_result` は、トリップワイヤーを作動させたガードレールを識別します。Runner によって発生した入力トリップワイヤーの場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの実行結果が含まれ、トリップワイヤーを作動させた実行結果も含まれます。ストリーミング実行結果では、`stream_events()` が例外を発生させた後、同じ蓄積済みの実行結果が `input_guardrail_results` を通じて公開されます。Runner が管理する実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 ## ガードレールの実装 -入力を受け取り、 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することでこれを行います。 +入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することで実装します。 ```python from pydantic import BaseModel @@ -125,9 +127,9 @@ async def main(): print("Math homework guardrail tripped") ``` -1. このエージェントをガードレール関数で使用します。 -2. これは、エージェントの入力 / コンテキストを受け取り、実行結果を返すガードレール関数です。 -3. ガードレールの実行結果に追加情報を含めることができます。 +1. このエージェントをガードレール関数内で使用します。 +2. これは、エージェントの入力/コンテキストを受け取り、実行結果を返すガードレール関数です。 +3. ガードレールの実行結果には追加情報を含められます。 4. これは、ワークフローを定義する実際のエージェントです。 出力ガードレールも同様です。 @@ -188,7 +190,7 @@ async def main(): 3. これは、エージェントの出力を受け取り、実行結果を返すガードレール関数です。 4. これは、ワークフローを定義する実際のエージェントです。 -最後に、ツールガードレールのコード例を示します。 +最後に、ツールガードレールの例を示します。 ```python import json diff --git a/docs/ja/index.md b/docs/ja/index.md index 7edea51f59..048e36fddc 100644 --- a/docs/ja/index.md +++ b/docs/ja/index.md @@ -4,51 +4,52 @@ search: --- # OpenAI Agents SDK -[OpenAI Agents SDK](https://github.com/openai/openai-agents-python) は、抽象化をほとんど持たない軽量で使いやすいパッケージで、エージェント型 AI アプリを構築できるようにします。これは、以前のエージェント向け実験プロジェクトである [Swarm](https://github.com/openai/swarm/tree/main) を本番環境対応に発展させたものです。Agents SDK は、非常に少数の基本コンポーネントで構成されています: +[OpenAI Agents SDK](https://github.com/openai/openai-agents-python) を使用すると、抽象化を最小限に抑えた軽量で使いやすいパッケージで、エージェント型 AI アプリを構築できます。これは、以前のエージェント向け実験プロジェクトである [Swarm](https://github.com/openai/swarm/tree/main) を本番環境向けに進化させたものです。Agents SDK は、ごく少数の基本コンポーネントで構成されています。 -- **エージェント**: 指示とツールを備えた LLM です -- **Agents as tools / ハンドオフ**: エージェントが特定のタスクを他のエージェントに委任できるようにします -- **ガードレール**: エージェントの入力と出力の検証を可能にします +- **エージェント**: 指示とツールを備えた LLM +- **Agents as tools / ハンドオフ**: エージェントが特定のタスクをほかのエージェントに委任できる仕組み +- **ガードレール**: エージェントの入力と出力を検証する仕組み -Python と組み合わせることで、これらの基本コンポーネントは、ツールとエージェント間の複雑な関係を表現するのに十分強力であり、習得のハードルを高くすることなく実世界のアプリケーションを構築できます。さらに、SDK には組み込みの **トレーシング** が含まれており、エージェント型フローの可視化とデバッグ、評価、さらにはアプリケーション向けのモデルのファインチューニングも可能です。 +Python と組み合わせることで、これらの基本コンポーネントは、ツールとエージェントの複雑な関係を表現するのに十分な能力を発揮し、学習負担を抑えながら実用的なアプリケーションを構築できます。さらに SDK には、エージェントフローの可視化とデバッグに加え、評価やアプリケーション向けのモデルのファインチューニングまで可能にする組み込みの **トレーシング** が用意されています。 -## Agents SDK の利用理由 +## Agents SDK を使用する理由 -SDK の設計を支える原則は 2 つあります: +SDK は、次の 2 つの設計原則に基づいています。 -1. 使用する価値がある十分な機能を備えつつ、すばやく学べるだけの少数の基本コンポーネントに抑えること。 -2. そのままでも優れた動作をしつつ、何が起こるかを正確にカスタマイズできること。 +1. 利用する価値がある十分な機能を備えつつ、すぐに習得できるよう基本コンポーネントを少数に絞ること。 +2. そのままでも優れた動作を提供しながら、処理内容を必要に応じて細かくカスタマイズできること。 -SDK の主な機能は次のとおりです: +SDK の主な機能は次のとおりです。 -- **エージェントループ**: ツール呼び出しを処理し、結果を LLM に送り返し、タスクが完了するまで継続する組み込みのエージェントループです。 -- **Python ファースト**: 新しい抽象化を学ぶ必要なく、組み込みの言語機能を使ってエージェントをオーケストレーションし、連鎖させます。 +- **エージェント**: 指示、ツール、ガードレール、ハンドオフ、およびタスクが完了するまで継続する組み込みループを使用してエージェントを構築できます。 +- **サンドボックスエージェント**: マニフェストで定義されたファイル、選択可能なサンドボックスクライアント、再開可能なサンドボックスセッションを備えた、実際に隔離されたワークスペース内で専門エージェントを実行できます。 +- **リアルタイムエージェント**: `gpt-realtime-2.1`、自動中断検出、コンテキスト管理、ガードレールなどを使用して、強力な音声エージェントを構築できます。 +- **音声エージェント**: 音声テキスト変換、エージェントワークフロー、テキスト音声変換を組み合わせた音声パイプラインを構築できます。 +- **Python ファースト**: 新しい抽象化を習得する代わりに、組み込みの言語機能を使用してエージェントオーケストレーションとエージェントの連携を実現できます。 - **Agents as tools / ハンドオフ**: 複数のエージェント間で作業を調整し、委任するための強力な仕組みです。 -- **Sandbox エージェント**: マニフェストで定義されたファイル、Sandbox クライアントの選択、再開可能なサンドボックスセッションを備えた、実際の隔離ワークスペース内で専門エージェントを実行します。 -- **ガードレール**: エージェント実行と並行して入力検証と安全性チェックを実行し、チェックに通らない場合は即座に失敗として終了します。 -- **関数ツール**: スキーマの自動生成と Pydantic によるバリデーションにより、任意の Python 関数をツールに変換します。 -- **MCP サーバーのツール呼び出し**: 関数ツールと同じように動作する、組み込みの MCP サーバーツール統合です。 +- **ガードレール**: エージェントの実行と並行して入力検証と安全性チェックを行い、チェックに合格しなかった場合は即座に失敗させます。 +- **関数ツール**: 自動スキーマ生成と Pydantic による検証を使用して、任意の Python 関数をツールに変換できます。 +- **MCP サーバーツール呼び出し**: 関数ツールと同じ方法で動作する、組み込みの MCP サーバーツール統合です。 - **セッション**: エージェントループ内で作業コンテキストを維持するための永続的なメモリレイヤーです。 -- **ヒューマンインザループ**: エージェント実行の各所に人間を関与させるための組み込みの仕組みです。 -- **トレーシング**: ワークフローを可視化、デバッグ、監視するための組み込みのトレーシングで、OpenAI の評価、ファインチューニング、蒸留ツール群をサポートします。 -- **Realtime エージェント**: `gpt-realtime-2.1` を使い、自動割り込み検出、コンテキスト管理、ガードレールなどを備えた強力な音声エージェントを構築します。 +- **ヒューマンインザループ**: エージェントの実行に人間が関与するための組み込みの仕組みです。 +- **トレーシング**: ワークフローを可視化、デバッグ、監視するための組み込みのトレーシングです。OpenAI の評価、ファインチューニング、蒸留ツールスイートにも対応しています。 ## Agents SDK と Responses API の選択 -SDK は OpenAI モデルに対してデフォルトで Responses API を使用しますが、モデル呼び出しの周りに高レベルのランタイムを追加します。 +SDK は OpenAI モデルに対してデフォルトで Responses API を使用しますが、モデル呼び出しを囲む、より高レベルのランタイムも提供します。 -次の場合は Responses API を直接使用します: +次の場合は、Responses API を直接使用します。 -- ループ、ツールのディスパッチ、状態処理を自分で管理したい場合 -- ワークフローが短期間で、主にモデルの応答を返すことが目的の場合 +- ループ、ツールのディスパッチ、状態管理を自分で制御したい場合 +- ワークフローが短時間で完了し、主な目的がモデルの応答を返すことである場合 -次の場合は Agents SDK を使用します: +次の場合は、Agents SDK を使用します。 -- ランタイムにターン、ツール実行、ガードレール、ハンドオフ、またはセッションを管理させたい場合 -- エージェントが成果物を生成する、または複数の協調したステップにわたって動作する必要がある場合 -- 実際のワークスペース、または [Sandbox エージェント](sandbox_agents.md) による再開可能な実行が必要な場合 +- ターン、ツール実行、ガードレール、ハンドオフ、またはセッションをランタイムに管理させたい場合 +- エージェントが成果物を生成する、または連携された複数のステップにわたって動作する必要がある場合 +- [サンドボックスエージェント](sandbox_agents.md)を通じて、実際のワークスペースや再開可能な実行が必要な場合 -アプリケーション全体でどちらか一方を選ぶ必要はありません。多くのアプリケーションでは、管理されたワークフローには SDK を使用し、低レベルの処理経路では Responses API を直接呼び出します。 +アプリケーション全体でどちらか一方を選択する必要はありません。多くのアプリケーションでは、管理されたワークフローに SDK を使用し、より低レベルの処理では Responses API を直接呼び出します。 ## インストール @@ -71,31 +72,31 @@ print(result.final_output) # Infinite loop's dance. ``` -(_これを実行する場合は、 `OPENAI_API_KEY` 環境変数を設定していることを確認してください_) +(_これを実行する場合は、`OPENAI_API_KEY` 環境変数が設定されていることを確認してください_) ```bash export OPENAI_API_KEY=sk-... ``` -## 開始ポイント +## はじめに -- [クイックスタート](quickstart.md) で、最初のテキストベースのエージェントを構築します。 -- 次に、[エージェントの実行](running_agents.md#choose-a-memory-strategy) で、ターン間で状態をどのように引き継ぐかを決定します。 -- タスクが実際のファイル、リポジトリ、またはエージェントごとに隔離されたワークスペース状態に依存する場合は、[Sandbox エージェントのクイックスタート](sandbox_agents.md) を参照してください。 -- ハンドオフとマネージャースタイルのオーケストレーションのどちらにするかを決める場合は、[エージェントオーケストレーション](multi_agent.md) を参照してください。 +- [クイックスタート](quickstart.md)で、最初のテキストベースのエージェントを構築します。 +- 次に、[エージェントの実行](running_agents.md#choose-a-memory-strategy)で、ターン間の状態を維持する方法を決定します。 +- タスクが実際のファイル、リポジトリ、またはエージェントごとに隔離されたワークスペースの状態に依存する場合は、[サンドボックスエージェントのクイックスタート](sandbox_agents.md)を参照してください。 +- ハンドオフとマネージャー型オーケストレーションのどちらを使用するか検討している場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。 -## パスの選択 +## 目的別ガイド -実行したい作業は分かっているものの、どのページで説明されているか分からない場合は、この表を使用してください。 +実行したい処理は決まっていても、どのページで説明されているか分からない場合は、次の表を使用してください。 | 目的 | 参照先 | | --- | --- | -| 最初のテキストエージェントを構築し、完全な 1 回の実行を確認する | [クイックスタート](quickstart.md) | -| 関数ツール、OpenAI がホストするツール、または agents as tools を追加する | [ツール](tools.md) | -| 実際の隔離ワークスペース内で、コーディング、レビュー、またはドキュメント処理のエージェントを実行する | [Sandbox エージェントのクイックスタート](sandbox_agents.md) and [Sandbox クライアント](sandbox/clients.md) | -| ハンドオフとマネージャースタイルのオーケストレーションのどちらを使うか決める | [エージェントオーケストレーション](multi_agent.md) | -| ターン間でメモリを保持する | [エージェントの実行](running_agents.md#choose-a-memory-strategy) and [セッション](sessions/index.md) | +| 最初のテキストエージェントを構築し、一連の完全な実行を確認する | [クイックスタート](quickstart.md) | +| 関数ツール、ホスト型ツール、または agents as tools を追加する | [ツール](tools.md) | +| 実際に隔離されたワークスペース内で、コーディング、レビュー、またはドキュメント処理を行うエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md)および[サンドボックスクライアント](sandbox/clients.md) | +| ハンドオフとマネージャー型オーケストレーションのどちらを使用するか決定する | [エージェントオーケストレーション](multi_agent.md) | +| ターン間でメモリを維持する | [エージェントの実行](running_agents.md#choose-a-memory-strategy)および[セッション](sessions/index.md) | | OpenAI モデル、WebSocket トランスポート、または OpenAI 以外のプロバイダーを使用する | [モデル](models/index.md) | -| 出力、実行アイテム、割り込み、再開状態を確認する | [実行結果](results.md) | -| `gpt-realtime-2.1` を使って低レイテンシの音声エージェントを構築する | [Realtime エージェントのクイックスタート](realtime/quickstart.md) and [Realtime トランスポート](realtime/transport.md) | -| 音声認識 / エージェント / 音声合成のパイプラインを構築する | [音声パイプラインのクイックスタート](voice/quickstart.md) | \ No newline at end of file +| 出力、実行項目、中断、再開状態を確認する | [実行結果](results.md) | +| `gpt-realtime-2.1` を使用して低レイテンシーの音声エージェントを構築する | [リアルタイムエージェントのクイックスタート](realtime/quickstart.md)および[リアルタイムトランスポート](realtime/transport.md) | +| 音声テキスト変換 / エージェント / テキスト音声変換のパイプラインを構築する | [音声パイプラインのクイックスタート](voice/quickstart.md) | \ No newline at end of file diff --git a/docs/ja/mcp.md b/docs/ja/mcp.md index 22ff438c83..9e98899eb5 100644 --- a/docs/ja/mcp.md +++ b/docs/ja/mcp.md @@ -4,31 +4,34 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction) (MCP) は、アプリケーションがツールや -コンテキストを言語モデルに公開する方法を標準化します。公式ドキュメントより: +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)は、アプリケーションがツールやコンテキストを言語モデルに公開する方法を標準化します。公式ドキュメントでは、次のように説明されています。 > MCP は、アプリケーションが LLM にコンテキストを提供する方法を標準化するオープンプロトコルです。MCP は、AI -> アプリケーションにおける USB-C ポートのようなものだと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリに接続する標準化された方法を提供するのと同様に、MCP -> は AI モデルをさまざまなデータソースやツールに接続する標準化された方法を提供します。 +> アプリケーション向けの USB-C ポートのようなものだと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続するための標準化された方法を提供するのと同様に、MCP +> は AI モデルをさまざまなデータソースやツールに接続するための標準化された方法を提供します。 -Agents Python SDK は複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、独自に構築して、ファイルシステム、HTTP、またはコネクターをバックエンドとするツールをエージェントに公開できます。 +Agents Python SDK は、複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、独自の MCP サーバーを構築して、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開したりできます。 + +!!! warning "接続前の MCP サーバーの信頼性確認" + + MCP ツールは、モデルコンテキストのデータを公開し、提供された認証情報を使用してアクションを実行できます。信頼できるサーバーにのみ接続し、最小権限の認証情報を使用してください。アクセストークンは URL ではなく認可フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)を参照してください。 ## MCP 統合の選択 -MCP サーバーをエージェントに組み込む前に、ツール呼び出しをどこで実行すべきか、どのトランスポートに到達できるかを決めてください。次の表は、Python SDK がサポートする選択肢の概要です。 +MCP サーバーをエージェントに接続する前に、ツール呼び出しをどこで実行するか、どのトランスポートに到達できるかを決定します。以下の表は、Python SDK がサポートする選択肢をまとめたものです。 -| 必要なこと | 推奨オプション | +| 必要なこと | 推奨オプション | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI の Responses API に、モデルに代わって公開到達可能な MCP サーバーを呼び出させる| **ホスト型 MCP サーバーツール** ([`HostedMCPTool`][agents.tool.HostedMCPTool] 経由) | -| ローカルまたはリモートで実行している Streamable HTTP サーバーに接続する | **Streamable HTTP MCP サーバー** ([`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 経由) | -| Server-Sent Events を用いた HTTP を実装しているサーバーと通信する | **SSE を用いた HTTP MCP サーバー** ([`MCPServerSse`][agents.mcp.server.MCPServerSse] 経由) | -| ローカルプロセスを起動し、stdin/stdout 経由で通信する | **stdio MCP サーバー** ([`MCPServerStdio`][agents.mcp.server.MCPServerStdio] 経由) | +| OpenAI の Responses API が、モデルに代わって公開アクセス可能な MCP サーバーを呼び出す| [`HostedMCPTool`][agents.tool.HostedMCPTool] を使用する **ホスト型 MCP サーバーツール** | +| ローカルまたはリモートで実行する Streamable HTTP サーバーに接続する | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用する **Streamable HTTP MCP サーバー** | +| Server-Sent Events を使用する HTTP を実装したサーバーと通信する | [`MCPServerSse`][agents.mcp.server.MCPServerSse] を使用する **SSE 対応 HTTP MCP サーバー** | +| ローカルプロセスを起動し、stdin/stdout 経由で通信する | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用する **stdio MCP サーバー** | -以降のセクションでは、各オプション、その設定方法、あるトランスポートを別のトランスポートより優先すべきタイミングについて説明します。 +以下のセクションでは、各オプション、その設定方法、および各トランスポートを選択すべき状況について説明します。 ## エージェントレベルの MCP 設定 -トランスポートの選択に加えて、`Agent.mcp_config` を設定することで MCP ツールの準備方法を調整できます。 +トランスポートの選択に加えて、`Agent.mcp_config` を設定することで、MCP ツールの準備方法を調整できます。 ```python from agents import Agent @@ -48,32 +51,33 @@ agent = Agent( ) ``` -注: +注記: -- `convert_schemas_to_strict` はベストエフォートです。スキーマを変換できない場合は、元のスキーマが使用されます。 -- `failure_error_function` は、MCP ツール呼び出しの失敗をモデルにどのように提示するかを制御します。 -- `failure_error_function` が未設定の場合、SDK はデフォルトのツールエラーフォーマッターを使用します。 +- `convert_schemas_to_strict` はベストエフォートで動作します。スキーマを変換できない場合は、元のスキーマが使用されます。 +- `failure_error_function` は、MCP ツール呼び出しの失敗をモデルに提示する方法を制御します。 +- `failure_error_function` が設定されていない場合、SDK はデフォルトのツールエラーフォーマッターを使用します。 - サーバーレベルの `failure_error_function` は、そのサーバーについて `Agent.mcp_config["failure_error_function"]` を上書きします。 -- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバー接頭辞付きの名前でモデルに公開されます。これにより、複数の MCP サーバーが同じ名前のツールを公開する場合の衝突を避けやすくなります。生成される名前は ASCII セーフで、関数ツール名の長さ制限内に収まり、同じエージェント上の既存のローカル関数ツール名および有効化されたハンドオフ名を避けます。それでも SDK は元のサーバー上で元の MCP ツール名を呼び出します。 +- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、サーバー名を接頭辞とする決定的な名前でモデルに公開されます。これは、複数の MCP サーバーが同じ名前のツールを公開している場合の衝突回避に役立ちます。生成される名前は ASCII で安全に扱うことができ、関数ツール名の長さ制限内に収まり、同じエージェント上にある既存のローカル関数ツール名や有効なハンドオフ名との衝突を回避します。SDK は引き続き、元のサーバー上で元の MCP ツール名を使用して呼び出します。 -## トランスポート共通のパターン +## トランスポート間で共通のパターン -トランスポートを選択した後、多くの統合では同じ追加判断が必要になります。 +トランスポートを選択した後、ほとんどの統合では、次の事項についても決定する必要があります。 -- ツールのサブセットのみを公開する方法([ツールフィルタリング](#tool-filtering))。 +- ツールの一部のみを公開する方法([ツールのフィルタリング](#tool-filtering))。 - サーバーが再利用可能なプロンプトも提供するかどうか([プロンプト](#prompts))。 -- `list_tools()` をキャッシュすべきかどうか([キャッシュ](#caching))。 -- MCP アクティビティがトレースにどのように表示されるか([トレーシング](#tracing))。 +- `list_tools()` をキャッシュするかどうか([キャッシュ](#caching))。 +- MCP のアクティビティをトレースにどのように表示するか([トレーシング](#tracing))。 -ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP セクションでは最も完全な例を示しており、同じパターンは他のローカルトランスポートにも適用されます。 +ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP のセクションでは最も包括的なコード例を示しており、同じパターンを他のローカルトランスポートにも適用できます。 ## 1. ホスト型 MCP サーバーツール -ホスト型ツールでは、ツールのラウンドトリップ全体を OpenAI のインフラに委ねます。ツールの一覧取得と呼び出しをコード側で行う代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベル(および任意のコネクターメタデータ)を Responses API に転送します。モデルはリモートサーバーのツールを一覧表示し、Python プロセスへの追加のコールバックなしにそれらを呼び出します。現在、ホスト型ツールは Responses API のホスト型 MCP 統合をサポートする OpenAI モデルで動作します。 +ホスト型ツールでは、ツール処理の一連の往復全体が OpenAI のインフラストラクチャ内で実行されます。コード側でツールを一覧表示して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベルと任意のコネクターメタデータを Responses API に転送します。モデルはリモートサーバーのツールを一覧表示し、Python プロセスへの追加のコールバックなしで呼び出します。現在、ホスト型ツールは、Responses API のホスト型 MCP 統合をサポートする OpenAI モデルで動作します。 ### 基本的なホスト型 MCP ツール -エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加してホスト型ツールを作成します。`tool_config` 辞書は REST API に送信する JSON と同じ構造です: +[`HostedMCPTool`][agents.tool.HostedMCPTool] をエージェントの `tools` リストに追加して、ホスト型ツールを作成します。`tool_config` +の `dict` は、REST API に送信する JSON に対応しています。 ```python import asyncio @@ -105,14 +109,14 @@ async def main() -> None: asyncio.run(main()) ``` -ホスト型サーバーはツールを自動的に公開します。`mcp_servers` に追加する必要はありません。 +ホスト型サーバーはツールを自動的に公開するため、`mcp_servers` に追加する必要はありません。 -ホスト型ツール検索にホスト型 MCP サーバーを遅延読み込みさせたい場合は、`tool_config["defer_loading"] = True` を設定し、エージェントに [`ToolSearchTool`][agents.tool.ToolSearchTool] を追加します。これは OpenAI Responses モデルでのみサポートされます。ツール検索の完全な設定と制約については、[ツール](tools.md#hosted-tool-search) を参照してください。 +ホスト型ツール検索でホスト型 MCP サーバーを遅延読み込みする場合は、`tool_config["defer_loading"] = True` を設定し、[`ToolSearchTool`][agents.tool.ToolSearchTool] をエージェントに追加します。これは OpenAI Responses モデルでのみサポートされます。ツール検索の完全な設定と制約については、[ツール](tools.md#hosted-tool-search)を参照してください。 -### ホスト型 MCP の結果のストリーミング +### ホスト型 MCP 実行結果のストリーミング -ホスト型ツールは、関数ツールとまったく同じ方法で結果のストリーミングをサポートします。モデルがまだ処理中でも、`Runner.run_streamed` を使用して -増分的な MCP 出力を受け取れます: +ホスト型ツールは、関数ツールとまったく同じ方法で実行結果のストリーミングをサポートします。モデルが処理を続けている間に、`Runner.run_streamed` を使用して +増分 MCP 出力を受け取ります。 ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -124,7 +128,7 @@ print(result.final_output) ### 任意の承認フロー -サーバーが機密性の高い操作を実行できる場合、各ツール実行の前に人間またはプログラムによる承認を必須にできます。`tool_config` で `require_approval` を、単一のポリシー(`"always"`、`"never"`)またはツール名をポリシーにマッピングする辞書として設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 +サーバーが機密性の高い操作を実行できる場合、各ツールの実行前に人間またはプログラムによる承認を必須にできます。`tool_config` の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名とポリシーを対応付ける `dict` を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -152,11 +156,11 @@ agent = Agent( ) ``` -このコールバックは同期または非同期にでき、モデルが実行を継続するための承認データを必要とするたびに呼び出されます。 +コールバックは同期または非同期のいずれでも使用でき、モデルが実行を継続するために承認情報を必要とするたびに呼び出されます。 -### コネクター対応のホスト型サーバー +### コネクター連携型のホスト型サーバー -ホスト型 MCP は OpenAI コネクターにも対応しています。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 +ホスト型 MCP は OpenAI コネクターもサポートします。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 ```python import os @@ -172,11 +176,11 @@ HostedMCPTool( ) ``` -完全に動作するホスト型ツールのサンプル(ストリーミング、承認、コネクターを含む)は [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) にあります。 +ストリーミング、承認、コネクターを含む、完全に動作するホスト型ツールのコード例は、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) にあります。 ## 2. Streamable HTTP MCP サーバー -ネットワーク接続を自分で管理したい場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを制御したい場合や、レイテンシを低く保ちながら自分のインフラ内でサーバーを実行したい場合に最適です。 +ネットワーク接続を自身で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを自身で制御する場合や、低遅延を維持しながら独自のインフラストラクチャ内でサーバーを実行する場合に最適です。 ```python import asyncio @@ -211,26 +215,26 @@ async def main() -> None: asyncio.run(main()) ``` -コンストラクターは追加オプションを受け取ります。 +コンストラクターは、次の追加オプションを受け取ります。 -- `client_session_timeout_seconds` は HTTP 読み取りタイムアウトを制御します。 -- `use_structured_content` は、`tool_result.structured_content` をテキスト出力より優先するかどうかを切り替えます。 -- `max_retry_attempts` と `retry_backoff_seconds_base` は、`list_tools()` と `call_tool()` に自動リトライを追加します。 -- `tool_filter` は、ツールのサブセットのみを公開できるようにします([ツールフィルタリング](#tool-filtering) を参照)。 +- `client_session_timeout_seconds` は、MCP ClientSession の読み取りタイムアウトを制御します。`datetime.timedelta` で表現可能かつ 1 マイクロ秒以上の正の有限値を指定すると、有限のタイムアウトが設定されます。`None` と `0` を指定すると無効になります。それ以外の値は、サーバーの構築時に拒否されます。 +- `use_structured_content` は、テキスト出力よりも `tool_result.structured_content` を優先するかどうかを切り替えます。 +- `max_retry_attempts` と `retry_backoff_seconds_base` は、`list_tools()` と `call_tool()` に自動再試行を追加します。 +- `tool_filter` を使用すると、一部のツールのみを公開できます([ツールのフィルタリング](#tool-filtering)を参照)。 - `require_approval` は、ローカル MCP ツールでヒューマンインザループの承認ポリシーを有効にします。 -- `failure_error_function` は、モデルに表示される MCP ツール失敗メッセージをカスタマイズします。`None` に設定すると、代わりにエラーを送出します。 -- `tool_meta_resolver` は、呼び出しごとの MCP `_meta` ペイロードを `call_tool()` の前に注入します。 +- `failure_error_function` は、モデルに表示される MCP ツールの失敗メッセージをカスタマイズします。代わりにエラーを送出するには、`None` に設定します。 +- `tool_meta_resolver` は、`call_tool()` の前に呼び出しごとの MCP `_meta` ペイロードを挿入します。 ### ローカル MCP サーバーの承認ポリシー -`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` はいずれも `require_approval` を受け取ります。 +`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` はすべて `require_approval` を受け取ります。 -サポートされる形式: +サポートされる形式: - すべてのツールに対する `"always"` または `"never"`。 - `True` / `False`(always/never と同等)。 -- ツールごとのマップ。例: `{"delete_file": "always", "read_file": "never"}`。 -- グループ化されたオブジェクト: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 +- ツールごとのマップ。例:`{"delete_file": "always", "read_file": "never"}`。 +- グループ化されたオブジェクト:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 ```python async with MCPServerStreamableHttp( @@ -241,11 +245,11 @@ async with MCPServerStreamableHttp( ... ``` -完全な一時停止 / 再開フローについては、[ヒューマンインザループ](human_in_the_loop.md) と `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 +完全な一時停止/再開フローについては、[ヒューマンインザループ](human_in_the_loop.md)および `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 ### `tool_meta_resolver` による呼び出しごとのメタデータ -MCP サーバーが `_meta` にリクエストメタデータ(たとえばテナント ID やトレースコンテキスト)を期待する場合は、`tool_meta_resolver` を使用します。下の例では、`Runner.run(...)` に `context` として `dict` を渡すことを前提としています。 +MCP サーバーが `_meta` 内にリクエストメタデータ(テナント ID やトレースコンテキストなど)を必要とする場合は、`tool_meta_resolver` を使用します。以下のコード例では、`Runner.run(...)` に `context` として `dict` を渡すことを前提としています。 ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -266,19 +270,19 @@ server = MCPServerStreamableHttp( ) ``` -実行コンテキストが Pydantic モデル、データクラス、またはカスタムクラスの場合は、属性アクセスでテナント ID を読み取ってください。 +実行コンテキストが Pydantic モデル、データクラス、またはカスタムクラスの場合は、属性アクセスを使用してテナント ID を読み取ります。 -### MCP ツール出力: テキストと画像 +### MCP ツールの出力:テキストと画像 -MCP ツールが画像コンテンツを返すと、SDK はそれを画像ツール出力エントリーに自動的にマッピングします。テキスト / 画像の混在レスポンスは出力項目のリストとして転送されるため、エージェントは通常の関数ツールからの画像出力を扱うのと同じ方法で MCP の画像結果を扱えます。 +MCP ツールが画像コンテンツを返すと、SDK はそれを画像ツールの出力エントリーに自動的にマッピングします。テキストと画像が混在するレスポンスは、出力項目のリストとして転送されます。そのため、エージェントは通常の関数ツールからの画像出力と同じ方法で、MCP の画像の実行結果を利用できます。 -## 3. SSE を用いた HTTP MCP サーバー +## 3. SSE 対応 HTTP MCP サーバー !!! warning - MCP プロジェクトでは Server-Sent Events トランスポートが非推奨になりました。新しい統合では Streamable HTTP または stdio を優先し、SSE はレガシーサーバーにのみ使用してください。 + MCP プロジェクトでは、Server-Sent Events トランスポートが非推奨になっています。新しい統合では Streamable HTTP または stdio を優先し、SSE はレガシーサーバーにのみ使用してください。 -MCP サーバーが SSE を用いた HTTP トランスポートを実装している場合は、[`MCPServerSse`][agents.mcp.server.MCPServerSse] をインスタンス化します。トランスポート以外は、API は Streamable HTTP サーバーと同一です。 +MCP サーバーが SSE 対応 HTTP トランスポートを実装している場合は、[`MCPServerSse`][agents.mcp.server.MCPServerSse] をインスタンス化します。トランスポートを除き、API は Streamable HTTP サーバーと同一です。 ```python @@ -307,7 +311,7 @@ async with MCPServerSse( ## 4. stdio MCP サーバー -ローカルサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを起動し、パイプを開いたままにし、コンテキストマネージャーを抜けると自動的に閉じます。このオプションは、簡単な概念実証や、サーバーがコマンドラインエントリーポイントのみを公開する場合に役立ちます。 +ローカルサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを生成してパイプを開いたままにし、コンテキストマネージャーの終了時に自動的にパイプを閉じます。このオプションは、簡単な概念実証や、サーバーがコマンドラインのエントリーポイントのみを公開している場合に便利です。 ```python from pathlib import Path @@ -335,7 +339,7 @@ async with MCPServerStdio( ## 5. MCP サーバーマネージャー -複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、接続済みのサブセットをエージェントに公開します。コンストラクターオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md) を参照してください。 +複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、正常に接続されたサーバーのサブセットをエージェントに公開します。コンストラクターのオプションと再接続動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 ```python from agents import Agent, Runner @@ -356,25 +360,25 @@ async with MCPServerManager(servers) as manager: print(result.final_output) ``` -主な動作: +主な動作: -- `active_servers` には、`drop_failed_servers=True`(デフォルト)の場合、正常に接続されたサーバーのみが含まれます。 -- 失敗は `failed_servers` と `errors` で追跡されます。 -- `strict=True` を設定すると、最初の接続失敗時にエラーを送出します。 -- `reconnect(failed_only=True)` を呼び出すと失敗したサーバーを再試行し、`reconnect(failed_only=False)` を呼び出すとすべてのサーバーを再起動します。 -- `connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を使用してライフサイクル動作を調整します。 +- `drop_failed_servers=True`(デフォルト)の場合、`active_servers` には正常に接続されたサーバーのみが含まれます。 +- 失敗は `failed_servers` と `errors` に記録されます。 +- 最初の接続失敗時に例外を送出するには、`strict=True` を設定します。 +- 失敗したサーバーを再試行するには `reconnect(failed_only=True)` を呼び出し、すべてのサーバーを再起動するには `reconnect(failed_only=False)` を呼び出します。 +- ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。ライフサイクルのタイムアウトには、正の有限秒数、またはタイムアウトを無効にする `None` を指定できます。これらは構築時と代入時の両方で検証されます。ゼロは即時の期限を作成するため拒否されます。 -## 共通のサーバー機能 +## サーバー共通機能 -以下のセクションは MCP サーバートランスポート全体に適用されます(正確な API の範囲はサーバークラスによって異なります)。 +以下のセクションは、MCP サーバーの各トランスポートに共通して適用されます(利用できる正確な API はサーバークラスによって異なります)。 -## ツールフィルタリング +## ツールのフィルタリング -各 MCP サーバーはツールフィルターに対応しているため、エージェントに必要な関数だけを公開できます。フィルタリングは構築時に行うことも、実行ごとに動的に行うこともできます。 +各 MCP サーバーはツールフィルターをサポートしているため、エージェントが必要とする関数のみを公開できます。フィルタリングは、構築時または実行ごとに動的に行えます。 ### 静的なツールフィルタリング -[`create_static_tool_filter`][agents.mcp.create_static_tool_filter] を使用して、シンプルな許可 / ブロックリストを設定します: +単純な許可/ブロックリストを設定するには、[`create_static_tool_filter`][agents.mcp.create_static_tool_filter] を使用します。 ```python from pathlib import Path @@ -392,11 +396,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK はまず許可リストを適用し、その後、残ったセットからブロックされたツールを削除します。 +`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK は最初に許可リストを適用し、残ったセットからブロックされたツールを削除します。 ### 動的なツールフィルタリング -より高度なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取るコール可能オブジェクトを渡します。このコール可能オブジェクトは同期または非同期にでき、ツールを公開すべき場合に `True` を返します。 +より複雑なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る callable を渡します。callable は同期または非同期のいずれでも使用でき、ツールを公開すべき場合に `True` を返します。 ```python from pathlib import Path @@ -424,11 +428,11 @@ async with MCPServerStdio( ## プロンプト -MCP サーバーは、エージェントの指示を動的に生成するプロンプトも提供できます。プロンプトに対応するサーバーは 2 つの -メソッドを公開します: +MCP サーバーは、エージェントへの指示を動的に生成するプロンプトも提供できます。プロンプトをサポートするサーバーは、次の 2 つの +メソッドを公開します。 -- `list_prompts()` は利用可能なプロンプトテンプレートを列挙します。 -- `get_prompt(name, arguments)` は具体的なプロンプトを取得します。任意でパラメーターを指定できます。 +- `list_prompts()` は、利用可能なプロンプトテンプレートを列挙します。 +- `get_prompt(name, arguments)` は、必要に応じてパラメーターを指定して、具体的なプロンプトを取得します。 ```python from agents import Agent @@ -448,19 +452,19 @@ agent = Agent( ## キャッシュ -エージェントを実行するたびに、各 MCP サーバーで `list_tools()` が呼び出されます。リモートサーバーでは目に見えるレイテンシが発生する可能性があるため、すべての MCP サーバークラスは `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新のリストを強制的に取得するには、サーバーインスタンスで `invalidate_tools_cache()` を呼び出します。 +エージェントを実行するたびに、各 MCP サーバーで `list_tools()` が呼び出されます。リモートサーバーは無視できない遅延を発生させる可能性があるため、すべての MCP サーバークラスは `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新のリストを強制的に取得するには、サーバーインスタンスで `invalidate_tools_cache()` を呼び出します。 ## トレーシング -[トレーシング](./tracing.md) は、次を含む MCP アクティビティを自動的にキャプチャします。 +[トレーシング](./tracing.md)は、次のような MCP アクティビティを自動的に記録します。 -1. ツールを一覧表示するための MCP サーバーへの呼び出し。 -2. ツール呼び出し上の MCP 関連情報。 +1. ツール一覧を取得するための MCP サーバーへの呼び出し。 +2. ツール呼び出しに含まれる MCP 関連情報。 ![MCP トレーシングのスクリーンショット](../assets/images/mcp-tracing.jpg) -## 参考情報 +## 関連資料 -- [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様と設計ガイド。 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 実行可能な stdio、SSE、Streamable HTTP のサンプル。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認とコネクターを含む、完全なホスト型 MCP デモ。 \ No newline at end of file +- [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様および設計ガイド。 +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 実行可能な stdio、SSE、Streamable HTTP のコード例。 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認とコネクターを含む、ホスト型 MCP の完全なデモ。 \ No newline at end of file diff --git a/docs/ja/tools.md b/docs/ja/tools.md index 339a4b7df8..81efb977c0 100644 --- a/docs/ja/tools.md +++ b/docs/ja/tools.md @@ -6,41 +6,41 @@ search: ツールを使用すると、エージェントはデータの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作などのアクションを実行できます。SDK は 5 つのカテゴリーをサポートしています。 -- OpenAI がホストするツール: OpenAI のサーバー上でモデルと並行して実行されます。 -- ローカル/ランタイム実行ツール: `ComputerTool` と `ApplyPatchTool` は常にご利用の環境で実行され、`ShellTool` はローカルまたはホスト型コンテナで実行できます。 -- Function Calling: 任意の Python 関数をツールとしてラップします。 -- Agents as tools: 完全なハンドオフを行わず、エージェントを呼び出し可能なツールとして公開します。 -- 試験的機能: Codex ツール: ツール呼び出しからワークスペーススコープの Codex タスクを実行します。 +- OpenAI がホストするツール:OpenAI のサーバー上でモデルと並行して実行されます。 +- ローカル/ランタイム実行ツール:`ComputerTool` と `ApplyPatchTool` は常にお使いの環境で実行され、`ShellTool` はローカルまたはホスト型コンテナで実行できます。 +- Function Calling:任意の Python 関数をツールとしてラップします。 +- Agents as tools:完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 +- 実験的機能:Codex ツール:ツール呼び出しからワークスペース単位の Codex タスクを実行します。 ## ツールタイプの選択 -このページをカタログとして使用し、管理するランタイムに該当するセクションへ移動してください。 +このページをカタログとして使用し、制御するランタイムに対応するセクションへ進んでください。 -| 目的 | 参照先 | +| 実行したいこと | 参照先 | | --- | --- | | OpenAI が管理するツール(Web 検索、ファイル検索、Code Interpreter、ホスト型 MCP、画像生成)を使用する | [ホスト型ツール](#hosted-tools) | -| ツール検索を使用して、大規模なツールサーフェスの読み込みをランタイムまで遅延する | [ホスト型ツール検索](#hosted-tool-search) | +| ツール検索を使用して、大規模なツール群の読み込みをランタイムまで遅延する | [ホスト型ツール検索](#hosted-tool-search) | | 生成された JavaScript から複数のツール呼び出しを調整する | [プログラムによるツール呼び出し](#programmatic-tool-calling) | | 独自のプロセスまたは環境でツールを実行する | [ローカルランタイムツール](#local-runtime-tools) | | Python 関数をツールとしてラップする | [関数ツール](#function-tools) | -| ハンドオフを行わずに、あるエージェントから別のエージェントを呼び出せるようにする | [Agents as tools](#agents-as-tools) | -| エージェントからワークスペーススコープの Codex タスクを実行する | [試験的機能: Codex ツール](#experimental-codex-tool) | +| ハンドオフなしで、あるエージェントから別のエージェントを呼び出せるようにする | [Agents as tools](#agents-as-tools) | +| エージェントからワークスペース単位の Codex タスクを実行する | [実験的機能:Codex ツール](#experimental-codex-tool) | ## ホスト型ツール -OpenAI は、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合に、いくつかの組み込みツールを提供しています。 +OpenAI は、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する際に、いくつかの組み込みツールを提供しています。 - [`WebSearchTool`][agents.tool.WebSearchTool] を使用すると、エージェントが Web を検索できます。 - [`FileSearchTool`][agents.tool.FileSearchTool] を使用すると、OpenAI ベクトルストアから情報を取得できます。 - [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] を使用すると、LLM がサンドボックス環境でコードを実行できます。 - [`HostedMCPTool`][agents.tool.HostedMCPTool] は、リモート MCP サーバーのツールをモデルに公開します。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] は、プロンプトから画像を生成します。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルが遅延ツール、名前空間、またはホスト型 MCP サーバーをオンデマンドで読み込めます。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルが生成した JavaScript から対象ツールを調整できます。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルは遅延されたツール、名前空間、またはホスト型 MCP サーバーを必要に応じて読み込めます。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルは生成された JavaScript から対象ツールを調整できます。 -ホスト型検索の高度なオプション: +ホスト型検索の高度なオプション: -- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。 +- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。`max_num_results` には 1~50 の整数を設定します。`None` またはゼロの場合は、プロバイダーのデフォルトが使用されます。 - `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートします。 ```python @@ -64,9 +64,9 @@ async def main(): ### ホスト型ツール検索 -ツール検索を使用すると、OpenAI Responses モデルは大規模なツールサーフェスの読み込みをランタイムまで遅延できるため、現在のターンに必要なサブセットのみを読み込みます。これは、多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールを事前に公開することなくツールスキーマのトークン数を削減したい場合に便利です。 +ツール検索を使用すると、OpenAI Responses モデルは大規模なツール群の読み込みをランタイムまで遅延できるため、現在のターンで必要なサブセットのみを読み込めます。多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールをあらかじめ公開せずにツールスキーマのトークン数を削減したい場合に役立ちます。 -エージェントを構築する時点で候補ツールがすでに判明している場合は、ホスト型ツール検索から始めてください。アプリケーションで読み込む対象を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしますが、標準の `Runner` はこのモードを自動実行しません。 +エージェントを構築する時点で候補となるツールがすでに分かっている場合は、ホスト型ツール検索を使用してください。アプリケーション側で読み込む対象を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしていますが、標準の `Runner` はこのモードを自動実行しません。 ```python from typing import Annotated @@ -109,28 +109,28 @@ result = await Runner.run(agent, "Look up customer_42 and list their open orders print(result.final_output) ``` -留意事項: - -- ホスト型ツール検索は、OpenAI Responses モデルでのみ利用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 -- エージェントに遅延読み込みサーフェスを設定する場合は、`ToolSearchTool()` をちょうど 1 つ追加してください。 -- 検索可能なサーフェスには、`@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])`、`HostedMCPTool(tool_config={..., "defer_loading": True})` が含まれます。 -- 遅延読み込みを行う関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成でも、モデルが適切なグループをオンデマンドで読み込めるように `ToolSearchTool()` を使用できます。 -- `tool_namespace()` は、`FunctionTool` インスタンスを共通の名前空間名と説明の下にグループ化します。これは通常、`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に最適です。 -- OpenAI の公式ベストプラクティスガイダンスは、[可能な限り名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことです。 -- 可能な場合は、個別に遅延される多数の関数よりも、名前空間またはホスト型 MCP サーバーを優先してください。通常、これらはモデルに対してより適切な高レベルの検索サーフェスを提供し、トークンをより多く節約できます。 -- 名前空間には、即時ツールと遅延ツールを混在させられます。`defer_loading=True` が指定されていないツールは引き続き即座に呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 -- 目安として、各名前空間は比較的小さく保ち、10 個未満の関数にすることが理想的です。 -- 名前付きの `tool_choice` では、単独の名前空間名や遅延専用ツールを対象にできません。`auto`、`required`、または実際のトップレベルの呼び出し可能なツール名を使用することを推奨します。 -- `ToolSearchTool(execution="client")` は、Responses を手動でオーケストレーションするためのものです。モデルがクライアント実行型の `tool_search_call` を生成した場合、標準の `Runner` はそれを実行せずに例外を送出します。 -- ツール検索のアクティビティは、専用の項目タイプおよびイベントタイプとともに、[`RunResult.new_items`](results.md#new-items) と [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 -- 名前空間による読み込みとトップレベルの遅延ツールの両方を扱う、実行可能な完全なコード例については、`examples/tools/tool_search.py` を参照してください。 -- 公式プラットフォームガイド: [ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 +留意事項: + +- ホスト型ツール検索は、OpenAI Responses モデルでのみ使用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 +- エージェントで遅延読み込み対象を設定する場合は、`ToolSearchTool()` を 1 つだけ追加します。 +- 検索可能な対象には、`@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])`、`HostedMCPTool(tool_config={..., "defer_loading": True})` が含まれます。 +- 遅延読み込みを行う関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成でも `ToolSearchTool()` を使用し、モデルが必要に応じて適切なグループを読み込めるようにできます。 +- `tool_namespace()` は、複数の `FunctionTool` インスタンスを共通の名前空間名と説明の下にまとめます。`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に通常最も適しています。 +- OpenAI の公式ベストプラクティスガイダンスでは、[可能な場合は名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことを推奨しています。 +- 可能であれば、個別に遅延される多数の関数よりも、名前空間またはホスト型 MCP サーバーを優先してください。通常、モデルにとって高水準で検索しやすい対象となり、トークンもより効果的に節約できます。 +- 名前空間には、即時利用可能なツールと遅延ツールを混在させられます。`defer_loading=True` が指定されていないツールはすぐに呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 +- 目安として、各名前空間は十分に小さく保ち、できれば関数を 10 個未満にしてください。 +- 名前付きの `tool_choice` では、単独の名前空間名や遅延専用ツールを指定できません。`auto`、`required`、または実際にトップレベルで呼び出し可能なツール名を使用してください。 +- `ToolSearchTool(execution="client")` は、Responses を手動でオーケストレーションするためのものです。モデルがクライアント実行型の `tool_search_call` を生成した場合、標準の `Runner` はそれを自動実行せず、例外を発生させます。 +- ツール検索のアクティビティは、[`RunResult.new_items`](results.md#new-items) および [`RunItemStreamEvent`](streaming.md#run-item-event-names) に、専用の項目タイプとイベントタイプとして表示されます。 +- 名前空間による読み込みとトップレベルの遅延ツールの両方を扱う、完全に実行可能なコード例については、`examples/tools/tool_search.py` を参照してください。 +- 公式プラットフォームガイド:[ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 ### プログラムによるツール呼び出し -プログラムによるツール呼び出しを使用すると、サポート対象の OpenAI Responses モデルが JavaScript を生成し、対象ツールを呼び出して、その出力を結合し、1 つの実行結果をモデルに返せます。各ツール呼び出し後にモデルとのラウンドトリップを行うことなく、ループ、分岐、並列呼び出し、中間計算を活用できる範囲の限定されたワークフローに便利です。 +プログラムによるツール呼び出しを使用すると、対応する OpenAI Responses モデルが JavaScript を生成し、対象ツールを呼び出して出力を結合し、1 つの結果をモデルに返せます。各ツール呼び出しの後にモデルとのラウンドトリップを行うことなく、ループ、分岐、並列呼び出し、中間計算を活用できる、範囲の明確なワークフローに役立ちます。 -生成されたプログラムは、新しいホスト型 V8 環境で実行されます。Node.js API、ファイルシステムやネットワークへのアクセス、永続的なプロセスは使用できません。プログラムが操作できるのは、明示的に許可したツールのみです。 +生成されたプログラムは、新しいホスト型 V8 環境で実行されます。Node.js API、ファイルシステムやネットワークへのアクセス、永続プロセスは利用できません。プログラムが操作できるのは、明示的に許可したツールのみです。 ```python from pydantic import BaseModel @@ -165,24 +165,24 @@ result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it. print(result.final_output) ``` -留意事項: - -- プログラムによるツール呼び出しは、サポート対象の OpenAI Responses モデルでのみ利用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 -- エージェントには `ProgrammaticToolCallingTool()` を最大 1 つ追加できます。また、エージェントはプログラムから呼び出し可能なツールを少なくとも 1 つ、名前空間、遅延関数、遅延されたホスト型 MCP サーバーに基づく `ToolSearchTool()`、または不透明なプロンプト管理ツールサーフェスを公開する必要があります。検索可能なサーフェスを伴わない単独の `ToolSearchTool()` は拒否されます。 -- `allowed_callers` は、ツールの呼び出し方法を制御します。省略すると、モデルからの直接呼び出しのみが許可されます。プログラム専用アクセスには `["programmatic"]`、両方を許可するには `["direct", "programmatic"]` を使用してください。 -- オプトインできる SDK ツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数、カスタム、シェル、パッチ適用の各ツールでは、`allowed_callers` を直接公開します。ホスト型 MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定してください。 -- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、dataclass などの構造化された戻り値アノテーションが、自動的に厳密なオブジェクト出力スキーマとなり、値がプログラムに返される前に検証されます。関数に使用可能なアノテーションがない場合は `output_type=...` を使用し、厳密なオブジェクトスキーマがすでにある場合は、より低レベルのエスケープハッチである `output_json_schema={...}` を使用してください。`output_type` と `output_json_schema` は相互排他的です。単純な `str`、`Any`、`None` の戻り値は型なしのままです。スキーマに基づくプログラム所有の呼び出しでは、自由形式のテキストが出力スキーマを満たさないため、デフォルトの失敗フォーマッターが無効になります。そのため、スキーマに準拠する JSON を返すカスタム `failure_error_function` を指定しない限り、ハンドラーの例外が伝播します。 -- プログラム所有の SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入力および出力ガードレール、フック、タイムアウト、同時実行数制限、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 -- `ProgrammaticToolCallingTool()` が存在する場合、プログラムが実行される前であっても、モデルリクエストの再試行にはより厳格なリプレイ安全性の境界が使用されます。SDK は、これらのリクエストに対するプロバイダー管理の再試行と WebSocket のイベント前再試行を無効にします。Runner の再試行ポリシーは、プロバイダーからの指示によってリプレイが安全であると明示的に示された場合にのみ再試行します。`retry_policies.network_error()` だけでは、この境界を上書きしません。 -- 承認が重要なツールや影響の大きいツールは、通常、直接呼び出しのままにすることを推奨します。これにより、大規模なプログラムの一部になる前に、各アクションを人が確認できます。プログラム所有の呼び出しが承認のために一時停止した場合は、通常どおり `RunState` を介して中断を解決し、元の実行を再開してください。 -- プログラムによるツール呼び出しは、[ホスト型ツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムが遅延ツールを呼び出す前に、モデルがそのツールを読み込む必要があります。 -- `program` 項目と、プログラムが所有する通常の子ツール呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。一方、ホスト型 MCP の承認リクエストとツールカタログでは、専用の MCP 項目およびストリームイベントが使用されます。確認方法の詳細については、[実行結果](results.md#new-items)および[ストリーミング](streaming.md#run-item-event-names)を参照してください。 -- 完全な並行在庫計画のコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 -- 公式プラットフォームガイド: [プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 +留意事項: + +- プログラムによるツール呼び出しは、対応する OpenAI Responses モデルでのみ使用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 +- エージェントに追加できる `ProgrammaticToolCallingTool()` は最大 1 つです。また、エージェントは、プログラムから呼び出し可能なツールを少なくとも 1 つ、名前空間、遅延関数、遅延されたホスト型 MCP サーバーを基盤とする `ToolSearchTool()`、またはプロンプトで管理される不透明なツール群のいずれかを公開する必要があります。検索可能な対象がない単独の `ToolSearchTool()` は拒否されます。 +- `allowed_callers` は、ツールをどのように呼び出せるかを制御します。省略した場合、モデルからの直接呼び出しのみが許可されます。プログラムからのみアクセス可能にするには `["programmatic"]` を使用し、両方を許可するには `["direct", "programmatic"]` を使用します。 +- オプトインできる SDK ツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数、カスタム、シェル、パッチ適用ツールでは、`allowed_callers` を直接公開します。ホスト型 MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定します。 +- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、dataclass などの構造化された戻り値アノテーションが、自動的に厳密なオブジェクト出力スキーマになり、値がプログラムに返される前に検証されます。関数に使用可能なアノテーションがない場合は `output_type=...` を使用します。厳密なオブジェクトスキーマがすでにある場合は、低水準のエスケープハッチである `output_json_schema={...}` を使用します。`output_type` と `output_json_schema` は同時に指定できません。単純な `str`、`Any`、`None` の戻り値には型が付きません。スキーマに基づくプログラム所有の呼び出しでは、自由形式のテキストが出力スキーマを満たさないため、デフォルトの失敗フォーマッターは無効になります。そのため、スキーマに準拠する JSON を返すカスタム `failure_error_function` を指定しない限り、ハンドラーの例外は伝播します。 +- プログラム所有の SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入出力ガードレール、フック、タイムアウト、同時実行制限、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 +- `ProgrammaticToolCallingTool()` が存在する場合、プログラムが実行される前であっても、モデルリクエストの再試行にはより厳格なリプレイ安全性の境界が適用されます。SDK は、これらのリクエストについて、プロバイダー管理の再試行と WebSocket のイベント前再試行を無効にします。Runner の再試行ポリシーは、プロバイダーの助言でリプレイが安全であると明示された場合にのみ再試行します。`retry_policies.network_error()` だけでは、この境界を上書きできません。 +- 承認が重要なツールや影響の大きいツールは通常、より大きなプログラムの一部になる前に各アクションを人が確認できるよう、直接呼び出しとして維持することを推奨します。プログラム所有の呼び出しが承認待ちで一時停止した場合は、通常どおり `RunState` を通じて中断を解決し、元の実行を再開します。 +- プログラムによるツール呼び出しは、[ホスト型ツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムから遅延ツールを呼び出すには、モデルが先にそのツールを読み込む必要があります。 +- `program` 項目と、その通常のプログラム所有の子ツール呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。一方、ホスト型 MCP の承認リクエストとツールカタログでは、専用の MCP 項目とストリームイベントが使用されます。確認方法の詳細については、[実行結果](results.md#new-items)と[ストリーミング](streaming.md#run-item-event-names)を参照してください。 +- 並行処理を行う在庫計画の完全なコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 +- 公式プラットフォームガイド:[プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 ### ホスト型コンテナシェルとスキル -`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートします。ローカルランタイムではなく、管理されたコンテナ内でモデルにシェルコマンドを実行させる場合に、このモードを使用してください。 +`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートします。ローカルランタイムではなく、管理されたコンテナでモデルにシェルコマンドを実行させたい場合は、このモードを使用します。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -215,52 +215,54 @@ result = await Runner.run( print(result.final_output) ``` -後続の実行で既存のコンテナを再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 +既存のコンテナを後続の実行で再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 -留意事項: +留意事項: - ホスト型シェルは、Responses API のシェルツールを通じて利用できます。 - `container_auto` はリクエスト用のコンテナをプロビジョニングし、`container_reference` は既存のコンテナを再利用します。 - `container_auto` には、`file_ids` と `memory_limit` も含められます。 -- `environment.skills` は、スキル参照とインラインスキルバンドルを受け入れます。 +- `environment.skills` は、スキル参照とインラインスキルバンドルを受け付けます。 - ホスト型環境では、`ShellTool` に `executor`、`needs_approval`、`on_approval` を設定しないでください。 - `network_policy` は、`disabled` モードと `allowlist` モードをサポートします。 -- 許可リストモードでは、`network_policy.domain_secrets` によって、名前を指定してドメインスコープのシークレットを挿入できます。 +- allowlist モードでは、`network_policy.domain_secrets` により、ドメイン単位のシークレットを名前で注入できます。 - 完全なコード例については、`examples/tools/container_shell_skill_reference.py` と `examples/tools/container_shell_inline_skill.py` を参照してください。 -- OpenAI プラットフォームガイド: [シェル](https://platform.openai.com/docs/guides/tools-shell)および[スキル](https://platform.openai.com/docs/guides/tools-skills)。 +- OpenAI プラットフォームガイド:[シェル](https://platform.openai.com/docs/guides/tools-shell)および[スキル](https://platform.openai.com/docs/guides/tools-skills)。 ## ローカルランタイムツール -ローカルランタイムツールは、モデルレスポンス自体の外部で実行されます。モデルは引き続き呼び出すタイミングを決定しますが、実際の処理はアプリケーションまたは設定済みの実行環境が行います。 +ローカルランタイムツールは、モデルレスポンス自体の外部で実行されます。呼び出すタイミングは引き続きモデルが決定しますが、実際の処理はアプリケーションまたは設定済みの実行環境が行います。 -`ComputerTool` と `ApplyPatchTool` には、常にご自身で用意したローカル実装が必要です。`ShellTool` は両方のモードに対応します。管理された実行が必要な場合は上記のホスト型コンテナ設定を使用し、独自のプロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用してください。 +`ComputerTool` と `ApplyPatchTool` には、常に利用者が提供するローカル実装が必要です。`ShellTool` は両方のモードに対応しています。管理された実行が必要な場合は前述のホスト型コンテナ設定を使用し、独自のプロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用します。 -ローカルランタイムツールには、実装を用意する必要があります。 +ローカルランタイムツールでは、次の実装を提供する必要があります。 -- [`ComputerTool`][agents.tool.ComputerTool]: GUI/ブラウザーの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 -- [`ShellTool`][agents.tool.ShellTool]: ローカル実行とホスト型コンテナ実行の両方に対応する最新のシェルツールです。 -- [`LocalShellTool`][agents.tool.LocalShellTool]: 従来のローカルシェル統合です。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 差分をローカルに適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 +- [`ComputerTool`][agents.tool.ComputerTool]:GUI/ブラウザーの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 +- [`ShellTool`][agents.tool.ShellTool]:ローカル実行とホスト型コンテナ実行の両方に対応する最新のシェルツールです。 +- [`LocalShellTool`][agents.tool.LocalShellTool]:従来のローカルシェル統合です。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:差分をローカルで適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 - ローカルシェルスキルは、`ShellTool(environment={"type": "local", "skills": [...]})` で利用できます。 -### `ComputerTool` と Responses コンピュータツール +シェルアクションのタイムアウトでは、有限のタイムアウトとして正の整数のミリ秒値を使用します。ゼロは executor 実装間で共通の意味を持たないため、ローカルの `ShellTool` executor を呼び出す前に、SDK は `0` と `None` の両方を明示的なタイムアウトなしとして扱います。それ以外の値は、executor の呼び出し前に拒否されます。これはタイムアウトフィールドに固有の動作です。`max_output_length=0` は、取得する出力を空にするリクエストとして引き続きサポートされます。 -`ComputerTool` は引き続きローカルハーネスです。[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を用意すると、SDK がそのハーネスを OpenAI Responses API のコンピュータサーフェスにマッピングします。 +### ComputerTool と Responses のコンピュータツール -明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストの場合、SDK は GA 組み込みツールのペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルでは、プレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` が引き続き使用されます。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)に記載されているプラットフォーム移行を反映しています。 +`ComputerTool` は引き続きローカルハーネスです。利用者が [`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供すると、SDK がそのハーネスを OpenAI Responses API のコンピュータ機能にマッピングします。 -- モデル: `computer-use-preview` -> `gpt-5.5` -- ツールセレクター: `computer_use_preview` -> `computer` -- コンピュータ呼び出し形式: `computer_call` ごとに 1 つの `action` -> `computer_call` 上の一括 `actions[]` -- 切り詰め: プレビューパスでは `ModelSettings(truncation="auto")` が必要 -> GA パスでは不要 +明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストの場合、SDK は GA の組み込みツールペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルでは、プレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` が引き続き使用されます。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)に記載されているプラットフォーム移行を反映しています。 -SDK は、実際の Responses リクエストで有効なモデルに基づいてワイヤー形式を選択します。プロンプトテンプレートを使用し、プロンプト側でモデルを保持しているためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 +- モデル:`computer-use-preview` -> `gpt-5.5` +- ツールセレクター:`computer_use_preview` -> `computer` +- コンピュータ呼び出しの形式:`computer_call` ごとに 1 つの `action` -> `computer_call` 上の一括 `actions[]` +- 切り詰め:プレビューパスでは `ModelSettings(truncation="auto")` が必須 -> GA パスでは不要 -[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け入れられ、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` がない場合、これらの文字列は引き続き通常の関数名として動作します。 +SDK は、実際の Responses リクエストで有効なモデルに基づいて、そのワイヤー形式を選択します。プロンプトテンプレートを使用し、モデルがプロンプト側で指定されているためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示的に維持するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 -この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーに基づく場合に重要です。GA の `computer` ペイロードでは、シリアライズ時に `environment` や寸法は不要なため、未解決のファクトリーでも問題ありません。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるように、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 +[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに対応する組み込みセレクターへ正規化されます。`ComputerTool` がない場合、これらの文字列は引き続き通常の関数名として動作します。 -ランタイムでは、両方のパスで同じローカルハーネスが引き続き使用されます。プレビューレスポンスは単一の `action` を含む `computer_call` 項目を生成します。`gpt-5.5` は一括の `actions[]` を生成でき、SDK は `computer_call_output` スクリーンショット項目を生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 +この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを基盤としている場合に重要です。GA の `computer` ペイロードでは、シリアライズ時に `environment` や寸法が不要なため、未解決のファクトリーでも問題ありません。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるよう、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 + +ランタイムでは、どちらのパスも同じローカルハーネスを使用します。プレビューレスポンスは、単一の `action` を持つ `computer_call` 項目を生成します。`gpt-5.5` は一括 `actions[]` を生成でき、SDK は `computer_call_output` のスクリーンショット項目を生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -311,11 +313,11 @@ agent = Agent( - 関数入力のスキーマは、関数の引数から自動的に作成されます - 無効にしない限り、各入力の説明は関数の docstring から取得されます -`@tool` で作成されたツールは、読み取り専用の `__wrapped__` 属性を通じて元の Python 呼び出し可能オブジェクトを公開します。これは検査やテストに便利ですが、直接呼び出すと、スキーマ検証、コンテキスト挿入、ガードレール、タイムアウト、失敗処理、トレーシングを含むツールランタイムパイプラインが回避されます。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 +`@tool` で作成されたツールは、読み取り専用の `__wrapped__` 属性を通じて、元の Python callable を公開します。これは調査やテストに役立ちますが、直接呼び出すと、スキーマ検証、コンテキスト注入、ガードレール、タイムアウト、失敗処理、トレーシングを含むツールランタイムパイプラインを迂回します。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 -Python の `inspect` モジュールを使用して関数シグネチャを抽出し、さらに [`griffe`](https://mkdocstrings.github.io/griffe/) で docstring を解析し、`pydantic` でスキーマを作成します。 +Python の `inspect` モジュールを使用して関数シグネチャを抽出し、[`griffe`](https://mkdocstrings.github.io/griffe/) で docstring を解析し、`pydantic` でスキーマを作成します。 -OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は `ToolSearchTool()` が読み込むまで関数ツールを非表示にします。[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 +OpenAI Responses モデルを使用する場合、`@function_tool(defer_loading=True)` は、`ToolSearchTool()` が読み込むまで関数ツールを非表示にします。また、[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 ```python import json @@ -368,12 +370,12 @@ for tool in agent.tools: ``` -1. 任意の Python 型を関数の引数として使用でき、関数は同期または非同期にできます。 -2. docstring が存在する場合、説明と引数の説明の取得に使用されます。 -3. 関数はオプションで `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring スタイルなどのオーバーライドも設定できます。 -4. デコレートされた関数をツールのリストに渡せます。 +1. 関数の引数には任意の Python 型を使用でき、関数は同期または非同期にできます。 +2. docstring が存在する場合は、説明と引数の説明を取得するために使用されます。 +3. 関数は任意で `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring スタイルなどのオーバーライドも設定できます。 +4. デコレーターを適用した関数をツールのリストに渡せます。 -??? note "出力の表示" +??? note "出力を表示するには展開してください" ``` fetch_weather @@ -445,11 +447,11 @@ for tool in agent.tools: ### 関数ツールからの画像またはファイルの返却 -テキスト出力に加えて、1 つまたは複数の画像やファイルを関数ツールの出力として返せます。そのためには、次のいずれかを返します。 +テキスト出力に加えて、1 つ以上の画像やファイルを関数ツールの出力として返せます。そのためには、次のいずれかを返します。 -- 画像: [`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) -- ファイル: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- テキスト: 文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- 画像:[`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) +- ファイル:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) +- テキスト:文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### カスタム関数ツール @@ -457,8 +459,8 @@ Python 関数をツールとして使用したくない場合もあります。 - `name` - `description` -- 引数の JSON スキーマである `params_json_schema` -- [`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(たとえば、テキスト、構造化ツール出力オブジェクト、出力のリスト)を返す非同期関数である `on_invoke_tool` +- `params_json_schema`:引数の JSON スキーマ +- `on_invoke_tool`:[`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数。 ```python from typing import Any @@ -493,10 +495,10 @@ tool = FunctionTool( ### 引数と docstring の自動解析 -前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと各引数の説明を抽出します。留意事項は次のとおりです。 +前述のように、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと個々の引数の説明を抽出します。留意点は次のとおりです。 -1. シグネチャの解析は、`inspect` モジュールを使用して行われます。型アノテーションを使用して引数の型を把握し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートしています。 -2. docstring の解析には `griffe` を使用します。サポートされる docstring 形式は、`google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートであり、`function_tool` を呼び出す際に明示的に設定できます。`use_docstring_info` を `False` に設定して、docstring の解析を無効にすることもできます。Google スタイルの docstring では、要約テキストの直後に空行を挟まずに配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーが受け入れます。 +1. シグネチャの解析は `inspect` モジュールを使用して行われます。型アノテーションを使用して引数の型を把握し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートします。 +2. docstring の解析には `griffe` を使用します。サポートされる docstring 形式は、`google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートです。`function_tool` を呼び出す際に明示的に設定することもできます。また、`use_docstring_info` を `False` に設定すると、docstring の解析を無効にできます。Google スタイルの docstring では、要約テキストの直後に空行を挟まず配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーが受け付けます。 スキーマ抽出のコードは、[`agents.function_schema`][] にあります。 @@ -543,13 +545,13 @@ agent = Agent( ) ``` -タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` であり、モデルから確認できるタイムアウトメッセージ(たとえば、`Tool 'slow_lookup' timed out after 2 seconds.`)を送信します。 +タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから確認できるタイムアウトメッセージ(例:`Tool 'slow_lookup' timed out after 2 seconds.`)が送信されます。 タイムアウト処理は次のように制御できます。 -- `timeout_behavior="error_as_result"`(デフォルト): モデルが復旧できるように、タイムアウトメッセージを返します。 -- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を送出し、実行を失敗させます。 -- `timeout_error_function=...`: `error_as_result` を使用する場合に、タイムアウトメッセージをカスタマイズします。 +- `timeout_behavior="error_as_result"`(デフォルト):モデルが回復できるように、タイムアウトメッセージをモデルへ返します。 +- `timeout_behavior="raise_exception"`:[`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 +- `timeout_error_function=...`:`error_as_result` を使用する場合のタイムアウトメッセージをカスタマイズします。 ```python import asyncio @@ -579,9 +581,9 @@ except ToolTimeoutError as e: `@function_tool` を使用して関数ツールを作成する場合、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 -- デフォルトでは(何も渡さない場合)、`default_tool_error_function` が実行され、エラーが発生したことを LLM に通知します。 +- デフォルトでは(つまり何も渡さない場合)、エラーが発生したことを LLM に通知する `default_tool_error_function` が実行されます。 - 独自のエラー関数を渡した場合は、代わりにその関数が実行され、レスポンスが LLM に送信されます。 -- `None` を明示的に渡した場合、ツール呼び出しのエラーは再送出され、ご自身で処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` などになる可能性があります。 +- 明示的に `None` を渡すと、ツール呼び出しのエラーが再送出され、利用者側で処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` になるなど、状況によって異なります。 ```python from agents import RunContextWrapper @@ -609,7 +611,7 @@ def get_user_profile(user_id: str) -> str: ## Agents as tools -一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントで専門エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 +一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントから特化したエージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 ```python import asyncio @@ -655,9 +657,9 @@ if __name__ == "__main__": ### ツールエージェントのカスタマイズ -`agent.as_tool` 関数は、エージェントを簡単にツールへ変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` などの一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` を使用した構造化入力もサポートします。 +`agent.as_tool` 関数は、エージェントを簡単にツールへ変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` を使用した構造化入力もサポートします。 -状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は自動的には継承されません。クライアント管理の履歴を親実行とネストされた実行の間で共有するには、同じ `session` を両方に明示的に渡してください。`Runner.run` と同様に、ネストされた実行には 1 つの状態戦略を選択します。クライアント管理の `session`、または `previous_response_id` もしくは `conversation_id` を使用したサーバー管理の継続です。 +状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親の実行の会話状態は自動的には継承されません。クライアント管理の履歴を親とネストされた実行との間で共有するには、両方に同じ `session` を明示的に渡します。`Runner.run` と同様に、ネストされた実行では、クライアント管理の `session`、または `previous_response_id` か `conversation_id` を介したサーバー管理の継続のいずれか 1 つの状態戦略を選択してください。 ```python from agents.decorators import tool @@ -683,7 +685,7 @@ async def run_my_agent() -> str: デフォルトでは、`Agent.as_tool()` は単一の文字列入力(`{"input": "..."}`)を想定しますが、`parameters`(Pydantic モデルまたは dataclass 型)を渡すことで構造化スキーマを公開できます。 -追加オプション: +追加オプション: - `include_input_schema=True` を指定すると、生成されるネストされた入力に完全な JSON Schema が含まれます。 - `input_builder=...` を使用すると、構造化されたツール引数をネストされたエージェント入力へ変換する方法を完全にカスタマイズできます。 @@ -707,19 +709,19 @@ translator_tool = translator_agent.as_tool( ) ``` -実行可能な完全なコード例については、`examples/agent_patterns/agents_as_tools_structured.py` を参照してください。 +完全に実行可能なコード例については、`examples/agent_patterns/agents_as_tools_structured.py` を参照してください。 ### ツールエージェントの承認ゲート -`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行が一時停止し、保留中の項目が `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開します。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行は一時停止し、保留中の項目が `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開します。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 -### カスタム出力の抽出 +### カスタム出力抽出 -場合によっては、中央のエージェントに返す前に、ツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 +場合によっては、ツールエージェントの出力を中央のエージェントへ返す前に変更したいことがあります。これは、次のような場合に役立ちます。 - サブエージェントのチャット履歴から特定の情報(JSON ペイロードなど)を抽出する。 -- エージェントの最終回答を変換または再フォーマットする(Markdown をプレーンテキストまたは CSV に変換するなど)。 -- 出力を検証するか、エージェントのレスポンスが欠落している場合や形式が不正な場合にフォールバック値を提供する。 +- エージェントの最終回答を変換または再フォーマットする(Markdown をプレーンテキストや CSV に変換するなど)。 +- 出力を検証する、またはエージェントのレスポンスが欠落している場合や形式が不正な場合にフォールバック値を提供する。 これを行うには、`as_tool` メソッドに `custom_output_extractor` 引数を指定します。 @@ -740,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -カスタム抽出関数内では、ネストされた [`RunResult`][agents.result.RunResult] によって [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] も公開されます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、raw 引数が必要な場合に便利です。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 +カスタム抽出関数内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、または raw 引数が必要な場合に役立ちます。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 ### ネストされたエージェント実行のストリーミング -`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが生成するストリーミングイベントを受信しながら、ストリーム完了後に最終出力を返せます。 +`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが生成するストリーミングイベントを受け取りながら、ストリーム完了後に最終出力を返せます。 ```python from agents import AgentToolStreamEvent @@ -762,15 +764,15 @@ billing_agent_tool = billing_agent.as_tool( ) ``` -想定される動作: +想定される動作: -- イベントタイプは `StreamEvent["type"]` を反映します。`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event` です。 +- イベントタイプは `StreamEvent["type"]` を反映します:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 - `on_stream` を指定すると、ネストされたエージェントが自動的にストリーミングモードで実行され、最終出力を返す前にストリームが最後まで処理されます。 - ハンドラーは同期または非同期にできます。各イベントは到着順に配信されます。 -- モデルのツール呼び出しによってツールが呼び出された場合、`tool_call` が存在します。直接呼び出しでは `None` の場合があります。 -- 実行可能な完全なサンプルについては、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 +- モデルのツール呼び出しを介してツールが呼び出された場合は `tool_call` が存在します。直接呼び出しの場合は `None` になることがあります。 +- 完全に実行可能なコード例については、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 -### 条件付きツール有効化 +### 条件付きのツール有効化 `is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的に絞り込めます。 @@ -827,24 +829,24 @@ async def main(): asyncio.run(main()) ``` -`is_enabled` パラメーターは、次を受け入れます。 +`is_enabled` パラメーターは次を受け付けます。 -- **ブール値**: `True`(常に有効)または `False`(常に無効) -- **呼び出し可能な関数**: `(context, agent)` を受け取り、ブール値を返す関数 -- **非同期関数**: 複雑な条件ロジックに使用する非同期関数 +- **ブール値**:`True`(常に有効)または `False`(常に無効) +- **呼び出し可能な関数**:`(context, agent)` を受け取り、ブール値を返す関数 +- **非同期関数**:複雑な条件ロジックのための非同期関数 -無効化されたツールはランタイムで LLM から完全に非表示になるため、次の用途に便利です。 +無効化されたツールはランタイムで LLM から完全に非表示になるため、次の用途に役立ちます。 -- ユーザー権限に基づく機能制限 +- ユーザー権限に基づく機能ゲーティング - 環境固有のツール可用性(開発環境と本番環境) - 異なるツール設定の A/B テスト - ランタイム状態に基づく動的なツールフィルタリング -## 試験的機能: Codex ツール +## 実験的機能:Codex ツール -`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペーススコープのタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。このサーフェスは試験的機能であり、変更される可能性があります。 +`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペース単位のタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。この機能は実験的であり、今後変更される可能性があります。 -メインエージェントが現在の実行を離れることなく、範囲の限定されたワークスペースタスクを Codex に委任する場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合は、`codex` または `codex_` で始まる名前にする必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 +メインエージェントが現在の実行を離れることなく、範囲の明確なワークスペースタスクを Codex に委任する場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合、`codex` または `codex_` で始まる名前にする必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 ```python from agents import Agent @@ -873,33 +875,33 @@ agent = Agent( ) ``` -最初に、次のオプショングループを確認してください。 +まず、次のオプショングループを確認してください。 -- 実行サーフェス: `sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定してください。 -- スレッドのデフォルト: `default_thread_options=ThreadOptions(...)` は、モデル、推論の労力、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` の使用を推奨します。 -- ターンのデフォルト: `default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` やオプションのキャンセル用 `signal` など、ターン単位の動作を設定します。 -- ツール I/O: ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` 項目を少なくとも 1 つ含める必要があります。`output_schema` を使用すると、Codex に構造化されたレスポンスを要求できます。 +- 実行対象:`sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定します。 +- スレッドのデフォルト:`default_thread_options=ThreadOptions(...)` は、モデル、推論エフォート、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 +- ターンのデフォルト:`default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` や任意のキャンセル用 `signal` など、ターン単位の動作を設定します。 +- ツール I/O:ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` 項目を少なくとも 1 つ含める必要があります。`output_schema` を使用すると、Codex の構造化されたレスポンスを必須にできます。 -スレッドの再利用と永続化は、個別の制御項目です。 +スレッドの再利用と永続化は別々に制御されます。 -- `persist_session=True` は、同じツールインスタンスへの繰り返し呼び出しで 1 つの Codex スレッドを再利用します。 -- `use_run_context_thread_id=True` は、同じ可変コンテキストオブジェクトを共有する複数の実行にわたって、実行コンテキスト内にスレッド ID を保存して再利用します。 +- `persist_session=True` は、同じツールインスタンスへの反復呼び出しで 1 つの Codex スレッドを再利用します。 +- `use_run_context_thread_id=True` は、同じ変更可能なコンテキストオブジェクトを共有する複数の実行にわたり、実行コンテキスト内でスレッド ID を保存して再利用します。 - スレッド ID の優先順位は、呼び出し単位の `thread_id`、実行コンテキストのスレッド ID(有効な場合)、設定済みの `thread_id` オプションの順です。 -- デフォルトの実行コンテキストキーは、`name="codex"` の場合は `codex_thread_id`、`name="codex_"` の場合は `codex_thread_id_` です。`run_context_thread_id_key` で上書きできます。 +- デフォルトの実行コンテキストキーは、`name="codex"` の場合は `codex_thread_id`、`name="codex_"` の場合は `codex_thread_id_` です。`run_context_thread_id_key` を使用して上書きできます。 -ランタイム設定: +ランタイム設定: -- 認証: `CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 -- ランタイム: `codex_options.base_url` は CLI のベース URL を上書きします。 -- バイナリーの解決: CLI パスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、見つからなければ同梱のベンダーバイナリーを使用します。 -- 環境: `codex_options.env` は、サブプロセス環境を完全に制御します。これを指定した場合、サブプロセスは `os.environ` を継承しません。 -- ストリーム制限: `codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの制限を制御します。有効範囲は `65536` から `67108864` で、デフォルトは `8388608` です。 -- ストリーミング: `on_stream` は、スレッド/ターンのライフサイクルイベントと項目イベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` の項目更新)を受け取ります。 -- 出力: 実行結果には `response`、`usage`、`thread_id` が含まれます。使用量は `RunContextWrapper.usage` に追加されます。 +- 認証:`CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 +- ランタイム:`codex_options.base_url` は CLI のベース URL を上書きします。 +- バイナリの解決:CLI のパスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、見つからなければ同梱のベンダーバイナリを使用します。 +- 環境:`codex_options.env` はサブプロセス環境を完全に制御します。指定した場合、サブプロセスは `os.environ` を継承しません。 +- ストリーム制限:`codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの上限を制御します。有効範囲は `65536`~`67108864` で、デフォルトは `8388608` です。 +- ストリーミング:`on_stream` は、スレッド/ターンのライフサイクルイベントと項目イベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` の項目更新)を受け取ります。 +- 出力:実行結果には `response`、`usage`、`thread_id` が含まれます。使用量は `RunContextWrapper.usage` に追加されます。 -リファレンス: +リファレンス: - [Codex ツール API リファレンス](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions リファレンス](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions リファレンス](ref/extensions/experimental/codex/turn_options.md) -- 実行可能な完全なサンプルについては、`examples/tools/codex.py` と `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file +- 完全に実行可能なコード例については、`examples/tools/codex.py` と `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index e14d338b5d..df237b48c4 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -4,75 +4,77 @@ search: --- # 가드레일 -가드레일을 사용하면 사용자 입력과 에이전트 출력에 대한 검사 및 검증을 수행할 수 있습니다. 예를 들어, 고객 요청을 돕기 위해 매우 똑똑한(따라서 느리고 비용이 많이 드는) 모델을 사용하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에게 수학 숙제를 도와달라고 요청하는 것은 원치 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적 사용을 감지하면 즉시 오류를 발생시켜 비용이 많이 드는 모델이 실행되지 않도록 하여 시간과 비용을 절약할 수 있습니다(**차단형 가드레일을 사용할 때입니다. 병렬 가드레일의 경우, 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 실행을 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요**). +가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 속도가 느리고 비용이 많이 드는 모델을 사용하여 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 상황은 원하지 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시키고 비용이 많이 드는 모델의 실행을 방지하여 시간과 비용을 절약할 수 있습니다(**차단형 가드레일을 사용하는 경우에 해당합니다. 병렬 가드레일의 경우 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 실행되기 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요**). -가드레일에는 두 가지 종류가 있습니다: +가드레일에는 두 가지 종류가 있습니다. -1. 입력 가드레일은 최초 사용자 입력에서 실행됩니다 -2. 출력 가드레일은 최종 에이전트 출력에서 실행됩니다 +1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다 +2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다 ## 워크플로 경계 -가드레일은 에이전트와 도구에 연결되지만, 워크플로의 모든 지점에서 실행되는 것은 아닙니다: +가드레일은 에이전트와 도구에 연결되지만, 워크플로의 모든 지점에서 실행되는 것은 아닙니다. -- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. -- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. -- **도구 가드레일**은 모든 사용자 지정 함수 도구 호출마다 실행되며, 실행 전에는 입력 가드레일이, 실행 후에는 출력 가드레일이 실행됩니다. +- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. +- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. +- **도구 가드레일**은 사용자 지정 함수 도구가 호출될 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. -매니저, 핸드오프 또는 위임된 전문 에이전트를 포함하는 워크플로에서 각 사용자 지정 함수 도구 호출 전후로 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. +관리자, 핸드오프 또는 작업을 위임받은 전문가가 포함된 워크플로에서 사용자 지정 함수 도구 호출마다 검사를 수행해야 한다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. ## 입력 가드레일 -입력 가드레일은 3단계로 실행됩니다: +입력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트에 전달된 것과 동일한 입력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이는 다시 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이 출력은 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 입력 가드레일은 사용자 입력에서 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *첫 번째* 에이전트인 경우에만 실행됩니다. 가드레일을 `Runner.run`에 전달하지 않고 에이전트의 `guardrails` 속성에 두는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하게 되므로, 코드를 한곳에 배치하는 것이 가독성에 유용합니다. + 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 그렇다면 왜 `guardrails` 속성을 `Runner.run`에 전달하지 않고 에이전트에 두는지 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. ### 실행 모드 -입력 가드레일은 두 가지 실행 모드를 지원합니다: +입력 가드레일은 두 가지 실행 모드를 지원합니다. -- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일은 에이전트 실행과 동시에 실행됩니다. 둘 다 같은 시점에 시작하므로 지연 시간이 가장 짧습니다. 그러나 가드레일이 실패하면, 취소되기 전에 에이전트가 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. +- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 동시에 시작되므로 지연 시간이 가장 짧습니다. 그러나 가드레일이 실패하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. -- **차단 실행**(`run_in_parallel=False`): 가드레일은 에이전트가 시작되기 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 전혀 실행되지 않아 토큰 소비와 도구 실행을 방지합니다. 이는 비용 최적화에 이상적이며 도구 호출의 잠재적 부작용을 피하고 싶을 때 적합합니다. +- **차단형 실행**(`run_in_parallel=False`): 가드레일이 에이전트 시작 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트가 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출에서 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. ## 출력 가드레일 -출력 가드레일은 3단계로 실행됩니다: +출력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트가 생성한 출력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이는 다시 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이 출력은 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 출력 가드레일은 최종 에이전트 출력에서 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *마지막* 에이전트인 경우에만 실행됩니다. 입력 가드레일과 마찬가지로, 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하게 되므로, 코드를 한곳에 배치하는 것이 가독성에 유용합니다. + 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로 이렇게 하는 이유는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. - 출력 가드레일은 에이전트가 완료된 후에 항상 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. + 출력 가드레일은 항상 에이전트 실행이 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. ## 도구 가드레일 -도구 가드레일은 **함수 도구**를 감싸며 실행 전후에 도구 호출을 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. +도구 가드레일은 **함수 도구**를 래핑하며, 실행 전후에 도구 호출을 검증하거나 차단할 수 있게 해 줍니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. -- 입력 도구 가드레일은 도구 실행 전에 실행되며 호출을 건너뛰거나, 출력을 메시지로 대체하거나, 트립와이어를 트리거할 수 있습니다. -- 출력 도구 가드레일은 도구 실행 후에 실행되며 출력을 대체하거나 트립와이어를 트리거할 수 있습니다. -- 함수 도구에 승인이 필요한 경우, 입력 도구 가드레일은 일반적으로 승인 후 실행 직전에 실행됩니다. 해당 입력 검사를 승인 대기 인터럽션(중단 처리)이 발생하기 전에 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 승인 전 검사를 통과한 호출도 승인 이후 도구가 실행되기 전에 다시 검사됩니다. -- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 통해 실행되므로, 도구 가드레일은 핸드오프 호출 자체에는 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 현재 도구 가드레일 옵션을 직접 노출하지 않습니다. +- 입력 도구 가드레일은 도구 실행 전에 실행되며 호출을 건너뛰거나, 출력을 메시지로 대체하거나, 트립와이어를 발생시킬 수 있습니다. +- 출력 도구 가드레일은 도구 실행 후에 실행되며 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. +- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후, 실행 직전에 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 승인 전 검사를 통과한 호출도 도구가 실행되기 전에 승인 후 다시 검사됩니다. +- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아닌 SDK의 핸드오프 파이프라인을 통해 실행되므로 도구 가드레일은 핸드오프 호출 자체에 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, 현재 [`Agent.as_tool()`][agents.agent.Agent.as_tool]도 도구 가드레일 옵션을 직접 노출하지 않습니다. 자세한 내용은 아래 코드 스니펫을 참조하세요. ## 트립와이어 -입력 또는 출력이 가드레일 검사를 통과하지 못하면, 가드레일은 이를 트립와이어로 신호할 수 있습니다. 트립와이어를 트리거한 가드레일이 확인되는 즉시, `{Input,Output}GuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. +입력 또는 출력이 가드레일을 통과하지 못하면 가드레일이 트립와이어로 이를 알릴 수 있습니다. 트립와이어를 트리거한 가드레일이 감지되는 즉시 `{Input,Output}GuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. + +예외의 `guardrail_result`는 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 발생시킨 입력 트립와이어의 경우 `exception.run_data.input_guardrail_results`에는 실행이 중지되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 여기에는 트립와이어를 트리거한 결과도 포함됩니다. 스트리밍 결과는 `stream_events()`가 예외를 발생시킨 후 `input_guardrail_results`를 통해 누적된 동일한 결과를 제공합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생한 경우 `run_data`는 `None`일 수 있습니다. ## 가드레일 구현 -입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행해 이를 수행합니다. +입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행하여 이를 구현합니다. ```python from pydantic import BaseModel @@ -125,12 +127,12 @@ async def main(): print("Math homework guardrail tripped") ``` -1. 이 에이전트를 가드레일 함수에서 사용합니다. -2. 이것은 에이전트의 입력/컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. +1. 가드레일 함수에서 이 에이전트를 사용합니다. +2. 에이전트의 입력/컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. 3. 가드레일 결과에 추가 정보를 포함할 수 있습니다. -4. 이것은 워크플로를 정의하는 실제 에이전트입니다. +4. 워크플로를 정의하는 실제 에이전트입니다. -출력 가드레일도 유사합니다. +출력 가드레일도 이와 유사합니다. ```python from pydantic import BaseModel @@ -183,12 +185,12 @@ async def main(): print("Math output guardrail tripped") ``` -1. 이것은 실제 에이전트의 출력 타입입니다. -2. 이것은 가드레일의 출력 타입입니다. -3. 이것은 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. -4. 이것은 워크플로를 정의하는 실제 에이전트입니다. +1. 실제 에이전트의 출력 유형입니다. +2. 가드레일의 출력 유형입니다. +3. 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. +4. 워크플로를 정의하는 실제 에이전트입니다. -마지막으로, 다음은 도구 가드레일의 코드 예제입니다. +마지막으로 도구 가드레일의 예제입니다. ```python import json diff --git a/docs/ko/index.md b/docs/ko/index.md index 66f947fd0b..ab8d3d3aec 100644 --- a/docs/ko/index.md +++ b/docs/ko/index.md @@ -4,51 +4,52 @@ search: --- # OpenAI Agents SDK -[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)를 사용하면 매우 적은 추상화만으로 가볍고 사용하기 쉬운 패키지에서 에이전트형 AI 앱을 구축할 수 있습니다. 이는 이전 에이전트 실험 프로젝트인 [Swarm](https://github.com/openai/swarm/tree/main)을 프로덕션에 바로 사용할 수 있도록 업그레이드한 것입니다. Agents SDK는 매우 작은 기본 구성 요소 집합을 갖습니다. +[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)를 사용하면 최소한의 추상화만 제공하는 가볍고 사용하기 쉬운 패키지로 에이전트형 AI 앱을 구축할 수 있습니다. 이전 에이전트 실험 프로젝트인 [Swarm](https://github.com/openai/swarm/tree/main)을 프로덕션 환경에서 사용할 수 있도록 개선한 버전입니다. Agents SDK에는 매우 적은 수의 기본 구성 요소가 있습니다: -- **에이전트**: instructions와 tools를 갖춘 LLM -- **Agents as tools / 핸드오프**: 에이전트가 특정 작업을 다른 에이전트에 위임할 수 있게 하는 기능 -- **가드레일**: 에이전트 입력과 출력을 검증할 수 있게 하는 기능 +- **에이전트**: 지침과 도구를 갖춘 LLM +- **Agents as tools / 핸드오프**: 에이전트가 특정 작업을 다른 에이전트에게 위임할 수 있도록 하는 기능 +- **가드레일**: 에이전트 입력과 출력의 검증을 지원하는 기능 -Python과 결합하면 이러한 기본 구성 요소만으로도 도구와 에이전트 간의 복잡한 관계를 표현하기에 충분히 강력하며, 가파른 학습 곡선 없이 실제 애플리케이션을 구축할 수 있습니다. 또한 SDK에는 에이전트형 흐름을 시각화하고 디버깅하며, 이를 평가하고 애플리케이션에 맞게 모델을 파인튜닝할 수 있는 내장 **트레이싱** 기능이 포함되어 있습니다. +이러한 기본 구성 요소는 Python과 함께 사용하면 도구와 에이전트 간의 복잡한 관계를 표현하기에 충분히 강력하며, 가파른 학습 곡선 없이 실제 애플리케이션을 구축할 수 있게 합니다. 또한 SDK에는 에이전트형 흐름을 시각화하고 디버깅할 수 있는 내장 **트레이싱** 기능이 포함되어 있으며, 이를 통해 흐름을 평가하고 애플리케이션에 맞게 모델을 미세 조정할 수도 있습니다. ## Agents SDK를 사용하는 이유 -SDK에는 두 가지 핵심 설계 원칙이 있습니다. +SDK는 다음 두 가지 설계 원칙을 따릅니다: -1. 사용할 가치가 있을 만큼 충분한 기능을 제공하되, 빠르게 배울 수 있을 만큼 기본 구성 요소는 적게 유지합니다. -2. 기본 설정만으로도 잘 작동하지만, 어떤 일이 일어나는지는 정확하게 사용자 지정할 수 있습니다. +1. 사용할 가치가 있을 만큼 충분한 기능을 제공하면서도 빠르게 학습할 수 있도록 기본 구성 요소를 최소화합니다. +2. 별도의 설정 없이도 원활하게 작동하지만, 동작을 원하는 대로 세밀하게 사용자 지정할 수 있습니다. -SDK의 주요 기능은 다음과 같습니다. +SDK의 주요 기능은 다음과 같습니다: -- **에이전트 루프**: 도구 호출을 처리하고, 결과를 LLM에 다시 보내며, 작업이 완료될 때까지 계속 실행하는 내장 에이전트 루프 -- **파이썬 우선**: 새로운 추상화를 배울 필요 없이, 내장 언어 기능을 사용해 에이전트를 오케스트레이션하고 체인으로 연결 -- **Agents as tools / 핸드오프**: 여러 에이전트 간 작업을 조율하고 위임하기 위한 강력한 메커니즘 -- **샌드박스 에이전트**: 매니페스트로 정의된 파일, 샌드박스 클라이언트 선택, 재개 가능한 샌드박스 세션을 통해 실제 격리된 워크스페이스 안에서 전문가 실행 -- **가드레일**: 에이전트 실행과 병렬로 입력 검증 및 안전성 검사를 실행하고, 검사를 통과하지 못하면 빠르게 실패 처리 -- **함수 도구**: 자동 스키마 생성 및 Pydantic 기반 검증을 통해 모든 Python 함수를 도구로 변환 -- **MCP 서버 도구 호출**: 함수 도구와 동일한 방식으로 작동하는 내장 MCP 서버 도구 통합 -- **세션**: 에이전트 루프 내에서 작업 컨텍스트를 유지하기 위한 영속 메모리 계층 -- **휴먼인더루프 (HITL)**: 에이전트 실행 전반에 사람을 참여시키기 위한 내장 메커니즘 -- **트레이싱**: OpenAI의 평가, 파인튜닝, 증류 도구 모음 지원과 함께 워크플로를 시각화, 디버깅, 모니터링하기 위한 내장 트레이싱 -- **실시간 에이전트**: `gpt-realtime-2.1`, 자동 인터럽션(중단 처리) 감지, 컨텍스트 관리, 가드레일 등을 활용해 강력한 음성 에이전트 구축 +- **에이전트**: instructions, 도구, 가드레일, 핸드오프와 작업이 완료될 때까지 계속되는 내장 루프를 갖춘 에이전트를 구축합니다. +- **샌드박스 에이전트**: 매니페스트에 정의된 파일, 샌드박스 클라이언트 선택 기능, 재개 가능한 샌드박스 세션을 갖춘 실제 격리 작업 공간에서 전문 에이전트를 실행합니다. +- **실시간 에이전트**: `gpt-realtime-2.1`, 자동 인터럽션(중단 처리) 감지, 컨텍스트 관리, 가드레일 등을 활용해 강력한 음성 에이전트를 구축합니다. +- **음성 에이전트**: 음성-텍스트 변환, 에이전트 워크플로, 텍스트-음성 변환을 결합한 음성 파이프라인을 구축합니다. +- **파이썬 우선**: 새로운 추상화를 학습하는 대신 내장 언어 기능을 사용하여 에이전트를 오케스트레이션하고 연결합니다. +- **Agents as tools / 핸드오프**: 여러 에이전트 간의 작업을 조율하고 위임하기 위한 강력한 메커니즘입니다. +- **가드레일**: 에이전트 실행과 병렬로 입력 검증 및 안전성 검사를 수행하고, 검사를 통과하지 못하면 빠르게 실패 처리합니다. +- **함수 도구**: 자동 스키마 생성과 Pydantic 기반 검증을 통해 모든 Python 함수를 도구로 변환합니다. +- **MCP 서버 도구 호출**: 함수 도구와 동일한 방식으로 작동하는 내장 MCP 서버 도구 통합입니다. +- **세션**: 에이전트 루프 내에서 작업 컨텍스트를 유지하기 위한 영구 메모리 계층입니다. +- **휴먼인더루프 (HITL)**: 여러 에이전트 실행에 사람을 참여시키기 위한 내장 메커니즘입니다. +- **트레이싱**: 워크플로를 시각화, 디버깅 및 모니터링하기 위한 내장 트레이싱 기능으로, OpenAI의 평가, 미세 조정 및 증류 도구 모음을 지원합니다. -## Agents SDK 또는 Responses API +## Agents SDK와 Responses API 비교 -SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 모델 호출 주변에 더 높은 수준의 런타임을 추가합니다. +SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 모델 호출을 둘러싼 더 높은 수준의 런타임을 추가로 제공합니다. -다음과 같은 경우 Responses API를 직접 사용하세요. +다음과 같은 경우 Responses API를 직접 사용하세요: -- 루프, 도구 디스패치, 상태 처리를 직접 관리하려는 경우 -- 워크플로가 짧게 실행되며 주로 모델의 응답을 반환하는 것이 목적인 경우 +- 루프, 도구 디스패치 및 상태 처리를 직접 제어하려는 경우 +- 워크플로가 단기적으로 실행되며 주로 모델 응답을 반환하는 데 중점을 두는 경우 -다음과 같은 경우 Agents SDK를 사용하세요. +다음과 같은 경우 Agents SDK를 사용하세요: -- 런타임이 턴, 도구 실행, 가드레일, 핸드오프 또는 세션을 관리하기를 원하는 경우 -- 에이전트가 아티팩트를 생성하거나 여러 조율된 단계에 걸쳐 동작해야 하는 경우 -- 실제 워크스페이스나 [샌드박스 에이전트](sandbox_agents.md)를 통한 재개 가능한 실행이 필요한 경우 +- 런타임에서 턴, 도구 실행, 가드레일, 핸드오프 또는 세션을 관리하도록 하려는 경우 +- 에이전트가 결과물을 생성하거나 여러 단계에 걸쳐 조율된 방식으로 작동해야 하는 경우 +- [샌드박스 에이전트](sandbox_agents.md)를 통해 실제 작업 공간이나 재개 가능한 실행이 필요한 경우 -둘 중 하나를 전역적으로 선택할 필요는 없습니다. 많은 애플리케이션은 관리형 워크플로에는 SDK를 사용하고, 더 낮은 수준의 경로에는 Responses API를 직접 호출합니다. +둘 중 하나만 전역적으로 선택할 필요는 없습니다. 많은 애플리케이션이 관리형 워크플로에는 SDK를 사용하고, 저수준 경로에는 Responses API를 직접 호출합니다. ## 설치 @@ -77,25 +78,25 @@ print(result.final_output) export OPENAI_API_KEY=sk-... ``` -## 시작 지점 +## 시작 안내 -- [빠른 시작](quickstart.md)으로 첫 텍스트 기반 에이전트를 구축하세요. -- 그런 다음 [에이전트 실행](running_agents.md#choose-a-memory-strategy)에서 턴 간 상태를 어떻게 유지할지 결정하세요. -- 작업이 실제 파일, 리포지토리 또는 에이전트별 격리된 워크스페이스 상태에 의존한다면 [샌드박스 에이전트 빠른 시작](sandbox_agents.md)을 읽어 보세요. -- 핸드오프와 매니저 스타일 오케스트레이션 중에서 결정하는 중이라면 [에이전트 오케스트레이션](multi_agent.md)을 읽어 보세요. +- [빠른 시작](quickstart.md)을 통해 첫 번째 텍스트 기반 에이전트를 구축합니다. +- 그런 다음 [에이전트 실행](running_agents.md#choose-a-memory-strategy)에서 턴 간 상태를 유지할 방법을 결정합니다. +- 작업이 실제 파일, 리포지토리 또는 에이전트별로 격리된 작업 공간 상태에 의존하는 경우 [샌드박스 에이전트 빠른 시작](sandbox_agents.md)을 읽어보세요. +- 핸드오프와 관리자 스타일 오케스트레이션 중 하나를 결정하려는 경우 [에이전트 오케스트레이션](multi_agent.md)을 읽어보세요. ## 경로 선택 -하려는 작업은 알고 있지만 어느 페이지에서 설명하는지 모를 때 이 표를 사용하세요. +수행하려는 작업은 알고 있지만 어떤 페이지에서 설명하는지 모를 때 이 표를 사용하세요. | 목표 | 시작 지점 | | --- | --- | -| 첫 텍스트 에이전트를 만들고 전체 실행 한 번 확인 | [빠른 시작](quickstart.md) | +| 첫 번째 텍스트 에이전트를 구축하고 전체 실행 과정 확인 | [빠른 시작](quickstart.md) | | 함수 도구, 호스티드 툴 또는 agents as tools 추가 | [도구](tools.md) | -| 실제 격리된 워크스페이스 안에서 코딩, 리뷰 또는 문서 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) 및 [샌드박스 클라이언트](sandbox/clients.md) | -| 핸드오프와 매니저 스타일 오케스트레이션 중에서 결정 | [에이전트 오케스트레이션](multi_agent.md) | +| 실제 격리 작업 공간에서 코딩, 검토 또는 문서 작업 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) 및 [샌드박스 클라이언트](sandbox/clients.md) | +| 핸드오프와 관리자 스타일 오케스트레이션 중 선택 | [에이전트 오케스트레이션](multi_agent.md) | | 턴 간 메모리 유지 | [에이전트 실행](running_agents.md#choose-a-memory-strategy) 및 [세션](sessions/index.md) | -| OpenAI 모델, 웹소켓 전송 또는 비 OpenAI 제공자 사용 | [모델](models/index.md) | -| 출력, 실행 항목, 인터럽션(중단 처리), 재개 상태 검토 | [결과](results.md) | -| `gpt-realtime-2.1`로 지연 시간이 낮은 음성 에이전트 구축 | [실시간 에이전트 빠른 시작](realtime/quickstart.md) 및 [실시간 전송](realtime/transport.md) | +| OpenAI 모델, WebSocket 트랜스포트 또는 OpenAI 외 제공업체 사용 | [모델](models/index.md) | +| 출력, 실행 항목, 인터럽션(중단 처리) 및 재개 상태 검토 | [결과](results.md) | +| `gpt-realtime-2.1`을 사용하는 저지연 음성 에이전트 구축 | [실시간 에이전트 빠른 시작](realtime/quickstart.md) 및 [실시간 트랜스포트](realtime/transport.md) | | 음성-텍스트 변환 / 에이전트 / 텍스트-음성 변환 파이프라인 구축 | [음성 파이프라인 빠른 시작](voice/quickstart.md) | \ No newline at end of file diff --git a/docs/ko/mcp.md b/docs/ko/mcp.md index 5e1d204188..81ee2c8548 100644 --- a/docs/ko/mcp.md +++ b/docs/ko/mcp.md @@ -4,31 +4,35 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction) (MCP)는 애플리케이션이 도구와 -컨텍스트를 언어 모델에 노출하는 방식을 표준화합니다. 공식 문서에 따르면 다음과 같습니다. +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)은 애플리케이션이 언어 모델에 도구와 +컨텍스트를 제공하는 방식을 표준화합니다. 공식 문서에서는 다음과 같이 설명합니다. -> MCP는 애플리케이션이 LLMs에 컨텍스트를 제공하는 방식을 표준화하는 개방형 프로토콜입니다. MCP를 AI -> 애플리케이션을 위한 USB-C 포트처럼 생각해 보세요. USB-C가 기기를 다양한 주변 장치와 액세서리에 연결하는 표준화된 방식을 제공하듯이, MCP는 -> AI 모델을 다양한 데이터 소스와 도구에 연결하는 표준화된 방식을 제공합니다. +> MCP는 애플리케이션이 LLM에 컨텍스트를 제공하는 방식을 표준화하는 개방형 프로토콜입니다. MCP를 AI +> 애플리케이션용 USB-C 포트라고 생각해 보세요. USB-C가 기기를 다양한 주변 장치 및 액세서리에 연결하는 표준화된 방법을 제공하듯이, MCP는 +> AI 모델을 다양한 데이터 소스와 도구에 연결하는 표준화된 방법을 제공합니다. -Agents Python SDK는 여러 MCP 전송 방식을 지원합니다. 이를 통해 기존 MCP 서버를 재사용하거나, 파일 시스템, HTTP 또는 커넥터 기반 도구를 에이전트에 노출하도록 직접 빌드할 수 있습니다. +Agents Python SDK는 여러 MCP 전송 방식을 지원합니다. 따라서 기존 MCP 서버를 재사용하거나 자체 서버를 구축하여 파일 시스템, HTTP 또는 커넥터 기반 도구를 에이전트에 제공할 수 있습니다. + +!!! warning "연결 전 MCP 서버 신뢰성 확인" + + MCP 도구는 모델 컨텍스트의 데이터를 노출하고 사용자가 제공한 자격 증명으로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 자격 증명을 사용하며, 액세스 토큰을 URL이 아닌 인증 필드나 헤더에 보관하고, 민감한 작업에는 승인을 요구하세요. [OpenAI MCP 보안 지침](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)을 참조하세요. ## MCP 통합 선택 -MCP 서버를 에이전트에 연결하기 전에 도구 호출을 어디서 실행해야 하는지, 어떤 전송 방식에 접근할 수 있는지 결정하세요. 아래 표는 Python SDK가 지원하는 옵션을 요약합니다. +MCP 서버를 에이전트에 연결하기 전에 도구 호출이 실행될 위치와 접근 가능한 전송 방식을 결정하세요. 아래 표에는 Python SDK가 지원하는 옵션이 요약되어 있습니다. -| 필요한 사항 | 권장 옵션 | +| 필요한 사항 | 권장 옵션 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI의 Responses API가 모델을 대신해 공개적으로 접근 가능한 MCP 서버를 호출하도록 하기| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | -| 로컬 또는 원격에서 실행하는 Streamable HTTP 서버에 연결하기 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 통한 **Streamable HTTP MCP 서버** | -| Server-Sent Events가 포함된 HTTP를 구현하는 서버와 통신하기 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **HTTP with SSE MCP 서버** | -| 로컬 프로세스를 시작하고 stdin/stdout을 통해 통신하기 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 통한 **stdio MCP 서버** | +| OpenAI Responses API가 모델을 대신하여 공개적으로 접근 가능한 MCP 서버를 호출하도록 허용| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | +| 로컬 또는 원격에서 실행하는 Streamable HTTP 서버에 연결 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 통한 **Streamable HTTP MCP 서버** | +| Server-Sent Events를 사용하는 HTTP를 구현한 서버와 통신 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **SSE 기반 HTTP MCP 서버** | +| 로컬 프로세스를 실행하고 stdin/stdout을 통해 통신 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 통한 **stdio MCP 서버** | -아래 섹션에서는 각 옵션, 구성 방법, 그리고 어떤 경우에 한 전송 방식을 다른 전송 방식보다 선호해야 하는지 살펴봅니다. +아래 섹션에서는 각 옵션과 구성 방법, 특정 전송 방식을 선택해야 하는 경우를 설명합니다. ## 에이전트 수준 MCP 구성 -전송 방식을 선택하는 것 외에도 `Agent.mcp_config`를 설정하여 MCP 도구가 준비되는 방식을 조정할 수 있습니다. +전송 방식을 선택하는 것 외에도 `Agent.mcp_config`를 설정하여 MCP 도구의 준비 방식을 조정할 수 있습니다. ```python from agents import Agent @@ -50,31 +54,31 @@ agent = Agent( 참고: -- `convert_schemas_to_strict`는 최선 노력 방식입니다. 스키마를 변환할 수 없으면 원래 스키마가 사용됩니다. -- `failure_error_function`은 MCP 도구 호출 실패가 모델에 어떻게 표시되는지 제어합니다. -- `failure_error_function`이 설정되지 않은 경우 SDK는 기본 도구 오류 포매터를 사용합니다. -- 서버 수준의 `failure_error_function`은 해당 서버에 대해 `Agent.mcp_config["failure_error_function"]`을 재정의합니다. -- `include_server_in_tool_names`는 옵트인 방식입니다. 활성화하면 각 로컬 MCP 도구가 결정적인 서버 접두사 이름으로 모델에 노출되어, 여러 MCP 서버가 같은 이름의 도구를 게시할 때 충돌을 피하는 데 도움이 됩니다. 생성된 이름은 ASCII-safe이고, 함수 도구 이름 길이 제한 내에 있으며, 동일한 에이전트에 있는 기존 로컬 함수 도구 이름 및 활성화된 핸드오프 이름과 충돌하지 않습니다. SDK는 여전히 원래 서버에서 원래 MCP 도구 이름을 호출합니다. +- `convert_schemas_to_strict`는 최선형 방식으로 동작합니다. 스키마를 변환할 수 없으면 원래 스키마를 사용합니다. +- `failure_error_function`은 MCP 도구 호출 실패가 모델에 표시되는 방식을 제어합니다. +- `failure_error_function`을 설정하지 않으면 SDK는 기본 도구 오류 포매터를 사용합니다. +- 서버 수준의 `failure_error_function`은 해당 서버에 대한 `Agent.mcp_config["failure_error_function"]`을 재정의합니다. +- `include_server_in_tool_names`는 명시적으로 활성화해야 합니다. 활성화하면 각 로컬 MCP 도구가 결정론적인 서버 접두사 이름으로 모델에 제공되므로 여러 MCP 서버가 동일한 이름의 도구를 게시할 때 충돌을 방지하는 데 도움이 됩니다. 생성되는 이름은 ASCII에 안전하고 함수 도구 이름 길이 제한을 준수하며, 동일한 에이전트의 기존 로컬 함수 도구 및 활성화된 핸드오프 이름과 겹치지 않습니다. SDK는 원래 서버에서 원래 MCP 도구 이름을 사용해 계속 호출합니다. -## 전송 방식 전반의 공통 패턴 +## 전송 방식의 공통 패턴 -전송 방식을 선택한 뒤에는 대부분의 통합에서 다음과 같은 후속 결정이 필요합니다. +전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 사항을 결정해야 합니다. -- 도구의 일부만 노출하는 방법([도구 필터링](#tool-filtering)). -- 서버가 재사용 가능한 프롬프트도 제공하는지 여부([프롬프트](#prompts)). -- `list_tools()`를 캐시해야 하는지 여부([캐싱](#caching)). -- MCP 활동이 트레이스에 표시되는 방식([트레이싱](#tracing)). +- 일부 도구만 제공하는 방법([도구 필터링](#tool-filtering)) +- 서버가 재사용 가능한 프롬프트도 제공하는지 여부([프롬프트](#prompts)) +- `list_tools()`를 캐시할지 여부([캐싱](#caching)) +- MCP 활동이 트레이스에 표시되는 방식([트레이싱](#tracing)) -로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)의 경우 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. Streamable HTTP 섹션은 가장 완전한 예를 보여주며, 동일한 패턴이 다른 로컬 전송 방식에도 적용됩니다. +로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. Streamable HTTP 섹션에서 가장 완전한 코드 예제를 제공하며, 동일한 패턴을 다른 로컬 전송 방식에도 적용할 수 있습니다. ## 1. 호스티드 MCP 서버 도구 -호스티드 툴은 전체 도구 왕복 과정을 OpenAI 인프라로 보냅니다. 코드가 도구를 나열하고 호출하는 대신, [`HostedMCPTool`][agents.tool.HostedMCPTool]은 서버 레이블(및 선택적 커넥터 메타데이터)을 Responses API로 전달합니다. 모델은 Python 프로세스에 대한 추가 콜백 없이 원격 서버의 도구를 나열하고 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. +호스티드 툴은 전체 도구 왕복 과정을 OpenAI 인프라에서 처리합니다. 코드에서 도구 목록을 가져오고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API로 전달합니다. 모델은 Python 프로세스에 추가 콜백을 수행하지 않고 원격 서버의 도구 목록을 가져와 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. ### 기본 호스티드 MCP 도구 -에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 만듭니다. `tool_config` -dict는 REST API로 보낼 JSON과 동일한 구조입니다. +에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 생성합니다. `tool_config` +딕셔너리는 REST API로 전송할 JSON과 동일한 구조를 사용합니다. ```python import asyncio @@ -106,14 +110,14 @@ async def main() -> None: asyncio.run(main()) ``` -호스티드 서버는 자체 도구를 자동으로 노출하므로, 이를 `mcp_servers`에 추가하지 않습니다. +호스티드 서버는 도구를 자동으로 제공하므로 `mcp_servers`에 추가하지 않아도 됩니다. -호스티드 툴 검색이 호스티드 MCP 서버를 지연 로드하도록 하려면 `tool_config["defer_loading"] = True`를 설정하고 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 에이전트에 추가하세요. 이는 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 설정 및 제약 사항은 [도구](tools.md#hosted-tool-search)를 참조하세요. +호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하도록 하려면 `tool_config["defer_loading"] = True`를 설정하고 에이전트에 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 추가합니다. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 설정과 제약 조건은 [도구](tools.md#hosted-tool-search)를 참조하세요. ### 호스티드 MCP 결과 스트리밍 -호스티드 툴은 함수 도구와 정확히 같은 방식으로 스트리밍 결과를 지원합니다. 모델이 계속 작업하는 동안 -증분 MCP 출력을 소비하려면 `Runner.run_streamed`를 사용하세요. +호스티드 툴은 함수 도구와 정확히 동일한 방식으로 스트리밍 결과를 지원합니다. 모델이 계속 작업하는 동안 +증분 MCP 출력을 사용하려면 `Runner.run_streamed`를 사용합니다. ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -125,7 +129,7 @@ print(result.final_output) ### 선택적 승인 흐름 -서버가 민감한 작업을 수행할 수 있다면 각 도구 실행 전에 사람 또는 프로그램 방식의 승인을 요구할 수 있습니다. `tool_config`에서 `require_approval`을 단일 정책(`"always"`, `"never"`) 또는 도구 이름을 정책에 매핑하는 dict로 구성하세요. Python 내부에서 결정을 내리려면 `on_approval_request` 콜백을 제공하세요. +서버가 민감한 작업을 수행할 수 있는 경우 각 도구를 실행하기 전에 사람 또는 프로그램에 의한 승인을 요구할 수 있습니다. 단일 정책(`"always"`, `"never"`)이나 도구 이름을 정책에 매핑하는 딕셔너리를 사용하여 `tool_config`의 `require_approval`을 구성합니다. Python 내에서 결정을 내리려면 `on_approval_request` 콜백을 제공합니다. ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -153,11 +157,11 @@ agent = Agent( ) ``` -콜백은 동기 또는 비동기일 수 있으며, 모델이 계속 실행하기 위해 승인 데이터가 필요할 때마다 호출됩니다. +콜백은 동기식 또는 비동기식일 수 있으며, 모델이 계속 실행하는 데 승인 데이터가 필요할 때마다 호출됩니다. ### 커넥터 기반 호스티드 서버 -호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`와 액세스 토큰을 제공하세요. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 노출합니다. +호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`와 액세스 토큰을 제공합니다. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 제공합니다. ```python import os @@ -173,11 +177,11 @@ HostedMCPTool( ) ``` -스트리밍, 승인, 커넥터를 포함해 완전히 동작하는 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에 있습니다. +스트리밍, 승인, 커넥터를 포함해 완전하게 작동하는 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에서 확인할 수 있습니다. ## 2. Streamable HTTP MCP 서버 -네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 사용하세요. Streamable HTTP 서버는 전송 방식을 제어하거나, 지연 시간을 낮게 유지하면서 자체 인프라 내부에서 서버를 실행하려는 경우에 적합합니다. +네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 사용합니다. Streamable HTTP 서버는 전송 방식을 직접 제어하거나 짧은 지연 시간을 유지하면서 자체 인프라 내에서 서버를 실행하려는 경우에 적합합니다. ```python import asyncio @@ -212,25 +216,25 @@ async def main() -> None: asyncio.run(main()) ``` -생성자는 추가 옵션을 받습니다. +생성자는 다음과 같은 추가 옵션을 허용합니다. -- `client_session_timeout_seconds`는 HTTP 읽기 타임아웃을 제어합니다. -- `use_structured_content`는 텍스트 출력보다 `tool_result.structured_content`를 선호할지 여부를 전환합니다. -- `max_retry_attempts`와 `retry_backoff_seconds_base`는 `list_tools()` 및 `call_tool()`에 대한 자동 재시도를 추가합니다. -- `tool_filter`를 사용하면 도구의 일부만 노출할 수 있습니다([도구 필터링](#tool-filtering) 참조). -- `require_approval`은 로컬 MCP 도구에서 휴먼인더루프 (HITL) 승인 정책을 활성화합니다. -- `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 지정합니다. 오류를 대신 발생시키려면 이를 `None`으로 설정하세요. -- `tool_meta_resolver`는 `call_tool()` 전에 호출별 MCP `_meta` 페이로드를 주입합니다. +- `client_session_timeout_seconds`는 MCP ClientSession 읽기 타임아웃을 제어합니다. `datetime.timedelta`로 표현할 수 있고 최소 1마이크로초인 양의 유한 값은 유한 타임아웃을 설정하며, `None`과 `0`은 이를 비활성화합니다. 그 밖의 값은 서버를 생성할 때 거부됩니다. +- `use_structured_content`는 텍스트 출력보다 `tool_result.structured_content`를 우선할지 여부를 전환합니다. +- `max_retry_attempts`와 `retry_backoff_seconds_base`는 `list_tools()`와 `call_tool()`에 자동 재시도를 추가합니다. +- `tool_filter`를 사용하면 일부 도구만 제공할 수 있습니다([도구 필터링](#tool-filtering) 참조). +- `require_approval`은 로컬 MCP 도구에 휴먼인더루프 (HITL) 승인 정책을 활성화합니다. +- `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 지정합니다. 오류를 대신 발생시키려면 `None`으로 설정합니다. +- `tool_meta_resolver`는 `call_tool()` 전에 호출별 MCP `_meta` 페이로드를 삽입합니다. ### 로컬 MCP 서버의 승인 정책 -`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`는 모두 `require_approval`을 받습니다. +`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`는 모두 `require_approval`을 허용합니다. 지원되는 형식: -- 모든 도구에 대해 `"always"` 또는 `"never"` -- `True` / `False`(`always`/`never`와 동일) -- 도구별 맵, 예: `{"delete_file": "always", "read_file": "never"}` +- 모든 도구에 대한 `"always"` 또는 `"never"` +- `True` / `False`(always/never와 동일) +- 도구별 맵(예: `{"delete_file": "always", "read_file": "never"}`) - 그룹화된 객체: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}` ```python @@ -244,9 +248,9 @@ async with MCPServerStreamableHttp( 전체 일시 중지/재개 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)와 `examples/mcp/get_all_mcp_tools_example/main.py`를 참조하세요. -### 호출별 메타데이터와 `tool_meta_resolver` +### `tool_meta_resolver`를 사용한 호출별 메타데이터 -MCP 서버가 `_meta`에서 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 기대하는 경우 `tool_meta_resolver`를 사용하세요. 아래 예시는 `Runner.run(...)`에 `context`로 `dict`를 전달한다고 가정합니다. +MCP 서버가 `_meta`에 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 요구하는 경우 `tool_meta_resolver`를 사용합니다. 아래 예제에서는 `Runner.run(...)`에 `dict`를 `context`로 전달한다고 가정합니다. ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -267,19 +271,19 @@ server = MCPServerStreamableHttp( ) ``` -실행 컨텍스트가 Pydantic 모델, dataclass 또는 사용자 지정 클래스라면 속성 접근으로 테넌트 ID를 읽으세요. +실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 지정 클래스인 경우 속성 접근을 사용하여 테넌트 ID를 읽습니다. -### MCP 도구 출력: 텍스트와 이미지 +### MCP 도구 출력: 텍스트 및 이미지 -MCP 도구가 이미지 콘텐츠를 반환하면 SDK는 이를 이미지 도구 출력 항목으로 자동 매핑합니다. 텍스트/이미지 혼합 응답은 출력 항목 목록으로 전달되므로, 에이전트는 일반 함수 도구의 이미지 출력을 소비하는 것과 같은 방식으로 MCP 이미지 결과를 소비할 수 있습니다. +MCP 도구가 이미지 콘텐츠를 반환하면 SDK는 이를 이미지 도구 출력 항목에 자동으로 매핑합니다. 텍스트와 이미지가 혼합된 응답은 출력 항목 목록으로 전달되므로 에이전트는 일반 함수 도구의 이미지 출력을 사용하는 것과 같은 방식으로 MCP 이미지 결과를 사용할 수 있습니다. -## 3. HTTP with SSE MCP 서버 +## 3. SSE 기반 HTTP MCP 서버 !!! warning - MCP 프로젝트는 Server-Sent Events 전송 방식을 deprecated 처리했습니다. 새 통합에는 Streamable HTTP 또는 stdio를 선호하고 SSE는 레거시 서버에만 유지하세요. + MCP 프로젝트에서는 Server-Sent Events 전송 방식의 사용을 중단했습니다. 새로운 통합에는 Streamable HTTP 또는 stdio를 우선 사용하고, SSE는 레거시 서버에만 사용하세요. -MCP 서버가 HTTP with SSE 전송 방식을 구현하는 경우 [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 인스턴스화하세요. 전송 방식을 제외하면 API는 Streamable HTTP 서버와 동일합니다. +MCP 서버가 SSE 기반 HTTP 전송 방식을 구현하는 경우 [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 인스턴스화합니다. 전송 방식을 제외하면 API는 Streamable HTTP 서버와 동일합니다. ```python @@ -308,7 +312,7 @@ async with MCPServerSse( ## 4. stdio MCP 서버 -로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 사용하세요. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며, 컨텍스트 관리자가 종료될 때 자동으로 닫습니다. 이 옵션은 빠른 개념 증명이나 서버가 명령줄 엔트리 포인트만 노출하는 경우에 유용합니다. +로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 사용합니다. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며, 컨텍스트 관리자가 종료될 때 자동으로 파이프를 닫습니다. 이 옵션은 빠른 개념 증명이나 서버가 명령줄 진입점만 제공하는 경우에 유용합니다. ```python from pathlib import Path @@ -336,7 +340,7 @@ async with MCPServerStdio( ## 5. MCP 서버 관리자 -MCP 서버가 여러 개 있다면 `MCPServerManager`를 사용해 미리 연결하고 연결된 하위 집합을 에이전트에 노출하세요. 생성자 옵션 및 재연결 동작은 [MCPServerManager API 참조](ref/mcp/manager.md)를 참조하세요. +MCP 서버가 여러 개인 경우 `MCPServerManager`를 사용하여 서버를 미리 연결하고 연결에 성공한 서버 집합을 에이전트에 제공합니다. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참조하세요. ```python from agents import Agent, Runner @@ -359,23 +363,23 @@ async with MCPServerManager(servers) as manager: 주요 동작: -- `drop_failed_servers=True`(기본값)인 경우 `active_servers`에는 성공적으로 연결된 서버만 포함됩니다. -- 실패는 `failed_servers`와 `errors`에 추적됩니다. -- 첫 번째 연결 실패 시 오류를 발생시키려면 `strict=True`를 설정하세요. -- 실패한 서버를 다시 시도하려면 `reconnect(failed_only=True)`를 호출하고, 모든 서버를 다시 시작하려면 `reconnect(failed_only=False)`를 호출하세요. -- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 사용하세요. +- `drop_failed_servers=True`(기본값)인 경우 `active_servers`에는 연결에 성공한 서버만 포함됩니다. +- 실패는 `failed_servers`와 `errors`에서 추적됩니다. +- 첫 번째 연결 실패 시 예외를 발생시키려면 `strict=True`를 설정합니다. +- 실패한 서버를 다시 시도하려면 `reconnect(failed_only=True)`를 호출하고, 모든 서버를 다시 시작하려면 `reconnect(failed_only=False)`를 호출합니다. +- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정합니다. 수명 주기 타임아웃에는 양의 유한 초 값 또는 비활성화를 위한 `None`을 사용할 수 있으며, 생성 및 할당 시 모두 검증됩니다. 0은 즉시 기한을 생성하므로 거부됩니다. ## 공통 서버 기능 -아래 섹션은 MCP 서버 전송 방식 전반에 적용됩니다(정확한 API 범위는 서버 클래스에 따라 달라짐). +아래 섹션은 MCP 서버 전송 방식 전반에 적용됩니다. 단, 정확한 API 범위는 서버 클래스에 따라 달라집니다. ## 도구 필터링 -각 MCP 서버는 도구 필터를 지원하므로 에이전트에 필요한 함수만 노출할 수 있습니다. 필터링은 생성 시점에 수행하거나 실행별로 동적으로 수행할 수 있습니다. +각 MCP 서버는 에이전트에 필요한 함수만 제공할 수 있도록 도구 필터를 지원합니다. 필터링은 생성 시점 또는 실행별로 동적으로 수행할 수 있습니다. ### 정적 도구 필터링 -간단한 허용/차단 목록을 구성하려면 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter]를 사용하세요. +간단한 허용/차단 목록을 구성하려면 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter]를 사용합니다. ```python from pathlib import Path @@ -393,11 +397,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names`와 `blocked_tool_names`가 모두 제공되면 SDK는 허용 목록을 먼저 적용한 뒤 남은 집합에서 차단된 도구를 제거합니다. +`allowed_tool_names`와 `blocked_tool_names`를 모두 제공하면 SDK는 먼저 허용 목록을 적용한 다음, 남은 집합에서 차단된 도구를 제거합니다. ### 동적 도구 필터링 -더 복잡한 로직의 경우 [`ToolFilterContext`][agents.mcp.ToolFilterContext]를 받는 호출 가능 객체를 전달하세요. 호출 가능 객체는 동기 또는 비동기일 수 있으며, 도구를 노출해야 할 때 `True`를 반환합니다. +더 복잡한 로직을 사용하려면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]를 받는 호출 가능 객체를 전달합니다. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며, 도구를 제공해야 하는 경우 `True`를 반환합니다. ```python from pathlib import Path @@ -421,15 +425,15 @@ async with MCPServerStdio( ... ``` -필터 컨텍스트는 활성 `run_context`, 도구를 요청하는 `agent`, 그리고 `server_name`을 노출합니다. +필터 컨텍스트는 활성 `run_context`, 도구를 요청하는 `agent`, `server_name`을 제공합니다. ## 프롬프트 -MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 두 가지 -메서드를 노출합니다. +MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 다음 두 가지 +메서드를 제공합니다. - `list_prompts()`는 사용 가능한 프롬프트 템플릿을 열거합니다. -- `get_prompt(name, arguments)`는 매개변수를 선택적으로 포함하여 구체적인 프롬프트를 가져옵니다. +- `get_prompt(name, arguments)`는 선택적으로 매개변수를 사용하여 구체적인 프롬프트를 가져옵니다. ```python from agents import Agent @@ -449,13 +453,13 @@ agent = Agent( ## 캐싱 -각 에이전트 실행은 모든 MCP 서버에서 `list_tools()`를 호출합니다. 원격 서버는 눈에 띄는 지연 시간을 유발할 수 있으므로, 모든 MCP 서버 클래스는 `cache_tools_list` 옵션을 노출합니다. 도구 정의가 자주 변경되지 않는다고 확신하는 경우에만 이를 `True`로 설정하세요. 나중에 최신 목록을 강제로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`를 호출하세요. +에이전트를 실행할 때마다 각 MCP 서버에서 `list_tools()`를 호출합니다. 원격 서버는 눈에 띄는 지연을 유발할 수 있으므로 모든 MCP 서버 클래스는 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신하는 경우에만 이를 `True`로 설정하세요. 나중에 새 목록을 강제로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`를 호출합니다. ## 트레이싱 -[트레이싱](./tracing.md)은 다음을 포함한 MCP 활동을 자동으로 캡처합니다. +[트레이싱](./tracing.md)은 다음을 비롯한 MCP 활동을 자동으로 캡처합니다. -1. 도구 목록을 나열하기 위한 MCP 서버 호출 +1. 도구 목록을 가져오기 위한 MCP 서버 호출 2. 도구 호출의 MCP 관련 정보 ![MCP 트레이싱 스크린샷](../assets/images/mcp-tracing.jpg) @@ -464,4 +468,4 @@ agent = Agent( - [Model Context Protocol](https://modelcontextprotocol.io/) – 사양 및 설계 가이드 - [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE 및 Streamable HTTP 샘플 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인 및 커넥터를 포함한 완전한 호스티드 MCP 데모 \ No newline at end of file +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인과 커넥터를 포함한 완전한 호스티드 MCP 데모 \ No newline at end of file diff --git a/docs/ko/tools.md b/docs/ko/tools.md index 420a1034ba..61a2776ca1 100644 --- a/docs/ko/tools.md +++ b/docs/ko/tools.md @@ -10,21 +10,21 @@ search: - 로컬/런타임 실행 도구: `ComputerTool`과 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`은 로컬 또는 호스티드 컨테이너에서 실행할 수 있습니다. - 함수 호출: 모든 Python 함수를 도구로 래핑합니다. - Agents as tools: 전체 핸드오프 없이 에이전트를 호출 가능한 도구로 노출합니다. -- 실험적 기능: Codex 도구: 도구 호출에서 작업 공간 범위의 Codex 작업을 실행합니다. +- 실험적 기능: Codex 도구: 도구 호출을 통해 워크스페이스 범위의 Codex 작업을 실행합니다. ## 도구 유형 선택 이 페이지를 카탈로그로 활용한 다음, 제어하는 런타임에 해당하는 섹션으로 이동하세요. -| 원하는 작업 | 시작 지점 | +| 원하는 작업 | 시작할 위치 | | --- | --- | -| OpenAI 관리형 도구(웹 검색, 파일 검색, Code Interpreter, 호스티드 MCP, 이미지 생성) 사용 | [호스티드 툴](#hosted-tools) | -| 도구 검색을 사용하여 대규모 도구 표면을 런타임까지 지연 | [호스티드 툴 검색](#hosted-tool-search) | +| OpenAI 관리형 도구 사용(웹 검색, 파일 검색, 코드 인터프리터, 호스티드 MCP, 이미지 생성) | [호스티드 툴](#hosted-tools) | +| 도구 검색을 사용해 대규모 도구 집합의 로딩을 런타임까지 지연 | [호스티드 도구 검색](#hosted-tool-search) | | 생성된 JavaScript에서 여러 도구 호출 조정 | [프로그래밍 방식 도구 호출](#programmatic-tool-calling) | | 자체 프로세스 또는 환경에서 도구 실행 | [로컬 런타임 도구](#local-runtime-tools) | | Python 함수를 도구로 래핑 | [함수 도구](#function-tools) | -| 핸드오프 없이 한 에이전트가 다른 에이전트를 호출하도록 설정 | [Agents as tools](#agents-as-tools) | -| 에이전트에서 작업 공간 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | +| 핸드오프 없이 한 에이전트가 다른 에이전트 호출 | [Agents as tools](#agents-as-tools) | +| 에이전트에서 워크스페이스 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | ## 호스티드 툴 @@ -40,7 +40,7 @@ OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponse 고급 호스티드 검색 옵션: -- `FileSearchTool`은 `vector_store_ids`와 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. +- `FileSearchTool`은 `vector_store_ids` 및 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. `max_num_results`를 1부터 50 사이의 정수로 설정하세요. `None` 또는 0이면 공급자의 기본값을 사용합니다. - `WebSearchTool`은 `filters`, `user_location`, `search_context_size`를 지원합니다. ```python @@ -62,11 +62,11 @@ async def main(): print(result.final_output) ``` -### 호스티드 툴 검색 +### 호스티드 도구 검색 -도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 표면의 로드를 런타임까지 지연하여 현재 턴에 필요한 일부 도구만 로드할 수 있습니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많고 모든 도구를 처음부터 노출하지 않으면서 도구 스키마 토큰을 줄이려는 경우에 유용합니다. +도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 집합의 로딩을 런타임까지 지연할 수 있으므로, 모델은 현재 턴에 필요한 일부만 로드합니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많고 모든 도구를 미리 노출하지 않으면서 도구 스키마 토큰을 줄이려는 경우 유용합니다. -에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 툴 검색부터 사용하세요. 애플리케이션에서 로드할 항목을 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행형 도구 검색도 지원하지만, 표준 `Runner`는 이 모드를 자동으로 실행하지 않습니다. +에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 도구 검색부터 사용하세요. 애플리케이션에서 무엇을 로드할지 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행 도구 검색도 지원하지만, 표준 `Runner`는 이 모드를 자동 실행하지 않습니다. ```python from typing import Annotated @@ -111,26 +111,26 @@ print(result.final_output) 알아둘 사항: -- 호스티드 툴 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원 여부는 `openai>=2.25.0`에 따라 달라집니다. -- 에이전트에서 지연 로드 표면을 구성할 때 정확히 하나의 `ToolSearchTool()`을 추가하세요. -- 검색 가능한 표면에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`가 포함됩니다. -- 지연 로드 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 설정에서도 모델이 필요할 때 적절한 그룹을 로드하도록 `ToolSearchTool()`을 사용할 수 있습니다. -- `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름과 설명 아래에 그룹화합니다. `crm`, `billing`, `shipping`처럼 서로 관련된 도구가 많은 경우 일반적으로 가장 적합합니다. -- OpenAI의 공식 모범 사례 지침은 [가능한 경우 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. -- 가능하면 개별적으로 지연되는 여러 함수보다 네임스페이스 또는 호스티드 MCP 서버를 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 표면을 제공하고 토큰을 더 많이 절약할 수 있습니다. -- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`가 없는 도구는 즉시 호출할 수 있으며, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. -- 일반적으로 각 네임스페이스는 비교적 작게 유지하며, 함수 수는 10개 미만이 이상적입니다. +- 호스티드 도구 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원은 `openai>=2.25.0`에 따라 달라집니다. +- 에이전트에 지연 로딩 대상을 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. +- 검색 가능한 대상에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`가 포함됩니다. +- 지연 로딩 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 구성에서도 모델이 필요할 때 적절한 그룹을 로드하도록 `ToolSearchTool()`을 사용할 수 있습니다. +- `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름과 설명 아래에 그룹화합니다. `crm`, `billing`, `shipping`처럼 관련 도구가 많은 경우 일반적으로 가장 적합합니다. +- OpenAI의 공식 모범 사례 지침은 [가능하면 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. +- 가능하면 개별적으로 지연된 함수를 많이 사용하는 대신 네임스페이스 또는 호스티드 MCP 서버를 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 대상을 제공하고 더 많은 토큰을 절약합니다. +- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`가 없는 도구는 즉시 호출할 수 있고, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. +- 일반적으로 각 네임스페이스를 비교적 작게 유지하고, 함수 수는 10개 미만으로 구성하는 것이 좋습니다. - 이름이 지정된 `tool_choice`는 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 사용하세요. -- `ToolSearchTool(execution="client")`는 수동 Responses 오케스트레이션을 위한 것입니다. 모델이 클라이언트 실행형 `tool_search_call`을 내보내면 표준 `Runner`는 이를 대신 실행하지 않고 예외를 발생시킵니다. -- 도구 검색 활동은 [`RunResult.new_items`](results.md#new-items)와 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 전용 항목 및 이벤트 유형으로 표시됩니다. -- 네임스페이스 기반 로드와 최상위 지연 도구를 모두 다루는 완전한 실행 가능 코드 예제는 `examples/tools/tool_search.py`를 참조하세요. +- `ToolSearchTool(execution="client")`는 수동 Responses 오케스트레이션용입니다. 모델이 클라이언트 실행 `tool_search_call`을 생성하면 표준 `Runner`는 이를 대신 실행하지 않고 예외를 발생시킵니다. +- 도구 검색 활동은 [`RunResult.new_items`](results.md#new-items)와 전용 항목 및 이벤트 유형을 사용하는 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 표시됩니다. +- 네임스페이스 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 코드 예제는 `examples/tools/tool_search.py`를 참조하세요. - 공식 플랫폼 가이드: [도구 검색](https://developers.openai.com/api/docs/guides/tools-tool-search) ### 프로그래밍 방식 도구 호출 -프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 출력을 결합하고, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델을 왕복하지 않고도 반복, 분기, 병렬 호출 또는 중간 계산을 활용할 수 있는 제한된 워크플로에 유용합니다. +프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 JavaScript를 생성하여 사용 가능한 도구를 호출하고, 출력을 결합한 후, 하나의 결과를 모델에 반환할 수 있습니다. 모든 도구 호출 후 모델을 왕복하지 않고도 반복문, 분기, 병렬 호출 또는 중간 계산을 활용할 수 있는 범위가 제한된 워크플로에 유용합니다. -생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. Node.js API, 파일 시스템 또는 네트워크에 접근할 수 없으며 영구 프로세스도 제공되지 않습니다. 프로그램은 명시적으로 허용한 도구와만 상호 작용할 수 있습니다. +생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. Node.js API, 파일 시스템 또는 네트워크에 액세스할 수 없으며 프로세스도 지속되지 않습니다. 프로그램은 명시적으로 허용한 도구와만 상호 작용할 수 있습니다. ```python from pydantic import BaseModel @@ -167,22 +167,22 @@ print(result.final_output) 알아둘 사항: -- 프로그래밍 방식 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. `ProgrammaticToolCallingTool()`과 `tool_choice="programmatic_tool_calling"`은 Chat Completions 모델 및 Responses가 아닌 백엔드에서 거부됩니다. -- 에이전트에는 `ProgrammaticToolCallingTool()`을 최대 하나만 추가하세요. 에이전트는 프로그래밍 방식으로 호출할 수 있는 도구를 하나 이상 노출하거나, 네임스페이스, 지연 함수 또는 지연된 호스티드 MCP 서버를 기반으로 하는 `ToolSearchTool()`을 제공하거나, 불투명한 프롬프트 관리형 도구 표면을 제공해야 합니다. 검색 가능한 표면이 없는 단독 `ToolSearchTool()`은 거부됩니다. -- `allowed_callers`는 도구를 호출할 수 있는 방식을 제어합니다. 생략하면 모델의 직접 호출만 허용됩니다. 프로그램에서만 접근하도록 하려면 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`을 사용하세요. -- 이 기능을 선택적으로 사용할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸, 패치 적용 도구는 `allowed_callers`를 직접 노출합니다. 호스티드 MCP와 Code Interpreter의 경우 `tool_config` 내부에서 `allowed_callers`를 설정하세요. -- `@function_tool(allowed_callers=[...])`의 경우 Pydantic 모델, TypedDict 또는 dataclass와 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 값이 프로그램에 반환되기 전에 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없다면 `output_type=...`을 사용하고, 이미 엄격한 객체 스키마가 있다면 하위 수준의 우회 수단인 `output_json_schema={...}`를 사용하세요. `output_type`과 `output_json_schema`는 함께 사용할 수 없습니다. 일반 `str`, `Any`, `None` 반환은 유형이 지정되지 않은 상태로 유지됩니다. 스키마를 기반으로 하는 프로그램 소유 호출에서는 자유 형식 텍스트가 출력 스키마를 충족하지 않으므로 기본 실패 포매터가 비활성화됩니다. 따라서 스키마를 준수하는 JSON을 반환하는 사용자 지정 `failure_error_function`을 제공하지 않으면 핸들러 예외가 전파됩니다. -- 프로그램 소유 SDK 도구에도 일반적인 Runner 수명 주기가 그대로 적용됩니다. 도구 입력 및 출력 가드레일, 훅, 시간 제한, 동시성 제한, 승인, 세션, `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출과 프로그램 호출자의 관계를 보존합니다. -- `ProgrammaticToolCallingTool()`이 있으면 프로그램이 실행되기 전이라도 모델 요청 재시도에 더 엄격한 재실행 안전성 경계가 적용됩니다. SDK는 이러한 요청에 대해 제공자 관리형 재시도와 WebSocket 사전 이벤트 재시도를 비활성화합니다. Runner 재시도 정책은 제공자의 지침이 재실행해도 안전하다고 명시적으로 표시한 경우에만 재시도합니다. `retry_policies.network_error()`만으로는 이 경계를 재정의하지 않습니다. -- 승인에 민감하거나 영향이 큰 도구는 일반적으로 직접 호출로 유지하는 것이 좋습니다. 그러면 더 큰 프로그램의 일부가 되기 전에 각 작업을 사람이 검토할 수 있습니다. 프로그램 소유 호출이 승인을 위해 일시 중지되면 평소와 같이 `RunState`를 통해 인터럽션(중단 처리)을 해결하고 원래 실행을 재개하세요. -- 프로그래밍 방식 도구 호출은 [호스티드 툴 검색](#hosted-tool-search)과 함께 사용할 수 있습니다. 생성된 프로그램이 지연된 도구를 호출하려면 모델이 먼저 해당 도구를 로드해야 합니다. -- `program` 항목과 일반적인 프로그램 소유 하위 도구 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 이에 대응하는 `program_output`은 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 호스티드 MCP 승인 요청과 도구 카탈로그에는 대신 특수 MCP 항목과 스트림 이벤트가 사용됩니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items)와 [스트리밍](streaming.md#run-item-event-names)을 참조하세요. -- 완전한 동시성 재고 계획 코드 예제는 `examples/tools/programmatic_tool_calling.py`를 참조하세요. +- 프로그래밍 방식 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. Chat Completions 모델과 Responses가 아닌 백엔드에서는 `ProgrammaticToolCallingTool()` 및 `tool_choice="programmatic_tool_calling"`이 거부됩니다. +- 에이전트에 `ProgrammaticToolCallingTool()`을 최대 하나 추가하세요. 에이전트는 프로그래밍 방식으로 호출할 수 있는 도구를 하나 이상 노출해야 하며, 네임스페이스, 지연된 함수 또는 지연된 호스티드 MCP 서버를 기반으로 하는 `ToolSearchTool()`이나 프롬프트로 관리되는 불투명한 도구 집합을 노출할 수도 있습니다. 검색 가능한 대상이 없는 단독 `ToolSearchTool()`은 거부됩니다. +- `allowed_callers`는 도구 호출 방식을 제어합니다. 이를 생략하면 모델의 직접 호출만 허용됩니다. 프로그램에서만 액세스하려면 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`을 사용하세요. +- 사용을 선택할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸, 패치 적용 도구는 `allowed_callers`를 직접 노출합니다. 호스티드 MCP와 코드 인터프리터에서는 `tool_config` 내부에 `allowed_callers`를 설정하세요. +- `@function_tool(allowed_callers=[...])`에서 Pydantic 모델, TypedDict 또는 데이터 클래스 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 값이 프로그램에 반환되기 전에 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없으면 `output_type=...`을 사용하고, 엄격한 객체 스키마가 이미 있다면 하위 수준의 우회 수단인 `output_json_schema={...}`를 사용하세요. `output_type`과 `output_json_schema`는 함께 사용할 수 없습니다. 일반 `str`, `Any`, `None` 반환은 유형이 지정되지 않은 상태로 유지됩니다. 스키마를 기반으로 하며 프로그램이 소유하는 호출에서는 자유 형식 텍스트가 출력 스키마를 충족하지 않으므로 기본 실패 포매터가 비활성화됩니다. 따라서 스키마를 준수하는 JSON을 반환하는 사용자 지정 `failure_error_function`을 제공하지 않으면 핸들러 예외가 전파됩니다. +- 프로그램이 소유하는 SDK 도구도 일반적인 Runner 수명 주기를 사용합니다. 도구 입력 및 출력 가드레일, 훅, 제한 시간, 동시성 제한, 승인, 세션, `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출과 프로그램 호출자 간의 관계를 보존합니다. +- `ProgrammaticToolCallingTool()`이 있으면 프로그램이 실행되기 전이라도 모델 요청 재시도에 더 엄격한 재실행 안전성 경계가 적용됩니다. SDK는 이러한 요청에 대해 공급자 관리형 재시도와 WebSocket 사전 이벤트 재시도를 비활성화합니다. Runner 재시도 정책은 공급자의 지침에서 재실행이 안전하다고 명시적으로 표시한 경우에만 재시도합니다. `retry_policies.network_error()`만으로는 이 경계를 재정의하지 않습니다. +- 승인이 중요하거나 영향이 큰 도구는 더 큰 프로그램의 일부가 되기 전에 사람이 각 작업을 검토할 수 있도록 직접 호출로 유지하는 것이 일반적으로 더 좋습니다. 프로그램이 소유하는 호출이 승인을 위해 일시 중지되면 `RunState`를 통해 인터럽션(중단 처리)을 해결하고 평소처럼 원래 실행을 재개하세요. +- 프로그래밍 방식 도구 호출은 [호스티드 도구 검색](#hosted-tool-search)과 결합할 수 있습니다. 생성된 프로그램이 지연된 도구를 호출하려면 모델이 먼저 해당 도구를 로드해야 합니다. +- `program` 항목과 프로그램이 소유하는 일반 하위 도구 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 이에 대응하는 `program_output`은 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 호스티드 MCP 승인 요청과 도구 카탈로그는 대신 특수 MCP 항목과 스트림 이벤트를 사용합니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items) 및 [스트리밍](streaming.md#run-item-event-names)을 참조하세요. +- 완전한 동시 실행 재고 계획 코드 예제는 `examples/tools/programmatic_tool_calling.py`를 참조하세요. - 공식 플랫폼 가이드: [프로그래밍 방식 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) -### 호스티드 컨테이너 셸 + 스킬 +### 호스티드 컨테이너 셸 및 스킬 -`ShellTool`은 OpenAI 호스티드 컨테이너 실행도 지원합니다. 모델이 로컬 런타임이 아닌 관리형 컨테이너에서 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. +`ShellTool`은 OpenAI 호스티드 컨테이너 실행도 지원합니다. 모델이 로컬 런타임 대신 관리형 컨테이너에서 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -215,52 +215,54 @@ result = await Runner.run( print(result.final_output) ``` -후속 실행에서 기존 컨테이너를 재사용하려면 `environment={"type": "container_reference", "container_id": "cntr_..."}`를 설정하세요. +이후 실행에서 기존 컨테이너를 재사용하려면 `environment={"type": "container_reference", "container_id": "cntr_..."}`를 설정하세요. 알아둘 사항: - 호스티드 셸은 Responses API 셸 도구를 통해 사용할 수 있습니다. -- `container_auto`는 요청을 위한 컨테이너를 프로비저닝하며, `container_reference`는 기존 컨테이너를 재사용합니다. -- `container_auto`에는 `file_ids`와 `memory_limit`도 포함할 수 있습니다. +- `container_auto`는 요청을 위한 컨테이너를 프로비저닝하고, `container_reference`는 기존 컨테이너를 재사용합니다. +- `container_auto`에는 `file_ids` 및 `memory_limit`도 포함할 수 있습니다. - `environment.skills`는 스킬 참조와 인라인 스킬 번들을 허용합니다. -- 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval`, `on_approval`을 설정하지 마세요. +- 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval` 또는 `on_approval`을 설정하지 마세요. - `network_policy`는 `disabled` 및 `allowlist` 모드를 지원합니다. -- 허용 목록 모드에서 `network_policy.domain_secrets`는 이름을 기준으로 도메인 범위의 보안 비밀을 주입할 수 있습니다. -- 완전한 코드 예제는 `examples/tools/container_shell_skill_reference.py`와 `examples/tools/container_shell_inline_skill.py`를 참조하세요. +- 허용 목록 모드에서 `network_policy.domain_secrets`는 이름을 기준으로 도메인 범위의 비밀 값을 주입할 수 있습니다. +- 완전한 코드 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참조하세요. - OpenAI 플랫폼 가이드: [셸](https://platform.openai.com/docs/guides/tools-shell) 및 [스킬](https://platform.openai.com/docs/guides/tools-skills) ## 로컬 런타임 도구 -로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 호출 시점은 여전히 모델이 결정하지만, 실제 작업은 애플리케이션이나 구성된 실행 환경에서 수행합니다. +로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 언제 호출할지는 계속 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. -`ComputerTool`과 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`은 두 모드를 모두 지원합니다. 관리형 실행을 원한다면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. +`ComputerTool`과 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`은 두 모드를 모두 지원합니다. 관리형 실행을 원하면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. 로컬 런타임 도구를 사용하려면 구현을 제공해야 합니다. -- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 사용하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현합니다. +- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 활성화하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현하세요. - [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행을 모두 지원하는 최신 셸 도구입니다. - [`LocalShellTool`][agents.tool.LocalShellTool]: 레거시 로컬 셸 통합입니다. -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현합니다. -- `ShellTool(environment={"type": "local", "skills": [...]})`을 사용하여 로컬 셸 스킬을 사용할 수 있습니다. +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: diff를 로컬에 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현하세요. +- 로컬 셸 스킬은 `ShellTool(environment={"type": "local", "skills": [...]})`에서 사용할 수 있습니다. -### ComputerTool과 Responses 컴퓨터 도구 +셸 작업 제한 시간에는 유한한 제한 시간을 나타내는 양의 정수 밀리초를 사용합니다. 0은 실행기 구현 전반에서 이식 가능한 의미가 없으므로, SDK는 로컬 `ShellTool` 실행기를 호출하기 전에 `0`과 `None`을 모두 명시적인 제한 시간이 없는 것으로 처리합니다. 다른 값은 실행기를 호출하기 전에 거부됩니다. 이는 제한 시간 필드에만 해당하며, `max_output_length=0`은 캡처된 빈 출력을 요청하는 값으로 계속 지원됩니다. -`ComputerTool`은 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 이 하네스를 OpenAI Responses API의 컴퓨터 표면에 매핑합니다. +### `ComputerTool`과 Responses 컴퓨터 도구 -명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델은 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`를 계속 사용합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션과 동일합니다. +`ComputerTool`은 계속 로컬 하네스 역할을 합니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API 컴퓨터 인터페이스에 매핑합니다. + +명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델은 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`를 유지합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. - 모델: `computer-use-preview` -> `gpt-5.5` - 도구 선택자: `computer_use_preview` -> `computer` -- 컴퓨터 호출 형식: 각 `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` -- 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 필요하지 않음 +- 컴퓨터 호출 형식: 각 `computer_call`에 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` +- 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 불필요 -SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하고 프롬프트가 모델을 소유하기 때문에 요청에서 `model`을 생략하면, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않는 한 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. +SDK는 실제 Responses 요청의 유효 모델을 기준으로 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하고 프롬프트가 모델을 소유하므로 요청에서 `model`을 생략하는 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않으면 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. -[`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 모두 허용되며 유효한 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. +[`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 모두 허용되며 유효 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. -`ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 기반으로 하는 경우 이 차이가 중요합니다. GA `computer` 페이로드는 직렬화 시점에 `environment` 또는 크기가 필요하지 않으므로 아직 해석되지 않은 팩토리도 사용할 수 있습니다. 프리뷰 호환 직렬화에서는 SDK가 `environment`, `display_width`, `display_height`를 전송할 수 있도록 해석된 `Computer` 또는 `AsyncComputer` 인스턴스가 필요합니다. +`ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩터리를 기반으로 할 때는 이 차이가 중요합니다. GA `computer` 페이로드는 직렬화 시 `environment` 또는 크기가 필요하지 않으므로 해결되지 않은 팩터리도 사용할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`를 전송할 수 있도록 해결된 `Computer` 또는 `AsyncComputer` 인스턴스가 계속 필요합니다. -런타임에서는 두 경로 모두 동일한 로컬 하네스를 계속 사용합니다. 프리뷰 응답은 하나의 `action`이 포함된 `computer_call` 항목을 내보냅니다. `gpt-5.5`는 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. Playwright 기반의 실행 가능한 하네스는 `examples/tools/computer_use.py`를 참조하세요. +런타임에서 두 경로는 계속 동일한 로컬 하네스를 사용합니다. 프리뷰 응답은 단일 `action`이 있는 `computer_call` 항목을 생성합니다. `gpt-5.5`는 일괄 처리된 `actions[]`를 생성할 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`를 참조하세요. ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -306,16 +308,16 @@ agent = Agent( 모든 Python 함수를 도구로 사용할 수 있습니다. Agents SDK가 도구를 자동으로 설정합니다. -- 도구 이름은 Python 함수의 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다 -- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 제공할 수 있습니다 -- 함수 입력 스키마는 함수의 인수에서 자동으로 생성됩니다 -- 비활성화하지 않는 한 각 입력에 대한 설명은 함수의 docstring에서 가져옵니다 +- 도구 이름은 Python 함수의 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다. +- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 제공할 수 있습니다. +- 함수 입력 스키마는 함수의 인수에서 자동으로 생성됩니다. +- 비활성화하지 않는 한 각 입력의 설명은 함수의 docstring에서 가져옵니다. -`@tool`로 생성한 도구는 읽기 전용 `__wrapped__` 속성을 통해 원래 Python 호출 가능 객체를 노출합니다. 이는 검사 및 테스트에 유용하지만, 직접 호출하면 스키마 검증, 컨텍스트 주입, 가드레일, 시간 제한, 실패 처리, 트레이싱을 포함한 도구 런타임 파이프라인을 우회합니다. 직접 구성한 `FunctionTool` 인스턴스는 `__wrapped__`를 노출하지 않습니다. +`@tool`로 생성된 도구는 읽기 전용 `__wrapped__` 속성을 통해 원래 Python 호출 가능 객체를 노출합니다. 이는 검사 및 테스트에 유용하지만, 직접 호출하면 스키마 검증, 컨텍스트 주입, 가드레일, 제한 시간, 실패 처리, 트레이싱을 포함한 도구 런타임 파이프라인을 우회합니다. 직접 생성한 `FunctionTool` 인스턴스는 `__wrapped__`를 노출하지 않습니다. -함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하며, docstring을 파싱하기 위해 [`griffe`](https://mkdocstrings.github.io/griffe/)를 사용하고 스키마 생성에는 `pydantic`을 사용합니다. +함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하고, docstring을 파싱하기 위해 [`griffe`](https://mkdocstrings.github.io/griffe/)를, 스키마 생성을 위해 `pydantic`을 사용합니다. -OpenAI Responses 모델을 사용할 때 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`이 로드할 때까지 함수 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정과 제약 조건은 [호스티드 툴 검색](#hosted-tool-search)을 참조하세요. +OpenAI Responses 모델을 사용할 때 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`이 로드할 때까지 함수 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정과 제약 조건은 [호스티드 도구 검색](#hosted-tool-search)을 참조하세요. ```python import json @@ -368,9 +370,9 @@ for tool in agent.tools: ``` -1. 모든 Python 유형을 함수의 인수로 사용할 수 있으며, 함수는 동기식 또는 비동기식일 수 있습니다. -2. docstring이 있으면 설명과 인수 설명을 추출하는 데 사용됩니다. -3. 함수는 선택적으로 `context`를 받을 수 있습니다. 이 인수는 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의 항목도 설정할 수 있습니다. +1. 모든 Python 유형을 함수의 인수로 사용할 수 있으며 함수는 동기 또는 비동기일 수 있습니다. +2. docstring이 있으면 설명과 인수 설명을 가져오는 데 사용됩니다. +3. 함수는 선택적으로 `context`를 받을 수 있으며 반드시 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의도 설정할 수 있습니다. 4. 데코레이팅된 함수를 도구 목록에 전달할 수 있습니다. ??? note "출력을 보려면 펼치기" @@ -445,20 +447,20 @@ for tool in agent.tools: ### 함수 도구의 이미지 또는 파일 반환 -텍스트 출력뿐만 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 다음 중 하나를 반환하면 됩니다. +텍스트 출력 외에도 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 다음 중 하나를 반환할 수 있습니다. - 이미지: [`ToolOutputImage`][agents.tool.ToolOutputImage] 또는 TypedDict 버전인 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict] - 파일: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent] 또는 TypedDict 버전인 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict] -- 텍스트: 문자열이나 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText] 또는 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] +- 텍스트: 문자열, 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText]나 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] ### 사용자 지정 함수 도구 -Python 함수를 도구로 사용하고 싶지 않은 경우도 있습니다. 원한다면 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. +Python 함수를 도구로 사용하지 않으려는 경우도 있습니다. 원한다면 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. - `name` - `description` -- 인수의 JSON 스키마인 `params_json_schema` -- [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형식의 인수를 받고 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` +- `params_json_schema`: 인수의 JSON 스키마 +- `on_invoke_tool`: [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형식의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수 ```python from typing import Any @@ -491,12 +493,12 @@ tool = FunctionTool( ) ``` -### 인수 및 docstring 자동 파싱 +### 자동 인수 및 docstring 파싱 -앞서 설명했듯이 도구의 스키마를 추출하기 위해 함수 시그니처를 자동으로 파싱하고, 도구 및 개별 인수의 설명을 추출하기 위해 docstring을 파싱합니다. 다음 사항을 참고하세요. +앞서 설명했듯이 도구 스키마를 추출하기 위해 함수 시그니처를 자동으로 파싱하고, 도구와 개별 인수의 설명을 추출하기 위해 docstring을 파싱합니다. 관련 참고 사항은 다음과 같습니다. -1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 유형 어노테이션을 사용하여 인수의 유형을 파악하고, 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 유형, Pydantic 모델, TypedDict 등을 포함한 대부분의 유형을 지원합니다. -2. docstring을 파싱하는 데 `griffe`를 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 시도하지만 완벽하지 않을 수 있으므로 `function_tool`을 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. Google 스타일 docstring의 경우 파서는 요약 텍스트 바로 다음에 빈 줄 없이 배치된 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 허용합니다. +1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용하여 인수 유형을 파악하고, 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 타입, Pydantic 모델, TypedDict 등을 포함한 대부분의 유형을 지원합니다. +2. docstring 파싱에는 `griffe`를 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 시도하지만 최선의 방식으로만 처리되므로, `function_tool`을 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. Google 스타일 docstring에서는 요약 텍스트 바로 뒤에 빈 줄 없이 나오는 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 파서가 허용합니다. 스키마 추출 코드는 [`agents.function_schema`][]에 있습니다. @@ -520,9 +522,9 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr return f"Score recorded: {score}" ``` -### 함수 도구 시간 제한 +### 함수 도구 제한 시간 -`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구의 호출별 시간 제한을 설정할 수 있습니다. +`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구의 호출별 제한 시간을 설정할 수 있습니다. ```python import asyncio @@ -543,13 +545,13 @@ agent = Agent( ) ``` -시간 제한에 도달하면 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델이 확인할 수 있는 시간 초과 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. +제한 시간에 도달했을 때의 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델이 볼 수 있는 제한 시간 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. -시간 초과 처리를 제어할 수 있습니다. +제한 시간 처리를 제어할 수 있습니다. -- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 시간 초과 메시지를 반환합니다. +- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 제한 시간 메시지를 반환합니다. - `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]를 발생시키고 실행을 실패 처리합니다. -- `timeout_error_function=...`: `error_as_result`를 사용할 때 시간 초과 메시지를 사용자 지정합니다. +- `timeout_error_function=...`: `error_as_result`를 사용할 때 제한 시간 메시지를 사용자 지정합니다. ```python import asyncio @@ -573,15 +575,15 @@ except ToolTimeoutError as e: !!! note - 시간 제한 구성은 비동기 `@function_tool` 핸들러에서만 지원됩니다. + 제한 시간 구성은 비동기 `@function_tool` 핸들러에만 지원됩니다. ### 함수 도구의 오류 처리 -`@function_tool`을 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이 함수는 도구 호출이 실패하는 경우 LLM에 오류 응답을 제공합니다. +`@function_tool`을 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 도구 호출이 중단되는 경우 이 함수가 LLM에 오류 응답을 제공합니다. -- 기본적으로 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`을 실행합니다. +- 기본적으로, 즉 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`을 실행합니다. - 자체 오류 함수를 전달하면 해당 함수를 대신 실행하고 응답을 LLM에 전송합니다. -- 명시적으로 `None`을 전달하면 도구 호출 오류가 다시 발생하며 사용자가 직접 처리해야 합니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`, 코드가 실패한 경우 `UserError` 등이 발생할 수 있습니다. +- `None`을 명시적으로 전달하면 모든 도구 호출 오류가 다시 발생하므로 직접 처리할 수 있습니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`일 수 있고, 코드가 중단된 경우 `UserError`일 수 있습니다. ```python from agents import RunContextWrapper @@ -605,11 +607,11 @@ def get_user_profile(user_id: str) -> str: ``` -`FunctionTool` 객체를 수동으로 생성하는 경우 `on_invoke_tool` 함수 내부에서 오류를 처리해야 합니다. +`FunctionTool` 객체를 수동으로 생성하는 경우 `on_invoke_tool` 함수 내에서 오류를 처리해야 합니다. ## Agents as tools -일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 agents as tools로 모델링하여 이를 구현할 수 있습니다. +일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 구성할 수 있습니다. 에이전트를 도구로 모델링하면 이를 구현할 수 있습니다. ```python import asyncio @@ -655,9 +657,9 @@ if __name__ == "__main__": ### 도구 에이전트 사용자 지정 -`agent.as_tool` 함수는 에이전트를 도구로 쉽게 변환할 수 있는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`를 통한 구조화된 입력도 지원합니다. +`agent.as_tool` 함수는 에이전트를 도구로 쉽게 변환할 수 있는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval` 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`를 사용하는 구조화된 입력도 지원합니다. -상태 옵션은 도구 호출로 시작되는 중첩 에이전트 실행을 구성하며, 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리형 기록을 공유하려면 동일한 `session`을 양쪽에 명시적으로 전달하세요. `Runner.run`과 마찬가지로 중첩 실행에는 하나의 상태 전략을 선택하세요. 클라이언트 관리형 `session`을 사용하거나 `previous_response_id` 또는 `conversation_id`를 통한 서버 관리형 연속 실행을 사용합니다. +상태 옵션은 도구 호출로 시작되는 중첩 에이전트 실행을 구성합니다. 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리형 기록을 공유하려면 두 실행에 동일한 `session`을 명시적으로 전달하세요. `Runner.run`과 마찬가지로 중첩 실행에는 클라이언트 관리형 `session` 또는 `previous_response_id`나 `conversation_id`를 통한 서버 관리형 연속 실행 중 하나의 상태 전략을 선택하세요. ```python from agents.decorators import tool @@ -681,13 +683,13 @@ async def run_my_agent() -> str: ### 도구 에이전트의 구조화된 입력 -기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 예상하지만, `parameters`에 Pydantic 모델 또는 dataclass 유형을 전달하여 구조화된 스키마를 노출할 수 있습니다. +기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 예상하지만, `parameters`에 Pydantic 모델 또는 데이터 클래스 유형을 전달하여 구조화된 스키마를 노출할 수 있습니다. 추가 옵션: -- `include_input_schema=True`는 생성된 중첩 입력에 전체 JSON Schema를 포함합니다. +- `include_input_schema=True`는 생성된 중첩 입력에 전체 JSON 스키마를 포함합니다. - `input_builder=...`를 사용하면 구조화된 도구 인수를 중첩 에이전트 입력으로 변환하는 방식을 완전히 사용자 지정할 수 있습니다. -- `RunContextWrapper.tool_input`은 중첩 실행 컨텍스트 내부에 파싱된 구조화 페이로드를 포함합니다. +- `RunContextWrapper.tool_input`에는 중첩 실행 컨텍스트 내부에서 파싱된 구조화 페이로드가 포함됩니다. ```python from pydantic import BaseModel, Field @@ -711,11 +713,11 @@ translator_tool = translator_agent.as_tool( ### 도구 에이전트의 승인 게이트 -`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요한 경우 실행이 일시 중지되고 보류 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`를 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 실행을 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참조하세요. +`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요하면 실행이 일시 중지되고 대기 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`를 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 가이드](human_in_the_loop.md)를 참조하세요. ### 사용자 지정 출력 추출 -특정한 경우 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 작업을 수행할 때 유용합니다. +경우에 따라 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 경우에 유용합니다. - 하위 에이전트의 채팅 기록에서 특정 정보(예: JSON 페이로드) 추출 - 에이전트의 최종 답변 변환 또는 형식 변경(예: Markdown을 일반 텍스트나 CSV로 변환) @@ -740,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 이는 중첩된 결과를 후처리할 때 외부 도구 이름, 호출 ID 또는 원문 인수가 필요한 경우 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참조하세요. +사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 이는 중첩 결과를 후처리하면서 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참조하세요. -### 중첩 에이전트 실행 스트리밍 +### 중첩 에이전트 실행의 스트리밍 -스트림이 완료되면 최종 출력을 반환하면서 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하려면 `on_stream` 콜백을 `as_tool`에 전달하세요. +스트림이 완료된 후에도 최종 출력을 반환하면서 중첩 에이전트가 생성하는 스트리밍 이벤트를 수신하려면 `on_stream` 콜백을 `as_tool`에 전달하세요. ```python from agents import AgentToolStreamEvent @@ -764,15 +766,15 @@ billing_agent_tool = billing_agent.as_tool( 예상 동작: -- 이벤트 유형은 `StreamEvent["type"]`을 반영합니다: `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event` -- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드로 실행되고 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. -- 핸들러는 동기식 또는 비동기식일 수 있으며, 각 이벤트는 도착하는 순서대로 전달됩니다. -- 모델 도구 호출을 통해 도구가 호출되면 `tool_call`이 존재합니다. 직접 호출에서는 `None`일 수 있습니다. -- 완전한 실행 가능 코드 예제는 `examples/agent_patterns/agents_as_tools_streaming.py`를 참조하세요. +- 이벤트 유형은 `StreamEvent["type"]`의 `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event`와 동일합니다. +- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드에서 실행되고, 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. +- 핸들러는 동기 또는 비동기일 수 있으며, 각 이벤트는 도착하는 순서대로 전달됩니다. +- 도구가 모델의 도구 호출을 통해 호출되면 `tool_call`이 존재합니다. 직접 호출에서는 `None`일 수 있습니다. +- 완전한 실행 가능 샘플은 `examples/agent_patterns/agents_as_tools_streaming.py`를 참조하세요. ### 조건부 도구 활성화 -`is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 기본 설정 또는 런타임 조건에 따라 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. +`is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 환경 설정 또는 런타임 조건에 따라 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. ```python import asyncio @@ -829,22 +831,22 @@ asyncio.run(main()) `is_enabled` 매개변수는 다음을 허용합니다. -- **불리언 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) -- **호출 가능 함수**: `(context, agent)`를 받고 불리언을 반환하는 함수 +- **부울 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) +- **호출 가능 함수**: `(context, agent)`를 받아 부울 값을 반환하는 함수 - **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 -비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 용도로 유용합니다. +비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 경우에 유용합니다. -- 사용자 권한 기반 기능 게이팅 +- 사용자 권한에 따른 기능 게이팅 - 환경별 도구 가용성(개발 환경과 프로덕션 환경) -- 서로 다른 도구 구성의 A/B 테스트 -- 런타임 상태 기반 동적 도구 필터링 +- 다양한 도구 구성의 A/B 테스트 +- 런타임 상태에 따른 동적 도구 필터링 ## 실험적 기능: Codex 도구 -`codex_tool`은 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 작업 공간 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있도록 합니다. 이 기능 표면은 실험적이며 변경될 수 있습니다. +`codex_tool`은 에이전트가 도구 호출 중 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있도록 Codex CLI를 래핑합니다. 이 인터페이스는 실험적이며 변경될 수 있습니다. -메인 에이전트가 현재 실행을 벗어나지 않고 제한된 작업 공간 작업을 Codex에 위임하도록 하려면 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 이름은 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함된 경우 각 도구는 고유한 이름을 사용해야 합니다. +기본 에이전트가 현재 실행을 벗어나지 않고 범위가 제한된 워크스페이스 작업을 Codex에 위임하도록 하려면 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함되면 각 도구에 고유한 이름을 사용해야 합니다. ```python from agents import Agent @@ -875,31 +877,31 @@ agent = Agent( 다음 옵션 그룹부터 시작하세요. -- 실행 표면: `sandbox_mode`와 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고, 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`를 설정하세요. -- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 수준, 승인 정책, 추가 디렉터리, 네트워크 접근, 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`를 사용하세요. -- 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal`과 같은 턴별 동작을 구성합니다. -- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }` 형식의 `inputs` 항목이 하나 이상 포함되어야 합니다. `output_schema`를 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. +- 실행 대상: `sandbox_mode`와 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`를 설정하세요. +- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 수준, 승인 정책, 추가 디렉터리, 네트워크 액세스, 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`를 사용하는 것이 좋습니다. +- 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal` 같은 턴별 동작을 구성합니다. +- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }`가 포함된 `inputs` 항목이 하나 이상 있어야 합니다. `output_schema`를 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. -스레드 재사용과 지속성은 별도의 제어 항목입니다. +스레드 재사용과 영속성은 별도의 제어 옵션입니다. - `persist_session=True`는 동일한 도구 인스턴스를 반복 호출할 때 하나의 Codex 스레드를 재사용합니다. -- `use_run_context_thread_id=True`는 동일한 가변 컨텍스트 객체를 공유하는 여러 실행에서 스레드 ID를 실행 컨텍스트에 저장하고 재사용합니다. +- `use_run_context_thread_id=True`는 동일한 변경 가능 컨텍스트 객체를 공유하는 여러 실행에서 스레드 ID를 실행 컨텍스트에 저장하고 재사용합니다. - 스레드 ID 우선순위는 호출별 `thread_id`, 실행 컨텍스트 스레드 ID(활성화된 경우), 구성된 `thread_id` 옵션 순입니다. -- 기본 실행 컨텍스트 키는 `name="codex"`일 때 `codex_thread_id`이고, `name="codex_"`일 때 `codex_thread_id_`입니다. `run_context_thread_id_key`로 재정의할 수 있습니다. +- 기본 실행 컨텍스트 키는 `name="codex"`의 경우 `codex_thread_id`이고, `name="codex_"`의 경우 `codex_thread_id_`입니다. `run_context_thread_id_key`를 사용하여 재정의할 수 있습니다. 런타임 구성: -- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`를 전달합니다. +- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`를 전달하세요. - 런타임: `codex_options.base_url`은 CLI 기본 URL을 재정의합니다. -- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`를 설정합니다. 그렇지 않으면 SDK는 `PATH`에서 `codex`를 확인한 다음, 찾지 못하면 번들로 제공되는 벤더 바이너리를 사용합니다. -- 환경: `codex_options.env`는 하위 프로세스 환경을 완전히 제어합니다. 이를 제공하면 하위 프로세스가 `os.environ`을 상속하지 않습니다. -- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`에서 `67108864`이며, 기본값은 `8388608`입니다. +- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`를 설정하세요. 그렇지 않으면 SDK는 `PATH`에서 `codex`를 확인한 후 번들로 제공되는 벤더 바이너리를 사용합니다. +- 환경: `codex_options.env`는 하위 프로세스 환경을 완전히 제어합니다. 이를 제공하면 하위 프로세스는 `os.environ`을 상속하지 않습니다. +- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`부터 `67108864`까지이며 기본값은 `8388608`입니다. - 스트리밍: `on_stream`은 스레드/턴 수명 주기 이벤트와 항목 이벤트(`reasoning`, `command_execution`, `mcp_tool_call`, `file_change`, `web_search`, `todo_list`, `error` 항목 업데이트)를 수신합니다. -- 출력: 결과에는 `response`, `usage`, `thread_id`가 포함되며, 사용량은 `RunContextWrapper.usage`에 추가됩니다. +- 출력: 결과에는 `response`, `usage`, `thread_id`가 포함되며 사용량은 `RunContextWrapper.usage`에 추가됩니다. 참조: - [Codex 도구 API 레퍼런스](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions 레퍼런스](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions 레퍼런스](ref/extensions/experimental/codex/turn_options.md) -- 완전한 실행 가능 코드 예제는 `examples/tools/codex.py`와 `examples/tools/codex_same_thread.py`를 참조하세요. \ No newline at end of file +- 완전한 실행 가능 샘플은 `examples/tools/codex.py` 및 `examples/tools/codex_same_thread.py`를 참조하세요. \ No newline at end of file diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index 730e832a40..43a4f5f2bc 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -4,75 +4,77 @@ search: --- # 安全防护措施 -安全防护措施使你能够对用户输入和智能体输出进行检查和验证。例如,假设你有一个智能体使用非常智能(因而较慢/昂贵)的模型来帮助处理客户请求。你不会希望恶意用户要求模型帮他们做数学作业。因此,你可以使用快速/便宜的模型运行安全防护措施。如果安全防护措施检测到恶意使用,它可以立即引发错误,并阻止昂贵的模型运行,从而节省时间和成本(**当使用阻塞式安全防护措施时;对于并行安全防护措施,昂贵的模型可能已经在安全防护措施完成之前开始运行。详见下方“执行模式”**)。 +安全防护措施可用于检查和验证用户输入与智能体输出。例如,假设你有一个使用非常智能(因而速度较慢且成本较高)的模型来协助处理客户请求的智能体。你肯定不希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用速度较快、成本较低的模型运行安全防护措施。如果安全防护措施检测到恶意使用,它可以立即引发错误并阻止高成本模型运行,从而节省时间和费用(**使用阻塞式安全防护措施时如此;对于并行安全防护措施,高成本模型可能在安全防护措施运行完毕前就已开始运行。有关详情,请参阅下文的“执行模式”**)。 -安全防护措施有两种: +安全防护措施分为两类: -1. 输入安全防护措施会在初始用户输入上运行 -2. 输出安全防护措施会在最终智能体输出上运行 +1. 输入安全防护措施针对初始用户输入运行 +2. 输出安全防护措施针对智能体的最终输出运行 ## 工作流边界 -安全防护措施会附加到智能体和工具上,但它们并不会全都在工作流中的同一位置运行: +安全防护措施会附加到智能体和工具,但它们并非都在工作流中的相同节点运行: -- **输入安全防护措施**仅对链中的第一个智能体运行。 -- **输出安全防护措施**仅对生成最终输出的智能体运行。 -- **工具安全防护措施**会在每次自定义函数工具调用时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 +- **输入安全防护措施**仅针对链中的第一个智能体运行。 +- **输出安全防护措施**仅针对生成最终输出的智能体运行。 +- **工具安全防护措施**会在每次调用自定义工具调用时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 -如果你的工作流包含管理者、任务转移或委托的专家,并且需要对每次自定义函数工具调用进行检查,请使用工具安全防护措施,而不要只依赖智能体级别的输入/输出安全防护措施。 +如果需要检查包含管理器、任务转移或受委派专家的工作流中的每次自定义工具调用,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 ## 输入安全防护措施 -输入安全防护措施分 3 步运行: +输入安全防护措施分 3 个步骤运行: -1. 首先,安全防护措施会接收传递给智能体的相同输入。 -2. 接下来,安全防护措施函数会运行并生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],该输出随后会包装为 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你可以适当地回应用户或处理该异常。 +1. 首先,安全防护措施接收传递给智能体的相同输入。 +2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 -!!! Note +!!! 注意 - 输入安全防护措施旨在针对用户输入运行,因此只有当智能体是*第一个*智能体时,该智能体的安全防护措施才会运行。你可能会疑惑,为什么 `guardrails` 属性在智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施往往与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将代码放在一起有助于可读性。 + 输入安全防护措施旨在针对用户输入运行,因此仅当某个智能体是*第一个*智能体时,其安全防护措施才会运行。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 ### 执行模式 输入安全防护措施支持两种执行模式: -- **并行执行**(默认,`run_in_parallel=True`):安全防护措施会与智能体的执行并发运行。这能提供最佳延迟,因为二者会同时启动。不过,如果安全防护措施失败,智能体在被取消之前可能已经消耗了 token 并执行了工具。 +- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体同时执行。由于二者同时启动,这种模式可实现最低延迟。但是,如果安全防护措施未通过,智能体在被取消之前可能已经消耗了 token 并执行了工具。 -- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的警戒线被触发,智能体就永远不会执行,从而避免 token 消耗和工具执行。这非常适合成本优化,以及你希望避免工具调用可能产生副作用的场景。 +- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将完全不会执行,从而避免 token 消耗和工具执行。这非常适合成本优化,以及希望避免工具调用产生潜在副作用的场景。 ## 输出安全防护措施 -输出安全防护措施分 3 步运行: +输出安全防护措施分 3 个步骤运行: -1. 首先,安全防护措施会接收智能体生成的输出。 -2. 接下来,安全防护措施函数会运行并生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],该输出随后会包装为 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你可以适当地回应用户或处理该异常。 +1. 首先,安全防护措施接收智能体生成的输出。 +2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 -!!! Note +!!! 注意 - 输出安全防护措施旨在针对最终智能体输出运行,因此只有当智能体是*最后一个*智能体时,该智能体的安全防护措施才会运行。与输入安全防护措施类似,我们这样做是因为安全防护措施往往与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将代码放在一起有助于可读性。 + 输出安全防护措施旨在针对智能体的最终输出运行,因此仅当某个智能体是*最后一个*智能体时,其安全防护措施才会运行。与输入安全防护措施类似,我们这样做是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 - 输出安全防护措施总是在智能体完成后运行,因此它们不支持 `run_in_parallel` 参数。 + 输出安全防护措施始终在智能体完成运行后执行,因此不支持 `run_in_parallel` 参数。 ## 工具安全防护措施 -工具安全防护措施会封装**工具调用**,并允许你在执行前后验证或阻止工具调用。它们配置在工具本身上,并在每次调用该工具时运行。 +工具安全防护措施会封装**工具调用**,使你能够在执行前后验证或阻止工具调用。它们在工具本身上配置,并在每次调用该工具时运行。 -- 输入工具安全防护措施会在工具执行前运行,可以跳过调用、用一条消息替换输出,或触发警戒线。 -- 输出工具安全防护措施会在工具执行后运行,可以替换输出或触发警戒线。 -- 如果某个函数工具需要审批,输入工具安全防护措施通常会在审批之后、执行之前立即运行。当你希望这些输入检查在发出待审批中断之前运行时,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此预审批检查的调用,在审批之后、工具执行之前仍会再次接受检查。 -- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的函数工具。任务转移会经过 SDK 的任务转移管道,而不是常规函数工具管道,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管道,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不直接暴露工具安全防护措施选项。 +- 输入工具安全防护措施在工具执行前运行,可以跳过调用、用消息替换输出或引发触发器。 +- 输出工具安全防护措施在工具执行后运行,可以替换输出或引发触发器。 +- 如果工具调用需要批准,输入工具安全防护措施通常会在获得批准后、执行前立即运行。如果希望在发出待批准中断前运行这些输入检查,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此批准前检查的调用仍会在获得批准后、工具执行前再次接受检查。 +- 工具安全防护措施仅适用于通过 [`function_tool`][agents.tool.function_tool] 创建的工具调用。任务转移通过 Agents SDK的任务转移管道运行,而不是通过常规的工具调用管道运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管道,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不直接提供工具安全防护措施选项。 -详情请参阅下面的代码片段。 +有关详情,请参阅下面的代码片段。 -## 警戒线 +## 触发器 -如果输入或输出未通过安全防护措施,安全防护措施可以通过警戒线发出信号。一旦我们发现某个安全防护措施触发了警戒线,就会立即引发 `{Input,Output}GuardrailTripwireTriggered` 异常并停止智能体执行。 +如果输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。一旦发现某项安全防护措施触发了触发器,我们会立即引发 `{Input,Output}GuardrailTripwireTriggered` 异常并停止智能体执行。 + +异常的 `guardrail_result` 可标识触发了触发器的安全防护措施。对于由运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的每项输入安全防护措施结果,包括触发了触发器的结果。在 `stream_events()` 引发异常后,流式传输结果会通过 `input_guardrail_results` 提供同一组累积结果。如果异常是在运行器管理的执行路径之外引发的,`run_data` 可以为 `None`。 ## 安全防护措施的实现 -你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在这个示例中,我们会通过在底层运行一个智能体来实现这一点。 +你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在底层通过运行智能体来实现这一点。 ```python from pydantic import BaseModel @@ -125,12 +127,12 @@ async def main(): print("Math homework guardrail tripped") ``` -1. 我们将在安全防护措施函数中使用这个智能体。 -2. 这是安全防护措施函数,它接收智能体的输入/上下文并返回结果。 +1. 我们将在安全防护措施函数中使用此智能体。 +2. 这是接收智能体输入/上下文并返回结果的安全防护措施函数。 3. 我们可以在安全防护措施结果中包含额外信息。 4. 这是定义工作流的实际智能体。 -输出安全防护措施类似。 +输出安全防护措施与之类似。 ```python from pydantic import BaseModel @@ -185,7 +187,7 @@ async def main(): 1. 这是实际智能体的输出类型。 2. 这是安全防护措施的输出类型。 -3. 这是安全防护措施函数,它接收智能体的输出并返回结果。 +3. 这是接收智能体输出并返回结果的安全防护措施函数。 4. 这是定义工作流的实际智能体。 最后,以下是工具安全防护措施的示例。 diff --git a/docs/zh/index.md b/docs/zh/index.md index bf36994982..f0a4653ef8 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -4,51 +4,52 @@ search: --- # OpenAI Agents SDK -[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)让你能够使用轻量、易用且抽象很少的包来构建智能体式 AI 应用。它是我们此前智能体实验项目[Swarm](https://github.com/openai/swarm/tree/main)的生产就绪升级版。Agents SDK包含一组非常小的基本组件: +[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)让你能够通过一个轻量、易用且仅包含少量抽象概念的软件包构建智能体式 AI 应用。它是我们此前智能体实验项目[Swarm](https://github.com/openai/swarm/tree/main)面向生产环境的升级版本。Agents SDK仅包含一组非常精简的基本组件: -- **智能体**,即配备指令和工具的 LLM +- **智能体**,即配备指令和工具的LLM - **Agents as tools / 任务转移**,允许智能体将特定任务委派给其他智能体 -- **安全防护措施**,支持对智能体输入和输出进行验证 +- **安全防护措施**,用于验证智能体的输入和输出 -结合 Python,这些基本组件足以表达工具与智能体之间的复杂关系,并让你无需陡峭的学习曲线即可构建真实世界的应用。此外,SDK 内置**追踪**,可用于可视化和调试你的智能体式流程,也可用于评估这些流程,甚至为你的应用微调模型。 +这些基本组件与 Python 结合后,足以表达工具与智能体之间的复杂关系,让你无需经历陡峭的学习曲线即可构建实际应用。此外,SDK 还内置了**追踪**功能,可用于可视化和调试智能体流程、对其进行评估,甚至针对你的应用微调模型。 -## 使用 Agents SDK 的理由 +## 使用Agents SDK的理由 -SDK 有两个核心设计原则: +SDK 遵循两项核心设计原则: -1. 功能足够实用,但基本组件足够少,便于快速学习。 -2. 开箱即用,同时可以精确自定义运行方式。 +1. 提供足够丰富、值得使用的功能,同时保持基本组件精简,以便快速上手。 +2. 开箱即用,同时允许你精确自定义具体行为。 -以下是 SDK 的主要功能: +SDK 的主要功能包括: -- **智能体循环**:内置智能体循环,可处理工具调用、将结果发送回 LLM,并持续运行直到任务完成。 -- **Python 优先**:使用内置语言特性来编排和串联智能体,而无需学习新的抽象。 +- **智能体**:使用指令、工具、安全防护措施和任务转移构建智能体,并通过内置循环持续运行,直至任务完成。 +- **沙箱智能体**:在真实的隔离工作区中运行专业智能体,支持由清单定义的文件、沙箱客户端选择,以及可恢复的沙箱会话。 +- **实时智能体**:使用`gpt-realtime-2.1`构建强大的语音智能体,支持自动中断检测、上下文管理、安全防护措施等功能。 +- **语音智能体**:构建结合语音转文本、智能体工作流和文本转语音的语音管线。 +- **Python 优先**:使用内置语言功能编排和串联智能体,无需学习新的抽象概念。 - **Agents as tools / 任务转移**:一种强大的机制,用于在多个智能体之间协调和委派工作。 -- **沙盒智能体**:在真实隔离工作区中运行专家智能体,支持由清单定义的文件、沙盒客户端选择以及可恢复的沙盒会话。 -- **安全防护措施**:与智能体执行并行运行输入验证和安全检查,并在检查未通过时快速失败。 -- **工具调用**:将任何 Python 函数转换为工具,自动生成 schema,并通过 Pydantic 进行验证。 -- **MCP 服务工具调用**:内置 MCP 服务工具集成,其工作方式与工具调用相同。 -- **会话**:用于在智能体循环中维护工作上下文的持久记忆层。 -- **人在回路**:内置机制,用于在智能体运行过程中引入人工参与。 -- **追踪**:内置追踪,用于可视化、调试和监控工作流,并支持 OpenAI 的评估、微调和蒸馏工具套件。 -- **实时智能体**:使用 `gpt-realtime-2.1`、自动中断检测、上下文管理、安全防护措施等构建强大的语音智能体。 +- **安全防护措施**:在智能体执行的同时并行运行输入验证和安全检查,并在检查未通过时快速失败。 +- **工具调用**:将任意 Python 函数转换为工具,并自动生成模式,同时使用 Pydantic 进行验证。 +- **MCP服务工具调用**:内置MCP服务工具集成,使用方式与工具调用相同。 +- **会话**:一种持久化记忆层,用于在智能体循环中维护工作上下文。 +- **人在回路**:内置在多次智能体运行中引入人工参与的机制。 +- **追踪**:内置追踪功能,用于可视化、调试和监控工作流,并支持OpenAI的一整套评估、微调和蒸馏工具。 -## Agents SDK 与 Responses API 的选择 +## Agents SDK与Responses API的选择 -SDK 默认将 Responses API 用于 OpenAI模型,但它在模型调用之上增加了更高层的运行时。 +对于OpenAI模型,SDK 默认使用 Responses API,但在模型调用之外增加了更高级别的运行时。 -在以下情况下直接使用 Responses API: +以下情况可直接使用 Responses API: -- 你希望自行掌控循环、工具分派和状态处理 -- 你的工作流生命周期较短,且主要目标是返回模型响应 +- 你希望自行控制循环、工具分派和状态处理 +- 你的工作流生命周期较短,主要目标是返回模型响应 -在以下情况下使用 Agents SDK: +以下情况可使用Agents SDK: - 你希望由运行时管理轮次、工具执行、安全防护措施、任务转移或会话 -- 你的智能体需要生成产物,或跨多个协调步骤运行 -- 你需要通过[沙盒智能体](sandbox_agents.md)获得真实工作区或可恢复执行 +- 你的智能体需要生成产物,或通过多个协调步骤执行操作 +- 你需要通过[沙箱智能体](sandbox_agents.md)使用真实工作区或可恢复执行 -你不必在全局范围内二选一。许多应用会使用 SDK 处理托管工作流,并在较底层路径中直接调用 Responses API。 +你不必在整个应用中只选择其中一种。许多应用使用 SDK 处理受管理的工作流,并针对较底层的路径直接调用 Responses API。 ## 安装 @@ -56,7 +57,7 @@ SDK 默认将 Responses API 用于 OpenAI模型,但它在模型调用之上增 pip install openai-agents ``` -## Hello world 示例 +## Hello world示例 ```python from agents import Agent, Runner @@ -71,31 +72,31 @@ print(result.final_output) # Infinite loop's dance. ``` -_(如果运行此示例,请确保设置 `OPENAI_API_KEY` 环境变量)_ +(_运行此示例时,请确保已设置`OPENAI_API_KEY`环境变量_) ```bash export OPENAI_API_KEY=sk-... ``` -## 入门起点 +## 入门指南 -- 通过[快速入门](quickstart.md)构建你的第一个基于文本的智能体。 -- 然后在[运行智能体](running_agents.md#choose-a-memory-strategy)中决定如何在多个轮次之间保留状态。 -- 如果任务依赖真实文件、代码仓库或每个智能体隔离的工作区状态,请阅读[沙盒智能体快速入门](sandbox_agents.md)。 -- 如果你正在任务转移和管理器式编排之间做选择,请阅读[智能体编排](multi_agent.md)。 +- 通过[快速入门](quickstart.md)构建你的第一个文本智能体。 +- 然后在[运行智能体](running_agents.md#choose-a-memory-strategy)中确定如何跨轮次保留状态。 +- 如果任务依赖真实文件、代码仓库或每个智能体独立的工作区状态,请阅读[沙箱智能体快速入门](sandbox_agents.md)。 +- 如果你正在任务转移与管理器式编排之间进行选择,请阅读[智能体编排](multi_agent.md)。 ## 路径选择 -当你知道想完成的工作,但不知道应查看哪个页面时,请使用此表。 +当你知道要完成什么工作,但不确定哪个页面提供相关说明时,可使用下表。 -| 目标 | 从这里开始 | +| 目标 | 入门页面 | | --- | --- | -| 构建第一个文本智能体,并查看一次完整运行 | [快速入门](quickstart.md) | -| 添加工具调用、托管工具或 Agents as tools | [工具](tools.md) | -| 在真实隔离工作区中运行编码、审查或文档智能体 | [沙盒智能体快速入门](sandbox_agents.md)和[沙盒客户端](sandbox/clients.md) | -| 在任务转移和管理器式编排之间做选择 | [智能体编排](multi_agent.md) | -| 在多个轮次之间保留记忆 | [运行智能体](running_agents.md#choose-a-memory-strategy)和[会话](sessions/index.md) | -| 使用 OpenAI模型、websocket 传输或非 OpenAI提供商 | [模型](models/index.md) | +| 构建第一个文本智能体并查看一次完整运行 | [快速入门](quickstart.md) | +| 添加工具调用、托管工具或Agents as tools | [工具](tools.md) | +| 在真实的隔离工作区中运行编码、审查或文档智能体 | [沙箱智能体快速入门](sandbox_agents.md)和[沙箱客户端](sandbox/clients.md) | +| 在任务转移与管理器式编排之间进行选择 | [智能体编排](multi_agent.md) | +| 跨轮次保留记忆 | [运行智能体](running_agents.md#choose-a-memory-strategy)和[会话](sessions/index.md) | +| 使用OpenAI模型、WebSocket 传输或非OpenAI提供商 | [模型](models/index.md) | | 查看输出、运行项、中断和恢复状态 | [结果](results.md) | -| 使用 `gpt-realtime-2.1` 构建低延迟语音智能体 | [实时智能体快速入门](realtime/quickstart.md)和[实时传输](realtime/transport.md) | -| 构建语音转文本 / 智能体 / 文本转语音流水线 | [语音流水线快速入门](voice/quickstart.md) | \ No newline at end of file +| 使用`gpt-realtime-2.1`构建低延迟语音智能体 | [实时智能体快速入门](realtime/quickstart.md)和[实时传输](realtime/transport.md) | +| 构建语音转文本 / 智能体 / 文本转语音管线 | [语音管线快速入门](voice/quickstart.md) | \ No newline at end of file diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md index 5591c02205..4c5e6c97f9 100644 --- a/docs/zh/mcp.md +++ b/docs/zh/mcp.md @@ -4,34 +4,35 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)对应用程序向语言模型提供工具和上下文的方式进行了标准化。官方文档中的说明如下: +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)对应用如何向语言模型公开工具和 +上下文进行了标准化。官方文档对此说明如下: -> MCP是一种开放协议,用于标准化应用程序向LLM提供上下文的方式。可以将MCP视为AI -> 应用程序的USB-C端口。正如USB-C提供了一种将设备连接到各种外围设备和配件的标准化方式,MCP -> 也提供了一种将AI模型连接到不同数据源和工具的标准化方式。 +> MCP是一种开放协议,对应用如何向LLM提供上下文进行了标准化。可以将MCP视为 AI +> 应用的 USB-C 端口。正如 USB-C 提供了一种标准化方式,用于将设备连接到各种外设和配件,MCP +> 也提供了一种标准化方式,用于将 AI 模型连接到不同的数据源和工具。 -Agents Python SDK支持多种MCP传输方式。这样,你就可以复用现有MCP服务,也可以构建自己的MCP服务,从而向智能体提供由文件系统、HTTP或连接器支持的工具。 +Agents Python SDK支持多种MCP传输方式。这样,您可以复用现有MCP服务,也可以构建自己的服务,以向智能体公开由文件系统、HTTP 或连接器支持的工具。 !!! warning "连接MCP服务前的信任要求" - MCP工具可以公开模型上下文中的数据,并使用你提供的凭据执行操作。请仅连接你信任的服务、使用最小权限凭据、将访问令牌放在授权字段或请求头中而非URL中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 + MCP工具可以公开模型上下文中的数据,并使用您提供的凭据执行操作。请仅连接您信任的服务,使用最小权限凭据,将访问令牌放在授权字段或标头中而非 URL 中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 -## MCP集成方案选择 +## MCP集成方式的选择 -在将MCP服务接入智能体之前,应先确定工具调用的执行位置,以及可访问的传输方式。下表总结了Python SDK支持的选项。 +在将MCP服务接入智能体之前,请确定工具调用应在何处执行,以及您可以访问哪些传输方式。下表概述了 Python SDK支持的选项。 -| 需求 | 推荐选项 | +| 您的需求 | 推荐选项 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| 让OpenAI的Responses API代表模型调用可公开访问的MCP服务| 通过[`HostedMCPTool`][agents.tool.HostedMCPTool]使用**托管式MCP服务工具** | -| 连接到你在本地或远程运行的Streamable HTTP服务 | 通过[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]使用**Streamable HTTP MCP服务** | -| 与实现了采用服务端发送事件的HTTP协议的服务通信 | 通过[`MCPServerSse`][agents.mcp.server.MCPServerSse]使用**采用SSE的HTTP MCP服务** | -| 启动本地进程并通过stdin/stdout通信 | 通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]使用**stdio MCP服务** | +| 让OpenAI的 Responses API 代表模型调用可公开访问的MCP服务| 通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 使用**托管式MCP服务工具** | +| 连接到您在本地或远程运行的可流式传输 HTTP 服务 | 通过 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 使用**可流式传输 HTTP 的MCP服务** | +| 与实现带 Server-Sent Events 的 HTTP 服务通信 | 通过 [`MCPServerSse`][agents.mcp.server.MCPServerSse] 使用**带 SSE 的 HTTP MCP服务** | +| 启动本地进程并通过 stdin/stdout 通信 | 通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] 使用**stdio MCP服务** | 以下各节将逐一介绍每个选项、配置方式,以及何时应优先选择某种传输方式。 ## 智能体级MCP配置 -除了选择传输方式外,你还可以通过设置`Agent.mcp_config`来调整MCP工具的准备方式。 +除选择传输方式外,您还可以通过设置 `Agent.mcp_config` 调整MCP工具的准备方式。 ```python from agents import Agent @@ -53,31 +54,31 @@ agent = Agent( 注意事项: -- `convert_schemas_to_strict`采用尽力而为的方式。如果某个模式无法转换,则使用原始模式。 -- `failure_error_function`控制如何将MCP工具调用失败呈现给模型。 -- 未设置`failure_error_function`时,SDK会使用默认的工具错误格式化程序。 -- 服务级`failure_error_function`会覆盖该服务的`Agent.mcp_config["failure_error_function"]`。 -- `include_server_in_tool_names`需要选择启用。启用后,每个本地MCP工具都会以带有确定性服务前缀的名称提供给模型,有助于避免多个MCP服务发布同名工具时发生冲突。生成的名称符合ASCII安全要求,不会超过工具调用名称的长度限制,并会避开同一智能体中已有的本地工具调用名称和已启用的任务转移名称。SDK仍会在原始服务上调用原始MCP工具名称。 +- `convert_schemas_to_strict` 会尽力执行转换。如果无法转换某个模式,则使用原始模式。 +- `failure_error_function` 控制如何向模型呈现MCP工具调用失败。 +- 未设置 `failure_error_function` 时,SDK会使用默认的工具错误格式化程序。 +- 服务级 `failure_error_function` 会覆盖该服务的 `Agent.mcp_config["failure_error_function"]`。 +- `include_server_in_tool_names` 需要主动启用。启用后,每个本地MCP工具都会使用带有确定性服务前缀的名称向模型公开,这有助于避免多个MCP服务发布同名工具时出现冲突。生成的名称符合 ASCII 安全要求,不会超过工具调用名称长度限制,并会避开同一智能体上现有的本地工具调用名称和已启用的任务转移名称。SDK仍会在原服务上调用原始MCP工具名称。 ## 各传输方式的通用模式 -选择传输方式后,大多数集成都需要做出相同的后续决策: +选择传输方式后,大多数集成还需要做出以下相同的后续决策: -- 如何仅公开工具的子集([工具筛选](#tool-filtering))。 +- 如何仅公开部分工具([工具筛选](#tool-filtering))。 - 服务是否还提供可复用的提示词([提示词](#prompts))。 -- 是否应缓存`list_tools()`([缓存](#caching))。 -- MCP活动如何显示在追踪记录中([追踪](#tracing))。 +- 是否应缓存 `list_tools()`([缓存](#caching))。 +- 如何在追踪记录中呈现MCP活动([追踪](#tracing))。 -对于本地MCP服务(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的`_meta`载荷也是通用概念。Streamable HTTP一节提供了最完整的示例,相同模式也适用于其他本地传输方式。 +对于本地MCP服务(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的 `_meta` 载荷也是通用概念。可流式传输 HTTP 一节展示了最完整的代码示例,相同模式也适用于其他本地传输方式。 ## 1. 托管式MCP服务工具 -托管工具会将整个工具往返流程交由OpenAI的基础设施处理。你的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool]会将服务标签(以及可选的连接器元数据)转发给Responses API。模型会列出远程服务的工具并调用它们,无需额外回调你的Python进程。托管工具目前适用于支持Responses API托管式MCP集成的OpenAI模型。 +托管工具会将整个工具往返流程交由OpenAI基础设施处理。您的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool] 会将服务标签(以及可选的连接器元数据)转发给 Responses API。模型会列出远程服务的工具并调用它们,无需额外回调您的 Python 进程。托管工具目前适用于支持 Responses API 托管式MCP集成的OpenAI模型。 ### 基础托管式MCP工具 -将[`HostedMCPTool`][agents.tool.HostedMCPTool]添加到智能体的`tools`列表中,即可创建托管工具。`tool_config` -字典对应于你会发送给REST API的JSON: +将 [`HostedMCPTool`][agents.tool.HostedMCPTool] 添加到智能体的 `tools` 列表,即可创建托管工具。`tool_config` +字典对应您将发送给 REST API 的 JSON: ```python import asyncio @@ -109,14 +110,14 @@ async def main() -> None: asyncio.run(main()) ``` -托管式服务会自动公开其工具;无需将其添加到`mcp_servers`。 +托管式服务会自动公开其工具;您无需将其添加到 `mcp_servers`。 -如果希望托管工具搜索延迟加载托管式MCP服务,请设置`tool_config["defer_loading"] = True`,并将[`ToolSearchTool`][agents.tool.ToolSearchTool]添加到智能体。此功能仅支持OpenAI Responses模型。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 +如果您希望托管工具搜索延迟加载托管式MCP服务,请设置 `tool_config["defer_loading"] = True`,并将 [`ToolSearchTool`][agents.tool.ToolSearchTool] 添加到智能体。此功能仅受OpenAI Responses模型支持。有关完整的工具搜索配置和限制,请参阅[工具](tools.md#hosted-tool-search)。 ### 托管式MCP结果的流式传输 -托管工具对流式结果的支持方式与工具调用完全相同。使用`Runner.run_streamed`可以在模型仍在工作时 -接收增量MCP输出: +托管工具支持流式传输结果,方式与工具调用完全相同。使用 `Runner.run_streamed` +可在模型仍在工作时接收增量MCP输出: ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -128,7 +129,7 @@ print(result.final_output) ### 可选审批流程 -如果服务可以执行敏感操作,你可以要求在每次执行工具前进行人工或程序化审批。在`tool_config`中配置`require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。如需在Python中做出决定,请提供`on_approval_request`回调。 +如果服务可以执行敏感操作,您可以要求在每次工具执行前进行人工或程序化审批。在 `tool_config` 中配置 `require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。若要在 Python 中做出决定,请提供 `on_approval_request` 回调。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -156,11 +157,11 @@ agent = Agent( ) ``` -该回调可以是同步或异步的,并且每当模型需要审批信息才能继续运行时都会被调用。 +该回调可以是同步或异步的,只要模型需要审批数据才能继续运行,就会调用此回调。 ### 连接器支持的托管式服务 -托管式MCP还支持OpenAI连接器。你无需指定`server_url`,只需提供`connector_id`和访问令牌。Responses API会处理身份验证,托管式服务则会公开连接器的工具。 +托管式MCP还支持OpenAI连接器。无需指定 `server_url`,只需提供 `connector_id` 和访问令牌。Responses API 会处理身份验证,托管式服务则公开连接器的工具。 ```python import os @@ -176,11 +177,11 @@ HostedMCPTool( ) ``` -完整可运行的托管工具示例(包括流式传输、审批和连接器)位于[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 +功能完整的托管工具代码示例(包括流式传输、审批和连接器)位于 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 -## 2. Streamable HTTP MCP服务 +## 2. 可流式传输 HTTP MCP服务 -如果希望自行管理网络连接,请使用[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你需要控制传输方式,或希望在自己的基础设施中运行服务并保持较低延迟时,Streamable HTTP服务是理想选择。 +如果您希望自行管理网络连接,请使用 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当您需要控制传输方式,或希望在自己的基础设施中运行服务并保持较低延迟时,可流式传输 HTTP 服务是理想选择。 ```python import asyncio @@ -217,23 +218,23 @@ asyncio.run(main()) 构造函数还接受以下选项: -- `client_session_timeout_seconds`控制HTTP读取超时。 -- `use_structured_content`控制是否优先使用`tool_result.structured_content`而非文本输出。 -- `max_retry_attempts`和`retry_backoff_seconds_base`为`list_tools()`和`call_tool()`添加自动重试。 -- `tool_filter`用于仅公开工具的子集(请参阅[工具筛选](#tool-filtering))。 -- `require_approval`为本地MCP工具启用人工介入审批策略。 -- `failure_error_function`用于自定义模型可见的MCP工具失败消息;将其设置为`None`则改为抛出错误。 -- `tool_meta_resolver`会在`call_tool()`之前注入每次调用的MCP `_meta`载荷。 +- `client_session_timeout_seconds` 控制MCP ClientSession的读取超时。至少为一微秒、可由 `datetime.timedelta` 表示的正有限值会设置有限超时;`None` 和 `0` 会禁用超时。构造服务时会拒绝其他值。 +- `use_structured_content` 控制是否优先使用 `tool_result.structured_content`,而非文本输出。 +- `max_retry_attempts` 和 `retry_backoff_seconds_base` 为 `list_tools()` 和 `call_tool()` 添加自动重试。 +- `tool_filter` 允许您仅公开部分工具(请参阅[工具筛选](#tool-filtering))。 +- `require_approval` 为本地MCP工具启用人在回路审批策略。 +- `failure_error_function` 自定义模型可见的MCP工具失败消息;将其设置为 `None` 则改为引发错误。 +- `tool_meta_resolver` 在 `call_tool()` 之前注入每次调用的MCP `_meta` 载荷。 ### 本地MCP服务的审批策略 -`MCPServerStdio`、`MCPServerSse`和`MCPServerStreamableHttp`都接受`require_approval`。 +`MCPServerStdio`、`MCPServerSse` 和 `MCPServerStreamableHttp` 均接受 `require_approval`。 -支持的形式: +支持以下形式: -- 对所有工具使用`"always"`或`"never"`。 -- `True` / `False`(分别等同于always/never)。 -- 按工具配置的映射,例如`{"delete_file": "always", "read_file": "never"}`。 +- 对所有工具使用 `"always"` 或 `"never"`。 +- `True` / `False`(分别等同于始终审批/从不审批)。 +- 按工具配置的映射,例如 `{"delete_file": "always", "read_file": "never"}`。 - 分组对象:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 ```python @@ -245,11 +246,11 @@ async with MCPServerStreamableHttp( ... ``` -有关完整的暂停/恢复流程,请参阅[人工介入](human_in_the_loop.md)和`examples/mcp/get_all_mcp_tools_example/main.py`。 +有关完整的暂停/恢复流程,请参阅[人在回路](human_in_the_loop.md)和 `examples/mcp/get_all_mcp_tools_example/main.py`。 -### 使用`tool_meta_resolver`的每次调用元数据 +### 使用 `tool_meta_resolver` 的单次调用元数据 -当MCP服务要求在`_meta`中包含请求元数据(例如租户ID或追踪上下文)时,请使用`tool_meta_resolver`。以下示例假设你将`dict`作为`context`传递给`Runner.run(...)`。 +当MCP服务要求在 `_meta` 中提供请求元数据(例如租户 ID 或追踪上下文)时,请使用 `tool_meta_resolver`。以下代码示例假设您将 `dict` 作为 `context` 传递给 `Runner.run(...)`。 ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -270,19 +271,19 @@ server = MCPServerStreamableHttp( ) ``` -如果运行上下文是Pydantic模型、数据类或自定义类,请改为通过属性访问读取租户ID。 +如果您的运行上下文是 Pydantic 模型、数据类或自定义类,请改用属性访问方式读取租户 ID。 -### MCP工具输出:文本与图像 +### MCP工具输出:文本和图像 -当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合文本/图像响应会作为输出项列表转发,因此智能体可以像使用普通工具调用产生的图像输出一样使用MCP图像结果。 +当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合文本/图像响应会作为输出项列表转发,因此智能体可以像使用常规工具调用的图像输出一样使用MCP图像结果。 -## 3. 采用SSE的HTTP MCP服务 +## 3. 带 SSE 的 HTTP MCP服务 !!! warning - MCP项目已弃用服务端发送事件传输方式。新集成应优先使用Streamable HTTP或stdio,仅为旧版服务保留SSE。 + MCP项目已弃用 Server-Sent Events 传输方式。新集成应优先使用可流式传输 HTTP 或 stdio,仅为旧版服务保留 SSE。 -如果MCP服务实现了采用SSE的HTTP传输方式,请实例化[`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其API与Streamable HTTP服务完全相同。 +如果MCP服务实现了带 SSE 的 HTTP 传输,请实例化 [`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其 API 与可流式传输 HTTP 服务完全相同。 ```python @@ -311,7 +312,7 @@ async with MCPServerSse( ## 4. stdio MCP服务 -对于作为本地子进程运行的MCP服务,请使用[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会启动进程、保持管道打开,并在退出上下文管理器时自动关闭它们。此选项适用于快速构建概念验证,或服务仅提供命令行入口点的情况。 +对于以本地子进程方式运行的MCP服务,请使用 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会启动进程、保持管道打开,并在退出上下文管理器时自动关闭管道。此选项适合快速进行概念验证,或服务仅公开命令行入口点的情况。 ```python from pathlib import Path @@ -339,7 +340,7 @@ async with MCPServerStdio( ## 5. MCP服务管理器 -当你有多个MCP服务时,可使用`MCPServerManager`预先连接它们,并向智能体公开已连接的服务子集。有关构造函数选项和重新连接行为,请参阅[MCPServerManager API参考](ref/mcp/manager.md)。 +如果您有多个MCP服务,请使用 `MCPServerManager` 预先连接这些服务,并向智能体公开已连接的服务子集。有关构造函数选项和重新连接行为,请参阅 [MCPServerManager API 参考](ref/mcp/manager.md)。 ```python from agents import Agent, Runner @@ -362,23 +363,23 @@ async with MCPServerManager(servers) as manager: 关键行为: -- 当`drop_failed_servers=True`(默认值)时,`active_servers`仅包含成功连接的服务。 -- 失败信息会记录在`failed_servers`和`errors`中。 -- 设置`strict=True`可在第一次连接失败时抛出异常。 -- 调用`reconnect(failed_only=True)`可重试连接失败的服务,调用`reconnect(failed_only=False)`则会重启所有服务。 -- 使用`connect_timeout_seconds`、`cleanup_timeout_seconds`和`connect_in_parallel`调整生命周期行为。 +- 当 `drop_failed_servers=True`(默认值)时,`active_servers` 仅包括成功连接的服务。 +- 失败情况会记录在 `failed_servers` 和 `errors` 中。 +- 设置 `strict=True` 可在第一次连接失败时引发错误。 +- 调用 `reconnect(failed_only=True)` 可重试连接失败的服务,调用 `reconnect(failed_only=False)` 则会重启所有服务。 +- 设置 `connect_timeout_seconds`、`cleanup_timeout_seconds` 和 `connect_in_parallel` 可调整生命周期行为。生命周期超时接受正有限秒数,或使用 `None` 禁用超时;这些值会在构造和赋值期间进行验证。零值会被拒绝,因为它会产生即时截止期限。 -## 常见服务能力 +## 通用服务能力 -以下各节适用于各种MCP服务传输方式(确切的API接口取决于服务类)。 +以下各节适用于各种MCP服务传输方式(具体 API 接口取决于服务类)。 ## 工具筛选 -每个MCP服务都支持工具筛选,因此你可以仅公开智能体所需的函数。筛选既可以在构造时执行,也可以在每次运行时动态执行。 +每个MCP服务都支持工具筛选,因此您可以仅公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 ### 静态工具筛选 -使用[`create_static_tool_filter`][agents.mcp.create_static_tool_filter]配置简单的允许/阻止列表: +使用 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter] 配置简单的允许/阻止列表: ```python from pathlib import Path @@ -396,11 +397,11 @@ filesystem_server = MCPServerStdio( ) ``` -当同时提供`allowed_tool_names`和`blocked_tool_names`时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 +同时提供 `allowed_tool_names` 和 `blocked_tool_names` 时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 ### 动态工具筛选 -对于更复杂的逻辑,请传入一个接收[`ToolFilterContext`][agents.mcp.ToolFilterContext]的可调用对象。该可调用对象可以是同步或异步的,并在应公开工具时返回`True`。 +对于更复杂的逻辑,请传入一个接收 [`ToolFilterContext`][agents.mcp.ToolFilterContext] 的可调用对象。该可调用对象可以是同步或异步的,并在应公开工具时返回 `True`。 ```python from pathlib import Path @@ -424,15 +425,15 @@ async with MCPServerStdio( ... ``` -筛选上下文会提供当前`run_context`、请求工具的`agent`以及`server_name`。 +筛选上下文会公开当前的 `run_context`、请求工具的 `agent` 和 `server_name`。 ## 提示词 -MCP服务还可以提供用于动态生成智能体指令的提示词。支持提示词的服务会公开两个 +MCP服务还可以提供动态生成智能体指令的提示词。支持提示词的服务会公开两种 方法: -- `list_prompts()`列出可用的提示词模板。 -- `get_prompt(name, arguments)`获取具体提示词,并可选择附带参数。 +- `list_prompts()` 枚举可用的提示词模板。 +- `get_prompt(name, arguments)` 获取具体提示词,并可选择提供参数。 ```python from agents import Agent @@ -452,7 +453,7 @@ agent = Agent( ## 缓存 -每次智能体运行都会在每个MCP服务上调用`list_tools()`。远程服务可能会引入明显延迟,因此所有MCP服务类都提供`cache_tools_list`选项。仅当你确信工具定义不会频繁变化时,才将其设置为`True`。如需之后强制获取最新列表,请在服务实例上调用`invalidate_tools_cache()`。 +每次智能体运行都会在每个MCP服务上调用 `list_tools()`。远程服务可能产生明显的延迟,因此所有MCP服务类都提供 `cache_tools_list` 选项。仅当您确定工具定义不会频繁变化时,才将其设置为 `True`。如需稍后强制获取最新列表,请在服务实例上调用 `invalidate_tools_cache()`。 ## 追踪 @@ -465,6 +466,6 @@ agent = Agent( ## 延伸阅读 -- [Model Context Protocol](https://modelcontextprotocol.io/) – 规范与设计指南。 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的stdio、SSE和Streamable HTTP代码示例。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 包含审批和连接器的完整托管式MCP演示。 \ No newline at end of file +- [Model Context Protocol](https://modelcontextprotocol.io/) – 规范和设计指南。 +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的 stdio、SSE 和可流式传输 HTTP 代码示例。 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 完整的托管式MCP演示,包括审批和连接器。 \ No newline at end of file diff --git a/docs/zh/tools.md b/docs/zh/tools.md index 1d4d6d38fc..b397fbdadc 100644 --- a/docs/zh/tools.md +++ b/docs/zh/tools.md @@ -10,37 +10,37 @@ search: - 本地/运行时执行工具:`ComputerTool` 和 `ApplyPatchTool` 始终在你的环境中运行,而 `ShellTool` 可以在本地或托管容器中运行。 - Function Calling:将任意 Python 函数封装为工具。 - Agents as tools:将智能体公开为可调用工具,而无需完整的任务转移。 -- 实验性功能:Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 +- 实验性 Codex 工具:通过工具调用运行限定于工作区范围的 Codex 任务。 -## 工具类型选择 +## 工具类型的选择 -可将本页面作为目录,然后跳转到与你所控制的运行时相匹配的部分。 +请将本页用作目录,然后跳转到与你所控制的运行时相匹配的部分。 -| 如果你想要…… | 从这里开始 | +| 如果你希望…… | 从这里开始 | | --- | --- | | 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管 MCP、图像生成) | [托管工具](#hosted-tools) | -| 通过工具搜索将大型工具集延迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | +| 通过工具搜索将大型工具集合延迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | | 通过生成的 JavaScript 协调多个工具调用 | [程序化工具调用](#programmatic-tool-calling) | | 在你自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | | 将 Python 函数封装为工具 | [工具调用](#function-tools) | | 让一个智能体在不进行任务转移的情况下调用另一个智能体 | [Agents as tools](#agents-as-tools) | -| 从智能体运行限定于工作区的 Codex 任务 | [实验性功能:Codex 工具](#experimental-codex-tool) | +| 从智能体运行限定于工作区范围的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | ## 托管工具 -使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI提供了一些内置工具: +使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI 提供了一些内置工具: -- [`WebSearchTool`][agents.tool.WebSearchTool] 允许智能体检索网络。 +- [`WebSearchTool`][agents.tool.WebSearchTool] 让智能体能够搜索网络。 - [`FileSearchTool`][agents.tool.FileSearchTool] 允许从你的 OpenAI 向量存储中检索信息。 -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 允许 LLM 在沙盒环境中执行代码。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] 将远程 MCP 服务的工具公开给模型。 +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 让 LLM 能够在沙盒环境中执行代码。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] 向模型公开远程 MCP 服务的工具。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] 根据提示词生成图像。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] 允许模型按需加载延迟加载的工具、命名空间或托管 MCP 服务。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 允许模型通过生成的 JavaScript 协调符合条件的工具。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] 让模型能够按需加载延迟加载的工具、命名空间或托管 MCP 服务。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 让模型能够通过生成的 JavaScript 协调符合条件的工具。 高级托管搜索选项: -- 除 `vector_store_ids` 和 `max_num_results` 外,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。 +- 除 `vector_store_ids` 和 `max_num_results` 外,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。将 `max_num_results` 设置为 1 到 50 之间的整数;`None` 或零将使用提供商默认值。 - `WebSearchTool` 支持 `filters`、`user_location` 和 `search_context_size`。 ```python @@ -64,9 +64,9 @@ async def main(): ### 托管工具搜索 -工具搜索允许 OpenAI Responses 模型将大型工具集延迟到运行时加载,使模型仅加载当前轮次所需的工具子集。当你拥有大量工具调用、命名空间组或托管 MCP 服务,并希望在不预先公开所有工具的情况下减少工具模式所占用的 token 时,此功能非常有用。 +工具搜索让 OpenAI Responses 模型能够将大型工具集合延迟到运行时加载,使模型仅加载当前轮次所需的子集。当你拥有许多工具调用、命名空间组或托管 MCP 服务,并希望在不预先公开所有工具的情况下减少工具架构所占的 token 时,这会很有用。 -如果构建智能体时已经知道候选工具,请从托管工具搜索开始。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 +如果构建智能体时已经知道候选工具,请优先使用托管工具搜索。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 ```python from typing import Annotated @@ -111,26 +111,26 @@ print(result.final_output) 注意事项: -- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 支持情况取决于 `openai>=2.25.0`。 -- 在智能体上配置延迟加载的工具集时,只添加一个 `ToolSearchTool()`。 -- 可搜索的工具集包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 +- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 的支持依赖于 `openai>=2.25.0`。 +- 在智能体上配置延迟加载集合时,只添加一个 `ToolSearchTool()`。 +- 可搜索的集合包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 - 延迟加载的工具调用必须与 `ToolSearchTool()` 配合使用。仅包含命名空间的设置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 -- `tool_namespace()` 将多个 `FunctionTool` 实例归入一个共享的命名空间名称和描述下。当你拥有大量相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最佳选择。 -- OpenAI官方最佳实践指南建议[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 -- 如有可能,优先使用命名空间或托管 MCP 服务,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高层级搜索界面,并节省更多 token。 -- 命名空间可以混合包含立即可用和延迟加载的工具。没有 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟工具则通过工具搜索加载。 -- 根据经验,应让每个命名空间保持较小规模,最好少于 10 个函数。 -- 具名 `tool_choice` 不能以单独的命名空间名称或仅延迟加载的工具为目标。请优先使用 `auto`、`required` 或实际可在顶层调用的工具名称。 -- `ToolSearchTool(execution="client")` 用于手动编排 Responses。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会引发异常,而不会替你执行它。 -- 工具搜索活动会以专用条目和事件类型出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中。 -- 有关涵盖命名空间加载和顶层延迟工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 +- `tool_namespace()` 将 `FunctionTool` 实例归入具有共享名称和描述的命名空间。当你拥有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最佳选择。 +- OpenAI 的官方最佳实践指南是[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 +- 如果可能,应优先使用命名空间或托管 MCP 服务,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高层搜索范围,并节省更多 token。 +- 命名空间可以混合包含立即可用和延迟加载的工具。未设置 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟加载工具则通过工具搜索进行加载。 +- 根据经验,每个命名空间应保持相对较小,最好少于 10 个函数。 +- 具名 `tool_choice` 不能以单独的命名空间名称或仅支持延迟加载的工具为目标。应优先使用 `auto`、`required` 或真正的顶层可调用工具名称。 +- `ToolSearchTool(execution="client")` 用于手动编排 Responses。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会抛出异常,而不会替你执行它。 +- 工具搜索活动会出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中,并使用专门的条目和事件类型。 +- 有关涵盖命名空间加载和顶层延迟加载工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 - 官方平台指南:[工具搜索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 ### 程序化工具调用 -程序化工具调用允许受支持的 OpenAI Responses 模型生成 JavaScript,以调用符合条件的工具、组合其输出,并向模型返回一个结果。它适用于边界明确的工作流,这些工作流能够受益于循环、分支、并行调用或中间计算,并且不需要在每次工具调用后都与模型往返交互。 +程序化工具调用让受支持的 OpenAI Responses 模型能够生成 JavaScript,以调用符合条件的工具、合并其输出,并向模型返回一个结果。它适用于范围明确且可受控的工作流,这类工作流可通过循环、分支、并行调用或中间计算获益,而无需在每次工具调用后都与模型往返交互。 -生成的程序在全新的托管 V8 环境中运行。它无法使用 Node.js API,不能访问文件系统或网络,也没有持久化进程。程序只能与明确允许的工具交互。 +生成的程序在全新的托管 V8 环境中运行。它无法使用 Node.js API,不能访问文件系统或网络,也没有持久化进程。程序只能与显式允许的工具交互。 ```python from pydantic import BaseModel @@ -168,21 +168,21 @@ print(result.final_output) 注意事项: - 程序化工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 -- 一个智能体最多添加一个 `ProgrammaticToolCallingTool()`。智能体还必须公开至少一个可由程序调用的工具、一个由命名空间、延迟函数或延迟托管 MCP 服务支持的 `ToolSearchTool()`,或者一个由提示词管理的不透明工具集。没有可搜索工具集的单独 `ToolSearchTool()` 会被拒绝。 -- `allowed_callers` 控制工具的调用方式。省略它时,仅允许模型直接调用。使用 `["programmatic"]` 可限制为仅由程序访问,使用 `["direct", "programmatic"]` 则可同时允许两种方式。 -- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、Shell 和补丁应用工具直接公开 `allowed_callers`。对于托管 MCP 和 Code Interpreter,请在 `tool_config` 内设置 `allowed_callers`。 -- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或 dataclass 等结构化返回注解会自动转换为严格的对象输出模式,并在将值返回给程序之前进行验证。如果函数没有可用的注解,请使用 `output_type=...`;如果你已经拥有严格的对象模式,可使用更底层的 `output_json_schema={...}` 备用方式。`output_type` 与 `output_json_schema` 互斥。普通 `str`、`Any` 和 `None` 返回值仍不带类型。对于由模式支持且归程序所有的调用,默认失败格式化程序会被禁用,因为其自由格式文本不符合输出模式。因此,除非你提供返回符合模式的 JSON 的自定义 `failure_error_function`,否则处理程序异常将继续向上传播。 -- 归程序所有的 SDK 工具仍使用正常的 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,SDK 也会保留每个子调用与程序调用者之间的关系。 -- 只要存在 `ProgrammaticToolCallingTool()`,模型请求重试就会采用更严格的重放安全边界,即使程序尚未执行也是如此。SDK 会为这些请求禁用提供方管理的重试和 WebSocket 事件前重试。仅当提供方建议明确将重放标记为安全时,Runner 重试策略才会重试;单独使用 `retry_policies.network_error()` 不会覆盖此边界。 -- 涉及审批或影响较大的工具通常更适合作为直接调用,以便人工在每个操作成为大型程序的一部分之前进行审查。如果归程序所有的调用因等待审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 -- 程序化工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。模型必须先加载延迟工具,生成的程序才能调用它们。 -- `program` 条目及其普通的归程序所有的子工具调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。托管 MCP 审批请求和工具目录则使用专用的 MCP 条目和流式事件。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 +- 每个智能体最多添加一个 `ProgrammaticToolCallingTool()`。该智能体还必须公开至少一个可通过编程方式调用的工具、一个由命名空间、延迟加载函数或延迟加载托管 MCP 服务支持的 `ToolSearchTool()`,或一个由提示词管理的不透明工具集合。没有可搜索集合的单独 `ToolSearchTool()` 会被拒绝。 +- `allowed_callers` 控制工具可以如何被调用。省略该参数时,仅允许模型直接调用。使用 `["programmatic"]` 可仅允许程序访问,使用 `["direct", "programmatic"]` 则同时允许两种方式。 +- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、shell 和补丁应用工具直接公开 `allowed_callers`。对于托管 MCP 和 Code Interpreter,请在 `tool_config` 中设置 `allowed_callers`。 +- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或数据类等结构化返回注解会自动转换为严格的对象输出架构,并在将值返回给程序之前进行验证。如果函数没有可用的注解,请使用 `output_type=...`;如果你已经有严格的对象架构,则可使用更底层的 `output_json_schema={...}` 逃生通道。`output_type` 和 `output_json_schema` 互斥。普通的 `str`、`Any` 和 `None` 返回值仍不带类型。对于由架构支持且归程序所有的调用,默认失败格式化器会被禁用,因为其自由格式文本不符合输出架构。因此,除非你提供返回符合架构 JSON 的自定义 `failure_error_function`,否则处理程序异常会继续向上传播。 +- 归程序所有的 SDK 工具仍使用正常的 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,SDK 也会保留每个子调用与程序调用方之间的关系。 +- 只要存在 `ProgrammaticToolCallingTool()`,模型请求重试就会使用更严格的重放安全边界,即使程序尚未执行也是如此。SDK 会为这些请求禁用由提供商管理的重试和 WebSocket 事件前重试。仅当提供商的建议明确标记重放安全时,Runner 重试策略才会重试;单独使用 `retry_policies.network_error()` 无法覆盖此边界。 +- 对于需要审批或影响较大的工具,通常更适合保留为直接调用,以便人员在每项操作成为更大程序的一部分之前进行审核。如果归程序所有的调用因等待审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 +- 程序化工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。生成的程序必须先由模型加载延迟加载的工具,之后才能调用它们。 +- `program` 条目及其常规的、归程序所有的子工具调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。托管 MCP 审批请求和工具目录则使用专门的 MCP 条目和流式事件。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 - 有关完整的并发库存规划代码示例,请参阅 `examples/tools/programmatic_tool_calling.py`。 - 官方平台指南:[程序化工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 -### 托管容器 Shell 与技能 +### 托管容器 shell 与技能 -`ShellTool` 还支持在OpenAI托管的容器中执行。当你希望模型在托管容器中而不是本地运行时中执行 Shell 命令时,请使用此模式。 +`ShellTool` 也支持由OpenAI托管的容器执行。如果你希望模型在托管容器中而不是本地运行时中执行 shell 命令,请使用此模式。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -215,52 +215,54 @@ result = await Runner.run( print(result.final_output) ``` -要在后续运行中复用现有容器,请设置 `environment={"type": "container_reference", "container_id": "cntr_..."}`。 +若要在后续运行中复用现有容器,请设置 `environment={"type": "container_reference", "container_id": "cntr_..."}`。 注意事项: -- 托管 Shell 可通过 Responses API 的 Shell 工具使用。 -- `container_auto` 为请求预配容器;`container_reference` 复用现有容器。 +- 可通过 Responses API 的 shell 工具使用托管 shell。 +- `container_auto` 会为请求预配容器;`container_reference` 会复用现有容器。 - `container_auto` 还可以包含 `file_ids` 和 `memory_limit`。 - `environment.skills` 接受技能引用和内联技能包。 -- 使用托管环境时,请勿在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 +- 使用托管环境时,不要在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 - `network_policy` 支持 `disabled` 和 `allowlist` 模式。 -- 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于域名的密钥。 +- 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于域的密钥。 - 有关完整代码示例,请参阅 `examples/tools/container_shell_skill_reference.py` 和 `examples/tools/container_shell_inline_skill.py`。 -- OpenAI平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 +- OpenAI 平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 ## 本地运行时工具 -本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用或已配置的执行环境完成。 +本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用或配置的执行环境完成。 -`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 横跨两种模式:如果需要托管执行,请使用上面的托管容器配置;如果希望命令在你自己的进程中运行,请使用下面的本地运行时配置。 +`ComputerTool` 和 `ApplyPatchTool` 始终需要你提供本地实现。`ShellTool` 横跨两种模式:如果需要托管执行,请使用上述托管容器配置;如果希望命令在你自己的进程中运行,请使用下述本地运行时配置。 本地运行时工具要求你提供实现: - [`ComputerTool`][agents.tool.ComputerTool]:实现 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 接口,以启用 GUI/浏览器自动化。 -- [`ShellTool`][agents.tool.ShellTool]:同时适用于本地执行和托管容器执行的最新 Shell 工具。 -- [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 Shell 集成。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor],以在本地应用差异。 -- 可通过 `ShellTool(environment={"type": "local", "skills": [...]})` 使用本地 Shell 技能。 +- [`ShellTool`][agents.tool.ShellTool]:用于本地执行和托管容器执行的最新 shell 工具。 +- [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 shell 集成。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] 以在本地应用差异。 +- 通过 `ShellTool(environment={"type": "local", "skills": [...]})` 可以使用本地 shell 技能。 + +对于有限超时,shell 操作超时使用正整数毫秒值。调用本地 `ShellTool` 执行器前,SDK 会将 `0` 和 `None` 都视为未显式设置超时,因为零在不同执行器实现中没有可移植的统一含义;其他值会在调用执行器前被拒绝。此规则仅适用于超时字段:`max_output_length=0` 仍是受支持的请求,表示捕获空输出。 ### ComputerTool 与 Responses 计算机工具 -`ComputerTool` 仍是本地运行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该运行框架映射到 OpenAI Responses API 的计算机操作界面。 +`ComputerTool` 仍是本地执行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该框架映射到 OpenAI Responses API 的计算机操作接口。 -对于显式的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布版内置工具载荷 `{"type": "computer"}`。较旧的 `computer-use-preview` 模型仍使用预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中所述的平台迁移一致: +对于显式的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布的内置工具载荷 `{"type": "computer"}`。较旧的 `computer-use-preview` 模型仍使用预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI 的[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中描述的平台迁移一致: - 模型:`computer-use-preview` -> `gpt-5.5` - 工具选择器:`computer_use_preview` -> `computer` -- 计算机调用形式:每个 `computer_call` 包含一个 `action` -> `computer_call` 上的批量 `actions[]` -- 截断:预览版路径要求使用 `ModelSettings(truncation="auto")` -> 正式发布版路径不要求 +- 计算机调用结构:每个 `computer_call` 包含一个 `action` -> `computer_call` 上的批量 `actions[]` +- 截断:预览路径要求使用 `ModelSettings(truncation="auto")` -> 正式发布路径不要求 -SDK 会根据实际 Responses 请求中的有效模型选择该传输格式。如果你使用提示词模板,并且由于模型由提示词指定而在请求中省略 `model`,SDK 会继续使用与预览版兼容的计算机载荷,除非你显式保留 `model="gpt-5.5"`,或者使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版选择器。 +SDK 会根据实际 Responses 请求中的有效模型选择对应的传输格式。如果你使用提示词模板,而请求因模型由提示词指定而省略 `model`,SDK 会保留与预览版兼容的计算机载荷,除非你显式保留 `model="gpt-5.5"`,或通过 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制使用正式发布的选择器。 -存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 都会被接受,并被规范化为与有效请求模型相匹配的内置选择器。不存在 `ComputerTool` 时,这些字符串仍会被视为普通函数名称。 +存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 均可接受,并会规范化为与有效请求模型匹配的内置选择器。不存在 `ComputerTool` 时,这些字符串仍会被视为普通函数名称。 -当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别非常重要。正式发布版 `computer` 载荷在序列化时不需要 `environment` 或尺寸,因此工厂尚未解析也没有问题。与预览版兼容的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 发送 `environment`、`display_width` 和 `display_height`。 +当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别非常重要。正式发布的 `computer` 载荷在序列化时不需要 `environment` 或尺寸,因此工厂尚未解析也没有问题。与预览版兼容的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 发送 `environment`、`display_width` 和 `display_height`。 -在运行时,两条路径仍使用相同的本地运行框架。预览版响应会发出包含单个 `action` 的 `computer_call` 条目;`gpt-5.5` 可以发出批量 `actions[]`,SDK 会依次执行这些操作,然后生成 `computer_call_output` 屏幕截图条目。有关基于 Playwright 的可运行框架,请参阅 `examples/tools/computer_use.py`。 +在运行时,两条路径仍使用同一本地执行框架。预览版响应会发出包含单个 `action` 的 `computer_call` 条目;`gpt-5.5` 可以发出批量 `actions[]`,SDK 会按顺序执行它们,然后生成 `computer_call_output` 截图条目。有关基于 Playwright 且可运行的执行框架,请参阅 `examples/tools/computer_use.py`。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -306,16 +308,16 @@ agent = Agent( 你可以将任意 Python 函数用作工具。Agents SDK 会自动设置该工具: -- 工具名称将使用 Python 函数的名称(你也可以提供名称) +- 工具名称将采用 Python 函数的名称(你也可以提供名称) - 工具描述将取自函数的文档字符串(你也可以提供描述) -- 函数输入的模式会根据函数参数自动创建 +- 函数输入的架构会根据函数参数自动创建 - 除非禁用,否则每个输入的描述都取自函数的文档字符串 -由 `@tool` 创建的工具通过只读 `__wrapped__` 属性公开原始 Python 可调用对象。这对检查和测试很有用,但直接调用它会绕过工具运行时管线,包括模式验证、上下文注入、安全防护措施、超时、失败处理和追踪。手动构建的 `FunctionTool` 实例不公开 `__wrapped__`。 +由 `@tool` 创建的工具通过只读 `__wrapped__` 属性公开原始 Python 可调用对象。这对于检查和测试很有用,但直接调用它会绕过工具运行时管线,包括架构验证、上下文注入、安全防护措施、超时、失败处理和追踪。手动构建的 `FunctionTool` 实例不公开 `__wrapped__`。 -我们使用 Python 的 `inspect` 模块提取函数签名,并结合 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,再使用 `pydantic` 创建模式。 +我们使用 Python 的 `inspect` 模块提取函数签名,同时使用 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,并使用 `pydantic` 创建架构。 -使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏工具调用,直到 `ToolSearchTool()` 加载它。你也可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关的工具调用进行分组。有关完整设置和限制,请参阅[托管工具搜索](#hosted-tool-search)。 +使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏函数工具,直至 `ToolSearchTool()` 加载它。你也可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关工具调用进行分组。有关完整设置和限制,请参阅[托管工具搜索](#hosted-tool-search)。 ```python import json @@ -368,12 +370,12 @@ for tool in agent.tools: ``` -1. 你可以使用任意 Python 类型作为函数参数,函数可以是同步或异步函数。 -2. 如果存在文档字符串,则会使用它来获取描述和参数描述。 -3. 函数可以选择接收 `context`(必须是第一个参数)。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 -4. 你可以将经过装饰的函数传入工具列表。 +1. 你可以将任意 Python 类型用作函数参数,并且函数可以是同步或异步的。 +2. 如果存在文档字符串,则使用它来获取描述和参数描述 +3. 函数可以选择接受 `context`(必须是第一个参数)。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 +4. 你可以将装饰后的函数传递给工具列表。 -??? note "展开以查看输出" +??? note "展开查看输出" ``` fetch_weather @@ -443,9 +445,9 @@ for tool in agent.tools: } ``` -### 从工具调用返回图像或文件 +### 工具调用返回的图像或文件 -除了返回文本输出外,你还可以将一个或多个图像或文件作为工具调用的输出返回。为此,你可以返回以下任意内容: +除了返回文本输出外,你还可以将一张或多张图像或一个或多个文件作为函数工具的输出返回。为此,你可以返回以下任意内容: - 图像:[`ToolOutputImage`][agents.tool.ToolOutputImage](或 TypedDict 版本 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) - 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) @@ -453,12 +455,12 @@ for tool in agent.tools: ### 自定义工具调用 -有时,你可能不想将 Python 函数用作工具。如果愿意,可以直接创建 [`FunctionTool`][agents.tool.FunctionTool]。你需要提供: +有时,你可能不希望使用 Python 函数作为工具。如果愿意,可以直接创建 [`FunctionTool`][agents.tool.FunctionTool]。你需要提供: - `name` - `description` -- `params_json_schema`,即参数的 JSON 模式 -- `on_invoke_tool`,即一个异步函数,它接收 [`ToolContext`][agents.tool_context.ToolContext] 和以 JSON 字符串形式提供的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 +- `params_json_schema`,即参数的 JSON 架构 +- `on_invoke_tool`,即一个异步函数,它接收 [`ToolContext`][agents.tool_context.ToolContext] 和 JSON 字符串形式的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 ```python from typing import Any @@ -491,18 +493,18 @@ tool = FunctionTool( ) ``` -### 参数与文档字符串的自动解析 +### 参数和文档字符串的自动解析 -如前所述,我们会自动解析函数签名以提取工具模式,并解析文档字符串以提取工具及各个参数的描述。相关注意事项如下: +如前所述,我们会自动解析函数签名以提取工具架构,并解析文档字符串以提取工具及各个参数的描述。相关注意事项如下: -1. 签名解析通过 `inspect` 模块完成。我们使用类型注解理解参数类型,并动态构建 Pydantic 模型来表示整体模式。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 -2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这属于尽力而为;你也可以在调用 `function_tool` 时显式设置格式。还可以将 `use_docstring_info` 设置为 `False`,以禁用文档字符串解析。对于 Google 风格的文档字符串,解析器还接受紧接在摘要文本之后且中间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 部分。 +1. 签名解析通过 `inspect` 模块完成。我们使用类型注解理解参数类型,并动态构建 Pydantic 模型来表示整体架构。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 +2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这只是尽力而为,你也可以在调用 `function_tool` 时显式设置格式。还可以通过将 `use_docstring_info` 设置为 `False` 来禁用文档字符串解析。对于 Google 风格的文档字符串,解析器也接受紧接在摘要文本之后、其间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 部分。 -模式提取代码位于 [`agents.function_schema`][]。 +架构提取的代码位于 [`agents.function_schema`][]。 ### 使用 Pydantic Field 约束和描述参数 -你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,两种形式均受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 模式和验证会包含这些约束。 +你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,两种形式均受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 架构和验证会包含这些约束。 ```python from typing import Annotated @@ -520,9 +522,9 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr return f"Score recorded: {score}" ``` -### 工具调用超时 +### 函数工具超时 -你可以使用 `@function_tool(timeout=...)` 为异步工具调用设置单次调用超时。 +你可以使用 `@function_tool(timeout=...)` 为异步函数工具设置每次调用的超时。 ```python import asyncio @@ -543,12 +545,12 @@ agent = Agent( ) ``` -达到超时时间时,默认行为是 `timeout_behavior="error_as_result"`,它会发送模型可见的超时消息(例如 `Tool 'slow_lookup' timed out after 2 seconds.`)。 +达到超时时间时,默认行为是 `timeout_behavior="error_as_result"`,它会发送一条模型可见的超时消息(例如 `Tool 'slow_lookup' timed out after 2 seconds.`)。 你可以控制超时处理方式: - `timeout_behavior="error_as_result"`(默认):向模型返回超时消息,使其能够恢复。 -- `timeout_behavior="raise_exception"`:引发 [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] 并使运行失败。 +- `timeout_behavior="raise_exception"`:抛出 [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] 并使运行失败。 - `timeout_error_function=...`:使用 `error_as_result` 时自定义超时消息。 ```python @@ -575,13 +577,13 @@ except ToolTimeoutError as e: 超时配置仅支持异步 `@function_tool` 处理程序。 -### 工具调用错误处理 +### 工具调用中的错误处理 -通过 `@function_tool` 创建工具调用时,可以传入 `failure_error_function`。如果工具调用崩溃,该函数会向 LLM 提供错误响应。 +通过 `@function_tool` 创建函数工具时,可以传入 `failure_error_function`。如果工具调用崩溃,该函数会向 LLM 提供错误响应。 -- 默认情况下(即不传入任何内容时),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 -- 如果传入自定义错误函数,则会改为运行该函数,并将响应发送给 LLM。 -- 如果显式传入 `None`,任何工具调用错误都会重新引发,供你自行处理。如果模型生成了无效 JSON,这可能是 `ModelBehaviorError`;如果你的代码崩溃,则可能是 `UserError`,等等。 +- 默认情况下(即你未传入任何内容时),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 +- 如果传入自己的错误函数,则改为运行该函数,并将响应发送给 LLM。 +- 如果显式传入 `None`,任何工具调用错误都会重新抛出,供你处理。如果模型生成了无效 JSON,错误可能是 `ModelBehaviorError`;如果你的代码崩溃,则可能是 `UserError`,等等。 ```python from agents import RunContextWrapper @@ -605,11 +607,11 @@ def get_user_profile(user_id: str) -> str: ``` -如果手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数中处理错误。 +如果你手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内处理错误。 ## Agents as tools -在某些工作流中,你可能希望由一个中央智能体编排由多个专用智能体组成的网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 +在某些工作流中,你可能希望由一个中心智能体编排由多个专业智能体组成的网络,而不是转移控制权。为此,你可以将智能体建模为工具。 ```python import asyncio @@ -653,11 +655,11 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 工具智能体自定义 +### 工具智能体的自定义 `agent.as_tool` 函数是一种便捷方法,可轻松将智能体转换为工具。它支持常见运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 -状态选项用于配置由工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请显式向两者传入相同的 `session`。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:使用由客户端管理的 `session`,或者通过 `previous_response_id` 或 `conversation_id` 在服务端延续。 +这些状态选项用于配置由工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请显式向两者传递相同的 `session`。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:由客户端管理的 `session`,或通过 `previous_response_id` 或 `conversation_id` 实现由服务管理的连续运行。 ```python from agents.decorators import tool @@ -681,13 +683,13 @@ async def run_my_agent() -> str: ### 工具智能体的结构化输入 -默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或 dataclass 类型)公开结构化模式。 +默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或数据类类型)公开结构化架构。 其他选项: - `include_input_schema=True` 会在生成的嵌套输入中包含完整的 JSON Schema。 -- `input_builder=...` 允许你完全自定义如何将结构化工具参数转换为嵌套智能体输入。 -- `RunContextWrapper.tool_input` 包含嵌套运行上下文中已解析的结构化载荷。 +- `input_builder=...` 让你能够完全自定义如何将结构化工具参数转换为嵌套智能体输入。 +- `RunContextWrapper.tool_input` 包含嵌套运行上下文中解析后的结构化载荷。 ```python from pydantic import BaseModel, Field @@ -709,19 +711,19 @@ translator_tool = translator_agent.as_tool( 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_structured.py`。 -### 工具智能体的审批门控 +### 工具智能体的审批关卡 -`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理条目会出现在 `result.interruptions` 中;然后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人工介入指南](human_in_the_loop.md)。 +`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理条目将显示在 `result.interruptions` 中;随后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复。有关完整的暂停/恢复模式,请参阅[人在回路指南](human_in_the_loop.md)。 ### 自定义输出提取 -在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中央智能体。以下情况可能会用到此功能: +在某些情况下,你可能希望在将工具智能体的输出返回给中心智能体之前对其进行修改。以下情况可能会用到此功能: - 从子智能体的聊天历史记录中提取特定信息(例如 JSON 载荷)。 -- 转换或重新格式化智能体的最终答案(例如将 Markdown 转换为纯文本或 CSV)。 -- 验证输出,或者在智能体响应缺失或格式错误时提供回退值。 +- 转换智能体的最终答案或重新设置其格式(例如将 Markdown 转换为纯文本或 CSV)。 +- 验证输出,或在智能体响应缺失或格式错误时提供回退值。 -可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现: +你可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现: ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -740,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -在自定义提取器内部,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在后处理嵌套结果时获取外层工具名称、调用 ID 或原始参数,这一属性非常有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 +在自定义提取器内部,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在后处理嵌套结果时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 ### 嵌套智能体运行的流式传输 -向 `as_tool` 传入 `on_stream` 回调,以监听嵌套智能体发出的流式事件,同时仍会在流完成后返回其最终输出。 +向 `as_tool` 传入 `on_stream` 回调,可以监听嵌套智能体发出的流式事件,同时仍会在流结束后返回其最终输出。 ```python from agents import AgentToolStreamEvent @@ -765,14 +767,14 @@ billing_agent_tool = billing_agent.as_tool( 预期行为: - 事件类型与 `StreamEvent["type"]` 一致:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 -- 提供 `on_stream` 后,嵌套智能体会自动以流式传输模式运行,并在返回最终输出前读取完流。 -- 处理程序可以是同步或异步的;每个事件都会按到达顺序传递。 -- 通过模型工具调用来调用工具时,会存在 `tool_call`;直接调用时,其值可能为 `None`。 -- 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 +- 提供 `on_stream` 后,嵌套智能体会自动以流式传输模式运行,并在返回最终输出前耗尽整个流。 +- 处理程序可以是同步或异步的;每个事件都会按照到达顺序依次传递。 +- 通过模型工具调用来调用工具时会存在 `tool_call`;直接调用时其值可能为 `None`。 +- 有关完整的可运行示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 -### 工具的条件启用 +### 工具的条件性启用 -你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选可供 LLM 使用的工具。 +你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选哪些工具可供 LLM 使用。 ```python import asyncio @@ -830,21 +832,21 @@ asyncio.run(main()) `is_enabled` 参数接受: - **布尔值**:`True`(始终启用)或 `False`(始终禁用) -- **可调用函数**:接收 `(context, agent)` 并返回布尔值的函数 +- **可调用函数**:接受 `(context, agent)` 并返回布尔值的函数 - **异步函数**:用于复杂条件逻辑的异步函数 -禁用的工具在运行时对 LLM 完全不可见,因此此功能适用于: +被禁用的工具在运行时对 LLM 完全隐藏,因此适用于: -- 根据用户权限控制功能 -- 特定于环境的工具可用性(开发环境与生产环境) +- 根据用户权限实施功能准入控制 +- 针对特定环境控制工具可用性(开发环境与生产环境) - 对不同工具配置进行 A/B 测试 - 根据运行时状态动态筛选工具 -## 实验性功能:Codex 工具 +## 实验性 Codex 工具 -`codex_tool` 封装了 Codex CLI,使智能体能够在工具调用期间运行限定于工作区的任务(Shell、文件编辑、MCP 工具)。此功能为实验性功能,将来可能发生变化。 +`codex_tool` 封装了 Codex CLI,使智能体能够在工具调用期间运行限定于工作区范围的任务(shell、文件编辑、MCP 工具)。此接口仍处于实验阶段,可能会发生变化。 -当你希望主智能体在不退出当前运行的情况下,将边界明确的工作区任务委派给 Codex 时,可以使用它。默认工具名称为 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当智能体包含多个 Codex 工具时,每个工具必须使用唯一名称。 +如果你希望主智能体在不离开当前运行的情况下,将范围明确且可受控的工作区任务委托给 Codex,请使用此工具。默认情况下,工具名称为 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当智能体包含多个 Codex 工具时,每个工具都必须使用唯一名称。 ```python from agents import Agent @@ -873,19 +875,19 @@ agent = Agent( ) ``` -可从以下选项组开始: +请从以下选项组开始: -- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可操作的位置。请将两者配对使用;如果工作目录不在 Git 仓库中,请设置 `skip_git_repo_check=True`。 -- 线程默认值:`default_thread_options=ThreadOptions(...)` 用于配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。请优先使用 `web_search_mode`,而不是旧版 `web_search_enabled`。 +- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以在哪里操作。请将两者配合设置;如果工作目录不在 Git 仓库内,请设置 `skip_git_repo_check=True`。 +- 线程默认值:`default_thread_options=ThreadOptions(...)` 用于配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。应优先使用 `web_search_mode`,而不是旧版 `web_search_enabled`。 - 轮次默认值:`default_turn_options=TurnOptions(...)` 用于配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 -- 工具输入/输出:工具调用必须至少包含一个 `inputs` 条目,其形式为 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }`。`output_schema` 允许你要求 Codex 返回结构化响应。 +- 工具输入/输出:工具调用必须至少包含一个 `inputs` 条目,其格式为 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }`。`output_schema` 让你能够要求 Codex 返回结构化响应。 -线程复用和持久化是相互独立的控制项: +线程复用和持久化是两个独立的控制项: -- `persist_session=True` 会让对同一工具实例的重复调用复用同一个 Codex 线程。 -- `use_run_context_thread_id=True` 会在运行上下文中存储并复用线程 ID,适用于共享同一可变上下文对象的多次运行。 -- 线程 ID 的优先顺序为:单次调用的 `thread_id`、运行上下文线程 ID(如果启用),最后是已配置的 `thread_id` 选项。 -- 当 `name="codex"` 时,默认运行上下文键为 `codex_thread_id`;当 `name="codex_"` 时,则为 `codex_thread_id_`。可以使用 `run_context_thread_id_key` 覆盖它。 +- `persist_session=True` 会为对同一工具实例的重复调用复用一个 Codex 线程。 +- `use_run_context_thread_id=True` 会在共享同一可变上下文对象的多次运行之间,将线程 ID 存储在运行上下文中并复用。 +- 线程 ID 的优先级依次为:每次调用的 `thread_id`、运行上下文线程 ID(如果启用),最后是配置的 `thread_id` 选项。 +- 当 `name="codex"` 时,默认运行上下文键为 `codex_thread_id`;当 `name="codex_"` 时,则为 `codex_thread_id_`。可使用 `run_context_thread_id_key` 覆盖该键。 运行时配置: @@ -895,11 +897,11 @@ agent = Agent( - 环境:`codex_options.env` 完全控制子进程环境。提供该选项后,子进程不会继承 `os.environ`。 - 流限制:`codex_options.codex_subprocess_stream_limit_bytes`(或 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)控制 stdout/stderr 读取器限制。有效范围为 `65536` 到 `67108864`;默认值为 `8388608`。 - 流式传输:`on_stream` 接收线程/轮次生命周期事件和条目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 和 `error` 条目更新)。 -- 输出:结果包括 `response`、`usage` 和 `thread_id`;使用量会添加到 `RunContextWrapper.usage`。 +- 输出:结果包含 `response`、`usage` 和 `thread_id`;使用量会添加到 `RunContextWrapper.usage`。 参考资料: - [Codex 工具 API 参考](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions 参考](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions 参考](ref/extensions/experimental/codex/turn_options.md) -- 有关完整的可运行代码示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file +- 有关完整的可运行示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file From b5944a42bd0e95b6fb9111aa92284f893e956805 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 2 Aug 2026 12:06:22 +0900 Subject: [PATCH 100/473] docs: improve example code details --- examples/basic/non_strict_output_type.py | 16 ++- examples/basic/tool_guardrails.py | 48 ++++---- examples/financial_research_agent/manager.py | 2 +- examples/mcp/filesystem_example/main.py | 2 +- .../mcp/get_all_mcp_tools_example/main.py | 2 +- examples/mcp/prompt_server/main.py | 7 +- examples/mcp/sse_example/main.py | 7 +- examples/mcp/sse_remote_example/main.py | 2 +- .../streamable_http_remote_example/main.py | 2 +- .../main.py | 5 + examples/mcp/streamablehttp_example/main.py | 7 +- examples/mcp/tool_filter_example/main.py | 2 +- examples/realtime/app/server.py | 42 ++++--- examples/realtime/cli/demo.py | 18 ++- examples/reasoning_content/main.py | 71 ++++++----- examples/reasoning_content/runner_example.py | 34 ++++-- examples/research_bot/manager.py | 2 +- .../sample_outputs/product_recs.txt | 2 +- .../research_bot/sample_outputs/vacation.txt | 4 +- .../sandbox/healthcare_support/workflow.py | 2 +- examples/tools/code_interpreter.py | 2 +- examples/tools/codex.py | 2 +- examples/tools/codex_same_thread.py | 2 +- examples/tools/file_search.py | 110 +++++++++++------- examples/tools/image_generator.py | 2 +- 25 files changed, 248 insertions(+), 147 deletions(-) diff --git a/examples/basic/non_strict_output_type.py b/examples/basic/non_strict_output_type.py index fcb7e4f38b..bef2d33450 100644 --- a/examples/basic/non_strict_output_type.py +++ b/examples/basic/non_strict_output_type.py @@ -3,7 +3,14 @@ from dataclasses import dataclass from typing import Any -from agents import Agent, AgentOutputSchema, AgentOutputSchemaBase, ModelBehaviorError, Runner +from agents import ( + Agent, + AgentOutputSchema, + AgentOutputSchemaBase, + ModelBehaviorError, + Runner, + UserError, +) """This example demonstrates how to use an output type that is not in strict mode. Strict mode allows us to guarantee valid JSON output, but some schemas are not strict-compatible. @@ -59,10 +66,11 @@ async def main(): # First, let's try with a strict output type. This should raise an exception. try: - result = await Runner.run(agent, input) - raise AssertionError("Should have raised an exception") - except Exception as e: + await Runner.run(agent, input) + except UserError as e: print(f"Error (expected): {e}") + else: + raise AssertionError("Strict schema validation should have raised UserError") # Now let's try again with a non-strict output type. This should work. # In some cases, it will raise an error - the schema isn't strict, so the model may diff --git a/examples/basic/tool_guardrails.py b/examples/basic/tool_guardrails.py index 4669401537..23bd939cdc 100644 --- a/examples/basic/tool_guardrails.py +++ b/examples/basic/tool_guardrails.py @@ -121,26 +121,23 @@ def reject_phone_numbers(data: ToolOutputGuardrailData) -> ToolGuardrailFunction async def main(): print("=== Tool Guardrails Example ===\n") - try: - # Example 1: Normal operation - should work fine - print("1. Normal email sending:") - result = await Runner.run( - agent, - "Send an email to john@example.com with subject 'Welcome' and body " - "'Welcome to our service.'", - ) - print(f"✅ Successful tool execution: {result.final_output}\n") - - # Example 2: Input guardrail triggers - function tool call is rejected but execution continues - print("2. Attempting to send email with suspicious content:") - result = await Runner.run( - agent, - "Send an email to john@example.com with subject 'Introduction' and body " - "'Introducing ACME corp.'", - ) - print(f"❌ Guardrail rejected function tool call: {result.final_output}\n") - except Exception as e: - print(f"Error: {e}\n") + # Example 1: Normal operation - should work fine + print("1. Normal email sending:") + result = await Runner.run( + agent, + "Send an email to john@example.com with subject 'Welcome' and body " + "'Welcome to our service.'", + ) + print(f"✅ Successful tool execution: {result.final_output}\n") + + # Example 2: Input guardrail triggers - function tool call is rejected but execution continues + print("2. Attempting to send email with suspicious content:") + result = await Runner.run( + agent, + "Send an email to john@example.com with subject 'Introduction' and body " + "'Introducing ACME corp.'", + ) + print(f"❌ Guardrail rejected function tool call: {result.final_output}\n") try: # Example 3: Output guardrail triggers - should raise exception for sensitive data @@ -151,13 +148,10 @@ async def main(): print("🚨 Output guardrail triggered: Execution halted for sensitive data") print(f"Details: {e.output.output_info}\n") - try: - # Example 4: Output guardrail triggers - reject returning function tool output but continue execution - print("4. Rejecting function tool output containing phone numbers:") - result = await Runner.run(agent, "Get contact info for user456") - print(f"❌ Guardrail rejected function tool output: {result.final_output}\n") - except Exception as e: - print(f"Error: {e}\n") + # Example 4: Output guardrail triggers - reject returning function tool output but continue execution + print("4. Rejecting function tool output containing phone numbers:") + result = await Runner.run(agent, "Get contact info for user456") + print(f"❌ Guardrail rejected function tool output: {result.final_output}\n") if __name__ == "__main__": diff --git a/examples/financial_research_agent/manager.py b/examples/financial_research_agent/manager.py index 4ac0ad5843..12b7ae65a0 100644 --- a/examples/financial_research_agent/manager.py +++ b/examples/financial_research_agent/manager.py @@ -76,7 +76,7 @@ async def run(self, query: str) -> None: with trace("Financial research trace", trace_id=trace_id): self.printer.update_item( "trace_id", - f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}", + f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}", is_done=True, hide_checkmark=True, ) diff --git a/examples/mcp/filesystem_example/main.py b/examples/mcp/filesystem_example/main.py index 392c92e419..9c6bb1b93e 100644 --- a/examples/mcp/filesystem_example/main.py +++ b/examples/mcp/filesystem_example/main.py @@ -45,7 +45,7 @@ async def main(): ) as server: trace_id = gen_trace_id() with trace(workflow_name="MCP Filesystem Example", trace_id=trace_id): - print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") await run(server) diff --git a/examples/mcp/get_all_mcp_tools_example/main.py b/examples/mcp/get_all_mcp_tools_example/main.py index e15f58f97b..1a54a7b860 100644 --- a/examples/mcp/get_all_mcp_tools_example/main.py +++ b/examples/mcp/get_all_mcp_tools_example/main.py @@ -73,7 +73,7 @@ async def main(): ) as server: trace_id = gen_trace_id() with trace(workflow_name="MCP get_all_mcp_tools Example", trace_id=trace_id): - print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") print("=== Fetching all tools with strict schemas ===") all_tools = await list_tools(server, convert_to_strict=True) diff --git a/examples/mcp/prompt_server/main.py b/examples/mcp/prompt_server/main.py index 3cd045e63b..ec191b72ba 100644 --- a/examples/mcp/prompt_server/main.py +++ b/examples/mcp/prompt_server/main.py @@ -97,7 +97,7 @@ async def main(): ) as server: trace_id = gen_trace_id() with trace(workflow_name="Simple Prompt Demo", trace_id=trace_id): - print(f"Trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"Trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") await show_available_prompts(server) await demo_code_review(server) @@ -128,4 +128,9 @@ async def main(): finally: if process: process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() print("Server terminated.") diff --git a/examples/mcp/sse_example/main.py b/examples/mcp/sse_example/main.py index 8180914cd3..a282153b8c 100644 --- a/examples/mcp/sse_example/main.py +++ b/examples/mcp/sse_example/main.py @@ -64,7 +64,7 @@ async def main(): ) as server: trace_id = gen_trace_id() with trace(workflow_name="SSE Example", trace_id=trace_id): - print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") await run(server) @@ -102,3 +102,8 @@ async def main(): finally: if process: process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/examples/mcp/sse_remote_example/main.py b/examples/mcp/sse_remote_example/main.py index f62ac1a511..9058ca4584 100644 --- a/examples/mcp/sse_remote_example/main.py +++ b/examples/mcp/sse_remote_example/main.py @@ -77,7 +77,7 @@ async def run(url: str, name: str) -> None: trace_id = gen_trace_id() with trace(workflow_name="SSE MCP Server Example", trace_id=trace_id): - print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") result = await Runner.run(agent, "Use the MCP add tool to add 7 and 22.") print(result.final_output) diff --git a/examples/mcp/streamable_http_remote_example/main.py b/examples/mcp/streamable_http_remote_example/main.py index d0c48da7d9..761bacf9e8 100644 --- a/examples/mcp/streamable_http_remote_example/main.py +++ b/examples/mcp/streamable_http_remote_example/main.py @@ -26,7 +26,7 @@ async def main(): trace_id = gen_trace_id() with trace(workflow_name="DeepWiki Streamable HTTP Example", trace_id=trace_id): - print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") result = await Runner.run( agent, "For the repository openai/codex, tell me the primary programming language.", diff --git a/examples/mcp/streamablehttp_custom_client_example/main.py b/examples/mcp/streamablehttp_custom_client_example/main.py index 20cbef1cdc..8a70cf820a 100644 --- a/examples/mcp/streamablehttp_custom_client_example/main.py +++ b/examples/mcp/streamablehttp_custom_client_example/main.py @@ -135,3 +135,8 @@ async def main(): finally: if process: process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/examples/mcp/streamablehttp_example/main.py b/examples/mcp/streamablehttp_example/main.py index 564a7bf98f..82b5a27fdc 100644 --- a/examples/mcp/streamablehttp_example/main.py +++ b/examples/mcp/streamablehttp_example/main.py @@ -64,7 +64,7 @@ async def main(): ) as server: trace_id = gen_trace_id() with trace(workflow_name="Streamable HTTP Example", trace_id=trace_id): - print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") await run(server) @@ -102,3 +102,8 @@ async def main(): finally: if process: process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/examples/mcp/tool_filter_example/main.py b/examples/mcp/tool_filter_example/main.py index 7f25cf4ae3..9e827065b4 100644 --- a/examples/mcp/tool_filter_example/main.py +++ b/examples/mcp/tool_filter_example/main.py @@ -51,7 +51,7 @@ async def main(): ) trace_id = gen_trace_id() with trace(workflow_name="MCP Tool Filter Example", trace_id=trace_id): - print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n") result = await run_with_auto_approval( agent, f"List the files in this allowed directory: {samples_dir}" ) diff --git a/examples/realtime/app/server.py b/examples/realtime/app/server.py index d92d8b0353..692989768d 100644 --- a/examples/realtime/app/server.py +++ b/examples/realtime/app/server.py @@ -4,8 +4,9 @@ import logging import os import struct -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from dataclasses import asdict +from pathlib import Path from typing import TYPE_CHECKING, Any from fastapi import FastAPI, WebSocket, WebSocketDisconnect @@ -44,12 +45,15 @@ logger = logging.getLogger(__name__) logger.setLevel(_log_level) +STATIC_DIR = Path(__file__).with_name("static") + class RealtimeWebSocketManager: def __init__(self): self.active_sessions: dict[str, RealtimeSession] = {} self.session_contexts: dict[str, Any] = {} self.websockets: dict[str, WebSocket] = {} + self.event_tasks: dict[str, asyncio.Task[None]] = {} async def connect(self, websocket: WebSocket, session_id: str): await websocket.accept() @@ -78,16 +82,26 @@ async def connect(self, websocket: WebSocket, session_id: str): self.session_contexts[session_id] = session_context # Start event processing task - asyncio.create_task(self._process_events(session_id)) + self.event_tasks[session_id] = asyncio.create_task( + self._process_events(session_id), + name=f"realtime-events-{session_id}", + ) async def disconnect(self, session_id: str): - if session_id in self.session_contexts: - await self.session_contexts[session_id].__aexit__(None, None, None) - del self.session_contexts[session_id] - if session_id in self.active_sessions: - del self.active_sessions[session_id] - if session_id in self.websockets: - del self.websockets[session_id] + event_task = self.event_tasks.pop(session_id, None) + try: + if event_task is not None: + event_task.cancel() + with suppress(asyncio.CancelledError): + await event_task + finally: + session_context = self.session_contexts.pop(session_id, None) + try: + if session_context is not None: + await session_context.__aexit__(None, None, None) + finally: + self.active_sessions.pop(session_id, None) + self.websockets.pop(session_id, None) async def send_audio(self, session_id: str, audio_bytes: bytes): if session_id in self.active_sessions: @@ -415,9 +429,9 @@ async def lifespan(app: FastAPI): @app.websocket("/ws/{session_id}") async def websocket_endpoint(websocket: WebSocket, session_id: str): - await manager.connect(websocket, session_id) - image_buffers: dict[str, dict[str, Any]] = {} try: + await manager.connect(websocket, session_id) + image_buffers: dict[str, dict[str, Any]] = {} while True: data = await websocket.receive_text() message = json.loads(data) @@ -567,15 +581,17 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str): await manager.interrupt(session_id) except WebSocketDisconnect: + pass + finally: await manager.disconnect(session_id) -app.mount("/", StaticFiles(directory="static", html=True), name="static") +app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") @app.get("/") async def read_index(): - return FileResponse("static/index.html") + return FileResponse(STATIC_DIR / "index.html") if __name__ == "__main__": diff --git a/examples/realtime/cli/demo.py b/examples/realtime/cli/demo.py index e0eeccb7c8..a51b0e2bad 100644 --- a/examples/realtime/cli/demo.py +++ b/examples/realtime/cli/demo.py @@ -2,6 +2,7 @@ import queue import sys import threading +from contextlib import suppress from typing import Any import numpy as np @@ -59,6 +60,7 @@ def __init__(self) -> None: self.audio_stream: sd.InputStream | None = None self.audio_player: sd.OutputStream | None = None self.recording = False + self.audio_capture_task: asyncio.Task[None] | None = None # Playback tracker lets the model know our real playback progress self.playback_tracker = RealtimePlaybackTracker() @@ -246,6 +248,7 @@ async def run(self) -> None: await self._on_event(event) finally: + await self.stop_audio_recording() # Clean up audio player if self.audio_player and self.audio_player.active: self.audio_player.stop() @@ -267,7 +270,20 @@ async def start_audio_recording(self) -> None: self.recording = True # Start audio capture task - asyncio.create_task(self.capture_audio()) + self.audio_capture_task = asyncio.create_task( + self.capture_audio(), + name="realtime-audio-capture", + ) + + async def stop_audio_recording(self) -> None: + """Stop recording and wait for the audio capture task to release the input stream.""" + self.recording = False + if self.audio_capture_task is None: + return + self.audio_capture_task.cancel() + with suppress(asyncio.CancelledError): + await self.audio_capture_task + self.audio_capture_task = None async def capture_audio(self) -> None: """Capture audio from the microphone and send to the session.""" diff --git a/examples/reasoning_content/main.py b/examples/reasoning_content/main.py index 425e6153a0..4e12775044 100644 --- a/examples/reasoning_content/main.py +++ b/examples/reasoning_content/main.py @@ -1,27 +1,31 @@ """ Example demonstrating how to access reasoning summaries when a model returns them. -Some models, like gpt-5.5, provide a reasoning_content field in addition to the regular content. +Some models, like gpt-5.6, provide reasoning summaries in addition to the regular content. This example shows how to access that content from both streaming and non-streaming responses, -and how to handle responses that do not include a reasoning summary. +and verifies that the requested summary was returned. To run this example, you need to: 1. Set your OPENAI_API_KEY environment variable -2. Use a model that supports reasoning content (e.g., gpt-5.5) +2. Use a model that supports reasoning summaries (e.g., gpt-5.6) """ import asyncio import os -from typing import Any, cast -from openai.types.responses import ResponseOutputRefusal, ResponseOutputText +from openai.types.responses import ( + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, + ResponseReasoningItem, +) from openai.types.shared.reasoning import Reasoning from agents import ModelSettings from agents.models.interface import ModelTracing from agents.models.openai_provider import OpenAIProvider -MODEL_NAME = os.getenv("REASONING_MODEL_NAME") or "gpt-5.5" +MODEL_NAME = os.getenv("REASONING_MODEL_NAME") or "gpt-5.6" async def stream_with_reasoning_content(): @@ -42,7 +46,7 @@ async def stream_with_reasoning_content(): async for event in model.stream_response( system_instructions="You are a helpful assistant that writes creative content.", input="Write a haiku about recursion in programming", - model_settings=ModelSettings(reasoning=Reasoning(effort="medium", summary="detailed")), + model_settings=ModelSettings(reasoning=Reasoning(effort="high", summary="auto")), tools=[], output_schema=None, handoffs=[], @@ -63,7 +67,9 @@ async def stream_with_reasoning_content(): print(f"\033[32m{event.delta}\033[0m", end="", flush=True) regular_content += event.delta if not reasoning_content: - print("\n(No reasoning summary deltas were returned.)") + raise RuntimeError(f"Model {MODEL_NAME} returned no reasoning summary deltas.") + if not regular_content: + raise RuntimeError(f"Model {MODEL_NAME} returned no output text deltas.") print("\n") @@ -76,12 +82,16 @@ async def get_response_with_reasoning_content(): model = provider.get_model(MODEL_NAME) print("\n=== Non-streaming Example ===") - print("Prompt: Explain the concept of recursion in programming") + prompt = ( + "A recursive function uses T(n) = 2 * T(n - 1) + 1 with T(0) = 1. " + "Compute T(20) and derive a closed form." + ) + print(f"Prompt: {prompt}") response = await model.get_response( system_instructions="You are a helpful assistant that explains technical concepts clearly.", - input="Explain the concept of recursion in programming", - model_settings=ModelSettings(reasoning=Reasoning(effort="medium", summary="detailed")), + input=prompt, + model_settings=ModelSettings(reasoning=Reasoning(effort="high", summary="auto")), tools=[], output_schema=None, handoffs=[], @@ -92,36 +102,37 @@ async def get_response_with_reasoning_content(): ) # Extract reasoning content and regular content from the response - reasoning_content = None - regular_content = None + reasoning_parts: list[str] = [] + regular_parts: list[str] = [] for item in response.output: - if hasattr(item, "type") and item.type == "reasoning": - reasoning_content = item.summary[0].text - elif hasattr(item, "type") and item.type == "message": - if item.content and len(item.content) > 0: - content_item = item.content[0] + if isinstance(item, ResponseReasoningItem): + reasoning_parts.extend(summary.text for summary in item.summary) + elif isinstance(item, ResponseOutputMessage): + for content_item in item.content: if isinstance(content_item, ResponseOutputText): - regular_content = content_item.text + regular_parts.append(content_item.text) elif isinstance(content_item, ResponseOutputRefusal): - refusal_item = cast(Any, content_item) - regular_content = refusal_item.refusal + regular_parts.append(content_item.refusal) + + reasoning_content = "\n".join(reasoning_parts) + regular_content = "\n".join(regular_parts) + + if not reasoning_content: + raise RuntimeError(f"Model {MODEL_NAME} returned no reasoning summary.") + if not regular_content: + raise RuntimeError(f"Model {MODEL_NAME} returned no regular output content.") print("\n\n### Reasoning Content:") - print(reasoning_content or "No reasoning content provided") + print(reasoning_content) print("\n\n### Regular Content:") - print(regular_content or "No regular content provided") + print(regular_content) print("\n") async def main(): - try: - await stream_with_reasoning_content() - await get_response_with_reasoning_content() - except Exception as e: - print(f"Error: {e}") - print("\nNote: This example requires a model that supports reasoning content.") - print("You may need to use a specific model like gpt-5.5 or similar.") + await stream_with_reasoning_content() + await get_response_with_reasoning_content() if __name__ == "__main__": diff --git a/examples/reasoning_content/runner_example.py b/examples/reasoning_content/runner_example.py index b5ff0a0ce4..9b1f302a09 100644 --- a/examples/reasoning_content/runner_example.py +++ b/examples/reasoning_content/runner_example.py @@ -6,7 +6,7 @@ To run this example, you need to: 1. Set your OPENAI_API_KEY environment variable -2. Use a model that supports reasoning content (e.g., gpt-5.5) +2. Use a model that supports reasoning summaries (e.g., gpt-5.6) """ import asyncio @@ -17,7 +17,7 @@ from agents import Agent, ModelSettings, Runner, trace from agents.items import ReasoningItem -MODEL_NAME = os.getenv("REASONING_MODEL_NAME") or "gpt-5.5" +MODEL_NAME = os.getenv("REASONING_MODEL_NAME") or "gpt-5.6" async def main(): @@ -28,7 +28,7 @@ async def main(): name="Reasoning Agent", instructions="You are a helpful assistant that explains your reasoning step by step.", model=MODEL_NAME, - model_settings=ModelSettings(reasoning=Reasoning(effort="medium", summary="detailed")), + model_settings=ModelSettings(reasoning=Reasoning(effort="high", summary="auto")), ) # Example 1: Non-streaming response @@ -38,32 +38,48 @@ async def main(): agent, "What is the square root of 841? Please explain your reasoning." ) # Extract reasoning content from the result items - reasoning_content = None + reasoning_parts: list[str] = [] for item in result.new_items: - if isinstance(item, ReasoningItem) and len(item.raw_item.summary) > 0: - reasoning_content = item.raw_item.summary[0].text - break + if isinstance(item, ReasoningItem): + reasoning_parts.extend(summary.text for summary in item.raw_item.summary) + + reasoning_content = "\n".join(reasoning_parts) + + if not reasoning_content: + raise RuntimeError(f"Model {MODEL_NAME} returned no reasoning summary.") print("\n### Reasoning Content:") - print(reasoning_content or "No reasoning content provided") + print(reasoning_content) print("\n### Final Output:") print(result.final_output) # Example 2: Streaming response with trace("Reasoning Content - Streaming"): print("\n=== Example 2: Streaming response ===") - stream = Runner.run_streamed(agent, "What is 15 x 27? Please explain your reasoning.") + stream = Runner.run_streamed( + agent, + "A recursive function uses T(n) = 2 * T(n - 1) + 1 with T(0) = 1. " + "Compute T(20) and derive a closed form.", + ) output_text_already_started = False + saw_reasoning_summary_delta = False + saw_output_text_delta = False async for event in stream.stream_events(): if event.type == "raw_response_event": if event.data.type == "response.reasoning_summary_text.delta": + saw_reasoning_summary_delta = True print(f"\033[33m{event.data.delta}\033[0m", end="", flush=True) elif event.data.type == "response.output_text.delta": + saw_output_text_delta = True if not output_text_already_started: print("\n") output_text_already_started = True print(f"\033[32m{event.data.delta}\033[0m", end="", flush=True) + if not saw_reasoning_summary_delta: + raise RuntimeError(f"Model {MODEL_NAME} returned no streaming reasoning summary.") + if not saw_output_text_delta: + raise RuntimeError(f"Model {MODEL_NAME} returned no streaming output text.") print("\n") diff --git a/examples/research_bot/manager.py b/examples/research_bot/manager.py index 294c88ea08..cbbdb20cec 100644 --- a/examples/research_bot/manager.py +++ b/examples/research_bot/manager.py @@ -23,7 +23,7 @@ async def run(self, query: str) -> None: with trace("Research trace", trace_id=trace_id): self.printer.update_item( "trace_id", - f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}", + f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}", is_done=True, hide_checkmark=True, ) diff --git a/examples/research_bot/sample_outputs/product_recs.txt b/examples/research_bot/sample_outputs/product_recs.txt index fd14d533d7..6fb1f15397 100644 --- a/examples/research_bot/sample_outputs/product_recs.txt +++ b/examples/research_bot/sample_outputs/product_recs.txt @@ -3,7 +3,7 @@ $ uv run python -m examples.research_bot.main What would you like to research? Best surfboards for beginners. I can catch my own waves, but previously used an 11ft board. What should I look for, what are my options? Various budget ranges. -View trace: https://platform.openai.com/traces/trace?trace_id=trace_... +View trace: https://platform.openai.com/logs/trace?trace_id=trace_... Starting research... ✅ Will perform 15 searches ✅ Searching... 15/15 completed diff --git a/examples/research_bot/sample_outputs/vacation.txt b/examples/research_bot/sample_outputs/vacation.txt index 491c000545..8c3bfd8b81 100644 --- a/examples/research_bot/sample_outputs/vacation.txt +++ b/examples/research_bot/sample_outputs/vacation.txt @@ -2,7 +2,7 @@ $ uv run python -m examples.research_bot.main What would you like to research? Caribbean vacation spots in April, optimizing for surfing, hiking and water sports -View trace: https://platform.openai.com/traces/trace?trace_id=trace_.... +View trace: https://platform.openai.com/logs/trace?trace_id=trace_.... Starting research... ✅ Will perform 15 searches ✅ Searching... 15/15 completed @@ -203,4 +203,4 @@ Happy travels! Follow up questions: Would you like detailed profiles for any of the highlighted destinations (e.g., Puerto Rico or Barbados)? Are you interested in more information about booking details and local tour operators in specific islands? -Do you need guidance on combining cultural events with outdoor adventures during your Caribbean vacation? \ No newline at end of file +Do you need guidance on combining cultural events with outdoor adventures during your Caribbean vacation? diff --git a/examples/sandbox/healthcare_support/workflow.py b/examples/sandbox/healthcare_support/workflow.py index 328dda660b..5c55b3e23f 100644 --- a/examples/sandbox/healthcare_support/workflow.py +++ b/examples/sandbox/healthcare_support/workflow.py @@ -453,7 +453,7 @@ async def run_healthcare_support_workflow( ) orchestrator = build_orchestrator(sandbox_policy_tool=sandbox_policy_tool) trace_id = gen_trace_id() - trace_url = f"https://platform.openai.com/traces/trace?trace_id={trace_id}" + trace_url = f"https://platform.openai.com/logs/trace?trace_id={trace_id}" try: async with sandbox: diff --git a/examples/tools/code_interpreter.py b/examples/tools/code_interpreter.py index 9795633b48..23bf167a3b 100644 --- a/examples/tools/code_interpreter.py +++ b/examples/tools/code_interpreter.py @@ -55,7 +55,7 @@ async def main(): print(f"Other event: {event.item.type}") if not saw_code_interpreter_call: - print("No code_interpreter_call item was emitted.") + raise RuntimeError("No code_interpreter_call item was emitted.") print(f"Final output: {result.final_output}") diff --git a/examples/tools/codex.py b/examples/tools/codex.py index 7a11d37768..c19cfb154f 100644 --- a/examples/tools/codex.py +++ b/examples/tools/codex.py @@ -135,7 +135,7 @@ async def main() -> None: ], ) trace_id = gen_trace_id() - log(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}") + log(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}") with trace("Codex tool example", trace_id=trace_id): log("Using the Codex tool to inspect pyproject.toml and summarize Python requirements...") diff --git a/examples/tools/codex_same_thread.py b/examples/tools/codex_same_thread.py index 19cfee534c..3217cdb5da 100644 --- a/examples/tools/codex_same_thread.py +++ b/examples/tools/codex_same_thread.py @@ -98,7 +98,7 @@ class MyContext(BaseModel): # context: dict[str, str] = {} trace_id = gen_trace_id() - log(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}") + log(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}") with trace("Codex same thread example", trace_id=trace_id): log("Turn 1: inspect AGENTS.md with the Codex tool.") diff --git a/examples/tools/file_search.py b/examples/tools/file_search.py index cd5332718c..8489756a0b 100644 --- a/examples/tools/file_search.py +++ b/examples/tools/file_search.py @@ -1,64 +1,84 @@ import asyncio -from openai import OpenAI +from openai import AsyncOpenAI from agents import Agent, FileSearchTool, Runner, trace async def main(): + file_id: str | None = None vector_store_id: str | None = None - if vector_store_id is None: - print("### Preparing vector store:\n") - # Create a new vector store and index a file - client = OpenAI() - text = "Arrakis, the desert planet in Frank Herbert's 'Dune,' was inspired by the scarcity of water as a metaphor for oil and other finite resources." - file_upload = client.files.create( - file=("example.txt", text.encode("utf-8")), - purpose="assistants", - ) - print(f"File uploaded: {file_upload.to_dict()}") + async with AsyncOpenAI() as client: + try: + print("### Preparing vector store:\n") + # Create a temporary vector store and index a file. + text = ( + "Arrakis, the desert planet in Frank Herbert's 'Dune,' was inspired by the " + "scarcity of water as a metaphor for oil and other finite resources." + ) + file_upload = await client.files.create( + file=("example.txt", text.encode("utf-8")), + purpose="assistants", + ) + file_id = file_upload.id + print(f"File uploaded: {file_upload.to_dict()}") - vector_store = client.vector_stores.create(name="example-vector-store") - print(f"Vector store created: {vector_store.to_dict()}") + vector_store = await client.vector_stores.create( + name="example-vector-store", + expires_after={"anchor": "last_active_at", "days": 1}, + ) + vector_store_id = vector_store.id + print(f"Vector store created: {vector_store.to_dict()}") - indexed = client.vector_stores.files.create_and_poll( - vector_store_id=vector_store.id, - file_id=file_upload.id, - ) - print(f"Stored files in vector store: {indexed.to_dict()}") - vector_store_id = vector_store.id + indexed = await client.vector_stores.files.create_and_poll( + vector_store_id=vector_store_id, + file_id=file_id, + ) + print(f"Stored files in vector store: {indexed.to_dict()}") - # Create an agent that can search the vector store - agent = Agent( - name="File searcher", - instructions="You are a helpful agent. You answer only based on the information in the vector store.", - tools=[ - FileSearchTool( - max_num_results=3, - vector_store_ids=[vector_store_id], - include_search_results=True, + # Create an agent that can search the vector store. + agent = Agent( + name="File searcher", + instructions=( + "You are a helpful agent. " + "You answer only based on the information in the vector store." + ), + tools=[ + FileSearchTool( + max_num_results=3, + vector_store_ids=[vector_store_id], + include_search_results=True, + ) + ], ) - ], - ) - with trace("File search example"): - result = await Runner.run( - agent, "Be concise, and tell me 1 sentence about Arrakis I might not know." - ) + with trace("File search example"): + result = await Runner.run( + agent, "Be concise, and tell me 1 sentence about Arrakis I might not know." + ) - print("\n### Final output:\n") - print(result.final_output) - """ - Arrakis, the desert planet in Frank Herbert's "Dune," was inspired by the scarcity of water - as a metaphor for oil and other finite resources. - """ + print("\n### Final output:\n") + print(result.final_output) + """ + Arrakis, the desert planet in Frank Herbert's "Dune," was inspired by the scarcity + of water as a metaphor for oil and other finite resources. + """ - print("\n### Output items:\n") - print("\n".join([str(out.raw_item) + "\n" for out in result.new_items])) - """ - {"id":"...", "queries":["Arrakis"], "results":[...]} - """ + print("\n### Output items:\n") + print("\n".join([str(out.raw_item) + "\n" for out in result.new_items])) + """ + {"id":"...", "queries":["Arrakis"], "results":[...]} + """ + finally: + try: + if vector_store_id is not None: + await client.vector_stores.delete(vector_store_id) + print(f"Deleted vector store: {vector_store_id}") + finally: + if file_id is not None: + await client.files.delete(file_id) + print(f"Deleted file: {file_id}") if __name__ == "__main__": diff --git a/examples/tools/image_generator.py b/examples/tools/image_generator.py index 3dcb7ee4cc..16622cd3df 100644 --- a/examples/tools/image_generator.py +++ b/examples/tools/image_generator.py @@ -71,7 +71,7 @@ async def main(): open_file(temp_path) if not generated_image: - print("No image_generation_call item was returned.") + raise RuntimeError("No image_generation_call item was returned.") if __name__ == "__main__": From 4808a9adf9c8a90a5d9f4ac1488ee5043f3f947a Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 1 Aug 2026 22:22:12 -0500 Subject: [PATCH 101/473] fix(run): replace closed default loop in run_sync (#4102) --- src/agents/run.py | 4 ++++ tests/test_agent_runner_sync.py | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/agents/run.py b/src/agents/run.py index 48fa0bff17..48ee148cb2 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1699,6 +1699,10 @@ def run_sync( default_loop = policy.new_event_loop() policy.set_event_loop(default_loop) + if default_loop.is_closed(): + default_loop = policy.new_event_loop() + policy.set_event_loop(default_loop) + # We intentionally leave the default loop open even if we had to create one above. Session # instances and other helpers stash loop-bound primitives between calls and expect to find # the same default loop every time run_sync is invoked on this thread. diff --git a/tests/test_agent_runner_sync.py b/tests/test_agent_runner_sync.py index 73906e7e93..cb66f5a59f 100644 --- a/tests/test_agent_runner_sync.py +++ b/tests/test_agent_runner_sync.py @@ -66,6 +66,33 @@ async def fake_run(self, *_args, **_kwargs): created_loop.close() +def test_run_sync_replaces_closed_default_loop(monkeypatch, fresh_event_loop_policy): + runner = AgentRunner() + observed_loops: list[asyncio.AbstractEventLoop] = [] + + async def fake_run(self, *_args, **_kwargs): + observed_loops.append(asyncio.get_running_loop()) + return object() + + monkeypatch.setattr(AgentRunner, "run", fake_run, raising=False) + + closed_loop = asyncio.new_event_loop() + fresh_event_loop_policy.set_event_loop(closed_loop) + closed_loop.close() + + try: + runner.run_sync(Agent(name="test-agent"), "input") + replacement_loop = observed_loops[0] + assert replacement_loop is fresh_event_loop_policy.get_event_loop() + assert replacement_loop is not closed_loop + assert not replacement_loop.is_closed() + finally: + current_loop = fresh_event_loop_policy.get_event_loop() + fresh_event_loop_policy.set_event_loop(None) + if not current_loop.is_closed(): + current_loop.close() + + def test_run_sync_errors_when_loop_already_running(monkeypatch, fresh_event_loop_policy): runner = AgentRunner() From 0585084552ccba0549fc3e05c06a304ca2623505 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 2 Aug 2026 12:58:40 +0900 Subject: [PATCH 102/473] chore(ci): preserve cache pruning with setup-uv v9 (#4103) --- .github/workflows/docs.yml | 3 ++- .github/workflows/publish.yml | 3 ++- .github/workflows/release-pr.yml | 3 ++- .github/workflows/tests.yml | 15 ++++++++++----- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 69afcb3e97..8992ff41dd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -36,10 +36,11 @@ jobs: fi - name: Setup uv if: steps.docs-only.outputs.skip != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true - name: Install dependencies if: steps.docs-only.outputs.skip != 'true' run: make sync diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 084d2d7fe4..9085f075c5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,10 +23,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true - name: Install dependencies run: make sync - name: Build package diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 919d23f06c..23eacb77e5 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -21,10 +21,11 @@ jobs: fetch-depth: 0 ref: main - name: Setup uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true - name: Fetch tags run: git fetch origin --tags --prune - name: Ensure release branch does not exist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e33cbc14c..b609de27be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,10 +24,11 @@ jobs: run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" - name: Setup uv if: steps.changes.outputs.run == 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync @@ -51,10 +52,11 @@ jobs: run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" - name: Setup uv if: steps.changes.outputs.run == 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync @@ -86,10 +88,11 @@ jobs: run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" - name: Setup uv if: steps.changes.outputs.run == 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true python-version: ${{ matrix.python-version }} - name: Install dependencies if: steps.changes.outputs.run == 'true' @@ -120,10 +123,11 @@ jobs: run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" - name: Setup uv if: steps.changes.outputs.run == 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true python-version: "3.13" - name: Install dependencies if: steps.changes.outputs.run == 'true' @@ -147,10 +151,11 @@ jobs: run: ./.github/scripts/detect-changes.sh docs "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" - name: Setup uv if: steps.changes.outputs.run == 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" enable-cache: true + prune-cache: true - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync From 6fca125770ad2b38b70f13d2fc52ff903d011d2f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 2 Aug 2026 13:11:39 +0900 Subject: [PATCH 103/473] docs: clarify repo skill resolution in AGENTS.md --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4fd6c51739..bba14e3461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ This guide helps new contributors get started with the OpenAI Agents Python repo ### Mandatory Skill Usage +Repository skills are stored under `.agents/skills/`. A reference such as `$` in this file is a repository instruction reference, not a request for manual user invocation. When a rule requires a skill, read `.agents/skills//SKILL.md` completely before taking task actions, follow its instructions, and resolve referenced files relative to that skill directory. + #### `$code-change-verification` Run `$code-change-verification` before marking work complete when changes affect runtime code, tests, or build/test behavior. From 7cc834a13c21500eb34e259ea4b0397d9ba40cfc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 2 Aug 2026 13:13:14 +0900 Subject: [PATCH 104/473] test: stabilize tracing atexit timeout coverage (#4104) --- tests/test_trace_processor.py | 74 +++++++++++++++++------------------ 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index 7a8ae2a694..9c274267e2 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -5,7 +5,6 @@ import textwrap import threading import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, cast from unittest.mock import MagicMock, patch @@ -615,26 +614,12 @@ def test_batch_trace_processor_shutdown_without_timeout_preserves_export_retries @pytest.mark.serial def test_tracing_atexit_cleanup_timeout_preserves_process_exit_code_on_504() -> None: - request_seen = threading.Event() - - class Always504Handler(BaseHTTPRequestHandler): - def do_POST(self) -> None: - request_seen.set() - self.send_response(504) - self.end_headers() - self.wfile.write(b"gateway timeout") - - def log_message(self, format: str, *args: Any) -> None: - return - - server = ThreadingHTTPServer(("127.0.0.1", 0), Always504Handler) - server_thread = threading.Thread(target=server.serve_forever, daemon=True) - server_thread.start() - script = textwrap.dedent( - f""" + """ import sys + import threading import time + from unittest.mock import patch from agents.tracing import custom_span, trace from agents.tracing.processors import BackendSpanExporter, BatchTraceProcessor @@ -643,13 +628,29 @@ def log_message(self, format: str, *args: Any) -> None: tracing_setup._DEFAULT_SHUTDOWN_TIMEOUT = 0.2 - exporter = BackendSpanExporter( - api_key="test_key", - endpoint="http://127.0.0.1:{server.server_port}/traces/ingest", - max_retries=100, - base_delay=10.0, - max_delay=10.0, - ) + class Always504Response: + status_code = 504 + text = "gateway timeout" + + class Always504Client: + def __init__(self): + self.request_seen = threading.Event() + + def post(self, **kwargs): + self.request_seen.set() + return Always504Response() + + def close(self): + pass + + client = Always504Client() + with patch("agents.tracing.processors.httpx.Client", return_value=client): + exporter = BackendSpanExporter( + api_key="test_key", + max_retries=100, + base_delay=10.0, + max_delay=10.0, + ) processor = BatchTraceProcessor( exporter=exporter, max_queue_size=1, @@ -667,7 +668,7 @@ def timed_shutdown(*args, **kwargs): return original_shutdown(*args, **kwargs) finally: print( - f"shutdown_elapsed={{time.monotonic() - shutdown_started:.6f}}", + f"shutdown_elapsed={time.monotonic() - shutdown_started:.6f}", flush=True, ) @@ -678,24 +679,19 @@ def timed_shutdown(*args, **kwargs): with custom_span("probe-span"): pass - time.sleep(0.3) + assert client.request_seen.wait(timeout=5.0) sys.exit(7) """ ) - try: - result = subprocess.run( - [sys.executable, "-c", script], - check=False, - capture_output=True, - text=True, - timeout=10.0, - ) - finally: - server.shutdown() - server.server_close() + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=10.0, + ) - assert request_seen.is_set() assert result.returncode == 7 shutdown_elapsed_prefix = "shutdown_elapsed=" shutdown_elapsed_lines = [ From fc084ae29cd751b801c2779c9ebd23ff6bad1668 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 2 Aug 2026 01:02:35 -0500 Subject: [PATCH 105/473] fix(run): report tool guardrail results for streamed runs (#4097) --- src/agents/run_internal/run_loop.py | 24 ++ tests/test_agent_runner_streamed.py | 351 +++++++++++++++++++++++++++- 2 files changed, 374 insertions(+), 1 deletion(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 95c5ba1dd8..7f96762eb4 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -449,6 +449,23 @@ async def _finalize_streamed_final_output( streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) +def _accumulate_tool_guardrail_results( + streamed_result: RunResultStreaming, + turn_result: SingleStepResult, +) -> None: + """Carry a turn's tool guardrail results onto the streamed result. + + The non-streaming loop extends its run-wide lists from every turn result, so the streaming + loop has to do the same for `RunResultStreaming` to report the guardrails that ran. + """ + streamed_result.tool_input_guardrail_results = ( + streamed_result.tool_input_guardrail_results + turn_result.tool_input_guardrail_results + ) + streamed_result.tool_output_guardrail_results = ( + streamed_result.tool_output_guardrail_results + turn_result.tool_output_guardrail_results + ) + + async def _finalize_streamed_interruption( *, streamed_result: RunResultStreaming, @@ -858,6 +875,12 @@ async def _save_stream_items_without_count( run_config.model_settings ).store + # The non-streaming resume path extends its run-wide lists before finalizing + # but skips a resumed turn that loops back to the model, so a guardrail that + # re-runs for the same tool call on resume is not counted twice. + if not isinstance(turn_result.next_step, NextStepRunAgain): + _accumulate_tool_guardrail_results(streamed_result, turn_result) + if isinstance(turn_result.next_step, NextStepInterruption): await _finalize_streamed_interruption( streamed_result=streamed_result, @@ -1140,6 +1163,7 @@ async def _save_stream_items_without_count( streamed_result.raw_responses = streamed_result.raw_responses + [ turn_result.model_response ] + _accumulate_tool_guardrail_results(streamed_result, turn_result) input_before_turn_rewrite = streamed_result.input streamed_result.input = turn_result.original_input if isinstance(turn_result.next_step, NextStepHandoff): diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 0b2aca1146..0248e25a8f 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -36,6 +36,9 @@ OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, UserError, function_tool, handoff, @@ -54,7 +57,8 @@ from agents.run_internal import run_loop from agents.run_internal.run_loop import QueueCompleteSentinel from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, StreamEvent -from agents.tool import Tool +from agents.tool import FunctionTool, Tool +from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail from agents.usage import Usage from .fake_model import FakeModel, get_response_obj @@ -2259,3 +2263,348 @@ async def test_streaming_hitl_server_conversation_tracker_priming(): # Should complete successfully without message duplication assert result2.final_output == "Second response" assert len(result2.new_items) >= 1 + + +def _tool_with_guardrails() -> FunctionTool: + """Build a function tool guarded by one input and one output tool guardrail.""" + + @tool_input_guardrail + def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="input-checked") + + @tool_output_guardrail + def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="output-checked") + + @function_tool( + name_override="guarded_tool", + tool_input_guardrails=[record_input], + tool_output_guardrails=[record_output], + ) + def guarded_tool() -> str: + return "tool-result" + + return guarded_tool + + +@pytest.mark.asyncio +async def test_streamed_run_reports_tool_guardrail_results(): + """Streamed runs must expose tool guardrail results like non-streamed runs do.""" + model, agent = make_model_and_agent(tools=[_tool_with_guardrails()]) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("guarded_tool", "{}", call_id="call_1")], + [get_text_message("done")], + ] + ) + + result = Runner.run_streamed(agent, input="hello") + await consume_stream(result) + + assert result.final_output == "done" + assert len(result.tool_input_guardrail_results) == 1 + assert result.tool_input_guardrail_results[0].output.output_info == "input-checked" + assert len(result.tool_output_guardrail_results) == 1 + assert result.tool_output_guardrail_results[0].output.output_info == "output-checked" + + +@pytest.mark.asyncio +async def test_streamed_tool_guardrail_results_match_non_streamed(): + """The same run reports the same tool guardrail results in both execution modes.""" + + def _build() -> tuple[FakeModel, Agent[Any]]: + model, agent = make_model_and_agent(tools=[_tool_with_guardrails()]) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("guarded_tool", "{}", call_id="call_1")], + [get_function_tool_call("guarded_tool", "{}", call_id="call_2")], + [get_text_message("done")], + ] + ) + return model, agent + + _, non_streamed_agent = _build() + non_streamed = await Runner.run(non_streamed_agent, input="hello") + + _, streamed_agent = _build() + streamed = Runner.run_streamed(streamed_agent, input="hello") + await consume_stream(streamed) + + assert len(non_streamed.tool_input_guardrail_results) == 2 + assert len(non_streamed.tool_output_guardrail_results) == 2 + assert len(streamed.tool_input_guardrail_results) == len( + non_streamed.tool_input_guardrail_results + ) + assert len(streamed.tool_output_guardrail_results) == len( + non_streamed.tool_output_guardrail_results + ) + + +@pytest.mark.asyncio +async def test_streamed_tool_guardrail_results_survive_handoff(): + """Tool guardrail results from a handoff turn reach the streamed result.""" + model = FakeModel() + target = Agent(name="target", model=model) + agent = Agent( + name="source", + model=model, + tools=[_tool_with_guardrails()], + handoffs=[target], + ) + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call("guarded_tool", "{}", call_id="call_1"), + get_handoff_tool_call(target), + ], + [get_text_message("done")], + ] + ) + + result = Runner.run_streamed(agent, input="hello") + await consume_stream(result) + + assert result.final_output == "done" + assert len(result.tool_input_guardrail_results) == 1 + assert len(result.tool_output_guardrail_results) == 1 + + +@pytest.mark.asyncio +async def test_streamed_interruption_reports_tool_guardrail_results(): + """An interrupted streamed turn reports the tool guardrail results it produced.""" + + @tool_input_guardrail + def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="input-checked") + + @function_tool(name_override="plain_tool", tool_input_guardrails=[record_input]) + def plain_tool() -> str: + return "plain-result" + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + return "approved-result" + + model, agent = make_model_and_agent(tools=[plain_tool, approval_tool]) + model.set_next_output( + [ + get_function_tool_call("plain_tool", "{}", call_id="call_plain"), + get_function_tool_call("approval_tool", "{}", call_id="call_approval"), + ] + ) + + result = Runner.run_streamed(agent, input="hello") + await consume_stream(result) + + assert len(result.interruptions) == 1 + assert len(result.tool_input_guardrail_results) == 1 + assert result.tool_input_guardrail_results[0].output.output_info == "input-checked" + + +@pytest.mark.asyncio +async def test_streamed_tool_guardrail_results_persist_into_run_state(): + """Tool guardrail results from a streamed run round-trip through RunState.""" + model, agent = make_model_and_agent(tools=[_tool_with_guardrails()]) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("guarded_tool", "{}", call_id="call_1")], + [get_text_message("done")], + ] + ) + + result = Runner.run_streamed(agent, input="hello") + await consume_stream(result) + + state = result.to_state() + assert len(state._tool_input_guardrail_results) == 1 + assert len(state._tool_output_guardrail_results) == 1 + + +@pytest.mark.asyncio +async def test_streamed_resume_tool_guardrail_results_match_non_streamed(): + """Resumed-turn accounting stays identical across execution modes. + + Accumulating tool guardrail results for streamed runs must not change how a resumed turn + reports them, so this pins streamed and non-streamed resumes to the same value rather than + to a specific count. + """ + + def _build() -> tuple[FakeModel, Agent[Any]]: + @tool_input_guardrail + def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="input-checked") + + @tool_output_guardrail + def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="output-checked") + + @function_tool( + name_override="approval_tool", + needs_approval=True, + tool_input_guardrails=[record_input], + tool_output_guardrails=[record_output], + ) + def approval_tool() -> str: + return "approved-result" + + model, agent = make_model_and_agent(tools=[approval_tool]) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("approval_tool", "{}", call_id="call_approval")], + [get_text_message("done")], + ] + ) + return model, agent + + _, non_streamed_agent = _build() + non_streamed_first = await Runner.run(non_streamed_agent, "hello") + assert len(non_streamed_first.interruptions) == 1 + non_streamed_state = non_streamed_first.to_state() + non_streamed_state.approve(non_streamed_first.interruptions[0]) + non_streamed = await Runner.run(non_streamed_agent, non_streamed_state) + + _, streamed_agent = _build() + streamed_first = Runner.run_streamed(streamed_agent, input="hello") + await consume_stream(streamed_first) + assert len(streamed_first.interruptions) == 1 + streamed = await resume_streamed_after_first_approval(streamed_agent, streamed_first) + + assert non_streamed.final_output == "done" + assert streamed.final_output == "done" + assert len(streamed.tool_input_guardrail_results) == len( + non_streamed.tool_input_guardrail_results + ) + assert len(streamed.tool_output_guardrail_results) == len( + non_streamed.tool_output_guardrail_results + ) + + +@pytest.mark.asyncio +async def test_streamed_resume_terminal_turn_reports_tool_guardrail_results(): + """A resumed streamed turn that ends the run reports its tool guardrail results. + + With `tool_use_behavior="stop_on_first_tool"` the approved tool produces the final output + inside the resumed turn, so the run finalizes from the resume branch rather than from the + regular turn loop. + """ + + def _build() -> tuple[FakeModel, Agent[Any]]: + @tool_input_guardrail + def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="input-checked") + + @tool_output_guardrail + def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="output-checked") + + @function_tool( + name_override="approval_tool", + needs_approval=True, + tool_input_guardrails=[record_input], + tool_output_guardrails=[record_output], + ) + def approval_tool() -> str: + return "approved-result" + + model = FakeModel() + agent = Agent( + name="TestAgent", + model=model, + tools=[approval_tool], + tool_use_behavior="stop_on_first_tool", + ) + model.set_next_output( + [get_function_tool_call("approval_tool", "{}", call_id="call_approval")] + ) + return model, agent + + _, streamed_agent = _build() + streamed_first = Runner.run_streamed(streamed_agent, input="hello") + await consume_stream(streamed_first) + assert len(streamed_first.interruptions) == 1 + streamed = await resume_streamed_after_first_approval(streamed_agent, streamed_first) + + assert streamed.final_output == "approved-result" + assert len(streamed.tool_input_guardrail_results) == 1 + assert streamed.tool_input_guardrail_results[0].output.output_info == "input-checked" + assert len(streamed.tool_output_guardrail_results) == 1 + assert streamed.tool_output_guardrail_results[0].output.output_info == "output-checked" + + _, non_streamed_agent = _build() + non_streamed_first = await Runner.run(non_streamed_agent, "hello") + assert len(non_streamed_first.interruptions) == 1 + non_streamed_state = non_streamed_first.to_state() + non_streamed_state.approve(non_streamed_first.interruptions[0]) + non_streamed = await Runner.run(non_streamed_agent, non_streamed_state) + + assert len(streamed.tool_input_guardrail_results) == len( + non_streamed.tool_input_guardrail_results + ) + assert len(streamed.tool_output_guardrail_results) == len( + non_streamed.tool_output_guardrail_results + ) + + +@pytest.mark.asyncio +async def test_streamed_resume_handoff_turn_reports_tool_guardrail_results(): + """A resumed streamed turn that hands off keeps the guardrail results it produced.""" + + def _build() -> tuple[FakeModel, Agent[Any]]: + @tool_input_guardrail + def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="input-checked") + + @tool_output_guardrail + def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="output-checked") + + @function_tool( + name_override="approval_tool", + needs_approval=True, + tool_input_guardrails=[record_input], + tool_output_guardrails=[record_output], + ) + def approval_tool() -> str: + return "approved-result" + + model = FakeModel() + target = Agent(name="target", model=model) + agent = Agent( + name="TestAgent", + model=model, + tools=[approval_tool], + handoffs=[target], + ) + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call("approval_tool", "{}", call_id="call_approval"), + get_handoff_tool_call(target), + ], + [get_text_message("done")], + ] + ) + return model, agent + + _, streamed_agent = _build() + streamed_first = Runner.run_streamed(streamed_agent, input="hello") + await consume_stream(streamed_first) + assert len(streamed_first.interruptions) == 1 + streamed = await resume_streamed_after_first_approval(streamed_agent, streamed_first) + + assert streamed.final_output == "done" + assert len(streamed.tool_input_guardrail_results) == 1 + assert len(streamed.tool_output_guardrail_results) == 1 + + _, non_streamed_agent = _build() + non_streamed_first = await Runner.run(non_streamed_agent, "hello") + non_streamed_state = non_streamed_first.to_state() + non_streamed_state.approve(non_streamed_first.interruptions[0]) + non_streamed = await Runner.run(non_streamed_agent, non_streamed_state) + + assert len(streamed.tool_input_guardrail_results) == len( + non_streamed.tool_input_guardrail_results + ) + assert len(streamed.tool_output_guardrail_results) == len( + non_streamed.tool_output_guardrail_results + ) From c06e1e3b09fcf67572b66aa022f95d2e9e3bea50 Mon Sep 17 00:00:00 2001 From: chinmayv095 Date: Mon, 3 Aug 2026 02:43:26 +0530 Subject: [PATCH 106/473] fix(memory): enforce closed state in AsyncSQLiteSession (#4109) --- .../extensions/memory/async_sqlite_session.py | 20 ++++++- .../memory/test_async_sqlite_session.py | 59 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/agents/extensions/memory/async_sqlite_session.py b/src/agents/extensions/memory/async_sqlite_session.py index 7094e1a0f0..06d0cc1755 100644 --- a/src/agents/extensions/memory/async_sqlite_session.py +++ b/src/agents/extensions/memory/async_sqlite_session.py @@ -59,6 +59,7 @@ def __init__( self._connection: aiosqlite.Connection | None = None self._lock = asyncio.Lock() self._init_lock = asyncio.Lock() + self._closed = False async def _init_db_for_connection(self, conn: aiosqlite.Connection) -> None: """Initialize the database schema for a specific connection.""" @@ -107,10 +108,16 @@ async def _get_connection(self) -> aiosqlite.Connection: return self._connection + def _check_not_closed(self) -> None: + """Raise if the session has already been closed.""" + if self._closed: + raise RuntimeError("AsyncSQLiteSession is closed") + @asynccontextmanager async def _locked_connection(self) -> AsyncIterator[aiosqlite.Connection]: """Provide a connection under the session lock.""" async with self._lock: + self._check_not_closed() conn = await self._get_connection() yield conn @@ -195,6 +202,7 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: Args: items: List of input items to add to the history """ + self._check_not_closed() if not items: return @@ -288,9 +296,15 @@ async def clear_session(self) -> None: await conn.commit() async def close(self) -> None: - """Close the database connection.""" - if self._connection is None: - return + """Close the database connection. + + The session becomes terminal from the first close attempt: subsequent + operations raise RuntimeError rather than reopening the database. Repeated + and concurrent calls are safe no-ops. + """ async with self._lock: + self._closed = True + if self._connection is None: + return await self._connection.close() self._connection = None diff --git a/tests/extensions/memory/test_async_sqlite_session.py b/tests/extensions/memory/test_async_sqlite_session.py index 2a9a21936b..b45cbdf4e7 100644 --- a/tests/extensions/memory/test_async_sqlite_session.py +++ b/tests/extensions/memory/test_async_sqlite_session.py @@ -436,3 +436,62 @@ async def test_async_sqlite_session_pop_item_same_timestamp_returns_latest(): assert _item_ids(remaining) == ["rs_pop_same_ts"] await session.close() + + +async def test_async_sqlite_session_closed_operations_raise_runtime_error(): + """Operations on a closed session must fail instead of reopening the database.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "closed_state.db" + session = AsyncSQLiteSession("closed_state_test", db_path) + await session.add_items([{"role": "user", "content": "before close"}]) + await session.close() + + with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): + await session.get_items() + + with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): + await session.add_items([{"role": "user", "content": "after close"}]) + + with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): + await session.pop_item() + + with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): + await session.clear_session() + + +async def test_async_sqlite_session_closed_rejects_empty_add_items(): + """add_items([]) must not bypass the closed check through the empty-list fast path.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "closed_empty_add.db" + session = AsyncSQLiteSession("closed_empty_add_test", db_path) + await session.close() + + with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): + await session.add_items([]) + + +async def test_async_sqlite_session_close_before_use_is_terminal(): + """close() before the connection is opened must still make the session terminal.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "close_before_use.db" + session = AsyncSQLiteSession("close_before_use_test", db_path) + await session.close() + + with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): + await session.get_items() + + +async def test_async_sqlite_session_close_is_idempotent(): + """Repeated and concurrent close() calls must remain safe no-ops.""" + import asyncio + + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "close_idempotent.db" + session = AsyncSQLiteSession("close_idempotent_test", db_path) + await session.add_items([{"role": "user", "content": "before close"}]) + + await asyncio.gather(session.close(), session.close()) + await session.close() + + with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): + await session.get_items() From 718acdeb9ef5a8ac68a9bfcbb0794f6419fd6826 Mon Sep 17 00:00:00 2001 From: LHMQ878 <72402929@cityu-dg.edu.cn> Date: Mon, 3 Aug 2026 05:14:41 +0800 Subject: [PATCH 107/473] fix(trimmer): keep definition names and instance data in trimmed schemas (#4110) --- src/agents/extensions/tool_output_trimmer.py | 36 ++++- tests/extensions/test_tool_output_trimmer.py | 132 +++++++++++++++++++ 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/agents/extensions/tool_output_trimmer.py b/src/agents/extensions/tool_output_trimmer.py index 39c0c42d3b..d6fab350a0 100644 --- a/src/agents/extensions/tool_output_trimmer.py +++ b/src/agents/extensions/tool_output_trimmer.py @@ -40,6 +40,28 @@ logger = logging.getLogger(__name__) +# Prose keywords worth dropping from a replayed schema: they cost tokens but the model can +# still call the tool without them. +_PROSE_SCHEMA_KEYWORDS = frozenset({"description", "title", "$comment", "examples"}) + +# Keywords whose value is a map keyed by *user-chosen names* — parameter names, definition +# names, regexes — rather than by schema keywords. Their keys must survive even when they +# spell one of the prose keywords above, so they are recursed into by value only. +_NAME_KEYED_SCHEMA_MAPS = frozenset( + { + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependentRequired", + } +) + +# Keywords whose value is instance *data* rather than a subschema. Nothing inside them is a +# schema keyword, so they are copied through untouched. +_DATA_SCHEMA_KEYWORDS = frozenset({"default", "const", "enum"}) + @dataclass class ToolOutputTrimmer: @@ -289,15 +311,21 @@ def _trim_json_schema(self, schema: dict[str, Any]) -> dict[str, Any]: """Remove verbose prose from a JSON schema while preserving its structure.""" trimmed_schema: dict[str, Any] = {} for key, value in schema.items(): - # Keys of a "properties" mapping are parameter names, not schema keywords, so - # they must survive even when they collide with the prose keywords below. - if key == "properties" and isinstance(value, dict): + # A name-keyed map is keyed by parameter/definition names, not by schema + # keywords, so recurse into its values while keeping its keys verbatim. + # Dropping a key here would delete a declared parameter or dangle a $ref. + if key in _NAME_KEYED_SCHEMA_MAPS and isinstance(value, dict): trimmed_schema[key] = { name: self._trim_json_schema(sub) if isinstance(sub, dict) else sub for name, sub in value.items() } continue - if key in {"description", "title", "$comment", "examples"}: + # These hold instance data. A "title" key inside a default value is part of the + # value, so trimming it would silently change the tool's contract. + if key in _DATA_SCHEMA_KEYWORDS: + trimmed_schema[key] = value + continue + if key in _PROSE_SCHEMA_KEYWORDS: continue if isinstance(value, dict): trimmed_schema[key] = self._trim_json_schema(value) diff --git a/tests/extensions/test_tool_output_trimmer.py b/tests/extensions/test_tool_output_trimmer.py index 8277b75ed4..58fcdbd191 100644 --- a/tests/extensions/test_tool_output_trimmer.py +++ b/tests/extensions/test_tool_output_trimmer.py @@ -423,6 +423,138 @@ def test_keeps_tool_parameters_named_like_schema_keywords(self) -> None: # Schema-level prose is still trimmed. assert "description" not in trimmed_parameters + def test_keeps_definition_names_and_instance_data_in_schema(self) -> None: + """Name-keyed schema maps keep their keys; data-valued keywords stay verbatim. + + ``properties`` is not the only map keyed by user-chosen names, and ``default`` / + ``const`` / ``enum`` hold instance data rather than subschemas. Trimming a prose + keyword out of either dangles a ``$ref`` or rewrites the tool's contract. + """ + parameters = { + "type": "object", + "description": "schema prose " * 200, + "$defs": { + "description": {"type": "object", "properties": {"text": {"type": "string"}}}, + "Priority": {"enum": ["low", "high"]}, + }, + "definitions": {"title": {"type": "string"}}, + "patternProperties": {"title": {"type": "string"}}, + "dependentSchemas": {"title": {"required": ["note"]}}, + "dependentRequired": {"title": ["note"]}, + "properties": { + "note": {"$ref": "#/$defs/description"}, + "prio": {"$ref": "#/$defs/Priority"}, + "opts": { + "type": "object", + "description": "options " * 200, + "default": {"title": "Untitled", "description": "auto", "retries": 3}, + }, + "mode": {"const": {"title": "A", "kind": "fast"}}, + "choice": {"enum": [{"title": "A", "id": 1}, {"title": "B", "id": 2}]}, + }, + "required": ["note"], + } + items = [ + _user("q1"), + {"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "reports"}}, + { + "type": "tool_search_output", + "call_id": "ts1", + "tools": [ + { + "type": "function", + "name": "make_report", + "description": "tool description " * 200, + "parameters": parameters, + } + ], + }, + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=60) + result = trimmer(_make_data(items)) + trimmed_item_dict = cast(dict[str, Any], result.input[2]) + trimmed = trimmed_item_dict["tools"][0]["parameters"] + + # Every $ref still resolves — deleting a definition would silently break the schema. + assert sorted(trimmed["$defs"]) == ["Priority", "description"] + for name in ("note", "prio"): + target = trimmed["properties"][name]["$ref"].removeprefix("#/$defs/") + assert target in trimmed["$defs"] + + # The other name-keyed maps keep their keys too. + assert sorted(trimmed["definitions"]) == ["title"] + assert sorted(trimmed["patternProperties"]) == ["title"] + assert sorted(trimmed["dependentSchemas"]) == ["title"] + assert trimmed["dependentRequired"] == {"title": ["note"]} + + # Instance data is preserved byte for byte. + assert trimmed["properties"]["opts"]["default"] == { + "title": "Untitled", + "description": "auto", + "retries": 3, + } + assert trimmed["properties"]["mode"]["const"] == {"title": "A", "kind": "fast"} + assert trimmed["properties"]["choice"]["enum"] == [ + {"title": "A", "id": 1}, + {"title": "B", "id": 2}, + ] + + # Prose is still trimmed, at the schema level and inside a nested subschema. + assert "description" not in trimmed + assert "description" not in trimmed["properties"]["opts"] + + def test_trims_prose_inside_genuine_subschema_keywords(self) -> None: + """Keywords whose value really is a subschema must keep getting trimmed.""" + parameters = { + "type": "object", + "description": "schema prose " * 200, + "properties": { + "tags": { + "type": "array", + "items": {"type": "string", "description": "a tag " * 200}, + }, + "bag": { + "type": "object", + "propertyNames": {"pattern": "^x", "description": "a key " * 200}, + }, + }, + } + items = [ + _user("q1"), + {"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "tags"}}, + { + "type": "tool_search_output", + "call_id": "ts1", + "tools": [ + { + "type": "function", + "name": "tag_it", + "description": "tool description " * 200, + "parameters": parameters, + } + ], + }, + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=60) + result = trimmer(_make_data(items)) + trimmed_item_dict = cast(dict[str, Any], result.input[2]) + trimmed = trimmed_item_dict["tools"][0]["parameters"] + + assert trimmed["properties"]["tags"]["items"] == {"type": "string"} + assert trimmed["properties"]["bag"]["propertyNames"] == {"pattern": "^x"} + def test_trims_legacy_tool_search_output_results(self) -> None: """Legacy tool_search_output snapshots with free-text results should still trim.""" large = "x" * 2000 From 6e2095d918a3875ba367ccbd9cc03ecb70ce2986 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 06:21:12 +0900 Subject: [PATCH 108/473] docs: update agent references on the client-side validation --- .../references/model-provider-boundaries.md | 19 ++++++++++++++----- .agents/references/tool-identity.md | 2 ++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.agents/references/model-provider-boundaries.md b/.agents/references/model-provider-boundaries.md index eea57091f6..5273a9e5a1 100644 --- a/.agents/references/model-provider-boundaries.md +++ b/.agents/references/model-provider-boundaries.md @@ -31,6 +31,14 @@ Do not infer that a feature available in one adapter is supported by every `Mode Validate capabilities at the adapter boundary where the resolved model and complete request are known. Avoid public flags that appear accepted by the SDK but are silently dropped before the provider request. +## Provider Validation and Error Ownership + +Do not duplicate provider-side request validation in the SDK merely to fail earlier. When the provider already rejects an invalid value with an actionable error, preserve that single source of truth instead of copying provider grammar, length limits, enum membership, or other request constraints into SDK runtime code. Duplicated validation can drift as provider contracts evolve, can reject values accepted by another provider, and can turn a provider-neutral SDK type into an accidental provider-specific contract. + +Add SDK-side validation only when it enforces an SDK-owned invariant or prevents a concrete risk that provider validation cannot address. Examples include ambiguous local routing, collisions before request serialization, invalid persisted state, unsafe local side effects, or a provider error that cannot identify the offending SDK input. A generic preference for earlier failure or a different error message is not sufficient. + +When local validation is justified and the constraint is provider-specific, keep it at the owning adapter boundary and derive it from an authoritative provider contract. Do not apply it to shared `Model` interfaces, provider-neutral tool types, or third-party adapters. Tests should distinguish the SDK-owned invariant from values that are intentionally left for the provider to validate. + ## Provider Data and Terminal Semantics - Preserve provider-supplied string IDs, request IDs, usage, and opaque provider data when the public SDK contract exposes them. @@ -55,11 +63,12 @@ Validate capabilities at the adapter boundary where the resolved model and compl ## Review Checklist 1. Identify which adapter owns the feature and how unsupported adapters behave. -2. Verify model and implicit-settings resolution when run config overrides the agent. -3. Compare HTTP/websocket and streaming/non-streaming terminal behavior when applicable. -4. Preserve request IDs, usage, provider data, and error semantics through normalization. -5. Prove retries are safe for the request's state ownership and side effects. -6. Test transport reuse, cross-loop access, closed-loop pruning, and provider shutdown when persistent connections are involved. +2. Before adding validation, determine whether it protects an SDK-owned invariant or only duplicates an actionable provider error. +3. Verify model and implicit-settings resolution when run config overrides the agent. +4. Compare HTTP/websocket and streaming/non-streaming terminal behavior when applicable. +5. Preserve request IDs, usage, provider data, and error semantics through normalization. +6. Prove retries are safe for the request's state ownership and side effects. +7. Test transport reuse, cross-loop access, closed-loop pruning, and provider shutdown when persistent connections are involved. ## Sources diff --git a/.agents/references/tool-identity.md b/.agents/references/tool-identity.md index 3465972c48..808e21e89e 100644 --- a/.agents/references/tool-identity.md +++ b/.agents/references/tool-identity.md @@ -18,6 +18,8 @@ One tool can have several related identifiers. They are not interchangeable. Do not collapse these layers into one string or introduce local rules that only one caller uses. +Provider wire-name grammar and length limits remain provider-owned validation unless an SDK-owned routing invariant requires local enforcement. Follow [Model and Provider Boundaries](model-provider-boundaries.md#provider-validation-and-error-ownership) before adding name validation to shared tool types or canonical identity helpers. + ## Canonical Helpers Use `src/agents/_tool_identity.py` as the single implementation layer. Important helpers include: From c1d40890c633063817016afe71ce2836a2fc6f25 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 2 Aug 2026 17:18:42 -0500 Subject: [PATCH 109/473] fix(memory): avoid creating remote conversation on uninitialized clear_session (#4111) --- .../memory/openai_conversations_session.py | 22 ++-- .../test_openai_conversations_session.py | 101 ++++++++++++++++-- 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/agents/memory/openai_conversations_session.py b/src/agents/memory/openai_conversations_session.py index 9114a7dea0..4aee981918 100644 --- a/src/agents/memory/openai_conversations_session.py +++ b/src/agents/memory/openai_conversations_session.py @@ -71,11 +71,10 @@ def session_id(self, value: str) -> None: self._session_id = value async def _get_session_id(self) -> str: - if self._session_id is None: - async with self._session_id_lock: - if self._session_id is None: - self._session_id = await start_openai_conversations_session(self._openai_client) - return self._session_id + async with self._session_id_lock: + if self._session_id is None: + self._session_id = await start_openai_conversations_session(self._openai_client) + return self._session_id async def _clear_session_id(self) -> None: self._session_id = None @@ -129,8 +128,11 @@ async def pop_item(self) -> TResponseInputItem | None: return items[0] async def clear_session(self) -> None: - session_id = await self._get_session_id() - await self._openai_client.conversations.delete( - conversation_id=session_id, - ) - await self._clear_session_id() + async with self._session_id_lock: + if self._session_id is None: + return + + await self._openai_client.conversations.delete( + conversation_id=self._session_id, + ) + self._session_id = None diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index 2e241b88b8..958832bd12 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -275,19 +276,107 @@ async def test_clear_session(self, mock_openai_client): assert session._session_id is None @pytest.mark.asyncio - async def test_clear_session_creates_session_id_first(self, mock_openai_client): - """Test that clear_session creates session_id if it doesn't exist.""" + async def test_clear_session_uninitialized_does_not_create_session(self, mock_openai_client): + """Test that clear_session on an uninitialized session does not call create or delete.""" session = OpenAIConversationsSession(openai_client=mock_openai_client) await session.clear_session() - # Should create conversation first, then delete it - mock_openai_client.conversations.create.assert_called_once_with(items=[]) - mock_openai_client.conversations.delete.assert_called_once_with( - conversation_id="test_conversation_id" + mock_openai_client.conversations.create.assert_not_called() + mock_openai_client.conversations.delete.assert_not_called() + assert session._session_id is None + + @pytest.mark.asyncio + async def test_clear_session_uninitialized_no_api_calls_on_create_failure( + self, mock_openai_client + ): + """Test that clear_session on an uninitialized session succeeds even if create raises.""" + mock_openai_client.conversations.create.side_effect = RuntimeError("API connection error") + session = OpenAIConversationsSession(openai_client=mock_openai_client) + + await session.clear_session() + + mock_openai_client.conversations.create.assert_not_called() + mock_openai_client.conversations.delete.assert_not_called() + assert session._session_id is None + + @pytest.mark.asyncio + async def test_clear_session_failed_delete_retains_session_id(self, mock_openai_client): + """Test that a failed delete retains the session ID for potential retries.""" + mock_openai_client.conversations.delete.side_effect = RuntimeError("Delete failed") + session = OpenAIConversationsSession( + conversation_id="test_id", openai_client=mock_openai_client + ) + + with pytest.raises(RuntimeError, match="Delete failed"): + await session.clear_session() + + assert session._session_id == "test_id" + + @pytest.mark.asyncio + async def test_clear_session_retry_after_failed_delete(self, mock_openai_client): + """Test that retrying clear_session after a failed delete targets the same ID + without calling create. + """ + mock_openai_client.conversations.delete.side_effect = [ + RuntimeError("Transient delete error"), + None, + ] + session = OpenAIConversationsSession( + conversation_id="test_id", openai_client=mock_openai_client ) + + with pytest.raises(RuntimeError, match="Transient delete error"): + await session.clear_session() + + assert session._session_id == "test_id" + + # Retry clear_session + await session.clear_session() + + mock_openai_client.conversations.create.assert_not_called() + assert mock_openai_client.conversations.delete.call_count == 2 + mock_openai_client.conversations.delete.assert_called_with(conversation_id="test_id") assert session._session_id is None + @pytest.mark.asyncio + async def test_clear_session_concurrent_get_does_not_clobber_new_session_id( + self, mock_openai_client + ): + """Test that a concurrent _get_session_id during clear_session waits for lock + and preserves new ID. + """ + + session = OpenAIConversationsSession( + conversation_id="old_id", openai_client=mock_openai_client + ) + mock_openai_client.conversations.create.return_value = MagicMock(id="new_id") + + delete_started = asyncio.Event() + allow_delete_finish = asyncio.Event() + + async def slow_delete(*args: Any, **kwargs: Any) -> Any: + delete_started.set() + await allow_delete_finish.wait() + return None + + mock_openai_client.conversations.delete.side_effect = slow_delete + + clear_task = asyncio.create_task(session.clear_session()) + await delete_started.wait() + + # Concurrently attempt _get_session_id() while clear_session is deleting + get_task = asyncio.create_task(session._get_session_id()) + + # Allow delete to complete + allow_delete_finish.set() + await clear_task + new_id = await get_task + + assert new_id == "new_id" + assert session._session_id == "new_id" + mock_openai_client.conversations.create.assert_called_once_with(items=[]) + class TestOpenAIConversationsSessionRunnerIntegration: """Test integration with Agent Runner using simple mocking.""" From bfcfcfc9d807c69a939ce4ab7f1be8e13e18e577 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 2 Aug 2026 17:34:02 -0500 Subject: [PATCH 110/473] fix(realtime): clamp interrupt truncation to received audio (#4122) --- src/agents/realtime/openai_realtime.py | 4 ++ tests/realtime/test_playback_tracker.py | 70 ++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 6f0cb1ce41..690dfee4f5 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -931,6 +931,10 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: _, max_audio_ms = audio_limits truncated_ms = max(int(elapsed_ms), 0) if self._ongoing_response or max_audio_ms is None or truncated_ms < max_audio_ms: + if max_audio_ms is not None: + # Never truncate past the audio this client received: the Realtime API + # rejects an audio_end_ms beyond the item's audio duration. + truncated_ms = min(truncated_ms, max_audio_ms) converted = _ConversionHelper.convert_interrupt( current_item_id, current_item_content_index, diff --git a/tests/realtime/test_playback_tracker.py b/tests/realtime/test_playback_tracker.py index 8133ac4401..2e426230a2 100644 --- a/tests/realtime/test_playback_tracker.py +++ b/tests/realtime/test_playback_tracker.py @@ -96,7 +96,75 @@ async def test_interrupt_sends_truncate_when_ongoing_response(self, model): if getattr(call.args[0], "type", None) == "conversation.item.truncate" ] assert truncate_events - assert truncate_events[0].audio_end_ms == 2000 + # The truncation point stays within the audio the client actually received. + assert truncate_events[0].audio_end_ms == 1000 + + @pytest.mark.asyncio + async def test_interrupt_clamps_truncate_to_received_audio_while_response_ongoing(self, model): + """Default timing must not truncate past the audio the client received. + + Without a custom playback tracker the elapsed time is wall clock since the first + audio delta, so it outgrows the received audio whenever the model pauses between + deltas. The Realtime API rejects a truncate whose ``audio_end_ms`` exceeds the + item's audio duration, so the value has to be clamped. + """ + model._ongoing_response = True + model._send_raw_message = AsyncMock() + model._audio_state_tracker.set_audio_format("pcm16") + + # 48_000 bytes of PCM16 at 24kHz equals ~1000ms of audio. + with patch("agents.realtime._default_tracker.time.monotonic", return_value=100.0): + model._audio_state_tracker.on_audio_delta("item_1", 0, b"a" * 48_000) + + with patch("agents.realtime.openai_realtime.time.monotonic", return_value=105.0): + await model._send_interrupt(RealtimeModelSendInterrupt()) + + truncate_events = [ + call.args[0] + for call in model._send_raw_message.await_args_list + if getattr(call.args[0], "type", None) == "conversation.item.truncate" + ] + assert truncate_events + assert truncate_events[0].audio_end_ms == 1000 + + @pytest.mark.asyncio + async def test_interrupt_matches_speech_started_truncation_point(self, model, monkeypatch): + """Explicit interrupts and VAD barge-in must truncate at the same point.""" + + async def truncate_ms_for(interrupt: bool) -> int: + fresh = OpenAIRealtimeWebSocketModel() + fresh._ongoing_response = True + send_raw = AsyncMock() + monkeypatch.setattr(fresh, "_send_raw_message", send_raw) + fresh._audio_state_tracker.set_audio_format("pcm16") + + with patch("agents.realtime._default_tracker.time.monotonic", return_value=100.0): + fresh._audio_state_tracker.on_audio_delta("item_1", 0, b"a" * 48_000) + + with patch("agents.realtime.openai_realtime.time.monotonic", return_value=105.0): + if interrupt: + await fresh._send_interrupt(RealtimeModelSendInterrupt()) + else: + await fresh._handle_ws_event( + { + "type": "input_audio_buffer.speech_started", + "event_id": "e1", + "item_id": "item_1", + "audio_start_ms": 0, + "audio_end_ms": 0, + } + ) + + truncate_events = [ + call.args[0] + for call in send_raw.await_args_list + if getattr(call.args[0], "type", None) == "conversation.item.truncate" + ] + assert truncate_events + audio_end_ms: int = truncate_events[0].audio_end_ms + return audio_end_ms + + assert await truncate_ms_for(interrupt=True) == await truncate_ms_for(interrupt=False) def test_audio_delta_before_set_audio_format_does_not_raise(self): """ModelAudioTracker must tolerate audio deltas before a format is negotiated. From 9d894a9032e8d373f8b54d81591db36ce75b315d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 08:24:24 +0900 Subject: [PATCH 111/473] fix: preserve approved tool output on streamed resume (#4126) --- src/agents/run_internal/run_loop.py | 11 +++- tests/test_agent_runner_streamed.py | 90 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 7f96762eb4..0cdc34a7ca 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -432,7 +432,13 @@ async def _finalize_streamed_final_output( items: list[RunItem], response_id: str | None, store_setting: bool | None, + persist_before_output_guardrails: bool, ) -> None: + if persist_before_output_guardrails: + # A resumed approval has already committed the tool side effect, so keep its call/output + # pair even when an agent output guardrail blocks delivery of the final result. + await save_items(items, response_id, store_setting) + output_guardrail_results = await _run_output_guardrails_for_stream( agent=agent, run_config=run_config, @@ -444,7 +450,8 @@ async def _finalize_streamed_final_output( streamed_result.final_output = output streamed_result.is_complete = True - await save_items(items, response_id, store_setting) + if not persist_before_output_guardrails: + await save_items(items, response_id, store_setting) streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) @@ -923,6 +930,7 @@ async def _save_stream_items_without_count( items=list(turn_session_items), response_id=turn_result.model_response.response_id, store_setting=store_setting, + persist_before_output_guardrails=True, ) break @@ -1236,6 +1244,7 @@ async def _save_stream_items_without_count( items=turn_session_items, response_id=turn_result.model_response.response_id, store_setting=store_setting, + persist_before_output_guardrails=False, ) break elif isinstance(turn_result.next_step, NextStepInterruption): diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 0248e25a8f..7cc04fa741 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -1985,6 +1985,96 @@ async def test_tool() -> str: assert output_count == 1 +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"]) +@pytest.mark.asyncio +async def test_resumed_approved_tool_final_persists_call_output_before_output_guardrails( + mode: str, + tripwire: bool, +) -> None: + guardrail_state = {"tripwire": tripwire} + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + return "approved-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=guardrail_state["tripwire"], + ) + + model = FakeModel() + model.set_next_output([get_function_tool_call("approval_tool", "{}", call_id="call-approved")]) + agent = Agent( + name="test", + model=model, + tools=[approval_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + async def run_once(input_value: Any) -> Any: + if mode == "non_streamed": + return await Runner.run(agent, input_value, session=session) + result = Runner.run_streamed(agent, input_value, session=session) + await consume_stream(result) + return result + + first = await run_once("Use approval_tool") + assert first.interruptions + state = first.to_state() + state.approve(first.interruptions[0]) + + if tripwire: + with pytest.raises(OutputGuardrailTripwireTriggered): + await run_once(state) + else: + resumed = await run_once(state) + assert resumed.final_output == "approved-result" + + saved_items = await session.get_items() + saved_types = [ + item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict) + ] + assert saved_types == ["user", "function_call", "function_call_output"] + saved_tool_items = [ + item + for item in saved_items + if isinstance(item, dict) and item.get("type") in {"function_call", "function_call_output"} + ] + assert [(item.get("type"), item.get("call_id")) for item in saved_tool_items] == [ + ("function_call", "call-approved"), + ("function_call_output", "call-approved"), + ] + assert saved_tool_items[1].get("output") == "approved-result" + + if tripwire: + guardrail_state["tripwire"] = False + model.set_next_output([get_text_message("done")]) + next_result = await run_once("Continue") + assert next_result.final_output == "done" + + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + replayed_tool_items = [ + item + for item in model_input + if isinstance(item, dict) + and item.get("type") in {"function_call", "function_call_output"} + ] + assert [(item.get("type"), item.get("call_id")) for item in replayed_tool_items] == [ + ("function_call", "call-approved"), + ("function_call_output", "call-approved"), + ] + assert replayed_tool_items[1].get("output") == "approved-result" + + @pytest.mark.asyncio async def test_streaming_resume_preserves_filtered_model_input_after_handoff(): model = FakeModel() From d6f82c00676c2f835d214f36fcd65ee147602420 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 09:11:21 +0900 Subject: [PATCH 112/473] fix(realtime): apply output guardrails to text deltas (#4124) --- docs/realtime/guide.md | 6 +- src/agents/realtime/__init__.py | 2 + src/agents/realtime/config.py | 4 +- src/agents/realtime/model.py | 15 + src/agents/realtime/model_events.py | 15 + src/agents/realtime/model_inputs.py | 9 + src/agents/realtime/openai_realtime.py | 80 ++++- src/agents/realtime/session.py | 144 +++++++-- tests/realtime/test_openai_realtime.py | 222 +++++++++++++ tests/realtime/test_session.py | 423 +++++++++++++++++++++++++ 10 files changed, 880 insertions(+), 40 deletions(-) diff --git a/docs/realtime/guide.md b/docs/realtime/guide.md index 47ac550a76..172f21804e 100644 --- a/docs/realtime/guide.md +++ b/docs/realtime/guide.md @@ -268,7 +268,7 @@ Bare `RealtimeAgent` handoffs are auto-wrapped, and `realtime_handoff(...)` lets ### Guardrails -Realtime agents support output guardrails on agent responses and input guardrails on function-tool calls. Output guardrails run on debounced transcript accumulation rather than on every partial token, and they emit `guardrail_tripped` instead of raising an exception. +Realtime agents support output guardrails on agent responses and input guardrails on function-tool calls. Output guardrails run on debounced accumulation of output-text and audio-transcript deltas rather than on every partial delta, and they emit `guardrail_tripped` instead of raising an exception. ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -288,7 +288,9 @@ agent = RealtimeAgent( ) ``` -When a realtime output guardrail trips, the session interrupts the active response, forces `response.cancel`, emits `guardrail_tripped`, and sends a follow-up user message that names the triggered guardrail so the model can produce a replacement response. Your audio player should still listen for `audio_interrupted` and stop local playback immediately, because guardrails run on debounced transcript text and some audio may already be buffered when the tripwire fires. +When a realtime output guardrail trips on an audio transcript, the session interrupts the active response, forces `response.cancel`, emits `guardrail_tripped`, and sends a follow-up user message that names the triggered guardrail so the model can produce a replacement response. Your audio player should still listen for `audio_interrupted` and stop local playback immediately, because some audio may already be buffered when the tripwire fires. For text-only output, the session instead sends a response-scoped `response.cancel`; it does not emit `audio_interrupted` because there is no audio playback to stop. The same `guardrail_tripped` event and follow-up user message are emitted for the text-only path when using the built-in OpenAI Realtime models. + +Custom `RealtimeModel` transports must override `RealtimeModel.send_event_if()` to support the text-only recovery message. The implementation must recheck or serialize the supplied condition at the transport's actual event commit boundary. The default implementation safely skips the recovery message because checking the condition before awaiting `send_event()` would allow a newer response to start before the message is committed; response cancellation and the `guardrail_tripped` event still occur. ## SIP and telephony diff --git a/src/agents/realtime/__init__.py b/src/agents/realtime/__init__.py index 5310d0bd5a..d3999b9602 100644 --- a/src/agents/realtime/__init__.py +++ b/src/agents/realtime/__init__.py @@ -70,6 +70,7 @@ RealtimeModelItemDeletedEvent, RealtimeModelItemUpdatedEvent, RealtimeModelOtherEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelOutputTokensDetails, RealtimeModelToolCallEvent, RealtimeModelTranscriptDeltaEvent, @@ -173,6 +174,7 @@ "RealtimeModelItemDeletedEvent", "RealtimeModelItemUpdatedEvent", "RealtimeModelOtherEvent", + "RealtimeModelOutputTextDeltaEvent", "RealtimeModelOutputTokensDetails", "RealtimeModelToolCallEvent", "RealtimeModelTranscriptDeltaEvent", diff --git a/src/agents/realtime/config.py b/src/agents/realtime/config.py index 1b988253c9..f13575d535 100644 --- a/src/agents/realtime/config.py +++ b/src/agents/realtime/config.py @@ -228,8 +228,8 @@ class RealtimeGuardrailsSettings(TypedDict): debounce_text_length: NotRequired[int] """ - The minimum number of characters to accumulate before running guardrails on transcript - deltas. Defaults to 100. Guardrails run every time the accumulated text reaches + The minimum number of characters to accumulate before running guardrails on output text or + transcript deltas. Defaults to 100. Guardrails run every time the accumulated text reaches 1x, 2x, 3x, etc. times this threshold. """ diff --git a/src/agents/realtime/model.py b/src/agents/realtime/model.py index 345114186e..8c8ef21b87 100644 --- a/src/agents/realtime/model.py +++ b/src/agents/realtime/model.py @@ -171,6 +171,21 @@ async def send_event(self, event: RealtimeModelSendEvent) -> None: """Send an event to the model.""" pass + async def send_event_if( + self, event: RealtimeModelSendEvent, send_if: Callable[[], bool] + ) -> bool: + """Conditionally send an event at the transport's commit boundary. + + Custom transports that support conditional sends must override this method and recheck or + serialize ``send_if`` at their actual event commit boundary. The default returns ``False`` + without calling ``send_event`` because a separate check before that await is not atomic. + + Returns: + ``True`` if the event was committed, or ``False`` if the condition became false or the + transport does not implement conditional sends. + """ + return False + @abc.abstractmethod async def close(self) -> None: """Close the session.""" diff --git a/src/agents/realtime/model_events.py b/src/agents/realtime/model_events.py index 2716d32026..133099cfc9 100644 --- a/src/agents/realtime/model_events.py +++ b/src/agents/realtime/model_events.py @@ -106,6 +106,17 @@ class RealtimeModelTranscriptDeltaEvent: type: Literal["transcript_delta"] = "transcript_delta" +@dataclass +class RealtimeModelOutputTextDeltaEvent: + """Partial text output update.""" + + item_id: str + delta: str + response_id: str + + type: Literal["output_text_delta"] = "output_text_delta" + + @dataclass class RealtimeModelItemUpdatedEvent: """Item added to the history or updated.""" @@ -139,6 +150,9 @@ class RealtimeModelTurnStartedEvent: type: Literal["turn_started"] = "turn_started" + response_id: str | None = None + """The response ID, when provided by the model transport.""" + @dataclass class RealtimeModelCachedTokensDetails: @@ -228,6 +242,7 @@ class RealtimeModelRawServerEvent: | RealtimeModelInputAudioTimeoutTriggeredEvent | RealtimeModelInputAudioTranscriptionCompletedEvent | RealtimeModelTranscriptDeltaEvent + | RealtimeModelOutputTextDeltaEvent | RealtimeModelItemUpdatedEvent | RealtimeModelItemDeletedEvent | RealtimeModelConnectionStatusEvent diff --git a/src/agents/realtime/model_inputs.py b/src/agents/realtime/model_inputs.py index c167ce34f8..7bb79aed3a 100644 --- a/src/agents/realtime/model_inputs.py +++ b/src/agents/realtime/model_inputs.py @@ -98,6 +98,15 @@ class RealtimeModelSendInterrupt: force_response_cancel: bool = False """Force sending a response.cancel event even if automatic cancellation is enabled.""" + response_id: str | None = None + """Limit response cancellation to this response ID, when supported by the model. + + Audio playback is still interrupted unless `cancel_response_only` is set. + """ + + cancel_response_only: bool = False + """Cancel only `response_id` without interrupting audio playback.""" + @dataclass class RealtimeModelSendSessionUpdate: diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 690dfee4f5..aa7065dec3 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -134,6 +134,7 @@ RealtimeModelInputTokensDetails, RealtimeModelItemDeletedEvent, RealtimeModelItemUpdatedEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelOutputTokensDetails, RealtimeModelRawServerEvent, RealtimeModelToolCallEvent, @@ -386,6 +387,10 @@ async def begin_cancel_response(self) -> bool: self._response_control = "cancel_requested" return True + async def has_pending_response_create(self) -> bool: + async with self._condition: + return bool(self._pending_request_versions) or self._pending_response_create is not None + def get_server_event_type_adapter() -> TypeAdapter[AllRealtimeServerEvents]: global ServerEventTypeAdapter @@ -504,6 +509,7 @@ def __init__(self, *, transport_config: TransportConfig | None = None) -> None: self._websocket: ClientConnection | None = None self._websocket_task: asyncio.Task[None] | None = None self._response_create_tasks: set[asyncio.Task[None]] = set() + self._user_input_lock = asyncio.Lock() self._listeners: list[RealtimeModelListener] = [] self._current_item_id: str | None = None self._audio_state_tracker: ModelAudioTracker = ModelAudioTracker() @@ -723,12 +729,30 @@ async def send_event(self, event: RealtimeModelSendEvent) -> None: assert_never(event) raise ValueError(f"Unknown event type: {type(event)}") + async def send_event_if( + self, event: RealtimeModelSendEvent, send_if: Callable[[], bool] + ) -> bool: + if isinstance(event, RealtimeModelSendUserInput): + return await self._send_user_input(event, send_if=send_if) + return await super().send_event_if(event, send_if) + async def _send_raw_message(self, event: OpenAIRealtimeClientEvent) -> None: """Send a raw message to the model.""" assert self._websocket is not None, "Not connected" payload = event.model_dump_json(exclude_unset=True) await self._websocket.send(payload) + async def _send_raw_message_if( + self, event: OpenAIRealtimeClientEvent, send_if: Callable[[], bool] + ) -> bool: + """Recheck a precondition at the WebSocket send boundary.""" + assert self._websocket is not None, "Not connected" + payload = event.model_dump_json(exclude_unset=True) + if not send_if(): + return False + await self._websocket.send(payload) + return True + async def _set_response_control( self, control: Literal["free", "create_requested", "cancel_requested"] ) -> None: @@ -841,11 +865,29 @@ async def _cancel_response_create_tasks(self) -> None: if tasks_to_await: await asyncio.gather(*tasks_to_await, return_exceptions=True) - async def _send_user_input(self, event: RealtimeModelSendUserInput) -> None: - converted = _ConversionHelper.convert_user_input_to_item_create(event) - await self._send_raw_message(converted) - request_version = await self._reserve_response_create_request() - self._start_response_create(request_version) + async def _send_user_input( + self, + event: RealtimeModelSendUserInput, + *, + send_if: Callable[[], bool] | None = None, + ) -> bool: + async with self._user_input_lock: + if send_if is not None: + if ( + not send_if() + or await self._response_create_sequencer.has_pending_response_create() + ): + return False + + converted = _ConversionHelper.convert_user_input_to_item_create(event) + if send_if is None: + await self._send_raw_message(converted) + elif not await self._send_raw_message_if(converted, send_if): + return False + + request_version = await self._reserve_response_create_request() + self._start_response_create(request_version) + return True async def _send_audio(self, event: RealtimeModelSendAudio) -> None: converted = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event) @@ -904,6 +946,12 @@ def _get_audio_limits(self, item_id: str, item_content_index: int) -> tuple[floa return audio_state.audio_length_ms, max_audio_ms async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: + if event.cancel_response_only: + if event.response_id is None: + raise ValueError("cancel_response_only requires response_id") + await self._cancel_response(response_id=event.response_id) + return + playback_state = self._get_playback_state() current_item_id = playback_state.get("current_item_id") current_item_content_index = playback_state.get("current_item_content_index") @@ -962,7 +1010,7 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: not automatic_response_cancellation_enabled ) if should_cancel_response: - await self._cancel_response() + await self._cancel_response(response_id=event.response_id) if current_item_id is not None and elapsed_ms is not None: self._audio_state_tracker.on_interrupted() @@ -1054,12 +1102,17 @@ async def close(self) -> None: else: await self._release_response_waiters() - async def _cancel_response(self) -> None: + async def _cancel_response(self, *, response_id: str | None = None) -> None: if not await self._response_create_sequencer.begin_cancel_response(): return + cancel_event = ( + OpenAIResponseCancelEvent(type="response.cancel") + if response_id is None + else OpenAIResponseCancelEvent(type="response.cancel", response_id=response_id) + ) try: - await self._send_raw_message(OpenAIResponseCancelEvent(type="response.cancel")) + await self._send_raw_message(cancel_event) except Exception: await self._set_response_control("free") raise @@ -1220,7 +1273,7 @@ async def _handle_ws_event(self, event: dict[str, Any]): await self._cancel_response() elif parsed.type == "response.created": await self._mark_response_created() - await self._emit_event(RealtimeModelTurnStartedEvent()) + await self._emit_event(RealtimeModelTurnStartedEvent(response_id=parsed.response.id)) elif parsed.type == "response.done": await self._mark_response_done() if parsed.response.usage is not None: @@ -1276,9 +1329,16 @@ async def _handle_ws_event(self, event: dict[str, Any]): item_id=parsed.item_id, delta=parsed.delta, response_id=parsed.response_id ) ) + elif parsed.type == "response.output_text.delta": + await self._emit_event( + RealtimeModelOutputTextDeltaEvent( + item_id=parsed.item_id, + delta=parsed.delta, + response_id=parsed.response_id, + ) + ) elif ( parsed.type == "conversation.item.input_audio_transcription.delta" - or parsed.type == "response.output_text.delta" or parsed.type == "response.function_call_arguments.delta" ): # No support for partials yet diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index c1f689e468..92c5ad3e47 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -72,6 +72,7 @@ from .model_events import ( RealtimeModelEvent, RealtimeModelInputAudioTranscriptionCompletedEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelToolCallEvent, RealtimeModelUsageEvent, ) @@ -228,6 +229,10 @@ def __init__( self._interrupted_response_ids: set[str] = set() self._item_transcripts: dict[str, str] = {} # item_id -> accumulated transcript self._item_guardrail_run_counts: dict[str, int] = {} # item_id -> run count + self._latest_output_response_generation = 0 + self._active_output_response_generation: int | None = None + self._active_output_response_id: str | None = None + self._active_output_response_agent: RealtimeAgent[Any] | None = None self._debounce_text_length = self._run_config.get("guardrails_settings", {}).get( "debounce_text_length", 100 ) @@ -423,13 +428,12 @@ async def on_event(self, event: RealtimeModelEvent) -> None: ) ) elif event.type == "transcript_delta": - # Accumulate transcript text for guardrail debouncing per item_id item_id = event.item_id - if item_id not in self._item_transcripts: - self._item_transcripts[item_id] = "" - self._item_guardrail_run_counts[item_id] = 0 - - self._item_transcripts[item_id] += event.delta + self._record_output_guardrail_delta( + item_id, + event.delta, + event.response_id, + ) self._history = self._get_new_history( self._history, AssistantMessageItem( @@ -437,16 +441,20 @@ async def on_event(self, event: RealtimeModelEvent) -> None: content=[AssistantAudio(transcript=self._item_transcripts[item_id])], ), ) - - # Check if we should run guardrails based on debounce threshold - current_length = len(self._item_transcripts[item_id]) - threshold = self._debounce_text_length - next_run_threshold = (self._item_guardrail_run_counts[item_id] + 1) * threshold - - if current_length >= next_run_threshold: - self._item_guardrail_run_counts[item_id] += 1 - # Pass response_id so we can ensure only a single interrupt per response - self._enqueue_guardrail_task(self._item_transcripts[item_id], event.response_id) + elif event.type == "output_text_delta": + assert isinstance(event, RealtimeModelOutputTextDeltaEvent) + if self._active_output_response_generation is None: + self._latest_output_response_generation += 1 + self._active_output_response_generation = self._latest_output_response_generation + self._active_output_response_id = event.response_id + self._active_output_response_agent = self._current_agent + self._record_output_guardrail_delta( + event.item_id, + event.delta, + event.response_id, + agent_snapshot=self._active_output_response_agent, + output_response_generation=self._active_output_response_generation, + ) elif event.type == "item_updated": is_new = not any(item.item_id == event.item.item_id for item in self._history) @@ -519,6 +527,16 @@ async def on_event(self, event: RealtimeModelEvent) -> None: elif event.type == "connection_status": pass elif event.type == "turn_started": + is_late_start_for_active_response = ( + event.response_id is not None + and event.response_id == self._active_output_response_id + and self._active_output_response_generation is not None + ) + if not is_late_start_for_active_response: + self._latest_output_response_generation += 1 + self._active_output_response_generation = self._latest_output_response_generation + self._active_output_response_id = event.response_id + self._active_output_response_agent = self._current_agent await self._put_event( RealtimeAgentStartEvent( agent=self._current_agent, @@ -532,6 +550,9 @@ async def on_event(self, event: RealtimeModelEvent) -> None: # Clear guardrail state for next turn self._item_transcripts.clear() self._item_guardrail_run_counts.clear() + self._active_output_response_generation = None + self._active_output_response_id = None + self._active_output_response_agent = None await self._put_event( RealtimeAgentEndEvent( @@ -1300,12 +1321,20 @@ def _image_url_str(val: object) -> str | None: # Otherwise, add it to the end return old_history + [event] - async def _run_output_guardrails(self, text: str, response_id: str) -> bool: + async def _run_output_guardrails( + self, + text: str, + response_id: str, + *, + agent_snapshot: RealtimeAgent[Any] | None = None, + output_response_generation: int | None = None, + ) -> bool: """Run output guardrails on the given text. Returns True if any guardrail was triggered.""" if self._closing or self._closed: return False - combined_guardrails = self._current_agent.output_guardrails + self._run_config.get( + source_agent = agent_snapshot or self._current_agent + combined_guardrails = source_agent.output_guardrails + self._run_config.get( "output_guardrails", [] ) seen_ids: set[int] = set() @@ -1327,7 +1356,7 @@ async def _run_output_guardrails(self, text: str, response_id: str) -> bool: result = await guardrail.run( # TODO (rm) Remove this cast, it's wrong self._context_wrapper, - cast(Agent[Any], self._current_agent), + cast(Agent[Any], source_agent), text, ) if self._closing or self._closed: @@ -1364,28 +1393,91 @@ async def _run_output_guardrails(self, text: str, response_id: str) -> bool: # Interrupt the model if self._closing or self._closed: return False - await self._model.send_event(RealtimeModelSendInterrupt(force_response_cancel=True)) + if output_response_generation is None: + await self._model.send_event(RealtimeModelSendInterrupt(force_response_cancel=True)) + else: + if output_response_generation != self._latest_output_response_generation: + return True + if output_response_generation == self._active_output_response_generation: + await self._model.send_event( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id=response_id, + cancel_response_only=True, + ) + ) # Send guardrail triggered message if self._closing or self._closed: return False + if ( + output_response_generation is not None + and output_response_generation != self._latest_output_response_generation + ): + return True guardrail_names = [result.guardrail.get_name() for result in triggered_results] - await self._model.send_event( - RealtimeModelSendUserInput( - user_input=f"guardrail triggered: {', '.join(guardrail_names)}" - ) + feedback_event = RealtimeModelSendUserInput( + user_input=f"guardrail triggered: {', '.join(guardrail_names)}" ) + if output_response_generation is None: + await self._model.send_event(feedback_event) + else: + await self._model.send_event_if( + feedback_event, + lambda: (output_response_generation == self._latest_output_response_generation), + ) return True return False - def _enqueue_guardrail_task(self, text: str, response_id: str) -> None: + def _record_output_guardrail_delta( + self, + item_id: str, + delta: str, + response_id: str, + *, + agent_snapshot: RealtimeAgent[Any] | None = None, + output_response_generation: int | None = None, + ) -> None: + if item_id not in self._item_transcripts: + self._item_transcripts[item_id] = "" + self._item_guardrail_run_counts[item_id] = 0 + + self._item_transcripts[item_id] += delta + current_length = len(self._item_transcripts[item_id]) + threshold = self._debounce_text_length + next_run_threshold = (self._item_guardrail_run_counts[item_id] + 1) * threshold + + if current_length >= next_run_threshold: + self._item_guardrail_run_counts[item_id] += 1 + self._enqueue_guardrail_task( + self._item_transcripts[item_id], + response_id, + agent_snapshot=agent_snapshot, + output_response_generation=output_response_generation, + ) + + def _enqueue_guardrail_task( + self, + text: str, + response_id: str, + *, + agent_snapshot: RealtimeAgent[Any] | None = None, + output_response_generation: int | None = None, + ) -> None: # Runs the guardrails in a separate task to avoid blocking the main loop if self._closing or self._closed: return - task = asyncio.create_task(self._run_output_guardrails(text, response_id)) + task = asyncio.create_task( + self._run_output_guardrails( + text, + response_id, + agent_snapshot=agent_snapshot, + output_response_generation=output_response_generation, + ) + ) self._guardrail_tasks.add(task) # Add callback to remove completed tasks and handle exceptions diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 54b5f1758d..38ed675390 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -17,6 +17,7 @@ from agents.realtime.model_events import ( RealtimeModelAudioEvent, RealtimeModelErrorEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelRawServerEvent, RealtimeModelToolCallEvent, RealtimeModelUsageEvent, @@ -636,6 +637,10 @@ def validate_python(self, event): self._string_adapter.validate_python(voice) if event["type"] == "response.done": return SimpleNamespace(type=event["type"], response=SimpleNamespace(usage=None)) + if event["type"] == "response.created": + return SimpleNamespace( + type=event["type"], response=SimpleNamespace(id="response_1") + ) return SimpleNamespace(type=event["type"]) monkeypatch.setattr(model, "_send_raw_message", fake_send_raw) @@ -902,6 +907,36 @@ async def test_text_mode_output_item_content(self, model): assert item.content[0].type == "text" assert item.content[0].text == "test data" + @pytest.mark.asyncio + async def test_output_text_delta_emits_normalized_event(self, model): + listener = AsyncMock() + model.add_listener(listener) + + await model._handle_ws_event( + { + "type": "response.output_text.delta", + "event_id": "event_1", + "response_id": "response_1", + "item_id": "item_1", + "output_index": 0, + "content_index": 0, + "delta": "hello", + } + ) + + normalized_events = [ + call.args[0] + for call in listener.on_event.call_args_list + if isinstance(call.args[0], RealtimeModelOutputTextDeltaEvent) + ] + assert normalized_events == [ + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ] + @pytest.mark.asyncio async def test_output_audio_content_type_normalized(self, model): """GA-style output_audio content parts on response.output_item.* are preserved. @@ -1124,6 +1159,12 @@ async def test_interrupt_force_cancel_overrides_auto_cancellation(self, model, m assert send_raw.await_count == 2 payload_types = {call.args[0].type for call in send_raw.call_args_list} assert payload_types == {"conversation.item.truncate", "response.cancel"} + cancel_event = next( + call.args[0] + for call in send_raw.call_args_list + if call.args[0].type == "response.cancel" + ) + assert cancel_event.model_dump(exclude_unset=True) == {"type": "response.cancel"} assert model._ongoing_response is True assert model._response_control == "cancel_requested" @@ -1132,6 +1173,94 @@ async def test_interrupt_force_cancel_overrides_auto_cancellation(self, model, m assert model._response_control == "free" assert model._audio_state_tracker.get_last_audio_item() is None + @pytest.mark.asyncio + async def test_response_only_interrupt_targets_response_without_touching_audio( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta("audio_item", 0, b"\x00" * 4800) + await model._mark_response_created() + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + cancel_response_only=True, + ) + ) + + send_raw.assert_awaited_once() + assert send_raw.await_args is not None + cancel_event = send_raw.await_args.args[0] + assert cancel_event.type == "response.cancel" + assert cancel_event.response_id == "response_1" + emit_event.assert_not_awaited() + assert model._audio_state_tracker.get_last_audio_item() == ("audio_item", 0) + + @pytest.mark.asyncio + async def test_response_only_interrupt_skips_cancel_after_response_done( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta("audio_item", 0, b"\x00" * 4800) + await model._mark_response_created() + await model._mark_response_done() + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + cancel_response_only=True, + ) + ) + + send_raw.assert_not_awaited() + emit_event.assert_not_awaited() + assert model._audio_state_tracker.get_last_audio_item() == ("audio_item", 0) + + @pytest.mark.asyncio + async def test_normal_interrupt_targets_response_and_interrupts_audio(self, model, monkeypatch): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta("audio_item", 0, b"\x00" * 4800) + await model._mark_response_created() + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + ) + ) + + assert send_raw.await_count == 2 + truncate_event, cancel_event = [call.args[0] for call in send_raw.call_args_list] + assert truncate_event.type == "conversation.item.truncate" + assert cancel_event.type == "response.cancel" + assert cancel_event.response_id == "response_1" + emit_event.assert_awaited_once() + assert emit_event.await_args is not None + assert emit_event.await_args.args[0].type == "audio_interrupted" + assert model._audio_state_tracker.get_last_audio_item() is None + + @pytest.mark.asyncio + async def test_response_only_interrupt_requires_response_id(self, model): + with pytest.raises(ValueError, match="cancel_response_only requires response_id"): + await model._send_interrupt(RealtimeModelSendInterrupt(cancel_response_only=True)) + @pytest.mark.asyncio async def test_interrupt_respects_auto_cancellation_when_not_forced(self, model, monkeypatch): """Interrupt should avoid sending response.cancel when relying on automatic cancellation.""" @@ -1182,6 +1311,99 @@ async def fake_send_raw(event): assert payload_types == ["conversation.item.create", "response.create"] + @pytest.mark.asyncio + async def test_conditional_user_input_skips_when_response_create_is_already_pending( + self, model, mock_websocket + ): + first_item_send_started = asyncio.Event() + release_first_item_send = asyncio.Event() + payload_types: list[str] = [] + + async def send(payload: str): + payload_type = json.loads(payload)["type"] + if payload_type == "conversation.item.create" and not payload_types: + first_item_send_started.set() + await release_first_item_send.wait() + payload_types.append(payload_type) + + mock_websocket.send.side_effect = send + model._websocket = mock_websocket + await model._mark_response_created() + + newer_input = asyncio.create_task( + model._send_user_input(RealtimeModelSendUserInput(user_input="newer input")) + ) + await first_item_send_started.wait() + feedback = asyncio.create_task( + model.send_event_if( + RealtimeModelSendUserInput(user_input="guardrail feedback"), + lambda: True, + ) + ) + await asyncio.sleep(0) + + release_first_item_send.set() + await newer_input + assert await feedback is False + assert payload_types == ["conversation.item.create"] + + await model._cancel_response_create_tasks() + + @pytest.mark.asyncio + async def test_conditional_user_input_reserves_response_before_later_normal_input( + self, model, mock_websocket + ): + feedback_send_started = asyncio.Event() + release_feedback_send = asyncio.Event() + normal_send_started = asyncio.Event() + release_normal_send = asyncio.Event() + payloads: list[dict[str, Any]] = [] + + async def send(payload: str): + parsed = json.loads(payload) + if parsed["type"] == "conversation.item.create": + if not payloads: + feedback_send_started.set() + await release_feedback_send.wait() + else: + normal_send_started.set() + await release_normal_send.wait() + payloads.append(parsed) + + mock_websocket.send.side_effect = send + model._websocket = mock_websocket + await model._mark_response_created() + + feedback = asyncio.create_task( + model.send_event_if( + RealtimeModelSendUserInput(user_input="guardrail feedback"), + lambda: True, + ) + ) + await feedback_send_started.wait() + normal_input = asyncio.create_task( + model._send_user_input(RealtimeModelSendUserInput(user_input="newer input")) + ) + + release_feedback_send.set() + await normal_send_started.wait() + + assert feedback.done() is True + assert feedback.result() is True + assert await model._response_create_sequencer.has_pending_response_create() is True + assert [payload["item"]["content"][0]["text"] for payload in payloads] == [ + "guardrail feedback" + ] + + release_normal_send.set() + await normal_input + assert [payload["item"]["content"][0]["text"] for payload in payloads] == [ + "guardrail feedback", + "newer input", + ] + + await model._cancel_response_create_tasks() + @pytest.mark.asyncio async def test_send_user_input_from_websocket_listener_defers_response_create_without_blocking( self, model, monkeypatch diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index b492d44035..4e4d0b086c 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -51,6 +51,7 @@ RealtimeModelItemDeletedEvent, RealtimeModelItemUpdatedEvent, RealtimeModelOtherEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelToolCallEvent, RealtimeModelTranscriptDeltaEvent, RealtimeModelTurnEndedEvent, @@ -1093,6 +1094,12 @@ async def send_event(self, event): elif isinstance(event, RealtimeModelSendInterrupt): self.interrupts_called += 1 + async def send_event_if(self, event, send_if): + if not send_if(): + return False + await self.send_event(event) + return True + async def close(self): self.close_called = True @@ -3724,6 +3731,422 @@ async def test_transcript_delta_triggers_guardrail_at_threshold( assert len(guardrail_events) == 1 assert guardrail_events[0].message == "this is more than ten characters" + @pytest.mark.asyncio + async def test_output_text_delta_triggers_response_scoped_guardrail( + self, mock_model, mock_agent, triggered_guardrail + ): + run_config: RealtimeRunConfig = { + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + await session.on_event(RealtimeModelTurnStartedEvent()) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + interrupt_event = next( + event + for event in mock_model.sent_events + if isinstance(event, RealtimeModelSendInterrupt) + ) + assert interrupt_event.force_response_cancel is True + assert interrupt_event.response_id == "response_1" + assert interrupt_event.cancel_response_only is True + assert mock_model.sent_messages == ["guardrail triggered: triggered_guardrail"] + + @pytest.mark.asyncio + async def test_stale_output_text_guardrail_does_not_affect_newer_response(self, mock_model): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + guardrail = OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + source_agent = RealtimeAgent(name="source", output_guardrails=[guardrail]) + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 1}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + assert not any( + isinstance(event, RealtimeModelSendInterrupt) for event in mock_model.sent_events + ) + assert mock_model.sent_messages == [] + queued_events = [] + while not session._event_queue.empty(): + queued_events.append(await session._event_queue.get()) + assert sum(isinstance(event, RealtimeGuardrailTripped) for event in queued_events) == 1 + + @pytest.mark.asyncio + async def test_output_text_guardrail_sends_feedback_after_source_turn_ends( + self, mock_model, mock_agent, triggered_guardrail + ): + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + original_send_event = mock_model.send_event + + async def send_event(event): + await original_send_event(event) + if isinstance(event, RealtimeModelSendInterrupt): + await session.on_event(RealtimeModelTurnEndedEvent()) + + mock_model.send_event = send_event + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert mock_model.sent_messages == ["guardrail triggered: triggered_guardrail"] + + @pytest.mark.asyncio + async def test_output_text_guardrail_skips_feedback_for_completed_idless_newer_turn( + self, mock_model, mock_agent, triggered_guardrail + ): + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + original_send_event = mock_model.send_event + + async def send_event(event): + await original_send_event(event) + if isinstance(event, RealtimeModelSendInterrupt): + await session.on_event(RealtimeModelTurnEndedEvent()) + await session.on_event(RealtimeModelTurnStartedEvent()) + await session.on_event(RealtimeModelTurnEndedEvent()) + + mock_model.send_event = send_event + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert mock_model.sent_messages == [] + + @pytest.mark.asyncio + async def test_output_text_guardrail_rechecks_generation_at_feedback_send_boundary( + self, mock_agent, triggered_guardrail + ): + feedback_send_started = asyncio.Event() + release_feedback_send = asyncio.Event() + + class BoundaryCheckingModel(MockRealtimeModel): + async def send_event_if(self, event, send_if): + feedback_send_started.set() + await release_feedback_send.wait() + return await super().send_event_if(event, send_if) + + model = BoundaryCheckingModel() + session = RealtimeSession( + model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await feedback_send_started.wait() + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) + release_feedback_send.set() + await self._wait_for_guardrail_tasks(session) + + assert model.sent_messages == [] + + @pytest.mark.asyncio + async def test_output_text_guardrail_skips_feedback_without_atomic_model_send( + self, mock_agent, triggered_guardrail + ): + class CustomModelWithoutAtomicSend(MockRealtimeModel): + def __init__(self): + super().__init__() + self.feedback_send_started = False + + async def send_event(self, event): + if isinstance(event, RealtimeModelSendUserInput): + self.feedback_send_started = True + await asyncio.sleep(0) + await super().send_event(event) + + async def send_event_if(self, event, send_if): + return await RealtimeModel.send_event_if(self, event, send_if) + + model = CustomModelWithoutAtomicSend() + session = RealtimeSession( + model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert any(isinstance(event, RealtimeModelSendInterrupt) for event in model.sent_events) + assert model.feedback_send_started is False + assert model.sent_messages == [] + + @pytest.mark.asyncio + async def test_output_text_guardrail_uses_agent_from_turn_start(self, mock_model): + observed_agents: list[RealtimeAgent] = [] + replacement_called = False + + def source_guardrail(context, agent, output): + _ = context, output + observed_agents.append(agent) + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + def replacement_guardrail(context, agent, output): + nonlocal replacement_called + _ = context, agent, output + replacement_called = True + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) + + source_agent = RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail(guardrail_function=source_guardrail, name="source_guardrail") + ], + ) + replacement_agent = RealtimeAgent( + name="replacement", + output_guardrails=[ + OutputGuardrail( + guardrail_function=replacement_guardrail, + name="replacement_guardrail", + ) + ], + ) + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 5}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.update_agent(replacement_agent) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert observed_agents == [source_agent] + assert replacement_called is False + assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] + + @pytest.mark.asyncio + async def test_output_text_guardrail_retains_agent_for_matching_late_turn_start( + self, mock_model + ): + observed_agents: list[RealtimeAgent] = [] + + def source_guardrail(context, agent, output): + _ = context, output + observed_agents.append(agent) + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + source_agent = RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail(guardrail_function=source_guardrail, name="source_guardrail") + ], + ) + replacement_agent = RealtimeAgent(name="replacement") + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 5}}, + ) + + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="he", + response_id="response_1", + ) + ) + await session.update_agent(replacement_agent) + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="llo", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert observed_agents == [source_agent] + assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] + + @pytest.mark.asyncio + async def test_matching_late_turn_start_retains_pending_guardrail_generation(self, mock_model): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + source_agent = RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail(guardrail_function=delayed_guardrail, name="source_guardrail") + ], + ) + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 2}}, + ) + + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="he", + response_id="response_1", + ) + ) + await guardrail_started.wait() + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + interrupt_event = next( + event + for event in mock_model.sent_events + if isinstance(event, RealtimeModelSendInterrupt) + ) + assert interrupt_event.response_id == "response_1" + assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] + + @pytest.mark.asyncio + async def test_output_text_guardrail_sends_feedback_if_source_ends_during_evaluation( + self, mock_model + ): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + guardrail = OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + session = RealtimeSession( + mock_model, + RealtimeAgent(name="source", output_guardrails=[guardrail]), + None, + run_config={"guardrails_settings": {"debounce_text_length": 1}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + + await session.on_event(RealtimeModelTurnEndedEvent()) + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + assert not any( + isinstance(event, RealtimeModelSendInterrupt) for event in mock_model.sent_events + ) + assert mock_model.sent_messages == ["guardrail triggered: delayed_guardrail"] + @pytest.mark.asyncio async def test_agent_and_run_config_guardrails_not_run_twice(self, mock_model): """Guardrails shared by agent and run config should execute once.""" From 686d041bacd0a07ebb6f6b5f4dc6422a0b19dbc6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 09:35:57 +0900 Subject: [PATCH 113/473] fix(streaming): synchronize after-turn cancellation with event consumption (#4130) --- src/agents/result.py | 74 ++++++++- src/agents/run_internal/run_loop.py | 40 ++++- tests/test_run_impl_resume_paths.py | 119 ++++++++++++++ tests/test_soft_cancel.py | 231 +++++++++++++++++++++++++++- 4 files changed, 454 insertions(+), 10 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index fa4a9ef664..daf8516927 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -579,6 +579,10 @@ class RunResultStreaming(RunResultBase): interruptions: list[ToolApprovalItem] = field(default_factory=list) """Pending tool approval requests (interruptions) for this run.""" _waiting_on_event_queue: bool = field(default=False, repr=False) + _active_stream_consumers: int = field(default=0, init=False, repr=False) + _stream_consumers_stopped: asyncio.Event = field( + default_factory=asyncio.Event, init=False, repr=False + ) _current_turn_persisted_item_count: int = 0 """Number of items from new_items already persisted to session for the @@ -775,6 +779,22 @@ def cancel(self, mode: Literal["immediate", "after_turn"] = "immediate") -> None # Don't call _cleanup_tasks() or clear queues yet pass + async def _wait_for_turn_event_consumption(self) -> None: + """Wait for active consumers to finish processing the current turn's events.""" + if self._active_stream_consumers == 0: + return + + queue_drained = asyncio.create_task(self._event_queue.join()) + consumers_stopped = asyncio.create_task(self._stream_consumers_stopped.wait()) + tasks = {queue_drained, consumers_stopped} + try: + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + async def stream_events(self) -> AsyncIterator[StreamEvent]: """Stream deltas for new items as they are generated. We're using the types from the OpenAI Responses API, so these are semantic events: each event has a `type` field that @@ -784,9 +804,53 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]: - A MaxTurnsExceeded exception if the agent exceeds the max_turns limit. - A GuardrailTripwireTriggered exception if a guardrail is tripped. """ + consumer_registered = False + registered_consumer_task: asyncio.Task[Any] | None = None + item_acknowledgement_pending = False + + def acknowledge_item() -> None: + nonlocal item_acknowledgement_pending + if not item_acknowledgement_pending: + return + item_acknowledgement_pending = False + self._event_queue.task_done() + + def unregister_consumer() -> None: + nonlocal consumer_registered, registered_consumer_task + if not consumer_registered: + return + acknowledge_item() + consumer_registered = False + registered_consumer_task = None + self._active_stream_consumers -= 1 + if self._active_stream_consumers == 0: + self._stream_consumers_stopped.set() + + def consumer_task_done(task: asyncio.Task[Any]) -> None: + if registered_consumer_task is not task: + return + if task.cancelled() or task.exception() is not None: + unregister_consumer() + + def register_current_consumer() -> None: + nonlocal consumer_registered, registered_consumer_task + current_task = asyncio.current_task() + if current_task is None or registered_consumer_task is current_task: + return + if registered_consumer_task is not None: + registered_consumer_task.remove_done_callback(consumer_task_done) + registered_consumer_task = current_task + if not consumer_registered: + consumer_registered = True + self._active_stream_consumers += 1 + self._stream_consumers_stopped.clear() + current_task.add_done_callback(consumer_task_done) + + register_current_consumer() cancelled = False try: while True: + register_current_consumer() self._check_errors() should_drain_queued_events = isinstance( self._stored_exception, MaxTurnsExceeded @@ -826,9 +890,15 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]: self._check_errors() break - yield item - self._event_queue.task_done() + item_acknowledgement_pending = True + try: + yield item + finally: + acknowledge_item() finally: + if registered_consumer_task is not None: + registered_consumer_task.remove_done_callback(consumer_task_done) + unregister_consumer() try: if cancelled: # Cancellation should return promptly, so avoid waiting on long-running tasks. diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 0cdc34a7ca..5aa0e3a6bf 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -325,6 +325,28 @@ def _complete_stream_interruption( streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) +async def _wait_for_streamed_turn_events_and_stop_if_cancelled( + streamed_result: RunResultStreaming, +) -> bool: + """Let consumers process the completed turn before starting another one.""" + await streamed_result._wait_for_turn_event_consumption() + if streamed_result._cancel_mode != "after_turn": + return False + + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + return True + + +def _publish_streamed_result_agent( + streamed_result: RunResultStreaming, + agent: Agent[Any], +) -> None: + """Publish an agent transition before cancellation can complete the streamed run.""" + streamed_result.current_agent = agent + streamed_result._current_agent_output_schema = get_output_schema(agent) + + async def _save_resumed_stream_items( *, session: Session | None, @@ -909,6 +931,7 @@ async def _save_stream_items_without_count( current_agent = turn_result.next_step.new_agent if run_state is not None: run_state._current_agent = current_agent + _publish_streamed_result_agent(streamed_result, current_agent) if current_span: current_span.finish(reset_current=True) current_span = None @@ -917,6 +940,10 @@ async def _save_stream_items_without_count( AgentUpdatedStreamEvent(new_agent=current_agent) ) run_state._current_step = NextStepRunAgain() # type: ignore[assignment] + if await _wait_for_streamed_turn_events_and_stop_if_cancelled( + streamed_result + ): + break continue if isinstance(turn_result.next_step, NextStepFinalOutput): @@ -941,6 +968,10 @@ async def _save_stream_items_without_count( store_setting, ) run_state._current_step = NextStepRunAgain() # type: ignore[assignment] + if await _wait_for_streamed_turn_events_and_stop_if_cancelled( + streamed_result + ): + break continue run_state._current_step = None @@ -1220,6 +1251,7 @@ async def _save_stream_items_without_count( current_agent = turn_result.next_step.new_agent if run_state is not None: run_state._current_agent = current_agent + _publish_streamed_result_agent(streamed_result, current_agent) current_span.finish(reset_current=True) current_span = None should_run_agent_start_hooks = True @@ -1229,9 +1261,7 @@ async def _save_stream_items_without_count( if streamed_result._state is not None: streamed_result._state._current_step = NextStepRunAgain() - if streamed_result._cancel_mode == "after_turn": # type: ignore[comparison-overlap] - streamed_result.is_complete = True - streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + if await _wait_for_streamed_turn_events_and_stop_if_cancelled(streamed_result): break elif isinstance(turn_result.next_step, NextStepFinalOutput): await _finalize_streamed_final_output( @@ -1282,9 +1312,7 @@ async def _save_stream_items_without_count( store_setting, ) - if streamed_result._cancel_mode == "after_turn": # type: ignore[comparison-overlap] - streamed_result.is_complete = True - streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + if await _wait_for_streamed_turn_events_and_stop_if_cancelled(streamed_result): break except Exception as e: if current_span and _should_attach_generic_agent_error(e): diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 22cf1c0768..1bfb118f20 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -1,3 +1,4 @@ +import asyncio import json from typing import Any, cast @@ -7,6 +8,7 @@ import agents.run as run_module from agents import Agent, Runner, function_tool from agents.agent import ToolsToFinalOutputResult +from agents.agent_output import AgentOutputSchema from agents.items import ( MessageOutputItem, ModelResponse, @@ -21,6 +23,7 @@ from agents.run_internal.agent_bindings import bind_public_agent from agents.run_internal.run_loop import ( NextStepFinalOutput, + NextStepHandoff, NextStepInterruption, NextStepRunAgain, ProcessedResponse, @@ -232,6 +235,122 @@ async def fake_run_single_turn(**_kwargs): assert "function_call" in saved_types +@pytest.mark.asyncio +@pytest.mark.parametrize("continuation", ["run_again", "handoff"]) +async def test_resumed_stream_waits_for_event_consumption_before_continuing( + monkeypatch: pytest.MonkeyPatch, + continuation: str, +) -> None: + agent = Agent(name="resume-agent") + delegate = Agent(name="delegate", output_type=int) + state: RunState[dict[str, str]] = RunState( + context=RunContextWrapper(context={}), + original_input="input", + starting_agent=agent, + max_turns=2, + ) + state._current_step = NextStepInterruption(interruptions=[]) + state._model_responses = [ + ModelResponse(output=[], usage=Usage(), response_id="resp_1"), + ] + state._last_processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + tool_output_item = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-resume", + "output": "ok", + }, + output="ok", + ) + next_step = NextStepHandoff(delegate) if continuation == "handoff" else NextStepRunAgain() + allow_resume_resolution = asyncio.Event() + + async def fake_resolve_interrupted_turn(**_kwargs: object) -> SingleStepResult: + await allow_resume_resolution.wait() + return SingleStepResult( + original_input="input", + model_response=ModelResponse(output=[], usage=Usage(), response_id="resp_resume"), + pre_step_items=[], + new_step_items=[tool_output_item], + next_step=next_step, + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + ) + + next_model_turn_started = asyncio.Event() + allow_model_turn_to_finish = asyncio.Event() + + async def fake_run_single_turn_streamed(*_args: object, **_kwargs: object) -> SingleStepResult: + next_model_turn_started.set() + await allow_model_turn_to_finish.wait() + return SingleStepResult( + original_input="input", + model_response=ModelResponse(output=[], usage=Usage(), response_id="unexpected"), + pre_step_items=[], + new_step_items=[], + next_step=NextStepFinalOutput("unexpected"), + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + ) + + monkeypatch.setattr(run_loop, "resolve_interrupted_turn", fake_resolve_interrupted_turn) + monkeypatch.setattr(run_loop, "run_single_turn_streamed", fake_run_single_turn_streamed) + + result = Runner.run_streamed(agent, state) + consumer_active = asyncio.Event() + consumer_suspended = asyncio.Event() + release_consumer = asyncio.Event() + cancel_called = asyncio.Event() + + async def consume_events() -> None: + async for event in result.stream_events(): + if event.type == "agent_updated_stream_event": + consumer_active.set() + if event.type == "run_item_stream_event" and event.name == "tool_output": + consumer_suspended.set() + await release_consumer.wait() + result.cancel(mode="after_turn") + cancel_called.set() + + consumer_task = asyncio.create_task(consume_events()) + await asyncio.wait_for(consumer_active.wait(), timeout=1) + allow_resume_resolution.set() + await asyncio.wait_for(consumer_suspended.wait(), timeout=1) + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert not next_model_turn_started.is_set() + + release_consumer.set() + await asyncio.wait_for(cancel_called.wait(), timeout=1) + allow_model_turn_to_finish.set() + await asyncio.wait_for(consumer_task, timeout=1) + + assert not next_model_turn_started.is_set() + assert result.final_output is None + expected_agent = delegate if continuation == "handoff" else agent + assert result.current_agent is expected_agent + assert result.last_agent is expected_agent + assert result.to_state()._current_agent is expected_agent + if continuation == "handoff": + assert result._current_agent_output_schema is not None + assert isinstance(result._current_agent_output_schema, AgentOutputSchema) + assert result._current_agent_output_schema.output_type is int + + @pytest.mark.parametrize( ("conversation_id", "previous_response_id", "auto_previous_response_id"), [ diff --git a/tests/test_soft_cancel.py b/tests/test_soft_cancel.py index ddb51f8f17..1ece9e3e2e 100644 --- a/tests/test_soft_cancel.py +++ b/tests/test_soft_cancel.py @@ -1,13 +1,23 @@ """Tests for soft cancel (after_turn mode) functionality.""" +import asyncio import json +from collections.abc import AsyncGenerator +from typing import cast import pytest from agents import Agent, Runner, SQLiteSession +from agents.agent_output import AgentOutputSchema +from agents.stream_events import StreamEvent from .fake_model import FakeModel -from .test_responses import get_function_tool, get_function_tool_call, get_text_message +from .test_responses import ( + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_message, +) @pytest.mark.asyncio @@ -140,7 +150,8 @@ async def test_soft_cancel_tracks_usage(): @pytest.mark.asyncio -async def test_soft_cancel_stops_next_turn(): +@pytest.mark.parametrize("consumer_suspensions", [0, 1, 3]) +async def test_soft_cancel_stops_next_turn(consumer_suspensions: int): """Verify soft cancel prevents next turn from starting.""" model = FakeModel() agent = Agent( @@ -165,9 +176,167 @@ async def test_soft_cancel_stops_next_turn(): if event.type == "run_item_stream_event" and event.name == "tool_output": turns_completed += 1 if turns_completed == 1: + for _ in range(consumer_suspensions): + await asyncio.sleep(0) result.cancel(mode="after_turn") assert turns_completed == 1, "Should complete exactly 1 turn" + assert result.final_output is None + assert result.context_wrapper.usage.requests == 1 + + +@pytest.mark.asyncio +async def test_soft_cancel_stops_next_turn_with_short_lived_anext_tasks(): + """Per-event tasks must not acknowledge a turn before the caller handles its event.""" + model = FakeModel() + agent = Agent( + name="Assistant", + model=model, + tools=[get_function_tool("tool1", "result1")], + ) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("tool1", "{}")], + [get_text_message("Turn 2")], + ] + ) + + result = Runner.run_streamed(agent, input="Hello") + events = cast(AsyncGenerator[StreamEvent, None], result.stream_events()) + try: + while True: + event = await asyncio.create_task(anext(events)) + if event.type == "run_item_stream_event" and event.name == "tool_output": + result.cancel(mode="after_turn") + except StopAsyncIteration: + pass + + assert result.final_output is None + assert result.context_wrapper.usage.requests == 1 + + +@pytest.mark.asyncio +async def test_streamed_run_completes_without_an_event_consumer(): + """Turn acknowledgement must not block a run whose events are not consumed.""" + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("tool1", "{}")], + [get_text_message("Turn 2")], + ] + ) + agent = Agent( + name="Assistant", + model=model, + tools=[get_function_tool("tool1", "result1")], + ) + + result = Runner.run_streamed(agent, input="Hello") + assert result.run_loop_task is not None + await asyncio.wait_for(result.run_loop_task, timeout=1) + + assert result.final_output == "Turn 2" + assert result.context_wrapper.usage.requests == 2 + + +@pytest.mark.asyncio +async def test_closing_stream_consumer_releases_turn_acknowledgement(): + """Closing an iterator must not deadlock while a turn awaits its consumer.""" + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("tool1", "{}")], + [get_text_message("Turn 2")], + ] + ) + agent = Agent( + name="Assistant", + model=model, + tools=[get_function_tool("tool1", "result1")], + ) + + result = Runner.run_streamed(agent, input="Hello") + events = cast(AsyncGenerator[StreamEvent, None], result.stream_events()) + while True: + event = await anext(events) + if event.type == "run_item_stream_event" and event.name == "tool_output": + break + + await asyncio.wait_for(events.aclose(), timeout=1) + + assert result.final_output == "Turn 2" + assert result.context_wrapper.usage.requests == 2 + + +@pytest.mark.asyncio +async def test_cancelled_stream_consumer_releases_turn_acknowledgement(): + """Cancelling a consumer suspended after yield must release the completed turn.""" + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("tool1", "{}")], + [get_text_message("Turn 2")], + ] + ) + agent = Agent( + name="Assistant", + model=model, + tools=[get_function_tool("tool1", "result1")], + ) + + result = Runner.run_streamed(agent, input="Hello") + events = cast(AsyncGenerator[StreamEvent, None], result.stream_events()) + consumer_suspended = asyncio.Event() + keep_consumer_suspended = asyncio.Event() + + async def consume_events() -> None: + async for event in events: + if event.type == "run_item_stream_event" and event.name == "tool_output": + consumer_suspended.set() + await keep_consumer_suspended.wait() + + consumer_task = asyncio.create_task(consume_events()) + await asyncio.wait_for(consumer_suspended.wait(), timeout=1) + + consumer_task.cancel() + with pytest.raises(asyncio.CancelledError): + await consumer_task + + assert result.run_loop_task is not None + await asyncio.wait_for(result.run_loop_task, timeout=1) + + assert result.final_output == "Turn 2" + assert result.context_wrapper.usage.requests == 2 + assert result._active_stream_consumers == 0 + + await asyncio.wait_for(events.aclose(), timeout=1) + + +@pytest.mark.asyncio +async def test_immediate_cancel_releases_turn_acknowledgement(): + """Immediate cancellation must cancel a run waiting for streamed event acknowledgement.""" + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("tool1", "{}")], + [get_text_message("Turn 2")], + ] + ) + agent = Agent( + name="Assistant", + model=model, + tools=[get_function_tool("tool1", "result1")], + ) + + result = Runner.run_streamed(agent, input="Hello") + async for event in result.stream_events(): + if event.type == "run_item_stream_event" and event.name == "tool_output": + await asyncio.sleep(0) + result.cancel(mode="immediate") + + assert result.is_complete + assert result.final_output is None + assert result.context_wrapper.usage.requests == 1 @pytest.mark.asyncio @@ -436,6 +605,64 @@ async def on_invoke_handoff(context, data): await session.clear_session() +@pytest.mark.asyncio +async def test_soft_cancel_waits_for_handoff_event_consumption_before_next_turn(): + """A suspended handoff consumer can stop the run before the delegate model starts.""" + second_request_started = asyncio.Event() + + class HandoffModel(FakeModel): + def __init__(self) -> None: + super().__init__() + self.request_count = 0 + + async def stream_response(self, *args, **kwargs): + self.request_count += 1 + if self.request_count == 2: + second_request_started.set() + async for event in super().stream_response(*args, **kwargs): + yield event + + model = HandoffModel() + delegate = Agent(name="Delegate", model=model, output_type=int) + triage = Agent(name="Triage", model=model, handoffs=[delegate]) + model.add_multiple_turn_outputs( + [ + [get_handoff_tool_call(delegate)], + [get_text_message("Delegate response")], + ] + ) + + result = Runner.run_streamed(triage, input="Route this request") + consumer_suspended = asyncio.Event() + release_consumer = asyncio.Event() + + async def consume_events() -> None: + async for event in result.stream_events(): + if event.type == "run_item_stream_event" and event.name == "handoff_requested": + consumer_suspended.set() + await release_consumer.wait() + result.cancel(mode="after_turn") + + consumer_task = asyncio.create_task(consume_events()) + await asyncio.wait_for(consumer_suspended.wait(), timeout=1) + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert not second_request_started.is_set() + + release_consumer.set() + await asyncio.wait_for(consumer_task, timeout=1) + + assert result.final_output is None + assert result.context_wrapper.usage.requests == 1 + assert result.current_agent is delegate + assert result.last_agent is delegate + assert result.to_state()._current_agent is delegate + assert result._current_agent_output_schema is not None + assert isinstance(result._current_agent_output_schema, AgentOutputSchema) + assert result._current_agent_output_schema.output_type is int + + @pytest.mark.asyncio async def test_soft_cancel_with_session_and_multiple_turns(): """Verify soft cancel with session across multiple turns.""" From e943deda36b4dd43249df1236e32318acbc61473 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 10:18:11 +0900 Subject: [PATCH 114/473] fix(voice): clean up tasks when streams close early (#4131) Co-authored-by: Gautam Sharma <148205237+GautamSharma99@users.noreply.github.com> --- src/agents/voice/models/openai_stt.py | 147 ++++++++----- src/agents/voice/result.py | 115 ++++++++--- tests/voice/test_openai_stt.py | 238 ++++++++++++++++++++- tests/voice/test_pipeline.py | 286 +++++++++++++++++++++++++- 4 files changed, 705 insertions(+), 81 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index fa52e2ef9b..939af0be62 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -123,9 +123,6 @@ def _start_turn(self) -> None: self._tracing_span.start() def _end_turn(self, _transcript: str) -> None: - if len(_transcript) < 1: - return - if self._tracing_span: # Only encode audio if tracing is enabled AND buffer is not empty if self._trace_include_sensitive_audio_data and self._turn_audio_buffer: @@ -306,77 +303,129 @@ async def _process_websocket_connection(self) -> None: raise def _check_errors(self) -> None: - if self._connection_task and self._connection_task.done(): + if ( + self._connection_task + and self._connection_task.done() + and not self._connection_task.cancelled() + ): exc = self._connection_task.exception() if exc and isinstance(exc, Exception): self._stored_exception = exc - if self._process_events_task and self._process_events_task.done(): + if ( + self._process_events_task + and self._process_events_task.done() + and not self._process_events_task.cancelled() + ): exc = self._process_events_task.exception() if exc and isinstance(exc, Exception): self._stored_exception = exc - if self._stream_audio_task and self._stream_audio_task.done(): + if ( + self._stream_audio_task + and self._stream_audio_task.done() + and not self._stream_audio_task.cancelled() + ): exc = self._stream_audio_task.exception() if exc and isinstance(exc, Exception): self._stored_exception = exc - if self._listener_task and self._listener_task.done(): + if ( + self._listener_task + and self._listener_task.done() + and not self._listener_task.cancelled() + ): exc = self._listener_task.exception() if exc and isinstance(exc, Exception): self._stored_exception = exc - def _cleanup_tasks(self) -> None: - if self._listener_task and not self._listener_task.done(): - self._listener_task.cancel() - - if self._process_events_task and not self._process_events_task.done(): - self._process_events_task.cancel() - - if self._stream_audio_task and not self._stream_audio_task.done(): - self._stream_audio_task.cancel() + async def _cleanup_tasks(self) -> None: + owned_tasks = [ + task + for task in ( + self._listener_task, + self._process_events_task, + self._stream_audio_task, + self._connection_task, + ) + if task is not None and task is not asyncio.current_task() + ] + for task in owned_tasks: + if not task.done(): + task.cancel() - if self._connection_task and not self._connection_task.done(): - self._connection_task.cancel() + if owned_tasks: + await asyncio.gather(*owned_tasks, return_exceptions=True) async def transcribe_turns(self) -> AsyncIterator[str]: self._connection_task = asyncio.create_task(self._process_websocket_connection()) - while True: - try: + primary_exception: BaseException | None = None + try: + while True: turn = await self._output_queue.get() - except asyncio.CancelledError: - if self._tracing_span: - self._end_turn("") - if self._websocket: - await self._websocket.close() - raise - - if ( - turn is None - or isinstance(turn, ErrorSentinel) - or isinstance(turn, SessionCompleteSentinel) - ): - self._output_queue.task_done() - break - yield turn - self._output_queue.task_done() - - if self._tracing_span: - self._end_turn("") - - if self._websocket: - await self._websocket.close() + if ( + turn is None + or isinstance(turn, ErrorSentinel) + or isinstance(turn, SessionCompleteSentinel) + ): + self._output_queue.task_done() + break + try: + yield turn + finally: + self._output_queue.task_done() + except BaseException as exc: + primary_exception = exc + raise + finally: + cleanup_exception: BaseException | None = None + try: + await self.close() + except BaseException as exc: + cleanup_exception = exc + + # Closing drains the owned tasks, so inspect their final outcomes before choosing + # between the session error and a secondary cleanup failure. + self._check_errors() + task_exception = self._stored_exception + preserve_primary_exception = primary_exception is not None + exception_to_raise: BaseException | None = None + if isinstance(primary_exception, asyncio.CancelledError): + pass + elif isinstance(cleanup_exception, asyncio.CancelledError): + exception_to_raise = cleanup_exception + elif preserve_primary_exception: + pass + elif task_exception is not None: + exception_to_raise = task_exception + elif cleanup_exception is not None: + exception_to_raise = cleanup_exception + + cleanup_exception_was_suppressed = ( + cleanup_exception is not None + and not isinstance(cleanup_exception, asyncio.CancelledError) + and cleanup_exception is not exception_to_raise + ) + if cleanup_exception_was_suppressed: + try: + logger.warning("STT session cleanup failed while preserving another exception") + except Exception: + # Logging must not replace the selected exception. + pass - self._check_errors() - if self._stored_exception: - raise self._stored_exception + if exception_to_raise is not None: + raise exception_to_raise async def close(self) -> None: - if self._websocket: - await self._websocket.close() - - self._cleanup_tasks() + try: + if self._websocket: + await self._websocket.close() + finally: + try: + await self._cleanup_tasks() + finally: + self._end_turn("") class OpenAISTTModel(STTModel): diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 9e5641384d..7c397a8152 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -7,7 +7,11 @@ from typing import Any from ..exceptions import UserError -from ..logger import log_model_action_error, log_model_and_tool_action_error, logger +from ..logger import ( + log_model_action_error, + log_model_and_tool_action_error, + logger, +) from ..tracing import Span, SpeechGroupSpanData, speech_group_span, speech_span from ..tracing.util import time_iso from ..util._error_tracing import get_trace_error @@ -289,7 +293,7 @@ async def _wait_for_completion(self): tasks.append(self._dispatcher_task) await asyncio.gather(*tasks) - async def _cleanup_tasks(self): + async def _cleanup_tasks(self) -> None: current_task = asyncio.current_task() tasks: list[asyncio.Task[Any]] = [] seen: set[asyncio.Task[Any]] = set() @@ -311,7 +315,7 @@ async def _cleanup_tasks(self): def _check_errors(self): for task in self._tasks: - if task.done(): + if task.done() and not task.cancelled(): if task.exception(): self._stored_exception = task.exception() break @@ -319,38 +323,89 @@ def _check_errors(self): async def stream(self) -> AsyncIterator[VoiceStreamEvent]: """Stream the events and audio data as they're generated.""" saw_session_end = False - while True: - try: + primary_exception: BaseException | None = None + try: + while True: event = await self._queue.get() - except asyncio.CancelledError: - await self._cleanup_tasks() - raise - if isinstance(event, VoiceStreamEventError): - self._stored_exception = event.error - log_model_and_tool_action_error( - logger, "Error processing voice output", event.error + if isinstance(event, VoiceStreamEventError): + self._stored_exception = event.error + log_model_and_tool_action_error( + logger, "Error processing voice output", event.error + ) + break + if event is None: + break + is_session_end = ( + event.type == "voice_stream_event_lifecycle" and event.event == "session_ended" ) - break - if event is None: - break - yield event - if event.type == "voice_stream_event_lifecycle" and event.event == "session_ended": - saw_session_end = True - break - - # On the normal completion path, let the producer task finish gracefully so any active - # trace context can emit `trace_end` before we run cleanup. - try: - if ( - saw_session_end - and self.text_generation_task is not None - and not self.text_generation_task.done() - ): - await asyncio.shield(self.text_generation_task) + if is_session_end: + saw_session_end = True + yield event + if is_session_end: + break self._check_errors() + if self._stored_exception: + raise self._stored_exception + except BaseException as exc: + primary_exception = exc + raise finally: - await self._cleanup_tasks() + producer_exception: BaseException | None = None + cleanup_exception: BaseException | None = None + + # Let the producer finish gracefully after terminal event delivery so any active + # trace context can emit `trace_end` before cleanup. Await completed tasks too so a + # terminal producer failure cannot be hidden by the preceding lifecycle event. + if saw_session_end and self.text_generation_task is not None: + try: + await asyncio.shield(self.text_generation_task) + except BaseException as exc: + producer_exception = exc + try: + await self._cleanup_tasks() + except BaseException as exc: + cleanup_exception = exc + + # A caller cancellation always wins. Otherwise preserve a consumer exception, except + # that GeneratorExit after terminal delivery is only the control-flow signal from + # aclose() and must not replace the producer's terminal outcome. + preserve_primary_exception = primary_exception is not None and not ( + isinstance(primary_exception, GeneratorExit) and saw_session_end + ) + exception_to_raise: BaseException | None = None + if isinstance(primary_exception, asyncio.CancelledError): + pass + elif isinstance(producer_exception, asyncio.CancelledError): + exception_to_raise = producer_exception + elif isinstance(cleanup_exception, asyncio.CancelledError): + exception_to_raise = cleanup_exception + elif preserve_primary_exception: + pass + elif producer_exception is not None: + exception_to_raise = producer_exception + elif cleanup_exception is not None: + exception_to_raise = cleanup_exception + + finalization_exception_was_suppressed = any( + exc is not None + and not isinstance(exc, asyncio.CancelledError) + and exc is not exception_to_raise + for exc in (producer_exception, cleanup_exception) + ) + if finalization_exception_was_suppressed: + try: + logger.warning( + "Voice stream finalization failed while preserving another exception" + ) + except Exception: + # Logging must not replace the selected exception. + pass + + if exception_to_raise is not None: + raise exception_to_raise + + self._check_errors() if self._stored_exception: raise self._stored_exception diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index e492d4dd91..1514676bbe 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -3,13 +3,17 @@ import asyncio import base64 import json +import logging import time -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch import numpy as np import numpy.typing as npt import pytest +import agents._debug as _debug from agents import trace from agents.exceptions import UserError from tests.testing_processor import fetch_span_errors @@ -25,6 +29,7 @@ from agents.voice.exceptions import STTWebsocketConnectionError from agents.voice.models.openai_stt import ( EVENT_INACTIVITY_TIMEOUT, + ErrorSentinel, _audio_buffer_to_base64, ) @@ -95,6 +100,237 @@ async def hold_connection_open() -> None: await asyncio.gather(session._connection_task, return_exceptions=True) +@pytest.mark.asyncio +async def test_transcribe_turns_closes_owned_tasks_after_yield(monkeypatch) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + session._websocket = AsyncMock() + tracing_span = MagicMock() + session._tracing_span = tracing_span + never_finishes = asyncio.Event() + started = [asyncio.Event() for _ in range(4)] + stopped = [asyncio.Event() for _ in range(4)] + + async def hold_open(index: int) -> None: + started[index].set() + try: + await never_finishes.wait() + finally: + stopped[index].set() + + async def hold_connection_open() -> None: + await hold_open(0) + + monkeypatch.setattr(session, "_process_websocket_connection", hold_connection_open) + session._listener_task = asyncio.create_task(hold_open(1)) + session._process_events_task = asyncio.create_task(hold_open(2)) + session._stream_audio_task = asyncio.create_task(hold_open(3)) + await session._output_queue.put("hello") + + turns = cast(AsyncGenerator[str, None], session.transcribe_turns()) + assert await anext(turns) == "hello" + await asyncio.gather(*(event.wait() for event in started)) + + owned_tasks = ( + session._connection_task, + session._listener_task, + session._process_events_task, + session._stream_audio_task, + ) + try: + await turns.aclose() + await asyncio.wait_for( + asyncio.gather(*(event.wait() for event in stopped)), + timeout=1, + ) + assert all(task is not None and task.cancelled() for task in owned_tasks) + session._websocket.close.assert_awaited_once() + tracing_span.finish.assert_called_once_with() + assert session._tracing_span is None + finally: + tasks = [task for task in owned_tasks if task is not None] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_close_finishes_span_started_while_websocket_close_is_pending() -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + old_span = MagicMock() + replacement_span = MagicMock() + session._tracing_span = old_span + websocket_close_started = asyncio.Event() + allow_websocket_close = asyncio.Event() + + async def close_websocket() -> None: + websocket_close_started.set() + await allow_websocket_close.wait() + + session._websocket = AsyncMock() + session._websocket.close.side_effect = close_websocket + session._process_events_task = asyncio.create_task(session._handle_events()) + + with patch( + "agents.voice.models.openai_stt.transcription_span", + return_value=replacement_span, + ): + close_task = asyncio.create_task(session.close()) + try: + await websocket_close_started.wait() + await session._event_queue.put( + { + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "late transcript", + } + ) + assert await session._output_queue.get() == "late transcript" + session._output_queue.task_done() + + allow_websocket_close.set() + await close_task + finally: + allow_websocket_close.set() + if not close_task.done(): + close_task.cancel() + await asyncio.gather(close_task, return_exceptions=True) + + old_span.finish.assert_called_once_with() + replacement_span.start.assert_called_once_with() + replacement_span.finish.assert_called_once_with() + assert session._tracing_span is None + assert session._process_events_task.cancelled() + + +@pytest.mark.asyncio +async def test_transcribe_turns_preserves_consumer_exception_when_cleanup_fails( + monkeypatch, + caplog: pytest.LogCaptureFixture, +) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + never_finishes = asyncio.Event() + + async def hold_connection_open() -> None: + await never_finishes.wait() + + async def fail_cleanup() -> None: + raise RuntimeError("sensitive cleanup detail") + + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(session, "_process_websocket_connection", hold_connection_open) + monkeypatch.setattr(session, "_cleanup_tasks", fail_cleanup) + await session._output_queue.put("hello") + turns = cast(AsyncGenerator[str, None], session.transcribe_turns()) + assert await anext(turns) == "hello" + + try: + with caplog.at_level(logging.WARNING, logger="openai.agents"): + with pytest.raises(ValueError, match="sensitive consumer detail"): + await turns.athrow(ValueError("sensitive consumer detail")) + finally: + if session._connection_task is not None: + session._connection_task.cancel() + await asyncio.gather(session._connection_task, return_exceptions=True) + + message = "STT session cleanup failed while preserving another exception" + record = caplog.records[-1] + assert record.msg == message + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert record.getMessage() == message + assert logging.Formatter().format(record) == message + assert all( + not isinstance(value, RuntimeError | ValueError) for value in record.__dict__.values() + ) + + +@pytest.mark.asyncio +async def test_transcribe_turns_propagates_cancellation_during_cleanup(monkeypatch) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + never_finishes = asyncio.Event() + + async def hold_connection_open() -> None: + await never_finishes.wait() + + async def cancelled_cleanup() -> None: + raise asyncio.CancelledError + + monkeypatch.setattr(session, "_process_websocket_connection", hold_connection_open) + monkeypatch.setattr(session, "_cleanup_tasks", cancelled_cleanup) + await session._output_queue.put("hello") + turns = cast(AsyncGenerator[str, None], session.transcribe_turns()) + assert await anext(turns) == "hello" + + try: + # A primary consumer exception is active, but a cancellation raised while the STT + # session is closing must still propagate rather than be swallowed as secondary. + with pytest.raises(asyncio.CancelledError): + await turns.athrow(ValueError("consumer detail")) + finally: + if session._connection_task is not None: + session._connection_task.cancel() + await asyncio.gather(session._connection_task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_transcribe_turns_preserves_terminal_error_when_close_fails( + monkeypatch, +) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + terminal_error = RuntimeError("terminal STT error") + + async def fail_connection() -> None: + await session._output_queue.put(ErrorSentinel(terminal_error)) + raise terminal_error + + session._websocket = AsyncMock() + session._websocket.close.side_effect = RuntimeError("websocket cleanup error") + monkeypatch.setattr(session, "_process_websocket_connection", fail_connection) + + turns = session.transcribe_turns() + with pytest.raises(RuntimeError, match="terminal STT error") as exc_info: + await anext(turns) + + assert exc_info.value is terminal_error + assert session._connection_task is not None + await asyncio.gather(session._connection_task, return_exceptions=True) + + @pytest.mark.asyncio @pytest.mark.parametrize( ("trace_include_sensitive_data", "expected_error"), diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index b76b51555a..86c7f2ab63 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -2,8 +2,9 @@ import asyncio import logging +from collections.abc import AsyncGenerator from dataclasses import dataclass, field -from typing import Any, Literal +from typing import Any, Literal, cast import numpy as np import numpy.typing as npt @@ -98,6 +99,289 @@ async def produce_events() -> None: assert producer.cancelled() +@pytest.mark.asyncio +async def test_streamed_audio_result_preserves_cancellation_when_cleanup_fails( + monkeypatch, + caplog: pytest.LogCaptureFixture, +) -> None: + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + get_started = asyncio.Event() + never_finishes = asyncio.Event() + + async def wait_for_event() -> VoiceStreamEvent: + get_started.set() + await never_finishes.wait() + raise AssertionError("Unreachable") + + async def fail_cleanup() -> None: + raise RuntimeError("sensitive cleanup detail") + + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(result._queue, "get", wait_for_event) + monkeypatch.setattr(result, "_cleanup_tasks", fail_cleanup) + with caplog.at_level(logging.WARNING, logger="openai.agents"): + consumer = asyncio.ensure_future(anext(result.stream())) + await get_started.wait() + consumer.cancel() + + with pytest.raises(asyncio.CancelledError): + await consumer + + message = "Voice stream finalization failed while preserving another exception" + record = caplog.records[-1] + assert record.msg == message + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert record.getMessage() == message + assert logging.Formatter().format(record) == message + assert all( + not isinstance(value, RuntimeError | asyncio.CancelledError) + for value in record.__dict__.values() + ) + + +@pytest.mark.asyncio +async def test_streamed_audio_result_closes_owned_tasks_after_yield() -> None: + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + never_finishes = asyncio.Event() + started = [asyncio.Event() for _ in range(3)] + stopped = [asyncio.Event() for _ in range(3)] + + async def hold_open(index: int) -> None: + started[index].set() + try: + await never_finishes.wait() + finally: + stopped[index].set() + + synthesis_task = asyncio.create_task(hold_open(0)) + dispatcher_task = asyncio.create_task(hold_open(1)) + producer_task = asyncio.create_task(hold_open(2)) + result._tasks.append(synthesis_task) + result._dispatcher_task = dispatcher_task + result._set_task(producer_task) + await asyncio.gather(*(event.wait() for event in started)) + await result._queue.put(VoiceStreamEventLifecycle(event="turn_started")) + + stream = cast(AsyncGenerator[VoiceStreamEvent, None], result.stream()) + event = await anext(stream) + assert isinstance(event, VoiceStreamEventLifecycle) + assert event.event == "turn_started" + + owned_tasks = (synthesis_task, dispatcher_task, producer_task) + try: + await stream.aclose() + await asyncio.wait_for( + asyncio.gather(*(event.wait() for event in stopped)), + timeout=1, + ) + assert all(task.cancelled() for task in owned_tasks) + finally: + for task in owned_tasks: + task.cancel() + await asyncio.gather(*owned_tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_streamed_audio_result_closes_gracefully_after_session_end_yield() -> None: + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + producer_started = asyncio.Event() + allow_producer_finish = asyncio.Event() + producer_completed = asyncio.Event() + producer_cancelled = asyncio.Event() + + async def produce_session() -> None: + producer_started.set() + try: + await allow_producer_finish.wait() + except asyncio.CancelledError: + producer_cancelled.set() + raise + else: + producer_completed.set() + + producer_task = asyncio.create_task(produce_session()) + result._set_task(producer_task) + await producer_started.wait() + await result._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + + stream = cast(AsyncGenerator[VoiceStreamEvent, None], result.stream()) + event = await anext(stream) + assert isinstance(event, VoiceStreamEventLifecycle) + assert event.event == "session_ended" + + close_task = asyncio.create_task(stream.aclose()) + try: + await asyncio.sleep(0) + assert not close_task.done() + assert not producer_cancelled.is_set() + allow_producer_finish.set() + await asyncio.wait_for(close_task, timeout=1) + assert producer_completed.is_set() + assert not producer_cancelled.is_set() + assert not producer_task.cancelled() + finally: + allow_producer_finish.set() + if not close_task.done(): + close_task.cancel() + if not producer_task.done(): + producer_task.cancel() + await asyncio.gather(close_task, producer_task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_streamed_audio_result_propagates_cancellation_when_terminal_cleanup_fails( + monkeypatch, +) -> None: + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + producer_started = asyncio.Event() + producer_release = asyncio.Event() + + async def produce_session() -> None: + producer_started.set() + await producer_release.wait() + + async def fail_cleanup() -> None: + raise RuntimeError("cleanup failed") + + producer_task = asyncio.create_task(produce_session()) + result._set_task(producer_task) + monkeypatch.setattr(result, "_cleanup_tasks", fail_cleanup) + await producer_started.wait() + await result._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + + stream = cast(AsyncGenerator[VoiceStreamEvent, None], result.stream()) + event = await anext(stream) + assert isinstance(event, VoiceStreamEventLifecycle) + assert event.event == "session_ended" + + # aclose() blocks in the finally awaiting asyncio.shield(text_generation_task). Cancelling + # that cleanup (as asyncio.wait_for would on timeout) must surface the cancellation instead + # of reporting a successful close. + close_task = asyncio.create_task(stream.aclose()) + try: + await asyncio.sleep(0) + assert not close_task.done() + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + finally: + producer_release.set() + if not producer_task.done(): + producer_task.cancel() + await asyncio.gather(producer_task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cleanup_fails", [False, True]) +async def test_streamed_audio_result_aclose_surfaces_terminal_producer_error( + cleanup_fails: bool, + monkeypatch, +) -> None: + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + producer_started = asyncio.Event() + producer_release = asyncio.Event() + producer_error = RuntimeError("producer-failed-after-terminal") + + async def produce_session() -> None: + producer_started.set() + try: + await producer_release.wait() + except asyncio.CancelledError: + raise + raise producer_error + + async def fail_cleanup() -> None: + raise RuntimeError("cleanup failed") + + producer_task = asyncio.create_task(produce_session()) + result._set_task(producer_task) + if cleanup_fails: + monkeypatch.setattr(result, "_cleanup_tasks", fail_cleanup) + await producer_started.wait() + await result._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + + stream = cast(AsyncGenerator[VoiceStreamEvent, None], result.stream()) + event = await anext(stream) + assert isinstance(event, VoiceStreamEventLifecycle) + assert event.event == "session_ended" + + # The producer can fail after the terminal event has been delivered but while aclose() is + # waiting for graceful finalization. The public stream boundary must surface that outcome. + close_task = asyncio.create_task(stream.aclose()) + try: + await asyncio.sleep(0) + assert not close_task.done() + producer_release.set() + with pytest.raises(RuntimeError, match="producer-failed-after-terminal") as exc_info: + await asyncio.wait_for(close_task, timeout=1) + assert exc_info.value is producer_error + finally: + producer_release.set() + if not close_task.done(): + close_task.cancel() + if not producer_task.done(): + producer_task.cancel() + await asyncio.gather(close_task, producer_task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("close_early", [False, True]) +async def test_streamed_audio_result_surfaces_completed_terminal_producer_error( + close_early: bool, +) -> None: + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + producer_error = RuntimeError("completed producer error") + + async def fail_producer() -> None: + raise producer_error + + producer_task = asyncio.create_task(fail_producer()) + result._set_task(producer_task) + await asyncio.wait({producer_task}) + await result._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + + stream = cast(AsyncGenerator[VoiceStreamEvent, None], result.stream()) + event = await anext(stream) + assert isinstance(event, VoiceStreamEventLifecycle) + assert event.event == "session_ended" + + try: + with pytest.raises(RuntimeError, match="completed producer error") as exc_info: + if close_early: + await stream.aclose() + else: + await anext(stream) + assert exc_info.value is producer_error + finally: + await asyncio.gather(producer_task, return_exceptions=True) + + def test_voice_pipeline_config_normalizes_dictionary_settings() -> None: config = VoicePipelineConfig( stt_settings={"language": "ja", "temperature": 0.0}, From b2012870c30c8e940f54f5251e2778597c2fab9d Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 2 Aug 2026 22:56:17 -0500 Subject: [PATCH 115/473] fix(extensions): release AnyLLM provider streams and preserve completed runs (#4133) --- src/agents/extensions/models/any_llm_model.py | 203 +++++-- tests/models/test_any_llm_model.py | 535 +++++++++++++++++- 2 files changed, 688 insertions(+), 50 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 72f930dbab..bcdb196db7 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -1,10 +1,12 @@ from __future__ import annotations +import asyncio +import contextlib import importlib import inspect import json import time -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncGenerator, AsyncIterator, Iterable from copy import copy from typing import TYPE_CHECKING, Any, Literal, cast, overload @@ -26,7 +28,7 @@ from ...exceptions import ModelBehaviorError, UserError from ...handoffs import Handoff from ...items import ItemHelpers, ModelResponse, TResponseInputItem, TResponseStreamEvent -from ...logger import logger +from ...logger import log_model_action_debug, logger from ...model_settings import ModelSettings from ...models._openai_retry import get_openai_retry_advice from ...models._response_terminal import ( @@ -305,8 +307,33 @@ async def stream_response( conversation_id: str | None = None, prompt: ResponsePromptParam | None = None, ) -> AsyncIterator[TResponseStreamEvent]: + # `aclosing` forwards an early `aclose()` on this generator to the delegate, so the + # delegate's cleanup runs deterministically instead of waiting for garbage collection. + # The guarantee stops at the iterator any-llm returns: as of any-llm 1.11.0 its + # exception and provider wrappers delegate with bare `async for ... yield`, so they do + # not forward `aclose()` to the underlying transport. Closing that transport is an + # upstream any-llm concern, not something to reach into from here. if self._selected_api() == "responses": - async for chunk in self._stream_response_via_responses( + async with contextlib.aclosing( + self._stream_response_via_responses( + system_instructions=system_instructions, + input=input, + model_settings=model_settings, + tools=tools, + output_schema=output_schema, + handoffs=handoffs, + tracing=tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + ) as responses_stream: + async for chunk in responses_stream: + yield chunk + return + + async with contextlib.aclosing( + self._stream_response_via_chat( system_instructions=system_instructions, input=input, model_settings=model_settings, @@ -314,24 +341,11 @@ async def stream_response( output_schema=output_schema, handoffs=handoffs, tracing=tracing, - previous_response_id=previous_response_id, - conversation_id=conversation_id, prompt=prompt, - ): + ) + ) as chat_stream: + async for chunk in chat_stream: yield chunk - return - - async for chunk in self._stream_response_via_chat( - system_instructions=system_instructions, - input=input, - model_settings=model_settings, - tools=tools, - output_schema=output_schema, - handoffs=handoffs, - tracing=tracing, - prompt=prompt, - ): - yield chunk async def _get_response_via_responses( self, @@ -410,7 +424,7 @@ async def _stream_response_via_responses( previous_response_id: str | None, conversation_id: str | None, prompt: ResponsePromptParam | None, - ) -> AsyncIterator[ResponseStreamEvent]: + ) -> AsyncGenerator[ResponseStreamEvent, None]: with response_span(disabled=tracing.is_disabled()) as span_response: stream = await self._fetch_responses_response( system_instructions=system_instructions, @@ -427,6 +441,8 @@ async def _stream_response_via_responses( final_response: Response | None = None terminal_failure_error: ModelBehaviorError | None = None + yielded_terminal_event = False + close_stream_in_background = False try: async for chunk in stream: chunk_type = getattr(chunk, "type", None) @@ -443,17 +459,41 @@ async def _stream_response_via_responses( cast(str, chunk_type), chunk, ) + if chunk_type in { + "response.completed", + "response.failed", + "response.incomplete", + "error", + "response.error", + }: + yielded_terminal_event = True + # Populate the span before yielding the terminal event so a consumer + # that stops there still leaves a fully recorded span. + if tracing.include_data() and final_response: + span_response.span_data.response = final_response + span_response.span_data.input = input yield chunk + except asyncio.CancelledError: + close_stream_in_background = True + self._schedule_async_iterator_close(stream) + raise finally: - await self._maybe_aclose(stream) + if not close_stream_in_background: + try: + await self._close_stream_allowing_background_completion(stream) + except Exception as exc: + if yielded_terminal_event: + log_model_action_debug( + logger, + "Ignoring stream cleanup error after terminal event", + exc, + ) + else: + raise if terminal_failure_error is not None: raise terminal_failure_error - if tracing.include_data() and final_response: - span_response.span_data.response = final_response - span_response.span_data.input = input - async def _get_response_via_chat( self, *, @@ -567,7 +607,7 @@ async def _stream_response_via_chat( handoffs: list[Handoff], tracing: ModelTracing, prompt: ResponsePromptParam | None, - ) -> AsyncIterator[TResponseStreamEvent]: + ) -> AsyncGenerator[TResponseStreamEvent, None]: with generation_span( model=str(self.model), model_config=model_config_for_trace( @@ -591,38 +631,68 @@ async def _stream_response_via_chat( ) final_response: Response | None = None + yielded_terminal_event = False + close_stream_in_background = False try: async for chunk in ChatCmplStreamHandler.handle_stream( response, cast(Any, self._normalize_chat_stream(stream)), model=self.model, ): - yield chunk + # Record terminal state and populate the span before yielding so a consumer + # that stops at the completed event still leaves a fully recorded span. if chunk.type == "response.completed": final_response = chunk.response + yielded_terminal_event = True + self._populate_chat_generation_span( + span_generation, final_response, tracing + ) + + yield chunk + except asyncio.CancelledError: + close_stream_in_background = True + self._schedule_async_iterator_close(stream) + raise finally: - await self._maybe_aclose(stream) - - if tracing.include_data() and final_response: - span_generation.span_data.output = [final_response.model_dump()] - - if final_response and final_response.usage: - span_generation.span_data.usage = { - "requests": 1, - "input_tokens": final_response.usage.input_tokens, - "output_tokens": final_response.usage.output_tokens, - "total_tokens": final_response.usage.total_tokens, - "input_tokens_details": ( - final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details - else {"cached_tokens": 0, "cache_write_tokens": 0} - ), - "output_tokens_details": ( - final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details - else {"reasoning_tokens": 0} - ), - } + if not close_stream_in_background: + try: + await self._close_stream_allowing_background_completion(stream) + except Exception as exc: + if yielded_terminal_event: + log_model_action_debug( + logger, + "Ignoring stream cleanup error after terminal event", + exc, + ) + else: + raise + + @staticmethod + def _populate_chat_generation_span( + span_generation: Span[GenerationSpanData], + final_response: Response, + tracing: ModelTracing, + ) -> None: + if tracing.include_data(): + span_generation.span_data.output = [final_response.model_dump()] + + if final_response.usage: + span_generation.span_data.usage = { + "requests": 1, + "input_tokens": final_response.usage.input_tokens, + "output_tokens": final_response.usage.output_tokens, + "total_tokens": final_response.usage.total_tokens, + "input_tokens_details": ( + final_response.usage.input_tokens_details.model_dump() + if final_response.usage.input_tokens_details + else {"cached_tokens": 0, "cache_write_tokens": 0} + ), + "output_tokens_details": ( + final_response.usage.output_tokens_details.model_dump() + if final_response.usage.output_tokens_details + else {"reasoning_tokens": 0} + ), + } @overload async def _fetch_chat_response( @@ -1100,6 +1170,41 @@ async def _maybe_aclose(value: Any) -> None: if inspect.isawaitable(result): await result + def _schedule_async_iterator_close(self, iterator: Any) -> None: + self._detach_stream_close(asyncio.ensure_future(self._maybe_aclose(iterator))) + + async def _close_stream_allowing_background_completion(self, iterator: Any) -> None: + """Close the provider iterator, letting an in-flight close finish in the background. + + Cancellation can arrive while `aclose()` is already awaiting the provider. Shielding the + close and detaching that exact task keeps it running instead of abandoning it half-done, + and avoids starting a second close: re-closing a provider iterator is not guaranteed to + be safe or idempotent. + """ + close_task = asyncio.ensure_future(self._maybe_aclose(iterator)) + try: + await asyncio.shield(close_task) + except asyncio.CancelledError: + self._detach_stream_close(close_task) + raise + + def _detach_stream_close(self, close_task: asyncio.Future[None]) -> None: + if close_task.done(): + self._consume_background_cleanup_task_result(close_task) + return + close_task.add_done_callback(self._consume_background_cleanup_task_result) + + @staticmethod + def _consume_background_cleanup_task_result(task: asyncio.Future[Any]) -> None: + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as exc: + log_model_action_debug( + logger, "Background stream cleanup failed after cancellation", exc + ) + def _build_chat_extra_kwargs(self, model_settings: ModelSettings) -> dict[str, Any]: extra_kwargs: dict[str, Any] = {} if model_settings.extra_query: diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index c87477cd60..d6bab845d9 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import importlib import sys import types as pytypes @@ -14,9 +15,10 @@ ChatCompletionMessageFunctionToolCall, ) from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_chunk import ChoiceDelta +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta from openai.types.completion_usage import CompletionUsage, PromptTokensDetails from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputMessage +from openai.types.responses.response_created_event import ResponseCreatedEvent from openai.types.responses.response_error_event import ResponseErrorEvent from openai.types.responses.response_failed_event import ResponseFailedEvent from openai.types.responses.response_incomplete_event import ResponseIncompleteEvent @@ -37,6 +39,7 @@ Tool, TResponseInputItem, __version__, + trace, ) from agents.exceptions import UserError from agents.models.chatcmpl_helpers import HEADERS_OVERRIDE @@ -1127,3 +1130,533 @@ async def test_any_llm_chat_omits_logprobs_when_top_logprobs_unset(monkeypatch) ) assert "logprobs" not in provider.chat_calls[0] + + +class _ClosableStream: + """Stands in for the iterator any-llm returns, recording how often it was closed. + + These fakes deliberately cover only the SDK's own boundary. any-llm's exception and + provider wrappers (`utils/exception_handler.py::_wrap_async_iterator`, the per-provider + `chunk_iterator` / `_stream_completion_async` generators) delegate with bare + `async for ... yield` as of 1.11.0 and do not forward `aclose()` to the underlying + transport, so no assertion here should be read as proving the transport was closed. + """ + + def __init__(self, chunks: list[Any]) -> None: + self._chunks = list(chunks) + self.aclose_calls = 0 + + def __aiter__(self) -> _ClosableStream: + return self + + async def __anext__(self) -> Any: + if not self._chunks: + raise StopAsyncIteration + return self._chunks.pop(0) + + async def aclose(self) -> None: + self.aclose_calls += 1 + + +class _FailingCloseStream(_ClosableStream): + """Raises from `aclose` after recording the cleanup attempt.""" + + async def aclose(self) -> None: + self.aclose_calls += 1 + raise RuntimeError("close-failure") + + +class _BlockingStream(_ClosableStream): + """Blocks forever after its chunks are exhausted so the consumer can be cancelled.""" + + def __init__(self, chunks: list[Any], blocked: asyncio.Event) -> None: + super().__init__(chunks) + self._blocked = blocked + + async def __anext__(self) -> Any: + if self._chunks: + return self._chunks.pop(0) + self._blocked.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + +class _SlowCloseStream(_BlockingStream): + """Blocks in `aclose` until released, mirroring a close that waits on transport I/O.""" + + def __init__(self, chunks: list[Any], blocked: asyncio.Event, release: asyncio.Event) -> None: + super().__init__(chunks, blocked) + self._release = release + self.aclose_completed = 0 + + async def aclose(self) -> None: + self.aclose_calls += 1 + await self._release.wait() + self.aclose_completed += 1 + + +def _chat_chunk(text: str) -> ChatCompletionChunk: + return ChatCompletionChunk( + id="chunk_123", + created=0, + model="fake-model", + object="chat.completion.chunk", + choices=[ChunkChoice(index=0, delta=ChoiceDelta(content=text))], + ) + + +def _completed_event() -> ResponseCompletedEvent: + return ResponseCompletedEvent( + type="response.completed", + response=_response("Hello"), + sequence_number=1, + ) + + +def _stream_events(model: Any, tracing: ModelTracing = ModelTracing.DISABLED) -> Any: + return model.stream_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=tracing, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + +def _chat_module_and_model_with_stream(monkeypatch, stream: _ClosableStream) -> tuple[Any, Any]: + """Build an AnyLLMModel whose Chat Completions path yields one completed event.""" + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=stream) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + + async def fake_handle_stream(response, chunk_stream, model=None): + async for _chunk in chunk_stream: + pass + yield _completed_event() + + monkeypatch.setattr(module.ChatCmplStreamHandler, "handle_stream", fake_handle_stream) + return module, module.AnyLLMModel(model="openrouter/openai/gpt-5.4-mini") + + +def _chat_model_with_stream(monkeypatch, stream: _ClosableStream) -> Any: + _module, model = _chat_module_and_model_with_stream(monkeypatch, stream) + return model + + +def _responses_module_and_model_with_stream( + monkeypatch, stream: _ClosableStream +) -> tuple[Any, Any]: + provider = FakeAnyLLMProvider(supports_responses=True, responses_response=stream) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + return module, module.AnyLLMModel(model="openai/gpt-5.4-mini") + + +def _responses_model_with_stream(monkeypatch, stream: _ClosableStream) -> Any: + _module, model = _responses_module_and_model_with_stream(monkeypatch, stream) + return model + + +def _capture_spans(monkeypatch, module: Any, factory_name: str) -> list[Any]: + """Record the live span objects the module creates so tests can read them mid-stream.""" + original = getattr(module, factory_name) + captured: list[Any] = [] + + def recording_factory(*args: Any, **kwargs: Any) -> Any: + span = original(*args, **kwargs) + captured.append(span) + return span + + monkeypatch.setattr(module, factory_name, recording_factory) + return captured + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_stream_ignores_close_failure_after_terminal_event(monkeypatch) -> None: + """A completed Chat Completions response stays successful when provider cleanup fails.""" + stream = _FailingCloseStream([_chat_chunk("Hello")]) + model = _chat_model_with_stream(monkeypatch, stream) + + events = [event async for event in _stream_events(model)] + + assert [event.type for event in events] == ["response.completed"] + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_stream_ignores_close_failure_when_closed_at_terminal_event( + monkeypatch, +) -> None: + """Terminal state must be recorded before the completed event is yielded.""" + stream = _FailingCloseStream([_chat_chunk("Hello")]) + model = _chat_model_with_stream(monkeypatch, stream) + stream_agen = cast(Any, _stream_events(model)) + + async for event in stream_agen: + if event.type == "response.completed": + break + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_stream_closes_provider_stream_after_cancellation(monkeypatch) -> None: + """Cancelling the consumer must still release the provider stream.""" + blocked = asyncio.Event() + stream = _BlockingStream([_chat_chunk("He")], blocked) + model = _chat_model_with_stream(monkeypatch, stream) + stream_agen = cast(Any, _stream_events(model)) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(blocked.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + task.cancel() + + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_stream_does_not_block_cancellation_on_slow_close(monkeypatch) -> None: + """A provider close that waits on transport I/O must not delay cancellation.""" + blocked = asyncio.Event() + release = asyncio.Event() + stream = _SlowCloseStream([_chat_chunk("He")], blocked, release) + model = _chat_model_with_stream(monkeypatch, stream) + stream_agen = cast(Any, _stream_events(model)) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(blocked.wait(), timeout=5) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + assert stream.aclose_calls == 1 + assert stream.aclose_completed == 0 + + release.set() + for _ in range(200): + if stream.aclose_completed == 1: + break + await asyncio.sleep(0.01) + assert stream.aclose_completed == 1 + finally: + release.set() + task.cancel() + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_stream_propagates_close_failure_before_terminal_event( + monkeypatch, +) -> None: + """Cleanup failures before completion stay observable by the caller.""" + stream = _FailingCloseStream([_chat_chunk("Hello")]) + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=stream) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + + async def fake_handle_stream(response, chunk_stream, model=None): + yield ResponseCreatedEvent( + type="response.created", + response=_response("partial"), + sequence_number=0, + ) + async for _chunk in chunk_stream: + pass + yield _completed_event() + + monkeypatch.setattr(module.ChatCmplStreamHandler, "handle_stream", fake_handle_stream) + model = module.AnyLLMModel(model="openrouter/openai/gpt-5.4-mini") + stream_agen = cast(Any, _stream_events(model)) + + first_event = await anext(stream_agen) + assert first_event.type == "response.created" + + with pytest.raises(RuntimeError, match="close-failure"): + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_ignores_close_failure_after_terminal_event( + monkeypatch, +) -> None: + """A completed Responses stream stays successful when provider cleanup fails.""" + stream = _FailingCloseStream([_completed_event()]) + model = _responses_model_with_stream(monkeypatch, stream) + + events = [event async for event in _stream_events(model)] + + assert [event.type for event in events] == ["response.completed"] + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_ignores_close_failure_after_terminal_failure( + monkeypatch, +) -> None: + """A cleanup failure must not replace the terminal failure the caller should see.""" + stream = _FailingCloseStream( + [ + ResponseFailedEvent( + type="response.failed", + response=_response("partial", response_id="resp-terminal"), + sequence_number=1, + ) + ] + ) + model = _responses_model_with_stream(monkeypatch, stream) + + with pytest.raises(ModelBehaviorError, match="response.failed"): + async for _event in _stream_events(model): + pass + + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_propagates_close_failure_before_terminal_event( + monkeypatch, +) -> None: + """Cleanup failures before completion stay observable by the caller.""" + stream = _FailingCloseStream( + [ + ResponseCreatedEvent( + type="response.created", + response=_response("partial"), + sequence_number=0, + ), + _completed_event(), + ] + ) + model = _responses_model_with_stream(monkeypatch, stream) + stream_agen = cast(Any, _stream_events(model)) + + first_event = await anext(stream_agen) + assert first_event.type == "response.created" + + with pytest.raises(RuntimeError, match="close-failure"): + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_closes_provider_stream_after_cancellation( + monkeypatch, +) -> None: + """Cancelling the consumer must still release the provider Responses stream.""" + blocked = asyncio.Event() + stream = _BlockingStream( + [ + ResponseCreatedEvent( + type="response.created", + response=_response("partial"), + sequence_number=0, + ) + ], + blocked, + ) + model = _responses_model_with_stream(monkeypatch, stream) + stream_agen = cast(Any, _stream_events(model)) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(blocked.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + task.cancel() + + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + +class _CloseSignalingStream(_ClosableStream): + """Signals when `aclose` starts and blocks until released, so a test can cancel mid-close.""" + + def __init__( + self, + chunks: list[Any], + close_started: asyncio.Event, + release: asyncio.Event, + ) -> None: + super().__init__(chunks) + self._close_started = close_started + self._release = release + self.aclose_completed = 0 + + async def aclose(self) -> None: + self.aclose_calls += 1 + self._close_started.set() + await self._release.wait() + self.aclose_completed += 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_stream_populates_span_before_yielding_completed(monkeypatch) -> None: + """The generation span must carry output and usage when the completed event is delivered.""" + stream = _ClosableStream([_chat_chunk("Hello")]) + module, model = _chat_module_and_model_with_stream(monkeypatch, stream) + spans = _capture_spans(monkeypatch, module, "generation_span") + + with trace(workflow_name="any-llm-chat-span"): + stream_agen = cast(Any, _stream_events(model, ModelTracing.ENABLED)) + async for event in stream_agen: + if event.type == "response.completed": + # Assert while the generator is suspended at the terminal yield. + [span] = spans + assert span.span_data.output == [_response("Hello").model_dump()] + assert span.span_data.usage == { + "requests": 1, + "input_tokens": 11, + "output_tokens": 13, + "total_tokens": 24, + "input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}, + } + break + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_populates_span_before_yielding_completed( + monkeypatch, +) -> None: + """The response span must carry the response and input when the completed event arrives.""" + stream = _ClosableStream([_completed_event()]) + module, model = _responses_module_and_model_with_stream(monkeypatch, stream) + spans = _capture_spans(monkeypatch, module, "response_span") + + with trace(workflow_name="any-llm-responses-span"): + stream_agen = cast(Any, _stream_events(model, ModelTracing.ENABLED)) + async for event in stream_agen: + if event.type == "response.completed": + [span] = spans + assert span.span_data.response is not None + assert span.span_data.response.id == "resp_123" + assert span.span_data.input == "hi" + break + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_stream_lets_in_flight_close_finish_after_cancellation( + monkeypatch, +) -> None: + """Cancelling during `aclose` continues that close instead of starting a second one.""" + close_started = asyncio.Event() + release = asyncio.Event() + stream = _CloseSignalingStream([_chat_chunk("Hello")], close_started, release) + model = _chat_model_with_stream(monkeypatch, stream) + + async def consume() -> None: + async for _event in _stream_events(model): + pass + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(close_started.wait(), timeout=5) + assert stream.aclose_calls == 1 + assert stream.aclose_completed == 0 + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + + # The cancelled consumer must not have started a second close. + assert stream.aclose_calls == 1 + assert stream.aclose_completed == 0 + + release.set() + for _ in range(200): + if stream.aclose_completed == 1: + break + await asyncio.sleep(0.01) + + assert stream.aclose_calls == 1 + assert stream.aclose_completed == 1 + finally: + release.set() + task.cancel() + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_lets_in_flight_close_finish_after_cancellation( + monkeypatch, +) -> None: + """Cancelling during `aclose` continues that close instead of starting a second one.""" + close_started = asyncio.Event() + release = asyncio.Event() + stream = _CloseSignalingStream([_completed_event()], close_started, release) + model = _responses_model_with_stream(monkeypatch, stream) + + async def consume() -> None: + async for _event in _stream_events(model): + pass + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(close_started.wait(), timeout=5) + assert stream.aclose_calls == 1 + assert stream.aclose_completed == 0 + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + + assert stream.aclose_calls == 1 + assert stream.aclose_completed == 0 + + release.set() + for _ in range(200): + if stream.aclose_completed == 1: + break + await asyncio.sleep(0.01) + + assert stream.aclose_calls == 1 + assert stream.aclose_completed == 1 + finally: + release.set() + task.cancel() From 2bd71302d9238aec44c34849e290e3f6af23f394 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 13:14:58 +0900 Subject: [PATCH 116/473] chore: add implementation-final-review skill to the repo --- .../implementation-final-review/SKILL.md | 173 ++++++++++ .../agents/openai.yaml | 7 + .../references/reviewer-brief.md | 70 ++++ .../scripts/review_state.py | 304 ++++++++++++++++++ .../scripts/test_review_state.py | 199 ++++++++++++ .../scripts/test_skill_contract.py | 169 ++++++++++ AGENTS.md | 4 + 7 files changed, 926 insertions(+) create mode 100644 .agents/skills/implementation-final-review/SKILL.md create mode 100644 .agents/skills/implementation-final-review/agents/openai.yaml create mode 100644 .agents/skills/implementation-final-review/references/reviewer-brief.md create mode 100644 .agents/skills/implementation-final-review/scripts/review_state.py create mode 100644 .agents/skills/implementation-final-review/scripts/test_review_state.py create mode 100644 .agents/skills/implementation-final-review/scripts/test_skill_contract.py diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md new file mode 100644 index 0000000000..b04e9b7bf0 --- /dev/null +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -0,0 +1,173 @@ +--- +name: implementation-final-review +description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use self-contained reviewer briefs and concurrent independent reviewers for elevated-risk changes, overlap non-mutating final repository verification with reviewer waits on the same frozen fingerprint, preserve clean evidence for unchanged review components, batch and fix actionable findings, trigger a complexity reset when related fixes expand the design, and escalate a non-converging loop after at most six fingerprint rounds. +--- + +# Implementation Final Review + +Treat implementation and final review as separate phases. Reconstruct the change from the original requirement and the complete diff; do not defend the current design merely because it is implemented or tested. + +## Non-negotiable guarantees + +- Review the exact final task content, including committed, staged, unstaged, and task-owned untracked deliverables. +- Use the merge-base three-dot diff for patch ownership and the latest release tag separately for released compatibility. +- Require independent review. A same-context self-review cannot satisfy the clean-review gate. +- Freeze task-owned content while reviewers inspect a fingerprint. +- Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. +- Never weaken final repository verification. Component-aware review invalidation reduces repeated review, not required build or test gates. + +## Workflow + +1. Finish the initial implementation and focused tests. Apply formatting before review when formatting can rewrite the diff. +2. Re-read the original user request and the current implementation scope contract. If no contract exists, record the required behavior, compatibility requirements, intentionally unsupported cases and failure behavior, and supported alternative or `none`. +3. Resolve the intended target and merge base. If a supplied target or base is not an ancestor of `HEAD`, compute their common merge base and treat `merge-base...HEAD` as the task-owned diff. Use the latest release tag separately when released compatibility is the relevant boundary. Include committed, staged, unstaged, and untracked changes that belong to the task. +4. Read the complete task-owned three-dot diff from the resolved merge base. Never treat target-only commits between the merge base and an advanced or divergent target as deletions or regressions introduced by the patch. Check integration with the current target separately when relevant; report an actual conflict or semantic incompatibility, not mere absence of target-side changes. Do not limit review to the latest fix or files named in prior feedback. Record a complexity delta: runtime lines changed, new state fields, new synchronization or ownership mechanisms, affected subsystems, and test permutations. +5. Run the baseline-reset gate before accepting the current design: + - Describe the required behavior without referring to branch-local helper types or state. + - Identify the nearest released/base pipeline that already owns the behavior. + - Compare patching the current diff with replacing task-owned branch-local machinery by a narrow change from the base implementation. + - Treat unreleased implementation and tests as disposable. Preserve unrelated or user-owned changes. + - Choose the narrower design unless concrete contract evidence requires the current machinery. +6. Select the relevant review dimensions below from the affected runtime boundaries and repository architecture references. Complete every selected dimension even after finding a blocker; the goal is a complete final review, not the first valid comment. Classify review risk before dispatch: normal when the change does not affect concurrency, cancellation, security, trust, persistence, durable state, released compatibility, package/runtime exports, protocol ownership, or cross-provider lifecycle; elevated when any of those boundaries changes or an earlier round produced P0/P1. Run the cheapest affected-boundary preflight broad enough to catch likely late fallout from a dependency, package surface, generated artifact, or cross-cutting runtime change. For normal risk, prefer focused tests plus the affected subsystem's build, type, import, or generated-surface check. For elevated or cross-cutting risk, run the affected subsystem's complete unit suite plus its build, type, import, or distribution checks when available. Do not run the complete repository verification merely to enter the review gate. Run this preflight once for a semantic state and rerun only the affected checks after fixes. +7. Build the pre-dispatch evidence required by the changed boundary: + - For every changed public symbol, configuration field, event, serialized field, wire value, or documented caller-visible behavior, create a contract-surface inventory: producers and constructors; every consumer, forwarding branch, and adapter; default, missing, and invalid-value behavior; package exports and generated public surfaces when applicable; adjacent docs and examples; and caller-visible tests. Search adjacent contract surfaces even when they are absent from the diff. A required docs, example, export, adapter, or generated-surface update is a missing task deliverable, not out of scope merely because it is not yet in the manifest. + - For concurrency, cancellation, reentrancy, shared lifecycle state, or a check followed by an await before a side effect, create an await-boundary matrix. For each relevant operation, record the state snapshot, blocking or await point, events and operations that may run while suspended, durable or monotonic evidence retained, revalidation before each side effect, and resulting cancel, feedback, persistence, or cleanup action. Include source completion, a newer operation active with known and unknown identity, a newer operation that starts and completes while suspended, and failure or cancellation of the awaited action when those states are supported. If correctness depends on whether something ever happened, current active state is insufficient unless serialization proves it cannot be lost; require monotonic identity, generation, tombstone, or equivalent durable evidence. + - For protocol, persistence, or security changes, create the analogous authority/data-flow inventory from input through validation, storage, retry or replay, output, exceptions, logs, telemetry, and cleanup. + Treat these as mechanical coverage artifacts, not implementation conclusions. The implementer must fill them from code and contract evidence before review; reviewers validate them independently against the complete diff and surrounding source. +8. Produce only concrete, patch-scoped findings that are reproducible from code, contract, documentation, or a focused probe. Do not report hypothetical extensibility or unrelated cleanup. Before concluding, account for every row in the contract-surface, await-boundary, and authority/data-flow inventories and every new or modified source of shared state. For a scenario outside the required behavior, run a differential check against the merge base or latest release and identify support evidence. Reachability through a public method, concurrent call, repeated call, host-language protocol, or third-party behavior is not by itself a supported contract. +9. Classify every finding before editing: + - required-behavior defect; + - released compatibility or durable-boundary defect; + - missing failure-path or adversarial coverage; + - unsupported neighboring case that should fail earlier; + - unnecessary machinery or duplicated source of truth; + - unrelated or unsupported suggestion to reject. + Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. +10. Start a fingerprint-round counter at 1. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Separate pathspecs into `runtime`, `tests-examples`, and `release-metadata` components when those boundaries exist; use repository-appropriate names otherwise. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file runtime=runtime.paths --component-pathspec-file tests-examples=tests.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by root cause, severity, action, and whether each finding is new, repeated, or reintroduced. +11. Prepare one self-contained reviewer brief per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. +12. Freeze task-owned content while reviewers for a round are running. For normal risk, dispatch one independent reviewer. For elevated risk or a prior P0/P1, dispatch two independent reviewers concurrently on the same fingerprint and give them complementary primary dimensions. A broad multi-boundary normal-risk diff may also use two concurrent specialists when that is likely to collect findings in one round. Every reviewer sees the complete raw diff and may report blockers outside its specialty. Wait for every reviewer in the round before editing so findings can be grouped and fixed as one batch. Use one multi-target wait or the platform's first-completion wait when available; do not poll reviewers separately, ask for progress, or make them repeat shared evidence collection. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. Record combined and component fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when both fingerprints match the reviewed fingerprint exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit for the changed fingerprint. +13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the round and all clean credit. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. If only tests, examples, or release metadata changed without changing required behavior, compatibility, assertions about runtime behavior, or the scope contract, preserve clean credit for unchanged components and require delta reviews of every changed component plus its boundary with runtime using the original risk tier: one independent reviewer for normal risk or two concurrent independent reviewers for elevated risk. Any ambiguity invalidates the affected clean credit. +14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. A valid response must state the verdict, exact reviewed fingerprints, dimensions actually checked, coverage of every assigned inventory row and changed public/shared-state surface, focused probes run or explicitly none, and remaining uncertainty. A bare `clean`, generic checklist, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared coverage and reject the round if any inventory row or selected high-risk dimension remains unreviewed. +15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. If the repository requires a separate strategy pass, rerun it when the fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification for every affected boundary. +16. If a second related finding adds another condition, state, resolver step, protocol hop, or test permutation to the same abstraction, stop local patching and run the complexity reset. +17. Increment the fingerprint round and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> review without waiting for another user prompt. +18. Apply the non-convergence guard before another local fix: + - If the same root-cause group produces another P0/P1 after a complexity reset, return to the merge base and replace task-owned branch-local machinery with the narrowest coherent implementation. + - If runtime diff size, state fields, ownership modes, or test permutations grow materially for two consecutive rounds, do not call that convergence merely because each finding is local. Re-run the baseline-reset gate. + - If the same root-cause group produces actionable findings in three finding-bearing rounds, or the narrower reimplementation still produces the same root-cause P0/P1, escalate early rather than consuming the round budget. + - If four rounds complete without a shrinking or stable diff and falling finding severity, escalate early. +19. Stop successfully only after the required clean-review condition is met on the exact reviewed content and every required reviewer output has passed the acceptance gate: + - normal-risk change: one independent clean review; + - elevated-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint; launch them concurrently rather than serially. + - component-only post-review edit: clean credit for every unchanged component plus clean independent delta reviews covering all changed components and their runtime boundary, using one reviewer for normal risk or two concurrent reviewers for elevated risk. +20. After the clean-review condition is met, complete the repository's code-change verification or accept the overlapped result from step 12 only when every mandatory command succeeded in the repository-required order against the exact clean-reviewed fingerprint, execution did not mutate reviewed content or create an ambiguous repository-state change, and the final combined and component fingerprints still match. If verification was still running, wait for it; do not rerun successful exact-fingerprint work merely because review completed later. If reviewer findings caused an edit, run the required verification again for the new fingerprint. Classify any final-gate edit before invalidating review evidence: + - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. + - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. + - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. + - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. + Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean or delta-review evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. +21. Stop the autonomous loop after six fingerprint rounds. This is an absolute cap, not a target. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or continue with another bounded loop. + +Maintain one compact round ledger throughout the loop: + +`Round | component fingerprints | root-cause groups | highest severity | complexity delta | action | clean credit` + +Update it only at a meaningful state transition: round start, accepted finding batch, complexity reset, clean result, or verification result. Do not emit repeated waiting messages when neither reviewer state nor repository content changed. + +## Independent reviewer + +An independent review uses a fresh context that did not implement the fingerprinted content and is not given prior reviewer findings or implementer conclusions. Prefer a distinct agent. A same-context self-review is not independent and cannot satisfy the clean-review gate. + +- Give the reviewer the original requirement, implementation scope contract, base and head identifiers, canonical component manifest and fingerprints, raw repository state, and relevant architecture references. +- Give the reviewer the precomputed contract-surface and await-boundary or authority/data-flow inventories. These are coverage maps, not conclusions; require the reviewer to validate every row against the raw diff and surrounding source. +- Tell the reviewer which identifier is the intended target and require an explicit merge-base calculation. When target and head diverge, provide or request a three-dot diff; do not present a two-dot target-to-head diff as the patch. +- Do not give the reviewer the implementer's conclusions, suspected bugs, intended fixes, or a list of expected findings. +- Ask for exactly one read-only review round. The reviewer must not edit or stage files, run the autonomous review loop recursively, spawn another reviewer, or perform the final repository verification. The implementer owns finding validation, edits, loop control, and final verification. +- Give every reviewer for a round the same review-state fingerprint and keep the diff frozen until all of them finish. Reject output produced from a different or changing state instead of merging partial observations across revisions. +- Give every reviewer the self-contained brief and one exact revalidation command. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, the reviewer must report it and cannot return a creditable clean verdict. Tell reviewers not to inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag unless supplied evidence is inconsistent or decision-relevant. Reopening source cannot replace missing packet contents. This preserves fresh judgment while avoiding repeated setup work. +- Use fresh reviewers for every round when possible. Do not reveal findings or conclusions from prior rounds; provide only the updated requirement, scope contract, raw final diff, component manifest, and relevant references. +- Use one fresh reviewer for normal risk. Use two concurrent reviewers for the high-risk conditions in step 12, assigning complementary specialties while requiring each to inspect the complete diff. Multiple reviewers of the same unchanged diff are one fingerprint round. Do not duplicate broad test execution. +- Concurrent reviewers receive the same fingerprint and raw context but different primary specialties. They must not communicate during the round. +- Give the reviewer existing verification commands and results as raw evidence. The reviewer should inspect code and tests, then run only focused probes needed to resolve a decision-relevant uncertainty. A probe must be demonstrably non-mutating or run in an isolated temporary checkout; any mutation of the reviewed worktree invalidates the round. Do not rerun the repository's broad test, typecheck, lint, build, or integration suites merely to reconfirm the implementer's evidence; the implementer runs the complete stack once after the clean-review gate. +- Require evidence-bearing output. `clean` alone is never sufficient: the reviewer must return the exact fingerprint, assigned inventory coverage, high-risk dimensions checked, probes or `none`, and unresolved uncertainty or `none`. +- After fixes, review the exact final diff again. Preserve earlier clean credit only under the explicit component-delta rule; do not infer that a change is isolated merely from its file location. + +When an independent reviewer is unavailable, rebuild context from the original request, scope contract, source, and complete diff before a best-effort self-review. Explicitly discard incremental-review assumptions, label the result non-independent, and do not count it toward the clean-review gate. Report the unavailable gate at handoff instead of silently weakening it. + +## Review dimensions + +Choose dimensions based on the changed boundary; do not mechanically invent findings for every item. + +### Requirement and scope + +- Verify that the smallest required caller-visible behavior works. +- Identify nearby constructible cases and confirm they are either intentionally supported or rejected before side effects. +- Require contract evidence before treating repeated, concurrent, reentrant, malformed, wrapped, or cross-provider combinations as blockers. Reproduce the same supported scenario on the baseline when claiming a regression. +- Check whether tests accidentally turn implementation permutations into public contract. +- Map every new abstraction, state field, branch, dependency, and cross-module change to a requirement, supported contract, or verified risk. + +### Compatibility and identity + +- Compare released public signatures, field order, imports, names, serialized values, configuration, and wire behavior. +- Preserve exact caller-visible identity or spelling unless transformation is required. +- Distinguish unreleased branch-local machinery from released or durable compatibility boundaries. +- For every new or modified public field, enumerate all construction, forwarding, and consumption branches. Verify that normal, specialized, default, missing-value, and error paths either honor the field or reject it according to one coherent contract; do not validate only the motivating branch. +- Search public docs, examples, docstrings, configuration reference, and release metadata for claims made stale by the behavior change. Missing documentation can be an actionable omission even when no documentation file is in the diff. + +### Lifecycle and failures + +- Trace ownership from acquisition through success, failure, cancellation, retry, replacement, and cleanup. +- When shared lifecycle state changes, build a compact operation-state matrix before concluding. Cover each affected public mutating operation against never-started, partial-failure, active, cleanup-in-progress, and terminal states as applicable. +- Trace repeated sequential calls and every relevant pair of overlapping public mutating operations. Identify the linearization point or manager-owned serialization mechanism; do not infer safety from per-resource deduplication alone. +- Check repeated cancellation, partial initialization, cleanup failure, retry through every supported public entry point, and primary-exception preservation. +- State the final survivor invariant: which tasks, workers, processes, sessions, listeners, files, or remote resources may remain. +- Review ordering when several validations or cleanup actions can short-circuit one another. +- Audit every check-await-side-effect sequence. State may change during the await; require revalidation or prove manager-owned serialization before cancellation, feedback, persistence, or cleanup. +- Distinguish current state from historical evidence. If a stale-result guarantee depends on whether a newer operation ever started, an active pointer that later returns to `None` cannot prove absence; use or require monotonic evidence unless the operation is serialized. + +### Security, trust, persistence, and protocol + +- Trace caller-controlled data through logs, exceptions, causes, contexts, telemetry, model-visible output, and persisted state. +- Treat serialized state as authority only when the supported trust boundary explicitly allows it. +- Check fail-closed behavior for malformed or ambiguous sensitive inputs without returning or retaining the original value. +- Verify protocol capability ownership, pagination termination, cache ownership, retry and replay safety, wire validation, and tool or call identity when affected. + +### Behavioral parity + +- Compare streaming and non-streaming, sync and async, initial and resumed, direct and wrapped, and provider-specific paths when the requirement crosses them. +- Verify that one path does not silently ignore, reshape, or hard-fail data that another path supports. + +### Tests and generated public surfaces + +- Prefer public-boundary or caller-visible adversarial tests. +- Add controlled interleavings for concurrency instead of relying only on sequential tests. +- Test the required behavior, the nearest supported alternative, and one representative input per unsupported category. +- Do not accept passing existing tests as proof when they encode the same assumptions as the implementation. +- Import through intended consumer entry points and verify generated or distribution artifacts when public package behavior changes; runtime tests alone do not prove the published surface. + +## Complexity reset + +Run a complexity reset when related findings keep expanding the same design, a narrow requirement requires recursive or cached classification, tests enumerate mechanics, representations are inferred in multiple places, or the diff spreads unexpectedly across subsystems. + +1. Stop addressing findings one by one. +2. Group them by root cause and restate the original required behavior. +3. Compare the full diff with the merge base or release boundary. +4. Delete branch-local machinery that is not required. +5. Reuse the nearest existing source-of-truth pipeline. +6. Narrow unsupported behavior and reject it before side effects with a supported alternative when one exists. +7. Rebuild tests around caller-visible invariants and representative negative cases. +8. Compare the replacement's runtime and test complexity with both the previous round and the merge base. A reset that only renames or redistributes a growing state machine is not a reset. + +## Review output + +Lead with one verdict: `clean`, `findings require fixes`, or `complexity reset required`. + +For each finding provide: + +- priority and concise title; +- exact file and line or symbol; +- concrete failure scenario and user-visible consequence; +- contract/support basis plus baseline-versus-patch evidence when the scenario is outside the original requirement; +- smallest safe correction. + +If no actionable findings remain, say so directly and list the high-risk dimensions actually checked. Keep unverified runtime uncertainty explicit. Do not claim implementation completion until the clean post-fix review and required verification both apply to the exact final state. diff --git a/.agents/skills/implementation-final-review/agents/openai.yaml b/.agents/skills/implementation-final-review/agents/openai.yaml new file mode 100644 index 0000000000..c8e7a08bf1 --- /dev/null +++ b/.agents/skills/implementation-final-review/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Implementation Final Review" + short_description: "Review implementation diffs before completion" + default_prompt: "Use $implementation-final-review to audit this implementation from first principles before calling it complete." + +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md new file mode 100644 index 0000000000..d1a771d039 --- /dev/null +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -0,0 +1,70 @@ +# Independent Reviewer Brief + +Use this template to prepare one self-contained, factual packet per fingerprint round. Fill every field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Fill it once, reuse the shared body byte-for-byte for every reviewer, and vary only the final specialty assignment. Do not include implementer conclusions, suspected bugs, prior findings, or intended fixes. + +## Shared evidence + +- Original requirement: +- Implementation scope contract: + - Required behavior: + - Compatibility requirements: + - Intentionally unsupported cases and failure behavior: + - Supported alternative or `none`: +- Intended target: +- Resolved merge base: +- HEAD: +- Latest release boundary when relevant: +- Risk tier and reason: +- Canonical task manifest: +- Component manifests: +- Combined, component, and repository fingerprints: +- Exact fingerprint revalidation command: +- Raw repository status: +- Complete three-dot diff command: +- Focused preflight commands and results: +- Eligible concurrent final-gate commands and non-mutation basis: +- Gates deferred because they may mutate task-owned content, or `none`: +- Selected architecture references or exact relevant excerpts: + +## Contract-surface inventory + +One row per changed public symbol, configuration field, event, serialized field, wire value, or documented behavior. + +`surface | producers/constructors | consumers/forwarding branches/adapters | default/missing/invalid behavior | package exports/generated public surfaces | adjacent docs/examples | caller-visible tests` + +Include adjacent surfaces found outside the current diff. If a required update is absent, add it to the task manifest before freezing the review. + +## Await-boundary or authority inventory + +For concurrency, cancellation, reentrancy, or lifecycle state: + +`operation | state snapshot | await/blocking point | events/operations possible while suspended | monotonic evidence retained | revalidation | side effects/invariant` + +Populate supported states including source completion, newer active operation with known or unknown identity, newer operation started then completed, and awaited-action failure or cancellation. If the contract depends on whether something ever happened, identify the monotonic evidence or the serialization proof. + +For protocol, security, or persistence instead use: + +`input/authority | validation | in-memory state | persisted/serialized state | retry/replay | output | exception/log/telemetry exposure | cleanup/revocation` + +## Reviewer instructions + +Perform exactly one read-only review round on the frozen fingerprint. First run the supplied revalidation command and calculate the merge base. Then inspect the complete raw diff, surrounding source, tests, and supplied references. Validate every assigned inventory row rather than trusting the implementer. You may report blockers outside your specialty. + +Do not edit or stage files, recursively invoke the review workflow, spawn another reviewer, run broad repository verification, inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, report the missing field and do not return a creditable clean verdict. Reopen primary source or released evidence only when supplied evidence is inconsistent or leaves a decision-relevant uncertainty; do not use reopening to replace missing packet contents. Run only focused non-mutating probes needed to resolve such uncertainty. + +Return: + +1. Verdict: `clean`, `findings require fixes`, or `complexity reset required`. +2. Exact reviewed combined and component fingerprints. +3. Assigned inventory rows and high-risk dimensions checked. +4. Focused probes run, or `none`. +5. Remaining uncertainty, or `none`. +6. Findings in the skill's required format when applicable. + +A bare `clean` or generic checklist is incomplete and earns no clean credit. + +## Specialty assignment + +- Primary dimensions: +- Required inventory rows: +- Complementary reviewer assignment, if any: diff --git a/.agents/skills/implementation-final-review/scripts/review_state.py b/.agents/skills/implementation-final-review/scripts/review_state.py new file mode 100644 index 0000000000..f9a0b305e3 --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/review_state.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Print deterministic content and repository fingerprints for a review state.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +from pathlib import Path + + +def _git(repo: Path, *args: str) -> bytes: + return subprocess.check_output(("git", "-C", os.fspath(repo), *args), stderr=subprocess.PIPE) + + +def _digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _canonical_pathspecs(pathspecs: tuple[str, ...]) -> tuple[str, ...]: + canonical: list[str] = [] + seen: set[str] = set() + for pathspec in pathspecs: + if not pathspec: + raise ValueError("Pathspecs must not be empty.") + if "\0" in pathspec: + raise ValueError("Pathspecs must not contain NUL bytes.") + if pathspec not in seen: + canonical.append(pathspec) + seen.add(pathspec) + return tuple(canonical) + + +def _load_pathspec_file(path: Path) -> tuple[str, ...]: + try: + values = [line for line in path.read_text().splitlines() if line] + except (OSError, UnicodeError) as error: + raise ValueError(f"Cannot read pathspec file {path}: {error}") from error + return _canonical_pathspecs(tuple(values)) + + +def _workspace_entry(repo: Path, relative_path: str) -> dict[str, object]: + path = repo / relative_path + if path.is_symlink(): + content = b"symlink\0" + os.fsencode(os.readlink(path)) + return { + "path": relative_path, + "kind": "symlink", + "sha256": _digest(content), + } + if path.is_file(): + content = b"file\0" + path.read_bytes() + return { + "path": relative_path, + "kind": "file", + "executable": bool(path.stat().st_mode & 0o111), + "sha256": _digest(content), + } + if path.is_dir(): + try: + submodule_head = _git(path, "rev-parse", "HEAD^{commit}").decode().strip() + submodule_status = _git(path, "status", "--porcelain=v1", "-z") + except (subprocess.CalledProcessError, FileNotFoundError): + return {"path": relative_path, "kind": "directory"} + return { + "path": relative_path, + "kind": "gitlink", + "head": submodule_head, + "status_sha256": _digest(submodule_status), + } + return {"path": relative_path, "kind": "missing"} + + +def _workspace_entries( + repo: Path, base: str, pathspecs: tuple[str, ...] +) -> list[dict[str, object]]: + tracked_paths = _git( + repo, + "diff", + "--name-only", + "--no-renames", + "-z", + base, + "--", + *pathspecs, + ) + untracked_paths = _git( + repo, + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + *pathspecs, + ) + paths = { + os.fsdecode(raw_path) + for raw_path in (*tracked_paths.split(b"\0"), *untracked_paths.split(b"\0")) + if raw_path + } + return [_workspace_entry(repo, relative_path) for relative_path in sorted(paths)] + + +def _content_fingerprint(base: str, workspace: list[dict[str, object]]) -> str: + canonical = json.dumps( + {"base": base, "workspace": workspace}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return _digest(canonical.encode()) + + +def review_state( + repo: Path, + base: str, + pathspecs: tuple[str, ...] = (), + components: dict[str, tuple[str, ...]] | None = None, +) -> dict[str, object]: + repo = repo.resolve() + pathspecs = _canonical_pathspecs(pathspecs) + if components and not pathspecs: + pathspecs = _canonical_pathspecs( + tuple( + pathspec + for component_pathspecs in components.values() + for pathspec in component_pathspecs + ) + ) + resolved_base = _git(repo, "rev-parse", f"{base}^{{commit}}").decode().strip() + head = _git(repo, "rev-parse", "HEAD^{commit}").decode().strip() + try: + _git(repo, "merge-base", "--is-ancestor", resolved_base, head) + except subprocess.CalledProcessError as error: + raise ValueError("Base must be an ancestor of HEAD.") from error + tracked_diff = _git( + repo, + "diff", + "--binary", + "--full-index", + resolved_base, + "--", + *pathspecs, + ) + status = _git( + repo, + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--", + *pathspecs, + ) + workspace = _workspace_entries(repo, resolved_base, pathspecs) + + content_fingerprint = _content_fingerprint(resolved_base, workspace) + component_states: dict[str, dict[str, object]] = {} + component_owners: dict[str, list[str]] = {} + for name, component_pathspecs in sorted((components or {}).items()): + canonical_component_pathspecs = _canonical_pathspecs(component_pathspecs) + if not canonical_component_pathspecs: + raise ValueError(f"Component manifest is empty: {name}") + component_workspace = _workspace_entries(repo, resolved_base, canonical_component_pathspecs) + for entry in component_workspace: + component_owners.setdefault(str(entry["path"]), []).append(name) + component_states[name] = { + "content_fingerprint": _content_fingerprint(resolved_base, component_workspace), + "pathspecs": list(canonical_component_pathspecs), + "workspace": component_workspace, + } + if component_states: + combined_paths = {str(entry["path"]) for entry in workspace} + component_paths = set(component_owners) + missing_paths = sorted(combined_paths - component_paths) + extra_paths = sorted(component_paths - combined_paths) + overlapping_paths = { + path: owners for path, owners in component_owners.items() if len(owners) > 1 + } + if missing_paths or extra_paths or overlapping_paths: + raise ValueError( + "Component manifests must partition the combined review content exactly: " + f"missing={missing_paths}, extra={extra_paths}, " + f"overlapping={overlapping_paths}" + ) + + repository_state = { + "content_fingerprint": content_fingerprint, + "head": head, + "status_sha256": _digest(status), + "tracked_diff_sha256": _digest(tracked_diff), + } + repository_canonical = json.dumps( + repository_state, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + repository_fingerprint = _digest(repository_canonical.encode()) + return { + "fingerprint": content_fingerprint, + "content_fingerprint": content_fingerprint, + "repository_fingerprint": repository_fingerprint, + "base": resolved_base, + "pathspecs": list(pathspecs), + "workspace": workspace, + "components": component_states, + **repository_state, + } + + +def _parse_component_files(values: list[str]) -> dict[str, tuple[str, ...]]: + components: dict[str, tuple[str, ...]] = {} + for value in values: + name, separator, raw_path = value.partition("=") + if not separator or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) or not raw_path: + raise ValueError( + "Component pathspec files must use lowercase NAME=FILE with a nonempty file." + ) + if name in components: + raise ValueError(f"Duplicate component name: {name}") + components[name] = _load_pathspec_file(Path(raw_path)) + return components + + +def _component(value: str) -> tuple[str, str]: + name, separator, pathspec = value.partition("=") + if not separator or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) or not pathspec: + raise argparse.ArgumentTypeError("component must use lowercase NAME=PATHSPEC") + if "\0" in pathspec: + raise argparse.ArgumentTypeError("component pathspec must not contain NUL bytes") + return name, pathspec + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=True, help="Resolved merge-base commit or revision.") + parser.add_argument( + "--pathspec", + action="append", + default=[], + help="Task-owned Git pathspec. Repeat to scope the review; omit to include all changes.", + ) + parser.add_argument( + "--pathspec-file", + action="append", + default=[], + type=Path, + help="File containing canonical task-owned pathspecs, one per line.", + ) + parser.add_argument( + "--component-pathspec-file", + action="append", + default=[], + metavar="NAME=FILE", + help="Named component manifest. Repeat for runtime, tests-examples, or metadata.", + ) + parser.add_argument( + "--component", + action="append", + default=[], + type=_component, + metavar="NAME=PATHSPEC", + help="Named component pathspec. Repeat a name to group paths into one fingerprint.", + ) + parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Repository worktree path.") + parser.add_argument("--pretty", action="store_true", help="Pretty-print the JSON output.") + args = parser.parse_args() + try: + loaded_pathspec_files = [_load_pathspec_file(path) for path in args.pathspec_file] + if any(not pathspecs for pathspecs in loaded_pathspec_files): + raise ValueError("A supplied pathspec file must contain at least one pathspec.") + file_pathspecs = tuple( + pathspec for pathspecs in loaded_pathspec_files for pathspec in pathspecs + ) + pathspecs = _canonical_pathspecs((*args.pathspec, *file_pathspecs)) + component_files = _parse_component_files(args.component_pathspec_file) + component_values: dict[str, list[str]] = { + name: list(component_pathspecs) for name, component_pathspecs in component_files.items() + } + for name, pathspec in args.component: + component_values.setdefault(name, []).append(pathspec) + components = { + name: _canonical_pathspecs(tuple(component_pathspecs)) + for name, component_pathspecs in component_values.items() + } + state = review_state(args.repo, args.base, pathspecs, components) + except ValueError as error: + parser.error(str(error)) + except subprocess.CalledProcessError as error: + parser.error(f"Git command failed with exit status {error.returncode}.") + except (OSError, UnicodeError) as error: + parser.error(f"Cannot inspect repository state: {error}") + print( + json.dumps( + state, + ensure_ascii=False, + indent=2 if args.pretty else None, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/implementation-final-review/scripts/test_review_state.py b/.agents/skills/implementation-final-review/scripts/test_review_state.py new file mode 100644 index 0000000000..41b0d194ed --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/test_review_state.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from review_state import _component, _load_pathspec_file, review_state + + +class ReviewStateTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.repo = Path(self.temporary_directory.name) + self._git("init", "-q") + self._git("config", "user.email", "review-state@example.test") + self._git("config", "user.name", "Review State Test") + (self.repo / ".gitignore").write_text("plans/private.md\n") + (self.repo / "src").mkdir() + (self.repo / "tests").mkdir() + (self.repo / "plans").mkdir() + (self.repo / "src" / "runtime.py").write_text("VALUE = 1\n") + (self.repo / "tests" / "test_runtime.py").write_text("assert True\n") + self._git("add", ".") + self._git("commit", "-qm", "initial") + self.base = self._git("rev-parse", "HEAD").strip() + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def _git(self, *args: str) -> str: + return subprocess.check_output(("git", "-C", str(self.repo), *args), text=True) + + def _run_cli(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ( + sys.executable, + str(Path(__file__).with_name("review_state.py")), + "--repo", + str(self.repo), + "--base", + self.base, + *args, + ), + capture_output=True, + text=True, + ) + + def test_equivalent_pathspecs_have_the_same_content_fingerprint(self) -> None: + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + explicit = review_state(self.repo, self.base, ("src/runtime.py",)) + directory = review_state(self.repo, self.base, ("src",)) + with_ignored_artifact = review_state( + self.repo, self.base, ("src/runtime.py", "plans/private.md") + ) + + self.assertEqual(explicit["content_fingerprint"], directory["content_fingerprint"]) + self.assertEqual( + explicit["content_fingerprint"], with_ignored_artifact["content_fingerprint"] + ) + + def test_component_fingerprints_invalidate_only_changed_content(self) -> None: + runtime = self.repo / "src" / "runtime.py" + tests = self.repo / "tests" / "test_runtime.py" + runtime.write_text("VALUE = 2\n") + tests.write_text("assert 2 == 2\n") + components = {"runtime": ("src",), "tests-examples": ("tests",)} + before = review_state(self.repo, self.base, ("src", "tests"), components) + + tests.write_text("assert 2 != 1\n") + after = review_state(self.repo, self.base, ("src", "tests"), components) + + self.assertEqual( + before["components"]["runtime"]["content_fingerprint"], + after["components"]["runtime"]["content_fingerprint"], + ) + self.assertNotEqual( + before["components"]["tests-examples"]["content_fingerprint"], + after["components"]["tests-examples"]["content_fingerprint"], + ) + self.assertNotEqual(before["content_fingerprint"], after["content_fingerprint"]) + + def test_pathspec_file_preserves_literal_values_and_deduplicates(self) -> None: + manifest = self.repo / "paths.txt" + manifest.write_text("src\n\n#literal\n lead.py\nsrc\n") + + self.assertEqual(_load_pathspec_file(manifest), ("src", "#literal", " lead.py")) + + def test_direct_pathspec_preserves_leading_space(self) -> None: + (self.repo / " lead.py").write_text("VALUE = 2\n") + + completed = self._run_cli("--pathspec", " lead.py") + + self.assertEqual(completed.returncode, 0, completed.stderr) + state = json.loads(completed.stdout) + self.assertEqual([entry["path"] for entry in state["workspace"]], [" lead.py"]) + + def test_empty_direct_pathspec_fails_closed(self) -> None: + completed = self._run_cli("--pathspec", "") + + self.assertEqual(completed.returncode, 2) + self.assertIn("Pathspecs must not be empty", completed.stderr) + self.assertNotIn("Traceback", completed.stderr) + + def test_invalid_manifest_files_are_parser_errors(self) -> None: + cases = ( + ("--pathspec-file", str(self.repo / "missing.paths")), + ("--pathspec-file", str(self.repo)), + ("--component-pathspec-file", "runtime="), + ("--component-pathspec-file", f"runtime={self.repo / 'missing.paths'}"), + ) + for arguments in cases: + with self.subTest(arguments=arguments): + completed = self._run_cli(*arguments) + self.assertEqual(completed.returncode, 2) + self.assertIn("error:", completed.stderr) + self.assertNotIn("Traceback", completed.stderr) + + def test_invalid_repository_is_a_parser_error(self) -> None: + missing_repo = self.repo / "missing-repo" + completed = subprocess.run( + ( + sys.executable, + str(Path(__file__).with_name("review_state.py")), + "--repo", + str(missing_repo), + "--base", + self.base, + ), + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 2) + self.assertIn("Git command failed", completed.stderr) + self.assertNotIn("fatal:", completed.stderr) + self.assertNotIn("Traceback", completed.stderr) + + def test_invalid_base_is_a_parser_error(self) -> None: + completed = self._run_cli("--base", "missing-revision") + + self.assertEqual(completed.returncode, 2) + self.assertIn("Git command failed", completed.stderr) + self.assertNotIn("fatal:", completed.stderr) + self.assertNotIn("Traceback", completed.stderr) + + def test_non_ancestor_base_is_a_parser_error(self) -> None: + self._git("checkout", "-qb", "sibling") + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + self._git("commit", "-qam", "sibling change") + sibling = self._git("rev-parse", "HEAD").strip() + self._git("checkout", "-qb", "current", self.base) + (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n") + self._git("commit", "-qam", "head change") + + completed = self._run_cli("--base", sibling) + + self.assertEqual(completed.returncode, 2) + self.assertIn("Base must be an ancestor of HEAD", completed.stderr) + self.assertNotIn("fatal:", completed.stderr) + self.assertNotIn("Traceback", completed.stderr) + + def test_component_manifests_must_cover_combined_content(self) -> None: + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n") + + with self.assertRaisesRegex(ValueError, "missing=.*test_runtime.py"): + review_state(self.repo, self.base, ("src", "tests"), {"runtime": ("src",)}) + + def test_component_manifests_must_not_overlap(self) -> None: + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + + with self.assertRaisesRegex(ValueError, "overlapping=.*runtime.py"): + review_state( + self.repo, + self.base, + ("src",), + {"runtime": ("src",), "tests-examples": ("src/runtime.py",)}, + ) + + def test_components_define_combined_scope_when_pathspecs_are_omitted(self) -> None: + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + state = review_state(self.repo, self.base, components={"runtime": ("src",)}) + + self.assertEqual(state["pathspecs"], ["src"]) + self.assertEqual([entry["path"] for entry in state["workspace"]], ["src/runtime.py"]) + + def test_component_cli_value(self) -> None: + self.assertEqual(_component("runtime=src"), ("runtime", "src")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py new file mode 100644 index 0000000000..39a4f65aef --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +class SkillContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.skill_root = Path(__file__).resolve().parent.parent + cls.skill = (cls.skill_root / "SKILL.md").read_text() + cls.agent_config = (cls.skill_root / "agents" / "openai.yaml").read_text() + cls.reviewer_brief = (cls.skill_root / "references" / "reviewer-brief.md").read_text() + + def test_repo_local_metadata_matches_skill(self) -> None: + self.assertEqual(self.skill.splitlines()[1], "name: implementation-final-review") + self.assertIn('display_name: "Implementation Final Review"', self.agent_config) + self.assertIn("$implementation-final-review", self.agent_config) + self.assertIn("allow_implicit_invocation: false", self.agent_config) + + def test_workflow_steps_are_consecutive(self) -> None: + workflow = self.skill.split("## Workflow", 1)[1].split( + "Maintain one compact round ledger", 1 + )[0] + steps = [int(value) for value in re.findall(r"^(\d+)\. ", workflow, re.MULTILINE)] + + self.assertEqual(steps, list(range(1, 22))) + + def test_quality_gates_cover_prior_failure_modes(self) -> None: + required_text = ( + "contract-surface inventory", + "every consumer, forwarding branch, and adapter", + "Search adjacent contract surfaces even when they are absent from the diff", + "await-boundary matrix", + "a newer operation that starts and completes while suspended", + "current active state is insufficient", + "A bare `clean`", + ) + + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_reviewer_brief_avoids_repeated_context_discovery(self) -> None: + required_text = ( + "Exact fingerprint revalidation command", + "Complete three-dot diff command", + "Do not edit or stage files", + "inspect memory", + "rediscover workflow skills", + "A bare `clean` or generic checklist is incomplete", + ) + + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.reviewer_brief) + + def test_incomplete_reviewer_packets_fail_closed(self) -> None: + required_skill_text = ( + "Populate every template field or mark it explicitly `none` or `not applicable`", + "do not dispatch an incomplete packet", + "missing packet evidence cannot be reconstructed by the reviewer", + "cannot return a creditable clean verdict", + "Reopening source cannot replace missing packet contents", + ) + required_brief_text = ( + "Fill every field or mark it explicitly `none` or `not applicable`", + "do not dispatch an incomplete packet", + "report the missing field and do not return a creditable clean verdict", + "do not use reopening to replace missing packet contents", + ) + + for text in required_skill_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + for text in required_brief_text: + with self.subTest(text=text): + self.assertIn(text, self.reviewer_brief) + + def test_full_verification_can_overlap_review_without_weakening_freeze(self) -> None: + required_text = ( + "Do not leave the implementer idle while reviewers run", + "complete mutating formatting before fingerprinting", + "every eligible non-mutating final repository gate", + "exact frozen content", + "`make lint`, `make typecheck`, and `make tests` during review", + "discard verification credit for the changed fingerprint", + "accept the overlapped result from step 12", + "exact clean-reviewed fingerprint", + "do not rerun successful exact-fingerprint work", + ) + + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + self.assertIn("Eligible concurrent final-gate commands", self.reviewer_brief) + self.assertIn( + "Gates deferred because they may mutate task-owned content", self.reviewer_brief + ) + + def test_overlapped_final_gates_preserve_fingerprint_integrity(self) -> None: + required_text = ( + "establish that it does not edit, format, regenerate, stage, or create any " + "task-owned deliverable", + "Record combined and component fingerprints immediately before each gate starts and " + "after it exits", + "both fingerprints match the reviewed fingerprint exactly", + "cancel or stop the obsolete verification when practical", + "Keep `$pr-draft-summary` deferred", + "Invoke `$pr-draft-summary` last", + ) + + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_final_gate_deltas_are_classified_by_component(self) -> None: + required_text = ( + "Runtime, public API, behavior-impacting docs", + "Tests or examples only", + "Release metadata only", + "Operational artifact only", + "final combined fingerprint", + ) + + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_shared_typescript_improvements_keep_python_boundaries(self) -> None: + required_text = ( + "package exports and generated public surfaces when applicable", + "protocol capability ownership, pagination termination, cache ownership", + "in `openai-agents-python`, this means `make format` before fingerprinting", + "`make lint`, `make typecheck`, and `make tests` during review", + ) + + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + self.assertNotIn("$changeset-validation", self.skill) + self.assertNotIn("browser/Node/workerd", self.skill) + + def test_final_clean_condition_uses_canonical_elevated_tier(self) -> None: + self.assertIn( + "elevated-risk change or any loop that produced a P0/P1 finding", + self.skill, + ) + self.assertNotIn( + "released compatibility, or any loop that produced a P0/P1 finding", + self.skill, + ) + + def test_cross_references_use_current_step_numbers(self) -> None: + self.assertIn("high-risk conditions in step 12", self.skill) + self.assertIn( + "component delta review using the risk tier and clean-review conditions from step 19", + self.skill, + ) + self.assertNotIn("high-risk conditions in step 10", self.skill) + + +if __name__ == "__main__": + unittest.main() diff --git a/AGENTS.md b/AGENTS.md index bba14e3461..1d39b8b20b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,10 @@ Before changing or reviewing runtime code, exported APIs, external configuration Repeat the skill before editing each new review-feedback batch; an earlier strategy decision is stale when a comment would widen the supported contract or add another compatibility branch, resolver condition, or test permutation. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. +#### `$implementation-final-review` + +After implementing runtime code, tests, examples, build/test behavior, or behavior-impacting docs and completing focused tests, run `$implementation-final-review` before final `$code-change-verification` and `$pr-draft-summary` work and before declaring the task complete. This repository instruction authorizes automatic invocation without a separate user mention. Do not invoke it for planning, investigation, review, or report-only tasks, repo-meta changes, or docs without behavior impact. The skill's clean-review gate does not replace any other mandatory repository skill or verification gate. + #### `$pr-draft-summary` Before every final response for a task that changed runtime code, tests, examples, build/test configuration, or docs with behavior impact, invoke `$pr-draft-summary` to generate the required PR summary block, branch suggestion, title, and draft description. Determine whether to invoke it from the changed files, not from a subjective assessment of change size. From 0c3844a220a4d9093d19cac275a0a43b7c575c56 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 2 Aug 2026 23:34:14 -0500 Subject: [PATCH 117/473] fix(extensions): send AnyLLM Responses reasoning as a mapping (#4138) --- src/agents/extensions/models/any_llm_model.py | 5 +- tests/models/test_any_llm_model.py | 99 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index bcdb196db7..b72f182a1b 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -978,7 +978,10 @@ async def _fetch_responses_response( "conversation": conversation_id, "include": include, "parallel_tool_calls": parallel_tool_calls, - "reasoning": _to_dump_compatible(model_settings.reasoning) + # any-llm types `ResponsesParams.reasoning` as a mapping, so dump the model + # directly. `_to_dump_compatible` only materializes lazy iterables, and a + # pydantic model iterates as key/value pairs, which would send a list instead. + "reasoning": model_settings.reasoning.model_dump(mode="json", exclude_none=True) if model_settings.reasoning is not None else None, "text": self._remove_not_given(text), diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index d6bab845d9..fd038d47cc 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -28,6 +28,7 @@ OutputTokensDetails, ResponseUsage, ) +from openai.types.shared import Reasoning from pydantic import BaseModel from agents import ( @@ -999,6 +1000,104 @@ def _get_provider(self) -> Any: ] +class _RecordingResponsesProvider: + """Provider stub that records the params any-llm's private responses API receives.""" + + SUPPORTS_RESPONSES = True + + def __init__(self, response: Any) -> None: + self._response = response + self.private_responses_calls: list[dict[str, Any]] = [] + + async def aresponses(self, **kwargs: Any) -> Any: + raise AssertionError("public aresponses path should not be used in this test") + + async def _aresponses(self, params: Any, **kwargs: Any) -> Any: + self.private_responses_calls.append({"params": params, "kwargs": kwargs}) + return self._response + + +def _model_bound_to_provider(provider: Any) -> Any: + from agents.extensions.models.any_llm_model import AnyLLMModel + + class _BoundAnyLLMModel(AnyLLMModel): + def _get_provider(self) -> Any: + return provider + + return _BoundAnyLLMModel(model="openai/gpt-5.4-mini", api="responses") + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_any_llm_responses_path_sends_reasoning_as_a_mapping(stream: bool) -> None: + """any-llm types `ResponsesParams.reasoning` as a mapping, so it must not be a pair list.""" + pytest.importorskip( + "any_llm", + reason="`any-llm-sdk` is only available when the optional dependency is installed.", + ) + + async def response_stream() -> AsyncIterator[ResponseCompletedEvent]: + yield ResponseCompletedEvent( + type="response.completed", + response=_response("Hello"), + sequence_number=1, + ) + + provider = _RecordingResponsesProvider(response_stream() if stream else _response("Hello")) + model = _model_bound_to_provider(provider) + + # Building `ResponsesParams` validates the payload, so a pair list fails before the + # provider is reached. + result = await cast(Any, model)._fetch_responses_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(reasoning=Reasoning(effort="low", summary="concise")), + tools=[], + output_schema=None, + handoffs=[], + previous_response_id=None, + conversation_id=None, + stream=stream, + prompt=None, + ) + if stream: + await result.aclose() + + assert len(provider.private_responses_calls) == 1 + params = provider.private_responses_calls[0]["params"] + # Unset reasoning fields are dropped, matching how this adapter sanitizes replayed input. + assert params.reasoning == {"effort": "low", "summary": "concise"} + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_path_omits_reasoning_when_unset() -> None: + pytest.importorskip( + "any_llm", + reason="`any-llm-sdk` is only available when the optional dependency is installed.", + ) + + provider = _RecordingResponsesProvider(_response("Hello")) + model = _model_bound_to_provider(provider) + + await cast(Any, model)._fetch_responses_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + previous_response_id=None, + conversation_id=None, + stream=False, + prompt=None, + ) + + assert len(provider.private_responses_calls) == 1 + assert provider.private_responses_calls[0]["params"].reasoning is None + + def test_any_llm_provider_passes_api_override() -> None: pytest.importorskip( "any_llm", From 7de6ccf05ddcddcd67839814fea9e4959f22b515 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 13:36:25 +0900 Subject: [PATCH 118/473] fix(schema): normalize typeless strict object schemas (#4139) Co-authored-by: Rajarshi Datta <138959719+rajarshidattapy@users.noreply.github.com> --- src/agents/strict_schema.py | 46 +++++++-- tests/mcp/test_mcp_util.py | 19 +++- tests/test_function_tool.py | 77 +++++++++++++++ tests/test_handoff_tool.py | 13 ++- tests/test_strict_schema.py | 153 ++++++++++++++++++++++++++++-- tests/test_strict_schema_oneof.py | 47 ++++++--- 6 files changed, 325 insertions(+), 30 deletions(-) diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 463cede791..1bab745cb5 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -20,6 +20,13 @@ # example, tool schemas advertised by a third-party MCP server). _MAX_SCHEMA_NODES = 100_000 +_ADDITIONAL_PROPERTIES_ERROR = ( + "additionalProperties should not be set for object types. This could be because " + "you're using an older version of Pydantic, or because you configured additional " + "properties to be allowed. If you really need this, update the function or output tool " + "to not use a strict schema." +) + class _NodeBudget: """Tracks the remaining schema-node expansion budget across the recursion.""" @@ -45,9 +52,27 @@ def ensure_strict_json_schema( """ if schema == {}: return copy.deepcopy(_EMPTY_SCHEMA) - return _ensure_strict_json_schema( + converted = _ensure_strict_json_schema( schema, path=(), root=schema, budget=_NodeBudget(_MAX_SCHEMA_NODES) ) + return _ensure_strict_root(converted) + + +def _ensure_strict_root(schema: dict[str, Any]) -> dict[str, Any]: + if is_list(schema.get("anyOf")): + raise UserError("The root of a strict JSON schema must not use `anyOf`.") + + typ = schema.get("type") + if is_list(typ) and "object" in typ: + if typ == ["object"]: + schema["type"] = "object" + else: + raise UserError( + "The root of a strict JSON schema must be a non-nullable object, but its type is " + f"{typ}. Make the root a plain object, or update the function or output tool to " + "not use a strict schema." + ) + return schema # Adapted from https://github.com/openai/openai-python/blob/main/src/openai/lib/_pydantic.py @@ -85,26 +110,26 @@ def _ensure_strict_json_schema( ) typ = json_schema.get("type") - if typ == "object" and "additionalProperties" not in json_schema: + properties = json_schema.get("properties") + if typ is None and is_dict(properties): + typ = json_schema["type"] = "object" + elif typ is None and json_schema.get("additionalProperties", False) is not False: + raise UserError(_ADDITIONAL_PROPERTIES_ERROR) + is_object = typ == "object" or (is_list(typ) and "object" in typ) + if is_object and "additionalProperties" not in json_schema: json_schema["additionalProperties"] = False elif ( - typ == "object" + is_object and "additionalProperties" in json_schema # Compare with ``is not False`` rather than truthiness: OpenAPI/MCP schemas often use # ``additionalProperties: {}`` (an empty schema meaning "allow anything"). That value is # falsy in Python, so a truthiness check would silently leave a non-strict schema in place. and json_schema["additionalProperties"] is not False ): - raise UserError( - "additionalProperties should not be set for object types. This could be because " - "you're using an older version of Pydantic, or because you configured additional " - "properties to be allowed. If you really need this, update the function or output tool " - "to not use a strict schema." - ) + raise UserError(_ADDITIONAL_PROPERTIES_ERROR) # object types # { 'type': 'object', 'properties': { 'a': {...} } } - properties = json_schema.get("properties") if is_dict(properties): json_schema["required"] = list(properties.keys()) json_schema["properties"] = { @@ -158,6 +183,7 @@ def _ensure_strict_json_schema( ) ) json_schema.pop("allOf") + return _ensure_strict_json_schema(json_schema, path=path, root=root, budget=budget) else: json_schema["allOf"] = [ _ensure_strict_json_schema( diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 5e88e6c579..b71ec8b776 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -1852,7 +1852,7 @@ def test_to_function_tool_failed_strict_conversion_keeps_original_schema(): schema = { "type": "object", "properties": { - "x": {"type": "object", "additionalProperties": True}, + "x": {"additionalProperties": True}, }, } tool = MCPTool(name="test_tool", inputSchema=schema) @@ -1863,11 +1863,26 @@ def test_to_function_tool_failed_strict_conversion_keeps_original_schema(): assert function_tool.params_json_schema == { "type": "object", "properties": { - "x": {"type": "object", "additionalProperties": True}, + "x": {"additionalProperties": True}, }, } +def test_to_function_tool_nullable_root_falls_back_to_non_strict(): + schema = { + "anyOf": [ + {"type": "object", "properties": {"value": {"type": "string"}}}, + {"type": "null"}, + ] + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == {**schema, "properties": {}} + + class StructuredContentTestServer(FakeMCPServer): """Test server that allows setting both content and structured content for testing.""" diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index 08aae36584..6fafb77890 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -332,6 +332,83 @@ def test_func_schema_is_strict(): ) +def test_manual_function_tool_normalizes_typeless_object_schemas(): + async def run_function(ctx: ToolContext[Any], args: str) -> str: + return args + + tool = FunctionTool( + name="test", + description="Processes nested data", + params_json_schema={ + "properties": { + "config": {"properties": {"key": {"type": "string"}}}, + "optional": { + "type": ["object", "null"], + "properties": {"value": {"type": "integer"}}, + }, + } + }, + on_invoke_tool=run_function, + ) + + assert tool.strict_json_schema is True + assert tool.params_json_schema == { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "additionalProperties": False, + "required": ["key"], + }, + "optional": { + "type": ["object", "null"], + "properties": {"value": {"type": "integer"}}, + "additionalProperties": False, + "required": ["value"], + }, + }, + "additionalProperties": False, + "required": ["config", "optional"], + } + + +def test_manual_function_tool_rejects_root_union(): + async def run_function(ctx: ToolContext[Any], args: str) -> str: + return args + + with pytest.raises(UserError, match="root of a strict JSON schema"): + FunctionTool( + name="test", + description="Processes nullable data", + params_json_schema={ + "anyOf": [ + {"properties": {"value": {"type": "string"}}}, + {"type": "null"}, + ] + }, + on_invoke_tool=run_function, + ) + + +def test_manual_function_tool_rejects_nested_typeless_open_map(): + async def run_function(ctx: ToolContext[Any], args: str) -> str: + return args + + with pytest.raises(UserError, match="additionalProperties"): + FunctionTool( + name="test", + description="Processes metadata", + params_json_schema={ + "type": "object", + "properties": { + "metadata": {"additionalProperties": {"type": "string"}}, + }, + }, + on_invoke_tool=run_function, + ) + + @pytest.mark.asyncio async def test_manual_function_tool_creation_works(): def do_some_work(data: str) -> str: diff --git a/tests/test_handoff_tool.py b/tests/test_handoff_tool.py index 051c725c17..8ce53f6339 100644 --- a/tests/test_handoff_tool.py +++ b/tests/test_handoff_tool.py @@ -2,7 +2,7 @@ import inspect import json import logging -from typing import Any +from typing import Any, cast import pytest from openai.types.responses import ResponseOutputMessage, ResponseOutputText @@ -377,6 +377,17 @@ def test_handoff_input_schema_is_strict(): ), "Input schema should be strict and have additionalProperties=False" +def test_handoff_rejects_nullable_input_root(): + agent = Agent(name="test") + + with pytest.raises(UserError, match="root of a strict JSON schema"): + handoff( + agent, + input_type=cast(type[Any], Foo | None), + on_handoff=lambda ctx, input: None, + ) + + def test_get_transfer_message_is_valid_json() -> None: agent = Agent(name="foo") obj = handoff(agent) diff --git a/tests/test_strict_schema.py b/tests/test_strict_schema.py index b431fb39bb..43b6f57461 100644 --- a/tests/test_strict_schema.py +++ b/tests/test_strict_schema.py @@ -45,6 +45,105 @@ def test_object_without_additional_properties(): assert result["properties"]["a"] == {"type": "string"} +def test_typeless_root_is_normalized_to_object(): + result = ensure_strict_json_schema({"properties": {"a": {"type": "string"}}}) + + assert result == { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": False, + "required": ["a"], + } + + +def test_nullable_object_root_errors(): + with pytest.raises(UserError, match="root of a strict JSON schema"): + ensure_strict_json_schema( + {"type": ["object", "null"], "properties": {"a": {"type": "string"}}} + ) + + +def test_open_map_root_errors(): + with pytest.raises(UserError): + ensure_strict_json_schema({"additionalProperties": {"type": "string"}}) + + +def test_nested_typeless_open_map_errors(): + with pytest.raises(UserError): + ensure_strict_json_schema( + { + "type": "object", + "properties": { + "metadata": {"additionalProperties": {"type": "string"}}, + }, + } + ) + + +@pytest.mark.parametrize("union_keyword", ["anyOf", "oneOf"]) +def test_union_root_errors(union_keyword): + with pytest.raises(UserError, match="root of a strict JSON schema"): + ensure_strict_json_schema( + { + union_keyword: [ + {"properties": {"a": {"type": "string"}}}, + {"type": "null"}, + ] + } + ) + + +@pytest.mark.parametrize( + ("schema", "path"), + [ + ( + {"type": "object", "properties": {"config": {"properties": {}}}}, + ("properties", "config"), + ), + ( + {"type": "array", "items": {"properties": {}}}, + ("items",), + ), + ( + { + "type": "object", + "properties": {"value": {"anyOf": [{"properties": {}}, {"type": "null"}]}}, + }, + ("properties", "value", "anyOf", 0), + ), + ], + ids=["property", "array-item", "any-of"], +) +def test_nested_typeless_objects_get_additional_properties(schema, path): + node = ensure_strict_json_schema(schema) + for key in path: + node = node[key] + + assert node["type"] == "object" + assert node["additionalProperties"] is False + + +def test_nested_nullable_object_preserves_type_union(): + result = ensure_strict_json_schema( + { + "type": "object", + "properties": { + "config": { + "type": ["object", "null"], + "properties": {"key": {"type": "string"}}, + } + }, + } + ) + + assert result["properties"]["config"] == { + "type": ["object", "null"], + "properties": {"key": {"type": "string"}}, + "additionalProperties": False, + "required": ["key"], + } + + def test_object_with_true_additional_properties(): # If additionalProperties is explicitly set to True for an object, a UserError should be raised. schema = { @@ -56,6 +155,31 @@ def test_object_with_true_additional_properties(): ensure_strict_json_schema(schema) +def test_typeless_object_with_additional_properties_errors(): + schema = { + "properties": {"a": {"type": "number"}}, + "additionalProperties": True, + } + with pytest.raises(UserError): + ensure_strict_json_schema(schema) + + +def test_explicit_non_object_with_properties_is_not_closed(): + schema = { + "type": "string", + "properties": {}, + "required": [], + "additionalProperties": True, + } + + assert ensure_strict_json_schema(schema) == { + "type": "string", + "properties": {}, + "required": [], + "additionalProperties": True, + } + + def test_object_with_empty_dict_additional_properties(): # OpenAPI/MCP schemas commonly use ``additionalProperties: {}`` to mean "allow anything". # That empty mapping is falsy in Python, but it is still non-strict and must be rejected. @@ -106,20 +230,26 @@ def test_array_items_processing_and_default_removal(): def test_anyOf_processing(): # Test that anyOf schemas are processed. schema = { - "anyOf": [ - {"type": "object", "properties": {"a": {"type": "string"}}}, - {"type": "number", "default": None}, - ] + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "number", "default": None}, + ] + } + }, } result = ensure_strict_json_schema(schema) + variants = result["properties"]["value"]["anyOf"] # For the first variant: object type should get additionalProperties and required keys set. - variant0 = result["anyOf"][0] + variant0 = variants[0] assert variant0["type"] == "object" assert variant0["additionalProperties"] is False assert variant0["required"] == ["a"] # For the second variant: the "default": None should be removed. - variant1 = result["anyOf"][1] + variant1 = variants[1] assert variant1["type"] == "number" assert "default" not in variant1 @@ -140,6 +270,17 @@ def test_allOf_single_entry_merging(): assert result["properties"]["a"]["type"] == "boolean" +@pytest.mark.parametrize("additional_properties", [True, {}], ids=["true", "schema"]) +def test_allOf_single_entry_cannot_overwrite_strict_object(additional_properties): + schema = { + "properties": {"a": {"type": "string"}}, + "allOf": [{"additionalProperties": additional_properties}], + } + + with pytest.raises(UserError): + ensure_strict_json_schema(schema) + + def test_default_removal_on_non_object(): # Test that "default": None is stripped from schemas that are not objects. schema = {"type": "string", "default": None} diff --git a/tests/test_strict_schema_oneof.py b/tests/test_strict_schema_oneof.py index fffacc34fc..a63d89c7f4 100644 --- a/tests/test_strict_schema_oneof.py +++ b/tests/test_strict_schema_oneof.py @@ -135,26 +135,44 @@ class Actions(BaseModel): def test_oneof_merged_with_existing_anyof(): schema = { "type": "object", - "anyOf": [{"type": "string"}], - "oneOf": [{"type": "integer"}, {"type": "boolean"}], + "properties": { + "value": { + "anyOf": [{"type": "string"}], + "oneOf": [{"type": "integer"}, {"type": "boolean"}], + } + }, } result = ensure_strict_json_schema(schema) expected = { "type": "object", - "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "boolean"}], + "properties": { + "value": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "boolean"}, + ] + } + }, "additionalProperties": False, + "required": ["value"], } assert result == expected def test_discriminator_preserved(): schema = { - "oneOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}], - "discriminator": { - "propertyName": "type", - "mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"}, + "type": "object", + "properties": { + "value": { + "oneOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}], + "discriminator": { + "propertyName": "type", + "mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"}, + }, + } }, "$defs": { "TypeA": { @@ -171,10 +189,15 @@ def test_discriminator_preserved(): result = ensure_strict_json_schema(schema) expected = { - "anyOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}], - "discriminator": { - "propertyName": "type", - "mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"}, + "type": "object", + "properties": { + "value": { + "anyOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}], + "discriminator": { + "propertyName": "type", + "mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"}, + }, + } }, "$defs": { "TypeA": { @@ -190,6 +213,8 @@ def test_discriminator_preserved(): "required": ["type", "value_b"], }, }, + "additionalProperties": False, + "required": ["value"], } assert result == expected From 9f4292e5d8235fcec85ae5670a99c51aabd89281 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 13:54:49 +0900 Subject: [PATCH 119/473] fix: resolve agent tool name collisions consistently (#4137) --- src/agents/__init__.py | 2 + src/agents/_tool_identity.py | 126 ++++++++ src/agents/agent.py | 2 + src/agents/handoffs/__init__.py | 4 + src/agents/run.py | 18 +- src/agents/run_config.py | 14 + src/agents/run_internal/run_loop.py | 74 +++-- src/agents/tool.py | 5 + tests/test_agent_as_tool.py | 161 ++++++++++ tests/test_agent_runner.py | 357 +++++++++++++++++++++++ tests/test_agent_tracing.py | 60 ++++ tests/test_handoff_tool.py | 120 ++++++++ tests/test_run_config.py | 44 +++ tests/test_source_compat_constructors.py | 38 +++ tests/test_tracing_errors.py | 2 +- tests/test_tracing_errors_streamed.py | 2 +- 16 files changed, 989 insertions(+), 40 deletions(-) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 6c2def39e3..8eeb38b203 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -115,6 +115,7 @@ ToolErrorFormatter, ToolErrorFormatterArgs, ToolExecutionConfig, + ToolNameCollisionPolicy, ToolNotFoundBehavior, ) from .run_context import AgentHookContext, RunContextWrapper, TContext @@ -476,6 +477,7 @@ def enable_verbose_stdout_logging() -> None: "RunResultStreaming", "ResponsesWebSocketSession", "RunConfig", + "ToolNameCollisionPolicy", "ReasoningItemIdPolicy", "ToolExecutionConfig", "ToolErrorFormatter", diff --git a/src/agents/_tool_identity.py b/src/agents/_tool_identity.py index 1dae29a9fe..1a557a788c 100644 --- a/src/agents/_tool_identity.py +++ b/src/agents/_tool_identity.py @@ -5,7 +5,9 @@ from typing_extensions import Required, TypedDict +from . import _debug from .exceptions import UserError +from .logger import logger BareFunctionToolLookupKey = tuple[Literal["bare"], str] NamespacedFunctionToolLookupKey = tuple[Literal["namespaced"], str, str] @@ -320,6 +322,130 @@ def validate_function_tool_namespace_shape( ) +def _format_tool_name_collision_message( + lookup_key: BareFunctionToolLookupKey, + entries: Sequence[tuple[str, int, Any]], +) -> str: + """Build a detailed diagnostic for errors or unredacted warnings.""" + derived_owners: dict[str, str] = {} + for entry_type, _, entry in entries: + identity_attribute = ( + "_agent_tool_default_identity" if entry_type == "tool" else "_default_tool_identity" + ) + default_identity = getattr(entry, identity_attribute, None) + if not ( + isinstance(default_identity, tuple) + and len(default_identity) == 2 + and all(isinstance(value, str) for value in default_identity) + ): + continue + agent_name, derived_tool_name = default_identity + current_tool_name = ( + get_function_tool_public_name(entry) + if entry_type == "tool" + else getattr(entry, "tool_name", None) + ) + if current_tool_name == derived_tool_name: + override_parameter = "tool_name" if entry_type == "tool" else "tool_name_override" + derived_owners.setdefault(agent_name, override_parameter) + + if len(entries) == 2 and len(derived_owners) == 2: + ( + (prior_agent_name, prior_override_parameter), + (agent_name, override_parameter), + ) = list(derived_owners.items())[:2] + if override_parameter == prior_override_parameter == "tool_name": + configuration_type = "agent tool" + name_label = "tool name" + override_instruction = "`tool_name=`" + elif override_parameter == prior_override_parameter == "tool_name_override": + configuration_type = "handoff" + name_label = "handoff tool name" + override_instruction = "`tool_name_override=`" + else: + configuration_type = "agent routing" + name_label = "tool name" + override_instruction = "`tool_name=` or `tool_name_override=`" + return ( + f"Ambiguous {configuration_type} configuration: agents " + f"{prior_agent_name!r} and {agent_name!r} both derive the {name_label} " + f"`{lookup_key[1]}`. Pass an explicit {override_instruction} to one of them." + ) + + entry_types = {entry_type for entry_type, _, _ in entries} + if entry_types == {"tool"}: + return ( + "Ambiguous function tool configuration: the tool name " + f"`{lookup_key[1]}` is used by multiple tools. Assign a unique routed name " + "to every colliding function tool with `name_override=`, `tool_name=`, or " + "a namespace." + ) + if entry_types == {"handoff"}: + return ( + "Ambiguous handoff configuration: the handoff tool name " + f"`{lookup_key[1]}` is used by multiple handoffs. Pass a unique " + "`tool_name_override=` to each handoff." + ) + return ( + "Ambiguous tool routing configuration: the tool name " + f"`{lookup_key[1]}` is used by both a function tool and a handoff. " + "Assign a unique routed name to every colliding function tool and handoff " + "with `name_override=`, `tool_name=`, `tool_name_override=`, or a namespace." + ) + + +def resolve_tool_name_collisions( + tools: Sequence[Any], + handoffs: Sequence[Any] = (), + *, + collision_policy: Literal["warn", "error"], +) -> tuple[list[Any], list[Any]]: + """Resolve bare function-tool and handoff name collisions before model exposure.""" + validate_function_tool_lookup_configuration(tools) + + owners: dict[BareFunctionToolLookupKey, list[tuple[str, int, Any]]] = {} + for index, tool in enumerate(tools): + lookup_key = get_function_tool_lookup_key_for_tool(tool) + if lookup_key is not None and lookup_key[0] == "bare": + owners.setdefault(lookup_key, []).append(("tool", index, tool)) + + for index, handoff in enumerate(handoffs): + tool_name = getattr(handoff, "tool_name", None) + if isinstance(tool_name, str) and tool_name: + owners.setdefault(("bare", tool_name), []).append(("handoff", index, handoff)) + + retained_tool_indices = set(range(len(tools))) + retained_handoff_indices = set(range(len(handoffs))) + for lookup_key, entries in owners.items(): + if len(entries) < 2: + continue + + if collision_policy == "error": + raise UserError(_format_tool_name_collision_message(lookup_key, entries)) + if _debug.DONT_LOG_TOOL_DATA: + logger.warning( + "Tool name collision detected. Assign unique routed tool names or enable tool " + "data logging for details." + ) + else: + logger.warning("%s", _format_tool_name_collision_message(lookup_key, entries)) + + handoff_entries = [entry for entry in entries if entry[0] == "handoff"] + winner = handoff_entries[-1] if handoff_entries else entries[-1] + for entry_type, index, _ in entries: + if (entry_type, index) == (winner[0], winner[1]): + continue + if entry_type == "tool": + retained_tool_indices.discard(index) + else: + retained_handoff_indices.discard(index) + + return ( + [tool for index, tool in enumerate(tools) if index in retained_tool_indices], + [handoff for index, handoff in enumerate(handoffs) if index in retained_handoff_indices], + ) + + def validate_function_tool_lookup_configuration(tools: Sequence[Any]) -> None: """Reject function-tool combinations that are ambiguous on the Responses wire.""" qualified_name_owners: dict[str, Any] = {} diff --git a/src/agents/agent.py b/src/agents/agent.py index 73bfbc2cda..778312fdf8 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -985,6 +985,8 @@ async def dispatch_stream_events() -> None: ), ) run_agent_tool._is_agent_tool = True + if not tool_name: + run_agent_tool._agent_tool_default_identity = (self.name, tool_name_resolved) run_agent_tool._agent_instance = self return run_agent_tool diff --git a/src/agents/handoffs/__init__.py b/src/agents/handoffs/__init__.py index c1902e0b47..79d1841760 100644 --- a/src/agents/handoffs/__init__.py +++ b/src/agents/handoffs/__init__.py @@ -169,6 +169,9 @@ class Handoff(Generic[TContext, TAgent]): ) """Weak reference to the target agent when constructed via `handoff()`.""" + _default_tool_identity: tuple[str, str] | None = field(default=None, kw_only=True, repr=False) + """The target agent name and derived tool name when the default was used.""" + def get_transfer_message(self, agent: AgentBase[Any]) -> str: return json.dumps({"assistant": agent.name}) @@ -336,6 +339,7 @@ async def _is_enabled(ctx: RunContextWrapper[Any], agent_base: AgentBase[Any]) - nest_handoff_history=nest_handoff_history, agent_name=agent.name, is_enabled=_is_enabled if callable(is_enabled) else is_enabled, + _default_tool_identity=(agent.name, tool_name) if not tool_name_override else None, ) handoff_obj._agent_ref = weakref.ref(agent) return handoff_obj diff --git a/src/agents/run.py b/src/agents/run.py index 48ee148cb2..c4c507213d 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -8,7 +8,6 @@ from typing_extensions import Unpack from . import _debug -from ._tool_identity import get_tool_trace_name_for_tool from .agent import Agent from .agent_tool_state import set_agent_tool_state_scope from .exceptions import ( @@ -42,6 +41,7 @@ ToolErrorFormatter, ToolErrorFormatterArgs, ToolExecutionConfig, + ToolNameCollisionPolicy as ToolNameCollisionPolicy, ToolNotFoundBehavior, _coerce_run_config, ) @@ -87,7 +87,6 @@ from .run_internal.run_loop import ( cleanup_models_after_run, get_all_tools, - get_handoffs, get_output_schema, initialize_computer_tools, resolve_interrupted_turn, @@ -143,6 +142,7 @@ "ModelInputData", "CallModelData", "CallModelInputFilter", + "ToolNameCollisionPolicy", "ReasoningItemIdPolicy", "ToolExecutionConfig", "ToolErrorFormatter", @@ -1081,10 +1081,6 @@ def _finalize_result(result: RunResult) -> RunResult: ) if current_span is None: - handoff_names = [ - h.agent_name - for h in await get_handoffs(execution_agent, context_wrapper) - ] if output_schema := get_output_schema(execution_agent): output_type_name = output_schema.name() else: @@ -1092,15 +1088,11 @@ def _finalize_result(result: RunResult) -> RunResult: current_span = agent_span( name=current_agent.name, - handoffs=handoff_names, + handoffs=[], + tools=[], output_type=output_type_name, ) current_span.start(mark_as_current=True) - current_span.span_data.tools = [ - tool_name - for tool in all_tools - if (tool_name := get_tool_trace_name_for_tool(tool)) is not None - ] current_turn += 1 if max_turns is not None and current_turn > max_turns: @@ -1268,6 +1260,7 @@ def _finalize_result(result: RunResult) -> RunResult: reasoning_item_id_policy=resolved_reasoning_item_id_policy, prompt_cache_key_resolver=prompt_cache_key_resolver, error_handlers=error_handlers, + agent_span=current_span, ) ) @@ -1338,6 +1331,7 @@ def _finalize_result(result: RunResult) -> RunResult: reasoning_item_id_policy=resolved_reasoning_item_id_policy, prompt_cache_key_resolver=prompt_cache_key_resolver, error_handlers=error_handlers, + agent_span=current_span, ) finally: if current_turn_span: diff --git a/src/agents/run_config.py b/src/agents/run_config.py index 393e6dd039..986f62a969 100644 --- a/src/agents/run_config.py +++ b/src/agents/run_config.py @@ -74,6 +74,7 @@ class CallModelData(Generic[TContext]): CallModelInputFilter = Callable[[CallModelData[Any]], MaybeAwaitable[ModelInputData]] ReasoningItemIdPolicy = Literal["preserve", "omit"] ToolNotFoundBehavior = Literal["raise_error", "return_error_to_model"] +ToolNameCollisionPolicy = Literal["warn", "error"] @dataclass @@ -429,6 +430,15 @@ class RunConfig: the run continue. """ + tool_name_collision_policy: ToolNameCollisionPolicy = "warn" + """Controls collisions between function tool and handoff names. + + - ``"warn"`` logs an actionable warning and exposes only the current dispatch winner. + - ``"error"`` raises ``UserError`` before the model is called. + + Existing strict validation for namespaced and deferred-loading tools is unchanged. + """ + if TYPE_CHECKING: def __init__( @@ -456,9 +466,12 @@ def __init__( sandbox: SandboxRunConfig | dict[str, Any] | None = None, tool_execution: ToolExecutionConfig | dict[str, Any] | None = None, tool_not_found_behavior: ToolNotFoundBehavior = "raise_error", + tool_name_collision_policy: ToolNameCollisionPolicy = "warn", ) -> None: ... def __post_init__(self) -> None: + if self.tool_name_collision_policy not in ("warn", "error"): + raise ValueError("tool_name_collision_policy must be either 'warn' or 'error'") if self.model_settings is not None: self.model_settings = _coerce_model_settings( self.model_settings, @@ -526,6 +539,7 @@ def _coerce_run_config(value: RunConfig | dict[str, Any]) -> RunConfig: __all__ = [ "DEFAULT_MAX_TURNS", + "ToolNameCollisionPolicy", "CallModelData", "CallModelInputFilter", "ModelInputData", diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 5aa0e3a6bf..d9d8747184 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -28,6 +28,7 @@ build_function_tool_lookup_map, get_function_tool_lookup_key_for_call, get_tool_trace_name_for_tool, + resolve_tool_name_collisions, ) from ..agent import Agent from ..agent_output import AgentOutputSchemaBase @@ -990,9 +991,6 @@ async def _save_stream_items_without_count( ) if current_span is None: - handoff_names = [ - h.agent_name for h in await get_handoffs(execution_agent, context_wrapper) - ] if output_schema := get_output_schema(execution_agent): output_type_name = output_schema.name() else: @@ -1000,16 +998,11 @@ async def _save_stream_items_without_count( current_span = agent_span( name=current_agent.name, - handoffs=handoff_names, + handoffs=[], + tools=[], output_type=output_type_name, ) current_span.start(mark_as_current=True) - tool_names = [ - tool_name - for tool in all_tools - if (tool_name := get_tool_trace_name_for_tool(tool)) is not None - ] - current_span.span_data.tools = tool_names current_turn += 1 streamed_result.current_turn = current_turn @@ -1176,6 +1169,7 @@ async def _save_stream_items_without_count( reasoning_item_id_policy=resolved_reasoning_item_id_policy, prompt_cache_key_resolver=prompt_cache_key_resolver, error_handlers=error_handlers, + agent_span=current_span, ) finally: if current_turn_span: @@ -1422,6 +1416,7 @@ async def run_single_turn_streamed( reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, prompt_cache_key_resolver: PromptCacheKeyResolver | None = None, error_handlers: RunErrorHandlers[TContext] | None = None, + agent_span: Span[AgentSpanData] | None = None, ) -> SingleStepResult: """Run a single streamed turn and emit events as results arrive.""" public_agent = bindings.public_agent @@ -1447,21 +1442,6 @@ async def raise_if_input_guardrail_tripwire_known() -> None: emitted_tool_call_ids: set[str] = set() emitted_reasoning_item_ids: set[str] = set() emitted_tool_search_fingerprints: set[str] = set() - # Precompute the lookup map used for streaming descriptions. Function tools use the same - # collision-free lookup keys as runtime dispatch, including deferred top-level aliases. - tool_map: dict[NamedToolLookupKey, Any] = cast( - dict[NamedToolLookupKey, Any], - build_function_tool_lookup_map( - [tool for tool in all_tools if isinstance(tool, FunctionTool)] - ), - ) - for tool in all_tools: - tool_name = getattr(tool, "name", None) - if not isinstance(tool_name, str) or not tool_name: - continue - if isinstance(tool, FunctionTool): - continue - tool_map[tool_name] = tool def _tool_search_fingerprint(raw_item: Any) -> str: if isinstance(raw_item, Mapping): @@ -1508,6 +1488,34 @@ def _tool_search_fingerprint(raw_item: Any) -> str: ) handoffs = await get_handoffs(execution_agent, context_wrapper) + all_tools, handoffs = resolve_tool_name_collisions( + all_tools, + handoffs, + collision_policy=run_config.tool_name_collision_policy, + ) + if agent_span is not None: + agent_span.span_data.handoffs = [handoff.agent_name for handoff in handoffs] + agent_span.span_data.tools = [ + tool_name + for tool in all_tools + if (tool_name := get_tool_trace_name_for_tool(tool)) is not None + ] + + # Precompute the lookup map used for streaming descriptions. Function tools use the same + # collision-free lookup keys as runtime dispatch, including deferred top-level aliases. + tool_map: dict[NamedToolLookupKey, Any] = cast( + dict[NamedToolLookupKey, Any], + build_function_tool_lookup_map( + [tool for tool in all_tools if isinstance(tool, FunctionTool)] + ), + ) + for tool in all_tools: + tool_name = getattr(tool, "name", None) + if not isinstance(tool_name, str) or not tool_name: + continue + if isinstance(tool, FunctionTool): + continue + tool_map[tool_name] = tool model = get_model(execution_agent, run_config) tool_use_tracker.record_model(model) model_settings = get_model_settings(execution_agent, run_config) @@ -1886,6 +1894,7 @@ async def run_single_turn( reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, prompt_cache_key_resolver: PromptCacheKeyResolver | None = None, error_handlers: RunErrorHandlers[TContext] | None = None, + agent_span: Span[AgentSpanData] | None = None, ) -> SingleStepResult: """Run a single non-streaming turn of the agent loop.""" public_agent = bindings.public_agent @@ -1917,8 +1926,21 @@ async def run_single_turn( execution_agent.get_prompt(context_wrapper), ) - output_schema = get_output_schema(execution_agent) handoffs = await get_handoffs(execution_agent, context_wrapper) + all_tools, handoffs = resolve_tool_name_collisions( + all_tools, + handoffs, + collision_policy=run_config.tool_name_collision_policy, + ) + if agent_span is not None: + agent_span.span_data.handoffs = [handoff.agent_name for handoff in handoffs] + agent_span.span_data.tools = [ + tool_name + for tool in all_tools + if (tool_name := get_tool_trace_name_for_tool(tool)) is not None + ] + + output_schema = get_output_schema(execution_agent) if server_conversation_tracker is not None: input = server_conversation_tracker.prepare_input(original_input, generated_items) else: diff --git a/src/agents/tool.py b/src/agents/tool.py index 6e4ed2bf26..63ac29af91 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -546,6 +546,11 @@ class FunctionTool: _is_agent_tool: bool = field(default=False, kw_only=True, repr=False) """Internal flag indicating if this tool is an agent-as-tool.""" + _agent_tool_default_identity: tuple[str, str] | None = field( + default=None, kw_only=True, repr=False + ) + """The source agent name and derived tool name when the default was used.""" + _is_codex_tool: bool = field(default=False, kw_only=True, repr=False) """Internal flag indicating if this tool is a Codex tool wrapper.""" diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index a6fc37a411..ec2c4bbc20 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -34,8 +34,10 @@ ToolCallOutputItem, TResponseInputItem, Usage, + UserError, tool_namespace, ) +from agents._tool_identity import resolve_tool_name_collisions from agents.agent_tool_input import StructuredToolInputBuilderOptions from agents.agent_tool_state import ( get_agent_tool_state_scope, @@ -56,6 +58,165 @@ class BoolCtx(BaseModel): enable_tools: bool +@pytest.mark.asyncio +async def test_agent_as_tool_rejects_colliding_derived_names(): + refund = Agent(name="Refund") + normalized_refund = Agent(name="refund") + orchestrator = Agent( + name="orchestrator", + tools=[ + refund.as_tool(tool_name=None, tool_description="First refund agent"), + normalized_refund.as_tool(tool_name=None, tool_description="Second refund agent"), + ], + ) + + tools = await orchestrator.get_all_tools(RunContextWrapper(None)) + with pytest.raises(UserError) as exc_info: + resolve_tool_name_collisions(tools, collision_policy="error") + + assert str(exc_info.value) == ( + "Ambiguous agent tool configuration: agents 'Refund' and 'refund' both derive the tool " + "name `refund`. Pass an explicit `tool_name=` to one of them." + ) + + +@pytest.mark.asyncio +async def test_agent_as_tool_derived_name_collision_allows_explicit_override(): + refund = Agent(name="Refund") + normalized_refund = Agent(name="refund") + orchestrator = Agent( + name="orchestrator", + tools=[ + refund.as_tool(tool_name=None, tool_description="First refund agent"), + normalized_refund.as_tool( + tool_name="normalized_refund", + tool_description="Second refund agent", + ), + ], + ) + + tools = await orchestrator.get_all_tools(RunContextWrapper(None)) + + assert [tool.name for tool in tools] == ["refund", "normalized_refund"] + + +@pytest.mark.asyncio +async def test_agent_as_tool_rejects_distinct_agents_with_the_same_name(): + orchestrator = Agent( + name="orchestrator", + tools=[ + Agent(name="Refund").as_tool( + tool_name=None, + tool_description="First refund agent", + ), + Agent(name="Refund").as_tool( + tool_name=None, + tool_description="Second refund agent", + ), + ], + ) + + tools = await orchestrator.get_all_tools(RunContextWrapper(None)) + with pytest.raises(UserError, match="the tool name `refund` is used by multiple tools"): + resolve_tool_name_collisions(tools, collision_policy="error") + + +@pytest.mark.asyncio +async def test_agent_as_tool_warns_and_keeps_last_distinct_agent_with_the_same_name( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + first_tool = Agent(name="Refund").as_tool( + tool_name=None, + tool_description="First refund agent", + ) + second_tool = Agent(name="Refund").as_tool( + tool_name=None, + tool_description="Second refund agent", + ) + orchestrator = Agent(name="orchestrator", tools=[first_tool, second_tool]) + tools = await orchestrator.get_all_tools(RunContextWrapper(None)) + + with caplog.at_level("WARNING", logger="openai.agents"): + resolved_tools, _ = resolve_tool_name_collisions(tools, collision_policy="warn") + + assert resolved_tools == [second_tool] + assert caplog.messages == [ + "Ambiguous function tool configuration: the tool name `refund` is used by multiple " + "tools. Assign a unique routed name to every colliding function tool with " + "`name_override=`, `tool_name=`, or a namespace." + ] + + +@pytest.mark.asyncio +async def test_agent_as_tool_ignores_disabled_derived_name_collision(): + refund = Agent(name="Refund") + normalized_refund = Agent(name="refund") + orchestrator = Agent( + name="orchestrator", + tools=[ + refund.as_tool(tool_name=None, tool_description="First refund agent"), + normalized_refund.as_tool( + tool_name=None, + tool_description="Second refund agent", + is_enabled=False, + ), + ], + ) + + tools = await orchestrator.get_all_tools(RunContextWrapper(None)) + + assert [tool.name for tool in tools] == ["refund"] + + +@pytest.mark.asyncio +async def test_agent_as_tool_default_identity_tracks_the_current_tool_name(): + refund = Agent(name="Refund") + derived_tool = refund.as_tool(tool_name=None, tool_description="Refund agent") + copied_tool = dataclasses.replace(derived_tool) + renamed_tool = dataclasses.replace(derived_tool, name="renamed_refund") + normalized_refund = Agent(name="refund") + colliding_tool = normalized_refund.as_tool( + tool_name=None, + tool_description="Normalized refund agent", + ) + + copied_orchestrator = Agent(name="copied", tools=[copied_tool, colliding_tool]) + copied_tools = await copied_orchestrator.get_all_tools(RunContextWrapper(None)) + with pytest.raises(UserError, match="Ambiguous agent tool configuration"): + resolve_tool_name_collisions(copied_tools, collision_policy="error") + + renamed_orchestrator = Agent(name="renamed", tools=[renamed_tool, colliding_tool]) + tools = await renamed_orchestrator.get_all_tools(RunContextWrapper(None)) + resolve_tool_name_collisions(tools, collision_policy="error") + assert [tool.name for tool in tools] == ["renamed_refund", "refund"] + + +@pytest.mark.asyncio +async def test_agent_as_tool_derived_names_are_disambiguated_by_namespace(): + refund = Agent(name="Refund").as_tool(tool_name=None, tool_description="Sales refunds") + normalized_refund = Agent(name="refund").as_tool( + tool_name=None, + tool_description="Support refunds", + ) + sales_refund = tool_namespace(name="sales", description="Sales", tools=[refund])[0] + support_refund = tool_namespace( + name="support", + description="Support", + tools=[normalized_refund], + )[0] + orchestrator = Agent(name="orchestrator", tools=[sales_refund, support_refund]) + + tools = await orchestrator.get_all_tools(RunContextWrapper(None)) + + assert all(isinstance(tool, FunctionTool) for tool in tools) + assert [cast(FunctionTool, tool).qualified_name for tool in tools] == [ + "sales.refund", + "support.refund", + ] + + @pytest.mark.asyncio async def test_agent_as_tool_is_enabled_bool(): """Test that agent.as_tool() respects static boolean is_enabled parameter.""" diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index ab6485045d..ecb4c876d0 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -2,6 +2,7 @@ import asyncio import json +import logging import tempfile import warnings from collections.abc import Callable @@ -39,6 +40,7 @@ ToolExecutionConfig, ToolGuardrailFunctionOutput, ToolInputGuardrailData, + ToolNameCollisionPolicy, ToolTimeoutError, UserError, handoff, @@ -46,6 +48,7 @@ tool_input_guardrail, tool_namespace, ) +from agents._tool_identity import resolve_tool_name_collisions from agents.agent import ToolsToFinalOutputResult from agents.computer import Computer from agents.items import ( @@ -168,6 +171,360 @@ async def _run_agent_with_optional_streaming( return await Runner.run(agent, input=input, **kwargs) +@pytest.mark.parametrize("surface", ["agent_tool", "handoff", "mixed"]) +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("collision_policy", ["warn", "error"]) +@pytest.mark.asyncio +async def test_run_reports_derived_agent_name_collisions_before_model_call( + surface: str, + streamed: bool, + collision_policy: ToolNameCollisionPolicy, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + model = FakeModel(initial_output=[get_text_message("done")]) + billing = Agent(name="Billing Agent") + normalized_billing = Agent(name="billing agent") + if surface == "agent_tool": + agent = Agent( + name="triage", + model=model, + tools=[ + billing.as_tool(tool_name=None, tool_description="First billing agent"), + normalized_billing.as_tool( + tool_name=None, + tool_description="Second billing agent", + ), + ], + ) + elif surface == "handoff": + agent = Agent( + name="triage", + model=model, + handoffs=[billing, normalized_billing], + ) + else: + agent = Agent( + name="triage", + model=model, + tools=[ + Agent(name="transfer to Billing Agent").as_tool( + tool_name=None, + tool_description="Billing tool", + ) + ], + handoffs=[billing], + ) + + run_config = RunConfig(tool_name_collision_policy=collision_policy) + if collision_policy == "error": + with pytest.raises( + UserError, + match="Ambiguous (agent tool|handoff|agent routing) configuration", + ): + await _run_agent_with_optional_streaming( + agent, + input="Route this request", + streamed=streamed, + run_config=run_config, + ) + + assert model.first_turn_args is None + assert not model.last_turn_args + else: + with caplog.at_level("WARNING", logger="openai.agents"): + await _run_agent_with_optional_streaming( + agent, + input="Route this request", + streamed=streamed, + run_config=run_config, + ) + + assert model.first_turn_args is not None + collision_messages = [ + message for message in caplog.messages if message.startswith("Ambiguous ") + ] + assert len(collision_messages) == 1 + assert "Pass an explicit" in collision_messages[0] + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_run_warns_and_keeps_last_duplicate_function_tool( + streamed: bool, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + calls: list[str] = [] + + @function_tool(name_override="lookup") + def first_lookup() -> str: + calls.append("first") + return "first" + + @function_tool(name_override="lookup") + def second_lookup() -> str: + calls.append("second") + return "second" + + model = FakeModel(initial_output=[get_function_tool_call("lookup", "{}")]) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[first_lookup, second_lookup]) + + with caplog.at_level("WARNING", logger="openai.agents"): + await _run_agent_with_optional_streaming( + agent, + input="Look this up", + streamed=streamed, + ) + + assert calls == ["second"] + assert model.first_turn_args is not None + assert model.first_turn_args["tools"] == [second_lookup] + collision_messages = [ + message for message in caplog.messages if message.startswith("Ambiguous ") + ] + assert len(collision_messages) == 2 + assert all( + message + == ( + "Ambiguous function tool configuration: the tool name `lookup` is used by multiple " + "tools. Assign a unique routed name to every colliding function tool with " + "`name_override=`, `tool_name=`, or a namespace." + ) + for message in collision_messages + ) + + +def test_collision_warning_redacts_tool_data( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + secret_tool_name = "tenant_secret_tool_token" + + @function_tool(name_override=secret_tool_name) + def first_tool() -> str: + return "first" + + @function_tool(name_override=secret_tool_name) + def second_tool() -> str: + return "second" + + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + with caplog.at_level("WARNING", logger="openai.agents"): + resolved_tools, resolved_handoffs = resolve_tool_name_collisions( + [first_tool, second_tool], + collision_policy="warn", + ) + + assert resolved_tools == [second_tool] + assert resolved_handoffs == [] + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.msg == ( + "Tool name collision detected. Assign unique routed tool names or enable tool data " + "logging for details." + ) + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert all( + secret_tool_name not in value + for value in record.__dict__.values() + if isinstance(value, str) + ) + assert secret_tool_name not in logging.Formatter().format(record) + + +def test_collision_warning_preserves_tool_diagnostics_when_enabled( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + tool_name = "diagnostic_tool_name" + + @function_tool(name_override=tool_name) + def first_tool() -> str: + return "first" + + @function_tool(name_override=tool_name) + def second_tool() -> str: + return "second" + + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + with caplog.at_level("WARNING", logger="openai.agents"): + resolve_tool_name_collisions( + [first_tool, second_tool], + collision_policy="warn", + ) + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.msg == "%s" + assert isinstance(record.args, tuple) + assert len(record.args) == 1 + assert isinstance(record.args[0], str) + assert tool_name in record.args[0] + assert tool_name in logging.Formatter().format(record) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_run_rejects_duplicate_function_tools_in_error_mode(streamed: bool) -> None: + @function_tool(name_override="lookup") + def first_lookup() -> str: + return "first" + + @function_tool(name_override="lookup") + def second_lookup() -> str: + return "second" + + model = FakeModel(initial_output=[get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[first_lookup, second_lookup]) + + with pytest.raises( + UserError, + match="the tool name `lookup` is used by multiple tools", + ): + await _run_agent_with_optional_streaming( + agent, + input="Look this up", + streamed=streamed, + run_config=RunConfig(tool_name_collision_policy="error"), + ) + + assert model.first_turn_args is None + + +@pytest.mark.asyncio +async def test_run_warns_once_for_repeated_source_agent_name( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + model = FakeModel(initial_output=[get_text_message("done")]) + agent = Agent( + name="orchestrator", + model=model, + tools=[ + Agent(name="Refund").as_tool(tool_name=None, tool_description="First refund agent"), + Agent(name="refund").as_tool(tool_name=None, tool_description="Second refund agent"), + Agent(name="refund").as_tool(tool_name=None, tool_description="Third refund agent"), + ], + ) + + with caplog.at_level("WARNING", logger="openai.agents"): + await Runner.run(agent, "Route this request") + + collision_messages = [ + message for message in caplog.messages if message.startswith("Ambiguous ") + ] + assert collision_messages == [ + "Ambiguous function tool configuration: the tool name `refund` is used by multiple " + "tools. Assign a unique routed name to every colliding function tool with " + "`name_override=`, `tool_name=`, or a namespace." + ] + assert model.first_turn_args is not None + assert model.first_turn_args["tools"] == [agent.tools[-1]] + + +def test_multiway_mixed_collision_reports_every_owner_must_be_unique( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + + @function_tool(name_override="route") + def first_route() -> str: + return "first" + + @function_tool(name_override="route") + def second_route() -> str: + return "second" + + route_handoff = handoff(Agent(name="Billing"), tool_name_override="route") + + with caplog.at_level("WARNING", logger="openai.agents"): + resolved_tools, resolved_handoffs = resolve_tool_name_collisions( + [first_route, second_route], + [route_handoff], + collision_policy="warn", + ) + + assert resolved_tools == [] + assert resolved_handoffs == [route_handoff] + assert caplog.messages == [ + "Ambiguous tool routing configuration: the tool name `route` is used by both a function " + "tool and a handoff. Assign a unique routed name to every colliding function tool and " + "handoff with `name_override=`, `tool_name=`, `tool_name_override=`, or a namespace." + ] + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_handoff_enablement_uses_initialized_turn_context(streamed: bool) -> None: + model = FakeModel() + target = Agent(name="target", model=model) + model.add_multiple_turn_outputs( + [ + [get_handoff_tool_call(target)], + [get_text_message("done")], + ] + ) + observed_context: list[tuple[list[TResponseInputItem], dict[str, bool]]] = [] + + class InitializeContextHooks(RunHooks[dict[str, bool]]): + async def on_agent_start(self, context, agent) -> None: + if agent.name == "source": + context.context["hook_initialized"] = True + + def dynamic_prompt(data): + data.context.context["prompt_initialized"] = True + return {"id": "prompt-id"} + + def handoff_is_enabled(context: RunContextWrapper[dict[str, bool]], agent: Agent[Any]) -> bool: + observed_context.append((list(context.turn_input), dict(context.context))) + return ( + agent.name == "source" + and context.turn_input == [{"content": "current turn", "role": "user"}] + and context.context.get("hook_initialized") is True + and context.context.get("prompt_initialized") is True + ) + + source = Agent( + name="source", + model=model, + prompt=dynamic_prompt, + handoffs=[handoff(target, is_enabled=handoff_is_enabled)], + ) + hooks = InitializeContextHooks() + + if streamed: + result = Runner.run_streamed( + source, + "current turn", + context={}, + hooks=hooks, + ) + async for _ in result.stream_events(): + pass + else: + await Runner.run( + source, + "current turn", + context={}, + hooks=hooks, + ) + + assert observed_context == [ + ( + [{"content": "current turn", "role": "user"}], + {"hook_initialized": True, "prompt_initialized": True}, + ) + ] + + def test_set_default_agent_runner_roundtrip(): runner = AgentRunner() set_default_agent_runner(runner) diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py index 2b63a0ef41..c60477cf10 100644 --- a/tests/test_agent_tracing.py +++ b/tests/test_agent_tracing.py @@ -70,6 +70,66 @@ async def test_single_run_is_single_trace(): ) +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("surface", ["function_tool", "handoff", "mixed"]) +@pytest.mark.asyncio +async def test_agent_span_uses_resolved_tool_name_collision_view( + surface: str, + streamed: bool, +) -> None: + model = FakeModel(initial_output=[get_text_message("done")]) + expected_tools: list[str] + expected_handoffs: list[str] + + if surface == "function_tool": + + @function_tool(name_override="lookup") + def first_lookup() -> str: + return "first" + + @function_tool(name_override="lookup") + def second_lookup() -> str: + return "second" + + agent = Agent(name="test_agent", model=model, tools=[first_lookup, second_lookup]) + expected_tools = ["lookup"] + expected_handoffs = [] + elif surface == "handoff": + agent = Agent( + name="test_agent", + model=model, + handoffs=[Agent(name="Billing Agent"), Agent(name="billing agent")], + ) + expected_tools = [] + expected_handoffs = ["billing agent"] + else: + agent = Agent( + name="test_agent", + model=model, + tools=[ + Agent(name="transfer to Billing Agent").as_tool( + tool_name=None, + tool_description="Billing tool", + ) + ], + handoffs=[Agent(name="Billing Agent")], + ) + expected_tools = [] + expected_handoffs = ["Billing Agent"] + + if streamed: + result = Runner.run_streamed(agent, input="test") + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, input="test") + + agent_spans = [span for span in fetch_ordered_spans() if span.span_data.type == "agent"] + assert len(agent_spans) == 1 + assert agent_spans[0].span_data.tools == expected_tools + assert agent_spans[0].span_data.handoffs == expected_handoffs + + @pytest.mark.asyncio async def test_task_and_turn_spans_export_aggregate_usage(): @function_tool diff --git a/tests/test_handoff_tool.py b/tests/test_handoff_tool.py index 8ce53f6339..3d69760dc4 100644 --- a/tests/test_handoff_tool.py +++ b/tests/test_handoff_tool.py @@ -1,4 +1,5 @@ import asyncio +import dataclasses import inspect import json import logging @@ -8,6 +9,7 @@ from openai.types.responses import ResponseOutputMessage, ResponseOutputText from pydantic import BaseModel +import agents._debug as _debug from agents import ( Agent, Handoff, @@ -18,6 +20,7 @@ UserError, handoff, ) +from agents._tool_identity import resolve_tool_name_collisions from agents.run_internal.run_loop import get_handoffs @@ -83,6 +86,123 @@ async def test_multiple_handoffs_setup(): assert handoff_objects[1].agent_name == agent_2.name +@pytest.mark.asyncio +async def test_handoffs_reject_colliding_derived_names(): + billing = Agent(name="Billing Agent") + normalized_billing = Agent(name="billing agent") + triage = Agent(name="triage", handoffs=[billing, normalized_billing]) + + handoffs = await get_handoffs(triage, RunContextWrapper(None)) + with pytest.raises(UserError) as exc_info: + resolve_tool_name_collisions((), handoffs, collision_policy="error") + + assert str(exc_info.value) == ( + "Ambiguous handoff configuration: agents 'Billing Agent' and 'billing agent' both derive " + "the handoff tool name `transfer_to_billing_agent`. Pass an explicit " + "`tool_name_override=` to one of them." + ) + + +@pytest.mark.asyncio +async def test_handoff_derived_name_collision_allows_explicit_override(): + billing = Agent(name="Billing Agent") + normalized_billing = Agent(name="billing agent") + triage = Agent( + name="triage", + handoffs=[ + billing, + handoff(normalized_billing, tool_name_override="transfer_to_normalized_billing"), + ], + ) + + handoffs = await get_handoffs(triage, RunContextWrapper(None)) + + assert [item.tool_name for item in handoffs] == [ + "transfer_to_billing_agent", + "transfer_to_normalized_billing", + ] + + +@pytest.mark.asyncio +async def test_handoff_rejects_distinct_agents_with_the_same_name(): + triage = Agent( + name="triage", + handoffs=[Agent(name="Billing"), Agent(name="Billing")], + ) + + handoffs = await get_handoffs(triage, RunContextWrapper(None)) + with pytest.raises( + UserError, + match="handoff tool name `transfer_to_billing` is used by multiple handoffs", + ): + resolve_tool_name_collisions((), handoffs, collision_policy="error") + + +@pytest.mark.asyncio +async def test_handoff_warns_and_keeps_last_distinct_agent_with_the_same_name( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + triage = Agent( + name="triage", + handoffs=[Agent(name="Billing"), Agent(name="Billing")], + ) + handoffs = await get_handoffs(triage, RunContextWrapper(None)) + + with caplog.at_level("WARNING", logger="openai.agents"): + _, resolved_handoffs = resolve_tool_name_collisions( + (), + handoffs, + collision_policy="warn", + ) + + assert resolved_handoffs == [handoffs[1]] + assert any( + "handoff tool name `transfer_to_billing` is used by multiple handoffs" in message + for message in caplog.messages + ) + + +@pytest.mark.asyncio +async def test_handoffs_ignore_disabled_derived_name_collision(): + billing = Agent(name="Billing Agent") + normalized_billing = Agent(name="billing agent") + triage = Agent( + name="triage", + handoffs=[billing, handoff(normalized_billing, is_enabled=False)], + ) + + handoffs = await get_handoffs(triage, RunContextWrapper(None)) + + assert [item.agent_name for item in handoffs] == ["Billing Agent"] + + +@pytest.mark.asyncio +async def test_handoff_default_identity_tracks_the_current_tool_name(): + billing = Agent(name="Billing Agent") + derived_handoff = handoff(billing) + copied_handoff = dataclasses.replace(derived_handoff) + renamed_handoff = dataclasses.replace( + derived_handoff, + tool_name="transfer_to_primary_billing", + ) + normalized_billing = Agent(name="billing agent") + + copied_triage = Agent(name="copied", handoffs=[copied_handoff, normalized_billing]) + copied_handoffs = await get_handoffs(copied_triage, RunContextWrapper(None)) + with pytest.raises(UserError, match="Ambiguous handoff configuration"): + resolve_tool_name_collisions((), copied_handoffs, collision_policy="error") + + renamed_triage = Agent(name="renamed", handoffs=[renamed_handoff, normalized_billing]) + handoffs = await get_handoffs(renamed_triage, RunContextWrapper(None)) + resolve_tool_name_collisions((), handoffs, collision_policy="error") + assert [item.tool_name for item in handoffs] == [ + "transfer_to_primary_billing", + "transfer_to_billing_agent", + ] + + def test_default_handoff_tool_name_allows_whitespace_without_warning( caplog: pytest.LogCaptureFixture, ): diff --git a/tests/test_run_config.py b/tests/test_run_config.py index 7b99b649f2..5c6fbeffa7 100644 --- a/tests/test_run_config.py +++ b/tests/test_run_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any, cast + import pytest from agents import ( @@ -8,10 +10,12 @@ Runner, SessionSettings, ToolExecutionConfig, + ToolNameCollisionPolicy, ToolNotFoundBehavior, ) from agents.model_settings import ModelSettings from agents.models.interface import Model, ModelProvider +from agents.run import __all__ as run_exports from agents.run_config import SandboxConcurrencyLimits, SandboxRunConfig from agents.sandbox.manifest import Manifest from agents.sandbox.snapshot import NoopSnapshotSpec @@ -322,3 +326,43 @@ def test_tool_not_found_behavior_is_public_from_agents_package() -> None: config = RunConfig(tool_not_found_behavior=behavior) assert config.tool_not_found_behavior == "return_error_to_model" + + +def test_tool_name_collision_policy_defaults_to_warn() -> None: + config = RunConfig() + + assert config.tool_name_collision_policy == "warn" + + +def test_tool_name_collision_policy_is_public_from_agents_package() -> None: + policy: ToolNameCollisionPolicy = "error" + config = RunConfig(tool_name_collision_policy=policy) + + assert config.tool_name_collision_policy == "error" + assert "ToolNameCollisionPolicy" in run_exports + + +def test_tool_name_collision_policy_rejects_invalid_value() -> None: + with pytest.raises( + ValueError, + match="tool_name_collision_policy must be either 'warn' or 'error'", + ): + RunConfig(tool_name_collision_policy=cast(Any, "erorr")) + + +@pytest.mark.asyncio +async def test_runner_dictionary_rejects_invalid_tool_name_collision_policy() -> None: + model = FakeModel(initial_output=[get_text_message("done")]) + agent = Agent(name="test", model=model) + + with pytest.raises( + ValueError, + match="tool_name_collision_policy must be either 'warn' or 'error'", + ): + await Runner.run( + agent, + "hello", + run_config={"tool_name_collision_policy": cast(Any, "erorr")}, + ) + + assert model.first_turn_args is None diff --git a/tests/test_source_compat_constructors.py b/tests/test_source_compat_constructors.py index aadeb3994c..9e9aae6e34 100644 --- a/tests/test_source_compat_constructors.py +++ b/tests/test_source_compat_constructors.py @@ -169,6 +169,44 @@ def test_run_config_tool_not_found_behavior_append_preserves_tool_execution_posi assert config.tool_not_found_behavior == "return_error_to_model" +def test_run_config_tool_name_collision_policy_append_preserves_prior_positions() -> None: + session_settings = SessionSettings(limit=123) + tool_execution = ToolExecutionConfig(max_function_tool_concurrency=2) + config = RunConfig( + None, + MultiProvider(), + None, + None, + False, + None, + None, + None, + False, + None, + True, + "Agent workflow", + None, + None, + None, + None, + None, + None, + session_settings, + "omit", + None, + tool_execution, + "return_error_to_model", + "error", + ) + + assert config.session_settings == session_settings + assert config.reasoning_item_id_policy == "omit" + assert config.sandbox is None + assert config.tool_execution is tool_execution + assert config.tool_not_found_behavior == "return_error_to_model" + assert config.tool_name_collision_policy == "error" + + def test_tool_execution_config_pre_approval_append_preserves_max_concurrency() -> None: config = ToolExecutionConfig(2, True) diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index f16841e654..cc61bd2825 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -249,7 +249,7 @@ async def test_multiple_handoff_doesnt_error(): "type": "agent", "data": { "name": "test", - "handoffs": ["test", "test"], + "handoffs": ["test"], "tools": ["some_function"], "output_type": "str", }, diff --git a/tests/test_tracing_errors_streamed.py b/tests/test_tracing_errors_streamed.py index cd20989660..69e65fdadb 100644 --- a/tests/test_tracing_errors_streamed.py +++ b/tests/test_tracing_errors_streamed.py @@ -314,7 +314,7 @@ async def test_multiple_handoff_doesnt_error(): "type": "agent", "data": { "name": "test", - "handoffs": ["test", "test"], + "handoffs": ["test"], "tools": ["some_function"], "output_type": "str", }, From 306ac1974576101653e75b117b07bf484832b1f9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 14:59:55 +0900 Subject: [PATCH 120/473] fix(sandbox): harden default snapshot path resolution (#4141) Co-authored-by: Gautam Sharma <148205237+GautamSharma99@users.noreply.github.com> --- src/agents/sandbox/snapshot_defaults.py | 10 ++++-- tests/sandbox/test_snapshot_defaults.py | 48 +++++++++++++++++++++---- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/agents/sandbox/snapshot_defaults.py b/src/agents/sandbox/snapshot_defaults.py index 1a54a14f72..4391116ff2 100644 --- a/src/agents/sandbox/snapshot_defaults.py +++ b/src/agents/sandbox/snapshot_defaults.py @@ -4,7 +4,7 @@ import sys import time from collections.abc import Mapping -from pathlib import Path, PureWindowsPath +from pathlib import Path, PurePosixPath, PureWindowsPath from .snapshot import LocalSnapshotSpec @@ -30,7 +30,7 @@ def default_local_snapshot_base_dir( os_name: str | None = None, ) -> Path: resolved_home = home or Path.home() - resolved_env = env or os.environ + resolved_env = os.environ if env is None else env resolved_platform = platform or sys.platform resolved_os_name = os_name or os.name @@ -45,7 +45,11 @@ def default_local_snapshot_base_dir( base = env_base if env_base is not None else resolved_home / "AppData" / "Local" else: xdg_state_home = resolved_env.get("XDG_STATE_HOME") - base = Path(xdg_state_home) if xdg_state_home else resolved_home / ".local" / "state" + base = ( + Path(xdg_state_home) + if xdg_state_home and PurePosixPath(xdg_state_home).is_absolute() + else resolved_home / ".local" / "state" + ) return base / _DEFAULT_LOCAL_SNAPSHOT_SUBDIR diff --git a/tests/sandbox/test_snapshot_defaults.py b/tests/sandbox/test_snapshot_defaults.py index 2c34be69a7..5e7009ebfe 100644 --- a/tests/sandbox/test_snapshot_defaults.py +++ b/tests/sandbox/test_snapshot_defaults.py @@ -3,6 +3,8 @@ import os from pathlib import Path +import pytest + from agents.sandbox.snapshot import LocalSnapshotSpec from agents.sandbox.snapshot_defaults import ( _DEFAULT_LOCAL_SNAPSHOT_TTL_SECONDS, @@ -13,15 +15,47 @@ def test_default_local_snapshot_base_dir_uses_xdg_state_home(tmp_path: Path) -> None: - state_home = tmp_path / "state" + state_home = "/state" result = default_local_snapshot_base_dir( home=tmp_path / "home", - env={"XDG_STATE_HOME": str(state_home)}, + env={"XDG_STATE_HOME": state_home}, + platform="linux", + os_name="posix", + ) + + assert result == Path(state_home) / "openai-agents-python" / "sandbox" / "snapshots" + + +def test_default_local_snapshot_base_dir_honors_explicit_empty_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = tmp_path / "home" + monkeypatch.setenv("XDG_STATE_HOME", "/ambient/state") + + result = default_local_snapshot_base_dir( + home=home, + env={}, + platform="linux", + os_name="posix", + ) + + assert result == home / ".local" / "state" / "openai-agents-python" / "sandbox" / "snapshots" + + +def test_default_local_snapshot_base_dir_ignores_relative_xdg_state_home( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + + result = default_local_snapshot_base_dir( + home=home, + env={"XDG_STATE_HOME": "relative-state"}, platform="linux", os_name="posix", ) - assert result == state_home / "openai-agents-python" / "sandbox" / "snapshots" + assert result == home / ".local" / "state" / "openai-agents-python" / "sandbox" / "snapshots" def test_default_local_snapshot_base_dir_uses_macos_application_support(tmp_path: Path) -> None: @@ -124,8 +158,8 @@ def test_cleanup_stale_default_local_snapshots_removes_only_old_tar_files(tmp_pa def test_resolve_default_local_snapshot_spec_keeps_existing_stale_files( tmp_path: Path, ) -> None: - state_home = tmp_path / "state" - managed_dir = state_home / "openai-agents-python" / "sandbox" / "snapshots" + home = tmp_path / "home" + managed_dir = home / ".local" / "state" / "openai-agents-python" / "sandbox" / "snapshots" managed_dir.mkdir(parents=True) stale = managed_dir / "stale.tar" stale.write_bytes(b"stale") @@ -134,8 +168,8 @@ def test_resolve_default_local_snapshot_spec_keeps_existing_stale_files( os.utime(stale, (stale_mtime, stale_mtime)) spec = resolve_default_local_snapshot_spec( - home=tmp_path / "home", - env={"XDG_STATE_HOME": str(state_home)}, + home=home, + env={}, platform="linux", os_name="posix", now=now, From bdc294fcd446718060b3444c2894c8b3d39fae56 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 3 Aug 2026 01:23:54 -0500 Subject: [PATCH 121/473] fix(run): keep input item order when collapsing duplicates (#4140) --- src/agents/run_internal/items.py | 24 ++++++-- tests/test_agent_runner.py | 43 +++++++++++++ tests/test_call_model_input_filter.py | 87 +++++++++++++++++++++++++++ tests/test_run_internal_items.py | 66 ++++++++++++++++++++ 4 files changed, 216 insertions(+), 4 deletions(-) diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index bc2f623d3c..ab0277f080 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -728,10 +728,26 @@ def deduplicate_input_items(items: Sequence[TResponseInputItem]) -> list[TRespon def deduplicate_input_items_preferring_latest( items: Sequence[TResponseInputItem], ) -> list[TResponseInputItem]: - """Deduplicate by stable identifiers while keeping the latest occurrence.""" - # deduplicate_input_items keeps the first item per dedupe key. Reverse twice so that - # the latest item in the original order wins for duplicate IDs/call_ids. - return list(reversed(deduplicate_input_items(list(reversed(items))))) + """Deduplicate by stable identifiers, keeping the latest value at the earliest position. + + Duplicates collapse onto the first occurrence of their dedupe key so the caller's item + order is preserved, while the last occurrence supplies the value. Relocating an item to + the position of its final duplicate would move a `function_call` behind its + `function_call_output`, which the Responses API rejects. + """ + latest_by_key: dict[str, TResponseInputItem] = {} + for item in items: + dedupe_key = _dedupe_key(item) + if dedupe_key is not None: + latest_by_key[dedupe_key] = item + + # deduplicate_input_items keeps the first item per dedupe key, which is what preserves the + # caller's order; swapping in the latest value per surviving key is all this adds. + deduplicated: list[TResponseInputItem] = [] + for item in deduplicate_input_items(items): + dedupe_key = _dedupe_key(item) + deduplicated.append(item if dedupe_key is None else latest_by_key[dedupe_key]) + return deduplicated def function_tool_error_output( diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index ecb4c876d0..0895694c60 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -3125,6 +3125,49 @@ async def test_save_result_to_session_prefers_latest_duplicate_function_outputs( assert duplicates[0]["output"] == "new-output" +@pytest.mark.asyncio +async def test_save_result_to_session_keeps_tool_call_before_its_output(): + session = SimpleListSession() + call_item = cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "call_ordered", + "name": "tool_ordered", + "arguments": "{}", + }, + ) + output_item = cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call_ordered", "output": "result"}, + ) + # A resumed turn can replay a tool call the input list already carries. Collapsing the + # duplicate must not move the call behind its output in the persisted history. + repeated_call = _DummyRunItem( + { + "type": "function_call", + "call_id": "call_ordered", + "name": "tool_ordered", + "arguments": "{}", + }, + item_type="tool_call_item", + ) + + await save_result_to_session( + session, + [call_item, output_item], + [cast(RunItem, repeated_call)], + None, + ) + + saved_types = [ + cast(dict[str, Any], item).get("type") + for item in session.saved_items + if isinstance(item, dict) + ] + assert saved_types == ["function_call", "function_call_output"] + + @pytest.mark.asyncio async def test_rewind_handles_id_stripped_sessions() -> None: session = IdStrippingSession() diff --git a/tests/test_call_model_input_filter.py b/tests/test_call_model_input_filter.py index 47de67f3b6..152cf6939f 100644 --- a/tests/test_call_model_input_filter.py +++ b/tests/test_call_model_input_filter.py @@ -203,3 +203,90 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: ] assert len(outputs) == 1 assert outputs[0]["output"] == "new-value" + + +def _duplicate_tool_call_input() -> list[TResponseInputItem]: + return [ + cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "ordered-call", + "name": "tool_ordered", + "arguments": "{}", + }, + ), + cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "ordered-call", "output": "result"}, + ), + cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "ordered-call", + "name": "tool_ordered", + "arguments": "{}", + }, + ), + ] + + +def _sent_item_types(sent_input: Any) -> list[str | None]: + return [ + cast(dict[str, Any], item).get("type") + for item in sent_input + if isinstance(item, dict) and item.get("type") is not None + ] + + +@pytest.mark.asyncio +async def test_call_model_input_filter_keeps_duplicate_item_order_non_streamed() -> None: + model = FakeModel() + agent = Agent(name="test", model=model) + model.set_next_output([get_text_message("ok")]) + + def filter_fn(data: CallModelData[Any]) -> ModelInputData: + return ModelInputData( + input=list(data.model_data.input) + _duplicate_tool_call_input(), + instructions=data.model_data.instructions, + ) + + await Runner.run( + agent, + input="start", + run_config=RunConfig(call_model_input_filter=filter_fn), + ) + + # Collapsing the repeated call must not move it behind its output; the Responses API + # rejects a function_call_output whose function_call has not been sent yet. + assert _sent_item_types(model.last_turn_args["input"]) == [ + "function_call", + "function_call_output", + ] + + +@pytest.mark.asyncio +async def test_call_model_input_filter_keeps_duplicate_item_order_streamed() -> None: + model = FakeModel() + agent = Agent(name="test", model=model) + model.set_next_output([get_text_message("ok")]) + + async def filter_fn(data: CallModelData[Any]) -> ModelInputData: + return ModelInputData( + input=list(data.model_data.input) + _duplicate_tool_call_input(), + instructions=data.model_data.instructions, + ) + + result = Runner.run_streamed( + agent, + input="start", + run_config=RunConfig(call_model_input_filter=filter_fn), + ) + async for _ in result.stream_events(): + pass + + assert _sent_item_types(model.last_turn_args["input"]) == [ + "function_call", + "function_call_output", + ] diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index d58830092c..02e5ee99bc 100644 --- a/tests/test_run_internal_items.py +++ b/tests/test_run_internal_items.py @@ -1001,3 +1001,69 @@ def test_run_result_to_input_list_preserves_tool_search_items() -> None: def test_coerce_tool_search_output_raw_item_rejects_legacy_type() -> None: with pytest.raises(AgentsException, match="Unexpected tool search output item type"): coerce_tool_search_output_raw_item({"type": "tool_search_result", "results": []}) + + +def test_deduplicate_input_items_preferring_latest_keeps_original_order() -> None: + call = cast( + TResponseInputItem, + {"type": "function_call", "call_id": "call-1", "name": "tool", "arguments": "{}"}, + ) + output = cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call-1", "output": "result"}, + ) + message = cast(TResponseInputItem, {"role": "assistant", "content": "ack"}) + repeated_call = cast( + TResponseInputItem, + {"type": "function_call", "call_id": "call-1", "name": "tool", "arguments": "{}"}, + ) + + deduplicated = run_items.deduplicate_input_items_preferring_latest( + [call, output, message, repeated_call] + ) + + # The repeated call collapses onto the first occurrence, so the call still precedes its + # output. Relocating it to the end would produce an item order the Responses API rejects. + assert [cast(dict[str, Any], item).get("type") for item in deduplicated] == [ + "function_call", + "function_call_output", + None, + ] + assert cast(dict[str, Any], deduplicated[2])["role"] == "assistant" + + +def test_deduplicate_input_items_preferring_latest_uses_latest_value_at_first_position() -> None: + old_output = cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call-1", "output": "old"}, + ) + message = cast(TResponseInputItem, {"role": "user", "content": "next"}) + new_output = cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call-1", "output": "new"}, + ) + + deduplicated = run_items.deduplicate_input_items_preferring_latest( + [old_output, message, new_output] + ) + + assert len(deduplicated) == 2 + assert cast(dict[str, Any], deduplicated[0])["output"] == "new" + assert cast(dict[str, Any], deduplicated[1])["content"] == "next" + + +def test_deduplicate_input_items_preferring_latest_leaves_unique_items_untouched() -> None: + items = [ + cast(TResponseInputItem, {"role": "user", "content": "hi"}), + cast( + TResponseInputItem, + {"type": "function_call", "call_id": "call-1", "name": "tool", "arguments": "{}"}, + ), + cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call-1", "output": "result"}, + ), + cast(TResponseInputItem, {"role": "user", "content": "hi"}), + ] + + assert run_items.deduplicate_input_items_preferring_latest(items) == items From 9af785b110bb3a90bedcd5b527d92b47e0b5c4b2 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 3 Aug 2026 03:36:12 -0500 Subject: [PATCH 122/473] fix(run): stop emitting handoff calls as streamed tool_called events (#4146) --- src/agents/run_internal/run_loop.py | 8 +- src/agents/run_internal/turn_resolution.py | 15 +++- tests/test_agent_runner_streamed.py | 8 +- tests/test_stream_events.py | 96 ++++++++++++++++++++++ 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index d9d8747184..cacb003418 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -203,6 +203,7 @@ execute_handoffs, execute_tools_and_side_effects, get_single_step_result_from_response, + is_handoff_tool_call, process_model_response, resolve_interrupted_turn, run_final_output_hooks, @@ -1516,6 +1517,7 @@ def _tool_search_fingerprint(raw_item: Any) -> str: if isinstance(tool, FunctionTool): continue tool_map[tool_name] = tool + handoff_tool_names = {handoff.tool_name for handoff in handoffs} model = get_model(execution_agent, run_config) tool_use_tracker.record_model(model) model_settings = get_model_settings(execution_agent, run_config) @@ -1730,7 +1732,11 @@ async def rewind_model_request() -> None: elif isinstance(output_item, McpListTools): hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata([output_item])) - elif isinstance(output_item, TOOL_CALL_TYPES): + elif isinstance(output_item, TOOL_CALL_TYPES) and not is_handoff_tool_call( + output_item, handoff_tool_names + ): + # Handoff calls are streamed as `handoff_requested` once the turn is processed, + # so emitting them here too would duplicate the item under a second event name. output_call_id: str | None = getattr( output_item, "call_id", getattr(output_item, "id", None) ) diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index e55436c295..40563d62e0 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -2,7 +2,7 @@ import asyncio import inspect -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Container, Mapping, Sequence from typing import Any, Literal, cast from openai.types.responses import ( @@ -174,6 +174,7 @@ "execute_final_output", "execute_handoffs", "check_for_final_output_from_tools", + "is_handoff_tool_call", "process_model_response", "execute_tools_and_side_effects", "resolve_interrupted_turn", @@ -182,6 +183,16 @@ ] +def is_handoff_tool_call(output: Any, handoff_tool_names: Container[str]) -> bool: + """Return whether a model output item routes to a handoff instead of a tool. + + Namespaced calls never resolve to a handoff, so only bare names are matched. + """ + if not isinstance(output, ResponseFunctionToolCall): + return False + return get_tool_call_qualified_name(output) == output.name and output.name in handoff_tool_names + + async def _maybe_finalize_from_tool_results( *, public_agent: Agent[TContext], @@ -2339,7 +2350,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tools_used.append(get_tool_call_trace_name(output) or output.name) qualified_output_name = get_tool_call_qualified_name(output) - if qualified_output_name == output.name and output.name in handoff_map: + if is_handoff_tool_call(output, handoff_map): ensure_tool_caller_allowed( tool_call=output, allowed_callers=None, diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 7cc04fa741..78cdce91c4 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -1825,11 +1825,11 @@ async def test_streaming_events(): # Now lets check the events expected_item_type_map = { - # 3 tool_call_item events: + # 2 tool_call_item events: # 1. get_function_tool_call("foo", ...) - # 2. get_handoff_tool_call(agent_1) because handoffs are implemented via tool calls too - # 3. get_function_tool_call("bar", ...) - "tool_call": 3, + # 2. get_function_tool_call("bar", ...) + # get_handoff_tool_call(agent_1) is only reported as a handoff_call_item. + "tool_call": 2, # Only 2 outputs, handoff tool call doesn't have corresponding tool_call_output event "tool_call_output": 2, "message": 2, # get_text_message("a_message") + get_final_output_message(...) diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index 741449af71..453a66ea30 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -543,3 +543,99 @@ async def test_stream_events_emit_tool_search_items() -> None: name == "tool_search_output_created" and isinstance(item, ToolSearchOutputItem) for name, item in seen_events ) + + +@pytest.mark.asyncio +async def test_streamed_handoff_call_is_not_emitted_as_tool_called(): + """A handoff call streams only as `handoff_requested`, never also as `tool_called`.""" + english_agent = Agent(name="EnglishAgent", model=FakeModel()) + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_handoff_tool_call(english_agent)], + [get_text_message("Done")], + ] + ) + triage_agent = Agent(name="TriageAgent", handoffs=[english_agent], model=model) + + result = Runner.run_streamed(triage_agent, input="Start") + + item_events = [ + (event.name, event.item) + async for event in result.stream_events() + if event.type == "run_item_stream_event" + ] + + handoff_events = [ + (name, item) for name, item in item_events if isinstance(item, HandoffCallItem) + ] + assert len(handoff_events) == 1 + assert handoff_events[0][0] == "handoff_requested" + + assert [name for name, _ in item_events if name == "tool_called"] == [] + assert not any(isinstance(item, ToolCallItem) for _, item in item_events) + + +@pytest.mark.asyncio +async def test_streamed_tool_call_alongside_handoff_still_emits_tool_called(): + """A real tool call in the same turn as a handoff keeps its `tool_called` event.""" + english_agent = Agent(name="EnglishAgent", model=FakeModel()) + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call("foo", '{"a": "b"}', call_id="tool_call"), + get_handoff_tool_call(english_agent), + ], + [get_text_message("Done")], + ] + ) + triage_agent = Agent( + name="TriageAgent", + handoffs=[handoff(english_agent, input_filter=remove_all_tools)], + tools=[foo], + model=model, + ) + + result = Runner.run_streamed(triage_agent, input="Start") + + item_events = [ + (event.name, event.item) + async for event in result.stream_events() + if event.type == "run_item_stream_event" + ] + + tool_called_items = [item for name, item in item_events if name == "tool_called"] + assert len(tool_called_items) == 1 + assert cast(ToolCallItem, tool_called_items[0]).call_id == "tool_call" + + assert [name for name, item in item_events if isinstance(item, HandoffCallItem)] == [ + "handoff_requested" + ] + + +@pytest.mark.asyncio +async def test_streamed_handoff_item_events_match_new_items(): + """Streamed run item events stay in sync with the items recorded on the result.""" + english_agent = Agent(name="EnglishAgent", model=FakeModel()) + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("Transferring"), get_handoff_tool_call(english_agent)], + [get_text_message("Done")], + ] + ) + triage_agent = Agent(name="TriageAgent", handoffs=[english_agent], model=model) + + result = Runner.run_streamed(triage_agent, input="Start") + + streamed_item_types = [ + event.item.type + async for event in result.stream_events() + if event.type == "run_item_stream_event" + ] + + assert sorted(streamed_item_types) == sorted(item.type for item in result.new_items) From c546ca12091b16a5bfbb73cfbdf9827e7a8c6f6a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 3 Aug 2026 19:25:46 +0900 Subject: [PATCH 123/473] fix: preserve tool call and output order when deduplicating inputs (#4147) --- src/agents/run_internal/items.py | 38 +++++--- tests/test_agent_runner.py | 35 +++++++ tests/test_call_model_input_filter.py | 133 ++++++++++++++++++++++++++ tests/test_run_internal_items.py | 107 ++++++++++++++++++++- 4 files changed, 298 insertions(+), 15 deletions(-) diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index ab0277f080..9a4c0ea6bf 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -35,6 +35,10 @@ "local_shell_call": "local_shell_call_output", "tool_search_call": "tool_search_output", } +# These items must retain their original position relative to required follower items. +_DEDUPE_EARLIEST_ANCHOR_ITEM_TYPES = frozenset( + {*_TOOL_CALL_TO_OUTPUT_TYPE, "mcp_approval_request", "reasoning"} +) _PROGRAM_OWNED_HOSTED_ITEM_TYPES = frozenset( { "hosted_tool_call", @@ -728,25 +732,35 @@ def deduplicate_input_items(items: Sequence[TResponseInputItem]) -> list[TRespon def deduplicate_input_items_preferring_latest( items: Sequence[TResponseInputItem], ) -> list[TResponseInputItem]: - """Deduplicate by stable identifiers, keeping the latest value at the earliest position. + """Deduplicate by stable identifiers while keeping the latest value. - Duplicates collapse onto the first occurrence of their dedupe key so the caller's item - order is preserved, while the last occurrence supplies the value. Relocating an item to - the position of its final duplicate would move a `function_call` behind its - `function_call_output`, which the Responses API rejects. + Causal precursor items stay at their earliest position so they cannot move behind required + followers. Other identified items stay at their latest position so replacing a stale value + does not move the replacement earlier in the conversation. """ latest_by_key: dict[str, TResponseInputItem] = {} - for item in items: + anchor_index_by_key: dict[str, int] = {} + for index, item in enumerate(items): dedupe_key = _dedupe_key(item) - if dedupe_key is not None: - latest_by_key[dedupe_key] = item + if dedupe_key is None: + continue + + latest_by_key[dedupe_key] = item + payload = _coerce_to_dict(item) + item_type = payload.get("type") if payload is not None else None + if ( + dedupe_key not in anchor_index_by_key + or item_type not in _DEDUPE_EARLIEST_ANCHOR_ITEM_TYPES + ): + anchor_index_by_key[dedupe_key] = index - # deduplicate_input_items keeps the first item per dedupe key, which is what preserves the - # caller's order; swapping in the latest value per surviving key is all this adds. deduplicated: list[TResponseInputItem] = [] - for item in deduplicate_input_items(items): + for index, item in enumerate(items): dedupe_key = _dedupe_key(item) - deduplicated.append(item if dedupe_key is None else latest_by_key[dedupe_key]) + if dedupe_key is None: + deduplicated.append(item) + elif anchor_index_by_key[dedupe_key] == index: + deduplicated.append(latest_by_key[dedupe_key]) return deduplicated diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 0895694c60..742da0419c 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -3168,6 +3168,41 @@ async def test_save_result_to_session_keeps_tool_call_before_its_output(): assert saved_types == ["function_call", "function_call_output"] +@pytest.mark.asyncio +async def test_save_result_to_session_keeps_latest_output_after_its_call(): + session = SimpleListSession() + old_output = cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call_ordered", "output": "old"}, + ) + call_item = cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "call_ordered", + "name": "tool_ordered", + "arguments": "{}", + }, + ) + new_output = _DummyRunItem( + {"type": "function_call_output", "call_id": "call_ordered", "output": "new"} + ) + + await save_result_to_session( + session, + [old_output, call_item], + [cast(RunItem, new_output)], + None, + ) + + saved_items = [cast(dict[str, Any], item) for item in session.saved_items] + assert [item.get("type") for item in saved_items] == [ + "function_call", + "function_call_output", + ] + assert saved_items[1]["output"] == "new" + + @pytest.mark.asyncio async def test_rewind_handles_id_stripped_sessions() -> None: session = IdStrippingSession() diff --git a/tests/test_call_model_input_filter.py b/tests/test_call_model_input_filter.py index 152cf6939f..3ae86206a5 100644 --- a/tests/test_call_model_input_filter.py +++ b/tests/test_call_model_input_filter.py @@ -232,6 +232,58 @@ def _duplicate_tool_call_input() -> list[TResponseInputItem]: ] +def _duplicate_tool_output_input() -> list[TResponseInputItem]: + return [ + cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "ordered-output", "output": "old"}, + ), + cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "ordered-output", + "name": "tool_ordered", + "arguments": "{}", + }, + ), + cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "ordered-output", "output": "new"}, + ), + ] + + +def _duplicate_reasoning_input() -> list[TResponseInputItem]: + return [ + cast( + TResponseInputItem, + { + "type": "reasoning", + "id": "ordered-reasoning", + "summary": [{"type": "summary_text", "text": "old"}], + }, + ), + cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "reasoning-call", + "name": "tool_ordered", + "arguments": "{}", + }, + ), + cast( + TResponseInputItem, + { + "type": "reasoning", + "id": "ordered-reasoning", + "summary": [{"type": "summary_text", "text": "new"}], + }, + ), + ] + + def _sent_item_types(sent_input: Any) -> list[str | None]: return [ cast(dict[str, Any], item).get("type") @@ -290,3 +342,84 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: "function_call", "function_call_output", ] + + +@pytest.mark.asyncio +async def test_call_model_input_filter_keeps_duplicate_output_order_non_streamed() -> None: + model = FakeModel() + agent = Agent(name="test", model=model) + model.set_next_output([get_text_message("ok")]) + + def filter_fn(data: CallModelData[Any]) -> ModelInputData: + return ModelInputData( + input=list(data.model_data.input) + _duplicate_tool_output_input(), + instructions=data.model_data.instructions, + ) + + await Runner.run( + agent, + input="start", + run_config=RunConfig(call_model_input_filter=filter_fn), + ) + + assert _sent_item_types(model.last_turn_args["input"]) == [ + "function_call", + "function_call_output", + ] + assert model.last_turn_args["input"][-1]["output"] == "new" + + +@pytest.mark.asyncio +async def test_call_model_input_filter_keeps_duplicate_output_order_streamed() -> None: + model = FakeModel() + agent = Agent(name="test", model=model) + model.set_next_output([get_text_message("ok")]) + + async def filter_fn(data: CallModelData[Any]) -> ModelInputData: + return ModelInputData( + input=list(data.model_data.input) + _duplicate_tool_output_input(), + instructions=data.model_data.instructions, + ) + + result = Runner.run_streamed( + agent, + input="start", + run_config=RunConfig(call_model_input_filter=filter_fn), + ) + async for _ in result.stream_events(): + pass + + assert _sent_item_types(model.last_turn_args["input"]) == [ + "function_call", + "function_call_output", + ] + assert model.last_turn_args["input"][-1]["output"] == "new" + + +@pytest.mark.asyncio +async def test_call_model_input_filter_keeps_reasoning_before_required_follower() -> None: + model = FakeModel() + agent = Agent(name="test", model=model) + model.set_next_output([get_text_message("ok")]) + + def filter_fn(data: CallModelData[Any]) -> ModelInputData: + return ModelInputData( + input=list(data.model_data.input) + _duplicate_reasoning_input(), + instructions=data.model_data.instructions, + ) + + await Runner.run( + agent, + input="start", + run_config=RunConfig(call_model_input_filter=filter_fn), + ) + + assert _sent_item_types(model.last_turn_args["input"]) == [ + "reasoning", + "function_call", + ] + reasoning_items = [ + item for item in model.last_turn_args["input"] if item.get("type") == "reasoning" + ] + assert len(reasoning_items) == 1 + assert reasoning_items[0]["summary"] == [{"type": "summary_text", "text": "new"}] diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index 02e5ee99bc..2b991ea342 100644 --- a/tests/test_run_internal_items.py +++ b/tests/test_run_internal_items.py @@ -1032,7 +1032,7 @@ def test_deduplicate_input_items_preferring_latest_keeps_original_order() -> Non assert cast(dict[str, Any], deduplicated[2])["role"] == "assistant" -def test_deduplicate_input_items_preferring_latest_uses_latest_value_at_first_position() -> None: +def test_deduplicate_input_items_preferring_latest_keeps_latest_output_position() -> None: old_output = cast( TResponseInputItem, {"type": "function_call_output", "call_id": "call-1", "output": "old"}, @@ -1048,8 +1048,109 @@ def test_deduplicate_input_items_preferring_latest_uses_latest_value_at_first_po ) assert len(deduplicated) == 2 - assert cast(dict[str, Any], deduplicated[0])["output"] == "new" - assert cast(dict[str, Any], deduplicated[1])["content"] == "next" + assert cast(dict[str, Any], deduplicated[0])["content"] == "next" + assert cast(dict[str, Any], deduplicated[1])["output"] == "new" + + +def test_deduplicate_input_items_preferring_latest_keeps_output_after_matching_call() -> None: + old_output = cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call-1", "output": "old"}, + ) + call = cast( + TResponseInputItem, + {"type": "function_call", "call_id": "call-1", "name": "tool", "arguments": "{}"}, + ) + new_output = cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "call-1", "output": "new"}, + ) + + deduplicated = run_items.deduplicate_input_items_preferring_latest( + [old_output, call, new_output] + ) + + assert [cast(dict[str, Any], item).get("type") for item in deduplicated] == [ + "function_call", + "function_call_output", + ] + assert cast(dict[str, Any], deduplicated[1])["output"] == "new" + + +def test_deduplicate_input_items_preferring_latest_keeps_reasoning_before_follower() -> None: + old_reasoning = cast( + TResponseInputItem, + { + "type": "reasoning", + "id": "rs-1", + "summary": [{"type": "summary_text", "text": "old"}], + }, + ) + call = cast( + TResponseInputItem, + {"type": "function_call", "call_id": "call-1", "name": "tool", "arguments": "{}"}, + ) + new_reasoning = cast( + TResponseInputItem, + { + "type": "reasoning", + "id": "rs-1", + "summary": [{"type": "summary_text", "text": "new"}], + }, + ) + + deduplicated = run_items.deduplicate_input_items_preferring_latest( + [old_reasoning, call, new_reasoning] + ) + + assert [cast(dict[str, Any], item).get("type") for item in deduplicated] == [ + "reasoning", + "function_call", + ] + assert cast(dict[str, Any], deduplicated[0])["summary"] == [ + {"type": "summary_text", "text": "new"} + ] + + +def test_deduplicate_input_items_preferring_latest_keeps_approval_request_before_response() -> None: + old_request = cast( + TResponseInputItem, + { + "type": "mcp_approval_request", + "id": "approval-1", + "arguments": "old", + "name": "lookup", + "server_label": "server", + }, + ) + response = cast( + TResponseInputItem, + { + "type": "mcp_approval_response", + "approval_request_id": "approval-1", + "approve": True, + }, + ) + new_request = cast( + TResponseInputItem, + { + "type": "mcp_approval_request", + "id": "approval-1", + "arguments": "new", + "name": "lookup", + "server_label": "server", + }, + ) + + deduplicated = run_items.deduplicate_input_items_preferring_latest( + [old_request, response, new_request] + ) + + assert [cast(dict[str, Any], item).get("type") for item in deduplicated] == [ + "mcp_approval_request", + "mcp_approval_response", + ] + assert cast(dict[str, Any], deduplicated[0])["arguments"] == "new" def test_deduplicate_input_items_preferring_latest_leaves_unique_items_untouched() -> None: From 052f8387a6c326799aa6e055bc66938e5df8d515 Mon Sep 17 00:00:00 2001 From: Omid Saffari Date: Tue, 4 Aug 2026 02:23:59 +0400 Subject: [PATCH 124/473] fix(run): honor falsey handoff input filters (#4153) --- src/agents/run_internal/turn_resolution.py | 10 +++-- tests/test_agent_runner.py | 46 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 40563d62e0..d95a8fcd6c 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -562,8 +562,10 @@ def nest_history( ), ) - input_filter = handoff.input_filter or ( - run_config.handoff_input_filter if run_config else None + input_filter = ( + handoff.input_filter + if handoff.input_filter is not None + else run_config.handoff_input_filter ) handoff_nest_setting = handoff.nest_handoff_history should_nest_history = ( @@ -583,7 +585,7 @@ def nest_history( handoff_input_data: HandoffInputData | None = None session_step_items: list[RunItem] | None = None nested_history_owned_items: list[NestedHistoryOwnedItem] | None = None - if input_filter or should_nest_history: + if input_filter is not None or should_nest_history: handoff_input_data = HandoffInputData( input_history=tuple(original_input) if isinstance(original_input, list) @@ -593,7 +595,7 @@ def nest_history( run_context=context_wrapper, ) - if input_filter and handoff_input_data is not None: + if input_filter is not None and handoff_input_data is not None: filter_name = getattr(input_filter, "__qualname__", repr(input_filter)) from_agent = getattr(public_agent, "name", public_agent.__class__.__name__) to_agent = getattr(new_agent, "name", new_agent.__class__.__name__) diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 742da0419c..8d343b6609 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -1832,6 +1832,52 @@ def passthrough_filter(data: HandoffInputData) -> HandoffInputData: assert filtered_result.input == "user_message" +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"]) +async def test_falsey_per_handoff_input_filter_takes_precedence(streamed: bool) -> None: + triage_model = FakeModel() + delegate_model = FakeModel() + delegate = Agent(name="delegate", model=delegate_model) + + class FalseyInputFilter: + def __init__(self) -> None: + self.call_count = 0 + + def __bool__(self) -> bool: + return False + + def __call__(self, data: HandoffInputData) -> HandoffInputData: + self.call_count += 1 + return data + + per_handoff_filter = FalseyInputFilter() + + def global_filter(_data: HandoffInputData) -> HandoffInputData: + raise AssertionError("The run-level filter must not replace the per-handoff filter") + + triage = Agent( + name="triage", + model=triage_model, + handoffs=[handoff(delegate, input_filter=per_handoff_filter)], + ) + triage_model.add_multiple_turn_outputs([[get_handoff_tool_call(delegate)]]) + delegate_model.add_multiple_turn_outputs([[get_text_message("done")]]) + + result = await _run_agent_with_optional_streaming( + triage, + input="user_message", + streamed=streamed, + run_config=RunConfig( + handoff_input_filter=global_filter, + nest_handoff_history=True, + ), + ) + + assert result.final_output == "done" + assert result.input == "user_message" + assert per_handoff_filter.call_count == 1 + + @pytest.mark.asyncio async def test_opt_in_handoff_history_accumulates_across_multiple_handoffs(): triage_model = FakeModel() From 6836d1dfd721883b31b327a82536e4fda5a6e843 Mon Sep 17 00:00:00 2001 From: Pranav Mishra Date: Mon, 3 Aug 2026 15:26:38 -0700 Subject: [PATCH 125/473] fix(models): record model-call failures on the provider's own span (#4143) --- src/agents/extensions/models/any_llm_model.py | 76 +++- src/agents/extensions/models/litellm_model.py | 47 +- src/agents/models/openai_chatcompletions.py | 35 +- src/agents/models/openai_responses.py | 12 + src/agents/util/_error_tracing.py | 81 ++++ tests/test_provider_span_errors.py | 430 ++++++++++++++++++ 6 files changed, 637 insertions(+), 44 deletions(-) create mode 100644 tests/test_provider_span_errors.py diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index b72f182a1b..aa217cb2a1 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -53,6 +53,7 @@ from ...tracing.span_data import GenerationSpanData from ...tracing.spans import Span from ...usage import Usage +from ...util._error_tracing import model_span_errors, record_model_error_on_span from ...util._json import _to_dump_compatible try: @@ -361,7 +362,14 @@ async def _get_response_via_responses( conversation_id: str | None, prompt: ResponsePromptParam | None, ) -> ModelResponse: - with response_span(disabled=tracing.is_disabled()) as span_response: + with ( + response_span(disabled=tracing.is_disabled()) as span_response, + model_span_errors( + span_response, + message="Error getting response", + trace_include_sensitive_data=tracing.include_data(), + ), + ): response = await self._fetch_responses_response( system_instructions=system_instructions, input=input, @@ -425,7 +433,14 @@ async def _stream_response_via_responses( conversation_id: str | None, prompt: ResponsePromptParam | None, ) -> AsyncGenerator[ResponseStreamEvent, None]: - with response_span(disabled=tracing.is_disabled()) as span_response: + with ( + response_span(disabled=tracing.is_disabled()) as span_response, + model_span_errors( + span_response, + message="Error streaming response", + trace_include_sensitive_data=tracing.include_data(), + ), + ): stream = await self._fetch_responses_response( system_instructions=system_instructions, input=input, @@ -472,6 +487,17 @@ async def _stream_response_via_responses( if tracing.include_data() and final_response: span_response.span_data.response = final_response span_response.span_data.input = input + if terminal_failure_error is not None: + # The failure is already known here. A consumer that stops at + # this event closes the generator, which raises GeneratorExit + # at the yield below and skips the raise after the loop, so + # recording later would miss it entirely. + record_model_error_on_span( + span_response, + message="Error streaming response", + error=terminal_failure_error, + trace_include_sensitive_data=tracing.include_data(), + ) yield chunk except asyncio.CancelledError: close_stream_in_background = True @@ -506,15 +532,22 @@ async def _get_response_via_chat( tracing: ModelTracing, prompt: ResponsePromptParam | None, ) -> ModelResponse: - with generation_span( - model=str(self.model), - model_config=model_config_for_trace( - model_settings, - base_url=self.base_url or "", - extra_config={"provider": self._provider_name, "model_impl": "any-llm"}, + with ( + generation_span( + model=str(self.model), + model_config=model_config_for_trace( + model_settings, + base_url=self.base_url or "", + extra_config={"provider": self._provider_name, "model_impl": "any-llm"}, + ), + disabled=tracing.is_disabled(), + ) as span_generation, + model_span_errors( + span_generation, + message="Error getting response", + trace_include_sensitive_data=tracing.include_data(), ), - disabled=tracing.is_disabled(), - ) as span_generation: + ): response = await self._fetch_chat_response( system_instructions=system_instructions, input=input, @@ -608,15 +641,22 @@ async def _stream_response_via_chat( tracing: ModelTracing, prompt: ResponsePromptParam | None, ) -> AsyncGenerator[TResponseStreamEvent, None]: - with generation_span( - model=str(self.model), - model_config=model_config_for_trace( - model_settings, - base_url=self.base_url or "", - extra_config={"provider": self._provider_name, "model_impl": "any-llm"}, + with ( + generation_span( + model=str(self.model), + model_config=model_config_for_trace( + model_settings, + base_url=self.base_url or "", + extra_config={"provider": self._provider_name, "model_impl": "any-llm"}, + ), + disabled=tracing.is_disabled(), + ) as span_generation, + model_span_errors( + span_generation, + message="Error streaming response", + trace_include_sensitive_data=tracing.include_data(), ), - disabled=tracing.is_disabled(), - ) as span_generation: + ): response, stream = await self._fetch_chat_response( system_instructions=system_instructions, input=input, diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index d8430ccc69..185b661031 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -59,6 +59,7 @@ from ...tracing.span_data import GenerationSpanData from ...tracing.spans import Span from ...usage import Usage, _cache_write_tokens, _make_input_tokens_details +from ...util._error_tracing import model_span_errors from ...util._json import _to_dump_compatible @@ -214,15 +215,22 @@ async def get_response( conversation_id: str | None = None, # unused prompt: Any | None = None, ) -> ModelResponse: - with generation_span( - model=str(self.model), - model_config=model_config_for_trace( - model_settings, - base_url=self.base_url or "", - extra_config={"model_impl": "litellm"}, + with ( + generation_span( + model=str(self.model), + model_config=model_config_for_trace( + model_settings, + base_url=self.base_url or "", + extra_config={"model_impl": "litellm"}, + ), + disabled=tracing.is_disabled(), + ) as span_generation, + model_span_errors( + span_generation, + message="Error getting response", + trace_include_sensitive_data=tracing.include_data(), ), - disabled=tracing.is_disabled(), - ) as span_generation: + ): response = await self._fetch_response( system_instructions, input, @@ -377,15 +385,22 @@ async def stream_response( conversation_id: str | None = None, # unused prompt: Any | None = None, ) -> AsyncIterator[TResponseStreamEvent]: - with generation_span( - model=str(self.model), - model_config=model_config_for_trace( - model_settings, - base_url=self.base_url or "", - extra_config={"model_impl": "litellm"}, + with ( + generation_span( + model=str(self.model), + model_config=model_config_for_trace( + model_settings, + base_url=self.base_url or "", + extra_config={"model_impl": "litellm"}, + ), + disabled=tracing.is_disabled(), + ) as span_generation, + model_span_errors( + span_generation, + message="Error streaming response", + trace_include_sensitive_data=tracing.include_data(), ), - disabled=tracing.is_disabled(), - ) as span_generation: + ): response, stream = await self._fetch_response( system_instructions, input, diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index b7f9d8e00a..0ac0a2690c 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -32,6 +32,7 @@ from ..tracing.span_data import GenerationSpanData from ..tracing.spans import Span from ..usage import Usage +from ..util._error_tracing import model_span_errors from ..util._json import _to_dump_compatible from ._openai_retry import get_openai_retry_advice from ._retry_runtime import should_disable_provider_managed_retries @@ -206,11 +207,18 @@ async def get_response( ) self._handle_unsupported_prompt(prompt) - with generation_span( - model=str(self.model), - model_config=model_config_for_trace(model_settings, base_url=self._client.base_url), - disabled=tracing.is_disabled(), - ) as span_generation: + with ( + generation_span( + model=str(self.model), + model_config=model_config_for_trace(model_settings, base_url=self._client.base_url), + disabled=tracing.is_disabled(), + ) as span_generation, + model_span_errors( + span_generation, + message="Error getting response", + trace_include_sensitive_data=tracing.include_data(), + ), + ): response = await self._fetch_response( system_instructions, input, @@ -340,11 +348,18 @@ async def stream_response( ) self._handle_unsupported_prompt(prompt) - with generation_span( - model=str(self.model), - model_config=model_config_for_trace(model_settings, base_url=self._client.base_url), - disabled=tracing.is_disabled(), - ) as span_generation: + with ( + generation_span( + model=str(self.model), + model_config=model_config_for_trace(model_settings, base_url=self._client.base_url), + disabled=tracing.is_disabled(), + ) as span_generation, + model_span_errors( + span_generation, + message="Error streaming response", + trace_include_sensitive_data=tracing.include_data(), + ), + ): response, stream = await self._fetch_response( system_instructions, input, diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index cafb168f4b..d3dd46f8f7 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -75,6 +75,7 @@ ) from ..tracing import SpanError, response_span from ..usage import Usage, _response_usage_to_usage, model_usage_to_span_usage +from ..util._error_tracing import record_model_error_on_span from ..util._json import _to_dump_compatible from ..version import __version__ from ._openai_retry import get_openai_retry_advice @@ -593,6 +594,17 @@ async def stream_response( "response.error", }: yielded_terminal_event = True + if terminal_failure_error is not None: + # A consumer that stops at this event closes the + # generator, which raises GeneratorExit at the yield + # below and skips the raise after the loop, so the + # span has to be annotated here or not at all. + record_model_error_on_span( + span_response, + message="Error streaming response", + error=terminal_failure_error, + trace_include_sensitive_data=tracing.include_data(), + ) yield chunk except asyncio.CancelledError: close_stream_in_background = True diff --git a/src/agents/util/_error_tracing.py b/src/agents/util/_error_tracing.py index 7f714482a5..27eae75ec2 100644 --- a/src/agents/util/_error_tracing.py +++ b/src/agents/util/_error_tracing.py @@ -1,3 +1,5 @@ +import contextlib +from collections.abc import Iterator from typing import Any from .. import _debug @@ -29,3 +31,82 @@ def attach_error_to_current_span(error: SpanError) -> None: logger.warning("No active span; trace error was not attached") else: logger.warning("No span to add error %s to", error) + + +def _model_error_text(error: Exception, *, trace_include_sensitive_data: bool) -> str: + """Render the span text for a failed model call. + + The exception is only stringified when its text will actually be exported, so a + provider exception with a side-effecting `__str__` is not invoked just to have + its output thrown away by redaction. + """ + if not trace_include_sensitive_data: + return REDACTED_TRACE_ERROR_MESSAGE + try: + return str(error) + except Exception: + logger.warning( + "Could not stringify %s for the model span; recording the type only", + type(error).__name__, + ) + return f"Unrenderable {type(error).__name__}" + + +def record_model_error_on_span( + span: Span[Any], + *, + message: str, + error: Exception, + trace_include_sensitive_data: bool, +) -> None: + """Record an already-known model failure on its span. + + Streaming providers learn about a terminal failure before they raise it, and a + consumer that stops at that terminal event closes the generator, so the raise + never happens. Recording at the point of knowledge keeps the span accurate in + that case. Best-effort: never raises, so annotating a span cannot change what + the caller sees. + """ + try: + attach_error_to_span( + span, + SpanError( + message=message, + data={ + "error": _model_error_text( + error, + trace_include_sensitive_data=trace_include_sensitive_data, + ) + }, + ), + ) + except Exception: + logger.warning("Could not record the model error on the span", exc_info=True) + + +@contextlib.contextmanager +def model_span_errors( + span: Span[Any], + *, + message: str, + trace_include_sensitive_data: bool, +) -> Iterator[None]: + """Record a failing model call on the span it happened in, then re-raise. + + `Span.__exit__` finishes a span without attaching an exception, so a provider + that does not annotate its own span exports a failed model call that is + indistinguishable from a successful one. + + Recording is best-effort on purpose: the exception the caller sees is always the + one the provider raised, never one produced while annotating the span. + """ + try: + yield + except Exception as error: + record_model_error_on_span( + span, + message=message, + error=error, + trace_include_sensitive_data=trace_include_sensitive_data, + ) + raise diff --git a/tests/test_provider_span_errors.py b/tests/test_provider_span_errors.py new file mode 100644 index 0000000000..2ea1765363 --- /dev/null +++ b/tests/test_provider_span_errors.py @@ -0,0 +1,430 @@ +"""Every model provider must record a failed model call on its own span. + +`Span.__exit__` finishes a span without attaching an exception, so a provider that +does not annotate its span exports a failed model call that is indistinguishable +from a successful one. `OpenAIResponsesModel` has always annotated its span; these +tests pin the same behavior for the other providers. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from openai import AsyncOpenAI + +from agents import ModelSettings, ModelTracing, OpenAIChatCompletionsModel, trace + +from .testing_processor import fetch_ordered_spans + + +class _Boom(Exception): + pass + + +def _span_error(span_filter: str) -> dict[str, Any] | None: + for span in fetch_ordered_spans(): + if span.span_data.type == span_filter and span.error is not None: + return dict(span.error) + return None + + +async def _drain(agen: Any) -> None: + async for _ in agen: + pass + + +def _chatcompletions_model() -> OpenAIChatCompletionsModel: + return OpenAIChatCompletionsModel( + model="gpt-4", openai_client=AsyncOpenAI(api_key="test", base_url="http://localhost:1") + ) + + +def _call_kwargs() -> dict[str, Any]: + return { + "system_instructions": None, + "input": "hi", + "model_settings": ModelSettings(), + "tools": [], + "output_schema": None, + "handoffs": [], + "tracing": ModelTracing.ENABLED, + "previous_response_id": None, + "conversation_id": None, + "prompt": None, + } + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_chatcompletions_get_response_records_span_error(monkeypatch) -> None: + model = _chatcompletions_model() + + async def boom(*args: Any, **kwargs: Any) -> Any: + raise _Boom("upstream exploded") + + monkeypatch.setattr(model, "_fetch_response", boom) + with trace(workflow_name="test"): + with pytest.raises(_Boom): + await model.get_response(**_call_kwargs()) + + error = _span_error("generation") + assert error is not None, "generation span carried no error" + assert error["message"] == "Error getting response" + assert "upstream exploded" in error["data"]["error"] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_chatcompletions_stream_response_records_span_error(monkeypatch) -> None: + model = _chatcompletions_model() + + async def boom(*args: Any, **kwargs: Any) -> Any: + raise _Boom("stream exploded") + + monkeypatch.setattr(model, "_fetch_response", boom) + with trace(workflow_name="test"): + with pytest.raises(_Boom): + await _drain(model.stream_response(**_call_kwargs())) + + error = _span_error("generation") + assert error is not None, "generation span carried no error" + assert error["message"] == "Error streaming response" + assert "stream exploded" in error["data"]["error"] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_chatcompletions_span_error_is_redacted_without_sensitive_data(monkeypatch) -> None: + """With tracing data disabled the exception text must not reach the span.""" + model = _chatcompletions_model() + + async def boom(*args: Any, **kwargs: Any) -> Any: + raise _Boom("secret-connection-string") + + monkeypatch.setattr(model, "_fetch_response", boom) + kwargs = _call_kwargs() + kwargs["tracing"] = ModelTracing.ENABLED_WITHOUT_DATA + with trace(workflow_name="test"): + with pytest.raises(_Boom): + await model.get_response(**kwargs) + + error = _span_error("generation") + assert error is not None + assert "secret-connection-string" not in error["data"]["error"] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_litellm_get_response_records_span_error(monkeypatch) -> None: + pytest.importorskip("litellm") + from agents.extensions.models.litellm_model import LitellmModel + + model = LitellmModel(model="gpt-4", api_key="test") + + async def boom(*args: Any, **kwargs: Any) -> Any: + raise _Boom("litellm exploded") + + monkeypatch.setattr(model, "_fetch_response", boom) + with trace(workflow_name="test"): + with pytest.raises(_Boom): + await model.get_response(**_call_kwargs()) + + error = _span_error("generation") + assert error is not None, "generation span carried no error" + assert error["message"] == "Error getting response" + assert "litellm exploded" in error["data"]["error"] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_litellm_stream_response_records_span_error(monkeypatch) -> None: + pytest.importorskip("litellm") + from agents.extensions.models.litellm_model import LitellmModel + + model = LitellmModel(model="gpt-4", api_key="test") + + async def boom(*args: Any, **kwargs: Any) -> Any: + raise _Boom("litellm stream exploded") + + monkeypatch.setattr(model, "_fetch_response", boom) + with trace(workflow_name="test"): + with pytest.raises(_Boom): + await _drain(model.stream_response(**_call_kwargs())) + + error = _span_error("generation") + assert error is not None, "generation span carried no error" + assert error["message"] == "Error streaming response" + assert "litellm stream exploded" in error["data"]["error"] + + +def _any_llm_model() -> Any: + from agents.extensions.models.any_llm_model import AnyLLMModel + + return AnyLLMModel(model="openai/gpt-4", api_key="test") + + +_ANY_LLM_BASE_KWARGS: dict[str, Any] = { + "system_instructions": None, + "input": "hi", + "model_settings": ModelSettings(), + "tools": [], + "output_schema": None, + "handoffs": [], + "tracing": ModelTracing.ENABLED, + "prompt": None, +} + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "fetch", "span_type", "message", "streaming"), + [ + ( + "_get_response_via_responses", + "_fetch_responses_response", + "response", + "Error getting response", + False, + ), + ( + "_stream_response_via_responses", + "_fetch_responses_response", + "response", + "Error streaming response", + True, + ), + ( + "_get_response_via_chat", + "_fetch_chat_response", + "generation", + "Error getting response", + False, + ), + ( + "_stream_response_via_chat", + "_fetch_chat_response", + "generation", + "Error streaming response", + True, + ), + ], +) +async def test_any_llm_records_span_error( + monkeypatch, method: str, fetch: str, span_type: str, message: str, streaming: bool +) -> None: + pytest.importorskip("any_llm") + model = _any_llm_model() + + async def boom(*args: Any, **kwargs: Any) -> Any: + raise _Boom("any_llm exploded") + + monkeypatch.setattr(model, fetch, boom) + kwargs = dict(_ANY_LLM_BASE_KWARGS) + if "via_responses" in method: + kwargs.update({"previous_response_id": None, "conversation_id": None}) + + with trace(workflow_name="test"): + with pytest.raises(_Boom): + if streaming: + await _drain(getattr(model, method)(**kwargs)) + else: + await getattr(model, method)(**kwargs) + + error = _span_error(span_type) + assert error is not None, f"{span_type} span carried no error" + assert error["message"] == message + assert "any_llm exploded" in error["data"]["error"] + + +class _SideEffectingStr(Exception): + """A provider exception whose `__str__` must not be called speculatively.""" + + def __init__(self) -> None: + super().__init__() + self.str_calls = 0 + + def __str__(self) -> str: + self.str_calls += 1 + return "sensitive detail" + + +class _BrokenStr(Exception): + def __str__(self) -> str: + raise ValueError("__str__ exploded") + + +def test_redacted_tracing_does_not_stringify_the_exception() -> None: + """`ENABLED_WITHOUT_DATA` must not evaluate `str(error)` just to discard it.""" + from agents.tracing import generation_span + from agents.util._error_tracing import REDACTED_TRACE_ERROR_MESSAGE, model_span_errors + + original = _SideEffectingStr() + with trace(workflow_name="test"): + with generation_span() as span: + with pytest.raises(_SideEffectingStr) as exc_info: + with model_span_errors( + span, + message="Error getting response", + trace_include_sensitive_data=False, + ): + raise original + + assert exc_info.value is original + assert original.str_calls == 0 + error = _span_error("generation") + assert error is not None + assert error["data"]["error"] == REDACTED_TRACE_ERROR_MESSAGE + + +def test_sensitive_tracing_stringifies_once() -> None: + from agents.tracing import generation_span + from agents.util._error_tracing import model_span_errors + + original = _SideEffectingStr() + with trace(workflow_name="test"): + with generation_span() as span: + with pytest.raises(_SideEffectingStr): + with model_span_errors( + span, + message="Error getting response", + trace_include_sensitive_data=True, + ): + raise original + + assert original.str_calls == 1 + error = _span_error("generation") + assert error is not None + assert error["data"]["error"] == "sensitive detail" + + +@pytest.mark.parametrize("include_sensitive_data", [True, False]) +def test_broken_str_preserves_the_provider_exception(include_sensitive_data: bool) -> None: + """A broken `__str__` must not replace the provider failure the caller sees.""" + from agents.tracing import generation_span + from agents.util._error_tracing import model_span_errors + + original = _BrokenStr() + with trace(workflow_name="test"): + with generation_span() as span: + with pytest.raises(_BrokenStr) as exc_info: + with model_span_errors( + span, + message="Error getting response", + trace_include_sensitive_data=include_sensitive_data, + ): + raise original + + assert exc_info.value is original + assert _span_error("generation") is not None + + +def test_failing_span_recording_preserves_the_provider_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If recording itself raises, the caller still sees the provider's exception.""" + from agents.tracing import generation_span + from agents.util import _error_tracing + from agents.util._error_tracing import model_span_errors + + def explode(*_args: Any, **_kwargs: Any) -> None: + raise RuntimeError("span backend is down") + + monkeypatch.setattr(_error_tracing, "attach_error_to_span", explode) + + original = _Boom("provider failed") + with trace(workflow_name="test"): + with generation_span() as span: + with pytest.raises(_Boom) as exc_info: + with model_span_errors( + span, + message="Error getting response", + trace_include_sensitive_data=True, + ): + raise original + + assert exc_info.value is original + + +class _TerminalFailureEvent: + """A terminal `response.failed` event with no response payload attached.""" + + type = "response.failed" + response = None + + +class _SingleEventStream: + def __init__(self) -> None: + self._sent = False + + def __aiter__(self) -> _SingleEventStream: + return self + + async def __anext__(self) -> _TerminalFailureEvent: + if self._sent: + raise StopAsyncIteration + self._sent = True + return _TerminalFailureEvent() + + async def aclose(self) -> None: + return None + + +async def _stop_at_terminal_event(agen: Any) -> None: + """Consume the terminal event and close the generator, as a raw consumer would.""" + first = await agen.__anext__() + assert getattr(first, "type", None) == "response.failed" + await agen.aclose() + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_openai_responses_records_terminal_failure_when_consumer_stops(monkeypatch) -> None: + """Closing the stream at a terminal failure must still mark the span. + + The failure is known when the terminal event is yielded, but `aclose()` raises + `GeneratorExit` at that yield, which skips the `raise terminal_failure_error` + after the loop. `GeneratorExit` is a `BaseException`, so nothing downstream + records it either and the span exports as if the call had succeeded. + """ + from agents import OpenAIResponsesModel + + model = OpenAIResponsesModel( + model="gpt-4", openai_client=AsyncOpenAI(api_key="test", base_url="http://localhost:1") + ) + + async def fake_fetch(*args: Any, **kwargs: Any) -> Any: + return _SingleEventStream() + + monkeypatch.setattr(model, "_fetch_response", fake_fetch) + + with trace(workflow_name="test"): + await _stop_at_terminal_event(model.stream_response(**_call_kwargs())) + + error = _span_error("response") + assert error is not None, "response span carried no error" + assert error["message"] == "Error streaming response" + assert "response.failed" in error["data"]["error"] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_records_terminal_failure_when_consumer_stops(monkeypatch) -> None: + pytest.importorskip("any_llm") + from agents.extensions.models.any_llm_model import AnyLLMModel + + model = AnyLLMModel(model="openai/gpt-4") + + async def fake_fetch(*args: Any, **kwargs: Any) -> Any: + return _SingleEventStream() + + monkeypatch.setattr(model, "_fetch_responses_response", fake_fetch) + + with trace(workflow_name="test"): + await _stop_at_terminal_event(model._stream_response_via_responses(**_call_kwargs())) + + error = _span_error("response") + assert error is not None, "response span carried no error" + assert error["message"] == "Error streaming response" + assert "response.failed" in error["data"]["error"] From 8d6ca279ec3aa10e3c893d838543a48b84a6569b Mon Sep 17 00:00:00 2001 From: LHMQ878 <72402929@cityu-dg.edu.cn> Date: Tue, 4 Aug 2026 06:28:04 +0800 Subject: [PATCH 126/473] fix(models): let an in-flight provider stream close finish after cancellation (#4156) --- src/agents/extensions/models/litellm_model.py | 28 +++++- src/agents/models/openai_chatcompletions.py | 30 +++++- src/agents/models/openai_responses.py | 30 +++++- .../test_litellm_chatcompletions_stream.py | 72 ++++++++++++++ .../test_openai_chatcompletions_stream.py | 99 +++++++++++++++++++ tests/models/test_openai_responses.py | 94 ++++++++++++++++++ 6 files changed, 341 insertions(+), 12 deletions(-) diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 185b661031..35750f1703 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -433,7 +433,7 @@ async def stream_response( finally: if not close_stream_in_background: try: - await self._maybe_aclose(stream) + await self._close_stream_allowing_background_completion(stream) except Exception as exc: if yielded_terminal_event: log_model_action_debug( @@ -885,11 +885,31 @@ async def _maybe_aclose(value: Any) -> None: await result def _schedule_async_iterator_close(self, iterator: Any) -> None: - task = asyncio.create_task(self._maybe_aclose(iterator)) - task.add_done_callback(self._consume_background_cleanup_task_result) + self._detach_stream_close(asyncio.ensure_future(self._maybe_aclose(iterator))) + + async def _close_stream_allowing_background_completion(self, iterator: Any) -> None: + """Close the provider stream, letting an in-flight close finish in the background. + + Cancellation can arrive while `aclose()` is already awaiting the provider. Shielding the + close and detaching that exact task keeps it running instead of abandoning it half-done, + and avoids starting a second close: re-closing a provider stream is not guaranteed to be + safe or idempotent. + """ + close_task = asyncio.ensure_future(self._maybe_aclose(iterator)) + try: + await asyncio.shield(close_task) + except asyncio.CancelledError: + self._detach_stream_close(close_task) + raise + + def _detach_stream_close(self, close_task: asyncio.Future[None]) -> None: + if close_task.done(): + self._consume_background_cleanup_task_result(close_task) + return + close_task.add_done_callback(self._consume_background_cleanup_task_result) @staticmethod - def _consume_background_cleanup_task_result(task: asyncio.Task[Any]) -> None: + def _consume_background_cleanup_task_result(task: asyncio.Future[Any]) -> None: try: task.result() except asyncio.CancelledError: diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 0ac0a2690c..b00cf0273d 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -140,11 +140,33 @@ async def _maybe_aclose_async_iterator(self, iterator: Any) -> None: await close_result def _schedule_async_iterator_close(self, iterator: Any) -> None: - task = asyncio.create_task(self._maybe_aclose_async_iterator(iterator)) - task.add_done_callback(self._consume_background_cleanup_task_result) + self._detach_stream_close( + asyncio.ensure_future(self._maybe_aclose_async_iterator(iterator)) + ) + + async def _close_stream_allowing_background_completion(self, iterator: Any) -> None: + """Close the provider stream, letting an in-flight close finish in the background. + + Cancellation can arrive while `aclose()` is already awaiting the provider. Shielding the + close and detaching that exact task keeps it running instead of abandoning it half-done, + and avoids starting a second close: re-closing a provider stream is not guaranteed to be + safe or idempotent. + """ + close_task = asyncio.ensure_future(self._maybe_aclose_async_iterator(iterator)) + try: + await asyncio.shield(close_task) + except asyncio.CancelledError: + self._detach_stream_close(close_task) + raise + + def _detach_stream_close(self, close_task: asyncio.Future[None]) -> None: + if close_task.done(): + self._consume_background_cleanup_task_result(close_task) + return + close_task.add_done_callback(self._consume_background_cleanup_task_result) @staticmethod - def _consume_background_cleanup_task_result(task: asyncio.Task[Any]) -> None: + def _consume_background_cleanup_task_result(task: asyncio.Future[Any]) -> None: try: task.result() except asyncio.CancelledError: @@ -401,7 +423,7 @@ async def stream_response( finally: if not close_stream_in_background: try: - await self._maybe_aclose_async_iterator(stream) + await self._close_stream_allowing_background_completion(stream) except Exception as exc: if yielded_terminal_event: log_model_action_debug( diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index d3dd46f8f7..c585e94eb6 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -445,11 +445,33 @@ async def _maybe_aclose_async_iterator(self, iterator: Any) -> None: await close_result def _schedule_async_iterator_close(self, iterator: Any) -> None: - task = asyncio.create_task(self._maybe_aclose_async_iterator(iterator)) - task.add_done_callback(self._consume_background_cleanup_task_result) + self._detach_stream_close( + asyncio.ensure_future(self._maybe_aclose_async_iterator(iterator)) + ) + + async def _close_stream_allowing_background_completion(self, iterator: Any) -> None: + """Close the provider stream, letting an in-flight close finish in the background. + + Cancellation can arrive while `aclose()` is already awaiting the provider. Shielding the + close and detaching that exact task keeps it running instead of abandoning it half-done, + and avoids starting a second close: re-closing a provider stream is not guaranteed to be + safe or idempotent. + """ + close_task = asyncio.ensure_future(self._maybe_aclose_async_iterator(iterator)) + try: + await asyncio.shield(close_task) + except asyncio.CancelledError: + self._detach_stream_close(close_task) + raise + + def _detach_stream_close(self, close_task: asyncio.Future[None]) -> None: + if close_task.done(): + self._consume_background_cleanup_task_result(close_task) + return + close_task.add_done_callback(self._consume_background_cleanup_task_result) @staticmethod - def _consume_background_cleanup_task_result(task: asyncio.Task[Any]) -> None: + def _consume_background_cleanup_task_result(task: asyncio.Future[Any]) -> None: try: task.result() except asyncio.CancelledError: @@ -613,7 +635,7 @@ async def stream_response( finally: if not close_stream_in_background: try: - await self._maybe_aclose_async_iterator(stream) + await self._close_stream_allowing_background_completion(stream) except Exception as exc: if yielded_terminal_event: log_model_action_debug( diff --git a/tests/models/test_litellm_chatcompletions_stream.py b/tests/models/test_litellm_chatcompletions_stream.py index 0fdd711aa9..8bc69eb1e1 100644 --- a/tests/models/test_litellm_chatcompletions_stream.py +++ b/tests/models/test_litellm_chatcompletions_stream.py @@ -764,6 +764,31 @@ async def aclose(self) -> None: self.aclose_completed += 1 +class _CloseSignalingChatStream(_ClosableChatStream): + """Exhausts normally, then signals from `aclose` and blocks until released. + + Unlike `_SlowCloseChatStream` this does not block in `__anext__`, so the consumer reaches + the cleanup `finally` on its own and a test can cancel while that close is in flight. + """ + + def __init__( + self, + chunks: list[ChatCompletionChunk], + close_started: asyncio.Event, + release: asyncio.Event, + ) -> None: + super().__init__(chunks) + self._close_started = close_started + self._release = release + self.aclose_completed = 0 + + async def aclose(self) -> None: + self.aclose_calls += 1 + self._close_started.set() + await self._release.wait() + self.aclose_completed += 1 + + class _FailingCloseChatStream(_ClosableChatStream): """Raises from `aclose` after recording the cleanup attempt.""" @@ -962,3 +987,50 @@ async def consume() -> None: finally: release.set() task.cancel() + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_lets_in_flight_close_finish_after_cancellation( + monkeypatch, +) -> None: + """Cancelling during the cleanup `aclose` continues that close instead of abandoning it.""" + close_started = asyncio.Event() + release = asyncio.Event() + provider_stream = _CloseSignalingChatStream([_text_chunk("He")], close_started, release) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + + stream_agen = cast(Any, _stream_response(model)) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + # The stream exhausts on its own, so the consumer reaches the cleanup `finally` + # and suspends inside the provider close. + await asyncio.wait_for(close_started.wait(), timeout=5) + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 0 + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + + # The cancelled consumer must not have started a second close. + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 0 + + release.set() + for _ in range(200): + if provider_stream.aclose_completed == 1: + break + await asyncio.sleep(0.01) + + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 1 + finally: + release.set() + task.cancel() diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index b435f12982..ee34317158 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -1,3 +1,4 @@ +import asyncio import logging from collections.abc import AsyncIterator from typing import Any, cast @@ -353,6 +354,104 @@ async def patched_fetch_response(self, *args, **kwargs): assert provider_stream.close_calls == 1 +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_lets_in_flight_close_finish_after_cancellation( + monkeypatch, +) -> None: + """Cancelling during the cleanup `aclose` continues that close instead of abandoning it.""" + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Hi"))], + ) + + class CloseSignalingChatStream: + """Exhausts normally, then signals from `aclose` and blocks until released.""" + + def __init__(self, close_started: asyncio.Event, release: asyncio.Event) -> None: + self._yielded = False + self._close_started = close_started + self._release = release + self.aclose_calls = 0 + self.aclose_completed = 0 + + def __aiter__(self) -> "CloseSignalingChatStream": + return self + + async def __anext__(self) -> ChatCompletionChunk: + if self._yielded: + raise StopAsyncIteration + self._yielded = True + return chunk + + async def aclose(self) -> None: + self.aclose_calls += 1 + self._close_started.set() + await self._release.wait() + self.aclose_completed += 1 + + close_started = asyncio.Event() + release = asyncio.Event() + provider_stream = CloseSignalingChatStream(close_started, release) + + async def patched_fetch_response(self, *args, **kwargs): + return _empty_response(), provider_stream + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + stream_agen = cast( + Any, + model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ), + ) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + # The stream exhausts on its own, so the consumer reaches the cleanup `finally` + # and suspends inside the provider close. + await asyncio.wait_for(close_started.wait(), timeout=5) + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 0 + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + + # The cancelled consumer must not have started a second close. + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 0 + + release.set() + for _ in range(200): + if provider_stream.aclose_completed == 1: + break + await asyncio.sleep(0.01) + + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 1 + finally: + release.set() + task.cancel() + + @pytest.mark.asyncio async def test_stream_handler_filters_multiple_choices_by_default( caplog: pytest.LogCaptureFixture, diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index bc1d33c64b..c5978b2ba9 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -4185,3 +4185,97 @@ def test_websocket_pre_event_disconnect_retry_respects_websocket_retry_disable() with websocket_pre_event_retries_disabled(True): assert _should_retry_pre_event_websocket_disconnect() is False + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_lets_in_flight_close_finish_after_cancellation() -> None: + """Cancelling during the cleanup `aclose` continues that close instead of abandoning it.""" + + class CloseSignalingStream: + """Exhausts normally, then signals from `aclose` and blocks until released.""" + + def __init__(self, close_started: asyncio.Event, release: asyncio.Event) -> None: + self._yielded = False + self._close_started = close_started + self._release = release + self.aclose_calls = 0 + self.aclose_completed = 0 + + def __aiter__(self) -> CloseSignalingStream: + return self + + async def __anext__(self): + if self._yielded: + raise StopAsyncIteration + self._yielded = True + return ResponseCompletedEvent( + type="response.completed", + response=get_response_obj([]), + sequence_number=0, + ) + + async def aclose(self) -> None: + self.aclose_calls += 1 + self._close_started.set() + await self._release.wait() + self.aclose_completed += 1 + + close_started = asyncio.Event() + release = asyncio.Event() + provider_stream = CloseSignalingStream(close_started, release) + + class DummyResponses: + async def create(self, **kwargs): + return provider_stream + + class DummyResponsesClient: + def __init__(self): + self.responses = DummyResponses() + + model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore[arg-type] + + stream_agen = cast( + Any, + model.stream_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ), + ) + + async def consume() -> None: + async for _event in stream_agen: + pass + + task = asyncio.create_task(consume()) + try: + # The stream exhausts on its own, so the consumer reaches the cleanup `finally` + # and suspends inside the provider close. + await asyncio.wait_for(close_started.wait(), timeout=5) + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 0 + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + + # The cancelled consumer must not have started a second close. + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 0 + + release.set() + for _ in range(200): + if provider_stream.aclose_completed == 1: + break + await asyncio.sleep(0.01) + + assert provider_stream.aclose_calls == 1 + assert provider_stream.aclose_completed == 1 + finally: + release.set() + task.cancel() From 04aaa50c8fb6942f92d84d13c037b7471b26aefc Mon Sep 17 00:00:00 2001 From: LHMQ878 <72402929@cityu-dg.edu.cn> Date: Tue, 4 Aug 2026 06:28:37 +0800 Subject: [PATCH 127/473] fix: keep committed tool session records when a streamed output guardrail trips (#4148) --- src/agents/run_internal/run_loop.py | 87 ++++++- tests/test_agent_runner_streamed.py | 383 ++++++++++++++++++++++++++++ 2 files changed, 463 insertions(+), 7 deletions(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index cacb003418..e1e1bd84ea 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -445,6 +445,64 @@ async def _run_output_guardrails_for_stream( raise +_SIDE_EFFECT_ITEM_TYPES = frozenset({"tool_call_item", "tool_call_output_item"}) + + +def _reasoning_indexes_tied_to_retained_items( + items: list[RunItem], + retained_indexes: set[int], +) -> set[int]: + """Indexes of the reasoning items whose tied item is being retained. + + Applies the same association rule as + ``agents.run_internal.items._drop_reasoning_items_preceding_dropped_calls``: a reasoning item + is tied to the next *non-reasoning* model-emitted item. Keeping a group whose following item is + dropped would leave a dangling reasoning item, which the Responses API rejects on the next + request (``reasoning was provided without its required following item``); dropping a group + whose following item is retained would strip the context that call needs to be replayed. + + A trailing reasoning group - one with no following non-reasoning item at all - is not tied to + anything retained, so it is dropped. Note this is stricter than the reference, which keeps such + a group because the item it belongs to may still arrive later in a longer history; here the + turn is complete, so there is nothing left to tie it to. + """ + tied: set[int] = set() + for index in range(len(items) - 1, -1, -1): + if items[index].type != "reasoning_item": + continue + for next_index in range(index + 1, len(items)): + if items[next_index].type == "reasoning_item": + continue + if next_index in retained_indexes: + tied.add(index) + break + return tied + + +def _retained_items_for_blocked_output(items: list[RunItem]) -> list[RunItem]: + """Pick out the items of a final turn to keep when its output is not deliverable. + + A tool that already ran has to stay in the session, together with the context needed to replay + its call. Everything else - the assistant message the guardrail rejected above all - is dropped, + including the reasoning that belongs to the rejected message rather than to a retained call. + + ``_SIDE_EFFECT_ITEM_TYPES`` is enumerated rather than derived, so an item type added later is + *discarded* here by default and has to be classified deliberately. A record of a side effect + that goes unclassified is a bug, so the safer default is the one that surfaces as a missing item + rather than as a rejected message quietly reaching the session. + """ + retained_indexes = { + index for index, item in enumerate(items) if item.type in _SIDE_EFFECT_ITEM_TYPES + } + if not retained_indexes: + return [] + # Reasoning items are not side effects themselves, but a reasoning model requires the reasoning + # item tied to a function call to accompany it in the next request. + retained_indexes |= _reasoning_indexes_tied_to_retained_items(items, retained_indexes) + # Indexed rather than filtered by type so the retained items keep the model's own order. + return [item for index, item in enumerate(items) if index in retained_indexes] + + async def _finalize_streamed_final_output( *, streamed_result: RunResultStreaming, @@ -463,18 +521,33 @@ async def _finalize_streamed_final_output( # pair even when an agent output guardrail blocks delivery of the final result. await save_items(items, response_id, store_setting) - output_guardrail_results = await _run_output_guardrails_for_stream( - agent=agent, - run_config=run_config, - output=output, - context_wrapper=context_wrapper, - streamed_result=streamed_result, - ) + try: + output_guardrail_results = await _run_output_guardrails_for_stream( + agent=agent, + run_config=run_config, + output=output, + context_wrapper=context_wrapper, + streamed_result=streamed_result, + ) + except Exception: + # The blocked output itself is not persisted, but a tool that already ran is: the next run + # has to see that side effect rather than re-issue it. This turn reaches here with tool + # items when `tool_use_behavior="stop_on_first_tool"` (or `stop_at_tool_names`, or a custom + # callable) turned a tool result straight into the final output. + if not persist_before_output_guardrails: + retained_items = _retained_items_for_blocked_output(items) + if retained_items: + await save_items(retained_items, response_id, store_setting) + raise + streamed_result.output_guardrail_results = output_guardrail_results streamed_result.final_output = output streamed_result.is_complete = True if not persist_before_output_guardrails: + # Saved as one ordered batch so the session mirrors the model response. Doing it in two + # halves would both reorder the turn and, because the first save advances the turn's + # persisted-item count, make the second one a no-op. await save_items(items, response_id, store_setting) streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 78cdce91c4..e7abbfbf43 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -2075,6 +2075,389 @@ async def run_once(input_value: Any) -> Any: assert replayed_tool_items[1].get("output") == "approved-result" +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.asyncio +async def test_stop_on_first_tool_final_persists_committed_tool_items_on_tripwire( + mode: str, +) -> None: + """A blocked final output must not discard the session record of a tool that already ran.""" + + calls: list[str] = [] + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + calls.append("ran") + return "committed-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = FakeModel() + model.set_next_output([get_function_tool_call("commit_tool", "{}", call_id="call-committed")]) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + with pytest.raises(OutputGuardrailTripwireTriggered): + if mode == "non_streamed": + await Runner.run(agent, "Use commit_tool", session=session) + else: + result = Runner.run_streamed(agent, "Use commit_tool", session=session) + await consume_stream(result) + + assert calls == ["ran"], "the tool never ran, so the test proves nothing" + + saved_items = await session.get_items() + saved = [ + (item.get("type") or item.get("role"), item.get("call_id")) + for item in saved_items + if isinstance(item, dict) + ] + assert saved == [ + ("user", None), + ("function_call", "call-committed"), + ("function_call_output", "call-committed"), + ] + + # The next run must see the completed call instead of re-issuing the same side effect. + agent.output_guardrails = [] + model.set_next_output([get_text_message("done")]) + if mode == "non_streamed": + followup: Any = await Runner.run(agent, "Continue", session=session) + else: + followup = Runner.run_streamed(agent, "Continue", session=session) + await consume_stream(followup) + assert followup.final_output == "done" + assert calls == ["ran"] + + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + replayed = [ + (item.get("type"), item.get("call_id")) + for item in model_input + if isinstance(item, dict) and item.get("type") in {"function_call", "function_call_output"} + ] + assert replayed == [ + ("function_call", "call-committed"), + ("function_call_output", "call-committed"), + ] + + +@pytest.mark.asyncio +async def test_streamed_blocked_message_final_output_is_not_persisted() -> None: + """Control for the committed-tool case: a rejected message is still withheld from the session. + + Streamed-only on purpose. The non-streamed path persists a turn before its output guardrails + run, so it keeps the rejected message today; that difference is out of scope here. + """ + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = FakeModel() + model.set_next_output([get_text_message("should_not_be_saved")]) + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + with pytest.raises(OutputGuardrailTripwireTriggered): + result = Runner.run_streamed(agent, "user_message", session=session) + await consume_stream(result) + + saved_items = await session.get_items() + saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] + assert saved == ["user"] + + +@pytest.mark.asyncio +async def test_streamed_blocked_final_persists_tool_items_but_not_the_message() -> None: + """A mixed final turn splits: the tool record is kept, the blocked message is not.""" + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + return "committed-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("commit_tool", "{}", call_id="call-mixed")], + [get_text_message("should_not_be_saved")], + ] + ) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + with pytest.raises(OutputGuardrailTripwireTriggered): + result = Runner.run_streamed(agent, "Use commit_tool", session=session) + await consume_stream(result) + + saved_items = await session.get_items() + saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] + assert saved == ["user", "function_call", "function_call_output"] + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"]) +@pytest.mark.asyncio +async def test_mixed_final_turn_session_order_and_committed_items( + mode: str, + tripwire: bool, +) -> None: + """A final turn holding a message *and* a committed tool call keeps model order when it passes. + + Only the tripwire case may drop anything, and only the undeliverable message. The passing case + must persist the whole batch in the model's order, so a later run does not replay a reordered + or truncated history. + """ + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + return "committed-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=tripwire) + + model = FakeModel() + # The message precedes the tool call, so a split save would reorder the persisted turn. + model.set_next_output( + [ + get_text_message("assistant-preamble"), + get_function_tool_call("commit_tool", "{}", call_id="call-mixed"), + ] + ) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + async def run_once() -> Any: + if mode == "non_streamed": + return await Runner.run(agent, "Use commit_tool", session=session) + result = Runner.run_streamed(agent, "Use commit_tool", session=session) + await consume_stream(result) + return result + + if tripwire: + with pytest.raises(OutputGuardrailTripwireTriggered): + await run_once() + else: + assert (await run_once()).final_output == "committed-result" + + saved_items = await session.get_items() + saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] + + if tripwire and mode == "streamed": + # The undeliverable message is withheld; the tool that already ran is not. + assert saved == ["user", "function_call", "function_call_output"] + else: + assert saved == ["user", "message", "function_call", "function_call_output"] + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"]) +@pytest.mark.asyncio +async def test_blocked_tool_final_keeps_reasoning_context_with_the_committed_call( + mode: str, + tripwire: bool, +) -> None: + """A retained tool call keeps the reasoning item it belongs to, in order. + + A reasoning model requires the reasoning item that preceded a function call to accompany that + call in the next request, so persisting the call/output pair without it leaves an unreplayable + turn. Asserted on both the session contents and the next run's model input. + """ + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + return "committed-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=tripwire) + + model = FakeModel() + model.set_next_output( + [ + ResponseReasoningItem( + id="rs_committed", + summary=[Summary(text="deciding to call the tool", type="summary_text")], + type="reasoning", + ), + get_function_tool_call("commit_tool", "{}", call_id="call-reasoned"), + ] + ) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + async def run_once(input_value: Any) -> Any: + if mode == "non_streamed": + return await Runner.run(agent, input_value, session=session) + result = Runner.run_streamed(agent, input_value, session=session) + await consume_stream(result) + return result + + if tripwire: + with pytest.raises(OutputGuardrailTripwireTriggered): + await run_once("Use commit_tool") + else: + assert (await run_once("Use commit_tool")).final_output == "committed-result" + + saved_items = await session.get_items() + saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] + assert saved == ["user", "reasoning", "function_call", "function_call_output"] + + # The reasoning/call/output group has to reach the next request in that order. + agent.output_guardrails = [] + model.set_next_output([get_text_message("done")]) + followup = await run_once("Continue") + assert followup.final_output == "done" + + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + replayed = [ + item.get("type") + for item in model_input + if isinstance(item, dict) + and item.get("type") in {"reasoning", "function_call", "function_call_output"} + ] + assert replayed == ["reasoning", "function_call", "function_call_output"] + + +@pytest.mark.asyncio +async def test_blocked_tool_final_drops_reasoning_tied_to_the_rejected_message() -> None: + """Only the reasoning tied to a retained call survives; the message's reasoning goes with it. + + The turn is `reasoning_for_message -> message -> reasoning_for_call -> function_call`. A + reasoning item belongs to the next non-reasoning item, so retaining every reasoning item + whenever the turn happens to contain a tool call would leave the rejected message's reasoning + dangling in the next request. + + Streamed only: on a tripwire the non-streamed path persists the whole turn - the rejected + message included - which predates this change and is a separate bug (see the PR discussion). + """ + mode = "streamed" + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + return "committed-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = FakeModel() + model.set_next_output( + [ + ResponseReasoningItem( + id="rs_rejected", + summary=[Summary(text="drafting the message", type="summary_text")], + type="reasoning", + ), + get_text_message("rejected-preamble"), + ResponseReasoningItem( + id="rs_committed", + summary=[Summary(text="deciding to call the tool", type="summary_text")], + type="reasoning", + ), + get_function_tool_call("commit_tool", "{}", call_id="call-reasoned"), + ] + ) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + async def run_once(input_value: Any) -> Any: + if mode == "non_streamed": + return await Runner.run(agent, input_value, session=session) + result = Runner.run_streamed(agent, input_value, session=session) + await consume_stream(result) + return result + + with pytest.raises(OutputGuardrailTripwireTriggered): + await run_once("Use commit_tool") + + saved_items = await session.get_items() + saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] + assert saved == ["user", "reasoning", "function_call", "function_call_output"] + + saved_reasoning_ids = [ + item.get("id") for item in saved_items if isinstance(item, dict) and item.get("id") + ] + assert "rs_committed" in saved_reasoning_ids + assert "rs_rejected" not in saved_reasoning_ids, ( + "reasoning tied to the rejected message must not be persisted" + ) + + # ...and the surviving group still replays in order, with no dangling reasoning item. + agent.output_guardrails = [] + model.set_next_output([get_text_message("done")]) + followup = await run_once("Continue") + assert followup.final_output == "done" + + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + replayed = [ + item.get("type") + for item in model_input + if isinstance(item, dict) + and item.get("type") in {"reasoning", "message", "function_call", "function_call_output"} + ] + assert replayed == ["reasoning", "function_call", "function_call_output"] + + @pytest.mark.asyncio async def test_streaming_resume_preserves_filtered_model_input_after_handoff(): model = FakeModel() From 8ca63f37d9c77e0b39f87441ac25f22fe650bd15 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 4 Aug 2026 09:13:13 +0900 Subject: [PATCH 128/473] fix(extensions): preserve thinking blocks for replay (#4157) Co-authored-by: Abhay Singh --- src/agents/models/chatcmpl_converter.py | 193 +++++++----- src/agents/models/reasoning_content_replay.py | 7 +- .../models/test_anthropic_thinking_blocks.py | 296 +++++++++++++++--- 3 files changed, 369 insertions(+), 127 deletions(-) diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index e03622e7a3..483cb9b736 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -2,6 +2,7 @@ import json from collections.abc import Iterable, Mapping +from copy import deepcopy from typing import Any, Literal, cast from openai import Omit, omit @@ -131,39 +132,52 @@ def message_to_output_items( """ items: list[TResponseOutputItem] = [] - # Check if message is agents.extensions.models.litellm_model.InternalChatCompletionMessage - # We can't actually import it here because litellm is an optional dependency - # So we use hasattr to check for reasoning_content and thinking_blocks - if hasattr(message, "reasoning_content") and message.reasoning_content: + # Check if message is agents.extensions.models.litellm_model.InternalChatCompletionMessage. + # We can't actually import it here because litellm is an optional dependency. + # So we use hasattr to check for reasoning_content and thinking_blocks. + reasoning_content = getattr(message, "reasoning_content", "") + raw_thinking_blocks = getattr(message, "thinking_blocks", None) + thinking_blocks = ( + [deepcopy(block) for block in raw_thinking_blocks if isinstance(block, dict)] + if isinstance(raw_thinking_blocks, list) + else [] + ) + + if reasoning_content or thinking_blocks: reasoning_kwargs: dict[str, Any] = { "id": FAKE_RESPONSES_ID, - "summary": [Summary(text=message.reasoning_content, type="summary_text")], + "summary": ( + [Summary(text=reasoning_content, type="summary_text")] + if reasoning_content + else [] + ), "type": "reasoning", } - # Add provider_data if available - if provider_data: - reasoning_kwargs["provider_data"] = provider_data + reasoning_provider_data = dict(provider_data or {}) + if thinking_blocks: + # The normalized reasoning fields below cannot represent empty thinking text or + # redacted_thinking blocks. Keep the complete provider sequence as the replay + # source of truth while retaining those released fields as derived data. + reasoning_provider_data["thinking_blocks"] = thinking_blocks + if reasoning_provider_data: + reasoning_kwargs["provider_data"] = reasoning_provider_data reasoning_item = ResponseReasoningItem(**reasoning_kwargs) - # Store thinking blocks for Anthropic compatibility - if hasattr(message, "thinking_blocks") and message.thinking_blocks: - # Store thinking text in content and signature in encrypted_content + # Retain the released normalized representation for callers and legacy histories. + if thinking_blocks: reasoning_item.content = [] signatures: list[str] = [] - for block in message.thinking_blocks: - if isinstance(block, dict): - thinking_text = block.get("thinking", "") - if thinking_text: - reasoning_item.content.append( - Content(text=thinking_text, type="reasoning_text") - ) - # Store the signature if present - if signature := block.get("signature"): - signatures.append(signature) + for block in thinking_blocks: + thinking_text = block.get("thinking", "") + if thinking_text: + reasoning_item.content.append( + Content(text=thinking_text, type="reasoning_text") + ) + if signature := block.get("signature"): + signatures.append(signature) - # Store the signatures in encrypted_content with newline delimiter if signatures: reasoning_item.encrypted_content = "\n".join(signatures) @@ -534,12 +548,14 @@ def items_to_messages( result: list[ChatCompletionMessageParam] = [] current_assistant_msg: ChatCompletionAssistantMessageParam | None = None - pending_thinking_blocks: list[dict[str, str]] | None = None + pending_thinking_blocks: list[dict[str, Any]] | None = None + pending_thinking_blocks_are_native = False pending_reasoning_content: str | None = None # For DeepSeek reasoning_content normalized_base_url = base_url.rstrip("/") if base_url is not None else None def flush_assistant_message(*, clear_pending_reasoning: bool = True) -> None: - nonlocal current_assistant_msg, pending_reasoning_content, pending_thinking_blocks + nonlocal current_assistant_msg, pending_reasoning_content + nonlocal pending_thinking_blocks, pending_thinking_blocks_are_native if current_assistant_msg is not None: # The API doesn't support empty arrays for tool_calls if not current_assistant_msg.get("tool_calls"): @@ -555,6 +571,37 @@ def flush_assistant_message(*, clear_pending_reasoning: bool = True) -> None: # reasoning item that is not directly followed by that turn's assistant # message must not leak its signed blocks into a later one. pending_thinking_blocks = None + pending_thinking_blocks_are_native = False + + def apply_pending_thinking_blocks( + assistant_msg: ChatCompletionAssistantMessageParam, + ) -> None: + nonlocal pending_thinking_blocks, pending_thinking_blocks_are_native + if not pending_thinking_blocks: + return + + if pending_thinking_blocks_are_native: + # LiteLLM's native field preserves the complete Anthropic block sequence, + # including empty thinking and redacted_thinking blocks. + assistant_msg["thinking_blocks"] = pending_thinking_blocks # type: ignore[typeddict-unknown-key] + else: + # Legacy stored reasoning items only contain normalized text and signatures. + # Preserve their released inline-content reconstruction behavior. + current_content = assistant_msg.get("content") + if isinstance(current_content, str): + text_content = ChatCompletionContentPartTextParam( + text=current_content, type="text" + ) + content_parts: list[Any] = [text_content] + elif current_content is None: + content_parts = [] + else: + content_parts = list(current_content) + + assistant_msg["content"] = pending_thinking_blocks + content_parts + + pending_thinking_blocks = None + pending_thinking_blocks_are_native = False def apply_pending_reasoning_content( assistant_msg: ChatCompletionAssistantMessageParam, @@ -571,6 +618,7 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: current_assistant_msg["content"] = None current_assistant_msg["tool_calls"] = [] + apply_pending_thinking_blocks(current_assistant_msg) apply_pending_reasoning_content(current_assistant_msg) return current_assistant_msg @@ -665,24 +713,7 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: combined = "\n".join(text_segments) new_asst["content"] = combined - # If we have pending thinking blocks, prepend them to the content - # This is required for Anthropic API with interleaved thinking - if pending_thinking_blocks: - # If there is a text content, convert it to a list to prepend thinking blocks - if "content" in new_asst and isinstance(new_asst["content"], str): - text_content = ChatCompletionContentPartTextParam( - text=new_asst["content"], type="text" - ) - new_asst["content"] = [text_content] - - if "content" not in new_asst or new_asst["content"] is None: - new_asst["content"] = [] - - # Thinking blocks MUST come before any other content - # We ignore type errors because pending_thinking_blocks is not openai standard - new_asst["content"] = pending_thinking_blocks + new_asst["content"] # type: ignore - pending_thinking_blocks = None # Clear after using - + apply_pending_thinking_blocks(new_asst) new_asst["tool_calls"] = [] apply_pending_reasoning_content(new_asst) current_assistant_msg = new_asst @@ -710,25 +741,6 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: elif func_call := cls.maybe_function_tool_call(item): asst = ensure_assistant_message() - # If we have pending thinking blocks, use them as the content - # This is required for Anthropic API tool calls with interleaved thinking - if pending_thinking_blocks: - # If there is a text content, save it to append after thinking blocks - # content type is Union[str, Iterable[ContentArrayOfContentPart], None] - if "content" in asst and isinstance(asst["content"], str): - text_content = ChatCompletionContentPartTextParam( - text=asst["content"], type="text" - ) - asst["content"] = [text_content] - - if "content" not in asst or asst["content"] is None: - asst["content"] = [] - - # Thinking blocks MUST come before any other content - # We ignore type errors because pending_thinking_blocks is not openai standard - asst["content"] = pending_thinking_blocks + asst["content"] # type: ignore - pending_thinking_blocks = None # Clear after using - tool_calls = list(asst.get("tool_calls", [])) arguments = func_call["arguments"] if func_call["arguments"] else "{}" new_tool_call = ChatCompletionMessageFunctionToolCallParam( @@ -807,38 +819,49 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: item_provider_data: dict[str, Any] = reasoning_item.get("provider_data", {}) # type: ignore[assignment] item_model = item_provider_data.get("model", "") + origin_provider_data = { + key: value + for key, value in item_provider_data.items() + if key != "thinking_blocks" + } should_replay = False if ( model and ("claude" in model.lower() or "anthropic" in model.lower()) - and content_items and preserve_thinking_blocks # Items may not all originate from Claude, so we need to check for model match. - # For backward compatibility, if provider_data is missing, we ignore the check. - and (model == item_model or item_provider_data == {}) + # New thinking-block metadata alone does not establish a conflicting origin, + # but other provider metadata without a model must remain origin-unknown. + and (model == item_model or not origin_provider_data) ): - signatures = encrypted_content.split("\n") if encrypted_content else [] - - # Reconstruct thinking blocks from content and signature - reconstructed_thinking_blocks = [] - for content_item in content_items: - if ( - isinstance(content_item, dict) - and content_item.get("type") == "reasoning_text" - ): - thinking_block = { - "type": "thinking", - "thinking": content_item.get("text", ""), - } - # Add signatures if available - if signatures: - thinking_block["signature"] = signatures.pop(0) - reconstructed_thinking_blocks.append(thinking_block) - - # Store thinking blocks as pending for the next assistant message - # This preserves the original behavior - pending_thinking_blocks = reconstructed_thinking_blocks + complete_thinking_blocks = item_provider_data.get("thinking_blocks") + if ( + isinstance(complete_thinking_blocks, list) + and complete_thinking_blocks + and all(isinstance(block, dict) for block in complete_thinking_blocks) + ): + pending_thinking_blocks = deepcopy(complete_thinking_blocks) + pending_thinking_blocks_are_native = True + elif content_items: + signatures = encrypted_content.split("\n") if encrypted_content else [] + + reconstructed_thinking_blocks: list[dict[str, Any]] = [] + for content_item in content_items: + if ( + isinstance(content_item, dict) + and content_item.get("type") == "reasoning_text" + ): + thinking_block = { + "type": "thinking", + "thinking": content_item.get("text", ""), + } + if signatures: + thinking_block["signature"] = signatures.pop(0) + reconstructed_thinking_blocks.append(thinking_block) + + pending_thinking_blocks = reconstructed_thinking_blocks + pending_thinking_blocks_are_native = False if model is not None: replay_context = ReasoningContentReplayContext( diff --git a/src/agents/models/reasoning_content_replay.py b/src/agents/models/reasoning_content_replay.py index 0f46b3d8f5..42335058e4 100644 --- a/src/agents/models/reasoning_content_replay.py +++ b/src/agents/models/reasoning_content_replay.py @@ -46,9 +46,14 @@ def default_should_replay_reasoning_content(context: ReasoningContentReplayConte # Replay only when the current request targets DeepSeek and the reasoning item either # came from a DeepSeek model or predates provider tracking. This avoids mixing reasoning # content from a different model family into the DeepSeek assistant message. + provider_data_without_thinking_blocks = { + key: value + for key, value in context.reasoning.provider_data.items() + if key != "thinking_blocks" + } return ( origin_model is not None and "deepseek" in origin_model.lower() - ) or context.reasoning.provider_data == {} + ) or not provider_data_without_thinking_blocks __all__ = [ diff --git a/tests/models/test_anthropic_thinking_blocks.py b/tests/models/test_anthropic_thinking_blocks.py index 39986f1a2e..31088cf546 100644 --- a/tests/models/test_anthropic_thinking_blocks.py +++ b/tests/models/test_anthropic_thinking_blocks.py @@ -12,10 +12,16 @@ from typing import Any, cast +import httpx +from litellm.llms.anthropic.chat.transformation import AnthropicConfig +from litellm.types.utils import ModelResponse as LiteLLMModelResponse from openai.types.chat import ChatCompletionMessageToolCall from openai.types.chat.chat_completion_message_tool_call import Function -from agents.extensions.models.litellm_model import InternalChatCompletionMessage +from agents.extensions.models.litellm_model import ( + InternalChatCompletionMessage, + LitellmConverter, +) from agents.models.chatcmpl_converter import Converter @@ -36,6 +42,99 @@ def create_mock_anthropic_response_with_thinking() -> InternalChatCompletionMess return message +def _assistant_thinking_blocks(message: Any) -> list[dict[str, Any]]: + thinking_blocks = message.get("thinking_blocks") + assert isinstance(thinking_blocks, list) + assert all(isinstance(block, dict) for block in thinking_blocks) + return cast(list[dict[str, Any]], thinking_blocks) + + +def _litellm_anthropic_message( + thinking_blocks: list[dict[str, Any]], + *, + tool_call: bool = False, +) -> Any: + response_tail = ( + { + "type": "tool_use", + "id": "toolu-weather", + "name": "get_weather", + "input": {"city": "Tokyo"}, + } + if tool_call + else {"type": "text", "text": "answer"} + ) + completion_response = { + "id": "msg-thinking", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [*thinking_blocks, response_tail], + "stop_reason": "tool_use" if tool_call else "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + raw_response = httpx.Response( + 200, + request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"), + ) + response = AnthropicConfig().transform_parsed_response( + completion_response=completion_response, + raw_response=raw_response, + model_response=LiteLLMModelResponse(model="claude-sonnet-4-5"), + ) + return response.choices[0].message + + +def _round_trip_litellm_anthropic_blocks( + thinking_blocks: list[dict[str, Any]], + *, + tool_call: bool = False, +) -> tuple[Any, list[dict[str, Any]]]: + litellm_message = _litellm_anthropic_message(thinking_blocks, tool_call=tool_call) + internal_message = LitellmConverter.convert_message_to_openai( + litellm_message, + model="anthropic/claude-sonnet-4-5", + ) + output_items = Converter.message_to_output_items( + internal_message, + provider_data={"model": "anthropic/claude-sonnet-4-5"}, + ) + serialized_items: list[Any] = [item.model_dump() for item in output_items] + messages = Converter.items_to_messages( + serialized_items, + model="anthropic/claude-sonnet-4-5", + preserve_thinking_blocks=True, + ) + outbound_request = AnthropicConfig().transform_request( + model="claude-sonnet-4-5", + messages=cast(Any, [*messages, {"role": "user", "content": "continue"}]), + optional_params={ + "max_tokens": 1024, + **( + { + "tools": [ + { + "name": "get_weather", + "description": "Get the weather.", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + } + ] + } + if tool_call + else {} + ), + }, + litellm_params={}, + headers={}, + ) + return output_items, cast(list[dict[str, Any]], outbound_request["messages"]) + + def test_converter_skips_reasoning_items(): """ Unit test to verify that reasoning items are skipped when converting items to messages. @@ -92,6 +191,9 @@ def test_reasoning_items_preserved_in_message_conversion(): reasoning_item = reasoning_items[0] assert reasoning_item.summary[0].text == "I need to call the weather function for Paris" + assert reasoning_item.model_dump()["provider_data"]["thinking_blocks"] == ( + mock_message.thinking_blocks + ) # Verify thinking blocks are stored if we preserve them if ( @@ -197,15 +299,11 @@ def test_anthropic_thinking_blocks_with_tool_calls(): assistant_msg = assistant_messages[0] - # Content must start with thinking blocks, not text - content = assistant_msg.get("content") - assert content is not None, "Assistant message should have content" - - assert isinstance(content, list) and len(content) > 0, ( - "Assistant message content should be a non-empty list" - ) + # Thinking blocks must remain ahead of text in Anthropic's native block sequence. + thinking_blocks = _assistant_thinking_blocks(assistant_msg) + assert thinking_blocks, "Assistant message should have thinking blocks" - first_content = content[0] + first_content = thinking_blocks[0] assert first_content.get("type") == "thinking", ( f"First content must be 'thinking' type for Anthropic compatibility, " f"but got '{first_content.get('type')}'" @@ -216,12 +314,11 @@ def test_anthropic_thinking_blocks_with_tool_calls(): assert first_content.get("thinking") == expected_thinking, ( "Thinking content should be preserved" ) - # Signature should also be preserved assert first_content.get("signature") == "TestSignature123", ( "Signature should be preserved in thinking block" ) - second_content = content[1] + second_content = thinking_blocks[1] assert second_content.get("type") == "thinking", ( f"Second content must be 'thinking' type for Anthropic compatibility, " f"but got '{second_content.get('type')}'" @@ -230,17 +327,12 @@ def test_anthropic_thinking_blocks_with_tool_calls(): assert second_content.get("thinking") == expected_thinking, ( "Thinking content should be preserved" ) - # Signature should also be preserved assert second_content.get("signature") == "TestSignature456", ( "Signature should be preserved in thinking block" ) - last_content = content[2] - assert last_content.get("type") == "text", ( - f"First content must be 'text' type but got '{last_content.get('type')}'" - ) expected_text = "I'll check the weather for you." - assert last_content.get("text") == expected_text, "Content text should be preserved" + assert assistant_msg.get("content") == expected_text, "Content text should be preserved" # Verify tool calls are preserved tool_calls = assistant_msg.get("tool_calls", []) @@ -296,11 +388,8 @@ def test_items_to_messages_preserves_positional_bool_arguments(): assert len(assistant_messages) == 1, "Should have exactly one assistant message with tool calls" assistant_msg = assistant_messages[0] - content = assistant_msg.get("content") - assert isinstance(content, list) and len(content) > 0, ( - "Positional bool arguments should still preserve thinking blocks" - ) - assert content[0].get("type") == "thinking", ( + thinking_blocks = _assistant_thinking_blocks(assistant_msg) + assert thinking_blocks[0].get("type") == "thinking", ( "The third positional argument must continue to map to preserve_thinking_blocks" ) @@ -383,20 +472,13 @@ def test_anthropic_thinking_blocks_without_tool_calls(): assistant_msg = assistant_messages[0] - # Content must start with thinking blocks even WITHOUT tool calls - content = assistant_msg.get("content") - assert content is not None, "Assistant message should have content" - assert isinstance(content, list), ( - f"Assistant message content should be a list when thinking blocks are present, " - f"but got {type(content)}" - ) - assert len(content) >= 2, ( - f"Assistant message should have at least 2 content items " - f"(thinking + text), got {len(content)}" + # Thinking blocks stay in LiteLLM's native field even without tool calls. + thinking_blocks = _assistant_thinking_blocks(assistant_msg) + assert len(thinking_blocks) == 1, ( + f"Assistant message should have exactly one thinking block, got {len(thinking_blocks)}" ) - # First content should be thinking block - first_content = content[0] + first_content = thinking_blocks[0] assert first_content.get("type") == "thinking", ( f"First content must be 'thinking' type for Anthropic compatibility, " f"but got '{first_content.get('type')}'" @@ -408,16 +490,148 @@ def test_anthropic_thinking_blocks_without_tool_calls(): "Signature should be preserved in thinking block" ) - # Second content should be text - second_content = content[1] - assert second_content.get("type") == "text", ( - f"Second content must be 'text' type, but got '{second_content.get('type')}'" - ) - assert ( - second_content.get("text") == "The weather in Paris is sunny with a temperature of 22°C." + assert assistant_msg.get("content") == ( + "The weather in Paris is sunny with a temperature of 22°C." ), "Text content should be preserved" +def test_litellm_round_trip_preserves_omitted_thinking_block() -> None: + thinking_blocks = [{"type": "thinking", "thinking": "", "signature": "OmittedSignature"}] + + output_items, outbound_messages = _round_trip_litellm_anthropic_blocks(thinking_blocks) + + reasoning_items = [item for item in output_items if item.type == "reasoning"] + assert len(reasoning_items) == 1 + assert reasoning_items[0].summary == [] + assert reasoning_items[0].model_dump()["provider_data"]["thinking_blocks"] == thinking_blocks + assert outbound_messages[0]["content"][:1] == thinking_blocks + + +def test_litellm_round_trip_preserves_complete_thinking_block_sequence() -> None: + thinking_blocks = [ + {"type": "thinking", "thinking": "", "signature": "OmittedSignature"}, + {"type": "redacted_thinking", "data": "EncryptedRedactedThinking"}, + {"type": "thinking", "thinking": "visible", "signature": "VisibleSignature"}, + ] + + _, outbound_messages = _round_trip_litellm_anthropic_blocks( + thinking_blocks, + tool_call=True, + ) + + assert outbound_messages[0]["content"][:3] == thinking_blocks + assert outbound_messages[0]["content"][3] == { + "type": "tool_use", + "id": "toolu-weather", + "name": "get_weather", + "input": {"city": "Tokyo"}, + } + + +def test_complete_thinking_blocks_respect_replay_guards() -> None: + message = InternalChatCompletionMessage( + role="assistant", + content="answer", + reasoning_content="visible", + thinking_blocks=[{"type": "thinking", "thinking": "visible", "signature": "Signature"}], + ) + items: list[Any] = [ + item.model_dump() + for item in Converter.message_to_output_items( + message, + provider_data={"model": "anthropic/claude-sonnet-4-5"}, + ) + ] + + disabled_messages = Converter.items_to_messages( + items, + model="anthropic/claude-sonnet-4-5", + preserve_thinking_blocks=False, + ) + mismatched_messages = Converter.items_to_messages( + items, + model="anthropic/claude-opus-4-5", + preserve_thinking_blocks=True, + ) + unknown_origin_items: list[Any] = [ + item.model_dump() + for item in Converter.message_to_output_items( + message, + provider_data={"response_id": "response-from-unknown-provider"}, + ) + ] + unknown_origin_messages = Converter.items_to_messages( + unknown_origin_items, + model="anthropic/claude-sonnet-4-5", + preserve_thinking_blocks=True, + ) + + assert all("thinking_blocks" not in message for message in disabled_messages) + assert all("thinking_blocks" not in message for message in mismatched_messages) + assert all("thinking_blocks" not in message for message in unknown_origin_messages) + + +def test_originless_thinking_blocks_preserve_legacy_deepseek_reasoning_replay() -> None: + message = InternalChatCompletionMessage( + role="assistant", + content="answer", + reasoning_content="legacy reasoning", + thinking_blocks=[{"type": "thinking", "thinking": "visible", "signature": "Signature"}], + ) + items: list[Any] = [item.model_dump() for item in Converter.message_to_output_items(message)] + + reasoning_item = next(item for item in items if item["type"] == "reasoning") + assert reasoning_item["provider_data"] == { + "thinking_blocks": [{"type": "thinking", "thinking": "visible", "signature": "Signature"}] + } + + messages = Converter.items_to_messages(items, model="deepseek/deepseek-reasoner") + + assistant_message = next(message for message in messages if message["role"] == "assistant") + assert assistant_message["reasoning_content"] == "legacy reasoning" # type: ignore[typeddict-item] + assert "thinking_blocks" not in assistant_message + + +def test_legacy_reasoning_item_reconstructs_inline_thinking_blocks() -> None: + for provider_data in (None, {"thinking_blocks": ["invalid-block"]}): + reasoning_item: dict[str, Any] = { + "id": "reasoning_legacy", + "type": "reasoning", + "summary": [], + "content": [{"type": "reasoning_text", "text": "legacy thinking"}], + "encrypted_content": "LegacySignature", + } + if provider_data is not None: + reasoning_item["provider_data"] = provider_data + + history: list[dict[str, Any]] = [ + reasoning_item, + { + "id": "message_legacy", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "answer", "annotations": []}], + }, + ] + + messages = Converter.items_to_messages( + history, # type: ignore[arg-type] + model="anthropic/claude-sonnet-4-5", + preserve_thinking_blocks=True, + ) + + assert messages[0]["content"] == [ + { + "type": "thinking", + "thinking": "legacy thinking", + "signature": "LegacySignature", + }, + {"type": "text", "text": "answer"}, + ] + assert "thinking_blocks" not in messages[0] + + def test_thinking_blocks_do_not_leak_across_an_intervening_user_turn(): """A reasoning item not followed by its own assistant message must not leak. From 648c25284bc5201bf7e733a7bae777330b6a9598 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 4 Aug 2026 11:15:59 +0900 Subject: [PATCH 129/473] fix: reconcile resumed tool name collisions (#4145) --- src/agents/_tool_identity.py | 28 + src/agents/agent.py | 26 +- src/agents/run_internal/turn_resolution.py | 865 +++++++++--- tests/test_tool_name_collision_policy.py | 1424 ++++++++++++++++++++ 4 files changed, 2143 insertions(+), 200 deletions(-) create mode 100644 tests/test_tool_name_collision_policy.py diff --git a/src/agents/_tool_identity.py b/src/agents/_tool_identity.py index 1a557a788c..bb67757f03 100644 --- a/src/agents/_tool_identity.py +++ b/src/agents/_tool_identity.py @@ -206,6 +206,34 @@ def _remove_tool_call_namespace(tool_call: Any) -> Any: return tool_call +def restore_tool_call_routing_identity( + tool_call: Any, + lookup_key: FunctionToolLookupKey | None, +) -> Any: + """Fill an absent call namespace from a persisted lookup key.""" + if lookup_key is None or get_tool_call_name(tool_call) != lookup_key[-1]: + return tool_call + if get_tool_call_namespace(tool_call) is not None or lookup_key[0] == "bare": + return tool_call + + namespace = lookup_key[1] + if isinstance(tool_call, dict): + restored = dict(tool_call) + restored["namespace"] = namespace + return restored + + model_dump = getattr(tool_call, "model_dump", None) + if callable(model_dump): + payload = model_dump(exclude_unset=True) + if isinstance(payload, dict): + payload["namespace"] = namespace + try: + return type(tool_call)(**payload) + except Exception: + return payload + return tool_call + + def has_function_tool_shape(tool: Any) -> bool: """Return True when the object looks like a FunctionTool instance.""" return callable(getattr(tool, "on_invoke_tool", None)) and isinstance( diff --git a/src/agents/agent.py b/src/agents/agent.py index 778312fdf8..fc822bf891 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -1,9 +1,11 @@ from __future__ import annotations import asyncio +import contextvars import dataclasses import inspect -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator, Sequence +from contextlib import contextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, cast @@ -93,6 +95,11 @@ class ToolsToFinalOutputResult: """ +_mcp_handoff_snapshot: contextvars.ContextVar[ + tuple[object, tuple[Handoff[Any, Any], ...]] | None +] = contextvars.ContextVar("mcp_handoff_snapshot", default=None) + + def _validate_codex_tool_name_collisions(tools: list[Tool]) -> None: codex_tool_names = { tool.name @@ -205,6 +212,11 @@ async def _get_mcp_tool_reserved_names( ) -> set[str]: reserved_tool_names = {tool.name for tool in self.tools if isinstance(tool, FunctionTool)} + snapshot = _mcp_handoff_snapshot.get() + if snapshot is not None and snapshot[0] is self: + reserved_tool_names.update(handoff.tool_name for handoff in snapshot[1]) + return reserved_tool_names + async def _check_handoff_enabled(handoff_obj: Handoff[Any, Any]) -> bool: attr = handoff_obj.is_enabled if isinstance(attr, bool): @@ -222,6 +234,18 @@ async def _check_handoff_enabled(handoff_obj: Handoff[Any, Any]) -> bool: reserved_tool_names.add(Handoff.default_tool_name(handoff_item)) return reserved_tool_names + @contextmanager + def _use_mcp_handoff_snapshot( + self, + enabled_handoffs: Sequence[Handoff[Any, Any]], + ) -> Iterator[None]: + """Keep MCP reserved-name generation on one enabled-handoff snapshot.""" + token = _mcp_handoff_snapshot.set((self, tuple(enabled_handoffs))) + try: + yield + finally: + _mcp_handoff_snapshot.reset(token) + async def get_mcp_tools(self, run_context: RunContextWrapper[TContext]) -> list[Tool]: """Fetches the available tools from the MCP servers.""" convert_schemas_to_strict = self.mcp_config.get("convert_schemas_to_strict", False) diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index d95a8fcd6c..a103513eed 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -3,6 +3,8 @@ import asyncio import inspect from collections.abc import Awaitable, Callable, Container, Mapping, Sequence +from copy import deepcopy +from dataclasses import replace from typing import Any, Literal, cast from openai.types.responses import ( @@ -32,20 +34,26 @@ from .. import _debug from .._mcp_tool_metadata import collect_mcp_list_tools_metadata from .._tool_identity import ( + FunctionToolLookupKey, build_function_tool_lookup_map, get_function_tool_lookup_key, get_function_tool_lookup_key_for_call, get_function_tool_lookup_key_for_tool, - get_function_tool_qualified_name, get_tool_call_namespace, get_tool_call_qualified_name, get_tool_call_trace_name, - normalize_tool_call_for_function_tool, + resolve_tool_name_collisions, + restore_tool_call_routing_identity, should_allow_bare_name_approval_alias, ) from ..agent import Agent, ToolsToFinalOutputResult from ..agent_output import AgentOutputSchemaBase -from ..agent_tool_state import get_agent_tool_state_scope, peek_agent_tool_run_result +from ..agent_tool_state import ( + drop_agent_tool_run_result, + get_agent_tool_state_scope, + peek_agent_tool_run_result, + record_agent_tool_run_result, +) from ..exceptions import ModelBehaviorError, ModelRefusalError, UserError from ..handoffs import Handoff, HandoffInputData, HandoffInputFilter, nest_handoff_history from ..handoffs.history import ( @@ -150,7 +158,6 @@ parse_apply_patch_function_args, process_hosted_mcp_approvals, resolve_approval_rejection_message, - resolve_enabled_function_tools, should_keep_hosted_mcp_item, ) from .tool_planning import ( @@ -166,6 +173,7 @@ _make_unique_item_appender, _select_function_tool_runs_for_resume, ) +from .turn_preparation import get_handoffs, get_output_schema _DEFAULT_NEST_HANDOFF_HISTORY = nest_handoff_history @@ -1057,7 +1065,10 @@ async def _record_function_rejection( call_id=call_id, tool_namespace=tool_namespace, tool_lookup_key=get_function_tool_lookup_key_for_tool(function_tool), - existing_pending=approval_items_by_call_id.get(call_id), + existing_pending=( + function_approval_items_by_call_id.get(call_id) + or approval_items_by_call_id.get(call_id) + ), ) rejected_function_outputs.append( function_rejection_item( @@ -1102,6 +1113,7 @@ async def _function_requires_approval(run: ToolRunFunction) -> bool: rerun_function_call_ids: set[str] = set() pending_interruptions: list[ToolApprovalItem] = [] pending_interruption_keys: set[str] = set() + stable_function_approval_sources: dict[int, ToolApprovalItem] = {} output_index = _build_tool_output_index(original_pre_step_items) @@ -1280,6 +1292,19 @@ def _function_output_exists(run: ToolRunFunction) -> bool: def _add_pending_interruption(item: ToolApprovalItem | None) -> None: if item is None: return + source_item = stable_function_approval_sources.get(id(item)) + if source_item is not None: + source_item.agent = item.agent + source_item.raw_item = cast( + ResponseFunctionToolCall, + item.raw_item, + ).model_copy(deep=True) + source_item.tool_name = item.tool_name + source_item.tool_namespace = item.tool_namespace + source_item.tool_origin = item.tool_origin + source_item.tool_lookup_key = item.tool_lookup_key + source_item._allow_bare_name_alias = item._allow_bare_name_alias + item = source_item call_id = extract_tool_call_id(item.raw_item) key = call_id or f"raw:{id(item.raw_item)}" if key in pending_interruption_keys: @@ -1311,155 +1336,552 @@ def _approval_matches_agent(approval: ToolApprovalItem) -> bool: return True return allow_legacy_name_agent_match and approval_agent.name == public_agent.name - available_function_tools = await resolve_enabled_function_tools( - execution_agent, - context_wrapper, - ) - approval_rebuild_function_tools = available_function_tools - if pending_approval_items and execution_agent.mcp_servers: - approval_rebuild_function_tools = [ - tool - for tool in await execution_agent.get_all_tools(context_wrapper) - if isinstance(tool, FunctionTool) + def _approval_persisted_lookup_key( + approval: ToolApprovalItem, + ) -> FunctionToolLookupKey | None: + persisted_key = approval.tool_lookup_key + if persisted_key is not None: + return persisted_key + if not approval.tool_name: + return None + return get_function_tool_lookup_key( + approval.tool_name, + approval.tool_namespace, + ) + + queued_call_id_counts: dict[str, int] = {} + queued_call_items = [ + *(run.tool_call for run in processed_response.functions), + *(run.tool_call for run in processed_response.handoffs), + *(run.tool_call for run in processed_response.computer_actions), + *(run.tool_call for run in processed_response.custom_tool_calls), + *(run.tool_call for run in processed_response.local_shell_calls), + *(run.tool_call for run in processed_response.shell_calls), + *(run.tool_call for run in processed_response.apply_patch_calls), + *(run.request_item for run in processed_response.mcp_approval_requests), + *(run.tool_call for run in processed_response.function_tools_not_found), + ] + for queued_call_item in queued_call_items: + queued_call_id = extract_tool_call_id(queued_call_item) + if queued_call_id is not None: + queued_call_id_counts[queued_call_id] = queued_call_id_counts.get(queued_call_id, 0) + 1 + duplicate_queued_call_ids = { + call_id for call_id, count in queued_call_id_counts.items() if count > 1 + } + non_function_owned_call_ids = { + call_id + for call_item in [ + *(run.tool_call for run in processed_response.computer_actions), + *(run.tool_call for run in processed_response.custom_tool_calls), + *(run.tool_call for run in processed_response.local_shell_calls), + *(run.tool_call for run in processed_response.shell_calls), + *(run.tool_call for run in processed_response.apply_patch_calls), + *(run.request_item for run in processed_response.mcp_approval_requests), ] - program_call_ids, completed_program_call_ids = _collect_program_parent_state( - [*original_pre_step_items, *new_response.output], - server_manages_conversation=server_manages_conversation, + if (call_id := extract_tool_call_id(call_item)) is not None + } + + stable_function_call_sources: dict[int, ResponseFunctionToolCall] = {} + stable_function_nested_results: dict[int, Any] = {} + + def _snapshot_function_run(run: ToolRunFunction) -> ToolRunFunction: + if run.tool_call.call_id in duplicate_queued_call_ids: + return run + stable_call = run.tool_call.model_copy(deep=True) + stable_function_call_sources[id(stable_call)] = run.tool_call + nested_result = peek_agent_tool_run_result( + run.tool_call, + scope_id=tool_state_scope_id, + ) + if nested_result is not None: + stable_function_nested_results[id(stable_call)] = nested_result + return ToolRunFunction( + tool_call=stable_call, + function_tool=run.function_tool, + ) + + stable_function_runs = [_snapshot_function_run(run) for run in processed_response.functions] + stable_handoff_runs = [ + run + if run.tool_call.call_id in duplicate_queued_call_ids + else ToolRunHandoff( + handoff=run.handoff, + tool_call=run.tool_call.model_copy(deep=True), + ) + for run in processed_response.handoffs + ] + response_function_calls = [ + output.model_copy(deep=True) + for output in new_response.output + if isinstance(output, ResponseFunctionToolCall) + ] + authoritative_response_function_calls = [ + call for call in response_function_calls if call.call_id not in non_function_owned_call_ids + ] + response_call_positions: dict[str, int] = {} + for index, output in enumerate(new_response.output): + output_call_id = extract_tool_call_id(output) + if output_call_id is not None: + response_call_positions.setdefault(output_call_id, index) + + queued_function_call_ids_by_lookup_key: dict[FunctionToolLookupKey, set[str]] = {} + queued_function_lookup_keys_by_call_id: dict[str, set[FunctionToolLookupKey]] = {} + canonical_queued_function_calls: dict[str, ResponseFunctionToolCall] = {} + authoritative_function_calls = [ + *(run.tool_call for run in stable_function_runs), + *authoritative_response_function_calls, + ] + for function_call in authoritative_function_calls: + lookup_key = get_function_tool_lookup_key_for_call(function_call) + if lookup_key is None: + continue + queued_function_call_ids_by_lookup_key.setdefault(lookup_key, set()).add( + function_call.call_id + ) + queued_function_lookup_keys_by_call_id.setdefault( + function_call.call_id, + set(), + ).add(lookup_key) + for function_run in stable_function_runs: + if function_run.tool_call.call_id not in duplicate_queued_call_ids: + canonical_queued_function_calls[function_run.tool_call.call_id] = function_run.tool_call + for response_call in authoritative_response_function_calls: + if response_call.call_id not in duplicate_queued_call_ids: + canonical_queued_function_calls.setdefault(response_call.call_id, response_call) + queued_function_call_ids = { + call_id + for call_ids in queued_function_call_ids_by_lookup_key.values() + for call_id in call_ids + } + + def _is_function_approval(approval: ToolApprovalItem) -> bool: + call_id = extract_tool_call_id(approval.raw_item) + if call_id in non_function_owned_call_ids and call_id not in queued_function_call_ids: + return False + if get_mapping_or_attr(approval.raw_item, "type") == "function_call": + return True + return approval.tool_lookup_key is not None or call_id in queued_function_call_ids + + function_approval_items = [ + approval + for approval in pending_approval_items + if _approval_matches_agent(approval) and _is_function_approval(approval) + ] + + def _validate_approval_identity(approval: ToolApprovalItem) -> None: + persisted_key = _approval_persisted_lookup_key(approval) + raw_name = get_mapping_or_attr(approval.raw_item, "name") + if persisted_key is None or not isinstance(raw_name, str) or not raw_name: + return + raw_namespace = get_tool_call_namespace(approval.raw_item) + raw_key = get_function_tool_lookup_key(raw_name, raw_namespace) + if persisted_key[-1] == raw_name and (raw_namespace is None or persisted_key == raw_key): + return + raw_identity = get_tool_call_qualified_name(approval.raw_item) or raw_name + raise ModelBehaviorError( + f"Persisted tool identity {persisted_key!r} does not match raw tool call " + f"{raw_identity}. Restore a consistent RunState before resuming." + ) + + def _coerce_approval_call( + approval: ToolApprovalItem, + ) -> ResponseFunctionToolCall | None: + raw = approval.raw_item + if get_mapping_or_attr(raw, "type") != "function_call": + return None + name = get_mapping_or_attr(raw, "name") + call_id = get_mapping_or_attr(raw, "call_id") + arguments = get_mapping_or_attr(raw, "arguments") + if not ( + isinstance(name, str) + and isinstance(call_id, str) + and call_id + and isinstance(arguments, str) + ): + return None + payload: dict[str, Any] = { + "type": "function_call", + "name": name, + "call_id": call_id, + "arguments": arguments, + } + status = get_mapping_or_attr(raw, "status") + if status in ("in_progress", "completed", "incomplete"): + payload["status"] = status + namespace = get_tool_call_namespace(raw) + if namespace is not None: + payload["namespace"] = namespace + caller = get_mapping_or_attr(raw, "caller") + if caller is not None: + payload["caller"] = deepcopy(caller) + item_id = get_mapping_or_attr(raw, "id") + if isinstance(item_id, str): + payload["id"] = item_id + call = ResponseFunctionToolCall(**payload) + + persisted_key = _approval_persisted_lookup_key(approval) + if get_tool_call_namespace(call) is None and persisted_key is not None: + call = cast( + ResponseFunctionToolCall, + restore_tool_call_routing_identity(call, persisted_key), + ) + return call + + validated_function_approval_items: dict[ToolApprovalItem, ToolApprovalItem] = {} + malformed_function_approvals: list[ToolApprovalItem] = [] + for approval in function_approval_items: + _validate_approval_identity(approval) + approval_call = _coerce_approval_call(approval) + if approval_call is None: + malformed_function_approvals.append(approval) + continue + persisted_key = _approval_persisted_lookup_key(approval) + queued_lookup_keys = queued_function_lookup_keys_by_call_id.get( + approval_call.call_id, + set(), + ) + if queued_lookup_keys and ( + persisted_key is None or persisted_key not in queued_lookup_keys + ): + malformed_function_approvals.append(approval) + continue + queued_call_ids = ( + queued_function_call_ids_by_lookup_key.get(persisted_key, set()) + if persisted_key is not None + else set() + ) + if queued_call_ids and approval_call.call_id not in queued_call_ids: + malformed_function_approvals.append(approval) + continue + stable_approval = ToolApprovalItem( + agent=public_agent, + raw_item=approval_call, + tool_name=approval.tool_name, + tool_namespace=approval.tool_namespace, + tool_origin=approval.tool_origin, + tool_lookup_key=approval.tool_lookup_key, + _allow_bare_name_alias=approval._allow_bare_name_alias, + ) + validated_function_approval_items[approval] = stable_approval + stable_function_approval_sources[id(stable_approval)] = approval + + if malformed_function_approvals: + processed_response.interruptions = list(pending_approval_items) + return SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=original_pre_step_items, + new_step_items=[], + next_step=NextStepInterruption(interruptions=list(pending_approval_items)), + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + processed_response=processed_response, + ) + + function_approval_items = list(validated_function_approval_items.values()) + function_approval_items_by_call_id = { + cast(ResponseFunctionToolCall, approval.raw_item).call_id: approval + for approval in function_approval_items + } + + classifier_context_items = [ + deepcopy(output) + for output in new_response.output + if get_mapping_or_attr(output, "type") in ("program", "program_output") + ] + classifier_existing_items = cast( + Sequence[RunItem], + [deepcopy(getattr(item, "raw_item", item)) for item in original_pre_step_items], ) - programmatic_tool_present = any( - isinstance(tool, ProgrammaticToolCallingTool) for tool in execution_agent.tools + classifier_server_managed_input_items = ( + deepcopy(ItemHelpers.input_to_new_input_list(original_input)) + if server_manages_conversation + else None ) - async def _rebuild_function_runs_from_approvals() -> list[ToolRunFunction]: - if not pending_approval_items: - return [] - tool_map = build_function_tool_lookup_map(approval_rebuild_function_tools) - existing_pending_call_ids: set[str] = set() - for existing_pending in pending_interruptions: - if isinstance(existing_pending, ToolApprovalItem): - existing_call_id = extract_tool_call_id(existing_pending.raw_item) - if existing_call_id: - existing_pending_call_ids.add(existing_call_id) - rebuilt_runs: list[ToolRunFunction] = [] - - def _add_unmatched_pending(approval: ToolApprovalItem) -> None: - call_id = extract_tool_call_id(approval.raw_item) - if not call_id: - _add_pending_interruption(approval) - return - tool_name = approval.tool_name or "" - approval_status = context_wrapper.get_approval_status( - tool_name, + available_handoffs = await get_handoffs(execution_agent, context_wrapper) + with execution_agent._use_mcp_handoff_snapshot(available_handoffs): + current_tool_inventory = await execution_agent.get_all_tools(context_wrapper) + resolved_tools, resolved_handoffs = resolve_tool_name_collisions( + current_tool_inventory, + available_handoffs, + collision_policy=run_config.tool_name_collision_policy, + ) + resolved_function_tools = [tool for tool in resolved_tools if isinstance(tool, FunctionTool)] + local_function_tool_ids = { + id(tool) for tool in execution_agent.tools if isinstance(tool, FunctionTool) + } + available_function_tools = [ + tool for tool in resolved_function_tools if id(tool) in local_function_tool_ids + ] + + stale_functions = { + run.tool_call.call_id: run + for run in stable_function_runs + if run.tool_call.call_id not in duplicate_queued_call_ids + } + executed_handoff_call_ids = { + executed_handoff_call_id + for item in original_pre_step_items + if isinstance(item, HandoffOutputItem) + and (executed_handoff_call_id := extract_tool_call_id(item.raw_item)) is not None + } + + calls_to_reconcile: list[ResponseFunctionToolCall] = [] + reconciled_call_ids: set[str] = set() + + def _append_reconciliation_call(call: ResponseFunctionToolCall) -> None: + call_id = call.call_id + if ( + call_id in duplicate_queued_call_ids + or call_id in reconciled_call_ids + or call_id in executed_handoff_call_ids + ): + return + stale_run = stale_functions.get(call_id) + nested_result = ( + peek_agent_tool_run_result(stale_run.tool_call, scope_id=tool_state_scope_id) + if stale_run is not None + else None + ) + if _has_output_item(call_id, "function_call_output") and not ( + nested_result and getattr(nested_result, "interruptions", None) + ): + return + approval = function_approval_items_by_call_id.get(call_id) + queued_call = canonical_queued_function_calls.get(call_id) + if queued_call is not None: + call = queued_call.model_copy(deep=True) + elif approval is not None: + call = cast(ResponseFunctionToolCall, approval.raw_item) + reconciled_call_ids.add(call_id) + calls_to_reconcile.append(call) + + for output in authoritative_response_function_calls: + _append_reconciliation_call(output) + for function_run in stable_function_runs: + _append_reconciliation_call(function_run.tool_call) + for handoff_run in stable_handoff_runs: + _append_reconciliation_call(handoff_run.tool_call) + for approval_item in function_approval_items: + _append_reconciliation_call(cast(ResponseFunctionToolCall, approval_item.raw_item)) + + classifier_tools: list[Tool] = [*resolved_function_tools] + classifier_tools.extend( + tool for tool in resolved_tools if isinstance(tool, ProgrammaticToolCallingTool) + ) + classifier_response = ModelResponse( + output=cast(Any, [*classifier_context_items, *calls_to_reconcile]), + usage=new_response.usage, + response_id=new_response.response_id, + request_id=new_response.request_id, + ) + classified = process_model_response( + agent=public_agent, + all_tools=classifier_tools, + response=classifier_response, + output_schema=get_output_schema(execution_agent), + handoffs=cast(list[Handoff], resolved_handoffs), + existing_items=classifier_existing_items, + run_config=replace(run_config, tool_not_found_behavior="return_error_to_model"), + server_manages_conversation=server_manages_conversation, + server_managed_input_items=classifier_server_managed_input_items, + allow_apply_patch_function_fallback=False, + ) + current_functions = {run.tool_call.call_id: run for run in classified.functions} + current_handoffs = {run.tool_call.call_id: run for run in classified.handoffs} + current_missing = {run.tool_call.call_id: run for run in classified.function_tools_not_found} + pending_nested_transfers: list[tuple[ResponseFunctionToolCall, Any]] = [] + pending_nested_drops: list[ResponseFunctionToolCall] = [] + + def _cached_nested_result(run: ToolRunFunction) -> Any | None: + stable_result = stable_function_nested_results.get(id(run.tool_call)) + if stable_result is not None: + return stable_result + return peek_agent_tool_run_result(run.tool_call, scope_id=tool_state_scope_id) + + def _drop_stable_nested_result(call: ResponseFunctionToolCall) -> None: + stable_function_nested_results.pop(id(call), None) + drop_agent_tool_run_result(call, scope_id=tool_state_scope_id) + source_call = stable_function_call_sources.get(id(call)) + if source_call is not None: + drop_agent_tool_run_result(source_call, scope_id=tool_state_scope_id) + + def _pending_nested_result(run: ToolRunFunction) -> Any | None: + result = _cached_nested_result(run) + return result if result and getattr(result, "interruptions", None) else None + + def _reject_nested_replacement(run: ToolRunFunction) -> None: + if _pending_nested_result(run) is None: + pending_nested_drops.append(run.tool_call) + return + qualified_name = get_tool_call_qualified_name(run.tool_call) or run.tool_call.name + # TODO: Persist Agent.as_tool() owner identity so replacement before RunState + # restoration can be detected and safely migrated. + raise ModelBehaviorError( + f"Cannot reconcile queued tool {qualified_name} with a new tool or handoff while " + "its Agent.as_tool() run is interrupted. Restore the original tool configuration " + "or start a new run." + ) + + def _rebind_function_run( + stale_run: ToolRunFunction | None, + current_run: ToolRunFunction, + ) -> ToolRunFunction: + if stale_run is None: + return current_run + cached_result = _cached_nested_result(stale_run) + pending_result = _pending_nested_result(stale_run) + if stale_run.function_tool is not current_run.function_tool: + stale_owner = getattr(stale_run.function_tool, "_agent_instance", None) + current_owner = getattr(current_run.function_tool, "_agent_instance", None) + if pending_result is not None and ( + stale_owner is None or stale_owner is not current_owner + ): + _reject_nested_replacement(stale_run) + if cached_result is not None and pending_result is None: + pending_nested_drops.append(stale_run.tool_call) + if pending_result is not None and current_run.tool_call is not stale_run.tool_call: + pending_nested_transfers.append((current_run.tool_call, pending_result)) + pending_nested_drops.append(stale_run.tool_call) + return current_run + + reconciled_functions: list[ToolRunFunction] = [ + run for run in stable_function_runs if run.tool_call.call_id not in reconciled_call_ids + ] + reconciled_handoffs: list[ToolRunHandoff] = [ + run for run in stable_handoff_runs if run.tool_call.call_id not in reconciled_call_ids + ] + missing_function_tools: list[ToolRunFunctionNotFound] = [] + missing_state_calls: list[ResponseFunctionToolCall] = [] + missing_function_call_ids: set[str] = set() + + for call in calls_to_reconcile: + call_id = call.call_id + approval_record = function_approval_items_by_call_id.get(call_id) + approval_status = ( + context_wrapper.get_approval_status( + approval_record.tool_name or call.name, call_id, - tool_namespace=approval.tool_namespace, - existing_pending=approval, + tool_namespace=approval_record.tool_namespace, + existing_pending=approval_record, ) - if approval_status is None: - _add_pending_interruption(approval) + if approval_record is not None + else True + ) + stale_function = stale_functions.get(call_id) + current_function = current_functions.get(call_id) + if current_function is not None: + reconciled_functions.append(_rebind_function_run(stale_function, current_function)) + continue - for approval in pending_approval_items: - if not isinstance(approval, ToolApprovalItem): - continue - if not _approval_matches_agent(approval): - _add_unmatched_pending(approval) - continue - raw = approval.raw_item - raw_type = get_mapping_or_attr(raw, "type") - if raw_type != "function_call": - _add_unmatched_pending(approval) - continue - name = get_mapping_or_attr(raw, "name") - namespace = get_tool_call_namespace(raw) - if namespace is None and isinstance(approval.tool_namespace, str): - namespace = approval.tool_namespace - approval_key = getattr(approval, "tool_lookup_key", None) - if approval_key is None: - approval_key = get_function_tool_lookup_key(name, namespace) - resolved_tool = tool_map.get(approval_key) if approval_key is not None else None - if not (isinstance(name, str) and resolved_tool is not None): - _add_unmatched_pending(approval) - continue + current_handoff = current_handoffs.get(call_id) + if current_handoff is not None and approval_status is True: + if stale_function is not None: + _reject_nested_replacement(stale_function) + reconciled_handoffs.append(current_handoff) + continue - rebuilt_call_id: str | None - arguments: str | None - tool_call: ResponseFunctionToolCall - if isinstance(raw, ResponseFunctionToolCall): - rebuilt_call_id = raw.call_id - arguments = raw.arguments - tool_call = raw - else: - rebuilt_call_id = extract_tool_call_id(raw) - arguments = get_mapping_or_attr(raw, "arguments") or "{}" - status = get_mapping_or_attr(raw, "status") - if not (isinstance(rebuilt_call_id, str) and isinstance(arguments, str)): - _add_unmatched_pending(approval) - continue - valid_status: Literal["in_progress", "completed", "incomplete"] | None = None - if isinstance(status, str) and status in ( - "in_progress", - "completed", - "incomplete", - ): - valid_status = status # type: ignore[assignment] - tool_call_payload: dict[str, Any] = { - "type": "function_call", - "name": name, - "call_id": rebuilt_call_id, - "arguments": arguments, - "status": valid_status, - } - if namespace is not None: - tool_call_payload["namespace"] = namespace - caller = get_mapping_or_attr(raw, "caller") - if caller is not None: - tool_call_payload["caller"] = caller - tool_call = ResponseFunctionToolCall(**tool_call_payload) - tool_call = cast( - ResponseFunctionToolCall, - normalize_tool_call_for_function_tool(tool_call, resolved_tool), - ) - ensure_programmatic_tool_call_parent( - tool_call=tool_call, - programmatic_tool_present=programmatic_tool_present, - program_call_ids=program_call_ids, - completed_program_call_ids=completed_program_call_ids, - agent_name=public_agent.name, - ) - ensure_tool_caller_allowed( - tool_call=tool_call, - allowed_callers=resolved_tool.allowed_callers, - tool_name=get_function_tool_qualified_name(resolved_tool) or resolved_tool.name, - agent_name=public_agent.name, + missing = current_missing.get(call_id) + if missing is not None and approval_status is True: + if run_config.tool_not_found_behavior != "return_error_to_model": + qualified_name = ( + get_tool_call_qualified_name(missing.tool_call) or missing.tool_name + ) + raise ModelBehaviorError( + f"Tool {qualified_name} not found in agent {public_agent.name}" + ) + cached_result = ( + _cached_nested_result(stale_function) + if stale_function is not None + else peek_agent_tool_run_result( + missing.tool_call, + scope_id=tool_state_scope_id, + ) ) + if cached_result is not None: + state_call = missing.tool_call + if stale_function is not None: + reconciled_functions.append(stale_function) + state_call = stale_function.tool_call + missing_state_calls.append(state_call) + missing_function_call_ids.add(call_id) + missing_function_tools.append(missing) + continue - if not (isinstance(rebuilt_call_id, str) and isinstance(arguments, str)): - _add_unmatched_pending(approval) - continue + if stale_function is not None: + reconciled_functions.append(stale_function) + elif approval_record is not None: + if approval_status is None: + _add_pending_interruption(approval_record) + elif approval_status is False: + rejection_call = cast( + ResponseFunctionToolCall, + approval_record.raw_item, + ) + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=run_config, + tool_type="function", + tool_name=get_tool_call_trace_name(rejection_call) or rejection_call.name, + call_id=call_id, + tool_namespace=get_tool_call_namespace(rejection_call), + tool_lookup_key=approval_record.tool_lookup_key, + existing_pending=approval_record, + ) + rejected_function_outputs.append( + function_rejection_item( + public_agent, + rejection_call, + rejection_message=rejection_message, + output_json_schema=None, + scope_id=tool_state_scope_id, + tool_origin=approval_record.tool_origin, + ) + ) + rejected_function_call_ids.add(call_id) - approval_status = context_wrapper.get_approval_status( - name, - rebuilt_call_id, - tool_namespace=namespace, - existing_pending=approval, - ) - if approval_status is False: - await _record_function_rejection( - rebuilt_call_id, - tool_call, - resolved_tool, + for original_approval in pending_approval_items: + approval_snapshot = validated_function_approval_items.get(original_approval) + if approval_snapshot is None: + approval = original_approval + approval_call_id = extract_tool_call_id(approval.raw_item) + if ( + approval_call_id is None + or context_wrapper.get_approval_status( + approval.tool_name or "", + approval_call_id, + tool_namespace=approval.tool_namespace, + existing_pending=approval, ) - continue - if approval_status is None: - if rebuilt_call_id not in existing_pending_call_ids: - _add_pending_interruption(approval) - existing_pending_call_ids.add(rebuilt_call_id) - continue - rebuilt_runs.append(ToolRunFunction(function_tool=resolved_tool, tool_call=tool_call)) - return rebuilt_runs + is None + ): + _add_pending_interruption(approval) + continue + approval_call_id = cast( + ResponseFunctionToolCall, + approval_snapshot.raw_item, + ).call_id + if ( + approval_call_id not in reconciled_call_ids + and context_wrapper.get_approval_status( + approval_snapshot.tool_name or "", + approval_call_id, + tool_namespace=approval_snapshot.tool_namespace, + existing_pending=approval_snapshot, + ) + is None + ): + _add_pending_interruption(approval_snapshot) + selectable_function_runs = [ + run + for run in reconciled_functions + if run.tool_call.call_id not in missing_function_call_ids + ] function_tool_runs = await _select_function_tool_runs_for_resume( - processed_response.functions, - approval_items_by_call_id=approval_items_by_call_id, + selectable_function_runs, + approval_items_by_call_id=function_approval_items_by_call_id, context_wrapper=context_wrapper, needs_approval_checker=_function_requires_approval, output_exists_checker=_function_output_exists, @@ -1479,21 +1901,6 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: ), ) - rebuilt_function_tool_runs = await _rebuild_function_runs_from_approvals() - if rebuilt_function_tool_runs: - existing_call_ids: set[str] = set() - for run in function_tool_runs: - call_id = extract_tool_call_id(run.tool_call) - if call_id: - existing_call_ids.add(call_id) - for run in rebuilt_function_tool_runs: - call_id = extract_tool_call_id(run.tool_call) - if call_id and call_id in existing_call_ids: - continue - function_tool_runs.append(run) - if call_id: - existing_call_ids.add(call_id) - pending_computer_actions: list[ToolRunComputerAction] = [] for action in processed_response.computer_actions: call_id = _computer_call_id_from_run(action) @@ -1554,6 +1961,29 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: apply_patch_calls=approved_apply_patch_calls, ) + missing_output_items = await _build_tool_not_found_output_items( + agent=public_agent, + calls=missing_function_tools, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + for current_call, nested_result in pending_nested_transfers: + record_agent_tool_run_result( + current_call, + nested_result, + scope_id=tool_state_scope_id, + ) + processed_response.functions = reconciled_functions + processed_response.handoffs = reconciled_handoffs + processed_response.function_tools_not_found = missing_function_tools + dropped_nested_call_ids: set[int] = set() + for stale_call in pending_nested_drops: + if id(stale_call) in dropped_nested_call_ids: + continue + dropped_nested_call_ids.add(id(stale_call)) + _drop_stable_nested_result(stale_call) + ( function_results, tool_input_guardrail_results, @@ -1581,8 +2011,35 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: new_items, append_if_new = _make_unique_item_appender(original_pre_step_items) - for item in _build_tool_result_items( + function_result_items = _build_tool_result_items( function_results=function_results, + computer_results=[], + custom_tool_results=[], + shell_results=[], + apply_patch_results=[], + local_shell_results=[], + ) + call_positions = dict(response_call_positions) + next_call_position = len(new_response.output) + for call in calls_to_reconcile: + if call.call_id not in call_positions: + call_positions[call.call_id] = next_call_position + next_call_position += 1 + function_outcomes = [ + *function_result_items, + *missing_output_items, + *rejected_function_outputs, + ] + function_outcomes.sort( + key=lambda item: call_positions.get( + extract_tool_call_id(getattr(item, "raw_item", None)) or "", + len(call_positions), + ) + ) + for item in function_outcomes: + append_if_new(item) + for item in _build_tool_result_items( + function_results=[], computer_results=computer_results, custom_tool_results=custom_tool_results, shell_results=shell_results, @@ -1590,8 +2047,6 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: local_shell_results=[], ): append_if_new(item) - for rejection_item in rejected_function_outputs: - append_if_new(rejection_item) for pending_item in pending_interruptions: if pending_item: append_if_new(pending_item) @@ -1604,19 +2059,32 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: for approved_response in plan.approved_mcp_responses: append_if_new(approved_response) + def _commit_missing_state(result: SingleStepResult) -> SingleStepResult: + if missing_function_call_ids: + processed_response.functions = [ + run + for run in processed_response.functions + if run.tool_call.call_id not in missing_function_call_ids + ] + for call in missing_state_calls: + _drop_stable_nested_result(call) + return result + processed_response.interruptions = pending_interruptions if pending_interruptions: - return SingleStepResult( - original_input=original_input, - model_response=new_response, - pre_step_items=original_pre_step_items, - new_step_items=new_items, - next_step=NextStepInterruption( - interruptions=[item for item in pending_interruptions if item] - ), - tool_input_guardrail_results=tool_input_guardrail_results, - tool_output_guardrail_results=tool_output_guardrail_results, - processed_response=processed_response, + return _commit_missing_state( + SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=original_pre_step_items, + new_step_items=new_items, + next_step=NextStepInterruption( + interruptions=[item for item in pending_interruptions if item] + ), + tool_input_guardrail_results=tool_input_guardrail_results, + tool_output_guardrail_results=tool_output_guardrail_results, + processed_response=processed_response, + ) ) await _append_mcp_callback_results( @@ -1672,13 +2140,6 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: ) ] - executed_handoff_call_ids: set[str] = set() - for item in original_pre_step_items: - if isinstance(item, HandoffOutputItem): - handoff_call_id = extract_tool_call_id(item.raw_item) - if handoff_call_id: - executed_handoff_call_ids.add(handoff_call_id) - pending_handoffs = [ handoff for handoff in processed_response.handoffs @@ -1687,20 +2148,22 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: ] if pending_handoffs: - return await execute_handoffs_call( - public_agent=public_agent, - original_input=original_input, - pre_step_items=pre_step_items, - new_step_items=new_items, - new_response=new_response, - run_handoffs=pending_handoffs, - hooks=hooks, - context_wrapper=context_wrapper, - run_config=run_config, - server_manages_conversation=server_manages_conversation, - nest_handoff_history_fn=nest_handoff_history_fn, - tool_input_guardrail_results=tool_input_guardrail_results, - tool_output_guardrail_results=tool_output_guardrail_results, + return _commit_missing_state( + await execute_handoffs_call( + public_agent=public_agent, + original_input=original_input, + pre_step_items=pre_step_items, + new_step_items=new_items, + new_response=new_response, + run_handoffs=pending_handoffs, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + server_manages_conversation=server_manages_conversation, + nest_handoff_history_fn=nest_handoff_history_fn, + tool_input_guardrail_results=tool_input_guardrail_results, + tool_output_guardrail_results=tool_output_guardrail_results, + ) ) tool_final_output = await _maybe_finalize_from_tool_results( @@ -1716,16 +2179,18 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: tool_output_guardrail_results=tool_output_guardrail_results, ) if tool_final_output is not None: - return tool_final_output + return _commit_missing_state(tool_final_output) - return SingleStepResult( - original_input=original_input, - model_response=new_response, - pre_step_items=pre_step_items, - new_step_items=new_items, - next_step=NextStepRunAgain(), - tool_input_guardrail_results=tool_input_guardrail_results, - tool_output_guardrail_results=tool_output_guardrail_results, + return _commit_missing_state( + SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_items, + next_step=NextStepRunAgain(), + tool_input_guardrail_results=tool_input_guardrail_results, + tool_output_guardrail_results=tool_output_guardrail_results, + ) ) @@ -1740,6 +2205,7 @@ def process_model_response( run_config: RunConfig | None = None, server_manages_conversation: bool = False, server_managed_input_items: Sequence[Any] | None = None, + allow_apply_patch_function_fallback: bool = True, ) -> ProcessedResponse: items: list[RunItem] = [] @@ -2307,6 +2773,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: raise ModelBehaviorError(f"Tool {output.name} not found in agent {agent.name}") elif ( isinstance(output, ResponseFunctionToolCall) + and allow_apply_patch_function_fallback and is_apply_patch_name(output.name, apply_patch_tool) and get_function_tool_lookup_key_for_call(output) not in function_map ): diff --git a/tests/test_tool_name_collision_policy.py b/tests/test_tool_name_collision_policy.py new file mode 100644 index 0000000000..9fa342890b --- /dev/null +++ b/tests/test_tool_name_collision_policy.py @@ -0,0 +1,1424 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from typing import Any, cast + +import pytest +from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call import CallerProgram +from openai.types.responses.response_output_item import Program + +from agents import ( + Agent, + ApplyPatchTool, + ModelBehaviorError, + ProgrammaticToolCallingTool, + RunConfig, + RunContextWrapper, + Runner, + RunState, + ShellTool, + UserError, + handoff, + tool_namespace, +) +from agents.items import ToolCallOutputItem +from agents.tool import Tool, function_tool + +from .fake_model import FakeModel +from .mcp.helpers import FakeMCPServer +from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message + + +def _record(calls: list[str], value: str, result: str | None = None) -> str: + calls.append(value) + return value if result is None else result + + +@pytest.mark.asyncio +async def test_resume_warn_mode_rebinds_queued_mcp_call_to_local_winner() -> None: + calls: list[str] = [] + server = FakeMCPServer(require_approval="always") + server.add_tool("lookup", {"type": "object", "properties": {}}) + + def local_lookup() -> str: + calls.append("local") + return "local" + + local_tool = function_tool(local_lookup, name_override="lookup") + model = FakeModel(initial_output=[get_function_tool_call("lookup", "{}")]) + agent = Agent(name="agent", model=model, mcp_servers=[server]) + + initial_result = await Runner.run(agent, "Look this up") + state = await RunState.from_json(agent, initial_result.to_state().to_json()) + interruption = state.get_interruptions()[0] + state.approve(interruption) + agent.tools = [local_tool] + model.set_next_output([get_text_message("done")]) + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "done" + assert calls == ["local"] + assert server.tool_calls == [] + + +@pytest.mark.asyncio +async def test_resume_error_mode_rejects_current_collision_before_side_effects() -> None: + calls: list[str] = [] + queued_tool = function_tool( + lambda: _record(calls, "queued"), + name_override="lookup", + needs_approval=True, + ) + colliding_tool = function_tool( + lambda: _record(calls, "colliding"), + name_override="lookup", + ) + model = FakeModel( + initial_output=[get_function_tool_call("lookup", "{}", call_id="lookup_call")] + ) + agent = Agent(name="agent", model=model, tools=[queued_tool]) + + initial_result = await Runner.run(agent, "Look this up") + state = initial_result.to_state() + state.approve(state.get_interruptions()[0]) + agent.tools = [queued_tool, colliding_tool] + + with pytest.raises(UserError, match="Ambiguous function tool configuration"): + await Runner.run( + agent, + state, + run_config=RunConfig(tool_name_collision_policy="error"), + ) + + assert calls == [] + + +@pytest.mark.parametrize("deserialize", [False, True]) +@pytest.mark.asyncio +async def test_resume_reclassifies_function_call_to_current_handoff( + deserialize: bool, +) -> None: + calls: list[str] = [] + filter_calls: list[str] = [] + + class FalsyFilter: + def __bool__(self) -> bool: + return False + + def __call__(self, data: Any) -> Any: + filter_calls.append("filter") + return data + + def route_function() -> str: + calls.append("function") + return "function" + + route_tool = function_tool( + route_function, + name_override="route", + needs_approval=True, + ) + target = Agent( + name="target", + model=FakeModel(initial_output=[get_text_message("target done")]), + ) + route_handoff = handoff( + target, + tool_name_override="route", + on_handoff=lambda _: calls.append("handoff"), + input_filter=FalsyFilter(), + ) + model = FakeModel(initial_output=[get_function_tool_call("route", "{}", call_id="route_call")]) + agent = Agent(name="agent", model=model, tools=[route_tool]) + + initial_result = await Runner.run(agent, "Route this request") + state_json = initial_result.to_state().to_json() + agent.tools = [] + agent.handoffs = [route_handoff] + state = ( + await RunState.from_json(agent, state_json) if deserialize else initial_result.to_state() + ) + state._model_responses[-1] = replace(state._model_responses[-1], output=[]) + state.approve(state.get_interruptions()[0]) + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "target done" + assert calls == ["handoff"] + assert filter_calls == ["filter"] + + +@pytest.mark.asyncio +async def test_resume_reclassifies_queued_handoff_to_current_function() -> None: + calls: list[str] = [] + + def approved_function() -> str: + calls.append("approved") + return "approved" + + def route_function() -> str: + calls.append("route") + return "route" + + approval_tool = function_tool( + approved_function, + name_override="approval_tool", + needs_approval=True, + ) + route_tool = function_tool(route_function, name_override="route") + target = Agent(name="target") + route_handoff = handoff(target, tool_name_override="route") + model = FakeModel( + initial_output=[ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(target, override_name="route", args="{}"), + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent( + name="agent", + model=model, + tools=[approval_tool], + handoffs=[route_handoff], + ) + + initial_result = await Runner.run(agent, "Route this request") + state = initial_result.to_state() + state.approve(state.get_interruptions()[0]) + agent.tools = [approval_tool, route_tool] + agent.handoffs = [] + state._model_responses[-1] = replace(state._model_responses[-1], output=[]) + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "done" + assert calls == ["approved", "route"] + + +@pytest.mark.asyncio +async def test_resume_rebinds_queued_handoff_to_current_warn_winner() -> None: + calls: list[str] = [] + + approval_tool = function_tool( + lambda: _record(calls, "approved"), + name_override="approval_tool", + needs_approval=True, + ) + first_target = Agent( + name="first", + model=FakeModel(initial_output=[get_text_message("first done")]), + ) + second_target = Agent( + name="second", + model=FakeModel(initial_output=[get_text_message("second done")]), + ) + first_handoff = handoff( + first_target, + tool_name_override="route", + on_handoff=lambda _: calls.append("first"), + ) + second_handoff = handoff( + second_target, + tool_name_override="route", + on_handoff=lambda _: calls.append("second"), + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(second_target, override_name="route", args="{}"), + ] + ) + agent = Agent( + name="agent", + model=model, + tools=[approval_tool], + handoffs=[first_handoff, second_handoff], + ) + + initial_result = await Runner.run(agent, "Route this request") + state = initial_result.to_state() + state.approve(state.get_interruptions()[0]) + agent.handoffs = [second_handoff, first_handoff] + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "first done" + assert calls == ["approved", "first"] + + +@pytest.mark.asyncio +async def test_resume_rejects_missing_queued_handoff_before_side_effects() -> None: + calls: list[str] = [] + approval_tool = function_tool( + lambda: _record(calls, "approved"), + name_override="approval_tool", + needs_approval=True, + ) + target = Agent(name="target") + route_handoff = handoff( + target, + tool_name_override="route", + on_handoff=lambda _: calls.append("handoff"), + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(target, override_name="route", args="{}"), + ] + ) + agent = Agent( + name="agent", + model=model, + tools=[approval_tool], + handoffs=[route_handoff], + ) + + initial_result = await Runner.run(agent, "Route this request") + state = initial_result.to_state() + state.approve(state.get_interruptions()[0]) + agent.handoffs = [] + + with pytest.raises(ModelBehaviorError, match="Tool route not found in agent agent"): + await Runner.run(agent, state) + + assert calls == [] + + +@pytest.mark.asyncio +async def test_missing_interrupted_agent_tool_preserves_nested_state_for_retry() -> None: + calls: list[str] = [] + before_tool = function_tool( + lambda: _record(calls, "before"), + name_override="before_pause", + ) + sensitive_tool = function_tool( + lambda: _record(calls, "sensitive"), + name_override="sensitive", + needs_approval=True, + ) + inner_model = FakeModel( + initial_output=[ + get_function_tool_call("before_pause", "{}", call_id="before_call"), + get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + ] + ) + inner_model.set_next_output([get_text_message("inner done")]) + inner_agent = Agent( + name="inner", + model=inner_model, + tools=[before_tool, sensitive_tool], + ) + nested_tool = inner_agent.as_tool( + tool_name="lookup", + tool_description="Look up a value with the inner agent.", + ) + outer_model = FakeModel( + initial_output=[ + get_function_tool_call( + "lookup", + '{"input":"hi"}', + call_id="outer_call", + ) + ] + ) + outer_model.set_next_output([get_text_message("outer done")]) + outer_agent = Agent(name="outer", model=outer_model, tools=[nested_tool]) + + initial_result = await Runner.run(outer_agent, "Look this up") + state = await RunState.from_json(outer_agent, initial_result.to_state().to_json()) + state.approve(state.get_interruptions()[0]) + assert calls == ["before"] + + outer_agent.tools = [] + with pytest.raises( + ModelBehaviorError, + match="Tool lookup not found in agent outer", + ) as strict_error: + await Runner.run(outer_agent, state) + + outer_agent.tools = [nested_tool] + resumed_result = await Runner.run(outer_agent, state) + + assert resumed_result.final_output == "outer done" + assert calls == ["before", "sensitive"] + assert strict_error.value is not None + + +@pytest.mark.asyncio +async def test_missing_formatter_cancellation_keeps_nested_state_serializable() -> None: + calls: list[str] = [] + formatter_started = asyncio.Event() + keep_formatter_waiting = asyncio.Event() + before_tool = function_tool( + lambda: _record(calls, "serial_before"), + name_override="serial_before_pause", + ) + sensitive_tool = function_tool( + lambda: _record(calls, "serial_sensitive"), + name_override="serial_sensitive", + needs_approval=True, + ) + inner_model = FakeModel( + initial_output=[ + get_function_tool_call( + "serial_before_pause", + "{}", + call_id="serial_before_call", + ), + get_function_tool_call( + "serial_sensitive", + "{}", + call_id="serial_sensitive_call", + ), + ] + ) + inner_model.set_next_output([get_text_message("inner done")]) + inner_agent = Agent( + name="inner", + model=inner_model, + tools=[before_tool, sensitive_tool], + ) + nested_tool = inner_agent.as_tool( + tool_name="serial_lookup", + tool_description="Look up a value with the inner agent.", + ) + outer_model = FakeModel( + initial_output=[ + get_function_tool_call( + "serial_lookup", + '{"input":"hi"}', + call_id="serial_outer_call", + ) + ] + ) + outer_model.set_next_output([get_text_message("outer done")]) + outer_agent = Agent(name="outer", model=outer_model, tools=[nested_tool]) + + initial_result = await Runner.run(outer_agent, "Look this up") + state = await RunState.from_json(outer_agent, initial_result.to_state().to_json()) + state.approve(state.get_interruptions()[0]) + assert calls == ["serial_before"] + outer_agent.tools = [] + + async def blocking_formatter(_args: Any) -> str: + formatter_started.set() + await keep_formatter_waiting.wait() + return "missing" + + resume_task = asyncio.create_task( + Runner.run( + outer_agent, + state, + run_config=RunConfig( + tool_not_found_behavior="return_error_to_model", + tool_error_formatter=blocking_formatter, + ), + ) + ) + await formatter_started.wait() + resume_task.cancel() + with pytest.raises(asyncio.CancelledError): + await resume_task + + outer_agent.tools = [nested_tool] + restored_state = await RunState.from_json(outer_agent, state.to_json()) + resumed_result = await Runner.run(outer_agent, restored_state) + + assert resumed_result.final_output == "outer done" + assert calls == ["serial_before", "serial_sensitive"] + + +@pytest.mark.asyncio +async def test_replacing_interrupted_agent_tool_fails_before_side_effects() -> None: + calls: list[str] = [] + sensitive_tool = function_tool( + lambda: "sensitive", + name_override="sensitive", + needs_approval=True, + ) + inner_agent = Agent( + name="inner", + model=FakeModel(initial_output=[get_function_tool_call("sensitive", "{}")]), + tools=[sensitive_tool], + ) + nested_tool = inner_agent.as_tool( + tool_name="lookup", + tool_description="Look up a value with the inner agent.", + ) + outer_agent = Agent( + name="outer", + model=FakeModel(initial_output=[get_function_tool_call("lookup", '{"input":"hi"}')]), + tools=[nested_tool], + ) + + initial_result = await Runner.run(outer_agent, "Look this up") + state = await RunState.from_json(outer_agent, initial_result.to_state().to_json()) + state.approve(state.get_interruptions()[0]) + outer_agent.tools = [ + function_tool( + lambda input: _record(calls, input, "local"), + name_override="lookup", + ) + ] + + with pytest.raises( + ModelBehaviorError, + match="Cannot reconcile queued tool lookup with a new tool", + ): + await Runner.run(outer_agent, state) + + assert calls == [] + + +@pytest.mark.asyncio +async def test_resume_preserves_model_order_for_function_outcomes() -> None: + calls: list[str] = [] + missing_tool = function_tool( + lambda: _record(calls, "missing"), + name_override="missing_lookup", + needs_approval=True, + ) + rejected_tool = function_tool( + lambda: _record(calls, "rejected"), + name_override="rejected_lookup", + needs_approval=True, + ) + available_tool = function_tool( + lambda: _record(calls, "available"), + name_override="available_lookup", + needs_approval=True, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("missing_lookup", "{}", call_id="missing_call"), + get_function_tool_call("rejected_lookup", "{}", call_id="rejected_call"), + get_function_tool_call("available_lookup", "{}", call_id="available_call"), + ] + ) + agent = Agent( + name="agent", + model=model, + tools=[missing_tool, rejected_tool, available_tool], + ) + + initial_result = await Runner.run(agent, "Look these up") + state = await RunState.from_json(agent, initial_result.to_state().to_json()) + for interruption in state.get_interruptions(): + if interruption.tool_name == "rejected_lookup": + state.reject(interruption) + else: + state.approve(interruption) + agent.tools = [rejected_tool, available_tool] + model.set_next_output([get_text_message("done")]) + + resumed_result = await Runner.run( + agent, + state, + run_config=RunConfig(tool_not_found_behavior="return_error_to_model"), + ) + + assert resumed_result.final_output == "done" + assert calls == ["available"] + output_ids = [ + cast(dict[str, Any], item.raw_item)["call_id"] + for item in resumed_result.new_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "function_call_output" + ] + assert output_ids == ["missing_call", "rejected_call", "available_call"] + + +@pytest.mark.asyncio +async def test_resume_preserves_duplicate_agent_tool_calls() -> None: + inner_calls: list[str] = [] + + @function_tool(needs_approval=True) + async def inner_hitl_tool() -> str: + inner_calls.append("inner") + return "ok" + + inner_model = FakeModel() + inner_model.add_multiple_turn_outputs( + [ + [get_function_tool_call(inner_hitl_tool.name, "{}", call_id="inner-1")], + [get_function_tool_call(inner_hitl_tool.name, "{}", call_id="inner-2")], + [get_text_message("inner done")], + [get_text_message("inner done")], + ] + ) + inner_agent = Agent(name="inner", model=inner_model, tools=[inner_hitl_tool]) + agent_tool = inner_agent.as_tool( + tool_name="inner_agent_tool", + tool_description="Run the inner agent.", + needs_approval=False, + ) + outer_model = FakeModel( + initial_output=[ + get_function_tool_call( + agent_tool.name, + '{"input":"a"}', + call_id="outer-dup", + ), + get_function_tool_call( + agent_tool.name, + '{"input":"b"}', + call_id="outer-dup", + ), + ] + ) + outer_agent = Agent(name="outer", model=outer_model, tools=[agent_tool]) + initial_result = await Runner.run(outer_agent, "start") + state = initial_result.to_state() + for interruption in state.get_interruptions(): + state.approve(interruption) + outer_model.set_next_output([get_text_message("done")]) + + resumed_result = await Runner.run(outer_agent, state) + + assert resumed_result.final_output == "done" + assert inner_calls == ["inner", "inner"] + outer_outputs = [ + item + for item in resumed_result.new_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "function_call_output" + and item.raw_item.get("call_id") == "outer-dup" + ] + assert len(outer_outputs) == 2 + + +@pytest.mark.parametrize("override_all_tools", [False, True]) +@pytest.mark.asyncio +async def test_resume_reuses_handoff_snapshot_for_delegating_overrides( + override_all_tools: bool, +) -> None: + resume_phase = False + resume_enablement_checks: list[bool] = [] + calls: list[str] = [] + server = FakeMCPServer() + server.add_tool("search", {"type": "object", "properties": {}}) + + def handoff_enabled( + _context: RunContextWrapper[None], + _agent: Agent[None], + ) -> bool: + if not resume_phase: + return True + enabled = not resume_enablement_checks + resume_enablement_checks.append(enabled) + return enabled + + approval_tool = function_tool( + lambda: _record(calls, "approved"), + name_override="approval_tool", + needs_approval=True, + ) + target = Agent( + name="target", + model=FakeModel(initial_output=[get_text_message("done")]), + ) + route_handoff = handoff( + target, + tool_name_override="route", + on_handoff=lambda _: calls.append("handoff"), + is_enabled=handoff_enabled, + ) + + class DelegatingMCPAgent(Agent[None]): + async def get_mcp_tools( + self, + run_context: RunContextWrapper[None], + ) -> list[Tool]: + return await super().get_mcp_tools(run_context) + + class DelegatingAllToolsAgent(Agent[None]): + async def get_all_tools( + self, + run_context: RunContextWrapper[None], + ) -> list[Tool]: + return await super().get_all_tools(run_context) + + model = FakeModel( + initial_output=[ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(target, override_name="route", args="{}"), + ] + ) + agent_class = DelegatingAllToolsAgent if override_all_tools else DelegatingMCPAgent + agent = agent_class( + name="agent", + model=model, + tools=[approval_tool], + handoffs=[route_handoff], + mcp_servers=[server], + mcp_config={"include_server_in_tool_names": True}, + ) + + initial_result = await Runner.run(agent, "Route this request") + state = initial_result.to_state() + state.approve(state.get_interruptions()[0]) + resume_phase = True + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "done" + assert resume_enablement_checks == [True] + assert calls == ["approved", "handoff"] + + +@pytest.mark.asyncio +async def test_resume_rejects_conflicting_persisted_identity_before_sibling_effects() -> None: + calls: list[str] = [] + conflicting_tool = function_tool( + lambda: _record(calls, "conflicting"), + name_override="lookup", + needs_approval=True, + ) + sibling_tool = function_tool( + lambda: _record(calls, "sibling"), + name_override="sibling", + needs_approval=True, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("lookup", "{}", call_id="conflicting_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] + ) + agent = Agent(name="agent", model=model, tools=[conflicting_tool, sibling_tool]) + + initial_result = await Runner.run(agent, "Look these up") + state = initial_result.to_state() + interruptions = state.get_interruptions() + for interruption in interruptions: + state.approve(interruption) + conflicting = next(item for item in interruptions if item.call_id == "conflicting_call") + conflicting.raw_item = { + "type": "function_call", + "name": "lookup", + "namespace": "current", + "call_id": "conflicting_call", + } + conflicting.tool_lookup_key = ("namespaced", "legacy", "lookup") + + with pytest.raises(ModelBehaviorError, match="Persisted tool identity"): + await Runner.run(agent, state) + + assert calls == [] + + +@pytest.mark.asyncio +async def test_resume_rejects_legacy_approval_name_change_before_side_effects() -> None: + calls: list[str] = [] + old_tool = function_tool( + lambda: _record(calls, "old"), + name_override="old_lookup", + needs_approval=True, + ) + new_tool = function_tool( + lambda: _record(calls, "new"), + name_override="new_lookup", + ) + model = FakeModel( + initial_output=[get_function_tool_call("old_lookup", "{}", call_id="lookup_call")] + ) + agent = Agent(name="agent", model=model, tools=[old_tool]) + + initial_result = await Runner.run(agent, "Look this up") + state = initial_result.to_state() + interruption = state.get_interruptions()[0] + state.approve(interruption) + interruption.tool_lookup_key = None + interruption.raw_item = { + "type": "function_call", + "name": "new_lookup", + "arguments": "{}", + "call_id": "lookup_call", + } + agent.tools = [new_tool] + + with pytest.raises(ModelBehaviorError, match="Persisted tool identity"): + await Runner.run(agent, state) + + assert calls == [] + + +@pytest.mark.parametrize("namespace", [None, "tools"]) +@pytest.mark.parametrize("return_error_to_model", [False, True]) +@pytest.mark.asyncio +async def test_resume_treats_apply_patch_prefixed_queued_function_as_function( + namespace: str | None, + return_error_to_model: bool, +) -> None: + calls: list[str] = [] + base_tool = function_tool( + lambda: _record(calls, "function"), + name_override="apply_patch_lookup", + needs_approval=True, + ) + tools: list[Tool] = [] + if namespace is not None: + tools.extend(tool_namespace(name=namespace, description="Lookup tools", tools=[base_tool])) + else: + tools.append(base_tool) + model = FakeModel( + initial_output=[ + get_function_tool_call( + "apply_patch_lookup", + "{}", + call_id="lookup_call", + namespace=namespace, + ) + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=tools) + + initial_result = await Runner.run(agent, "Look this up") + state = initial_result.to_state() + state.approve(state.get_interruptions()[0]) + agent.tools = [] + run_config = RunConfig( + tool_not_found_behavior=( + "return_error_to_model" if return_error_to_model else "raise_error" + ) + ) + + if return_error_to_model: + resumed_result = await Runner.run(agent, state, run_config=run_config) + assert resumed_result.final_output == "done" + else: + with pytest.raises( + ModelBehaviorError, + match=r"Tool .*apply_patch_lookup not found in agent agent", + ): + await Runner.run(agent, state, run_config=run_config) + + assert calls == [] + + +@pytest.mark.parametrize( + "malformed_raw_item", + [ + { + "type": "function_call", + "name": "lookup", + "call_id": "lookup_call", + }, + { + "type": "function_call", + "name": "lookup", + "arguments": "{}", + "id": "lookup_call", + }, + ], +) +@pytest.mark.asyncio +async def test_approved_malformed_approval_only_stays_pending_without_side_effects( + malformed_raw_item: dict[str, Any], +) -> None: + calls: list[str] = [] + tool = function_tool( + lambda: _record(calls, "lookup"), + name_override="lookup", + needs_approval=True, + ) + model = FakeModel( + initial_output=[get_function_tool_call("lookup", "{}", call_id="lookup_call")] + ) + agent = Agent(name="agent", model=model, tools=[tool]) + + initial_result = await Runner.run(agent, "Look this up") + state = initial_result.to_state() + interruption = state.get_interruptions()[0] + state.approve(interruption) + assert state._last_processed_response is not None + state._last_processed_response.functions = [] + state._model_responses[-1] = replace(state._model_responses[-1], output=[]) + interruption.raw_item = malformed_raw_item + + resumed_result = await Runner.run(agent, state) + + assert len(resumed_result.interruptions) == 1 + assert resumed_result.interruptions[0].call_id == "lookup_call" + assert calls == [] + + +@pytest.mark.asyncio +async def test_resume_snapshots_function_approval_before_tool_inventory_await() -> None: + calls: list[str] = [] + approval_holder: dict[str, Any] = {} + + lookup_tool = function_tool( + lambda: _record(calls, "lookup"), + name_override="lookup", + needs_approval=True, + ) + other_tool = function_tool( + lambda: _record(calls, "other"), + name_override="other", + ) + + class MutatingAgent(Agent[None]): + async def get_all_tools( + self, + run_context: RunContextWrapper[None], + ) -> list[Tool]: + approval = approval_holder.get("approval") + if approval is not None: + approval.tool_name = "other" + approval.tool_namespace = None + approval.tool_origin = "local" + approval.tool_lookup_key = ("bare", "other") + approval._allow_bare_name_alias = True + approval.raw_item.name = "other" + return await super().get_all_tools(run_context) + + model = FakeModel( + initial_output=[get_function_tool_call("lookup", "{}", call_id="lookup_call")] + ) + model.set_next_output([get_text_message("done")]) + agent = MutatingAgent(name="agent", model=model, tools=[lookup_tool, other_tool]) + + initial_result = await Runner.run(agent, "Look this up") + state = initial_result.to_state() + approval = state.get_interruptions()[0] + state.approve(approval) + approval_holder["approval"] = approval + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "done" + assert calls == ["lookup"] + + +@pytest.mark.asyncio +async def test_resume_uses_queued_arguments_instead_of_mutated_approval_arguments() -> None: + calls: list[int] = [] + + def lookup(amount: int) -> str: + calls.append(amount) + return str(amount) + + lookup_tool = function_tool( + lookup, + name_override="lookup", + needs_approval=True, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call( + "lookup", + '{"amount":10}', + call_id="lookup_call", + ) + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[lookup_tool]) + + initial_result = await Runner.run(agent, "Look this up") + state = await RunState.from_json(agent, initial_result.to_state().to_json()) + approval = state.get_interruptions()[0] + state.approve(approval) + cast(Any, approval.raw_item).arguments = '{"amount":999}' + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "done" + assert calls == [10] + + +@pytest.mark.asyncio +async def test_resume_deep_copies_approval_only_program_caller_before_inventory_await() -> None: + calls: list[str] = [] + approval_holder: dict[str, Any] = {} + program = Program( + id="program_item", + call_id="program_call", + code="lookup()", + fingerprint="fingerprint", + type="program", + ) + function_call = cast( + ResponseFunctionToolCall, + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + ) + function_call.caller = CallerProgram(type="program", caller_id="program_call") + + lookup_tool = function_tool( + lambda: _record(calls, "lookup"), + name_override="lookup", + needs_approval=True, + allowed_callers=["programmatic"], + ) + + class MutatingCallerAgent(Agent[None]): + async def get_all_tools( + self, + run_context: RunContextWrapper[None], + ) -> list[Tool]: + approval = approval_holder.get("approval") + if approval is not None: + cast(Any, approval.raw_item).caller.caller_id = "mutated_program" + return await super().get_all_tools(run_context) + + model = FakeModel(initial_output=[program, function_call]) + model.set_next_output([get_text_message("done")]) + agent = MutatingCallerAgent( + name="agent", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_tool], + ) + + initial_result = await Runner.run(agent, "Look this up") + state = initial_result.to_state() + approval = state.get_interruptions()[0] + state.approve(approval) + assert state._last_processed_response is not None + state._last_processed_response.functions = [] + state._model_responses[-1] = replace( + state._model_responses[-1], + output=[program], + ) + approval_holder["approval"] = approval + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "done" + assert calls == ["lookup"] + + +@pytest.mark.asyncio +async def test_resume_snapshots_program_parent_context_before_inventory_await() -> None: + calls: list[str] = [] + mutate_parent = False + program = Program( + id="program_item", + call_id="legit_program", + code="lookup()", + fingerprint="fingerprint", + type="program", + ) + function_call = cast( + ResponseFunctionToolCall, + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + ) + function_call.caller = CallerProgram(type="program", caller_id="legit_program") + lookup_tool = function_tool( + lambda: _record(calls, "lookup"), + name_override="lookup", + needs_approval=True, + allowed_callers=["programmatic"], + ) + + class MutatingParentAgent(Agent[None]): + async def get_all_tools( + self, + run_context: RunContextWrapper[None], + ) -> list[Tool]: + if mutate_parent: + program.call_id = "forged_program" + return await super().get_all_tools(run_context) + + model = FakeModel(initial_output=[program, function_call]) + agent = MutatingParentAgent( + name="agent", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_tool], + ) + + initial_result = await Runner.run(agent, "Look this up") + state = initial_result.to_state() + approval = state.get_interruptions()[0] + state.approve(approval) + cast(Any, approval.raw_item).caller.caller_id = "forged_program" + assert state._last_processed_response is not None + state._last_processed_response.functions = [] + state._model_responses[-1] = replace( + state._model_responses[-1], + output=[program], + ) + mutate_parent = True + + with pytest.raises(ModelBehaviorError, match="does not match a parent program item"): + await Runner.run(agent, state) + + assert calls == [] + + +@pytest.mark.asyncio +async def test_resume_rejects_response_backed_approval_lookup_mismatch_before_effects() -> None: + calls: list[str] = [] + lookup_tool = function_tool( + lambda: _record(calls, "lookup"), + name_override="lookup", + needs_approval=True, + ) + sibling_tool = function_tool( + lambda: _record(calls, "sibling"), + name_override="sibling", + needs_approval=True, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] + ) + agent = Agent(name="agent", model=model, tools=[lookup_tool, sibling_tool]) + + initial_result = await Runner.run(agent, "Look these up") + state = await RunState.from_json(agent, initial_result.to_state().to_json()) + interruptions = state.get_interruptions() + for interruption in interruptions: + state.approve(interruption) + assert state._last_processed_response is not None + state._last_processed_response.functions = [ + run + for run in state._last_processed_response.functions + if run.tool_call.call_id != "lookup_call" + ] + lookup_approval = next(item for item in interruptions if item.call_id == "lookup_call") + cast(Any, lookup_approval.raw_item).name = "other" + lookup_approval.tool_name = "other" + lookup_approval.tool_lookup_key = ("bare", "other") + + resumed_result = await Runner.run(agent, state) + + assert len(resumed_result.interruptions) == 2 + assert calls == [] + + +@pytest.mark.asyncio +async def test_resume_preserves_function_shaped_apply_patch_owner() -> None: + operations: list[Any] = [] + + class Editor: + def create_file(self, operation: Any) -> dict[str, str]: + operations.append(operation) + return {"output": "created", "status": "completed"} + + def update_file(self, operation: Any) -> dict[str, str]: + operations.append(operation) + return {"output": "updated", "status": "completed"} + + def delete_file(self, operation: Any) -> dict[str, str]: + operations.append(operation) + return {"output": "deleted", "status": "completed"} + + patch_tool = ApplyPatchTool(editor=cast(Any, Editor()), needs_approval=True) + model = FakeModel( + initial_output=[ + get_function_tool_call( + "apply_patch", + '{"type":"update_file","path":"test.md","diff":"-a\\n+b\\n"}', + call_id="patch_call", + ) + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[patch_tool]) + + initial_result = await Runner.run(agent, "Update the file") + state = initial_result.to_state() + state.approve(state.get_interruptions()[0]) + + resumed_result = await Runner.run(agent, state) + + assert resumed_result.final_output == "done" + assert len(operations) == 1 + + +@pytest.mark.asyncio +async def test_nested_rebind_is_not_committed_before_later_strict_missing_error() -> None: + calls: list[str] = [] + before_tool = function_tool( + lambda: _record(calls, "before"), + name_override="before", + ) + sensitive_tool = function_tool( + lambda: _record(calls, "sensitive"), + name_override="sensitive", + needs_approval=True, + ) + inner_model = FakeModel( + initial_output=[ + get_function_tool_call("before", "{}", call_id="before_call"), + get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + ] + ) + inner_model.set_next_output([get_text_message("inner done")]) + inner_agent = Agent( + name="inner", + model=inner_model, + tools=[before_tool, sensitive_tool], + ) + nested_tool = inner_agent.as_tool( + tool_name="nested", + tool_description="Run the inner agent.", + ) + missing_tool = function_tool( + lambda: _record(calls, "missing"), + name_override="missing", + needs_approval=True, + ) + outer_model = FakeModel( + initial_output=[ + get_function_tool_call("nested", '{"input":"go"}', call_id="nested_call"), + get_function_tool_call("missing", "{}", call_id="missing_call"), + ] + ) + outer_model.set_next_output([get_text_message("outer done")]) + outer_agent = Agent( + name="outer", + model=outer_model, + tools=[nested_tool, missing_tool], + ) + + initial_result = await Runner.run(outer_agent, "Start") + assert calls == ["before"] + state = initial_result.to_state() + for interruption in state.get_interruptions(): + state.approve(interruption) + outer_agent.tools = [nested_tool] + + with pytest.raises(ModelBehaviorError, match="Tool missing not found"): + await Runner.run(outer_agent, state) + + outer_agent.tools = [nested_tool, missing_tool] + restored_state = await RunState.from_json(outer_agent, state.to_json()) + resumed_result = await Runner.run(outer_agent, restored_state) + + assert resumed_result.final_output == "outer done" + assert calls[0] == "before" + assert sorted(calls[1:]) == ["missing", "sensitive"] + + +@pytest.mark.asyncio +async def test_resume_preserves_cross_kind_duplicate_call_id_baseline() -> None: + calls: list[str] = [] + missing_tool = function_tool( + lambda: _record(calls, "missing"), + name_override="missing", + needs_approval=True, + ) + original_tool = function_tool( + lambda: _record(calls, "original"), + name_override="lookup", + needs_approval=True, + ) + replacement_tool = function_tool( + lambda: _record(calls, "replacement"), + name_override="lookup", + ) + shell_tool = ShellTool( + executor=lambda _request: _record(calls, "shell"), + ) + shell_call = cast( + Any, + { + "type": "shell_call", + "id": "shell_item", + "call_id": "shared_call", + "status": "completed", + "action": { + "type": "exec", + "commands": ["echo test"], + "timeout_ms": 1000, + }, + }, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("missing", "{}", call_id="missing_call"), + get_function_tool_call("lookup", "{}", call_id="shared_call"), + shell_call, + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent( + name="agent", + model=model, + tools=[missing_tool, original_tool, shell_tool], + ) + + initial_result = await Runner.run(agent, "Look this up") + assert calls == ["shell"] + state = initial_result.to_state() + for interruption in state.get_interruptions(): + state.approve(interruption) + agent.tools = [replacement_tool, shell_tool] + + resumed_result = await Runner.run( + agent, + state, + run_config=RunConfig(tool_not_found_behavior="return_error_to_model"), + ) + + assert resumed_result.final_output == "done" + assert calls == ["shell", "original"] + output_ids = [ + cast(dict[str, Any], item.raw_item)["call_id"] + for item in resumed_result.new_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "function_call_output" + ] + assert output_ids == ["missing_call", "shared_call"] + + +@pytest.mark.parametrize( + "malformed_raw_item", + [ + {"name": "lookup"}, + { + "type": "function_call", + "name": "lookup", + "arguments": "{}", + "call_id": "changed_call", + }, + ], +) +@pytest.mark.asyncio +async def test_approved_malformed_queued_approval_stays_pending_without_side_effects( + malformed_raw_item: dict[str, Any], +) -> None: + calls: list[str] = [] + lookup_tool = function_tool( + lambda: _record(calls, "lookup"), + name_override="lookup", + needs_approval=True, + ) + sibling_tool = function_tool( + lambda: _record(calls, "sibling"), + name_override="sibling", + needs_approval=True, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] + ) + agent = Agent(name="agent", model=model, tools=[lookup_tool, sibling_tool]) + + initial_result = await Runner.run(agent, "Look these up") + state = initial_result.to_state() + interruptions = state.get_interruptions() + for interruption in interruptions: + state.approve(interruption) + lookup_approval = next(item for item in interruptions if item.call_id == "lookup_call") + lookup_approval.raw_item = malformed_raw_item + + resumed_result = await Runner.run(agent, state) + + assert lookup_approval in resumed_result.interruptions + assert calls == [] + + +@pytest.mark.asyncio +async def test_resume_rejects_cross_kind_approval_identity_before_sibling_effects() -> None: + calls: list[str] = [] + lookup_tool = function_tool( + lambda: _record(calls, "lookup"), + name_override="lookup", + needs_approval=True, + ) + sibling_tool = function_tool( + lambda: _record(calls, "sibling"), + name_override="sibling", + needs_approval=True, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] + ) + agent = Agent(name="agent", model=model, tools=[lookup_tool, sibling_tool]) + + initial_result = await Runner.run(agent, "Look these up") + state = initial_result.to_state() + interruptions = state.get_interruptions() + for interruption in interruptions: + state.approve(interruption) + lookup_approval = next(item for item in interruptions if item.call_id == "lookup_call") + lookup_approval.raw_item = { + "type": "custom_tool_call", + "name": "evil", + "call_id": "lookup_call", + "input": "{}", + } + + with pytest.raises(ModelBehaviorError, match="Persisted tool identity"): + await Runner.run(agent, state) + + assert calls == [] + + +@pytest.mark.asyncio +async def test_missing_formatter_cancellation_precedes_sibling_side_effects() -> None: + calls: list[str] = [] + formatter_started = asyncio.Event() + keep_formatter_waiting = asyncio.Event() + missing_tool = function_tool( + lambda: _record(calls, "missing"), + name_override="missing", + needs_approval=True, + ) + available_tool = function_tool( + lambda: _record(calls, "available"), + name_override="available", + needs_approval=True, + ) + model = FakeModel( + initial_output=[ + get_function_tool_call("missing", "{}", call_id="missing_call"), + get_function_tool_call("available", "{}", call_id="available_call"), + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[missing_tool, available_tool]) + + initial_result = await Runner.run(agent, "Look these up") + state = initial_result.to_state() + for interruption in state.get_interruptions(): + state.approve(interruption) + agent.tools = [available_tool] + + async def blocking_formatter(_args: Any) -> str: + formatter_started.set() + await keep_formatter_waiting.wait() + return "missing" + + resume_task = asyncio.create_task( + Runner.run( + agent, + state, + run_config=RunConfig( + tool_not_found_behavior="return_error_to_model", + tool_error_formatter=blocking_formatter, + ), + ) + ) + await formatter_started.wait() + resume_task.cancel() + with pytest.raises(asyncio.CancelledError): + await resume_task + + assert calls == [] + + resumed_result = await Runner.run( + agent, + state, + run_config=RunConfig(tool_not_found_behavior="return_error_to_model"), + ) + + assert resumed_result.final_output == "done" + assert calls == ["available"] From 945332dabcf132a0ca04301d778a8753197606e1 Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Mon, 3 Aug 2026 19:32:55 -0700 Subject: [PATCH 130/473] fix(sessions): persist the max-turns handler output to the session (#4160) --- src/agents/run.py | 8 +++++- tests/test_max_turns.py | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/agents/run.py b/src/agents/run.py index c4c507213d..66d3d9da0e 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1174,12 +1174,18 @@ def _finalize_result(result: RunResult) -> RunResult: if session_input_items_for_persistence is not None else [] ) + # The synthesized item is a fresh one-item list, not the + # cumulative turn item list, so the run state's per-turn + # persisted count must not be applied as a slice offset here. + # Pass the reasoning item id policy explicitly instead, the same + # way `save_resumed_turn_items` does. await save_result_to_session( session, handler_input_items_for_save, [synthesized_item], - run_state, + None, response_id=None, + reasoning_item_id_policy=resolved_reasoning_item_id_policy, store=store_setting, ) result._original_input = copy_input_items(original_input) diff --git a/tests/test_max_turns.py b/tests/test_max_turns.py index 0a21aaf385..7e6de97001 100644 --- a/tests/test_max_turns.py +++ b/tests/test_max_turns.py @@ -14,6 +14,7 @@ ModelRefusalError, RunErrorHandlerResult, Runner, + SQLiteSession, UserError, ) from agents.stream_events import RunItemStreamEvent @@ -487,3 +488,62 @@ async def test_streamed_max_turns_handler_list_output(): assert run_item_events[0].name == "message_output_created" assert isinstance(run_item_events[0].item, MessageOutputItem) assert ItemHelpers.text_message_output(run_item_events[0].item) == '{"response":["a","b"]}' + + +async def _run_max_turns_handler_with_session(streamed: bool) -> list[str]: + """Run one tool turn, trip max turns, and return the session's persisted item types.""" + model = FakeModel() + agent = Agent( + name="test_1", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + model.add_multiple_turn_outputs( + [[get_function_tool_call("some_function", json.dumps({"a": "b"}))]] + ) + session = SQLiteSession("max-turns-handler", ":memory:") + try: + if streamed: + streamed_result = Runner.run_streamed( + agent, + input="user_message", + max_turns=1, + session=session, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + async for _ in streamed_result.stream_events(): + pass + assert streamed_result.final_output == "fallback answer" + else: + run_result = await Runner.run( + agent, + input="user_message", + max_turns=1, + session=session, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + assert run_result.final_output == "fallback answer" + + return [str(item.get("type", item.get("role"))) for item in await session.get_items()] + finally: + session.close() + + +@pytest.mark.asyncio +async def test_non_streamed_max_turns_handler_persists_output_to_session(): + """The synthesized max-turns final output must reach the session. + + It is a brand new item, so the per-turn persisted-item count left over from the previous + turn must not be applied as an offset into the one-item list handed to the session save. + """ + item_types = await _run_max_turns_handler_with_session(streamed=False) + + assert item_types == ["user", "function_call", "function_call_output", "message"] + + +@pytest.mark.asyncio +async def test_streamed_max_turns_handler_persists_output_to_session(): + """The streamed path already persists the synthesized output; keep both paths aligned.""" + item_types = await _run_max_turns_handler_with_session(streamed=True) + + assert item_types == ["user", "function_call", "function_call_output", "message"] From 7b7587425a17676f5a713d346abec76db30e0eab Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Mon, 3 Aug 2026 20:07:02 -0700 Subject: [PATCH 131/473] fix(memory): roll back a failed SQLiteSession insert (#4163) --- src/agents/memory/sqlite_session.py | 13 +++++++++-- tests/memory/test_session.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/agents/memory/sqlite_session.py b/src/agents/memory/sqlite_session.py index 29ba270298..4bd641cc8d 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -288,8 +288,17 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: def _add_items_sync(): with self._locked_connection() as conn: - self._insert_items(conn, items) - conn.commit() + try: + self._insert_items(conn, items) + conn.commit() + except Exception: + # _locked_connection() does not manage transactions; roll back + # explicitly so a failure partway through the insert never leaves a + # partial mutation or an open transaction on this cached connection. + # An open write transaction would hold the SQLite write lock for the + # lifetime of the connection and block every later writer. + conn.rollback() + raise await asyncio.to_thread(_add_items_sync) diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index ade1b32314..be761aea6e 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -4,6 +4,7 @@ import sqlite3 import tempfile from pathlib import Path +from typing import cast import pytest @@ -693,6 +694,40 @@ async def test_sqlite_session_file_lock_is_shared_across_instances(): assert lock_path not in SQLiteSession._file_locks +@pytest.mark.asyncio +async def test_sqlite_session_failed_add_items_releases_write_lock(): + """A failed add_items must not leave an open write transaction on the cached connection.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "test_rollback.db" + session = SQLiteSession("rollback_test", db_path) + + # json.dumps() fails only after _insert_items() has already opened a write + # transaction with the sessions-table upsert. + unserializable = cast(TResponseInputItem, {"role": "user", "content": object()}) + with pytest.raises(TypeError): + await session.add_items([unserializable]) + + # timeout=0 disables the busy handler, so this raises immediately if the failed + # write is still holding the SQLite write lock. + probe = sqlite3.connect(str(db_path), timeout=0) + try: + probe.execute("INSERT INTO agent_sessions (session_id) VALUES ('probe')") + probe.commit() + rolled_back = probe.execute( + "SELECT COUNT(*) FROM agent_sessions WHERE session_id = 'rollback_test'" + ).fetchone()[0] + finally: + probe.close() + + assert rolled_back == 0 + + # The session must remain usable after the failure. + await session.add_items([{"role": "user", "content": "after failure"}]) + assert [item.get("content") for item in await session.get_items()] == ["after failure"] + + session.close() + + @pytest.mark.asyncio async def test_session_add_items_exception_propagates_in_streamed(): """Test that exceptions from session.add_items are properly propagated From 72e7c6e5495c6d3963e5115069ea75795e67deeb Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Mon, 3 Aug 2026 21:57:16 -0700 Subject: [PATCH 132/473] fix(tracing): name streamed task spans after the run's own workflow (#4167) --- src/agents/run.py | 1 + src/agents/run_internal/run_loop.py | 4 ++-- tests/test_agent_tracing.py | 25 +++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 66d3d9da0e..ee8125ddab 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1972,6 +1972,7 @@ def run_streamed( conversation_id=conversation_id, session=session, run_state=run_state, + trace_workflow_name=trace_workflow_name, is_resumed_state=is_resumed_state, sandbox_runtime=sandbox_runtime, ) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index e1e1bd84ea..65d4e794e8 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -606,6 +606,7 @@ async def start_streaming( session: Session | None, run_state: RunState[TContext] | None = None, *, + trace_workflow_name: str, is_resumed_state: bool = False, sandbox_runtime: SandboxRuntime[TContext] | None = None, ): @@ -628,10 +629,9 @@ async def start_streaming( auto_previous_response_id=auto_previous_response_id, ) - current_trace = streamed_result.trace or get_current_trace() use_task_and_turn_spans = include_task_and_turn_spans(run_config.tracing) current_task_span: Span[TaskSpanData] | None = ( - task_span(name=current_trace.name) if current_trace and use_task_and_turn_spans else None + task_span(name=trace_workflow_name) if use_task_and_turn_spans else None ) if current_task_span: current_task_span.start(mark_as_current=True) diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py index c60477cf10..2a629926a8 100644 --- a/tests/test_agent_tracing.py +++ b/tests/test_agent_tracing.py @@ -992,6 +992,31 @@ async def test_wrapped_streaming_run_creates_root_task_span(): assert generation_spans[0].parent_id == turn_spans[0]["id"] +@pytest.mark.asyncio +async def test_wrapped_run_task_span_uses_run_workflow_name(): + def _make_agent() -> Agent[None]: + return Agent( + name="test_agent", + model=FakeModel(initial_output=[get_text_message("first_test")]), + ) + + run_config = RunConfig(workflow_name="inner_workflow") + + with trace(workflow_name="outer_workflow"): + await Runner.run(_make_agent(), input="first_test", run_config=run_config) + result = Runner.run_streamed(_make_agent(), input="first_test", run_config=run_config) + async for _ in result.stream_events(): + pass + + task_spans = [span.export() for span in fetch_ordered_spans() if span.span_data.type == "task"] + # A task span names one Runner invocation, so both runs must use their own workflow name + # rather than the enclosing trace's name. + assert [span["span_data"]["data"]["name"] for span in task_spans if span] == [ + "inner_workflow", + "inner_workflow", + ] + + @pytest.mark.asyncio async def test_wrapped_streaming_run_can_disable_task_and_turn_spans(): agent = Agent( From 27c136cec871804c8508d19365801bce63bc0b1e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 4 Aug 2026 13:57:58 +0900 Subject: [PATCH 133/473] fix(realtime): scope delayed audio guardrail interruption (#4135) --- docs/realtime/guide.md | 4 +- src/agents/realtime/_default_tracker.py | 49 +- src/agents/realtime/model.py | 4 + src/agents/realtime/model_events.py | 2 + src/agents/realtime/model_inputs.py | 9 +- src/agents/realtime/openai_realtime.py | 268 ++++++++--- src/agents/realtime/session.py | 138 ++++-- tests/realtime/test_model_events.py | 16 + tests/realtime/test_openai_realtime.py | 581 +++++++++++++++++++++++- tests/realtime/test_session.py | 218 ++++++++- 10 files changed, 1190 insertions(+), 99 deletions(-) diff --git a/docs/realtime/guide.md b/docs/realtime/guide.md index 172f21804e..22d9af72b5 100644 --- a/docs/realtime/guide.md +++ b/docs/realtime/guide.md @@ -288,9 +288,9 @@ agent = RealtimeAgent( ) ``` -When a realtime output guardrail trips on an audio transcript, the session interrupts the active response, forces `response.cancel`, emits `guardrail_tripped`, and sends a follow-up user message that names the triggered guardrail so the model can produce a replacement response. Your audio player should still listen for `audio_interrupted` and stop local playback immediately, because some audio may already be buffered when the tripwire fires. For text-only output, the session instead sends a response-scoped `response.cancel`; it does not emit `audio_interrupted` because there is no audio playback to stop. The same `guardrail_tripped` event and follow-up user message are emitted for the text-only path when using the built-in OpenAI Realtime models. +When a realtime output guardrail trips on an audio transcript, the session interrupts the active response, forces `response.cancel`, emits `guardrail_tripped`, and sends a follow-up user message that names the triggered guardrail so the model can produce a replacement response. Your audio player should still listen for `audio_interrupted` and stop local playback immediately, because some audio may already be buffered when the tripwire fires. With the built-in OpenAI Realtime transports, if the guardrail finishes after its source response has ended, the session interrupts only that response's buffered playback and does not cancel a newer response. For text-only output, the session instead sends a response-scoped `response.cancel`; it does not emit `audio_interrupted` because there is no audio playback to stop. The same `guardrail_tripped` event and follow-up user message are emitted for the text-only path when using the built-in OpenAI Realtime models. -Custom `RealtimeModel` transports must override `RealtimeModel.send_event_if()` to support the text-only recovery message. The implementation must recheck or serialize the supplied condition at the transport's actual event commit boundary. The default implementation safely skips the recovery message because checking the condition before awaiting `send_event()` would allow a newer response to start before the message is committed; response cancellation and the `guardrail_tripped` event still occur. +Custom `RealtimeModel` transports must honor `RealtimeModelSendInterrupt.response_id` and `playback_only` to provide the same source-scoped audio interruption behavior. They must also override `RealtimeModel.send_event_if()` to support the text-only recovery message. The implementation must recheck or serialize the supplied condition at the transport's actual event commit boundary. The default implementation safely skips the recovery message because checking the condition before awaiting `send_event()` would allow a newer response to start before the message is committed; response cancellation and the `guardrail_tripped` event still occur. ## SIP and telephony diff --git a/src/agents/realtime/_default_tracker.py b/src/agents/realtime/_default_tracker.py index dfc28e771f..b633d506cd 100644 --- a/src/agents/realtime/_default_tracker.py +++ b/src/agents/realtime/_default_tracker.py @@ -11,6 +11,12 @@ class ModelAudioState: initial_received_time: float audio_length_ms: float + response_id: str | None = None + + +@dataclass +class ModelResponseAudioState: + items: list[tuple[str, int]] class ModelAudioTracker: @@ -18,6 +24,7 @@ def __init__(self) -> None: # (item_id, item_content_index) -> ModelAudioState self._states: dict[tuple[str, int], ModelAudioState] = {} self._last_audio_item: tuple[str, int] | None = None + self._audio_items_by_response_id: dict[str, ModelResponseAudioState] = {} # Format is set once the session payload negotiates one. Audio deltas can # arrive before that for transcription-only sessions or when the payload # omits an audio format, so we default to None and let the length @@ -28,21 +35,54 @@ def set_audio_format(self, format: RealtimeAudioFormat) -> None: """Called when the model wants to set the audio format.""" self._format = format - def on_audio_delta(self, item_id: str, item_content_index: int, audio_bytes: bytes) -> None: + def on_audio_delta( + self, + item_id: str, + item_content_index: int, + audio_bytes: bytes, + response_id: str | None = None, + ) -> None: """Called when an audio delta is received from the model.""" ms = calculate_audio_length_ms(self._format, audio_bytes) new_key = (item_id, item_content_index) self._last_audio_item = new_key + if response_id is not None: + response_state = self._audio_items_by_response_id.setdefault( + response_id, + ModelResponseAudioState(items=[]), + ) + if new_key not in response_state.items: + response_state.items.append(new_key) if new_key not in self._states: - self._states[new_key] = ModelAudioState(time.monotonic(), ms) + self._states[new_key] = ModelAudioState(time.monotonic(), ms, response_id) else: self._states[new_key].audio_length_ms += ms + if response_id is not None: + self._states[new_key].response_id = response_id def on_interrupted(self) -> None: """Called when the audio playback has been interrupted.""" + if self._last_audio_item is not None: + state = self._states.get(self._last_audio_item) + if state is not None and state.response_id is not None: + self._audio_items_by_response_id.pop(state.response_id, None) self._last_audio_item = None + def on_response_interrupted(self, response_id: str) -> None: + """Called when buffered audio for a specific response has been interrupted.""" + interrupted_state = self._audio_items_by_response_id.pop(response_id, None) + if interrupted_state is not None and self._last_audio_item in interrupted_state.items: + self._last_audio_item = None + + def on_response_done(self, response_id: str) -> None: + """Release response-scoped indexes after guardrails settle.""" + self._audio_items_by_response_id.pop(response_id, None) + + def clear_response_indexes(self) -> None: + """Release every response-scoped index when the model connection closes.""" + self._audio_items_by_response_id.clear() + def get_state(self, item_id: str, item_content_index: int) -> ModelAudioState | None: """Called when the model wants to get the current playback state.""" return self._states.get((item_id, item_content_index)) @@ -50,3 +90,8 @@ def get_state(self, item_id: str, item_content_index: int) -> ModelAudioState | def get_last_audio_item(self) -> tuple[str, int] | None: """Called when the model wants to get the last audio item ID and content index.""" return self._last_audio_item + + def get_audio_items_for_response(self, response_id: str) -> tuple[tuple[str, int], ...]: + """Return every audio item received for a response in arrival order.""" + response_state = self._audio_items_by_response_id.get(response_id) + return tuple(response_state.items) if response_state is not None else () diff --git a/src/agents/realtime/model.py b/src/agents/realtime/model.py index 8c8ef21b87..1a5efa7f93 100644 --- a/src/agents/realtime/model.py +++ b/src/agents/realtime/model.py @@ -186,6 +186,10 @@ async def send_event_if( """ return False + def _retire_response_audio(self, response_id: str) -> None: + """Release model-owned response audio indexes after session guardrails settle.""" + return None + @abc.abstractmethod async def close(self) -> None: """Close the session.""" diff --git a/src/agents/realtime/model_events.py b/src/agents/realtime/model_events.py index 133099cfc9..af21ce0a5f 100644 --- a/src/agents/realtime/model_events.py +++ b/src/agents/realtime/model_events.py @@ -203,6 +203,8 @@ class RealtimeModelTurnEndedEvent: """Triggered when the model finishes generating a response for a turn.""" type: Literal["turn_ended"] = "turn_ended" + response_id: str | None = None + """Provider response ID for this turn, when available.""" @dataclass diff --git a/src/agents/realtime/model_inputs.py b/src/agents/realtime/model_inputs.py index 7bb79aed3a..91823c33a8 100644 --- a/src/agents/realtime/model_inputs.py +++ b/src/agents/realtime/model_inputs.py @@ -99,14 +99,19 @@ class RealtimeModelSendInterrupt: """Force sending a response.cancel event even if automatic cancellation is enabled.""" response_id: str | None = None - """Limit response cancellation to this response ID, when supported by the model. + """Scope response cancellation and audio playback interruption to this response ID, when + supported by the model. - Audio playback is still interrupted unless `cancel_response_only` is set. + If the transport no longer has audio for this response, it must not interrupt unrelated current + playback. Audio playback is skipped when `cancel_response_only` is set. """ cancel_response_only: bool = False """Cancel only `response_id` without interrupting audio playback.""" + playback_only: bool = False + """Interrupt playback for `response_id` without cancelling a response.""" + @dataclass class RealtimeModelSendSessionUpdate: diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index aa7065dec3..ec4a156c58 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -219,12 +219,21 @@ class _PendingResponseCreate: is_manual: bool +class _RealtimeInterruptError(RuntimeError): + def __init__(self, errors: list[tuple[str, Exception]]) -> None: + self.errors = tuple(errors) + details = "; ".join(f"{operation}={error!r}" for operation, error in errors) + super().__init__(f"Multiple Realtime interrupt operations failed: {details}") + + class _ResponseCreateSequencer: """Tracks local response sequencing around response.create and response.cancel.""" def __init__(self) -> None: self._ongoing_response = False + self._ongoing_response_id: str | None = None self._response_control: Literal["free", "create_requested", "cancel_requested"] = "free" + self._active_cancel_token: object | None = None self._response_create_request_version = 0 self._response_create_event_counter = 0 self._pending_request_versions: set[int] = set() @@ -268,6 +277,8 @@ def _auto_response_create_target_version(self, request_version: int) -> int: def set_ongoing_response_for_test(self, value: bool) -> None: self._ongoing_response = value + if not value: + self._ongoing_response_id = None async def set_response_control( self, control: Literal["free", "create_requested", "cancel_requested"] @@ -276,29 +287,41 @@ async def set_response_control( self._response_control = control self._condition.notify_all() - async def mark_response_created(self) -> None: + async def mark_response_created(self, response_id: str | None = None) -> None: async with self._condition: self._ongoing_response = True + self._ongoing_response_id = response_id self._pending_response_create = None self._response_control = "free" + self._active_cancel_token = None self._condition.notify_all() - async def mark_response_done(self) -> None: + async def mark_response_done(self, response_id: str | None = None) -> None: async with self._condition: + if ( + response_id is not None + and self._ongoing_response_id is not None + and response_id != self._ongoing_response_id + ): + return self._ongoing_response = False + self._ongoing_response_id = None self._pending_response_create = None self._response_control = "free" + self._active_cancel_token = None self._condition.notify_all() async def release_waiters(self) -> None: async with self._condition: self._ongoing_response = False + self._ongoing_response_id = None self._pending_response_create = None self._pending_request_versions.clear() self._manual_response_create_versions.clear() self._response_create_request_version = 0 self._response_create_event_counter = 0 self._response_control = "free" + self._active_cancel_token = None self._condition.notify_all() async def reserve_response_create_request(self, *, manual: bool = False) -> int: @@ -380,12 +403,28 @@ async def mark_response_create_sent(self, pending: _PendingResponseCreate) -> No self._manual_response_create_versions.difference_update(covered_versions) self._condition.notify_all() - async def begin_cancel_response(self) -> bool: + async def begin_cancel_response(self, response_id: str | None = None) -> object | None: async with self._condition: if not self._ongoing_response or self._response_control == "cancel_requested": - return False + return None + if ( + response_id is not None + and self._ongoing_response_id is not None + and response_id != self._ongoing_response_id + ): + return None + cancel_token = object() self._response_control = "cancel_requested" - return True + self._active_cancel_token = cancel_token + return cancel_token + + async def release_cancel_response(self, cancel_token: object) -> None: + async with self._condition: + if self._active_cancel_token is not cancel_token: + return + self._response_control = "free" + self._active_cancel_token = None + self._condition.notify_all() async def has_pending_response_create(self) -> bool: async with self._condition: @@ -513,6 +552,7 @@ def __init__(self, *, transport_config: TransportConfig | None = None) -> None: self._listeners: list[RealtimeModelListener] = [] self._current_item_id: str | None = None self._audio_state_tracker: ModelAudioTracker = ModelAudioTracker() + self._interrupted_audio_response_ids: set[str] = set() self._response_create_sequencer = _ResponseCreateSequencer() self._tracing_config: RealtimeModelTracingConfig | Literal["auto"] | None = None self._playback_tracker: RealtimePlaybackTracker | None = None @@ -758,11 +798,11 @@ async def _set_response_control( ) -> None: await self._response_create_sequencer.set_response_control(control) - async def _mark_response_created(self) -> None: - await self._response_create_sequencer.mark_response_created() + async def _mark_response_created(self, response_id: str | None = None) -> None: + await self._response_create_sequencer.mark_response_created(response_id) - async def _mark_response_done(self) -> None: - await self._response_create_sequencer.mark_response_done() + async def _mark_response_done(self, response_id: str | None = None) -> None: + await self._response_create_sequencer.mark_response_done(response_id) async def _release_response_waiters(self) -> None: # Connection teardown means no response.done will arrive, so local @@ -945,18 +985,55 @@ def _get_audio_limits(self, item_id: str, item_content_index: int) -> tuple[floa max_audio_ms = int(math.ceil(audio_state.audio_length_ms)) return audio_state.audio_length_ms, max_audio_ms - async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: - if event.cancel_response_only: - if event.response_id is None: - raise ValueError("cancel_response_only requires response_id") - await self._cancel_response(response_id=event.response_id) - return - + async def _interrupt_audio_playback( + self, + event: RealtimeModelSendInterrupt, + ) -> list[tuple[str, Exception]]: + errors: list[tuple[str, Exception]] = [] playback_state = self._get_playback_state() current_item_id = playback_state.get("current_item_id") current_item_content_index = playback_state.get("current_item_content_index") elapsed_ms = playback_state.get("elapsed_ms") + response_scoped = event.response_id is not None + source_audio_items: tuple[tuple[str, int], ...] = () + if response_scoped: + assert event.response_id is not None + source_audio_items = self._audio_state_tracker.get_audio_items_for_response( + event.response_id + ) + if not source_audio_items: + return errors + for source_audio_item in source_audio_items: + try: + await self._emit_event( + RealtimeModelAudioInterruptedEvent( + item_id=source_audio_item[0], + content_index=source_audio_item[1], + ) + ) + except Exception as exc: + errors.append(("emit_audio_interrupted", exc)) + + current_audio_item = ( + (current_item_id, current_item_content_index or 0) + if current_item_id is not None + else None + ) + if current_audio_item not in source_audio_items: + playback_state = self._get_playback_state() + current_item_id = playback_state.get("current_item_id") + current_item_content_index = playback_state.get("current_item_content_index") + elapsed_ms = playback_state.get("elapsed_ms") + current_audio_item = ( + (current_item_id, current_item_content_index or 0) + if current_item_id is not None + else None + ) + if current_audio_item not in source_audio_items: + self._audio_state_tracker.on_response_interrupted(event.response_id) + return errors + if current_item_id is None or elapsed_ms is None: logger.debug( "Skipping interrupt. Item id: %s, elapsed ms: %s, content index: %s", @@ -967,28 +1044,37 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: else: current_item_content_index = current_item_content_index or 0 if elapsed_ms > 0: - await self._emit_event( - RealtimeModelAudioInterruptedEvent( - item_id=current_item_id, - content_index=current_item_content_index, - ) - ) + if not response_scoped: + try: + await self._emit_event( + RealtimeModelAudioInterruptedEvent( + item_id=current_item_id, + content_index=current_item_content_index, + ) + ) + except Exception as exc: + errors.append(("emit_audio_interrupted", exc)) max_audio_ms: int | None = None audio_limits = self._get_audio_limits(current_item_id, current_item_content_index) if audio_limits is not None: _, max_audio_ms = audio_limits truncated_ms = max(int(elapsed_ms), 0) - if self._ongoing_response or max_audio_ms is None or truncated_ms < max_audio_ms: + if ( + (self._ongoing_response and not event.playback_only) + or max_audio_ms is None + or truncated_ms < max_audio_ms + ): if max_audio_ms is not None: - # Never truncate past the audio this client received: the Realtime API - # rejects an audio_end_ms beyond the item's audio duration. truncated_ms = min(truncated_ms, max_audio_ms) converted = _ConversionHelper.convert_interrupt( current_item_id, current_item_content_index, truncated_ms, ) - await self._send_raw_message(converted) + try: + await self._send_raw_message(converted) + except Exception as exc: + errors.append(("truncate_audio", exc)) else: logger.debug( "Didn't interrupt bc elapsed ms is < 0. Item id: %s, " @@ -998,24 +1084,59 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: current_item_content_index, ) - session = self._created_session - automatic_response_cancellation_enabled = ( - session - and session.audio is not None - and session.audio.input is not None - and session.audio.input.turn_detection is not None - and session.audio.input.turn_detection.interrupt_response is True - ) - should_cancel_response = event.force_response_cancel or ( - not automatic_response_cancellation_enabled - ) - if should_cancel_response: - await self._cancel_response(response_id=event.response_id) - if current_item_id is not None and elapsed_ms is not None: - self._audio_state_tracker.on_interrupted() + if response_scoped and event.response_id is not None: + self._audio_state_tracker.on_response_interrupted(event.response_id) + else: + self._audio_state_tracker.on_interrupted() if self._playback_tracker: - self._playback_tracker.on_interrupted() + latest_playback_state = self._playback_tracker.get_state() + latest_item_id = latest_playback_state.get("current_item_id") + latest_content_index = latest_playback_state.get("current_item_content_index") or 0 + latest_audio_item = ( + (latest_item_id, latest_content_index) if latest_item_id is not None else None + ) + if not response_scoped or latest_audio_item in source_audio_items: + self._playback_tracker.on_interrupted() + + return errors + + async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: + if event.playback_only and (event.cancel_response_only or event.force_response_cancel): + raise ValueError("playback_only cannot be combined with explicit cancellation modes") + if event.cancel_response_only: + if event.response_id is None: + raise ValueError("cancel_response_only requires response_id") + await self._cancel_response(response_id=event.response_id) + return + + if event.response_id is not None: + self._interrupted_audio_response_ids.add(event.response_id) + + errors = await self._interrupt_audio_playback(event) + + if not event.playback_only: + session = self._created_session + automatic_response_cancellation_enabled = ( + session + and session.audio is not None + and session.audio.input is not None + and session.audio.input.turn_detection is not None + and session.audio.input.turn_detection.interrupt_response is True + ) + should_cancel_response = event.force_response_cancel or ( + not automatic_response_cancellation_enabled + ) + if should_cancel_response: + try: + await self._cancel_response(response_id=event.response_id) + except Exception as exc: + errors.append(("cancel_response", exc)) + + if len(errors) == 1: + raise errors[0][1] + if errors: + raise _RealtimeInterruptError(errors) from errors[0][1] async def _send_session_update(self, event: RealtimeModelSendSessionUpdate) -> None: """Send a session update to the model.""" @@ -1023,11 +1144,19 @@ async def _send_session_update(self, event: RealtimeModelSendSessionUpdate) -> N async def _handle_audio_delta(self, parsed: ResponseAudioDeltaEvent) -> None: """Handle audio delta events and update audio tracking state.""" + if parsed.response_id in self._interrupted_audio_response_ids: + return + self._current_item_id = parsed.item_id audio_bytes = base64.b64decode(parsed.delta) - self._audio_state_tracker.on_audio_delta(parsed.item_id, parsed.content_index, audio_bytes) + self._audio_state_tracker.on_audio_delta( + parsed.item_id, + parsed.content_index, + audio_bytes, + response_id=parsed.response_id, + ) await self._emit_event( RealtimeModelAudioEvent( @@ -1088,22 +1217,34 @@ async def _handle_conversation_item( async def close(self) -> None: """Close the session.""" - await self._cancel_response_create_tasks() - if self._websocket: - await self._websocket.close() - self._websocket = None - if self._websocket_task: - self._websocket_task.cancel() - try: - await self._websocket_task - except asyncio.CancelledError: - pass - self._websocket_task = None - else: - await self._release_response_waiters() + try: + await self._cancel_response_create_tasks() + if self._websocket: + await self._websocket.close() + self._websocket = None + if self._websocket_task: + self._websocket_task.cancel() + try: + await self._websocket_task + except asyncio.CancelledError: + pass + self._websocket_task = None + else: + await self._release_response_waiters() + finally: + self._clear_response_audio_indexes() + + def _retire_response_audio(self, response_id: str) -> None: + self._interrupted_audio_response_ids.discard(response_id) + self._audio_state_tracker.on_response_done(response_id) + + def _clear_response_audio_indexes(self) -> None: + self._interrupted_audio_response_ids.clear() + self._audio_state_tracker.clear_response_indexes() async def _cancel_response(self, *, response_id: str | None = None) -> None: - if not await self._response_create_sequencer.begin_cancel_response(): + cancel_token = await self._response_create_sequencer.begin_cancel_response(response_id) + if cancel_token is None: return cancel_event = ( @@ -1113,8 +1254,8 @@ async def _cancel_response(self, *, response_id: str | None = None) -> None: ) try: await self._send_raw_message(cancel_event) - except Exception: - await self._set_response_control("free") + except BaseException: + await self._response_create_sequencer.release_cancel_response(cancel_token) raise def _error_matches_pending_response_create(self, error: Any) -> bool: @@ -1272,15 +1413,18 @@ async def _handle_ws_event(self, event: dict[str, Any]): if not automatic_response_cancellation_enabled: await self._cancel_response() elif parsed.type == "response.created": - await self._mark_response_created() + await self._mark_response_created(parsed.response.id) await self._emit_event(RealtimeModelTurnStartedEvent(response_id=parsed.response.id)) elif parsed.type == "response.done": - await self._mark_response_done() + response_id = getattr(parsed.response, "id", None) + if response_id is not None: + self._interrupted_audio_response_ids.discard(response_id) + await self._mark_response_done(response_id) if parsed.response.usage is not None: await self._emit_event( _ConversionHelper.convert_response_usage(parsed.response.usage) ) - await self._emit_event(RealtimeModelTurnEndedEvent()) + await self._emit_event(RealtimeModelTurnEndedEvent(response_id=response_id)) elif parsed.type == "session.created": await self._send_tracing_config(self._tracing_config) self._update_created_session(parsed.session) diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 92c5ad3e47..43009511f9 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -238,6 +238,8 @@ def __init__( ) self._guardrail_tasks: set[asyncio.Task[Any]] = set() + self._guardrail_tasks_by_response_id: dict[str, set[asyncio.Task[Any]]] = {} + self._responses_awaiting_guardrail_cleanup: set[str] = set() self._tool_call_tasks: set[asyncio.Task[Any]] = set() self._async_tool_calls: bool = bool(self._run_config.get("async_tool_calls", True)) @@ -365,6 +367,15 @@ async def update_agent(self, agent: RealtimeAgent) -> None: RealtimeModelSendSessionUpdate(session_settings=updated_settings) ) + def _reconcile_output_response(self, response_id: str) -> None: + if self._active_output_response_generation is None: + self._latest_output_response_generation += 1 + self._active_output_response_generation = self._latest_output_response_generation + self._active_output_response_id = response_id + self._active_output_response_agent = self._current_agent + elif self._active_output_response_id is None: + self._active_output_response_id = response_id + async def on_event(self, event: RealtimeModelEvent) -> None: if self._closing or self._closed: return @@ -389,14 +400,15 @@ async def on_event(self, event: RealtimeModelEvent) -> None: handle_kwargs["dispatch_snapshot"] = dispatch_snapshot await self._handle_tool_call(event, **handle_kwargs) elif event.type == "audio": - await self._put_event( - RealtimeAudio( - info=self._event_info, - audio=event, - item_id=event.item_id, - content_index=event.content_index, + if event.response_id not in self._interrupted_response_ids: + await self._put_event( + RealtimeAudio( + info=self._event_info, + audio=event, + item_id=event.item_id, + content_index=event.content_index, + ) ) - ) elif event.type == "audio_interrupted": await self._put_event( RealtimeAudioInterrupted( @@ -429,10 +441,14 @@ async def on_event(self, event: RealtimeModelEvent) -> None: ) elif event.type == "transcript_delta": item_id = event.item_id + self._reconcile_output_response(event.response_id) self._record_output_guardrail_delta( item_id, event.delta, event.response_id, + agent_snapshot=self._active_output_response_agent, + output_response_generation=self._active_output_response_generation, + is_audio_output=True, ) self._history = self._get_new_history( self._history, @@ -443,17 +459,14 @@ async def on_event(self, event: RealtimeModelEvent) -> None: ) elif event.type == "output_text_delta": assert isinstance(event, RealtimeModelOutputTextDeltaEvent) - if self._active_output_response_generation is None: - self._latest_output_response_generation += 1 - self._active_output_response_generation = self._latest_output_response_generation - self._active_output_response_id = event.response_id - self._active_output_response_agent = self._current_agent + self._reconcile_output_response(event.response_id) self._record_output_guardrail_delta( event.item_id, event.delta, event.response_id, agent_snapshot=self._active_output_response_agent, output_response_generation=self._active_output_response_generation, + is_audio_output=False, ) elif event.type == "item_updated": is_new = not any(item.item_id == event.item.item_id for item in self._history) @@ -547,19 +560,27 @@ async def on_event(self, event: RealtimeModelEvent) -> None: assert isinstance(event, RealtimeModelUsageEvent) self._context_wrapper.usage.add(event.usage) elif event.type == "turn_ended": - # Clear guardrail state for next turn - self._item_transcripts.clear() - self._item_guardrail_run_counts.clear() - self._active_output_response_generation = None - self._active_output_response_id = None - self._active_output_response_agent = None + response_id = event.response_id or self._active_output_response_id + if response_id is not None: + self._finish_response_guardrail_lifecycle(response_id) - await self._put_event( - RealtimeAgentEndEvent( - agent=self._current_agent, - info=self._event_info, - ) + is_active_response_ended = ( + event.response_id is None or event.response_id == self._active_output_response_id ) + if is_active_response_ended: + # Clear guardrail state for next turn. + self._item_transcripts.clear() + self._item_guardrail_run_counts.clear() + self._active_output_response_generation = None + self._active_output_response_id = None + self._active_output_response_agent = None + + await self._put_event( + RealtimeAgentEndEvent( + agent=self._current_agent, + info=self._event_info, + ) + ) elif event.type == "exception": # Store the exception to be raised in __aiter__ self._stored_exception = event.exception @@ -1328,6 +1349,7 @@ async def _run_output_guardrails( *, agent_snapshot: RealtimeAgent[Any] | None = None, output_response_generation: int | None = None, + is_audio_output: bool = True, ) -> bool: """Run output guardrails on the given text. Returns True if any guardrail was triggered.""" if self._closing or self._closed: @@ -1397,13 +1419,27 @@ async def _run_output_guardrails( await self._model.send_event(RealtimeModelSendInterrupt(force_response_cancel=True)) else: if output_response_generation != self._latest_output_response_generation: + if is_audio_output: + await self._model.send_event( + RealtimeModelSendInterrupt( + response_id=response_id, + playback_only=True, + ) + ) return True if output_response_generation == self._active_output_response_generation: await self._model.send_event( RealtimeModelSendInterrupt( force_response_cancel=True, response_id=response_id, - cancel_response_only=True, + cancel_response_only=not is_audio_output, + ) + ) + elif is_audio_output: + await self._model.send_event( + RealtimeModelSendInterrupt( + response_id=response_id, + playback_only=True, ) ) @@ -1439,6 +1475,7 @@ def _record_output_guardrail_delta( *, agent_snapshot: RealtimeAgent[Any] | None = None, output_response_generation: int | None = None, + is_audio_output: bool = True, ) -> None: if item_id not in self._item_transcripts: self._item_transcripts[item_id] = "" @@ -1456,6 +1493,7 @@ def _record_output_guardrail_delta( response_id, agent_snapshot=agent_snapshot, output_response_generation=output_response_generation, + is_audio_output=is_audio_output, ) def _enqueue_guardrail_task( @@ -1465,6 +1503,7 @@ def _enqueue_guardrail_task( *, agent_snapshot: RealtimeAgent[Any] | None = None, output_response_generation: int | None = None, + is_audio_output: bool = True, ) -> None: # Runs the guardrails in a separate task to avoid blocking the main loop if self._closing or self._closed: @@ -1476,22 +1515,39 @@ def _enqueue_guardrail_task( response_id, agent_snapshot=agent_snapshot, output_response_generation=output_response_generation, + is_audio_output=is_audio_output, ) ) self._guardrail_tasks.add(task) + self._guardrail_tasks_by_response_id.setdefault(response_id, set()).add(task) # Add callback to remove completed tasks and handle exceptions - task.add_done_callback(self._on_guardrail_task_done) + task.add_done_callback(partial(self._on_guardrail_task_done, response_id=response_id)) - def _on_guardrail_task_done(self, task: asyncio.Task[Any]) -> None: + def _on_guardrail_task_done(self, task: asyncio.Task[Any], *, response_id: str) -> None: """Handle completion of a guardrail task.""" # Remove from tracking set self._guardrail_tasks.discard(task) + response_tasks = self._guardrail_tasks_by_response_id.get(response_id) + if response_tasks is not None: + response_tasks.discard(task) + if not response_tasks: + self._guardrail_tasks_by_response_id.pop(response_id, None) + + should_retire_response_audio = ( + response_id in self._responses_awaiting_guardrail_cleanup + and response_id not in self._guardrail_tasks_by_response_id + ) + if should_retire_response_audio: + self._responses_awaiting_guardrail_cleanup.discard(response_id) if self._closing or self._closed: self._consume_task_result(task) return + if should_retire_response_audio: + self._retire_response_audio(response_id) + # Check for exceptions and propagate as events if not task.cancelled(): exception = task.exception() @@ -1504,6 +1560,25 @@ def _on_guardrail_task_done(self, task: asyncio.Task[Any]) -> None: ) ) + def _finish_response_guardrail_lifecycle(self, response_id: str) -> None: + if response_id in self._guardrail_tasks_by_response_id: + self._responses_awaiting_guardrail_cleanup.add(response_id) + return + self._retire_response_audio(response_id) + + def _retire_response_audio(self, response_id: str) -> None: + try: + self._model._retire_response_audio(response_id) + except Exception as exception: + self._put_event_nowait( + RealtimeError( + info=self._event_info, + error={"message": f"Response audio cleanup failed: {exception}"}, + ) + ) + finally: + self._interrupted_response_ids.discard(response_id) + def _enqueue_tool_call_task( self, event: RealtimeModelToolCallEvent, @@ -1617,6 +1692,16 @@ def _wake_event_iterators(self) -> None: for _ in range(self._event_iterator_waiters): self._event_queue.put_nowait(_REALTIME_SESSION_CLOSED_SENTINEL) + def _clear_response_bookkeeping(self) -> None: + self._interrupted_response_ids.clear() + self._active_output_response_generation = None + self._active_output_response_id = None + self._active_output_response_agent = None + self._item_transcripts.clear() + self._item_guardrail_run_counts.clear() + self._guardrail_tasks_by_response_id.clear() + self._responses_awaiting_guardrail_cleanup.clear() + async def _cleanup(self) -> None: """Clean up all resources and mark session as closed.""" if self._closed: @@ -1628,6 +1713,7 @@ async def _cleanup(self) -> None: # Account for session-owned background work before closing its transport. await self._cancel_background_tasks() + self._clear_response_bookkeeping() # Close the model connection await self._model.close() diff --git a/tests/realtime/test_model_events.py b/tests/realtime/test_model_events.py index 031567b632..42213b2ebb 100644 --- a/tests/realtime/test_model_events.py +++ b/tests/realtime/test_model_events.py @@ -49,3 +49,19 @@ def test_custom_model_can_construct_typed_usage_without_openai_types() -> None: assert event.input_tokens_details.audio_tokens == 6 assert event.output_tokens_details is not None assert event.output_tokens_details.audio_tokens == 4 + + +def test_turn_ended_response_id_preserves_existing_positional_construction() -> None: + event = realtime.RealtimeModelTurnEndedEvent("turn_ended") + + assert event.type == "turn_ended" + assert event.response_id is None + + +def test_interrupt_playback_only_preserves_existing_positional_construction() -> None: + event = realtime.RealtimeModelSendInterrupt(True, "response_1", True) + + assert event.force_response_cancel is True + assert event.response_id == "response_1" + assert event.cancel_response_only is True + assert event.playback_only is False diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 38ed675390..2d57a4244e 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -13,7 +13,7 @@ from agents import Agent, function_tool from agents.exceptions import UserError from agents.handoffs import handoff -from agents.realtime.model import RealtimeModelConfig +from agents.realtime.model import RealtimeModelConfig, RealtimePlaybackTracker from agents.realtime.model_events import ( RealtimeModelAudioEvent, RealtimeModelErrorEvent, @@ -30,7 +30,11 @@ RealtimeModelSendToolOutput, RealtimeModelSendUserInput, ) -from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel, TransportConfig +from agents.realtime.openai_realtime import ( + OpenAIRealtimeWebSocketModel, + TransportConfig, + _RealtimeInterruptError, +) class TestOpenAIRealtimeWebSocketModel: @@ -719,7 +723,10 @@ def validate_python(self, event): return SimpleNamespace( type=event["type"], - response=SimpleNamespace(usage=RealtimeResponseUsage.model_validate(usage)), + response=SimpleNamespace( + id="response_1", + usage=RealtimeResponseUsage.model_validate(usage), + ), ) model._server_event_type_adapter = ResponseDoneAdapter() @@ -738,6 +745,7 @@ def validate_python(self, event): assert isinstance(emitted[1], RealtimeModelUsageEvent) assert emitted[1].input_tokens_details is not None assert emitted[1].input_tokens_details.audio_tokens == 10 + assert emitted[2].response_id == "response_1" @pytest.mark.asyncio async def test_response_done_without_usage_skips_usage_event(self, model): @@ -1202,6 +1210,136 @@ async def test_response_only_interrupt_targets_response_without_touching_audio( emit_event.assert_not_awaited() assert model._audio_state_tracker.get_last_audio_item() == ("audio_item", 0) + @pytest.mark.asyncio + async def test_concurrent_response_only_interrupts_cancel_response_once( + self, model, monkeypatch + ): + await model._mark_response_created() + send_started = asyncio.Event() + allow_send = asyncio.Event() + sent_events = [] + + async def send_raw(event): + sent_events.append(event) + send_started.set() + await allow_send.wait() + + monkeypatch.setattr(model, "_send_raw_message", send_raw) + interrupt = RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + cancel_response_only=True, + ) + + first = asyncio.create_task(model.send_event(interrupt)) + await send_started.wait() + second = asyncio.create_task(model.send_event(interrupt)) + await asyncio.sleep(0) + allow_send.set() + await asyncio.gather(first, second) + + assert [(event.type, event.response_id) for event in sent_events] == [ + ("response.cancel", "response_1") + ] + assert model._response_control == "cancel_requested" + await model._mark_response_done("response_1") + assert model._response_control == "free" + + @pytest.mark.asyncio + async def test_response_only_interrupt_stays_deduplicated_until_response_done( + self, model, monkeypatch + ): + await model._mark_response_created("response_1") + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + first_interrupt = RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + cancel_response_only=True, + ) + + await model.send_event(first_interrupt) + await model.send_event(first_interrupt) + + assert [call.args[0].response_id for call in send_raw.await_args_list] == ["response_1"] + assert model._response_control == "cancel_requested" + + await model._mark_response_done("response_1") + await model._mark_response_created("response_2") + await model.send_event( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_2", + cancel_response_only=True, + ) + ) + + assert [call.args[0].response_id for call in send_raw.await_args_list] == [ + "response_1", + "response_2", + ] + assert model._response_control == "cancel_requested" + await model._mark_response_done("response_1") + assert model._response_control == "cancel_requested" + await model._mark_response_done("response_2") + assert model._response_control == "free" + + @pytest.mark.asyncio + async def test_response_only_interrupt_can_retry_after_send_failure(self, model, monkeypatch): + await model._mark_response_created() + send_error = RuntimeError("cancel failed") + send_raw = AsyncMock(side_effect=[send_error, None]) + monkeypatch.setattr(model, "_send_raw_message", send_raw) + interrupt = RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + cancel_response_only=True, + ) + + with pytest.raises(RuntimeError) as exc_info: + await model.send_event(interrupt) + await model.send_event(interrupt) + + assert exc_info.value is send_error + assert send_raw.await_count == 2 + assert model._response_control == "cancel_requested" + await model._mark_response_done("response_1") + assert model._response_control == "free" + + @pytest.mark.asyncio + async def test_response_only_interrupt_can_retry_after_send_cancellation( + self, model, monkeypatch + ): + await model._mark_response_created() + send_started = asyncio.Event() + + async def blocked_send(_event): + send_started.set() + await asyncio.Event().wait() + + monkeypatch.setattr(model, "_send_raw_message", blocked_send) + interrupt = RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + cancel_response_only=True, + ) + + first = asyncio.create_task(model.send_event(interrupt)) + await send_started.wait() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + assert model._response_control == "free" + retry_send = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", retry_send) + + await model.send_event(interrupt) + + retry_send.assert_awaited_once() + assert retry_send.await_args is not None + assert retry_send.await_args.args[0].response_id == "response_1" + @pytest.mark.asyncio async def test_response_only_interrupt_skips_cancel_after_response_done( self, model, monkeypatch @@ -1231,7 +1369,9 @@ async def test_response_only_interrupt_skips_cancel_after_response_done( @pytest.mark.asyncio async def test_normal_interrupt_targets_response_and_interrupts_audio(self, model, monkeypatch): model._audio_state_tracker.set_audio_format("pcm16") - model._audio_state_tracker.on_audio_delta("audio_item", 0, b"\x00" * 4800) + model._audio_state_tracker.on_audio_delta( + "audio_item", 0, b"\x00" * 4800, response_id="response_1" + ) await model._mark_response_created() send_raw = AsyncMock() @@ -1256,11 +1396,415 @@ async def test_normal_interrupt_targets_response_and_interrupts_audio(self, mode assert emit_event.await_args.args[0].type == "audio_interrupted" assert model._audio_state_tracker.get_last_audio_item() is None + @pytest.mark.asyncio + async def test_playback_only_interrupt_does_not_stop_newer_response_audio( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "old_audio_item", + 0, + b"\x00" * 4800, + response_id="old_response", + ) + model._audio_state_tracker.on_audio_delta( + "new_audio_item", + 0, + b"\x00" * 4800, + response_id="new_response", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._playback_tracker.on_play_ms("new_audio_item", 0, 50) + await model._mark_response_created() + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + response_id="old_response", + playback_only=True, + ) + ) + + send_raw.assert_not_awaited() + assert [call.args[0].item_id for call in emit_event.await_args_list] == ["old_audio_item"] + assert model._ongoing_response is True + assert model._response_control == "free" + assert model._playback_tracker.get_state()["current_item_id"] == "new_audio_item" + assert model._audio_state_tracker.get_audio_items_for_response("old_response") == () + + @pytest.mark.asyncio + async def test_response_scoped_interrupt_rechecks_playback_after_event_listener( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "old_audio_item", + 0, + b"\x00" * 4800, + response_id="old_response", + ) + model._audio_state_tracker.on_audio_delta( + "new_audio_item", + 0, + b"\x00" * 4800, + response_id="new_response", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._playback_tracker.on_play_ms("old_audio_item", 0, 50) + + async def advance_playback(_event): + model._playback_tracker.on_play_ms("new_audio_item", 0, 25) + + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", AsyncMock(side_effect=advance_playback)) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + response_id="old_response", + playback_only=True, + ) + ) + + assert send_raw.await_count == 1 + assert send_raw.await_args is not None + assert send_raw.await_args.args[0].item_id == "old_audio_item" + assert model._playback_tracker.get_state()["current_item_id"] == "new_audio_item" + + @pytest.mark.asyncio + async def test_response_scoped_interrupt_rechecks_playback_before_skipping_source( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "old_audio_item", + 0, + b"\x00" * 4800, + response_id="old_response", + ) + model._audio_state_tracker.on_audio_delta( + "new_audio_item", + 0, + b"\x00" * 4800, + response_id="new_response", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._playback_tracker.on_play_ms("new_audio_item", 0, 50) + + async def start_source_playback(_event): + model._playback_tracker.on_play_ms("old_audio_item", 0, 25) + + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", AsyncMock(side_effect=start_source_playback)) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + response_id="old_response", + playback_only=True, + ) + ) + + send_raw.assert_awaited_once() + assert send_raw.await_args is not None + assert send_raw.await_args.args[0].item_id == "old_audio_item" + assert model._playback_tracker.get_state()["current_item_id"] is None + + @pytest.mark.asyncio + async def test_response_scoped_interrupt_does_not_consume_new_response_cancel_state( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "old_audio_item", + 0, + b"\x00" * 4800, + response_id="old_response", + ) + model._audio_state_tracker.on_audio_delta( + "new_audio_item", + 0, + b"\x00" * 4800, + response_id="new_response", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._playback_tracker.on_play_ms("old_audio_item", 0, 50) + await model._mark_response_created("old_response") + + async def advance_response(_event): + await model._mark_response_done("old_response") + await model._mark_response_created("new_response") + model._playback_tracker.on_play_ms("new_audio_item", 0, 25) + + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", AsyncMock(side_effect=advance_response)) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="old_response", + ) + ) + + assert model._response_control == "free" + monkeypatch.setattr(model, "_emit_event", AsyncMock()) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="new_response", + ) + ) + + cancel_events = [ + call.args[0] + for call in send_raw.await_args_list + if call.args[0].type == "response.cancel" + ] + assert [event.response_id for event in cancel_events] == ["new_response"] + + @pytest.mark.asyncio + async def test_interrupt_preserves_single_failure_after_attempting_cancel( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "audio_item", + 0, + b"\x00" * 4800, + response_id="response_1", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._playback_tracker.on_play_ms("audio_item", 0, 50) + await model._mark_response_created() + + listener_error = RuntimeError("listener failed") + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", AsyncMock(side_effect=listener_error)) + + with pytest.raises(RuntimeError) as exc_info: + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + ) + ) + + assert exc_info.value is listener_error + assert [call.args[0].type for call in send_raw.await_args_list] == [ + "conversation.item.truncate", + "response.cancel", + ] + + @pytest.mark.asyncio + async def test_interrupt_aggregates_failures_in_operation_order(self, model, monkeypatch): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "audio_item", + 0, + b"\x00" * 4800, + response_id="response_1", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._playback_tracker.on_play_ms("audio_item", 0, 50) + await model._mark_response_created() + + listener_error = RuntimeError("listener failed") + truncate_error = RuntimeError("truncate failed") + cancel_error = RuntimeError("cancel failed") + + async def fail_raw_message(event): + if event.type == "conversation.item.truncate": + raise truncate_error + if event.type == "response.cancel": + raise cancel_error + raise AssertionError(f"Unexpected event type: {event.type}") + + monkeypatch.setattr(model, "_send_raw_message", AsyncMock(side_effect=fail_raw_message)) + monkeypatch.setattr(model, "_emit_event", AsyncMock(side_effect=listener_error)) + + with pytest.raises(_RealtimeInterruptError) as exc_info: + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="response_1", + ) + ) + + assert exc_info.value.errors == ( + ("emit_audio_interrupted", listener_error), + ("truncate_audio", truncate_error), + ("cancel_response", cancel_error), + ) + + @pytest.mark.asyncio + async def test_response_scoped_interrupt_suppresses_late_source_audio_until_done( + self, model, monkeypatch + ): + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + await model._send_interrupt( + RealtimeModelSendInterrupt( + force_response_cancel=True, + response_id="source_response", + ) + ) + + await model._handle_audio_delta( + SimpleNamespace( + response_id="source_response", + item_id="source_item", + content_index=0, + delta="dGVzdA==", + ) + ) + await model._handle_audio_delta( + SimpleNamespace( + response_id="newer_response", + item_id="newer_item", + content_index=0, + delta="dGVzdA==", + ) + ) + + assert model._audio_state_tracker.get_state("source_item", 0) is None + assert model._audio_state_tracker.get_state("newer_item", 0) is not None + assert [ + event.response_id for event in (call.args[0] for call in emit_event.await_args_list) + ] == ["newer_response"] + + class ResponseDoneAdapter: + def validate_python(self, event): + return SimpleNamespace( + type=event["type"], + response=SimpleNamespace(id="source_response", usage=None), + ) + + model._server_event_type_adapter = ResponseDoneAdapter() + await model._handle_ws_event({"type": "response.done", "response": {}}) + + assert "source_response" not in model._interrupted_audio_response_ids + + def test_response_audio_indexes_are_bounded_after_retirement(self, model): + model._audio_state_tracker.set_audio_format("pcm16") + for response_number in range(20): + response_id = f"response_{response_number}" + item_id = f"item_{response_number}" + model._audio_state_tracker.on_audio_delta( + item_id, + 0, + b"\x00" * 4800, + response_id=response_id, + ) + state = model._audio_state_tracker.get_state(item_id, 0) + assert state is not None + state.initial_received_time -= 1 + model._retire_response_audio(response_id) + + assert model._audio_state_tracker._audio_items_by_response_id == {} + assert model._audio_state_tracker.get_state("item_19", 0) is not None + + def test_custom_playback_that_never_starts_releases_response_index(self, model): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "first_item", + 0, + b"\x00" * 4800, + response_id="response_1", + ) + model._audio_state_tracker.on_audio_delta( + "second_item", + 0, + b"\x00" * 4800, + response_id="response_1", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._retire_response_audio("response_1") + + assert model._audio_state_tracker.get_audio_items_for_response("response_1") == () + + @pytest.mark.asyncio + async def test_close_releases_pending_response_audio_indexes(self, model): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "audio_item", + 0, + b"\x00" * 4800, + response_id="response_1", + ) + model._interrupted_audio_response_ids.add("response_1") + + await model.close() + + assert model._audio_state_tracker.get_audio_items_for_response("response_1") == () + assert model._interrupted_audio_response_ids == set() + + @pytest.mark.asyncio + async def test_close_failure_releases_pending_response_audio_indexes(self, model): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "audio_item", + 0, + b"\x00" * 4800, + response_id="response_1", + ) + model._interrupted_audio_response_ids.add("response_1") + close_error = RuntimeError("close failed") + model._websocket = AsyncMock() + model._websocket.close.side_effect = close_error + + with pytest.raises(RuntimeError) as exc_info: + await model.close() + + assert exc_info.value is close_error + assert model._audio_state_tracker.get_audio_items_for_response("response_1") == () + assert model._interrupted_audio_response_ids == set() + @pytest.mark.asyncio async def test_response_only_interrupt_requires_response_id(self, model): with pytest.raises(ValueError, match="cancel_response_only requires response_id"): await model._send_interrupt(RealtimeModelSendInterrupt(cancel_response_only=True)) + @pytest.mark.asyncio + @pytest.mark.parametrize( + "cancellation_mode", + [ + {"cancel_response_only": True}, + {"force_response_cancel": True}, + ], + ) + async def test_interrupt_rejects_contradictory_modes_before_side_effects( + self, model, monkeypatch, cancellation_mode + ): + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + with pytest.raises( + ValueError, + match="playback_only cannot be combined with explicit cancellation modes", + ): + await model._send_interrupt( + RealtimeModelSendInterrupt( + response_id="response_1", + playback_only=True, + **cancellation_mode, + ) + ) + + send_raw.assert_not_awaited() + emit_event.assert_not_awaited() + @pytest.mark.asyncio async def test_interrupt_respects_auto_cancellation_when_not_forced(self, model, monkeypatch): """Interrupt should avoid sending response.cancel when relying on automatic cancellation.""" @@ -1751,6 +2295,35 @@ async def test_release_response_waiters_clears_active_response_state(self, model assert model._response_control == "free" assert model._pending_response_create_event_id is None + @pytest.mark.asyncio + async def test_release_response_waiters_preserves_audio_for_delayed_guardrail( + self, model, monkeypatch + ): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta( + "source_item", + 0, + b"\x00" * 4800, + response_id="source_response", + ) + model._playback_tracker = RealtimePlaybackTracker() + model._playback_tracker.on_play_ms("source_item", 0, 50) + emit_event = AsyncMock() + monkeypatch.setattr(model, "_emit_event", emit_event) + monkeypatch.setattr(model, "_send_raw_message", AsyncMock()) + + await model._release_response_waiters() + await model._send_interrupt( + RealtimeModelSendInterrupt( + response_id="source_response", + playback_only=True, + ) + ) + + assert emit_event.await_count == 1 + assert emit_event.await_args is not None + assert emit_event.await_args.args[0].item_id == "source_item" + @pytest.mark.asyncio async def test_close_cancels_waiting_response_create_after_active_response(self, model): """Closing should cancel deferred response.create work for the old connection.""" diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 4e4d0b086c..a549c13fe9 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -337,6 +337,27 @@ async def close(self): assert session._closed +@pytest.mark.asyncio +async def test_close_clears_response_bookkeeping_when_model_close_fails(): + class FailingCloseModel(_DummyModel): + async def close(self): + raise RuntimeError("close failed") + + session = RealtimeSession(FailingCloseModel(), RealtimeAgent(name="agent"), None) + session._interrupted_response_ids.add("response_1") + session._active_output_response_id = "response_1" + session._guardrail_tasks_by_response_id["response_1"] = set() + session._responses_awaiting_guardrail_cleanup.add("response_1") + + with pytest.raises(RuntimeError, match="close failed"): + await session.close() + + assert session._interrupted_response_ids == set() + assert session._active_output_response_id is None + assert session._guardrail_tasks_by_response_id == {} + assert session._responses_awaiting_guardrail_cleanup == set() + + @pytest.mark.asyncio async def test_cancelling_one_close_waiter_does_not_cancel_cleanup(): class BlockingCloseModel(_DummyModel): @@ -710,7 +731,7 @@ async def failing_task(): except Exception: # noqa: S110 pass - session._on_guardrail_task_done(task) + session._on_guardrail_task_done(task, response_id="response_1") err = session._event_queue.get_nowait() assert isinstance(err, RealtimeError) @@ -1063,6 +1084,7 @@ def __init__(self): self.sent_audio = [] self.sent_tool_outputs = [] self.interrupts_called = 0 + self.retired_audio_response_ids = [] async def connect(self, options=None): self.connect_called = True @@ -1103,6 +1125,9 @@ async def send_event_if(self, event, send_if): async def close(self): self.close_called = True + def _retire_response_audio(self, response_id: str) -> None: + self.retired_audio_response_ids.append(response_id) + @pytest.fixture def mock_agent(): @@ -3807,6 +3832,197 @@ async def delayed_guardrail(context, agent, output): queued_events.append(await session._event_queue.get()) assert sum(isinstance(event, RealtimeGuardrailTripped) for event in queued_events) == 1 + @pytest.mark.asyncio + async def test_stale_audio_guardrail_interrupts_only_source_playback(self, mock_model): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + session = RealtimeSession( + mock_model, + RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + ], + ), + None, + run_config={"guardrails_settings": {"debounce_text_length": 1}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + await session.on_event(RealtimeModelTurnEndedEvent(response_id="response_1")) + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) + + assert mock_model.retired_audio_response_ids == [] + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + interrupts = [ + event + for event in mock_model.sent_events + if isinstance(event, RealtimeModelSendInterrupt) + ] + assert len(interrupts) == 1 + assert interrupts[0].response_id == "response_1" + assert interrupts[0].playback_only is True + assert interrupts[0].force_response_cancel is False + assert mock_model.sent_messages == [] + assert mock_model.retired_audio_response_ids == ["response_1"] + assert session._interrupted_response_ids == set() + + @pytest.mark.asyncio + async def test_response_audio_cleanup_waits_for_delayed_guardrail(self, mock_agent): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + operations: list[str] = [] + + class TrackingModel(MockRealtimeModel): + async def send_event(self, event): + await super().send_event(event) + if isinstance(event, RealtimeModelSendInterrupt): + operations.append("interrupt") + + def _retire_response_audio(self, response_id: str) -> None: + super()._retire_response_audio(response_id) + operations.append("retire") + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + model = TrackingModel() + session = RealtimeSession( + model, + mock_agent, + None, + run_config={ + "output_guardrails": [ + OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + ], + "guardrails_settings": {"debounce_text_length": 1}, + }, + ) + + await session.on_event(RealtimeModelTurnStartedEvent()) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + assert session._active_output_response_id == "response_1" + await session.on_event(RealtimeModelTurnEndedEvent()) + await asyncio.sleep(0) + + assert operations == [] + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + assert operations == ["interrupt", "retire"] + assert model.retired_audio_response_ids == ["response_1"] + assert session._guardrail_tasks_by_response_id == {} + assert session._responses_awaiting_guardrail_cleanup == set() + + @pytest.mark.asyncio + async def test_response_audio_cleanup_runs_immediately_without_guardrail_tasks( + self, mock_model, mock_agent + ): + session = RealtimeSession(mock_model, mock_agent, None) + + await session.on_event(RealtimeModelTurnEndedEvent(response_id="response_1")) + + assert mock_model.retired_audio_response_ids == ["response_1"] + + @pytest.mark.asyncio + async def test_stale_explicit_turn_end_preserves_active_response_guardrail_state( + self, mock_model, mock_agent + ): + session = RealtimeSession(mock_model, mock_agent, None) + await session.on_event(RealtimeModelTurnStartedEvent(response_id="new_response")) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="new_item", + delta="still active", + response_id="new_response", + ) + ) + active_generation = session._active_output_response_generation + active_agent = session._active_output_response_agent + + await session.on_event(RealtimeModelTurnEndedEvent(response_id="old_response")) + + assert mock_model.retired_audio_response_ids == ["old_response"] + assert session._active_output_response_id == "new_response" + assert session._active_output_response_generation == active_generation + assert session._active_output_response_agent is active_agent + assert session._item_transcripts == {"new_item": "still active"} + assert session._item_guardrail_run_counts == {"new_item": 0} + queued_events = [] + while not session._event_queue.empty(): + queued_events.append(await session._event_queue.get()) + assert not any(isinstance(event, RealtimeAgentEndEvent) for event in queued_events) + + @pytest.mark.asyncio + async def test_interrupted_response_audio_delta_is_not_forwarded(self, mock_model, mock_agent): + session = RealtimeSession(mock_model, mock_agent, None) + session._interrupted_response_ids.add("response_1") + + await session.on_event( + RealtimeModelAudioEvent( + data=b"audio", + response_id="response_1", + item_id="item_1", + content_index=0, + ) + ) + + queued_events = [] + while not session._event_queue.empty(): + queued_events.append(await session._event_queue.get()) + assert not any(isinstance(event, RealtimeAudio) for event in queued_events) + + @pytest.mark.asyncio + async def test_response_audio_cleanup_error_releases_session_suppression(self, mock_agent): + class FailingRetirementModel(MockRealtimeModel): + def _retire_response_audio(self, response_id: str) -> None: + raise RuntimeError(f"failed to retire {response_id}") + + session = RealtimeSession(FailingRetirementModel(), mock_agent, None) + session._interrupted_response_ids.add("response_1") + + session._retire_response_audio("response_1") + + assert session._interrupted_response_ids == set() + queued_event = await session._event_queue.get() + assert isinstance(queued_event, RealtimeError) + assert queued_event.error == { + "message": "Response audio cleanup failed: failed to retire response_1" + } + @pytest.mark.asyncio async def test_output_text_guardrail_sends_feedback_after_source_turn_ends( self, mock_model, mock_agent, triggered_guardrail From 08fa43c099dc572fed52b60ed99069a3ac92379d Mon Sep 17 00:00:00 2001 From: wesleyzhangwq Date: Tue, 4 Aug 2026 13:31:05 +0800 Subject: [PATCH 134/473] docs: fix spelling in GPT-5 example (#4168) --- examples/basic/hello_world_gpt_5.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/basic/hello_world_gpt_5.py b/examples/basic/hello_world_gpt_5.py index 6b33be4d85..14686d41ab 100644 --- a/examples/basic/hello_world_gpt_5.py +++ b/examples/basic/hello_world_gpt_5.py @@ -14,8 +14,8 @@ async def main(): agent = Agent( - name="Knowledgable GPT-5 Assistant", - instructions="You're a knowledgable assistant. You always provide an interesting answer.", + name="Knowledgeable GPT-5 Assistant", + instructions="You're a knowledgeable assistant. You always provide an interesting answer.", model="gpt-5.6-sol", model_settings=ModelSettings( reasoning=Reasoning(effort="low"), # "none", "low", "medium", "high", "xhigh" From d89fddee7757af9452e87cf05c0d7c7739b53e9e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 4 Aug 2026 15:51:53 +0900 Subject: [PATCH 135/473] fix(voice): finish STT event handling after listener errors (#4170) --- src/agents/voice/models/openai_stt.py | 41 +++++++--- tests/voice/test_openai_stt.py | 103 ++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 939af0be62..f14db755c7 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -40,6 +40,10 @@ class WebsocketDoneSentinel: pass +class _ListenerError(Exception): + pass + + def _audio_to_base64(audio_data: list[npt.NDArray[np.int16 | np.float32]]) -> str: return _audio_buffer_to_base64(np.concatenate(audio_data)) @@ -55,7 +59,9 @@ def _audio_buffer_to_base64(buffer: npt.NDArray[np.int16 | np.float32]) -> str: async def _wait_for_event( - event_queue: asyncio.Queue[dict[str, Any]], expected_types: list[str], timeout: float + event_queue: asyncio.Queue[dict[str, Any] | ErrorSentinel], + expected_types: list[str], + timeout: float, ): """ Wait for an event from event_queue whose type is in expected_types within the specified timeout. @@ -66,6 +72,8 @@ async def _wait_for_event( if remaining <= 0: raise TimeoutError(f"Timeout waiting for event(s): {expected_types}") evt = await asyncio.wait_for(event_queue.get(), timeout=remaining) + if isinstance(evt, ErrorSentinel): + raise _ListenerError("Websocket listener failed") from evt.error evt_type = evt.get("type", "") if evt_type in expected_types: return evt @@ -98,8 +106,10 @@ def __init__( asyncio.Queue() ) self._websocket: websockets.ClientConnection | None = None - self._event_queue: asyncio.Queue[dict[str, Any] | WebsocketDoneSentinel] = asyncio.Queue() - self._state_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._event_queue: asyncio.Queue[dict[str, Any] | ErrorSentinel | WebsocketDoneSentinel] = ( + asyncio.Queue() + ) + self._state_queue: asyncio.Queue[dict[str, Any] | ErrorSentinel] = asyncio.Queue() self._turn_audio_buffer: list[npt.NDArray[np.int16 | np.float32]] = [] self._tracing_span: Span[TranscriptionSpanData] | None = None @@ -140,8 +150,8 @@ def _end_turn(self, _transcript: str) -> None: async def _event_listener(self) -> None: assert self._websocket is not None, "Websocket not initialized" - async for message in self._websocket: - try: + try: + async for message in self._websocket: event = json.loads(message) if event.get("type") == "error": @@ -156,10 +166,12 @@ async def _event_listener(self) -> None: await self._state_queue.put(event) await self._event_queue.put(event) - except Exception as e: - await self._output_queue.put(ErrorSentinel(e)) - raise STTWebsocketConnectionError("Error parsing events") from e - await self._event_queue.put(WebsocketDoneSentinel()) + except Exception as e: + error = ErrorSentinel(e) + await self._event_queue.put(error) + await self._state_queue.put(error) + finally: + await self._event_queue.put(WebsocketDoneSentinel()) async def _configure_session(self) -> None: assert self._websocket is not None, "Websocket not initialized" @@ -191,6 +203,8 @@ async def _setup_connection(self, ws: websockets.ClientConnection) -> None: ["session.created", "transcription_session.created"], SESSION_CREATION_TIMEOUT, ) + except _ListenerError: + raise except TimeoutError as e: wrapped_err = STTWebsocketConnectionError( "Timeout waiting for transcription_session.created event" @@ -213,6 +227,8 @@ async def _setup_connection(self, ws: websockets.ClientConnection) -> None: logger.debug("Session updated") else: logger.debug("Session updated: %s", event) + except _ListenerError: + raise except TimeoutError as e: wrapped_err = STTWebsocketConnectionError( "Timeout waiting for transcription_session.updated event" @@ -232,6 +248,8 @@ async def _handle_events(self) -> None: if isinstance(event, WebsocketDoneSentinel): # processed all events and websocket is done break + if isinstance(event, ErrorSentinel): + raise STTWebsocketConnectionError("Error parsing events") from event.error event_type = event.get("type", "unknown") if event_type in [ @@ -298,6 +316,11 @@ async def _process_websocket_connection(self) -> None: else: logger.error("Listener task not initialized") raise AgentsException("Listener task not initialized") + except _ListenerError as e: + if self._process_events_task is None: + self._process_events_task = asyncio.create_task(self._handle_events()) + await self._process_events_task + raise STTWebsocketConnectionError("Error parsing events") from e.__cause__ except Exception as e: await self._output_queue.put(ErrorSentinel(e)) raise diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index 1514676bbe..e75e27c37a 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -30,6 +30,7 @@ from agents.voice.models.openai_stt import ( EVENT_INACTIVITY_TIMEOUT, ErrorSentinel, + WebsocketDoneSentinel, _audio_buffer_to_base64, ) @@ -612,19 +613,29 @@ def fake_time_func(): @pytest.mark.asyncio -async def test_session_error_event(): +async def test_session_error_event(monkeypatch: pytest.MonkeyPatch): """ - If the session receives an event with "type": "error", it should propagate an exception - and put an ErrorSentinel in the output queue. + If the session receives an event with "type": "error", it should emit preceding transcripts, + drain the event processor, and then propagate an exception. """ mock_ws = create_mock_websocket( [ json.dumps({"type": "transcription_session.created"}), json.dumps({"type": "transcription_session.updated"}), + json.dumps( + { + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "Transcript before error", + } + ), # Then an error from the server json.dumps({"type": "error", "error": "Simulated server error!"}), ] ) + monkeypatch.setattr( + "agents.voice.models.openai_stt.EVENT_INACTIVITY_TIMEOUT", + 0.1, + ) with patch("websockets.connect", return_value=mock_ws): audio_input = await FakeStreamedAudioInput.get(count=2) @@ -638,13 +649,95 @@ async def test_session_error_event(): trace_include_sensitive_data=False, trace_include_sensitive_audio_data=False, ) + event_queue_put = AsyncMock(wraps=session._event_queue.put) + monkeypatch.setattr(session._event_queue, "put", event_queue_put) + collected_turns: list[str] = [] with pytest.raises(STTWebsocketConnectionError): turns = session.transcribe_turns() - async for _ in turns: - pass + async for turn in turns: + collected_turns.append(turn) + assert collected_turns == ["Transcript before error"] + assert any( + isinstance(call.args[0], WebsocketDoneSentinel) + for call in event_queue_put.await_args_list + ) await session.close() + assert session._process_events_task is not None + assert session._process_events_task.done() + assert not session._process_events_task.cancelled() + + +@pytest.mark.asyncio +async def test_session_error_event_before_session_created(): + mock_ws = create_mock_websocket( + [json.dumps({"type": "error", "error": "Simulated setup error!"})] + ) + + with patch("websockets.connect", return_value=mock_ws): + audio_input = await FakeStreamedAudioInput.get(count=2) + session = OpenAISTTTranscriptionSession( + input=audio_input, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + async def consume_turns() -> None: + async for _ in session.transcribe_turns(): + pass + + with pytest.raises(STTWebsocketConnectionError): + await asyncio.wait_for(consume_turns(), timeout=1) + + assert session._process_events_task is not None + assert session._process_events_task.done() + assert not session._process_events_task.cancelled() + + +@pytest.mark.asyncio +async def test_listener_timeout_drains_buffered_transcript_before_setup(): + messages = [ + json.dumps( + { + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "Transcript before listener timeout", + } + ) + ] + + async def messages_then_timeout() -> AsyncGenerator[str, None]: + for message in messages: + yield message + raise TimeoutError("Simulated listener timeout") + + mock_ws = AsyncMock() + mock_ws.__aenter__.return_value = mock_ws + mock_ws.__aiter__.side_effect = messages_then_timeout + + with patch("websockets.connect", return_value=mock_ws): + audio_input = await FakeStreamedAudioInput.get(count=2) + session = OpenAISTTTranscriptionSession( + input=audio_input, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + collected_turns: list[str] = [] + with pytest.raises(STTWebsocketConnectionError): + async for turn in session.transcribe_turns(): + collected_turns.append(turn) + + assert collected_turns == ["Transcript before listener timeout"] + assert session._process_events_task is not None + assert session._process_events_task.done() + assert not session._process_events_task.cancelled() @pytest.mark.asyncio From 39814aea097805410c0583db23757fcb484f1648 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:13:04 +0900 Subject: [PATCH 136/473] Bump version to 0.19.3 (#4169) Co-authored-by: Kazuhiro Sera --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dad974a2b6..122b15f3b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.19.2" +version = "0.19.3" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index d37ff5665d..faa0f73e57 100644 --- a/uv.lock +++ b/uv.lock @@ -2437,7 +2437,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.19.2" +version = "0.19.3" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 3de564c89893442384ab10a6c13153707ab47892 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 4 Aug 2026 16:35:32 +0900 Subject: [PATCH 137/473] docs: updates for v0.19.3 --- docs/guardrails.md | 2 +- docs/mcp.md | 6 ++++++ docs/running_agents.md | 3 ++- docs/streaming.md | 2 ++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/guardrails.md b/docs/guardrails.md index ace94942ea..2f42d4205c 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -66,7 +66,7 @@ See the code snippet below for details. If the input or output fails the guardrail, the Guardrail can signal this with a tripwire. As soon as we see a guardrail that has triggered the tripwires, we immediately raise a `{Input,Output}GuardrailTripwireTriggered` exception and halt the Agent execution. -The exception's `guardrail_result` identifies the guardrail that triggered the tripwire. For an input tripwire raised by the runner, `exception.run_data.input_guardrail_results` contains every input guardrail result completed before the run stopped, including the result that triggered the tripwire. The streamed result exposes the same accumulated results through `input_guardrail_results` after `stream_events()` raises. `run_data` can be `None` when an exception is raised outside a runner-managed execution path. +The exception's `guardrail_result` identifies the guardrail that triggered the tripwire. For an input tripwire raised by the runner, `exception.run_data.input_guardrail_results` contains every input guardrail result completed before the run stopped, including the result that triggered the tripwire. Output tripwires provide the equivalent accumulated results through `exception.run_data.output_guardrail_results`. After `stream_events()` raises, the streamed result exposes the same completed results through `input_guardrail_results` or `output_guardrail_results`. `run_data` can be `None` when an exception is raised outside a runner-managed execution path. ## Implementing a guardrail diff --git a/docs/mcp.md b/docs/mcp.md index 2a24cabe7d..fecc00709a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -447,6 +447,12 @@ agent = Agent( ) ``` +## Pagination + +The built-in local MCP server classes automatically follow `nextCursor` when listing tools and prompts. `list_tools()` returns the complete tool list before applying filters or populating its cache, and `list_prompts()` returns one combined result with `nextCursor=None`. If a later page fails or a server repeats a cursor, the operation raises an error instead of exposing or caching partial results. + +Resources remain explicitly paginated. Pass the `nextCursor` from `list_resources()` or `list_resource_templates()` back as the `cursor` argument to retrieve the next page. + ## Caching Every agent run calls `list_tools()` on each MCP server. Remote servers can introduce noticeable latency, so all of the MCP server classes expose a `cache_tools_list` option. Set it to `True` only if you are confident that the tool definitions do not change frequently. To force a fresh list later, call `invalidate_tools_cache()` on the server instance. diff --git a/docs/running_agents.md b/docs/running_agents.md index 593d73e768..3b0643ee50 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -158,6 +158,7 @@ Use `RunConfig` to override behavior for a single run without changing each agen - [`tool_execution`][agents.run.RunConfig.tool_execution]: Configure SDK-side execution behavior for local tool calls, such as limiting how many function tools run at once. - [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: Configure how the runner handles unresolved function tool calls emitted by the model. The default raises `ModelBehaviorError`; opt in to return a model-visible error output instead. +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: Configure how the runner handles bare function-tool and handoff names that collide. The default, `"warn"`, logs an actionable warning and exposes only the current dispatch winner; `"error"` raises `UserError` before the model is called. Strict validation for namespaced and deferred-loading tools is unchanged. - [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: Customize model-visible tool error messages, such as approval rejections and opt-in tool-not-found outputs. Nested handoffs are available as an opt-in beta. Enable ordered transcript compaction by passing `RunConfig(nest_handoff_history=True)` or set `handoff(..., nest_handoff_history=True)` to turn it on for a specific handoff. The built-in mapper places generated assistant summary segments around lossless message items instead of collapsing the whole transcript into one message. If you prefer to keep the raw transcript (the default), leave the flag unset or provide a `handoff_input_filter` (or `handoff_history_mapper`) that forwards the conversation exactly as you need. To change the wrapper text used in generated summary segments without writing a custom mapper, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] (and [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] to restore the defaults). @@ -528,7 +529,7 @@ result = Runner.run_sync( print(result.final_output) ``` -Set `include_in_history=False` when you do not want the fallback output appended to conversation history. +`RunErrorHandlerResult.include_in_history` defaults to `True`. For a max-turns handler, this appends the synthesized fallback output to conversation history and persists it to the configured session. Set `include_in_history=False` when you want the fallback returned to the caller without adding it to result history or session storage. Use `"model_refusal"` when a model refusal should produce an application-specific fallback instead of ending the run with `ModelRefusalError`. diff --git a/docs/streaming.md b/docs/streaming.md index 0d82d64a31..2e7f408373 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -87,6 +87,8 @@ If you are manually continuing from [`result.to_input_list(mode="normalized")`][ `handoff_occured` is intentionally misspelled for backward compatibility. +A handoff call is emitted only as `handoff_requested`; it is not also emitted as `tool_called`. Ordinary function tool calls in the same turn still emit `tool_called`. + When you use hosted tool search, `tool_search_called` is emitted when the model issues a tool-search request and `tool_search_output_created` is emitted when the Responses API returns the loaded subset. With Programmatic Tool Calling, `tool_called` is emitted for the generated `program` and for ordinary program-owned child tool calls. `tool_output` is emitted for child tool outputs and the matching `program_output`. Program-owned hosted MCP `mcp_approval_request` and `mcp_list_tools` items are exceptions: they are emitted as `mcp_approval_requested` and `mcp_list_tools`, wrapping [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] and [`MCPListToolsItem`][agents.items.MCPListToolsItem], respectively. Inspect the raw item's `type` to distinguish the remaining items; program-owned child calls also carry a `caller` whose type is `program` and whose caller ID identifies the parent program. From 26e461f055b78399ff6af34cbce65fd9eec23517 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 4 Aug 2026 16:49:48 +0900 Subject: [PATCH 138/473] docs: update translated pages --- docs/ja/guardrails.md | 54 ++++----- docs/ja/mcp.md | 126 ++++++++++---------- docs/ja/realtime/guide.md | 126 ++++++++++---------- docs/ja/running_agents.md | 220 +++++++++++++++++----------------- docs/ja/streaming.md | 44 +++---- docs/ko/guardrails.md | 56 ++++----- docs/ko/mcp.md | 114 +++++++++--------- docs/ko/realtime/guide.md | 108 ++++++++--------- docs/ko/running_agents.md | 205 +++++++++++++++---------------- docs/ko/streaming.md | 30 ++--- docs/zh/guardrails.md | 52 ++++---- docs/zh/mcp.md | 145 +++++++++++----------- docs/zh/realtime/guide.md | 138 ++++++++++----------- docs/zh/running_agents.md | 245 +++++++++++++++++++------------------- docs/zh/streaming.md | 44 +++---- 15 files changed, 870 insertions(+), 837 deletions(-) diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index 30590ed091..b191c8e80e 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -4,7 +4,7 @@ search: --- # ガードレール -ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、非常に高性能である一方、低速でコストの高いモデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストのモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、直ちにエラーを発生させて高コストのモデルの実行を防ぎ、時間と費用を節約できます **(ブロッキングガードレールを使用する場合。並列ガードレールでは、ガードレールが完了する前に高コストのモデルがすでに実行を開始している可能性があります。詳細については、以下の「実行モード」を参照してください)** 。 +ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を実行できます。たとえば、非常に高性能な(そのため低速で高コストな)モデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されるのは避けたいでしょう。そこで、高速で低コストなモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、即座にエラーを発生させ、高コストなモデルの実行を防げるため、時間と費用を節約できます( **ブロッキングガードレールを使用する場合です。並列ガードレールでは、ガードレールが完了する前に、高コストなモデルがすでに実行を開始している可能性があります。詳しくは、以下の「実行モード」を参照してください** )。 ガードレールには次の 2 種類があります。 @@ -15,66 +15,66 @@ search: ガードレールはエージェントとツールに設定されますが、すべてがワークフロー内の同じ時点で実行されるわけではありません。 -- **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 -- **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 -- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前に、出力ガードレールは実行後に実行されます。 +- **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 +- **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 +- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前に、出力ガードレールは実行後に実行されます。 -マネージャー、ハンドオフ、または委任されたスペシャリストを含むワークフローで、各カスタム関数ツールの呼び出し前後にチェックが必要な場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 +マネージャー、ハンドオフ、または委任された専門エージェントを含むワークフローで、カスタム関数ツールの各呼び出しをチェックする必要がある場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 ## 入力ガードレール 入力ガードレールは、次の 3 ステップで実行されます。 -1. 最初に、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 +1. まず、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答するか、例外を処理できます。 +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答したり、例外を処理したりできます。 !!! Note - 入力ガードレールはユーザー入力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最初の* エージェントである場合にのみ実行されます。なぜ `guardrails` プロパティを `Runner.run` に渡すのではなく、エージェントに設定するのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所にまとめると可読性が向上します。 + 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初* のエージェントである場合にのみ実行されます。`guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェントに設定されるのはなぜだろうと思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 ### 実行モード 入力ガードレールは、次の 2 つの実行モードをサポートしています。 -- **並列実行** (デフォルト、`run_in_parallel=True`):ガードレールはエージェントの実行と並行して実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールが失敗した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 +- **並列実行** (デフォルト、`run_in_parallel=True`):ガードレールはエージェントの実行と並行して動作します。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールが失敗した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 -- **ブロッキング実行** (`run_in_parallel=False`):ガードレールは、エージェントが開始する *前に* 実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防げます。コストを最適化したい場合や、ツール呼び出しによる潜在的な副作用を避けたい場合に最適です。 +- **ブロッキング実行** (`run_in_parallel=False`):ガードレールは、エージェントが開始する *前* に実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防げます。コストを最適化したい場合や、ツール呼び出しによる潜在的な副作用を避けたい場合に最適です。 ## 出力ガードレール 出力ガードレールは、次の 3 ステップで実行されます。 -1. 最初に、ガードレールはエージェントが生成した出力を受け取ります。 +1. まず、ガードレールはエージェントが生成した出力を受け取ります。 2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答するか、例外を処理できます。 +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答したり、例外を処理したりできます。 !!! Note - 出力ガードレールは最終的なエージェント出力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最後の* エージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所にまとめると可読性が向上します。 + 出力ガードレールは最終的なエージェント出力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最後* のエージェントである場合にのみ実行されます。入力ガードレールと同様に、このようにするのは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 - 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターはサポートしていません。 + 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターをサポートしていません。 ## ツールガードレール -ツールガードレールは **関数ツール** をラップし、実行前後にツール呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 +ツールガードレールは **関数ツール** をラップし、実行の前後でツール呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 -- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、メッセージによる出力の置き換え、またはトリップワイヤーの作動が可能です。 -- 出力ツールガードレールはツールの実行後に実行され、出力の置き換え、またはトリップワイヤーの作動が可能です。 -- 関数ツールに承認が必要な場合、通常、入力ツールガードレールは承認後、実行の直前に実行されます。保留中の承認による中断が発生する前にこれらの入力チェックを実行する場合は、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定してください。この承認前チェックに合格した呼び出しも、承認後、ツールの実行前に再度チェックされます。 -- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)と組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)も、このガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 +- 入力ツールガードレールはツールの実行前に動作し、呼び出しをスキップしたり、出力をメッセージに置き換えたり、トリップワイヤーを作動させたりできます。 +- 出力ツールガードレールはツールの実行後に動作し、出力を置き換えたり、トリップワイヤーを作動させたりできます。 +- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に動作します。保留中の承認による中断が発生する前にこれらの入力チェックを実行する場合は、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定します。この承認前チェックを通過した呼び出しも、承認後かつツールの実行前に再度チェックされます。 +- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)および組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)も、このガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 -詳細については、以下のコードスニペットを参照してください。 +詳しくは、以下のコードスニペットを参照してください。 ## トリップワイヤー -入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーによってこれを通知できます。トリップワイヤーが作動したガードレールを検出すると、直ちに `{Input,Output}GuardrailTripwireTriggered` 例外が発生し、エージェントの実行が停止します。 +入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーを使用してそれを通知できます。トリップワイヤーを作動させたガードレールが検出されると、即座に `{Input,Output}GuardrailTripwireTriggered` 例外が発生し、エージェントの実行が停止します。 -例外の `guardrail_result` は、トリップワイヤーを作動させたガードレールを識別します。Runner によって発生した入力トリップワイヤーの場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの実行結果が含まれ、トリップワイヤーを作動させた実行結果も含まれます。ストリーミング実行結果では、`stream_events()` が例外を発生させた後、同じ蓄積済みの実行結果が `input_guardrail_results` を通じて公開されます。Runner が管理する実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 +例外の `guardrail_result` により、トリップワイヤーを作動させたガードレールを特定できます。ランナーによって入力トリップワイヤーが作動した場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの結果が含まれ、トリップワイヤーを作動させた結果も含まれます。出力トリップワイヤーでは、`exception.run_data.output_guardrail_results` を通じて同様に蓄積された結果が提供されます。`stream_events()` が例外を発生させた後、ストリーミングされた実行結果では、`input_guardrail_results` または `output_guardrail_results` を通じて、同じ完了済みの結果を確認できます。ランナーが管理する実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 ## ガードレールの実装 -入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することで実装します。 +入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することでこれを実現します。 ```python from pydantic import BaseModel @@ -127,9 +127,9 @@ async def main(): print("Math homework guardrail tripped") ``` -1. このエージェントをガードレール関数内で使用します。 -2. これは、エージェントの入力/コンテキストを受け取り、実行結果を返すガードレール関数です。 -3. ガードレールの実行結果には追加情報を含められます。 +1. このエージェントをガードレール関数で使用します。 +2. これは、エージェントの入力/コンテキストを受け取り、結果を返すガードレール関数です。 +3. ガードレールの結果に追加情報を含めることができます。 4. これは、ワークフローを定義する実際のエージェントです。 出力ガードレールも同様です。 @@ -187,7 +187,7 @@ async def main(): 1. これは、実際のエージェントの出力型です。 2. これは、ガードレールの出力型です。 -3. これは、エージェントの出力を受け取り、実行結果を返すガードレール関数です。 +3. これは、エージェントの出力を受け取り、結果を返すガードレール関数です。 4. これは、ワークフローを定義する実際のエージェントです。 最後に、ツールガードレールの例を示します。 diff --git a/docs/ja/mcp.md b/docs/ja/mcp.md index 9e98899eb5..0f9c211ce4 100644 --- a/docs/ja/mcp.md +++ b/docs/ja/mcp.md @@ -4,30 +4,30 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)は、アプリケーションがツールやコンテキストを言語モデルに公開する方法を標準化します。公式ドキュメントでは、次のように説明されています。 +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)は、アプリケーションがツールとコンテキストを言語モデルに公開する方法を標準化します。公式ドキュメントからの引用です。 > MCP は、アプリケーションが LLM にコンテキストを提供する方法を標準化するオープンプロトコルです。MCP は、AI -> アプリケーション向けの USB-C ポートのようなものだと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続するための標準化された方法を提供するのと同様に、MCP +> アプリケーション向けの USB-C ポートのようなものと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続するための標準化された方法を提供するのと同様に、MCP > は AI モデルをさまざまなデータソースやツールに接続するための標準化された方法を提供します。 -Agents Python SDK は、複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、独自の MCP サーバーを構築して、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開したりできます。 +Agents Python SDK は複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、独自のサーバーを構築して、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開したりできます。 !!! warning "接続前の MCP サーバーの信頼性確認" - MCP ツールは、モデルコンテキストのデータを公開し、提供された認証情報を使用してアクションを実行できます。信頼できるサーバーにのみ接続し、最小権限の認証情報を使用してください。アクセストークンは URL ではなく認可フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)を参照してください。 + MCP ツールは、モデルコンテキストのデータを公開し、提供された認証情報を使用してアクションを実行できます。信頼できるサーバーにのみ接続し、最小権限の認証情報を使用し、アクセストークンは URL ではなく認証フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)を参照してください。 ## MCP 統合の選択 -MCP サーバーをエージェントに接続する前に、ツール呼び出しをどこで実行するか、どのトランスポートに到達できるかを決定します。以下の表は、Python SDK がサポートする選択肢をまとめたものです。 +MCP サーバーをエージェントに接続する前に、ツール呼び出しをどこで実行するか、またどのトランスポートにアクセスできるかを決定してください。以下の表は、Python SDK がサポートする選択肢をまとめたものです。 -| 必要なこと | 推奨オプション | +| 必要なこと | 推奨オプション | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI の Responses API が、モデルに代わって公開アクセス可能な MCP サーバーを呼び出す| [`HostedMCPTool`][agents.tool.HostedMCPTool] を使用する **ホスト型 MCP サーバーツール** | +| OpenAI の Responses API がモデルに代わって、公開アクセス可能な MCP サーバーを呼び出す| [`HostedMCPTool`][agents.tool.HostedMCPTool] を使用する **ホスト型 MCP サーバーツール** | | ローカルまたはリモートで実行する Streamable HTTP サーバーに接続する | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用する **Streamable HTTP MCP サーバー** | -| Server-Sent Events を使用する HTTP を実装したサーバーと通信する | [`MCPServerSse`][agents.mcp.server.MCPServerSse] を使用する **SSE 対応 HTTP MCP サーバー** | +| Server-Sent Events 対応 HTTP を実装するサーバーと通信する | [`MCPServerSse`][agents.mcp.server.MCPServerSse] を使用する **SSE 対応 HTTP MCP サーバー** | | ローカルプロセスを起動し、stdin/stdout 経由で通信する | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用する **stdio MCP サーバー** | -以下のセクションでは、各オプション、その設定方法、および各トランスポートを選択すべき状況について説明します。 +以下のセクションでは、各オプション、その設定方法、および各トランスポートを選ぶべき状況について説明します。 ## エージェントレベルの MCP 設定 @@ -51,33 +51,33 @@ agent = Agent( ) ``` -注記: +注意事項: -- `convert_schemas_to_strict` はベストエフォートで動作します。スキーマを変換できない場合は、元のスキーマが使用されます。 -- `failure_error_function` は、MCP ツール呼び出しの失敗をモデルに提示する方法を制御します。 -- `failure_error_function` が設定されていない場合、SDK はデフォルトのツールエラーフォーマッターを使用します。 -- サーバーレベルの `failure_error_function` は、そのサーバーについて `Agent.mcp_config["failure_error_function"]` を上書きします。 -- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、サーバー名を接頭辞とする決定的な名前でモデルに公開されます。これは、複数の MCP サーバーが同じ名前のツールを公開している場合の衝突回避に役立ちます。生成される名前は ASCII で安全に扱うことができ、関数ツール名の長さ制限内に収まり、同じエージェント上にある既存のローカル関数ツール名や有効なハンドオフ名との衝突を回避します。SDK は引き続き、元のサーバー上で元の MCP ツール名を使用して呼び出します。 +- `convert_schemas_to_strict` はベストエフォート方式です。スキーマを変換できない場合は、元のスキーマが使用されます。 +- `failure_error_function` は、MCP ツール呼び出しの失敗をモデルにどのように提示するかを制御します。 +- `failure_error_function` が未設定の場合、SDK はデフォルトのツールエラーフォーマッターを使用します。 +- サーバーレベルの `failure_error_function` は、そのサーバーに対する `Agent.mcp_config["failure_error_function"]` を上書きします。 +- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバー接頭辞付きの名前でモデルに公開されます。これにより、複数の MCP サーバーが同名のツールを公開する場合の名前の衝突を回避できます。生成される名前は ASCII セーフで、関数ツール名の長さ制限内に収まり、同じエージェント上にある既存のローカル関数ツール名や有効なハンドオフ名との衝突も回避します。SDK は引き続き、元のサーバー上で元の MCP ツール名を使用して呼び出します。 -## トランスポート間で共通のパターン +## トランスポート間で共通するパターン -トランスポートを選択した後、ほとんどの統合では、次の事項についても決定する必要があります。 +トランスポートを選択した後、ほとんどの統合では、次の事項も決定する必要があります。 - ツールの一部のみを公開する方法([ツールのフィルタリング](#tool-filtering))。 - サーバーが再利用可能なプロンプトも提供するかどうか([プロンプト](#prompts))。 - `list_tools()` をキャッシュするかどうか([キャッシュ](#caching))。 - MCP のアクティビティをトレースにどのように表示するか([トレーシング](#tracing))。 -ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP のセクションでは最も包括的なコード例を示しており、同じパターンを他のローカルトランスポートにも適用できます。 +ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP のセクションに最も完全なコード例を示していますが、同じパターンを他のローカルトランスポートにも適用できます。 ## 1. ホスト型 MCP サーバーツール -ホスト型ツールでは、ツール処理の一連の往復全体が OpenAI のインフラストラクチャ内で実行されます。コード側でツールを一覧表示して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベルと任意のコネクターメタデータを Responses API に転送します。モデルはリモートサーバーのツールを一覧表示し、Python プロセスへの追加のコールバックなしで呼び出します。現在、ホスト型ツールは、Responses API のホスト型 MCP 統合をサポートする OpenAI モデルで動作します。 +ホスト型ツールでは、ツール呼び出しの往復処理全体が OpenAI のインフラストラクチャ内で実行されます。コード側でツールを一覧取得して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベル(および任意のコネクターメタデータ)を Responses API に転送します。モデルは、Python プロセスへの追加のコールバックなしで、リモートサーバーのツールを一覧取得して呼び出します。現在、ホスト型ツールは、Responses API のホスト型 MCP 統合をサポートする OpenAI モデルで動作します。 ### 基本的なホスト型 MCP ツール -[`HostedMCPTool`][agents.tool.HostedMCPTool] をエージェントの `tools` リストに追加して、ホスト型ツールを作成します。`tool_config` -の `dict` は、REST API に送信する JSON に対応しています。 +エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加して、ホスト型ツールを作成します。`tool_config` +辞書は、REST API に送信する JSON と同じ構造です。 ```python import asyncio @@ -115,8 +115,8 @@ asyncio.run(main()) ### ホスト型 MCP 実行結果のストリーミング -ホスト型ツールは、関数ツールとまったく同じ方法で実行結果のストリーミングをサポートします。モデルが処理を続けている間に、`Runner.run_streamed` を使用して -増分 MCP 出力を受け取ります。 +ホスト型ツールは、関数ツールとまったく同じ方法で実行結果のストリーミングをサポートします。モデルがまだ処理中でも、`Runner.run_streamed` を使用して +増分 MCP 出力を受け取れます。 ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -128,7 +128,7 @@ print(result.final_output) ### 任意の承認フロー -サーバーが機密性の高い操作を実行できる場合、各ツールの実行前に人間またはプログラムによる承認を必須にできます。`tool_config` の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名とポリシーを対応付ける `dict` を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 +サーバーが機密性の高い操作を実行できる場合、ツールを実行するたびに、人またはプログラムによる承認を必須にできます。`tool_config` の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名をポリシーに対応付ける辞書を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -156,9 +156,9 @@ agent = Agent( ) ``` -コールバックは同期または非同期のいずれでも使用でき、モデルが実行を継続するために承認情報を必要とするたびに呼び出されます。 +コールバックは同期または非同期にでき、モデルが実行を継続するために承認データを必要とするたびに呼び出されます。 -### コネクター連携型のホスト型サーバー +### コネクターを基盤とするホスト型サーバー ホスト型 MCP は OpenAI コネクターもサポートします。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 @@ -176,11 +176,11 @@ HostedMCPTool( ) ``` -ストリーミング、承認、コネクターを含む、完全に動作するホスト型ツールのコード例は、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) にあります。 +ストリーミング、承認、コネクターを含む、完全に動作するホスト型ツールのサンプルは、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) にあります。 ## 2. Streamable HTTP MCP サーバー -ネットワーク接続を自身で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを自身で制御する場合や、低遅延を維持しながら独自のインフラストラクチャ内でサーバーを実行する場合に最適です。 +ネットワーク接続を自分で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを制御する場合や、低レイテンシーを維持しながら独自のインフラストラクチャ内でサーバーを実行する場合に適しています。 ```python import asyncio @@ -215,24 +215,24 @@ async def main() -> None: asyncio.run(main()) ``` -コンストラクターは、次の追加オプションを受け取ります。 +コンストラクターでは、追加のオプションを指定できます。 -- `client_session_timeout_seconds` は、MCP ClientSession の読み取りタイムアウトを制御します。`datetime.timedelta` で表現可能かつ 1 マイクロ秒以上の正の有限値を指定すると、有限のタイムアウトが設定されます。`None` と `0` を指定すると無効になります。それ以外の値は、サーバーの構築時に拒否されます。 -- `use_structured_content` は、テキスト出力よりも `tool_result.structured_content` を優先するかどうかを切り替えます。 +- `client_session_timeout_seconds` は、MCP ClientSession の読み取りタイムアウトを制御します。`datetime.timedelta` で表現可能かつ 1 マイクロ秒以上の正の有限値を指定すると有限のタイムアウトが設定され、`None` と `0` を指定すると無効になります。それ以外の値は、サーバーの構築時に拒否されます。 +- `use_structured_content` は、テキスト出力より `tool_result.structured_content` を優先するかどうかを切り替えます。 - `max_retry_attempts` と `retry_backoff_seconds_base` は、`list_tools()` と `call_tool()` に自動再試行を追加します。 -- `tool_filter` を使用すると、一部のツールのみを公開できます([ツールのフィルタリング](#tool-filtering)を参照)。 -- `require_approval` は、ローカル MCP ツールでヒューマンインザループの承認ポリシーを有効にします。 -- `failure_error_function` は、モデルに表示される MCP ツールの失敗メッセージをカスタマイズします。代わりにエラーを送出するには、`None` に設定します。 -- `tool_meta_resolver` は、`call_tool()` の前に呼び出しごとの MCP `_meta` ペイロードを挿入します。 +- `tool_filter` を使用すると、ツールの一部のみを公開できます([ツールのフィルタリング](#tool-filtering)を参照)。 +- `require_approval` は、ローカル MCP ツールで人間参加型の承認ポリシーを有効にします。 +- `failure_error_function` は、モデルに表示される MCP ツール失敗メッセージをカスタマイズします。代わりにエラーを送出するには、`None` に設定します。 +- `tool_meta_resolver` は、`call_tool()` の前に、呼び出しごとの MCP `_meta` ペイロードを挿入します。 ### ローカル MCP サーバーの承認ポリシー -`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` はすべて `require_approval` を受け取ります。 +`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` は、いずれも `require_approval` を受け付けます。 サポートされる形式: - すべてのツールに対する `"always"` または `"never"`。 -- `True` / `False`(always/never と同等)。 +- `True` / `False`(常に承認する/承認しないのと同等)。 - ツールごとのマップ。例:`{"delete_file": "always", "read_file": "never"}`。 - グループ化されたオブジェクト:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 @@ -245,7 +245,7 @@ async with MCPServerStreamableHttp( ... ``` -完全な一時停止/再開フローについては、[ヒューマンインザループ](human_in_the_loop.md)および `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 +一時停止/再開を含む完全なフローについては、[人間参加型](human_in_the_loop.md)および `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 ### `tool_meta_resolver` による呼び出しごとのメタデータ @@ -270,17 +270,17 @@ server = MCPServerStreamableHttp( ) ``` -実行コンテキストが Pydantic モデル、データクラス、またはカスタムクラスの場合は、属性アクセスを使用してテナント ID を読み取ります。 +実行コンテキストが Pydantic モデル、dataclass、またはカスタムクラスの場合は、属性アクセスを使用してテナント ID を読み取ります。 ### MCP ツールの出力:テキストと画像 -MCP ツールが画像コンテンツを返すと、SDK はそれを画像ツールの出力エントリーに自動的にマッピングします。テキストと画像が混在するレスポンスは、出力項目のリストとして転送されます。そのため、エージェントは通常の関数ツールからの画像出力と同じ方法で、MCP の画像の実行結果を利用できます。 +MCP ツールが画像コンテンツを返すと、SDK はそれを画像ツールの出力エントリーに自動的にマッピングします。テキストと画像が混在するレスポンスは出力項目のリストとして転送されるため、エージェントは通常の関数ツールからの画像出力と同じ方法で、MCP の画像実行結果を利用できます。 ## 3. SSE 対応 HTTP MCP サーバー !!! warning - MCP プロジェクトでは、Server-Sent Events トランスポートが非推奨になっています。新しい統合では Streamable HTTP または stdio を優先し、SSE はレガシーサーバーにのみ使用してください。 + MCP プロジェクトでは、Server-Sent Events トランスポートは非推奨になっています。新しい統合には Streamable HTTP または stdio を使用し、SSE はレガシーサーバーにのみ使用してください。 MCP サーバーが SSE 対応 HTTP トランスポートを実装している場合は、[`MCPServerSse`][agents.mcp.server.MCPServerSse] をインスタンス化します。トランスポートを除き、API は Streamable HTTP サーバーと同一です。 @@ -311,7 +311,7 @@ async with MCPServerSse( ## 4. stdio MCP サーバー -ローカルサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを生成してパイプを開いたままにし、コンテキストマネージャーの終了時に自動的にパイプを閉じます。このオプションは、簡単な概念実証や、サーバーがコマンドラインのエントリーポイントのみを公開している場合に便利です。 +ローカルのサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを起動し、パイプを開いた状態に保ち、コンテキストマネージャーの終了時に自動的に閉じます。このオプションは、簡単な概念実証を行う場合や、サーバーがコマンドラインのエントリーポイントのみを公開している場合に役立ちます。 ```python from pathlib import Path @@ -339,7 +339,7 @@ async with MCPServerStdio( ## 5. MCP サーバーマネージャー -複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、正常に接続されたサーバーのサブセットをエージェントに公開します。コンストラクターのオプションと再接続動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 +複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、接続済みのサーバーのみをエージェントに公開します。コンストラクターのオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 ```python from agents import Agent, Runner @@ -365,18 +365,18 @@ async with MCPServerManager(servers) as manager: - `drop_failed_servers=True`(デフォルト)の場合、`active_servers` には正常に接続されたサーバーのみが含まれます。 - 失敗は `failed_servers` と `errors` に記録されます。 - 最初の接続失敗時に例外を送出するには、`strict=True` を設定します。 -- 失敗したサーバーを再試行するには `reconnect(failed_only=True)` を呼び出し、すべてのサーバーを再起動するには `reconnect(failed_only=False)` を呼び出します。 -- ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。ライフサイクルのタイムアウトには、正の有限秒数、またはタイムアウトを無効にする `None` を指定できます。これらは構築時と代入時の両方で検証されます。ゼロは即時の期限を作成するため拒否されます。 +- 失敗したサーバーを再試行するには `reconnect(failed_only=True)` を、すべてのサーバーを再起動するには `reconnect(failed_only=False)` を呼び出します。 +- ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。ライフサイクルのタイムアウトには、正の有限秒数、または無効化するための `None` を指定できます。これらは構築時と代入時の両方で検証されます。ゼロを指定すると即時の期限が設定されてしまうため、拒否されます。 -## サーバー共通機能 +## 共通のサーバー機能 -以下のセクションは、MCP サーバーの各トランスポートに共通して適用されます(利用できる正確な API はサーバークラスによって異なります)。 +以下のセクションは、MCP サーバーの各トランスポートに共通して適用されます(利用できる具体的な API はサーバークラスによって異なります)。 ## ツールのフィルタリング -各 MCP サーバーはツールフィルターをサポートしているため、エージェントが必要とする関数のみを公開できます。フィルタリングは、構築時または実行ごとに動的に行えます。 +各 MCP サーバーはツールフィルターをサポートしているため、エージェントに必要な関数のみを公開できます。フィルタリングは構築時に行うことも、実行ごとに動的に行うこともできます。 -### 静的なツールフィルタリング +### 静的なツールのフィルタリング 単純な許可/ブロックリストを設定するには、[`create_static_tool_filter`][agents.mcp.create_static_tool_filter] を使用します。 @@ -396,11 +396,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK は最初に許可リストを適用し、残ったセットからブロックされたツールを削除します。 +`allowed_tool_names` と `blocked_tool_names` の両方を指定した場合、SDK は最初に許可リストを適用し、その後、残りのセットからブロックされたツールを削除します。 -### 動的なツールフィルタリング +### 動的なツールのフィルタリング -より複雑なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る callable を渡します。callable は同期または非同期のいずれでも使用でき、ツールを公開すべき場合に `True` を返します。 +より複雑なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る呼び出し可能オブジェクトを渡します。この呼び出し可能オブジェクトは同期または非同期にでき、ツールを公開する場合に `True` を返します。 ```python from pathlib import Path @@ -424,7 +424,7 @@ async with MCPServerStdio( ... ``` -フィルターコンテキストは、アクティブな `run_context`、ツールを要求している `agent`、および `server_name` を公開します。 +フィルターコンテキストからは、アクティブな `run_context`、ツールを要求している `agent`、および `server_name` にアクセスできます。 ## プロンプト @@ -432,7 +432,7 @@ MCP サーバーは、エージェントへの指示を動的に生成するプ メソッドを公開します。 - `list_prompts()` は、利用可能なプロンプトテンプレートを列挙します。 -- `get_prompt(name, arguments)` は、必要に応じてパラメーターを指定して、具体的なプロンプトを取得します。 +- `get_prompt(name, arguments)` は、必要に応じてパラメーターを指定し、具体的なプロンプトを取得します。 ```python from agents import Agent @@ -450,21 +450,27 @@ agent = Agent( ) ``` +## ページネーション + +組み込みのローカル MCP サーバークラスは、ツールとプロンプトの一覧取得時に `nextCursor` を自動的にたどります。`list_tools()` は、フィルターの適用またはキャッシュへの格納前にツールの完全なリストを返し、`list_prompts()` は `nextCursor=None` の 1 つに統合された実行結果を返します。後続ページの取得に失敗した場合、またはサーバーが同じカーソルを繰り返した場合、部分的な実行結果を公開またはキャッシュする代わりに、操作はエラーを送出します。 + +リソースは引き続き明示的にページ分割されます。次のページを取得するには、`list_resources()` または `list_resource_templates()` から返された `nextCursor` を `cursor` 引数として渡します。 + ## キャッシュ -エージェントを実行するたびに、各 MCP サーバーで `list_tools()` が呼び出されます。リモートサーバーは無視できない遅延を発生させる可能性があるため、すべての MCP サーバークラスは `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新のリストを強制的に取得するには、サーバーインスタンスで `invalidate_tools_cache()` を呼び出します。 +エージェントを実行するたびに、各 MCP サーバーで `list_tools()` が呼び出されます。リモートサーバーでは無視できないレイテンシーが生じる可能性があるため、すべての MCP サーバークラスが `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新のリストを強制的に取得するには、サーバーインスタンスの `invalidate_tools_cache()` を呼び出します。 ## トレーシング -[トレーシング](./tracing.md)は、次のような MCP アクティビティを自動的に記録します。 +[トレーシング](./tracing.md)では、次の項目を含む MCP のアクティビティが自動的に記録されます。 -1. ツール一覧を取得するための MCP サーバーへの呼び出し。 -2. ツール呼び出しに含まれる MCP 関連情報。 +1. ツール一覧を取得するための MCP サーバー呼び出し。 +2. ツール呼び出しに関する MCP 関連情報。 ![MCP トレーシングのスクリーンショット](../assets/images/mcp-tracing.jpg) ## 関連資料 - [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様および設計ガイド。 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 実行可能な stdio、SSE、Streamable HTTP のコード例。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認とコネクターを含む、ホスト型 MCP の完全なデモ。 \ No newline at end of file +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 実行可能な stdio、SSE、Streamable HTTP のサンプル。 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認とコネクターを含む、完全なホスト型 MCP のデモ。 \ No newline at end of file diff --git a/docs/ja/realtime/guide.md b/docs/ja/realtime/guide.md index d32c8af370..95d3c2ad3a 100644 --- a/docs/ja/realtime/guide.md +++ b/docs/ja/realtime/guide.md @@ -2,28 +2,28 @@ search: exclude: true --- -# Realtime エージェントガイド +# リアルタイムエージェントガイド -本ガイドでは、OpenAI Agents SDK の Realtime レイヤーが OpenAI Realtime API にどのように対応しているか、および Python SDK がその上に追加する動作について説明します。 +このガイドでは、OpenAI Agents SDK のリアルタイムレイヤーが OpenAI Realtime API にどのように対応しているか、また Python SDK がその上にどのような追加動作を提供するかを説明します。 !!! note "まずはこちら" - Python の標準的な利用方法については、最初に[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用するか検討している場合は、[Realtime トランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれていません。 + デフォルトの Python の利用方法を確認する場合は、まず [クイックスタート](quickstart.md)をお読みください。アプリでサーバー側の WebSocket と SIP のどちらを使用すべきか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK には含まれていません。 ## 概要 -Realtime エージェントは Realtime API への長時間接続を維持するため、モデルはターンごとに新しいリクエストを最初から開始することなく、テキストと音声を逐次処理し、音声出力をストリーミングし、ツールを呼び出し、中断を処理できます。 +リアルタイムエージェントは Realtime API への長時間接続を維持します。これにより、モデルはテキストと音声を逐次処理し、音声出力をストリーミングし、ツールを呼び出し、ターンごとに新しいリクエストを開始し直すことなく中断を処理できます。 -SDK の主なコンポーネントは次のとおりです。 +主な SDK コンポーネントは次のとおりです。 -- **RealtimeAgent**: 1 つの Realtime 専門エージェント向けの指示、ツール、出力ガードレール、ハンドオフ -- **RealtimeRunner**: 開始エージェントを Realtime トランスポートに接続するセッションファクトリー -- **RealtimeSession**: 入力を送信し、イベントを受信し、履歴を追跡し、ツールを実行するライブセッション +- **RealtimeAgent**: 1 つのリアルタイム専門エージェントに対する指示、ツール、出力ガードレール、ハンドオフ +- **RealtimeRunner**: 開始エージェントをリアルタイムトランスポートに接続するセッションファクトリー +- **RealtimeSession**: 入力の送信、イベントの受信、履歴の追跡、ツールの実行を行うライブセッション - **RealtimeModel**: トランスポートの抽象化。デフォルトは OpenAI のサーバー側 WebSocket 実装です。 ## セッションのライフサイクル -一般的な Realtime セッションは次のようになります。 +一般的なリアルタイムセッションは次のようになります。 1. 1 つ以上の `RealtimeAgent` を作成します。 2. 開始エージェントを指定して `RealtimeRunner` を作成します。 @@ -32,20 +32,20 @@ SDK の主なコンポーネントは次のとおりです。 5. `send_message()` または `send_audio()` を使用してユーザー入力を送信します。 6. 会話が終了するまでセッションイベントを反復処理します。 -テキストのみの実行とは異なり、`runner.run()` は最終的な実行結果をすぐには生成しません。代わりに、ローカル履歴、バックグラウンドでのツール実行、ガードレールの状態、アクティブなエージェント設定をトランスポートレイヤーと同期し続けるライブセッションオブジェクトを返します。 +テキストのみの実行とは異なり、`runner.run()` は最終実行結果をすぐには生成しません。代わりに、ローカル履歴、バックグラウンドでのツール実行、ガードレールの状態、アクティブなエージェント設定をトランスポートレイヤーと同期し続けるライブセッションオブジェクトを返します。 -デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、Python の標準的な利用方法では Realtime API へのサーバー側 WebSocket 接続が使用されます。別の `RealtimeModel` を渡した場合も、接続の仕組みは変えられますが、同じセッションライフサイクルとエージェント機能が引き続き適用されます。 +デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、デフォルトの Python の利用方法では Realtime API へのサーバー側 WebSocket 接続が使用されます。別の `RealtimeModel` を渡した場合でも、接続の仕組みを変更しつつ、同じセッションライフサイクルとエージェント機能を利用できます。 ## エージェントとセッションの設定 -`RealtimeAgent` は意図的に通常の `Agent` 型よりも対象範囲が限定されています。 +`RealtimeAgent` は通常の `Agent` 型よりも意図的に機能範囲が限定されています。 -- モデルの選択はエージェントごとではなく、セッションレベルで設定します。 -- structured outputs はサポートされていません。 -- 音声は設定できますが、セッションが発話音声を生成した後は変更できません。 -- 指示、関数ツール、ハンドオフ、フック、出力ガードレールはすべて引き続き機能します。 +- モデルの選択はエージェント単位ではなく、セッションレベルで設定します。 +- Structured outputs はサポートされていません。 +- 音声は設定できますが、セッションが音声を生成した後は変更できません。 +- 指示、関数ツール、ハンドオフ、フック、出力ガードレールはすべて引き続き利用できます。 -`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と従来のフラット形式のエイリアスの両方をサポートしています。新しいコードではネスト形式を推奨します。また、新しい Realtime エージェントでは `gpt-realtime-2.1` から始めてください。 +`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と従来のフラットなエイリアスの両方をサポートします。新しいコードではネスト形式を使用し、新しいリアルタイムエージェントには `gpt-realtime-2.1` を使用することを推奨します。 ```python runner = RealtimeRunner( @@ -67,7 +67,7 @@ runner = RealtimeRunner( ) ``` -主なセッションレベルの設定は次のとおりです。 +便利なセッションレベルの設定には、次のものがあります。 - `audio.input.format`, `audio.output.format` - `audio.input.transcription` @@ -79,7 +79,7 @@ runner = RealtimeRunner( - `prompt` - `tracing` -`RealtimeRunner(config=...)` の主な実行レベルの設定は次のとおりです。 +`RealtimeRunner(config=...)` で使用できる便利な実行レベルの設定には、次のものがあります。 - `async_tool_calls` - `output_guardrails` @@ -87,13 +87,13 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -型付けされたインターフェース全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 +型付けされた設定項目の全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 ## 入出力 ### テキストと構造化ユーザーメッセージ -プレーンテキストまたは構造化された Realtime メッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] を使用します。 +プレーンテキストまたは構造化されたリアルタイムメッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] を使用します。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +111,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -構造化メッセージは、Realtime 会話に画像入力を含めるための主な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) の Web デモのコード例では、この方法で `input_image` メッセージを転送しています。 +構造化メッセージは、リアルタイム会話に画像入力を含めるための主な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) の Web デモ例では、この方法で `input_image` メッセージを転送します。 ### 音声入力 -raw 音声バイトをストリーミングするには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用します。 +生の音声バイトをストリーミングするには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用します。 ```python await session.send_audio(audio_bytes) ``` -サーバー側のターン検出が無効になっている場合、ターンの境界を示す処理はご自身で行う必要があります。高レベルの便利な方法は次のとおりです。 +サーバー側のターン検出を無効にしている場合は、ターンの境界を自身で指定する必要があります。高レベルの便利な方法は次のとおりです。 ```python await session.send_audio(audio_bytes, commit=True) ``` -より低レベルの制御が必要な場合は、基盤となるモデルトランスポートを介して、`input_audio_buffer.commit` などの raw クライアントイベントを送信することもできます。 +より低レベルの制御が必要な場合は、基盤となるモデルトランスポートを介して `input_audio_buffer.commit` などの生のクライアントイベントを送信することもできます。 ### 手動レスポンス制御 -`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを開始します。raw 音声のバッファリングでは、すべての設定で同じ処理が **自動的に行われるわけではありません**。 +`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを開始します。生の音声バッファリングでは、すべての設定で同じ処理が **自動的に行われるわけではありません**。 -Realtime API レベルでターンを手動制御するには、raw の `session.update` で `turn_detection` をクリアしてから、ご自身で `input_audio_buffer.commit` と `response.create` を送信します。 +Realtime API レベルでターンを手動制御するには、生の `session.update` で `turn_detection` をクリアし、その後に `input_audio_buffer.commit` と `response.create` を自身で送信します。 -ターンを手動で管理する場合は、モデルトランスポートを介して raw クライアントイベントを送信できます。 +ターンを手動で管理する場合は、モデルトランスポートを介して生のクライアントイベントを送信できます。 ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -151,17 +151,17 @@ await session.model.send_event( このパターンは、次の場合に役立ちます。 -- `turn_detection` が無効であり、モデルが応答するタイミングを指定したい場合 -- レスポンスを開始する前にユーザー入力を確認または制御したい場合 -- 帯域外レスポンス用のカスタムプロンプトが必要な場合 +- `turn_detection` が無効で、モデルが応答するタイミングを自身で決定したい場合 +- レスポンスを開始する前にユーザー入力を検査または制限したい場合 +- 会話外のレスポンスにカスタムプロンプトが必要な場合 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) の SIP のコード例では、raw の `response.create` を使用して冒頭の挨拶を強制的に生成しています。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) の SIP の例では、生の `response.create` を使用して最初の挨拶を強制的に生成します。 ## イベント、履歴、中断 -`RealtimeSession` は高レベルの SDK イベントを発行しながら、必要に応じて raw モデルイベントも転送します。 +`RealtimeSession` は、必要に応じて生のモデルイベントも転送しながら、より高レベルな SDK イベントを発行します。 -重要なセッションイベントは次のとおりです。 +特に重要なセッションイベントには、次のものがあります。 - `audio`, `audio_end`, `audio_interrupted` - `agent_start`, `agent_end` @@ -173,13 +173,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI の状態に最も役立つイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しなど、セッションのローカル履歴を `RealtimeItem` オブジェクトとして公開します。 +UI の状態に最も有用なイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しを含むセッションのローカル履歴を `RealtimeItem` オブジェクトとして公開します。 ### 使用量の集計 -完了したモデルレスポンスに使用量が含まれている場合、OpenAI の Realtime モデルは `raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] を発行します。その `usage` フィールドにはレスポンスのトークン数が含まれ、`input_tokens_details` と `output_tokens_details` では任意のモダリティ別内訳が提供されます。 +完了したモデルレスポンスに使用量が含まれている場合、OpenAI のリアルタイムモデルは `raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] を発行します。その `usage` フィールドには当該レスポンスのトークン数が含まれ、`input_tokens_details` と `output_tokens_details` ではモダリティ別の内訳がオプションで提供されます。 -セッションは各レスポンスの使用量を、共有される [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] にも追加します。ライブセッションの累積使用量を確認するには、`agent_end` などの後続の高レベルイベントで `event.info.context.usage` から読み取ります。 +また、セッションは各レスポンスの使用量を共有の [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] に加算します。ライブセッションの累積使用量を確認するには、`agent_end` など、後続の高レベルイベントにある `event.info.context.usage` から読み取ります。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,21 +197,21 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -使用量は、モデルプロバイダーが完了したレスポンスにその情報を含めた場合にのみ報告されます。累積値の対象は、その `RealtimeSession` が受信したレスポンスです。複数のセッションをまたぐ合計値ではありません。 +使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めている場合にのみ報告されます。累積値の対象は、その `RealtimeSession` が受信したレスポンスです。複数のセッションをまたぐ合計値ではありません。 -### 中断と再生トラッキング +### 中断と再生追跡 ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を発行し、サーバー側の会話がユーザーに実際に聞こえた内容と一致するように履歴を更新します。 -低遅延のローカル再生では、通常、デフォルトの再生トラッカーで十分です。リモート再生や遅延再生、特にテレフォニーでは、生成された音声がすべて再生済みであると想定するのではなく、実際の再生進捗に基づいて中断時の切り詰めを行うために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] を使用してください。 +低遅延のローカル再生では、通常、デフォルトの再生トラッカーで十分です。リモート再生や遅延再生、特にテレフォニーのシナリオでは、生成されたすべての音声がすでに再生されたと仮定するのではなく、実際の再生進捗に基づいて中断時の切り詰めを行うために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] を使用します。 -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) の Twilio のコード例は、このパターンを示しています。 +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) の Twilio の例で、このパターンを確認できます。 ## ツール、承認、ハンドオフ、ガードレール ### 関数ツール -Realtime エージェントは、ライブ会話中の関数ツールをサポートしています。 +リアルタイムエージェントは、ライブ会話中の関数ツールをサポートします。 ```python from agents.decorators import tool @@ -232,9 +232,9 @@ agent = RealtimeAgent( ### ツールの承認 -関数ツールでは、実行前に人間による承認を必須にできます。その場合、セッションは `tool_approval_required` を発行し、`approve_tool_call()` または `reject_tool_call()` が呼び出されるまでツールの実行を一時停止します。 +関数ツールでは、実行前に人間の承認を必須にできます。その場合、セッションは `tool_approval_required` を発行し、`approve_tool_call()` または `reject_tool_call()` が呼び出されるまでツール実行を一時停止します。 -ツールに入力ガードレールも設定されている場合、そのガードレールは承認後、実行直前に動作します。承認イベントが発行される前に実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を使用してランナーを作成します。この承認前チェックを通過した呼び出しも、承認後の実行前に再度チェックされます。 +ツールに入力ガードレールも設定されている場合、それらのガードレールは承認後、実行直前に実行されます。承認イベントが発行される前に実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を使用してランナーを作成します。この承認前チェックに合格した呼び出しは、承認後、実行前に再度チェックされます。 ```python async for event in session: @@ -242,11 +242,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) を参照してください。ヒューマンインザループのドキュメントでも、[ヒューマンインザループ](../human_in_the_loop.md)でこのフローを参照しています。 +具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) を参照してください。ヒューマンインザループのドキュメントにある[ヒューマンインザループ](../human_in_the_loop.md)でも、このフローを参照しています。 ### ハンドオフ -Realtime ハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を転送できます。 +リアルタイムハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を転送できます。 ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +268,11 @@ main_agent = RealtimeAgent( ) ``` -`RealtimeAgent` を直接指定したハンドオフは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。Realtime ハンドオフは、通常のハンドオフの `input_filter` をサポートして **いません**。 +単体の `RealtimeAgent` を指定したハンドオフは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。リアルタイムハンドオフは、通常のハンドオフの `input_filter` を **サポートしていません**。 ### ガードレール -Realtime エージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートしています。出力ガードレールは、部分トークンごとではなく、デバウンスされた文字起こしの累積に対して動作し、例外を発生させる代わりに `guardrail_tripped` を発行します。 +リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートします。出力ガードレールは、部分的な差分ごとではなく、出力テキストと音声文字起こしの差分をデバウンスして蓄積した単位で実行され、例外を発生させる代わりに `guardrail_tripped` を発行します。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,13 +292,15 @@ agent = RealtimeAgent( ) ``` -Realtime 出力ガードレールが作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制的に実行して `guardrail_tripped` を発行します。さらに、作動したガードレールの名前を含む後続のユーザーメッセージを送信し、モデルが代替レスポンスを生成できるようにします。音声プレイヤーでは引き続き `audio_interrupted` を監視し、ローカル再生を即座に停止する必要があります。ガードレールはデバウンスされた文字起こしテキストに対して動作するため、トリップワイヤーが作動した時点ですでに一部の音声がバッファリングされている可能性があります。 +音声文字起こしに対してリアルタイム出力ガードレールが作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制実行して `guardrail_tripped` を発行します。さらに、作動したガードレールの名前を含む後続のユーザーメッセージを送信し、モデルが代替レスポンスを生成できるようにします。トリップワイヤーが作動した時点で一部の音声がすでにバッファリングされている可能性があるため、音声プレイヤーでは引き続き `audio_interrupted` を監視し、ローカル再生を直ちに停止する必要があります。組み込みの OpenAI Realtime トランスポートでは、ガードレールの処理が元のレスポンスの終了後に完了した場合、そのレスポンスのバッファリング済み再生のみを中断し、それより新しいレスポンスはキャンセルしません。テキストのみの出力では、セッションは代わりにレスポンス単位の `response.cancel` を送信します。停止すべき音声再生がないため、`audio_interrupted` は発行しません。組み込みの OpenAI Realtime モデルを使用している場合、テキストのみの経路でも同じ `guardrail_tripped` イベントと後続のユーザーメッセージが発行されます。 + +カスタム `RealtimeModel` トランスポートは、同じように元のレスポンス単位で音声を中断できるよう、`RealtimeModelSendInterrupt.response_id` と `playback_only` の指定に従う必要があります。また、テキストのみの復旧メッセージをサポートするには、`RealtimeModel.send_event_if()` もオーバーライドする必要があります。実装では、トランスポートが実際にイベントをコミットする境界で、指定された条件を再確認するか、その条件の処理を直列化する必要があります。デフォルト実装は復旧メッセージを安全にスキップします。これは、`send_event()` を待機する前に条件を確認すると、メッセージがコミットされる前に新しいレスポンスが開始される可能性があるためです。レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 ## SIP とテレフォニー -Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] を使用する正式な SIP 接続フローが含まれています。 +Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] を介したファーストクラスの SIP アタッチフローが含まれています。 -Realtime Calls API を介して着信があり、生成された `call_id` にエージェントセッションを接続する場合に使用します。 +Realtime Calls API を介して通話を受信し、生成された `call_id` にエージェントセッションをアタッチする場合に使用します。 ```python from agents.realtime import RealtimeRunner @@ -315,20 +317,20 @@ async with await runner.run( ... ``` -最初に通話を受け入れる必要があり、受け入れ時のペイロードをエージェントから導出されたセッション設定に一致させたい場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローについては、[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) を参照してください。 +最初に通話を受け入れる必要があり、受け入れ時のペイロードをエージェントから派生したセッション設定と一致させたい場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) に示されています。 ## 低レベルアクセスとカスタムエンドポイント -`session.model` を介して、基盤となるトランスポートオブジェクトにアクセスできます。 +基盤となるトランスポートオブジェクトには、`session.model` を介してアクセスできます。 -次の場合に使用します。 +次の処理が必要な場合に使用します。 - `session.model.add_listener(...)` を介したカスタムリスナー -- `response.create` や `session.update` などの raw クライアントイベント +- `response.create` や `session.update` などの生のクライアントイベント - `model_config` を介したカスタムの `url`、`headers`、`api_key` の処理 -- 既存の Realtime 通話への `call_id` 接続 +- 既存のリアルタイム通話への `call_id` のアタッチ -`RealtimeModelConfig` は次をサポートしています。 +`RealtimeModelConfig` は次の項目をサポートします。 - `api_key` - `url` @@ -337,9 +339,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -このリポジトリに含まれる `call_id` のコード例は SIP 用です。より広範な Realtime API でも、一部のサーバー側制御フローに `call_id` が使用されますが、ここでは Python のコード例としてパッケージ化されていません。 +このリポジトリに含まれる `call_id` の例は SIP です。より広範な Realtime API でも、一部のサーバー側制御フローで `call_id` が使用されますが、ここでは Python の例として提供されていません。 -Azure OpenAI に接続する場合は、GA 版の Realtime エンドポイント URL と明示的なヘッダーを渡します。次に例を示します。 +Azure OpenAI に接続する場合は、GA 版の Realtime エンドポイント URL と明示的なヘッダーを渡します。例: ```python session = await runner.run( @@ -350,7 +352,7 @@ session = await runner.run( ) ``` -トークンベース認証では、`headers` 内でベアラートークンを使用します。 +トークンベースの認証では、`headers` に Bearer トークンを指定します。 ```python session = await runner.run( @@ -361,11 +363,11 @@ session = await runner.run( ) ``` -`headers` を渡した場合、SDK は `Authorization` を自動的に追加しません。Realtime エージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を使用しないでください。 +`headers` を渡した場合、SDK は `Authorization` を自動的に追加しません。リアルタイムエージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を使用しないでください。 ## 関連情報 -- [Realtime トランスポート](transport.md) +- [リアルタイムトランスポート](transport.md) - [クイックスタート](quickstart.md) - [OpenAI Realtime の会話](https://developers.openai.com/api/docs/guides/realtime-conversations/) - [OpenAI Realtime のサーバー側制御](https://developers.openai.com/api/docs/guides/realtime-server-controls/) diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index 1d115dcb6a..79b3c44dec 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -7,8 +7,8 @@ search: [`Runner`][agents.run.Runner] クラスを介してエージェントを実行できます。次の 3 つの方法があります。 1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行し、[`RunResult`][agents.result.RunResult] を返します。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドであり、内部で `.run()` を実行します。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントを順次ストリーミングします。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドであり、内部では `.run()` を実行します。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、イベントを受信すると順次ストリーミングします。 ```python from agents import Agent, Runner @@ -23,26 +23,26 @@ async def main(): # Infinite loop's dance ``` -詳細については、[実行結果ガイド](results.md)を参照してください。 +詳しくは、[実行結果ガイド](results.md)をご覧ください。 ## Runner のライフサイクルと設定 ### エージェントループ -`Runner` の run メソッドを使用する際は、開始エージェントと入力を渡します。入力には次のものを使用できます。 +`Runner` の run メソッドを使用する際は、開始エージェントと入力を渡します。入力には次のものを指定できます。 - 文字列(ユーザーメッセージとして扱われます) - OpenAI Responses API 形式の入力項目のリスト - 中断された実行を再開する場合は [`RunState`][agents.run_state.RunState] -その後、Runner はループを実行します。 +その後、Runner は次のループを実行します。 -1. 現在のエージェントに対し、現在の入力を使用して LLM を呼び出します。 +1. 現在のエージェントについて、現在の入力で LLM を呼び出します。 2. LLM が出力を生成します。 - 1. LLM が `final_output` を返した場合、ループは終了し、実行結果を返します。 + 1. LLM が `final_output` を返した場合、ループを終了して実行結果を返します。 2. LLM がハンドオフを行った場合、現在のエージェントと入力を更新し、ループを再実行します。 - 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行し、実行結果を追加して、ループを再実行します。 -3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外が発生します。このターン制限を無効にするには、`max_turns=None` を渡します。 + 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行して実行結果を追加し、ループを再実行します。 +3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note @@ -50,7 +50,7 @@ async def main(): ### ストリーミング -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳しくは、[ストリーミングガイド](streaming.md)をご覧ください。 #### Responses WebSocket トランスポート(オプションのヘルパー) @@ -58,11 +58,11 @@ OpenAI Responses WebSocket トランスポートを有効にしても、通常 これは WebSocket トランスポート経由の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 -トランスポートの選択ルールと、具象モデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)を参照してください。 +トランスポートの選択ルール、および具象モデルオブジェクトやカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 ##### パターン 1:セッションヘルパーなし(利用可能) -WebSocket トランスポートのみが必要で、SDK に共有プロバイダーやセッションを管理させる必要がない場合に使用します。 +WebSocket トランスポートのみが必要で、共有プロバイダーやセッションを SDK に管理させる必要がない場合に使用します。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -このパターンは、単一の実行には適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行のたびに再接続される可能性があります。 +このパターンは単一の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 ##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) -複数の実行で WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。これには、同じ `run_config` を継承する、ネストされた「ツールとしてのエージェント」の呼び出しも含まれます。 +複数の実行にわたって WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 ```python import asyncio @@ -119,58 +119,59 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果を最後まで取得してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制終了される可能性があります。 +コンテキストを終了する前に、ストリーミングされた実行結果の消費を完了してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 -サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約をなくすものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。完全な復旧動作については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)を参照してください。 +サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約を取り除くものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストで新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。完全な復旧動作については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)をご覧ください。 -長時間の推論ターンで WebSocket の keepalive タイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket のレイテンシーよりも信頼性が重要な実行には、HTTP/SSE トランスポートを使用してください。 +長時間の推論ターンで WebSocket の keepalive タイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。WebSocket のレイテンシーより信頼性を重視する実行には、HTTP/SSE トランスポートを使用してください。 ### 実行設定 -`run_config` パラメーターを使用すると、エージェントの実行に関する一部のグローバル設定を構成できます。 +`run_config` パラメーターを使用すると、エージェント実行に関するいくつかのグローバル設定を構成できます。 #### 一般的な実行設定のカテゴリー -各エージェントの定義を変更せずに単一の実行の動作を上書きするには、`RunConfig` を使用します。 +各エージェントの定義を変更せずに、単一の実行に対する動作を上書きするには、`RunConfig` を使用します。 ##### モデル、プロバイダー、セッションのデフォルト -- [`model`][agents.run.RunConfig.model]:各 Agent に設定された `model` に関係なく、使用するグローバル LLM モデルを設定できます。 -- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAIです。 -- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 -- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(たとえば `SessionSettings(limit=...)`)を上書きします。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各ターンの前に新しいユーザー入力をセッション履歴とマージする方法をカスタマイズします。コールバックは同期または非同期にできます。 +- [`model`][agents.run.RunConfig.model]:各 Agent に設定されている `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 +- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 +- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` や `top_p` を設定できます。 +- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(例:`SessionSettings(limit=...)`)を上書きします。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions を使用する際に、各ターンの前に新しいユーザー入力をセッション履歴へマージする方法をカスタマイズします。コールバックは同期または非同期にできます。 ##### ガードレール、ハンドオフ、モデル入力の整形 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに独自のフィルターがまだない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信する入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントを参照してください。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、損失のないメッセージ項目を元の位置に保持しながら、要約可能な履歴を順序付きの assistant 要約セグメントへ圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、raw のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、および `RunResult.to_input_list()` は、SDK デフォルトのネストされた履歴がすでに所有している完全に同一のメッセージ出現箇所を重複して追加しない一方、別々に存在する同一メッセージは保持します。明示的に渡さなかった場合、すべての [Runner メソッド][agents.run.Runner]は自動的に `RunConfig` を作成するため、クイックスタートとコード例ではデフォルトで無効のままになり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を介してこの設定を上書きできます。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインした場合に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを作成することなく、組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴を削減したり、システムプロンプトを挿入したりできます。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力へ変換する際に、推論項目 ID を保持するか省略するかを制御します。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに独自のフィルターが設定されていない場合に、すべてのハンドオフへ適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントへ送信される入力を編集できます。詳しくは、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、元の位置にあるメッセージ項目を欠損なく保持しながら、要約可能な履歴を順序付きの assistant 要約セグメントへ圧縮するオプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、raw のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、`RunResult.to_input_list()` は、SDK のデフォルトで生成されたネスト済み履歴に同一のメッセージ出現箇所がすでに含まれている場合、そのメッセージを重複して追加しません。一方、内容が同一でも別々のメッセージは保持します。[Runner のすべてのメソッド][agents.run.Runner]は、指定されていない場合に `RunConfig` を自動作成するため、クイックスタートやコード例ではデフォルトで無効のままです。また、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックによる上書きも引き続き有効です。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を介してこの設定を上書きできます。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` を有効にした際に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを記述せずに、組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の切り詰めやシステムプロンプトの注入に使用できます。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力へ変換する際に、推論項目の ID を保持するか省略するかを制御します。 -##### トレーシングと可観測性 +##### トレーシングとオブザーバビリティ - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 - [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密性の高い可能性があるデータをトレースに含めるかどうかを構成します。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入力/出力など、機密情報である可能性のあるデータをトレースに含めるかどうかを設定します。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にまたがるトレースを関連付けるためのオプションフィールドです。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 ##### ツール実行、承認、ツールエラーの動作 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行する関数ツールの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を構成します。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した未解決の関数ツール呼び出しを Runner が処理する方法を構成します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから参照可能なエラー出力を返すようオプトインできます。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから参照可能なツールエラーメッセージをカスタマイズします。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行する関数ツールの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した未解決の関数ツール呼び出しを Runner が処理する方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから確認できるエラー出力を返すようオプトインできます。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、Runner が処理する方法を設定します。デフォルトの `"warn"` は、対応方法を示す警告をログに記録し、現在ディスパッチ対象となっているものだけを公開します。`"error"` は、モデルが呼び出される前に `UserError` を発生させます。名前空間付きツールと遅延読み込みツールに対する厳密な検証は変更されません。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから確認できるツールエラーメッセージをカスタマイズします。 -ネストされたハンドオフは、オプトインのベータ機能として利用できます。`RunConfig(nest_handoff_history=True)` を渡して順序付きトランスクリプトの圧縮を有効にするか、特定のハンドオフで有効にするために `handoff(..., nest_handoff_history=True)` を設定します。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、損失のないメッセージ項目の前後に、生成された assistant 要約セグメントを配置します。raw のトランスクリプトを保持する場合(デフォルト)は、フラグを未設定のままにするか、必要な形式で会話を正確に転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを作成せずに、生成された要約セグメントで使用されるラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 +ネストされたハンドオフは、オプトインのベータ機能として利用できます。`RunConfig(nest_handoff_history=True)` を渡すか、`handoff(..., nest_handoff_history=True)` を設定すると、特定のハンドオフについて順序付きのトランスクリプト圧縮を有効にできます。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージへ圧縮するのではなく、欠損のないメッセージ項目を囲むように、生成された assistant 要約セグメントを配置します。raw のトランスクリプトを保持する場合(デフォルト)は、フラグを設定しないか、必要な形式で会話を転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成された要約セグメントで使用されるラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。デフォルトへ戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します。 #### 実行設定の詳細 ##### `tool_execution` -実行中のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を構成する場合は、`tool_execution` を使用します。 +実行中のローカル関数ツールの並行処理数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -189,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成した場合、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツールの数を制限するには、整数値を設定します。 +`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成すると、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツールの数を制限するには、整数値を設定します。 -これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別のものです。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがローカル関数ツール呼び出しを生成した後、それらを SDK がどのように実行するかを制御します。 +これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別の設定です。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルが生成した後に、SDK がローカル関数ツール呼び出しを実行する方法を制御します。 -`pre_approval_tool_input_guardrails=False` は、デフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、承認後の実行直前にのみツール入力ガードレールが実行されます。保留中の承認による中断が生成される前に関数ツールの入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックに合格した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間に依存するチェックは実行前に再検証されます。 +`pre_approval_tool_input_guardrails=False` は、デフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後の実行直前にのみ実行されます。保留中の承認による中断が生成される前に、関数ツールの入力ガードレールを実行する場合は `True` を設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間依存のチェックは実行前に再検証されます。 ##### `tool_not_found_behavior` -デフォルトでは、現在のエージェントが利用できるどの関数ツールにも一致しない関数ツール呼び出しをモデルが生成した場合、Runner は `ModelBehaviorError` を発生させます。 +デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 -実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は未解決のツール呼び出しに対する `function_call_output` を追加し、モデルを再度実行します。これにより、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 +実行を復旧可能な状態に保つには、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は未解決のツール呼び出しに対する `function_call_output` を追加し、モデルを再実行します。これにより、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 ```python from agents import Agent, RunConfig, Runner @@ -213,11 +214,11 @@ result = await Runner.run( ) ``` -現在、このオプションは未解決の関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、引き続き既存のエラー動作が使用されます。 +現在、このオプションは未解決の関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、既存のエラー動作が引き続き使用されます。 ##### `tool_error_formatter` -SDK がモデルから参照可能なツールエラー出力を作成する際に、モデルへ返されるメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 +SDK がモデルから確認できるツールエラー出力を作成する際に、モデルへ返されるメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 フォーマッターは、次の情報を含む [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 @@ -225,7 +226,7 @@ SDK がモデルから参照可能なツールエラー出力を作成する際 - `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)。 - `tool_name`:ツール名。 - `call_id`:ツール呼び出し ID。 -- `default_message`:SDK のデフォルトの、モデルから参照可能なメッセージ。 +- `default_message`:モデルから確認できる SDK のデフォルトメッセージ。 - `run_context`:アクティブな実行コンテキストラッパー。 メッセージを置き換えるには文字列を返し、SDK のデフォルトを使用するには `None` を返します。 @@ -255,52 +256,52 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` は、Runner が履歴を次のターンへ引き継ぐ際に、推論項目を次のターンのモデル入力へ変換する方法を制御します(たとえば、`RunResult.to_input_list()` またはセッションを利用した実行を使用する場合)。 +`reasoning_item_id_policy` は、Runner が履歴を次へ引き継ぐ際に、推論項目を次のターンのモデル入力へ変換する方法を制御します。たとえば、`RunResult.to_input_list()` を使用する場合や、セッションを利用した実行が対象です。 -- `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 -- `"omit"`:生成された次のターンの入力から推論項目 ID を削除します。 +- `None` または `"preserve"`(デフォルト):推論項目の ID を保持します。 +- `"omit"`:生成される次のターンの入力から、推論項目の ID を削除します。 -`"omit"` は主に、推論項目が `id` を伴って送信されたものの、必須の後続項目がない場合に発生する一連の Responses API 400 エラーに対する、オプトインの緩和策として使用します(たとえば、`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` は主に、推論項目が `id` 付きで送信されたものの、後続に必要な項目がない場合に発生する Responses API の 400 エラーへのオプトインの緩和策として使用します。たとえば、`Item 'rs_...' of type 'reasoning' was provided without its required following item.` というエラーです。 -これは、SDK が以前の出力から後続入力を構築する複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。このとき、推論項目 ID は保持されているものの、プロバイダーがその ID と対応する後続項目とのペアを維持するよう要求する場合があります。 +これは、SDK が以前の出力から後続の入力を構築する複数ターンのエージェント実行で発生する可能性があります。対象には、セッションの永続化、サーバー管理の会話差分、ストリーミング/非ストリーミングの後続ターン、再開パスが含まれます。推論項目の ID が保持されていても、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合に発生します。 -`reasoning_item_id_policy="omit"` を設定すると、推論内容を保持しながら推論項目の `id` を削除します。これにより、SDK が生成する後続入力で、この API の不変条件に抵触することを回避できます。 +`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` が削除されます。これにより、SDK が生成する後続入力でその API 不変条件に抵触することを回避できます。 適用範囲に関する注意事項: -- これは、SDK が後続入力を構築する際に、SDK によって生成または転送される推論項目のみを変更します。 +- これは、SDK が後続入力を構築する際に生成または転送する推論項目のみを変更します。 - ユーザーが指定した初期入力項目は書き換えません。 -- このポリシーが適用された後でも、`call_model_input_filter` によって意図的に推論 ID を再導入できます。 +- このポリシーの適用後でも、`call_model_input_filter` によって意図的に推論 ID を再導入できます。 ## 状態と会話の管理 -### メモリ戦略の選択 +### メモリー戦略の選択 状態を次のターンへ引き継ぐ一般的な方法は 4 つあります。 -| 戦略 | 状態の保存場所 | 最適な用途 | 次のターンで渡すもの | +| 戦略 | 状態の保存場所 | 適した用途 | 次のターンで渡すもの | | --- | --- | --- | --- | -| `result.to_input_list()` | アプリのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | +| `result.to_input_list()` | アプリのメモリー | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | | `session` | ストレージと SDK | 永続的なチャット状態、再開可能な実行、カスタムストア | 同じ `session` インスタンス、または同じストアを参照する別のインスタンス | | `conversation_id` | OpenAI Conversations API | ワーカーやサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | -| `previous_response_id` | OpenAI Responses API | 会話リソースを作成しない、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | +| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに使用する、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | -`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。両方のレイヤーを意図的に調整している場合を除き、クライアント管理の履歴と OpenAI管理の状態を混在させると、コンテキストが重複する可能性があります。 +`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両方のレイヤーを意図的に調整している場合を除き、コンテキストが重複する可能性があります。 !!! note - 同じ実行内で、セッションの永続化をサーバー管理の会話設定 + セッションの永続化は、サーバー管理の会話設定 (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)と - 組み合わせることはできません。呼び出しごとに 1 つの方法を選択してください。 + 同じ実行内で併用できません。呼び出しごとに 1 つの方法を選択してください。 -### 会話とチャットスレッド +### 会話/チャットスレッド -いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される(したがって、LLM が 1 回以上呼び出される)可能性がありますが、チャット会話における 1 つの論理ターンを表します。たとえば、次のようになります。 +いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される場合があり、その結果として 1 回以上の LLM 呼び出しが発生する可能性があります。ただし、チャット会話においては論理的に 1 つのターンを表します。たとえば、次のようになります。 1. ユーザーターン:ユーザーがテキストを入力します -2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行し、2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 +2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力のみを表示することもできます。どちらの場合でも、ユーザーが追加の質問をする可能性があり、その場合は run メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力だけを表示することもできます。いずれの場合も、ユーザーが追加の質問をする可能性があり、その際は run メソッドを再度呼び出せます。 #### 手動による会話管理 @@ -326,9 +327,9 @@ async def main(): # California ``` -#### セッションによる自動会話管理 +#### Sessions による自動会話管理 -より簡単な方法として、[Sessions](sessions/index.md) を使用すると、`.to_input_list()` を手動で呼び出さずに会話履歴を自動的に処理できます。 +より簡単な方法として、`.to_input_list()` を手動で呼び出すことなく、[Sessions](sessions/index.md) を使用して会話履歴を自動的に処理できます。 ```python from agents import Agent, Runner, SQLiteSession, trace @@ -352,24 +353,24 @@ async def main(): # California ``` -Sessions は以下を自動的に実行します。 +Sessions は、次の処理を自動的に行います。 -- 各実行の前に会話履歴を取得します -- 各実行の後に新しいメッセージを保存します +- 各実行前に会話履歴を取得します +- 各実行後に新しいメッセージを保存します - セッション ID ごとに個別の会話を維持します -詳細については、[Sessions のドキュメント](sessions/index.md)を参照してください。 +詳しくは、[Sessions のドキュメント](sessions/index.md)をご覧ください。 #### サーバー管理の会話 -`to_input_list()` または `Sessions` を使用してローカルで会話状態を処理する代わりに、OpenAIの会話状態機能によってサーバー側で会話状態を管理することもできます。これにより、過去のすべてのメッセージを手動で再送信することなく、会話履歴を保持できます。以下のいずれかのサーバー管理方式では、各リクエストで新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAIの会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)を参照してください。 +`to_input_list()` や `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能にサーバー側の会話状態を管理させることもできます。これにより、過去のすべてのメッセージを手動で再送信することなく、会話履歴を保持できます。以下のどちらのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳しくは、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 -OpenAIは、ターンをまたいで状態を追跡する 2 つの方法を提供します。 +OpenAI は、ターン間で状態を追跡するための方法を 2 つ提供しています。 ##### 1. `conversation_id` の使用 -まず OpenAI Conversations API を使用して会話を作成し、以降のすべての呼び出しでその ID を再利用します。 +まず OpenAI Conversations API を使用して会話を作成し、その後の各呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -392,7 +393,7 @@ async def main(): ##### 2. `previous_response_id` の使用 -もう 1 つの選択肢は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的に関連付けます。 +もう 1 つの方法は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID へ明示的に関連付けます。 ```python from agents import Agent, Runner @@ -417,30 +418,31 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開した場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を保持するため、再開されたターンは同じサーバー管理の会話で継続されます。 +実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を維持するため、再開したターンは同じサーバー管理の会話内で継続されます。 `conversation_id` と `previous_response_id` は相互排他的です。システム間で共有できる名前付きの会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 !!! note - SDK は、`conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話実行では、再試行前に内部の会話追跡用入力を巻き戻し、準備済みの同じ項目を - 問題なく再送信できるようにします。 + SDK は `conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の + 会話を使用する実行では、再試行前に内部の会話トラッカー入力を巻き戻し、 + 準備済みの同じ項目を問題なく再送信できるようにします。 - ローカルのセッションベースの実行(`conversation_id`、`previous_response_id`、 - `auto_previous_response_id` のいずれとも組み合わせられません)では、SDK は再試行後の - 履歴項目の重複を減らすため、直近に永続化された入力項目のロールバックもベストエフォートで行います。 + ローカルのセッションベースの実行(`conversation_id`、 + `previous_response_id`、または `auto_previous_response_id` とは併用不可)では、SDK は + 再試行後の履歴項目の重複を減らすため、直近に永続化された入力項目の + ベストエフォートなロールバックも行います。 - この互換性のための再試行は、`ModelSettings.retry` を構成していない場合でも行われます。モデルリクエストに対する - より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 + この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも行われます。モデルリクエストに対する + より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)をご覧ください。 ## フックとカスタマイズ -### モデル呼び出しの入力フィルター +### モデル呼び出し入力フィルター -モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。フックは、現在のエージェント、コンテキスト、結合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 +モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、および結合済みの入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 -戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。それ以外の形式を返すと、`UserError` が発生します。 +戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。それ以外の形式を返すと `UserError` が発生します。 ```python from agents import Agent, Runner, RunConfig @@ -459,19 +461,19 @@ result = Runner.run_sync( ) ``` -Runner は準備済みの入力リストのコピーをフックへ渡すため、呼び出し元の元のリストをその場で変更せずに、項目を削減、置換、または並べ替えられます。 +Runner は準備済み入力リストのコピーをフックへ渡すため、呼び出し元の元のリストをその場で変更せずに、切り詰め、置き換え、並べ替えを行えます。 -セッションを使用している場合、`call_model_input_filter` は、セッション履歴がすでに読み込まれ、現在のターンとマージされた後に実行されます。この前段階のマージ処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 +セッションを使用している場合、`call_model_input_filter` はセッション履歴が読み込まれ、現在のターンとマージされた後に実行されます。それより前のマージ処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 -`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAIのサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再現ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続用に送信済みとしてマークされます。 +`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目だけが、そのサーバー管理の継続処理で送信済みとして記録されます。 -機密データの秘匿化、長い履歴の削減、または追加のシステムガイダンスの挿入を行うには、`run_config` を介して実行ごとにフックを設定します。 +機密データの秘匿化、長い履歴の切り詰め、追加のシステムガイダンスの注入を行うには、`run_config` を介して実行ごとにフックを設定します。 ## エラーと復旧 ### エラーハンドラー -すべての `Runner` エントリーポイントは、エラー種別をキーとする dict である `error_handlers` を受け取ります。サポートされるキーは、`"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 +すべての `Runner` エントリーポイントは、エラー種別をキーとする dict である `error_handlers` を受け入れます。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 ```python from agents import ( @@ -500,7 +502,7 @@ result = Runner.run_sync( print(result.final_output) ``` -モデルメッセージがエージェントの structured `output_type` に対して検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を行いません。フォールバックがない場合、空でない検証エラーでは引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 +モデルのメッセージがエージェントの構造化された `output_type` に対する検証を通過しない場合、またはモデルが構造化された最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を辞退します。フォールバックがない場合、空でない出力の検証失敗では引き続き `ModelBehaviorError` が発生し、空の構造化レスポンスでは既存の次ターンの動作が維持されます。 ```python from pydantic import BaseModel @@ -532,9 +534,9 @@ result = Runner.run_sync( print(result.final_output) ``` -フォールバック出力を会話履歴に追加しない場合は、`include_in_history=False` を設定します。 +`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数のハンドラーでは、生成されたフォールバック出力を会話履歴へ追加し、設定済みのセッションに永続化します。実行結果の履歴やセッションストレージへ追加せず、呼び出し元へフォールバックを返す場合は、`include_in_history=False` を設定します。 -モデルによる拒否時に `ModelRefusalError` で実行を終了する代わりに、アプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 +モデルの拒否によって `ModelRefusalError` で実行を終了する代わりに、アプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 ```python from pydantic import BaseModel @@ -566,35 +568,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続実行の統合と Human-in-the-loop +## 永続実行の統合とヒューマンインザループ -ツール承認の一時停止と再開のパターンについては、専用の [Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。以下の統合は、長い待機、再試行、またはプロセスの再起動にまたがる可能性がある実行を永続的にオーケストレーションするためのものです。 +ツールの承認に関する一時停止/再開パターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長い待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 ### Dapr -Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、Human-in-the-loop をサポートし、障害から自動的に復旧する、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAIエージェントの使用を[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 +Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、ヒューマンインザループをサポートし、障害から自動的に復旧する、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの利用は、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 ### Temporal -Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、Human-in-the-loop タスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)です。 +Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。長時間実行タスクを完了するために Temporal と Agents SDK が連携して動作するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)です。 ### Restate -Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実行できます。この統合には、依存関係として Restate の単一バイナリランタイムが必要であり、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)を参照してください。 +Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実行できます。この統合は Restate の単一バイナリランタイムを依存関係として必要とし、エージェントをプロセス/コンテナまたはサーバーレス関数として実行できます。詳しくは、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 ### DBOS -Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、Human-in-the-loop ワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは、SQLite または Postgres データベースのみです。詳細については、統合の [repo](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)を参照してください。 +Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートしています。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは SQLite または Postgres データベースだけです。詳しくは、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 ## 例外 -SDK は特定の場合に例外を発生させます。完全なリストは [`agents.exceptions`][] にあります。概要は次のとおりです。 +SDK は特定の場合に例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 -- [`AgentsException`][agents.exceptions.AgentsException]:SDK 内で発生するすべての例外の基底クラスです。他のすべての具体的な例外は、この汎用型から派生します。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドに渡された `max_turns` の制限を超えた場合に発生します。指定された対話ターン数以内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。これには次のものが含まれます。 - - 不正な JSON:モデルがツール呼び出しまたは直接出力で不正な JSON 構造を提供した場合。特に、特定の `output_type` が定義されている場合に該当します。 - - 予期しないツール関連の障害:モデルが想定どおりにツールを使用できなかった場合 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが構成されたタイムアウトを超え、そのツールで `timeout_behavior="raise_exception"` が使用されている場合に発生します。 -- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードの作成者が、SDK の使用中に誤りを犯した場合に発生します。通常、コード実装の誤り、無効な設定、または SDK API の誤用が原因です。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:それぞれ、入力ガードレールまたは出力ガードレールの条件が満たされた場合に発生します。入力ガードレールは処理前の受信メッセージをチェックし、出力ガードレールは配信前のエージェントの最終レスポンスをチェックします。 \ No newline at end of file +- [`AgentsException`][agents.exceptions.AgentsException]:SDK 内で発生するすべての例外の基底クラスです。その他すべての特定の例外は、この汎用型から派生します。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が `Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドへ渡された `max_turns` 制限を超えた場合に発生します。指定された対話ターン数以内に、エージェントがタスクを完了できなかったことを示します。制限を無効にするには `max_turns=None` を設定します。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない、または無効な出力を生成した場合に発生します。次のようなケースが含まれます。 + - 不正な形式の JSON:モデルがツール呼び出しまたは直接出力で不正な形式の JSON 構造を生成した場合。特に、特定の `output_type` が定義されている場合が該当します。 + - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定済みのタイムアウトを超え、そのツールで `timeout_behavior="raise_exception"` が使用されている場合に発生します。 +- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードの作成者が、SDK の使用時に誤りを犯した場合に発生します。通常は、不適切なコード実装、無効な設定、SDK API の誤用が原因です。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:それぞれ、入力ガードレールまたは出力ガードレールの条件が満たされた場合に発生します。入力ガードレールは処理前に受信メッセージをチェックし、出力ガードレールは配信前にエージェントの最終レスポンスをチェックします。 \ No newline at end of file diff --git a/docs/ja/streaming.md b/docs/ja/streaming.md index 6c60153e26..26c8ad49a1 100644 --- a/docs/ja/streaming.md +++ b/docs/ja/streaming.md @@ -4,19 +4,19 @@ search: --- # ストリーミング -ストリーミングを使用すると、エージェントの実行中に更新を購読できます。エンドユーザーに進捗状況の更新や部分的なレスポンスを表示する場合に役立ちます。 +ストリーミングを使用すると、エージェントの実行中に更新を受け取れます。これは、エンドユーザーに進行状況の更新や部分的なレスポンスを表示する場合に役立ちます。 ストリーミングするには、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を呼び出します。これにより、[`RunResultStreaming`][agents.result.RunResultStreaming] が返されます。`result.stream_events()` を呼び出すと、以下で説明する [`StreamEvent`][agents.stream_events.StreamEvent] オブジェクトの非同期ストリームが返されます。 -非同期イテレーターが終了するまで、`result.stream_events()` を消費し続けてください。ストリーミング実行は、イテレーターが終了するまで完了しません。また、セッションの永続化、承認の記録管理、履歴の圧縮などの後処理は、最後に表示されるトークンが到着した後に完了する場合があります。ループが終了すると、`result.is_complete` に最終的な実行状態が反映されます。 +非同期イテレーターが終了するまで、`result.stream_events()` を処理し続けてください。ストリーミング実行は、イテレーターが終了するまで完了しません。また、セッションの永続化、承認状態の記録管理、履歴の圧縮などの後処理は、最後の可視トークンが到着した後に完了する場合があります。ループが終了すると、`result.is_complete` に最終的な実行状態が反映されます。 ## raw レスポンスイベント -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] は、LLM から直接渡される raw イベントです。これらは OpenAI Responses API 形式であり、各イベントには型(`response.created`、`response.output_text.delta` など)とデータがあります。これらのイベントは、生成されたレスポンスメッセージをすぐにユーザーへストリーミングする場合に役立ちます。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] は、LLM から直接渡される raw イベントです。これらは OpenAI Responses API 形式であるため、各イベントにはタイプ(`response.created`、`response.output_text.delta` など)とデータがあります。これらのイベントは、レスポンスメッセージが生成され次第、ユーザーにストリーミングする場合に役立ちます。 -コンピュータツールの raw イベントでは、保存された結果と同様に、プレビュー版と GA 版が区別されます。プレビュー版のフローでは、1 つの `action` を持つ `computer_call` 項目がストリーミングされます。一方、`gpt-5.5` では、バッチ化された `actions[]` を持つ `computer_call` 項目をストリーミングできます。上位レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] インターフェースでは、このためにコンピュータ専用の特別なイベント名は追加されません。どちらの形式も引き続き `tool_called` として公開され、スクリーンショットの結果は `computer_call_output` 項目をラップする `tool_output` として返されます。 +コンピュータツールの raw イベントでは、保存された実行結果と同じく、プレビュー版と GA 版が区別されます。プレビュー版のフローでは、1 つの `action` を持つ `computer_call` アイテムがストリーミングされます。一方、`gpt-5.5` では、バッチ化された `actions[]` を持つ `computer_call` アイテムがストリーミングされる場合があります。上位レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] インターフェースでは、これに対してコンピュータ専用の特別なイベント名は追加されません。どちらの形式も引き続き `tool_called` として公開され、スクリーンショットの実行結果は `computer_call_output` アイテムをラップする `tool_output` として返されます。 -たとえば、次の例では LLM が生成したテキストをトークン単位で出力します。 +たとえば、次のコードは LLM が生成したテキストをトークン単位で出力します。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## ストリーミングと承認 -ストリーミングは、ツールの承認のために一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` が終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 +ストリーミングは、ツールの承認待ちで一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` は終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] に公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,25 +57,25 @@ if result.interruptions: pass ``` -一時停止と再開の詳しい手順については、[human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +一時停止と再開の手順全体については、[ヒューマンインザループのガイド](human_in_the_loop.md)を参照してください。 -## 現在のターン終了後のストリーミングキャンセル +## 現在のターン終了後のストリーミング停止 -ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行は直ちに停止します。停止する前に現在のターンを正常に完了させるには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 +ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行は即座に停止します。現在のターンを正常に完了させてから停止するには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 -ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後に表示されるトークンの後も、SDK がセッション項目を永続化したり、承認状態を確定したり、履歴を圧縮したりしている可能性があります。 +ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後の可視トークンの後も、SDK がセッションアイテムの永続化、承認状態の確定、履歴の圧縮を行っている場合があります。 -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で処理を継続している場合に、`cancel(mode="after_turn")` がツールターンの後で停止したときは、新しいユーザーターンをすぐに追加するのではなく、正規化された入力で `result.last_agent` を再実行して、その未完了のターンを継続してください。 -- ストリーミング実行がツールの承認のために停止した場合は、それを新しいターンとして扱わないでください。ストリームを最後まで消費し、`result.interruptions` を確認して、`result.to_state()` から再開してください。 -- 次回のモデル呼び出し前に、取得したセッション履歴と新しいユーザー入力をどのように統合するかをカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。そのコールバック内で新しいターンの項目を書き換えた場合、そのターンでは書き換え後のバージョンが永続化されます。 +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で処理を継続しており、`cancel(mode="after_turn")` によってツールターンの後で停止した場合は、すぐに新しいユーザーターンを追加するのではなく、その正規化された入力で `result.last_agent` を再実行して、未完了のターンを継続してください。 +- ストリーミング実行がツールの承認待ちで停止した場合、それを新しいターンとして扱わないでください。ストリームを最後まで処理し、`result.interruptions` を確認して、`result.to_state()` から再開してください。 +- 取得したセッション履歴と新しいユーザー入力を、次のモデル呼び出しの前にどのように統合するかをカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。そこで新しいターンのアイテムを書き換えた場合、そのターンでは書き換え後のバージョンが永続化されます。 -## 実行項目イベントとエージェントイベント +## 実行アイテムイベントとエージェントイベント -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より上位レベルのイベントです。項目が完全に生成された時点を通知します。これにより、各トークン単位ではなく、「メッセージが生成された」「ツールが実行された」などの単位で進捗状況の更新を送信できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(ハンドオフの結果など)に更新を提供します。 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、上位レベルのイベントです。アイテムの生成が完全に完了すると通知されます。これにより、トークンごとではなく、「メッセージ生成済み」や「ツール実行済み」などの単位で進行状況の更新を送信できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更された場合(ハンドオフの結果など)に更新を通知します。 -### 実行項目のイベント名 +### 実行アイテムのイベント名 -`RunItemStreamEvent.name` では、次の固定された一連の意味的イベント名を使用します。 +`RunItemStreamEvent.name` では、固定されたセマンティックイベント名のセットを使用します。 - `message_output_created` - `handoff_requested` @@ -89,13 +89,15 @@ if result.interruptions: - `mcp_approval_response` - `mcp_list_tools` -`handoff_occured` は、後方互換性のために意図的にスペルミスのままになっています。 +`handoff_occured` は、後方互換性のため意図的にスペルが誤っています。 -ホスト型ツール検索を使用すると、モデルがツール検索リクエストを発行したときに `tool_search_called` が生成され、Responses API が読み込まれたサブセットを返したときに `tool_search_output_created` が生成されます。 +ハンドオフ呼び出しは `handoff_requested` としてのみ発行され、`tool_called` として重複して発行されることはありません。同じターン内の通常の関数ツール呼び出しでは、引き続き `tool_called` が発行されます。 -プログラムによるツール呼び出しでは、生成された `program` と、プログラムが所有する通常の子ツール呼び出しに対して `tool_called` が生成されます。子ツールの出力と対応する `program_output` に対しては、`tool_output` が生成されます。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外です。これらはそれぞれ、[`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] と [`MCPListToolsItem`][agents.items.MCPListToolsItem] をラップする `mcp_approval_requested` および `mcp_list_tools` として生成されます。残りの項目を区別するには、raw 項目の `type` を確認してください。また、プログラムが所有する子呼び出しには `caller` も含まれ、その型は `program` で、呼び出し元 ID によって親プログラムが識別されます。 +ホスト型ツール検索を使用すると、モデルがツール検索リクエストを発行したときに `tool_search_called` が発行され、Responses API が読み込まれたサブセットを返したときに `tool_search_output_created` が発行されます。 -たとえば、次の例では raw イベントを無視し、ユーザーへの更新をストリーミングします。 +Programmatic Tool Calling では、生成された `program` と、通常のプログラム配下の子ツール呼び出しに対して `tool_called` が発行されます。子ツールの出力と、それに対応する `program_output` に対しては、`tool_output` が発行されます。プログラム配下のホスト型 MCP の `mcp_approval_request` アイテムと `mcp_list_tools` アイテムは例外です。これらは、それぞれ [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] と [`MCPListToolsItem`][agents.items.MCPListToolsItem] をラップし、`mcp_approval_requested` と `mcp_list_tools` として発行されます。残りのアイテムを区別するには、raw アイテムの `type` を確認してください。プログラム配下の子呼び出しには、タイプが `program` で、呼び出し元 ID が親プログラムを識別する `caller` も含まれます。 + +たとえば、次のコードは raw イベントを無視し、更新をユーザーにストリーミングします。 ```python import asyncio diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index df237b48c4..e179c2da0c 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -4,7 +4,7 @@ search: --- # 가드레일 -가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 속도가 느리고 비용이 많이 드는 모델을 사용하여 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 상황은 원하지 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시키고 비용이 많이 드는 모델의 실행을 방지하여 시간과 비용을 절약할 수 있습니다(**차단형 가드레일을 사용하는 경우에 해당합니다. 병렬 가드레일의 경우 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 실행되기 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요**). +가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 속도가 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 상황은 원하지 않을 것입니다. 이 경우 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 고비용 모델이 실행되지 않도록 함으로써 시간과 비용을 절약할 수 있습니다(**차단형 가드레일을 사용할 때에 해당합니다. 병렬 가드레일의 경우 가드레일 실행이 완료되기 전에 고비용 모델이 이미 실행되기 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참고하세요**). 가드레일에는 두 가지 종류가 있습니다. @@ -13,64 +13,64 @@ search: ## 워크플로 경계 -가드레일은 에이전트와 도구에 연결되지만, 워크플로의 모든 지점에서 실행되는 것은 아닙니다. +가드레일은 에이전트와 도구에 연결되지만, 워크플로에서 모두 같은 시점에 실행되는 것은 아닙니다. -- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. -- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. -- **도구 가드레일**은 사용자 지정 함수 도구가 호출될 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. +- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. +- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. +- **도구 가드레일**은 사용자 정의 함수 도구가 호출될 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. -관리자, 핸드오프 또는 작업을 위임받은 전문가가 포함된 워크플로에서 사용자 지정 함수 도구 호출마다 검사를 수행해야 한다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. +관리자, 핸드오프 또는 위임된 전문가가 포함된 워크플로에서 각 사용자 정의 함수 도구 호출 전후에 검사가 필요하다면 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. ## 입력 가드레일 입력 가드레일은 다음 3단계로 실행됩니다. -1. 먼저 가드레일은 에이전트에 전달된 것과 동일한 입력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이 출력은 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 +1. 먼저 가드레일이 에이전트에 전달된 것과 동일한 입력을 받습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이는 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! 참고 +!!! Note - 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 그렇다면 왜 `guardrails` 속성을 `Runner.run`에 전달하지 않고 에이전트에 두는지 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. + 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 가드레일을 `Runner.run`에 전달하지 않고 에이전트의 `guardrails` 속성에 지정하는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경향이 있기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 같은 위치에 두면 가독성에 도움이 됩니다. ### 실행 모드 입력 가드레일은 두 가지 실행 모드를 지원합니다. -- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 동시에 시작되므로 지연 시간이 가장 짧습니다. 그러나 가드레일이 실패하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. +- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 같은 시점에 시작하므로 지연 시간을 최소화할 수 있습니다. 하지만 가드레일 검사가 실패하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. -- **차단형 실행**(`run_in_parallel=False`): 가드레일이 에이전트 시작 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트가 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출에서 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. +- **차단 실행**(`run_in_parallel=False`): 가드레일이 에이전트 실행 *전에* 시작되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 실행되지 않으므로 토큰 소비와 도구 실행을 방지합니다. 비용을 최적화하거나 도구 호출에서 발생할 수 있는 잠재적 부작용을 방지하려는 경우에 적합합니다. ## 출력 가드레일 출력 가드레일은 다음 3단계로 실행됩니다. -1. 먼저 가드레일은 에이전트가 생성한 출력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이 출력은 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 +1. 먼저 가드레일이 에이전트가 생성한 출력을 받습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이는 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! 참고 +!!! Note - 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로 이렇게 하는 이유는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. + 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로, 가드레일이 실제 에이전트와 관련되는 경향이 있기 때문에 이와 같이 동작합니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 같은 위치에 두면 가독성에 도움이 됩니다. 출력 가드레일은 항상 에이전트 실행이 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. ## 도구 가드레일 -도구 가드레일은 **함수 도구**를 래핑하며, 실행 전후에 도구 호출을 검증하거나 차단할 수 있게 해 줍니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. +도구 가드레일은 **함수 도구**를 감싸 실행 전후에 도구 호출을 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. -- 입력 도구 가드레일은 도구 실행 전에 실행되며 호출을 건너뛰거나, 출력을 메시지로 대체하거나, 트립와이어를 발생시킬 수 있습니다. -- 출력 도구 가드레일은 도구 실행 후에 실행되며 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. -- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후, 실행 직전에 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 승인 전 검사를 통과한 호출도 도구가 실행되기 전에 승인 후 다시 검사됩니다. -- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아닌 SDK의 핸드오프 파이프라인을 통해 실행되므로 도구 가드레일은 핸드오프 호출 자체에 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, 현재 [`Agent.as_tool()`][agents.agent.Agent.as_tool]도 도구 가드레일 옵션을 직접 노출하지 않습니다. +- 입력 도구 가드레일은 도구가 실행되기 전에 실행되며, 호출을 건너뛰거나 출력을 메시지로 대체하거나 트립와이어를 발생시킬 수 있습니다. +- 출력 도구 가드레일은 도구가 실행된 후에 실행되며, 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. +- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후, 실행 직전에 실행됩니다. 승인 대기 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 도구가 실행되기 전에 승인 후 다시 검사됩니다. +- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아닌 SDK의 핸드오프 파이프라인을 통해 실행되므로 도구 가드레일은 핸드오프 호출 자체에는 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 내장 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, 현재 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 도구 가드레일 옵션을 직접 제공하지 않습니다. -자세한 내용은 아래 코드 스니펫을 참조하세요. +자세한 내용은 아래 코드 조각을 참고하세요. ## 트립와이어 -입력 또는 출력이 가드레일을 통과하지 못하면 가드레일이 트립와이어로 이를 알릴 수 있습니다. 트립와이어를 트리거한 가드레일이 감지되는 즉시 `{Input,Output}GuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. +입력이나 출력이 가드레일 검사를 통과하지 못하면 가드레일은 트립와이어를 통해 이를 알릴 수 있습니다. 트립와이어를 트리거한 가드레일이 확인되는 즉시 `{Input,Output}GuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. -예외의 `guardrail_result`는 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 발생시킨 입력 트립와이어의 경우 `exception.run_data.input_guardrail_results`에는 실행이 중지되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 여기에는 트립와이어를 트리거한 결과도 포함됩니다. 스트리밍 결과는 `stream_events()`가 예외를 발생시킨 후 `input_guardrail_results`를 통해 누적된 동일한 결과를 제공합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생한 경우 `run_data`는 `None`일 수 있습니다. +예외의 `guardrail_result`는 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 입력 트립와이어를 발생시킨 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어의 경우 이에 상응하는 누적 결과가 `exception.run_data.output_guardrail_results`를 통해 제공됩니다. `stream_events()`가 예외를 발생시킨 후에는 스트리밍된 결과에서 `input_guardrail_results` 또는 `output_guardrail_results`를 통해 동일한 완료 결과를 확인할 수 있습니다. 러너가 관리하는 실행 경로 밖에서 예외가 발생하면 `run_data`는 `None`일 수 있습니다. ## 가드레일 구현 @@ -132,7 +132,7 @@ async def main(): 3. 가드레일 결과에 추가 정보를 포함할 수 있습니다. 4. 워크플로를 정의하는 실제 에이전트입니다. -출력 가드레일도 이와 유사합니다. +출력 가드레일도 유사합니다. ```python from pydantic import BaseModel @@ -185,12 +185,12 @@ async def main(): print("Math output guardrail tripped") ``` -1. 실제 에이전트의 출력 유형입니다. -2. 가드레일의 출력 유형입니다. +1. 실제 에이전트의 출력 타입입니다. +2. 가드레일의 출력 타입입니다. 3. 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. 4. 워크플로를 정의하는 실제 에이전트입니다. -마지막으로 도구 가드레일의 예제입니다. +마지막으로 다음은 도구 가드레일의 예제입니다. ```python import json diff --git a/docs/ko/mcp.md b/docs/ko/mcp.md index 81ee2c8548..e93dd32abd 100644 --- a/docs/ko/mcp.md +++ b/docs/ko/mcp.md @@ -8,31 +8,31 @@ search: 컨텍스트를 제공하는 방식을 표준화합니다. 공식 문서에서는 다음과 같이 설명합니다. > MCP는 애플리케이션이 LLM에 컨텍스트를 제공하는 방식을 표준화하는 개방형 프로토콜입니다. MCP를 AI -> 애플리케이션용 USB-C 포트라고 생각해 보세요. USB-C가 기기를 다양한 주변 장치 및 액세서리에 연결하는 표준화된 방법을 제공하듯이, MCP는 -> AI 모델을 다양한 데이터 소스와 도구에 연결하는 표준화된 방법을 제공합니다. +> 애플리케이션용 USB-C 포트라고 생각하면 됩니다. USB-C가 기기를 다양한 주변 장치 및 액세서리에 연결하는 표준화된 방식을 제공하는 것처럼, MCP는 +> AI 모델을 다양한 데이터 소스와 도구에 연결하는 표준화된 방식을 제공합니다. Agents Python SDK는 여러 MCP 전송 방식을 지원합니다. 따라서 기존 MCP 서버를 재사용하거나 자체 서버를 구축하여 파일 시스템, HTTP 또는 커넥터 기반 도구를 에이전트에 제공할 수 있습니다. !!! warning "연결 전 MCP 서버 신뢰성 확인" - MCP 도구는 모델 컨텍스트의 데이터를 노출하고 사용자가 제공한 자격 증명으로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 자격 증명을 사용하며, 액세스 토큰을 URL이 아닌 인증 필드나 헤더에 보관하고, 민감한 작업에는 승인을 요구하세요. [OpenAI MCP 보안 지침](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)을 참조하세요. + MCP 도구는 모델 컨텍스트의 데이터를 노출하고 사용자가 제공한 자격 증명으로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 자격 증명을 사용하며, 액세스 토큰은 URL이 아닌 인증 필드 또는 헤더에 보관하고, 민감한 작업에는 승인을 요구해야 합니다. [OpenAI MCP 보안 지침](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)을 참고하세요. ## MCP 통합 선택 -MCP 서버를 에이전트에 연결하기 전에 도구 호출이 실행될 위치와 접근 가능한 전송 방식을 결정하세요. 아래 표에는 Python SDK가 지원하는 옵션이 요약되어 있습니다. +MCP 서버를 에이전트에 연결하기 전에 도구 호출을 어디에서 실행할지와 접근 가능한 전송 방식을 결정해야 합니다. 아래 표에는 Python SDK가 지원하는 옵션이 요약되어 있습니다. -| 필요한 사항 | 권장 옵션 | +| 필요한 기능 | 권장 옵션 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI Responses API가 모델을 대신하여 공개적으로 접근 가능한 MCP 서버를 호출하도록 허용| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | -| 로컬 또는 원격에서 실행하는 Streamable HTTP 서버에 연결 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 통한 **Streamable HTTP MCP 서버** | -| Server-Sent Events를 사용하는 HTTP를 구현한 서버와 통신 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **SSE 기반 HTTP MCP 서버** | -| 로컬 프로세스를 실행하고 stdin/stdout을 통해 통신 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 통한 **stdio MCP 서버** | +| OpenAI의 Responses API가 모델을 대신해 공개적으로 접근 가능한 MCP 서버를 호출하도록 설정| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | +| 로컬 또는 원격에서 직접 실행하는 스트리밍 가능 HTTP 서버에 연결 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 통한 **스트리밍 가능 HTTP MCP 서버** | +| Server-Sent Events를 사용하는 HTTP를 구현한 서버와 통신 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **SSE 기반 HTTP MCP 서버** | +| 로컬 프로세스를 실행하고 stdin/stdout으로 통신 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 통한 **stdio MCP 서버** | -아래 섹션에서는 각 옵션과 구성 방법, 특정 전송 방식을 선택해야 하는 경우를 설명합니다. +아래 섹션에서는 각 옵션의 구성 방법과 특정 전송 방식을 선택해야 하는 경우를 설명합니다. ## 에이전트 수준 MCP 구성 -전송 방식을 선택하는 것 외에도 `Agent.mcp_config`를 설정하여 MCP 도구의 준비 방식을 조정할 수 있습니다. +전송 방식을 선택하는 것 외에도 `Agent.mcp_config`를 설정하여 MCP 도구가 준비되는 방식을 조정할 수 있습니다. ```python from agents import Agent @@ -54,31 +54,31 @@ agent = Agent( 참고: -- `convert_schemas_to_strict`는 최선형 방식으로 동작합니다. 스키마를 변환할 수 없으면 원래 스키마를 사용합니다. +- `convert_schemas_to_strict`는 최선의 방식으로 변환을 시도합니다. 스키마를 변환할 수 없으면 원래 스키마가 사용됩니다. - `failure_error_function`은 MCP 도구 호출 실패가 모델에 표시되는 방식을 제어합니다. - `failure_error_function`을 설정하지 않으면 SDK는 기본 도구 오류 포매터를 사용합니다. - 서버 수준의 `failure_error_function`은 해당 서버에 대한 `Agent.mcp_config["failure_error_function"]`을 재정의합니다. -- `include_server_in_tool_names`는 명시적으로 활성화해야 합니다. 활성화하면 각 로컬 MCP 도구가 결정론적인 서버 접두사 이름으로 모델에 제공되므로 여러 MCP 서버가 동일한 이름의 도구를 게시할 때 충돌을 방지하는 데 도움이 됩니다. 생성되는 이름은 ASCII에 안전하고 함수 도구 이름 길이 제한을 준수하며, 동일한 에이전트의 기존 로컬 함수 도구 및 활성화된 핸드오프 이름과 겹치지 않습니다. SDK는 원래 서버에서 원래 MCP 도구 이름을 사용해 계속 호출합니다. +- `include_server_in_tool_names`는 선택적으로 활성화해야 합니다. 활성화하면 각 로컬 MCP 도구가 결정론적인 서버 접두사 이름으로 모델에 제공되므로, 여러 MCP 서버가 동일한 이름의 도구를 게시할 때 발생하는 충돌을 방지하는 데 도움이 됩니다. 생성된 이름은 ASCII에 안전하고 함수 도구 이름의 길이 제한을 준수하며, 동일한 에이전트에 있는 기존 로컬 함수 도구 및 활성화된 핸드오프 이름과의 충돌을 방지합니다. SDK는 여전히 원래 서버에서 원래 MCP 도구 이름을 호출합니다. -## 전송 방식의 공통 패턴 +## 전송 방식 전반의 공통 패턴 -전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 사항을 결정해야 합니다. +전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 동일한 후속 결정을 내려야 합니다. -- 일부 도구만 제공하는 방법([도구 필터링](#tool-filtering)) +- 도구의 일부만 제공하는 방법([도구 필터링](#tool-filtering)) - 서버가 재사용 가능한 프롬프트도 제공하는지 여부([프롬프트](#prompts)) - `list_tools()`를 캐시할지 여부([캐싱](#caching)) - MCP 활동이 트레이스에 표시되는 방식([트레이싱](#tracing)) -로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. Streamable HTTP 섹션에서 가장 완전한 코드 예제를 제공하며, 동일한 패턴을 다른 로컬 전송 방식에도 적용할 수 있습니다. +로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. 스트리밍 가능 HTTP 섹션에서 가장 완전한 예제를 보여 주며, 동일한 패턴이 다른 로컬 전송 방식에도 적용됩니다. ## 1. 호스티드 MCP 서버 도구 -호스티드 툴은 전체 도구 왕복 과정을 OpenAI 인프라에서 처리합니다. 코드에서 도구 목록을 가져오고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API로 전달합니다. 모델은 Python 프로세스에 추가 콜백을 수행하지 않고 원격 서버의 도구 목록을 가져와 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. +호스티드 툴은 전체 도구 왕복 과정을 OpenAI 인프라로 이전합니다. 코드에서 도구를 나열하고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API에 전달합니다. 모델은 Python 프로세스에 추가 콜백을 보내지 않고 원격 서버의 도구를 나열하고 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. ### 기본 호스티드 MCP 도구 에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 생성합니다. `tool_config` -딕셔너리는 REST API로 전송할 JSON과 동일한 구조를 사용합니다. +딕셔너리는 REST API에 전송할 JSON과 동일한 구조를 사용합니다. ```python import asyncio @@ -110,13 +110,13 @@ async def main() -> None: asyncio.run(main()) ``` -호스티드 서버는 도구를 자동으로 제공하므로 `mcp_servers`에 추가하지 않아도 됩니다. +호스티드 서버는 자체 도구를 자동으로 제공하므로 `mcp_servers`에 추가하지 않습니다. -호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하도록 하려면 `tool_config["defer_loading"] = True`를 설정하고 에이전트에 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 추가합니다. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 설정과 제약 조건은 [도구](tools.md#hosted-tool-search)를 참조하세요. +호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하도록 하려면 `tool_config["defer_loading"] = True`를 설정하고 에이전트에 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 추가합니다. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 설정과 제약 조건은 [도구](tools.md#hosted-tool-search)를 참고하세요. ### 호스티드 MCP 결과 스트리밍 -호스티드 툴은 함수 도구와 정확히 동일한 방식으로 스트리밍 결과를 지원합니다. 모델이 계속 작업하는 동안 +호스티드 툴은 함수 도구와 완전히 동일한 방식으로 스트리밍 결과를 지원합니다. 모델이 계속 작업하는 동안 증분 MCP 출력을 사용하려면 `Runner.run_streamed`를 사용합니다. ```python @@ -129,7 +129,7 @@ print(result.final_output) ### 선택적 승인 흐름 -서버가 민감한 작업을 수행할 수 있는 경우 각 도구를 실행하기 전에 사람 또는 프로그램에 의한 승인을 요구할 수 있습니다. 단일 정책(`"always"`, `"never"`)이나 도구 이름을 정책에 매핑하는 딕셔너리를 사용하여 `tool_config`의 `require_approval`을 구성합니다. Python 내에서 결정을 내리려면 `on_approval_request` 콜백을 제공합니다. +서버가 민감한 작업을 수행할 수 있다면 각 도구 실행 전에 사람 또는 프로그램을 통한 승인을 요구할 수 있습니다. `tool_config`의 `require_approval`을 단일 정책(`"always"`, `"never"`) 또는 도구 이름을 정책에 매핑하는 딕셔너리로 구성합니다. Python 내에서 결정을 내리려면 `on_approval_request` 콜백을 제공합니다. ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -157,7 +157,7 @@ agent = Agent( ) ``` -콜백은 동기식 또는 비동기식일 수 있으며, 모델이 계속 실행하는 데 승인 데이터가 필요할 때마다 호출됩니다. +콜백은 동기식 또는 비동기식일 수 있으며, 모델이 실행을 계속하기 위해 승인 데이터가 필요할 때마다 호출됩니다. ### 커넥터 기반 호스티드 서버 @@ -177,11 +177,11 @@ HostedMCPTool( ) ``` -스트리밍, 승인, 커넥터를 포함해 완전하게 작동하는 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에서 확인할 수 있습니다. +스트리밍, 승인, 커넥터를 포함하여 완전히 작동하는 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에서 확인할 수 있습니다. -## 2. Streamable HTTP MCP 서버 +## 2. 스트리밍 가능 HTTP MCP 서버 -네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 사용합니다. Streamable HTTP 서버는 전송 방식을 직접 제어하거나 짧은 지연 시간을 유지하면서 자체 인프라 내에서 서버를 실행하려는 경우에 적합합니다. +네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 사용합니다. 스트리밍 가능 HTTP 서버는 전송 방식을 직접 제어하거나 낮은 지연 시간을 유지하면서 자체 인프라 내에서 서버를 실행하려는 경우에 적합합니다. ```python import asyncio @@ -218,10 +218,10 @@ asyncio.run(main()) 생성자는 다음과 같은 추가 옵션을 허용합니다. -- `client_session_timeout_seconds`는 MCP ClientSession 읽기 타임아웃을 제어합니다. `datetime.timedelta`로 표현할 수 있고 최소 1마이크로초인 양의 유한 값은 유한 타임아웃을 설정하며, `None`과 `0`은 이를 비활성화합니다. 그 밖의 값은 서버를 생성할 때 거부됩니다. +- `client_session_timeout_seconds`는 MCP ClientSession 읽기 제한 시간을 제어합니다. `datetime.timedelta`로 표현할 수 있고 1마이크로초 이상인 양의 유한 값은 유한한 제한 시간을 설정하며, `None`과 `0`은 이를 비활성화합니다. 그 밖의 값은 서버를 생성할 때 거부됩니다. - `use_structured_content`는 텍스트 출력보다 `tool_result.structured_content`를 우선할지 여부를 전환합니다. -- `max_retry_attempts`와 `retry_backoff_seconds_base`는 `list_tools()`와 `call_tool()`에 자동 재시도를 추가합니다. -- `tool_filter`를 사용하면 일부 도구만 제공할 수 있습니다([도구 필터링](#tool-filtering) 참조). +- `max_retry_attempts`와 `retry_backoff_seconds_base`는 `list_tools()` 및 `call_tool()`에 자동 재시도를 추가합니다. +- `tool_filter`를 사용하면 도구의 일부만 제공할 수 있습니다([도구 필터링](#tool-filtering) 참고). - `require_approval`은 로컬 MCP 도구에 휴먼인더루프 (HITL) 승인 정책을 활성화합니다. - `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 지정합니다. 오류를 대신 발생시키려면 `None`으로 설정합니다. - `tool_meta_resolver`는 `call_tool()` 전에 호출별 MCP `_meta` 페이로드를 삽입합니다. @@ -232,8 +232,8 @@ asyncio.run(main()) 지원되는 형식: -- 모든 도구에 대한 `"always"` 또는 `"never"` -- `True` / `False`(always/never와 동일) +- 모든 도구에 적용되는 `"always"` 또는 `"never"` +- `True` / `False`(항상/안 함과 동일) - 도구별 맵(예: `{"delete_file": "always", "read_file": "never"}`) - 그룹화된 객체: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}` @@ -246,11 +246,11 @@ async with MCPServerStreamableHttp( ... ``` -전체 일시 중지/재개 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)와 `examples/mcp/get_all_mcp_tools_example/main.py`를 참조하세요. +전체 일시 중지/재개 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md) 및 `examples/mcp/get_all_mcp_tools_example/main.py`를 참고하세요. -### `tool_meta_resolver`를 사용한 호출별 메타데이터 +### `tool_meta_resolver`를 통한 호출별 메타데이터 -MCP 서버가 `_meta`에 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 요구하는 경우 `tool_meta_resolver`를 사용합니다. 아래 예제에서는 `Runner.run(...)`에 `dict`를 `context`로 전달한다고 가정합니다. +MCP 서버가 `_meta`에 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 요구하는 경우 `tool_meta_resolver`를 사용합니다. 아래 예제에서는 `Runner.run(...)`에 `context`로 `dict`를 전달한다고 가정합니다. ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -271,19 +271,19 @@ server = MCPServerStreamableHttp( ) ``` -실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 지정 클래스인 경우 속성 접근을 사용하여 테넌트 ID를 읽습니다. +실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 지정 클래스인 경우에는 속성 접근을 사용하여 테넌트 ID를 읽습니다. -### MCP 도구 출력: 텍스트 및 이미지 +### MCP 도구 출력: 텍스트와 이미지 -MCP 도구가 이미지 콘텐츠를 반환하면 SDK는 이를 이미지 도구 출력 항목에 자동으로 매핑합니다. 텍스트와 이미지가 혼합된 응답은 출력 항목 목록으로 전달되므로 에이전트는 일반 함수 도구의 이미지 출력을 사용하는 것과 같은 방식으로 MCP 이미지 결과를 사용할 수 있습니다. +MCP 도구가 이미지 콘텐츠를 반환하면 SDK가 이를 이미지 도구 출력 항목에 자동으로 매핑합니다. 텍스트와 이미지가 혼합된 응답은 출력 항목 목록으로 전달되므로, 에이전트는 일반 함수 도구의 이미지 출력을 사용하는 것과 동일한 방식으로 MCP 이미지 결과를 사용할 수 있습니다. ## 3. SSE 기반 HTTP MCP 서버 !!! warning - MCP 프로젝트에서는 Server-Sent Events 전송 방식의 사용을 중단했습니다. 새로운 통합에는 Streamable HTTP 또는 stdio를 우선 사용하고, SSE는 레거시 서버에만 사용하세요. + MCP 프로젝트에서는 Server-Sent Events 전송 방식을 더 이상 권장하지 않습니다. 새로운 통합에는 스트리밍 가능 HTTP 또는 stdio를 사용하고, SSE는 레거시 서버에만 유지하세요. -MCP 서버가 SSE 기반 HTTP 전송 방식을 구현하는 경우 [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 인스턴스화합니다. 전송 방식을 제외하면 API는 Streamable HTTP 서버와 동일합니다. +MCP 서버가 SSE 기반 HTTP 전송 방식을 구현하는 경우 [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 인스턴스화합니다. 전송 방식을 제외하면 API는 스트리밍 가능 HTTP 서버와 동일합니다. ```python @@ -312,7 +312,7 @@ async with MCPServerSse( ## 4. stdio MCP 서버 -로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 사용합니다. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며, 컨텍스트 관리자가 종료될 때 자동으로 파이프를 닫습니다. 이 옵션은 빠른 개념 증명이나 서버가 명령줄 진입점만 제공하는 경우에 유용합니다. +로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 사용합니다. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며, 컨텍스트 관리자가 종료될 때 자동으로 닫습니다. 이 옵션은 빠른 개념 증명을 만들거나 서버가 명령줄 진입점만 제공하는 경우에 유용합니다. ```python from pathlib import Path @@ -340,7 +340,7 @@ async with MCPServerStdio( ## 5. MCP 서버 관리자 -MCP 서버가 여러 개인 경우 `MCPServerManager`를 사용하여 서버를 미리 연결하고 연결에 성공한 서버 집합을 에이전트에 제공합니다. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참조하세요. +MCP 서버가 여러 개라면 `MCPServerManager`를 사용하여 서버에 미리 연결하고 연결된 서버의 일부를 에이전트에 제공합니다. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참고하세요. ```python from agents import Agent, Runner @@ -363,11 +363,11 @@ async with MCPServerManager(servers) as manager: 주요 동작: -- `drop_failed_servers=True`(기본값)인 경우 `active_servers`에는 연결에 성공한 서버만 포함됩니다. +- `drop_failed_servers=True`(기본값)이면 `active_servers`에는 연결에 성공한 서버만 포함됩니다. - 실패는 `failed_servers`와 `errors`에서 추적됩니다. -- 첫 번째 연결 실패 시 예외를 발생시키려면 `strict=True`를 설정합니다. -- 실패한 서버를 다시 시도하려면 `reconnect(failed_only=True)`를 호출하고, 모든 서버를 다시 시작하려면 `reconnect(failed_only=False)`를 호출합니다. -- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정합니다. 수명 주기 타임아웃에는 양의 유한 초 값 또는 비활성화를 위한 `None`을 사용할 수 있으며, 생성 및 할당 시 모두 검증됩니다. 0은 즉시 기한을 생성하므로 거부됩니다. +- 첫 번째 연결 실패 시 오류를 발생시키려면 `strict=True`로 설정합니다. +- 실패한 서버를 재시도하려면 `reconnect(failed_only=True)`를 호출하고, 모든 서버를 다시 시작하려면 `reconnect(failed_only=False)`를 호출합니다. +- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정합니다. 수명 주기 제한 시간에는 양의 유한 초 단위 값 또는 이를 비활성화하는 `None`을 사용할 수 있으며, 생성 및 할당 시 모두 검증됩니다. `0`은 즉시 기한이 만료되므로 거부됩니다. ## 공통 서버 기능 @@ -375,7 +375,7 @@ async with MCPServerManager(servers) as manager: ## 도구 필터링 -각 MCP 서버는 에이전트에 필요한 함수만 제공할 수 있도록 도구 필터를 지원합니다. 필터링은 생성 시점 또는 실행별로 동적으로 수행할 수 있습니다. +각 MCP 서버는 도구 필터를 지원하므로 에이전트에 필요한 함수만 제공할 수 있습니다. 필터링은 생성 시점에 수행하거나 실행별로 동적으로 수행할 수 있습니다. ### 정적 도구 필터링 @@ -397,11 +397,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names`와 `blocked_tool_names`를 모두 제공하면 SDK는 먼저 허용 목록을 적용한 다음, 남은 집합에서 차단된 도구를 제거합니다. +`allowed_tool_names`와 `blocked_tool_names`가 모두 제공되면 SDK는 먼저 허용 목록을 적용한 다음 남은 집합에서 차단된 도구를 제거합니다. ### 동적 도구 필터링 -더 복잡한 로직을 사용하려면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]를 받는 호출 가능 객체를 전달합니다. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며, 도구를 제공해야 하는 경우 `True`를 반환합니다. +더 정교한 로직이 필요하면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]를 받는 호출 가능 객체를 전달합니다. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며, 도구를 제공해야 하는 경우 `True`를 반환합니다. ```python from pathlib import Path @@ -451,15 +451,21 @@ agent = Agent( ) ``` +## 페이지네이션 + +기본 제공 로컬 MCP 서버 클래스는 도구와 프롬프트를 나열할 때 자동으로 `nextCursor`를 따라갑니다. `list_tools()`는 필터를 적용하거나 캐시를 채우기 전에 전체 도구 목록을 반환하며, `list_prompts()`는 `nextCursor=None`인 하나의 결합된 결과를 반환합니다. 이후 페이지에서 오류가 발생하거나 서버가 커서를 반복하면 일부 결과를 제공하거나 캐시하는 대신 작업에서 오류가 발생합니다. + +리소스에는 명시적 페이지네이션이 계속 적용됩니다. 다음 페이지를 가져오려면 `list_resources()` 또는 `list_resource_templates()`의 `nextCursor`를 `cursor` 인수로 다시 전달합니다. + ## 캐싱 -에이전트를 실행할 때마다 각 MCP 서버에서 `list_tools()`를 호출합니다. 원격 서버는 눈에 띄는 지연을 유발할 수 있으므로 모든 MCP 서버 클래스는 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신하는 경우에만 이를 `True`로 설정하세요. 나중에 새 목록을 강제로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`를 호출합니다. +에이전트를 실행할 때마다 각 MCP 서버에서 `list_tools()`가 호출됩니다. 원격 서버는 눈에 띄는 지연 시간을 유발할 수 있으므로 모든 MCP 서버 클래스가 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신할 때만 이를 `True`로 설정하세요. 나중에 최신 목록을 강제로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`를 호출합니다. ## 트레이싱 -[트레이싱](./tracing.md)은 다음을 비롯한 MCP 활동을 자동으로 캡처합니다. +[트레이싱](./tracing.md)은 다음을 포함한 MCP 활동을 자동으로 캡처합니다. -1. 도구 목록을 가져오기 위한 MCP 서버 호출 +1. 도구를 나열하기 위한 MCP 서버 호출 2. 도구 호출의 MCP 관련 정보 ![MCP 트레이싱 스크린샷](../assets/images/mcp-tracing.jpg) @@ -467,5 +473,5 @@ agent = Agent( ## 추가 자료 - [Model Context Protocol](https://modelcontextprotocol.io/) – 사양 및 설계 가이드 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE 및 Streamable HTTP 샘플 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인과 커넥터를 포함한 완전한 호스티드 MCP 데모 \ No newline at end of file +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE 및 스트리밍 가능 HTTP 샘플 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인 및 커넥터를 포함한 완전한 호스티드 MCP 데모 \ No newline at end of file diff --git a/docs/ko/realtime/guide.md b/docs/ko/realtime/guide.md index dd7724063d..af9570410d 100644 --- a/docs/ko/realtime/guide.md +++ b/docs/ko/realtime/guide.md @@ -2,23 +2,23 @@ search: exclude: true --- -# Realtime agents 가이드 +# 실시간 에이전트 가이드 -이 가이드에서는 OpenAI Agents SDK의 실시간 계층이 OpenAI Realtime API에 어떻게 매핑되는지와 Python SDK가 그 위에 추가하는 동작을 설명합니다. +이 가이드에서는 OpenAI Agents SDK의 실시간 계층이 OpenAI Realtime API에 어떻게 매핑되는지와 파이썬 SDK가 추가로 제공하는 동작을 설명합니다. -!!! note "시작하기" +!!! note "여기서 시작하기" - 기본 Python 경로를 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어보세요. 애플리케이션에서 서버 측 WebSocket과 SIP 중 무엇을 사용할지 결정하려면 [실시간 전송](transport.md)을 읽어보세요. 브라우저 WebRTC 전송은 Python SDK에 포함되지 않습니다. + 기본 파이썬 경로를 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어보세요. 애플리케이션에서 서버 측 WebSocket과 SIP 중 무엇을 사용해야 할지 결정하려면 [Realtime 전송](transport.md)을 읽어보세요. 브라우저 WebRTC 전송은 파이썬 SDK에 포함되지 않습니다. ## 개요 -Realtime agents는 Realtime API와의 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하고, 도구를 호출하고, 매 턴마다 새로운 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. +실시간 에이전트는 Realtime API와 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하고, 도구를 호출하고, 매 턴마다 새 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. 주요 SDK 구성 요소는 다음과 같습니다. -- **RealtimeAgent**: 한 실시간 전문 에이전트를 위한 지침, 도구, 출력 가드레일 및 핸드오프 +- **RealtimeAgent**: 하나의 실시간 전문 에이전트를 위한 instructions, 도구, 출력 가드레일, 핸드오프 - **RealtimeRunner**: 시작 에이전트를 실시간 전송에 연결하는 세션 팩토리 -- **RealtimeSession**: 입력을 전송하고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 라이브 세션 +- **RealtimeSession**: 입력을 전송하고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 활성 세션 - **RealtimeModel**: 전송 추상화입니다. 기본값은 OpenAI의 서버 측 WebSocket 구현입니다. ## 세션 수명 주기 @@ -28,24 +28,24 @@ Realtime agents는 Realtime API와의 장기 연결을 유지하므로 모델이 1. 하나 이상의 `RealtimeAgent`를 생성합니다. 2. 시작 에이전트로 `RealtimeRunner`를 생성합니다. 3. `await runner.run()`을 호출하여 `RealtimeSession`을 가져옵니다. -4. `async with session:` 또는 `await session.enter()`을 사용하여 세션에 진입합니다. -5. `send_message()` 또는 `send_audio()`를 사용하여 사용자 입력을 전송합니다. -6. 대화가 종료될 때까지 세션 이벤트를 순회합니다. +4. `async with session:` 또는 `await session.enter()`을 사용해 세션에 진입합니다. +5. `send_message()` 또는 `send_audio()`로 사용자 입력을 전송합니다. +6. 대화가 끝날 때까지 세션 이벤트를 순회합니다. -텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태 및 활성 에이전트 구성을 전송 계층과 동기화된 상태로 유지하는 라이브 세션 객체를 반환합니다. +텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태, 활성 에이전트 구성을 전송 계층과 동기화하는 활성 세션 객체를 반환합니다. -기본적으로 `RealtimeRunner`는 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 Python 경로는 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달하더라도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 방식만 달라질 수 있습니다. +기본적으로 `RealtimeRunner`는 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 파이썬 경로는 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달해도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 방식만 달라질 수 있습니다. ## 에이전트 및 세션 구성 -`RealtimeAgent`는 의도적으로 일반 `Agent` 유형보다 범위가 제한되어 있습니다. +`RealtimeAgent`는 의도적으로 일반 `Agent` 타입보다 지원 범위가 좁습니다. - 모델 선택은 에이전트별이 아니라 세션 수준에서 구성합니다. - structured outputs은 지원되지 않습니다. -- 음성은 구성할 수 있지만 세션에서 음성 오디오를 이미 생성한 후에는 변경할 수 없습니다. -- 지침, 함수 도구, 핸드오프, 훅 및 출력 가드레일은 모두 계속 작동합니다. +- 음성을 구성할 수 있지만 세션에서 음성 오디오를 이미 생성한 후에는 변경할 수 없습니다. +- Instructions, 함수 도구, 핸드오프, 훅, 출력 가드레일은 모두 계속 작동합니다. -`RealtimeSessionModelSettings`는 새로운 중첩 `audio` 구성과 이전의 평면 별칭을 모두 지원합니다. 새 코드에서는 중첩 구조를 사용하는 것이 좋으며, 새로운 Realtime agents에는 `gpt-realtime-2.1`부터 사용하세요. +`RealtimeSessionModelSettings`는 새로운 중첩 `audio` 구성과 이전의 평면 별칭을 모두 지원합니다. 새 코드에는 중첩 구조를 사용하는 것이 좋으며, 새 실시간 에이전트에는 `gpt-realtime-2.1`부터 시작하세요. ```python runner = RealtimeRunner( @@ -87,13 +87,13 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -전체 타입 지정 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]를 참조하세요. +전체 타입 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]와 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]를 참조하세요. ## 입력 및 출력 ### 텍스트 및 구조화된 사용자 메시지 -일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]를 사용하세요. +일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]를 사용합니다. ```python from agents.realtime import RealtimeUserInputMessage @@ -111,29 +111,29 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -구조화된 메시지는 실시간 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 코드 예제에서는 이 방식으로 `input_image` 메시지를 전달합니다. +구조화된 메시지는 실시간 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 이 방식으로 `input_image` 메시지를 전달합니다. ### 오디오 입력 -원문 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]를 사용하세요. +원문 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]를 사용합니다. ```python await session.send_audio(audio_bytes) ``` -서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 다음과 같은 상위 수준 편의 기능을 사용할 수 있습니다. +서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 상위 수준 편의 기능은 다음과 같습니다. ```python await session.send_audio(audio_bytes, commit=True) ``` -더 낮은 수준의 제어가 필요한 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 원문 클라이언트 이벤트를 전송할 수도 있습니다. +더 세밀하게 제어해야 하는 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 원문 클라이언트 이벤트를 전송할 수도 있습니다. ### 수동 응답 제어 -`session.send_message()`는 상위 수준 경로를 사용하여 사용자 입력을 전송하고 응답을 자동으로 시작합니다. 원문 오디오 버퍼링은 모든 구성에서 동일한 작업을 **자동으로 수행하지는 않습니다**. +`session.send_message()`는 상위 수준 경로를 사용하여 사용자 입력을 전송하고 응답을 시작합니다. 원문 오디오 버퍼링은 모든 구성에서 동일한 작업을 **자동으로** 수행하지는 않습니다. -Realtime API 수준에서 수동 턴 제어를 사용하려면 원문 `session.update`로 `turn_detection`을 지운 다음, `input_audio_buffer.commit`과 `response.create`를 직접 전송해야 합니다. +Realtime API 수준에서 수동 턴 제어를 수행하려면 원문 `session.update`로 `turn_detection`을 지운 다음 `input_audio_buffer.commit`과 `response.create`를 직접 전송해야 합니다. 턴을 수동으로 관리하는 경우 모델 전송을 통해 원문 클라이언트 이벤트를 전송할 수 있습니다. @@ -155,13 +155,13 @@ await session.model.send_event( - 응답을 트리거하기 전에 사용자 입력을 검사하거나 제어하려는 경우 - 대역 외 응답에 사용자 지정 프롬프트가 필요한 경우 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 코드 예제에서는 원문 `response.create`를 사용하여 첫 인사말을 강제로 생성합니다. +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제에서는 원문 `response.create`를 사용하여 첫 인사말을 강제로 생성합니다. ## 이벤트, 기록 및 인터럽션(중단 처리) -`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내면서, 필요할 때 사용할 수 있도록 원문 모델 이벤트도 계속 전달합니다. +`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내는 동시에, 필요한 경우 원문 모델 이벤트도 계속 전달합니다. -주요 세션 이벤트는 다음과 같습니다. +중요한 세션 이벤트는 다음과 같습니다. - `audio`, `audio_end`, `audio_interrupted` - `agent_start`, `agent_end` @@ -173,13 +173,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`와 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지 및 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. +UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`와 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지, 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. ### 사용량 집계 -완료된 모델 응답에 사용량이 포함된 경우 OpenAI 실시간 모델은 `raw_model_event` 내에서 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]를 내보냅니다. `usage` 필드에는 해당 응답의 토큰 수가 포함되며, `input_tokens_details`와 `output_tokens_details`는 선택적인 모달리티별 내역을 제공합니다. +완료된 모델 응답에 사용량이 포함되면 OpenAI 실시간 모델은 `raw_model_event` 내부에 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]를 내보냅니다. 해당 `usage` 필드에는 그 응답의 토큰 수가 포함되며, `input_tokens_details`와 `output_tokens_details`는 선택적인 모달리티별 세부 내역을 제공합니다. -세션은 각 응답의 사용량도 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. 라이브 세션의 누적 사용량을 확인하려면 이후의 `agent_end` 같은 상위 수준 이벤트에서 `event.info.context.usage`를 읽으세요. +세션은 각 응답의 사용량도 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. 활성 세션의 누적 사용량을 확인하려면 이후에 발생하는 `agent_end`와 같은 상위 수준 이벤트에서 `event.info.context.usage`를 읽으세요. ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,21 +197,21 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -사용량은 모델 제공자가 완료된 응답에 사용량을 포함한 경우에만 보고됩니다. 누적 값에는 해당 `RealtimeSession`이 수신한 응답이 포함되며, 세션 간 합계가 아닙니다. +사용량은 모델 제공자가 완료된 응답에 이를 포함한 경우에만 보고됩니다. 누적 값에는 해당 `RealtimeSession`이 수신한 응답이 포함되며, 세션 간 합계는 아닙니다. ### 인터럽션(중단 처리) 및 재생 추적 -사용자가 어시스턴트의 응답을 중단하면 세션은 `audio_interrupted`를 내보내고 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 기록을 업데이트합니다. +사용자가 어시스턴트의 응답을 중단하면 세션은 `audio_interrupted`를 내보내고 기록을 업데이트하여 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 유지합니다. -지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기로 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 모든 오디오가 이미 재생되었다고 가정하는 대신 실제 재생 진행률을 기준으로 인터럽션(중단 처리) 시점의 잘라내기를 수행하도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]를 사용하세요. +지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기만으로도 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 오디오가 모두 이미 재생되었다고 가정하는 대신 실제 재생 진행률을 기준으로 인터럽션(중단 처리) 시점의 잘라내기를 수행하도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]를 사용하세요. -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)의 Twilio 코드 예제에서 이 패턴을 확인할 수 있습니다. +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)의 Twilio 예제에서 이 패턴을 확인할 수 있습니다. ## 도구, 승인, 핸드오프 및 가드레일 ### 함수 도구 -Realtime agents는 라이브 대화 중 함수 도구를 지원합니다. +실시간 에이전트는 실시간 대화 중 함수 도구를 지원합니다. ```python from agents.decorators import tool @@ -234,7 +234,7 @@ agent = RealtimeAgent( 함수 도구는 실행 전에 사람의 승인을 요구할 수 있습니다. 이 경우 세션은 `tool_approval_required`를 내보내고 `approve_tool_call()` 또는 `reject_tool_call()`을 호출할 때까지 도구 실행을 일시 중지합니다. -도구에 입력 가드레일도 있는 경우 해당 가드레일은 승인 후 실행 직전에 작동합니다. 승인 이벤트가 발생하기 전에 가드레일을 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 실행 전에 다시 검사됩니다. +도구에 입력 가드레일도 있는 경우 승인 후 실행 직전에 해당 가드레일이 실행됩니다. 승인 이벤트가 발생하기 전에 입력 가드레일을 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 실행 전에 다시 검사됩니다. ```python async for event in session: @@ -242,11 +242,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)를 참조하세요. 휴먼인더루프 (HITL) 문서의 [휴먼인더루프 (HITL)](../human_in_the_loop.md)에서도 이 흐름을 안내합니다. +구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)를 참조하세요. 휴먼인더루프 문서에서도 [휴먼인더루프 (HITL)](../human_in_the_loop.md)의 이 흐름을 다시 안내합니다. ### 핸드오프 -실시간 핸드오프를 사용하면 한 에이전트가 라이브 대화를 다른 전문 에이전트에게 전달할 수 있습니다. +실시간 핸드오프를 사용하면 한 에이전트가 활성 대화를 다른 전문 에이전트에게 전달할 수 있습니다. ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +268,11 @@ main_agent = RealtimeAgent( ) ``` -별도 설정이 없는 `RealtimeAgent` 핸드오프는 자동으로 래핑되며, `realtime_handoff(...)`를 사용하면 이름, 설명, 유효성 검사, 콜백 및 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프의 `input_filter`를 지원하지 **않습니다**. +별도 래핑되지 않은 `RealtimeAgent` 핸드오프는 자동으로 래핑되며, `realtime_handoff(...)`를 사용하면 이름, 설명, 검증, 콜백, 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프의 `input_filter`를 지원하지 **않습니다**. ### 가드레일 -Realtime agents는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일은 모든 부분 토큰이 아니라 디바운스된 트랜스크립트 누적 내용에 대해 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`를 내보냅니다. +실시간 에이전트는 에이전트 응답의 출력 가드레일과 함수 도구 호출의 입력 가드레일을 지원합니다. 출력 가드레일은 모든 부분 델타마다 실행되는 대신 출력 텍스트 및 오디오 트랜스크립트 델타가 디바운스 방식으로 누적될 때 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`를 내보냅니다. ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,13 +292,15 @@ agent = RealtimeAgent( ) ``` -실시간 출력 가드레일이 작동하면 세션은 활성 응답을 중단하고, `response.cancel`을 강제로 실행하고, `guardrail_tripped`를 내보내며, 모델이 대체 응답을 생성할 수 있도록 작동한 가드레일의 이름이 포함된 후속 사용자 메시지를 전송합니다. 가드레일은 디바운스된 트랜스크립트 텍스트에 대해 실행되고 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되어 있을 수 있으므로, 오디오 플레이어는 계속 `audio_interrupted`를 수신하고 로컬 재생을 즉시 중지해야 합니다. +실시간 출력 가드레일이 오디오 트랜스크립트에서 작동하면 세션은 활성 응답을 중단하고, `response.cancel`을 강제로 실행하고, `guardrail_tripped`를 내보내고, 트리거된 가드레일의 이름을 포함한 후속 사용자 메시지를 전송하여 모델이 대체 응답을 생성할 수 있도록 합니다. 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되어 있을 수 있으므로 오디오 플레이어는 계속 `audio_interrupted`를 수신하고 로컬 재생을 즉시 중지해야 합니다. 내장 OpenAI Realtime 전송을 사용할 때 가드레일이 원본 응답이 종료된 후 완료되면 세션은 해당 응답의 버퍼링된 재생만 중단하고 더 새로운 응답은 취소하지 않습니다. 텍스트 전용 출력의 경우 세션은 대신 응답 범위의 `response.cancel`을 전송합니다. 중지할 오디오 재생이 없으므로 `audio_interrupted`는 내보내지 않습니다. 내장 OpenAI Realtime 모델을 사용할 때 텍스트 전용 경로에서도 동일한 `guardrail_tripped` 이벤트와 후속 사용자 메시지가 내보내집니다. + +사용자 지정 `RealtimeModel` 전송은 동일한 원본 응답 범위의 오디오 인터럽션(중단 처리) 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`와 `playback_only`를 준수해야 합니다. 또한 텍스트 전용 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`를 재정의해야 합니다. 구현에서는 전송의 실제 이벤트 커밋 경계에서 제공된 조건을 다시 검사하거나 직렬화해야 합니다. `send_event()`를 기다리기 전에 조건을 검사하면 메시지가 커밋되기 전에 더 새로운 응답이 시작될 수 있으므로 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. ## SIP 및 전화 통신 -Python SDK는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통한 일급 SIP 연결 흐름을 제공합니다. +파이썬 SDK는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통한 일급 SIP 연결 흐름을 제공합니다. -Realtime Calls API를 통해 전화가 수신되고 생성된 `call_id`에 에이전트 세션을 연결하려는 경우 사용하세요. +Realtime Calls API를 통해 전화가 수신되고 결과 `call_id`에 에이전트 세션을 연결하려는 경우 사용합니다. ```python from agents.realtime import RealtimeRunner @@ -317,16 +319,16 @@ async with await runner.run( 먼저 전화를 수락해야 하며 수락 페이로드가 에이전트에서 파생된 세션 구성과 일치하도록 하려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`를 사용하세요. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. -## 저수준 접근 및 사용자 지정 엔드포인트 +## 저수준 액세스 및 사용자 지정 엔드포인트 -`session.model`을 통해 기본 전송 객체에 접근할 수 있습니다. +`session.model`을 통해 기본 전송 객체에 액세스할 수 있습니다. -다음과 같은 경우에 사용하세요. +다음과 같은 경우 사용합니다. - `session.model.add_listener(...)`를 통한 사용자 지정 리스너 -- `response.create` 또는 `session.update` 같은 원문 클라이언트 이벤트 +- `response.create` 또는 `session.update`와 같은 원문 클라이언트 이벤트 - `model_config`를 통한 사용자 지정 `url`, `headers` 또는 `api_key` 처리 -- 기존 실시간 호출에 대한 `call_id` 연결 +- 기존 실시간 호출에 `call_id` 연결 `RealtimeModelConfig`는 다음을 지원합니다. @@ -337,9 +339,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -이 저장소에서 제공하는 `call_id` 코드 예제는 SIP를 사용합니다. 더 광범위한 Realtime API에서도 일부 서버 측 제어 흐름에 `call_id`를 사용하지만, 여기서는 해당 흐름을 Python 코드 예제로 제공하지 않습니다. +이 리포지토리에 포함된 `call_id` 예제는 SIP입니다. 더 광범위한 Realtime API에서도 일부 서버 측 제어 흐름에 `call_id`를 사용하지만, 여기에는 파이썬 예제로 패키징되어 있지 않습니다. -Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적인 헤더를 전달하세요. 예를 들면 다음과 같습니다. +Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적 헤더를 전달하세요. 예시는 다음과 같습니다. ```python session = await runner.run( @@ -350,7 +352,7 @@ session = await runner.run( ) ``` -토큰 기반 인증에는 `headers`에서 전달자 토큰을 사용하세요. +토큰 기반 인증에는 `headers`의 bearer 토큰을 사용합니다. ```python session = await runner.run( @@ -361,11 +363,11 @@ session = await runner.run( ) ``` -`headers`를 전달하면 SDK가 `Authorization`을 자동으로 추가하지 않습니다. Realtime agents에서 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. +`headers`를 전달하면 SDK는 `Authorization`을 자동으로 추가하지 않습니다. 실시간 에이전트에서는 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. ## 추가 자료 -- [실시간 전송](transport.md) +- [Realtime 전송](transport.md) - [빠른 시작](quickstart.md) - [OpenAI Realtime 대화](https://developers.openai.com/api/docs/guides/realtime-conversations/) - [OpenAI Realtime 서버 측 제어](https://developers.openai.com/api/docs/guides/realtime-server-controls/) diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index 81ecbe8efc..4a9cc6ff7d 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -33,36 +33,36 @@ async def main(): - 문자열(사용자 메시지로 처리) - OpenAI Responses API 형식의 입력 항목 목록 -- 인터럽션(중단 처리)된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState] +- 인터럽션된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState] -그런 다음 러너는 루프를 실행합니다. +그런 다음 Runner는 다음 루프를 실행합니다. -1. 현재 입력을 사용하여 현재 에이전트의 LLM을 호출합니다. +1. 현재 에이전트에 대해 현재 입력으로 LLM을 호출합니다. 2. LLM이 출력을 생성합니다. - 1. LLM이 `final_output`을 반환하면 루프를 종료하고 결과를 반환합니다. - 2. LLM이 핸드오프를 수행하면 현재 에이전트와 입력을 업데이트한 후 루프를 다시 실행합니다. + 1. LLM이 `final_output`을 반환하면 루프가 종료되고 결과를 반환합니다. + 2. LLM이 핸드오프를 수행하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. -3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외를 발생시킵니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. +3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 타입의 텍스트 출력을 생성하고 도구 호출이 없는 경우입니다. + LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. ### 스트리밍 -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 포함한 전체 실행 정보가 담깁니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 받을 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 생성된 모든 새 출력을 포함한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송(선택적 도우미) +#### Responses WebSocket 전송(선택적 헬퍼) -OpenAI Responses websocket 전송을 활성화해도 일반적인 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 websocket 세션 도우미를 사용하는 것이 권장되지만 필수는 아닙니다. +OpenAI Responses WebSocket 전송을 활성화해도 일반적인 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 WebSocket 세션 헬퍼 사용을 권장하지만 필수는 아닙니다. -이는 websocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. +이는 WebSocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. -전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 공급자 관련 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. +전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 공급자에 관한 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 도우미 미사용(작동함) +##### 패턴 1: 세션 헬퍼 없음(사용 가능) -websocket 전송만 필요하고 SDK가 공유 공급자나 세션을 관리할 필요가 없을 때 사용하세요. +WebSocket 전송만 필요하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`를 반복적으로 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. +이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 직접 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. ##### 패턴 2: `responses_websocket_session()` 사용(여러 턴에서 재사용 시 권장) -여러 실행에서 websocket을 지원하는 공유 공급자와 `RunConfig`를 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 에이전트 도구 호출도 포함됩니다. +여러 실행에서 WebSocket을 지원하는 공급자와 `RunConfig`를 공유하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 도구로서의 에이전트 호출도 포함됩니다. ```python import asyncio @@ -119,11 +119,11 @@ async def main(): asyncio.run(main()) ``` -컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 소비하세요. websocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. +컨텍스트가 종료되기 전에 스트리밍 결과 사용을 완료하세요. WebSocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. -서비스는 각 websocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간은 60분으로 제한됩니다. 도우미는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`를 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용하여 체인을 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. +서비스는 각 WebSocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 없애지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`를 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용해 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. -긴 추론 턴에서 websocket 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. +긴 추론 턴에서 WebSocket 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. WebSocket 지연 시간보다 안정성이 중요한 실행에는 HTTP/SSE 전송을 사용하세요. ### 실행 구성 @@ -135,42 +135,43 @@ asyncio.run(main()) ##### 모델, 공급자 및 세션 기본값 -- [`model`][agents.run.RunConfig.model]: 각 Agent의 `model` 설정과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. +- [`model`][agents.run.RunConfig.model]: 각 에이전트에 설정된 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. - [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며 기본값은 OpenAI입니다. - [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`를 설정할 수 있습니다. -- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 검색할 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. +- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. - [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 턴 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기 방식일 수 있습니다. ##### 가드레일, 핸드오프 및 모델 입력 구성 - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 필터가 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 무손실 메시지 항목을 원래 위치에 보존하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 선택적 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화되어 있습니다. 활성화하려면 `True`로 설정하고, 원문 트랜스크립트를 그대로 전달하려면 `False`로 두세요. Sessions, `RunState`, `RunResult.to_input_list()`는 SDK 기본 중첩 기록에 이미 포함된 정확히 동일한 메시지 인스턴스를 두 번 추가하지 않으면서 별개의 동일한 메시지는 보존합니다. 모든 [Runner 메서드][agents.run.Runner]는 `RunConfig`를 전달하지 않으면 자동으로 생성하므로 빠른 시작과 예제에서는 기본적으로 비활성화 상태가 유지되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 활성화할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 수신하는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순서형 요약 세그먼트를 대체할 수 있도록 다음 에이전트에 전달할 입력 항목의 정확한 목록을 반환해야 합니다. -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: 러너가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 입력 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 손실 없이 보존되는 메시지 항목을 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화됩니다. 활성화하려면 `True`로 설정하고, 원문 트랜스크립트를 그대로 전달하려면 `False`로 두세요. Sessions, `RunState`, `RunResult.to_input_list()`는 SDK 기본 중첩 기록이 이미 소유한 정확히 동일한 메시지 인스턴스를 두 번 추가하지 않으면서도 별도의 동일 메시지는 유지합니다. [Runner 메서드][agents.run.Runner]는 명시적으로 전달하지 않으면 모두 자동으로 `RunConfig`를 생성하므로 빠른 시작과 코드 예제에서는 이 기능이 기본적으로 비활성화되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정보다 우선합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 활성화할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순차 요약 세그먼트를 대체하여 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 잘라내거나 시스템 프롬프트를 삽입할 수 있습니다. +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 유지할지 생략할지 제어합니다. ##### 트레이싱 및 관측 가능성 -- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에서 [트레이싱](tracing.md)을 비활성화할 수 있습니다. +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에 대해 [트레이싱](tracing.md)을 비활성화할 수 있습니다. - [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]를 전달합니다. -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출의 입력/출력과 같이 잠재적으로 민감한 데이터를 포함할지 구성합니다. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출의 입력/출력과 같은 잠재적으로 민감한 데이터를 포함할지 구성합니다. - [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`은 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. ##### 도구 실행, 승인 및 도구 오류 동작 -- [`tool_execution`][agents.run.RunConfig.tool_execution]: 동시에 실행할 함수 도구 수 제한과 같은 로컬 도구 호출의 SDK 측 실행 동작을 구성합니다. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 확인 불가능한 함수 도구 호출을 러너가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`를 발생시키며, 대신 모델에 표시되는 오류 출력을 반환하도록 선택할 수 있습니다. -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 선택적으로 활성화된 도구 미발견 출력과 같이 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행되는 함수 도구 수 제한과 같이 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 해결되지 않은 함수 도구 호출을 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`가 발생하며, 대신 모델에 표시되는 오류 출력을 반환하도록 옵트인할 수 있습니다. +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구 이름과 핸드오프 이름이 충돌할 때 Runner가 처리하는 방식을 구성합니다. 기본값인 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치에서 선택된 항목만 노출합니다. `"error"`는 모델 호출 전에 `UserError`를 발생시킵니다. 네임스페이스가 지정된 도구와 지연 로딩 도구에 대한 엄격한 검증은 변경되지 않습니다. +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인된 도구를 찾을 수 없음 출력과 같이 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. -중첩된 핸드오프는 선택적 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`를 전달하여 순서형 트랜스크립트 압축을 활성화하거나 `handoff(..., nest_handoff_history=True)`로 설정하여 특정 핸드오프에서 활성화하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하는 대신 생성된 어시스턴트 요약 세그먼트를 무손실 메시지 항목 주변에 배치합니다. 원문 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 대화를 필요한 형태 그대로 전달하는 `handoff_input_filter` 또는 `handoff_history_mapper`를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트의 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]를 호출합니다. +중첩된 핸드오프는 옵트인 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`를 전달하여 순서가 지정된 트랜스크립트 압축을 활성화하거나 `handoff(..., nest_handoff_history=True)`를 설정하여 특정 핸드오프에서 활성화하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하는 대신, 손실 없이 보존되는 메시지 항목 전후에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 원문 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 필요한 방식 그대로 대화를 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]를 호출하세요. #### 실행 구성 세부 정보 ##### `tool_execution` -실행 중 로컬 함수 도구의 동시 실행 수 제한과 같은 로컬 함수 도구의 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. +로컬 함수 도구의 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. 예를 들어 실행 중 로컬 함수 도구의 동시 실행 수를 제한할 수 있습니다. ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -189,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 수를 제한하려면 정숫값을 설정하세요. +`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에서 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 수를 제한하려면 정숫값을 설정하세요. -이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. +이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 로컬 함수 도구 호출을 생성한 후 SDK가 이를 실행하는 방식을 제어합니다. -`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요한 경우 실행이 먼저 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 작동합니다. 보류 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 승인 전 검사를 통과한 호출에도 승인 후 동일한 입력 가드레일이 다시 적용되므로, 시간에 민감한 검사는 실행 전에 다시 검증됩니다. +`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 실행이 먼저 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 작동합니다. 대기 중인 승인 인터럽션(중단 처리)이 생성되기 전에 함수 도구 입력 가드레일을 실행하려면 이를 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 동일한 입력 가드레일을 다시 실행하므로, 시간에 민감한 검사가 실행 전에 다시 검증됩니다. ##### `tool_not_found_behavior` -기본적으로 모델이 현재 에이전트에서 사용할 수 있는 함수 도구와 일치하지 않는 함수 도구 호출을 생성하면 러너는 `ModelBehaviorError`를 발생시킵니다. +기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 Runner에서 `ModelBehaviorError`가 발생합니다. -실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`로 설정하세요. 이 모드에서 SDK는 확인 불가능한 도구 호출에 대한 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. +실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`로 설정하세요. 이 모드에서는 SDK가 해결되지 않은 도구 호출에 대한 `function_call_output`을 추가하고 모델을 다시 실행하므로 모델이 사용 가능한 도구를 선택하거나 해당 도구 없이 응답할 수 있습니다. ```python from agents import Agent, RunConfig, Runner @@ -213,19 +214,19 @@ result = await Runner.run( ) ``` -현재 이 옵션은 확인 불가능한 함수 도구 호출에만 적용됩니다. 그 외의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 해결되지 않은 함수 도구 호출에만 적용됩니다. 그 밖의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. ##### `tool_error_formatter` -SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`를 사용하세요. +SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델로 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`를 사용하세요. -포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 수신합니다. +포매터는 다음 항목을 포함하는 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. -- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`와 같은 오류 카테고리 +- `kind`: `"approval_rejected"` 또는 `"tool_not_found"` 같은 오류 카테고리 - `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`) - `tool_name`: 도구 이름 - `call_id`: 도구 호출 ID -- `default_message`: 모델에 표시되는 SDK 기본 메시지 +- `default_message`: 모델에 표시되는 SDK의 기본 메시지 - `run_context`: 활성 실행 컨텍스트 래퍼 메시지를 대체하려면 문자열을 반환하고, SDK 기본값을 사용하려면 `None`을 반환하세요. @@ -255,22 +256,22 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`는 러너가 기록을 다음 턴으로 전달할 때 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. 예를 들어 `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때 적용됩니다. +`reasoning_item_id_policy`는 Runner가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행 사용 시) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. - `None` 또는 `"preserve"`(기본값): 추론 항목 ID 유지 - `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID 제거 -`"omit"`은 주로 추론 항목이 `id`와 함께 전송되지만 필수 후속 항목 없이 전송되어 발생하는 Responses API 400 오류 유형을 완화하기 위한 선택적 설정입니다. 예를 들면 `Item 'rs_...' of type 'reasoning' was provided without its required following item.` 오류가 있습니다. +`"omit"`은 주로 추론 항목이 `id`와 함께 전송되지만 필수 후속 항목은 없는 경우 발생하는 Responses API 400 오류 유형을 완화하기 위한 옵트인 방식으로 사용합니다. 예를 들면 `Item 'rs_...' of type 'reasoning' was provided without its required following item.` 오류가 있습니다. -이는 SDK가 이전 출력에서 후속 입력을 구성하는 여러 턴의 에이전트 실행에서 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 추론 항목 ID는 보존되지만 공급자가 해당 ID를 관련 후속 항목과 쌍으로 유지하도록 요구할 때 발생합니다. +이 오류는 SDK가 이전 출력에서 후속 입력을 구성하는 여러 턴의 에이전트 실행에서 발생할 수 있습니다. 여기에는 세션 영속성, 서버 관리형 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID는 유지되지만 공급자가 해당 ID를 대응하는 후속 항목과 계속 쌍으로 유지하도록 요구할 수 있습니다. -`reasoning_item_id_policy="omit"`으로 설정하면 추론 콘텐츠는 유지하지만 추론 항목의 `id`는 제거하므로 SDK가 생성한 후속 입력에서 해당 API 불변 조건이 위반되는 것을 방지할 수 있습니다. +`reasoning_item_id_policy="omit"`으로 설정하면 추론 콘텐츠는 유지하면서 추론 항목의 `id`를 제거하므로 SDK가 생성한 후속 입력에서 해당 API 불변 조건이 위반되는 것을 방지할 수 있습니다. 적용 범위 참고 사항: - SDK가 후속 입력을 구성할 때 생성하거나 전달하는 추론 항목만 변경합니다. - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. -- 이 정책이 적용된 후에도 `call_model_input_filter`가 의도적으로 추론 ID를 다시 추가할 수 있습니다. +- 이 정책이 적용된 후에도 `call_model_input_filter`에서 의도적으로 추론 ID를 다시 도입할 수 있습니다. ## 상태 및 대화 관리 @@ -278,27 +279,27 @@ result = Runner.run_sync( 다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달할 항목 | +| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | | --- | --- | --- | --- | -| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | -| `session` | 자체 스토리지 및 SDK | 지속형 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 같은 저장소를 가리키는 다른 인스턴스 | -| `conversation_id` | OpenAI Conversations API | 작업자 또는 서비스 간에 공유할 명명된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 전달 | -| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리 연속 처리 | `result.last_response_id`와 새 사용자 턴만 전달 | +| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`에서 반환된 목록과 다음 사용자 메시지 | +| `session` | 자체 스토리지 및 SDK | 영구적인 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 동일한 저장소를 가리키는 다른 인스턴스 | +| `conversation_id` | OpenAI Conversations API | 작업자 또는 서비스 간에 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 전달 | +| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 실행 | `result.last_response_id`와 새 사용자 턴만 전달 | -`result.to_input_list()`와 `session`은 클라이언트에서 관리합니다. `conversation_id`와 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 클라이언트 관리 기록과 OpenAI 관리 상태를 혼합하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. +`result.to_input_list()`와 `session`은 클라이언트에서 관리합니다. `conversation_id`와 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 영속성 전략을 선택하세요. 클라이언트 관리형 기록과 OpenAI 관리형 상태를 혼합하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. !!! note - 세션 지속성은 동일한 실행에서 서버 관리 대화 설정 - (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 - 함께 사용할 수 없습니다. 호출마다 하나의 접근 방식을 선택하세요. + 세션 영속성은 동일한 실행에서 서버 관리형 대화 설정 + (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 함께 사용할 수 + 없습니다. 호출마다 하나의 접근 방식을 선택하세요. -### 대화 및 채팅 스레드 +### 대화/채팅 스레드 -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행되고 이에 따라 하나 이상의 LLM 호출이 발생할 수 있지만, 이는 채팅 대화에서 논리적으로 하나의 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있고, 이에 따라 하나 이상의 LLM 호출이 발생할 수 있지만 채팅 대화에서는 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. -1. 사용자 턴: 사용자가 텍스트를 입력합니다. -2. 러너 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트가 추가 도구를 실행하고 출력을 생성합니다. +1. 사용자 턴: 사용자가 텍스트 입력 +2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프하며, 두 번째 에이전트가 추가 도구를 실행하고 출력을 생성 에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 경우든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. @@ -326,9 +327,9 @@ async def main(): # California ``` -#### 세션을 사용한 자동 대화 관리 +#### 세션을 통한 자동 대화 관리 -더 간단한 접근 방식으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 수동으로 호출하지 않고 대화 기록을 자동으로 처리할 수 있습니다. +더 간단한 방식으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 직접 호출하지 않고도 대화 기록을 자동으로 처리할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession, trace @@ -354,22 +355,22 @@ async def main(): Sessions는 다음 작업을 자동으로 수행합니다. -- 각 실행 전에 대화 기록 검색 -- 각 실행 후 새 메시지 저장 -- 서로 다른 세션 ID의 대화를 별도로 유지 +- 각 실행 전에 대화 기록을 가져옵니다 +- 각 실행 후에 새 메시지를 저장합니다 +- 서로 다른 세션 ID별로 별도의 대화를 유지합니다 자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. -#### 서버 관리 대화 +#### 서버 관리형 대화 -`to_input_list()` 또는 `Sessions`를 사용하여 로컬에서 처리하는 대신 OpenAI 대화 상태 기능을 통해 서버 측에서 대화 상태를 관리할 수도 있습니다. 이를 사용하면 이전의 모든 메시지를 수동으로 다시 전송하지 않고 대화 기록을 보존할 수 있습니다. 아래의 서버 관리 접근 방식 중 하나를 사용하는 경우 각 요청에는 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. +`to_input_list()` 또는 `Sessions`를 사용해 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 직접 다시 전송하지 않고도 대화 기록을 유지할 수 있습니다. 아래의 서버 관리형 방식 중 어느 것을 사용하든 각 요청에는 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. OpenAI는 여러 턴에 걸쳐 상태를 추적하는 두 가지 방법을 제공합니다. ##### 1. `conversation_id` 사용 -먼저 OpenAI Conversations API를 사용하여 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. +먼저 OpenAI Conversations API로 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. ```python from agents import Agent, Runner @@ -417,30 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하면 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리 대화에서 계속됩니다. +실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개되는 경우 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리형 대화에서 계속됩니다. -`conversation_id`와 `previous_response_id`는 상호 배타적입니다. 시스템 간에 공유할 수 있는 명명된 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 턴 사이를 연결하는 가장 가벼운 Responses API 기본 구성 요소가 필요하면 `previous_response_id`를 사용하세요. +`conversation_id`와 `previous_response_id`는 함께 사용할 수 없습니다. 시스템 간에 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`를 사용하세요. !!! note - SDK는 `conversation_locked` 오류를 백오프와 함께 자동으로 재시도합니다. 서버 관리 - 대화 실행에서는 재시도 전에 내부 대화 추적기의 입력을 되돌려 - 준비된 동일 항목을 문제없이 다시 전송할 수 있도록 합니다. + SDK는 `conversation_locked` 오류를 백오프와 함께 자동으로 재시도합니다. 서버 관리형 + 대화 실행에서는 재시도 전에 내부 대화 추적기의 입력을 되돌려 동일하게 준비된 + 항목을 문제없이 다시 전송할 수 있도록 합니다. - `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용할 수 없는 - 로컬 세션 기반 실행에서는 SDK가 최근에 지속된 입력 항목을 가능한 범위에서 - 롤백하여 재시도 후 기록 항목의 중복을 줄입니다. + 로컬 세션 기반 실행(`conversation_id`, `previous_response_id` 또는 + `auto_previous_response_id`와 함께 사용할 수 없음)에서도 SDK는 재시도 후 기록 항목이 + 중복되는 것을 줄이기 위해 최근에 저장된 입력 항목을 최선의 방식으로 롤백합니다. - 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 대한 - 더 광범위한 선택적 재시도 동작은 [Runner 관리 재시도](models/index.md#runner-managed-retries)를 참조하세요. + 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 + 대한 더 광범위한 옵트인 재시도 동작은 [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. ## 훅 및 사용자 지정 ### 모델 호출 입력 필터 -모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 이 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 수신하고 새로운 `ModelInputData`를 반환합니다. +모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 이 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 받고 새로운 `ModelInputData`를 반환합니다. -반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`가 발생합니다. +반환값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`가 발생합니다. ```python from agents import Agent, Runner, RunConfig @@ -459,19 +460,19 @@ result = Runner.run_sync( ) ``` -러너는 준비된 입력 목록의 사본을 훅에 전달하므로 호출자의 원래 목록을 제자리에서 변경하지 않고도 항목을 줄이거나 교체하거나 순서를 변경할 수 있습니다. +Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원본 목록을 제자리에서 변경하지 않고도 항목을 잘라내거나 대체하거나 순서를 변경할 수 있습니다. -세션을 사용하는 경우 `call_model_input_filter`는 세션 기록이 이미 로드되어 현재 턴과 병합된 후에 실행됩니다. 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. +세션을 사용하는 경우 `call_model_input_filter`는 세션 기록이 이미 로드되어 현재 턴과 병합된 후 실행됩니다. 이보다 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. -OpenAI의 서버 관리 대화 상태를 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용하는 경우 이 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록 전체를 다시 전달하는 대신 새 턴의 델타만 이미 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리 연속 처리에 전송된 것으로 표시됩니다. +`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`로 OpenAI 서버 관리형 대화 상태를 사용하는 경우 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 이 페이로드는 이전 기록 전체의 재생이 아니라 이미 새 턴의 델타만 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리형 연속 실행에서 전송된 것으로 표시됩니다. -민감한 데이터를 삭제하거나 긴 기록을 줄이거나 추가 시스템 지침을 삽입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. +민감한 데이터를 수정하거나 긴 기록을 잘라내거나 추가 시스템 지침을 삽입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. ## 오류 및 복구 ### 오류 핸들러 -모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 허용합니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. +모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 허용합니다. 지원되는 키는 `"max_turns"`, `"model_refusal"`, `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. ```python from agents import ( @@ -500,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -모델 메시지가 에이전트의 구조화된 `output_type`에 대해 유효성 검사를 통과하지 못하거나 모델이 구조화된 최종 메시지를 반환하지 않을 때는 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 출력을 반환할 수 있으며, SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지 않습니다. `None`을 반환하면 복구하지 않습니다. 대체 출력 없이 비어 있지 않은 값의 유효성 검사에 실패하면 계속해서 `ModelBehaviorError`가 발생하며, 비어 있는 구조화된 응답에는 기존의 다음 턴 동작이 유지됩니다. +모델 메시지가 에이전트의 구조화된 `output_type`에 대해 유효성 검사를 통과하지 못하거나 모델이 구조화된 최종 메시지를 반환하지 않을 때 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 출력을 반환할 수 있으며 SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 다시 시도하거나 도구의 부수 효과를 재실행하지 않습니다. `None`을 반환하면 복구를 수행하지 않습니다. 대체 출력 없이 비어 있지 않은 값의 검증이 실패하면 계속 `ModelBehaviorError`가 발생하며, 비어 있는 구조화된 응답에는 기존의 다음 턴 동작이 유지됩니다. ```python from pydantic import BaseModel @@ -532,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -대체 출력을 대화 기록에 추가하지 않으려면 `include_in_history=False`로 설정하세요. +`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 수 핸들러의 경우 이렇게 하면 생성된 대체 출력이 대화 기록에 추가되고 구성된 세션에 저장됩니다. 대체 출력을 결과 기록이나 세션 스토리지에 추가하지 않고 호출자에게만 반환하려면 `include_in_history=False`로 설정하세요. -모델의 거부로 실행을 `ModelRefusalError`와 함께 종료하는 대신 애플리케이션별 대체 출력을 생성해야 할 때는 `"model_refusal"`을 사용하세요. +모델의 응답 거부가 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 출력을 생성해야 할 때 `"model_refusal"`을 사용하세요. ```python from pydantic import BaseModel @@ -566,35 +567,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 내구성 실행 통합 및 휴먼인더루프 (HITL) +## 내구성 있는 실행 통합 및 휴먼인더루프 (HITL) -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 실행에 긴 대기, 재시도 또는 프로세스 재시작이 포함될 수 있는 내구성 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 실행될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. ### Dapr -Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 휴먼인더루프 (HITL)를 지원하면서 장애에서 자동으로 복구되는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. +Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 휴먼인더루프를 지원하고 실패에서 자동으로 복구되는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr은 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr 및 OpenAI 에이전트 시작 방법은 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)를 참조하세요. ### Temporal -Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함하여 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. +Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 작업을 포함한 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. ### Restate -Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있도록 지원합니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. +Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프, 세션 관리를 포함한 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합에는 Restate의 단일 바이너리 런타임이 종속성으로 필요하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. ### DBOS -Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 장애 및 재시작 시에도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. +Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 실패 및 재시작 후에도 진행 상황을 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents) 및 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. ## 예외 -SDK는 특정한 경우에 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. +SDK는 특정 상황에서 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. -- [`AgentsException`][agents.exceptions.AgentsException]: SDK 내에서 발생하는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 타입입니다. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과하면 발생하는 예외입니다. 에이전트가 지정된 상호작용 턴 수 안에 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`으로 설정하세요. -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상치 못한 출력이나 유효하지 않은 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 경우가 포함될 수 있습니다. +- [`AgentsException`][agents.exceptions.AgentsException]: SDK 내에서 발생하는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 유형입니다. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과할 때 발생하는 예외입니다. 지정된 상호작용 턴 수 내에 에이전트가 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`으로 설정하세요. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 유효하지 않은 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 상황이 포함될 수 있습니다. - 잘못된 형식의 JSON: 모델이 도구 호출이나 직접 출력에서 잘못된 형식의 JSON 구조를 제공하는 경우로, 특히 특정 `output_type`이 정의되어 있을 때 발생합니다. - - 예상치 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못하는 경우 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 해당 도구가 `timeout_behavior="raise_exception"`을 사용하는 경우 발생하는 예외입니다. -- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드 작성자가 SDK 사용 중 오류를 범하면 발생하는 예외입니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 잘못된 사용으로 인해 발생합니다. + - 예상하지 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못하는 경우 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생하는 예외입니다. +- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성하는 사람인 사용자가 SDK 사용 중 오류를 범했을 때 발생하는 예외입니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API 오용으로 인해 발생합니다. - [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 각각 입력 가드레일 또는 출력 가드레일의 조건이 충족될 때 발생하는 예외입니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. \ No newline at end of file diff --git a/docs/ko/streaming.md b/docs/ko/streaming.md index 1b6e3cef38..e68a6fa24d 100644 --- a/docs/ko/streaming.md +++ b/docs/ko/streaming.md @@ -4,17 +4,17 @@ search: --- # 스트리밍 -스트리밍을 사용하면 에이전트 실행이 진행되는 동안 업데이트를 구독할 수 있습니다. 이는 최종 사용자에게 진행 상황 업데이트와 부분 응답을 표시할 때 유용합니다. +스트리밍을 사용하면 에이전트 실행이 진행되는 동안 업데이트를 구독할 수 있습니다. 최종 사용자에게 진행 상황 업데이트와 부분 응답을 표시할 때 유용합니다. -스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 호출하여 [`RunResultStreaming`][agents.result.RunResultStreaming]을 받을 수 있습니다. `result.stream_events()`를 호출하면 아래에 설명된 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림을 받을 수 있습니다. +스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 호출합니다. 그러면 [`RunResultStreaming`][agents.result.RunResultStreaming]이 반환됩니다. `result.stream_events()`를 호출하면 아래에서 설명하는 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림이 반환됩니다. -비동기 이터레이터가 완료될 때까지 `result.stream_events()`를 계속 소비해야 합니다. 이터레이터가 종료되기 전까지 스트리밍 실행은 완료된 것이 아니며, 세션 영구 저장, 승인 상태 기록, 기록 압축과 같은 후처리는 표시되는 마지막 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. +비동기 반복자가 종료될 때까지 `result.stream_events()`를 계속 소비해야 합니다. 스트리밍 실행은 반복자가 종료될 때까지 완료되지 않으며, 세션 영속화, 승인 상태 관리 또는 기록 압축과 같은 후처리는 마지막으로 표시되는 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. ## 원문 응답 이벤트 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent]는 LLM에서 직접 전달되는 원문 이벤트입니다. 이 이벤트는 OpenAI Responses API 형식이므로 각 이벤트에는 유형(예: `response.created`, `response.output_text.delta` 등)과 데이터가 있습니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent]는 LLM에서 직접 전달되는 원문 이벤트입니다. OpenAI Responses API 형식이므로 각 이벤트에는 유형(예: `response.created`, `response.output_text.delta` 등)과 데이터가 있습니다. 이 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. -컴퓨터 도구의 원문 이벤트는 저장된 결과와 동일하게 프리뷰와 GA를 구분합니다. 프리뷰 흐름은 하나의 `action`이 포함된 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 일괄 처리된 `actions[]`가 포함된 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 인터페이스는 이를 위해 컴퓨터 전용 이벤트 이름을 별도로 추가하지 않습니다. 두 형식 모두 여전히 `tool_called`로 제공되며, 스크린샷 결과는 `computer_call_output` 항목을 감싼 `tool_output`으로 반환됩니다. +컴퓨터 도구의 원문 이벤트는 저장된 결과와 동일하게 프리뷰와 GA를 구분합니다. 프리뷰 흐름에서는 하나의 `action`이 있는 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 일괄 처리된 `actions[]`가 있는 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 인터페이스는 이를 위한 컴퓨터 전용 이벤트 이름을 별도로 추가하지 않습니다. 두 형식 모두 계속 `tool_called`로 표시되며, 스크린샷 결과는 `computer_call_output` 항목을 래핑한 `tool_output`으로 반환됩니다. 예를 들어 다음 코드는 LLM이 생성한 텍스트를 토큰 단위로 출력합니다. @@ -41,7 +41,7 @@ if __name__ == "__main__": ## 스트리밍과 승인 -스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요한 경우 `result.stream_events()`가 완료되고 대기 중인 승인은 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 제공됩니다. `result.to_state()`를 사용하여 결과를 [`RunState`][agents.run_state.RunState]로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`로 재개합니다. +스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요하면 `result.stream_events()`가 종료되고 대기 중인 승인이 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 표시됩니다. `result.to_state()`를 사용하여 결과를 [`RunState`][agents.run_state.RunState]로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`를 사용하여 재개합니다. ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -63,15 +63,15 @@ if result.interruptions: 스트리밍 실행을 도중에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출합니다. 기본적으로 실행이 즉시 중지됩니다. 중지하기 전에 현재 턴이 정상적으로 완료되도록 하려면 대신 `result.cancel(mode="after_turn")`을 호출합니다. -`result.stream_events()`가 완료되기 전까지 스트리밍 실행은 완료된 것이 아닙니다. 표시되는 마지막 토큰 이후에도 SDK가 세션 항목을 영구 저장하거나, 승인 상태를 마무리하거나, 기록을 압축하고 있을 수 있습니다. +스트리밍 실행은 `result.stream_events()`가 종료될 때까지 완료되지 않습니다. 마지막으로 표시되는 토큰 이후에도 SDK가 세션 항목을 영속화하거나, 승인 상태를 확정하거나, 기록을 압축하고 있을 수 있습니다. -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하고 있으며 `cancel(mode="after_turn")`이 도구 턴 이후에 중지된 경우, 곧바로 새로운 사용자 턴을 추가하지 말고 정규화된 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 턴을 이어서 진행합니다. -- 스트리밍 실행이 도구 승인을 위해 중지된 경우 이를 새 턴으로 취급하지 마세요. 스트림 소비를 끝까지 완료하고 `result.interruptions`를 확인한 후 `result.to_state()`에서 재개합니다. -- 다음 모델 호출 전에 가져온 세션 기록과 새 사용자 입력이 병합되는 방식을 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용합니다. 여기에서 새 턴 항목을 다시 작성하면 해당 턴에는 다시 작성된 버전이 영구 저장됩니다. +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하는 중이고 `cancel(mode="after_turn")`이 도구 턴 이후 중지된 경우, 즉시 새로운 사용자 턴을 추가하지 말고 정규화된 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 턴을 계속 진행합니다. +- 스트리밍 실행이 도구 승인을 위해 중지된 경우 이를 새 턴으로 취급하지 마세요. 스트림을 끝까지 소비하고 `result.interruptions`를 확인한 다음 `result.to_state()`에서 재개합니다. +- [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하면 검색한 세션 기록과 새 사용자 입력을 다음 모델 호출 전에 병합하는 방식을 사용자 지정할 수 있습니다. 여기에서 새 턴 항목을 다시 작성하면 다시 작성된 버전이 해당 턴에 대해 영속화됩니다. ## 실행 항목 이벤트와 에이전트 이벤트 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 상위 수준 이벤트입니다. 항목 생성이 완전히 끝났을 때 이를 알려 줍니다. 따라서 각 토큰이 아니라 "메시지 생성 완료", "도구 실행 완료" 등의 수준에서 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과로 변경될 때) 업데이트를 제공합니다. +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 상위 수준의 이벤트입니다. 항목이 완전히 생성되면 이를 알려 줍니다. 따라서 각 토큰 대신 "메시지 생성됨", "도구 실행됨" 등의 수준으로 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과로 변경될 때) 업데이트를 제공합니다. ### 실행 항목 이벤트 이름 @@ -91,11 +91,13 @@ if result.interruptions: `handoff_occured`는 이전 버전과의 호환성을 위해 의도적으로 철자가 잘못 표기되어 있습니다. -호스티드 툴 검색을 사용하면 모델이 도구 검색 요청을 보낼 때 `tool_search_called`가 발생하고, Responses API가 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 발생합니다. +핸드오프 호출은 `handoff_requested`로만 내보내지며 `tool_called`로도 내보내지는 않습니다. 동일한 턴에 있는 일반 함수 도구 호출은 계속 `tool_called`를 내보냅니다. -프로그래밍 방식 도구 호출(Programmatic Tool Calling)에서는 생성된 `program`과 일반적인 프로그램 소유 하위 도구 호출에 대해 `tool_called`가 발생합니다. 하위 도구 출력과 이에 대응하는 `program_output`에는 `tool_output`이 발생합니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외입니다. 이 항목들은 각각 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]과 [`MCPListToolsItem`][agents.items.MCPListToolsItem]을 감싼 `mcp_approval_requested` 및 `mcp_list_tools`로 발생합니다. 나머지 항목을 구분하려면 원문 항목의 `type`을 확인하세요. 프로그램 소유 하위 호출에는 유형이 `program`이고 호출자 ID로 상위 프로그램을 식별하는 `caller`도 포함됩니다. +호스티드 툴 검색을 사용하는 경우 모델이 도구 검색 요청을 실행할 때 `tool_search_called`가 내보내지고, Responses API가 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 내보내집니다. -예를 들어 다음 코드는 원문 이벤트를 무시하고 사용자에게 업데이트를 스트리밍합니다. +Programmatic Tool Calling을 사용하면 생성된 `program`과 프로그램이 소유한 일반 하위 도구 호출에 대해 `tool_called`가 내보내집니다. 하위 도구 출력과 이에 대응하는 `program_output`에 대해서는 `tool_output`이 내보내집니다. 프로그램이 소유한 호스티드 MCP의 `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외입니다. 각각 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 및 [`MCPListToolsItem`][agents.items.MCPListToolsItem]을 래핑한 `mcp_approval_requested` 및 `mcp_list_tools`로 내보내집니다. 나머지 항목을 구분하려면 원문 항목의 `type`을 확인하세요. 프로그램이 소유한 하위 호출에는 유형이 `program`이고 호출자 ID가 상위 프로그램을 식별하는 `caller`도 포함됩니다. + +예를 들어 다음 코드는 원문 이벤트를 무시하고 업데이트를 사용자에게 스트리밍합니다. ```python import asyncio diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index 43a4f5f2bc..b1b35c9de5 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -4,7 +4,7 @@ search: --- # 安全防护措施 -安全防护措施可用于检查和验证用户输入与智能体输出。例如,假设你有一个使用非常智能(因而速度较慢且成本较高)的模型来协助处理客户请求的智能体。你肯定不希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用速度较快、成本较低的模型运行安全防护措施。如果安全防护措施检测到恶意使用,它可以立即引发错误并阻止高成本模型运行,从而节省时间和费用(**使用阻塞式安全防护措施时如此;对于并行安全防护措施,高成本模型可能在安全防护措施运行完毕前就已开始运行。有关详情,请参阅下文的“执行模式”**)。 +安全防护措施支持对用户输入和智能体输出进行检查与验证。例如,假设你有一个使用非常智能(因而速度较慢、费用较高)的模型来帮助处理客户请求的智能体。你不会希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用一个快速且低成本的模型运行安全防护措施。如果安全防护措施检测到恶意使用行为,它可以立即引发错误,并阻止高成本模型运行,从而为你节省时间和费用(**使用阻塞式安全防护措施时如此;对于并行安全防护措施,高成本模型可能已在安全防护措施完成前开始运行。有关详细信息,请参阅下方的“执行模式”**)。 安全防护措施分为两类: @@ -13,68 +13,68 @@ search: ## 工作流边界 -安全防护措施会附加到智能体和工具,但它们并非都在工作流中的相同节点运行: +安全防护措施会附加到智能体和工具上,但它们并非都在工作流中的相同节点运行: - **输入安全防护措施**仅针对链中的第一个智能体运行。 - **输出安全防护措施**仅针对生成最终输出的智能体运行。 -- **工具安全防护措施**会在每次调用自定义工具调用时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 +- **工具安全防护措施**会在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 -如果需要检查包含管理器、任务转移或受委派专家的工作流中的每次自定义工具调用,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 +如果需要检查包含管理智能体、任务转移或委派专家的工作流中的每次自定义函数工具调用,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 ## 输入安全防护措施 输入安全防护措施分 3 个步骤运行: -1. 首先,安全防护措施接收传递给智能体的相同输入。 -2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 +1. 首先,安全防护措施接收与传递给智能体的相同输入。 +2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],然后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 +3. 最后,检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 -!!! 注意 +!!! Note - 输入安全防护措施旨在针对用户输入运行,因此仅当某个智能体是*第一个*智能体时,其安全防护措施才会运行。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输入安全防护措施用于处理用户输入,因此只有当智能体是*第一个*智能体时,其安全防护措施才会运行。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 ### 执行模式 输入安全防护措施支持两种执行模式: -- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体同时执行。由于二者同时启动,这种模式可实现最低延迟。但是,如果安全防护措施未通过,智能体在被取消之前可能已经消耗了 token 并执行了工具。 +- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体的执行并发运行。由于二者同时启动,因此这种模式可实现最低延迟。但是,如果安全防护措施检查失败,智能体可能已经消耗了 token 并执行了工具,随后才被取消。 -- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将完全不会执行,从而避免 token 消耗和工具执行。这非常适合成本优化,以及希望避免工具调用产生潜在副作用的场景。 +- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将永远不会执行,从而避免消耗 token 和执行工具。此模式非常适合优化成本,以及希望避免工具调用可能产生的副作用的场景。 ## 输出安全防护措施 输出安全防护措施分 3 个步骤运行: 1. 首先,安全防护措施接收智能体生成的输出。 -2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 +2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],然后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 +3. 最后,检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 -!!! 注意 +!!! Note - 输出安全防护措施旨在针对智能体的最终输出运行,因此仅当某个智能体是*最后一个*智能体时,其安全防护措施才会运行。与输入安全防护措施类似,我们这样做是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输出安全防护措施用于处理智能体的最终输出,因此只有当智能体是*最后一个*智能体时,其安全防护措施才会运行。与输入安全防护措施类似,我们这样做是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 - 输出安全防护措施始终在智能体完成运行后执行,因此不支持 `run_in_parallel` 参数。 + 输出安全防护措施始终在智能体完成后运行,因此不支持 `run_in_parallel` 参数。 ## 工具安全防护措施 -工具安全防护措施会封装**工具调用**,使你能够在执行前后验证或阻止工具调用。它们在工具本身上配置,并在每次调用该工具时运行。 +工具安全防护措施封装**工具调用**,支持在执行前后验证或阻止工具调用。它们在工具本身上配置,并在每次调用该工具时运行。 -- 输入工具安全防护措施在工具执行前运行,可以跳过调用、用消息替换输出或引发触发器。 -- 输出工具安全防护措施在工具执行后运行,可以替换输出或引发触发器。 -- 如果工具调用需要批准,输入工具安全防护措施通常会在获得批准后、执行前立即运行。如果希望在发出待批准中断前运行这些输入检查,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此批准前检查的调用仍会在获得批准后、工具执行前再次接受检查。 -- 工具安全防护措施仅适用于通过 [`function_tool`][agents.tool.function_tool] 创建的工具调用。任务转移通过 Agents SDK的任务转移管道运行,而不是通过常规的工具调用管道运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管道,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不直接提供工具安全防护措施选项。 +- 输入工具安全防护措施在工具执行前运行,可以跳过调用、用消息替换输出,或触发触发器。 +- 输出工具安全防护措施在工具执行后运行,可以替换输出或触发触发器。 +- 如果函数工具需要审批,输入工具安全防护措施通常会在审批后、执行前立即运行。如果希望这些输入检查在发出待审批中断之前运行,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此项审批前检查的调用仍会在审批后、工具执行前再次接受检查。 +- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的工具调用。任务转移通过 SDK 的任务转移管线运行,而不是通过常规函数工具管线运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管线,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不直接提供工具安全防护措施选项。 -有关详情,请参阅下面的代码片段。 +有关详细信息,请参阅下方的代码片段。 ## 触发器 -如果输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。一旦发现某项安全防护措施触发了触发器,我们会立即引发 `{Input,Output}GuardrailTripwireTriggered` 异常并停止智能体执行。 +如果输入或输出未通过安全防护措施检查,安全防护措施可以通过触发器发出信号。一旦发现某项安全防护措施触发了触发器,我们会立即引发 `{Input,Output}GuardrailTripwireTriggered` 异常,并停止智能体执行。 -异常的 `guardrail_result` 可标识触发了触发器的安全防护措施。对于由运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的每项输入安全防护措施结果,包括触发了触发器的结果。在 `stream_events()` 引发异常后,流式传输结果会通过 `input_guardrail_results` 提供同一组累积结果。如果异常是在运行器管理的执行路径之外引发的,`run_data` 可以为 `None`。 +异常的 `guardrail_result` 可标识触发了触发器的安全防护措施。对于由运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,其中包括触发了触发器的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供相应的累积结果。在 `stream_events()` 引发异常后,流式结果会通过 `input_guardrail_results` 或 `output_guardrail_results` 公开相同的已完成结果。如果异常是在运行器管理的执行路径之外引发的,`run_data` 可以为 `None`。 ## 安全防护措施的实现 -你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在底层通过运行智能体来实现这一点。 +你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在底层运行一个智能体来实现这一点。 ```python from pydantic import BaseModel @@ -190,7 +190,7 @@ async def main(): 3. 这是接收智能体输出并返回结果的安全防护措施函数。 4. 这是定义工作流的实际智能体。 -最后,以下是工具安全防护措施的示例。 +最后,以下是工具安全防护措施的代码示例。 ```python import json diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md index 4c5e6c97f9..28bbd9ab86 100644 --- a/docs/zh/mcp.md +++ b/docs/zh/mcp.md @@ -4,35 +4,34 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)对应用如何向语言模型公开工具和 -上下文进行了标准化。官方文档对此说明如下: +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)对应用如何向语言模型公开工具和上下文进行了标准化。官方文档中的定义如下: -> MCP是一种开放协议,对应用如何向LLM提供上下文进行了标准化。可以将MCP视为 AI -> 应用的 USB-C 端口。正如 USB-C 提供了一种标准化方式,用于将设备连接到各种外设和配件,MCP -> 也提供了一种标准化方式,用于将 AI 模型连接到不同的数据源和工具。 +> MCP是一种开放协议,对应用如何向LLMs提供上下文进行了标准化。可以将MCP视为AI +> 应用的 USB-C 端口。正如 USB-C 提供了一种将设备连接到各种外围设备和配件的标准化方式,MCP +> 也提供了一种将 AI 模型连接到不同数据源和工具的标准化方式。 -Agents Python SDK支持多种MCP传输方式。这样,您可以复用现有MCP服务,也可以构建自己的服务,以向智能体公开由文件系统、HTTP 或连接器支持的工具。 +Agents Python SDK支持多种MCP传输方式。这样,你可以复用现有的MCP服务,也可以构建自己的服务,向智能体公开由文件系统、HTTP 或连接器支持的工具。 -!!! warning "连接MCP服务前的信任要求" +!!! warning "连接前信任MCP服务" - MCP工具可以公开模型上下文中的数据,并使用您提供的凭据执行操作。请仅连接您信任的服务,使用最小权限凭据,将访问令牌放在授权字段或标头中而非 URL 中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 + MCP工具可以公开模型上下文中的数据,并使用你提供的凭据执行操作。请仅连接到你信任的服务,使用最小权限凭据,将访问令牌放在授权字段或标头中而非 URL 中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 -## MCP集成方式的选择 +## MCP集成方案选择 -在将MCP服务接入智能体之前,请确定工具调用应在何处执行,以及您可以访问哪些传输方式。下表概述了 Python SDK支持的选项。 +在将MCP服务接入智能体之前,需要确定工具调用应在何处执行,以及你可以访问哪些传输方式。下表汇总了 Python SDK支持的选项。 -| 您的需求 | 推荐选项 | +| 你的需求 | 推荐选项 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| 让OpenAI的 Responses API 代表模型调用可公开访问的MCP服务| 通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 使用**托管式MCP服务工具** | -| 连接到您在本地或远程运行的可流式传输 HTTP 服务 | 通过 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 使用**可流式传输 HTTP 的MCP服务** | -| 与实现带 Server-Sent Events 的 HTTP 服务通信 | 通过 [`MCPServerSse`][agents.mcp.server.MCPServerSse] 使用**带 SSE 的 HTTP MCP服务** | -| 启动本地进程并通过 stdin/stdout 通信 | 通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] 使用**stdio MCP服务** | +| 让OpenAI的 Responses API代表模型调用可公开访问的MCP服务| 通过[`HostedMCPTool`][agents.tool.HostedMCPTool]使用**托管式MCP服务工具** | +| 连接到你在本地或远程运行的 Streamable HTTP 服务 | 通过[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]使用**Streamable HTTP MCP服务** | +| 与实现了基于 Server-Sent Events 的 HTTP 的服务通信 | 通过[`MCPServerSse`][agents.mcp.server.MCPServerSse]使用**基于 SSE 的 HTTP MCP服务** | +| 启动本地进程并通过 stdin/stdout 通信 | 通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]使用**stdio MCP服务** | -以下各节将逐一介绍每个选项、配置方式,以及何时应优先选择某种传输方式。 +以下各节将介绍每种选项、配置方式,以及何时应优先选择某种传输方式。 ## 智能体级MCP配置 -除选择传输方式外,您还可以通过设置 `Agent.mcp_config` 调整MCP工具的准备方式。 +除了选择传输方式之外,还可以通过设置 `Agent.mcp_config` 来调整MCP工具的准备方式。 ```python from agents import Agent @@ -52,33 +51,33 @@ agent = Agent( ) ``` -注意事项: +注意: -- `convert_schemas_to_strict` 会尽力执行转换。如果无法转换某个模式,则使用原始模式。 +- `convert_schemas_to_strict` 会尽力执行转换。如果某个架构无法转换,则使用原始架构。 - `failure_error_function` 控制如何向模型呈现MCP工具调用失败。 -- 未设置 `failure_error_function` 时,SDK会使用默认的工具错误格式化程序。 +- 未设置 `failure_error_function` 时,SDK使用默认的工具错误格式化程序。 - 服务级 `failure_error_function` 会覆盖该服务的 `Agent.mcp_config["failure_error_function"]`。 -- `include_server_in_tool_names` 需要主动启用。启用后,每个本地MCP工具都会使用带有确定性服务前缀的名称向模型公开,这有助于避免多个MCP服务发布同名工具时出现冲突。生成的名称符合 ASCII 安全要求,不会超过工具调用名称长度限制,并会避开同一智能体上现有的本地工具调用名称和已启用的任务转移名称。SDK仍会在原服务上调用原始MCP工具名称。 +- `include_server_in_tool_names` 需要显式启用。启用后,每个本地MCP工具都会以带有确定性服务前缀的名称公开给模型,这有助于避免多个MCP服务发布同名工具时发生冲突。生成的名称符合 ASCII 安全要求,不超过工具调用名称的长度限制,并且不会与同一智能体上现有的本地工具调用及已启用的任务转移名称冲突。SDK仍会在原始服务上调用原始MCP工具名称。 ## 各传输方式的通用模式 -选择传输方式后,大多数集成还需要做出以下相同的后续决策: +选择传输方式后,大多数集成还需要做出相同的后续决策: -- 如何仅公开部分工具([工具筛选](#tool-filtering))。 +- 如何仅公开工具的一个子集([工具筛选](#tool-filtering))。 - 服务是否还提供可复用的提示词([提示词](#prompts))。 - 是否应缓存 `list_tools()`([缓存](#caching))。 -- 如何在追踪记录中呈现MCP活动([追踪](#tracing))。 +- MCP活动如何显示在追踪记录中([追踪](#tracing))。 -对于本地MCP服务(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的 `_meta` 载荷也是通用概念。可流式传输 HTTP 一节展示了最完整的代码示例,相同模式也适用于其他本地传输方式。 +对于本地MCP服务(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的 `_meta` 负载也是通用概念。Streamable HTTP 一节展示了最完整的代码示例,同样的模式也适用于其他本地传输方式。 ## 1. 托管式MCP服务工具 -托管工具会将整个工具往返流程交由OpenAI基础设施处理。您的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool] 会将服务标签(以及可选的连接器元数据)转发给 Responses API。模型会列出远程服务的工具并调用它们,无需额外回调您的 Python 进程。托管工具目前适用于支持 Responses API 托管式MCP集成的OpenAI模型。 +托管工具会将整个工具往返流程转移到OpenAI的基础设施中。你的代码无需列出并调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool] 会将服务标签(以及可选的连接器元数据)转发给 Responses API。模型会列出远程服务的工具并调用它们,无需再回调你的 Python 进程。托管工具目前可与支持 Responses API托管式MCP集成的OpenAI模型配合使用。 ### 基础托管式MCP工具 -将 [`HostedMCPTool`][agents.tool.HostedMCPTool] 添加到智能体的 `tools` 列表,即可创建托管工具。`tool_config` -字典对应您将发送给 REST API 的 JSON: +将 [`HostedMCPTool`][agents.tool.HostedMCPTool] 添加到智能体的 `tools` 列表中,即可创建托管工具。`tool_config` +字典与发送给 REST API的 JSON 相对应: ```python import asyncio @@ -110,14 +109,14 @@ async def main() -> None: asyncio.run(main()) ``` -托管式服务会自动公开其工具;您无需将其添加到 `mcp_servers`。 +托管服务会自动公开其工具;无需将其添加到 `mcp_servers`。 -如果您希望托管工具搜索延迟加载托管式MCP服务,请设置 `tool_config["defer_loading"] = True`,并将 [`ToolSearchTool`][agents.tool.ToolSearchTool] 添加到智能体。此功能仅受OpenAI Responses模型支持。有关完整的工具搜索配置和限制,请参阅[工具](tools.md#hosted-tool-search)。 +如果希望托管工具搜索延迟加载托管式MCP服务,请设置 `tool_config["defer_loading"] = True`,并将 [`ToolSearchTool`][agents.tool.ToolSearchTool] 添加到智能体。此功能仅受OpenAI Responses 模型支持。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 ### 托管式MCP结果的流式传输 -托管工具支持流式传输结果,方式与工具调用完全相同。使用 `Runner.run_streamed` -可在模型仍在工作时接收增量MCP输出: +托管工具支持与工具调用完全相同的流式传输结果方式。使用 `Runner.run_streamed` +可以在模型仍在工作时使用增量MCP输出: ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -129,7 +128,7 @@ print(result.final_output) ### 可选审批流程 -如果服务可以执行敏感操作,您可以要求在每次工具执行前进行人工或程序化审批。在 `tool_config` 中配置 `require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。若要在 Python 中做出决定,请提供 `on_approval_request` 回调。 +如果服务可以执行敏感操作,可以要求在每次执行工具前进行人工或程序化审批。在 `tool_config` 中配置 `require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。若要在 Python 中做出决定,请提供 `on_approval_request` 回调。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -157,11 +156,11 @@ agent = Agent( ) ``` -该回调可以是同步或异步的,只要模型需要审批数据才能继续运行,就会调用此回调。 +该回调可以是同步或异步的,并会在模型需要审批数据以继续运行时调用。 -### 连接器支持的托管式服务 +### 由连接器支持的托管服务 -托管式MCP还支持OpenAI连接器。无需指定 `server_url`,只需提供 `connector_id` 和访问令牌。Responses API 会处理身份验证,托管式服务则公开连接器的工具。 +托管式MCP也支持OpenAI连接器。无需指定 `server_url`,只需提供 `connector_id` 和访问令牌。Responses API负责处理身份验证,托管服务则公开连接器的工具。 ```python import os @@ -177,11 +176,11 @@ HostedMCPTool( ) ``` -功能完整的托管工具代码示例(包括流式传输、审批和连接器)位于 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 +完整可运行的托管工具示例(包括流式传输、审批和连接器)位于 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 -## 2. 可流式传输 HTTP MCP服务 +## 2. Streamable HTTP MCP服务 -如果您希望自行管理网络连接,请使用 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当您需要控制传输方式,或希望在自己的基础设施中运行服务并保持较低延迟时,可流式传输 HTTP 服务是理想选择。 +如果希望自行管理网络连接,请使用 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你需要控制传输方式,或希望在自己的基础设施中运行服务并保持低延迟时,Streamable HTTP 服务是理想选择。 ```python import asyncio @@ -218,19 +217,19 @@ asyncio.run(main()) 构造函数还接受以下选项: -- `client_session_timeout_seconds` 控制MCP ClientSession的读取超时。至少为一微秒、可由 `datetime.timedelta` 表示的正有限值会设置有限超时;`None` 和 `0` 会禁用超时。构造服务时会拒绝其他值。 -- `use_structured_content` 控制是否优先使用 `tool_result.structured_content`,而非文本输出。 +- `client_session_timeout_seconds` 控制MCP ClientSession 的读取超时。可由 `datetime.timedelta` 表示且不小于一微秒的有限正数会设置有限超时;`None` 和 `0` 会禁用超时。构造服务时,其他值将被拒绝。 +- `use_structured_content` 控制是否优先使用 `tool_result.structured_content`,而不是文本输出。 - `max_retry_attempts` 和 `retry_backoff_seconds_base` 为 `list_tools()` 和 `call_tool()` 添加自动重试。 -- `tool_filter` 允许您仅公开部分工具(请参阅[工具筛选](#tool-filtering))。 -- `require_approval` 为本地MCP工具启用人在回路审批策略。 -- `failure_error_function` 自定义模型可见的MCP工具失败消息;将其设置为 `None` 则改为引发错误。 -- `tool_meta_resolver` 在 `call_tool()` 之前注入每次调用的MCP `_meta` 载荷。 +- `tool_filter` 允许你仅公开工具的一个子集(请参阅[工具筛选](#tool-filtering))。 +- `require_approval` 为本地MCP工具启用人工介入审批策略。 +- `failure_error_function` 自定义模型可见的MCP工具失败消息;将其设为 `None` 则会改为抛出错误。 +- `tool_meta_resolver` 在调用 `call_tool()` 前注入每次调用的MCP `_meta` 负载。 ### 本地MCP服务的审批策略 `MCPServerStdio`、`MCPServerSse` 和 `MCPServerStreamableHttp` 均接受 `require_approval`。 -支持以下形式: +支持的形式: - 对所有工具使用 `"always"` 或 `"never"`。 - `True` / `False`(分别等同于始终审批/从不审批)。 @@ -246,11 +245,11 @@ async with MCPServerStreamableHttp( ... ``` -有关完整的暂停/恢复流程,请参阅[人在回路](human_in_the_loop.md)和 `examples/mcp/get_all_mcp_tools_example/main.py`。 +有关完整的暂停/恢复流程,请参阅[人工介入](human_in_the_loop.md)和 `examples/mcp/get_all_mcp_tools_example/main.py`。 -### 使用 `tool_meta_resolver` 的单次调用元数据 +### 使用 `tool_meta_resolver` 配置每次调用的元数据 -当MCP服务要求在 `_meta` 中提供请求元数据(例如租户 ID 或追踪上下文)时,请使用 `tool_meta_resolver`。以下代码示例假设您将 `dict` 作为 `context` 传递给 `Runner.run(...)`。 +当MCP服务期望在 `_meta` 中接收请求元数据(例如租户 ID 或追踪上下文)时,请使用 `tool_meta_resolver`。以下示例假设你将 `dict` 作为 `context` 传递给 `Runner.run(...)`。 ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -271,19 +270,19 @@ server = MCPServerStreamableHttp( ) ``` -如果您的运行上下文是 Pydantic 模型、数据类或自定义类,请改用属性访问方式读取租户 ID。 +如果运行上下文是 Pydantic 模型、数据类或自定义类,请改用属性访问来读取租户 ID。 ### MCP工具输出:文本和图像 -当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合文本/图像响应会作为输出项列表转发,因此智能体可以像使用常规工具调用的图像输出一样使用MCP图像结果。 +当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合的文本/图像响应会作为输出项列表转发,因此智能体使用MCP图像结果的方式,与使用常规工具调用所产生的图像输出相同。 -## 3. 带 SSE 的 HTTP MCP服务 +## 3. 基于 SSE 的 HTTP MCP服务 !!! warning - MCP项目已弃用 Server-Sent Events 传输方式。新集成应优先使用可流式传输 HTTP 或 stdio,仅为旧版服务保留 SSE。 + MCP项目已弃用 Server-Sent Events 传输。对于新集成,请优先使用 Streamable HTTP 或 stdio,仅为旧版服务保留 SSE。 -如果MCP服务实现了带 SSE 的 HTTP 传输,请实例化 [`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其 API 与可流式传输 HTTP 服务完全相同。 +如果MCP服务实现了基于 SSE 的 HTTP 传输,请实例化 [`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其 API 与 Streamable HTTP 服务相同。 ```python @@ -312,7 +311,7 @@ async with MCPServerSse( ## 4. stdio MCP服务 -对于以本地子进程方式运行的MCP服务,请使用 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会启动进程、保持管道打开,并在退出上下文管理器时自动关闭管道。此选项适合快速进行概念验证,或服务仅公开命令行入口点的情况。 +对于以本地子进程方式运行的MCP服务,请使用 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会生成进程、保持管道打开,并在上下文管理器退出时自动将其关闭。此选项适用于快速概念验证,或服务仅公开命令行入口点的情况。 ```python from pathlib import Path @@ -340,7 +339,7 @@ async with MCPServerStdio( ## 5. MCP服务管理器 -如果您有多个MCP服务,请使用 `MCPServerManager` 预先连接这些服务,并向智能体公开已连接的服务子集。有关构造函数选项和重新连接行为,请参阅 [MCPServerManager API 参考](ref/mcp/manager.md)。 +如果有多个MCP服务,请使用 `MCPServerManager` 预先连接它们,并将已连接的服务子集公开给智能体。有关构造函数选项和重新连接行为,请参阅 [MCPServerManager API参考](ref/mcp/manager.md)。 ```python from agents import Agent, Runner @@ -363,19 +362,19 @@ async with MCPServerManager(servers) as manager: 关键行为: -- 当 `drop_failed_servers=True`(默认值)时,`active_servers` 仅包括成功连接的服务。 -- 失败情况会记录在 `failed_servers` 和 `errors` 中。 -- 设置 `strict=True` 可在第一次连接失败时引发错误。 -- 调用 `reconnect(failed_only=True)` 可重试连接失败的服务,调用 `reconnect(failed_only=False)` 则会重启所有服务。 -- 设置 `connect_timeout_seconds`、`cleanup_timeout_seconds` 和 `connect_in_parallel` 可调整生命周期行为。生命周期超时接受正有限秒数,或使用 `None` 禁用超时;这些值会在构造和赋值期间进行验证。零值会被拒绝,因为它会产生即时截止期限。 +- 当 `drop_failed_servers=True`(默认值)时,`active_servers` 仅包含成功连接的服务。 +- 连接失败会记录在 `failed_servers` 和 `errors` 中。 +- 设置 `strict=True` 可在首次连接失败时抛出异常。 +- 调用 `reconnect(failed_only=True)` 可重试失败的服务,调用 `reconnect(failed_only=False)` 则会重启所有服务。 +- 设置 `connect_timeout_seconds`、`cleanup_timeout_seconds` 和 `connect_in_parallel` 可调整生命周期行为。生命周期超时接受有限正秒数,也可以设为 `None` 以禁用超时,并且会在构造和赋值时进行验证;不接受零,因为零会创建立即到期的截止时间。 ## 通用服务能力 -以下各节适用于各种MCP服务传输方式(具体 API 接口取决于服务类)。 +以下各节适用于各种MCP服务传输方式(具体 API 范围取决于服务类)。 ## 工具筛选 -每个MCP服务都支持工具筛选,因此您可以仅公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 +每个MCP服务都支持工具筛选器,因此你可以只公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 ### 静态工具筛选 @@ -397,11 +396,11 @@ filesystem_server = MCPServerStdio( ) ``` -同时提供 `allowed_tool_names` 和 `blocked_tool_names` 时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 +当同时提供 `allowed_tool_names` 和 `blocked_tool_names` 时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 ### 动态工具筛选 -对于更复杂的逻辑,请传入一个接收 [`ToolFilterContext`][agents.mcp.ToolFilterContext] 的可调用对象。该可调用对象可以是同步或异步的,并在应公开工具时返回 `True`。 +如需更复杂的逻辑,请传入一个接收 [`ToolFilterContext`][agents.mcp.ToolFilterContext] 的可调用对象。该可调用对象可以是同步或异步的,并在应公开该工具时返回 `True`。 ```python from pathlib import Path @@ -425,7 +424,7 @@ async with MCPServerStdio( ... ``` -筛选上下文会公开当前的 `run_context`、请求工具的 `agent` 和 `server_name`。 +筛选器上下文会公开活动的 `run_context`、请求工具的 `agent` 和 `server_name`。 ## 提示词 @@ -433,7 +432,7 @@ MCP服务还可以提供动态生成智能体指令的提示词。支持提示 方法: - `list_prompts()` 枚举可用的提示词模板。 -- `get_prompt(name, arguments)` 获取具体提示词,并可选择提供参数。 +- `get_prompt(name, arguments)` 获取具体的提示词,可选择提供参数。 ```python from agents import Agent @@ -451,15 +450,21 @@ agent = Agent( ) ``` +## 分页 + +内置的本地MCP服务类在列出工具和提示词时会自动跟随 `nextCursor`。`list_tools()` 会在应用筛选器或填充缓存前返回完整的工具列表,而 `list_prompts()` 会返回一个合并结果,其中 `nextCursor=None`。如果后续页面失败或服务重复返回某个游标,该操作会抛出错误,而不会公开或缓存部分结果。 + +资源仍会明确分页。将 `list_resources()` 或 `list_resource_templates()` 返回的 `nextCursor` 作为 `cursor` 参数传回,即可获取下一页。 + ## 缓存 -每次智能体运行都会在每个MCP服务上调用 `list_tools()`。远程服务可能产生明显的延迟,因此所有MCP服务类都提供 `cache_tools_list` 选项。仅当您确定工具定义不会频繁变化时,才将其设置为 `True`。如需稍后强制获取最新列表,请在服务实例上调用 `invalidate_tools_cache()`。 +每次智能体运行都会在每个MCP服务上调用 `list_tools()`。远程服务可能会产生明显的延迟,因此所有MCP服务类都公开了 `cache_tools_list` 选项。只有在确信工具定义不会频繁更改时,才应将其设为 `True`。若之后需要强制获取最新列表,请在服务实例上调用 `invalidate_tools_cache()`。 ## 追踪 [追踪](./tracing.md)会自动捕获MCP活动,包括: -1. 为列出工具而对MCP服务发起的调用。 +1. 为列出工具而对MCP服务进行的调用。 2. 工具调用中与MCP相关的信息。 ![MCP追踪截图](../assets/images/mcp-tracing.jpg) @@ -467,5 +472,5 @@ agent = Agent( ## 延伸阅读 - [Model Context Protocol](https://modelcontextprotocol.io/) – 规范和设计指南。 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的 stdio、SSE 和可流式传输 HTTP 代码示例。 +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的 stdio、SSE 和 Streamable HTTP 示例。 - [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 完整的托管式MCP演示,包括审批和连接器。 \ No newline at end of file diff --git a/docs/zh/realtime/guide.md b/docs/zh/realtime/guide.md index 74491e1d88..9ff7153887 100644 --- a/docs/zh/realtime/guide.md +++ b/docs/zh/realtime/guide.md @@ -4,48 +4,48 @@ search: --- # 实时智能体指南 -本指南介绍 OpenAI Agents SDK的实时层如何映射到 OpenAI Realtime API,以及 Python SDK 在此基础上增加的行为。 +本指南说明OpenAI Agents SDK的实时层如何映射到OpenAI Realtime API,以及Python SDK在此基础上增加了哪些额外行为。 !!! note "从这里开始" - 如果你希望使用默认的 Python 路径,请先阅读[快速入门](quickstart.md)。如果你正在确定应用应使用服务端 WebSocket 还是 SIP,请阅读[实时传输](transport.md)。浏览器 WebRTC 传输不属于 Python SDK。 + 如果你想使用默认的Python路径,请先阅读[快速入门](quickstart.md)。如果你正在确定应用应使用服务端WebSocket还是SIP,请阅读[实时传输](transport.md)。浏览器WebRTC传输不属于Python SDK的一部分。 ## 概述 -实时智能体会与 Realtime API 保持长期连接,使模型能够增量处理文本和音频、以流式传输方式输出音频、调用工具并处理中断,而无需在每轮对话时重新发起请求。 +实时智能体会与Realtime API保持长连接,使模型能够增量处理文本和音频、流式传输音频输出、调用工具,并处理打断,而无需在每一轮都重新发起请求。 -SDK 的主要组件包括: +主要SDK组件包括: -- **RealtimeAgent**:单个实时专家智能体的指令、工具、输出安全防护措施和任务转移 -- **RealtimeRunner**:将起始智能体连接到实时传输层的会话工厂 -- **RealtimeSession**:用于发送输入、接收事件、追踪历史记录和执行工具的实时会话 -- **RealtimeModel**:传输抽象。默认实现是 OpenAI的服务端 WebSocket。 +- **RealtimeAgent**: 单个实时专用智能体的指令、工具、输出安全防护措施和任务转移 +- **RealtimeRunner**: 将起始智能体连接到实时传输层的会话工厂 +- **RealtimeSession**: 用于发送输入、接收事件、跟踪历史记录和执行工具的实时会话 +- **RealtimeModel**: 传输抽象。默认实现是OpenAI的服务端WebSocket实现。 ## 会话生命周期 -典型的实时会话流程如下: +典型的实时会话如下: -1. 创建一个或多个 `RealtimeAgent`。 -2. 使用起始智能体创建 `RealtimeRunner`。 -3. 调用 `await runner.run()` 获取 `RealtimeSession`。 -4. 使用 `async with session:` 或 `await session.enter()` 进入会话。 -5. 使用 `send_message()` 或 `send_audio()` 发送用户输入。 -6. 迭代处理会话事件,直到对话结束。 +1. 创建一个或多个`RealtimeAgent`。 +2. 使用起始智能体创建`RealtimeRunner`。 +3. 调用`await runner.run()`以获取`RealtimeSession`。 +4. 使用`async with session:`或`await session.enter()`进入会话。 +5. 使用`send_message()`或`send_audio()`发送用户输入。 +6. 迭代会话事件,直到对话结束。 -与纯文本运行不同,`runner.run()` 不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和当前智能体配置与传输层保持同步。 +与纯文本运行不同,`runner.run()`不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和当前智能体配置与传输层保持同步。 -默认情况下,`RealtimeRunner` 使用 `OpenAIRealtimeWebSocketModel`,因此默认的 Python 路径是通过服务端 WebSocket 连接到 Realtime API。如果传入其他 `RealtimeModel`,仍会使用相同的会话生命周期和智能体功能,但连接机制可以有所不同。 +默认情况下,`RealtimeRunner`使用`OpenAIRealtimeWebSocketModel`,因此默认Python路径是通过服务端WebSocket连接到Realtime API。如果传入其他`RealtimeModel`,相同的会话生命周期和智能体功能仍然适用,但连接机制可以不同。 ## 智能体与会话配置 -`RealtimeAgent` 的设计范围有意比常规 `Agent` 类型更窄: +与常规`Agent`类型相比,`RealtimeAgent`的范围有意设计得更窄: -- 模型选择在会话级别配置,而不是为每个智能体单独配置。 -- 不支持 structured outputs。 -- 可以配置语音,但会话生成口语音频后便无法更改。 +- 模型选择在会话级别配置,而不是按智能体配置。 +- 不支持structured outputs。 +- 可以配置语音,但会话生成语音音频后便无法更改。 - 指令、工具调用、任务转移、钩子和输出安全防护措施仍然可用。 -`RealtimeSessionModelSettings` 同时支持较新的嵌套 `audio` 配置和旧版扁平别名。新代码应优先使用嵌套结构,并为新的实时智能体从 `gpt-realtime-2.1` 开始: +`RealtimeSessionModelSettings`既支持较新的嵌套`audio`配置,也支持较旧的扁平别名。新代码应优先使用嵌套结构,并使用`gpt-realtime-2.1`开始构建新的实时智能体: ```python runner = RealtimeRunner( @@ -67,7 +67,7 @@ runner = RealtimeRunner( ) ``` -常用的会话级设置包括: +常用的会话级别设置包括: - `audio.input.format`, `audio.output.format` - `audio.input.transcription` @@ -79,7 +79,7 @@ runner = RealtimeRunner( - `prompt` - `tracing` -`RealtimeRunner(config=...)` 中常用的运行级设置包括: +`RealtimeRunner(config=...)`中常用的运行级别设置包括: - `async_tool_calls` - `output_guardrails` @@ -87,13 +87,13 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -有关完整的类型化接口,请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 +如需了解完整的类型化接口,请参阅[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]和[`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 ## 输入与输出 ### 文本与结构化用户消息 -使用 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] 发送纯文本或结构化实时消息。 +使用[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]发送纯文本或结构化实时消息。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +111,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -结构化消息是在实时对话中包含图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) 中的 Web 演示示例以这种方式转发 `input_image` 消息。 +结构化消息是在实时对话中加入图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)中的Web演示代码通过这种方式转发`input_image`消息。 ### 音频输入 -使用 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] 以流式传输方式发送原始音频字节: +使用[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]流式传输原始音频字节: ```python await session.send_audio(audio_bytes) ``` -如果禁用了服务端轮次检测,你需要负责标记轮次边界。高级便捷用法如下: +如果禁用了服务端轮次检测,则需要自行标记轮次边界。高层便捷方法如下: ```python await session.send_audio(audio_bytes, commit=True) ``` -如果需要更底层的控制,也可以通过底层模型传输层发送原始客户端事件,例如 `input_audio_buffer.commit`。 +如果需要更底层的控制,也可以通过底层模型传输层发送原始客户端事件,例如`input_audio_buffer.commit`。 ### 手动响应控制 -`session.send_message()` 使用高级路径发送用户输入,并为你启动响应。原始音频缓冲在所有配置下**并不会**自动执行相同操作。 +`session.send_message()`通过高层路径发送用户输入,并自动启动响应。原始音频缓冲在所有配置中**并不**都会自动执行相同操作。 -在 Realtime API 层面,手动轮次控制意味着使用原始 `session.update` 清除 `turn_detection`,然后自行发送 `input_audio_buffer.commit` 和 `response.create`。 +在Realtime API层面,手动控制轮次意味着通过原始`session.update`清除`turn_detection`,然后自行发送`input_audio_buffer.commit`和`response.create`。 -如果你要手动管理轮次,可以通过模型传输层发送原始客户端事件: +如果你正在手动管理轮次,可以通过模型传输层发送原始客户端事件: ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -151,15 +151,15 @@ await session.model.send_event( 此模式适用于以下情况: -- 已禁用 `turn_detection`,且你希望自行决定模型何时响应 -- 希望在触发响应前检查或限制用户输入 +- 已禁用`turn_detection`,并且你希望自行决定模型何时响应 +- 希望在触发响应前检查或控制用户输入 - 需要为带外响应使用自定义提示词 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) 中的 SIP 示例使用原始 `response.create` 强制发送开场问候语。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)中的SIP代码示例使用原始`response.create`强制生成开场问候语。 -## 事件、历史记录与中断 +## 事件、历史记录与打断 -`RealtimeSession` 会发出更高级的 SDK 事件,同时在需要时仍会转发原始模型事件。 +`RealtimeSession`会发出更高层的SDK事件,同时仍会转发原始模型事件,以便在需要时使用。 重要的会话事件包括: @@ -173,13 +173,13 @@ await session.model.send_event( - `error` - `raw_model_event` -对 UI 状态最有用的事件通常是 `history_added` 和 `history_updated`。它们会以 `RealtimeItem` 对象形式公开会话的本地历史记录,其中包括用户消息、助手消息和工具调用。 +对UI状态最有用的事件通常是`history_added`和`history_updated`。它们以`RealtimeItem`对象的形式公开会话的本地历史记录,其中包括用户消息、助手消息和工具调用。 ### 用量统计 -当已完成的模型响应包含用量信息时,OpenAI实时模型会在 `raw_model_event` 中发出 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其 `usage` 字段包含该响应的 token 数量,而 `input_tokens_details` 和 `output_tokens_details` 提供可选的模态细分数据。 +当已完成的模型响应包含用量信息时,OpenAI实时模型会在`raw_model_event`中发出[`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其`usage`字段包含该响应的令牌计数,而`input_tokens_details`和`output_tokens_details`提供可选的模态细分信息。 -会话还会将每个响应的用量添加到共享的 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] 中。可在后续的高级事件(例如 `agent_end`)中通过 `event.info.context.usage` 读取它,以查看实时会话的累计用量。 +会话还会将每个响应的用量添加到共享的[`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]中。可以从后续高层事件(例如`agent_end`)的`event.info.context.usage`中读取该值,以检查实时会话的累计用量。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,15 +197,15 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -仅当模型提供方在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该 `RealtimeSession` 收到的响应,并非跨会话总计。 +只有当模型提供商在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该`RealtimeSession`收到的响应,并不是跨会话的总量。 -### 中断与播放追踪 +### 打断与播放跟踪 -当用户打断助手时,会话会发出 `audio_interrupted` 并更新历史记录,使服务端对话与用户实际听到的内容保持一致。 +当用户打断助手时,会话会发出`audio_interrupted`并更新历史记录,使服务端对话与用户实际听到的内容保持一致。 -对于低延迟本地播放,默认播放追踪器通常已足够。在远程或延迟播放场景中,尤其是电话场景,应使用 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],使中断截断基于实际播放进度,而不是假设所有已生成的音频都已播放给用户。 +对于低延迟本地播放,默认播放跟踪器通常已经足够。在远程或延迟播放场景中,尤其是电话场景,应使用[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],使打断时的截断操作基于实际播放进度,而不是假定所有已生成音频均已播放给用户。 -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) 中的 Twilio 示例展示了此模式。 +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)中的Twilio代码示例展示了此模式。 ## 工具、审批、任务转移与安全防护措施 @@ -232,9 +232,9 @@ agent = RealtimeAgent( ### 工具审批 -工具调用可以要求在执行前进行人工审批。发生这种情况时,会话会发出 `tool_approval_required`,并暂停工具运行,直到你调用 `approve_tool_call()` 或 `reject_tool_call()`。 +工具调用可以要求在执行前进行人工审批。发生这种情况时,会话会发出`tool_approval_required`,并暂停工具执行,直到你调用`approve_tool_call()`或`reject_tool_call()`。 -如果工具还具有输入安全防护措施,则这些安全防护措施会在审批后、执行前立即运行。若要在发出审批事件之前运行它们,请使用 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` 创建运行器。通过该预审批检查的调用仍会在审批后、执行前再次接受检查。 +如果工具还具有输入安全防护措施,这些安全防护措施会在审批后、执行前立即运行。若要在发出审批事件前运行它们,请使用`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`创建运行器。通过此审批前检查的调用,在审批后、执行前仍会再次接受检查。 ```python async for event in session: @@ -242,11 +242,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -有关具体的服务端审批循环,请参阅 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。[人工介入](../human_in_the_loop.md)文档也介绍了此流程。 +有关具体的服务端审批循环,请参阅[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。人工介入文档中的[人工介入](../human_in_the_loop.md)也会引用此流程。 ### 任务转移 -实时任务转移允许一个智能体将实时对话转交给另一个专家智能体: +实时任务转移允许一个智能体将实时对话转交给另一个专用智能体: ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +268,11 @@ main_agent = RealtimeAgent( ) ``` -直接使用的 `RealtimeAgent` 任务转移会被自动封装,而 `realtime_handoff(...)` 允许你自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的 `input_filter`。 +直接使用的`RealtimeAgent`任务转移会被自动包装,而`realtime_handoff(...)`允许自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的`input_filter`。 ### 安全防护措施 -实时智能体支持对智能体响应使用输出安全防护措施,并支持对工具调用使用输入安全防护措施。输出安全防护措施基于经过防抖处理的转录文本累积结果运行,而不是针对每个部分 token 运行;触发时会发出 `guardrail_tripped`,而不是引发异常。 +实时智能体支持针对智能体响应的输出安全防护措施,以及针对工具调用的输入安全防护措施。输出安全防护措施会在经过防抖处理的输出文本和音频转录增量累积内容上运行,而不是在每个部分增量上运行;触发时会发出`guardrail_tripped`,而不是抛出异常。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,13 +292,15 @@ agent = RealtimeAgent( ) ``` -当实时输出安全防护措施被触发时,会话会中断当前响应,强制执行 `response.cancel`,发出 `guardrail_tripped`,并发送一条后续用户消息,其中包含被触发的安全防护措施名称,以便模型生成替代响应。你的音频播放器仍应监听 `audio_interrupted` 并立即停止本地播放,因为安全防护措施基于经过防抖处理的转录文本运行,触发机制生效时可能已有部分音频进入缓冲区。 +当实时输出安全防护措施因音频转录而触发时,会话会打断当前响应,强制发出`response.cancel`,发出`guardrail_tripped`,并发送一条注明已触发安全防护措施的后续用户消息,以便模型生成替代响应。音频播放器仍应监听`audio_interrupted`并立即停止本地播放,因为触发条件生效时,部分音频可能已经进入缓冲区。使用内置OpenAI实时传输实现时,如果安全防护措施在其源响应结束后才完成,会话只会打断该响应的缓冲播放,而不会取消较新的响应。对于纯文本输出,会话会改为发送仅针对该响应的`response.cancel`;由于没有需要停止的音频播放,因此不会发出`audio_interrupted`。使用内置OpenAI实时模型时,纯文本路径也会发出相同的`guardrail_tripped`事件和后续用户消息。 -## SIP 与电话 +自定义`RealtimeModel`传输实现必须遵循`RealtimeModelSendInterrupt.response_id`和`playback_only`,以提供相同的、限定于源响应的音频打断行为。它们还必须重写`RealtimeModel.send_event_if()`,以支持纯文本恢复消息。实现必须在传输层的实际事件提交边界重新检查给定条件,或对该条件的检查进行串行化。默认实现会安全地跳过恢复消息,因为如果在等待`send_event()`前检查条件,较新的响应可能会在消息提交前启动;响应取消和`guardrail_tripped`事件仍会发生。 -Python SDK 通过 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] 提供一流的 SIP 挂接流程。 +## SIP与电话 -当呼叫通过 Realtime Calls API 到达,且你希望将智能体会话挂接到生成的 `call_id` 时,请使用此流程: +Python SDK通过[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]提供一流的SIP附加流程。 + +当呼叫通过Realtime Calls API到达,并且你希望将智能体会话附加到生成的`call_id`时,请使用该流程: ```python from agents.realtime import RealtimeRunner @@ -315,20 +317,20 @@ async with await runner.run( ... ``` -如果需要先接听呼叫,并希望接听载荷与基于智能体生成的会话配置保持一致,请使用 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程可参阅 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 +如果需要先接受呼叫,并希望接受载荷与根据智能体生成的会话配置保持一致,请使用`OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程见[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 ## 底层访问与自定义端点 -可以通过 `session.model` 访问底层传输对象。 +可以通过`session.model`访问底层传输对象。 -以下情况需要使用此对象: +以下情况可使用此对象: -- 通过 `session.model.add_listener(...)` 添加自定义监听器 -- 发送原始客户端事件,例如 `response.create` 或 `session.update` -- 通过 `model_config` 自定义 `url`、`headers` 或 `api_key` 处理 -- 使用 `call_id` 挂接到现有实时呼叫 +- 通过`session.model.add_listener(...)`添加自定义监听器 +- 发送原始客户端事件,例如`response.create`或`session.update` +- 通过`model_config`自定义`url`、`headers`或`api_key`处理 +- 使用`call_id`附加到现有实时呼叫 -`RealtimeModelConfig` 支持: +`RealtimeModelConfig`支持: - `api_key` - `url` @@ -337,9 +339,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -此代码仓库提供的 `call_id` 示例使用 SIP。更广泛的 Realtime API 也会在某些服务端控制流程中使用 `call_id`,但此处未将这些流程打包为 Python 示例。 +本仓库随附的`call_id`代码示例使用SIP。更广泛的Realtime API也会将`call_id`用于某些服务端控制流程,但此处并未将这些流程作为Python代码示例提供。 -连接 Azure OpenAI 时,请传入正式发布版(GA)的 Realtime 端点 URL 和显式请求头。例如: +连接Azure OpenAI时,请传入正式版Realtime端点URL和显式标头。例如: ```python session = await runner.run( @@ -350,7 +352,7 @@ session = await runner.run( ) ``` -对于基于 token 的身份验证,请在 `headers` 中使用 bearer token: +对于基于令牌的身份验证,请在`headers`中使用Bearer令牌: ```python session = await runner.run( @@ -361,7 +363,7 @@ session = await runner.run( ) ``` -如果传入 `headers`,SDK 不会自动添加 `Authorization`。使用实时智能体时,应避免使用旧版 beta 路径(`/openai/realtime?api-version=...`)。 +如果传入`headers`,SDK不会自动添加`Authorization`。使用实时智能体时,请避免使用旧版Beta路径(`/openai/realtime?api-version=...`)。 ## 延伸阅读 diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index 72ef2a62e4..b7a13cfff2 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -4,11 +4,11 @@ search: --- # 智能体运行 -你可以通过[`Runner`][agents.run.Runner]类运行智能体。共有 3 种方式: +你可以通过 [`Runner`][agents.run.Runner] 类运行智能体。你有 3 种选择: -1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回[`RunResult`][agents.result.RunResult]。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,底层直接运行`.run()`。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回[`RunResultStreaming`][agents.result.RunResultStreaming]。它会以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 +1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回 [`RunResult`][agents.result.RunResult]。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其内部只是运行 `.run()`。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 ```python from agents import Agent, Runner @@ -23,26 +23,26 @@ async def main(): # Infinite loop's dance ``` -有关更多信息,请参阅[结果指南](results.md)。 +请在[结果指南](results.md)中了解更多信息。 -## 运行器生命周期与配置 +## Runner 生命周期与配置 ### 智能体循环 -使用`Runner`中的运行方法时,你需要传入一个起始智能体和输入。输入可以是: +使用 `Runner` 中的运行方法时,你需要传入一个起始智能体和输入。输入可以是: -- 字符串(作为用户消息处理), +- 字符串(视为用户消息), - OpenAI Responses API 格式的输入项列表,或 -- 恢复中断的运行时使用的[`RunState`][agents.run_state.RunState]。 +- 恢复中断的运行时使用的 [`RunState`][agents.run_state.RunState]。 随后,运行器会执行一个循环: -1. 使用当前输入为当前智能体调用 LLM。 +1. 我们使用当前输入为当前智能体调用 LLM。 2. LLM 生成输出。 - 1. 如果 LLM 返回`final_output`,循环结束并返回结果。 + 1. 如果 LLM 返回 `final_output`,循环结束并返回结果。 2. 如果 LLM 执行任务转移,我们会更新当前智能体和输入,然后重新运行循环。 3. 如果 LLM 生成工具调用,我们会运行这些工具调用、追加结果,然后重新运行循环。 -3. 如果超过传入的`max_turns`,则引发[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]异常。传入`max_turns=None`可禁用此轮次限制。 +3. 如果超过传入的 `max_turns`,我们会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 !!! note @@ -50,19 +50,19 @@ async def main(): ### 流式传输 -流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming]将包含此次运行的完整信息,包括生成的所有新输出。你可以调用`.stream_events()`获取流式事件。有关更多信息,请参阅[流式传输指南](streaming.md)。 +流式传输允许你在 LLM 运行时额外接收流式传输事件。流式传输结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含此次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式传输事件。请在[流式传输指南](streaming.md)中了解更多信息。 #### Responses WebSocket 传输(可选辅助工具) -如果启用 OpenAI Responses WebSocket 传输,你仍可继续使用常规的`Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 +如果启用 OpenAI Responses websocket 传输,你仍然可以继续使用常规的 `Runner` API。建议使用 websocket 会话辅助工具来复用连接,但这不是必需的。 -这是通过 WebSocket 传输使用 Responses API,而不是[Realtime API](realtime/guide.md)。 +这是基于 websocket 传输的 Responses API,而不是 [Realtime API](realtime/guide.md)。 -有关传输方式选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 +有关传输选择规则以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 -##### 模式 1:不使用会话辅助工具(可用) +##### 模式 1:不使用会话辅助工具(可行) -如果你只希望使用 WebSocket 传输,且不需要 SDK 为你管理共享的提供商或会话,请使用此模式。 +如果你只需要 websocket 传输,而不需要 SDK 为你管理共享提供商或会话,请使用此模式。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -此模式适用于单次运行。如果反复调用`Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个`RunConfig` / 提供商实例,否则每次运行都可能重新连接。 +此模式适用于单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个 `RunConfig` / 提供商实例,否则每次运行都可能重新连接。 -##### 模式 2:使用`responses_websocket_session()`(建议用于多轮复用) +##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) -如果希望在多次运行之间共享支持 WebSocket 的提供商和`RunConfig`,请使用[`responses_websocket_session()`][agents.responses_websocket_session],这也包括继承相同`run_config`的嵌套智能体工具调用。 +如果希望在多次运行之间共享支持 websocket 的提供商和 `RunConfig`(包括继承同一 `run_config` 的嵌套智能体工具调用),请使用 [`responses_websocket_session()`][agents.responses_websocket_session]。 ```python import asyncio @@ -119,58 +119,59 @@ async def main(): asyncio.run(main()) ``` -请在上下文退出前完成对流式结果的消费。如果 WebSocket 请求仍在处理中便退出上下文,可能会强制关闭共享连接。 +请在退出上下文之前完成流式传输结果的消费。如果 websocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 -该服务在每个 WebSocket 连接上一次处理一个响应,并将单个连接限制为 60 分钟。辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False`和 ZDR 流程无法恢复未缓存的`previous_response_id`;请使用完整输入上下文启动新链,或根据本地管理的会话状态进行重建。有关完整的恢复行为,请参阅[Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 +服务会在每个 websocket 连接上一次处理一个响应,并将每个连接的时长限制为 60 分钟。该辅助工具会复用连接,但不会解除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文启动一条新链,或根据本地管理的会话状态重建该链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 -如果较长的推理轮次触发 WebSocket 保活超时,请增大`ping_timeout`,或设置`ping_timeout=None`以禁用心跳超时。对于可靠性比 WebSocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 +如果长时间推理轮次触发 websocket keepalive 超时,请增大 `ping_timeout`,或设置 `ping_timeout=None` 以禁用心跳超时。对于可靠性比 websocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 ### 运行配置 -`run_config`参数允许你为智能体运行配置一些全局设置: +`run_config` 参数可用于配置智能体运行的一些全局设置: -#### 常用运行配置类别 +#### 常用运行配置目录 -使用`RunConfig`可在不更改各个智能体定义的情况下,覆盖单次运行的行为。 +使用 `RunConfig` 可覆盖单次运行的行为,而无需更改每个智能体的定义。 ##### 模型、提供商和会话默认值 -- [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个智能体的`model`设置。 -- [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 -- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,你可以设置全局`temperature`或`top_p`。 -- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认值(例如`SessionSettings(limit=...)`)。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用会话时,自定义每轮开始前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 +- [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个 Agent 所设置的 `model`。 +- [`model_provider`][agents.run.RunConfig.model_provider]:用于按名称查找模型的模型提供商,默认为 OpenAI。 +- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,你可以设置全局 `temperature` 或 `top_p`。 +- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认值(例如 `SessionSettings(limit=...)`)。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每轮开始前将新用户输入与会话历史记录合并的方式。该回调可以是同步或异步的。 -##### 安全防护措施、任务转移和模型输入调整 +##### 安全防护措施、任务转移和模型输入塑形 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要在所有运行中包含的输入或输出安全防护措施列表。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未设置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详细信息,请参阅[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]文档。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选择启用的 Beta 功能,在调用下一个智能体前,将可摘要的历史记录压缩为按序排列的助手摘要片段,同时在原始位置保留无损消息项。在我们稳定嵌套任务转移功能期间,此功能默认禁用;将其设置为`True`可启用,保留为`False`则会原样传递原始记录。当 SDK 默认的嵌套历史记录已包含某条消息时,会话、`RunState`和`RunResult.to_input_list()`会避免重复追加该消息的同一次出现,同时仍保留彼此独立但内容相同的消息。如果你未传入`RunConfig`,所有[运行器方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例中的该默认功能仍处于关闭状态,而任何显式的[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter]回调仍会覆盖它。各个任务转移可以通过[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]覆盖此设置。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:选择启用`nest_handoff_history`时调用的可选函数,它会接收规范化的记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的准确输入项列表,在无需编写完整任务转移过滤器的情况下,替换内置的按序摘要片段。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如修剪历史记录或注入系统提示词。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未设置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详情,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 的文档。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选启用的测试版功能,在调用下一个智能体之前,将可总结的历史记录压缩为有序的助手摘要片段,同时在原始位置无损保留消息项。在我们完善嵌套任务转移期间,此功能默认禁用;设置为 `True` 可启用,保留为 `False` 则会直接传递原始记录。当 SDK 默认的嵌套历史记录已包含某条消息时,Sessions、`RunState` 和 `RunResult.to_input_list()` 会避免再次追加完全相同的消息实例,同时仍会保留彼此独立但内容相同的消息。如果你未传入 `RunConfig`,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,而任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。各项任务转移可通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:一个可选的可调用对象,在你选择启用 `nest_handoff_history` 时接收规范化记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的准确输入项列表,从而替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:用于在调用模型前立即编辑已完全准备好的模型输入(instructions 和输入项)的钩子,例如裁剪历史记录或注入系统提示词。 - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制运行器将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 ##### 追踪与可观测性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许为整个运行禁用[追踪](tracing.md)。 -- [`tracing`][agents.run.RunConfig.tracing]:传入[`TracingConfig`][agents.tracing.TracingConfig]以覆盖追踪导出设置,例如每次运行的追踪 API 密钥。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在敏感数据,例如 LLM 和工具调用的输入/输出。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置`workflow_name`。组 ID 是一个可选字段,用于关联多次运行中的追踪。 +- [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],以覆盖追踪导出设置,例如每次运行使用的追踪 API 密钥。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在的敏感数据,例如 LLM 和工具调用的输入/输出。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行的追踪。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 -##### 工具执行、审批和工具错误行为 +##### 工具执行、审批与工具错误行为 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用的 SDK 端执行行为,例如限制同时运行的工具调用数量。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置运行器如何处理模型生成但无法解析的工具调用。默认行为是引发`ModelBehaviorError`;你可以选择改为返回模型可见的错误输出。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批拒绝和选择启用的“工具未找到”输出。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 端的执行行为,例如限制同时运行的工具调用数量。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置运行器如何处理模型发出的、无法解析的工具调用。默认行为是引发 `ModelBehaviorError`;也可以选择改为返回模型可见的错误输出。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置运行器如何处理发生冲突的无命名空间工具调用名称和任务转移名称。默认值 `"warn"` 会记录一条可操作的警告,并且只公开当前分派的胜出项;`"error"` 会在调用模型之前引发 `UserError`。对具有命名空间和延迟加载工具的严格验证保持不变。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒和选择启用的工具未找到输出。 -嵌套任务转移是一项可选择启用的 Beta 功能。传入`RunConfig(nest_handoff_history=True)`可启用按序记录压缩,也可设置`handoff(..., nest_handoff_history=True)`,仅为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段置于无损消息项周围,而不是将整个记录压缩成一条消息。如果你希望保留原始记录(默认行为),请勿设置此标志,或提供一个根据需要准确转发对话的`handoff_input_filter`(或`handoff_history_mapper`)。如需更改生成的摘要片段中使用的包装文本,而不编写自定义映射器,请调用[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并使用[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]恢复默认设置)。 +嵌套任务转移是一项可选启用的测试版功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序记录压缩,或设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段放置在无损消息项周围,而不是将整个记录合并为一条消息。如果希望保留原始记录(默认行为),请不要设置此标志,或提供按需准确转发对话的 `handoff_input_filter`(或 `handoff_history_mapper`)。如果希望更改生成的摘要片段所使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 恢复默认值)。 #### 运行配置详情 ##### `tool_execution` -如果希望配置本地工具调用的 SDK 端行为,例如限制某次运行中的本地工具调用并发数,请使用`tool_execution`。 +如果希望配置本地工具调用在 SDK 端的行为,例如限制一次运行中本地工具调用的并发数量,请使用 `tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -189,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`会保留默认行为:当模型在一轮中生成多个工具调用时,SDK 会启动所有已生成的本地工具调用。设置一个整数值,可以限制同时运行的本地工具调用数量。 +`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中发出多个工具调用时,SDK 会启动所有已发出的本地工具调用。将其设置为整数值,可限制同时运行的本地工具调用数量。 -这与提供商端的[`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]相互独立。`parallel_tool_calls`控制是否允许模型在单个响应中生成多个工具调用。`tool_execution.max_function_tool_concurrency`控制模型生成工具调用后,SDK 如何执行本地工具调用。 +这与提供商端的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地工具调用后,SDK 如何执行这些调用。 -`pre_approval_tool_input_guardrails=False`会保留默认审批流程:如果工具调用需要审批,运行会先暂停,并且仅在审批通过后、执行前立即运行工具输入安全防护措施。如果希望在发出待审批中断前运行工具调用输入安全防护措施,请将其设置为`True`。通过此次审批前检查的调用仍会在审批通过后再次运行相同的输入安全防护措施,以便在执行前重新验证时效性检查。 +`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果工具调用需要审批,运行会先暂停,并且工具输入安全防护措施仅在审批后、紧接执行前运行。如果希望工具调用输入安全防护措施在发出待审批中断前运行,请将其设置为 `True`。通过此审批前检查的调用仍会在审批后再次运行相同的输入安全防护措施,因此执行前会重新验证时效性检查。 ##### `tool_not_found_behavior` -默认情况下,如果模型生成的工具调用与当前智能体可用的任何工具调用都不匹配,运行器会引发`ModelBehaviorError`。 +默认情况下,如果模型发出的工具调用与当前智能体可用的任何工具调用都不匹配,运行器会引发 `ModelBehaviorError`。 -如果希望运行仍可恢复,请设置`tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为无法解析的工具调用追加一个`function_call_output`,然后再次运行模型,使模型能够选择可用工具,或在不使用该工具的情况下作答。 +如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在该模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,以便模型选择可用工具,或在不使用该工具的情况下作答。 ```python from agents import Agent, RunConfig, Runner @@ -213,22 +214,22 @@ result = await Runner.run( ) ``` -目前,此选项仅适用于无法解析的工具调用。其他无效工具负载仍会使用其现有错误处理行为。 +此选项目前仅适用于无法解析的工具调用。其他无效工具有效负载仍沿用现有的错误处理行为。 ##### `tool_error_formatter` -当 SDK 创建模型可见的工具错误输出时,可使用`tool_error_formatter`自定义返回给模型的消息。 +使用 `tool_error_formatter` 可自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 -格式化器接收包含以下字段的[`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]: +格式化器接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: -- `kind`:错误目录,例如`"approval_rejected"`或`"tool_not_found"`。 -- `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`或`"custom"`)。 +- `kind`:错误目录,例如 `"approval_rejected"` 或 `"tool_not_found"`。 +- `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"` 或 `"custom"`)。 - `tool_name`:工具名称。 - `call_id`:工具调用 ID。 - `default_message`:SDK 默认的模型可见消息。 - `run_context`:当前运行上下文包装器。 -返回字符串可替换该消息,返回`None`则使用 SDK 默认值。 +返回字符串可替换该消息;返回 `None` 则使用 SDK 默认值。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -255,22 +256,22 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -当运行器向后传递历史记录时(例如使用`RunResult.to_input_list()`或基于会话的运行),`reasoning_item_id_policy`控制如何将推理项转换为下一轮模型输入。 +`reasoning_item_id_policy` 控制运行器向后传递历史记录时,如何将推理项转换为下一轮模型输入(例如使用 `RunResult.to_input_list()` 或由会话支持的运行时)。 -- `None`或`"preserve"`(默认值):保留推理项 ID。 +- `None` 或 `"preserve"`(默认):保留推理项 ID。 - `"omit"`:从生成的下一轮输入中移除推理项 ID。 -`"omit"`主要用于选择性缓解一类 Responses API 400 错误:发送的推理项带有`id`,但缺少后续必需项(例如`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` 主要用作一种可选启用的缓解措施,用于处理一类 Responses API 400 错误:发送的推理项包含 `id`,但缺少所需的后续项(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -在多轮智能体运行中,当 SDK 根据先前输出构建后续输入时,可能会发生这种情况,其中包括会话持久化、服务管理的对话增量、流式传输/非流式传输的后续轮次,以及恢复路径。如果推理项 ID 被保留,但提供商要求该 ID 必须与其对应的后续项配对,就会触发此错误。 +这种情况可能发生在多轮智能体运行中:SDK 根据先前输出构建后续输入(包括会话持久化、服务端管理的对话增量、流式传输/非流式传输的后续轮次以及恢复路径),并保留了推理项 ID,但提供商要求该 ID 必须与其对应的后续项保持配对。 -设置`reasoning_item_id_policy="omit"`会保留推理内容,但移除推理项的`id`,从而避免 SDK 生成的后续输入触发该 API 不变量。 +设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量。 作用范围说明: -- 这仅会更改 SDK 在构建后续输入时生成或转发的推理项。 +- 这只会更改 SDK 构建后续输入时生成或转发的推理项。 - 它不会重写用户提供的初始输入项。 -- 应用此策略后,`call_model_input_filter`仍可有意重新引入推理 ID。 +- 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 ## 状态与对话管理 @@ -278,33 +279,33 @@ result = Runner.run_sync( 将状态带入下一轮通常有四种方式: -| 策略 | 状态存储位置 | 最适合 | 下一轮传入的内容 | +| 策略 | 状态存储位置 | 最适用场景 | 下一轮传入内容 | | --- | --- | --- | --- | -| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()`返回的列表加上下一条用户消息 | -| `session` | 你的存储加 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个`session`实例,或指向同一存储的另一个实例 | -| `conversation_id` | OpenAI Conversations API | 希望在工作进程或服务之间共享的具名服务端对话 | 相同的`conversation_id`加上新的用户轮次 | -| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务管理续接 | `result.last_response_id`加上新的用户轮次 | +| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()` 返回的列表加上下一条用户消息 | +| `session` | 你的存储加上 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | +| `conversation_id` | OpenAI Conversations API | 希望在多个工作进程或服务之间共享的具名服务端对话 | 同一个 `conversation_id`,并且只传入新的用户轮次 | +| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务端管理续接 | `result.last_response_id`,并且只传入新的用户轮次 | -`result.to_input_list()`和`session`由客户端管理。`conversation_id`和`previous_response_id`由OpenAI管理,并且仅适用于使用 OpenAI Responses API 的情况。在大多数应用中,请为每个对话选择一种持久化策略。除非你有意协调这两个层级,否则混用客户端管理的历史记录和OpenAI管理的状态可能导致上下文重复。 +`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅在使用 OpenAI Responses API 时适用。在大多数应用中,每个对话应选择一种持久化策略。除非你有意协调这两个层级,否则混合使用客户端管理的历史记录与 OpenAI 管理的状态可能导致上下文重复。 !!! note - 在同一次运行中,会话持久化不能与服务管理的对话设置 - (`conversation_id`、`previous_response_id`或`auto_previous_response_id`) + 会话持久化不能在同一次运行中与服务端管理的对话设置 + (`conversation_id`、`previous_response_id` 或 `auto_previous_response_id`) 结合使用。每次调用请选择一种方式。 ### 对话/聊天线程 -调用任何运行方法都可能导致一个或多个智能体运行(因此会进行一次或多次 LLM 调用),但它表示聊天对话中的一个逻辑轮次。例如: +调用任何运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但这在聊天对话中只代表一个逻辑轮次。例如: 1. 用户轮次:用户输入文本 -2. 运行器运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体,第二个智能体运行更多工具,然后生成输出。 +2. 运行器运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 -智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项,也可以只显示最终输出。无论采用哪种方式,用户之后都可能提出后续问题,此时可以再次调用运行方法。 +智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论采用哪种方式,用户之后都可能提出后续问题,此时你可以再次调用运行方法。 #### 手动对话管理 -你可以使用[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list]方法手动管理对话历史记录,以获取下一轮的输入: +你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法手动管理对话历史记录,以获取下一轮的输入: ```python from agents import Agent, Runner, trace @@ -326,9 +327,9 @@ async def main(): # California ``` -#### 使用会话的自动对话管理 +#### 使用会话自动管理对话 -如需更简单的方法,可以使用[会话](sessions/index.md)自动处理对话历史记录,而无需手动调用`.to_input_list()`: +若要采用更简单的方式,可以使用 [Sessions](sessions/index.md) 自动处理对话历史记录,而无需手动调用 `.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -352,22 +353,22 @@ async def main(): # California ``` -会话会自动: +Sessions 会自动: - 在每次运行前检索对话历史记录 - 在每次运行后存储新消息 - 为不同的会话 ID 维护独立的对话 -有关更多详细信息,请参阅[会话文档](sessions/index.md)。 +有关更多详情,请参阅 [Sessions 文档](sessions/index.md)。 -#### 服务管理的对话 +#### 服务端管理的对话 -你也可以让OpenAI对话状态功能在服务端管理对话状态,而不是在本地使用`to_input_list()`或`Sessions`进行处理。这样便可保留对话历史记录,而无需手动重新发送所有过去的消息。使用以下任一服务管理方式时,每次请求仅传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅[OpenAI对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 +你也可以让 OpenAI 对话状态功能在服务端管理对话状态,而不是使用 `to_input_list()` 或 `Sessions` 在本地处理。这让你无需手动重新发送所有历史消息,即可保留对话历史记录。使用以下任一服务端管理方式时,每次请求只需传入新轮次的输入并复用保存的 ID。有关更多详情,请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 -OpenAI提供两种跨轮次追踪状态的方式: +OpenAI 提供两种跨轮次追踪状态的方式: -##### 1. 使用`conversation_id` +##### 1. 使用 `conversation_id` 首先使用 OpenAI Conversations API 创建对话,然后在后续每次调用中复用其 ID: @@ -390,9 +391,9 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -##### 2. 使用`previous_response_id` +##### 2. 使用 `previous_response_id` -另一种方式是**响应链式衔接**,即每个轮次都显式链接到上一轮的响应 ID。 +另一种方式是**响应链式衔接**,其中每一轮都会显式链接到上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -417,30 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -如果运行暂停以等待审批,并且你从[`RunState`][agents.run_state.RunState]恢复运行,SDK 会保留已保存的`conversation_id` / `previous_response_id` / `auto_previous_response_id`设置,以便恢复后的轮次继续使用同一个服务管理的对话。 +如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,使恢复后的轮次继续使用同一个服务端管理的对话。 -`conversation_id`和`previous_response_id`互斥。如果需要可在不同系统间共享的具名对话资源,请使用`conversation_id`。如果需要最轻量的 Responses API 基本组件来续接相邻轮次,请使用`previous_response_id`。 +`conversation_id` 和 `previous_response_id` 互斥。如果需要一个可跨系统共享的具名对话资源,请使用 `conversation_id`。如果希望使用最轻量的 Responses API 基本组件从一个轮次续接到下一轮,请使用 `previous_response_id`。 !!! note - SDK 会通过退避机制自动重试`conversation_locked`错误。在服务管理的 - 对话运行中,它会在重试前回退内部对话追踪器的输入,以便清晰地重新发送 - 相同的已准备项。 + SDK 会自动以退避方式重试 `conversation_locked` 错误。在服务端管理的 + 对话运行中,它会在重试前回退内部对话追踪器输入,以便 + 清晰地重新发送同一批已准备好的项目。 - 在基于本地会话的运行中(无法与`conversation_id`、 - `previous_response_id`或`auto_previous_response_id`结合使用),SDK 还会尽力 - 回滚最近持久化的输入项,以减少重试后出现重复的历史记录条目。 + 在基于本地会话的运行中(它不能与 `conversation_id`、 + `previous_response_id` 或 `auto_previous_response_id` 结合使用),SDK 还会尽最大努力 + 回滚最近持久化的输入项,以减少重试后产生重复的历史记录条目。 - 即使没有配置`ModelSettings.retry`,也会执行此兼容性重试。有关模型请求中 - 更广泛的可选择启用重试行为,请参阅[运行器管理的重试](models/index.md#runner-managed-retries)。 + 即使未配置 `ModelSettings.retry`,也会执行此兼容性重试。有关 + 更广泛的可选模型请求重试行为,请参阅[由 Runner 管理的重试](models/index.md#runner-managed-retries)。 ## 钩子与自定义 ### 模型调用输入过滤器 -使用`call_model_input_filter`可在模型调用前编辑模型输入。该钩子接收当前智能体、上下文和合并后的输入项(包括存在的会话历史记录),并返回新的`ModelInputData`。 +使用 `call_model_input_filter` 可在模型调用前编辑模型输入。该钩子接收当前智能体、上下文以及合并后的输入项(包括会话历史记录,如有),并返回新的 `ModelInputData`。 -返回值必须是[`ModelInputData`][agents.run.ModelInputData]对象。其`input`字段为必填项,并且必须是输入项列表。返回任何其他结构都会引发`UserError`。 +返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填项,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 ```python from agents import Agent, Runner, RunConfig @@ -459,19 +460,19 @@ result = Runner.run_sync( ) ``` -运行器会将已准备输入列表的副本传给该钩子,因此你可以修剪、替换或重新排序,而不会就地修改调用方的原始列表。 +运行器会将已准备好的输入列表副本传给钩子,因此你可以对其进行裁剪、替换或重新排序,而不会就地修改调用方的原始列表。 -如果使用会话,`call_model_input_filter`会在会话历史记录加载完毕并与当前轮次合并后运行。如果希望自定义更早的合并步骤本身,请使用[`session_input_callback`][agents.run.RunConfig.session_input_callback]。 +如果使用会话,`call_model_input_filter` 会在会话历史记录加载并与当前轮次合并后运行。如果希望自定义前面的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 -如果通过`conversation_id`、`previous_response_id`或`auto_previous_response_id`使用OpenAI服务管理的对话状态,该钩子会在为下一次 Responses API 调用准备的负载上运行。该负载可能已经只表示新轮次的增量,而不是对先前历史记录的完整重放。只有你返回的项才会被标记为已发送,用于该服务管理的续接。 +如果通过 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 使用 OpenAI 服务端管理的对话状态,该钩子会针对下一次 Responses API 调用已准备好的有效负载运行。该有效负载可能已经只表示新轮次的增量,而不是对先前完整历史记录的重放。只有你返回的项目会被标记为已发送,以用于该服务端管理的续接。 -可通过`run_config`为每次运行设置该钩子,以遮盖敏感数据、修剪过长的历史记录,或注入额外的系统指导。 +通过 `run_config` 为每次运行设置该钩子,可用于遮盖敏感数据、裁剪过长的历史记录或注入额外的系统指导信息。 ## 错误与恢复 -### 错误处理程序 +### 错误处理器 -所有`Runner`入口点均接受`error_handlers`,它是一个以错误类型为键的字典。支持的键为`"max_turns"`、`"model_refusal"`和`"invalid_final_output"`。如果希望返回受控的最终输出,而不是因相应错误而结束运行,请使用这些处理程序。 +所有 `Runner` 入口点都接受 `error_handlers`,它是一个以错误类型为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以对应错误结束运行,请使用这些键。 ```python from agents import ( @@ -500,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -当模型消息无法通过智能体的结构化`output_type`验证,或模型未返回结构化最终消息时,请使用`"invalid_final_output"`。处理程序可以返回应用特定的回退值,SDK 会根据相同的`output_type`对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回`None`表示放弃恢复。如果没有回退值,非空验证失败仍会引发`ModelBehaviorError`,而空的结构化响应会保留现有的下一轮行为。 +当模型消息无法通过智能体结构化 `output_type` 的验证,或模型没有返回结构化最终消息时,请使用 `"invalid_final_output"`。处理器可以返回应用特定的回退值,SDK 会使用相同的 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空验证失败仍会引发 `ModelBehaviorError`,而空结构化响应会保留现有的下一轮行为。 ```python from pydantic import BaseModel @@ -532,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -如果不希望将回退输出追加到对话历史记录,请设置`include_in_history=False`。 +`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理器,这会将合成的回退输出追加到对话历史记录中,并将其持久化到已配置的会话。如果希望将回退值返回给调用方,但不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 -当模型拒绝应生成应用特定的回退值,而不是以`ModelRefusalError`结束运行时,请使用`"model_refusal"`。 +如果模型拒绝响应时应生成应用特定的回退值,而不是以 `ModelRefusalError` 结束运行,请使用 `"model_refusal"`。 ```python from pydantic import BaseModel @@ -566,35 +567,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人工介入 +## 持久执行集成与人在回路 -有关工具审批的暂停/恢复模式,请先参阅专门的[人工介入指南](human_in_the_loop.md)。以下集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 +有关工具审批的暂停/恢复模式,请先参阅专门的[人在回路指南](human_in_the_loop.md)。以下集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 ### Dapr -你可以使用 Agents SDK 的[Dapr](https://dapr.io) Diagrid 集成,运行持久的长时间运行智能体。这些智能体支持人工介入,并可从故障中自动恢复。Dapr 是一个供应商中立的[CNCF](https://cncf.io)工作流编排器。可从[这里](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和OpenAI智能体。 +你可以使用 Agents SDK 的 [Dapr](https://dapr.io) Diagrid 集成,运行持久、长时间运行的智能体。这些智能体支持人在回路,并能自动从故障中恢复。Dapr 是一个厂商中立的 [CNCF](https://cncf.io) 工作流编排器。可从[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI 智能体。 ### Temporal -你可以使用 Agents SDK 的[Temporal](https://temporal.io/)集成来运行持久的长时间运行工作流,包括人工介入任务。可在[此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并可在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 +你可以使用 Agents SDK 的 [Temporal](https://temporal.io/) 集成运行持久、长时间运行的工作流,包括人在回路任务。你可以在[此视频](https://www.youtube.com/watch?v=fFBZqzT4DD8)中查看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 ### Restate -你可以使用 Agents SDK 的[Restate](https://restate.dev/)集成来构建轻量且持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务函数运行。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 +你可以使用 Agents SDK 的 [Restate](https://restate.dev/) 集成实现轻量级持久智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务函数运行。有关更多详情,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 ### DBOS -你可以使用 Agents SDK 的[DBOS](https://dbos.dev/)集成来运行可靠的智能体,并在故障和重启时保留进度。它支持长时间运行智能体、人工介入工作流和任务转移,同时支持同步和异步方法。该集成仅需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 +你可以使用 Agents SDK 的 [DBOS](https://dbos.dev/) 集成运行可靠的智能体,使其在故障和重启后仍能保留进度。它支持长时间运行的智能体、人在回路工作流和任务转移,同时支持同步和异步方法。该集成只需要一个 SQLite 或 Postgres 数据库。有关更多详情,请查看集成[仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 ## 异常 -SDK 会在特定情况下引发异常。完整列表请参阅[`agents.exceptions`][]。概述如下: +SDK 会在特定情况下引发异常。完整列表位于 [`agents.exceptions`][]。概述如下: -- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 内部引发的所有异常的基类。它是一种通用类型,所有其他特定异常均派生自该类型。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给`Runner.run`、`Runner.run_sync`或`Runner.run_streamed`方法的`max_turns`限制时,会引发此异常。它表示智能体无法在指定的交互轮次数内完成任务。设置`max_turns=None`可禁用此限制。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。可能包括: - - 格式错误的 JSON:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定`output_type`的情况下。 - - 意外的工具相关故障:模型未按预期方式使用工具 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当工具调用超过其配置的超时时间,并且工具使用`timeout_behavior="raise_exception"`时,会引发此异常。 -- [`UserError`][agents.exceptions.UserError]:当你(编写使用 SDK 的代码的人员)在使用 SDK 时出错,会引发此异常。这通常是由不正确的代码实现、无效配置或误用 SDK API 导致的。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:当分别满足输入安全防护措施或输出安全防护措施的条件时,会引发此异常。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file +- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 内引发的所有异常的基类。它是一个通用类型,所有其他特定异常均派生自此类。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。它表示智能体无法在指定的交互轮次数内完成任务。设置 `max_turns=None` 可禁用此限制。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效输出时,会发生此异常。这可能包括: + - 格式错误的 JSON:模型为工具调用或直接输出提供了格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 时。 + - 意外的工具相关故障:模型未能以预期方式使用工具 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会引发此异常。 +- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,会引发此异常。这通常是由代码实现不正确、配置无效或误用 SDK API 导致的。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:分别在满足输入安全防护措施或输出安全防护措施的条件时引发这些异常。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file diff --git a/docs/zh/streaming.md b/docs/zh/streaming.md index cc3a9dd821..7fb4e47080 100644 --- a/docs/zh/streaming.md +++ b/docs/zh/streaming.md @@ -4,19 +4,19 @@ search: --- # 流式传输 -流式传输允许你在智能体运行期间订阅其更新。这对于向最终用户展示进度更新和部分响应非常有用。 +流式传输允许你在智能体运行过程中订阅其更新。这对于向最终用户展示进度更新和部分响应非常有用。 -要使用流式传输,可以调用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed],它会返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。调用 `result.stream_events()` 会得到由 [`StreamEvent`][agents.stream_events.StreamEvent] 对象组成的异步流,下文将对其进行说明。 +要使用流式传输,可以调用[`Runner.run_streamed()`][agents.run.Runner.run_streamed],它将返回[`RunResultStreaming`][agents.result.RunResultStreaming]。调用`result.stream_events()`会提供一个由[`StreamEvent`][agents.stream_events.StreamEvent]对象组成的异步流,这些对象将在下文中介绍。 -应持续消费 `result.stream_events()`,直到异步迭代器结束。流式运行只有在迭代器结束后才算完成;会话持久化、审批记录处理或历史记录压缩等后处理操作,可能会在最后一个可见 token 到达后才完成。循环退出时,`result.is_complete` 会反映运行的最终状态。 +请持续消费`result.stream_events()`,直到异步迭代器结束。流式运行在迭代器结束前并未完成;会话持久化、审批状态记录或历史压缩等后处理可能会在最后一个可见 token 到达后完成。循环退出时,`result.is_complete`会反映最终的运行状态。 ## 原始响应事件 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 是直接从 LLM 传递的原始事件。它们采用 OpenAI Responses API格式,这意味着每个事件都有类型(例如 `response.created`、`response.output_text.delta` 等)和数据。如果你希望在响应消息生成后立即以流式方式发送给用户,这些事件会非常有用。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent]是直接从LLM传递而来的原始事件。它们采用OpenAI Responses API格式,这意味着每个事件都有一个类型(例如`response.created`、`response.output_text.delta`等)和相应数据。如果你希望在响应消息生成后立即将其流式传输给用户,这些事件会很有用。 -计算机工具的原始事件与已存储结果一样,会保留预览版与正式版(GA)之间的区别。预览版流程会流式传输包含单个 `action` 的 `computer_call` 项,而 `gpt-5.5` 可以流式传输包含批量 `actions[]` 的 `computer_call` 项。更高层级的 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 接口不会为此添加计算机工具专用的特殊事件名称:两种形式仍然都以 `tool_called` 呈现,而截图结果则以 `tool_output` 返回,其中封装了一个 `computer_call_output` 项。 +计算机工具的原始事件会保留与存储结果相同的预览版与正式版差异。预览版流程会流式传输包含单个`action`的`computer_call`项目,而`gpt-5.5`可以流式传输包含批量`actions[]`的`computer_call`项目。更高层级的[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]接口不会为此添加仅限计算机工具的特殊事件名称:这两种形式仍会以`tool_called`呈现,而截图结果则以封装`computer_call_output`项目的`tool_output`返回。 -例如,以下代码会逐 token 输出 LLM 生成的文本。 +例如,以下代码将逐 token 输出LLM生成的文本。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## 流式传输与审批 -流式传输兼容因等待工具审批而暂停的运行。如果某个工具需要审批,`result.stream_events()` 会结束,待处理的审批将通过 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 提供。使用 `result.to_state()` 将结果转换为 [`RunState`][agents.run_state.RunState],批准或拒绝中断项,然后通过 `Runner.run_streamed(...)` 恢复运行。 +流式传输与因工具审批而暂停的运行兼容。如果某个工具需要审批,`result.stream_events()`会结束,并且待处理的审批会在[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]中公开。使用`result.to_state()`将结果转换为[`RunState`][agents.run_state.RunState],批准或拒绝中断,然后通过`Runner.run_streamed(...)`恢复运行。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,25 +57,25 @@ if result.interruptions: pass ``` -有关完整的暂停/恢复流程,请参阅[人工介入指南](human_in_the_loop.md)。 +有关完整的暂停和恢复演示,请参阅[人在回路指南](human_in_the_loop.md)。 ## 当前轮次结束后的流式传输取消 -如果需要中途停止流式运行,请调用 [`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。若要让当前轮次完整结束后再停止,请改为调用 `result.cancel(mode="after_turn")`。 +如果需要中途停止流式运行,请调用[`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。若要让当前轮次完整结束后再停止,请改为调用`result.cancel(mode="after_turn")`。 -流式运行只有在 `result.stream_events()` 结束后才算完成。在最后一个可见 token 到达后,SDK 可能仍在持久化会话项、完成审批状态处理或压缩历史记录。 +在`result.stream_events()`结束之前,流式运行尚未完成。在最后一个可见 token 出现后,SDK可能仍在持久化会话项目、完成审批状态处理或压缩历史记录。 -如果你要手动基于 [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] 继续运行,并且 `cancel(mode="after_turn")` 在某个工具轮次后停止,请使用该规范化输入重新运行 `result.last_agent`,以继续尚未完成的轮次,而不要立即追加新的用户轮次。 -- 如果流式运行因等待工具审批而停止,请勿将其视为新的轮次。应完整消费流、检查 `result.interruptions`,然后从 `result.to_state()` 恢复运行。 -- 使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 自定义在下一次模型调用前,如何合并检索到的会话历史记录与新的用户输入。如果你在此处重写了新轮次中的项目,该轮次将持久化重写后的版本。 +如果你正通过[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]手动继续运行,并且`cancel(mode="after_turn")`在某个工具轮次后停止,请使用该规范化输入重新运行`result.last_agent`,以继续这一未完成的轮次,而不是立即追加一个新的用户轮次。 +- 如果流式运行因工具审批而停止,请勿将其视为新轮次。应先消费完流,检查`result.interruptions`,然后从`result.to_state()`恢复运行。 +- 使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可以自定义在下一次模型调用前,如何合并检索到的会话历史与新的用户输入。如果在此处重写新轮次项目,该轮次将持久化重写后的版本。 -## 运行项事件与智能体事件 +## 运行项目事件与智能体事件 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在某个项目完全生成后通知你。这样,你就可以按“消息已生成”“工具已运行”等粒度向用户推送进度更新,而不必逐 token 更新。类似地,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会在当前智能体发生变化时向你提供更新(例如,由任务转移引起的变化)。 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]是更高层级的事件。它们会在项目完全生成后通知你。这样,你就可以按“消息已生成”“工具已运行”等粒度向用户推送进度更新,而不是逐 token 推送。同样,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]会在当前智能体发生变化时提供更新(例如由任务转移导致的变化)。 -### 运行项事件名称 +### 运行项目事件名称 -`RunItemStreamEvent.name` 使用一组固定的语义事件名称: +`RunItemStreamEvent.name`使用一组固定的语义事件名称: - `message_output_created` - `handoff_requested` @@ -89,13 +89,15 @@ if result.interruptions: - `mcp_approval_response` - `mcp_list_tools` -为保持向后兼容,`handoff_occured` 有意保留了拼写错误。 +为了向后兼容,`handoff_occured`被有意拼错。 -使用托管工具搜索时,模型发出工具搜索请求会触发 `tool_search_called`,而 Responses API 返回已加载的子集时会触发 `tool_search_output_created`。 +任务转移调用仅以`handoff_requested`发出,不会同时以`tool_called`发出。同一轮次中的普通工具调用仍会发出`tool_called`。 -使用程序化工具调用时,生成的 `program` 和由程序管理的普通子工具调用都会触发 `tool_called`。子工具输出以及相应的 `program_output` 会触发 `tool_output`。由程序管理的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 项属于例外:它们分别以 `mcp_approval_requested` 和 `mcp_list_tools` 的形式触发,并分别封装 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem]。可以检查原始项目的 `type` 来区分其他项目;由程序管理的子调用还带有一个 `caller`,其类型为 `program`,并且其调用方 ID 用于标识父程序。 +使用托管工具搜索时,模型发出工具搜索请求会触发`tool_search_called`,Responses API返回已加载的子集时会触发`tool_search_output_created`。 -例如,以下代码会忽略原始事件,并以流式方式向用户发送更新。 +使用程序化工具调用时,生成的`program`以及程序拥有的普通子工具调用都会触发`tool_called`。子工具输出和对应的`program_output`会触发`tool_output`。程序拥有的托管MCP `mcp_approval_request`和`mcp_list_tools`项目属于例外:它们分别以`mcp_approval_requested`和`mcp_list_tools`发出,并分别封装[`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]和[`MCPListToolsItem`][agents.items.MCPListToolsItem]。检查原始项目的`type`以区分其余项目;程序拥有的子调用还会携带一个`caller`,其类型为`program`,其调用方ID用于标识父程序。 + +例如,以下代码将忽略原始事件,并向用户流式传输更新。 ```python import asyncio From 19e364c17344905ce6d17f41ea4fe084cba20388 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 4 Aug 2026 17:35:55 +0900 Subject: [PATCH 139/473] perf: speed up the test suite (#4171) --- AGENTS.md | 2 + Makefile | 2 +- tests/README.md | 25 +++ tests/extensions/sandbox/test_blaxel.py | 229 ++++++++++++---------- tests/mcp/test_client_session_retries.py | 5 +- tests/sandbox/test_session_utils.py | 30 +-- tests/test_function_schema.py | 52 ++--- tests/tracing/test_import_side_effects.py | 17 +- 8 files changed, 222 insertions(+), 140 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d39b8b20b..0c8c393b29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,8 @@ The OpenAI Agents Python repository provides the Python Agents SDK, examples, an Before submitting changes, ensure relevant checks pass and extend tests when you touch code. +Before adding or changing async, retry, timeout, subprocess, PTY, warning, or xdist-sensitive tests, read [Performance and determinism](tests/README.md#performance-and-determinism) and preserve the applicable behavioral and lifecycle coverage while optimizing execution. + When `$code-change-verification` applies, run it to execute the required verification stack from the repository root. Rerun the full stack after applying fixes. #### Unit tests and type checking diff --git a/Makefile b/Makefile index daa1745f56..86b6fa4f10 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ tests-asyncio-stability: .PHONY: tests-parallel tests-parallel: - uv run pytest -n auto --dist loadfile -m "not serial" + uv run pytest -n auto --dist worksteal -m "not serial" .PHONY: tests-serial tests-serial: diff --git a/tests/README.md b/tests/README.md index d3829dae5a..1dd4b23eb5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,6 +10,31 @@ make tests `make tests` runs the shard-safe suite in parallel and then runs tests marked `serial` in a separate serial pass. +## Performance and determinism + +Tests should wait for observable state transitions rather than elapsed wall-clock time. Preserve the behavior and lifecycle branches under test when removing waits; a faster test is not equivalent if it replaces an active state with a completed state or bypasses the production finalization path. + +Use these guidelines when adding or changing tests: + +- Use events, deterministic fakes, immediate exceptions, and narrowly scoped mocks instead of real sleeps or retry backoff when elapsed time is not the behavior under test. +- Keep a real timeout or delay only when its duration semantics are the contract being tested. Use the smallest focused value that distinguishes the expected behavior. +- Preserve active, completed, failure, cancellation, and cleanup coverage as applicable. Release blocked tasks and clean up sessions, processes, and other resources in `finally` blocks so failed assertions cannot hang the suite. +- Parameterize cases that share the same setup, execution path, and assertions. Give each case a descriptive ID, and keep separate tests when their lifecycle or failure invariants differ. +- Capture expected warnings in the narrowest test with the specific warning category and a stable message match. Do not hide unrelated warnings with a global filter. +- Preserve subprocess isolation when import state, registration, shutdown, or interpreter lifecycle is under test. Instrument the exact side effect, such as construction or registration, instead of scanning the heap or waiting for it to occur. +- Run independent read-only subprocess or filesystem probes with bounded concurrency when useful. Keep cases that mutate shared fixtures, scripts, environment, ports, or external services sequential. +- Keep parallel tests shard-safe: avoid shared mutable global state, fixed writable paths, order dependence, and uncoordinated external resources. Mark a test `serial` only when isolation cannot express its required behavior. +- Keep timing and scheduler patches local to the test context, and continue exercising the production decision, retry, finalization, or cleanup path rather than replacing it wholesale. + +Measure performance changes with both focused and broad runs: + +```bash +uv run pytest tests/path/to/test_file.py --durations=10 +uv run pytest -n auto --dist worksteal -m "not serial" --durations=20 +``` + +Compare test counts, skips, warnings, assertions, and lifecycle coverage as well as elapsed time. Full-suite wall-clock results depend on host load and worker scheduling, so treat repeated focused measurements as the stronger evidence for an individual optimization. Run the repository's required verification stack after the final test changes. + ## Snapshots We use [inline-snapshots](https://15r10nk.github.io/inline-snapshot/latest/) for some tests. If your code adds new snapshot tests or breaks existing ones, you can fix/create them. After fixing/creating snapshots, run `make tests` again to verify the tests pass. diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index fb4fda63a0..1791da1c68 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -29,6 +29,7 @@ WorkspaceReadNotFoundError, WorkspaceWriteTypeError, ) +from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExposedPortEndpoint from agents.sandbox.util.tar_utils import validate_tar_bytes @@ -351,14 +352,17 @@ async def test_exec_nonzero(self, fake_sandbox: _FakeSandboxInstance) -> None: @pytest.mark.asyncio async def test_exec_transport_error(self, fake_sandbox: _FakeSandboxInstance) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + session = _make_session(fake_sandbox) async def _raise(*args: object, **kw: object) -> None: raise ConnectionError("transport error") fake_sandbox.process.exec = _raise # type: ignore[assignment] - with pytest.raises(ExecTransportError) as exc_info: - await session._exec_internal("echo", "hello") + with patch.object(mod, "_import_sandbox_api_error", return_value=None): + with pytest.raises(ExecTransportError) as exc_info: + await session._exec_internal("echo", "hello") assert str(exc_info.value) == "Blaxel exec failed: ConnectionError: transport error" assert exc_info.value.context["backend"] == "blaxel" assert exc_info.value.context["provider_error"] == "ConnectionError: transport error" @@ -598,14 +602,14 @@ async def test_exec_timeout_reports_default_timeout( from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts state = _make_state() - state.timeouts = BlaxelTimeouts(exec_timeout_s=1) + state.timeouts = BlaxelTimeouts.model_construct(exec_timeout_s=0.01) session = _make_session(fake_sandbox, state=state) fake_sandbox.process.delay = 10.0 with pytest.raises(ExecTimeoutError) as exc_info: await session._exec_internal("sleep", "100") - assert exc_info.value.timeout_s == 1.0 + assert exc_info.value.timeout_s == 0.01 @pytest.mark.asyncio async def test_stop_calls_pty_terminate(self, fake_sandbox: _FakeSandboxInstance) -> None: @@ -1572,15 +1576,10 @@ async def _raise(*args: object, **kw: object) -> None: raise ConnectionError("mkdir failed") fake_sandbox.process.exec = _raise # type: ignore[assignment] - # start() should suppress the mkdir error and call super().start(). - # super().start() will try to materialize the manifest, which may - # also call process.exec. We just verify it does not raise from the - # initial mkdir. - try: + with patch.object(BaseSandboxSession, "start", new_callable=AsyncMock) as base_start: await session.start() - except Exception: - # May fail in super().start() but not from the mkdir. - pass + + base_start.assert_awaited_once_with() # --------------------------------------------------------------------------- @@ -1656,21 +1655,88 @@ def ClientSession(self) -> _FakeHTTPSession: class TestPtyExec: + @pytest.mark.parametrize( + ("messages", "expected_output"), + [ + pytest.param( + [ + _FakeWSMessage( + _FakeAiohttp.WSMsgType.TEXT, + json.dumps({"type": "output", "data": "hello from pty"}), + ) + ], + b"hello from pty", + id="text", + ), + pytest.param( + [ + _FakeWSMessage( + _FakeAiohttp.WSMsgType.BINARY, + json.dumps({"type": "output", "data": "binary-data"}).encode(), + ) + ], + b"binary-data", + id="binary", + ), + pytest.param( + [ + _FakeWSMessage( + _FakeAiohttp.WSMsgType.TEXT, + json.dumps({"Type": "output", "Data": "cap-data"}), + ) + ], + b"cap-data", + id="capitalized-keys", + ), + pytest.param( + [ + _FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, "not json"), + _FakeWSMessage( + _FakeAiohttp.WSMsgType.TEXT, + json.dumps({"type": "output", "data": "valid"}), + ), + ], + b"valid", + id="invalid-json-ignored", + ), + ], + ) @pytest.mark.asyncio - async def test_pty_exec_start_success(self, fake_sandbox: _FakeSandboxInstance) -> None: + async def test_pty_exec_start_decodes_output_messages( + self, + fake_sandbox: _FakeSandboxInstance, + messages: list[_FakeWSMessage], + expected_output: bytes, + ) -> None: from agents.extensions.sandbox.blaxel import sandbox as mod - output_msg = json.dumps({"type": "output", "data": "hello from pty"}) - ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)]) + ws = _FakeWS(messages=[*messages, _FakeWSMessage(_FakeAiohttp.WSMsgType.CLOSE, "")]) fake_aiohttp = _FakeAiohttp(ws=ws) - session = _make_session(fake_sandbox) with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): update = await session.pty_exec_start("echo", "hello", yield_time_s=0.5) - assert update.output is not None - assert b"hello from pty" in update.output - # process_id may be None if the reader finishes before finalize (entry.done=True). + assert expected_output in update.output + + @pytest.mark.asyncio + async def test_pty_exec_start_preserves_active_session( + self, fake_sandbox: _FakeSandboxInstance + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + + session = _make_session(fake_sandbox) + output_msg = json.dumps({"type": "output", "data": "still running"}) + ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)]) + + try: + with patch.object(mod, "_import_aiohttp", return_value=_FakeAiohttp(ws=ws)): + update = await session.pty_exec_start("echo", "hello", yield_time_s=0.01) + + assert b"still running" in update.output + assert update.process_id is not None + assert session._pty_sessions[update.process_id].ws is ws + finally: + await session.pty_terminate_all() @pytest.mark.asyncio async def test_pty_exec_start_timeout(self, fake_sandbox: _FakeSandboxInstance) -> None: @@ -1703,7 +1769,7 @@ async def test_pty_exec_start_timeout_reports_default_timeout( from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts state = _make_state() - state.timeouts = BlaxelTimeouts(exec_timeout_s=1) + state.timeouts = BlaxelTimeouts.model_construct(exec_timeout_s=0.01) session = _make_session(fake_sandbox, state=state) class _SlowAiohttp: @@ -1723,7 +1789,7 @@ async def close(self) -> None: with pytest.raises(ExecTimeoutError) as exc_info: await session.pty_exec_start("echo", "hello") - assert exc_info.value.timeout_s == 1.0 + assert exc_info.value.timeout_s == 0.01 @pytest.mark.asyncio async def test_pty_exec_start_connection_error( @@ -1750,8 +1816,20 @@ async def close(self) -> None: with pytest.raises(ExecTransportError): await session.pty_exec_start("echo", "hello") + @pytest.mark.parametrize( + ("chars", "expected_send_count"), + [ + pytest.param("input\n", 1, id="input"), + pytest.param("", 0, id="empty"), + ], + ) @pytest.mark.asyncio - async def test_pty_write_stdin(self, fake_sandbox: _FakeSandboxInstance) -> None: + async def test_pty_write_stdin_sends_only_nonempty_input( + self, + fake_sandbox: _FakeSandboxInstance, + chars: str, + expected_send_count: int, + ) -> None: from agents.extensions.sandbox.blaxel import sandbox as mod from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry @@ -1765,31 +1843,28 @@ async def test_pty_write_stdin(self, fake_sandbox: _FakeSandboxInstance) -> None session._pty_sessions[1] = entry session._reserved_pty_process_ids.add(1) - with patch.object(mod, "_import_aiohttp", return_value=_FakeAiohttp()): - update = await session.pty_write_stdin(session_id=1, chars="input\n", yield_time_s=0.2) - assert update.output is not None - assert len(ws._sent) == 1 - - @pytest.mark.asyncio - async def test_pty_write_stdin_empty_chars(self, fake_sandbox: _FakeSandboxInstance) -> None: - from agents.extensions.sandbox.blaxel import sandbox as mod - from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry - - session = _make_session(fake_sandbox) - ws = _FakeWS() - entry = _BlaxelPtySessionEntry( - ws_session_id="empty-write", - ws=ws, - http_session=_FakeHTTPSession(ws), - ) - session._pty_sessions[1] = entry - session._reserved_pty_process_ids.add(1) + try: + with ( + patch.object(mod, "_import_aiohttp", return_value=_FakeAiohttp()), + patch.object(asyncio, "sleep", new=AsyncMock()), + patch.object( + session, + "_collect_pty_output", + new=AsyncMock(return_value=(b"", None)), + ), + ): + update = await session.pty_write_stdin( + session_id=1, + chars=chars, + yield_time_s=0.2, + ) - with patch.object(mod, "_import_aiohttp", return_value=_FakeAiohttp()): - update = await session.pty_write_stdin(session_id=1, chars="", yield_time_s=0.2) assert update.output is not None - # Empty chars should not send anything. - assert len(ws._sent) == 0 + assert update.process_id == 1 + assert session._pty_sessions[1] is entry + assert len(ws._sent) == expected_send_count + finally: + await session.pty_terminate_all() @pytest.mark.asyncio async def test_pty_terminate_all(self, fake_sandbox: _FakeSandboxInstance) -> None: @@ -1824,19 +1899,6 @@ async def test_pty_ws_reader_error_message(self, fake_sandbox: _FakeSandboxInsta assert update.output is not None assert b"something failed" in update.output - @pytest.mark.asyncio - async def test_pty_ws_reader_binary_message(self, fake_sandbox: _FakeSandboxInstance) -> None: - from agents.extensions.sandbox.blaxel import sandbox as mod - - output_msg = json.dumps({"type": "output", "data": "binary-data"}).encode() - ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.BINARY, output_msg)]) - fake_aiohttp = _FakeAiohttp(ws=ws) - session = _make_session(fake_sandbox) - - with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): - update = await session.pty_exec_start("echo", "test", yield_time_s=0.5) - assert b"binary-data" in update.output - @pytest.mark.asyncio async def test_pty_ws_reader_close_message(self, fake_sandbox: _FakeSandboxInstance) -> None: from agents.extensions.sandbox.blaxel import sandbox as mod @@ -1856,27 +1918,6 @@ async def test_pty_ws_reader_close_message(self, fake_sandbox: _FakeSandboxInsta update = await session.pty_exec_start("echo", "test", yield_time_s=0.5) assert b"hi" in update.output - @pytest.mark.asyncio - async def test_pty_ws_reader_invalid_json(self, fake_sandbox: _FakeSandboxInstance) -> None: - from agents.extensions.sandbox.blaxel import sandbox as mod - - ws = _FakeWS( - messages=[ - _FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, "not json"), - _FakeWSMessage( - _FakeAiohttp.WSMsgType.TEXT, - json.dumps({"type": "output", "data": "valid"}), - ), - ] - ) - fake_aiohttp = _FakeAiohttp(ws=ws) - session = _make_session(fake_sandbox) - - with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): - update = await session.pty_exec_start("echo", "test", yield_time_s=0.5) - # Invalid JSON should be silently ignored; valid output should appear. - assert b"valid" in update.output - @pytest.mark.asyncio async def test_pty_ws_reader_error_type_message( self, fake_sandbox: _FakeSandboxInstance @@ -2000,40 +2041,28 @@ async def test_pty_exec_default_yield_time(self, fake_sandbox: _FakeSandboxInsta _FakeAiohttp.WSMsgType.TEXT, json.dumps({"type": "output", "data": "quick"}), ), + _FakeWSMessage(_FakeAiohttp.WSMsgType.CLOSE, ""), ] ) fake_aiohttp = _FakeAiohttp(ws=ws) session = _make_session(fake_sandbox) with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): - # Pass yield_time_s=None to test default (10s), but with a short timeout. - # We use a small timeout to not wait 10 seconds. - update = await session.pty_exec_start("echo", "test", yield_time_s=0.1) + update = await session.pty_exec_start("echo", "test") assert b"quick" in update.output - @pytest.mark.asyncio - async def test_pty_ws_reader_capital_type_keys( - self, fake_sandbox: _FakeSandboxInstance - ) -> None: - from agents.extensions.sandbox.blaxel import sandbox as mod - - # Test the alternative capitalized key paths (Type/Data). - output_msg = json.dumps({"Type": "output", "Data": "cap-data"}) - ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)]) - fake_aiohttp = _FakeAiohttp(ws=ws) - session = _make_session(fake_sandbox) - - with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): - update = await session.pty_exec_start("echo", "test", yield_time_s=0.5) - assert b"cap-data" in update.output - @pytest.mark.asyncio async def test_pty_max_output_tokens(self, fake_sandbox: _FakeSandboxInstance) -> None: from agents.extensions.sandbox.blaxel import sandbox as mod long_output = "x" * 10000 output_msg = json.dumps({"type": "output", "data": long_output}) - ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)]) + ws = _FakeWS( + messages=[ + _FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg), + _FakeWSMessage(_FakeAiohttp.WSMsgType.CLOSE, ""), + ] + ) fake_aiohttp = _FakeAiohttp(ws=ws) session = _make_session(fake_sandbox) @@ -2554,6 +2583,7 @@ async def test_pty_exec_with_pruning(self, fake_sandbox: _FakeSandboxInstance) - _FakeAiohttp.WSMsgType.TEXT, json.dumps({"type": "output", "data": "pruned-test"}), ), + _FakeWSMessage(_FakeAiohttp.WSMsgType.CLOSE, ""), ] ) fake_aiohttp = _FakeAiohttp(ws=ws) @@ -2587,6 +2617,7 @@ async def test_pty_warning_threshold(self, fake_sandbox: _FakeSandboxInstance) - _FakeAiohttp.WSMsgType.TEXT, json.dumps({"type": "output", "data": "warn-test"}), ), + _FakeWSMessage(_FakeAiohttp.WSMsgType.CLOSE, ""), ] ) fake_aiohttp = _FakeAiohttp(ws=ws) diff --git a/tests/mcp/test_client_session_retries.py b/tests/mcp/test_client_session_retries.py index c868791079..6afd8d633f 100644 --- a/tests/mcp/test_client_session_retries.py +++ b/tests/mcp/test_client_session_retries.py @@ -292,7 +292,7 @@ async def call_tool(self, tool_name, arguments, meta=None): if tool_name == "slow": self._slow_task = cast(asyncio.Task[CallToolResult], asyncio.current_task()) self._slow_started.set() - await asyncio.sleep(0.1) + await asyncio.sleep(0) return CallToolResult(content=[]) await self._slow_started.wait() @@ -384,6 +384,7 @@ def __init__(self, shared_session: object, isolated_session: object): params={"url": "https://example.test/mcp"}, client_session_timeout_seconds=None, max_retry_attempts=0, + retry_backoff_seconds_base=0, ) self.session = cast(ClientSession, shared_session) self._isolated_session = cast(ClientSession, isolated_session) @@ -597,7 +598,7 @@ async def _enter_request(self): self.in_flight += 1 self.max_in_flight = max(self.max_in_flight, self.in_flight) try: - await asyncio.sleep(0.02) + await asyncio.sleep(0) yield finally: self.in_flight -= 1 diff --git a/tests/sandbox/test_session_utils.py b/tests/sandbox/test_session_utils.py index ca2326da54..d1ddc828ef 100644 --- a/tests/sandbox/test_session_utils.py +++ b/tests/sandbox/test_session_utils.py @@ -6,6 +6,7 @@ import subprocess import sys import uuid +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest @@ -353,17 +354,24 @@ def probe(path: Path, *, env: dict[str, str] | None = None) -> int: ) return result.returncode - assert probe(dangling) == 1 - assert probe(invalid_target) == 2 - assert probe(dangling_parent / "child") == 1 - assert probe(invalid_parent / "child") == 2 - assert probe(loop) == 2 - assert probe(newline_link) == 0 - assert probe(workspace / "[a]") == 1 - assert probe(workspace / "?") == 1 - assert probe(workspace / "*") == 1 - assert probe(workspace.joinpath(*symlink_parts, "missing")) == 2 - assert probe(workspace / ("x" * 256)) == 2 + probe_cases = [ + (dangling, 1), + (invalid_target, 2), + (dangling_parent / "child", 1), + (invalid_parent / "child", 2), + (loop, 2), + (newline_link, 0), + (workspace / "[a]", 1), + (workspace / "?", 1), + (workspace / "*", 1), + (workspace.joinpath(*symlink_parts, "missing"), 2), + (workspace / ("x" * 256), 2), + ] + with ThreadPoolExecutor(max_workers=4) as executor: + results = executor.map(probe, (path for path, _expected in probe_cases)) + + for (path, expected), actual in zip(probe_cases, results, strict=True): + assert actual == expected, f"unexpected probe result for {path}" fake_bin = tmp_path / "fake-bin" fake_bin.mkdir() diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index bdecfc0605..1b261ce9b5 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -1,9 +1,10 @@ -from collections.abc import Mapping +from collections.abc import Callable, Mapping from enum import Enum from typing import Annotated, Any, Literal import pytest from pydantic import BaseModel, Field, ValidationError +from pydantic.json_schema import PydanticJsonSchemaWarning from typing_extensions import TypedDict from agents import RunContextWrapper, function_tool @@ -1101,31 +1102,38 @@ def __hash__(self) -> int: _ALWAYS_EQUAL_DEFAULT = _AlwaysEqual() -def test_default_with_elementwise_eq_does_not_crash(): - """Defaults must be compared to the inspect sentinel by identity: a numpy-style - default whose ``==`` returns a non-boolean container used to crash schema creation.""" +def _function_with_elementwise_default(x: int, value: Any = _ELEMENTWISE_DEFAULT) -> int: + return x - def score(x: int, weights: Any = _ELEMENTWISE_DEFAULT) -> int: - return x - - fs = function_schema(score, strict_json_schema=False) - assert "weights" not in fs.params_json_schema.get("required", []) - - parsed = fs.params_pydantic_model(x=1) - args, kwargs = fs.to_call_args(parsed) - assert isinstance((args + list(kwargs.values()))[-1], _ElementwiseEqual) +def _function_with_always_equal_default(x: int, value: Any = _ALWAYS_EQUAL_DEFAULT) -> int: + return x -def test_default_with_always_true_eq_stays_optional(): - """A default whose ``__eq__`` answers True used to be mistaken for the no-default - sentinel, silently marking the parameter required and discarding the default.""" - def strip(text: str, punctuation: Any = _ALWAYS_EQUAL_DEFAULT) -> str: - return text +@pytest.mark.parametrize( + ("func", "default_type"), + [ + pytest.param( + _function_with_elementwise_default, + _ElementwiseEqual, + id="elementwise-equality", + ), + pytest.param( + _function_with_always_equal_default, + _AlwaysEqual, + id="always-true-equality", + ), + ], +) +def test_default_equality_is_not_used_for_sentinel_comparison( + func: Callable[..., Any], default_type: type[Any] +) -> None: + """Defaults with non-boolean or always-true equality remain optional and are preserved.""" + with pytest.warns(PydanticJsonSchemaWarning, match="is not JSON serializable"): + fs = function_schema(func, strict_json_schema=False) - fs = function_schema(strip, strict_json_schema=False) - assert fs.params_json_schema.get("required", []) == ["text"] + assert fs.params_json_schema.get("required", []) == ["x"] - parsed = fs.params_pydantic_model(text="hi") + parsed = fs.params_pydantic_model(x=1) args, kwargs = fs.to_call_args(parsed) - assert isinstance((args + list(kwargs.values()))[-1], _AlwaysEqual) + assert isinstance((args + list(kwargs.values()))[-1], default_type) diff --git a/tests/tracing/test_import_side_effects.py b/tests/tracing/test_import_side_effects.py index 4b6cc060ab..4655a4d73f 100644 --- a/tests/tracing/test_import_side_effects.py +++ b/tests/tracing/test_import_side_effects.py @@ -36,20 +36,27 @@ def _run_python(script: str) -> dict[str, object]: def test_import_agents_has_no_tracing_side_effects() -> None: payload = _run_python( """ -import gc import json import httpx -clients_before = sum(1 for obj in gc.get_objects() if isinstance(obj, httpx.Client)) +client_init_calls = 0 +original_client_init = httpx.Client.__init__ + +def tracking_client_init(self, *args, **kwargs): + global client_init_calls + client_init_calls += 1 + original_client_init(self, *args, **kwargs) + +httpx.Client.__init__ = tracking_client_init + import agents # noqa: F401 from agents.tracing import processors as tracing_processors from agents.tracing import setup as tracing_setup -clients_after = sum(1 for obj in gc.get_objects() if isinstance(obj, httpx.Client)) print( json.dumps( { - "client_delta": clients_after - clients_before, + "client_init_calls": client_init_calls, "provider_initialized": tracing_setup.GLOBAL_TRACE_PROVIDER is not None, "exporter_initialized": tracing_processors._global_exporter is not None, "processor_initialized": tracing_processors._global_processor is not None, @@ -60,7 +67,7 @@ def test_import_agents_has_no_tracing_side_effects() -> None: """ ) - assert payload["client_delta"] == 0 + assert payload["client_init_calls"] == 0 assert payload["provider_initialized"] is False assert payload["exporter_initialized"] is False assert payload["processor_initialized"] is False From 046f82bb83218ec9f2026aa2fa0edcfd7f9a1d00 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 07:28:36 +0900 Subject: [PATCH 140/473] fix(memory): enforce closed state in MongoDBSession (#4176) Co-authored-by: Chinmay V <203952148+chinmayv095@users.noreply.github.com> --- docs/sessions/index.md | 2 +- .../extensions/memory/mongodb_session.py | 38 +++++- .../extensions/memory/test_mongodb_session.py | 112 +++++++++++++++++- 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/docs/sessions/index.md b/docs/sessions/index.md index dd66c087a4..95f66172d0 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -448,7 +448,7 @@ await session.close() Notes: -- `from_uri(...)` creates and owns the `AsyncMongoClient` and closes it on `session.close()`. If your application already manages a client, construct `MongoDBSession(...)` directly with `client=...`; in that case `session.close()` is a no-op and lifecycle stays with the caller. +- `from_uri(...)` creates and owns the `AsyncMongoClient` and closes it on `session.close()`. An owned-client session is terminal after `close()`, and subsequent session operations raise `RuntimeError`. If your application already manages a client, construct `MongoDBSession(...)` directly with `client=...`; in that case `session.close()` is a no-op, and lifecycle plus session usability stay with the caller. - Connect to [MongoDB Atlas](https://www.mongodb.com/products/platform) by passing an `mongodb+srv://user:password@cluster.example.mongodb.net` URI to `from_uri(...)` with no other changes. - Two collections are used and both names are configurable via `sessions_collection=` (default `agent_sessions`) and `messages_collection=` (default `agent_messages`). Indexes are created automatically on first use. Each message document carries a monotonically increasing `seq` counter that preserves ordering across concurrent writers and processes. - Use `await session.ping()` to verify connectivity before your first run. diff --git a/src/agents/extensions/memory/mongodb_session.py b/src/agents/extensions/memory/mongodb_session.py index 3886c7b853..b2ba601ab0 100644 --- a/src/agents/extensions/memory/mongodb_session.py +++ b/src/agents/extensions/memory/mongodb_session.py @@ -139,6 +139,7 @@ def __init__( ) self._client = client self._owns_client = False + self._closed = False client.append_metadata(_DRIVER_INFO) @@ -219,6 +220,11 @@ def _mark_init_done(self) -> None: weakref.finalize(self._client, self._init_state.pop, self._client_id, None) per_client[self._init_sub_key] = True + def _check_not_closed(self) -> None: + """Raise if the session has already been closed.""" + if self._closed: + raise RuntimeError("MongoDBSession is closed") + async def _ensure_indexes(self) -> None: """Create required indexes the first time this (client, sub_key) is accessed. @@ -226,7 +232,13 @@ async def _ensure_indexes(self) -> None: from different coroutines or event loops are safe — at most a redundant round-trip is issued. The threading-lock-guarded boolean prevents that extra round-trip after the first call completes. + + Session operations that require index initialization go through here, so + this is also where they reject a closed session. The empty ``add_items`` + fast path and ``ping`` check the closed state directly. """ + self._check_not_closed() + if self._is_init_done(): return @@ -312,6 +324,10 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: Args: items: List of input items to append to the session. """ + # Checked before the empty-list fast path, which would otherwise return + # successfully on a closed session. + self._check_not_closed() + if not items: return @@ -385,18 +401,32 @@ async def close(self) -> None: """Close the underlying MongoDB connection. Only closes the client if this session owns it (i.e. it was created - via :meth:`from_uri`). If the client was injected externally the - caller is responsible for managing its lifecycle. + via :meth:`from_uri`). In that case the session becomes terminal and + subsequent operations raise ``RuntimeError``. If the client was injected + externally the caller is responsible for managing its lifecycle and this + is a no-op. + + The session is terminal from the first close attempt. If releasing the + client fails or is cancelled, operations still raise and a later close() + retries the release, which ``AsyncMongoClient.close`` allows. """ - if self._owns_client: - await self._client.close() + if not self._owns_client: + return + + self._closed = True + await self._client.close() async def ping(self) -> bool: """Test MongoDB connectivity. Returns: ``True`` if the server is reachable, ``False`` otherwise. + + Raises: + RuntimeError: If the session owns its client and has been closed. """ + # Checked outside the try block; the except clause below would swallow it. + self._check_not_closed() try: await self._client.admin.command("ping") return True diff --git a/tests/extensions/memory/test_mongodb_session.py b/tests/extensions/memory/test_mongodb_session.py index d463f458ba..3bd8f7c034 100644 --- a/tests/extensions/memory/test_mongodb_session.py +++ b/tests/extensions/memory/test_mongodb_session.py @@ -8,12 +8,13 @@ from __future__ import annotations +import asyncio import sys import types from collections import defaultdict from datetime import datetime, timezone from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -729,6 +730,115 @@ async def test_close_owned_client_is_closed() -> None: assert fake_client._closed +def _make_owned_session(session_id: str = "owned") -> MongoDBSession: + """Create a from_uri session, which is the case where close() owns the client.""" + MongoDBSession._init_state.clear() + with patch( + "agents.extensions.memory.mongodb_session.AsyncMongoClient", + return_value=FakeAsyncMongoClient(), + ): + return MongoDBSession.from_uri(session_id, uri="mongodb://localhost:27017", database="t") + + +async def test_closed_operations_raise_runtime_error() -> None: + """Operations on a closed session must fail instead of running against a released client.""" + session = _make_owned_session() + await session.add_items([{"role": "user", "content": "hi"}]) + await session.close() + + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.get_items() + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.add_items([{"role": "user", "content": "after close"}]) + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.pop_item() + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.clear_session() + + +async def test_closed_rejects_empty_add_items() -> None: + """add_items([]) must not bypass the closed check through the empty-list fast path.""" + session = _make_owned_session() + await session.close() + + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.add_items([]) + + +async def test_close_before_use_is_terminal() -> None: + """close() before the first operation must still be terminal.""" + session = _make_owned_session() + await session.close() + + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.get_items() + + +async def test_repeated_close_remains_safe() -> None: + """Repeated close() calls must remain safe for callers.""" + session = _make_owned_session() + + await session.close() + await session.close() + + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.get_items() + + +async def test_failed_close_is_terminal_and_can_be_retried() -> None: + """A failed client release must leave the session terminal and cleanup retryable.""" + session = _make_owned_session() + close_mock = AsyncMock(side_effect=[ConnectionError("close failed"), None]) + + with patch.object(session._client, "close", close_mock): + with pytest.raises(ConnectionError, match="close failed"): + await session.close() + + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.get_items() + + await session.close() + + assert close_mock.await_count == 2 + + +async def test_cancelled_close_is_terminal_and_can_be_retried() -> None: + """A cancelled client release must leave the session terminal and cleanup retryable.""" + session = _make_owned_session() + close_mock = AsyncMock(side_effect=[asyncio.CancelledError(), None]) + + with patch.object(session._client, "close", close_mock): + with pytest.raises(asyncio.CancelledError): + await session.close() + + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.get_items() + + await session.close() + + assert close_mock.await_count == 2 + + +async def test_ping_on_closed_session_raises() -> None: + """ping() swallows connectivity errors, so the closed check runs outside its try.""" + session = _make_owned_session() + await session.close() + + with pytest.raises(RuntimeError, match="^MongoDBSession is closed$"): + await session.ping() + + +async def test_external_client_session_stays_usable_after_close() -> None: + """An injected client is the caller's to manage, so close() must not be terminal.""" + session = _make_session() + assert session._owns_client is False + + await session.close() + + await session.add_items([{"role": "user", "content": "still works"}]) + assert len(await session.get_items()) == 1 + + # --------------------------------------------------------------------------- # Runner integration # --------------------------------------------------------------------------- From 0f5d3c371ab52d5f7e068f74e4b62203a6611ccf Mon Sep 17 00:00:00 2001 From: Rakshit Sharma <132228481+rxits@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:02:05 +0530 Subject: [PATCH 141/473] fix(mcp): derive streamable HTTP retry backoff from backoffs taken (#4174) --- src/agents/mcp/server.py | 11 ++++- tests/mcp/test_client_session_retries.py | 54 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 4ff1bf8c5a..5e8e2543ea 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -2102,6 +2102,11 @@ async def call_tool( try: self._validate_required_parameters(tool_name=tool_name, arguments=arguments) retries_used = 0 + # `retries_used` measures the retry budget, not elapsed backoffs: it is + # deliberately not advanced while `max_retry_attempts` is -1, and a single + # isolated-session retry charges it twice. Count backoffs separately so the + # delay follows the configured schedule in both cases. + backoffs_taken = 0 first_attempt = True while True: if not first_attempt and self.max_retry_attempts != -1: @@ -2125,12 +2130,14 @@ async def call_tool( if exc.__cause__ is not None: raise exc.__cause__ from exc raise - backoff = self.retry_backoff_seconds_base * (2 ** (retries_used - 1)) + backoff = self.retry_backoff_seconds_base * (2**backoffs_taken) + backoffs_taken += 1 await asyncio.sleep(backoff) except Exception: if self.max_retry_attempts != -1 and retries_used >= self.max_retry_attempts: raise - backoff = self.retry_backoff_seconds_base * (2**retries_used) + backoff = self.retry_backoff_seconds_base * (2**backoffs_taken) + backoffs_taken += 1 await asyncio.sleep(backoff) first_attempt = False except httpx.HTTPStatusError as e: diff --git a/tests/mcp/test_client_session_retries.py b/tests/mcp/test_client_session_retries.py index 6afd8d633f..d6f2704413 100644 --- a/tests/mcp/test_client_session_retries.py +++ b/tests/mcp/test_client_session_retries.py @@ -696,3 +696,57 @@ async def test_streamable_http_serializes_call_tool_with_prompt_requests(prompt_ assert isinstance(results[1], GetPromptResult) assert shared_session.max_in_flight == 1 assert isolated_session.call_tool_attempts == 0 + + +class FlakyRuntimeErrorSession: + """Fails with an error that does not qualify for an isolated-session retry.""" + + def __init__(self, failures: int): + self.failures = failures + self.call_tool_attempts = 0 + + async def call_tool(self, tool_name, arguments, meta=None): + self.call_tool_attempts += 1 + if self.call_tool_attempts <= self.failures: + raise RuntimeError("transient failure") + return CallToolResult(content=[]) + + +@pytest.mark.asyncio +async def test_streamable_http_backoff_grows_with_unlimited_retries(monkeypatch): + delays: list[float] = [] + + async def record_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(asyncio, "sleep", record_sleep) + + shared_session = FlakyRuntimeErrorSession(failures=3) + server = DummyStreamableHttpServer(shared_session, TimeoutSession()) + server.max_retry_attempts = -1 + server.retry_backoff_seconds_base = 1.0 + + await server.call_tool("tool", None) + + assert delays == [1.0, 2.0, 4.0] + + +@pytest.mark.asyncio +async def test_streamable_http_backoff_matches_generic_schedule_on_isolated_retry(monkeypatch): + delays: list[float] = [] + + async def record_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(asyncio, "sleep", record_sleep) + + shared_session = TimeoutSession("shared timed out") + isolated_session = TimeoutSession("isolated timed out") + server = DummyStreamableHttpServer(shared_session, isolated_session) + server.max_retry_attempts = 6 + server.retry_backoff_seconds_base = 1.0 + + with pytest.raises(httpx.TimeoutException, match="shared timed out"): + await server.call_tool("tool", None) + + assert delays == [1.0, 2.0, 4.0] From ad976ad81166109556a5faabbed3adbecc554a8b Mon Sep 17 00:00:00 2001 From: Kevin <20504493+cosin2077@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:57:06 +0800 Subject: [PATCH 142/473] fix sandbox token output budgets (#3934) --- src/agents/sandbox/util/token_truncation.py | 41 +++++++++++++++-- .../capabilities/test_shell_capability.py | 3 +- tests/sandbox/test_memory.py | 4 +- tests/sandbox/test_token_truncation.py | 44 +++++++++++++++++-- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/agents/sandbox/util/token_truncation.py b/src/agents/sandbox/util/token_truncation.py index 41440b33af..c3996d6079 100644 --- a/src/agents/sandbox/util/token_truncation.py +++ b/src/agents/sandbox/util/token_truncation.py @@ -40,6 +40,9 @@ def formatted_truncate_text(content: str, policy: TruncationPolicy) -> str: if _byte_len(content) <= policy.byte_budget(): return content total_lines = len(content.splitlines()) + if policy.mode == "tokens": + prefix = f"Total output lines: {total_lines}\n\n" + return _truncate_token_output(content, policy, prefix=prefix) result = truncate_text(content, policy) return f"Total output lines: {total_lines}\n\n{result}" @@ -61,9 +64,10 @@ def formatted_truncate_text_with_token_count( if _byte_len(content) <= policy.byte_budget(): return content, None - truncated, original_token_count = truncate_with_token_budget(content, policy) total_lines = len(content.splitlines()) - return f"Total output lines: {total_lines}\n\n{truncated}", original_token_count + prefix = f"Total output lines: {total_lines}\n\n" + truncated = _truncate_token_output(content, policy, prefix=prefix) + return truncated, approx_token_count(content) def truncate_with_token_budget(s: str, policy: TruncationPolicy) -> tuple[str, int | None]: @@ -75,13 +79,44 @@ def truncate_with_token_budget(s: str, policy: TruncationPolicy) -> tuple[str, i if max_tokens > 0 and byte_len <= approx_bytes_for_tokens(max_tokens): return s, None - truncated = truncate_with_byte_estimate(s, policy) approx_total = approx_token_count(s) + truncated = _truncate_token_output(s, policy) if truncated == s: return truncated, None return truncated, approx_total +def _truncate_token_output(content: str, policy: TruncationPolicy, *, prefix: str = "") -> str: + max_bytes = policy.byte_budget() + if max_bytes == 0: + return "" + + source_bytes = content.encode("utf-8") + marker = format_truncation_marker(policy, approx_token_count(content)) + + if _byte_len(prefix) + _byte_len(marker) > max_bytes: + prefix = "" + + content_budget = max_bytes - _byte_len(prefix) - _byte_len(marker) + if content_budget < 0: + return _truncate_utf8(marker, max_bytes) + + left_budget, right_budget = split_budget(content_budget) + _, left, right = split_string(content, left_budget, right_budget) + retained_bytes = _byte_len(left) + _byte_len(right) + removed_bytes = len(source_bytes) - retained_bytes + removed_chars = len(content) - len(left) - len(right) + marker = format_truncation_marker( + policy, + removed_units_for_source(policy, removed_bytes, removed_chars), + ) + return assemble_truncated_output(f"{prefix}{left}", right, marker) + + +def _truncate_utf8(text: str, max_bytes: int) -> str: + return text.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore") + + def truncate_with_byte_estimate(s: str, policy: TruncationPolicy) -> str: if s == "": return "" diff --git a/tests/sandbox/capabilities/test_shell_capability.py b/tests/sandbox/capabilities/test_shell_capability.py index 2802c97345..9986365925 100644 --- a/tests/sandbox/capabilities/test_shell_capability.py +++ b/tests/sandbox/capabilities/test_shell_capability.py @@ -469,8 +469,7 @@ async def test_exec_command_tool_includes_original_token_count_when_truncating( "Process exited with code 7\n" "Original token count: 6\n" "Output:\n" - "Total output lines: 2\n\n" - "stdo…4 tokens truncated… pwd" + "…6 tok" ) @pytest.mark.asyncio diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index fa6c3d4bca..61625e77f4 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -819,11 +819,11 @@ async def test_memory_capability_injects_truncated_memory_summary( try: async with session: - monkeypatch.setattr(memory_module, "_MEMORY_SUMMARY_MAX_TOKENS", 1) + monkeypatch.setattr(memory_module, "_MEMORY_SUMMARY_MAX_TOKENS", 8) await session.mkdir("memories", parents=True) await session.write( Path("memories/memory_summary.md"), - io.BytesIO(b"abcdefg"), + io.BytesIO(b"abcdefghijklmnopqrstuvwxyz" * 2), ) capability.bind(session) diff --git a/tests/sandbox/test_token_truncation.py b/tests/sandbox/test_token_truncation.py index fdd0f0627c..a63bfbce78 100644 --- a/tests/sandbox/test_token_truncation.py +++ b/tests/sandbox/test_token_truncation.py @@ -40,6 +40,16 @@ def test_formatted_truncate_text_adds_line_count_when_truncated() -> None: assert "chars truncated" in result +def test_formatted_truncate_text_keeps_token_metadata_within_budget() -> None: + content = "\n".join(f"line {index}: {('value ' * 8).strip()}" for index in range(20)) + + result = formatted_truncate_text(content, TruncationPolicy.tokens(32)) + + assert result.startswith("Total output lines: 20\n\n") + assert "tokens truncated" in result + assert approx_token_count(result) <= 32 + + def test_formatted_truncate_text_with_token_count_handles_none_and_short_content() -> None: assert formatted_truncate_text_with_token_count("short", None) == ("short", None) assert formatted_truncate_text_with_token_count("short", 10) == ("short", None) @@ -48,14 +58,26 @@ def test_formatted_truncate_text_with_token_count_handles_none_and_short_content def test_formatted_truncate_text_with_token_count_reports_original_count() -> None: result, original_token_count = formatted_truncate_text_with_token_count("abcdefghi", 1) - assert result.startswith("Total output lines: 1\n\n") - assert "tokens truncated" in result + assert approx_token_count(result) <= 1 assert original_token_count == approx_token_count("abcdefghi") +def test_formatted_truncate_text_with_token_count_keeps_metadata_within_budget() -> None: + content = "\n".join(f"line {index}: {('value ' * 8).strip()}" for index in range(20)) + + result, original_token_count = formatted_truncate_text_with_token_count(content, 32) + + assert result.startswith("Total output lines: 20\n\n") + assert "tokens truncated" in result + assert approx_token_count(result) <= 32 + assert original_token_count == approx_token_count(content) + + def test_truncate_text_dispatches_byte_and_token_modes() -> None: assert truncate_text("abcdef", TruncationPolicy.bytes(4)).startswith("a") - assert "tokens truncated" in truncate_text("abcdefghi", TruncationPolicy.tokens(1)) + token_result = truncate_text("abcdefghijklmnopqrstuvwxyz" * 2, TruncationPolicy.tokens(8)) + assert "tokens truncated" in token_result + assert approx_token_count(token_result) <= 8 def test_truncate_with_token_budget_handles_empty_and_short_content() -> None: @@ -63,6 +85,22 @@ def test_truncate_with_token_budget_handles_empty_and_short_content() -> None: assert truncate_with_token_budget("abc", TruncationPolicy.tokens(1)) == ("abc", None) +def test_truncate_with_token_budget_includes_marker_within_budget() -> None: + content = "abcdefghijklmnopqrstuvwxyz" * 2 + result, original_token_count = truncate_with_token_budget(content, TruncationPolicy.tokens(8)) + + assert "tokens truncated" in result + assert approx_token_count(result) <= 8 + assert original_token_count == approx_token_count(content) + + +def test_formatted_truncate_text_with_zero_token_budget_returns_empty_payload() -> None: + result, original_token_count = formatted_truncate_text_with_token_count("content", 0) + + assert result == "" + assert original_token_count == approx_token_count("content") + + def test_truncate_with_byte_estimate_handles_empty_zero_and_short_content() -> None: assert truncate_with_byte_estimate("", TruncationPolicy.bytes(0)) == "" assert "chars truncated" in truncate_with_byte_estimate("abc", TruncationPolicy.bytes(0)) From d3463f2a1507a74807586bcfc6e9effc071d9610 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 08:01:59 +0900 Subject: [PATCH 143/473] fix(extensions): preserve streamed provider thinking blocks (#4177) Co-authored-by: abhay-codes07 --- src/agents/models/chatcmpl_stream_handler.py | 132 +++++-- .../test_openai_chatcompletions_stream.py | 349 +++++++++++++++++- 2 files changed, 454 insertions(+), 27 deletions(-) diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index b960c6542d..5c4924d6f1 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -75,13 +75,68 @@ class StreamingState: # Fields for real-time function call streaming function_call_streaming: dict[int, bool] = field(default_factory=dict) ignored_tool_call_indexes: set[int] = field(default_factory=set) - # Store accumulated thinking text and signature for Anthropic compatibility - thinking_text: str = "" - thinking_signature: str | None = None + # Store the ordered Anthropic thinking block sequence for replay. Text and signatures + # cannot be flattened into scalars: each block carries its own signature, and a + # redacted_thinking block carries neither text nor signature. + thinking_blocks: list[dict[str, Any]] = field(default_factory=list) # Store provider data for all output items provider_data: dict[str, Any] = field(default_factory=dict) has_warned_unsupported_choice: bool = False + def accumulate_thinking_block(self, block: dict[str, Any]) -> bool: + """Fold one streamed thinking delta into the ordered block sequence. + + Anthropic streams a thinking block as a run of `thinking_delta` chunks terminated by a + single `signature_delta`, so a signature both belongs to the block being accumulated and + marks its end. A `redacted_thinking` block arrives whole and is kept verbatim. + """ + block_type = block.get("type", "thinking") + if block_type == "redacted_thinking": + self.thinking_blocks.append(dict(block)) + return True + + if block_type != "thinking": + return False + + for field_name in ("thinking", "signature"): + if field_name not in block: + continue + field_value = block[field_name] + if not isinstance(field_value, str): + raise ModelBehaviorError( + f"Expected streamed thinking block field '{field_name}' to be a string, " + f"got {type(field_value).__name__}." + ) + + thinking_text = block.get("thinking") or "" + signature = block.get("signature") or "" + metadata = { + key: value + for key, value in block.items() + if key not in {"type", "thinking", "signature"} + } + if not thinking_text and not signature and not metadata: + return False + + current = self._open_thinking_block() + current.update(metadata) + if thinking_text: + current["thinking"] = f"{current.get('thinking', '')}{thinking_text}" + if signature: + # A signature closes the block, so later text starts a new one. + current["signature"] = signature + return True + + def _open_thinking_block(self) -> dict[str, Any]: + """Return the thinking block still accepting deltas, creating one when needed.""" + if self.thinking_blocks: + last_block = self.thinking_blocks[-1] + if last_block.get("type") == "thinking" and "signature" not in last_block: + return last_block + new_block: dict[str, Any] = {"type": "thinking", "thinking": ""} + self.thinking_blocks.append(new_block) + return new_block + @dataclass class _BufferedToolCall: @@ -460,6 +515,35 @@ def _finish_reasoning_summary_part( ) state.active_reasoning_summary_index = None + @staticmethod + def _finalize_thinking_blocks(state: StreamingState) -> None: + if not state.thinking_blocks or not state.reasoning_content_index_and_output: + return + + reasoning_item = state.reasoning_content_index_and_output[1] + provider_data = getattr(reasoning_item, "provider_data", None) + reasoning_provider_data = provider_data.copy() if isinstance(provider_data, dict) else {} + reasoning_provider_data["thinking_blocks"] = [ + dict(block) for block in state.thinking_blocks + ] + reasoning_item.provider_data = reasoning_provider_data # type: ignore[attr-defined] + + # Retain the released normalized representation for callers and legacy replay. + thinking_text = "" + last_signature: str | None = None + for block in state.thinking_blocks: + if block.get("type") != "thinking": + continue + thinking_text += block.get("thinking") or "" + if signature := block.get("signature"): + last_signature = signature + if thinking_text: + if not reasoning_item.content: + reasoning_item.content = [] + reasoning_item.content.append(Content(text=thinking_text, type="reasoning_text")) + if last_signature: + reasoning_item.encrypted_content = last_signature + @classmethod def _finish_reasoning_item( cls, @@ -576,16 +660,26 @@ async def handle_stream( # Handle thinking blocks from Anthropic (for preserving signatures) if hasattr(delta, "thinking_blocks") and delta.thinking_blocks: + has_thinking_block = False for block in delta.thinking_blocks: if isinstance(block, dict): - # Accumulate thinking text - thinking_text = block.get("thinking", "") - if thinking_text: - state.thinking_text += thinking_text - # Store signature if present - signature = block.get("signature") - if signature: - state.thinking_signature = signature + has_thinking_block |= state.accumulate_thinking_block(block) + + if has_thinking_block and not state.reasoning_content_index_and_output: + reasoning_item = ResponseReasoningItem( + id=FAKE_RESPONSES_ID, + summary=[], + type="reasoning", + ) + if state.provider_data: + reasoning_item.provider_data = state.provider_data.copy() # type: ignore[attr-defined] + state.reasoning_content_index_and_output = (0, reasoning_item) + yield ResponseOutputItemAddedEvent( + item=reasoning_item, + output_index=0, + type="response.output_item.added", + sequence_number=sequence_number.get_and_increment(), + ) # Handle reasoning content for reasoning summaries if hasattr(delta, "reasoning_content"): @@ -705,8 +799,6 @@ async def handle_stream( # text part has already opened keep their existing behavior. if not state.text_content_index_and_output: content_index = 0 - if state.reasoning_content_index_and_output: - content_index += 1 if state.refusal_content_index_and_output: content_index += 1 @@ -784,8 +876,6 @@ async def handle_stream( if hasattr(delta, "refusal") and delta.refusal: if not state.refusal_content_index_and_output: refusal_index = 0 - if state.reasoning_content_index_and_output: - refusal_index += 1 if state.text_content_index_and_output: refusal_index += 1 @@ -1021,6 +1111,7 @@ async def handle_stream( sequence_number=sequence_number.get_and_increment(), ) + cls._finalize_thinking_blocks(state) for event in cls._finish_reasoning_item(state, sequence_number): yield event @@ -1099,17 +1190,6 @@ async def handle_stream( # include Reasoning item if it exists if state.reasoning_content_index_and_output: reasoning_item = state.reasoning_content_index_and_output[1] - # Store thinking text in content and signature in encrypted_content - if state.thinking_text: - # Add thinking text as a Content object - if not reasoning_item.content: - reasoning_item.content = [] - reasoning_item.content.append( - Content(text=state.thinking_text, type="reasoning_text") - ) - # Store signature in encrypted_content - if state.thinking_signature: - reasoning_item.encrypted_content = state.thinking_signature outputs.append(reasoning_item) outputs.extend(output_layout.function_calls_before_message(state)) diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index ee34317158..5c77510b38 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -37,6 +37,7 @@ from agents import Agent, Runner, function_tool from agents.exceptions import ModelBehaviorError, UserError from agents.model_settings import ModelSettings +from agents.models.chatcmpl_converter import Converter from agents.models.chatcmpl_stream_handler import ( ChatCmplStreamHandler, Part, @@ -884,8 +885,354 @@ async def test_stream_handler_preserves_thinking_blocks_with_reasoning_summary() assert isinstance(reasoning_item, ResponseReasoningItem) assert reasoning_item.summary[0].text == "summary" assert reasoning_item.content - assert cast(Any, reasoning_item.content[0]).text == "hidden one hidden two" + # Preserve the released normalized projection while provider_data retains exact blocks. + assert [cast(Any, part).text for part in reasoning_item.content] == ["hidden one hidden two"] assert reasoning_item.encrypted_content == "sig-2" + assert cast(Any, reasoning_item).provider_data["thinking_blocks"] == [ + {"type": "thinking", "thinking": "hidden one ", "signature": "sig-1"}, + {"type": "thinking", "thinking": "hidden two", "signature": "sig-2"}, + ] + + +def _thinking_chunk(**delta_kwargs: Any) -> ChatCompletionChunk: + return ChatCompletionChunk( + id="chunk-id", + created=1, + model="anthropic/claude-4-opus", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta.model_construct(**delta_kwargs))], + ) + + +@pytest.mark.asyncio +async def test_stream_handler_segments_thinking_blocks_at_signature_deltas() -> None: + """A streamed block ends at its signature_delta, so each block keeps its own signature. + + Anthropic streams a thinking block as a run of `thinking_delta` chunks terminated by one + `signature_delta`. Accumulating the text into a single scalar merged interleaved blocks and + kept only the last signature, which cannot be verified against the merged text on replay. + """ + events = await _collect_handler_events( + _thinking_chunk(reasoning_content="summary"), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "step ", "signature": ""}] + ), + _thinking_chunk(thinking_blocks=[{"type": "thinking", "thinking": "one", "signature": ""}]), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "SIG-1"}] + ), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "step two", "signature": ""}] + ), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "SIG-2"}] + ), + ) + + completed_event = next(event for event in events if event.type == "response.completed") + reasoning_item = completed_event.response.output[0] + assert isinstance(reasoning_item, ResponseReasoningItem) + assert cast(Any, reasoning_item).provider_data["thinking_blocks"] == [ + {"type": "thinking", "thinking": "step one", "signature": "SIG-1"}, + {"type": "thinking", "thinking": "step two", "signature": "SIG-2"}, + ] + assert [cast(Any, part).text for part in (reasoning_item.content or [])] == ["step onestep two"] + assert reasoning_item.encrypted_content == "SIG-2" + + +@pytest.mark.asyncio +async def test_stream_handler_finalizes_thinking_blocks_before_item_done() -> None: + """The done event must carry the finalized item at the time it is yielded.""" + snapshots = [ + event.model_dump() + async for event in ChatCmplStreamHandler.handle_stream( + _empty_response(), + cast( + Any, + _completion_stream( + _thinking_chunk(reasoning_content="summary"), + _thinking_chunk( + thinking_blocks=[ + {"type": "thinking", "thinking": "hidden", "signature": ""} + ] + ), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "SIG"}] + ), + ), + ), + ) + ] + + done_event = next( + event + for event in snapshots + if event["type"] == "response.output_item.done" and event["item"]["type"] == "reasoning" + ) + completed_event = next(event for event in snapshots if event["type"] == "response.completed") + completed_reasoning_item = completed_event["response"]["output"][0] + + assert done_event["item"] == completed_reasoning_item + assert done_event["item"]["provider_data"]["thinking_blocks"] == [ + {"type": "thinking", "thinking": "hidden", "signature": "SIG"} + ] + + +@pytest.mark.asyncio +async def test_stream_handler_preserves_redacted_thinking_without_summary() -> None: + """LiteLLM emits redacted blocks without reasoning_content, so they need their own item.""" + events = await _collect_handler_events( + _thinking_chunk(thinking_blocks=[{"type": "redacted_thinking", "data": "BLOB"}]), + _thinking_chunk(content="visible answer"), + ) + + reasoning_done_event = next( + event + for event in events + if event.type == "response.output_item.done" + and isinstance(event.item, ResponseReasoningItem) + ) + assert reasoning_done_event.output_index == 0 + assert cast(Any, reasoning_done_event.item).provider_data["thinking_blocks"] == [ + {"type": "redacted_thinking", "data": "BLOB"} + ] + + completed_event = next(event for event in events if event.type == "response.completed") + assert [item.type for item in completed_event.response.output] == ["reasoning", "message"] + assert cast(Any, completed_event.response.output[0]).provider_data["thinking_blocks"] == [ + {"type": "redacted_thinking", "data": "BLOB"} + ] + + text_events = [ + event + for event in events + if event.type + in { + "response.content_part.added", + "response.output_text.delta", + "response.content_part.done", + } + ] + assert text_events + assert all(event.output_index == 1 and event.content_index == 0 for event in text_events) + + +@pytest.mark.asyncio +async def test_stream_handler_places_refusal_after_redacted_thinking_item() -> None: + events = await _collect_handler_events( + _thinking_chunk(thinking_blocks=[{"type": "redacted_thinking", "data": "BLOB"}]), + _thinking_chunk(refusal="blocked"), + ) + + refusal_events = [ + event + for event in events + if event.type + in { + "response.content_part.added", + "response.refusal.delta", + "response.content_part.done", + } + ] + assert refusal_events + assert all(event.output_index == 1 and event.content_index == 0 for event in refusal_events) + + completed_event = next(event for event in events if event.type == "response.completed") + message = completed_event.response.output[1] + assert isinstance(message, ResponseOutputMessage) + assert message.content == [ResponseOutputRefusal(refusal="blocked", type="refusal")] + + +@pytest.mark.asyncio +async def test_stream_handler_keeps_redacted_block_metadata_opaque() -> None: + """Additional redacted-block keys must not become normalized thinking fields.""" + redacted_block = { + "type": "redacted_thinking", + "data": "BLOB", + "thinking": {"opaque": True}, + "signature": {"opaque": True}, + } + + events = await _collect_handler_events(_thinking_chunk(thinking_blocks=[redacted_block])) + + done_event = next( + event + for event in events + if event.type == "response.output_item.done" + and isinstance(event.item, ResponseReasoningItem) + ) + reasoning_item = cast(ResponseReasoningItem, done_event.item) + assert cast(Any, reasoning_item).provider_data["thinking_blocks"] == [redacted_block] + assert reasoning_item.content is None + assert reasoning_item.encrypted_content is None + + +@pytest.mark.asyncio +async def test_stream_handler_emits_complete_signed_thinking_only_lifecycle() -> None: + """Signed thinking without a summary must produce one complete reasoning item.""" + snapshots = [ + event.model_dump() + async for event in ChatCmplStreamHandler.handle_stream( + _empty_response(), + cast( + Any, + _completion_stream( + _thinking_chunk( + thinking_blocks=[ + {"type": "thinking", "thinking": "hidden", "signature": ""} + ] + ), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "SIG"}] + ), + ), + ), + ) + ] + + assert [event["type"] for event in snapshots] == [ + "response.created", + "response.output_item.added", + "response.reasoning_text.done", + "response.output_item.done", + "response.completed", + ] + assert snapshots[1]["output_index"] == 0 + assert snapshots[2]["output_index"] == 0 + assert snapshots[2]["text"] == "hidden" + + done_item = snapshots[3]["item"] + assert snapshots[3]["output_index"] == 0 + assert done_item["content"] == [{"text": "hidden", "type": "reasoning_text"}] + assert done_item["encrypted_content"] == "SIG" + assert done_item["provider_data"]["thinking_blocks"] == [ + {"type": "thinking", "thinking": "hidden", "signature": "SIG"} + ] + assert snapshots[4]["response"]["output"] == [done_item] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("field_name", "field_value"), + [ + ("thinking", {"opaque": True}), + ("signature", {"opaque": True}), + ("thinking", None), + ("signature", None), + ], +) +async def test_stream_handler_rejects_non_string_thinking_fields( + field_name: str, field_value: Any +) -> None: + block: dict[str, Any] = {"type": "thinking", "thinking": "", "signature": ""} + block[field_name] = field_value + + with pytest.raises( + ModelBehaviorError, + match=rf"Expected streamed thinking block field '{field_name}' to be a string, " + rf"got {type(field_value).__name__}", + ): + await _collect_handler_events(_thinking_chunk(thinking_blocks=[block])) + + +@pytest.mark.asyncio +async def test_stream_handler_ignores_explicit_null_thinking_block_type() -> None: + events = await _collect_handler_events( + _thinking_chunk( + thinking_blocks=[ + {"type": None, "thinking": "hidden", "signature": "SIG"}, + ] + ) + ) + + completed_event = next(event for event in events if event.type == "response.completed") + assert completed_event.response.output == [] + + +@pytest.mark.asyncio +async def test_stream_handler_preserves_redacted_thinking_blocks() -> None: + """A redacted_thinking block carries neither text nor signature and was dropped entirely. + + Anthropic requires redacted blocks to be replayed unmodified during tool-use continuation, + and the normalized content/encrypted_content pair cannot represent them. + """ + events = await _collect_handler_events( + _thinking_chunk(reasoning_content="summary"), + _thinking_chunk(thinking_blocks=[{"type": "redacted_thinking", "data": "REDACTED-BLOB"}]), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "visible", "signature": ""}] + ), + _thinking_chunk(thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "SIG"}]), + ) + + completed_event = next(event for event in events if event.type == "response.completed") + reasoning_item = completed_event.response.output[0] + assert isinstance(reasoning_item, ResponseReasoningItem) + assert cast(Any, reasoning_item).provider_data["thinking_blocks"] == [ + {"type": "redacted_thinking", "data": "REDACTED-BLOB"}, + {"type": "thinking", "thinking": "visible", "signature": "SIG"}, + ] + # The redacted block contributes no reasoning_text and no signature. + assert [cast(Any, part).text for part in (reasoning_item.content or [])] == ["visible"] + assert reasoning_item.encrypted_content == "SIG" + + +@pytest.mark.asyncio +async def test_streamed_thinking_blocks_replay_through_the_converter() -> None: + """End-to-end: a streamed Anthropic response must replay as the same ordered blocks. + + This covers the streaming counterpart of the non-streaming replay path, so a signed block + survives item serialization and reaches the outbound request unchanged. + """ + events = await _collect_handler_events( + _thinking_chunk(reasoning_content="summary"), + _thinking_chunk( + thinking_blocks=[ + { + "type": "thinking", + "thinking": "alpha", + "signature": "", + "cache_control": {"type": "ephemeral"}, + } + ] + ), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "SIG-A"}] + ), + _thinking_chunk(thinking_blocks=[{"type": "redacted_thinking", "data": "BLOB"}]), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "beta", "signature": ""}] + ), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "", "signature": "SIG-B"}] + ), + _thinking_chunk(content="visible answer"), + ) + + completed_event = next(event for event in events if event.type == "response.completed") + stored_items = [item.model_dump() for item in completed_event.response.output] + + messages = Converter.items_to_messages( + cast(Any, stored_items), + model="anthropic/claude-4-opus", + preserve_thinking_blocks=True, + ) + + assistant_messages = [msg for msg in messages if msg.get("role") == "assistant"] + assert len(assistant_messages) == 1 + # The complete sequence replays through LiteLLM's native assistant thinking_blocks field, + # which round-trips redacted blocks and per-block signatures unchanged. + assert cast(Any, assistant_messages[0])["thinking_blocks"] == [ + { + "type": "thinking", + "thinking": "alpha", + "signature": "SIG-A", + "cache_control": {"type": "ephemeral"}, + }, + {"type": "redacted_thinking", "data": "BLOB"}, + {"type": "thinking", "thinking": "beta", "signature": "SIG-B"}, + ] + assert assistant_messages[0].get("content") == "visible answer" @pytest.mark.asyncio From eb02f60ce0241a3f5fe8511f2773b075d3e9c97e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 08:29:50 +0900 Subject: [PATCH 144/473] fix(sandbox): single-flight cached dependency factories (#4178) Co-authored-by: cosin2077 --- src/agents/sandbox/session/dependencies.py | 90 ++++++- tests/sandbox/test_dependencies.py | 279 +++++++++++++++++++++ 2 files changed, 364 insertions(+), 5 deletions(-) diff --git a/src/agents/sandbox/session/dependencies.py b/src/agents/sandbox/session/dependencies.py index cb1cec7552..1a3f1fd40d 100644 --- a/src/agents/sandbox/session/dependencies.py +++ b/src/agents/sandbox/session/dependencies.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import inspect from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass @@ -73,7 +74,10 @@ class Dependencies: def __init__(self) -> None: self._bindings: dict[DependencyKey, _Binding] = {} self._cache: dict[DependencyKey, object] = {} + self._pending: dict[DependencyKey, asyncio.Task[object]] = {} + self._active_tasks: set[asyncio.Task[object]] = set() self._owned_results: list[object] = [] + self._close_task: asyncio.Task[None] | None = None self._closed = False @classmethod @@ -144,6 +148,9 @@ def _bind( raise DependenciesBindingError(f"Dependency `{key}` is already bound") self._bindings[key] = binding self._cache.pop(key, None) + pending = self._pending.pop(key, None) + if pending is not None: + pending.cancel() async def get(self, key: DependencyKey) -> object | None: binding = self._bindings.get(key) @@ -173,24 +180,92 @@ async def _resolve(self, key: DependencyKey, binding: _Binding) -> object: return binding.value assert isinstance(binding, _FactoryBinding) + if self._closed: + raise DependenciesError(f"Dependencies container is closed; cannot resolve `{key}`") if binding.cache and key in self._cache: return self._cache[key] + if binding.cache: + task = self._pending.get(key) + if task is not None and task.done(): + self._pending.pop(key, None) + task = None + if task is None: + task = self._create_factory_task(key, binding) + self._pending[key] = task + return await self._await_factory_task(key, binding, task, shield=True) + + task = self._create_factory_task(key, binding) + return await self._await_factory_task(key, binding, task, shield=False) + + def _create_factory_task( + self, key: DependencyKey, binding: _FactoryBinding + ) -> asyncio.Task[object]: + task = asyncio.create_task(self._run_factory(key, binding)) + self._active_tasks.add(task) + task.add_done_callback(lambda completed: self._factory_task_done(key, completed)) + return task + + async def _run_factory(self, key: DependencyKey, binding: _FactoryBinding) -> object: produced = binding.factory(self) value = ( await cast(Awaitable[object], produced) if inspect.isawaitable(produced) else produced ) - if binding.cache: - self._cache[key] = value if binding.owns_result: self._owned_results.append(value) + self._raise_if_factory_invalid(key, binding) + if binding.cache: + self._cache[key] = value return value - async def aclose(self) -> None: + async def _await_factory_task( + self, + key: DependencyKey, + binding: _FactoryBinding, + task: asyncio.Task[object], + *, + shield: bool, + ) -> object: + try: + value = await asyncio.shield(task) if shield else await task + except asyncio.CancelledError: + if task.cancelled(): + self._raise_if_factory_invalid(key, binding) + raise + + self._raise_if_factory_invalid(key, binding) + return value + + def _raise_if_factory_invalid(self, key: DependencyKey, binding: _FactoryBinding) -> None: if self._closed: - return - self._closed = True + raise DependenciesError(f"Dependencies container closed while resolving `{key}`") + if self._bindings.get(key) is not binding: + raise DependenciesBindingError( + f"Dependency `{key}` was rebound while its factory was resolving" + ) + + def _factory_task_done(self, key: DependencyKey, task: asyncio.Task[object]) -> None: + self._active_tasks.discard(task) + if self._pending.get(key) is task: + self._pending.pop(key, None) + if not task.cancelled(): + task.exception() + + async def aclose(self) -> None: + task = self._close_task + if task is None: + self._closed = True + task = asyncio.create_task(self._close()) + self._close_task = task + await asyncio.shield(task) + + async def _close(self) -> None: + active_tasks = tuple(self._active_tasks) + for task in active_tasks: + task.cancel() + if active_tasks: + await asyncio.gather(*active_tasks, return_exceptions=True) seen_ids: set[int] = set() for value in reversed(self._owned_results): @@ -199,3 +274,8 @@ async def aclose(self) -> None: continue seen_ids.add(value_id) await _close_best_effort(value) + + self._pending.clear() + self._active_tasks.clear() + self._cache.clear() + self._owned_results.clear() diff --git a/tests/sandbox/test_dependencies.py b/tests/sandbox/test_dependencies.py index ed282cf3e1..b0d37a94b0 100644 --- a/tests/sandbox/test_dependencies.py +++ b/tests/sandbox/test_dependencies.py @@ -1,13 +1,18 @@ from __future__ import annotations +import asyncio + import pytest from agents.sandbox.session import ( Dependencies, DependenciesBindingError, + DependenciesError, DependenciesMissingDependencyError, ) +_EAGER_TASK_FACTORY = getattr(asyncio, "eager_task_factory", None) + class _AsyncClosable: def __init__(self) -> None: @@ -17,6 +22,20 @@ async def aclose(self) -> None: self.calls += 1 +class _BlockingAsyncClosable: + def __init__(self) -> None: + self.calls = 0 + self.completed = False + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def aclose(self) -> None: + self.calls += 1 + self.started.set() + await self.release.wait() + self.completed = True + + class _AsyncCloseMethod: def __init__(self) -> None: self.calls = 0 @@ -99,6 +118,245 @@ def _factory(_dependencies: Dependencies) -> str: assert calls == 1 +@pytest.mark.asyncio +async def test_dependencies_cached_factory_resolves_once_concurrently() -> None: + dependencies = Dependencies() + key = "tests.concurrent_cached_factory" + started = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def _factory(_dependencies: Dependencies) -> _AsyncClosable: + nonlocal calls + calls += 1 + started.set() + await release.wait() + return _AsyncClosable() + + dependencies.bind_factory(key, _factory, cache=True, owns_result=True) + tasks = [asyncio.create_task(dependencies.require(key)) for _ in range(3)] + + await started.wait() + release.set() + values = await asyncio.gather(*tasks) + + assert calls == 1 + assert values[0] is values[1] is values[2] + + await dependencies.aclose() + assert isinstance(values[0], _AsyncClosable) + assert values[0].calls == 1 + + +@pytest.mark.asyncio +async def test_dependencies_cached_factory_survives_waiter_cancellation() -> None: + dependencies = Dependencies() + key = "tests.cancelled_waiter" + started = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def _factory(_dependencies: Dependencies) -> object: + nonlocal calls + calls += 1 + started.set() + await release.wait() + return object() + + dependencies.bind_factory(key, _factory, cache=True) + cancelled_waiter = asyncio.create_task(dependencies.require(key)) + surviving_waiter = asyncio.create_task(dependencies.require(key)) + + await started.wait() + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + + release.set() + value = await surviving_waiter + + assert calls == 1 + assert await dependencies.require(key) is value + + +@pytest.mark.asyncio +async def test_dependencies_cached_factory_failure_allows_retry() -> None: + dependencies = Dependencies() + key = "tests.failed_factory_retry" + started = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def _factory(_dependencies: Dependencies) -> str: + nonlocal calls + calls += 1 + if calls == 1: + started.set() + await release.wait() + raise RuntimeError("factory failed") + return "recovered" + + dependencies.bind_factory(key, _factory, cache=True) + first = asyncio.create_task(dependencies.require(key)) + second = asyncio.create_task(dependencies.require(key)) + + await started.wait() + release.set() + + for task in (first, second): + with pytest.raises(RuntimeError, match="factory failed"): + await task + + assert await dependencies.require(key) == "recovered" + assert calls == 2 + + +@pytest.mark.asyncio +async def test_dependencies_rebind_before_factory_starts_cleans_up_task() -> None: + dependencies = Dependencies() + key = "tests.rebind_before_start" + factory_started = asyncio.Event() + + async def _factory(_dependencies: Dependencies) -> object: + factory_started.set() + return object() + + dependencies.bind_factory(key, _factory, cache=True) + stale_resolve = asyncio.create_task(dependencies.require(key)) + + def _rebind() -> None: + dependencies.bind_factory( + key, lambda _dependencies: "replacement", cache=True, overwrite=True + ) + + asyncio.get_running_loop().call_soon(_rebind) + await asyncio.sleep(0) + + with pytest.raises(DependenciesBindingError, match="rebound"): + await stale_resolve + assert not factory_started.is_set() + assert await dependencies.require(key) == "replacement" + + await asyncio.sleep(0) + assert not dependencies._pending + assert not dependencies._active_tasks + + +@pytest.mark.asyncio +@pytest.mark.skipif(_EAGER_TASK_FACTORY is None, reason="requires Python 3.12+") +async def test_dependencies_eager_factory_failure_allows_retry() -> None: + dependencies = Dependencies() + key = "tests.eager_failed_factory_retry" + calls = 0 + + async def _factory(_dependencies: Dependencies) -> str: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("factory failed") + return "recovered" + + loop = asyncio.get_running_loop() + previous_task_factory = loop.get_task_factory() + loop.set_task_factory(_EAGER_TASK_FACTORY) + try: + dependencies.bind_factory(key, _factory, cache=True) + with pytest.raises(RuntimeError, match="factory failed"): + await dependencies.require(key) + assert await dependencies.require(key) == "recovered" + finally: + loop.set_task_factory(previous_task_factory) + + assert calls == 2 + + +@pytest.mark.asyncio +async def test_dependencies_rebind_preserves_aliased_stale_result_until_close() -> None: + dependencies = Dependencies() + key = "tests.rebind_aliased_result" + started = asyncio.Event() + value = _AsyncClosable() + + async def _factory(_dependencies: Dependencies) -> _AsyncClosable: + started.set() + try: + await asyncio.Future() + raise AssertionError("Unreachable") + except asyncio.CancelledError: + return value + + dependencies.bind_factory(key, _factory, cache=True, owns_result=True) + stale_resolve = asyncio.create_task(dependencies.require(key)) + + await started.wait() + dependencies.bind_factory( + key, + lambda _dependencies: value, + cache=True, + overwrite=True, + owns_result=True, + ) + + with pytest.raises(DependenciesBindingError, match="rebound"): + await stale_resolve + assert await dependencies.require(key) is value + assert value.calls == 0 + + await dependencies.aclose() + assert value.calls == 1 + + +@pytest.mark.asyncio +async def test_dependencies_close_cleans_up_owned_in_flight_result() -> None: + dependencies = Dependencies() + key = "tests.close_in_flight" + started = asyncio.Event() + produced: list[_AsyncClosable] = [] + + async def _factory(_dependencies: Dependencies) -> _AsyncClosable: + started.set() + try: + await asyncio.Future() + raise AssertionError("Unreachable") + except asyncio.CancelledError: + value = _AsyncClosable() + produced.append(value) + return value + + dependencies.bind_factory(key, _factory, cache=True, owns_result=True) + resolve_task = asyncio.create_task(dependencies.require(key)) + + await started.wait() + await dependencies.aclose() + + with pytest.raises(DependenciesError, match="closed"): + await resolve_task + assert len(produced) == 1 + assert produced[0].calls == 1 + + +@pytest.mark.asyncio +async def test_dependencies_close_before_waiter_resumes_rejects_closed_result() -> None: + dependencies = Dependencies() + key = "tests.close_before_waiter_resumes" + produced = asyncio.Event() + value = _AsyncClosable() + + async def _factory(_dependencies: Dependencies) -> _AsyncClosable: + produced.set() + return value + + dependencies.bind_factory(key, _factory, cache=True, owns_result=True) + resolve_task = asyncio.create_task(dependencies.require(key)) + + await produced.wait() + await dependencies.aclose() + + with pytest.raises(DependenciesError, match="closed"): + await resolve_task + assert value.calls == 1 + + @pytest.mark.asyncio async def test_dependencies_uncached_factory_resolves_every_time() -> None: dependencies = Dependencies() @@ -156,6 +414,27 @@ async def test_dependencies_aclose_closes_owned_results_and_is_idempotent() -> N assert isinstance(v3b, _SyncClosable) and v3b.calls == 1 +@pytest.mark.asyncio +async def test_dependencies_aclose_continues_after_waiter_cancellation() -> None: + dependencies = Dependencies() + key = "tests.cancelled_close" + value = _BlockingAsyncClosable() + dependencies.bind_factory(key, lambda _dependencies: value, owns_result=True) + _ = await dependencies.require(key) + + close_waiter = asyncio.create_task(dependencies.aclose()) + await value.started.wait() + close_waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await close_waiter + + value.release.set() + await dependencies.aclose() + assert value.calls == 1 + assert value.completed + + @pytest.mark.asyncio async def test_dependencies_bound_values_are_not_closed() -> None: dependencies = Dependencies() From fb24afcff1d73121b8a74b033123f6ec3fb1a85c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 09:21:55 +0900 Subject: [PATCH 145/473] fix(run): preserve completed tool guardrail results (#4180) Co-authored-by: LHMQ878 --- src/agents/exceptions.py | 9 +- src/agents/result.py | 2 + src/agents/run.py | 2 + src/agents/run_internal/run_loop.py | 2 + src/agents/util/_pretty_print.py | 2 + tests/test_pretty_print.py | 2 + tests/test_source_compat_constructors.py | 28 ++++++ tests/test_tool_guardrails.py | 119 +++++++++++++++++++++++ 8 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index 8c086b2c08..349004c97d 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -11,7 +11,9 @@ from .tool_guardrails import ( ToolGuardrailFunctionOutput, ToolInputGuardrail, + ToolInputGuardrailResult, ToolOutputGuardrail, + ToolOutputGuardrailResult, ) from .util._pretty_print import pretty_print_run_error_details @@ -38,6 +40,11 @@ class RunErrorDetails: context_wrapper: RunContextWrapper[Any] input_guardrail_results: list[InputGuardrailResult] output_guardrail_results: list[OutputGuardrailResult] + tool_input_guardrail_results: list[ToolInputGuardrailResult] = field(default_factory=list) + """Tool input guardrail results accumulated from completed turns before the run failed.""" + + tool_output_guardrail_results: list[ToolOutputGuardrailResult] = field(default_factory=list) + """Tool output guardrail results accumulated from completed turns before the run failed.""" def __str__(self) -> str: return pretty_print_run_error_details(self) diff --git a/src/agents/result.py b/src/agents/result.py index daf8516927..6482cd2813 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -947,6 +947,8 @@ def _create_error_details(self) -> RunErrorDetails | None: context_wrapper=self.context_wrapper, input_guardrail_results=self.input_guardrail_results, output_guardrail_results=self.output_guardrail_results, + tool_input_guardrail_results=self.tool_input_guardrail_results, + tool_output_guardrail_results=self.tool_output_guardrail_results, ) def _check_errors(self): diff --git a/src/agents/run.py b/src/agents/run.py index ee8125ddab..286a0e9fa5 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1601,6 +1601,8 @@ def _finalize_result(result: RunResult) -> RunResult: context_wrapper=context_wrapper, input_guardrail_results=input_guardrail_results, output_guardrail_results=output_guardrail_results, + tool_input_guardrail_results=tool_input_guardrail_results, + tool_output_guardrail_results=tool_output_guardrail_results, ) raise finally: diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 65d4e794e8..a1389670f5 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1410,6 +1410,8 @@ async def _save_stream_items_without_count( context_wrapper=context_wrapper, input_guardrail_results=streamed_result.input_guardrail_results, output_guardrail_results=streamed_result.output_guardrail_results, + tool_input_guardrail_results=streamed_result.tool_input_guardrail_results, + tool_output_guardrail_results=streamed_result.tool_output_guardrail_results, ) raise except Exception as e: diff --git a/src/agents/util/_pretty_print.py b/src/agents/util/_pretty_print.py index 9af5a3a1de..4c869f3a84 100644 --- a/src/agents/util/_pretty_print.py +++ b/src/agents/util/_pretty_print.py @@ -46,6 +46,8 @@ def pretty_print_run_error_details(result: "RunErrorDetails") -> str: output += f"\n- {len(result.raw_responses)} raw response(s)" output += f"\n- {len(result.input_guardrail_results)} input guardrail result(s)" output += f"\n- {len(result.output_guardrail_results)} output guardrail result(s)" + output += f"\n- {len(result.tool_input_guardrail_results)} tool input guardrail result(s)" + output += f"\n- {len(result.tool_output_guardrail_results)} tool output guardrail result(s)" output += "\n(See `RunErrorDetails` for more details)" return output diff --git a/tests/test_pretty_print.py b/tests/test_pretty_print.py index 5d76e0cc0a..1bb6814bd5 100644 --- a/tests/test_pretty_print.py +++ b/tests/test_pretty_print.py @@ -84,6 +84,8 @@ def test_pretty_run_error_details(): - 0 raw response(s) - 0 input guardrail result(s) - 0 output guardrail result(s) +- 0 tool input guardrail result(s) +- 0 tool output guardrail result(s) (See `RunErrorDetails` for more details)\ """) diff --git a/tests/test_source_compat_constructors.py b/tests/test_source_compat_constructors.py index 9e9aae6e34..7274cfda1d 100644 --- a/tests/test_source_compat_constructors.py +++ b/tests/test_source_compat_constructors.py @@ -16,6 +16,7 @@ MultiProvider, RunConfig, RunContextWrapper, + RunErrorDetails, RunResult, RunResultStreaming, SessionSettings, @@ -40,6 +41,33 @@ async def keep_handoff_input(data: HandoffInputData) -> HandoffInputData: assert config.session_settings is None +def test_run_error_details_positional_prefix_and_defaults_are_preserved() -> None: + first = RunErrorDetails( + "input", + [], + [], + Agent(name="agent"), + RunContextWrapper(context=None), + [], + [], + ) + second = RunErrorDetails( + "input", + [], + [], + Agent(name="agent"), + RunContextWrapper(context=None), + [], + [], + ) + + first.tool_input_guardrail_results.append(cast(Any, object())) + first.tool_output_guardrail_results.append(cast(Any, object())) + + assert second.tool_input_guardrail_results == [] + assert second.tool_output_guardrail_results == [] + + def test_run_config_session_settings_positional_binding_is_preserved() -> None: session_settings = SessionSettings(limit=123) config = RunConfig( diff --git a/tests/test_tool_guardrails.py b/tests/test_tool_guardrails.py index 30e862f1fa..9402edf247 100644 --- a/tests/test_tool_guardrails.py +++ b/tests/test_tool_guardrails.py @@ -7,6 +7,8 @@ from agents import ( Agent, + MaxTurnsExceeded, + Runner, ToolGuardrailFunctionOutput, ToolInputGuardrail, ToolInputGuardrailData, @@ -15,10 +17,14 @@ ToolOutputGuardrailData, ToolOutputGuardrailTripwireTriggered, UserError, + function_tool, ) from agents.tool_context import ToolContext from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail +from .fake_model import FakeModel +from .test_responses import get_function_tool_call + def get_mock_tool_context(tool_arguments: str = '{"param": "value"}') -> ToolContext: """Helper to create a mock tool context for testing.""" @@ -520,6 +526,119 @@ def mixed_guardrail(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutpu assert result.output_info["status"] == "clean" +def _agent_with_repeated_guarded_tool_calls( + *, + input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, +) -> Agent[Any]: + @function_tool + def guarded(query: str) -> str: + return "tool output" + + guarded.tool_input_guardrails = input_guardrails or [] + guarded.tool_output_guardrails = output_guardrails or [] + + model = FakeModel() + tool_call = [get_function_tool_call("guarded", '{"query": "secret"}')] + model.add_multiple_turn_outputs([tool_call, tool_call]) + return Agent(name="guarded_tool_agent", model=model, tools=[guarded]) + + +async def _run_until_max_turns(agent: Agent[Any], *, streaming: bool) -> MaxTurnsExceeded: + with pytest.raises(MaxTurnsExceeded) as exc_info: + if streaming: + result = Runner.run_streamed(agent, "go", max_turns=2) + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, "go", max_turns=2) + return exc_info.value + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_tool_input_guardrail_results_reported_on_max_turns(streaming: bool): + async def reject(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.reject_content( + message="blocked by policy", output_info="input_rejected" + ) + + guardrail: ToolInputGuardrail[Any] = ToolInputGuardrail( + guardrail_function=reject, + name="input_rejects", + ) + exc = await _run_until_max_turns( + _agent_with_repeated_guarded_tool_calls(input_guardrails=[guardrail]), + streaming=streaming, + ) + + assert exc.run_data is not None + assert [ + result.guardrail.get_name() for result in exc.run_data.tool_input_guardrail_results + ] == ["input_rejects", "input_rejects"] + assert exc.run_data.tool_output_guardrail_results == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_tool_output_guardrail_results_reported_on_max_turns(streaming: bool): + async def reject(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.reject_content( + message="blocked by policy", output_info="output_rejected" + ) + + guardrail: ToolOutputGuardrail[Any] = ToolOutputGuardrail( + guardrail_function=reject, + name="output_rejects", + ) + exc = await _run_until_max_turns( + _agent_with_repeated_guarded_tool_calls(output_guardrails=[guardrail]), + streaming=streaming, + ) + + assert exc.run_data is not None + assert [ + result.guardrail.get_name() for result in exc.run_data.tool_output_guardrail_results + ] == ["output_rejects", "output_rejects"] + assert exc.run_data.tool_input_guardrail_results == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_tool_tripwire_preserves_completed_turn_results(streaming: bool): + guardrail_runs = 0 + + async def allow_then_raise(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + nonlocal guardrail_runs + guardrail_runs += 1 + if guardrail_runs == 2: + return ToolGuardrailFunctionOutput.raise_exception(output_info="second_turn") + return ToolGuardrailFunctionOutput.allow(output_info="first_turn") + + guardrail: ToolInputGuardrail[Any] = ToolInputGuardrail( + guardrail_function=allow_then_raise, + name="allow_then_raise", + ) + agent = _agent_with_repeated_guarded_tool_calls(input_guardrails=[guardrail]) + + with pytest.raises(ToolInputGuardrailTripwireTriggered) as exc_info: + if streaming: + result = Runner.run_streamed(agent, "go") + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, "go") + + exc = exc_info.value + assert exc.guardrail is guardrail + assert exc.output.output_info == "second_turn" + assert exc.run_data is not None + assert [result.output.output_info for result in exc.run_data.tool_input_guardrail_results] == [ + "first_turn" + ] + assert exc.run_data.tool_output_guardrail_results == [] + + if __name__ == "__main__": # Run a simple test to verify functionality async def main(): From 154e44146d2f55116491f72cd055faafa2980231 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 09:26:54 +0900 Subject: [PATCH 146/473] fix(memory): preserve repeated history provenance (#4181) Co-authored-by: Henry Su --- .../run_internal/session_persistence.py | 12 +- tests/memory/test_session.py | 37 +++++++ tests/test_agent_runner.py | 104 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index b4c98d2747..3f44e7d2d7 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -217,6 +217,10 @@ async def prepare_input_with_session( ) history_for_callback = copy.deepcopy(converted_history) new_items_for_callback = copy.deepcopy(new_input_list) + # Keep the original history objects alive so their identities remain valid even if the + # callback removes them from the list it receives. + original_history_objects = list(history_for_callback) + original_history_object_ids = {id(item) for item in original_history_objects} combined = session_input_callback(history_for_callback, new_items_for_callback) if inspect.isawaitable(combined): combined = await combined @@ -246,12 +250,18 @@ async def prepare_input_with_session( new_key = _session_item_key(item) if _consume_reference(new_refs, new_key, item): new_counts[new_key] = max(new_counts.get(new_key, 0) - 1, 0) - appended.append(item) + if id(item) in original_history_object_ids: + prune_history_indexes.add(combined_index) + else: + appended.append(item) continue if _consume_reference(history_refs, history_key, item): history_counts[history_key] = max(history_counts.get(history_key, 0) - 1, 0) prune_history_indexes.add(combined_index) continue + if id(item) in original_history_object_ids: + prune_history_indexes.add(combined_index) + continue if history_counts.get(history_key, 0) > 0: history_counts[history_key] = history_counts.get(history_key, 0) - 1 prune_history_indexes.add(combined_index) diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index be761aea6e..3b180539b6 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -569,6 +569,43 @@ def filter_assistant_messages(history, new_input): session.close() +@pytest.mark.parametrize("runner_method", ["run", "run_sync", "run_streamed"]) +@pytest.mark.asyncio +async def test_session_callback_repeating_history_does_not_grow_session(runner_method): + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "test_memory.db" + model = FakeModel() + agent = Agent(name="test", model=model) + session = SQLiteSession("session_repeat", db_path) + + def repeat_first(history, new_input): + if not history: + return new_input + return history + [history[0]] + new_input + + try: + for turn in range(3): + model.set_next_output([get_text_message(f"assistant {turn}")]) + await run_agent_async( + runner_method, + agent, + f"user {turn}", + session=session, + run_config=RunConfig(session_input_callback=repeat_first), + ) + + stored = await session.get_items() + user_messages = [item for item in stored if item.get("role") == "user"] + assert [item.get("content") for item in user_messages] == [ + "user 0", + "user 1", + "user 2", + ] + assert len(stored) == 6 + finally: + session.close() + + @pytest.mark.asyncio async def test_sqlite_session_unicode_content(): """Test that session correctly stores and retrieves unicode/non-ASCII content.""" diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 8d343b6609..f651dfff9c 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -2676,6 +2676,110 @@ def callback( assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"] +@pytest.mark.asyncio +async def test_prepare_input_with_session_repeated_history_keeps_equal_new_item() -> None: + history_item = cast(TResponseInputItem, {"role": "user", "content": "same"}) + session = SimpleListSession(history=[history_item]) + + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + return [history[0], history[0], new_input[0]] + + prepared, session_items = await prepare_input_with_session("same", session, callback) + + assert [cast(dict[str, Any], item).get("content") for item in prepared] == [ + "same", + "same", + "same", + ] + assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["same"] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_async_callback_moves_repeated_history_item() -> None: + history_item = cast(TResponseInputItem, {"role": "user", "content": "history"}) + session = SimpleListSession(history=[history_item]) + + async def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + await asyncio.sleep(0) + moved = history.pop(0) + return [moved, new_input[0], moved] + + prepared, session_items = await prepare_input_with_session("new", session, callback) + + assert [cast(dict[str, Any], item).get("content") for item in prepared] == [ + "history", + "new", + "history", + ] + assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_history_moved_to_new_input_stays_history() -> None: + history_item = cast(TResponseInputItem, {"role": "user", "content": "history"}) + session = SimpleListSession(history=[history_item]) + + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + moved = history.pop(0) + new_input.insert(0, moved) + return new_input + [moved] + + prepared, session_items = await prepare_input_with_session("new", session, callback) + + assert [cast(dict[str, Any], item).get("content") for item in prepared] == [ + "history", + "new", + "history", + ] + assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_callback_replaces_history_item() -> None: + history_item = cast(TResponseInputItem, {"role": "user", "content": "history"}) + replacement = cast(TResponseInputItem, {"role": "user", "content": "summary"}) + session = SimpleListSession(history=[history_item]) + + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + history[0] = replacement + return history + new_input + + prepared, session_items = await prepare_input_with_session("new", session, callback) + + assert [cast(dict[str, Any], item).get("content") for item in prepared] == [ + "summary", + "new", + ] + assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_extra_reconstructed_history_item_stays_new() -> None: + history_item = cast(TResponseInputItem, {"role": "user", "content": "history"}) + session = SimpleListSession(history=[history_item]) + + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + rebuilt = cast(TResponseInputItem, dict(cast(dict[str, Any], history[0]))) + return [history[0], rebuilt, new_input[0]] + + _, session_items = await prepare_input_with_session("new", session, callback) + + assert [cast(dict[str, Any], item).get("content") for item in session_items] == [ + "history", + "new", + ] + + @pytest.mark.asyncio async def test_prepare_input_with_openai_conversation_strips_assistant_history_ids() -> None: class DummyOpenAIConversationsSession(OpenAIConversationsSession): From 1ebcfd4f01323d7ec88b9108ff6bdadd6e365474 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 09:28:04 +0900 Subject: [PATCH 147/473] fix: redact invalid tool argument errors (#4182) Co-authored-by: Illia Oleksiuk --- src/agents/agent.py | 10 +- .../experimental/codex/codex_tool.py | 20 +++- src/agents/tool.py | 9 +- .../experiemental/codex/test_codex_tool.py | 55 ++++++++- tests/test_error_logging_redaction.py | 105 ++++++++++++++++++ tests/test_programmatic_tool_calling.py | 3 +- 6 files changed, 192 insertions(+), 10 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index fc822bf891..1d42624f2b 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, TypeAdapter, ValidationError from typing_extensions import NotRequired, TypedDict +from . import _debug from ._tool_identity import get_function_tool_approval_keys from .agent_output import AgentOutputSchemaBase from .agent_tool_input import ( @@ -681,10 +682,17 @@ async def _run_agent_impl(context: ToolContext, input_json: str) -> Any: ) _log_function_tool_invocation(tool_name=tool_name, input_json=input_json) + base_message = f"Invalid JSON input for tool {tool_name}" + validation_failed = False try: parsed_params = params_adapter.validate_python(json_data) except ValidationError as exc: - raise ModelBehaviorError(f"Invalid JSON input for tool {tool_name}: {exc}") from exc + if not _debug.DONT_LOG_TOOL_DATA: + raise ModelBehaviorError(f"{base_message}: {exc}") from exc + validation_failed = True + + if validation_failed: + raise ModelBehaviorError(base_message) params_data = _normalize_tool_input(parsed_params, tool_name) resolved_input = await resolve_agent_tool_input( diff --git a/src/agents/extensions/experimental/codex/codex_tool.py b/src/agents/extensions/experimental/codex/codex_tool.py index 2c252e3d00..7138286dfe 100644 --- a/src/agents/extensions/experimental/codex/codex_tool.py +++ b/src/agents/extensions/experimental/codex/codex_tool.py @@ -530,19 +530,27 @@ def _validate_default_run_context_thread_id_suffix(value: str) -> str: def _parse_tool_input(parameters_model: type[BaseModel], input_json: str) -> BaseModel: + base_message = "Invalid JSON input for codex tool" + decode_failed = False try: json_data = json.loads(input_json) if input_json else {} except Exception as exc: - if _debug.DONT_LOG_TOOL_DATA: - logger.debug("Invalid JSON input for codex tool") - else: - logger.debug("Invalid JSON input for codex tool: %s", input_json) - raise ModelBehaviorError(f"Invalid JSON input for codex tool: {input_json}") from exc + if not _debug.DONT_LOG_TOOL_DATA: + logger.debug("%s: %s", base_message, input_json) + raise ModelBehaviorError(f"{base_message}: {input_json}") from exc + logger.debug(base_message) + decode_failed = True + + if decode_failed: + raise ModelBehaviorError(base_message) try: return parameters_model.model_validate(json_data) except ValidationError as exc: - raise ModelBehaviorError(f"Invalid JSON input for codex tool: {exc}") from exc + if not _debug.DONT_LOG_TOOL_DATA: + raise ModelBehaviorError(f"{base_message}: {exc}") from exc + + raise ModelBehaviorError(base_message) def _normalize_parameters(params: BaseModel) -> CodexToolCallArguments: diff --git a/src/agents/tool.py b/src/agents/tool.py index 63ac29af91..5552d5b11d 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -2600,6 +2600,8 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: json_data = _parse_function_tool_json_input(tool_name=tool_name, input_json=input) _log_function_tool_invocation(tool_name=tool_name, input_json=input) + base_message = f"Invalid JSON input for tool {tool_name}" + validation_failed = False try: parsed = ( schema.params_pydantic_model(**json_data) @@ -2607,7 +2609,12 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: else schema.params_pydantic_model() ) except ValidationError as e: - raise ModelBehaviorError(f"Invalid JSON input for tool {tool_name}: {e}") from e + if not _debug.DONT_LOG_TOOL_DATA: + raise ModelBehaviorError(f"{base_message}: {e}") from e + validation_failed = True + + if validation_failed: + raise ModelBehaviorError(base_message) args, kwargs_dict = schema.to_call_args(parsed) diff --git a/tests/extensions/experiemental/codex/test_codex_tool.py b/tests/extensions/experiemental/codex/test_codex_tool.py index 3aeb7db73d..36b6a2822a 100644 --- a/tests/extensions/experiemental/codex/test_codex_tool.py +++ b/tests/extensions/experiemental/codex/test_codex_tool.py @@ -12,7 +12,7 @@ import pytest from openai.types.responses import ResponseFunctionToolCall -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, ValidationError import agents._debug as _debug from agents import Agent, function_tool @@ -2055,3 +2055,56 @@ def test_codex_tool_coerce_options_rejects_empty_run_context_key() -> None: "run_context_thread_id_key": " ", } ) + + +_CODEX_TOOL_ARGUMENT_SECRET = "SECRET_CODEX_TOOL_ARGUMENT_123" + + +@pytest.mark.parametrize( + "input_json, cause_type", + [ + ( + f'{{"inputs": "{_CODEX_TOOL_ARGUMENT_SECRET}"}}', + ValidationError, + ), + ( + f"not valid json {_CODEX_TOOL_ARGUMENT_SECRET}", + json.JSONDecodeError, + ), + ], + ids=["validation", "json_decode"], +) +@pytest.mark.parametrize("redact", [True, False], ids=["redacted", "diagnostic"]) +@pytest.mark.asyncio +async def test_codex_tool_argument_errors_respect_tool_data_redaction( + monkeypatch: pytest.MonkeyPatch, + input_json: str, + cause_type: type[Exception], + redact: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redact) + tool = codex_tool( + CodexToolOptions( + codex=cast(Codex, FakeCodex(CodexMockState())), + failure_error_function=None, + ) + ) + context = ToolContext( + None, + tool_name=tool.name, + tool_call_id="call-1", + tool_arguments=input_json, + ) + + with pytest.raises(ModelBehaviorError) as exc_info: + await tool.on_invoke_tool(context, input_json) + + error = exc_info.value + if redact: + assert str(error) == "Invalid JSON input for codex tool" + assert _CODEX_TOOL_ARGUMENT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + else: + assert _CODEX_TOOL_ARGUMENT_SECRET in str(error) + assert isinstance(error.__cause__, cause_type) diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index 85ebd221b3..b194c6f41b 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -20,15 +20,18 @@ import httpx import pytest from openai import AsyncOpenAI +from pydantic import BaseModel, ValidationError import agents._debug as _debug from agents import ( Agent, + ModelBehaviorError, ModelSettings, ModelTracing, OpenAIResponsesModel, RunConfig, RunContextWrapper, + function_tool, trace, ) from agents.logger import ( @@ -48,6 +51,7 @@ resolve_approval_rejection_message, ) from agents.run_state import _deserialize_items +from agents.tool_context import ToolContext from agents.tracing.processor_interface import TracingProcessor from agents.tracing.provider import SynchronousMultiTracingProcessor from agents.tracing.spans import Span @@ -753,3 +757,104 @@ def boom(_args): assert record.__dict__["openai_agents_diagnostic_context"] == {"tool_name": tool_name} assert record.exc_info is not None assert "SECRET_FMT_123" in caplog.text + + +_TOOL_ARGUMENT_SECRET = "SECRET_TOOL_ARGUMENT_123" + + +def _requires_integer_argument(value: int) -> str: + return str(value) + + +@pytest.mark.asyncio +async def test_function_tool_validation_error_redacts_payload_when_tool_data_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + tool = function_tool(_requires_integer_argument, failure_error_function=None) + payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}' + + with pytest.raises(ModelBehaviorError) as exc_info: + await tool.on_invoke_tool( + ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload), + payload, + ) + + error = exc_info.value + assert str(error) == f"Invalid JSON input for tool {tool.name}" + assert _TOOL_ARGUMENT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + + +@pytest.mark.asyncio +async def test_function_tool_validation_error_preserves_diagnostics_when_tool_data_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + tool = function_tool(_requires_integer_argument, failure_error_function=None) + payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}' + + with pytest.raises(ModelBehaviorError) as exc_info: + await tool.on_invoke_tool( + ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload), + payload, + ) + + error = exc_info.value + assert _TOOL_ARGUMENT_SECRET in str(error) + assert isinstance(error.__cause__, ValidationError) + + +class _AgentToolParameters(BaseModel): + value: int + + +@pytest.mark.asyncio +async def test_agent_tool_validation_error_redacts_payload_when_tool_data_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + tool = Agent(name="worker").as_tool( + tool_name="worker_tool", + tool_description="Runs the worker agent.", + parameters=_AgentToolParameters, + failure_error_function=None, + ) + payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}' + + with pytest.raises(ModelBehaviorError) as exc_info: + await tool.on_invoke_tool( + ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload), + payload, + ) + + error = exc_info.value + assert str(error) == f"Invalid JSON input for tool {tool.name}" + assert _TOOL_ARGUMENT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + + +@pytest.mark.asyncio +async def test_agent_tool_validation_error_preserves_diagnostics_when_tool_data_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + tool = Agent(name="worker").as_tool( + tool_name="worker_tool", + tool_description="Runs the worker agent.", + parameters=_AgentToolParameters, + failure_error_function=None, + ) + payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}' + + with pytest.raises(ModelBehaviorError) as exc_info: + await tool.on_invoke_tool( + ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload), + payload, + ) + + error = exc_info.value + assert _TOOL_ARGUMENT_SECRET in str(error) + assert isinstance(error.__cause__, ValidationError) diff --git a/tests/test_programmatic_tool_calling.py b/tests/test_programmatic_tool_calling.py index 3236a7fbc0..79b32fa2c6 100644 --- a/tests/test_programmatic_tool_calling.py +++ b/tests/test_programmatic_tool_calling.py @@ -487,7 +487,8 @@ def failing_tool(sku: str) -> InventoryOutput: result = await failing_tool.on_invoke_tool(context, "{}") assert result.startswith("An error occurred while running the tool. Please try again. Error:") - assert "sku" in result + assert "Invalid JSON input for tool failing_tool" in result + assert "sku" not in result @pytest.mark.asyncio From 69e26269f52a1fde684154376d77e5a21b507c19 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 09:46:02 +0900 Subject: [PATCH 148/473] fix: defer non-stream session saves until output guardrails (#4184) Co-authored-by: Henry Su --- src/agents/run.py | 74 ++++--- .../run_internal/agent_runner_helpers.py | 38 +++- tests/test_agent_runner.py | 198 ++++++++++++++++++ tests/test_agent_runner_streamed.py | 36 ++-- 4 files changed, 303 insertions(+), 43 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 286a0e9fa5..aafbf0e9ec 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -14,6 +14,7 @@ AgentsException, InputGuardrailTripwireTriggered, MaxTurnsExceeded, + OutputGuardrailTripwireTriggered, RunErrorDetails, UserError, ) @@ -61,6 +62,7 @@ resolve_processed_response, resolve_resumed_context, resolve_trace_settings, + save_final_turn_items_after_guardrails, save_turn_items_if_needed, should_cancel_parallel_model_task_on_input_guardrail_trip, snapshot_usage, @@ -85,6 +87,7 @@ from .run_internal.prompt_cache_key import PromptCacheKeyResolver from .run_internal.run_grouping import resolve_run_grouping_id from .run_internal.run_loop import ( + _retained_items_for_blocked_output, cleanup_models_after_run, get_all_tools, get_output_schema, @@ -1378,15 +1381,6 @@ def _finalize_result(result: RunResult) -> RunResult: items_to_save_turn = list(turn_session_items) if not isinstance(turn_result.next_step, NextStepInterruption): - # When resuming a turn we have already persisted the tool_call items; - if ( - is_resumed_state - and run_state - and run_state._current_turn_persisted_item_count > 0 - ): - items_to_save_turn = [ - item for item in items_to_save_turn if item.type != "tool_call_item" - ] if session_persistence_enabled: output_call_ids = { item.raw_item.get("call_id") @@ -1418,7 +1412,9 @@ def _finalize_result(result: RunResult) -> RunResult: ) ): items_to_save_turn.append(item) - if items_to_save_turn: + if items_to_save_turn and not isinstance( + turn_result.next_step, NextStepFinalOutput + ): logger.debug( "Persisting turn items (types=%s)", [item.type for item in items_to_save_turn], @@ -1452,13 +1448,48 @@ def _finalize_result(result: RunResult) -> RunResult: try: if isinstance(turn_result.next_step, NextStepFinalOutput): - await run_output_guardrails( - current_agent.output_guardrails - + (run_config.output_guardrails or []), - current_agent, - turn_result.next_step.output, - context_wrapper, - output_guardrail_results, + try: + await run_output_guardrails( + current_agent.output_guardrails + + (run_config.output_guardrails or []), + current_agent, + turn_result.next_step.output, + context_wrapper, + output_guardrail_results, + ) + except OutputGuardrailTripwireTriggered: + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=session_persistence_enabled, + input_guardrail_results=input_guardrail_results, + items=_retained_items_for_blocked_output(items_to_save_turn), + response_id=turn_result.model_response.response_id, + store=store_setting, + ) + raise + except (Exception, asyncio.CancelledError): + # Preserve the released non-stream behavior for guardrail errors + # and cancellation: the completed final turn remains replayable. + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=session_persistence_enabled, + input_guardrail_results=input_guardrail_results, + items=items_to_save_turn, + response_id=turn_result.model_response.response_id, + store=store_setting, + ) + raise + + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=session_persistence_enabled, + input_guardrail_results=input_guardrail_results, + items=items_to_save_turn, + response_id=turn_result.model_response.response_id, + store=store_setting, ) # Ensure starting_input is not None and not RunState @@ -1489,15 +1520,6 @@ def _finalize_result(result: RunResult) -> RunResult: result._current_turn_persisted_item_count = ( run_state._current_turn_persisted_item_count ) - await save_turn_items_if_needed( - session=session, - run_state=run_state, - session_persistence_enabled=session_persistence_enabled, - input_guardrail_results=input_guardrail_results, - items=session_items_for_turn(turn_result), - response_id=turn_result.model_response.response_id, - store=store_setting, - ) result._original_input = copy_input_items(original_input) return _finalize_result(result) elif isinstance(turn_result.next_step, NextStepInterruption): diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 2e212e597f..d380ebe649 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -40,7 +40,7 @@ NextStepRunAgain, ProcessedResponse, ) -from .session_persistence import save_result_to_session +from .session_persistence import save_result_to_session, save_resumed_turn_items from .tool_use_tracker import AgentToolUseTracker, serialize_tool_use_tracker __all__ = [ @@ -59,6 +59,7 @@ "resolve_trace_settings", "resolve_processed_response", "resolve_resumed_context", + "save_final_turn_items_after_guardrails", "save_turn_items_if_needed", "should_cancel_parallel_model_task_on_input_guardrail_trip", "update_run_state_for_interruption", @@ -491,6 +492,41 @@ async def save_turn_items_if_needed( ) +async def save_final_turn_items_after_guardrails( + *, + session: Session | None, + run_state: RunState | None, + session_persistence_enabled: bool, + input_guardrail_results: list[InputGuardrailResult], + items: list[RunItem], + response_id: str | None, + store: bool | None = None, +) -> None: + """Persist deferred final-turn items without skipping a partially persisted resumed turn.""" + if not session_persistence_enabled or not items: + return + if input_guardrails_triggered(input_guardrail_results): + return + if run_state is not None and run_state._current_turn_persisted_item_count > 0: + run_state._current_turn_persisted_item_count = await save_resumed_turn_items( + session=session, + items=items, + persisted_count=run_state._current_turn_persisted_item_count, + response_id=response_id, + reasoning_item_id_policy=run_state._reasoning_item_id_policy, + store=store, + ) + return + await save_result_to_session( + session, + [], + list(items), + run_state, + response_id=response_id, + store=store, + ) + + def resolve_processed_response( *, run_state: RunState | None, diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index f651dfff9c..06f6705e76 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -3967,6 +3967,9 @@ async def noop_initialize_computer_tools( monkeypatch.setattr( "agents.run_internal.session_persistence.save_result_to_session", save_wrapper ) + monkeypatch.setattr( + "agents.run_internal.agent_runner_helpers.save_result_to_session", save_wrapper + ) monkeypatch.setattr("agents.run.run_single_turn", fake_run_single_turn) monkeypatch.setattr("agents.run_internal.run_loop.run_single_turn", fake_run_single_turn) monkeypatch.setattr("agents.run.run_output_guardrails", fake_run_output_guardrails) @@ -4019,6 +4022,201 @@ def guardrail_function( await Runner.run(agent, input="user_message") +def test_output_guardrail_tripwire_does_not_save_assistant_message_to_session_sync() -> None: + def guardrail_function( + _context: RunContextWrapper[Any], _agent: Agent[Any], _agent_output: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + session = SimpleListSession() + model = FakeModel() + model.set_next_output([get_text_message("should_not_be_saved")]) + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], + ) + + with pytest.raises(OutputGuardrailTripwireTriggered): + Runner.run_sync(agent, input="user_message", session=session) + + items = asyncio.run(session.get_items()) + assert [ + cast(dict[str, Any], item).get("type") or cast(dict[str, Any], item).get("role") + for item in items + ] == ["user"] + + +@pytest.mark.asyncio +async def test_output_guardrail_error_preserves_final_output_in_session() -> None: + def guardrail_function( + _context: RunContextWrapper[Any], _agent: Agent[Any], _agent_output: Any + ) -> GuardrailFunctionOutput: + raise RuntimeError("guardrail failed") + + session = SimpleListSession() + model = FakeModel() + model.set_next_output([get_text_message("preserved_on_guardrail_error")]) + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], + ) + + with pytest.raises(RuntimeError, match="guardrail failed"): + await Runner.run(agent, input="user_message", session=session) + + items = await session.get_items() + assert [ + cast(dict[str, Any], item).get("type") or cast(dict[str, Any], item).get("role") + for item in items + ] == ["user", "message"] + + +@pytest.mark.asyncio +async def test_output_guardrail_cancellation_preserves_final_output_in_session() -> None: + guardrail_started = asyncio.Event() + + async def guardrail_function( + _context: RunContextWrapper[Any], _agent: Agent[Any], _agent_output: Any + ) -> GuardrailFunctionOutput: + guardrail_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + session = SimpleListSession() + model = FakeModel() + model.set_next_output([get_text_message("preserved_on_guardrail_cancellation")]) + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], + ) + + run_task = asyncio.create_task(Runner.run(agent, input="user_message", session=session)) + await guardrail_started.wait() + run_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await run_task + + items = await session.get_items() + assert [ + cast(dict[str, Any], item).get("type") or cast(dict[str, Any], item).get("role") + for item in items + ] == ["user", "message"] + + +@pytest.mark.asyncio +async def test_resumed_final_output_persists_once_after_passing_output_guardrail() -> None: + def guardrail_function( + _context: RunContextWrapper[Any], _agent: Agent[Any], _agent_output: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + @function_tool + def foo(a: str) -> str: + return f"result:{a}" + + session = SimpleListSession() + model = FakeModel() + model.set_next_output([get_function_tool_call("foo", json.dumps({"a": "b"}))]) + agent = Agent( + name="test", + model=model, + tools=[foo], + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], + ) + + streamed = Runner.run_streamed(agent, input="user_message", session=session) + async for event in streamed.stream_events(): + if event.type == "run_item_stream_event" and event.name == "tool_output": + streamed.cancel(mode="after_turn") + + items_before_resume = await session.get_items() + state = streamed.to_state() + state._current_turn_persisted_item_count = 2 + + model.set_next_output([get_text_message("accepted_final")]) + resumed = await Runner.run(agent, state, session=session) + assert resumed.final_output == "accepted_final" + + items_after_resume = await session.get_items() + assert items_after_resume[: len(items_before_resume)] == items_before_resume + assistant_messages = [ + item + for item in items_after_resume + if cast(dict[str, Any], item).get("role") == "assistant" + and cast(dict[str, Any], item).get("type") == "message" + ] + assert len(assistant_messages) == 1 + content = cast(dict[str, Any], assistant_messages[0]).get("content") + assert isinstance(content, list) + assert any(isinstance(part, dict) and part.get("text") == "accepted_final" for part in content) + + +@pytest.mark.parametrize("tripwire_triggered", [False, True]) +@pytest.mark.asyncio +async def test_resumed_final_tool_persists_call_and_output_after_output_guardrail( + tripwire_triggered: bool, +) -> None: + def guardrail_function( + _context: RunContextWrapper[Any], _agent: Agent[Any], _agent_output: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=tripwire_triggered, + ) + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + return "committed-result" + + session = SimpleListSession() + model = FakeModel() + model.set_next_output([get_function_tool_call("commit_tool", "{}", call_id="call-first")]) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], + ) + + streamed = Runner.run_streamed(agent, input="user_message", session=session) + async for event in streamed.stream_events(): + if event.type == "run_item_stream_event" and event.name == "tool_output": + streamed.cancel(mode="after_turn") + + state = streamed.to_state() + assert state._current_turn_persisted_item_count == 2 + + agent.tool_use_behavior = "stop_on_first_tool" + model.set_next_output([get_function_tool_call("commit_tool", "{}", call_id="call-second")]) + + if tripwire_triggered: + with pytest.raises(OutputGuardrailTripwireTriggered): + await Runner.run(agent, state, session=session) + else: + result = await Runner.run(agent, state, session=session) + assert result.final_output == "committed-result" + + assert state._current_turn_persisted_item_count == 4 + items = await session.get_items() + assert [ + ( + cast(dict[str, Any], item).get("type") or cast(dict[str, Any], item).get("role"), + cast(dict[str, Any], item).get("call_id"), + ) + for item in items + ] == [ + ("user", None), + ("function_call", "call-first"), + ("function_call_output", "call-first"), + ("function_call", "call-second"), + ("function_call_output", "call-second"), + ] + + @pytest.mark.asyncio async def test_input_guardrail_no_tripwire_continues_execution(): """Test input guardrail that doesn't trigger tripwire continues execution.""" diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index e7abbfbf43..a908eced94 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -2152,13 +2152,10 @@ def output_guardrail( ] +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.asyncio -async def test_streamed_blocked_message_final_output_is_not_persisted() -> None: - """Control for the committed-tool case: a rejected message is still withheld from the session. - - Streamed-only on purpose. The non-streamed path persists a turn before its output guardrails - run, so it keeps the rejected message today; that difference is out of scope here. - """ +async def test_blocked_message_final_output_is_not_persisted(mode: str) -> None: + """Control for the committed-tool case: a rejected message is withheld from the session.""" def output_guardrail( _context: RunContextWrapper[Any], @@ -2177,16 +2174,20 @@ def output_guardrail( session = SimpleListSession() with pytest.raises(OutputGuardrailTripwireTriggered): - result = Runner.run_streamed(agent, "user_message", session=session) - await consume_stream(result) + if mode == "non_streamed": + await Runner.run(agent, "user_message", session=session) + else: + result = Runner.run_streamed(agent, "user_message", session=session) + await consume_stream(result) saved_items = await session.get_items() saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] assert saved == ["user"] +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.asyncio -async def test_streamed_blocked_final_persists_tool_items_but_not_the_message() -> None: +async def test_blocked_final_persists_tool_items_but_not_the_message(mode: str) -> None: """A mixed final turn splits: the tool record is kept, the blocked message is not.""" @function_tool(name_override="commit_tool") @@ -2216,8 +2217,11 @@ def output_guardrail( session = SimpleListSession() with pytest.raises(OutputGuardrailTripwireTriggered): - result = Runner.run_streamed(agent, "Use commit_tool", session=session) - await consume_stream(result) + if mode == "non_streamed": + await Runner.run(agent, "Use commit_tool", session=session) + else: + result = Runner.run_streamed(agent, "Use commit_tool", session=session) + await consume_stream(result) saved_items = await session.get_items() saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] @@ -2282,7 +2286,7 @@ async def run_once() -> Any: saved_items = await session.get_items() saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] - if tripwire and mode == "streamed": + if tripwire: # The undeliverable message is withheld; the tool that already ran is not. assert saved == ["user", "function_call", "function_call_output"] else: @@ -2368,8 +2372,11 @@ async def run_once(input_value: Any) -> Any: assert replayed == ["reasoning", "function_call", "function_call_output"] +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.asyncio -async def test_blocked_tool_final_drops_reasoning_tied_to_the_rejected_message() -> None: +async def test_blocked_tool_final_drops_reasoning_tied_to_the_rejected_message( + mode: str, +) -> None: """Only the reasoning tied to a retained call survives; the message's reasoning goes with it. The turn is `reasoning_for_message -> message -> reasoning_for_call -> function_call`. A @@ -2377,10 +2384,7 @@ async def test_blocked_tool_final_drops_reasoning_tied_to_the_rejected_message() whenever the turn happens to contain a tool call would leave the rejected message's reasoning dangling in the next request. - Streamed only: on a tripwire the non-streamed path persists the whole turn - the rejected - message included - which predates this change and is a separate bug (see the PR discussion). """ - mode = "streamed" @function_tool(name_override="commit_tool") def commit_tool() -> str: From 6e4cec5cee95adca51afd088355423d935315943 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 09:56:54 +0900 Subject: [PATCH 149/473] fix(approvals): honor resolved status before policy checks (#4183) Co-authored-by: Henry Su --- src/agents/realtime/session.py | 30 +- src/agents/run_internal/tool_actions.py | 156 +++--- src/agents/run_internal/tool_execution.py | 33 +- src/agents/run_internal/tool_planning.py | 39 +- src/agents/run_internal/turn_resolution.py | 23 +- .../capabilities/tools/apply_patch_tool.py | 8 +- tests/mcp/test_mcp_approval.py | 3 +- tests/realtime/test_session.py | 180 +++++++ .../capabilities/test_apply_patch_tool.py | 68 +++ tests/test_hitl_error_scenarios.py | 488 +++++++++++++++++- 10 files changed, 919 insertions(+), 109 deletions(-) diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 43009511f9..f224bbf068 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -668,19 +668,25 @@ async def _maybe_request_tool_approval( agent, tool_lookup_key=tool_lookup_key, ) - - needs_approval = await self._function_needs_approval(function_tool, tool_call) - if self._closing or self._closed: - return None - if not needs_approval: - return True - approval_status = self._context_wrapper.get_approval_status( function_tool.name, tool_call.call_id, existing_pending=approval_item, tool_lookup_key=tool_lookup_key, ) + if approval_status is None: + needs_approval = await self._function_needs_approval(function_tool, tool_call) + if self._closing or self._closed: + return None + approval_status = self._context_wrapper.get_approval_status( + function_tool.name, + tool_call.call_id, + existing_pending=approval_item, + tool_lookup_key=tool_lookup_key, + ) + if approval_status is None and not needs_approval: + return True + if approval_status is True: return True if approval_status is False: @@ -694,6 +700,16 @@ async def _maybe_request_tool_approval( ) if self._closing or self._closed: return None + approval_status = self._context_wrapper.get_approval_status( + function_tool.name, + tool_call.call_id, + existing_pending=approval_item, + tool_lookup_key=tool_lookup_key, + ) + if approval_status is True: + return True + if approval_status is False: + return False if rejected_message is not None: return self._build_realtime_tool_output( tool=function_tool, diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py index 0421c15c43..f2872770d8 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -454,11 +454,23 @@ async def _run_call(span: Any | None) -> RunItem: dataclasses.asdict(shell_call.action) ) - needs_approval_result = await evaluate_needs_approval_setting( - shell_tool.needs_approval, context_wrapper, shell_call.action, shell_call.call_id + approval_status = context_wrapper.get_approval_status( + shell_tool.name, shell_call.call_id ) + if approval_status is None: + needs_approval_result = await evaluate_needs_approval_setting( + shell_tool.needs_approval, + context_wrapper, + shell_call.action, + shell_call.call_id, + ) + approval_status = context_wrapper.get_approval_status( + shell_tool.name, shell_call.call_id + ) + else: + needs_approval_result = False - if needs_approval_result: + if approval_status is None and needs_approval_result: approval_status, approval_item = await resolve_approval_status( tool_name=shell_tool.name, call_id=shell_call.call_id, @@ -468,24 +480,24 @@ async def _run_call(span: Any | None) -> RunItem: on_approval=shell_tool.on_approval, ) - if approval_status is False: - rejection_message = await resolve_approval_rejection_message( - context_wrapper=context_wrapper, - run_config=config, - tool_type="shell", - tool_name=shell_tool.name, - call_id=shell_call.call_id, - ) - return shell_rejection_item( - agent, - shell_call.call_id, - tool_call=call.tool_call, - rejection_message=rejection_message, - ) - - if approval_status is not True: + if approval_status is None: return approval_item + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="shell", + tool_name=shell_tool.name, + call_id=shell_call.call_id, + ) + return shell_rejection_item( + agent, + shell_call.call_id, + tool_call=call.tool_call, + rejection_message=rejection_message, + ) + await asyncio.gather( hooks.on_tool_start(context_wrapper, agent, shell_tool), ( @@ -649,11 +661,16 @@ async def _run_call(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: span.span_data.input = tool_input - needs_approval_result = await evaluate_needs_approval_setting( - custom_tool.runtime_needs_approval(), context_wrapper, tool_input, call_id - ) + approval_status = context_wrapper.get_approval_status(custom_tool.name, call_id) + if approval_status is None: + needs_approval_result = await evaluate_needs_approval_setting( + custom_tool.runtime_needs_approval(), context_wrapper, tool_input, call_id + ) + approval_status = context_wrapper.get_approval_status(custom_tool.name, call_id) + else: + needs_approval_result = False - if needs_approval_result: + if approval_status is None and needs_approval_result: approval_status, approval_item = await resolve_approval_status( tool_name=custom_tool.name, call_id=call_id, @@ -663,27 +680,27 @@ async def _run_call(span: Any | None) -> RunItem: on_approval=custom_tool.runtime_on_approval(), ) - if approval_status is False: - rejection_message = await resolve_approval_rejection_message( - context_wrapper=context_wrapper, - run_config=config, - tool_type="custom", - tool_name=custom_tool.name, - call_id=call_id, - ) - return cls._tool_output_item( - agent, + if approval_status is None: + return approval_item + + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="custom", + tool_name=custom_tool.name, + call_id=call_id, + ) + return cls._tool_output_item( + agent, + call_id, + rejection_message, + raw_item=cls._raw_tool_output_item( call_id, rejection_message, - raw_item=cls._raw_tool_output_item( - call_id, - rejection_message, - tool_call=call.tool_call, - ), - ) - - if approval_status is not True: - return approval_item + tool_call=call.tool_call, + ), + ) await asyncio.gather( hooks.on_tool_start(tool_context, agent, custom_tool), @@ -830,15 +847,20 @@ async def _run_call(span: Any | None) -> RunItem: ] ) + approval_status = context_wrapper.get_approval_status(apply_patch_tool.name, call_id) needs_approval_result = False - for operation in operations: - if await evaluate_needs_approval_setting( - apply_patch_tool.needs_approval, context_wrapper, operation, call_id - ): - needs_approval_result = True - break - - if needs_approval_result: + if approval_status is None: + for operation in operations: + needs_approval_result = await evaluate_needs_approval_setting( + apply_patch_tool.needs_approval, context_wrapper, operation, call_id + ) + approval_status = context_wrapper.get_approval_status( + apply_patch_tool.name, call_id + ) + if approval_status is not None or needs_approval_result: + break + + if approval_status is None and needs_approval_result: approval_status, approval_item = await resolve_approval_status( tool_name=apply_patch_tool.name, call_id=call_id, @@ -848,25 +870,25 @@ async def _run_call(span: Any | None) -> RunItem: on_approval=apply_patch_tool.on_approval, ) - if approval_status is False: - rejection_message = await resolve_approval_rejection_message( - context_wrapper=context_wrapper, - run_config=config, - tool_type="apply_patch", - tool_name=apply_patch_tool.name, - call_id=call_id, - ) - return apply_patch_rejection_item( - agent, - call_id, - tool_call=call.tool_call, - output_type="apply_patch_call_output", - rejection_message=rejection_message, - ) - - if approval_status is not True: + if approval_status is None: return approval_item + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="apply_patch", + tool_name=apply_patch_tool.name, + call_id=call_id, + ) + return apply_patch_rejection_item( + agent, + call_id, + tool_call=call.tool_call, + output_type="apply_patch_call_output", + rejection_message=rejection_message, + ) + await asyncio.gather( hooks.on_tool_start(context_wrapper, agent, apply_patch_tool), ( diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 4663ca5897..07aa611c68 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -1724,14 +1724,6 @@ async def _maybe_execute_tool_approval( raw_tool_call: ResponseFunctionToolCall, span_fn: Span[Any], ) -> Any | None: - needs_approval_result = await function_needs_approval( - func_tool, - self.context_wrapper, - tool_call, - ) - if not needs_approval_result: - return None - tool_namespace = get_tool_call_namespace(raw_tool_call) if tool_namespace is None and is_deferred_top_level_function_tool(func_tool): tool_namespace = func_tool.name @@ -1744,6 +1736,21 @@ async def _maybe_execute_tool_approval( tool_namespace=tool_namespace, tool_lookup_key=tool_lookup_key, ) + if approval_status is None: + needs_approval_result = await function_needs_approval( + func_tool, + self.context_wrapper, + tool_call, + ) + approval_status = self.context_wrapper.get_approval_status( + func_tool.name, + tool_call.call_id, + tool_namespace=tool_namespace, + tool_lookup_key=tool_lookup_key, + ) + if approval_status is None and not needs_approval_result: + return None + if approval_status is None: if self._should_run_pre_approval_tool_input_guardrails(): tool_context_namespace = get_tool_call_namespace(raw_tool_call) @@ -1763,7 +1770,13 @@ async def _maybe_execute_tool_approval( agent=self.public_agent, tool_input_guardrail_results=self.tool_input_guardrail_results, ) - if rejected_message is not None: + approval_status = self.context_wrapper.get_approval_status( + func_tool.name, + tool_call.call_id, + tool_namespace=tool_namespace, + tool_lookup_key=tool_lookup_key, + ) + if approval_status is None and rejected_message is not None: return FunctionToolResult( tool=func_tool, output=rejected_message, @@ -1776,6 +1789,8 @@ async def _maybe_execute_tool_approval( tool_origin=get_function_tool_origin(func_tool), ), ) + + if approval_status is None: approval_item = ToolApprovalItem( agent=self.public_agent, raw_item=raw_tool_call, diff --git a/src/agents/run_internal/tool_planning.py b/src/agents/run_internal/tool_planning.py index e960b1edf0..84cd323fbf 100644 --- a/src/agents/run_internal/tool_planning.py +++ b/src/agents/run_internal/tool_planning.py @@ -404,6 +404,20 @@ async def _collect_runs_by_approval( if output_exists_checker and output_exists_checker(call_id): continue + needs_approval = True + if approval_status is None and needs_approval_checker: + try: + needs_approval = await needs_approval_checker(run) + except UserError: + raise + except Exception: + needs_approval = True + approval_status = context_wrapper.get_approval_status( + tool_name, + call_id, + existing_pending=existing_pending, + ) + if approval_status is False: rejection = rejection_builder(run, call_id) if inspect.isawaitable(rejection): @@ -417,15 +431,6 @@ async def _collect_runs_by_approval( approved_runs.append(run) continue - needs_approval = True - if needs_approval_checker: - try: - needs_approval = await needs_approval_checker(run) - except UserError: - raise - except Exception: - needs_approval = True - if not needs_approval: approved_runs.append(run) continue @@ -517,6 +522,16 @@ async def _select_function_tool_runs_for_resume( existing_pending=approval_items_by_call_id.get(call_id), ) + requires_approval = True + if approval_status is None: + requires_approval = await needs_approval_checker(run) + approval_status = context_wrapper.get_approval_status( + run.function_tool.name, + call_id, + tool_namespace=get_tool_call_namespace(run.tool_call), + existing_pending=approval_items_by_call_id.get(call_id), + ) + if approval_status is False: await record_rejection(call_id, run.tool_call, run.function_tool) continue @@ -525,12 +540,6 @@ async def _select_function_tool_runs_for_resume( selected.append(run) continue - # Only invoke needs_approval_checker when the approval state is unresolved; - # for explicit approve/reject decisions the checker's result is unused, and - # invoking it eagerly risks user-side effects (or exceptions that swallow - # rejections) on calls whose outcome is already determined. - requires_approval = await needs_approval_checker(run) - if not requires_approval: selected.append(run) continue diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index a103513eed..49dd5c439a 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -1220,10 +1220,16 @@ async def _apply_patch_needs_approval(run: ToolRunApplyPatchCall) -> bool: ) call_id = extract_apply_patch_call_id(run.tool_call) for operation in operations: - if await evaluate_needs_approval_setting( + needs_approval = await evaluate_needs_approval_setting( run.apply_patch_tool.needs_approval, context_wrapper, operation, call_id - ): - return True + ) + approval_status = context_wrapper.get_approval_status( + run.apply_patch_tool.name, + call_id, + existing_pending=approval_items_by_call_id.get(call_id), + ) + if approval_status is not None or needs_approval: + return needs_approval return False async def _custom_tool_needs_approval(run: ToolRunCustom) -> bool: @@ -1841,11 +1847,18 @@ def _rebind_function_run( ) rejected_function_call_ids.add(call_id) + collector_owned_call_ids = { + *(_shell_call_id_from_run(run) for run in processed_response.shell_calls), + *(_apply_patch_call_id_from_run(run) for run in processed_response.apply_patch_calls), + *(_custom_call_id_from_run(run) for run in processed_response.custom_tool_calls), + } for original_approval in pending_approval_items: approval_snapshot = validated_function_approval_items.get(original_approval) if approval_snapshot is None: approval = original_approval approval_call_id = extract_tool_call_id(approval.raw_item) + if approval_call_id in collector_owned_call_ids: + continue if ( approval_call_id is None or context_wrapper.get_approval_status( @@ -2004,8 +2017,8 @@ def _rebind_function_run( for interruption in _collect_tool_interruptions( function_results=function_results, custom_tool_results=custom_tool_results, - shell_results=[], - apply_patch_results=[], + shell_results=shell_results, + apply_patch_results=apply_patch_results, ): _add_pending_interruption(interruption) diff --git a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py index 20ffb10b3b..5aa653a0ef 100644 --- a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py +++ b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py @@ -202,13 +202,15 @@ async def _needs_custom_approval( return False for operation in operations: - if await evaluate_needs_approval_setting( + needs_approval = await evaluate_needs_approval_setting( self.needs_approval, ctx_wrapper, operation, call_id, - ): - return True + ) + approval_status = ctx_wrapper.get_approval_status(self.name, call_id) + if approval_status is not None or needs_approval: + return needs_approval return False async def _on_invoke_tool(self, ctx: ToolContext[Any], raw_input: str) -> str: diff --git a/tests/mcp/test_mcp_approval.py b/tests/mcp/test_mcp_approval.py index 791fa71c24..873746f0fd 100644 --- a/tests/mcp/test_mcp_approval.py +++ b/tests/mcp/test_mcp_approval.py @@ -191,7 +191,7 @@ def require_approval( assert not second.interruptions, "safe should bypass approval via callable policy" assert second.final_output == "safe done" - assert seen == ["guarded", "guarded", "safe"] + assert seen == ["guarded", "safe"] @pytest.mark.asyncio @@ -236,7 +236,6 @@ async def require_approval( assert second.final_output == "no approval path" assert seen_contexts == [ - {"needs_approval": True}, {"needs_approval": True}, {"needs_approval": False}, ] diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index a549c13fe9..9a6309fcc5 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -3343,6 +3343,186 @@ async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: ] assert tool_calls == [] + @pytest.mark.asyncio + async def test_sticky_rejection_skips_dynamic_approval_checker(self, mock_model): + checker_calls: list[str] = [] + tool_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + if call_id != "call-reject-first": + raise AssertionError("sticky rejection must bypass needs_approval") + return True + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "should-not-run" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + first_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-first", arguments="{}" + ) + second_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-second", arguments="{}" + ) + + await session._handle_tool_call(first_call) + await session.reject_tool_call(first_call.call_id, always=True) + await session._handle_tool_call(second_call) + + assert checker_calls == ["call-reject-first"] + assert tool_calls == [] + assert session._pending_tool_calls == {} + assert len(mock_model.sent_tool_outputs) == 2 + + @pytest.mark.asyncio + async def test_sticky_rejection_wins_while_dynamic_approval_checker_is_pending( + self, mock_model + ): + checker_started = asyncio.Event() + checker_release = asyncio.Event() + checker_calls: list[str] = [] + tool_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + if call_id == "call-pending-checker": + checker_started.set() + await checker_release.wait() + return False + return True + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "should-not-run" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None) + first_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-first", arguments="{}" + ) + pending_checker_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-pending-checker", arguments="{}" + ) + + await session._handle_tool_call(first_call) + pending_checker_task = asyncio.create_task(session._handle_tool_call(pending_checker_call)) + try: + await asyncio.wait_for(checker_started.wait(), timeout=1) + await session.reject_tool_call( + first_call.call_id, + always=True, + rejection_message="sticky rejection", + ) + finally: + checker_release.set() + await pending_checker_task + + assert checker_calls == ["call-reject-first", "call-pending-checker"] + assert tool_calls == [] + assert session._pending_tool_calls == {} + assert [output for _call, output, _start in mock_model.sent_tool_outputs] == [ + "sticky rejection", + "sticky rejection", + ] + + @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) + @pytest.mark.asyncio + async def test_sticky_decision_wins_while_rejecting_pre_approval_guardrail_is_pending( + self, mock_model, approved: bool + ): + guardrail_started = asyncio.Event() + guardrail_release = asyncio.Event() + guardrail_calls: list[str | None] = [] + tool_calls: list[str] = [] + + @tool_input_guardrail + async def blocking_guardrail( + data: ToolInputGuardrailData, + ) -> ToolGuardrailFunctionOutput: + call_id = data.context.tool_call_id + guardrail_calls.append(call_id) + if call_id == "call-pending-guardrail": + guardrail_started.set() + await guardrail_release.wait() + return ToolGuardrailFunctionOutput.reject_content("guardrail rejection") + return ToolGuardrailFunctionOutput.allow() + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "tool output" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + tool_input_guardrails=[blocking_guardrail], + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession( + mock_model, + agent, + None, + run_config={"tool_execution": {"pre_approval_tool_input_guardrails": True}}, + ) + first_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-first", arguments="{}" + ) + pending_guardrail_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-pending-guardrail", arguments="{}" + ) + + await session._handle_tool_call(first_call) + pending_guardrail_task = asyncio.create_task( + session._handle_tool_call(pending_guardrail_call) + ) + try: + await asyncio.wait_for(guardrail_started.wait(), timeout=1) + approval_item = session._pending_tool_calls[first_call.call_id].approval_item + if approved: + session._context_wrapper.approve_tool(approval_item, always_approve=True) + else: + session._context_wrapper.reject_tool( + approval_item, + always_reject=True, + rejection_message="sticky rejection", + ) + finally: + guardrail_release.set() + await pending_guardrail_task + + assert pending_guardrail_call.call_id not in session._pending_tool_calls + outputs = [output for _call, output, _start in mock_model.sent_tool_outputs] + if approved: + assert guardrail_calls == [ + "call-reject-first", + "call-pending-guardrail", + "call-pending-guardrail", + ] + assert tool_calls == [] + assert outputs == ["guardrail rejection"] + else: + assert guardrail_calls == ["call-reject-first", "call-pending-guardrail"] + assert tool_calls == [] + assert outputs == ["sticky rejection"] + @pytest.mark.asyncio async def test_function_tool_exception_handling( self, mock_model, mock_agent, mock_function_tool diff --git a/tests/sandbox/capabilities/test_apply_patch_tool.py b/tests/sandbox/capabilities/test_apply_patch_tool.py index 450d2f8763..a69d34ddf3 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections.abc import Awaitable from pathlib import Path from typing import Any, cast @@ -77,6 +78,73 @@ async def needs_approval( assert isinstance(result, ToolApprovalItem) + @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) + @pytest.mark.asyncio + async def test_multi_operation_checker_stops_when_approval_resolves( + self, + approved: bool, + ) -> None: + checker_started = asyncio.Event() + release_checker = asyncio.Event() + checked_paths: list[str] = [] + + async def needs_approval( + _ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str + ) -> bool: + checked_paths.append(operation.path) + if len(checked_paths) > 1: + raise AssertionError("resolved approval must stop later callbacks") + checker_started.set() + await release_checker.wait() + return False + + session = ApplyPatchSession() + tool = SandboxApplyPatchTool(session=session, needs_approval=needs_approval) + context_wrapper = make_context_wrapper() + raw_input = ( + "*** Begin Patch\n" + "*** Add File: first.txt\n" + "+first\n" + "*** Add File: second.txt\n" + "+second\n" + "*** End Patch\n" + ) + approval_item = ToolApprovalItem( + agent=Agent(name="patcher"), + raw_item={ + "type": "custom_tool_call", + "name": tool.name, + "call_id": "call_apply", + "input": raw_input, + }, + tool_name=tool.name, + ) + execution_task = asyncio.create_task( + _execute_custom_tool_call( + tool, + context_wrapper=context_wrapper, + raw_input=raw_input, + ) + ) + try: + await asyncio.wait_for(checker_started.wait(), timeout=1) + if approved: + context_wrapper.approve_tool(approval_item) + else: + context_wrapper.reject_tool(approval_item) + release_checker.set() + result = await execution_task + finally: + release_checker.set() + + assert checked_paths == ["first.txt"] + assert isinstance(result, ToolCallOutputItem) + if approved: + assert session.files[Path("/workspace/first.txt")] == b"first" + assert session.files[Path("/workspace/second.txt")] == b"second" + else: + assert session.files == {} + @pytest.mark.asyncio async def test_invalid_patch_input_surfaces_tool_error_after_approval_precheck(self) -> None: tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=True) diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index 23d4002c1d..7f936d4ca0 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -2,11 +2,16 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from typing import Any, Optional, cast import pytest -from openai.types.responses import ResponseComputerToolCall, ResponseFunctionToolCall +from openai.types.responses import ( + ResponseComputerToolCall, + ResponseCustomToolCall, + ResponseFunctionToolCall, +) from openai.types.responses.response_computer_tool_call import ActionScreenshot from openai.types.responses.response_input_param import ( ComputerCallOutput, @@ -18,12 +23,14 @@ Agent, ApplyPatchTool, ComputerTool, + CustomTool, LocalShellTool, Runner, RunResult, RunState, ShellTool, ToolApprovalItem, + ToolExecutionConfig, function_tool, tool_namespace, ) @@ -53,12 +60,20 @@ ToolRunShellCall, extract_tool_call_id, ) +from agents.run_internal.run_steps import ToolRunCustom +from agents.run_internal.tool_actions import ApplyPatchAction, CustomToolAction, ShellAction +from agents.run_internal.tool_execution import execute_function_tool_calls from agents.run_internal.tool_planning import ( _collect_runs_by_approval, _select_function_tool_runs_for_resume, ) from agents.run_state import RunState as RunStateClass from agents.tool import FunctionTool, HostedMCPTool +from agents.tool_guardrails import ( + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + tool_input_guardrail, +) from agents.usage import Usage from .fake_model import FakeModel @@ -1393,6 +1408,309 @@ async def _record_rejection( assert rejections == ["rejected-call"] +@pytest.mark.asyncio +async def test_resume_rechecks_rejection_after_function_approval_checker() -> None: + """A rejection recorded while the checker waits must prevent another interruption.""" + + @function_tool(needs_approval=True) + async def sensitive() -> str: + return "should-not-run" + + tool_call = make_function_tool_call(sensitive.name, call_id="call-concurrent-function") + run = ToolRunFunction(tool_call=tool_call, function_tool=sensitive) + agent = Agent(name="agent", tools=[sensitive]) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + context_wrapper = make_context_wrapper() + checker_started = asyncio.Event() + release_checker = asyncio.Event() + + async def _needs_approval_checker(_run: ToolRunFunction) -> bool: + checker_started.set() + await release_checker.wait() + return True + + pending: list[ToolApprovalItem] = [] + rejections: list[str | None] = [] + + async def _record_rejection( + call_id: str | None, + _tool_call: ResponseFunctionToolCall, + _tool: FunctionTool, + ) -> None: + rejections.append(call_id) + + selection_task = asyncio.create_task( + _select_function_tool_runs_for_resume( + [run], + approval_items_by_call_id={tool_call.call_id: approval_item}, + context_wrapper=context_wrapper, + needs_approval_checker=_needs_approval_checker, + output_exists_checker=lambda _run: False, + record_rejection=_record_rejection, + pending_interruption_adder=pending.append, + pending_item_builder=lambda _run: approval_item, + ) + ) + try: + await asyncio.wait_for(checker_started.wait(), timeout=1) + context_wrapper.reject_tool(approval_item) + release_checker.set() + selected = await selection_task + finally: + release_checker.set() + + assert selected == [] + assert pending == [] + assert rejections == [tool_call.call_id] + + +@pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) +@pytest.mark.asyncio +async def test_execute_path_prefers_decision_resolved_during_rejecting_guardrail( + approved: bool, +) -> None: + """Stored approval status must win when a rejecting guardrail was already waiting.""" + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + guardrail_calls = 0 + executed: list[str] = [] + + @tool_input_guardrail + async def rejecting_guardrail( + _data: ToolInputGuardrailData, + ) -> ToolGuardrailFunctionOutput: + nonlocal guardrail_calls + guardrail_calls += 1 + guardrail_started.set() + await release_guardrail.wait() + return ToolGuardrailFunctionOutput.reject_content("guardrail rejection") + + @function_tool(needs_approval=True, tool_input_guardrails=[rejecting_guardrail]) + async def sensitive() -> str: + executed.append("ran") + return "tool output" + + tool_call = make_function_tool_call(sensitive.name, call_id="call-pending-guardrail") + tool_run = ToolRunFunction(tool_call=tool_call, function_tool=sensitive) + agent = Agent(name="agent", tools=[sensitive]) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + context_wrapper = make_context_wrapper() + execution_task = asyncio.create_task( + execute_function_tool_calls( + bindings=bind_public_agent(agent), + tool_runs=[tool_run], + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig( + tool_execution=ToolExecutionConfig(pre_approval_tool_input_guardrails=True) + ), + ) + ) + try: + await asyncio.wait_for(guardrail_started.wait(), timeout=1) + if approved: + context_wrapper.approve_tool(approval_item) + else: + context_wrapper.reject_tool(approval_item, rejection_message="stored rejection") + release_guardrail.set() + results, _, _ = await execution_task + finally: + release_guardrail.set() + + assert len(results) == 1 + if approved: + assert results[0].output == "guardrail rejection" + assert guardrail_calls == 2 + assert executed == [] + else: + assert results[0].output == "stored rejection" + assert guardrail_calls == 1 + assert executed == [] + + +@pytest.mark.asyncio +async def test_execute_path_skips_needs_approval_checker_when_status_resolved() -> None: + """Resuming an approved call must not re-evaluate its dynamic approval policy.""" + checker_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _args: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + if len(checker_calls) > 1: + raise AssertionError("resolved approval must bypass needs_approval") + return True + + @function_tool(needs_approval=needs_approval) + async def sensitive(value: str) -> str: + return f"ran:{value}" + + model = FakeModel() + agent = Agent(name="agent", model=model, tools=[sensitive]) + model.add_multiple_turn_outputs( + [ + [make_function_tool_call(sensitive.name, call_id="call-1", arguments='{"value":"x"}')], + [get_text_message("done")], + ] + ) + + first = await Runner.run(agent, "hello") + assert len(first.interruptions) == 1 + assert checker_calls == ["call-1"] + + state = first.to_state() + state.approve(first.interruptions[0]) + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert checker_calls == ["call-1"] + assert any( + isinstance(item, ToolCallOutputItem) and item.output == "ran:x" + for item in resumed.new_items + ) + + +@pytest.mark.parametrize("tool_kind", ["function", "shell", "custom", "apply_patch"]) +@pytest.mark.asyncio +async def test_execute_path_honors_sticky_rejection_before_checker(tool_kind: str) -> None: + """A sticky rejection must bypass dynamic policies and prevent side effects.""" + executed: list[str] = [] + context_wrapper = make_context_wrapper() + + async def unexpected_checker(_ctx: Any, _payload: Any, _call_id: str) -> bool: + raise AssertionError("sticky rejection must bypass needs_approval") + + if tool_kind == "function": + + @function_tool(needs_approval=unexpected_checker) + async def sensitive() -> str: + executed.append("function") + return "should-not-run" + + agent = Agent(name="agent", tools=[sensitive]) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call(sensitive.name, call_id="call-prior"), + ), + always_reject=True, + ) + function_results, _, _ = await execute_function_tool_calls( + bindings=bind_public_agent(agent), + tool_runs=[ + ToolRunFunction( + tool_call=make_function_tool_call(sensitive.name, call_id="call-next"), + function_tool=sensitive, + ) + ], + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert [result.output for result in function_results] == [HITL_REJECTION_MSG] + elif tool_kind == "shell": + + def shell_executor(_req: Any) -> str: + executed.append("shell") + return "should-not-run" + + shell_tool = ShellTool(executor=shell_executor, needs_approval=unexpected_checker) + agent = Agent(name="agent", tools=[shell_tool]) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=cast(dict[str, Any], make_shell_call("call-prior")), + tool_name=shell_tool.name, + ), + always_reject=True, + ) + result = await ShellAction.execute( + agent=agent, + call=ToolRunShellCall( + tool_call=cast(dict[str, Any], make_shell_call("call-next")), + shell_tool=shell_tool, + ), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert isinstance(result, ToolCallOutputItem) + assert HITL_REJECTION_MSG in str(result.output) + elif tool_kind == "custom": + + async def invoke_custom(_ctx: Any, _raw: str) -> str: + executed.append("custom") + return "should-not-run" + + custom_tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke_custom, + format={"type": "text"}, + needs_approval=unexpected_checker, + ) + agent = Agent(name="agent", tools=[custom_tool]) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=cast( + Any, + ResponseCustomToolCall( + type="custom_tool_call", + name=custom_tool.name, + call_id="call-prior", + input="prior", + ), + ), + tool_name=custom_tool.name, + ), + always_reject=True, + ) + next_call = ResponseCustomToolCall( + type="custom_tool_call", + name=custom_tool.name, + call_id="call-next", + input="next", + ) + result = await CustomToolAction.execute( + agent=agent, + call=ToolRunCustom(tool_call=next_call, custom_tool=custom_tool), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert isinstance(result, ToolCallOutputItem) + assert result.output == HITL_REJECTION_MSG + else: + editor = RecordingEditor() + apply_patch_tool = ApplyPatchTool( + editor=editor, + needs_approval=unexpected_checker, + ) + agent = Agent(name="agent", tools=[apply_patch_tool]) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=cast(dict[str, Any], make_apply_patch_dict("call-prior")), + tool_name=apply_patch_tool.name, + ), + always_reject=True, + ) + result = await ApplyPatchAction.execute( + agent=agent, + call=ToolRunApplyPatchCall( + tool_call=cast(dict[str, Any], make_apply_patch_dict("call-next")), + apply_patch_tool=apply_patch_tool, + ), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert isinstance(result, ToolCallOutputItem) + assert HITL_REJECTION_MSG in str(result.output) + assert editor.operations == [] + + assert executed == [] + + @pytest.mark.asyncio async def test_collect_runs_by_approval_skips_checker_when_status_resolved() -> None: """Approved/rejected shell calls must not invoke needs_approval_checker. @@ -1456,6 +1774,174 @@ async def _build_rejection(run: ToolRunShellCall, call_id: str) -> RunItem: assert len(rejections) == 1 +@pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) +@pytest.mark.asyncio +async def test_resume_apply_patch_uses_concurrent_decision_without_reinterrupting( + approved: bool, +) -> None: + """A resolved apply-patch decision must stop callbacks and avoid stale interruptions.""" + checker_started = asyncio.Event() + release_checker = asyncio.Event() + checked_paths: list[str] = [] + + async def _needs_approval(_ctx: Any, operation: Any, _call_id: str) -> bool: + checked_paths.append(operation.path) + if len(checked_paths) > 1: + raise AssertionError("resolved rejection must stop later approval callbacks") + checker_started.set() + await release_checker.wait() + return False + + editor = RecordingEditor() + apply_patch_tool = ApplyPatchTool(editor=editor, needs_approval=_needs_approval) + _model, public_agent = make_model_and_agent(tools=[apply_patch_tool]) + execution_agent = public_agent.clone() + set_public_agent(execution_agent, public_agent) + raw_item = cast( + Any, + { + "type": "apply_patch_call", + "call_id": "call-concurrent-apply-patch", + "operations": [ + {"type": "update_file", "path": "first.txt", "diff": "-old\n+new\n"}, + {"type": "delete_file", "path": "second.txt"}, + ], + }, + ) + approval_item = ToolApprovalItem( + agent=public_agent, + raw_item=raw_item, + tool_name=apply_patch_tool.name, + ) + context_wrapper = make_context_wrapper() + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[ + ToolRunApplyPatchCall(tool_call=raw_item, apply_patch_tool=apply_patch_tool) + ], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + resolution_task = asyncio.create_task( + _resolve_interrupted_turn( + agent=execution_agent, + original_input="resume apply patch", + original_pre_step_items=[], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=make_state_with_interruptions(public_agent, [approval_item]), + ) + ) + try: + await asyncio.wait_for(checker_started.wait(), timeout=1) + if approved: + context_wrapper.approve_tool(approval_item) + else: + context_wrapper.reject_tool(approval_item) + release_checker.set() + result = await resolution_task + finally: + release_checker.set() + + assert checked_paths == ["first.txt"] + assert not isinstance(result.next_step, NextStepInterruption) + rejection_outputs = [ + item + for item in result.new_step_items + if isinstance(item, ToolCallOutputItem) and item.output == HITL_REJECTION_MSG + ] + if approved: + assert rejection_outputs == [] + assert len(editor.operations) == 2 + else: + assert len(rejection_outputs) == 1 + assert editor.operations == [] + + +@pytest.mark.parametrize("tool_kind", ["shell", "apply_patch"]) +@pytest.mark.asyncio +async def test_resume_preserves_approval_created_during_tool_execution(tool_kind: str) -> None: + """A second policy evaluation may create a new approval interruption during execution.""" + checker_calls = 0 + executed: list[str] = [] + context_wrapper = make_context_wrapper() + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + async def needs_approval(_ctx: Any, _payload: Any, _call_id: str) -> bool: + nonlocal checker_calls + checker_calls += 1 + return checker_calls == 2 + + tool: Any + if tool_kind == "shell": + + def execute_shell(_request: Any) -> str: + executed.append("shell") + return "should-not-run" + + tool = ShellTool(executor=execute_shell, needs_approval=needs_approval) + raw_item = cast(dict[str, Any], make_shell_call("call-execution-approval")) + processed_response.shell_calls = [ToolRunShellCall(tool_call=raw_item, shell_tool=tool)] + else: + editor = RecordingEditor() + tool = ApplyPatchTool(editor=editor, needs_approval=needs_approval) + raw_item = cast(Any, make_apply_patch_dict("call-execution-approval")) + processed_response.apply_patch_calls = [ + ToolRunApplyPatchCall(tool_call=raw_item, apply_patch_tool=tool) + ] + + _model, public_agent = make_model_and_agent(tools=[tool]) + execution_agent = public_agent.clone() + set_public_agent(execution_agent, public_agent) + original_approval = ToolApprovalItem( + agent=public_agent, + raw_item=raw_item, + tool_name=tool.name, + ) + + result = await _resolve_interrupted_turn( + agent=execution_agent, + original_input="resume approval", + original_pre_step_items=[], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=make_state_with_interruptions(public_agent, [original_approval]), + ) + + assert checker_calls == 2 + assert isinstance(result.next_step, NextStepInterruption) + assert [extract_tool_call_id(item.raw_item) for item in result.next_step.interruptions] == [ + "call-execution-approval" + ] + assert executed == [] + if tool_kind == "apply_patch": + assert editor.operations == [] + + @pytest.mark.asyncio async def test_resume_rebuilds_function_runs_from_object_approvals() -> None: """Rebuild should handle ResponseFunctionToolCall approval items.""" From afbadd085e20e533efc7714466062f714c342a3d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 10:01:31 +0900 Subject: [PATCH 150/473] test: make parallel guardrail overlap deterministic (#4187) Co-authored-by: LeSingh1 --- tests/test_guardrails.py | 68 +++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index cc5f00db9c..cd7f98fbed 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -1245,15 +1245,18 @@ async def blocking_check( @pytest.mark.asyncio async def test_mixed_blocking_and_parallel_guardrails(): - timestamps = {} + blocking_finished = asyncio.Event() + parallel_started = asyncio.Event() + model_called = asyncio.Event() + parallel_finished = asyncio.Event() + observed: dict[str, bool] = {} @input_guardrail(run_in_parallel=False) async def blocking_check( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: - timestamps["blocking_start"] = time.time() await asyncio.sleep(MEDIUM_DELAY) - timestamps["blocking_end"] = time.time() + blocking_finished.set() return GuardrailFunctionOutput( output_info="blocking_passed", tripwire_triggered=False, @@ -1263,9 +1266,10 @@ async def blocking_check( async def parallel_check( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: - timestamps["parallel_start"] = time.time() - await asyncio.sleep(MEDIUM_DELAY) - timestamps["parallel_end"] = time.time() + observed["blocking_finished_at_parallel_start"] = blocking_finished.is_set() + parallel_started.set() + await asyncio.wait_for(model_called.wait(), timeout=5) + parallel_finished.set() return GuardrailFunctionOutput( output_info="parallel_passed", tripwire_triggered=False, @@ -1276,7 +1280,10 @@ async def parallel_check( original_get_response = model.get_response async def tracked_get_response(*args, **kwargs): - timestamps["model_called"] = time.time() + observed["blocking_finished_at_model_call"] = blocking_finished.is_set() + await asyncio.wait_for(parallel_started.wait(), timeout=5) + observed["parallel_finished_at_model_call"] = parallel_finished.is_set() + model_called.set() return await original_get_response(*args, **kwargs) agent = Agent( @@ -1293,21 +1300,16 @@ async def tracked_get_response(*args, **kwargs): assert result.final_output is not None assert len(result.input_guardrail_results) == 2 - assert "blocking_start" in timestamps - assert "blocking_end" in timestamps - assert "parallel_start" in timestamps - assert "parallel_end" in timestamps - assert "model_called" in timestamps - - assert timestamps["blocking_end"] <= timestamps["parallel_start"], ( + assert observed["blocking_finished_at_parallel_start"] is True, ( "Blocking must complete before parallel starts" ) - assert timestamps["blocking_end"] <= timestamps["model_called"], ( + assert observed["blocking_finished_at_model_call"] is True, ( "Blocking must complete before model is called" ) - assert timestamps["model_called"] <= timestamps["parallel_end"], ( + assert observed["parallel_finished_at_model_call"] is False, ( "Model called while parallel guardrail still running" ) + assert parallel_finished.is_set() is True, "Parallel guardrail should have completed" assert model.first_turn_args is not None, ( "Model should have been called after blocking guardrails passed" ) @@ -1315,15 +1317,18 @@ async def tracked_get_response(*args, **kwargs): @pytest.mark.asyncio async def test_mixed_blocking_and_parallel_guardrails_streaming(): - timestamps = {} + blocking_finished = asyncio.Event() + parallel_started = asyncio.Event() + model_called = asyncio.Event() + parallel_finished = asyncio.Event() + observed: dict[str, bool] = {} @input_guardrail(run_in_parallel=False) async def blocking_check( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: - timestamps["blocking_start"] = time.time() await asyncio.sleep(MEDIUM_DELAY) - timestamps["blocking_end"] = time.time() + blocking_finished.set() return GuardrailFunctionOutput( output_info="blocking_passed", tripwire_triggered=False, @@ -1333,9 +1338,10 @@ async def blocking_check( async def parallel_check( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: - timestamps["parallel_start"] = time.time() - await asyncio.sleep(MEDIUM_DELAY) - timestamps["parallel_end"] = time.time() + observed["blocking_finished_at_parallel_start"] = blocking_finished.is_set() + parallel_started.set() + await asyncio.wait_for(model_called.wait(), timeout=5) + parallel_finished.set() return GuardrailFunctionOutput( output_info="parallel_passed", tripwire_triggered=False, @@ -1346,7 +1352,10 @@ async def parallel_check( original_stream_response = model.stream_response async def tracked_stream_response(*args, **kwargs): - timestamps["model_called"] = time.time() + observed["blocking_finished_at_model_call"] = blocking_finished.is_set() + await asyncio.wait_for(parallel_started.wait(), timeout=5) + observed["parallel_finished_at_model_call"] = parallel_finished.is_set() + model_called.set() async for event in original_stream_response(*args, **kwargs): yield event @@ -1366,21 +1375,16 @@ async def tracked_stream_response(*args, **kwargs): received_events = True assert received_events is True - assert "blocking_start" in timestamps - assert "blocking_end" in timestamps - assert "parallel_start" in timestamps - assert "parallel_end" in timestamps - assert "model_called" in timestamps - - assert timestamps["blocking_end"] <= timestamps["parallel_start"], ( + assert observed["blocking_finished_at_parallel_start"] is True, ( "Blocking must complete before parallel starts" ) - assert timestamps["blocking_end"] <= timestamps["model_called"], ( + assert observed["blocking_finished_at_model_call"] is True, ( "Blocking must complete before model is called" ) - assert timestamps["model_called"] <= timestamps["parallel_end"], ( + assert observed["parallel_finished_at_model_call"] is False, ( "Model called while parallel guardrail still running" ) + assert parallel_finished.is_set() is True, "Parallel guardrail should have completed" assert model.first_turn_args is not None, ( "Model should have been called after blocking guardrails passed" ) From 6972c14f25925235152858fd878d6c8dd8ca4a51 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 10:06:09 +0900 Subject: [PATCH 151/473] fix(memory): skip conversation creation on empty add_items (#4190) Co-authored-by: LeSingh1 --- .../memory/openai_conversations_session.py | 9 ++++--- .../test_openai_conversations_session.py | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/agents/memory/openai_conversations_session.py b/src/agents/memory/openai_conversations_session.py index 4aee981918..186c004e5d 100644 --- a/src/agents/memory/openai_conversations_session.py +++ b/src/agents/memory/openai_conversations_session.py @@ -55,13 +55,14 @@ def session_id(self) -> str: Raises: ValueError: If the session has not been initialized yet. - Call any session method (get_items, add_items, etc.) first - to trigger lazy initialization. + Call a session method that accesses the remote conversation, such as + get_items() or add_items() with a non-empty list, to initialize it. """ if self._session_id is None: raise ValueError( "Session ID not yet available. The session is lazily initialized " - "on first API call. Call get_items(), add_items(), or similar first." + "on first API call. Call get_items(), add_items() with a non-empty list, " + "or a similar method first." ) return self._session_id @@ -107,10 +108,10 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: return all_items # type: ignore async def add_items(self, items: list[TResponseInputItem]) -> None: - session_id = await self._get_session_id() if not items: return + session_id = await self._get_session_id() await self._openai_client.conversations.items.create( conversation_id=session_id, items=items, diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index 958832bd12..1f0160bc01 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -230,6 +230,31 @@ async def test_add_items_creates_session_id(self, mock_openai_client): conversation_id="test_conversation_id", items=items ) + @pytest.mark.asyncio + async def test_add_items_empty_does_not_create_session(self, mock_openai_client): + """Test that add_items with no items does not create a remote conversation.""" + session = OpenAIConversationsSession(openai_client=mock_openai_client) + + await session.add_items([]) + + mock_openai_client.conversations.create.assert_not_called() + mock_openai_client.conversations.items.create.assert_not_called() + with pytest.raises(ValueError, match="add_items\\(\\) with a non-empty list"): + _ = session.session_id + + @pytest.mark.asyncio + async def test_add_items_empty_keeps_existing_session_id(self, mock_openai_client): + """Test that add_items with no items leaves an initialized session untouched.""" + session = OpenAIConversationsSession( + conversation_id="test_id", openai_client=mock_openai_client + ) + + await session.add_items([]) + + mock_openai_client.conversations.create.assert_not_called() + mock_openai_client.conversations.items.create.assert_not_called() + assert session.session_id == "test_id" + @pytest.mark.asyncio async def test_pop_item_with_items(self, mock_openai_client): """Test popping item when items exist using method patching.""" From 4c9e50757bad7c22bc56914a7a249d80df17dec3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 10:15:20 +0900 Subject: [PATCH 152/473] fix(run): cancel sibling work after concurrent failures (#4185) Co-authored-by: Pranav Mishra --- src/agents/run_internal/run_loop.py | 17 +- src/agents/run_internal/tool_actions.py | 22 +- src/agents/run_internal/tool_execution.py | 39 ++- src/agents/run_internal/tool_planning.py | 8 +- src/agents/run_internal/turn_resolution.py | 5 +- src/agents/util/_asyncio_tasks.py | 76 ++++- tests/test_agent_prompt.py | 76 +++++ tests/test_asyncio_tasks.py | 77 +++++ tests/test_run_step_execution.py | 368 +++++++++++++++++++++ 9 files changed, 657 insertions(+), 31 deletions(-) create mode 100644 tests/test_asyncio_tasks.py diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index a1389670f5..0ae8aeebdb 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -97,6 +97,7 @@ from ..tracing.span_data import AgentSpanData, TaskSpanData from ..usage import Usage, _response_usage_to_usage from ..util import _coro, _error_tracing +from ..util._asyncio_tasks import gather_with_cancel from .agent_bindings import AgentBindings, bind_public_agent from .agent_runner_helpers import ( apply_resumed_conversation_settings, @@ -1544,7 +1545,7 @@ def _tool_search_fingerprint(raw_item: Any) -> str: _approvals=context_wrapper._approvals, turn_input=turn_input, ) - await asyncio.gather( + await gather_with_cancel( hooks.on_agent_start(agent_hook_context, public_agent), ( public_agent.hooks.on_start(agent_hook_context, public_agent) @@ -1558,7 +1559,7 @@ def _tool_search_fingerprint(raw_item: Any) -> str: streamed_result.current_agent = public_agent streamed_result._current_agent_output_schema = get_output_schema(public_agent) - system_prompt, prompt_config = await asyncio.gather( + system_prompt, prompt_config = await gather_with_cancel( execution_agent.get_system_prompt(context_wrapper), execution_agent.get_prompt(context_wrapper), ) @@ -1642,7 +1643,7 @@ def _tool_search_fingerprint(raw_item: Any) -> str: # explicitly rewind this state before replaying a failed request. server_conversation_tracker.mark_input_as_sent(filtered.input) - await asyncio.gather( + await gather_with_cancel( hooks.on_llm_start(context_wrapper, public_agent, filtered.instructions, filtered.input), ( public_agent.hooks.on_llm_start( @@ -1879,7 +1880,7 @@ async def rewind_model_request() -> None: if final_response is not None: context_wrapper.usage.add(final_response.usage) - await asyncio.gather( + await gather_with_cancel( ( public_agent.hooks.on_llm_end(context_wrapper, public_agent, final_response) if public_agent.hooks @@ -1993,7 +1994,7 @@ async def run_single_turn( _approvals=context_wrapper._approvals, turn_input=turn_input, ) - await asyncio.gather( + await gather_with_cancel( hooks.on_agent_start(agent_hook_context, public_agent), ( public_agent.hooks.on_start(agent_hook_context, public_agent) @@ -2002,7 +2003,7 @@ async def run_single_turn( ), ) - system_prompt, prompt_config = await asyncio.gather( + system_prompt, prompt_config = await gather_with_cancel( execution_agent.get_system_prompt(context_wrapper), execution_agent.get_prompt(context_wrapper), ) @@ -2100,7 +2101,7 @@ async def get_new_response( if server_conversation_tracker is not None: server_conversation_tracker.mark_input_as_sent(filtered.input) - await asyncio.gather( + await gather_with_cancel( hooks.on_llm_start(context_wrapper, public_agent, filtered.instructions, filtered.input), ( public_agent.hooks.on_llm_start( @@ -2180,7 +2181,7 @@ async def rewind_model_request() -> None: context_wrapper.usage.add(new_response.usage) - await asyncio.gather( + await gather_with_cancel( ( public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) if public_agent.hooks diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py index f2872770d8..6b1d5cc97b 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -5,7 +5,6 @@ from __future__ import annotations -import asyncio import copy import dataclasses import inspect @@ -40,6 +39,7 @@ from ..tracing import SpanError from ..util import _coro from ..util._approvals import evaluate_needs_approval_setting +from ..util._asyncio_tasks import gather_with_cancel from ..util._custom_data import maybe_extract_custom_data from .items import apply_patch_rejection_item, shell_rejection_item from .tool_execution import ( @@ -126,7 +126,7 @@ async def _run_action(span: Any | None) -> RunItem: tool=action.computer_tool, run_context=context_wrapper ) agent_hooks = agent.hooks - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, action.computer_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, action.computer_tool) @@ -177,7 +177,7 @@ async def _run_action(span: Any | None) -> RunItem: ), ) - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output), ( agent_hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output) @@ -393,7 +393,7 @@ async def execute( ) -> RunItem: """Run a local shell tool call and wrap the result as a ToolCallOutputItem.""" agent_hooks = agent.hooks - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, call.local_shell_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, call.local_shell_tool) @@ -409,7 +409,7 @@ async def execute( output = call.local_shell_tool.executor(request) result = await output if inspect.isawaitable(output) else output - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result), ( agent_hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result) @@ -498,7 +498,7 @@ async def _run_call(span: Any | None) -> RunItem: rejection_message=rejection_message, ) - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, shell_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, shell_tool) @@ -572,7 +572,7 @@ async def _run_call(span: Any | None) -> RunItem: output_text = output_text[:max_output_length] log_tool_action_error("Shell executor failed", exc) - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text), ( agent_hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text) @@ -702,7 +702,7 @@ async def _run_call(span: Any | None) -> RunItem: ), ) - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_start(tool_context, agent, custom_tool), ( agent_hooks.on_tool_start(tool_context, agent, custom_tool) @@ -749,7 +749,7 @@ async def _run_call(span: Any | None) -> RunItem: ), ) - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_end(tool_context, agent, custom_tool, output_text), ( agent_hooks.on_tool_end(tool_context, agent, custom_tool, output_text) @@ -889,7 +889,7 @@ async def _run_call(span: Any | None) -> RunItem: rejection_message=rejection_message, ) - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, apply_patch_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, apply_patch_tool) @@ -966,7 +966,7 @@ async def _run_call(span: Any | None) -> RunItem: ), ) - await asyncio.gather( + await gather_with_cancel( hooks.on_tool_end(context_wrapper, agent, apply_patch_tool, output_text), ( agent_hooks.on_tool_end(context_wrapper, agent, apply_patch_tool, output_text) diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 07aa611c68..22cdddf8b2 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -602,7 +602,7 @@ async def initialize_computer_tools( tool for tool in computer_tools if _computer_tool_uses_run_scoped_initializer(tool) } - resolved_computers = await asyncio.gather( + resolved_computers = await gather_with_cancel( *(resolve_computer(tool=tool, run_context=context_wrapper) for tool in computer_tools) ) resolved_by_tool = dict(zip(computer_tools, resolved_computers, strict=True)) @@ -1456,6 +1456,7 @@ def __init__( context_wrapper: RunContextWrapper[Any], config: RunConfig, isolate_parallel_failures: bool | None, + sibling_category_failure: asyncio.Event | None, ) -> None: self.execution_agent = bindings.execution_agent self.public_agent = bindings.public_agent @@ -1466,6 +1467,7 @@ def __init__( self.isolate_parallel_failures = ( len(tool_runs) > 1 if isolate_parallel_failures is None else isolate_parallel_failures ) + self.sibling_category_failure = sibling_category_failure self.tool_input_guardrail_results: list[ToolInputGuardrailResult] = [] self.tool_output_guardrail_results: list[ToolOutputGuardrailResult] = [] self.tool_state_scope_id = get_agent_tool_state_scope(context_wrapper) @@ -1516,7 +1518,10 @@ async def execute( except asyncio.CancelledError as exc: if self.propagating_failure is exc: raise - self._cancel_pending_tasks_for_parent_cancellation() + if self.sibling_category_failure is not None and self.sibling_category_failure.is_set(): + await self._drain_pending_tasks_for_sibling_category_failure() + else: + self._cancel_pending_tasks_for_parent_cancellation() raise return ( @@ -1635,6 +1640,30 @@ async def _wait_post_invoke_tasks( timeout_seconds=_FUNCTION_TOOL_POST_INVOKE_WAIT_SECONDS, ) + async def _drain_pending_tasks_for_sibling_category_failure(self) -> None: + """Settle nested function tasks after another tool category fails.""" + cancellable_tasks, post_invoke_tasks = self._partition_pending_tasks() + self.teardown_cancelled_tasks.update(cancellable_tasks) + _cancel_function_tool_tasks(cancellable_tasks) + + try: + _, remaining_cancelled_tasks = await self._drain_cancelled_tasks(cancellable_tasks) + _, remaining_post_invoke_tasks = await self._wait_post_invoke_tasks(post_invoke_tasks) + except BaseException: + self._cancel_pending_tasks_for_parent_cancellation() + self.pending_tasks = set() + raise + + _attach_function_tool_task_result_callbacks( + remaining_cancelled_tasks, + message_for_exception=_background_cleanup_task_exception_message, + ) + _attach_function_tool_task_result_callbacks( + remaining_post_invoke_tasks, + message_for_exception=_background_post_invoke_task_exception_message, + ) + self.pending_tasks = set() + def _cancel_pending_tasks_for_parent_cancellation(self) -> None: self.teardown_cancelled_tasks.update(self.pending_tasks) _cancel_function_tool_tasks(self.pending_tasks) @@ -1862,7 +1891,7 @@ async def _execute_single_tool_body( self.schema_bypassed_tool_runs.add(id(task_state.tool_run)) return rejected_message - await asyncio.gather( + await gather_with_cancel( self.hooks.on_tool_start(tool_context, self.public_agent, func_tool), ( agent_hooks.on_tool_start(tool_context, self.public_agent, func_tool) @@ -1978,7 +2007,7 @@ async def _invoke_tool_and_run_post_invoke( if custom_data: self.custom_data_by_tool_run[id(task_state.tool_run)] = custom_data - await asyncio.gather( + await gather_with_cancel( self.hooks.on_tool_end(tool_context, self.public_agent, func_tool, final_result), ( agent_hooks.on_tool_end(tool_context, self.public_agent, func_tool, final_result) @@ -2134,6 +2163,7 @@ async def execute_function_tool_calls( context_wrapper: RunContextWrapper[Any], config: RunConfig, isolate_parallel_failures: bool | None = None, + sibling_category_failure: asyncio.Event | None = None, ) -> tuple[ list[FunctionToolResult], list[ToolInputGuardrailResult], list[ToolOutputGuardrailResult] ]: @@ -2145,6 +2175,7 @@ async def execute_function_tool_calls( context_wrapper=context_wrapper, config=config, isolate_parallel_failures=isolate_parallel_failures, + sibling_category_failure=sibling_category_failure, ).execute() diff --git a/src/agents/run_internal/tool_planning.py b/src/agents/run_internal/tool_planning.py index 84cd323fbf..8647859d67 100644 --- a/src/agents/run_internal/tool_planning.py +++ b/src/agents/run_internal/tool_planning.py @@ -25,6 +25,7 @@ from ..run_context import RunContextWrapper from ..tool import FunctionTool, MCPToolApprovalRequest, get_function_tool_origin from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult +from ..util._asyncio_tasks import gather_with_cancel from .agent_bindings import AgentBindings from .run_steps import ( ToolRunApplyPatchCall, @@ -136,7 +137,7 @@ async def run_single_approval(approval_request: ToolRunMCPApprovalRequest) -> Ru ) tasks = [run_single_approval(approval_request) for approval_request in approval_requests] - return await asyncio.gather(*tasks) + return list(await gather_with_cancel(*tasks)) def _build_tool_output_index(items: Sequence[RunItem]) -> set[tuple[str, str]]: @@ -582,6 +583,7 @@ async def _execute_tool_plan( ) ) if parallel: + sibling_category_failure = asyncio.Event() ( (function_results, tool_input_guardrail_results, tool_output_guardrail_results), computer_results, @@ -589,7 +591,7 @@ async def _execute_tool_plan( shell_results, apply_patch_results, local_shell_results, - ) = await asyncio.gather( + ) = await gather_with_cancel( execute_function_tool_calls( bindings=bindings, tool_runs=plan.function_runs, @@ -597,6 +599,7 @@ async def _execute_tool_plan( context_wrapper=context_wrapper, config=run_config, isolate_parallel_failures=isolate_function_tool_failures, + sibling_category_failure=sibling_category_failure, ), execute_computer_actions( public_agent=public_agent, @@ -633,6 +636,7 @@ async def _execute_tool_plan( context_wrapper=context_wrapper, config=run_config, ), + on_child_failure=sibling_category_failure.set, ) else: ( diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 49dd5c439a..5bf9839533 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -107,6 +107,7 @@ from ..tracing import SpanError, handoff_span from ..util import _coro, _error_tracing from ..util._approvals import evaluate_needs_approval_setting +from ..util._asyncio_tasks import gather_with_cancel from .agent_bindings import AgentBindings from .error_handlers import ( build_run_error_data, @@ -330,7 +331,7 @@ async def run_final_output_hooks( turn_input=context_wrapper.turn_input, ) - await asyncio.gather( + await gather_with_cancel( hooks.on_agent_end(agent_hook_context, agent, final_output), agent.hooks.on_end(agent_hook_context, agent, final_output) if agent.hooks @@ -553,7 +554,7 @@ def nest_history( ) ) - await asyncio.gather( + await gather_with_cancel( hooks.on_handoff( context=context_wrapper, from_agent=public_agent, diff --git a/src/agents/util/_asyncio_tasks.py b/src/agents/util/_asyncio_tasks.py index b134b3d24e..90c3146f18 100644 --- a/src/agents/util/_asyncio_tasks.py +++ b/src/agents/util/_asyncio_tasks.py @@ -1,13 +1,24 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from typing import Any, TypeVar, overload T = TypeVar("T") T1 = TypeVar("T1") T2 = TypeVar("T2") T3 = TypeVar("T3") +T4 = TypeVar("T4") +T5 = TypeVar("T5") +T6 = TypeVar("T6") + + +def _consume_future_exception(future: asyncio.Future[Any]) -> None: + """Retrieve a completed future's exception without changing its result semantics.""" + try: + future.exception() + except asyncio.CancelledError: + pass @overload @@ -15,6 +26,8 @@ async def gather_with_cancel( awaitable_1: Awaitable[T1], awaitable_2: Awaitable[T2], /, + *, + on_child_failure: Callable[[], None] | None = None, ) -> tuple[T1, T2]: ... @@ -24,18 +37,73 @@ async def gather_with_cancel( awaitable_2: Awaitable[T2], awaitable_3: Awaitable[T3], /, + *, + on_child_failure: Callable[[], None] | None = None, ) -> tuple[T1, T2, T3]: ... @overload -async def gather_with_cancel(*awaitables: Awaitable[T]) -> tuple[T, ...]: ... +async def gather_with_cancel( + awaitable_1: Awaitable[T1], + awaitable_2: Awaitable[T2], + awaitable_3: Awaitable[T3], + awaitable_4: Awaitable[T4], + /, + *, + on_child_failure: Callable[[], None] | None = None, +) -> tuple[T1, T2, T3, T4]: ... -async def gather_with_cancel(*awaitables: Awaitable[Any]) -> tuple[Any, ...]: +@overload +async def gather_with_cancel( + awaitable_1: Awaitable[T1], + awaitable_2: Awaitable[T2], + awaitable_3: Awaitable[T3], + awaitable_4: Awaitable[T4], + awaitable_5: Awaitable[T5], + /, + *, + on_child_failure: Callable[[], None] | None = None, +) -> tuple[T1, T2, T3, T4, T5]: ... + + +@overload +async def gather_with_cancel( + awaitable_1: Awaitable[T1], + awaitable_2: Awaitable[T2], + awaitable_3: Awaitable[T3], + awaitable_4: Awaitable[T4], + awaitable_5: Awaitable[T5], + awaitable_6: Awaitable[T6], + /, + *, + on_child_failure: Callable[[], None] | None = None, +) -> tuple[T1, T2, T3, T4, T5, T6]: ... + + +@overload +async def gather_with_cancel( + *awaitables: Awaitable[T], + on_child_failure: Callable[[], None] | None = None, +) -> tuple[T, ...]: ... + + +async def gather_with_cancel( + *awaitables: Awaitable[Any], + on_child_failure: Callable[[], None] | None = None, +) -> tuple[Any, ...]: """Gather awaitables, cancelling and draining siblings when one raises.""" tasks = [asyncio.ensure_future(awaitable) for awaitable in awaitables] + gather_future = asyncio.gather(*tasks) + gather_future.add_done_callback(_consume_future_exception) try: - return tuple(await asyncio.gather(*tasks)) + await asyncio.wait((gather_future,)) + try: + return tuple(gather_future.result()) + except BaseException: + if on_child_failure is not None: + on_child_failure() + raise except BaseException: for task in tasks: if not task.done(): diff --git a/tests/test_agent_prompt.py b/tests/test_agent_prompt.py index e3ed40fbe1..b9a9865b03 100644 --- a/tests/test_agent_prompt.py +++ b/tests/test_agent_prompt.py @@ -1,11 +1,15 @@ from __future__ import annotations +import asyncio +from typing import Any + import pytest from openai import omit from agents import Agent, Prompt, RunConfig, RunContextWrapper, Runner from agents.models.interface import Model, ModelProvider from agents.models.openai_responses import OpenAIResponsesModel +from agents.prompts import GenerateDynamicPromptData from .fake_model import FakeModel, get_response_obj from .test_responses import get_text_message @@ -142,3 +146,75 @@ def __init__(self): assert called_kwargs["prompt"] == expected_prompt assert called_kwargs["model"] is omit assert called_kwargs["tools"] is omit + + +@pytest.mark.asyncio +async def test_run_cancels_sibling_instructions_when_prompt_resolution_fails() -> None: + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + slow_finished = asyncio.Event() + + async def slow_instructions(_ctx: RunContextWrapper[Any], _agent: Agent[Any]) -> str: + slow_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + slow_cancelled.set() + raise + finally: + slow_finished.set() + return "unreachable" + + async def failing_prompt(_data: GenerateDynamicPromptData) -> Prompt: + await slow_started.wait() + raise RuntimeError("prompt resolution failed") + + agent = Agent( + name="prompt-agent", + model=FakeModel(), + instructions=slow_instructions, + prompt=failing_prompt, + ) + + with pytest.raises(RuntimeError, match="prompt resolution failed"): + await Runner.run(agent, input="hi") + + assert slow_cancelled.is_set() + assert slow_finished.is_set() + + +@pytest.mark.asyncio +async def test_run_streamed_cancels_sibling_instructions_when_prompt_resolution_fails() -> None: + slow_started = asyncio.Event() + slow_cancelled = asyncio.Event() + slow_finished = asyncio.Event() + + async def slow_instructions(_ctx: RunContextWrapper[Any], _agent: Agent[Any]) -> str: + slow_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + slow_cancelled.set() + raise + finally: + slow_finished.set() + return "unreachable" + + async def failing_prompt(_data: GenerateDynamicPromptData) -> Prompt: + await slow_started.wait() + raise RuntimeError("prompt resolution failed") + + agent = Agent( + name="prompt-agent", + model=FakeModel(), + instructions=slow_instructions, + prompt=failing_prompt, + ) + + with pytest.raises(RuntimeError, match="prompt resolution failed"): + result = Runner.run_streamed(agent, input="hi") + async for _event in result.stream_events(): + pass + + assert slow_cancelled.is_set() + assert slow_finished.is_set() diff --git a/tests/test_asyncio_tasks.py b/tests/test_asyncio_tasks.py new file mode 100644 index 0000000000..0315a63705 --- /dev/null +++ b/tests/test_asyncio_tasks.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from agents.util._asyncio_tasks import gather_with_cancel + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error_type", [RuntimeError, asyncio.CancelledError]) +async def test_gather_with_cancel_reports_child_failure_before_cancelling_siblings( + error_type: type[BaseException], +) -> None: + sibling_started = asyncio.Event() + sibling_cancelled = asyncio.Event() + child_failure_reported = asyncio.Event() + + async def sibling() -> None: + sibling_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + sibling_cancelled.set() + raise + + async def fail_after_sibling_starts() -> None: + await sibling_started.wait() + raise error_type("child failed") + + with pytest.raises(error_type): + await gather_with_cancel( + sibling(), + fail_after_sibling_starts(), + on_child_failure=child_failure_reported.set, + ) + + assert child_failure_reported.is_set() + assert sibling_cancelled.is_set() + + +@pytest.mark.asyncio +async def test_gather_with_cancel_does_not_report_parent_cancellation_as_child_failure() -> None: + children_started = 0 + all_children_started = asyncio.Event() + child_failure_reported = asyncio.Event() + loop_errors: list[dict[str, object]] = [] + loop = asyncio.get_running_loop() + previous_exception_handler = loop.get_exception_handler() + + async def child() -> None: + nonlocal children_started + children_started += 1 + if children_started == 2: + all_children_started.set() + await asyncio.Event().wait() + + loop.set_exception_handler(lambda _loop, context: loop_errors.append(context)) + try: + task = asyncio.create_task( + gather_with_cancel( + child(), + child(), + on_child_failure=child_failure_reported.set, + ) + ) + await all_children_started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0) + finally: + loop.set_exception_handler(previous_exception_handler) + + assert not child_failure_reported.is_set() + assert loop_errors == [] diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 6f7cbf07e4..8a7d080942 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -12,6 +12,11 @@ import pytest from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_computer_tool_call import ( + ActionScreenshot, + PendingSafetyCheck, + ResponseComputerToolCall, +) from openai.types.responses.response_output_item import McpApprovalRequest from openai.types.responses.response_output_message import ResponseOutputMessage from openai.types.responses.response_output_refusal import ResponseOutputRefusal @@ -21,6 +26,7 @@ Agent, AgentBase, ApplyPatchTool, + ComputerTool, FunctionTool, HostedMCPTool, MCPApprovalRequestItem, @@ -3618,3 +3624,365 @@ async def test_execute_tools_emits_hosted_mcp_rejection_reason_from_explicit_mes assert responses[0].raw_item["approve"] is False assert responses[0].raw_item["approval_request_id"] == "mcp-approval-reject-reason" assert responses[0].raw_item["reason"] == "Denied by policy" + + +@pytest.mark.asyncio +async def test_execute_tool_plan_cancels_sibling_category_on_failure() -> None: + from agents.run_internal.tool_planning import ToolExecutionPlan, _execute_tool_plan + + from .test_computer_tool_lifecycle import FakeComputer + + shell_started = asyncio.Event() + shell_cancelled = asyncio.Event() + shell_finished = asyncio.Event() + + async def blocking_executor(_request: Any) -> str: + shell_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + shell_cancelled.set() + raise + finally: + shell_finished.set() + return "unreachable" + + async def reject_once_shell_is_running(_data: Any) -> bool: + await shell_started.wait() + return False + + shell_tool = ShellTool(executor=blocking_executor) + computer_tool = ComputerTool( + computer=FakeComputer(), on_safety_check=reject_once_shell_is_running + ) + agent: Agent[Any] = Agent(name="test", tools=[shell_tool, computer_tool]) + plan = ToolExecutionPlan( + shell_calls=[ + ToolRunShellCall( + tool_call=cast(Any, make_shell_call("shell-1", commands=["sleep 1000"])), + shell_tool=shell_tool, + ) + ], + computer_actions=[ + ToolRunComputerAction( + tool_call=ResponseComputerToolCall( + id="computer-1", + type="computer_call", + call_id="computer-1", + action=ActionScreenshot(type="screenshot"), + pending_safety_checks=[ + PendingSafetyCheck(id="sc-1", code="malicious", message="nope") + ], + status="completed", + ), + computer_tool=computer_tool, + ) + ], + ) + + with pytest.raises(UserError, match="safety check was not acknowledged"): + await _execute_tool_plan( + plan=plan, + bindings=bind_public_agent(agent), + hooks=RunHooks[Any](), + context_wrapper=RunContextWrapper(context=None), + run_config=RunConfig(), + ) + + assert shell_cancelled.is_set() + assert shell_finished.is_set() + + +@pytest.mark.asyncio +async def test_execute_tool_plan_drains_function_tools_on_sibling_failure() -> None: + from agents.run_internal.tool_planning import ToolExecutionPlan, _execute_tool_plan + + from .test_computer_tool_lifecycle import FakeComputer + + tool_started = asyncio.Event() + tool_cancelled = asyncio.Event() + tool_unwound = asyncio.Event() + + @function_tool + async def slow_tool() -> str: + tool_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + tool_cancelled.set() + await asyncio.sleep(0) + tool_unwound.set() + raise + return "unreachable" + + async def reject_once_tool_is_running(_data: Any) -> bool: + await tool_started.wait() + return False + + computer_tool = ComputerTool( + computer=FakeComputer(), on_safety_check=reject_once_tool_is_running + ) + agent: Agent[Any] = Agent(name="test", tools=[slow_tool, computer_tool]) + plan = ToolExecutionPlan( + function_runs=[ + ToolRunFunction( + tool_call=ResponseFunctionToolCall( + id="fn-1", + call_id="fn-1", + name="slow_tool", + arguments="{}", + type="function_call", + ), + function_tool=cast(Any, slow_tool), + ) + ], + computer_actions=[ + ToolRunComputerAction( + tool_call=ResponseComputerToolCall( + id="computer-1", + type="computer_call", + call_id="computer-1", + action=ActionScreenshot(type="screenshot"), + pending_safety_checks=[ + PendingSafetyCheck(id="sc-1", code="malicious", message="nope") + ], + status="completed", + ), + computer_tool=computer_tool, + ) + ], + ) + + with pytest.raises(UserError, match="safety check was not acknowledged"): + await _execute_tool_plan( + plan=plan, + bindings=bind_public_agent(agent), + hooks=RunHooks[Any](), + context_wrapper=RunContextWrapper(context=None), + run_config=RunConfig(), + ) + + assert tool_cancelled.is_set() + assert tool_unwound.is_set() + + +class _SlowToolEndHooks(RunHooks[Any]): + def __init__(self, started: asyncio.Event, finished: asyncio.Event) -> None: + self.started = started + self.finished = finished + + async def on_tool_end(self, context: Any, agent: Any, tool: Any, result: Any) -> None: + self.started.set() + await asyncio.sleep(0.05) + self.finished.set() + + +@pytest.mark.asyncio +async def test_execute_tool_plan_preserves_function_tool_post_invoke_work() -> None: + from agents.run_internal.tool_planning import ToolExecutionPlan, _execute_tool_plan + + from .test_computer_tool_lifecycle import FakeComputer + + post_invoke_started = asyncio.Event() + post_invoke_finished = asyncio.Event() + + @function_tool + async def quick_tool() -> str: + return "ok" + + async def reject_once_post_invoke_is_running(_data: Any) -> bool: + await post_invoke_started.wait() + return False + + computer_tool = ComputerTool( + computer=FakeComputer(), on_safety_check=reject_once_post_invoke_is_running + ) + agent: Agent[Any] = Agent(name="test", tools=[quick_tool, computer_tool]) + plan = ToolExecutionPlan( + function_runs=[ + ToolRunFunction( + tool_call=ResponseFunctionToolCall( + id="fn-1", + call_id="fn-1", + name="quick_tool", + arguments="{}", + type="function_call", + ), + function_tool=cast(Any, quick_tool), + ) + ], + computer_actions=[ + ToolRunComputerAction( + tool_call=ResponseComputerToolCall( + id="computer-1", + type="computer_call", + call_id="computer-1", + action=ActionScreenshot(type="screenshot"), + pending_safety_checks=[ + PendingSafetyCheck(id="sc-1", code="malicious", message="nope") + ], + status="completed", + ), + computer_tool=computer_tool, + ) + ], + ) + + with pytest.raises(UserError, match="safety check was not acknowledged"): + await _execute_tool_plan( + plan=plan, + bindings=bind_public_agent(agent), + hooks=_SlowToolEndHooks(post_invoke_started, post_invoke_finished), + context_wrapper=RunContextWrapper(context=None), + run_config=RunConfig(), + ) + + assert post_invoke_started.is_set() + assert post_invoke_finished.is_set() + + +@pytest.mark.asyncio +async def test_execute_tool_plan_parent_cancellation_does_not_wait_for_function_cleanup() -> None: + from agents.run_internal.tool_planning import ToolExecutionPlan, _execute_tool_plan + + tool_started = asyncio.Event() + cleanup_started = asyncio.Event() + cleanup_finished = asyncio.Event() + allow_cleanup_exit = asyncio.Event() + + @function_tool + async def slow_tool() -> str: + tool_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cleanup_started.set() + await allow_cleanup_exit.wait() + cleanup_finished.set() + raise + return "unreachable" + + agent: Agent[Any] = Agent(name="test", tools=[slow_tool]) + plan = ToolExecutionPlan( + function_runs=[ + ToolRunFunction( + tool_call=ResponseFunctionToolCall( + id="fn-1", + call_id="fn-1", + name="slow_tool", + arguments="{}", + type="function_call", + ), + function_tool=cast(Any, slow_tool), + ) + ] + ) + execution_task = asyncio.create_task( + _execute_tool_plan( + plan=plan, + bindings=bind_public_agent(agent), + hooks=RunHooks[Any](), + context_wrapper=RunContextWrapper(context=None), + run_config=RunConfig(), + ) + ) + await tool_started.wait() + + execution_task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(execution_task, timeout=0.1) + + await cleanup_started.wait() + allow_cleanup_exit.set() + await cleanup_finished.wait() + + +@pytest.mark.asyncio +async def test_execute_tool_plan_parent_cancellation_interrupts_sibling_failure_drain() -> None: + from agents.run_internal.tool_planning import ToolExecutionPlan, _execute_tool_plan + + from .test_computer_tool_lifecycle import FakeComputer + + tool_started = asyncio.Event() + cleanup_started = asyncio.Event() + allow_cleanup_exit = asyncio.Event() + cleanup_finished = asyncio.Event() + loop_errors: list[dict[str, object]] = [] + loop = asyncio.get_running_loop() + previous_exception_handler = loop.get_exception_handler() + + @function_tool + async def slow_tool() -> str: + tool_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cleanup_started.set() + await allow_cleanup_exit.wait() + cleanup_finished.set() + raise RuntimeError("late cleanup failure") from None + return "unreachable" + + async def reject_once_tool_is_running(_data: Any) -> bool: + await tool_started.wait() + return False + + computer_tool = ComputerTool( + computer=FakeComputer(), on_safety_check=reject_once_tool_is_running + ) + agent: Agent[Any] = Agent(name="test", tools=[slow_tool, computer_tool]) + plan = ToolExecutionPlan( + function_runs=[ + ToolRunFunction( + tool_call=ResponseFunctionToolCall( + id="fn-1", + call_id="fn-1", + name="slow_tool", + arguments="{}", + type="function_call", + ), + function_tool=cast(Any, slow_tool), + ) + ], + computer_actions=[ + ToolRunComputerAction( + tool_call=ResponseComputerToolCall( + id="computer-1", + type="computer_call", + call_id="computer-1", + action=ActionScreenshot(type="screenshot"), + pending_safety_checks=[ + PendingSafetyCheck(id="sc-1", code="malicious", message="nope") + ], + status="completed", + ), + computer_tool=computer_tool, + ) + ], + ) + + loop.set_exception_handler(lambda _loop, context: loop_errors.append(context)) + try: + execution_task = asyncio.create_task( + _execute_tool_plan( + plan=plan, + bindings=bind_public_agent(agent), + hooks=RunHooks[Any](), + context_wrapper=RunContextWrapper(context=None), + run_config=RunConfig(), + ) + ) + await cleanup_started.wait() + + execution_task.cancel() + try: + with pytest.raises(asyncio.CancelledError): + await execution_task + finally: + allow_cleanup_exit.set() + await asyncio.wait_for(cleanup_finished.wait(), timeout=1.0) + await asyncio.sleep(0) + finally: + loop.set_exception_handler(previous_exception_handler) + + assert loop_errors == [] From 36829b62e327069d6119f15db934d2afb806b26a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 10:22:57 +0900 Subject: [PATCH 153/473] fix(models): surface non-streaming content-filter refusals (#4188) Co-authored-by: LeSingh1 --- src/agents/models/openai_chatcompletions.py | 14 ++ tests/models/test_openai_chatcompletions.py | 150 ++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index b00cf0273d..39f7d71388 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -293,6 +293,20 @@ async def get_response( if response.usage else Usage() ) + + # Some providers signal a filtered non-streaming completion only through + # finish_reason="content_filter" and an otherwise empty message. Preserve + # that terminal signal as a refusal instead of returning an empty output. + if ( + message is not None + and first_choice is not None + and first_choice.finish_reason == "content_filter" + and not message.content + and not message.refusal + and not message.tool_calls + ): + message.refusal = "Response withheld by the provider's content filter." + if tracing.include_data(): span_generation.span_data.output = ( [message.model_dump()] if message is not None else [] diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 7bebfbc6e4..2f1c13f7cd 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -46,11 +46,13 @@ Runner, __version__, generation_span, + trace, ) from agents.exceptions import UserError from agents.models._retry_runtime import provider_managed_retries_disabled from agents.models.chatcmpl_helpers import HEADERS_OVERRIDE, ChatCmplHelpers from agents.models.fake_id import FAKE_RESPONSES_ID +from tests.testing_processor import fetch_ordered_spans def _minimal_chat_completion(content: str = "ok") -> ChatCompletion: @@ -172,6 +174,154 @@ async def patched_fetch_response(self, *args, **kwargs): assert resp.response_id is None +async def _get_response_for_choice( + monkeypatch: pytest.MonkeyPatch, + choice: Choice, + tracing: ModelTracing = ModelTracing.DISABLED, +) -> ModelResponse: + chat = ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[choice], + ) + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + return await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=tracing, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_surfaces_empty_content_filter_as_refusal(monkeypatch) -> None: + resp = await _get_response_for_choice( + monkeypatch, + Choice( + index=0, + finish_reason="content_filter", + message=ChatCompletionMessage(role="assistant", content=None), + ), + ) + + assert len(resp.output) == 1 + assert isinstance(resp.output[0], ResponseOutputMessage) + assert len(resp.output[0].content) == 1 + assert isinstance(resp.output[0].content[0], ResponseOutputRefusal) + assert ( + resp.output[0].content[0].refusal == "Response withheld by the provider's content filter." + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_traces_synthesized_content_filter_refusal(monkeypatch) -> None: + with trace(workflow_name="content-filter-refusal"): + await _get_response_for_choice( + monkeypatch, + Choice( + index=0, + finish_reason="content_filter", + message=ChatCompletionMessage(role="assistant", content=None), + ), + tracing=ModelTracing.ENABLED, + ) + + generation_spans = [ + span for span in fetch_ordered_spans() if span.span_data.type == "generation" + ] + assert len(generation_spans) == 1 + exported_span = generation_spans[0].export() + assert exported_span is not None + assert exported_span["span_data"]["output"][0]["refusal"] == ( + "Response withheld by the provider's content filter." + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("message", "expected_output_type", "expected_content_type"), + [ + ( + ChatCompletionMessage(role="assistant", content="partial"), + ResponseOutputMessage, + ResponseOutputText, + ), + ( + ChatCompletionMessage(role="assistant", content=None, refusal="provider refusal"), + ResponseOutputMessage, + ResponseOutputRefusal, + ), + ( + ChatCompletionMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call-1", + type="function", + function=Function(name="do_thing", arguments="{}"), + ) + ], + ), + ResponseFunctionToolCall, + None, + ), + ], +) +async def test_get_response_preserves_nonempty_content_filter_output( + monkeypatch, + message: ChatCompletionMessage, + expected_output_type: type[object], + expected_content_type: type[object] | None, +) -> None: + resp = await _get_response_for_choice( + monkeypatch, + Choice(index=0, finish_reason="content_filter", message=message), + ) + + assert len(resp.output) == 1 + assert isinstance(resp.output[0], expected_output_type) + if expected_content_type is not None: + assert isinstance(resp.output[0], ResponseOutputMessage) + assert len(resp.output[0].content) == 1 + assert isinstance(resp.output[0].content[0], expected_content_type) + if isinstance(resp.output[0], ResponseOutputMessage) and isinstance( + resp.output[0].content[0], ResponseOutputRefusal + ): + assert resp.output[0].content[0].refusal == "provider refusal" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_preserves_empty_nonfiltered_output(monkeypatch) -> None: + resp = await _get_response_for_choice( + monkeypatch, + Choice( + index=0, + finish_reason="stop", + message=ChatCompletionMessage(role="assistant", content=None), + ), + ) + + assert resp.output == [] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize( From 81de0d0afe9ef537e095a16031339dc87867ab4b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 10:25:58 +0900 Subject: [PATCH 154/473] fix(realtime): clean up failed connection attempts (#4189) Co-authored-by: Shaurya Singh --- src/agents/realtime/openai_realtime.py | 129 ++++++++++++++++--------- tests/realtime/test_openai_realtime.py | 93 +++++++++++++++++- 2 files changed, 177 insertions(+), 45 deletions(-) diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index ec4a156c58..b0af25ad40 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -547,6 +547,7 @@ def __init__(self, *, transport_config: TransportConfig | None = None) -> None: self.model = DEFAULT_REALTIME_MODEL self._websocket: ClientConnection | None = None self._websocket_task: asyncio.Task[None] | None = None + self._connection_attempt_active = False self._response_create_tasks: set[asyncio.Task[None]] = set() self._user_input_lock = asyncio.Lock() self._listeners: list[RealtimeModelListener] = [] @@ -579,56 +580,78 @@ def _pending_response_create_event_id(self) -> str | None: async def connect(self, options: RealtimeModelConfig) -> None: """Establish a connection to the model and keep it alive.""" - assert self._websocket is None, "Already connected" - assert self._websocket_task is None, "Already connected" + if ( + self._connection_attempt_active + or self._websocket is not None + or self._websocket_task is not None + ): + raise AssertionError("Already connected") + previous_model = self.model + self._connection_attempt_active = True - model_settings: RealtimeSessionModelSettings = options.get("initial_model_settings", {}) + try: + model_settings: RealtimeSessionModelSettings = options.get("initial_model_settings", {}) - self._playback_tracker = options.get("playback_tracker", None) + self._playback_tracker = options.get("playback_tracker", None) - call_id = options.get("call_id") - model_name = model_settings.get("model_name") - if call_id and model_name: - error_message = ( - "Cannot specify both `call_id` and `model_name` " - "when attaching to an existing realtime call." - ) - raise UserError(error_message) + call_id = options.get("call_id") + model_name = model_settings.get("model_name") + if call_id and model_name: + error_message = ( + "Cannot specify both `call_id` and `model_name` " + "when attaching to an existing realtime call." + ) + raise UserError(error_message) - if model_name: - self.model = model_name + if model_name: + self.model = model_name - self._call_id = call_id - api_key = await get_api_key(options.get("api_key")) + self._call_id = call_id + api_key = await get_api_key(options.get("api_key")) - if "tracing" in model_settings: - self._tracing_config = model_settings["tracing"] - else: - self._tracing_config = "auto" + if "tracing" in model_settings: + self._tracing_config = model_settings["tracing"] + else: + self._tracing_config = "auto" - if call_id: - url = options.get("url", f"wss://api.openai.com/v1/realtime?call_id={call_id}") - else: - url = options.get("url", f"wss://api.openai.com/v1/realtime?model={self.model}") + if call_id: + url = options.get("url", f"wss://api.openai.com/v1/realtime?call_id={call_id}") + else: + url = options.get("url", f"wss://api.openai.com/v1/realtime?model={self.model}") - headers: dict[str, str] = {} - if options.get("headers") is not None: - # For customizing request headers - headers.update(options["headers"]) - else: - # OpenAI's Realtime API - if not api_key: - raise UserError("API key is required but was not provided.") + headers: dict[str, str] = {} + if options.get("headers") is not None: + # For customizing request headers + headers.update(options["headers"]) + else: + # OpenAI's Realtime API + if not api_key: + raise UserError("API key is required but was not provided.") - headers.update({"Authorization": f"Bearer {api_key}"}) + headers.update({"Authorization": f"Bearer {api_key}"}) - self._websocket = await self._create_websocket_connection( - url=url, - headers=headers, - transport_config=self._transport_config, - ) - self._websocket_task = asyncio.create_task(self._listen_for_messages()) - await self._update_session_config(model_settings) + self._websocket = await self._create_websocket_connection( + url=url, + headers=headers, + transport_config=self._transport_config, + ) + try: + self._websocket_task = asyncio.create_task(self._listen_for_messages()) + await self._update_session_config(model_settings) + except BaseException: + try: + await self.close() + except BaseException: + logger.warning( + "Failed to clean up after Realtime connection setup failure", + exc_info=True, + ) + raise + except BaseException: + self.model = previous_model + raise + finally: + self._connection_attempt_active = False async def _create_websocket_connection( self, @@ -1219,18 +1242,36 @@ async def close(self) -> None: """Close the session.""" try: await self._cancel_response_create_tasks() + cleanup_error: BaseException | None = None + if self._websocket: - await self._websocket.close() - self._websocket = None + try: + await self._websocket.close() + except BaseException as exc: + cleanup_error = exc + finally: + self._websocket = None + if self._websocket_task: self._websocket_task.cancel() try: await self._websocket_task except asyncio.CancelledError: pass - self._websocket_task = None + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + finally: + self._websocket_task = None else: - await self._release_response_waiters() + try: + await self._release_response_waiters() + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + + if cleanup_error is not None: + raise cleanup_error finally: self._clear_response_audio_indexes() diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 2d57a4244e..b4a12ae639 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -10,7 +10,7 @@ import websockets from pydantic import TypeAdapter -from agents import Agent, function_tool +from agents import Agent, WebSearchTool, function_tool from agents.exceptions import UserError from agents.handoffs import handoff from agents.realtime.model import RealtimeModelConfig, RealtimePlaybackTracker @@ -342,6 +342,97 @@ async def test_connect_websocket_failure_propagates(self, model): assert model._websocket is None assert model._websocket_task is None + @pytest.mark.asyncio + async def test_connect_session_config_failure_releases_websocket(self, model, mock_websocket): + """A failed initial session update must release the connection.""" + default_model = model.model + invalid_config: RealtimeModelConfig = { + "api_key": "test-key", + "initial_model_settings": { + "model_name": "failed-model", + "tools": [WebSearchTool()], + }, + } + + async def async_websocket(*args, **kwargs): + return mock_websocket + + with patch("websockets.connect", side_effect=async_websocket) as mock_connect: + with pytest.raises(UserError, match="Must be a function tool"): + await model.connect(invalid_config) + + assert model._websocket is None + assert model._websocket_task is None + assert model.model == default_model + mock_websocket.close.assert_awaited_once() + + await model.connect({"api_key": "test-key"}) + assert mock_connect.call_count == 2 + assert mock_connect.call_args_list[1].args[0].endswith(f"?model={default_model}") + + await model.close() + + @pytest.mark.asyncio + async def test_connect_preserves_setup_error_when_websocket_close_fails( + self, model, mock_websocket + ): + """A cleanup failure must not mask the initial session update error.""" + mock_websocket.close.side_effect = RuntimeError("close failed") + retry_websocket = AsyncMock() + connections = iter((mock_websocket, retry_websocket)) + + async def async_websocket(*args, **kwargs): + return next(connections) + + invalid_config: RealtimeModelConfig = { + "api_key": "test-key", + "initial_model_settings": {"tools": [WebSearchTool()]}, + } + + with patch("websockets.connect", side_effect=async_websocket): + with pytest.raises(UserError, match="Must be a function tool"): + await model.connect(invalid_config) + + assert model._websocket is None + assert model._websocket_task is None + mock_websocket.close.assert_awaited_once() + + await model.connect({"api_key": "test-key"}) + + assert model._websocket is retry_websocket + await model.close() + + @pytest.mark.asyncio + async def test_concurrent_connect_is_rejected_before_acquiring_another_websocket( + self, model, mock_websocket + ): + """A connection attempt must own the model before its first suspension point.""" + connection_started = asyncio.Event() + allow_connection = asyncio.Event() + + async def async_websocket(*args, **kwargs): + connection_started.set() + await allow_connection.wait() + return mock_websocket + + config: RealtimeModelConfig = {"api_key": "test-key"} + with patch("websockets.connect", side_effect=async_websocket) as mock_connect: + first_connect = asyncio.create_task(model.connect(config)) + try: + await asyncio.wait_for(connection_started.wait(), timeout=1) + + with pytest.raises(AssertionError, match="Already connected"): + await model.connect(config) + + mock_connect.assert_called_once() + finally: + allow_connection.set() + + await asyncio.wait_for(first_connect, timeout=1) + + assert model._websocket is mock_websocket + await model.close() + @pytest.mark.asyncio async def test_connect_with_empty_transport_config(self, mock_websocket): """Test that empty transport configuration works without error.""" From 107260f992c7dca8b87579d0acd55c61d265936e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 10:47:53 +0900 Subject: [PATCH 155/473] fix(tracing): mark non-streaming agent span failures (#4191) Co-authored-by: Henry Su --- src/agents/run.py | 6 + src/agents/run_internal/error_handlers.py | 82 +++++++- src/agents/run_internal/run_loop.py | 46 ++--- tests/test_tracing_errors.py | 219 +++++++++++++++++++++- 4 files changed, 315 insertions(+), 38 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index aafbf0e9ec..00028cf406 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -72,6 +72,7 @@ ) from .run_internal.approvals import approvals_from_step from .run_internal.error_handlers import ( + attach_generic_agent_error, build_run_error_data, create_message_output_item, format_final_output_text, @@ -1614,6 +1615,11 @@ def _finalize_result(result: RunResult) -> RunResult: turn_result.new_step_items.clear() except BaseException as exc: run_exception = exc + attach_generic_agent_error( + current_span, + exc, + trace_include_sensitive_data=run_config.trace_include_sensitive_data, + ) if isinstance(exc, AgentsException): exc.run_data = RunErrorDetails( input=original_input, diff --git a/src/agents/run_internal/error_handlers.py b/src/agents/run_internal/error_handlers.py index 39a291d4f9..8c30f54d95 100644 --- a/src/agents/run_internal/error_handlers.py +++ b/src/agents/run_internal/error_handlers.py @@ -8,7 +8,14 @@ from ..agent import Agent from ..agent_output import _WRAPPER_DICT_KEY, AgentOutputSchema -from ..exceptions import MaxTurnsExceeded, ModelBehaviorError, ModelRefusalError, UserError +from ..exceptions import ( + InputGuardrailTripwireTriggered, + MaxTurnsExceeded, + ModelBehaviorError, + ModelRefusalError, + OutputGuardrailTripwireTriggered, + UserError, +) from ..items import ( ItemHelpers, MessageOutputItem, @@ -16,6 +23,7 @@ RunItem, TResponseInputItem, ) +from ..logger import logger from ..models.fake_id import FAKE_RESPONSES_ID from ..run_context import RunContextWrapper, TContext from ..run_error_handlers import ( @@ -24,11 +32,83 @@ RunErrorHandlerResult, RunErrorHandlers, ) +from ..tracing import Span, SpanError +from ..util import _error_tracing +from ..util._error_tracing import REDACTED_TRACE_ERROR_MESSAGE from .items import ReasoningItemIdPolicy, run_item_to_input_item from .turn_preparation import get_output_schema RunErrorHandlerKind = Literal["max_turns", "model_refusal", "invalid_final_output"] +GENERIC_AGENT_ERROR_MESSAGE = "Error in agent run" +UNFORMATTABLE_TRACE_ERROR_MESSAGE = "Error details are unavailable." + + +def _is_generic_agent_error(exc: BaseException) -> bool: + """Return whether a failed run still needs the generic agent-span error. + + Only ``Exception`` is eligible: the non-streaming handler also catches ``BaseException``, but + cancellation is not an agent failure and the streamed path never marks it. Failures that + already write their own agent-span error, or that a dedicated child span reports, are excluded + so the span keeps the more specific diagnosis. + """ + if not isinstance(exc, Exception): + return False + return not isinstance( + exc, + ModelBehaviorError | InputGuardrailTripwireTriggered | OutputGuardrailTripwireTriggered, + ) + + +def _format_agent_error_detail(exc: BaseException) -> str: + """Stringify an exception for tracing without ever raising. + + A custom exception whose ``__str__`` raises must not replace the exception the run is + propagating, so the formatting failure is swallowed and reported as a placeholder. + """ + try: + return str(exc) + except BaseException: + return UNFORMATTABLE_TRACE_ERROR_MESSAGE + + +def attach_generic_agent_error( + span: Span[Any] | None, + exc: BaseException, + *, + trace_include_sensitive_data: bool, +) -> None: + """Mark the agent span of a failed run with the generic ``Error in agent run`` error. + + This owns the whole policy shared by the streaming and non-streaming paths: eligibility, + preserving a more specific error already on the span, redaction, the span error payload, and + the attachment itself. Tracing never changes what the run raises: the exception is stringified + only when sensitive data is traced, and a formatting failure cannot propagate. + """ + if span is None or not _is_generic_agent_error(exc): + return + + try: + if span.error is not None: + return + detail = ( + _format_agent_error_detail(exc) + if trace_include_sensitive_data + else REDACTED_TRACE_ERROR_MESSAGE + ) + _error_tracing.attach_error_to_span( + span, + SpanError(message=GENERIC_AGENT_ERROR_MESSAGE, data={"error": detail}), + ) + except BaseException as tracing_error: + try: + logger.warning( + "Failed to record a generic agent error on the span (%s)", + type(tracing_error).__name__, + ) + except BaseException: + pass + def build_run_error_data( *, diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 0ae8aeebdb..643238d914 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -108,6 +108,7 @@ ) from .approvals import approvals_from_step from .error_handlers import ( + attach_generic_agent_error, build_run_error_data, create_message_output_item, format_final_output_text, @@ -287,13 +288,6 @@ def _agent_diagnostic_extra(agent: Agent[Any]) -> dict[str, object]: return {"agent_name": agent.name} -def _should_attach_generic_agent_error(exc: Exception) -> bool: - return not isinstance( - exc, - ModelBehaviorError | InputGuardrailTripwireTriggered | OutputGuardrailTripwireTriggered, - ) - - async def _should_persist_stream_items( *, session: Session | None, @@ -1384,21 +1378,11 @@ async def _save_stream_items_without_count( if await _wait_for_streamed_turn_events_and_stop_if_cancelled(streamed_result): break except Exception as e: - if current_span and _should_attach_generic_agent_error(e): - _error_tracing.attach_error_to_span( - current_span, - SpanError( - message="Error in agent run", - data={ - "error": _error_tracing.get_trace_error( - trace_include_sensitive_data=( - run_config.trace_include_sensitive_data - ), - error_message=str(e), - ) - }, - ), - ) + attach_generic_agent_error( + current_span, + e, + trace_include_sensitive_data=run_config.trace_include_sensitive_data, + ) raise except AgentsException as exc: streamed_result.is_complete = True @@ -1416,19 +1400,11 @@ async def _save_stream_items_without_count( ) raise except Exception as e: - if current_span and _should_attach_generic_agent_error(e): - _error_tracing.attach_error_to_span( - current_span, - SpanError( - message="Error in agent run", - data={ - "error": _error_tracing.get_trace_error( - trace_include_sensitive_data=run_config.trace_include_sensitive_data, - error_message=str(e), - ) - }, - ), - ) + attach_generic_agent_error( + current_span, + e, + trace_include_sensitive_data=run_config.trace_include_sensitive_data, + ) streamed_result.is_complete = True streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) raise diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index cc61bd2825..e256f90cc8 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any +from typing import Any, cast import pytest from inline_snapshot import snapshot @@ -13,11 +13,15 @@ InputGuardrail, InputGuardrailTripwireTriggered, MaxTurnsExceeded, + ModelBehaviorError, + RunConfig, RunContextWrapper, + RunHooks, Runner, TResponseInputItem, _debug, ) +from agents.run_internal.error_handlers import attach_generic_agent_error from .fake_model import FakeModel from .test_responses import ( @@ -27,7 +31,7 @@ get_handoff_tool_call, get_text_message, ) -from .testing_processor import fetch_normalized_spans +from .testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans, fetch_span_errors @pytest.mark.asyncio @@ -49,6 +53,7 @@ async def test_single_turn_model_error(): "children": [ { "type": "agent", + "error": {"message": "Error in agent run", "data": {"error": "test error"}}, "data": { "name": "test_agent", "handoffs": [], @@ -102,6 +107,7 @@ async def test_multi_turn_no_handoffs(): "children": [ { "type": "agent", + "error": {"message": "Error in agent run", "data": {"error": "test error"}}, "data": { "name": "test_agent", "handoffs": [], @@ -558,3 +564,212 @@ async def test_guardrail_error(): } ] ) + + +SENSITIVE_ERROR_MESSAGE = "sensitive-error-detail" + + +def test_run_sync_marks_agent_span_with_generic_error(): + model = FakeModel(tracing_enabled=True) + model.set_next_output(ValueError("test error")) + + with pytest.raises(ValueError, match="test error"): + Runner.run_sync(Agent(name="test_agent", model=model), input="first_test") + + assert fetch_span_errors("agent") == [ + {"message": "Error in agent run", "data": {"error": "test error"}} + ] + + +@pytest.mark.asyncio +async def test_run_agent_span_error_matches_streamed_path(): + """The non-streamed and streamed paths record the same agent span error.""" + non_streamed_model = FakeModel(tracing_enabled=True) + non_streamed_model.set_next_output(ValueError("test error")) + with pytest.raises(ValueError): + await Runner.run(Agent(name="test_agent", model=non_streamed_model), input="first_test") + non_streamed_errors = fetch_span_errors("agent") + + SPAN_PROCESSOR_TESTING.clear() + + streamed_model = FakeModel(tracing_enabled=True) + streamed_model.set_next_output(ValueError("test error")) + result = Runner.run_streamed(Agent(name="test_agent", model=streamed_model), input="first_test") + with pytest.raises(ValueError): + async for _ in result.stream_events(): + pass + + assert non_streamed_errors == fetch_span_errors("agent") + + +@pytest.mark.asyncio +async def test_run_agent_span_error_redacts_sensitive_data(): + model = FakeModel(tracing_enabled=False) + model.set_next_output(ValueError(SENSITIVE_ERROR_MESSAGE)) + + with pytest.raises(ValueError): + await Runner.run( + Agent(name="test_agent", model=model), + input="first_test", + run_config=RunConfig(trace_include_sensitive_data=False), + ) + + assert fetch_span_errors("agent") == [ + { + "message": "Error in agent run", + "data": {"error": "Error details are redacted."}, + } + ] + + +@pytest.mark.asyncio +async def test_run_does_not_mark_agent_span_for_model_behavior_error(): + """ModelBehaviorError is reported by the generation span, so the agent span stays clean.""" + model = FakeModel(tracing_enabled=True) + model.set_next_output(ModelBehaviorError("bad model output")) + + with pytest.raises(ModelBehaviorError): + await Runner.run(Agent(name="test_agent", model=model), input="first_test") + + assert fetch_span_errors("agent") == [] + + +class UnformattableError(Exception): + """An exception whose ``__str__`` raises, like an error with a broken custom formatter.""" + + def __init__(self) -> None: + super().__init__() + self.str_calls = 0 + + def __str__(self) -> str: + self.str_calls += 1 + raise RuntimeError("__str__ is broken") + + +class BaseExceptionUnformattableError(UnformattableError): + """An exception whose formatter raises outside the ``Exception`` hierarchy.""" + + def __str__(self) -> str: + self.str_calls += 1 + raise KeyboardInterrupt("__str__ is broken") + + +class RaisingHooks(RunHooks[Any]): + """Raises the given error from a run hook, i.e. from user code inside the agent span.""" + + def __init__(self, error: Exception) -> None: + self.error = error + + async def on_agent_start(self, context: RunContextWrapper[Any], agent: Agent[Any]) -> None: + raise self.error + + +@pytest.mark.asyncio +async def test_run_propagates_exception_whose_str_raises(): + """Tracing must not replace the run exception when formatting it fails.""" + error = UnformattableError() + + with pytest.raises(UnformattableError) as exc_info: + await Runner.run( + Agent(name="test_agent", model=FakeModel(tracing_enabled=True)), + input="first_test", + hooks=RaisingHooks(error), + ) + + assert exc_info.value is error + assert fetch_span_errors("agent") == [ + {"message": "Error in agent run", "data": {"error": "Error details are unavailable."}} + ] + + +@pytest.mark.asyncio +async def test_streamed_run_propagates_exception_whose_str_raises(): + """The streamed path shares the helper, so it keeps the same guarantee.""" + error = UnformattableError() + + result = Runner.run_streamed( + Agent(name="test_agent", model=FakeModel(tracing_enabled=True)), + input="first_test", + hooks=RaisingHooks(error), + ) + with pytest.raises(UnformattableError) as exc_info: + async for _ in result.stream_events(): + pass + + assert exc_info.value is error + assert fetch_span_errors("agent") == [ + {"message": "Error in agent run", "data": {"error": "Error details are unavailable."}} + ] + + +class RecordingSpan: + """The subset of the span API the generic agent-error helper uses.""" + + def __init__(self) -> None: + self.error: Any = None + + def set_error(self, error: Any) -> None: + self.error = error + + +class FailingRecordingSpan: + """A custom span that fails while the generic error is inspected or attached.""" + + def __init__(self, failure_point: str) -> None: + self.failure_point = failure_point + + @property + def error(self) -> Any: + if self.failure_point == "read": + raise RuntimeError("span error read failed") + return None + + def set_error(self, error: Any) -> None: + raise RuntimeError("span set_error failed") + + +@pytest.mark.parametrize("failure_point", ["read", "write"]) +def test_span_failure_cannot_replace_the_run_exception(failure_point: str): + """A custom span failure is contained so the original run exception is re-raised.""" + original_error = ValueError("original run error") + + with pytest.raises(ValueError) as exc_info: + try: + raise original_error + except ValueError as error: + attach_generic_agent_error( + cast(Any, FailingRecordingSpan(failure_point)), + error, + trace_include_sensitive_data=True, + ) + raise + + assert exc_info.value is original_error + + +def test_trace_formatting_failure_cannot_replace_the_run_exception(): + """Even a ``BaseException`` from ``__str__`` is contained at the trace-only boundary.""" + span = RecordingSpan() + error = BaseExceptionUnformattableError() + + attach_generic_agent_error(cast(Any, span), error, trace_include_sensitive_data=True) + + assert error.str_calls == 1 + assert span.error == { + "message": "Error in agent run", + "data": {"error": "Error details are unavailable."}, + } + + +def test_redacted_tracing_never_stringifies_the_exception(): + """With redaction on, the detail is fixed, so the exception is never formatted at all.""" + span = RecordingSpan() + error = UnformattableError() + + attach_generic_agent_error(cast(Any, span), error, trace_include_sensitive_data=False) + + assert error.str_calls == 0 + assert span.error == { + "message": "Error in agent run", + "data": {"error": "Error details are redacted."}, + } From bf00f45f32e3516880452240c8b5ade9444266fc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 10:48:15 +0900 Subject: [PATCH 156/473] test: make async tool and guardrail tests deterministic (#4192) Co-authored-by: Shaurya Singh --- tests/test_guardrails.py | 87 ++++++++++++++------------------ tests/test_run_step_execution.py | 59 +++++++++++++++++----- 2 files changed, 82 insertions(+), 64 deletions(-) diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index cd7f98fbed..bfb092ef3c 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -491,11 +491,15 @@ async def blocking_check( async def test_parallel_guardrail_may_not_prevent_tool_execution(): tool_was_executed = False guardrail_executed = False + loop = asyncio.get_running_loop() + tool_executed = asyncio.Event() @function_tool def fast_tool() -> str: nonlocal tool_was_executed tool_was_executed = True + # Sync tools run in a worker thread, so signal the loop-owned event safely. + loop.call_soon_threadsafe(tool_executed.set) return "tool_executed" @input_guardrail(run_in_parallel=True) @@ -503,7 +507,9 @@ async def slow_parallel_check( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: nonlocal guardrail_executed - await asyncio.sleep(LONG_DELAY) + # Trip only after the tool ran. If parallel guardrails gated the turn, this + # would deadlock instead of silently losing a wall-clock race. + await asyncio.wait_for(tool_executed.wait(), timeout=5) guardrail_executed = True return GuardrailFunctionOutput( output_info="slow_parallel_triggered", @@ -646,7 +652,8 @@ async def slow_parallel_check( ) -> GuardrailFunctionOutput: guardrail_started.set() try: - await asyncio.sleep(LONG_DELAY) + # Never finishes on its own, so only cancellation can end this guardrail. + await asyncio.Event().wait() guardrail_finished.set() return GuardrailFunctionOutput( output_info="parallel_ok", @@ -671,7 +678,7 @@ async def boom_get_response(*args, **kwargs): with patch.object(model, "get_response", side_effect=boom_get_response): with pytest.raises(RuntimeError, match="model boom"): - await Runner.run(agent, "trigger guardrail") + await asyncio.wait_for(Runner.run(agent, "trigger guardrail"), timeout=5) # By the time Runner.run returns, the guardrail task must already be # cancelled rather than left running to completion in the background. @@ -703,7 +710,8 @@ async def raising_parallel_check( async def slow_get_response(*args, **kwargs): model_started.set() try: - await asyncio.sleep(LONG_DELAY) + # Never finishes on its own, so only cancellation can end the model call. + await asyncio.Event().wait() return await original_get_response(*args, **kwargs) except asyncio.CancelledError: model_cancelled.set() @@ -720,7 +728,7 @@ async def slow_get_response(*args, **kwargs): with patch.object(model, "get_response", side_effect=slow_get_response): with pytest.raises(ValueError, match="guardrail boom"): - await Runner.run(agent, "trigger guardrail") + await asyncio.wait_for(Runner.run(agent, "trigger guardrail"), timeout=5) await asyncio.wait_for(model_finished.wait(), timeout=1) assert model_started.is_set() is True @@ -842,11 +850,15 @@ async def wait_until_guardrail_task_finishes() -> None: async def test_parallel_guardrail_may_not_prevent_tool_execution_streaming(): tool_was_executed = False guardrail_executed = False + loop = asyncio.get_running_loop() + tool_executed = asyncio.Event() @function_tool def fast_tool() -> str: nonlocal tool_was_executed tool_was_executed = True + # Sync tools run in a worker thread, so signal the loop-owned event safely. + loop.call_soon_threadsafe(tool_executed.set) return "tool_executed" @input_guardrail(run_in_parallel=True) @@ -854,7 +866,9 @@ async def slow_parallel_check( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: nonlocal guardrail_executed - await asyncio.sleep(LONG_DELAY) + # Trip only after the tool ran. If parallel guardrails gated the turn, this + # would deadlock instead of silently losing a wall-clock race. + await asyncio.wait_for(tool_executed.wait(), timeout=5) guardrail_executed = True return GuardrailFunctionOutput( output_info="slow_parallel_triggered_streaming", @@ -1713,17 +1727,16 @@ async def test_blocking_guardrail_cancels_remaining_on_trigger(): fast_guardrail_executed = False slow_guardrail_executed = False slow_guardrail_cancelled = False - timestamps = {} + slow_guardrail_started = asyncio.Event() @input_guardrail(run_in_parallel=False) async def fast_guardrail_that_triggers( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: nonlocal fast_guardrail_executed - timestamps["fast_start"] = time.time() - await asyncio.sleep(SHORT_DELAY) + # Trip only once the sibling is provably in flight and therefore cancellable. + await asyncio.wait_for(slow_guardrail_started.wait(), timeout=5) fast_guardrail_executed = True - timestamps["fast_end"] = time.time() return GuardrailFunctionOutput( output_info="fast_triggered", tripwire_triggered=True, @@ -1734,18 +1747,17 @@ async def slow_guardrail_that_should_be_cancelled( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: nonlocal slow_guardrail_executed, slow_guardrail_cancelled - timestamps["slow_start"] = time.time() + slow_guardrail_started.set() try: - await asyncio.sleep(MEDIUM_DELAY) + # Never finishes on its own, so only cancellation can end this guardrail. + await asyncio.Event().wait() slow_guardrail_executed = True - timestamps["slow_end"] = time.time() return GuardrailFunctionOutput( output_info="slow_completed", tripwire_triggered=False, ) except asyncio.CancelledError: slow_guardrail_cancelled = True - timestamps["slow_cancelled"] = time.time() raise model = FakeModel() @@ -1758,7 +1770,7 @@ async def slow_guardrail_that_should_be_cancelled( model.set_next_output([get_text_message("hello")]) with pytest.raises(InputGuardrailTripwireTriggered): - await Runner.run(agent, "test input") + await asyncio.wait_for(Runner.run(agent, "test input"), timeout=5) # Verify the fast guardrail executed assert fast_guardrail_executed is True, "Fast guardrail should have executed" @@ -1767,19 +1779,6 @@ async def slow_guardrail_that_should_be_cancelled( assert slow_guardrail_cancelled is True, "Slow guardrail should have been cancelled" assert slow_guardrail_executed is False, "Slow guardrail should NOT have completed execution" - # Verify timing: cancellation happened shortly after fast guardrail triggered - assert "fast_end" in timestamps - assert "slow_cancelled" in timestamps - cancellation_delay = timestamps["slow_cancelled"] - timestamps["fast_end"] - assert cancellation_delay >= 0, ( - f"Slow guardrail should be cancelled after fast one completes, " - f"but was {cancellation_delay:.2f}s" - ) - assert cancellation_delay < 0.2, ( - f"Cancellation should happen before the slow guardrail completes, " - f"but took {cancellation_delay:.2f}s" - ) - # Verify agent never started assert model.first_turn_args is None, ( "Model should not have been called when guardrail triggered" @@ -1795,17 +1794,16 @@ async def test_blocking_guardrail_cancels_remaining_on_trigger_streaming(): fast_guardrail_executed = False slow_guardrail_executed = False slow_guardrail_cancelled = False - timestamps = {} + slow_guardrail_started = asyncio.Event() @input_guardrail(run_in_parallel=False) async def fast_guardrail_that_triggers( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: nonlocal fast_guardrail_executed - timestamps["fast_start"] = time.time() - await asyncio.sleep(SHORT_DELAY) + # Trip only once the sibling is provably in flight and therefore cancellable. + await asyncio.wait_for(slow_guardrail_started.wait(), timeout=5) fast_guardrail_executed = True - timestamps["fast_end"] = time.time() return GuardrailFunctionOutput( output_info="fast_triggered", tripwire_triggered=True, @@ -1816,18 +1814,17 @@ async def slow_guardrail_that_should_be_cancelled( ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] ) -> GuardrailFunctionOutput: nonlocal slow_guardrail_executed, slow_guardrail_cancelled - timestamps["slow_start"] = time.time() + slow_guardrail_started.set() try: - await asyncio.sleep(MEDIUM_DELAY) + # Never finishes on its own, so only cancellation can end this guardrail. + await asyncio.Event().wait() slow_guardrail_executed = True - timestamps["slow_end"] = time.time() return GuardrailFunctionOutput( output_info="slow_completed", tripwire_triggered=False, ) except asyncio.CancelledError: slow_guardrail_cancelled = True - timestamps["slow_cancelled"] = time.time() raise model = FakeModel() @@ -1841,10 +1838,13 @@ async def slow_guardrail_that_should_be_cancelled( result = Runner.run_streamed(agent, "test input") - with pytest.raises(InputGuardrailTripwireTriggered): + async def consume_stream() -> None: async for _event in result.stream_events(): pass + with pytest.raises(InputGuardrailTripwireTriggered): + await asyncio.wait_for(consume_stream(), timeout=5) + # Verify the fast guardrail executed assert fast_guardrail_executed is True, "Fast guardrail should have executed" @@ -1852,19 +1852,6 @@ async def slow_guardrail_that_should_be_cancelled( assert slow_guardrail_cancelled is True, "Slow guardrail should have been cancelled" assert slow_guardrail_executed is False, "Slow guardrail should NOT have completed execution" - # Verify timing: cancellation happened shortly after fast guardrail triggered - assert "fast_end" in timestamps - assert "slow_cancelled" in timestamps - cancellation_delay = timestamps["slow_cancelled"] - timestamps["fast_end"] - assert cancellation_delay >= 0, ( - f"Slow guardrail should be cancelled after fast one completes, " - f"but was {cancellation_delay:.2f}s" - ) - assert cancellation_delay < 0.2, ( - f"Cancellation should happen before the slow guardrail completes, " - f"but took {cancellation_delay:.2f}s" - ) - # Verify agent never started assert model.first_turn_args is None, ( "Model should not have been called when guardrail triggered" diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 8a7d080942..b50405027b 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -58,7 +58,7 @@ trace, ) from agents._public_agent import set_public_agent -from agents.run_internal import run_loop, turn_resolution +from agents.run_internal import run_loop, tool_execution, turn_resolution from agents.run_internal.agent_bindings import bind_execution_agent, bind_public_agent from agents.run_internal.run_loop import ( NextStepFinalOutput, @@ -2095,15 +2095,23 @@ async def _release_guardrail_later() -> None: @pytest.mark.asyncio -async def test_multiple_tool_calls_surface_sleeping_post_invoke_failure_before_sibling_error(): +async def test_multiple_tool_calls_surface_sleeping_post_invoke_failure_before_sibling_error( + monkeypatch: pytest.MonkeyPatch, +): loop = asyncio.get_running_loop() original_handler = loop.get_exception_handler() unhandled_contexts: list[dict[str, Any]] = [] + post_invoke_started = asyncio.Event() + + # Widen the post-invoke drain budget so the guardrail delay below stays well inside + # it. Otherwise a scheduling hiccup, not the runtime, decides which failure wins. + monkeypatch.setattr(tool_execution, "_FUNCTION_TOOL_POST_INVOKE_WAIT_SECONDS", 5.0) @tool_output_guardrail async def sleeping_tripwire_guardrail( _data: ToolOutputGuardrailData, ) -> ToolGuardrailFunctionOutput: + post_invoke_started.set() await asyncio.sleep(0.05) return ToolGuardrailFunctionOutput.raise_exception(output_info={"status": "sleep-tripwire"}) @@ -2111,6 +2119,8 @@ async def _ok_tool() -> str: return "ok" async def _error_tool() -> str: + # Fail only once the sibling guardrail is provably in its post-invoke phase. + await post_invoke_started.wait() raise ValueError("boom") ok_tool = function_tool( @@ -2141,7 +2151,7 @@ def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any] loop.set_exception_handler(_exception_handler) try: with pytest.raises(ToolOutputGuardrailTripwireTriggered): - await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2) + await asyncio.wait_for(get_execute_result(agent, response), timeout=10) gc.collect() await asyncio.sleep(0) finally: @@ -2156,13 +2166,18 @@ def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any] @pytest.mark.asyncio async def test_multiple_tool_calls_do_not_wait_indefinitely_for_sleeping_post_invoke_sibling(): + post_invoke_started = asyncio.Event() + release_guardrail = asyncio.Event() guardrail_finished = asyncio.Event() @tool_output_guardrail - async def long_sleeping_guardrail( + async def blocked_post_invoke_guardrail( _data: ToolOutputGuardrailData, ) -> ToolGuardrailFunctionOutput: - await asyncio.sleep(0.3) + post_invoke_started.set() + # Outlast the post-invoke drain budget by construction: only the test can + # release this guardrail, and it does so after the sibling error propagated. + await release_guardrail.wait() guardrail_finished.set() return ToolGuardrailFunctionOutput.allow(output_info="done") @@ -2170,13 +2185,15 @@ async def _ok_tool() -> str: return "ok" async def _error_tool() -> str: + # Fail only once the sibling guardrail is provably in its post-invoke phase. + await post_invoke_started.wait() raise ValueError("boom") ok_tool = function_tool( _ok_tool, name_override="ok_tool", failure_error_function=None, - tool_output_guardrails=[long_sleeping_guardrail], + tool_output_guardrails=[blocked_post_invoke_guardrail], ) error_tool = function_tool( _error_tool, @@ -2194,10 +2211,17 @@ async def _error_tool() -> str: response_id=None, ) - with pytest.raises(UserError, match="Error running tool error_tool: boom"): - await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2) + try: + with pytest.raises(UserError, match="Error running tool error_tool: boom"): + await asyncio.wait_for(get_execute_result(agent, response), timeout=5) + finally: + # Release the detached guardrail even when the assertion fails so the test + # cannot leave a blocked task behind. + release_guardrail.set() - await asyncio.wait_for(guardrail_finished.wait(), timeout=0.5) + # The post-invoke sibling was still pending when the failure surfaced, and it + # must remain able to finish in the background. + await asyncio.wait_for(guardrail_finished.wait(), timeout=5) @pytest.mark.asyncio @@ -2362,6 +2386,7 @@ async def _error_tool_2() -> str: @pytest.mark.asyncio @pytest.mark.parametrize("delay_ticks", [1, 6, 20]) async def test_multiple_tool_calls_raise_late_fatal_sibling_exception_after_cancellation( + monkeypatch: pytest.MonkeyPatch, delay_ticks: int, ): class ToolAborted(BaseException): @@ -2370,6 +2395,10 @@ class ToolAborted(BaseException): sibling_ready = asyncio.Event() sibling_cancelled = asyncio.Event() + # Keep the cancelled-sibling drain open long enough for every parametrized + # scheduling step. This test covers failure arbitration, not the default budget. + monkeypatch.setattr(tool_execution, "_FUNCTION_TOOL_CANCELLED_DRAIN_SECONDS", 5.0) + async def _error_tool_1() -> str: await sibling_ready.wait() raise ValueError("boom-1") @@ -2407,7 +2436,7 @@ async def _error_tool_2() -> str: ) with pytest.raises(ToolAborted, match=f"boom-{delay_ticks}"): - await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2) + await asyncio.wait_for(get_execute_result(agent, response), timeout=5) assert sibling_cancelled.is_set() @@ -2516,11 +2545,13 @@ async def _cleanup_tool() -> str: loop.set_exception_handler(_exception_handler) try: - with pytest.raises(UserError, match="Error running tool error_tool: boom"): - await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2) + try: + with pytest.raises(UserError, match="Error running tool error_tool: boom"): + await asyncio.wait_for(get_execute_result(agent, response), timeout=5) - assert cleanup_blocked.is_set() - release_cleanup.set() + assert cleanup_blocked.is_set() + finally: + release_cleanup.set() await asyncio.wait_for(cleanup_finished.wait(), timeout=0.2) await asyncio.wait_for(late_cleanup_reported.wait(), timeout=0.5) finally: From 7379e75d4ea93345d7c6ac989c803753da2f39f1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 11:13:37 +0900 Subject: [PATCH 157/473] fix(memory): reject reused branch IDs when creating a branch (#4186) Co-authored-by: Henry Su --- docs/sessions/advanced_sqlite_session.md | 16 +- .../memory/advanced_sqlite_session.py | 396 +++++++++------ .../memory/test_advanced_sqlite_session.py | 471 ++++++++++++++++++ 3 files changed, 734 insertions(+), 149 deletions(-) diff --git a/docs/sessions/advanced_sqlite_session.md b/docs/sessions/advanced_sqlite_session.md index 2e50ea9fc0..b10082b960 100644 --- a/docs/sessions/advanced_sqlite_session.md +++ b/docs/sessions/advanced_sqlite_session.md @@ -161,6 +161,8 @@ branch_id = await session.create_branch_from_content( ) ``` +Branch IDs are unique for the lifetime of a session ID. Deleting a branch or clearing the session removes its conversation data but does not make previously used branch IDs available again; use a new name when creating another branch. + ### Branch management ```python @@ -251,7 +253,7 @@ The session automatically tracks message structure including: ## Database schema -AdvancedSQLiteSession extends the basic SQLite schema with two additional tables: +AdvancedSQLiteSession extends the basic SQLite schema with three additional tables: ### message_structure table @@ -272,6 +274,18 @@ CREATE TABLE message_structure ( ); ``` +### branch_reservations table + +```sql +CREATE TABLE branch_reservations ( + session_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + PRIMARY KEY (session_id, branch_id) +); +``` + +This table atomically reserves branch IDs, including branches whose copied prefix is empty. Reservation rows are retained after branch deletion and session clearing so stale session instances cannot merge history into a later branch that reused the same ID. + ### turn_usage table ```sql diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index cbf8f510f1..89ca91938b 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -4,6 +4,7 @@ import json import logging import sqlite3 +import time from contextlib import closing from pathlib import Path from typing import Any, cast @@ -93,8 +94,8 @@ def _commit_branch_pointer(self, branch_id: str, generation: int) -> bool: def _init_structure_tables(self): """Add structure and usage tracking tables. - Creates the message_structure and turn_usage tables with appropriate - indexes for conversation branching and usage analytics. + Creates the message_structure, branch_reservations, and turn_usage tables + with appropriate indexes for conversation branching and usage analytics. """ with self._locked_connection() as conn: # Message structure with branch support @@ -137,6 +138,8 @@ def _init_structure_tables(self): ) """) + self._ensure_branch_reservations_table(conn) + # Indexes conn.execute(""" CREATE INDEX IF NOT EXISTS idx_structure_session_seq @@ -321,6 +324,11 @@ def _pop_item_sync(): message_row = cursor.fetchone() try: + # Preserve every legacy branch ID before a pop can remove its + # final message_structure row. This stays inside the existing + # rollback boundary for the mutation. + self._ensure_branch_reservations_table(conn) + # Remove the structure row for this branch, then drop # the underlying message only if no other branch # references it. @@ -381,11 +389,17 @@ async def clear_session(self) -> None: rows declare an `ON DELETE CASCADE` foreign key, but SQLite does not enforce foreign keys unless `PRAGMA foreign_keys=ON` is set, so they must be deleted explicitly to avoid leaking stale structure and usage data. + + Previously used branch IDs remain reserved so a stale session instance + cannot write into a later branch that reused the same ID. """ def _clear_session_sync(): with self._locked_connection() as conn: try: + # Backfill legacy branch IDs before clearing their only durable + # identity evidence. + self._ensure_branch_reservations_table(conn) conn.execute( f"DELETE FROM {self.messages_table} WHERE session_id = ?", (self.session_id,), @@ -812,65 +826,31 @@ async def create_branch_from_turn( Args: turn_number: The branch turn number of the user message to branch from - branch_name: Optional name for the branch (auto-generated if None) + branch_name: Optional name for the branch. Must not use a previously used branch ID. + Auto-generated if None. Returns: The branch_id of the newly created branch Raises: - ValueError: If turn doesn't exist or doesn't contain a user message + ValueError: If turn doesn't exist, doesn't contain a user message, or + `branch_name` has already been used in this session """ - import time - - # Capture the generation before any DB work so a clear that commits - # while this branch is being created cannot be overwritten by the - # pointer update below. - generation = self._generation - - # Validate the turn exists and contains a user message - def _validate_turn(): - """Synchronous helper to validate turn exists and contains user message.""" - with self._locked_connection() as conn: - with closing(conn.cursor()) as cursor: - cursor.execute( - f""" - SELECT am.message_data - FROM message_structure ms - JOIN {self.messages_table} am ON ms.message_id = am.id - WHERE ms.session_id = ? AND ms.branch_id = ? - AND ms.branch_turn_number = ? AND ms.message_type = 'user' - """, - (self.session_id, self._current_branch_id, turn_number), - ) - - result = cursor.fetchone() - if not result: - raise ValueError( - f"Turn {turn_number} does not contain a user message " - f"in branch '{self._current_branch_id}'" - ) - - message_data = result[0] - try: - content = json.loads(message_data).get("content", "") - return content[:50] + "..." if len(content) > 50 else content - except Exception: - return "Unable to parse content" - - turn_content = await asyncio.to_thread(_validate_turn) - - # Generate branch name if not provided - if branch_name is None: - timestamp = int(time.time()) - branch_name = f"branch_from_turn_{turn_number}_{timestamp}" + # Snapshot the source branch and clear generation together. The source turn is + # revalidated inside the reservation transaction below. + with self._lock: + generation = self._generation + source_branch_id = self._current_branch_id - # Copy messages before the branch point to the new branch - await self._copy_messages_to_new_branch(branch_name, turn_number) + # Resolve the target branch ID under the same transaction that performs the copy + # so concurrent creators cannot reserve the same branch. + branch_name, turn_content = await self._copy_messages_to_new_branch( + branch_name, turn_number, source_branch_id + ) # Switch to new branch under the lock; skipped if a clear_session has # committed since `generation` was captured (its reset to 'main' wins), # so we never point at a branch that clear removed. - old_branch = self._current_branch_id await asyncio.to_thread(self._commit_branch_pointer, branch_name, generation) if _debug.DONT_LOG_MODEL_DATA: @@ -878,7 +858,7 @@ def _validate_turn(): "Created branch '%s' from turn %s in '%s'", branch_name, turn_number, - old_branch, + source_branch_id, ) else: self._logger.debug( @@ -886,7 +866,7 @@ def _validate_turn(): branch_name, turn_number, turn_content, - old_branch, + source_branch_id, ) return branch_name @@ -897,13 +877,15 @@ async def create_branch_from_content( Args: search_term: Text to search for in user messages. - branch_name: Optional name for the branch (auto-generated if None). + branch_name: Optional name for the branch. Must not use a previously used branch ID. + Auto-generated if None. Returns: The branch_id of the newly created branch. Raises: - ValueError: If no matching turns are found. + ValueError: If no matching turns are found or `branch_name` has already been used + in this session. """ matching_turns = await self.find_turns_by_content(search_term) if not matching_turns: @@ -956,6 +938,8 @@ def _validate_branch(): async def delete_branch(self, branch_id: str, force: bool = False) -> None: """Delete a branch and all its associated data. + The branch ID remains reserved and cannot be reused in this session. + Args: branch_id: The branch to delete. force: If True, allows deleting the current branch (will switch to 'main'). @@ -985,47 +969,53 @@ async def delete_branch(self, branch_id: str, force: bool = False) -> None: def _delete_sync(): """Synchronous helper to delete branch and associated data.""" with self._locked_connection() as conn: - with closing(conn.cursor()) as cursor: - # First verify the branch exists - cursor.execute( - """ - SELECT COUNT(*) FROM message_structure - WHERE session_id = ? AND branch_id = ? - """, - (self.session_id, branch_id), - ) + try: + # Backfill legacy branch IDs before deleting their message structure. + self._ensure_branch_reservations_table(conn) + with closing(conn.cursor()) as cursor: + # First verify the branch exists + cursor.execute( + """ + SELECT COUNT(*) FROM message_structure + WHERE session_id = ? AND branch_id = ? + """, + (self.session_id, branch_id), + ) - count = cursor.fetchone()[0] - if count == 0: - raise ValueError(f"Branch '{branch_id}' does not exist") + count = cursor.fetchone()[0] + if count == 0: + raise ValueError(f"Branch '{branch_id}' does not exist") - # Delete from turn_usage first (foreign key constraint) - cursor.execute( - """ - DELETE FROM turn_usage - WHERE session_id = ? AND branch_id = ? - """, - (self.session_id, branch_id), - ) + # Delete from turn_usage first (foreign key constraint) + cursor.execute( + """ + DELETE FROM turn_usage + WHERE session_id = ? AND branch_id = ? + """, + (self.session_id, branch_id), + ) - usage_deleted = cursor.rowcount + usage_deleted = cursor.rowcount - # Delete from message_structure - cursor.execute( - """ - DELETE FROM message_structure - WHERE session_id = ? AND branch_id = ? - """, - (self.session_id, branch_id), - ) + # Delete from message_structure + cursor.execute( + """ + DELETE FROM message_structure + WHERE session_id = ? AND branch_id = ? + """, + (self.session_id, branch_id), + ) - structure_deleted = cursor.rowcount + structure_deleted = cursor.rowcount - orphaned_messages_deleted = self._cleanup_orphaned_messages_sync(conn) + orphaned_messages_deleted = self._cleanup_orphaned_messages_sync(conn) - conn.commit() + conn.commit() - return usage_deleted, structure_deleted, orphaned_messages_deleted + return usage_deleted, structure_deleted, orphaned_messages_deleted + except Exception: + conn.rollback() + raise usage_deleted, structure_deleted, orphaned_messages_deleted = await asyncio.to_thread( _delete_sync @@ -1087,87 +1077,197 @@ def _list_branches_sync(): return await asyncio.to_thread(_list_branches_sync) - async def _copy_messages_to_new_branch(self, new_branch_id: str, from_turn_number: int) -> None: + def _ensure_branch_reservations_table(self, conn: sqlite3.Connection) -> None: + """Create the reservation table and backfill populated branches for this session.""" + conn.execute(""" + CREATE TABLE IF NOT EXISTS branch_reservations ( + session_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + PRIMARY KEY (session_id, branch_id) + ) + """) + missing_branch = conn.execute( + """ + SELECT 1 + FROM message_structure ms + WHERE ms.session_id = ? + AND NOT EXISTS ( + SELECT 1 FROM branch_reservations br + WHERE br.session_id = ms.session_id AND br.branch_id = ms.branch_id + ) + LIMIT 1 + """, + (self.session_id,), + ).fetchone() + if missing_branch is not None: + conn.execute( + """ + INSERT OR IGNORE INTO branch_reservations (session_id, branch_id) + SELECT DISTINCT session_id, branch_id + FROM message_structure + WHERE session_id = ? + """, + (self.session_id,), + ) + + def _reserve_branch_id( + self, cursor: sqlite3.Cursor, new_branch_id: str | None, from_turn_number: int + ) -> str: + """Reserve and return a new branch ID for this session.""" + if new_branch_id is not None: + cursor.execute( + """ + INSERT OR IGNORE INTO branch_reservations (session_id, branch_id) + VALUES (?, ?) + """, + (self.session_id, new_branch_id), + ) + if cursor.rowcount == 0: + raise ValueError( + f"Branch ID '{new_branch_id}' has already been used. Choose a new branch ID." + ) + return new_branch_id + + base_branch_id = f"branch_from_turn_{from_turn_number}_{int(time.time())}" + branch_id = base_branch_id + suffix = 1 + while True: + cursor.execute( + """ + INSERT OR IGNORE INTO branch_reservations (session_id, branch_id) + VALUES (?, ?) + """, + (self.session_id, branch_id), + ) + if cursor.rowcount == 1: + return branch_id + suffix += 1 + branch_id = f"{base_branch_id}_{suffix}" + + async def _copy_messages_to_new_branch( + self, new_branch_id: str | None, from_turn_number: int, source_branch_id: str + ) -> tuple[str, Any]: """Copy messages before the branch point to the new branch. Args: - new_branch_id: The ID of the new branch to copy messages to. + new_branch_id: The ID of the new branch, or None to generate an unused ID. from_turn_number: The turn number to copy messages up to (exclusive). + source_branch_id: The branch to copy messages from. + + Returns: + The resolved branch ID and a preview of the source turn content. + + Raises: + ValueError: If `new_branch_id` has already been used in this session. """ - def _copy_sync(): + def _copy_sync() -> tuple[str, Any]: """Synchronous helper to copy messages to new branch.""" with self._locked_connection() as conn: - with closing(conn.cursor()) as cursor: - # Get all messages before the branch point - cursor.execute( - """ - SELECT - ms.message_id, - ms.message_type, - ms.sequence_number, - ms.user_turn_number, - ms.branch_turn_number, - ms.tool_name - FROM message_structure ms - WHERE ms.session_id = ? AND ms.branch_id = ? - AND ms.branch_turn_number < ? - ORDER BY ms.sequence_number - """, - (self.session_id, self._current_branch_id, from_turn_number), - ) + try: + # Acquire SQLite's write reservation before checking the branch ID so + # sessions in other processes cannot pass the same check concurrently. + conn.execute("BEGIN IMMEDIATE") + self._ensure_branch_reservations_table(conn) + with closing(conn.cursor()) as cursor: + cursor.execute( + f""" + SELECT am.message_data + FROM message_structure ms + JOIN {self.messages_table} am ON ms.message_id = am.id + WHERE ms.session_id = ? AND ms.branch_id = ? + AND ms.branch_turn_number = ? AND ms.message_type = 'user' + """, + (self.session_id, source_branch_id, from_turn_number), + ) + result = cursor.fetchone() + if result is None: + raise ValueError( + f"Turn {from_turn_number} does not contain a user message " + f"in branch '{source_branch_id}'" + ) - messages_to_copy = cursor.fetchall() + try: + content = json.loads(result[0]).get("content", "") + turn_content = content[:50] + "..." if len(content) > 50 else content + except Exception: + turn_content = "Unable to parse content" - if messages_to_copy: - # Get the max sequence number for the new inserts + branch_id = self._reserve_branch_id(cursor, new_branch_id, from_turn_number) + + # Get all messages before the branch point cursor.execute( """ - SELECT COALESCE(MAX(sequence_number), 0) - FROM message_structure - WHERE session_id = ? + SELECT + ms.message_id, + ms.message_type, + ms.sequence_number, + ms.user_turn_number, + ms.branch_turn_number, + ms.tool_name + FROM message_structure ms + WHERE ms.session_id = ? AND ms.branch_id = ? + AND ms.branch_turn_number < ? + ORDER BY ms.sequence_number """, - (self.session_id,), + (self.session_id, source_branch_id, from_turn_number), ) - seq_start = cursor.fetchone()[0] - - # Insert copied messages with new branch_id - new_structure_data = [] - for i, ( - msg_id, - msg_type, - _, - user_turn, - branch_turn, - tool_name, - ) in enumerate(messages_to_copy): - new_structure_data.append( - ( - self.session_id, - msg_id, # Same message_id (sharing the actual message data) - new_branch_id, - msg_type, - seq_start + i + 1, # New sequence number - user_turn, # Keep same global turn number - branch_turn, # Keep same branch turn number - tool_name, - ) + messages_to_copy = cursor.fetchall() + + if messages_to_copy: + # Get the max sequence number for the new inserts + cursor.execute( + """ + SELECT COALESCE(MAX(sequence_number), 0) + FROM message_structure + WHERE session_id = ? + """, + (self.session_id,), ) - cursor.executemany( - """ - INSERT INTO message_structure - (session_id, message_id, branch_id, message_type, sequence_number, - user_turn_number, branch_turn_number, tool_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - new_structure_data, - ) + seq_start = cursor.fetchone()[0] + + # Insert copied messages with new branch_id + new_structure_data = [] + for i, ( + msg_id, + msg_type, + _, + user_turn, + branch_turn, + tool_name, + ) in enumerate(messages_to_copy): + new_structure_data.append( + ( + self.session_id, + msg_id, # Same message_id (sharing the actual message data) + branch_id, + msg_type, + seq_start + i + 1, # New sequence number + user_turn, # Keep same global turn number + branch_turn, # Keep same branch turn number + tool_name, + ) + ) + + cursor.executemany( + """ + INSERT INTO message_structure + (session_id, message_id, branch_id, message_type, sequence_number, + user_turn_number, branch_turn_number, tool_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + new_structure_data, + ) conn.commit() + return branch_id, turn_content + except Exception: + conn.rollback() + raise - await asyncio.to_thread(_copy_sync) + return await asyncio.to_thread(_copy_sync) async def get_conversation_turns(self, branch_id: str | None = None) -> list[dict[str, Any]]: """Get user turns with content for easy browsing and branching decisions. diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index ae1606f249..2f26e0a2ef 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -4,8 +4,11 @@ import contextlib import json import logging +import multiprocessing import tempfile import threading +import time +from collections.abc import Iterator from pathlib import Path from typing import Any, cast from unittest.mock import Mock, patch @@ -650,6 +653,437 @@ async def test_branching_functionality(agent: Agent): session.close() +def _branch_collision_items() -> list[TResponseInputItem]: + """Return three user turns with assistant replies for branch collision tests.""" + return [ + {"role": "user", "content": "Turn one question"}, + {"role": "assistant", "content": "Turn one answer"}, + {"role": "user", "content": "Turn two question"}, + {"role": "assistant", "content": "Turn two answer"}, + {"role": "user", "content": "Turn three question"}, + {"role": "assistant", "content": "Turn three answer"}, + ] + + +def _create_branch_in_process( + worker_name: str, + db_path: str, + session_id: str, + branch_name: str | None, + turn_number: int, + ready: Any, + start: Any, + attempted: Any, + checked: Any, + release_check: Any, + hold_after_check: bool, + results: Any, +) -> None: + """Create a branch in a separate process with a controllable ID check.""" + + class InstrumentedSession(AdvancedSQLiteSession): + def __init__(self, **kwargs: Any) -> None: + self._reported_reservation = False + super().__init__(**kwargs) + + @contextlib.contextmanager + def _locked_connection(self) -> Iterator[Any]: + class BeginObservableConnection: + def __init__(self, connection: Any) -> None: + self._connection = connection + + def execute(self, sql: str, parameters: Any = ()) -> Any: + if sql == "BEGIN IMMEDIATE": + attempted.set() + return self._connection.execute(sql, parameters) + + def __getattr__(self, name: str) -> Any: + return getattr(self._connection, name) + + with super()._locked_connection() as connection: + yield BeginObservableConnection(connection) + + def _reserve_branch_id( + self, cursor: Any, new_branch_id: str | None, from_turn_number: int + ) -> str: + branch_id = super()._reserve_branch_id(cursor, new_branch_id, from_turn_number) + if not self._reported_reservation: + self._reported_reservation = True + checked.set() + if hold_after_check and not release_check.wait(timeout=10): + raise TimeoutError("Timed out waiting to release the branch ID reservation") + return branch_id + + session = InstrumentedSession(session_id=session_id, db_path=db_path) + try: + ready.set() + if not start.wait(timeout=10): + raise TimeoutError("Timed out waiting to start branch creation") + with patch( + "agents.extensions.memory.advanced_sqlite_session.time.time", + return_value=1_700_000_000.0, + ): + branch_id = asyncio.run(session.create_branch_from_turn(turn_number, branch_name)) + branch_item: TResponseInputItem = { + "role": "user", + "content": f"{worker_name} branch item", + } + asyncio.run(session.add_items([branch_item])) + results.put((worker_name, "success", branch_id)) + except Exception as exc: + results.put((worker_name, "error", type(exc).__name__, str(exc))) + finally: + session.close() + + +@pytest.mark.parametrize("branch_id", ["main", "existing_branch"]) +async def test_create_branch_rejects_populated_branch_id(branch_id: str): + """Creating a branch must not append history to a populated branch.""" + session = AdvancedSQLiteSession( + session_id=f"branch_collision_{branch_id}", + create_tables=True, + ) + items = _branch_collision_items() + + try: + await session.add_items(items) + if branch_id != "main": + await session.create_branch_from_turn(3, branch_id) + await session.switch_to_branch("main") + + branch_items_before = await session.get_items(branch_id=branch_id) + + with pytest.raises(ValueError, match="already been used"): + await session.create_branch_from_turn(2, branch_id) + + assert session._current_branch_id == "main" + assert await session.get_items(branch_id=branch_id) == branch_items_before + assert await session.get_items(branch_id="main") == items + finally: + session.close() + + +async def test_generated_branch_ids_do_not_merge_within_the_same_second(monkeypatch): + """Repeated generated IDs must not merge copied branch histories.""" + monkeypatch.setattr(time, "time", lambda: 1_700_000_000.0) + session = AdvancedSQLiteSession( + session_id="generated_branch_collision", + create_tables=True, + ) + items = _branch_collision_items() + + try: + await session.add_items(items) + first_branch = await session.create_branch_from_turn(3) + await session.switch_to_branch("main") + second_branch = await session.create_branch_from_turn(3) + + assert first_branch == "branch_from_turn_3_1700000000" + assert second_branch == "branch_from_turn_3_1700000000_2" + assert await session.get_items(branch_id=first_branch) == items[:4] + assert await session.get_items(branch_id=second_branch) == items[:4] + assert await session.get_items(branch_id="main") == items + finally: + session.close() + + +async def test_failed_branch_reservation_rolls_back_and_allows_retry(tmp_path: Path): + """A failure after reservation must not burn the branch ID or retain a transaction.""" + + class FailAfterReservationSession(AdvancedSQLiteSession): + fail_after_reservation = True + + def _reserve_branch_id( + self, cursor: Any, new_branch_id: str | None, from_turn_number: int + ) -> str: + branch_id = super()._reserve_branch_id(cursor, new_branch_id, from_turn_number) + if self.fail_after_reservation: + self.fail_after_reservation = False + raise RuntimeError("failed after reservation") + return branch_id + + session = FailAfterReservationSession( + session_id="failed_branch_reservation", + db_path=tmp_path / "failed_branch_reservation.db", + create_tables=True, + ) + items = _branch_collision_items() + + try: + await session.add_items(items) + + with pytest.raises(RuntimeError, match="failed after reservation"): + await session.create_branch_from_turn(3, "retryable_branch") + + with session._locked_connection() as conn: + reservation_count = conn.execute( + """ + SELECT COUNT(*) FROM branch_reservations + WHERE session_id = ? AND branch_id = ? + """, + (session.session_id, "retryable_branch"), + ).fetchone()[0] + assert reservation_count == 0 + assert await session.get_items(branch_id="retryable_branch") == [] + with session._connections_lock: + assert all(not conn.in_transaction for conn in session._connections) + + assert await session.create_branch_from_turn(3, "retryable_branch") == "retryable_branch" + assert await session.get_items(branch_id="retryable_branch") == items[:4] + finally: + session.close() + + +async def test_branch_ids_remain_reserved_after_delete_and_clear(): + """Deleted and cleared branch IDs must not be reused by stale session instances.""" + session = AdvancedSQLiteSession( + session_id="branch_reservation_tombstones", + create_tables=True, + ) + items = _branch_collision_items() + + try: + await session.add_items(items) + await session.create_branch_from_turn(3, "used_branch") + await session.switch_to_branch("main") + await session.delete_branch("used_branch") + + with pytest.raises(ValueError, match="already been used"): + await session.create_branch_from_turn(2, "used_branch") + + await session.clear_session() + await session.add_items(items) + with pytest.raises(ValueError, match="already been used"): + await session.create_branch_from_turn(1, "used_branch") + finally: + session.close() + + +async def test_branch_reservations_migrate_existing_populated_branches(tmp_path: Path): + """Existing databases must backfill branch reservations before allocating IDs.""" + db_path = tmp_path / "branch_reservation_migration.db" + session_id = "branch_reservation_migration" + items = _branch_collision_items() + setup_session = AdvancedSQLiteSession( + session_id=session_id, + db_path=db_path, + create_tables=True, + ) + try: + await setup_session.add_items(items) + await setup_session.create_branch_from_turn(3, "existing_branch") + with setup_session._locked_connection() as conn: + conn.execute("DROP TABLE branch_reservations") + conn.commit() + finally: + setup_session.close() + + session = AdvancedSQLiteSession(session_id=session_id, db_path=db_path) + try: + with pytest.raises(ValueError, match="already been used"): + await session.create_branch_from_turn(2, "existing_branch") + await session.create_branch_from_turn(1, "empty_branch") + + with session._locked_connection() as conn: + reservations = conn.execute( + """ + SELECT branch_id FROM branch_reservations + WHERE session_id = ? + ORDER BY branch_id + """, + (session_id,), + ).fetchall() + assert reservations == [("empty_branch",), ("existing_branch",), ("main",)] + finally: + session.close() + + +@pytest.mark.parametrize("operation", ["clear", "delete", "pop"]) +async def test_legacy_branch_ids_are_backfilled_before_destructive_operations( + tmp_path: Path, operation: str +): + """Destructive operations must preserve IDs from databases created before reservations.""" + db_path = tmp_path / f"legacy_branch_{operation}.db" + session_id = f"legacy_branch_{operation}" + items = _branch_collision_items() + setup_session = AdvancedSQLiteSession( + session_id=session_id, + db_path=db_path, + create_tables=True, + ) + try: + await setup_session.add_items(items) + await setup_session.create_branch_from_turn(3, "legacy_branch") + with setup_session._locked_connection() as conn: + conn.execute("DROP TABLE branch_reservations") + conn.commit() + finally: + setup_session.close() + + session = AdvancedSQLiteSession(session_id=session_id, db_path=db_path) + try: + if operation == "clear": + await session.clear_session() + await session.add_items(items) + elif operation == "delete": + await session.delete_branch("legacy_branch") + else: + await session.switch_to_branch("legacy_branch") + while await session.pop_item() is not None: + pass + await session.switch_to_branch("main") + + with pytest.raises(ValueError, match="already been used"): + await session.create_branch_from_turn(2, "legacy_branch") + finally: + session.close() + + +@pytest.mark.parametrize("operation", ["missing_delete", "empty_pop"]) +async def test_legacy_destructive_noops_leave_database_unlocked(tmp_path: Path, operation: str): + """Lazy migration must not retain a writer transaction after a no-op or error.""" + db_path = tmp_path / f"legacy_noop_{operation}.db" + session_id = f"legacy_noop_{operation}" + setup_session = AdvancedSQLiteSession( + session_id=session_id, + db_path=db_path, + create_tables=True, + ) + try: + await setup_session.add_items(_branch_collision_items()) + await setup_session.create_branch_from_turn(3, "legacy_branch") + if operation == "empty_pop": + await setup_session.switch_to_branch("main") + while await setup_session.pop_item() is not None: + pass + with setup_session._locked_connection() as conn: + conn.execute("DROP TABLE branch_reservations") + conn.commit() + finally: + setup_session.close() + + session = AdvancedSQLiteSession(session_id=session_id, db_path=db_path) + contender = AdvancedSQLiteSession(session_id=f"{session_id}_contender", db_path=db_path) + try: + if operation == "missing_delete": + with pytest.raises(ValueError, match="does not exist"): + await session.delete_branch("missing_branch") + else: + assert await session.pop_item() is None + + with session._connections_lock: + assert all(not conn.in_transaction for conn in session._connections) + + await contender.add_items([{"role": "user", "content": "writer acquired"}]) + finally: + contender.close() + session.close() + + +@pytest.mark.parametrize("branch_name", [None, "shared_branch"]) +@pytest.mark.parametrize("turn_number", [1, 3]) +async def test_branch_allocation_is_serialized_across_processes( + tmp_path: Path, branch_name: str | None, turn_number: int +): + """Processes must serialize branch reservations, including empty branches.""" + db_path = tmp_path / "branch_allocation.db" + session_id = f"branch_allocation_{branch_name}_{turn_number}" + setup_session = AdvancedSQLiteSession( + session_id=session_id, + db_path=db_path, + create_tables=True, + ) + items = _branch_collision_items() + await setup_session.add_items(items) + setup_session.close() + + context = multiprocessing.get_context("spawn") + results = context.Queue() + release_check = context.Event() + processes = [] + + try: + worker_events = [] + for worker_name, hold_after_check in (("first", True), ("second", False)): + ready = context.Event() + start = context.Event() + attempted = context.Event() + checked = context.Event() + process = context.Process( + target=_create_branch_in_process, + args=( + worker_name, + str(db_path), + session_id, + branch_name, + turn_number, + ready, + start, + attempted, + checked, + release_check, + hold_after_check, + results, + ), + ) + process.start() + processes.append(process) + worker_events.append((ready, start, attempted, checked)) + + first_ready, first_start, _, first_checked = worker_events[0] + second_ready, second_start, second_attempted, second_checked = worker_events[1] + assert first_ready.wait(timeout=10) + first_start.set() + assert first_checked.wait(timeout=10) + assert second_ready.wait(timeout=10) + second_start.set() + assert second_attempted.wait(timeout=10) + + # The second process has entered branch creation, but SQLite's write transaction + # must keep it from reserving an ID until the first process commits. + assert not second_checked.wait(timeout=0.2) + release_check.set() + + for process in processes: + process.join(timeout=10) + assert process.exitcode == 0 + + process_results = [results.get(timeout=5), results.get(timeout=5)] + successful_ids = [result[2] for result in process_results if result[1] == "success"] + if branch_name is None: + assert all(result[1] == "success" for result in process_results) + assert sorted(successful_ids) == [ + f"branch_from_turn_{turn_number}_1700000000", + f"branch_from_turn_{turn_number}_1700000000_2", + ] + else: + assert successful_ids == [branch_name] + errors = [result for result in process_results if result[1] == "error"] + assert len(errors) == 1 + assert errors[0][2] == "ValueError" + assert "already been used" in errors[0][3] + + verification_session = AdvancedSQLiteSession(session_id=session_id, db_path=db_path) + copied_items = items[: 2 * (turn_number - 1)] + successful_results = [result for result in process_results if result[1] == "success"] + for worker_name, _, branch_id in successful_results: + assert await verification_session.get_items(branch_id=branch_id) == [ + *copied_items, + {"role": "user", "content": f"{worker_name} branch item"}, + ] + assert await verification_session.get_items(branch_id="main") == items + verification_session.close() + finally: + release_check.set() + for _, start, _, _ in worker_events: + start.set() + for process in processes: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + results.close() + + async def test_delete_branch_removes_branch_only_messages(): """Deleting a branch should not leave unreferenced branch-only messages behind.""" session_id = "branch_delete_cleanup_test" @@ -2333,6 +2767,41 @@ async def test_stale_create_branch_after_clear_does_not_repoint(): session.close() +async def test_clear_before_branch_transaction_prevents_stale_reservation(): + """A clear that wins before transactional validation must leave no reservation.""" + session = AdvancedSQLiteSession( + session_id="clear_before_branch_transaction_test", + create_tables=True, + ) + + try: + await session.add_items( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + ] + ) + + with _gate_worker("_copy_sync") as (started, real_to_thread, release): + task = asyncio.ensure_future(session.create_branch_from_turn(1, "stale_branch")) + await real_to_thread(started.wait) + await session.clear_session() + release.set() + with pytest.raises(ValueError, match="does not contain a user message"): + await task + + assert session._current_branch_id == "main" + assert await session.list_branches() == [] + with session._locked_connection() as conn: + reservations = conn.execute( + "SELECT branch_id FROM branch_reservations WHERE session_id = ?", + (session.session_id,), + ).fetchall() + assert reservations == [("main",)] + finally: + session.close() + + async def test_stale_store_run_usage_skipped_when_turn_removed_by_pop(usage_data: Usage): """A store_run_usage that reads a turn and then races with pop_item removing that turn must not reinsert usage for the now-nonexistent turn. @@ -2462,11 +2931,13 @@ async def test_clear_session_resets_current_branch_to_main(): await session.create_branch_from_turn(2, "branch_a") await session.switch_to_branch("branch_a") assert session._current_branch_id == "branch_a" + assert _count_rows(session, "branch_reservations") == 2 await session.clear_session() assert session._current_branch_id == "main" assert await session.get_items() == [] + assert _count_rows(session, "branch_reservations") == 2 finally: session.close() From e064ab68395012903bf5481640d8c76aae92b0c5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 11:29:51 +0900 Subject: [PATCH 158/473] test: make parent cancellation tests deterministic (#4193) Co-authored-by: Shaurya Singh --- tests/test_run_step_execution.py | 92 +++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index b50405027b..2326607353 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -101,6 +101,11 @@ reject_tool_call, ) +# Deadlock detector for the parent-cancellation tests below. It is deliberately far larger +# than any bound the runtime itself applies (see `_FUNCTION_TOOL_CANCELLED_DRAIN_SECONDS`), +# so it cannot become the behavioral assertion. +_CANCELLATION_HANG_GUARD_SECONDS = 5.0 + def _function_spans() -> list[dict[str, Any]]: function_spans: list[dict[str, Any]] = [] @@ -2622,12 +2627,23 @@ async def _waiting_tool(name: str) -> str: @pytest.mark.asyncio -async def test_parent_cancellation_does_not_wait_for_tool_cleanup(): +async def test_parent_cancellation_does_not_wait_for_tool_cleanup( + monkeypatch: pytest.MonkeyPatch, +): tool_started = asyncio.Event() cleanup_started = asyncio.Event() cleanup_finished = asyncio.Event() allow_cleanup_exit = asyncio.Event() + settle_calls: list[set[asyncio.Task[Any]]] = [] + original_settle = tool_execution._settle_pending_function_tool_tasks + + async def _recording_settle(*args: Any, **kwargs: Any) -> tuple[Any, set[asyncio.Task[Any]]]: + settle_calls.append(set(kwargs["pending_tasks"])) + return await original_settle(*args, **kwargs) + + monkeypatch.setattr(tool_execution, "_settle_pending_function_tool_tasks", _recording_settle) + async def _slow_cancel_tool() -> str: tool_started.set() try: @@ -2653,15 +2669,20 @@ async def _slow_cancel_tool() -> str: ) execution_task = asyncio.create_task(get_execute_result(agent, response)) - await asyncio.wait_for(tool_started.wait(), timeout=0.2) + await asyncio.wait_for(tool_started.wait(), timeout=_CANCELLATION_HANG_GUARD_SECONDS) execution_task.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(execution_task, timeout=0.1) + try: + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(execution_task, timeout=_CANCELLATION_HANG_GUARD_SECONDS) - await asyncio.wait_for(cleanup_started.wait(), timeout=0.2) - allow_cleanup_exit.set() - await asyncio.wait_for(cleanup_finished.wait(), timeout=0.2) + await asyncio.wait_for(cleanup_started.wait(), timeout=_CANCELLATION_HANG_GUARD_SECONDS) + assert settle_calls == [] + assert not cleanup_finished.is_set() + finally: + allow_cleanup_exit.set() + + await asyncio.wait_for(cleanup_finished.wait(), timeout=_CANCELLATION_HANG_GUARD_SECONDS) @pytest.mark.asyncio @@ -2693,19 +2714,50 @@ async def _shield_then_cancel(task: asyncio.Task[Any]) -> Any: @pytest.mark.asyncio -async def test_parent_cancellation_does_not_report_tool_failure_as_background_error(): +async def test_parent_cancellation_does_not_report_tool_failure_as_background_error( + monkeypatch: pytest.MonkeyPatch, +): loop = asyncio.get_running_loop() original_handler = loop.get_exception_handler() reported_contexts: list[dict[str, Any]] = [] tool_started = asyncio.Event() + cleanup_started = asyncio.Event() + allow_cleanup_failure = asyncio.Event() + background_callback_ran = asyncio.Event() + background_task: asyncio.Task[Any] | None = None + background_exception: UserError | None = None def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None: reported_contexts.append(context) + original_consume = tool_execution._consume_function_tool_task_result + + def _recording_consume(task: asyncio.Task[Any], **kwargs: Any) -> None: + nonlocal background_task, background_exception + try: + original_consume(task, **kwargs) + finally: + if not task.cancelled(): + exception = task.exception() + if ( + isinstance(exception, UserError) + and str(exception) == "Error running tool failing_tool: boom" + ): + background_task = task + background_exception = exception + background_callback_ran.set() + + monkeypatch.setattr(tool_execution, "_consume_function_tool_task_result", _recording_consume) + async def _failing_tool() -> str: tool_started.set() - await asyncio.sleep(0) - raise ValueError("boom") + try: + await asyncio.Future() + return "unreachable" + except asyncio.CancelledError: + cleanup_started.set() + await allow_cleanup_failure.wait() + raise ValueError("boom") from None tool = function_tool( _failing_tool, @@ -2722,23 +2774,25 @@ async def _failing_tool() -> str: loop.set_exception_handler(_exception_handler) try: execution_task = asyncio.create_task(get_execute_result(agent, response)) - await asyncio.wait_for(tool_started.wait(), timeout=0.2) + await asyncio.wait_for(tool_started.wait(), timeout=_CANCELLATION_HANG_GUARD_SECONDS) execution_task.cancel() with pytest.raises(asyncio.CancelledError): - await execution_task + await asyncio.wait_for(execution_task, timeout=_CANCELLATION_HANG_GUARD_SECONDS) - await asyncio.sleep(0) - await asyncio.sleep(0) + await asyncio.wait_for(cleanup_started.wait(), timeout=_CANCELLATION_HANG_GUARD_SECONDS) + allow_cleanup_failure.set() + await asyncio.wait_for( + background_callback_ran.wait(), timeout=_CANCELLATION_HANG_GUARD_SECONDS + ) finally: + allow_cleanup_failure.set() loop.set_exception_handler(original_handler) + assert background_task is not None + assert background_exception is not None assert not any( - context.get("message") - == "Background function tool task raised during cancellation cleanup after failure " - "propagation." - and isinstance(context.get("exception"), UserError) - and str(context["exception"]) == "Error running tool failing_tool: boom" + context.get("task") is background_task and context.get("exception") is background_exception for context in reported_contexts ) From b4386278aa1e37375b6e468f0230e3b0c0f155f6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 11:54:03 +0900 Subject: [PATCH 159/473] docs: fix tool duplication in sandbox examples --- examples/sandbox/memory_multi_agent_multiturn.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/sandbox/memory_multi_agent_multiturn.py b/examples/sandbox/memory_multi_agent_multiturn.py index cf278ef4bb..a564e80626 100644 --- a/examples/sandbox/memory_multi_agent_multiturn.py +++ b/examples/sandbox/memory_multi_agent_multiturn.py @@ -112,7 +112,6 @@ def _build_gtm_agent(*, model: str, manifest: Manifest) -> SandboxAgent: ), Filesystem(), Shell(), - Filesystem(), ], ) From 92aa1b905306d7f5a130d911061c44cddeaa6e20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:55:50 +0900 Subject: [PATCH 160/473] Bump version to 0.19.4 (#4194) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 122b15f3b4..5a7d097aae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.19.3" +version = "0.19.4" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index faa0f73e57..e3336f1490 100644 --- a/uv.lock +++ b/uv.lock @@ -2437,7 +2437,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.19.3" +version = "0.19.4" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 08c1c9f365e4a02bdfbe051e89c6ababfad85ee7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 12:49:01 +0900 Subject: [PATCH 161/473] docs: update config and guardrails pages --- docs/config.md | 25 ++- docs/guardrails.md | 6 +- docs/ja/sessions/advanced_sqlite_session.md | 50 ++++-- docs/ja/sessions/index.md | 124 ++++++------- docs/ko/sessions/advanced_sqlite_session.md | 58 +++--- docs/ko/sessions/index.md | 148 +++++++-------- docs/zh/sessions/advanced_sqlite_session.md | 58 +++--- docs/zh/sessions/index.md | 188 ++++++++++---------- 8 files changed, 361 insertions(+), 296 deletions(-) diff --git a/docs/config.md b/docs/config.md index 5df409dde9..cbdbdbd539 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,6 +12,25 @@ If you need to configure a specific agent or run instead, start with: - [Models](models/index.md) for model selection and provider configuration. - [Tracing](tracing.md) for per-run tracing metadata and custom trace processors. +## Configuration objects and dictionaries + +SDK-owned configuration parameters generally accept either their typed settings object or a dictionary containing the same fields. This applies across agent, run, model, session, sandbox, and voice configuration boundaries whose type annotations include a dictionary. Nested SDK-owned settings can also use dictionaries. + +```python +from agents import Agent + +agent = Agent( + name="Assistant", + model="gpt-5.6-sol", + model_settings={ + "reasoning": {"effort": "high"}, + "verbosity": "low", + }, +) +``` + +The SDK normalizes these dictionaries into the corresponding settings objects. Unknown fields in SDK-owned dataclass configurations raise `TypeError`, which helps catch misspelled option names early. Check the parameter's type annotation or API reference to confirm whether a specific boundary accepts a dictionary. + ## API keys and clients By default, the SDK uses the `OPENAI_API_KEY` environment variable for LLM requests and tracing. The key is resolved when the SDK first creates an OpenAI client (lazy initialization), so set the environment variable before your first model call. If you are unable to set that environment variable before your app starts, you can use the [set_default_openai_key()][agents.set_default_openai_key] function to set the key. @@ -182,9 +201,9 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### Sensitive data in logs +### Sensitive data in logs and diagnostics -Certain logs may contain sensitive data (for example, user data). +Certain logs and diagnostic exceptions may contain sensitive data (for example, model or tool inputs and outputs). By default, the SDK does **not** log LLM inputs/outputs or tool inputs/outputs. These protections are controlled by: @@ -199,3 +218,5 @@ If you need to include this data temporarily for debugging, set either variable export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` + +These flags also control whether affected failures retain payload-bearing diagnostic details. For example, with tool-data redaction enabled, invalid function-tool arguments raise a generic `ModelBehaviorError` without chaining the underlying validation error. Setting either variable to `0` can expose raw model or tool data in logs, exception messages, exception chains, and other diagnostic context, so enable it only in a controlled development environment. diff --git a/docs/guardrails.md b/docs/guardrails.md index 2f42d4205c..ac95e748d1 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -64,9 +64,11 @@ See the code snippet below for details. ## Tripwires -If the input or output fails the guardrail, the Guardrail can signal this with a tripwire. As soon as we see a guardrail that has triggered the tripwires, we immediately raise a `{Input,Output}GuardrailTripwireTriggered` exception and halt the Agent execution. +If an agent input or output fails a guardrail, the guardrail can signal this with a tripwire. The runner immediately raises an `InputGuardrailTripwireTriggered` or `OutputGuardrailTripwireTriggered` exception and halts agent execution. Tool guardrails use the corresponding `ToolInputGuardrailTripwireTriggered` and `ToolOutputGuardrailTripwireTriggered` exceptions. -The exception's `guardrail_result` identifies the guardrail that triggered the tripwire. For an input tripwire raised by the runner, `exception.run_data.input_guardrail_results` contains every input guardrail result completed before the run stopped, including the result that triggered the tripwire. Output tripwires provide the equivalent accumulated results through `exception.run_data.output_guardrail_results`. After `stream_events()` raises, the streamed result exposes the same completed results through `input_guardrail_results` or `output_guardrail_results`. `run_data` can be `None` when an exception is raised outside a runner-managed execution path. +For agent-level tripwires, the exception's `guardrail_result` identifies the guardrail that triggered the tripwire. For an input tripwire raised by the runner, `exception.run_data.input_guardrail_results` contains every input guardrail result completed before the run stopped, including the result that triggered the tripwire. Output tripwires provide the equivalent accumulated results through `exception.run_data.output_guardrail_results`. + +Tool tripwire exceptions instead expose the triggering `guardrail` and `output` directly. Their `run_data.tool_input_guardrail_results` and `run_data.tool_output_guardrail_results` lists preserve results accumulated from completed turns before the failure; the triggering result is available through the exception's `output`. Other runner-managed failures, such as `MaxTurnsExceeded`, also preserve completed tool guardrail results in these lists. After `stream_events()` raises, the streamed result exposes the same accumulated agent and tool guardrail result lists. `run_data` can be `None` when an exception is raised outside a runner-managed execution path. ## Implementing a guardrail diff --git a/docs/ja/sessions/advanced_sqlite_session.md b/docs/ja/sessions/advanced_sqlite_session.md index 6927602fd0..6fd3af277b 100644 --- a/docs/ja/sessions/advanced_sqlite_session.md +++ b/docs/ja/sessions/advanced_sqlite_session.md @@ -4,15 +4,15 @@ search: --- # 高度な SQLite セッション -`AdvancedSQLiteSession` は基本的な `SQLiteSession` の拡張版であり、会話の分岐、詳細な使用状況分析、構造化された会話クエリなど、高度な会話管理機能を提供します。 +`AdvancedSQLiteSession` は、基本的な `SQLiteSession` の拡張版であり、会話の分岐、詳細な使用状況分析、構造化された会話クエリなど、高度な会話管理機能を提供します。 ## 機能 -- **会話の分岐**: 任意のユーザーメッセージから別の会話パスを作成します -- **使用状況の追跡**: ターンごとの詳細なトークン使用状況分析を、完全な JSON 内訳付きで提供します -- **構造化クエリ**: ターン別の会話、ツール使用状況の統計などを取得します -- **ブランチ管理**: 独立したブランチ切り替えと管理を行います -- **メッセージ構造メタデータ**: メッセージタイプ、ツール使用、会話フローを追跡します +- **会話の分岐**: 任意のユーザーメッセージから別の会話経路を作成 +- **使用状況の追跡**: ターンごとの詳細なトークン使用状況分析と完全な JSON 内訳 +- **構造化クエリ**: ターン単位の会話、ツール使用状況の統計などを取得 +- **ブランチ管理**: 独立したブランチの切り替えと管理 +- **メッセージ構造のメタデータ**: メッセージタイプ、ツールの使用状況、会話フローを追跡 ## クイックスタート @@ -84,14 +84,14 @@ session = AdvancedSQLiteSession( ### パラメーター -- `session_id` (str): 会話セッションの一意の識別子 -- `db_path` (str | Path): SQLite データベースファイルへのパス。デフォルトはインメモリストレージ用の `:memory:` です +- `session_id` (str): 会話セッションの一意な識別子 +- `db_path` (str | Path): SQLite データベースファイルへのパス。インメモリストレージの場合、デフォルトは `:memory:` です - `create_tables` (bool): 高度なテーブルを自動的に作成するかどうか。デフォルトは `False` です - `logger` (logging.Logger | None): セッション用のカスタムロガー。デフォルトはモジュールロガーです ## 使用状況の追跡 -AdvancedSQLiteSession は、会話ターンごとにトークン使用状況データを保存することで、詳細な使用状況分析を提供します。 **これは、各エージェント実行後に `store_run_usage` メソッドが呼び出されることに完全に依存します。** +AdvancedSQLiteSession は、会話の各ターンのトークン使用状況データを保存することで、詳細な使用状況分析を提供します。**これは、エージェントの実行後に毎回 `store_run_usage` メソッドが呼び出されることに全面的に依存します。** ### 使用状況データの保存 @@ -137,7 +137,7 @@ turn_2_usage = await session.get_turn_usage(user_turn_number=2) ## 会話の分岐 -AdvancedSQLiteSession の主要機能の 1 つは、任意のユーザーメッセージから会話ブランチを作成し、別の会話パスを探索できることです。 +AdvancedSQLiteSession の主な機能の 1 つは、任意のユーザーメッセージから会話のブランチを作成し、別の会話経路を探索できることです。 ### ブランチの作成 @@ -165,6 +165,8 @@ branch_id = await session.create_branch_from_content( ) ``` +ブランチ ID は、セッション ID の存続期間を通じて一意です。ブランチを削除したりセッションをクリアしたりすると、その会話データは削除されますが、以前使用したブランチ ID が再び使用可能になるわけではありません。別のブランチを作成する際は、新しい名前を使用してください。 + ### ブランチ管理 ```python @@ -182,7 +184,7 @@ await session.switch_to_branch(branch_id) await session.delete_branch(branch_id, force=True) # force=True allows deleting current branch ``` -### ブランチワークフロー例 +### ブランチワークフローの例 ```python # Original conversation @@ -245,9 +247,9 @@ for turn in matching_turns: ### メッセージ構造 -セッションは、次を含むメッセージ構造を自動的に追跡します。 +セッションは、以下を含むメッセージ構造を自動的に追跡します。 -- メッセージタイプ(ユーザー、assistant、tool_call など) +- メッセージタイプ(user、assistant、tool_call など) - ツール呼び出しのツール名 - ターン番号とシーケンス番号 - ブランチとの関連付け @@ -255,9 +257,9 @@ for turn in matching_turns: ## データベーススキーマ -AdvancedSQLiteSession は、基本的な SQLite スキーマを 2 つの追加テーブルで拡張します。 +AdvancedSQLiteSession は、基本的な SQLite スキーマを 3 つの追加テーブルで拡張します。 -### message_structure テーブル +### `message_structure` テーブル ```sql CREATE TABLE message_structure ( @@ -276,7 +278,19 @@ CREATE TABLE message_structure ( ); ``` -### turn_usage テーブル +### `branch_reservations` テーブル + +```sql +CREATE TABLE branch_reservations ( + session_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + PRIMARY KEY (session_id, branch_id) +); +``` + +このテーブルは、コピーされた接頭部分が空のブランチを含め、ブランチ ID をアトミックに予約します。予約行はブランチの削除後やセッションのクリア後も保持されるため、古いセッションインスタンスが、同じ ID を再利用した後続のブランチに履歴をマージすることはありません。 + +### `turn_usage` テーブル ```sql CREATE TABLE turn_usage ( @@ -298,10 +312,10 @@ CREATE TABLE turn_usage ( ## 完全な例 -すべての機能を包括的に示す [完全な例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py) を確認してください。 +すべての機能を包括的に紹介する[完全な例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)をご確認ください。 ## API リファレンス - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - メインクラス -- [`Session`][agents.memory.session.Session] - ベースセッションプロトコル \ No newline at end of file +- [`Session`][agents.memory.session.Session] - 基本セッションプロトコル \ No newline at end of file diff --git a/docs/ja/sessions/index.md b/docs/ja/sessions/index.md index cbe8753296..baa26b0a16 100644 --- a/docs/ja/sessions/index.md +++ b/docs/ja/sessions/index.md @@ -6,9 +6,9 @@ search: Agents SDK には、複数回のエージェント実行にわたって会話履歴を自動的に維持する組み込みのセッションメモリが用意されているため、ターン間で `.to_input_list()` を手動で処理する必要がありません。 -セッションは特定のセッションの会話履歴を保存し、明示的な手動メモリ管理を必要とせずに、エージェントがコンテキストを維持できるようにします。これは、エージェントに以前のやり取りを記憶させたいチャットアプリケーションや複数ターンの会話を構築する場合に特に便利です。 +セッションは特定のセッションの会話履歴を保存し、明示的な手動のメモリ管理を必要とせずに、エージェントがコンテキストを維持できるようにします。これは、エージェントに以前のやり取りを記憶させたいチャットアプリケーションや、複数ターンの会話を構築する場合に特に便利です。 -SDK にクライアント側のメモリを管理させたい場合は、セッションを使用してください。同じ実行内で、セッションを `conversation_id`、`previous_response_id`、または `auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI サーバーが管理する継続機能を使用する場合は、セッションと重ねて使用せず、これらのメカニズムのいずれかを選択してください。 +SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内でセッションを `conversation_id`、`previous_response_id`、または `auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI のサーバーで管理される継続機能を使用したい場合は、セッションと重ねて使用せず、これらの仕組みのいずれかを選択してください。 ## クイックスタート @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 同じセッションによる中断された実行の再開 -承認待ちで実行が一時停止した場合は、再開されたターンが同じ保存済み会話履歴を継続できるように、同じセッションインスタンス、または同じバッキングストアを指す別のセッションインスタンスを使用して再開してください。 +実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または同じバックエンドストアを参照する別のセッションインスタンス)を使用して再開し、再開後のターンが保存済みの同じ会話履歴を引き継ぐようにしてください。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -67,27 +67,27 @@ if result.interruptions: セッションメモリが有効な場合、次のように動作します。 -1. **各実行の前**: Runner はセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 -2. **各実行の後**: 実行中に生成されたすべての新しい項目(ユーザー入力、アシスタントの応答、ツール呼び出しなど)が、セッションに自動的に保存されます。 +1. **各実行前**: ランナーはセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 +2. **各実行後**: 実行中に生成されたすべての新しい項目(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 3. **コンテキストの保持**: 同じセッションを使用する後続の各実行には完全な会話履歴が含まれるため、エージェントはコンテキストを維持できます。 これにより、`.to_input_list()` を手動で呼び出し、実行間の会話状態を管理する必要がなくなります。 -## 履歴と新規入力のマージ制御 +## 履歴と新しい入力のマージ制御 -セッションを渡すと、Runner は通常、モデル入力を次の順序で準備します。 +セッションを渡すと、通常、ランナーは次の順序でモデル入力を準備します。 1. セッション履歴(`session.get_items(...)` から取得) 2. 新しいターンの入力 -モデルを呼び出す前のこのマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 +モデル呼び出し前のマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 - `history`: 取得したセッション履歴(入力項目形式に正規化済み) - `new_input`: 現在のターンの新しい入力項目 -モデルに送信する最終的な入力項目のリストを返してください。 +モデルに送信する入力項目の最終的なリストを返してください。 -コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストはそのターンのモデル入力を制御しますが、SDK が永続化するのは新しいターンに属する項目のみです。そのため、古い履歴を並べ替えたりフィルタリングしたりしても、古いセッション項目が新しい入力として再度保存されることはありません。 +コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストによってそのターンのモデル入力が決まりますが、SDK が永続化するのは新しいターンに属する項目だけです。したがって、古い履歴を並べ替えたりフィルタリングしたりしても、古いセッション項目が新しい入力として再び保存されることはありません。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,7 +109,7 @@ result = await Runner.run( ) ``` -セッションが項目を保存する方法を変更せずに、履歴の独自の枝刈り、並べ替え、または選択的な追加が必要な場合に使用します。モデル呼び出しの直前に最終処理が必要な場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 +セッションによる項目の保存方法を変更せずに、履歴の独自の枝刈り、並べ替え、または選択的な追加が必要な場合に使用します。モデル呼び出しの直前に後段の最終処理が必要な場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 ## 取得する履歴の制限 @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` はその実行について、`None` 以外の値を上書きします。これは、セッションのデフォルト動作を変更せずに取得件数を制限したい長い会話で役立ちます。 +セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` はその実行について、`None` ではない値を上書きします。これは、セッションのデフォルト動作を変更せずに取得サイズを制限したい長い会話で便利です。 ## メモリ操作 ### 基本操作 -セッションでは、会話履歴を管理するための複数の操作を使用できます。 +セッションでは、会話履歴を管理するために複数の操作を使用できます。 ```python from agents import SQLiteSession @@ -165,7 +165,7 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 修正での pop_item の使用 +### `pop_item` を使用した修正 `pop_item` メソッドは、会話の最後の項目を取り消したり変更したりする場合に特に便利です。 @@ -206,20 +206,20 @@ SDK には、さまざまなユースケースに対応する複数のセッシ | セッションタイプ | 最適な用途 | 備考 | | --- | --- | --- | -| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量、ファイルベースまたはインメモリ | +| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量。ファイルベースまたはインメモリ | | `AsyncSQLiteSession` | `aiosqlite` を使用する非同期 SQLite | 非同期ドライバーをサポートする拡張バックエンド | -| `RedisSession` | ワーカーやサービス間での共有メモリ | 低レイテンシーの分散デプロイに適しています | -| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作します | +| `RedisSession` | ワーカーやサービス間の共有メモリ | 低レイテンシーの分散デプロイに最適 | +| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースに対応 | | `MongoDBSession` | MongoDB をすでに使用しているアプリ、またはマルチプロセスストレージが必要なアプリ | 非同期 pymongo。順序付け用のアトミックなシーケンスカウンター | -| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数のステートストアに加え、TTL と整合性制御をサポートします | -| `OpenAIConversationsSession` | OpenAI でのサーバー管理ストレージ | OpenAI Conversations API を利用した履歴 | -| `OpenAIResponsesCompactionSession` | 自動コンパクションを使用する長い会話 | 別のセッションバックエンドをラップします | -| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析が必要な場合 | より多機能です。専用ページを参照してください | -| `EncryptedSession` | 別のセッションに暗号化と TTL を追加する場合 | ラッパーです。まず基盤となるバックエンドを選択してください | +| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数の状態ストアに加え、TTL と整合性の制御をサポート | +| `OpenAIConversationsSession` | OpenAI でのサーバー管理ストレージ | OpenAI Conversations API をバックエンドとする履歴 | +| `OpenAIResponsesCompactionSession` | 自動圧縮を伴う長い会話 | 別のセッションバックエンドをラップ | +| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析が必要な場合 | より高度な機能セット。専用ページを参照 | +| `EncryptedSession` | 別のセッションに追加する暗号化と TTL | ラッパー。最初に基盤となるバックエンドを選択 | -一部の実装には追加の詳細を説明する専用ページがあり、それぞれのサブセクション内にリンクがあります。 +一部の実装には、追加の詳細を記載した専用ページがあります。それぞれのサブセクション内にリンクがあります。 -ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドと項目の永続化に `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えることはできません。[ChatKit データストアの実装に関する `chatkit-python` ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 +ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドと項目を永続化するために `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[ChatKit データストアの実装に関する `chatkit-python` ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 ### OpenAI Conversations API セッション @@ -257,11 +257,11 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses コンパクションセッション +### OpenAI Responses 圧縮セッション -Responses API(`responses.compact`)を使用して保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターンの後に自動的にコンパクションを実行できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 +Responses API(`responses.compact`)を使用して保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動圧縮できます。`OpenAIConversationsSession` をラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 -#### 一般的な使用方法(自動コンパクション) +#### 典型的な使用方法(自動圧縮) ```python from agents import Agent, Runner, SQLiteSession @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -デフォルトでは、候補のしきい値に達すると、各ターンの後にコンパクションが実行されます。 +デフォルトでは、候補のしきい値に達すると、各ターン後に圧縮が実行されます。 -Responses API のレスポンス ID を使用してターンをすでに連結している場合は、`compaction_mode="previous_response_id"` が最適です。一方、`compaction_mode="input"` は、現在のセッション項目からコンパクションリクエストを再構築します。これは、レスポンスチェーンを利用できない場合や、セッション内容を信頼できる唯一の情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 +Responses API のレスポンス ID を使用してすでにターンを連結している場合は、`compaction_mode="previous_response_id"` が最適です。`compaction_mode="input"` は、代わりに現在のセッション項目から圧縮リクエストを再構築します。これは、レスポンスチェーンを利用できない場合や、セッションの内容を信頼できる情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 -エージェントが `ModelSettings(store=False)` で実行される場合、Responses API は後で参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースのコンパクションにフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 +エージェントが `ModelSettings(store=False)` で実行されている場合、Responses API は後から参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースの圧縮にフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 -#### 自動コンパクションによるストリーミングのブロック +#### 自動圧縮によるストリーミングのブロック -コンパクションではセッション履歴がクリアされて書き換えられるため、SDK はコンパクションが完了するまで実行を完了と見なしません。ストリーミングモードでは、コンパクションの処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 +圧縮はセッション履歴を消去して書き換えるため、SDK は圧縮が完了するまで実行を完了と見なしません。ストリーミングモードでは、圧縮処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 -低レイテンシーのストリーミングやターンの迅速な切り替えが必要な場合は、自動コンパクションを無効にし、ターン間またはアイドル時間中に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、コンパクションを強制するタイミングを決定できます。 +低レイテンシーのストリーミングや素早いターン移行が必要な場合は、自動圧縮を無効にし、ターン間またはアイドル時に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、圧縮を強制するタイミングを決定できます。 ```python from agents import Agent, Runner, SQLiteSession @@ -311,7 +311,7 @@ await session.run_compaction({"force": True}) ### SQLite セッション -SQLite を使用するデフォルトの軽量セッション実装です。 +SQLite を使用する、デフォルトの軽量なセッション実装です。 ```python from agents import SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 非同期 SQLite セッション -`aiosqlite` を基盤とする SQLite 永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 +`aiosqlite` をバックエンドとする SQLite 永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 ```bash pip install aiosqlite @@ -349,7 +349,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis セッション -複数のワーカーまたはサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 +複数のワーカーやサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 ```bash pip install openai-agents[redis] @@ -368,11 +368,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)` は Redis クライアントを作成して所有します。`close()` の後、セッションは終了状態になり、それ以降のセッション操作では `RuntimeError` が発生します。`close()` を繰り返し、または同時に呼び出しても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築してください。その場合、`close()` は何も行わず、呼び出し元がクライアントの所有権とセッションの利用可能性の両方を維持します。 +`from_url(...)` は Redis クライアントを作成して所有します。`close()` の後、セッションは終了状態となり、後続のセッション操作では `RuntimeError` が発生します。`close()` を繰り返し呼び出したり、同時に呼び出したりしても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築してください。その場合、`close()` は何も行わず、呼び出し元がクライアントの所有権を保持し、セッションも引き続き利用できます。 ### SQLAlchemy セッション -SQLAlchemy がサポートする任意のデータベースを使用した、本番環境向けの Agents SDK セッション永続化です。 +SQLAlchemy がサポートする任意のデータベースを使用する、本番環境対応の Agents SDK セッション永続化です。 ```python from agents.extensions.memory import SQLAlchemySession @@ -394,7 +394,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ### Dapr セッション -Dapr サイドカーをすでに実行している場合、またはエージェントコードを変更せずに異なるステートストアバックエンド間で移行できるセッションストレージが必要な場合は、`DaprSession` を使用します。 +すでに Dapr サイドカーを実行している場合や、エージェントコードを変更せずに異なる状態ストアのバックエンド間で移行できるセッションストレージが必要な場合は、`DaprSession` を使用します。 ```bash pip install openai-agents[dapr] @@ -415,19 +415,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -注意事項: +注記: - `from_address(...)` は Dapr クライアントを作成して所有します。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を指定して `DaprSession(...)` を直接構築してください。 -- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを使用するセッションは終了状態になります。それ以降のセッション操作では `RuntimeError` が発生しますが、`close()` を繰り返し、または同時に呼び出しても安全です。注入されたクライアントを使用する場合、`close()` は何も行わず、セッションは引き続き使用できます。 -- バッキングステートストアが TTL をサポートしている場合、古いセッションデータを自動的に期限切れにするには `ttl=...` を渡します。 -- 書き込み後の読み取りについて、より強い保証が必要な場合は `consistency=DAPR_CONSISTENCY_STRONG` を渡します。 +- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを使用するセッションは終了状態になります。後続のセッション操作では `RuntimeError` が発生しますが、`close()` を繰り返し呼び出したり、同時に呼び出したりしても安全です。注入されたクライアントを使用する場合、`close()` は何も行わず、セッションは引き続き利用できます。 +- バックエンドの状態ストアが TTL をサポートしている場合に、古いセッションデータを自動的に期限切れにするには、`ttl=...` を渡します。 +- 書き込み後の読み取りについて、より強い一貫性保証が必要な場合は、`consistency=DAPR_CONSISTENCY_STRONG` を渡します。 - Dapr Python SDK は HTTP サイドカーエンドポイントも確認します。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` を指定して Dapr を起動してください。 - ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)を参照してください。 ### MongoDB セッション -MongoDB をすでに使用しているアプリケーション、または水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションでは、`MongoDBSession` を使用します。 +MongoDB をすでに使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要な場合は、`MongoDBSession` を使用します。 ```bash pip install openai-agents[mongodb] @@ -450,16 +450,16 @@ print(result.final_output) await session.close() ``` -注意事項: +注記: -- `from_uri(...)` は `AsyncMongoClient` を作成して所有し、`session.close()` で閉じます。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築してください。その場合、`session.close()` は何も行わず、ライフサイクルの管理は呼び出し元が引き続き行います。 -- `mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すことで、ほかに変更を加えずに [MongoDB Atlas](https://www.mongodb.com/products/platform) に接続できます。 -- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。各メッセージドキュメントには単調増加する `seq` カウンターが含まれ、同時に書き込む複数のライターやプロセス間でも順序が保持されます。 +- `from_uri(...)` は `AsyncMongoClient` を作成して所有し、`session.close()` で閉じます。所有クライアントを使用するセッションは `close()` 後に終了状態となり、後続のセッション操作では `RuntimeError` が発生します。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築してください。その場合、`session.close()` は何も行わず、ライフサイクルとセッションの利用可否は呼び出し元が管理します。 +- ほかに変更を加えることなく、`mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すことで、[MongoDB Atlas](https://www.mongodb.com/products/platform)に接続できます。 +- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。各メッセージドキュメントには単調増加する `seq` カウンターが含まれ、同時実行される書き込み元やプロセス間で順序を維持します。 - 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 ### 高度な SQLite セッション -会話の分岐、使用状況分析、構造化クエリを備えた拡張 SQLite セッションです。 +会話の分岐、使用状況分析、構造化クエリに対応した拡張 SQLite セッションです。 ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -483,7 +483,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 ### 暗号化セッション -任意のセッション実装に対する透過的な暗号化ラッパーです。 +任意のセッション実装に対応する透過的な暗号化ラッパーです。 ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -527,12 +527,12 @@ result = await Runner.run(agent, "Hello", session=session) - 一時的な会話には、インメモリ SQLite(`SQLiteSession("session_id")`)を使用します - 永続的な会話には、ファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します - `aiosqlite` ベースの実装が必要な場合は、非同期 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)を使用します -- 共有された低レイテンシーのセッションメモリには、Redis ベースのセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します +- 共有可能で低レイテンシーのセッションメモリには、Redis をバックエンドとするセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します - SQLAlchemy がサポートする既存のデータベースを使用する本番システムには、SQLAlchemy ベースのセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)を使用します -- MongoDB をすでに使用しているアプリケーション、または水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションには、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します -- 組み込みのテレメトリー、トレーシング、データ分離を備え、30 種類以上のデータベースバックエンドをサポートする本番環境のクラウドネイティブなデプロイには、Dapr ステートストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します +- MongoDB をすでに使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要な場合は、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します +- 組み込みのテレメトリ、トレーシング、データ分離機能と 30 種類超のデータベースバックエンドのサポートが必要な本番環境のクラウドネイティブデプロイには、Dapr 状態ストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します - OpenAI Conversations API に履歴を保存したい場合は、OpenAI がホストするストレージ(`OpenAIConversationsSession()`)を使用します -- 任意のセッションを透過的な暗号化と TTL ベースの有効期限でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します +- 任意のセッションを透過的な暗号化と TTL ベースの期限切れ機能でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します - より高度なユースケースでは、ほかの本番システム(Django など)向けのカスタムセッションバックエンドの実装を検討してください ### 複数のセッション @@ -581,7 +581,7 @@ result2 = await Runner.run( ## 完全な例 -セッションメモリの動作を示す完全な例を以下に示します。 +セッションメモリの実際の動作を示す完全な例を次に示します。 ```python import asyncio @@ -645,7 +645,7 @@ if __name__ == "__main__": ## カスタムセッション実装 -[`Session`][agents.memory.session.Session] プロトコルに準拠するクラスを作成することで、独自のセッションメモリを実装できます。 +[`Session`][agents.memory.session.Session] プロトコルに従うクラスを作成することで、独自のセッションメモリを実装できます。 ```python from agents.memory.session import SessionABC @@ -704,12 +704,12 @@ result = await Runner.run( - [`Session`][agents.memory.session.Session] - プロトコルインターフェース - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 実装 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API コンパクションラッパー +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 圧縮ラッパー - [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基本的な SQLite 実装 - [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` ベースの非同期 SQLite 実装 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis ベースのセッション実装 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis をバックエンドとするセッション実装 - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy ベースの実装 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB ベースのセッション実装 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr ステートストア実装 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析機能を備えた拡張 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッション向けの暗号化ラッパー \ No newline at end of file +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB をバックエンドとするセッション実装 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状態ストア実装 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析に対応した拡張 SQLite +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッションに対応する暗号化ラッパー \ No newline at end of file diff --git a/docs/ko/sessions/advanced_sqlite_session.md b/docs/ko/sessions/advanced_sqlite_session.md index 53029430e1..ca10beb8b3 100644 --- a/docs/ko/sessions/advanced_sqlite_session.md +++ b/docs/ko/sessions/advanced_sqlite_session.md @@ -4,14 +4,14 @@ search: --- # 고급 SQLite 세션 -`AdvancedSQLiteSession`은 기본 `SQLiteSession`의 향상된 버전으로, 대화 분기, 상세 사용량 분석, 구조화된 대화 쿼리 등 고급 대화 관리 기능을 제공합니다. +`AdvancedSQLiteSession`은 기본 `SQLiteSession`을 개선한 버전으로, 대화 브랜칭, 상세한 사용량 분석, 구조화된 대화 쿼리 등 고급 대화 관리 기능을 제공합니다. ## 기능 -- **대화 분기**: 모든 사용자 메시지에서 대체 대화 경로를 생성 -- **사용량 추적**: 전체 JSON 세부 내역과 함께 턴별 상세 토큰 사용량 분석 -- **구조화된 쿼리**: 턴별 대화, 도구 사용 통계 등을 조회 -- **분기 관리**: 독립적인 분기 전환 및 관리 +- **대화 브랜칭**: 모든 사용자 메시지에서 대체 대화 경로 생성 +- **사용량 추적**: 전체 JSON 세부 내역을 포함한 턴별 상세 토큰 사용량 분석 +- **구조화된 쿼리**: 턴별 대화, 도구 사용 통계 등 조회 +- **브랜치 관리**: 독립적인 브랜치 전환 및 관리 - **메시지 구조 메타데이터**: 메시지 유형, 도구 사용, 대화 흐름 추적 ## 빠른 시작 @@ -87,11 +87,11 @@ session = AdvancedSQLiteSession( - `session_id` (str): 대화 세션의 고유 식별자 - `db_path` (str | Path): SQLite 데이터베이스 파일 경로. 인메모리 저장소의 경우 기본값은 `:memory:` - `create_tables` (bool): 고급 테이블을 자동으로 생성할지 여부. 기본값은 `False` -- `logger` (logging.Logger | None): 세션용 사용자 지정 로거. 기본값은 모듈 로거 +- `logger` (logging.Logger | None): 세션의 사용자 지정 로거. 기본값은 모듈 로거 ## 사용량 추적 -AdvancedSQLiteSession은 대화 턴별 토큰 사용량 데이터를 저장하여 상세한 사용량 분석을 제공합니다. **이는 각 에이전트 실행 후 `store_run_usage` 메서드가 호출되는지에 전적으로 의존합니다.** +AdvancedSQLiteSession은 대화 턴별 토큰 사용량 데이터를 저장하여 상세한 사용량 분석을 제공합니다. **이 기능은 각 에이전트 실행 후 `store_run_usage` 메서드를 호출하는지 여부에 전적으로 달려 있습니다.** ### 사용량 데이터 저장 @@ -135,11 +135,11 @@ for turn_data in turn_usage: turn_2_usage = await session.get_turn_usage(user_turn_number=2) ``` -## 대화 분기 +## 대화 브랜칭 -AdvancedSQLiteSession의 핵심 기능 중 하나는 모든 사용자 메시지에서 대화 분기를 생성하여 대체 대화 경로를 탐색할 수 있는 기능입니다. +AdvancedSQLiteSession의 주요 기능 중 하나는 모든 사용자 메시지에서 대화 브랜치를 생성하여 대체 대화 경로를 탐색할 수 있다는 점입니다. -### 분기 생성 +### 브랜치 생성 ```python # Get available turns for branching @@ -165,7 +165,9 @@ branch_id = await session.create_branch_from_content( ) ``` -### 분기 관리 +브랜치 ID는 세션 ID의 수명 동안 고유합니다. 브랜치를 삭제하거나 세션을 지우면 해당 대화 데이터는 제거되지만, 이전에 사용한 브랜치 ID를 다시 사용할 수 있는 것은 아닙니다. 다른 브랜치를 생성할 때는 새로운 이름을 사용하세요. + +### 브랜치 관리 ```python # List all branches @@ -182,7 +184,7 @@ await session.switch_to_branch(branch_id) await session.delete_branch(branch_id, force=True) # force=True allows deleting current branch ``` -### 분기 워크플로 예제 +### 브랜치 워크플로 예제 ```python # Original conversation @@ -217,7 +219,7 @@ await session.store_run_usage(result) ## 구조화된 쿼리 -AdvancedSQLiteSession은 대화 구조와 내용을 분석하기 위한 여러 메서드를 제공합니다. +AdvancedSQLiteSession은 대화 구조와 콘텐츠를 분석하기 위한 여러 메서드를 제공합니다. ### 대화 분석 @@ -247,17 +249,17 @@ for turn in matching_turns: 세션은 다음을 포함한 메시지 구조를 자동으로 추적합니다. -- 메시지 유형(사용자, 어시스턴트, tool_call 등) -- 도구 호출의 도구 이름 +- 메시지 유형(사용자, 어시스턴트, `tool_call` 등) +- 도구 호출에 사용된 도구 이름 - 턴 번호 및 시퀀스 번호 -- 분기 연결 +- 브랜치 연결 관계 - 타임스탬프 ## 데이터베이스 스키마 -AdvancedSQLiteSession은 두 개의 추가 테이블로 기본 SQLite 스키마를 확장합니다. +AdvancedSQLiteSession은 세 개의 테이블을 추가하여 기본 SQLite 스키마를 확장합니다. -### message_structure 테이블 +### `message_structure` 테이블 ```sql CREATE TABLE message_structure ( @@ -276,7 +278,19 @@ CREATE TABLE message_structure ( ); ``` -### turn_usage 테이블 +### `branch_reservations` 테이블 + +```sql +CREATE TABLE branch_reservations ( + session_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + PRIMARY KEY (session_id, branch_id) +); +``` + +이 테이블은 복사된 접두사가 비어 있는 브랜치를 포함하여 브랜치 ID를 원자적으로 예약합니다. 브랜치를 삭제하거나 세션을 지운 후에도 예약 행이 유지되므로, 오래된 세션 인스턴스가 동일한 ID를 재사용한 이후의 브랜치에 기록을 병합할 수 없습니다. + +### `turn_usage` 테이블 ```sql CREATE TABLE turn_usage ( @@ -298,10 +312,10 @@ CREATE TABLE turn_usage ( ## 전체 예제 -모든 기능을 종합적으로 보여 주는 [전체 예제](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)를 확인하세요. +모든 기능을 종합적으로 살펴보려면 [전체 예제](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)를 확인하세요. -## API 참조 +## API 레퍼런스 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 메인 클래스 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 기본 클래스 - [`Session`][agents.memory.session.Session] - 기본 세션 프로토콜 \ No newline at end of file diff --git a/docs/ko/sessions/index.md b/docs/ko/sessions/index.md index 07d4ac8eb5..d6b4e699b1 100644 --- a/docs/ko/sessions/index.md +++ b/docs/ko/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 세션 -Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 내장 세션 메모리를 제공하므로, 턴 사이에 `.to_input_list()`를 수동으로 처리할 필요가 없습니다. +Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 기본 제공 세션 메모리를 지원하므로, 턴 사이에 `.to_input_list()`를 수동으로 처리할 필요가 없습니다. -세션은 특정 세션의 대화 기록을 저장하여, 명시적으로 메모리를 직접 관리하지 않아도 에이전트가 컨텍스트를 유지할 수 있게 합니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. +세션은 특정 세션의 대화 기록을 저장하므로, 명시적인 수동 메모리 관리 없이도 에이전트가 컨텍스트를 유지할 수 있습니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. -SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용하세요. 동일한 실행에서 세션을 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용할 수 없습니다. OpenAI 서버에서 관리하는 대화 연속성을 사용하려면 세션을 추가로 적용하지 말고 이러한 메커니즘 중 하나를 선택하세요. +SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용하세요. 동일한 실행에서 세션을 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용할 수 없습니다. 대신 OpenAI 서버가 관리하는 연속 실행을 원한다면 세션을 추가로 계층화하지 말고 이러한 메커니즘 중 하나를 선택하세요. ## 빠른 시작 @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 동일한 세션을 사용한 인터럽션된 실행 재개 -승인을 위해 실행이 일시 중지되면 동일한 세션 인스턴스 또는 동일한 기반 스토리지를 가리키는 다른 세션 인스턴스로 재개하여, 재개된 턴이 저장된 동일한 대화 기록을 이어가도록 하세요. +승인을 위해 실행이 일시 중지된 경우, 재개된 턴이 저장된 동일한 대화 기록을 이어가도록 동일한 세션 인스턴스(또는 동일한 백엔드 저장소를 가리키는 다른 세션 인스턴스)를 사용하여 실행을 재개하세요. ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -65,29 +65,29 @@ if result.interruptions: ## 핵심 세션 동작 -세션 메모리가 활성화된 경우: +세션 메모리가 활성화되면 다음과 같이 동작합니다. 1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 가져와 입력 항목 앞에 추가합니다. 2. **각 실행 후**: 실행 중 생성된 모든 새 항목(사용자 입력, 어시스턴트 응답, 도구 호출 등)이 세션에 자동으로 저장됩니다. -3. **컨텍스트 보존**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. +3. **컨텍스트 유지**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. -따라서 `.to_input_list()`를 수동으로 호출하고 실행 사이의 대화 상태를 관리할 필요가 없습니다. +따라서 `.to_input_list()`를 수동으로 호출하거나 실행 사이의 대화 상태를 직접 관리할 필요가 없습니다. ## 기록과 새 입력의 병합 방식 제어 -세션을 전달하면 일반적으로 러너는 다음 순서로 모델 입력을 준비합니다. +세션을 전달하면 러너는 일반적으로 다음 순서로 모델 입력을 준비합니다. 1. 세션 기록(`session.get_items(...)`에서 가져옴) 2. 새 턴 입력 -모델 호출 전에 이 병합 단계를 맞춤 설정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 콜백은 다음 두 목록을 받습니다. +모델을 호출하기 전에 이 병합 단계를 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 콜백은 다음 두 목록을 받습니다. - `history`: 가져온 세션 기록(이미 입력 항목 형식으로 정규화됨) - `new_input`: 현재 턴의 새 입력 항목 모델로 전송할 최종 입력 항목 목록을 반환하세요. -콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속하는 항목만 저장합니다. 따라서 이전 기록을 재정렬하거나 필터링해도 기존 세션 항목이 새로운 입력으로 다시 저장되지 않습니다. +콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속한 항목만 저장합니다. 따라서 이전 기록의 순서를 바꾸거나 필터링해도 이전 세션 항목이 새로운 입력으로 다시 저장되지 않습니다. ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,14 +109,14 @@ result = await Runner.run( ) ``` -세션의 항목 저장 방식을 변경하지 않으면서 기록을 맞춤형으로 정리하거나 재정렬하거나 선택적으로 포함해야 할 때 이 기능을 사용하세요. 모델 호출 직전에 최종 처리 단계가 더 필요하면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. +세션의 항목 저장 방식을 변경하지 않고 기록을 사용자 지정 방식으로 정리하거나, 순서를 바꾸거나, 선택적으로 포함해야 할 때 사용하세요. 모델 호출 직전에 최종 처리 단계가 추가로 필요하다면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. ## 가져올 기록 제한 각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]를 사용하세요. -- `SessionSettings(limit=None)`(기본값): 사용 가능한 모든 세션 항목을 가져옵니다 -- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 가져옵니다 +- `SessionSettings(limit=None)`(기본값): 사용 가능한 모든 세션 항목 가져오기 +- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 가져오기 [`RunConfig.session_settings`][agents.run.RunConfig.session_settings]를 통해 실행별로 적용할 수 있습니다. @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -세션 구현에서 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`는 해당 실행에서 `None`이 아닌 값을 재정의합니다. 이는 세션의 기본 동작을 변경하지 않고 가져올 기록의 크기를 제한하려는 긴 대화에 유용합니다. +세션 구현이 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`는 해당 실행에서 `None`이 아닌 모든 값을 재정의합니다. 이는 세션의 기본 동작을 변경하지 않으면서 긴 대화에서 가져올 기록의 크기를 제한하려는 경우 유용합니다. ## 메모리 작업 ### 기본 작업 -세션은 대화 기록을 관리하기 위한 여러 작업을 지원합니다. +세션은 대화 기록 관리를 위한 여러 작업을 지원합니다. ```python from agents import SQLiteSession @@ -165,9 +165,9 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 수정을 위한 pop_item 사용 +### 수정 시 pop_item 사용 -대화의 마지막 항목을 실행 취소하거나 수정하려는 경우 `pop_item` 메서드가 특히 유용합니다. +`pop_item` 메서드는 대화의 마지막 항목을 실행 취소하거나 수정하려는 경우 특히 유용합니다. ```python from agents import Agent, Runner, SQLiteSession @@ -196,30 +196,30 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 내장 세션 구현 +## 기본 제공 세션 구현 SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다. -### 내장 세션 구현 선택 +### 기본 제공 세션 구현 선택 -아래의 상세한 예제를 읽기 전에 이 표를 참고하여 시작점을 선택하세요. +아래의 상세한 예제를 읽기 전에 이 표를 사용하여 시작할 구현을 선택하세요. -| 세션 유형 | 적합한 용도 | 참고 | +| 세션 유형 | 적합한 용도 | 참고 사항 | | --- | --- | --- | -| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 내장형 경량 구현, 파일 기반 또는 인메모리 | +| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 기본 제공, 경량, 파일 기반 또는 인메모리 | | `AsyncSQLiteSession` | `aiosqlite`를 사용하는 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | | `RedisSession` | 여러 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | -| `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스에서 작동 | -| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 스토리지가 필요한 앱 | 비동기 pymongo 사용, 순서 보존을 위한 원자적 시퀀스 카운터 | -| `DaprSession` | Dapr 사이드카를 사용하는 클라우드 네이티브 배포 | 여러 상태 스토어와 TTL 및 일관성 제어 지원 | -| `OpenAIConversationsSession` | OpenAI에서 서버가 관리하는 스토리지 | OpenAI Conversations API 기반 기록 | -| `OpenAIResponsesCompactionSession` | 자동 압축을 사용하는 긴 대화 | 다른 세션 백엔드를 감싸는 래퍼 | -| `AdvancedSQLiteSession` | SQLite와 분기/분석 기능 | 더 많은 기능을 제공하며 전용 페이지 참고 | -| `EncryptedSession` | 다른 세션에 암호화와 TTL 추가 | 래퍼이므로 먼저 기반 백엔드 선택 필요 | +| `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 호환 | +| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 저장소가 필요한 앱 | 비동기 pymongo 사용, 순서 유지를 위한 원자적 시퀀스 카운터 | +| `DaprSession` | Dapr 사이드카를 사용하는 클라우드 네이티브 배포 | 여러 상태 저장소와 TTL 및 일관성 제어 지원 | +| `OpenAIConversationsSession` | OpenAI의 서버 관리형 저장소 | OpenAI Conversations API 기반 기록 | +| `OpenAIResponsesCompactionSession` | 자동 압축이 필요한 긴 대화 | 다른 세션 백엔드를 감싸는 래퍼 | +| `AdvancedSQLiteSession` | SQLite 및 분기/분석 | 더 많은 기능 제공, 전용 페이지 참조 | +| `EncryptedSession` | 다른 세션 위에 암호화 및 TTL 추가 | 래퍼, 먼저 기반 백엔드 선택 필요 | 일부 구현에는 추가 세부 정보를 제공하는 전용 페이지가 있으며, 해당 하위 섹션에 링크되어 있습니다. -ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성에는 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만 ChatKit 스토어를 그대로 대체할 수는 없습니다. [`ChatKit 데이터 스토어 구현에 관한 chatkit-python 가이드`](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참고하세요. +ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성을 위해 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만, ChatKit 저장소를 그대로 대체할 수는 없습니다. [`chatkit-python`의 ChatKit 데이터 저장소 구현 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참조하세요. ### OpenAI Conversations API 세션 @@ -259,7 +259,7 @@ print(result.final_output) # "California" ### OpenAI Responses 압축 세션 -Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 구현은 기반 세션을 감싸며 `should_trigger_compaction`을 기준으로 각 턴 이후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 구현으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. +Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 세션은 기반 세션을 감싸며 `should_trigger_compaction`에 따라 각 턴 후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 세션으로 감싸지 마세요. 두 기능은 기록을 서로 다른 방식으로 관리합니다. #### 일반적인 사용법(자동 압축) @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -기본적으로 압축 후보 임계값에 도달하면 각 턴 이후 압축이 실행됩니다. +기본적으로 후보 항목 수가 임계값에 도달하면 각 턴 후에 압축이 실행됩니다. -Responses API 응답 ID로 이미 턴을 연결하고 있다면 `compaction_mode="previous_response_id"`가 가장 적합합니다. 반면 `compaction_mode="input"`은 현재 세션 항목에서 압축 요청을 다시 구성하므로, 응답 체인을 사용할 수 없거나 세션 내용을 기준 데이터로 사용하려는 경우에 유용합니다. 기본값인 `"auto"`는 사용 가능한 옵션 중 가장 안전한 것을 선택합니다. +Responses API 응답 ID로 이미 턴을 연결하고 있다면 `compaction_mode="previous_response_id"`가 가장 적합합니다. 반면 `compaction_mode="input"`은 현재 세션 항목에서 압축 요청을 다시 구성합니다. 이는 응답 체인을 사용할 수 없거나 세션 콘텐츠를 단일 진실 공급원으로 사용하려는 경우 유용합니다. 기본값인 `"auto"`는 사용 가능한 옵션 중 가장 안전한 옵션을 선택합니다. -에이전트가 `ModelSettings(store=False)`로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보관하지 않습니다. 이러한 무상태 구성에서는 기본 `"auto"` 모드가 `previous_response_id`에 의존하지 않고 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)를 참고하세요. +에이전트가 `ModelSettings(store=False)`로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보관하지 않습니다. 이러한 무상태 설정에서는 기본 `"auto"` 모드가 `previous_response_id`에 의존하지 않고 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)를 참조하세요. #### 자동 압축으로 인한 스트리밍 차단 -압축은 세션 기록을 지우고 다시 작성하므로, SDK는 실행이 완료된 것으로 처리하기 전에 압축이 끝날 때까지 기다립니다. 스트리밍 모드에서는 압축 작업이 많은 경우 마지막 출력 토큰 이후에도 `run.stream_events()`가 몇 초 동안 열린 상태로 유지될 수 있습니다. +압축은 세션 기록을 지우고 다시 작성하므로, SDK는 압축이 완료될 때까지 실행이 완료된 것으로 간주하지 않습니다. 스트리밍 모드에서는 압축 작업이 많은 경우 마지막 출력 토큰 후에도 `run.stream_events()`가 몇 초 동안 열린 상태로 유지될 수 있습니다. -지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요하면 자동 압축을 비활성화하고 턴 사이 또는 유휴 시간에 `run_compaction()`을 직접 호출하세요. 자체 기준에 따라 압축을 강제로 실행할 시점을 결정할 수 있습니다. +지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요한 경우 자동 압축을 비활성화하고 턴 사이(또는 유휴 시간)에 직접 `run_compaction()`을 호출하세요. 자체 기준에 따라 압축을 강제로 실행할 시점을 결정할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 비동기 SQLite 세션 -`aiosqlite` 기반의 SQLite 영속성이 필요하면 `AsyncSQLiteSession`을 사용하세요. +`aiosqlite` 기반 SQLite 영속성이 필요한 경우 `AsyncSQLiteSession`을 사용하세요. ```bash pip install aiosqlite @@ -349,7 +349,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis 세션 -여러 워커 또는 서비스에서 세션 메모리를 공유하려면 `RedisSession`을 사용하세요. +여러 워커 또는 서비스 간에 세션 메모리를 공유하려면 `RedisSession`을 사용하세요. ```bash pip install openai-agents[redis] @@ -368,11 +368,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후에는 세션이 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`를 발생시킵니다. 반복적으로 또는 동시에 `close()`를 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하고 있다면 `redis_client=...`를 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`는 아무 작업도 하지 않으며 호출자가 클라이언트 소유권을 유지하고 세션도 계속 사용할 수 있습니다. +`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 후에는 세션이 종료 상태가 되며 이후 세션 작업에서 `RuntimeError`가 발생합니다. `close()`를 반복해서 또는 동시에 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하고 있다면 `redis_client=...`를 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`는 아무 작업도 수행하지 않으며 호출자가 클라이언트의 소유권과 세션의 사용 가능 상태를 모두 유지합니다. ### SQLAlchemy 세션 -SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션 수준의 Agents SDK 세션 영속성 구현입니다. +SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션용 Agents SDK 세션 영속성 구현입니다. ```python from agents.extensions.memory import SQLAlchemySession @@ -390,11 +390,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참고하세요. +자세한 내용은 [SQLAlchemy 세션](sqlalchemy_session.md)을 참조하세요. ### Dapr 세션 -이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 다양한 상태 스토어 백엔드 간에 이동할 수 있는 세션 스토리지가 필요하면 `DaprSession`을 사용하세요. +이미 Dapr 사이드카를 실행하고 있거나 에이전트 코드를 변경하지 않고 여러 상태 저장소 백엔드 간에 이동할 수 있는 세션 저장소가 필요한 경우 `DaprSession`을 사용하세요. ```bash pip install openai-agents[dapr] @@ -415,19 +415,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -참고: +참고 사항: - `from_address(...)`는 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리하고 있다면 `dapr_client=...`를 사용하여 `DaprSession(...)`을 직접 생성하세요. -- 컨텍스트에서 나가거나 `close()`를 호출하면 클라이언트를 소유한 세션은 종료 상태가 됩니다. 이후 세션 작업은 `RuntimeError`를 발생시키지만, 반복적으로 또는 동시에 `close()`를 호출해도 안전합니다. 주입된 클라이언트를 사용하면 `close()`는 아무 작업도 하지 않으며 세션을 계속 사용할 수 있습니다. -- 상태 스토어에서 TTL을 지원하는 경우 오래된 세션 데이터가 자동으로 만료되도록 하려면 `ttl=...`을 전달하세요. -- 쓰기 직후 읽기에 대해 더 강한 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG`을 전달하세요. -- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발 환경에서는 `dapr_address`에 사용되는 gRPC 포트뿐만 아니라 `--dapr-http-port 3500`도 지정하여 Dapr를 시작하세요. -- 로컬 구성 요소와 문제 해결 방법을 포함한 전체 설정 과정은 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참고하세요. +- 컨텍스트에서 나가거나 `close()`를 호출하면 소유 클라이언트 세션이 종료 상태가 되며 이후 세션 작업에서 `RuntimeError`가 발생합니다. 단, `close()`를 반복해서 또는 동시에 호출해도 안전합니다. 주입된 클라이언트를 사용하는 경우 `close()`는 아무 작업도 수행하지 않으며 세션은 계속 사용할 수 있습니다. +- 기반 상태 저장소에서 TTL을 지원하는 경우 `ttl=...`을 전달하면 오래된 세션 데이터가 자동으로 만료됩니다. +- 쓰기 직후 읽기에 대한 더 강력한 보장이 필요한 경우 `consistency=DAPR_CONSISTENCY_STRONG`을 전달하세요. +- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발 시 `dapr_address`에서 사용하는 gRPC 포트뿐 아니라 `--dapr-http-port 3500`을 지정하여 Dapr를 시작하세요. +- 로컬 구성 요소와 문제 해결을 포함한 전체 설정 절차는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참조하세요. ### MongoDB 세션 -이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 `MongoDBSession`을 사용하세요. +이미 MongoDB를 사용하는 애플리케이션이나 수평 확장이 가능한 다중 프로세스 세션 저장소가 필요한 경우 `MongoDBSession`을 사용하세요. ```bash pip install openai-agents[mongodb] @@ -450,12 +450,12 @@ print(result.final_output) await session.close() ``` -참고: +참고 사항: -- `from_uri(...)`는 `AsyncMongoClient`를 생성하고 소유하며 `session.close()` 호출 시 이를 닫습니다. 애플리케이션에서 이미 클라이언트를 관리하고 있다면 `client=...`를 사용하여 `MongoDBSession(...)`을 직접 생성하세요. 이 경우 `session.close()`는 아무 작업도 하지 않으며 수명 주기는 호출자가 관리합니다. +- `from_uri(...)`는 `AsyncMongoClient`를 생성하고 소유하며 `session.close()` 호출 시 이를 닫습니다. 소유 클라이언트 세션은 `close()` 후 종료 상태가 되며 이후 세션 작업에서 `RuntimeError`가 발생합니다. 애플리케이션에서 이미 클라이언트를 관리하고 있다면 `client=...`를 사용하여 `MongoDBSession(...)`을 직접 생성하세요. 이 경우 `session.close()`는 아무 작업도 수행하지 않으며 수명 주기 및 세션 사용 가능 여부는 호출자가 관리합니다. - 다른 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결할 수 있습니다. -- 두 개의 컬렉션이 사용되며, 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`)과 `messages_collection=`(기본값 `agent_messages`)을 통해 설정할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 각 메시지 문서에는 단조 증가하는 `seq` 카운터가 포함되어 동시 작성자와 여러 프로세스 간에도 순서를 보존합니다. -- 첫 실행 전에 연결을 확인하려면 `await session.ping()`을 사용하세요. +- 두 개의 컬렉션이 사용되며, 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`) 및 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 각 메시지 문서에는 단조 증가하는 `seq` 카운터가 포함되어 동시 작성자와 프로세스 전반에서 순서를 유지합니다. +- 첫 번째 실행 전에 `await session.ping()`을 사용하여 연결 상태를 확인하세요. ### 고급 SQLite 세션 @@ -479,11 +479,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참고하세요. +자세한 내용은 [고급 SQLite 세션](advanced_sqlite_session.md)을 참조하세요. ### 암호화된 세션 -모든 세션 구현에 적용할 수 있는 투명한 암호화 래퍼입니다. +모든 세션 구현을 위한 투명한 암호화 래퍼입니다. ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -506,17 +506,17 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -자세한 문서는 [암호화된 세션](encrypted_session.md)을 참고하세요. +자세한 내용은 [암호화된 세션](encrypted_session.md)을 참조하세요. ### 기타 세션 유형 -그 밖에도 몇 가지 내장 옵션이 있습니다. `examples/memory/`와 `extensions/memory/`의 소스 코드를 참고하세요. +이 밖에도 몇 가지 기본 제공 옵션이 있습니다. `examples/memory/` 및 `extensions/memory/` 아래의 소스 코드를 참조하세요. ## 운영 패턴 -### 세션 ID 명명법 +### 세션 ID 명명 -대화를 정리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. +대화를 체계적으로 정리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. - 사용자 기반: `"user_12345"` - 스레드 기반: `"thread_abc123"` @@ -524,16 +524,16 @@ result = await Runner.run(agent, "Hello", session=session) ### 메모리 영속성 -- 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`)를 사용합니다 -- 영구 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)를 사용합니다 -- `aiosqlite` 기반 구현이 필요하면 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)를 사용합니다 -- 공유되는 저지연 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`)을 사용합니다 -- SQLAlchemy에서 지원하는 기존 데이터베이스가 있는 프로덕션 시스템에는 SQLAlchemy 기반 세션(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)을 사용합니다 -- 이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)을 사용합니다 -- 내장된 텔레메트리, 트레이싱 및 데이터 격리 기능과 30개 이상의 데이터베이스 백엔드를 지원하는 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 스토어 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)을 사용합니다 -- OpenAI Conversations API에 기록을 저장하려면 OpenAI 호스팅 스토리지(`OpenAIConversationsSession()`)를 사용합니다 -- 모든 세션에 투명한 암호화와 TTL 기반 만료를 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`)을 사용합니다 -- 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 맞춤형 세션 백엔드 구현을 고려합니다 +- 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`) 사용 +- 영구 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) 사용 +- `aiosqlite` 기반 구현이 필요한 경우 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) 사용 +- 공유되는 지연 시간이 짧은 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`) 사용 +- SQLAlchemy가 지원하는 기존 데이터베이스를 사용하는 프로덕션 시스템에는 SQLAlchemy 기반 세션(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) 사용 +- 이미 MongoDB를 사용하거나 다중 프로세스 및 수평 확장이 가능한 세션 저장소가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) 사용 +- 기본 제공 텔레메트리, 트레이싱 및 데이터 격리와 30개 이상의 데이터베이스 백엔드를 지원하는 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 +- OpenAI Conversations API에 기록을 저장하려는 경우 OpenAI 호스트 저장소(`OpenAIConversationsSession()`) 사용 +- 모든 세션을 투명한 암호화 및 TTL 기반 만료 기능으로 감싸려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 +- 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 사용자 지정 세션 백엔드 구현 고려 ### 여러 세션 @@ -581,7 +581,7 @@ result2 = await Runner.run( ## 전체 예제 -다음은 세션 메모리의 실제 동작을 보여 주는 전체 예제입니다. +다음은 세션 메모리의 실제 동작을 보여주는 전체 예제입니다. ```python import asyncio @@ -643,7 +643,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 맞춤형 세션 구현 +## 사용자 지정 세션 구현 [`Session`][agents.memory.session.Session] 프로토콜을 따르는 클래스를 생성하여 자체 세션 메모리를 구현할 수 있습니다. @@ -696,11 +696,11 @@ result = await Runner.run( |---------|-------------| | [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 위한 Django ORM 기반 세션 | -세션 구현을 개발했다면 여기에 추가할 수 있도록 문서 PR을 자유롭게 제출해 주세요! +세션 구현을 구축했다면 여기에 추가할 수 있도록 언제든지 문서 PR을 제출해 주세요! ## API 레퍼런스 -자세한 API 문서는 다음을 참고하세요. +자세한 API 문서는 다음을 참조하세요. - [`Session`][agents.memory.session.Session] - 프로토콜 인터페이스 - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 구현 @@ -710,6 +710,6 @@ result = await Runner.run( - [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 기반 세션 구현 - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy 기반 구현 - [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 기반 세션 구현 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 상태 스토어 구현 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 상태 저장소 구현 - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기 및 분석 기능을 갖춘 향상된 SQLite - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 모든 세션을 위한 암호화 래퍼 \ No newline at end of file diff --git a/docs/zh/sessions/advanced_sqlite_session.md b/docs/zh/sessions/advanced_sqlite_session.md index d52d39c585..923ca0f63d 100644 --- a/docs/zh/sessions/advanced_sqlite_session.md +++ b/docs/zh/sessions/advanced_sqlite_session.md @@ -4,15 +4,15 @@ search: --- # 高级 SQLite 会话 -`AdvancedSQLiteSession` 是基础 `SQLiteSession` 的增强版本,提供高级对话管理能力,包括对话分支、详细的使用情况分析以及结构化对话查询。 +`AdvancedSQLiteSession` 是基础 `SQLiteSession` 的增强版本,提供高级会话管理功能,包括会话分支、详细的使用情况分析和结构化会话查询。 ## 功能 -- **对话分支**:从任意用户消息创建替代对话路径 -- **使用情况追踪**:按轮次提供详细的 token 使用情况分析,并包含完整的 JSON 明细 -- **结构化查询**:按轮次获取对话、工具使用统计等 -- **分支管理**:独立的分支切换与管理 -- **消息结构元数据**:跟踪消息类型、工具使用情况和对话流程 +- **会话分支**:从任意用户消息创建不同的会话路径 +- **使用情况追踪**:提供每轮详细的 token 使用情况分析及完整的 JSON 明细 +- **结构化查询**:按轮次获取会话、工具使用情况统计等信息 +- **分支管理**:独立切换和管理分支 +- **消息结构元数据**:追踪消息类型、工具使用情况和会话流程 ## 快速开始 @@ -84,14 +84,14 @@ session = AdvancedSQLiteSession( ### 参数 -- `session_id` (str):对话会话的唯一标识符 -- `db_path` (str | Path):SQLite 数据库文件路径。默认为 `:memory:`,用于内存存储 -- `create_tables` (bool):是否自动创建高级表。默认为 `False` -- `logger` (logging.Logger | None):用于会话的自定义日志记录器。默认为模块日志记录器 +- `session_id` (str):会话 session 的唯一标识符 +- `db_path` (str | Path):SQLite 数据库文件的路径。默认值为 `:memory:`,用于内存存储 +- `create_tables` (bool):是否自动创建高级数据表。默认值为 `False` +- `logger` (logging.Logger | None):会话的自定义日志记录器。默认使用模块日志记录器 ## 使用情况追踪 -AdvancedSQLiteSession 通过按对话轮次存储 token 使用情况数据,提供详细的使用情况分析。**这完全依赖于在每次智能体运行后调用 `store_run_usage` 方法。** +AdvancedSQLiteSession 通过存储每轮会话的 token 使用情况数据,提供详细的使用情况分析。**这完全取决于是否在每次智能体运行后调用 `store_run_usage` 方法。** ### 使用情况数据存储 @@ -135,9 +135,9 @@ for turn_data in turn_usage: turn_2_usage = await session.get_turn_usage(user_turn_number=2) ``` -## 对话分支 +## 会话分支 -AdvancedSQLiteSession 的关键功能之一是能够从任意用户消息创建对话分支,从而让你探索替代的对话路径。 +AdvancedSQLiteSession 的一项关键功能是能够从任意用户消息创建会话分支,以便探索不同的会话路径。 ### 分支创建 @@ -165,6 +165,8 @@ branch_id = await session.create_branch_from_content( ) ``` +在一个会话 ID 的整个生命周期内,分支 ID 都是唯一的。删除分支或清除会话会移除其会话数据,但不会使之前使用过的分支 ID 再次可用;创建其他分支时,请使用新名称。 + ### 分支管理 ```python @@ -217,9 +219,9 @@ await session.store_run_usage(result) ## 结构化查询 -AdvancedSQLiteSession 提供了多种方法,用于分析对话结构和内容。 +AdvancedSQLiteSession 提供多种方法,用于分析会话的结构和内容。 -### 对话分析 +### 会话分析 ```python # Get conversation organized by turns @@ -245,17 +247,17 @@ for turn in matching_turns: ### 消息结构 -会话会自动跟踪消息结构,包括: +会话会自动追踪消息结构,包括: -- 消息类型(用户、assistant、tool_call 等) -- 工具调用的工具名称 -- 轮次编号和序列号 -- 分支关联 +- 消息类型(用户、助手、工具调用等) +- 工具调用对应的工具名称 +- 轮次编号和序列编号 +- 分支关联关系 - 时间戳 ## 数据库架构 -AdvancedSQLiteSession 在基础 SQLite 架构之上扩展了两个额外的表: +AdvancedSQLiteSession 在基础 SQLite 架构之上新增了三个表: ### message_structure 表 @@ -276,6 +278,18 @@ CREATE TABLE message_structure ( ); ``` +### branch_reservations 表 + +```sql +CREATE TABLE branch_reservations ( + session_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + PRIMARY KEY (session_id, branch_id) +); +``` + +此表以原子方式预留分支 ID,也包括所复制前缀为空的分支。删除分支和清除会话后,预留记录仍会保留,从而防止过期的会话实例将历史记录合并到之后复用同一 ID 的分支中。 + ### turn_usage 表 ```sql @@ -298,7 +312,7 @@ CREATE TABLE turn_usage ( ## 完整示例 -查看[完整示例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py),全面了解所有功能。 +请查看[完整示例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py),全面了解所有功能。 ## API 参考 diff --git a/docs/zh/sessions/index.md b/docs/zh/sessions/index.md index daf1213fda..f72d8150b6 100644 --- a/docs/zh/sessions/index.md +++ b/docs/zh/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 会话 -Agents SDK提供内置会话记忆,可在多次智能体运行之间自动维护对话历史,无需在轮次之间手动处理 `.to_input_list()`。 +Agents SDK提供内置会话内存,可在多次智能体运行之间自动维护对话历史记录,无需在不同轮次之间手动处理`.to_input_list()`。 -会话会存储特定会话的对话历史,使智能体无需显式的手动记忆管理即可维护上下文。这对于构建聊天应用或多轮对话尤其有用,因为你希望智能体记住之前的交互。 +会话存储特定会话的对话历史记录,使智能体无需显式手动管理内存即可保持上下文。这对于构建聊天应用或多轮对话尤其有用,因为在这些场景中,你希望智能体能够记住先前的交互。 -当你希望 SDK 为你管理客户端侧记忆时,请使用会话。在同一次运行中,会话不能与 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 结合使用。如果希望改用由OpenAI服务管理的延续机制,请选择其中一种机制,而不是在其上叠加会话。 +如果希望由 SDK 为你管理客户端内存,请使用会话。在同一次运行中,会话不能与`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果希望改用由OpenAI服务端管理的延续机制,请选择其中一种机制,而不要在其上叠加会话。 ## 快速入门 @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 使用同一会话恢复中断的运行 +## 中断运行的同会话恢复 -如果运行因等待批准而暂停,请使用同一会话实例(或指向同一底层存储的另一个会话实例)恢复运行,以便恢复后的轮次继续使用同一份已存储对话历史。 +如果运行因等待批准而暂停,请使用同一个会话实例(或指向同一底层存储的另一个会话实例)恢复运行,以便恢复后的轮次能够延续相同的已存储对话历史记录。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -63,31 +63,31 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## 会话的核心行为 +## 核心会话行为 -启用会话记忆后: +启用会话内存后: -1. **每次运行前**:运行器会自动检索该会话的对话历史,并将其添加到输入条目之前。 -2. **每次运行后**:运行期间生成的所有新条目(用户输入、助手响应、工具调用等)都会自动存储到会话中。 -3. **上下文保留**:之后每次使用同一会话运行时,都会包含完整的对话历史,使智能体能够维护上下文。 +1. **每次运行之前**:运行器会自动检索会话的对话历史记录,并将其添加到输入项之前。 +2. **每次运行之后**:运行期间生成的所有新项目(用户输入、助手回复、工具调用等)都会自动存储到会话中。 +3. **上下文保留**:之后使用同一会话的每次运行都会包含完整的对话历史记录,使智能体能够保持上下文。 -这样便无需手动调用 `.to_input_list()`,也无需在运行之间管理对话状态。 +这样便无需手动调用`.to_input_list()`并在不同运行之间管理对话状态。 ## 历史记录与新输入的合并控制 传入会话时,运行器通常会按以下顺序准备模型输入: -1. 会话历史(从 `session.get_items(...)` 检索) +1. 会话历史记录(从`session.get_items(...)`检索) 2. 新轮次输入 -使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 可在调用模型之前自定义该合并步骤。回调会接收两个列表: +使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可在调用模型之前自定义该合并步骤。该回调接收两个列表: -- `history`:检索到的会话历史(已规范化为输入条目格式) -- `new_input`:当前轮次的新输入条目 +- `history`:检索到的会话历史记录(已规范化为输入项格式) +- `new_input`:当前轮次的新输入项 -返回应发送给模型的最终输入条目列表。 +返回应发送给模型的最终输入项列表。 -回调接收的是两个列表的副本,因此你可以安全地修改它们。返回的列表会控制该轮次的模型输入,但 SDK 仍只持久化属于新轮次的条目。因此,对旧历史记录进行重新排序或筛选,不会导致旧会话条目被再次保存为新输入。 +该回调接收这两个列表的副本,因此你可以安全地修改它们。返回的列表控制该轮次的模型输入,但 SDK 仍然只会持久化属于新轮次的项目。因此,重新排序或筛选旧历史记录不会导致旧会话项再次作为新输入保存。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -当你需要自定义历史记录的裁剪、重新排序或选择性纳入方式,同时又不改变会话存储条目的方式时,请使用此功能。如果需要在模型调用前立即执行后续的最终处理,请使用[运行智能体指南](../running_agents.md)中的 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 +如果需要自定义历史记录的裁剪、重新排序或选择性纳入方式,同时又不改变会话存储项目的方式,请使用此功能。如果需要在调用模型前立即进行最后一次处理,请使用[运行智能体指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 -## 历史记录检索限制 +## 检索历史记录限制 -使用 [`SessionSettings`][agents.memory.SessionSettings] 控制每次运行前获取的历史记录量。 +使用[`SessionSettings`][agents.memory.SessionSettings]控制每次运行前获取的历史记录量。 -- `SessionSettings(limit=None)`(默认):检索所有可用的会话条目 -- `SessionSettings(limit=N)`:仅检索最近的 `N` 个条目 +- `SessionSettings(limit=None)`(默认):检索所有可用的会话项 +- `SessionSettings(limit=N)`:仅检索最近的`N`个项目 -可以通过 [`RunConfig.session_settings`][agents.run.RunConfig.session_settings] 将此设置应用于单次运行: +你可以通过[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]按运行应用此设置: ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -如果会话实现提供默认会话设置,`RunConfig.session_settings` 会在该次运行中覆盖所有非 `None` 值。这对于长对话非常有用,可以限制检索量而无需更改会话的默认行为。 +如果会话实现提供默认会话设置,`RunConfig.session_settings`会在该次运行中覆盖所有非`None`值。这适用于较长的对话,可在不更改会话默认行为的情况下限制检索量。 -## 记忆操作 +## 内存操作 ### 基本操作 -会话支持多种对话历史管理操作: +会话支持多种对话历史记录管理操作: ```python from agents import SQLiteSession @@ -167,7 +167,7 @@ await session.clear_session() ### 使用 pop_item 进行修正 -当你想撤销或修改对话中的最后一个条目时,`pop_item` 方法尤其有用: +当需要撤销或修改对话中的最后一个项目时,`pop_item`方法尤其有用: ```python from agents import Agent, Runner, SQLiteSession @@ -202,28 +202,28 @@ SDK 针对不同使用场景提供了多种会话实现: ### 内置会话实现的选择 -阅读下方详细示例之前,可使用此表选择起点。 +阅读下方详细示例之前,可使用此表选择一个起点。 | 会话类型 | 最适用场景 | 说明 | | --- | --- | --- | -| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可由文件支持或在内存中运行 | -| `AsyncSQLiteSession` | 使用 `aiosqlite` 的异步 SQLite | 支持异步驱动程序的扩展后端 | -| `RedisSession` | 跨工作进程或服务共享记忆 | 适合低延迟分布式部署 | -| `SQLAlchemySession` | 使用现有数据库的生产应用 | 适用于 SQLAlchemy 支持的数据库 | -| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;使用原子序列计数器保持顺序 | -| `DaprSession` | 使用 Dapr sidecar 的云原生部署 | 支持多种状态存储,以及 TTL 和一致性控制 | -| `OpenAIConversationsSession` | 由OpenAI服务管理的存储 | 由OpenAI Conversations API 支持的历史记录 | +| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可使用文件或内存作为后端 | +| `AsyncSQLiteSession` | 通过`aiosqlite`使用异步 SQLite | 支持异步驱动程序的扩展后端 | +| `RedisSession` | 跨工作进程或服务共享内存 | 适合低延迟分布式部署 | +| `SQLAlchemySession` | 使用现有数据库的生产应用 | 支持 SQLAlchemy 所支持的数据库 | +| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;使用原子序列计数器保证顺序 | +| `DaprSession` | 使用 Dapr 边车的云原生部署 | 支持多种状态存储以及 TTL 和一致性控制 | +| `OpenAIConversationsSession` | 由OpenAI服务端管理的存储 | 基于OpenAI Conversations API的历史记录 | | `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 对另一种会话后端的封装 | -| `AdvancedSQLiteSession` | 支持分支和分析的 SQLite | 功能集更丰富;请参阅专门页面 | -| `EncryptedSession` | 在另一会话之上提供加密和 TTL | 封装器;请先选择底层后端 | +| `AdvancedSQLiteSession` | 需要分支和分析功能的 SQLite | 功能集较为丰富;请参阅专属页面 | +| `EncryptedSession` | 在另一种会话之上提供加密和 TTL | 封装器;请先选择底层后端 | -某些实现拥有提供更多详细信息的专门页面;其链接位于对应小节中。 +某些实现具有包含更多详细信息的专属页面,其链接已内嵌在相应小节中。 -如果你正在为 ChatKit 实现 Python 服务,请使用 `chatkit.store.Store` 实现来持久化 ChatKit 的线程和条目。`SQLAlchemySession` 等 Agents SDK会话用于管理 SDK 侧的对话历史,但不能直接替代 ChatKit 的存储。请参阅 [`chatkit-python` 中有关实现 ChatKit 数据存储的指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 +如果你正在为 ChatKit 实现 Python 服务,请使用`chatkit.store.Store`实现来持久化 ChatKit 的线程和项目。`SQLAlchemySession`等Agents SDK会话负责管理 SDK 侧的对话历史记录,但不能直接替代 ChatKit 的存储。请参阅[`chatkit-python`中的 ChatKit 数据存储实现指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 ### OpenAI Conversations API 会话 -通过 `OpenAIConversationsSession` 使用 [OpenAI的 Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 +通过`OpenAIConversationsSession`使用[OpenAI的 Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -259,7 +259,7 @@ print(result.final_output) # "California" ### OpenAI Responses 压缩会话 -使用 `OpenAIResponsesCompactionSession` 通过 Responses API(`responses.compact`)压缩已存储的对话历史。它会封装一个底层会话,并可根据 `should_trigger_compaction` 在每个轮次后自动执行压缩。不要用它封装 `OpenAIConversationsSession`;这两项功能管理历史记录的方式不同。 +使用`OpenAIResponsesCompactionSession`通过 Responses API(`responses.compact`)压缩已存储的对话历史记录。它会封装底层会话,并可根据`should_trigger_compaction`在每个轮次后自动进行压缩。请勿用它封装`OpenAIConversationsSession`;这两项功能采用不同的方式管理历史记录。 #### 典型用法(自动压缩) @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -默认情况下,一旦达到候选阈值,每个轮次后都会执行压缩。 +默认情况下,达到候选阈值后,每个轮次结束时都会运行压缩。 -当你已经使用 Responses API 响应 ID 串联各轮次时,`compaction_mode="previous_response_id"` 效果最佳。`compaction_mode="input"` 则根据当前会话条目重新构建压缩请求,适用于响应链不可用,或你希望将会话内容作为权威数据源的情况。默认值 `"auto"` 会选择最安全的可用选项。 +当你已通过 Responses API 响应 ID 串联各轮次时,`compaction_mode="previous_response_id"`效果最佳。`compaction_mode="input"`则会根据当前会话项重新构建压缩请求,适用于响应链不可用或希望将会话内容作为权威数据源的情况。默认值`"auto"`会选择最安全的可用选项。 -如果智能体使用 `ModelSettings(store=False)` 运行,Responses API 不会保留最后一次响应供后续查询。在这种无状态配置中,默认的 `"auto"` 模式会改用基于输入的压缩,而不依赖 `previous_response_id`。完整示例请参阅 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 +如果智能体使用`ModelSettings(store=False)`运行,Responses API 不会保留最后一次响应以供后续查找。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不依赖`previous_response_id`。完整示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 -#### 自动压缩造成的流式传输阻塞 +#### 自动压缩对流式传输的阻塞 -压缩会清除并重写会话历史,因此 SDK 会等待压缩完成后,才将运行视为已完成。在流式传输模式下,如果压缩负载较重,这意味着最后一个输出 token 生成后,`run.stream_events()` 可能还会保持打开数秒。 +压缩会清除并重写会话历史记录,因此 SDK 会等待压缩完成后,才将运行视为完成。在流式传输模式下,如果压缩任务较重,这意味着`run.stream_events()`可能会在输出最后一个 token 后继续保持打开数秒。 -如果你需要低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲时)自行调用 `run_compaction()`。你可以根据自己的条件决定何时强制执行压缩。 +如果希望实现低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲期间)自行调用`run_compaction()`。你可以根据自己的标准决定何时强制压缩。 ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 异步 SQLite 会话 -如果希望使用由 `aiosqlite` 支持的 SQLite 持久化,请使用 `AsyncSQLiteSession`。 +如果希望使用由`aiosqlite`支持的 SQLite 持久化,请使用`AsyncSQLiteSession`。 ```bash pip install aiosqlite @@ -349,7 +349,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis 会话 -使用 `RedisSession` 可在多个工作进程或服务之间共享会话记忆。 +使用`RedisSession`可在多个工作进程或服务之间共享会话内存。 ```bash pip install openai-agents[redis] @@ -368,11 +368,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)` 会创建并拥有 Redis 客户端。调用 `close()` 后,会话将进入终止状态,后续会话操作会引发 `RuntimeError`;重复或并发调用 `close()` 是安全的。如果应用已管理 Redis 客户端,请直接构造 `RedisSession(...)` 并传入 `redis_client=...`。在这种情况下,`close()` 不执行任何操作,调用方仍拥有客户端所有权,会话也仍可使用。 +`from_url(...)`会创建并拥有 Redis 客户端。调用`close()`后,会话将进入终止状态,后续会话操作会引发`RuntimeError`;重复或并发调用`close()`是安全的。如果应用已经管理 Redis 客户端,请通过`redis_client=...`直接构造`RedisSession(...)`。在这种情况下,`close()`不会执行任何操作,调用方仍拥有客户端,并且会话仍然可用。 ### SQLAlchemy 会话 -使用任何 SQLAlchemy 支持的数据库,为 Agents SDK提供可用于生产环境的会话持久化: +使用 SQLAlchemy 所支持的任意数据库,为Agents SDK提供可用于生产环境的会话持久化: ```python from agents.extensions.memory import SQLAlchemySession @@ -390,11 +390,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -详细文档请参阅 [SQLAlchemy 会话](sqlalchemy_session.md)。 +有关详细文档,请参阅[SQLAlchemy 会话](sqlalchemy_session.md)。 ### Dapr 会话 -如果你已运行 Dapr sidecar,或希望在不更改智能体代码的情况下,让会话存储可在不同状态存储后端之间迁移,请使用 `DaprSession`。 +如果你已经运行 Dapr 边车,或希望会话存储能在不同状态存储后端之间迁移而无需更改智能体代码,请使用`DaprSession`。 ```bash pip install openai-agents[dapr] @@ -417,17 +417,17 @@ async with DaprSession.from_address( 注意: -- `from_address(...)` 会为你创建并拥有 Dapr 客户端。如果应用已管理客户端,请直接构造 `DaprSession(...)` 并传入 `dapr_client=...`。 -- 退出上下文或调用 `close()` 会使拥有客户端的会话进入终止状态;后续会话操作会引发 `RuntimeError`,但重复或并发调用 `close()` 是安全的。使用注入的客户端时,`close()` 不执行任何操作,会话仍可使用。 -- 当底层状态存储支持 TTL 时,传入 `ttl=...` 可让其自动使旧会话数据过期。 -- 当需要更强的写后读保证时,传入 `consistency=DAPR_CONSISTENCY_STRONG`。 -- Dapr Python SDK 还会检查 HTTP sidecar 端点。在本地开发中,除了 `dapr_address` 使用的 gRPC 端口之外,还应使用 `--dapr-http-port 3500` 启动 Dapr。 -- 完整的设置演练(包括本地组件和故障排除)请参阅 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 +- `from_address(...)`会为你创建并拥有 Dapr 客户端。如果应用已经管理 Dapr 客户端,请通过`dapr_client=...`直接构造`DaprSession(...)`。 +- 退出上下文或调用`close()`会使拥有客户端的会话进入终止状态;后续会话操作会引发`RuntimeError`,但重复或并发调用`close()`是安全的。使用注入客户端时,`close()`不会执行任何操作,会话仍然可用。 +- 传入`ttl=...`可在底层状态存储支持 TTL 时,使其自动让旧会话数据过期。 +- 当需要更强的写后读保证时,传入`consistency=DAPR_CONSISTENCY_STRONG`。 +- Dapr Python SDK 还会检查 HTTP 边车端点。在本地开发中,启动 Dapr 时,除了`dapr_address`中使用的 gRPC 端口外,还应指定`--dapr-http-port 3500`。 +- 有关包括本地组件和问题排查在内的完整设置演练,请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 ### MongoDB 会话 -对于已使用 MongoDB,或需要可横向扩展的多进程会话存储的应用,请使用 `MongoDBSession`。 +对于已经使用 MongoDB 或需要可横向扩展的多进程会话存储的应用,请使用`MongoDBSession`。 ```bash pip install openai-agents[mongodb] @@ -452,14 +452,14 @@ await session.close() 注意: -- `from_uri(...)` 会创建并拥有 `AsyncMongoClient`,并在调用 `session.close()` 时将其关闭。如果应用已管理客户端,请直接构造 `MongoDBSession(...)` 并传入 `client=...`;在这种情况下,`session.close()` 不执行任何操作,生命周期仍由调用方管理。 -- 若要连接到 [MongoDB Atlas](https://www.mongodb.com/products/platform),只需向 `from_uri(...)` 传入 `mongodb+srv://user:password@cluster.example.mongodb.net` URI,无需进行其他更改。 -- 系统会使用两个集合,二者的名称均可配置:通过 `sessions_collection=` 配置会话集合(默认值为 `agent_sessions`),通过 `messages_collection=` 配置消息集合(默认值为 `agent_messages`)。首次使用时会自动创建索引。每个消息文档都包含一个单调递增的 `seq` 计数器,可在并发写入方和进程之间保持顺序。 -- 在首次运行前,使用 `await session.ping()` 验证连接。 +- `from_uri(...)`会创建并拥有`AsyncMongoClient`,并在调用`session.close()`时将其关闭。调用`close()`后,拥有客户端的会话将进入终止状态,后续会话操作会引发`RuntimeError`。如果应用已经管理客户端,请通过`client=...`直接构造`MongoDBSession(...)`;在这种情况下,`session.close()`不会执行任何操作,生命周期管理和会话可用性由调用方负责。 +- 要连接到[MongoDB Atlas](https://www.mongodb.com/products/platform),只需向`from_uri(...)`传入`mongodb+srv://user:password@cluster.example.mongodb.net`URI,无需进行其他更改。 +- 系统会使用两个集合,其名称都可分别通过`sessions_collection=`(默认为`agent_sessions`)和`messages_collection=`(默认为`agent_messages`)进行配置。首次使用时会自动创建索引。每个消息文档都带有单调递增的`seq`计数器,可在并发写入进程和多个进程之间保持顺序。 +- 首次运行之前,使用`await session.ping()`验证连接。 ### 高级 SQLite 会话 -增强型 SQLite 会话,支持对话分支、用量分析和结构化查询: +支持对话分支、用量分析和结构化查询的增强型 SQLite 会话: ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -479,7 +479,7 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -详细文档请参阅[高级 SQLite 会话](advanced_sqlite_session.md)。 +有关详细文档,请参阅[高级 SQLite 会话](advanced_sqlite_session.md)。 ### 加密会话 @@ -506,34 +506,34 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -详细文档请参阅[加密会话](encrypted_session.md)。 +有关详细文档,请参阅[加密会话](encrypted_session.md)。 ### 其他会话类型 -此外还有一些内置选项。请参阅 `examples/memory/` 以及 `extensions/memory/` 下的源代码。 +还有少量其他内置选项。请参阅`examples/memory/`和`extensions/memory/`下的源代码。 -## 运维模式 +## 操作模式 ### 会话 ID 命名 -使用有意义的会话 ID 以便组织对话: +使用有意义的会话 ID 来帮助组织对话: - 基于用户:`"user_12345"` - 基于线程:`"thread_abc123"` - 基于上下文:`"support_ticket_456"` -### 记忆持久化 +### 内存持久化 -- 对于临时对话,使用内存 SQLite(`SQLiteSession("session_id")`) -- 对于持久化对话,使用基于文件的 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) -- 当需要基于 `aiosqlite` 的实现时,使用异步 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) -- 对于共享的低延迟会话记忆,使用 Redis 支持的会话(`RedisSession.from_url("session_id", url="redis://...")`) -- 对于拥有 SQLAlchemy 所支持现有数据库的生产系统,使用基于 SQLAlchemy 的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) -- 对于已使用 MongoDB,或需要多进程、可横向扩展会话存储的应用,使用 MongoDB 会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) -- 对于生产环境中的云原生部署,使用 Dapr 状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),它支持 30 多种数据库后端,并内置遥测、追踪和数据隔离功能 -- 如果希望将历史记录存储在 OpenAI Conversations API 中,请使用 OpenAI托管的存储(`OpenAIConversationsSession()`) -- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)为任意会话提供透明加密和基于 TTL 的过期机制 -- 对于更高级的使用场景,可以考虑为其他生产系统(例如 Django)实现自定义会话后端 +- 对临时对话使用内存 SQLite(`SQLiteSession("session_id")`) +- 对持久对话使用基于文件的 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) +- 需要基于`aiosqlite`的实现时,使用异步 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) +- 对共享的低延迟会话内存使用 Redis 后端会话(`RedisSession.from_url("session_id", url="redis://...")`) +- 对使用 SQLAlchemy 所支持现有数据库的生产系统,使用由 SQLAlchemy 提供支持的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) +- 对已经使用 MongoDB 或需要多进程、可横向扩展会话存储的应用,使用 MongoDB 会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) +- 对生产环境中的云原生部署,使用 Dapr 状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),支持 30 多种数据库后端,并内置遥测、追踪和数据隔离功能 +- 如果希望将历史记录存储在OpenAI Conversations API中,请使用由OpenAI托管的存储(`OpenAIConversationsSession()`) +- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)封装任意会话,以提供透明加密和基于 TTL 的过期机制 +- 对于更高级的使用场景,可考虑为其他生产系统(例如 Django)实现自定义会话后端 ### 多会话 @@ -581,7 +581,7 @@ result2 = await Runner.run( ## 完整示例 -以下完整示例展示了会话记忆的实际运作方式: +以下完整示例展示了会话内存的实际工作方式: ```python import asyncio @@ -645,7 +645,7 @@ if __name__ == "__main__": ## 自定义会话实现 -你可以创建遵循 [`Session`][agents.memory.session.Session] 协议的类,实现自己的会话记忆: +你可以创建遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话内存: ```python from agents.memory.session import SessionABC @@ -690,26 +690,26 @@ result = await Runner.run( ## 社区会话实现 -社区开发了其他会话实现: +社区开发了更多会话实现: -| 软件包 | 描述 | +| 软件包 | 说明 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 适用于任何 Django 支持的数据库(PostgreSQL、MySQL、SQLite 等)的基于 Django ORM 的会话 | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 适用于 Django 所支持任意数据库(PostgreSQL、MySQL、SQLite 等)的基于 Django ORM 的会话 | 如果你构建了会话实现,欢迎提交文档 PR,将其添加到此处! ## API 参考 -详细 API 文档请参阅: +有关详细的 API 文档,请参阅: - [`Session`][agents.memory.session.Session] - 协议接口 -- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 实现 +- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API实现 - [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 压缩封装器 - [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础 SQLite 实现 -- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于 `aiosqlite` 的异步 SQLite 实现 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 支持的会话实现 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 基于 SQLAlchemy 的实现 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 支持的会话实现 +- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于`aiosqlite`的异步 SQLite 实现 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 后端会话实现 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 由 SQLAlchemy 提供支持的实现 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 后端会话实现 - [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状态存储实现 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析的增强型 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任意会话的加密封装器 \ No newline at end of file +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析功能的增强型 SQLite +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任何会话的加密封装器 \ No newline at end of file From d270dac3a5b18d711c84fb15268dff0b2e8d159d Mon Sep 17 00:00:00 2001 From: Pranav Mishra Date: Tue, 4 Aug 2026 21:38:20 -0700 Subject: [PATCH 162/473] fix(sandbox): cancel sibling env resolvers when one fails (#4195) --- src/agents/sandbox/manifest.py | 8 ++- tests/sandbox/test_manifest.py | 95 +++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index 7fc57ac413..62d88e202f 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -1,5 +1,4 @@ import abc -import asyncio import inspect from collections.abc import Iterator, Mapping from pathlib import Path, PurePath, PurePosixPath @@ -16,6 +15,7 @@ from typing_extensions import assert_never from .._config_coercion import coerce_pydantic_config +from ..util._asyncio_tasks import gather_with_cancel from .entries import BaseEntry, Dir, Mount, resolve_workspace_path from .errors import InvalidManifestPathError from .manifest_render import render_manifest_description @@ -206,7 +206,11 @@ def normalized(self) -> dict[str, EnvEntry]: async def resolve(self) -> dict[str, str]: normalized = self.normalized() keys = normalized.keys() - values = await asyncio.gather(*[normalized[key].value.resolve() for key in keys]) + # `EnvValue` is an extension point, so these are user-supplied coroutines that + # can reach a secret store or the network. A bare gather returns on the first + # failure and leaves the rest running, which is how a rejected lookup ends up + # with sibling fetches still in flight after the manifest has already failed. + values = await gather_with_cancel(*[normalized[key].value.resolve() for key in keys]) return dict(zip(keys, values, strict=False)) diff --git a/tests/sandbox/test_manifest.py b/tests/sandbox/test_manifest.py index 40c3b3d9bb..0f5eef6bc1 100644 --- a/tests/sandbox/test_manifest.py +++ b/tests/sandbox/test_manifest.py @@ -1,6 +1,8 @@ +import asyncio +import contextlib import json from pathlib import Path -from typing import Literal +from typing import ClassVar, Literal import pytest from pydantic import model_serializer @@ -447,3 +449,94 @@ class _DuplicateSecretReferenceEnvValue(EnvValue): async def resolve(self) -> str: return "unused" + + +class _BlockingEnvValue(EnvValue): + """Stands in for a user resolver that reaches a secret store or the network. + + Blocks on a test-owned release signal rather than forever, so a failed + assertion (or a future regression) cannot leave this task pending for the rest + of the session. + """ + + type: Literal["test.blocking"] = "test.blocking" + + _started: ClassVar[asyncio.Event] + _release: ClassVar[asyncio.Event] + _finished: ClassVar[asyncio.Event] + _cancelled: ClassVar[bool] + + async def resolve(self) -> str: + cls = type(self) + cls._started.set() + try: + await cls._release.wait() + except asyncio.CancelledError: + cls._cancelled = True + raise + finally: + cls._finished.set() + return "unreachable" + + +class _FailingEnvValue(EnvValue): + type: Literal["test.failing"] = "test.failing" + + async def resolve(self) -> str: + # Fail only once the sibling is genuinely in flight, so the test pins the + # interleaving instead of racing the two resolvers. + await _BlockingEnvValue._started.wait() + raise RuntimeError("secret backend rejected the request") + + +@pytest.mark.asyncio +async def test_environment_resolve_cancels_siblings_when_one_resolver_fails() -> None: + """A failed env lookup must not leave the other resolvers running. + + `EnvValue` is an extension point, so `Environment.resolve()` fans out + user-supplied coroutines that can reach a secret store. A bare `asyncio.gather` + returns on the first failure and leaves the siblings pending, so a rejected + lookup left other secret fetches in flight after the manifest had already + failed. + """ + _BlockingEnvValue._started = asyncio.Event() + _BlockingEnvValue._release = asyncio.Event() + _BlockingEnvValue._finished = asyncio.Event() + _BlockingEnvValue._cancelled = False + + environment = Environment( + value={"BLOCKING": _BlockingEnvValue(), "FAILING": _FailingEnvValue()} + ) + + try: + with pytest.raises(RuntimeError, match="secret backend rejected the request"): + await environment.resolve() + + assert _BlockingEnvValue._cancelled, "sibling resolver was not cancelled" + await asyncio.wait_for(_BlockingEnvValue._finished.wait(), timeout=1) + finally: + # Release the resolver whether or not the assertions held, so running this + # against the base revision drains its task instead of stranding it. + _BlockingEnvValue._release.set() + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(_BlockingEnvValue._finished.wait(), timeout=1) + + +@pytest.mark.asyncio +async def test_environment_resolve_still_returns_every_value() -> None: + """The cancel path must not change the success path's mapping.""" + environment = Environment( + value={ + "PLAIN": "literal", + "REF": _SecretReferenceEnvValue(key="alpha"), + "ENTRY": EnvEntry(value=_SecretReferenceEnvValue(key="beta")), + } + ) + + resolved = await environment.resolve() + + assert resolved == { + "PLAIN": "literal", + "REF": "resolved-secret-for-alpha", + "ENTRY": "resolved-secret-for-beta", + } From 8be468f35b6630fa8cb71a98bbd52e87409577ae Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Tue, 4 Aug 2026 22:19:06 -0700 Subject: [PATCH 163/473] docs(tracing): fix custom span output example (#4196) --- src/agents/tracing/spans.py | 2 +- tests/test_tracing.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/agents/tracing/spans.py b/src/agents/tracing/spans.py index 3cc3863955..662063877c 100644 --- a/src/agents/tracing/spans.py +++ b/src/agents/tracing/spans.py @@ -46,7 +46,7 @@ class Span(abc.ABC, Generic[TSpanData]): "table": "users" }) as span: results = await db.query("SELECT * FROM users") - span.set_output({"count": len(results)}) + span.span_data.data["output"] = {"count": len(results)} # Handling errors in spans with custom_span("risky_operation") as span: diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 1076a79cfa..2a69930bc3 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -273,6 +273,24 @@ def test_spans_with_setters() -> None: ) +def test_custom_span_records_output_in_data() -> None: + with trace(workflow_name="test"): + with custom_span("database_query", {"operation": "SELECT", "table": "users"}) as span: + span.span_data.data["output"] = {"count": 2} + + exported = span.export() + assert exported is not None + assert exported["span_data"] == { + "type": "custom", + "name": "database_query", + "data": { + "operation": "SELECT", + "table": "users", + "output": {"count": 2}, + }, + } + + def disabled_tracing(): with trace(workflow_name="test", trace_id="123", group_id="456", disabled=True): with agent_span(name="agent_1"): From 3088d5f402ffea3b3c30061371834738db9873a3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 18:33:34 +0900 Subject: [PATCH 164/473] perf: run serial tests alongside xdist (#4197) --- .github/scripts/run_serial_tests.py | 46 +++++++++++++++++++++++ Makefile | 5 ++- tests/README.md | 2 +- tests/test_run_serial_tests.py | 58 +++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/run_serial_tests.py create mode 100644 tests/test_run_serial_tests.py diff --git a/.github/scripts/run_serial_tests.py b/.github/scripts/run_serial_tests.py new file mode 100644 index 0000000000..46030bdb3a --- /dev/null +++ b/.github/scripts/run_serial_tests.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import fnmatch +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PYTEST_FILE_PATTERNS = ("test_*.py", "*_test.py") +SERIAL_MARKER = "pytest.mark.serial" + + +def _test_files() -> list[Path]: + return sorted( + path + for path in (ROOT / "tests").rglob("*.py") + if any(fnmatch.fnmatchcase(path.name, pattern) for pattern in PYTEST_FILE_PATTERNS) + ) + + +def _serial_test_files() -> list[Path]: + return [path for path in _test_files() if SERIAL_MARKER in path.read_text(encoding="utf-8")] + + +def _relative(path: Path) -> str: + return str(path.relative_to(ROOT)) + + +def _serial_args() -> list[str]: + return [ + sys.executable, + "-m", + "pytest", + *(_relative(path) for path in _serial_test_files()), + "-m", + "serial", + ] + + +def main() -> None: + os.chdir(ROOT) + os.execv(sys.executable, _serial_args()) + + +if __name__ == "__main__": + main() diff --git a/Makefile b/Makefile index 86b6fa4f10..daad2fb794 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,8 @@ typecheck: trap - EXIT .PHONY: tests -tests: tests-parallel tests-serial +tests: tests-parallel + $(MAKE) tests-serial .PHONY: tests-asyncio-stability tests-asyncio-stability: @@ -53,7 +54,7 @@ tests-parallel: .PHONY: tests-serial tests-serial: - uv run pytest -m serial + uv run python .github/scripts/run_serial_tests.py .PHONY: integration-tests integration-tests: diff --git a/tests/README.md b/tests/README.md index 1dd4b23eb5..6a8d83d1e4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,7 @@ Before running any tests, make sure you have `uv` installed (and ideally run `ma make tests ``` -`make tests` runs the shard-safe suite in parallel and then runs tests marked `serial` in a separate serial pass. +`make tests` runs the shard-safe suite first, then runs the tests marked `serial` after all xdist workers have exited. The serial runner limits collection to test files containing the literal `pytest.mark.serial`, so keep that literal marker in every file containing serial tests. For indirect or custom serial marker spellings, use `uv run pytest -m serial` to perform generic pytest collection. ## Performance and determinism diff --git a/tests/test_run_serial_tests.py b/tests/test_run_serial_tests.py new file mode 100644 index 0000000000..0025d32a37 --- /dev/null +++ b/tests/test_run_serial_tests.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + + +@pytest.fixture +def serial_test_runner() -> ModuleType: + path = Path(__file__).parents[1] / ".github" / "scripts" / "run_serial_tests.py" + spec = importlib.util.spec_from_file_location("run_serial_tests_under_test", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_discovers_both_default_pytest_filename_patterns( + serial_test_runner: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_prefix.py").write_text("def test_prefix(): pass\n", encoding="utf-8") + (tests / "suffix_test.py").write_text( + "import pytest\npytestmark = pytest.mark.serial\n", + encoding="utf-8", + ) + (tests / "helper.py").write_text("HELPER = True\n", encoding="utf-8") + monkeypatch.setattr(serial_test_runner, "ROOT", tmp_path) + + assert [path.name for path in serial_test_runner._test_files()] == [ + "suffix_test.py", + "test_prefix.py", + ] + assert [path.name for path in serial_test_runner._serial_test_files()] == ["suffix_test.py"] + + +def test_serial_command_targets_only_discovered_files( + serial_test_runner: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + serial_file = tmp_path / "tests" / "test_serial.py" + serial_file.parent.mkdir() + serial_file.write_text("import pytest\npytestmark = pytest.mark.serial\n", encoding="utf-8") + monkeypatch.setattr(serial_test_runner, "ROOT", tmp_path) + + assert serial_test_runner._serial_args() == [ + sys.executable, + "-m", + "pytest", + str(Path("tests") / "test_serial.py"), + "-m", + "serial", + ] From afa911f82ab69e6af38a7259faf42bba3448a329 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 18:35:57 +0900 Subject: [PATCH 165/473] perf: add source-only typecheck target (#4200) --- Makefile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index daad2fb794..5aa912c055 100644 --- a/Makefile +++ b/Makefile @@ -21,11 +21,11 @@ lint: .PHONY: mypy mypy: - uv run mypy . --exclude site + uv run mypy $(if $(TYPECHECK_SRC_ONLY),src,.) --exclude site .PHONY: pyright pyright: - uv run pyright --project pyrightconfig.json + uv run pyright --project pyrightconfig.json $(if $(TYPECHECK_SRC_ONLY),src,) .PHONY: typecheck typecheck: @@ -40,6 +40,10 @@ typecheck: wait $$pyright_pid; \ trap - EXIT +.PHONY: typecheck-src +typecheck-src: + @$(MAKE) typecheck TYPECHECK_SRC_ONLY=1 + .PHONY: tests tests: tests-parallel $(MAKE) tests-serial From 8f7e6d763c6a623c296b41a606c6dba0d5c3c19f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 18:52:13 +0900 Subject: [PATCH 166/473] feat: support MCP Python SDK v1 and v2 (#4106) --- .github/scripts/run_integration_tests.py | 36 +- .github/workflows/tests.yml | 25 + Makefile | 4 + examples/mcp/manager_example/README.md | 2 + examples/mcp/manager_example/mcp_server.py | 14 +- examples/mcp/prompt_server/README.md | 2 + examples/mcp/prompt_server/server.py | 10 +- examples/mcp/sse_example/README.md | 2 + examples/mcp/sse_example/server.py | 6 +- .../README.md | 10 +- .../main.py | 14 +- .../server.py | 10 +- examples/mcp/streamablehttp_example/README.md | 2 + examples/mcp/streamablehttp_example/server.py | 10 +- examples/sandbox/README.md | 2 + .../misc/reference_policy_mcp_server.py | 4 +- integration_tests/README.md | 2 +- .../packaging/mcp_legacy_server.py | 68 ++ .../packaging/test_mcp_compat.py | 85 +++ integration_tests/pytest.ini | 1 + pyproject.toml | 2 +- src/agents/mcp/_compat.py | 175 +++++ src/agents/mcp/server.py | 601 +++++++++++++----- src/agents/mcp/util.py | 45 +- tests/mcp/helpers.py | 13 +- tests/mcp/model_compat.py | 83 +++ tests/mcp/servers/legacy.py | 76 +++ tests/mcp/servers/paginated.py | 75 ++- tests/mcp/test_caching.py | 3 +- tests/mcp/test_client_session_retries.py | 13 +- tests/mcp/test_connect_disconnect.py | 2 +- tests/mcp/test_mcp_auth_params.py | 3 + tests/mcp/test_mcp_pagination_integration.py | 11 +- tests/mcp/test_mcp_resources.py | 42 +- tests/mcp/test_mcp_server_manager.py | 3 +- tests/mcp/test_mcp_util.py | 31 +- tests/mcp/test_mcp_v2_http.py | 378 +++++++++++ tests/mcp/test_mcp_version_compat.py | 31 + tests/mcp/test_message_handler.py | 15 +- tests/mcp/test_prompt_server.py | 3 +- tests/mcp/test_server_errors.py | 7 +- .../test_streamable_http_client_factory.py | 10 +- tests/mcp/test_streamable_http_session_id.py | 2 + tests/test_agent_as_tool.py | 5 +- tests/test_process_model_response.py | 2 +- tests/test_stream_events.py | 2 +- tests/test_tool_origin.py | 2 +- uv.lock | 112 +++- 48 files changed, 1721 insertions(+), 335 deletions(-) create mode 100644 integration_tests/packaging/mcp_legacy_server.py create mode 100644 integration_tests/packaging/test_mcp_compat.py create mode 100644 src/agents/mcp/_compat.py create mode 100644 tests/mcp/model_compat.py create mode 100644 tests/mcp/servers/legacy.py create mode 100644 tests/mcp/test_mcp_v2_http.py create mode 100644 tests/mcp/test_mcp_version_compat.py diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 28aa259d71..28dbf65bb8 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -24,6 +24,7 @@ ) PROFILES = ( "packaging", + "mcp-v1", "core", "providers", "realtime", @@ -78,7 +79,12 @@ def _any_llm_provider_extras( def create_environment( - name: str, distribution: Path, *, extras: bool = False, optional_extra: str | None = None + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), ) -> Path: environment = WORKSPACE / name venv_command = ["uv", "venv", "--clear", str(environment)] @@ -88,7 +94,13 @@ def create_environment( python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") selected_extra = EXTRAS if extras else optional_extra requirement = f"{distribution}[{selected_extra}]" if selected_extra else str(distribution) - requirements = [requirement, "pytest", "pytest-asyncio", "pytest-timeout"] + requirements = [ + requirement, + "pytest", + "pytest-asyncio", + "pytest-timeout", + *additional_requirements, + ] external_providers_enabled = os.environ.get( "OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "" ).lower() in {"1", "true", "yes"} @@ -126,6 +138,7 @@ def run_suite( *, selection: str, environment_kind: str, + additional_env: dict[str, str] | None = None, ) -> None: child_env = dict(os.environ) child_env.pop("PYTHONPATH", None) @@ -147,6 +160,8 @@ def run_suite( child_env["OPENAI_AGENTS_INTEGRATION_WHEEL"] = str(wheel) child_env["OPENAI_AGENTS_INTEGRATION_SDIST"] = str(sdist) child_env["OPENAI_AGENTS_INTEGRATION_ENVIRONMENT"] = environment_kind + if additional_env: + child_env.update(additional_env) if environment_kind.startswith("extra-"): child_env["OPENAI_AGENTS_INTEGRATION_EXTRA"] = environment_kind.removeprefix("extra-") if not os.environ.get("OPENAI_AGENTS_INTEGRATION_ENABLE_TRACING"): @@ -182,6 +197,23 @@ def main() -> None: wheel, sdist = build_distributions() print(f"[integration] wheel={wheel.name} sdist={sdist.name} profile={args.profile}") + if args.profile == "mcp-v1": + for mcp_version in ("1.19.0", "1.29.0"): + environment_kind = f"mcp-v1-{mcp_version}" + python = create_environment( + environment_kind, + wheel, + additional_requirements=(f"mcp=={mcp_version}",), + ) + run_suite( + python, + wheel, + sdist, + selection="mcp_compat", + environment_kind=environment_kind, + additional_env={"OPENAI_AGENTS_INTEGRATION_MCP_VERSION": mcp_version}, + ) + if args.profile in {"packaging", "core", "hosted", "full", "release", "nightly", "manual"}: python = create_environment("core", wheel) selections = { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b609de27be..a61c28bec1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -110,6 +110,31 @@ jobs: if: steps.changes.outputs.run != 'true' run: echo "Skipping tests for non-code changes." + mcp-v1-compat: + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Detect code changes + id: changes + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: true + prune-cache: true + python-version: "3.12" + - name: Run packaged MCP v1 compatibility tests + if: steps.changes.outputs.run == 'true' + run: make integration-tests-mcp-v1 + - name: Skip MCP v1 compatibility tests + if: steps.changes.outputs.run != 'true' + run: echo "Skipping MCP v1 compatibility tests for non-code changes." + tests-windows: runs-on: windows-latest env: diff --git a/Makefile b/Makefile index 5aa912c055..b6ac796d2f 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,10 @@ integration-tests-manual: integration-tests-packaging: uv run python .github/scripts/run_integration_tests.py --profile packaging +.PHONY: integration-tests-mcp-v1 +integration-tests-mcp-v1: + uv run python .github/scripts/run_integration_tests.py --profile mcp-v1 + .PHONY: integration-tests-core integration-tests-core: uv run python .github/scripts/run_integration_tests.py --profile core diff --git a/examples/mcp/manager_example/README.md b/examples/mcp/manager_example/README.md index ec4dcbe4bc..715fa40532 100644 --- a/examples/mcp/manager_example/README.md +++ b/examples/mcp/manager_example/README.md @@ -1,5 +1,7 @@ # MCP Manager Example (FastAPI) +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example shows how to use `MCPServerManager` to keep MCP server lifecycle management in a single task inside a FastAPI app with the Streamable HTTP transport. ## Run the MCP server (Streamable HTTP) diff --git a/examples/mcp/manager_example/mcp_server.py b/examples/mcp/manager_example/mcp_server.py index a67c224994..92c6709abd 100644 --- a/examples/mcp/manager_example/mcp_server.py +++ b/examples/mcp/manager_example/mcp_server.py @@ -1,15 +1,11 @@ import os -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "8000")) -mcp = FastMCP( - "FastAPI Example Server", - host=STREAMABLE_HTTP_HOST, - port=STREAMABLE_HTTP_PORT, -) +mcp = MCPServer("FastAPI Example Server") @mcp.tool() @@ -23,4 +19,8 @@ def echo(message: str) -> str: if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/mcp/prompt_server/README.md b/examples/mcp/prompt_server/README.md index 74ee0fe07e..562fb573ca 100644 --- a/examples/mcp/prompt_server/README.md +++ b/examples/mcp/prompt_server/README.md @@ -1,5 +1,7 @@ # MCP Prompt Server Example +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example uses a local MCP prompt server in [server.py](server.py). Run the example via: diff --git a/examples/mcp/prompt_server/server.py b/examples/mcp/prompt_server/server.py index 7d6629acd7..22125cf7dd 100644 --- a/examples/mcp/prompt_server/server.py +++ b/examples/mcp/prompt_server/server.py @@ -1,12 +1,12 @@ import os -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "18080")) # Create server -mcp = FastMCP("Prompt Server", host=STREAMABLE_HTTP_HOST, port=STREAMABLE_HTTP_PORT) +mcp = MCPServer("Prompt Server") # Instruction-generating prompts (user-controlled) @@ -39,4 +39,8 @@ def generate_code_review_instructions( if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/mcp/sse_example/README.md b/examples/mcp/sse_example/README.md index 9a667d31e1..cd9747bb98 100644 --- a/examples/mcp/sse_example/README.md +++ b/examples/mcp/sse_example/README.md @@ -1,5 +1,7 @@ # MCP SSE Example +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example uses a local SSE server in [server.py](server.py). Run the example via: diff --git a/examples/mcp/sse_example/server.py b/examples/mcp/sse_example/server.py index 075137fe03..a8f65c261b 100644 --- a/examples/mcp/sse_example/server.py +++ b/examples/mcp/sse_example/server.py @@ -1,13 +1,13 @@ import os import random -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer SSE_HOST = os.getenv("SSE_HOST", "127.0.0.1") SSE_PORT = int(os.getenv("SSE_PORT", "8000")) # Create server -mcp = FastMCP("Echo Server", host=SSE_HOST, port=SSE_PORT) +mcp = MCPServer("Echo Server") @mcp.tool() @@ -39,4 +39,4 @@ def get_current_weather(city: str) -> str: if __name__ == "__main__": - mcp.run(transport="sse") + mcp.run(transport="sse", host=SSE_HOST, port=SSE_PORT) diff --git a/examples/mcp/streamablehttp_custom_client_example/README.md b/examples/mcp/streamablehttp_custom_client_example/README.md index fc269a0644..33890a45e6 100644 --- a/examples/mcp/streamablehttp_custom_client_example/README.md +++ b/examples/mcp/streamablehttp_custom_client_example/README.md @@ -1,5 +1,7 @@ # Custom HTTP Client Factory Example +This repository example targets MCP Python SDK v2 and `httpx2`, and is intended to run with the repository's locked development environment. The Agents SDK client itself supports MCP v1 with `httpx` and MCP v2 with `httpx2`. + This example demonstrates how to use the new `httpx_client_factory` parameter in `MCPServerStreamableHttp` to configure custom HTTP client behavior for MCP StreamableHTTP connections. ## Features Demonstrated @@ -25,13 +27,13 @@ This example demonstrates how to use the new `httpx_client_factory` parameter in ### Basic Custom Client ```python -import httpx +import httpx2 from agents.mcp import MCPServerStreamableHttp -def create_custom_http_client() -> httpx.AsyncClient: - return httpx.AsyncClient( +def create_custom_http_client() -> httpx2.AsyncClient: + return httpx2.AsyncClient( verify=False, # Disable SSL verification for testing - timeout=httpx.Timeout(60.0, read=120.0), + timeout=httpx2.Timeout(60.0, read=120.0), headers={"X-Custom-Client": "my-app"}, ) diff --git a/examples/mcp/streamablehttp_custom_client_example/main.py b/examples/mcp/streamablehttp_custom_client_example/main.py index 8a70cf820a..548c06391f 100644 --- a/examples/mcp/streamablehttp_custom_client_example/main.py +++ b/examples/mcp/streamablehttp_custom_client_example/main.py @@ -12,7 +12,7 @@ import time from typing import Any, cast -import httpx +import httpx2 from agents import Agent, Runner, gen_trace_id, trace from agents.mcp import MCPServer, MCPServerStreamableHttp @@ -38,9 +38,9 @@ def _choose_port() -> int: def create_custom_http_client( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, -) -> httpx.AsyncClient: + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, +) -> httpx2.AsyncClient: """Create a custom HTTP client with specific configurations. This function demonstrates how to configure: @@ -55,14 +55,14 @@ def create_custom_http_client( "User-Agent": "OpenAI-Agents-MCP/1.0", } if timeout is None: - timeout = httpx.Timeout(60.0, read=120.0) + timeout = httpx2.Timeout(60.0, read=120.0) if auth is None: auth = None - return httpx.AsyncClient( + return httpx2.AsyncClient( # Disable SSL verification for testing (not recommended for production) verify=False, # Set custom timeout - timeout=httpx.Timeout(60.0, read=120.0), + timeout=httpx2.Timeout(60.0, read=120.0), # Add custom headers that will be sent with every request headers=headers, ) diff --git a/examples/mcp/streamablehttp_custom_client_example/server.py b/examples/mcp/streamablehttp_custom_client_example/server.py index dd0d468753..e6ec5d5f93 100644 --- a/examples/mcp/streamablehttp_custom_client_example/server.py +++ b/examples/mcp/streamablehttp_custom_client_example/server.py @@ -1,13 +1,13 @@ import os import random -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "18080")) # Create server -mcp = FastMCP("Echo Server", host=STREAMABLE_HTTP_HOST, port=STREAMABLE_HTTP_PORT) +mcp = MCPServer("Echo Server") @mcp.tool() @@ -24,4 +24,8 @@ def get_secret_word() -> str: if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/mcp/streamablehttp_example/README.md b/examples/mcp/streamablehttp_example/README.md index 83cae670b6..0c446ddfe6 100644 --- a/examples/mcp/streamablehttp_example/README.md +++ b/examples/mcp/streamablehttp_example/README.md @@ -1,5 +1,7 @@ # MCP Streamable HTTP Example +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example uses a local Streamable HTTP server in [server.py](server.py). Run the example via: diff --git a/examples/mcp/streamablehttp_example/server.py b/examples/mcp/streamablehttp_example/server.py index d73ab895b6..2afb6587b5 100644 --- a/examples/mcp/streamablehttp_example/server.py +++ b/examples/mcp/streamablehttp_example/server.py @@ -2,13 +2,13 @@ import random import requests -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "18080")) # Create server -mcp = FastMCP("Echo Server", host=STREAMABLE_HTTP_HOST, port=STREAMABLE_HTTP_PORT) +mcp = MCPServer("Echo Server") @mcp.tool() @@ -40,4 +40,8 @@ def get_current_weather(city: str) -> str: if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md index e411ae70f1..733159a065 100644 --- a/examples/sandbox/README.md +++ b/examples/sandbox/README.md @@ -4,6 +4,8 @@ These examples show how to run agents with an isolated workspace. Start with the Most examples call a model through `Runner`, so set `OPENAI_API_KEY` in the repository-root `.env` file, in the example's `.env` file when it has one, or in your shell environment. +`sandbox_agent_with_tools.py` starts the repository's MCP v2 reference server and is intended to run with the locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + ## Small API examples | Example | Run | What it shows | diff --git a/examples/sandbox/misc/reference_policy_mcp_server.py b/examples/sandbox/misc/reference_policy_mcp_server.py index 0e6486d575..4bf915e6dd 100644 --- a/examples/sandbox/misc/reference_policy_mcp_server.py +++ b/examples/sandbox/misc/reference_policy_mcp_server.py @@ -1,6 +1,6 @@ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP("Reference Policy Server") +mcp = MCPServer("Reference Policy Server") @mcp.tool() diff --git a/integration_tests/README.md b/integration_tests/README.md index 4d5db77f50..150307c4ca 100644 --- a/integration_tests/README.md +++ b/integration_tests/README.md @@ -7,7 +7,7 @@ Run the complete release-oriented matrix with: export UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests -`make integration-tests-release` runs the same release-safe matrix explicitly. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. +`make integration-tests-release` runs the same release-safe matrix explicitly. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. Invoke the repository-local `$integration-tests` skill to run the release profile with configured OpenRouter-backed provider checks. OpenRouter provides a single configured gateway for the standard multi-provider matrix; provider-specific direct connections are optional extensions selected explicitly. When a release review also requires runnable examples, run `$examples-auto-run` first and then `$integration-tests`. diff --git a/integration_tests/packaging/mcp_legacy_server.py b/integration_tests/packaging/mcp_legacy_server.py new file mode 100644 index 0000000000..64b801b65e --- /dev/null +++ b/integration_tests/packaging/mcp_legacy_server.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json +import sys + + +def send(message: dict[str, object]) -> None: + sys.stdout.write(json.dumps(message) + "\n") + sys.stdout.flush() + + +def main() -> None: + for line in sys.stdin: + message = json.loads(line) + request_id = message.get("id") + method = message.get("method") + if request_id is None: + continue + if method == "initialize": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "legacy-test-server", "version": "1.0"}, + }, + } + ) + elif method == "tools/list": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "legacy_tool", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + }, + } + ) + elif method == "tools/call": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": "legacy-result"}], + "isError": False, + }, + } + ) + else: + send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Unknown method: {method}"}, + } + ) + + +if __name__ == "__main__": + main() diff --git a/integration_tests/packaging/test_mcp_compat.py b/integration_tests/packaging/test_mcp_compat.py new file mode 100644 index 0000000000..cdf7dc2446 --- /dev/null +++ b/integration_tests/packaging/test_mcp_compat.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import importlib.metadata +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from agents.mcp import MCPServerSse, MCPServerStdio, MCPServerStreamableHttp + +pytestmark = pytest.mark.mcp_compat + +LEGACY_SERVER_PATH = Path(__file__).with_name("mcp_legacy_server.py") + + +@pytest.mark.asyncio +async def test_packaged_client_supports_mcp_v1() -> None: + expected_version = os.environ["OPENAI_AGENTS_INTEGRATION_MCP_VERSION"] + assert importlib.metadata.version("mcp") == expected_version + + server = MCPServerStdio( + name="legacy-test-server", + params={"command": sys.executable, "args": [str(LEGACY_SERVER_PATH)]}, + ) + + async with server: + tools = await server.list_tools() + result = await server.call_tool("legacy_tool", {}) + + assert [tool.name for tool in tools] == ["legacy_tool"] + assert getattr(result, "isError", getattr(result, "is_error", None)) is False + assert result.content[0].type == "text" + assert result.content[0].text == "legacy-result" + + +def test_packaged_client_uses_mcp_v1_sse_transport() -> None: + with patch("agents.mcp.server.sse_client") as mock_client: + mock_client.return_value = MagicMock() + server = MCPServerSse( + params={ + "url": "https://example.test/sse", + "headers": {"Authorization": "Bearer token"}, + } + ) + + server.create_streams() + + mock_client.assert_called_once() + assert mock_client.call_args.kwargs["url"] == "https://example.test/sse" + assert mock_client.call_args.kwargs["headers"] == {"Authorization": "Bearer token"} + assert mock_client.call_args.kwargs["timeout"] == 5 + assert mock_client.call_args.kwargs["sse_read_timeout"] == 300 + assert callable(mock_client.call_args.kwargs["httpx_client_factory"]) + + +def test_packaged_client_uses_mcp_v1_streamable_http_auth_and_factory() -> None: + auth = httpx.BasicAuth("user", "pass") + + def factory(headers=None, timeout=None, auth=None): + return httpx.AsyncClient(headers=headers, timeout=timeout, auth=auth) + + with patch("agents.mcp.server.streamablehttp_client") as mock_client: + mock_client.return_value = MagicMock() + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "auth": auth, + "httpx_client_factory": factory, + } + ) + + server.create_streams() + + mock_client.assert_called_once_with( + url="https://example.test/mcp", + headers=None, + timeout=5, + sse_read_timeout=300, + terminate_on_close=True, + auth=auth, + httpx_client_factory=factory, + ) diff --git a/integration_tests/pytest.ini b/integration_tests/pytest.ini index ab59a65e6e..ed81acb658 100644 --- a/integration_tests/pytest.ini +++ b/integration_tests/pytest.ini @@ -6,6 +6,7 @@ timeout = 75 testpaths = . markers = packaging: Distribution contents and installed-package boundaries. + mcp_compat: Packaged MCP client compatibility across supported dependency versions. extras: Independently installed optional dependency groups. core: Live OpenAI Responses and Chat Completions coverage. providers: Live AnyLLM and LiteLLM provider-adapter coverage. diff --git a/pyproject.toml b/pyproject.toml index 5a7d097aae..cc276869a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "typing-extensions>=4.12.2, <5", "requests>=2.0, <3", "websockets>=15.0, <17", - "mcp>=1.19.0, <2; python_version >= '3.10'", + "mcp>=1.19.0, <3; python_version >= '3.10'", ] classifiers = [ "Typing :: Typed", diff --git a/src/agents/mcp/_compat.py b/src/agents/mcp/_compat.py new file mode 100644 index 0000000000..859d71f3aa --- /dev/null +++ b/src/agents/mcp/_compat.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from importlib import import_module +from importlib.metadata import version +from types import ModuleType +from typing import Any, cast + +import httpx +from pydantic import AnyUrl + + +def _major_version(distribution: str) -> int: + raw_version = version(distribution) + major, separator, _ = raw_version.partition(".") + if not separator or not major.isdigit(): # pragma: no cover - package versions are validated + raise RuntimeError(f"Unsupported {distribution} version: {raw_version}") + return int(major) + + +MCP_MAJOR_VERSION = _major_version("mcp") +MCP_V2 = MCP_MAJOR_VERSION >= 2 + +_mcp_exceptions = import_module("mcp.shared.exceptions") +MCPError = cast( + type[Exception], + vars(_mcp_exceptions).get("MCPError") or vars(_mcp_exceptions)["McpError"], +) + +MCP_HTTPX: ModuleType = import_module("httpx2") if MCP_V2 else httpx + +HTTP_STATUS_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.HTTPStatusError, cast(type[Exception], MCP_HTTPX.HTTPStatusError))) +) +HTTP_REQUEST_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.RequestError, cast(type[Exception], MCP_HTTPX.RequestError))) +) +HTTP_CONNECT_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.ConnectError, cast(type[Exception], MCP_HTTPX.ConnectError))) +) +HTTP_TIMEOUT_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.TimeoutException, cast(type[Exception], MCP_HTTPX.TimeoutException))) +) +HTTP_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.HTTPError, cast(type[Exception], MCP_HTTPX.HTTPError))) +) +HTTP_INVALID_URL_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.InvalidURL, cast(type[Exception], MCP_HTTPX.InvalidURL))) +) + + +def create_v2_client( + transport: Any, + *, + read_timeout_seconds: float | None, + message_handler: Any, +) -> Any: + if not MCP_V2: # pragma: no cover - guarded by the caller + raise RuntimeError("MCP v2 client requested with MCP v1 installed.") + client_class = vars(import_module("mcp"))["Client"] + return client_class( + transport, + mode="auto", + cache=None, + read_timeout_seconds=read_timeout_seconds, + message_handler=message_handler, + ) + + +def streamable_http_client_v2( + url: str, + *, + http_client: Any, + terminate_on_close: bool, +) -> Any: + if not MCP_V2: # pragma: no cover - guarded by the caller + raise RuntimeError("MCP v2 transport requested with MCP v1 installed.") + module = import_module("mcp.client.streamable_http") + return module.streamable_http_client( + url, + http_client=http_client, + terminate_on_close=terminate_on_close, + ) + + +def tool_input_schema(tool: Any) -> dict[str, Any]: + return cast(dict[str, Any], tool.input_schema if MCP_V2 else tool.inputSchema) + + +def result_next_cursor(result: Any) -> str | None: + return cast(str | None, result.next_cursor if MCP_V2 else result.nextCursor) + + +def clear_result_next_cursor(result: Any, **updates: Any) -> Any: + updates["next_cursor" if MCP_V2 else "nextCursor"] = None + return result.model_copy(update=updates) + + +def result_structured_content(result: Any) -> dict[str, Any] | None: + return cast( + dict[str, Any] | None, + result.structured_content if MCP_V2 else result.structuredContent, + ) + + +def result_is_error(result: Any) -> bool | None: + return cast(bool | None, result.is_error if MCP_V2 else result.isError) + + +def image_mime_type(content: Any) -> str: + return cast(str, content.mime_type if MCP_V2 else content.mimeType) + + +def resource_uri(uri: str) -> str | AnyUrl: + return uri if MCP_V2 else AnyUrl(uri) + + +def mcp_error_code(error: BaseException) -> int | None: + if not isinstance(error, MCPError): + return None + if MCP_V2: + return cast(int, cast(Any, error).code) + error_data = getattr(error, "error", None) + return cast(int | None, getattr(error_data, "code", None)) + + +def mcp_error_message(error: BaseException) -> str: + if not isinstance(error, MCPError): + return str(error) + if MCP_V2: + return cast(str, cast(Any, error).message) + error_data = getattr(error, "error", None) + return cast(str, getattr(error_data, "message", str(error))) + + +def mcp_request_timeout_code() -> int: + return -32001 if MCP_V2 else int(httpx.codes.REQUEST_TIMEOUT) + + +def is_mcp_timeout_error(error: BaseException) -> bool: + return mcp_error_code(error) == mcp_request_timeout_code() + + +def is_mcp_connection_closed_error(error: BaseException) -> bool: + if not MCP_V2: + return False + connection_closed = int(vars(import_module("mcp_types"))["CONNECTION_CLOSED"]) + return mcp_error_code(error) == connection_closed + + +def is_http_status_error(error: BaseException) -> bool: + return isinstance(error, HTTP_STATUS_ERROR_TYPES) + + +def is_http_request_error(error: BaseException) -> bool: + return isinstance(error, HTTP_REQUEST_ERROR_TYPES) + + +def is_http_connect_error(error: BaseException) -> bool: + return isinstance(error, HTTP_CONNECT_ERROR_TYPES) + + +def is_http_timeout_error(error: BaseException) -> bool: + return isinstance(error, HTTP_TIMEOUT_ERROR_TYPES) + + +def is_http_transport_error(error: BaseException) -> bool: + return is_http_status_error(error) or is_http_request_error(error) + + +def http_status_code(error: BaseException) -> int: + return cast(int, cast(Any, error).response.status_code) + + +def http_reason_phrase(error: BaseException) -> str: + return cast(str, cast(Any, error).response.reason_phrase) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 5e8e2543ea..e4b2fc6c5f 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -3,6 +3,7 @@ import abc import asyncio import inspect +import json import math import sys from collections.abc import AsyncGenerator, Awaitable, Callable @@ -17,16 +18,9 @@ if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup # pyright: ignore[reportMissingImports] from anyio import ClosedResourceError -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import ClientSession, StdioServerParameters, Tool as MCPTool, stdio_client from mcp.client.session import MessageHandlerFnT from mcp.client.sse import sse_client -from mcp.client.streamable_http import ( - GetSessionIdCallback, - StreamableHTTPTransport, - streamablehttp_client, -) -from mcp.shared.exceptions import McpError from mcp.shared.message import SessionMessage from mcp.types import ( CallToolResult, @@ -52,6 +46,31 @@ from ..run_context import RunContextWrapper from ..tool import ToolErrorFunction from ..util._types import MaybeAwaitable +from ._compat import ( + HTTP_CONNECT_ERROR_TYPES, + HTTP_ERROR_TYPES, + HTTP_INVALID_URL_TYPES, + HTTP_REQUEST_ERROR_TYPES, + HTTP_STATUS_ERROR_TYPES, + HTTP_TIMEOUT_ERROR_TYPES, + MCP_HTTPX, + MCP_V2, + MCPError, + clear_result_next_cursor, + create_v2_client, + http_reason_phrase, + http_status_code, + is_http_connect_error, + is_http_request_error, + is_http_status_error, + is_http_timeout_error, + is_mcp_connection_closed_error, + is_mcp_timeout_error, + resource_uri, + result_next_cursor, + streamable_http_client_v2, + tool_input_schema, +) from ._logging import get_mcp_server_log_message, get_mcp_server_log_name from .util import ( HttpClientFactory, @@ -103,12 +122,19 @@ class RequireApprovalObject(TypedDict, total=False): T = TypeVar("T") +GetSessionIdCallback = Callable[[], str | None] + +_streamable_http_module = __import__( + "mcp.client.streamable_http", fromlist=["StreamableHTTPTransport"] +) +StreamableHTTPTransport = cast(Any, vars(_streamable_http_module)["StreamableHTTPTransport"]) +streamablehttp_client = vars(_streamable_http_module).get("streamablehttp_client") _SAFE_EXCEPTION_GROUP_MESSAGE = "MCP request failed with additional errors." _SAFE_EXCEPTION_MESSAGE = "An additional error occurred during the MCP request." -def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | None: +def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | float | None: """Convert an MCP read timeout while intentionally treating zero as no timeout.""" if timeout_seconds is None: return None @@ -132,21 +158,22 @@ def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | N raise ValueError( "client_session_timeout_seconds must fit in a datetime.timedelta." ) from error - return timeout + return timeout_seconds if MCP_V2 else timeout def _transport_error_urls_are_safe( - http_error: httpx.HTTPStatusError | httpx.RequestError, + http_error: Exception, ) -> bool: """Return whether one HTTPX exception contains only credential-safe URLs.""" request_urls: list[str] = [] try: - request_urls.append(str(http_error.request.url)) + request_urls.append(str(cast(Any, http_error).request.url)) except RuntimeError: pass - if isinstance(http_error, httpx.HTTPStatusError): - for response in [*http_error.response.history, http_error.response]: + if is_http_status_error(http_error): + original_response = cast(Any, http_error).response + for response in [*original_response.history, original_response]: try: response_url = response.request.url except RuntimeError: @@ -157,7 +184,7 @@ def _transport_error_urls_are_safe( if redirect_location is not None: try: request_urls.append(str(response_url.join(redirect_location))) - except (httpx.InvalidURL, ValueError): + except HTTP_INVALID_URL_TYPES + (ValueError,): return False return all(get_mcp_server_log_name(url) == url for url in request_urls) @@ -165,7 +192,7 @@ def _transport_error_urls_are_safe( def _safe_transport_cause(http_error: Exception) -> Exception | None: """Keep an unchained transport exception only when its HTTPX URLs are credential-safe.""" - if not isinstance(http_error, httpx.HTTPStatusError | httpx.RequestError): + if not _is_http_transport_error(http_error): return http_error if not _transport_error_urls_are_safe(http_error): @@ -186,8 +213,7 @@ def _first_unsafe_transport_error(http_errors: list[Exception]) -> Exception | N ( error for error in http_errors - if isinstance(error, httpx.HTTPStatusError | httpx.RequestError) - and not _transport_error_urls_are_safe(error) + if _is_http_transport_error(error) and not _transport_error_urls_are_safe(error) ), None, ) @@ -200,7 +226,7 @@ def _first_unretainable_transport_error(http_errors: list[Exception]) -> Excepti def _is_http_transport_error(error: BaseException) -> bool: """Return whether an exception is an HTTPX transport error.""" - return isinstance(error, httpx.HTTPStatusError | httpx.RequestError) + return is_http_status_error(error) or is_http_request_error(error) def _credential_safe_exception_group(error_group: BaseExceptionGroup) -> BaseExceptionGroup: @@ -245,11 +271,11 @@ def _log_transport_warning(message: str, http_error: Exception) -> None: def _get_cleanup_transport_error_message(http_error: Exception) -> str: """Return the cleanup warning message for an HTTPX transport failure.""" - if isinstance(http_error, httpx.HTTPStatusError): + if is_http_status_error(http_error): return "HTTP error during cleanup of MCP server" - if isinstance(http_error, httpx.ConnectError): + if is_http_connect_error(http_error): return "Connection error during cleanup of MCP server" - if isinstance(http_error, httpx.TimeoutException): + if is_http_timeout_error(http_error): return "Timeout error during cleanup of MCP server" return "Request error during cleanup of MCP server" @@ -261,10 +287,20 @@ def _log_cleanup_transport_warning(message: str) -> None: def _create_default_streamable_http_client( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, -) -> httpx.AsyncClient: + timeout: Any = None, + auth: Any = None, +) -> Any: kwargs: dict[str, Any] = {"follow_redirects": False} + if MCP_V2: + _validate_v2_http_auth(auth) + if timeout is not None: + kwargs["timeout"] = timeout + if headers is not None: + kwargs["headers"] = headers + if auth is not None: + kwargs["auth"] = auth + return MCP_HTTPX.AsyncClient(**kwargs) + if timeout is not None: kwargs["timeout"] = timeout if headers is not None: @@ -274,7 +310,105 @@ def _create_default_streamable_http_client( return httpx.AsyncClient(**kwargs) -class _InitializedNotificationTolerantStreamableHTTPTransport(StreamableHTTPTransport): +def _validate_v2_http_auth(auth: Any) -> None: + if auth is None or isinstance(auth, MCP_HTTPX.Auth): + return + raise UserError( + "MCP Python SDK v2 requires auth to be an httpx2.Auth instance. " + "Use httpx2 authentication, configure an Authorization header, or pin mcp<2." + ) + + +def _validated_v2_http_client_factory(factory: Callable[..., Any]) -> Callable[..., Any]: + def create_client( + headers: dict[str, str] | None = None, + timeout: Any = None, + auth: Any = None, + ) -> Any: + _validate_v2_http_auth(auth) + client = factory(headers=headers, timeout=timeout, auth=auth) + if not isinstance(client, MCP_HTTPX.AsyncClient): + raise UserError( + "MCP Python SDK v2 requires httpx_client_factory to return an " + "httpx2.AsyncClient. Use an httpx2 factory or pin mcp<2." + ) + return client + + return create_client + + +def _jsonrpc_request_method(request: Any) -> str | None: + try: + payload = json.loads(request.content) + except (TypeError, ValueError, UnicodeDecodeError): + return None + if not isinstance(payload, dict): + return None + method = payload.get("method") + return method if isinstance(method, str) else None + + +def _configure_v2_session_id_hook( + client: Any, + *, + on_session_id: Callable[[str], None] | None, +) -> None: + async def handle_response(response: Any) -> None: + if response.status_code >= 500: + response.raise_for_status() + method = _jsonrpc_request_method(response.request) + if ( + on_session_id is not None + and method == "initialize" + and 200 <= response.status_code < 300 + ): + session_id = response.headers.get("mcp-session-id") + if session_id: + on_session_id(session_id) + + client.event_hooks.setdefault("response", []).append(handle_response) + + +@asynccontextmanager +async def _streamablehttp_client_v2( + url: str, + *, + headers: dict[str, str] | None, + timeout: float | timedelta, # noqa: ASYNC109 + sse_read_timeout: float | timedelta, + terminate_on_close: bool, + httpx_client_factory: Callable[..., Any], + auth: Any, + on_session_id: Callable[[str], None] | None, +) -> AsyncGenerator[MCPStreamTransport, None]: + timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout + sse_read_timeout_seconds = ( + sse_read_timeout.total_seconds() + if isinstance(sse_read_timeout, timedelta) + else sse_read_timeout + ) + factory = _validated_v2_http_client_factory(httpx_client_factory) + client = factory( + headers=headers, + timeout=MCP_HTTPX.Timeout(timeout_seconds, read=sse_read_timeout_seconds), + auth=auth, + ) + _configure_v2_session_id_hook( + client, + on_session_id=on_session_id, + ) + async with client: + async with streamable_http_client_v2( + url, + http_client=client, + terminate_on_close=terminate_on_close, + ) as streams: + yield streams + + +class _InitializedNotificationTolerantStreamableHTTPTransport( + StreamableHTTPTransport # type: ignore[misc, valid-type] +): async def _handle_post_request(self, ctx: Any) -> None: message = ctx.session_message.message if not self._is_initialized_notification(message): @@ -283,7 +417,7 @@ async def _handle_post_request(self, ctx: Any) -> None: try: await super()._handle_post_request(ctx) - except httpx.HTTPError as exc: + except HTTP_ERROR_TYPES as exc: _log_transport_warning( "Ignoring initialized notification HTTP failure", exc, @@ -302,7 +436,7 @@ async def _streamablehttp_client_with_transport( terminate_on_close: bool = True, httpx_client_factory: HttpClientFactory = _create_default_streamable_http_client, auth: httpx.Auth | None = None, - transport_factory: Callable[[str], StreamableHTTPTransport] = StreamableHTTPTransport, + transport_factory: Callable[[str], Any] = StreamableHTTPTransport, ) -> AsyncGenerator[MCPStreamTransport, None]: timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout sse_read_timeout_seconds = ( @@ -361,6 +495,12 @@ def start_get_stream() -> None: await write_stream.aclose() +def _require_streamablehttp_client_v1() -> Callable[..., Any]: + if streamablehttp_client is None: # pragma: no cover - guarded by MCP major + raise RuntimeError("The legacy streamable HTTP client requires MCP Python SDK v1.") + return cast(Callable[..., Any], streamablehttp_client) + + class _SharedSessionRequestNeedsIsolation(Exception): """Raised when a shared-session request should be retried on an isolated session.""" @@ -379,17 +519,7 @@ class _UnsetType: from ..agent import AgentBase -MCPStreamTransport = ( - tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - ] - | tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - GetSessionIdCallback | None, - ] -) +MCPStreamTransport = tuple[Any, Any] | tuple[Any, Any, GetSessionIdCallback | None] class MCPServer(abc.ABC): @@ -506,14 +636,15 @@ async def list_resources(self, cursor: str | None = None) -> ListResourcesResult Args: cursor: An opaque pagination cursor returned in a previous - :class:`~mcp.types.ListResourcesResult` as ``nextCursor``. Pass it - here to fetch the next page of results. ``None`` fetches the first - page. + :class:`~mcp.types.ListResourcesResult` as ``next_cursor`` under + MCP v2 or ``nextCursor`` under MCP v1. Pass it here to fetch the + next page of results. ``None`` fetches the first page. Returns a :class:`~mcp.types.ListResourcesResult`. When the result contains - a ``nextCursor`` field, call this method again with that cursor to retrieve - the next page. Subclasses that do not support resources may leave this - unimplemented; it will raise :exc:`NotImplementedError` at call time. + a ``next_cursor`` field under MCP v2 or ``nextCursor`` under MCP v1, call + this method again with that cursor to retrieve the next page. Subclasses + that do not support resources may leave this unimplemented; it will raise + :exc:`NotImplementedError` at call time. """ raise NotImplementedError( f"MCP server '{self._error_name}' does not support list_resources. " @@ -527,15 +658,15 @@ async def list_resource_templates( Args: cursor: An opaque pagination cursor returned in a previous - :class:`~mcp.types.ListResourceTemplatesResult` as ``nextCursor``. - Pass it here to fetch the next page of results. ``None`` fetches - the first page. + :class:`~mcp.types.ListResourceTemplatesResult` as ``next_cursor`` + under MCP v2 or ``nextCursor`` under MCP v1. Pass it here to fetch + the next page of results. ``None`` fetches the first page. Returns a :class:`~mcp.types.ListResourceTemplatesResult`. When the result - contains a ``nextCursor`` field, call this method again with that cursor to - retrieve the next page. Subclasses that do not support resource templates - may leave this unimplemented; it will raise :exc:`NotImplementedError` at - call time. + contains a ``next_cursor`` field under MCP v2 or ``nextCursor`` under MCP + v1, call this method again with that cursor to retrieve the next page. + Subclasses that do not support resource templates may leave this + unimplemented; it will raise :exc:`NotImplementedError` at call time. """ raise NotImplementedError( f"MCP server '{self._error_name}' does not support list_resource_templates. " @@ -793,6 +924,7 @@ def __init__( self.tool_filter = tool_filter self._serialize_session_requests = False self._get_session_id: GetSessionIdCallback | None = None + self._v2_session_id: str | None = None async def _maybe_serialize_request(self, func: Callable[[], Awaitable[T]]) -> T: if not self._serialize_session_requests: @@ -927,7 +1059,8 @@ def invalidate_tools_cache(self): def _extract_http_errors_from_exception(self, e: BaseException) -> list[Exception]: """Extract all HTTP errors from an exception or nested ExceptionGroup.""" - if isinstance(e, httpx.HTTPStatusError | httpx.RequestError): + if _is_http_transport_error(e): + assert isinstance(e, Exception) return [e] if isinstance(e, BaseExceptionGroup): @@ -947,16 +1080,16 @@ def _select_cleanup_transport_error(self, error: BaseException) -> Exception | N return unsafe_http_error candidates = error.exceptions if isinstance(error, BaseExceptionGroup) else (error,) - for error_type in ( - httpx.HTTPStatusError, - httpx.ConnectError, - httpx.TimeoutException, + for error_types in ( + HTTP_STATUS_ERROR_TYPES, + HTTP_CONNECT_ERROR_TYPES, + HTTP_TIMEOUT_ERROR_TYPES, ): selected_http_error = next( ( candidate for candidate in reversed(candidates) - if isinstance(candidate, Exception) and isinstance(candidate, error_type) + if isinstance(candidate, Exception) and isinstance(candidate, error_types) ), None, ) @@ -973,18 +1106,18 @@ def _user_error_for_http_error( ) -> UserError: """Build a UserError from safe HTTP diagnostics.""" error_message = f"Failed to connect to MCP server '{self._error_name}': " - if isinstance(http_error, httpx.HTTPStatusError): - error_message += f"HTTP error {http_error.response.status_code}" + if is_http_status_error(http_error): + error_message += f"HTTP error {http_status_code(http_error)}" if include_http_reason_phrase: - error_message += f" ({http_error.response.reason_phrase})" + error_message += f" ({http_reason_phrase(http_error)})" - elif isinstance(http_error, httpx.ConnectError): + elif is_http_connect_error(http_error): error_message += "Could not reach the server." - elif isinstance(http_error, httpx.TimeoutException): + elif is_http_timeout_error(http_error): error_message += "Connection timeout." - elif isinstance(http_error, httpx.RequestError): + elif is_http_request_error(http_error): error_message += "Request failed." return UserError(error_message) @@ -1003,11 +1136,11 @@ def _user_error_for_request_operation( ) -> UserError: """Build a credential-safe error for an MCP request operation.""" error_message = f"Failed to {operation} on MCP server '{self._error_name}': " - if isinstance(http_error, httpx.HTTPStatusError): - error_message += f"HTTP error {http_error.response.status_code}" - elif isinstance(http_error, httpx.ConnectError): + if is_http_status_error(http_error): + error_message += f"HTTP error {http_status_code(http_error)}" + elif is_http_connect_error(http_error): error_message += "Connection lost. The server may have disconnected." - elif isinstance(http_error, httpx.TimeoutException): + elif is_http_timeout_error(http_error): error_message += "Connection timeout." else: error_message += "Request failed." @@ -1023,7 +1156,7 @@ async def _run_request_with_transport_error_redaction( base_error_group: BaseExceptionGroup | None = None try: return await func() - except (httpx.HTTPStatusError, httpx.RequestError) as http_error: + except HTTP_STATUS_ERROR_TYPES + HTTP_REQUEST_ERROR_TYPES as http_error: transport_error = self._user_error_for_request_operation(operation, http_error) except BaseExceptionGroup as error_group: http_errors = self._extract_http_errors_from_exception(error_group) @@ -1066,6 +1199,79 @@ async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: backoff = self.retry_backoff_seconds_base * (2 ** (attempts - 1)) await asyncio.sleep(backoff) + @asynccontextmanager + async def _client_session_context(self, read_timeout: timedelta | float | None): + """Create one initialized or discovered client session for the installed MCP major.""" + async with AsyncExitStack() as exit_stack: + if MCP_V2: + v2_timeout = cast(float | None, read_timeout) + session_ready: asyncio.Future[ClientSession] = ( + asyncio.get_running_loop().create_future() + ) + close_client = asyncio.Event() + + async def run_client() -> None: + try: + client = create_v2_client( + self.create_streams(), + read_timeout_seconds=v2_timeout, + message_handler=self.message_handler, + ) + async with client as connected_client: + session_ready.set_result(connected_client.session) + await close_client.wait() + except BaseException as exc: + if not session_ready.done(): + session_ready.set_exception(exc) + return + raise + + client_task = asyncio.create_task(run_client()) + try: + try: + session = await asyncio.shield(session_ready) + except asyncio.CancelledError: + session_ready.cancel() + client_task.cancel() + try: + await client_task + except BaseException: + pass + raise + except BaseException: + await client_task + raise + + try: + yield session + finally: + close_client.set() + try: + await client_task + except asyncio.CancelledError: + client_task.cancel() + try: + await client_task + except BaseException: + pass + raise + finally: + close_client.set() + return + + transport = await exit_stack.enter_async_context(self.create_streams()) + read, write, *rest = transport + session = await exit_stack.enter_async_context( + cast(Any, ClientSession)( + read, + write, + cast(timedelta | None, read_timeout), + message_handler=self.message_handler, + ) + ) + await session.initialize() + yield session + async def connect(self): """Connect to the server.""" read_timeout = _client_session_read_timeout(self.client_session_timeout_seconds) @@ -1075,24 +1281,25 @@ async def connect(self): connection_exception: BaseException | None = None cleanup_failure: BaseException | None = None try: - transport = await self.exit_stack.enter_async_context(self.create_streams()) - # streamablehttp_client returns (read, write, get_session_id) - # sse_client returns (read, write) - - read, write, *rest = transport - # Capture the session-id callback when present (streamablehttp_client only). - self._get_session_id = rest[0] if rest and callable(rest[0]) else None - - session = await self.exit_stack.enter_async_context( - ClientSession( - read, - write, - read_timeout, - message_handler=self.message_handler, + if MCP_V2: + session = await self.exit_stack.enter_async_context( + self._client_session_context(read_timeout) ) - ) - server_result = await session.initialize() - self.server_initialize_result = server_result + self.server_initialize_result = getattr(session, "initialize_result", None) + else: + v1_read_timeout = cast(timedelta | None, read_timeout) + transport = await self.exit_stack.enter_async_context(self.create_streams()) + read, write, *rest = transport + self._get_session_id = rest[0] if rest and callable(rest[0]) else None + session = await self.exit_stack.enter_async_context( + cast(Any, ClientSession)( + read, + write, + v1_read_timeout, + message_handler=self.message_handler, + ) + ) + self.server_initialize_result = await session.initialize() self.session = session connection_succeeded = True except BaseException as e: @@ -1106,9 +1313,10 @@ async def connect(self): unsafe_http_error = _first_unretainable_transport_error(http_errors) http_error = unsafe_http_error or http_errors[0] connection_cause = _safe_transport_cause(http_error) - maps_safe_error = isinstance( - http_error, - httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException, + maps_safe_error = ( + is_http_status_error(http_error) + or is_http_connect_error(http_error) + or is_http_timeout_error(http_error) ) if connection_cause is not None and not maps_safe_error: connection_exception = e @@ -1191,7 +1399,7 @@ async def fetch_pages() -> bool: result = await self._list_tools_page(session, cursor) tools.extend(result.tools) seen_cursors.add(cursor) - next_cursor = result.nextCursor + next_cursor = result_next_cursor(result) if next_cursor is None: return True if next_cursor in seen_cursors: @@ -1237,23 +1445,23 @@ async def fetch_pages() -> bool: if self.tool_filter is not None: filtered_tools = await self._apply_tool_filter(filtered_tools, run_context, agent) return filtered_tools - except httpx.HTTPStatusError as e: - status_code = e.response.status_code + except HTTP_STATUS_ERROR_TYPES as e: + status_code = http_status_code(e) transport_error = UserError( f"Failed to list tools from MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except httpx.RequestError as e: + except HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) - if transport_cause is not None and not isinstance(e, httpx.ConnectError): + if transport_cause is not None and not is_http_connect_error(e): raise - if isinstance(e, httpx.ConnectError): + if is_http_connect_error(e): transport_error = UserError( f"Failed to list tools from MCP server '{self._error_name}': Connection lost. " f"The server may have disconnected." ) - elif isinstance(e, httpx.TimeoutException): + elif is_http_timeout_error(e): transport_error = UserError( f"Failed to list tools from MCP server '{self._error_name}': " "Connection timeout." @@ -1290,26 +1498,26 @@ async def call_tool( ) return await self._run_with_retries( lambda: self._maybe_serialize_request( - lambda: session.call_tool(tool_name, arguments, meta=meta) + lambda: cast(Any, session).call_tool(tool_name, arguments, meta=meta) ) ) - except httpx.HTTPStatusError as e: - status_code = e.response.status_code + except HTTP_STATUS_ERROR_TYPES as e: + status_code = http_status_code(e) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except httpx.RequestError as e: + except HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) - if transport_cause is not None and not isinstance(e, httpx.ConnectError): + if transport_cause is not None and not is_http_connect_error(e): raise - if isinstance(e, httpx.ConnectError): + if is_http_connect_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection lost. The server may have disconnected." ) - elif isinstance(e, httpx.TimeoutException): + elif is_http_timeout_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection timeout." @@ -1331,10 +1539,10 @@ def _validate_required_parameters( return tool = next((item for item in self._tools_list if item.name == tool_name), None) - if tool is None or not isinstance(tool.inputSchema, dict): + if tool is None or not isinstance(tool_input_schema(tool), dict): return - raw_required = tool.inputSchema.get("required") + raw_required = tool_input_schema(tool).get("required") if not isinstance(raw_required, list) or not raw_required: return @@ -1366,11 +1574,11 @@ async def list_prompts( session = self.session assert session is not None result = await self._list_prompts_page(session) - if result.nextCursor is None: + if result_next_cursor(result) is None: return result prompts = list(result.prompts) - cursor: str | None = result.nextCursor + cursor: str | None = result_next_cursor(result) seen_cursors: set[str | None] = {None} pagination_failure: BaseException | None = None repeated_cursor = False @@ -1391,7 +1599,7 @@ async def list_prompts( break prompts.extend(page.prompts) seen_cursors.add(cursor) - next_cursor = page.nextCursor + next_cursor = result_next_cursor(page) if next_cursor is not None and next_cursor in seen_cursors: repeated_cursor = True break @@ -1410,7 +1618,7 @@ async def list_prompts( f"MCP server '{self._error_name}' returned a repeated cursor while listing prompts." ) from None - return result.model_copy(update={"prompts": prompts, "nextCursor": None}) + return cast(ListPromptsResult, clear_result_next_cursor(result, prompts=prompts)) async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None @@ -1433,7 +1641,15 @@ async def list_resources(self, cursor: str | None = None) -> ListResourcesResult assert session is not None return await self._run_request_with_transport_error_redaction( "list resources", - lambda: self._maybe_serialize_request(lambda: session.list_resources(cursor)), + lambda: self._maybe_serialize_request( + lambda: ( + session.list_resources() + if cursor is None + else session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + ) + if MCP_V2 + else cast(Any, session).list_resources(cursor) + ), ) async def list_resource_templates( @@ -1446,7 +1662,17 @@ async def list_resource_templates( assert session is not None return await self._run_request_with_transport_error_redaction( "list resource templates", - lambda: self._maybe_serialize_request(lambda: session.list_resource_templates(cursor)), + lambda: self._maybe_serialize_request( + lambda: ( + session.list_resource_templates() + if cursor is None + else session.list_resource_templates( + params=PaginatedRequestParams(cursor=cursor) + ) + ) + if MCP_V2 + else cast(Any, session).list_resource_templates(cursor) + ), ) async def read_resource(self, uri: str) -> ReadResourceResult: @@ -1460,11 +1686,11 @@ async def read_resource(self, uri: str) -> ReadResourceResult: raise UserError("Server not initialized. Make sure you call `connect()` first.") session = self.session assert session is not None - from pydantic import AnyUrl - return await self._run_request_with_transport_error_redaction( "read resource", - lambda: self._maybe_serialize_request(lambda: session.read_resource(AnyUrl(uri))), + lambda: self._maybe_serialize_request( + lambda: cast(Any, session).read_resource(resource_uri(uri)) + ), ) async def cleanup(self): @@ -1485,7 +1711,11 @@ async def cleanup(self): e, ) raise - except (BaseExceptionGroup, httpx.HTTPStatusError, httpx.RequestError) as e: + except ( # type: ignore[misc] + BaseExceptionGroup, + *HTTP_STATUS_ERROR_TYPES, + *HTTP_REQUEST_ERROR_TYPES, + ) as e: selected_http_error = self._select_cleanup_transport_error(e) if selected_http_error is not None: if is_failed_connection_cleanup: @@ -1500,7 +1730,7 @@ async def cleanup(self): _get_cleanup_transport_error_message(selected_http_error), self ) ) - elif isinstance(e, httpx.RequestError): + elif is_http_request_error(e): _log_cleanup_transport_warning( get_mcp_server_log_message(_get_cleanup_transport_error_message(e), self) ) @@ -1559,6 +1789,7 @@ async def cleanup(self): finally: self.session = None self._get_session_id = None + self._v2_session_id = None if cleanup_error is not None: self._raise_mapped_transport_error(cleanup_error, None) @@ -1709,15 +1940,16 @@ class MCPServerSseParams(TypedDict): sse_read_timeout: NotRequired[float] """The timeout for the SSE connection, in seconds. Defaults to 5 minutes.""" - auth: NotRequired[httpx.Auth | None] - """Optional httpx authentication handler (e.g. ``httpx.BasicAuth``, a custom - ``httpx.Auth`` subclass for OAuth token refresh, etc.). When provided, it is - passed directly to the underlying ``httpx.AsyncClient`` used by the SSE transport. + auth: NotRequired[Any] + """Optional authentication handler for the installed MCP SDK's HTTP stack. + + Use ``httpx.Auth`` with MCP v1 or ``httpx2.Auth`` with MCP v2. """ httpx_client_factory: NotRequired[HttpClientFactory] - """Custom HTTP client factory for configuring httpx.AsyncClient behavior (e.g. - to set custom SSL certificates, proxies, or other transport options). + """Custom HTTP client factory for the installed MCP SDK's HTTP stack. + + Return ``httpx.AsyncClient`` with MCP v1 or ``httpx2.AsyncClient`` with MCP v2. """ @@ -1814,6 +2046,16 @@ def create_streams( "timeout": self.params.get("timeout", 5), "sse_read_timeout": self.params.get("sse_read_timeout", 60 * 5), } + if MCP_V2: + _validate_v2_http_auth(self.params.get("auth")) + factory = ( + self.params.get("httpx_client_factory") or _create_default_streamable_http_client + ) + kwargs["httpx_client_factory"] = _validated_v2_http_client_factory(factory) + if "auth" in self.params: + kwargs["auth"] = self.params["auth"] + return sse_client(**kwargs) + if "auth" in self.params: kwargs["auth"] = self.params["auth"] kwargs["httpx_client_factory"] = ( @@ -1846,13 +2088,15 @@ class MCPServerStreamableHttpParams(TypedDict): """Terminate on close""" httpx_client_factory: NotRequired[HttpClientFactory] - """Custom HTTP client factory for configuring httpx.AsyncClient behavior.""" + """Custom HTTP client factory for the installed MCP SDK's HTTP stack. + + Return ``httpx.AsyncClient`` with MCP v1 or ``httpx2.AsyncClient`` with MCP v2. + """ + + auth: NotRequired[Any] + """Optional authentication handler for the installed MCP SDK's HTTP stack. - auth: NotRequired[httpx.Auth | None] - """Optional httpx authentication handler (e.g. ``httpx.BasicAuth``, a custom - ``httpx.Auth`` subclass for OAuth token refresh, etc.). When provided, it is - passed directly to the underlying ``httpx.AsyncClient`` used by the Streamable HTTP - transport. + Use ``httpx.Auth`` with MCP v1 or ``httpx2.Auth`` with MCP v2. """ ignore_initialized_notification_failure: NotRequired[bool] @@ -1860,7 +2104,9 @@ class MCPServerStreamableHttpParams(TypedDict): ``notifications/initialized`` POST. Defaults to ``False``. When set to ``True``, initialized-notification failures are - logged and ignored so subsequent requests on the same transport can continue. + logged and ignored so subsequent requests on the same transport can continue. This + option requires MCP Python SDK v1; MCP v2 rejects it before connecting because its + public transport API does not expose these failures. """ @@ -1961,6 +2207,30 @@ def create_streams( "terminate_on_close": self.params.get("terminate_on_close", True), } httpx_client_factory = self.params.get("httpx_client_factory") + if MCP_V2: + if self.params.get("ignore_initialized_notification_failure", False): + raise UserError( + "ignore_initialized_notification_failure is not supported with MCP Python " + "SDK v2 because its public transport API does not expose initialized-" + "notification failures. Leave it disabled or pin mcp<2." + ) + _validate_v2_http_auth(self.params.get("auth")) + on_session_id: Callable[[str], None] | None = None + if self.session is None: + self._v2_session_id = None + + def capture_session_id(session_id: str) -> None: + self._v2_session_id = session_id + + on_session_id = capture_session_id + self._get_session_id = lambda: self._v2_session_id + return _streamablehttp_client_v2( + **kwargs, + httpx_client_factory=httpx_client_factory or _create_default_streamable_http_client, + auth=self.params.get("auth"), + on_session_id=on_session_id, + ) + if self.params.get("ignore_initialized_notification_failure", False): return _streamablehttp_client_with_transport( **kwargs, @@ -1973,23 +2243,15 @@ def create_streams( ) if "auth" in self.params: kwargs["auth"] = self.params["auth"] - return streamablehttp_client(**kwargs) + return cast( + AbstractAsyncContextManager[MCPStreamTransport], + _require_streamablehttp_client_v1()(**kwargs), + ) @asynccontextmanager async def _isolated_client_session(self): read_timeout = _client_session_read_timeout(self.client_session_timeout_seconds) - async with AsyncExitStack() as exit_stack: - transport = await exit_stack.enter_async_context(self.create_streams()) - read, write, *_ = transport - session = await exit_stack.enter_async_context( - ClientSession( - read, - write, - read_timeout, - message_handler=self.message_handler, - ) - ) - await session.initialize() + async with self._client_session_context(read_timeout) as session: yield session async def _call_tool_with_session( @@ -2001,21 +2263,20 @@ async def _call_tool_with_session( ) -> CallToolResult: if meta is None: return await session.call_tool(tool_name, arguments) - return await session.call_tool(tool_name, arguments, meta=meta) + return cast( + CallToolResult, + await cast(Any, session).call_tool(tool_name, arguments, meta=meta), + ) def _should_retry_in_isolated_session(self, exc: BaseException) -> bool: - if isinstance( - exc, - asyncio.CancelledError - | ClosedResourceError - | httpx.ConnectError - | httpx.TimeoutException, - ): + if isinstance(exc, asyncio.CancelledError | ClosedResourceError): + return True + if is_http_connect_error(exc) or is_http_timeout_error(exc): return True - if isinstance(exc, httpx.HTTPStatusError): - return exc.response.status_code >= 500 - if isinstance(exc, McpError): - return exc.error.code == httpx.codes.REQUEST_TIMEOUT + if is_http_status_error(exc): + return http_status_code(exc) >= 500 + if isinstance(exc, MCPError): + return is_mcp_timeout_error(exc) or is_mcp_connection_closed_error(exc) if isinstance(exc, BaseExceptionGroup): return bool(exc.exceptions) and all( self._should_retry_in_isolated_session(inner) for inner in exc.exceptions @@ -2140,23 +2401,23 @@ async def call_tool( backoffs_taken += 1 await asyncio.sleep(backoff) first_attempt = False - except httpx.HTTPStatusError as e: - status_code = e.response.status_code + except HTTP_STATUS_ERROR_TYPES as e: + status_code = http_status_code(e) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except httpx.RequestError as e: + except HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) - if transport_cause is not None and not isinstance(e, httpx.ConnectError): + if transport_cause is not None and not is_http_connect_error(e): raise - if isinstance(e, httpx.ConnectError): + if is_http_connect_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection lost. The server may have disconnected." ) - elif isinstance(e, httpx.TimeoutException): + elif is_http_timeout_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection timeout." @@ -2174,23 +2435,23 @@ async def call_tool( unsafe_http_error = _first_unretainable_transport_error(http_errors) http_error = unsafe_http_error or http_errors[0] transport_cause = _safe_transport_cause(http_error) - if isinstance(http_error, httpx.HTTPStatusError): - status_code = http_error.response.status_code + if is_http_status_error(http_error): + status_code = http_status_code(http_error) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) - elif isinstance(http_error, httpx.ConnectError): + elif is_http_connect_error(http_error): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection lost. The server may have disconnected." ) - elif isinstance(http_error, httpx.TimeoutException): + elif is_http_timeout_error(http_error): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection timeout." ) - elif isinstance(http_error, httpx.RequestError): + elif is_http_request_error(http_error): if transport_cause is not None: raise transport_error = UserError( @@ -2214,13 +2475,13 @@ def name(self) -> str: @property def session_id(self) -> str | None: - """The MCP session ID assigned by the server, or None if not yet connected - or if the server did not issue a session ID. + """The legacy MCP session ID assigned by the server, if one is available. - The session ID is stable for the lifetime of this server instance's connection. - You can persist it and pass it back via the Mcp-Session-Id request header - (params["headers"]) on a new MCPServerStreamableHttp instance to resume - the same server-side session across process restarts or stateless workers. + MCP 2026-07-28 does not use protocol sessions, so this property returns None for a + modern connection. It also returns None before connection or when a legacy server does + not issue a session ID. A legacy session ID is stable for this instance's connection and + can be passed through the Mcp-Session-Id request header when reconnecting to a server that + supports legacy session resumption. Example:: diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index af62873f6b..3a29bec5ba 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -12,17 +12,11 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Protocol, Union -import httpx from typing_extensions import NotRequired, TypedDict from .. import _debug from .._mcp_tool_metadata import resolve_mcp_tool_description_for_model, resolve_mcp_tool_title from ..exceptions import AgentsException, MCPToolCancellationError, ModelBehaviorError, UserError - -try: - from mcp.shared.exceptions import McpError as _McpError -except ImportError: # pragma: no cover – mcp is optional on Python < 3.10 - _McpError = None # type: ignore[assignment, misc] from ..logger import log_tool_action_error, logger from ..run_context import RunContextWrapper from ..strict_schema import ensure_strict_json_schema @@ -42,6 +36,14 @@ from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span from ..util._custom_data import maybe_extract_custom_data from ..util._types import MaybeAwaitable +from ._compat import ( + MCPError, + image_mime_type, + mcp_error_message, + result_is_error, + result_structured_content, + tool_input_schema, +) from ._logging import get_mcp_server_log_message, get_mcp_server_log_name if TYPE_CHECKING: @@ -73,18 +75,19 @@ class _PrefixedToolNameCandidate: class HttpClientFactory(Protocol): - """Protocol for HTTP client factory functions. + """Protocol for MCP HTTP client factory functions. - This interface matches the MCP SDK's McpHttpClientFactory but is defined locally - to avoid accessing internal MCP SDK modules. + The factory must use the HTTP stack required by the installed MCP SDK: ``httpx`` + for MCP v1 or ``httpx2`` for MCP v2. This protocol avoids importing either SDK's + private factory type. """ def __call__( self, headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: ... + timeout: Any = None, + auth: Any = None, + ) -> Any: ... @dataclass @@ -532,7 +535,7 @@ def to_function_tool( effective_failure_error_function = server._get_failure_error_function( failure_error_function ) - schema, is_strict = copy.deepcopy(tool.inputSchema), False + schema, is_strict = copy.deepcopy(tool_input_schema(tool)), False # MCP spec doesn't require the inputSchema to have `properties`, but OpenAI spec does. if "properties" not in schema: @@ -620,8 +623,8 @@ async def _extract_custom_data( tool_display_name=tool_display_name, arguments=MappingProxyType(copy.deepcopy(arguments)), result_meta=cls._copy_mapping_proxy(getattr(result, "meta", None)), - structured_content=cls._copy_mapping_proxy(getattr(result, "structuredContent", None)), - is_error=getattr(result, "isError", None), + structured_content=cls._copy_mapping_proxy(result_structured_content(result)), + is_error=result_is_error(result), tool_output=copy.deepcopy(tool_output), ) return await maybe_extract_custom_data(extractor, extractor_context) @@ -724,7 +727,7 @@ async def invoke_mcp_tool( # will format them into model-visible tool errors when appropriate. raise except Exception as e: - if _McpError is not None and isinstance(e, _McpError): + if isinstance(e, MCPError): # An MCP-level error (e.g. upstream HTTP 4xx/5xx, tool not found, etc.) # is not a programming error – re-raise so the FunctionTool failure # pipeline (failure_error_function) can handle it. The default handler @@ -734,7 +737,7 @@ async def invoke_mcp_tool( logger.warning("MCP tool returned an error.") else: server_log_name = get_mcp_server_log_name(server.name) - error_text = e.error.message if hasattr(e, "error") and e.error else str(e) + error_text = mcp_error_message(e) logger.warning( "MCP tool %s on server '%s' returned an error: %s", tool_name_for_display, @@ -761,8 +764,9 @@ async def invoke_mcp_tool( # If structured content is requested and available, use it exclusively tool_output: ToolOutput - if server.use_structured_content and result.structuredContent: - tool_output = json.dumps(result.structuredContent) + structured_content = result_structured_content(result) + if server.use_structured_content and structured_content: + tool_output = json.dumps(structured_content) else: tool_output_list: list[ToolOutputItem] = [] for item in result.content: @@ -771,7 +775,8 @@ async def invoke_mcp_tool( elif item.type == "image": tool_output_list.append( ToolOutputImageDict( - type="image", image_url=f"data:{item.mimeType};base64,{item.data}" + type="image", + image_url=f"data:{image_mime_type(item)};base64,{item.data}", ) ) else: diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py index 59a5b9a8f9..6e7d080b2d 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -5,14 +5,13 @@ import shutil from typing import Any -from mcp import Tool as MCPTool +from mcp import Tool as MCPToolType from mcp.types import ( CallToolResult, - Content, + ContentBlock, GetPromptResult, ListPromptsResult, ListResourcesResult, - ListResourceTemplatesResult, PromptMessage, ReadResourceResult, TextContent, @@ -23,6 +22,8 @@ from agents.mcp.util import MCPToolCustomDataExtractor, MCPToolMetaResolver, ToolFilter from agents.tool import ToolErrorFunction +from .model_compat import ListResourceTemplatesResult, Tool as MCPTool + tee = shutil.which("tee") or "" assert tee, "tee not found" @@ -70,7 +71,7 @@ def name(self) -> str: class FakeMCPServer(MCPServer): def __init__( self, - tools: list[MCPTool] | None = None, + tools: list[MCPToolType] | None = None, tool_filter: ToolFilter = None, server_name: str = "fake_mcp_server", require_approval: object | None = None, @@ -85,13 +86,13 @@ def __init__( tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, ) - self.tools: list[MCPTool] = tools or [] + self.tools: list[MCPToolType] = tools or [] self.tool_calls: list[str] = [] self.tool_results: list[str] = [] self.tool_metas: list[dict[str, Any] | None] = [] self.tool_filter = tool_filter self._server_name = server_name - self._custom_content: list[Content] | None = None + self._custom_content: list[ContentBlock] | None = None self._response_meta: dict[str, Any] | None = None def add_tool(self, name: str, input_schema: dict[str, Any]): diff --git a/tests/mcp/model_compat.py b/tests/mcp/model_compat.py new file mode 100644 index 0000000000..6bd6133008 --- /dev/null +++ b/tests/mcp/model_compat.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any, cast + +from mcp import Tool as _Tool +from mcp.types import ( + CallToolResult as _CallToolResult, + ImageContent as _ImageContent, + InitializeResult as _InitializeResult, + JSONRPCMessage as _JSONRPCMessage, + ListPromptsResult as _ListPromptsResult, + ListResourceTemplatesResult as _ListResourceTemplatesResult, + ListToolsResult as _ListToolsResult, + Resource as _Resource, + ResourceTemplate as _ResourceTemplate, + TextResourceContents as _TextResourceContents, +) + +from agents.mcp._compat import MCP_V2, MCPError + + +# MCP v1 and v2 accept their wire-format aliases at runtime, but expose different constructor +# signatures to static type checkers. Keep alias-based fixture construction in one test-only module. +class Tool(_Tool): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class CallToolResult(_CallToolResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ImageContent(_ImageContent): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class InitializeResult(_InitializeResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +def JSONRPCMessage(*args: Any, **kwargs: Any) -> Any: + return cast(Any, _JSONRPCMessage)(*args, **kwargs) + + +class ListPromptsResult(_ListPromptsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ListResourceTemplatesResult(_ListResourceTemplatesResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ListToolsResult(_ListToolsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class Resource(_Resource): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ResourceTemplate(_ResourceTemplate): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class TextResourceContents(_TextResourceContents): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +def create_mcp_error(code: int, message: str, data: Any = None) -> Exception: + if MCP_V2: + return cast(Exception, cast(Any, MCPError)(code=code, message=message, data=data)) + from mcp.types import ErrorData + + return cast(Exception, cast(Any, MCPError)(ErrorData(code=code, message=message, data=data))) diff --git a/tests/mcp/servers/legacy.py b/tests/mcp/servers/legacy.py new file mode 100644 index 0000000000..634c5f2a4b --- /dev/null +++ b/tests/mcp/servers/legacy.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import sys + + +def send(message: dict[str, object]) -> None: + sys.stdout.write(json.dumps(message) + "\n") + sys.stdout.flush() + + +def main() -> None: + for line in sys.stdin: + message = json.loads(line) + request_id = message.get("id") + method = message.get("method") + if request_id is None: + continue + if method == "server/discover": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": "Method not found"}, + } + ) + elif method == "initialize": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "legacy-test-server", "version": "1.0"}, + }, + } + ) + elif method == "tools/list": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "legacy_tool", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + }, + } + ) + elif method == "tools/call": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": "legacy-result"}], + "isError": False, + }, + } + ) + else: + send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Unknown method: {method}"}, + } + ) + + +if __name__ == "__main__": + main() diff --git a/tests/mcp/servers/paginated.py b/tests/mcp/servers/paginated.py index 06e706324b..c4cecda617 100644 --- a/tests/mcp/servers/paginated.py +++ b/tests/mcp/servers/paginated.py @@ -1,24 +1,42 @@ from __future__ import annotations +from importlib.metadata import version +from typing import Any + import anyio from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import ( + CallToolResult, ListPromptsRequest, - ListPromptsResult, + ListPromptsResult as _ListPromptsResult, ListToolsRequest, - ListToolsResult, + ListToolsResult as _ListToolsResult, Prompt, TextContent, - Tool, + Tool as _Tool, ) -server = Server("paginated-test-server") +class ListPromptsResult(_ListPromptsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ListToolsResult(_ListToolsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class Tool(_Tool): + def __init__(self, **data: Any) -> None: + super().__init__(**data) -@server.list_tools() # type: ignore[misc] -async def list_tools(request: ListToolsRequest) -> ListToolsResult: - cursor = request.params.cursor if request.params is not None else None + +MCP_V2 = int(version("mcp").partition(".")[0]) >= 2 + + +def tools_page(cursor: str | None) -> ListToolsResult: if cursor is None: return ListToolsResult( tools=[ @@ -41,9 +59,7 @@ async def list_tools(request: ListToolsRequest) -> ListToolsResult: raise ValueError(f"Unexpected tools cursor: {cursor}") -@server.list_prompts() # type: ignore[misc] -async def list_prompts(request: ListPromptsRequest) -> ListPromptsResult: - cursor = request.params.cursor if request.params is not None else None +def prompts_page(cursor: str | None) -> ListPromptsResult: if cursor is None: return ListPromptsResult( prompts=[Prompt(name="first_page_prompt")], @@ -58,11 +74,44 @@ async def list_prompts(request: ListPromptsRequest) -> ListPromptsResult: raise ValueError(f"Unexpected prompts cursor: {cursor}") -@server.call_tool() # type: ignore[misc] -async def call_tool(name: str, arguments: dict[str, object] | None) -> list[TextContent]: +def tool_result(name: str) -> CallToolResult: if name not in {"first_page_tool", "second_page_tool"}: raise ValueError(f"Unexpected tool: {name}") - return [TextContent(type="text", text=f"called:{name}")] + return CallToolResult(content=[TextContent(type="text", text=f"called:{name}")]) + + +if MCP_V2: + + async def list_tools_v2(_context: Any, params: Any) -> ListToolsResult: + return tools_page(params.cursor if params is not None else None) + + async def list_prompts_v2(_context: Any, params: Any) -> ListPromptsResult: + return prompts_page(params.cursor if params is not None else None) + + async def call_tool_v2(_context: Any, params: Any) -> CallToolResult: + return tool_result(params.name) + + server = Server( + "paginated-test-server", + on_list_tools=list_tools_v2, + on_list_prompts=list_prompts_v2, + on_call_tool=call_tool_v2, + ) +else: + server = Server("paginated-test-server") + + @server.list_tools() # type: ignore[attr-defined, misc] + async def list_tools_v1(request: ListToolsRequest) -> ListToolsResult: + return tools_page(request.params.cursor if request.params is not None else None) + + @server.list_prompts() # type: ignore[attr-defined, misc] + async def list_prompts_v1(request: ListPromptsRequest) -> ListPromptsResult: + return prompts_page(request.params.cursor if request.params is not None else None) + + @server.call_tool() # type: ignore[attr-defined, misc] + async def call_tool_v1(name: str, arguments: dict[str, object] | None) -> list[TextContent]: + del arguments + return tool_result(name).content # type: ignore[return-value] async def main() -> None: diff --git a/tests/mcp/test_caching.py b/tests/mcp/test_caching.py index 4465a5e057..9a3ce885fa 100644 --- a/tests/mcp/test_caching.py +++ b/tests/mcp/test_caching.py @@ -1,13 +1,14 @@ from unittest.mock import AsyncMock, call, patch import pytest -from mcp.types import ListToolsResult, PaginatedRequestParams, Tool as MCPTool +from mcp.types import PaginatedRequestParams from agents import Agent from agents.mcp import MCPServerStdio from agents.run_context import RunContextWrapper from .helpers import DummyStreamsContextManager, tee +from .model_compat import ListToolsResult, Tool as MCPTool @pytest.mark.asyncio diff --git a/tests/mcp/test_client_session_retries.py b/tests/mcp/test_client_session_retries.py index d6f2704413..b571a15c58 100644 --- a/tests/mcp/test_client_session_retries.py +++ b/tests/mcp/test_client_session_retries.py @@ -6,21 +6,20 @@ import httpx import pytest from anyio import ClosedResourceError -from mcp import ClientSession, Tool as MCPTool -from mcp.shared.exceptions import McpError +from mcp import ClientSession from mcp.types import ( CallToolResult, - ErrorData, GetPromptResult, - ListPromptsResult, - ListToolsResult, PaginatedRequestParams, Prompt, ) from agents.exceptions import UserError +from agents.mcp._compat import mcp_request_timeout_code from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession +from .model_compat import ListPromptsResult, ListToolsResult, Tool as MCPTool, create_mcp_error + if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup # pyright: ignore[reportMissingImports] @@ -359,9 +358,7 @@ def __init__(self, message: str = "timed out"): async def call_tool(self, tool_name, arguments, meta=None): self.call_tool_attempts += 1 - raise McpError( - ErrorData(code=httpx.codes.REQUEST_TIMEOUT, message=self.message), - ) + raise create_mcp_error(mcp_request_timeout_code(), self.message) class IsolatedRetrySession: diff --git a/tests/mcp/test_connect_disconnect.py b/tests/mcp/test_connect_disconnect.py index b001303974..167ae8bd0a 100644 --- a/tests/mcp/test_connect_disconnect.py +++ b/tests/mcp/test_connect_disconnect.py @@ -1,11 +1,11 @@ from unittest.mock import AsyncMock, patch import pytest -from mcp.types import ListToolsResult, Tool as MCPTool from agents.mcp import MCPServerStdio from .helpers import DummyStreamsContextManager, tee +from .model_compat import ListToolsResult, Tool as MCPTool @pytest.mark.asyncio diff --git a/tests/mcp/test_mcp_auth_params.py b/tests/mcp/test_mcp_auth_params.py index ebc6c1934e..ad634c83a6 100644 --- a/tests/mcp/test_mcp_auth_params.py +++ b/tests/mcp/test_mcp_auth_params.py @@ -8,8 +8,11 @@ import pytest from agents.mcp import MCPServerSse, MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2 from agents.mcp.server import _create_default_streamable_http_client +pytestmark = pytest.mark.skipif(MCP_V2, reason="These assertions cover the MCP v1 HTTP stack") + class TestMCPServerSseAuthAndFactory: """Tests for auth and httpx_client_factory added to MCPServerSseParams.""" diff --git a/tests/mcp/test_mcp_pagination_integration.py b/tests/mcp/test_mcp_pagination_integration.py index 30e1f03b78..48d61230ae 100644 --- a/tests/mcp/test_mcp_pagination_integration.py +++ b/tests/mcp/test_mcp_pagination_integration.py @@ -7,6 +7,7 @@ from agents import Agent, Runner from agents.mcp import MCPServerStdio +from agents.mcp._compat import MCP_V2, result_next_cursor from ..fake_model import FakeModel from ..test_responses import get_function_tool_call, get_text_message @@ -30,14 +31,20 @@ async def test_stdio_server_auto_paginates_tools_and_prompts(): async with create_paginated_server() as server: tools = await server.list_tools() prompts = await server.list_prompts() + protocol_version = getattr(server.session, "protocol_version", None) + initialize_result = server.server_initialize_result assert [tool.name for tool in tools] == ["first_page_tool", "second_page_tool"] assert [prompt.name for prompt in prompts.prompts] == [ "first_page_prompt", "second_page_prompt", ] - assert prompts.nextCursor is None - assert prompts.meta == {"page": "first"} + assert result_next_cursor(prompts) is None + assert prompts.meta is not None + assert prompts.meta["page"] == "first" + if MCP_V2: + assert protocol_version == "2026-07-28" + assert initialize_result is None @pytest.mark.asyncio diff --git a/tests/mcp/test_mcp_resources.py b/tests/mcp/test_mcp_resources.py index 75bacc99f7..b887023769 100644 --- a/tests/mcp/test_mcp_resources.py +++ b/tests/mcp/test_mcp_resources.py @@ -5,15 +5,19 @@ import pytest from mcp.types import ( ListResourcesResult, - ListResourceTemplatesResult, + PaginatedRequestParams, ReadResourceResult, +) + +from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2, resource_uri + +from .model_compat import ( + ListResourceTemplatesResult, Resource, ResourceTemplate, TextResourceContents, ) -from pydantic import AnyUrl - -from agents.mcp import MCPServerStreamableHttp @pytest.fixture @@ -54,7 +58,7 @@ async def test_list_resources_returns_result(server: MCPServerStreamableHttp): mock_session = MagicMock() expected = ListResourcesResult( resources=[ - Resource(uri=AnyUrl("file:///readme.md"), name="readme.md", mimeType="text/markdown"), + Resource(uri="file:///readme.md", name="readme.md", mimeType="text/markdown"), ] ) mock_session.list_resources = AsyncMock(return_value=expected) @@ -63,7 +67,10 @@ async def test_list_resources_returns_result(server: MCPServerStreamableHttp): result = await server.list_resources() assert result is expected - mock_session.list_resources.assert_awaited_once_with(None) + if MCP_V2: + mock_session.list_resources.assert_awaited_once_with() + else: + mock_session.list_resources.assert_awaited_once_with(None) @pytest.mark.asyncio @@ -77,7 +84,12 @@ async def test_list_resources_forwards_cursor(server: MCPServerStreamableHttp): result = await server.list_resources(cursor="tok_abc") assert result is page2 - mock_session.list_resources.assert_awaited_once_with("tok_abc") + if MCP_V2: + mock_session.list_resources.assert_awaited_once_with( + params=PaginatedRequestParams(cursor="tok_abc") + ) + else: + mock_session.list_resources.assert_awaited_once_with("tok_abc") @pytest.mark.asyncio @@ -95,7 +107,10 @@ async def test_list_resource_templates_returns_result(server: MCPServerStreamabl result = await server.list_resource_templates() assert result is expected - mock_session.list_resource_templates.assert_awaited_once_with(None) + if MCP_V2: + mock_session.list_resource_templates.assert_awaited_once_with() + else: + mock_session.list_resource_templates.assert_awaited_once_with(None) @pytest.mark.asyncio @@ -109,7 +124,12 @@ async def test_list_resource_templates_forwards_cursor(server: MCPServerStreamab result = await server.list_resource_templates(cursor="tok_xyz") assert result is page2 - mock_session.list_resource_templates.assert_awaited_once_with("tok_xyz") + if MCP_V2: + mock_session.list_resource_templates.assert_awaited_once_with( + params=PaginatedRequestParams(cursor="tok_xyz") + ) + else: + mock_session.list_resource_templates.assert_awaited_once_with("tok_xyz") @pytest.mark.asyncio @@ -119,7 +139,7 @@ async def test_read_resource_returns_result(server: MCPServerStreamableHttp): uri = "file:///readme.md" expected = ReadResourceResult( contents=[ - TextResourceContents(uri=AnyUrl(uri), text="# Hello", mimeType="text/markdown"), + TextResourceContents(uri=uri, text="# Hello", mimeType="text/markdown"), ] ) mock_session.read_resource = AsyncMock(return_value=expected) @@ -128,7 +148,7 @@ async def test_read_resource_returns_result(server: MCPServerStreamableHttp): result = await server.read_resource(uri) assert result is expected - mock_session.read_resource.assert_awaited_once_with(AnyUrl(uri)) + mock_session.read_resource.assert_awaited_once_with(resource_uri(uri)) @pytest.mark.asyncio diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index b92ca76a56..5d79b36bb6 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -9,7 +9,6 @@ GetPromptResult, ListPromptsResult, ListResourcesResult, - ListResourceTemplatesResult, ReadResourceResult, Tool as MCPTool, ) @@ -19,6 +18,8 @@ from agents.mcp._logging import get_mcp_server_log_name from agents.run_context import RunContextWrapper +from .model_compat import ListResourceTemplatesResult + class TaskBoundServer(MCPServer): def __init__(self) -> None: diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index b71ec8b776..e86bc63d99 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -7,8 +7,8 @@ import pytest from inline_snapshot import snapshot -from mcp.shared.exceptions import McpError -from mcp.types import CallToolResult, ErrorData, ImageContent, TextContent, Tool as MCPTool +from mcp import Tool as MCPToolType +from mcp.types import CallToolResult as CallToolResultType, TextContent from pydantic import BaseModel, TypeAdapter import agents._debug as _debug @@ -27,9 +27,11 @@ UserError, ) from agents.mcp import MCPServer, MCPUtil +from agents.mcp._compat import MCPError, tool_input_schema from agents.tool_context import ToolContext from .helpers import FakeMCPServer +from .model_compat import CallToolResult, ImageContent, Tool as MCPTool, create_mcp_error class Foo(BaseModel): @@ -668,7 +670,7 @@ async def call_tool( tool_name: str, arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, - ) -> CallToolResult: + ) -> CallToolResultType: if meta is not None: meta["nested"]["headers"].append("mutated") return await super().call_tool(tool_name, arguments, meta=meta) @@ -864,7 +866,7 @@ async def call_tool( arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, ): - raise McpError(ErrorData(code=-32000, message="upstream said SECRET_MCP_123")) + raise create_mcp_error(-32000, "upstream said SECRET_MCP_123") @pytest.mark.asyncio @@ -926,7 +928,7 @@ async def test_mcp_tool_returned_error_redacts_message_when_dont_log_tool_data( ctx = RunContextWrapper(context=None) tool = MCPTool(name="SECRET_MCP_TOOL_NAME", inputSchema={}) - with pytest.raises(McpError): + with pytest.raises(MCPError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") assert "MCP tool returned an error" in caplog.text @@ -947,7 +949,7 @@ async def test_mcp_tool_returned_error_includes_message_when_tool_logging_enable ctx = RunContextWrapper(context=None) tool = MCPTool(name="test_tool_1", inputSchema={}) - with pytest.raises(McpError): + with pytest.raises(MCPError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") assert "SECRET_MCP_123" in caplog.text @@ -1157,9 +1159,6 @@ async def test_mcp_invocation_mcp_error_reraises(caplog: pytest.LogCaptureFixtur """ caplog.set_level(logging.DEBUG) - from mcp.shared.exceptions import McpError - from mcp.types import ErrorData - class McpErrorFakeMCPServer(FakeMCPServer): async def call_tool( self, @@ -1167,7 +1166,7 @@ async def call_tool( arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, ): - raise McpError(ErrorData(code=-32000, message="upstream 422 Unprocessable Entity")) + raise create_mcp_error(-32000, "upstream 422 Unprocessable Entity") server = McpErrorFakeMCPServer() server.add_tool("search", {}) @@ -1176,7 +1175,7 @@ async def call_tool( tool = MCPTool(name="search", inputSchema={}) # invoke_mcp_tool itself should re-raise McpError - with pytest.raises(McpError): + with pytest.raises(MCPError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}") # Warning (not error) should be logged before re-raising @@ -1388,7 +1387,7 @@ async def test_to_function_tool_legacy_call_callable_policy_requires_approval(): def require_approval( _run_context: RunContextWrapper[Any], _agent: Agent, - _tool: MCPTool, + _tool: MCPToolType, ) -> bool: return False @@ -1412,7 +1411,7 @@ async def test_to_function_tool_callable_policy_uses_agent_and_tool(): def require_approval( run_context: RunContextWrapper[Any], agent: Agent, - tool: MCPTool, + tool: MCPToolType, ) -> bool: captured["run_context"] = run_context captured["agent"] = agent @@ -1448,7 +1447,7 @@ async def test_to_function_tool_async_callable_policy_is_awaited(): async def require_approval( _run_context: RunContextWrapper[Any], _agent: Agent, - tool: MCPTool, + tool: MCPToolType, ) -> bool: await asyncio.sleep(0) return tool.name == "async_guarded_tool" @@ -1841,7 +1840,7 @@ def test_to_function_tool_does_not_mutate_mcp_input_schema(): "properties": {}, } assert schema == {"type": "object", "description": "Test tool"} - assert tool.inputSchema == {"type": "object", "description": "Test tool"} + assert tool_input_schema(tool) == {"type": "object", "description": "Test tool"} def test_to_function_tool_failed_strict_conversion_keeps_original_schema(): @@ -1902,7 +1901,7 @@ async def call_tool( tool_name: str, arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, - ) -> CallToolResult: + ) -> CallToolResultType: """Return test result with specified content and structured content.""" self.tool_calls.append(tool_name) diff --git a/tests/mcp/test_mcp_v2_http.py b/tests/mcp/test_mcp_v2_http.py new file mode 100644 index 0000000000..cf66823a12 --- /dev/null +++ b/tests/mcp/test_mcp_v2_http.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import asyncio +import json +import socket +from typing import Any + +import httpx +import mcp +import pytest +import uvicorn +from mcp.server import Server +from mcp.types import ListToolsResult, TextContent, Tool + +from agents.exceptions import UserError +from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2, create_v2_client +from agents.mcp.server import ( + _configure_v2_session_id_hook, + _create_default_streamable_http_client, + _validated_v2_http_client_factory, +) + +pytestmark = pytest.mark.skipif(not MCP_V2, reason="MCP v2 HTTP behavior") +httpx2 = pytest.importorskip("httpx2") + + +@pytest.mark.asyncio +async def test_v2_streamable_http_negotiates_modern_protocol(): + async def list_tools(_context, _params) -> ListToolsResult: + return ListToolsResult( + tools=[Tool(name="probe", input_schema={"type": "object", "properties": {}})] + ) + + app = Server("probe-server", on_list_tools=list_tools).streamable_http_app() + socket_ = socket.socket() + socket_.bind(("127.0.0.1", 0)) + socket_.listen() + port = socket_.getsockname()[1] + uvicorn_server = uvicorn.Server( + uvicorn.Config(app, log_level="error", lifespan="on", ws="none") + ) + server_task = asyncio.create_task(uvicorn_server.serve(sockets=[socket_])) + + async def wait_until_started() -> None: + while not uvicorn_server.started: + if server_task.done(): + await server_task + await asyncio.sleep(0.01) + + try: + await asyncio.wait_for(wait_until_started(), timeout=5) + server = MCPServerStreamableHttp(params={"url": f"http://127.0.0.1:{port}/mcp"}) + async with server: + tools = await server.list_tools() + protocol_version = server.session.protocol_version if server.session else None + session_id = server.session_id + + assert [tool.name for tool in tools] == ["probe"] + assert protocol_version == "2026-07-28" + assert session_id is None + finally: + uvicorn_server.should_exit = True + await server_task + + +@pytest.mark.asyncio +async def test_v2_response_hook_only_captures_legacy_initialize_session(): + captured: list[str] = [] + + def handle_request(request): + return httpx2.Response( + int(request.headers.get("x-response-status", "200")), + headers={"mcp-session-id": "legacy-session"}, + request=request, + ) + + client = httpx2.AsyncClient(transport=httpx2.MockTransport(handle_request)) + _configure_v2_session_id_hook( + client, + on_session_id=captured.append, + ) + + await client.post( + "https://example.test/mcp", + content=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "server/discover"}), + ) + assert captured == [] + + with pytest.raises(httpx2.HTTPStatusError): + await client.post( + "https://example.test/mcp", + headers={"x-response-status": "503"}, + content=json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize"}), + ) + assert captured == [] + + await client.post( + "https://example.test/mcp", + content=json.dumps({"jsonrpc": "2.0", "id": 3, "method": "initialize"}), + ) + assert captured == ["legacy-session"] + await client.aclose() + + +def test_v2_rejects_initialized_notification_tolerance_before_connecting(): + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "ignore_initialized_notification_failure": True, + } + ) + + with pytest.raises(UserError, match="not supported with MCP Python SDK v2"): + server.create_streams() + + +def test_v2_rejects_v1_auth_before_request(): + with pytest.raises(UserError, match="httpx2.Auth"): + _create_default_streamable_http_client(auth=httpx.BasicAuth("user", "pass")) + + +def test_v2_rejects_v1_client_factory_result(): + factory = _validated_v2_http_client_factory(lambda **kwargs: httpx.AsyncClient()) + with pytest.raises(UserError, match="httpx2.AsyncClient"): + factory() + + +def test_v2_default_factory_returns_httpx2_client(): + client = _create_default_streamable_http_client() + assert isinstance(client, httpx2.AsyncClient) + + +def test_v2_client_receives_timeout_message_handler_and_disables_cache(monkeypatch): + captured: dict[str, object] = {} + + class StubClient: + def __init__(self, transport, **kwargs): + captured["transport"] = transport + captured.update(kwargs) + + monkeypatch.setattr(mcp, "Client", StubClient) + transport = object() + handler = object() + + create_v2_client( + transport, + read_timeout_seconds=12.5, + message_handler=handler, + ) + + assert captured == { + "transport": transport, + "mode": "auto", + "cache": None, + "read_timeout_seconds": 12.5, + "message_handler": handler, + } + + +def _v2_response_for_request( + request, + *, + fail_tool_call: bool = False, + tool_status_code: int | None = None, +): + payload = json.loads(request.content) if request.content else {} + method = payload.get("method") + if method == "server/discover": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "error": {"code": -32601, "message": "Method not found"}, + } + elif method == "initialize": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test", "version": "1"}, + }, + } + elif method == "notifications/initialized": + return httpx2.Response(202, request=request) + elif method == "tools/list": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "tools": [ + { + "name": "test", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + }, + } + elif method == "tools/call" and tool_status_code is not None: + return httpx2.Response(tool_status_code, request=request) + elif method == "tools/call" and fail_tool_call: + raise httpx2.ConnectError("connection dropped", request=request) + elif method == "tools/call": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "content": [{"type": "text", "text": "ok"}], + "isError": False, + }, + } + else: + body = { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32601, "message": "Unknown method"}, + } + return httpx2.Response( + 200, + json=body, + headers={"content-type": "application/json"}, + request=request, + ) + + +@pytest.mark.asyncio +async def test_v2_streamable_http_retries_connect_error_on_isolated_session(): + clients: list[Any] = [] + + def factory(headers=None, timeout=None, auth=None): + fail_tool_call = not clients + + async def handler(request): + return _v2_response_for_request(request, fail_tool_call=fail_tool_call) + + client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + headers=headers, + timeout=timeout, + auth=auth, + ) + clients.append(client) + return client + + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "httpx_client_factory": factory, + }, + max_retry_attempts=1, + retry_backoff_seconds_base=0, + ) + + async with server: + result = await asyncio.wait_for(server.call_tool("test", {}), timeout=2) + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "ok" + assert len(clients) == 2 + assert all(client.is_closed for client in clients) + + +@pytest.mark.asyncio +async def test_v2_streamable_http_retries_5xx_on_isolated_session(): + clients: list[Any] = [] + observed_statuses: list[int] = [] + + def factory(headers=None, timeout=None, auth=None): + tool_status_code = 503 if not clients else None + + async def handler(request): + return _v2_response_for_request(request, tool_status_code=tool_status_code) + + async def observe_response(response): + observed_statuses.append(response.status_code) + + client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + headers=headers, + timeout=timeout, + auth=auth, + event_hooks={"response": [observe_response]}, + ) + clients.append(client) + return client + + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "httpx_client_factory": factory, + }, + max_retry_attempts=1, + retry_backoff_seconds_base=0, + ) + + async with server: + result = await asyncio.wait_for(server.call_tool("test", {}), timeout=2) + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "ok" + assert len(clients) == 2 + assert 503 in observed_statuses + assert all(client.is_closed for client in clients) + + +@pytest.mark.asyncio +async def test_v2_connect_cancellation_stops_pending_client_owner(monkeypatch): + client_entered = asyncio.Event() + owner_task: asyncio.Task[None] | None = None + + class BlockingClient: + async def __aenter__(self): + nonlocal owner_task + owner_task = asyncio.current_task() + client_entered.set() + await asyncio.Event().wait() + + async def __aexit__(self, exc_type, exc_value, traceback): + return False + + monkeypatch.setattr( + "agents.mcp.server.create_v2_client", + lambda *args, **kwargs: BlockingClient(), + ) + server = MCPServerStreamableHttp(params={"url": "https://example.test/mcp"}) + connect_task = asyncio.create_task(server.connect()) + await asyncio.wait_for(client_entered.wait(), timeout=2) + + connect_task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(connect_task, timeout=2) + + assert owner_task is not None + assert owner_task.done() + assert server.session is None + + +@pytest.mark.asyncio +async def test_v2_streamable_http_preserves_outer_cancellation(): + call_started = asyncio.Event() + clients: list[Any] = [] + + def factory(headers=None, timeout=None, auth=None): + async def handler(request): + payload = json.loads(request.content) if request.content else {} + if payload.get("method") == "tools/call": + call_started.set() + await asyncio.Event().wait() + return _v2_response_for_request(request) + + client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + headers=headers, + timeout=timeout, + auth=auth, + ) + clients.append(client) + return client + + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "httpx_client_factory": factory, + }, + max_retry_attempts=1, + retry_backoff_seconds_base=0, + ) + + async with server: + call_task = asyncio.create_task(server.call_tool("test", {})) + await asyncio.wait_for(call_started.wait(), timeout=2) + call_task.cancel() + with pytest.raises(asyncio.CancelledError): + await call_task + + assert len(clients) == 1 + assert clients[0].is_closed diff --git a/tests/mcp/test_mcp_version_compat.py b/tests/mcp/test_mcp_version_compat.py new file mode 100644 index 0000000000..77bb49e0b1 --- /dev/null +++ b/tests/mcp/test_mcp_version_compat.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from agents.mcp import MCPServerStdio +from agents.mcp._compat import MCP_V2, result_is_error + +LEGACY_SERVER_PATH = Path(__file__).parent / "servers" / "legacy.py" + + +@pytest.mark.asyncio +async def test_stdio_connects_to_legacy_server(): + server = MCPServerStdio( + name="legacy-test-server", + params={"command": sys.executable, "args": [str(LEGACY_SERVER_PATH)]}, + ) + + async with server: + tools = await server.list_tools() + result = await server.call_tool("legacy_tool", {}) + protocol_version = getattr(server.session, "protocol_version", None) + + assert [tool.name for tool in tools] == ["legacy_tool"] + assert result.content[0].type == "text" + assert result_is_error(result) is False + if MCP_V2: + assert protocol_version == "2025-06-18" + assert server.server_initialize_result is not None diff --git a/tests/mcp/test_message_handler.py b/tests/mcp/test_message_handler.py index 193815c2e7..4f93f22f40 100644 --- a/tests/mcp/test_message_handler.py +++ b/tests/mcp/test_message_handler.py @@ -1,22 +1,18 @@ from __future__ import annotations import contextlib -from typing import Union +from typing import Any import anyio import pytest from mcp.client.session import MessageHandlerFnT from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder from mcp.types import ( - ClientResult, Implementation, - InitializeResult, ServerCapabilities, - ServerNotification, - ServerRequest, ) +from agents.mcp._compat import MCP_V2 from agents.mcp.server import ( MCPServerSse, MCPServerStdio, @@ -24,9 +20,9 @@ _MCPServerWithClientSession, ) -HandlerMessage = Union[ # noqa: UP007 - RequestResponder[ServerRequest, ClientResult], ServerNotification, Exception -] +from .model_compat import InitializeResult + +HandlerMessage = Any class _StubClientSession: @@ -87,6 +83,7 @@ def name(self) -> str: @pytest.mark.asyncio +@pytest.mark.skipif(MCP_V2, reason="MCP v2 message handling is owned by the high-level client") async def test_client_session_receives_message_handler(monkeypatch): captured: dict[str, object] = {} diff --git a/tests/mcp/test_prompt_server.py b/tests/mcp/test_prompt_server.py index cf6254e5dd..9df2048bcd 100644 --- a/tests/mcp/test_prompt_server.py +++ b/tests/mcp/test_prompt_server.py @@ -1,13 +1,14 @@ from typing import Any import pytest -from mcp.types import ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult +from mcp.types import ListResourcesResult, ReadResourceResult from agents import Agent, Runner from agents.mcp import MCPServer, MCPToolMetaResolver from ..fake_model import FakeModel from ..test_responses import get_text_message +from .model_compat import ListResourceTemplatesResult class FakeMCPPromptServer(MCPServer): diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index f28947a936..e74b2c01db 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -8,10 +8,10 @@ import httpx import pytest -from mcp.types import ListPromptsResult, ListToolsResult from agents import Agent, _debug from agents.exceptions import UserError +from agents.mcp._compat import MCP_V2 from agents.mcp.server import ( MCPServerSse, MCPServerStreamableHttp, @@ -20,6 +20,8 @@ ) from agents.run_context import RunContextWrapper +from .model_compat import ListPromptsResult, ListToolsResult + # Handle Python version compatibility for ExceptionGroups if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup @@ -184,7 +186,8 @@ def test_client_session_read_timeout_treats_zero_as_disabled( @pytest.mark.parametrize("timeout_seconds", [0.000001, 2.5]) def test_client_session_read_timeout_preserves_positive_value(timeout_seconds: float) -> None: - assert _client_session_read_timeout(timeout_seconds) == timedelta(seconds=timeout_seconds) + expected = timeout_seconds if MCP_V2 else timedelta(seconds=timeout_seconds) + assert _client_session_read_timeout(timeout_seconds) == expected @pytest.mark.parametrize( diff --git a/tests/mcp/test_streamable_http_client_factory.py b/tests/mcp/test_streamable_http_client_factory.py index 3e526db7b3..d92a42fe23 100644 --- a/tests/mcp/test_streamable_http_client_factory.py +++ b/tests/mcp/test_streamable_http_client_factory.py @@ -10,16 +10,24 @@ import pytest from anyio import create_memory_object_stream from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest +from mcp.types import JSONRPCNotification, JSONRPCRequest from agents import _debug from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2 from agents.mcp.server import ( _create_default_streamable_http_client, _InitializedNotificationTolerantStreamableHTTPTransport, _streamablehttp_client_with_transport, ) +from .model_compat import JSONRPCMessage + +pytestmark = pytest.mark.skipif( + MCP_V2, + reason="These assertions cover MCP v1 streamable HTTP transport internals", +) + class TestMCPServerStreamableHttpClientFactory: """Test cases for custom httpx_client_factory parameter.""" diff --git a/tests/mcp/test_streamable_http_session_id.py b/tests/mcp/test_streamable_http_session_id.py index a98013b8f1..871b9e57db 100644 --- a/tests/mcp/test_streamable_http_session_id.py +++ b/tests/mcp/test_streamable_http_session_id.py @@ -7,6 +7,7 @@ import pytest from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2 class TestStreamableHttpSessionId: @@ -53,6 +54,7 @@ def changing_callback() -> str | None: assert server.session_id == "session-2" @pytest.mark.asyncio + @pytest.mark.skipif(MCP_V2, reason="MCP v2 session IDs are captured by HTTP response hooks") async def test_connect_captures_get_session_id_callback(self): """connect() should capture the third element of the transport tuple as _get_session_id.""" server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"}) diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index ec2c4bbc20..ebe53f3315 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -7,8 +7,6 @@ from typing import Any, cast import pytest -from mcp.shared.exceptions import McpError -from mcp.types import ErrorData from openai.types.responses import ResponseOutputMessage, ResponseOutputText from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall from pydantic import BaseModel, Field @@ -50,6 +48,7 @@ from agents.tool_context import ToolContext from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer +from tests.mcp.model_compat import create_mcp_error from tests.test_responses import get_function_tool_call, get_text_message from tests.utils.hitl import make_function_tool_call @@ -2292,7 +2291,7 @@ async def call_tool( ): self.tool_calls.append(tool_name) del arguments, meta - raise McpError(ErrorData(code=-32000, message="synthetic upstream 422")) + raise create_mcp_error(-32000, "synthetic upstream 422") nested_server: FakeMCPServer if server == "cancelled": diff --git a/tests/test_process_model_response.py b/tests/test_process_model_response.py index f21d65911f..ff70db72ac 100644 --- a/tests/test_process_model_response.py +++ b/tests/test_process_model_response.py @@ -1,7 +1,6 @@ from typing import Any, cast import pytest -from mcp import Tool as MCPTool from openai._models import construct_type from openai.types.responses import ( ResponseApplyPatchToolCall, @@ -45,6 +44,7 @@ from agents.usage import Usage from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer +from tests.mcp.model_compat import Tool as MCPTool from tests.test_responses import get_function_tool_call from tests.utils.hitl import ( RecordingEditor, diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index 453a66ea30..5cdc026f66 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -3,7 +3,6 @@ from typing import Any, cast import pytest -from mcp import Tool as MCPTool from openai._models import construct_type from openai.types.responses import ( ResponseCompletedEvent, @@ -53,6 +52,7 @@ from .fake_model import FakeModel from .mcp.helpers import FakeMCPServer +from .mcp.model_compat import Tool as MCPTool from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message diff --git a/tests/test_tool_origin.py b/tests/test_tool_origin.py index 969b089447..6343427987 100644 --- a/tests/test_tool_origin.py +++ b/tests/test_tool_origin.py @@ -7,7 +7,6 @@ from typing import Any, TypeVar, cast import pytest -from mcp import Tool as MCPTool from openai.types.responses.response_output_item import McpCall, McpListTools, McpListToolsTool from pydantic import BaseModel @@ -35,6 +34,7 @@ from agents.run_internal.tool_execution import execute_function_tool_calls from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer +from tests.mcp.model_compat import Tool as MCPTool from tests.test_responses import get_function_tool_call, get_text_message from tests.utils.factories import make_run_state, make_tool_call, roundtrip_state diff --git a/uv.lock b/uv.lock index e3336f1490..37b5f2c63a 100644 --- a/uv.lock +++ b/uv.lock @@ -1006,7 +1006,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, - { name = "starlette" }, + { name = "starlette", version = "0.47.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] @@ -1440,6 +1441,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1456,12 +1470,19 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.1" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -1494,11 +1515,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1792,27 +1813,41 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, - { name = "starlette" }, + { name = "starlette", version = "0.47.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "typing-extensions" }, { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -2570,7 +2605,7 @@ requires-dist = [ { name = "griffelib", specifier = ">=2,<3" }, { name = "grpcio", marker = "extra == 'dapr'", specifier = ">=1.60.0" }, { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.83.0" }, - { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<2" }, + { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<3" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, { name = "openai", specifier = ">=2.45.0,<3" }, @@ -3063,20 +3098,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, -] - [[package]] name = "pyee" version = "12.1.1" @@ -3920,8 +3941,13 @@ wheels = [ name = "starlette" version = "0.47.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "python_full_version < '3.14'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } @@ -3929,6 +3955,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "synchronicity" version = "0.12.2" @@ -4140,6 +4181,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "types-certifi" version = "2021.10.8.3" From b9f817aba85df07f0868adf86aa0fd0d4e1f492f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 19:05:00 +0900 Subject: [PATCH 167/473] test: fix failuring tests --- tests/test_call_model_input_filter_unit.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_call_model_input_filter_unit.py b/tests/test_call_model_input_filter_unit.py index ff14fc2829..ba96b32332 100644 --- a/tests/test_call_model_input_filter_unit.py +++ b/tests/test_call_model_input_filter_unit.py @@ -1,20 +1,15 @@ from __future__ import annotations -import sys -from pathlib import Path from typing import Any import pytest from openai.types.responses import ResponseOutputMessage, ResponseOutputText -# Make the repository tests helpers importable from this unit test -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tests")) -from fake_model import FakeModel # type: ignore - # Import directly from submodules to avoid heavy __init__ side effects from agents.agent import Agent from agents.exceptions import UserError from agents.run import CallModelData, ModelInputData, RunConfig, Runner +from tests.fake_model import FakeModel @pytest.mark.asyncio From 6af30c57e257a6fb75f4891bf50a890d1a0b107c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 19:52:01 +0900 Subject: [PATCH 168/473] fix: prevent queue consumer deadlocks (#4201) Co-authored-by: abhay-codes07 --- src/agents/agent.py | 23 +-- .../experimental/codex/codex_tool.py | 126 ++++++------ src/agents/sandbox/memory/manager.py | 10 +- src/agents/util/_asyncio_tasks.py | 40 ++++ .../experiemental/codex/test_codex_tool.py | 188 ++++++++++++++++++ tests/sandbox/test_memory.py | 107 ++++++++++ tests/test_agent_as_tool.py | 155 +++++++++++++++ tests/test_asyncio_tasks.py | 56 +++++- 8 files changed, 622 insertions(+), 83 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index 1d42624f2b..7768372cee 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -58,7 +58,7 @@ ) from .tool_context import ToolContext from .util import _transforms -from .util._asyncio_tasks import gather_with_cancel +from .util._asyncio_tasks import gather_with_cancel, run_producer_consumer from .util._types import MaybeAwaitable if TYPE_CHECKING: @@ -912,10 +912,7 @@ async def dispatch_stream_events() -> None: if is_sentinel: break - dispatch_task = asyncio.create_task(dispatch_stream_events()) - stream_iteration_cancelled = False - - try: + async def enqueue_stream_events() -> None: from .stream_events import AgentUpdatedStreamEvent current_agent = run_result_streaming.current_agent @@ -930,20 +927,10 @@ async def dispatch_stream_events() -> None: "tool_call": context.tool_call, } await event_queue.put(payload) - except asyncio.CancelledError: - stream_iteration_cancelled = True - raise - finally: - if stream_iteration_cancelled: - dispatch_task.cancel() - try: - await dispatch_task - except asyncio.CancelledError: - pass - else: + finally: await event_queue.put(None) - await event_queue.join() - await dispatch_task + + await run_producer_consumer(enqueue_stream_events(), dispatch_stream_events()) run_result = run_result_streaming else: run_result = await Runner.run( diff --git a/src/agents/extensions/experimental/codex/codex_tool.py b/src/agents/extensions/experimental/codex/codex_tool.py index 7138286dfe..62c9eea334 100644 --- a/src/agents/extensions/experimental/codex/codex_tool.py +++ b/src/agents/extensions/experimental/codex/codex_tool.py @@ -31,6 +31,7 @@ from agents.tool_context import ToolContext from agents.tracing import SpanError, custom_span from agents.usage import Usage as AgentsUsage, _make_input_tokens_details +from agents.util._asyncio_tasks import run_producer_consumer from agents.util._types import MaybeAwaitable from .codex import Codex @@ -1047,78 +1048,81 @@ async def _consume_events( resolved_thread_id_holder["thread_id"] = resolved_thread_id event_queue: asyncio.Queue[CodexToolStreamEvent | None] | None = None - dispatch_task: asyncio.Task[None] | None = None - if on_stream is not None: # Buffer events so user callbacks cannot block the Codex stream loop. event_queue = asyncio.Queue() - async def _run_handler(payload: CodexToolStreamEvent) -> None: - # Dispatch user callbacks asynchronously to avoid blocking the stream. + async def _run_handler(payload: CodexToolStreamEvent) -> None: + # Dispatch user callbacks asynchronously to avoid blocking the stream. + assert on_stream is not None + try: + maybe_result = on_stream(payload) + if inspect.isawaitable(maybe_result): + await maybe_result + except Exception as exc: + log_model_and_tool_action_error( + logger, + "Error while handling Codex on_stream event", + exc, + ) + + async def _dispatch() -> None: + assert event_queue is not None + while True: + payload = await event_queue.get() + is_sentinel = payload is None try: - maybe_result = on_stream(payload) - if inspect.isawaitable(maybe_result): - await maybe_result - except Exception as exc: - log_model_and_tool_action_error( - logger, - "Error while handling Codex on_stream event", - exc, - ) + if payload is not None: + await _run_handler(payload) + finally: + event_queue.task_done() + if is_sentinel: + break - async def _dispatch() -> None: - assert event_queue is not None - while True: - payload = await event_queue.get() - is_sentinel = payload is None - try: - if payload is not None: - await _run_handler(payload) - finally: - event_queue.task_done() - if is_sentinel: - break + async def _process_events() -> None: + nonlocal final_response, resolved_thread_id, usage - dispatch_task = asyncio.create_task(_dispatch()) + try: + async for raw_event in events: + event = coerce_thread_event(raw_event) + if event_queue is not None: + await event_queue.put( + CodexToolStreamEvent( + event=event, + thread=thread, + tool_call=ctx.tool_call, + ) + ) - try: - async for raw_event in events: - event = coerce_thread_event(raw_event) + if isinstance(event, ItemStartedEvent): + _handle_item_started(event.item, active_spans, span_data_max_chars) + elif isinstance(event, ItemUpdatedEvent): + _handle_item_updated(event.item, active_spans, span_data_max_chars) + elif isinstance(event, ItemCompletedEvent): + _handle_item_completed(event.item, active_spans, span_data_max_chars) + if is_agent_message_item(event.item): + final_response = event.item.text + elif isinstance(event, TurnCompletedEvent): + usage = event.usage + elif isinstance(event, ThreadStartedEvent): + resolved_thread_id = event.thread_id + if resolved_thread_id_holder is not None: + resolved_thread_id_holder["thread_id"] = resolved_thread_id + elif isinstance(event, TurnFailedEvent): + error = event.error.message + raise UserError(f"Codex turn failed{(': ' + error) if error else ''}") + elif isinstance(event, ThreadErrorEvent): + raise UserError(f"Codex stream error: {event.message}") + finally: if event_queue is not None: - await event_queue.put( - CodexToolStreamEvent( - event=event, - thread=thread, - tool_call=ctx.tool_call, - ) - ) + await event_queue.put(None) - if isinstance(event, ItemStartedEvent): - _handle_item_started(event.item, active_spans, span_data_max_chars) - elif isinstance(event, ItemUpdatedEvent): - _handle_item_updated(event.item, active_spans, span_data_max_chars) - elif isinstance(event, ItemCompletedEvent): - _handle_item_completed(event.item, active_spans, span_data_max_chars) - if is_agent_message_item(event.item): - final_response = event.item.text - elif isinstance(event, TurnCompletedEvent): - usage = event.usage - elif isinstance(event, ThreadStartedEvent): - resolved_thread_id = event.thread_id - if resolved_thread_id_holder is not None: - resolved_thread_id_holder["thread_id"] = resolved_thread_id - elif isinstance(event, TurnFailedEvent): - error = event.error.message - raise UserError(f"Codex turn failed{(': ' + error) if error else ''}") - elif isinstance(event, ThreadErrorEvent): - raise UserError(f"Codex stream error: {event.message}") + try: + if on_stream is None: + await _process_events() + else: + await run_producer_consumer(_process_events(), _dispatch()) finally: - if event_queue is not None: - await event_queue.put(None) - await event_queue.join() - if dispatch_task is not None: - await dispatch_task - # Ensure any open spans are closed even on failure. for span in active_spans.values(): span.finish() diff --git a/src/agents/sandbox/memory/manager.py b/src/agents/sandbox/memory/manager.py index 9919d8035b..8c16f3534a 100644 --- a/src/agents/sandbox/memory/manager.py +++ b/src/agents/sandbox/memory/manager.py @@ -131,11 +131,15 @@ async def flush(self) -> None: self._ensure_worker() for rollout_file in rollout_files: self._queue.put_nowait(rollout_file) - await self._queue.join() if self._worker_task is not None: self._queue.put_nowait(_STOP) - await self._worker_task - self._worker_task = None + worker_task = self._worker_task + try: + # The stop marker follows every rollout, so worker completion implies + # that all preceding rollout files were processed. + await worker_task + finally: + self._worker_task = None await self._run_phase_two() finally: _unregister_memory_generation_manager(session=self._session, manager=self) diff --git a/src/agents/util/_asyncio_tasks.py b/src/agents/util/_asyncio_tasks.py index 90c3146f18..2974af39f7 100644 --- a/src/agents/util/_asyncio_tasks.py +++ b/src/agents/util/_asyncio_tasks.py @@ -11,6 +11,8 @@ T4 = TypeVar("T4") T5 = TypeVar("T5") T6 = TypeVar("T6") +TProducer = TypeVar("TProducer") +TConsumer = TypeVar("TConsumer") def _consume_future_exception(future: asyncio.Future[Any]) -> None: @@ -110,3 +112,41 @@ async def gather_with_cancel( task.cancel() await asyncio.gather(*tasks, return_exceptions=True) raise + + +async def run_producer_consumer( + producer: Awaitable[TProducer], + consumer: Awaitable[TConsumer], + /, +) -> tuple[TProducer, TConsumer]: + """Run a producer and consumer with asymmetric failure handling. + + The producer must signal completion to the consumer in a ``finally`` block. A producer + failure waits for the consumer to drain before propagating, while a consumer failure or + parent cancellation cancels and drains the sibling task. + """ + producer_task = asyncio.ensure_future(producer) + consumer_task = asyncio.ensure_future(consumer) + tasks = (producer_task, consumer_task) + + try: + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + if consumer_task in done: + consumer_result = consumer_task.result() + producer_result = await producer_task + return producer_result, consumer_result + + try: + producer_result = producer_task.result() + except BaseException: + await consumer_task + raise + + consumer_result = await consumer_task + return producer_result, consumer_result + except BaseException: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise diff --git a/tests/extensions/experiemental/codex/test_codex_tool.py b/tests/extensions/experiemental/codex/test_codex_tool.py index 36b6a2822a..1fd67e933d 100644 --- a/tests/extensions/experiemental/codex/test_codex_tool.py +++ b/tests/extensions/experiemental/codex/test_codex_tool.py @@ -2108,3 +2108,191 @@ async def test_codex_tool_argument_errors_respect_tool_data_redaction( else: assert _CODEX_TOOL_ARGUMENT_SECRET in str(error) assert isinstance(error.__cause__, cause_type) + + +class _FatalCodexStreamHandlerError(BaseException): + pass + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "handler_error", + [_FatalCodexStreamHandlerError("fatal"), asyncio.CancelledError()], + ids=["base_exception", "cancelled_error"], +) +async def test_codex_tool_streaming_propagates_base_exception_and_finishes_spans( + monkeypatch: pytest.MonkeyPatch, + handler_error: BaseException, +) -> None: + class RecordingSpan: + def __init__(self) -> None: + self.started = False + self.finished = False + + def start(self) -> None: + self.started = True + + def finish(self) -> None: + self.finished = True + + span = RecordingSpan() + monkeypatch.setattr(codex_tool_module, "custom_span", lambda **_kwargs: span) + source_cancelled = asyncio.Event() + + async def event_stream(): + yield { + "type": "item.started", + "item": { + "id": "cmd-1", + "type": "command_execution", + "command": "pwd", + "status": "in_progress", + }, + } + try: + await asyncio.Event().wait() + finally: + source_cancelled.set() + + def on_stream(payload: CodexToolStreamEvent) -> None: + del payload + raise handler_error + + context = ToolContext( + context=None, + tool_name="codex", + tool_call_id="call-1", + tool_arguments="{}", + ) + + with pytest.raises(type(handler_error)): + await asyncio.wait_for( + codex_tool_module._consume_events( + event_stream(), + {"inputs": [{"type": "text", "text": "hello"}]}, + context, + SimpleNamespace(id="thread-1"), + on_stream, + 64, + ), + timeout=1.0, + ) + + assert span.started + assert span.finished + assert source_cancelled.is_set() + + +@pytest.mark.asyncio +async def test_codex_tool_streaming_parent_cancellation_stops_dispatcher() -> None: + handler_started = asyncio.Event() + handler_cancelled = asyncio.Event() + source_cancelled = asyncio.Event() + + async def event_stream(): + yield { + "type": "turn.completed", + "usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1}, + } + try: + await asyncio.Event().wait() + finally: + source_cancelled.set() + + async def on_stream(payload: CodexToolStreamEvent) -> None: + del payload + handler_started.set() + try: + await asyncio.Event().wait() + finally: + handler_cancelled.set() + + context = ToolContext( + context=None, + tool_name="codex", + tool_call_id="call-1", + tool_arguments="{}", + ) + invoke_task = asyncio.create_task( + codex_tool_module._consume_events( + event_stream(), + {"inputs": [{"type": "text", "text": "hello"}]}, + context, + SimpleNamespace(id="thread-1"), + on_stream, + 64, + ) + ) + + await asyncio.wait_for(handler_started.wait(), timeout=1.0) + invoke_task.cancel() + + try: + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(invoke_task, timeout=1.0) + finally: + if not invoke_task.done(): + invoke_task.cancel() + await asyncio.gather(invoke_task, return_exceptions=True) + + assert source_cancelled.is_set() + assert handler_cancelled.is_set() + + +@pytest.mark.asyncio +async def test_codex_tool_streaming_drains_events_before_stream_error() -> None: + handler_started = asyncio.Event() + terminal_event_emitted = asyncio.Event() + allow_handler_to_finish = asyncio.Event() + handler_cancelled = asyncio.Event() + handled_event_types: list[str] = [] + + async def event_stream(): + yield {"type": "turn.started"} + await handler_started.wait() + terminal_event_emitted.set() + yield {"type": "turn.failed", "error": {"message": "boom"}} + + async def on_stream(payload: CodexToolStreamEvent) -> None: + if not handled_event_types: + handler_started.set() + try: + await allow_handler_to_finish.wait() + except asyncio.CancelledError: + handler_cancelled.set() + raise + handled_event_types.append(payload.event.type) + + context = ToolContext( + context=None, + tool_name="codex", + tool_call_id="call-1", + tool_arguments="{}", + ) + invoke_task = asyncio.create_task( + codex_tool_module._consume_events( + event_stream(), + {"inputs": [{"type": "text", "text": "hello"}]}, + context, + SimpleNamespace(id="thread-1"), + on_stream, + 64, + ) + ) + + try: + await asyncio.wait_for(terminal_event_emitted.wait(), timeout=1.0) + await asyncio.sleep(0) + + assert not invoke_task.done() + assert not handler_cancelled.is_set() + + allow_handler_to_finish.set() + with pytest.raises(UserError, match="Codex turn failed: boom"): + await asyncio.wait_for(invoke_task, timeout=1.0) + finally: + if not invoke_task.done(): + invoke_task.cancel() + await asyncio.gather(invoke_task, return_exceptions=True) + + assert handled_event_types == ["turn.started", "turn.failed"] diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index 61625e77f4..1a8ed9a560 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import io import json import logging @@ -1497,6 +1498,112 @@ async def test_sandbox_memory_unregisters_manager_on_session_close() -> None: await client.delete(session) +class _FatalMemoryWorkerError(BaseException): + pass + + +@pytest.mark.parametrize( + "worker_error", + [_FatalMemoryWorkerError("fatal"), asyncio.CancelledError()], + ids=["base_exception", "cancelled_error"], +) +@pytest.mark.asyncio +async def test_sandbox_memory_flush_propagates_worker_base_exception_without_hanging( + monkeypatch: pytest.MonkeyPatch, + worker_error: BaseException, +) -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + memory = _memory_config() + manager = get_or_create_memory_generation_manager(session=session, memory=memory) + + async def fail_processing(_rollout_file_name: str) -> None: + raise worker_error + + monkeypatch.setattr(manager, "_process_rollout_file", fail_processing) + + try: + await manager.enqueue_rollout_payload( + { + "updated_at": "2026-08-05T00:00:00+00:00", + "input": [], + "generated_items": [], + "terminal_metadata": { + "terminal_state": "completed", + "has_final_output": False, + }, + }, + rollout_id="fatal-worker", + ) + + with pytest.raises(type(worker_error)): + await asyncio.wait_for(manager.flush(), timeout=1.0) + + assert manager._worker_task is None + assert memory_manager_module._MEMORY_GENERATION_MANAGERS.get(session) is None + finally: + await client.delete(session) + + +@pytest.mark.asyncio +async def test_sandbox_memory_flush_parent_cancellation_stops_worker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + memory = _memory_config() + manager = get_or_create_memory_generation_manager(session=session, memory=memory) + worker_started = asyncio.Event() + worker_cancelled = asyncio.Event() + phase_two_called = False + + async def block_processing(_rollout_file_name: str) -> None: + worker_started.set() + try: + await asyncio.Event().wait() + finally: + worker_cancelled.set() + + async def record_phase_two() -> None: + nonlocal phase_two_called + phase_two_called = True + + monkeypatch.setattr(manager, "_process_rollout_file", block_processing) + monkeypatch.setattr(manager, "_run_phase_two", record_phase_two) + + try: + await manager.enqueue_rollout_payload( + { + "updated_at": "2026-08-05T00:00:00+00:00", + "input": [], + "generated_items": [], + "terminal_metadata": { + "terminal_state": "completed", + "has_final_output": False, + }, + }, + rollout_id="cancelled-flush", + ) + flush_task = asyncio.create_task(manager.flush()) + await asyncio.wait_for(worker_started.wait(), timeout=1.0) + flush_task.cancel() + + try: + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(flush_task, timeout=1.0) + finally: + if not flush_task.done(): + flush_task.cancel() + await asyncio.gather(flush_task, return_exceptions=True) + + assert worker_cancelled.is_set() + assert manager._worker_task is None + assert memory_manager_module._MEMORY_GENERATION_MANAGERS.get(session) is None + assert not phase_two_called + finally: + await client.delete(session) + + @pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"]) @pytest.mark.parametrize( ("model_redacted", "tool_redacted"), diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index ebe53f3315..bb52d5743c 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -3046,3 +3046,158 @@ def test_replaced_agent_as_tool_preserves_agent_markers_for_build_agent_map() -> agent_map = _build_agent_map(parent_agent) assert agent_map["nested_agent"] is nested_agent + + +class _FatalAgentToolStreamHandlerError(BaseException): + pass + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "handler_error", + [_FatalAgentToolStreamHandlerError("fatal"), asyncio.CancelledError()], + ids=["base_exception", "cancelled_error"], +) +async def test_agent_as_tool_streaming_propagates_base_exception_without_hanging( + monkeypatch: pytest.MonkeyPatch, + handler_error: BaseException, +) -> None: + agent = Agent(name="streamer") + source_cancelled = asyncio.Event() + stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"})) + + class DummyStreamingResult: + def __init__(self) -> None: + self.final_output = "streamed" + self.current_agent = agent + + async def stream_events(self): + yield stream_event + try: + await asyncio.Event().wait() + finally: + source_cancelled.set() + + monkeypatch.setattr( + Runner, + "run_streamed", + classmethod(lambda *args, **kwargs: DummyStreamingResult()), + ) + + async def on_stream(payload: AgentToolStreamEvent) -> None: + del payload + raise handler_error + + tool_call = ResponseFunctionToolCall( + id="call_fatal", + arguments='{"input": "go"}', + call_id="call-fatal", + name="stream_tool", + type="function_call", + ) + tool = agent.as_tool( + tool_name="stream_tool", + tool_description="Streams events", + on_stream=on_stream, + ) + tool_context = ToolContext( + context=None, + tool_name="stream_tool", + tool_call_id=tool_call.call_id, + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + + with pytest.raises(type(handler_error)): + await asyncio.wait_for( + tool.on_invoke_tool(tool_context, '{"input": "go"}'), + timeout=1.0, + ) + + assert source_cancelled.is_set() + + +class _NestedAgentStreamError(Exception): + pass + + +@pytest.mark.asyncio +async def test_agent_as_tool_streaming_drains_emitted_events_before_stream_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent = Agent(name="streamer") + stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"})) + handler_started = asyncio.Event() + producer_failed = asyncio.Event() + allow_handler_to_finish = asyncio.Event() + handler_cancelled = asyncio.Event() + handled_events: list[RawResponsesStreamEvent] = [] + + class DummyStreamingResult: + def __init__(self) -> None: + self.final_output = "streamed" + self.current_agent = agent + + async def stream_events(self): + yield stream_event + await handler_started.wait() + producer_failed.set() + raise _NestedAgentStreamError("nested stream failed") + + monkeypatch.setattr( + Runner, + "run_streamed", + classmethod(lambda *args, **kwargs: DummyStreamingResult()), + ) + + async def on_stream(payload: AgentToolStreamEvent) -> None: + handler_started.set() + try: + await allow_handler_to_finish.wait() + except asyncio.CancelledError: + handler_cancelled.set() + raise + handled_events.append(cast(RawResponsesStreamEvent, payload["event"])) + + tool_call = ResponseFunctionToolCall( + id="call_stream_error", + arguments='{"input": "go"}', + call_id="call-stream-error", + name="stream_tool", + type="function_call", + ) + tool = agent.as_tool( + tool_name="stream_tool", + tool_description="Streams events", + on_stream=on_stream, + failure_error_function=None, + ) + tool_context = ToolContext( + context=None, + tool_name="stream_tool", + tool_call_id=tool_call.call_id, + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + + async def invoke() -> Any: + return await tool.on_invoke_tool(tool_context, '{"input": "go"}') + + invoke_task = asyncio.create_task(invoke()) + + try: + await asyncio.wait_for(producer_failed.wait(), timeout=1.0) + await asyncio.sleep(0) + + assert not invoke_task.done() + assert not handler_cancelled.is_set() + + allow_handler_to_finish.set() + with pytest.raises(_NestedAgentStreamError, match="nested stream failed"): + await asyncio.wait_for(invoke_task, timeout=1.0) + finally: + if not invoke_task.done(): + invoke_task.cancel() + await asyncio.gather(invoke_task, return_exceptions=True) + + assert handled_events == [stream_event] diff --git a/tests/test_asyncio_tasks.py b/tests/test_asyncio_tasks.py index 0315a63705..5a77291a47 100644 --- a/tests/test_asyncio_tasks.py +++ b/tests/test_asyncio_tasks.py @@ -4,7 +4,7 @@ import pytest -from agents.util._asyncio_tasks import gather_with_cancel +from agents.util._asyncio_tasks import gather_with_cancel, run_producer_consumer @pytest.mark.asyncio @@ -75,3 +75,57 @@ async def child() -> None: assert not child_failure_reported.is_set() assert loop_errors == [] + + +@pytest.mark.asyncio +async def test_run_producer_consumer_drains_consumer_before_producer_failure() -> None: + class ProducerError(Exception): + pass + + item_ready = asyncio.Event() + allow_consumer_to_finish = asyncio.Event() + consumer_finished = asyncio.Event() + + async def producer() -> None: + item_ready.set() + raise ProducerError("producer failed") + + async def consumer() -> None: + await item_ready.wait() + await allow_consumer_to_finish.wait() + consumer_finished.set() + + task = asyncio.create_task(run_producer_consumer(producer(), consumer())) + await item_ready.wait() + await asyncio.sleep(0) + + assert not task.done() + allow_consumer_to_finish.set() + + with pytest.raises(ProducerError, match="producer failed"): + await task + assert consumer_finished.is_set() + + +@pytest.mark.asyncio +async def test_run_producer_consumer_cancels_producer_after_consumer_failure() -> None: + class ConsumerError(BaseException): + pass + + producer_started = asyncio.Event() + producer_cancelled = asyncio.Event() + + async def producer() -> None: + producer_started.set() + try: + await asyncio.Event().wait() + finally: + producer_cancelled.set() + + async def consumer() -> None: + await producer_started.wait() + raise ConsumerError("consumer failed") + + with pytest.raises(ConsumerError, match="consumer failed"): + await run_producer_consumer(producer(), consumer()) + assert producer_cancelled.is_set() From f6a32fee4e28661e61c734b78ac89868b1b2fc97 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 20:02:08 +0900 Subject: [PATCH 169/473] fix: harden tool output trimming contracts (#4204) --- src/agents/extensions/tool_output_trimmer.py | 226 ++++++++++-- tests/extensions/test_tool_output_trimmer.py | 344 ++++++++++++++++++- 2 files changed, 534 insertions(+), 36 deletions(-) diff --git a/src/agents/extensions/tool_output_trimmer.py b/src/agents/extensions/tool_output_trimmer.py index d6fab350a0..3955c22aa9 100644 --- a/src/agents/extensions/tool_output_trimmer.py +++ b/src/agents/extensions/tool_output_trimmer.py @@ -44,23 +44,44 @@ # still call the tool without them. _PROSE_SCHEMA_KEYWORDS = frozenset({"description", "title", "$comment", "examples"}) -# Keywords whose value is a map keyed by *user-chosen names* — parameter names, definition -# names, regexes — rather than by schema keywords. Their keys must survive even when they -# spell one of the prose keywords above, so they are recursed into by value only. -_NAME_KEYED_SCHEMA_MAPS = frozenset( +# Keywords whose value is itself a schema. Unknown keywords are intentionally not traversed: +# preserving unfamiliar data is safer than treating every nested mapping as a schema and +# accidentally deleting user-controlled values. +_SCHEMA_VALUE_KEYWORDS = frozenset( { - "properties", - "patternProperties", - "$defs", - "definitions", - "dependentSchemas", - "dependentRequired", + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", } ) -# Keywords whose value is instance *data* rather than a subschema. Nothing inside them is a -# schema keyword, so they are copied through untouched. -_DATA_SCHEMA_KEYWORDS = frozenset({"default", "const", "enum"}) +# Keywords whose value is a list of schemas. +_SCHEMA_LIST_KEYWORDS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) + +# Keywords whose value is a map keyed by user-chosen names and whose values are schemas. +_SCHEMA_MAP_KEYWORDS = frozenset( + {"$defs", "definitions", "dependentSchemas", "patternProperties", "properties"} +) + +# The legacy ``dependencies`` keyword is also name-keyed, but each value may be either a +# schema or a list of property names. +_SCHEMA_OR_PROPERTY_LIST_MAP_KEYWORDS = frozenset({"dependencies"}) + +_STRUCTURED_OUTPUT_FIELDS = { + "input_text": frozenset({"type", "text"}), + "input_image": frozenset({"type", "image_url", "file_id", "detail"}), + "input_file": frozenset({"type", "file_data", "file_url", "file_id", "filename"}), +} +_IMAGE_DETAILS = frozenset({"low", "high", "auto"}) @dataclass @@ -76,9 +97,12 @@ class ToolOutputTrimmer: recent_turns: Number of recent user messages whose surrounding items are never trimmed. Defaults to 2. max_output_chars: Tool outputs above this character count are candidates for - trimming. Defaults to 500. - preview_chars: How many characters of the original output to preserve as a - preview when trimming. Defaults to 200. + trimming. Structured outputs count their model-facing string payloads without + Python or JSON representation overhead, and their replacements fit within this + budget. Defaults to 500. + preview_chars: Maximum number of characters of a string output, or the text parts of + a structured output, to preserve as a preview when trimming. Structured previews + may be shorter when needed to fit ``max_output_chars``. Defaults to 200. trimmable_tools: Optional tool name or set of tool names whose outputs can be trimmed. For namespaced tools, both bare names and qualified ``namespace.name`` entries are supported. If ``None``, all tool outputs are eligible for trimming. Defaults @@ -223,6 +247,9 @@ def _trim_function_call_output( ) -> tuple[dict[str, Any] | None, int]: """Trim a function_call_output item when its serialized output is too large.""" output = item.get("output", "") + if isinstance(output, list): + return self._trim_structured_function_call_output(item, output, tool_names) + output_str = output if isinstance(output, str) else str(output) output_len = len(output_str) if output_len <= self.max_output_chars: @@ -242,6 +269,126 @@ def _trim_function_call_output( trimmed_item["output"] = summary return trimmed_item, output_len - len(summary) + def _trim_structured_function_call_output( + self, + item: dict[str, Any], + parts: list[Any], + tool_names: tuple[str, ...], + ) -> tuple[dict[str, Any] | None, int]: + """Trim a canonical structured function output without previewing opaque payloads.""" + details = self._structured_output_details(parts) + if details is None: + return None, 0 + + output_len, text_content, dropped_part_types = details + if output_len <= self.max_output_chars: + return None, 0 + + display_name = (tool_names[0] if tool_names else "") or "unknown_tool" + dropped_note = "" + if dropped_part_types: + dropped_note = "; dropped " + ", ".join( + f"{count} {part_type}" for part_type, count in sorted(dropped_part_types.items()) + ) + + minimal_header = "[Trimmed]" + if self.max_output_chars < len(minimal_header): + summary = minimal_header[: self.max_output_chars] + else: + preview_budget = self.max_output_chars - len(minimal_header) - 1 + preview_len = min(len(text_content), self.preview_chars, max(0, preview_budget)) + body = f"\n{text_content[:preview_len]}" if preview_len else "" + if ( + preview_len < len(text_content) + and len(minimal_header) + len(body) + len("...") <= self.max_output_chars + ): + body += "..." + + preview_note = f"; preview {preview_len}" if text_content else "" + headers = [ + f"[Trimmed: {display_name}; payload {output_len}{preview_note}{dropped_note}]" + ] + if dropped_part_types: + dropped_types = ", ".join(sorted(dropped_part_types)) + headers.extend( + [ + f"[Trimmed: {display_name}{dropped_note}]", + f"[Trimmed{dropped_note}]", + f"[Trimmed: {dropped_types}]", + f"[Trimmed: dropped {sum(dropped_part_types.values())} opaque]", + ] + ) + headers.extend( + [ + f"[Trimmed: payload {output_len}]", + f"[Trimmed: {display_name}]", + minimal_header, + ] + ) + summary = next( + header + body + for header in headers + if len(header) + len(body) <= self.max_output_chars + ) + + trimmed_item = dict(item) + trimmed_item["output"] = summary + return trimmed_item, output_len - len(summary) + + def _structured_output_details( + self, + parts: list[Any], + ) -> tuple[int, str, dict[str, int]] | None: + """Return payload size, readable text, and dropped-part counts for canonical parts.""" + if not parts: + return None + + output_len = 0 + text_segments: list[str] = [] + dropped_part_types: dict[str, int] = {} + + for part in parts: + if not isinstance(part, dict): + return None + + part_type = part.get("type") + if not isinstance(part_type, str): + return None + allowed_fields = _STRUCTURED_OUTPUT_FIELDS.get(part_type) + if allowed_fields is None or not set(part).issubset(allowed_fields): + return None + if any(key != "type" and not isinstance(value, str) for key, value in part.items()): + return None + + if part_type == "input_text": + text = part.get("text") + if not isinstance(text, str): + return None + text_segments.append(text) + elif part_type == "input_image": + if not isinstance(part.get("image_url"), str) and not isinstance( + part.get("file_id"), str + ): + return None + if "detail" in part and part["detail"] not in _IMAGE_DETAILS: + return None + dropped_part_types[part_type] = dropped_part_types.get(part_type, 0) + 1 + elif part_type == "input_file": + if not any( + isinstance(part.get(field), str) + for field in ("file_data", "file_url", "file_id") + ): + return None + dropped_part_types[part_type] = dropped_part_types.get(part_type, 0) + 1 + + output_len += sum( + len(value) + for key, value in part.items() + if key != "type" and isinstance(value, str) + ) + + return output_len, "\n".join(text_segments), dropped_part_types + def _trim_tool_search_output(self, item: dict[str, Any]) -> tuple[dict[str, Any] | None, int]: """Trim a tool_search_output item while keeping a valid replayable shape.""" if isinstance(item.get("results"), list): @@ -311,31 +458,40 @@ def _trim_json_schema(self, schema: dict[str, Any]) -> dict[str, Any]: """Remove verbose prose from a JSON schema while preserving its structure.""" trimmed_schema: dict[str, Any] = {} for key, value in schema.items(): - # A name-keyed map is keyed by parameter/definition names, not by schema - # keywords, so recurse into its values while keeping its keys verbatim. - # Dropping a key here would delete a declared parameter or dangle a $ref. - if key in _NAME_KEYED_SCHEMA_MAPS and isinstance(value, dict): - trimmed_schema[key] = { - name: self._trim_json_schema(sub) if isinstance(sub, dict) else sub - for name, sub in value.items() - } - continue - # These hold instance data. A "title" key inside a default value is part of the - # value, so trimming it would silently change the tool's contract. - if key in _DATA_SCHEMA_KEYWORDS: - trimmed_schema[key] = value - continue if key in _PROSE_SCHEMA_KEYWORDS: continue - if isinstance(value, dict): - trimmed_schema[key] = self._trim_json_schema(value) - elif isinstance(value, list): + if key in _SCHEMA_VALUE_KEYWORDS: + if isinstance(value, dict): + trimmed_schema[key] = self._trim_json_schema(value) + elif key == "items" and isinstance(value, list): + trimmed_schema[key] = [ + self._trim_json_schema(item) if isinstance(item, dict) else item + for item in value + ] + else: + trimmed_schema[key] = value + continue + if key in _SCHEMA_LIST_KEYWORDS and isinstance(value, list): trimmed_schema[key] = [ self._trim_json_schema(item) if isinstance(item, dict) else item for item in value ] - else: - trimmed_schema[key] = value + continue + if key in _SCHEMA_MAP_KEYWORDS and isinstance(value, dict): + trimmed_schema[key] = { + name: self._trim_json_schema(sub) if isinstance(sub, dict) else sub + for name, sub in value.items() + } + continue + if key in _SCHEMA_OR_PROPERTY_LIST_MAP_KEYWORDS and isinstance(value, dict): + trimmed_schema[key] = { + name: self._trim_json_schema(dependency) + if isinstance(dependency, dict) + else dependency + for name, dependency in value.items() + } + continue + trimmed_schema[key] = value return trimmed_schema def _serialize_json_like(self, value: Any) -> str: diff --git a/tests/extensions/test_tool_output_trimmer.py b/tests/extensions/test_tool_output_trimmer.py index 58fcdbd191..fb615e99b2 100644 --- a/tests/extensions/test_tool_output_trimmer.py +++ b/tests/extensions/test_tool_output_trimmer.py @@ -10,8 +10,11 @@ from unittest.mock import MagicMock import pytest +from openai.types.responses import ResponseFunctionToolCall +from agents import ItemHelpers, ToolOutputFileContent, ToolOutputImage, ToolOutputText from agents.extensions.tool_output_trimmer import ToolOutputTrimmer +from agents.models.chatcmpl_converter import Converter from agents.run_config import CallModelData, ModelInputData # --------------------------------------------------------------------------- @@ -34,7 +37,7 @@ def _func_call(call_id: str, name: str, *, namespace: str | None = None) -> dict return item -def _func_output(call_id: str, output: str) -> dict[str, Any]: +def _func_output(call_id: str, output: Any) -> dict[str, Any]: return {"type": "function_call_output", "call_id": call_id, "output": output} @@ -220,6 +223,329 @@ def test_preserves_small_old_output(self) -> None: result = trimmer(_make_data(items)) assert _output(result, 2) == small + @pytest.mark.parametrize("opaque_first", [True, False]) + def test_structured_output_previews_text_and_drops_opaque_parts( + self, opaque_first: bool + ) -> None: + """Canonical structured outputs use text content instead of representation order.""" + caption = "Revenue chart: Q3 up 12% YoY, driven by EMEA." + image_part = { + "type": "input_image", + "image_url": "data:image/png;base64," + "Q" * 3000, + "detail": "auto", + } + text_part = {"type": "input_text", "text": caption} + parts = [image_part, text_part] if opaque_first else [text_part, image_part] + items = [ + _user("q1"), + _func_call("c1", "plot"), + _func_output("c1", parts), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=500, preview_chars=200) + result = trimmer(_make_data(items)) + trimmed = _output(result, 2) + + assert isinstance(trimmed, str) + assert caption in trimmed + assert "base64," not in trimmed + assert f"preview {len(caption)}" in trimmed + assert "dropped 1 input_image" in trimmed + assert not trimmed.endswith("...") + + repeated = trimmer(_make_data(result.input)) + assert repeated.input == result.input + + def test_structured_output_truncates_long_text_with_exact_preview_length(self) -> None: + """The summary reports and marks truncation only when text itself is shortened.""" + text = "abcdefghij" * 40 + parts = [{"type": "input_text", "text": text}] + items = [ + _user("q1"), + _func_call("c1", "search"), + _func_output("c1", parts), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=100, preview_chars=40) + result = trimmer(_make_data(items)) + trimmed = _output(result, 2) + + assert len(trimmed) <= 100 + assert "payload 400" in trimmed + assert "preview 40" in trimmed + assert trimmed.endswith(f"{'abcdefghij' * 4}...") + + repeated = trimmer(_make_data(result.input)) + assert repeated.input == result.input + + def test_structured_output_prioritizes_text_at_tight_budget(self) -> None: + """A tight structured budget preserves feasible text before optional metadata.""" + text = "abcdefghijklmnopqrstuvwxyz" * 10 + items = [ + _user("q1"), + _func_call("c1", "search"), + _func_output("c1", [{"type": "input_text", "text": text}]), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=40, preview_chars=100) + result = trimmer(_make_data(items)) + trimmed = _output(result, 2) + + assert isinstance(trimmed, str) + assert len(trimmed) <= 40 + assert trimmed.startswith("[Trimmed]\n") + assert text[:20] in trimmed + + repeated = trimmer(_make_data(result.input)) + assert repeated.input == result.input + + def test_structured_output_without_text_names_dropped_parts(self) -> None: + """Image-only and file-only outputs are summarized without leaking their payloads.""" + parts = [ + {"type": "input_image", "image_url": "data:image/png;base64," + "Q" * 1000}, + {"type": "input_file", "file_data": "R" * 1000, "filename": "report.pdf"}, + ] + items = [ + _user("q1"), + _func_call("c1", "render"), + _func_output("c1", parts), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=500, preview_chars=200) + result = trimmer(_make_data(items)) + trimmed = _output(result, 2) + + assert "base64," not in trimmed + assert "report.pdf" not in trimmed + assert "dropped 1 input_file, 1 input_image" in trimmed + assert "char preview" not in trimmed + assert not trimmed.endswith("...") + + def test_structured_output_prioritizes_text_over_opaque_metadata(self) -> None: + """Mixed outputs retain their feasible text before optional dropped-part details.""" + text = "useful-text-preview-more" + parts = [ + {"type": "input_text", "text": text}, + {"type": "input_image", "image_url": "image-payload-" + "Q" * 1000}, + {"type": "input_file", "file_data": "file-payload-" + "R" * 1000}, + ] + items = [ + _user("q1"), + _func_call("c1", "render"), + _func_output("c1", parts), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=70, preview_chars=20) + result = trimmer(_make_data(items)) + trimmed = _output(result, 2) + + assert isinstance(trimmed, str) + assert len(trimmed) <= 70 + assert text[:20] in trimmed + assert "image-payload" not in trimmed + assert "file-payload" not in trimmed + + repeated = trimmer(_make_data(result.input)) + assert repeated.input == result.input + + @pytest.mark.parametrize( + "part,payload_fragment", + [ + ({"type": "input_image", "image_url": "image-payload-12345"}, "image-payload"), + ({"type": "input_file", "file_data": "file-payload-12345"}, "file-payload"), + ], + ) + def test_structured_opaque_output_respects_tight_budget( + self, part: dict[str, str], payload_fragment: str + ) -> None: + """Canonical opaque payloads become stable bounded summaries at tight budgets.""" + items = [ + _user("q1"), + _func_call("c1", "render"), + _func_output("c1", [part]), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=10, preview_chars=0) + result = trimmer(_make_data(items)) + trimmed = _output(result, 2) + + assert isinstance(trimmed, str) + assert len(trimmed) <= 10 + assert payload_fragment not in trimmed + + repeated = trimmer(_make_data(result.input)) + assert repeated.input == result.input + + def test_structured_opaque_output_uses_exact_type_header_when_it_fits(self) -> None: + """A compact summary reports the exact omitted type before generic metadata.""" + part = {"type": "input_image", "image_url": "image-payload-" + "Q" * 1000} + items = [ + _user("q1"), + _func_call("c1", "render"), + _func_output("c1", [part]), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + expected = "[Trimmed: input_image]" + + result = ToolOutputTrimmer(max_output_chars=len(expected), preview_chars=0)( + _make_data(items) + ) + + assert _output(result, 2) == expected + + @pytest.mark.parametrize( + "max_output_chars", + [1, len("[Trimmed]"), len("[Trimmed: input_image]"), 70, 200], + ) + def test_canonical_structured_output_replays_through_chat_completions( + self, max_output_chars: int + ) -> None: + """SDK-produced structured output stays bounded and replayable after trimming.""" + call = ResponseFunctionToolCall( + id="fc1", + call_id="c1", + name="render", + arguments="{}", + type="function_call", + ) + output_item = ItemHelpers.tool_call_output_item( + call, + [ + ToolOutputText(text="useful-text-preview-" + "T" * 1000), + ToolOutputImage( + image_url="image-payload-" + "I" * 1000, + file_id="image-file-id", + detail="high", + ), + ToolOutputFileContent( + file_data="file-payload-" + "F" * 1000, + file_url="https://example.com/report.pdf", + file_id="file-id", + filename="report.pdf", + ), + ], + ) + produced_output = output_item["output"] + assert isinstance(produced_output, list) + assert produced_output[1] == { + "type": "input_image", + "image_url": "image-payload-" + "I" * 1000, + "file_id": "image-file-id", + "detail": "high", + } + assert produced_output[2] == { + "type": "input_file", + "file_data": "file-payload-" + "F" * 1000, + "file_url": "https://example.com/report.pdf", + "file_id": "file-id", + "filename": "report.pdf", + } + + items = [ + _user("q1"), + _func_call("c1", "render"), + output_item, + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + original = copy.deepcopy(items) + trimmer = ToolOutputTrimmer(max_output_chars=max_output_chars, preview_chars=20) + result = trimmer(_make_data(items)) + trimmed = _output(result, 2) + + assert isinstance(trimmed, str) + assert len(trimmed) <= max_output_chars + assert "image-payload" not in trimmed + assert "file-payload" not in trimmed + assert items == original + assert trimmer(_make_data(result.input)).input == result.input + + messages = Converter.items_to_messages([result.input[2]]) + assert messages == [{"role": "tool", "tool_call_id": "c1", "content": trimmed}] + + def test_structured_output_threshold_uses_payload_characters(self) -> None: + """Structured syntax and field names do not cause a small payload to be trimmed.""" + parts = [{"type": "input_text", "text": "short"}] + items = [ + _user("q1"), + _func_call("c1", "search"), + _func_output("c1", parts), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=5, preview_chars=2) + result = trimmer(_make_data(items)) + + assert _output(result, 2) == parts + + @pytest.mark.parametrize( + "parts", + [ + [{"type": "output_text", "text": "x" * 1000}], + [{"type": "input_text", "text": "x" * 1000, "metadata": "unsupported"}], + [{"type": "input_image", "image_url": "x" * 1000, "detail": "invalid"}], + ["not a content part"], + ], + ) + def test_unsupported_structured_output_is_preserved(self, parts: list[Any]) -> None: + """The built-in trimmer does not infer semantics for non-canonical list shapes.""" + items = [ + _user("q1"), + _func_call("c1", "search"), + _func_output("c1", parts), + _assistant("a1"), + _user("q2"), + _assistant("a2"), + _user("q3"), + _assistant("a3"), + ] + + trimmer = ToolOutputTrimmer(max_output_chars=100, preview_chars=40) + result = trimmer(_make_data(items)) + + assert _output(result, 2) == parts + def test_respects_trimmable_tools_allowlist(self) -> None: """Only outputs from tools in trimmable_tools should be trimmed.""" large = "x" * 1000 @@ -441,6 +767,10 @@ def test_keeps_definition_names_and_instance_data_in_schema(self) -> None: "patternProperties": {"title": {"type": "string"}}, "dependentSchemas": {"title": {"required": ["note"]}}, "dependentRequired": {"title": ["note"]}, + "dependencies": { + "description": ["note"], + "title": {"type": "string", "description": "dependency prose " * 200}, + }, "properties": { "note": {"$ref": "#/$defs/description"}, "prio": {"$ref": "#/$defs/Priority"}, @@ -452,6 +782,10 @@ def test_keeps_definition_names_and_instance_data_in_schema(self) -> None: "mode": {"const": {"title": "A", "kind": "fast"}}, "choice": {"enum": [{"title": "A", "id": 1}, {"title": "B", "id": 2}]}, }, + "x-tool-metadata": { + "description": "application data", + "nested": {"title": "must survive"}, + }, "required": ["note"], } items = [ @@ -492,6 +826,8 @@ def test_keeps_definition_names_and_instance_data_in_schema(self) -> None: assert sorted(trimmed["patternProperties"]) == ["title"] assert sorted(trimmed["dependentSchemas"]) == ["title"] assert trimmed["dependentRequired"] == {"title": ["note"]} + assert sorted(trimmed["dependencies"]) == ["description", "title"] + assert "description" not in trimmed["dependencies"]["title"] # Instance data is preserved byte for byte. assert trimmed["properties"]["opts"]["default"] == { @@ -504,6 +840,10 @@ def test_keeps_definition_names_and_instance_data_in_schema(self) -> None: {"title": "A", "id": 1}, {"title": "B", "id": 2}, ] + assert trimmed["x-tool-metadata"] == { + "description": "application data", + "nested": {"title": "must survive"}, + } # Prose is still trimmed, at the schema level and inside a nested subschema. assert "description" not in trimmed @@ -524,6 +864,7 @@ def test_trims_prose_inside_genuine_subschema_keywords(self) -> None: "propertyNames": {"pattern": "^x", "description": "a key " * 200}, }, }, + "allOf": [{"type": "object", "description": "combined schema " * 200}], } items = [ _user("q1"), @@ -554,6 +895,7 @@ def test_trims_prose_inside_genuine_subschema_keywords(self) -> None: assert trimmed["properties"]["tags"]["items"] == {"type": "string"} assert trimmed["properties"]["bag"]["propertyNames"] == {"pattern": "^x"} + assert trimmed["allOf"] == [{"type": "object"}] def test_trims_legacy_tool_search_output_results(self) -> None: """Legacy tool_search_output snapshots with free-text results should still trim.""" From afd1a262494e8bcbb113158ca7dcb73bd7981628 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:09:43 -0700 Subject: [PATCH 170/473] fix(memory): release SQLite engine config entries when engines are collected (#4210) --- .../extensions/memory/sqlalchemy_session.py | 8 ++++ .../memory/test_sqlalchemy_session.py | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/agents/extensions/memory/sqlalchemy_session.py b/src/agents/extensions/memory/sqlalchemy_session.py index 977c25cfa4..8751cca68b 100644 --- a/src/agents/extensions/memory/sqlalchemy_session.py +++ b/src/agents/extensions/memory/sqlalchemy_session.py @@ -26,6 +26,7 @@ import asyncio import json import threading +import weakref from typing import Any, ClassVar from sqlalchemy import ( @@ -63,6 +64,10 @@ class SQLAlchemySession(SessionABC): _table_init_locks: ClassVar[dict[tuple[str, str, str], threading.Lock]] = {} _table_init_locks_guard: ClassVar[threading.Lock] = threading.Lock() + # Keyed on id(engine.sync_engine) so two distinct engines that happen to compare equal + # never share a cache entry. A weakref.finalize callback removes the entry when the sync + # engine is garbage collected, preventing stale id() values from being reused by a future + # engine that has not been configured yet. _sqlite_configured_engines: ClassVar[set[int]] = set() _sqlite_configured_engines_guard: ClassVar[threading.Lock] = threading.Lock() _SQLITE_BUSY_TIMEOUT_MS: ClassVar[int] = 5000 @@ -109,6 +114,9 @@ def _configure_sqlite_connection(dbapi_connection: Any, _: Any) -> None: cursor.close() cls._sqlite_configured_engines.add(engine_key) + # Drop the entry once the sync engine goes away so a later engine allocated at the + # same address is still configured instead of being treated as already configured. + weakref.finalize(engine.sync_engine, cls._sqlite_configured_engines.discard, engine_key) @staticmethod def _is_sqlite_lock_error(exc: OperationalError) -> bool: diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index 25f3001a2e..b1984f4e4a 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import gc import json import threading from collections.abc import Iterable, Sequence @@ -990,3 +991,44 @@ async def test_runner_with_session_settings_override(agent: Agent): history_items = [item for item in last_input if item.get("content") != "New question"] # Should have 2 history items (last two from the 10 we added) assert len(history_items) == 2 + + +async def test_sqlite_configuration_registry_releases_collected_engines(tmp_path): + """The SQLite configuration registry must not keep ids of collected engines.""" + db_url = f"sqlite+aiosqlite:///{tmp_path / 'sqlite_registry_release.db'}" + session = SQLAlchemySession.from_url( + "sqlite_registry_release", + url=db_url, + create_tables=True, + ) + engine = session.engine + engine_key = id(engine.sync_engine) + assert engine_key in SQLAlchemySession._sqlite_configured_engines + + await engine.dispose() + del session + del engine + gc.collect() + + # A later engine can be allocated at the same address, so a stale entry would make the + # SQLite PRAGMA setup silently skipped for an engine that was never configured. + assert engine_key not in SQLAlchemySession._sqlite_configured_engines + + +async def test_sqlite_configuration_registry_does_not_grow_unbounded(tmp_path): + """Short-lived SQLite sessions must not accumulate registry entries.""" + baseline = len(SQLAlchemySession._sqlite_configured_engines) + + for index in range(25): + db_url = f"sqlite+aiosqlite:///{tmp_path / f'sqlite_registry_growth_{index}.db'}" + session = SQLAlchemySession.from_url( + f"sqlite_registry_growth_{index}", + url=db_url, + create_tables=True, + ) + await session.add_items([{"role": "user", "content": f"turn {index}"}]) + await session.engine.dispose() + del session + gc.collect() + + assert len(SQLAlchemySession._sqlite_configured_engines) == baseline From c48dd4c1aaa6fddf8afd3a99e6cbdc465148b9d4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 5 Aug 2026 22:27:54 +0900 Subject: [PATCH 171/473] feat: pass run context to custom sessions (#4209) --- .../extensions/memory/encrypt_session.py | 67 ++- src/agents/memory/session.py | 64 ++- src/agents/run.py | 28 +- .../run_internal/agent_runner_helpers.py | 5 + src/agents/run_internal/run_loop.py | 26 +- .../run_internal/session_persistence.py | 136 ++++-- .../extensions/memory/test_encrypt_session.py | 71 ++- tests/memory/test_session_context_wrapper.py | 459 ++++++++++++++++++ tests/test_agent_runner_streamed.py | 2 + 9 files changed, 810 insertions(+), 48 deletions(-) create mode 100644 tests/memory/test_session_context_wrapper.py diff --git a/src/agents/extensions/memory/encrypt_session.py b/src/agents/extensions/memory/encrypt_session.py index 19ba7a5683..8b2eb18226 100644 --- a/src/agents/extensions/memory/encrypt_session.py +++ b/src/agents/extensions/memory/encrypt_session.py @@ -37,8 +37,9 @@ from typing_extensions import TypedDict from ...items import TResponseInputItem -from ...memory.session import SessionABC +from ...memory.session import SessionABC, _call_session_method, _get_session_wrapper from ...memory.session_settings import SessionSettings, resolve_session_limit +from ...run_context import RunContextWrapper class EncryptedEnvelope(TypedDict): @@ -180,12 +181,25 @@ def _unwrap_valid_items( valid_items.append(item) return valid_items - async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: + wrapper = _get_session_wrapper(self.underlying_session, wrapper) effective_limit = resolve_session_limit(limit, self.session_settings) if effective_limit is not None and effective_limit > 0: window = effective_limit while True: - encrypted_items = await self.underlying_session.get_items(window) + encrypted_items = cast( + list[TResponseInputItem], + await _call_session_method( + self.underlying_session.get_items, + window, + wrapper=wrapper, + ), + ) valid_items = self._unwrap_valid_items(encrypted_items) if len(valid_items) >= effective_limit: return valid_items[-effective_limit:] @@ -193,21 +207,54 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: return valid_items window *= 2 - encrypted_items = await self.underlying_session.get_items(limit) + encrypted_items = cast( + list[TResponseInputItem], + await _call_session_method( + self.underlying_session.get_items, + limit, + wrapper=wrapper, + ), + ) return self._unwrap_valid_items(encrypted_items) - async def add_items(self, items: list[TResponseInputItem]) -> None: + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + wrapper = _get_session_wrapper(self.underlying_session, wrapper) wrapped: list[EncryptedEnvelope] = [self._wrap(it) for it in items] - await self.underlying_session.add_items(cast(list[TResponseInputItem], wrapped)) + await _call_session_method( + self.underlying_session.add_items, + cast(list[TResponseInputItem], wrapped), + wrapper=wrapper, + ) - async def pop_item(self) -> TResponseInputItem | None: + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: + wrapper = _get_session_wrapper(self.underlying_session, wrapper) while True: - enc = await self.underlying_session.pop_item() + enc = await _call_session_method( + self.underlying_session.pop_item, + wrapper=wrapper, + ) if not enc: return None item = self._unwrap(enc) if item is not None: return item - async def clear_session(self) -> None: - await self.underlying_session.clear_session() + async def clear_session( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + wrapper = _get_session_wrapper(self.underlying_session, wrapper) + await _call_session_method( + self.underlying_session.clear_session, + wrapper=wrapper, + ) diff --git a/src/agents/memory/session.py b/src/agents/memory/session.py index 1781b7ac9f..26690c2c71 100644 --- a/src/agents/memory/session.py +++ b/src/agents/memory/session.py @@ -1,12 +1,14 @@ from __future__ import annotations +import inspect from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Literal, Protocol, TypeGuard, runtime_checkable +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeGuard, runtime_checkable from typing_extensions import TypedDict if TYPE_CHECKING: from ..items import TResponseInputItem + from ..run_context import RunContextWrapper from .session_settings import SessionSettings @@ -148,3 +150,63 @@ def is_openai_responses_compaction_aware_session( except Exception: return False return callable(run_compaction) + + +def _session_method_accepts_wrapper(method: Any) -> bool: + """Return whether a session method opts into receiving ``wrapper``. + + The public ``Session`` protocol keeps its released signatures so existing structural + implementations remain type-compatible. Custom sessions can opt in by adding a ``wrapper`` + parameter that can be passed by keyword. + """ + try: + parameters = inspect.signature(method).parameters.values() + except Exception: + return False + + return any( + parameter.name == "wrapper" + and parameter.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + for parameter in parameters + ) + + +def _session_accepts_wrapper(session: Any) -> bool: + """Return whether every history operation accepts ``wrapper``.""" + try: + methods = ( + session.get_items, + session.add_items, + session.pop_item, + session.clear_session, + ) + except Exception: + return False + return all(_session_method_accepts_wrapper(method) for method in methods) + + +def _get_session_wrapper( + session: Any, + wrapper: RunContextWrapper[Any] | None, +) -> RunContextWrapper[Any] | None: + """Return ``wrapper`` only for sessions with a complete context-aware contract.""" + if wrapper is None or not _session_accepts_wrapper(session): + return None + return wrapper + + +async def _call_session_method( + method: Any, + /, + *args: Any, + wrapper: RunContextWrapper[Any] | None = None, + **kwargs: Any, +) -> Any: + """Call a session method with its legacy shape unless it opts into ``wrapper``.""" + if wrapper is not None and _session_method_accepts_wrapper(method): + kwargs["wrapper"] = wrapper + result = method(*args, **kwargs) + if inspect.isawaitable(result): + return await result + return result diff --git a/src/agents/run.py b/src/agents/run.py index 00028cf406..e95bb0d7f7 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -108,6 +108,7 @@ NextStepRunAgain, ) from .run_internal.session_persistence import ( + _session_get_items, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -525,6 +526,9 @@ async def run( previous_response_id=previous_response_id, auto_previous_response_id=auto_previous_response_id, ) + context_wrapper = ensure_context_wrapper(context) + context = context_wrapper.context + set_agent_tool_state_scope(context_wrapper, None) server_manages_conversation = ( conversation_id is not None @@ -540,6 +544,7 @@ async def run( run_config.session_settings, include_history_in_prepared_input=False, preserve_dropped_new_items=True, + wrapper=context_wrapper, ) original_input_for_state = raw_input session_input_items_for_persistence = [] @@ -552,6 +557,7 @@ async def run( session, run_config.session_input_callback, run_config.session_settings, + wrapper=context_wrapper, ) original_input_for_state = prepared_input @@ -588,7 +594,10 @@ async def run( session_input_items: list[TResponseInputItem] | None = None if session is not None: try: - session_input_items = await session.get_items() + session_input_items = await _session_get_items( + session, + wrapper=context_wrapper, + ) except Exception: session_input_items = None server_conversation_tracker.hydrate_from_state( @@ -646,8 +655,6 @@ async def run( generated_items = [] session_items = [] model_responses = [] - context_wrapper = ensure_context_wrapper(context) - set_agent_tool_state_scope(context_wrapper, None) run_state = RunState( context=context_wrapper, original_input=original_input, @@ -782,6 +789,7 @@ def _finalize_result(result: RunResult) -> RunResult: [], run_state, store=store_setting, + wrapper=context_wrapper, ) session_input_items_for_persistence = [] except BaseException: @@ -825,6 +833,7 @@ def _finalize_result(result: RunResult) -> RunResult: original_user_input, run_state, store=store_setting, + wrapper=context_wrapper, ) ) raise @@ -875,6 +884,7 @@ def _finalize_result(result: RunResult) -> RunResult: [], run_state, store=store_setting, + wrapper=context_wrapper, ) session_input_items_for_persistence = [] if run_state is not None and run_state._current_step is not None: @@ -944,6 +954,7 @@ def _finalize_result(result: RunResult) -> RunResult: run_state._reasoning_item_id_policy ), store=store_setting, + wrapper=context_wrapper, ) ) @@ -1057,6 +1068,7 @@ def _finalize_result(result: RunResult) -> RunResult: run_state, response_id=turn_result.model_response.response_id, store=store_setting, + wrapper=context_wrapper, ) result._original_input = copy_input_items(original_input) return _finalize_result(result) @@ -1191,6 +1203,7 @@ def _finalize_result(result: RunResult) -> RunResult: response_id=None, reasoning_item_id_policy=resolved_reasoning_item_id_policy, store=store_setting, + wrapper=context_wrapper, ) result._original_input = copy_input_items(original_input) return _finalize_result(result) @@ -1245,6 +1258,7 @@ def _finalize_result(result: RunResult) -> RunResult: original_user_input, run_state, store=store_setting, + wrapper=context_wrapper, ) ) raise @@ -1302,6 +1316,7 @@ def _finalize_result(result: RunResult) -> RunResult: original_user_input, run_state, store=store_setting, + wrapper=context_wrapper, ) ) raise @@ -1431,6 +1446,7 @@ def _finalize_result(result: RunResult) -> RunResult: run_state._reasoning_item_id_policy ), store=store_setting, + wrapper=context_wrapper, ) run_state._current_turn_persisted_item_count += saved_count else: @@ -1441,6 +1457,7 @@ def _finalize_result(result: RunResult) -> RunResult: run_state, response_id=turn_result.model_response.response_id, store=store_setting, + wrapper=context_wrapper, ) # After the first resumed turn, treat subsequent turns as fresh @@ -1467,6 +1484,7 @@ def _finalize_result(result: RunResult) -> RunResult: items=_retained_items_for_blocked_output(items_to_save_turn), response_id=turn_result.model_response.response_id, store=store_setting, + wrapper=context_wrapper, ) raise except (Exception, asyncio.CancelledError): @@ -1480,6 +1498,7 @@ def _finalize_result(result: RunResult) -> RunResult: items=items_to_save_turn, response_id=turn_result.model_response.response_id, store=store_setting, + wrapper=context_wrapper, ) raise @@ -1491,6 +1510,7 @@ def _finalize_result(result: RunResult) -> RunResult: items=items_to_save_turn, response_id=turn_result.model_response.response_id, store=store_setting, + wrapper=context_wrapper, ) # Ensure starting_input is not None and not RunState @@ -1539,6 +1559,7 @@ def _finalize_result(result: RunResult) -> RunResult: run_state, response_id=turn_result.model_response.response_id, store=store_setting, + wrapper=context_wrapper, ) append_model_response_if_new( model_responses, turn_result.model_response @@ -1600,6 +1621,7 @@ def _finalize_result(result: RunResult) -> RunResult: items=session_items_for_turn(turn_result), response_id=turn_result.model_response.response_id, store=store_setting, + wrapper=context_wrapper, ) continue else: diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index d380ebe649..348908b79f 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -474,6 +474,7 @@ async def save_turn_items_if_needed( items: list[RunItem], response_id: str | None, store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, ) -> None: """Persist turn items when persistence is enabled and guardrails allow it.""" if not session_persistence_enabled: @@ -489,6 +490,7 @@ async def save_turn_items_if_needed( run_state, response_id=response_id, store=store, + wrapper=wrapper, ) @@ -501,6 +503,7 @@ async def save_final_turn_items_after_guardrails( items: list[RunItem], response_id: str | None, store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, ) -> None: """Persist deferred final-turn items without skipping a partially persisted resumed turn.""" if not session_persistence_enabled or not items: @@ -515,6 +518,7 @@ async def save_final_turn_items_after_guardrails( response_id=response_id, reasoning_item_id_policy=run_state._reasoning_item_id_policy, store=store, + wrapper=wrapper, ) return await save_result_to_session( @@ -524,6 +528,7 @@ async def save_final_turn_items_after_guardrails( run_state, response_id=response_id, store=store, + wrapper=wrapper, ) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 643238d914..66924bc5fb 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -157,6 +157,7 @@ ToolRunShellCall, ) from .session_persistence import ( + _session_get_items, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -367,6 +368,7 @@ async def _save_resumed_stream_items( response_id=response_id, reasoning_item_id_policy=streamed_result._reasoning_item_id_policy, store=store, + wrapper=streamed_result.context_wrapper, ) if run_state is not None: run_state._current_turn_persisted_item_count = ( @@ -398,6 +400,7 @@ async def _save_stream_items( run_state, response_id=response_id, store=store, + wrapper=streamed_result.context_wrapper, ) if update_persisted_count and streamed_result._state is not None: streamed_result._current_turn_persisted_item_count = ( @@ -728,7 +731,10 @@ def _sync_conversation_tracking_from_tracker() -> None: session_items: list[TResponseInputItem] | None = None if session is not None: try: - session_items = await session.get_items() + session_items = await _session_get_items( + session, + wrapper=context_wrapper, + ) except Exception: session_items = None server_conversation_tracker.hydrate_from_state( @@ -768,6 +774,7 @@ def _sync_conversation_tracking_from_tracker() -> None: run_config.session_settings, include_history_in_prepared_input=not server_manages_conversation, preserve_dropped_new_items=True, + wrapper=context_wrapper, ) streamed_result.input = prepared_input streamed_result._original_input = copy_input_items(prepared_input) @@ -871,6 +878,7 @@ async def _save_stream_items_without_count( store=current_agent.model_settings.resolve( run_config.model_settings ).store, + wrapper=context_wrapper, ) ) raise InputGuardrailTripwireTriggered(result) @@ -1180,6 +1188,7 @@ async def _save_stream_items_without_count( store=current_agent.model_settings.resolve( run_config.model_settings ).store, + wrapper=context_wrapper, ) ) raise InputGuardrailTripwireTriggered(result) @@ -1648,7 +1657,13 @@ def _tool_search_fingerprint(raw_item: Any) -> str: ) ] if input_items_to_save: - await save_result_to_session(session, input_items_to_save, [], streamed_result._state) + await save_result_to_session( + session, + input_items_to_save, + [], + streamed_result._state, + wrapper=context_wrapper, + ) previous_response_id = ( server_conversation_tracker.previous_response_id @@ -2121,7 +2136,12 @@ async def get_new_response( async def rewind_model_request() -> None: if server_conversation_tracker is not None: items_to_rewind = session_items_to_rewind if session_items_to_rewind is not None else [] - await rewind_session_items(session, items_to_rewind, server_conversation_tracker) + await rewind_session_items( + session, + items_to_rewind, + server_conversation_tracker, + wrapper=context_wrapper, + ) server_conversation_tracker.rewind_input(filtered.input) with model_run_context(tool_use_tracker): diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 3f44e7d2d7..b9dc6449ad 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -29,6 +29,8 @@ is_openai_responses_compaction_aware_session, ) from ..memory.openai_conversations_session import OpenAIConversationsSession +from ..memory.session import _call_session_method, _get_session_wrapper +from ..run_context import RunContextWrapper from ..run_state import RunState from .items import ( NestedHistoryOwnedItem, @@ -65,6 +67,48 @@ ] +_SESSION_LIMIT_UNSET = object() + + +async def _session_get_items( + session: Session, + limit: int | None | object = _SESSION_LIMIT_UNSET, + *, + wrapper: RunContextWrapper[Any] | None = None, +) -> list[TResponseInputItem]: + """Read session items while preserving the legacy method call shape.""" + wrapper = _get_session_wrapper(session, wrapper) + if limit is _SESSION_LIMIT_UNSET: + result = await _call_session_method(session.get_items, wrapper=wrapper) + else: + result = await _call_session_method(session.get_items, limit=limit, wrapper=wrapper) + return cast(list[TResponseInputItem], result) + + +async def _session_add_items( + session: Session, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, +) -> None: + """Append session items while preserving the legacy method call shape.""" + wrapper = _get_session_wrapper(session, wrapper) + await _call_session_method(session.add_items, items, wrapper=wrapper) + + +async def _session_pop_item( + session: Session, + *, + wrapper: RunContextWrapper[Any] | None = None, +) -> TResponseInputItem | None: + """Pop a session item while preserving the legacy method call shape.""" + wrapper = _get_session_wrapper(session, wrapper) + return cast( + TResponseInputItem | None, + await _call_session_method(session.pop_item, wrapper=wrapper), + ) + + def resolve_nested_history_owned_session_item_refs( session_items: Sequence[RunItem], current_input: str | Sequence[TResponseInputItem], @@ -162,6 +206,7 @@ async def prepare_input_with_session( *, include_history_in_prepared_input: bool = True, preserve_dropped_new_items: bool = False, + wrapper: RunContextWrapper[Any] | None = None, ) -> tuple[str | list[TResponseInputItem], list[TResponseInputItem]]: """Prepare model input from session history plus the new turn input. @@ -186,9 +231,13 @@ async def prepare_input_with_session( resolved_settings = resolved_settings.resolve(session_settings) if resolved_settings.limit is not None: - history = await session.get_items(limit=resolved_settings.limit) + history = await _session_get_items( + session, + limit=resolved_settings.limit, + wrapper=wrapper, + ) else: - history = await session.get_items() + history = await _session_get_items(session, wrapper=wrapper) is_openai_conversation_session = isinstance(session, OpenAIConversationsSession) converted_history = [ strip_internal_input_item_metadata(ensure_input_item_format(item)) for item in history @@ -307,6 +356,7 @@ async def persist_session_items_for_guardrail_trip( original_user_input: str | list[TResponseInputItem] | None, run_state: RunState | None, store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, ) -> list[TResponseInputItem] | None: """ Persist input items when a guardrail tripwire is triggered. @@ -321,7 +371,14 @@ async def persist_session_items_for_guardrail_trip( input_items_for_save: list[TResponseInputItem] = ( updated_session_input_items if updated_session_input_items is not None else [] ) - await save_result_to_session(session, input_items_for_save, [], run_state, store=store) + await save_result_to_session( + session, + input_items_for_save, + [], + run_state, + store=store, + wrapper=wrapper, + ) return updated_session_input_items @@ -366,6 +423,7 @@ async def save_result_to_session( response_id: str | None = None, reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, ) -> int: """ Persist a turn to the session store, keeping track of what was already saved so retries @@ -379,6 +437,8 @@ async def save_result_to_session( if session is None: return 0 + wrapper = _get_session_wrapper(session, wrapper) + new_run_items: list[RunItem] if already_persisted >= len(new_items): new_run_items = [] @@ -459,7 +519,7 @@ async def save_result_to_session( run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count return saved_run_items_count - await session.add_items(items_to_save) + await _session_add_items(session, items_to_save, wrapper=wrapper) if run_state: run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count @@ -471,9 +531,12 @@ async def save_result_to_session( if has_local_tool_outputs: defer_compaction = getattr(session, "_defer_compaction", None) if callable(defer_compaction): - result = defer_compaction(response_id, store=store) - if inspect.isawaitable(result): - await result + await _call_session_method( + defer_compaction, + response_id, + store=store, + wrapper=wrapper, + ) logger.debug( "skip: deferring compaction for response %s due to local tool outputs", response_id, @@ -497,7 +560,11 @@ async def save_result_to_session( } if store is not None: compaction_args["store"] = store - await session.run_compaction(compaction_args) + await _call_session_method( + session.run_compaction, + compaction_args, + wrapper=wrapper, + ) return saved_run_items_count @@ -510,6 +577,7 @@ async def save_resumed_turn_items( response_id: str | None, reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, ) -> int: """Persist resumed turn items and return the updated persisted count.""" if session is None or not items: @@ -522,6 +590,7 @@ async def save_resumed_turn_items( response_id=response_id, reasoning_item_id_policy=reasoning_item_id_policy, store=store, + wrapper=wrapper, ) return persisted_count + saved_count @@ -530,6 +599,8 @@ async def rewind_session_items( session: Session | None, items: Sequence[TResponseInputItem], server_tracker: OpenAIServerConversationTracker | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, ) -> None: """ Best-effort helper to roll back items recently persisted to a session when a conversation @@ -538,8 +609,7 @@ async def rewind_session_items( if session is None or not items: return - pop_item = getattr(session, "pop_item", None) - if not callable(pop_item): + if not callable(getattr(session, "pop_item", None)): return ignore_ids_for_matching = _ignore_ids_for_matching(session) @@ -564,13 +634,13 @@ async def rewind_session_items( snapshot_serializations = target_serializations.copy() rewound = await _rewind_session_tail_suffix( session=session, - pop_item=pop_item, expected_serializations=target_serializations, ignore_ids_for_matching=ignore_ids_for_matching, mismatch_warning=( "Skipping session rewind because the current tail does not match the retry-owned suffix" ), pop_failure_warning="Failed to rewind session item", + wrapper=wrapper, ) if not rewound: return @@ -579,13 +649,14 @@ async def rewind_session_items( session, snapshot_serializations, ignore_ids_for_matching=ignore_ids_for_matching, + wrapper=wrapper, ) if session is None or server_tracker is None: return try: - latest_items = await session.get_items(limit=1) + latest_items = await _session_get_items(session, limit=1, wrapper=wrapper) except Exception as exc: log_model_and_tool_action_debug(logger, "Failed to peek session items while rewinding", exc) return @@ -598,7 +669,7 @@ async def rewind_session_items( return try: - session_items = await session.get_items() + session_items = await _session_get_items(session, wrapper=wrapper) except Exception as exc: log_model_and_tool_action_debug( logger, "Failed to inspect session tail while stripping stray items", exc @@ -620,7 +691,6 @@ async def rewind_session_items( ) await _rewind_session_tail_suffix( session=session, - pop_item=pop_item, expected_serializations=stray_serializations, ignore_ids_for_matching=ignore_ids_for_matching, mismatch_warning=( @@ -628,6 +698,7 @@ async def rewind_session_items( "retry-owned conversation items" ), pop_failure_warning="Failed to strip stray session item", + wrapper=wrapper, ) @@ -637,6 +708,7 @@ async def wait_for_session_cleanup( *, max_attempts: int = 5, ignore_ids_for_matching: bool = False, + wrapper: RunContextWrapper[Any] | None = None, ) -> None: """ Confirm that rewound items are no longer present in the session tail so the store stays @@ -649,7 +721,7 @@ async def wait_for_session_cleanup( for attempt in range(max_attempts): try: - tail_items = await session.get_items(limit=window) + tail_items = await _session_get_items(session, limit=window, wrapper=wrapper) except Exception as exc: log_model_and_tool_action_debug( logger, f"Failed to verify session cleanup (attempt {attempt + 1})", exc @@ -771,18 +843,22 @@ def _fingerprint_or_repr(item: TResponseInputItem, *, ignore_ids_for_matching: b async def _rewind_session_tail_suffix( *, session: Session, - pop_item: Any, expected_serializations: Sequence[str], ignore_ids_for_matching: bool, mismatch_warning: str, pop_failure_warning: str, + wrapper: RunContextWrapper[Any] | None = None, ) -> bool: """Remove an exact serialized suffix from the session tail, aborting when the tail diverges.""" if not expected_serializations: return True try: - tail_items = await session.get_items(limit=len(expected_serializations)) + tail_items = await _session_get_items( + session, + limit=len(expected_serializations), + wrapper=wrapper, + ) except Exception as exc: log_model_and_tool_action_warning(logger, pop_failure_warning, exc) return False @@ -806,16 +882,14 @@ async def _rewind_session_tail_suffix( popped_items: list[TResponseInputItem] = [] for expected in reversed(expected_serializations): try: - result = pop_item() - if inspect.isawaitable(result): - result = await result + result = await _session_pop_item(session, wrapper=wrapper) except Exception as exc: - await _restore_popped_session_items(session, popped_items) + await _restore_popped_session_items(session, popped_items, wrapper=wrapper) log_model_and_tool_action_warning(logger, pop_failure_warning, exc) return False if result is None: - await _restore_popped_session_items(session, popped_items) + await _restore_popped_session_items(session, popped_items, wrapper=wrapper) logger.warning(mismatch_warning) return False @@ -824,7 +898,7 @@ async def _rewind_session_tail_suffix( result, ignore_ids_for_matching=ignore_ids_for_matching ) if popped_serialized != expected: - await _restore_popped_session_items(session, popped_items) + await _restore_popped_session_items(session, popped_items, wrapper=wrapper) logger.warning(mismatch_warning) return False @@ -832,20 +906,24 @@ async def _rewind_session_tail_suffix( async def _restore_popped_session_items( - session: Session, popped_items: Sequence[TResponseInputItem] + session: Session, + popped_items: Sequence[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, ) -> None: """Best-effort restoration for items popped during a failed rewind attempt.""" if not popped_items: return - add_items = getattr(session, "add_items", None) - if not callable(add_items): + if not callable(getattr(session, "add_items", None)): return try: - result = add_items(list(reversed(popped_items))) - if inspect.isawaitable(result): - await result + await _session_add_items( + session, + list(reversed(popped_items)), + wrapper=wrapper, + ) except Exception as exc: log_model_and_tool_action_warning( logger, "Failed to restore session items after a rewind mismatch", exc diff --git a/tests/extensions/memory/test_encrypt_session.py b/tests/extensions/memory/test_encrypt_session.py index 71d2bd13b6..fb6da900dd 100644 --- a/tests/extensions/memory/test_encrypt_session.py +++ b/tests/extensions/memory/test_encrypt_session.py @@ -2,7 +2,7 @@ import tempfile from pathlib import Path -from typing import cast +from typing import Any, cast import pytest @@ -10,7 +10,14 @@ from cryptography.fernet import Fernet -from agents import Agent, Runner, SessionSettings, SQLiteSession, TResponseInputItem +from agents import ( + Agent, + RunContextWrapper, + Runner, + SessionSettings, + SQLiteSession, + TResponseInputItem, +) from agents.extensions.memory.encrypt_session import EncryptedSession from tests.fake_model import FakeModel from tests.test_responses import get_text_message @@ -161,6 +168,66 @@ async def test_encrypted_session_clear(encryption_key: str, underlying_session: underlying_session.close() +async def test_encrypted_session_forwards_wrapper_to_all_underlying_operations( + encryption_key: str, +): + class ContextAwareUnderlying: + def __init__(self) -> None: + self.session_id = "test_session" + self.session_settings = None + self.items: list[TResponseInputItem] = [] + self.wrappers: list[RunContextWrapper[Any] | None] = [] + + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: + self.wrappers.append(wrapper) + return list(self.items if limit is None else self.items[-limit:]) + + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + self.wrappers.append(wrapper) + self.items.extend(items) + + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: + self.wrappers.append(wrapper) + return self.items.pop() if self.items else None + + async def clear_session( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + self.wrappers.append(wrapper) + self.items.clear() + + underlying = ContextAwareUnderlying() + session = EncryptedSession( + session_id="test_session", + underlying_session=cast(Any, underlying), + encryption_key=encryption_key, + ) + wrapper = RunContextWrapper(context={"tenant": "a"}) + + await session.add_items([{"role": "user", "content": "hello"}], wrapper=wrapper) + assert await session.get_items(wrapper=wrapper) == [{"role": "user", "content": "hello"}] + assert await session.pop_item(wrapper=wrapper) == {"role": "user", "content": "hello"} + await session.clear_session(wrapper=wrapper) + + assert underlying.wrappers == [wrapper, wrapper, wrapper, wrapper] + + async def test_encrypted_session_ttl_expiration( encryption_key: str, underlying_session: SQLiteSession, set_fernet_time ): diff --git a/tests/memory/test_session_context_wrapper.py b/tests/memory/test_session_context_wrapper.py new file mode 100644 index 0000000000..0a7cf65f24 --- /dev/null +++ b/tests/memory/test_session_context_wrapper.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import pytest + +from agents import Agent, RunContextWrapper, Runner, SessionSettings, TResponseInputItem +from agents.exceptions import InputGuardrailTripwireTriggered +from agents.guardrail import GuardrailFunctionOutput, InputGuardrail +from agents.memory import OpenAIResponsesCompactionSession +from agents.memory.session import _session_accepts_wrapper, _session_method_accepts_wrapper +from agents.run_internal.session_persistence import rewind_session_items +from agents.tool import function_tool +from tests.fake_model import FakeModel +from tests.test_responses import get_function_tool_call, get_text_message + + +@dataclass +class TenantContext: + tenant_id: str + + +class ContextAwareSession: + def __init__(self) -> None: + self.session_id = "context-aware" + self.session_settings: SessionSettings | None = None + self.items_by_scope: dict[str, list[TResponseInputItem]] = {"default": []} + self.calls: list[tuple[str, RunContextWrapper[Any] | None]] = [] + + def _scope(self, wrapper: RunContextWrapper[Any] | None) -> str: + if wrapper is None: + return "default" + context = cast(TenantContext, wrapper.context) + return context.tenant_id + + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: + self.calls.append(("get_items", wrapper)) + items = self.items_by_scope.setdefault(self._scope(wrapper), []) + if limit is None: + return list(items) + return list(items[-limit:]) + + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + self.calls.append(("add_items", wrapper)) + self.items_by_scope.setdefault(self._scope(wrapper), []).extend(items) + + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: + self.calls.append(("pop_item", wrapper)) + items = self.items_by_scope.setdefault(self._scope(wrapper), []) + return items.pop() if items else None + + async def clear_session( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + self.calls.append(("clear_session", wrapper)) + self.items_by_scope[self._scope(wrapper)] = [] + + +class LegacySession: + def __init__(self) -> None: + self.session_id = "legacy" + self.session_settings = None + self.items: list[TResponseInputItem] = [] + self.get_calls = 0 + + async def get_items(self) -> list[TResponseInputItem]: + self.get_calls += 1 + return list(self.items) + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.items.extend(items) + + async def pop_item(self) -> TResponseInputItem | None: + return self.items.pop() if self.items else None + + async def clear_session(self) -> None: + self.items.clear() + + +class LegacyKwargsSession: + def __init__(self) -> None: + self.session_id = "legacy-kwargs" + self.session_settings: SessionSettings | None = None + self.items: list[TResponseInputItem] = [] + self.kwargs_calls: list[dict[str, Any]] = [] + + async def get_items(self, limit: int | None = None, **kwargs: Any) -> list[TResponseInputItem]: + self.kwargs_calls.append(kwargs) + if limit is None: + return list(self.items) + return list(self.items[-limit:]) + + async def add_items(self, items: list[TResponseInputItem], **kwargs: Any) -> None: + self.kwargs_calls.append(kwargs) + self.items.extend(items) + + async def pop_item(self, **kwargs: Any) -> TResponseInputItem | None: + self.kwargs_calls.append(kwargs) + return self.items.pop() if self.items else None + + async def clear_session(self, **kwargs: Any) -> None: + self.kwargs_calls.append(kwargs) + self.items.clear() + + +class UninspectableAsyncMethod: + def __init__(self, method: Any) -> None: + self.method = method + + @property + def __signature__(self) -> Any: + raise RuntimeError("signature unavailable") + + async def __call__(self, *args: Any, **kwargs: Any) -> Any: + return await self.method(*args, **kwargs) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_runner_passes_same_wrapper_to_context_aware_session(streamed: bool) -> None: + session = ContextAwareSession() + model = FakeModel(initial_output=[get_text_message("ok")]) + agent = Agent(name="test", model=model) + context = TenantContext(tenant_id="tenant-a") + + if streamed: + result: Any = Runner.run_streamed(agent, "hello", context=context, session=session) + async for _ in result.stream_events(): + pass + else: + result = await Runner.run(agent, "hello", context=context, session=session) + + assert result.final_output == "ok" + assert [name for name, _ in session.calls] == [ + "get_items", + "add_items", + "add_items", + ] + assert all(wrapper is result.context_wrapper for _, wrapper in session.calls) + assert session.items_by_scope["default"] == [] + assert len(session.items_by_scope["tenant-a"]) == 2 + + +@pytest.mark.asyncio +async def test_runner_preserves_legacy_session_call_shapes() -> None: + session = LegacySession() + model = FakeModel(initial_output=[get_text_message("ok")]) + agent = Agent(name="test", model=model) + + result = await Runner.run( + agent, + "hello", + context=TenantContext(tenant_id="tenant-a"), + session=cast(Any, session), + ) + + assert result.final_output == "ok" + assert session.get_calls == 1 + assert len(session.items) == 2 + + +@pytest.mark.asyncio +async def test_runner_does_not_treat_legacy_kwargs_as_wrapper_opt_in() -> None: + session = LegacyKwargsSession() + model = FakeModel(initial_output=[get_text_message("ok")]) + + result = await Runner.run( + Agent(name="test", model=model), + "hello", + context=TenantContext(tenant_id="tenant-a"), + session=session, + ) + + assert result.final_output == "ok" + assert session.kwargs_calls == [{}, {}, {}] + assert len(session.items) == 2 + + +@pytest.mark.asyncio +async def test_runner_preserves_legacy_calls_when_signature_inspection_fails() -> None: + session = cast(Any, LegacySession()) + session.get_items = UninspectableAsyncMethod(session.get_items) + model = FakeModel(initial_output=[get_text_message("ok")]) + + result = await Runner.run( + Agent(name="test", model=model), + "hello", + context=TenantContext(tenant_id="tenant-a"), + session=session, + ) + + assert result.final_output == "ok" + assert session.get_calls == 1 + assert len(session.items) == 2 + + +@pytest.mark.asyncio +async def test_runner_does_not_partially_enable_context_aware_session() -> None: + class PartialSession: + def __init__(self) -> None: + self.session_id = "partial" + self.session_settings: SessionSettings | None = None + self.items: list[TResponseInputItem] = [] + self.wrappers: list[RunContextWrapper[Any] | None] = [] + + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: + self.wrappers.append(wrapper) + return list(self.items if limit is None else self.items[-limit:]) + + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + self.wrappers.append(wrapper) + self.items.extend(items) + + async def pop_item(self) -> TResponseInputItem | None: + return None + + async def clear_session(self) -> None: + pass + + session = PartialSession() + model = FakeModel(initial_output=[get_text_message("ok")]) + + result = await Runner.run( + Agent(name="test", model=model), + "hello", + context=TenantContext(tenant_id="tenant-a"), + session=session, + ) + + assert result.final_output == "ok" + assert session.wrappers == [None, None, None] + assert len(session.items) == 2 + + +@pytest.mark.asyncio +async def test_retry_rewind_uses_same_context_scope_for_reads_pops_and_cleanup() -> None: + session = ContextAwareSession() + wrapper = RunContextWrapper(context=TenantContext(tenant_id="tenant-a")) + items: list[TResponseInputItem] = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + session.items_by_scope["default"] = [{"role": "user", "content": "keep"}] + await session.add_items(items, wrapper=wrapper) + session.calls.clear() + + await rewind_session_items(session, items, wrapper=wrapper) + + assert session.items_by_scope["tenant-a"] == [] + assert session.items_by_scope["default"] == [{"role": "user", "content": "keep"}] + assert [name for name, _ in session.calls] == [ + "get_items", + "pop_item", + "pop_item", + "get_items", + ] + assert all(call_wrapper is wrapper for _, call_wrapper in session.calls) + + +@pytest.mark.asyncio +async def test_retry_rewind_restores_partial_pops_in_the_same_context_scope() -> None: + class FailingSecondPopSession(ContextAwareSession): + def __init__(self) -> None: + super().__init__() + self.pop_count = 0 + + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: + self.calls.append(("pop_item", wrapper)) + self.pop_count += 1 + if self.pop_count == 2: + raise RuntimeError("pop failed") + items = self.items_by_scope.setdefault(self._scope(wrapper), []) + return items.pop() if items else None + + session = FailingSecondPopSession() + wrapper = RunContextWrapper(context=TenantContext(tenant_id="tenant-a")) + items: list[TResponseInputItem] = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + session.items_by_scope["tenant-a"] = list(items) + session.items_by_scope["default"] = [{"role": "user", "content": "keep"}] + + await rewind_session_items(session, items, wrapper=wrapper) + + assert session.items_by_scope["tenant-a"] == items + assert session.items_by_scope["default"] == [{"role": "user", "content": "keep"}] + assert [name for name, _ in session.calls] == [ + "get_items", + "pop_item", + "pop_item", + "add_items", + ] + assert all(call_wrapper is wrapper for _, call_wrapper in session.calls) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_input_guardrail_persists_in_the_context_scope(streamed: bool) -> None: + def guardrail_function( + _context: RunContextWrapper[Any], _agent: Agent[Any], _input: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + session = ContextAwareSession() + session.items_by_scope["default"] = [{"role": "user", "content": "keep"}] + context = TenantContext(tenant_id="tenant-a") + agent = Agent( + name="test", + model=FakeModel(initial_output=[get_text_message("not persisted")]), + input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)], + ) + + with pytest.raises(InputGuardrailTripwireTriggered): + if streamed: + result = Runner.run_streamed(agent, "hello", context=context, session=session) + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, "hello", context=context, session=session) + + assert session.items_by_scope["default"] == [{"role": "user", "content": "keep"}] + assert session.items_by_scope["tenant-a"] == [{"role": "user", "content": "hello"}] + assert all(wrapper is not None and wrapper.context is context for _, wrapper in session.calls) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_resumed_run_persists_in_the_context_scope(streamed: bool) -> None: + async def test_tool() -> str: + return "tool result" + + tool = function_tool(test_tool, name_override="test_tool", needs_approval=True) + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("test_tool", "{}", call_id="call-resume")], + [get_text_message("done")], + ] + ) + agent = Agent(name="test", model=model, tools=[tool]) + session = ContextAwareSession() + session.items_by_scope["default"] = [{"role": "user", "content": "keep"}] + context = TenantContext(tenant_id="tenant-a") + + if streamed: + first: Any = Runner.run_streamed(agent, "hello", context=context, session=session) + async for _ in first.stream_events(): + pass + else: + first = await Runner.run(agent, "hello", context=context, session=session) + + assert len(first.interruptions) == 1 + state = first.to_state() + state.approve(first.interruptions[0]) + session.calls.clear() + + if streamed: + resumed: Any = Runner.run_streamed(agent, state, session=session) + async for _ in resumed.stream_events(): + pass + else: + resumed = await Runner.run(agent, state, session=session) + + assert resumed.final_output == "done" + assert session.items_by_scope["default"] == [{"role": "user", "content": "keep"}] + assert all(wrapper is resumed.context_wrapper for _, wrapper in session.calls) + assert any( + isinstance(item, dict) + and item.get("type") == "function_call_output" + and item.get("call_id") == "call-resume" + for item in session.items_by_scope["tenant-a"] + ) + + +@pytest.mark.asyncio +async def test_compaction_session_keeps_context_aware_underlying_on_legacy_scope() -> None: + underlying = ContextAwareSession() + underlying.items_by_scope["default"] = [{"role": "user", "content": "existing"}] + session = OpenAIResponsesCompactionSession( + session_id="compaction", + underlying_session=underlying, + should_trigger_compaction=lambda _: False, + ) + + result = await Runner.run( + Agent(name="test", model=FakeModel(initial_output=[get_text_message("done")])), + "hello", + context=TenantContext(tenant_id="tenant-a"), + session=session, + ) + + assert result.final_output == "done" + assert not _session_accepts_wrapper(session) + assert "tenant-a" not in underlying.items_by_scope + assert len(underlying.items_by_scope["default"]) == 3 + assert underlying.calls + assert all(wrapper is None for _, wrapper in underlying.calls) + + +def test_session_wrapper_method_requires_named_wrapper_parameter() -> None: + class Methods: + async def legacy(self) -> None: + pass + + async def positional_only(self, wrapper: Any, /) -> None: + pass + + async def keyword(self, *, wrapper: Any = None) -> None: + pass + + async def kwargs(self, **kwargs: Any) -> None: + pass + + methods = Methods() + + assert not _session_method_accepts_wrapper(methods.legacy) + assert not _session_method_accepts_wrapper(methods.positional_only) + assert _session_method_accepts_wrapper(methods.keyword) + assert not _session_method_accepts_wrapper(methods.kwargs) + + +def test_session_wrapper_opt_in_requires_all_history_operations() -> None: + session = ContextAwareSession() + assert _session_accepts_wrapper(session) + + cast(Any, session).clear_session = LegacySession().clear_session + assert not _session_accepts_wrapper(session) diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index a908eced94..2a3c605817 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -2615,6 +2615,7 @@ async def save_wrapper( response_id: str | None, reasoning_item_id_policy: str | None = None, store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, ) -> int: observed_counts.append(persisted_count) result = await real_save_resumed( @@ -2624,6 +2625,7 @@ async def save_wrapper( response_id=response_id, reasoning_item_id_policy=reasoning_item_id_policy, store=store, + wrapper=wrapper, ) return int(result) From c358dbabe7f2424e2cbe5da7a8bb3b30ea8b5234 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Thu, 6 Aug 2026 04:22:01 +0530 Subject: [PATCH 172/473] fix(sandbox): encode Blaxel terminal WebSocket URL query values (#4217) --- .../extensions/sandbox/blaxel/sandbox.py | 35 ++++++++--- tests/extensions/sandbox/test_blaxel.py | 58 +++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index 5eb88ab1ee..97145e4563 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -25,7 +25,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Literal, cast -from urllib.parse import urlsplit +from urllib.parse import quote, urlencode, urlsplit from pydantic import BaseModel, Field @@ -1280,15 +1280,32 @@ def _build_ws_url( ) -> str: """Build the WebSocket URL for a Blaxel terminal session.""" base = sandbox_url.rstrip("/") - ws_base = base.replace("https://", "wss://").replace("http://", "ws://") - return ( - f"{ws_base}/terminal/ws" - f"?token={token}" - f"&cols={cols}" - f"&rows={rows}" - f"&sessionId={session_id}" - f"&workingDir={cwd}" + # Rewrite only the scheme. `replace` would also rewrite an occurrence inside the path, + # such as a proxied URL. + if base.startswith("https://"): + ws_base = f"wss://{base.removeprefix('https://')}" + elif base.startswith("http://"): + ws_base = f"ws://{base.removeprefix('http://')}" + else: + ws_base = base + # Percent-encode the values. The workspace path and session id are caller-controlled and + # may contain characters that are structural in a query string, so interpolating them + # raw lets a path such as `/w/a&rows=1` add or override parameters, and lets a `#` + # silently truncate the rest into a fragment. A `+` in a token would also decode back as + # a space. + # `/` stays literal because it is legal in a query value and keeps paths readable. + query = urlencode( + { + "token": token, + "cols": cols, + "rows": rows, + "sessionId": session_id, + "workingDir": cwd, + }, + quote_via=quote, + safe="/", ) + return f"{ws_base}/terminal/ws?{query}" __all__ = [ diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 1791da1c68..1533660a31 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -934,6 +934,64 @@ def test_build_ws_url(self) -> None: assert "sessionId=sess-1" in url assert "workingDir=/workspace" in url + @pytest.mark.parametrize( + "field, value", + [ + ("cwd", "/workspace/my project"), + ("cwd", "/workspace/a&rows=9999"), + ("cwd", "/workspace/a#b"), + ("cwd", "/workspace/café"), + ("token", "ab+cd/ef=="), + ("session_id", "a&b"), + ], + ids=["space", "ampersand", "hash", "non_ascii", "token_plus", "session_amp"], + ) + def test_build_ws_url_percent_encodes_query_values(self, field: str, value: str) -> None: + """Caller-controlled values must survive the round trip intact. + + The workspace path and session id can contain characters that are structural in a + query string. Interpolating them raw let a path such as `/w/a&rows=1` append or + override parameters, let a `#` truncate the rest into a fragment, and let a `+` in a + token decode back as a space. + """ + from urllib.parse import parse_qs, urlsplit + + from agents.extensions.sandbox.blaxel.sandbox import _build_ws_url + + kwargs: dict[str, Any] = { + "sandbox_url": "https://test.bl.run", + "token": "tok123", + "session_id": "sess-1", + "cwd": "/workspace", + } + kwargs[field] = value + + url = _build_ws_url(**kwargs) + parts = urlsplit(url) + query = parse_qs(parts.query, keep_blank_values=True) + + assert parts.fragment == "" + assert " " not in url + assert query["token"] == [kwargs["token"]] + assert query["sessionId"] == [kwargs["session_id"]] + assert query["workingDir"] == [kwargs["cwd"]] + # A structural character in a value must not add or override a parameter. + assert query["rows"] == ["24"] + assert query["cols"] == ["80"] + + def test_build_ws_url_rewrites_only_the_scheme(self) -> None: + """`replace` also rewrote an occurrence inside the path, such as a proxied URL.""" + from agents.extensions.sandbox.blaxel.sandbox import _build_ws_url + + url = _build_ws_url( + sandbox_url="https://test.bl.run/proxy/http://inner", + token="t", + session_id="s", + cwd="/workspace", + ) + + assert url.startswith("wss://test.bl.run/proxy/http://inner/terminal/ws?") + def test_extract_preview_url(self) -> None: from agents.extensions.sandbox.blaxel.sandbox import _extract_preview_url From dac872ebbc4a4b9b782b8176a4c1fd63dce0c554 Mon Sep 17 00:00:00 2001 From: dfedoryshchev <64079946+dfedoryshchev@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:52:46 +0100 Subject: [PATCH 173/473] docs: correct nonexistent guardrail type names (#4218) --- examples/agent_patterns/README.md | 2 +- src/agents/guardrail.py | 16 ++++++++-------- src/agents/result.py | 6 ++++-- src/agents/run.py | 12 ++++++------ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/examples/agent_patterns/README.md b/examples/agent_patterns/README.md index 3da5e5caf4..c046df649c 100644 --- a/examples/agent_patterns/README.md +++ b/examples/agent_patterns/README.md @@ -50,7 +50,7 @@ See the [`parallelization.py`](./parallelization.py) file for an example of this Related to parallelization, you often want to run input guardrails to make sure the inputs to your agents are valid. For example, if you have a customer support agent, you might want to make sure that the user isn't trying to ask for help with a math problem. -You can definitely do this without any special Agents SDK features by using parallelization, but we support a special guardrail primitive. Guardrails can have a "tripwire" - if the tripwire is triggered, the agent execution will immediately stop and a `GuardrailTripwireTriggered` exception will be raised. +You can definitely do this without any special Agents SDK features by using parallelization, but we support a special guardrail primitive. Guardrails can have a "tripwire" - if the tripwire is triggered, the agent execution will immediately stop and an `InputGuardrailTripwireTriggered` or `OutputGuardrailTripwireTriggered` exception will be raised. This is really useful for latency: for example, you might have a very fast model that runs the guardrail and a slow model that runs the actual agent. You wouldn't want to wait for the slow model to finish, so guardrails let you quickly reject invalid inputs. diff --git a/src/agents/guardrail.py b/src/agents/guardrail.py index 7f5061c8c1..07475c8183 100644 --- a/src/agents/guardrail.py +++ b/src/agents/guardrail.py @@ -78,8 +78,8 @@ class InputGuardrail(Generic[TContext]): You can use the `@input_guardrail()` decorator to turn a function into an `InputGuardrail`, or create an `InputGuardrail` manually. - Guardrails return a `GuardrailResult`. If `result.tripwire_triggered` is `True`, - the agent's execution will immediately stop, and + Guardrail functions return a `GuardrailFunctionOutput`. If its `tripwire_triggered` field is + `True`, the agent's execution will immediately stop, and an `InputGuardrailTripwireTriggered` exception will be raised """ @@ -88,8 +88,8 @@ class InputGuardrail(Generic[TContext]): MaybeAwaitable[GuardrailFunctionOutput], ] """A function that receives the agent input and the context, and returns a - `GuardrailResult`. The result marks whether the tripwire was triggered, and can optionally - include information about the guardrail's output. + `GuardrailFunctionOutput`. The output marks whether the tripwire was triggered, and can + optionally include information about the guardrail's output. """ name: str | None = None @@ -138,8 +138,8 @@ class OutputGuardrail(Generic[TContext]): You can use the `@output_guardrail()` decorator to turn a function into an `OutputGuardrail`, or create an `OutputGuardrail` manually. - Guardrails return a `GuardrailResult`. If `result.tripwire_triggered` is `True`, an - `OutputGuardrailTripwireTriggered` exception will be raised. + Guardrail functions return a `GuardrailFunctionOutput`. If its `tripwire_triggered` field is + `True`, an `OutputGuardrailTripwireTriggered` exception will be raised. """ guardrail_function: Callable[ @@ -147,8 +147,8 @@ class OutputGuardrail(Generic[TContext]): MaybeAwaitable[GuardrailFunctionOutput], ] """A function that receives the final agent, its output, and the context, and returns a - `GuardrailResult`. The result marks whether the tripwire was triggered, and can optionally - include information about the guardrail's output. + `GuardrailFunctionOutput`. The output marks whether the tripwire was triggered, and can + optionally include information about the guardrail's output. """ name: str | None = None diff --git a/src/agents/result.py b/src/agents/result.py index 6482cd2813..9cc3a7feb9 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -528,7 +528,8 @@ class RunResultStreaming(RunResultBase): The streaming method will raise: - A MaxTurnsExceeded exception if the agent exceeds the max_turns limit. - - A GuardrailTripwireTriggered exception if a guardrail is tripped. + - A tripwire exception if a guardrail is tripped, e.g. InputGuardrailTripwireTriggered + or OutputGuardrailTripwireTriggered. """ current_agent: Agent[Any] @@ -802,7 +803,8 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]: This will raise: - A MaxTurnsExceeded exception if the agent exceeds the max_turns limit. - - A GuardrailTripwireTriggered exception if a guardrail is tripped. + - A tripwire exception if a guardrail is tripped, e.g. InputGuardrailTripwireTriggered + or OutputGuardrailTripwireTriggered. """ consumer_registered = False registered_consumer_task: asyncio.Task[Any] | None = None diff --git a/src/agents/run.py b/src/agents/run.py index e95bb0d7f7..d1ac024ea6 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -236,8 +236,8 @@ async def run( In two cases, the agent may raise an exception: 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised unless handled. - 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered - exception is raised. + 2. If a guardrail tripwire is triggered, the matching tripwire exception is raised, + e.g. InputGuardrailTripwireTriggered or OutputGuardrailTripwireTriggered. Note: Only the first agent's input guardrails are run. @@ -325,8 +325,8 @@ def run_sync( In two cases, the agent may raise an exception: 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised unless handled. - 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered - exception is raised. + 2. If a guardrail tripwire is triggered, the matching tripwire exception is raised, + e.g. InputGuardrailTripwireTriggered or OutputGuardrailTripwireTriggered. Note: Only the first agent's input guardrails are run. @@ -405,8 +405,8 @@ def run_streamed( In two cases, the agent may raise an exception: 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised unless handled. - 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered - exception is raised. + 2. If a guardrail tripwire is triggered, the matching tripwire exception is raised, + e.g. InputGuardrailTripwireTriggered or OutputGuardrailTripwireTriggered. Note: Only the first agent's input guardrails are run. From 5c7fdd53a790c5887d67ed9417ed81860d8401cf Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:53:23 -0700 Subject: [PATCH 174/473] fix(extensions): resolve the any-llm default model at call time (#4219) --- src/agents/extensions/models/any_llm_provider.py | 3 ++- tests/models/test_any_llm_model.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/agents/extensions/models/any_llm_provider.py b/src/agents/extensions/models/any_llm_provider.py index f327869499..8e8bbd9327 100644 --- a/src/agents/extensions/models/any_llm_provider.py +++ b/src/agents/extensions/models/any_llm_provider.py @@ -4,6 +4,7 @@ from ...models.interface import Model, ModelProvider from .any_llm_model import AnyLLMModel +# This is kept for backward compatibility but using get_default_model() method is recommended. DEFAULT_MODEL: str = f"openai/{get_default_model()}" @@ -28,7 +29,7 @@ def __init__( def get_model(self, model_name: str | None) -> Model: return AnyLLMModel( - model=model_name or DEFAULT_MODEL, + model=model_name or f"openai/{get_default_model()}", api_key=self.api_key, base_url=self.base_url, api=self.api, diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index fd038d47cc..1371976d1d 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -1113,6 +1113,19 @@ def test_any_llm_provider_passes_api_override() -> None: assert model.api == "chat_completions" +def test_any_llm_provider_reads_default_model_at_call_time(monkeypatch: Any) -> None: + pytest.importorskip( + "any_llm", + reason="`any-llm-sdk` is only available when the optional dependency is installed.", + ) + from agents.extensions.models.any_llm_provider import AnyLLMProvider + + monkeypatch.setenv("OPENAI_DEFAULT_MODEL", "gpt-4.1") + provider = AnyLLMProvider() + + assert cast(Any, provider.get_model(None)).model == "openai/gpt-4.1" + + def test_any_llm_reasoning_objects_prefer_content_attributes_over_iterable_pairs() -> None: pytest.importorskip( "any_llm", From a8ac730e664ff5d2cd22e92b79f3ef7cbdb49603 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:53:43 -0700 Subject: [PATCH 175/473] fix(voice): forward TTSModelSettings.speed to the OpenAI speech API (#4220) --- src/agents/voice/models/openai_tts.py | 3 +- tests/voice/test_openai_tts.py | 48 +++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/agents/voice/models/openai_tts.py b/src/agents/voice/models/openai_tts.py index 3b7dcf150b..2f18791274 100644 --- a/src/agents/voice/models/openai_tts.py +++ b/src/agents/voice/models/openai_tts.py @@ -1,7 +1,7 @@ from collections.abc import AsyncIterator from typing import Literal -from openai import AsyncOpenAI +from openai import AsyncOpenAI, omit from ..model import TTSModel, TTSModelSettings @@ -44,6 +44,7 @@ async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[byte voice=settings.voice or DEFAULT_VOICE, input=text, response_format="pcm", + speed=settings.speed if settings.speed is not None else omit, extra_body={ "instructions": settings.instructions, }, diff --git a/tests/voice/test_openai_tts.py b/tests/voice/test_openai_tts.py index b18f9e8c09..5652e3a425 100644 --- a/tests/voice/test_openai_tts.py +++ b/tests/voice/test_openai_tts.py @@ -4,6 +4,7 @@ from typing import Any import pytest +from openai import omit try: from agents.voice import OpenAITTSModel, TTSModelSettings @@ -44,12 +45,19 @@ async def test_openai_tts_default_voice_and_instructions() -> None: captured: dict[str, object] = {} def fake_create( - *, model: str, voice: str, input: str, response_format: str, extra_body: dict[str, Any] + *, + model: str, + voice: str, + input: str, + response_format: str, + speed: Any, + extra_body: dict[str, Any], ) -> _FakeStreamResponse: captured["model"] = model captured["voice"] = voice captured["input"] = input captured["response_format"] = response_format + captured["speed"] = speed captured["extra_body"] = extra_body return _FakeStreamResponse(chunks) @@ -64,6 +72,7 @@ def fake_create( assert captured["voice"] == "ash" assert captured["input"] == "hello world" assert captured["response_format"] == "pcm" + assert captured["speed"] is omit assert captured["extra_body"] == {"instructions": settings.instructions} @@ -74,12 +83,19 @@ async def test_openai_tts_custom_voice_and_instructions() -> None: captured: dict[str, object] = {} def fake_create( - *, model: str, voice: str, input: str, response_format: str, extra_body: dict[str, Any] + *, + model: str, + voice: str, + input: str, + response_format: str, + speed: Any, + extra_body: dict[str, Any], ) -> _FakeStreamResponse: captured["model"] = model captured["voice"] = voice captured["input"] = input captured["response_format"] = response_format + captured["speed"] = speed captured["extra_body"] = extra_body return _FakeStreamResponse(chunks) @@ -92,3 +108,31 @@ def fake_create( assert out == chunks assert captured["voice"] == "fable" assert captured["extra_body"] == {"instructions": "Custom instructions"} + + +@pytest.mark.asyncio +async def test_openai_tts_forwards_speed() -> None: + """A configured speed is forwarded to the OpenAI speech API.""" + chunks = [b"y"] + captured: dict[str, object] = {} + + def fake_create( + *, + model: str, + voice: str, + input: str, + response_format: str, + speed: Any, + extra_body: dict[str, Any], + ) -> _FakeStreamResponse: + captured["speed"] = speed + return _FakeStreamResponse(chunks) + + client = _make_fake_openai_client(fake_create) + tts_model = OpenAITTSModel(model="my-model", openai_client=client) # type: ignore[arg-type] + settings = TTSModelSettings(speed=1.5) + out: list[bytes] = [] + async for b in tts_model.run("hi", settings): + out.append(b) + assert out == chunks + assert captured["speed"] == 1.5 From 55b9ea3785359d57e6f17ffd957f0e0161e5ccb4 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:01:07 -0700 Subject: [PATCH 176/473] fix(run): publish completed streamed guardrail results on failure paths (#4223) --- src/agents/run_internal/guardrails.py | 16 ++++------ src/agents/run_internal/run_loop.py | 14 +++++---- tests/test_guardrails.py | 45 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/agents/run_internal/guardrails.py b/src/agents/run_internal/guardrails.py index 289d1a5ba8..dc09f0cd7a 100644 --- a/src/agents/run_internal/guardrails.py +++ b/src/agents/run_internal/guardrails.py @@ -67,16 +67,16 @@ async def run_input_guardrails_with_queue( asyncio.create_task(run_single_input_guardrail(agent, guardrail, input, context)) for guardrail in guardrails ] - guardrail_results = [] try: for done in asyncio.as_completed(guardrail_tasks): result = await done - guardrail_results.append(result) + # Publish into the runner-owned accumulator as each guardrail completes, so no exit + # path can omit results that already finished. This mirrors how the non-streamed + # `run_input_guardrails` records into its caller-owned sink. + streamed_result.input_guardrail_results = streamed_result.input_guardrail_results + [ + result + ] if result.output.tripwire_triggered: - streamed_result.input_guardrail_results = ( - streamed_result.input_guardrail_results + guardrail_results - ) - guardrail_results = [] streamed_result._triggered_input_guardrail_result = result queue.put_nowait(result) for t in guardrail_tasks: @@ -111,10 +111,6 @@ async def run_input_guardrails_with_queue( streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) raise - streamed_result.input_guardrail_results = ( - streamed_result.input_guardrail_results + guardrail_results - ) - async def run_input_guardrails( agent: Agent[Any], diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 66924bc5fb..8c0f753981 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -431,15 +431,17 @@ async def _run_output_guardrails_for_stream( try: return cast(list[Any], await streamed_result._output_guardrails_task) - except OutputGuardrailTripwireTriggered: - streamed_result.output_guardrail_results = ( - streamed_result.output_guardrail_results + completed_results - ) - raise except asyncio.CancelledError: raise except Exception as exc: - log_model_action_error(logger, "Unexpected error in output guardrails", exc) + # Publish at a single boundary so no failure path can omit results that already + # finished. A guardrail raising a non-tripwire error reports the same completed + # results a tripwire does. + streamed_result.output_guardrail_results = ( + streamed_result.output_guardrail_results + completed_results + ) + if not isinstance(exc, OutputGuardrailTripwireTriggered): + log_model_action_error(logger, "Unexpected error in output guardrails", exc) raise diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index bfb092ef3c..9bd343dead 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -2128,6 +2128,32 @@ async def test_input_guardrail_exception_reports_completed_results(): assert _result_names(collected) == ["passes"] +@pytest.mark.asyncio +@pytest.mark.parametrize("run_in_parallel", [False, True]) +async def test_input_guardrail_exception_reports_completed_results_streamed( + run_in_parallel: bool, +): + """A streamed guardrail raising a non-tripwire error still reports earlier results.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + agent = Agent( + name="guardrail_results_agent", + model=model, + input_guardrails=_ordered_input_guardrails( + second_triggers=False, + second_raises=True, + run_in_parallel=run_in_parallel, + ), + ) + + result = Runner.run_streamed(agent, "test input") + with pytest.raises(RuntimeError, match="guardrail exploded"): + async for _ in result.stream_events(): + pass + + assert _result_names(result.input_guardrail_results) == ["passes"] + + def _ordered_output_guardrails( *, second_triggers: bool, second_raises: bool = False ) -> list[OutputGuardrail[Any]]: @@ -2239,3 +2265,22 @@ async def test_output_guardrail_exception_reports_completed_results(): ) assert _result_names(collected) == ["passes"] + + +@pytest.mark.asyncio +async def test_output_guardrail_exception_reports_completed_results_streamed(): + """A streamed output guardrail raising a non-tripwire error still reports earlier results.""" + model = FakeModel() + model.set_next_output([get_text_message("hello")]) + agent = Agent( + name="output_guardrail_results_agent", + model=model, + output_guardrails=_ordered_output_guardrails(second_triggers=False, second_raises=True), + ) + + result = Runner.run_streamed(agent, "test input") + with pytest.raises(RuntimeError, match="guardrail exploded"): + async for _ in result.stream_events(): + pass + + assert _result_names(result.output_guardrail_results) == ["passes"] From eca12234401edd00704b298098537e130d28aa3c Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:06:50 -0700 Subject: [PATCH 177/473] fix(models): keep url citations when converting chat completions output (#4222) --- src/agents/models/chatcmpl_converter.py | 30 +++++++++++++++- .../test_openai_chatcompletions_converter.py | 35 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 483cb9b736..e38f5f4075 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -42,6 +42,10 @@ ResponseReasoningItemParam, ) from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message +from openai.types.responses.response_output_text import ( + Annotation as ResponseOutputTextAnnotation, + AnnotationURLCitation, +) from openai.types.responses.response_reasoning_item import Content, Summary from ..agent_output import AgentOutputSchemaBase @@ -199,7 +203,10 @@ def message_to_output_items( if message.content: message_item.content.append( ResponseOutputText( - text=message.content, type="output_text", annotations=[], logprobs=[] + text=message.content, + type="output_text", + annotations=cls._convert_annotations(message), + logprobs=[], ) ) if message.refusal: @@ -252,6 +259,27 @@ def message_to_output_items( return items + @classmethod + def _convert_annotations( + cls, message: ChatCompletionMessage + ) -> list[ResponseOutputTextAnnotation]: + """Convert Chat Completions url citations into output text annotations.""" + annotations: list[ResponseOutputTextAnnotation] = [] + for annotation in message.annotations or []: + url_citation = getattr(annotation, "url_citation", None) + if getattr(annotation, "type", None) != "url_citation" or url_citation is None: + continue + annotations.append( + AnnotationURLCitation( + type="url_citation", + start_index=url_citation.start_index, + end_index=url_citation.end_index, + url=url_citation.url, + title=url_citation.title, + ) + ) + return annotations + @classmethod def maybe_easy_input_message(cls, item: Any) -> EasyInputMessageParam | None: if not isinstance(item, dict): diff --git a/tests/models/test_openai_chatcompletions_converter.py b/tests/models/test_openai_chatcompletions_converter.py index 3297016c4a..e75f3298cf 100644 --- a/tests/models/test_openai_chatcompletions_converter.py +++ b/tests/models/test_openai_chatcompletions_converter.py @@ -29,6 +29,7 @@ import pytest from openai import omit from openai.types.chat import ChatCompletionMessage, ChatCompletionMessageFunctionToolCall +from openai.types.chat.chat_completion_message import Annotation, AnnotationURLCitation from openai.types.chat.chat_completion_message_custom_tool_call import ( ChatCompletionMessageCustomToolCall, Custom, @@ -73,6 +74,40 @@ def test_message_to_output_items_with_text_only(): assert text_part.text == "Hello" +def test_message_to_output_items_keeps_url_citation_annotations(): + """ + URL citations reported on the Chat Completions message should survive as + output text annotations, the same way the Responses API reports them. + """ + msg = ChatCompletionMessage( + role="assistant", + content="It will rain tomorrow.", + annotations=[ + Annotation( + type="url_citation", + url_citation=AnnotationURLCitation( + start_index=0, + end_index=22, + url="https://example.com/weather", + title="Weather", + ), + ) + ], + ) + items = Converter.message_to_output_items(msg) + message_item = cast(ResponseOutputMessage, items[0]) + text_part = cast(ResponseOutputText, message_item.content[0]) + assert [annotation.model_dump() for annotation in text_part.annotations] == [ + { + "end_index": 22, + "start_index": 0, + "title": "Weather", + "type": "url_citation", + "url": "https://example.com/weather", + } + ] + + def test_message_to_output_items_with_refusal(): """ Make sure a message with a refusal string produces a ResponseOutputMessage From 005a752dfc372733d28da997cb0d6195ee0229eb Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Thu, 6 Aug 2026 04:39:40 +0530 Subject: [PATCH 178/473] fix(voice): keep the word separator when flushing streamed sentences (#4227) --- src/agents/voice/utils.py | 7 +++- tests/voice/test_utils.py | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/voice/test_utils.py diff --git a/src/agents/voice/utils.py b/src/agents/voice/utils.py index 29d6ad7285..6b39600f12 100644 --- a/src/agents/voice/utils.py +++ b/src/agents/voice/utils.py @@ -30,7 +30,12 @@ def sentence_based_text_splitter(text_buffer: str) -> tuple[str, str]: if len(sentences) >= 1: combined_sentences = " ".join(sentences[:-1]) if len(combined_sentences) >= min_sentence_length: - remaining_text_buffer = sentences[-1] + # Carry any trailing whitespace over to the remainder. It separates the text + # held back from the next streamed delta, and stripping it concatenates the + # next word onto the last one, so "He " followed by "arrived" is spoken as + # "Hearrived". + trailing_whitespace = text_buffer[len(text_buffer.rstrip()) :] + remaining_text_buffer = sentences[-1] + trailing_whitespace return combined_sentences, remaining_text_buffer return "", text_buffer diff --git a/tests/voice/test_utils.py b/tests/voice/test_utils.py new file mode 100644 index 0000000000..8cb078db14 --- /dev/null +++ b/tests/voice/test_utils.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest + +from agents.voice import get_sentence_based_splitter + + +def _stream(text: str, chunk_size: int, min_sentence_length: int = 20) -> list[str]: + """Feed text through the splitter the way VoiceStreamedResult._add_text does.""" + split = get_sentence_based_splitter(min_sentence_length) + buffer = "" + spoken: list[str] = [] + for index in range(0, len(text), chunk_size): + buffer += text[index : index + chunk_size] + chunk, buffer = split(buffer) + if chunk: + spoken.append(chunk) + if buffer.strip(): + spoken.append(buffer.strip()) + return spoken + + +@pytest.mark.parametrize("chunk_size", [1, 2, 3, 5, 7, 11, 15]) +@pytest.mark.parametrize( + "text", + [ + "Dr. Smith went to Washington. He arrived at 3 p.m. sharp.", + "Hello there friend. How are you doing today? I am fine. Goodbye now.", + "One. Two. Three. Four. Five. Six. Seven. Eight. Nine. Ten.", + "A short one. Then a much longer sentence that exceeds the minimum easily.", + ], + ids=["abbreviations", "questions", "many_short", "mixed_lengths"], +) +def test_streamed_text_is_spoken_without_losing_or_gluing_words(text: str, chunk_size: int) -> None: + """Splitting must not depend on where the model's deltas happen to break. + + The buffer was stripped before splitting, which also removed the trailing space that + separates the held-back text from the next delta. When a delta boundary landed just + after a space, the following word was concatenated onto the previous one, so "He " + plus "arrived" was spoken as "Hearrived". + """ + assert " ".join(_stream(text, chunk_size)).split() == text.split() + + +def test_split_preserves_the_separator_before_the_next_delta() -> None: + """The remainder must still end with the whitespace it was given.""" + split = get_sentence_based_splitter(20) + + spoken, remaining = split("This sentence is long enough to flush. He ") + + assert spoken == "This sentence is long enough to flush." + # Without the trailing space, appending the next delta glues the words together. + assert remaining == "He " + assert (remaining + "arrived").split() == ["He", "arrived"] + + +def test_split_leaves_the_buffer_untouched_when_nothing_is_flushed() -> None: + split = get_sentence_based_splitter(20) + + assert split("Too short. ") == ("", "Too short. ") + assert split(" ") == ("", " ") + assert split("") == ("", "") + + +def test_split_without_trailing_whitespace_is_unchanged() -> None: + split = get_sentence_based_splitter(20) + + assert split("This sentence is long enough to flush. He") == ( + "This sentence is long enough to flush.", + "He", + ) From b6787a3f0f02b84945521c3a85132e5050afca7a Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:21:09 -0700 Subject: [PATCH 179/473] fix(mcp): keep MCP error content when structured output is enabled (#4224) --- src/agents/mcp/util.py | 8 ++++-- tests/mcp/test_mcp_util.py | 56 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 3a29bec5ba..8c675b6c2f 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -762,10 +762,14 @@ async def invoke_mcp_tool( else: logger.debug("MCP tool %s returned %s", tool_name_for_display, result) - # If structured content is requested and available, use it exclusively + # If structured content is requested and available, use it exclusively. Results the + # server flagged as errors keep their content instead, because that is where the + # actionable failure text lives. MCP permits `structuredContent` alongside + # `isError`, so this is an error-content precedence policy in this SDK rather than + # the structured payload being invalid for a failed call. tool_output: ToolOutput structured_content = result_structured_content(result) - if server.use_structured_content and structured_content: + if server.use_structured_content and structured_content and not result_is_error(result): tool_output = json.dumps(structured_content) else: tool_output_list: list[ToolOutputItem] = [] diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index e86bc63d99..0400490581 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -1890,11 +1890,18 @@ def __init__(self, use_structured_content: bool = False, **kwargs): self.use_structured_content = use_structured_content self._test_content: list[Any] = [] self._test_structured_content: dict[str, Any] | None = None + self._test_is_error: bool | None = None - def set_test_result(self, content: list[Any], structured_content: dict[str, Any] | None = None): + def set_test_result( + self, + content: list[Any], + structured_content: dict[str, Any] | None = None, + is_error: bool | None = None, + ): """Set the content and structured content that will be returned by call_tool.""" self._test_content = content self._test_structured_content = structured_content + self._test_is_error = is_error async def call_tool( self, @@ -1905,8 +1912,13 @@ async def call_tool( """Return test result with specified content and structured content.""" self.tool_calls.append(tool_name) + extra: dict[str, Any] = {} + if self._test_is_error is not None: + extra["isError"] = self._test_is_error return CallToolResult( - content=self._test_content, structuredContent=self._test_structured_content + content=self._test_content, + structuredContent=self._test_structured_content, + **extra, ) @@ -2001,6 +2013,46 @@ async def test_structured_content_handling( assert result == expected_output +@pytest.mark.asyncio +async def test_structured_content_skipped_for_error_results(): + """A result flagged as an error keeps the content that carries the error text.""" + + server = StructuredContentTestServer(use_structured_content=True) + server.add_tool("failing_tool", {}) + server.set_test_result( + [TextContent(text="database connection refused", type="text")], + {"answer": 42}, + is_error=True, + ) + + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="failing_tool", inputSchema={}) + + result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}") + + assert result == {"type": "text", "text": "database connection refused"} + + +@pytest.mark.asyncio +async def test_structured_content_used_for_non_error_results(): + """An explicit isError=False result still prefers structured content.""" + + server = StructuredContentTestServer(use_structured_content=True) + server.add_tool("ok_tool", {}) + server.set_test_result( + [TextContent(text="ignored", type="text")], + {"answer": 42}, + is_error=False, + ) + + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="ok_tool", inputSchema={}) + + result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}") + + assert result == '{"answer": 42}' + + @pytest.mark.asyncio async def test_structured_content_priority_over_text(): """Test that when use_structured_content=True, structured content takes priority. From 5d6885e0cb6fa9f49c23f06647c6a3f58e39c0d4 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:24:22 -0700 Subject: [PATCH 180/473] fix(tracing): release the trace scope when a generator is closed (#4221) --- src/agents/tracing/traces.py | 37 +++++++++- tests/tracing/test_traces_impl.py | 114 +++++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/src/agents/tracing/traces.py b/src/agents/tracing/traces.py index 4f91ca709c..591d1f3980 100644 --- a/src/agents/tracing/traces.py +++ b/src/agents/tracing/traces.py @@ -15,6 +15,28 @@ from .scope import Scope +def _finish_on_generator_exit(trace: Trace) -> None: + """Finish a trace whose ``with`` block is unwinding because of ``GeneratorExit``. + + A generator closed from the task that advanced it resets normally, which is the common + case. An abandoned async generator is instead finalized from whichever task happens to + run its ``aclose``, so the body resumes in a context that never set the token and + ``ContextVar.reset`` raises ``ValueError``. Nothing can be done about that from here: + a ``Token`` is only valid in the ``Context`` that created it, so the task doing the + finalizing cannot rewrite the caller's context, and the caller keeps seeing this trace + as current until its own scope ends. Raising would only add a crash on top of that. + + The tolerance is deliberately limited to this path. An explicit ``finish`` from the + wrong context is a context-ownership violation rather than an unavoidable one, so it + still raises. + """ + try: + trace.finish(reset_current=True) + except ValueError: + logger.debug("Skipping trace context reset, token belongs to another context") + trace._prev_context_token = None # type: ignore[attr-defined] + + class Trace(abc.ABC): """A complete end-to-end workflow containing related spans and metadata. @@ -339,7 +361,10 @@ def __enter__(self) -> Trace: return self def __exit__(self, exc_type, exc_val, exc_tb): - self.finish(reset_current=exc_type is not GeneratorExit) + if exc_type is GeneratorExit: + _finish_on_generator_exit(self) + else: + self.finish(reset_current=True) def export(self) -> dict[str, Any] | None: return { @@ -399,7 +424,10 @@ def __enter__(self) -> Trace: return self def __exit__(self, exc_type, exc_val, exc_tb): - self.finish(reset_current=True) + if exc_type is GeneratorExit: + _finish_on_generator_exit(self) + else: + self.finish(reset_current=True) def start(self, mark_as_current: bool = False): if mark_as_current: @@ -521,7 +549,10 @@ def __enter__(self) -> Trace: return self def __exit__(self, exc_type, exc_val, exc_tb): - self.finish(reset_current=exc_type is not GeneratorExit) + if exc_type is GeneratorExit: + _finish_on_generator_exit(self) + else: + self.finish(reset_current=True) def export(self) -> dict[str, Any] | None: return { diff --git a/tests/tracing/test_traces_impl.py b/tests/tracing/test_traces_impl.py index 866b23b3d8..fc24580dea 100644 --- a/tests/tracing/test_traces_impl.py +++ b/tests/tracing/test_traces_impl.py @@ -1,10 +1,21 @@ +import asyncio import logging +from collections.abc import AsyncGenerator, Callable from typing import Any, cast +import pytest + from agents.tracing.processor_interface import TracingProcessor from agents.tracing.scope import Scope from agents.tracing.spans import Span -from agents.tracing.traces import NoOpTrace, Trace, TraceImpl, TraceState, reattach_trace +from agents.tracing.traces import ( + NoOpTrace, + ReattachedTrace, + Trace, + TraceImpl, + TraceState, + reattach_trace, +) class DummyProcessor(TracingProcessor): @@ -31,6 +42,107 @@ def force_flush(self) -> None: return None +def _new_no_op_trace() -> Trace: + return NoOpTrace() + + +def _new_trace_impl() -> Trace: + return TraceImpl( + name="generator-exit", + trace_id="trace-generator-exit", + group_id=None, + metadata=None, + processor=DummyProcessor(), + ) + + +def _new_reattached_trace() -> Trace: + return ReattachedTrace( + name="generator-exit", + trace_id="trace-generator-exit", + group_id=None, + metadata=None, + tracing_api_key=None, + ) + + +_TRACE_FACTORIES = [_new_no_op_trace, _new_trace_impl, _new_reattached_trace] + + +def _traced_stream(new_trace: Callable[[], Trace]) -> AsyncGenerator[int, None]: + async def stream() -> AsyncGenerator[int, None]: + with new_trace(): + yield 1 + yield 2 + + return stream() + + +@pytest.mark.parametrize("new_trace", _TRACE_FACTORIES) +async def test_generator_close_in_the_same_task_releases_the_trace_scope( + new_trace: Callable[[], Trace], +) -> None: + """Closing a generator from the task that advanced it must restore the caller's trace. + + ``GeneratorExit`` unwinds the ``with`` block, but the token saved by ``start`` is still + valid here because the body resumes in the caller's own context. Skipping the reset + would leave the closed trace current and nest every later trace under it. + """ + Scope.set_current_trace(None) + + generator = _traced_stream(new_trace) + assert await generator.asend(None) == 1 + await generator.aclose() + + assert Scope.get_current_trace() is None + + +@pytest.mark.parametrize("new_trace", _TRACE_FACTORIES) +async def test_generator_close_from_another_task_does_not_raise( + new_trace: Callable[[], Trace], +) -> None: + """Abandoned async generators are finalized from whichever task runs ``aclose``. + + The body then resumes in a context that never set the token, so ``ContextVar.reset`` + raises ``ValueError``. That reset cannot succeed from there, and the caller keeps + seeing the trace as current, so closing must at least not raise on top of it. + Disabled tracing must behave the same as enabled tracing here. + """ + Scope.set_current_trace(None) + + generator = _traced_stream(new_trace) + assert await generator.asend(None) == 1 + await asyncio.create_task(generator.aclose()) + + # The caller's own context still holds the trace, which is the documented residue of + # finalizing from another task. Clear it so later tests do not inherit it. + Scope.set_current_trace(None) + + +@pytest.mark.parametrize("new_trace", _TRACE_FACTORIES) +async def test_explicit_finish_from_another_context_still_raises( + new_trace: Callable[[], Trace], +) -> None: + """Only ``GeneratorExit`` cleanup tolerates a foreign token. + + An explicit ``finish`` from a context that never set the token is a context-ownership + violation rather than an unavoidable one, so it must surface instead of silently + discarding the saved token. + """ + Scope.set_current_trace(None) + + trace = new_trace() + trace.start(mark_as_current=True) + + async def finish_elsewhere() -> None: + with pytest.raises(ValueError): + trace.finish(reset_current=True) + + await asyncio.create_task(finish_elsewhere()) + + Scope.set_current_trace(None) + + def test_no_op_trace_double_enter_logs_error(caplog) -> None: Scope.set_current_trace(None) trace = NoOpTrace() From f65a89b01575a43729635fd9a18c686f4731faae Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 Aug 2026 08:24:44 +0900 Subject: [PATCH 181/473] test: stabilize Blaxel prune patching on Python 3.10 --- tests/extensions/sandbox/test_blaxel.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 1533660a31..99efdeb779 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -2747,6 +2747,7 @@ def cancel(self) -> None: @pytest.mark.asyncio async def test_prune_returns_none_when_no_pid(self, fake_sandbox: _FakeSandboxInstance) -> None: """Cover line 819: prune returns None when process_id_to_prune_from_meta returns None.""" + from agents.extensions.sandbox.blaxel import sandbox as blaxel_sandbox from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry from agents.sandbox.session.pty_types import PTY_PROCESSES_MAX @@ -2762,10 +2763,7 @@ async def test_prune_returns_none_when_no_pid(self, fake_sandbox: _FakeSandboxIn session._pty_sessions[i + 300] = entry session._reserved_pty_process_ids.add(i + 300) - with patch( - "agents.extensions.sandbox.blaxel.sandbox.process_id_to_prune_from_meta", - return_value=None, - ): + with patch.object(blaxel_sandbox, "process_id_to_prune_from_meta", return_value=None): result = session._prune_pty_sessions_if_needed() assert result is None From 0068ce4329d0af7dd5398c3f300ab178c986495e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 Aug 2026 08:26:45 +0900 Subject: [PATCH 182/473] fix: redact JSON validation errors (#4211) --- src/agents/agent_output.py | 34 +- src/agents/exceptions.py | 28 +- src/agents/handoffs/__init__.py | 35 +- src/agents/realtime/handoffs.py | 13 +- src/agents/realtime/session.py | 48 +- src/agents/result.py | 32 +- src/agents/run.py | 128 ++- src/agents/run_internal/error_handlers.py | 24 +- src/agents/run_internal/run_loop.py | 29 +- src/agents/run_internal/turn_resolution.py | 99 ++- src/agents/util/_json.py | 35 +- tests/realtime/test_session.py | 229 ++++- tests/test_error_logging_redaction.py | 946 ++++++++++++++++++++- tests/test_invalid_final_output_handler.py | 6 +- 14 files changed, 1575 insertions(+), 111 deletions(-) diff --git a/src/agents/agent_output.py b/src/agents/agent_output.py index f2274280b0..32df9cb712 100644 --- a/src/agents/agent_output.py +++ b/src/agents/agent_output.py @@ -1,11 +1,17 @@ import abc from dataclasses import dataclass -from typing import Any, get_args, get_origin +from typing import Any, cast, get_args, get_origin from pydantic import BaseModel, TypeAdapter from typing_extensions import TypedDict -from .exceptions import ModelBehaviorError, UserError +from .exceptions import ( + ModelBehaviorError, + UserError, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, + _raise_data_redacted_error, +) from .strict_schema import ensure_strict_json_schema from .tracing import SpanError from .util import _error_tracing, _json @@ -137,12 +143,24 @@ def validate_json(self, json_str: str) -> Any: """Validate a JSON string against the output type. Returns the validated object, or raises a `ModelBehaviorError` if the JSON is invalid. """ - validated = _json.validate_json( - json_str, - self._type_adapter, - partial=False, - strict=True if self._strict_json_schema else None, - ) + redacted_error: ModelBehaviorError | None = None + try: + validated = _json.validate_json( + json_str, + self._type_adapter, + partial=False, + strict=True if self._strict_json_schema else None, + ) + except ModelBehaviorError as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + + if redacted_error is not None: + self = cast(Any, None) + json_str = "" + _raise_data_redacted_error(redacted_error) if self._is_wrapped: if not isinstance(validated, dict): _error_tracing.attach_error_to_current_span( diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index 349004c97d..887ea910ba 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -1,7 +1,8 @@ from __future__ import annotations +import traceback from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NoReturn if TYPE_CHECKING: from .agent import Agent @@ -19,6 +20,8 @@ from .util._pretty_print import pretty_print_run_error_details _DRAIN_STREAM_EVENTS_ATTR = "_agents_drain_queued_stream_events" +_DATA_REDACTED_ATTR = "_agents_data_redacted" +_DATA_REDACTED_ERROR_MESSAGE = "Error details are redacted." def _mark_error_to_drain_stream_events(error: Exception) -> None: @@ -29,6 +32,29 @@ def _should_drain_stream_events_before_raising(error: Exception) -> bool: return bool(getattr(error, _DRAIN_STREAM_EVENTS_ATTR, False)) +def _mark_error_data_redacted(error: Exception) -> None: + setattr(error, _DATA_REDACTED_ATTR, True) + + +def _is_error_data_redacted(error: Exception) -> bool: + return bool(getattr(error, _DATA_REDACTED_ATTR, False)) + + +def _clear_data_redacted_error_traceback(error: Exception) -> None: + if _is_error_data_redacted(error) and error.__traceback__ is not None: + traceback.clear_frames(error.__traceback__) + + +def _detach_data_redacted_error_traceback(error: Exception) -> None: + if _is_error_data_redacted(error): + error.__traceback__ = None + + +def _raise_data_redacted_error(error: Exception) -> NoReturn: + """Raise a detached redacted error from a frame that owns no payload data.""" + raise error from None + + @dataclass class RunErrorDetails: """Data collected from an agent run when an exception occurs.""" diff --git a/src/agents/handoffs/__init__.py b/src/agents/handoffs/__init__.py index 79d1841760..17eaed1de7 100644 --- a/src/agents/handoffs/__init__.py +++ b/src/agents/handoffs/__init__.py @@ -5,12 +5,19 @@ import weakref from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace as dataclasses_replace +from functools import partial from typing import TYPE_CHECKING, Any, Generic, TypeAlias, cast, overload from pydantic import TypeAdapter from typing_extensions import TypeVar -from ..exceptions import ModelBehaviorError, UserError +from ..exceptions import ( + ModelBehaviorError, + UserError, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, + _raise_data_redacted_error, +) from ..items import RunItem, TResponseInputItem from ..run_context import RunContextWrapper, TContext from ..strict_schema import ensure_strict_json_schema @@ -39,6 +46,27 @@ OnHandoffWithoutInput = Callable[[RunContextWrapper[Any]], Any] +async def _invoke_handoff_with_redaction( + invoke_handoff: Callable[[RunContextWrapper[Any], str | None], Awaitable[TAgent]], + ctx: RunContextWrapper[Any], + input_json: str | None = None, +) -> TAgent: + redacted_error: ModelBehaviorError | None = None + try: + return await invoke_handoff(ctx, input_json) + except ModelBehaviorError as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + + invoke_handoff = cast(Any, None) + ctx = cast(Any, None) + input_json = "" + assert redacted_error is not None + _raise_data_redacted_error(redacted_error) + + @dataclass(frozen=True) class HandoffInputData: input_history: str | tuple[TResponseInputItem, ...] @@ -282,7 +310,7 @@ def handoff( if len(sig.parameters) != 1: raise UserError("on_handoff must take one argument: context") - async def _invoke_handoff( + async def _invoke_handoff_impl( ctx: RunContextWrapper[Any], input_json: str | None = None ) -> Agent[TContext]: if input_type is not None and type_adapter is not None: @@ -300,6 +328,7 @@ async def _invoke_handoff( type_adapter=type_adapter, partial=False, strict=True, + contains_tool_data=True, ) input_func = cast(OnHandoffWithInput[THandoffInput], on_handoff) result = input_func(ctx, validated_input) @@ -334,7 +363,7 @@ async def _is_enabled(ctx: RunContextWrapper[Any], agent_base: AgentBase[Any]) - tool_name=tool_name, tool_description=tool_description, input_json_schema=input_json_schema, - on_invoke_handoff=_invoke_handoff, + on_invoke_handoff=partial(_invoke_handoff_with_redaction, _invoke_handoff_impl), input_filter=input_filter, nest_handoff_history=nest_handoff_history, agent_name=agent.name, diff --git a/src/agents/realtime/handoffs.py b/src/agents/realtime/handoffs.py index a2026772ee..3373e24cbb 100644 --- a/src/agents/realtime/handoffs.py +++ b/src/agents/realtime/handoffs.py @@ -2,13 +2,17 @@ import inspect from collections.abc import Callable, Iterable +from functools import partial from typing import TYPE_CHECKING, Any, cast, overload from pydantic import TypeAdapter from typing_extensions import TypeVar -from ..exceptions import ModelBehaviorError, UserError -from ..handoffs import Handoff +from ..exceptions import ( + ModelBehaviorError, + UserError, +) +from ..handoffs import Handoff, _invoke_handoff_with_redaction from ..run_context import RunContextWrapper, TContext from ..strict_schema import ensure_strict_json_schema from ..tracing.spans import SpanError @@ -146,7 +150,7 @@ def realtime_handoff( if len(sig.parameters) != 1: raise UserError("on_handoff must take one argument: context") - async def _invoke_handoff( + async def _invoke_handoff_impl( ctx: RunContextWrapper[Any], input_json: str | None = None ) -> RealtimeAgent[TContext]: if input_type is not None and type_adapter is not None: @@ -164,6 +168,7 @@ async def _invoke_handoff( type_adapter=type_adapter, partial=False, strict=True, + contains_tool_data=True, ) input_func = cast(OnHandoffWithInput[THandoffInput], on_handoff) result = input_func(ctx, validated_input) @@ -196,7 +201,7 @@ async def _is_enabled(ctx: RunContextWrapper[Any], agent_base: AgentBase[Any]) - tool_name=tool_name, tool_description=tool_description, input_json_schema=input_json_schema, - on_invoke_handoff=_invoke_handoff, + on_invoke_handoff=partial(_invoke_handoff_with_redaction, _invoke_handoff_impl), input_filter=None, # Not supported for RealtimeAgent handoffs agent_name=agent.name, is_enabled=_is_enabled if callable(is_enabled) else is_enabled, diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index f224bbf068..7d6265a96c 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -18,7 +18,15 @@ get_function_tool_namespace, ) from ..agent import Agent -from ..exceptions import ToolInputGuardrailTripwireTriggered, UserError +from ..exceptions import ( + ModelBehaviorError, + ToolInputGuardrailTripwireTriggered, + UserError, + _clear_data_redacted_error_traceback, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, + _raise_data_redacted_error, +) from ..handoffs import Handoff from ..items import ToolApprovalItem from ..logger import ( @@ -304,7 +312,16 @@ async def __aiter__(self) -> AsyncIterator[RealtimeSessionEvent]: if self._stored_exception is not None: # Clean up resources before raising await self.close() - raise self._stored_exception + stored_exception = self._stored_exception + if isinstance(stored_exception, Exception) and _is_error_data_redacted( + stored_exception + ): + _detach_data_redacted_error_traceback(stored_exception) + # Do not retain the session or the previously yielded raw event in the + # traceback frame that exposes a redacted error to the caller. + self = cast(Any, None) + event = cast(Any, None) + raise stored_exception self._event_iterator_waiters += 1 try: @@ -398,7 +415,22 @@ async def on_event(self, event: RealtimeModelEvent) -> None: handle_kwargs: dict[str, Any] = {"agent_snapshot": agent_snapshot} if dispatch_snapshot is not None: handle_kwargs["dispatch_snapshot"] = dispatch_snapshot - await self._handle_tool_call(event, **handle_kwargs) + redacted_error: ModelBehaviorError | None = None + try: + await self._handle_tool_call(event, **handle_kwargs) + except ModelBehaviorError as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + + if redacted_error is not None: + self = cast(Any, None) + event = cast(Any, None) + agent_snapshot = cast(Any, None) + dispatch_snapshot = cast(Any, None) + handle_kwargs = {} + _raise_data_redacted_error(redacted_error) elif event.type == "audio": if event.response_id not in self._interrupted_response_ids: await self._put_event( @@ -1625,14 +1657,16 @@ def _enqueue_tool_call_task( def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None: self._tool_call_tasks.discard(task) - if self._closing or self._closed: - self._consume_task_result(task) - return - if task.cancelled(): return exception = task.exception() + if isinstance(exception, ModelBehaviorError): + _clear_data_redacted_error_traceback(exception) + + if self._closing or self._closed: + return + if exception is None: return diff --git a/src/agents/result.py b/src/agents/result.py index 9cc3a7feb9..40f92b7a0a 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -18,6 +18,8 @@ InputGuardrailTripwireTriggered, MaxTurnsExceeded, RunErrorDetails, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, _should_drain_stream_events_before_raising, ) from .guardrail import InputGuardrailResult, OutputGuardrailResult @@ -730,7 +732,10 @@ def run_loop_exception(self) -> BaseException | None: task = self.run_loop_task if task is None or not task.done() or task.cancelled(): return None - return task.exception() + error = task.exception() + if isinstance(error, Exception) and _is_error_data_redacted(error): + _detach_data_redacted_error_traceback(error) + return error def cancel(self, mode: Literal["immediate", "after_turn"] = "immediate") -> None: """Cancel the streaming run. @@ -929,8 +934,16 @@ def register_current_consumer() -> None: self._drain_event_queue() self._drain_input_guardrail_queue() - if self._stored_exception: - raise self._stored_exception + stored_exception = self._stored_exception + if stored_exception: + if _is_error_data_redacted(stored_exception): + _detach_data_redacted_error_traceback(stored_exception) + # The streaming result retains caller-visible run data. Drop the local reference + # before raising so the redacted exception cannot retain it through this frame. + self = cast(Any, None) + registered_consumer_task = None + item = cast(Any, None) + raise stored_exception def _create_error_details(self) -> RunErrorDetails | None: """Return a `RunErrorDetails` object considering the current attributes of the class. @@ -976,7 +989,11 @@ def _check_errors(self): if not self.run_loop_task.cancelled(): run_impl_exc = self.run_loop_task.exception() if run_impl_exc and isinstance(run_impl_exc, Exception): - if isinstance(run_impl_exc, AgentsException) and run_impl_exc.run_data is None: + if ( + isinstance(run_impl_exc, AgentsException) + and run_impl_exc.run_data is None + and not _is_error_data_redacted(run_impl_exc) + ): run_impl_exc.run_data = self._create_error_details() self._stored_exception = run_impl_exc @@ -984,7 +1001,11 @@ def _check_errors(self): if not self._input_guardrails_task.cancelled(): in_guard_exc = self._input_guardrails_task.exception() if in_guard_exc and isinstance(in_guard_exc, Exception): - if isinstance(in_guard_exc, AgentsException) and in_guard_exc.run_data is None: + if ( + isinstance(in_guard_exc, AgentsException) + and in_guard_exc.run_data is None + and not _is_error_data_redacted(in_guard_exc) + ): in_guard_exc.run_data = self._create_error_details() self._stored_exception = in_guard_exc @@ -995,6 +1016,7 @@ def _check_errors(self): if ( isinstance(out_guard_exc, AgentsException) and out_guard_exc.run_data is None + and not _is_error_data_redacted(out_guard_exc) ): out_guard_exc.run_data = self._create_error_details() self._stored_exception = out_guard_exc diff --git a/src/agents/run.py b/src/agents/run.py index d1ac024ea6..e20c01df45 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -14,9 +14,13 @@ AgentsException, InputGuardrailTripwireTriggered, MaxTurnsExceeded, + ModelBehaviorError, OutputGuardrailTripwireTriggered, RunErrorDetails, UserError, + _clear_data_redacted_error_traceback, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, ) from .guardrail import ( InputGuardrailResult, @@ -276,19 +280,40 @@ async def run( """ runner = DEFAULT_AGENT_RUNNER - return await runner.run( - starting_agent, - input, - context=context, - max_turns=max_turns, - hooks=hooks, - run_config=run_config, - error_handlers=error_handlers, - previous_response_id=previous_response_id, - auto_previous_response_id=auto_previous_response_id, - conversation_id=conversation_id, - session=session, - ) + redacted_error: AgentsException | None = None + try: + return await runner.run( + starting_agent, + input, + context=context, + max_turns=max_turns, + hooks=hooks, + run_config=run_config, + error_handlers=error_handlers, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + conversation_id=conversation_id, + session=session, + ) + except AgentsException as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + + starting_agent = cast(Any, None) + input = cast(Any, None) + context = cast(Any, None) + hooks = cast(Any, None) + run_config = cast(Any, None) + error_handlers = cast(Any, None) + previous_response_id = None + auto_previous_response_id = cast(Any, None) + conversation_id = None + session = cast(Any, None) + runner = cast(Any, None) + assert redacted_error is not None + raise redacted_error from None @classmethod def run_sync( @@ -358,19 +383,40 @@ def run_sync( """ runner = DEFAULT_AGENT_RUNNER - return runner.run_sync( - starting_agent, - input, - context=context, - max_turns=max_turns, - hooks=hooks, - run_config=run_config, - error_handlers=error_handlers, - previous_response_id=previous_response_id, - conversation_id=conversation_id, - session=session, - auto_previous_response_id=auto_previous_response_id, - ) + redacted_error: AgentsException | None = None + try: + return runner.run_sync( + starting_agent, + input, + context=context, + max_turns=max_turns, + hooks=hooks, + run_config=run_config, + error_handlers=error_handlers, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + session=session, + auto_previous_response_id=auto_previous_response_id, + ) + except AgentsException as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + + starting_agent = cast(Any, None) + input = cast(Any, None) + context = cast(Any, None) + hooks = cast(Any, None) + run_config = cast(Any, None) + error_handlers = cast(Any, None) + previous_response_id = None + auto_previous_response_id = cast(Any, None) + conversation_id = None + session = cast(Any, None) + runner = cast(Any, None) + assert redacted_error is not None + raise redacted_error from None @classmethod def run_streamed( @@ -1643,17 +1689,21 @@ def _finalize_result(result: RunResult) -> RunResult: trace_include_sensitive_data=run_config.trace_include_sensitive_data, ) if isinstance(exc, AgentsException): - exc.run_data = RunErrorDetails( - input=original_input, - new_items=session_items, - raw_responses=model_responses, - last_agent=current_agent, - context_wrapper=context_wrapper, - input_guardrail_results=input_guardrail_results, - output_guardrail_results=output_guardrail_results, - tool_input_guardrail_results=tool_input_guardrail_results, - tool_output_guardrail_results=tool_output_guardrail_results, - ) + if _is_error_data_redacted(exc): + _detach_data_redacted_error_traceback(exc) + else: + _clear_data_redacted_error_traceback(exc) + exc.run_data = RunErrorDetails( + input=original_input, + new_items=session_items, + raw_responses=model_responses, + last_agent=current_agent, + context_wrapper=context_wrapper, + input_guardrail_results=input_guardrail_results, + output_guardrail_results=output_guardrail_results, + tool_input_guardrail_results=tool_input_guardrail_results, + tool_output_guardrail_results=tool_output_guardrail_results, + ) raise finally: await cleanup_models_after_run(tool_use_tracker) @@ -1778,13 +1828,15 @@ def run_sync( try: # Drive the coroutine to completion, harvesting the final RunResult. return default_loop.run_until_complete(task) - except BaseException: + except BaseException as error: # If the sync caller aborts (KeyboardInterrupt, etc.), make sure the scheduled task # does not linger on the shared loop by cancelling it and waiting for completion. if not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): default_loop.run_until_complete(task) + if isinstance(error, ModelBehaviorError): + _detach_data_redacted_error_traceback(error) raise finally: if not default_loop.is_closed(): diff --git a/src/agents/run_internal/error_handlers.py b/src/agents/run_internal/error_handlers.py index 8c30f54d95..f55e8b9929 100644 --- a/src/agents/run_internal/error_handlers.py +++ b/src/agents/run_internal/error_handlers.py @@ -136,7 +136,12 @@ def build_run_error_data( ) -def format_final_output_text(agent: Agent[Any], final_output: Any) -> str: +def format_final_output_text( + agent: Agent[Any], + final_output: Any, + *, + data_redacted: bool = False, +) -> str: output_schema = get_output_schema(agent) if output_schema is None or output_schema.is_plain_text(): return str(final_output) @@ -148,7 +153,10 @@ def format_final_output_text(agent: Agent[Any], final_output: Any) -> str: payload_value = {_WRAPPER_DICT_KEY: final_output} try: if isinstance(output_schema, AgentOutputSchema): - payload_bytes = output_schema._type_adapter.dump_json(payload_value) + payload_bytes = output_schema._type_adapter.dump_json( + payload_value, + warnings="none" if data_redacted else "warn", + ) return ( payload_bytes.decode() if isinstance(payload_bytes, bytes | bytearray) @@ -159,7 +167,12 @@ def format_final_output_text(agent: Agent[Any], final_output: Any) -> str: return str(final_output) -def validate_handler_final_output(agent: Agent[Any], final_output: Any) -> Any: +def validate_handler_final_output( + agent: Agent[Any], + final_output: Any, + *, + data_redacted: bool = False, +) -> Any: output_schema = get_output_schema(agent) if output_schema is None or output_schema.is_plain_text(): return final_output @@ -171,7 +184,10 @@ def validate_handler_final_output(agent: Agent[Any], final_output: Any) -> Any: payload_value = {_WRAPPER_DICT_KEY: final_output} try: if isinstance(output_schema, AgentOutputSchema): - payload_bytes = output_schema._type_adapter.dump_json(payload_value) + payload_bytes = output_schema._type_adapter.dump_json( + payload_value, + warnings="none" if data_redacted else "warn", + ) payload = ( payload_bytes.decode() if isinstance(payload_bytes, bytes | bytearray) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 8c0f753981..1429270c2e 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -40,6 +40,9 @@ OutputGuardrailTripwireTriggered, RunErrorDetails, UserError, + _clear_data_redacted_error_traceback, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, ) from ..handoffs import Handoff from ..items import ( @@ -1398,17 +1401,21 @@ async def _save_stream_items_without_count( except AgentsException as exc: streamed_result.is_complete = True streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) - exc.run_data = RunErrorDetails( - input=streamed_result.input, - new_items=streamed_result.new_items, - raw_responses=streamed_result.raw_responses, - last_agent=current_agent, - context_wrapper=context_wrapper, - input_guardrail_results=streamed_result.input_guardrail_results, - output_guardrail_results=streamed_result.output_guardrail_results, - tool_input_guardrail_results=streamed_result.tool_input_guardrail_results, - tool_output_guardrail_results=streamed_result.tool_output_guardrail_results, - ) + if _is_error_data_redacted(exc): + _detach_data_redacted_error_traceback(exc) + else: + _clear_data_redacted_error_traceback(exc) + exc.run_data = RunErrorDetails( + input=streamed_result.input, + new_items=streamed_result.new_items, + raw_responses=streamed_result.raw_responses, + last_agent=current_agent, + context_wrapper=context_wrapper, + input_guardrail_results=streamed_result.input_guardrail_results, + output_guardrail_results=streamed_result.output_guardrail_results, + tool_input_guardrail_results=streamed_result.tool_input_guardrail_results, + tool_output_guardrail_results=streamed_result.tool_output_guardrail_results, + ) raise except Exception as e: attach_generic_agent_error( diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 5bf9839533..2302a9d1a5 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -54,7 +54,15 @@ peek_agent_tool_run_result, record_agent_tool_run_result, ) -from ..exceptions import ModelBehaviorError, ModelRefusalError, UserError +from ..exceptions import ( + _DATA_REDACTED_ERROR_MESSAGE, + ModelBehaviorError, + ModelRefusalError, + UserError, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, + _mark_error_data_redacted, +) from ..handoffs import Handoff, HandoffInputData, HandoffInputFilter, nest_handoff_history from ..handoffs.history import ( _get_nested_history_owned_items, @@ -415,32 +423,63 @@ async def _resolve_invalid_final_output( new_items: list[RunItem], context_wrapper: RunContextWrapper[TContext], ) -> tuple[Any, MessageOutputItem | None] | None: + redacted = _is_error_data_redacted(error) or _debug.DONT_LOG_MODEL_DATA run_error_data = build_run_error_data( input=original_input, new_items=new_items, raw_responses=[new_response], last_agent=public_agent, ) - handler_result = await resolve_run_error_handler_result( - error_handlers=error_handlers, - error_kind="invalid_final_output", - error=error, - context_wrapper=context_wrapper, - run_data=run_error_data, - ) - if handler_result is None: - return None + _detach_data_redacted_error_traceback(error) + safe_error: UserError | None = None + try: + handler_result = await resolve_run_error_handler_result( + error_handlers=error_handlers, + error_kind="invalid_final_output", + error=error, + context_wrapper=context_wrapper, + run_data=run_error_data, + ) + if handler_result is None: + return None - final_output = validate_handler_final_output(public_agent, handler_result.final_output) - message_item = ( - create_message_output_item( + final_output = validate_handler_final_output( public_agent, - format_final_output_text(public_agent, final_output), + handler_result.final_output, + data_redacted=redacted, ) - if handler_result.include_in_history - else None - ) - return final_output, message_item + message_item = ( + create_message_output_item( + public_agent, + format_final_output_text( + public_agent, + final_output, + data_redacted=redacted, + ), + ) + if handler_result.include_in_history + else None + ) + return final_output, message_item + except Exception: + if not redacted: + raise + safe_error = UserError(_DATA_REDACTED_ERROR_MESSAGE) + _mark_error_data_redacted(safe_error) + + error = cast(Any, None) + error_handlers = cast(Any, None) + public_agent = cast(Any, None) + original_input = cast(Any, None) + new_response = cast(Any, None) + new_items = cast(Any, None) + context_wrapper = cast(Any, None) + run_error_data = cast(Any, None) + handler_result = cast(Any, None) + final_output = cast(Any, None) + message_item = cast(Any, None) + assert safe_error is not None + raise safe_error from None def _resolve_server_managed_handoff_behavior( @@ -902,12 +941,32 @@ async def execute_tools_and_side_effects( ) if output_schema and not output_schema.is_plain_text(): if potential_final_output_text: + validation_error: ModelBehaviorError | None = None try: final_output = output_schema.validate_json(potential_final_output_text) except ModelBehaviorError as error: + if _is_error_data_redacted(error): + validation_error = error + else: + resolved_handler_output = await _resolve_invalid_final_output( + error_handlers=error_handlers, + error=error, + public_agent=public_agent, + original_input=original_input, + new_response=new_response, + new_items=pre_step_items + new_step_items, + context_wrapper=context_wrapper, + ) + if resolved_handler_output is None: + raise + final_output, message_item = resolved_handler_output + if message_item is not None: + new_step_items.append(message_item) + + if validation_error is not None: resolved_handler_output = await _resolve_invalid_final_output( error_handlers=error_handlers, - error=error, + error=validation_error, public_agent=public_agent, original_input=original_input, new_response=new_response, @@ -915,7 +974,7 @@ async def execute_tools_and_side_effects( context_wrapper=context_wrapper, ) if resolved_handler_output is None: - raise + raise validation_error final_output, message_item = resolved_handler_output if message_item is not None: new_step_items.append(message_item) diff --git a/src/agents/util/_json.py b/src/agents/util/_json.py index 67186328cd..fd944b6129 100644 --- a/src/agents/util/_json.py +++ b/src/agents/util/_json.py @@ -6,7 +6,8 @@ from pydantic import TypeAdapter, ValidationError from typing_extensions import TypeVar -from ..exceptions import ModelBehaviorError +from .. import _debug +from ..exceptions import ModelBehaviorError, _mark_error_data_redacted from ..tracing import SpanError from ._error_tracing import attach_error_to_current_span @@ -14,8 +15,14 @@ def validate_json( - json_str: str, type_adapter: TypeAdapter[T], partial: bool, strict: bool | None = None + json_str: str, + type_adapter: TypeAdapter[T], + partial: bool, + strict: bool | None = None, + *, + contains_tool_data: bool = False, ) -> T: + should_redact = _debug.DONT_LOG_MODEL_DATA or (contains_tool_data and _debug.DONT_LOG_TOOL_DATA) partial_setting: bool | Literal["off", "on", "trailing-strings"] = ( "trailing-strings" if partial else False ) @@ -26,15 +33,33 @@ def validate_json( validated = type_adapter.validate_json(json_str, **kwargs) return validated except ValidationError as e: + if not should_redact: + attach_error_to_current_span( + SpanError( + message="Invalid JSON provided", + data={}, + ) + ) + raise ModelBehaviorError( + f"Invalid JSON when parsing {json_str} for {type_adapter}; {e}" + ) from e + + # Clear the payload before creating the redacted traceback frame. Raising outside the except + # block also prevents the payload-bearing ValidationError from becoming the cause or context. + json_str = "" + error = ModelBehaviorError("Invalid JSON when parsing model output") + _mark_error_data_redacted(error) + # Redacted error reporting is best-effort so tracing failures cannot replace the safe error. + try: attach_error_to_current_span( SpanError( message="Invalid JSON provided", data={}, ) ) - raise ModelBehaviorError( - f"Invalid JSON when parsing {json_str} for {type_adapter}; {e}" - ) from e + except Exception: + pass + raise error def _to_dump_compatible(obj: Any) -> Any: diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 9a6309fcc5..a52b89f241 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -3,7 +3,9 @@ import json import logging import threading -from typing import Any, cast +import traceback +from pathlib import Path +from typing import Any, Literal, cast from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest @@ -11,9 +13,10 @@ import agents._debug as _debug from agents.agent import AgentBase -from agents.exceptions import ToolTimeoutError, UserError +from agents.exceptions import ModelBehaviorError, ToolTimeoutError, UserError from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail from agents.handoffs import Handoff +from agents.realtime import realtime_handoff from agents.realtime.agent import RealtimeAgent from agents.realtime.config import RealtimeRunConfig, RealtimeSessionModelSettings from agents.realtime.events import ( @@ -780,6 +783,228 @@ async def failing_task() -> None: assert err.error["message"] == expected_message +@pytest.mark.asyncio +@pytest.mark.parametrize("state_name", [None, "_closing", "_closed"]) +async def test_on_tool_call_task_done_clears_redacted_error_traceback( + monkeypatch: pytest.MonkeyPatch, + state_name: str | None, +) -> None: + secret = "REALTIME_HANDOFF_TRACEBACK_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + session = RealtimeSession(_DummyModel(), RealtimeAgent(name="agent"), None) + target = RealtimeAgent(name="target") + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: int) -> None: + pass # pragma: no cover + + handoff_obj = realtime_handoff(target, on_handoff=on_handoff, input_type=int) + + async def failing_task() -> None: + payload = f'"{secret}"' + await handoff_obj.on_invoke_handoff(RunContextWrapper(None), payload) + + task = asyncio.create_task(failing_task()) + await asyncio.gather(task, return_exceptions=True) + error = task.exception() + assert isinstance(error, ModelBehaviorError) + + before = traceback.TracebackException.from_exception(error, capture_locals=True) + assert secret in "".join( + value for frame in before.stack for value in (frame.locals or {}).values() + ) + + if state_name is not None: + setattr(session, state_name, True) + session._on_tool_call_task_done(task) + + after = traceback.TracebackException.from_exception(error, capture_locals=True) + assert secret not in "".join( + value for frame in after.stack for value in (frame.locals or {}).values() + ) + if state_name is None: + assert session._stored_exception is error + event = session._event_queue.get_nowait() + assert isinstance(event, RealtimeError) + assert secret not in event.error["message"] + else: + assert session._stored_exception is None + assert session._event_queue.empty() + + +def _realtime_sdk_traceback_frame_locals(error: BaseException) -> list[dict[str, Any]]: + agents_source = (Path(__file__).parents[2] / "src" / "agents").resolve() + frame_locals: list[dict[str, Any]] = [] + traceback_object = error.__traceback__ + while traceback_object is not None: + if ( + Path(traceback_object.tb_frame.f_code.co_filename) + .resolve() + .is_relative_to(agents_source) + ): + frame_locals.append(traceback_object.tb_frame.f_locals) + traceback_object = traceback_object.tb_next + return frame_locals + + +@pytest.mark.asyncio +async def test_synchronous_realtime_handoff_clears_redacted_error_traceback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload_secret = "SYNCHRONOUS_REALTIME_HANDOFF_TRACEBACK_SECRET" + schema_secret = "SYNCHRONOUS_REALTIME_HANDOFF_SCHEMA_SECRET_4207" + sensitive_input_type = cast( + type[Any], + Literal["SYNCHRONOUS_REALTIME_HANDOFF_SCHEMA_SECRET_4207"], + ) + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + target = RealtimeAgent(name="target") + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: Any) -> None: + pass # pragma: no cover + + handoff_obj = realtime_handoff( + target, + on_handoff=on_handoff, + input_type=sensitive_input_type, + ) + agent = RealtimeAgent(name="agent", handoffs=[handoff_obj]) + session = RealtimeSession( + _DummyModel(), + agent, + None, + run_config={"async_tool_calls": False}, + ) + event = RealtimeModelToolCallEvent( + name=handoff_obj.tool_name, + call_id="call-1", + arguments=f'"{payload_secret}"', + ) + + with pytest.raises(ModelBehaviorError) as exc_info: + await session.on_event(event) + + error = exc_info.value + traceback_exception = traceback.TracebackException.from_exception(error, capture_locals=True) + agents_source = (Path(__file__).parents[2] / "src" / "agents").resolve() + sdk_frames = [ + frame + for frame in traceback_exception.stack + if Path(frame.filename).resolve().is_relative_to(agents_source) + ] + assert sdk_frames + assert payload_secret not in "".join( + value for frame in sdk_frames for value in (frame.locals or {}).values() + ) + assert schema_secret not in "".join( + value for frame in sdk_frames for value in (frame.locals or {}).values() + ) + actual_frame_locals = _realtime_sdk_traceback_frame_locals(error) + assert actual_frame_locals + assert all( + value is not session and value is not event + for frame in actual_frame_locals + for value in frame.values() + ) + assert payload_secret not in str(error) + assert schema_secret not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + + +@pytest.mark.asyncio +async def test_async_realtime_handoff_detaches_session_from_redacted_error_traceback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + secret = "ASYNCHRONOUS_REALTIME_HANDOFF_TRACEBACK_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + target = RealtimeAgent(name="target") + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: int) -> None: + pass # pragma: no cover + + handoff_obj = realtime_handoff(target, on_handoff=on_handoff, input_type=int) + agent = RealtimeAgent(name="agent", handoffs=[handoff_obj]) + session = RealtimeSession(_DummyModel(), agent, None) + event = RealtimeModelToolCallEvent( + name=handoff_obj.tool_name, + call_id="call-1", + arguments=f'"{secret}"', + ) + event_iterator = session.__aiter__() + + await session.on_event(event) + raw_event = await anext(event_iterator) + assert isinstance(raw_event, RealtimeRawModelEvent) + + for _ in range(20): + if session._stored_exception is not None: + break + await asyncio.sleep(0) + assert isinstance(session._stored_exception, ModelBehaviorError) + + with pytest.raises(ModelBehaviorError) as exc_info: + await anext(event_iterator) + + error = exc_info.value + traceback_exception = traceback.TracebackException.from_exception(error, capture_locals=True) + agents_source = (Path(__file__).parents[2] / "src" / "agents").resolve() + sdk_frames = [ + frame + for frame in traceback_exception.stack + if Path(frame.filename).resolve().is_relative_to(agents_source) + ] + assert sdk_frames + assert secret not in "".join( + value for frame in sdk_frames for value in (frame.locals or {}).values() + ) + actual_frame_locals = _realtime_sdk_traceback_frame_locals(error) + assert actual_frame_locals + assert all(session is not value for frame in actual_frame_locals for value in frame.values()) + assert secret not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + + +@pytest.mark.asyncio +async def test_synchronous_realtime_handoff_preserves_diagnostic_traceback_locals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + secret = "SYNCHRONOUS_REALTIME_HANDOFF_DIAGNOSTIC_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + target = RealtimeAgent(name="target") + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: int) -> None: + pass # pragma: no cover + + handoff_obj = realtime_handoff(target, on_handoff=on_handoff, input_type=int) + agent = RealtimeAgent(name="agent", handoffs=[handoff_obj]) + session = RealtimeSession( + _DummyModel(), + agent, + None, + run_config={"async_tool_calls": False}, + ) + event = RealtimeModelToolCallEvent( + name=handoff_obj.tool_name, + call_id="call-1", + arguments=f'"{secret}"', + ) + + with pytest.raises(ModelBehaviorError) as exc_info: + await session.on_event(event) + + error = exc_info.value + assert secret in str(error) + assert any( + frame_locals.get("event") is event + for frame_locals in _realtime_sdk_traceback_frame_locals(error) + ) + + @pytest.mark.asyncio async def test_get_handoffs_async_is_enabled(monkeypatch): # Agent includes both a direct Handoff and a RealtimeAgent (auto-converted) diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index b194c6f41b..821907a4ce 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -8,32 +8,45 @@ from __future__ import annotations +import asyncio +import json import logging import pickle import threading +import traceback +import warnings from logging.handlers import QueueHandler from pathlib import Path from queue import SimpleQueue -from typing import Any +from typing import Any, Literal, cast from unittest.mock import patch import httpx import pytest from openai import AsyncOpenAI -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, SkipValidation, ValidationError import agents._debug as _debug from agents import ( Agent, + GuardrailFunctionOutput, + InputGuardrail, ModelBehaviorError, ModelSettings, ModelTracing, OpenAIResponsesModel, + OutputGuardrail, RunConfig, RunContextWrapper, + RunErrorHandlerInput, + RunErrorHandlerResult, + Runner, + UserError, function_tool, + handoff, trace, ) +from agents.agent_output import AgentOutputSchema from agents.logger import ( log_model_action_debug, log_model_action_error, @@ -46,6 +59,7 @@ log_tool_action_error as log_shared_tool_action_error, log_tool_action_warning, ) +from agents.realtime import RealtimeAgent, realtime_handoff from agents.run_internal.tool_execution import ( log_tool_action_error, resolve_approval_rejection_message, @@ -57,6 +71,10 @@ from agents.tracing.spans import Span from agents.tracing.traces import Trace +from .fake_model import FakeModel +from .test_responses import get_function_tool_call, get_text_message +from .utils.simple_session import SimpleListSession + _SECRET = "super secret prompt content" @@ -82,6 +100,11 @@ def __getattribute__(self, name: str): return super().__getattribute__(name) +class _HostileAttributeWriteException(Exception): + def __setattr__(self, name: str, value: Any) -> None: + raise RuntimeError("redacted handling mutated the handler exception") + + class _TruthinessException(Exception): def __init__(self, *, truthy: bool) -> None: super().__init__("diagnostic failure") @@ -858,3 +881,922 @@ async def test_agent_tool_validation_error_preserves_diagnostics_when_tool_data_ error = exc_info.value assert _TOOL_ARGUMENT_SECRET in str(error) assert isinstance(error.__cause__, ValidationError) + + +_MODEL_OUTPUT_SECRET = "SECRET_MODEL_OUTPUT_123" +_SENSITIVE_SCHEMA_SECRET = "SENSITIVE_HANDOFF_SCHEMA_SECRET_4207" +_SENSITIVE_OUTPUT_SCHEMA_SECRET = "SENSITIVE_OUTPUT_SCHEMA_SECRET_4207" +_SensitiveHandoffInput = Literal["SENSITIVE_HANDOFF_SCHEMA_SECRET_4207"] +_SensitiveOutput = Literal["SENSITIVE_OUTPUT_SCHEMA_SECRET_4207"] + + +class _RequiredOutput(BaseModel): + answer: str + count: int + + +class _PermissiveFallbackOutput(BaseModel): + payload: SkipValidation[str] + count: int + + +def _assert_secret_absent_from_agents_traceback( + error: BaseException, + secret: str, + *, + require_agents_frames: bool = True, +) -> None: + traceback_exception = traceback.TracebackException.from_exception(error, capture_locals=True) + agents_source = (Path(__file__).parents[1] / "src" / "agents").resolve() + agents_frames = [ + frame + for frame in traceback_exception.stack + if Path(frame.filename).resolve().is_relative_to(agents_source) + ] + if require_agents_frames: + assert agents_frames + for frame in agents_frames: + assert secret not in "".join((frame.locals or {}).values()) + + +def _agents_traceback_frame_locals(error: BaseException) -> list[dict[str, Any]]: + agents_source = (Path(__file__).parents[1] / "src" / "agents").resolve() + frame_locals: list[dict[str, Any]] = [] + traceback_object = error.__traceback__ + while traceback_object is not None: + if ( + Path(traceback_object.tb_frame.f_code.co_filename) + .resolve() + .is_relative_to(agents_source) + ): + frame_locals.append(traceback_object.tb_frame.f_locals) + traceback_object = traceback_object.tb_next + return frame_locals + + +def _assert_handoff_closure_absent_from_traceback( + error: BaseException, + *, + callback: Any, + schema_secret: str, +) -> None: + for frame_locals in _agents_traceback_frame_locals(error): + for value in frame_locals.values(): + closure = getattr(value, "__closure__", None) + if closure is None: + continue + closure_values = [cell.cell_contents for cell in closure] + assert all(item is not callback for item in closure_values) + assert schema_secret not in repr(closure_values) + + +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted", "expected_redacted"), + [ + (True, False, True), + (False, True, False), + ], +) +def test_output_schema_validation_error_follows_model_data_policy( + monkeypatch: pytest.MonkeyPatch, + model_redacted: bool, + tool_redacted: bool, + expected_redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + + with pytest.raises(ModelBehaviorError) as exc_info: + AgentOutputSchema(_RequiredOutput).validate_json(payload) + + error = exc_info.value + if expected_redacted: + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + require_agents_frames=False, + ) + else: + assert _MODEL_OUTPUT_SECRET in str(error) + assert isinstance(error.__cause__, ValidationError) + assert any( + frame_locals.get("json_str") == payload + for frame_locals in _agents_traceback_frame_locals(error) + ) + + +def test_output_schema_redaction_survives_trace_attachment_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + + with patch( + "agents.util._json.attach_error_to_current_span", + side_effect=RuntimeError("trace attachment failed"), + ): + with pytest.raises(ModelBehaviorError) as exc_info: + AgentOutputSchema(_RequiredOutput).validate_json(payload) + + error = exc_info.value + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + require_agents_frames=False, + ) + + +def test_output_schema_redaction_omits_sensitive_schema_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + output_type = cast(type[Any], _SensitiveOutput) + + with pytest.raises(ModelBehaviorError) as exc_info: + AgentOutputSchema(output_type).validate_json('"invalid"') + + error = exc_info.value + assert _SENSITIVE_OUTPUT_SCHEMA_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _SENSITIVE_OUTPUT_SCHEMA_SECRET, + require_agents_frames=False, + ) + + +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted", "expected_redacted"), + [ + (True, False, True), + (False, True, True), + (True, True, True), + (False, False, False), + ], +) +@pytest.mark.asyncio +async def test_handoff_input_validation_error_follows_mixed_data_policy( + monkeypatch: pytest.MonkeyPatch, + model_redacted: bool, + tool_redacted: bool, + expected_redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + target = Agent(name="target") + handoff_calls = 0 + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: _RequiredOutput) -> None: + nonlocal handoff_calls + handoff_calls += 1 + + handoff_obj = handoff(target, input_type=_RequiredOutput, on_handoff=on_handoff) + with pytest.raises(ModelBehaviorError) as exc_info: + await handoff_obj.on_invoke_handoff(RunContextWrapper(None), payload) + + assert handoff_calls == 0 + error = exc_info.value + if expected_redacted: + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + require_agents_frames=False, + ) + else: + assert _MODEL_OUTPUT_SECRET in str(error) + assert isinstance(error.__cause__, ValidationError) + assert any( + frame_locals.get("input_json") == payload + for frame_locals in _agents_traceback_frame_locals(error) + ) + + +@pytest.mark.asyncio +async def test_handoff_redaction_omits_sensitive_schema_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + target = Agent(name="target") + handoff_calls = 0 + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: Any) -> None: + nonlocal handoff_calls + handoff_calls += 1 + + handoff_obj = handoff( + target, + input_type=cast(type[Any], _SensitiveHandoffInput), + on_handoff=on_handoff, + ) + with pytest.raises(ModelBehaviorError) as exc_info: + await handoff_obj.on_invoke_handoff(RunContextWrapper(None), '"invalid"') + + error = exc_info.value + assert handoff_calls == 0 + assert _SENSITIVE_SCHEMA_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _SENSITIVE_SCHEMA_SECRET, + require_agents_frames=False, + ) + _assert_handoff_closure_absent_from_traceback( + error, + callback=on_handoff, + schema_secret=_SENSITIVE_SCHEMA_SECRET, + ) + + +@pytest.mark.asyncio +async def test_realtime_handoff_redaction_omits_sensitive_schema_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + target = RealtimeAgent(name="target") + handoff_calls = 0 + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: Any) -> None: + nonlocal handoff_calls + handoff_calls += 1 + + handoff_obj = realtime_handoff( + target, + input_type=cast(type[Any], _SensitiveHandoffInput), + on_handoff=on_handoff, + ) + with pytest.raises(ModelBehaviorError) as exc_info: + await handoff_obj.on_invoke_handoff(RunContextWrapper(None), '"invalid"') + + error = exc_info.value + assert handoff_calls == 0 + assert _SENSITIVE_SCHEMA_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _SENSITIVE_SCHEMA_SECRET, + require_agents_frames=False, + ) + _assert_handoff_closure_absent_from_traceback( + error, + callback=on_handoff, + schema_secret=_SENSITIVE_SCHEMA_SECRET, + ) + + +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted", "expected_redacted"), + [ + (True, False, True), + (False, True, True), + (True, True, True), + (False, False, False), + ], +) +@pytest.mark.asyncio +async def test_realtime_handoff_input_validation_error_follows_mixed_data_policy( + monkeypatch: pytest.MonkeyPatch, + model_redacted: bool, + tool_redacted: bool, + expected_redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + target = RealtimeAgent(name="target") + handoff_calls = 0 + + async def on_handoff(_ctx: RunContextWrapper[Any], _input: _RequiredOutput) -> None: + nonlocal handoff_calls + handoff_calls += 1 + + handoff_obj = realtime_handoff(target, input_type=_RequiredOutput, on_handoff=on_handoff) + with pytest.raises(ModelBehaviorError) as exc_info: + await handoff_obj.on_invoke_handoff(RunContextWrapper(None), payload) + + assert handoff_calls == 0 + error = exc_info.value + if expected_redacted: + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + require_agents_frames=False, + ) + else: + assert _MODEL_OUTPUT_SECRET in str(error) + assert isinstance(error.__cause__, ValidationError) + assert any( + frame_locals.get("input_json") == payload + for frame_locals in _agents_traceback_frame_locals(error) + ) + + +@pytest.mark.asyncio +async def test_run_surfaces_redacted_output_validation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + model = FakeModel() + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + model.set_next_output([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + session = SimpleListSession( + session_id="redacted-run", + history=[{"role": "user", "content": _MODEL_OUTPUT_SECRET}], + ) + + with pytest.raises(ModelBehaviorError) as exc_info: + await Runner.run(agent, _MODEL_OUTPUT_SECRET, session=session) + + error = exc_info.value + frame_locals = _agents_traceback_frame_locals(error) + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + ) + assert all(session is not value for frame in frame_locals for value in frame.values()) + + +def test_run_sync_surfaces_redacted_output_validation_error_without_runner_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + model = FakeModel() + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + model.set_next_output([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + session = SimpleListSession( + session_id="redacted-run-sync", + history=[{"role": "user", "content": _MODEL_OUTPUT_SECRET}], + ) + + with pytest.raises(ModelBehaviorError) as exc_info: + Runner.run_sync(agent, _MODEL_OUTPUT_SECRET, session=session) + + error = exc_info.value + frame_locals = _agents_traceback_frame_locals(error) + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + ) + assert all(session is not value for frame in frame_locals for value in frame.values()) + + +@pytest.mark.asyncio +async def test_run_preserves_diagnostic_wrapper_traceback_locals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + diagnostic_input = "DIAGNOSTIC_RUNNER_INPUT_SECRET" + model = FakeModel(initial_output=[get_text_message('{"answer": "missing count"}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + session = SimpleListSession(session_id="diagnostic-runner") + + with pytest.raises(ModelBehaviorError) as exc_info: + await Runner.run(agent, diagnostic_input, session=session) + + error = exc_info.value + frame_locals = _agents_traceback_frame_locals(error) + assert any(frame.get("input") == diagnostic_input for frame in frame_locals) + assert any(frame.get("session") is session for frame in frame_locals) + + +def test_run_sync_preserves_diagnostic_wrapper_traceback_locals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + diagnostic_input = "DIAGNOSTIC_RUNNER_SYNC_INPUT_SECRET" + model = FakeModel(initial_output=[get_text_message('{"answer": "missing count"}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + session = SimpleListSession(session_id="diagnostic-runner-sync") + + with pytest.raises(ModelBehaviorError) as exc_info: + Runner.run_sync(agent, diagnostic_input, session=session) + + error = exc_info.value + frame_locals = _agents_traceback_frame_locals(error) + assert any(frame.get("input") == diagnostic_input for frame in frame_locals) + assert any(frame.get("session") is session for frame in frame_locals) + + +@pytest.mark.asyncio +async def test_streamed_run_surfaces_redacted_output_validation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + model = FakeModel() + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + model.set_next_output([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + result = Runner.run_streamed(agent, "go") + + with pytest.raises(ModelBehaviorError) as exc_info: + async for _ in result.stream_events(): + pass + + error = exc_info.value + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + + +@pytest.mark.parametrize("redacted", [False, True]) +@pytest.mark.asyncio +async def test_streamed_run_loop_exception_follows_model_data_policy( + monkeypatch: pytest.MonkeyPatch, + redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + result = Runner.run_streamed(agent, "go") + + assert result.run_loop_task is not None + while not result.run_loop_task.done(): + await asyncio.sleep(0) + + error = result.run_loop_exception + assert isinstance(error, ModelBehaviorError) + if redacted: + assert error.run_data is None + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + require_agents_frames=False, + ) + else: + assert _MODEL_OUTPUT_SECRET in str(error) + assert isinstance(error.__cause__, ValidationError) + assert any( + _MODEL_OUTPUT_SECRET in repr(frame_locals) + for frame_locals in _agents_traceback_frame_locals(error) + ) + + +@pytest.mark.asyncio +async def test_streamed_output_guardrail_omits_run_data_from_redacted_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + model = FakeModel(initial_output=[get_text_message(_MODEL_OUTPUT_SECRET)]) + agent = Agent( + name="A", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + result = Runner.run_streamed(agent, "go") + + with pytest.raises(ModelBehaviorError) as exc_info: + async for _ in result.stream_events(): + pass + + error = exc_info.value + assert error.run_data is None + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + + +@pytest.mark.asyncio +async def test_streamed_input_guardrail_omits_run_data_from_redacted_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + + def input_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _input: str | list[Any], + ) -> GuardrailFunctionOutput: + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + model = FakeModel(initial_output=[get_text_message("unused")]) + agent = Agent( + name="A", + model=model, + input_guardrails=[InputGuardrail(guardrail_function=input_guardrail)], + ) + result = Runner.run_streamed(agent, _MODEL_OUTPUT_SECRET) + + with pytest.raises(ModelBehaviorError) as exc_info: + async for _ in result.stream_events(): + pass + + error = exc_info.value + assert error.run_data is None + assert _MODEL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_invalid_final_output_handler_receives_detached_redacted_error( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + retained_errors: list[ModelBehaviorError] = [] + + def recover(data: RunErrorHandlerInput[None]) -> _RequiredOutput: + assert isinstance(data.error, ModelBehaviorError) + retained_errors.append(data.error) + return _RequiredOutput(answer="safe", count=1) + + if streamed: + result = Runner.run_streamed( + agent, + "go", + error_handlers={"invalid_final_output": recover}, + ) + async for _ in result.stream_events(): + pass + else: + await Runner.run( + agent, + "go", + error_handlers={"invalid_final_output": recover}, + ) + + assert len(retained_errors) == 1 + error = retained_errors[0] + assert error.__traceback__ is None + assert error.__cause__ is None + assert error.__context__ is None + assert _MODEL_OUTPUT_SECRET not in str(error) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_invalid_final_output_handler_invalid_fallback_preserves_redaction( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, +) -> None: + fallback_secret = "INVALID_HANDLER_FALLBACK_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + + def invalid_fallback(_data: RunErrorHandlerInput[None]) -> dict[str, str]: + return {"answer": fallback_secret} + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + if streamed: + result = Runner.run_streamed( + agent, + "go", + error_handlers={"invalid_final_output": invalid_fallback}, + ) + with pytest.raises(UserError) as exc_info: + async for _ in result.stream_events(): + pass + else: + with pytest.raises(UserError) as exc_info: + await Runner.run( + agent, + "go", + error_handlers={"invalid_final_output": invalid_fallback}, + ) + + error = exc_info.value + assert not caught_warnings + assert str(error) == "Error details are redacted." + assert error.run_data is None + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + _assert_secret_absent_from_agents_traceback(error, fallback_secret) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("redacted", [True, False]) +@pytest.mark.asyncio +async def test_invalid_final_output_handler_fallback_serialization_follows_redaction_policy( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, + redacted: bool, +) -> None: + fallback_secret = "PERMISSIVE_HANDLER_FALLBACK_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + model = FakeModel( + initial_output=[ + get_text_message(f'{{"payload": "{_MODEL_OUTPUT_SECRET}", "count": "invalid"}}') + ] + ) + agent = Agent(name="A", model=model, output_type=_PermissiveFallbackOutput) + + def permissive_fallback(_data: RunErrorHandlerInput[None]) -> _PermissiveFallbackOutput: + return _PermissiveFallbackOutput( + payload=cast(Any, {"secret": fallback_secret}), + count=1, + ) + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + if streamed: + streaming_result = Runner.run_streamed( + agent, + "go", + error_handlers={"invalid_final_output": permissive_fallback}, + ) + async for _ in streaming_result.stream_events(): + pass + actual_final_output = streaming_result.final_output + else: + run_result = await Runner.run( + agent, + "go", + error_handlers={"invalid_final_output": permissive_fallback}, + ) + actual_final_output = run_result.final_output + + rendered_warnings = "\n".join(str(warning.message) for warning in caught_warnings) + if redacted: + assert not caught_warnings + else: + assert fallback_secret in rendered_warnings + assert actual_final_output == _PermissiveFallbackOutput( + payload=cast(Any, {"secret": fallback_secret}), + count=1, + ) + + +@pytest.mark.parametrize( + ("streamed", "include_in_history", "redacted"), + [ + (False, True, True), + (False, False, True), + (True, True, True), + (True, False, True), + (False, True, False), + ], +) +@pytest.mark.asyncio +async def test_empty_final_output_handler_fallback_serialization_follows_redaction_policy( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, + include_in_history: bool, + redacted: bool, +) -> None: + fallback_secret = "EMPTY_HANDLER_FALLBACK_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + model = FakeModel(initial_output=[]) + agent = Agent(name="A", model=model, output_type=_PermissiveFallbackOutput) + + def permissive_fallback(_data: RunErrorHandlerInput[None]) -> RunErrorHandlerResult: + return RunErrorHandlerResult( + final_output=_PermissiveFallbackOutput( + payload=cast(Any, {"secret": fallback_secret}), + count=1, + ), + include_in_history=include_in_history, + ) + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + if streamed: + streaming_result = Runner.run_streamed( + agent, + "go", + error_handlers={"invalid_final_output": permissive_fallback}, + ) + async for _ in streaming_result.stream_events(): + pass + actual_final_output = streaming_result.final_output + else: + run_result = await Runner.run( + agent, + "go", + error_handlers={"invalid_final_output": permissive_fallback}, + ) + actual_final_output = run_result.final_output + + rendered_warnings = "\n".join(str(warning.message) for warning in caught_warnings) + if redacted: + assert not caught_warnings + else: + assert fallback_secret in rendered_warnings + assert actual_final_output == _PermissiveFallbackOutput( + payload=cast(Any, {"secret": fallback_secret}), + count=1, + ) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_invalid_final_output_handler_failure_preserves_redaction( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + + def fail(data: RunErrorHandlerInput[None]) -> None: + raise RuntimeError(repr(data.run_data.raw_responses)) + + if streamed: + result = Runner.run_streamed( + agent, + "go", + error_handlers={"invalid_final_output": fail}, + ) + with pytest.raises(UserError) as exc_info: + async for _ in result.stream_events(): + pass + else: + with pytest.raises(UserError) as exc_info: + await Runner.run( + agent, + "go", + error_handlers={"invalid_final_output": fail}, + ) + + error = exc_info.value + assert str(error) == "Error details are redacted." + assert error.run_data is None + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_invalid_final_output_handler_hostile_failure_preserves_redaction( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, +) -> None: + handler_secret = "HOSTILE_HANDLER_FAILURE_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + + def fail(_data: RunErrorHandlerInput[None]) -> None: + raise _HostileAttributeWriteException(handler_secret) + + if streamed: + result = Runner.run_streamed( + agent, + "go", + error_handlers={"invalid_final_output": fail}, + ) + with pytest.raises(UserError) as exc_info: + async for _ in result.stream_events(): + pass + else: + with pytest.raises(UserError) as exc_info: + await Runner.run( + agent, + "go", + error_handlers={"invalid_final_output": fail}, + ) + + error = exc_info.value + assert str(error) == "Error details are redacted." + assert error.run_data is None + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + _assert_secret_absent_from_agents_traceback(error, handler_secret) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_invalid_final_output_handler_failure_preserves_diagnostic_context( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + + def fail(_data: RunErrorHandlerInput[None]) -> None: + raise RuntimeError("handler failed") + + if streamed: + result = Runner.run_streamed( + agent, + "go", + error_handlers={"invalid_final_output": fail}, + ) + with pytest.raises(RuntimeError, match="handler failed") as exc_info: + async for _ in result.stream_events(): + pass + else: + with pytest.raises(RuntimeError, match="handler failed") as exc_info: + await Runner.run( + agent, + "go", + error_handlers={"invalid_final_output": fail}, + ) + + validation_error = exc_info.value.__context__ + assert isinstance(validation_error, ModelBehaviorError) + assert _MODEL_OUTPUT_SECRET in str(validation_error) + assert isinstance(validation_error.__cause__, ValidationError) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("redacted", [False, True]) +@pytest.mark.asyncio +async def test_multiturn_output_validation_error_run_data_follows_redaction_policy( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, + redacted: bool, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted) + + @function_tool + def record_value(value: str) -> str: + return "recorded" + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + record_value.name, + json.dumps({"value": _MODEL_OUTPUT_SECRET}), + ) + ], + [get_text_message('{"answer": "missing count"}')], + ] + ) + agent = Agent( + name="A", + model=model, + tools=[record_value], + output_type=_RequiredOutput, + ) + + if streamed: + result = Runner.run_streamed(agent, "go") + with pytest.raises(ModelBehaviorError) as exc_info: + async for _ in result.stream_events(): + pass + else: + with pytest.raises(ModelBehaviorError) as exc_info: + await Runner.run(agent, "go") + + error = exc_info.value + if redacted: + assert error.run_data is None + assert _MODEL_OUTPUT_SECRET not in str(error) + _assert_secret_absent_from_agents_traceback( + error, + _MODEL_OUTPUT_SECRET, + require_agents_frames=streamed, + ) + else: + assert error.run_data is not None + assert error.run_data.raw_responses + assert error.run_data.new_items diff --git a/tests/test_invalid_final_output_handler.py b/tests/test_invalid_final_output_handler.py index b4519debdc..667d9a746d 100644 --- a/tests/test_invalid_final_output_handler.py +++ b/tests/test_invalid_final_output_handler.py @@ -7,6 +7,7 @@ from openai.types.responses import ResponseOutputMessage from pydantic import BaseModel +import agents._debug as _debug from agents import ( Agent, AgentHookContext, @@ -114,7 +115,10 @@ async def test_invalid_final_output_handler_can_skip_fallback_history() -> None: @pytest.mark.asyncio -async def test_invalid_final_output_handler_rejects_invalid_fallback() -> None: +async def test_invalid_final_output_handler_rejects_invalid_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) model = FakeModel(initial_output=[get_text_message("not valid json")]) agent = Agent(name="test", model=model, output_type=FinalOutput) From 810620b1220131442e6dbf3c8b1a6ac63b31d11b Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Thu, 6 Aug 2026 05:17:08 +0530 Subject: [PATCH 183/473] fix(sandbox): stop splitting Cloudflare SSE events at chunk boundaries (#4215) --- .../extensions/sandbox/cloudflare/sandbox.py | 29 ++++-- tests/extensions/sandbox/test_cloudflare.py | 99 ++++++++++++++++++- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index c8881fd8a9..d0a5f83d87 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -207,12 +207,22 @@ class _ServerSentEvent: class _SSELineDecoder: _buf: bytes + _skip_leading_lf: bool def __init__(self) -> None: self._buf = b"" + self._skip_leading_lf = False def decode(self, text: str) -> list[str]: - raw = self._buf + text.encode("utf-8") + data = text.encode("utf-8") + if self._skip_leading_lf and data: + # The previous chunk ended on a CR, which already terminated its line. A LF + # opening this chunk is the second half of that CRLF, so consume it instead of + # reading it as a blank line, which SSE treats as an event dispatch. + self._skip_leading_lf = False + if data.startswith(b"\n"): + data = data[1:] + raw = self._buf + data self._buf = b"" lines: list[str] = [] @@ -231,7 +241,12 @@ def decode(self, text: str) -> list[str]: if cr + 1 < length and raw[cr + 1 : cr + 2] == b"\n": i = cr + 2 elif cr + 1 == length: - self._buf = b"\r" + # A CR is a complete line ending on its own, so deliver the line now + # rather than waiting to learn whether a LF follows. Only remember that + # a LF opening the next chunk belongs to this CRLF; without that, the + # LF reads as a blank line and dispatches the event early, splitting one + # multi-line event into several. + self._skip_leading_lf = True lines.append(line.decode("utf-8")) break else: @@ -247,11 +262,11 @@ def decode(self, text: str) -> list[str]: def flush(self) -> list[str]: buf = self._buf self._buf = b"" - if buf == b"\r": - return [""] - if buf: - return [buf.decode("utf-8")] - return [] + # A trailing CR already delivered its line, so there is nothing pending for it here. + self._skip_leading_lf = False + if not buf: + return [] + return [buf.decode("utf-8")] class _SSEDecoder: diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index c0a32f13de..3f707ba3c9 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -19,7 +19,11 @@ CloudflareSandboxSession, CloudflareSandboxSessionState, ) -from agents.extensions.sandbox.cloudflare.sandbox import _CloudflarePtyProcessEntry +from agents.extensions.sandbox.cloudflare.sandbox import ( + _CloudflarePtyProcessEntry, + _SSEDecoder, + _SSELineDecoder, +) from agents.sandbox.entries import Dir, GCSMount, R2Mount, S3Mount from agents.sandbox.errors import ( ConfigurationError, @@ -1567,3 +1571,96 @@ async def test_cloudflare_shutdown_logs_respect_tool_data_policy( ) assert has_detail is not redacted assert response.read_calls == (0 if redacted else 1) + + +def _decode_in_chunks(stream: str, size: int) -> list[str]: + decoder = _SSELineDecoder() + lines: list[str] = [] + for index in range(0, len(stream), size): + lines.extend(decoder.decode(stream[index : index + size])) + lines.extend(decoder.flush()) + return lines + + +def _events(lines: list[str]) -> list[tuple[str, str]]: + decoder = _SSEDecoder() + collected: list[tuple[str, str]] = [] + for line in lines: + event = decoder.decode(line) + if event is not None: + collected.append((event.event, event.data)) + return collected + + +@pytest.mark.parametrize( + "stream", + [ + "data: a\ndata: b\n\n", + "data: a\r\ndata: b\r\n\r\n", + "data: a\rdata: b\r\r", + "data: a\r\ndata: b\n\r\n", + "data:\r\n\r\n", + "data: a\r", + "\r\n", + ], + ids=["lf", "crlf", "cr", "mixed", "empty_data", "trailing_cr", "bare_crlf"], +) +def test_sse_line_decoder_matches_splitlines_for_every_chunk_boundary(stream: str) -> None: + """Line splitting must not depend on how the transport chunks the stream. + + A chunk that ends on a CR cannot be classified yet: the next chunk may start with LF, + and CRLF is a single terminator. Emitting the line early left that LF to be read as a + blank line, and a blank line dispatches an SSE event. + """ + expected = stream.splitlines() + for size in range(1, len(stream) + 1): + assert _decode_in_chunks(stream, size) == expected, f"chunk size {size}" + + +def test_sse_event_is_not_split_when_crlf_straddles_a_chunk_boundary() -> None: + """One multi-line event must stay one event regardless of chunking.""" + stream = "data: a\r\ndata: b\r\n\r\n" + + whole = _events(_decode_in_chunks(stream, len(stream))) + assert whole == [("message", "a\nb")] + + for size in range(1, len(stream) + 1): + assert _events(_decode_in_chunks(stream, size)) == whole, f"chunk size {size}" + + +def test_sse_line_decoder_delivers_a_cr_terminated_line_immediately() -> None: + """A CR is a complete line ending, so the line must not wait for the next chunk. + + Holding it back to learn whether a LF follows would delay every CR-terminated line + until more bytes arrive or the stream ends. + """ + decoder = _SSELineDecoder() + + assert decoder.decode("data: a\r") == ["data: a"] + # The LF is the second half of that CRLF, not a blank line, so it yields nothing. + assert decoder.decode("\n") == [] + assert decoder.flush() == [] + + +def test_sse_line_decoder_dispatches_a_cr_only_blank_line_without_more_input() -> None: + """A CR-only blank line must dispatch its event without another chunk or EOF.""" + decoder = _SSELineDecoder() + sse = _SSEDecoder() + + lines = decoder.decode("data: a\r\r") + + assert lines == ["data: a", ""] + events = [event for event in (sse.decode(line) for line in lines) if event is not None] + assert [(event.event, event.data) for event in events] == [("message", "a")] + + +def test_sse_line_decoder_flush_emits_only_an_unterminated_line() -> None: + """A stream ending on a lone CR already delivered its line, so flush adds nothing.""" + decoder = _SSELineDecoder() + + assert decoder.decode("data: a\r") == ["data: a"] + assert decoder.flush() == [] + + partial = _SSELineDecoder() + assert partial.decode("data: b") == [] + assert partial.flush() == ["data: b"] From cce949a3fc3e589a5d0b6bd4a1ba1e6a78a53b9b Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:51:45 -0700 Subject: [PATCH 184/473] fix(memory): enforce closed state on SQLite session empty add_items (#4231) --- .../extensions/memory/advanced_sqlite_session.py | 3 +++ src/agents/memory/sqlite_session.py | 11 +++++++++-- .../memory/test_advanced_sqlite_session.py | 15 +++++++++++++++ tests/memory/test_session.py | 12 ++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index 89ca91938b..de88927bc2 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -170,6 +170,9 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: Args: items: The items to add to the session """ + # Checked before the empty-list fast path, which would otherwise return + # successfully on a closed session. + self._check_not_closed() if not items: return diff --git a/src/agents/memory/sqlite_session.py b/src/agents/memory/sqlite_session.py index 4bd641cc8d..61bf4e563b 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -120,11 +120,15 @@ def _locked_connection(self) -> Iterator[sqlite3.Connection]: with self._lock: yield self._get_connection() - def _get_connection(self) -> sqlite3.Connection: - """Get a database connection.""" + def _check_not_closed(self) -> None: + """Raise if the session has already been closed.""" if self._closed: raise RuntimeError("SQLiteSession is closed") + def _get_connection(self) -> sqlite3.Connection: + """Get a database connection.""" + self._check_not_closed() + if self._is_memory_db: # Use shared connection for in-memory database to avoid thread isolation return self._shared_connection @@ -283,6 +287,9 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: Args: items: List of input items to add to the history """ + # Checked before the empty-list fast path, which would otherwise return + # successfully on a closed session. + self._check_not_closed() if not items: return diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 2f26e0a2ef..ef1e72e16a 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -328,6 +328,21 @@ async def test_add_items_failure_preserves_existing_history(): session.close() +async def test_advanced_sqlite_session_closed_rejects_empty_add_items(): + """add_items([]) must not bypass the closed check through the empty-list fast path.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "closed_empty_add.db" + session = AdvancedSQLiteSession( + session_id="advanced_closed_empty_add", + db_path=db_path, + create_tables=True, + ) + session.close() + + with pytest.raises(RuntimeError, match="SQLiteSession is closed"): + await session.add_items([]) + + async def test_add_items_rolls_back_partial_structure_metadata_write(): """Partial metadata writes should roll back with the message rows in the same batch.""" session = PartiallyFailingStructureMetadataSession( diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index 3b180539b6..a2df310df1 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -234,6 +234,18 @@ async def test_sqlite_session_close_closes_worker_thread_connections(): connections[0].execute("SELECT 1") +@pytest.mark.asyncio +async def test_sqlite_session_closed_rejects_empty_add_items(): + """add_items([]) must not bypass the closed check through the empty-list fast path.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "closed_empty_add.db" + session = SQLiteSession("closed_empty_add_test", db_path) + session.close() + + with pytest.raises(RuntimeError, match="SQLiteSession is closed"): + await session.add_items([]) + + @pytest.mark.asyncio async def test_sqlite_session_memory_pop_item(): """Test SQLiteSession pop_item functionality.""" From 7c6ff9a3f8e404da6c7b8df57a04f22eab8411a7 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:01:04 -0700 Subject: [PATCH 185/473] fix(extensions): surface content filter refusals on the any_llm chat path (#4234) --- src/agents/extensions/models/any_llm_model.py | 13 +++ tests/models/test_any_llm_model.py | 83 ++++++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index aa217cb2a1..39ea7f5874 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -592,6 +592,19 @@ async def _get_response_via_chat( else Usage() ) + # Some providers signal a filtered non-streaming completion only through + # finish_reason="content_filter" and an otherwise empty message. Preserve + # that terminal signal as a refusal instead of returning an empty output. + if ( + message is not None + and first_choice is not None + and first_choice.finish_reason == "content_filter" + and not message.content + and not message.refusal + and not message.tool_calls + ): + message.refusal = "Response withheld by the provider's content filter." + if tracing.include_data(): span_generation.span_data.output = ( [message.model_dump()] if message is not None else [] diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 1371976d1d..ae9e150627 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -17,7 +17,12 @@ from openai.types.chat.chat_completion import Choice from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta from openai.types.completion_usage import CompletionUsage, PromptTokensDetails -from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputMessage +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseOutputMessage, + ResponseOutputRefusal, +) from openai.types.responses.response_created_event import ResponseCreatedEvent from openai.types.responses.response_error_event import ResponseErrorEvent from openai.types.responses.response_failed_event import ResponseFailedEvent @@ -442,6 +447,82 @@ async def test_any_llm_chat_path_is_used_when_responses_are_unsupported(monkeypa assert getattr(response.usage.input_tokens_details, "cache_write_tokens", None) == 4 +def _content_filtered_chat_completion(content: str) -> ChatCompletion: + completion = _chat_completion(content) + completion.choices[0].finish_reason = "content_filter" + return completion + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_path_surfaces_content_filter_refusal(monkeypatch) -> None: + """A filtered turn must become a refusal instead of an empty output.""" + provider = FakeAnyLLMProvider( + supports_responses=False, + chat_response=_content_filtered_chat_completion(""), + ) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + + model = module.AnyLLMModel(model="openrouter/openai/gpt-5.4-mini") + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + refusals = [ + content + for item in response.output + if isinstance(item, ResponseOutputMessage) + for content in item.content + if isinstance(content, ResponseOutputRefusal) + ] + assert refusals, f"expected a refusal item, got: {response.output}" + assert refusals[0].refusal + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_path_content_filter_keeps_real_content(monkeypatch) -> None: + """A filtered turn that still carries text keeps the text and gains no refusal.""" + provider = FakeAnyLLMProvider( + supports_responses=False, + chat_response=_content_filtered_chat_completion("here is the answer"), + ) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + + model = module.AnyLLMModel(model="openrouter/openai/gpt-5.4-mini") + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + refusals = [ + content + for item in response.output + if isinstance(item, ResponseOutputMessage) + for content in item.content + if isinstance(content, ResponseOutputRefusal) + ] + assert not refusals + assert response.output[0].content[0].text == "here is the answer" + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize( From c9153d2554293f8bc975ceb0862699151fc34390 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:01:56 -0700 Subject: [PATCH 186/473] fix(mcp): build the active server list without re-consuming the iterable (#4235) --- src/agents/mcp/manager.py | 2 +- tests/mcp/test_mcp_server_manager.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index 59c1d7e56f..c009abac5a 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -169,7 +169,7 @@ def __init__( connect_in_parallel: bool = False, ) -> None: self._all_servers = list(servers) - self._active_servers = list(servers) + self._active_servers = list(self._all_servers) self.connect_timeout_seconds = connect_timeout_seconds self.cleanup_timeout_seconds = cleanup_timeout_seconds self.drop_failed_servers = drop_failed_servers diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index 5d79b36bb6..7a5b677127 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -833,3 +833,38 @@ async def test_manager_async_with_cleans_cancelled_server_when_unsuppressed() -> assert server.cleanup_calls == 1 assert cancelled_server.cleanup_calls == 1 assert cancelled_server.resource_open is False + + +def test_manager_accepts_one_shot_iterables() -> None: + server_a = FlakyServer(failures=0) + server_b = FlakyServer(failures=0) + + manager = MCPServerManager(iter([server_a, server_b])) + + assert manager.all_servers == [server_a, server_b] + assert manager.active_servers == [server_a, server_b] + + +@pytest.mark.asyncio +async def test_manager_connects_servers_from_a_one_shot_iterable() -> None: + server_a = CleanupAwareServer() + server_b = CleanupAwareServer() + + async with MCPServerManager(server for server in (server_a, server_b)) as manager: + assert manager.active_servers == [server_a, server_b] + assert server_a.connect_calls == 1 + assert server_b.connect_calls == 1 + + +@pytest.mark.asyncio +async def test_manager_restores_one_shot_iterable_servers_after_a_failed_connect() -> None: + server = FlakyServer(failures=1) + + manager = MCPServerManager(iter([server]), strict=True, drop_failed_servers=False) + + with pytest.raises(RuntimeError): + await manager.connect_all() + + # drop_failed_servers=False keeps failed servers active, so the restored list must match + # what an equivalent list argument produces. + assert manager.active_servers == [server] From 2de0178d61ac56c3303fcc183eb6faef9a7ac592 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:02:36 -0700 Subject: [PATCH 187/473] fix(streaming): assemble chat completions content parts in content index order (#4236) --- src/agents/models/chatcmpl_stream_handler.py | 11 +++++++++-- .../test_openai_chatcompletions_stream.py | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 5c4924d6f1..4cd0a65686 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -1205,10 +1205,17 @@ async def handle_stream( ) if state.provider_data: assistant_msg.provider_data = state.provider_data.copy() # type: ignore[attr-defined] + # Assemble the parts in the order of the content indexes already announced by + # the content_part events. A refusal that opened before any text holds index 0 + # and the text holds index 1, so appending text first would contradict the + # indexes consumers already received. + content_parts: list[tuple[int, ResponseOutputText | ResponseOutputRefusal]] = [] if state.text_content_index_and_output: - assistant_msg.content.append(state.text_content_index_and_output[1]) + content_parts.append(state.text_content_index_and_output) if state.refusal_content_index_and_output: - assistant_msg.content.append(state.refusal_content_index_and_output[1]) + content_parts.append(state.refusal_content_index_and_output) + content_parts.sort(key=lambda entry: entry[0]) + assistant_msg.content.extend(part for _, part in content_parts) outputs.append(assistant_msg) # send a ResponseOutputItemDone for the assistant message diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 5c77510b38..1b5ead93b6 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -1326,6 +1326,13 @@ async def test_stream_handler_places_text_after_existing_refusal_part() -> None: events = await _collect_handler_events(*chunks) + refusal_part_added = next( + event + for event in events + if event.type == "response.content_part.added" + and isinstance(event.part, ResponseOutputRefusal) + ) + assert refusal_part_added.content_index == 0 text_part_added = next( event for event in events @@ -1337,10 +1344,12 @@ async def test_stream_handler_places_text_after_existing_refusal_part() -> None: completed_event = next(event for event in events if event.type == "response.completed") assistant_item = completed_event.response.output[0] assert isinstance(assistant_item, ResponseOutputMessage) - assert isinstance(assistant_item.content[0], ResponseOutputText) - assert isinstance(assistant_item.content[1], ResponseOutputRefusal) - assert assistant_item.content[0].text == "partial" - assert assistant_item.content[1].refusal == "blocked" + # The completed content must line up with the content indexes announced above: the + # refusal opened first at index 0 and the text followed at index 1. + assert isinstance(assistant_item.content[0], ResponseOutputRefusal) + assert isinstance(assistant_item.content[1], ResponseOutputText) + assert assistant_item.content[0].refusal == "blocked" + assert assistant_item.content[1].text == "partial" @pytest.mark.allow_call_model_methods From 141f59949e823bb6edd55aa2370320ac1476624a Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:04:20 -0700 Subject: [PATCH 188/473] fix(tracing): release the span scope when a generator is closed (#4233) --- src/agents/tracing/spans.py | 44 +++++++-- tests/tracing/test_spans_impl.py | 162 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 tests/tracing/test_spans_impl.py diff --git a/src/agents/tracing/spans.py b/src/agents/tracing/spans.py index 662063877c..8f564beedb 100644 --- a/src/agents/tracing/spans.py +++ b/src/agents/tracing/spans.py @@ -28,6 +28,34 @@ class SpanError(TypedDict): data: dict[str, Any] | None +def _finish_on_generator_exit(span: Span[Any]) -> None: + """Finish a span whose ``with`` block is unwinding because of ``GeneratorExit``. + + A generator closed from the task that advanced it resets normally, which is the common + case. An abandoned async generator is instead finalized from whichever task happens to + run its ``aclose``, so the body resumes in a context that never set the token and + ``ContextVar.reset`` raises ``ValueError``. Nothing can be done about that from here: + a ``Token`` is only valid in the ``Context`` that created it, so the task doing the + finalizing cannot rewrite the caller's context, and the caller keeps seeing this span + as current until its own scope ends. Raising would only add a crash on top of that. + + The tolerance is deliberately limited to this path, and within it to the reset itself. + An explicit ``finish`` from the wrong context is a context-ownership violation rather + than an unavoidable one, so it still raises, and a processor that fails during + ``finish`` still surfaces rather than being mistaken for a foreign token. + """ + span.finish(reset_current=False) + + token: contextvars.Token[Span[Any] | None] | None = span._prev_span_token # type: ignore[attr-defined] + if token is None: + return + span._prev_span_token = None # type: ignore[attr-defined] + try: + Scope.reset_current_span(token) + except ValueError: + logger.debug("Skipping span context reset, token belongs to another context") + + class Span(abc.ABC, Generic[TSpanData]): """Base class for representing traceable operations with timing and context. @@ -230,12 +258,10 @@ def __enter__(self) -> Span[TSpanData]: return self def __exit__(self, exc_type, exc_val, exc_tb): - reset_current = True if exc_type is GeneratorExit: - logger.debug("GeneratorExit, skipping span reset") - reset_current = False - - self.finish(reset_current=reset_current) + _finish_on_generator_exit(self) + else: + self.finish(reset_current=True) def set_error(self, error: SpanError) -> None: pass @@ -339,12 +365,10 @@ def __enter__(self) -> Span[TSpanData]: return self def __exit__(self, exc_type, exc_val, exc_tb): - reset_current = True if exc_type is GeneratorExit: - logger.debug("GeneratorExit, skipping span reset") - reset_current = False - - self.finish(reset_current=reset_current) + _finish_on_generator_exit(self) + else: + self.finish(reset_current=True) def set_error(self, error: SpanError) -> None: self._error = error diff --git a/tests/tracing/test_spans_impl.py b/tests/tracing/test_spans_impl.py new file mode 100644 index 0000000000..94940e04f0 --- /dev/null +++ b/tests/tracing/test_spans_impl.py @@ -0,0 +1,162 @@ +import asyncio +from collections.abc import AsyncGenerator, Callable +from typing import Any, cast + +import pytest + +from agents.tracing.processor_interface import TracingProcessor +from agents.tracing.scope import Scope +from agents.tracing.span_data import AgentSpanData, SpanData +from agents.tracing.spans import NoOpSpan, Span, SpanImpl +from agents.tracing.traces import Trace + + +class DummyProcessor(TracingProcessor): + def __init__(self) -> None: + self.started: list[str] = [] + self.ended: list[str] = [] + + def on_trace_start(self, trace: Trace) -> None: + return None + + def on_trace_end(self, trace: Trace) -> None: + return None + + def on_span_start(self, span: Span[Any]) -> None: + self.started.append(span.span_id) + + def on_span_end(self, span: Span[Any]) -> None: + self.ended.append(span.span_id) + + def shutdown(self) -> None: + return None + + def force_flush(self) -> None: + return None + + +def _new_no_op_span() -> Span[SpanData]: + return NoOpSpan(AgentSpanData(name="generator-exit")) + + +def _new_span_impl() -> Span[SpanData]: + return SpanImpl( + trace_id="trace-generator-exit", + span_id="span-generator-exit", + parent_id=None, + processor=DummyProcessor(), + span_data=AgentSpanData(name="generator-exit"), + tracing_api_key=None, + ) + + +_SPAN_FACTORIES = [_new_no_op_span, _new_span_impl] + + +def _spanned_stream(new_span: Callable[[], Span[SpanData]]) -> AsyncGenerator[int, None]: + async def stream() -> AsyncGenerator[int, None]: + with new_span(): + yield 1 + yield 2 + + return stream() + + +@pytest.mark.parametrize("new_span", _SPAN_FACTORIES) +async def test_generator_close_in_the_same_task_releases_the_span_scope( + new_span: Callable[[], Span[SpanData]], +) -> None: + """Closing a generator from the task that advanced it must restore the caller's span. + + ``GeneratorExit`` unwinds the ``with`` block, but the token saved by ``start`` is still + valid here because the body resumes in the caller's own context. Skipping the reset + would leave the closed span current and nest every later span under it. + """ + Scope.set_current_span(None) + + generator = _spanned_stream(new_span) + assert await generator.asend(None) == 1 + await generator.aclose() + + assert Scope.get_current_span() is None + + +@pytest.mark.parametrize("new_span", _SPAN_FACTORIES) +async def test_generator_close_from_another_task_does_not_raise( + new_span: Callable[[], Span[SpanData]], +) -> None: + """Abandoned async generators are finalized from whichever task runs ``aclose``. + + The body then resumes in a context that never set the token, so ``ContextVar.reset`` + raises ``ValueError``. That reset cannot succeed from there, and the caller keeps + seeing the span as current, so closing must at least not raise on top of it. + Disabled tracing must behave the same as enabled tracing here. + """ + Scope.set_current_span(None) + + generator = _spanned_stream(new_span) + assert await generator.asend(None) == 1 + await asyncio.create_task(generator.aclose()) + + # The caller's own context still holds the span, which is the documented residue of + # finalizing from another task. Clear it so later tests do not inherit it. + Scope.set_current_span(None) + + +@pytest.mark.parametrize("new_span", _SPAN_FACTORIES) +async def test_explicit_finish_from_another_context_still_raises( + new_span: Callable[[], Span[SpanData]], +) -> None: + """Only ``GeneratorExit`` cleanup tolerates a foreign token. + + An explicit ``finish`` from a context that never set the token is a context-ownership + violation rather than an unavoidable one, so it must surface instead of silently + discarding the saved token. + """ + Scope.set_current_span(None) + + span = new_span() + span.start(mark_as_current=True) + + async def finish_elsewhere() -> None: + with pytest.raises(ValueError): + span.finish(reset_current=True) + + await asyncio.create_task(finish_elsewhere()) + + Scope.set_current_span(None) + + +async def test_generator_close_surfaces_processor_failure() -> None: + """A processor failing during close must not be mistaken for a foreign token. + + ``finish`` calls ``on_span_end`` before resetting the scope, so catching every + ``ValueError`` around the whole call would swallow a processor failure, drop the saved + token, and leave the finished span current for everything that ran afterwards. + """ + Scope.set_current_span(None) + + class FailingProcessor(DummyProcessor): + def on_span_end(self, span: Span[Any]) -> None: + raise ValueError("processor exploded") + + span = SpanImpl( + trace_id="trace-processor-failure", + span_id="span-processor-failure", + parent_id=None, + processor=cast(Any, FailingProcessor()), + span_data=AgentSpanData(name="processor-failure"), + tracing_api_key=None, + ) + + async def stream() -> AsyncGenerator[int, None]: + with span: + yield 1 + + generator = stream() + assert await generator.asend(None) == 1 + + with pytest.raises(ValueError, match="processor exploded"): + await generator.aclose() + + Scope.set_current_span(None) From 7e5b3e076eda7f6ebc4bfa771c623c8ece45d3f3 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:11:09 -0700 Subject: [PATCH 189/473] fix: copy raw_responses when building a RunState (#4237) --- src/agents/result.py | 2 +- tests/test_run_state.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/agents/result.py b/src/agents/result.py index 40f92b7a0a..bb6f4ef4a9 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -131,7 +131,7 @@ def _populate_state_from_result( snapshot_refs, ) state._nested_history_owned_session_item_refs = live_refs - state._model_responses = result.raw_responses + state._model_responses = list(result.raw_responses) state._input_guardrail_results = result.input_guardrail_results state._output_guardrail_results = result.output_guardrail_results state._tool_input_guardrail_results = result.tool_input_guardrail_results diff --git a/tests/test_run_state.py b/tests/test_run_state.py index ad3b3a839c..35c3d450b3 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3177,6 +3177,27 @@ async def test_resume_from_run_state(self): assert result2.final_output == "Second response" + @pytest.mark.asyncio + async def test_resume_from_run_state_does_not_mutate_source_result(self): + """Resuming from a state must not append to the raw_responses already returned.""" + model = FakeModel() + agent = Agent(name="TestAgent", model=model) + + model.set_next_output([get_text_message("First response")]) + result1 = await Runner.run(agent, "First input") + assert len(result1.raw_responses) == 1 + + state = result1.to_state() + + model.set_next_output([get_text_message("Second response")]) + result2 = await Runner.run(agent, state) + + # The second run accumulates on top of the first, but the RunResult that was + # already handed back to the caller must keep only its own response. + assert len(result2.raw_responses) == 2 + assert len(result1.raw_responses) == 1 + assert result1.raw_responses is not result2.raw_responses + @pytest.mark.asyncio async def test_resume_from_run_state_with_context(self): """Test resuming a run from a RunState with context override.""" From aad96a7c1131527d2dfb10d00e56a05fec15c1f2 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:11:53 -0700 Subject: [PATCH 190/473] fix(tools): annotate the bare tool guardrail decorator overloads (#4238) --- src/agents/tool_guardrails.py | 8 ++++---- tests/test_decorators.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/agents/tool_guardrails.py b/src/agents/tool_guardrails.py index db308d20f1..613d5dbcec 100644 --- a/src/agents/tool_guardrails.py +++ b/src/agents/tool_guardrails.py @@ -212,11 +212,11 @@ async def run(self, data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutpu @overload -def tool_input_guardrail(func: _ToolInputFuncSync): ... +def tool_input_guardrail(func: _ToolInputFuncSync) -> ToolInputGuardrail[Any]: ... @overload -def tool_input_guardrail(func: _ToolInputFuncAsync): ... +def tool_input_guardrail(func: _ToolInputFuncAsync) -> ToolInputGuardrail[Any]: ... @overload @@ -248,11 +248,11 @@ def decorator(f: _ToolInputFuncSync | _ToolInputFuncAsync) -> ToolInputGuardrail @overload -def tool_output_guardrail(func: _ToolOutputFuncSync): ... +def tool_output_guardrail(func: _ToolOutputFuncSync) -> ToolOutputGuardrail[Any]: ... @overload -def tool_output_guardrail(func: _ToolOutputFuncAsync): ... +def tool_output_guardrail(func: _ToolOutputFuncAsync) -> ToolOutputGuardrail[Any]: ... @overload diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 567300da2f..785ee6c2dc 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,4 +1,5 @@ import types +from typing import Any from typing_extensions import assert_type @@ -6,6 +7,7 @@ import agents.tool as tool_module from agents import ( FunctionTool, + ToolGuardrailFunctionOutput, function_tool, input_guardrail, output_guardrail, @@ -13,6 +15,12 @@ tool_output_guardrail, ) from agents.decorators import function_tool as decorators_function_tool, tool +from agents.tool_guardrails import ( + ToolInputGuardrail, + ToolInputGuardrailData, + ToolOutputGuardrail, + ToolOutputGuardrailData, +) def test_decorator_module_preserves_existing_imports_and_identities() -> None: @@ -40,3 +48,30 @@ async def configured_alias() -> str: assert_type(configured_alias, FunctionTool) assert bare_alias.name == "bare_alias" assert configured_alias.name == "configured_alias" + + +def test_tool_guardrail_decorators_keep_their_type_in_bare_form() -> None: + @tool_input_guardrail + def bare_input(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow() + + @tool_input_guardrail(name="configured_input") + def configured_input(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow() + + @tool_output_guardrail + def bare_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow() + + @tool_output_guardrail(name="configured_output") + def configured_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow() + + assert_type(bare_input, ToolInputGuardrail[Any]) + assert_type(configured_input, ToolInputGuardrail[Any]) + assert_type(bare_output, ToolOutputGuardrail[Any]) + assert_type(configured_output, ToolOutputGuardrail[Any]) + assert bare_input.get_name() == "bare_input" + assert configured_input.get_name() == "configured_input" + assert bare_output.get_name() == "bare_output" + assert configured_output.get_name() == "configured_output" From 0f4acc1cb9b8e36698ce0421fd3e6afa809aac3a Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:18:16 -0700 Subject: [PATCH 191/473] fix(tracing): catch only the context reset on generator close (#4232) --- src/agents/tracing/traces.py | 25 ++++++++++++++------ tests/tracing/test_traces_impl.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/agents/tracing/traces.py b/src/agents/tracing/traces.py index 591d1f3980..a703acf317 100644 --- a/src/agents/tracing/traces.py +++ b/src/agents/tracing/traces.py @@ -26,15 +26,26 @@ def _finish_on_generator_exit(trace: Trace) -> None: finalizing cannot rewrite the caller's context, and the caller keeps seeing this trace as current until its own scope ends. Raising would only add a crash on top of that. - The tolerance is deliberately limited to this path. An explicit ``finish`` from the - wrong context is a context-ownership violation rather than an unavoidable one, so it - still raises. + The tolerance is deliberately limited to this path, and within it to the reset itself. + An explicit ``finish`` from the wrong context is a context-ownership violation rather + than an unavoidable one, so it still raises, and a processor that fails during + ``finish`` still surfaces rather than being mistaken for a foreign token. + + The reset runs in a ``finally`` so that a failing ``finish`` still releases the scope + instead of leaving the finished trace current, and the token is cleared only once the + reset has either succeeded or hit the foreign-context ``ValueError`` it expects. An + unexpected reset failure therefore leaves the handle in place rather than losing it. """ try: - trace.finish(reset_current=True) - except ValueError: - logger.debug("Skipping trace context reset, token belongs to another context") - trace._prev_context_token = None # type: ignore[attr-defined] + trace.finish(reset_current=False) + finally: + token: contextvars.Token[Trace | None] | None = trace._prev_context_token # type: ignore[attr-defined] + if token is not None: + try: + Scope.reset_current_trace(token) + except ValueError: + logger.debug("Skipping trace context reset, token belongs to another context") + trace._prev_context_token = None # type: ignore[attr-defined] class Trace(abc.ABC): diff --git a/tests/tracing/test_traces_impl.py b/tests/tracing/test_traces_impl.py index fc24580dea..dbf4a5f82e 100644 --- a/tests/tracing/test_traces_impl.py +++ b/tests/tracing/test_traces_impl.py @@ -237,3 +237,42 @@ def test_reattached_trace_restores_scope_without_reemitting_processor_events() - assert processor.started == ["trace-123"] assert processor.ended == ["trace-123"] assert Scope.get_current_trace() is None + + +async def test_generator_close_surfaces_processor_failure() -> None: + """A processor failing during close must not be mistaken for a foreign token. + + ``finish`` calls ``on_trace_end`` before resetting the scope, so catching every + ``ValueError`` around the whole call would swallow a processor failure, drop the saved + token, and leave the finished trace current for everything that ran afterwards. + """ + Scope.set_current_trace(None) + + class FailingProcessor(DummyProcessor): + def on_trace_end(self, trace: Trace) -> None: + raise ValueError("processor exploded") + + trace = TraceImpl( + name="processor-failure", + trace_id="trace-processor-failure", + group_id=None, + metadata=None, + processor=cast(Any, FailingProcessor()), + ) + + async def stream() -> AsyncGenerator[int, None]: + with trace: + yield 1 + + generator = stream() + assert await generator.asend(None) == 1 + + with pytest.raises(ValueError, match="processor exploded"): + await generator.aclose() + + # The processor failure is the one that propagates, and the scope is still released: + # running the reset in a finally keeps a failing finish from leaving the trace current. + assert Scope.get_current_trace() is None + assert trace._prev_context_token is None + + Scope.set_current_trace(None) From 37b7a035b1ce877a63daaaa0b1cbef226ba51e85 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Thu, 6 Aug 2026 05:53:26 +0530 Subject: [PATCH 192/473] fix: keep empty turns in the nested conversation history (#4230) --- src/agents/handoffs/history.py | 35 ++++- tests/test_handoff_history_duplication.py | 179 +++++++++++++++++++++- 2 files changed, 210 insertions(+), 4 deletions(-) diff --git a/src/agents/handoffs/history.py b/src/agents/handoffs/history.py index fad8555ac5..9b14dbc7a5 100644 --- a/src/agents/handoffs/history.py +++ b/src/agents/handoffs/history.py @@ -429,7 +429,9 @@ def _format_transcript_item_legacy(item: TResponseInputItem) -> str: if isinstance(name, str) and name: prefix = f"{prefix} ({name})" content_str = _stringify_content(item.get("content")) - return f"{prefix}: {content_str}" if content_str else prefix + # Always emit the separator. A bare role has no record separator for the parser to + # find, so the turn is dropped when the summary is flattened on the next handoff. + return f"{prefix}: {content_str}" item_type = item.get("type", "item") rest = {k: v for k, v in item.items() if k not in ("type", "provider_data")} @@ -535,7 +537,21 @@ def _parse_summary_line(line: str) -> TResponseInputItem | None: role_part, sep, remainder = stripped.partition(":") if not sep: - return None + # Summaries written before the separator was always emitted record an empty turn as + # a bare role, so recover those instead of dropping the turn. Restricted to a lone + # role token, optionally with a "(name)" suffix, so prose inside the block is still + # rejected rather than turning into a fabricated message. + if not _is_bare_role_record(stripped): + return None + role, name = _split_role_and_name(stripped) + recovered: dict[str, Any] = {"role": role} + if name: + recovered["name"] = name + # Keep an explicit empty content. Adapters such as the Chat Completions converter + # only recognize a message when both keys are present, so a role-only item is not + # replayable. + recovered["content"] = "" + return cast(TResponseInputItem, recovered) role_text = role_part.strip() if not role_text: return None @@ -548,7 +564,9 @@ def _parse_summary_line(line: str) -> TResponseInputItem | None: legacy_typed_item = _parse_legacy_typed_item(role, content) if legacy_typed_item is not None: return legacy_typed_item - reconstructed["content"] = content + # Set content even when empty, so a turn that carried none stays a replayable message + # rather than a role-only item that adapters do not recognize. + reconstructed["content"] = content return cast(TResponseInputItem, reconstructed) @@ -591,6 +609,17 @@ def _strip_transcript_item_metadata(item: TResponseInputItem) -> TResponseInputI return strip_internal_input_item_metadata(item) +_KNOWN_TRANSCRIPT_ROLES = frozenset({"user", "assistant", "system", "developer"}) + + +def _is_bare_role_record(stripped: str) -> bool: + """Return whether a separator-less record is a lone role, optionally with a name.""" + candidate = stripped + if candidate.endswith(")") and "(" in candidate: + candidate = candidate[: candidate.rfind("(")].strip() + return candidate in _KNOWN_TRANSCRIPT_ROLES + + def _split_role_and_name(role_text: str) -> tuple[str, str | None]: if role_text.endswith(")") and "(" in role_text: open_idx = role_text.rfind("(") diff --git a/tests/test_handoff_history_duplication.py b/tests/test_handoff_history_duplication.py index 47dbb02aa4..bdd03fe4e7 100644 --- a/tests/test_handoff_history_duplication.py +++ b/tests/test_handoff_history_duplication.py @@ -38,7 +38,11 @@ reset_conversation_history_wrappers, set_conversation_history_wrappers, ) -from agents.handoffs.history import _get_nested_history_owned_items +from agents.handoffs.history import ( + _extract_nested_history_transcript, + _get_nested_history_owned_items, + get_conversation_history_wrappers, +) from agents.items import ( HandoffCallItem, HandoffOutputItem, @@ -49,6 +53,7 @@ ToolCallOutputItem, TResponseInputItem, ) +from agents.models.chatcmpl_converter import Converter from agents.result import RunResult, RunResultStreaming from agents.run_internal.items import ( NestedHistoryOwnedItem, @@ -2298,3 +2303,175 @@ def approval_tool() -> str: expected_occurrences = 2 if legacy_snapshot else 1 assert replay_types.count("tool_search_call") == expected_occurrences assert replay_types.count("tool_search_output") == expected_occurrences + + +@pytest.mark.parametrize( + "empty_item", + [ + {"role": "user", "content": ""}, + {"role": "assistant", "content": ""}, + {"role": "user", "content": None}, + {"role": "user", "name": "bob", "content": ""}, + ], + ids=["user_empty", "assistant_empty", "user_none", "named_empty"], +) +def test_nested_history_keeps_turns_with_no_content(empty_item: dict[str, Any]) -> None: + """A turn with no content must survive being summarized and flattened again. + + An empty turn was rendered as a bare role with no separator, and the parser that + flattens the summary on the next handoff requires one, so the turn was dropped. The + next agent then saw a transcript with a turn missing, which also breaks the + user/assistant alternation. + """ + history = ( + {"role": "user", "content": "first question"}, + empty_item, + {"role": "user", "content": "second question"}, + ) + data = HandoffInputData( + input_history=cast(Any, history), + pre_handoff_items=(), + new_items=(), + run_context=None, + ) + + nested = nest_handoff_history(data) + transcript = _extract_nested_history_transcript(cast(Any, nested.input_history)[0]) + + assert transcript is not None + assert len(transcript) == len(history) + assert [item.get("role") for item in transcript] == ["user", empty_item["role"], "user"] + + +def test_nested_history_survives_repeated_handoffs() -> None: + """Flattening and re-nesting must not shed the empty turn on each hop.""" + history = ( + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": ""}, + {"role": "user", "content": "second question"}, + ) + data = HandoffInputData( + input_history=cast(Any, history), + pre_handoff_items=(), + new_items=(), + run_context=None, + ) + + nested = nest_handoff_history(data) + for _ in range(3): + nested = nest_handoff_history(nested) + transcript = _extract_nested_history_transcript(cast(Any, nested.input_history)[0]) + assert transcript is not None + assert len(transcript) == len(history) + + +def test_bare_role_record_from_an_older_summary_is_recovered() -> None: + """Summaries written before the separator was always emitted must still flatten.""" + start_marker, end_marker = get_conversation_history_wrappers() + legacy = { + "role": "assistant", + "content": "\n".join( + [ + "For context, here is the conversation so far between the user and the " + "previous agent:", + start_marker, + "1. user: first question", + "2. assistant", + "3. user: second question", + end_marker, + ] + ), + } + + transcript = _extract_nested_history_transcript(cast(Any, legacy)) + + assert transcript is not None + assert [item.get("role") for item in transcript] == ["user", "assistant", "user"] + + +def test_prose_inside_the_summary_block_is_still_rejected() -> None: + """The bare-role recovery must not turn arbitrary text into a fabricated turn. + + The prose is numbered so it becomes its own record. An unnumbered line is folded into + the previous record as continuation text and never reaches the rejection branch. + """ + start_marker, end_marker = get_conversation_history_wrappers() + with_prose = { + "role": "assistant", + "content": "\n".join( + [ + "For context, here is the conversation so far between the user and the " + "previous agent:", + start_marker, + "1. user: first question", + "2. some stray prose with no separator", + "3. user: second question", + end_marker, + ] + ), + } + + transcript = _extract_nested_history_transcript(cast(Any, with_prose)) + + assert transcript is not None + assert [item.get("role") for item in transcript] == ["user", "user"] + assert [item.get("content") for item in transcript] == [ + "first question", + "second question", + ] + + +def test_recovered_empty_turn_keeps_explicit_content() -> None: + """A recovered turn must carry content, not just a role. + + Adapters such as the Chat Completions converter only recognize a message when both + keys are present, so a role-only item is not replayable. + """ + start_marker, end_marker = get_conversation_history_wrappers() + legacy = { + "role": "assistant", + "content": "\n".join( + [ + "For context, here is the conversation so far between the user and the " + "previous agent:", + start_marker, + "1. user: first question", + "2. assistant", + "3. assistant: ", + end_marker, + ] + ), + } + + transcript = _extract_nested_history_transcript(cast(Any, legacy)) + + assert transcript is not None + # Both the recovered bare role and the separator-only record keep empty content. + assert transcript[1] == {"role": "assistant", "content": ""} + assert transcript[2] == {"role": "assistant", "content": ""} + + +def test_second_pass_nesting_keeps_empty_turns_provider_valid() -> None: + """After a second handoff the empty turn must still convert for a real adapter.""" + history = ( + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": ""}, + {"role": "user", "content": "second question"}, + ) + data = HandoffInputData( + input_history=cast(Any, history), + pre_handoff_items=(), + new_items=(), + run_context=None, + ) + + nested = nest_handoff_history(nest_handoff_history(data)) + transcript = _extract_nested_history_transcript(cast(Any, nested.input_history)[0]) + + assert transcript is not None + assert transcript[1] == {"role": "assistant", "content": ""} + + # The reconstructed transcript must convert through a supported adapter. + messages = Converter.items_to_messages(cast(Any, transcript)) + assert [message["role"] for message in messages] == ["user", "assistant", "user"] + assert messages[1].get("content") == "" From 36d50b014a92d09c9f667bf95bfc26c2f22920ca Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:27:36 -0700 Subject: [PATCH 193/473] fix: keep tool guardrail results when a resumed run interrupts again (#4239) --- src/agents/run.py | 14 +-- tests/test_runner_guardrail_resume.py | 138 +++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index e20c01df45..e163f8bf16 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1015,6 +1015,12 @@ def _finalize_result(result: RunResult) -> RunResult: append_model_response_if_new( model_responses, turn_result.model_response ) + tool_input_guardrail_results.extend( + turn_result.tool_input_guardrail_results + ) + tool_output_guardrail_results.extend( + turn_result.tool_output_guardrail_results + ) processed_response_for_state = resolve_processed_response( run_state=run_state, processed_response=turn_result.processed_response, @@ -1035,12 +1041,8 @@ def _finalize_result(result: RunResult) -> RunResult: model_responses=model_responses, current_agent=current_agent, input_guardrail_results=input_guardrail_results, - tool_input_guardrail_results=( - turn_result.tool_input_guardrail_results - ), - tool_output_guardrail_results=( - turn_result.tool_output_guardrail_results - ), + tool_input_guardrail_results=tool_input_guardrail_results, + tool_output_guardrail_results=tool_output_guardrail_results, context_wrapper=context_wrapper, interruptions=approvals_from_step(turn_result.next_step), processed_response=processed_response_for_state, diff --git a/tests/test_runner_guardrail_resume.py b/tests/test_runner_guardrail_resume.py index b7c0684612..f2d928f717 100644 --- a/tests/test_runner_guardrail_resume.py +++ b/tests/test_runner_guardrail_resume.py @@ -1,13 +1,19 @@ -from typing import Any +from types import SimpleNamespace +from typing import Any, cast import pytest +from openai.types.responses import ResponseFunctionToolCall import agents.run as run_module from agents import Agent, Runner from agents.guardrail import GuardrailFunctionOutput, InputGuardrail, InputGuardrailResult -from agents.items import ModelResponse +from agents.items import ModelResponse, ToolApprovalItem from agents.run_context import RunContextWrapper -from agents.run_internal.run_steps import NextStepFinalOutput, SingleStepResult +from agents.run_internal.run_steps import ( + NextStepFinalOutput, + NextStepInterruption, + SingleStepResult, +) from agents.run_state import RunState from agents.tool_guardrails import ( AllowBehavior, @@ -150,3 +156,129 @@ async def fake_initialize_computer_tools( "state_tool_output_guardrail", "new_tool_output_guardrail", ] + + +@pytest.mark.asyncio +async def test_runner_resume_preserves_guardrail_results_on_reinterruption( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A resumed run that interrupts again must keep the tool guardrail results it carried in.""" + agent = Agent(name="agent", model=FakeModel()) + context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={}) + + tool_input_guardrail: ToolInputGuardrail[Any] = ToolInputGuardrail( + guardrail_function=lambda data: ToolGuardrailFunctionOutput( + output_info={"source": "state"}, + behavior=AllowBehavior(type="allow"), + ), + name="state_tool_input_guardrail", + ) + tool_output_guardrail: ToolOutputGuardrail[Any] = ToolOutputGuardrail( + guardrail_function=lambda data: ToolGuardrailFunctionOutput( + output_info={"source": "state"}, + behavior=AllowBehavior(type="allow"), + ), + name="state_tool_output_guardrail", + ) + initial_tool_input_result = ToolInputGuardrailResult( + guardrail=tool_input_guardrail, + output=ToolGuardrailFunctionOutput( + output_info={"source": "state"}, + behavior=AllowBehavior(type="allow"), + ), + ) + initial_tool_output_result = ToolOutputGuardrailResult( + guardrail=tool_output_guardrail, + output=ToolGuardrailFunctionOutput( + output_info={"source": "state"}, + behavior=AllowBehavior(type="allow"), + ), + ) + + model_response = ModelResponse(output=[], usage=Usage(), response_id="resp-interrupted") + processed_response = cast(Any, SimpleNamespace(tools_used=[], new_items=[])) + + run_state = RunState( + context=context_wrapper, + original_input="hello", + starting_agent=agent, + max_turns=3, + ) + run_state._tool_input_guardrail_results = [initial_tool_input_result] + run_state._tool_output_guardrail_results = [initial_tool_output_result] + run_state._model_responses = [model_response] + run_state._last_processed_response = processed_response + + pending_approval = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + id="call-pending", + call_id="call-pending", + name="pending_tool", + arguments="{}", + type="function_call", + ), + ) + run_state._current_step = NextStepInterruption(interruptions=[pending_approval]) + + new_tool_input_result = ToolInputGuardrailResult( + guardrail=ToolInputGuardrail( + guardrail_function=lambda data: ToolGuardrailFunctionOutput( + output_info={"source": "new"}, + behavior=AllowBehavior(type="allow"), + ), + name="new_tool_input_guardrail", + ), + output=ToolGuardrailFunctionOutput( + output_info={"source": "new"}, + behavior=AllowBehavior(type="allow"), + ), + ) + new_tool_output_result = ToolOutputGuardrailResult( + guardrail=ToolOutputGuardrail( + guardrail_function=lambda data: ToolGuardrailFunctionOutput( + output_info={"source": "new"}, + behavior=AllowBehavior(type="allow"), + ), + name="new_tool_output_guardrail", + ), + output=ToolGuardrailFunctionOutput( + output_info={"source": "new"}, + behavior=AllowBehavior(type="allow"), + ), + ) + + async def fake_resolve_interrupted_turn(**_: object) -> SingleStepResult: + return SingleStepResult( + original_input="hello", + model_response=model_response, + pre_step_items=[], + new_step_items=[], + next_step=NextStepInterruption(interruptions=[pending_approval]), + tool_input_guardrail_results=[new_tool_input_result], + tool_output_guardrail_results=[new_tool_output_result], + ) + + async def fake_get_all_tools(*_: object, **__: object) -> list[object]: + return [] + + async def fake_initialize_computer_tools( + *args: object, tools: list[object], **kwargs: object + ) -> list[object]: + return tools + + monkeypatch.setattr(run_module, "resolve_interrupted_turn", fake_resolve_interrupted_turn) + monkeypatch.setattr(run_module, "get_all_tools", fake_get_all_tools) + monkeypatch.setattr(run_module, "initialize_computer_tools", fake_initialize_computer_tools) + + result = await Runner.run(agent, run_state) + + assert result.interruptions + assert [res.guardrail.get_name() for res in result.tool_input_guardrail_results] == [ + "state_tool_input_guardrail", + "new_tool_input_guardrail", + ] + assert [res.guardrail.get_name() for res in result.tool_output_guardrail_results] == [ + "state_tool_output_guardrail", + "new_tool_output_guardrail", + ] From 065feebfd8d9eb913612c0b82d72ba52c1726982 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 Aug 2026 09:55:12 +0900 Subject: [PATCH 194/473] docs: document API Fast mode --- docs/models/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/index.md b/docs/models/index.md index 819e02582a..65522451b9 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -478,7 +478,7 @@ Server-side compaction is different from [`OpenAIResponsesCompactionSession`][ag Use `extra_args` when you need provider-specific or newer request fields that the SDK does not expose directly at the top level yet. -Also, when you use OpenAI's Responses API, [there are a few other optional parameters](https://platform.openai.com/docs/api-reference/responses/create) (e.g., `user`, `service_tier`, and so on). If they are not available at the top level, you can use `extra_args` to pass them as well. Do not also set the same request field through a direct `ModelSettings` field. +When you use an OpenAI model, `extra_args` can pass optional parameters to both the Responses API and Chat Completions API (for example, `user` and `service_tier`). For supported models, set `extra_args={"service_tier": "fast"}` to use [Fast mode](https://developers.openai.com/api/docs/guides/fast-mode); `"priority"` remains equivalent. Do not also set the same request field through a direct `ModelSettings` field. ```python from agents import Agent, ModelSettings From b47a0e4be785ee8c09ae56250315d6cb8c145f79 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 Aug 2026 10:18:59 +0900 Subject: [PATCH 195/473] docs: update translated pages --- docs/ja/config.md | 87 +++++++----- docs/ja/guardrails.md | 56 ++++---- docs/ja/models/index.md | 272 ++++++++++++++++++------------------- docs/ko/config.md | 83 +++++++----- docs/ko/guardrails.md | 52 ++++---- docs/ko/models/index.md | 283 ++++++++++++++++++++------------------- docs/zh/config.md | 91 ++++++++----- docs/zh/guardrails.md | 54 ++++---- docs/zh/models/index.md | 287 ++++++++++++++++++++-------------------- 9 files changed, 666 insertions(+), 599 deletions(-) diff --git a/docs/ja/config.md b/docs/ja/config.md index 001deeb473..98932d11c6 100644 --- a/docs/ja/config.md +++ b/docs/ja/config.md @@ -4,21 +4,40 @@ search: --- # 設定 -このページでは、デフォルトの OpenAI キーまたはクライアント、デフォルトの OpenAI API の形式、トレーシングエクスポートのデフォルト、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 +このページでは、デフォルトの OpenAI キーやクライアント、デフォルトの OpenAI API 形式、トレーシングのエクスポート設定、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 -これらのデフォルトはサンドボックスベースのワークフローにも引き続き適用されますが、サンドボックスワークスペース、サンドボックスクライアント、セッションの再利用は別途設定します。 +これらのデフォルトはサンドボックスベースのワークフローにも適用されますが、サンドボックスのワークスペース、サンドボックスクライアント、セッションの再利用は個別に設定します。 -代わりに特定のエージェントまたは実行を設定する必要がある場合は、まず次を参照してください: +代わりに特定のエージェントや実行を設定する必要がある場合は、以下を参照してください。 -- [エージェント](agents.md): 通常の `Agent` の instructions、tools、出力タイプ、ハンドオフ、ガードレールについて。 -- [エージェントの実行](running_agents.md): `RunConfig`、セッション、会話状態オプションについて。 -- [サンドボックスエージェント](sandbox/guide.md): `SandboxRunConfig`、マニフェスト、ケイパビリティ、サンドボックスクライアント固有のワークスペース設定について。 -- [モデル](models/index.md): モデル選択とプロバイダー設定について。 -- [トレーシング](tracing.md): 実行ごとのトレーシングメタデータとカスタムトレースプロセッサーについて。 +- 標準的な `Agent` の instructions、tools、出力型、ハンドオフ、ガードレールについては、[エージェント](agents.md)を参照してください。 +- `RunConfig`、セッション、会話状態のオプションについては、[エージェントの実行](running_agents.md)を参照してください。 +- `SandboxRunConfig`、マニフェスト、機能、サンドボックスクライアント固有のワークスペース設定については、[サンドボックスエージェント](sandbox/guide.md)を参照してください。 +- モデルの選択とプロバイダーの設定については、[モデル](models/index.md)を参照してください。 +- 実行ごとのトレーシングメタデータとカスタムトレースプロセッサーについては、[トレーシング](tracing.md)を参照してください。 + +## 設定オブジェクトと辞書 + +SDK が管理する設定パラメーターは通常、型付き設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け入れます。これは、型アノテーションに辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各設定境界に適用されます。ネストされた SDK 管理の設定でも辞書を使用できます。 + +```python +from agents import Agent + +agent = Agent( + name="Assistant", + model="gpt-5.6-sol", + model_settings={ + "reasoning": {"effort": "high"}, + "verbosity": "low", + }, +) +``` + +SDK はこれらの辞書を対応する設定オブジェクトに正規化します。SDK が管理する dataclass 設定に不明なフィールドがあると `TypeError` が発生するため、オプション名の入力ミスを早期に検出できます。特定の設定境界が辞書を受け入れるかどうかを確認するには、パラメーターの型アノテーションまたは API リファレンスを参照してください。 ## API キーとクライアント -デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。このキーは、SDK が初めて OpenAI クライアントを作成するときに解決されます(遅延初期化)。そのため、最初のモデル呼び出しの前に環境変数を設定してください。アプリの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 +デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が初めて OpenAI クライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリケーションの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 ```python from agents import set_default_openai_key @@ -26,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -また、使用する OpenAI クライアントを設定することもできます。デフォルトでは、SDK は環境変数の API キー、または上で設定したデフォルトキーを使用して、`AsyncOpenAI` インスタンスを作成します。これは [set_default_openai_client()][agents.set_default_openai_client] 関数を使用して変更できます。 +また、使用する OpenAI クライアントを設定することもできます。デフォルトでは、SDK は環境変数の API キー、または上記で設定したデフォルトキーを使用して `AsyncOpenAI` インスタンスを作成します。[set_default_openai_client()][agents.set_default_openai_client] 関数を使用すると、これを変更できます。 ```python from openai import AsyncOpenAI @@ -36,14 +55,14 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -環境変数ベースのエンドポイント設定を使用したい場合、デフォルトの OpenAI プロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses WebSocket トランスポートを有効にすると、WebSocket の `/responses` エンドポイント用に `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 +環境変数によるエンドポイント設定を使用する場合、デフォルトの OpenAI プロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses の WebSocket トランスポートを有効にすると、WebSocket の `/responses` エンドポイント用に `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -最後に、使用される OpenAI API もカスタマイズできます。デフォルトでは OpenAI Responses API を使用します。[set_default_openai_api()][agents.set_default_openai_api] 関数を使用すると、これを上書きして Chat Completions API を使用できます。 +最後に、使用する OpenAI API をカスタマイズすることもできます。デフォルトでは、OpenAI Responses API を使用します。[set_default_openai_api()][agents.set_default_openai_api] 関数を使用すると、これを上書きして Chat Completions API を使用できます。 ```python from agents import set_default_openai_api @@ -53,7 +72,7 @@ set_default_openai_api("chat_completions") ## OpenAI プロバイダーのデフォルト -OpenAI を基盤とするプロバイダーは、モデル名を解決するときにも SDK 全体のデフォルトを読み取ります。OpenAI Responses モデルがデフォルトで WebSocket トランスポートを使用するようにするには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します: +OpenAI ベースのプロバイダーも、モデル名を解決するときに SDK 全体のデフォルトを読み取ります。OpenAI Responses モデルでデフォルトで WebSocket トランスポートを使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 ```python from agents import set_default_openai_responses_transport @@ -61,9 +80,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデルに影響します。プロバイダーレベルのセットアップ、接続の再利用、キープアライブオプション、カスタム WebSocket エンドポイントについては、[Responses WebSocket トランスポート](models/index.md#responses-websocket-transport) を参照してください。 +これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデルに影響します。プロバイダーレベルの設定、接続の再利用、キープアライブオプション、カスタム WebSocket エンドポイントについては、[Responses WebSocket トランスポート](models/index.md#responses-websocket-transport)を参照してください。 -OpenAI セットアップでプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度設定してください: +OpenAI の設定でプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度設定します。 ```python from agents import set_default_openai_harness @@ -71,7 +90,7 @@ from agents import set_default_openai_harness set_default_openai_harness("your-harness-id") ``` -完全な登録オブジェクトを渡すこともできます: +完全な登録オブジェクトを渡すこともできます。 ```python from agents import OpenAIAgentRegistrationConfig, set_default_openai_agent_registration @@ -81,11 +100,11 @@ set_default_openai_agent_registration( ) ``` -SDK のデフォルトが設定されていない場合、OpenAI を基盤とするプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、`agent_harness_id` が `RunConfig.trace_metadata` にすでに存在する場合を除き、SDK はそれを `agent_harness_id` としてトレースメタデータに追加します。 +SDK のデフォルトが設定されていない場合、OpenAI ベースのプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、そのキーが `RunConfig.trace_metadata` にすでに存在しない限り、SDK はトレースメタデータに `agent_harness_id` として追加します。 ## トレーシング -トレーシングはデフォルトで有効です。デフォルトでは、上のセクションで説明したモデルリクエストと同じ OpenAI API キー(つまり、環境変数または設定したデフォルトキー)を使用します。トレーシングに使用する API キーは、[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用して個別に設定できます。 +トレーシングはデフォルトで有効です。デフォルトでは、上記のセクションにあるモデルリクエストと同じ OpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用すると、トレーシングに使用する API キーを個別に設定できます。 ```python from agents import set_tracing_export_api_key @@ -93,7 +112,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -モデルのトラフィックではあるキーまたはクライアントを使用し、トレーシングでは別の OpenAI キーを使用したい場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡し、そのうえでトレーシングを個別に設定してください。カスタムクライアントを使用していない場合は、同じパターンを [`set_default_openai_key()`][agents.set_default_openai_key] にも適用できます。 +モデルの通信ではあるキーまたはクライアントを使用し、トレーシングでは別の OpenAI キーを使用する必要がある場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡してから、トレーシングを個別に設定します。カスタムクライアントを使用していない場合は、[`set_default_openai_key()`][agents.set_default_openai_key] でも同じ方法を使用できます。 ```python from openai import AsyncOpenAI @@ -108,14 +127,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -デフォルトエクスポーターを使用するときに、トレースを特定の組織またはプロジェクトに関連付ける必要がある場合は、アプリの起動前に次の環境変数を設定してください: +デフォルトのエクスポーターを使用する際に、トレースを特定の組織またはプロジェクトに関連付ける必要がある場合は、アプリケーションの起動前に以下の環境変数を設定します。 ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -グローバルエクスポーターを変更せずに、実行ごとにトレーシング API キーを設定することもできます。 +グローバルエクスポーターを変更せずに、実行ごとにトレーシング用の API キーを設定することもできます。 ```python from agents import Runner, RunConfig @@ -127,7 +146,7 @@ await Runner.run( ) ``` -[`set_tracing_disabled()`][agents.set_tracing_disabled] 関数を使用して、トレーシングを完全に無効化することもできます。 +[`set_tracing_disabled()`][agents.set_tracing_disabled] 関数を使用して、トレーシングを完全に無効にすることもできます。 ```python from agents import set_tracing_disabled @@ -135,7 +154,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -トレーシングを有効のままにしつつ、機微情報を含む可能性のある入力/出力をトレースペイロードから除外したい場合は、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定してください: +トレーシングを有効なままにしながら、機密情報を含む可能性がある入出力をトレースペイロードから除外する場合は、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定します。 ```python from agents import Runner, RunConfig @@ -147,19 +166,19 @@ await Runner.run( ) ``` -コードを使わずにデフォルトを変更することもできます。アプリの起動前にこの環境変数を設定してください: +アプリケーションの起動前に以下の環境変数を設定すると、コードを使用せずにデフォルトを変更することもできます。 ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -トレーシング制御の詳細については、[トレーシングガイド](tracing.md) を参照してください。 +トレーシングのすべての制御方法については、[トレーシングガイド](tracing.md)を参照してください。 ## デバッグログ -SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しており、デフォルトではハンドラーをアタッチしません。ログは、アプリケーションの Python ロギング設定に従います。 +SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーを追加しません。ログは、アプリケーションの Python ロギング設定に従います。 -詳細ログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 +詳細なログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 ```python from agents import enable_verbose_stdout_logging @@ -167,7 +186,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -また、ハンドラー、フィルター、フォーマッターなどを追加してログをカスタマイズすることもできます。詳しくは [Python ロギングガイド](https://docs.python.org/3/howto/logging.html) を参照してください。 +また、ハンドラー、フィルター、フォーマッターなどを追加して、ログをカスタマイズすることもできます。詳しくは、[Python ロギングガイド](https://docs.python.org/3/howto/logging.html)を参照してください。 ```python import logging @@ -186,20 +205,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### ログ内の機微データ +### ログと診断情報内の機密データ -一部のログには、機微データ(たとえば、ユーザーデータ)が含まれる場合があります。 +一部のログや診断例外には、機密データ(モデルまたはツールの入出力など)が含まれる場合があります。 -デフォルトでは、SDK は LLM の入力/出力やツールの入力/出力を **ログに記録しません** 。これらの保護は次の項目で制御されます: +デフォルトでは、SDK は LLM の入出力やツールの入出力を **ログに記録しません**。これらの保護は、以下によって制御されます。 ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -デバッグのためにこのデータを一時的に含める必要がある場合は、アプリの起動前にいずれかの変数を `0`(または `false`)に設定してください: +デバッグのために一時的にこのデータを含める必要がある場合は、アプリケーションの起動前にいずれかの変数を `0`(または `false`)に設定します。 ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 -``` \ No newline at end of file +``` + +これらのフラグは、影響を受ける失敗に、ペイロードを含む診断の詳細を保持するかどうかも制御します。たとえば、ツールデータの編集が有効な場合、関数ツールへの無効な引数によって、元の検証エラーを例外チェーンに含まない汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、未加工のモデルまたはツールデータが、ログ、例外メッセージ、例外チェーン、その他の診断コンテキストに露出する可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index b191c8e80e..190462bf48 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -4,73 +4,75 @@ search: --- # ガードレール -ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を実行できます。たとえば、非常に高性能な(そのため低速で高コストな)モデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されるのは避けたいでしょう。そこで、高速で低コストなモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、即座にエラーを発生させ、高コストなモデルの実行を防げるため、時間と費用を節約できます( **ブロッキングガードレールを使用する場合です。並列ガードレールでは、ガードレールが完了する前に、高コストなモデルがすでに実行を開始している可能性があります。詳しくは、以下の「実行モード」を参照してください** )。 +ガードレールを使用すると、ユーザー入力とエージェント出力の検査および検証を行えます。たとえば、非常に高性能である一方、低速でコストの高いモデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーが、数学の宿題を手伝うようモデルに依頼できる状態は避けたいでしょう。そのため、高速で低コストのモデルを使用してガードレールを実行できます。ガードレールが不正利用を検出した場合、ただちにエラーを発生させ、高コストのモデルが実行されるのを防ぐことで、時間と費用を節約できます( **ブロッキングガードレールを使用する場合。並列ガードレールでは、ガードレールが完了する前に高コストのモデルがすでに実行を開始している可能性があります。詳細については、以下の「実行モード」を参照してください** )。 -ガードレールには次の 2 種類があります。 +ガードレールには、次の 2 種類があります。 1. 入力ガードレールは、最初のユーザー入力に対して実行されます 2. 出力ガードレールは、最終的なエージェント出力に対して実行されます ## ワークフローの境界 -ガードレールはエージェントとツールに設定されますが、すべてがワークフロー内の同じ時点で実行されるわけではありません。 +ガードレールはエージェントとツールに関連付けられますが、すべてがワークフロー内の同じ時点で実行されるわけではありません。 - **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 - **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 -- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前に、出力ガードレールは実行後に実行されます。 +- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前、出力ガードレールは実行後に実行されます。 -マネージャー、ハンドオフ、または委任された専門エージェントを含むワークフローで、カスタム関数ツールの各呼び出しをチェックする必要がある場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 +マネージャー、ハンドオフ、または処理を委任されたスペシャリストを含むワークフローで、カスタム関数ツールの呼び出しごとに検査が必要な場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 ## 入力ガードレール 入力ガードレールは、次の 3 ステップで実行されます。 -1. まず、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 -2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答したり、例外を処理したりできます。 +1. 最初に、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 +2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成します。その後、これは [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合は、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 !!! Note - 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初* のエージェントである場合にのみ実行されます。`guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェントに設定されるのはなぜだろうと思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初の* エージェントである場合にのみ実行されます。なぜ `guardrails` プロパティを `Runner.run` に渡すのではなく、エージェントに設定するのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 ### 実行モード -入力ガードレールは、次の 2 つの実行モードをサポートしています。 +入力ガードレールは、次の 2 つの実行モードをサポートします。 -- **並列実行** (デフォルト、`run_in_parallel=True`):ガードレールはエージェントの実行と並行して動作します。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールが失敗した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 +- **並列実行**(デフォルト、`run_in_parallel=True`): ガードレールはエージェントの実行と同時に実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールが不合格になった場合でも、エージェントがキャンセルされる前に、すでにトークンを消費し、ツールを実行している可能性があります。 -- **ブロッキング実行** (`run_in_parallel=False`):ガードレールは、エージェントが開始する *前* に実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防げます。コストを最適化したい場合や、ツール呼び出しによる潜在的な副作用を避けたい場合に最適です。 +- **ブロッキング実行**(`run_in_parallel=False`): ガードレールは、エージェントが開始される *前に* 実行され、完了します。ガードレールのトリップワイヤーがトリガーされた場合、エージェントは実行されないため、トークンの消費とツールの実行を防げます。これは、コストを最適化する場合や、ツール呼び出しによる潜在的な副作用を回避したい場合に最適です。 ## 出力ガードレール 出力ガードレールは、次の 3 ステップで実行されます。 -1. まず、ガードレールはエージェントが生成した出力を受け取ります。 -2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーへ適切に応答したり、例外を処理したりできます。 +1. 最初に、ガードレールはエージェントが生成した出力を受け取ります。 +2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成します。その後、これは [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合は、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 !!! Note - 出力ガードレールは最終的なエージェント出力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最後* のエージェントである場合にのみ実行されます。入力ガードレールと同様に、このようにするのは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 出力ガードレールは最終的なエージェント出力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最後の* エージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 - 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターをサポートしていません。 + 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターはサポートされません。 ## ツールガードレール -ツールガードレールは **関数ツール** をラップし、実行の前後でツール呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 +ツールガードレールは **関数ツール** をラップし、実行前後にツール呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 -- 入力ツールガードレールはツールの実行前に動作し、呼び出しをスキップしたり、出力をメッセージに置き換えたり、トリップワイヤーを作動させたりできます。 -- 出力ツールガードレールはツールの実行後に動作し、出力を置き換えたり、トリップワイヤーを作動させたりできます。 -- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に動作します。保留中の承認による中断が発生する前にこれらの入力チェックを実行する場合は、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定します。この承認前チェックを通過した呼び出しも、承認後かつツールの実行前に再度チェックされます。 -- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)および組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)も、このガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 +- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、出力のメッセージへの置き換え、またはトリップワイヤーの発生が可能です。 +- 出力ツールガードレールはツールの実行後に実行され、出力の置き換えまたはトリップワイヤーの発生が可能です。 +- 関数ツールに承認が必要な場合、通常、入力ツールガードレールは承認後、実行直前に実行されます。保留中の承認による中断が発生する前にこれらの入力検査を実行する場合は、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定します。この承認前検査に合格した呼び出しも、ツールの実行前に承認後の再検査を受けます。 +- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく、SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)と組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)もこのガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 -詳しくは、以下のコードスニペットを参照してください。 +詳細については、以下のコードスニペットを参照してください。 ## トリップワイヤー -入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーを使用してそれを通知できます。トリップワイヤーを作動させたガードレールが検出されると、即座に `{Input,Output}GuardrailTripwireTriggered` 例外が発生し、エージェントの実行が停止します。 +エージェントの入力または出力がガードレール検査に不合格になった場合、ガードレールはトリップワイヤーを使用して通知できます。ランナーはただちに `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を発生させ、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外を使用します。 -例外の `guardrail_result` により、トリップワイヤーを作動させたガードレールを特定できます。ランナーによって入力トリップワイヤーが作動した場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの結果が含まれ、トリップワイヤーを作動させた結果も含まれます。出力トリップワイヤーでは、`exception.run_data.output_guardrail_results` を通じて同様に蓄積された結果が提供されます。`stream_events()` が例外を発生させた後、ストリーミングされた実行結果では、`input_guardrail_results` または `output_guardrail_results` を通じて、同じ完了済みの結果を確認できます。ランナーが管理する実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 +エージェントレベルのトリップワイヤーでは、例外の `guardrail_result` によって、トリップワイヤーをトリガーしたガードレールを特定できます。ランナーによって入力トリップワイヤーが発生した場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの結果が含まれます。これには、トリップワイヤーをトリガーした結果も含まれます。出力トリップワイヤーでは、`exception.run_data.output_guardrail_results` を通じて、同等の累積結果が提供されます。 + +一方、ツールのトリップワイヤー例外では、トリガーした `guardrail` と `output` が直接公開されます。`run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` のリストには、エラーが発生する前に完了したターンで蓄積された結果が保持されます。トリガーした結果は、例外の `output` から取得できます。`MaxTurnsExceeded` など、ランナーによって管理されるその他のエラーでも、完了したツールガードレールの結果がこれらのリストに保持されます。`stream_events()` が例外を発生させた後、ストリーミング結果からも、同じく蓄積されたエージェントおよびツールガードレールの結果リストを取得できます。ランナーによって管理される実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 ## ガードレールの実装 @@ -127,7 +129,7 @@ async def main(): print("Math homework guardrail tripped") ``` -1. このエージェントをガードレール関数で使用します。 +1. ガードレール関数でこのエージェントを使用します。 2. これは、エージェントの入力/コンテキストを受け取り、結果を返すガードレール関数です。 3. ガードレールの結果に追加情報を含めることができます。 4. これは、ワークフローを定義する実際のエージェントです。 @@ -190,7 +192,7 @@ async def main(): 3. これは、エージェントの出力を受け取り、結果を返すガードレール関数です。 4. これは、ワークフローを定義する実際のエージェントです。 -最後に、ツールガードレールの例を示します。 +最後に、ツールガードレールのコード例を示します。 ```python import json diff --git a/docs/ja/models/index.md b/docs/ja/models/index.md index a649fa1cdb..84e06ad40e 100644 --- a/docs/ja/models/index.md +++ b/docs/ja/models/index.md @@ -4,30 +4,30 @@ search: --- # モデル -Agents SDK は、すぐに利用できる OpenAI モデルを 2 種類サポートしています。 +Agents SDKには、OpenAIモデルがすぐに利用できる形で、次の 2 種類用意されています。 -- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] -- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] +- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 +- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 ## モデル設定の選択 -まず、設定に適した最もシンプルな方法を選択してください。 +まず、環境に適した最もシンプルな方法から始めてください。 -| 実現したいこと | 推奨される方法 | 詳細 | +| 目的 | 推奨される方法 | 詳細 | | --- | --- | --- | -| OpenAI モデルのみを使用する | Responses モデルの経路でデフォルトの OpenAI プロバイダーを使用する | [OpenAI モデル](#openai-models) | -| websocket トランスポート経由で OpenAI Responses API を使用する | Responses モデルの経路を維持し、websocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | -| OpenAI がホストするサブエージェントを使用する | 試験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | -| OpenAI 以外のプロバイダーを 1 つ使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI 以外のモデル](#non-openai-models) | -| エージェント間でモデルまたはプロバイダーを組み合わせる | 実行単位またはエージェント単位でプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[プロバイダーをまたぐモデルの組み合わせ](#mixing-models-across-providers) | -| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses の経路で `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | -| OpenAI 以外のプロバイダーまたは複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポート対象のベータ版アダプターを比較し、リリース予定のプロバイダー経路を検証する | [サードパーティ製アダプター](#third-party-adapters) | +| OpenAIモデルのみを使用する | デフォルトの OpenAIプロバイダーを Responses モデル経由で使用する | [OpenAIモデル](#openai-models) | +| OpenAI Responses API を WebSocket トランスポート経由で使用する | Responses モデル経由を維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | +| OpenAIがホストするサブエージェントを使用する | 実験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | +| OpenAI以外のプロバイダーを 1 つ使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI以外のモデル](#non-openai-models) | +| エージェント間でモデルやプロバイダーを組み合わせる | 実行ごと、またはエージェントごとにプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[プロバイダー間でのモデルの組み合わせ](#mixing-models-across-providers) | +| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses 経由で `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | +| OpenAI以外、または複数プロバイダーのルーティングにサードパーティー製アダプターを使用する | サポート対象のベータ版アダプターを比較し、リリース予定のプロバイダー経路を検証する | [サードパーティー製アダプター](#third-party-adapters) | -## OpenAI モデル +## OpenAIモデル -OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルの経路を維持することを推奨します。 +OpenAIのみを使用するほとんどのアプリでは、デフォルトの OpenAIプロバイダーで文字列のモデル名を使用し、Responses モデル経由を維持することを推奨します。 -`Agent` の初期化時にモデルを指定しない場合、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` と `verbosity="low"` を設定した [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。利用可能な場合は、明示的な `model_settings` を維持しながら、品質向上のためにエージェントを `gpt-5.6-sol` に設定することを推奨します。 +`Agent` の初期化時にモデルを指定しない場合、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` および `verbosity="low"` を設定した [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。利用できる場合は、明示的な `model_settings` を維持しながら、より高品質な `gpt-5.6-sol` をエージェントに設定することを推奨します。 `gpt-5.6-sol` などの別のモデルに切り替える場合、エージェントを設定する方法は 2 つあります。 @@ -40,7 +40,7 @@ export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -次に、`RunConfig` を使用して実行のデフォルトモデルを設定できます。エージェントにモデルを設定しない場合、その実行のモデルが使用されます。 +次に、`RunConfig` を使用して実行のデフォルトモデルを設定できます。エージェントにモデルを設定しない場合、この実行のモデルが使用されます。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 モデル -この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースに最適な設定が適用されます。デフォルトモデルの推論エフォートを調整するには、独自の `ModelSettings` を渡します。 +この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースに最適な設定が使用されます。デフォルトモデルの推論 effort を調整するには、独自の `ModelSettings` を渡します。 ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -レイテンシーを低減するには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 +レイテンシーを抑えるには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 -GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、永続化された推論コンテキスト、および `"max"` エフォートレベルもサポートします。これらの制御は Responses API の経路で利用できます。 +GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、永続化された推論コンテキスト、および `"max"` effort レベルもサポートします。これらの制御は Responses API 経由で利用できます。 ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions は `reasoning.effort` のみを使用し、サポートされるエフォートレベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` エフォートには Responses API を使用してください。Chat Completions アダプターは警告を出してモードとコンテキストを無視します。その警告をエラーにするには、OpenAI プロバイダーで `strict_feature_validation=True` を設定します。 +`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされる effort レベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` effort には Responses API を使用してください。Chat Completions アダプターは警告を表示して mode と context を無視します。この警告をエラーにするには、OpenAIプロバイダーで `strict_feature_validation=True` を設定します。 -`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の会話、または以前の推論項目の再送により会話を維持します。ステートレスな `store=False` 呼び出しでは、レスポンスに `reasoning.encrypted_content` を含め、次のリクエストでそれらの推論項目を再送してください。 +`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の会話、または以前の推論項目の再送信によって会話を維持します。ステートレスな `store=False` 呼び出しでは、レスポンスに `reasoning.encrypted_content` を含め、次のリクエストでそれらの推論項目を再送信します。 #### ComputerTool のモデル選択 -エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは GA の組み込み `computer` ツールが使用されますが、明示的な `computer-use-preview` リクエストでは従来の `computer_use_preview` ペイロードが維持されます。 +エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは GA 版の組み込み `computer` ツールが使用されますが、明示的な `computer-use-preview` リクエストでは従来の `computer_use_preview` ペイロードが維持されます。 -主な例外は、プロンプトによって管理される呼び出しです。プロンプトテンプレートがモデルを保持し、SDK がリクエストから `model` を省略する場合、プロンプトに固定されたモデルを SDK が推測しないように、プレビュー互換のコンピューターペイロードがデフォルトで使用されます。このフローで GA の経路を維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制します。 +主な例外は、プロンプト管理の呼び出しです。プロンプトテンプレートがモデルを管理し、SDK がリクエストから `model` を省略する場合、プロンプトが固定するモデルを SDK が推測しないよう、デフォルトでプレビュー互換のコンピューターペイロードが使用されます。このフローで GA 経路を維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制します。 -[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに対応する組み込みセレクターに正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 -プレビュー互換リクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエスト送信前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 +プレビュー互換のリクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエストを送信する前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 #### GPT-5 以外のモデル -カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK は任意のモデルと互換性のある汎用的な `ModelSettings` に戻ります。 +カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はすべてのモデルと互換性のある汎用的な `ModelSettings` に戻します。 ### Responses 専用のツール機能 -次のツール機能は、OpenAI Responses モデルでのみサポートされています。 +次のツール機能は、OpenAI Responses モデルでのみサポートされます。 - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` およびその他の遅延読み込み対応 Responses ツールサーフェス +- `@function_tool(defer_loading=True)` およびその他の遅延読み込み型 Responses ツールサーフェス - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`、および `tool_choice="programmatic_tool_calling"` -これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、修飾されていない名前空間名や遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 +これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、単独の名前空間名や遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 ### Responses WebSocket トランスポート -デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI ベースのモデルを使用する場合は、websocket トランスポートを有効にできます。 +デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAIを基盤とするモデルを使用する場合は、WebSocket トランスポートを有効にできます。 #### 基本設定 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデル(`"gpt-5.6-sol"` などの文字列モデル名を含む)に影響します。 +これは、デフォルトの OpenAIプロバイダーによって解決される OpenAI Responses モデルに影響します。`"gpt-5.6-sol"` などの文字列モデル名も含まれます。 -トランスポートの選択は、SDK がモデル名をモデルインスタンスに解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は websocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡す場合、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 +トランスポートは、SDK がモデル名をモデルインスタンスに解決するときに選択されます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡す場合、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 -#### プロバイダー単位または実行単位の設定 +#### プロバイダーまたは実行レベルの設定 -プロバイダー単位または実行単位で websocket トランスポートを設定することもできます。 +プロバイダーごと、または実行ごとに WebSocket トランスポートを設定することもできます。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI ベースのプロバイダーでは、オプションのエージェント登録設定も使用できます。これは、OpenAI の設定でハーネス ID などのプロバイダー単位の登録メタデータが必要な場合に使用する高度なオプションです。 +OpenAIを基盤とするプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAIの設定でハーネス ID などのプロバイダーレベルの登録メタデータが必要な場合に使用する高度なオプションです。 ```python from agents import ( @@ -188,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider` による高度なルーティング +#### `MultiProvider` を使用した高度なルーティング -プレフィックスベースのモデルルーティングが必要な場合(たとえば、1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合)、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 +プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 -`MultiProvider` には、歴史的なデフォルトが 2 つあります。 +`MultiProvider` は、従来からの 2 つのデフォルト動作を維持します。 -- `openai/...` は OpenAI プロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 +- `openai/...` は OpenAIプロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 - 不明なプレフィックスは、そのまま渡されるのではなく `UserError` を発生させます。 -OpenAI プロバイダーを、リテラルの名前空間付きモデル ID を必要とする OpenAI 互換エンドポイントに接続する場合は、パススルー動作を明示的に有効にします。websocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を設定してください。 +リテラルな名前空間付きモデル ID を必要とする OpenAI互換エンドポイントに OpenAIプロバイダーを接続する場合は、パススルー動作を明示的に有効にしてください。WebSocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を維持します。 ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,31 +223,31 @@ result = await Runner.run( ) ``` -バックエンドがリテラルの `openai/...` 文字列を必要とする場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を必要とする場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、websocket トランスポート外の `MultiProvider` でも機能します。この例では、このセクションで説明しているトランスポート設定の一部であるため、websocket を有効なままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 +バックエンドがリテラルな `openai/...` 文字列を必要とする場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を必要とする場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも機能します。この例では、このセクションで説明しているトランスポート設定の一部であるため、WebSocket を有効なままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 -`MultiProvider` を通じてルーティングする際に、同じプロバイダー単位の登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡すと、基盤となる OpenAI プロバイダーに転送されます。 +`MultiProvider` を介してルーティングしながら同じプロバイダーレベルの登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡します。これは基盤となる OpenAIプロバイダーに転送されます。 -カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、websocket トランスポートにも互換性のある websocket `/responses` エンドポイントが必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 +カスタムの OpenAI互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 #### 注意事項 -- これは websocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions や OpenAI 以外のプロバイダーには、Responses websocket `/responses` エンドポイントをサポートしていない限り適用されません。 +- これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions や OpenAI以外のプロバイダーには、それらが Responses WebSocket `/responses` エンドポイントをサポートしていない限り適用されません。 - 環境にまだ存在しない場合は、`websockets` パッケージをインストールしてください。 -- websocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンのワークフローで、ターン間(およびネストされた Agents-as-tools 呼び出し)に同じ websocket 接続を再利用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 -- 長時間の推論ターンやレイテンシーの急増が発生するネットワークでは、`responses_websocket_options` を使用して websocket のキープアライブ動作をカスタマイズします。遅延した pong フレームを許容するには `ping_timeout` を増やすか、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。websocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを選択してください。 -- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後にある長時間稼働エージェントプロセスや、メモリが制限されたコンテナでは、`responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定して、メッセージ単位のメモリ使用量に上限を設けます。 -- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。この制限に達したら新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用します。 -- サービスは、接続ローカルのメモリに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、参照された `previous_response_id` が削除されます。再接続後も、保存済みのレスポンスが利用可能であれば続行できますが、`store=False` および ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` を使用して新しいチェーンを開始し、完全な入力コンテキストを送信するか、ローカルで管理されるセッション状態からそのコンテキストを再構築してください。 +- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。ターン間、およびネストされた Agents as tools の呼び出し間で同じ WebSocket 接続を再利用するマルチターンワークフローでは、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 +- 長時間の推論ターンやレイテンシーの急増があるネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズします。遅延した pong フレームを許容するには `ping_timeout` を増やすか、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。WebSocket のレイテンシーよりも信頼性が重要な場合は、HTTP/SSE トランスポートを優先してください。 +- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後で動作する長寿命のエージェントプロセスやメモリー制約のあるコンテナーでは、`responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定して、メッセージごとのメモリー使用量に上限を設けます。 +- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。その制限後は新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用します。 +- サービスは、接続ローカルのメモリーに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、参照された `previous_response_id` が削除されます。再接続後も、保存済みレスポンスが利用可能であれば継続できますが、`store=False` および ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` を指定して新しいチェーンを開始し、完全な入力コンテキストを送信するか、ローカルで管理しているセッション状態からそのコンテキストを再構築してください。 -### ホスト型マルチエージェント(試験的) +### ホスト型マルチエージェント(実験的) -OpenAI Responses API のホスト型マルチエージェントベータでは、GPT-5.6 のルートモデルがサーバーでホストされるサブエージェントを作成して調整できます。Agents SDK は通常の `Runner` を引き続き使用できます。ホスト型オーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 +OpenAI Responses API のホスト型マルチエージェントベータ版では、GPT-5.6 ルートモデルがサーバーでホストされるサブエージェントを作成し、連携させることができます。Agents SDKは通常の `Runner` を引き続き使用できます。ホスト型オーケストレーションはサービス上で実行され、開発者が定義した関数ツールはアプリケーション内で実行されます。 -この統合は試験的であり、ローカル関数の出力を `response.inject` によってアクティブなホスト型エージェントへ返せるように、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開するベータビルドを含む `openai[realtime]>=2.45.0` が必要です。インターフェースとベータ版の項目スキーマは、一般提供前に変更される可能性があります。 +この統合は実験的であり、ローカル関数の出力を `response.inject` によってアクティブなホスト型エージェントへ返せるように、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開するベータビルドを含む `openai[realtime]>=2.45.0` が必要です。一般提供までに、インターフェースとベータ版の項目スキーマが変更される可能性があります。 #### モデルの設定 -試験的モジュールからモデルをインポートし、SDK の `Agent` に割り当てます。 +実験的モジュールからモデルをインポートし、SDK の `Agent` に割り当てます。 ```python from agents import Agent @@ -260,13 +260,13 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel` を構築すると、`multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` が指定されていない場合、モデルはデフォルトの OpenAI クライアントを使用します。`max_concurrent_subagents` を省略した場合は、サービスのデフォルトが使用されます。 +`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` が指定されていない限り、モデルはデフォルトの OpenAIクライアントを使用します。`max_concurrent_subagents` を省略すると、サービスのデフォルト値が使用されます。 #### ローカル関数ツール -すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは、Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスに注入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、Runner の通常のガードレール、フック、および失敗変換が引き続き適用されます。SDK ツールの承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 +すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスに挿入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、引き続き Runner の通常のガードレール、フック、および失敗時の変換が適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエスト送信前に拒否されます。 -ツールで呼び出し元を考慮したログ記録または認可が必要な場合は、`get_hosted_agent_metadata()` を使用します。 +呼び出し元を考慮したログ記録や認可がツールに必要な場合は、`get_hosted_agent_metadata()` を使用します。 ```python from typing import Any @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -ホスト型エージェントの名前は観測用メタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、ツールの実行前または実行中に、必要な認可をアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 +ホスト型エージェント名は観測用のメタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、必要な認可をツールの実行前または実行中にアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 #### 出力とストリーミングの動作 -`final_answer` フェーズを持ち、`/root` に帰属するメッセージのみが通常の最終メッセージになります。試験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がそれらのレコードをローカル関数として実行することはありません。 +`final_answer` フェーズで `/root` に帰属するメッセージのみが、通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がそれらのレコードをローカル関数として実行することはありません。 -raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含む、ベータ版 Responses イベントが引き続き公開されます。アダプターは、関数呼び出しの準備が整った時点で、アクティブな 1 つのプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。帰属情報を確認するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用します。 +raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含むベータ版 Responses イベントが引き続き公開されます。関数呼び出しの準備ができると、アダプターは 1 つのアクティブなプロバイダーレスポンスを SDK から見える論理モデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。帰属情報を確認するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用します。 #### SDK オーケストレーションとの関係 -ホスト型マルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 +ホスト型マルチエージェントは、SDK のハンドオフおよび Agents as tools とは別のものです。 -- ホスト型マルチエージェントは、OpenAI サービス上でサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 -- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この試験的モデルを使用する場合は、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が発生するため、ハンドオフは拒否されます。 -- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 +- ホスト型マルチエージェントは、OpenAIサービス上にサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 +- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この実験的モデルを使用すると、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が発生するため、ハンドオフは拒否されます。 +- Agents as tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 #### 現在の制限事項 -試験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスがホスト型エージェントごとのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 +実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定した `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスが各ホスト型エージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 -1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に保持できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待機している間に実行を中止した場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 +1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に保持できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待っている間に実行を中止した場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 -基盤となる Responses API ベータ版の動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 +基盤となる Responses API ベータ版の動作については、[OpenAIマルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 -## OpenAI 以外のモデル +## OpenAI以外のモデル -OpenAI 以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくても、これで十分です。各パターンのコード例は [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 +OpenAI以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティー製アダプターを追加しなくても十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 -### OpenAI 以外のプロバイダーの統合方法 +### OpenAI以外のプロバイダーの統合方法 -| 方法 | 使用する状況 | 適用範囲 | +| アプローチ | 使用する場合 | 適用範囲 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI 互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | +| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | | [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行単位 | | [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェント単位 | -| サードパーティ製アダプター | 組み込みの経路では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合 | [サードパーティ製アダプター](#third-party-adapters)を参照 | +| サードパーティー製アダプター | 組み込み経路では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合 | [サードパーティー製アダプター](#third-party-adapters)を参照 | -次の組み込みの経路を使用して、他の LLM プロバイダーを統合できます。 +次の組み込み経路を使用して、他の LLM プロバイダーを統合できます。 -1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーに OpenAI 互換の API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 +1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に役立ちます。これは、LLM プロバイダーに OpenAI互換の API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで使用します。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスにモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせられます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 -`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` を使用してトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 +`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` でトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,7 +341,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 + これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses API をサポートしている場合は、Responses の使用を推奨します。 ## 1 つのワークフローでのモデルの組み合わせ @@ -353,7 +353,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、この 2 つの形式ではサポートされる機能とツールが異なるため、ワークフローごとに 1 つのモデル形式を使用することを推奨します。ワークフローで複数のモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用可能であることを確認してください。 + SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、それぞれがサポートする機能とツールのセットは異なります。そのため、ワークフローごとに単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 ```python import asyncio @@ -391,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAI モデルの名前を直接設定します。 +1. OpenAIモデルの名前を直接設定します。 2. [`Model`][agents.models.interface.Model] の実装を指定します。 -エージェントで使用するモデルをさらに設定するには、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。 +エージェントが使用するモデルをさらに設定する場合は、[`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。これにより、temperature などのオプションのモデル設定パラメーターを指定できます。 ```python from agents import Agent, ModelSettings @@ -409,22 +409,22 @@ english_agent = Agent( ## OpenAI Responses の高度な設定 -OpenAI Responses の経路を使用していて、より詳細な制御が必要な場合は、まず `ModelSettings` を使用してください。 +OpenAI Responses 経由でより詳細な制御が必要な場合は、まず `ModelSettings` を使用します。 ### 一般的な高度な `ModelSettings` オプション OpenAI Responses API を使用する場合、いくつかのリクエストフィールドには対応する `ModelSettings` フィールドがすでに用意されているため、それらに `extra_args` を使用する必要はありません。 -- `parallel_tool_calls`: 同じターン内で複数のツール呼び出しを許可または禁止します。 -- `truncation`: コンテキストが上限を超える場合に失敗させるのではなく、Responses API に最も古い会話項目を削除させるには、`"auto"` を設定します。 -- `store`: 生成されたレスポンスを後から取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに影響します。 +- `parallel_tool_calls`: 同じターンで複数のツール呼び出しを許可または禁止します。 +- `truncation`: `"auto"` を設定すると、コンテキストが上限を超える場合に失敗する代わりに、Responses API が最も古い会話項目を削除します。 +- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに影響します。 - `context_management`: `compact_threshold` を使用した Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 - `prompt_cache_retention`: 以前のモデルファミリー向けに、たとえば - `"24h"` を使用して保持期間の延長を設定します。 + `"24h"` を指定して保持期間の延長を設定します。 - `prompt_cache_options`: 暗黙的または明示的なプロンプトキャッシュを選択し、GPT-5.6 では `"30m"` のキャッシュ TTL を設定します。 - `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードをリクエストします。 - `top_logprobs`: 出力テキストの上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 -- `retry`: モデル呼び出しに対する Runner 管理の再試行設定を有効にします。[Runner 管理の再試行](#runner-managed-retries)を参照してください。 +- `retry`: モデル呼び出しに対して、Runner が管理する再試行設定を有効にします。[Runner 管理の再試行](#runner-managed-retries)を参照してください。 ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾にあるコンテンツ部分へブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses および Chat Completions のリクエストに渡され、Chat Completions コンバーターはテキスト、画像、音声、ファイルの各コンテンツ部分にあるブレークポイントを維持します。 +明示的なプロンプトキャッシュでは、再利用可能なプレフィックスが終了するコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストでそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、およびファイルのコンテンツ部分にあるブレークポイントを維持します。 ```python from agents import Runner @@ -471,9 +471,9 @@ result = await Runner.run( ``` `prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーでも引き続き利用できます。 -`ModelSettings` の直接フィールドと、`extra_args` 内の同じキーを併用しないでください。 +直接指定する `ModelSettings` フィールドと同じキーを `extra_args` に含めないでください。 -`store=False` を設定すると、Responses API はそのレスポンスを後からサーバー側で取得できるようには保持しません。これはステートレスまたはゼロデータ保持形式のフローに役立ちますが、通常であればレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、最後のレスポンスが保存されなかった場合、デフォルトの `"auto"` 圧縮経路を入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 +`store=False` を設定すると、Responses API は、そのレスポンスを後でサーバー側から取得できるようには保持しません。これはステートレスまたはゼロデータ保持形式のフローに役立ちますが、通常ならレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、最後のレスポンスが保存されていない場合、デフォルトの `"auto"` 圧縮経路を入力ベースの圧縮に切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間で独立した `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 @@ -481,7 +481,7 @@ result = await Runner.run( SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 -また、OpenAI の Responses API を使用する場合、[その他のオプションパラメーターもいくつかあります](https://platform.openai.com/docs/api-reference/responses/create)(例: `user`、`service_tier` など)。トップレベルで利用できない場合は、`extra_args` を使用してこれらを渡すこともできます。同じリクエストフィールドを `ModelSettings` の直接フィールドでも設定しないでください。 +OpenAIモデルを使用する場合、`extra_args` を使用して、Responses API と Chat Completions API の両方にオプションのパラメーター(たとえば `user` や `service_tier`)を渡せます。サポート対象のモデルで[高速モード](https://developers.openai.com/api/docs/guides/fast-mode)を使用するには、`extra_args={"service_tier": "fast"}` を設定します。`"priority"` も同等です。同じリクエストフィールドを、直接指定する `ModelSettings` フィールドにも設定しないでください。 ```python from agents import Agent, ModelSettings @@ -499,9 +499,9 @@ english_agent = Agent( ## Runner 管理の再試行 -再試行は実行時のみ有効で、明示的な有効化が必要です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 +再試行はランタイム専用で、明示的に有効にする必要があります。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 -Responses websocket トランスポートでは、`retry_policies.provider_suggested()` はレスポンス前の過負荷フレームと、コードのない `server_error` フレームを再試行の提案として認識します。これだけでは再試行は有効になりません。引き続き `ModelRetrySettings` が必要で、通常の再送安全性チェックも適用されます。レスポンスイベントが 1 つでも到着した後は、SDK はリクエストを再送しません。 +Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` は、レスポンス前の過負荷フレームとコードのない `server_error` フレームを再試行の提案として認識します。これだけでは再試行は有効になりません。引き続き `ModelRetrySettings` が必要で、通常の再送信安全性チェックも適用されます。レスポンスイベントが 1 つでもすでに到着している場合、SDK はリクエストを再送信しません。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -536,78 +536,78 @@ agent = Agent( | フィールド | 型 | 注意事項 | | --- | --- | --- | | `max_retries` | `int | None` | 最初のリクエスト後に許可される再試行回数です。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合の、デフォルトの遅延戦略です。`backoff.max_delay` は、この方法で計算されるバックオフ遅延のみを制限します。ポリシーが返す明示的な遅延や retry-after ヒントは制限しません。 | -| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドは実行時専用であり、シリアライズされません。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合の、デフォルトの遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみに上限を設定します。ポリシーから返される明示的な遅延や retry-after ヒントには上限を設定しません。 | +| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドはランタイム専用で、シリアライズされません。 | 再試行ポリシーは、次の情報を持つ [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 -- `attempt` と `max_retries`: 試行回数を考慮した判断に使用できます。 -- `stream`: ストリーミングと非ストリーミングの動作を分岐できます。 -- `error`: raw の内容を確認できます。 -- `normalized`: `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの正規化された情報です。 -- `provider_advice`: 基盤となるモデルアダプターが再試行のガイダンスを提供できる場合に設定されます。 +- `attempt` と `max_retries`。試行回数を考慮した判断に使用できます。 +- `stream`。ストリーミング動作と非ストリーミング動作を分岐できます。 +- `error`。raw の内容を確認できます。 +- `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの正規化された情報。 +- 基盤となるモデルアダプターが再試行のガイダンスを提供できる場合の `provider_advice`。 -ポリシーは次のいずれかを返せます。 +ポリシーは、次のいずれかを返せます。 -- 単純に再試行を判断する `True`/`False` -- 遅延を上書きしたり診断用の理由を付加したりする場合の [`RetryDecision`][agents.retry.RetryDecision] +- 単純な再試行判断を表す `True` / `False`。 +- 遅延をオーバーライドするか、診断理由を付加する場合の [`RetryDecision`][agents.retry.RetryDecision]。 -SDK は、`retry_policies` で既製のヘルパーを公開しています。 +SDK は、`retry_policies` にすぐに使用できるヘルパーを提供しています。 | ヘルパー | 動作 | | --- | --- | | `retry_policies.never()` | 常に再試行しません。 | | `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行アドバイスに従います。 | -| `retry_policies.network_error()` | 一時的なトランスポート障害とタイムアウトに一致します。 | +| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウトに一致します。 | | `retry_policies.http_status([...])` | 選択した HTTP ステータスコードに一致します。 | -| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合にのみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` はその値を制限しません。 | +| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合のみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` による上限は適用されません。 | | `retry_policies.any(...)` | ネストされたポリシーのいずれかが再試行を選択した場合に再試行します。 | -| `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合にのみ再試行します。 | +| `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合のみ再試行します。 | -ポリシーを組み合わせる場合、プロバイダーが拒否判断と再送安全性の承認を区別できるときにそれらを維持するため、最初の構成要素としては `provider_suggested()` が最も安全です。 +ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の構成要素です。これは、プロバイダーが拒否判断と再送信安全性の承認を区別できる場合に、それらを維持するためです。 ##### 安全性の境界 -一部の障害は自動的に再試行されません。 +一部の失敗は、自動的に再試行されることはありません。 -- 中止エラー -- プロバイダーのアドバイスで再送が安全でないと判断されたリクエスト -- 出力がすでに開始され、再送が安全でなくなるストリーミング実行 +- 中止エラー。 +- プロバイダーのアドバイスで再送信が安全でないと判断されたリクエスト。 +- 出力がすでに開始され、再送信が安全でなくなるストリーミング実行。 -`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` など、プロバイダーに依存しない述語だけでは不十分です。再試行ポリシーには、通常は `retry_policies.provider_suggested()` を使用して、プロバイダーによる再送安全性の承認を含める必要があります。 +`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` などのプロバイダー以外の述語だけでは不十分です。再試行ポリシーには、通常は `retry_policies.provider_suggested()` を通じて、プロバイダーによる再送信安全性の承認を含める必要があります。 ##### Runner とエージェントのマージ動作 -`retry` は、Runner レベルとエージェントレベルの `ModelSettings` の間でディープマージされます。 +`retry` は、Runner レベルとエージェントレベルの `ModelSettings` 間でディープマージされます。 -- エージェントは `retry.max_retries` のみを上書きしながら、Runner の `policy` を継承できます。 -- エージェントは `retry.backoff` の一部のみを上書きしながら、Runner の他のバックオフフィールドを維持できます。 -- `policy` は実行時専用であるため、シリアライズされた `ModelSettings` は `max_retries` と `backoff` を保持しますが、コールバック自体は省略します。 +- エージェントは `retry.max_retries` のみをオーバーライドし、Runner の `policy` を継承できます。 +- エージェントは `retry.backoff` の一部のみをオーバーライドし、Runner の他のバックオフフィールドを維持できます。 +- `policy` はランタイム専用であるため、シリアライズされた `ModelSettings` は `max_retries` と `backoff` を維持しますが、コールバック自体は省略します。 -より完全なコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターベースの再試行コード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 +より詳しいコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを使用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 -## OpenAI 以外のプロバイダーのトラブルシューティング +## OpenAI以外のプロバイダーのトラブルシューティング -### トレーシングクライアントエラー 401 +### トレーシングクライアントのエラー 401 -トレーシングに関連するエラーが発生する場合、トレースが OpenAI サーバーにアップロードされる一方で、OpenAI API キーがないことが原因です。これを解決するには、次の 3 つの方法があります。 +トレーシング関連のエラーが発生する場合、トレースが OpenAIサーバーへアップロードされる一方で、OpenAI API キーが設定されていないことが原因です。これを解決するには、次の 3 つの方法があります。 -1. トレーシングを完全に無効にします: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] -2. トレーシング用の OpenAI キーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 -3. OpenAI 以外のトレースプロセッサーを使用します。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 +1. トレーシングを完全に無効にします: [`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 +2. トレーシング用の OpenAIキーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードのみに使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 +3. OpenAI以外のトレースプロセッサーを使用します。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 ### Responses API のサポート -SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだ対応していません。その結果、404 エラーまたは同様の問題が発生する場合があります。これを解決するには、次の 2 つの方法があります。 +SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだサポートしていません。そのため、404 エラーや同様の問題が発生する場合があります。解決するには、次の 2 つの方法があります。 1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数で `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] を使用します。コード例は[こちら](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)にあります。 ### Chat Completions の互換性オプション -Chat Completions 経由でルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、プロンプト、テキストのみではないツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に削除することで互換性を維持します。開発中にこのような不一致を即座にエラーにするには、OpenAI プロバイダーで厳格な機能検証を有効にします。 +Chat Completions を介してルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、プロンプト、またはテキスト以外を含むツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に削除して互換性を維持します。開発中にこれらの不一致を即座に失敗させるには、OpenAIプロバイダーで厳密な機能検証を有効にします。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -627,7 +627,7 @@ result = await Runner.run( [`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡します。 -一部の OpenAI 互換 Chat Completions プロバイダーは、SDK が段階的に処理するには信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされたツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが完了した後にのみ SDK がツール呼び出しを生成するようにします。 +一部の OpenAI互換 Chat Completions プロバイダーは、SDK が増分処理するには信頼性が十分でないチャンクでツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされるツール呼び出しのバッファリングを有効にし、プロバイダーのストリーム終了後にのみ SDK がツール呼び出しを生成するようにします。 ```python from agents import OpenAIProvider @@ -642,7 +642,7 @@ provider = OpenAIProvider( ### structured outputs のサポート -一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。その場合、次のようなエラーが発生することがあります。 +一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)をサポートしていません。この場合、次のようなエラーが発生することがあります。 ``` @@ -650,19 +650,19 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていますが、出力に使用する `json_schema` を指定できません。この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートしているプロバイダーを使用することを推奨します。そうしない場合、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 +これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていても、出力に使用する `json_schema` を指定できません。現在この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを使用することを推奨します。そうしないと、不正な形式の JSON によってアプリが頻繁に動作しなくなるためです。 -## プロバイダーをまたぐモデルの組み合わせ +## プロバイダー間でのモデルの組み合わせ -モデルプロバイダー間の機能差を把握しておかないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホスト型のファイル検索と Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 +モデルプロバイダー間の機能差を把握しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAIは structured outputs、マルチモーダル入力、ホスト型のファイル検索および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 -- 対応していないプロバイダーへ、サポートされていない `tools` を送信しないでください +- サポートされていない `tools` を、それらを理解できないプロバイダーに送信しないでください - テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください - 構造化 JSON 出力をサポートしていないプロバイダーは、無効な JSON を生成する場合があることに注意してください。 -## サードパーティ製アダプター +## サードパーティー製アダプター -サードパーティ製アダプターは、SDK の組み込みプロバイダー統合ポイントだけでは不十分な場合にのみ使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] の経路を推奨します。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる必要がある場合や、組み込みの経路では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合に使用します。アダプターは SDK と上流のモデルプロバイダーの間に互換性レイヤーを追加するため、サポートされる機能とリクエストのセマンティクスはプロバイダーによって異なる場合があります。現在、SDK にはベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 +SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティー製アダプターを使用してください。この SDK で OpenAIモデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 経路を優先してください。サードパーティー製アダプターは、OpenAIモデルと OpenAI以外のプロバイダーを組み合わせる必要がある場合や、組み込み経路では提供されないアダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合に使用します。アダプターは SDK と上流のモデルプロバイダーの間に互換性レイヤーを追加するため、機能のサポート状況やリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 ### Any-LLM @@ -672,7 +672,7 @@ Any-LLM のサポートは、Any-LLM が管理するプロバイダーカバレ Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡します。 -Any-LLM はサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能差は SDK ではなく、上流の Any-LLM によって定義されます。上流のプロバイダーが使用量指標を返す場合、それらは自動的に伝播されます。ただし、ストリーミングされる Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 +Any-LLM は引き続きサードパーティー製のアダプターレイヤーであるため、プロバイダーの依存関係や機能の差異は SDK ではなく、上流の Any-LLM によって定義されます。上流プロバイダーが使用量指標を返す場合、それらは自動的に伝播されますが、ストリーミング対応の Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 ### LiteLLM @@ -680,12 +680,12 @@ LiteLLM のサポートは、LiteLLM 固有のプロバイダーカバレッジ LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールし、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` モデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 -LiteLLM ベースの一部のプロバイダーは、デフォルトでは SDK の使用量指標を設定しません。使用量レポートが必要な場合は、`ModelSettings(include_usage=True)` を渡してください。また、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 +LiteLLM を基盤とする一部のプロバイダーは、デフォルトでは SDK の使用量指標を設定しません。使用量レポートが必要な場合は `ModelSettings(include_usage=True)` を渡し、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 -LiteLLM がレスポンスオブジェクトに対する Pydantic シリアライザー警告を生成する場合は、LiteLLM アダプターをインポートする前に、SDK の互換性パッチを有効にできます。 +LiteLLM がレスポンスオブジェクトに関する Pydantic シリアライザーの警告を生成する場合は、LiteLLM アダプターをインポートする前に SDK の互換性パッチを有効にできます。 ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -このパッチはデフォルトでは無効で、値が `1` または `true` の場合にのみ有効になります。プライベートな LiteLLM ロギングヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。プライベートな LiteLLM API に依存しているため、LiteLLM をアップグレードする際には再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file +このパッチはデフォルトで無効であり、値が `1` または `true` の場合にのみ有効になります。これは、LiteLLM の非公開ログヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。LiteLLM の非公開 API に依存しているため、LiteLLM をアップグレードするときは再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file diff --git a/docs/ko/config.md b/docs/ko/config.md index 47ec148fd2..486321d651 100644 --- a/docs/ko/config.md +++ b/docs/ko/config.md @@ -4,21 +4,40 @@ search: --- # 구성 -이 페이지에서는 애플리케이션 시작 시 일반적으로 한 번 설정하는 SDK 전체 기본값을 다룹니다. 예를 들어 기본 OpenAI 키 또는 클라이언트, 기본 OpenAI API 형태, 트레이싱 내보내기 기본값, 로깅 동작 등이 있습니다. +이 페이지에서는 기본 OpenAI 키 또는 클라이언트, 기본 OpenAI API 형식, 트레이싱 내보내기 기본값, 로깅 동작처럼 일반적으로 애플리케이션 시작 시 한 번 설정하는 SDK 전체 기본값을 설명합니다. -이러한 기본값은 샌드박스 기반 워크플로에도 계속 적용되지만, 샌드박스 워크스페이스, 샌드박스 클라이언트, 세션 재사용은 별도로 구성합니다. +이러한 기본값은 샌드박스 기반 워크플로에도 적용되지만, 샌드박스 워크스페이스, 샌드박스 클라이언트 및 세션 재사용은 별도로 구성합니다. -대신 특정 에이전트 또는 실행을 구성해야 한다면 다음부터 시작하세요: +대신 특정 에이전트나 실행을 구성해야 한다면 다음 문서부터 확인하세요. -- [에이전트](agents.md): 일반 `Agent`의 instructions, tools, 출력 유형, 핸드오프, 가드레일 -- [에이전트 실행](running_agents.md): `RunConfig`, 세션, 대화 상태 옵션 -- [샌드박스 에이전트](sandbox/guide.md): `SandboxRunConfig`, 매니페스트, 기능, 샌드박스 클라이언트별 워크스페이스 설정 -- [모델](models/index.md): 모델 선택 및 제공자 구성 -- [트레이싱](tracing.md): 실행별 트레이싱 메타데이터 및 사용자 지정 트레이스 프로세서 +- 일반 `Agent`의 instructions, tools, 출력 유형, 핸드오프 및 가드레일에 대해서는 [에이전트](agents.md) +- `RunConfig`, 세션 및 대화 상태 옵션에 대해서는 [에이전트 실행](running_agents.md) +- `SandboxRunConfig`, 매니페스트, 기능 및 샌드박스 클라이언트별 워크스페이스 설정에 대해서는 [샌드박스 에이전트](sandbox/guide.md) +- 모델 선택 및 프로바이더 구성에 대해서는 [모델](models/index.md) +- 실행별 트레이싱 메타데이터 및 사용자 지정 트레이스 프로세서에 대해서는 [트레이싱](tracing.md) + +## 구성 객체 및 딕셔너리 + +SDK 소유 구성 매개변수는 일반적으로 형식이 지정된 설정 객체 또는 동일한 필드를 포함하는 딕셔너리를 허용합니다. 이는 형식 주석에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 구성 인터페이스 전반에 적용됩니다. 중첩된 SDK 소유 설정에도 딕셔너리를 사용할 수 있습니다. + +```python +from agents import Agent + +agent = Agent( + name="Assistant", + model="gpt-5.6-sol", + model_settings={ + "reasoning": {"effort": "high"}, + "verbosity": "low", + }, +) +``` + +SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK 소유 데이터클래스 구성에 알 수 없는 필드가 있으면 `TypeError`가 발생하므로, 옵션 이름의 오타를 조기에 발견할 수 있습니다. 특정 인터페이스에서 딕셔너리를 허용하는지 확인하려면 매개변수의 형식 주석 또는 API 레퍼런스를 확인하세요. ## API 키 및 클라이언트 -기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. 키는 SDK가 처음으로 OpenAI 클라이언트를 생성할 때 확인됩니다(지연 초기화). 따라서 첫 모델 호출 전에 환경 변수를 설정하세요. 앱 시작 전에 해당 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용해 키를 설정할 수 있습니다. +기본적으로 SDK는 LLM 요청 및 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. 키는 SDK가 OpenAI 클라이언트를 처음 생성할 때 확인되므로(지연 초기화), 첫 번째 모델 호출 전에 환경 변수를 설정하세요. 앱이 시작되기 전에 해당 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용해 키를 설정할 수 있습니다. ```python from agents import set_default_openai_key @@ -36,14 +55,14 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -환경 변수 기반 엔드포인트 구성을 선호한다면, 기본 OpenAI 제공자는 `OPENAI_BASE_URL`도 읽습니다. Responses WebSocket 전송을 활성화하면 WebSocket `/responses` 엔드포인트용 `OPENAI_WEBSOCKET_BASE_URL`도 읽습니다. +환경 기반 엔드포인트 구성을 선호한다면 기본 OpenAI 프로바이더는 `OPENAI_BASE_URL`도 읽습니다. Responses WebSocket 전송을 활성화하면 WebSocket `/responses` 엔드포인트에 사용할 `OPENAI_WEBSOCKET_BASE_URL`도 읽습니다. ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -마지막으로 사용할 OpenAI API를 사용자 지정할 수도 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용해 이를 재정의하여 Chat Completions API를 사용할 수 있습니다. +마지막으로 사용할 OpenAI API도 사용자 지정할 수 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용하면 이를 재정의하여 Chat Completions API를 사용할 수 있습니다. ```python from agents import set_default_openai_api @@ -51,9 +70,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 제공자 기본값 +## OpenAI 프로바이더 기본값 -OpenAI 기반 제공자는 모델 이름을 확인할 때도 SDK 전체 기본값을 읽습니다. OpenAI Responses 모델이 기본적으로 WebSocket 전송을 사용하도록 하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]를 사용하세요: +OpenAI 기반 프로바이더는 모델 이름을 확인할 때 SDK 전체 기본값도 읽습니다. OpenAI Responses 모델에서 WebSocket 전송을 기본으로 사용하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]를 사용하세요. ```python from agents import set_default_openai_responses_transport @@ -61,9 +80,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 제공자가 확인한 OpenAI Responses 모델에 영향을 줍니다. 제공자 수준 설정, 연결 재사용, keepalive 옵션, 사용자 지정 WebSocket 엔드포인트는 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참조하세요. +이는 기본 OpenAI 프로바이더가 확인하는 OpenAI Responses 모델에 영향을 줍니다. 프로바이더 수준 설정, 연결 재사용, 연결 유지 옵션 및 사용자 지정 WebSocket 엔드포인트에 대해서는 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참조하세요. -OpenAI 설정에서 제공자 수준 에이전트 등록 메타데이터를 기대하는 경우, 시작 시 기본 harness ID를 한 번 구성하세요: +OpenAI 설정에서 프로바이더 수준의 에이전트 등록 메타데이터가 필요하다면 시작 시 기본 하네스 ID를 한 번 구성하세요. ```python from agents import set_default_openai_harness @@ -71,7 +90,7 @@ from agents import set_default_openai_harness set_default_openai_harness("your-harness-id") ``` -전체 등록 객체를 전달할 수도 있습니다: +전체 등록 객체를 전달할 수도 있습니다. ```python from agents import OpenAIAgentRegistrationConfig, set_default_openai_agent_registration @@ -81,11 +100,11 @@ set_default_openai_agent_registration( ) ``` -SDK 기본값이 설정되어 있지 않으면 OpenAI 기반 제공자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수로 폴백합니다. harness ID가 구성되어 있으면, SDK는 `RunConfig.trace_metadata`에 해당 키가 이미 있는 경우를 제외하고 이를 `agent_harness_id`로 트레이스 메타데이터에 추가합니다. +SDK 기본값이 설정되지 않은 경우 OpenAI 기반 프로바이더는 `OPENAI_AGENT_HARNESS_ID` 환경 변수를 대신 사용합니다. 하네스 ID가 구성되어 있으면 해당 키가 `RunConfig.trace_metadata`에 이미 존재하지 않는 한 SDK는 이를 `agent_harness_id`로 트레이스 메타데이터에 추가합니다. ## 트레이싱 -트레이싱은 기본적으로 활성화되어 있습니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키(즉, 환경 변수 또는 설정한 기본 키)를 사용합니다. [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하여 트레이싱에 사용할 API 키를 별도로 설정할 수 있습니다. +트레이싱은 기본적으로 활성화됩니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수나 사용자가 설정한 기본 키를 사용합니다. [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하면 트레이싱에 사용할 API 키를 별도로 설정할 수 있습니다. ```python from agents import set_tracing_export_api_key @@ -93,7 +112,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -모델 트래픽에는 한 키나 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 한다면, 기본 키나 클라이언트를 설정할 때 `use_for_tracing=False`를 전달한 다음 트레이싱을 별도로 구성하세요. 사용자 지정 클라이언트를 사용하지 않는 경우 [`set_default_openai_key()`][agents.set_default_openai_key]에서도 동일한 패턴을 사용할 수 있습니다. +모델 트래픽에는 특정 키나 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 한다면, 기본 키나 클라이언트를 설정할 때 `use_for_tracing=False`를 전달한 다음 트레이싱을 별도로 구성하세요. 사용자 지정 클라이언트를 사용하지 않는 경우 [`set_default_openai_key()`][agents.set_default_openai_key]에도 같은 패턴을 적용할 수 있습니다. ```python from openai import AsyncOpenAI @@ -108,14 +127,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -기본 익스포터를 사용할 때 트레이스를 특정 조직 또는 프로젝트에 귀속해야 한다면 앱 시작 전에 다음 환경 변수를 설정하세요: +기본 내보내기를 사용할 때 트레이스를 특정 조직이나 프로젝트에 귀속해야 한다면 앱이 시작되기 전에 다음 환경 변수를 설정하세요. ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -전역 익스포터를 변경하지 않고 실행별로 트레이싱 API 키를 설정할 수도 있습니다. +전역 내보내기를 변경하지 않고 실행별로 트레이싱 API 키를 설정할 수도 있습니다. ```python from agents import Runner, RunConfig @@ -127,7 +146,7 @@ await Runner.run( ) ``` -[`set_tracing_disabled()`][agents.set_tracing_disabled] 함수를 사용하여 트레이싱을 완전히 비활성화할 수도 있습니다. +[`set_tracing_disabled()`][agents.set_tracing_disabled] 함수를 사용해 트레이싱을 완전히 비활성화할 수도 있습니다. ```python from agents import set_tracing_disabled @@ -135,7 +154,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -트레이싱은 활성화한 상태로 유지하되 트레이스 페이로드에서 민감할 수 있는 입력/출력을 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 `False`로 설정하세요: +트레이싱은 활성화된 상태로 유지하면서 잠재적으로 민감한 입력/출력을 트레이스 페이로드에서 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 `False`로 설정하세요. ```python from agents import Runner, RunConfig @@ -147,17 +166,17 @@ await Runner.run( ) ``` -앱 시작 전에 다음 환경 변수를 설정하면 코드 없이도 기본값을 변경할 수 있습니다: +앱이 시작되기 전에 다음 환경 변수를 설정하여 코드 없이 기본값을 변경할 수도 있습니다. ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -전체 트레이싱 제어 옵션은 [트레이싱 가이드](tracing.md)를 참조하세요. +전체 트레이싱 제어 기능에 대해서는 [트레이싱 가이드](tracing.md)를 참조하세요. ## 디버그 로깅 -SDK는 두 개의 Python 로거(`openai.agents` 및 `openai.agents.tracing`)를 정의하며, 기본적으로 핸들러를 연결하지 않습니다. 로그는 애플리케이션의 Python 로깅 구성을 따릅니다. +SDK는 두 개의 Python 로거(`openai.agents` 및 `openai.agents.tracing`)를 정의하며 기본적으로 핸들러를 연결하지 않습니다. 로그는 애플리케이션의 Python 로깅 구성을 따릅니다. 상세 로깅을 활성화하려면 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 함수를 사용하세요. @@ -186,20 +205,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 로그의 민감한 데이터 +### 로그 및 진단의 민감한 데이터 -일부 로그에는 민감한 데이터(예: 사용자 데이터)가 포함될 수 있습니다. +특정 로그와 진단 예외에는 민감한 데이터(예: 모델 또는 도구의 입력 및 출력)가 포함될 수 있습니다. -기본적으로 SDK는 LLM 입력/출력이나 도구 입력/출력을 로그로 기록하지 **않습니다**. 이러한 보호 기능은 다음으로 제어됩니다: +기본적으로 SDK는 LLM 입력/출력이나 도구 입력/출력을 **기록하지 않습니다**. 이러한 보호 기능은 다음 항목으로 제어합니다. ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -디버깅을 위해 이 데이터를 일시적으로 포함해야 한다면, 앱 시작 전에 둘 중 하나의 변수를 `0`(또는 `false`)으로 설정하세요: +디버깅을 위해 이 데이터를 일시적으로 포함해야 한다면 앱이 시작되기 전에 둘 중 하나의 변수를 `0`(또는 `false`)으로 설정하세요. ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 -``` \ No newline at end of file +``` + +이러한 플래그는 영향을 받는 실패에서 페이로드가 포함된 진단 세부 정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 교정이 활성화된 상태에서 함수 도구 인수가 유효하지 않으면, 내부 유효성 검사 오류를 예외 체인으로 연결하지 않고 일반적인 `ModelBehaviorError`가 발생합니다. 둘 중 하나의 변수를 `0`으로 설정하면 로그, 예외 메시지, 예외 체인 및 기타 진단 컨텍스트에 모델 또는 도구의 원문 데이터가 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index e179c2da0c..4ff34139f7 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -4,7 +4,7 @@ search: --- # 가드레일 -가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 속도가 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 상황은 원하지 않을 것입니다. 이 경우 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 고비용 모델이 실행되지 않도록 함으로써 시간과 비용을 절약할 수 있습니다(**차단형 가드레일을 사용할 때에 해당합니다. 병렬 가드레일의 경우 가드레일 실행이 완료되기 전에 고비용 모델이 이미 실행되기 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참고하세요**). +가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 속도가 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 것은 원하지 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시키고 고비용 모델이 실행되지 않도록 하여 시간과 비용을 절약할 수 있습니다(**블로킹 가드레일을 사용하는 경우에 해당합니다. 병렬 가드레일의 경우 가드레일이 완료되기 전에 고비용 모델이 이미 실행되기 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요**). 가드레일에는 두 가지 종류가 있습니다. @@ -17,64 +17,66 @@ search: - **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. - **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. -- **도구 가드레일**은 사용자 정의 함수 도구가 호출될 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. +- **도구 가드레일**은 사용자 지정 함수 도구를 호출할 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. -관리자, 핸드오프 또는 위임된 전문가가 포함된 워크플로에서 각 사용자 정의 함수 도구 호출 전후에 검사가 필요하다면 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. +관리자, 핸드오프 또는 위임된 전문가가 포함된 워크플로에서 각 사용자 지정 함수 도구 호출을 검사해야 한다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. ## 입력 가드레일 입력 가드레일은 다음 3단계로 실행됩니다. -1. 먼저 가드레일이 에이전트에 전달된 것과 동일한 입력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이는 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +1. 먼저 가드레일은 에이전트에 전달된 것과 동일한 입력을 받습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 출력은 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. !!! Note - 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 가드레일을 `Runner.run`에 전달하지 않고 에이전트의 `guardrails` 속성에 지정하는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경향이 있기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 같은 위치에 두면 가독성에 도움이 됩니다. + 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *첫 번째* 에이전트인 경우에만 실행됩니다. 가드레일을 `Runner.run`에 전달하지 않고 에이전트의 `guardrails` 속성에 지정하는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성에 도움이 됩니다. ### 실행 모드 입력 가드레일은 두 가지 실행 모드를 지원합니다. -- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 같은 시점에 시작하므로 지연 시간을 최소화할 수 있습니다. 하지만 가드레일 검사가 실패하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. +- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 같은 시점에 시작하므로 지연 시간이 가장 짧습니다. 그러나 가드레일 검사가 실패하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. -- **차단 실행**(`run_in_parallel=False`): 가드레일이 에이전트 실행 *전에* 시작되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 실행되지 않으므로 토큰 소비와 도구 실행을 방지합니다. 비용을 최적화하거나 도구 호출에서 발생할 수 있는 잠재적 부작용을 방지하려는 경우에 적합합니다. +- **블로킹 실행**(`run_in_parallel=False`): 가드레일이 에이전트가 시작되기 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트가 전혀 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출에서 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. ## 출력 가드레일 출력 가드레일은 다음 3단계로 실행됩니다. -1. 먼저 가드레일이 에이전트가 생성한 출력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하며, 이는 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +1. 먼저 가드레일은 에이전트가 생성한 출력을 받습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 출력은 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. !!! Note - 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로, 가드레일이 실제 에이전트와 관련되는 경향이 있기 때문에 이와 같이 동작합니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 같은 위치에 두면 가독성에 도움이 됩니다. + 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *마지막* 에이전트인 경우에만 실행됩니다. 입력 가드레일과 마찬가지로 이렇게 하는 이유는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성에 도움이 됩니다. 출력 가드레일은 항상 에이전트 실행이 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. ## 도구 가드레일 -도구 가드레일은 **함수 도구**를 감싸 실행 전후에 도구 호출을 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. +도구 가드레일은 **함수 도구**를 래핑하고 실행 전후에 도구 호출을 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. - 입력 도구 가드레일은 도구가 실행되기 전에 실행되며, 호출을 건너뛰거나 출력을 메시지로 대체하거나 트립와이어를 발생시킬 수 있습니다. - 출력 도구 가드레일은 도구가 실행된 후에 실행되며, 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. -- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후, 실행 직전에 실행됩니다. 승인 대기 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 도구가 실행되기 전에 승인 후 다시 검사됩니다. -- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아닌 SDK의 핸드오프 파이프라인을 통해 실행되므로 도구 가드레일은 핸드오프 호출 자체에는 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 내장 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, 현재 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 도구 가드레일 옵션을 직접 제공하지 않습니다. +- 함수 도구에 승인이 필요한 경우, 입력 도구 가드레일은 일반적으로 승인 후 실행 직전에 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 도구가 실행되기 전 승인 후에 다시 검사됩니다. +- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성한 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 통해 실행되므로, 도구 가드레일은 핸드오프 호출 자체에는 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, 현재 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 도구 가드레일 옵션을 직접 제공하지 않습니다. -자세한 내용은 아래 코드 조각을 참고하세요. +자세한 내용은 아래 코드 조각을 참조하세요. ## 트립와이어 -입력이나 출력이 가드레일 검사를 통과하지 못하면 가드레일은 트립와이어를 통해 이를 알릴 수 있습니다. 트립와이어를 트리거한 가드레일이 확인되는 즉시 `{Input,Output}GuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. +에이전트 입력 또는 출력이 가드레일 검사를 통과하지 못하면 가드레일이 트립와이어를 통해 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 각각 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. -예외의 `guardrail_result`는 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 입력 트립와이어를 발생시킨 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어의 경우 이에 상응하는 누적 결과가 `exception.run_data.output_guardrail_results`를 통해 제공됩니다. `stream_events()`가 예외를 발생시킨 후에는 스트리밍된 결과에서 `input_guardrail_results` 또는 `output_guardrail_results`를 통해 동일한 완료 결과를 확인할 수 있습니다. 러너가 관리하는 실행 경로 밖에서 예외가 발생하면 `run_data`는 `None`일 수 있습니다. +에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`는 트립와이어를 트리거한 가드레일을 나타냅니다. 러너가 발생시킨 입력 트립와이어의 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 여기에는 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어는 `exception.run_data.output_guardrail_results`를 통해 이에 상응하는 누적 결과를 제공합니다. + +반면 도구 트립와이어 예외는 트립와이어를 트리거한 `guardrail`과 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록에는 실패하기 전에 완료된 턴에서 누적된 결과가 보존되며, 트립와이어를 트리거한 결과는 예외의 `output`을 통해 확인할 수 있습니다. `MaxTurnsExceeded`와 같이 러너가 관리하는 다른 실패에서도 완료된 도구 가드레일 결과가 이 목록에 보존됩니다. `stream_events()`에서 예외가 발생한 후에도 스트리밍된 결과는 동일하게 누적된 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생하면 `run_data`는 `None`일 수 있습니다. ## 가드레일 구현 -입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행하여 이를 구현합니다. +입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행해 이를 구현합니다. ```python from pydantic import BaseModel @@ -127,12 +129,12 @@ async def main(): print("Math homework guardrail tripped") ``` -1. 가드레일 함수에서 이 에이전트를 사용합니다. +1. 이 에이전트를 가드레일 함수에서 사용합니다. 2. 에이전트의 입력/컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. 3. 가드레일 결과에 추가 정보를 포함할 수 있습니다. 4. 워크플로를 정의하는 실제 에이전트입니다. -출력 가드레일도 유사합니다. +출력 가드레일도 이와 유사합니다. ```python from pydantic import BaseModel @@ -185,12 +187,12 @@ async def main(): print("Math output guardrail tripped") ``` -1. 실제 에이전트의 출력 타입입니다. -2. 가드레일의 출력 타입입니다. +1. 실제 에이전트의 출력 유형입니다. +2. 가드레일의 출력 유형입니다. 3. 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. 4. 워크플로를 정의하는 실제 에이전트입니다. -마지막으로 다음은 도구 가드레일의 예제입니다. +마지막으로 도구 가드레일의 예제입니다. ```python import json diff --git a/docs/ko/models/index.md b/docs/ko/models/index.md index d970c6d27d..822a097849 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -4,32 +4,32 @@ search: --- # 모델 -Agents SDK는 다음 두 가지 방식으로 OpenAI 모델을 즉시 사용할 수 있도록 지원합니다. +Agents SDK는 두 가지 방식으로 OpenAI 모델을 즉시 사용할 수 있도록 지원합니다. -- **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] -- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] +- **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] +- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] ## 모델 설정 선택 -설정에 맞는 가장 간단한 방식부터 시작하세요. +설정에 적합한 가장 간단한 방법부터 시작하세요. -| 원하는 작업 | 권장 방식 | 자세히 보기 | +| 목표 | 권장 방법 | 자세히 보기 | | --- | --- | --- | -| OpenAI 모델만 사용 | Responses 모델 경로에서 기본 OpenAI 프로바이더 사용 | [OpenAI 모델](#openai-models) | -| 웹소켓 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 웹소켓 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | -| OpenAI 호스트 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | -| OpenAI 이외의 단일 프로바이더 사용 | 기본 제공 프로바이더 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | -| 에이전트 간에 모델 또는 프로바이더 혼합 | 실행별 또는 에이전트별로 프로바이더를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [여러 프로바이더의 모델 혼합](#mixing-models-across-providers) | +| OpenAI 모델만 사용 | 기본 OpenAI 프로바이더와 Responses 모델 경로 사용 | [OpenAI 모델](#openai-models) | +| WebSocket 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 WebSocket 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | +| OpenAI에서 호스팅되는 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | +| OpenAI가 아닌 하나의 프로바이더 사용 | 기본 제공 프로바이더 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | +| 에이전트 간에 모델 또는 프로바이더 혼합 | 실행별 또는 에이전트별로 프로바이더를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [프로바이더 간 모델 혼합](#mixing-models-across-providers) | | 고급 OpenAI Responses 요청 설정 조정 | OpenAI Responses 경로에서 `ModelSettings` 사용 | [고급 OpenAI Responses 설정](#advanced-openai-responses-settings) | -| OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 출시할 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | +| OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 배포할 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | ## OpenAI 모델 -OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 것이 좋습니다. +OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하면서 Responses 모델 경로를 유지하는 것이 좋습니다. -`Agent`를 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위해 `reasoning.effort="none"` 및 `verbosity="low"`로 설정된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 사용할 수 있다면 명시적인 `model_settings`를 유지하면서 더 높은 품질을 제공하는 `gpt-5.6-sol`로 에이전트를 설정하는 것이 좋습니다. +`Agent`를 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위해 `reasoning.effort="none"` 및 `verbosity="low"`가 설정된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 액세스 권한이 있다면 명시적인 `model_settings`를 유지하면서 더 높은 품질을 얻을 수 있도록 에이전트 모델을 `gpt-5.6-sol`로 설정하는 것이 좋습니다. -`gpt-5.6-sol`과 같은 다른 모델로 전환하려면 두 가지 방법으로 에이전트를 구성할 수 있습니다. +`gpt-5.6-sol`과 같은 다른 모델로 전환하려는 경우 에이전트를 구성하는 두 가지 방법이 있습니다. ### 기본 모델 @@ -77,7 +77,7 @@ my_agent = Agent( 지연 시간을 줄이려면 GPT-5 모델에서 `reasoning.effort="none"`을 사용하는 것이 좋습니다. -GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 영구 저장되는 추론 컨텍스트, `"max"` 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. +GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 유지되는 추론 컨텍스트 및 `"max"` 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode`와 `reasoning.context`는 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 인터페이스에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고와 함께 모드와 컨텍스트를 무시합니다. 이 경고를 오류로 전환하려면 OpenAI 프로바이더에서 `strict_feature_validation=True`를 설정하세요. +`reasoning.mode`와 `reasoning.context`는 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 인터페이스에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고와 함께 모드 및 컨텍스트를 무시합니다. 이 경고를 오류로 전환하려면 OpenAI 프로바이더에 `strict_feature_validation=True`를 설정하세요. -`context="all_turns"`를 사용할 때는 `previous_response_id`, 서버 측 대화 또는 이전 추론 항목의 재실행을 통해 대화를 보존하세요. 상태를 유지하지 않는 `store=False` 호출에서는 응답에 `reasoning.encrypted_content`를 포함하고 다음 요청에서 해당 추론 항목을 다시 전달하세요. +`context="all_turns"`를 사용할 때는 `previous_response_id`, 서버 측 대화 또는 이전 추론 항목 재실행을 통해 대화를 보존하세요. 상태 비저장 `store=False` 호출에서는 응답에 `reasoning.encrypted_content`를 포함하고 다음 요청에서 해당 추론 항목을 다시 전달하세요. #### ComputerTool 모델 선택 -에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청의 유효 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 정식 출시된 기본 제공 `computer` 도구를 사용하지만, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. +에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청에서 유효한 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 GA 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. -프롬프트 관리형 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 소유하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 고정한 모델을 추측하지 않도록 프리뷰 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 정식 출시 경로를 유지하려면 요청에 `model="gpt-5.5"`를 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 정식 출시 선택기를 강제로 지정하세요. +프롬프트가 관리하는 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 소유하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트에 고정된 모델을 추측하지 않도록 프리뷰 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 GA 경로를 유지하려면 요청에 `model="gpt-5.5"`를 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 GA 선택기를 강제하세요. -등록된 [`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 유효 요청 모델과 일치하는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. +등록된 [`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`는 유효한 요청 모델과 일치하는 기본 제공 선택기로 정규화됩니다. `ComputerTool`이 등록되어 있지 않으면 이러한 문자열은 일반 함수 이름처럼 계속 동작합니다. -프리뷰 호환 요청은 `environment`와 디스플레이 크기를 사전에 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리형 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 정식 출시 선택기를 강제로 지정해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. +프리뷰 호환 요청은 `environment`와 디스플레이 크기를 미리 직렬화해야 합니다. 따라서 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 전송하기 전에 GA 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. #### GPT-5 이외의 모델 -사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. +사용자 지정 `model_settings` 없이 GPT-5가 아닌 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. ### Responses 전용 도구 기능 다음 도구 기능은 OpenAI Responses 모델에서만 지원됩니다. -- [`ToolSearchTool`][agents.tool.ToolSearchTool] -- [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 인터페이스 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers`, `tool_choice="programmatic_tool_calling"` +- [`ToolSearchTool`][agents.tool.ToolSearchTool] +- [`tool_namespace()`][agents.tool.tool_namespace] +- `@function_tool(defer_loading=True)` 및 지연 로딩을 사용하는 기타 Responses 도구 인터페이스 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers` 및 `tool_choice="programmatic_tool_calling"` -이러한 기능은 Chat Completions 모델 및 Responses가 아닌 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 단독 네임스페이스 이름이나 지연 전용 함수 이름을 강제로 지정하는 대신 `auto` 또는 `required` 도구 선택을 통해 모델이 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 사항은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래매틱 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. +이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 네임스페이스 이름만 또는 지연 전용 함수 이름을 강제하는 대신 `auto`나 `required` 도구 선택을 통해 모델이 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 사항은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. ### Responses WebSocket 전송 -기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 웹소켓 전송을 선택적으로 활성화할 수 있습니다. +기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 WebSocket 전송을 사용하도록 설정할 수 있습니다. #### 기본 설정 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 프로바이더가 결정하는 OpenAI Responses 모델에 적용되며, `"gpt-5.6-sol"`과 같은 문자열 모델 이름도 포함됩니다. +이는 기본 OpenAI 프로바이더가 확인하는 OpenAI Responses 모델에 영향을 줍니다. 여기에는 `"gpt-5.6-sol"`과 같은 문자열 모델 이름도 포함됩니다. -전송 방식은 SDK가 모델 이름을 모델 인스턴스로 결정할 때 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 웹소켓을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 계속 사용합니다. `RunConfig(model_provider=...)`를 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식 선택을 제어합니다. +SDK가 모델 이름을 모델 인스턴스로 확인할 때 전송 방식이 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 계속 사용합니다. `RunConfig(model_provider=...)`를 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식을 제어합니다. #### 프로바이더 또는 실행 수준 설정 -프로바이더별 또는 실행별로 웹소켓 전송을 구성할 수도 있습니다. +프로바이더별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI 기반 프로바이더는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID와 같은 프로바이더 수준 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. +OpenAI 기반 프로바이더는 선택적인 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에 하네스 ID와 같은 프로바이더 수준 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. ```python from agents import ( @@ -190,14 +190,14 @@ result = await Runner.run( #### `MultiProvider`를 사용한 고급 라우팅 -접두사 기반 모델 라우팅이 필요한 경우(예: 한 번의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]를 사용하고 여기에서 `openai_use_responses_websocket=True`를 설정하세요. +접두사 기반 모델 라우팅이 필요한 경우, 예를 들어 한 번의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름을 혼합하려면 [`MultiProvider`][agents.MultiProvider]를 사용하고 여기에서 `openai_use_responses_websocket=True`를 설정하세요. -`MultiProvider`는 다음 두 가지 기존 기본 동작을 유지합니다. +`MultiProvider`는 기존의 두 가지 기본 동작을 유지합니다. -- `openai/...`는 OpenAI 프로바이더의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`로 라우팅됩니다. -- 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`를 발생시킵니다. +- `openai/...`는 OpenAI 프로바이더의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`로 라우팅됩니다. +- 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`를 발생시킵니다. -OpenAI 프로바이더가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키도록 설정하는 경우, 명시적으로 통과 동작을 활성화하세요. 웹소켓이 활성화된 설정에서는 `MultiProvider`에서도 `openai_use_responses_websocket=True`를 유지하세요. +OpenAI 프로바이더가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키는 경우, 통과 동작을 명시적으로 활성화하세요. WebSocket이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`를 유지하세요. ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -백엔드가 리터럴 `openai/...` 문자열을 요구하는 경우 `openai_prefix_mode="model_id"`를 사용하세요. 백엔드가 `openrouter/openai/gpt-4.1-mini`와 같은 다른 네임스페이스 모델 ID를 요구하는 경우 `unknown_prefix_mode="model_id"`를 사용하세요. 이러한 옵션은 웹소켓 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 웹소켓을 활성화된 상태로 유지합니다. 같은 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. +백엔드가 리터럴 `openai/...` 문자열을 요구하면 `openai_prefix_mode="model_id"`를 사용하세요. 백엔드가 `openrouter/openai/gpt-4.1-mini`와 같은 다른 네임스페이스 모델 ID를 요구하면 `unknown_prefix_mode="model_id"`를 사용하세요. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화된 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. -`MultiProvider`를 통해 라우팅하면서 동일한 프로바이더 수준 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`를 전달하면 내부 OpenAI 프로바이더로 전달됩니다. +`MultiProvider`를 통해 라우팅하면서 동일한 프로바이더 수준 등록 메타데이터가 필요하면 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`을 전달하세요. 이 값은 기본 OpenAI 프로바이더로 전달됩니다. -사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 웹소켓 전송에는 호환되는 웹소켓 `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. +사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 WebSocket 전송에는 호환되는 WebSocket `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. #### 참고 사항 -- 이는 [Realtime API](../realtime/guide.md)가 아니라 웹소켓 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않으며, Responses 웹소켓 `/responses` 엔드포인트를 지원하지 않는 OpenAI 이외의 프로바이더에도 적용되지 않습니다. -- 환경에 아직 없다면 `websockets` 패키지를 설치하세요. -- 웹소켓 전송을 활성화한 직후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 직접 사용할 수 있습니다. 여러 턴에 걸쳐 동일한 웹소켓 연결을 재사용하려는 워크플로에서는 중첩된 Agents-as-tools 호출을 포함하여 [`responses_websocket_session()`][agents.responses_websocket_session] 도우미를 사용하는 것이 좋습니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. -- 추론 턴이 길거나 네트워크 지연이 급증하는 경우 `responses_websocket_options`를 사용하여 웹소켓 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화된 상태로 유지하면서 하트비트 시간 제한을 비활성화하려면 `ping_timeout=None`을 설정하세요. 웹소켓 지연 시간보다 안정성이 더 중요하면 HTTP/SSE 전송을 사용하세요. -- SDK는 기본적으로 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 장기간 실행되는 에이전트 프로세스나 메모리가 제한된 컨테이너에서는 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`를 설정하여 메시지별 메모리 사용량을 제한하세요. -- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 각 연결을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 여세요. 병렬 실행이 필요한 경우 여러 연결을 사용하세요. -- 서비스는 연결 로컬 메모리에 가장 최근 응답만 유지합니다. 실패한 `4xx` 또는 `5xx` 턴은 참조된 `previous_response_id`를 제거합니다. 재연결 후에도 저장된 응답은 사용할 수 있는 경우 계속 이어갈 수 있지만, `store=False` 및 ZDR 흐름에는 영구 저장된 대체 수단이 없습니다. `previous_response_id=None`으로 새 체인을 시작하고 전체 입력 컨텍스트를 보내거나 로컬에서 관리하는 세션 상태를 사용하여 해당 컨텍스트를 다시 구성하세요. +- 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 통한 Responses API입니다. Chat Completions 또는 OpenAI 이외의 프로바이더가 Responses WebSocket `/responses` 엔드포인트를 지원하지 않는 한 적용되지 않습니다. +- 환경에 아직 설치되어 있지 않다면 `websockets` 패키지를 설치하세요. +- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 직접 사용할 수 있습니다. 여러 턴과 중첩된 Agents-as-tools 호출에서 동일한 WebSocket 연결을 재사용하려는 멀티턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 헬퍼를 사용하는 것이 좋습니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. +- 추론 턴이 길거나 네트워크 지연이 급증하는 경우 `responses_websocket_options`로 WebSocket 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화된 상태로 유지하면서 하트비트 시간 제한을 비활성화하려면 `ping_timeout=None`을 설정하세요. WebSocket 지연 시간보다 안정성이 더 중요할 때는 HTTP/SSE 전송을 사용하는 것이 좋습니다. +- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 장기간 실행되는 에이전트 프로세스나 메모리가 제한된 컨테이너에서는 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`를 설정하세요. +- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 각 연결을 60분으로 제한합니다. 이 제한 이후에는 새 연결을 여세요. 병렬 실행이 필요하면 여러 연결을 사용하세요. +- 서비스는 연결 로컬 메모리에 가장 최근 응답만 유지합니다. 실패한 `4xx` 또는 `5xx` 턴은 참조된 `previous_response_id`를 제거합니다. 다시 연결한 후에도 저장된 응답이 있다면 이어서 처리할 수 있지만, `store=False`와 ZDR 흐름에는 유지된 대체 데이터가 없습니다. `previous_response_id=None`으로 새 체인을 시작하고 전체 입력 컨텍스트를 전송하거나 로컬에서 관리하는 세션 상태로 해당 컨텍스트를 다시 구성하세요. ### 호스티드 멀티 에이전트(실험적) -OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 하위 에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 기존 `Runner`를 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 이루어지고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. +OpenAI Responses API의 호스티드 멀티 에이전트 베타에서는 GPT-5.6 루트 모델이 서버에서 호스팅되는 하위 에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`를 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. -이 통합은 실험적이며, 로컬 함수 출력을 `response.inject`를 통해 활성 호스티드 에이전트로 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`를 제공하는 베타 빌드를 포함한 `openai[realtime]>=2.45.0`이 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. +이 통합은 실험적이며 로컬 함수 출력을 `response.inject`를 통해 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`를 노출하는 베타 빌드를 포함하여 `openai[realtime]>=2.45.0`이 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. #### 모델 구성 @@ -260,13 +260,13 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`가 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`가 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`가 생략되면 서비스 기본값이 사용됩니다. +`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`가 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`가 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`를 생략하면 서비스 기본값이 사용됩니다. #### 로컬 함수 도구 -모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. Responses API는 어떤 호스티드 에이전트가 함수를 호출할지 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 있는 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 그러면 서비스가 원래의 호스티드 호출자를 재개할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 훅, 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`가 아닌 함수 도구는 요청이 전송되기 전에 거부됩니다. +모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. 어떤 호스티드 에이전트가 함수를 호출할지는 Responses API가 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 포함된 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 이를 통해 서비스가 원래 호스티드 호출자의 처리를 재개할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 훅 및 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`가 아닌 함수 도구는 요청이 전송되기 전에 거부됩니다. -도구에 호출자를 인식하는 로깅이나 권한 부여가 필요한 경우 `get_hosted_agent_metadata()`를 사용하세요. +도구에서 호출자를 인식하는 로깅이나 권한 부여가 필요하면 `get_hosted_agent_metadata()`를 사용하세요. ```python from typing import Any @@ -283,48 +283,48 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID를 사용하여 출력을 라우팅하세요. 부작용이 있는 도구에서는 해당 호출 ID를 멱등성 키로 사용하고, 도구 실행 전이나 도중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. +호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID로 출력을 라우팅하세요. 부작용이 있는 도구에서는 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. #### 출력 및 스트리밍 동작 -`final_answer` 단계가 있는 `/root`의 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 해당 레코드를 로컬 함수로 실행하지 않습니다. +단계가 `final_answer`인 `/root`에 귀속된 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 이러한 레코드를 로컬 함수로 실행하지 않습니다. -원문 스트리밍에서는 호스티드 출력 항목과 `response.inject.created` 확인 응답을 포함한 베타 Responses 이벤트가 계속 노출됩니다. 어댑터는 함수 호출이 준비되면 활성 프로바이더 응답 하나를 SDK에 표시되는 논리적 모델 턴으로 나누고, Runner가 출력을 생성한 후 동일한 프로바이더 응답을 재개합니다. 원문 호스티드 항목 또는 `ToolContext`와 함께 `get_hosted_agent_metadata()`를 사용하여 출처를 확인하세요. +원문 스트리밍은 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트를 계속 노출합니다. 함수 호출이 준비되면 어댑터는 하나의 활성 프로바이더 응답을 SDK에 표시되는 논리적 모델 턴으로 나누고, Runner가 출력을 생성한 후 동일한 프로바이더 응답을 재개합니다. 귀속 정보를 확인하려면 원문 호스티드 항목이나 `ToolContext`와 함께 `get_hosted_agent_metadata()`를 사용하세요. #### SDK 오케스트레이션과의 관계 -호스티드 멀티 에이전트는 SDK 핸드오프 및 agents-as-tools와 별개입니다. +호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. -- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 해당 하위 에이전트를 생성하거나 예약하지 않습니다. -- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 이 실험적 모델을 사용하면 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 핸드오프가 거부됩니다. -- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. +- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 이러한 하위 에이전트를 생성하거나 예약하지 않습니다. +- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 이 실험적 모델을 사용할 때는 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 핸드오프가 거부됩니다. +- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용 및 도구 노출을 신중하게 평가하세요. #### 현재 제한 사항 -실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공한 `multi_agent` 또는 `betas` 재정의를 거부합니다. Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 다만 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`는 사용할 수 있습니다. +실험적 모델은 `reasoning.summary`, `max_tool_calls` 및 호출자가 제공한 `multi_agent`나 `betas` 재정의를 거부합니다. 명시적인 `context_management.compact_threshold`는 사용할 수 있지만 Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하기 때문입니다. -하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단된 경우 `await model.close()`를 호출하여 WebSocket을 해제하세요. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. +하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답만 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단된 경우 `await model.close()`를 호출하여 WebSocket을 해제하세요. 현재는 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원할 수 없습니다. 기본 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)를 참조하세요. ## OpenAI 이외의 모델 -OpenAI 이외의 프로바이더가 필요한 경우 SDK의 기본 제공 프로바이더 통합 지점부터 시작하세요. 많은 설정에서 서드 파티 어댑터를 추가하지 않아도 이것만으로 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. +OpenAI 이외의 프로바이더가 필요하면 SDK의 기본 제공 프로바이더 통합 지점으로 시작하세요. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 이것으로 충분합니다. 각 패턴의 코드 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. -### OpenAI 이외의 프로바이더 통합 방식 +### OpenAI 이외의 프로바이더 통합 방법 | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트를 대부분 또는 모든 에이전트의 기본값으로 사용해야 하는 경우 | 전역 기본값 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 프로바이더를 단일 실행에 적용해야 하는 경우 | 실행별 | -| [`Agent.model`][agents.agent.Agent.model] | 서로 다른 에이전트에 서로 다른 프로바이더 또는 구체적인 모델 객체가 필요한 경우 | 에이전트별 | -| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 어댑터 관리형 프로바이더 지원 범위 또는 라우팅이 필요한 경우 | [서드 파티 어댑터](#third-party-adapters) 참조 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트를 대부분 또는 모든 에이전트의 기본값으로 사용해야 할 때 | 전역 기본값 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 프로바이더를 단일 실행에 적용해야 할 때 | 실행별 | +| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 서로 다른 프로바이더나 구체적인 모델 객체가 필요할 때 | 에이전트별 | +| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 어댑터 관리형 프로바이더 지원 범위나 라우팅이 필요할 때 | [서드 파티 어댑터](#third-party-adapters) 참조 | -다음과 같은 기본 제공 경로를 사용하여 다른 LLM 프로바이더를 통합할 수 있습니다. +다음 기본 제공 경로를 사용하여 다른 LLM 프로바이더를 통합할 수 있습니다. -1. [`set_default_openai_client`][agents.set_default_openai_client]는 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우 유용합니다. 이는 LLM 프로바이더에 OpenAI 호환 API 엔드포인트가 있고 `base_url`과 `api_key`를 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. -2. [`ModelProvider`][agents.models.interface.ModelProvider]는 `Runner.run` 수준에 적용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 프로바이더를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. -3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 프로바이더를 조합하여 사용할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. +1. [`set_default_openai_client`][agents.set_default_openai_client]는 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우 유용합니다. LLM 프로바이더에 OpenAI 호환 API 엔드포인트가 있고 `base_url` 및 `api_key`를 설정할 수 있는 경우에 적합합니다. 구성 가능한 코드 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. +2. [`ModelProvider`][agents.models.interface.ModelProvider]는 `Runner.run` 수준에서 사용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 프로바이더 사용"을 지정할 수 있습니다. 구성 가능한 코드 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. +3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트마다 서로 다른 프로바이더를 조합하여 사용할 수 있습니다. 구성 가능한 코드 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. `platform.openai.com`에서 발급한 API 키가 없는 경우 `set_tracing_disabled()`를 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것이 좋습니다. @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 이 예제에서는 많은 LLM 프로바이더가 아직 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. 사용 중인 LLM 프로바이더가 Responses를 지원한다면 Responses를 사용하는 것이 좋습니다. + 이 코드 예제에서는 아직 많은 LLM 프로바이더가 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 프로바이더가 Responses API를 지원한다면 Responses를 사용하는 것이 좋습니다. ## 하나의 워크플로에서 모델 혼합 -단일 워크플로 내에서 에이전트별로 서로 다른 모델을 사용해야 할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고 복잡한 작업에는 더 크고 강력한 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. +단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용해야 할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고 복잡한 작업에는 더 크고 성능이 우수한 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. 1. 모델 이름 전달 2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider] 전달 -3. [`Model`][agents.models.interface.Model] 구현을 직접 제공 +3. [`Model`][agents.models.interface.Model] 구현 직접 제공 !!! note - SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]과 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만 두 형식이 서로 다른 기능과 도구 집합을 지원하므로 각 워크플로에서 하나의 모델 형식을 사용하는 것이 좋습니다. 워크플로에 모델 형식 혼합이 필요한 경우 사용 중인 모든 기능을 두 형식 모두에서 사용할 수 있는지 확인하세요. + SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 및 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식에서 지원하는 기능과 도구 집합이 다르므로 각 워크플로에서는 단일 모델 형식을 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용하는 모든 기능을 양쪽에서 사용할 수 있는지 확인하세요. ```python import asyncio @@ -391,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAI 모델의 이름을 직접 설정합니다. -2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. +1. OpenAI 모델 이름을 직접 설정합니다. +2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. -에이전트에 사용되는 모델을 더 세부적으로 구성하려면 temperature와 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.model_settings.ModelSettings]를 전달할 수 있습니다. +에이전트에 사용되는 모델을 추가로 구성하려면 temperature와 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.model_settings.ModelSettings]를 전달할 수 있습니다. ```python from agents import Agent, ModelSettings @@ -409,22 +409,22 @@ english_agent = Agent( ## 고급 OpenAI Responses 설정 -OpenAI Responses 경로에서 더 많은 제어가 필요하면 `ModelSettings`부터 사용하세요. +OpenAI Responses 경로를 사용하면서 더 세밀한 제어가 필요하다면 `ModelSettings`부터 시작하세요. ### 일반적인 고급 `ModelSettings` 옵션 OpenAI Responses API를 사용할 때 여러 요청 필드에는 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 `extra_args`를 사용할 필요가 없습니다. -- `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출 허용 또는 금지 -- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 삭제하도록 `"auto"` 설정 +- `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출을 허용하거나 금지합니다. +- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 제거하도록 `"auto"`를 설정합니다. - `store`: 생성된 응답을 나중에 검색할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로와 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. -- `context_management`: `compact_threshold`를 사용하는 Responses 압축 등 서버 측 컨텍스트 처리 구성 -- `prompt_cache_retention`: 이전 모델 계열에 대한 연장 보존 구성(예: - `"24h"`) -- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고 GPT-5.6에서는 `"30m"` 캐시 TTL 구성 -- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 등 더 풍부한 응답 페이로드 요청 -- `top_logprobs`: 출력 텍스트의 상위 토큰 로그 확률 요청. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. -- `retry`: 모델 호출에 Runner 관리형 재시도 설정을 선택적으로 활성화합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. +- `context_management`: `compact_threshold`를 사용하는 Responses 압축과 같은 서버 측 컨텍스트 처리를 구성합니다. +- `prompt_cache_retention`: 이전 모델 제품군의 연장된 보존 기간을 구성합니다. 예를 들면 + `"24h"`입니다. +- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고 GPT-5.6에서는 `"30m"` 캐시 TTL을 구성합니다. +- `response_include`: `web_search_call.action.sources`, `file_search_call.results` 또는 `reasoning.encrypted_content`와 같은 더 풍부한 응답 페이로드를 요청합니다. +- `top_logprobs`: 출력 텍스트의 상위 토큰 logprobs를 요청합니다. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. +- `retry`: 모델 호출에 대한 Runner 관리형 재시도 설정을 활성화합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 유지합니다. +명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드가 Responses 및 Chat Completions 요청에 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오 및 파일 콘텐츠 부분의 중단점을 보존합니다. ```python from agents import Runner @@ -470,19 +470,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention`은 기존 보존 제어를 사용하는 이전 모델 계열에서 계속 사용할 수 -있습니다. 직접적인 `ModelSettings` 필드와 동일한 키를 `extra_args`에서 함께 -사용하지 마세요. +`prompt_cache_retention`은 레거시 보존 제어를 사용하는 이전 모델 제품군에서 계속 사용할 수 있습니다. +직접적인 `ModelSettings` 필드와 동일한 키를 `extra_args`에서 함께 사용하지 마세요. -`store=False`를 설정하면 Responses API는 해당 응답을 나중에 서버 측에서 검색할 수 있도록 보관하지 않습니다. 이는 상태 비저장 또는 데이터 미보존 방식의 흐름에 유용하지만, 응답 ID를 재사용할 수 있었던 기능이 대신 로컬에서 관리하는 상태에 의존해야 한다는 의미이기도 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않은 경우 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. +`store=False`를 설정하면 Responses API는 나중에 서버 측에서 검색할 수 있도록 해당 응답을 보관하지 않습니다. 이는 상태 비저장 또는 데이터 무보존 방식의 흐름에 유용하지만, 원래 응답 ID를 재사용하는 기능은 대신 로컬에서 관리하는 상태를 사용해야 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않았을 때 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. 서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`는 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. ### `extra_args` 전달 -SDK가 아직 최상위 수준에서 직접 노출하지 않는 프로바이더별 또는 최신 요청 필드가 필요할 때 `extra_args`를 사용하세요. +SDK가 아직 최상위 수준에서 직접 노출하지 않는 프로바이더별 요청 필드나 최신 요청 필드가 필요할 때 `extra_args`를 사용하세요. -또한 OpenAI의 Responses API를 사용할 때 [몇 가지 다른 선택적 매개변수](https://platform.openai.com/docs/api-reference/responses/create)(예: `user`, `service_tier` 등)가 있습니다. 최상위 수준에서 사용할 수 없는 경우 `extra_args`를 사용하여 전달할 수도 있습니다. 직접적인 `ModelSettings` 필드를 통해 동일한 요청 필드를 함께 설정하지 마세요. +OpenAI 모델을 사용할 때 `extra_args`는 Responses API와 Chat Completions API 모두에 선택적 매개변수를 전달할 수 있습니다. 예를 들면 `user` 및 `service_tier`입니다. 지원되는 모델에서는 [Fast 모드](https://developers.openai.com/api/docs/guides/fast-mode)를 사용하도록 `extra_args={"service_tier": "fast"}`를 설정하세요. `"priority"`도 동일하게 동작합니다. 동일한 요청 필드를 직접적인 `ModelSettings` 필드에도 설정하지 마세요. ```python from agents import Agent, ModelSettings @@ -500,9 +499,9 @@ english_agent = Agent( ## Runner 관리형 재시도 -재시도는 런타임 전용이며 선택적으로 활성화됩니다. `ModelSettings(retry=...)`를 설정하고 재시도 정책에서 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. +재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`를 설정하고 재시도 정책에서 재시도를 선택하지 않으면 SDK는 일반 모델 요청을 재시도하지 않습니다. -Responses 웹소켓 전송에서 `retry_policies.provider_suggested()`는 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`가 필요하며 일반적인 재실행 안전성 검사도 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. +Responses WebSocket 전송에서 `retry_policies.provider_suggested()`는 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`가 필요하며 일반적인 재실행 안전성 검사도 계속 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -534,81 +533,81 @@ agent = Agent(
-| 필드 | 유형 | 참고 | +| 필드 | 유형 | 참고 사항 | | --- | --- | --- | -| `max_retries` | `int | None` | 최초 요청 이후 허용되는 재시도 횟수입니다. | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`는 이렇게 계산된 백오프 지연에만 상한을 적용합니다. 정책이 반환한 명시적 지연이나 retry-after 힌트에는 상한을 적용하지 않습니다. | +| `max_retries` | `int | None` | 최초 요청 후 허용되는 재시도 횟수 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`는 계산된 이 백오프 지연만 제한합니다. 정책이 반환한 명시적 지연이나 retry-after 힌트는 제한하지 않습니다. | | `policy` | `RetryPolicy | None` | 재시도 여부를 결정하는 콜백입니다. 이 필드는 런타임 전용이며 직렬화되지 않습니다. |
재시도 정책은 다음 항목이 포함된 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. -- 시도 횟수를 고려하여 결정할 수 있도록 제공되는 `attempt` 및 `max_retries` +- 시도 횟수를 고려한 결정을 내릴 수 있도록 제공되는 `attempt` 및 `max_retries` - 스트리밍 및 비스트리밍 동작을 분기할 수 있도록 제공되는 `stream` - 원문 검사를 위한 `error` - `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort`와 같은 정규화된 정보가 포함된 `normalized` -- 내부 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용되는 `provider_advice` +- 기본 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용되는 `provider_advice` 정책은 다음 중 하나를 반환할 수 있습니다. - 간단한 재시도 결정을 위한 `True` / `False` - 지연 시간을 재정의하거나 진단 사유를 첨부하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] -SDK는 `retry_policies`에 즉시 사용할 수 있는 도우미를 제공합니다. +SDK는 `retry_policies`에 바로 사용할 수 있는 헬퍼를 제공합니다. -| 도우미 | 동작 | +| 헬퍼 | 동작 | | --- | --- | | `retry_policies.never()` | 항상 재시도하지 않습니다. | -| `retry_policies.provider_suggested()` | 프로바이더 재시도 지침이 있으면 이를 따릅니다. | -| `retry_policies.network_error()` | 일시적인 전송 및 시간 제한 실패와 일치합니다. | +| `retry_policies.provider_suggested()` | 사용 가능한 경우 프로바이더의 재시도 지침을 따릅니다. | +| `retry_policies.network_error()` | 일시적인 전송 및 시간 제한 오류와 일치합니다. | | `retry_policies.http_status([...])` | 선택한 HTTP 상태 코드와 일치합니다. | -| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 도우미는 retry-after 값을 명시적 정책 지연으로 처리하므로 `backoff.max_delay`가 상한을 적용하지 않습니다. | +| `retry_policies.retry_after()` | retry-after 힌트를 사용할 수 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 헬퍼는 retry-after 값을 명시적인 정책 지연으로 처리하므로 `backoff.max_delay`가 이를 제한하지 않습니다. | | `retry_policies.any(...)` | 중첩된 정책 중 하나라도 재시도를 선택하면 재시도합니다. | -| `retry_policies.all(...)` | 모든 중첩 정책이 재시도를 선택할 때만 재시도합니다. | +| `retry_policies.all(...)` | 중첩된 모든 정책이 재시도를 선택할 때만 재시도합니다. | -정책을 조합할 때는 `provider_suggested()`가 가장 안전한 첫 번째 기본 구성 요소입니다. 프로바이더가 거부 및 재실행 안전성 승인을 구분할 수 있는 경우 이를 보존하기 때문입니다. +정책을 조합할 때는 `provider_suggested()`가 가장 안전한 첫 번째 기본 구성 요소입니다. 프로바이더가 거부 및 재실행 안전성 승인을 구분할 수 있을 때 이를 보존하기 때문입니다. ##### 안전 경계 일부 실패는 자동으로 재시도되지 않습니다. - 중단 오류 -- 프로바이더 지침에서 재실행이 안전하지 않다고 표시한 요청 +- 프로바이더 지침이 재실행을 안전하지 않다고 표시한 요청 - 출력이 이미 시작되어 재실행이 안전하지 않은 스트리밍 실행 -`previous_response_id` 또는 `conversation_id`를 사용하는 상태 유지형 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()`나 `http_status([500])`와 같은 비프로바이더 조건만으로 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`를 통한 프로바이더의 재실행 안전성 승인이 포함되어야 합니다. +`previous_response_id` 또는 `conversation_id`를 사용하는 상태 유지형 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()`나 `http_status([500])` 같은 프로바이더 외부 조건만으로는 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`를 통한 프로바이더의 재실행 안전성 승인이 포함되어야 합니다. -##### Runner 및 에이전트 병합 동작 +##### Runner와 에이전트의 병합 동작 -`retry`는 Runner 수준 및 에이전트 수준의 `ModelSettings` 간에 깊은 병합이 적용됩니다. +`retry`는 Runner 수준 및 에이전트 수준 `ModelSettings` 사이에서 깊은 병합됩니다. -- 에이전트가 `retry.max_retries`만 재정의하고 Runner의 `policy`를 계속 상속할 수 있습니다. -- 에이전트가 `retry.backoff`의 일부만 재정의하고 Runner의 나머지 백오프 필드를 유지할 수 있습니다. -- `policy`는 런타임 전용이므로 직렬화된 `ModelSettings`에는 `max_retries`와 `backoff`가 유지되지만 콜백 자체는 생략됩니다. +- 에이전트는 `retry.max_retries`만 재정의하면서 Runner의 `policy`를 상속할 수 있습니다. +- 에이전트는 `retry.backoff`의 일부만 재정의하고 Runner의 다른 백오프 필드를 유지할 수 있습니다. +- `policy`는 런타임 전용이므로 직렬화된 `ModelSettings`는 `max_retries` 및 `backoff`를 유지하지만 콜백 자체는 생략합니다. -더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. +더 자세한 코드 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 코드 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. ## OpenAI 이외의 프로바이더 문제 해결 ### 트레이싱 클라이언트 오류 401 -트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되는데 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. +트레이싱 관련 오류가 발생한다면 트레이스가 OpenAI 서버로 업로드되는데 OpenAI API 키가 없기 때문입니다. 이 문제를 해결하는 방법은 세 가지입니다. -1. 트레이싱을 완전히 비활성화: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] -2. 트레이싱용 OpenAI 키 설정: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급한 키여야 합니다. -3. OpenAI 이외의 트레이스 프로세서 사용. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. +1. 트레이싱을 완전히 비활성화합니다: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] +2. 트레이싱용 OpenAI 키를 설정합니다: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급한 키여야 합니다. +3. OpenAI 이외의 트레이스 프로세서를 사용합니다. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. ### Responses API 지원 -SDK는 기본적으로 Responses API를 사용하지만 다른 많은 LLM 프로바이더는 아직 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. +SDK는 기본적으로 Responses API를 사용하지만, 아직 많은 다른 LLM 프로바이더가 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 이를 해결하는 방법은 두 가지입니다. -1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] 호출. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. -2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 사용. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. +1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]를 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. +2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 코드 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. ### Chat Completions 호환성 옵션 -Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, 프롬프트 또는 텍스트 전용이 아닌 도구 출력 등 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 자동으로 삭제하여 호환성을 유지합니다. 개발 중 이러한 불일치를 즉시 실패로 처리하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화하세요. +Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, 프롬프트 또는 텍스트 전용이 아닌 도구 출력처럼 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 자동으로 제거하여 호환성을 유지합니다. 개발 중 이러한 불일치가 즉시 실패하도록 하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화하세요. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -628,7 +627,7 @@ result = await Runner.run( [`MultiProvider`][agents.MultiProvider]를 사용하는 경우 대신 `openai_strict_feature_validation=True`를 전달하세요. -일부 OpenAI 호환 Chat Completions 프로바이더는 점진적 SDK 처리에 충분히 신뢰할 수 없는 청크 형태로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 프로바이더 스트림이 완료된 후에만 SDK가 도구 호출을 내보내도록 하세요. +일부 OpenAI 호환 Chat Completions 프로바이더는 점진적인 SDK 처리에 충분히 신뢰할 수 없는 청크 형태로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍된 도구 호출 버퍼링을 활성화하여 프로바이더 스트림이 종료된 후에만 SDK가 도구 호출을 내보내도록 하세요. ```python from agents import OpenAIProvider @@ -641,9 +640,9 @@ provider = OpenAIProvider( [`MultiProvider`][agents.MultiProvider]에서는 `openai_buffer_streamed_tool_calls=True`를 사용하세요. -### Structured outputs 지원 +### structured outputs 지원 -일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 지원하지 않습니다. 이 경우 때때로 다음과 같은 오류가 발생합니다. +일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이 경우 다음과 유사한 오류가 발생할 수 있습니다. ``` @@ -651,42 +650,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`를 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만 JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 JSON 때문에 앱이 자주 중단될 수 있습니다. +이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`는 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. -## 여러 프로바이더의 모델 혼합 +## 프로바이더 간 모델 혼합 모델 프로바이더 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. -- 이해하지 못하는 프로바이더에 지원되지 않는 `tools`를 전송하지 마세요. -- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. -- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 잘못된 JSON을 생성할 수 있다는 점에 유의하세요. +- 이해하지 못하는 프로바이더에 지원되지 않는 `tools`를 전송하지 마세요. +- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. +- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 유효하지 않은 JSON을 생성할 수 있다는 점에 유의하세요. ## 서드 파티 어댑터 -SDK의 기본 제공 프로바이더 통합 지점만으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 프로바이더와 결합하거나, 기본 제공 경로가 제공하지 않는 어댑터 관리형 프로바이더 지원 범위 또는 라우팅이 필요한 경우에 사용합니다. 어댑터는 SDK와 상위 모델 프로바이더 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 프로바이더별로 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선 지원 방식의 베타 어댑터 통합으로 포함되어 있습니다. +SDK의 기본 제공 프로바이더 통합 지점으로 충분하지 않을 때만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM이나 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델과 OpenAI 이외의 프로바이더를 결합해야 하거나 기본 제공 경로에서 제공하지 않는 어댑터 관리형 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위한 것입니다. 어댑터는 SDK와 업스트림 모델 프로바이더 사이에 또 다른 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 프로바이더마다 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선형 베타 어댑터 통합으로 포함되어 있습니다. ### Any-LLM -Any-LLM이 관리하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 Any-LLM 지원이 최선 지원 방식의 베타로 포함되어 있습니다. +Any-LLM 지원은 Any-LLM이 관리하는 프로바이더 지원 범위나 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. -상위 프로바이더 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 프로바이더별 호환성 계층을 사용할 수 있습니다. +업스트림 프로바이더 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 프로바이더별 호환성 계층을 사용할 수 있습니다. -Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 후 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)부터 시작하세요. [`MultiProvider`][agents.MultiProvider]에서 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`를 사용할 수 있습니다. 모델 인터페이스를 명시적으로 고정해야 하는 경우 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`를 전달하세요. +Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 후 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)부터 시작하세요. [`MultiProvider`][agents.MultiProvider]와 함께 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`를 사용할 수 있습니다. 모델 인터페이스를 명시적으로 고정해야 한다면 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`를 전달하세요. -Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 차이는 SDK가 아닌 Any-LLM 상위 계층에서 정의합니다. 상위 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드가 사용량 청크를 내보내려면 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 전용 동작에 의존하는 경우 배포하려는 정확한 프로바이더 백엔드를 검증하세요. +Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의됩니다. 업스트림 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드는 사용량 청크를 내보내기 전에 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 전용 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. ### LiteLLM -LiteLLM별 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 LiteLLM 지원이 최선 지원 방식의 베타로 포함되어 있습니다. +LiteLLM 지원은 LiteLLM별 프로바이더 지원 범위나 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. LiteLLM이 필요하면 `openai-agents[litellm]`을 설치한 후 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)부터 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. -일부 LiteLLM 기반 프로바이더는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요한 경우 `ModelSettings(include_usage=True)`를 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. +일부 LiteLLM 기반 프로바이더는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요하면 `ModelSettings(include_usage=True)`를 전달하세요. structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. -LiteLLM이 응답 객체에 대한 Pydantic 직렬화 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 선택적으로 활성화할 수 있습니다. +LiteLLM이 응답 객체에 대한 Pydantic 직렬화 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -이 패치는 기본적으로 비활성화되며 `1` 또는 `true` 값에 대해서만 활성화됩니다. 비공개 LiteLLM 로깅 도우미를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로 일반 직렬화 설정이 아닌 특정 문제를 위한 우회책으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고 상위 계층에서 더 이상 경고가 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file +패치는 기본적으로 비활성화되어 있으며 값이 `1` 또는 `true`인 경우에만 활성화됩니다. 비공개 LiteLLM 로깅 헬퍼를 래핑하여 특정 종류의 LiteLLM 응답 직렬화 경고를 억제하므로, 일반적인 직렬화 설정이 아니라 특정 문제를 위한 우회 방법으로 사용하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file diff --git a/docs/zh/config.md b/docs/zh/config.md index bb05f963f3..cb291df7d7 100644 --- a/docs/zh/config.md +++ b/docs/zh/config.md @@ -4,21 +4,40 @@ search: --- # 配置 -本页涵盖 SDK 范围的默认设置,这些设置通常在应用启动时设置一次,例如默认 OpenAI 密钥或客户端、默认 OpenAI API 形态、追踪导出默认设置以及日志行为。 +本页介绍通常在应用启动时一次性设置的 SDK 全局默认值,例如默认OpenAI密钥或客户端、默认OpenAI API 形式、追踪导出默认值以及日志记录行为。 -这些默认设置仍适用于基于沙盒的工作流,但沙盒工作区、沙盒客户端和会话复用需单独配置。 +这些默认值同样适用于基于沙箱的工作流,但沙箱工作区、沙箱客户端和会话复用需单独配置。 -如果你需要改为配置特定智能体或运行,请从以下内容开始: +如果需要配置特定智能体或运行,请先参阅: -- [智能体](agents.md),了解普通 `Agent` 上的 instructions、tools、输出类型、任务转移和安全防护措施。 -- [运行智能体](running_agents.md),了解 `RunConfig`、会话和对话状态选项。 -- [沙盒智能体](sandbox/guide.md),了解 `SandboxRunConfig`、清单、能力以及特定于沙盒客户端的工作区设置。 -- [模型](models/index.md),了解模型选择和提供方配置。 -- [追踪](tracing.md),了解每次运行的追踪元数据和自定义追踪进程。 +- [智能体](agents.md):了解普通 `Agent` 的指令、工具、输出类型、任务转移和安全防护措施。 +- [运行智能体](running_agents.md):了解 `RunConfig`、会话和对话状态选项。 +- [沙箱智能体](sandbox/guide.md):了解 `SandboxRunConfig`、清单、能力和沙箱客户端专用的工作区设置。 +- [模型](models/index.md):了解模型选择和提供商配置。 +- [追踪](tracing.md):了解每次运行的追踪元数据和自定义追踪进程。 -## API 密钥和客户端 +## 配置对象与字典 -默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量进行 LLM 请求和追踪。密钥会在 SDK 首次创建 OpenAI 客户端时解析(惰性初始化),因此请在第一次模型调用之前设置该环境变量。如果无法在应用启动前设置该环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数来设置密钥。 +SDK 管理的配置参数通常既接受类型化设置对象,也接受包含相同字段的字典。这适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。嵌套的 SDK 管理设置也可以使用字典。 + +```python +from agents import Agent + +agent = Agent( + name="Assistant", + model="gpt-5.6-sol", + model_settings={ + "reasoning": {"effort": "high"}, + "verbosity": "low", + }, +) +``` + +SDK 会将这些字典规范化为相应的设置对象。SDK 管理的 dataclass 配置中出现未知字段时会引发 `TypeError`,这有助于尽早发现拼写错误的选项名称。请查看参数的类型注解或 API 参考文档,以确认特定边界是否接受字典。 + +## API 密钥与客户端 + +默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理 LLM 请求和追踪。SDK 首次创建OpenAI客户端时才会解析该密钥(延迟初始化),因此请在首次调用模型之前设置此环境变量。如果无法在应用启动前设置该环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 ```python from agents import set_default_openai_key @@ -26,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -或者,也可以配置要使用的 OpenAI 客户端。默认情况下,SDK 会创建一个 `AsyncOpenAI` 实例,并使用环境变量中的 API 密钥或上面设置的默认密钥。可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此行为。 +或者,您也可以配置要使用的OpenAI客户端。默认情况下,SDK 会创建一个 `AsyncOpenAI` 实例,并使用环境变量中的 API 密钥或上文设置的默认密钥。您可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此行为。 ```python from openai import AsyncOpenAI @@ -36,14 +55,14 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -如果你偏好基于环境变量的端点配置,默认 OpenAI 提供方也会读取 `OPENAI_BASE_URL`。启用 Responses websocket 传输时,它还会读取 `OPENAI_WEBSOCKET_BASE_URL`,用于 websocket `/responses` 端点。 +如果您倾向于通过环境变量配置端点,默认OpenAI提供商还会读取 `OPENAI_BASE_URL`。启用 Responses WebSocket 传输时,它还会读取 `OPENAI_WEBSOCKET_BASE_URL`,作为 WebSocket `/responses` 端点。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -最后,也可以自定义所使用的 OpenAI API。默认情况下,我们使用 OpenAI Responses API。可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数覆盖此设置,改用 Chat Completions API。 +最后,您还可以自定义所使用的OpenAI API。默认情况下,我们使用OpenAI Responses API。您可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数覆盖此设置,改用Chat Completions API。 ```python from agents import set_default_openai_api @@ -51,9 +70,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 提供方默认设置 +## OpenAI提供商默认值 -由 OpenAI 支持的提供方在解析模型名称时也会读取 SDK 范围的默认设置。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可让 OpenAI Responses 模型默认使用 websocket 传输: +基于OpenAI的提供商在解析模型名称时,也会读取 SDK 全局默认值。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使OpenAI Responses 模型默认使用 WebSocket 传输: ```python from agents import set_default_openai_responses_transport @@ -61,9 +80,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响由默认 OpenAI 提供方解析的 OpenAI Responses 模型。有关提供方级别设置、连接复用、keepalive 选项以及自定义 websocket 端点,请参见 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 +这会影响由默认OpenAI提供商解析的OpenAI Responses 模型。有关提供商级设置、连接复用、保活选项和自定义 WebSocket 端点,请参阅 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 -如果你的 OpenAI 设置需要提供方级别的智能体注册元数据,请在启动时配置一次默认 harness ID: +如果您的OpenAI设置需要提供商级智能体注册元数据,请在启动时一次性配置默认 harness ID: ```python from agents import set_default_openai_harness @@ -71,7 +90,7 @@ from agents import set_default_openai_harness set_default_openai_harness("your-harness-id") ``` -你也可以传入完整的注册对象: +您也可以传入完整的注册对象: ```python from agents import OpenAIAgentRegistrationConfig, set_default_openai_agent_registration @@ -81,11 +100,11 @@ set_default_openai_agent_registration( ) ``` -如果未设置 SDK 默认值,由 OpenAI 支持的提供方会回退使用 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 +如果未设置 SDK 默认值,基于OpenAI的提供商会回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 ## 追踪 -追踪默认启用。默认情况下,它使用与上一节中的模型请求相同的 OpenAI API 密钥(即环境变量中的密钥或你设置的默认密钥)。可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 +追踪默认启用。默认情况下,它使用与上一节模型请求相同的OpenAI API 密钥(即环境变量中的密钥或您设置的默认密钥)。您可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 ```python from agents import set_tracing_export_api_key @@ -93,7 +112,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -如果模型流量使用一个密钥或客户端,但追踪应使用另一个 OpenAI 密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果不使用自定义客户端,同样的模式也适用于 [`set_default_openai_key()`][agents.set_default_openai_key]。 +如果模型流量使用一个密钥或客户端,而追踪需要使用另一个OpenAI密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果未使用自定义客户端,则 [`set_default_openai_key()`][agents.set_default_openai_key] 也适用相同的模式。 ```python from openai import AsyncOpenAI @@ -108,14 +127,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -如果在使用默认导出器时需要将追踪归因到特定组织或项目,请在应用启动前设置这些环境变量: +使用默认导出器时,如果需要将追踪归属到特定组织或项目,请在应用启动前设置以下环境变量: ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -也可以为每次运行设置追踪 API 密钥,而不更改全局导出器。 +您也可以为每次运行设置追踪 API 密钥,而无需更改全局导出器。 ```python from agents import Runner, RunConfig @@ -127,7 +146,7 @@ await Runner.run( ) ``` -还可以使用 [`set_tracing_disabled()`][agents.set_tracing_disabled] 函数完全禁用追踪。 +您还可以使用 [`set_tracing_disabled()`][agents.set_tracing_disabled] 函数完全禁用追踪。 ```python from agents import set_tracing_disabled @@ -135,7 +154,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -如果想保持追踪启用,但从追踪载荷中排除可能敏感的输入/输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: +如果希望保持启用追踪,但从追踪载荷中排除可能包含敏感信息的输入和输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: ```python from agents import Runner, RunConfig @@ -147,19 +166,19 @@ await Runner.run( ) ``` -也可以在应用启动前设置此环境变量,从而无需改代码即可更改默认值: +您也可以在应用启动前设置以下环境变量,无需编写代码即可更改默认值: ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -有关完整的追踪控制,请参见[追踪指南](tracing.md)。 +有关完整的追踪控制选项,请参阅[追踪指南](tracing.md)。 ## 调试日志 -SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),并且默认不附加处理程序。日志遵循应用的 Python 日志配置。 +SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加任何处理器。日志遵循应用的 Python 日志配置。 -要启用详细日志,请使用 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 函数。 +如需启用详细日志记录,请使用 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 函数。 ```python from agents import enable_verbose_stdout_logging @@ -167,7 +186,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -或者,可以通过添加处理程序、过滤器、格式化器等来自定义日志。更多信息可参见 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 +或者,您也可以通过添加处理器、过滤器、格式化器等来自定义日志。有关更多信息,请参阅 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 ```python import logging @@ -186,20 +205,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 日志中的敏感数据 +### 日志与诊断中的敏感数据 -某些日志可能包含敏感数据(例如用户数据)。 +某些日志和诊断异常可能包含敏感数据,例如模型或工具的输入和输出。 -默认情况下,SDK **不会** 记录 LLM 输入/输出或工具输入/输出。这些保护由以下设置控制: +默认情况下,SDK **不会**记录 LLM 输入/输出或工具输入/输出。这些保护措施由以下配置控制: ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -如果需要临时包含这些数据以进行调试,请在应用启动前将任一变量设置为 `0`(或 `false`): +如果需要暂时包含这些数据以便调试,请在应用启动前将任一变量设置为 `0`(或 `false`): ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 -``` \ No newline at end of file +``` + +这些标志还控制受影响的故障是否保留包含载荷的诊断详情。例如,启用工具数据编校后,工具调用的参数无效会引发通用的 `ModelBehaviorError`,且不会将底层验证错误链接为异常链。将任一变量设置为 `0` 可能会在日志、异常消息、异常链及其他诊断上下文中暴露原始模型或工具数据,因此请仅在受控的开发环境中启用。 \ No newline at end of file diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index b1b35c9de5..3b2e548c4d 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -4,7 +4,7 @@ search: --- # 安全防护措施 -安全防护措施支持对用户输入和智能体输出进行检查与验证。例如,假设你有一个使用非常智能(因而速度较慢、费用较高)的模型来帮助处理客户请求的智能体。你不会希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用一个快速且低成本的模型运行安全防护措施。如果安全防护措施检测到恶意使用行为,它可以立即引发错误,并阻止高成本模型运行,从而为你节省时间和费用(**使用阻塞式安全防护措施时如此;对于并行安全防护措施,高成本模型可能已在安全防护措施完成前开始运行。有关详细信息,请参阅下方的“执行模式”**)。 +安全防护措施可用于检查和验证用户输入及智能体输出。例如,假设你有一个使用非常智能(因而速度较慢、成本较高)的模型来协助处理客户请求的智能体。你不会希望恶意用户要求模型帮助他们完成数学作业。因此,你可以使用一个快速且成本较低的模型来运行安全防护措施。如果安全防护措施检测到恶意使用,它可以立即引发错误并阻止高成本模型运行,从而为你节省时间和费用(**使用阻塞式安全防护措施时;对于并行安全防护措施,高成本模型可能在安全防护措施完成前就已开始运行。有关详细信息,请参阅下文的“执行模式”**)。 安全防护措施分为两类: @@ -13,68 +13,70 @@ search: ## 工作流边界 -安全防护措施会附加到智能体和工具上,但它们并非都在工作流中的相同节点运行: +安全防护措施会附加到智能体和工具,但它们并不全都在工作流中的相同节点运行: - **输入安全防护措施**仅针对链中的第一个智能体运行。 - **输出安全防护措施**仅针对生成最终输出的智能体运行。 -- **工具安全防护措施**会在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 +- **工具安全防护措施**会在每次自定义工具调用时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 -如果需要检查包含管理智能体、任务转移或委派专家的工作流中的每次自定义函数工具调用,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 +如果需要检查包含管理智能体、任务转移或受委派专家的工作流中的每次自定义工具调用,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 ## 输入安全防护措施 输入安全防护措施分 3 个步骤运行: -1. 首先,安全防护措施接收与传递给智能体的相同输入。 -2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],然后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 -3. 最后,检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 +1. 首先,安全防护措施接收传递给智能体的同一输入。 +2. 接下来,安全防护措施函数运行并生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你向用户作出适当响应或处理该异常。 -!!! Note +!!! 注意 - 输入安全防护措施用于处理用户输入,因此只有当智能体是*第一个*智能体时,其安全防护措施才会运行。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输入安全防护措施旨在针对用户输入运行,因此只有当该智能体是*第一个*智能体时,它的安全防护措施才会运行。你可能会想,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与实际智能体相关——不同的智能体会运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 ### 执行模式 输入安全防护措施支持两种执行模式: -- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体的执行并发运行。由于二者同时启动,因此这种模式可实现最低延迟。但是,如果安全防护措施检查失败,智能体可能已经消耗了 token 并执行了工具,随后才被取消。 +- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体并发执行。由于两者同时启动,因此这种模式可实现最低延迟。不过,如果安全防护措施未通过,智能体可能已经消耗了 token 并执行了工具,之后才被取消。 -- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将永远不会执行,从而避免消耗 token 和执行工具。此模式非常适合优化成本,以及希望避免工具调用可能产生的副作用的场景。 +- **阻塞式执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果触发了安全防护措施的触发器,智能体将完全不会执行,从而避免消耗 token 和执行工具。这种模式非常适合优化成本,以及避免工具调用可能产生的副作用。 ## 输出安全防护措施 输出安全防护措施分 3 个步骤运行: 1. 首先,安全防护措施接收智能体生成的输出。 -2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],然后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 -3. 最后,检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 +2. 接下来,安全防护措施函数运行并生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你向用户作出适当响应或处理该异常。 -!!! Note +!!! 注意 - 输出安全防护措施用于处理智能体的最终输出,因此只有当智能体是*最后一个*智能体时,其安全防护措施才会运行。与输入安全防护措施类似,我们这样做是因为安全防护措施通常与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输出安全防护措施旨在针对智能体的最终输出运行,因此只有当该智能体是*最后一个*智能体时,它的安全防护措施才会运行。与输入安全防护措施类似,这样做是因为安全防护措施通常与实际智能体相关——不同的智能体会运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 输出安全防护措施始终在智能体完成后运行,因此不支持 `run_in_parallel` 参数。 ## 工具安全防护措施 -工具安全防护措施封装**工具调用**,支持在执行前后验证或阻止工具调用。它们在工具本身上配置,并在每次调用该工具时运行。 +工具安全防护措施会封装**工具调用**,让你能够在执行前后验证或阻止工具调用。它们配置在工具本身,并在每次调用该工具时运行。 -- 输入工具安全防护措施在工具执行前运行,可以跳过调用、用消息替换输出,或触发触发器。 -- 输出工具安全防护措施在工具执行后运行,可以替换输出或触发触发器。 -- 如果函数工具需要审批,输入工具安全防护措施通常会在审批后、执行前立即运行。如果希望这些输入检查在发出待审批中断之前运行,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此项审批前检查的调用仍会在审批后、工具执行前再次接受检查。 -- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的工具调用。任务转移通过 SDK 的任务转移管线运行,而不是通过常规函数工具管线运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管线,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不直接提供工具安全防护措施选项。 +- 工具输入安全防护措施在工具执行前运行,可以跳过调用、使用消息替换输出,或触发安全机制。 +- 工具输出安全防护措施在工具执行后运行,可以替换输出或触发安全机制。 +- 如果工具调用需要审批,工具输入安全防护措施通常会在审批后、即将执行前运行。如果希望这些输入检查在发出待审批中断之前运行,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此次审批前检查的调用仍会在审批后、工具执行前再次接受检查。 +- 工具安全防护措施仅适用于通过 [`function_tool`][agents.tool.function_tool] 创建的工具调用。任务转移通过 SDK 的任务转移管线运行,而不是通过常规工具调用管线,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管线,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不会直接公开工具安全防护措施选项。 -有关详细信息,请参阅下方的代码片段。 +有关详细信息,请参阅下面的代码片段。 -## 触发器 +## 触发机制 -如果输入或输出未通过安全防护措施检查,安全防护措施可以通过触发器发出信号。一旦发现某项安全防护措施触发了触发器,我们会立即引发 `{Input,Output}GuardrailTripwireTriggered` 异常,并停止智能体执行。 +如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即引发 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止智能体执行。工具安全防护措施使用对应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 -异常的 `guardrail_result` 可标识触发了触发器的安全防护措施。对于由运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,其中包括触发了触发器的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供相应的累积结果。在 `stream_events()` 引发异常后,流式结果会通过 `input_guardrail_results` 或 `output_guardrail_results` 公开相同的已完成结果。如果异常是在运行器管理的执行路径之外引发的,`run_data` 可以为 `None`。 +对于智能体级别的触发器,异常的 `guardrail_result` 用于标识触发该机制的安全防护措施。对于运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,其中包括触发该机制的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供对应的累计结果。 -## 安全防护措施的实现 +工具触发器异常则会直接公开触发该机制的 `guardrail` 和 `output`。其 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留失败前已完成轮次中累计的结果;触发该机制的结果可通过异常的 `output` 获取。其他由运行器管理的失败(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。在 `stream_events()` 引发异常后,流式结果会公开相同的智能体和工具安全防护措施累计结果列表。如果异常是在运行器管理的执行路径之外引发的,`run_data` 可能为 `None`。 -你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在底层运行一个智能体来实现这一点。 +## 安全防护措施实现 + +你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将通过在底层运行一个智能体来实现这一点。 ```python from pydantic import BaseModel diff --git a/docs/zh/models/index.md b/docs/zh/models/index.md index fa4dfd01f5..da163bd1a0 100644 --- a/docs/zh/models/index.md +++ b/docs/zh/models/index.md @@ -4,43 +4,43 @@ search: --- # 模型 -Agents SDK 原生支持两种形式的OpenAI模型: +Agents SDK 原生支持两种 OpenAI 模型: -- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用OpenAI API。 -- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用OpenAI API。 +- **推荐**:使用新 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API 的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 +- 使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API 的 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 ## 模型设置选择 -请从最符合您设置的最简单路径开始: +请从符合您设置的最简单路径开始: | 如果您希望…… | 推荐路径 | 更多信息 | | --- | --- | --- | -| 仅使用OpenAI模型 | 使用默认OpenAI提供商和 Responses 模型路径 | [OpenAI模型](#openai-models) | +| 仅使用 OpenAI模型 | 使用默认 OpenAI提供商和 Responses 模型路径 | [OpenAI模型](#openai-models) | | 通过 WebSocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径并启用 WebSocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | -| 使用由OpenAI托管的子智能体 | 使用实验性的托管多智能体模型 | [托管多智能体](#hosted-multi-agent-experimental) | -| 使用一个非OpenAI提供商 | 从内置的提供商集成点开始 | [非OpenAI模型](#non-openai-models) | -| 在不同智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | +| 使用由OpenAI托管的子智能体 | 使用实验性托管式多智能体模型 | [托管式多智能体](#hosted-multi-agent-experimental) | +| 使用一个非 OpenAI提供商 | 从内置提供商集成点开始 | [非 OpenAI模型](#non-openai-models) | +| 在多个智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | | 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径上使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | -| 使用第三方适配器实现非OpenAI或混合提供商路由 | 比较受支持的 Beta 版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | +| 使用第三方适配器实现非 OpenAI或混合提供商路由 | 比较受支持的测试版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | ## OpenAI模型 -对于大多数仅使用OpenAI的应用,推荐使用默认OpenAI提供商的字符串模型名称,并继续使用 Responses 模型路径。 +对于大多数仅使用 OpenAI的应用,推荐路径是配合默认 OpenAI提供商使用字符串模型名称,并继续使用 Responses 模型路径。 -如果初始化 `Agent` 时未指定模型,则会使用默认模型。目前的默认模型是 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并设置 `reasoning.effort="none"` 和 `verbosity="low"`,适用于低延迟智能体工作流。如果您拥有访问权限,我们建议将智能体设置为 `gpt-5.6-sol`,以便在保持显式 `model_settings` 的同时获得更高质量。 +如果初始化 `Agent` 时未指定模型,将使用默认模型。目前的默认模型是 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并使用 `reasoning.effort="none"` 和 `verbosity="low"`,以适应低延迟智能体工作流。如果您拥有访问权限,我们建议将智能体设置为 `gpt-5.6-sol`,以获得更高质量,同时继续显式设置 `model_settings`。 -如果要切换到 `gpt-5.6-sol` 等其他模型,可以通过两种方式配置智能体。 +如果您希望切换到 `gpt-5.6-sol` 等其他模型,可通过两种方式配置智能体。 ### 默认模型 -首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 +首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -其次,您可以通过 `RunConfig` 为一次运行设置默认模型。如果没有为智能体设置模型,则会使用此次运行的模型。 +其次,您可以通过 `RunConfig` 为一次运行设置默认模型。如果未为某个智能体设置模型,将使用本次运行的模型。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 模型 -以这种方式使用任何 GPT-5 模型(例如 `gpt-5.6-sol`)时,SDK 会应用默认的 `ModelSettings`。它会采用最适合大多数用例的设置。要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: +以这种方式使用 `gpt-5.6-sol` 等任意 GPT-5 模型时,SDK 会应用默认的 `ModelSettings`,其中包含最适合大多数用例的设置。要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -为了降低延迟,建议对 GPT-5 模型使用 `reasoning.effort="none"`。 +为降低延迟,建议对 GPT-5 模型使用 `reasoning.effort="none"`。 -GPT-5.6 还通过现有的 `reasoning` 设置支持推理模式、持久化推理上下文和 `"max"` 强度级别。这些控制项可在 Responses API 路径上使用: +GPT-5.6 还通过现有的 `reasoning` 设置支持推理模式、持久化推理上下文以及 `"max"` 强度级别。这些控制项可用于 Responses API 路径: ```python from openai.types.shared import Reasoning @@ -96,25 +96,25 @@ agent = Agent( ) ``` -`reasoning.mode` 和 `reasoning.context` 是仅限 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,支持的强度级别取决于模型和 API 接口。请使用 Responses API 启用 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;在OpenAI提供商上设置 `strict_feature_validation=True` 可将该警告转换为错误。 +`reasoning.mode` 和 `reasoning.context` 是仅适用于 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,受支持的强度级别取决于模型和 API 接口。请使用 Responses API 设置 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;可在 OpenAI提供商上设置 `strict_feature_validation=True`,将该警告转为错误。 -使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端对话或重放先前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中包含 `reasoning.encrypted_content`,并在下一个请求中重放这些推理项。 +使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端会话或重放先前的推理项来保留对话。对于 `store=False` 的无状态调用,请在响应中包含 `reasoning.encrypted_content`,并在下一个请求中重放这些推理项。 #### ComputerTool 模型选择 -如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],实际 Responses 请求所使用的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布版内置 `computer` 工具,而显式的 `computer-use-preview` 请求继续使用较旧的 `computer_use_preview` 载荷。 +如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],实际 Responses 请求中的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求继续使用较旧的 `computer_use_preview` 载荷。 -由提示词管理的调用是主要例外。如果提示词模板指定模型,而 SDK 从请求中省略 `model`,SDK 会默认使用与预览版兼容的计算机载荷,以免猜测提示词固定了哪个模型。要在此流程中继续使用正式发布版路径,请在请求中显式指定 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版。 +由提示词管理的调用是主要例外。如果提示词模板决定模型,且 SDK 在请求中省略 `model`,SDK 将默认使用兼容预览版的计算机载荷,以避免猜测提示词锁定了哪个模型。要在该流程中继续使用正式发布路径,请在请求中显式设置 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制使用正式发布选择器。 -注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与有效请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串仍会像普通函数名称一样工作。 +注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与有效请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串将继续按普通函数名称处理。 -与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此,使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂且由提示词管理的流程应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制选择正式发布版。完整迁移详情请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 +兼容预览版的请求必须预先序列化 `environment` 和显示尺寸,因此,使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂且由提示词管理的流程,应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制使用正式发布选择器。有关完整迁移详情,请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 #### 非 GPT-5 模型 -如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复使用与任何模型兼容的通用 `ModelSettings`。 +如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 将恢复使用与任意模型兼容的通用 `ModelSettings`。 -### 仅限 Responses 的工具功能 +### Responses 专属工具功能 以下工具功能仅受 OpenAI Responses 模型支持: @@ -123,13 +123,13 @@ agent = Agent( - `@function_tool(defer_loading=True)` 和其他延迟加载的 Responses 工具接口 - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers` 和 `tool_choice="programmatic_tool_calling"` -Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请向智能体添加 `ToolSearchTool()`,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制指定单独的命名空间名称或仅限延迟加载的函数名称。有关设置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 +Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请向智能体添加 `ToolSearchTool()`,并让模型通过 `auto` 或 `required` 工具选择加载工具,而不是强制指定单独的命名空间名称或仅延迟加载的函数名称。有关设置详情和当前限制,请参阅[托管式工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 ### Responses WebSocket 传输 -默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用OpenAI支持的模型时,您可以选择启用 WebSocket 传输。 +默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用由OpenAI支持的模型时,您可以选择启用 WebSocket 传输。 -#### 基础设置 +#### 基本设置 ```python from agents import set_default_openai_responses_transport @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响由默认OpenAI提供商解析的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 +这会影响由默认 OpenAI提供商解析的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 -SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已固定:[​​`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 仍使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商控制传输方式选择,而非全局默认设置。 +SDK 将模型名称解析为模型实例时,会进行传输方式选择。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已固定:[‌`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 继续使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商控制传输方式选择,而不是全局默认设置。 -#### 提供商级或运行级设置 +#### 提供商或运行级设置 -您也可以按提供商或按运行配置 WebSocket 传输: +您还可以按提供商或按运行配置 WebSocket 传输: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI支持的提供商还接受可选的智能体注册配置。这是一个高级选项,适用于您的OpenAI设置需要提供商级注册元数据(例如 harness ID)的情况。 +由OpenAI支持的提供商还接受可选的智能体注册配置。这是一个高级选项,适用于 OpenAI设置需要提供商级注册元数据(例如测试框架 ID)的场景。 ```python from agents import ( @@ -192,12 +192,12 @@ result = await Runner.run( 如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 -`MultiProvider` 保留了两个历史默认设置: +`MultiProvider` 保留了两项历史默认行为: -- `openai/...` 被视为OpenAI提供商的别名,因此 `openai/gpt-4.1` 会作为模型 `gpt-4.1` 进行路由。 -- 未知前缀会引发 `UserError`,而不会被直接透传。 +- `openai/...` 被视为 OpenAI提供商的别名,因此 `openai/gpt-4.1` 会作为模型 `gpt-4.1` 进行路由。 +- 未知前缀会引发 `UserError`,而不是直接透传。 -如果将OpenAI提供商指向需要字面量命名空间模型 ID 的OpenAI兼容端点,请显式选择透传行为。在启用 WebSocket 的设置中,也请在 `MultiProvider` 上保持 `openai_use_responses_websocket=True`: +当 OpenAI提供商指向要求使用字面命名空间模型 ID 的 OpenAI兼容端点时,请显式启用透传行为。在启用了 WebSocket 的设置中,还应在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -当后端需要字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端需要其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项也适用于 WebSocket 传输之外的 `MultiProvider`;此示例保持启用 WebSocket,因为它属于本节所述的传输设置。同样的选项也可用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 +当后端要求使用字面的 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端要求使用其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项也适用于 WebSocket 传输之外的 `MultiProvider`;此示例继续启用 WebSocket,是因为它属于本节介绍的传输设置。相同选项也可用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 -如果通过 `MultiProvider` 路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发给底层OpenAI提供商。 +如果通过 `MultiProvider` 进行路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发到底层 OpenAI提供商。 -如果使用自定义OpenAI兼容端点或代理,WebSocket 传输还需要兼容的 WebSocket `/responses` 端点。在这些设置中,您可能需要显式设置 `websocket_base_url`。 +如果使用自定义 OpenAI兼容端点或代理,WebSocket 传输还需要兼容的 WebSocket `/responses` 端点。在这些设置中,您可能需要显式设置 `websocket_base_url`。 #### 注意事项 -- 这是通过 WebSocket 传输使用的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions 或非OpenAI提供商,除非它们支持 Responses WebSocket `/responses` 端点。 -- 如果环境中尚未安装 `websockets` 软件包,请进行安装。 -- 启用 WebSocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次复用同一 WebSocket 连接的多轮工作流,包括嵌套的智能体即工具调用,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助函数。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 -- 对于耗时较长的推理轮次或存在延迟峰值的网络,请使用 `responses_websocket_options` 自定义 WebSocket 保活行为。增大 `ping_timeout` 可容忍延迟的 pong 帧,也可以设置 `ping_timeout=None`,在保持启用 ping 的同时禁用心跳超时。当可靠性比 WebSocket 延迟更重要时,优先使用 HTTP/SSE 传输。 -- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于代理之后长期运行的智能体进程,或内存受限容器中的智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}` 以限制每条消息的内存使用量。 -- [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每条连接上一次处理一个响应,并将每条连接的持续时间限制为 60 分钟。达到该限制后,请打开新连接;需要并行运行时,请使用多个连接。 -- 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会清除引用的 `previous_response_id`。重新连接后,如果存储的响应仍然可用,则仍可继续处理;但 `store=False` 和 ZDR 流程没有持久化回退机制。请使用 `previous_response_id=None` 启动新链并发送完整输入上下文,或根据本地管理的会话状态重建该上下文。 +- 这是通过 WebSocket 传输使用的 Responses API,并非 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions 或非 OpenAI提供商,除非它们支持 Responses WebSocket `/responses` 端点。 +- 如果您的环境中尚未安装 `websockets` 软件包,请安装它。 +- 启用 WebSocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望在多个轮次间复用同一 WebSocket 连接的多轮工作流,包括嵌套的“智能体作为工具”调用,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助函数。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 +- 对于耗时较长的推理轮次或延迟偶发突增的网络,可通过 `responses_websocket_options` 自定义 WebSocket 保活行为。增大 `ping_timeout` 以容忍延迟的 pong 帧,或设置 `ping_timeout=None`,在继续启用 ping 的同时禁用心跳超时。当可靠性比 WebSocket 延迟更重要时,优先使用 HTTP/SSE 传输。 +- 默认情况下,SDK 会禁用传入消息大小限制(`max_size=None`)。对于位于代理之后或运行在内存受限容器中的长生命周期智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`,以限制每条消息的内存使用量。 +- [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每个连接上一次处理一个响应,并将每个连接限制为 60 分钟。达到该限制后请打开新连接;需要并行运行时,请使用多个连接。 +- 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会逐出引用的 `previous_response_id`。重新连接后,如果存储的响应仍然可用,依然可以继续该响应;但 `store=False` 和 ZDR 流程没有持久化回退方案。请使用 `previous_response_id=None` 启动新的响应链并发送完整输入上下文,或通过本地管理的会话状态重建该上下文。 -### 托管多智能体(实验性) +### 托管式多智能体(实验性) -OpenAI Responses API 托管多智能体 Beta 版允许 GPT-5.6 根模型创建并协调服务端托管的子智能体。Agents SDK 可以继续使用常规 `Runner`:托管编排保留在服务端,而开发者定义的工具调用则在您的应用程序中执行。 +OpenAI Responses API 托管式多智能体测试版允许 GPT-5.6 根模型创建并协调由服务托管的子智能体。Agents SDK 可以继续使用常规 `Runner`:托管式编排保留在服务上,而开发者定义的工具调用则在您的应用中执行。 -此集成为实验性功能,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活跃的托管智能体。它需要 `openai[realtime]>=2.45.0`,其中包括公开 `client.beta.responses.connect` 的 Beta 版本。其接口和 Beta 项目架构可能会在正式发布前发生变化。 +此集成是实验性的,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活动的托管智能体。它要求使用 `openai[realtime]>=2.45.0`,其中包括公开 `client.beta.responses.connect` 的测试版本。在正式发布前,接口和测试版项目架构可能发生变化。 #### 模型配置 @@ -260,11 +260,11 @@ agent = Agent( ) ``` -构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则该模型使用默认OpenAI客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 +构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则该模型会使用默认 OpenAI客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 #### 本地工具调用 -所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 会在本地执行函数,并使用相同的调用 ID 将 `function_call_output` 注入活跃的 WebSocket 响应中,以便服务恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的工具调用都会在发送请求前被拒绝。 +所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 在本地执行函数,并将具有相同调用 ID 的 `function_call_output` 注入活动 WebSocket 响应,使服务能够恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。SDK 不支持工具审批中断:任何 `needs_approval` 设置不为 `False` 的工具调用,都会在发送请求前被拒绝。 当工具需要感知调用方的日志记录或授权时,请使用 `get_hosted_agent_metadata()`: @@ -283,48 +283,48 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -托管智能体名称属于观测性元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间通过应用程序代码实施任何必要的授权;不要对此模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 +托管智能体名称是观察性元数据,并非本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于有副作用的工具,请将该调用 ID 用作幂等键,并在执行工具之前或期间,通过应用代码执行所有必要的授权;请勿对该模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 -#### 输出和流式传输行为 +#### 输出与流式传输行为 -只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为普通最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管编排记录;SDK 绝不会将这些记录作为本地函数执行。 +只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为常规最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管式编排记录;SDK 绝不会将这些记录作为本地函数执行。 -原始流式传输仍会公开 Beta Responses 事件,包括托管输出项和 `response.inject.created` 确认。当函数调用就绪时,适配器会将一个活跃的提供商响应划分为 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。请对原始托管项或 `ToolContext` 使用 `get_hosted_agent_metadata()` 来检查归属信息。 +原始流式传输会继续公开测试版 Responses 事件,包括托管输出项和 `response.inject.created` 确认。函数调用就绪时,适配器会将一个活动的提供商响应拆分为 SDK 可见的逻辑模型轮次;Runner 生成输出后,再恢复同一个提供商响应。请将 `get_hosted_agent_metadata()` 与原始托管项或 `ToolContext` 配合使用,以检查归属信息。 #### 与 SDK 编排的关系 -托管多智能体不同于 SDK 任务转移和 Agents-as-tools: +托管式多智能体与 SDK 任务转移及 agents-as-tools 不同: -- 托管多智能体在OpenAI服务上创建子智能体。您的应用程序不会创建或调度这些子智能体。 -- SDK 任务转移会更改当前活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,从而导致所有权冲突。 -- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请仔细评估额外的延迟、成本和工具暴露。 +- 托管式多智能体会在 OpenAI服务上创建子智能体。您的应用不会创建或调度这些子智能体。 +- SDK 任务转移会更改当前活动的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,从而造成所有权冲突。 +- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估额外的延迟、成本和工具暴露。 #### 当前限制 -实验性模型会拒绝 `reasoning.summary`、`max_tool_calls`,以及调用方提供的 `multi_agent` 或 `betas` 覆盖设置。Beta 版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 +实验性模型不接受 `reasoning.summary`、`max_tool_calls` 以及调用方提供的 `multi_agent` 或 `betas` 覆盖值。测试版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 -一个 `OpenAIHostedMultiAgentModel` 实例一次最多拥有一个活跃的托管响应。如果运行在等待本地函数输出时被放弃,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复正在进行的托管响应。 +一个 `OpenAIHostedMultiAgentModel` 实例同一时间最多拥有一个活动的托管响应。如果运行在等待本地函数输出时被放弃,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复正在进行的托管响应。 -有关底层 Responses API Beta 版行为,请参阅 [OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式传输和流式传输的 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 +有关底层 Responses API 测试版行为,请参阅 [OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 -## 非OpenAI模型 +## 非 OpenAI模型 -如果需要非OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多设置中,无需添加第三方适配器即可满足需求。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 +如果需要非 OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多设置中,无需添加第三方适配器即可满足需求。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 -### 非OpenAI提供商的集成方式 +### 非 OpenAI提供商的集成方式 -| 方法 | 适用情况 | 作用域 | +| 方式 | 适用场景 | 作用域 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 一个OpenAI兼容端点应成为大多数或所有智能体的默认端点 | 全局默认 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应适用于单次运行 | 每次运行 | -| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同提供商或具体模型对象 | 每个智能体 | -| 第三方适配器 | 需要由适配器管理的提供商覆盖范围或内置路径不提供的路由 | 请参阅[第三方适配器](#third-party-adapters) | +| [`set_default_openai_client`][agents.set_default_openai_client] | 一个 OpenAI兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应应用于单次运行 | 每次运行 | +| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同的提供商或具体模型对象 | 每个智能体 | +| 第三方适配器 | 您需要由适配器管理的提供商覆盖或内置路径未提供的路由功能 | 请参阅[第三方适配器](#third-party-adapters) | 您可以通过以下内置路径集成其他 LLM 提供商: -1. 如果希望在全局范围内使用 `AsyncOpenAI` 实例作为 LLM 客户端,[`set_default_openai_client`][agents.set_default_openai_client] 会很有用。这适用于 LLM 提供商具有OpenAI兼容 API 端点,且您可以设置 `base_url` 和 `api_key` 的情况。可配置代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 层级。这让您可以指定“此次运行中的所有智能体都使用自定义模型提供商”。可配置代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 -3. [`Agent.model`][agents.agent.Agent.model] 允许您在特定 Agent 实例上指定模型。这样可以为不同智能体灵活混用不同提供商。可配置代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 +1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望在全局范围内使用 `AsyncOpenAI` 实例作为 LLM 客户端的场景。它适用于 LLM 提供商拥有 OpenAI兼容 API 端点,并且您可以设置 `base_url` 和 `api_key` 的情况。可配置的代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 层级。您可以借此指定“本次运行中的所有智能体均使用自定义模型提供商”。可配置的代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 +3. [`Agent.model`][agents.agent.Agent.model] 允许您为特定 Agent 实例指定模型,从而为不同智能体灵活混用不同的提供商。可配置的代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 如果您没有来自 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或设置[其他追踪进程](../tracing.md)。 @@ -343,17 +343,17 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持 Responses API,我们建议使用 Responses。 -## 单个工作流中的模型混用 +## 在一个工作流中混用模型 -在单个工作流中,您可能希望为每个智能体使用不同的模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: +在单个工作流中,您可能希望为每个智能体使用不同的模型。例如,可以使用较小、较快的模型进行分流,同时使用较大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: 1. 传入模型名称。 -2. 传入任意模型名称,以及可将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 +2. 传入任意模型名称以及可将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 3. 直接提供 [`Model`][agents.models.interface.Model] 实现。 !!! note - 虽然 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 两种形式,但我们建议每个工作流只使用一种模型形式,因为两者支持的功能和工具集合不同。如果工作流需要混用不同模型形式,请确保您使用的所有功能均受两者支持。 + 虽然我们的 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 形式,但由于这两种形式支持不同的功能和工具集,我们建议每个工作流仅使用一种模型形式。如果您的工作流需要混用模型形式,请确保您使用的所有功能均受二者支持。 ```python import asyncio @@ -391,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 直接设置OpenAI模型的名称。 +1. 直接设置 OpenAI模型的名称。 2. 提供 [`Model`][agents.models.interface.Model] 实现。 -如果希望进一步配置智能体使用的模型,可以传入 [`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 +如果希望进一步配置智能体所用的模型,可以传入 [`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 ```python from agents import Agent, ModelSettings @@ -409,22 +409,22 @@ english_agent = Agent( ## 高级 OpenAI Responses 设置 -使用 OpenAI Responses 路径且需要更多控制时,请从 `ModelSettings` 开始。 +当您使用 OpenAI Responses 路径并需要更多控制时,请首先使用 `ModelSettings`。 ### 常用高级 `ModelSettings` 选项 -使用 OpenAI Responses API 时,多个请求字段已经有对应的直接 `ModelSettings` 字段,因此无需通过 `extra_args` 传入。 +使用 OpenAI Responses API 时,多个请求字段已经拥有直接对应的 `ModelSettings` 字段,因此无需为它们使用 `extra_args`。 -- `parallel_tool_calls`:允许或禁止在同一轮中进行多个工具调用。 -- `truncation`:设置为 `"auto"`,让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是使请求失败。 -- `store`:控制生成的响应是否存储在服务端,以供后续检索。这对依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程十分重要。 -- `context_management`:配置服务端上下文处理,例如使用 `compact_threshold` 进行 Responses 压缩。 +- `parallel_tool_calls`:允许或禁止在同一轮中进行多次工具调用。 +- `truncation`:设置为 `"auto"`,让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是让请求失败。 +- `store`:控制生成的响应是否存储在服务端,以便稍后检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 +- `context_management`:配置服务端上下文处理,例如通过 `compact_threshold` 进行 Responses 压缩。 - `prompt_cache_retention`:为较早的模型系列配置延长保留时间,例如 使用 `"24h"`。 - `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置 `"30m"` 缓存 TTL。 - `response_include`:请求更丰富的响应载荷,例如 `web_search_call.action.sources`、`file_search_call.results` 或 `reasoning.encrypted_content`。 -- `top_logprobs`:请求输出文本的最高概率词元 logprobs。SDK 还会自动添加 `message.output_text.logprobs`。 -- `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅[由 Runner 管理的重试](#runner-managed-retries)。 +- `top_logprobs`:请求输出文本中概率最高的 token 对数概率。SDK 还会自动添加 `message.output_text.logprobs`。 +- `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅 [Runner 管理的重试](#runner-managed-retries)。 ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。同一个 `ModelSettings.prompt_cache_options` 字段会透传给 Responses 和 Chat Completions 请求,Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 +使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。同一个 `ModelSettings.prompt_cache_options` 字段会透传给 Responses 和 Chat Completions 请求,且 Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 ```python from agents import Runner @@ -470,18 +470,17 @@ result = await Runner.run( ) ``` -对于使用旧版保留控制的较早模型系列,`prompt_cache_retention` 仍然可用。不要将直接的 `ModelSettings` 字段与 -`extra_args` 中的相同键组合使用。 +对于使用旧版保留控制的较早模型系列,`prompt_cache_retention` 仍然可用。请勿将直接的 `ModelSettings` 字段与 `extra_args` 中的同名键结合使用。 -设置 `store=False` 时,Responses API 不会保留该响应以供之后在服务端检索。这适用于无状态或零数据保留类型的流程,但也意味着原本会复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当最后一个响应未被存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将其默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 +设置 `store=False` 时,Responses API 不会保留该响应供服务端稍后检索。这对于无状态或零数据保留类型的流程很有用,但也意味着原本会复用响应 ID 的功能必须改为依赖本地管理的状态。例如,当最后一个响应未被存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 -服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每个 Responses API 请求发送,当渲染后的上下文超过阈值时,API 可以在响应中生成压缩项。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 +服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每个 Responses API 请求一同发送;当渲染后的上下文超过阈值时,API 可以将压缩项作为响应的一部分发出。`OpenAIResponsesCompactionSession` 则会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 ### `extra_args` 传递 -当您需要 SDK 尚未在顶层直接公开的提供商特定请求字段或较新的请求字段时,请使用 `extra_args`。 +当您需要 SDK 尚未在顶层直接公开的提供商专属或较新的请求字段时,请使用 `extra_args`。 -此外,使用OpenAI的 Responses API 时,[还可以使用其他一些可选参数](https://platform.openai.com/docs/api-reference/responses/create),例如 `user`、`service_tier` 等。如果顶层没有这些参数,也可以使用 `extra_args` 传入。不要同时通过直接的 `ModelSettings` 字段设置同一个请求字段。 +使用 OpenAI模型时,`extra_args` 可以向 Responses API 和 Chat Completions API 传递可选参数,例如 `user` 和 `service_tier`。对于受支持的模型,可设置 `extra_args={"service_tier": "fast"}` 以使用[快速模式](https://developers.openai.com/api/docs/guides/fast-mode);`"priority"` 仍与之等效。请勿同时通过直接的 `ModelSettings` 字段设置同一请求字段。 ```python from agents import Agent, ModelSettings @@ -497,11 +496,11 @@ english_agent = Agent( ) ``` -## 由 Runner 管理的重试 +## Runner 管理的重试 -重试仅在运行时生效,并且需要主动启用。除非您设置 `ModelSettings(retry=...)` 且重试策略选择重试,否则 SDK 不会重试常规模型请求。 +重试仅在运行时生效,并且需要主动启用。除非您设置 `ModelSettings(retry=...)`,且您的重试策略选择进行重试,否则 SDK 不会重试常规模型请求。 -在 Responses WebSocket 传输上,`retry_policies.provider_suggested()` 会将响应前的过载帧和无代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:您仍需要 `ModelRetrySettings`,并且常规重放安全检查仍然适用。如果已经收到任何响应事件,SDK 将不会重放请求。 +在 Responses WebSocket 传输中,`retry_policies.provider_suggested()` 会将响应前的过载帧和无代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:您仍然需要 `ModelRetrySettings`,且常规的重放安全检查仍然适用。如果已经收到任何响应事件,SDK 就不会重放请求。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -535,79 +534,79 @@ agent = Agent( | 字段 | 类型 | 说明 | | --- | --- | --- | -| `max_retries` | `int | None` | 初始请求之后允许的重试次数。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制此处计算出的退避延迟。它不会限制策略返回的显式延迟或 retry-after 提示。 | -| `policy` | `RetryPolicy | None` | 决定是否重试的回调。此字段仅在运行时生效,不会被序列化。 | +| `max_retries` | `int | None` | 初始请求后允许的重试次数。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略选择重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制该计算得出的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | +| `policy` | `RetryPolicy | None` | 决定是否重试的回调。该字段仅在运行时生效,不会被序列化。 | 重试策略会收到一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: -- `attempt` 和 `max_retries`,便于根据尝试次数做出决策。 -- `stream`,便于针对流式传输和非流式传输行为采用不同分支。 +- `attempt` 和 `max_retries`,以便根据尝试次数做出决策。 +- `stream`,以便区分流式和非流式行为。 - `error`,用于原始检查。 -- 规范化信息,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 -- 当底层模型适配器能够提供重试指导时的 `provider_advice`。 +- `normalized` 信息,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 +- `provider_advice`,用于底层模型适配器能够提供重试指导的情况。 -策略可以返回以下任一种结果: +策略可以返回以下任一内容: -- `True` / `False`,表示简单的重试决定。 -- [`RetryDecision`][agents.retry.RetryDecision],用于覆盖延迟或附加诊断原因。 +- `True` / `False`,表示简单的重试决策。 +- 当您希望覆盖延迟或附加诊断原因时,返回 [`RetryDecision`][agents.retry.RetryDecision]。 SDK 在 `retry_policies` 上导出了现成的辅助函数: | 辅助函数 | 行为 | | --- | --- | | `retry_policies.never()` | 始终不重试。 | -| `retry_policies.provider_suggested()` | 在提供商提供重试建议时遵循该建议。 | -| `retry_policies.network_error()` | 匹配暂时性传输和超时故障。 | +| `retry_policies.provider_suggested()` | 在有可用建议时遵循提供商的重试建议。 | +| `retry_policies.network_error()` | 匹配暂时性传输失败和超时失败。 | | `retry_policies.http_status([...])` | 匹配选定的 HTTP 状态码。 | -| `retry_policies.retry_after()` | 仅在存在 retry-after 提示时重试,并使用该延迟。此辅助函数会将 retry-after 值视为显式策略延迟,因此 `backoff.max_delay` 不会对其进行限制。 | +| `retry_policies.retry_after()` | 仅在存在 retry-after 提示时重试,并使用该延迟。该辅助函数将 retry-after 值视为显式策略延迟,因此 `backoff.max_delay` 不会限制它。 | | `retry_policies.any(...)` | 任一嵌套策略选择重试时进行重试。 | -| `retry_policies.all(...)` | 仅当所有嵌套策略都选择重试时才进行重试。 | +| `retry_policies.all(...)` | 仅当所有嵌套策略均选择重试时进行重试。 | -组合策略时,`provider_suggested()` 是最安全的首选基础组件,因为当提供商可以区分否决和重放安全批准时,它会保留这些信息。 +组合策略时,`provider_suggested()` 是最安全的首选基础组件,因为当提供商能够区分拒绝重试和重放安全批准时,它会保留这些信息。 ##### 安全边界 -某些故障绝不会自动重试: +某些失败绝不会自动重试: - 中止错误。 - 提供商建议将重放标记为不安全的请求。 -- 已开始输出且重放会带来安全风险的流式传输运行。 +- 输出已开始,且重放会变得不安全的流式运行。 -使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求也会得到更保守的处理。对于这些请求,仅使用 `network_error()` 或 `http_status([500])` 等非提供商判断条件还不够。重试策略应包含来自提供商的重放安全批准,通常通过 `retry_policies.provider_suggested()` 实现。 +使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求也会得到更保守的处理。对于这些请求,`network_error()` 或 `http_status([500])` 等非提供商判断条件本身并不足够。重试策略应包含来自提供商的重放安全批准,通常通过 `retry_policies.provider_suggested()` 实现。 ##### Runner 与智能体的合并行为 -Runner 级和智能体级 `ModelSettings` 之间会对 `retry` 进行深度合并: +Runner 级与智能体级 `ModelSettings` 之间会深度合并 `retry`: -- 智能体可以仅覆盖 `retry.max_retries`,同时继承 Runner 的 `policy`。 -- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留 Runner 中同级的其他退避字段。 +- 智能体可以仅覆盖 `retry.max_retries`,并继续继承 Runner 的 `policy`。 +- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留来自 Runner 的其他同级退避字段。 - `policy` 仅在运行时生效,因此序列化后的 `ModelSettings` 会保留 `max_retries` 和 `backoff`,但省略回调本身。 有关更完整的代码示例,请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[由适配器支持的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 -## 非OpenAI提供商故障排除 +## 非 OpenAI提供商故障排除 -### 追踪客户端错误 401 +### 追踪客户端 401 错误 -如果遇到与追踪相关的错误,这是因为追踪数据会上传到OpenAI服务,而您没有OpenAI API 密钥。您可以通过以下三种方式解决: +如果出现与追踪相关的错误,这是因为追踪数据会上传到 OpenAI服务,而您没有 OpenAI API 密钥。您可以通过以下三种方式解决: 1. 完全禁用追踪:[`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. 为追踪设置OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 -3. 使用非OpenAI追踪进程。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 +2. 为追踪设置 OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 +3. 使用非 OpenAI追踪进程。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 ### Responses API 支持 SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,您可能会遇到 404 或类似问题。您可以通过以下两种方式解决: 1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方式有效。 -2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。相关代码示例请参阅[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 +2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)提供了代码示例。 ### Chat Completions 兼容性选项 -通过 Chat Completions 进行路由时,SDK 会静默丢弃 Chat Completions 无法发送且仅限 Responses 的字段,例如 `previous_response_id`、`conversation_id`、提示词或非纯文本工具输出,从而保持兼容性。如果希望在开发期间遇到这些不匹配时立即失败,请在OpenAI提供商上启用严格功能验证: +通过 Chat Completions 进行路由时,SDK 会静默丢弃 Chat Completions 无法发送的 Responses 专属字段,以保持兼容性,例如 `previous_response_id`、`conversation_id`、提示词或非纯文本工具输出。如果您希望这些不匹配问题在开发期间快速失败,请在 OpenAI提供商上启用严格功能验证: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -627,7 +626,7 @@ result = await Runner.run( 如果使用 [`MultiProvider`][agents.MultiProvider],请改为传入 `openai_strict_feature_validation=True`。 -某些OpenAI兼容 Chat Completions 提供商会将工具调用增量分块进行流式传输,但这些分块不够可靠,无法供 SDK 进行增量处理。在这种情况下,请启用流式传输工具调用缓冲,使 SDK 仅在提供商流结束后生成工具调用: +某些 OpenAI兼容 Chat Completions 提供商会分块流式传输工具调用增量,但这些数据不足以支持可靠的 SDK 增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后发出工具调用: ```python from agents import OpenAIProvider @@ -642,7 +641,7 @@ provider = OpenAIProvider( ### structured outputs 支持 -某些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: +部分模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: ``` @@ -650,42 +649,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -这是某些模型提供商的不足之处:它们支持 JSON 输出,但不允许您指定输出所使用的 `json_schema`。我们正在研究相应的修复方案,但建议依赖支持 JSON schema 输出的提供商,否则您的应用往往会因格式错误的 JSON 而中断。 +这是部分模型提供商的不足之处——它们支持 JSON 输出,但不允许您指定用于输出的 `json_schema`。我们正在解决此问题,但建议依赖支持 JSON schema 输出的提供商,否则您的应用经常会因 JSON 格式错误而中断。 -## 跨提供商的模型混用 +## 跨提供商混用模型 -您需要注意不同模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI支持 structured outputs、多模态输入以及托管文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: +您需要注意模型提供商之间的功能差异,否则可能遇到错误。例如,OpenAI支持 structured outputs、多模态输入以及托管式文件检索和网络检索,但许多其他提供商并不支持这些功能。请注意以下限制: -- 不要向无法理解不受支持 `tools` 的提供商发送这些工具 -- 在调用纯文本模型之前过滤掉多模态输入 -- 请注意,不支持结构化 JSON 输出的提供商有时会生成无效 JSON。 +- 不要向无法理解的提供商发送不受支持的 `tools` +- 调用纯文本模型前,请过滤掉多模态输入 +- 请注意,不支持结构化 JSON 输出的提供商偶尔会生成无效 JSON。 ## 第三方适配器 -只有在 SDK 的内置提供商集成点不足以满足需求时,才应使用第三方适配器。如果您仅通过此 SDK 使用OpenAI模型,请优先使用内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将OpenAI模型与非OpenAI提供商组合使用,或需要由适配器管理的提供商覆盖范围或内置路径不提供的路由时。适配器会在 SDK 与上游模型提供商之间增加一层兼容性,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的 Beta 版集成形式提供 Any-LLM 和 LiteLLM 适配器。 +仅当 SDK 的内置提供商集成点无法满足需求时,才应使用第三方适配器。如果您仅通过此 SDK 使用 OpenAI模型,请优先使用内置 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI模型与非 OpenAI提供商结合使用,或需要由适配器管理的提供商覆盖或内置路径未提供的路由功能的场景。适配器会在 SDK 与上游模型提供商之间增加一个兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的测试版集成形式包含 Any-LLM 和 LiteLLM。 ### Any-LLM -对于需要由 Any-LLM 管理提供商覆盖范围或路由的情况,SDK 以尽力支持的 Beta 版形式提供 Any-LLM 支持。 +对于需要由 Any-LLM 管理提供商覆盖或路由的场景,我们会以尽力支持的测试版形式提供 Any-LLM 支持。 -根据上游提供商路径,Any-LLM 可能使用 Responses API、Chat Completions 兼容 API 或提供商特定的兼容层。 +根据上游提供商路径,Any-LLM 可能使用 Responses API、Chat Completions 兼容 API 或提供商专属兼容层。 -如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。您可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 配合使用、直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 +如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。您可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 配合使用、直接实例化 `AnyLLMModel`,或在运行作用域中使用 `AnyLLMProvider`。如果需要显式锁定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 -Any-LLM 仍是第三方适配器层,因此提供商依赖项和功能缺口由上游 Any-LLM 而非 SDK 定义。当上游提供商返回使用量指标时,这些指标会自动传播,但流式传输 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会生成使用量数据块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 +Any-LLM 仍然是第三方适配层,因此提供商依赖项和能力缺口由上游 Any-LLM 定义,而非 SDK。上游提供商返回使用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会发出使用量数据块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 专属行为,请验证计划部署的确切提供商后端。 ### LiteLLM -对于需要 LiteLLM 特定提供商覆盖范围或路由的情况,SDK 以尽力支持的 Beta 版形式提供 LiteLLM 支持。 +对于需要 LiteLLM 专属提供商覆盖或路由的场景,我们会以尽力支持的测试版形式提供 LiteLLM 支持。 -如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。您可以使用 `litellm/...` 模型名称,或直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 +如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。您可以使用 `litellm/...` 模型名称,也可以直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 -某些 LiteLLM 支持的提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 +某些由 LiteLLM 支持的提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器专属路由行为,请验证计划部署的确切提供商后端。 -如果 LiteLLM 为响应对象发出 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器之前选择启用 SDK 的兼容性补丁: +如果 LiteLLM 针对响应对象发出 Pydantic 序列化器警告,您可以在导入 LiteLLM 适配器前选择启用 SDK 的兼容性补丁: ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -该补丁默认禁用,仅在值为 `1` 或 `true` 时启用。它通过包装 LiteLLM 的私有日志辅助函数,抑制特定类别的 LiteLLM 响应序列化警告,因此应将其视为有针对性的临时解决方案,而不是通用序列化设置。由于它依赖 LiteLLM 的私有 API,升级 LiteLLM 时请重新验证该补丁,并在上游警告不再出现时移除该环境变量。 \ No newline at end of file +该补丁默认禁用,仅在值为 `1` 或 `true` 时启用。它通过包装 LiteLLM 的私有日志辅助函数,抑制一类特定的 LiteLLM 响应序列化警告,因此应将其视为针对性权宜方案,而非通用序列化设置。由于它依赖 LiteLLM 的私有 API,升级 LiteLLM 时请重新验证该补丁;当上游不再出现该警告时,请移除该环境变量。 \ No newline at end of file From 4a1773f405b2c516b774fbd8971d670f2801e61c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 Aug 2026 14:49:57 +0900 Subject: [PATCH 196/473] fix: make session mutations atomic (#4212) --- docs/sessions/index.md | 2 +- .../memory/advanced_sqlite_session.py | 694 ++++--- .../extensions/memory/async_sqlite_session.py | 160 +- .../extensions/memory/mongodb_session.py | 265 ++- src/agents/extensions/memory/redis_session.py | 561 +++++- .../extensions/memory/sqlalchemy_session.py | 107 +- src/agents/memory/sqlite_session.py | 129 +- .../memory/test_advanced_sqlite_session.py | 707 +++++++ .../memory/test_async_sqlite_session.py | 628 ++++++ .../extensions/memory/test_mongodb_session.py | 532 ++++- tests/extensions/memory/test_redis_session.py | 1772 +++++++++++++++++ .../memory/test_sqlalchemy_session.py | 288 ++- tests/memory/test_session.py | 247 ++- 13 files changed, 5588 insertions(+), 504 deletions(-) diff --git a/docs/sessions/index.md b/docs/sessions/index.md index 95f66172d0..2c0cd5f2c1 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -450,7 +450,7 @@ Notes: - `from_uri(...)` creates and owns the `AsyncMongoClient` and closes it on `session.close()`. An owned-client session is terminal after `close()`, and subsequent session operations raise `RuntimeError`. If your application already manages a client, construct `MongoDBSession(...)` directly with `client=...`; in that case `session.close()` is a no-op, and lifecycle plus session usability stay with the caller. - Connect to [MongoDB Atlas](https://www.mongodb.com/products/platform) by passing an `mongodb+srv://user:password@cluster.example.mongodb.net` URI to `from_uri(...)` with no other changes. -- Two collections are used and both names are configurable via `sessions_collection=` (default `agent_sessions`) and `messages_collection=` (default `agent_messages`). Indexes are created automatically on first use. Each message document carries a monotonically increasing `seq` counter that preserves ordering across concurrent writers and processes. +- Two collections are used and both names are configurable via `sessions_collection=` (default `agent_sessions`) and `messages_collection=` (default `agent_messages`). Indexes are created automatically on first use. Each non-empty `add_items()` call writes one logical-batch document whose monotonically increasing `seq` orders the batch by its final item; legacy per-item message documents remain readable. A logical batch must fit within MongoDB's single-document size limit; an oversized batch fails atomically without storing a partial batch. - Use `await session.ping()` to verify connectivity before your first run. ### Advanced SQLite sessions diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index de88927bc2..c67e3f8a6a 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -22,6 +22,7 @@ ) from ...memory import SQLiteSession from ...memory.session_settings import SessionSettings, resolve_session_limit +from ...memory.sqlite_session import _await_mutation def _content_preview(content: Any, max_length: int | None = None) -> str: @@ -67,13 +68,18 @@ def __init__( **kwargs, ) if create_tables: - self._init_structure_tables() + try: + self._init_structure_tables() + except BaseException: + try: + self.close() + except BaseException: + pass + raise self._current_branch_id = "main" - # Bumped (under the connection lock) whenever clear_session() wipes the - # session. switch_to_branch / create_branch_from_turn capture the - # generation before their DB work and only update the branch pointer if - # no clear has committed since, so a stale switch/create cannot resurrect - # a branch that clear already removed. + # Synchronized with the durable session_clear_generations row whenever a + # branch pointer is established or a write begins. A mismatch means + # another instance cleared the session, so the local pointer resets to main. self._generation = 0 self._logger = logger or logging.getLogger(__name__) @@ -85,19 +91,31 @@ def _commit_branch_pointer(self, branch_id: str, generation: int) -> bool: updated, False if a clear_session committed after ``generation`` was captured (in which case its reset to 'main' wins). """ - with self._lock: - if self._generation != generation: + with self._locked_connection() as conn: + row = conn.execute( + """ + SELECT generation FROM session_clear_generations + WHERE session_id = ? + """, + (self.session_id,), + ).fetchone() + durable_generation = row[0] if row is not None else 0 + if durable_generation != generation: + self._generation = durable_generation + self._current_branch_id = "main" return False + self._generation = durable_generation self._current_branch_id = branch_id return True def _init_structure_tables(self): """Add structure and usage tracking tables. - Creates the message_structure, branch_reservations, and turn_usage tables - with appropriate indexes for conversation branching and usage analytics. + Creates the message_structure, branch_reservations, session_clear_generations, + and turn_usage tables with appropriate indexes for conversation branching + and usage analytics. """ - with self._locked_connection() as conn: + with self._write_connection() as conn: # Message structure with branch support conn.execute(f""" CREATE TABLE IF NOT EXISTS message_structure ( @@ -139,6 +157,7 @@ def _init_structure_tables(self): """) self._ensure_branch_reservations_table(conn) + self._ensure_session_clear_generations_table(conn) # Indexes conn.execute(""" @@ -178,20 +197,18 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: def _add_items_sync(): """Synchronous helper to add items and structure metadata together.""" - with self._locked_connection() as conn: - try: - # Keep both writes in one transaction so metadata failures do not leave orphans. - self._insert_items(conn, items) - self._insert_structure_metadata(conn, items) - conn.commit() - except Exception as exc: - conn.rollback() - log_model_and_tool_action_error( - self._logger, "Failed to add session items", exc - ) - raise + with self._write_connection() as conn: + self._refresh_branch_after_external_clear(conn) + # Keep both writes in one transaction so metadata failures do not leave orphans. + self._insert_items(conn, items) + self._insert_structure_metadata(conn, items) + conn.commit() - await asyncio.to_thread(_add_items_sync) + try: + await _await_mutation(asyncio.to_thread(_add_items_sync)) + except Exception as exc: + log_model_and_tool_action_error(self._logger, "Failed to add session items", exc) + raise async def get_items( self, @@ -209,9 +226,6 @@ async def get_items( """ session_limit = resolve_session_limit(limit, self.session_settings) - if branch_id is None: - branch_id = self._current_branch_id - def _decode_rows(rows: list[Any]) -> list[TResponseInputItem]: items: list[TResponseInputItem] = [] for (message_data,) in rows: @@ -225,6 +239,7 @@ def _decode_rows(rows: list[Any]) -> list[TResponseInputItem]: def _get_items_sync(): """Synchronous helper to get items for a specific branch.""" with self._locked_connection() as conn: + resolved_branch_id = self._resolve_read_branch(conn, branch_id) with closing(conn.cursor()) as cursor: # Get message IDs in correct order for this branch if session_limit is None: @@ -236,7 +251,7 @@ def _get_items_sync(): WHERE m.session_id = ? AND s.branch_id = ? ORDER BY s.sequence_number ASC """, - (self.session_id, branch_id), + (self.session_id, resolved_branch_id), ) return _decode_rows(cursor.fetchall()) @@ -255,7 +270,7 @@ def _get_items_sync(): ORDER BY s.sequence_number DESC LIMIT ? """, - (self.session_id, branch_id, window), + (self.session_id, resolved_branch_id, window), ) rows = cursor.fetchall() items = _decode_rows(list(reversed(rows))) @@ -276,7 +291,7 @@ def _get_items_sync(): ORDER BY s.sequence_number DESC LIMIT ? """, - (self.session_id, branch_id, session_limit), + (self.session_id, resolved_branch_id, session_limit), ) return _decode_rows(list(reversed(cursor.fetchall()))) @@ -298,79 +313,72 @@ async def pop_item(self) -> TResponseInputItem | None: # switch_to_branch() cannot redirect this pop to a different branch once # it has been dispatched to the worker thread. branch_id = self._current_branch_id + generation = self._generation def _pop_item_sync(): - with self._locked_connection() as conn: + with self._write_connection() as conn: + self._refresh_branch_after_external_clear(conn) + resolved_branch_id = ( + self._current_branch_id if self._generation != generation else branch_id + ) while True: with closing(conn.cursor()) as cursor: - # Find the most recent item on the snapshotted branch. + # Preserve every legacy branch ID before a pop can remove its + # final message_structure row. This stays inside the existing + # rollback boundary for the mutation. + self._ensure_branch_reservations_table(conn) + + # Atomically claim the newest structure row across processes. cursor.execute( """ - SELECT id, message_id, user_turn_number FROM message_structure - WHERE session_id = ? AND branch_id = ? - ORDER BY sequence_number DESC - LIMIT 1 + DELETE FROM message_structure + WHERE id = ( + SELECT id FROM message_structure + WHERE session_id = ? AND branch_id = ? + ORDER BY sequence_number DESC + LIMIT 1 + ) + RETURNING message_id, user_turn_number """, - (self.session_id, branch_id), + (self.session_id, resolved_branch_id), ) - row = cursor.fetchone() - if row is None: + claimed_row = cursor.fetchone() + if claimed_row is None: + conn.commit() return None - structure_id, message_id, user_turn_number = row - - # Read the message payload before removing anything. + message_id, user_turn_number = claimed_row cursor.execute( f"SELECT message_data FROM {self.messages_table} WHERE id = ?", (message_id,), ) message_row = cursor.fetchone() - try: - # Preserve every legacy branch ID before a pop can remove its - # final message_structure row. This stays inside the existing - # rollback boundary for the mutation. - self._ensure_branch_reservations_table(conn) - - # Remove the structure row for this branch, then drop - # the underlying message only if no other branch - # references it. + # Drop the underlying message only if no other branch references it. + self._cleanup_orphaned_messages_sync(conn) + + # If this was the last item of the turn on this + # branch, drop the now-stale turn_usage row for it. + if user_turn_number is not None: cursor.execute( - "DELETE FROM message_structure WHERE id = ?", - (structure_id,), + """ + SELECT COUNT(*) FROM message_structure + WHERE session_id = ? AND branch_id = ? + AND user_turn_number = ? + """, + (self.session_id, resolved_branch_id, user_turn_number), ) - self._cleanup_orphaned_messages_sync(conn) - - # If this was the last item of the turn on this - # branch, drop the now-stale turn_usage row for it. - if user_turn_number is not None: + if cursor.fetchone()[0] == 0: cursor.execute( """ - SELECT COUNT(*) FROM message_structure + DELETE FROM turn_usage WHERE session_id = ? AND branch_id = ? AND user_turn_number = ? """, - (self.session_id, branch_id, user_turn_number), + (self.session_id, resolved_branch_id, user_turn_number), ) - if cursor.fetchone()[0] == 0: - cursor.execute( - """ - DELETE FROM turn_usage - WHERE session_id = ? AND branch_id = ? - AND user_turn_number = ? - """, - (self.session_id, branch_id, user_turn_number), - ) - conn.commit() - except Exception: - # _locked_connection() does not manage transactions; - # roll back explicitly so a failure partway through - # this delete sequence never leaves a partial - # mutation or an open transaction for a later - # operation on this connection to inherit. - conn.rollback() - raise + conn.commit() if message_row is None: # Structure row pointed at a missing message; keep looking. @@ -382,7 +390,7 @@ def _pop_item_sync(): # Drop corrupted JSON entries and keep looking for a valid item. continue - return await asyncio.to_thread(_pop_item_sync) + return await _await_mutation(asyncio.to_thread(_pop_item_sync)) async def clear_session(self) -> None: """Clear all items for this session. @@ -398,37 +406,43 @@ async def clear_session(self) -> None: """ def _clear_session_sync(): - with self._locked_connection() as conn: - try: - # Backfill legacy branch IDs before clearing their only durable - # identity evidence. - self._ensure_branch_reservations_table(conn) - conn.execute( - f"DELETE FROM {self.messages_table} WHERE session_id = ?", - (self.session_id,), - ) - conn.execute( - f"DELETE FROM {self.sessions_table} WHERE session_id = ?", - (self.session_id,), - ) - conn.execute( - "DELETE FROM message_structure WHERE session_id = ?", - (self.session_id,), - ) - conn.execute( - "DELETE FROM turn_usage WHERE session_id = ?", - (self.session_id,), - ) - conn.commit() - except Exception: - # _locked_connection() does not manage transactions; roll - # back explicitly so a failure partway through this delete - # sequence never leaves a partial mutation or an open - # transaction for a later operation on this connection to - # inherit. The in-memory branch state below is only updated - # after a successful commit, so it stays consistent with it. - conn.rollback() - raise + with self._write_connection() as conn: + # Backfill legacy branch IDs before clearing their only durable + # identity evidence. + self._ensure_branch_reservations_table(conn) + self._ensure_session_clear_generations_table(conn) + conn.execute( + f"DELETE FROM {self.messages_table} WHERE session_id = ?", + (self.session_id,), + ) + conn.execute( + f"DELETE FROM {self.sessions_table} WHERE session_id = ?", + (self.session_id,), + ) + conn.execute( + "DELETE FROM message_structure WHERE session_id = ?", + (self.session_id,), + ) + conn.execute( + "DELETE FROM turn_usage WHERE session_id = ?", + (self.session_id,), + ) + conn.execute( + """ + UPDATE session_clear_generations + SET generation = generation + 1 + WHERE session_id = ? + """, + (self.session_id,), + ) + generation = conn.execute( + """ + SELECT generation FROM session_clear_generations + WHERE session_id = ? + """, + (self.session_id,), + ).fetchone()[0] + conn.commit() # All branches were removed, so reset the in-memory pointer to # 'main' while still holding the lock. Doing this inside the # locked operation keeps the reset atomic with the clear, so no @@ -436,10 +450,10 @@ def _clear_session_sync(): # the pointer still references a deleted branch. Bumping the # generation invalidates any in-flight switch/create that # captured the pre-clear generation. - self._generation += 1 + self._generation = generation self._current_branch_id = "main" - await asyncio.to_thread(_clear_session_sync) + await _await_mutation(asyncio.to_thread(_clear_session_sync)) async def store_run_usage(self, result: RunResult) -> None: """Store usage data for the current conversation turn. @@ -490,8 +504,8 @@ def _capture_current_turn(self) -> tuple[int, str, int | None]: yields a different anchor. """ with self._locked_connection() as conn: + branch_id = self._resolve_read_branch(conn, None) with closing(conn.cursor()) as cursor: - branch_id = self._current_branch_id cursor.execute( """ SELECT COALESCE(MAX(user_turn_number), 0) @@ -564,6 +578,7 @@ def _get_current_turn_number(self) -> int: The current turn number for the active branch. """ with self._locked_connection() as conn: + branch_id = self._resolve_read_branch(conn, None) with closing(conn.cursor()) as cursor: cursor.execute( """ @@ -571,7 +586,7 @@ def _get_current_turn_number(self) -> int: FROM message_structure WHERE session_id = ? AND branch_id = ? """, - (self.session_id, self._current_branch_id), + (self.session_id, branch_id), ) result = cursor.fetchone() return result[0] if result else 0 @@ -591,12 +606,12 @@ async def _add_structure_metadata(self, items: list[TResponseInputItem]) -> None def _add_structure_sync(): """Synchronous helper to add structure metadata to database.""" - with self._locked_connection() as conn: + with self._write_connection() as conn: self._insert_structure_metadata(conn, items) conn.commit() try: - await asyncio.to_thread(_add_structure_sync) + await _await_mutation(asyncio.to_thread(_add_structure_sync)) except Exception as exc: log_model_and_tool_action_error( self._logger, @@ -709,15 +724,12 @@ async def _cleanup_orphaned_messages(self) -> int: def _cleanup_sync(): """Synchronous helper to cleanup orphaned messages.""" - with self._locked_connection() as conn: + with self._write_connection() as conn: deleted_count = self._cleanup_orphaned_messages_sync(conn) - if deleted_count: - conn.commit() - else: - conn.rollback() + conn.commit() return deleted_count - return await asyncio.to_thread(_cleanup_sync) + return await _await_mutation(asyncio.to_thread(_cleanup_sync)) def _cleanup_orphaned_messages_sync(self, conn: sqlite3.Connection) -> int: with closing(conn.cursor()) as cursor: @@ -839,39 +851,43 @@ async def create_branch_from_turn( ValueError: If turn doesn't exist, doesn't contain a user message, or `branch_name` has already been used in this session """ - # Snapshot the source branch and clear generation together. The source turn is - # revalidated inside the reservation transaction below. - with self._lock: - generation = self._generation - source_branch_id = self._current_branch_id - - # Resolve the target branch ID under the same transaction that performs the copy - # so concurrent creators cannot reserve the same branch. - branch_name, turn_content = await self._copy_messages_to_new_branch( - branch_name, turn_number, source_branch_id - ) - # Switch to new branch under the lock; skipped if a clear_session has - # committed since `generation` was captured (its reset to 'main' wins), - # so we never point at a branch that clear removed. - await asyncio.to_thread(self._commit_branch_pointer, branch_name, generation) + async def _create_and_switch() -> tuple[str, Any, str]: + # Copying the branch is the first durable side effect. Keep the + # generation-guarded pointer update in the same completion-owned task. + ( + resolved_name, + turn_content, + source_branch_id, + generation, + ) = await self._copy_messages_to_new_branch(branch_name, turn_number) + await asyncio.to_thread( + self._commit_branch_pointer, + resolved_name, + generation, + ) + return resolved_name, turn_content, source_branch_id + + resolved_branch_name, turn_content, source_branch_id = await _await_mutation( + _create_and_switch() + ) if _debug.DONT_LOG_MODEL_DATA: self._logger.debug( "Created branch '%s' from turn %s in '%s'", - branch_name, + resolved_branch_name, turn_number, source_branch_id, ) else: self._logger.debug( "Created branch '%s' from turn %s ('%s') in '%s'", - branch_name, + resolved_branch_name, turn_number, turn_content, source_branch_id, ) - return branch_name + return resolved_branch_name async def create_branch_from_content( self, search_term: str, branch_name: str | None = None @@ -908,14 +924,11 @@ async def switch_to_branch(self, branch_id: str) -> None: ValueError: If the branch doesn't exist. """ - # Capture the generation before validating so a clear that commits - # between validation and the pointer update is detected and skipped. - generation = self._generation - # Validate branch exists - def _validate_branch(): - """Synchronous helper to validate branch exists.""" - with self._locked_connection() as conn: + def _validate_branch() -> int: + """Validate the branch and return its current durable clear generation.""" + with self._write_connection() as conn: + self._ensure_session_clear_generations_table(conn) with closing(conn.cursor()) as cursor: cursor.execute( """ @@ -928,13 +941,27 @@ def _validate_branch(): count = cursor.fetchone()[0] if count == 0: raise ValueError(f"Branch '{branch_id}' does not exist") + generation = cast( + int, + cursor.execute( + """ + SELECT generation FROM session_clear_generations + WHERE session_id = ? + """, + (self.session_id,), + ).fetchone()[0], + ) + conn.commit() + return generation - await asyncio.to_thread(_validate_branch) + generation = await _await_mutation(asyncio.to_thread(_validate_branch)) old_branch = self._current_branch_id # Update the pointer under the lock; a no-op if a clear_session has # committed since `generation` was captured (its reset to 'main' wins). - switched = await asyncio.to_thread(self._commit_branch_pointer, branch_id, generation) + switched = await _await_mutation( + asyncio.to_thread(self._commit_branch_pointer, branch_id, generation) + ) if switched: self._logger.info("Switched from branch '%s' to '%s'", old_branch, branch_id) @@ -971,57 +998,53 @@ async def delete_branch(self, branch_id: str, force: bool = False) -> None: def _delete_sync(): """Synchronous helper to delete branch and associated data.""" - with self._locked_connection() as conn: - try: - # Backfill legacy branch IDs before deleting their message structure. - self._ensure_branch_reservations_table(conn) - with closing(conn.cursor()) as cursor: - # First verify the branch exists - cursor.execute( - """ - SELECT COUNT(*) FROM message_structure - WHERE session_id = ? AND branch_id = ? - """, - (self.session_id, branch_id), - ) + with self._write_connection() as conn: + # Backfill legacy branch IDs before deleting their message structure. + self._ensure_branch_reservations_table(conn) + with closing(conn.cursor()) as cursor: + # First verify the branch exists + cursor.execute( + """ + SELECT COUNT(*) FROM message_structure + WHERE session_id = ? AND branch_id = ? + """, + (self.session_id, branch_id), + ) - count = cursor.fetchone()[0] - if count == 0: - raise ValueError(f"Branch '{branch_id}' does not exist") + count = cursor.fetchone()[0] + if count == 0: + raise ValueError(f"Branch '{branch_id}' does not exist") - # Delete from turn_usage first (foreign key constraint) - cursor.execute( - """ - DELETE FROM turn_usage - WHERE session_id = ? AND branch_id = ? - """, - (self.session_id, branch_id), - ) + # Delete from turn_usage first (foreign key constraint) + cursor.execute( + """ + DELETE FROM turn_usage + WHERE session_id = ? AND branch_id = ? + """, + (self.session_id, branch_id), + ) - usage_deleted = cursor.rowcount + usage_deleted = cursor.rowcount - # Delete from message_structure - cursor.execute( - """ - DELETE FROM message_structure - WHERE session_id = ? AND branch_id = ? - """, - (self.session_id, branch_id), - ) + # Delete from message_structure + cursor.execute( + """ + DELETE FROM message_structure + WHERE session_id = ? AND branch_id = ? + """, + (self.session_id, branch_id), + ) - structure_deleted = cursor.rowcount + structure_deleted = cursor.rowcount - orphaned_messages_deleted = self._cleanup_orphaned_messages_sync(conn) + orphaned_messages_deleted = self._cleanup_orphaned_messages_sync(conn) - conn.commit() + conn.commit() - return usage_deleted, structure_deleted, orphaned_messages_deleted - except Exception: - conn.rollback() - raise + return usage_deleted, structure_deleted, orphaned_messages_deleted - usage_deleted, structure_deleted, orphaned_messages_deleted = await asyncio.to_thread( - _delete_sync + usage_deleted, structure_deleted, orphaned_messages_deleted = await _await_mutation( + asyncio.to_thread(_delete_sync) ) self._logger.info( @@ -1047,6 +1070,7 @@ async def list_branches(self) -> list[dict[str, Any]]: def _list_branches_sync(): """Synchronous helper to list all branches.""" with self._locked_connection() as conn: + current_branch_id = self._resolve_read_branch(conn, None) with closing(conn.cursor()) as cursor: cursor.execute( """ @@ -1071,7 +1095,7 @@ def _list_branches_sync(): "branch_id": branch_id, "message_count": msg_count, "user_turns": user_turns, - "is_current": branch_id == self._current_branch_id, + "is_current": branch_id == current_branch_id, "created_at": created_at, } ) @@ -1113,6 +1137,64 @@ def _ensure_branch_reservations_table(self, conn: sqlite3.Connection) -> None: (self.session_id,), ) + def _ensure_session_clear_generations_table(self, conn: sqlite3.Connection) -> None: + """Create and initialize the durable clear generation for this session.""" + conn.execute(""" + CREATE TABLE IF NOT EXISTS session_clear_generations ( + session_id TEXT PRIMARY KEY, + generation INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.execute( + """ + INSERT OR IGNORE INTO session_clear_generations (session_id, generation) + VALUES (?, 0) + """, + (self.session_id,), + ) + + def _refresh_branch_after_external_clear( + self, + conn: sqlite3.Connection, + *, + initialize: bool = True, + ) -> None: + """Reset a stale branch pointer after another session instance clears history.""" + if initialize: + self._ensure_session_clear_generations_table(conn) + else: + table_exists = conn.execute( + """ + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'session_clear_generations' + """ + ).fetchone() + if table_exists is None: + return + + row = conn.execute( + """ + SELECT generation FROM session_clear_generations + WHERE session_id = ? + """, + (self.session_id,), + ).fetchone() + generation = row[0] if row is not None else 0 + if generation != self._generation: + self._generation = generation + self._current_branch_id = "main" + + def _resolve_read_branch( + self, + conn: sqlite3.Connection, + branch_id: str | None, + ) -> str: + """Resolve an implicit branch after synchronizing an external clear.""" + if branch_id is not None: + return branch_id + self._refresh_branch_after_external_clear(conn, initialize=False) + return self._current_branch_id + def _reserve_branch_id( self, cursor: sqlite3.Cursor, new_branch_id: str | None, from_turn_number: int ) -> str: @@ -1148,127 +1230,124 @@ def _reserve_branch_id( branch_id = f"{base_branch_id}_{suffix}" async def _copy_messages_to_new_branch( - self, new_branch_id: str | None, from_turn_number: int, source_branch_id: str - ) -> tuple[str, Any]: + self, new_branch_id: str | None, from_turn_number: int + ) -> tuple[str, Any, str, int]: """Copy messages before the branch point to the new branch. Args: new_branch_id: The ID of the new branch, or None to generate an unused ID. from_turn_number: The turn number to copy messages up to (exclusive). - source_branch_id: The branch to copy messages from. - Returns: - The resolved branch ID and a preview of the source turn content. + The resolved branch ID, source preview, source branch, and clear generation. Raises: ValueError: If `new_branch_id` has already been used in this session. """ - def _copy_sync() -> tuple[str, Any]: + def _copy_sync() -> tuple[str, Any, str, int]: """Synchronous helper to copy messages to new branch.""" - with self._locked_connection() as conn: - try: - # Acquire SQLite's write reservation before checking the branch ID so - # sessions in other processes cannot pass the same check concurrently. - conn.execute("BEGIN IMMEDIATE") - self._ensure_branch_reservations_table(conn) - with closing(conn.cursor()) as cursor: - cursor.execute( - f""" - SELECT am.message_data - FROM message_structure ms - JOIN {self.messages_table} am ON ms.message_id = am.id - WHERE ms.session_id = ? AND ms.branch_id = ? - AND ms.branch_turn_number = ? AND ms.message_type = 'user' - """, - (self.session_id, source_branch_id, from_turn_number), + with self._write_connection() as conn: + # Acquire SQLite's write reservation before checking the branch ID so + # sessions in other processes cannot pass the same check concurrently. + conn.execute("BEGIN IMMEDIATE") + self._ensure_branch_reservations_table(conn) + self._refresh_branch_after_external_clear(conn) + source_branch_id = self._current_branch_id + generation = self._generation + with closing(conn.cursor()) as cursor: + cursor.execute( + f""" + SELECT am.message_data + FROM message_structure ms + JOIN {self.messages_table} am ON ms.message_id = am.id + WHERE ms.session_id = ? AND ms.branch_id = ? + AND ms.branch_turn_number = ? AND ms.message_type = 'user' + """, + (self.session_id, source_branch_id, from_turn_number), + ) + result = cursor.fetchone() + if result is None: + raise ValueError( + f"Turn {from_turn_number} does not contain a user message " + f"in branch '{source_branch_id}'" ) - result = cursor.fetchone() - if result is None: - raise ValueError( - f"Turn {from_turn_number} does not contain a user message " - f"in branch '{source_branch_id}'" - ) - try: - content = json.loads(result[0]).get("content", "") - turn_content = content[:50] + "..." if len(content) > 50 else content - except Exception: - turn_content = "Unable to parse content" + try: + content = json.loads(result[0]).get("content", "") + turn_content = content[:50] + "..." if len(content) > 50 else content + except Exception: + turn_content = "Unable to parse content" + + branch_id = self._reserve_branch_id(cursor, new_branch_id, from_turn_number) + + # Get all messages before the branch point + cursor.execute( + """ + SELECT + ms.message_id, + ms.message_type, + ms.sequence_number, + ms.user_turn_number, + ms.branch_turn_number, + ms.tool_name + FROM message_structure ms + WHERE ms.session_id = ? AND ms.branch_id = ? + AND ms.branch_turn_number < ? + ORDER BY ms.sequence_number + """, + (self.session_id, source_branch_id, from_turn_number), + ) - branch_id = self._reserve_branch_id(cursor, new_branch_id, from_turn_number) + messages_to_copy = cursor.fetchall() - # Get all messages before the branch point + if messages_to_copy: + # Get the max sequence number for the new inserts cursor.execute( """ - SELECT - ms.message_id, - ms.message_type, - ms.sequence_number, - ms.user_turn_number, - ms.branch_turn_number, - ms.tool_name - FROM message_structure ms - WHERE ms.session_id = ? AND ms.branch_id = ? - AND ms.branch_turn_number < ? - ORDER BY ms.sequence_number + SELECT COALESCE(MAX(sequence_number), 0) + FROM message_structure + WHERE session_id = ? """, - (self.session_id, source_branch_id, from_turn_number), + (self.session_id,), ) - messages_to_copy = cursor.fetchall() - - if messages_to_copy: - # Get the max sequence number for the new inserts - cursor.execute( - """ - SELECT COALESCE(MAX(sequence_number), 0) - FROM message_structure - WHERE session_id = ? - """, - (self.session_id,), - ) - - seq_start = cursor.fetchone()[0] - - # Insert copied messages with new branch_id - new_structure_data = [] - for i, ( - msg_id, - msg_type, - _, - user_turn, - branch_turn, - tool_name, - ) in enumerate(messages_to_copy): - new_structure_data.append( - ( - self.session_id, - msg_id, # Same message_id (sharing the actual message data) - branch_id, - msg_type, - seq_start + i + 1, # New sequence number - user_turn, # Keep same global turn number - branch_turn, # Keep same branch turn number - tool_name, - ) + seq_start = cursor.fetchone()[0] + + # Insert copied messages with new branch_id + new_structure_data = [] + for i, ( + msg_id, + msg_type, + _, + user_turn, + branch_turn, + tool_name, + ) in enumerate(messages_to_copy): + new_structure_data.append( + ( + self.session_id, + msg_id, # Same message_id (sharing the actual message data) + branch_id, + msg_type, + seq_start + i + 1, # New sequence number + user_turn, # Keep same global turn number + branch_turn, # Keep same branch turn number + tool_name, ) - - cursor.executemany( - """ - INSERT INTO message_structure - (session_id, message_id, branch_id, message_type, sequence_number, - user_turn_number, branch_turn_number, tool_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - new_structure_data, ) - conn.commit() - return branch_id, turn_content - except Exception: - conn.rollback() - raise + cursor.executemany( + """ + INSERT INTO message_structure + (session_id, message_id, branch_id, message_type, sequence_number, + user_turn_number, branch_turn_number, tool_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + new_structure_data, + ) + + conn.commit() + return branch_id, turn_content, source_branch_id, generation return await asyncio.to_thread(_copy_sync) @@ -1286,12 +1365,11 @@ async def get_conversation_turns(self, branch_id: str | None = None) -> list[dic - 'timestamp': When the turn was created - 'can_branch': Always True (all user messages can branch) """ - if branch_id is None: - branch_id = self._current_branch_id def _get_turns_sync(): """Synchronous helper to get conversation turns.""" with self._locked_connection() as conn: + resolved_branch_id = self._resolve_read_branch(conn, branch_id) with closing(conn.cursor()) as cursor: cursor.execute( f""" @@ -1305,7 +1383,7 @@ def _get_turns_sync(): AND ms.message_type = 'user' ORDER BY ms.branch_turn_number """, - (self.session_id, branch_id), + (self.session_id, resolved_branch_id), ) turns = [] @@ -1341,12 +1419,11 @@ async def find_turns_by_content( Returns: List of matching turns with same format as get_conversation_turns(). """ - if branch_id is None: - branch_id = self._current_branch_id def _search_sync(): """Synchronous helper to search turns by content.""" with self._locked_connection() as conn: + resolved_branch_id = self._resolve_read_branch(conn, branch_id) with closing(conn.cursor()) as cursor: cursor.execute( f""" @@ -1361,7 +1438,7 @@ def _search_sync(): AND am.message_data LIKE ? ORDER BY ms.branch_turn_number """, - (self.session_id, branch_id, f"%{search_term}%"), + (self.session_id, resolved_branch_id, f"%{search_term}%"), ) matches = [] @@ -1396,12 +1473,11 @@ async def get_conversation_by_turns( Returns: Dictionary mapping turn numbers to lists of message metadata. """ - if branch_id is None: - branch_id = self._current_branch_id def _get_conversation_sync(): """Synchronous helper to get conversation by turns.""" with self._locked_connection() as conn: + resolved_branch_id = self._resolve_read_branch(conn, branch_id) with closing(conn.cursor()) as cursor: cursor.execute( """ @@ -1410,7 +1486,7 @@ def _get_conversation_sync(): WHERE session_id = ? AND branch_id = ? ORDER BY sequence_number """, - (self.session_id, branch_id), + (self.session_id, resolved_branch_id), ) turns: dict[int, list[dict[str, str | None]]] = {} @@ -1432,12 +1508,11 @@ async def get_tool_usage(self, branch_id: str | None = None) -> list[tuple[str, Returns: List of tuples containing (tool_name, usage_count, turn_number). """ - if branch_id is None: - branch_id = self._current_branch_id def _get_tool_usage_sync(): """Synchronous helper to get tool usage statistics.""" with self._locked_connection() as conn: + resolved_branch_id = self._resolve_read_branch(conn, branch_id) with closing(conn.cursor()) as cursor: cursor.execute( """ @@ -1472,9 +1547,9 @@ def _get_tool_usage_sync(): """, ( self.session_id, - branch_id, + resolved_branch_id, self.session_id, - branch_id, + resolved_branch_id, ), ) return cursor.fetchall() @@ -1554,12 +1629,10 @@ async def get_turn_usage( Dictionary with usage data for specific turn, or list of dictionaries for all turns. """ - if branch_id is None: - branch_id = self._current_branch_id - def _get_turn_usage_sync(): """Synchronous helper to get turn usage statistics.""" with self._locked_connection() as conn: + resolved_branch_id = self._resolve_read_branch(conn, branch_id) if user_turn_number is not None: query = """ SELECT requests, input_tokens, output_tokens, total_tokens, @@ -1569,7 +1642,10 @@ def _get_turn_usage_sync(): """ with closing(conn.cursor()) as cursor: - cursor.execute(query, (self.session_id, branch_id, user_turn_number)) + cursor.execute( + query, + (self.session_id, resolved_branch_id, user_turn_number), + ) row = cursor.fetchone() if row: @@ -1608,7 +1684,7 @@ def _get_turn_usage_sync(): """ with closing(conn.cursor()) as cursor: - cursor.execute(query, (self.session_id, branch_id)) + cursor.execute(query, (self.session_id, resolved_branch_id)) results = [] for row in cursor.fetchall(): # Parse JSON details if present @@ -1671,7 +1747,7 @@ async def _update_turn_usage_internal( def _update_sync(): """Synchronous helper to update turn usage data.""" - with self._locked_connection() as conn: + with self._write_connection() as conn: if turn_anchor is not None: with closing(conn.cursor()) as guard_cursor: guard_cursor.execute( @@ -1733,4 +1809,4 @@ def _update_sync(): ) conn.commit() - await asyncio.to_thread(_update_sync) + await _await_mutation(asyncio.to_thread(_update_sync)) diff --git a/src/agents/extensions/memory/async_sqlite_session.py b/src/agents/extensions/memory/async_sqlite_session.py index 06d0cc1755..215a668902 100644 --- a/src/agents/extensions/memory/async_sqlite_session.py +++ b/src/agents/extensions/memory/async_sqlite_session.py @@ -16,6 +16,7 @@ coerce_session_settings, resolve_session_limit, ) +from ...memory.sqlite_session import _await_mutation class AsyncSQLiteSession(SessionABC): @@ -57,6 +58,7 @@ def __init__( self.sessions_table = sessions_table self.messages_table = messages_table self._connection: aiosqlite.Connection | None = None + self._quarantined_connections: set[aiosqlite.Connection] = set() self._lock = asyncio.Lock() self._init_lock = asyncio.Lock() self._closed = False @@ -102,9 +104,46 @@ async def _get_connection(self) -> aiosqlite.Connection: async with self._init_lock: if self._connection is None: - self._connection = await aiosqlite.connect(str(self.db_path)) - await self._connection.execute("PRAGMA journal_mode=WAL") - await self._init_db_for_connection(self._connection) + connect_task = asyncio.ensure_future(aiosqlite.connect(str(self.db_path))) + try: + connection = await asyncio.shield(connect_task) + except BaseException as acquisition_error: + connection = None + cleanup_cancellation: asyncio.CancelledError | None = None + try: + connection = await _await_mutation(connect_task) + except asyncio.CancelledError as exc: + cleanup_cancellation = exc + try: + connection = connect_task.result() + except BaseException: + pass + except BaseException: + pass + close_error = ( + await self._close_owned_connection(connection) + if connection is not None + else None + ) + if isinstance(acquisition_error, asyncio.CancelledError): + raise + if cleanup_cancellation is not None: + raise cleanup_cancellation from None + if isinstance(close_error, asyncio.CancelledError): + raise close_error from None + raise + assert connection is not None + try: + await connection.execute("PRAGMA journal_mode=WAL") + await self._init_db_for_connection(connection) + except BaseException as initialization_error: + close_error = await self._close_owned_connection(connection) + if isinstance(initialization_error, asyncio.CancelledError): + raise + if isinstance(close_error, asyncio.CancelledError): + raise close_error from None + raise + self._connection = connection return self._connection @@ -121,6 +160,71 @@ async def _locked_connection(self) -> AsyncIterator[aiosqlite.Connection]: conn = await self._get_connection() yield conn + @asynccontextmanager + async def _write_connection(self) -> AsyncIterator[aiosqlite.Connection]: + """Provide a connection that cannot retain a failed write transaction.""" + async with self._locked_connection() as conn: + try: + yield conn + except BaseException as operation_error: + rollback_task = asyncio.create_task(conn.rollback()) + rollback_error: BaseException | None = None + rollback_cancellation: asyncio.CancelledError | None = None + try: + await _await_mutation(rollback_task) + except asyncio.CancelledError as exc: + rollback_cancellation = exc + try: + rollback_task.result() + except BaseException as outcome_error: + rollback_error = outcome_error + except BaseException as exc: + rollback_error = exc + + invalidation_error = None + if rollback_error is not None: + invalidation_error = await self._invalidate_connection(conn) + + if isinstance(operation_error, asyncio.CancelledError): + raise + if rollback_cancellation is not None: + raise rollback_cancellation from None + if isinstance(invalidation_error, asyncio.CancelledError): + raise invalidation_error from None + raise + + async def _invalidate_connection(self, conn: aiosqlite.Connection) -> BaseException | None: + """Close and evict a connection that could not roll back safely.""" + close_error = await self._close_owned_connection(conn) + if self._connection is conn: + self._connection = None + if str(self.db_path) == ":memory:" or close_error is not None: + self._closed = True + return close_error + + async def _close_owned_connection(self, conn: aiosqlite.Connection) -> BaseException | None: + """Close an owned connection or retain it for a later cleanup retry.""" + close_task = asyncio.create_task(conn.close()) + cancellation: asyncio.CancelledError | None = None + close_error: BaseException | None = None + try: + await _await_mutation(close_task) + except asyncio.CancelledError as exc: + cancellation = exc + try: + close_task.result() + except BaseException as outcome_error: + close_error = outcome_error + except BaseException as exc: + close_error = exc + + if close_error is not None: + self._quarantined_connections.add(conn) + self._closed = True + else: + self._quarantined_connections.discard(conn) + return cancellation or close_error + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: """Retrieve the conversation history for this session. @@ -206,7 +310,7 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: if not items: return - async with self._locked_connection() as conn: + async with self._write_connection() as conn: await conn.execute( f""" INSERT OR IGNORE INTO {self.sessions_table} (session_id) VALUES (?) @@ -231,7 +335,7 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: (self.session_id,), ) - await conn.commit() + await _await_mutation(conn.commit()) async def pop_item(self) -> TResponseInputItem | None: """Remove and return the most recent item from the session. @@ -239,7 +343,8 @@ async def pop_item(self) -> TResponseInputItem | None: Returns: The most recent item if it exists, None if the session is empty """ - async with self._locked_connection() as conn: + + async with self._write_connection() as conn: cursor = await conn.execute( f""" DELETE FROM {self.messages_table} @@ -256,7 +361,7 @@ async def pop_item(self) -> TResponseInputItem | None: result = await cursor.fetchone() await cursor.close() - await conn.commit() + await _await_mutation(conn.commit()) while result: message_data = result[0] @@ -278,13 +383,14 @@ async def pop_item(self) -> TResponseInputItem | None: ) result = await cursor.fetchone() await cursor.close() - await conn.commit() + await _await_mutation(conn.commit()) return None async def clear_session(self) -> None: """Clear all items for this session.""" - async with self._locked_connection() as conn: + + async with self._write_connection() as conn: await conn.execute( f"DELETE FROM {self.messages_table} WHERE session_id = ?", (self.session_id,), @@ -293,18 +399,42 @@ async def clear_session(self) -> None: f"DELETE FROM {self.sessions_table} WHERE session_id = ?", (self.session_id,), ) - await conn.commit() + await _await_mutation(conn.commit()) async def close(self) -> None: """Close the database connection. The session becomes terminal from the first close attempt: subsequent operations raise RuntimeError rather than reopening the database. Repeated - and concurrent calls are safe no-ops. + and concurrent calls are safe. A repeated call retries any owned + connection whose previous close did not complete. """ async with self._lock: self._closed = True - if self._connection is None: - return - await self._connection.close() - self._connection = None + connections = set(self._quarantined_connections) + if self._connection is not None: + connections.add(self._connection) + + first_error: BaseException | None = None + cancellation: asyncio.CancelledError | None = None + for connection in connections: + close_task = asyncio.create_task(self._close_owned_connection(connection)) + try: + close_error = await asyncio.shield(close_task) + except asyncio.CancelledError as exc: + if cancellation is None: + cancellation = exc + try: + close_error = await _await_mutation(close_task) + except asyncio.CancelledError: + close_error = close_task.result() + if close_error is None: + if self._connection is connection: + self._connection = None + elif first_error is None: + first_error = close_error + + if cancellation is not None: + raise cancellation + if first_error is not None: + raise first_error diff --git a/src/agents/extensions/memory/mongodb_session.py b/src/agents/extensions/memory/mongodb_session.py index b2ba601ab0..3887409ca1 100644 --- a/src/agents/extensions/memory/mongodb_session.py +++ b/src/agents/extensions/memory/mongodb_session.py @@ -31,6 +31,7 @@ from __future__ import annotations +import asyncio import json import threading import weakref @@ -50,6 +51,7 @@ from pymongo.asynchronous.collection import AsyncCollection from pymongo.asynchronous.mongo_client import AsyncMongoClient from pymongo.driver_info import DriverInfo + from pymongo.read_preferences import ReadPreference except ImportError as e: raise_optional_dependency_error( "MongoDBSession", @@ -65,6 +67,7 @@ coerce_session_settings, resolve_session_limit, ) +from ...memory.sqlite_session import _await_mutation # Identifies this library in the MongoDB handshake for server-side telemetry. _DRIVER_INFO = DriverInfo(name="openai-agents", version=_VERSION) @@ -73,19 +76,22 @@ class MongoDBSession(SessionABC): """MongoDB implementation of [`Session`][agents.memory.session.Session]. - Conversation items are stored as individual documents in a ``messages`` - collection. A lightweight ``sessions`` collection tracks metadata - (creation time, last-updated time) for each session. + Conversation items are stored as logical-batch documents in a ``messages`` + collection. Legacy per-item documents remain readable. A lightweight + ``sessions`` collection tracks metadata (creation time, last-updated time) + for each session. Each logical batch must fit within MongoDB's single-document + size limit; an oversized batch fails atomically without storing a partial batch. Indexes are created once per ``(client, database, sessions_collection, messages_collection)`` combination on the first call to any of the session protocol methods. Subsequent calls skip the setup entirely. - Each message document carries a ``seq`` field — an integer assigned by - atomically incrementing a counter on the session metadata document. This - guarantees a strictly monotonic insertion order that is safe across - multiple writers and processes, unlike sorting by ``_id`` / ObjectId which - is only second-level accurate and non-monotonic across machines. + Each message document carries a ``seq`` field for the final item in that + document. Sequence ranges are assigned by atomically incrementing a counter + on the session metadata document. This guarantees a strictly monotonic + insertion order that is safe across multiple writers and processes, unlike + sorting by ``_id`` / ObjectId which is only second-level accurate and + non-monotonic across machines. """ # Class-level registry so index creation runs only once per unique @@ -125,8 +131,9 @@ def __init__( Defaults to ``"agents"``. sessions_collection: Name of the collection that stores session metadata. Defaults to ``"agent_sessions"``. - messages_collection: Name of the collection that stores individual - conversation items. Defaults to ``"agent_messages"``. + messages_collection: Name of the collection that stores logical + conversation batches and legacy per-item records. Defaults to + ``"agent_messages"``. session_settings: Optional session configuration. When ``None`` a default [`SessionSettings`][agents.memory.session_settings.SessionSettings] is used (no item limit). @@ -245,9 +252,9 @@ async def _ensure_indexes(self) -> None: # sessions: unique index on session_id. await self._sessions.create_index("session_id", unique=True) - # messages: compound index for efficient per-session retrieval and - # sorting by the explicit seq counter. - await self._messages.create_index([("session_id", 1), ("seq", 1)]) + # messages: compound index for efficient active-generation retrieval + # and sorting by the explicit seq counter. + await self._messages.create_index([("session_id", 1), ("generation", 1), ("seq", 1)]) self._mark_init_done() @@ -263,6 +270,21 @@ async def _deserialize_item(self, raw: str) -> TResponseInputItem: """Deserialize a JSON string to an item. Can be overridden by subclasses.""" return json.loads(raw) # type: ignore[no-any-return] + async def _get_generation(self) -> int: + """Return the authoritative history generation for this session.""" + sessions = self._sessions.with_options(read_preference=ReadPreference.PRIMARY) + docs = await sessions.find({"session_id": self.session_id}).limit(1).to_list() + if not docs: + return 0 + generation = docs[0].get("_generation", 0) + return generation if isinstance(generation, int) else 0 + + def _generation_query(self, generation: int) -> dict[str, Any]: + """Match the active generation while retaining legacy generation-zero data.""" + if generation == 0: + return {"generation": {"$in": [0, None]}} + return {"generation": generation} + # ------------------------------------------------------------------ # Session protocol implementation # ------------------------------------------------------------------ @@ -287,16 +309,23 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: if session_limit is not None and session_limit <= 0: return [] - query = {"session_id": self.session_id} + generation = await self._get_generation() + query = { + "session_id": self.session_id, + **self._generation_query(generation), + } async def _decode_docs(docs: list[Any]) -> list[TResponseInputItem]: items: list[TResponseInputItem] = [] for doc in docs: - try: - items.append(await self._deserialize_item(doc["message_data"])) - except (json.JSONDecodeError, KeyError, TypeError): - # Skip corrupted or malformed documents (including non-string BSON values). - continue + raw = doc.get("message_data") + raw_items = raw if isinstance(raw, list) else [raw] + for raw_item in raw_items: + try: + items.append(await self._deserialize_item(raw_item)) + except (json.JSONDecodeError, TypeError): + # Skip corrupted or malformed entries, including legacy non-string values. + continue return items if session_limit is None: @@ -319,79 +348,211 @@ async def _decode_docs(docs: list[Any]) -> list[TResponseInputItem]: window *= 2 async def add_items(self, items: list[TResponseInputItem]) -> None: - """Add new items to the conversation history. - - Args: - items: List of input items to append to the session. - """ - # Checked before the empty-list fast path, which would otherwise return - # successfully on a closed session. + """Add new items and wait until the batch outcome is known.""" self._check_not_closed() - if not items: return - await self._ensure_indexes() + serialized_items = [await self._serialize_item(item) for item in items] + await _await_mutation(self._add_items(serialized_items)) + async def _add_items(self, serialized_items: list[str]) -> None: + """Store one pre-serialized logical batch.""" now = datetime.now(timezone.utc) # Atomically reserve a block of sequence numbers for this batch. - # $inc returns the new value, so subtract len(items) to get the first - # number in the block. + # $inc returns the new value, so subtract the batch size to get the + # first number in the block. result = await self._sessions.find_one_and_update( {"session_id": self.session_id}, { - "$setOnInsert": {"session_id": self.session_id, "created_at": now}, + "$setOnInsert": { + "session_id": self.session_id, + "created_at": now, + "_generation": 0, + }, "$set": {"updated_at": now}, - "$inc": {"_seq": len(items)}, + "$inc": {"_seq": len(serialized_items)}, }, upsert=True, return_document=True, ) - next_seq: int = (result["_seq"] if result else len(items)) - len(items) + next_seq: int = (result["_seq"] if result else len(serialized_items)) - len( + serialized_items + ) + generation = result.get("_generation", 0) if result else 0 + if not isinstance(generation, int): + generation = 0 - payload = [ + # One document is the commit boundary for the logical batch. This keeps + # standalone MongoDB deployments failure-atomic without requiring transactions. + await self._messages.insert_one( { "session_id": self.session_id, - "seq": next_seq + i, - "message_data": await self._serialize_item(item), + "seq": next_seq + len(serialized_items) - 1, + "generation": generation, + "message_data": serialized_items, } - for i, item in enumerate(items) - ] - - await self._messages.insert_many(payload, ordered=True) + ) async def pop_item(self) -> TResponseInputItem | None: + """Remove the most recent item after the destructive claim settles.""" + await self._ensure_indexes() + return await _await_mutation(self._pop_item()) + + async def _pop_item(self) -> TResponseInputItem | None: """Remove and return the most recent item from the session. Returns: The most recent item if it exists, ``None`` if the session is empty. - Corrupt documents (invalid JSON, missing/non-string ``message_data``) - are silently discarded and the next-most-recent item is returned. This - matches :meth:`get_items`, which also skips corrupt documents, so a - single bad row cannot make a non-empty session look empty to callers. + New list-valued logical batches and legacy string-valued items are both + supported. Malformed entries (invalid JSON, missing ``message_data``, or + other value types) are silently discarded and the next-most-recent item + is returned. This matches :meth:`get_items`, which also skips malformed + entries, so one bad record cannot make a non-empty session look empty. """ - await self._ensure_indexes() + generation = await self._get_generation() + + # Retry cleanup left by a prior post-claim failure. Empty markers are + # never model-visible or claimable, so cleanup failure must not block a + # later valid tail claim. + try: + await self._messages.delete_many( + { + "session_id": self.session_id, + **self._generation_query(generation), + "message_data": [], + } + ) + except asyncio.CancelledError: + raise + except Exception: + pass while True: - doc = await self._messages.find_one_and_delete( - {"session_id": self.session_id}, + doc = await self._messages.find_one_and_update( + { + "session_id": self.session_id, + **self._generation_query(generation), + "message_data": {"$ne": []}, + }, + [ + { + "$set": { + "message_data": { + "$cond": [ + {"$isArray": "$message_data"}, + { + "$slice": [ + "$message_data", + { + "$subtract": [ + {"$size": "$message_data"}, + 1, + ] + }, + ] + }, + [], + ] + } + } + } + ], sort=[("seq", -1)], + return_document=False, ) if doc is None: + current_generation = await self._get_generation() + if current_generation != generation: + generation = current_generation + continue return None + + current_generation = await self._get_generation() + if current_generation != generation: + try: + await self._messages.delete_one({"_id": doc["_id"]}) + except asyncio.CancelledError: + raise + except Exception: + pass + generation = current_generation + continue + raw = doc.get("message_data") + + if isinstance(raw, list): + if not raw: + continue + claimed_raw = raw[-1] + exhausted = len(raw) == 1 + else: + claimed_raw = raw + exhausted = True + + if exhausted: + # The atomic claim above leaves an empty marker so another pop cannot + # claim the same item. Remove that marker before returning to avoid + # accumulating exhausted logical-batch and legacy documents. + try: + await self._messages.delete_one({"_id": doc["_id"], "message_data": []}) + except asyncio.CancelledError: + raise + except Exception: + # The item is already claimed. Do not turn a known destructive + # outcome into a retry-visible failure; the next pop retries the + # best-effort empty-marker sweep above. + pass + try: - return await self._deserialize_item(doc["message_data"]) - except (json.JSONDecodeError, KeyError, TypeError): + return await self._deserialize_item(claimed_raw) + except (json.JSONDecodeError, TypeError): # Corrupt — drop it and try the next-most-recent document. continue async def clear_session(self) -> None: - """Clear all items for this session.""" + """Clear history after the authoritative delete settles.""" await self._ensure_indexes() - await self._messages.delete_many({"session_id": self.session_id}) - await self._sessions.delete_one({"session_id": self.session_id}) + await _await_mutation(self._clear_session()) + + async def _clear_session(self) -> None: + """Advance the authoritative generation and clean obsolete history.""" + now = datetime.now(timezone.utc) + result = await self._sessions.find_one_and_update( + {"session_id": self.session_id}, + { + "$setOnInsert": { + "session_id": self.session_id, + "created_at": now, + "_seq": 0, + }, + "$set": {"updated_at": now}, + "$inc": {"_generation": 1}, + }, + upsert=True, + return_document=True, + ) + generation = result.get("_generation", 1) if result else 1 + if not isinstance(generation, int): + generation = 1 + + # The metadata update above is the single-document clear boundary. + # Obsolete batches are no longer visible, so physical deletion is best effort. + try: + await self._messages.delete_many( + { + "session_id": self.session_id, + "$or": [ + {"generation": {"$lt": generation}}, + {"generation": {"$exists": False}}, + ], + } + ) + except asyncio.CancelledError: + raise + except Exception: + pass # ------------------------------------------------------------------ # Lifecycle helpers diff --git a/src/agents/extensions/memory/redis_session.py b/src/agents/extensions/memory/redis_session.py index de9efbf6c8..953b1cf683 100644 --- a/src/agents/extensions/memory/redis_session.py +++ b/src/agents/extensions/memory/redis_session.py @@ -24,13 +24,17 @@ import asyncio import json import time +from dataclasses import dataclass from typing import Any from ._optional_imports import raise_optional_dependency_error try: import redis.asyncio as redis - from redis.asyncio import Redis + import redis.asyncio.connection as redis_connection + from redis.asyncio import BlockingConnectionPool, Redis + from redis.event import AsyncAfterConnectionReleasedEvent + from redis.exceptions import ConnectionError as RedisConnectionError, ResponseError, WatchError except ImportError as e: raise_optional_dependency_error( "RedisSession", @@ -46,6 +50,308 @@ coerce_session_settings, resolve_session_limit, ) +from ...memory.sqlite_session import _await_mutation + +_redis_connection_api: Any = redis_connection + + +@dataclass +class _PipelineAttemptOutcome: + committed: bool + retryable_watch_conflict: bool + operation_error: BaseException | None + cleanup_error: BaseException | None + settled: bool + + +class _PipelineConnectionPool: + """Track one pipeline's connection release without changing the shared pool.""" + + def __init__(self, pool: Any): + self._pool = pool + self.connection: Any | None = None + self.release_started = False + self.release_completed = False + self._checkout_recorded_used = False + + def __getattr__(self, name: str) -> Any: + return getattr(self._pool, name) + + async def get_connection(self, *args: Any, **kwargs: Any) -> Any: + """Retain an acquired identity before validating it for pipeline use.""" + del args, kwargs + + async def acquire() -> Any: + if isinstance(self._pool, BlockingConnectionPool): + start_time_acquired = time.monotonic() + has_timing_observability = all( + hasattr(_redis_connection_api, name) + for name in ( + "get_pool_name", + "record_connection_create_time", + "record_connection_wait_time", + ) + ) + try: + async with self._pool._condition: + await asyncio.wait_for( + self._pool._condition.wait_for(self._pool.can_get_connection), + timeout=self._pool.timeout, + ) + maybe_pool_lock = getattr(self._pool, "_maybe_pool_lock", None) + if has_timing_observability: + connections_before = len(self._pool._available_connections) + len( + self._pool._in_use_connections + ) + start_time_created = time.monotonic() + if maybe_pool_lock is None: + connection = self._pool.get_available_connection() + self.connection = connection + else: + async with maybe_pool_lock(): + connection = self._pool.get_available_connection() + self.connection = connection + if has_timing_observability: + connections_after = len(self._pool._available_connections) + len( + self._pool._in_use_connections + ) + is_created = connections_after > connections_before + except asyncio.TimeoutError as exc: + raise RedisConnectionError("No connection available.") from exc + await self._pool.ensure_connection(connection) + if has_timing_observability: + if is_created: + await _redis_connection_api.record_connection_create_time( + connection_pool=self._pool, + duration_seconds=time.monotonic() - start_time_created, + ) + await _redis_connection_api.record_connection_wait_time( + pool_name=_redis_connection_api.get_pool_name(self._pool), + duration_seconds=time.monotonic() - start_time_acquired, + ) + return connection + + has_observability = hasattr(_redis_connection_api, "record_connection_count") + async with self._pool._lock: + if has_observability: + connections_before = len(self._pool._available_connections) + len( + self._pool._in_use_connections + ) + start_time_created = time.monotonic() + connection = self._pool.get_available_connection() + self.connection = connection + if has_observability: + connections_after = len(self._pool._available_connections) + len( + self._pool._in_use_connections + ) + is_created = connections_after > connections_before + else: + await self._pool.ensure_connection(connection) + return connection + + pool_name = _redis_connection_api.get_pool_name(self._pool) + if is_created: + await _redis_connection_api.record_connection_count( + pool_name=pool_name, + connection_state=_redis_connection_api.ConnectionState.USED, + counter=1, + ) + else: + await _redis_connection_api.record_connection_count( + pool_name=pool_name, + connection_state=_redis_connection_api.ConnectionState.IDLE, + counter=-1, + ) + await _redis_connection_api.record_connection_count( + pool_name=pool_name, + connection_state=_redis_connection_api.ConnectionState.USED, + counter=1, + ) + self._checkout_recorded_used = True + await self._pool.ensure_connection(connection) + if is_created: + await _redis_connection_api.record_connection_create_time( + connection_pool=self._pool, + duration_seconds=time.monotonic() - start_time_created, + ) + return connection + + acquisition = asyncio.create_task(acquire()) + cancellation: asyncio.CancelledError | None = None + while not acquisition.done(): + try: + await asyncio.wait({acquisition}) + except asyncio.CancelledError as exc: + if cancellation is None: + cancellation = exc + if self.connection is None: + acquisition.cancel() + + try: + connection = acquisition.result() + except BaseException: + if cancellation is not None: + raise cancellation from None + raise + if cancellation is not None: + raise cancellation from None + return connection + + async def record_discard(self) -> None: + """Balance supported pool observability when a checked-out connection is removed.""" + if not self._checkout_recorded_used: + return + await _redis_connection_api.record_connection_count( + pool_name=_redis_connection_api.get_pool_name(self._pool), + connection_state=_redis_connection_api.ConnectionState.USED, + counter=-1, + ) + self._checkout_recorded_used = False + + async def notify_capacity_available(self) -> None: + if isinstance(self._pool, BlockingConnectionPool): + async with self._pool._condition: + self._pool._condition.notify() + + def _install_release_transfer_listener(self, connection: Any) -> Any: + """Mark the exact point where redis-py exposes an identity for reuse.""" + owner = self + + class ReleaseTransferListener: + async def listen(self, event: Any) -> None: + if event.connection is connection: + owner.release_completed = True + owner._checkout_recorded_used = False + + listener = ReleaseTransferListener() + dispatcher = self._pool._event_dispatcher + with dispatcher._lock: + listeners = dispatcher._event_listeners_mapping.get( + AsyncAfterConnectionReleasedEvent, [] + ) + dispatcher._event_listeners_mapping[AsyncAfterConnectionReleasedEvent] = [ + listener, + *listeners, + ] + return listener + + def _remove_release_transfer_listener(self, listener: Any) -> None: + dispatcher = self._pool._event_dispatcher + with dispatcher._lock: + listeners = dispatcher._event_listeners_mapping.get( + AsyncAfterConnectionReleasedEvent, [] + ) + dispatcher._event_listeners_mapping[AsyncAfterConnectionReleasedEvent] = [ + current for current in listeners if current is not listener + ] + + async def release(self, connection: Any) -> None: + self.connection = connection + self.release_started = True + was_in_use = connection in self._pool._in_use_connections + if was_in_use: + try: + if connection.should_reconnect(): + # Let Pipeline.reset() finish clearing its local state, then + # let _finish_pipeline() detach this retained identity without + # exposing it to shared-pool release listeners. + return + except BaseException: + pass + transfer_listener = self._install_release_transfer_listener(connection) + try: + await self._pool.release(connection) + except BaseException: + if self.release_completed and connection in self._pool._available_connections: + await self.notify_capacity_available() + raise + else: + self.release_completed = True + self._checkout_recorded_used = False + finally: + self._remove_release_transfer_listener(transfer_listener) + + +async def _finish_pipeline( + pipe: Any, + connection_pool: _PipelineConnectionPool, + *, + discard_connection: bool = False, +) -> tuple[BaseException | None, bool, Any | None]: + """Reset a pipeline and prove whether its connection left pipeline ownership.""" + if connection_pool.release_completed: + pipe.connection = None + return None, True, None + + reset_error: BaseException | None = None + if not connection_pool.release_started and not discard_connection: + try: + await pipe.reset() + except BaseException as exc: + reset_error = exc + + if connection_pool.release_completed: + pipe.connection = None + return reset_error, True, None + + connection = connection_pool.connection or getattr(pipe, "connection", None) + if connection is None: + return reset_error, True, None + + # Any retained identity at this point has no proven pool transfer. Detach it + # directly instead of starting or repeating a shared-pool release whose + # listeners could reborrow the identity before reporting a failure. + connection_pool._pool._in_use_connections.discard(connection) + while connection in connection_pool._pool._available_connections: + connection_pool._pool._available_connections.remove(connection) + metrics_error: BaseException | None = None + try: + await connection_pool.record_discard() + except BaseException as exc: + metrics_error = exc + try: + connection._close() + except BaseException as close_error: + pipe.connection = None + connection_pool.release_completed = True + await connection_pool.notify_capacity_available() + return close_error, True, connection + if metrics_error is not None: + pipe.connection = None + connection_pool.release_completed = True + await connection_pool.notify_capacity_available() + return metrics_error, True, None + await connection_pool.notify_capacity_available() + pipe.connection = None + connection_pool.release_completed = True + return reset_error, True, None + + +async def _await_pipeline_attempt( + attempt: asyncio.Task[_PipelineAttemptOutcome], + completion_owned: asyncio.Event, +) -> tuple[_PipelineAttemptOutcome, asyncio.CancelledError | None]: + """Wait for an attempt while preserving only caller-originated cancellation.""" + cancellation: asyncio.CancelledError | None = None + attempt_cancelled = False + + while not attempt.done(): + try: + await asyncio.wait({attempt}) + except asyncio.CancelledError as exc: + if cancellation is None: + cancellation = exc + if not completion_owned.is_set() and not attempt_cancelled: + attempt.cancel() + attempt_cancelled = True + + try: + outcome = attempt.result() + except BaseException: + if cancellation is not None: + raise cancellation from None + raise + return outcome, cancellation class RedisSession(SessionABC): @@ -70,7 +376,8 @@ def __init__( key_prefix (str, optional): Prefix for Redis keys to avoid collisions. Defaults to "agents:session". ttl (int | None, optional): Time-to-live in seconds for session data. - If None, data persists indefinitely. Defaults to None. + If None, data persists indefinitely. Values outside Redis's supported expiration + range raise ValueError when adding items. Defaults to None. session_settings (SessionSettings | None): Session configuration settings including default limit for retrieving items. If None, uses default SessionSettings(). """ @@ -87,6 +394,7 @@ def __init__( self._owns_client = False # Track if we own the Redis client self._closed = False self._client_released = False + self._detached_connections: set[Any] = set() # Redis key patterns self._session_key = f"{self._key_prefix}:{self.session_id}" @@ -143,14 +451,6 @@ async def _get_next_id(self) -> int: result = await self._redis.incr(self._counter_key) return int(result) - async def _set_ttl_if_configured(self, *keys: str) -> None: - """Set TTL on keys if configured.""" - if self._ttl is not None: - pipe = self._redis.pipeline() - for key in keys: - pipe.expire(key, self._ttl) - await pipe.execute() - # ------------------------------------------------------------------ # Session protocol implementation # ------------------------------------------------------------------ @@ -160,6 +460,162 @@ def _check_not_closed(self) -> None: if self._closed: raise RuntimeError("RedisSession is closed") + @staticmethod + def _key_type_name(key_type: Any) -> str: + """Normalize Redis TYPE responses from bytes and decoded clients.""" + if isinstance(key_type, bytes): + return key_type.decode("utf-8") + return str(key_type) + + async def _write_items_attempt( + self, + pipe: Any, + keys: tuple[str, str, str], + serialized_items: list[str], + completion_owned: asyncio.Event, + ) -> _PipelineAttemptOutcome: + """Run one watched write attempt and finish its pipeline before returning.""" + committed = False + retryable_watch_conflict = False + operation_error: BaseException | None = None + discard_connection = False + batch_response_index: int | None = None + raise_first_error = pipe.raise_first_error + parse_response = pipe.parse_response + raw_connection_pool = pipe.connection_pool + connection_pool = _PipelineConnectionPool(raw_connection_pool) + pipe.connection_pool = connection_pool + + def raise_first_error_and_mark(*args: Any, **kwargs: Any) -> Any: + nonlocal committed + response = args[1] if len(args) > 1 else kwargs.get("response") + if ( + batch_response_index is not None + and isinstance(response, list) + and batch_response_index < len(response) + and not isinstance(response[batch_response_index], BaseException) + ): + committed = True + result = raise_first_error(*args, **kwargs) + committed = True + return result + + parsed_transaction_responses = 0 + exec_response_position = 0 + + async def parse_response_and_classify(*args: Any, **kwargs: Any) -> Any: + nonlocal parsed_transaction_responses, retryable_watch_conflict + response_position = parsed_transaction_responses + parsed_transaction_responses += 1 + response = await parse_response(*args, **kwargs) + if response_position == exec_response_position and response is None: + retryable_watch_conflict = True + return response + + try: + try: + await pipe.watch(*keys) + session_key_type = self._key_type_name(await pipe.type(self._session_key)) + messages_key_type = self._key_type_name(await pipe.type(self._messages_key)) + if session_key_type not in ("none", "hash"): + raise ResponseError("WRONGTYPE session metadata key must contain a hash") + if messages_key_type not in ("none", "list"): + raise ResponseError("WRONGTYPE session messages key must contain a list") + + if self._ttl is None: + now = str(int(time.time())) + expiration_time_ms = None + else: + server_seconds, server_microseconds = await pipe.time() + now = str(int(server_seconds)) + expiration_time_ms = ( + int(server_seconds) * 1000 + + int(server_microseconds) // 1000 + + self._ttl * 1000 + ) + min_int64 = -(2**63) + max_int64 = 2**63 - 1 + if not min_int64 <= expiration_time_ms <= max_int64: + raise ValueError("ttl is outside Redis's supported expiration range") + + pipe.multi() + pipe.hset(self._session_key, "session_id", self.session_id) + pipe.hsetnx(self._session_key, "created_at", now) + batch_response_index = len(pipe.command_stack) + pipe.rpush(self._messages_key, *serialized_items) + pipe.hset(self._session_key, "updated_at", now) + if expiration_time_ms is not None: + for key in keys: + pipe.pexpireat(key, expiration_time_ms) + + pipe.raise_first_error = raise_first_error_and_mark + exec_response_position = len(pipe.command_stack) + 1 + pipe.parse_response = parse_response_and_classify + completion_owned.set() + await pipe.execute() + committed = True + except WatchError as exc: + operation_error = exc + except BaseException as exc: + operation_error = exc + if isinstance(exc, asyncio.CancelledError) and not completion_owned.is_set(): + # An immediate WATCH command may have been sent without its + # response being consumed. Never return that connection to + # shared pool reuse or invoke release listeners with it. + discard_connection = True + finally: + completion_owned.set() + pipe.raise_first_error = raise_first_error + pipe.parse_response = parse_response + cleanup_error, settled, detached_connection = await _finish_pipeline( + pipe, + connection_pool, + discard_connection=discard_connection, + ) + if detached_connection is not None: + self._detached_connections.add(detached_connection) + pipe.connection_pool = raw_connection_pool + + return _PipelineAttemptOutcome( + committed=committed, + retryable_watch_conflict=retryable_watch_conflict, + operation_error=operation_error, + cleanup_error=cleanup_error, + settled=settled, + ) + + async def _write_items( + self, + serialized_items: list[str], + ) -> None: + """Validate key types and atomically write one batch with optimistic locking.""" + keys = (self._session_key, self._messages_key, self._counter_key) + while True: + pipe = self._redis.pipeline() + completion_owned = asyncio.Event() + attempt = asyncio.create_task( + self._write_items_attempt(pipe, keys, serialized_items, completion_owned) + ) + outcome, cancellation = await _await_pipeline_attempt(attempt, completion_owned) + + if not outcome.settled: + if outcome.cleanup_error is not None: + raise outcome.cleanup_error + raise RuntimeError("Redis pipeline cleanup did not settle its connection") + if outcome.committed: + if cancellation is not None: + raise cancellation + return + if cancellation is not None: + raise cancellation + if outcome.cleanup_error is not None: + raise outcome.cleanup_error + if outcome.retryable_watch_conflict: + continue + if outcome.operation_error is not None: + raise outcome.operation_error + return + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: """Retrieve the conversation history for this session. @@ -224,32 +680,12 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: async with self._lock: self._check_not_closed() - pipe = self._redis.pipeline() - now = str(int(time.time())) - - # Set session metadata, preserving created_at across subsequent writes. - pipe.hset(self._session_key, "session_id", self.session_id) - pipe.hsetnx(self._session_key, "created_at", now) - - # Add all items to the messages list serialized_items = [] for item in items: serialized = await self._serialize_item(item) serialized_items.append(serialized) - if serialized_items: - pipe.rpush(self._messages_key, *serialized_items) - - # Update the session timestamp - pipe.hset(self._session_key, "updated_at", now) - - # Execute all commands - await pipe.execute() - - # Set TTL if configured - await self._set_ttl_if_configured( - self._session_key, self._messages_key, self._counter_key - ) + await self._write_items(serialized_items) async def pop_item(self) -> TResponseInputItem | None: """Remove and return the most recent item from the session. @@ -259,34 +695,41 @@ async def pop_item(self) -> TResponseInputItem | None: """ async with self._lock: self._check_not_closed() - while True: - # Use RPOP to atomically remove and return the rightmost (most recent) item - raw_msg = await self._redis.rpop(self._messages_key) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context + return await _await_mutation(self._pop_item_locked()) - if raw_msg is None: - return None + async def _pop_item_locked(self) -> TResponseInputItem | None: + """Claim one item while the caller retains the session lock.""" + while True: + # Use RPOP to atomically remove and return the rightmost (most recent) item + raw_msg = await self._redis.rpop(self._messages_key) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context - try: - # Handle both bytes (default) and str (decode_responses=True) Redis clients - if isinstance(raw_msg, bytes): - msg_str = raw_msg.decode("utf-8") - else: - msg_str = raw_msg # Already a string - return await self._deserialize_item(msg_str) - except (json.JSONDecodeError, UnicodeDecodeError): - # Drop corrupted messages and keep looking for a valid item. - continue + if raw_msg is None: + return None + + try: + # Handle both bytes (default) and str (decode_responses=True) Redis clients + if isinstance(raw_msg, bytes): + msg_str = raw_msg.decode("utf-8") + else: + msg_str = raw_msg # Already a string + return await self._deserialize_item(msg_str) + except (json.JSONDecodeError, UnicodeDecodeError): + # Drop corrupted messages and keep looking for a valid item. + continue async def clear_session(self) -> None: """Clear all items for this session.""" async with self._lock: self._check_not_closed() - # Delete all keys associated with this session - await self._redis.delete( - self._session_key, - self._messages_key, - self._counter_key, - ) + await _await_mutation(self._clear_session_locked()) + + async def _clear_session_locked(self) -> None: + """Delete all session keys while the caller retains the session lock.""" + await self._redis.delete( + self._session_key, + self._messages_key, + self._counter_key, + ) async def close(self) -> None: """Close the Redis connection. @@ -303,12 +746,26 @@ async def close(self) -> None: concurrent calls are safe no-ops. """ async with self._lock: + detached_error: BaseException | None = None + for connection in tuple(self._detached_connections): + try: + connection._close() + except BaseException as exc: + if detached_error is None: + detached_error = exc + else: + self._detached_connections.discard(connection) + if not self._owns_client: + if detached_error is not None: + raise detached_error return self._closed = True if not self._client_released: await self._redis.aclose() self._client_released = True + if detached_error is not None: + raise detached_error async def ping(self) -> bool: """Test Redis connectivity. diff --git a/src/agents/extensions/memory/sqlalchemy_session.py b/src/agents/extensions/memory/sqlalchemy_session.py index 8751cca68b..81f7dcdae8 100644 --- a/src/agents/extensions/memory/sqlalchemy_session.py +++ b/src/agents/extensions/memory/sqlalchemy_session.py @@ -27,7 +27,8 @@ import json import threading import weakref -from typing import Any, ClassVar +from collections.abc import Awaitable, Callable +from typing import Any, ClassVar, TypeVar from sqlalchemy import ( TIMESTAMP, @@ -57,6 +58,9 @@ coerce_session_settings, resolve_session_limit, ) +from ...memory.sqlite_session import _await_mutation + +_T = TypeVar("_T") class SQLAlchemySession(SessionABC): @@ -122,23 +126,22 @@ def _configure_sqlite_connection(dbapi_connection: Any, _: Any) -> None: def _is_sqlite_lock_error(exc: OperationalError) -> bool: return "database is locked" in str(exc).lower() - async def _run_sqlite_write_with_retry(self, operation: Any) -> None: + async def _run_sqlite_write_with_retry(self, operation: Callable[[], Awaitable[_T]]) -> _T: """Retry transient SQLite write lock failures with bounded backoff.""" if self._engine.dialect.name != "sqlite": - await operation() - return + return await operation() for attempt, delay in enumerate((0.0, *self._SQLITE_LOCK_RETRY_DELAYS)): if delay: await asyncio.sleep(delay) try: - await operation() - return + return await operation() except OperationalError as exc: if not self._is_sqlite_lock_error(exc): raise if attempt == len(self._SQLITE_LOCK_RETRY_DELAYS): raise + raise AssertionError("SQLite write retry loop exited unexpectedly") def __init__( self, @@ -412,21 +415,32 @@ async def _write_items() -> None: .values(updated_at=sql_text("CURRENT_TIMESTAMP")) ) - await self._run_sqlite_write_with_retry(_write_items) + await _await_mutation(self._run_sqlite_write_with_retry(_write_items)) async def pop_item(self) -> TResponseInputItem | None: + """Remove the most recent item after its transaction settles.""" + await self._ensure_tables() + return await _await_mutation(self._run_sqlite_write_with_retry(self._pop_item)) + + async def _pop_item(self) -> TResponseInputItem | None: """Remove and return the most recent item from the session. Returns: The most recent item if it exists, None if the session is empty """ - await self._ensure_tables() - async with self._session_factory() as sess: - async with sess.begin(): - while True: - # Fallback for all dialects - get ID first, then delete - subq = ( - select(self._messages.c.id) + while True: + retry_claim = False + async with self._session_factory() as sess: + async with sess.begin(): + if ( + self._engine.dialect.name == "sqlite" + and not self._engine.dialect.delete_returning + ): + # SQLite ignores SELECT ... FOR UPDATE. Reserve the single + # writer before selecting so the fallback claim remains unique. + await sess.execute(sql_text("BEGIN IMMEDIATE")) + tail = ( + select(self._messages.c.id, self._messages.c.message_data) .where(self._messages.c.session_id == self.session_id) .order_by( self._messages.c.created_at.desc(), @@ -434,27 +448,58 @@ async def pop_item(self) -> TResponseInputItem | None: ) .limit(1) ) - res = await sess.execute(subq) - row_id = res.scalar_one_or_none() - if row_id is None: - return None - # Fetch data before deleting - res_data = await sess.execute( - select(self._messages.c.message_data).where(self._messages.c.id == row_id) - ) - row = res_data.scalar_one_or_none() - await sess.execute(delete(self._messages).where(self._messages.c.id == row_id)) - if row is None: - continue - try: - return await self._deserialize_item(row) - except (json.JSONDecodeError, TypeError): - continue + if self._engine.dialect.delete_returning: + # DELETE ... RETURNING is the claim: only the transaction that + # removes the current tail receives its payload. This avoids relying + # on DBAPI rowcount, which some dialects report as unknown. + result = await sess.execute( + delete(self._messages) + .where( + self._messages.c.id + == tail.with_only_columns(self._messages.c.id).scalar_subquery() + ) + .returning(self._messages.c.message_data) + ) + row = result.scalar_one_or_none() + if row is None: + # A concurrent DELETE can win the same tail between the + # subquery read and this claim. Distinguish that race from + # an empty session before retrying with a fresh transaction. + remaining = await sess.execute( + tail.with_only_columns(self._messages.c.id) + ) + if remaining.scalar_one_or_none() is None: + return None + retry_claim = True + else: + # Dialects without DELETE ... RETURNING claim the row with a + # transaction-scoped lock before deleting it. The lock, rather than + # rowcount, establishes ownership of the returned payload. + result = await sess.execute(tail.with_for_update()) + claimed = result.one_or_none() + if claimed is None: + return None + row_id, row = claimed + await sess.execute( + delete(self._messages).where(self._messages.c.id == row_id) + ) + + if retry_claim: + continue + assert row is not None + try: + return await self._deserialize_item(row) + except (json.JSONDecodeError, TypeError): + continue async def clear_session(self) -> None: - """Clear all items for this session.""" + """Clear history after its transaction settles.""" await self._ensure_tables() + await _await_mutation(self._clear_session()) + + async def _clear_session(self) -> None: + """Clear all items for this session.""" async with self._session_factory() as sess: async with sess.begin(): await sess.execute( diff --git a/src/agents/memory/sqlite_session.py b/src/agents/memory/sqlite_session.py index 61bf4e563b..fc9f3fdb8f 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -4,15 +4,39 @@ import json import sqlite3 import threading -from collections.abc import Iterator +from collections.abc import Awaitable, Iterator from contextlib import contextmanager from pathlib import Path -from typing import Any, ClassVar +from typing import Any, ClassVar, TypeVar from ..items import TResponseInputItem from .session import SessionABC from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit +_T = TypeVar("_T") + + +async def _await_mutation(awaitable: Awaitable[_T]) -> _T: + """Wait for a mutation outcome despite repeated caller cancellation.""" + task = asyncio.ensure_future(awaitable) + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.wait({task}) + except asyncio.CancelledError as exc: + if cancellation is None: + cancellation = exc + + try: + result = task.result() + except BaseException: + if cancellation is not None: + raise cancellation from None + raise + if cancellation is not None: + raise cancellation from None + return result + class SQLiteSession(SessionABC): """SQLite-based implementation of session storage. @@ -57,6 +81,7 @@ def __init__( self.messages_table = messages_table self._local = threading.local() self._connections: set[sqlite3.Connection] = set() + self._quarantined_connections: set[sqlite3.Connection] = set() self._connections_lock = threading.Lock() self._closed = False @@ -125,6 +150,39 @@ def _check_not_closed(self) -> None: if self._closed: raise RuntimeError("SQLiteSession is closed") + @contextmanager + def _write_connection(self) -> Iterator[sqlite3.Connection]: + """Provide a connection that cannot retain a failed write transaction.""" + with self._locked_connection() as conn: + try: + yield conn + except BaseException: + try: + conn.rollback() + except BaseException: + self._invalidate_connection(conn) + raise + + def _invalidate_connection(self, conn: sqlite3.Connection) -> None: + """Close and evict a connection that could not roll back safely.""" + try: + conn.close() + except BaseException: + close_failed = True + else: + close_failed = False + + with self._connections_lock: + self._connections.discard(conn) + if close_failed: + self._quarantined_connections.add(conn) + else: + self._quarantined_connections.discard(conn) + if getattr(self._local, "connection", None) is conn: + del self._local.connection + if self._is_memory_db or close_failed: + self._closed = True + def _get_connection(self) -> sqlite3.Connection: """Get a database connection.""" self._check_not_closed() @@ -294,20 +352,11 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: return def _add_items_sync(): - with self._locked_connection() as conn: - try: - self._insert_items(conn, items) - conn.commit() - except Exception: - # _locked_connection() does not manage transactions; roll back - # explicitly so a failure partway through the insert never leaves a - # partial mutation or an open transaction on this cached connection. - # An open write transaction would hold the SQLite write lock for the - # lifetime of the connection and block every later writer. - conn.rollback() - raise + with self._write_connection() as conn: + self._insert_items(conn, items) + conn.commit() - await asyncio.to_thread(_add_items_sync) + await _await_mutation(asyncio.to_thread(_add_items_sync)) async def pop_item(self) -> TResponseInputItem | None: """Remove and return the most recent item from the session. @@ -317,7 +366,7 @@ async def pop_item(self) -> TResponseInputItem | None: """ def _pop_item_sync(): - with self._locked_connection() as conn: + with self._write_connection() as conn: # Use DELETE with RETURNING to atomically delete and return the most recent item cursor = conn.execute( f""" @@ -361,13 +410,13 @@ def _pop_item_sync(): return None - return await asyncio.to_thread(_pop_item_sync) + return await _await_mutation(asyncio.to_thread(_pop_item_sync)) async def clear_session(self) -> None: """Clear all items for this session.""" def _clear_session_sync(): - with self._locked_connection() as conn: + with self._write_connection() as conn: conn.execute( f"DELETE FROM {self.messages_table} WHERE session_id = ?", (self.session_id,), @@ -378,24 +427,44 @@ def _clear_session_sync(): ) conn.commit() - await asyncio.to_thread(_clear_session_sync) + await _await_mutation(asyncio.to_thread(_clear_session_sync)) def close(self) -> None: """Close the database connection.""" with self._lock: - if self._closed: - return - self._closed = True + with self._connections_lock: + connections = self._connections | self._quarantined_connections if self._is_memory_db: if hasattr(self, "_shared_connection"): - self._shared_connection.close() - else: + connections.add(self._shared_connection) + + first_error: BaseException | None = None + for connection in connections: + try: + connection.close() + except BaseException as exc: + if first_error is None: + first_error = exc + with self._connections_lock: + self._connections.discard(connection) + self._quarantined_connections.add(connection) + else: + with self._connections_lock: + self._connections.discard(connection) + self._quarantined_connections.discard(connection) + + if getattr(self._local, "connection", None) in connections: + del self._local.connection + + with self._connections_lock: + has_unclosed_connections = bool(self._quarantined_connections) + if not has_unclosed_connections and self._lock_path is not None: with self._connections_lock: - connections = list(self._connections) self._connections.clear() - for connection in connections: - connection.close() - if self._lock_path is not None and not self._lock_released: - self._release_file_lock(self._lock_path) - self._lock_released = True + if not self._lock_released: + self._release_file_lock(self._lock_path) + self._lock_released = True + + if first_error is not None: + raise first_error diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index ef1e72e16a..389d174e68 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -5,6 +5,8 @@ import json import logging import multiprocessing +import sqlite3 +import sys import tempfile import threading import time @@ -31,6 +33,12 @@ pytestmark = pytest.mark.asyncio +def _assert_cancel_message(exc: asyncio.CancelledError, expected: str) -> None: + """Account for Python 3.10 dropping Task cancellation messages when re-awaited.""" + expected_args = (expected,) if sys.version_info >= (3, 11) else () + assert exc.args == expected_args + + @function_tool async def test_tool(query: str) -> str: """A test tool for testing tool call tracking.""" @@ -373,6 +381,297 @@ async def test_add_items_rolls_back_partial_structure_metadata_write(): session.close() +async def test_add_items_rollback_failure_invalidates_connection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """Advanced add failures must use the base rollback-failure invalidation path.""" + + class FailingRollbackConnection(sqlite3.Connection): + def rollback(self) -> None: + raise RuntimeError("rollback failed") + + db_path = tmp_path / "advanced_rollback_failure.db" + session = AdvancedSQLiteSession( + session_id="advanced_rollback_failure", + db_path=db_path, + create_tables=True, + ) + conn = sqlite3.connect( + str(db_path), + check_same_thread=False, + factory=FailingRollbackConnection, + ) + with session._connections_lock: + session._connections.add(conn) + real_get_connection = session._get_connection + real_insert_structure_metadata = session._insert_structure_metadata + monkeypatch.setattr(session, "_get_connection", lambda: conn) + + def fail_structure_metadata(*_args: Any) -> None: + raise RuntimeError("structure metadata failed") + + monkeypatch.setattr(session, "_insert_structure_metadata", fail_structure_metadata) + + with pytest.raises(RuntimeError, match="structure metadata failed"): + await session.add_items([{"role": "user", "content": "not saved"}]) + + assert conn not in session._connections + probe = sqlite3.connect(str(db_path), timeout=0) + try: + probe.execute("CREATE TABLE IF NOT EXISTS probe_lock (x INTEGER)") + probe.commit() + finally: + probe.close() + + monkeypatch.setattr(session, "_get_connection", real_get_connection) + monkeypatch.setattr(session, "_insert_structure_metadata", real_insert_structure_metadata) + await session.add_items([{"role": "user", "content": "after failure"}]) + assert await session.get_items() == [{"role": "user", "content": "after failure"}] + session.close() + + +async def test_structure_initialization_failure_invalidates_connection( + tmp_path: Path, +): + """Initialization must release its write lock even when rollback also fails.""" + + class FailingRollbackConnection(sqlite3.Connection): + def rollback(self) -> None: + raise RuntimeError("rollback failed") + + captured_connections: list[sqlite3.Connection] = [] + + class FailingRollbackInitSession(AdvancedSQLiteSession): + def _get_connection(self) -> sqlite3.Connection: + if not hasattr(self, "_test_connection"): + connection = sqlite3.connect( + str(self.db_path), + check_same_thread=False, + factory=FailingRollbackConnection, + ) + self._test_connection = connection + captured_connections.append(connection) + with self._connections_lock: + self._connections.add(connection) + return self._test_connection + + db_path = tmp_path / "advanced_init_failure.db" + setup = AdvancedSQLiteSession( + session_id="advanced_init_failure", + db_path=db_path, + create_tables=True, + ) + try: + await setup.add_items([{"role": "user", "content": "existing"}]) + finally: + setup.close() + + conflict = sqlite3.connect(str(db_path)) + try: + conflict.execute("DROP TABLE branch_reservations") + conflict.execute("DROP INDEX idx_structure_session_seq") + conflict.execute("CREATE TABLE idx_structure_session_seq (value INTEGER)") + conflict.commit() + finally: + conflict.close() + + with pytest.raises(sqlite3.OperationalError, match="already a table"): + FailingRollbackInitSession( + session_id="advanced_init_failure", + db_path=db_path, + create_tables=True, + ) + + assert len(captured_connections) == 1 + with pytest.raises(sqlite3.ProgrammingError): + captured_connections[0].execute("SELECT 1") + + probe = sqlite3.connect(str(db_path), timeout=0) + try: + probe.execute("CREATE TABLE IF NOT EXISTS probe_lock (x INTEGER)") + probe.commit() + finally: + probe.close() + + +@pytest.mark.parametrize("operation", ["add", "pop", "clear"]) +async def test_post_commit_cancellation_propagates_after_known_mutation_outcome( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + operation: str, +): + """Cancellation after commit must propagate without inviting a mutation retry.""" + + class PausingCommitConnection(sqlite3.Connection): + pause_commit = False + commit_finished = threading.Event() + allow_return = threading.Event() + + def commit(self) -> None: + super().commit() + if self.pause_commit: + self.pause_commit = False + self.commit_finished.set() + assert self.allow_return.wait(timeout=10) + + db_path = tmp_path / f"advanced_post_commit_{operation}.db" + session = AdvancedSQLiteSession( + session_id=f"advanced_post_commit_{operation}", + db_path=db_path, + create_tables=True, + ) + item: TResponseInputItem = {"role": "user", "content": "once"} + if operation != "add": + await session.add_items([item]) + + conn = sqlite3.connect( + str(db_path), + check_same_thread=False, + factory=PausingCommitConnection, + ) + with session._connections_lock: + session._connections.add(conn) + monkeypatch.setattr(session, "_get_connection", lambda: conn) + conn.pause_commit = True + + if operation == "add": + mutation: asyncio.Task[Any] = asyncio.create_task(session.add_items([item])) + elif operation == "pop": + mutation = asyncio.create_task(session.pop_item()) + else: + mutation = asyncio.create_task(session.clear_session()) + + try: + assert await asyncio.to_thread(conn.commit_finished.wait, 10) + mutation.cancel() + await asyncio.sleep(0) + mutation.cancel() + await asyncio.sleep(0) + conn.allow_return.set() + with pytest.raises(asyncio.CancelledError): + await mutation + finally: + conn.allow_return.set() + if not mutation.done(): + mutation.cancel() + await asyncio.gather(mutation, return_exceptions=True) + + if operation == "add": + assert await session.get_items() == [item] + elif operation == "pop": + assert await session.get_items() == [] + else: + assert await session.get_items() == [] + assert mutation.cancelled() + session.close() + + +@pytest.mark.parametrize("operation", ["create_branch", "delete_branch", "cleanup", "usage"]) +async def test_auxiliary_mutation_cancellation_waits_for_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + usage_data: Usage, + operation: str, +): + """Branch and ancillary mutations must settle before cancellation propagates.""" + + class PausingCommitConnection(sqlite3.Connection): + pause_commit = False + commit_finished = threading.Event() + allow_return = threading.Event() + + def commit(self) -> None: + super().commit() + if self.pause_commit: + self.pause_commit = False + self.commit_finished.set() + assert self.allow_return.wait(timeout=10) + + session = AdvancedSQLiteSession( + session_id=f"advanced_auxiliary_cancel_{operation}", + db_path=tmp_path / f"advanced_auxiliary_cancel_{operation}.db", + create_tables=True, + ) + items: list[TResponseInputItem] = [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + {"role": "assistant", "content": "a2"}, + ] + mutation: asyncio.Task[Any] | None = None + + try: + if operation in {"create_branch", "delete_branch"}: + await session.add_items(items) + if operation == "delete_branch": + await session.create_branch_from_turn(2, "cancelled_branch") + await session.switch_to_branch("main") + elif operation == "cleanup": + with session._write_connection() as setup_connection: + session._insert_items( + setup_connection, + [{"role": "user", "content": "orphan"}], + ) + setup_connection.commit() + elif operation == "usage": + await session.add_items([{"role": "user", "content": "usage turn"}]) + + connection = sqlite3.connect( + str(session.db_path), + check_same_thread=False, + factory=PausingCommitConnection, + ) + with session._connections_lock: + session._connections.add(connection) + monkeypatch.setattr(session, "_get_connection", lambda: connection) + connection.pause_commit = True + + if operation == "create_branch": + mutation = asyncio.create_task(session.create_branch_from_turn(2, "cancelled_branch")) + elif operation == "delete_branch": + mutation = asyncio.create_task(session.delete_branch("cancelled_branch")) + elif operation == "cleanup": + mutation = asyncio.create_task(session._cleanup_orphaned_messages()) + else: + mutation = asyncio.create_task( + session.store_run_usage(create_mock_run_result(usage_data)) + ) + + assert await asyncio.to_thread(connection.commit_finished.wait, 10) + mutation.cancel("first-caller-cancel") + await asyncio.sleep(0) + mutation.cancel("second-caller-cancel") + await asyncio.sleep(0) + connection.allow_return.set() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await mutation + + _assert_cancel_message(exc_info.value, "first-caller-cancel") + assert mutation.cancelled() + + if operation == "create_branch": + branches = await session.list_branches() + assert {branch["branch_id"] for branch in branches} == {"main", "cancelled_branch"} + assert session._current_branch_id == "cancelled_branch" + elif operation == "delete_branch": + branches = await session.list_branches() + assert {branch["branch_id"] for branch in branches} == {"main"} + elif operation == "cleanup": + assert _count_rows(session, session.messages_table) == 0 + else: + turn_usage = await session.get_turn_usage(1) + assert isinstance(turn_usage, dict) + assert turn_usage["total_tokens"] == usage_data.total_tokens + finally: + PausingCommitConnection.allow_return.set() + if mutation is not None and not mutation.done(): + mutation.cancel() + await asyncio.gather(mutation, return_exceptions=True) + session.close() + + async def test_message_structure_tracking(agent: Agent): """Test that message structure is properly tracked.""" session_id = "structure_test" @@ -751,6 +1050,30 @@ def _reserve_branch_id( session.close() +def _pop_item_in_process( + db_path: str, + session_id: str, + ready: Any, + start: Any, + results: Any, +) -> None: + """Pop one AdvancedSQLite item in a separately synchronized process.""" + session = AdvancedSQLiteSession( + session_id=session_id, + db_path=db_path, + create_tables=False, + ) + try: + ready.set() + if not start.wait(timeout=10): + raise TimeoutError("Timed out waiting to start pop") + results.put(("ok", asyncio.run(session.pop_item()))) + except Exception as exc: + results.put(("error", type(exc).__name__, str(exc))) + finally: + session.close() + + @pytest.mark.parametrize("branch_id", ["main", "existing_branch"]) async def test_create_branch_rejects_populated_branch_id(branch_id: str): """Creating a branch must not append history to a populated branch.""" @@ -1675,6 +1998,46 @@ async def test_usage_tracking_storage(agent: Agent, usage_data: Usage): session.close() +async def test_failed_usage_write_rolls_back_cached_connection(usage_data: Usage): + """A swallowed usage-write failure must not strand a transaction or SQLite lock.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "usage_rollback.db" + session = AdvancedSQLiteSession( + session_id="usage_rollback", + db_path=db_path, + create_tables=True, + ) + await session.add_items([{"role": "user", "content": "turn"}]) + + helper = session._get_connection() + helper.execute( + """ + CREATE TRIGGER fail_turn_usage + BEFORE INSERT ON turn_usage + BEGIN + SELECT RAISE(ABORT, 'usage write failed'); + END + """ + ) + helper.commit() + + await session.store_run_usage(create_mock_run_result(usage_data)) + + assert all(not conn.in_transaction for conn in session._connections) + probe = sqlite3.connect(str(db_path), timeout=0) + try: + probe.execute("CREATE TABLE usage_lock_probe (x INTEGER)") + probe.commit() + finally: + probe.close() + + helper.execute("DROP TRIGGER fail_turn_usage") + helper.commit() + await session.store_run_usage(create_mock_run_result(usage_data)) + assert await session.get_turn_usage(1) + session.close() + + async def test_runner_integration_with_usage_tracking(agent: Agent): """Test integration with Runner and automatic usage tracking pattern.""" session_id = "integration_test" @@ -2716,6 +3079,51 @@ async def test_pop_item_uses_branch_snapshot_when_branch_switches_concurrently() session.close() +async def test_pop_item_claim_is_unique_across_processes(tmp_path: Path): + """Two processes must not return the same destructively read item.""" + db_path = tmp_path / "advanced_pop_processes.db" + session_id = "advanced_pop_processes" + item: TResponseInputItem = {"role": "user", "content": "only"} + setup = AdvancedSQLiteSession(session_id=session_id, db_path=db_path, create_tables=True) + await setup.add_items([item]) + setup.close() + + context = multiprocessing.get_context("spawn") + start = context.Event() + results = context.Queue() + ready_events = [context.Event(), context.Event()] + processes = [ + context.Process( + target=_pop_item_in_process, + args=(str(db_path), session_id, ready, start, results), + ) + for ready in ready_events + ] + + try: + for process in processes: + process.start() + for ready in ready_events: + assert ready.wait(timeout=10) + start.set() + for process in processes: + process.join(timeout=10) + assert process.exitcode == 0 + + outcomes = [results.get(timeout=5), results.get(timeout=5)] + assert all(outcome[0] == "ok" for outcome in outcomes) + popped_items = [outcome[1] for outcome in outcomes] + assert popped_items.count(item) == 1 + assert popped_items.count(None) == 1 + finally: + start.set() + for process in processes: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + results.close() + + async def test_stale_switch_after_clear_does_not_repoint_to_deleted_branch(): """A switch_to_branch that commits its pointer after clear_session must not resurrect the deleted branch; the generation guard makes it a no-op. @@ -2957,6 +3365,305 @@ async def test_clear_session_resets_current_branch_to_main(): session.close() +async def test_external_clear_resets_stale_branch_before_next_write(tmp_path: Path): + """A second instance's clear must prevent stale branch resurrection.""" + db_path = tmp_path / "external_clear_generation.db" + stale = AdvancedSQLiteSession( + session_id="external_clear_generation", + db_path=db_path, + create_tables=True, + ) + clearer = AdvancedSQLiteSession( + session_id="external_clear_generation", + db_path=db_path, + ) + + try: + await stale.add_items( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + ] + ) + await stale.create_branch_from_turn(2, "stale") + assert stale._current_branch_id == "stale" + + await clearer.clear_session() + await stale.add_items([{"role": "user", "content": "after clear"}]) + + assert stale._current_branch_id == "main" + assert [item.get("content") for item in await stale.get_items()] == ["after clear"] + assert await stale.get_items(branch_id="stale") == [] + assert {branch["branch_id"] for branch in await stale.list_branches()} == {"main"} + finally: + stale.close() + clearer.close() + + +async def test_external_clear_resets_stale_branch_before_pop(tmp_path: Path): + """A stale instance must pop the current main tail after an external clear.""" + db_path = tmp_path / "external_clear_pop_generation.db" + stale = AdvancedSQLiteSession( + session_id="external_clear_pop_generation", + db_path=db_path, + create_tables=True, + ) + clearer = AdvancedSQLiteSession( + session_id="external_clear_pop_generation", + db_path=db_path, + ) + + try: + await stale.add_items( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + ] + ) + await stale.create_branch_from_turn(2, "stale") + assert stale._current_branch_id == "stale" + + await clearer.clear_session() + item: TResponseInputItem = {"role": "user", "content": "after clear"} + await clearer.add_items([item]) + + assert await stale.pop_item() == item + assert stale._current_branch_id == "main" + assert await clearer.get_items() == [] + finally: + stale.close() + clearer.close() + + +@pytest.mark.parametrize( + "read_path", + ["items", "turns", "search", "conversation", "tools", "usage", "branches"], +) +async def test_external_clear_resets_stale_branch_before_default_reads( + tmp_path: Path, + usage_data: Usage, + read_path: str, +): + """Default reads must recover from a stale branch pointer after an external clear.""" + db_path = tmp_path / f"external_clear_read_generation_{read_path}.db" + session_id = f"external_clear_read_generation_{read_path}" + stale = AdvancedSQLiteSession( + session_id=session_id, + db_path=db_path, + create_tables=True, + ) + clearer = AdvancedSQLiteSession(session_id=session_id, db_path=db_path) + + try: + await stale.add_items( + [ + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "old follow-up"}, + ] + ) + await stale.create_branch_from_turn(2, "stale") + assert stale._current_branch_id == "stale" + + await clearer.clear_session() + new_items: list[TResponseInputItem] = [ + {"role": "user", "content": "new main question"}, + { + "type": "function_call", + "name": "lookup", + "arguments": '{"query": "new"}', + "call_id": "lookup-new-main", + }, + {"role": "assistant", "content": "new main answer"}, + ] + await clearer.add_items(new_items) + await clearer.store_run_usage(create_mock_run_result(usage_data)) + + if read_path == "items": + assert await stale.get_items() == new_items + elif read_path == "turns": + assert [turn["full_content"] for turn in await stale.get_conversation_turns()] == [ + "new main question" + ] + elif read_path == "search": + assert [turn["full_content"] for turn in await stale.find_turns_by_content("new")] == [ + "new main question" + ] + elif read_path == "conversation": + assert set(await stale.get_conversation_by_turns()) == {1} + elif read_path == "tools": + assert await stale.get_tool_usage() == [("lookup", 1, 1)] + elif read_path == "usage": + assert await stale.get_turn_usage(1) == { + "requests": 1, + "input_tokens": 50, + "output_tokens": 30, + "total_tokens": 80, + "input_tokens_details": {"cache_write_tokens": 0, "cached_tokens": 10}, + "output_tokens_details": {"reasoning_tokens": 5}, + } + else: + assert [ + (branch["branch_id"], branch["is_current"]) + for branch in await stale.list_branches() + ] == [("main", True)] + + assert stale._current_branch_id == "main" + finally: + stale.close() + clearer.close() + + +async def test_default_read_does_not_initialize_clear_generation_table(tmp_path: Path): + """Reading a legacy database must not create the clear-generation table.""" + session = AdvancedSQLiteSession( + session_id="legacy_generation_read", + db_path=tmp_path / "legacy_generation_read.db", + create_tables=True, + ) + + try: + await session.add_items([{"role": "user", "content": "legacy history"}]) + with session._locked_connection() as conn: + conn.execute("DROP TABLE session_clear_generations") + conn.commit() + + assert await session.get_items() == [{"role": "user", "content": "legacy history"}] + + with session._locked_connection() as conn: + table_exists = conn.execute( + """ + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'session_clear_generations' + """ + ).fetchone() + assert table_exists is None + finally: + session.close() + + +async def test_switch_validation_cancellation_waits_for_generation_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """Legacy generation initialization must settle before cancellation propagates.""" + + class PausingCommitConnection(sqlite3.Connection): + pause_commit = False + commit_finished = threading.Event() + allow_return = threading.Event() + + def commit(self) -> None: + super().commit() + if self.pause_commit: + self.pause_commit = False + self.commit_finished.set() + assert self.allow_return.wait(timeout=10) + + db_path = tmp_path / "switch_validation_cancellation.db" + session = AdvancedSQLiteSession( + session_id="switch_validation_cancellation", + db_path=db_path, + create_tables=True, + ) + mutation: asyncio.Task[Any] | None = None + + try: + await session.add_items( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + ] + ) + await session.create_branch_from_turn(2, "target") + await session.switch_to_branch("main") + with session._write_connection() as setup_connection: + setup_connection.execute("DROP TABLE session_clear_generations") + setup_connection.commit() + + connection = sqlite3.connect( + str(db_path), + check_same_thread=False, + factory=PausingCommitConnection, + ) + with session._connections_lock: + session._connections.add(connection) + monkeypatch.setattr(session, "_get_connection", lambda: connection) + connection.pause_commit = True + + mutation = asyncio.create_task(session.switch_to_branch("target")) + assert await asyncio.to_thread(connection.commit_finished.wait, 10) + mutation.cancel("first-caller-cancel") + await asyncio.sleep(0) + mutation.cancel("second-caller-cancel") + await asyncio.sleep(0) + assert mutation.done() is False + connection.allow_return.set() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await mutation + + _assert_cancel_message(exc_info.value, "first-caller-cancel") + assert session._current_branch_id == "main" + row = connection.execute( + "SELECT generation FROM session_clear_generations WHERE session_id = ?", + (session.session_id,), + ).fetchone() + assert row == (0,) + finally: + PausingCommitConnection.allow_return.set() + if mutation is not None and not mutation.done(): + mutation.cancel() + await asyncio.gather(mutation, return_exceptions=True) + session.close() + + +async def test_post_clear_switch_synchronizes_generation_before_next_write(tmp_path: Path): + """A new instance may select and write to a branch created after an earlier clear.""" + db_path = tmp_path / "post_clear_branch_switch.db" + owner = AdvancedSQLiteSession( + session_id="post_clear_branch_switch", + db_path=db_path, + create_tables=True, + ) + other = AdvancedSQLiteSession( + session_id="post_clear_branch_switch", + db_path=db_path, + ) + + try: + await owner.clear_session() + await owner.add_items( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + ] + ) + await owner.create_branch_from_turn(2, "fresh") + + await other.switch_to_branch("fresh") + await other.add_items([{"role": "assistant", "content": "on fresh"}]) + + assert other._current_branch_id == "fresh" + assert [item.get("content") for item in await other.get_items()] == [ + "u1", + "a1", + "on fresh", + ] + assert [item.get("content") for item in await other.get_items(branch_id="main")] == [ + "u1", + "a1", + "u2", + ] + finally: + owner.close() + other.close() + + async def test_pop_item_rolls_back_on_failure_after_earlier_delete(): """Regression: a failure partway through pop_item's delete sequence must roll back so no partial mutation or open transaction survives. diff --git a/tests/extensions/memory/test_async_sqlite_session.py b/tests/extensions/memory/test_async_sqlite_session.py index b45cbdf4e7..5ade0e2cc5 100644 --- a/tests/extensions/memory/test_async_sqlite_session.py +++ b/tests/extensions/memory/test_async_sqlite_session.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio import json +import sqlite3 +import sys import tempfile from collections.abc import Sequence from datetime import datetime @@ -22,6 +25,12 @@ pytestmark = pytest.mark.asyncio +def _assert_cancel_message(exc: asyncio.CancelledError, expected: str) -> None: + """Account for Python 3.10 dropping Task cancellation messages when re-awaited.""" + expected_args = (expected,) if sys.version_info >= (3, 11) else () + assert exc.args == expected_args + + @pytest.fixture def agent() -> Agent: """Fixture for a basic agent with a fake model.""" @@ -495,3 +504,622 @@ async def test_async_sqlite_session_close_is_idempotent(): with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): await session.get_items() + + +async def test_cancelled_close_finishes_cleanup_and_propagates_cancellation( + monkeypatch: pytest.MonkeyPatch, +): + """Repeated cancellation must propagate after the owned connection closes.""" + close_started = asyncio.Event() + allow_close = asyncio.Event() + session = AsyncSQLiteSession("cancelled_close") + conn: Any = None + real_close: Any = None + close_task: asyncio.Task[None] | None = None + try: + conn = await session._get_connection() + real_close = conn.close + + async def controlled_close() -> None: + close_started.set() + await allow_close.wait() + await real_close() + + monkeypatch.setattr(conn, "close", controlled_close) + close_task = asyncio.create_task(session.close()) + try: + await close_started.wait() + close_task.cancel() + await asyncio.sleep(0) + close_task.cancel() + await asyncio.sleep(0) + allow_close.set() + with pytest.raises(asyncio.CancelledError): + await close_task + finally: + allow_close.set() + if not close_task.done(): + close_task.cancel() + await asyncio.gather(close_task, return_exceptions=True) + + assert session._closed is True + assert session._connection is None + assert session._quarantined_connections == set() + assert conn._running is False + finally: + allow_close.set() + if close_task is not None and not close_task.done(): + close_task.cancel() + await asyncio.gather(close_task, return_exceptions=True) + if conn is not None and real_close is not None: + monkeypatch.setattr(conn, "close", real_close) + try: + await session.close() + finally: + if conn is not None and real_close is not None and conn._running: + await real_close() + + +@pytest.mark.parametrize("operation", ["add", "pop", "clear"]) +async def test_post_commit_cancellation_propagates_after_known_mutation_outcome( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + operation: str, +): + """Cancellation after async commit must propagate without inviting a retry.""" + db_path = tmp_path / f"async_post_commit_{operation}.db" + session = AsyncSQLiteSession(f"async_post_commit_{operation}", db_path) + item: TResponseInputItem = {"role": "user", "content": "once"} + try: + if operation != "add": + await session.add_items([item]) + + conn = await session._get_connection() + real_commit = conn.commit + commit_finished = asyncio.Event() + allow_return = asyncio.Event() + pause_commit = True + + async def controlled_commit() -> None: + nonlocal pause_commit + await real_commit() + if pause_commit: + pause_commit = False + commit_finished.set() + await allow_return.wait() + + monkeypatch.setattr(conn, "commit", controlled_commit) + if operation == "add": + mutation: asyncio.Task[Any] = asyncio.create_task(session.add_items([item])) + elif operation == "pop": + mutation = asyncio.create_task(session.pop_item()) + else: + mutation = asyncio.create_task(session.clear_session()) + + try: + await commit_finished.wait() + mutation.cancel() + await asyncio.sleep(0) + mutation.cancel() + await asyncio.sleep(0) + allow_return.set() + with pytest.raises(asyncio.CancelledError): + await mutation + finally: + allow_return.set() + if not mutation.done(): + mutation.cancel() + await asyncio.gather(mutation, return_exceptions=True) + + if operation == "add": + assert await session.get_items() == [item] + elif operation == "pop": + assert await session.get_items() == [] + else: + assert await session.get_items() == [] + assert mutation.cancelled() + finally: + await session.close() + + +def _drop_sqlite_table(db_path: Path, table: str) -> None: + """Drop a table from an independent connection to make a later statement fail.""" + helper = sqlite3.connect(str(db_path)) + try: + helper.execute(f"DROP TABLE {table}") + helper.commit() + finally: + helper.close() + + +def _sqlite_write_lock_is_free(db_path: Path) -> bool: + """Return whether an independent writer can take the SQLite write lock.""" + probe = sqlite3.connect(str(db_path), timeout=0) + try: + probe.execute("CREATE TABLE IF NOT EXISTS probe_lock (x INTEGER)") + probe.commit() + return True + except sqlite3.OperationalError: + return False + finally: + probe.close() + + +async def test_failed_add_items_rolls_back_and_reuses_connection(): + """A failed add must roll back its partial write and leave the session reusable.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "add_rollback.db" + session = AsyncSQLiteSession("add_rollback", db_path) + unserializable = cast(TResponseInputItem, {"role": "user", "content": object()}) + try: + with pytest.raises(TypeError): + await session.add_items([unserializable]) + + conn = await session._get_connection() + assert conn.in_transaction is False + assert _sqlite_write_lock_is_free(db_path) + + await session.add_items([{"role": "user", "content": "after failure"}]) + assert [item.get("content") for item in await session.get_items()] == ["after failure"] + finally: + await session.close() + + +async def test_failed_clear_session_rolls_back(): + """A failed clear must restore earlier statements and release the shared write lock.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "clear_rollback.db" + session = AsyncSQLiteSession("clear_rollback", db_path) + try: + await session.add_items([{"role": "user", "content": "kept"}]) + + _drop_sqlite_table(db_path, "agent_sessions") + + with pytest.raises(sqlite3.OperationalError): + await session.clear_session() + + conn = await session._get_connection() + assert conn.in_transaction is False + assert _sqlite_write_lock_is_free(db_path) + finally: + await session.close() + + +async def test_failed_pop_item_releases_write_lock(): + """A failed pop must not leave a write transaction on the shared connection.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "pop_rollback.db" + session = AsyncSQLiteSession("pop_rollback", db_path) + try: + await session.add_items([{"role": "user", "content": "kept"}]) + + _drop_sqlite_table(db_path, "agent_messages") + + with pytest.raises(sqlite3.OperationalError): + await session.pop_item() + + conn = await session._get_connection() + assert conn.in_transaction is False + assert _sqlite_write_lock_is_free(db_path) + finally: + await session.close() + + +async def test_failed_initialization_closes_candidate_connection(): + """A failed initialization must not retain a half-initialized connection.""" + + class FailingInitSession(AsyncSQLiteSession): + captured_connection: Any = None + + async def _init_db_for_connection(self, conn: Any) -> None: + self.captured_connection = conn + raise RuntimeError("initialization failed") + + session = FailingInitSession("failed_init") + try: + with pytest.raises(RuntimeError, match="initialization failed"): + await session.get_items() + + assert session._connection is None + assert session.captured_connection._running is False + finally: + try: + await session.close() + finally: + if session.captured_connection is not None and session.captured_connection._running: + await session.captured_connection.close() + + +async def test_cancelled_add_items_rolls_back_write_transaction( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """Cancellation after the first write must release the transaction and database lock.""" + db_path = tmp_path / "cancelled_add.db" + session = AsyncSQLiteSession("cancelled_add", db_path) + try: + await session.get_items() + conn = await session._get_connection() + real_execute = conn.execute + real_rollback = conn.rollback + first_write_started = asyncio.Event() + rollback_started = asyncio.Event() + allow_rollback = asyncio.Event() + + async def pause_after_first_write(*args: Any, **kwargs: Any) -> Any: + cursor = await real_execute(*args, **kwargs) + first_write_started.set() + await asyncio.Event().wait() + return cursor + + async def controlled_rollback() -> None: + rollback_started.set() + await allow_rollback.wait() + await real_rollback() + + monkeypatch.setattr(conn, "execute", pause_after_first_write) + monkeypatch.setattr(conn, "rollback", controlled_rollback) + task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}])) + try: + await first_write_started.wait() + task.cancel() + await rollback_started.wait() + task.cancel() + allow_rollback.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + allow_rollback.set() + monkeypatch.setattr(conn, "execute", real_execute) + monkeypatch.setattr(conn, "rollback", real_rollback) + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + assert conn.in_transaction is False + assert _sqlite_write_lock_is_free(db_path) + assert await session.get_items() == [] + finally: + await session.close() + + +async def test_operation_failure_then_cancellation_during_rollback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """Cancellation during rollback must supersede an earlier operation failure.""" + db_path = tmp_path / "failure_then_cancelled_rollback.db" + session = AsyncSQLiteSession("failure_then_cancelled_rollback", db_path) + task: asyncio.Task[None] | None = None + try: + await session.get_items() + conn = await session._get_connection() + real_execute = conn.execute + real_rollback = conn.rollback + rollback_started = asyncio.Event() + allow_rollback = asyncio.Event() + + async def fail_after_write(*args: Any, **kwargs: Any) -> Any: + await real_execute(*args, **kwargs) + raise RuntimeError("operation failed") + + async def controlled_rollback() -> None: + rollback_started.set() + await allow_rollback.wait() + await real_rollback() + + monkeypatch.setattr(conn, "execute", fail_after_write) + monkeypatch.setattr(conn, "rollback", controlled_rollback) + task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}])) + try: + await rollback_started.wait() + task.cancel("first-caller-cancel") + await asyncio.sleep(0) + task.cancel("second-caller-cancel") + allow_rollback.set() + with pytest.raises(asyncio.CancelledError) as exc_info: + await task + finally: + allow_rollback.set() + monkeypatch.setattr(conn, "execute", real_execute) + monkeypatch.setattr(conn, "rollback", real_rollback) + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + _assert_cancel_message(exc_info.value, "first-caller-cancel") + assert conn.in_transaction is False + assert _sqlite_write_lock_is_free(db_path) + assert await session.get_items() == [] + finally: + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await session.close() + + +async def test_rollback_failure_closes_and_evicts_connection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """A connection that cannot roll back must not remain cached or retain its write lock.""" + db_path = tmp_path / "rollback_failure.db" + session = AsyncSQLiteSession("rollback_failure", db_path) + try: + await session.get_items() + conn = await session._get_connection() + + async def fail_rollback() -> None: + raise RuntimeError("rollback failed") + + monkeypatch.setattr(conn, "rollback", fail_rollback) + unserializable = cast(TResponseInputItem, {"role": "user", "content": object()}) + + with pytest.raises(TypeError): + await session.add_items([unserializable]) + + assert session._connection is None + assert session._closed is False + assert _sqlite_write_lock_is_free(db_path) + + await session.add_items([{"role": "user", "content": "after failure"}]) + assert [item.get("content") for item in await session.get_items()] == ["after failure"] + finally: + await session.close() + + +async def test_close_retries_connection_quarantined_after_rollback_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """A failed invalidation close must remain owned until a later close succeeds.""" + db_path = tmp_path / "close_retry.db" + session = AsyncSQLiteSession("close_retry", db_path) + conn: Any = None + real_close: Any = None + try: + await session.get_items() + conn = await session._get_connection() + real_close = conn.close + + async def fail_rollback() -> None: + raise RuntimeError("rollback failed") + + async def fail_close() -> None: + raise RuntimeError("close failed") + + monkeypatch.setattr(conn, "rollback", fail_rollback) + monkeypatch.setattr(conn, "close", fail_close) + unserializable = cast(TResponseInputItem, {"role": "user", "content": object()}) + + with pytest.raises(TypeError): + await session.add_items([unserializable]) + + assert session._closed is True + assert session._connection is None + assert conn in session._quarantined_connections + assert conn._running is True + assert _sqlite_write_lock_is_free(db_path) is False + + monkeypatch.setattr(conn, "close", real_close) + await session.close() + + assert session._quarantined_connections == set() + assert conn._running is False + assert _sqlite_write_lock_is_free(db_path) + finally: + if conn is not None and real_close is not None: + monkeypatch.setattr(conn, "close", real_close) + try: + await session.close() + finally: + if conn is not None and real_close is not None and conn._running: + await real_close() + + +async def test_close_retries_quarantined_failed_initialization_candidate( + monkeypatch: pytest.MonkeyPatch, +): + """A failed initialization candidate close must remain owned for close retry.""" + + class FailingInitSession(AsyncSQLiteSession): + captured_connection: Any = None + real_close: Any = None + + async def _init_db_for_connection(self, conn: Any) -> None: + self.captured_connection = conn + self.real_close = conn.close + + async def fail_close() -> None: + raise RuntimeError("close failed") + + monkeypatch.setattr(conn, "close", fail_close) + raise RuntimeError("initialization failed") + + session = FailingInitSession("failed_init_close_retry") + try: + with pytest.raises(RuntimeError, match="initialization failed"): + await session.get_items() + + conn = session.captured_connection + assert session._closed is True + assert conn in session._quarantined_connections + assert conn._running is True + + monkeypatch.setattr(conn, "close", session.real_close) + await session.close() + + assert session._quarantined_connections == set() + assert conn._running is False + finally: + if session.captured_connection is not None and session.real_close is not None: + monkeypatch.setattr(session.captured_connection, "close", session.real_close) + try: + await session.close() + finally: + if ( + session.captured_connection is not None + and session.real_close is not None + and session.captured_connection._running + ): + await session.real_close() + + +async def test_cancelled_initialization_finishes_candidate_close( + monkeypatch: pytest.MonkeyPatch, +): + """Repeated cancellation must not release initialization ownership before close finishes.""" + init_started = asyncio.Event() + close_started = asyncio.Event() + allow_close = asyncio.Event() + + class CancelledInitSession(AsyncSQLiteSession): + captured_connection: Any = None + real_close: Any = None + + async def _init_db_for_connection(self, conn: Any) -> None: + self.captured_connection = conn + self.real_close = conn.close + + async def controlled_close() -> None: + close_started.set() + await allow_close.wait() + await self.real_close() + + monkeypatch.setattr(conn, "close", controlled_close) + init_started.set() + await asyncio.Event().wait() + + session = CancelledInitSession("cancelled_init") + task = asyncio.create_task(session.get_items()) + try: + try: + await init_started.wait() + task.cancel() + await close_started.wait() + task.cancel() + allow_close.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + allow_close.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + assert session._connection is None + assert session.captured_connection._running is False + finally: + if session.captured_connection is not None and session.real_close is not None: + monkeypatch.setattr(session.captured_connection, "close", session.real_close) + try: + await session.close() + finally: + if ( + session.captured_connection is not None + and session.real_close is not None + and session.captured_connection._running + ): + await session.real_close() + + +async def test_initialization_failure_then_cancellation_during_candidate_close( + monkeypatch: pytest.MonkeyPatch, +): + """Cancellation during candidate close must supersede an initialization failure.""" + close_started = asyncio.Event() + allow_close = asyncio.Event() + + class FailingInitSession(AsyncSQLiteSession): + captured_connection: Any = None + real_close: Any = None + + async def _init_db_for_connection(self, conn: Any) -> None: + self.captured_connection = conn + self.real_close = conn.close + + async def controlled_close() -> None: + close_started.set() + await allow_close.wait() + await self.real_close() + + monkeypatch.setattr(conn, "close", controlled_close) + raise RuntimeError("initialization failed") + + session = FailingInitSession("failed_init_then_cancelled_close") + task = asyncio.create_task(session.get_items()) + try: + try: + await close_started.wait() + task.cancel("first-caller-cancel") + await asyncio.sleep(0) + task.cancel("second-caller-cancel") + allow_close.set() + with pytest.raises(asyncio.CancelledError) as exc_info: + await task + finally: + allow_close.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + _assert_cancel_message(exc_info.value, "first-caller-cancel") + assert session._connection is None + assert session.captured_connection._running is False + finally: + allow_close.set() + if session.captured_connection is not None and session.real_close is not None: + monkeypatch.setattr(session.captured_connection, "close", session.real_close) + try: + await session.close() + finally: + if ( + session.captured_connection is not None + and session.real_close is not None + and session.captured_connection._running + ): + await session.real_close() + + +async def test_cancelled_connect_closes_eventually_acquired_connection( + monkeypatch: pytest.MonkeyPatch, +): + """Cancellation during connect must wait for and close the eventual connection.""" + import aiosqlite + + real_connect = aiosqlite.connect + connect_started = asyncio.Event() + allow_connect = asyncio.Event() + created_connections: list[Any] = [] + + async def controlled_connect(database: str) -> Any: + connect_started.set() + await allow_connect.wait() + conn = await real_connect(database) + created_connections.append(conn) + return conn + + monkeypatch.setattr(aiosqlite, "connect", controlled_connect) + session = AsyncSQLiteSession("cancelled_connect") + task = asyncio.create_task(session.get_items()) + try: + try: + await connect_started.wait() + task.cancel() + allow_connect.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + allow_connect.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + assert len(created_connections) == 1 + assert created_connections[0]._running is False + assert session._connection is None + finally: + await session.close() + for conn in created_connections: + if conn._running: + await conn.close() diff --git a/tests/extensions/memory/test_mongodb_session.py b/tests/extensions/memory/test_mongodb_session.py index 3bd8f7c034..da8b6214f4 100644 --- a/tests/extensions/memory/test_mongodb_session.py +++ b/tests/extensions/memory/test_mongodb_session.py @@ -9,11 +9,13 @@ from __future__ import annotations import asyncio +import copy +import json import sys import types from collections import defaultdict from datetime import datetime, timezone -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, patch import pytest @@ -43,6 +45,12 @@ def __init__(self) -> None: def __lt__(self, other: FakeObjectId) -> bool: return self._value < other._value + def __eq__(self, other: object) -> bool: + return isinstance(other, FakeObjectId) and self._value == other._value + + def __hash__(self) -> int: + return hash(self._value) + def __repr__(self) -> str: return f"FakeObjectId({self._value})" @@ -87,6 +95,9 @@ def __init__(self) -> None: async def create_index(self, keys: Any, **kwargs: Any) -> str: return "fake_index" + def with_options(self, **kwargs: Any) -> FakeAsyncCollection: + return self + def find(self, query: dict[str, Any] | None = None) -> FakeCursor: query = query or {} results = [doc for doc in self._docs.values() if self._matches(doc, query)] @@ -117,22 +128,44 @@ async def insert_many( doc["_id"] = FakeObjectId() self._docs[id(doc["_id"])] = dict(doc) + async def insert_one(self, document: dict[str, Any]) -> Any: + if "_id" not in document: + document["_id"] = FakeObjectId() + stored = dict(document) + if isinstance(stored.get("message_data"), list): + stored["message_data"] = list(stored["message_data"]) + self._docs[id(document["_id"])] = stored + async def find_one_and_update( self, query: dict[str, Any], - update: dict[str, Any], + update: dict[str, Any] | list[dict[str, Any]], upsert: bool = False, return_document: bool = False, + sort: list[tuple[str, int]] | None = None, ) -> dict[str, Any] | None: - for doc in self._docs.values(): - if self._matches(doc, query): - # Apply $inc fields. - for field, delta in update.get("$inc", {}).items(): - doc[field] = doc.get(field, 0) + delta - for field, value in update.get("$set", {}).items(): - doc[field] = value - return dict(doc) if return_document else None + matches = [doc for doc in self._docs.values() if self._matches(doc, query)] + if sort: + for field, direction in reversed(sort): + matches.sort(key=lambda doc: doc.get(field, 0), reverse=(direction == -1)) + if matches: + doc = matches[0] + before = copy.deepcopy(doc) + if isinstance(update, list): + raw = doc.get("message_data") + doc["message_data"] = raw[:-1] if isinstance(raw, list) else [] + return copy.deepcopy(doc) if return_document else before + for field, delta in update.get("$inc", {}).items(): + doc[field] = doc.get(field, 0) + delta + for field, value in update.get("$set", {}).items(): + doc[field] = value + for field, direction in update.get("$pop", {}).items(): + values = doc.get(field) + if isinstance(values, list) and values: + values.pop(-1 if direction == 1 else 0) + return copy.deepcopy(doc) if return_document else before if upsert: + assert isinstance(update, dict) new_doc: dict[str, Any] = {"_id": FakeObjectId()} new_doc.update(update.get("$setOnInsert", {})) new_doc.update(update.get("$set", {})) @@ -169,7 +202,25 @@ async def delete_one(self, query: dict[str, Any]) -> None: @staticmethod def _matches(doc: dict[str, Any], query: dict[str, Any]) -> bool: - return all(doc.get(k) == v for k, v in query.items()) + for key, expected in query.items(): + if key == "$or": + if not any(FakeAsyncCollection._matches(doc, branch) for branch in expected): + return False + continue + actual = doc.get(key) + if isinstance(expected, dict): + if "$ne" in expected and actual == expected["$ne"]: + return False + if "$in" in expected and actual not in expected["$in"]: + return False + if "$lt" in expected and (actual is None or actual >= expected["$lt"]): + return False + if "$exists" in expected and (key in doc) != expected["$exists"]: + return False + continue + if actual != expected: + return False + return True class FakeAsyncDatabase: @@ -202,6 +253,12 @@ def __init__(self, name: str, version: str | None = None) -> None: self.version = version +class FakeReadPreference: + """Minimal stand-in for pymongo read preference constants.""" + + PRIMARY = object() + + class FakeAsyncMongoClient: """In-memory substitute for pymongo AsyncMongoClient.""" @@ -237,16 +294,19 @@ def _make_fake_pymongo_modules() -> None: collection_mod = types.ModuleType("pymongo.asynchronous.collection") client_mod = types.ModuleType("pymongo.asynchronous.mongo_client") driver_info_mod = types.ModuleType("pymongo.driver_info") + read_preferences_mod = types.ModuleType("pymongo.read_preferences") collection_mod.AsyncCollection = FakeAsyncCollection # type: ignore[attr-defined] client_mod.AsyncMongoClient = FakeAsyncMongoClient # type: ignore[attr-defined] driver_info_mod.DriverInfo = FakeDriverInfo # type: ignore[attr-defined] + read_preferences_mod.ReadPreference = FakeReadPreference # type: ignore[attr-defined] sys.modules["pymongo"] = pymongo_mod sys.modules["pymongo.asynchronous"] = async_pkg sys.modules["pymongo.asynchronous.collection"] = collection_mod sys.modules["pymongo.asynchronous.mongo_client"] = client_mod sys.modules["pymongo.driver_info"] = driver_info_mod + sys.modules["pymongo.read_preferences"] = read_preferences_mod _make_fake_pymongo_modules() @@ -334,9 +394,149 @@ async def test_pop_item_empty_session(session: MongoDBSession) -> None: async def test_clear_session(session: MongoDBSession) -> None: - """clear_session must remove all items and session metadata.""" + """clear_session removes history while preserving monotonic ordering metadata.""" await session.add_items([{"role": "user", "content": "x"}]) + metadata: dict[str, Any] = next(iter(session._sessions._docs.values())) + sequence_before_clear = metadata["_seq"] + await session.clear_session() + + assert await session.get_items() == [] + metadata = next(iter(session._sessions._docs.values())) + assert metadata["_seq"] == sequence_before_clear + assert metadata["_generation"] == 1 + + await session.add_items([{"role": "user", "content": "after clear"}]) + batch = next(iter(session._messages._docs.values())) + assert batch["seq"] == sequence_before_clear + assert batch["generation"] == 1 + + +async def test_clear_session_partial_cleanup_failure_is_logically_atomic( + session: MongoDBSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed physical sweep must not expose a partially cleared generation.""" + await session.add_items([{"role": "user", "content": "first"}]) + await session.add_items([{"role": "assistant", "content": "second"}]) + messages = cast(FakeAsyncCollection, session._messages) + + async def delete_one_then_fail(query: dict[str, Any]) -> None: + matching = [ + key for key, doc in messages._docs.items() if FakeAsyncCollection._matches(doc, query) + ] + assert len(matching) == 2 + del messages._docs[matching[0]] + raise RuntimeError("partial cleanup failed") + + monkeypatch.setattr(session._messages, "delete_many", delete_one_then_fail) + + await session.clear_session() + + assert len(messages._docs) == 1 + assert await session.get_items() == [] + + +async def test_generation_reads_use_primary_after_failed_clear_cleanup( + session: MongoDBSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Lagging secondary metadata must not expose cleared history to reads or pops.""" + await session.add_items([{"role": "user", "content": "old"}]) + sessions = cast(FakeAsyncCollection, session._sessions) + messages = cast(FakeAsyncCollection, session._messages) + primary_find = sessions.find + + async def fail_cleanup(query: dict[str, Any]) -> None: + raise RuntimeError("cleanup failed") + + monkeypatch.setattr(messages, "delete_many", fail_cleanup) + await session.clear_session() + assert len(messages._docs) == 1 + + metadata = copy.deepcopy(next(iter(sessions._docs.values()))) + stale_metadata = {**metadata, "_generation": 0} + + def stale_secondary_find(query: dict[str, Any] | None = None) -> FakeCursor: + query = query or {} + docs = [stale_metadata] if FakeAsyncCollection._matches(stale_metadata, query) else [] + return FakeCursor(docs) + + class PrimaryCollectionView: + def find(self, query: dict[str, Any] | None = None) -> FakeCursor: + return primary_find(query) + + def with_options(**kwargs: Any) -> PrimaryCollectionView: + assert kwargs == {"read_preference": FakeReadPreference.PRIMARY} + return PrimaryCollectionView() + + monkeypatch.setattr(sessions, "find", stale_secondary_find) + monkeypatch.setattr(sessions, "with_options", with_options) + + assert await session.get_items() == [] + assert await session.pop_item() is None + assert len(messages._docs) == 1 + + +async def test_clear_session_hides_add_reserved_before_generation_advance( + session: MongoDBSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A batch reserved before clear must remain in the cleared generation.""" + insert_started = asyncio.Event() + allow_insert = asyncio.Event() + original_insert = session._messages.insert_one + + async def controlled_insert(document: dict[str, Any]) -> Any: + insert_started.set() + await allow_insert.wait() + return await original_insert(document) + + monkeypatch.setattr(session._messages, "insert_one", controlled_insert) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "old"}])) + try: + await insert_started.wait() + await session.clear_session() + allow_insert.set() + await add_task + finally: + allow_insert.set() + if not add_task.done(): + add_task.cancel() + await asyncio.gather(add_task, return_exceptions=True) + + assert await session.get_items() == [] + + +async def test_pop_rechecks_generation_after_concurrent_clear( + session: MongoDBSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A claim from a cleared generation must not be returned to the caller.""" + await session.add_items([{"role": "user", "content": "old"}]) + claim_finished = asyncio.Event() + allow_claim_return = asyncio.Event() + original_claim = session._messages.find_one_and_update + + async def controlled_claim(*args: Any, **kwargs: Any) -> Any: + result = await original_claim(*args, **kwargs) + claim_finished.set() + await allow_claim_return.wait() + return result + + monkeypatch.setattr(session._messages, "find_one_and_update", controlled_claim) + pop_task = asyncio.create_task(session.pop_item()) + try: + await claim_finished.wait() + await session.clear_session() + allow_claim_return.set() + assert await pop_task is None + finally: + allow_claim_return.set() + if not pop_task.done(): + pop_task.cancel() + await asyncio.gather(pop_task, return_exceptions=True) + assert await session.get_items() == [] @@ -974,3 +1174,311 @@ def _fake_client(uri: str, **kwargs: Any) -> FakeAsyncMongoClient: # The caller's value must be preserved — setdefault must not overwrite it. assert captured_kwargs["driver"] is custom_info + + +async def test_add_items_serializes_before_reserving_sequence_numbers() -> None: + """Serialization failure must not advance the durable sequence counter.""" + + class FailingSerializationSession(MongoDBSession): + async def _serialize_item(self, item: TResponseInputItem) -> str: + if item.get("content") == "fail": + raise TypeError("serialization failed") + return await super()._serialize_item(item) + + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + session = FailingSerializationSession( + "serialize-first", + client=client, # type: ignore[arg-type] + database="agents_test", + ) + + with pytest.raises(TypeError, match="serialization failed"): + await session.add_items( + [ + {"role": "user", "content": "valid"}, + {"role": "assistant", "content": "fail"}, + ] + ) + + assert not session._sessions._docs + assert await session.get_items() == [] + + +async def test_add_items_is_invisible_until_single_document_commit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed batch must remain invisible while its single-document insert is pending.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + session = MongoDBSession( + "partial-batch", + client=client, # type: ignore[arg-type] + database="agents_test", + ) + seed: TResponseInputItem = {"role": "user", "content": "seed"} + await session.add_items([seed]) + + real_insert_one = session._messages.insert_one + insert_started = asyncio.Event() + allow_failure = asyncio.Event() + + async def pause_then_fail(document: dict[str, Any]) -> None: + insert_started.set() + await allow_failure.wait() + raise RuntimeError("insert failed") + + monkeypatch.setattr(session._messages, "insert_one", pause_then_fail) + batch: list[TResponseInputItem] = [ + {"role": "assistant", "content": "first"}, + {"role": "user", "content": "second"}, + ] + add_task = asyncio.create_task(session.add_items(batch)) + + try: + await asyncio.wait_for(insert_started.wait(), timeout=1) + assert await session.get_items() == [seed] + allow_failure.set() + with pytest.raises(RuntimeError, match="insert failed"): + await asyncio.wait_for(add_task, timeout=1) + finally: + allow_failure.set() + if not add_task.done(): + add_task.cancel() + await asyncio.gather(add_task, return_exceptions=True) + + assert await session.get_items() == [seed] + + monkeypatch.setattr(session._messages, "insert_one", real_insert_one) + await session.add_items(batch) + assert await session.get_items() == [seed, *batch] + + +async def test_concurrent_pop_item_claims_distinct_items_from_batch() -> None: + """Atomic array pops must return each item in a logical batch at most once.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + session = MongoDBSession( + "concurrent-pop", + client=client, # type: ignore[arg-type] + database="agents_test", + ) + items: list[TResponseInputItem] = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second"}, + {"role": "user", "content": "third"}, + ] + await session.add_items(items) + + tasks = [asyncio.create_task(session.pop_item()) for _ in items] + try: + popped = await asyncio.wait_for(asyncio.gather(*tasks), timeout=1) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + contents = {cast(dict[str, Any], item).get("content") for item in popped if item is not None} + assert contents == { + "first", + "second", + "third", + } + assert await session.get_items() == [] + assert session._messages._docs == {} + + +async def test_pop_item_atomically_selects_newest_concurrent_batch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The claim operation must select a batch appended before its linearization point.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + session = MongoDBSession( + "concurrent-add-pop", + client=client, # type: ignore[arg-type] + database="agents_test", + ) + await session.add_items([{"role": "user", "content": "older"}]) + + real_find_one_and_update = session._messages.find_one_and_update + appended = False + + async def append_before_claim(*args: Any, **kwargs: Any) -> Any: + nonlocal appended + if not appended: + appended = True + await session.add_items([{"role": "assistant", "content": "newer"}]) + return await real_find_one_and_update(*args, **kwargs) + + monkeypatch.setattr(session._messages, "find_one_and_update", append_before_claim) + + popped = await session.pop_item() + + assert popped is not None + assert popped.get("content") == "newer" + assert [item.get("content") for item in await session.get_items()] == ["older"] + + +async def test_pop_item_deletes_exhausted_batch_document(session: MongoDBSession) -> None: + """Popping a one-item batch must not leave an empty batch document behind.""" + await session.add_items([{"role": "user", "content": "only"}]) + + popped = await session.pop_item() + assert popped is not None + assert popped.get("content") == "only" + assert session._messages._docs == {} + + +async def test_pop_item_cancellation_waits_for_exhausted_batch_cleanup( + session: MongoDBSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cancellation after a claim must wait until its empty marker is deleted.""" + await session.add_items([{"role": "user", "content": "claimed"}]) + real_delete_one = session._messages.delete_one + delete_started = asyncio.Event() + allow_delete = asyncio.Event() + + async def controlled_delete(query: dict[str, Any]) -> None: + delete_started.set() + await allow_delete.wait() + await real_delete_one(query) + + monkeypatch.setattr(session._messages, "delete_one", controlled_delete) + task = asyncio.create_task(session.pop_item()) + try: + await delete_started.wait() + task.cancel("first-caller-cancel") + await asyncio.sleep(0) + task.cancel("second-caller-cancel") + await asyncio.sleep(0) + assert task.done() is False + allow_delete.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + allow_delete.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + assert session._messages._docs == {} + + +@pytest.mark.parametrize("operation", ["add", "pop", "clear"]) +async def test_mutation_cancellation_waits_for_authoritative_outcome( + session: MongoDBSession, + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + """Cancellation must wait after the server has applied a history mutation.""" + item: TResponseInputItem = {"role": "user", "content": "once"} + if operation != "add": + await session.add_items([item]) + + mutation_applied = asyncio.Event() + allow_return = asyncio.Event() + task: asyncio.Task[Any] + + if operation == "add": + original_insert = session._messages.insert_one + + async def controlled_insert(document: dict[str, Any]) -> Any: + result = await original_insert(document) + mutation_applied.set() + await allow_return.wait() + return result + + monkeypatch.setattr(session._messages, "insert_one", controlled_insert) + task = asyncio.create_task(session.add_items([item])) + elif operation == "pop": + original_claim = session._messages.find_one_and_update + + async def controlled_claim(*args: Any, **kwargs: Any) -> Any: + result = await original_claim(*args, **kwargs) + mutation_applied.set() + await allow_return.wait() + return result + + monkeypatch.setattr(session._messages, "find_one_and_update", controlled_claim) + task = asyncio.create_task(session.pop_item()) + else: + original_clear = session._messages.delete_many + + async def controlled_clear(*args: Any, **kwargs: Any) -> Any: + result = await original_clear(*args, **kwargs) + mutation_applied.set() + await allow_return.wait() + return result + + monkeypatch.setattr(session._messages, "delete_many", controlled_clear) + task = asyncio.create_task(session.clear_session()) + + try: + await mutation_applied.wait() + task.cancel() + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + assert task.done() is False + allow_return.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + allow_return.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + expected = [item] if operation == "add" else [] + assert await session.get_items() == expected + + +async def test_pop_item_cleanup_failure_does_not_hide_known_claim( + session: MongoDBSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed empty-marker delete must not make a claimed item retry-visible.""" + await session.add_items([{"role": "user", "content": "claimed"}]) + real_delete_one = session._messages.delete_one + + async def fail_delete_one(query: dict[str, Any]) -> None: + raise RuntimeError("cleanup failed") + + monkeypatch.setattr(session._messages, "delete_one", fail_delete_one) + popped = await session.pop_item() + + assert popped is not None + assert popped.get("content") == "claimed" + assert len(session._messages._docs) == 1 + remaining_doc: dict[str, Any] = next(iter(session._messages._docs.values())) + assert remaining_doc["message_data"] == [] + + monkeypatch.setattr(session._messages, "delete_one", real_delete_one) + assert await session.pop_item() is None + assert session._messages._docs == {} + + +async def test_reads_legacy_item_documents_with_new_batch_documents() -> None: + """New readers must preserve histories written by released per-item storage.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + session = MongoDBSession( + "legacy-read", + client=client, # type: ignore[arg-type] + database="agents_test", + ) + await session.add_items([{"role": "assistant", "content": "new"}]) + legacy_doc = { + "_id": FakeObjectId(), + "session_id": session.session_id, + "seq": -1, + "message_data": json.dumps({"role": "user", "content": "legacy"}), + } + session._messages._docs[id(legacy_doc["_id"])] = legacy_doc + + assert [item.get("content") for item in await session.get_items()] == ["legacy", "new"] + assert (await session.pop_item() or {}).get("content") == "new" + assert (await session.pop_item() or {}).get("content") == "legacy" diff --git a/tests/extensions/memory/test_redis_session.py b/tests/extensions/memory/test_redis_session.py index e906387c0c..8a6f35eee2 100644 --- a/tests/extensions/memory/test_redis_session.py +++ b/tests/extensions/memory/test_redis_session.py @@ -1,5 +1,10 @@ from __future__ import annotations +import asyncio +import json +import sys +import time +from collections.abc import Awaitable from typing import Any, cast import pytest @@ -14,6 +19,21 @@ # Keep the fallback-to-real-Redis path isolated from xdist workers. pytestmark = [pytest.mark.asyncio, pytest.mark.serial] + +def _assert_cancel_message(exc: asyncio.CancelledError, expected: str) -> None: + """Account for Python 3.10 dropping Task cancellation messages when re-awaited.""" + expected_args = (expected,) if sys.version_info >= (3, 11) else () + assert exc.args == expected_args + + +async def _release_after_detaching_pipeline_connection(delegate: Any) -> None: + """Model Redis 8.1 clearing its pipeline reference before awaiting release.""" + connection = delegate.connection + delegate.connection = None + if connection is not None: + await delegate.connection_pool.release(connection) + + # Try to use fakeredis for in-memory testing, fall back to real Redis if not available try: import fakeredis.aioredis @@ -125,6 +145,60 @@ async def test_redis_session_direct_ops(): await session.close() +@pytest.mark.parametrize("operation", ["pop", "clear"]) +async def test_mutation_cancellation_waits_for_authoritative_outcome( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + """Cancellation must wait after Redis applies a destructive mutation.""" + session = await _create_test_session() + item: TResponseInputItem = {"role": "user", "content": "once"} + await session.add_items([item]) + mutation_applied = asyncio.Event() + allow_return = asyncio.Event() + + if operation == "pop": + original_rpop = session._redis.rpop + + async def controlled_rpop(*args: Any, **kwargs: Any) -> Any: + result = await cast(Awaitable[Any], original_rpop(*args, **kwargs)) + mutation_applied.set() + await allow_return.wait() + return result + + monkeypatch.setattr(session._redis, "rpop", controlled_rpop) + task: asyncio.Task[Any] = asyncio.create_task(session.pop_item()) + else: + original_delete = session._redis.delete + + async def controlled_delete(*args: Any, **kwargs: Any) -> Any: + result = await original_delete(*args, **kwargs) + mutation_applied.set() + await allow_return.wait() + return result + + monkeypatch.setattr(session._redis, "delete", controlled_delete) + task = asyncio.create_task(session.clear_session()) + + try: + await mutation_applied.wait() + task.cancel() + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + assert task.done() is False + allow_return.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + allow_return.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + assert await session.get_items() == [] + + async def test_runner_integration(agent: Agent): """Test that RedisSession works correctly with the agent Runner.""" session = await _create_test_session() @@ -1267,6 +1341,1704 @@ async def test_redis_session_close_is_noop_for_injected_client(): await session.clear_session() +async def test_add_items_applies_ttl_in_the_write_transaction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """TTL setup must not create a post-commit failure window for add_items.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis for pipeline instrumentation") + + client = fakeredis.aioredis.FakeRedis() + session = RedisSession( + session_id="atomic_ttl", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=60, + ) + real_pipeline = client.pipeline + execute_calls = 0 + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + + class PipelineProxy: + def __getattr__(self, name: str) -> Any: + return getattr(delegate, name) + + def __setattr__(self, name: str, value: Any) -> None: + setattr(delegate, name, value) + + async def execute(self) -> Any: + nonlocal execute_calls + execute_calls += 1 + result = await delegate.execute() + if execute_calls == 2: + raise RuntimeError("post-commit TTL failure") + return result + + return PipelineProxy() + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "once"} + + await session.add_items([item]) + + assert execute_calls == 1 + assert await session.get_items() == [item] + + +async def test_add_items_rejects_unrepresentable_ttl_before_writing() -> None: + """An invalid Redis TTL must not turn a failed write retry into duplicates.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis() + session = RedisSession( + session_id="invalid_ttl", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=2**63, + ) + item: TResponseInputItem = {"role": "user", "content": "never committed"} + + for _ in range(2): + with pytest.raises(ValueError, match="outside Redis's supported expiration range"): + await session.add_items([item]) + + assert await session.get_items() == [] + + +async def test_add_items_rejects_wrong_metadata_key_type_before_writing() -> None: + """A metadata type error must not commit history that a retry duplicates.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.exceptions import ResponseError + + client = fakeredis.aioredis.FakeRedis() + session = RedisSession( + session_id="wrong_metadata_type", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + await client.set(session._session_key, "not a hash") + item: TResponseInputItem = {"role": "user", "content": "never committed"} + + for _ in range(2): + with pytest.raises(ResponseError, match="metadata key must contain a hash"): + await session.add_items([item]) + + assert await session.get_items() == [] + + +async def test_add_items_uses_server_absolute_expiration_after_serialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """TTL validation must not become stale between serialization and Redis execution.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis() + server_seconds = 1_750_000_000 + server_microseconds = 500_000 + server_time_ms = server_seconds * 1000 + server_microseconds // 1000 + ttl = (2**63 - 1 - server_time_ms) // 1000 + expected_expiration_ms = server_time_ms + ttl * 1000 + order: list[str] = [] + real_pipeline = client.pipeline + + session = RedisSession( + session_id="absolute_ttl", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=ttl, + ) + + async def serialize(item: TResponseInputItem) -> str: + order.append("serialize") + return json.dumps(item) + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + + class PipelineProxy: + def __getattr__(self, name: str) -> Any: + return getattr(delegate, name) + + async def time(self) -> tuple[int, int]: + order.append("time") + return server_seconds, server_microseconds + + return PipelineProxy() + + monkeypatch.setattr(session, "_serialize_item", serialize) + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "once"} + + await session.add_items([item]) + + assert order == ["serialize", "time"] + assert -(2**63) <= expected_expiration_ms <= 2**63 - 1 + assert await session.get_items() == [item] + + +async def test_add_items_refreshes_server_timestamps_after_watch_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A watched retry must derive metadata and expiration from its own attempt.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis() + ttl = 60 + first_seconds = int(time.time()) + second_seconds = first_seconds + ttl * 2 + server_times = iter([(first_seconds, 0), (second_seconds, 0)]) + expirations: list[int] = [] + pipeline_attempt = 0 + real_pipeline = client.pipeline + + session = RedisSession( + session_id="watch_retry_timestamps", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=ttl, + ) + + def pipeline(*args: Any, **kwargs: Any) -> Any: + nonlocal pipeline_attempt + pipeline_attempt += 1 + attempt = pipeline_attempt + delegate = real_pipeline(*args, **kwargs) + + class PipelineProxy: + def __getattr__(self, name: str) -> Any: + return getattr(delegate, name) + + def __setattr__(self, name: str, value: Any) -> None: + setattr(delegate, name, value) + + def pexpireat(self, key: str, expiration_time_ms: int) -> Any: + expirations.append(expiration_time_ms) + return delegate.pexpireat(key, expiration_time_ms) + + async def time(self) -> tuple[int, int]: + return next(server_times) + + async def execute(self) -> Any: + if attempt == 1: + await client.hset( # type: ignore[misc] + session._session_key, "concurrent_write", "1" + ) + return await delegate.execute() + + return PipelineProxy() + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "once"} + + await session.add_items([item]) + + first_expiration = (first_seconds + ttl) * 1000 + second_expiration = (second_seconds + ttl) * 1000 + assert expirations == [first_expiration] * 3 + [second_expiration] * 3 + metadata = await client.hgetall(session._session_key) # type: ignore[misc] + updated_at = metadata.get(b"updated_at") or metadata.get("updated_at") + assert updated_at in (str(second_seconds), str(second_seconds).encode()) + assert await session.get_items() == [item] + + +async def test_ambiguous_exec_watch_error_is_not_retried( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A transport-derived WatchError after a possible commit must remain ambiguous.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.exceptions import WatchError + + client = fakeredis.aioredis.FakeRedis() + session = RedisSession( + session_id="ambiguous_exec_watch_error", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + attempts = 0 + item: TResponseInputItem = {"role": "user", "content": "once"} + serialized = json.dumps(item, separators=(",", ":")) + + def pipeline(*args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + delegate = real_pipeline(*args, **kwargs) + + class PipelineProxy: + def __getattr__(self, name: str) -> Any: + return getattr(delegate, name) + + async def execute(self) -> Any: + await client.rpush(session._messages_key, serialized) # type: ignore[misc] + raise WatchError("A ConnectionError occurred while watching one or more keys") + + return PipelineProxy() + + monkeypatch.setattr(client, "pipeline", pipeline) + + with pytest.raises(WatchError, match="ConnectionError"): + await session.add_items([item]) + + assert attempts == 1 + assert await session.get_items() == [item] + + +async def test_redis81_standard_pool_checkout_records_native_observability( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The retained checkout path must preserve Redis 8.1 standard-pool metrics.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + import agents.extensions.memory.redis_session as redis_session_module + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id="redis81_standard_observability", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + counts: list[tuple[str, int]] = [] + create_times: list[float] = [] + real_release = pool.release + + class ConnectionState: + IDLE = "idle" + USED = "used" + + async def record_connection_count( + *, pool_name: str, connection_state: str, counter: int + ) -> None: + assert pool_name == "test-pool" + counts.append((connection_state, counter)) + + async def record_connection_create_time( + *, connection_pool: Any, duration_seconds: float + ) -> None: + assert connection_pool is pool + create_times.append(duration_seconds) + + async def release(connection: Any) -> None: + await real_release(connection) + await record_connection_count( + pool_name="test-pool", connection_state=ConnectionState.USED, counter=-1 + ) + await record_connection_count( + pool_name="test-pool", connection_state=ConnectionState.IDLE, counter=1 + ) + + connection_module = cast(Any, redis_session_module)._redis_connection_api + has_native_release_metrics = hasattr(connection_module, "record_connection_count") + monkeypatch.setattr(connection_module, "ConnectionState", ConnectionState, raising=False) + monkeypatch.setattr( + connection_module, "get_pool_name", lambda _pool: "test-pool", raising=False + ) + monkeypatch.setattr( + connection_module, "record_connection_count", record_connection_count, raising=False + ) + monkeypatch.setattr( + connection_module, + "record_connection_create_time", + record_connection_create_time, + raising=False, + ) + if not has_native_release_metrics: + monkeypatch.setattr(pool, "release", release) + + await session.add_items([{"role": "user", "content": "once"}]) + + assert sum(counter for state, counter in counts if state == ConnectionState.USED) == 0 + assert sum(counter for state, counter in counts if state == ConnectionState.IDLE) == 1 + assert len(create_times) == 1 + + +@pytest.mark.parametrize("has_maintenance_lock", [False, True]) +async def test_redis8_blocking_pool_checkout_preserves_native_timing( + monkeypatch: pytest.MonkeyPatch, + has_maintenance_lock: bool, +) -> None: + """The retained checkout path must preserve Redis 8.0 and 8.1 timing hooks.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from contextlib import asynccontextmanager + + from redis.asyncio import BlockingConnectionPool + + import agents.extensions.memory.redis_session as redis_session_module + + seed_client = fakeredis.aioredis.FakeRedis() + source_pool = seed_client.connection_pool + pool = BlockingConnectionPool( + max_connections=1, + timeout=1, + connection_class=source_pool.connection_class, + **source_pool.connection_kwargs, + ) + client = fakeredis.aioredis.FakeRedis(connection_pool=pool) + session = RedisSession( + session_id="redis81_blocking_observability", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + maintenance_entries = 0 + create_times: list[float] = [] + wait_times: list[float] = [] + + @asynccontextmanager + async def maybe_pool_lock() -> Any: + nonlocal maintenance_entries + maintenance_entries += 1 + yield + + async def record_connection_create_time( + *, connection_pool: Any, duration_seconds: float + ) -> None: + assert connection_pool is pool + create_times.append(duration_seconds) + + async def record_connection_wait_time(*, pool_name: str, duration_seconds: float) -> None: + assert pool_name == "test-pool" + wait_times.append(duration_seconds) + + connection_module = cast(Any, redis_session_module)._redis_connection_api + if has_maintenance_lock: + monkeypatch.setattr(pool, "_maybe_pool_lock", maybe_pool_lock, raising=False) + monkeypatch.setattr( + connection_module, "get_pool_name", lambda _pool: "test-pool", raising=False + ) + monkeypatch.setattr( + connection_module, + "record_connection_create_time", + record_connection_create_time, + raising=False, + ) + monkeypatch.setattr( + connection_module, + "record_connection_wait_time", + record_connection_wait_time, + raising=False, + ) + + await session.add_items([{"role": "user", "content": "once"}]) + + assert maintenance_entries == int(has_maintenance_lock) + assert len(create_times) == 1 + assert len(wait_times) == 1 + + +async def test_add_items_with_ttl_supports_single_connection_pool() -> None: + """WATCH and server time must share one caller-managed Redis connection.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="single_connection_ttl", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=60, + ) + item: TResponseInputItem = {"role": "user", "content": "once"} + + await session.add_items([item]) + + assert await session.get_items() == [item] + + +async def test_cancelled_watch_releases_single_connection_pool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cancellation before an immediate command must discard the watched connection.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="cancelled_watch_cleanup", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=60, + ) + real_pipeline = client.pipeline + pool = client.connection_pool + time_started = asyncio.Event() + dirty_connection: Any = None + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + + async def controlled_time() -> tuple[int, int]: + nonlocal dirty_connection + dirty_connection = delegate.connection + assert dirty_connection is not None + time_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + monkeypatch.setattr(delegate, "time", controlled_time) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}])) + try: + await time_started.wait() + add_task.cancel("first-pre-exec-cancel") + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + finally: + if not add_task.done(): + add_task.cancel() + await asyncio.gather(add_task, return_exceptions=True) + + _assert_cancel_message(exc_info.value, "first-pre-exec-cancel") + assert dirty_connection not in pool._in_use_connections + assert dirty_connection not in pool._available_connections + assert await session.get_items() == [] + assert await client.ping() is True # type: ignore[misc] + + +async def test_cancelled_in_flight_watch_command_reconnects_before_pool_reuse( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cancellation with an unread reply must discard the dirty connection.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="cancelled_in_flight_watch_command", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + pool = client.connection_pool + real_release = pool.release + response_read_started = asyncio.Event() + dirty_connection: Any = None + close_calls = 0 + released_connections: list[Any] = [] + + async def track_release(connection: Any) -> None: + released_connections.append(connection) + await real_release(connection) + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_type = delegate.type + + async def controlled_type(key: str) -> Any: + nonlocal close_calls, dirty_connection + connection = delegate.connection + assert connection is not None + dirty_connection = connection + real_read_response = connection.read_response + real_close = connection._close + read_calls = 0 + + async def block_first_read(*args: Any, **kwargs: Any) -> Any: + nonlocal read_calls + read_calls += 1 + if read_calls == 1: + response_read_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + return await real_read_response(*args, **kwargs) + + def track_close() -> None: + nonlocal close_calls + close_calls += 1 + real_close() + + monkeypatch.setattr(connection, "read_response", block_first_read) + monkeypatch.setattr(connection, "_close", track_close) + return await real_type(key) + + monkeypatch.setattr(delegate, "type", controlled_type) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + monkeypatch.setattr(pool, "release", track_release) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}])) + try: + await response_read_started.wait() + add_task.cancel("cancel-during-response-read") + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + finally: + if not add_task.done(): + add_task.cancel() + await asyncio.gather(add_task, return_exceptions=True) + + _assert_cancel_message(exc_info.value, "cancel-during-response-read") + assert dirty_connection is not None + assert close_calls == 1 + assert released_connections == [] + assert dirty_connection not in pool._in_use_connections + assert dirty_connection not in pool._available_connections + assert await client.ping() is True # type: ignore[misc] + assert len(released_connections) == 1 + assert released_connections[0] is not dirty_connection + assert await session.get_items() == [] + + +@pytest.mark.parametrize("blocking_pool", [False, True]) +@pytest.mark.parametrize("validation_fails", [False, True]) +async def test_cancelled_connection_acquisition_is_discarded_before_release( + monkeypatch: pytest.MonkeyPatch, + blocking_pool: bool, + validation_fails: bool, +) -> None: + """Cancellation during pool validation must retain and discard the acquired identity.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.asyncio import BlockingConnectionPool + from redis.event import AsyncAfterConnectionReleasedEvent + + pool: Any + if blocking_pool: + seed_client = fakeredis.aioredis.FakeRedis() + source_pool = seed_client.connection_pool + pool = BlockingConnectionPool( + max_connections=1, + timeout=1, + connection_class=source_pool.connection_class, + **source_pool.connection_kwargs, + ) + client = fakeredis.aioredis.FakeRedis(connection_pool=pool) + else: + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + + session = RedisSession( + session_id=f"cancelled_connection_acquisition_{blocking_pool}", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + dispatcher = pool._event_dispatcher + assert dispatcher is not None + listeners = dispatcher._event_listeners_mapping[AsyncAfterConnectionReleasedEvent] + released_connections: list[Any] = [] + ensure_started = asyncio.Event() + allow_ensure = asyncio.Event() + dirty_connection: Any = None + real_ensure_connection = pool.ensure_connection + loop = asyncio.get_running_loop() + previous_exception_handler = loop.get_exception_handler() + loop_errors: list[dict[str, Any]] = [] + + class RecordingReleaseListener: + async def listen(self, event: Any) -> None: + released_connections.append(event.connection) + + async def controlled_ensure_connection(connection: Any) -> None: + nonlocal dirty_connection + if dirty_connection is None: + dirty_connection = connection + ensure_started.set() + await allow_ensure.wait() + if validation_fails: + raise RuntimeError("connection validation failed") + await real_ensure_connection(connection) + + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + [*listeners, RecordingReleaseListener()], + ) + monkeypatch.setattr(pool, "ensure_connection", controlled_ensure_connection) + loop.set_exception_handler(lambda _loop, context: loop_errors.append(context)) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}])) + waiter: asyncio.Task[Any] | None = None + try: + await ensure_started.wait() + add_task.cancel("cancel-during-acquisition") + if blocking_pool: + + async def ping() -> Any: + return await client.ping() # type: ignore[misc] + + waiter = asyncio.create_task(ping()) + await asyncio.sleep(0) + allow_ensure.set() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + _assert_cancel_message(exc_info.value, "cancel-during-acquisition") + + assert dirty_connection is not None + assert dirty_connection not in pool._in_use_connections + assert dirty_connection not in pool._available_connections + assert dirty_connection not in released_connections + if waiter is not None: + assert await waiter is True + else: + assert await client.ping() is True # type: ignore[misc] + assert released_connections + assert all(connection is not dirty_connection for connection in released_connections) + assert await session.get_items() == [] + await asyncio.sleep(0) + assert loop_errors == [] + finally: + loop.set_exception_handler(previous_exception_handler) + allow_ensure.set() + pending = [task for task in (add_task, waiter) if task is not None and not task.done()] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + +@pytest.mark.parametrize( + "cleanup_mode", + ["internal_reset", "failing_internal_reset"], +) +async def test_post_commit_cancellation_propagates_after_cleanup( + monkeypatch: pytest.MonkeyPatch, + cleanup_mode: str, +) -> None: + """Cancellation after EXEC must propagate once driver cleanup settles.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id=f"post_commit_cancellation_{cleanup_mode}", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + internal_reset_started = asyncio.Event() + allow_internal_reset = asyncio.Event() + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + reset_calls = 0 + + async def controlled_reset() -> None: + nonlocal reset_calls + reset_calls += 1 + if reset_calls == 1: + internal_reset_started.set() + await allow_internal_reset.wait() + await real_reset() + if cleanup_mode == "failing_internal_reset" and reset_calls == 1: + raise RuntimeError("post-commit reset failed") + + monkeypatch.setattr(delegate, "reset", controlled_reset) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + add_task = asyncio.create_task(session.add_items([item])) + try: + await internal_reset_started.wait() + add_task.cancel("first-post-commit-cancel") + await asyncio.sleep(0) + add_task.cancel("second-during-cleanup") + await asyncio.sleep(0) + allow_internal_reset.set() + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + finally: + allow_internal_reset.set() + if not add_task.done(): + add_task.cancel() + await asyncio.gather(add_task, return_exceptions=True) + + assert await session.get_items() == [item] + _assert_cancel_message(exc_info.value, "first-post-commit-cancel") + assert add_task.cancelled() + assert await client.ping() is True # type: ignore[misc] + + +async def test_post_commit_response_callback_failure_does_not_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A response callback failure after EXEC must not duplicate the committed batch.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="post_commit_callback_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + + def fail_rpush_callback(response: Any, **kwargs: Any) -> Any: + raise RuntimeError("response callback failed") + + client.set_response_callback("RPUSH", fail_rpush_callback) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + monkeypatch.delitem(client.response_callbacks, "RPUSH") + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +async def test_successful_batch_response_with_sibling_error_does_not_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A successful RPUSH remains committed when a sibling EXEC response fails.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="successful_batch_with_sibling_error", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_hset = delegate.hset + hset_calls = 0 + + def replace_updated_at_with_wrong_type(*args: Any, **kwargs: Any) -> Any: + nonlocal hset_calls + hset_calls += 1 + if hset_calls == 2: + return delegate.incr(session._session_key) + return real_hset(*args, **kwargs) + + monkeypatch.setattr(delegate, "hset", replace_updated_at_with_wrong_type) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +async def test_post_commit_reset_self_cancellation_does_not_cancel_caller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A reset child cancellation after EXEC must not impersonate caller cancellation.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="post_commit_reset_self_cancel", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + reset_calls = 0 + + async def controlled_reset() -> None: + nonlocal reset_calls + reset_calls += 1 + if reset_calls == 1: + raise asyncio.CancelledError("reset self-cancelled") + await real_reset() + + monkeypatch.setattr(delegate, "reset", controlled_reset) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +async def test_post_commit_reset_failure_detaches_without_release_listener( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A reset failure must detach rather than release through a reborrowing listener.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.event import AsyncAfterConnectionReleasedEvent + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id="post_commit_repeated_reset_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + dispatcher = pool._event_dispatcher + assert dispatcher is not None + listeners = dispatcher._event_listeners_mapping[AsyncAfterConnectionReleasedEvent] + listener_calls = 0 + borrowed_connection: Any = None + + class ReborrowingFailingReleaseListener: + async def listen(self, event: Any) -> None: + nonlocal borrowed_connection, listener_calls + listener_calls += 1 + borrowed_connection = await pool.get_connection() + raise RuntimeError("release listener failed after reborrow") + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + reset_calls = 0 + + async def fail_two_resets_before_release() -> None: + nonlocal reset_calls + reset_calls += 1 + if reset_calls <= 2: + raise RuntimeError("reset failed before release") + await real_reset() + + monkeypatch.setattr(delegate, "reset", fail_two_resets_before_release) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + [*listeners, ReborrowingFailingReleaseListener()], + ) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + assert listener_calls == 0 + assert borrowed_connection is None + assert not pool._in_use_connections + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + listeners, + ) + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +async def test_reconnect_required_release_detaches_without_listener_reborrow( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A reconnect-required identity must never enter shared-pool release.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.event import AsyncAfterConnectionReleasedEvent + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id="reconnect_required_release", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + dispatcher = pool._event_dispatcher + assert dispatcher is not None + listeners = dispatcher._event_listeners_mapping[AsyncAfterConnectionReleasedEvent] + listener_calls = 0 + borrowed_connection: Any = None + + class ReborrowingFailingReleaseListener: + async def listen(self, event: Any) -> None: + nonlocal borrowed_connection, listener_calls + listener_calls += 1 + borrowed_connection = await pool.get_connection() + raise RuntimeError("release listener failed after reborrow") + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + + async def mark_for_reconnect_before_reset() -> None: + connection = delegate.connection + assert connection is not None + connection.mark_for_reconnect() + await real_reset() + + monkeypatch.setattr(delegate, "reset", mark_for_reconnect_before_reset) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + [*listeners, ReborrowingFailingReleaseListener()], + ) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + assert listener_calls == 0 + assert borrowed_connection is None + assert not pool._in_use_connections + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + listeners, + ) + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +async def test_reconnect_marked_inside_release_failure_is_detached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A reconnect race before native transfer must retain cleanup ownership.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id="reconnect_during_release", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_release = pool.release + real_pipeline = client.pipeline + close_calls = 0 + detached_connection: Any = None + + async def redis81_raced_release(connection: Any) -> None: + nonlocal close_calls, detached_connection + detached_connection = connection + real_close = connection._close + + def tracked_close() -> None: + nonlocal close_calls + close_calls += 1 + real_close() + + monkeypatch.setattr(connection, "_close", tracked_close) + connection.mark_for_reconnect() + pool._in_use_connections.remove(connection) + raise RuntimeError("disconnect failed before pool transfer") + + monkeypatch.setattr(pool, "release", redis81_raced_release) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + assert detached_connection is not None + assert close_calls == 1 + assert detached_connection not in pool._in_use_connections + assert detached_connection not in pool._available_connections + monkeypatch.setattr(pool, "release", real_release) + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +async def test_post_commit_release_listener_failure_does_not_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A listener failure after pool release must not make a committed batch retryable.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.event import AsyncAfterConnectionReleasedEvent + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="post_commit_release_listener_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + dispatcher = client.connection_pool._event_dispatcher + assert dispatcher is not None + listeners = dispatcher._event_listeners_mapping[AsyncAfterConnectionReleasedEvent] + + class FailingReleaseListener: + async def listen(self, event: Any) -> None: + raise RuntimeError("release listener failed after pool return") + + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + [*listeners, FailingReleaseListener()], + ) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + listeners, + ) + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +async def test_pre_exec_close_failure_is_quarantined( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed dirty-connection close must remain owned for close retry.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="pre_exec_disconnect_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=60, + ) + real_pipeline = client.pipeline + time_started = asyncio.Event() + dirty_connection: Any = None + real_close: Any = None + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + + async def controlled_time() -> tuple[int, int]: + nonlocal dirty_connection, real_close + dirty_connection = delegate.connection + assert dirty_connection is not None + real_close = dirty_connection._close + + def fail_close() -> None: + raise RuntimeError("close failed") + + monkeypatch.setattr(dirty_connection, "_close", fail_close) + time_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + monkeypatch.setattr(delegate, "time", controlled_time) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}])) + await time_started.wait() + add_task.cancel("caller-cancel") + + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + + _assert_cancel_message(exc_info.value, "caller-cancel") + assert session._detached_connections == {dirty_connection} + assert dirty_connection not in client.connection_pool._in_use_connections + assert dirty_connection not in client.connection_pool._available_connections + monkeypatch.setattr(dirty_connection, "_close", real_close) + await session.close() + assert session._detached_connections == set() + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await client.ping() is True # type: ignore[misc] + assert await session.get_items() == [] + + +async def test_pre_exec_cancellation_skips_pipeline_reset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A protocol-dirty cancellation must not expose the connection through reset.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="pre_exec_cancel_released_reset_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=60, + ) + real_pipeline = client.pipeline + time_started = asyncio.Event() + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + + async def controlled_time() -> tuple[int, int]: + time_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def fail_if_reset() -> None: + raise AssertionError("dirty connection reached pipeline reset") + + monkeypatch.setattr(delegate, "time", controlled_time) + monkeypatch.setattr(delegate, "reset", fail_if_reset) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}])) + await time_started.wait() + add_task.cancel("first-caller-cancel") + + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + + _assert_cancel_message(exc_info.value, "first-caller-cancel") + assert await client.ping() is True # type: ignore[misc] + assert await session.get_items() == [] + + +async def test_blocking_pool_cancellation_completion_owns_release( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller cancellation must not interrupt blocking-pool cleanup after it starts.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.asyncio import BlockingConnectionPool + + seed_client = fakeredis.aioredis.FakeRedis() + source_pool = seed_client.connection_pool + pool = BlockingConnectionPool( + max_connections=1, + timeout=1, + connection_class=source_pool.connection_class, + **source_pool.connection_kwargs, + ) + client = fakeredis.aioredis.FakeRedis(connection_pool=pool) + session = RedisSession( + session_id="blocking_pool_cancelled_release", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=60, + ) + real_pipeline = client.pipeline + time_started = asyncio.Event() + fail_time = asyncio.Event() + reset_started = asyncio.Event() + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + + async def controlled_time() -> tuple[int, int]: + time_started.set() + await fail_time.wait() + raise RuntimeError("pre-execute failure") + + async def observed_reset() -> None: + reset_started.set() + await real_reset() + + monkeypatch.setattr(delegate, "time", controlled_time) + monkeypatch.setattr(delegate, "reset", observed_reset) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "failed"}])) + await time_started.wait() + await pool._condition.acquire() + try: + fail_time.set() + await reset_started.wait() + add_task.cancel("caller-cancel") + await asyncio.sleep(0) + finally: + pool._condition.release() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + + _assert_cancel_message(exc_info.value, "caller-cancel") + assert not pool._in_use_connections + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await client.ping() is True # type: ignore[misc] + assert await session.get_items() == [] + + +async def test_ordinary_pool_cancellation_completion_owns_release( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller cancellation must not interrupt ordinary-pool cleanup after it starts.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id="ordinary_pool_cancelled_release", + redis_client=cast("Redis", client), + key_prefix="test:", + ttl=60, + ) + real_pipeline = client.pipeline + time_started = asyncio.Event() + fail_time = asyncio.Event() + reset_started = asyncio.Event() + allow_reset = asyncio.Event() + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + + async def controlled_time() -> tuple[int, int]: + time_started.set() + await fail_time.wait() + raise RuntimeError("pre-execute failure") + + async def controlled_reset() -> None: + reset_started.set() + await allow_reset.wait() + await real_reset() + + monkeypatch.setattr(delegate, "time", controlled_time) + monkeypatch.setattr(delegate, "reset", controlled_reset) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + add_task = asyncio.create_task(session.add_items([{"role": "user", "content": "failed"}])) + try: + await time_started.wait() + fail_time.set() + await reset_started.wait() + add_task.cancel("caller-cancel") + await asyncio.sleep(0) + allow_reset.set() + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + finally: + allow_reset.set() + if not add_task.done(): + add_task.cancel() + await asyncio.gather(add_task, return_exceptions=True) + + _assert_cancel_message(exc_info.value, "caller-cancel") + assert not pool._in_use_connections + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await client.ping() is True # type: ignore[misc] + assert await session.get_items() == [] + + +async def test_transparent_overridden_pool_release_is_supported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A custom release that preserves pool ownership semantics remains supported.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id="overridden_pool_release", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_release = pool.release + release_calls = 0 + + async def transparent_release(connection: Any) -> None: + nonlocal release_calls + release_calls += 1 + await real_release(connection) + + monkeypatch.setattr(pool, "release", transparent_release) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + assert release_calls > 0 + assert not pool._in_use_connections + monkeypatch.setattr(pool, "release", real_release) + assert await session.get_items() == [item] + + +async def test_release_failure_after_reborrow_preserves_new_borrower( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A release-then-reborrow failure must not reclaim the new borrower's connection.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id="ambiguous_pool_release", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_release = pool.release + real_pipeline = client.pipeline + release_calls = 0 + borrowed_connection: Any = None + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + monkeypatch.setattr( + delegate, + "reset", + lambda: _release_after_detaching_pipeline_connection(delegate), + ) + return delegate + + async def transfer_reborrow_then_fail(connection: Any) -> None: + nonlocal borrowed_connection, release_calls + release_calls += 1 + await real_release(connection) + if release_calls == 1: + borrowed_connection = await pool.get_connection() + assert borrowed_connection is connection + raise RuntimeError("release failed after reborrow") + + monkeypatch.setattr(pool, "release", transfer_reborrow_then_fail) + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + assert release_calls == 1 + assert borrowed_connection is not None + assert borrowed_connection in pool._in_use_connections + assert borrowed_connection not in pool._available_connections + monkeypatch.setattr(pool, "release", real_release) + monkeypatch.setattr(client, "pipeline", real_pipeline) + await real_release(borrowed_connection) + replacement_connection = await pool.get_connection() + assert replacement_connection is borrowed_connection + await real_release(replacement_connection) + assert await session.get_items() == [item] + + +async def test_post_commit_disconnect_failure_is_not_retryable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A detached failed-disconnect connection must not make a committed batch retryable.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="post_commit_disconnect_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + reset_calls = 0 + + async def fail_disconnect_after_commit() -> None: + nonlocal reset_calls + reset_calls += 1 + if reset_calls == 1: + connection = delegate.connection + assert connection is not None + connection.mark_for_reconnect() + + async def fail_disconnect(nowait: bool = False) -> None: + raise RuntimeError("disconnect failed") + + monkeypatch.setattr(connection, "disconnect", fail_disconnect) + await _release_after_detaching_pipeline_connection(delegate) + + monkeypatch.setattr(delegate, "reset", fail_disconnect_after_commit) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + +@pytest.mark.parametrize("cancelled", [False, True]) +async def test_post_commit_detached_close_failure_is_quarantined( + monkeypatch: pytest.MonkeyPatch, + cancelled: bool, +) -> None: + """A non-reusable connection close failure must not make a commit retryable.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + pool = client.connection_pool + session = RedisSession( + session_id=f"post_commit_detached_close_failure_{cancelled}", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + reset_started = asyncio.Event() + allow_reset = asyncio.Event() + detached_connection: Any = None + real_close: Any = None + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + + async def fail_disconnect_and_close_after_commit() -> None: + nonlocal detached_connection, real_close + reset_started.set() + await allow_reset.wait() + if detached_connection is None: + detached_connection = delegate.connection + assert detached_connection is not None + real_close = detached_connection._close + detached_connection.mark_for_reconnect() + + async def fail_disconnect(nowait: bool = False) -> None: + raise RuntimeError("disconnect failed") + + def fail_close() -> None: + raise RuntimeError("close failed") + + monkeypatch.setattr(detached_connection, "disconnect", fail_disconnect) + monkeypatch.setattr(detached_connection, "_close", fail_close) + await _release_after_detaching_pipeline_connection(delegate) + + monkeypatch.setattr(delegate, "reset", fail_disconnect_and_close_after_commit) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + add_task = asyncio.create_task(session.add_items([item])) + try: + await reset_started.wait() + if cancelled: + add_task.cancel("caller-cancel") + await asyncio.sleep(0) + allow_reset.set() + if cancelled: + with pytest.raises(asyncio.CancelledError) as exc_info: + await add_task + _assert_cancel_message(exc_info.value, "caller-cancel") + else: + await add_task + + assert not pool._in_use_connections + assert session._detached_connections == {detached_connection} + monkeypatch.setattr(detached_connection, "_close", real_close) + await session.close() + assert session._detached_connections == set() + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + finally: + allow_reset.set() + if not add_task.done(): + add_task.cancel() + await asyncio.gather(add_task, return_exceptions=True) + if detached_connection is not None and real_close is not None: + monkeypatch.setattr(detached_connection, "_close", real_close) + await session.close() + + +async def test_blocking_pool_listener_failure_notifies_waiter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A completed blocking-pool release must notify a waiter despite listener failure.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.asyncio import BlockingConnectionPool + from redis.event import AsyncAfterConnectionReleasedEvent + + seed_client = fakeredis.aioredis.FakeRedis() + source_pool = seed_client.connection_pool + pool = BlockingConnectionPool( + max_connections=1, + timeout=1, + connection_class=source_pool.connection_class, + **source_pool.connection_kwargs, + ) + client = fakeredis.aioredis.FakeRedis(connection_pool=pool) + session = RedisSession( + session_id="blocking_pool_listener_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + dispatcher = pool._event_dispatcher + assert dispatcher is not None + listeners = dispatcher._event_listeners_mapping[AsyncAfterConnectionReleasedEvent] + real_pipeline = client.pipeline + reset_started = asyncio.Event() + allow_reset = asyncio.Event() + + class FailFirstReleaseListener: + def __init__(self) -> None: + self.calls = 0 + + async def listen(self, event: Any) -> None: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("release listener failed") + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + + async def controlled_reset() -> None: + reset_started.set() + await allow_reset.wait() + await real_reset() + + monkeypatch.setattr(delegate, "reset", controlled_reset) + return delegate + + monkeypatch.setitem( + dispatcher._event_listeners_mapping, + AsyncAfterConnectionReleasedEvent, + [*listeners, FailFirstReleaseListener()], + ) + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + add_task = asyncio.create_task(session.add_items([item])) + ping_task: asyncio.Task[Any] | None = None + + async def ping() -> Any: + return await client.ping() # type: ignore[misc] + + try: + await reset_started.wait() + ping_task = asyncio.create_task(ping()) + await asyncio.sleep(0) + allow_reset.set() + await add_task + assert await ping_task is True + finally: + allow_reset.set() + pending = [task for task in (add_task, ping_task) if task is not None and not task.done()] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await session.get_items() == [item] + + +async def test_blocking_pool_detached_disconnect_notifies_waiter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Closing a detached blocking-pool connection must notify an existing waiter.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + from redis.asyncio import BlockingConnectionPool + + seed_client = fakeredis.aioredis.FakeRedis() + source_pool = seed_client.connection_pool + pool = BlockingConnectionPool( + max_connections=1, + timeout=1, + connection_class=source_pool.connection_class, + **source_pool.connection_kwargs, + ) + client = fakeredis.aioredis.FakeRedis(connection_pool=pool) + session = RedisSession( + session_id="blocking_pool_detached_disconnect", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + reset_started = asyncio.Event() + allow_reset = asyncio.Event() + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + + async def fail_disconnect_after_commit() -> None: + reset_started.set() + await allow_reset.wait() + connection = delegate.connection + assert connection is not None + connection.mark_for_reconnect() + + async def fail_disconnect(nowait: bool = False) -> None: + raise RuntimeError("disconnect failed") + + monkeypatch.setattr(connection, "disconnect", fail_disconnect) + await _release_after_detaching_pipeline_connection(delegate) + + monkeypatch.setattr(delegate, "reset", fail_disconnect_after_commit) + return delegate + + async def ping() -> Any: + return await client.ping() # type: ignore[misc] + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + add_task = asyncio.create_task(session.add_items([item])) + ping_task: asyncio.Task[Any] | None = None + try: + await reset_started.wait() + ping_task = asyncio.create_task(ping()) + await asyncio.sleep(0) + allow_reset.set() + await add_task + assert await ping_task is True + finally: + allow_reset.set() + pending = [task for task in (add_task, ping_task) if task is not None and not task.done()] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + monkeypatch.setattr(client, "pipeline", real_pipeline) + assert await session.get_items() == [item] + + +async def test_post_commit_internal_reset_failure_does_not_report_write_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cleanup failure after a successful EXEC must not invite a duplicate retry.""" + if not USE_FAKE_REDIS: + pytest.skip("This test requires fakeredis") + + client = fakeredis.aioredis.FakeRedis(max_connections=1) + session = RedisSession( + session_id="post_commit_reset_failure", + redis_client=cast("Redis", client), + key_prefix="test:", + ) + real_pipeline = client.pipeline + + def pipeline(*args: Any, **kwargs: Any) -> Any: + delegate = real_pipeline(*args, **kwargs) + real_reset = delegate.reset + reset_calls = 0 + + async def fail_first_reset_after_release() -> None: + nonlocal reset_calls + reset_calls += 1 + await real_reset() + if reset_calls == 1: + raise RuntimeError("post-commit reset failed") + + monkeypatch.setattr(delegate, "reset", fail_first_reset_after_release) + return delegate + + monkeypatch.setattr(client, "pipeline", pipeline) + item: TResponseInputItem = {"role": "user", "content": "committed"} + + await session.add_items([item]) + + assert await session.get_items() == [item] + assert await client.ping() is True # type: ignore[misc] + + async def test_redis_session_operation_waiting_behind_close_raises(): """An operation queued behind close() must fail rather than run after shutdown completes.""" if not USE_FAKE_REDIS: diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index b1984f4e4a..7019b000c4 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -7,6 +7,7 @@ from collections.abc import Iterable, Sequence from contextlib import asynccontextmanager from datetime import datetime, timedelta +from pathlib import Path from typing import Any, cast import pytest @@ -16,7 +17,7 @@ ResponseReasoningItemParam, Summary, ) -from sqlalchemy import insert, select, text, update +from sqlalchemy import event, insert, select, text, update from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.sql import Select @@ -237,6 +238,291 @@ async def test_pop_from_empty_session(): assert popped is None +async def test_concurrent_pop_item_returns_each_row_once(tmp_path): + """Concurrent atomic DELETE claims must return each stored row at most once.""" + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'concurrent_pop.db'}") + writer = SQLAlchemySession("concurrent_pop", engine=engine, create_tables=True) + other = SQLAlchemySession("concurrent_pop", engine=engine) + await writer.add_items( + [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second"}, + ] + ) + + tasks = [asyncio.create_task(session.pop_item()) for session in (writer, other)] + try: + popped = await asyncio.wait_for(asyncio.gather(*tasks), timeout=2) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await engine.dispose() + + contents = {cast(dict[str, Any], item)["content"] for item in popped if item is not None} + assert contents == {"first", "second"} + + +async def test_sqlite_fallback_reserves_writer_before_tail_claim( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SQLite without DELETE RETURNING must serialize the select-delete fallback.""" + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'fallback_pop.db'}") + writer = SQLAlchemySession("fallback_pop", engine=engine, create_tables=True) + other = SQLAlchemySession("fallback_pop", engine=engine) + statements: list[str] = [] + + def record_statement( + conn: Any, + cursor: Any, + statement: str, + parameters: Any, + context: Any, + executemany: bool, + ) -> None: + statements.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", record_statement) + monkeypatch.setattr(engine.dialect, "delete_returning", False) + await writer.add_items([{"role": "user", "content": "only"}]) + + tasks = [asyncio.create_task(session.pop_item()) for session in (writer, other)] + try: + popped = await asyncio.wait_for(asyncio.gather(*tasks), timeout=2) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + event.remove(engine.sync_engine, "before_cursor_execute", record_statement) + await engine.dispose() + + assert sum(item is not None for item in popped) == 1 + assert [item.get("content") for item in popped if item is not None] == ["only"] + assert any(statement.strip().upper() == "BEGIN IMMEDIATE" for statement in statements) + + +async def test_pop_item_supports_unknown_delete_rowcount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A locked fallback claim must not depend on the DBAPI DELETE row count.""" + session = SQLAlchemySession.from_url("unknown_rowcount", url=DB_URL, create_tables=False) + transaction_exit_errors: list[type[BaseException] | None] = [] + delete_executed = False + + class FakeResult: + def __init__(self, row: Any = None, rowcount: int = -1) -> None: + self._row = row + self.rowcount = rowcount + + def one_or_none(self) -> Any: + return self._row + + class FakeTransaction: + async def __aenter__(self) -> None: + return None + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: Any, + ) -> None: + transaction_exit_errors.append(exc_type) + + class FakeSession: + async def __aenter__(self) -> FakeSession: + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + def begin(self) -> FakeTransaction: + return FakeTransaction() + + async def execute(self, statement: Any) -> FakeResult: + nonlocal delete_executed + if isinstance(statement, Select): + assert statement._for_update_arg is not None + return FakeResult((1, json.dumps({"role": "user", "content": "claimed"}))) + delete_executed = True + return FakeResult(rowcount=-1) + + class FakeSessionFactory: + def __call__(self) -> FakeSession: + return FakeSession() + + async def tables_ready() -> None: + return None + + monkeypatch.setattr(session, "_ensure_tables", tables_ready) + monkeypatch.setattr(session, "_session_factory", FakeSessionFactory()) + monkeypatch.setattr(session.engine.dialect, "delete_returning", False) + + try: + popped = await session.pop_item() + finally: + await session.engine.dispose() + + assert popped is not None + assert popped.get("content") == "claimed" + assert delete_executed is True + assert transaction_exit_errors == [None] + + +async def test_pop_item_retries_returning_claim_lost_to_concurrent_delete( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A lost DELETE RETURNING race must retry if older rows remain.""" + session = SQLAlchemySession.from_url("returning_retry", url=DB_URL, create_tables=False) + session_count = 0 + + class FakeResult: + def __init__(self, value: Any = None) -> None: + self._value = value + + def scalar_one_or_none(self) -> Any: + return self._value + + class FakeTransaction: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *args: Any) -> None: + return None + + class FakeSession: + def __init__(self, attempt: int) -> None: + self._attempt = attempt + + async def __aenter__(self) -> FakeSession: + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + def begin(self) -> FakeTransaction: + return FakeTransaction() + + async def execute(self, statement: Any) -> FakeResult: + if self._attempt == 1: + if isinstance(statement, Select): + return FakeResult(1) + return FakeResult() + assert not isinstance(statement, Select) + return FakeResult(json.dumps({"role": "user", "content": "older"})) + + class FakeSessionFactory: + def __call__(self) -> FakeSession: + nonlocal session_count + session_count += 1 + return FakeSession(session_count) + + async def tables_ready() -> None: + return None + + monkeypatch.setattr(session, "_ensure_tables", tables_ready) + monkeypatch.setattr(session, "_session_factory", FakeSessionFactory()) + monkeypatch.setattr(session.engine.dialect, "delete_returning", True) + + try: + popped = await session.pop_item() + finally: + await session.engine.dispose() + + assert popped is not None + assert popped.get("content") == "older" + assert session_count == 2 + + +@pytest.mark.parametrize("operation", ["add", "pop", "clear"]) +async def test_mutation_cancellation_waits_for_transaction_exit( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + """Cancellation must wait until the transaction context finishes settling.""" + session = SQLAlchemySession.from_url( + f"transaction_cancellation_{operation}", + url=DB_URL, + create_tables=False, + ) + transaction_applied = asyncio.Event() + allow_return = asyncio.Event() + transaction_returned = False + + class FakeResult: + def scalar_one_or_none(self) -> Any: + if operation == "add": + return 1 + if operation == "pop": + return json.dumps({"role": "user", "content": "claimed"}) + return None + + class FakeTransaction: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *args: Any) -> None: + nonlocal transaction_returned + transaction_applied.set() + await allow_return.wait() + transaction_returned = True + + class FakeSession: + async def __aenter__(self) -> FakeSession: + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + def begin(self) -> FakeTransaction: + return FakeTransaction() + + async def execute(self, statement: Any) -> FakeResult: + return FakeResult() + + class FakeSessionFactory: + def __call__(self) -> FakeSession: + return FakeSession() + + async def tables_ready() -> None: + return None + + monkeypatch.setattr(session, "_ensure_tables", tables_ready) + monkeypatch.setattr(session, "_session_factory", FakeSessionFactory()) + monkeypatch.setattr(session.engine.dialect, "delete_returning", True) + + if operation == "add": + task: asyncio.Task[Any] = asyncio.create_task( + session.add_items([{"role": "user", "content": "once"}]) + ) + elif operation == "pop": + task = asyncio.create_task(session.pop_item()) + else: + task = asyncio.create_task(session.clear_session()) + + try: + await transaction_applied.wait() + task.cancel() + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + assert task.done() is False + allow_return.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + allow_return.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await session.engine.dispose() + + assert transaction_returned is True + + async def test_pop_item_skips_corrupt_most_recent(): """pop_item skips corrupt newest rows and returns the next valid item.""" session = SQLAlchemySession.from_url("pop_corrupt", url=DB_URL, create_tables=True) diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index a2df310df1..8c8bf5ea0d 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -3,16 +3,51 @@ import asyncio import sqlite3 import tempfile +import threading from pathlib import Path -from typing import cast +from typing import Any, cast import pytest from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession, TResponseInputItem +from agents.memory.sqlite_session import _await_mutation from tests.fake_model import FakeModel from tests.test_responses import get_text_message +@pytest.mark.asyncio +async def test_await_mutation_cancellation_hides_later_failure_without_loop_error() -> None: + """A failed mutation must not leak a false loop error after caller cancellation.""" + mutation_started = asyncio.Event() + allow_failure = asyncio.Event() + loop = asyncio.get_running_loop() + previous_exception_handler = loop.get_exception_handler() + loop_errors: list[dict[str, Any]] = [] + + async def mutation() -> None: + mutation_started.set() + await allow_failure.wait() + raise RuntimeError("mutation failed") + + loop.set_exception_handler(lambda _loop, context: loop_errors.append(context)) + task = asyncio.create_task(_await_mutation(mutation())) + try: + await mutation_started.wait() + task.cancel("caller-cancelled") + allow_failure.set() + + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0) + assert loop_errors == [] + finally: + loop.set_exception_handler(previous_exception_handler) + allow_failure.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + # Helper functions for parametrized testing of different Runner methods def _run_sync_wrapper(agent, input_data, **kwargs): """Wrapper for run_sync that properly sets up an event loop.""" @@ -967,3 +1002,213 @@ async def test_runner_with_session_settings_override(): assert len(history_items) == 2 session.close() + + +def _drop_sqlite_table(db_path: Path, table: str) -> None: + """Drop a table from an independent connection to make a later statement fail.""" + helper = sqlite3.connect(str(db_path)) + try: + helper.execute(f"DROP TABLE {table}") + helper.commit() + finally: + helper.close() + + +def _sqlite_write_lock_is_free(db_path: Path) -> bool: + """Return whether an independent writer can take the SQLite write lock.""" + probe = sqlite3.connect(str(db_path), timeout=0) + try: + probe.execute("CREATE TABLE IF NOT EXISTS probe_lock (x INTEGER)") + probe.commit() + return True + except sqlite3.OperationalError: + return False + finally: + probe.close() + + +@pytest.mark.asyncio +async def test_sqlite_session_failed_clear_session_rolls_back(): + """A failed clear must restore earlier statements and release the cached write lock.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "clear_rollback.db" + session = SQLiteSession("clear_rollback", db_path) + await session.add_items([{"role": "user", "content": "kept"}]) + + _drop_sqlite_table(db_path, "agent_sessions") + + with pytest.raises(sqlite3.OperationalError): + await session.clear_session() + + assert all(not conn.in_transaction for conn in session._connections) + assert _sqlite_write_lock_is_free(db_path) + session.close() + + +@pytest.mark.asyncio +async def test_sqlite_session_failed_pop_item_releases_write_lock(): + """A failed pop must not leave a write transaction on the cached connection.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "pop_rollback.db" + session = SQLiteSession("pop_rollback", db_path) + await session.add_items([{"role": "user", "content": "kept"}]) + + _drop_sqlite_table(db_path, "agent_messages") + + with pytest.raises(sqlite3.OperationalError): + await session.pop_item() + + assert all(not conn.in_transaction for conn in session._connections) + assert _sqlite_write_lock_is_free(db_path) + session.close() + + +@pytest.mark.asyncio +async def test_sqlite_session_rollback_failure_evicts_connection( + monkeypatch: pytest.MonkeyPatch, +): + """A file connection that cannot roll back must be closed and replaced.""" + + class FailingRollbackConnection(sqlite3.Connection): + def rollback(self) -> None: + raise RuntimeError("rollback failed") + + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "rollback_failure.db" + session = SQLiteSession("rollback_failure", db_path) + conn = sqlite3.connect( + str(db_path), + check_same_thread=False, + factory=FailingRollbackConnection, + ) + with session._connections_lock: + session._connections.add(conn) + real_get_connection = session._get_connection + monkeypatch.setattr(session, "_get_connection", lambda: conn) + unserializable = cast(TResponseInputItem, {"role": "user", "content": object()}) + + with pytest.raises(TypeError): + await session.add_items([unserializable]) + + assert conn not in session._connections + assert _sqlite_write_lock_is_free(db_path) + + monkeypatch.setattr(session, "_get_connection", real_get_connection) + await session.add_items([{"role": "user", "content": "after failure"}]) + assert [item.get("content") for item in await session.get_items()] == ["after failure"] + session.close() + + +@pytest.mark.asyncio +async def test_sqlite_session_close_retries_quarantined_connection( + monkeypatch: pytest.MonkeyPatch, +): + """A failed invalidation close must remain owned until a later close succeeds.""" + + class FailingRollbackAndCloseConnection(sqlite3.Connection): + fail_close = True + + def rollback(self) -> None: + raise RuntimeError("rollback failed") + + def close(self) -> None: + if self.fail_close: + raise RuntimeError("close failed") + super().close() + + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "close_retry.db" + session = SQLiteSession("close_retry", db_path) + conn = sqlite3.connect( + str(db_path), + check_same_thread=False, + factory=FailingRollbackAndCloseConnection, + ) + with session._connections_lock: + session._connections.add(conn) + monkeypatch.setattr(session, "_get_connection", lambda: conn) + unserializable = cast(TResponseInputItem, {"role": "user", "content": object()}) + + with pytest.raises(TypeError): + await session.add_items([unserializable]) + + assert session._closed is True + assert conn in session._quarantined_connections + assert _sqlite_write_lock_is_free(db_path) is False + + conn.fail_close = False + session.close() + + assert session._quarantined_connections == set() + assert _sqlite_write_lock_is_free(db_path) + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["add", "pop", "clear"]) +async def test_sqlite_session_post_commit_cancellation_propagates_after_known_outcome( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + operation: str, +): + """Cancellation after a worker commit must propagate without inviting a retry.""" + + class PausingCommitConnection(sqlite3.Connection): + pause_commit = False + commit_finished = threading.Event() + allow_return = threading.Event() + + def commit(self) -> None: + super().commit() + if self.pause_commit: + self.pause_commit = False + self.commit_finished.set() + assert self.allow_return.wait(timeout=10) + + db_path = tmp_path / f"post_commit_{operation}.db" + session = SQLiteSession(f"post_commit_{operation}", db_path) + item: TResponseInputItem = {"role": "user", "content": "once"} + if operation != "add": + await session.add_items([item]) + + conn = sqlite3.connect( + str(db_path), + check_same_thread=False, + factory=PausingCommitConnection, + ) + with session._connections_lock: + session._connections.add(conn) + monkeypatch.setattr(session, "_get_connection", lambda: conn) + conn.pause_commit = True + + if operation == "add": + mutation: asyncio.Task[Any] = asyncio.create_task(session.add_items([item])) + elif operation == "pop": + mutation = asyncio.create_task(session.pop_item()) + else: + mutation = asyncio.create_task(session.clear_session()) + + try: + assert await asyncio.to_thread(conn.commit_finished.wait, 10) + mutation.cancel() + await asyncio.sleep(0) + mutation.cancel() + await asyncio.sleep(0) + conn.allow_return.set() + with pytest.raises(asyncio.CancelledError): + await mutation + finally: + conn.allow_return.set() + if not mutation.done(): + mutation.cancel() + await asyncio.gather(mutation, return_exceptions=True) + + if operation == "add": + assert await session.get_items() == [item] + elif operation == "pop": + assert await session.get_items() == [] + else: + assert await session.get_items() == [] + assert mutation.cancelled() + session.close() From 19f6bde526ed4700664a844a8e1ce2be3713174c Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 6 Aug 2026 02:05:33 -0500 Subject: [PATCH 197/473] fix(models): propagate the OpenAI request ID on the Chat Completions path (#4243) --- src/agents/models/openai_chatcompletions.py | 28 ++++ tests/models/test_openai_chatcompletions.py | 60 +++++++++ .../test_openai_chatcompletions_stream.py | 120 ++++++++++++++++++ 3 files changed, 208 insertions(+) diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 39f7d71388..8b6c77a557 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -348,8 +348,34 @@ async def get_response( output=items, usage=usage, response_id=None, + # The OpenAI SDK records the `x-request-id` header on every parsed response, + # so callers can inspect the same debugging handle as on the Responses path. + request_id=getattr(response, "_request_id", None), ) + @staticmethod + def _attach_stream_request_id(response: Response, stream: Any) -> None: + """Copy the OpenAI request ID onto the synthesized streamed response. + + The streamed Chat Completions response is built locally rather than returned by the + API, so the `x-request-id` header has to be carried over from the underlying HTTP + response. The terminal response is a `model_copy()` of this object, and that copy + preserves the private attribute, so `Runner` can read it back. Custom clients and + test doubles may yield a bare async iterator with no HTTP response attached. + """ + headers = getattr(getattr(stream, "response", None), "headers", None) + if headers is None: + return + request_id = headers.get("x-request-id") + if request_id is None: + return + try: + response._request_id = request_id + except Exception: + # Matches the Responses adapter: a custom response object that rejects the + # attribute must not break the stream for a debugging field. + return + def _attach_logprobs_to_output( self, output_items: list[ResponseOutputItem], logprobs: list[Logprob] ) -> None: @@ -409,6 +435,8 @@ async def stream_response( prompt=None, ) + self._attach_stream_request_id(response, stream) + final_response: Response | None = None stream_for_handler: AsyncIterator[ChatCompletionChunk] if self._buffer_streamed_tool_calls: diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 2f1c13f7cd..9364025275 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -7,6 +7,7 @@ import httpx import pytest from openai import APIConnectionError, APIStatusError, AsyncOpenAI, omit +from openai._models import add_request_id from openai.types.chat.chat_completion import ChatCompletion, Choice, ChoiceLogprobs from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from openai.types.chat.chat_completion_message import ChatCompletionMessage @@ -1363,3 +1364,62 @@ def __init__(self): assert ChatCmplHelpers.get_store_param(client, model_settings) is True, ( "Should respect explicitly set store=True" ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_propagates_request_id(monkeypatch) -> None: + """The OpenAI request ID must reach `ModelResponse.request_id` on the non-streamed path. + + The OpenAI SDK records the `x-request-id` header on every parsed response object, so + Chat Completions runs can expose the same debugging handle as Responses runs. + """ + chat = _minimal_chat_completion() + add_request_id(chat, "req_nonstreamed_123") + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + resp: ModelResponse = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert resp.request_id == "req_nonstreamed_123" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_request_id_is_none_when_absent(monkeypatch) -> None: + """Clients and test doubles that never set `_request_id` keep returning `None`.""" + chat = _minimal_chat_completion() + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + resp: ModelResponse = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert resp.request_id is None diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 1b5ead93b6..2f9e1c485c 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -3545,3 +3545,123 @@ async def source() -> AsyncIterator[ChatCompletionChunk]: ] assert len(finish_choices) == 1 assert finish_choices[0].delta.tool_calls + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_propagates_request_id(monkeypatch) -> None: + """The OpenAI request ID must reach the terminal streamed response. + + `Runner` reads `_request_id` off the terminal response to populate + `ModelResponse.request_id`, so the streamed Chat Completions path has to carry the + `x-request-id` header from the underlying HTTP response. + """ + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Hello"))], + ) + + class FakeStream: + """Mimics `openai.AsyncStream`, which exposes the raw HTTP response.""" + + def __init__(self) -> None: + self.response = httpx.Response( + 200, + headers={"x-request-id": "req_streamed_456"}, + request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"), + ) + + def __aiter__(self) -> AsyncIterator[ChatCompletionChunk]: + async def gen() -> AsyncIterator[ChatCompletionChunk]: + yield chunk + + return gen() + + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, FakeStream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + completed: ResponseCompletedEvent | None = None + async for event in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + if event.type == "response.completed": + completed = event + + assert completed is not None + assert getattr(completed.response, "_request_id", None) == "req_streamed_456" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_without_http_response_has_no_request_id(monkeypatch) -> None: + """Custom clients and test doubles that yield a bare async iterator still stream.""" + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Hello"))], + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + yield chunk + + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, fake_stream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + completed: ResponseCompletedEvent | None = None + async for event in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + if event.type == "response.completed": + completed = event + + assert completed is not None + assert getattr(completed.response, "_request_id", None) is None From f3b6c617853880b6dbad16b58ff9d071d5756afb Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 6 Aug 2026 03:02:04 -0500 Subject: [PATCH 198/473] fix(sandbox): keep move_to when coercing apply_patch operation mappings (#4242) --- src/agents/sandbox/apply_patch.py | 6 ++++ tests/sandbox/test_apply_patch.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/agents/sandbox/apply_patch.py b/src/agents/sandbox/apply_patch.py index 0262a64fed..304c29eeca 100644 --- a/src/agents/sandbox/apply_patch.py +++ b/src/agents/sandbox/apply_patch.py @@ -208,6 +208,7 @@ def _coerce_operation_mapping(operation: dict[str, object]) -> ApplyPatchOperati raw_path = operation.get("path") raw_diff = operation.get("diff") raw_ctx_wrapper = operation.get("ctx_wrapper") + raw_move_to = operation.get("move_to") if raw_type not in {"create_file", "update_file", "delete_file"}: raise ApplyPatchDiffError( @@ -221,11 +222,16 @@ def _coerce_operation_mapping(operation: dict[str, object]) -> ApplyPatchOperati raise ApplyPatchDiffError( message=f"Invalid apply_patch diff type: {type(raw_diff).__name__}" ) + if raw_move_to is not None and not isinstance(raw_move_to, str): + raise ApplyPatchDiffError( + message=f"Invalid apply_patch move_to type: {type(raw_move_to).__name__}" + ) return ApplyPatchOperation( type=cast(ApplyPatchOperationType, raw_type), path=raw_path, diff=raw_diff, ctx_wrapper=cast(Any, raw_ctx_wrapper), + move_to=raw_move_to, ) diff --git a/tests/sandbox/test_apply_patch.py b/tests/sandbox/test_apply_patch.py index 34a5471ae9..c62d3c07de 100644 --- a/tests/sandbox/test_apply_patch.py +++ b/tests/sandbox/test_apply_patch.py @@ -262,3 +262,57 @@ async def test_apply_patch_supports_non_default_root() -> None: ) assert session.files[Path("/custom-workspace/new.txt")] == b"hello" + + +@pytest.mark.asyncio +async def test_apply_patch_mapping_operation_moves_file() -> None: + session = ApplyPatchSession() + session.files[Path("/workspace/old.txt")] = b"alpha\n" + + result = await session.apply_patch( + { + "type": "update_file", + "path": "old.txt", + "diff": "@@\n-alpha\n+beta\n", + "move_to": "renamed/new.txt", + } + ) + + assert result == "Done!" + assert session.files[Path("/workspace/renamed/new.txt")] == b"beta\n" + assert Path("/workspace/old.txt") not in session.files + + +@pytest.mark.asyncio +async def test_apply_patch_mapping_operation_without_move_to_updates_in_place() -> None: + session = ApplyPatchSession() + session.files[Path("/workspace/keep.txt")] = b"alpha\n" + + await session.apply_patch( + { + "type": "update_file", + "path": "keep.txt", + "diff": "@@\n-alpha\n+beta\n", + } + ) + + assert session.files[Path("/workspace/keep.txt")] == b"beta\n" + assert session.rm_calls == [] + + +@pytest.mark.asyncio +async def test_apply_patch_mapping_operation_rejects_non_string_move_to() -> None: + session = ApplyPatchSession() + session.files[Path("/workspace/old.txt")] = b"alpha\n" + + with pytest.raises(ApplyPatchDiffError): + await session.apply_patch( + { + "type": "update_file", + "path": "old.txt", + "diff": "@@\n-alpha\n+beta\n", + "move_to": 5, + } + ) + + assert session.files[Path("/workspace/old.txt")] == b"alpha\n" From 070b6e15437be3eff700189ff633ba4bdf0f8873 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 08:06:57 +0900 Subject: [PATCH 199/473] perf: accelerate developer checks (#4258) --- Makefile | 4 ++-- src/agents/run.py | 8 +++++++- tests/README.md | 6 ++++-- .../memory/test_advanced_sqlite_session.py | 19 ++++++++++++------- tests/extensions/memory/test_redis_session.py | 10 +++++----- .../memory/test_sqlalchemy_session.py | 12 +++++++++--- 6 files changed, 39 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index b6ac796d2f..ed7fa8a814 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ mypy: .PHONY: pyright pyright: - uv run pyright --project pyrightconfig.json $(if $(TYPECHECK_SRC_ONLY),src,) + uv run pyright --project pyrightconfig.json --threads "$${PYRIGHT_THREADS:-4}" $(if $(TYPECHECK_SRC_ONLY),src,) .PHONY: typecheck typecheck: @@ -54,7 +54,7 @@ tests-asyncio-stability: .PHONY: tests-parallel tests-parallel: - uv run pytest -n auto --dist worksteal -m "not serial" + uv run pytest -n "$${PYTEST_XDIST_AUTO_NUM_WORKERS:-auto}" $(if $(PYTEST_XDIST_AUTO_NUM_WORKERS),,--maxprocesses=9) --dist worksteal -m "not serial" .PHONY: tests-serial tests-serial: diff --git a/src/agents/run.py b/src/agents/run.py index e163f8bf16..1b8f61de14 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -3,7 +3,7 @@ import asyncio import contextlib import warnings -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from typing_extensions import Unpack @@ -849,6 +849,12 @@ def _finalize_result(result: RunResult) -> RunResult: try: while True: + if TYPE_CHECKING: + # Keep loop-carried types explicit to bound Pyright's flow analysis. + original_input = cast( # type: ignore[redundant-cast] + str | list[TResponseInputItem], original_input + ) + run_state = cast(RunState[TContext] | None, run_state) resuming_turn = is_resumed_state all_input_guardrails = ( starting_agent.input_guardrails + (run_config.input_guardrails or []) diff --git a/tests/README.md b/tests/README.md index 6a8d83d1e4..4eea328a92 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,9 @@ Before running any tests, make sure you have `uv` installed (and ideally run `ma make tests ``` -`make tests` runs the shard-safe suite first, then runs the tests marked `serial` after all xdist workers have exited. The serial runner limits collection to test files containing the literal `pytest.mark.serial`, so keep that literal marker in every file containing serial tests. For indirect or custom serial marker spellings, use `uv run pytest -m serial` to perform generic pytest collection. +`make tests` runs the shard-safe suite first with pytest-xdist using up to nine workers, then runs the tests marked `serial` after all xdist workers have exited. Set `PYTEST_XDIST_AUTO_NUM_WORKERS` to a positive integer to override the automatic worker count and cap. The serial runner limits collection to test files containing the literal `pytest.mark.serial`, so keep that literal marker in every file containing serial tests. For indirect or custom serial marker spellings, use `uv run pytest -m serial` to perform generic pytest collection. + +`make typecheck` runs mypy and pyright concurrently. Pyright uses four analysis threads by default; set `PYRIGHT_THREADS` to a positive integer to override the local thread count. The speedup does not remove either analyzer or narrow its selected project or source scope. ## Performance and determinism @@ -30,7 +32,7 @@ Measure performance changes with both focused and broad runs: ```bash uv run pytest tests/path/to/test_file.py --durations=10 -uv run pytest -n auto --dist worksteal -m "not serial" --durations=20 +make tests-parallel ``` Compare test counts, skips, warnings, assertions, and lifecycle coverage as well as elapsed time. Full-suite wall-clock results depend on host load and worker scheduling, so treat repeated focused measurements as the stronger evidence for an individual optimization. Run the repository's required verification stack after the final test changes. diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 389d174e68..2aa40200fa 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -33,6 +33,11 @@ pytestmark = pytest.mark.asyncio +def _multiprocessing_context() -> Any: + method = "spawn" if sys.platform == "win32" else "forkserver" + return multiprocessing.get_context(method) + + def _assert_cancel_message(exc: asyncio.CancelledError, expected: str) -> None: """Account for Python 3.10 dropping Task cancellation messages when re-awaited.""" expected_args = (expected,) if sys.version_info >= (3, 11) else () @@ -1335,7 +1340,7 @@ async def test_branch_allocation_is_serialized_across_processes( await setup_session.add_items(items) setup_session.close() - context = multiprocessing.get_context("spawn") + context = _multiprocessing_context() results = context.Queue() release_check = context.Event() processes = [] @@ -1370,12 +1375,12 @@ async def test_branch_allocation_is_serialized_across_processes( first_ready, first_start, _, first_checked = worker_events[0] second_ready, second_start, second_attempted, second_checked = worker_events[1] - assert first_ready.wait(timeout=10) + assert first_ready.wait(timeout=30) first_start.set() - assert first_checked.wait(timeout=10) - assert second_ready.wait(timeout=10) + assert first_checked.wait(timeout=30) + assert second_ready.wait(timeout=30) second_start.set() - assert second_attempted.wait(timeout=10) + assert second_attempted.wait(timeout=30) # The second process has entered branch creation, but SQLite's write transaction # must keep it from reserving an ID until the first process commits. @@ -3088,7 +3093,7 @@ async def test_pop_item_claim_is_unique_across_processes(tmp_path: Path): await setup.add_items([item]) setup.close() - context = multiprocessing.get_context("spawn") + context = _multiprocessing_context() start = context.Event() results = context.Queue() ready_events = [context.Event(), context.Event()] @@ -3104,7 +3109,7 @@ async def test_pop_item_claim_is_unique_across_processes(tmp_path: Path): for process in processes: process.start() for ready in ready_events: - assert ready.wait(timeout=10) + assert ready.wait(timeout=30) start.set() for process in processes: process.join(timeout=10) diff --git a/tests/extensions/memory/test_redis_session.py b/tests/extensions/memory/test_redis_session.py index 8a6f35eee2..c7f2b292db 100644 --- a/tests/extensions/memory/test_redis_session.py +++ b/tests/extensions/memory/test_redis_session.py @@ -753,9 +753,11 @@ async def test_get_next_id_method(): await session.close() -async def test_add_items_preserves_created_at_metadata(): +async def test_add_items_preserves_created_at_metadata(monkeypatch: pytest.MonkeyPatch): """`created_at` must be set once and not overwritten by subsequent add_items calls.""" session = await _create_test_session("created_at_test") + current_time = 1_000 + monkeypatch.setattr("agents.extensions.memory.redis_session.time.time", lambda: current_time) try: await session.clear_session() @@ -764,10 +766,8 @@ async def test_add_items_preserves_created_at_metadata(): first_created = first_meta.get(b"created_at") or first_meta.get("created_at") assert first_created is not None - # Force a clock advance so a regression would surface as a different value. - import time - - time.sleep(1.1) + # Advance the controlled clock so a regression would surface as a different value. + current_time += 1 await session.add_items([{"role": "user", "content": "second"}]) second_meta = await session._redis.hgetall(session._session_key) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index 7019b000c4..f18d803328 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -1304,6 +1304,7 @@ async def test_sqlite_configuration_registry_releases_collected_engines(tmp_path async def test_sqlite_configuration_registry_does_not_grow_unbounded(tmp_path): """Short-lived SQLite sessions must not accumulate registry entries.""" baseline = len(SQLAlchemySession._sqlite_configured_engines) + created_engine_keys: list[int] = [] for index in range(25): db_url = f"sqlite+aiosqlite:///{tmp_path / f'sqlite_registry_growth_{index}.db'}" @@ -1312,9 +1313,14 @@ async def test_sqlite_configuration_registry_does_not_grow_unbounded(tmp_path): url=db_url, create_tables=True, ) + engine = session.engine + created_engine_keys.append(id(engine.sync_engine)) await session.add_items([{"role": "user", "content": f"turn {index}"}]) - await session.engine.dispose() + await engine.dispose() del session - gc.collect() + del engine + + gc.collect() - assert len(SQLAlchemySession._sqlite_configured_engines) == baseline + assert SQLAlchemySession._sqlite_configured_engines.isdisjoint(created_engine_keys) + assert len(SQLAlchemySession._sqlite_configured_engines) <= baseline From 53a461f54e15bc08cf554d3ca162ed01241ee657 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 08:57:52 +0900 Subject: [PATCH 200/473] ci: cache mypy results across typecheck runs --- .github/workflows/tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a61c28bec1..8120f2b1ff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,14 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true + - name: Restore mypy cache + if: steps.changes.outputs.run == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: .mypy_cache + key: mypy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'Makefile') }}-${{ github.sha }} + restore-keys: | + mypy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'Makefile') }}- - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync From 6e0dffb3879e3ee4aec0c89a3d84a3b4d6ef73c5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 09:10:04 +0900 Subject: [PATCH 201/473] ci: upgrade actions/cache to the latest --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8120f2b1ff..ec9d27ee7f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,7 +59,7 @@ jobs: prune-cache: true - name: Restore mypy cache if: steps.changes.outputs.run == 'true' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .mypy_cache key: mypy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'Makefile') }}-${{ github.sha }} From 0342746cf8578576a4cc2e7734efa28743c9eb7d Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 6 Aug 2026 19:29:26 -0500 Subject: [PATCH 202/473] fix(extensions): forward prompt_cache_retention on the any-llm Responses path (#4248) --- src/agents/extensions/models/any_llm_model.py | 1 + tests/models/test_any_llm_model.py | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 39ea7f5874..74538bcd7d 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -1027,6 +1027,7 @@ async def _fetch_responses_response( "stream": stream, "truncation": model_settings.truncation, "store": model_settings.store, + "prompt_cache_retention": model_settings.prompt_cache_retention, "previous_response_id": previous_response_id, "conversation": conversation_id, "include": include, diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index ae9e150627..b7788da2a2 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -1179,6 +1179,65 @@ async def test_any_llm_responses_path_omits_reasoning_when_unset() -> None: assert provider.private_responses_calls[0]["params"].reasoning is None +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_any_llm_responses_path_forwards_prompt_cache_retention(stream: bool) -> None: + """`ModelSettings.prompt_cache_retention` must reach the any-llm Responses request.""" + pytest.importorskip( + "any_llm", + reason="`any-llm-sdk` is only available when the optional dependency is installed.", + ) + + provider = _RecordingResponsesProvider(_response("Hello")) + model = _model_bound_to_provider(provider) + + await cast(Any, model)._fetch_responses_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(prompt_cache_retention="24h"), + tools=[], + output_schema=None, + handoffs=[], + previous_response_id=None, + conversation_id=None, + stream=stream, + prompt=None, + ) + + assert len(provider.private_responses_calls) == 1 + assert provider.private_responses_calls[0]["params"].prompt_cache_retention == "24h" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_path_omits_prompt_cache_retention_when_unset() -> None: + """An unset retention stays unset instead of pinning a default on the request.""" + pytest.importorskip( + "any_llm", + reason="`any-llm-sdk` is only available when the optional dependency is installed.", + ) + + provider = _RecordingResponsesProvider(_response("Hello")) + model = _model_bound_to_provider(provider) + + await cast(Any, model)._fetch_responses_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + previous_response_id=None, + conversation_id=None, + stream=False, + prompt=None, + ) + + assert len(provider.private_responses_calls) == 1 + assert provider.private_responses_calls[0]["params"].prompt_cache_retention is None + + def test_any_llm_provider_passes_api_override() -> None: pytest.importorskip( "any_llm", From 2ec632fef312a79e9bb47b455e9d6986ab4024f6 Mon Sep 17 00:00:00 2001 From: Coleby Pearson Date: Thu, 6 Aug 2026 20:32:26 -0400 Subject: [PATCH 203/473] docs: voice quickstart also needs sounddevice (#4250) --- docs/voice/quickstart.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index 125c14998e..aff583ab57 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -8,6 +8,12 @@ Make sure you've followed the base [quickstart instructions](../quickstart.md) f pip install 'openai-agents[voice]' ``` +The demo code below also uses [`sounddevice`](https://pypi.org/project/sounddevice/) for microphone and speaker I/O, which is not part of the `voice` extra: + +```bash +pip install sounddevice +``` + ## Concepts The main concept to know about is a [`VoicePipeline`][agents.voice.pipeline.VoicePipeline], which is a 3 step process: From b42ead5c119738afd57f44d0190f088af441e515 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 6 Aug 2026 19:38:31 -0500 Subject: [PATCH 204/473] fix(run): work on copies of the lists a resumed run adopts from RunState (#4251) --- src/agents/run.py | 12 ++++-- tests/test_run_state.py | 92 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 1b8f61de14..7ac81be072 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -690,9 +690,11 @@ async def run( run_state._nested_history_owned_session_item_refs, ) run_state._original_input = copy_input_items(original_input) - generated_items = run_state._generated_items + # Copy every list adopted from the state: the run appends to these, and + # the caller still owns the state as a resumable snapshot. + generated_items = list(run_state._generated_items) session_items = list(run_state._session_items) - model_responses = run_state._model_responses + model_responses = list(run_state._model_responses) # Cast to the correct type since we know this is TContext context_wrapper = cast(RunContextWrapper[TContext], run_state._context) else: @@ -2010,8 +2012,10 @@ def run_streamed( streamed_result = RunResultStreaming( input=copy_input_items(streamed_input), # When resuming from RunState, use session_items from state. - # primeFromState will mark items as sent so prepareInput skips them - new_items=run_state._session_items if run_state else [], + # primeFromState will mark items as sent so prepareInput skips them. + # Copy it: the streamed loop appends to new_items, and the caller still + # owns the state as a resumable snapshot. + new_items=list(run_state._session_items) if run_state else [], current_agent=schema_agent, raw_responses=run_state._model_responses if run_state else [], final_output=None, diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 35c3d450b3..a6bad8a4b0 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -67,6 +67,7 @@ TResponseStreamEvent, ) from agents.run_context import RunContextWrapper +from agents.run_error_handlers import RunErrorHandlerResult, RunErrorHandlers from agents.run_internal.agent_runner_helpers import resolve_trace_settings from agents.run_internal.items import ( NestedHistoryOwnedItemRef, @@ -3198,6 +3199,97 @@ async def test_resume_from_run_state_does_not_mutate_source_result(self): assert len(result1.raw_responses) == 1 assert result1.raw_responses is not result2.raw_responses + @pytest.mark.asyncio + async def test_resume_does_not_append_to_the_state_it_resumed_from(self): + """A resumed run must not accumulate its responses into the caller's checkpoint.""" + model = FakeModel() + agent = Agent(name="TestAgent", model=model) + + model.set_next_output([get_text_message("First response")]) + result1 = await Runner.run(agent, "First input") + state = result1.to_state() + serialized_before = state.to_json()["model_responses"] + + model.set_next_output([get_text_message("Second response")]) + result2 = await Runner.run(agent, state) + assert len(result2.raw_responses) == 2 + + # The state is a snapshot of the first turn, so the second run's response must + # not land in it, neither in memory nor in the serialized snapshot. + assert len(state._model_responses) == 1 + assert state.to_json()["model_responses"] == serialized_before + + # Re-running the same checkpoint therefore replays only its own history. + model.set_next_output([get_text_message("Third response")]) + result3 = await Runner.run(agent, state) + assert len(result3.raw_responses) == 2 + + @pytest.mark.asyncio + async def test_streamed_resume_does_not_append_to_the_state_it_resumed_from(self): + """A streamed resume must not accumulate its items into the caller's checkpoint.""" + model = FakeModel() + agent = Agent(name="TestAgent", model=model) + + model.set_next_output([get_text_message("First response")]) + result1 = await Runner.run(agent, "First input") + state = result1.to_state() + serialized_before = state.to_json()["session_items"] + + model.set_next_output([get_text_message("Second response")]) + result2 = Runner.run_streamed(agent, state) + async for _ in result2.stream_events(): + pass + assert len(result2.new_items) == 2 + + assert len(state._session_items) == 1 + assert state.to_json()["session_items"] == serialized_before + + # Without this, the abandoned attempt's message leaks into the replayed history. + model.set_next_output([get_text_message("Third response")]) + result3 = Runner.run_streamed(agent, state) + async for _ in result3.stream_events(): + pass + assert len(result3.new_items) == 2 + assert len(result3.to_input_list()) == 3 + + @pytest.mark.asyncio + async def test_resumed_max_turns_handler_does_not_append_to_state_items(self): + """A resumed run that trips max turns must not append to the state's items.""" + model = FakeModel() + agent = Agent(name="TestAgent", model=model) + + model.set_next_output([get_text_message("First response")]) + result1 = await Runner.run(agent, "First input", max_turns=1) + state = result1.to_state() + serialized_before = state.to_json()["generated_items"] + + handlers: RunErrorHandlers[Any] = { + "max_turns": lambda _input: RunErrorHandlerResult(final_output="fallback") + } + result2 = await Runner.run(agent, state, error_handlers=handlers) + assert result2.final_output == "fallback" + + assert len(state._generated_items) == 1 + assert state.to_json()["generated_items"] == serialized_before + + @pytest.mark.asyncio + async def test_fresh_runs_still_report_their_own_history(self): + """Boundary: a run that starts without a state is unaffected by the copies.""" + model = FakeModel() + agent = Agent(name="TestAgent", model=model) + + model.set_next_output([get_text_message("First response")]) + result1 = await Runner.run(agent, "First input") + assert len(result1.raw_responses) == 1 + assert len(result1.new_items) == 1 + + model.set_next_output([get_text_message("Streamed response")]) + result2 = Runner.run_streamed(agent, "Second input") + async for _ in result2.stream_events(): + pass + assert len(result2.raw_responses) == 1 + assert len(result2.new_items) == 1 + @pytest.mark.asyncio async def test_resume_from_run_state_with_context(self): """Test resuming a run from a RunState with context override.""" From 20dd205d5d1d2340f3f0693eced4570393714d66 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 6 Aug 2026 19:46:37 -0500 Subject: [PATCH 205/473] fix(sessions): preserve required program item ids for OpenAI conversations (#4253) --- .../run_internal/session_persistence.py | 2 + .../test_openai_conversations_session.py | 73 ++++++++++++++++++- .../test_session_persistence_sanitize.py | 44 +++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index b9dc6449ad..ae4a369a52 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -772,6 +772,8 @@ def _ignore_ids_for_matching(session: Session) -> bool: "mcp_approval_request", "mcp_call", "item_reference", + "program", + "program_output", } ) diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index 1f0160bc01..42d3716220 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -3,12 +3,19 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest - -from agents import Agent, Runner, TResponseInputItem +from openai.types.responses.response_output_item import Program, ProgramOutput + +from agents import ( + Agent, + ProgrammaticToolCallingTool, + Runner, + TResponseInputItem, + function_tool, +) from agents.memory.openai_conversations_session import ( OpenAIConversationsSession, start_openai_conversations_session, @@ -456,6 +463,66 @@ async def test_runner_with_conversation_history(self, agent: Agent, mock_openai_ input_contents = [str(item.get("content", "")) for item in last_input] assert any("Golden Gate Bridge" in content for content in input_contents) + @pytest.mark.asyncio + async def test_runner_persists_program_item_ids(self, mock_openai_client): + """Program items keep the id the Conversations create-item schema requires.""" + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + Program( + id="program_item", + call_id="call_program", + code='lookup_inventory(sku="A-1")', + fingerprint="fingerprint", + type="program", + ), + ], + [ + ProgramOutput( + id="program_output_item", + call_id="call_program", + result='{"sku":"A-1","available_units":42}', + status="completed", + type="program_output", + ), + get_text_message("done"), + ], + ] + ) + + @function_tool(allowed_callers=["programmatic"]) + def lookup_inventory(sku: str) -> str: + return sku + + program_agent = Agent( + name="inventory", + model=model, + tools=[ProgrammaticToolCallingTool(), lookup_inventory], + ) + session = OpenAIConversationsSession(openai_client=mock_openai_client) + + saved: list[TResponseInputItem] = [] + + async def record(items: list[TResponseInputItem]) -> None: + saved.extend(items) + + with patch.object(session, "get_items", return_value=[]): + with patch.object(session, "add_items", side_effect=record): + result = await Runner.run(program_agent, "Check inventory", session=session) + + assert result.final_output == "done" + + saved_items = { + item["type"]: item + for item in cast(list[dict[str, Any]], saved) + if isinstance(item, dict) and "type" in item + } + assert saved_items["program"]["id"] == "program_item" + assert saved_items["program_output"]["id"] == "program_output_item" + # Item types whose id the Conversations schema leaves optional stay stripped. + assert "id" not in saved_items["message"] + class TestOpenAIConversationsSessionErrorHandling: """Test error handling for various failure scenarios.""" diff --git a/tests/memory/test_session_persistence_sanitize.py b/tests/memory/test_session_persistence_sanitize.py index bae7d3348d..b870018432 100644 --- a/tests/memory/test_session_persistence_sanitize.py +++ b/tests/memory/test_session_persistence_sanitize.py @@ -26,6 +26,8 @@ def _sanitize(item: dict[str, Any]) -> dict[str, Any]: "mcp_approval_request", "mcp_call", "item_reference", + "program", + "program_output", ], ) def test_sanitize_preserves_ids_required_by_openai_conversation_items(item_type: str) -> None: @@ -53,6 +55,40 @@ def test_sanitize_preserves_file_search_call_payload_id() -> None: assert sanitized["status"] == "completed" +def test_sanitize_preserves_program_payload_id() -> None: + item = { + "type": "program", + "id": "program_abc", + "call_id": "call_program", + "code": 'lookup_inventory(sku="A-1")', + "fingerprint": "fingerprint", + } + + sanitized = _sanitize(item) + + assert sanitized["id"] == "program_abc" + assert sanitized["call_id"] == "call_program" + assert sanitized["code"] == 'lookup_inventory(sku="A-1")' + assert sanitized["fingerprint"] == "fingerprint" + + +def test_sanitize_preserves_program_output_payload_id() -> None: + item = { + "type": "program_output", + "id": "program_output_abc", + "call_id": "call_program", + "result": '{"available_units":42}', + "status": "completed", + } + + sanitized = _sanitize(item) + + assert sanitized["id"] == "program_output_abc" + assert sanitized["call_id"] == "call_program" + assert sanitized["result"] == '{"available_units":42}' + assert sanitized["status"] == "completed" + + @pytest.mark.parametrize( "item", [ @@ -73,6 +109,14 @@ def test_sanitize_preserves_file_search_call_payload_id() -> None: {"type": "computer_call_output", "id": "ccout_abc", "call_id": "call_abc", "output": {}}, {"type": "tool_search_call", "id": "ts_abc", "status": "completed"}, {"type": "shell_call", "id": "sh_abc", "call_id": "call_abc", "action": {}}, + { + "type": "function_call", + "id": "fc_prog", + "call_id": "call_abc", + "name": "get_weather", + "arguments": "{}", + "caller": {"type": "program", "caller_id": "call_program"}, + }, ], ) def test_sanitize_strips_optional_or_policy_controlled_ids(item: dict[str, Any]) -> None: From 191722fd81775606e74e23d13f57eb1e93a4db8a Mon Sep 17 00:00:00 2001 From: Lucca Boas <86315612+Luccacvb@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:46:41 -0300 Subject: [PATCH 206/473] fix(models): keep url citations on the streamed chat completions path (#4252) --- src/agents/models/chatcmpl_converter.py | 18 +- src/agents/models/chatcmpl_helpers.py | 49 ++++- src/agents/models/chatcmpl_stream_handler.py | 10 + .../test_openai_chatcompletions_stream.py | 174 ++++++++++++++++++ 4 files changed, 234 insertions(+), 17 deletions(-) diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index e38f5f4075..8f51ed5eee 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -44,7 +44,6 @@ from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message from openai.types.responses.response_output_text import ( Annotation as ResponseOutputTextAnnotation, - AnnotationURLCitation, ) from openai.types.responses.response_reasoning_item import Content, Summary @@ -60,6 +59,7 @@ ensure_function_tool_supports_responses_only_features, ensure_tool_choice_supports_backend, ) +from .chatcmpl_helpers import ChatCmplHelpers from .fake_id import FAKE_RESPONSES_ID from .reasoning_content_replay import ( ReasoningContentReplayContext, @@ -264,21 +264,7 @@ def _convert_annotations( cls, message: ChatCompletionMessage ) -> list[ResponseOutputTextAnnotation]: """Convert Chat Completions url citations into output text annotations.""" - annotations: list[ResponseOutputTextAnnotation] = [] - for annotation in message.annotations or []: - url_citation = getattr(annotation, "url_citation", None) - if getattr(annotation, "type", None) != "url_citation" or url_citation is None: - continue - annotations.append( - AnnotationURLCitation( - type="url_citation", - start_index=url_citation.start_index, - end_index=url_citation.end_index, - url=url_citation.url, - title=url_citation.title, - ) - ) - return annotations + return ChatCmplHelpers.convert_url_citations(message.annotations) @classmethod def maybe_easy_input_message(cls, item: Any) -> EasyInputMessageParam | None: diff --git a/src/agents/models/chatcmpl_helpers.py b/src/agents/models/chatcmpl_helpers.py index 487de8f3c8..d28ddcec2e 100644 --- a/src/agents/models/chatcmpl_helpers.py +++ b/src/agents/models/chatcmpl_helpers.py @@ -1,19 +1,36 @@ from __future__ import annotations +from collections.abc import Mapping from contextvars import ContextVar +from typing import Any from openai import AsyncOpenAI from openai.types.chat.chat_completion_token_logprob import ChatCompletionTokenLogprob -from openai.types.responses.response_output_text import Logprob, LogprobTopLogprob +from openai.types.responses.response_output_text import ( + Annotation as ResponseOutputTextAnnotation, + AnnotationURLCitation, + Logprob, + LogprobTopLogprob, +) from openai.types.responses.response_text_delta_event import ( Logprob as DeltaLogprob, LogprobTopLogprob as DeltaTopLogprob, ) +from pydantic import ValidationError +from ..logger import log_model_action_debug, logger from ..model_settings import ModelSettings from ..version import __version__ from .openai_client_utils import is_official_openai_client + +def _mapping_or_attr(source: Any, name: str) -> Any: + """Read a field from a value that is either a typed object or a plain mapping.""" + if isinstance(source, Mapping): + return source.get(name) + return getattr(source, name, None) + + _USER_AGENT = f"Agents/Python {__version__}" HEADERS = {"User-Agent": _USER_AGENT} @@ -100,6 +117,36 @@ def convert_logprobs_for_text_delta( ) return converted + @classmethod + def convert_url_citations(cls, raw_annotations: Any) -> list[ResponseOutputTextAnnotation]: + """Convert Chat Completions url citations into output text annotations.""" + # Providers report annotations as typed objects or as raw payloads, so validate + # rather than assume the declared shape. + if not isinstance(raw_annotations, list | tuple): + return [] + + annotations: list[ResponseOutputTextAnnotation] = [] + for annotation in raw_annotations: + url_citation = _mapping_or_attr(annotation, "url_citation") + if _mapping_or_attr(annotation, "type") != "url_citation" or url_citation is None: + continue + try: + annotations.append( + AnnotationURLCitation.model_validate( + { + "type": "url_citation", + "start_index": _mapping_or_attr(url_citation, "start_index"), + "end_index": _mapping_or_attr(url_citation, "end_index"), + "url": _mapping_or_attr(url_citation, "url"), + "title": _mapping_or_attr(url_citation, "title"), + } + ) + ) + except ValidationError as exc: + # A provider that reports an incomplete citation should not fail the turn. + log_model_action_debug(logger, "Skipping malformed url citation", exc) + return annotations + @classmethod def clean_gemini_tool_call_id(cls, tool_call_id: str, model: str | None = None) -> str: """Clean up litellm's __thought__ suffix from Gemini tool call IDs. diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 4cd0a65686..23dcd2f5d0 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -290,6 +290,9 @@ def _delta_has_passthrough_output(delta: ChoiceDelta | None) -> bool: if hasattr(delta, "thinking_blocks") and delta.thinking_blocks: return True + if getattr(delta, "annotations", None): + return True + return False @staticmethod @@ -871,6 +874,13 @@ async def handle_stream( # every content delta, which would be O(n^2) over a long stream. existing_logprobs.extend(output_logprobs) + # Handle url citations. These can arrive on the delta carrying the cited text + # or on a later one, so this sits outside the content branch above. + if state.text_content_index_and_output: + state.text_content_index_and_output[1].annotations.extend( + ChatCmplHelpers.convert_url_citations(getattr(delta, "annotations", None)) + ) + # Handle refusals (model declines to answer) # This is always set by the OpenAI API, but not by others e.g. LiteLLM if hasattr(delta, "refusal") and delta.refusal: diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 2f9e1c485c..7f4f939cc4 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -102,6 +102,50 @@ async def _collect_buffered_tool_call_chunks( ] +def _url_citation( + url: str = "https://example.com/weather", + title: str = "Weather", + start_index: int = 0, + end_index: int = 22, +) -> dict[str, Any]: + return { + "type": "url_citation", + "url_citation": { + "start_index": start_index, + "end_index": end_index, + "url": url, + "title": title, + }, + } + + +def _annotated_chunk( + delta_payload: dict[str, Any], finish_reason: str | None = None +) -> ChatCompletionChunk: + # `annotations` is not a declared field on ChoiceDelta, so it is built through + # model_validate to reach the object the same way a provider payload does. + return ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta.model_validate(delta_payload), + finish_reason=cast(Any, finish_reason), + ) + ], + ) + + +def _streamed_annotations(events: list[Any]) -> list[dict[str, Any]]: + completed = cast(ResponseCompletedEvent, events[-1]) + message = cast(ResponseOutputMessage, completed.response.output[0]) + text_part = cast(ResponseOutputText, message.content[0]) + return [annotation.model_dump() for annotation in text_part.annotations] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("use_dictionary", [False, True], ids=["model-settings", "dictionary"]) @@ -3665,3 +3709,133 @@ async def patched_fetch_response(self, *args, **kwargs): assert completed is not None assert getattr(completed.response, "_request_id", None) is None + + +@pytest.mark.asyncio +async def test_stream_handler_keeps_url_citations_on_the_text_delta() -> None: + """Citations reported alongside the text reach the output text, as when not streaming.""" + events = await _collect_handler_events( + _annotated_chunk( + { + "role": "assistant", + "content": "It will rain tomorrow.", + "annotations": [_url_citation()], + }, + finish_reason="stop", + ) + ) + + assert _streamed_annotations(events) == [ + { + "type": "url_citation", + "start_index": 0, + "end_index": 22, + "url": "https://example.com/weather", + "title": "Weather", + } + ] + + +@pytest.mark.asyncio +async def test_stream_handler_keeps_url_citations_reported_after_the_text() -> None: + """A provider may cite on a later delta, once the text the citation indexes is sent.""" + events = await _collect_handler_events( + _annotated_chunk({"role": "assistant", "content": "It will rain tomorrow."}), + _annotated_chunk({"annotations": [_url_citation()]}, finish_reason="stop"), + ) + + assert [annotation["url"] for annotation in _streamed_annotations(events)] == [ + "https://example.com/weather" + ] + + +@pytest.mark.asyncio +async def test_stream_handler_accumulates_url_citations_across_deltas() -> None: + """Citations accumulate rather than replace, as LiteLLM does in `stream_chunk_builder`. + + `delta.annotations` is undocumented, so a provider may spread citations over several + deltas or report them only on the last one, and accumulating keeps both cases whole. + A provider repeating its full list on every delta would report duplicates, which is + the same tradeoff LiteLLM makes. + """ + events = await _collect_handler_events( + _annotated_chunk( + { + "role": "assistant", + "content": "It will rain tomorrow.", + "annotations": [_url_citation()], + } + ), + _annotated_chunk( + {"annotations": [_url_citation(url="https://example.com/forecast", title="Forecast")]}, + finish_reason="stop", + ), + ) + + assert [annotation["url"] for annotation in _streamed_annotations(events)] == [ + "https://example.com/weather", + "https://example.com/forecast", + ] + + +@pytest.mark.asyncio +async def test_stream_handler_buffering_keeps_a_citation_only_delta() -> None: + """Tool call buffering must forward a delta whose only output is a citation.""" + chunks = await _collect_buffered_tool_call_chunks( + _annotated_chunk({"role": "assistant", "content": "It will rain tomorrow."}), + _annotated_chunk({"annotations": [_url_citation()]}, finish_reason="stop"), + ) + events = await _collect_handler_events(*chunks) + + assert [annotation["url"] for annotation in _streamed_annotations(events)] == [ + "https://example.com/weather" + ] + + +@pytest.mark.asyncio +async def test_stream_handler_skips_unsupported_annotation_shapes() -> None: + """An unsupported or incomplete citation is dropped instead of failing the turn.""" + other_type = {"type": "file_citation", "file_citation": {"file_id": "file-1", "index": 0}} + incomplete = {"type": "url_citation", "url_citation": {"url": "https://example.com/partial"}} + events = await _collect_handler_events( + _annotated_chunk( + { + "role": "assistant", + "content": "It will rain tomorrow.", + "annotations": [other_type, incomplete, _url_citation()], + }, + finish_reason="stop", + ) + ) + + assert [annotation["url"] for annotation in _streamed_annotations(events)] == [ + "https://example.com/weather" + ] + + +@pytest.mark.asyncio +async def test_stream_handler_ignores_annotations_that_are_not_a_sequence() -> None: + """The streamed field is untyped, so an unexpected shape must not fail the turn.""" + events = await _collect_handler_events( + _annotated_chunk( + {"role": "assistant", "content": "It will rain tomorrow.", "annotations": 5}, + finish_reason="stop", + ) + ) + + assert _streamed_annotations(events) == [] + + +@pytest.mark.asyncio +async def test_stream_handler_drops_citations_reported_before_any_text() -> None: + """Citations index into text, so one reported before any text part opens is dropped.""" + events = await _collect_handler_events( + _annotated_chunk({"role": "assistant", "annotations": [_url_citation()]}), + _annotated_chunk({"content": "It will rain tomorrow."}, finish_reason="stop"), + ) + + assert _streamed_annotations(events) == [] + completed = cast(ResponseCompletedEvent, events[-1]) + message = cast(ResponseOutputMessage, completed.response.output[0]) + assert len(message.content) == 1 + assert cast(ResponseOutputText, message.content[0]).text == "It will rain tomorrow." From 00c9d269153e005d04a3bca59bb1de456de70173 Mon Sep 17 00:00:00 2001 From: Ojas Sharma <67553823+ojassharma7@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:10:16 -0400 Subject: [PATCH 207/473] Tool approval is not honored on resume when `Runner.run` is given a context (#4245) --- src/agents/agent.py | 15 +- .../run_internal/agent_runner_helpers.py | 22 +- tests/test_run_state.py | 222 +++++++++++++++++- 3 files changed, 255 insertions(+), 4 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index 7768372cee..09dae267d0 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -856,6 +856,11 @@ def _find_mirrored_approval_record( context, pending_run_result.interruptions, ) + # Keep accumulating nested post-resume usage on the parent + # ToolContext accumulator. resolve_resumed_context only + # replaces application .context and would otherwise leave + # the restored nested wrapper on a detached Usage object. + resume_state._context.usage = context.usage consume_agent_tool_run_result( context.tool_call, scope_id=tool_state_scope_id, @@ -867,7 +872,10 @@ def _find_mirrored_approval_record( run_result_streaming = Runner.run_streamed( starting_agent=cast(Agent[Any], self), input=resume_state or resolved_input, - context=None if resume_state is not None else cast(Any, nested_context), + # On resume, pass the parent application context so + # resolve_resumed_context can update the nested restored + # wrapper's .context without dropping nested approvals. + context=cast(Any, nested_context), run_config=resolved_run_config, max_turns=resolved_max_turns, hooks=hooks, @@ -936,7 +944,10 @@ async def enqueue_stream_events() -> None: run_result = await Runner.run( starting_agent=cast(Agent[Any], self), input=resume_state or resolved_input, - context=None if resume_state is not None else cast(Any, nested_context), + # On resume, pass the parent application context so + # resolve_resumed_context can update the nested restored + # wrapper's .context without dropping nested approvals. + context=cast(Any, nested_context), run_config=resolved_run_config, max_turns=resolved_max_turns, hooks=hooks, diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 348908b79f..2803c4695e 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -283,8 +283,28 @@ def resolve_resumed_context( run_state: RunState[TContext], context: RunContextWrapper[TContext] | TContext | None, ) -> RunContextWrapper[TContext]: - """Return the context wrapper for a resumed run, overriding when provided.""" + """Return the context wrapper for a resumed run, overriding when provided. + + When an override is supplied, the restored ``RunContextWrapper`` stays + authoritative. Only its application ``context`` value is replaced so + run-owned wrapper state (approvals, usage, turn input, tool input, ...) + survives the override instead of being dropped by a fresh wrapper. + Nested ``Agent.as_tool()`` resumes should pass the parent application + context into ``Runner.run`` / ``Runner.run_streamed`` so this same path + applies there. + """ if context is not None: + existing_context = run_state._context + if existing_context is not None: + application_context = ( + context.context if isinstance(context, RunContextWrapper) else context + ) + if existing_context is not context: + existing_context.context = application_context + set_agent_tool_state_scope(existing_context, run_state._agent_tool_state_scope_id) + run_state._context = existing_context + return existing_context + context_wrapper = ensure_context_wrapper(context) set_agent_tool_state_scope(context_wrapper, run_state._agent_tool_state_scope_id) run_state._context = context_wrapper diff --git a/tests/test_run_state.py b/tests/test_run_state.py index a6bad8a4b0..45cd031aef 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -68,7 +68,10 @@ ) from agents.run_context import RunContextWrapper from agents.run_error_handlers import RunErrorHandlerResult, RunErrorHandlers -from agents.run_internal.agent_runner_helpers import resolve_trace_settings +from agents.run_internal.agent_runner_helpers import ( + resolve_resumed_context, + resolve_trace_settings, +) from agents.run_internal.items import ( NestedHistoryOwnedItemRef, digest_input_item, @@ -7287,3 +7290,220 @@ async def needs_ok(text: str) -> str: for item in resumed.new_items ) assert calls == [] + + +def test_resolve_resumed_context_keeps_restored_wrapper_and_replaces_app_context() -> None: + """Override must mutate the restored wrapper in place, not allocate a replacement.""" + from agents.run_context import _ApprovalRecord + + agent = Agent(name="unit-agent") + original_context = {"user": "original"} + restored_wrapper = RunContextWrapper(context=original_context) + restored_wrapper.tool_input = {"scoped": True} + restored_wrapper.turn_input = [{"role": "user", "content": "hi"}] + restored_usage = restored_wrapper.usage + restored_approvals = restored_wrapper._approvals + restored_approvals["needs_ok"] = _ApprovalRecord(approved=["1"]) + + state = make_state(agent, context=restored_wrapper, original_input="hi") + override = {"user": "reviewer"} + + resolved = resolve_resumed_context(run_state=state, context=override) + + assert resolved is restored_wrapper + assert resolved is state._context + assert resolved.context is override + assert resolved.context is not original_context + assert resolved.usage is restored_usage + assert resolved._approvals is restored_approvals + assert resolved._approvals["needs_ok"].approved == ["1"] + assert resolved.turn_input == [{"role": "user", "content": "hi"}] + assert resolved.tool_input == {"scoped": True} + + # Passing a wrapper only donates its application value; run-owned state stays. + donor = RunContextWrapper(context={"user": "from-wrapper"}) + donor.tool_input = {"should": "not-win"} + resolved_again = resolve_resumed_context(run_state=state, context=donor) + assert resolved_again is restored_wrapper + assert resolved_again.context == {"user": "from-wrapper"} + assert resolved_again.tool_input == {"scoped": True} + + +async def _interrupted_approval_state_with_tool_input( + *, + calls: list[str], + seen_contexts: list[dict[str, str]], + seen_tool_inputs: list[object], +) -> tuple[Any, Any, RunState[Any, Agent[Any]]]: + @function_tool(needs_approval=True) + async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: + seen_contexts.append(dict(ctx.context)) + seen_tool_inputs.append(ctx.tool_input) + calls.append(text) + return text + + model, agent = make_model_and_agent(tools=[needs_ok], name="agent") + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("needs_ok", json.dumps({"text": "one"}), call_id="1")], + [get_final_output_message("done")], + ] + ) + + first = await Runner.run(agent, input="hi", context={"user": "original"}) + assert first.interruptions + state = first.to_state() + assert state._context is not None + state._context.tool_input = {"scoped": True} + state.approve(first.interruptions[0]) + restored = await RunState.from_json(agent, state.to_json()) + assert restored._context is not None + assert restored._context.tool_input == {"scoped": True} + assert restored._context._approvals + return model, agent, restored + + +@pytest.mark.asyncio +async def test_resume_approved_function_approval_via_json_with_context_override() -> None: + """JSON resume + context= keeps approvals/tool_input and applies the new app context.""" + calls: list[str] = [] + seen_contexts: list[dict[str, str]] = [] + seen_tool_inputs: list[object] = [] + _model, agent, restored = await _interrupted_approval_state_with_tool_input( + calls=calls, seen_contexts=seen_contexts, seen_tool_inputs=seen_tool_inputs + ) + restored_wrapper = restored._context + assert restored_wrapper is not None + override = {"user": "reviewer"} + + resumed = await Runner.run(agent, input=restored, context=override) + + assert resumed.final_output == "done" + assert resumed.interruptions == [] + assert calls == ["one"] + assert seen_contexts == [override] + assert seen_tool_inputs == [{"scoped": True}] + assert resumed.context_wrapper is restored_wrapper + assert resumed.context_wrapper.context == override + assert resumed.context_wrapper.tool_input == {"scoped": True} + assert resumed.context_wrapper._approvals is restored_wrapper._approvals + + +@pytest.mark.asyncio +async def test_resume_approved_function_approval_streamed_with_context_override() -> None: + """Streamed resume + context= keeps approvals/tool_input and applies the new app context.""" + calls: list[str] = [] + seen_contexts: list[dict[str, str]] = [] + seen_tool_inputs: list[object] = [] + _model, agent, restored = await _interrupted_approval_state_with_tool_input( + calls=calls, seen_contexts=seen_contexts, seen_tool_inputs=seen_tool_inputs + ) + restored_wrapper = restored._context + assert restored_wrapper is not None + override = {"user": "reviewer"} + + resumed = Runner.run_streamed(agent, restored, context=override) + async for _ in resumed.stream_events(): + pass + + assert resumed.final_output == "done" + assert resumed.interruptions == [] + assert calls == ["one"] + assert seen_contexts == [override] + assert seen_tool_inputs == [{"scoped": True}] + assert resumed.context_wrapper is restored_wrapper + assert resumed.context_wrapper.context == override + assert resumed.context_wrapper.tool_input == {"scoped": True} + assert resumed.context_wrapper._approvals is restored_wrapper._approvals + + +@pytest.mark.asyncio +async def test_resume_nested_agent_as_tool_with_context_override() -> None: + """Nested Agent.as_tool() resume sees context= while keeping nested wrapper-owned state.""" + seen_contexts: list[dict[str, str]] = [] + seen_tool_inputs: list[object] = [] + calls: list[str] = [] + + @dataclass + class NestedParams: + input: str + + @function_tool(needs_approval=True) + async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: + seen_contexts.append(dict(ctx.context)) + seen_tool_inputs.append(ctx.tool_input) + calls.append(text) + return text + + nested_turn_usage = Usage( + requests=1, + input_tokens=17, + output_tokens=3, + total_tokens=20, + ) + nested_model = FakeModel() + nested_model.set_hardcoded_usage(nested_turn_usage) + nested_agent = Agent(name="nested", tools=[needs_ok], model=nested_model) + nested_model.add_multiple_turn_outputs( + [ + [get_function_tool_call("needs_ok", json.dumps({"text": "one"}), call_id="inner-1")], + [get_final_output_message("nested-done")], + ] + ) + + outer_model = FakeModel() + outer = Agent( + name="outer", + tools=[ + nested_agent.as_tool( + tool_name="nested_agent", + tool_description="Run nested agent", + parameters=NestedParams, + ) + ], + model=outer_model, + ) + outer_model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "nested_agent", + json.dumps({"input": "hi"}), + call_id="outer-1", + ) + ], + [get_final_output_message("done")], + ] + ) + + first = await Runner.run(outer, input="hi", context={"user": "original"}) + assert first.interruptions + assert first.interruptions[0].tool_name == "needs_ok" + + state = first.to_state() + assert state._context is not None + state._context.tool_input = {"scoped": True} + state.approve(first.interruptions[0]) + restored = await RunState.from_json(outer, state.to_json()) + restored_wrapper = restored._context + assert restored_wrapper is not None + assert restored_wrapper.tool_input == {"scoped": True} + assert restored_wrapper._approvals + usage_before_resume = restored_wrapper.usage.input_tokens + override = {"user": "reviewer"} + + resumed = await Runner.run(outer, input=restored, context=override) + + assert resumed.final_output == "done" + assert resumed.interruptions == [] + assert calls == ["one"] + assert seen_contexts == [override] + assert seen_tool_inputs == [{"input": "hi"}] + assert resumed.context_wrapper is restored_wrapper + assert resumed.context_wrapper.context == override + assert resumed.context_wrapper.tool_input == {"scoped": True} + assert resumed.context_wrapper._approvals is restored_wrapper._approvals + # Nested post-resume model turns must keep accruing on the parent usage object. + assert resumed.context_wrapper.usage.input_tokens == ( + usage_before_resume + nested_turn_usage.input_tokens + ) From ece7b0e5861d6c839041d5f860a2a2cf08bba81e Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:13:44 -0700 Subject: [PATCH 208/473] fix(voice): end a streamed session that produced no audio (#4259) --- src/agents/voice/pipeline.py | 60 ++++--- src/agents/voice/result.py | 18 +- tests/voice/test_pipeline.py | 336 ++++++++++++++++++++++++++++++++++- 3 files changed, 389 insertions(+), 25 deletions(-) diff --git a/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py index 7fc659b285..7373308a8d 100644 --- a/src/agents/voice/pipeline.py +++ b/src/agents/voice/pipeline.py @@ -136,33 +136,49 @@ async def process_turns(): transcription_session = None try: try: - async for intro_text in self.workflow.on_start(): - await output._add_text(intro_text) - except Exception as e: - log_model_and_tool_action_warning( - logger, "Voice workflow on_start failed", e + emitted_intro = False + try: + async for intro_text in self.workflow.on_start(): + await output._add_text(intro_text) + emitted_intro = True + except Exception as e: + log_model_and_tool_action_warning( + logger, "Voice workflow on_start failed", e + ) + + if emitted_intro: + # Finalize the intro turn as part of startup. Leaving it open would + # hold a greeting with no sentence-final punctuation until the session + # ends, or merge it into the first user turn. + await output._turn_done() + + transcription_session = await self._get_stt_model().create_session( + audio_input, + self.config.stt_settings, + self.config.trace_include_sensitive_data, + self.config.trace_include_sensitive_audio_data, ) - transcription_session = await self._get_stt_model().create_session( - audio_input, - self.config.stt_settings, - self.config.trace_include_sensitive_data, - self.config.trace_include_sensitive_audio_data, - ) - - async for input_text in transcription_session.transcribe_turns(): - result = self.workflow.run(input_text) - async for text_event in result: - await output._add_text(text_event) - await output._turn_done() - except Exception as e: - log_model_and_tool_action_error(logger, "Error processing voice turns", e) - await output._add_error(e) - raise + async for input_text in transcription_session.transcribe_turns(): + result = self.workflow.run(input_text) + async for text_event in result: + await output._add_text(text_event) + await output._turn_done() + except Exception as e: + # Report before closing the session below. A `close()` that also fails + # would otherwise replace this exception on its way out and the consumer + # would see only the cleanup error. + log_model_and_tool_action_error(logger, "Error processing voice turns", e) + await output._add_error(e) + raise finally: if transcription_session is not None: await transcription_session.close() - await output._done() + + # Only a clean run reaches here. The error path above has already queued its + # terminal event, and a cancelled producer has no consumer left to serve, so + # neither should start TTS work or wait on it. + await output._done() output._set_task(asyncio.create_task(process_turns())) return output diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 7c397a8152..329c53c311 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -259,6 +259,11 @@ def _finish_turn(self): async def _done(self): self._completed_session = True self._dispatcher_event.set() + # A session that produced no audio never started the dispatcher, so nothing would put + # the terminal event on the queue and `stream()` would wait on it forever. Start the + # dispatcher here so it observes the completed session and emits `session_ended`. + if self._dispatcher_task is None: + self._dispatcher_task = asyncio.create_task(self._dispatch_audio()) await self._wait_for_completion() async def _dispatch_audio(self): @@ -323,12 +328,14 @@ def _check_errors(self): async def stream(self) -> AsyncIterator[VoiceStreamEvent]: """Stream the events and audio data as they're generated.""" saw_session_end = False + saw_terminal_event = False primary_exception: BaseException | None = None try: while True: event = await self._queue.get() if isinstance(event, VoiceStreamEventError): self._stored_exception = event.error + saw_terminal_event = True log_model_and_tool_action_error( logger, "Error processing voice output", event.error ) @@ -340,6 +347,7 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: ) if is_session_end: saw_session_end = True + saw_terminal_event = True yield event if is_session_end: break @@ -357,7 +365,11 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: # Let the producer finish gracefully after terminal event delivery so any active # trace context can emit `trace_end` before cleanup. Await completed tasks too so a # terminal producer failure cannot be hidden by the preceding lifecycle event. - if saw_session_end and self.text_generation_task is not None: + # + # An error is a terminal event too. The producer reports it and then still has to + # close the transcription session, so cancelling here instead of waiting would tear + # that session down mid-close. + if saw_terminal_event and self.text_generation_task is not None: try: await asyncio.shield(self.text_generation_task) except BaseException as exc: @@ -388,10 +400,14 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: elif cleanup_exception is not None: exception_to_raise = cleanup_exception + # `exc is not primary_exception` because the producer re-raises the same error it + # queued, so on the error path it arrives here as both. That is the outcome being + # preserved, not a second failure hidden behind it. finalization_exception_was_suppressed = any( exc is not None and not isinstance(exc, asyncio.CancelledError) and exc is not exception_to_raise + and exc is not primary_exception for exc in (producer_exception, cleanup_exception) ) if finalization_exception_was_suppressed: diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 86c7f2ab63..4125d0f27b 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -2,7 +2,7 @@ import asyncio import logging -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass, field from typing import Any, Literal, cast @@ -27,7 +27,13 @@ VoiceStreamEventLifecycle, ) - from .fake_models import FakeStreamedAudioInput, FakeSTT, FakeTTS, FakeWorkflow + from .fake_models import ( + FakeSession, + FakeStreamedAudioInput, + FakeSTT, + FakeTTS, + FakeWorkflow, + ) from .helpers import extract_events except ImportError: pass @@ -786,6 +792,332 @@ async def test_voicepipeline_streamed_audio_input() -> None: await fake_tts.verify_audio("out_2", audio_chunks[1]) +def _never_complete(text: str) -> tuple[str, str]: + """A splitter that never returns a complete sentence, so everything stays buffered.""" + return "", text + + +class _RecordingTTS(FakeTTS): + """Records every text handed to TTS so a test can assert no work was started.""" + + def __init__(self) -> None: + super().__init__() + self.texts: list[str] = [] + + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + self.texts.append(text) + yield np.zeros(2, dtype=np.int16).tobytes() + + +@pytest.mark.asyncio +async def test_voicepipeline_streamed_audio_input_without_turns() -> None: + # Zero turns. The session still has to end, otherwise `stream()` waits on the queue forever. + + fake_stt = FakeSTT([]) + workflow = FakeWorkflow() + fake_tts = FakeTTS() + pipeline = VoicePipeline(workflow=workflow, stt_model=fake_stt, tts_model=fake_tts) + + streamed_audio_input = await FakeStreamedAudioInput.get(count=0) + + result = await pipeline.run(streamed_audio_input) + # The timeout bounds the failure mode under test, which is a stream that never terminates. + events, audio_chunks = await asyncio.wait_for(extract_events(result), timeout=5) + assert events == ["session_ended"] + assert audio_chunks == [] + + +@pytest.mark.asyncio +async def test_voicepipeline_delivers_on_start_output_during_startup() -> None: + # A greeting belongs to startup, so it must reach the consumer while the transcription + # session is still open rather than being held until the session ends. + + intro_delivered = asyncio.Event() + + class GatedSession(FakeSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + # Released only once the greeting has been fully delivered. If the intro turn were + # left open until session end, this would never be released and the test times out. + await intro_delivered.wait() + for t in self.outputs: + yield t + + class GatedSTT(FakeSTT): + async def create_session(self, *args: Any, **kwargs: Any) -> GatedSession: + session = GatedSession() + session.outputs = self.outputs + return session + + class GreetingWorkflow(FakeWorkflow): + async def on_start(self) -> AsyncIterator[str]: + yield "Hello there" + + config = VoicePipelineConfig( + tts_settings=TTSModelSettings(buffer_size=1, text_splitter=_never_complete) + ) + pipeline = VoicePipeline( + workflow=GreetingWorkflow(), + stt_model=GatedSTT([]), + tts_model=_RecordingTTS(), + config=config, + ) + result = await pipeline.run(await FakeStreamedAudioInput.get(count=0)) + + events: list[str] = [] + + async def consume() -> None: + async for event in result.stream(): + if event.type == "voice_stream_event_lifecycle": + events.append(event.event) + if event.event == "turn_ended": + intro_delivered.set() + elif event.type == "voice_stream_event_audio": + events.append("audio") + + await asyncio.wait_for(consume(), timeout=5) + + assert events == ["turn_started", "audio", "turn_ended", "session_ended"] + assert cast(_RecordingTTS, pipeline.tts_model).texts == ["Hello there"] + + +@pytest.mark.asyncio +async def test_voicepipeline_on_start_output_is_its_own_turn() -> None: + # The same guarantee as the test above, but with a transcription session that produces a turn + # immediately rather than waiting for the greeting to be delivered. Nothing serializes the two, + # so this pins that the greeting is finalized as its own turn and the first user response still + # gets its own turn_started rather than being folded into an intro that is still open. + + class ImmediateSession(FakeSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + yield "hello" + + class ImmediateSTT(FakeSTT): + async def create_session(self, *args: Any, **kwargs: Any) -> ImmediateSession: + return ImmediateSession() + + class GreetingWorkflow(FakeWorkflow): + async def on_start(self) -> AsyncIterator[str]: + yield "Hello there" + + async def run(self, _: str) -> AsyncIterator[str]: + yield "the reply" + + recording_tts = _RecordingTTS() + config = VoicePipelineConfig( + tts_settings=TTSModelSettings(buffer_size=1, text_splitter=_never_complete) + ) + pipeline = VoicePipeline( + workflow=GreetingWorkflow(), + stt_model=ImmediateSTT([]), + tts_model=recording_tts, + config=config, + ) + result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + + events, _ = await asyncio.wait_for(extract_events(result), timeout=5) + + assert events == [ + "turn_started", + "audio", + "turn_ended", + "turn_started", + "audio", + "turn_ended", + "session_ended", + ] + assert recording_tts.texts == ["Hello there", "the reply"] + + +@pytest.mark.asyncio +async def test_voicepipeline_failed_turn_closes_the_session_without_further_tts() -> None: + # A failing turn must still close the transcription session, and must not send the text it + # had buffered to TTS. The consumer stops at the error, so that audio is unobservable. + + closed = asyncio.Event() + + class ClosingSession(FakeSession): + async def close(self) -> None: + closed.set() + + class ClosingSTT(FakeSTT): + async def create_session(self, *args: Any, **kwargs: Any) -> ClosingSession: + session = ClosingSession() + session.outputs = self.outputs + return session + + error = RuntimeError("workflow blew up") + + class FailingWorkflow(FakeWorkflow): + async def run(self, _: str) -> AsyncIterator[str]: + yield "partial" + raise error + + recording_tts = _RecordingTTS() + config = VoicePipelineConfig( + tts_settings=TTSModelSettings(buffer_size=1, text_splitter=_never_complete) + ) + pipeline = VoicePipeline( + workflow=FailingWorkflow(), + stt_model=ClosingSTT(["hello"]), + tts_model=recording_tts, + config=config, + ) + result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + + with pytest.raises(RuntimeError) as exc_info: + await asyncio.wait_for(extract_events(result), timeout=5) + + assert exc_info.value is error + assert closed.is_set() + assert recording_tts.texts == [] + + +@pytest.mark.asyncio +async def test_voicepipeline_error_waits_for_the_session_close_before_cleanup() -> None: + # An error is a terminal event, but the producer still has to close the transcription session + # after reporting it. `stream()` has to wait for that instead of cancelling the producer, or + # the session is torn down mid-close. + + turn_error = RuntimeError("workflow blew up") + close_started = asyncio.Event() + release_close = asyncio.Event() + close_finished = asyncio.Event() + + class BlockingCloseSession(FakeSession): + async def close(self) -> None: + close_started.set() + await release_close.wait() + close_finished.set() + + class BlockingCloseSTT(FakeSTT): + async def create_session(self, *args: Any, **kwargs: Any) -> BlockingCloseSession: + session = BlockingCloseSession() + session.outputs = self.outputs + return session + + class FailingWorkflow(FakeWorkflow): + async def run(self, _: str) -> AsyncIterator[str]: + yield "partial" + raise turn_error + + recording_tts = _RecordingTTS() + config = VoicePipelineConfig( + tts_settings=TTSModelSettings(buffer_size=1, text_splitter=_never_complete) + ) + pipeline = VoicePipeline( + workflow=FailingWorkflow(), + stt_model=BlockingCloseSTT(["hello"]), + tts_model=recording_tts, + config=config, + ) + result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + + consumer = asyncio.create_task(extract_events(result)) + await asyncio.wait_for(close_started.wait(), timeout=5) + + # The consumer has the error and is inside its finally. It must be parked on the producer + # rather than cancelling it, so the close is still running and neither side has finished. + await asyncio.sleep(0) + producer = result.text_generation_task + assert producer is not None + assert not producer.cancelled() + assert not producer.done() + assert not consumer.done() + + release_close.set() + + with pytest.raises(RuntimeError) as exc_info: + await asyncio.wait_for(consumer, timeout=5) + + assert exc_info.value is turn_error + assert close_finished.is_set() + assert recording_tts.texts == [] + + +@pytest.mark.asyncio +async def test_voicepipeline_failing_close_does_not_replace_the_turn_error() -> None: + # When a turn fails and closing the transcription session fails too, the consumer must still + # see the error that actually broke the run, not the cleanup error that followed it. + + turn_error = RuntimeError("workflow blew up") + close_error = RuntimeError("close blew up") + + class FailingCloseSession(FakeSession): + async def close(self) -> None: + raise close_error + + class FailingCloseSTT(FakeSTT): + async def create_session(self, *args: Any, **kwargs: Any) -> FailingCloseSession: + session = FailingCloseSession() + session.outputs = self.outputs + return session + + class FailingWorkflow(FakeWorkflow): + async def run(self, _: str) -> AsyncIterator[str]: + raise turn_error + yield "" + + pipeline = VoicePipeline( + workflow=FailingWorkflow(), + stt_model=FailingCloseSTT(["hello"]), + tts_model=FakeTTS(), + ) + result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + + with pytest.raises(RuntimeError) as exc_info: + await asyncio.wait_for(extract_events(result), timeout=5) + + assert exc_info.value is turn_error + + +@pytest.mark.asyncio +async def test_voicepipeline_cancelled_consumer_closes_the_session_without_further_tts() -> None: + # Cancelling the consumer tears down the producer. The transcription session still has to be + # closed, and the turn the producer had open must not be sent to TTS on the way out. + + closed = asyncio.Event() + buffered = asyncio.Event() + + class ClosingSession(FakeSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + yield "hello" + await asyncio.Event().wait() + + async def close(self) -> None: + closed.set() + + class ClosingSTT(FakeSTT): + async def create_session(self, *args: Any, **kwargs: Any) -> ClosingSession: + return ClosingSession() + + class BufferingWorkflow(FakeWorkflow): + async def run(self, _: str) -> AsyncIterator[str]: + yield "partial" + buffered.set() + await asyncio.Event().wait() + + recording_tts = _RecordingTTS() + config = VoicePipelineConfig( + tts_settings=TTSModelSettings(buffer_size=1, text_splitter=_never_complete) + ) + pipeline = VoicePipeline( + workflow=BufferingWorkflow(), + stt_model=ClosingSTT([]), + tts_model=recording_tts, + config=config, + ) + result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + + consumer = asyncio.create_task(extract_events(result)) + await asyncio.wait_for(buffered.wait(), timeout=5) + consumer.cancel() + with pytest.raises(asyncio.CancelledError): + await consumer + + assert closed.is_set() + assert recording_tts.texts == [] + + @pytest.mark.asyncio async def test_voicepipeline_run_single_turn_split_words() -> None: # Single turn. Should produce multiple audio outputs, which are the TTS outputs of "foo bar baz" From 8b810bc4bd1acaafeab3fcfe65ad93187a561be0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 11:29:37 +0900 Subject: [PATCH 209/473] fix: scope hosted MCP approvals to server identity (#4256) --- src/agents/_tool_identity.py | 78 +++ src/agents/agent.py | 41 +- src/agents/run_context.py | 275 +++++++++-- src/agents/run_internal/tool_execution.py | 197 ++++---- src/agents/run_internal/turn_resolution.py | 34 +- src/agents/run_state.py | 88 +++- src/agents/tool_context.py | 4 +- tests/test_agent_as_tool.py | 231 +++++++++ tests/test_agent_runner.py | 66 ++- tests/test_run_context_approvals.py | 534 ++++++++++++++++++++- tests/test_run_state.py | 351 ++++++++++++++ tests/test_run_step_execution.py | 484 +++++++++++++++++++ 12 files changed, 2201 insertions(+), 182 deletions(-) diff --git a/src/agents/_tool_identity.py b/src/agents/_tool_identity.py index bb67757f03..108a13da56 100644 --- a/src/agents/_tool_identity.py +++ b/src/agents/_tool_identity.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass from typing import Any, Literal, cast from typing_extensions import Required, TypedDict @@ -17,9 +18,31 @@ | NamespacedFunctionToolLookupKey | DeferredTopLevelFunctionToolLookupKey ) +HostedMCPApprovalIdentity = tuple[Literal["hosted_mcp"], str, str] +HostedMCPApprovalCallIdentity = tuple[Literal["hosted_mcp_call"], str] +HostedMCPApprovalQueryIdentity = tuple[Literal["hosted_mcp_query"], str, str] +HostedMCPApprovalKey = ( + HostedMCPApprovalIdentity | HostedMCPApprovalCallIdentity | HostedMCPApprovalQueryIdentity +) NamedToolLookupKey = FunctionToolLookupKey | str +@dataclass(frozen=True) +class HostedMCPApprovalRequestIdentity: + """Validated identity fields from a hosted MCP approval request.""" + + request_id: str | None + server_label: str | None + tool_name: str | None + + @property + def approval_identity(self) -> HostedMCPApprovalIdentity | None: + """Return the persistent identity when all required fields are available.""" + if self.server_label is None or self.tool_name is None: + return None + return ("hosted_mcp", self.server_label, self.tool_name) + + def validate_function_tool_fallback_name(name: str) -> str: """Return an API-safe generated tool name or require an explicit override.""" if 1 <= len(name) <= 64 and all( @@ -48,6 +71,61 @@ def get_mapping_or_attr(value: Any, key: str) -> Any: return getattr(value, key, None) +def _non_empty_string(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def get_hosted_mcp_approval_request_identity( + value: Any, +) -> HostedMCPApprovalRequestIdentity | None: + """Return strictly validated identity fields for a hosted MCP approval request.""" + raw_item = get_mapping_or_attr(value, "raw_item") + if raw_item is None: + raw_item = value + + raw_type = get_mapping_or_attr(raw_item, "type") + if raw_type == "mcp_approval_request": + request = raw_item + request_id = _non_empty_string(get_mapping_or_attr(request, "id")) + tool_name = _non_empty_string(get_mapping_or_attr(request, "name")) + elif raw_type == "hosted_tool_call": + request = get_mapping_or_attr(raw_item, "provider_data") + if get_mapping_or_attr(request, "type") != "mcp_approval_request": + return None + + provider_request_id = get_mapping_or_attr(request, "id") + if provider_request_id is None: + request_id = _non_empty_string(get_mapping_or_attr(raw_item, "call_id")) + if request_id is None: + request_id = _non_empty_string(get_mapping_or_attr(raw_item, "id")) + else: + request_id = _non_empty_string(provider_request_id) + tool_name = _non_empty_string(get_mapping_or_attr(request, "name")) + if tool_name is None: + tool_name = _non_empty_string(get_mapping_or_attr(raw_item, "name")) + else: + return None + + return HostedMCPApprovalRequestIdentity( + request_id=request_id, + server_label=_non_empty_string(get_mapping_or_attr(request, "server_label")), + tool_name=tool_name, + ) + + +def get_tool_approval_item_call_id(value: Any) -> str | None: + """Return the canonical call ID for a tool approval item.""" + hosted_request = get_hosted_mcp_approval_request_identity(value) + if hosted_request is not None: + return hosted_request.request_id + + raw_item = get_mapping_or_attr(value, "raw_item") + if raw_item is None: + raw_item = value + call_id = get_mapping_or_attr(raw_item, "call_id") or get_mapping_or_attr(raw_item, "id") + return _non_empty_string(call_id) + + def tool_qualified_name(name: str | None, namespace: str | None = None) -> str | None: """Return `namespace.name` when a namespace exists, otherwise `name`.""" if not isinstance(name, str) or not name: diff --git a/src/agents/agent.py b/src/agents/agent.py index 09dae267d0..677e8cf868 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -14,7 +14,11 @@ from typing_extensions import NotRequired, TypedDict from . import _debug -from ._tool_identity import get_function_tool_approval_keys +from ._tool_identity import ( + get_function_tool_approval_keys, + get_hosted_mcp_approval_request_identity, + get_tool_approval_item_call_id, +) from .agent_output import AgentOutputSchemaBase from .agent_tool_input import ( AgentAsToolInput, @@ -748,7 +752,7 @@ def _nested_approvals_status( has_pending = False has_decision = False for interruption in interruptions: - call_id = interruption.call_id + call_id = get_tool_approval_item_call_id(interruption) if not call_id: has_pending = True continue @@ -781,6 +785,15 @@ def _find_mirrored_approval_record( *, approved: bool, ) -> Any | None: + hosted_request = get_hosted_mcp_approval_request_identity(interruption) + if hosted_request is not None and hosted_request.request_id is not None: + hosted_key = hosted_request.approval_identity or ( + "hosted_mcp_call", + hosted_request.request_id, + ) + hosted_record = parent_context._approvals.get(hosted_key) + if hosted_record is not None: + return hosted_record candidate_keys = list(RunContextWrapper._resolve_approval_keys(interruption)) for candidate_key in get_function_tool_approval_keys( tool_name=RunContextWrapper._resolve_tool_name(interruption), @@ -804,7 +817,7 @@ def _find_mirrored_approval_record( return fallback for interruption in interruptions: - call_id = interruption.call_id + call_id = get_tool_approval_item_call_id(interruption) if not call_id: continue tool_name = RunContextWrapper._resolve_tool_name(interruption) @@ -818,12 +831,19 @@ def _find_mirrored_approval_record( ) if status is None: continue - approval_record = parent_context._approvals.get(approval_key) - if approval_record is None: + hosted_request = get_hosted_mcp_approval_request_identity(interruption) + if hosted_request is not None: approval_record = _find_mirrored_approval_record( interruption, approved=status, ) + else: + approval_record = parent_context._approvals.get(approval_key) + if approval_record is None: + approval_record = _find_mirrored_approval_record( + interruption, + approved=status, + ) if status is True: always_approve = bool(approval_record and approval_record.approved is True) nested_context.approve_tool( @@ -832,9 +852,20 @@ def _find_mirrored_approval_record( ) else: always_reject = bool(approval_record and approval_record.rejected is True) + rejection_message = ( + parent_context.get_rejection_message( + tool_name, + call_id, + tool_namespace=tool_namespace, + existing_pending=interruption, + ) + if hosted_request is not None + else None + ) nested_context.reject_tool( interruption, always_reject=always_reject, + rejection_message=rejection_message, ) if isinstance(context, ToolContext) and context.tool_call is not None: diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 1dd74a6040..946b3db879 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -8,11 +8,15 @@ from ._tool_identity import ( FunctionToolLookupKey, + HostedMCPApprovalKey, + HostedMCPApprovalRequestIdentity, get_function_tool_approval_keys, get_function_tool_lookup_key, + get_hosted_mcp_approval_request_identity, is_reserved_synthetic_tool_namespace, tool_qualified_name, ) +from .exceptions import UserError from .usage import Usage if TYPE_CHECKING: @@ -58,7 +62,7 @@ class RunContextWrapper(Generic[TContext]): """ turn_input: list[TResponseInputItem] = field(default_factory=list) - _approvals: dict[str, _ApprovalRecord] = field(default_factory=dict) + _approvals: dict[str | HostedMCPApprovalKey, _ApprovalRecord] = field(default_factory=dict) tool_input: Any | None = None """Structured input for the current agent tool run, when available.""" @@ -168,21 +172,39 @@ def _resolve_call_id(approval_item: ToolApprovalItem) -> str | None: candidate = getattr(raw, "call_id", None) or getattr(raw, "id", None) return RunContextWrapper._to_str_or_none(candidate) - def _get_or_create_approval_entry(self, tool_name: str) -> _ApprovalRecord: - approval_entry = self._approvals.get(tool_name) + def _get_or_create_approval_entry( + self, + approval_key: str | HostedMCPApprovalKey, + ) -> _ApprovalRecord: + approval_entry = self._approvals.get(approval_key) if approval_entry is None: approval_entry = _ApprovalRecord() - self._approvals[tool_name] = approval_entry + self._approvals[approval_key] = approval_entry return approval_entry def is_tool_approved(self, tool_name: str, call_id: str) -> bool | None: """Return True/False/None for the given tool call.""" + hosted_query_record = self._approvals.get(("hosted_mcp_query", tool_name, call_id)) + hosted_query_status = self._get_per_call_approval_status_for_record( + hosted_query_record, + call_id, + ) + if hosted_query_status is not None: + return hosted_query_status return self._get_approval_status_for_key(tool_name, call_id) def _get_approval_status_for_key(self, approval_key: str, call_id: str) -> bool | None: """Return True/False/None for a concrete approval key and tool call.""" approval_entry = self._approvals.get(approval_key) - if not approval_entry: + return self._get_approval_status_for_record(approval_entry, call_id) + + @staticmethod + def _get_approval_status_for_record( + approval_entry: _ApprovalRecord | None, + call_id: str, + ) -> bool | None: + """Return True/False/None for an approval record and tool call.""" + if approval_entry is None: return None # Check for permanent approval/rejection @@ -210,6 +232,29 @@ def _get_approval_status_for_key(self, approval_key: str, call_id: str) -> bool # Per-call approvals are scoped to the exact call ID, so other calls require a new decision. return None + def _get_per_call_approval_status_for_key( + self, + approval_key: str, + call_id: str, + ) -> bool | None: + """Return only exact-call decisions, ignoring sticky values on the same key.""" + approval_entry = self._approvals.get(approval_key) + return self._get_per_call_approval_status_for_record(approval_entry, call_id) + + @staticmethod + def _get_per_call_approval_status_for_record( + approval_entry: _ApprovalRecord | None, + call_id: str, + ) -> bool | None: + """Return only an exact-call decision from an approval record.""" + if approval_entry is None: + return None + if isinstance(approval_entry.approved, list) and call_id in approval_entry.approved: + return True + if isinstance(approval_entry.rejected, list) and call_id in approval_entry.rejected: + return False + return None + @staticmethod def _clear_rejection_message(record: _ApprovalRecord, call_id: str | None) -> None: if call_id is None: @@ -234,6 +279,79 @@ def _restore_approval_value(value: Any) -> bool | list[str]: return [item for item in value if isinstance(item, str)] return [] + @staticmethod + def _resolve_hosted_mcp_tool_name( + approval_item: ToolApprovalItem, + hosted_request: HostedMCPApprovalRequestIdentity, + ) -> str | None: + """Resolve a hosted MCP tool name, including persisted legacy item metadata.""" + if hosted_request.tool_name is not None: + return hosted_request.tool_name + persisted_tool_name = getattr(approval_item, "tool_name", None) + if isinstance(persisted_tool_name, str) and persisted_tool_name: + return persisted_tool_name + return None + + def _resolve_hosted_mcp_approval_record( + self, + approval_item: ToolApprovalItem, + *, + allow_legacy_exact: bool, + ) -> tuple[_ApprovalRecord | None, str | None, bool]: + """Resolve the authoritative hosted MCP record and whether it is exact-call-only.""" + hosted_request = get_hosted_mcp_approval_request_identity(approval_item) + if hosted_request is None or hosted_request.request_id is None: + return None, None, True + + request_id = hosted_request.request_id + hosted_identity = hosted_request.approval_identity + if hosted_identity is not None: + current_record = self._approvals.get(hosted_identity) + current_status = self._get_approval_status_for_record(current_record, request_id) + if current_status is not None: + return current_record, request_id, False + else: + current_record = self._approvals.get(("hosted_mcp_call", request_id)) + current_status = self._get_per_call_approval_status_for_record( + current_record, + request_id, + ) + if current_status is not None: + return current_record, request_id, True + + if not allow_legacy_exact: + return None, request_id, True + + legacy_key = self._resolve_hosted_mcp_tool_name(approval_item, hosted_request) + if legacy_key is None: + return None, request_id, True + + legacy_record = self._approvals.get(legacy_key) + legacy_status = self._get_per_call_approval_status_for_record(legacy_record, request_id) + if legacy_status is None: + return None, request_id, True + return legacy_record, request_id, True + + def _resolve_hosted_mcp_approval_decision( + self, + approval_item: ToolApprovalItem, + *, + allow_legacy_exact: bool = True, + ) -> tuple[bool | None, str | None]: + """Return a hosted MCP decision and its rejection message from one record.""" + approval_record, request_id, exact_call_only = self._resolve_hosted_mcp_approval_record( + approval_item, + allow_legacy_exact=allow_legacy_exact, + ) + if approval_record is None or request_id is None: + return None, None + + if exact_call_only: + status = self._get_per_call_approval_status_for_record(approval_record, request_id) + else: + status = self._get_approval_status_for_record(approval_record, request_id) + return status, self._get_rejection_message_for_key(approval_record, request_id) + def get_rejection_message( self, tool_name: str, @@ -244,6 +362,21 @@ def get_rejection_message( tool_lookup_key: FunctionToolLookupKey | None = None, ) -> str | None: """Return a stored rejection message for a tool call if one exists.""" + if existing_pending is not None: + hosted_request = get_hosted_mcp_approval_request_identity(existing_pending) + if hosted_request is not None: + _, rejection_message = self._resolve_hosted_mcp_approval_decision(existing_pending) + return rejection_message + + hosted_query_record = self._approvals.get(("hosted_mcp_query", tool_name, call_id)) + hosted_query_status = self._get_per_call_approval_status_for_record( + hosted_query_record, + call_id, + ) + if hosted_query_status is not None: + assert hosted_query_record is not None + return self._get_rejection_message_for_key(hosted_query_record, call_id) + candidates: list[str] = [] explicit_namespace = ( tool_namespace if isinstance(tool_namespace, str) and tool_namespace else None @@ -315,14 +448,55 @@ def _apply_approval_decision( rejection_message: str | None = None, ) -> None: """Record an approval or rejection decision.""" - approval_keys = self._resolve_approval_keys(approval_item) or ("unknown_tool",) - exact_approval_key = self._resolve_approval_key(approval_item) - call_id = self._resolve_call_id(approval_item) - decision_keys = (exact_approval_key,) if always or call_id is None else approval_keys - - for approval_key in decision_keys: - approval_entry = self._get_or_create_approval_entry(approval_key) - if always or call_id is None: + hosted_request = get_hosted_mcp_approval_request_identity(approval_item) + if hosted_request is not None: + call_id = hosted_request.request_id + if call_id is None: + raise UserError("Hosted MCP approval decisions require a non-empty request id.") + hosted_identity = hosted_request.approval_identity + if always and hosted_identity is None: + raise UserError( + "Persistent hosted MCP approval decisions require a non-empty server_label " + "and tool name." + ) + else: + call_id = self._resolve_call_id(approval_item) + hosted_identity = None + + approval_entries: tuple[tuple[_ApprovalRecord, bool], ...] + if hosted_request is not None: + assert call_id is not None + hosted_key: HostedMCPApprovalKey + if hosted_identity is None: + hosted_key = ("hosted_mcp_call", call_id) + else: + hosted_key = hosted_identity + approval_entries = ((self._get_or_create_approval_entry(hosted_key), always),) + hosted_tool_name = self._resolve_hosted_mcp_tool_name( + approval_item, + hosted_request, + ) + if hosted_tool_name is not None: + # Preserve exact name-based lookup without adding an authorization source. + approval_entries += ( + ( + self._get_or_create_approval_entry( + ("hosted_mcp_query", hosted_tool_name, call_id) + ), + False, + ), + ) + else: + approval_keys = self._resolve_approval_keys(approval_item) or ("unknown_tool",) + exact_approval_key = self._resolve_approval_key(approval_item) + decision_keys = (exact_approval_key,) if always or call_id is None else approval_keys + approval_entries = tuple( + (self._get_or_create_approval_entry(approval_key), always) + for approval_key in decision_keys + ) + + for approval_entry, entry_is_sticky in approval_entries: + if entry_is_sticky or call_id is None: approval_entry.approved = approve approval_entry.rejected = [] if approve else True if not approve: @@ -384,6 +558,12 @@ def get_approval_status( tool_lookup_key: FunctionToolLookupKey | None = None, ) -> bool | None: """Return approval status, retrying with pending item's tool name if necessary.""" + if existing_pending is not None: + hosted_request = get_hosted_mcp_approval_request_identity(existing_pending) + if hosted_request is not None: + hosted_status, _ = self._resolve_hosted_mcp_approval_decision(existing_pending) + return hosted_status + candidates: list[str] = [] explicit_namespace = ( tool_namespace if isinstance(tool_namespace, str) and tool_namespace else None @@ -452,20 +632,61 @@ def _rebuild_approvals(self, approvals: Any) -> None: for tool_name, record_dict in approvals.items(): if not isinstance(tool_name, str) or not isinstance(record_dict, dict): continue - record = _ApprovalRecord() - record.approved = self._restore_approval_value(record_dict.get("approved", [])) - record.rejected = self._restore_approval_value(record_dict.get("rejected", [])) - rejection_messages = record_dict.get("rejection_messages", {}) - if isinstance(rejection_messages, dict): - record.rejection_messages = { - str(call_id): message - for call_id, message in rejection_messages.items() - if isinstance(message, str) - } - sticky_rejection_message = record_dict.get("sticky_rejection_message") - if isinstance(sticky_rejection_message, str): - record.sticky_rejection_message = sticky_rejection_message - self._approvals[tool_name] = record + self._approvals[tool_name] = self._restore_approval_record(record_dict) + + @classmethod + def _restore_approval_record(cls, record_dict: Mapping[str, Any]) -> _ApprovalRecord: + record = _ApprovalRecord() + record.approved = cls._restore_approval_value(record_dict.get("approved", [])) + record.rejected = cls._restore_approval_value(record_dict.get("rejected", [])) + rejection_messages = record_dict.get("rejection_messages", {}) + if isinstance(rejection_messages, dict): + record.rejection_messages = { + str(call_id): message + for call_id, message in rejection_messages.items() + if isinstance(message, str) + } + sticky_rejection_message = record_dict.get("sticky_rejection_message") + if isinstance(sticky_rejection_message, str): + record.sticky_rejection_message = sticky_rejection_message + return record + + def _rebuild_hosted_mcp_approvals(self, approvals: Any) -> None: + """Restore typed hosted MCP approval records from serialized state.""" + if not isinstance(approvals, list): + return + for entry in approvals: + if not isinstance(entry, Mapping): + continue + identity = entry.get("identity") + decision = entry.get("decision") + if not isinstance(identity, Mapping) or not isinstance(decision, Mapping): + continue + identity_type = identity.get("type") + if identity_type == "server_tool": + server_label = identity.get("server_label") + tool_name = identity.get("tool_name") + if not isinstance(server_label, str) or not server_label: + continue + if not isinstance(tool_name, str) or not tool_name: + continue + key: HostedMCPApprovalKey = ("hosted_mcp", server_label, tool_name) + elif identity_type == "request": + request_id = identity.get("request_id") + if not isinstance(request_id, str) or not request_id: + continue + key = ("hosted_mcp_call", request_id) + elif identity_type == "query": + tool_name = identity.get("tool_name") + request_id = identity.get("request_id") + if not isinstance(tool_name, str) or not tool_name: + continue + if not isinstance(request_id, str) or not request_id: + continue + key = ("hosted_mcp_query", tool_name, request_id) + else: + continue + self._approvals[key] = self._restore_approval_record(decision) def _fork_with_tool_input(self, tool_input: Any) -> RunContextWrapper[TContext]: """Create a child context that shares approvals and usage with tool input set.""" diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 22cdddf8b2..08f6d9fb3a 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -19,7 +19,6 @@ ComputerCallOutputAcknowledgedSafetyCheck, ) from openai.types.responses.response_input_param import McpApprovalResponse -from openai.types.responses.response_output_item import McpApprovalRequest from .. import _debug from .._tool_identity import ( @@ -29,6 +28,8 @@ get_function_tool_lookup_key, get_function_tool_lookup_key_for_call, get_function_tool_trace_name, + get_hosted_mcp_approval_request_identity, + get_tool_approval_item_call_id, get_tool_call_namespace, get_tool_call_trace_name, is_deferred_top_level_function_tool, @@ -102,7 +103,6 @@ from .approvals import append_approval_error_output from .items import ( REJECTION_MESSAGE, - extract_mcp_request_id, extract_mcp_request_id_from_run, function_rejection_item, function_tool_error_output, @@ -144,10 +144,8 @@ "get_trace_tool_error", "with_tool_function_span", "build_litellm_json_tool_call", - "process_hosted_mcp_approvals", "collect_manual_mcp_approvals", "index_approval_items_by_call_id", - "should_keep_hosted_mcp_item", "resolve_approval_status", "resolve_approval_interruption", "resolve_approval_rejection_message", @@ -1280,68 +1278,46 @@ async def function_needs_approval( return bool(needs_approval) -def process_hosted_mcp_approvals( - *, - original_pre_step_items: Sequence[RunItem], - mcp_approval_requests: Sequence[Any], - context_wrapper: RunContextWrapper[Any], - agent: Agent[Any], - append_item: Callable[[RunItem], None], -) -> tuple[list[ToolApprovalItem], set[str]]: - """Filter hosted MCP outputs and merge manual approvals so only coherent items remain.""" - hosted_mcp_approvals_by_id: dict[str, ToolApprovalItem] = {} - for item in original_pre_step_items: - if not isinstance(item, ToolApprovalItem): - continue - raw = item.raw_item - if not _is_hosted_mcp_approval_request(raw): - continue - request_id = extract_mcp_request_id(raw) - if request_id: - hosted_mcp_approvals_by_id[request_id] = item - - pending_hosted_mcp_approvals: list[ToolApprovalItem] = [] - pending_hosted_mcp_approval_ids: set[str] = set() - - for mcp_run in mcp_approval_requests: - request_id = extract_mcp_request_id_from_run(mcp_run) - # MCP approval requests are documented to include an id used as approval_request_id. - # See https://platform.openai.com/docs/guides/tools-connectors-mcp#approvals - approval_item = hosted_mcp_approvals_by_id.get(request_id) if request_id else None - if not approval_item or not request_id: - continue - - tool_name = RunContextWrapper._resolve_tool_name(approval_item) - approved = context_wrapper.get_approval_status( - tool_name=tool_name, - call_id=request_id, - existing_pending=approval_item, - ) - - if approved is not None: - raw_item: McpApprovalResponse = { - "type": "mcp_approval_response", - "approval_request_id": request_id, - "approve": approved, - } - rejection_message = context_wrapper.get_rejection_message( - tool_name=tool_name, - call_id=request_id, - existing_pending=approval_item, - ) - if approved is False and rejection_message is not None: - raw_item["reason"] = rejection_message - ItemHelpers.copy_tool_call_caller(mcp_run.request_item, raw_item) - response_item = MCPApprovalResponseItem(raw_item=raw_item, agent=agent) - append_item(response_item) - continue - - if approval_item not in pending_hosted_mcp_approvals: - pending_hosted_mcp_approvals.append(approval_item) - pending_hosted_mcp_approval_ids.add(request_id) - append_item(approval_item) - - return pending_hosted_mcp_approvals, pending_hosted_mcp_approval_ids +def _classify_hosted_mcp_pending_request( + pending: ToolApprovalItem, + current_request: Any, +) -> Literal["reuse_pending", "use_current", "use_current_with_pending_exact", "conflict"]: + """Choose the safe identity source when reconciling pending and current requests.""" + pending_identity = get_hosted_mcp_approval_request_identity(pending) + current_identity = get_hosted_mcp_approval_request_identity(current_request) + if pending_identity is None or current_identity is None: + return "conflict" + if pending_identity.request_id is None or current_identity.request_id is None: + return "conflict" + if pending_identity.request_id != current_identity.request_id: + return "conflict" + if ( + pending_identity.server_label is not None + and current_identity.server_label is not None + and pending_identity.server_label != current_identity.server_label + ): + return "conflict" + pending_tool_name = RunContextWrapper._resolve_hosted_mcp_tool_name( + pending, + pending_identity, + ) + if ( + pending_tool_name is not None + and current_identity.tool_name is not None + and pending_tool_name != current_identity.tool_name + ): + return "conflict" + if ( + current_identity.approval_identity is None + and pending_identity.approval_identity is not None + ): + return "use_current" + if ( + current_identity.approval_identity is not None + and pending_identity.approval_identity is None + ): + return "use_current_with_pending_exact" + return "reuse_pending" def collect_manual_mcp_approvals( @@ -1370,10 +1346,49 @@ def collect_manual_mcp_approvals( tool_name = RunContextWrapper._to_str_or_none(getattr(request_item, "name", None)) tool_name = tool_name or get_mapping_or_attr(request, "mcp_tool").name + current_approval_item = ToolApprovalItem( + agent=agent, + raw_item=request_item, + tool_name=tool_name, + ) existing_pending = pending_lookup.get(request_id or "") - approval_status = context_wrapper.get_approval_status( - tool_name, request_id or "", existing_pending=existing_pending + pending_resolution = ( + _classify_hosted_mcp_pending_request(existing_pending, request_item) + if existing_pending is not None + else "use_current" + ) + identity_mismatch = pending_resolution == "conflict" + if existing_pending is not None and pending_resolution == "reuse_pending": + approval_item = existing_pending + else: + approval_item = current_approval_item + allow_primary_legacy = ( + existing_pending is not None + and not identity_mismatch + and pending_resolution != "use_current_with_pending_exact" + ) + approval_status, rejection_message = context_wrapper._resolve_hosted_mcp_approval_decision( + approval_item, + allow_legacy_exact=allow_primary_legacy, ) + if ( + approval_status is None + and existing_pending is not None + and pending_resolution == "use_current_with_pending_exact" + ): + approval_status, rejection_message = ( + context_wrapper._resolve_hosted_mcp_approval_decision( + existing_pending, + allow_legacy_exact=True, + ) + ) + if approval_status is None and pending_resolution == "use_current_with_pending_exact": + approval_status, rejection_message = ( + context_wrapper._resolve_hosted_mcp_approval_decision( + current_approval_item, + allow_legacy_exact=True, + ) + ) if approval_status is not None and request_id: approval_response_raw: McpApprovalResponse = { @@ -1381,11 +1396,6 @@ def collect_manual_mcp_approvals( "approval_request_id": request_id, "approve": approval_status, } - rejection_message = context_wrapper.get_rejection_message( - tool_name, - request_id, - existing_pending=existing_pending, - ) if approval_status is False and rejection_message is not None: approval_response_raw["reason"] = rejection_message ItemHelpers.copy_tool_call_caller(request_item, approval_response_raw) @@ -1395,14 +1405,7 @@ def collect_manual_mcp_approvals( if approval_status is not None: continue - pending.append( - existing_pending - or ToolApprovalItem( - agent=agent, - raw_item=request_item, - tool_name=tool_name, - ) - ) + pending.append(approval_item) return approved, pending @@ -1413,29 +1416,12 @@ def index_approval_items_by_call_id(items: Sequence[RunItem]) -> dict[str, ToolA for item in items: if not isinstance(item, ToolApprovalItem): continue - call_id = extract_tool_call_id(item.raw_item) + call_id = get_tool_approval_item_call_id(item) if call_id: approvals[call_id] = item return approvals -def should_keep_hosted_mcp_item( - item: RunItem, - *, - pending_hosted_mcp_approvals: Sequence[ToolApprovalItem], - pending_hosted_mcp_approval_ids: set[str], -) -> bool: - """Keep only hosted MCP approvals that match pending requests from the provider.""" - if not isinstance(item, ToolApprovalItem): - return True - if not _is_hosted_mcp_approval_request(item.raw_item): - return False - request_id = extract_mcp_request_id(item.raw_item) - return item in pending_hosted_mcp_approvals or ( - request_id is not None and request_id in pending_hosted_mcp_approval_ids - ) - - def _uses_programmatic_output_schema( function_tool: FunctionTool, tool_call: Any, @@ -2603,16 +2589,3 @@ def _normalize_exit_code(value: Any) -> int | None: return int(value) except (TypeError, ValueError): return None - - -def _is_hosted_mcp_approval_request(raw_item: Any) -> bool: - """Detect hosted MCP approval request payloads emitted by the provider.""" - if isinstance(raw_item, McpApprovalRequest): - return True - if not isinstance(raw_item, dict): - return False - provider_data = raw_item.get("provider_data", {}) - return ( - raw_item.get("type") == "hosted_tool_call" - and provider_data.get("type") == "mcp_approval_request" - ) diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 2302a9d1a5..a30372f74b 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -39,6 +39,7 @@ get_function_tool_lookup_key, get_function_tool_lookup_key_for_call, get_function_tool_lookup_key_for_tool, + get_tool_approval_item_call_id, get_tool_call_namespace, get_tool_call_qualified_name, get_tool_call_trace_name, @@ -128,6 +129,7 @@ REJECTION_MESSAGE, NestedHistoryOwnedItem, apply_patch_rejection_item, + extract_mcp_request_id_from_run, function_rejection_item, shell_rejection_item, ) @@ -165,9 +167,7 @@ is_apply_patch_name, parse_apply_patch_custom_input, parse_apply_patch_function_args, - process_hosted_mcp_approvals, resolve_approval_rejection_message, - should_keep_hosted_mcp_item, ) from .tool_planning import ( _append_mcp_callback_results, @@ -1321,7 +1321,7 @@ def _nested_interruptions_status( ) -> Literal["approved", "pending", "rejected"]: has_pending = False for interruption in interruptions: - call_id = extract_tool_call_id(interruption.raw_item) + call_id = get_tool_approval_item_call_id(interruption) if not call_id: has_pending = True continue @@ -1371,7 +1371,7 @@ def _add_pending_interruption(item: ToolApprovalItem | None) -> None: source_item.tool_lookup_key = item.tool_lookup_key source_item._allow_bare_name_alias = item._allow_bare_name_alias item = source_item - call_id = extract_tool_call_id(item.raw_item) + call_id = get_tool_approval_item_call_id(item) key = call_id or f"raw:{id(item.raw_item)}" if key in pending_interruption_keys: return @@ -1521,7 +1521,7 @@ def _snapshot_function_run(run: ToolRunFunction) -> ToolRunFunction: } def _is_function_approval(approval: ToolApprovalItem) -> bool: - call_id = extract_tool_call_id(approval.raw_item) + call_id = get_tool_approval_item_call_id(approval) if call_id in non_function_owned_call_ids and call_id not in queued_function_call_ids: return False if get_mapping_or_attr(approval.raw_item, "type") == "function_call": @@ -1911,12 +1911,13 @@ def _rebind_function_run( *(_shell_call_id_from_run(run) for run in processed_response.shell_calls), *(_apply_patch_call_id_from_run(run) for run in processed_response.apply_patch_calls), *(_custom_call_id_from_run(run) for run in processed_response.custom_tool_calls), + *(extract_mcp_request_id_from_run(run) for run in processed_response.mcp_approval_requests), } for original_approval in pending_approval_items: approval_snapshot = validated_function_approval_items.get(original_approval) if approval_snapshot is None: approval = original_approval - approval_call_id = extract_tool_call_id(approval.raw_item) + approval_call_id = get_tool_approval_item_call_id(approval) if approval_call_id in collector_owned_call_ids: continue if ( @@ -2167,25 +2168,8 @@ def _commit_missing_state(result: SingleStepResult) -> SingleStepResult: append_item=append_if_new, ) - ( - pending_hosted_mcp_approvals, - pending_hosted_mcp_approval_ids, - ) = process_hosted_mcp_approvals( - original_pre_step_items=original_pre_step_items, - mcp_approval_requests=processed_response.mcp_approval_requests, - context_wrapper=context_wrapper, - agent=public_agent, - append_item=append_if_new, - ) - - pre_step_items = [ - item - for item in original_pre_step_items - if should_keep_hosted_mcp_item( - item, - pending_hosted_mcp_approvals=pending_hosted_mcp_approvals, - pending_hosted_mcp_approval_ids=pending_hosted_mcp_approval_ids, - ) + pre_step_items: list[RunItem] = [ + item for item in original_pre_step_items if not isinstance(item, ToolApprovalItem) ] if rejected_function_call_ids: diff --git a/src/agents/run_state.py b/src/agents/run_state.py index b5bc887297..243a6d2c9e 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -150,7 +150,9 @@ # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported # versions). -CURRENT_SCHEMA_VERSION = "1.13" +CURRENT_SCHEMA_VERSION = "1.14" +_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13" +_HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. SCHEMA_VERSION_SUMMARIES: dict[str, str] = { "1.0": "Initial RunState snapshot format for HITL pause/resume flows.", @@ -173,6 +175,7 @@ "Persists programmatic tool calling and nested handoff history ownership across resume " "flows." ), + "1.14": "Scopes hosted MCP approvals and restored requests by server label.", } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -395,6 +398,8 @@ def _serialize_approvals(self) -> dict[str, dict[str, Any]]: return {} approvals_dict: dict[str, dict[str, Any]] = {} for tool_name, record in self._context._approvals.items(): + if not isinstance(tool_name, str): + continue approvals_dict[tool_name] = { "approved": record.approved if isinstance(record.approved, bool) @@ -411,6 +416,49 @@ def _serialize_approvals(self) -> dict[str, dict[str, Any]]: ) return approvals_dict + def _serialize_hosted_mcp_approvals(self) -> list[dict[str, Any]]: + """Serialize hosted MCP approvals with explicit typed identities.""" + if self._context is None: + return [] + serialized: list[dict[str, Any]] = [] + hosted_records = ( + (identity, record) + for identity, record in self._context._approvals.items() + if isinstance(identity, tuple) + ) + for identity, record in sorted(hosted_records): + if identity[0] == "hosted_mcp": + identity_data = { + "type": "server_tool", + "server_label": identity[1], + "tool_name": identity[2], + } + elif identity[0] == "hosted_mcp_call": + identity_data = { + "type": "request", + "request_id": identity[1], + } + else: + identity_data = { + "type": "query", + "tool_name": identity[1], + "request_id": identity[2], + } + decision: dict[str, Any] = { + "approved": record.approved + if isinstance(record.approved, bool) + else list(record.approved), + "rejected": record.rejected + if isinstance(record.rejected, bool) + else list(record.rejected), + } + if record.rejection_messages: + decision["rejection_messages"] = dict(record.rejection_messages) + if record.sticky_rejection_message is not None: + decision["sticky_rejection_message"] = record.sticky_rejection_message + serialized.append({"identity": identity_data, "decision": decision}) + return serialized + def _serialize_model_responses(self) -> list[dict[str, Any]]: """Serialize model responses.""" return [ @@ -754,6 +802,7 @@ def to_json( raise UserError("Cannot serialize RunState: No context") approvals_dict = self._serialize_approvals() + hosted_mcp_approvals = self._serialize_hosted_mcp_approvals() model_responses = self._serialize_model_responses() original_input_serialized = self._serialize_original_input() context_payload, context_meta = self._serialize_context_payload( @@ -771,6 +820,8 @@ def to_json( tool_input = self._serialize_tool_input(self._context.tool_input) if tool_input is not None: context_entry["tool_input"] = tool_input + if hosted_mcp_approvals: + context_entry["hosted_mcp_approvals"] = hosted_mcp_approvals agent_identity_keys_by_id = ( _build_agent_identity_keys_by_id(cast(Agent[Any], self._starting_agent)) @@ -1721,6 +1772,18 @@ def _build_named_tool_map( return tool_map +def _build_hosted_mcp_tool_map(tools: Sequence[Any]) -> dict[str, HostedMCPTool]: + """Build a server-label-indexed map for hosted MCP tools.""" + tool_map: dict[str, HostedMCPTool] = {} + for tool in tools: + if not isinstance(tool, HostedMCPTool): + continue + server_label = tool.tool_config.get("server_label") + if isinstance(server_label, str) and server_label: + tool_map[server_label] = tool + return tool_map + + def _build_handoffs_map(current_agent: Agent[Any]) -> dict[str, Handoff[Any, Agent[Any]]]: """Map handoff tool names to their definitions for quick lookup.""" handoffs_map: dict[str, Handoff[Any, Agent[Any]]] = {} @@ -1830,7 +1893,7 @@ async def _deserialize_processed_response( local_shell_tools_map = _build_named_tool_map(all_tools, LocalShellTool) shell_tools_map = _build_named_tool_map(all_tools, ShellTool) apply_patch_tools_map = _build_named_tool_map(all_tools, ApplyPatchTool) - mcp_tools_map = _build_named_tool_map(all_tools, HostedMCPTool) + mcp_tools_map = _build_hosted_mcp_tool_map(all_tools) handoffs_map = _build_handoffs_map(current_agent) programmatic_tool_present = any( isinstance(tool, ProgrammaticToolCallingTool) for tool in all_tools @@ -2133,8 +2196,7 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]: if not mcp_tool_data: continue - mcp_tool_name = mcp_tool_data.get("name") - mcp_tool = mcp_tools_map.get(mcp_tool_name) if mcp_tool_name else None + mcp_tool = mcp_tools_map.get(request_item.server_label) if mcp_tool: _ensure_restored_tool_call_allowed( @@ -2701,13 +2763,18 @@ async def _build_run_state_from_json( f"Supported versions are: {supported_versions}. " f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}." ) - if schema_version != CURRENT_SCHEMA_VERSION and _run_state_uses_programmatic_tool_calling( - state_json - ): + schema_major, schema_minor = (int(part) for part in schema_version.split(".", maxsplit=1)) + programmatic_major, programmatic_minor = ( + int(part) for part in _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION.split(".", maxsplit=1) + ) + if (schema_major, schema_minor) < ( + programmatic_major, + programmatic_minor, + ) and _run_state_uses_programmatic_tool_calling(state_json): raise UserError( "Run state contains Programmatic Tool Calling data but uses schema version " f"{schema_version}. Programmatic Tool Calling requires schema version " - f"{CURRENT_SCHEMA_VERSION}." + f"{_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION} or later." ) agent_identity_map = _build_agent_identity_map(initial_agent) @@ -2771,6 +2838,11 @@ async def _build_run_state_from_json( raise UserError("Serialized run state context must be a mapping. Please provide one.") context.usage = usage context._rebuild_approvals(context_data.get("approvals", {})) + hosted_mcp_major, hosted_mcp_minor = ( + int(part) for part in _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION.split(".", maxsplit=1) + ) + if (schema_major, schema_minor) >= (hosted_mcp_major, hosted_mcp_minor): + context._rebuild_hosted_mcp_approvals(context_data.get("hosted_mcp_approvals", [])) serialized_tool_input = context_data.get("tool_input") if ( context_override is None diff --git a/src/agents/tool_context.py b/src/agents/tool_context.py index b9c753c79a..1ab2dd29f3 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -5,7 +5,7 @@ from openai.types.responses import ResponseFunctionToolCall -from ._tool_identity import get_tool_call_namespace, tool_trace_name +from ._tool_identity import HostedMCPApprovalKey, get_tool_call_namespace, tool_trace_name from .agent_tool_state import get_agent_tool_state_scope, set_agent_tool_state_scope from .run_context import RunContextWrapper, TContext from .usage import Usage @@ -70,7 +70,7 @@ def __init__( agent: AgentBase[Any] | None = None, run_config: RunConfig | dict[str, Any] | None = None, turn_input: list[TResponseInputItem] | None = None, - _approvals: dict[str, _ApprovalRecord] | None = None, + _approvals: dict[str | HostedMCPApprovalKey, _ApprovalRecord] | None = None, tool_input: Any | None = None, ) -> None: """Preserve the v0.7 positional constructor while accepting new context fields.""" diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index bb52d5743c..96012ac71a 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -9,6 +9,7 @@ import pytest from openai.types.responses import ResponseOutputMessage, ResponseOutputText from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall +from openai.types.responses.response_output_item import McpApprovalRequest from pydantic import BaseModel, Field import agents._debug as _debug @@ -1538,6 +1539,86 @@ async def extractor(result: Any) -> str: assert run_inputs == [resume_state] +@pytest.mark.asyncio +async def test_agent_as_tool_wrapped_hosted_mcp_exact_decision_resumes_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent = Agent(name="outer") + tool_call = make_function_tool_call( + "outer_tool", + call_id="outer-1", + arguments='{"input": "hello"}', + ) + tool_context = ToolContext( + context=None, + tool_name="outer_tool", + tool_call_id="outer-1", + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + approval_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "inner-1", + "name": "lookup_account", + }, + }, + tool_name="lookup_account", + ) + + class DummyState: + def __init__(self, nested_context: ToolContext) -> None: + self._context = nested_context + + class DummyPendingResult: + def __init__(self) -> None: + self.interruptions = [approval_item] + self.final_output = None + + def to_state(self) -> DummyState: + return resume_state + + class DummyResumedResult: + def __init__(self) -> None: + self.interruptions: list[ToolApprovalItem] = [] + self.final_output = "rejected" + + nested_context = ToolContext( + context=None, + tool_name=tool_call.name, + tool_call_id=tool_call.call_id, + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + resume_state = DummyState(nested_context) + pending_result = DummyPendingResult() + record_agent_tool_run_result(tool_call, cast(Any, pending_result)) + tool_context.reject_tool(approval_item, rejection_message="exact denial") + + resumed_result = DummyResumedResult() + + async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: + assert input is resume_state + assert input._context is not None + assert input._context.is_tool_approved("lookup_account", "inner-1") is False + assert input._context.get_rejection_message("lookup_account", "inner-1") == "exact denial" + return resumed_result + + monkeypatch.setattr(Runner, "run", classmethod(run_resume)) + tool = agent.as_tool( + tool_name="outer_tool", + tool_description="Outer agent tool", + is_enabled=True, + ) + + output = await tool.on_invoke_tool(tool_context, tool_call.arguments) + + assert output == "rejected" + + @pytest.mark.asyncio async def test_agent_as_tool_namespaced_nested_always_approve_stays_permanent( monkeypatch: pytest.MonkeyPatch, @@ -1624,6 +1705,156 @@ async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedRes assert run_inputs == [resume_state] +@pytest.mark.parametrize( + ("approve", "sticky", "legacy_sticky", "expected_followup"), + [ + (True, True, False, True), + (False, True, False, False), + (True, False, True, None), + (False, False, True, None), + ], +) +@pytest.mark.asyncio +async def test_agent_as_tool_hosted_mcp_nested_sticky_decision_stays_scoped( + monkeypatch: pytest.MonkeyPatch, + approve: bool, + sticky: bool, + legacy_sticky: bool, + expected_followup: bool | None, +) -> None: + agent = Agent(name="outer") + tool_call = make_function_tool_call( + "outer_tool", + call_id="outer-1", + arguments='{"input": "hello"}', + ) + tool_context = ToolContext( + context=None, + tool_name="outer_tool", + tool_call_id="outer-1", + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="inner-1", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ), + ) + + class DummyState: + def __init__(self, nested_context: ToolContext) -> None: + self._context = nested_context + + class DummyPendingResult: + def __init__(self) -> None: + self.interruptions = [approval_item] + self.final_output = None + + def to_state(self) -> DummyState: + return resume_state + + class DummyResumedResult: + def __init__(self) -> None: + self.interruptions: list[ToolApprovalItem] = [] + self.final_output = "resumed" + + nested_context = ToolContext( + context=None, + tool_name=tool_call.name, + tool_call_id=tool_call.call_id, + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + resume_state = DummyState(nested_context) + pending_result = DummyPendingResult() + record_agent_tool_run_result(tool_call, cast(Any, pending_result)) + if legacy_sticky: + tool_context._rebuild_approvals( # noqa: SLF001 + { + "lookup_account": { + "approved": approve, + "rejected": [] if approve else True, + "sticky_rejection_message": None if approve else "legacy denial", + } + } + ) + if approve: + tool_context.approve_tool(approval_item, always_approve=sticky) + else: + tool_context.reject_tool( + approval_item, + always_reject=sticky, + rejection_message="server-a denied", + ) + + resumed_result = DummyResumedResult() + + async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: + assert input is resume_state + assert input._context is not None + assert ( + input._context.get_approval_status( + "lookup_account", + "inner-1", + existing_pending=approval_item, + ) + is approve + ) + original_message = None if approve else "server-a denied" + assert ( + input._context.get_rejection_message( + "lookup_account", + "inner-1", + existing_pending=approval_item, + ) + == original_message + ) + followup = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="inner-2", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ), + ) + assert ( + input._context.get_approval_status( + "lookup_account", + "inner-2", + existing_pending=followup, + ) + is expected_followup + ) + expected_message = "server-a denied" if expected_followup is False else None + assert ( + input._context.get_rejection_message( + "lookup_account", + "inner-2", + existing_pending=followup, + ) + == expected_message + ) + return resumed_result + + monkeypatch.setattr(Runner, "run", classmethod(run_resume)) + tool = agent.as_tool( + tool_name="outer_tool", + tool_description="Outer agent tool", + is_enabled=True, + ) + + output = await tool.on_invoke_tool(tool_context, tool_call.arguments) + + assert output == "resumed" + + @pytest.mark.asyncio async def test_agent_as_tool_deferred_same_name_legacy_nested_always_approve_stays_permanent( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 06f6705e76..d37e8cf608 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -14,8 +14,10 @@ import pytest from openai import APIConnectionError, BadRequestError from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_output_item import McpApprovalRequest from openai.types.responses.response_output_text import AnnotationFileCitation, ResponseOutputText from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary +from openai.types.responses.tool_param import Mcp from typing_extensions import TypedDict import agents._debug as _debug @@ -88,7 +90,7 @@ from agents.run_internal.tool_execution import execute_approved_tools from agents.run_internal.tool_use_tracker import AgentToolUseTracker from agents.run_state import RunState -from agents.tool import ComputerTool, FunctionToolResult, ShellTool, function_tool +from agents.tool import ComputerTool, FunctionToolResult, HostedMCPTool, ShellTool, function_tool from agents.tool_context import ToolContext from agents.usage import Usage @@ -159,7 +161,7 @@ async def run_execute_approved_tools( async def _run_agent_with_optional_streaming( agent: Agent[Any], *, - input: str | list[TResponseInputItem], + input: str | list[TResponseInputItem] | RunState[Any, Agent[Any]], streamed: bool, **kwargs: Any, ): @@ -171,6 +173,66 @@ async def _run_agent_with_optional_streaming( return await Runner.run(agent, input=input, **kwargs) +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_persistent_hosted_mcp_approval_does_not_cross_servers(streamed: bool) -> None: + model = FakeModel() + server_a = HostedMCPTool( + tool_config=Mcp( + type="mcp", + server_label="server-a", + server_url="https://server-a.example/mcp", + ) + ) + server_b = HostedMCPTool( + tool_config=Mcp( + type="mcp", + server_label="server-b", + server_url="https://server-b.example/mcp", + ) + ) + model.add_multiple_turn_outputs( + [ + [ + McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ) + ], + [ + McpApprovalRequest( + id="request-b", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-b", + ) + ], + ] + ) + agent = Agent(name="test", model=model, tools=[server_a, server_b]) + + first = await _run_agent_with_optional_streaming(agent, input="hello", streamed=streamed) + assert len(first.interruptions) == 1 + assert first.interruptions[0].raw_item.server_label == "server-a" + + state = first.to_state() + state.approve(first.interruptions[0], always_approve=True) + restored_state = await RunState.from_json(agent, state.to_json()) + + resumed = await _run_agent_with_optional_streaming( + agent, + input=restored_state, + streamed=streamed, + ) + + assert len(resumed.interruptions) == 1 + assert resumed.interruptions[0].raw_item.server_label == "server-b" + + @pytest.mark.parametrize("surface", ["agent_tool", "handoff", "mixed"]) @pytest.mark.parametrize("streamed", [False, True]) @pytest.mark.parametrize("collision_policy", ["warn", "error"]) diff --git a/tests/test_run_context_approvals.py b/tests/test_run_context_approvals.py index 79b34ac2ba..2b9df0a6ac 100644 --- a/tests/test_run_context_approvals.py +++ b/tests/test_run_context_approvals.py @@ -1,10 +1,542 @@ from __future__ import annotations -from agents import Agent, RunContextWrapper +import pytest +from openai.types.responses.response_output_item import McpApprovalRequest + +from agents import Agent, RunContextWrapper, ToolApprovalItem, UserError from .utils.factories import make_tool_approval_item +def _make_hosted_mcp_approval_item( + agent: Agent[None], + *, + request_id: str, + server_label: str, + tool_name: str = "lookup_account", +) -> ToolApprovalItem: + return ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id=request_id, + type="mcp_approval_request", + arguments="{}", + name=tool_name, + server_label=server_label, + ), + ) + + +def test_hosted_mcp_permanent_approval_is_scoped_by_server_label() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + server_a = _make_hosted_mcp_approval_item( + agent, + request_id="request-a-1", + server_label="server-a", + ) + server_a_next = _make_hosted_mcp_approval_item( + agent, + request_id="request-a-2", + server_label="server-a", + ) + server_b = _make_hosted_mcp_approval_item( + agent, + request_id="request-b-1", + server_label="server-b", + ) + + context_wrapper.approve_tool(server_a, always_approve=True) + + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-a-2", + existing_pending=server_a_next, + ) + is True + ) + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-b-1", + existing_pending=server_b, + ) + is None + ) + assert context_wrapper.is_tool_approved("lookup_account", "request-a-1") is True + assert context_wrapper.is_tool_approved("lookup_account", "request-a-2") is None + assert "lookup_account" not in context_wrapper._approvals + + +def test_hosted_mcp_permanent_rejection_message_is_scoped_by_server_label() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + server_a = _make_hosted_mcp_approval_item( + agent, + request_id="request-a-1", + server_label="server-a", + ) + server_a_next = _make_hosted_mcp_approval_item( + agent, + request_id="request-a-2", + server_label="server-a", + ) + server_b = _make_hosted_mcp_approval_item( + agent, + request_id="request-b-1", + server_label="server-b", + ) + + context_wrapper.reject_tool( + server_a, + always_reject=True, + rejection_message="server-a denied", + ) + + assert ( + context_wrapper.get_rejection_message( + "lookup_account", + "request-a-2", + existing_pending=server_a_next, + ) + == "server-a denied" + ) + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-b-1", + existing_pending=server_b, + ) + is None + ) + assert ( + context_wrapper.get_rejection_message( + "lookup_account", + "request-b-1", + existing_pending=server_b, + ) + is None + ) + + +@pytest.mark.parametrize( + ("approved", "always"), + [(True, False), (True, True), (False, False), (False, True)], +) +def test_hosted_mcp_name_based_query_preserves_exact_call_decision( + approved: bool, + always: bool, +) -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + approval_item = _make_hosted_mcp_approval_item( + agent, + request_id="request-a-1", + server_label="server-a", + ) + + if approved: + context_wrapper.approve_tool(approval_item, always_approve=always) + else: + context_wrapper.reject_tool( + approval_item, + always_reject=always, + rejection_message="server-a denied", + ) + + assert context_wrapper.is_tool_approved("lookup_account", "request-a-1") is approved + assert context_wrapper.is_tool_approved("lookup_account", "request-a-2") is None + if not approved: + assert ( + context_wrapper.get_rejection_message("lookup_account", "request-a-1") + == "server-a denied" + ) + + +def test_hosted_mcp_exact_query_precedes_colliding_function_sticky_decision() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + function_item = make_tool_approval_item( + agent, + call_id="function-call", + name="lookup_account", + ) + hosted_item = _make_hosted_mcp_approval_item( + agent, + request_id="hosted-call", + server_label="server-a", + ) + + context_wrapper.approve_tool(function_item, always_approve=True) + context_wrapper.reject_tool(hosted_item, rejection_message="hosted denial") + + assert context_wrapper.is_tool_approved("lookup_account", "hosted-call") is False + assert context_wrapper.is_tool_approved("lookup_account", "function-next") is True + assert context_wrapper.get_rejection_message("lookup_account", "hosted-call") == "hosted denial" + + +@pytest.mark.parametrize("hosted_approved", [True, False]) +def test_hosted_mcp_exact_query_does_not_inherit_function_rejection_reason( + hosted_approved: bool, +) -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + function_item = make_tool_approval_item( + agent, + call_id="shared-call", + name="lookup_account", + ) + hosted_item = _make_hosted_mcp_approval_item( + agent, + request_id="shared-call", + server_label="server-a", + ) + + context_wrapper.reject_tool(function_item, rejection_message="function denial") + if hosted_approved: + context_wrapper.approve_tool(hosted_item) + else: + context_wrapper.reject_tool(hosted_item) + + assert context_wrapper.is_tool_approved("lookup_account", "shared-call") is hosted_approved + assert context_wrapper.get_rejection_message("lookup_account", "shared-call") is None + + +@pytest.mark.parametrize("approved", [True, False]) +def test_hosted_mcp_exact_query_does_not_authorize_other_server(approved: bool) -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + server_a = _make_hosted_mcp_approval_item( + agent, + request_id="shared-request", + server_label="server-a", + ) + server_b = _make_hosted_mcp_approval_item( + agent, + request_id="shared-request", + server_label="server-b", + ) + + if approved: + context_wrapper.approve_tool(server_a) + else: + context_wrapper.reject_tool(server_a, rejection_message="server-a denied") + + assert context_wrapper.is_tool_approved("lookup_account", "shared-request") is approved + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "shared-request", + existing_pending=server_b, + ) + is None + ) + assert ( + context_wrapper.get_rejection_message( + "lookup_account", + "shared-request", + existing_pending=server_b, + ) + is None + ) + + +def test_hosted_mcp_legacy_bare_name_approval_does_not_grant_access() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + pending = _make_hosted_mcp_approval_item( + agent, + request_id="request-a-1", + server_label="server-a", + ) + context_wrapper._rebuild_approvals( # noqa: SLF001 + {"lookup_account": {"approved": True, "rejected": []}} + ) + + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-a-1", + existing_pending=pending, + ) + is None + ) + + +def test_hosted_mcp_scoped_identity_cannot_alias_legacy_tool_name() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + pending = _make_hosted_mcp_approval_item( + agent, + request_id="request-a-1", + server_label="server-a", + ) + colliding_legacy_name = '["hosted_mcp","server-a","lookup_account"]' + context_wrapper._rebuild_approvals( # noqa: SLF001 + {colliding_legacy_name: {"approved": True, "rejected": []}} + ) + + assert context_wrapper.is_tool_approved(colliding_legacy_name, "legacy-call") is True + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-a-1", + existing_pending=pending, + ) + is None + ) + + +def test_hosted_mcp_legacy_exact_call_decisions_remain_usable() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + approved = _make_hosted_mcp_approval_item( + agent, + request_id="request-approved", + server_label="server-a", + ) + rejected = _make_hosted_mcp_approval_item( + agent, + request_id="request-rejected", + server_label="server-a", + ) + rejected_without_raw_name = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "request-rejected", + }, + }, + tool_name="lookup_account", + ) + context_wrapper._rebuild_approvals( # noqa: SLF001 + { + "lookup_account": { + "approved": ["request-approved"], + "rejected": ["request-rejected"], + "rejection_messages": {"request-rejected": "legacy exact denial"}, + } + } + ) + + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-approved", + existing_pending=approved, + ) + is True + ) + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-rejected", + existing_pending=rejected, + ) + is False + ) + assert ( + context_wrapper.get_rejection_message( + "lookup_account", + "request-rejected", + existing_pending=rejected_without_raw_name, + ) + == "legacy exact denial" + ) + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-rejected", + existing_pending=rejected_without_raw_name, + ) + is False + ) + + +def test_hosted_mcp_persistent_decision_requires_complete_identity() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + malformed = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "name": "lookup_account", + "provider_data": { + "type": "mcp_approval_request", + "id": "request-a-1", + }, + }, + ) + + with pytest.raises(UserError, match="non-empty server_label and tool name"): + context_wrapper.approve_tool(malformed, always_approve=True) + + +def test_incomplete_hosted_mcp_uses_only_exact_call_decisions() -> None: + agent = Agent(name="test-agent") + malformed = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "name": "lookup_account", + "id": "request-a-1", + "provider_data": { + "type": "mcp_approval_request", + "id": "request-a-1", + }, + }, + ) + context_wrapper = RunContextWrapper(context=None) + context_wrapper._rebuild_approvals( # noqa: SLF001 + { + "lookup_account": { + "approved": True, + "rejected": True, + "sticky_rejection_message": "legacy denial", + } + } + ) + + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-a-1", + existing_pending=malformed, + ) + is None + ) + assert ( + context_wrapper.get_rejection_message( + "lookup_account", + "request-a-1", + existing_pending=malformed, + ) + is None + ) + + context_wrapper.approve_tool(malformed) + assert context_wrapper.is_tool_approved("lookup_account", "request-a-1") is True + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-a-1", + existing_pending=malformed, + ) + is True + ) + + context_wrapper.reject_tool(malformed, rejection_message="exact denial") + assert context_wrapper.is_tool_approved("lookup_account", "request-a-1") is False + assert ( + context_wrapper.get_approval_status( + "lookup_account", + "request-a-1", + existing_pending=malformed, + ) + is False + ) + assert ( + context_wrapper.get_rejection_message( + "lookup_account", + "request-a-1", + existing_pending=malformed, + ) + == "exact denial" + ) + + +def test_hosted_mcp_decision_requires_request_id() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + malformed = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "name": "lookup_account", + "provider_data": { + "type": "mcp_approval_request", + "server_label": "server-a", + }, + }, + ) + + with pytest.raises(UserError, match="non-empty request id"): + context_wrapper.approve_tool(malformed) + + assert context_wrapper._approvals == {} # noqa: SLF001 + + +@pytest.mark.parametrize("request_id", ["", 123]) +def test_hosted_mcp_invalid_request_id_does_not_mutate_approvals(request_id: object) -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + malformed = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "mcp_approval_request", + "id": request_id, + "arguments": "{}", + "name": "lookup_account", + "server_label": "server-a", + }, + ) + + with pytest.raises(UserError, match="non-empty request id"): + context_wrapper.reject_tool( + malformed, + always_reject=True, + rejection_message="must not persist", + ) + + assert context_wrapper._approvals == {} # noqa: SLF001 + + +def test_hosted_mcp_provider_invalid_request_id_does_not_fall_back_to_outer_id() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + malformed = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "call_id": "outer-id", + "name": "lookup_account", + "provider_data": { + "type": "mcp_approval_request", + "id": 123, + "server_label": "server-a", + "name": "lookup_account", + }, + }, + ) + + with pytest.raises(UserError, match="non-empty request id"): + context_wrapper.approve_tool(malformed) + + assert context_wrapper._approvals == {} # noqa: SLF001 + + +def test_hosted_mcp_request_type_is_not_used_as_missing_tool_name() -> None: + agent = Agent(name="test-agent") + context_wrapper = RunContextWrapper(context=None) + malformed = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "mcp_approval_request", + "id": "request-a-1", + "arguments": "{}", + "server_label": "server-a", + }, + ) + + with pytest.raises(UserError, match="non-empty server_label and tool name"): + context_wrapper.approve_tool(malformed, always_approve=True) + + assert context_wrapper._approvals == {} # noqa: SLF001 + + def test_latest_approval_decision_wins_for_call_id() -> None: agent = Agent(name="test-agent") context_wrapper = RunContextWrapper(context=None) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 45cd031aef..83bbdb7c1f 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -5519,6 +5519,37 @@ async def test_previous_schema_rejects_programmatic_tool_calling_items(self): with pytest.raises(UserError, match="Programmatic Tool Calling requires schema version"): await RunState.from_json(agent, json_data) + @pytest.mark.asyncio + async def test_schema_1_13_accepts_programmatic_tool_calling_items(self): + agent = Agent(name="TestAgent", tools=[ProgrammaticToolCallingTool()]) + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + state._model_responses = [ + ModelResponse( + output=[ + Program( + id="program_item", + call_id="call_program", + code="lookup()", + fingerprint="fingerprint", + type="program", + ) + ], + usage=Usage(), + response_id="response_1", + ) + ] + json_data = state.to_json() + json_data["$schemaVersion"] = "1.13" + + restored = await RunState.from_json(agent, json_data) + + assert restored._schema_version == "1.13" + assert restored._model_responses[0].output[0].type == "program" + @pytest.mark.asyncio async def test_previous_schema_ignores_program_like_arbitrary_context(self): agent = Agent(name="TestAgent") @@ -5555,6 +5586,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.10", "1.11", "1.12", + "1.13", CURRENT_SCHEMA_VERSION, } ) @@ -7507,3 +7539,322 @@ async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: assert resumed.context_wrapper.usage.input_tokens == ( usage_before_resume + nested_turn_usage.input_tokens ) + + +@pytest.mark.asyncio +async def test_hosted_mcp_approval_request_restores_matching_server_tool() -> None: + server_a = HostedMCPTool( + tool_config=Mcp( + type="mcp", + server_label="server-a", + server_url="https://server-a.example/mcp", + ) + ) + server_b = HostedMCPTool( + tool_config=Mcp( + type="mcp", + server_label="server-b", + server_url="https://server-b.example/mcp", + ) + ) + agent = Agent(name="test", tools=[server_a, server_b]) + request_item = McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ) + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(agent, context=context) + state._last_processed_response = make_processed_response( + mcp_approval_requests=[ + ToolRunMCPApprovalRequest( + request_item=request_item, + mcp_tool=server_a, + ) + ] + ) + + restored = await RunState.from_json(agent, state.to_json()) + + assert restored._last_processed_response is not None + restored_requests = restored._last_processed_response.mcp_approval_requests + assert len(restored_requests) == 1 + assert restored_requests[0].mcp_tool is server_a + + +@pytest.mark.asyncio +async def test_hosted_mcp_approval_round_trip_uses_typed_identity_records() -> None: + agent = Agent(name="test") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(agent, context=context) + approval = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ), + ) + state.approve(approval, always_approve=True) + + serialized = state.to_json() + + assert serialized["context"]["approvals"] == {} + assert serialized["context"]["hosted_mcp_approvals"] == [ + { + "identity": { + "type": "server_tool", + "server_label": "server-a", + "tool_name": "lookup_account", + }, + "decision": {"approved": True, "rejected": []}, + }, + { + "identity": { + "type": "query", + "tool_name": "lookup_account", + "request_id": "request-a", + }, + "decision": {"approved": ["request-a"], "rejected": []}, + }, + ] + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + assert restored._context.is_tool_approved("lookup_account", "request-a") is True + assert restored._context.is_tool_approved("lookup_account", "request-next") is None + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-next", + existing_pending=ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-next", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ), + ), + ) + is True + ) + + +@pytest.mark.asyncio +async def test_incomplete_hosted_mcp_query_round_trip_preserves_exact_decision() -> None: + agent = Agent(name="test") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(agent, context=context) + approval = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "request-a", + }, + }, + tool_name="lookup_account", + ) + state.reject(approval, rejection_message="exact denial") + + serialized = state.to_json() + + assert serialized["context"]["hosted_mcp_approvals"] == [ + { + "identity": { + "type": "request", + "request_id": "request-a", + }, + "decision": { + "approved": [], + "rejected": ["request-a"], + "rejection_messages": {"request-a": "exact denial"}, + }, + }, + { + "identity": { + "type": "query", + "tool_name": "lookup_account", + "request_id": "request-a", + }, + "decision": { + "approved": [], + "rejected": ["request-a"], + "rejection_messages": {"request-a": "exact denial"}, + }, + }, + ] + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + assert restored._context.is_tool_approved("lookup_account", "request-a") is False + assert restored._context.get_rejection_message("lookup_account", "request-a") == "exact denial" + assert restored._context.is_tool_approved("lookup_account", "request-next") is None + + +@pytest.mark.asyncio +async def test_hosted_mcp_rejection_query_round_trip_does_not_cross_servers() -> None: + agent = Agent(name="test") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(agent, context=context) + server_a = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ), + ) + server_b = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-b", + ), + ) + state.reject(server_a, rejection_message="server-a denied") + + restored = await RunState.from_json(agent, state.to_json()) + + assert restored._context is not None + assert restored._context.is_tool_approved("lookup_account", "shared-request") is False + assert ( + restored._context.get_rejection_message("lookup_account", "shared-request") + == "server-a denied" + ) + assert ( + restored._context.get_approval_status( + "lookup_account", + "shared-request", + existing_pending=server_b, + ) + is None + ) + assert ( + restored._context.get_rejection_message( + "lookup_account", + "shared-request", + existing_pending=server_b, + ) + is None + ) + + +@pytest.mark.asyncio +async def test_schema_1_13_ignores_typed_hosted_mcp_approval_records() -> None: + agent = Agent(name="test") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(agent, context=context) + approval = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ), + ) + state.approve(approval, always_approve=True) + serialized = state.to_json() + serialized["$schemaVersion"] = "1.13" + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-next", + existing_pending=ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-next", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ), + ), + ) + is None + ) + + +@pytest.mark.asyncio +async def test_schema_1_13_hosted_mcp_exact_call_decisions_remain_usable() -> None: + agent = Agent(name="test") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + context._rebuild_approvals( # noqa: SLF001 + { + "lookup_account": { + "approved": ["request-approved"], + "rejected": ["request-rejected"], + "rejection_messages": {"request-rejected": "legacy exact denial"}, + } + } + ) + state = make_state(agent, context=context) + serialized = state.to_json() + serialized["$schemaVersion"] = "1.13" + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + approved = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-approved", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ), + ) + rejected = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "request-rejected", + }, + }, + tool_name="lookup_account", + ) + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-approved", + existing_pending=approved, + ) + is True + ) + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-rejected", + existing_pending=rejected, + ) + is False + ) + assert ( + restored._context.get_rejection_message( + "lookup_account", + "request-rejected", + existing_pending=rejected, + ) + == "legacy exact denial" + ) diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 2326607353..16f70c074f 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -3409,6 +3409,490 @@ async def test_execute_tools_surfaces_hosted_mcp_interruptions_without_callback( ) +def test_manual_hosted_mcp_approval_does_not_reuse_stale_pending_identity(): + server_b = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-b", + "server_url": "https://server-b.example/mcp", + } + ) + agent = make_agent(tools=[server_b]) + pending_a = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ), + ) + current_b = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-b", + arguments="{}", + name="lookup_account", + ) + request_run = ToolRunMCPApprovalRequest(request_item=current_b, mcp_tool=server_b) + context_wrapper = make_context_wrapper() + context_wrapper._rebuild_approvals( # noqa: SLF001 + {"lookup_account": {"approved": ["shared-request"], "rejected": []}} + ) + + approved, pending = tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[request_run], + context_wrapper=context_wrapper, + existing_pending_by_call_id={"shared-request": pending_a}, + ) + + assert approved == [] + assert len(pending) == 1 + assert pending[0].raw_item is current_b + + +def test_hosted_mcp_approval_does_not_reuse_legacy_name_for_a_different_current_tool(): + server = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-a", + "server_url": "https://server-a.example/mcp", + } + ) + agent = make_agent(tools=[server]) + pending_lookup = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "shared-request", + "server_label": "server-a", + }, + }, + tool_name="lookup_account", + ) + current_delete = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="delete_account", + ) + request_run = ToolRunMCPApprovalRequest(request_item=current_delete, mcp_tool=server) + context_wrapper = make_context_wrapper() + context_wrapper._rebuild_approvals( # noqa: SLF001 + {"lookup_account": {"approved": ["shared-request"], "rejected": []}} + ) + + responses, pending = tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[request_run], + context_wrapper=context_wrapper, + existing_pending_by_call_id={"shared-request": pending_lookup}, + ) + + assert responses == [] + assert len(pending) == 1 + assert pending[0].raw_item is current_delete + + +def test_manual_hosted_mcp_approval_uses_current_scoped_decision_after_identity_conflict(): + server_b = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-b", + "server_url": "https://server-b.example/mcp", + } + ) + agent = make_agent(tools=[server_b]) + pending_a = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ), + ) + current_b = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-b", + arguments="{}", + name="lookup_account", + ) + current_b_approval = ToolApprovalItem(agent=agent, raw_item=current_b) + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(current_b_approval, always_approve=True) + + approved, pending = tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[ToolRunMCPApprovalRequest(request_item=current_b, mcp_tool=server_b)], + context_wrapper=context_wrapper, + existing_pending_by_call_id={"shared-request": pending_a}, + ) + + assert pending == [] + assert len(approved) == 1 + assert approved[0].raw_item["approve"] is True + + +@pytest.mark.asyncio +async def test_resolve_interrupted_turn_uses_current_scoped_hosted_mcp_decision(): + server_b = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-b", + "server_url": "https://server-b.example/mcp", + } + ) + agent = make_agent(tools=[server_b]) + pending_a = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ), + ) + current_b = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-b", + arguments="{}", + name="lookup_account", + ) + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool( + ToolApprovalItem(agent=agent, raw_item=current_b), + always_approve=True, + ) + processed_response = make_processed_response( + new_items=[MCPApprovalRequestItem(raw_item=current_b, agent=agent)], + mcp_approval_requests=[ + ToolRunMCPApprovalRequest(request_item=current_b, mcp_tool=server_b) + ], + ) + + result = await turn_resolution.resolve_interrupted_turn( + bindings=_bind_agent(agent), + original_input="test", + original_pre_step_items=[pending_a], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + ) + + assert not isinstance(result.next_step, NextStepInterruption) + responses = [ + item + for item in result.new_step_items + if isinstance(item, MCPApprovalResponseItem) + and item.raw_item.get("approval_request_id") == "shared-request" + ] + assert len(responses) == 1 + assert all(item.raw_item["approve"] is True for item in responses) + assert not any( + isinstance(item, ToolApprovalItem) + and getattr(item.raw_item, "server_label", None) == "server-a" + for item in result.new_step_items + ) + + +@pytest.mark.asyncio +async def test_resolve_interrupted_turn_keeps_callback_owned_hosted_mcp_request_out_of_pending(): + callback_tool = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-a", + "server_url": "https://server-a.example/mcp", + }, + on_approval_request=lambda request: {"approve": True}, + ) + agent = make_agent(tools=[callback_tool]) + request = McpApprovalRequest( + id="callback-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ) + pending = ToolApprovalItem(agent=agent, raw_item=request) + processed_response = make_processed_response( + new_items=[MCPApprovalRequestItem(raw_item=request, agent=agent)], + mcp_approval_requests=[ + ToolRunMCPApprovalRequest(request_item=request, mcp_tool=callback_tool) + ], + ) + + result = await turn_resolution.resolve_interrupted_turn( + bindings=_bind_agent(agent), + original_input="test", + original_pre_step_items=[pending], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=make_context_wrapper(), + run_config=RunConfig(), + ) + + assert not isinstance(result.next_step, NextStepInterruption) + responses = [ + item + for item in result.new_step_items + if isinstance(item, MCPApprovalResponseItem) + and item.raw_item.get("approval_request_id") == "callback-request" + ] + assert len(responses) == 1 + assert responses[0].raw_item["approve"] is True + assert not any(isinstance(item, ToolApprovalItem) for item in result.pre_step_items) + assert not any(isinstance(item, ToolApprovalItem) for item in result.new_step_items) + + +def test_manual_hosted_mcp_approval_keeps_incomplete_exact_call_decision(): + server_a = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-a", + "server_url": "https://server-a.example/mcp", + } + ) + agent = make_agent(tools=[server_a]) + pending_unknown = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "shared-request", + "name": "lookup_account", + }, + }, + ) + current = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ) + request_run = ToolRunMCPApprovalRequest(request_item=current, mcp_tool=server_a) + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(pending_unknown) + + approved, pending = tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[request_run], + context_wrapper=context_wrapper, + existing_pending_by_call_id={"shared-request": pending_unknown}, + ) + + assert pending == [] + assert len(approved) == 1 + assert approved[0].raw_item["approve"] is True + + +def test_manual_hosted_mcp_approval_prefers_complete_current_scoped_identity(): + server_a = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-a", + "server_url": "https://server-a.example/mcp", + } + ) + agent = make_agent(tools=[server_a]) + pending_partial = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "shared-request", + "name": "lookup_account", + }, + }, + tool_name="lookup_account", + ) + current = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ) + context_wrapper = make_context_wrapper() + context_wrapper._rebuild_hosted_mcp_approvals( # noqa: SLF001 + [ + { + "identity": { + "type": "server_tool", + "server_label": "server-a", + "tool_name": "lookup_account", + }, + "decision": {"approved": True, "rejected": []}, + } + ] + ) + + approved, pending = tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[ToolRunMCPApprovalRequest(request_item=current, mcp_tool=server_a)], + context_wrapper=context_wrapper, + existing_pending_by_call_id={"shared-request": pending_partial}, + ) + + assert pending == [] + assert len(approved) == 1 + assert approved[0].raw_item["approve"] is True + + +def test_manual_hosted_mcp_approval_does_not_apply_legacy_exact_without_pending(): + server_b = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-b", + "server_url": "https://server-b.example/mcp", + } + ) + agent = make_agent(tools=[server_b]) + current = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-b", + arguments="{}", + name="lookup_account", + ) + context_wrapper = make_context_wrapper() + context_wrapper._rebuild_approvals( # noqa: SLF001 + {"lookup_account": {"approved": ["shared-request"], "rejected": []}} + ) + + approved, pending = tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[ToolRunMCPApprovalRequest(request_item=current, mcp_tool=server_b)], + context_wrapper=context_wrapper, + ) + + assert approved == [] + assert len(pending) == 1 + assert pending[0].raw_item is current + + +@pytest.mark.asyncio +async def test_resolve_interrupted_turn_prefers_wrapped_pending_exact_over_legacy(): + server_a = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-a", + "server_url": "https://server-a.example/mcp", + } + ) + agent = make_agent(tools=[server_a]) + pending_partial = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "provider_data": { + "type": "mcp_approval_request", + "id": "shared-request", + "name": "lookup_account", + }, + }, + tool_name="lookup_account", + ) + current = McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ) + context_wrapper = make_context_wrapper() + context_wrapper._rebuild_approvals( # noqa: SLF001 + {"lookup_account": {"approved": ["shared-request"], "rejected": []}} + ) + context_wrapper.reject_tool(pending_partial, rejection_message="new exact denial") + processed_response = make_processed_response( + new_items=[MCPApprovalRequestItem(raw_item=current, agent=agent)], + mcp_approval_requests=[ToolRunMCPApprovalRequest(request_item=current, mcp_tool=server_a)], + ) + + result = await turn_resolution.resolve_interrupted_turn( + bindings=_bind_agent(agent), + original_input="test", + original_pre_step_items=[pending_partial], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + ) + + assert not isinstance(result.next_step, NextStepInterruption) + responses = [ + item + for item in result.new_step_items + if isinstance(item, MCPApprovalResponseItem) + and item.raw_item.get("approval_request_id") == "shared-request" + ] + assert len(responses) == 1 + assert responses[0].raw_item["approve"] is False + assert responses[0].raw_item["reason"] == "new exact denial" + assert not any(isinstance(item, ToolApprovalItem) for item in result.pre_step_items) + assert not any(isinstance(item, ToolApprovalItem) for item in result.new_step_items) + + +def test_incomplete_current_hosted_mcp_request_does_not_reuse_scoped_pending_identity(): + server_a = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "server-a", + "server_url": "https://server-a.example/mcp", + } + ) + agent = make_agent(tools=[server_a]) + pending_complete = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="shared-request", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ), + ) + current_incomplete = McpApprovalRequest.model_construct( + id="shared-request", + type="mcp_approval_request", + arguments="{}", + ) + request_run = ToolRunMCPApprovalRequest( + request_item=current_incomplete, + mcp_tool=server_a, + ) + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(pending_complete, always_approve=True) + + approved, manual_pending = tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[request_run], + context_wrapper=context_wrapper, + existing_pending_by_call_id={"shared-request": pending_complete}, + ) + + assert approved == [] + assert len(manual_pending) == 1 + assert manual_pending[0].raw_item is current_incomplete + + @pytest.mark.asyncio async def test_execute_tools_uses_public_agent_for_hosted_mcp_interruptions(): """Hosted MCP approval items should expose the public agent when execution uses a clone.""" From 105aeef401c34d465be1100ce206701f505d3790 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 02:27:40 -0500 Subject: [PATCH 210/473] fix(streaming): announce the chat completions assistant message once (#4275) --- src/agents/models/chatcmpl_stream_handler.py | 78 +++++++++++-------- .../test_openai_chatcompletions_stream.py | 64 +++++++++++++++ 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 23dcd2f5d0..7187b1219d 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -814,23 +814,28 @@ async def handle_stream( logprobs=[], ), ) - # Start a new assistant message stream - assistant_item = ResponseOutputMessage( - id=FAKE_RESPONSES_ID, - content=[], - role="assistant", - type="message", - status="in_progress", - ) - if state.provider_data: - assistant_item.provider_data = state.provider_data.copy() # type: ignore[attr-defined] - # Notify consumers of the start of a new output message + first content part - yield ResponseOutputItemAddedEvent( - item=assistant_item, - output_index=output_layout.assistant_message_output_index(state), - type="response.output_item.added", - sequence_number=sequence_number.get_and_increment(), - ) + # A refusal part already opened this assistant message, so only the new + # content part is announced here. Re-announcing the message would emit a + # second response.output_item.added for an item that is already open and + # is closed by a single response.output_item.done. + if content_index == 0: + # Start a new assistant message stream + assistant_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="in_progress", + ) + if state.provider_data: + assistant_item.provider_data = state.provider_data.copy() # type: ignore[attr-defined] + # Notify consumers of the start of a new output message + yield ResponseOutputItemAddedEvent( + item=assistant_item, + output_index=output_layout.assistant_message_output_index(state), + type="response.output_item.added", + sequence_number=sequence_number.get_and_increment(), + ) yield ResponseContentPartAddedEvent( content_index=state.text_content_index_and_output[0], item_id=FAKE_RESPONSES_ID, @@ -893,23 +898,28 @@ async def handle_stream( refusal_index, ResponseOutputRefusal(refusal="", type="refusal"), ) - # Start a new assistant message if one doesn't exist yet (in-progress) - assistant_item = ResponseOutputMessage( - id=FAKE_RESPONSES_ID, - content=[], - role="assistant", - type="message", - status="in_progress", - ) - if state.provider_data: - assistant_item.provider_data = state.provider_data.copy() # type: ignore[attr-defined] - # Notify downstream that assistant message + first content part are starting - yield ResponseOutputItemAddedEvent( - item=assistant_item, - output_index=output_layout.assistant_message_output_index(state), - type="response.output_item.added", - sequence_number=sequence_number.get_and_increment(), - ) + # A text part already opened this assistant message, so only the new + # content part is announced here. Re-announcing the message would emit a + # second response.output_item.added for an item that is already open and + # is closed by a single response.output_item.done. + if refusal_index == 0: + # Start a new assistant message if one doesn't exist yet (in-progress) + assistant_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="in_progress", + ) + if state.provider_data: + assistant_item.provider_data = state.provider_data.copy() # type: ignore[attr-defined] + # Notify downstream that the assistant message is starting + yield ResponseOutputItemAddedEvent( + item=assistant_item, + output_index=output_layout.assistant_message_output_index(state), + type="response.output_item.added", + sequence_number=sequence_number.get_and_increment(), + ) yield ResponseContentPartAddedEvent( content_index=state.refusal_content_index_and_output[0], item_id=FAKE_RESPONSES_ID, diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 7f4f939cc4..fcb8bc74ab 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -1396,6 +1396,70 @@ async def test_stream_handler_places_text_after_existing_refusal_part() -> None: assert assistant_item.content[1].text == "partial" +@pytest.mark.parametrize( + "deltas", + [ + pytest.param( + [ + ChoiceDelta.model_construct(refusal="blocked"), + ChoiceDelta.model_construct(content="partial"), + ], + id="refusal_then_text", + ), + pytest.param( + [ + ChoiceDelta.model_construct(content="partial"), + ChoiceDelta.model_construct(refusal="blocked"), + ], + id="text_then_refusal", + ), + ], +) +@pytest.mark.asyncio +async def test_stream_handler_announces_assistant_message_once_for_text_and_refusal( + deltas: list[ChoiceDelta], +) -> None: + """A message holding both a text and a refusal part is announced by a single added event.""" + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=delta)], + ) + for delta in deltas + ] + + events = await _collect_handler_events(*chunks) + + message_added = [ + event + for event in events + if event.type == "response.output_item.added" + and isinstance(event.item, ResponseOutputMessage) + ] + message_done = [ + event + for event in events + if event.type == "response.output_item.done" + and isinstance(event.item, ResponseOutputMessage) + ] + assert len(message_added) == 1 + assert len(message_done) == 1 + assert message_added[0].output_index == message_done[0].output_index + + # The single added event still opens the message before its first content part. + event_types = [event.type for event in events] + assert event_types.index("response.output_item.added") < event_types.index( + "response.content_part.added" + ) + # Both content parts are still announced, one each. + part_added = [event for event in events if event.type == "response.content_part.added"] + assert sorted(event.content_index for event in part_added) == [0, 1] + assert {event.part.type for event in part_added} == {"output_text", "refusal"} + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_stream_response_passes_strict_validation_to_stream_handler(monkeypatch) -> None: From 50749a686b1a7b2b656bf16763f7e448d6fd45d0 Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Fri, 7 Aug 2026 00:32:23 -0700 Subject: [PATCH 211/473] fix(models): strip placeholder item IDs without provider_data on the Responses path (#4266) --- src/agents/models/openai_responses.py | 13 +++--- ...penai_responses_api_incompatible_fields.py | 45 ++++++++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index c585e94eb6..8ff70ef3bf 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -916,17 +916,20 @@ def _remove_openai_responses_api_incompatible_fields(self, list_input: list[Any] This data transformation does not always guarantee that items from other provider interactions are accepted by the OpenAI Responses API. - Only items with truthy provider_data are processed. This function handles the following incompatibilities: - provider_data: Removes fields specific to other providers (e.g., Gemini, Claude). - Fake IDs: Removes temporary IDs (FAKE_RESPONSES_ID) that should not be sent to OpenAI. - Reasoning items: Filters out provider-specific reasoning items entirely. """ - # Early return optimization: if no item has provider_data, return unchanged. - has_provider_data = any( - isinstance(item, dict) and item.get("provider_data") for item in list_input + # Early return optimization: skip the copy when nothing needs cleaning. Placeholder IDs + # are emitted without provider_data by several SDK paths, so they have to be checked + # independently of it. + needs_cleaning = any( + isinstance(item, dict) + and (item.get("provider_data") or item.get("id") == FAKE_RESPONSES_ID) + for item in list_input ) - if not has_provider_data: + if not needs_cleaning: return list_input result = [] diff --git a/tests/models/test_remove_openai_responses_api_incompatible_fields.py b/tests/models/test_remove_openai_responses_api_incompatible_fields.py index 87c91196b2..709516eede 100644 --- a/tests/models/test_remove_openai_responses_api_incompatible_fields.py +++ b/tests/models/test_remove_openai_responses_api_incompatible_fields.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock import pytest +from agents import Agent, ModelSettings +from agents.items import TResponseInputItem from agents.models.fake_id import FAKE_RESPONSES_ID from agents.models.openai_responses import OpenAIResponsesModel +from agents.run_internal.error_handlers import create_message_output_item @pytest.fixture @@ -91,6 +94,23 @@ def test_removes_fake_responses_id(self, model: OpenAIResponsesModel): assert "id" not in result[0] assert result[0]["content"] == "hello" + def test_removes_fake_responses_id_without_provider_data(self, model: OpenAIResponsesModel): + """Placeholder IDs are stripped even when no item carries provider_data. + + Several SDK paths build output items with FAKE_RESPONSES_ID and no provider_data, so + the placeholder cannot be assumed to travel alongside it. + """ + list_input = [ + {"role": "user", "content": "hi"}, + {"type": "message", "id": FAKE_RESPONSES_ID, "content": "hello"}, + ] + + result = model._remove_openai_responses_api_incompatible_fields(list_input) + + assert len(result) == 2 + assert "id" not in result[1] + assert result[1]["content"] == "hello" + def test_preserves_real_ids(self, model: OpenAIResponsesModel): """Real IDs (not FAKE_RESPONSES_ID) should be preserved.""" list_input = [ @@ -160,3 +180,26 @@ def test_combined_scenario(self, model: OpenAIResponsesModel): assert result[3]["content"] == "The weather is 72F" assert "id" not in result[3] assert "provider_data" not in result[3] + + def test_request_payload_drops_sdk_generated_placeholder_ids( + self, model: OpenAIResponsesModel + ) -> None: + """A replayed SDK-built assistant message must not send its placeholder ID upstream.""" + message = create_message_output_item( + Agent(name="test"), "the run error handler final output" + ).raw_item + + create_kwargs = model._build_response_create_kwargs( + system_instructions=None, + input=[ + {"role": "user", "content": "hi"}, + cast(TResponseInputItem, message.model_dump()), + ], + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + ) + + assert message.id == FAKE_RESPONSES_ID + assert "id" not in create_kwargs["input"][1] From 47ff39bf7904bbc8c06681f5fa70c0c3025b882a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 17:23:46 +0900 Subject: [PATCH 212/473] fix(sandbox): retain program items in memory rollouts (#4276) Co-authored-by: Henry Su --- src/agents/sandbox/memory/rollouts.py | 2 + tests/sandbox/test_memory.py | 119 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/agents/sandbox/memory/rollouts.py b/src/agents/sandbox/memory/rollouts.py index 2ca64dd596..1c0904932b 100644 --- a/src/agents/sandbox/memory/rollouts.py +++ b/src/agents/sandbox/memory/rollouts.py @@ -38,6 +38,8 @@ "mcp_approval_request", "mcp_approval_response", "mcp_call", + "program", + "program_output", "shell_call", "shell_call_output", "tool_search_call", diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index 1a8ed9a560..dc889704b1 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -11,6 +11,9 @@ import pytest from openai.types.responses import ResponseCustomToolCall, ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call import CallerProgram +from openai.types.responses.response_input_item_param import FunctionCallOutput +from openai.types.responses.response_output_item import Program, ProgramOutput from openai.types.responses.response_output_message import ResponseOutputMessage from openai.types.responses.response_reasoning_item import ResponseReasoningItem @@ -33,6 +36,8 @@ CompactionItem, MessageOutputItem, ToolApprovalItem, + ToolCallItem, + ToolCallOutputItem, TResponseOutputItem, ) from agents.result import RunResult, RunResultStreaming @@ -279,6 +284,120 @@ def test_build_rollout_payload_filters_developer_and_noisy_items() -> None: assert payload["final_output"] == "done" +def test_build_rollout_payload_keeps_programmatic_tool_calling_items() -> None: + agent = Agent(name="test") + program = Program( + id="program_item", + call_id="call_prog_1", + code='lookup_inventory(sku="A-1")', + fingerprint="fingerprint", + type="program", + ) + function_call = ResponseFunctionToolCall( + id="function_item", + call_id="call_fn_1", + name="lookup_inventory", + arguments='{"sku":"A-1"}', + caller=CallerProgram(type="program", caller_id="call_prog_1"), + type="function_call", + ) + function_call_output = cast( + FunctionCallOutput, + { + "type": "function_call_output", + "call_id": "call_fn_1", + "output": '{"available_units":42}', + }, + ) + program_output = ProgramOutput( + id="program_output_item", + call_id="call_prog_1", + result='{"sku":"A-1","available_units":42}', + status="completed", + type="program_output", + ) + + payload = build_rollout_payload( + input="what is in stock?", + new_items=[ + ToolCallItem(agent=agent, raw_item=program), + ToolCallItem(agent=agent, raw_item=function_call), + ToolCallOutputItem(agent=agent, raw_item=function_call_output, output="42"), + ToolCallOutputItem(agent=agent, raw_item=program_output, output="42"), + ], + final_output="done", + interruptions=[], + terminal_metadata=RolloutTerminalMetadata( + terminal_state="completed", + has_final_output=True, + ), + ) + + generated_items = payload["generated_items"] + assert [item["type"] for item in generated_items] == [ + "program", + "function_call", + "function_call_output", + "program_output", + ] + # The retained function call points back at the program that issued it, so the program + # it names has to survive alongside it. + assert generated_items[1]["caller"] == {"type": "program", "caller_id": "call_prog_1"} + assert generated_items[0]["call_id"] == "call_prog_1" + assert generated_items[0]["code"] == 'lookup_inventory(sku="A-1")' + assert generated_items[3]["call_id"] == "call_prog_1" + assert generated_items[3]["result"] == '{"sku":"A-1","available_units":42}' + + +def test_build_rollout_payload_keeps_program_items_from_input() -> None: + payload = build_rollout_payload( + input=[ + cast( + TResponseInputItem, + { + "type": "program", + "call_id": "call_prog_1", + "code": 'lookup_inventory(sku="A-1")', + "fingerprint": "fingerprint", + }, + ), + cast( + TResponseInputItem, + { + "type": "program_output", + "call_id": "call_prog_1", + "result": '{"available_units":42}', + "status": "completed", + }, + ), + ], + new_items=[], + final_output=None, + interruptions=[], + terminal_metadata=RolloutTerminalMetadata(terminal_state="completed"), + ) + + assert [item["type"] for item in payload["input"]] == ["program", "program_output"] + + +def test_build_rollout_payload_still_drops_hosted_items_outside_the_included_set() -> None: + """Program items are included because every other call/output pair is; hosted tool calls + with no output half stay out.""" + payload = build_rollout_payload( + input=[ + cast(TResponseInputItem, {"type": "file_search_call", "id": "fs_1", "queries": []}), + cast(TResponseInputItem, {"type": "image_generation_call", "id": "ig_1"}), + cast(TResponseInputItem, {"type": "program", "call_id": "call_prog_1", "code": "x()"}), + ], + new_items=[], + final_output=None, + interruptions=[], + terminal_metadata=RolloutTerminalMetadata(terminal_state="completed"), + ) + + assert [item["type"] for item in payload["input"]] == ["program"] + + def test_build_rollout_payload_serializes_model_interruptions_as_dicts() -> None: agent = Agent(name="test") raw = ResponseFunctionToolCall( From a3f2bb8eea4af1d4a4acb7ff3ea523eacb1e9ebb Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Fri, 7 Aug 2026 03:11:35 -0700 Subject: [PATCH 213/473] fix(sessions): apply the reasoning item id policy to stored session history (#4278) --- src/agents/run.py | 24 ++--- src/agents/run_internal/items.py | 11 +++ src/agents/run_internal/run_loop.py | 1 + .../run_internal/session_persistence.py | 9 ++ tests/test_agent_runner.py | 85 +++++++++++++++++- tests/test_agent_runner_streamed.py | 87 ++++++++++++++++++- 6 files changed, 204 insertions(+), 13 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 7ac81be072..6831334bf2 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -523,7 +523,16 @@ async def run( run_config = RunConfig() if run_config is None else _coerce_run_config(run_config) is_resumed_state = isinstance(input, RunState) - run_state: RunState[TContext] | None = None + run_state: RunState[TContext] | None = ( + cast(RunState[TContext], input) if is_resumed_state else None + ) + resolved_reasoning_item_id_policy: ReasoningItemIdPolicy | None = ( + run_config.reasoning_item_id_policy + if run_config.reasoning_item_id_policy is not None + else (run_state._reasoning_item_id_policy if run_state is not None else None) + ) + if run_state is not None: + run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy starting_input = input if not is_resumed_state else None original_user_input: str | list[TResponseInputItem] | None = None session_input_items_for_persistence: list[TResponseInputItem] | None = ( @@ -533,8 +542,7 @@ async def run( # exactly those items (and not the full history). last_saved_input_snapshot_for_rewind: list[TResponseInputItem] | None = None - if is_resumed_state: - run_state = cast(RunState[TContext], input) + if is_resumed_state and run_state is not None: ( conversation_id, previous_response_id, @@ -590,6 +598,7 @@ async def run( run_config.session_settings, include_history_in_prepared_input=False, preserve_dropped_new_items=True, + reasoning_item_id_policy=resolved_reasoning_item_id_policy, wrapper=context_wrapper, ) original_input_for_state = raw_input @@ -603,18 +612,11 @@ async def run( session, run_config.session_input_callback, run_config.session_settings, + reasoning_item_id_policy=resolved_reasoning_item_id_policy, wrapper=context_wrapper, ) original_input_for_state = prepared_input - resolved_reasoning_item_id_policy: ReasoningItemIdPolicy | None = ( - run_config.reasoning_item_id_policy - if run_config.reasoning_item_id_policy is not None - else (run_state._reasoning_item_id_policy if run_state is not None else None) - ) - if run_state is not None: - run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy - # Check whether to enable OpenAI server-managed conversation if ( conversation_id is not None diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index 9a4c0ea6bf..ad9bb25cae 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -60,6 +60,7 @@ "REJECTION_MESSAGE", "TOOL_CALL_SESSION_DESCRIPTION_KEY", "TOOL_CALL_SESSION_TITLE_KEY", + "apply_reasoning_item_id_policy", "copy_input_items", "drop_orphan_function_calls", "ensure_input_item_format", @@ -697,6 +698,16 @@ def strip_internal_input_item_metadata(item: TResponseInputItem) -> TResponseInp return cast(TResponseInputItem, cleaned) +def apply_reasoning_item_id_policy( + items: list[TResponseInputItem], + reasoning_item_id_policy: ReasoningItemIdPolicy | None, +) -> list[TResponseInputItem]: + """Apply the reasoning item ID policy to already-converted input items.""" + if not _should_omit_reasoning_item_ids(reasoning_item_id_policy): + return items + return [_without_reasoning_item_id(item) for item in items] + + def _should_omit_reasoning_item_ids(reasoning_item_id_policy: ReasoningItemIdPolicy | None) -> bool: return reasoning_item_id_policy == "omit" diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 1429270c2e..c2d1c08eaf 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -779,6 +779,7 @@ def _sync_conversation_tracking_from_tracker() -> None: run_config.session_settings, include_history_in_prepared_input=not server_manages_conversation, preserve_dropped_new_items=True, + reasoning_item_id_policy=resolved_reasoning_item_id_policy, wrapper=context_wrapper, ) streamed_result.input = prepared_input diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index ae4a369a52..fee753b40c 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -36,6 +36,7 @@ NestedHistoryOwnedItem, NestedHistoryOwnedItemRef, ReasoningItemIdPolicy, + apply_reasoning_item_id_policy, copy_input_items, deduplicate_input_items_preferring_latest, digest_input_item, @@ -206,6 +207,7 @@ async def prepare_input_with_session( *, include_history_in_prepared_input: bool = True, preserve_dropped_new_items: bool = False, + reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, wrapper: RunContextWrapper[Any] | None = None, ) -> tuple[str | list[TResponseInputItem], list[TResponseInputItem]]: """Prepare model input from session history plus the new turn input. @@ -242,6 +244,13 @@ async def prepare_input_with_session( converted_history = [ strip_internal_input_item_metadata(ensure_input_item_format(item)) for item in history ] + if not is_openai_conversation_session: + # History written before the caller opted into "omit" still carries server-assigned + # reasoning IDs. Apply the policy on read too, the same way `save_result_to_session` + # applies it on write, so replaying that history cannot 404 on a stale `rs_...` ID. + converted_history = apply_reasoning_item_id_policy( + converted_history, reasoning_item_id_policy + ) new_input_list = [ ensure_input_item_format(item) for item in ItemHelpers.input_to_new_input_list(input) diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index d37e8cf608..b2512b6ab4 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -12,7 +12,7 @@ import httpx import pytest -from openai import APIConnectionError, BadRequestError +from openai import APIConnectionError, BadRequestError, NotFoundError from openai.types.responses import ResponseFunctionToolCall from openai.types.responses.response_output_item import McpApprovalRequest from openai.types.responses.response_output_text import AnnotationFileCitation, ResponseOutputText @@ -1231,6 +1231,89 @@ def reintroduce_reasoning_id(data: Any) -> Any: assert "id" not in history_reasoning +class _RevokedReasoningIdModel(FakeModel): + """FakeModel that 404s like the Responses API when a revoked reasoning ID is replayed.""" + + def __init__(self) -> None: + super().__init__() + self.revoked_reasoning_ids: set[str] = set() + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + *args: Any, + **kwargs: Any, + ) -> ModelResponse: + if isinstance(input, list): + for item in input: + if not isinstance(item, dict) or item.get("type") != "reasoning": + continue + item_id = item.get("id") + if item_id in self.revoked_reasoning_ids: + message = f"Item with id '{item_id}' not found." + body = {"error": {"message": message, "type": "invalid_request_error"}} + raise NotFoundError( + message, + response=httpx.Response( + 404, + request=httpx.Request("POST", "https://api.openai.com/v1/responses"), + json=body, + ), + body=body, + ) + return await super().get_response(system_instructions, input, *args, **kwargs) + + +@pytest.mark.asyncio +async def test_omit_policy_strips_reasoning_ids_already_stored_in_the_session() -> None: + """Adopting `omit` must also cover reasoning IDs a session recorded before it was set. + + Reproduces https://github.com/openai/openai-agents-python/issues/2020: a triage agent hands + off, its empty-summary reasoning item is persisted to the session, the server later drops the + item, and every later turn of that conversation fails with + `404 Item with id 'rs_...' not found`. + """ + model = _RevokedReasoningIdModel() + specialist = Agent(name="specialist", model=model) + triage = Agent(name="triage", model=model, handoffs=[specialist]) + + session = SQLiteSession("issue-2020") + + # Turn 1 predates the mitigation, so the session records the reasoning ID. + model.add_multiple_turn_outputs( + [ + [ + ResponseReasoningItem(id="rs_triage", type="reasoning", summary=[]), + get_handoff_tool_call(specialist), + ], + [get_text_message("handled")], + ] + ) + first = await Runner.run(triage, input="hello", session=session) + assert first.final_output == "handled" + stored_reasoning = _find_reasoning_input_item(await session.get_items()) + assert stored_reasoning is not None + assert stored_reasoning.get("id") == "rs_triage" + + # The server no longer resolves that reasoning item. + model.revoked_reasoning_ids.add("rs_triage") + + # Turn 2 opts into the documented mitigation for this failure. + model.add_multiple_turn_outputs([[get_text_message("done")]]) + second = await Runner.run( + triage, + input="anything else?", + session=session, + run_config=RunConfig(reasoning_item_id_policy="omit"), + ) + + assert second.final_output == "done" + replayed_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + assert replayed_reasoning is not None + assert "id" not in replayed_reasoning + + @pytest.mark.asyncio async def test_resumed_run_uses_serialized_reasoning_item_id_policy() -> None: model = FakeModel() diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 2a3c605817..c09ccb957c 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -8,7 +8,7 @@ import httpx import pytest -from openai import APIConnectionError, BadRequestError +from openai import APIConnectionError, BadRequestError, NotFoundError from openai.types.responses import ( ResponseCompletedEvent, ResponseErrorEvent, @@ -36,6 +36,7 @@ OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, + SQLiteSession, ToolGuardrailFunctionOutput, ToolInputGuardrailData, ToolOutputGuardrailData, @@ -830,6 +831,90 @@ async def test_streamed_reasoning_item_id_policy_omits_follow_up_reasoning_ids() assert "id" not in history_reasoning +class _StreamedRevokedReasoningIdModel(FakeModel): + """FakeModel that 404s like the Responses API when a revoked reasoning ID is replayed.""" + + def __init__(self) -> None: + super().__init__() + self.revoked_reasoning_ids: set[str] = set() + + def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + *args: Any, + **kwargs: Any, + ) -> AsyncIterator[TResponseStreamEvent]: + if isinstance(input, list): + for item in input: + if not isinstance(item, dict) or item.get("type") != "reasoning": + continue + item_id = item.get("id") + if item_id in self.revoked_reasoning_ids: + message = f"Item with id '{item_id}' not found." + body = {"error": {"message": message, "type": "invalid_request_error"}} + raise NotFoundError( + message, + response=httpx.Response( + 404, + request=httpx.Request("POST", "https://api.openai.com/v1/responses"), + json=body, + ), + body=body, + ) + return super().stream_response(system_instructions, input, *args, **kwargs) + + +@pytest.mark.asyncio +async def test_streamed_omit_policy_strips_reasoning_ids_already_stored_in_the_session() -> None: + """Adopting `omit` must also cover reasoning IDs a session recorded before it was set. + + Streaming counterpart of the non-streamed regression test for + https://github.com/openai/openai-agents-python/issues/2020. + """ + model = _StreamedRevokedReasoningIdModel() + specialist = Agent(name="specialist", model=model) + triage = Agent(name="triage", model=model, handoffs=[specialist]) + session = SQLiteSession("issue-2020-streamed") + + # Turn 1 predates the mitigation, so the session records the reasoning ID. + model.add_multiple_turn_outputs( + [ + [ + ResponseReasoningItem(id="rs_triage", type="reasoning", summary=[]), + get_handoff_tool_call(specialist), + ], + [get_text_message("handled")], + ] + ) + first = Runner.run_streamed(triage, input="hello", session=session) + async for _ in first.stream_events(): + pass + assert first.final_output == "handled" + stored_reasoning = _find_reasoning_input_item(await session.get_items()) + assert stored_reasoning is not None + assert stored_reasoning.get("id") == "rs_triage" + + # The server no longer resolves that reasoning item. + model.revoked_reasoning_ids.add("rs_triage") + + # Turn 2 opts into the documented mitigation for this failure. + model.add_multiple_turn_outputs([[get_text_message("done")]]) + second = Runner.run_streamed( + triage, + input="anything else?", + session=session, + run_config=RunConfig(reasoning_item_id_policy="omit"), + ) + async for _ in second.stream_events(): + pass + + assert second.final_output == "done" + replayed_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + assert replayed_reasoning is not None + assert "id" not in replayed_reasoning + + @pytest.mark.asyncio async def test_streamed_run_again_persists_tool_items_to_session(): model = FakeModel() From 0c60a196af1236044a829e39b10f22a9cedaa326 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 19:24:39 +0900 Subject: [PATCH 214/473] feat(models): preserve raw usage payloads (#4279) --- src/agents/extensions/models/any_llm_model.py | 85 +++++++++++++++++-- src/agents/items.py | 9 ++ src/agents/model_settings.py | 11 +++ src/agents/models/chatcmpl_stream_handler.py | 15 +++- src/agents/models/openai_chatcompletions.py | 11 ++- src/agents/models/openai_responses.py | 15 +++- src/agents/run_internal/run_loop.py | 7 +- src/agents/usage.py | 60 ++++++++++++- tests/model_settings/test_serialization.py | 11 ++- tests/models/test_any_llm_model.py | 11 ++- .../test_litellm_chatcompletions_stream.py | 5 +- tests/models/test_litellm_logprobs.py | 5 +- tests/models/test_openai_chatcompletions.py | 81 ++++++++++++++++++ .../test_openai_chatcompletions_stream.py | 17 +++- tests/models/test_openai_responses.py | 4 +- tests/test_agent_runner_streamed.py | 25 +++++- tests/test_run_state.py | 4 + tests/test_usage.py | 48 ++++++++++- 18 files changed, 397 insertions(+), 27 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 74538bcd7d..9169c49508 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -6,7 +6,7 @@ import inspect import json import time -from collections.abc import AsyncGenerator, AsyncIterator, Iterable +from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Mapping from copy import copy from typing import TYPE_CHECKING, Any, Literal, cast, overload @@ -52,7 +52,12 @@ from ...tracing import generation_span, response_span from ...tracing.span_data import GenerationSpanData from ...tracing.spans import Span -from ...usage import Usage +from ...usage import ( + Usage, + _attach_raw_usage_snapshot, + _extract_raw_usage_snapshot, + _raw_usage_snapshot, +) from ...util._error_tracing import model_span_errors, record_model_error_on_span from ...util._json import _to_dump_compatible @@ -75,6 +80,12 @@ class InternalChatCompletionMessage(ChatCompletionMessage): reasoning_content: str = "" +def _usage_payload(response: Any) -> Any | None: + if isinstance(response, Mapping): + return response.get("usage") + return getattr(response, "usage", None) + + class _AnyLLMResponsesParamsShim: """Fallback shim for tests and older any-llm layouts.""" @@ -417,6 +428,11 @@ async def _get_response_via_responses( usage=usage, response_id=response.id, request_id=getattr(response, "_request_id", None), + raw_usage=( + _extract_raw_usage_snapshot(response, fallback=response.usage) + if model_settings.preserve_raw_usage is True + else None + ), ) async def _stream_response_via_responses( @@ -463,6 +479,8 @@ async def _stream_response_via_responses( chunk_type = getattr(chunk, "type", None) if isinstance(chunk, ResponseCompletedEvent): final_response = chunk.response + if model_settings.preserve_raw_usage is True: + _attach_raw_usage_snapshot(chunk.response, chunk.response.usage) elif chunk_type in {"response.failed", "response.incomplete"}: terminal_response = getattr(chunk, "response", None) terminal_failure_error = response_terminal_failure_error( @@ -640,7 +658,16 @@ async def _get_response_via_chat( if logprob_models: self._attach_logprobs_to_output(items, logprob_models) - return ModelResponse(output=items, usage=usage, response_id=None) + return ModelResponse( + output=items, + usage=usage, + response_id=None, + raw_usage=( + _extract_raw_usage_snapshot(response, fallback=response.usage) + if model_settings.preserve_raw_usage is True + else None + ), + ) async def _stream_response_via_chat( self, @@ -686,11 +713,21 @@ async def _stream_response_via_chat( final_response: Response | None = None yielded_terminal_event = False close_stream_in_background = False + raw_usage_options: dict[str, Any] = ( + {"preserve_raw_usage": True} if model_settings.preserve_raw_usage is True else {} + ) try: async for chunk in ChatCmplStreamHandler.handle_stream( response, - cast(Any, self._normalize_chat_stream(stream)), + cast( + Any, + self._normalize_chat_stream( + stream, + preserve_raw_usage=model_settings.preserve_raw_usage is True, + ), + ), model=self.model, + **raw_usage_options, ): # Record terminal state and populate the span before yielding so a consumer # that stops at the completed event still leaves a fully recorded span. @@ -899,7 +936,18 @@ async def _fetch_chat_response( ) if not stream: - return self._normalize_chat_completion_response(ret) + raw_usage = ( + _raw_usage_snapshot(_usage_payload(ret)) + if model_settings.preserve_raw_usage is True + else None + ) + normalized_response = self._normalize_chat_completion_response(ret) + if model_settings.preserve_raw_usage is True: + _attach_raw_usage_snapshot( + normalized_response, + raw_usage, + ) + return normalized_response responses_tool_choice = OpenAIResponsesConverter.convert_tool_choice( model_settings.tool_choice @@ -1051,7 +1099,18 @@ async def _fetch_responses_response( if stream: return cast(AsyncIterator[ResponseStreamEvent], response) - return self._normalize_response(response) + raw_usage = ( + _raw_usage_snapshot(_usage_payload(response)) + if model_settings.preserve_raw_usage is True + else None + ) + normalized_response = self._normalize_response(response) + if model_settings.preserve_raw_usage is True: + _attach_raw_usage_snapshot( + normalized_response, + raw_usage, + ) + return normalized_response @staticmethod def _split_model_name(model: str) -> tuple[str, str]: @@ -1183,10 +1242,20 @@ def _normalize_chat_completion_response(self, response: Any) -> ChatCompletion: return ChatCompletion.model_validate(response) async def _normalize_chat_stream( - self, stream: AsyncIterator[ChatCompletionChunk] + self, + stream: AsyncIterator[ChatCompletionChunk], + *, + preserve_raw_usage: bool = False, ) -> AsyncIterator[ChatCompletionChunk]: async for chunk in stream: - yield self._normalize_chat_chunk(chunk) + raw_usage = _raw_usage_snapshot(_usage_payload(chunk)) if preserve_raw_usage else None + normalized_chunk = self._normalize_chat_chunk(chunk) + if preserve_raw_usage: + _attach_raw_usage_snapshot( + normalized_chunk, + raw_usage, + ) + yield normalized_chunk def _normalize_chat_chunk(self, chunk: Any) -> ChatCompletionChunk: normalized_chunk = chunk diff --git a/src/agents/items.py b/src/agents/items.py index 012d81b1dd..e44f483adf 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -678,6 +678,15 @@ class ModelResponse: request_id: str | None = None """The transport request ID for this model call, if provided by the model SDK.""" + raw_usage: dict[str, Any] | None = None + """A JSON-compatible snapshot of the provider usage payload, when preservation is enabled. + + The snapshot is captured only while the unnormalized provider payload is available, before the + Agents SDK normalizes missing usage fields. It is ``None`` when preservation is disabled, no + usage payload reaches the model adapter, or upstream normalization has already discarded + field-presence information. + """ + def to_input_items(self) -> list[TResponseInputItem]: """Convert the output into a list of input items suitable for passing to the model.""" # Most output items can be replayed via a direct model_dump. Tool-search items carry diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py index 93fa4112d5..d9db8daefa 100644 --- a/src/agents/model_settings.py +++ b/src/agents/model_settings.py @@ -201,6 +201,16 @@ class ModelSettings: control which prompt prefixes are eligible for caching. """ + preserve_raw_usage: bool | None = None + """Whether to preserve the provider usage payload on completed model responses. + + When enabled and the model adapter still has the unnormalized provider payload, + ``ModelResponse.raw_usage`` contains a JSON-compatible snapshot captured before the Agents + SDK normalizes missing usage fields. It remains ``None`` when usage is absent or upstream + normalization has already discarded field-presence information. This setting does not request + usage from the provider; use ``include_usage`` separately when a streaming provider requires it. + """ + if TYPE_CHECKING: def __init__( @@ -228,6 +238,7 @@ def __init__( retry: ModelRetrySettings | dict[str, Any] | None = None, context_management: list[ContextManagement] | None = None, prompt_cache_options: PromptCacheOptions | None = None, + preserve_raw_usage: bool | None = None, ) -> None: ... def resolve(self, override: ModelSettings | dict[str, Any] | None) -> ModelSettings: diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 7187b1219d..0f2cd1f152 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -51,7 +51,12 @@ from ..exceptions import ModelBehaviorError, UserError from ..items import TResponseStreamEvent from ..logger import logger -from ..usage import _cache_write_tokens, _make_input_tokens_details +from ..usage import ( + _attach_raw_usage_snapshot, + _cache_write_tokens, + _extract_raw_usage_snapshot, + _make_input_tokens_details, +) from .chatcmpl_helpers import ChatCmplHelpers from .fake_id import FAKE_RESPONSES_ID @@ -584,6 +589,7 @@ async def handle_stream( stream: AsyncStream[ChatCompletionChunk], model: str | None = None, strict_feature_validation: bool = False, + preserve_raw_usage: bool = False, ) -> AsyncIterator[TResponseStreamEvent]: """ Handle a streaming chat completion response and yield response events. @@ -593,8 +599,11 @@ async def handle_stream( stream: The async stream of chat completion chunks from the model model: The source model that is generating this stream. Used to handle provider-specific stream processing. + preserve_raw_usage: Whether to retain the last provider usage payload before + converting it to the Responses usage shape. """ usage: CompletionUsage | None = None + raw_usage: dict[str, Any] | None = None state = StreamingState() output_layout = _StreamOutputLayout() sequence_number = SequenceNumber() @@ -616,6 +625,8 @@ async def handle_stream( # Only update when chunk has usage data (not always in the last chunk) if hasattr(chunk, "usage") and chunk.usage is not None: usage = chunk.usage + if preserve_raw_usage: + raw_usage = _extract_raw_usage_snapshot(chunk, fallback=chunk.usage) if not chunk.choices: continue @@ -1272,6 +1283,8 @@ async def handle_stream( if usage else None ) + if preserve_raw_usage: + _attach_raw_usage_snapshot(final_response, raw_usage) yield ResponseCompletedEvent( response=final_response, diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 8b6c77a557..22d0427d41 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -31,7 +31,7 @@ from ..tracing import generation_span from ..tracing.span_data import GenerationSpanData from ..tracing.spans import Span -from ..usage import Usage +from ..usage import Usage, _raw_usage_snapshot from ..util._error_tracing import model_span_errors from ..util._json import _to_dump_compatible from ._openai_retry import get_openai_retry_advice @@ -351,6 +351,11 @@ async def get_response( # The OpenAI SDK records the `x-request-id` header on every parsed response, # so callers can inspect the same debugging handle as on the Responses path. request_id=getattr(response, "_request_id", None), + raw_usage=( + _raw_usage_snapshot(response.usage) + if model_settings.preserve_raw_usage is True + else None + ), ) @staticmethod @@ -444,6 +449,9 @@ async def stream_response( else: stream_for_handler = stream + raw_usage_options: dict[str, Any] = ( + {"preserve_raw_usage": True} if model_settings.preserve_raw_usage is True else {} + ) close_stream_in_background = False yielded_terminal_event = False try: @@ -452,6 +460,7 @@ async def stream_response( cast(AsyncStream[ChatCompletionChunk], stream_for_handler), model=self.model, strict_feature_validation=self._strict_feature_validation, + **raw_usage_options, ): if chunk.type == "response.completed": final_response = chunk.response diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 8ff70ef3bf..95b3e2f426 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -74,7 +74,13 @@ validate_responses_tool_search_configuration, ) from ..tracing import SpanError, response_span -from ..usage import Usage, _response_usage_to_usage, model_usage_to_span_usage +from ..usage import ( + Usage, + _attach_raw_usage_snapshot, + _raw_usage_snapshot, + _response_usage_to_usage, + model_usage_to_span_usage, +) from ..util._error_tracing import record_model_error_on_span from ..util._json import _to_dump_compatible from ..version import __version__ @@ -550,6 +556,11 @@ async def get_response( usage=usage, response_id=response.id, request_id=getattr(response, "_request_id", None), + raw_usage=( + _raw_usage_snapshot(response.usage) + if model_settings.preserve_raw_usage is True + else None + ), ) async def stream_response( @@ -592,6 +603,8 @@ async def stream_response( chunk_type = getattr(chunk, "type", None) if isinstance(chunk, ResponseCompletedEvent): final_response = chunk.response + if model_settings.preserve_raw_usage is True: + _attach_raw_usage_snapshot(chunk.response, chunk.response.usage) elif chunk_type in { "response.failed", "response.incomplete", diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index c2d1c08eaf..33d6fbfc50 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -98,7 +98,7 @@ from ..tracing.config import include_task_and_turn_spans from ..tracing.model_tracing import get_model_tracing_impl from ..tracing.span_data import AgentSpanData, TaskSpanData -from ..usage import Usage, _response_usage_to_usage +from ..usage import Usage, _extract_raw_usage_snapshot, _response_usage_to_usage from ..util import _coro, _error_tracing from ..util._asyncio_tasks import gather_with_cancel from .agent_bindings import AgentBindings, bind_public_agent @@ -1775,6 +1775,11 @@ async def rewind_model_request() -> None: usage=usage, response_id=terminal_response.id, request_id=getattr(terminal_response, "_request_id", None), + raw_usage=( + _extract_raw_usage_snapshot(terminal_response) + if model_settings.preserve_raw_usage is True + else None + ), ) if isinstance(event, ResponseOutputItemDoneEvent): diff --git a/src/agents/usage.py b/src/agents/usage.py index 4880ecdc62..5d5a0c479e 100644 --- a/src/agents/usage.py +++ b/src/agents/usage.py @@ -1,14 +1,70 @@ from __future__ import annotations +import json from collections.abc import Mapping from dataclasses import field -from typing import Annotated, Any +from typing import Annotated, Any, cast from openai.types.completion_usage import CompletionTokensDetails, PromptTokensDetails from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails -from pydantic import BeforeValidator, TypeAdapter, ValidationError +from pydantic import BeforeValidator, JsonValue, TypeAdapter, ValidationError from pydantic.dataclasses import dataclass +_RAW_USAGE_ATTRIBUTE = "_agents_sdk_raw_usage" +_RAW_USAGE_ADAPTER = TypeAdapter(dict[str, JsonValue]) +_RAW_USAGE_MISSING = object() + + +def _raw_usage_snapshot(raw_usage: Any | None) -> dict[str, Any] | None: + """Return a detached JSON-compatible usage object without adding omitted fields.""" + if raw_usage is None: + return None + + try: + if isinstance(raw_usage, Mapping): + candidate = dict(raw_usage) + else: + model_dump = getattr(raw_usage, "model_dump", None) + if not callable(model_dump): + return None + candidate = model_dump(mode="json", by_alias=True, exclude_unset=True) + + if not isinstance(candidate, dict) or not all(isinstance(key, str) for key in candidate): + return None + + validated = _RAW_USAGE_ADAPTER.validate_python(candidate) + return cast( + dict[str, Any], + json.loads(json.dumps(validated, allow_nan=False)), + ) + except Exception: + # Usage preservation is diagnostic metadata. An adapter-specific value that cannot be + # represented as JSON must not turn an otherwise successful model call into a failure. + return None + + +def _attach_raw_usage_snapshot(target: Any, raw_usage: Any | None) -> None: + """Attach a pre-normalization usage snapshot to an internal response object.""" + snapshot = _raw_usage_snapshot(raw_usage) + try: + object.__setattr__(target, _RAW_USAGE_ATTRIBUTE, snapshot) + except Exception: + # Some custom response objects reject private attributes. Their completed response can + # still be processed normally, but no raw usage snapshot is available downstream. + return + + +def _extract_raw_usage_snapshot( + target: Any, + *, + fallback: Any | None = None, +) -> dict[str, Any] | None: + """Read an attached snapshot, or capture the provided unnormalized fallback.""" + snapshot = getattr(target, _RAW_USAGE_ATTRIBUTE, _RAW_USAGE_MISSING) + if snapshot is not _RAW_USAGE_MISSING: + return snapshot if isinstance(snapshot, dict) else None + return _raw_usage_snapshot(fallback) + def _make_input_tokens_details( *, diff --git a/tests/model_settings/test_serialization.py b/tests/model_settings/test_serialization.py index 073801bd11..d458a8a7b3 100644 --- a/tests/model_settings/test_serialization.py +++ b/tests/model_settings/test_serialization.py @@ -126,6 +126,7 @@ def test_all_fields_serialization() -> None: ), context_management=[{"type": "compaction", "compact_threshold": 200000}], prompt_cache_options={"mode": "explicit", "ttl": "30m"}, + preserve_raw_usage=True, ) # Verify that every single field is set to a non-None value @@ -154,10 +155,14 @@ def test_gpt_5_6_reasoning_and_prompt_cache_serialization() -> None: } -def test_prompt_cache_options_is_appended_to_public_field_order() -> None: +def test_usage_preservation_is_appended_to_public_field_order() -> None: field_names = [field.name for field in fields(ModelSettings)] - assert field_names[-2:] == ["context_management", "prompt_cache_options"] + assert field_names[-3:] == [ + "context_management", + "prompt_cache_options", + "preserve_raw_usage", + ] def test_extra_args_serialization() -> None: @@ -185,6 +190,7 @@ def test_traceable_serialization_omits_request_extras() -> None: extra_query={"api-key": "query-token"}, extra_body={"secret": "body-token"}, extra_args={"api_key": "arg-token"}, + preserve_raw_usage=True, ) json_dict = model_settings.to_json_dict() @@ -199,6 +205,7 @@ def test_traceable_serialization_omits_request_extras() -> None: assert "extra_query" not in traceable assert "extra_body" not in traceable assert "extra_args" not in traceable + assert "preserve_raw_usage" not in traceable def test_extra_args_resolve() -> None: diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index b7788da2a2..9808845898 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -421,7 +421,7 @@ async def test_any_llm_chat_path_is_used_when_responses_are_unsupported(monkeypa response = await model.get_response( system_instructions="You are terse.", input="hi", - model_settings=ModelSettings(), + model_settings=ModelSettings(preserve_raw_usage=True), tools=[], output_schema=None, handoffs=[], @@ -445,6 +445,11 @@ async def test_any_llm_chat_path_is_used_when_responses_are_unsupported(monkeypa assert response.output[0].content[0].text == "Hello" assert response.usage.input_tokens_details.cached_tokens == 2 assert getattr(response.usage.input_tokens_details, "cache_write_tokens", None) == 4 + assert response.raw_usage is not None + assert response.raw_usage["prompt_tokens_details"] == { + "cached_tokens": 2, + "cache_write_tokens": 4, + } def _content_filtered_chat_completion(content: str) -> ChatCompletion: @@ -655,7 +660,7 @@ async def test_any_llm_responses_path_defaults_missing_cache_write_tokens( normalized = await model.get_response( system_instructions=None, input="hi", - model_settings=ModelSettings(), + model_settings=ModelSettings(preserve_raw_usage=True), tools=[], output_schema=None, handoffs=[], @@ -668,6 +673,8 @@ async def test_any_llm_responses_path_defaults_missing_cache_write_tokens( assert normalized.output[0].content[0].text == "Hello" assert normalized.usage.input_tokens_details.cache_write_tokens == 0 assert "cache_write_tokens" not in response_payload["usage"]["input_tokens_details"] + assert normalized.raw_usage is not None + assert "cache_write_tokens" not in normalized.raw_usage["input_tokens_details"] @pytest.mark.allow_call_model_methods diff --git a/tests/models/test_litellm_chatcompletions_stream.py b/tests/models/test_litellm_chatcompletions_stream.py index 8bc69eb1e1..ece71165de 100644 --- a/tests/models/test_litellm_chatcompletions_stream.py +++ b/tests/models/test_litellm_chatcompletions_stream.py @@ -91,7 +91,7 @@ async def patched_fetch_response(self, *args, **kwargs): async for event in model.stream_response( system_instructions=None, input="", - model_settings=ModelSettings(), + model_settings=ModelSettings(preserve_raw_usage=True), tools=[], output_schema=None, handoffs=[], @@ -133,6 +133,9 @@ async def patched_fetch_response(self, *args, **kwargs): assert completed_resp.usage.total_tokens == 12 assert completed_resp.usage.input_tokens_details.cached_tokens == 6 assert completed_resp.usage.output_tokens_details.reasoning_tokens == 2 + # LiteLLM has already normalized usage before the Agents adapter receives this chunk, so + # omitted-versus-null provenance is unavailable and no raw snapshot should be attached. + assert not hasattr(completed_resp, "_agents_sdk_raw_usage") @pytest.mark.allow_call_model_methods diff --git a/tests/models/test_litellm_logprobs.py b/tests/models/test_litellm_logprobs.py index 00354ab57e..1cb247a994 100644 --- a/tests/models/test_litellm_logprobs.py +++ b/tests/models/test_litellm_logprobs.py @@ -94,7 +94,7 @@ async def fake_acompletion(model, messages=None, **kwargs): response = await LitellmModel(model="test-model").get_response( system_instructions=None, input=[], - model_settings=ModelSettings(top_logprobs=2), + model_settings=ModelSettings(top_logprobs=2, preserve_raw_usage=True), tools=[], output_schema=None, handoffs=[], @@ -116,3 +116,6 @@ async def fake_acompletion(model, messages=None, **kwargs): assert output_logprobs[0].token == "Hello" assert output_logprobs[0].logprob == -0.25 assert [tlp.token for tlp in output_logprobs[0].top_logprobs] == ["Hello", "Hi"] + # LiteLLM has already normalized usage before the Agents adapter receives this response, so + # omitted-versus-null provenance is unavailable. + assert response.raw_usage is None diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 9364025275..e756ec020d 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -173,6 +173,87 @@ async def patched_fetch_response(self, *args, **kwargs): assert getattr(resp.usage.input_tokens_details, "cache_write_tokens", None) == 4 assert resp.usage.output_tokens_details.reasoning_tokens == 0 assert resp.response_id is None + assert resp.raw_usage is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("prompt_tokens_details", "expected_details"), + [({}, {}), ({"cached_tokens": 0}, {"cached_tokens": 0})], + ids=["omitted-cached-tokens", "explicit-zero-cached-tokens"], +) +async def test_get_response_preserves_raw_usage_field_presence( + monkeypatch: pytest.MonkeyPatch, + prompt_tokens_details: dict[str, int], + expected_details: dict[str, int], +) -> None: + chat = _minimal_chat_completion() + chat.usage = CompletionUsage.model_validate( + { + "completion_tokens": 5, + "prompt_tokens": 7, + "total_tokens": 12, + "prompt_tokens_details": prompt_tokens_details, + } + ) + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + response = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(preserve_raw_usage=True), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert response.raw_usage == { + "completion_tokens": 5, + "prompt_tokens": 7, + "total_tokens": 12, + "prompt_tokens_details": expected_details, + } + assert response.usage.input_tokens_details.cached_tokens == 0 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_raw_usage_is_none_when_provider_omits_usage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + chat = _minimal_chat_completion() + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + response = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(preserve_raw_usage=True), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert response.raw_usage is None + assert response.usage.total_tokens == 0 async def _get_response_for_choice( diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index fcb8bc74ab..014dbe2ac3 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -568,7 +568,14 @@ async def test_stream_handler_keeps_empty_choice_usage_chunks() -> None: model="fake", object="chat.completion.chunk", choices=[], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=2, total_tokens=3), + usage=CompletionUsage.model_validate( + { + "completion_tokens": 1, + "prompt_tokens": 2, + "total_tokens": 3, + "prompt_tokens_details": {"cached_tokens": 0}, + } + ), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: @@ -577,7 +584,7 @@ async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: events = [ event async for event in ChatCmplStreamHandler.handle_stream( - _empty_response(), cast(Any, fake_stream()) + _empty_response(), cast(Any, fake_stream()), preserve_raw_usage=True ) ] @@ -587,6 +594,12 @@ async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: assert completed_event.response.output == [] assert completed_event.response.usage assert completed_event.response.usage.total_tokens == 3 + assert cast(Any, completed_event.response)._agents_sdk_raw_usage == { + "completion_tokens": 1, + "prompt_tokens": 2, + "total_tokens": 3, + "prompt_tokens_details": {"cached_tokens": 0}, + } @pytest.mark.asyncio diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index c5978b2ba9..34bcc7cb5e 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -252,7 +252,7 @@ def __init__(self): response = await model.get_response( system_instructions=None, input="hi", - model_settings=ModelSettings(), + model_settings=ModelSettings(preserve_raw_usage=True), tools=[], output_schema=None, handoffs=[], @@ -261,6 +261,8 @@ def __init__(self): assert response.response_id == "resp-request-id" assert response.request_id == "req_nonstream_123" + assert response.raw_usage is not None + assert response.raw_usage["input_tokens_details"]["cached_tokens"] == 0 @pytest.mark.allow_call_model_methods diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index c09ccb957c..cc749ea352 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -60,7 +60,7 @@ from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, StreamEvent from agents.tool import FunctionTool, Tool from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail -from agents.usage import Usage +from agents.usage import Usage, _attach_raw_usage_snapshot from .fake_model import FakeModel, get_response_obj from .test_responses import ( @@ -318,7 +318,10 @@ async def stream_response( @pytest.mark.asyncio -async def test_streamed_run_exposes_request_id_on_raw_responses() -> None: +@pytest.mark.parametrize("preserve_raw_usage", [None, False, True]) +async def test_streamed_run_exposes_request_id_on_raw_responses( + preserve_raw_usage: bool | None, +) -> None: class RequestIdTerminalFakeModel(FakeModel): async def stream_response( self, @@ -338,6 +341,10 @@ async def stream_response( [get_text_message("partial final")], response_id="resp-partial" ) response._request_id = "req_streamed_result_123" + _attach_raw_usage_snapshot( + response, + {"input_tokens": 3, "input_tokens_details": {"cached_tokens": 0}}, + ) yield ResponseCompletedEvent( type="response.completed", response=response, @@ -345,7 +352,11 @@ async def stream_response( ) model = RequestIdTerminalFakeModel() - agent = Agent(name="test", model=model) + agent = Agent( + name="test", + model=model, + model_settings=ModelSettings(preserve_raw_usage=preserve_raw_usage), + ) result = Runner.run_streamed(agent, input="test") async for _ in result.stream_events(): @@ -353,6 +364,14 @@ async def stream_response( assert len(result.raw_responses) == 1 assert result.raw_responses[0].request_id == "req_streamed_result_123" + assert result.raw_responses[0].raw_usage == ( + { + "input_tokens": 3, + "input_tokens_details": {"cached_tokens": 0}, + } + if preserve_raw_usage is True + else None + ) @pytest.mark.asyncio diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 83bbdb7c1f..78cc09b11b 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -2668,16 +2668,20 @@ async def test_model_response_serialization_roundtrip(self): ], response_id="resp123", request_id="req123", + raw_usage={"input_tokens": 10, "provider_metric": 0}, ) state._model_responses.append(response) # Round trip + serialized = state.to_json() + assert "raw_usage" not in serialized["model_responses"][0] json_str = state.to_string() restored = await RunState.from_string(agent, json_str) assert len(restored._model_responses) == 1 assert restored._model_responses[0].response_id == "resp123" assert restored._model_responses[0].request_id == "req123" + assert restored._model_responses[0].raw_usage is None assert restored._model_responses[0].usage.requests == 1 assert restored._model_responses[0].usage.input_tokens == 10 diff --git a/tests/test_usage.py b/tests/test_usage.py index 254a88cbea..c6c8444ef0 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -1,7 +1,13 @@ from __future__ import annotations +from typing import Any + import pytest -from openai.types.completion_usage import CompletionTokensDetails, PromptTokensDetails +from openai.types.completion_usage import ( + CompletionTokensDetails, + CompletionUsage, + PromptTokensDetails, +) from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails from agents import Agent, Runner @@ -9,6 +15,7 @@ from agents.usage import ( RequestUsage, Usage, + _raw_usage_snapshot, deserialize_usage, model_usage_to_span_usage, serialize_usage, @@ -24,6 +31,45 @@ def test_usage_defaults_cache_write_tokens_to_zero() -> None: assert getattr(usage.input_tokens_details, "cache_write_tokens", None) == 0 +def test_raw_usage_snapshot_preserves_presence_and_is_detached() -> None: + raw_usage: dict[str, Any] = { + "input_tokens": 3, + "input_tokens_details": {"cached_tokens": 0}, + "provider_metric": None, + } + + snapshot = _raw_usage_snapshot(raw_usage) + raw_usage["input_tokens_details"]["cached_tokens"] = 9 + + assert snapshot == { + "input_tokens": 3, + "input_tokens_details": {"cached_tokens": 0}, + "provider_metric": None, + } + + +def test_raw_usage_snapshot_does_not_add_unset_pydantic_fields() -> None: + usage = CompletionUsage.model_validate( + { + "completion_tokens": 2, + "prompt_tokens": 3, + "total_tokens": 5, + "prompt_tokens_details": {}, + } + ) + + assert _raw_usage_snapshot(usage) == { + "completion_tokens": 2, + "prompt_tokens": 3, + "total_tokens": 5, + "prompt_tokens_details": {}, + } + + +def test_raw_usage_snapshot_rejects_non_json_values() -> None: + assert _raw_usage_snapshot({"provider_metric": object()}) is None + + @pytest.mark.asyncio async def test_runner_run_carries_request_usage_entries() -> None: """Ensure usage produced by the model propagates to RunResult context.""" From 4720150fde047baa4e88b16082b282bee3a5e87d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 20:59:01 +0900 Subject: [PATCH 215/473] fix: bind tool approvals to concrete invocations (#4257) --- src/agents/_tool_invocation.py | 302 ++ src/agents/agent.py | 203 +- src/agents/agent_tool_state.py | 77 +- src/agents/items.py | 5 + src/agents/models/interface.py | 9 +- src/agents/realtime/session.py | 298 +- src/agents/run_context.py | 629 +++- src/agents/run_internal/items.py | 20 +- src/agents/run_internal/run_loop.py | 285 +- src/agents/run_internal/tool_actions.py | 177 +- src/agents/run_internal/tool_execution.py | 299 +- src/agents/run_internal/tool_planning.py | 474 ++- src/agents/run_internal/turn_resolution.py | 598 +++- src/agents/run_state.py | 500 ++- src/agents/tool_context.py | 120 +- tests/mcp/test_mcp_tracing.py | 9 +- tests/realtime/test_session.py | 666 +++- .../capabilities/test_apply_patch_tool.py | 6 +- tests/sandbox/test_runtime.py | 10 +- tests/test_agent_as_tool.py | 153 + tests/test_agent_hooks.py | 18 +- tests/test_agent_runner.py | 120 +- tests/test_agent_runner_streamed.py | 34 +- tests/test_apply_patch_tool.py | 7 + tests/test_example_workflows.py | 22 +- tests/test_global_hooks.py | 16 +- tests/test_hitl_error_scenarios.py | 789 ++++- tests/test_max_turns.py | 36 +- tests/test_responses.py | 8 +- tests/test_run_context_approvals.py | 41 +- tests/test_run_context_wrapper.py | 60 +- tests/test_run_hooks.py | 9 +- tests/test_run_state.py | 1364 +++++++- tests/test_run_step_execution.py | 222 +- tests/test_soft_cancel.py | 8 +- tests/test_stream_events.py | 133 +- tests/test_tool_approval_call_id_reuse.py | 2902 +++++++++++++++++ tests/test_tool_guardrails.py | 8 +- tests/test_tool_name_collision_policy.py | 150 +- tests/test_tracing_errors.py | 18 +- tests/test_tracing_errors_streamed.py | 18 +- 41 files changed, 9778 insertions(+), 1045 deletions(-) create mode 100644 src/agents/_tool_invocation.py create mode 100644 tests/test_tool_approval_call_id_reuse.py diff --git a/src/agents/_tool_invocation.py b/src/agents/_tool_invocation.py new file mode 100644 index 0000000000..a18d6dd94e --- /dev/null +++ b/src/agents/_tool_invocation.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from typing import Any, TypeGuard + +from ._tool_identity import ( + FunctionToolLookupKey, + get_function_tool_lookup_key_for_call, + get_hosted_mcp_approval_request_identity, +) + +_TOOL_INVOCATION_TYPES = frozenset( + { + "apply_patch_call", + "computer_call", + "custom_tool_call", + "function_call", + "local_shell_call", + "mcp_approval_request", + "shell_call", + } +) +_TOOL_OUTPUT_TYPES = { + "apply_patch_call_output": "apply_patch_call", + "computer_call_output": "computer_call", + "custom_tool_call_output": "custom_tool_call", + "function_call_output": "function_call", + "local_shell_call_output": "local_shell_call", + "mcp_approval_response": "mcp_approval_request", + "shell_call_output": "shell_call", +} +_SEMANTIC_FIELDS = ( + "type", + "name", + "namespace", + "server_label", + "arguments", + "input", + "action", + "actions", + "pending_safety_checks", + "operation", + "operations", + "environment", + "caller", +) + + +def is_tool_invocation_type(value: Any) -> TypeGuard[str]: + """Return whether a value names a canonical tool invocation type.""" + return isinstance(value, str) and value in _TOOL_INVOCATION_TYPES + + +def is_tool_invocation_digest(value: Any) -> TypeGuard[str]: + """Return whether a value is a canonical lowercase SHA-256 digest.""" + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _as_mapping(value: Any) -> Mapping[str, Any] | None: + if isinstance(value, Mapping): + return value + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + dumped = model_dump(exclude_none=True, exclude_unset=True) + return dumped if isinstance(dumped, Mapping) else None + return None + + +def _normalize_value(value: Any) -> Any: + mapping = _as_mapping(value) + if mapping is not None: + return { + str(key): _normalize_value(item) + for key, item in sorted(mapping.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [_normalize_value(item) for item in value] + if value is None or isinstance(value, str | int | float | bool): + return value + return str(value) + + +def _normalize_arguments(value: Any) -> Any: + if not isinstance(value, str): + return _normalize_value(value) + try: + parsed = json.loads( + value, + parse_constant=lambda constant: (_ for _ in ()).throw( + ValueError(f"Invalid JSON constant: {constant}") + ), + ) + except (TypeError, ValueError, json.JSONDecodeError): + return value + return _normalize_value(parsed) + + +def _unwrap_hosted_mcp_approval(raw_item: Any) -> Mapping[str, Any] | None: + mapping = _as_mapping(raw_item) + if mapping is None: + return None + provider_data = mapping.get("provider_data") + if ( + mapping.get("type") == "hosted_tool_call" + and isinstance(provider_data, Mapping) + and provider_data.get("type") == "mcp_approval_request" + ): + request_identity = get_hosted_mcp_approval_request_identity(mapping) + if request_identity is None: + return None + merged = dict(mapping) + merged.update(provider_data) + if request_identity.request_id is None: + merged.pop("id", None) + else: + merged["id"] = request_identity.request_id + if request_identity.tool_name is not None: + merged["name"] = request_identity.tool_name + return merged + return mapping + + +def tool_invocation_identity( + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, +) -> tuple[str, str, str] | None: + """Return invocation type, provider call ID, and a stable semantic fingerprint.""" + identity = tool_invocation_identity_and_scope( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if identity is None: + return None + invocation_type, call_id, _, fingerprint = identity + return invocation_type, call_id, fingerprint + + +def tool_invocation_identity_and_scope( + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, +) -> tuple[str, str, str, str] | None: + """Return invocation identity together with its stable approval scope.""" + call_identity = tool_invocation_call_id(raw_item) + approval_scope_identity = tool_invocation_approval_scope( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if call_identity is None or approval_scope_identity is None: + return None + invocation_type, call_id = call_identity + scope_invocation_type, approval_scope = approval_scope_identity + if call_id is None or scope_invocation_type != invocation_type: + return None + + mapping = _unwrap_hosted_mcp_approval(raw_item) + if mapping is None: + return None + + if invocation_type == "function_call": + if "arguments" not in mapping: + return None + elif invocation_type == "mcp_approval_request": + if "arguments" not in mapping: + return None + elif invocation_type == "custom_tool_call": + if not isinstance(mapping.get("name"), str) or not mapping["name"]: + return None + if "input" not in mapping: + return None + elif invocation_type in {"computer_call", "local_shell_call", "shell_call"}: + if "action" not in mapping: + return None + elif invocation_type == "apply_patch_call": + if "operation" not in mapping and "operations" not in mapping: + return None + + semantic_payload: dict[str, Any] = {"approval_scope": approval_scope} + for field_name in _SEMANTIC_FIELDS: + if invocation_type == "function_call" and field_name in {"name", "namespace"}: + continue + if field_name not in mapping: + continue + value = mapping[field_name] + semantic_payload[field_name] = ( + _normalize_arguments(value) if field_name == "arguments" else _normalize_value(value) + ) + + return ( + invocation_type, + call_id, + approval_scope, + _fingerprint(semantic_payload), + ) + + +def tool_invocation_call_id(raw_item: Any) -> tuple[str, str | None] | None: + """Return a recognized invocation type and its valid non-empty call ID, if present.""" + mapping = _unwrap_hosted_mcp_approval(raw_item) + if mapping is None: + return None + invocation_type = mapping.get("type") + if invocation_type not in _TOOL_INVOCATION_TYPES: + return None + candidate = ( + mapping.get("id") if invocation_type == "mcp_approval_request" else mapping.get("call_id") + ) + return invocation_type, candidate if isinstance(candidate, str) and candidate else None + + +def tool_invocation_approval_scope( + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, +) -> tuple[str, str] | None: + """Return the stable authorization scope for a recognized tool invocation.""" + mapping = _unwrap_hosted_mcp_approval(raw_item) + if mapping is None: + return None + invocation_type = mapping.get("type") + if invocation_type not in _TOOL_INVOCATION_TYPES: + return None + + payload: dict[str, Any] = {"type": invocation_type} + if invocation_role is not None: + payload["invocation_role"] = invocation_role + if invocation_type == "function_call": + resolved_lookup_key = tool_lookup_key or get_function_tool_lookup_key_for_call(mapping) + if resolved_lookup_key is None: + return None + payload["tool_lookup_key"] = _normalize_value(resolved_lookup_key) + elif invocation_type == "mcp_approval_request": + tool_name = mapping.get("name") + server_label = mapping.get("server_label") + if ( + not isinstance(tool_name, str) + or not tool_name + or not isinstance(server_label, str) + or not server_label + ): + return None + payload["name"] = tool_name + payload["server_label"] = server_label + else: + resolved_tool_name = tool_name or mapping.get("name") + if isinstance(resolved_tool_name, str) and resolved_tool_name: + payload["name"] = resolved_tool_name + return invocation_type, _fingerprint(payload) + + +def _fingerprint(payload: Mapping[str, Any]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def is_mcp_approval_invocation(raw_item: Any) -> bool: + """Return whether an item represents a hosted MCP approval request.""" + mapping = _unwrap_hosted_mcp_approval(raw_item) + return mapping is not None and mapping.get("type") == "mcp_approval_request" + + +def tool_output_identity(raw_item: Any) -> tuple[str, str] | None: + """Return the invocation type and call ID completed by a tool output item.""" + mapping = _as_mapping(raw_item) + if mapping is None: + return None + output_type = mapping.get("type") + if not isinstance(output_type, str): + return None + invocation_type = _TOOL_OUTPUT_TYPES.get(output_type) + if invocation_type is None: + return None + candidate = ( + mapping.get("approval_request_id") + if output_type == "mcp_approval_response" + else mapping.get("call_id") + ) + if not isinstance(candidate, str) or not candidate: + return None + return invocation_type, candidate diff --git a/src/agents/agent.py b/src/agents/agent.py index 677e8cf868..9b572846ff 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -14,11 +14,7 @@ from typing_extensions import NotRequired, TypedDict from . import _debug -from ._tool_identity import ( - get_function_tool_approval_keys, - get_hosted_mcp_approval_request_identity, - get_tool_approval_item_call_id, -) +from ._tool_identity import get_tool_approval_item_call_id from .agent_output import AgentOutputSchemaBase from .agent_tool_input import ( AgentAsToolInput, @@ -27,9 +23,10 @@ resolve_agent_tool_input, ) from .agent_tool_state import ( - consume_agent_tool_run_result, + get_agent_tool_resume_state, get_agent_tool_state_scope, peek_agent_tool_run_result, + record_agent_tool_resume_state, record_agent_tool_run_result, set_agent_tool_state_scope, ) @@ -68,7 +65,6 @@ if TYPE_CHECKING: from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall - from .items import ToolApprovalItem from .lifecycle import AgentHooks, RunHooks from .mcp import MCPServer from .memory.session import Session @@ -747,8 +743,10 @@ async def _run_agent_impl(context: ToolContext, input_json: str) -> Any: should_record_run_result = True def _nested_approvals_status( - interruptions: list[ToolApprovalItem], + pending_run_result: RunResult | RunResultStreaming, ) -> Literal["approved", "pending", "rejected"]: + interruptions = pending_run_result.interruptions + nested_decision_context = pending_run_result.to_state()._context has_pending = False has_decision = False for interruption in interruptions: @@ -757,12 +755,72 @@ def _nested_approvals_status( has_pending = True continue tool_namespace = RunContextWrapper._resolve_tool_namespace(interruption) - status = context.get_approval_status( - interruption.tool_name or "", - call_id, - tool_namespace=tool_namespace, - existing_pending=interruption, + status = ( + nested_decision_context.get_approval_status( + interruption.tool_name or "", + call_id, + tool_namespace=tool_namespace, + existing_pending=interruption, + ) + if nested_decision_context is not None + else None ) + if ( + status is None + and nested_decision_context is not None + and context._allow_legacy_approval_binding_reconstruction + ): + status = context.get_approval_status( + interruption.tool_name or "", + call_id, + tool_namespace=tool_namespace, + existing_pending=interruption, + ) + if status is not None: + legacy_namespace = RunContextWrapper._resolve_tool_namespace( + interruption + ) + legacy_tool_name = RunContextWrapper._resolve_tool_name(interruption) + legacy_qualified_key = ( + f"{legacy_namespace}.{legacy_tool_name}" + if legacy_namespace is not None + else legacy_tool_name + ) + approval_keys = ( + RunContextWrapper._resolve_approval_key(interruption), + *RunContextWrapper._resolve_approval_keys(interruption), + legacy_qualified_key, + ) + approval_record = next( + ( + context._approvals[key] + for key in approval_keys + if key in context._approvals + ), + None, + ) + if status: + RunContextWrapper.approve_tool( + nested_decision_context, + interruption, + always_approve=bool( + approval_record and approval_record.approved is True + ), + ) + else: + RunContextWrapper.reject_tool( + nested_decision_context, + interruption, + always_reject=bool( + approval_record and approval_record.rejected is True + ), + rejection_message=context.get_rejection_message( + interruption.tool_name or "", + call_id, + tool_namespace=tool_namespace, + existing_pending=interruption, + ), + ) if status is False: return "rejected" if status is True: @@ -775,126 +833,36 @@ def _nested_approvals_status( return "pending" return "approved" - def _apply_nested_approvals( - nested_context: RunContextWrapper[Any], - parent_context: RunContextWrapper[Any], - interruptions: list[ToolApprovalItem], - ) -> None: - def _find_mirrored_approval_record( - interruption: ToolApprovalItem, - *, - approved: bool, - ) -> Any | None: - hosted_request = get_hosted_mcp_approval_request_identity(interruption) - if hosted_request is not None and hosted_request.request_id is not None: - hosted_key = hosted_request.approval_identity or ( - "hosted_mcp_call", - hosted_request.request_id, - ) - hosted_record = parent_context._approvals.get(hosted_key) - if hosted_record is not None: - return hosted_record - candidate_keys = list(RunContextWrapper._resolve_approval_keys(interruption)) - for candidate_key in get_function_tool_approval_keys( - tool_name=RunContextWrapper._resolve_tool_name(interruption), - tool_namespace=RunContextWrapper._resolve_tool_namespace(interruption), - tool_lookup_key=RunContextWrapper._resolve_tool_lookup_key(interruption), - include_legacy_deferred_key=True, - ): - if candidate_key not in candidate_keys: - candidate_keys.append(candidate_key) - fallback: Any | None = None - for candidate_key in candidate_keys: - candidate = parent_context._approvals.get(candidate_key) - if candidate is None: - continue - if approved and candidate.approved is True: - return candidate - if not approved and candidate.rejected is True: - return candidate - if fallback is None: - fallback = candidate - return fallback - - for interruption in interruptions: - call_id = get_tool_approval_item_call_id(interruption) - if not call_id: - continue - tool_name = RunContextWrapper._resolve_tool_name(interruption) - tool_namespace = RunContextWrapper._resolve_tool_namespace(interruption) - approval_key = RunContextWrapper._resolve_approval_key(interruption) - status = parent_context.get_approval_status( - tool_name, - call_id, - tool_namespace=tool_namespace, - existing_pending=interruption, - ) - if status is None: - continue - hosted_request = get_hosted_mcp_approval_request_identity(interruption) - if hosted_request is not None: - approval_record = _find_mirrored_approval_record( - interruption, - approved=status, - ) - else: - approval_record = parent_context._approvals.get(approval_key) - if approval_record is None: - approval_record = _find_mirrored_approval_record( - interruption, - approved=status, - ) - if status is True: - always_approve = bool(approval_record and approval_record.approved is True) - nested_context.approve_tool( - interruption, - always_approve=always_approve, - ) - else: - always_reject = bool(approval_record and approval_record.rejected is True) - rejection_message = ( - parent_context.get_rejection_message( - tool_name, - call_id, - tool_namespace=tool_namespace, - existing_pending=interruption, - ) - if hosted_request is not None - else None - ) - nested_context.reject_tool( - interruption, - always_reject=always_reject, - rejection_message=rejection_message, - ) - if isinstance(context, ToolContext) and context.tool_call is not None: pending_run_result = peek_agent_tool_run_result( context.tool_call, scope_id=tool_state_scope_id, ) - if pending_run_result and getattr(pending_run_result, "interruptions", None): - status = _nested_approvals_status(pending_run_result.interruptions) + pending_resume_state = get_agent_tool_resume_state(pending_run_result) + if pending_resume_state is not None: + resume_state = pending_resume_state + elif pending_run_result and getattr(pending_run_result, "interruptions", None): + resolved_pending_result = cast( + "RunResult | RunResultStreaming", + pending_run_result, + ) + status = _nested_approvals_status(resolved_pending_result) if status == "pending": - run_result = pending_run_result + run_result = resolved_pending_result should_record_run_result = False elif status in ("approved", "rejected"): - resume_state = pending_run_result.to_state() + resume_state = resolved_pending_result.to_state() if resume_state._context is not None: - # Apply only explicit parent approvals to the nested resumed run. - _apply_nested_approvals( - resume_state._context, - context, - pending_run_result.interruptions, - ) # Keep accumulating nested post-resume usage on the parent # ToolContext accumulator. resolve_resumed_context only # replaces application .context and would otherwise leave # the restored nested wrapper on a detached Usage object. resume_state._context.usage = context.usage - consume_agent_tool_run_result( + record_agent_tool_resume_state( context.tool_call, + resume_state, scope_id=tool_state_scope_id, + approval_items=resolved_pending_result.interruptions, ) if run_result is None: @@ -999,6 +967,7 @@ async def enqueue_stream_events() -> None: run_result, scope_id=tool_state_scope_id, ) + return run_result.final_output if custom_output_extractor is not None: return await custom_output_extractor(run_result) diff --git a/src/agents/agent_tool_state.py b/src/agents/agent_tool_state.py index 2ddb2c9884..07cc3dfb8d 100644 --- a/src/agents/agent_tool_state.py +++ b/src/agents/agent_tool_state.py @@ -1,8 +1,11 @@ from __future__ import annotations import weakref +from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from ._tool_invocation import tool_invocation_identity_and_scope + if TYPE_CHECKING: from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall @@ -11,11 +14,28 @@ ToolCallSignature = tuple[str, str, str, str, str | None, str | None] ScopedToolCallSignature = tuple[str | None, ToolCallSignature] + +@dataclass +class _AgentToolResumeCheckpoint: + state: Any + approval_identities: frozenset[tuple[str, str, str, str]] + + @property + def interruptions(self) -> list[Any]: + interruptions = self.state.get_interruptions() + return interruptions if isinstance(interruptions, list) else [] + + def to_state(self) -> Any: + return self.state + + _AGENT_TOOL_STATE_SCOPE_ATTR = "_agent_tool_state_scope_id" # Ephemeral maps linking tool call objects to nested agent results within the same run. # Store by object identity, and index by a stable signature to avoid call ID collisions. -_agent_tool_run_results_by_obj: dict[int, RunResult | RunResultStreaming] = {} +_agent_tool_run_results_by_obj: dict[ + int, RunResult | RunResultStreaming | _AgentToolResumeCheckpoint +] = {} _agent_tool_run_results_by_signature: dict[ ScopedToolCallSignature, set[int], @@ -118,7 +138,7 @@ def _on_tool_call_gc(_ref: weakref.ReferenceType[ResponseFunctionToolCall]) -> N def record_agent_tool_run_result( tool_call: ResponseFunctionToolCall, - run_result: RunResult | RunResultStreaming, + run_result: RunResult | RunResultStreaming | _AgentToolResumeCheckpoint, *, scope_id: str | None = None, ) -> None: @@ -129,6 +149,55 @@ def record_agent_tool_run_result( _register_tool_call_ref(tool_call, tool_call_obj_id) +def record_agent_tool_resume_state( + tool_call: ResponseFunctionToolCall, + state: Any, + *, + scope_id: str | None = None, + approval_items: list[Any] | None = None, +) -> None: + """Keep a live nested RunState checkpoint while an approved resume is in flight.""" + resolved_approval_items = approval_items + if resolved_approval_items is None: + get_interruptions = getattr(state, "get_interruptions", None) + interruptions = get_interruptions() if callable(get_interruptions) else [] + resolved_approval_items = interruptions if isinstance(interruptions, list) else [] + approval_identities = frozenset( + identity + for item in resolved_approval_items + if ( + identity := tool_invocation_identity_and_scope( + item.raw_item, + tool_lookup_key=getattr(item, "tool_lookup_key", None), + tool_name=getattr(item, "tool_name", None), + ) + ) + is not None + ) + record_agent_tool_run_result( + tool_call, + _AgentToolResumeCheckpoint(state, approval_identities), + scope_id=scope_id, + ) + + +def get_agent_tool_resume_state(run_result: Any) -> Any | None: + """Return the live nested RunState stored in an in-flight resume checkpoint.""" + return run_result.state if isinstance(run_result, _AgentToolResumeCheckpoint) else None + + +def agent_tool_resume_checkpoint_owns_approval(run_result: Any, approval_item: Any) -> bool: + """Return whether an in-flight nested resume accepted the approval item.""" + if not isinstance(run_result, _AgentToolResumeCheckpoint): + return False + identity = tool_invocation_identity_and_scope( + approval_item.raw_item, + tool_lookup_key=getattr(approval_item, "tool_lookup_key", None), + tool_name=getattr(approval_item, "tool_name", None), + ) + return identity is not None and identity in run_result.approval_identities + + def _tool_call_obj_matches_scope(tool_call_obj_id: int, *, scope_id: str | None) -> bool: scoped_signature = _agent_tool_run_result_signature_by_obj.get(tool_call_obj_id) if scoped_signature is None: @@ -141,7 +210,7 @@ def consume_agent_tool_run_result( tool_call: ResponseFunctionToolCall, *, scope_id: str | None = None, -) -> RunResult | RunResultStreaming | None: +) -> RunResult | RunResultStreaming | _AgentToolResumeCheckpoint | None: """Return and drop the stored nested agent run result for the given tool call.""" obj_id = id(tool_call) if _tool_call_obj_matches_scope(obj_id, scope_id=scope_id): @@ -168,7 +237,7 @@ def peek_agent_tool_run_result( tool_call: ResponseFunctionToolCall, *, scope_id: str | None = None, -) -> RunResult | RunResultStreaming | None: +) -> RunResult | RunResultStreaming | _AgentToolResumeCheckpoint | None: """Return the stored nested agent run result without removing it.""" obj_id = id(tool_call) if _tool_call_obj_matches_scope(obj_id, scope_id=scope_id): diff --git a/src/agents/items.py b/src/agents/items.py index e44f483adf..f68a88741b 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -367,9 +367,14 @@ class ToolCallItem(RunItemBase[Any]): tool_origin: ToolOrigin | None = None """Optional metadata describing the source of a function-tool-backed item.""" + _resolved_tool_name: str | None = field(default=None, kw_only=True, repr=False) + """SDK-resolved tool name when the provider payload does not carry one.""" + @property def tool_name(self) -> str | None: """Return the tool name from the raw item, if available.""" + if self._resolved_tool_name is not None: + return self._resolved_tool_name if isinstance(self.raw_item, dict): return self.raw_item.get("name") return getattr(self.raw_item, "name", None) diff --git a/src/agents/models/interface.py b/src/agents/models/interface.py index 3be588c2a8..6c4bd2cc03 100644 --- a/src/agents/models/interface.py +++ b/src/agents/models/interface.py @@ -35,7 +35,14 @@ def include_data(self) -> bool: class Model(abc.ABC): - """The base interface for calling an LLM.""" + """The base interface for calling an LLM. + + Model implementations must assign a non-empty call ID to each tool invocation. A call ID must + identify one canonical invocation for the lifetime of the run and its serialized resume + lineage; it must not be reused for changed tool identity or payload. An exact completed replay + may be omitted by the runtime without re-executing the invocation. Tool outputs must retain the + call ID for correlation. + """ async def _cleanup_on_run_end(self, owner: object) -> None: """Release run-scoped resources after the runner finishes using this model.""" diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 7d6265a96c..18d8d716f1 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -14,9 +14,11 @@ from .. import _debug from .._tool_identity import ( FunctionToolLookupKey, + get_function_tool_lookup_key, get_function_tool_lookup_key_for_tool, get_function_tool_namespace, ) +from .._tool_invocation import tool_invocation_identity from ..agent import Agent from ..exceptions import ( ModelBehaviorError, @@ -228,8 +230,11 @@ def __init__( self._cleanup_task: asyncio.Task[None] | None = None self._stored_exception: BaseException | None = None self._pending_tool_calls: dict[str, _PendingToolCall] = {} - self._active_tool_call_ids: set[str] = set() - self._completed_tool_call_ids: set[str] = set() + self._tool_invocation_routes: dict[ + str, + tuple[FunctionToolLookupKey | None, str | None], + ] = {} + self._active_tool_invocations: dict[str, tuple[str, str, str]] = {} self._pending_tool_outputs: dict[str, _PendingToolOutput] = {} self._current_dispatch_snapshot: _RealtimeDispatchSnapshot | None = None @@ -793,9 +798,8 @@ async def _run_tool_input_guardrails( if not guardrails: return None - tool_context = ToolContext( - context=self._context_wrapper.context, - usage=self._context_wrapper.usage, + tool_context = ToolContext.from_agent_context( + self._context_wrapper, tool_name=tool_call.name, tool_call_id=tool_call.call_id, tool_arguments=tool_call.arguments, @@ -843,6 +847,7 @@ async def _send_tool_rejection( rejection_message = await self._resolve_approval_rejection_message( tool=tool, call_id=event.call_id, + tool_call=self._build_tool_approval_item(tool, event, agent).raw_item, ) await self._send_tool_output_completion( _PendingToolOutput( @@ -866,21 +871,29 @@ async def _send_tool_output_completion(self, pending_output: _PendingToolOutput) call_id = pending_output.tool_call.call_id self._pending_tool_outputs[call_id] = pending_output try: - await self._send_pending_tool_output(pending_output) + output_sent = await self._send_pending_tool_output(pending_output) except Exception as exc: if self._closing or self._closed: self._pending_tool_outputs.pop(call_id, None) return raise _PendingToolOutputSendError(call_id, exc) from exc + if not output_sent: + self._pending_tool_outputs.pop(call_id, None) + return + self._context_wrapper._mark_tool_call_completed( + {"type": "function_call_output", "call_id": call_id}, + ) self._pending_tool_outputs.pop(call_id, None) + if pending_output.tool_end_event is not None: + self._put_event_nowait(pending_output.tool_end_event) - async def _send_pending_tool_output(self, pending_output: _PendingToolOutput) -> None: + async def _send_pending_tool_output(self, pending_output: _PendingToolOutput) -> bool: if self._closing or self._closed: - return + return False if pending_output.session_update is not None: await self._model.send_event(pending_output.session_update) if self._closing or self._closed: - return + return False await self._model.send_event( RealtimeModelSendToolOutput( tool_call=pending_output.tool_call, @@ -888,12 +901,15 @@ async def _send_pending_tool_output(self, pending_output: _PendingToolOutput) -> start_response=pending_output.start_response, ) ) - if self._closing or self._closed: - return - if pending_output.tool_end_event is not None: - await self._put_event(pending_output.tool_end_event) + return True - async def _resolve_approval_rejection_message(self, *, tool: FunctionTool, call_id: str) -> str: + async def _resolve_approval_rejection_message( + self, + *, + tool: FunctionTool, + call_id: str, + tool_call: Any | None = None, + ) -> str: """Resolve model-visible output text for approval rejections.""" explicit_message = self._context_wrapper.get_rejection_message( tool.name, @@ -907,6 +923,11 @@ async def _resolve_approval_rejection_message(self, *, tool: FunctionTool, call_ if formatter is None: return REJECTION_MESSAGE + if tool_call is not None: + self._context_wrapper._mark_tool_invocation_executed( + tool_call, + tool_lookup_key=get_function_tool_lookup_key_for_tool(tool), + ) try: maybe_message = formatter( ToolErrorFormatterArgs( @@ -948,7 +969,17 @@ async def approve_tool_call(self, call_id: str, *, always: bool = False) -> None if pending is None: return - if not self._begin_tool_call(call_id, from_pending_approval=True): + pending_identity = tool_invocation_identity( + pending.approval_item.raw_item, + tool_lookup_key=pending.approval_item.tool_lookup_key, + ) + if pending_identity is None: + raise ModelBehaviorError("Realtime tool calls require a canonical invocation identity.") + if not self._begin_tool_call( + call_id, + pending_identity, + from_pending_approval=True, + ): return try: @@ -971,7 +1002,7 @@ async def approve_tool_call(self, call_id: str, *, always: bool = False) -> None call_id_reserved=True, ) except Exception: - if call_id in self._active_tool_call_ids: + if call_id in self._active_tool_invocations: self._finish_tool_call(call_id, mark_completed=False) raise @@ -990,7 +1021,17 @@ async def reject_tool_call( if pending is None: return - if not self._begin_tool_call(call_id, from_pending_approval=True): + pending_identity = tool_invocation_identity( + pending.approval_item.raw_item, + tool_lookup_key=pending.approval_item.tool_lookup_key, + ) + if pending_identity is None: + raise ModelBehaviorError("Realtime tool calls require a canonical invocation identity.") + if not self._begin_tool_call( + call_id, + pending_identity, + from_pending_approval=True, + ): return mark_completed = False @@ -1020,15 +1061,91 @@ async def _handle_tool_call( ) -> None: """Handle a tool call event.""" mark_completed = False + agent = dispatch_snapshot.agent if dispatch_snapshot is not None else agent_snapshot + agent = agent or self._current_agent + recorded_route = self._tool_invocation_routes.get(event.call_id) + recorded_role = recorded_route[1] if recorded_route is not None else None + if ( + recorded_route is not None + and recorded_route[0] is not None + and recorded_route[0][-1] != event.name + ): + raise ModelBehaviorError( + "Model reused a Realtime tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + dispatch_role = self._resolve_tool_dispatch_role( + event.name, + agent=agent, + dispatch_snapshot=dispatch_snapshot, + ) + if ( + recorded_route is not None + and dispatch_role is not None + and recorded_role != dispatch_role + ): + raise ModelBehaviorError( + "Model reused a Realtime tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + identity_role = ( + recorded_role if dispatch_role is None and recorded_route is not None else dispatch_role + ) + current_raw_item = { + "type": "function_call", + "name": event.name, + "call_id": event.call_id, + "arguments": event.arguments, + } + current_lookup_key = ( + recorded_route[0] + if recorded_route is not None + else get_function_tool_lookup_key(event.name, None) + ) + current_identity = tool_invocation_identity( + current_raw_item, + tool_lookup_key=current_lookup_key, + invocation_role="handoff" if identity_role == "handoff" else None, + ) + if current_identity is None: + raise ModelBehaviorError("Realtime tool calls require a non-empty string call ID.") + active_identity = self._active_tool_invocations.get(event.call_id) + if active_identity is not None and active_identity != current_identity: + raise ModelBehaviorError( + "Model reused a Realtime tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + invocation_status = self._context_wrapper._tool_invocation_status( + current_raw_item, + tool_lookup_key=current_lookup_key, + invocation_role="handoff" if identity_role == "handoff" else None, + ) + if invocation_status is None: + raise ModelBehaviorError("Realtime tool calls require a non-empty string call ID.") + + pending_output = self._pending_tool_outputs.get(event.call_id) + has_pending_output = pending_output is not None + is_duplicate_call = ( + active_identity is not None + or event.call_id in self._pending_tool_calls + or invocation_status[1] + ) + if not call_id_reserved: + if is_duplicate_call: + return + if invocation_status[2] and not invocation_status[1] and not has_pending_output: + raise ModelBehaviorError( + "A Realtime tool call already executed, but its output was not committed. " + "Start a new call instead of retrying the invocation." + ) if not call_id_reserved and not self._begin_tool_call( - event.call_id, from_pending_approval=from_pending_approval + event.call_id, + current_identity, + from_pending_approval=from_pending_approval, ): return - agent = dispatch_snapshot.agent if dispatch_snapshot is not None else agent_snapshot - agent = agent or self._current_agent try: - pending_output = self._pending_tool_outputs.get(event.call_id) if pending_output is not None: await self._send_tool_output_completion(pending_output) mark_completed = True @@ -1046,6 +1163,14 @@ async def _handle_tool_call( if event.name in function_map: func_tool = function_map[event.name] + approval_item = self._build_tool_approval_item(func_tool, event, agent) + self._bind_resolved_tool_invocation( + event.call_id, + approval_item.raw_item, + preliminary_identity=current_identity, + tool_lookup_key=approval_item.tool_lookup_key, + route_role="function", + ) approval_status = await self._maybe_request_tool_approval( event, function_tool=func_tool, @@ -1065,6 +1190,10 @@ async def _handle_tool_call( if approval_status is None: return + self._context_wrapper._mark_tool_invocation_executed( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + ) rejected_message = await self._run_tool_input_guardrails( tool=func_tool, tool_call=event, @@ -1095,9 +1224,8 @@ async def _handle_tool_call( if self._closing or self._closed: return - tool_context = ToolContext( - context=self._context_wrapper.context, - usage=self._context_wrapper.usage, + tool_context = ToolContext.from_agent_context( + self._context_wrapper, tool_name=event.name, tool_call_id=event.call_id, tool_arguments=event.arguments, @@ -1128,9 +1256,15 @@ async def _handle_tool_call( mark_completed = True elif event.name in handoff_map: handoff = handoff_map[event.name] - tool_context = ToolContext( - context=self._context_wrapper.context, - usage=self._context_wrapper.usage, + self._bind_resolved_tool_invocation( + event.call_id, + current_raw_item, + preliminary_identity=current_identity, + tool_lookup_key=get_function_tool_lookup_key(event.name, None), + route_role="handoff", + ) + tool_context = ToolContext.from_agent_context( + self._context_wrapper, tool_name=event.name, tool_call_id=event.call_id, tool_arguments=event.arguments, @@ -1138,6 +1272,11 @@ async def _handle_tool_call( ) # Execute the handoff to get the new agent + self._context_wrapper._mark_tool_invocation_executed( + current_raw_item, + tool_lookup_key=get_function_tool_lookup_key(event.name, None), + invocation_role="handoff", + ) result = await handoff.on_invoke_handoff(self._context_wrapper, event.arguments) if self._closing or self._closed: return @@ -1185,6 +1324,14 @@ async def _handle_tool_call( ) mark_completed = True else: + fallback_role = "handoff" if identity_role == "handoff" else None + self._bind_resolved_tool_invocation( + event.call_id, + current_raw_item, + preliminary_identity=current_identity, + tool_lookup_key=get_function_tool_lookup_key(event.name, None), + route_role=fallback_role, + ) error_message = f"Tool {event.name} not found" await self._send_tool_output_completion( _PendingToolOutput( @@ -1203,20 +1350,101 @@ async def _handle_tool_call( finally: self._finish_tool_call(event.call_id, mark_completed=mark_completed) - def _begin_tool_call(self, call_id: str, *, from_pending_approval: bool) -> bool: + def _begin_tool_call( + self, + call_id: str, + identity: tuple[str, str, str], + *, + from_pending_approval: bool, + ) -> bool: if self._closing or self._closed: return False - if call_id in self._active_tool_call_ids or call_id in self._completed_tool_call_ids: + active_identity = self._active_tool_invocations.get(call_id) + if active_identity is not None: + if active_identity != identity: + raise ModelBehaviorError( + "Model reused a Realtime tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) return False if not from_pending_approval and call_id in self._pending_tool_calls: return False - self._active_tool_call_ids.add(call_id) + self._active_tool_invocations[call_id] = identity return True + def _bind_resolved_tool_invocation( + self, + call_id: str, + raw_item: Any, + *, + preliminary_identity: tuple[str, str, str], + tool_lookup_key: FunctionToolLookupKey | None, + route_role: str | None, + ) -> None: + """Atomically replace a provisional Realtime identity with its resolved identity.""" + resolved_identity = tool_invocation_identity( + raw_item, + tool_lookup_key=tool_lookup_key, + invocation_role="handoff" if route_role == "handoff" else None, + ) + if resolved_identity is None: + raise ModelBehaviorError("Realtime tool calls require a canonical invocation identity.") + + active_identity = self._active_tool_invocations.get(call_id) + if active_identity not in {None, preliminary_identity, resolved_identity}: + raise ModelBehaviorError( + "Model reused a Realtime tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + self._context_wrapper._rebind_tool_invocation( + raw_item, + previous_identity=preliminary_identity, + tool_lookup_key=tool_lookup_key, + invocation_role="handoff" if route_role == "handoff" else None, + ) + if active_identity is not None: + self._active_tool_invocations[call_id] = resolved_identity + self._tool_invocation_routes[call_id] = (tool_lookup_key, route_role) + + def _resolve_tool_dispatch_role( + self, + tool_name: str, + *, + agent: RealtimeAgent[Any], + dispatch_snapshot: _RealtimeDispatchSnapshot | None, + ) -> str | None: + """Return the known dispatch role without invoking dynamic tool resolvers.""" + snapshot = dispatch_snapshot + if snapshot is None and self._current_dispatch_snapshot is not None: + if self._current_dispatch_snapshot.agent is agent: + snapshot = self._current_dispatch_snapshot + + tools: Sequence[Any] + handoffs: Sequence[Any] + if snapshot is not None: + tools = snapshot.tools + handoffs = snapshot.handoffs + else: + raw_tools = getattr(agent, "tools", ()) + raw_handoffs = getattr(agent, "handoffs", ()) + tools = raw_tools if isinstance(raw_tools, Sequence) else () + handoffs = raw_handoffs if isinstance(raw_handoffs, Sequence) else () + + if any( + (isinstance(handoff, Handoff) and handoff.tool_name == tool_name) + or ( + isinstance(handoff, RealtimeAgent) + and Handoff.default_tool_name(handoff) == tool_name + ) + for handoff in handoffs + ): + return "handoff" + if any(isinstance(tool, FunctionTool) and tool.name == tool_name for tool in tools): + return "function" + return None + def _finish_tool_call(self, call_id: str, *, mark_completed: bool) -> None: - self._active_tool_call_ids.discard(call_id) - if mark_completed and not self._closing and not self._closed: - self._completed_tool_call_ids.add(call_id) + self._active_tool_invocations.pop(call_id, None) @classmethod def _get_new_history( @@ -1770,9 +1998,9 @@ async def _cleanup(self) -> None: # Clear pending approval tracking self._pending_tool_calls.clear() + self._tool_invocation_routes.clear() self._pending_tool_outputs.clear() - self._active_tool_call_ids.clear() - self._completed_tool_call_ids.clear() + self._active_tool_invocations.clear() # Mark as closed self._closed = True diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 946b3db879..962771fe01 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -16,7 +16,17 @@ is_reserved_synthetic_tool_namespace, tool_qualified_name, ) -from .exceptions import UserError +from ._tool_invocation import ( + is_mcp_approval_invocation, + is_tool_invocation_digest, + is_tool_invocation_type, + tool_invocation_approval_scope, + tool_invocation_call_id, + tool_invocation_identity, + tool_invocation_identity_and_scope, + tool_output_identity, +) +from .exceptions import ModelBehaviorError, UserError from .usage import Usage if TYPE_CHECKING: @@ -30,6 +40,17 @@ TContext = TypeVar("TContext", default=Any) +@dataclass(eq=False) +class _ToolInvocationRecord: + """Tracks the canonical identity and lifecycle of one provider tool call ID.""" + + invocation_type: str + approval_scope: str + fingerprint: str + executed: bool = False + completed: bool = False + + @dataclass(eq=False) class _ApprovalRecord: """Tracks approval/rejection state for a tool. @@ -42,6 +63,7 @@ class _ApprovalRecord: rejected: bool | list[str] = field(default_factory=list) rejection_messages: dict[str, str] = field(default_factory=dict) sticky_rejection_message: str | None = None + sticky_scope: str | None = None @dataclass(eq=False) @@ -63,8 +85,32 @@ class RunContextWrapper(Generic[TContext]): turn_input: list[TResponseInputItem] = field(default_factory=list) _approvals: dict[str | HostedMCPApprovalKey, _ApprovalRecord] = field(default_factory=dict) + _tool_invocations: dict[str, _ToolInvocationRecord] = field( + default_factory=dict, + init=False, + repr=False, + ) tool_input: Any | None = None """Structured input for the current agent tool run, when available.""" + _allow_legacy_approval_binding_reconstruction: bool = field( + default=False, + init=False, + repr=False, + ) + _restored_unbound_approval_call_ids: set[str] = field( + default_factory=set, + init=False, + repr=False, + ) + + def _share_tool_state_with(self, target: RunContextWrapper[Any]) -> None: + """Share tool approval and invocation state with a derived context wrapper.""" + target._approvals = self._approvals + target._tool_invocations = self._tool_invocations + target._allow_legacy_approval_binding_reconstruction = ( + self._allow_legacy_approval_binding_reconstruction + ) + target._restored_unbound_approval_call_ids = self._restored_unbound_approval_call_ids @staticmethod def _to_str_or_none(value: Any) -> str | None: @@ -149,8 +195,13 @@ def _resolve_tool_lookup_key(approval_item: ToolApprovalItem) -> FunctionToolLoo @staticmethod def _resolve_call_id(approval_item: ToolApprovalItem) -> str | None: + hosted_request = get_hosted_mcp_approval_request_identity(approval_item) + if hosted_request is not None: + return hosted_request.request_id + raw = approval_item.raw_item if isinstance(raw, dict): + raw_type = raw.get("type") provider_data = raw.get("provider_data") if ( isinstance(provider_data, dict) @@ -159,8 +210,11 @@ def _resolve_call_id(approval_item: ToolApprovalItem) -> str | None: candidate = provider_data.get("id") if isinstance(candidate, str): return candidate - candidate = raw.get("call_id") or raw.get("id") + candidate = raw.get("id") if raw_type == "mcp_approval_request" else raw.get("call_id") + if candidate is None and raw_type is None: + candidate = raw.get("id") else: + raw_type = getattr(raw, "type", None) provider_data = getattr(raw, "provider_data", None) if ( isinstance(provider_data, dict) @@ -169,7 +223,13 @@ def _resolve_call_id(approval_item: ToolApprovalItem) -> str | None: candidate = provider_data.get("id") if isinstance(candidate, str): return candidate - candidate = getattr(raw, "call_id", None) or getattr(raw, "id", None) + candidate = ( + getattr(raw, "id", None) + if raw_type == "mcp_approval_request" + else getattr(raw, "call_id", None) + ) + if candidate is None and raw_type is None: + candidate = getattr(raw, "id", None) return RunContextWrapper._to_str_or_none(candidate) def _get_or_create_approval_entry( @@ -182,6 +242,355 @@ def _get_or_create_approval_entry( self._approvals[approval_key] = approval_entry return approval_entry + def _approved_tool_invocation_status( + self, + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, + ) -> tuple[tuple[str, str], bool, bool] | None: + """Validate an invocation and return status when an approval decision applies.""" + status = self._tool_invocation_status( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if status is None: + return None + identity = tool_invocation_identity_and_scope( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if identity is None: + return None + _, call_id, approval_scope, _ = identity + sticky_approval_keys = self._matching_sticky_approval_keys( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + approval_scope=approval_scope, + ) + has_per_call_decision = any( + (isinstance(record.approved, list) and call_id in record.approved) + or (isinstance(record.rejected, list) and call_id in record.rejected) + for record in self._approvals.values() + ) + if not has_per_call_decision and not sticky_approval_keys: + return None + return status + + def _tool_invocation_status( + self, + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, + ) -> tuple[tuple[str, str], bool, bool] | None: + """Validate and register one canonical invocation for a provider call ID.""" + call_identity = tool_invocation_call_id(raw_item) + call_id = call_identity[1] if call_identity is not None else None + is_restored_unbound = ( + call_id is not None and call_id in self._restored_unbound_approval_call_ids + ) + identity = tool_invocation_identity_and_scope( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if identity is None: + if is_mcp_approval_invocation(raw_item): + return None + if call_id is not None and call_id in self._tool_invocations: + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + return None + invocation_type, call_id, approval_scope, fingerprint = identity + record = self._tool_invocations.get(call_id) + if record is None: + if is_restored_unbound: + return None + record = _ToolInvocationRecord( + invocation_type=invocation_type, + approval_scope=approval_scope, + fingerprint=fingerprint, + ) + self._tool_invocations[call_id] = record + elif ( + record.invocation_type != invocation_type + or record.approval_scope != approval_scope + or record.fingerprint != fingerprint + ): + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + if is_restored_unbound: + return None + return ((invocation_type, call_id), record.completed, record.executed) + + def _rebind_tool_invocation( + self, + raw_item: Any, + *, + previous_identity: tuple[str, str, str], + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, + ) -> tuple[tuple[str, str], bool, bool] | None: + """Replace an unresolved invocation identity before execution begins.""" + identity = tool_invocation_identity_and_scope( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if identity is None: + return None + invocation_type, call_id, approval_scope, fingerprint = identity + record = self._tool_invocations.get(call_id) + resolved_identity = (invocation_type, approval_scope, fingerprint) + if record is None: + return self._tool_invocation_status( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + current_identity = ( + record.invocation_type, + record.approval_scope, + record.fingerprint, + ) + if current_identity == resolved_identity: + return ((invocation_type, call_id), record.completed, record.executed) + matches_previous = ( + record.invocation_type == previous_identity[0] + and call_id == previous_identity[1] + and record.fingerprint == previous_identity[2] + ) + if not matches_previous or record.executed or record.completed: + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + record.invocation_type = invocation_type + record.approval_scope = approval_scope + record.fingerprint = fingerprint + return ((invocation_type, call_id), False, False) + + def _matching_sticky_approval_keys( + self, + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None, + tool_name: str | None = None, + approval_scope: str, + ) -> frozenset[str | HostedMCPApprovalKey]: + """Return sticky approval keys that independently authorize this tool identity.""" + if isinstance(raw_item, Mapping): + mapping = raw_item + else: + model_dump = getattr(raw_item, "model_dump", None) + dumped = ( + model_dump(exclude_none=True, exclude_unset=True) if callable(model_dump) else None + ) + mapping = dumped if isinstance(dumped, Mapping) else {} + provider_data = mapping.get("provider_data") + if ( + mapping.get("type") == "hosted_tool_call" + and isinstance(provider_data, Mapping) + and provider_data.get("type") == "mcp_approval_request" + ): + merged = dict(mapping) + merged.update(provider_data) + mapping = merged + + invocation_type = mapping.get("type") + if not isinstance(invocation_type, str): + return frozenset() + tool_name = tool_name or self._to_str_or_none(mapping.get("name")) + tool_namespace = self._to_str_or_none(mapping.get("namespace")) + if invocation_type == "function_call": + approval_keys: tuple[str | HostedMCPApprovalKey, ...] = get_function_tool_approval_keys( + tool_name=tool_name, + tool_namespace=tool_namespace, + tool_lookup_key=tool_lookup_key, + include_legacy_deferred_key=True, + ) + elif invocation_type == "mcp_approval_request": + server_label = self._to_str_or_none(mapping.get("server_label")) + approval_keys = ( + (("hosted_mcp", server_label, tool_name),) + if server_label is not None and tool_name is not None + else () + ) + else: + if tool_name is None: + tool_name = { + "apply_patch_call": "apply_patch", + "computer_call": "computer", + "local_shell_call": "local_shell", + "shell_call": "shell", + }.get(invocation_type) + approval_keys = (tool_name,) if tool_name else () + + matching_keys: set[str | HostedMCPApprovalKey] = set() + for approval_key in approval_keys: + record = self._approvals.get(approval_key) + if ( + record is not None + and (isinstance(record.approved, bool) or isinstance(record.rejected, bool)) + and record.sticky_scope == approval_scope + ): + matching_keys.add(approval_key) + return frozenset(matching_keys) + + def _mark_tool_call_completed( + self, + raw_item: Any, + ) -> None: + """Mark a canonical invocation completed when its output is committed.""" + identity = tool_output_identity(raw_item) + if identity is None: + return + invocation_type, call_id = identity + record = self._tool_invocations.get(call_id) + if record is None or record.invocation_type != invocation_type: + return + record.executed = True + record.completed = True + + def _mark_tool_invocation_executed( + self, + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, + ) -> None: + """Mark an invocation executed before the first user-code side effect.""" + status = self._tool_invocation_status( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if status is None: + return + _, call_id = status[0] + self._tool_invocations[call_id].executed = True + + def _restore_pending_approval_binding(self, approval_item: ToolApprovalItem) -> None: + """Rebuild a missing binding from a serialized pending approval item.""" + if not self._allow_legacy_approval_binding_reconstruction: + return + approval_keys: list[str | HostedMCPApprovalKey] = list( + self._resolve_approval_keys(approval_item) + ) + hosted_request = get_hosted_mcp_approval_request_identity(approval_item) + if hosted_request is not None and hosted_request.request_id is not None: + hosted_key: HostedMCPApprovalKey = ( + hosted_request.approval_identity + if hosted_request.approval_identity is not None + else ("hosted_mcp_call", hosted_request.request_id) + ) + approval_keys.append(hosted_key) + scope_identity = tool_invocation_approval_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + if scope_identity is not None: + _, approval_scope = scope_identity + for approval_key in approval_keys: + record = self._approvals.get(approval_key) + if record is not None and ( + isinstance(record.approved, bool) or isinstance(record.rejected, bool) + ): + record.sticky_scope = record.sticky_scope or approval_scope + call_id = self._resolve_call_id(approval_item) + if call_id is None: + return + identity = tool_invocation_identity_and_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + if identity is None: + self._restored_unbound_approval_call_ids.add(call_id) + return + has_matching_decision = False + for approval_key in approval_keys: + record = self._approvals.get(approval_key) + if record is None: + continue + has_per_call_decision = ( + isinstance(record.approved, list) and call_id in record.approved + ) or (isinstance(record.rejected, list) and call_id in record.rejected) + has_sticky_decision = ( + record.sticky_scope == approval_scope + and self._get_approval_status_for_record(record, call_id) is not None + ) + has_matching_decision = ( + has_matching_decision or has_per_call_decision or has_sticky_decision + ) + if has_matching_decision: + self._tool_invocation_status( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + + def _mark_restored_unbound_pending_approval( + self, + approval_item: ToolApprovalItem, + ) -> None: + """Remember a current-schema pending call whose sticky binding was not restored.""" + if self._allow_legacy_approval_binding_reconstruction: + return + call_id = self._resolve_call_id(approval_item) + if call_id is None: + return + identity = tool_invocation_identity_and_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + if identity is None: + self._restored_unbound_approval_call_ids.add(call_id) + return + invocation_type, identity_call_id, approval_scope, fingerprint = identity + if identity_call_id != call_id: + self._restored_unbound_approval_call_ids.add(call_id) + return + sticky_keys = self._matching_sticky_approval_keys( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + approval_scope=approval_scope, + ) + has_per_call_decision = any( + (isinstance(record.approved, list) and call_id in record.approved) + or (isinstance(record.rejected, list) and call_id in record.rejected) + for record in self._approvals.values() + ) + if sticky_keys or has_per_call_decision: + restored_binding = self._tool_invocations.get(call_id) + if restored_binding is None or ( + restored_binding.invocation_type != invocation_type + or restored_binding.approval_scope != approval_scope + or restored_binding.fingerprint != fingerprint + ): + self._restored_unbound_approval_call_ids.add(call_id) + def is_tool_approved(self, tool_name: str, call_id: str) -> bool | None: """Return True/False/None for the given tool call.""" hosted_query_record = self._approvals.get(("hosted_mcp_query", tool_name, call_id)) @@ -350,6 +759,18 @@ def _resolve_hosted_mcp_approval_decision( status = self._get_per_call_approval_status_for_record(approval_record, request_id) else: status = self._get_approval_status_for_record(approval_record, request_id) + if ( + status is not None + and approval_record.sticky_scope is None + and self._allow_legacy_approval_binding_reconstruction + ): + scope_identity = tool_invocation_approval_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + if scope_identity is not None: + approval_record.sticky_scope = scope_identity[1] return status, self._get_rejection_message_for_key(approval_record, request_id) def get_rejection_message( @@ -463,8 +884,62 @@ def _apply_approval_decision( call_id = self._resolve_call_id(approval_item) hosted_identity = None + call_identity = tool_invocation_call_id(approval_item.raw_item) + if call_identity is not None and call_identity[1] is None: + raise ModelBehaviorError( + "Approval decisions require a non-empty call ID for recognized tool invocations." + ) + + raw_item = approval_item.raw_item + if isinstance(raw_item, Mapping): + raw_call_id = raw_item.get("call_id") if "call_id" in raw_item else raw_item.get("id") + else: + raw_call_id = ( + getattr(raw_item, "call_id", None) + if hasattr(raw_item, "call_id") + else getattr(raw_item, "id", None) + ) + if raw_call_id == "" and not always: + raise ModelBehaviorError("Per-call approval decisions require a non-empty call ID.") + invocation = ( + None + if call_id is None + else tool_invocation_identity_and_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + ) + if call_id is not None and invocation is None: + raise ModelBehaviorError("Approval decisions require a canonical invocation identity.") + if call_id is None and raw_call_id is not None: + raise ModelBehaviorError("Approval decisions require a canonical invocation identity.") + scope_identity = tool_invocation_approval_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + if invocation is not None: + assert call_id is not None + if invocation[1] != call_id: + raise ModelBehaviorError( + "Approval decision call ID does not match its canonical invocation ID." + ) + was_restored_unbound = call_id in self._restored_unbound_approval_call_ids + if was_restored_unbound: + self._restored_unbound_approval_call_ids.remove(call_id) + try: + self._tool_invocation_status( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + finally: + if was_restored_unbound: + self._restored_unbound_approval_call_ids.add(call_id) approval_entries: tuple[tuple[_ApprovalRecord, bool], ...] if hosted_request is not None: + approval_keys: tuple[str, ...] = () assert call_id is not None hosted_key: HostedMCPApprovalKey if hosted_identity is None: @@ -497,6 +972,9 @@ def _apply_approval_decision( for approval_entry, entry_is_sticky in approval_entries: if entry_is_sticky or call_id is None: + approval_entry.sticky_scope = ( + scope_identity[1] if scope_identity is not None else None + ) approval_entry.approved = approve approval_entry.rejected = [] if approve else True if not approve: @@ -526,6 +1004,10 @@ def _apply_approval_decision( else: self._clear_rejection_message(approval_entry, call_id) + if invocation is not None: + assert call_id is not None + self._restored_unbound_approval_call_ids.discard(call_id) + def approve_tool(self, approval_item: ToolApprovalItem, always_approve: bool = False) -> None: """Approve a tool call, optionally for all future calls.""" self._apply_approval_decision( @@ -556,13 +1038,38 @@ def get_approval_status( tool_namespace: str | None = None, existing_pending: ToolApprovalItem | None = None, tool_lookup_key: FunctionToolLookupKey | None = None, + current_invocation: ToolApprovalItem | None = None, ) -> bool | None: """Return approval status, retrying with pending item's tool name if necessary.""" + if not isinstance(call_id, str) or not call_id: + raise ModelBehaviorError("Approval-gated tool calls require a non-empty call ID.") if existing_pending is not None: + self._restore_pending_approval_binding(existing_pending) + pending_identity = tool_invocation_identity( + existing_pending.raw_item, + tool_lookup_key=existing_pending.tool_lookup_key, + tool_name=existing_pending.tool_name, + ) + if pending_identity is None: + pending_call_id = self._resolve_call_id(existing_pending) + if pending_call_id is not None and ( + current_invocation is None or pending_call_id not in self._tool_invocations + ): + self._restored_unbound_approval_call_ids.add(pending_call_id) + if current_invocation is None: + return None hosted_request = get_hosted_mcp_approval_request_identity(existing_pending) if hosted_request is not None: hosted_status, _ = self._resolve_hosted_mcp_approval_decision(existing_pending) - return hosted_status + if hosted_status is None: + return None + effective_invocation = current_invocation or existing_pending + binding_status = self._approved_tool_invocation_status( + effective_invocation.raw_item, + tool_lookup_key=effective_invocation.tool_lookup_key, + tool_name=effective_invocation.tool_name, + ) + return hosted_status if binding_status is not None else None candidates: list[str] = [] explicit_namespace = ( @@ -618,10 +1125,73 @@ def get_approval_status( candidates.append(pending_tool_name) status: bool | None = None + matched_record: _ApprovalRecord | None = None for candidate in candidates: status = self._get_approval_status_for_key(candidate, call_id) if status is not None: + matched_record = self._approvals.get(candidate) break + selected_invocation = current_invocation or existing_pending + if status is None or matched_record is None or selected_invocation is None: + return status + is_sticky = isinstance(matched_record.approved, bool) or isinstance( + matched_record.rejected, bool + ) + if is_sticky: + if ( + matched_record.sticky_scope is None + and self._allow_legacy_approval_binding_reconstruction + ): + scope_identity = tool_invocation_approval_scope( + selected_invocation.raw_item, + tool_lookup_key=selected_invocation.tool_lookup_key, + tool_name=selected_invocation.tool_name, + ) + if scope_identity is not None: + matched_record.sticky_scope = scope_identity[1] + binding_status = self._approved_tool_invocation_status( + selected_invocation.raw_item, + tool_lookup_key=selected_invocation.tool_lookup_key, + tool_name=selected_invocation.tool_name, + ) + return status if binding_status is not None else None + if current_invocation is not None: + current_identity = tool_invocation_identity( + current_invocation.raw_item, + tool_lookup_key=current_invocation.tool_lookup_key, + tool_name=current_invocation.tool_name, + ) + if current_identity is None: + self._approved_tool_invocation_status( + current_invocation.raw_item, + tool_lookup_key=current_invocation.tool_lookup_key, + tool_name=current_invocation.tool_name, + ) + return None + binding_status = self._approved_tool_invocation_status( + selected_invocation.raw_item, + tool_lookup_key=selected_invocation.tool_lookup_key, + tool_name=selected_invocation.tool_name, + ) + if binding_status is None: + current_identity = tool_invocation_identity( + selected_invocation.raw_item, + tool_lookup_key=selected_invocation.tool_lookup_key, + tool_name=selected_invocation.tool_name, + ) + if current_identity is not None: + return None + if existing_pending is not None: + pending_identity = tool_invocation_identity( + existing_pending.raw_item, + tool_lookup_key=existing_pending.tool_lookup_key, + tool_name=existing_pending.tool_name, + ) + if pending_identity is None and is_mcp_approval_invocation( + existing_pending.raw_item + ): + return None + return status return status def _rebuild_approvals(self, approvals: Any) -> None: @@ -649,8 +1219,55 @@ def _restore_approval_record(cls, record_dict: Mapping[str, Any]) -> _ApprovalRe sticky_rejection_message = record_dict.get("sticky_rejection_message") if isinstance(sticky_rejection_message, str): record.sticky_rejection_message = sticky_rejection_message + sticky_scope = record_dict.get("sticky_scope") + if isinstance(sticky_scope, str): + record.sticky_scope = sticky_scope return record + def _rebuild_tool_invocations(self, invocations: Any) -> None: + """Restore the current-schema canonical tool invocation ledger.""" + self._tool_invocations = {} + if not isinstance(invocations, Mapping): + raise UserError("RunState tool_invocations must be a mapping.") + for call_id, serialized_invocation in invocations.items(): + if not isinstance(call_id, str) or not call_id: + raise UserError("RunState tool_invocations contains an invalid call ID.") + if not isinstance(serialized_invocation, Mapping): + raise UserError(f"RunState tool invocation {call_id!r} must be a mapping.") + invocation_type = serialized_invocation.get("type") + approval_scope = serialized_invocation.get("approval_scope") + fingerprint = serialized_invocation.get("fingerprint") + executed = serialized_invocation.get("executed") + completed = serialized_invocation.get("completed") + if ( + not is_tool_invocation_type(invocation_type) + or not is_tool_invocation_digest(approval_scope) + or not is_tool_invocation_digest(fingerprint) + or not isinstance(executed, bool) + or not isinstance(completed, bool) + or (completed and not executed) + ): + raise UserError( + f"RunState tool invocation {call_id!r} contains invalid lifecycle data." + ) + self._tool_invocations[call_id] = _ToolInvocationRecord( + invocation_type=invocation_type, + approval_scope=approval_scope, + fingerprint=fingerprint, + executed=executed, + completed=completed, + ) + + def _mark_restored_unbound_approval_call_ids(self) -> None: + """Require reapproval for restored per-call decisions without a ledger binding.""" + for record in self._approvals.values(): + for decision in (record.approved, record.rejected): + if not isinstance(decision, list): + continue + self._restored_unbound_approval_call_ids.update( + call_id for call_id in decision if call_id not in self._tool_invocations + ) + def _rebuild_hosted_mcp_approvals(self, approvals: Any) -> None: """Restore typed hosted MCP approval records from serialized state.""" if not isinstance(approvals, list): @@ -692,7 +1309,7 @@ def _fork_with_tool_input(self, tool_input: Any) -> RunContextWrapper[TContext]: """Create a child context that shares approvals and usage with tool input set.""" fork = RunContextWrapper(context=self.context) fork.usage = self.usage - fork._approvals = self._approvals + self._share_tool_state_with(fork) fork.turn_input = self.turn_input fork.tool_input = tool_input return fork @@ -701,7 +1318,7 @@ def _fork_without_tool_input(self) -> RunContextWrapper[TContext]: """Create a child context that shares approvals and usage without tool input.""" fork = RunContextWrapper(context=self.context) fork.usage = self.usage - fork._approvals = self._approvals + self._share_tool_state_with(fork) fork.turn_input = self.turn_input return fork diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index ad9bb25cae..9cacc50501 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -16,6 +16,7 @@ from openai.types.responses import ResponseFunctionToolCall from pydantic import BaseModel +from .._tool_identity import get_hosted_mcp_approval_request_identity from ..agent_tool_state import drop_agent_tool_run_result from ..items import ItemHelpers, RunItem, ToolCallOutputItem, TResponseInputItem from ..models.fake_id import FAKE_RESPONSES_ID @@ -668,6 +669,12 @@ def _dedupe_key(item: TResponseInputItem) -> str | None: item_type = payload.get("type") or role if role is not None or item_type == "message": return None + call_id = payload.get("call_id") + if isinstance(call_id, str) and item_type in { + *_TOOL_CALL_TO_OUTPUT_TYPE, + *_TOOL_CALL_TO_OUTPUT_TYPE.values(), + }: + return f"call_id:{item_type}:{call_id}" item_id = payload.get("id") if item_id == FAKE_RESPONSES_ID: # Ignore placeholder IDs so call_id-based dedupe remains possible. @@ -675,7 +682,6 @@ def _dedupe_key(item: TResponseInputItem) -> str | None: if isinstance(item_id, str): return f"id:{item_type}:{item_id}" - call_id = payload.get("call_id") if isinstance(call_id, str): return f"call_id:{item_type}:{call_id}" @@ -876,6 +882,12 @@ def apply_patch_rejection_item( def extract_mcp_request_id(raw_item: Any) -> str | None: """Pull the request id from hosted MCP approval payloads.""" + try: + hosted_request = get_hosted_mcp_approval_request_identity(raw_item) + except Exception: + hosted_request = None + if hosted_request is not None: + return hosted_request.request_id if isinstance(raw_item, dict): provider_data = raw_item.get("provider_data") if isinstance(provider_data, dict): @@ -902,6 +914,12 @@ def extract_mcp_request_id(raw_item: Any) -> str | None: def extract_mcp_request_id_from_run(mcp_run: Any) -> str | None: """Extract the hosted MCP request id from a streaming run item.""" request_item = getattr(mcp_run, "request_item", None) or getattr(mcp_run, "requestItem", None) + try: + hosted_request = get_hosted_mcp_approval_request_identity(request_item) + except Exception: + hosted_request = None + if hosted_request is not None: + return hosted_request.request_id if isinstance(request_item, dict): provider_data = request_item.get("provider_data") if isinstance(provider_data, dict): diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 33d6fbfc50..a3316d2615 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -7,26 +7,20 @@ import asyncio import dataclasses as _dc -import json -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable from functools import partial from typing import Any, TypeVar, cast +from uuid import uuid4 from openai.types.responses import ( Response, ResponseCompletedEvent, - ResponseFunctionToolCall, ResponseOutputItemDoneEvent, ) -from openai.types.responses.response_output_item import McpCall, McpListTools, ResponseOutputItem +from openai.types.responses.response_output_item import ResponseOutputItem from openai.types.responses.response_prompt_param import ResponsePromptParam -from openai.types.responses.response_reasoning_item import ResponseReasoningItem -from .._mcp_tool_metadata import collect_mcp_list_tools_metadata from .._tool_identity import ( - NamedToolLookupKey, - build_function_tool_lookup_map, - get_function_tool_lookup_key_for_call, get_tool_trace_name_for_tool, resolve_tool_name_collisions, ) @@ -46,19 +40,11 @@ ) from ..handoffs import Handoff from ..items import ( - HandoffCallItem, ItemHelpers, ModelResponse, - ReasoningItem, RunItem, ToolApprovalItem, - ToolCallItem, - ToolCallItemTypes, - ToolSearchCallItem, - ToolSearchOutputItem, TResponseInputItem, - coerce_tool_search_call_raw_item, - coerce_tool_search_output_raw_item, ) from ..lifecycle import RunHooks from ..logger import ( @@ -83,16 +69,11 @@ from ..stream_events import ( AgentUpdatedStreamEvent, RawResponsesStreamEvent, - RunItemStreamEvent, ) from ..tool import ( - FunctionTool, ProgrammaticToolCallingTool, Tool, - ToolOrigin, - ToolOriginType, dispose_resolved_computers, - get_function_tool_origin, ) from ..tracing import Span, SpanError, agent_span, get_current_trace, task_span, turn_span from ..tracing.config import include_task_and_turn_spans @@ -174,7 +155,6 @@ from .streaming import stream_step_items_to_queue, stream_step_result_to_queue from .tool_actions import ApplyPatchAction, ComputerAction, LocalShellAction, ShellAction from .tool_execution import ( - build_litellm_json_tool_call, coerce_shell_call, execute_apply_patch_calls, execute_computer_actions, @@ -189,7 +169,6 @@ ) from .tool_planning import execute_mcp_approval_requests from .tool_use_tracker import ( - TOOL_CALL_TYPES, AgentToolUseTracker, hydrate_tool_use_tracker, serialize_tool_use_tracker, @@ -209,7 +188,6 @@ execute_handoffs, execute_tools_and_side_effects, get_single_step_result_from_response, - is_handoff_tool_call, process_model_response, resolve_interrupted_turn, run_final_output_hooks, @@ -278,6 +256,21 @@ "input_guardrail_tripwire_triggered_for_stream", ] +_STREAM_EVENT_ITEM_OCCURRENCE_KEY = "_agents_stream_event_item_occurrence_key" + + +def _stream_event_item_occurrence_key(item: RunItem) -> str | None: + key = getattr(item, _STREAM_EVENT_ITEM_OCCURRENCE_KEY, None) + return key if isinstance(key, str) and key else None + + +def _ensure_stream_event_item_occurrence_key(item: RunItem) -> str: + key = _stream_event_item_occurrence_key(item) + if key is None: + key = uuid4().hex + setattr(item, _STREAM_EVENT_ITEM_OCCURRENCE_KEY, key) + return key + async def cleanup_models_after_run(tool_use_tracker: AgentToolUseTracker) -> None: """Notify every model resolved during the run that its owning run has ended.""" @@ -1511,22 +1504,6 @@ async def raise_if_input_guardrail_tripwire_known() -> None: if tripwire_result is not None: raise InputGuardrailTripwireTriggered(tripwire_result) - emitted_tool_call_ids: set[str] = set() - emitted_reasoning_item_ids: set[str] = set() - emitted_tool_search_fingerprints: set[str] = set() - - def _tool_search_fingerprint(raw_item: Any) -> str: - if isinstance(raw_item, Mapping): - payload: Any = dict(raw_item) - elif hasattr(raw_item, "model_dump"): - payload = cast(Any, raw_item).model_dump(exclude_unset=True) - else: - payload = { - "type": getattr(raw_item, "type", None), - "id": getattr(raw_item, "id", None), - } - return json.dumps(payload, sort_keys=True, default=str) - try: turn_input = ItemHelpers.input_to_new_input_list(streamed_result.input) except Exception: @@ -1537,9 +1514,9 @@ def _tool_search_fingerprint(raw_item: Any) -> str: agent_hook_context = AgentHookContext( context=context_wrapper.context, usage=context_wrapper.usage, - _approvals=context_wrapper._approvals, turn_input=turn_input, ) + context_wrapper._share_tool_state_with(agent_hook_context) await gather_with_cancel( hooks.on_agent_start(agent_hook_context, public_agent), ( @@ -1573,22 +1550,6 @@ def _tool_search_fingerprint(raw_item: Any) -> str: if (tool_name := get_tool_trace_name_for_tool(tool)) is not None ] - # Precompute the lookup map used for streaming descriptions. Function tools use the same - # collision-free lookup keys as runtime dispatch, including deferred top-level aliases. - tool_map: dict[NamedToolLookupKey, Any] = cast( - dict[NamedToolLookupKey, Any], - build_function_tool_lookup_map( - [tool for tool in all_tools if isinstance(tool, FunctionTool)] - ), - ) - for tool in all_tools: - tool_name = getattr(tool, "name", None) - if not isinstance(tool_name, str) or not tool_name: - continue - if isinstance(tool, FunctionTool): - continue - tool_map[tool_name] = tool - handoff_tool_names = {handoff.tool_name for handoff in handoffs} model = get_model(execution_agent, run_config) tool_use_tracker.record_model(model) model_settings = get_model_settings(execution_agent, run_config) @@ -1596,6 +1557,7 @@ def _tool_search_fingerprint(raw_item: Any) -> str: final_response: ModelResponse | None = None streamed_response_output: list[ResponseOutputItem] = [] + emitted_model_item_occurrence_keys: set[str] = set() if server_conversation_tracker is not None: items_for_input = ( @@ -1625,9 +1587,6 @@ def _tool_search_fingerprint(raw_item: Any) -> str: ) if isinstance(filtered.input, list): filtered.input = deduplicate_input_items_preferring_latest(filtered.input) - hosted_mcp_tool_metadata = collect_mcp_list_tools_metadata(streamed_result._model_input_items) - if isinstance(filtered.input, list): - hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata(filtered.input)) if server_conversation_tracker is not None: logger.debug( "filtered.input has %s items; ids=%s", @@ -1783,109 +1742,27 @@ async def rewind_model_request() -> None: ) if isinstance(event, ResponseOutputItemDoneEvent): - output_item = event.item - streamed_response_output.append(output_item) - output_item_type = getattr(output_item, "type", None) - - if output_item_type == "tool_search_call": - emitted_tool_search_fingerprints.add(_tool_search_fingerprint(output_item)) - streamed_result._event_queue.put_nowait( - RunItemStreamEvent( - item=ToolSearchCallItem( - raw_item=coerce_tool_search_call_raw_item(output_item), - agent=public_agent, - ), - name="tool_search_called", - ) - ) - - elif output_item_type == "tool_search_output": - emitted_tool_search_fingerprints.add(_tool_search_fingerprint(output_item)) - streamed_result._event_queue.put_nowait( - RunItemStreamEvent( - item=ToolSearchOutputItem( - raw_item=coerce_tool_search_output_raw_item(output_item), - agent=public_agent, - ), - name="tool_search_output_created", - ) - ) + streamed_response_output.append(event.item) - elif isinstance(output_item, McpListTools): - hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata([output_item])) - - elif isinstance(output_item, TOOL_CALL_TYPES) and not is_handoff_tool_call( - output_item, handoff_tool_names - ): - # Handoff calls are streamed as `handoff_requested` once the turn is processed, - # so emitting them here too would duplicate the item under a second event name. - output_call_id: str | None = getattr( - output_item, "call_id", getattr(output_item, "id", None) - ) - - if ( - output_call_id - and isinstance(output_call_id, str) - and output_call_id not in emitted_tool_call_ids - ): - emitted_tool_call_ids.add(output_call_id) - - # Look up tool description from precomputed map ("last wins" matches - # execution behavior in process_model_response). - tool_lookup_key = get_function_tool_lookup_key_for_call(output_item) - matched_tool = ( - tool_map.get(tool_lookup_key) if tool_lookup_key is not None else None - ) - if ( - matched_tool is None - and output_schema is not None - and isinstance(output_item, ResponseFunctionToolCall) - and output_item.name == "json_tool_call" - ): - matched_tool = build_litellm_json_tool_call(output_item) - tool_description: str | None = None - tool_title: str | None = None - tool_origin = None - if isinstance(output_item, McpCall): - metadata = hosted_mcp_tool_metadata.get( - (output_item.server_label, output_item.name) - ) - if metadata is not None: - tool_description = metadata.description - tool_title = metadata.title - tool_origin = ToolOrigin( - type=ToolOriginType.MCP, - mcp_server_name=output_item.server_label, - ) - elif matched_tool is not None: - tool_description = getattr(matched_tool, "description", None) - tool_title = getattr(matched_tool, "_mcp_title", None) - tool_origin = get_function_tool_origin(matched_tool) - - tool_item = ToolCallItem( - raw_item=cast(ToolCallItemTypes, output_item), - agent=public_agent, - description=tool_description, - title=tool_title, - tool_origin=tool_origin, - ) - streamed_result._event_queue.put_nowait( - RunItemStreamEvent(item=tool_item, name="tool_called") - ) - - elif isinstance(output_item, ResponseReasoningItem): - reasoning_id: str | None = getattr(output_item, "id", None) + if not final_response: + raise ModelBehaviorError("Model did not produce a final response!") - if reasoning_id and reasoning_id not in emitted_reasoning_item_ids: - emitted_reasoning_item_ids.add(reasoning_id) + context_wrapper.usage.add(final_response.usage) - reasoning_item = ReasoningItem(raw_item=output_item, agent=public_agent) - streamed_result._event_queue.put_nowait( - RunItemStreamEvent(item=reasoning_item, name="reasoning_item_created") - ) + if server_conversation_tracker is not None: + # Streaming uses the same rewind helper, so a successful retry must restore delivered + # input tracking before the next turn computes server-managed deltas. + server_conversation_tracker.mark_input_as_sent(filtered.input) + server_conversation_tracker.track_server_items(final_response) - if final_response is not None: - context_wrapper.usage.add(final_response.usage) + async def after_invocation_validation( + model_items: list[RunItem] | None, + ) -> None: + if model_items is not None: + emitted_model_item_occurrence_keys.update( + _ensure_stream_event_item_occurrence_key(item) for item in model_items + ) + stream_step_items_to_queue(model_items, streamed_result._event_queue) await gather_with_cancel( ( public_agent.hooks.on_llm_end(context_wrapper, public_agent, final_response) @@ -1895,14 +1772,8 @@ async def rewind_model_request() -> None: hooks.on_llm_end(context_wrapper, public_agent, final_response), ) - if not final_response: - raise ModelBehaviorError("Model did not produce a final response!") - - if server_conversation_tracker is not None: - # Streaming uses the same rewind helper, so a successful retry must restore delivered - # input tracking before the next turn computes server-managed deltas. - server_conversation_tracker.mark_input_as_sent(filtered.input) - server_conversation_tracker.track_server_items(final_response) + async def check_input_guardrails_before_side_effects() -> None: + await raise_if_input_guardrail_tripwire_known() single_step_result = await get_single_step_result_from_response( bindings=bindings, @@ -1918,47 +1789,17 @@ async def rewind_model_request() -> None: error_handlers=error_handlers, tool_use_tracker=tool_use_tracker, server_manages_conversation=server_conversation_tracker is not None, - event_queue=streamed_result._event_queue, - before_side_effects=raise_if_input_guardrail_tripwire_known, + after_invocation_validation=after_invocation_validation, + before_side_effects=check_input_guardrails_before_side_effects, ) items_to_filter = session_items_for_turn(single_step_result) - if emitted_tool_call_ids: - items_to_filter = [ - item - for item in items_to_filter - if not ( - isinstance(item, ToolCallItem) - and ( - call_id := getattr(item.raw_item, "call_id", getattr(item.raw_item, "id", None)) - ) - and call_id in emitted_tool_call_ids - ) - ] - - if emitted_reasoning_item_ids: - items_to_filter = [ - item - for item in items_to_filter - if not ( - isinstance(item, ReasoningItem) - and (reasoning_id := getattr(item.raw_item, "id", None)) - and reasoning_id in emitted_reasoning_item_ids - ) - ] - - if emitted_tool_search_fingerprints: - items_to_filter = [ - item - for item in items_to_filter - if not ( - isinstance(item, ToolSearchCallItem | ToolSearchOutputItem) - and _tool_search_fingerprint(item.raw_item) in emitted_tool_search_fingerprints - ) - ] - - items_to_filter = [item for item in items_to_filter if not isinstance(item, HandoffCallItem)] + items_to_filter = [ + item + for item in items_to_filter + if _stream_event_item_occurrence_key(item) not in emitted_model_item_occurrence_keys + ] filtered_result = _dc.replace(single_step_result, new_step_items=items_to_filter) stream_step_result_to_queue(filtered_result, streamed_result._event_queue) @@ -1997,9 +1838,9 @@ async def run_single_turn( agent_hook_context = AgentHookContext( context=context_wrapper.context, usage=context_wrapper.usage, - _approvals=context_wrapper._approvals, turn_input=turn_input, ) + context_wrapper._share_tool_state_with(agent_hook_context) await gather_with_cancel( hooks.on_agent_start(agent_hook_context, public_agent), ( @@ -2050,8 +1891,21 @@ async def run_single_turn( session=session, session_items_to_rewind=session_items_to_rewind, prompt_cache_key_resolver=prompt_cache_key_resolver, + defer_llm_end_hooks=True, ) + async def after_invocation_validation( + _validated_model_items: list[RunItem] | None, + ) -> None: + await gather_with_cancel( + ( + public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) + if public_agent.hooks + else _coro.noop_coroutine() + ), + hooks.on_llm_end(context_wrapper, public_agent, new_response), + ) + return await get_single_step_result_from_response( bindings=bindings, original_input=original_input, @@ -2066,6 +1920,7 @@ async def run_single_turn( error_handlers=error_handlers, tool_use_tracker=tool_use_tracker, server_manages_conversation=server_conversation_tracker is not None, + after_invocation_validation=after_invocation_validation, ) @@ -2085,6 +1940,7 @@ async def get_new_response( session: Session | None = None, session_items_to_rewind: list[TResponseInputItem] | None = None, prompt_cache_key_resolver: PromptCacheKeyResolver | None = None, + defer_llm_end_hooks: bool = False, ) -> ModelResponse: """Call the model and return the raw response, handling retries and hooks.""" public_agent = bindings.public_agent @@ -2192,13 +2048,14 @@ async def rewind_model_request() -> None: context_wrapper.usage.add(new_response.usage) - await gather_with_cancel( - ( - public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) - if public_agent.hooks - else _coro.noop_coroutine() - ), - hooks.on_llm_end(context_wrapper, public_agent, new_response), - ) + if not defer_llm_end_hooks: + await gather_with_cancel( + ( + public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) + if public_agent.hooks + else _coro.noop_coroutine() + ), + hooks.on_llm_end(context_wrapper, public_agent, new_response), + ) return new_response diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py index 6b1d5cc97b..cea85b4645 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -9,6 +9,7 @@ import dataclasses import inspect import json +from collections.abc import Callable from typing import TYPE_CHECKING, Any, Literal, cast from openai.types.responses import ResponseComputerToolCall @@ -20,7 +21,7 @@ from .._tool_identity import get_mapping_or_attr, get_tool_trace_name_for_tool from ..agent import Agent from ..exceptions import ModelBehaviorError -from ..items import ItemHelpers, RunItem, ToolCallOutputItem +from ..items import ItemHelpers, RunItem, ToolApprovalItem, ToolCallOutputItem from ..logger import logger from ..run_config import RunConfig from ..run_context import RunContextWrapper @@ -112,6 +113,7 @@ async def execute( context_wrapper: RunContextWrapper[Any], config: RunConfig, acknowledged_safety_checks: list[ComputerCallOutputAcknowledgedSafetyCheck] | None = None, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> RunItem: """Run a computer action, capturing a screenshot and notifying hooks.""" trace_tool_name = get_tool_trace_name_for_tool(action.computer_tool) or cls.TRACE_TOOL_NAME @@ -166,6 +168,13 @@ async def _run_action(span: Any | None) -> RunItem: type="computer_call_output", acknowledged_safety_checks=acknowledged_safety_checks, ) + output_item = ToolCallOutputItem( + agent=agent, + output=image_url, + raw_item=raw_item, + ) + if tool_output_committer is not None: + tool_output_committer(output_item) custom_data = await maybe_extract_custom_data( action.computer_tool.custom_data_extractor, ComputerToolCustomDataContext( @@ -176,6 +185,7 @@ async def _run_action(span: Any | None) -> RunItem: raw_item=copy.deepcopy(raw_item), ), ) + output_item.custom_data = custom_data await gather_with_cancel( hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output), @@ -189,12 +199,7 @@ async def _run_action(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: span.span_data.output = image_url - return ToolCallOutputItem( - agent=agent, - output=image_url, - raw_item=raw_item, - custom_data=custom_data, - ) + return output_item return await with_tool_function_span( config=config, @@ -390,9 +395,14 @@ async def execute( hooks: RunHooks[Any], context_wrapper: RunContextWrapper[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> RunItem: """Run a local shell tool call and wrap the result as a ToolCallOutputItem.""" agent_hooks = agent.hooks + context_wrapper._mark_tool_invocation_executed( + call.tool_call, + tool_name=call.local_shell_tool.name, + ) await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, call.local_shell_tool), ( @@ -409,25 +419,28 @@ async def execute( output = call.local_shell_tool.executor(request) result = await output if inspect.isawaitable(output) else output - await gather_with_cancel( - hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result), - ( - agent_hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result) - if agent_hooks - else _coro.noop_coroutine() - ), - ) - raw_payload: dict[str, Any] = { "type": "local_shell_call_output", "call_id": call.tool_call.call_id, "output": result, } - return ToolCallOutputItem( + output_item = ToolCallOutputItem( agent=agent, output=result, raw_item=raw_payload, ) + if tool_output_committer is not None: + tool_output_committer(output_item) + + await gather_with_cancel( + hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result), + ( + agent_hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result) + if agent_hooks + else _coro.noop_coroutine() + ), + ) + return output_item class ShellAction: @@ -442,11 +455,17 @@ async def execute( hooks: RunHooks[Any], context_wrapper: RunContextWrapper[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> RunItem: """Run a shell tool call and return a normalized ToolCallOutputItem.""" shell_call = coerce_shell_call(call.tool_call) shell_tool = call.shell_tool agent_hooks = agent.hooks + current_item = ToolApprovalItem( + agent=agent, + raw_item=call.tool_call, + tool_name=shell_tool.name, + ) async def _run_call(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: @@ -455,7 +474,9 @@ async def _run_call(span: Any | None) -> RunItem: ) approval_status = context_wrapper.get_approval_status( - shell_tool.name, shell_call.call_id + shell_tool.name, + shell_call.call_id, + current_invocation=current_item, ) if approval_status is None: needs_approval_result = await evaluate_needs_approval_setting( @@ -465,7 +486,9 @@ async def _run_call(span: Any | None) -> RunItem: shell_call.call_id, ) approval_status = context_wrapper.get_approval_status( - shell_tool.name, shell_call.call_id + shell_tool.name, + shell_call.call_id, + current_invocation=current_item, ) else: needs_approval_result = False @@ -487,6 +510,7 @@ async def _run_call(span: Any | None) -> RunItem: rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=config, + tool_call=call.tool_call, tool_type="shell", tool_name=shell_tool.name, call_id=shell_call.call_id, @@ -498,6 +522,10 @@ async def _run_call(span: Any | None) -> RunItem: rejection_message=rejection_message, ) + context_wrapper._mark_tool_invocation_executed( + call.tool_call, + tool_name=shell_tool.name, + ) await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, shell_tool), ( @@ -572,15 +600,6 @@ async def _run_call(span: Any | None) -> RunItem: output_text = output_text[:max_output_length] log_tool_action_error("Shell executor failed", exc) - await gather_with_cancel( - hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text), - ( - agent_hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text) - if agent_hooks - else _coro.noop_coroutine() - ), - ) - raw_entries: list[dict[str, Any]] | None = None if shell_output_payload: raw_entries = shell_output_payload @@ -610,14 +629,27 @@ async def _run_call(span: Any | None) -> RunItem: if provider_meta: raw_item["provider_data"] = provider_meta - if span and config.trace_include_sensitive_data: - span.span_data.output = output_text - - return ToolCallOutputItem( + output_item = ToolCallOutputItem( agent=agent, output=output_text, raw_item=raw_item, ) + if tool_output_committer is not None: + tool_output_committer(output_item) + + await gather_with_cancel( + hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text), + ( + agent_hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text) + if agent_hooks + else _coro.noop_coroutine() + ), + ) + + if span and config.trace_include_sensitive_data: + span.span_data.output = output_text + + return output_item return await with_tool_function_span( config=config, @@ -638,13 +670,14 @@ async def execute( hooks: RunHooks[Any], context_wrapper: RunContextWrapper[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> RunItem: custom_tool: CustomTool = call.custom_tool agent_hooks = agent.hooks call_id = get_mapping_or_attr(call.tool_call, "call_id") tool_input = get_mapping_or_attr(call.tool_call, "input") - if not isinstance(call_id, str): - raise ModelBehaviorError("Custom tool call is missing call_id.") + if not isinstance(call_id, str) or not call_id: + raise ModelBehaviorError("Custom tool call is missing a non-empty call_id.") if not isinstance(tool_input, str): raise ModelBehaviorError("Custom tool call is missing input.") @@ -656,17 +689,30 @@ async def execute( agent=agent, run_config=config, ) + current_item = ToolApprovalItem( + agent=agent, + raw_item=call.tool_call, + tool_name=custom_tool.name, + ) async def _run_call(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: span.span_data.input = tool_input - approval_status = context_wrapper.get_approval_status(custom_tool.name, call_id) + approval_status = context_wrapper.get_approval_status( + custom_tool.name, + call_id, + current_invocation=current_item, + ) if approval_status is None: needs_approval_result = await evaluate_needs_approval_setting( custom_tool.runtime_needs_approval(), context_wrapper, tool_input, call_id ) - approval_status = context_wrapper.get_approval_status(custom_tool.name, call_id) + approval_status = context_wrapper.get_approval_status( + custom_tool.name, + call_id, + current_invocation=current_item, + ) else: needs_approval_result = False @@ -687,6 +733,7 @@ async def _run_call(span: Any | None) -> RunItem: rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=config, + tool_call=call.tool_call, tool_type="custom", tool_name=custom_tool.name, call_id=call_id, @@ -702,6 +749,10 @@ async def _run_call(span: Any | None) -> RunItem: ), ) + context_wrapper._mark_tool_invocation_executed( + call.tool_call, + tool_name=custom_tool.name, + ) await gather_with_cancel( hooks.on_tool_start(tool_context, agent, custom_tool), ( @@ -738,6 +789,14 @@ async def _run_call(span: Any | None) -> RunItem: output_text, tool_call=call.tool_call, ) + output_item = cls._tool_output_item( + agent, + call_id, + output_text, + raw_item=raw_item, + ) + if tool_output_committer is not None: + tool_output_committer(output_item) custom_data = await maybe_extract_custom_data( custom_tool.custom_data_extractor, CustomToolCustomDataContext( @@ -748,6 +807,7 @@ async def _run_call(span: Any | None) -> RunItem: raw_item=copy.deepcopy(raw_item), ), ) + output_item.custom_data = custom_data await gather_with_cancel( hooks.on_tool_end(tool_context, agent, custom_tool, output_text), @@ -760,13 +820,7 @@ async def _run_call(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: span.span_data.output = output_text - return cls._tool_output_item( - agent, - call_id, - output_text, - raw_item=raw_item, - custom_data=custom_data, - ) + return output_item return await with_tool_function_span( config=config, @@ -824,6 +878,7 @@ async def execute( hooks: RunHooks[Any], context_wrapper: RunContextWrapper[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> RunItem: """Run an apply_patch call and serialize the editor result for the model.""" apply_patch_tool: ApplyPatchTool = call.apply_patch_tool @@ -833,6 +888,11 @@ async def execute( context_wrapper=context_wrapper, ) call_id = extract_apply_patch_call_id(call.tool_call) + current_item = ToolApprovalItem( + agent=agent, + raw_item=call.tool_call, + tool_name=apply_patch_tool.name, + ) async def _run_call(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: @@ -847,7 +907,11 @@ async def _run_call(span: Any | None) -> RunItem: ] ) - approval_status = context_wrapper.get_approval_status(apply_patch_tool.name, call_id) + approval_status = context_wrapper.get_approval_status( + apply_patch_tool.name, + call_id, + current_invocation=current_item, + ) needs_approval_result = False if approval_status is None: for operation in operations: @@ -855,7 +919,9 @@ async def _run_call(span: Any | None) -> RunItem: apply_patch_tool.needs_approval, context_wrapper, operation, call_id ) approval_status = context_wrapper.get_approval_status( - apply_patch_tool.name, call_id + apply_patch_tool.name, + call_id, + current_invocation=current_item, ) if approval_status is not None or needs_approval_result: break @@ -877,6 +943,7 @@ async def _run_call(span: Any | None) -> RunItem: rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=config, + tool_call=call.tool_call, tool_type="apply_patch", tool_name=apply_patch_tool.name, call_id=call_id, @@ -889,6 +956,10 @@ async def _run_call(span: Any | None) -> RunItem: rejection_message=rejection_message, ) + context_wrapper._mark_tool_invocation_executed( + call.tool_call, + tool_name=apply_patch_tool.name, + ) await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, apply_patch_tool), ( @@ -954,6 +1025,14 @@ async def _run_call(span: Any | None) -> RunItem: if output_text: raw_item["output"] = output_text + output_item = ToolCallOutputItem( + agent=agent, + output=output_text, + raw_item=raw_item, + ) + if tool_output_committer is not None: + tool_output_committer(output_item) + custom_data = await maybe_extract_custom_data( apply_patch_tool.custom_data_extractor, ApplyPatchToolCustomDataContext( @@ -965,6 +1044,7 @@ async def _run_call(span: Any | None) -> RunItem: raw_item=copy.deepcopy(raw_item), ), ) + output_item.custom_data = custom_data await gather_with_cancel( hooks.on_tool_end(context_wrapper, agent, apply_patch_tool, output_text), @@ -978,12 +1058,7 @@ async def _run_call(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: span.span_data.output = output_text - return ToolCallOutputItem( - agent=agent, - output=output_text, - raw_item=raw_item, - custom_data=custom_data, - ) + return output_item return await with_tool_function_span( config=config, diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 08f6d9fb3a..3bf3d7a940 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -27,6 +27,7 @@ build_function_tool_lookup_map, get_function_tool_lookup_key, get_function_tool_lookup_key_for_call, + get_function_tool_lookup_key_for_tool, get_function_tool_trace_name, get_hosted_mcp_approval_request_identity, get_tool_approval_item_call_id, @@ -103,6 +104,7 @@ from .approvals import append_approval_error_output from .items import ( REJECTION_MESSAGE, + extract_mcp_request_id, extract_mcp_request_id_from_run, function_rejection_item, function_tool_error_output, @@ -128,6 +130,7 @@ "coerce_shell_call", "parse_apply_patch_custom_input", "parse_apply_patch_function_args", + "normalize_apply_patch_fallback_call", "extract_apply_patch_call_id", "coerce_apply_patch_operation", "coerce_apply_patch_operations", @@ -634,7 +637,7 @@ def extract_tool_call_id(raw: Any) -> str | None: def extract_shell_call_id(tool_call: Any) -> str: """Ensure shell calls include a call_id before executing them.""" - value = extract_tool_call_id(tool_call) + value = get_mapping_or_attr(tool_call, "call_id") if not value: raise ModelBehaviorError("Shell call is missing call_id.") return str(value) @@ -733,9 +736,37 @@ def parse_apply_patch_function_args(arguments: str) -> dict[str, Any]: return _parse_apply_patch_json(arguments, label="arguments") +def normalize_apply_patch_fallback_call(tool_call: Any) -> dict[str, Any] | None: + """Normalize supported custom/function apply_patch fallbacks into one pseudo-call.""" + call_type = get_mapping_or_attr(tool_call, "type") + call_id = get_mapping_or_attr(tool_call, "call_id") + if call_type == "custom_tool_call": + parsed_operation = parse_apply_patch_custom_input( + str(get_mapping_or_attr(tool_call, "input") or "") + ) + pseudo_call = { + "type": "apply_patch_call", + "call_id": call_id, + **parsed_operation, + } + elif call_type == "function_call": + parsed_operation = parse_apply_patch_function_args( + str(get_mapping_or_attr(tool_call, "arguments") or "") + ) + pseudo_call = { + "type": "apply_patch_call", + "call_id": call_id, + "operation": parsed_operation, + } + else: + return None + ItemHelpers.copy_tool_call_caller(tool_call, pseudo_call) + return pseudo_call + + def extract_apply_patch_call_id(tool_call: Any) -> str: """Ensure apply_patch calls include a call_id for approvals and tracing.""" - value = extract_tool_call_id(tool_call) + value = get_mapping_or_attr(tool_call, "call_id") if not value: raise ModelBehaviorError("Apply patch call is missing call_id.") return str(value) @@ -754,7 +785,7 @@ def coerce_apply_patch_operation( def coerce_apply_patch_operations( - tool_call: Any, + tool_call: Any | None = None, *, context_wrapper: RunContextWrapper[Any], ) -> list[ApplyPatchOperation]: @@ -1155,6 +1186,7 @@ async def resolve_approval_status( tool_namespace=tool_namespace, existing_pending=approval_item, tool_lookup_key=tool_lookup_key, + current_invocation=approval_item, ) if approval_status is None and on_approval: decision_result = on_approval(context_wrapper, approval_item) @@ -1176,6 +1208,7 @@ async def resolve_approval_status( tool_namespace=tool_namespace, existing_pending=approval_item, tool_lookup_key=tool_lookup_key, + current_invocation=approval_item, ) return approval_status, approval_item @@ -1201,6 +1234,7 @@ async def resolve_approval_rejection_message( tool_type: Literal["function", "computer", "shell", "apply_patch", "custom"], tool_name: str, call_id: str, + tool_call: Any | None = None, tool_namespace: str | None = None, tool_lookup_key: FunctionToolLookupKey | None = None, existing_pending: ToolApprovalItem | None = None, @@ -1220,6 +1254,12 @@ async def resolve_approval_rejection_message( if formatter is None: return REJECTION_MESSAGE + if tool_call is not None: + context_wrapper._mark_tool_invocation_executed( + tool_call, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + ) try: maybe_message = formatter( ToolErrorFormatterArgs( @@ -1320,6 +1360,49 @@ def _classify_hosted_mcp_pending_request( return "reuse_pending" +def process_hosted_mcp_approvals( + *, + original_pre_step_items: Sequence[RunItem], + mcp_approval_requests: Sequence[Any], + context_wrapper: RunContextWrapper[Any], + agent: Agent[Any], + append_item: Callable[[RunItem], None], +) -> tuple[list[ToolApprovalItem], set[str]]: + """Filter hosted MCP outputs and merge manual approvals so only coherent items remain.""" + hosted_mcp_approvals_by_id: dict[str, ToolApprovalItem] = {} + for item in original_pre_step_items: + if not isinstance(item, ToolApprovalItem): + continue + raw = item.raw_item + if get_hosted_mcp_approval_request_identity(item) is None: + continue + request_id = extract_mcp_request_id(raw) + if request_id: + hosted_mcp_approvals_by_id[request_id] = item + + resumed_requests = [ + request + for request in mcp_approval_requests + if extract_mcp_request_id_from_run(request) in hosted_mcp_approvals_by_id + ] + responses, pending = collect_manual_mcp_approvals( + agent=agent, + requests=resumed_requests, + context_wrapper=context_wrapper, + existing_pending_by_call_id=hosted_mcp_approvals_by_id, + ) + for item in responses: + append_item(item) + for item in pending: + append_item(item) + pending_ids = { + request_id + for item in pending + if (request_id := extract_mcp_request_id(item.raw_item)) is not None + } + return pending, pending_ids + + def collect_manual_mcp_approvals( *, agent: Agent[Any], @@ -1352,6 +1435,11 @@ def collect_manual_mcp_approvals( tool_name=tool_name, ) existing_pending = pending_lookup.get(request_id or "") + if existing_pending is not None: + context_wrapper._restore_pending_approval_binding(existing_pending) + binding_status = context_wrapper._approved_tool_invocation_status( + current_approval_item.raw_item + ) pending_resolution = ( _classify_hosted_mcp_pending_request(existing_pending, request_item) if existing_pending is not None @@ -1390,6 +1478,20 @@ def collect_manual_mcp_approvals( ) ) + if approval_status is not None and binding_status is None: + binding_status = context_wrapper._approved_tool_invocation_status( + current_approval_item.raw_item + ) + + if binding_status is None: + approval_item = current_approval_item + + if approval_status is not None and request_id: + if binding_status is None: + approval_status = None + elif binding_status[1]: + continue + if approval_status is not None and request_id: approval_response_raw: McpApprovalResponse = { "type": "mcp_approval_response", @@ -1422,6 +1524,23 @@ def index_approval_items_by_call_id(items: Sequence[RunItem]) -> dict[str, ToolA return approvals +def should_keep_hosted_mcp_item( + item: RunItem, + *, + pending_hosted_mcp_approvals: Sequence[ToolApprovalItem], + pending_hosted_mcp_approval_ids: set[str], +) -> bool: + """Keep only hosted MCP approvals that match pending requests from the provider.""" + if not isinstance(item, ToolApprovalItem): + return True + if get_hosted_mcp_approval_request_identity(item) is None: + return False + request_id = extract_mcp_request_id(item.raw_item) + return item in pending_hosted_mcp_approvals or ( + request_id is not None and request_id in pending_hosted_mcp_approval_ids + ) + + def _uses_programmatic_output_schema( function_tool: FunctionTool, tool_call: Any, @@ -1443,6 +1562,7 @@ def __init__( config: RunConfig, isolate_parallel_failures: bool | None, sibling_category_failure: asyncio.Event | None, + tool_output_committer: Callable[[RunItem], None] | None, ) -> None: self.execution_agent = bindings.execution_agent self.public_agent = bindings.public_agent @@ -1454,6 +1574,7 @@ def __init__( len(tool_runs) > 1 if isolate_parallel_failures is None else isolate_parallel_failures ) self.sibling_category_failure = sibling_category_failure + self.tool_output_committer = tool_output_committer self.tool_input_guardrail_results: list[ToolInputGuardrailResult] = [] self.tool_output_guardrail_results: list[ToolOutputGuardrailResult] = [] self.tool_state_scope_id = get_agent_tool_state_scope(context_wrapper) @@ -1462,6 +1583,7 @@ def __init__( self.results_by_tool_run: dict[int, Any] = {} self.schema_bypassed_tool_runs: set[int] = set() self.custom_data_by_tool_run: dict[int, dict[str, Any]] = {} + self.output_items_by_tool_run: dict[int, ToolCallOutputItem] = {} self.pending_tasks: set[asyncio.Task[Any]] = set() self.propagating_failure: BaseException | None = None self.available_function_tools: list[FunctionTool] = [] @@ -1745,11 +1867,24 @@ async def _maybe_execute_tool_approval( tool_lookup_key = get_function_tool_lookup_key_for_call(raw_tool_call) if is_deferred_top_level_function_tool(func_tool): tool_lookup_key = ("deferred_top_level", func_tool.name) + current_approval_item = ToolApprovalItem( + agent=self.public_agent, + raw_item=raw_tool_call, + tool_name=func_tool.name, + tool_namespace=tool_namespace, + tool_origin=get_function_tool_origin(func_tool), + tool_lookup_key=tool_lookup_key, + _allow_bare_name_alias=should_allow_bare_name_approval_alias( + func_tool, + self.available_function_tools, + ), + ) approval_status = self.context_wrapper.get_approval_status( func_tool.name, tool_call.call_id, tool_namespace=tool_namespace, tool_lookup_key=tool_lookup_key, + current_invocation=current_approval_item, ) if approval_status is None: needs_approval_result = await function_needs_approval( @@ -1762,6 +1897,7 @@ async def _maybe_execute_tool_approval( tool_call.call_id, tool_namespace=tool_namespace, tool_lookup_key=tool_lookup_key, + current_invocation=current_approval_item, ) if approval_status is None and not needs_approval_result: return None @@ -1790,6 +1926,7 @@ async def _maybe_execute_tool_approval( tool_call.call_id, tool_namespace=tool_namespace, tool_lookup_key=tool_lookup_key, + current_invocation=current_approval_item, ) if approval_status is None and rejected_message is not None: return FunctionToolResult( @@ -1806,19 +1943,11 @@ async def _maybe_execute_tool_approval( ) if approval_status is None: - approval_item = ToolApprovalItem( - agent=self.public_agent, - raw_item=raw_tool_call, - tool_name=func_tool.name, - tool_namespace=tool_namespace, - tool_origin=get_function_tool_origin(func_tool), - tool_lookup_key=tool_lookup_key, - _allow_bare_name_alias=should_allow_bare_name_approval_alias( - func_tool, - self.available_function_tools, - ), + return FunctionToolResult( + tool=func_tool, + output=None, + run_item=current_approval_item, ) - return FunctionToolResult(tool=func_tool, output=None, run_item=approval_item) if approval_status is not False: return None @@ -1826,6 +1955,7 @@ async def _maybe_execute_tool_approval( rejection_message = await resolve_approval_rejection_message( context_wrapper=self.context_wrapper, run_config=self.config, + tool_call=tool_call, tool_type="function", tool_name=tool_trace_name(func_tool.name, tool_namespace) or func_tool.name, call_id=tool_call.call_id, @@ -1867,24 +1997,34 @@ async def _execute_single_tool_body( tool_context: ToolContext[Any], agent_hooks: Any, ) -> Any: - rejected_message = await _execute_tool_input_guardrails( - func_tool=func_tool, - tool_context=tool_context, - agent=self.public_agent, - tool_input_guardrail_results=self.tool_input_guardrail_results, - ) - if rejected_message is not None: - self.schema_bypassed_tool_runs.add(id(task_state.tool_run)) - return rejected_message - - await gather_with_cancel( - self.hooks.on_tool_start(tool_context, self.public_agent, func_tool), - ( - agent_hooks.on_tool_start(tool_context, self.public_agent, func_tool) - if agent_hooks - else _coro.noop_coroutine() - ), + pending_nested_result = peek_agent_tool_run_result( + task_state.tool_run.tool_call, + scope_id=self.tool_state_scope_id, ) + is_nested_continuation = bool(self._get_nested_tool_interruptions(pending_nested_result)) + if not is_nested_continuation: + self.context_wrapper._mark_tool_invocation_executed( + tool_call, + tool_lookup_key=get_function_tool_lookup_key_for_tool(func_tool), + ) + rejected_message = await _execute_tool_input_guardrails( + func_tool=func_tool, + tool_context=tool_context, + agent=self.public_agent, + tool_input_guardrail_results=self.tool_input_guardrail_results, + ) + if rejected_message is not None: + self.schema_bypassed_tool_runs.add(id(task_state.tool_run)) + return rejected_message + + await gather_with_cancel( + self.hooks.on_tool_start(tool_context, self.public_agent, func_tool), + ( + agent_hooks.on_tool_start(tool_context, self.public_agent, func_tool) + if agent_hooks + else _coro.noop_coroutine() + ), + ) invoke_task = asyncio.create_task( self._invoke_tool_and_run_post_invoke( @@ -1952,8 +2092,15 @@ async def _invoke_tool_and_run_post_invoke( ) real_result = result - task_state.in_post_invoke_phase = True + nested_run_result = peek_agent_tool_run_result( + task_state.tool_run.tool_call, + scope_id=self.tool_state_scope_id, + ) + nested_interruptions = self._get_nested_tool_interruptions(nested_run_result) + if nested_interruptions: + return real_result + task_state.in_post_invoke_phase = True output_guardrail_result = await _execute_tool_output_guardrails( func_tool=func_tool, tool_context=tool_context, @@ -1980,6 +2127,17 @@ async def _invoke_tool_and_run_post_invoke( output_json_schema=None if bypass_output_schema else func_tool.output_json_schema, output_type_adapter=None if bypass_output_schema else func_tool._output_type_adapter, ) + output_item: ToolCallOutputItem | None = None + if not nested_interruptions: + output_item = ToolCallOutputItem( + output=final_result, + raw_item=raw_output_item, + agent=self.public_agent, + tool_origin=get_function_tool_origin(func_tool), + ) + self.output_items_by_tool_run[id(task_state.tool_run)] = output_item + if self.tool_output_committer is not None: + self.tool_output_committer(output_item) extracted_custom_data = await maybe_extract_custom_data( func_tool.custom_data_extractor, FunctionToolCustomDataContext( @@ -1992,6 +2150,8 @@ async def _invoke_tool_and_run_post_invoke( custom_data = merge_custom_data(tool_context._custom_data, extracted_custom_data) if custom_data: self.custom_data_by_tool_run[id(task_state.tool_run)] = custom_data + if output_item is not None: + output_item.custom_data = custom_data await gather_with_cancel( self.hooks.on_tool_end(tool_context, self.public_agent, func_tool, final_result), @@ -2095,35 +2255,37 @@ def _build_function_tool_results(self) -> list[FunctionToolResult]: run_item: RunItem | None if not nested_interruptions: - provider_result = ( - function_tool_error_output( - tool_run.tool_call, - result, - output_json_schema=tool_run.function_tool.output_json_schema, + run_item = self.output_items_by_tool_run.get(id(tool_run)) + if run_item is None: + provider_result = ( + function_tool_error_output( + tool_run.tool_call, + result, + output_json_schema=tool_run.function_tool.output_json_schema, + ) + if bypass_output_schema + else result ) - if bypass_output_schema - else result - ) - run_item = ToolCallOutputItem( - output=result, - raw_item=ItemHelpers.tool_call_output_item( - tool_run.tool_call, - provider_result, - output_json_schema=( - None - if bypass_output_schema - else tool_run.function_tool.output_json_schema - ), - output_type_adapter=( - None - if bypass_output_schema - else tool_run.function_tool._output_type_adapter + run_item = ToolCallOutputItem( + output=result, + raw_item=ItemHelpers.tool_call_output_item( + tool_run.tool_call, + provider_result, + output_json_schema=( + None + if bypass_output_schema + else tool_run.function_tool.output_json_schema + ), + output_type_adapter=( + None + if bypass_output_schema + else tool_run.function_tool._output_type_adapter + ), ), - ), - agent=self.public_agent, - tool_origin=get_function_tool_origin(tool_run.function_tool), - custom_data=self.custom_data_by_tool_run.get(id(tool_run)), - ) + agent=self.public_agent, + tool_origin=get_function_tool_origin(tool_run.function_tool), + custom_data=self.custom_data_by_tool_run.get(id(tool_run)), + ) else: # Skip tool output until nested interruptions are resolved. run_item = None @@ -2150,6 +2312,7 @@ async def execute_function_tool_calls( config: RunConfig, isolate_parallel_failures: bool | None = None, sibling_category_failure: asyncio.Event | None = None, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> tuple[ list[FunctionToolResult], list[ToolInputGuardrailResult], list[ToolOutputGuardrailResult] ]: @@ -2162,6 +2325,7 @@ async def execute_function_tool_calls( config=config, isolate_parallel_failures=isolate_parallel_failures, sibling_category_failure=sibling_category_failure, + tool_output_committer=tool_output_committer, ).execute() @@ -2172,6 +2336,7 @@ async def execute_custom_tool_calls( context_wrapper: RunContextWrapper[Any], hooks: RunHooks[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> list[RunItem]: """Run Responses custom tool calls serially and wrap outputs.""" from .tool_actions import CustomToolAction @@ -2185,6 +2350,7 @@ async def execute_custom_tool_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2197,6 +2363,7 @@ async def execute_local_shell_calls( context_wrapper: RunContextWrapper[Any], hooks: RunHooks[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> list[RunItem]: """Run local shell tool calls serially and wrap outputs.""" from .tool_actions import LocalShellAction @@ -2210,6 +2377,7 @@ async def execute_local_shell_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2222,6 +2390,7 @@ async def execute_shell_calls( context_wrapper: RunContextWrapper[Any], hooks: RunHooks[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> list[RunItem]: """Run shell tool calls serially and wrap outputs.""" from .tool_actions import ShellAction @@ -2235,6 +2404,7 @@ async def execute_shell_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2247,6 +2417,7 @@ async def execute_apply_patch_calls( context_wrapper: RunContextWrapper[Any], hooks: RunHooks[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> list[RunItem]: """Run apply_patch tool calls serially and normalize outputs.""" from .tool_actions import ApplyPatchAction @@ -2260,6 +2431,7 @@ async def execute_apply_patch_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2272,12 +2444,17 @@ async def execute_computer_actions( hooks: RunHooks[Any], context_wrapper: RunContextWrapper[Any], config: RunConfig, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> list[RunItem]: """Run computer actions serially and emit screenshot outputs.""" from .tool_actions import ComputerAction results: list[RunItem] = [] for action in actions: + context_wrapper._mark_tool_invocation_executed( + action.tool_call, + tool_name=action.computer_tool.name, + ) acknowledged: list[ComputerCallOutputAcknowledgedSafetyCheck] | None = None if action.tool_call.pending_safety_checks and action.computer_tool.on_safety_check: acknowledged = [] @@ -2309,6 +2486,7 @@ async def execute_computer_actions( context_wrapper=context_wrapper, config=config, acknowledged_safety_checks=acknowledged, + tool_output_committer=tool_output_committer, ) ) @@ -2416,6 +2594,7 @@ async def _resolve_tool_run( message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=run_config, + tool_call=tool_call, tool_type="function", tool_name=display_tool_name, call_id=call_id, diff --git a/src/agents/run_internal/tool_planning.py b/src/agents/run_internal/tool_planning.py index 8647859d67..89463b9b53 100644 --- a/src/agents/run_internal/tool_planning.py +++ b/src/agents/run_internal/tool_planning.py @@ -10,12 +10,26 @@ from openai.types.responses import ResponseFunctionToolCall from openai.types.responses.response_input_param import McpApprovalResponse -from .._tool_identity import get_function_tool_lookup_key_for_call, get_tool_call_namespace +from .._tool_identity import ( + FunctionToolLookupKey, + get_function_tool_lookup_key_for_call, + get_function_tool_lookup_key_for_tool, + get_tool_call_namespace, +) +from .._tool_invocation import ( + tool_invocation_call_id, + tool_invocation_identity, + tool_output_identity, +) from ..agent import Agent -from ..exceptions import UserError +from ..exceptions import ModelBehaviorError, UserError from ..items import ( + HandoffCallItem, + HandoffOutputItem, ItemHelpers, + MCPApprovalRequestItem, MCPApprovalResponseItem, + ReasoningItem, RunItem, RunItemBase, ToolApprovalItem, @@ -28,6 +42,7 @@ from ..util._asyncio_tasks import gather_with_cancel from .agent_bindings import AgentBindings from .run_steps import ( + ProcessedResponse, ToolRunApplyPatchCall, ToolRunComputerAction, ToolRunCustom, @@ -53,6 +68,9 @@ "execute_mcp_approval_requests", "_build_tool_output_index", "_dedupe_tool_call_items", + "_dedupe_processed_response_invocations", + "_register_tool_call_items", + "_validate_unresolved_function_calls", "ToolExecutionPlan", "_build_plan_for_fresh_turn", "_build_plan_for_resume_turn", @@ -106,29 +124,67 @@ async def execute_mcp_approval_requests( ) -> list[RunItem]: """Run hosted MCP approval callbacks and return approval response items.""" + approval_requests, _ = _preflight_mcp_approval_requests(approval_requests) + async def run_single_approval(approval_request: ToolRunMCPApprovalRequest) -> RunItem: - callback = approval_request.mcp_tool.on_approval_request - assert callback is not None, "Callback is required for MCP approval requests" - maybe_awaitable_result = callback( - MCPToolApprovalRequest(context_wrapper, approval_request.request_item) - ) - if inspect.isawaitable(maybe_awaitable_result): - result = await maybe_awaitable_result - else: - result = maybe_awaitable_result - reason = result.get("reason", None) request_item = approval_request.request_item request_id = ( request_item.id if hasattr(request_item, "id") else cast(dict[str, Any], request_item).get("id", "") ) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=request_item, + tool_name=get_mapping_or_attr(request_item, "name"), + ) + approval_status = context_wrapper.get_approval_status( + approval_item.tool_name or "", + request_id, + existing_pending=approval_item, + current_invocation=approval_item, + ) + reason = context_wrapper.get_rejection_message( + approval_item.tool_name or "", + request_id, + existing_pending=approval_item, + ) + if approval_status is None: + invocation_status = context_wrapper._tool_invocation_status(request_item) + if invocation_status is None: + raise ModelBehaviorError( + "Hosted MCP approval requests require a canonical invocation identity." + ) + if invocation_status[2]: + raise ModelBehaviorError( + "A Hosted MCP approval callback already ran, but its response was not " + "committed. Start a new request instead of retrying the invocation." + ) + context_wrapper._mark_tool_invocation_executed(request_item) + callback = approval_request.mcp_tool.on_approval_request + assert callback is not None, "Callback is required for MCP approval requests" + maybe_awaitable_result = callback( + MCPToolApprovalRequest(context_wrapper, approval_request.request_item) + ) + if inspect.isawaitable(maybe_awaitable_result): + result = await maybe_awaitable_result + else: + result = maybe_awaitable_result + approval_status = result["approve"] + reason = result.get("reason", None) + if approval_status: + context_wrapper.approve_tool(approval_item) + else: + context_wrapper.reject_tool( + approval_item, + rejection_message=reason if isinstance(reason, str) else None, + ) raw_item: McpApprovalResponse = { "approval_request_id": request_id, - "approve": result["approve"], + "approve": approval_status, "type": "mcp_approval_response", } - if not result["approve"] and reason: + if not approval_status and reason: raw_item["reason"] = reason ItemHelpers.copy_tool_call_caller(request_item, raw_item) return MCPApprovalResponseItem( @@ -140,6 +196,34 @@ async def run_single_approval(approval_request: ToolRunMCPApprovalRequest) -> Ru return list(await gather_with_cancel(*tasks)) +def _preflight_mcp_approval_requests( + approval_requests: Sequence[ToolRunMCPApprovalRequest], +) -> tuple[list[ToolRunMCPApprovalRequest], set[int]]: + """Reject changed same-ID MCP siblings and coalesce exact duplicates.""" + seen_by_call: dict[tuple[str, str], tuple[str, str, str]] = {} + deduped: list[ToolRunMCPApprovalRequest] = [] + skipped_raw_item_ids: set[int] = set() + for approval_request in approval_requests: + raw_item = approval_request.request_item + identity = tool_invocation_identity(raw_item) + if identity is None: + deduped.append(approval_request) + continue + call_key = identity[:2] + existing_identity = seen_by_call.get(call_key) + if existing_identity is None: + seen_by_call[call_key] = identity + deduped.append(approval_request) + continue + if existing_identity != identity: + raise ModelBehaviorError( + "Model reused an approval-gated tool call ID for a different invocation. " + "Use a unique call ID for each approval-gated invocation." + ) + skipped_raw_item_ids.add(id(raw_item)) + return deduped, skipped_raw_item_ids + + def _build_tool_output_index(items: Sequence[RunItem]) -> set[tuple[str, str]]: """Index tool call output items by (type, call_id) for fast lookups.""" index: set[tuple[str, str]] = set() @@ -159,7 +243,10 @@ def _build_tool_output_index(items: Sequence[RunItem]) -> set[tuple[str, str]]: def _dedupe_tool_call_items( - *, existing_items: Sequence[RunItem], new_items: Sequence[RunItem] + *, + existing_items: Sequence[RunItem], + new_items: Sequence[RunItem], + skipped_raw_item_ids: set[int], ) -> list[RunItem]: """Return new items while skipping tool call duplicates already seen by identity.""" existing_call_keys: set[tuple[str | None, str | None, Hashable | None]] = set() @@ -168,7 +255,9 @@ def _dedupe_tool_call_items( existing_call_keys.add(_tool_call_identity(item.raw_item)) deduped: list[RunItem] = [] for item in new_items: - if isinstance(item, ToolCallItem): + if isinstance(item, ToolCallItem | HandoffCallItem | MCPApprovalRequestItem): + if id(item.raw_item) in skipped_raw_item_ids: + continue identity = _tool_call_identity(item.raw_item) if identity in existing_call_keys: continue @@ -177,6 +266,294 @@ def _dedupe_tool_call_items( return deduped +def _register_tool_call_items( + context_wrapper: RunContextWrapper[Any], + items: Sequence[RunItem], + *, + validate_invocations: bool = True, +) -> None: + """Validate approval-bound calls and record their committed outputs.""" + call_item_types = (ToolCallItem, HandoffCallItem, MCPApprovalRequestItem, ToolApprovalItem) + for item in items: + if isinstance(item, ToolApprovalItem): + context_wrapper._restore_pending_approval_binding(item) + for item in items: + if not isinstance(item, call_item_types): + continue + if isinstance(item, ToolApprovalItem): + continue + if not validate_invocations and isinstance(item, ToolCallItem | MCPApprovalRequestItem): + raw_type = get_mapping_or_attr(item.raw_item, "type") + tool_name = get_mapping_or_attr(item.raw_item, "name") + if not isinstance(tool_name, str): + tool_name = { + "apply_patch_call": "apply_patch", + "computer_call": "computer", + "local_shell_call": "local_shell", + "shell_call": "shell", + }.get(raw_type) + if isinstance(tool_name, str): + context_wrapper._restore_pending_approval_binding( + ToolApprovalItem( + agent=item.agent, + raw_item=cast(Any, item.raw_item), + tool_name=tool_name, + tool_namespace=get_tool_call_namespace(item.raw_item), + tool_lookup_key=( + get_function_tool_lookup_key_for_call(item.raw_item) + if raw_type == "function_call" + else None + ), + ) + ) + if not validate_invocations: + continue + if ( + isinstance(item, ToolCallItem) + and get_mapping_or_attr(item.raw_item, "type") == "function_call" + ): + # Resolved function calls are validated from the plan, where canonical routing identity + # is available. Raw calls can omit deferred-loading routing metadata. + continue + context_wrapper._tool_invocation_status( + item.raw_item, + tool_name=(item.tool_name if isinstance(item, ToolCallItem) else None), + invocation_role=("handoff" if isinstance(item, HandoffCallItem) else None), + ) + for item in items: + if isinstance(item, call_item_types): + continue + if isinstance(item, ToolCallOutputItem | HandoffOutputItem | MCPApprovalResponseItem): + context_wrapper._mark_tool_call_completed(item.raw_item) + + +def _validate_unresolved_function_calls( + context_wrapper: RunContextWrapper[Any], + runs: Sequence[Any], +) -> None: + """Validate unresolved function calls before any sibling tool starts.""" + for run in runs: + context_wrapper._tool_invocation_status(get_mapping_or_attr(run, "tool_call")) + + +def _dedupe_processed_response_invocations( + processed_response: ProcessedResponse, + *, + context_wrapper: RunContextWrapper[Any], + existing_items: Sequence[RunItem], + deferred_binding_validation_raw_item_ids: set[int] | None = None, + filter_completed: bool = True, +) -> set[int]: + """Validate and coalesce one response's tool invocations before user callbacks run.""" + deferred_binding_validation_raw_item_ids = deferred_binding_validation_raw_item_ids or set() + completed_output_keys = { + output_identity + for item in existing_items + if (output_identity := tool_output_identity(getattr(item, "raw_item", None))) is not None + } + completed_historical_invocations: dict[str, tuple[str, str, str]] = {} + for item in existing_items: + raw_item = getattr(item, "raw_item", None) + identity = tool_invocation_identity( + raw_item, + tool_lookup_key=(item.tool_lookup_key if isinstance(item, ToolApprovalItem) else None), + tool_name=( + item.tool_name if isinstance(item, ToolCallItem | ToolApprovalItem) else None + ), + invocation_role=("handoff" if isinstance(item, HandoffCallItem) else None), + ) + if ( + context_wrapper._allow_legacy_approval_binding_reconstruction + and isinstance(item, ToolCallItem) + and item.tool_name is None + ): + call_identity = tool_invocation_call_id(raw_item) + if call_identity is not None and call_identity[1] is not None: + legacy_record = context_wrapper._tool_invocations.get(call_identity[1]) + if ( + legacy_record is not None + and legacy_record.completed + and legacy_record.invocation_type == call_identity[0] + ): + identity = ( + legacy_record.invocation_type, + call_identity[1], + legacy_record.fingerprint, + ) + if identity is None or identity[:2] not in completed_output_keys: + continue + previous_identity = completed_historical_invocations.get(identity[1]) + if previous_identity is not None and previous_identity != identity: + raise ModelBehaviorError( + "Run history reused a tool call ID for different completed invocations. " + "Use a unique call ID for each tool invocation." + ) + completed_historical_invocations[identity[1]] = identity + current_response_invocations: dict[str, tuple[str, str, str]] = {} + ( + processed_response.mcp_approval_requests, + skipped_raw_item_ids, + ) = _preflight_mcp_approval_requests(processed_response.mcp_approval_requests) + uncanonical_response_call_ids: set[str] = set() + + def should_keep( + raw_item: Any, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, + ) -> bool: + call_identity = tool_invocation_call_id(raw_item) + if call_identity is not None and call_identity[1] is None: + raise ModelBehaviorError( + "Tool invocations require a non-empty string call ID before execution." + ) + identity = tool_invocation_identity( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if identity is None: + context_wrapper._tool_invocation_status( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if call_identity is not None and call_identity[1] is not None: + call_id = call_identity[1] + if ( + call_id in current_response_invocations + or call_id in uncanonical_response_call_ids + ): + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation in one response. " + "Use a unique call ID for each tool invocation." + ) + uncanonical_response_call_ids.add(call_id) + return True + + if identity[1] in uncanonical_response_call_ids: + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation in one response. " + "Use a unique call ID for each tool invocation." + ) + + historical_identity = completed_historical_invocations.get(identity[1]) + if historical_identity is not None: + if historical_identity != identity: + raise ModelBehaviorError( + "Model reused a completed tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + if filter_completed: + skipped_raw_item_ids.add(id(raw_item)) + return False + previous_identity = current_response_invocations.get(identity[1]) + if previous_identity is not None: + if previous_identity != identity: + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation in one response. " + "Use a unique call ID for each tool invocation." + ) + skipped_raw_item_ids.add(id(raw_item)) + return False + current_response_invocations[identity[1]] = identity + + if id(raw_item) not in deferred_binding_validation_raw_item_ids: + try: + binding_status = context_wrapper._tool_invocation_status( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + except ModelBehaviorError: + # A completed exact sibling with the same provider ID can predate approval + # binding, so preserve that released cross-kind resume behavior. Changed content + # has no exact historical identity and still fails closed. + if historical_identity == identity: + return True + raise + if filter_completed and binding_status is not None and binding_status[1]: + skipped_raw_item_ids.add(id(raw_item)) + return False + if binding_status is not None and binding_status[2] and not binding_status[1]: + raise ModelBehaviorError( + "A tool call already executed, but its output was not committed. " + "Start a new run instead of retrying the invocation." + ) + return True + + processed_response.functions = [ + run + for run in processed_response.functions + if should_keep( + run.tool_call, + get_function_tool_lookup_key_for_tool(run.function_tool), + ) + ] + processed_response.handoffs = [ + run + for run in processed_response.handoffs + if should_keep(run.tool_call, invocation_role="handoff") + ] + processed_response.function_tools_not_found = [ + run for run in processed_response.function_tools_not_found if should_keep(run.tool_call) + ] + processed_response.computer_actions = [ + run + for run in processed_response.computer_actions + if should_keep(run.tool_call, tool_name=run.computer_tool.name) + ] + processed_response.custom_tool_calls = [ + run + for run in processed_response.custom_tool_calls + if should_keep(run.tool_call, tool_name=run.custom_tool.name) + ] + processed_response.local_shell_calls = [ + run + for run in processed_response.local_shell_calls + if should_keep(run.tool_call, tool_name=run.local_shell_tool.name) + ] + processed_response.shell_calls = [ + run + for run in processed_response.shell_calls + if should_keep(run.tool_call, tool_name=run.shell_tool.name) + ] + processed_response.apply_patch_calls = [ + run + for run in processed_response.apply_patch_calls + if should_keep(run.tool_call, tool_name=run.apply_patch_tool.name) + ] + processed_response.mcp_approval_requests = [ + run for run in processed_response.mcp_approval_requests if should_keep(run.request_item) + ] + dropped_item_indexes = { + index + for index, item in enumerate(processed_response.new_items) + if isinstance(item, ToolCallItem | HandoffCallItem | MCPApprovalRequestItem) + and id(item.raw_item) in skipped_raw_item_ids + } + dropped_reasoning_indexes: set[int] = set() + for index in range(len(processed_response.new_items) - 1, -1, -1): + if not isinstance(processed_response.new_items[index], ReasoningItem): + continue + for next_index in range(index + 1, len(processed_response.new_items)): + if isinstance(processed_response.new_items[next_index], ReasoningItem): + continue + if next_index in dropped_item_indexes: + dropped_reasoning_indexes.add(index) + break + excluded_item_indexes = dropped_item_indexes | dropped_reasoning_indexes + processed_response.new_items = [ + item + for index, item in enumerate(processed_response.new_items) + if index not in excluded_item_indexes + ] + return skipped_raw_item_ids + + @_dc.dataclass class ToolExecutionPlan: """Represents tool execution work to perform in a single turn.""" @@ -203,7 +580,10 @@ def _partition_mcp_approval_requests( with_callback: list[ToolRunMCPApprovalRequest] = [] manual: list[ToolRunMCPApprovalRequest] = [] for request in requests: - if request.mcp_tool.on_approval_request: + if ( + request.mcp_tool.on_approval_request + and tool_invocation_identity(request.request_item) is not None + ): with_callback.append(request) else: manual.append(request) @@ -394,17 +774,34 @@ async def _collect_runs_by_approval( rejection_items: list[RunItem] = [] for run in runs: call_id = call_id_extractor(run) + if output_exists_checker and output_exists_checker(call_id): + continue tool_name = tool_name_resolver(run) existing_pending = approval_items_by_call_id.get(call_id) + function_tool = get_mapping_or_attr(run, "function_tool") + current_item = ToolApprovalItem( + agent=agent, + raw_item=get_mapping_or_attr(run, "tool_call"), + tool_name=tool_name, + tool_namespace=get_tool_call_namespace(get_mapping_or_attr(run, "tool_call")), + tool_origin=( + get_function_tool_origin(function_tool) + if isinstance(function_tool, FunctionTool) + else None + ), + tool_lookup_key=( + get_function_tool_lookup_key_for_tool(function_tool) + if isinstance(function_tool, FunctionTool) + else None + ), + ) approval_status = context_wrapper.get_approval_status( tool_name, call_id, existing_pending=existing_pending, + current_invocation=current_item, ) - if output_exists_checker and output_exists_checker(call_id): - continue - needs_approval = True if approval_status is None and needs_approval_checker: try: @@ -417,6 +814,7 @@ async def _collect_runs_by_approval( tool_name, call_id, existing_pending=existing_pending, + current_invocation=current_item, ) if approval_status is False: @@ -436,21 +834,7 @@ async def _collect_runs_by_approval( approved_runs.append(run) continue - function_tool = get_mapping_or_attr(run, "function_tool") - pending_item = existing_pending or ToolApprovalItem( - agent=agent, - raw_item=get_mapping_or_attr(run, "tool_call"), - tool_name=tool_name, - tool_namespace=get_tool_call_namespace(get_mapping_or_attr(run, "tool_call")), - tool_origin=( - get_function_tool_origin(function_tool) - if isinstance(function_tool, FunctionTool) - else None - ), - tool_lookup_key=get_function_tool_lookup_key_for_call( - get_mapping_or_attr(run, "tool_call") - ), - ) + pending_item = existing_pending or current_item pending_interruption_adder(pending_item) return approved_runs, rejection_items @@ -516,11 +900,14 @@ async def _select_function_tool_runs_for_resume( if output_exists_checker(run): continue + current_item = pending_item_builder(run) approval_status = context_wrapper.get_approval_status( run.function_tool.name, call_id, tool_namespace=get_tool_call_namespace(run.tool_call), existing_pending=approval_items_by_call_id.get(call_id), + tool_lookup_key=current_item.tool_lookup_key, + current_invocation=current_item, ) requires_approval = True @@ -531,6 +918,8 @@ async def _select_function_tool_runs_for_resume( call_id, tool_namespace=get_tool_call_namespace(run.tool_call), existing_pending=approval_items_by_call_id.get(call_id), + tool_lookup_key=current_item.tool_lookup_key, + current_invocation=current_item, ) if approval_status is False: @@ -546,7 +935,7 @@ async def _select_function_tool_runs_for_resume( continue pending_interruption_adder( - approval_items_by_call_id.get(run.tool_call.call_id) or pending_item_builder(run) + approval_items_by_call_id.get(run.tool_call.call_id) or current_item ) return selected @@ -560,6 +949,7 @@ async def _execute_tool_plan( context_wrapper: RunContextWrapper[Any], run_config, parallel: bool = True, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> tuple[ list[Any], list[ToolInputGuardrailResult], @@ -600,6 +990,7 @@ async def _execute_tool_plan( config=run_config, isolate_parallel_failures=isolate_function_tool_failures, sibling_category_failure=sibling_category_failure, + tool_output_committer=tool_output_committer, ), execute_computer_actions( public_agent=public_agent, @@ -607,6 +998,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ), execute_custom_tool_calls( public_agent=public_agent, @@ -614,6 +1006,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ), execute_shell_calls( public_agent=public_agent, @@ -621,6 +1014,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ), execute_apply_patch_calls( public_agent=public_agent, @@ -628,6 +1022,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ), execute_local_shell_calls( public_agent=public_agent, @@ -635,6 +1030,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ), on_child_failure=sibling_category_failure.set, ) @@ -650,6 +1046,7 @@ async def _execute_tool_plan( context_wrapper=context_wrapper, config=run_config, isolate_parallel_failures=isolate_function_tool_failures, + tool_output_committer=tool_output_committer, ) computer_results = await execute_computer_actions( public_agent=public_agent, @@ -657,6 +1054,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ) custom_tool_results = await execute_custom_tool_calls( public_agent=public_agent, @@ -664,6 +1062,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ) shell_results = await execute_shell_calls( public_agent=public_agent, @@ -671,6 +1070,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ) apply_patch_results = await execute_apply_patch_calls( public_agent=public_agent, @@ -678,6 +1078,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ) local_shell_results = await execute_local_shell_calls( public_agent=public_agent, @@ -685,6 +1086,7 @@ async def _execute_tool_plan( hooks=hooks, context_wrapper=context_wrapper, config=run_config, + tool_output_committer=tool_output_committer, ) return ( diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index a30372f74b..58a184f583 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import inspect from collections.abc import Awaitable, Callable, Container, Mapping, Sequence from copy import deepcopy @@ -47,10 +46,13 @@ restore_tool_call_routing_identity, should_allow_bare_name_approval_alias, ) +from .._tool_invocation import tool_invocation_call_id, tool_invocation_identity_and_scope from ..agent import Agent, ToolsToFinalOutputResult from ..agent_output import AgentOutputSchemaBase from ..agent_tool_state import ( + agent_tool_resume_checkpoint_owns_approval, drop_agent_tool_run_result, + get_agent_tool_resume_state, get_agent_tool_state_scope, peek_agent_tool_run_result, record_agent_tool_run_result, @@ -95,7 +97,6 @@ from ..run_context import AgentHookContext, RunContextWrapper, TContext from ..run_error_handlers import RunErrorHandlers from ..run_state import RunState -from ..stream_events import StreamEvent from ..tool import ( ApplyPatchTool, CodeInterpreterTool, @@ -140,7 +141,6 @@ NextStepInterruption, NextStepRunAgain, ProcessedResponse, - QueueCompleteSentinel, SingleStepResult, ToolRunApplyPatchCall, ToolRunComputerAction, @@ -152,7 +152,6 @@ ToolRunMCPApprovalRequest, ToolRunShellCall, ) -from .streaming import stream_step_items_to_queue from .tool_caller import ensure_programmatic_tool_call_parent, ensure_tool_caller_allowed from .tool_execution import ( build_litellm_json_tool_call, @@ -165,9 +164,10 @@ get_mapping_or_attr, index_approval_items_by_call_id, is_apply_patch_name, - parse_apply_patch_custom_input, - parse_apply_patch_function_args, + normalize_apply_patch_fallback_call, + process_hosted_mcp_approvals, resolve_approval_rejection_message, + should_keep_hosted_mcp_item, ) from .tool_planning import ( _append_mcp_callback_results, @@ -177,10 +177,13 @@ _build_tool_result_items, _collect_runs_by_approval, _collect_tool_interruptions, + _dedupe_processed_response_invocations, _dedupe_tool_call_items, _execute_tool_plan, _make_unique_item_appender, + _register_tool_call_items, _select_function_tool_runs_for_resume, + _validate_unresolved_function_calls, ) from .turn_preparation import get_handoffs, get_output_schema @@ -260,6 +263,7 @@ async def _resolve_tool_not_found_message( *, context_wrapper: RunContextWrapper[Any], run_config: RunConfig, + tool_call: ResponseFunctionToolCall, tool_name: str, call_id: str, ) -> str: @@ -268,6 +272,7 @@ async def _resolve_tool_not_found_message( if formatter is None: return default_message + context_wrapper._mark_tool_invocation_executed(tool_call) try: maybe_message = formatter( ToolErrorFormatterArgs( @@ -313,6 +318,7 @@ async def _build_tool_not_found_output_items( message = await _resolve_tool_not_found_message( context_wrapper=context_wrapper, run_config=run_config, + tool_call=call.tool_call, tool_name=call.tool_name, call_id=call.tool_call.call_id, ) @@ -335,9 +341,9 @@ async def run_final_output_hooks( agent_hook_context = AgentHookContext( context=context_wrapper.context, usage=context_wrapper.usage, - _approvals=context_wrapper._approvals, turn_input=context_wrapper.turn_input, ) + context_wrapper._share_tool_state_with(agent_hook_context) await gather_with_cancel( hooks.on_agent_end(agent_hook_context, agent, final_output), @@ -529,6 +535,7 @@ async def execute_handoffs( nest_handoff_history_fn: Callable[..., HandoffInputData] | None = None, tool_input_guardrail_results: list[ToolInputGuardrailResult] | None = None, tool_output_guardrail_results: list[ToolOutputGuardrailResult] | None = None, + handoff_output_committer: Callable[[HandoffOutputItem, Agent[Any]], None] | None = None, ) -> SingleStepResult: """Execute a handoff and prepare the next turn for the new agent.""" @@ -566,6 +573,10 @@ def nest_history( actual_handoff = run_handoffs[0] with handoff_span(from_agent=public_agent.name) as span_handoff: handoff = actual_handoff.handoff + context_wrapper._mark_tool_invocation_executed( + actual_handoff.tool_call, + invocation_role="handoff", + ) new_agent: Agent[Any] = await handoff.on_invoke_handoff( context_wrapper, actual_handoff.tool_call.arguments ) @@ -581,17 +592,19 @@ def nest_history( ) ) - new_step_items.append( - HandoffOutputItem( - agent=public_agent, - raw_item=ItemHelpers.tool_call_output_item( - actual_handoff.tool_call, - handoff.get_transfer_message(new_agent), - ), - source_agent=public_agent, - target_agent=new_agent, - ) + handoff_output = HandoffOutputItem( + agent=public_agent, + raw_item=ItemHelpers.tool_call_output_item( + actual_handoff.tool_call, + handoff.get_transfer_message(new_agent), + ), + source_agent=public_agent, + target_agent=new_agent, ) + new_step_items.append(handoff_output) + if handoff_output_committer is not None: + _register_tool_call_items(context_wrapper, [handoff_output]) + handoff_output_committer(handoff_output, new_agent) await gather_with_cancel( hooks.on_handoff( @@ -714,6 +727,12 @@ def nest_history( # No filtering or nesting - session_step_items not needed. session_step_items = None + if handoff_output_committer is None and ( + handoff_output in new_step_items + or (session_step_items is not None and handoff_output in session_step_items) + ): + _register_tool_call_items(context_wrapper, [handoff_output]) + return SingleStepResult( original_input=original_input, model_response=new_response, @@ -771,6 +790,7 @@ async def execute_tools_and_side_effects( run_config: RunConfig, error_handlers: RunErrorHandlers[TContext] | None = None, server_manages_conversation: bool = False, + precomputed_skipped_raw_item_ids: set[int] | None = None, ) -> SingleStepResult: """Run one turn of the loop, coordinating tools, approvals, guardrails, and handoffs.""" public_agent = bindings.public_agent @@ -779,6 +799,25 @@ async def execute_tools_and_side_effects( execute_handoffs_call = execute_handoffs pre_step_items = list(pre_step_items) + _register_tool_call_items( + context_wrapper, + pre_step_items, + validate_invocations=False, + ) + skipped_raw_item_ids = ( + precomputed_skipped_raw_item_ids + if precomputed_skipped_raw_item_ids is not None + else _dedupe_processed_response_invocations( + processed_response, + context_wrapper=context_wrapper, + existing_items=pre_step_items, + ) + ) + _register_tool_call_items(context_wrapper, processed_response.new_items) + _validate_unresolved_function_calls( + context_wrapper, + processed_response.function_tools_not_found, + ) approval_items_by_call_id = index_approval_items_by_call_id(pre_step_items) plan = _build_plan_for_fresh_turn( @@ -787,10 +826,10 @@ async def execute_tools_and_side_effects( context_wrapper=context_wrapper, approval_items_by_call_id=approval_items_by_call_id, ) - new_step_items = _dedupe_tool_call_items( existing_items=pre_step_items, new_items=processed_response.new_items, + skipped_raw_item_ids=skipped_raw_item_ids, ) ( @@ -840,6 +879,8 @@ async def execute_tools_and_side_effects( interruptions.extend(plan.pending_interruptions) new_step_items.extend(plan.pending_interruptions) + _register_tool_call_items(context_wrapper, new_step_items) + processed_response.interruptions = interruptions if interruptions: @@ -860,6 +901,7 @@ async def execute_tools_and_side_effects( context_wrapper=context_wrapper, append_item=new_step_items.append, ) + _register_tool_call_items(context_wrapper, new_step_items) if run_handoffs := processed_response.handoffs: return await execute_handoffs_call( @@ -900,7 +942,7 @@ async def execute_tools_and_side_effects( if not processed_response.has_tools_or_approvals_to_run(): has_tool_activity_without_message = not message_items and bool( - processed_response.tools_used + processed_response.tools_used or skipped_raw_item_ids ) if not has_tool_activity_without_message: if refusal: @@ -1094,6 +1136,12 @@ async def resolve_interrupted_turn( execute_handoffs_call = execute_handoffs + _register_tool_call_items( + context_wrapper, + original_pre_step_items, + validate_invocations=False, + ) + def _pending_approvals_from_state() -> list[ToolApprovalItem]: if ( run_state is not None @@ -1120,6 +1168,7 @@ async def _record_function_rejection( rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=run_config, + tool_call=tool_call, tool_type="function", tool_name=get_tool_call_trace_name(tool_call) or function_tool.name, call_id=call_id, @@ -1165,7 +1214,34 @@ async def _function_requires_approval(run: ToolRunFunction) -> bool: context_wrapper.turn_input = [] pending_approval_items = _pending_approvals_from_state() - approval_items_by_call_id = index_approval_items_by_call_id(pending_approval_items) + + def _allow_legacy_name_agent_match() -> bool: + schema_version = getattr(run_state, "_schema_version", None) + if not isinstance(schema_version, str): + return False + try: + version_parts = tuple(int(part) for part in schema_version.split(".")) + except ValueError: + return False + # Schema 1.6 and earlier only serialized approval owners by agent name. With duplicate-name + # agents, deserialization can legitimately resolve the approval to a sibling instance, so + # resume must accept a same-name match for those legacy snapshots. Schema 1.7+ persists + # duplicate-name identities, so newer snapshots should continue requiring object identity. + return version_parts < (1, 7) + + allow_legacy_name_agent_match = _allow_legacy_name_agent_match() + + def _approval_matches_agent(approval: ToolApprovalItem) -> bool: + approval_agent = approval.agent + if approval_agent is None: + return False + if approval_agent is public_agent: + return True + return allow_legacy_name_agent_match and approval_agent.name == public_agent.name + + approval_items_by_call_id = index_approval_items_by_call_id( + [item for item in pending_approval_items if _approval_matches_agent(item)] + ) tool_state_scope_id = get_agent_tool_state_scope(context_wrapper) rejected_function_outputs: list[RunItem] = [] @@ -1211,6 +1287,7 @@ async def _build_shell_rejection(run: ToolRunShellCall, call_id: str) -> RunItem rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=run_config, + tool_call=run.tool_call, tool_type="shell", tool_name=run.shell_tool.name, call_id=call_id, @@ -1229,6 +1306,7 @@ async def _build_apply_patch_rejection(run: ToolRunApplyPatchCall, call_id: str) rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=run_config, + tool_call=run.tool_call, tool_type="apply_patch", tool_name=run.apply_patch_tool.name, call_id=call_id, @@ -1248,6 +1326,7 @@ async def _build_custom_rejection(run: ToolRunCustom, call_id: str) -> RunItem: rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=run_config, + tool_call=run.tool_call, tool_type="custom", tool_name=run.custom_tool.name, call_id=call_id, @@ -1317,20 +1396,34 @@ def _computer_output_exists(call_id: str) -> bool: return _has_output_item(call_id, "computer_call_output") def _nested_interruptions_status( - interruptions: Sequence[ToolApprovalItem], + nested_run_result: Any, ) -> Literal["approved", "pending", "rejected"]: + interruptions = cast(Sequence[ToolApprovalItem], nested_run_result.interruptions) + nested_state = nested_run_result.to_state() + nested_decision_context = getattr(nested_state, "_context", None) has_pending = False for interruption in interruptions: call_id = get_tool_approval_item_call_id(interruption) if not call_id: has_pending = True continue - status = context_wrapper.get_approval_status( - interruption.tool_name or "", - call_id, - tool_namespace=interruption.tool_namespace, - existing_pending=interruption, + status = ( + nested_decision_context.get_approval_status( + interruption.tool_name or "", + call_id, + tool_namespace=interruption.tool_namespace, + existing_pending=interruption, + ) + if isinstance(nested_decision_context, RunContextWrapper) + else None ) + if status is None and context_wrapper._allow_legacy_approval_binding_reconstruction: + status = context_wrapper.get_approval_status( + interruption.tool_name or "", + call_id, + tool_namespace=interruption.tool_namespace, + existing_pending=interruption, + ) if status is False: return "rejected" if status is None: @@ -1342,18 +1435,30 @@ def _function_output_exists(run: ToolRunFunction) -> bool: if not call_id: return False + if call_id not in approval_items_by_call_id and _has_output_item( + call_id, "function_call_output" + ): + return True + pending_run_result = peek_agent_tool_run_result( run.tool_call, scope_id=tool_state_scope_id, ) if pending_run_result and getattr(pending_run_result, "interruptions", None): - status = _nested_interruptions_status(pending_run_result.interruptions) + status = _nested_interruptions_status(pending_run_result) if status in ("approved", "rejected"): rerun_function_call_ids.add(call_id) return False return True - return _has_output_item(call_id, "function_call_output") + binding_status = context_wrapper._approved_tool_invocation_status( + run.tool_call, + tool_lookup_key=get_function_tool_lookup_key_for_tool(run.function_tool), + ) + if binding_status is not None: + return binding_status[1] + + return False def _add_pending_interruption(item: ToolApprovalItem | None) -> None: if item is None: @@ -1378,30 +1483,6 @@ def _add_pending_interruption(item: ToolApprovalItem | None) -> None: pending_interruption_keys.add(key) pending_interruptions.append(item) - def _allow_legacy_name_agent_match() -> bool: - schema_version = getattr(run_state, "_schema_version", None) - if not isinstance(schema_version, str): - return False - try: - version_parts = tuple(int(part) for part in schema_version.split(".")) - except ValueError: - return False - # Schema 1.6 and earlier only serialized approval owners by agent name. With duplicate-name - # agents, deserialization can legitimately resolve the approval to a sibling instance, so - # resume must accept a same-name match for those legacy snapshots. Schema 1.7+ persists - # duplicate-name identities, so newer snapshots should continue requiring object identity. - return version_parts < (1, 7) - - allow_legacy_name_agent_match = _allow_legacy_name_agent_match() - - def _approval_matches_agent(approval: ToolApprovalItem) -> bool: - approval_agent = approval.agent - if approval_agent is None: - return False - if approval_agent is public_agent: - return True - return allow_legacy_name_agent_match and approval_agent.name == public_agent.name - def _approval_persisted_lookup_key( approval: ToolApprovalItem, ) -> FunctionToolLookupKey | None: @@ -1415,6 +1496,48 @@ def _approval_persisted_lookup_key( approval.tool_namespace, ) + deferred_binding_validation_raw_item_ids = { + id(run.tool_call) + for run in processed_response.functions + if ( + ( + nested_result := peek_agent_tool_run_result( + run.tool_call, + scope_id=tool_state_scope_id, + ) + ) + is not None + and ( + getattr(nested_result, "interruptions", None) + or get_agent_tool_resume_state(nested_result) is not None + ) + ) + } + for function_run in processed_response.functions: + if id(function_run.tool_call) not in deferred_binding_validation_raw_item_ids: + continue + approval_item = approval_items_by_call_id.get(function_run.tool_call.call_id) + persisted_lookup_key = ( + _approval_persisted_lookup_key(approval_item) + if approval_item is not None + else get_function_tool_lookup_key_for_tool(function_run.function_tool) + ) + current_lookup_key = get_function_tool_lookup_key_for_tool(function_run.function_tool) + if persisted_lookup_key != current_lookup_key: + # Preserve the more specific interrupted Agent.as_tool() replacement error below. + continue + context_wrapper._approved_tool_invocation_status( + function_run.tool_call, + tool_lookup_key=persisted_lookup_key, + ) + _dedupe_processed_response_invocations( + processed_response, + context_wrapper=context_wrapper, + existing_items=original_pre_step_items, + deferred_binding_validation_raw_item_ids=deferred_binding_validation_raw_item_ids, + filter_completed=False, + ) + queued_call_id_counts: dict[str, int] = {} queued_call_items = [ *(run.tool_call for run in processed_response.functions), @@ -1831,6 +1954,12 @@ def _rebind_function_run( call_id, tool_namespace=approval_record.tool_namespace, existing_pending=approval_record, + current_invocation=ToolApprovalItem( + agent=public_agent, + raw_item=call, + tool_name=call.name, + tool_namespace=get_tool_call_namespace(call), + ), ) if approval_record is not None else True @@ -1842,6 +1971,19 @@ def _rebind_function_run( continue current_handoff = current_handoffs.get(call_id) + if current_handoff is not None and approval_record is not None: + approval_status = context_wrapper.get_approval_status( + approval_record.tool_name or call.name, + call_id, + tool_namespace=approval_record.tool_namespace, + existing_pending=approval_record, + current_invocation=ToolApprovalItem( + agent=public_agent, + raw_item=current_handoff.tool_call, + tool_name=current_handoff.tool_call.name, + tool_namespace=get_tool_call_namespace(current_handoff.tool_call), + ), + ) if current_handoff is not None and approval_status is True: if stale_function is not None: _reject_nested_replacement(stale_function) @@ -1888,6 +2030,7 @@ def _rebind_function_run( rejection_message = await resolve_approval_rejection_message( context_wrapper=context_wrapper, run_config=run_config, + tool_call=rejection_call, tool_type="function", tool_name=get_tool_call_trace_name(rejection_call) or rejection_call.name, call_id=call_id, @@ -1914,6 +2057,39 @@ def _rebind_function_run( *(extract_mcp_request_id_from_run(run) for run in processed_response.mcp_approval_requests), } for original_approval in pending_approval_items: + if not _approval_matches_agent(original_approval): + nested_approval = ( + run_state._find_nested_approval_state(original_approval) + if run_state is not None + else None + ) + if nested_approval is None: + if any( + agent_tool_resume_checkpoint_owns_approval( + nested_result, + original_approval, + ) + for nested_result in stable_function_nested_results.values() + ): + continue + _add_pending_interruption(original_approval) + continue + nested_state, nested_item = nested_approval + nested_context = nested_state._context + nested_call_id = get_tool_approval_item_call_id(nested_item) + nested_status = ( + nested_context.get_approval_status( + nested_item.tool_name or "", + nested_call_id, + tool_namespace=nested_item.tool_namespace, + existing_pending=nested_item, + ) + if nested_context is not None and nested_call_id is not None + else None + ) + if nested_status is None: + _add_pending_interruption(original_approval) + continue approval_snapshot = validated_function_approval_items.get(original_approval) if approval_snapshot is None: approval = original_approval @@ -1953,6 +2129,21 @@ def _rebind_function_run( for run in reconciled_functions if run.tool_call.call_id not in missing_function_call_ids ] + + # Validate every current execution candidate before any dynamic approval callback or + # output-based replay suppression can run. This keeps a changed invocation under an approved + # call ID from triggering sibling user code or hiding behind a previously committed output. + for function_run in selectable_function_runs: + context_wrapper._approved_tool_invocation_status( + function_run.tool_call, + tool_lookup_key=get_function_tool_lookup_key_for_tool(function_run.function_tool), + ) + for handoff_run in reconciled_handoffs: + context_wrapper._approved_tool_invocation_status( + handoff_run.tool_call, + invocation_role="handoff", + ) + _validate_unresolved_function_calls(context_wrapper, missing_function_tools) function_tool_runs = await _select_function_tool_runs_for_resume( selectable_function_runs, approval_items_by_call_id=function_approval_items_by_call_id, @@ -2034,7 +2225,6 @@ def _rebind_function_run( shell_calls=approved_shell_calls, apply_patch_calls=approved_apply_patch_calls, ) - missing_output_items = await _build_tool_not_found_output_items( agent=public_agent, calls=missing_function_tools, @@ -2058,6 +2248,29 @@ def _rebind_function_run( dropped_nested_call_ids.add(id(stale_call)) _drop_stable_nested_result(stale_call) + call_positions = dict(response_call_positions) + next_call_position = len(new_response.output) + for call in calls_to_reconcile: + if call.call_id not in call_positions: + call_positions[call.call_id] = next_call_position + next_call_position += 1 + + committed_tool_outputs: list[RunItem] = [] + + def _commit_tool_output(item: RunItem) -> None: + if any(existing is item for existing in committed_tool_outputs): + return + committed_tool_outputs.append(item) + committed_tool_outputs.sort( + key=lambda output: call_positions.get( + extract_tool_call_id(getattr(output, "raw_item", None)) or "", + len(call_positions), + ) + ) + if run_state is not None: + run_state._generated_items = [*original_pre_step_items, *committed_tool_outputs] + _register_tool_call_items(context_wrapper, [item]) + ( function_results, tool_input_guardrail_results, @@ -2073,6 +2286,7 @@ def _rebind_function_run( hooks=hooks, context_wrapper=context_wrapper, run_config=run_config, + tool_output_committer=_commit_tool_output, ) for interruption in _collect_tool_interruptions( @@ -2093,12 +2307,6 @@ def _rebind_function_run( apply_patch_results=[], local_shell_results=[], ) - call_positions = dict(response_call_positions) - next_call_position = len(new_response.output) - for call in calls_to_reconcile: - if call.call_id not in call_positions: - call_positions[call.call_id] = next_call_position - next_call_position += 1 function_outcomes = [ *function_result_items, *missing_output_items, @@ -2133,7 +2341,15 @@ def _rebind_function_run( for approved_response in plan.approved_mcp_responses: append_if_new(approved_response) + def _checkpoint_new_items() -> None: + if run_state is not None: + run_state._generated_items = [*original_pre_step_items, *new_items] + _register_tool_call_items(context_wrapper, new_items) + + _checkpoint_new_items() + def _commit_missing_state(result: SingleStepResult) -> SingleStepResult: + _checkpoint_new_items() if missing_function_call_ids: processed_response.functions = [ run @@ -2167,9 +2383,26 @@ def _commit_missing_state(result: SingleStepResult) -> SingleStepResult: context_wrapper=context_wrapper, append_item=append_if_new, ) + _checkpoint_new_items() + ( + pending_hosted_mcp_approvals, + pending_hosted_mcp_approval_ids, + ) = process_hosted_mcp_approvals( + original_pre_step_items=original_pre_step_items, + mcp_approval_requests=processed_response.mcp_approval_requests, + context_wrapper=context_wrapper, + agent=public_agent, + append_item=append_if_new, + ) - pre_step_items: list[RunItem] = [ - item for item in original_pre_step_items if not isinstance(item, ToolApprovalItem) + pre_step_items = [ + item + for item in original_pre_step_items + if should_keep_hosted_mcp_item( + item, + pending_hosted_mcp_approvals=pending_hosted_mcp_approvals, + pending_hosted_mcp_approval_ids=pending_hosted_mcp_approval_ids, + ) ] if rejected_function_call_ids: @@ -2205,6 +2438,15 @@ def _commit_missing_state(result: SingleStepResult) -> SingleStepResult: ] if pending_handoffs: + + def _commit_handoff_output( + _handoff_output: HandoffOutputItem, + new_agent: Agent[Any], + ) -> None: + _checkpoint_new_items() + if run_state is not None: + run_state._current_agent = new_agent + return _commit_missing_state( await execute_handoffs_call( public_agent=public_agent, @@ -2220,6 +2462,7 @@ def _commit_missing_state(result: SingleStepResult) -> SingleStepResult: nest_handoff_history_fn=nest_handoff_history_fn, tool_input_guardrail_results=tool_input_guardrail_results, tool_output_guardrail_results=tool_output_guardrail_results, + handoff_output_committer=_commit_handoff_output, ) ) @@ -2448,7 +2691,6 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: "created_by": get_mapping_or_attr(output, "created_by"), } shell_call_raw.pop("created_by", None) - items.append(ToolCallItem(raw_item=cast(Any, shell_call_raw), agent=agent)) if not shell_tool: tools_used.append("shell") _error_tracing.attach_error_to_current_span( @@ -2458,6 +2700,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) ) raise ModelBehaviorError("Model produced shell call without a shell tool.") + items.append( + ToolCallItem( + raw_item=cast(Any, shell_call_raw), + agent=agent, + _resolved_tool_name=shell_tool.name, + ) + ) ensure_tool_caller_allowed( tool_call=output, allowed_callers=shell_tool.allowed_callers, @@ -2535,7 +2784,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tool_name=apply_patch_tool.name, agent_name=agent.name, ) - items.append(ToolCallItem(raw_item=cast(Any, apply_patch_call_raw), agent=agent)) + items.append( + ToolCallItem( + raw_item=cast(Any, apply_patch_call_raw), + agent=agent, + _resolved_tool_name=apply_patch_tool.name, + ) + ) tools_used.append(apply_patch_tool.name) call_identifier = get_mapping_or_attr(apply_patch_call_raw, "call_id") logger.debug("Queuing apply_patch_call %s", call_identifier) @@ -2640,7 +2895,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tool_name=computer_tool.name, agent_name=agent.name, ) - items.append(ToolCallItem(raw_item=output, agent=agent)) + items.append( + ToolCallItem( + raw_item=output, + agent=agent, + _resolved_tool_name=computer_tool.name, + ) + ) tools_used.append(computer_tool.name) computer_actions.append( ToolRunComputerAction(tool_call=output, computer_tool=computer_tool) @@ -2747,7 +3008,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tool_name=local_shell_tool.name, agent_name=agent.name, ) - items.append(ToolCallItem(raw_item=output, agent=agent)) + items.append( + ToolCallItem( + raw_item=output, + agent=agent, + _resolved_tool_name=local_shell_tool.name, + ) + ) tools_used.append("local_shell") local_shell_calls.append( ToolRunLocalShellCall(tool_call=output, local_shell_tool=local_shell_tool) @@ -2759,7 +3026,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tool_name=shell_tool.name, agent_name=agent.name, ) - items.append(ToolCallItem(raw_item=output, agent=agent)) + items.append( + ToolCallItem( + raw_item=output, + agent=agent, + _resolved_tool_name=shell_tool.name, + ) + ) tools_used.append(shell_tool.name) shell_calls.append(ToolRunShellCall(tool_call=output, shell_tool=shell_tool)) else: @@ -2786,13 +3059,8 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tools_used.append(custom_tool.name) custom_tool_calls.append(ToolRunCustom(tool_call=output, custom_tool=custom_tool)) elif is_apply_patch_name(output.name, apply_patch_tool): - parsed_operation = parse_apply_patch_custom_input(output.input) - pseudo_call = { - "type": "apply_patch_call", - "call_id": output.call_id, - **parsed_operation, - } - ItemHelpers.copy_tool_call_caller(output, pseudo_call) + pseudo_call = normalize_apply_patch_fallback_call(output) + assert pseudo_call is not None if apply_patch_tool: ensure_tool_caller_allowed( tool_call=pseudo_call, @@ -2800,7 +3068,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tool_name=apply_patch_tool.name, agent_name=agent.name, ) - items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent)) + items.append( + ToolCallItem( + raw_item=cast(Any, pseudo_call), + agent=agent, + _resolved_tool_name=apply_patch_tool.name, + ) + ) tools_used.append(apply_patch_tool.name) apply_patch_calls.append( ToolRunApplyPatchCall( @@ -2834,13 +3108,8 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: and is_apply_patch_name(output.name, apply_patch_tool) and get_function_tool_lookup_key_for_call(output) not in function_map ): - parsed_operation = parse_apply_patch_function_args(output.arguments) - pseudo_call = { - "type": "apply_patch_call", - "call_id": output.call_id, - "operation": parsed_operation, - } - ItemHelpers.copy_tool_call_caller(output, pseudo_call) + pseudo_call = normalize_apply_patch_fallback_call(output) + assert pseudo_call is not None if apply_patch_tool: ensure_tool_caller_allowed( tool_call=pseudo_call, @@ -2848,7 +3117,13 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tool_name=apply_patch_tool.name, agent_name=agent.name, ) - items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent)) + items.append( + ToolCallItem( + raw_item=cast(Any, pseudo_call), + agent=agent, + _resolved_tool_name=apply_patch_tool.name, + ) + ) tools_used.append(apply_patch_tool.name) apply_patch_calls.append( ToolRunApplyPatchCall(tool_call=pseudo_call, apply_patch_tool=apply_patch_tool) @@ -2974,6 +3249,104 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ) +def _preflight_response_invocations_after_processing_error( + *, + response: ModelResponse, + all_tools: Sequence[Tool], + handoffs: Sequence[Handoff], + context_wrapper: RunContextWrapper[Any], +) -> None: + """Reject call-ID reuse before reporting an unrelated response-processing error.""" + handoff_map = {handoff.tool_name: handoff for handoff in handoffs} + function_map = build_function_tool_lookup_map( + [tool for tool in all_tools if isinstance(tool, FunctionTool)] + ) + custom_tool_map = {tool.name: tool for tool in all_tools if isinstance(tool, CustomTool)} + computer_tool = next((tool for tool in all_tools if isinstance(tool, ComputerTool)), None) + local_shell_tool = next((tool for tool in all_tools if isinstance(tool, LocalShellTool)), None) + shell_tool = next((tool for tool in all_tools if isinstance(tool, ShellTool)), None) + apply_patch_tool = next((tool for tool in all_tools if isinstance(tool, ApplyPatchTool)), None) + response_identities: dict[str, tuple[str, str, str, str] | None] = {} + + for output in response.output: + raw_item: Any = output + output_type = get_mapping_or_attr(output, "type") + tool_lookup_key: FunctionToolLookupKey | None = None + tool_name: str | None = None + invocation_role: str | None = None + + if isinstance(output, ResponseFunctionToolCall): + tool_lookup_key = get_function_tool_lookup_key_for_call(output) + if is_handoff_tool_call(output, handoff_map): + invocation_role = "handoff" + elif ( + is_apply_patch_name(output.name, apply_patch_tool) + and tool_lookup_key not in function_map + ): + raw_item = normalize_apply_patch_fallback_call(output) or output + tool_name = apply_patch_tool.name if apply_patch_tool is not None else None + elif isinstance(output, ResponseCustomToolCall): + custom_tool = custom_tool_map.get(output.name) + if custom_tool is not None: + tool_name = custom_tool.name + elif is_apply_patch_name(output.name, apply_patch_tool): + raw_item = normalize_apply_patch_fallback_call(output) or output + tool_name = apply_patch_tool.name if apply_patch_tool is not None else None + elif output_type == "shell_call": + tool_name = shell_tool.name if shell_tool is not None else None + elif isinstance(output, LocalShellCall): + selected_shell_tool = local_shell_tool or shell_tool + tool_name = selected_shell_tool.name if selected_shell_tool is not None else None + elif output_type == "apply_patch_call": + tool_name = apply_patch_tool.name if apply_patch_tool is not None else None + elif isinstance(output, ResponseComputerToolCall): + tool_name = computer_tool.name if computer_tool is not None else None + + call_identity = tool_invocation_call_id(raw_item) + if call_identity is None: + continue + _, call_id = call_identity + if call_id is None: + raise ModelBehaviorError( + "Tool invocations require a non-empty string call ID before execution." + ) + identity = tool_invocation_identity_and_scope( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if call_id in response_identities: + previous_identity = response_identities[call_id] + if previous_identity is None or identity is None or previous_identity != identity: + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation in one response. " + "Use a unique call ID for each tool invocation." + ) + response_identities[call_id] = identity + + record = context_wrapper._tool_invocations.get(call_id) + if record is None: + continue + if identity is None: + if not isinstance(output, McpApprovalRequest): + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + continue + invocation_type, _, approval_scope, fingerprint = identity + if ( + record.invocation_type != invocation_type + or record.approval_scope != approval_scope + or record.fingerprint != fingerprint + ): + raise ModelBehaviorError( + "Model reused a tool call ID for a different invocation. " + "Use a unique call ID for each tool invocation." + ) + + async def get_single_step_result_from_response( *, bindings: AgentBindings[TContext], @@ -2989,38 +3362,56 @@ async def get_single_step_result_from_response( tool_use_tracker, error_handlers: RunErrorHandlers[TContext] | None = None, server_manages_conversation: bool = False, - event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] | None = None, + after_invocation_validation: Callable[[list[RunItem] | None], Awaitable[None]] | None = None, before_side_effects: Callable[[], Awaitable[None]] | None = None, ) -> SingleStepResult: item_agent = bindings.public_agent - processed_response = process_model_response( - agent=item_agent, - all_tools=all_tools, - response=new_response, - output_schema=output_schema, - handoffs=handoffs, + try: + processed_response = process_model_response( + agent=item_agent, + all_tools=all_tools, + response=new_response, + output_schema=output_schema, + handoffs=handoffs, + existing_items=pre_step_items, + run_config=run_config, + server_manages_conversation=server_manages_conversation, + server_managed_input_items=( + ItemHelpers.input_to_new_input_list(original_input) + if server_manages_conversation + else None + ), + ) + except ModelBehaviorError: + _preflight_response_invocations_after_processing_error( + response=new_response, + all_tools=all_tools, + handoffs=handoffs, + context_wrapper=context_wrapper, + ) + if after_invocation_validation is not None: + await after_invocation_validation(None) + raise + + _register_tool_call_items( + context_wrapper, + pre_step_items, + validate_invocations=False, + ) + skipped_raw_item_ids = _dedupe_processed_response_invocations( + processed_response, + context_wrapper=context_wrapper, existing_items=pre_step_items, - run_config=run_config, - server_manages_conversation=server_manages_conversation, - server_managed_input_items=( - ItemHelpers.input_to_new_input_list(original_input) - if server_manages_conversation - else None - ), ) + if after_invocation_validation is not None: + await after_invocation_validation(processed_response.new_items) + if before_side_effects is not None: await before_side_effects() tool_use_tracker.record_processed_response(item_agent, processed_response) - if event_queue is not None and processed_response.new_items: - handoff_items = [ - item for item in processed_response.new_items if isinstance(item, HandoffCallItem) - ] - if handoff_items: - stream_step_items_to_queue(cast(list[RunItem], handoff_items), event_queue) - return await execute_tools_and_side_effects( bindings=bindings, original_input=original_input, @@ -3033,4 +3424,5 @@ async def get_single_step_result_from_response( run_config=run_config, error_handlers=error_handlers, server_manages_conversation=server_manages_conversation, + precomputed_skipped_raw_item_ids=skipped_raw_item_ids, ) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 243a6d2c9e..71ceef3154 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -50,8 +50,14 @@ get_function_tool_qualified_name, serialize_function_tool_lookup_key, ) +from ._tool_invocation import ( + tool_invocation_call_id, + tool_invocation_identity, + tool_invocation_identity_and_scope, + tool_output_identity, +) from .agent import Agent -from .exceptions import UserError +from .exceptions import ModelBehaviorError, UserError from .guardrail import ( GuardrailFunctionOutput, InputGuardrail, @@ -150,7 +156,7 @@ # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported # versions). -CURRENT_SCHEMA_VERSION = "1.14" +CURRENT_SCHEMA_VERSION = "1.15" _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13" _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. @@ -176,6 +182,7 @@ "flows." ), "1.14": "Scopes hosted MCP approvals and restored requests by server label.", + "1.15": "Persists canonical tool invocation identity and lifecycle across resume flows.", } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -365,10 +372,175 @@ def get_interruptions(self) -> list[ToolApprovalItem]: return [] return self._current_step.interruptions + @staticmethod + def _approval_items_match( + candidate: ToolApprovalItem, + approval_item: ToolApprovalItem, + ) -> bool: + """Return whether two approval items identify the same nested invocation.""" + if candidate is approval_item: + return True + candidate_agent = candidate.agent + approval_agent = approval_item.agent + if ( + candidate_agent is not None + and approval_agent is not None + and candidate_agent is not approval_agent + ): + return False + candidate_identity = tool_invocation_identity( + candidate.raw_item, + tool_lookup_key=candidate.tool_lookup_key, + tool_name=candidate.tool_name, + ) + approval_identity = tool_invocation_identity( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + return candidate_identity is not None and candidate_identity == approval_identity + + def _find_nested_approval_state( + self, + approval_item: ToolApprovalItem, + ) -> tuple[RunState[Any, Agent[Any]], ToolApprovalItem] | None: + """Find the nested agent-tool state that owns an approval interruption.""" + if self._last_processed_response is None: + return None + + from .agent_tool_state import peek_agent_tool_run_result + + approval_identity = tool_invocation_identity_and_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + current_state_owns_approval = False + if approval_identity is not None and self._context is not None: + invocation_type, call_id, approval_scope, fingerprint = approval_identity + current_record = self._context._tool_invocations.get(call_id) + current_state_owns_approval = current_record is not None and ( + not current_record.completed + and current_record.invocation_type == invocation_type + and current_record.approval_scope == approval_scope + and current_record.fingerprint == fingerprint + ) + current_response_identities = [ + *( + tool_invocation_identity_and_scope( + run.tool_call, + tool_lookup_key=get_function_tool_lookup_key_for_tool(run.function_tool), + ) + for run in self._last_processed_response.functions + ), + *( + tool_invocation_identity_and_scope( + run.tool_call, + invocation_role="handoff", + ) + for run in self._last_processed_response.handoffs + ), + *( + tool_invocation_identity_and_scope( + run.tool_call, + tool_name=run.computer_tool.name, + ) + for run in self._last_processed_response.computer_actions + ), + *( + tool_invocation_identity_and_scope( + run.tool_call, + tool_name=run.custom_tool.name, + ) + for run in self._last_processed_response.custom_tool_calls + ), + *( + tool_invocation_identity_and_scope( + run.tool_call, + tool_name=run.local_shell_tool.name, + ) + for run in self._last_processed_response.local_shell_calls + ), + *( + tool_invocation_identity_and_scope( + run.tool_call, + tool_name=run.shell_tool.name, + ) + for run in self._last_processed_response.shell_calls + ), + *( + tool_invocation_identity_and_scope( + run.tool_call, + tool_name=run.apply_patch_tool.name, + ) + for run in self._last_processed_response.apply_patch_calls + ), + *( + tool_invocation_identity_and_scope( + run.tool_call, + tool_name=run.tool_name, + ) + for run in self._last_processed_response.function_tools_not_found + ), + *( + tool_invocation_identity_and_scope(run.request_item) + for run in self._last_processed_response.mcp_approval_requests + ), + ] + current_state_owns_approval = ( + current_state_owns_approval and approval_identity in current_response_identities + ) + + exact_match: tuple[RunState[Any, Agent[Any]], ToolApprovalItem] | None = None + canonical_matches: list[tuple[RunState[Any, Agent[Any]], ToolApprovalItem]] = [] + for function_run in self._last_processed_response.functions: + pending_result = peek_agent_tool_run_result( + function_run.tool_call, + scope_id=self._agent_tool_state_scope_id, + ) + interruptions = getattr(pending_result, "interruptions", None) + to_state = getattr(pending_result, "to_state", None) + if not isinstance(interruptions, list) or not callable(to_state): + continue + nested_state = to_state() + if not isinstance(nested_state, RunState) or nested_state is self: + continue + for candidate in interruptions: + if not isinstance(candidate, ToolApprovalItem): + continue + if candidate is approval_item: + exact_match = (nested_state, candidate) + break + if self._approval_items_match(candidate, approval_item): + canonical_matches.append((nested_state, candidate)) + if exact_match is not None: + break + + if current_state_owns_approval and (exact_match is not None or canonical_matches): + raise UserError( + "Cannot apply approval because the same tool invocation identity belongs to both " + "the current run and a nested agent-tool run. Use distinct call IDs." + ) + if exact_match is not None: + return exact_match + if len(canonical_matches) == 1: + return canonical_matches[0] + if len(canonical_matches) > 1: + raise UserError( + "Cannot apply approval because multiple nested agent-tool runs contain the same " + "tool invocation identity. Use unique call IDs within nested runs." + ) + return None + def approve(self, approval_item: ToolApprovalItem, always_approve: bool = False) -> None: """Approve a tool call and rerun with this state to continue.""" if self._context is None: raise UserError("Cannot approve tool: RunState has no context") + nested_approval = self._find_nested_approval_state(approval_item) + if nested_approval is not None: + nested_state, nested_item = nested_approval + nested_state.approve(nested_item, always_approve=always_approve) + return self._context.approve_tool(approval_item, always_approve=always_approve) def reject( @@ -386,6 +558,15 @@ def reject( """ if self._context is None: raise UserError("Cannot reject tool: RunState has no context") + nested_approval = self._find_nested_approval_state(approval_item) + if nested_approval is not None: + nested_state, nested_item = nested_approval + nested_state.reject( + nested_item, + always_reject=always_reject, + rejection_message=rejection_message, + ) + return self._context.reject_tool( approval_item, always_reject=always_reject, @@ -414,8 +595,25 @@ def _serialize_approvals(self) -> dict[str, dict[str, Any]]: approvals_dict[tool_name]["sticky_rejection_message"] = ( record.sticky_rejection_message ) + if record.sticky_scope is not None: + approvals_dict[tool_name]["sticky_scope"] = record.sticky_scope return approvals_dict + def _serialize_tool_invocations(self) -> dict[str, dict[str, Any]]: + """Serialize the run-owned canonical tool invocation ledger.""" + if self._context is None: + return {} + return { + call_id: { + "type": invocation.invocation_type, + "approval_scope": invocation.approval_scope, + "fingerprint": invocation.fingerprint, + "executed": invocation.executed, + "completed": invocation.completed, + } + for call_id, invocation in self._context._tool_invocations.items() + } + def _serialize_hosted_mcp_approvals(self) -> list[dict[str, Any]]: """Serialize hosted MCP approvals with explicit typed identities.""" if self._context is None: @@ -456,6 +654,8 @@ def _serialize_hosted_mcp_approvals(self) -> list[dict[str, Any]]: decision["rejection_messages"] = dict(record.rejection_messages) if record.sticky_rejection_message is not None: decision["sticky_rejection_message"] = record.sticky_rejection_message + if record.sticky_scope is not None: + decision["sticky_scope"] = record.sticky_scope serialized.append({"identity": identity_data, "decision": decision}) return serialized @@ -802,6 +1002,7 @@ def to_json( raise UserError("Cannot serialize RunState: No context") approvals_dict = self._serialize_approvals() + tool_invocations = self._serialize_tool_invocations() hosted_mcp_approvals = self._serialize_hosted_mcp_approvals() model_responses = self._serialize_model_responses() original_input_serialized = self._serialize_original_input() @@ -813,6 +1014,7 @@ def to_json( context_entry: dict[str, Any] = { "usage": serialize_usage(self._context.usage), "approvals": approvals_dict, + "tool_invocations": tool_invocations, "context": context_payload, # Preserve metadata so deserialization can warn when context types were erased. "context_meta": context_meta, @@ -2837,12 +3039,20 @@ async def _build_run_state_from_json( else: raise UserError("Serialized run state context must be a mapping. Please provide one.") context.usage = usage + context._restored_unbound_approval_call_ids = set() + context._allow_legacy_approval_binding_reconstruction = (schema_major, schema_minor) < (1, 15) context._rebuild_approvals(context_data.get("approvals", {})) + if (schema_major, schema_minor) >= (1, 15): + context._rebuild_tool_invocations(context_data.get("tool_invocations", {})) + else: + context._tool_invocations = {} hosted_mcp_major, hosted_mcp_minor = ( int(part) for part in _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION.split(".", maxsplit=1) ) if (schema_major, schema_minor) >= (hosted_mcp_major, hosted_mcp_minor): context._rebuild_hosted_mcp_approvals(context_data.get("hosted_mcp_approvals", [])) + if (schema_major, schema_minor) >= (1, 15): + context._mark_restored_unbound_approval_call_ids() serialized_tool_input = context_data.get("tool_input") if ( context_override is None @@ -3061,6 +3271,8 @@ async def _build_run_state_from_json( state._current_step = NextStepInterruption( interruptions=[item for item in interruptions if isinstance(item, ToolApprovalItem)] ) + for approval_item in state._current_step.interruptions: + context._mark_restored_unbound_pending_approval(approval_item) state._current_turn_persisted_item_count = state_json.get( "current_turn_persisted_item_count", 0 @@ -3083,9 +3295,288 @@ async def _build_run_state_from_json( sandbox_data = state_json.get("sandbox") state._sandbox = dict(sandbox_data) if isinstance(sandbox_data, Mapping) else None + _validate_completed_tool_invocations( + state, + reconstruct_legacy=(schema_major, schema_minor) < (1, 15), + ) + return state +def _validate_completed_tool_invocations( + state: RunState[Any, Agent[Any]], + *, + reconstruct_legacy: bool = False, +) -> None: + """Reconcile invocation bindings with restored calls and outputs.""" + if state._context is None: + return + from .run_internal.tool_execution import ( + is_apply_patch_name, + normalize_apply_patch_fallback_call, + ) + + completed_records = { + call_id: record + for call_id, record in state._context._tool_invocations.items() + if record.completed + } + starting_agent = state._starting_agent + assert starting_agent is not None + apply_patch_tools = [ + tool + for agent in _iter_agent_graph(starting_agent) + for tool in agent.tools + if isinstance(tool, ApplyPatchTool) + ] + legacy_native_tool_names: dict[str, set[str]] = {} + if reconstruct_legacy: + native_tool_types = ( + (ComputerTool, "computer_call"), + (CustomTool, "custom_tool_call"), + (LocalShellTool, "local_shell_call"), + (ShellTool, "shell_call"), + (ApplyPatchTool, "apply_patch_call"), + ) + for agent in _iter_agent_graph(starting_agent): + for tool in agent.tools: + for tool_type, invocation_type in native_tool_types: + if isinstance(tool, tool_type): + legacy_native_tool_names.setdefault(invocation_type, set()).add(tool.name) + break + resolved_tool_names_by_call_id: dict[str, str] = {} + + def collect_resolved_tool_name(raw_item: Any, tool_name: Any) -> None: + call_identity = tool_invocation_call_id(raw_item) + if isinstance(tool_name, str) and tool_name and call_identity is not None: + _, call_id = call_identity + if call_id is not None: + resolved_tool_names_by_call_id.setdefault(call_id, tool_name) + + for run_item in state._generated_items: + collect_resolved_tool_name(run_item.raw_item, getattr(run_item, "tool_name", None)) + for run_item in state._session_items: + collect_resolved_tool_name(run_item.raw_item, getattr(run_item, "tool_name", None)) + if state._last_processed_response is not None: + for run_item in state._last_processed_response.new_items: + collect_resolved_tool_name(run_item.raw_item, getattr(run_item, "tool_name", None)) + for computer_run in state._last_processed_response.computer_actions: + collect_resolved_tool_name(computer_run.tool_call, computer_run.computer_tool.name) + for custom_run in state._last_processed_response.custom_tool_calls: + collect_resolved_tool_name(custom_run.tool_call, custom_run.custom_tool.name) + for local_shell_run in state._last_processed_response.local_shell_calls: + collect_resolved_tool_name( + local_shell_run.tool_call, + local_shell_run.local_shell_tool.name, + ) + for shell_run in state._last_processed_response.shell_calls: + collect_resolved_tool_name(shell_run.tool_call, shell_run.shell_tool.name) + for apply_patch_run in state._last_processed_response.apply_patch_calls: + collect_resolved_tool_name( + apply_patch_run.tool_call, + apply_patch_run.apply_patch_tool.name, + ) + for missing_run in state._last_processed_response.function_tools_not_found: + collect_resolved_tool_name(missing_run.tool_call, missing_run.tool_name) + + restored_call_occurrences: list[ + dict[ + tuple[str, str, str], + tuple[Any, FunctionToolLookupKey | None, str | None, str | None], + ] + ] = [] + restored_outputs: dict[tuple[str, str], Any] = {} + uncanonical_call_ids: set[str] = set() + + def record_raw_item( + raw_item: Any, + *, + tool_lookup_key: FunctionToolLookupKey | None = None, + tool_name: str | None = None, + invocation_role: str | None = None, + allow_handoff_alternative: bool = False, + ) -> None: + output_identity = tool_output_identity(raw_item) + if output_identity is not None: + restored_outputs.setdefault(output_identity, raw_item) + + occurrence: dict[ + tuple[str, str, str], + tuple[Any, FunctionToolLookupKey | None, str | None, str | None], + ] = {} + + call_identity = tool_invocation_call_id(raw_item) + if tool_name is None: + if call_identity is not None and call_identity[1] is not None: + tool_name = resolved_tool_names_by_call_id.get(call_identity[1]) + if tool_name is None: + candidate_names = legacy_native_tool_names.get(call_identity[0], set()) + if len(candidate_names) == 1: + tool_name = next(iter(candidate_names)) + + def add_identity(role: str | None) -> None: + identity = tool_invocation_identity( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=role, + ) + if identity is not None: + occurrence.setdefault(identity, (raw_item, tool_lookup_key, tool_name, role)) + + add_identity(invocation_role) + if allow_handoff_alternative and invocation_role is None: + add_identity("handoff") + raw_name = getattr(raw_item, "name", None) + if isinstance(raw_item, Mapping): + raw_name = raw_item.get("name") + if any(is_apply_patch_name(raw_name, tool) for tool in apply_patch_tools): + try: + fallback_call = normalize_apply_patch_fallback_call(raw_item) + except ModelBehaviorError: + fallback_call = None + if fallback_call is not None: + fallback_identity = tool_invocation_identity( + fallback_call, + tool_name=tool_name, + ) + if fallback_identity is not None: + occurrence.setdefault( + fallback_identity, + (fallback_call, None, tool_name, None), + ) + if occurrence: + restored_call_occurrences.append(occurrence) + elif call_identity is not None and call_identity[1] is not None: + uncanonical_call_ids.add(call_identity[1]) + + def record_run_item(run_item: RunItem) -> None: + record_raw_item( + run_item.raw_item, + tool_lookup_key=getattr(run_item, "tool_lookup_key", None), + tool_name=getattr(run_item, "tool_name", None), + invocation_role="handoff" if isinstance(run_item, HandoffCallItem) else None, + ) + + for run_item in state._generated_items: + record_run_item(run_item) + for run_item in state._session_items: + record_run_item(run_item) + if state._last_processed_response is not None: + for run_item in state._last_processed_response.new_items: + record_run_item(run_item) + for response in state._model_responses: + for raw_item in response.output: + record_raw_item(raw_item, allow_handoff_alternative=True) + if isinstance(state._original_input, list): + for raw_item in state._original_input: + record_raw_item(raw_item, allow_handoff_alternative=True) + + occurrences_by_call_id: dict[ + str, + list[ + dict[ + tuple[str, str, str], + tuple[Any, FunctionToolLookupKey | None, str | None, str | None], + ] + ], + ] = {} + for occurrence in restored_call_occurrences: + call_ids = {call_id for _, call_id, _ in occurrence} + if len(call_ids) == 1: + occurrences_by_call_id.setdefault(next(iter(call_ids)), []).append(occurrence) + + if reconstruct_legacy: + for call_id, occurrences in occurrences_by_call_id.items(): + if call_id in state._context._tool_invocations or call_id in uncanonical_call_ids: + continue + output_types = { + invocation_type + for invocation_type, output_call_id in restored_outputs + if output_call_id == call_id + } + if not output_types: + continue + common_identities = set(occurrences[0]) + for occurrence in occurrences[1:]: + common_identities.intersection_update(occurrence) + completed_identities = [ + identity for identity in common_identities if identity[0] in output_types + ] + if completed_identities: + identity = next( + ( + candidate + for candidate in completed_identities + if occurrences[0][candidate][3] is None + ), + completed_identities[0], + ) + details = next( + occurrence[identity] for occurrence in occurrences if identity in occurrence + ) + else: + identity, details = next( + candidate for occurrence in occurrences for candidate in occurrence.items() + ) + raw_item, tool_lookup_key, tool_name, invocation_role = details + invocation_type, _, _ = identity + status = state._context._tool_invocation_status( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + if status is not None: + if completed_identities: + state._context._mark_tool_call_completed( + restored_outputs[(invocation_type, call_id)] + ) + else: + state._context._mark_tool_invocation_executed( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, + ) + + interruptions = getattr(state._current_step, "interruptions", ()) + for approval_item in interruptions: + if isinstance(approval_item, ToolApprovalItem): + try: + state._context._restore_pending_approval_binding(approval_item) + except ModelBehaviorError: + pending_call_id = state._context._resolve_call_id(approval_item) + if pending_call_id is not None: + state._context._restored_unbound_approval_call_ids.add(pending_call_id) + + state._context._mark_restored_unbound_approval_call_ids() + + state._context._restored_unbound_approval_call_ids.update( + { + call_id + for call_id in occurrences_by_call_id + if call_id not in state._context._tool_invocations + } + | uncanonical_call_ids + ) + + for call_id, record in completed_records.items(): + expected_call = (record.invocation_type, call_id, record.fingerprint) + expected_output = (record.invocation_type, call_id) + occurrences = occurrences_by_call_id.get(call_id, []) + if ( + not occurrences + or call_id in uncanonical_call_ids + or any(expected_call not in occurrence for occurrence in occurrences) + or expected_output not in restored_outputs + ): + raise UserError( + f"RunState completed tool invocation {call_id!r} does not match a restored " + "tool call and output." + ) + + def _iter_agent_graph(initial_agent: Agent[Any]) -> Iterator[Agent[Any]]: """Yield agents reachable from the starting agent in breadth-first order.""" queue: deque[Agent[Any]] = deque([initial_agent]) @@ -3731,6 +4222,11 @@ def _resolve_agent_info( description=description, title=title, tool_origin=tool_origin, + _resolved_tool_name=( + item_data.get("tool_name") + if isinstance(item_data.get("tool_name"), str) + else None + ), ) ) diff --git a/src/agents/tool_context.py b/src/agents/tool_context.py index 1ab2dd29f3..dc9e167b60 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -6,13 +6,19 @@ from openai.types.responses import ResponseFunctionToolCall from ._tool_identity import HostedMCPApprovalKey, get_tool_call_namespace, tool_trace_name -from .agent_tool_state import get_agent_tool_state_scope, set_agent_tool_state_scope +from ._tool_invocation import tool_invocation_identity, tool_invocation_identity_and_scope +from .agent_tool_state import ( + get_agent_tool_state_scope, + peek_agent_tool_run_result, + set_agent_tool_state_scope, +) +from .exceptions import UserError from .run_context import RunContextWrapper, TContext from .usage import Usage if TYPE_CHECKING: from .agent import AgentBase - from .items import TResponseInputItem + from .items import ToolApprovalItem, TResponseInputItem from .run_config import RunConfig from .run_context import _ApprovalRecord @@ -116,6 +122,111 @@ def qualified_tool_name(self) -> str: """Return the tool name qualified by namespace when available.""" return tool_trace_name(self.tool_name, self.tool_namespace) or self.tool_name + def _find_nested_approval_target( + self, + approval_item: ToolApprovalItem, + ) -> tuple[RunContextWrapper[Any], ToolApprovalItem] | None: + """Find a pending nested agent-tool context that owns an approval item.""" + if self.tool_call is None: + return None + pending_result = peek_agent_tool_run_result( + self.tool_call, + scope_id=get_agent_tool_state_scope(self), + ) + interruptions = getattr(pending_result, "interruptions", None) + to_state = getattr(pending_result, "to_state", None) + if not isinstance(interruptions, list) or not callable(to_state): + return None + nested_context = getattr(to_state(), "_context", None) + if not isinstance(nested_context, RunContextWrapper) or nested_context is self: + return None + + target_identity = tool_invocation_identity( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + target_identity_and_scope = tool_invocation_identity_and_scope( + approval_item.raw_item, + tool_lookup_key=approval_item.tool_lookup_key, + tool_name=approval_item.tool_name, + ) + current_context_owns_approval = False + if target_identity_and_scope is not None: + invocation_type, call_id, approval_scope, fingerprint = target_identity_and_scope + current_record = self._tool_invocations.get(call_id) + current_context_owns_approval = current_record is not None and ( + not current_record.completed + and current_record.invocation_type == invocation_type + and current_record.approval_scope == approval_scope + and current_record.fingerprint == fingerprint + ) + + exact_match: ToolApprovalItem | None = None + canonical_matches: list[ToolApprovalItem] = [] + for candidate in interruptions: + if candidate is approval_item: + exact_match = candidate + continue + candidate_identity = tool_invocation_identity( + candidate.raw_item, + tool_lookup_key=candidate.tool_lookup_key, + tool_name=candidate.tool_name, + ) + if target_identity is not None and candidate_identity == target_identity: + canonical_matches.append(candidate) + if current_context_owns_approval and (exact_match is not None or canonical_matches): + raise UserError( + "Cannot apply approval because the same tool invocation identity belongs to both " + "the current run and a nested agent-tool run." + ) + if exact_match is not None: + return (nested_context, exact_match) + if len(canonical_matches) == 1: + return (nested_context, canonical_matches[0]) + if len(canonical_matches) > 1: + raise UserError( + "Cannot apply approval because multiple nested agent-tool calls contain the same " + "tool invocation identity. Use distinct call IDs." + ) + return None + + def approve_tool(self, approval_item: ToolApprovalItem, always_approve: bool = False) -> None: + """Approve this context's call or route a surfaced nested approval to its owner.""" + nested_target = self._find_nested_approval_target(approval_item) + if nested_target is None: + super().approve_tool(approval_item, always_approve=always_approve) + return + nested_context, nested_item = nested_target + RunContextWrapper.approve_tool( + nested_context, + nested_item, + always_approve=always_approve, + ) + + def reject_tool( + self, + approval_item: ToolApprovalItem, + always_reject: bool = False, + rejection_message: str | None = None, + ) -> None: + """Reject this context's call or route a surfaced nested rejection to its owner.""" + nested_target = self._find_nested_approval_target(approval_item) + if nested_target is None: + super().reject_tool( + approval_item, + always_reject=always_reject, + rejection_message=rejection_message, + ) + return + nested_context, nested_item = nested_target + RunContextWrapper.reject_tool( + nested_context, + nested_item, + always_reject=always_reject, + rejection_message=rejection_message, + ) + @classmethod def from_agent_context( cls, @@ -134,7 +245,9 @@ def from_agent_context( """ # Grab the names of the RunContextWrapper's init=True fields base_values: dict[str, Any] = { - f.name: getattr(context, f.name) for f in fields(RunContextWrapper) if f.init + f.name: getattr(context, f.name) + for f in fields(RunContextWrapper) + if f.init and f.name != "_approvals" } resolved_tool_name = ( tool_name @@ -174,5 +287,6 @@ def from_agent_context( run_config=tool_run_config, **base_values, ) + context._share_tool_state_with(tool_context) set_agent_tool_state_scope(tool_context, get_agent_tool_state_scope(context)) return tool_context diff --git a/tests/mcp/test_mcp_tracing.py b/tests/mcp/test_mcp_tracing.py index 7654ab948f..2ebcf83cb1 100644 --- a/tests/mcp/test_mcp_tracing.py +++ b/tests/mcp/test_mcp_tracing.py @@ -26,7 +26,10 @@ async def test_mcp_tracing(): model.add_multiple_turn_outputs( [ # First turn: a message and tool call - [get_text_message("a_message"), get_function_tool_call("test_tool_1", "")], + [ + get_text_message("a_message"), + get_function_tool_call("test_tool_1", "", call_id="mcp_call_1"), + ], # Second turn: text message [get_text_message("done")], ] @@ -88,8 +91,8 @@ async def test_mcp_tracing(): # First turn: a message and tool call [ get_text_message("a_message"), - get_function_tool_call("non_mcp_tool", ""), - get_function_tool_call("test_tool_2", ""), + get_function_tool_call("non_mcp_tool", "", call_id="function_call_1"), + get_function_tool_call("test_tool_2", "", call_id="mcp_call_2"), ], # Second turn: text message [get_text_message("done")], diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index a52b89f241..435dba4647 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, ConfigDict import agents._debug as _debug +from agents._tool_identity import get_function_tool_lookup_key_for_tool from agents.agent import AgentBase from agents.exceptions import ModelBehaviorError, ToolTimeoutError, UserError from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -2458,11 +2459,44 @@ async def test_duplicate_function_tool_call_id_is_ignored( mock_function_tool.on_invoke_tool.assert_called_once() assert len(mock_model.sent_tool_outputs) == 1 + @pytest.mark.asyncio + async def test_approved_function_tool_failure_replay_does_not_rerun( + self, mock_model, mock_agent, mock_function_tool + ): + mock_function_tool.needs_approval = True + mock_function_tool.on_invoke_tool.side_effect = RuntimeError("failed after side effect") + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_failed", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + with pytest.raises(RuntimeError, match="failed after side effect"): + await session.approve_tool_call(tool_call_event.call_id) + + with pytest.raises(ModelBehaviorError, match="already executed"): + await session._handle_tool_call(tool_call_event) + + mock_function_tool.on_invoke_tool.assert_awaited_once() + assert len(mock_model.sent_tool_outputs) == 0 + + @pytest.mark.parametrize("always", [False, True], ids=["per-call", "sticky"]) + @pytest.mark.parametrize("changed_field", ["arguments", "tool_name"]) @pytest.mark.asyncio async def test_function_tool_send_failure_retries_cached_output_without_rerun( - self, mock_agent, mock_function_tool + self, + mock_agent, + mock_function_tool, + always: bool, + changed_field: str, ): - """A post-execution send failure should retry output without rerunning the tool.""" + """An approved call should retry cached output only for the same invocation.""" class FailingToolOutputModel(MockRealtimeModel): def __init__(self): @@ -2475,29 +2509,92 @@ async def send_event(self, event): raise RuntimeError("send failed") await super().send_event(event) + mock_function_tool.needs_approval = True mock_agent.get_all_tools.return_value = [mock_function_tool] mock_model = FailingToolOutputModel() - session = RealtimeSession(mock_model, mock_agent, None) + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) tool_call_event = RealtimeModelToolCallEvent( name="test_function", call_id="call_retry_output", arguments="{}" ) + await session._handle_tool_call(tool_call_event) with pytest.raises(RuntimeError, match="send failed"): - await session._handle_tool_call(tool_call_event) + await session.approve_tool_call(tool_call_event.call_id, always=always) mock_function_tool.on_invoke_tool.assert_called_once() assert len(mock_model.sent_tool_outputs) == 0 + changed_event = RealtimeModelToolCallEvent( + name="other_function" if changed_field == "tool_name" else tool_call_event.name, + call_id=tool_call_event.call_id, + arguments=( + tool_call_event.arguments if changed_field == "tool_name" else '{"changed":true}' + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) await session._handle_tool_call(tool_call_event) mock_function_tool.on_invoke_tool.assert_called_once() assert len(mock_model.sent_tool_outputs) == 1 + @pytest.mark.asyncio + async def test_tool_end_cancellation_after_output_send_does_not_resend( + self, mock_model, mock_agent, mock_function_tool + ) -> None: + """Provider delivery commits the output before local end-event publication.""" + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", + call_id="call_tool_end_cancelled", + arguments="{}", + ) + original_put_event_nowait = session._put_event_nowait + + def cancel_tool_end(event: Any) -> bool: + if isinstance(event, RealtimeToolEnd): + raise asyncio.CancelledError + return original_put_event_nowait(event) + + session._put_event_nowait = cancel_tool_end # type: ignore[method-assign] + with pytest.raises(asyncio.CancelledError): + await session._handle_tool_call(tool_call_event) + + invocation = session._context_wrapper._tool_invocations[tool_call_event.call_id] + assert invocation.executed is True + assert invocation.completed is True + assert tool_call_event.call_id not in session._pending_tool_outputs + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + session._put_event_nowait = original_put_event_nowait # type: ignore[method-assign] + await session._handle_tool_call(tool_call_event) + + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.parametrize("always", [False, True], ids=["per-call", "sticky"]) + @pytest.mark.parametrize("changed_field", ["arguments", "tool_name"]) @pytest.mark.asyncio async def test_async_function_tool_send_failure_retries_cached_output_without_rerun( - self, mock_agent, mock_function_tool + self, + mock_agent, + mock_function_tool, + always: bool, + changed_field: str, ): - """The async task path should keep cached outputs retryable after send failure.""" + """The async approval path should bind retries to the original invocation.""" class FailingToolOutputModel(MockRealtimeModel): def __init__(self): @@ -2510,6 +2607,7 @@ async def send_event(self, event): raise RuntimeError("send failed") await super().send_event(event) + mock_function_tool.needs_approval = True mock_agent.get_all_tools.return_value = [mock_function_tool] mock_model = FailingToolOutputModel() session = RealtimeSession(mock_model, mock_agent, None) @@ -2517,7 +2615,8 @@ async def send_event(self, event): name="test_function", call_id="call_async_retry_output", arguments="{}" ) - await session.on_event(tool_call_event) + await session._handle_tool_call(tool_call_event) + await session.approve_tool_call(tool_call_event.call_id, always=always) tool_call_tasks = list(session._tool_call_tasks) assert len(tool_call_tasks) == 1 task_results = await asyncio.gather(*tool_call_tasks, return_exceptions=True) @@ -2530,6 +2629,15 @@ async def send_event(self, event): mock_function_tool.on_invoke_tool.assert_called_once() assert len(mock_model.sent_tool_outputs) == 0 + changed_event = RealtimeModelToolCallEvent( + name="other_function" if changed_field == "tool_name" else tool_call_event.name, + call_id=tool_call_event.call_id, + arguments=( + tool_call_event.arguments if changed_field == "tool_name" else '{"changed":true}' + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) await session.on_event(tool_call_event) tool_call_tasks = list(session._tool_call_tasks) assert len(tool_call_tasks) == 1 @@ -2806,8 +2914,8 @@ async def test_handoff_validation_failure_keeps_current_agent(self, mock_model): assert session._current_agent is first_agent assert mock_model.sent_events == [] assert mock_model.sent_tool_outputs == [] - assert "call_invalid" not in session._active_tool_call_ids - assert "call_invalid" not in session._completed_tool_call_ids + assert "call_invalid" not in session._active_tool_invocations + assert not session._context_wrapper._tool_invocations["call_invalid"].completed @pytest.mark.asyncio async def test_handoff_session_update_preserves_custom_voice(self, mock_model): @@ -3024,6 +3132,43 @@ async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: assert sent_output == "blocked before execution" assert start_response is True + @pytest.mark.asyncio + async def test_realtime_tool_contexts_share_session_tool_state(self, mock_model): + """Realtime guardrails and callbacks receive the session-owned tool state.""" + observed_contexts: list[ToolContext[Any]] = [] + + @tool_input_guardrail + def capture_guardrail(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + observed_contexts.append(data.context) + return ToolGuardrailFunctionOutput.allow() + + async def invoke_tool(context: ToolContext[Any], _arguments: str) -> str: + observed_contexts.append(context) + return "ok" + + guarded_tool = FunctionTool( + name="test_function", + description="guarded", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + tool_input_guardrails=[capture_guardrail], + ) + agent = RealtimeAgent(name="agent", tools=[guarded_tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + + await session._handle_tool_call( + RealtimeModelToolCallEvent( + name="test_function", + call_id="call_shared_context", + arguments="{}", + ) + ) + + assert len(observed_contexts) == 2 + for context in observed_contexts: + assert context._approvals is session._context_wrapper._approvals + assert context._tool_invocations is session._context_wrapper._tool_invocations + @pytest.mark.asyncio async def test_realtime_pending_approval_skips_tool_input_guardrails_by_default( self, mock_model @@ -3177,6 +3322,14 @@ async def test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_o await session._handle_tool_call(tool_call_event) await session._handle_tool_call(tool_call_event) + changed_event = RealtimeModelToolCallEvent( + name="test_function", + call_id=tool_call_event.call_id, + arguments='{"changed":true}', + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) + assert list(session._pending_tool_calls) == [tool_call_event.call_id] approval_events = [] while not session._event_queue.empty(): @@ -3187,10 +3340,185 @@ async def test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_o await session.approve_tool_call(tool_call_event.call_id) await session._handle_tool_call(tool_call_event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) mock_function_tool.on_invoke_tool.assert_called_once() assert len(mock_model.sent_tool_outputs) == 1 + @pytest.mark.asyncio + async def test_changed_realtime_call_id_fails_while_dispatch_resolution_is_pending( + self, mock_model + ) -> None: + """Concurrent Realtime events compare identities before dispatch resolution awaits.""" + dispatch_started = asyncio.Event() + release_dispatch = asyncio.Event() + executed: list[str] = [] + + async def invoke_tool(_ctx: ToolContext[Any], arguments: str) -> str: + executed.append(arguments) + return "ok" + + tool = FunctionTool( + name="test_function", + description="test", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + original_resolver = session._resolve_dispatch_snapshot + + async def delayed_resolver( + resolver_agent: RealtimeAgent[Any], + dispatch_snapshot: Any, + ) -> Any: + dispatch_started.set() + await release_dispatch.wait() + return await original_resolver(resolver_agent, dispatch_snapshot) + + session._resolve_dispatch_snapshot = delayed_resolver # type: ignore[assignment] + first_event = RealtimeModelToolCallEvent( + name="test_function", + call_id="call_dispatch_pending", + arguments='{"value":"safe"}', + ) + first_task = asyncio.create_task(session._handle_tool_call(first_event)) + await dispatch_started.wait() + + changed_event = RealtimeModelToolCallEvent( + name="test_function", + call_id=first_event.call_id, + arguments='{"value":"changed"}', + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) + + release_dispatch.set() + await first_task + + assert executed == ['{"value":"safe"}'] + + @pytest.mark.parametrize( + ("failure_stage", "error_type"), + [ + ("dispatch", RuntimeError), + ("dispatch", asyncio.CancelledError), + ("enablement", RuntimeError), + ("enablement", asyncio.CancelledError), + ], + ) + @pytest.mark.asyncio + async def test_changed_realtime_call_id_fails_after_dispatch_await_failure( + self, + mock_model: Any, + failure_stage: str, + error_type: type[BaseException], + ) -> None: + """A failed dispatch await retains the provisional invocation identity.""" + invoke_tool = AsyncMock(return_value="ok") + tool = FunctionTool( + name="test_function", + description="test", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + stage_calls = 0 + + async def fail_stage(*_args: Any, **_kwargs: Any) -> Any: + nonlocal stage_calls + stage_calls += 1 + raise error_type() + + if failure_stage == "dispatch": + session._resolve_dispatch_snapshot = fail_stage # type: ignore[method-assign] + else: + session._filter_enabled_dispatch_snapshot = fail_stage # type: ignore[method-assign] + + first_event = RealtimeModelToolCallEvent( + name="test_function", + call_id="call_failed_dispatch", + arguments='{"value":"safe"}', + ) + with pytest.raises(error_type): + await session._handle_tool_call(first_event) + + changed_event = RealtimeModelToolCallEvent( + name=first_event.name, + call_id=first_event.call_id, + arguments='{"value":"changed"}', + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) + + assert stage_calls == 1 + invoke_tool.assert_not_called() + + @pytest.mark.asyncio + async def test_namespaced_realtime_call_rebinds_active_identity_after_resolution( + self, mock_model + ) -> None: + """Resolved routing replaces the provisional identity before later awaits.""" + approval_started = asyncio.Event() + release_approval = asyncio.Event() + executed: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], _call_id: str) -> bool: + approval_started.set() + await release_approval.wait() + return False + + async def invoke_tool(_ctx: ToolContext[Any], arguments: str) -> str: + executed.append(arguments) + return "ok" + + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[ + FunctionTool( + name="lookup_account", + description="Look up an account.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + ], + )[0] + agent = RealtimeAgent(name="agent", tools=[namespaced_tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + event = RealtimeModelToolCallEvent( + name="lookup_account", + call_id="call_namespaced_active", + arguments="{}", + ) + first_task = asyncio.create_task(session._handle_tool_call(event)) + approval_wait = asyncio.create_task(approval_started.wait()) + done, _ = await asyncio.wait( + {first_task, approval_wait}, + return_when=asyncio.FIRST_COMPLETED, + ) + if first_task in done: + approval_wait.cancel() + await first_task + + await session._handle_tool_call(event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call( + RealtimeModelToolCallEvent( + name=event.name, + call_id=event.call_id, + arguments='{"changed":true}', + ) + ) + + release_approval.set() + await first_task + + assert executed == ["{}"] + @pytest.mark.asyncio async def test_approve_pending_tool_call_runs_tool( self, mock_model, mock_agent, mock_function_tool @@ -3264,7 +3592,7 @@ async def invoke_duplicate_tool(_ctx: ToolContext[Any], _arguments: str) -> str: await session._handle_tool_call(tool_call_event) await session.approve_tool_call(tool_call_event.call_id) - assert tool_call_event.call_id in session._active_tool_call_ids + assert tool_call_event.call_id in session._active_tool_invocations await session._handle_tool_call(tool_call_event, agent_snapshot=duplicate_agent) tool_call_tasks = list(session._tool_call_tasks) @@ -3477,6 +3805,60 @@ def fail_formatter(_args): assert message mock_logger.error.assert_called_once_with("%s", "Tool error formatter failed", stacklevel=3) + @pytest.mark.asyncio + async def test_cancelled_rejection_formatter_leaves_invocation_executed( + self, mock_model, mock_agent + ): + formatter_entered = asyncio.Event() + + @function_tool + def approval_tool() -> str: + return "done" + + async def blocking_formatter(_args): + formatter_entered.set() + await asyncio.Event().wait() + return "rejected" + + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"tool_error_formatter": blocking_formatter}, + ) + tool_call = RealtimeModelToolCallEvent( + name=approval_tool.name, + call_id="call_rejected_cancelled", + arguments="{}", + ) + canonical_call = session._build_tool_approval_item( # noqa: SLF001 + approval_tool, + tool_call, + mock_agent, + ).raw_item + lookup_key = get_function_tool_lookup_key_for_tool(approval_tool) + assert session._context_wrapper._tool_invocation_status( # noqa: SLF001 + canonical_call, + tool_lookup_key=lookup_key, + ) == (("function_call", "call_rejected_cancelled"), False, False) + + task = asyncio.create_task( + session._resolve_approval_rejection_message( # noqa: SLF001 + tool=approval_tool, + call_id=tool_call.call_id, + tool_call=canonical_call, + ) + ) + await formatter_entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert session._context_wrapper._tool_invocation_status( # noqa: SLF001 + canonical_call, + tool_lookup_key=lookup_key, + ) == (("function_call", "call_rejected_cancelled"), False, True) + @pytest.mark.asyncio async def test_reject_pending_tool_call_prefers_explicit_message( self, mock_model, mock_agent, mock_function_tool @@ -3568,6 +3950,270 @@ async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: ] assert tool_calls == [] + @pytest.mark.asyncio + async def test_sticky_rejection_does_not_bind_duplicate_call_id_payload( + self, mock_model, mock_agent, mock_function_tool + ): + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession(mock_model, mock_agent, None) + first_call = RealtimeModelToolCallEvent( + name="test_function", call_id="call-sticky-reject", arguments="{}" + ) + changed_call = RealtimeModelToolCallEvent( + name="test_function", + call_id=first_call.call_id, + arguments='{"changed":true}', + ) + + await session._handle_tool_call(first_call) + await session.reject_tool_call(first_call.call_id, always=True) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_call) + + mock_function_tool.on_invoke_tool.assert_not_called() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.asyncio + async def test_changed_completed_non_approval_call_id_fails_before_execution( + self, mock_model, mock_agent, mock_function_tool + ): + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + first_call = RealtimeModelToolCallEvent( + name="test_function", call_id="call-reused", arguments='{"value":"safe"}' + ) + changed_call = RealtimeModelToolCallEvent( + name="test_function", call_id="call-reused", arguments='{"value":"changed"}' + ) + + await session._handle_tool_call(first_call) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_call) + + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.asyncio + async def test_changed_completed_function_call_id_fails_for_handoff_role(self, mock_model): + function_calls: list[str] = [] + + async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: + function_calls.append("function") + return "function result" + + function_tool = FunctionTool( + name="route", + description="Run a function.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_function, + ) + function_agent = RealtimeAgent(name="function", tools=[function_tool]) + target = RealtimeAgent(name="target") + route_name = Handoff.default_tool_name(target) + function_tool.name = route_name + handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) + session = RealtimeSession( + mock_model, + function_agent, + None, + run_config={"async_tool_calls": False}, + ) + event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") + + await session._handle_tool_call(event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(event, agent_snapshot=handoff_agent) + + assert function_calls == ["function"] + + @pytest.mark.asyncio + async def test_async_changed_completed_function_call_id_fails_for_handoff_role( + self, mock_model + ): + function_calls: list[str] = [] + + async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: + function_calls.append("function") + return "function result" + + function_tool = FunctionTool( + name="route", + description="Run a function.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_function, + ) + function_agent = RealtimeAgent(name="function", tools=[function_tool]) + target = RealtimeAgent(name="target") + route_name = Handoff.default_tool_name(target) + function_tool.name = route_name + handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) + session = RealtimeSession(mock_model, function_agent, None) + event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") + + await session.on_event(event) + await asyncio.gather(*list(session._tool_call_tasks)) + session._current_agent = handoff_agent + session._current_dispatch_snapshot = None + await session.on_event(event) + results = await asyncio.gather( + *list(session._tool_call_tasks), + return_exceptions=True, + ) + + assert any(isinstance(result, ModelBehaviorError) for result in results) + assert function_calls == ["function"] + + @pytest.mark.asyncio + async def test_pending_function_output_rejects_handoff_role_reuse(self): + class FailingToolOutputModel(MockRealtimeModel): + async def send_event(self, event): + if isinstance(event, RealtimeModelSendToolOutput): + raise RuntimeError("send failed") + await super().send_event(event) + + function_callback = AsyncMock(return_value="function result") + function_tool = FunctionTool( + name="route", + description="Run a function.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=function_callback, + ) + function_agent = RealtimeAgent(name="function", tools=[function_tool]) + target = RealtimeAgent(name="target") + route_name = Handoff.default_tool_name(target) + function_tool.name = route_name + handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) + session = RealtimeSession( + FailingToolOutputModel(), + function_agent, + None, + run_config={"async_tool_calls": False}, + ) + event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") + + with pytest.raises(RuntimeError, match="send failed"): + await session._handle_tool_call(event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(event, agent_snapshot=handoff_agent) + + function_callback.assert_awaited_once() + + @pytest.mark.parametrize( + "failure", + [RuntimeError("settings failed"), asyncio.CancelledError()], + ids=["failure", "cancellation"], + ) + @pytest.mark.asyncio + async def test_exact_handoff_retry_after_settings_failure_does_not_repeat_callback( + self, + mock_model, + failure: BaseException, + ): + target = RealtimeAgent(name="target") + callback = AsyncMock(return_value=target) + route = Handoff( + tool_name="route", + tool_description="Route to target.", + input_json_schema={}, + on_invoke_handoff=callback, + input_filter=None, + agent_name=target.name, + is_enabled=True, + ) + agent = RealtimeAgent(name="source", handoffs=[route]) + session = RealtimeSession( + mock_model, + agent, + None, + run_config={"async_tool_calls": False}, + ) + event = RealtimeModelToolCallEvent(name="route", call_id="shared", arguments="{}") + + with patch.object( + session, + "_get_updated_model_settings_from_agent", + AsyncMock(side_effect=failure), + ): + with pytest.raises(type(failure)): + await session._handle_tool_call(event) + + with pytest.raises(ModelBehaviorError, match="already executed"): + await session._handle_tool_call(event) + + callback.assert_awaited_once() + + @pytest.mark.asyncio + async def test_async_exact_function_retry_after_serialization_failure_does_not_repeat_callback( + self, + mock_model, + ): + callback = AsyncMock(return_value={"result": "ok"}) + tool = FunctionTool( + name="run_function", + description="Run a function.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=callback, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None) + event = RealtimeModelToolCallEvent( + name=tool.name, + call_id="shared", + arguments="{}", + ) + + with patch( + "agents.realtime.session._serialize_tool_output", + side_effect=RuntimeError("serialization failed"), + ): + await session.on_event(event) + first_results = await asyncio.gather( + *list(session._tool_call_tasks), + return_exceptions=True, + ) + + await session.on_event(event) + retry_results = await asyncio.gather( + *list(session._tool_call_tasks), + return_exceptions=True, + ) + + assert any( + isinstance(result, RuntimeError) and str(result) == "serialization failed" + for result in first_results + ) + assert any(isinstance(result, ModelBehaviorError) for result in retry_results) + callback.assert_awaited_once() + + @pytest.mark.asyncio + async def test_empty_handoff_call_id_fails_before_callback(self, mock_model): + target = RealtimeAgent(name="target") + callback = AsyncMock(return_value=target) + route = Handoff( + tool_name="route", + tool_description="Route to target.", + input_json_schema={}, + on_invoke_handoff=callback, + input_filter=None, + agent_name=target.name, + is_enabled=True, + ) + agent = RealtimeAgent(name="source", handoffs=[route]) + session = RealtimeSession(mock_model, agent, None) + + with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): + await session._handle_tool_call( + RealtimeModelToolCallEvent(name=route.tool_name, call_id="", arguments="{}") + ) + + callback.assert_not_awaited() + @pytest.mark.asyncio async def test_sticky_rejection_skips_dynamic_approval_checker(self, mock_model): checker_calls: list[str] = [] diff --git a/tests/sandbox/capabilities/test_apply_patch_tool.py b/tests/sandbox/capabilities/test_apply_patch_tool.py index a69d34ddf3..0d4b847c00 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -303,6 +303,7 @@ async def test_custom_tool_input_create_update_move_delete(self) -> None: await _execute_custom_tool_call( tool, context_wrapper=context_wrapper, + call_id="call_create", raw_input=("*** Begin Patch\n*** Add File: notes.txt\n+hello\n+world\n*** End Patch\n"), ) assert session.files[Path("/workspace/notes.txt")] == b"hello\nworld" @@ -310,6 +311,7 @@ async def test_custom_tool_input_create_update_move_delete(self) -> None: result = await _execute_custom_tool_call( tool, context_wrapper=context_wrapper, + call_id="call_update", raw_input=( "*** Begin Patch\n" "*** Update File: notes.txt\n" @@ -329,6 +331,7 @@ async def test_custom_tool_input_create_update_move_delete(self) -> None: await _execute_custom_tool_call( tool, context_wrapper=context_wrapper, + call_id="call_delete", raw_input="*** Begin Patch\n*** Delete File: moved.txt\n*** End Patch\n", ) assert Path("/workspace/moved.txt") not in session.files @@ -339,6 +342,7 @@ async def _execute_custom_tool_call( *, context_wrapper: RunContextWrapper[Any], raw_input: str, + call_id: str = "call_apply", ) -> Any: result = await CustomToolAction.execute( agent=Agent(name="patcher", tools=[tool]), @@ -347,7 +351,7 @@ async def _execute_custom_tool_call( tool_call={ "type": "custom_tool_call", "name": "apply_patch", - "call_id": "call_apply", + "call_id": call_id, "input": raw_input, }, ), diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 59a64ff643..55810a5b52 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -2811,7 +2811,7 @@ def approval_tool() -> str: call_id="call_write", ) ], - [get_handoff_tool_call(second)], + [get_handoff_tool_call(second, call_id="handoff_to_second")], [ get_function_tool_call( "read_file", @@ -2825,7 +2825,7 @@ def approval_tool() -> str: second_model.add_multiple_turn_outputs( [ [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")], - [get_handoff_tool_call(first)], + [get_handoff_tool_call(first, call_id="handoff_to_first")], ] ) @@ -4027,7 +4027,7 @@ def approval_tool() -> str: call_id="call_write", ) ], - [get_handoff_tool_call(second)], + [get_handoff_tool_call(second, call_id="handoff_to_second")], ] ) second_model.add_multiple_turn_outputs( @@ -4059,7 +4059,9 @@ def approval_tool() -> str: ) resumed_first.handoffs = [resumed_second] resumed_second.handoffs = [resumed_first] - resumed_second_model.add_multiple_turn_outputs([[get_handoff_tool_call(resumed_first)]]) + resumed_second_model.add_multiple_turn_outputs( + [[get_handoff_tool_call(resumed_first, call_id="handoff_to_first")]] + ) resumed_first_model.add_multiple_turn_outputs( [ [ diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index 96012ac71a..3b97c5d9b7 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -34,6 +34,7 @@ TResponseInputItem, Usage, UserError, + function_tool, tool_namespace, ) from agents._tool_identity import resolve_tool_name_collisions @@ -1564,6 +1565,8 @@ async def test_agent_as_tool_wrapped_hosted_mcp_exact_decision_resumes_run( "type": "mcp_approval_request", "id": "inner-1", "name": "lookup_account", + "server_label": "accounts", + "arguments": "{}", }, }, tool_name="lookup_account", @@ -1705,6 +1708,154 @@ async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedRes assert run_inputs == [resume_state] +@pytest.mark.parametrize("clone_approval_item", [False, True], ids=["exact", "clone"]) +@pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) +def test_agent_as_tool_tool_context_ambiguous_approval_identity_fails_closed( + approve: bool, + clone_approval_item: bool, +) -> None: + """A direct ToolContext decision must not guess between current and nested scopes.""" + agent = Agent(name="Agent") + outer_call = make_function_tool_call("nested_agent_tool", call_id="outer-nested") + current_call = make_function_tool_call("sensitive", call_id="shared") + current_approval = ToolApprovalItem(agent=agent, raw_item=current_call) + nested_approval = ( + ToolApprovalItem(agent=agent, raw_item=current_call.model_copy(deep=True)) + if clone_approval_item + else current_approval + ) + tool_context = ToolContext( + context=None, + tool_name=outer_call.name, + tool_call_id=outer_call.call_id, + tool_arguments=outer_call.arguments, + tool_call=outer_call, + ) + tool_context._tool_invocation_status(current_call) # noqa: SLF001 + + class DummyState: + def __init__(self, nested_context: ToolContext) -> None: + self._context = nested_context + + class DummyPendingResult: + interruptions = [nested_approval] + + def to_state(self) -> DummyState: + return resume_state + + nested_context = ToolContext( + context=None, + tool_name=outer_call.name, + tool_call_id=outer_call.call_id, + tool_arguments=outer_call.arguments, + tool_call=outer_call, + ) + resume_state = DummyState(nested_context) + record_agent_tool_run_result( + outer_call, + cast(Any, DummyPendingResult()), + scope_id=get_agent_tool_state_scope(tool_context), + ) + + with pytest.raises(UserError, match="current run and a nested agent-tool run"): + if approve: + tool_context.approve_tool(current_approval) + else: + tool_context.reject_tool(current_approval) + + assert ( + tool_context.get_approval_status( + "sensitive", + "shared", + existing_pending=current_approval, + ) + is None + ) + assert ( + nested_context.get_approval_status( + "sensitive", + "shared", + existing_pending=nested_approval, + ) + is None + ) + + +@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"]) +@pytest.mark.asyncio +async def test_agent_as_tool_resume_survives_cancellation_after_nested_output_commit( + streamed: bool, +) -> None: + tool_attempts: list[str] = [] + nested_model_waiting = asyncio.Event() + keep_nested_model_waiting = asyncio.Event() + + class BlockingSecondModel(FakeModel): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.calls = 0 + + async def get_response(self, *args: Any, **kwargs: Any) -> ModelResponse: + self.calls += 1 + if self.calls == 2: + nested_model_waiting.set() + await keep_nested_model_waiting.wait() + return await super().get_response(*args, **kwargs) + + @function_tool(needs_approval=True, failure_error_function=None) + async def sensitive() -> str: + tool_attempts.append("ran") + return "inner value" + + inner_model = BlockingSecondModel( + initial_output=[get_function_tool_call("sensitive", "{}", call_id="inner_call")] + ) + inner_model.set_next_output([get_text_message("inner done")]) + inner_agent = Agent(name="inner", model=inner_model, tools=[sensitive]) + nested_tool = inner_agent.as_tool( + tool_name="delegate", + tool_description="Delegate", + ) + outer_model = FakeModel( + initial_output=[ + get_function_tool_call( + "delegate", + '{"input":"hi"}', + call_id="outer_call", + ) + ] + ) + outer_model.set_next_output([get_text_message("outer done")]) + outer_agent = Agent(name="outer", model=outer_model, tools=[nested_tool]) + + async def run_outer(input_value: Any) -> RunResult | RunResultStreaming: + if not streamed: + return await Runner.run(outer_agent, input_value) + result = Runner.run_streamed(outer_agent, input_value) + async for _event in result.stream_events(): + pass + return result + + interrupted = await run_outer("go") + state = interrupted.to_state() + state.approve(interrupted.interruptions[0]) + + resume_task = asyncio.create_task(run_outer(state)) + await nested_model_waiting.wait() + assert tool_attempts == ["ran"] + assert inner_model.calls == 2 + + resume_task.cancel() + with pytest.raises(asyncio.CancelledError): + await resume_task + + result = await run_outer(state) + + assert result.final_output == "outer done" + assert tool_attempts == ["ran"] + assert inner_model.calls == 3 + + @pytest.mark.parametrize( ("approve", "sticky", "legacy_sticky", "expected_followup"), [ @@ -1783,6 +1934,7 @@ def __init__(self) -> None: } } ) + tool_context._allow_legacy_approval_binding_reconstruction = True if approve: tool_context.approve_tool(approval_item, always_approve=sticky) else: @@ -1919,6 +2071,7 @@ def __init__(self) -> None: approved=True, rejected=[], ) + tool_context._allow_legacy_approval_binding_reconstruction = True resume_state = DummyState(nested_context) pending_result = DummyPendingResult() record_agent_tool_run_result(tool_call, cast(Any, pending_result)) diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 750566009b..a580fb2633 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -103,7 +103,7 @@ async def test_non_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], [get_text_message("done")], ] ) @@ -136,11 +136,11 @@ async def test_non_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent @@ -210,11 +210,11 @@ async def test_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent @@ -287,11 +287,11 @@ async def test_structured_output_non_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent @@ -359,11 +359,11 @@ async def test_structured_output_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index b2512b6ab4..ab3aba3124 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -1825,13 +1825,23 @@ async def test_structured_output(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("foo", json.dumps({"bar": "baz"}))], + [ + get_function_tool_call( + "foo", + json.dumps({"bar": "baz"}), + call_id="call_foo", + ) + ], # Second turn: a message and a handoff [get_text_message("a_message"), get_handoff_tool_call(agent_1)], # Third turn: tool call with preamble message [ get_text_message(json.dumps(Foo(bar="preamble"))), - get_function_tool_call("bar", json.dumps({"bar": "baz"})), + get_function_tool_call( + "bar", + json.dumps({"bar": "baz"}), + call_id="call_bar", + ), ], # Fourth turn: structured output [get_final_output_message(json.dumps(Foo(bar="baz")))], @@ -4440,8 +4450,8 @@ async def test_tool_use_behavior_first_output(): # First turn: a message and tool call [ get_text_message("a_message"), - get_function_tool_call("test_tool_one", None), - get_function_tool_call("test_tool_two", None), + get_function_tool_call("test_tool_one", None, call_id="tool-one"), + get_function_tool_call("test_tool_two", None, call_id="tool-two"), ], ] ) @@ -4477,13 +4487,13 @@ async def test_tool_use_behavior_custom_function(): # First turn: a message and tool call [ get_text_message("a_message"), - get_function_tool_call("test_tool_two", None), + get_function_tool_call("test_tool_two", None, call_id="call-tool-two-first"), ], # Second turn: a message and tool call [ get_text_message("a_message"), - get_function_tool_call("test_tool_one", None), - get_function_tool_call("test_tool_two", None), + get_function_tool_call("test_tool_one", None, call_id="call-tool-one"), + get_function_tool_call("test_tool_two", None, call_id="call-tool-two-second"), ], ] ) @@ -4690,9 +4700,19 @@ async def test_conversation_id_only_sends_new_items_multi_turn(): model.add_multiple_turn_outputs( [ # First turn: a message and tool call - [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], + [ + get_text_message("a_message"), + get_function_tool_call( + "test_func", '{"arg": "foo"}', call_id="call-test-func-first" + ), + ], # Second turn: another message and tool call - [get_text_message("b_message"), get_function_tool_call("test_func", '{"arg": "bar"}')], + [ + get_text_message("b_message"), + get_function_tool_call( + "test_func", '{"arg": "bar"}', call_id="call-test-func-second" + ), + ], # Third turn: final text message [get_text_message("done")], ] @@ -4740,9 +4760,19 @@ async def test_conversation_id_only_sends_new_items_multi_turn_streamed(): model.add_multiple_turn_outputs( [ # First turn: a message and tool call - [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], + [ + get_text_message("a_message"), + get_function_tool_call( + "test_func", '{"arg": "foo"}', call_id="call-test-func-first" + ), + ], # Second turn: another message and tool call - [get_text_message("b_message"), get_function_tool_call("test_func", '{"arg": "bar"}')], + [ + get_text_message("b_message"), + get_function_tool_call( + "test_func", '{"arg": "bar"}', call_id="call-test-func-second" + ), + ], # Third turn: final text message [get_text_message("done")], ] @@ -5485,8 +5515,8 @@ async def add_tool() -> str: model.add_multiple_turn_outputs( [ - [get_function_tool_call("add_tool", json.dumps({}))], - [get_function_tool_call("tool2", json.dumps({}))], + [get_function_tool_call("add_tool", json.dumps({}), call_id="call-add-tool")], + [get_function_tool_call("tool2", json.dumps({}), call_id="call-tool-two")], [get_text_message("done")], ] ) @@ -5813,6 +5843,44 @@ async def test_tool() -> str: assert not tool_called # Tool should not have been executed +@pytest.mark.asyncio +async def test_execute_approved_tools_rejects_changed_pending_invocation() -> None: + """A decision for one payload must not authorize a changed interruption.""" + tool_called = False + + async def test_tool(value: str) -> str: + nonlocal tool_called + tool_called = True + return value + + tool = function_tool(test_tool, name_override="test_tool") + _, agent = make_model_and_agent(tools=[tool]) + approved_call = get_function_tool_call( + "test_tool", + '{"value":"safe"}', + call_id="call-shared", + ) + changed_call = get_function_tool_call( + "test_tool", + '{"value":"changed"}', + call_id="call-shared", + ) + assert isinstance(approved_call, ResponseFunctionToolCall) + assert isinstance(changed_call, ResponseFunctionToolCall) + approved_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + changed_item = ToolApprovalItem(agent=agent, raw_item=changed_call) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await run_execute_approved_tools( + agent=agent, + approval_item=changed_item, + approve=None, + mutate_state=lambda state, _item: state.approve(approved_item), + ) + + assert tool_called is False + + @pytest.mark.asyncio async def test_execute_approved_tools_with_rejected_tool_uses_run_level_formatter(): """Rejected tools should prefer RunConfig tool error formatter output.""" @@ -6228,21 +6296,18 @@ async def second_lookup() -> str: @pytest.mark.asyncio -async def test_execute_approved_tools_with_missing_call_id(): - """Test _execute_approved_tools handles tool approvals without call IDs.""" +async def test_execute_approved_tools_rejects_missing_call_id(): + """Test _execute_approved_tools rejects tool approvals without call IDs.""" _, agent = make_model_and_agent() tool_call = {"type": "function_call", "name": "test_tool"} approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) - generated_items = await run_execute_approved_tools( - agent=agent, - approval_item=approval_item, - approve=True, - ) - - assert len(generated_items) == 1 - assert isinstance(generated_items[0], ToolCallOutputItem) - assert "missing call id" in generated_items[0].output.lower() + with pytest.raises(ModelBehaviorError, match="non-empty call ID"): + await run_execute_approved_tools( + agent=agent, + approval_item=approval_item, + approve=True, + ) @pytest.mark.asyncio @@ -6254,7 +6319,12 @@ async def test_tool() -> str: tool = function_tool(test_tool, name_override="test_tool") _, agent = make_model_and_agent(tools=[tool]) - tool_call = {"type": "function_call", "name": "test_tool", "call_id": "call-1"} + tool_call = { + "type": "function_call", + "name": "test_tool", + "call_id": "call-1", + "arguments": "{}", + } approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) generated_items = await run_execute_approved_tools( diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index cc749ea352..36a5695332 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -1036,13 +1036,23 @@ async def test_structured_output(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("foo", json.dumps({"bar": "baz"}))], + [ + get_function_tool_call( + "foo", + json.dumps({"bar": "baz"}), + call_id="call_foo", + ) + ], # Second turn: a message and a handoff [get_text_message("a_message"), get_handoff_tool_call(agent_1)], # Third turn: tool call with preamble message [ get_text_message(json.dumps(Foo(bar="preamble"))), - get_function_tool_call("bar", json.dumps({"bar": "baz"})), + get_function_tool_call( + "bar", + json.dumps({"bar": "baz"}), + call_id="call_bar", + ), ], # Fourth turn: structured output [get_final_output_message(json.dumps(Foo(bar="baz")))], @@ -1882,11 +1892,23 @@ async def test_streaming_events(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("foo", json.dumps({"bar": "baz"}))], + [ + get_function_tool_call( + "foo", + json.dumps({"bar": "baz"}), + call_id="call_foo", + ) + ], # Second turn: a message and a handoff [get_text_message("a_message"), get_handoff_tool_call(agent_1)], # Third turn: tool call - [get_function_tool_call("bar", json.dumps({"bar": "baz"}))], + [ + get_function_tool_call( + "bar", + json.dumps({"bar": "baz"}), + call_id="call_bar", + ) + ], # Fourth turn: structured output [get_final_output_message(json.dumps(Foo(bar="baz")))], ] @@ -1978,8 +2000,8 @@ async def add_tool() -> str: model.add_multiple_turn_outputs( [ - [get_function_tool_call("add_tool", json.dumps({}))], - [get_function_tool_call("tool2", json.dumps({}))], + [get_function_tool_call("add_tool", json.dumps({}), call_id="call-add-tool")], + [get_function_tool_call("tool2", json.dumps({}), call_id="call-tool-two")], [get_text_message("done")], ] ) diff --git a/tests/test_apply_patch_tool.py b/tests/test_apply_patch_tool.py index 1e66312c4a..569fb09632 100644 --- a/tests/test_apply_patch_tool.py +++ b/tests/test_apply_patch_tool.py @@ -65,6 +65,13 @@ class DummyApplyPatchCall: call_id: str operation: dict[str, Any] + def model_dump(self, **_kwargs: Any) -> dict[str, Any]: + return { + "type": self.type, + "call_id": self.call_id, + "operation": self.operation, + } + class RecordingEditor: def __init__(self) -> None: diff --git a/tests/test_example_workflows.py b/tests/test_example_workflows.py index bab6f8eb74..757478b416 100644 --- a/tests/test_example_workflows.py +++ b/tests/test_example_workflows.py @@ -1075,7 +1075,11 @@ def european_enabled(ctx: RunContextWrapper[AppContext], _agent: AgentBase) -> b orchestrator_model = FakeModel() # Build tool calls only for expected tools to avoid missing-tool errors. tool_calls = [ - get_function_tool_call(tool_name, json.dumps({"input": "Hi"})) + get_function_tool_call( + tool_name, + json.dumps({"input": "Hi"}), + call_id=f"call_{tool_name}", + ) for tool_name in sorted(expected_tools) ] orchestrator_model.add_multiple_turn_outputs([tool_calls, [get_text_message("Done")]]) @@ -1139,8 +1143,20 @@ async def test_agents_as_tools_orchestrator_runs_multiple_translations() -> None orchestrator_model = FakeModel() orchestrator_model.add_multiple_turn_outputs( [ - [get_function_tool_call("translate_to_spanish", json.dumps({"input": "Hi"}))], - [get_function_tool_call("translate_to_french", json.dumps({"input": "Hi"}))], + [ + get_function_tool_call( + "translate_to_spanish", + json.dumps({"input": "Hi"}), + call_id="translate_spanish", + ) + ], + [ + get_function_tool_call( + "translate_to_french", + json.dumps({"input": "Hi"}), + call_id="translate_french", + ) + ], [get_text_message("Summary complete")], ] ) diff --git a/tests/test_global_hooks.py b/tests/test_global_hooks.py index f4ec6dfe1f..0b4bada0c0 100644 --- a/tests/test_global_hooks.py +++ b/tests/test_global_hooks.py @@ -130,11 +130,11 @@ async def test_non_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent @@ -207,11 +207,11 @@ async def test_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent @@ -287,11 +287,11 @@ async def test_structured_output_non_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent @@ -363,11 +363,11 @@ async def test_structured_output_streamed_agent_hooks(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message, another tool call, and a handoff [ get_text_message("a_message"), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2"), get_handoff_tool_call(agent_1), ], # Third turn: a message and a handoff back to the orig agent diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index 7f936d4ca0..483346c8b9 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -21,6 +21,7 @@ from agents import ( Agent, + AgentBase, ApplyPatchTool, ComputerTool, CustomTool, @@ -32,6 +33,7 @@ ToolApprovalItem, ToolExecutionConfig, function_tool, + handoff, tool_namespace, ) from agents._public_agent import set_public_agent @@ -47,6 +49,7 @@ ) from agents.lifecycle import RunHooks from agents.run import RunConfig +from agents.run_context import RunContextWrapper from agents.run_internal import run_loop from agents.run_internal.agent_bindings import bind_execution_agent, bind_public_agent from agents.run_internal.run_loop import ( @@ -56,6 +59,7 @@ ToolRunApplyPatchCall, ToolRunComputerAction, ToolRunFunction, + ToolRunHandoff, ToolRunMCPApprovalRequest, ToolRunShellCall, extract_tool_call_id, @@ -66,13 +70,16 @@ from agents.run_internal.tool_planning import ( _collect_runs_by_approval, _select_function_tool_runs_for_resume, + execute_mcp_approval_requests, ) from agents.run_state import RunState as RunStateClass from agents.tool import FunctionTool, HostedMCPTool from agents.tool_guardrails import ( ToolGuardrailFunctionOutput, ToolInputGuardrailData, + ToolOutputGuardrailData, tool_input_guardrail, + tool_output_guardrail, ) from agents.usage import Usage @@ -369,8 +376,63 @@ async def inner_hitl_tool() -> str: @pytest.mark.asyncio -async def test_nested_agent_tool_interruptions_dont_collide_on_duplicate_call_ids() -> None: - """Nested agent tool interruptions should survive duplicate outer call IDs.""" +async def test_changed_nested_parent_fails_before_tool_inventory_callbacks() -> None: + enabled_calls: list[str] = [] + + async def enabled(_context: RunContextWrapper[Any], agent: AgentBase[Any]) -> bool: + enabled_calls.append(agent.name) + return True + + @function_tool(needs_approval=True) + async def inner_hitl_tool() -> str: + return "ok" + + @function_tool(is_enabled=enabled) + async def observer() -> str: + return "unused" + + inner_model = FakeModel() + inner_model.add_multiple_turn_outputs( + [[make_function_tool_call(inner_hitl_tool.name, call_id="inner-1")]] + ) + inner_agent = Agent(name="Inner", model=inner_model, tools=[inner_hitl_tool]) + agent_tool = inner_agent.as_tool( + tool_name="inner_agent_tool", + tool_description="Inner agent tool with HITL", + needs_approval=True, + ) + outer_model = FakeModel( + initial_output=[ + make_function_tool_call( + agent_tool.name, + call_id="outer-1", + arguments='{"input":"safe"}', + ) + ] + ) + outer_agent = Agent(name="Outer", model=outer_model, tools=[agent_tool, observer]) + + first = await Runner.run(outer_agent, "start") + first_state = first.to_state() + first_state.approve(first.interruptions[0]) + second = await Runner.run(outer_agent, first_state) + assert second.interruptions[0].tool_name == inner_hitl_tool.name + + resume_state = second.to_state() + assert resume_state._last_processed_response is not None + enabled_calls.clear() + resume_state._last_processed_response.functions[0].tool_call.arguments = '{"input":"evil"}' + resume_state.approve(second.interruptions[0]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(outer_agent, resume_state) + + assert enabled_calls == [] + + +@pytest.mark.asyncio +async def test_nested_agent_tool_interruptions_remain_distinct_across_outer_calls() -> None: + """Nested agent tool interruptions should survive multiple outer calls.""" @function_tool(needs_approval=True) async def inner_hitl_tool() -> str: @@ -397,10 +459,10 @@ async def inner_hitl_tool() -> str: [ [ make_function_tool_call( - agent_tool.name, call_id="outer-dup", arguments='{"input":"a"}' + agent_tool.name, call_id="outer-a", arguments='{"input":"a"}' ), make_function_tool_call( - agent_tool.name, call_id="outer-dup", arguments='{"input":"b"}' + agent_tool.name, call_id="outer-b", arguments='{"input":"b"}' ), ] ] @@ -698,8 +760,8 @@ async def test_deserialize_interruptions_preserve_mcp_tools( @pytest.mark.asyncio -async def test_hosted_mcp_approval_matches_unknown_tool_key() -> None: - """Approved hosted MCP interruptions should resume even when the tool name is missing.""" +async def test_hosted_mcp_approval_with_unknown_legacy_identity_requires_reapproval() -> None: + """Incomplete legacy MCP approvals cannot receive an authorization decision.""" agent = make_agent() context_wrapper = make_context_wrapper() @@ -711,51 +773,8 @@ async def test_hosted_mcp_approval_matches_unknown_tool_key() -> None: include_name=False, use_call_id=False, ) - context_wrapper.approve_tool(approval_item) - - class DummyMcpTool: - on_approval_request: Any = None - - processed_response = ProcessedResponse( - new_items=[], - handoffs=[], - functions=[], - computer_actions=[], - local_shell_calls=[], - shell_calls=[], - apply_patch_calls=[], - tools_used=[], - mcp_approval_requests=[ - ToolRunMCPApprovalRequest( - request_item=McpApprovalRequest( - id="mcp-123", - type="mcp_approval_request", - server_label="test_server", - arguments="{}", - name="hosted_mcp", - ), - mcp_tool=cast(Any, DummyMcpTool()), - ) - ], - interruptions=[], - ) - - result = await _resolve_interrupted_turn( - agent=agent, - original_input="test", - original_pre_step_items=[approval_item], - new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), - processed_response=processed_response, - hooks=RunHooks(), - context_wrapper=context_wrapper, - run_config=RunConfig(), - run_state=None, - ) - - assert any( - isinstance(item, MCPApprovalResponseItem) and item.raw_item.get("approve") is True - for item in result.new_step_items - ), "Approved hosted MCP call should emit an approval response" + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + context_wrapper.approve_tool(approval_item) @pytest.mark.asyncio @@ -1092,6 +1111,137 @@ async def get_current_timestamp() -> str: assert tool_calls == ["called"] +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_nested_agent_tool_continuation_runs_outer_callbacks_once(streamed: bool) -> None: + """Nested resume re-enters only the saved agent run, not the outer callback pipeline.""" + nested_model, nested_agent = make_model_and_agent(name="nested_agent") + inner_calls: list[str] = [] + counts = { + "input_guardrail": 0, + "start": 0, + "output_guardrail": 0, + "custom_output": 0, + "extractor": 0, + "end": 0, + } + + @function_tool(needs_approval=True) + async def inner_tool() -> str: + inner_calls.append("called") + return "inner output" + + nested_agent.tools = [inner_tool] + nested_model.add_multiple_turn_outputs( + [ + [ + make_function_tool_call( + "inner_tool", + call_id=f"inner-call-{streamed}", + ) + ], + [get_text_message("nested done")], + ] + ) + + @tool_input_guardrail + def track_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + counts["input_guardrail"] += 1 + return ToolGuardrailFunctionOutput.allow() + + @tool_output_guardrail + def track_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + counts["output_guardrail"] += 1 + return ToolGuardrailFunctionOutput.allow() + + def extract_custom_data(_context: Any) -> dict[str, Any]: + counts["extractor"] += 1 + return {"nested": True} + + async def extract_custom_output(result: Any) -> str: + counts["custom_output"] += 1 + return cast(str, result.final_output) + + outer_tool = nested_agent.as_tool( + tool_name="delegate", + tool_description="Delegate to the nested agent", + custom_output_extractor=extract_custom_output, + ) + outer_tool.tool_input_guardrails = [track_input] + outer_tool.tool_output_guardrails = [track_output] + outer_tool.custom_data_extractor = extract_custom_data + + outer_model = FakeModel() + outer_model.add_multiple_turn_outputs( + [ + [ + make_function_tool_call( + "delegate", + call_id=f"outer-call-{streamed}", + arguments='{"input":"hello"}', + ) + ], + [get_text_message("outer done")], + ] + ) + outer_agent = Agent(name="outer_agent", model=outer_model, tools=[outer_tool]) + + class CountingHooks(RunHooks[Any]): + async def on_tool_start( + self, + _context: Any, + _agent: Agent[Any], + tool: Any, + ) -> None: + if tool.name == "delegate": + counts["start"] += 1 + + async def on_tool_end( + self, + _context: Any, + _agent: Agent[Any], + tool: Any, + _result: object, + ) -> None: + if tool.name == "delegate": + counts["end"] += 1 + + hooks = CountingHooks() + + async def run(input_value: Any) -> Any: + if not streamed: + return await Runner.run(outer_agent, input_value, hooks=hooks) + result = Runner.run_streamed(outer_agent, input_value, hooks=hooks) + async for _event in result.stream_events(): + pass + return result + + interrupted = await run("start") + assert interrupted.interruptions + assert counts["input_guardrail"] <= 1 + assert counts["start"] <= 1 + assert counts["output_guardrail"] == 0 + assert counts["custom_output"] == 0 + assert counts["extractor"] == 0 + assert counts["end"] == 0 + + state = interrupted.to_state() + state.approve(state.get_interruptions()[0]) + restored = await RunState.from_json(outer_agent, state.to_json()) + final = await run(restored) + + assert final.final_output == "outer done" + assert inner_calls == ["called"] + assert counts == { + "input_guardrail": 1, + "start": 1, + "output_guardrail": 1, + "custom_output": 1, + "extractor": 1, + "end": 1, + } + + @pytest.mark.asyncio async def test_resume_rebuilds_function_runs_from_pending_approvals() -> None: """Resuming with only pending approvals should reconstruct and run function calls.""" @@ -1464,6 +1614,56 @@ async def _record_rejection( assert rejections == [tool_call.call_id] +@pytest.mark.asyncio +async def test_resume_rejects_changed_handoff_under_approved_function_call_id() -> None: + """A resumed handoff must match the invocation that received approval.""" + target = Agent(name="target") + route_handoff = handoff(target, tool_name_override="route") + agent = Agent(name="agent", handoffs=[route_handoff]) + approved_call = make_function_tool_call( + "route", + call_id="call-shared", + arguments='{"destination":"safe"}', + ) + changed_call = make_function_tool_call( + "route", + call_id="call-shared", + arguments='{"destination":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call, tool_name="route") + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(approval_item, always_approve=True) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[ToolRunHandoff(handoff=route_handoff, tool_call=changed_call)], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _resolve_interrupted_turn( + agent=agent, + original_input="resume handoff", + original_pre_step_items=[approval_item], + new_response=ModelResponse( + output=[changed_call], + usage=Usage(), + response_id="resp", + ), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=make_state_with_interruptions(agent, [approval_item]), + ) + + @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) @pytest.mark.asyncio async def test_execute_path_prefers_decision_resolved_during_rejecting_guardrail( @@ -1568,6 +1768,53 @@ async def sensitive(value: str) -> str: ) +@pytest.mark.asyncio +async def test_resume_checkpoints_tool_output_before_tool_use_behavior_failure() -> None: + """A failed post-tool callback must leave the exact output replayable without reexecution.""" + executions: list[str] = [] + + @function_tool(needs_approval=True) + async def sensitive(value: str) -> str: + executions.append(value) + return f"ran:{value}" + + def failing_behavior(_ctx: Any, _results: Any) -> Any: + raise RuntimeError("tool use behavior failed") + + model = FakeModel() + agent = Agent( + name="agent", + model=model, + tools=[sensitive], + tool_use_behavior=failing_behavior, + ) + model.add_multiple_turn_outputs( + [ + [make_function_tool_call(sensitive.name, call_id="call-1", arguments='{"value":"x"}')], + [get_text_message("done")], + ] + ) + + first = await Runner.run(agent, "hello") + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(RuntimeError, match="tool use behavior failed"): + await Runner.run(agent, state) + + assert executions == ["x"] + assert any( + isinstance(item, ToolCallOutputItem) and item.output == "ran:x" + for item in state._generated_items + ) + + agent.tool_use_behavior = "run_llm_again" + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert executions == ["x"] + + @pytest.mark.parametrize("tool_kind", ["function", "shell", "custom", "apply_patch"]) @pytest.mark.asyncio async def test_execute_path_honors_sticky_rejection_before_checker(tool_kind: str) -> None: @@ -1711,6 +1958,120 @@ async def invoke_custom(_ctx: Any, _raw: str) -> str: assert executed == [] +@pytest.mark.parametrize("tool_kind", ["shell", "custom", "apply_patch"]) +@pytest.mark.asyncio +async def test_tool_execution_rejects_changed_approval_recorded_while_policy_waits( + tool_kind: str, +) -> None: + """A concurrent decision for changed content must not authorize the waiting call.""" + checker_started = asyncio.Event() + release_checker = asyncio.Event() + executed: list[str] = [] + context_wrapper = make_context_wrapper() + tool: Any + current_raw: Any + changed_raw: Any + execution_task: asyncio.Task[RunItem] + + async def needs_approval(_ctx: Any, _payload: Any, _call_id: str) -> bool: + checker_started.set() + await release_checker.wait() + return True + + if tool_kind == "shell": + + def execute_shell(_request: Any) -> str: + executed.append("shell") + return "should-not-run" + + tool = ShellTool(executor=execute_shell, needs_approval=needs_approval) + agent = Agent(name="agent", tools=[tool]) + current_raw = cast(dict[str, Any], make_shell_call("call-shared", commands=["safe"])) + changed_raw = cast(dict[str, Any], make_shell_call("call-shared", commands=["changed"])) + execution_task = asyncio.create_task( + ShellAction.execute( + agent=agent, + call=ToolRunShellCall(tool_call=current_raw, shell_tool=tool), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + ) + elif tool_kind == "custom": + + async def invoke_custom(_ctx: Any, _raw: str) -> str: + executed.append("custom") + return "should-not-run" + + tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke_custom, + format={"type": "text"}, + needs_approval=needs_approval, + ) + agent = Agent(name="agent", tools=[tool]) + current_raw = ResponseCustomToolCall( + type="custom_tool_call", + name=tool.name, + call_id="call-shared", + input="safe", + ) + changed_raw = ResponseCustomToolCall( + type="custom_tool_call", + name=tool.name, + call_id="call-shared", + input="changed", + ) + execution_task = asyncio.create_task( + CustomToolAction.execute( + agent=agent, + call=ToolRunCustom(tool_call=current_raw, custom_tool=tool), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + ) + else: + editor = RecordingEditor() + tool = ApplyPatchTool(editor=editor, needs_approval=needs_approval) + agent = Agent(name="agent", tools=[tool]) + current_raw = { + "type": "apply_patch_call", + "call_id": "call-shared", + "operation": {"type": "delete_file", "path": "safe.txt"}, + } + changed_raw = { + "type": "apply_patch_call", + "call_id": "call-shared", + "operation": {"type": "delete_file", "path": "changed.txt"}, + } + execution_task = asyncio.create_task( + ApplyPatchAction.execute( + agent=agent, + call=ToolRunApplyPatchCall(tool_call=current_raw, apply_patch_tool=tool), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + ) + + try: + await asyncio.wait_for(checker_started.wait(), timeout=1) + context_wrapper.approve_tool( + ToolApprovalItem(agent=agent, raw_item=changed_raw, tool_name=tool.name) + ) + release_checker.set() + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await execution_task + finally: + release_checker.set() + + assert executed == [] + if tool_kind == "apply_patch": + assert editor.operations == [] + + @pytest.mark.asyncio async def test_collect_runs_by_approval_skips_checker_when_status_resolved() -> None: """Approved/rejected shell calls must not invoke needs_approval_checker. @@ -2507,6 +2868,100 @@ async def test_resume_skips_shell_calls_with_existing_output() -> None: assert not result.new_step_items, "Shell call should not run when output already exists" +@pytest.mark.asyncio +async def test_resume_validates_changed_shell_before_sibling_approval_callback() -> None: + """Changed completed calls must fail before sibling approval callbacks run.""" + checker_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _args: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + return False + + @function_tool(needs_approval=needs_approval) + async def sibling_tool() -> str: + return "should-not-run" + + shell_tool = ShellTool(executor=lambda _request: "should-not-run", needs_approval=True) + enabled_calls: list[str] = [] + target = Agent(name="target") + + def handoff_is_enabled(_ctx: Any, _agent: Agent[Any]) -> bool: + enabled_calls.append("handoff") + return True + + agent = Agent( + name="agent", + tools=[sibling_tool, shell_tool], + handoffs=[handoff(target, is_enabled=handoff_is_enabled)], + ) + context_wrapper = make_context_wrapper() + approved_shell_call = cast( + dict[str, Any], + make_shell_call("call-reused", commands=["echo safe"], status="completed"), + ) + changed_shell_call = cast( + dict[str, Any], + make_shell_call("call-reused", commands=["echo changed"], status="completed"), + ) + context_wrapper.approve_tool( + ToolApprovalItem( + agent=agent, + raw_item=approved_shell_call, + tool_name=shell_tool.name, + ) + ) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[ + ToolRunFunction( + tool_call=make_function_tool_call(sibling_tool.name, call_id="call-sibling"), + function_tool=sibling_tool, + ) + ], + computer_actions=[], + local_shell_calls=[], + shell_calls=[ + ToolRunShellCall(tool_call=changed_shell_call, shell_tool=shell_tool), + ], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + original_pre_step_items = [ + ToolCallOutputItem( + agent=agent, + raw_item=cast( + dict[str, Any], + { + "type": "shell_call_output", + "call_id": "call-reused", + "status": "completed", + "output": "prior run", + }, + ), + output="prior run", + ) + ] + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _resolve_interrupted_turn( + agent=agent, + original_input="resume run", + original_pre_step_items=cast(list[RunItem], original_pre_step_items), + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=None, + ) + + assert checker_calls == [] + assert enabled_calls == [] + + @pytest.mark.asyncio async def test_resume_keeps_approved_shell_outputs_with_pending_interruptions() -> None: """Approved shell outputs should be emitted even when other approvals are still pending.""" @@ -2631,6 +3086,84 @@ async def test_resume_executes_pending_computer_actions() -> None: assert isinstance(result.next_step, NextStepRunAgain) +@pytest.mark.asyncio +async def test_resume_checkpoints_computer_output_before_custom_data_failure() -> None: + """A failed extractor must not make a completed computer side effect retryable.""" + + computer = TrackingComputer() + + def fail_custom_data(_context: Any) -> dict[str, Any]: + raise RuntimeError("custom data failed") + + computer_tool = ComputerTool( + computer=computer, + custom_data_extractor=fail_custom_data, + ) + _model, agent = make_model_and_agent(tools=[computer_tool]) + computer_call = ResponseComputerToolCall( + type="computer_call", + id="comp_checkpoint", + call_id="comp_checkpoint", + status="in_progress", + action=ActionScreenshot(type="screenshot"), + pending_safety_checks=[], + ) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[ + ToolRunComputerAction(tool_call=computer_call, computer_tool=computer_tool) + ], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[computer_tool.name], + mcp_approval_requests=[], + interruptions=[], + ) + context_wrapper = make_context_wrapper() + run_state = make_state_with_interruptions(agent, []) + run_state._context = context_wrapper + + with pytest.raises(RuntimeError, match="custom data failed"): + await _resolve_interrupted_turn( + agent=agent, + original_input="resume computer", + original_pre_step_items=[], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=run_state, + ) + + checkpointed_items = list(run_state._generated_items) + assert len(checkpointed_items) == 1 + assert isinstance(checkpointed_items[0], ToolCallOutputItem) + assert checkpointed_items[0].call_id == "comp_checkpoint" + assert checkpointed_items[0].custom_data is None + + computer_tool.custom_data_extractor = None + resumed = await _resolve_interrupted_turn( + agent=agent, + original_input="resume computer", + original_pre_step_items=checkpointed_items, + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + run_state=run_state, + ) + + assert computer.calls == ["screenshot"] + assert resumed.new_step_items == [] + assert run_state._generated_items == checkpointed_items + assert isinstance(resumed.next_step, NextStepRunAgain) + + @pytest.mark.asyncio async def test_resume_skips_computer_actions_with_existing_output() -> None: """Computer actions with persisted output should not execute again when resuming.""" @@ -3016,3 +3549,157 @@ def __init__(self) -> None: for item in result.new_step_items ), "MCP callback approvals should emit approval responses" assert isinstance(result.next_step, NextStepRunAgain) + + +@pytest.mark.asyncio +async def test_mcp_callback_exact_retry_reuses_stored_decision() -> None: + """An exact uncommitted MCP retry must not invoke the approval callback twice.""" + callback_calls: list[str] = [] + agent = make_agent() + context_wrapper = make_context_wrapper() + + class DummyMcpTool: + def on_approval_request(self, request: Any) -> dict[str, Any]: + callback_calls.append(request.data.id) + return {"approve": True, "reason": "ok"} + + approval_request = ToolRunMCPApprovalRequest( + request_item=McpApprovalRequest( + id="mcp-callback-retry", + type="mcp_approval_request", + server_label="server", + arguments="{}", + name="hosted_mcp", + ), + mcp_tool=cast(HostedMCPTool, DummyMcpTool()), + ) + + first = await execute_mcp_approval_requests( + agent=agent, + approval_requests=[approval_request], + context_wrapper=context_wrapper, + ) + second = await execute_mcp_approval_requests( + agent=agent, + approval_requests=[approval_request], + context_wrapper=context_wrapper, + ) + + assert callback_calls == ["mcp-callback-retry"] + responses = [item for item in [*first, *second] if isinstance(item, MCPApprovalResponseItem)] + assert [item.raw_item["approve"] for item in responses] == [True, True] + + +@pytest.mark.asyncio +async def test_mcp_callback_failure_is_not_retried_for_same_request() -> None: + """A callback that started without a committed response fails closed on retry.""" + callback_calls: list[str] = [] + agent = make_agent() + context_wrapper = make_context_wrapper() + + class DummyMcpTool: + def on_approval_request(self, request: Any) -> dict[str, Any]: + callback_calls.append(request.data.id) + raise RuntimeError("callback failed") + + approval_request = ToolRunMCPApprovalRequest( + request_item=McpApprovalRequest( + id="mcp-callback-failure", + type="mcp_approval_request", + server_label="server", + arguments="{}", + name="hosted_mcp", + ), + mcp_tool=cast(HostedMCPTool, DummyMcpTool()), + ) + + with pytest.raises(RuntimeError, match="callback failed"): + await execute_mcp_approval_requests( + agent=agent, + approval_requests=[approval_request], + context_wrapper=context_wrapper, + ) + with pytest.raises(ModelBehaviorError, match="already ran"): + await execute_mcp_approval_requests( + agent=agent, + approval_requests=[approval_request], + context_wrapper=context_wrapper, + ) + + assert callback_calls == ["mcp-callback-failure"] + + +@pytest.mark.asyncio +async def test_mcp_callback_exact_siblings_invoke_callback_once() -> None: + """Exact same-ID MCP siblings must share one approval callback result.""" + callback_calls: list[str] = [] + agent = make_agent() + context_wrapper = make_context_wrapper() + + class DummyMcpTool: + async def on_approval_request(self, request: Any) -> dict[str, Any]: + callback_calls.append(request.data.id) + await asyncio.sleep(0) + return {"approve": True, "reason": "ok"} + + mcp_tool = cast(HostedMCPTool, DummyMcpTool()) + approval_requests = [ + ToolRunMCPApprovalRequest( + request_item=McpApprovalRequest( + id="mcp-callback-sibling", + type="mcp_approval_request", + server_label="server", + arguments="{}", + name="hosted_mcp", + ), + mcp_tool=mcp_tool, + ) + for _ in range(2) + ] + + responses = await execute_mcp_approval_requests( + agent=agent, + approval_requests=approval_requests, + context_wrapper=context_wrapper, + ) + + assert callback_calls == ["mcp-callback-sibling"] + assert len(responses) == 1 + + +@pytest.mark.asyncio +async def test_mcp_callback_changed_same_id_siblings_fail_before_callbacks() -> None: + """Changed same-ID MCP siblings must fail before invoking approval callbacks.""" + callback_calls: list[str] = [] + agent = make_agent() + context_wrapper = make_context_wrapper() + + class DummyMcpTool: + async def on_approval_request(self, request: Any) -> dict[str, Any]: + callback_calls.append(request.data.arguments) + await asyncio.sleep(0) + return {"approve": True, "reason": "ok"} + + mcp_tool = cast(HostedMCPTool, DummyMcpTool()) + approval_requests = [ + ToolRunMCPApprovalRequest( + request_item=McpApprovalRequest( + id="mcp-callback-sibling", + type="mcp_approval_request", + server_label="server", + arguments=arguments, + name="hosted_mcp", + ), + mcp_tool=mcp_tool, + ) + for arguments in ('{"q":1}', '{"q":2}') + ] + + with pytest.raises(ModelBehaviorError, match="reused an approval-gated tool call ID"): + await execute_mcp_approval_requests( + agent=agent, + approval_requests=approval_requests, + context_wrapper=context_wrapper, + ) + + assert callback_calls == [] diff --git a/tests/test_max_turns.py b/tests/test_max_turns.py index 7e6de97001..e192b14e83 100644 --- a/tests/test_max_turns.py +++ b/tests/test_max_turns.py @@ -41,11 +41,11 @@ async def test_non_streamed_max_turns(): model.add_multiple_turn_outputs( [ - [get_text_message("1"), get_function_tool_call("some_function", func_output)], - [get_text_message("2"), get_function_tool_call("some_function", func_output)], - [get_text_message("3"), get_function_tool_call("some_function", func_output)], - [get_text_message("4"), get_function_tool_call("some_function", func_output)], - [get_text_message("5"), get_function_tool_call("some_function", func_output)], + [get_text_message("1"), get_function_tool_call("some_function", func_output, "1")], + [get_text_message("2"), get_function_tool_call("some_function", func_output, "2")], + [get_text_message("3"), get_function_tool_call("some_function", func_output, "3")], + [get_text_message("4"), get_function_tool_call("some_function", func_output, "4")], + [get_text_message("5"), get_function_tool_call("some_function", func_output, "5")], ] ) with pytest.raises(MaxTurnsExceeded): @@ -65,10 +65,10 @@ async def test_non_streamed_max_turns_none_disables_limit(): model.add_multiple_turn_outputs( [ - [get_text_message("1"), get_function_tool_call("some_function", func_output)], - [get_text_message("2"), get_function_tool_call("some_function", func_output)], - [get_text_message("3"), get_function_tool_call("some_function", func_output)], - [get_text_message("4"), get_function_tool_call("some_function", func_output)], + [get_text_message("1"), get_function_tool_call("some_function", func_output, "1")], + [get_text_message("2"), get_function_tool_call("some_function", func_output, "2")], + [get_text_message("3"), get_function_tool_call("some_function", func_output, "3")], + [get_text_message("4"), get_function_tool_call("some_function", func_output, "4")], [get_text_message("done")], ] ) @@ -93,23 +93,23 @@ async def test_streamed_max_turns(): [ [ get_text_message("1"), - get_function_tool_call("some_function", func_output), + get_function_tool_call("some_function", func_output, "1"), ], [ get_text_message("2"), - get_function_tool_call("some_function", func_output), + get_function_tool_call("some_function", func_output, "2"), ], [ get_text_message("3"), - get_function_tool_call("some_function", func_output), + get_function_tool_call("some_function", func_output, "3"), ], [ get_text_message("4"), - get_function_tool_call("some_function", func_output), + get_function_tool_call("some_function", func_output, "4"), ], [ get_text_message("5"), - get_function_tool_call("some_function", func_output), + get_function_tool_call("some_function", func_output, "5"), ], ] ) @@ -131,10 +131,10 @@ async def test_streamed_max_turns_none_disables_limit(): model.add_multiple_turn_outputs( [ - [get_text_message("1"), get_function_tool_call("some_function", func_output)], - [get_text_message("2"), get_function_tool_call("some_function", func_output)], - [get_text_message("3"), get_function_tool_call("some_function", func_output)], - [get_text_message("4"), get_function_tool_call("some_function", func_output)], + [get_text_message("1"), get_function_tool_call("some_function", func_output, "1")], + [get_text_message("2"), get_function_tool_call("some_function", func_output, "2")], + [get_text_message("3"), get_function_tool_call("some_function", func_output, "3")], + [get_text_message("4"), get_function_tool_call("some_function", func_output, "4")], [get_text_message("done")], ] ) diff --git a/tests/test_responses.py b/tests/test_responses.py index 944fba596f..fbb38e4072 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -80,10 +80,14 @@ def get_function_tool_call( def get_handoff_tool_call( - to_agent: Agent[Any], override_name: str | None = None, args: str | None = None + to_agent: Agent[Any], + override_name: str | None = None, + args: str | None = None, + *, + call_id: str | None = None, ) -> ResponseOutputItem: name = override_name or Handoff.default_tool_name(to_agent) - return get_function_tool_call(name, args) + return get_function_tool_call(name, args, call_id=call_id or f"handoff_{to_agent.name}") def get_final_output_message(args: str) -> ResponseOutputItem: diff --git a/tests/test_run_context_approvals.py b/tests/test_run_context_approvals.py index 2b9df0a6ac..675852d6a2 100644 --- a/tests/test_run_context_approvals.py +++ b/tests/test_run_context_approvals.py @@ -3,7 +3,7 @@ import pytest from openai.types.responses.response_output_item import McpApprovalRequest -from agents import Agent, RunContextWrapper, ToolApprovalItem, UserError +from agents import Agent, ModelBehaviorError, RunContextWrapper, ToolApprovalItem, UserError from .utils.factories import make_tool_approval_item @@ -184,7 +184,7 @@ def test_hosted_mcp_exact_query_does_not_inherit_function_rejection_reason( context_wrapper = RunContextWrapper(context=None) function_item = make_tool_approval_item( agent, - call_id="shared-call", + call_id="function-call", name="lookup_account", ) hosted_item = _make_hosted_mcp_approval_item( @@ -321,6 +321,7 @@ def test_hosted_mcp_legacy_exact_call_decisions_remain_usable() -> None: } } ) + context_wrapper._allow_legacy_approval_binding_reconstruction = True # noqa: SLF001 assert ( context_wrapper.get_approval_status( @@ -352,7 +353,7 @@ def test_hosted_mcp_legacy_exact_call_decisions_remain_usable() -> None: "request-rejected", existing_pending=rejected_without_raw_name, ) - is False + is None ) @@ -417,35 +418,10 @@ def test_incomplete_hosted_mcp_uses_only_exact_call_decisions() -> None: is None ) - context_wrapper.approve_tool(malformed) - assert context_wrapper.is_tool_approved("lookup_account", "request-a-1") is True - assert ( - context_wrapper.get_approval_status( - "lookup_account", - "request-a-1", - existing_pending=malformed, - ) - is True - ) - - context_wrapper.reject_tool(malformed, rejection_message="exact denial") - assert context_wrapper.is_tool_approved("lookup_account", "request-a-1") is False - assert ( - context_wrapper.get_approval_status( - "lookup_account", - "request-a-1", - existing_pending=malformed, - ) - is False - ) - assert ( - context_wrapper.get_rejection_message( - "lookup_account", - "request-a-1", - existing_pending=malformed, - ) - == "exact denial" - ) + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + context_wrapper.approve_tool(malformed) + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + context_wrapper.reject_tool(malformed, rejection_message="exact denial") def test_hosted_mcp_decision_requires_request_id() -> None: @@ -681,6 +657,7 @@ def test_deferred_top_level_legacy_permanent_approval_key_still_restores() -> No context_wrapper._rebuild_approvals( # noqa: SLF001 {"get_weather.get_weather": {"approved": True, "rejected": []}} ) + context_wrapper._allow_legacy_approval_binding_reconstruction = True # noqa: SLF001 assert ( context_wrapper.get_approval_status( diff --git a/tests/test_run_context_wrapper.py b/tests/test_run_context_wrapper.py index 159027d1e0..6623675a19 100644 --- a/tests/test_run_context_wrapper.py +++ b/tests/test_run_context_wrapper.py @@ -28,7 +28,15 @@ def test_run_context_resolve_tool_name_and_call_id_fallbacks() -> None: def test_run_context_scopes_approvals_to_call_ids() -> None: wrapper: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) agent = make_agent() - approval = ToolApprovalItem(agent=agent, raw_item={"type": "tool_call", "call_id": "call-1"}) + approval = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": "call-1", + "arguments": "{}", + }, + ) wrapper.approve_tool(approval) assert wrapper.is_tool_approved("tool_call", "call-1") is True @@ -40,7 +48,15 @@ def test_run_context_scopes_approvals_to_call_ids() -> None: def test_run_context_scopes_rejections_to_call_ids() -> None: wrapper: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) agent = make_agent() - approval = ToolApprovalItem(agent=agent, raw_item={"type": "tool_call", "call_id": "call-1"}) + approval = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": "call-1", + "arguments": "{}", + }, + ) wrapper.reject_tool(approval) assert wrapper.is_tool_approved("tool_call", "call-1") is False @@ -52,7 +68,15 @@ def test_run_context_scopes_rejections_to_call_ids() -> None: def test_run_context_honors_global_approval_and_rejection() -> None: wrapper: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) agent = make_agent() - approval = ToolApprovalItem(agent=agent, raw_item={"type": "tool_call", "call_id": "call-1"}) + approval = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": "call-1", + "arguments": "{}", + }, + ) wrapper.approve_tool(approval, always_approve=True) assert wrapper.is_tool_approved("tool_call", "call-2") is True @@ -64,7 +88,15 @@ def test_run_context_honors_global_approval_and_rejection() -> None: def test_run_context_stores_per_call_rejection_messages() -> None: wrapper: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) agent = make_agent() - approval = ToolApprovalItem(agent=agent, raw_item={"type": "tool_call", "call_id": "call-1"}) + approval = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": "call-1", + "arguments": "{}", + }, + ) wrapper.reject_tool(approval, rejection_message="Denied by policy") @@ -75,7 +107,15 @@ def test_run_context_stores_per_call_rejection_messages() -> None: def test_run_context_stores_sticky_rejection_messages_for_always_reject() -> None: wrapper: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) agent = make_agent() - approval = ToolApprovalItem(agent=agent, raw_item={"type": "tool_call", "call_id": "call-1"}) + approval = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": "call-1", + "arguments": "{}", + }, + ) wrapper.reject_tool(approval, always_reject=True, rejection_message="") @@ -86,7 +126,15 @@ def test_run_context_stores_sticky_rejection_messages_for_always_reject() -> Non def test_run_context_clears_rejection_message_after_approval() -> None: wrapper: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) agent = make_agent() - approval = ToolApprovalItem(agent=agent, raw_item={"type": "tool_call", "call_id": "call-1"}) + approval = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": "call-1", + "arguments": "{}", + }, + ) wrapper.reject_tool(approval, rejection_message="Denied by policy") wrapper.approve_tool(approval) diff --git a/tests/test_run_hooks.py b/tests/test_run_hooks.py index c37ca2b5d0..e580651b8c 100644 --- a/tests/test_run_hooks.py +++ b/tests/test_run_hooks.py @@ -375,10 +375,13 @@ async def test_streamed_run_hooks_count_tool_and_handoff_invocations(): model.add_multiple_turn_outputs( [ [ - get_function_tool_call("some_function", json.dumps({"a": "b"})), - get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="call_1"), + get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="call_2"), + ], + [ + get_text_message("a_message"), + get_handoff_tool_call(agent_1, call_id="handoff_1"), ], - [get_text_message("a_message"), get_handoff_tool_call(agent_1)], [get_text_message("done")], ] ) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 78cc09b11b..2754a2c72e 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -38,7 +38,8 @@ from openai.types.responses.tool_param import Mcp from pydantic import BaseModel -from agents import Agent, Model, ModelSettings, RunConfig, Runner, handoff, trace +from agents import Agent, Model, ModelSettings, RunConfig, RunHooks, Runner, handoff, trace +from agents._tool_invocation import tool_invocation_identity_and_scope from agents.computer import Computer from agents.exceptions import ModelBehaviorError, UserError from agents.guardrail import ( @@ -150,6 +151,7 @@ HITL_REJECTION_MSG, make_function_tool_call, make_model_and_agent, + make_shell_call, make_state_with_interruptions, run_and_resume_with_mutation, ) @@ -1618,6 +1620,971 @@ async def test_serializes_and_restores_approvals(self): assert new_state._context.is_tool_approved(tool_name="tool2", call_id="cid2") is False assert new_state._context.get_rejection_message("tool2", "cid2") is None + async def test_schema_1_13_restores_pending_approval_binding_from_interruption(self): + """A 1.13 snapshot may resume only the exact invocation that was approved.""" + agent = Agent(name="ApprovalLegacyAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + json_data["$schemaVersion"] = "1.13" + json_data["context"].pop("tool_invocations", None) + + restored = await RunState.from_json(agent, json_data) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + ) + is True + ) + changed_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"changed"}', + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + current_invocation=changed_item, + ) + + @pytest.mark.parametrize("schema_version", ["1.13", "1.14"]) + async def test_legacy_schema_sticky_approval_binds_pending_function_invocation( + self, + schema_version: str, + ): + """A legacy sticky decision cannot authorize changed resumed arguments.""" + agent = Agent(name="ApprovalLegacyAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item, always_approve=True) + json_data = state.to_json() + json_data["$schemaVersion"] = schema_version + json_data["context"].pop("tool_invocations", None) + + restored = await RunState.from_json(agent, json_data) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + changed_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"changed"}', + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + current_invocation=changed_item, + ) + + async def test_schema_1_14_sticky_approval_binds_pending_hosted_mcp_invocation(self): + """A restored hosted MCP sticky decision binds the pending request payload.""" + agent = Agent(name="ApprovalLegacyAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments='{"value":"safe"}', + name="lookup_account", + server_label="server-a", + ), + ) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item, always_approve=True) + json_data = state.to_json() + json_data["$schemaVersion"] = "1.14" + json_data["context"].pop("tool_invocations", None) + + restored = await RunState.from_json(agent, json_data) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + changed_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments='{"value":"changed"}', + name="lookup_account", + server_label="server-a", + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + restored._context.get_approval_status( + "lookup_account", + "request-a", + existing_pending=restored_item, + current_invocation=changed_item, + ) + + async def test_current_schema_does_not_reconstruct_missing_approval_binding(self): + """A malformed current snapshot must require a new approval decision.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + json_data["context"].pop("tool_invocations", None) + + restored = await RunState.from_json(agent, json_data) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + ) + is None + ) + + async def test_current_schema_sticky_approval_requires_restored_pending_binding(self): + """A malformed sticky snapshot cannot treat a resumed call ID as fresh.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item, always_approve=True) + json_data = state.to_json() + json_data["context"].pop("tool_invocations", None) + + restored = await RunState.from_json(agent, json_data) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + ) + is None + ) + changed_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"changed"}', + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + current_invocation=changed_item, + ) + is None + ) + fresh_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid-fresh", + arguments='{"value":"fresh"}', + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid-fresh", + current_invocation=fresh_item, + ) + is True + ) + + tool_context = ToolContext.from_agent_context( + restored._context, + tool_call_id="cid1", + tool_call=approved_call, + ) + assert ( + tool_context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + ) + is None + ) + + hook_statuses: list[bool | None] = [] + + class ApprovalProbeHooks(RunHooks[Any]): + async def on_agent_start(self, context: Any, _agent: Agent[Any]) -> None: + hook_statuses.append( + context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + ) + ) + + probe_agent = Agent( + name="ApprovalProbeAgent", + model=FakeModel(initial_output=[get_text_message("done")]), + ) + await Runner.run( + probe_agent, + "probe approval state", + context=restored._context, + hooks=ApprovalProbeHooks(), + ) + + assert hook_statuses == [None] + assert "cid1" not in restored._context._tool_invocations + + @pytest.mark.parametrize("sticky", [False, True], ids=["per_call", "sticky"]) + async def test_current_schema_mismatched_pending_ledger_binding_requires_reapproval( + self, + sticky: bool, + ) -> None: + """A restored ledger entry must match the pending invocation before authorizing it.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item, always_approve=sticky) + json_data = state.to_json() + + changed_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"changed"}', + ) + changed_identity = tool_invocation_identity_and_scope(changed_call) + assert changed_identity is not None + invocation_type, _, approval_scope, fingerprint = changed_identity + json_data["context"]["tool_invocations"]["cid1"].update( + { + "type": invocation_type, + "approval_scope": approval_scope, + "fingerprint": fingerprint, + } + ) + + restored = await RunState.from_json(agent, json_data) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + changed_item = ToolApprovalItem(agent=agent, raw_item=changed_call) + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_item, + current_invocation=changed_item, + ) + is None + ) + if sticky: + fresh_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid-fresh", + arguments='{"value":"fresh"}', + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid-fresh", + current_invocation=fresh_item, + ) + is True + ) + + @pytest.mark.parametrize( + ("field", "value"), + [ + ("type", "unknown_tool_call"), + ("approval_scope", "not-a-digest"), + ("fingerprint", 123), + ("fingerprint", "A" * 64), + ], + ) + async def test_current_schema_rejects_malformed_tool_invocation_ledger( + self, + field: str, + value: Any, + ): + """Current snapshots fail closed when canonical invocation data is malformed.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + json_data["context"]["tool_invocations"]["cid1"][field] = value + + with pytest.raises(UserError, match="invalid lifecycle data"): + await RunState.from_json(agent, json_data) + + @pytest.mark.parametrize("missing_field", ["executed", "completed"]) + async def test_current_schema_requires_tool_invocation_lifecycle_fields( + self, + missing_field: str, + ): + """Current snapshots must preserve explicit monotonic lifecycle evidence.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + invocation = json_data["context"]["tool_invocations"]["cid1"] + invocation["executed"] = True + invocation["completed"] = False + del invocation[missing_field] + + with pytest.raises(UserError, match="invalid lifecycle data"): + await RunState.from_json(agent, json_data) + + async def test_current_schema_rejects_null_tool_invocation_ledger(self): + """A present current-schema ledger must be a mapping.""" + agent = Agent(name="ApprovalCurrentAgent") + state = make_state(agent, context=RunContextWrapper(context=None)) + json_data = state.to_json() + json_data["context"]["tool_invocations"] = None + + with pytest.raises(UserError, match="tool_invocations must be a mapping"): + await RunState.from_json(agent, json_data) + + async def test_output_item_id_does_not_complete_unrelated_invocation(self): + """Only an output call_id can commit a tool invocation.""" + context: RunContextWrapper[Any] = RunContextWrapper(context=None) + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + context._tool_invocation_status(approved_call) + + context._mark_tool_call_completed( + { + "type": "function_call_output", + "call_id": "", + "id": "cid1", + "output": "forged", + } + ) + + assert context._tool_invocation_status(approved_call) == ( + ("function_call", "cid1"), + False, + False, + ) + + async def test_current_schema_rejects_completed_invocation_with_only_output_item_id(self): + """An output item ID cannot satisfy completed-call reconciliation.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + invocation = json_data["context"]["tool_invocations"]["cid1"] + invocation["executed"] = True + invocation["completed"] = True + json_data["original_input"] = [ + { + "type": "function_call_output", + "call_id": "", + "id": "cid1", + "output": "forged", + } + ] + + with pytest.raises(UserError, match="does not match a restored tool call and output"): + await RunState.from_json(agent, json_data) + + async def test_current_schema_rejects_completed_invocation_without_committed_output(self): + """A completed ledger entry must have a matching restored call and output.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + invocation = json_data["context"]["tool_invocations"]["cid1"] + invocation["executed"] = True + invocation["completed"] = True + + with pytest.raises(UserError, match="does not match a restored tool call and output"): + await RunState.from_json(agent, json_data) + + async def test_current_schema_rejects_completed_cross_paired_same_id_invocations(self): + """A historical output cannot complete changed arguments under the same call ID.""" + agent = Agent(name="ApprovalCurrentAgent") + changed_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"changed"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=changed_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + invocation = json_data["context"]["tool_invocations"]["cid1"] + invocation["executed"] = True + invocation["completed"] = True + historical_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + json_data["original_input"] = [ + historical_call.model_dump(exclude_none=True), + { + "type": "function_call_output", + "call_id": "cid1", + "output": "safe", + }, + ] + + with pytest.raises(UserError, match="does not match a restored tool call and output"): + await RunState.from_json(agent, json_data) + + async def test_current_schema_rejects_completed_id_with_malformed_call_occurrence(self): + """A malformed same-ID occurrence invalidates completed-ledger authority.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + json_data = state.to_json() + invocation = json_data["context"]["tool_invocations"]["cid1"] + invocation["executed"] = True + invocation["completed"] = True + json_data["original_input"] = [ + approved_call.model_dump(exclude_none=True), + { + "type": "function_call", + "name": "missing", + "call_id": "cid1", + }, + { + "type": "function_call_output", + "call_id": "cid1", + "output": "safe", + }, + ] + + with pytest.raises(UserError, match="does not match a restored tool call and output"): + await RunState.from_json(agent, json_data) + + async def test_current_schema_missing_call_id_cannot_create_sticky_approval(self): + """Approving a malformed current interruption must not authorize later calls.""" + agent = Agent(name="ApprovalCurrentAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool1", + "arguments": '{"value":"safe"}', + }, + ) + state = make_state_with_interruptions(agent, [approval_item]) + restored = await RunState.from_json(agent, state.to_json()) + + assert restored._context is not None + with pytest.raises(ModelBehaviorError, match="non-empty call ID"): + restored.approve(restored.get_interruptions()[0]) + + assert restored._context._approvals == {} + fresh_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid-fresh", + arguments='{"value":"safe"}', + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid-fresh", + current_invocation=fresh_item, + ) + is None + ) + + @pytest.mark.parametrize( + "raw_item", + [ + { + "type": "function_call", + "name": "tool1", + "call_id": "cid1", + }, + { + "type": "mcp_approval_request", + "name": "lookup_account", + "server_label": "server-a", + "id": "request-a", + }, + { + "type": "unknown_tool_call", + "name": "tool1", + "call_id": "cid1", + }, + { + "type": "unknown_tool_call", + "name": "tool1", + "id": "provider-id", + }, + { + "type": "mcp_approval_request", + "name": "", + "server_label": "server-a", + "arguments": "{}", + "id": "request-empty-name", + }, + { + "type": "mcp_approval_request", + "name": "lookup_account", + "server_label": None, + "arguments": "{}", + "id": "request-null-server", + }, + { + "type": "hosted_tool_call", + "call_id": "request-wrapped-empty-name", + "provider_data": { + "type": "mcp_approval_request", + "name": "", + "server_label": "server-a", + "arguments": "{}", + }, + }, + ], + ) + async def test_approval_decision_requires_canonical_invocation(self, raw_item: dict[str, Any]): + """An unbindable recognized item cannot create approval authority.""" + agent = Agent(name="ApprovalCurrentAgent") + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + state = make_state_with_interruptions(agent, [approval_item]) + + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + state.approve(approval_item) + + assert state._context is not None + assert state._context._approvals == {} + + async def test_current_schema_orphaned_per_call_approval_requires_reapproval(self): + """A restored per-call decision without a ledger entry cannot bind a new payload.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + state: RunState[Any, Agent[Any]] = make_state(agent, context=RunContextWrapper(context={})) + state.approve(ToolApprovalItem(agent=agent, raw_item=approved_call)) + serialized = state.to_json() + serialized["context"]["tool_invocations"] = {} + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + changed_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"changed"}', + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + current_invocation=changed_item, + ) + is None + ) + assert "cid1" not in restored._context._tool_invocations + + @pytest.mark.parametrize("schema_version", ["1.13", "1.14"]) + @pytest.mark.parametrize("arguments", ['{"value":"safe"}', '{"value":"changed"}']) + async def test_legacy_schema_orphaned_per_call_approval_requires_reapproval( + self, + schema_version: str, + arguments: str, + ): + """A legacy per-call decision without a reconstructable call is not authority.""" + agent = Agent(name="ApprovalLegacyAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + state: RunState[Any, Agent[Any]] = make_state(agent, context=RunContextWrapper(context={})) + state.approve(ToolApprovalItem(agent=agent, raw_item=approved_call)) + serialized = state.to_json() + serialized["$schemaVersion"] = schema_version + serialized["context"].pop("tool_invocations", None) + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + current_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid1", + arguments=arguments, + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + current_invocation=current_item, + ) + is None + ) + assert "cid1" not in restored._context._tool_invocations + + restored.approve(current_item) + + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + current_invocation=current_item, + ) + is True + ) + + async def test_current_schema_missing_ledger_marks_historical_sticky_call_unbound(self): + """A historical ID cannot borrow sticky authority when its ledger entry is missing.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input=[approved_call.model_dump(exclude_none=True)], + ) + state.approve( + ToolApprovalItem(agent=agent, raw_item=approved_call), + always_approve=True, + ) + serialized = state.to_json() + serialized["context"].pop("tool_invocations") + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + changed_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"changed"}', + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + current_invocation=changed_item, + ) + is None + ) + fresh_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="cid-fresh", + arguments='{"value":"fresh"}', + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid-fresh", + current_invocation=fresh_item, + ) + is True + ) + + @pytest.mark.parametrize("missing_field", ["arguments", "server_label"]) + async def test_current_schema_unbindable_pending_approval_cannot_bind_replacement( + self, + missing_field: str, + ): + """A malformed current pending item cannot lend authority to a replacement payload.""" + agent = Agent(name="ApprovalCurrentAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments='{"value":"safe"}', + name="lookup_account", + server_label="server-a", + ), + ) + state = make_state_with_interruptions(agent, [approval_item]) + assert state._context is not None + state._context._rebuild_approvals( # noqa: SLF001 + { + "lookup_account": { + "approved": ["request-a"], + "rejected": [], + } + } + ) + serialized = state.to_json() + serialized["context"].pop("tool_invocations", None) + serialized["current_step"]["data"]["interruptions"][0]["raw_item"].pop(missing_field) + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + current_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments='{"value":"changed"}', + name="lookup_account", + server_label="server-a", + ), + ) + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-a", + existing_pending=restored_item, + current_invocation=current_item, + ) + is None + ) + + async def test_current_schema_unbindable_pending_with_ledger_requires_reapproval(self): + """An unbindable pending item overrides even a matching serialized ledger entry.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments='{"value":"safe"}', + name="lookup_account", + server_label="server-a", + ), + ) + state = make_state_with_interruptions(agent, [approved_item]) + state.approve(approved_item) + serialized = state.to_json() + serialized["current_step"]["data"]["interruptions"][0]["raw_item"].pop("arguments") + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + restored_pending = restored.get_interruptions()[0] + safe_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments='{"value":"safe"}', + name="lookup_account", + server_label="server-a", + ), + ) + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-a", + existing_pending=restored_pending, + current_invocation=safe_item, + ) + is None + ) + + changed_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments='{"value":"changed"}', + name="lookup_account", + server_label="server-a", + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + restored._context.approve_tool(changed_item) + + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-a", + existing_pending=restored_pending, + current_invocation=safe_item, + ) + is None + ) + + restored._context.approve_tool(safe_item) + + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-a", + existing_pending=restored_pending, + current_invocation=safe_item, + ) + is True + ) + + async def test_current_schema_missing_ledger_rejects_malformed_current_authority(self): + """A malformed current call cannot consume a decision whose binding is missing.""" + agent = Agent(name="ApprovalCurrentAgent") + approved_call = make_function_tool_call( + "tool1", + call_id="cid1", + arguments='{"value":"safe"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=approved_call) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + serialized = state.to_json() + serialized["context"]["tool_invocations"] = {} + + restored = await RunState.from_json(agent, serialized) + + assert restored._context is not None + restored_pending = restored.get_interruptions()[0] + malformed_current = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall.model_construct( + type="function_call", + name="tool1", + call_id="cid1", + ), + ) + assert ( + restored._context.get_approval_status( + "tool1", + "cid1", + existing_pending=restored_pending, + current_invocation=malformed_current, + ) + is None + ) + assert restored._context._tool_invocations == {} + + @pytest.mark.parametrize("always_approve", [False, True]) + async def test_serialized_apply_patch_approval_binds_plural_operations( + self, + always_approve: bool, + ): + """Changed plural apply-patch operations cannot reuse a restored decision.""" + agent = Agent(name="ApprovalCurrentAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "apply_patch_call", + "name": "apply_patch", + "call_id": "patch-call", + "operations": [{"type": "delete_file", "path": "safe.txt"}], + }, + tool_name="apply_patch", + ) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item, always_approve=always_approve) + + restored = await RunState.from_json(agent, state.to_json()) + + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + changed_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "apply_patch_call", + "name": "apply_patch", + "call_id": "patch-call", + "operations": [{"type": "delete_file", "path": "important.txt"}], + }, + tool_name="apply_patch", + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + restored._context.get_approval_status( + "apply_patch", + "patch-call", + existing_pending=restored_item, + current_invocation=changed_item, + ) + async def test_serializes_and_restores_rejection_messages(self): """Test that rejection messages are preserved through serialization.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) @@ -1705,6 +2672,40 @@ async def test_from_json_with_context_override_uses_serialized_rejection_message assert restored._context.get_rejection_message("tool2", "cid2") == "Denied by reviewer" assert restored._context.get_rejection_message("tool2", "cid3") == "Denied by reviewer" + async def test_context_override_discards_unbound_ids_from_previous_restore(self): + """Each restore rebuilds derived approval state on a reused context wrapper.""" + agent = Agent(name="ApprovalOverrideAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item=make_function_tool_call( + "tool1", + call_id="shared", + arguments='{"value":"safe"}', + ), + ) + state = make_state_with_interruptions(agent, [approval_item]) + state.approve(approval_item) + malformed = state.to_json() + malformed["context"]["tool_invocations"] = {} + valid = state.to_json() + override_context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + + await RunState.from_json(agent, malformed, context_override=override_context) + assert override_context._restored_unbound_approval_call_ids == {"shared"} + + restored = await RunState.from_json(agent, valid, context_override=override_context) + + assert restored._context is override_context + assert override_context._restored_unbound_approval_call_ids == set() + assert ( + override_context.get_approval_status( + "tool1", + "shared", + current_invocation=approval_item, + ) + is True + ) + class TestBuildAgentMap: """Test agent map building for handoff resolution.""" @@ -2741,6 +3742,256 @@ async def test_nested_agent_tool_interruptions_roundtrip(self): assert interruptions[0].agent.name == "InnerAgent" assert interruptions[0].raw_item.name == "sensitive_tool" # type: ignore[union-attr] + @pytest.mark.parametrize("round_trip", [False, True], ids=["live", "serialized"]) + async def test_ambiguous_current_and_nested_approval_identity_fails_closed( + self, + round_trip: bool, + ) -> None: + """An approval shared by current and nested scopes must not be guessed.""" + from agents.agent_tool_state import ( + drop_agent_tool_run_result, + record_agent_tool_run_result, + ) + + agent = Agent(name="Agent") + sensitive_tool = function_tool(lambda: "sensitive", name_override="sensitive") + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + agent.tools = [sensitive_tool, nested_tool] + + current_call = make_tool_call(call_id="shared", name="sensitive") + nested_outer_call = make_tool_call(call_id="outer-nested", name="nested_agent_tool") + current_approval = ToolApprovalItem(agent=agent, raw_item=current_call) + nested_approval = ToolApprovalItem( + agent=agent, + raw_item=current_call.model_copy(deep=True), + ) + state = make_state_with_interruptions( + agent, + [current_approval, nested_approval], + ) + state._last_processed_response = make_processed_response( + functions=[ + ToolRunFunction(tool_call=current_call, function_tool=sensitive_tool), + ToolRunFunction(tool_call=nested_outer_call, function_tool=nested_tool), + ] + ) + assert state._context is not None + state._context._tool_invocation_status(current_call) + + nested_state = make_state_with_interruptions(agent, [nested_approval]) + record_agent_tool_run_result( + nested_outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[nested_approval], + to_state=lambda: nested_state, + ), + ), + scope_id=state._agent_tool_state_scope_id, + ) + + target_state = state + target_nested_call = nested_outer_call + try: + if round_trip: + target_state = await RunState.from_json(agent, state.to_json()) + assert target_state._last_processed_response is not None + target_nested_call = target_state._last_processed_response.functions[1].tool_call + + with pytest.raises(UserError, match="current run and a nested agent-tool run"): + target_state.approve(target_state.get_interruptions()[0]) + finally: + drop_agent_tool_run_result( + nested_outer_call, + scope_id=state._agent_tool_state_scope_id, + ) + if target_state is not state: + drop_agent_tool_run_result( + target_nested_call, + scope_id=target_state._agent_tool_state_scope_id, + ) + + @pytest.mark.parametrize("round_trip", [False, True], ids=["live", "serialized"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + async def test_completed_current_invocation_does_not_own_nested_approval( + self, + round_trip: bool, + approve: bool, + ) -> None: + """A completed current invocation must not shadow a pending nested invocation.""" + from agents.agent_tool_state import ( + drop_agent_tool_run_result, + peek_agent_tool_run_result, + record_agent_tool_run_result, + ) + + agent = Agent(name="Agent") + sensitive_tool = function_tool(lambda: "sensitive", name_override="sensitive") + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + agent.tools = [sensitive_tool, nested_tool] + + completed_call = make_tool_call(call_id="shared", name="sensitive") + nested_outer_call = make_tool_call(call_id="outer-nested", name="nested_agent_tool") + nested_approval = ToolApprovalItem( + agent=agent, + raw_item=completed_call.model_copy(deep=True), + ) + state = make_state_with_interruptions(agent, [nested_approval]) + state._last_processed_response = make_processed_response( + functions=[ + ToolRunFunction(tool_call=completed_call, function_tool=sensitive_tool), + ToolRunFunction(tool_call=nested_outer_call, function_tool=nested_tool), + ] + ) + assert state._context is not None + state._context._tool_invocation_status(completed_call) + completed_output = { + "type": "function_call_output", + "call_id": completed_call.call_id, + "output": "done", + } + state._context._mark_tool_call_completed(completed_output) + state._generated_items = [ + ToolCallItem(agent=agent, raw_item=completed_call), + ToolCallOutputItem(agent=agent, raw_item=completed_output, output="done"), + ] + + nested_state = make_state_with_interruptions(agent, [nested_approval]) + record_agent_tool_run_result( + nested_outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[nested_approval], + to_state=lambda: nested_state, + ), + ), + scope_id=state._agent_tool_state_scope_id, + ) + + target_state = state + target_nested_call = nested_outer_call + try: + if round_trip: + target_state = await RunState.from_json(agent, state.to_json()) + assert target_state._last_processed_response is not None + target_nested_call = target_state._last_processed_response.functions[1].tool_call + + target_approval = target_state.get_interruptions()[0] + if approve: + target_state.approve(target_approval) + else: + target_state.reject(target_approval) + + pending_result = peek_agent_tool_run_result( + target_nested_call, + scope_id=target_state._agent_tool_state_scope_id, + ) + assert pending_result is not None + target_nested_state = pending_result.to_state() + assert target_nested_state._context is not None + assert ( + target_nested_state._context.get_approval_status( + "sensitive", + "shared", + existing_pending=target_approval, + ) + is approve + ) + finally: + drop_agent_tool_run_result( + nested_outer_call, + scope_id=state._agent_tool_state_scope_id, + ) + if target_state is not state: + drop_agent_tool_run_result( + target_nested_call, + scope_id=target_state._agent_tool_state_scope_id, + ) + + @pytest.mark.parametrize("round_trip", [False, True], ids=["live", "serialized"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + async def test_native_current_and_nested_approval_identity_fails_closed( + self, + round_trip: bool, + approve: bool, + ) -> None: + """A name-less native call shared by current and nested scopes must not be guessed.""" + from agents.agent_tool_state import ( + drop_agent_tool_run_result, + record_agent_tool_run_result, + ) + + agent = Agent(name="Agent") + + async def shell_executor(_request: Any) -> Any: + return {"output": "done"} + + shell_tool = ShellTool(executor=shell_executor, needs_approval=True) + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + agent.tools = [shell_tool, nested_tool] + + current_call = make_shell_call("shared") + nested_outer_call = make_tool_call(call_id="outer-nested", name="nested_agent_tool") + current_approval = ToolApprovalItem( + agent=agent, + raw_item=cast(Any, current_call), + tool_name=shell_tool.name, + ) + nested_approval = ToolApprovalItem( + agent=agent, + raw_item=cast(Any, deepcopy(current_call)), + tool_name=shell_tool.name, + ) + state = make_state_with_interruptions( + agent, + [current_approval, nested_approval], + ) + state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=nested_outer_call, function_tool=nested_tool)], + shell_calls=[ToolRunShellCall(tool_call=current_call, shell_tool=shell_tool)], + ) + assert state._context is not None + state._context._tool_invocation_status(current_call, tool_name=shell_tool.name) + + nested_state = make_state_with_interruptions(agent, [nested_approval]) + record_agent_tool_run_result( + nested_outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[nested_approval], + to_state=lambda: nested_state, + ), + ), + scope_id=state._agent_tool_state_scope_id, + ) + + target_state = state + target_nested_call = nested_outer_call + try: + if round_trip: + target_state = await RunState.from_json(agent, state.to_json()) + assert target_state._last_processed_response is not None + target_nested_call = target_state._last_processed_response.functions[0].tool_call + + with pytest.raises(UserError, match="current run and a nested agent-tool run"): + if approve: + target_state.approve(target_state.get_interruptions()[0]) + else: + target_state.reject(target_state.get_interruptions()[0]) + finally: + drop_agent_tool_run_result( + nested_outer_call, + scope_id=state._agent_tool_state_scope_id, + ) + if target_state is not state: + drop_agent_tool_run_result( + target_nested_call, + scope_id=target_state._agent_tool_state_scope_id, + ) + @pytest.mark.parametrize("drop_mode", ["disabled", "removed", "malformed_call"]) async def test_nested_agent_tool_state_survives_when_earlier_function_is_dropped( self, drop_mode: str @@ -5591,6 +6842,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.11", "1.12", "1.13", + "1.14", CURRENT_SCHEMA_VERSION, } ) @@ -6450,8 +7702,8 @@ def test_approve_tool_with_explicit_tool_name(self): assert context.is_tool_approved(tool_name="explicit_name", call_id="call123") is True - def test_approve_tool_extracts_call_id_from_dict(self): - """Test that approve_tool extracts call_id from dict raw_item.""" + def test_approve_tool_rejects_uncanonical_hosted_call_dict(self): + """A generic hosted call cannot create approval authority from its item ID.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) agent = Agent(name="TestAgent") # Dict with hosted tool identifiers (id instead of call_id) @@ -6462,9 +7714,10 @@ def test_approve_tool_extracts_call_id_from_dict(self): } approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) - context.approve_tool(approval_item) + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + context.approve_tool(approval_item) - assert context.is_tool_approved(tool_name="hosted_tool", call_id="hosted_call_123") is True + assert context.is_tool_approved(tool_name="hosted_tool", call_id="hosted_call_123") is None def test_reject_tool_with_explicit_tool_name(self): """Test that reject_tool works with explicit tool_name.""" @@ -7524,7 +8777,18 @@ async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: restored_wrapper = restored._context assert restored_wrapper is not None assert restored_wrapper.tool_input == {"scoped": True} - assert restored_wrapper._approvals + assert restored_wrapper._approvals == {} + assert restored._last_processed_response is not None + from agents.agent_tool_state import peek_agent_tool_run_result + + restored_nested_result = peek_agent_tool_run_result( + restored._last_processed_response.functions[0].tool_call, + scope_id=restored._agent_tool_state_scope_id, + ) + assert restored_nested_result is not None + restored_nested_state = restored_nested_result.to_state() + assert restored_nested_state._context is not None + assert restored_nested_state._context._approvals usage_before_resume = restored_wrapper.usage.input_tokens override = {"user": "reviewer"} @@ -7608,24 +8872,33 @@ async def test_hosted_mcp_approval_round_trip_uses_typed_identity_records() -> N serialized = state.to_json() assert serialized["context"]["approvals"] == {} - assert serialized["context"]["hosted_mcp_approvals"] == [ + hosted_approvals = serialized["context"]["hosted_mcp_approvals"] + assert [entry["identity"] for entry in hosted_approvals] == [ { - "identity": { - "type": "server_tool", - "server_label": "server-a", - "tool_name": "lookup_account", - }, - "decision": {"approved": True, "rejected": []}, + "type": "server_tool", + "server_label": "server-a", + "tool_name": "lookup_account", }, { - "identity": { - "type": "query", - "tool_name": "lookup_account", - "request_id": "request-a", - }, - "decision": {"approved": ["request-a"], "rejected": []}, + "type": "query", + "tool_name": "lookup_account", + "request_id": "request-a", }, ] + server_decision = hosted_approvals[0]["decision"] + assert server_decision["approved"] is True + assert server_decision["rejected"] == [] + assert isinstance(server_decision["sticky_scope"], str) + server_binding = serialized["context"]["tool_invocations"]["request-a"] + assert server_binding["type"] == "mcp_approval_request" + assert server_binding["approval_scope"] == server_decision["sticky_scope"] + assert isinstance(server_binding["fingerprint"], str) + assert server_binding["executed"] is False + assert server_binding["completed"] is False + query_decision = hosted_approvals[1]["decision"] + assert query_decision["approved"] == ["request-a"] + assert query_decision["rejected"] == [] + assert "invocations" not in query_decision restored = await RunState.from_json(agent, serialized) assert restored._context is not None @@ -7651,7 +8924,7 @@ async def test_hosted_mcp_approval_round_trip_uses_typed_identity_records() -> N @pytest.mark.asyncio -async def test_incomplete_hosted_mcp_query_round_trip_preserves_exact_decision() -> None: +async def test_incomplete_hosted_mcp_query_cannot_create_approval_authority() -> None: agent = Agent(name="test") context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) state = make_state(agent, context=context) @@ -7666,41 +8939,11 @@ async def test_incomplete_hosted_mcp_query_round_trip_preserves_exact_decision() }, tool_name="lookup_account", ) - state.reject(approval, rejection_message="exact denial") - - serialized = state.to_json() - - assert serialized["context"]["hosted_mcp_approvals"] == [ - { - "identity": { - "type": "request", - "request_id": "request-a", - }, - "decision": { - "approved": [], - "rejected": ["request-a"], - "rejection_messages": {"request-a": "exact denial"}, - }, - }, - { - "identity": { - "type": "query", - "tool_name": "lookup_account", - "request_id": "request-a", - }, - "decision": { - "approved": [], - "rejected": ["request-a"], - "rejection_messages": {"request-a": "exact denial"}, - }, - }, - ] - restored = await RunState.from_json(agent, serialized) + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + state.reject(approval, rejection_message="exact denial") - assert restored._context is not None - assert restored._context.is_tool_approved("lookup_account", "request-a") is False - assert restored._context.get_rejection_message("lookup_account", "request-a") == "exact denial" - assert restored._context.is_tool_approved("lookup_account", "request-next") is None + assert context._approvals == {} + assert state._serialize_hosted_mcp_approvals() == [] @pytest.mark.asyncio @@ -7798,7 +9041,7 @@ async def test_schema_1_13_ignores_typed_hosted_mcp_approval_records() -> None: @pytest.mark.asyncio -async def test_schema_1_13_hosted_mcp_exact_call_decisions_remain_usable() -> None: +async def test_schema_1_13_hosted_mcp_orphaned_call_decisions_require_reapproval() -> None: agent = Agent(name="test") context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) context._rebuild_approvals( # noqa: SLF001 @@ -7838,6 +9081,15 @@ async def test_schema_1_13_hosted_mcp_exact_call_decisions_remain_usable() -> No }, tool_name="lookup_account", ) + assert ( + restored._context.get_approval_status( + "lookup_account", + "request-approved", + existing_pending=approved, + ) + is None + ) + restored._context.approve_tool(approved) assert ( restored._context.get_approval_status( "lookup_account", @@ -7852,7 +9104,7 @@ async def test_schema_1_13_hosted_mcp_exact_call_decisions_remain_usable() -> No "request-rejected", existing_pending=rejected, ) - is False + is None ) assert ( restored._context.get_rejection_message( diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 16f70c074f..329917e509 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -31,6 +31,8 @@ HostedMCPTool, MCPApprovalRequestItem, MCPApprovalResponseItem, + MCPToolApprovalFunctionResult, + MCPToolApprovalRequest, MessageOutputItem, ModelBehaviorError, ModelRefusalError, @@ -562,8 +564,8 @@ async def test_multiple_tool_calls(): response = ModelResponse( output=[ get_text_message("Hello, world!"), - get_function_tool_call("test_1"), - get_function_tool_call("test_2"), + get_function_tool_call("test_1", call_id="test-1"), + get_function_tool_call("test_2", call_id="test-2"), ], usage=Usage(), response_id=None, @@ -3187,6 +3189,51 @@ def _apply_patch_tool_approval_run() -> ToolApprovalRun: ) +@pytest.mark.parametrize("tool_kind", ["shell", "apply_patch"]) +@pytest.mark.asyncio +async def test_empty_action_call_id_fails_before_approval_callback(tool_kind: str) -> None: + approval_calls: list[str] = [] + + async def approve(_context: RunContextWrapper[Any], _item: ToolApprovalItem) -> Any: + approval_calls.append(tool_kind) + return {"approve": True} + + if tool_kind == "shell": + shell_tool = ShellTool( + executor=lambda _request: "output", + needs_approval=True, + on_approval=approve, + ) + agent = make_agent(tools=[shell_tool]) + tool_call = cast(dict[str, Any], make_shell_call("")) + tool_call["id"] = "item-shell" + processed_response = make_processed_response( + shell_calls=[ToolRunShellCall(tool_call=tool_call, shell_tool=shell_tool)] + ) + else: + apply_patch_tool = ApplyPatchTool( + editor=RecordingEditor(), + needs_approval=True, + on_approval=approve, + ) + agent = make_agent(tools=[apply_patch_tool]) + tool_call = cast(dict[str, Any], make_apply_patch_dict("")) + tool_call["id"] = "item-apply" + processed_response = make_processed_response( + apply_patch_calls=[ + ToolRunApplyPatchCall( + tool_call=tool_call, + apply_patch_tool=apply_patch_tool, + ) + ] + ) + + with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): + await run_execute_with_processed_response(agent, processed_response) + + assert approval_calls == [] + + @pytest.mark.parametrize( "setup_fn", [ @@ -3314,6 +3361,80 @@ async def test_execute_tools_runs_hosted_mcp_callback_when_present(): assert not result.processed_response or not result.processed_response.interruptions +@pytest.mark.parametrize("with_callback", [False, True], ids=["manual", "callback"]) +@pytest.mark.asyncio +async def test_execute_tools_omits_completed_mcp_approval_request_replay( + with_callback: bool, +) -> None: + """A committed MCP approval replay must not emit a request or invoke its callback.""" + callback_calls = 0 + + def approve_request(_request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult: + nonlocal callback_calls + callback_calls += 1 + return {"approve": True} + + mcp_tool = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "test_mcp_server", + "server_url": "https://example.com", + "require_approval": "always", + }, + on_approval_request=approve_request if with_callback else None, + ) + agent = make_agent(tools=[mcp_tool]) + request_item = McpApprovalRequest( + id="mcp-approval-replay", + type="mcp_approval_request", + server_label="test_mcp_server", + arguments='{"path":"src"}', + name="list_files", + ) + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool( + ToolApprovalItem( + agent=agent, + raw_item=request_item, + tool_name="list_files", + ) + ) + context_wrapper._mark_tool_call_completed( + { + "type": "mcp_approval_response", + "approval_request_id": request_item.id, + "approve": True, + } + ) + processed_response = make_processed_response( + new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)], + mcp_approval_requests=[ + ToolRunMCPApprovalRequest( + request_item=request_item, + mcp_tool=mcp_tool, + ) + ], + ) + + result = await run_loop.execute_tools_and_side_effects( + bindings=_bind_agent(agent), + original_input="test", + pre_step_items=[], + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), + processed_response=processed_response, + output_schema=None, + hooks=RunHooks(), + context_wrapper=context_wrapper, + run_config=RunConfig(), + ) + + assert callback_calls == 0 + assert not any( + isinstance(item, MCPApprovalRequestItem | MCPApprovalResponseItem) + for item in result.new_step_items + ) + + @pytest.mark.asyncio async def test_execute_tools_uses_public_agent_for_hosted_mcp_callback_results(): """Hosted MCP callback responses should expose the public agent when execution uses a clone.""" @@ -3440,17 +3561,15 @@ def test_manual_hosted_mcp_approval_does_not_reuse_stale_pending_identity(): context_wrapper._rebuild_approvals( # noqa: SLF001 {"lookup_account": {"approved": ["shared-request"], "rejected": []}} ) + context_wrapper._allow_legacy_approval_binding_reconstruction = True # noqa: SLF001 - approved, pending = tool_execution.collect_manual_mcp_approvals( - agent=agent, - requests=[request_run], - context_wrapper=context_wrapper, - existing_pending_by_call_id={"shared-request": pending_a}, - ) - - assert approved == [] - assert len(pending) == 1 - assert pending[0].raw_item is current_b + with pytest.raises(ModelBehaviorError, match="unique call ID"): + tool_execution.collect_manual_mcp_approvals( + agent=agent, + requests=[request_run], + context_wrapper=context_wrapper, + existing_pending_by_call_id={"shared-request": pending_a}, + ) def test_hosted_mcp_approval_does_not_reuse_legacy_name_for_a_different_current_tool(): @@ -3657,7 +3776,7 @@ async def test_resolve_interrupted_turn_keeps_callback_owned_hosted_mcp_request_ assert not any(isinstance(item, ToolApprovalItem) for item in result.new_step_items) -def test_manual_hosted_mcp_approval_keeps_incomplete_exact_call_decision(): +def test_manual_hosted_mcp_approval_rejects_incomplete_exact_call_decision(): server_a = HostedMCPTool( tool_config={ "type": "mcp", @@ -3677,30 +3796,13 @@ def test_manual_hosted_mcp_approval_keeps_incomplete_exact_call_decision(): }, }, ) - current = McpApprovalRequest( - id="shared-request", - type="mcp_approval_request", - server_label="server-a", - arguments="{}", - name="lookup_account", - ) - request_run = ToolRunMCPApprovalRequest(request_item=current, mcp_tool=server_a) context_wrapper = make_context_wrapper() - context_wrapper.approve_tool(pending_unknown) - approved, pending = tool_execution.collect_manual_mcp_approvals( - agent=agent, - requests=[request_run], - context_wrapper=context_wrapper, - existing_pending_by_call_id={"shared-request": pending_unknown}, - ) - - assert pending == [] - assert len(approved) == 1 - assert approved[0].raw_item["approve"] is True + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + context_wrapper.approve_tool(pending_unknown) -def test_manual_hosted_mcp_approval_prefers_complete_current_scoped_identity(): +def test_manual_hosted_mcp_approval_reprompts_for_partial_pending_identity(): server_a = HostedMCPTool( tool_config={ "type": "mcp", @@ -3741,6 +3843,7 @@ def test_manual_hosted_mcp_approval_prefers_complete_current_scoped_identity(): } ] ) + context_wrapper._allow_legacy_approval_binding_reconstruction = True # noqa: SLF001 approved, pending = tool_execution.collect_manual_mcp_approvals( agent=agent, @@ -3749,9 +3852,9 @@ def test_manual_hosted_mcp_approval_prefers_complete_current_scoped_identity(): existing_pending_by_call_id={"shared-request": pending_partial}, ) - assert pending == [] - assert len(approved) == 1 - assert approved[0].raw_item["approve"] is True + assert approved == [] + assert len(pending) == 1 + assert pending[0].raw_item is current def test_manual_hosted_mcp_approval_does_not_apply_legacy_exact_without_pending(): @@ -3786,8 +3889,7 @@ def test_manual_hosted_mcp_approval_does_not_apply_legacy_exact_without_pending( assert pending[0].raw_item is current -@pytest.mark.asyncio -async def test_resolve_interrupted_turn_prefers_wrapped_pending_exact_over_legacy(): +def test_resolve_interrupted_turn_rejects_incomplete_pending_decision(): server_a = HostedMCPTool( tool_config={ "type": "mcp", @@ -3808,46 +3910,12 @@ async def test_resolve_interrupted_turn_prefers_wrapped_pending_exact_over_legac }, tool_name="lookup_account", ) - current = McpApprovalRequest( - id="shared-request", - type="mcp_approval_request", - server_label="server-a", - arguments="{}", - name="lookup_account", - ) context_wrapper = make_context_wrapper() context_wrapper._rebuild_approvals( # noqa: SLF001 {"lookup_account": {"approved": ["shared-request"], "rejected": []}} ) - context_wrapper.reject_tool(pending_partial, rejection_message="new exact denial") - processed_response = make_processed_response( - new_items=[MCPApprovalRequestItem(raw_item=current, agent=agent)], - mcp_approval_requests=[ToolRunMCPApprovalRequest(request_item=current, mcp_tool=server_a)], - ) - - result = await turn_resolution.resolve_interrupted_turn( - bindings=_bind_agent(agent), - original_input="test", - original_pre_step_items=[pending_partial], - new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), - processed_response=processed_response, - hooks=RunHooks(), - context_wrapper=context_wrapper, - run_config=RunConfig(), - ) - - assert not isinstance(result.next_step, NextStepInterruption) - responses = [ - item - for item in result.new_step_items - if isinstance(item, MCPApprovalResponseItem) - and item.raw_item.get("approval_request_id") == "shared-request" - ] - assert len(responses) == 1 - assert responses[0].raw_item["approve"] is False - assert responses[0].raw_item["reason"] == "new exact denial" - assert not any(isinstance(item, ToolApprovalItem) for item in result.pre_step_items) - assert not any(isinstance(item, ToolApprovalItem) for item in result.new_step_items) + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + context_wrapper.reject_tool(pending_partial, rejection_message="new exact denial") def test_incomplete_current_hosted_mcp_request_does_not_reuse_scoped_pending_identity(): @@ -4022,8 +4090,12 @@ async def test_execute_handoffs_uses_public_agent_for_ignored_extra_handoffs(): public_agent = Agent(name="triage", handoffs=[first_target, second_target]) execution_agent = public_agent.clone() set_public_agent(execution_agent, public_agent) + first_call = cast(ResponseFunctionToolCall, get_handoff_tool_call(first_target)) + first_call.call_id = "handoff-alpha" + second_call = cast(ResponseFunctionToolCall, get_handoff_tool_call(second_target)) + second_call.call_id = "handoff-beta" response = ModelResponse( - output=[get_handoff_tool_call(first_target), get_handoff_tool_call(second_target)], + output=[first_call, second_call], usage=Usage(), response_id="resp", ) diff --git a/tests/test_soft_cancel.py b/tests/test_soft_cancel.py index 1ece9e3e2e..3941c85523 100644 --- a/tests/test_soft_cancel.py +++ b/tests/test_soft_cancel.py @@ -453,8 +453,8 @@ async def test_soft_cancel_with_multiple_tool_calls(): model.add_multiple_turn_outputs( [ [ - get_function_tool_call("tool1", "{}"), - get_function_tool_call("tool2", "{}"), + get_function_tool_call("tool1", "{}", call_id="tool_1"), + get_function_tool_call("tool2", "{}", call_id="tool_2"), ], [get_text_message("Both tools executed")], ] @@ -679,8 +679,8 @@ async def test_soft_cancel_with_session_and_multiple_turns(): # Setup 3 turns model.add_multiple_turn_outputs( [ - [get_function_tool_call("tool1", "{}")], - [get_function_tool_call("tool1", "{}")], + [get_function_tool_call("tool1", "{}", call_id="tool_1")], + [get_function_tool_call("tool1", "{}", call_id="tool_2")], [get_text_message("Final")], ] ) diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index 5cdc026f66..27e482d55a 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -1,5 +1,6 @@ import asyncio import time +from copy import deepcopy from typing import Any, cast import pytest @@ -32,10 +33,11 @@ from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from agents import Agent, HandoffCallItem, Runner, function_tool -from agents.extensions.handoff_filters import remove_all_tools -from agents.handoffs import handoff +from agents.extensions.handoff_filters import nest_handoff_history, remove_all_tools +from agents.handoffs import HandoffInputData, handoff from agents.items import ( CompactionItem, + ItemHelpers, MCPApprovalRequestItem, MCPApprovalResponseItem, MCPListToolsItem, @@ -400,35 +402,35 @@ async def test_complete_streaming_events(): assert events[8].type == "raw_response_event" assert isinstance(events[8].data, ResponseOutputItemDoneEvent) - # Event 9: ReasoningItem run_item_stream_event - assert events[9].type == "run_item_stream_event" - assert events[9].name == "reasoning_item_created" - assert isinstance(events[9].item, ReasoningItem) + # Event 9: ResponseOutputItemAddedEvent (function call) + assert events[9].type == "raw_response_event" + assert isinstance(events[9].data, ResponseOutputItemAddedEvent) - # Event 10: ResponseOutputItemAddedEvent (function call) + # Event 10: ResponseFunctionCallArgumentsDeltaEvent assert events[10].type == "raw_response_event" - assert isinstance(events[10].data, ResponseOutputItemAddedEvent) + assert isinstance(events[10].data, ResponseFunctionCallArgumentsDeltaEvent) - # Event 11: ResponseFunctionCallArgumentsDeltaEvent + # Event 11: ResponseFunctionCallArgumentsDoneEvent assert events[11].type == "raw_response_event" - assert isinstance(events[11].data, ResponseFunctionCallArgumentsDeltaEvent) + assert isinstance(events[11].data, ResponseFunctionCallArgumentsDoneEvent) - # Event 12: ResponseFunctionCallArgumentsDoneEvent + # Event 12: ResponseOutputItemDoneEvent (function call) assert events[12].type == "raw_response_event" - assert isinstance(events[12].data, ResponseFunctionCallArgumentsDoneEvent) + assert isinstance(events[12].data, ResponseOutputItemDoneEvent) - # Event 13: ResponseOutputItemDoneEvent (function call) + # Event 13: ResponseCompletedEvent (first turn ended) assert events[13].type == "raw_response_event" - assert isinstance(events[13].data, ResponseOutputItemDoneEvent) + assert isinstance(events[13].data, ResponseCompletedEvent) - # Event 14: ToolCallItem run_item_stream_event + # Event 14: ReasoningItem after the complete response passes canonical validation assert events[14].type == "run_item_stream_event" - assert events[14].name == "tool_called" - assert isinstance(events[14].item, ToolCallItem) + assert events[14].name == "reasoning_item_created" + assert isinstance(events[14].item, ReasoningItem) - # Event 15: ResponseCompletedEvent (first turn ended) - assert events[15].type == "raw_response_event" - assert isinstance(events[15].data, ResponseCompletedEvent) + # Event 15: ToolCallItem after the complete response passes canonical validation + assert events[15].type == "run_item_stream_event" + assert events[15].name == "tool_called" + assert isinstance(events[15].item, ToolCallItem) # Event 16: ToolCallOutputItem run_item_stream_event assert events[16].type == "run_item_stream_event" @@ -477,6 +479,97 @@ async def test_complete_streaming_events(): assert isinstance(events[26].item, MessageOutputItem) +@pytest.mark.asyncio +async def test_tool_call_event_preserves_order_before_later_reasoning_item() -> None: + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call("foo", '{"arg": "value"}'), + get_reasoning_item(), + ], + [get_text_message("Final response")], + ] + ) + agent = Agent(name="TestAgent", model=model, tools=[foo]) + + result = Runner.run_streamed(agent, input="Hello") + semantic_event_names = [ + event.name + async for event in result.stream_events() + if event.type == "run_item_stream_event" + ] + + assert semantic_event_names[:3] == [ + "tool_called", + "reasoning_item_created", + "tool_output", + ] + + +@pytest.mark.asyncio +async def test_handoff_event_preserves_order_before_later_reasoning_item() -> None: + english_agent = Agent( + name="EnglishAgent", + model=FakeModel(initial_output=[get_text_message("Done")]), + ) + model = FakeModel( + initial_output=[ + get_handoff_tool_call(english_agent), + get_reasoning_item(), + ] + ) + triage_agent = Agent(name="TriageAgent", model=model, handoffs=[english_agent]) + + result = Runner.run_streamed(triage_agent, input="Start") + semantic_event_names = [ + event.name + async for event in result.stream_events() + if event.type == "run_item_stream_event" + ] + + assert semantic_event_names[:2] == [ + "handoff_requested", + "reasoning_item_created", + ] + + +@pytest.mark.asyncio +async def test_handoff_filter_copy_does_not_duplicate_streamed_model_items() -> None: + def copied_filter(data: HandoffInputData) -> HandoffInputData: + nested = nest_handoff_history(data) + return nested.clone(new_items=deepcopy(nested.new_items)) + + english_agent = Agent( + name="EnglishAgent", + model=FakeModel(initial_output=[get_text_message("Done")]), + ) + model = FakeModel( + initial_output=[ + get_text_message("Transferring"), + get_handoff_tool_call(english_agent), + ] + ) + triage_agent = Agent( + name="TriageAgent", + model=model, + handoffs=[handoff(english_agent, input_filter=copied_filter)], + ) + + result = Runner.run_streamed(triage_agent, input="Start") + item_events = [ + event async for event in result.stream_events() if event.type == "run_item_stream_event" + ] + + message_texts = [ + ItemHelpers.text_message_output(event.item) + for event in item_events + if isinstance(event.item, MessageOutputItem) + ] + assert message_texts == ["Transferring", "Done"] + assert sum(event.name == "handoff_requested" for event in item_events) == 1 + + @pytest.mark.asyncio async def test_stream_events_emit_tool_search_items() -> None: model = FakeModel() diff --git a/tests/test_tool_approval_call_id_reuse.py b/tests/test_tool_approval_call_id_reuse.py new file mode 100644 index 0000000000..e1b9c022e7 --- /dev/null +++ b/tests/test_tool_approval_call_id_reuse.py @@ -0,0 +1,2902 @@ +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from typing import Any, Literal, cast + +import pytest +from openai.types.responses import ResponseCustomToolCall, ResponseFunctionToolCall +from openai.types.responses.response_computer_tool_call import ( + ActionScreenshot, + PendingSafetyCheck, + ResponseComputerToolCall, +) +from openai.types.responses.response_output_item import McpApprovalRequest +from openai.types.responses.response_reasoning_item import ResponseReasoningItem + +from agents import ( + Agent, + ApplyPatchTool, + ComputerTool, + CustomTool, + HostedMCPTool, + MCPToolApprovalFunctionResult, + MCPToolApprovalRequest, + RunConfig, + Runner, + ShellTool, + ToolGuardrailFunctionOutput, + ToolOutputGuardrailData, + ToolOutputGuardrailTripwireTriggered, + function_tool, + handoff, + tool_output_guardrail, +) +from agents._tool_invocation import tool_invocation_identity, tool_invocation_identity_and_scope +from agents.editor import ApplyPatchOperation, ApplyPatchResult +from agents.exceptions import ModelBehaviorError, UserError +from agents.items import ModelResponse, ToolApprovalItem +from agents.lifecycle import RunHooks +from agents.models.interface import Model, ModelProvider +from agents.run_context import RunContextWrapper +from agents.run_internal.run_loop import ToolRunFunction +from agents.run_internal.tool_execution import ( + collect_manual_mcp_approvals, + process_hosted_mcp_approvals, + resolve_approval_rejection_message, +) +from agents.run_internal.tool_planning import _collect_runs_by_approval +from agents.run_state import RunState +from agents.stream_events import RunItemStreamEvent +from agents.tool import Tool +from agents.tool_context import ToolContext +from tests.fake_model import FakeModel +from tests.test_computer_tool_lifecycle import FakeComputer +from tests.test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message +from tests.utils.hitl import make_apply_patch_dict, make_shell_call, make_state_with_interruptions + + +class _ScriptedProvider(ModelProvider): + def __init__(self, model: Model) -> None: + self.model = model + + def get_model(self, model_name: str | None) -> Model: + assert model_name == "scripted-provider-model" + return self.model + + +def test_canonical_shell_identity_ignores_stripped_provider_metadata() -> None: + provider_call = { + "type": "shell_call", + "call_id": "shell_0", + "action": {"commands": ["echo safe"]}, + "created_by": "server", + } + persisted_call = dict(provider_call) + persisted_call.pop("created_by") + + assert tool_invocation_identity(provider_call) == tool_invocation_identity(persisted_call) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fallback_type", ["custom", "function"]) +async def test_completed_apply_patch_fallback_run_state_round_trip(fallback_type: str) -> None: + class RecordingEditor: + def __init__(self) -> None: + self.paths: list[str] = [] + + def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: + self.paths.append(operation.path) + return ApplyPatchResult(status="completed", output="updated") + + editor = RecordingEditor() + tool = ApplyPatchTool(editor=cast(Any, editor)) + operation = {"type": "update_file", "path": "safe.txt", "diff": "-old\n+new\n"} + if fallback_type == "custom": + call: Any = ResponseCustomToolCall( + type="custom_tool_call", + name="apply_patch", + call_id="patch_0", + input=json.dumps(operation), + ) + else: + call = ResponseFunctionToolCall( + type="function_call", + name="apply_patch", + call_id="patch_0", + arguments=json.dumps(operation), + ) + model = FakeModel(initial_output=[call]) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[tool]) + + result = await Runner.run(agent, "update the file") + restored = await RunState.from_json(agent, result.to_state().to_json()) + + assert result.final_output == "done" + assert editor.paths == ["safe.txt"] + assert restored._context is not None + assert restored._context._tool_invocations["patch_0"].completed is True + + +@pytest.mark.asyncio +async def test_streamed_function_apply_patch_replay_emits_one_tool_called_event() -> None: + class RecordingEditor: + def __init__(self) -> None: + self.paths: list[str] = [] + + def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: + self.paths.append(operation.path) + return ApplyPatchResult(status="completed", output="updated") + + editor = RecordingEditor() + operation = {"type": "update_file", "path": "safe.txt", "diff": "-old\n+new\n"} + call = ResponseFunctionToolCall( + type="function_call", + name="apply_patch", + call_id="patch_0", + arguments=json.dumps(operation), + ) + model = FakeModel() + model.add_multiple_turn_outputs( + [[call], [call.model_copy(deep=True)], [get_text_message("done")]] + ) + agent = Agent( + name="agent", + model=model, + tools=[ApplyPatchTool(editor=cast(Any, editor))], + ) + + streamed = Runner.run_streamed(agent, "update the file") + events = [event async for event in streamed.stream_events()] + + tool_called_events = [ + event + for event in events + if isinstance(event, RunItemStreamEvent) and event.name == "tool_called" + ] + assert streamed.final_output == "done" + assert editor.paths == ["safe.txt"] + assert len(tool_called_events) == 1 + assert tool_called_events[0].item.call_id == "patch_0" + + +@pytest.mark.parametrize( + ("raw_item", "first_tool_name", "replacement_tool_name"), + [ + (make_shell_call("call_0", commands=["echo safe"]), "safe_shell", "other_shell"), + (make_apply_patch_dict("call_0"), "safe_patch", "other_patch"), + ], + ids=["shell", "apply_patch"], +) +def test_native_tool_approval_scope_rejects_resolved_tool_replacement( + raw_item: Any, + first_tool_name: str, + replacement_tool_name: str, +) -> None: + context: RunContextWrapper[Any] = RunContextWrapper(context=None) + approval_item = ToolApprovalItem( + agent=Agent(name="agent"), + raw_item=cast(Any, raw_item), + tool_name=first_tool_name, + ) + context.approve_tool(approval_item) + + assert ( + context._approved_tool_invocation_status( # noqa: SLF001 + raw_item, + tool_name=first_tool_name, + ) + is not None + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + context._approved_tool_invocation_status( # noqa: SLF001 + raw_item, + tool_name=replacement_tool_name, + ) + + +@pytest.mark.asyncio +async def test_custom_named_shell_sticky_approval_applies_to_fresh_call_ids() -> None: + executed: list[str] = [] + + def execute(request: Any) -> str: + executed.extend(request.data.action.commands) + return "ok" + + first_call = make_shell_call("call_0", commands=["echo first"]) + second_call = make_shell_call("call_1", commands=["echo second"]) + model = FakeModel() + model.add_multiple_turn_outputs([[first_call], [second_call], [get_text_message("done")]]) + tool = ShellTool( + executor=execute, + name="safe_shell", + needs_approval=True, + ) + agent = Agent(name="agent", model=model, tools=[tool]) + + first = await Runner.run(agent, "run commands") + state = first.to_state() + state.approve(first.interruptions[0], always_approve=True) + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert executed == ["echo first", "echo second"] + + +@pytest.mark.asyncio +async def test_custom_named_shell_replacement_rejects_completed_call_id_replay() -> None: + first_executions: list[str] = [] + replacement_executions: list[str] = [] + + def run_first(_request: Any) -> str: + first_executions.append("ran") + return "ok" + + def run_replacement(_request: Any) -> str: + replacement_executions.append("ran") + return "ok" + + call = make_shell_call("call_0", commands=["echo safe"]) + model = FakeModel(initial_output=[call]) + original_tool = ShellTool( + executor=run_first, + name="safe_shell", + needs_approval=True, + ) + agent = Agent(name="agent", model=model, tools=[original_tool]) + + first = await Runner.run(agent, "run command") + state = first.to_state() + state.approve(first.interruptions[0]) + model.set_next_output([get_text_message("done")]) + completed = await Runner.run(agent, state) + + agent.tools = [ + ShellTool( + executor=run_replacement, + name="other_shell", + needs_approval=False, + ) + ] + model.set_next_output([cast(Any, dict(cast(dict[str, Any], call)))]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, completed.to_state()) + + assert first_executions == ["ran"] + assert replacement_executions == [] + + +@pytest.mark.asyncio +async def test_schema_1_13_custom_named_shell_skips_exact_completed_replay() -> None: + executions: list[str] = [] + + def execute(request: Any) -> str: + executions.append(request.data.call_id) + return "ok" + + call = make_shell_call("legacy-shell", commands=["echo safe"]) + model = FakeModel(initial_output=[call]) + model.set_next_output([get_text_message("done")]) + agent = Agent( + name="agent", + model=model, + tools=[ShellTool(executor=execute, name="safe_shell")], + ) + + completed = await Runner.run(agent, "run") + serialized = completed.to_state().to_json() + serialized["$schemaVersion"] = "1.13" + serialized["context"].pop("tool_invocations", None) + for key in ("generated_items", "session_items"): + for item in serialized.get(key, []): + item.pop("tool_name", None) + for item in (serialized.get("last_processed_response") or {}).get("new_items", []): + item.pop("tool_name", None) + + restored = await RunState.from_json(agent, serialized) + assert restored._context is not None + expected_identity = tool_invocation_identity_and_scope(call, tool_name="safe_shell") + assert expected_identity is not None + restored_record = restored._context._tool_invocations["legacy-shell"] + assert restored_record.approval_scope == expected_identity[2] + assert restored_record.fingerprint == expected_identity[3] + model.add_multiple_turn_outputs( + [ + [call], + [get_text_message("done again")], + ] + ) + resumed = await Runner.run(agent, restored) + + assert resumed.final_output == "done again" + assert executions == ["legacy-shell"] + + +@pytest.mark.parametrize( + "raw_item", + [ + {"type": "custom_tool_call", "name": "raw_editor", "call_id": "call_0"}, + {"type": "computer_call", "call_id": "call_0"}, + {"type": "local_shell_call", "call_id": "call_0"}, + {"type": "shell_call", "call_id": "call_0"}, + {"type": "apply_patch_call", "call_id": "call_0"}, + ], +) +def test_canonical_identity_requires_each_tool_payload(raw_item: dict[str, Any]) -> None: + assert tool_invocation_identity(raw_item) is None + + +@pytest.mark.asyncio +async def test_cancelled_rejection_formatter_leaves_invocation_executed() -> None: + tool_call = { + "type": "function_call", + "name": "approval_tool", + "call_id": "call_rejected", + "arguments": "{}", + } + context: RunContextWrapper[Any] = RunContextWrapper(context=None) + assert context._tool_invocation_status(tool_call) == ( # noqa: SLF001 + ("function_call", "call_rejected"), + False, + False, + ) + formatter_entered = asyncio.Event() + + async def blocking_formatter(_args: Any) -> str: + formatter_entered.set() + await asyncio.Event().wait() + return "rejected" + + task = asyncio.create_task( + resolve_approval_rejection_message( + context_wrapper=context, + run_config=RunConfig(tool_error_formatter=blocking_formatter), + tool_call=tool_call, + tool_type="function", + tool_name="approval_tool", + call_id="call_rejected", + ) + ) + await formatter_entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert context._tool_invocation_status(tool_call) == ( # noqa: SLF001 + ("function_call", "call_rejected"), + False, + True, + ) + + +async def _run( + agent: Agent[Any], + input_value: Any, + *, + run_config: RunConfig, + mode: Literal["non_streamed", "streamed"], + hooks: RunHooks[Any] | None = None, + events: list[Any] | None = None, +) -> Any: + if mode == "non_streamed": + return await Runner.run(agent, input_value, run_config=run_config, hooks=hooks) + result = Runner.run_streamed(agent, input_value, run_config=run_config, hooks=hooks) + async for event in result.stream_events(): + if events is not None: + events.append(event) + return result + + +def _build_scenario( + second_call_id: str, + second_value: str, +) -> tuple[Agent[Any], RunConfig, list[str]]: + executed: list[str] = [] + + @function_tool(name_override="record_value", needs_approval=True) + def record_value(value: str) -> str: + executed.append(value) + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "record_value", + json.dumps({"value": "safe"}), + call_id="call_0", + ) + ], + [ + get_function_tool_call( + "record_value", + json.dumps({"value": second_value}), + call_id=second_call_id, + ) + ], + [get_text_message("done")], + ] + ) + provider = _ScriptedProvider(model) + agent = Agent( + name="custom-provider-agent", + model="scripted-provider-model", + tools=[record_value], + ) + return agent, RunConfig(model_provider=provider), executed + + +@pytest.mark.asyncio +async def test_empty_custom_tool_call_id_fails_before_approval_or_execution() -> None: + callbacks: list[str] = [] + executed: list[str] = [] + + async def approve(_context: RunContextWrapper[Any], _item: ToolApprovalItem) -> Any: + callbacks.append("approval") + return {"approve": True} + + async def invoke(_context: Any, raw_input: str) -> str: + executed.append(raw_input) + return raw_input + + tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke, + format={"type": "text"}, + needs_approval=True, + on_approval=approve, + ) + model = FakeModel( + initial_output=[ + ResponseCustomToolCall( + type="custom_tool_call", + name=tool.name, + call_id="", + input="changed", + ) + ] + ) + agent = Agent(name="agent", model=model, tools=[tool]) + + with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): + await Runner.run(agent, "run it") + + assert callbacks == [] + assert executed == [] + + +@pytest.mark.asyncio +async def test_empty_unresolved_function_call_id_fails_before_error_formatter() -> None: + formatter_calls: list[str] = [] + + def format_tool_error(args: Any) -> str: + formatter_calls.append(args.tool_name) + return "error" + + model = FakeModel( + initial_output=[ + ResponseFunctionToolCall( + id="item_0", + type="function_call", + name="missing", + arguments="{}", + call_id="", + ) + ] + ) + agent = Agent(name="agent", model=model) + + with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): + await Runner.run( + agent, + "run it", + run_config=RunConfig( + tool_error_formatter=format_tool_error, + tool_not_found_behavior="return_error_to_model", + ), + ) + + assert formatter_calls == [] + + +@pytest.mark.asyncio +async def test_bound_call_id_with_missing_arguments_fails_before_error_formatter() -> None: + executed: list[str] = [] + formatter_calls: list[str] = [] + + @function_tool + def record_value(context: ToolContext[Any], value: str) -> str: + executed.append(value) + context._restored_unbound_approval_call_ids.add("shared") + return value + + def format_tool_error(args: Any) -> str: + formatter_calls.append(args.tool_name) + return "error" + + valid_call = get_function_tool_call( + "record_value", + json.dumps({"value": "safe"}), + call_id="shared", + ) + malformed_replacement = ResponseFunctionToolCall.model_construct( + type="function_call", + name="missing", + call_id="shared", + ) + model = FakeModel() + model.add_multiple_turn_outputs([[valid_call], [malformed_replacement]]) + agent = Agent(name="agent", model=model, tools=[record_value]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run( + agent, + "run it", + run_config=RunConfig( + tool_error_formatter=format_tool_error, + tool_not_found_behavior="return_error_to_model", + ), + ) + + assert executed == ["safe"] + assert formatter_calls == [] + + +@pytest.mark.asyncio +async def test_empty_handoff_call_id_fails_before_handoff_callback() -> None: + handoff_calls: list[str] = [] + target = Agent(name="target") + route = handoff( + target, + tool_name_override="route", + on_handoff=lambda _context: handoff_calls.append("route"), + ) + model = FakeModel( + initial_output=[ + ResponseFunctionToolCall( + id="item_0", + type="function_call", + name="route", + arguments="{}", + call_id="", + ) + ] + ) + agent = Agent(name="agent", model=model, handoffs=[route]) + + with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): + await Runner.run(agent, "route it") + + assert handoff_calls == [] + + +@pytest.mark.asyncio +async def test_failed_handoff_hook_does_not_commit_output_or_repeat_callback() -> None: + hook_calls: list[str] = [] + target = Agent(name="target") + call = get_handoff_tool_call(target, call_id="handoff_0") + + class FailingHooks(RunHooks[Any]): + async def on_handoff( + self, + context: RunContextWrapper[Any], + from_agent: Agent[Any], + to_agent: Agent[Any], + ) -> None: + hook_calls.append(to_agent.name) + raise RuntimeError("handoff hook failed") + + model = FakeModel(initial_output=[call]) + model.set_next_output([call.model_copy(deep=True)]) + agent = Agent(name="source", model=model, handoffs=[target]) + context = RunContextWrapper(context=None) + hooks = FailingHooks() + + with pytest.raises(RuntimeError, match="handoff hook failed"): + await Runner.run(agent, "handoff", context=context, hooks=hooks) + + assert context._tool_invocation_status(call, invocation_role="handoff") == ( # noqa: SLF001 + ("function_call", "handoff_0"), + False, + True, + ) + with pytest.raises(ModelBehaviorError, match="already executed"): + await Runner.run(agent, "handoff", context=context, hooks=hooks) + + assert hook_calls == ["target"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_changed_arguments_under_reused_call_id_fail_before_second_side_effect( + mode: Literal["non_streamed", "streamed"], +) -> None: + agent, run_config, executed = _build_scenario("call_0", "changed") + + first = await _run(agent, "record a value", run_config=run_config, mode=mode) + assert len(first.interruptions) == 1 + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _run(agent, state, run_config=run_config, mode=mode) + + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_exact_replay_reuses_committed_output_without_executing_again( + mode: Literal["non_streamed", "streamed"], +) -> None: + agent, run_config, executed = _build_scenario("call_0", "safe") + + first = await _run(agent, "record a value", run_config=run_config, mode=mode) + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed = await _run(agent, state, run_config=run_config, mode=mode) + + assert resumed.final_output == "done" + assert resumed.interruptions == [] + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_non_approval_identical_siblings_execute_once( + mode: Literal["non_streamed", "streamed"], +) -> None: + executed: list[str] = [] + events: list[Any] = [] + + @function_tool + async def record_value(value: str) -> str: + executed.append(value) + return value + + duplicate = get_function_tool_call( + "record_value", + '{"value":"safe"}', + call_id="call_0", + ) + model = FakeModel(initial_output=[duplicate, duplicate.model_copy(deep=True)]) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[record_value]) + + result = await _run( + agent, + "record a value", + run_config=RunConfig(), + mode=mode, + events=events, + ) + + assert result.final_output == "done" + assert executed == ["safe"] + if mode == "streamed": + assert [ + event.name for event in events if getattr(event, "name", None) == "tool_called" + ] == ["tool_called"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_non_approval_completed_replay_does_not_execute_again( + mode: Literal["non_streamed", "streamed"], +) -> None: + executed: list[str] = [] + + @function_tool + async def record_value(value: str) -> str: + executed.append(value) + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], + [get_function_tool_call("record_value", '{ "value" : "safe" }', call_id="call_0")], + [get_text_message("done")], + ] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + + result = await _run(agent, "record a value", run_config=RunConfig(), mode=mode) + + assert result.final_output == "done" + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_non_approval_changed_completed_call_id_fails_before_execution( + mode: Literal["non_streamed", "streamed"], +) -> None: + executed: list[str] = [] + events: list[Any] = [] + + class RecordingHooks(RunHooks[Any]): + def __init__(self) -> None: + self.llm_end_calls = 0 + + async def on_llm_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + response: ModelResponse, + ) -> None: + self.llm_end_calls += 1 + + hooks = RecordingHooks() + + @function_tool + async def record_value(value: str) -> str: + executed.append(value) + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], + [get_function_tool_call("record_value", '{"value":"changed"}', call_id="call_0")], + ] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _run( + agent, + "record a value", + run_config=RunConfig(), + mode=mode, + hooks=hooks, + events=events, + ) + + assert executed == ["safe"] + assert hooks.llm_end_calls == 1 + if mode == "streamed": + assert [ + event.name for event in events if getattr(event, "name", None) == "tool_called" + ] == ["tool_called"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "serialized"]) +async def test_nested_agent_tool_approval_allows_outer_call_id_collision( + round_trip: bool, +) -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + async def inner_sensitive_tool(value: str) -> str: + executed.append(value) + return value + + inner_model = FakeModel( + initial_output=[ + get_function_tool_call( + "inner_sensitive_tool", + '{"value":"safe"}', + call_id="shared", + ) + ] + ) + inner_model.set_next_output([get_text_message("inner done")]) + inner_agent = Agent(name="inner", model=inner_model, tools=[inner_sensitive_tool]) + + outer_model = FakeModel( + initial_output=[ + get_function_tool_call( + "nested_agent", + '{"input":"hello"}', + call_id="shared", + ) + ] + ) + outer_model.set_next_output([get_text_message("outer done")]) + outer_agent = Agent( + name="outer", + model=outer_model, + tools=[ + inner_agent.as_tool( + tool_name="nested_agent", + tool_description="Run the nested agent.", + ) + ], + ) + + interrupted = await Runner.run(outer_agent, "start") + assert len(interrupted.interruptions) == 1 + + state = interrupted.to_state() + if round_trip: + state = await RunState.from_json(outer_agent, state.to_json()) + state.approve(state.get_interruptions()[0]) + resumed = await Runner.run(outer_agent, state) + + assert resumed.final_output == "outer done" + assert resumed.interruptions == [] + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "serialized"]) +async def test_parent_approval_does_not_authorize_independent_nested_run( + round_trip: bool, +) -> None: + executed: list[str] = [] + + @function_tool(name_override="sensitive", needs_approval=True) + async def sensitive(value: str) -> str: + executed.append(value) + return value + + inner_model = FakeModel() + inner_model.add_multiple_turn_outputs( + [ + [get_function_tool_call("sensitive", '{"value":"same"}', call_id="shared")], + [get_text_message("inner done")], + ] + ) + inner_agent = Agent(name="inner", model=inner_model, tools=[sensitive]) + + outer_model = FakeModel() + outer_model.add_multiple_turn_outputs( + [ + [get_function_tool_call("sensitive", '{"value":"same"}', call_id="shared")], + [ + get_function_tool_call( + "nested_agent", + '{"input":"hello"}', + call_id="outer-nested", + ) + ], + [get_text_message("outer done")], + ] + ) + outer_agent = Agent( + name="outer", + model=outer_model, + tools=[ + sensitive, + inner_agent.as_tool( + tool_name="nested_agent", + tool_description="Run the nested agent.", + ), + ], + ) + + parent_interruption = await Runner.run(outer_agent, "start") + parent_state = parent_interruption.to_state() + parent_state.approve(parent_state.get_interruptions()[0]) + nested_interruption = await Runner.run(outer_agent, parent_state) + + assert len(nested_interruption.interruptions) == 1 + assert executed == ["same"] + + nested_state = nested_interruption.to_state() + if round_trip: + nested_state = await RunState.from_json(outer_agent, nested_state.to_json()) + still_pending = await Runner.run(outer_agent, nested_state) + + assert len(still_pending.interruptions) == 1 + assert executed == ["same"] + + approved_state = still_pending.to_state() + approved_state.approve(approved_state.get_interruptions()[0]) + completed = await Runner.run(outer_agent, approved_state) + + assert completed.final_output == "outer done" + assert completed.interruptions == [] + assert executed == ["same", "same"] + + +@pytest.mark.asyncio +async def test_streamed_validated_items_precede_llm_end_hook_failure() -> None: + executed: list[str] = [] + events: list[Any] = [] + + class FailingHooks(RunHooks[Any]): + async def on_llm_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + response: ModelResponse, + ) -> None: + _ = (context, agent, response) + raise RuntimeError("llm end failed") + + @function_tool + async def record_value(value: str) -> str: + executed.append(value) + return value + + model = FakeModel( + initial_output=[ + get_function_tool_call( + "record_value", + '{"value":"safe"}', + call_id="call_0", + ) + ] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + result = Runner.run_streamed(agent, "record a value", hooks=FailingHooks()) + + with pytest.raises(RuntimeError, match="llm end failed"): + async for event in result.stream_events(): + events.append(event) + + assert [event.name for event in events if isinstance(event, RunItemStreamEvent)] == [ + "tool_called" + ] + assert executed == [] + + +@pytest.mark.asyncio +async def test_non_approval_failed_tool_body_does_not_reexecute() -> None: + attempts: list[str] = [] + + @function_tool(failure_error_function=None) + async def perform_side_effect() -> str: + attempts.append("ran") + raise RuntimeError("failed after side effect") + + call = get_function_tool_call("perform_side_effect", "{}", call_id="call_0") + model = FakeModel() + model.add_multiple_turn_outputs([[call], [call.model_copy(deep=True)]]) + agent = Agent(name="agent", model=model, tools=[perform_side_effect]) + context = RunContextWrapper(context=None) + + with pytest.raises(UserError, match="failed after side effect"): + await Runner.run(agent, "run it", context=context) + + with pytest.raises(ModelBehaviorError, match="already executed"): + await Runner.run(agent, "run it", context=context) + + assert attempts == ["ran"] + + +@pytest.mark.asyncio +async def test_non_approval_custom_tool_identical_siblings_execute_once() -> None: + executed: list[str] = [] + + async def invoke(_context: Any, value: str) -> str: + executed.append(value) + return value + + tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke, + format={"type": "text"}, + ) + duplicate = ResponseCustomToolCall( + type="custom_tool_call", + name=tool.name, + call_id="call_0", + input="safe", + ) + model = FakeModel(initial_output=[duplicate, duplicate.model_copy(deep=True)]) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[tool]) + + result = await Runner.run(agent, "edit text") + + assert result.final_output == "done" + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_changed_same_id_siblings_fail_before_approval_callback( + mode: Literal["non_streamed", "streamed"], +) -> None: + approval_calls: list[str] = [] + + async def needs_approval(_context: Any, arguments: dict[str, Any], _call_id: str) -> bool: + approval_calls.append(arguments["value"]) + return True + + @function_tool(needs_approval=needs_approval) + async def record_value(value: str) -> str: + return value + + model = FakeModel( + initial_output=[ + get_function_tool_call("record_value", '{"value":"one"}', call_id="call_0"), + get_function_tool_call("record_value", '{"value":"two"}', call_id="call_0"), + ] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + + with pytest.raises(ModelBehaviorError, match="one response"): + await _run(agent, "record values", run_config=RunConfig(), mode=mode) + + assert approval_calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_response_processing_error_still_invokes_llm_end( + mode: Literal["non_streamed", "streamed"], +) -> None: + class CountingHooks(RunHooks[Any]): + def __init__(self) -> None: + self.llm_end_calls = 0 + + async def on_llm_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + response: ModelResponse, + ) -> None: + _ = (context, agent, response) + self.llm_end_calls += 1 + + hooks = CountingHooks() + model = FakeModel(initial_output=[make_shell_call("call_0", commands=["echo safe"])]) + agent = Agent(name="agent", model=model) + + with pytest.raises(ModelBehaviorError, match="without a shell tool"): + await _run(agent, "run command", run_config=RunConfig(), mode=mode, hooks=hooks) + + assert hooks.llm_end_calls == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_processing_error_with_changed_call_id_still_suppresses_llm_end( + mode: Literal["non_streamed", "streamed"], +) -> None: + class CountingHooks(RunHooks[Any]): + def __init__(self) -> None: + self.llm_end_calls = 0 + + async def on_llm_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + response: ModelResponse, + ) -> None: + _ = (context, agent, response) + self.llm_end_calls += 1 + + @function_tool + async def record_value(value: str) -> str: + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("record_value", '{"value":"safe"}', call_id="shared")], + [ + get_function_tool_call( + "record_value", + '{"value":"changed"}', + call_id="shared", + ), + make_shell_call("shell_0", commands=["echo safe"]), + ], + ] + ) + hooks = CountingHooks() + agent = Agent(name="agent", model=model, tools=[record_value]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _run(agent, "record value", run_config=RunConfig(), mode=mode, hooks=hooks) + + assert hooks.llm_end_calls == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_processing_error_with_repeated_uncanonical_id_suppresses_llm_end( + mode: Literal["non_streamed", "streamed"], +) -> None: + class CountingHooks(RunHooks[Any]): + def __init__(self) -> None: + self.llm_end_calls = 0 + + async def on_llm_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + response: ModelResponse, + ) -> None: + _ = (context, agent, response) + self.llm_end_calls += 1 + + first_call = ResponseCustomToolCall.model_construct( + type="custom_tool_call", + name="first_missing_tool", + call_id="shared", + ) + second_call = ResponseCustomToolCall.model_construct( + type="custom_tool_call", + name="second_missing_tool", + call_id="shared", + ) + hooks = CountingHooks() + model = FakeModel(initial_output=[first_call, second_call]) + agent = Agent(name="agent", model=model) + + with pytest.raises(ModelBehaviorError, match="one response"): + await _run(agent, "run tool", run_config=RunConfig(), mode=mode, hooks=hooks) + + assert hooks.llm_end_calls == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_changed_same_id_siblings_fail_before_non_approval_execution( + mode: Literal["non_streamed", "streamed"], +) -> None: + executed: list[str] = [] + + @function_tool + async def record_value(value: str) -> str: + executed.append(value) + return value + + model = FakeModel( + initial_output=[ + get_function_tool_call("record_value", '{"value":"one"}', call_id="call_0"), + get_function_tool_call("record_value", '{"value":"two"}', call_id="call_0"), + ] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + + with pytest.raises(ModelBehaviorError, match="one response"): + await _run(agent, "record values", run_config=RunConfig(), mode=mode) + + assert executed == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_changed_computer_safety_checks_fail_before_same_response_effects( + mode: Literal["non_streamed", "streamed"], +) -> None: + safety_checks: list[str] = [] + screenshots: list[str] = [] + + class RecordingComputer(FakeComputer): + def screenshot(self) -> str: + screenshots.append("screenshot") + return "img" + + def acknowledge_safety_check(data: Any) -> bool: + safety_checks.append(data.safety_check.id) + return True + + tool = ComputerTool( + computer=RecordingComputer(), + on_safety_check=acknowledge_safety_check, + ) + first_call = ResponseComputerToolCall( + id="computer-item-1", + type="computer_call", + action=ActionScreenshot(type="screenshot"), + call_id="computer-call", + pending_safety_checks=[PendingSafetyCheck(id="safety-1", code="code-1", message="first")], + status="completed", + ) + changed_call = first_call.model_copy( + update={ + "id": "computer-item-2", + "pending_safety_checks": [ + PendingSafetyCheck(id="safety-2", code="code-2", message="changed") + ], + } + ) + agent = Agent( + name="computer-agent", + model=FakeModel(initial_output=[first_call, changed_call]), + tools=[tool], + ) + + with pytest.raises(ModelBehaviorError, match="one response"): + await _run(agent, "use computer", run_config=RunConfig(), mode=mode) + + assert safety_checks == [] + assert screenshots == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_changed_computer_safety_checks_fail_before_completed_replay_effects( + mode: Literal["non_streamed", "streamed"], +) -> None: + safety_checks: list[str] = [] + screenshots: list[str] = [] + + class RecordingComputer(FakeComputer): + def screenshot(self) -> str: + screenshots.append("screenshot") + return "img" + + def acknowledge_safety_check(data: Any) -> bool: + safety_checks.append(data.safety_check.id) + return True + + tool = ComputerTool( + computer=RecordingComputer(), + on_safety_check=acknowledge_safety_check, + ) + first_call = ResponseComputerToolCall( + id="computer-item-1", + type="computer_call", + action=ActionScreenshot(type="screenshot"), + call_id="computer-call", + pending_safety_checks=[PendingSafetyCheck(id="safety-1", code="code-1", message="first")], + status="completed", + ) + changed_call = first_call.model_copy( + update={ + "id": "computer-item-2", + "pending_safety_checks": [ + PendingSafetyCheck(id="safety-2", code="code-2", message="changed") + ], + } + ) + model = FakeModel() + model.add_multiple_turn_outputs([[first_call], [changed_call]]) + agent = Agent(name="computer-agent", model=model, tools=[tool]) + + with pytest.raises(ModelBehaviorError, match="completed tool call ID"): + await _run(agent, "use computer", run_config=RunConfig(), mode=mode) + + assert safety_checks == ["safety-1"] + assert screenshots == ["screenshot"] + + +@pytest.mark.asyncio +async def test_computer_hook_failure_does_not_repeat_side_effect() -> None: + screenshots: list[str] = [] + + class RecordingComputer(FakeComputer): + def screenshot(self) -> str: + screenshots.append("screenshot") + return "img" + + class FailOnceHooks(RunHooks[Any]): + def __init__(self) -> None: + self.failed = False + + async def on_tool_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + result: object, + ) -> None: + if not self.failed: + self.failed = True + raise RuntimeError("end hook failed") + + tool = ComputerTool(computer=RecordingComputer()) + call = ResponseComputerToolCall( + id="computer-item", + type="computer_call", + action=ActionScreenshot(type="screenshot"), + call_id="computer-call", + pending_safety_checks=[], + status="completed", + ) + model = FakeModel() + model.add_multiple_turn_outputs([[call], [call.model_copy(deep=True)]]) + agent = Agent(name="computer-agent", model=model, tools=[tool]) + context = RunContextWrapper(context=None) + hooks = FailOnceHooks() + + with pytest.raises(RuntimeError, match="end hook failed"): + await Runner.run(agent, "use computer", context=context, hooks=hooks) + + with pytest.raises(ModelBehaviorError, match="already executed"): + await Runner.run(agent, "use computer", context=context, hooks=hooks) + + assert screenshots == ["screenshot"] + + +@pytest.mark.asyncio +async def test_exact_replay_drops_tied_reasoning_item() -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + async def record_value(value: str) -> str: + executed.append(value) + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], + [ + ResponseReasoningItem(id="rs_replay", summary=[], type="reasoning"), + get_function_tool_call( + "record_value", + '{ "value" : "safe" }', + call_id="call_0", + ), + ], + [get_text_message("done")], + ] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + first = await Runner.run(agent, "record a value") + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert executed == ["safe"] + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + assert not any(item.get("id") == "rs_replay" for item in model_input) + assert ( + sum( + item.get("type") == "function_call" and item.get("call_id") == "call_0" + for item in model_input + ) + == 1 + ) + + +@pytest.mark.asyncio +async def test_streamed_exact_replay_does_not_emit_tied_reasoning_item() -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + async def record_value(value: str) -> str: + executed.append(value) + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], + [ + ResponseReasoningItem(id="rs_replay", summary=[], type="reasoning"), + get_function_tool_call( + "record_value", + '{ "value" : "safe" }', + call_id="call_0", + ), + ], + [get_text_message("done")], + ] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + first = await Runner.run(agent, "record a value") + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed = Runner.run_streamed(agent, state) + item_events = [ + event async for event in resumed.stream_events() if isinstance(event, RunItemStreamEvent) + ] + + assert resumed.final_output == "done" + assert executed == ["safe"] + assert not any( + event.name == "reasoning_item_created" + and getattr(event.item.raw_item, "id", None) == "rs_replay" + for event in item_events + ) + assert not any(event.name == "tool_called" for event in item_events) + + +@pytest.mark.asyncio +async def test_failed_tool_end_hook_does_not_reexecute_approved_call() -> None: + agent, run_config, executed = _build_scenario("call_0", "safe") + + class FailOnceHooks(RunHooks[Any]): + def __init__(self) -> None: + self.failed = False + + async def on_tool_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + result: object, + ) -> None: + if not self.failed: + self.failed = True + raise RuntimeError("end hook failed") + + hooks = FailOnceHooks() + first = await Runner.run(agent, "record a value", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(UserError, match="end hook failed"): + await Runner.run(agent, state, run_config=run_config, hooks=hooks) + + resumed = await Runner.run(agent, state, run_config=run_config, hooks=hooks) + + assert resumed.final_output == "done" + assert executed == ["safe"] + + +@pytest.mark.asyncio +async def test_failed_output_guardrail_does_not_reexecute_approved_call() -> None: + executed: list[str] = [] + + @tool_output_guardrail + async def reject_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.raise_exception(output_info="blocked") + + @function_tool(needs_approval=True, tool_output_guardrails=[reject_output]) + async def record_value() -> str: + executed.append("ran") + return "sensitive" + + model = FakeModel( + initial_output=[get_function_tool_call("record_value", "{}", call_id="call_0")] + ) + agent = Agent(name="agent", model=model, tools=[record_value]) + first = await Runner.run(agent, "record a value") + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(ToolOutputGuardrailTripwireTriggered): + await Runner.run(agent, state) + + restored = await RunState.from_json(agent, state.to_json()) + with pytest.raises(ModelBehaviorError, match="already executed"): + await Runner.run(agent, restored) + + assert executed == ["ran"] + + +@pytest.mark.asyncio +async def test_failed_approved_tool_body_does_not_reexecute() -> None: + attempts: list[str] = [] + + @function_tool(needs_approval=True, failure_error_function=None) + async def perform_side_effect() -> str: + attempts.append("ran") + raise RuntimeError("failed after side effect") + + model = FakeModel( + initial_output=[get_function_tool_call("perform_side_effect", "{}", call_id="call_0")] + ) + agent = Agent(name="agent", model=model, tools=[perform_side_effect]) + first = await Runner.run(agent, "run it") + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(UserError, match="failed after side effect"): + await Runner.run(agent, state) + + with pytest.raises(ModelBehaviorError, match="already executed"): + await Runner.run(agent, state) + + assert attempts == ["ran"] + + +@pytest.mark.asyncio +async def test_cancelled_approved_tool_body_does_not_reexecute() -> None: + attempts: list[str] = [] + started = asyncio.Event() + keep_running = asyncio.Event() + + @function_tool(needs_approval=True, failure_error_function=None) + async def perform_side_effect() -> str: + attempts.append("ran") + started.set() + await keep_running.wait() + return "done" + + model = FakeModel( + initial_output=[get_function_tool_call("perform_side_effect", "{}", call_id="call_0")] + ) + agent = Agent(name="agent", model=model, tools=[perform_side_effect]) + first = await Runner.run(agent, "run it") + state = first.to_state() + state.approve(first.interruptions[0]) + resume_task = asyncio.create_task(Runner.run(agent, state)) + await started.wait() + resume_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await resume_task + + with pytest.raises(ModelBehaviorError, match="already executed"): + await Runner.run(agent, state) + + assert attempts == ["ran"] + + +@pytest.mark.asyncio +async def test_failed_approved_agent_tool_start_does_not_reexecute() -> None: + hook_calls: list[str] = [] + inner_agent = Agent( + name="inner", + model=FakeModel(initial_output=[get_text_message("inner done")]), + ) + agent_tool = inner_agent.as_tool( + tool_name="delegate", + tool_description="Delegate work.", + needs_approval=True, + ) + + class FailingHooks(RunHooks[Any]): + async def on_tool_start( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + ) -> None: + if tool is agent_tool: + hook_calls.append("ran") + raise RuntimeError("failed after side effect") + + outer_model = FakeModel( + initial_output=[get_function_tool_call("delegate", '{"input":"hi"}', call_id="call_0")] + ) + outer_agent = Agent(name="outer", model=outer_model, tools=[agent_tool]) + first = await Runner.run(outer_agent, "delegate") + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(UserError, match="failed after side effect"): + await Runner.run(outer_agent, state, hooks=FailingHooks()) + + with pytest.raises(ModelBehaviorError, match="already executed"): + await Runner.run(outer_agent, state, hooks=FailingHooks()) + + assert hook_calls == ["ran"] + + +@pytest.mark.asyncio +async def test_failed_parallel_tool_end_hook_checkpoints_outputs_in_model_order() -> None: + executed: list[str] = [] + first_finished = asyncio.Event() + + @function_tool(needs_approval=True) + async def first_tool() -> str: + await asyncio.sleep(0.01) + executed.append("first") + first_finished.set() + return "first" + + @function_tool(needs_approval=True) + async def second_tool() -> str: + executed.append("second") + return "second" + + class FailSecondHookOnce(RunHooks[Any]): + def __init__(self) -> None: + self.failed = False + + async def on_tool_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + result: object, + ) -> None: + if tool.name == "second_tool" and not self.failed: + await first_finished.wait() + self.failed = True + raise RuntimeError("second end hook failed") + + model = FakeModel( + initial_output=[ + get_function_tool_call("first_tool", "{}", call_id="call_first"), + get_function_tool_call("second_tool", "{}", call_id="call_second"), + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[first_tool, second_tool]) + hooks = FailSecondHookOnce() + + first = await Runner.run(agent, "run tools") + state = first.to_state() + for interruption in first.interruptions: + state.approve(interruption) + + with pytest.raises(UserError, match="second end hook failed"): + await Runner.run(agent, state, hooks=hooks) + + resumed = await Runner.run(agent, state, hooks=hooks) + + assert resumed.final_output == "done" + assert sorted(executed) == ["first", "second"] + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + output_call_ids = [ + item["call_id"] + for item in model_input + if isinstance(item, dict) and item.get("type") == "function_call_output" + ] + assert output_call_ids == ["call_first", "call_second"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_identical_approval_bound_siblings_execute_once( + mode: Literal["non_streamed", "streamed"], +) -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + def record_value(value: str) -> str: + executed.append(value) + return value + + duplicated_call = get_function_tool_call( + "record_value", + json.dumps({"value": "safe"}), + call_id="call-duplicate", + ) + model = FakeModel() + model.add_multiple_turn_outputs( + [[duplicated_call, duplicated_call.model_copy(deep=True)], [get_text_message("done")]] + ) + run_config = RunConfig(model_provider=_ScriptedProvider(model)) + agent = Agent( + name="custom-provider-agent", + model="scripted-provider-model", + tools=[record_value], + ) + + first = await _run(agent, "record a value", run_config=run_config, mode=mode) + assert len(first.interruptions) == 1 + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed = await _run(agent, state, run_config=run_config, mode=mode) + + assert resumed.final_output == "done" + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_new_call_id_requires_a_new_per_call_approval( + mode: Literal["non_streamed", "streamed"], +) -> None: + agent, run_config, executed = _build_scenario("call_1", "changed") + + first = await _run(agent, "record a value", run_config=run_config, mode=mode) + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed = await _run(agent, state, run_config=run_config, mode=mode) + + assert len(resumed.interruptions) == 1 + assert resumed.interruptions[0].arguments == json.dumps({"value": "changed"}) + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("approve", [True, False], ids=["always-approve", "always-reject"]) +async def test_serialized_sticky_decision_rejects_changed_reused_call_id( + approve: bool, +) -> None: + agent, run_config, executed = _build_scenario("call_0", "changed") + + first = await Runner.run(agent, "record a value", run_config=run_config) + state = first.to_state() + if approve: + state.approve(first.interruptions[0], always_approve=True) + else: + state.reject(first.interruptions[0], always_reject=True) + restored_state = await RunState.from_json(agent, state.to_json()) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, restored_state, run_config=run_config) + + assert executed == (["safe"] if approve else []) + + +@pytest.mark.asyncio +async def test_sticky_upgrade_rejects_changed_reuse_of_prior_call_id() -> None: + agent, run_config, executed = _build_scenario("call_0", "changed") + + first = await Runner.run(agent, "record a value", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + state.approve(first.interruptions[0], always_approve=True) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state, run_config=run_config) + + assert executed == ["safe"] + + +@pytest.mark.asyncio +async def test_serialized_sticky_identical_siblings_execute_once() -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + async def record_value(value: int) -> str: + executed.append(str(value)) + return str(value) + + duplicate = get_function_tool_call( + "record_value", + '{"value":1}', + call_id="call_0", + ) + model = FakeModel( + initial_output=[ + duplicate, + duplicate.model_copy(deep=True), + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[record_value]) + + first = await Runner.run(agent, "record a value") + state = first.to_state() + state.approve(first.interruptions[0], always_approve=True) + restored = await RunState.from_json(agent, state.to_json()) + + resumed = await Runner.run(agent, restored) + + assert resumed.final_output == "done" + assert executed == ["1"] + + +@pytest.mark.parametrize("approve", [True, False], ids=["always-approve", "always-reject"]) +def test_sticky_decision_preserves_prior_per_call_binding(approve: bool) -> None: + agent = Agent(name="agent") + context = RunContextWrapper(context=None) + first = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_a", '{"value":1}', call_id="call_0"), + ), + tool_name="tool_a", + ) + sticky = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_a", '{"value":2}', call_id="call_1"), + ), + tool_name="tool_a", + ) + changed = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_a", '{"value":3}', call_id="call_0"), + ), + tool_name="tool_a", + ) + context.approve_tool(first) + if approve: + context.approve_tool(sticky, always_approve=True) + else: + context.reject_tool(sticky, always_reject=True) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + context.get_approval_status( + "tool_a", + "call_0", + current_invocation=changed, + ) + + +@pytest.mark.parametrize("approve", [True, False], ids=["always-approve", "always-reject"]) +def test_sticky_decision_does_not_disable_other_tool_binding(approve: bool) -> None: + agent = Agent(name="agent") + context = RunContextWrapper(context=None) + other = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_b", '{"value":1}', call_id="call_0"), + ), + tool_name="tool_b", + ) + sticky = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_a", '{"value":2}', call_id="call_1"), + ), + tool_name="tool_a", + ) + changed_other = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_b", '{"value":3}', call_id="call_0"), + ), + tool_name="tool_b", + ) + context.approve_tool(other) + if approve: + context.approve_tool(sticky, always_approve=True) + else: + context.reject_tool(sticky, always_reject=True) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + context.get_approval_status( + "tool_b", + "call_0", + current_invocation=changed_other, + ) + + +@pytest.mark.parametrize("approve", [True, False], ids=["always-approve", "always-reject"]) +def test_matching_sticky_decision_does_not_mask_other_tool_binding(approve: bool) -> None: + agent = Agent(name="agent") + context = RunContextWrapper(context=None) + other = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_b", '{"value":1}', call_id="call_0"), + ), + tool_name="tool_b", + ) + sticky = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_a", '{"value":2}', call_id="call_1"), + ), + tool_name="tool_a", + ) + current = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("tool_a", '{"value":3}', call_id="call_0"), + ), + tool_name="tool_a", + ) + context.approve_tool(other) + if approve: + context.approve_tool(sticky, always_approve=True) + else: + context.reject_tool(sticky, always_reject=True) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + context.get_approval_status( + "tool_a", + "call_0", + current_invocation=current, + ) + + +@pytest.mark.parametrize("approve", [True, False], ids=["always-approve", "always-reject"]) +def test_deferred_sticky_decision_preserves_mirrored_per_call_binding(approve: bool) -> None: + agent = Agent(name="agent") + context = RunContextWrapper(context=None) + + def approval_item(call_id: str, value: int) -> ToolApprovalItem: + raw_item = cast( + ResponseFunctionToolCall, + get_function_tool_call( + "lookup", + json.dumps({"value": value}), + call_id=call_id, + ), + ) + return ToolApprovalItem( + agent=agent, + raw_item=raw_item, + tool_name="lookup", + tool_namespace="lookup", + tool_lookup_key=("deferred_top_level", "lookup"), + _allow_bare_name_alias=True, + ) + + first = approval_item("call_0", 1) + sticky = approval_item("call_1", 2) + changed = approval_item("call_0", 3) + context.approve_tool(first) + if approve: + context.approve_tool(sticky, always_approve=True) + else: + context.reject_tool(sticky, always_reject=True) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + context.get_approval_status( + "lookup", + "call_0", + tool_namespace="lookup", + tool_lookup_key=("deferred_top_level", "lookup"), + current_invocation=changed, + ) + + +def test_sticky_function_approval_rejects_same_id_shell_call() -> None: + agent = Agent(name="agent") + context = RunContextWrapper(context=None) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=cast( + ResponseFunctionToolCall, + get_function_tool_call("shell", "{}", call_id="call_0"), + ), + tool_name="shell", + ) + context.approve_tool(approval_item, always_approve=True) + shell_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "shell_call", + "call_id": "call_0", + "action": {"commands": ["echo safe"]}, + }, + tool_name="shell", + ) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + context.get_approval_status( + "shell", + "call_0", + current_invocation=shell_item, + ) + + +@pytest.mark.asyncio +async def test_changed_tool_under_approved_call_id_fails_before_second_tool_starts() -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + def first_tool(value: str) -> str: + executed.append(f"first:{value}") + return value + + @function_tool(needs_approval=True) + def second_tool(value: str) -> str: + executed.append(f"second:{value}") + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "first_tool", + json.dumps({"value": "same"}), + call_id="call_0", + ) + ], + [ + get_function_tool_call( + "second_tool", + json.dumps({"value": "same"}), + call_id="call_0", + ) + ], + ] + ) + run_config = RunConfig(model_provider=_ScriptedProvider(model)) + agent = Agent( + name="custom-provider-agent", + model="scripted-provider-model", + tools=[first_tool, second_tool], + ) + + first = await Runner.run(agent, "run tools", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state, run_config=run_config) + + assert executed == ["first:same"] + + +@pytest.mark.asyncio +async def test_approved_call_id_reused_for_another_invocation_type_fails_before_execution() -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + def approved_tool(value: str) -> str: + executed.append(f"function:{value}") + return value + + async def invoke_custom(_ctx: Any, raw_input: str) -> str: + executed.append(f"custom:{raw_input}") + return raw_input + + custom_tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke_custom, + format={"type": "text"}, + ) + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "approved_tool", + json.dumps({"value": "safe"}), + call_id="call_0", + ) + ], + [ + ResponseCustomToolCall( + type="custom_tool_call", + name="raw_editor", + call_id="call_0", + input="changed-kind", + ) + ], + ] + ) + run_config = RunConfig(model_provider=_ScriptedProvider(model)) + agent = Agent( + name="custom-provider-agent", + model="scripted-provider-model", + tools=[approved_tool, custom_tool], + ) + + first = await Runner.run(agent, "run tools", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state, run_config=run_config) + + assert executed == ["function:safe"] + + +@pytest.mark.asyncio +async def test_changed_missing_tool_under_approved_call_id_fails_before_sibling_tool() -> None: + executed: list[str] = [] + approval_checks: list[str] = [] + + @function_tool(needs_approval=True) + def approved_tool(value: str) -> str: + executed.append(f"approved:{value}") + return value + + async def sibling_needs_approval(_ctx: Any, _args: dict[str, Any], call_id: str) -> bool: + approval_checks.append(call_id) + return False + + @function_tool(needs_approval=sibling_needs_approval) + def sibling_tool() -> str: + executed.append("sibling") + return "sibling" + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "approved_tool", + json.dumps({"value": "safe"}), + call_id="call_0", + ) + ], + [ + get_function_tool_call("sibling_tool", "{}", call_id="call_1"), + get_function_tool_call("missing_tool", "{}", call_id="call_0"), + ], + ] + ) + run_config = RunConfig( + model_provider=_ScriptedProvider(model), + tool_not_found_behavior="return_error_to_model", + ) + agent = Agent( + name="custom-provider-agent", + model="scripted-provider-model", + tools=[approved_tool, sibling_tool], + ) + + first = await Runner.run(agent, "run tools", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state, run_config=run_config) + + assert executed == ["approved:safe"] + assert approval_checks == [] + + +def _build_serialized_replay_scenario( + replay_value: str, +) -> tuple[Agent[Any], RunConfig, list[str]]: + executed: list[str] = [] + + @function_tool(needs_approval=True) + def record_value(value: str) -> str: + executed.append(f"record:{value}") + return value + + @function_tool(needs_approval=True) + def approval_gate(value: str) -> str: + executed.append(f"gate:{value}") + return value + + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "record_value", + json.dumps({"value": "safe"}), + call_id="call_0", + ) + ], + [ + get_function_tool_call( + "approval_gate", + json.dumps({"value": "pause"}), + call_id="call_1", + ) + ], + [ + get_function_tool_call( + "record_value", + json.dumps({"value": replay_value}, separators=(",", ":")), + call_id="call_0", + ) + ], + [get_text_message("done")], + ] + ) + run_config = RunConfig(model_provider=_ScriptedProvider(model)) + agent = Agent( + name="custom-provider-agent", + model="scripted-provider-model", + tools=[record_value, approval_gate], + ) + return agent, run_config, executed + + +@pytest.mark.asyncio +async def test_serialized_pending_approval_keeps_its_invocation_binding() -> None: + agent, run_config, executed = _build_scenario("call_0", "changed") + + first = await Runner.run(agent, "record a value", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + restored = await RunState.from_string(agent, state.to_string()) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, restored, run_config=run_config) + + assert executed == ["safe"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_serialized_completed_approval_rejects_changed_replay( + mode: Literal["non_streamed", "streamed"], +) -> None: + agent, run_config, executed = _build_serialized_replay_scenario("changed") + + first = await _run(agent, "record a value", run_config=run_config, mode=mode) + state = first.to_state() + state.approve(first.interruptions[0]) + second = await _run(agent, state, run_config=run_config, mode=mode) + restored = await RunState.from_string(agent, second.to_state().to_string()) + restored.approve(restored.get_interruptions()[0]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _run(agent, restored, run_config=run_config, mode=mode) + + assert executed == ["record:safe", "gate:pause"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +async def test_serialized_completed_approval_skips_exact_replay( + mode: Literal["non_streamed", "streamed"], +) -> None: + agent, run_config, executed = _build_serialized_replay_scenario("safe") + + first = await _run(agent, "record a value", run_config=run_config, mode=mode) + state = first.to_state() + state.approve(first.interruptions[0]) + second = await _run(agent, state, run_config=run_config, mode=mode) + restored = await RunState.from_string(agent, second.to_state().to_string()) + restored.approve(restored.get_interruptions()[0]) + + result = await _run(agent, restored, run_config=run_config, mode=mode) + + assert result.final_output == "done" + assert executed == ["record:safe", "gate:pause"] + provider = run_config.model_provider + assert isinstance(provider, _ScriptedProvider) + model = provider.model + assert isinstance(model, FakeModel) + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + for call_id in ("call_0", "call_1"): + calls = [ + item + for item in model_input + if item.get("type") == "function_call" and item.get("call_id") == call_id + ] + outputs = [ + item + for item in model_input + if item.get("type") == "function_call_output" and item.get("call_id") == call_id + ] + assert len(calls) == 1 + assert len(outputs) == 1 + + +async def _restore_as_schema_1_13( + agent: Agent[Any], + state: RunState[Any, Agent[Any]], +) -> RunState[Any, Agent[Any]]: + json_data = state.to_json() + json_data["$schemaVersion"] = "1.13" + json_data["context"].pop("tool_invocations", None) + return await RunState.from_json(agent, json_data) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version", ["1.13", "1.14"]) +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("replay_value", ["safe", "changed"]) +async def test_legacy_schema_historical_sticky_call_id_is_not_reexecuted( + schema_version: str, + mode: Literal["non_streamed", "streamed"], + replay_value: str, +) -> None: + executed: list[str] = [] + + @function_tool(needs_approval=True) + async def record_value(value: str) -> str: + executed.append(value) + return value + + historical_call = cast( + ResponseFunctionToolCall, + get_function_tool_call( + "record_value", + '{"value":"safe"}', + call_id="call_0", + ), + ) + model = FakeModel( + initial_output=[ + get_function_tool_call( + "record_value", + json.dumps({"value": replay_value}), + call_id="call_0", + ) + ] + ) + model.set_next_output([get_text_message("done")]) + agent = Agent(name="agent", model=model, tools=[record_value]) + context: RunContextWrapper[Any] = RunContextWrapper(context=None) + context.approve_tool( + ToolApprovalItem(agent=agent, raw_item=historical_call), + always_approve=True, + ) + state = RunState( + context=context, + original_input=[ + historical_call.model_dump(exclude_none=True), + { + "type": "function_call_output", + "call_id": "call_0", + "output": "safe", + }, + ], + starting_agent=agent, + ) + serialized = state.to_json() + serialized["$schemaVersion"] = schema_version + serialized["context"].pop("tool_invocations", None) + restored = await RunState.from_json(agent, serialized) + + if replay_value == "changed": + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _run(agent, restored, run_config=RunConfig(), mode=mode) + else: + result = await _run(agent, restored, run_config=RunConfig(), mode=mode) + assert result.final_output == "done" + + assert executed == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version", ["1.13", "1.14"]) +async def test_legacy_changed_pending_call_fails_before_approval_callback( + schema_version: str, +) -> None: + approval_calls: list[str] = [] + + async def needs_approval(_context: Any, arguments: dict[str, Any], _call_id: str) -> bool: + approval_calls.append(arguments["value"]) + return True + + @function_tool(needs_approval=needs_approval) + async def record_value(value: str) -> str: + return value + + historical_call = cast( + ResponseFunctionToolCall, + get_function_tool_call( + "record_value", + '{"value":"safe"}', + call_id="call_0", + ), + ) + changed_call = cast( + ResponseFunctionToolCall, + get_function_tool_call( + "record_value", + '{"value":"changed"}', + call_id="call_0", + ), + ) + agent = Agent(name="agent", tools=[record_value]) + context: RunContextWrapper[Any] = RunContextWrapper(context=None) + context.approve_tool(ToolApprovalItem(agent=agent, raw_item=historical_call)) + pending_item = ToolApprovalItem(agent=agent, raw_item=changed_call) + state = make_state_with_interruptions( + agent, + [pending_item], + original_input=cast( + Any, + [ + historical_call.model_dump(exclude_none=True), + { + "type": "function_call_output", + "call_id": "call_0", + "output": "safe", + }, + ], + ), + ) + state._context = context + serialized = state.to_json() + serialized["$schemaVersion"] = schema_version + serialized["context"].pop("tool_invocations", None) + restored = await RunState.from_json(agent, serialized) + restored_pending = restored.get_interruptions()[0] + run = ToolRunFunction(tool_call=changed_call, function_tool=record_value) + + async def build_rejection(_run: ToolRunFunction, _call_id: str) -> ToolApprovalItem: + return restored_pending + + async def check_approval(_run: ToolRunFunction) -> bool: + return await needs_approval(None, {"value": "changed"}, "call_0") + + assert restored._context is not None + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await _collect_runs_by_approval( + [run], + call_id_extractor=lambda item: item.tool_call.call_id, + tool_name_resolver=lambda item: item.function_tool.name, + rejection_builder=build_rejection, + context_wrapper=restored._context, + approval_items_by_call_id={"call_0": restored_pending}, + agent=agent, + pending_interruption_adder=lambda _item: None, + needs_approval_checker=check_approval, + ) + + assert approval_calls == [] + + +@pytest.mark.asyncio +async def test_schema_1_13_completed_approval_rejects_changed_replay() -> None: + agent, run_config, executed = _build_serialized_replay_scenario("changed") + + first = await Runner.run(agent, "record a value", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + second = await Runner.run(agent, state, run_config=run_config) + restored = await _restore_as_schema_1_13(agent, second.to_state()) + restored.approve(restored.get_interruptions()[0]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, restored, run_config=run_config) + + assert executed == ["record:safe", "gate:pause"] + + +@pytest.mark.asyncio +async def test_schema_1_13_completed_approval_skips_exact_replay() -> None: + agent, run_config, executed = _build_serialized_replay_scenario("safe") + + first = await Runner.run(agent, "record a value", run_config=run_config) + state = first.to_state() + state.approve(first.interruptions[0]) + second = await Runner.run(agent, state, run_config=run_config) + restored = await _restore_as_schema_1_13(agent, second.to_state()) + restored.approve(restored.get_interruptions()[0]) + + result = await Runner.run(agent, restored, run_config=run_config) + + assert result.final_output == "done" + assert executed == ["record:safe", "gate:pause"] + + +def _build_mcp_approval_request( + agent: Agent[Any], + *, + name: str = "lookup", + server_label: str = "test_server", + call_id: str = "mcp_call_0", + arguments: str | None = None, +) -> tuple[Any, ToolApprovalItem]: + request_item = McpApprovalRequest( + id=call_id, + type="mcp_approval_request", + name=name, + server_label=server_label, + arguments=arguments or json.dumps({"query": "safe"}), + ) + request = SimpleNamespace( + request_item=request_item, + mcp_tool=SimpleNamespace(name=name, on_approval_request=None), + ) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=request_item, + tool_name=name, + ) + return request, approval_item + + +def test_wrapped_mcp_approval_uses_the_same_canonical_request_id() -> None: + agent = Agent(name="mcp-approval-agent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "hosted_tool_call", + "id": "outer-item-id", + "call_id": "shared-request", + "name": "lookup", + "provider_data": { + "type": "mcp_approval_request", + "name": "lookup", + "server_label": "test_server", + "arguments": '{"query":"safe"}', + }, + }, + tool_name="lookup", + ) + changed_request, _ = _build_mcp_approval_request( + agent, + call_id="shared-request", + arguments='{"query":"changed"}', + ) + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + context.approve_tool(approval_item) + + assert set(context._tool_invocations) == {"shared-request"} + with pytest.raises(ModelBehaviorError, match="unique call ID"): + collect_manual_mcp_approvals( + agent=agent, + requests=[changed_request], + context_wrapper=context, + existing_pending_by_call_id={"shared-request": approval_item}, + ) + + +def test_sticky_mcp_approval_rejects_same_id_on_another_server() -> None: + agent = Agent(name="mcp-approval-agent") + _, approval_item = _build_mcp_approval_request(agent, server_label="server_a") + changed_request, _ = _build_mcp_approval_request(agent, server_label="server_b") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + context.approve_tool(approval_item, always_approve=True) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + collect_manual_mcp_approvals( + agent=agent, + requests=[changed_request], + context_wrapper=context, + existing_pending_by_call_id={"mcp_call_0": approval_item}, + ) + + +def test_sticky_mcp_approval_reprompts_new_id_on_another_server() -> None: + agent = Agent(name="mcp-approval-agent") + _, approval_item = _build_mcp_approval_request(agent, server_label="server_a") + changed_request, _ = _build_mcp_approval_request( + agent, + server_label="server_b", + call_id="mcp_call_1", + ) + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + context.approve_tool(approval_item, always_approve=True) + + responses, pending = collect_manual_mcp_approvals( + agent=agent, + requests=[changed_request], + context_wrapper=context, + existing_pending_by_call_id={}, + ) + + assert responses == [] + assert len(pending) == 1 + assert pending[0].raw_item is changed_request.request_item + + +@pytest.mark.asyncio +async def test_serialized_sticky_mcp_scope_reprompts_another_server() -> None: + agent = Agent(name="mcp-approval-agent") + _, approval_item = _build_mcp_approval_request(agent, server_label="server_a") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = RunState(context=context, original_input="", starting_agent=agent) + state.approve(approval_item, always_approve=True) + restored = await RunState.from_json(agent, state.to_json()) + assert restored._context is not None + changed_request, _ = _build_mcp_approval_request( + agent, + server_label="server_b", + call_id="mcp_call_1", + ) + + responses, pending = collect_manual_mcp_approvals( + agent=agent, + requests=[changed_request], + context_wrapper=restored._context, + existing_pending_by_call_id={}, + ) + + assert responses == [] + assert len(pending) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version", ["1.13", "1.14"]) +@pytest.mark.parametrize("missing_field", ["arguments", "server_label"]) +async def test_unbindable_legacy_mcp_approval_requires_current_reapproval( + schema_version: str, + missing_field: str, +) -> None: + agent = Agent(name="mcp-approval-agent") + current_request, _ = _build_mcp_approval_request(agent) + approval_item = ToolApprovalItem( + agent=agent, + raw_item=current_request.request_item, + tool_name="lookup", + ) + state = make_state_with_interruptions(agent, [approval_item]) + assert state._context is not None + state._context._rebuild_approvals( # noqa: SLF001 + { + "lookup": { + "approved": ["mcp_call_0"], + "rejected": [], + } + } + ) + serialized = state.to_json() + serialized["$schemaVersion"] = schema_version + serialized["context"].pop("tool_invocations", None) + serialized["current_step"]["data"]["interruptions"][0]["raw_item"].pop(missing_field) + + restored = await RunState.from_json(agent, serialized) + assert restored._context is not None + restored_item = restored.get_interruptions()[0] + + responses, pending = collect_manual_mcp_approvals( + agent=agent, + requests=[current_request], + context_wrapper=restored._context, + existing_pending_by_call_id={"mcp_call_0": restored_item}, + ) + + assert responses == [] + assert len(pending) == 1 + assert pending[0].raw_item is current_request.request_item + + +def _build_unbindable_current_mcp_request() -> Any: + request_item = McpApprovalRequest.model_construct( + id="mcp_call_0", + type="mcp_approval_request", + name="lookup", + server_label="test_server", + ) + return SimpleNamespace( + request_item=request_item, + mcp_tool=SimpleNamespace(name="lookup", on_approval_request=None), + ) + + +@pytest.mark.asyncio +async def test_unbindable_mcp_callback_request_requires_manual_reapproval() -> None: + callback_calls = 0 + + def approve_request(_request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult: + nonlocal callback_calls + callback_calls += 1 + return {"approve": True} + + mcp_tool = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "test_server", + "server_url": "https://example.com", + "require_approval": "always", + }, + on_approval_request=approve_request, + ) + request = McpApprovalRequest.model_construct( + id="mcp_call_0", + type="mcp_approval_request", + name="lookup", + server_label="test_server", + ) + agent = Agent( + name="mcp-approval-agent", + model=FakeModel(initial_output=[request]), + tools=[mcp_tool], + ) + + result = await Runner.run(agent, "lookup") + + assert callback_calls == 0 + assert len(result.interruptions) == 1 + assert result.interruptions[0].raw_item is request + + +@pytest.mark.parametrize("always_approve", [False, True], ids=["per-call", "sticky"]) +def test_unbindable_current_manual_mcp_request_requires_reapproval( + always_approve: bool, +) -> None: + agent = Agent(name="mcp-approval-agent") + _, approval_item = _build_mcp_approval_request(agent) + current_request = _build_unbindable_current_mcp_request() + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + context.approve_tool(approval_item, always_approve=always_approve) + + responses, pending = collect_manual_mcp_approvals( + agent=agent, + requests=[current_request], + context_wrapper=context, + existing_pending_by_call_id={"mcp_call_0": approval_item}, + ) + + assert responses == [] + assert len(pending) == 1 + assert pending[0].raw_item is current_request.request_item + + +def test_unbindable_current_hosted_mcp_request_requires_reapproval() -> None: + agent = Agent(name="mcp-approval-agent") + _, approval_item = _build_mcp_approval_request(agent) + current_request = _build_unbindable_current_mcp_request() + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + context.approve_tool(approval_item) + appended: list[Any] = [] + + pending, pending_ids = process_hosted_mcp_approvals( + original_pre_step_items=[approval_item], + mcp_approval_requests=[current_request], + context_wrapper=context, + agent=agent, + append_item=appended.append, + ) + + assert len(pending) == 1 + assert pending[0].raw_item is current_request.request_item + assert pending_ids == {"mcp_call_0"} + assert appended == pending + + +@pytest.mark.parametrize("with_callback", [False, True], ids=["manual", "callback"]) +@pytest.mark.asyncio +async def test_runner_omits_completed_mcp_approval_request_replay( + with_callback: bool, +) -> None: + callback_calls = 0 + + def approve_request(_request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult: + nonlocal callback_calls + callback_calls += 1 + return {"approve": True} + + mcp_tool = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "test_server", + "server_url": "https://example.com", + "require_approval": "always", + }, + on_approval_request=approve_request if with_callback else None, + ) + first_request = McpApprovalRequest( + id="mcp_call_0", + type="mcp_approval_request", + name="lookup", + server_label="test_server", + arguments='{"query": "safe", "limit": 1}', + ) + replayed_request = McpApprovalRequest( + id="mcp_call_0", + type="mcp_approval_request", + name="lookup", + server_label="test_server", + arguments='{"limit":1,"query":"safe"}', + ) + model = FakeModel() + model.add_multiple_turn_outputs( + [[first_request], [replayed_request], [get_text_message("done")]] + ) + agent = Agent(name="mcp-approval-agent", model=model, tools=[mcp_tool]) + + first = await Runner.run(agent, "lookup") + if with_callback: + result = first + else: + assert len(first.interruptions) == 1 + state = first.to_state() + state.approve(first.interruptions[0]) + result = await Runner.run(agent, state) + + assert result.final_output == "done" + assert callback_calls == int(with_callback) + model_input = model.last_turn_args["input"] + assert isinstance(model_input, list) + replay_items = [ + item + for item in model_input + if item.get("type") == "mcp_approval_request" and item.get("id") == "mcp_call_0" + ] + approval_responses = [ + item + for item in model_input + if item.get("type") == "mcp_approval_response" + and item.get("approval_request_id") == "mcp_call_0" + ] + assert len(replay_items) == 1 + assert len(approval_responses) == 1 + + +@pytest.mark.asyncio +async def test_serialized_completed_manual_mcp_approval_skips_exact_replay() -> None: + agent = Agent(name="mcp-approval-agent") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + request, approval_item = _build_mcp_approval_request(agent) + state = RunState(context=context, original_input="", starting_agent=agent) + state.approve(approval_item) + + first_responses, first_pending = collect_manual_mcp_approvals( + agent=agent, + requests=[request], + context_wrapper=context, + existing_pending_by_call_id={"mcp_call_0": approval_item}, + ) + + assert len(first_responses) == 1 + assert first_pending == [] + context._mark_tool_call_completed(first_responses[0].raw_item) + state._generated_items = [approval_item, first_responses[0]] + + restored = await RunState.from_string(agent, state.to_string()) + assert restored._context is not None + replayed_responses, replayed_pending = collect_manual_mcp_approvals( + agent=agent, + requests=[request], + context_wrapper=restored._context, + existing_pending_by_call_id={"mcp_call_0": approval_item}, + ) + + assert replayed_responses == [] + assert replayed_pending == [] + + +def test_completed_hosted_mcp_approval_reconciliation_skips_exact_replay() -> None: + agent = Agent(name="mcp-approval-agent") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + request, approval_item = _build_mcp_approval_request(agent) + context.approve_tool(approval_item) + appended: list[Any] = [] + + process_hosted_mcp_approvals( + original_pre_step_items=[approval_item], + mcp_approval_requests=[request], + context_wrapper=context, + agent=agent, + append_item=appended.append, + ) + + assert len(appended) == 1 + context._mark_tool_call_completed(appended[0].raw_item) + appended.clear() + + pending, pending_ids = process_hosted_mcp_approvals( + original_pre_step_items=[approval_item], + mcp_approval_requests=[request], + context_wrapper=context, + agent=agent, + append_item=appended.append, + ) + + assert appended == [] + assert pending == [] + assert pending_ids == set() + + +def test_sticky_manual_mcp_approval_rejects_same_id_for_changed_tool_name() -> None: + agent = Agent(name="mcp-approval-agent") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + _, approval_item = _build_mcp_approval_request(agent) + context.approve_tool(approval_item, always_approve=True) + + same_tool, _ = _build_mcp_approval_request( + agent, + arguments=json.dumps({"query": "changed"}), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + collect_manual_mcp_approvals( + agent=agent, + requests=[same_tool], + context_wrapper=context, + existing_pending_by_call_id={"mcp_call_0": approval_item}, + ) + changed_context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + changed_context.approve_tool(approval_item, always_approve=True) + changed_tool, _ = _build_mcp_approval_request( + agent, + name="delete_all", + arguments=json.dumps({"confirm": True}), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + collect_manual_mcp_approvals( + agent=agent, + requests=[changed_tool], + context_wrapper=changed_context, + existing_pending_by_call_id={"mcp_call_0": approval_item}, + ) + + +def test_sticky_hosted_mcp_approval_rejects_same_id_for_changed_tool_name() -> None: + agent = Agent(name="mcp-approval-agent") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + _, approval_item = _build_mcp_approval_request(agent) + context.approve_tool(approval_item, always_approve=True) + + same_tool, _ = _build_mcp_approval_request( + agent, + arguments=json.dumps({"query": "changed"}), + ) + same_appended: list[Any] = [] + with pytest.raises(ModelBehaviorError, match="unique call ID"): + process_hosted_mcp_approvals( + original_pre_step_items=[approval_item], + mcp_approval_requests=[same_tool], + context_wrapper=context, + agent=agent, + append_item=same_appended.append, + ) + changed_context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + changed_context.approve_tool(approval_item, always_approve=True) + changed_tool, _ = _build_mcp_approval_request( + agent, + name="delete_all", + arguments=json.dumps({"confirm": True}), + ) + changed_appended: list[Any] = [] + with pytest.raises(ModelBehaviorError, match="unique call ID"): + process_hosted_mcp_approvals( + original_pre_step_items=[approval_item], + mcp_approval_requests=[changed_tool], + context_wrapper=changed_context, + agent=agent, + append_item=changed_appended.append, + ) + + assert same_appended == [] + assert changed_appended == [] diff --git a/tests/test_tool_guardrails.py b/tests/test_tool_guardrails.py index 9402edf247..6819ba885d 100644 --- a/tests/test_tool_guardrails.py +++ b/tests/test_tool_guardrails.py @@ -539,8 +539,12 @@ def guarded(query: str) -> str: guarded.tool_output_guardrails = output_guardrails or [] model = FakeModel() - tool_call = [get_function_tool_call("guarded", '{"query": "secret"}')] - model.add_multiple_turn_outputs([tool_call, tool_call]) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("guarded", '{"query": "secret"}', call_id="guarded_1")], + [get_function_tool_call("guarded", '{"query": "secret"}', call_id="guarded_2")], + ] + ) return Agent(name="guarded_tool_agent", model=model, tools=[guarded]) diff --git a/tests/test_tool_name_collision_policy.py b/tests/test_tool_name_collision_policy.py index 9fa342890b..3eae9ba5f8 100644 --- a/tests/test_tool_name_collision_policy.py +++ b/tests/test_tool_name_collision_policy.py @@ -24,6 +24,7 @@ tool_namespace, ) from agents.items import ToolCallOutputItem +from agents.lifecycle import RunHooks from agents.tool import Tool, function_tool from .fake_model import FakeModel @@ -98,7 +99,7 @@ async def test_resume_error_mode_rejects_current_collision_before_side_effects() @pytest.mark.parametrize("deserialize", [False, True]) @pytest.mark.asyncio -async def test_resume_reclassifies_function_call_to_current_handoff( +async def test_resume_rejects_function_approval_reclassified_as_handoff( deserialize: bool, ) -> None: calls: list[str] = [] @@ -144,15 +145,61 @@ def route_function() -> str: state._model_responses[-1] = replace(state._model_responses[-1], output=[]) state.approve(state.get_interruptions()[0]) - resumed_result = await Runner.run(agent, state) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state) + + assert calls == [] + assert filter_calls == [] + + +@pytest.mark.asyncio +async def test_reclassified_handoff_is_rejected_before_run_hook() -> None: + calls: list[str] = [] + + route_tool = function_tool( + lambda: "function", + name_override="route", + needs_approval=True, + ) + target = Agent( + name="target", + model=FakeModel(initial_output=[get_text_message("target done")]), + ) + route_handoff = handoff( + target, + tool_name_override="route", + on_handoff=lambda _: calls.append("handoff"), + ) + model = FakeModel(initial_output=[get_function_tool_call("route", "{}", call_id="route")]) + agent = Agent(name="agent", model=model, tools=[route_tool]) + + first = await Runner.run(agent, "Route this request") + state = first.to_state() + state.approve(state.get_interruptions()[0]) + state._model_responses[-1] = replace(state._model_responses[-1], output=[]) + agent.tools = [] + agent.handoffs = [route_handoff] - assert resumed_result.final_output == "target done" - assert calls == ["handoff"] - assert filter_calls == ["filter"] + hook_calls: list[str] = [] + + class RecordingHandoffHooks(RunHooks[Any]): + async def on_handoff( + self, + context: RunContextWrapper[Any], + from_agent: Agent[Any], + to_agent: Agent[Any], + ) -> None: + hook_calls.append("handoff") + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state, hooks=RecordingHandoffHooks()) + + assert calls == [] + assert hook_calls == [] @pytest.mark.asyncio -async def test_resume_reclassifies_queued_handoff_to_current_function() -> None: +async def test_resume_rejects_queued_handoff_reclassified_as_function() -> None: calls: list[str] = [] def approved_function() -> str: @@ -192,10 +239,10 @@ def route_function() -> str: agent.handoffs = [] state._model_responses[-1] = replace(state._model_responses[-1], output=[]) - resumed_result = await Runner.run(agent, state) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state) - assert resumed_result.final_output == "done" - assert calls == ["approved", "route"] + assert calls == [] @pytest.mark.asyncio @@ -441,7 +488,9 @@ async def test_replacing_interrupted_agent_tool_fails_before_side_effects() -> N ) inner_agent = Agent( name="inner", - model=FakeModel(initial_output=[get_function_tool_call("sensitive", "{}")]), + model=FakeModel( + initial_output=[get_function_tool_call("sensitive", "{}", call_id="call_sensitive")] + ), tools=[sensitive_tool], ) nested_tool = inner_agent.as_tool( @@ -450,7 +499,15 @@ async def test_replacing_interrupted_agent_tool_fails_before_side_effects() -> N ) outer_agent = Agent( name="outer", - model=FakeModel(initial_output=[get_function_tool_call("lookup", '{"input":"hi"}')]), + model=FakeModel( + initial_output=[ + get_function_tool_call( + "lookup", + '{"input":"hi"}', + call_id="call_lookup", + ) + ] + ), tools=[nested_tool], ) @@ -533,7 +590,7 @@ async def test_resume_preserves_model_order_for_function_outcomes() -> None: @pytest.mark.asyncio -async def test_resume_preserves_duplicate_agent_tool_calls() -> None: +async def test_resume_preserves_multiple_agent_tool_calls() -> None: inner_calls: list[str] = [] @function_tool(needs_approval=True) @@ -561,12 +618,12 @@ async def inner_hitl_tool() -> str: get_function_tool_call( agent_tool.name, '{"input":"a"}', - call_id="outer-dup", + call_id="outer-a", ), get_function_tool_call( agent_tool.name, '{"input":"b"}', - call_id="outer-dup", + call_id="outer-b", ), ] ) @@ -587,7 +644,7 @@ async def inner_hitl_tool() -> str: if isinstance(item, ToolCallOutputItem) and isinstance(item.raw_item, dict) and item.raw_item.get("type") == "function_call_output" - and item.raw_item.get("call_id") == "outer-dup" + and item.raw_item.get("call_id") in {"outer-a", "outer-b"} ] assert len(outer_outputs) == 2 @@ -1201,7 +1258,7 @@ async def test_nested_rebind_is_not_committed_before_later_strict_missing_error( @pytest.mark.asyncio -async def test_resume_preserves_cross_kind_duplicate_call_id_baseline() -> None: +async def test_cross_kind_duplicate_call_id_fails_before_execution() -> None: calls: list[str] = [] missing_tool = function_tool( lambda: _record(calls, "missing"), @@ -1213,10 +1270,6 @@ async def test_resume_preserves_cross_kind_duplicate_call_id_baseline() -> None: name_override="lookup", needs_approval=True, ) - replacement_tool = function_tool( - lambda: _record(calls, "replacement"), - name_override="lookup", - ) shell_tool = ShellTool( executor=lambda _request: _record(calls, "shell"), ) @@ -1241,36 +1294,16 @@ async def test_resume_preserves_cross_kind_duplicate_call_id_baseline() -> None: shell_call, ] ) - model.set_next_output([get_text_message("done")]) agent = Agent( name="agent", model=model, tools=[missing_tool, original_tool, shell_tool], ) - initial_result = await Runner.run(agent, "Look this up") - assert calls == ["shell"] - state = initial_result.to_state() - for interruption in state.get_interruptions(): - state.approve(interruption) - agent.tools = [replacement_tool, shell_tool] + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, "Look this up") - resumed_result = await Runner.run( - agent, - state, - run_config=RunConfig(tool_not_found_behavior="return_error_to_model"), - ) - - assert resumed_result.final_output == "done" - assert calls == ["shell", "original"] - output_ids = [ - cast(dict[str, Any], item.raw_item)["call_id"] - for item in resumed_result.new_items - if isinstance(item, ToolCallOutputItem) - and isinstance(item.raw_item, dict) - and item.raw_item.get("type") == "function_call_output" - ] - assert output_ids == ["missing_call", "shared_call"] + assert calls == [] @pytest.mark.parametrize( @@ -1363,7 +1396,10 @@ async def test_resume_rejects_cross_kind_approval_identity_before_sibling_effect @pytest.mark.asyncio -async def test_missing_formatter_cancellation_precedes_sibling_side_effects() -> None: +@pytest.mark.parametrize("streamed", [False, True], ids=["non-streamed", "streamed"]) +async def test_missing_formatter_cancellation_precedes_sibling_side_effects( + streamed: bool, +) -> None: calls: list[str] = [] formatter_started = asyncio.Event() keep_formatter_waiting = asyncio.Event() @@ -1397,14 +1433,20 @@ async def blocking_formatter(_args: Any) -> str: await keep_formatter_waiting.wait() return "missing" + async def resume(run_config: RunConfig) -> Any: + if not streamed: + return await Runner.run(agent, state, run_config=run_config) + result = Runner.run_streamed(agent, state, run_config=run_config) + async for _event in result.stream_events(): + pass + return result + resume_task = asyncio.create_task( - Runner.run( - agent, - state, - run_config=RunConfig( + resume( + RunConfig( tool_not_found_behavior="return_error_to_model", tool_error_formatter=blocking_formatter, - ), + ) ) ) await formatter_started.wait() @@ -1414,11 +1456,7 @@ async def blocking_formatter(_args: Any) -> str: assert calls == [] - resumed_result = await Runner.run( - agent, - state, - run_config=RunConfig(tool_not_found_behavior="return_error_to_model"), - ) + with pytest.raises(ModelBehaviorError, match="already executed"): + await resume(RunConfig(tool_not_found_behavior="return_error_to_model")) - assert resumed_result.final_output == "done" - assert calls == ["available"] + assert calls == [] diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index e256f90cc8..b37622ef90 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -236,8 +236,8 @@ async def test_multiple_handoff_doesnt_error(): # Second turn: a message and 2 handoff [ get_text_message("a_message"), - get_handoff_tool_call(agent_1), - get_handoff_tool_call(agent_2), + get_handoff_tool_call(agent_1, call_id="handoff_1"), + get_handoff_tool_call(agent_2, call_id="handoff_2"), ], # Third turn: text message [get_text_message("done")], @@ -363,7 +363,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message and 2 handoff [ get_text_message("a_message"), @@ -371,7 +371,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): get_handoff_tool_call(agent_2), ], # Third turn: tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2")], # Fourth turn: handoff [get_handoff_tool_call(agent_3)], # Fifth turn: text message @@ -477,11 +477,11 @@ async def test_max_turns_exceeded(): model.add_multiple_turn_outputs( [ - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], + [get_function_tool_call("foo", call_id="tool_1")], + [get_function_tool_call("foo", call_id="tool_2")], + [get_function_tool_call("foo", call_id="tool_3")], + [get_function_tool_call("foo", call_id="tool_4")], + [get_function_tool_call("foo", call_id="tool_5")], ] ) diff --git a/tests/test_tracing_errors_streamed.py b/tests/test_tracing_errors_streamed.py index 69e65fdadb..52b6b50a58 100644 --- a/tests/test_tracing_errors_streamed.py +++ b/tests/test_tracing_errors_streamed.py @@ -292,8 +292,8 @@ async def test_multiple_handoff_doesnt_error(): # Second turn: a message and 2 handoff [ get_text_message("a_message"), - get_handoff_tool_call(agent_1), - get_handoff_tool_call(agent_2), + get_handoff_tool_call(agent_1, call_id="handoff_1"), + get_handoff_tool_call(agent_2, call_id="handoff_2"), ], # Third turn: text message [get_text_message("done")], @@ -421,7 +421,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): model.add_multiple_turn_outputs( [ # First turn: a tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], # Second turn: a message and 2 handoff [ get_text_message("a_message"), @@ -429,7 +429,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): get_handoff_tool_call(agent_2), ], # Third turn: tool call - [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2")], # Fourth turn: handoff [get_handoff_tool_call(agent_3)], # Fifth turn: text message @@ -532,11 +532,11 @@ async def test_max_turns_exceeded(): model.add_multiple_turn_outputs( [ - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], - [get_function_tool_call("foo")], + [get_function_tool_call("foo", call_id="tool_1")], + [get_function_tool_call("foo", call_id="tool_2")], + [get_function_tool_call("foo", call_id="tool_3")], + [get_function_tool_call("foo", call_id="tool_4")], + [get_function_tool_call("foo", call_id="tool_5")], ] ) From b8aed66015710cdcc75ebb4302a42e958aa8c6a2 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:18:27 -0700 Subject: [PATCH 216/473] fix(sessions): preserve DaprSession created_at across writes (#4213) --- src/agents/extensions/memory/dapr_session.py | 94 ++++++- tests/extensions/memory/test_dapr_session.py | 263 ++++++++++++++++++- 2 files changed, 342 insertions(+), 15 deletions(-) diff --git a/src/agents/extensions/memory/dapr_session.py b/src/agents/extensions/memory/dapr_session.py index 20b300ce3c..f9462fd465 100644 --- a/src/agents/extensions/memory/dapr_session.py +++ b/src/agents/extensions/memory/dapr_session.py @@ -43,7 +43,11 @@ ) from ...items import TResponseInputItem -from ...logger import log_model_and_tool_action_error, logger +from ...logger import ( + log_model_and_tool_action_error, + log_model_and_tool_action_warning, + logger, +) from ...memory.session import SessionABC from ...memory.session_settings import ( SessionSettings, @@ -184,6 +188,32 @@ def _get_metadata(self) -> dict[str, str]: metadata["ttlInSeconds"] = str(self._ttl) return metadata + async def _read_created_at(self) -> tuple[str | None, str | None]: + """Return the stored creation timestamp and the metadata etag backing it. + + The timestamp is None when it is missing or unreadable. The etag is returned so the + caller can write the metadata conditionally against the same revision it read. The + Dapr SDK reports a missing etag as an empty string, so it is normalized to None and + callers can test for a real etag rather than for a particular empty representation. + """ + response = await self._dapr_client.get_state( + store_name=self._state_store_name, + key=self._metadata_key, + state_metadata=self._get_read_metadata(), + ) + etag = response.etag or None + data = response.data + if not data: + return None, etag + try: + stored = json.loads(data.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return None, etag + if not isinstance(stored, dict): + return None, etag + created_at = stored.get("created_at") + return (created_at if isinstance(created_at, str) and created_at else None), etag + async def _serialize_item(self, item: TResponseInputItem) -> str: """Serialize an item to JSON string. Can be overridden by subclasses.""" return json.dumps(item, separators=(",", ":")) @@ -362,19 +392,55 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: continue raise - # Update metadata - metadata = { - "session_id": self.session_id, - "created_at": str(int(time.time())), - "updated_at": str(int(time.time())), - } - await self._dapr_client.save_state( - store_name=self._state_store_name, - key=self._metadata_key, - value=json.dumps(metadata), - state_metadata=self._get_metadata(), - options=self._get_state_options(), - ) + # Update metadata, preserving created_at across subsequent writes. A plain write + # would let a later append overwrite created_at with its own now, so the save is + # guarded by the etag that backed the value that was read. + # + # The guard only applies once a real etag was read. Dapr documents a write without + # an etag as last-write-wins even when first-write concurrency is requested, so + # asking for it on the create is not a race guarantee and is left off rather than + # implying one. Concurrent etag-less creation is therefore last-write-wins. + # + # The messages key is already committed once we reach here, so raising would tell + # the caller the append failed while their items are in the session, and the + # natural response of retrying add_items() would store the batch twice. Metadata + # is derived bookkeeping, so give up on it with a warning instead. + attempt = 0 + while True: + attempt += 1 + try: + stored_created_at, metadata_etag = await self._read_created_at() + now = str(int(time.time())) + metadata = { + "session_id": self.session_id, + "created_at": stored_created_at or now, + "updated_at": now, + } + await self._dapr_client.save_state( + store_name=self._state_store_name, + key=self._metadata_key, + value=json.dumps(metadata), + etag=metadata_etag, + state_metadata=self._get_metadata(), + options=self._get_state_options( + concurrency=( + Concurrency.first_write if metadata_etag is not None else None + ) + ), + ) + break + except Exception as error: + should_retry = await self._handle_concurrency_conflict(error, attempt) + if should_retry: + continue + log_model_and_tool_action_warning( + logger, + "DaprSession stored the new items but could not update the session " + "metadata", + error, + diagnostic_extra=lambda: {"session_id": self.session_id}, + ) + break async def pop_item(self) -> TResponseInputItem | None: """Remove and return the most recent item from the session. diff --git a/tests/extensions/memory/test_dapr_session.py b/tests/extensions/memory/test_dapr_session.py index 702cb7388c..5458d39128 100644 --- a/tests/extensions/memory/test_dapr_session.py +++ b/tests/extensions/memory/test_dapr_session.py @@ -40,7 +40,8 @@ async def get_state( """Get state from in-memory store.""" response = Mock() response.data = self._state.get(key, b"") - response.etag = self._etags.get(key) + # The Dapr SDK reports a missing etag as an empty string rather than None. + response.etag = self._etags.get(key, "") return response async def save_state( @@ -1327,3 +1328,263 @@ def entry_signalling_resolve(*args: Any, **kwargs: Any) -> Any: task.cancel() with suppress(asyncio.CancelledError, RuntimeError): await task + + +async def test_add_items_preserves_created_at_metadata( + fake_dapr_client: FakeDaprClient, monkeypatch: pytest.MonkeyPatch +): + """`created_at` must be set once and not overwritten by subsequent add_items calls.""" + session = await _create_test_session(fake_dapr_client) + + try: + monkeypatch.setattr("agents.extensions.memory.dapr_session.time.time", lambda: 1000.0) + await session.add_items([{"role": "user", "content": "first"}]) + first = json.loads(fake_dapr_client._state[session._metadata_key].decode("utf-8")) + assert first["created_at"] == "1000" + assert first["updated_at"] == "1000" + + monkeypatch.setattr("agents.extensions.memory.dapr_session.time.time", lambda: 2000.0) + await session.add_items([{"role": "user", "content": "second"}]) + second = json.loads(fake_dapr_client._state[session._metadata_key].decode("utf-8")) + assert second["created_at"] == "1000" + assert second["updated_at"] == "2000" + finally: + await session.close() + + +async def test_metadata_creation_does_not_request_first_write( + fake_dapr_client: FakeDaprClient, monkeypatch: pytest.MonkeyPatch +): + """Creating the metadata key must not ask for first-write concurrency. + + Dapr treats a write with no etag as last-write-wins even when first-write is requested, + so asking for it on the create would imply a guarantee the store does not provide. The + SDK reports a missing etag as an empty string, so this also pins that the empty value is + not mistaken for a real one. + """ + session = await _create_test_session(fake_dapr_client, "metadata_create_concurrency") + + try: + real_save = fake_dapr_client.save_state + seen: list[tuple[str | None, Any]] = [] + + async def record_metadata_saves( + store_name: str, + key: str, + value: str | bytes, + **kwargs: Any, + ) -> None: + if key == session._metadata_key: + seen.append( + (kwargs.get("etag"), getattr(kwargs.get("options"), "concurrency", None)) + ) + await real_save(store_name, key, value, **kwargs) + + monkeypatch.setattr(fake_dapr_client, "save_state", record_metadata_saves) + + await session.add_items([{"role": "user", "content": "first"}]) + assert len(seen) == 1 + create_etag, create_concurrency = seen[0] + # No real etag existed, so no etag is sent and concurrency is left unspecified + # rather than first-write, which Dapr would ignore here anyway. + assert create_etag is None + assert getattr(create_concurrency, "name", None) == "unspecified" + + await session.add_items([{"role": "user", "content": "second"}]) + assert len(seen) == 2 + update_etag, update_concurrency = seen[1] + # Metadata now exists, so the update is guarded by the etag that backed it. + assert update_etag is not None + assert getattr(update_concurrency, "name", None) == "first_write" + finally: + await session.close() + + +async def test_stale_metadata_etag_retries_and_keeps_created_at( + fake_dapr_client: FakeDaprClient, monkeypatch: pytest.MonkeyPatch +): + """A metadata save guarded by a stale etag must retry and keep the stored `created_at`. + + Once the metadata key exists, every later append reads a real etag and saves against it. + A writer holding an etag that another append has already superseded is rejected, so it + re-reads and adopts the stored `created_at` rather than replacing it with its own `now`. + + Dapr treats a write with no etag as last-write-wins even when first-write concurrency is + requested, so the create is deliberately not covered here. This is the guarantee the + store actually provides. + """ + session_id = "shared_session_created_at_stale_etag" + first = await _create_test_session(fake_dapr_client, session_id=session_id) + second = await _create_test_session(fake_dapr_client, session_id=session_id) + + try: + monkeypatch.setattr("agents.extensions.memory.dapr_session.time.time", lambda: 1000.0) + await first.add_items([{"role": "user", "content": "first"}]) + created = json.loads(fake_dapr_client._state[first._metadata_key].decode("utf-8")) + assert created["created_at"] == "1000" + stale_etag = fake_dapr_client._etags[first._metadata_key] + + # A second append supersedes that etag, so the value captured above is now stale but + # still non-null, which is the situation the guard is actually for. + monkeypatch.setattr("agents.extensions.memory.dapr_session.time.time", lambda: 1500.0) + await first.add_items([{"role": "user", "content": "second"}]) + assert fake_dapr_client._etags[first._metadata_key] != stale_etag + + real_read = second._read_created_at + calls = 0 + + async def read_stale_once() -> tuple[str | None, str | None]: + nonlocal calls + calls += 1 + if calls == 1: + # Pretend this writer read the metadata before the second append landed. + return "1000", stale_etag + return await real_read() + + monkeypatch.setattr(second, "_read_created_at", read_stale_once) + monkeypatch.setattr("agents.extensions.memory.dapr_session.time.time", lambda: 2000.0) + await second.add_items([{"role": "user", "content": "third"}]) + + final = json.loads(fake_dapr_client._state[second._metadata_key].decode("utf-8")) + # The stale save is rejected, so the retry reads the stored value and keeps it. + assert final["created_at"] == "1000" + assert final["updated_at"] == "2000" + # Two reads means the conditional save actually rejected the stale one and retried. + assert calls == 2 + finally: + await first.close() + await second.close() + + +async def test_add_items_survives_metadata_write_giving_up( + fake_dapr_client: FakeDaprClient, monkeypatch: pytest.MonkeyPatch, caplog: Any +): + """A metadata write that exhausts its retries must not fail an append that already landed. + + The messages key is saved before the metadata key, so raising here would report failure for + items that are already in the session, and the natural retry of `add_items` would store the + same batch a second time. + """ + import logging + + session = await _create_test_session(fake_dapr_client, "metadata_gives_up") + + try: + real_save = fake_dapr_client.save_state + + async def fail_only_metadata_saves( + store_name: str, + key: str, + value: str | bytes, + **kwargs: Any, + ) -> None: + if key == session._metadata_key: + raise RuntimeError("etag mismatch") + await real_save(store_name, key, value, **kwargs) + + monkeypatch.setattr(fake_dapr_client, "save_state", fail_only_metadata_saves) + monkeypatch.setattr(session, "_calculate_retry_delay", lambda attempt: 0.0) + + with caplog.at_level(logging.WARNING): + await session.add_items([{"role": "user", "content": "kept"}]) + + items = await session.get_items() + assert [item.get("content") for item in items] == ["kept"] + + warnings = [ + record for record in caplog.records if "could not update" in record.getMessage() + ] + assert warnings + # Data logging is off by default, so neither the caller-supplied session id nor the + # provider error text may reach the record. + for record in warnings: + assert "metadata_gives_up" not in record.getMessage() + assert "etag mismatch" not in record.getMessage() + finally: + await session.close() + + +@pytest.mark.parametrize( + ("dont_log_model_data", "dont_log_tool_data", "redacted"), + [ + (True, False, True), + (False, True, True), + (False, False, False), + ], + ids=["model-redacted", "tool-redacted", "fully-diagnostic"], +) +async def test_metadata_write_warning_respects_data_policies( + fake_dapr_client: FakeDaprClient, + monkeypatch: pytest.MonkeyPatch, + caplog: Any, + dont_log_model_data: bool, + dont_log_tool_data: bool, + redacted: bool, +): + """The give-up warning must obey both data policies, and never lose the committed items. + + `log_model_and_tool_action_warning` redacts when either policy is on, so only the mode with + both off may carry the session id or the provider error. This inspects the whole LogRecord + rather than just the rendered message, because the session id travels in `extra` and the + exception travels in `exc_info`, neither of which shows up in `getMessage()`. + """ + import logging + + import agents._debug as _debug + + session_id = f"metadata_policy_{dont_log_model_data}_{dont_log_tool_data}" + session = await _create_test_session(fake_dapr_client, session_id) + + try: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", dont_log_model_data) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", dont_log_tool_data) + + real_save = fake_dapr_client.save_state + error_text = "etag mismatch" + + async def fail_only_metadata_saves( + store_name: str, + key: str, + value: str | bytes, + **kwargs: Any, + ) -> None: + if key == session._metadata_key: + raise RuntimeError(error_text) + await real_save(store_name, key, value, **kwargs) + + monkeypatch.setattr(fake_dapr_client, "save_state", fail_only_metadata_saves) + monkeypatch.setattr(session, "_calculate_retry_delay", lambda attempt: 0.0) + + with caplog.at_level(logging.WARNING): + await session.add_items([{"role": "user", "content": "kept"}]) + + # The append landed regardless of how the failure was logged. + items = await session.get_items() + assert [item.get("content") for item in items] == ["kept"] + + records = [record for record in caplog.records if "could not update" in record.getMessage()] + assert len(records) == 1 + record = records[0] + rendered = logging.Formatter().format(record) + + if redacted: + # Redacted form is the bare message with no exception and no diagnostic context. + assert record.msg == "%s" + assert record.exc_info is None + assert record.exc_text is None + assert not hasattr(record, "openai_agents_diagnostic_context") + assert session_id not in rendered + assert error_text not in rendered + assert all( + session_id not in str(value) and error_text not in str(value) + for value in record.__dict__.values() + ) + else: + # Diagnostic form carries the exception and the session id, by design. + assert record.msg == "%s: %s" + assert record.exc_info is not None + assert isinstance(record.exc_info[1], RuntimeError) + assert error_text in rendered + assert record.openai_agents_diagnostic_context == {"session_id": session_id} + finally: + await session.close() From 5a249592ef454b250c5a8bb9c5cf864bf692bc0e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 22:10:26 +0900 Subject: [PATCH 217/473] feat: use GPT-5.6 Luna as the default model (#4282) --- src/agents/agent.py | 2 +- src/agents/models/default_models.py | 2 +- tests/models/test_default_models.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index 9b572846ff..c5989f32d0 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -338,7 +338,7 @@ class Agent(AgentBase, Generic[TContext]): """The model implementation to use when invoking the LLM. By default, if not set, the agent will use the default model configured in - `agents.models.get_default_model()` (currently "gpt-5.4-mini"). + `agents.models.get_default_model()` (currently "gpt-5.6-luna"). """ model_settings: ModelSettings = field(default_factory=get_default_model_settings) diff --git a/src/agents/models/default_models.py b/src/agents/models/default_models.py index 05baef9c24..3a9d7e9c8f 100644 --- a/src/agents/models/default_models.py +++ b/src/agents/models/default_models.py @@ -100,7 +100,7 @@ def get_default_model() -> str: """ Returns the default model name. """ - return os.getenv(OPENAI_DEFAULT_MODEL_ENV_VARIABLE_NAME, "gpt-5.4-mini").lower() + return os.getenv(OPENAI_DEFAULT_MODEL_ENV_VARIABLE_NAME, "gpt-5.6-luna").lower() def get_default_model_settings(model: str | None = None) -> ModelSettings: diff --git a/tests/models/test_default_models.py b/tests/models/test_default_models.py index b2300d8d31..9fb1f37d5c 100644 --- a/tests/models/test_default_models.py +++ b/tests/models/test_default_models.py @@ -23,8 +23,8 @@ def _gpt_5_default_settings( return ModelSettings(reasoning=Reasoning(effort=reasoning_effort), verbosity="low") -def test_default_model_is_gpt_5_4_mini(): - assert get_default_model() == "gpt-5.4-mini" +def test_default_model_is_gpt_5_6_luna(): + assert get_default_model() == "gpt-5.6-luna" assert is_gpt_5_default() is True assert gpt_5_reasoning_settings_required(get_default_model()) is True assert get_default_model_settings() == _gpt_5_default_settings("none") From 237716cfb3047356dcdce8a8557a6750006e98c7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 00:25:16 +0900 Subject: [PATCH 218/473] perf: add impact-aware review test target --- .../implementation-final-review/SKILL.md | 2 +- .../scripts/test_skill_contract.py | 15 +++++++ .github/scripts/run_serial_tests.py | 13 ++++-- Makefile | 12 +++++ pyproject.toml | 3 +- tests/README.md | 6 +++ .../memory/test_advanced_sqlite_session.py | 2 + .../memory/test_dapr_redis_integration.py | 28 +++++++----- tests/mcp/test_mcp_pagination_integration.py | 1 + .../test_runner_pause_resume.py | 1 + tests/sandbox/test_unix_local.py | 1 + tests/test_run_serial_tests.py | 44 ++++++++++++++++++- tests/test_trace_processor.py | 1 + tests/tracing/test_import_side_effects.py | 3 ++ 14 files changed, 113 insertions(+), 19 deletions(-) diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index b04e9b7bf0..b2b1664e73 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -45,7 +45,7 @@ Treat implementation and final review as separate phases. Reconstruct the change Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. 10. Start a fingerprint-round counter at 1. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Separate pathspecs into `runtime`, `tests-examples`, and `release-metadata` components when those boundaries exist; use repository-appropriate names otherwise. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file runtime=runtime.paths --component-pathspec-file tests-examples=tests.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by root cause, severity, action, and whether each finding is new, repeated, or reintroduced. 11. Prepare one self-contained reviewer brief per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. -12. Freeze task-owned content while reviewers for a round are running. For normal risk, dispatch one independent reviewer. For elevated risk or a prior P0/P1, dispatch two independent reviewers concurrently on the same fingerprint and give them complementary primary dimensions. A broad multi-boundary normal-risk diff may also use two concurrent specialists when that is likely to collect findings in one round. Every reviewer sees the complete raw diff and may report blockers outside its specialty. Wait for every reviewer in the round before editing so findings can be grouped and fixed as one batch. Use one multi-target wait or the platform's first-completion wait when available; do not poll reviewers separately, ask for progress, or make them repeat shared evidence collection. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. Record combined and component fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when both fingerprints match the reviewed fingerprint exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit for the changed fingerprint. +12. Freeze task-owned content while reviewers for a round are running. For normal risk, dispatch one independent reviewer. For elevated risk or a prior P0/P1, dispatch two independent reviewers concurrently on the same fingerprint and give them complementary primary dimensions. A broad multi-boundary normal-risk diff may also use two concurrent specialists when that is likely to collect findings in one round. Every reviewer sees the complete raw diff and may report blockers outside its specialty. Wait for every reviewer in the round before editing so findings can be grouped and fixed as one batch. Use one multi-target wait or the platform's first-completion wait when available; do not poll reviewers separately, ask for progress, or make them repeat shared evidence collection. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. During an iterative review round, use the narrowest of three evidence-based choices: for changes unrelated to every `review_optional` owner, run `make tests-review`; for a leaf subsystem change, run `make tests-review` plus that subsystem's complete test file or directory without a marker filter; for cross-cutting core or shared test-infrastructure changes, run `make tests`. Inspect the current marker owners before choosing. The reduced check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete `make tests` gate. If the affected boundary is uncertain, run `make tests`. Record combined and component fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when both fingerprints match the reviewed fingerprint exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit for the changed fingerprint. 13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the round and all clean credit. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. If only tests, examples, or release metadata changed without changing required behavior, compatibility, assertions about runtime behavior, or the scope contract, preserve clean credit for unchanged components and require delta reviews of every changed component plus its boundary with runtime using the original risk tier: one independent reviewer for normal risk or two concurrent independent reviewers for elevated risk. Any ambiguity invalidates the affected clean credit. 14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. A valid response must state the verdict, exact reviewed fingerprints, dimensions actually checked, coverage of every assigned inventory row and changed public/shared-state surface, focused probes run or explicitly none, and remaining uncertainty. A bare `clean`, generic checklist, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared coverage and reject the round if any inventory row or selected high-risk dimension remains unreviewed. 15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. If the repository requires a separate strategy pass, rerun it when the fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification for every affected boundary. diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index 39a4f65aef..e72a36d892 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -118,6 +118,21 @@ def test_overlapped_final_gates_preserve_fingerprint_integrity(self) -> None: with self.subTest(text=text): self.assertIn(text, self.skill) + def test_iterative_review_can_skip_unaffected_slow_subsystems(self) -> None: + required_text = ( + "for changes unrelated to every `review_optional` owner, run `make tests-review`", + "for a leaf subsystem change, run `make tests-review` plus that subsystem's " + "complete test file or directory", + "for cross-cutting core or shared test-infrastructure changes, run `make tests`", + "The reduced check earns no final-gate credit", + "the exact clean-reviewed fingerprint must still pass the complete `make tests` gate", + "If the affected boundary is uncertain, run `make tests`", + ) + + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + def test_final_gate_deltas_are_classified_by_component(self) -> None: required_text = ( "Runtime, public API, behavior-impacting docs", diff --git a/.github/scripts/run_serial_tests.py b/.github/scripts/run_serial_tests.py index 46030bdb3a..9cbcaea2fe 100644 --- a/.github/scripts/run_serial_tests.py +++ b/.github/scripts/run_serial_tests.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import fnmatch import os import sys @@ -26,20 +27,26 @@ def _relative(path: Path) -> str: return str(path.relative_to(ROOT)) -def _serial_args() -> list[str]: +def _serial_args(*, marker_expression: str = "serial") -> list[str]: return [ sys.executable, "-m", "pytest", *(_relative(path) for path in _serial_test_files()), "-m", - "serial", + marker_expression, ] def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--exclude-review-optional", action="store_true") + args = parser.parse_args() os.chdir(ROOT) - os.execv(sys.executable, _serial_args()) + marker_expression = ( + "serial and not review_optional" if args.exclude_review_optional else "serial" + ) + os.execv(sys.executable, _serial_args(marker_expression=marker_expression)) if __name__ == "__main__": diff --git a/Makefile b/Makefile index ed7fa8a814..247bbb977e 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,10 @@ typecheck-src: tests: tests-parallel $(MAKE) tests-serial +.PHONY: tests-review +tests-review: tests-parallel-review + $(MAKE) tests-serial-review + .PHONY: tests-asyncio-stability tests-asyncio-stability: bash .github/scripts/run-asyncio-teardown-stability.sh @@ -56,10 +60,18 @@ tests-asyncio-stability: tests-parallel: uv run pytest -n "$${PYTEST_XDIST_AUTO_NUM_WORKERS:-auto}" $(if $(PYTEST_XDIST_AUTO_NUM_WORKERS),,--maxprocesses=9) --dist worksteal -m "not serial" +.PHONY: tests-parallel-review +tests-parallel-review: + uv run pytest -n "$${PYTEST_XDIST_AUTO_NUM_WORKERS:-auto}" $(if $(PYTEST_XDIST_AUTO_NUM_WORKERS),,--maxprocesses=9) --dist worksteal -m "not serial and not review_optional" + .PHONY: tests-serial tests-serial: uv run python .github/scripts/run_serial_tests.py +.PHONY: tests-serial-review +tests-serial-review: + uv run python .github/scripts/run_serial_tests.py --exclude-review-optional + .PHONY: integration-tests integration-tests: uv run python .github/scripts/run_integration_tests.py --profile full $(filter --all,$(MAKECMDGOALS)) diff --git a/pyproject.toml b/pyproject.toml index cc276869a8..2fdf3194c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -221,7 +221,8 @@ filterwarnings = [ ] markers = [ "allow_call_model_methods: mark test as allowing calls to real model implementations", - "serial: mark test as requiring serial execution", + "review_optional: mark a slow subsystem-specific test that an unrelated iterative review check may omit", + "serial: mark test as requiring exclusive execution after all xdist workers exit", ] [tool.inline-snapshot] diff --git a/tests/README.md b/tests/README.md index 4eea328a92..59ef96afbf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,6 +10,12 @@ make tests `make tests` runs the shard-safe suite first with pytest-xdist using up to nine workers, then runs the tests marked `serial` after all xdist workers have exited. Set `PYTEST_XDIST_AUTO_NUM_WORKERS` to a positive integer to override the automatic worker count and cap. The serial runner limits collection to test files containing the literal `pytest.mark.serial`, so keep that literal marker in every file containing serial tests. For indirect or custom serial marker spellings, use `uv run pytest -m serial` to perform generic pytest collection. +The `serial` marker means that a test needs exclusive execution after every xdist worker exits, not merely ordered execution within one worker. Use it for shared external resources, process-wide state, or timing-sensitive lifecycle tests that have demonstrated interference under xdist. Tests that use their own subprocess, random port, or temporary directory do not need `serial` solely for that reason; prove them under xdist instead. + +`make tests-review` omits tests marked `review_optional`. These are slow subsystem-specific integration, subprocess, or multiprocessing checks that remain mandatory in the final `make tests` verification. Use the review target only as a preliminary check during an iterative implementation review when the task-owned paths do not affect any marked test or its owning subsystem. Inspect the current owners with `rg -n "review_optional" tests` when deciding; if the boundary is uncertain, run `make tests`. + +Choose review-round coverage by impact. For a leaf subsystem change, run `make tests-review` plus the owning subsystem's complete test file or directory without a marker filter, so its `review_optional` cases are restored. For cross-cutting runtime changes such as runner orchestration, agent or item flow, shared persistence, or test infrastructure, run `make tests` during review. Prefer the full suite whenever the affected boundary is ambiguous. This selection changes only iterative feedback; the final verification always runs `make tests`. + `make typecheck` runs mypy and pyright concurrently. Pyright uses four analysis threads by default; set `PYRIGHT_THREADS` to a positive integer to override the local thread count. The speedup does not remove either analyzer or narrow its selected project or source scope. ## Performance and determinism diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 2aa40200fa..bea48ce0f3 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -1325,6 +1325,7 @@ async def test_legacy_destructive_noops_leave_database_unlocked(tmp_path: Path, @pytest.mark.parametrize("branch_name", [None, "shared_branch"]) @pytest.mark.parametrize("turn_number", [1, 3]) +@pytest.mark.review_optional async def test_branch_allocation_is_serialized_across_processes( tmp_path: Path, branch_name: str | None, turn_number: int ): @@ -3084,6 +3085,7 @@ async def test_pop_item_uses_branch_snapshot_when_branch_switches_concurrently() session.close() +@pytest.mark.review_optional async def test_pop_item_claim_is_unique_across_processes(tmp_path: Path): """Two processes must not return the same destructively read item.""" db_path = tmp_path / "advanced_pop_processes.db" diff --git a/tests/extensions/memory/test_dapr_redis_integration.py b/tests/extensions/memory/test_dapr_redis_integration.py index 75de06da53..4f3d8f4453 100644 --- a/tests/extensions/memory/test_dapr_redis_integration.py +++ b/tests/extensions/memory/test_dapr_redis_integration.py @@ -35,16 +35,6 @@ "Docker executable is not available; skipping Dapr integration tests", allow_module_level=True, ) -try: - client = docker.from_env() - client.ping() -except DockerException: - pytest.skip( - "Docker daemon is not available; skipping Dapr integration tests", allow_module_level=True - ) -else: - client.close() - from testcontainers.core.container import DockerContainer # type: ignore[import-untyped] from testcontainers.core.network import Network # type: ignore[import-untyped] from testcontainers.core.waiting_utils import wait_for_logs # type: ignore[import-untyped] @@ -58,8 +48,22 @@ from tests.fake_model import FakeModel from tests.test_responses import get_text_message -# Docker-backed integration tests should stay on the serial test path. -pytestmark = [pytest.mark.asyncio, pytest.mark.serial] +# Docker-backed integration tests should stay on the exclusive serial test path. +pytestmark = [pytest.mark.asyncio, pytest.mark.review_optional, pytest.mark.serial] + + +@pytest.fixture(scope="module", autouse=True) +def require_docker_daemon(): + """Skip the selected Dapr tests when the Docker daemon is unavailable.""" + client = None + try: + client = docker.from_env() + client.ping() + except DockerException: + pytest.skip("Docker daemon is not available; skipping Dapr integration tests") + finally: + if client is not None: + client.close() def wait_for_dapr_health(host: str, port: int, timeout: int = 60) -> bool: diff --git a/tests/mcp/test_mcp_pagination_integration.py b/tests/mcp/test_mcp_pagination_integration.py index 48d61230ae..fcef8449e0 100644 --- a/tests/mcp/test_mcp_pagination_integration.py +++ b/tests/mcp/test_mcp_pagination_integration.py @@ -13,6 +13,7 @@ from ..test_responses import get_function_tool_call, get_text_message PAGINATED_SERVER_PATH = Path(__file__).parent / "servers" / "paginated.py" +pytestmark = pytest.mark.review_optional def create_paginated_server() -> MCPServerStdio: diff --git a/tests/sandbox/integration_tests/test_runner_pause_resume.py b/tests/sandbox/integration_tests/test_runner_pause_resume.py index 9207a8be7d..ce723958a8 100644 --- a/tests/sandbox/integration_tests/test_runner_pause_resume.py +++ b/tests/sandbox/integration_tests/test_runner_pause_resume.py @@ -22,6 +22,7 @@ @pytest.mark.asyncio +@pytest.mark.review_optional async def test_runner_preserves_unix_local_lifecycle_state_across_pause_and_resume( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index c2fdf65e32..8b097c002c 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -73,6 +73,7 @@ def _unexpected_mkdtemp(*args: object, **kwargs: object) -> str: ) +@pytest.mark.review_optional class TestUnixLocalPty: @pytest.mark.asyncio async def test_tty_fd_close_is_owned_without_blocking_termination( diff --git a/tests/test_run_serial_tests.py b/tests/test_run_serial_tests.py index 0025d32a37..81b1c1241c 100644 --- a/tests/test_run_serial_tests.py +++ b/tests/test_run_serial_tests.py @@ -7,6 +7,9 @@ import pytest +SERIAL_MARKER_SOURCE = ".".join(("pytest", "mark", "serial")) +REVIEW_OPTIONAL_MARKER_SOURCE = ".".join(("pytest", "mark", "review_optional")) + @pytest.fixture def serial_test_runner() -> ModuleType: @@ -20,6 +23,13 @@ def serial_test_runner() -> ModuleType: return module +def test_runner_tests_do_not_self_select_as_serial() -> None: + contents = Path(__file__).read_text(encoding="utf-8") + + assert SERIAL_MARKER_SOURCE not in contents + assert REVIEW_OPTIONAL_MARKER_SOURCE not in contents + + def test_discovers_both_default_pytest_filename_patterns( serial_test_runner: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -27,7 +37,7 @@ def test_discovers_both_default_pytest_filename_patterns( tests.mkdir() (tests / "test_prefix.py").write_text("def test_prefix(): pass\n", encoding="utf-8") (tests / "suffix_test.py").write_text( - "import pytest\npytestmark = pytest.mark.serial\n", + f"import pytest\npytestmark = {SERIAL_MARKER_SOURCE}\n", encoding="utf-8", ) (tests / "helper.py").write_text("HELPER = True\n", encoding="utf-8") @@ -40,12 +50,42 @@ def test_discovers_both_default_pytest_filename_patterns( assert [path.name for path in serial_test_runner._serial_test_files()] == ["suffix_test.py"] +def test_review_selection_keeps_mixed_serial_files( + serial_test_runner: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + tests = tmp_path / "tests" + tests.mkdir() + mixed_file = tests / "test_mixed.py" + mixed_file.write_text( + "import pytest\n" + f"pytestmark = {SERIAL_MARKER_SOURCE}\n" + f"@{REVIEW_OPTIONAL_MARKER_SOURCE}\n" + "def test_optional(): pass\n" + "def test_required(): pass\n", + encoding="utf-8", + ) + monkeypatch.setattr(serial_test_runner, "ROOT", tmp_path) + + assert [path.name for path in serial_test_runner._serial_test_files()] == ["test_mixed.py"] + assert serial_test_runner._serial_args(marker_expression="serial and not review_optional") == [ + sys.executable, + "-m", + "pytest", + str(Path("tests") / "test_mixed.py"), + "-m", + "serial and not review_optional", + ] + + def test_serial_command_targets_only_discovered_files( serial_test_runner: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: serial_file = tmp_path / "tests" / "test_serial.py" serial_file.parent.mkdir() - serial_file.write_text("import pytest\npytestmark = pytest.mark.serial\n", encoding="utf-8") + serial_file.write_text( + f"import pytest\npytestmark = {SERIAL_MARKER_SOURCE}\n", + encoding="utf-8", + ) monkeypatch.setattr(serial_test_runner, "ROOT", tmp_path) assert serial_test_runner._serial_args() == [ diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index 9c274267e2..e1ebfbb5b2 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -613,6 +613,7 @@ def test_batch_trace_processor_shutdown_without_timeout_preserves_export_retries @pytest.mark.serial +@pytest.mark.review_optional def test_tracing_atexit_cleanup_timeout_preserves_process_exit_code_on_504() -> None: script = textwrap.dedent( """ diff --git a/tests/tracing/test_import_side_effects.py b/tests/tracing/test_import_side_effects.py index 4655a4d73f..c343f24091 100644 --- a/tests/tracing/test_import_side_effects.py +++ b/tests/tracing/test_import_side_effects.py @@ -7,8 +7,11 @@ from pathlib import Path from typing import cast +import pytest + REPO_ROOT = Path(__file__).resolve().parents[2] SRC_ROOT = REPO_ROOT / "src" +pytestmark = pytest.mark.review_optional def _run_python(script: str) -> dict[str, object]: From ed7fd85ed927c739e77c92c1a22f0273913f6106 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 17:09:32 -0500 Subject: [PATCH 219/473] fix(realtime): apply tool call item updates to session history (#4284) --- src/agents/realtime/session.py | 7 ++- tests/realtime/test_session.py | 100 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 18d8d716f1..91a1aa2917 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -1487,7 +1487,12 @@ def _get_new_history( ) if existing_index is not None: new_history = old_history.copy() - if event.type == "message" and event.content is not None and len(event.content) > 0: + if event.type != "message": + # Tool calls reuse a single item for the call and its output, so the transport + # re-sends the same item_id with status "completed" once the output is known. + # Only message items carry content worth merging, so replace anything else. + new_history[existing_index] = event + elif event.content is not None and len(event.content) > 0: existing_item = old_history[existing_index] if existing_item.type == "message": # Merge content preserving existing transcript/text when incoming entry is empty diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 435dba4647..a9951069ce 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -42,6 +42,7 @@ InputAudio, InputText, RealtimeItem, + RealtimeToolCallItem, UserMessageItem, ) from agents.realtime.model import RealtimeModel, RealtimeModelConfig @@ -1638,6 +1639,46 @@ async def test_item_updated_event_updates_existing_item(self, mock_model, mock_a history_event = await session._event_queue.get() assert isinstance(history_event, RealtimeHistoryUpdated) + @pytest.mark.asyncio + async def test_item_updated_event_completes_tool_call(self, mock_model, mock_agent): + """The transport reuses one item for a tool call and its output, so the second + item_updated must land in history.""" + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + + in_progress = RealtimeToolCallItem( + item_id="fc_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="in_progress", + arguments='{"city": "Oakland"}', + name="get_weather", + output=None, + ) + await session.on_event(RealtimeModelItemUpdatedEvent(item=in_progress)) + + completed = in_progress.model_copy(update={"status": "completed", "output": "sunny"}) + await session.on_event(RealtimeModelItemUpdatedEvent(item=completed)) + + assert len(session._history) == 1 + stored = cast(RealtimeToolCallItem, session._history[0]) + assert stored.status == "completed" + assert stored.output == "sunny" + + # raw + history added, then raw + history updated. + assert session._event_queue.qsize() == 4 + await session._event_queue.get() # raw event + assert isinstance(await session._event_queue.get(), RealtimeHistoryAdded) + await session._event_queue.get() # raw event + history_event = await session._event_queue.get() + assert isinstance(history_event, RealtimeHistoryUpdated) + assert cast(RealtimeToolCallItem, history_event.history[0]).output == "sunny" + @pytest.mark.asyncio async def test_item_deleted_event_removes_item(self, mock_model, mock_agent): """Test that item_deleted events remove items from history""" @@ -2020,6 +2061,65 @@ def test_add_new_item_to_end_when_no_previous_item_id(self): assert new_history[0].item_id == "item_1" assert new_history[1].item_id == "item_2" + def test_tool_call_item_update_replaces_existing_entry(self): + """A completed tool call replaces the in-progress entry it shares an item_id with.""" + in_progress = RealtimeToolCallItem( + item_id="item_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="in_progress", + arguments='{"city": "Oakland"}', + name="get_weather", + output=None, + ) + completed = RealtimeToolCallItem( + item_id="item_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="completed", + arguments='{"city": "Oakland"}', + name="get_weather", + output="sunny", + ) + + history = RealtimeSession._get_new_history([], in_progress) + history = RealtimeSession._get_new_history(history, completed) + + assert len(history) == 1 + updated = cast(RealtimeToolCallItem, history[0]) + assert updated.status == "completed" + assert updated.output == "sunny" + + def test_tool_call_item_update_preserves_other_items(self): + """Replacing a tool call entry leaves the surrounding history untouched.""" + before = UserMessageItem( + item_id="item_0", role="user", content=[InputText(text="what's the weather?")] + ) + after = AssistantMessageItem( + item_id="item_2", role="assistant", content=[AssistantText(text="It is sunny.")] + ) + in_progress = RealtimeToolCallItem( + item_id="item_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="in_progress", + arguments="{}", + name="get_weather", + output=None, + ) + old_history = cast(list[RealtimeItem], [before, in_progress, after]) + + completed = in_progress.model_copy(update={"status": "completed", "output": "sunny"}) + new_history = RealtimeSession._get_new_history(old_history, completed) + + assert [item.item_id for item in new_history] == ["item_0", "item_1", "item_2"] + assert new_history[0] == before + assert new_history[2] == after + assert cast(RealtimeToolCallItem, new_history[1]).output == "sunny" + def test_add_first_item_to_empty_history(self): """Test adding first item to empty history""" old_history: list[RealtimeItem] = [] From 4f184aad9db358b7a2ba3b1e62af33ea1aef06ec Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 17:10:49 -0500 Subject: [PATCH 220/473] fix(realtime): preserve falsey custom models (#4286) --- src/agents/realtime/runner.py | 2 +- tests/realtime/test_runner.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/agents/realtime/runner.py b/src/agents/realtime/runner.py index a6ec189216..3998f37833 100644 --- a/src/agents/realtime/runner.py +++ b/src/agents/realtime/runner.py @@ -43,7 +43,7 @@ def __init__( """ self._starting_agent = starting_agent self._config = config - self._model = model or OpenAIRealtimeWebSocketModel() + self._model = model if model is not None else OpenAIRealtimeWebSocketModel() async def run( self, *, context: TContext | None = None, model_config: RealtimeModelConfig | None = None diff --git a/tests/realtime/test_runner.py b/tests/realtime/test_runner.py index 1e6eccbae4..39b9d4031a 100644 --- a/tests/realtime/test_runner.py +++ b/tests/realtime/test_runner.py @@ -55,6 +55,19 @@ def mock_model(): return MockRealtimeModel() +@pytest.mark.asyncio +async def test_run_preserves_falsey_custom_model(mock_agent: Mock): + class FalseyRealtimeModel(MockRealtimeModel): + def __bool__(self) -> bool: + return False + + model = FalseyRealtimeModel() + + session = await RealtimeRunner(mock_agent, model=model).run() + + assert session.model is model + + @pytest.mark.asyncio async def test_run_creates_session_with_no_settings( mock_agent: Mock, mock_model: MockRealtimeModel From fb3a2482eac40d3d51d11048d4d86bd7ad649127 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 17:11:55 -0500 Subject: [PATCH 221/473] fix(sessions): strip placeholder item IDs before Conversations persistence (#4288) --- .../run_internal/session_persistence.py | 9 +- tests/test_agent_runner.py | 94 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index fee753b40c..55745db55d 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -30,6 +30,7 @@ ) from ..memory.openai_conversations_session import OpenAIConversationsSession from ..memory.session import _call_session_method, _get_session_wrapper +from ..models.fake_id import FAKE_RESPONSES_ID from ..run_context import RunContextWrapper from ..run_state import RunState from .items import ( @@ -794,11 +795,15 @@ def _sanitize_openai_conversation_item(item: TResponseInputItem) -> TResponseInp persisted through the Conversations API. Reasoning items also need their server identity or encrypted content to remain persistable. Other item IDs remain stripped so replayed messages, function calls, and tool outputs do not carry stale provider IDs. + + ``FAKE_RESPONSES_ID`` is the SDK's own placeholder for providers that assign no item ID, + so it is never a server identity and is stripped from every item type. """ if isinstance(item, dict): clean_item = cast(dict[str, Any], strip_internal_input_item_metadata(item)) - if clean_item.get("type") != "reasoning" and not _openai_conversation_item_requires_id( - clean_item + if clean_item.get("id") == FAKE_RESPONSES_ID or ( + clean_item.get("type") != "reasoning" + and not _openai_conversation_item_requires_id(clean_item) ): clean_item.pop("id", None) clean_item.pop("provider_data", None) diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index ab3aba3124..3553815da9 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -64,6 +64,7 @@ TResponseInputItem, ) from agents.lifecycle import RunHooks +from agents.models.fake_id import FAKE_RESPONSES_ID from agents.run import AgentRunner, get_default_agent_runner, set_default_agent_runner from agents.run_config import _default_trace_include_sensitive_data from agents.run_internal.agent_bindings import bind_public_agent @@ -3925,6 +3926,99 @@ async def clear_session(self) -> None: assert saved_reasoning["encrypted_content"] == "encrypted" +@pytest.mark.asyncio +async def test_save_result_to_openai_conversation_drops_placeholder_id_reasoning_item() -> None: + class DummyOpenAIConversationsSession(OpenAIConversationsSession): + def __init__(self) -> None: + self.saved_items: list[TResponseInputItem] = [] + + async def _get_session_id(self) -> str: + return "conv_test" + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.saved_items.extend(items) + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + return [] + + async def pop_item(self) -> TResponseInputItem | None: + return None + + async def clear_session(self) -> None: + return None + + session = DummyOpenAIConversationsSession() + agent = Agent(name="agent", model=FakeModel()) + # Chat Completions providers have no server-assigned reasoning ID, so the SDK stamps its + # own placeholder. That placeholder is not a server identity, so the item is no more + # persistable than one with no ID at all. + placeholder_reasoning = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + type="reasoning", + id=FAKE_RESPONSES_ID, + summary=[Summary(text="thinking", type="summary_text")], + ), + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [placeholder_reasoning]), + None, + ) + + assert saved_count == 1 + assert session.saved_items == [] + + +@pytest.mark.asyncio +async def test_save_result_to_openai_conversation_strips_placeholder_reasoning_id() -> None: + class DummyOpenAIConversationsSession(OpenAIConversationsSession): + def __init__(self) -> None: + self.saved_items: list[TResponseInputItem] = [] + + async def _get_session_id(self) -> str: + return "conv_test" + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.saved_items.extend(items) + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + return [] + + async def pop_item(self) -> TResponseInputItem | None: + return None + + async def clear_session(self) -> None: + return None + + session = DummyOpenAIConversationsSession() + agent = Agent(name="agent", model=FakeModel()) + placeholder_reasoning = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + type="reasoning", + id=FAKE_RESPONSES_ID, + summary=[], + encrypted_content="encrypted", + ), + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [placeholder_reasoning]), + None, + ) + + assert saved_count == 1 + assert len(session.saved_items) == 1 + saved_reasoning = cast(dict[str, Any], session.saved_items[0]) + assert saved_reasoning["encrypted_content"] == "encrypted" + assert "id" not in saved_reasoning + + @pytest.mark.asyncio async def test_save_result_to_session_keeps_tool_call_payload_api_safe() -> None: session = SimpleListSession() From 54ab78ec1da443384e19648148274271e65bdf56 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 07:38:36 +0900 Subject: [PATCH 222/473] fix: normalize optional fields in tool invocation identity (#4289) --- .../agents/verifier_agent.py | 9 +++-- .../agents/writer_agent.py | 4 ++- examples/financial_research_agent/manager.py | 21 ++++++++--- examples/run_examples.py | 2 ++ src/agents/_tool_invocation.py | 13 ++++--- tests/test_example_workflows.py | 24 +++++++++++++ tests/test_run_examples_script.py | 1 + tests/test_tool_approval_call_id_reuse.py | 35 +++++++++++++++++++ 8 files changed, 97 insertions(+), 12 deletions(-) diff --git a/examples/financial_research_agent/agents/verifier_agent.py b/examples/financial_research_agent/agents/verifier_agent.py index 46f59be771..f3c696dccf 100644 --- a/examples/financial_research_agent/agents/verifier_agent.py +++ b/examples/financial_research_agent/agents/verifier_agent.py @@ -12,9 +12,12 @@ "URLs. Judge the report only against that supplied evidence; do not reject or approve claims " "based on your own memory. Check that material numeric and time-sensitive claims are supported " "by the evidence, that citations use supplied URLs, that the report is internally consistent, " - "and that uncertainty is appropriately caveated. Treat information published on or before the " - "research cutoff as potentially available. Mark unsupported claims separately from claims that " - "the evidence directly contradicts." + "and that uncertainty is appropriately caveated. The payload includes allowed_source_urls; " + "compare citation URL strings exactly against that list. If a citation exactly matches an " + "allowed URL, accept the URL even when it contains a tracking parameter or another allowed " + "variant exists. Treat information published on or before the research cutoff as potentially " + "available. Mark unsupported claims separately from claims that the evidence directly " + "contradicts." ) diff --git a/examples/financial_research_agent/agents/writer_agent.py b/examples/financial_research_agent/agents/writer_agent.py index 0db7295a79..43b8957c6c 100644 --- a/examples/financial_research_agent/agents/writer_agent.py +++ b/examples/financial_research_agent/agents/writer_agent.py @@ -17,7 +17,9 @@ REVISION_PROMPT = ( f"{WRITER_PROMPT} You are revising an existing report after evidence verification. Address " "every verification issue, remove claims that cannot be supported, preserve valid analysis, " - "and return a complete replacement report rather than a patch or commentary." + "and return a complete replacement report rather than a patch or commentary. Copy replacement " + "citation URLs verbatim from each verification issue's source_urls; do not change path casing, " + "language segments, or query parameters." ) diff --git a/examples/financial_research_agent/manager.py b/examples/financial_research_agent/manager.py index 12b7ae65a0..b029f0efcd 100644 --- a/examples/financial_research_agent/manager.py +++ b/examples/financial_research_agent/manager.py @@ -237,18 +237,31 @@ async def _verify_report( search_results: Sequence[FinancialSearchEvidence], ) -> VerificationResult: self.printer.update_item("verifying", "Verifying report...") - input_data = json.dumps( + result = await Runner.run( + verifier_agent, + self._verification_input(query, report, search_results), + ) + self.printer.mark_item_done("verifying") + return result.final_output_as(VerificationResult) + + def _verification_input( + self, + query: str, + report: FinancialReportData, + search_results: Sequence[FinancialSearchEvidence], + ) -> str: + return json.dumps( { "original_query": query, "research_cutoff": self.research_cutoff, "report": report.model_dump(mode="json"), "evidence": [item.model_dump(mode="json") for item in search_results], + "allowed_source_urls": sorted( + {source.url for item in search_results for source in item.sources} + ), }, ensure_ascii=False, ) - result = await Runner.run(verifier_agent, input_data) - self.printer.mark_item_done("verifying") - return result.final_output_as(VerificationResult) def _report_input( self, diff --git a/examples/run_examples.py b/examples/run_examples.py index 8d9a7f1b3a..3387e4cc9a 100644 --- a/examples/run_examples.py +++ b/examples/run_examples.py @@ -79,6 +79,8 @@ "examples/sandbox/docker/mounts/gcs_mount_read_write.py", "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", + # Blaxel 0.3.2 still imports an MCP v1 module that was removed in MCP v2. + "examples/sandbox/extensions/blaxel_runner.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py", # Temporarily disabled due to credential issues. diff --git a/src/agents/_tool_invocation.py b/src/agents/_tool_invocation.py index a18d6dd94e..969b98f996 100644 --- a/src/agents/_tool_invocation.py +++ b/src/agents/_tool_invocation.py @@ -72,15 +72,16 @@ def _as_mapping(value: Any) -> Mapping[str, Any] | None: return None -def _normalize_value(value: Any) -> Any: +def _normalize_value(value: Any, *, exclude_none: bool = False) -> Any: mapping = _as_mapping(value) if mapping is not None: return { - str(key): _normalize_value(item) + str(key): _normalize_value(item, exclude_none=exclude_none) for key, item in sorted(mapping.items(), key=lambda pair: str(pair[0])) + if not (exclude_none and item is None) } if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): - return [_normalize_value(item) for item in value] + return [_normalize_value(item, exclude_none=exclude_none) for item in value] if value is None or isinstance(value, str | int | float | bool): return value return str(value) @@ -197,8 +198,12 @@ def tool_invocation_identity_and_scope( if field_name not in mapping: continue value = mapping[field_name] + if value is None: + continue semantic_payload[field_name] = ( - _normalize_arguments(value) if field_name == "arguments" else _normalize_value(value) + _normalize_arguments(value) + if field_name == "arguments" + else _normalize_value(value, exclude_none=True) ) return ( diff --git a/tests/test_example_workflows.py b/tests/test_example_workflows.py index 757478b416..3018ef8dc1 100644 --- a/tests/test_example_workflows.py +++ b/tests/test_example_workflows.py @@ -279,6 +279,30 @@ def test_financial_report_input_includes_cutoff_and_evidence() -> None: } +def test_financial_verification_input_lists_exact_allowed_source_urls() -> None: + manager = object.__new__(FinancialResearchManager) + manager.research_cutoff = "2026-07-11" + source_url = "https://example.com/report?utm_source=openai" + evidence = FinancialSearchEvidence( + query="company annual report", + reason="Ground annual metrics", + summary="Revenue increased.", + sources=[FinancialSource(title="Annual report", url=source_url)], + retrieved_at="2026-07-11", + ) + report = FinancialReportData( + short_summary="Summary", + markdown_report=f"Revenue increased ([source]({source_url})).", + follow_up_questions=[], + ) + + payload = json.loads(manager._verification_input("Analyze the company", report, [evidence])) + + assert payload["allowed_source_urls"] == [source_url] + assert payload["report"] == report.model_dump(mode="json") + assert payload["evidence"] == [evidence.model_dump(mode="json")] + + def test_sandbox_basic_direct_run_imports_external_docker_sdk( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/test_run_examples_script.py b/tests/test_run_examples_script.py index 09794c4569..19bdcbca93 100644 --- a/tests/test_run_examples_script.py +++ b/tests/test_run_examples_script.py @@ -11,6 +11,7 @@ def test_default_auto_skip_excludes_prerequisite_bound_examples() -> None: "examples/sandbox/docker/mounts/gcs_mount_read_write.py", "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", + "examples/sandbox/extensions/blaxel_runner.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py", "examples/sandbox/extensions/vercel_runner.py", diff --git a/tests/test_tool_approval_call_id_reuse.py b/tests/test_tool_approval_call_id_reuse.py index e1b9c022e7..45426d36da 100644 --- a/tests/test_tool_approval_call_id_reuse.py +++ b/tests/test_tool_approval_call_id_reuse.py @@ -79,6 +79,41 @@ def test_canonical_shell_identity_ignores_stripped_provider_metadata() -> None: assert tool_invocation_identity(provider_call) == tool_invocation_identity(persisted_call) +def test_canonical_shell_identity_treats_optional_nulls_as_omitted() -> None: + provider_call = { + "type": "shell_call", + "call_id": "shell_0", + "action": { + "commands": ["echo safe"], + "max_output_length": None, + "timeout_ms": None, + }, + "environment": None, + } + normalized_call = dict(provider_call) + normalized_call["action"] = {"commands": ["echo safe"]} + normalized_call.pop("environment") + + assert tool_invocation_identity(provider_call) == tool_invocation_identity(normalized_call) + + +def test_canonical_function_identity_preserves_null_argument_values() -> None: + call_with_null = { + "type": "function_call", + "call_id": "call_0", + "name": "lookup", + "arguments": '{"value": null}', + } + call_without_value = { + "type": "function_call", + "call_id": "call_0", + "name": "lookup", + "arguments": "{}", + } + + assert tool_invocation_identity(call_with_null) != tool_invocation_identity(call_without_value) + + @pytest.mark.asyncio @pytest.mark.parametrize("fallback_type", ["custom", "function"]) async def test_completed_apply_patch_fallback_run_state_round_trip(fallback_type: str) -> None: From 9c6cadf8201f4908ced206d49ed9f1489dc9db67 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 07:42:57 +0900 Subject: [PATCH 223/473] fix: count requests when providers omit usage (#4290) Co-authored-by: abhay-codes07 --- src/agents/extensions/models/any_llm_model.py | 29 +++- src/agents/extensions/models/litellm_model.py | 69 +++++--- src/agents/models/chatcmpl_stream_handler.py | 6 + src/agents/models/openai_chatcompletions.py | 66 +++++--- src/agents/run_internal/run_loop.py | 11 +- src/agents/usage.py | 21 +++ tests/models/test_any_llm_model.py | 82 +++++++++ tests/models/test_litellm_usage_requests.py | 45 +++++ tests/models/test_openai_chatcompletions.py | 50 +++++- .../test_openai_chatcompletions_stream.py | 158 +++++++++++++++++- 10 files changed, 485 insertions(+), 52 deletions(-) create mode 100644 tests/models/test_litellm_usage_requests.py diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 9169c49508..e11bdea618 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -56,7 +56,11 @@ Usage, _attach_raw_usage_snapshot, _extract_raw_usage_snapshot, + _mark_request_completed_without_usage, _raw_usage_snapshot, + _requests_for_response_without_usage, + _response_usage_to_usage, + model_usage_to_span_usage, ) from ...util._error_tracing import model_span_errors, record_model_error_on_span from ...util._json import _to_dump_compatible @@ -416,9 +420,12 @@ async def _get_response_via_responses( output_tokens_details=response.usage.output_tokens_details, ) if response.usage - else Usage() + # The request completed, so it counts even when the provider omits usage. + else Usage(requests=1) ) + span_response.span_data.usage = model_usage_to_span_usage(usage) + if tracing.include_data(): span_response.span_data.response = response span_response.span_data.input = input @@ -481,6 +488,11 @@ async def _stream_response_via_responses( final_response = chunk.response if model_settings.preserve_raw_usage is True: _attach_raw_usage_snapshot(chunk.response, chunk.response.usage) + if final_response.usage is None: + # Match the non-streaming path: the request happened even though + # the provider reported no usage. Recorded without synthesizing a + # usage payload, so tokens are not reported as real zeros. + _mark_request_completed_without_usage(final_response) elif chunk_type in {"response.failed", "response.incomplete"}: terminal_response = getattr(chunk, "response", None) terminal_failure_error = response_terminal_failure_error( @@ -502,6 +514,14 @@ async def _stream_response_via_responses( yielded_terminal_event = True # Populate the span before yielding the terminal event so a consumer # that stops there still leaves a fully recorded span. + if final_response is not None: + span_response.span_data.usage = model_usage_to_span_usage( + _response_usage_to_usage(final_response.usage) + if final_response.usage + else Usage( + requests=_requests_for_response_without_usage(final_response) + ) + ) if tracing.include_data() and final_response: span_response.span_data.response = final_response span_response.span_data.input = input @@ -607,7 +627,8 @@ async def _get_response_via_chat( output_tokens_details=response.usage.completion_tokens_details, # type: ignore[arg-type] ) if response.usage - else Usage() + # The request completed, so it counts even when the provider omits usage. + else Usage(requests=1) ) # Some providers signal a filtered non-streaming completion only through @@ -783,6 +804,10 @@ def _populate_chat_generation_span( else {"reasoning_tokens": 0} ), } + elif _requests_for_response_without_usage(final_response): + # Keep streamed tracing aligned with the non-streaming path, which records the + # request even when the provider reports no usage. + span_generation.span_data.usage = model_usage_to_span_usage(Usage(requests=1)) @overload async def _fetch_chat_response( diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 35750f1703..017224f286 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -58,7 +58,13 @@ from ...tracing import generation_span from ...tracing.span_data import GenerationSpanData from ...tracing.spans import Span -from ...usage import Usage, _cache_write_tokens, _make_input_tokens_details +from ...usage import ( + Usage, + _cache_write_tokens, + _make_input_tokens_details, + _requests_for_response_without_usage, + model_usage_to_span_usage, +) from ...util._error_tracing import model_span_errors from ...util._json import _to_dump_compatible @@ -289,10 +295,11 @@ async def get_response( ), ) if response.usage - else Usage() + # The request completed, so it counts even when the provider omits usage. + else Usage(requests=1) ) else: - usage = Usage() + usage = Usage(requests=1) logger.warning("No usage information returned from Litellm") if tracing.include_data(): @@ -424,6 +431,12 @@ async def stream_response( if chunk.type == "response.completed": final_response = chunk.response yielded_terminal_event = True + # Populate the span before yielding, because a caller that stops + # consuming at the terminal event closes this generator and never + # resumes it, which would leave the span without usage. + self._populate_stream_generation_span( + span_generation, final_response, tracing + ) yield chunk except asyncio.CancelledError: @@ -444,26 +457,36 @@ async def stream_response( else: raise - if tracing.include_data() and final_response: - span_generation.span_data.output = [final_response.model_dump()] - - if final_response and final_response.usage: - span_generation.span_data.usage = { - "requests": 1, - "input_tokens": final_response.usage.input_tokens, - "output_tokens": final_response.usage.output_tokens, - "total_tokens": final_response.usage.total_tokens, - "input_tokens_details": ( - final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details - else {"cached_tokens": 0, "cache_write_tokens": 0} - ), - "output_tokens_details": ( - final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details - else {"reasoning_tokens": 0} - ), - } + @staticmethod + def _populate_stream_generation_span( + span_generation: Span[GenerationSpanData], + final_response: Response, + tracing: ModelTracing, + ) -> None: + if tracing.include_data(): + span_generation.span_data.output = [final_response.model_dump()] + + if final_response.usage: + span_generation.span_data.usage = { + "requests": 1, + "input_tokens": final_response.usage.input_tokens, + "output_tokens": final_response.usage.output_tokens, + "total_tokens": final_response.usage.total_tokens, + "input_tokens_details": ( + final_response.usage.input_tokens_details.model_dump() + if final_response.usage.input_tokens_details + else {"cached_tokens": 0, "cache_write_tokens": 0} + ), + "output_tokens_details": ( + final_response.usage.output_tokens_details.model_dump() + if final_response.usage.output_tokens_details + else {"reasoning_tokens": 0} + ), + } + elif _requests_for_response_without_usage(final_response): + # Keep streamed tracing aligned with the non-streaming path, which records the + # request even when the provider reports no usage. + span_generation.span_data.usage = model_usage_to_span_usage(Usage(requests=1)) @overload async def _fetch_response( diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 0f2cd1f152..077b0e377f 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -56,6 +56,7 @@ _cache_write_tokens, _extract_raw_usage_snapshot, _make_input_tokens_details, + _mark_request_completed_without_usage, ) from .chatcmpl_helpers import ChatCmplHelpers from .fake_id import FAKE_RESPONSES_ID @@ -1285,6 +1286,11 @@ async def handle_stream( ) if preserve_raw_usage: _attach_raw_usage_snapshot(final_response, raw_usage) + if usage is None: + # The stream reached a terminal response, so a request was made even though the + # provider reported no usage. Record that without inventing a usage payload, so + # the raw usage snapshot stays absent and tokens are not reported as real zeros. + _mark_request_completed_without_usage(final_response) yield ResponseCompletedEvent( response=final_response, diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 22d0427d41..e3bfc3bebf 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -31,7 +31,12 @@ from ..tracing import generation_span from ..tracing.span_data import GenerationSpanData from ..tracing.spans import Span -from ..usage import Usage, _raw_usage_snapshot +from ..usage import ( + Usage, + _raw_usage_snapshot, + _requests_for_response_without_usage, + model_usage_to_span_usage, +) from ..util._error_tracing import model_span_errors from ..util._json import _to_dump_compatible from ._openai_retry import get_openai_retry_advice @@ -291,7 +296,8 @@ async def get_response( output_tokens_details=response.usage.completion_tokens_details, # type: ignore[arg-type] ) if response.usage - else Usage() + # The request completed, so it counts even when the provider omits usage. + else Usage(requests=1) ) # Some providers signal a filtered non-streaming completion only through @@ -465,6 +471,12 @@ async def stream_response( if chunk.type == "response.completed": final_response = chunk.response yielded_terminal_event = True + # Populate the span before yielding, because a caller that stops + # consuming at the terminal event closes this generator and never + # resumes it, which would leave the span without usage. + self._populate_stream_generation_span( + span_generation, final_response, tracing + ) yield chunk except asyncio.CancelledError: @@ -485,26 +497,36 @@ async def stream_response( else: raise - if tracing.include_data() and final_response: - span_generation.span_data.output = [final_response.model_dump()] - - if final_response and final_response.usage: - span_generation.span_data.usage = { - "requests": 1, - "input_tokens": final_response.usage.input_tokens, - "output_tokens": final_response.usage.output_tokens, - "total_tokens": final_response.usage.total_tokens, - "input_tokens_details": ( - final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details - else {"cached_tokens": 0, "cache_write_tokens": 0} - ), - "output_tokens_details": ( - final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details - else {"reasoning_tokens": 0} - ), - } + @staticmethod + def _populate_stream_generation_span( + span_generation: Span[GenerationSpanData], + final_response: Response, + tracing: ModelTracing, + ) -> None: + if tracing.include_data(): + span_generation.span_data.output = [final_response.model_dump()] + + if final_response.usage: + span_generation.span_data.usage = { + "requests": 1, + "input_tokens": final_response.usage.input_tokens, + "output_tokens": final_response.usage.output_tokens, + "total_tokens": final_response.usage.total_tokens, + "input_tokens_details": ( + final_response.usage.input_tokens_details.model_dump() + if final_response.usage.input_tokens_details + else {"cached_tokens": 0, "cache_write_tokens": 0} + ), + "output_tokens_details": ( + final_response.usage.output_tokens_details.model_dump() + if final_response.usage.output_tokens_details + else {"reasoning_tokens": 0} + ), + } + elif _requests_for_response_without_usage(final_response): + # Keep streamed tracing aligned with the non-streaming path, which records the + # request even when the provider reports no usage. + span_generation.span_data.usage = model_usage_to_span_usage(Usage(requests=1)) def _handle_unsupported_server_managed_conversation_state( self, diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index a3316d2615..998075c7e9 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -79,7 +79,12 @@ from ..tracing.config import include_task_and_turn_spans from ..tracing.model_tracing import get_model_tracing_impl from ..tracing.span_data import AgentSpanData, TaskSpanData -from ..usage import Usage, _extract_raw_usage_snapshot, _response_usage_to_usage +from ..usage import ( + Usage, + _extract_raw_usage_snapshot, + _requests_for_response_without_usage, + _response_usage_to_usage, +) from ..util import _coro, _error_tracing from ..util._asyncio_tasks import gather_with_cancel from .agent_bindings import AgentBindings, bind_public_agent @@ -1725,7 +1730,9 @@ async def rewind_model_request() -> None: ( _response_usage_to_usage(terminal_response.usage) if terminal_response.usage - else Usage() + # Defaults to zero requests, so adapters that fold several provider + # responses into one and report counts separately are not double-counted. + else Usage(requests=_requests_for_response_without_usage(terminal_response)) ), stream_failed_retry_attempts[0], ) diff --git a/src/agents/usage.py b/src/agents/usage.py index 5d5a0c479e..28e482e77a 100644 --- a/src/agents/usage.py +++ b/src/agents/usage.py @@ -310,6 +310,27 @@ def add(self, other: Usage) -> None: self.request_usage_entries.append(request_usage) +_REQUEST_WITHOUT_USAGE_ATTR = "_agents_sdk_request_completed_without_usage" + + +def _mark_request_completed_without_usage(response: Any) -> None: + """Record that a response completed even though the provider reported no usage. + + Adapters call this instead of synthesizing a zero-filled usage payload, so the raw + provider usage stays absent while the request itself is still counted. + """ + object.__setattr__(response, _REQUEST_WITHOUT_USAGE_ATTR, True) + + +def _requests_for_response_without_usage(response: Any) -> int: + """How many requests a usage-less response represents. + + Defaults to zero so adapters that multiplex several provider responses into one + response, and report their counts separately, are not double-counted. + """ + return 1 if getattr(response, _REQUEST_WITHOUT_USAGE_ATTR, False) else 0 + + def _response_usage_to_usage(response_usage: Any) -> Usage: """Convert Responses API usage, including adapter-supplied per-request details.""" request_usages = getattr(response_usage, "_agents_sdk_request_usages", None) diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 9808845898..210685d986 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -1919,3 +1919,85 @@ async def consume() -> None: finally: release.set() task.cancel() + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_counts_request_when_usage_is_absent(monkeypatch) -> None: + """The AnyLLM Responses stream must count its request like the non-streaming path. + + `get_response` already reports one request when the provider omits usage. The streaming + path went through the run loop's usage-less fallback and reported zero, so the same call + was counted differently depending only on whether it was streamed. + """ + completed = _response("Hello") + completed.usage = None + + async def response_stream() -> AsyncIterator[ResponseCompletedEvent]: + yield ResponseCompletedEvent( + type="response.completed", response=completed, sequence_number=1 + ) + + provider = FakeAnyLLMProvider(supports_responses=True, responses_response=response_stream()) + module, _ = _import_any_llm_module(monkeypatch, provider) + + events = [ + event + async for event in module.AnyLLMModel(model="openai/gpt-5.4-mini").stream_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + from agents.usage import _requests_for_response_without_usage + + terminal = events[-1] + assert isinstance(terminal, ResponseCompletedEvent) + # No usage payload is synthesized, so token counts are not reported as real zeros. + assert terminal.response.usage is None + assert _requests_for_response_without_usage(terminal.response) == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_responses_stream_with_usage_is_not_marked(monkeypatch) -> None: + """A response that did report usage must not also be counted by the usage-less path.""" + + async def response_stream() -> AsyncIterator[ResponseCompletedEvent]: + yield ResponseCompletedEvent( + type="response.completed", response=_response("Hello"), sequence_number=1 + ) + + provider = FakeAnyLLMProvider(supports_responses=True, responses_response=response_stream()) + module, _ = _import_any_llm_module(monkeypatch, provider) + + events = [ + event + async for event in module.AnyLLMModel(model="openai/gpt-5.4-mini").stream_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + from agents.usage import _requests_for_response_without_usage + + terminal = events[-1] + assert isinstance(terminal, ResponseCompletedEvent) + assert terminal.response.usage is not None + assert _requests_for_response_without_usage(terminal.response) == 0 diff --git a/tests/models/test_litellm_usage_requests.py b/tests/models/test_litellm_usage_requests.py new file mode 100644 index 0000000000..35385c4bbc --- /dev/null +++ b/tests/models/test_litellm_usage_requests.py @@ -0,0 +1,45 @@ +import litellm +import pytest +from litellm.types.utils import Choices, Message, ModelResponse + +from agents.extensions.models.litellm_model import LitellmModel +from agents.model_settings import ModelSettings +from agents.models.interface import ModelTracing + + +async def _get_response(monkeypatch, *, response: ModelResponse): + async def fake_acompletion(model, messages=None, **kwargs): + return response + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + return await LitellmModel(model="test-model").get_response( + system_instructions=None, + input=[], + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + ) + + +def _response_without_usage() -> ModelResponse: + response = ModelResponse( + choices=[Choices(index=0, message=Message(role="assistant", content="ok"))] + ) + # LiteLLM providers that report nothing leave usage unset or None. + response.usage = None # type: ignore[attr-defined] + return response + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_request_is_counted_when_litellm_reports_no_usage(monkeypatch) -> None: + """The call happened, so it counts, even though no token counts came back.""" + resp = await _get_response(monkeypatch, response=_response_without_usage()) + + assert resp.usage.requests == 1 + assert resp.usage.input_tokens == 0 + assert resp.usage.output_tokens == 0 + assert resp.usage.total_tokens == 0 diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index e756ec020d..57d89cd57f 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -760,8 +760,9 @@ async def patched_fetch_response(self, *args, **kwargs): refusal_part = resp.output[0].content[0] assert isinstance(refusal_part, ResponseOutputRefusal) assert refusal_part.refusal == "No thanks" - # With no usage from the completion, usage defaults to zeros. - assert resp.usage.requests == 0 + # With no usage from the completion, token counts default to zeros, but the request itself + # still happened and is counted. + assert resp.usage.requests == 1 assert resp.usage.input_tokens == 0 assert resp.usage.output_tokens == 0 assert resp.usage.input_tokens_details.cached_tokens == 0 @@ -1504,3 +1505,48 @@ async def patched_fetch_response(self, *args, **kwargs): ) assert resp.request_id is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_request_is_counted_when_provider_omits_usage(monkeypatch) -> None: + """A completed call counts as one request even when the provider reports no usage. + + Some OpenAI-compatible providers and gateways return no `usage` block. Counting those as + zero requests understates `Usage.requests`, which is documented as the number of requests + made to the LLM API, and is inconsistent with the retry path, which already forces the + successful attempt to count via `max(usage.requests, 1)`. + """ + msg = ChatCompletionMessage(role="assistant", content="hello") + chat = ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[Choice(index=0, finish_reason="stop", message=msg)], + usage=None, + ) + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + resp: ModelResponse = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert resp.usage.requests == 1 + # Token counts stay at zero, since the provider genuinely did not report them. + assert resp.usage.input_tokens == 0 + assert resp.usage.output_tokens == 0 + assert resp.usage.total_tokens == 0 diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 014dbe2ac3..174486760a 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -34,7 +34,7 @@ ResponseReasoningItem, ) -from agents import Agent, Runner, function_tool +from agents import Agent, Runner, function_tool, trace from agents.exceptions import ModelBehaviorError, UserError from agents.model_settings import ModelSettings from agents.models.chatcmpl_converter import Converter @@ -50,6 +50,7 @@ from agents.models.interface import ModelTracing from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel from agents.models.openai_provider import OpenAIProvider +from tests.testing_processor import fetch_ordered_spans from tests.utils.simple_session import SimpleListSession @@ -3916,3 +3917,158 @@ async def test_stream_handler_drops_citations_reported_before_any_text() -> None message = cast(ResponseOutputMessage, completed.response.output[0]) assert len(message.content) == 1 assert cast(ResponseOutputText, message.content[0]).text == "It will rain tomorrow." + + +def _usageless_stream_patch(usage: CompletionUsage | None = None): + """Patch `_fetch_response` with a stream whose only chunk carries `usage`.""" + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Hello"))], + usage=usage, + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + yield chunk + + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, fake_stream() + + return patched_fetch_response + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_streamed_run_counts_request_when_provider_omits_usage(monkeypatch) -> None: + """A stream that never carries a usage chunk still made a request. + + Providers without `stream_options.include_usage` finish the stream with no usage payload. + The run must still report the request, while token counts stay at zero because the + provider genuinely did not report them. + """ + monkeypatch.setattr( + OpenAIChatCompletionsModel, "_fetch_response", _usageless_stream_patch(usage=None) + ) + agent = Agent(name="test", model=OpenAIProvider(use_responses=False).get_model("gpt-4")) + + result = Runner.run_streamed(agent, "hi") + completed: ResponseCompletedEvent | None = None + async for event in result.stream_events(): + raw = getattr(event, "data", None) + if isinstance(raw, ResponseCompletedEvent): + completed = raw + + assert result.context_wrapper.usage.requests == 1 + assert result.context_wrapper.usage.total_tokens == 0 + # No usage payload is synthesized, so nothing reports token counts that never arrived. + assert completed is not None + assert completed.response.usage is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_streamed_run_does_not_double_count_when_usage_is_present(monkeypatch) -> None: + """The usage-less path must not add a second request when usage did arrive.""" + monkeypatch.setattr( + OpenAIChatCompletionsModel, + "_fetch_response", + _usageless_stream_patch( + usage=CompletionUsage(completion_tokens=5, prompt_tokens=7, total_tokens=12) + ), + ) + agent = Agent(name="test", model=OpenAIProvider(use_responses=False).get_model("gpt-4")) + + result = Runner.run_streamed(agent, "hi") + async for _ in result.stream_events(): + pass + + assert result.context_wrapper.usage.requests == 1 + assert result.context_wrapper.usage.total_tokens == 12 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_streamed_span_records_the_request_when_provider_omits_usage(monkeypatch) -> None: + """Streamed tracing must record the request the same way the non-streaming path does. + + Non-streaming writes a span usage object with `requests: 1` when the provider reports no + usage. Streaming used to omit span usage entirely, so the run reported one request while + the model span showed none. + """ + monkeypatch.setattr( + OpenAIChatCompletionsModel, "_fetch_response", _usageless_stream_patch(usage=None) + ) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + with trace(workflow_name="test"): + async for _ in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + pass + + spans = fetch_ordered_spans() + generation = next(s for s in spans if s.span_data.type == "generation") + assert generation.span_data.usage is not None + assert generation.span_data.usage["requests"] == 1 + # The provider reported no tokens, so every total stays at zero. + assert generation.span_data.usage["total_tokens"] == 0 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_span_is_recorded_for_a_consumer_that_stops_at_the_terminal_event( + monkeypatch, +) -> None: + """A caller that stops at `response.completed` closes the generator. + + Anything recorded only after the yield loop never runs for such a consumer, so the span + has to be populated before the terminal event is handed out. + """ + monkeypatch.setattr( + OpenAIChatCompletionsModel, "_fetch_response", _usageless_stream_patch(usage=None) + ) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + with trace(workflow_name="test"): + stream = model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + stream_agen = cast(Any, stream) + async for event in stream_agen: + if event.type == "response.completed": + break # stop consuming, as a caller watching for the terminal event would + await stream_agen.aclose() + + generation = next(s for s in fetch_ordered_spans() if s.span_data.type == "generation") + assert generation.span_data.usage is not None + assert generation.span_data.usage["requests"] == 1 From ae84ca1132292a0bb014332476a6777698063dce Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 08:28:21 +0900 Subject: [PATCH 224/473] fix: suppress pydantic serializer warnings (#4291) --- src/agents/_tool_invocation.py | 7 ++- tests/test_tool_approval_call_id_reuse.py | 65 ++++++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/agents/_tool_invocation.py b/src/agents/_tool_invocation.py index 969b98f996..eb36068741 100644 --- a/src/agents/_tool_invocation.py +++ b/src/agents/_tool_invocation.py @@ -5,6 +5,8 @@ from collections.abc import Mapping, Sequence from typing import Any, TypeGuard +from pydantic import BaseModel + from ._tool_identity import ( FunctionToolLookupKey, get_function_tool_lookup_key_for_call, @@ -67,7 +69,10 @@ def _as_mapping(value: Any) -> Mapping[str, Any] | None: return value model_dump = getattr(value, "model_dump", None) if callable(model_dump): - dumped = model_dump(exclude_none=True, exclude_unset=True) + kwargs = {"exclude_none": True, "exclude_unset": True} + if isinstance(value, BaseModel): + kwargs["warnings"] = False + dumped = model_dump(**kwargs) return dumped if isinstance(dumped, Mapping) else None return None diff --git a/tests/test_tool_approval_call_id_reuse.py b/tests/test_tool_approval_call_id_reuse.py index 45426d36da..5b6a43172b 100644 --- a/tests/test_tool_approval_call_id_reuse.py +++ b/tests/test_tool_approval_call_id_reuse.py @@ -2,6 +2,7 @@ import asyncio import json +import warnings from types import SimpleNamespace from typing import Any, Literal, cast @@ -12,6 +13,11 @@ PendingSafetyCheck, ResponseComputerToolCall, ) +from openai.types.responses.response_function_web_search import ( + ActionSearch, + ActionSearchSource, + ResponseFunctionWebSearch, +) from openai.types.responses.response_output_item import McpApprovalRequest from openai.types.responses.response_reasoning_item import ResponseReasoningItem @@ -33,7 +39,11 @@ handoff, tool_output_guardrail, ) -from agents._tool_invocation import tool_invocation_identity, tool_invocation_identity_and_scope +from agents._tool_invocation import ( + tool_invocation_call_id, + tool_invocation_identity, + tool_invocation_identity_and_scope, +) from agents.editor import ApplyPatchOperation, ApplyPatchResult from agents.exceptions import ModelBehaviorError, UserError from agents.items import ModelResponse, ToolApprovalItem @@ -79,6 +89,59 @@ def test_canonical_shell_identity_ignores_stripped_provider_metadata() -> None: assert tool_invocation_identity(provider_call) == tool_invocation_identity(persisted_call) +def test_web_search_source_schema_drift_does_not_warn_during_invocation_lookup() -> None: + source = ActionSearchSource.model_construct(type="api", name="oai-calculator") + output_item = ResponseFunctionWebSearch( + id="ws_123", + action=ActionSearch(type="search", query="current market data", sources=[source]), + status="completed", + type="web_search_call", + ) + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always", UserWarning) + call_id = tool_invocation_call_id(output_item) + + serializer_warnings = [ + warning + for warning in caught_warnings + if "Pydantic serializer warnings" in str(warning.message) + ] + assert call_id is None + assert not serializer_warnings + + +def test_invocation_lookup_preserves_legacy_model_dump_signature() -> None: + class LegacyModel: + def model_dump(self, *, exclude_none: bool, exclude_unset: bool) -> dict[str, str]: + assert exclude_none is True + assert exclude_unset is True + return {"type": "function_call", "call_id": "call_legacy"} + + assert tool_invocation_call_id(LegacyModel()) == ("function_call", "call_legacy") + + +def test_invocation_lookup_propagates_internal_model_dump_type_error() -> None: + class FailingModel: + def __init__(self) -> None: + self.calls = 0 + + def model_dump( + self, + *, + exclude_none: bool, + exclude_unset: bool, + warnings: bool = True, + ) -> dict[str, str]: + self.calls += 1 + raise TypeError("internal serialization failure") + + model = FailingModel() + with pytest.raises(TypeError, match="internal serialization failure"): + tool_invocation_call_id(model) + assert model.calls == 1 + + def test_canonical_shell_identity_treats_optional_nulls_as_omitted() -> None: provider_call = { "type": "shell_call", From 2221313b72d012ff03e586daeaf555f1edb3cffc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 08:28:22 +0900 Subject: [PATCH 225/473] feat: add implementation kickoff skill --- .../skills/implementation-kickoff/SKILL.md | 109 +++++++++++++ .../implementation-kickoff/agents/openai.yaml | 6 + .../scripts/validate_handoff.py | 146 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 .agents/skills/implementation-kickoff/SKILL.md create mode 100644 .agents/skills/implementation-kickoff/agents/openai.yaml create mode 100755 .agents/skills/implementation-kickoff/scripts/validate_handoff.py diff --git a/.agents/skills/implementation-kickoff/SKILL.md b/.agents/skills/implementation-kickoff/SKILL.md new file mode 100644 index 0000000000..0ce93b345a --- /dev/null +++ b/.agents/skills/implementation-kickoff/SKILL.md @@ -0,0 +1,109 @@ +--- +name: implementation-kickoff +description: Start and carry an explicitly invoked openai-agents-python implementation through a fresh isolated worktree and a local PR-ready handoff. Fetch the latest origin/main, keep task changes uncommitted, replay them onto the latest main before final review, run applicable verification and $implementation-final-review, use $pr-draft-summary to generate the complete PR draft and branch name, then create one clean local commit with takeover provenance when applicable. Use only when the user explicitly invokes this skill; never push, open a PR, or mutate GitHub. +--- + +# Implementation Kickoff + +Use this skill as the explicit transition from an agreed implementation scope to isolated execution. Keep the user's original checkout and existing branches unchanged, and finish with a clean local branch that is ready for the user to push. + +## Non-negotiable boundaries + +- Treat explicit invocation of this skill as authorization to fetch, create one dedicated worktree, rebase or replay task-owned changes, create the final local branch, stage task-owned files, and create one local commit. It never authorizes push, pull-request creation, or any GitHub mutation. +- Do not start during an investigation-only phase or before a required user approval. Finish planning and any required implementation scope contract first. +- Use read-only GitHub access when remote PR evidence is required. +- Preserve unrelated and user-owned changes. Do not remove an existing worktree or rewrite an existing branch to make room for this workflow. + +## 1. Establish the task boundary + +Record the original requirement, success criteria, intended target (`origin/main` unless the user states otherwise), task-owned paths, compatibility boundary, intentionally unsupported cases, and required repository skills. For a multi-step task, create and maintain the repository's required ExecPlan, but keep operational artifacts out of the shipped-path manifest unless they are intended deliverables. + +If the current directory is a worktree previously created for this same task in the current conversation, resume it. Otherwise, continue from the user's current checkout only long enough to create a new worktree. + +## 2. Create a detached worktree from current main + +1. Verify the source checkout's raw status without modifying it. +2. Fetch `origin main`. If the fetch fails, stop rather than claiming a stale ref is current. +3. Record the fetched `origin/main` commit. +4. Choose a unique task-oriented path under the configured Codex worktree root. Check both the filesystem and `git worktree list`; never reuse or delete a collision. +5. Run `git worktree add --detach origin/main` and perform all subsequent implementation work there. +6. Confirm the new worktree is detached at the recorded commit and initially clean. + +Do not create the final branch yet. A detached worktree makes the eventual `$pr-draft-summary` branch suggestion authoritative and prevents temporary naming from becoming accidental output. + +## 3. Implement without task commits + +Keep the task diff uncommitted through implementation, focused tests, formatting, and review fixes. Track new files explicitly because ordinary diff statistics omit untracked files. Use the applicable repository skills and references, including `$implementation-strategy` before user-facing or runtime changes. + +Do not create checkpoint commits. If an external interruption requires extra protection, leave the dedicated worktree intact or use a clearly named temporary stash; restore the changes before continuing and do not treat the stash as a deliverable. + +### Taking over an existing pull request + +When the user asks to complete another author's pull request: + +1. Refresh the PR metadata, head, discussion, and complete three-dot diff through read-only access. +2. Confirm that the PR is still an appropriate takeover source. Do not treat an already merged PR as an active takeover. +3. Apply the original PR's complete task diff onto the worktree based on current `origin/main`; do not derive the final branch from the contributor branch and do not preserve its intermediate commit topology. +4. Record the original PR number, PR author login, verified commit identity, existing valid `Co-authored-by` trailers, linked issues, and the original intent that the replacement must preserve. +5. If a valid author identity cannot be obtained from the PR's commits, stop before committing and ask the user. Never invent an email address. + +## 4. Replay the complete task onto the latest main + +After implementation, focused tests, and formatting are stable, fetch `origin main` again. If it advanced: + +1. Confirm that every local change is task-owned. +2. Save tracked and untracked task changes in a uniquely named temporary stash. +3. Rebase the detached HEAD onto `origin/main`. With no task commits, this updates the empty local commit range to the new base. +4. Reapply the stash and confirm it was removed only after a clean application. +5. Resolve conflicts only when the requirement and surrounding source make the correct result unambiguous. Otherwise preserve the stash and conflict evidence, then stop for user direction. +6. Rerun formatting and every focused check affected by the new base. + +Record this observed `origin/main` commit as the final-base candidate. Do not call an older base "latest" merely because its changes appear unrelated. + +## 5. Complete final review and verification + +Run the repository's applicable completion gates against the complete task-owned diff on the final-base candidate. For runtime code, tests, examples, build or test behavior, or behavior-impacting docs, run `$implementation-final-review` and the required `$code-change-verification` sequence in their mandated order. Honor their fingerprint and invalidation rules. + +Skip those skills only when their own repository rules say the task is ineligible, such as a repo-meta-only change. Do not weaken an eligible gate merely because the diff is small. + +Do not create the branch or commit when review is non-converging, verification fails, required evidence is missing, or the final content lacks clean-review credit. + +## 6. Generate the complete PR handoff + +Invoke `$pr-draft-summary` only after review and verification apply to the final content. Give it a self-contained packet containing the original requirement, implementation scope contract, important decisions and intent, complete changed-path inventory including untracked files, final diff and statistics, compatibility notes, issue references, and takeover provenance. + +The worktree is intentionally detached. Tell `$pr-draft-summary` to treat the current branch value `HEAD` as "no branch yet" and require a concrete unused branch-name suggestion; never accept `HEAD` as the suggestion. The description must explain the complete final change and its motivation, not only the last review fix. + +For a takeover, begin the description with prose such as `This pull request supersedes # and ...`. Preserve any separate issue-closing line only when the final implementation actually resolves that issue. + +If the diff, scope, base, behavior claim, issue relationship, or provenance changes after generation, regenerate the entire PR handoff. + +## 7. Recheck main and create one commit + +Fetch `origin main` once more immediately before creating the branch. If it differs from the final-base candidate, return to section 4 and repeat replay, affected checks, final review, verification, and PR handoff. Once stable: + +1. Check whether the suggested branch exists locally, remotely, or in another worktree. Ask `$pr-draft-summary` for the next available numeric suffix and regenerate the handoff before creating a colliding branch. +2. Create the exact suggested branch in the task worktree. +3. Stage only the task-owned shipped-path manifest, including intended new files. Inspect the staged diff before committing. +4. Use the PR draft title as the commit subject. +5. For a takeover, add the verified original PR author as `Co-authored-by: Name `, retain distinct valid co-author trailers from the imported commits, and deduplicate identities. +6. Create exactly one commit. Let repository hooks run normally. + +Branch creation and committing identical content are repository bookkeeping and do not invalidate clean content review. If a hook or manual fix changes task content, stop, classify the change under `$implementation-final-review`, and rerun every invalidated gate and `$pr-draft-summary` before replacing or amending the commit. + +## 8. Validate and hand off + +Run `python .agents/skills/implementation-kickoff/scripts/validate_handoff.py --repo --base --expected-branch `. For a takeover, also pass `--required-trailer-email ` for each identity that must be credited. + +Independently confirm that the committed diff has the reviewed content fingerprint when final review supplied one. The validator checks Git topology and repository cleanliness; it does not replace semantic review or fingerprint verification. + +Leave the worktree in place. Report the worktree path, final observed base commit, branch, commit SHA and subject, verification results, review status, PR title and description, and whether takeover provenance was included. Treat the worktree path and validator output as local diagnostics: never include them in the PR title, PR description, or other copy-ready external text. State explicitly that nothing was pushed and no pull request was created. + +## Failure behavior + +- Fetch failure: stop without creating or updating the final branch. +- Worktree or branch collision: preserve the existing target and choose a new unused path or regenerated branch suggestion. +- Replay conflict: retain recoverable task changes and ask for direction when the correct resolution is ambiguous. +- Review or verification failure: leave the detached task worktree for continuation; do not package a commit as ready. +- Commit-hook mutation: invalidate affected evidence and repeat the required gates. +- Non-clean or multi-commit final state: do not hand off as complete until corrected without discarding user-owned work. diff --git a/.agents/skills/implementation-kickoff/agents/openai.yaml b/.agents/skills/implementation-kickoff/agents/openai.yaml new file mode 100644 index 0000000000..d99003f172 --- /dev/null +++ b/.agents/skills/implementation-kickoff/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Implementation Kickoff" + short_description: "Run implementation in a fresh PR-ready worktree" + default_prompt: "Use $implementation-kickoff to start this implementation in a fresh worktree and leave one verified local commit." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/implementation-kickoff/scripts/validate_handoff.py b/.agents/skills/implementation-kickoff/scripts/validate_handoff.py new file mode 100755 index 0000000000..a19b4bed15 --- /dev/null +++ b/.agents/skills/implementation-kickoff/scripts/validate_handoff.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Validate the Git invariants of an implementation-kickoff handoff.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + + +class GitCommandError(RuntimeError): + """Report a failed Git inspection command.""" + + +def run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["git", *args], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + if check and result.returncode != 0: + command = "git " + " ".join(args) + detail = result.stderr.strip() or result.stdout.strip() or "unknown Git error" + raise GitCommandError(f"{command} failed: {detail}") + return result + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate a clean, single-commit implementation-kickoff handoff." + ) + parser.add_argument("--repo", type=Path, required=True, help="Path to the task worktree.") + parser.add_argument( + "--base", + required=True, + help="Expected parent commit or ref for the single handoff commit.", + ) + parser.add_argument( + "--expected-branch", + required=True, + help="Exact local branch name expected at HEAD.", + ) + parser.add_argument( + "--required-trailer-email", + action="append", + default=[], + help="Email that must appear in a Co-authored-by trailer. Repeat as needed.", + ) + parser.add_argument("--json", action="store_true", help="Emit the result as JSON.") + return parser.parse_args() + + +def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]: + repo = args.repo.expanduser().resolve() + failures: list[str] = [] + + if not repo.is_dir(): + return {"repo": str(repo)}, [f"Repository path does not exist: {repo}"] + + top_level = Path(run_git(repo, "rev-parse", "--show-toplevel").stdout.strip()).resolve() + if top_level != repo: + failures.append(f"--repo must be the worktree root: expected {top_level}, got {repo}") + + status = run_git(repo, "status", "--porcelain=v1", "--untracked-files=all").stdout + if status: + failures.append("Worktree is not clean.") + + branch_result = run_git(repo, "symbolic-ref", "--quiet", "--short", "HEAD", check=False) + branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None + if branch is None: + failures.append("HEAD is detached.") + elif branch != args.expected_branch: + failures.append(f"Current branch is {branch!r}, expected {args.expected_branch!r}.") + + base = run_git(repo, "rev-parse", f"{args.base}^{{commit}}").stdout.strip() + head = run_git(repo, "rev-parse", "HEAD").stdout.strip() + parent_line = run_git(repo, "show", "-s", "--format=%P", "HEAD").stdout.strip() + parents = parent_line.split() if parent_line else [] + if len(parents) != 1: + failures.append(f"HEAD must have exactly one parent, found {len(parents)}.") + elif parents[0] != base: + failures.append(f"HEAD parent is {parents[0]}, expected base {base}.") + + ahead_text = run_git(repo, "rev-list", "--count", f"{base}..{head}").stdout.strip() + ahead = int(ahead_text) + if ahead != 1: + failures.append(f"HEAD must be exactly one commit ahead of base, found {ahead} commits.") + + subject = run_git(repo, "show", "-s", "--format=%s", "HEAD").stdout.strip() + if not subject: + failures.append("HEAD commit subject is empty.") + + body = run_git(repo, "show", "-s", "--format=%B", "HEAD").stdout + trailer_pattern = re.compile(r"^Co-authored-by:\s*.+\s+<([^>]+)>\s*$", re.IGNORECASE) + trailer_emails = { + match.group(1).strip().casefold() + for line in body.splitlines() + if (match := trailer_pattern.match(line)) is not None + } + for email in args.required_trailer_email: + if email.strip().casefold() not in trailer_emails: + failures.append(f"Missing required Co-authored-by trailer for {email}.") + + report: dict[str, object] = { + "repo": str(repo), + "base": base, + "head": head, + "branch": branch, + "subject": subject, + "ahead": ahead, + "clean": not status, + "coauthor_trailer_emails": sorted(trailer_emails), + "valid": not failures, + } + return report, failures + + +def main() -> int: + args = parse_args() + try: + report, failures = validate(args) + except (GitCommandError, ValueError) as exc: + report = {"repo": str(args.repo.expanduser().resolve()), "valid": False} + failures = [str(exc)] + + if args.json: + print(json.dumps({**report, "failures": failures}, indent=2, sort_keys=True)) + else: + status = "valid" if not failures else "invalid" + print(f"Implementation handoff: {status}") + for key in ("repo", "base", "head", "branch", "subject", "ahead", "clean"): + if key in report: + print(f"{key}: {report[key]}") + for failure in failures: + print(f"error: {failure}", file=sys.stderr) + + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9e2770c55088705eda5a3f09e0cf73b0e17c5e21 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 08:25:50 +0900 Subject: [PATCH 226/473] chore: improve release and review workflows --- .agents/skills/final-release-review/SKILL.md | 259 +++++++++++------- .../final-release-review/agents/openai.yaml | 4 +- .../references/review-checklist.md | 218 ++++++++------- .agents/skills/maintainer-review/SKILL.md | 2 + .../references/evaluation-framework.md | 10 + .agents/skills/pr-draft-summary/SKILL.md | 7 +- AGENTS.md | 1 + 7 files changed, 302 insertions(+), 199 deletions(-) diff --git a/.agents/skills/final-release-review/SKILL.md b/.agents/skills/final-release-review/SKILL.md index ced1118919..d32048ecbe 100644 --- a/.agents/skills/final-release-review/SKILL.md +++ b/.agents/skills/final-release-review/SKILL.md @@ -1,110 +1,159 @@ --- name: final-release-review -description: Perform a release-readiness review by locating the previous release tag from remote tags and auditing the diff (e.g., v1.2.3...) for breaking changes, regressions, improvement opportunities, and risks before releasing openai-agents-python. +description: Perform pre-release planning or a final release-candidate review for openai-agents-python by comparing the target with the previous remote tag, determining the minimum compatible release type, auditing regressions and contract changes, reviewing open documentation PR coverage, drafting minor-release Key Changes, and calling the ship/block gate. --- # Final Release Review ## Purpose -Use this skill when validating the latest release candidate commit (default tip of `origin/main`) for release. It guides you to fetch remote tags, pick the previous release tag, and thoroughly inspect the `BASE_TAG...TARGET` diff for breaking changes, introduced bugs/regressions, improvement opportunities, and release risks. +Audit `BASE_TAG...TARGET` in one of two modes: -The review must be stable and actionable: avoid variance between runs by using explicit gate rules, and never produce a `BLOCKED` call without concrete evidence and clear unblock actions. +- **Pre-release planning:** use when the user asks to plan the next release or when the target, normally `origin/main`, does not yet declare a release candidate. The user may still supply a tentative `patch` or `minor` intent. Recommend the compatible type; do not treat unchanged package metadata as a blocker. +- **Final candidate:** use when the user asks for a final candidate decision, the target is a release branch, or target package metadata has already been bumped beyond BASE for the next release. Compare the candidate intent with the minimum release type required by the diff. + +In both modes, find concrete regressions and release risks, independently determine version compatibility, review the latest open documentation PRs before claiming coverage is missing, and produce an actionable release handoff. Keep documentation readiness separate from the release gate. ## Quick start -1. Ensure repository root: `pwd` → `path-to-workspace/openai-agents-python`. -2. Sync tags and pick base (default `v*`): +1. Ensure the repository root is `openai-agents-python`. +2. Sync remote tags and choose the previous release: ```bash BASE_TAG="$(.agents/skills/final-release-review/scripts/find_latest_release_tag.sh origin 'v*')" ``` -3. Choose target commit (default tip of `origin/main`, ensure fresh): `git fetch origin main --prune` then `TARGET="$(git rev-parse origin/main)"`. -4. Snapshot scope: +3. Refresh and resolve the target, defaulting to `origin/main`: + ```bash + git fetch origin main --prune + TARGET="$(git rev-parse origin/main)" + ``` +4. Resolve review mode independently from release intent: + 1. Honor an explicit user request for pre-release planning or final-candidate review. + 2. Otherwise, use final-candidate mode only when the target is a release branch or its package metadata has already been bumped beyond BASE for the next release. + 3. Otherwise, use pre-release planning mode. +5. Resolve release intent separately, without asking when repository state already answers it: + 1. User-supplied version or `patch`/`minor` intent. + 2. A target branch name or target package version that declares the next release. + 3. Otherwise, set intent to `unspecified`. + 4. If final-candidate mode was explicitly requested but intent remains `unspecified`, ask for the intended type or version before issuing a final-candidate gate. If the user prefers an uninterrupted review, switch to pre-release planning and make a recommendation instead. +6. Snapshot the release diff: ```bash git diff --stat "${BASE_TAG}"..."${TARGET}" git diff --dirstat=files,0 "${BASE_TAG}"..."${TARGET}" git log --oneline --reverse "${BASE_TAG}".."${TARGET}" git diff --name-status "${BASE_TAG}"..."${TARGET}" ``` -5. Use the broad signals in `references/review-checklist.md` to find breaking-change, regression, and release-polish candidates. -6. Prove or dismiss each candidate with a BASE-versus-TARGET contract comparison and the owning SDK invariant from `.agents/references/README.md`. -7. Report only actionable findings and call the release gate: ship/block with concrete conditions. +7. Audit the diff with `references/review-checklist.md`, determine the minimum release type, and prove or dismiss each candidate against the released contract. +8. Discover and review relevant open documentation PRs using current read-only GitHub state. Do not infer coverage from local branches, titles, or historical context. +9. Report the release intent, ship/block gate, risk assessment, documentation coverage, and conditional minor-release Key Changes draft. + +## Release intent and versioning policy + +- Treat routine compatible releases as `patch`. +- Require `minor` for a breaking change to a non-beta public contract or for a major feature addition. Reserve major versions until 1.0. +- Determine the **minimum required release type** from the diff independently of the declared intent. +- Classify versioning as follows: + +| Mode | Intended release | Minimum required | Verdict | +|---|---|---|---| +| planning | `unspecified` | either | recommend the minimum type | +| planning | `patch` | `patch` | compatible plan | +| planning | `minor` | `patch` or `minor` | compatible plan; say when minor is optional | +| planning | `patch` | `minor` | recommend changing the plan to minor; do not block the unreleased target | +| candidate | `patch` | `patch` | compatible | +| candidate | `minor` | `patch` or `minor` | compatible; say when minor is optional | +| candidate | `patch` | `minor` | under-versioned and blocking | + +- In pre-release planning mode, always report `Recommended release type: patch|minor`, even when the user supplied a tentative intent. Do not require `pyproject.toml` or `uv.lock` to already contain the next version; the release workflow owns that later bump. +- In final-candidate mode, verify that the declared version, package metadata, lockfile, and release branch agree. Block a patch candidate that requires a minor release. +- Distinguish an undocumented migration from the absence of a usable migration or compatibility path. Missing documentation is non-blocking; an actual supported-path break with no usable migration or fallback can block. ## Deterministic gate policy -- Default to **🟢 GREEN LIGHT TO SHIP** unless at least one blocking trigger below is satisfied. -- Use **🔴 BLOCKED** only when you can cite concrete release-blocking evidence and provide actionable unblock steps. -- Blocking triggers (at least one required for `BLOCKED`): - - A confirmed regression or bug introduced in `BASE...TARGET` (for example, failing targeted test, incompatible behavior in diff, or removed behavior without fallback). - - A confirmed breaking public API/protocol/config change with missing or mismatched versioning and no migration path (for example, patch release for a breaking change). +- Default to **🟢 GREEN LIGHT TO SHIP** unless at least one blocking trigger is proven. +- Use **🔴 BLOCKED** only with concrete release-blocking evidence and an actionable unblock condition. +- Blocking triggers: + - A confirmed regression or bug introduced in `BASE_TAG...TARGET`. + - In final-candidate mode, a declared `patch` release when the diff requires `minor`, or inconsistent candidate version metadata. + - A confirmed breaking public API, protocol, config, or durable-state change with no usable migration, fallback, or compatibility path. - A concrete data-loss, corruption, or security-impacting change with unresolved mitigation. - - A release-critical packaging/build/runtime path is broken by the diff (not speculative). -- Non-blocking by itself: - - Large diff size, broad refactor, or many touched files. - - "Could regress" risk statements without concrete evidence. - - Not running tests locally. -- If evidence is incomplete, do not block. Report a validation action only when the diff establishes a concrete unresolved risk; otherwise omit the candidate. -- A green gate must still explain the important release surfaces that were audited. Do not collapse a behavior-impacting release into a bare "No material risks identified" result. + - A release-critical packaging, build, or runtime path broken by the diff. +- The following are never blocking by themselves: + - Large diff size, broad refactoring, or many touched files. + - Speculative "could regress" concerns without evidence. + - Not rerunning CI checks locally. + - Missing, incomplete, unmerged, stale, or post-release documentation. + - Unchanged package version metadata in pre-release planning mode. +- A documentation review may reveal an underlying runtime or compatibility defect. Block only for that defect, not for the documentation state. +- A green gate must still explain important user-visible release surfaces. ## Workflow -- **Prepare** - - Run the quick-start tag command to ensure you use the latest remote tag. If the tag pattern differs, override the pattern argument (e.g., `'*.*.*'`). - - If the user specifies a base tag, prefer it but still fetch remote tags first. - - Keep the working tree clean to avoid diff noise. -- **Assumptions** - - Assume the target commit (default `origin/main` tip) has already passed `$code-change-verification` in CI unless the user says otherwise. - - Treat repository unit tests, lint, formatting, type checking, and coverage as CI evidence, not as the release audit. Do not rerun them by default. - - Do not block a release solely because you did not rerun CI checks locally; focus on concrete behavioral, compatibility, packaging, or API risks. - - Release policy: routine releases use patch versions; use minor only for breaking changes or major feature additions. Major versions are reserved until the 1.0 release. -- **Map the diff** - - Use `--stat`, `--dirstat`, and `--name-status` outputs to spot hot directories and file types. - - For suspicious files, prefer `git diff --word-diff BASE...TARGET -- `. - - Note any deleted or newly added tests, config, migrations, or scripts. -- **Discover candidates** - - Walk through all categories in `references/review-checklist.md` (breaking changes, regression clues, improvement opportunities). Keep this broad scan so refactors, error handling, concurrency, dependencies, docs drift, and missing coverage remain visible. - - Read changed tests to understand the intended behavior, exercised branches, and missing invariants. A changed or missing test is a clue, not a finding by itself. -- **Audit contract deltas** - - Compare BASE and TARGET rather than reviewing TARGET in isolation. - - For public APIs, compare exports, import identity, signatures, constructor and dataclass field order, defaults, enums, and documented behavior. - - For package metadata, compare supported Python versions, dependencies, optional extras, distribution contents, and import behavior from the built artifacts. - - For persisted state, schemas, protocols, config, and environment variables, identify the released durable boundary and verify backward-read or migration behavior where required. - - Route each changed runtime area through the owning reference in `.agents/references/README.md`. Trace the affected value, state, item, or side effect across all required downstream surfaces instead of stopping at the edited function. - - Check only the relevant symmetry and failure axes: streaming/non-streaming, sync/async, fresh/resumed, client/server-managed state, success/error/cancellation, sequential/concurrent, and normal/repeated cleanup. -- **Prove findings** - - Promote a candidate to a finding only when the diff shows a concrete contract violation, a reachable supported-path regression, or a release-polish gap with user impact. - - Also retain substantiated non-blocking release considerations when they explain an intentional default change, public API or package expansion, durable schema transition, trace/logging behavior change, or other user-visible contract that is safe but important for release consumers to understand. - - For a green gate, report at least one such consideration whenever the diff changes runtime behavior, public APIs, package support, persisted schemas, protocols, configuration defaults, observability, or documented user workflows. Normally report two to five, grouped by contract rather than by directory. - - Assign **🟢 LOW** to a verified, correctly versioned, non-blocking consideration. Use neutral titles that describe the contract change; do not imply that a safe intentional change is a defect. - - If static evidence cannot resolve a concrete semantic question, use the smallest public-path or installed-artifact probe that can. Prefer the same scenario against BASE and TARGET so environment failures and pre-existing behavior are separated from regressions. - - Do not run repository unit-test slices merely to accumulate passing evidence. Run a focused test only when reproducing a specific failure or when no more direct contract, artifact, or runtime probe is available. - - When you confirm a risk, cite the specific file/commit and explain the behavioral impact. - - For every finding, include all of: `Evidence`, `Impact`, and `Action`. - - Severity calibration: - - **🟢 LOW**: low blast radius or clearly covered behavior; no release gate impact. - - **🟡 MODERATE**: plausible user-facing regression signal; needs validation but not a confirmed blocker. - - **🔴 HIGH**: confirmed or strongly evidenced release-blocking issue. - - Every reported item needs a concrete next step and pass condition. For an unresolved risk, give the smallest validation or fix. For a verified LOW consideration, use a release-handoff task such as preserving exact migration, opt-out, compatibility, or supported-version wording in generated release notes. Do not invent additional code or test work merely to populate the report. - - Breaking changes do not automatically require a BLOCKED release call when they are already covered by an appropriate version bump and migration/upgrade notes; only block when the bump is missing/mismatched (e.g., patch bump) or when the breaking change introduces unresolved risk. -- **Form a recommendation** - - State BASE_TAG and TARGET explicitly. - - Provide a concise diff summary (key directories/files and counts). - - List substantiated breaking changes, regressions/bugs, improvement opportunities, missing release notes/migrations, and the most important verified non-blocking contract changes. Do not turn every audit clue or touched directory into a report item. - - Recommend ship/block and the exact checks needed to unblock if blocking. If a breaking change is properly versioned (minor/major), you may still recommend a GREEN LIGHT TO SHIP while calling out the change. Use emoji and boldface in the release call to make the gate obvious. - - If you cannot provide a concrete unblock checklist item, do not use `BLOCKED`. - - Do not include routine command results, pass counts, skips, deselections, or a validation-status inventory. Mention a validation limitation only when it materially changes a specific finding or the release call. +### Prepare and map the diff -## Output format (required) +- Fetch current remote tags and the target ref. Keep the working tree out of the comparison. +- Prefer a user-specified base tag, but still refresh remote tags. +- Assume the target passed repository CI unless told otherwise. Do not rerun routine unit, lint, formatting, type, or coverage checks by default. +- Use diff stats, directory distribution, commit order, and name status to identify high-risk areas. Read changed tests as behavioral evidence, not as proof by themselves. -All output must be in English. +### Audit contracts and prove findings -Use the following report structure in every response produced by this skill. Be proactive and decisive: make a clear ship/block call near the top, and assign an explicit risk level (LOW/MODERATE/HIGH) to each finding with a short impact statement. Avoid overly cautious hedging when the risk is low and tests passed. +- Compare BASE and TARGET rather than reviewing TARGET in isolation. +- For public APIs, compare exports, identity, signatures, positional order, defaults, enums, and documented behavior. +- For packages, compare supported Python versions, dependencies, extras, distribution contents, version metadata, and import behavior. +- For persisted state, schemas, protocols, config, and environment variables, identify the released durable boundary and verify backward reads or a usable migration path. +- Route runtime changes through the owning reference in `.agents/references/README.md` and trace required consumers and symmetry axes. +- Promote a candidate only when the diff proves a contract violation, reachable supported-path regression, or concrete user-visible release consideration. +- Use the smallest BASE-versus-TARGET public-path or installed-artifact probe when static evidence cannot resolve a decision-relevant question. +- Assign **🟢 LOW** to verified, correctly versioned considerations, **🟡 MODERATE** to concrete unresolved regression signals, and **🔴 HIGH** to confirmed blockers. +- Include `Evidence`, `Impact`, `Files`, and `Action` for every risk item. Do not manufacture test or code work for a safe release consideration. -Always use the fixed repository URL in the Diff section (`https://github.com/openai/openai-agents-python/compare/...`). Do not use `${GITHUB_REPOSITORY}` or any other template variable. Format risk levels as bold emoji labels: **🟢 LOW**, **🟡 MODERATE**, **🔴 HIGH**. +### Review documentation coverage -Every Risk assessment item must contain an actionable next step. If the report uses `**🔴 BLOCKED**`, include an `Unblock checklist` section with at least one concrete command/task and a pass condition. +- First derive a documentation-obligation inventory from the runtime audit: breaking changes, migrations, defaults, opt-ins/opt-outs, major features, public APIs, provider/version compatibility, durable schemas, and changed user workflows. +- Before reporting any obligation as uncovered, inspect current open PRs through approved read-only GitHub access. Never use `gh` in this repository and never mutate GitHub. +- Discover candidates using the intended/recommended version, feature names, linked implementation PRs, branch names, and changed documentation paths. Do not rely on the PR title alone. +- For each candidate, record the PR URL/number and latest head SHA, then review its complete current diff and any current discussion that materially affects a coverage claim. Several PRs may collectively cover the inventory. +- Keep the release target diff and documentation-PR diffs separate. Do not imply that an open docs PR is already part of the release target. +- Classify aggregate coverage as `covered`, `partially covered`, `not covered`, `stale/conflicting`, or `unverified`. +- If current read-only GitHub access is unavailable, use `unverified`, explain the search limitation, and do not claim that no docs PR exists. +- For every obligation that is not demonstrably covered, including `partially covered`, `not covered`, `stale/conflicting`, and `unverified` cases, suggest the exact post-release file, section, example or claim, and migration wording. Mark suggestions provisional when coverage is unverified. +- Treat an unmerged docs PR as an acceptable post-release handoff. Documentation is published live, so note when the PR should remain unmerged until the SDK release is available. -``` +### Draft minor-release Key Changes + +- Include a copy-ready Key Changes draft whenever the intended release is `minor` or pre-release planning recommends `minor`. Omit it for patch releases unless the user requests it. +- Derive the draft from verified user-facing contracts, not raw commit counts or directory summaries. +- Follow the established GitHub release format: + + ```markdown + ## Key Changes + + + + ### Highlights: + + - + ``` + +- Put breaking behavior and the supported migration or fallback first. If the minor bump is for major features without a break, say so explicitly. +- Cover the major release themes without reproducing the full `## What's Changed` list. Preserve exact public names, defaults, version bounds, opt-outs, and compatibility qualifiers. +- Link to published documentation when it already exists. When documentation is only in an open PR, do not publish an unstable branch link; keep the wording self-contained and mention the docs PR separately in Documentation coverage. +- Produce the draft even when the release is blocked, but do not let polished release copy hide the blocker. + +## Form the recommendation + +- State BASE_TAG, TARGET commit, review mode, intended release type, minimum required type, and versioning verdict. +- Summarize key directories and file counts without turning every commit into a report item. +- List only substantiated blockers and the most important verified release considerations, normally two to five grouped by user impact. +- Keep documentation coverage in its own non-blocking section. +- If blocked, include an exact unblock checklist and pass condition. If no concrete unblock action exists, do not block. +- Do not include routine command results, pass counts, skips, deselections, or a validation-status inventory. + +## Output format (required) + +Produce the report in English using this structure. Always use the fixed compare URL `https://github.com/openai/openai-agents-python/compare/...`. + +```markdown ### Release readiness review ( -> TARGET ) This is a release readiness report done by `$final-release-review` skill. @@ -113,34 +162,58 @@ This is a release readiness report done by `$final-release-review` skill. https://github.com/openai/openai-agents-python/compare/... -### Release call: +### Release intent + +- Review mode: +- Intended release: +- Minimum required release type: +- Recommended release type: +- Versioning verdict: + +### Release call + **<🟢 GREEN LIGHT TO SHIP | 🔴 BLOCKED>** -### Scope summary: +### Scope summary + - -### Risk assessment (ordered by impact): -1) **** - - Risk: **<🟢 LOW | 🟡 MODERATE | 🔴 HIGH>**. - - Evidence: +### Risk assessment (ordered by impact) + +1. **** + - Risk: **<🟢 LOW | 🟡 MODERATE | 🔴 HIGH>**. + - Evidence: - Files: - - Action: -2) ... + - Action: -### Unblock checklist (required when Release call is BLOCKED): -1. [ ] - - Exit criteria: -2. ... +### Documentation coverage (non-blocking) -### Notes: -- -``` +- Coverage source: +- Status: +- Covered obligations: +- Gaps or post-release suggestions: +- Publication timing: + +### Unblock checklist + +1. [ ] + - Exit criteria: + +### Key Changes draft -For a green gate, the Risk assessment must still itemize the important verified release considerations as **🟢 LOW** when the diff has behavior, API, package, schema, protocol, configuration, observability, or user-workflow impact. Do not use "No material risks identified" as the sole Risk assessment for such a release. That fallback is allowed only when the diff has no reportable contract or user-facing surface, such as a metadata-only release. Do not add a verification-status section or report routine check results. If the report is not blocked, omit the `Unblock checklist` section. + + +### Notes + +- +``` -Typical green items include a correctly versioned default change with its exact opt-in or opt-out path, a durable schema bump with backward-read behavior, an optional-extra or supported-version expansion that retains compatibility, or a tracing change with an explicit opt-out. Keep each item tied to consumer impact and a release-handoff pass condition. +- Omit `Unblock checklist` when the release is green. +- Omit `Key Changes draft` for patch releases unless requested. +- For a behavior-impacting green release, retain at least one **🟢 LOW** consideration; do not return only "No material risks identified". +- For a metadata-only release with no reportable user-facing contract, a concise empty-risk statement is acceptable. -### Resources +## Resources -- `scripts/find_latest_release_tag.sh`: Fetches remote tags and returns the newest tag matching a pattern (default `v*`). -- `references/review-checklist.md`: Detailed signals and commands for spotting breaking changes, regressions, and release polish gaps. +- `scripts/find_latest_release_tag.sh`: refresh remote tags and return the newest matching release tag. +- `references/review-checklist.md`: detailed discovery signals, release-intent checks, docs-coverage review, and evidence requirements. diff --git a/.agents/skills/final-release-review/agents/openai.yaml b/.agents/skills/final-release-review/agents/openai.yaml index 1c09487791..437d0f86a1 100644 --- a/.agents/skills/final-release-review/agents/openai.yaml +++ b/.agents/skills/final-release-review/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Final Release Review" - short_description: "Audit a release candidate against the previous tag" - default_prompt: "Use $final-release-review to audit the release candidate diff against the previous release tag and call the ship/block gate." + short_description: "Plan and audit releases with docs coverage" + default_prompt: "Use $final-release-review to determine review mode, release intent, and the minimum compatible release type, audit the target, review open docs PR coverage, and draft Key Changes when the release is minor." diff --git a/.agents/skills/final-release-review/references/review-checklist.md b/.agents/skills/final-release-review/references/review-checklist.md index 8368fd6e1c..2a0e553000 100644 --- a/.agents/skills/final-release-review/references/review-checklist.md +++ b/.agents/skills/final-release-review/references/review-checklist.md @@ -1,116 +1,132 @@ # Release Diff Review Checklist -## Quick commands - -- Sync tags: `git fetch origin --tags --prune`. -- Identify latest release tag (default pattern `v*`): `git tag -l 'v*' --sort=-v:refname | head -n1` or use `.agents/skills/final-release-review/scripts/find_latest_release_tag.sh`. -- Generate overview: `git diff --stat BASE...TARGET`, `git diff --dirstat=files,0 BASE...TARGET`, `git log --oneline --reverse BASE..TARGET`. -- Inspect risky files quickly: `git diff --name-status BASE...TARGET`, `git diff --word-diff BASE...TARGET -- `. - -## Gate decision matrix - -- Choose `🟢 GREEN LIGHT TO SHIP` when no concrete blocking trigger is found. -- Choose `🔴 BLOCKED` only when at least one blocking trigger has concrete evidence and a defined unblock action. -- Blocking triggers: - - Confirmed regression/bug introduced in the diff. - - Confirmed breaking public API/protocol/config change with missing or mismatched versioning/migration path. - - Concrete data-loss/corruption/security-impacting issue with unresolved mitigation. - - Release-critical build/package/runtime break introduced by the diff. -- Non-blocking by itself: - - Large refactor or high file count. - - Speculative risk without evidence. - - Not running tests locally. -- If uncertain, keep the gate green. Add a focused follow-up only when it resolves a concrete risk already identified in the diff. -- A green gate is not an empty audit. Itemize the most important verified release considerations when the diff changes behavior, APIs, packages, schemas, defaults, observability, or user workflows. - -## Actionability contract - -- Every risk finding or non-blocking release consideration should include: - - `Evidence`: specific file/commit/diff/test signal. - - `Impact`: one-sentence user or runtime effect. - - `Action`: concrete command/task with pass criteria. -- A candidate becomes a finding only when it has a concrete contract violation, a reachable supported path, or a release-polish gap with user impact. -- A verified intentional change may become a **🟢 LOW** release consideration when it defines a contract users must understand, such as a default flip, new trace behavior, public API expansion, supported-version widening, or durable schema transition. -- For a green gate with behavior or contract impact, include at least one consideration and normally two to five. Group related changes by consumer impact rather than listing files or commits individually. -- For a resolved LOW consideration, the action may be a release-handoff check: retain exact compatibility, migration, opt-out, or configuration wording in generated release notes and state the pass condition. -- Changed tests, missing tests, diff size, and risky patterns are discovery signals; they are not findings without contract or runtime evidence. -- A `BLOCKED` report must contain an `Unblock checklist` with at least one executable item. -- If no executable unblock item exists, do not block. Keep the gate green; use validation or fix actions for unresolved risks and release-handoff checks for resolved LOW considerations. -- Do not use "No material risks identified" as the sole Risk assessment when the diff has reportable behavior or contract changes. Reserve it for metadata-only or otherwise non-reportable release diffs. - -## Two-stage audit +Use the release-mode, versioning, gate, documentation, and output policies in `../SKILL.md` as the normative rules. This checklist supplies operational discovery and evidence checks without redefining those policies. -### Stage 1: broad discovery +## Establish the review inputs + +- Sync remote tags and resolve the latest matching release tag with `../scripts/find_latest_release_tag.sh origin 'v*'`. +- Refresh the requested target, defaulting to `origin/main`, and record its exact commit. +- Resolve review mode first, then release intent. Record the evidence for each decision separately. +- Generate `git diff --stat BASE...TARGET`, `git diff --dirstat=files,0 BASE...TARGET`, `git log --oneline --reverse BASE..TARGET`, and `git diff --name-status BASE...TARGET`. +- Inspect suspicious paths with `git diff --word-diff BASE...TARGET -- `. +- Keep working-tree changes and open documentation PR diffs outside the release comparison. + +## Determine the minimum release type + +Compare the diff with the released BASE contract. Use `minor` as the minimum for either of these conditions: + +- a breaking change to a non-beta public API, protocol, configuration, environment, or durable serialized boundary; +- a major user-facing feature addition that warrants a minor release under repository policy. + +Use `patch` otherwise. For a final candidate, verify the intended version against the branch name, `pyproject.toml`, `uv.lock`, and built package metadata when relevant. For planning mode, do not interpret unchanged version metadata as a declared patch candidate. + +Capture: + +- review mode and its evidence; +- intended release type/version and its evidence, or `unspecified`; +- minimum required release type and the contracts that establish it; +- planning recommendation or final-candidate compatibility verdict. + +## Audit runtime and package contracts -Use all of the existing breaking-change, regression, dependency, documentation, and improvement signals below. The goal is high recall: collect plausible candidates without prematurely reporting them. +### Stage 1: broad discovery -Read changed tests as behavioral documentation. Identify the intended outcome, covered branches, deleted assertions, new skips, and missing failure paths, but do not rerun repository unit tests merely to accumulate passing evidence. +Scan the full diff for breaking changes, regressions, dependencies, package changes, persistence, error handling, concurrency, and release-polish signals. Read changed tests as behavioral evidence, including removed assertions, new skips, and uncovered failure paths. ### Stage 2: contract and invariant proof For each candidate: -1. Compare the released BASE behavior or contract with TARGET. Do not infer compatibility from TARGET alone. -2. Identify the owning SDK boundary using `.agents/references/README.md`. -3. Trace the changed value, state, item, identity, or side effect across every downstream consumer required by that boundary. -4. Check the relevant paired paths and failure modes. -5. Promote the candidate to a finding only when this trace establishes concrete impact. - -Use these contract comparisons when relevant: +1. Compare the released BASE contract with TARGET. +2. Identify the owning boundary through `.agents/references/README.md`. +3. Trace the changed value, identity, state, or side effect through every required consumer. +4. Check only the relevant parity and failure axes. +5. Promote the candidate only when the trace proves concrete impact. | Changed surface | BASE-versus-TARGET audit | |---|---| -| Public API | Exports, import identity, signatures, positional parameter order, dataclass field order, defaults, enums, and documented behavior | -| Runner and run items | Provider output, result items, semantic stream events, session history, replay, handoffs, and `RunState` | +| Public API | Exports, import identity, signatures, positional order, defaults, enums, and documented behavior | +| Runner and run items | Provider output, result items, stream events, session history, replay, handoffs, and `RunState` | | Tool execution | Planning, approvals, guardrails, invocation, hooks, output conversion, persistence, cancellation, and cleanup | | Conversation and sessions | First turn, follow-up, retry, filtering, handoff, compaction, interruption, and resume | -| Model and provider adapters | Model/settings resolution, request conversion, streaming terminals, provider data, errors, retries, and transport ownership | -| Persisted schemas and config | Serialized shape, version support, backward reads, migrations, defaults, environment variables, and wire compatibility | -| Package boundary | Supported Python versions, dependencies, extras, distribution contents, public imports, and built wheel/sdist behavior | - -Select only the axes implicated by the diff: - -- streaming versus non-streaming; -- sync versus async; -- fresh execution versus serialized resume; -- client-managed versus server-managed state; -- success, exception, and cancellation; -- sequential versus concurrent execution; -- normal, partial-failure, and repeated cleanup. - -If static inspection cannot resolve a concrete semantic question, run the smallest public-path or installed-artifact probe that can. Prefer an identical BASE and TARGET scenario. A focused unit test is a fallback for reproducing a specific failure, not the default release validation. - -## Breaking change signals - -- Public API surface: removed/renamed modules, classes, functions, or re-exports; changed parameters/return types, default values changed, new required options, stricter validation. -- Protocol/schema: request/response fields added/removed/renamed, enum changes, JSON shape changes, ID formats, pagination defaults. -- Config/CLI/env: renamed flags, default behavior flips, removed fallbacks, environment variable changes, logging levels tightened. -- Dependencies/platform: Python version requirement changes, dependency major bumps, `pyproject.toml`/`uv.lock` changes, removed or renamed extras. -- Persistence/data: migration scripts missing, data model changes, stored file formats, cache keys altered without invalidation. -- Docs/examples drift: examples still reflect old behavior or lack migration note. - -## Regression risk clues - -- Large refactors with light test deltas or deleted tests; new `skip`/`todo` markers. -- Concurrency/timing: new async flows, asyncio event-loop changes, retries, timeouts, debounce/caching changes, race-prone patterns. -- Error handling: catch blocks removed, swallowed errors, broader catch-all added without logging, stricter throws without caller updates. -- Stateful components: mutable shared state, global singletons, lifecycle changes (init/teardown), resource cleanup removal. -- Third-party changes: swapped core libraries, feature flags toggled, observability removed or gated. - -## Improvement opportunities - -- Missing coverage for new code paths; add focused tests. -- Performance: obvious N+1 loops, repeated I/O without caching, excessive serialization. -- Developer ergonomics: unclear naming, missing inline docs for public APIs, missing examples for new features. -- Release hygiene: add migration/upgrade note when behavior changes; ensure changelog/notes capture user-facing shifts. - -## Evidence to capture in the review output - -- BASE tag and TARGET ref used for the diff; confirm tags fetched. -- High-level diff stats and key directories touched. -- Concrete, actionable findings plus the most important verified non-blocking release considerations, each with evidence, impact, affected files, and action. -- A validation command or task only when it resolves a specific finding; include its pass criteria. -- For a resolved LOW consideration, a precise generated-release-note or migration-wording check with a pass condition is sufficient; do not manufacture code changes or redundant tests. -- Explicit release gate call (ship/block) with conditions to unblock. -- `Unblock checklist` section when (and only when) gate is `BLOCKED`. -- Do not report routine command results, pass counts, skips, deselections, or a validation-status inventory. +| Model/provider adapters | Settings resolution, request conversion, streaming terminals, provider data, errors, retries, and transport ownership | +| Persisted schemas/config | Serialized shape, supported versions, backward reads, usable migrations, defaults, env vars, and wire compatibility | +| Package boundary | Python support, dependencies, extras, version metadata, distribution contents, public imports, and wheel/sdist behavior | + +Relevant axes include streaming/non-streaming, sync/async, fresh/resumed, client/server-managed state, success/error/cancellation, sequential/concurrent, and normal/partial/repeated cleanup. + +When static inspection is insufficient, run the smallest identical BASE and TARGET public-path or installed-artifact probe. Do not run broad unit slices merely to accumulate passing evidence. + +## Check high-signal change classes + +- Public API: removed or renamed exports, changed signatures or positional order, default changes, new required values, or stricter validation. +- Protocol/config: request or response fields, enums, ID meaning, config flags, environment variables, or default behavior flips. +- Package/platform: Python support, dependency major changes, extras, package contents, or import side effects. +- Persistence: durable schema, stored format, backward reads, migration capability, cache identity, or resume behavior. +- Runtime: concurrency, cancellation, retries, timeouts, resource ownership, cleanup, swallowed errors, or changed exception types. +- Security: sensitive values in exceptions, logs, traces, telemetry, persisted state, or model-visible output. + +Separate a released supported-path break with no usable migration, fallback, or compatibility path from a usable path that merely lacks documentation. Only the former is a compatibility blocker. + +## Make every reported item actionable + +For every risk finding or verified release consideration, capture: + +- `Evidence`: concrete BASE-versus-TARGET source, contract, artifact, test, or probe evidence. +- `Impact`: one user or runtime consequence. +- `Files`: the affected paths. +- `Action`: an exact task or validation plus its pass condition. + +Changed tests, missing tests, large diffs, and risky patterns are discovery signals rather than findings. If no executable unblock action exists, do not manufacture one. For a safe LOW consideration, use a release-handoff action that preserves exact compatibility, migration, opt-out, default, or version-bound wording. + +## Audit documentation coverage + +### Build the obligation inventory + +Derive one row per user-facing obligation: + +`contract change | affected users | required migration/default/opt-out/version wording | expected docs surface` + +Include breaking behavior, major features, public API additions, defaults, provider/dependency bounds, durable state, and changed workflows. + +### Discover current open docs PRs + +- Use approved read-only GitHub access; never use `gh` or mutate GitHub in this repository. +- Refresh current open PR state for each review. Historical local refs, cached task context, and prior reports are not evidence of current coverage. +- Search with the intended or recommended version, release label, feature names, implementation PR links, branch names, and changed docs paths. +- Inspect candidate file lists, the complete latest PR diff, and current review discussion when it materially affects a coverage claim. Titles and descriptions are discovery hints only. +- Record each relevant PR number or URL and exact head SHA. Review competing or complementary PRs together when necessary. +- Keep open docs PR diffs outside `BASE...TARGET`; report them as follow-up coverage rather than shipped content. +- If the search fails or is incomplete, record the failing source and scope. Do not convert an unavailable search into `none found`. + +### Map evidence and follow-up work + +For every obligation, record: + +- the covering PR and exact file/section, if any; +- missing or incorrect qualifiers when coverage is partial or stale; +- an exact post-release file, section, example or claim, and migration wording whenever coverage is not demonstrably complete; +- whether a live-site docs PR should remain unmerged until the package is released. + +If GitHub access is unavailable, make the follow-up suggestions provisional and state what still needs verification. Missing docs never enters the unblock checklist by itself. + +## Build the conditional Key Changes draft + +When `../SKILL.md` requires the minor-release draft: + +- derive three to seven highlights from verified user-visible themes rather than commits; +- state breaking status explicitly and put migration or fallback guidance first; +- preserve exact identifiers, defaults, provider/model/dependency versions, opt-outs, and compatibility bounds; +- cover the major feature areas without reproducing the generated `## What's Changed` list; +- link only stable published docs and mention open docs PRs separately in Documentation coverage; +- keep the block copy-ready even if the release is blocked. + +## Final evidence inventory + +- BASE tag, TARGET commit, and confirmation that remote tags and target were refreshed. +- Review mode, intended release type, minimum required type, and versioning verdict. +- High-level diff stats and key directories. +- Concrete findings and verified release considerations with Evidence, Impact, Files, and Action. +- Documentation-obligation inventory, current docs PR source and head SHA or search limitation, aggregate coverage, and exact post-release suggestions. +- Conditional copy-ready Key Changes draft for minor releases. +- Explicit ship/block call and an unblock checklist only when blocked. diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index b81cdbff99..4fc2218018 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -202,6 +202,8 @@ When existing functionality or a better alternative materially affects the decis When recommending closure, requesting more evidence, requesting code changes, or superseding a PR, append the English, copy-paste-ready maintainer comment defined by the framework. If multiple PRs need different actions, label one draft for each affected PR. Include only merge-blocking requests in the main action paragraph; keep optional documentation or polish clearly non-blocking or omit it. +Before returning any maintainer comment draft, perform a GitHub paste-readiness pass using the repository-wide rule in `AGENTS.md` and the detailed guidance in `references/evaluation-framework.md`. In the draft, use `#123` for same-repository issues or PRs and `owner/repo#123` for cross-repository references. Remove Markdown-linked issue or PR labels, Codex navigation links, local file links, Codex-only citation markers or footnotes, and app directives from the copy-ready draft. Preserve ordinary descriptive links to API docs, design notes, and other targets without native GitHub issue or pull-request syntax. + For request-changes comments, phrase maintainer-owned semantic decisions as a directive, not as a menu. It is fine to mention the rejected alternative briefly in the rationale, but the requested action must identify the chosen behavior, scope, or compatibility boundary. Use "please do X because..." instead of "either do X or Y" when X versus Y changes the SDK contract or user-visible semantics. Do not produce a line-by-line review unless requested. Do not equate passing tests with merge-worthiness, or a logically correct patch with practical value. diff --git a/.agents/skills/maintainer-review/references/evaluation-framework.md b/.agents/skills/maintainer-review/references/evaluation-framework.md index 9738a5aaad..2d93b3e7c3 100644 --- a/.agents/skills/maintainer-review/references/evaluation-framework.md +++ b/.agents/skills/maintainer-review/references/evaluation-framework.md @@ -236,6 +236,16 @@ Keep each draft polite, direct, and copy-paste-ready. Usually use 60-160 words i 2. Explain the decision with the smallest amount of decisive technical evidence. 3. Give the exact next action or the condition for reconsideration. +Use GitHub-native references in every draft: + +- Use `#123` for an issue or pull request in `openai/openai-agents-python`. +- Use `owner/repo#123` for an issue or pull request in another repository. +- Keep closing keywords native, for example `Fixes #123` or `Resolves #123`. +- Never wrap a native reference in a Markdown link. Write `#123`, not `[PR #123](https://github.com/openai/openai-agents-python/pull/123)` or `[#123](...)`. +- Remove Codex-only navigation links, local file links, Codex-only citation markers or footnotes, and app directives from the draft. Preserve ordinary descriptive Markdown links for API docs, design notes, external resources, and GitHub targets that do not have native issue or pull-request syntax. + +Before returning the draft, normalize any same-repository URL or qualified reference to `#`, normalize any cross-repository issue or pull-request URL to `owner/repo#`, and rescan the draft. Do not return it while a Markdown-linked issue or pull-request label, `openai/openai-agents-python#`, or bare GitHub issue or pull-request URL remains. + Do not include internal labels such as `severity: low`, speculate about AI authorship or contributor intent, repeat the full review, or soften the message until the requested action becomes unclear. Do not ask contributors to choose maintainer-owned semantics. If two implementations are technically possible but one changes the SDK contract, decide the contract in the review and make the comment actionable. Use a short rationale such as "This keeps the new handler scoped to the existing raise site" or "This makes the handler name match all invalid final messages", then request the exact code and tests for that decision. diff --git a/.agents/skills/pr-draft-summary/SKILL.md b/.agents/skills/pr-draft-summary/SKILL.md index 313def852e..3190d97658 100644 --- a/.agents/skills/pr-draft-summary/SKILL.md +++ b/.agents/skills/pr-draft-summary/SKILL.md @@ -33,9 +33,10 @@ Produce the PR-ready summary required in this repository after eligible code wor 4) Summarize changes in 1–3 short sentences using the key paths (top 5) and `git diff --stat` output; explicitly call out untracked files from `git status -sb`/`git ls-files --others --exclude-standard` because `--stat` does not include them. If the working tree is clean but there are commits ahead of `${BASE_COMMIT}`, summarize using those commit messages. 5) Choose the lead verb for the description: feature → `adds`, bug fix → `fixes`, refactor/perf → `improves` or `updates`, docs-only → `updates`. 6) Suggest a branch name. If already off main, keep it; otherwise propose `feat/`, `fix/`, or `docs/` based on the primary area (e.g., `docs/pr-draft-summary-guidance`). -7) If the current branch matches `issue-` (digits only), keep that branch suggestion. Optionally pull light issue context (for example via the GitHub API) when available, but do not block or retry if it is not. When an issue number is present, reference `https://github.com/openai/openai-agents-python/issues/` and include an auto-closing line such as `This pull request resolves #.`. -8) Draft the PR title and description using the template below. -9) Output only the block in "Output Format". Keep any surrounding status note minimal and in English. +7) If the current branch matches `issue-` (digits only), keep that branch suggestion. Optionally pull light issue context (for example via the GitHub API) when available, but do not block or retry if it is not. When an issue number is present, use the native same-repository reference `#` and include an auto-closing line such as `This pull request resolves #.`. Do not add the explicit issue URL or wrap the reference in a Markdown link. +8) Draft the PR title and description using the template below. Apply the repository-wide GitHub paste-readiness rule: use exactly `#123` for same-repository issues or PRs and `owner/repo#123` for cross-repository references; never emit `[PR #123](https://github.com/owner/repo/pull/123)`, `[#123](...)`, Codex navigation links, local file links, Codex-only citation markers or footnotes, or app directives in the copy-ready block. Preserve ordinary descriptive links to API docs, design notes, and other targets without native GitHub issue or pull-request syntax. +9) Normalize references before returning the block: replace every same-repository URL or `openai/openai-agents-python#` reference with `#`, replace every cross-repository issue or pull-request URL with `owner/repo#`, then rescan the full block. Do not return it while a Markdown-linked issue or pull-request label, a same-repository qualified reference, or a bare GitHub issue or pull-request URL remains. +10) Output only the block in "Output Format". Keep any surrounding status note minimal and in English. ## Output Format When closing out a task, add this concise Markdown block (English only) after any brief status note unless the task falls under the documented skip cases or the user says they do not want it. diff --git a/AGENTS.md b/AGENTS.md index 0c8c393b29..a85124533f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,6 +255,7 @@ make tests ### Pull Request & Commit Guidelines - Use the template at `.github/PULL_REQUEST_TEMPLATE/pull_request_template.md`; include a summary, test plan, and issue number if applicable. +- In copy-ready GitHub text, use native issue and pull-request references: exactly `#123` for this repository and `owner/repo#123` for another repository. Do not qualify same-repository references as `openai/openai-agents-python#123`. Preserve closing forms such as `Fixes #123` or `Resolves #123`. Never wrap these references in Markdown links such as `[PR #123](https://github.com/owner/repo/pull/123)` or `[#123](...)`; those Codex-friendly links require manual cleanup after pasting into GitHub. Use descriptive Markdown links only for external resources or GitHub targets that cannot be expressed as a native issue or pull-request reference. - Add tests for new behavior when feasible and update documentation for user-facing changes. - Run `make format`, `make lint`, `make typecheck`, and `make tests` before marking work ready. - Commit messages should be concise and written in the imperative mood. Small, focused commits are preferred. From d9a384bdc489492a1cb7421a81953f5d83bd459f Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 19:01:46 -0500 Subject: [PATCH 227/473] fix(sandbox): restore archived file modes when extracting a workspace tar (#4287) --- src/agents/sandbox/util/tar_utils.py | 23 +++++++ tests/sandbox/test_tar_utils.py | 98 +++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/util/tar_utils.py b/src/agents/sandbox/util/tar_utils.py index 6ada6378a1..cf3c4595ac 100644 --- a/src/agents/sandbox/util/tar_utils.py +++ b/src/agents/sandbox/util/tar_utils.py @@ -212,6 +212,21 @@ def should_skip_tar_member( return any(_is_within(rel, prefix) for rel in rel_variants for prefix in prefixes) +def _restored_regular_file_mode(mode: int) -> int: + """Return the permission bits to restore for an extracted regular file. + + This mirrors the mode policy of the standard library's ``tarfile`` ``data`` filter, which + this extractor replaces: setuid, setgid, sticky and group/other write bits are dropped, + execute bits are kept only when the owner had them, and the owner is always left able to + read and write the file. + """ + + restored = mode & 0o755 + if not restored & 0o100: + restored &= ~0o111 + return restored | 0o600 + + def _ensure_no_symlink_parents(*, root: Path, dest: Path, check_leaf: bool = True) -> None: """ Ensure that no existing parent directory in `dest` is a symlink. @@ -410,6 +425,14 @@ def _write_file(member: tarfile.TarInfo, *, dest: Path, rel_path: Path, name: st try: with os.fdopen(fd, "wb") as out: shutil.copyfileobj(fileobj, out) + out.flush() + if hasattr(os, "fchmod"): + # Restore the archived permissions so a workspace snapshot round-trip keeps + # executable scripts executable. This runs on the still-open descriptor, + # after the payload is written and flushed: the file keeps its private + # creation mode while it holds partial data, and a failed copy leaves the + # partial file at 0o600 instead of its final readable/executable mode. + os.fchmod(out.fileno(), _restored_regular_file_mode(member.mode)) finally: try: fileobj.close() diff --git a/tests/sandbox/test_tar_utils.py b/tests/sandbox/test_tar_utils.py index 8f82b70af0..50402557c6 100644 --- a/tests/sandbox/test_tar_utils.py +++ b/tests/sandbox/test_tar_utils.py @@ -2,6 +2,8 @@ import io import os +import stat +import sys import tarfile from dataclasses import dataclass from pathlib import Path @@ -40,9 +42,11 @@ def _dir(name: str) -> _Member: return _Member(member) -def _file(name: str, payload: bytes = b"payload") -> _Member: +def _file(name: str, payload: bytes = b"payload", mode: int | None = None) -> _Member: member = tarfile.TarInfo(name) member.size = len(payload) + if mode is not None: + member.mode = mode return _Member(member, payload) @@ -393,3 +397,95 @@ def test_validate_tar_bytes_ignores_skipped_unsafe_member() -> None: _tar_bytes(_symlink(".runtime/escape", "/tmp/outside")), skip_rel_paths=[Path(".runtime")], ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are Unix-specific") +@pytest.mark.parametrize( + ("archived_mode", "expected_mode"), + [ + pytest.param(0o755, 0o755, id="executable-script"), + pytest.param(0o644, 0o644, id="plain-file"), + pytest.param(0o600, 0o600, id="owner-only-file"), + pytest.param(0o700, 0o700, id="owner-only-executable"), + pytest.param(0o444, 0o644, id="read-only-file-stays-owner-writable"), + pytest.param(0o000, 0o600, id="unreadable-file-stays-owner-readable"), + pytest.param(0o777, 0o755, id="group-and-other-write-dropped"), + pytest.param(0o655, 0o644, id="execute-without-owner-execute-dropped"), + pytest.param(0o4755, 0o755, id="setuid-dropped"), + pytest.param(0o2755, 0o755, id="setgid-dropped"), + pytest.param(0o1755, 0o755, id="sticky-dropped"), + ], +) +def test_safe_extract_tarfile_restores_regular_file_modes( + tmp_path: Path, + archived_mode: int, + expected_mode: int, +) -> None: + raw = _tar_bytes(_file("run.sh", b"#!/bin/sh\n", mode=archived_mode)) + + _safe_extract(raw, tmp_path) + + assert stat.S_IMODE((tmp_path / "run.sh").stat().st_mode) == expected_mode + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are Unix-specific") +def test_safe_extract_tarfile_keeps_workspace_scripts_executable(tmp_path: Path) -> None: + raw = _tar_bytes( + _dir("."), + _dir("./bin"), + _file("./bin/start", b"#!/bin/sh\necho hi\n", mode=0o755), + _file("./README.md", b"# readme\n", mode=0o644), + ) + + _safe_extract(raw, tmp_path) + + assert os.access(tmp_path / "bin" / "start", os.X_OK) + assert not os.access(tmp_path / "README.md", os.X_OK) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are Unix-specific") +def test_safe_extract_tarfile_restores_mode_when_replacing_an_existing_file( + tmp_path: Path, +) -> None: + _safe_extract(_tar_bytes(_file("run.sh", b"v1\n", mode=0o644)), tmp_path) + assert stat.S_IMODE((tmp_path / "run.sh").stat().st_mode) == 0o644 + + _safe_extract(_tar_bytes(_file("run.sh", b"v2\n", mode=0o755)), tmp_path) + + assert (tmp_path / "run.sh").read_bytes() == b"v2\n" + assert stat.S_IMODE((tmp_path / "run.sh").stat().st_mode) == 0o755 + + +class _FailingPayload: + """A member payload that yields one chunk and then fails, like a truncated read.""" + + def __init__(self, chunk: bytes) -> None: + self._chunk: bytes | None = chunk + + def read(self, size: int = -1) -> bytes: + if self._chunk is None: + raise OSError("payload stream failed") + chunk, self._chunk = self._chunk, None + return chunk + + def close(self) -> None: + return None + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are Unix-specific") +def test_safe_extract_tarfile_keeps_a_partially_written_file_private( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + raw = _tar_bytes(_file("run.sh", b"#!/bin/sh\necho hi\n", mode=0o755)) + + with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar: + monkeypatch.setattr(tar, "extractfile", lambda member: _FailingPayload(b"#!/bin/sh\n")) + + with pytest.raises(OSError, match="payload stream failed"): + safe_extract_tarfile(tar, root=tmp_path) + + dest = tmp_path / "run.sh" + assert dest.read_bytes() == b"#!/bin/sh\n" + assert stat.S_IMODE(dest.stat().st_mode) == 0o600 + assert not os.access(dest, os.X_OK) From 5f5c7738286ca6cc89af9732dcdd784ee393ff57 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 19:03:19 -0500 Subject: [PATCH 228/473] fix(models): convert input_file items that reference a file_id on the Chat Completions path (#4295) --- src/agents/models/chatcmpl_converter.py | 15 ++++-- .../test_openai_chatcompletions_converter.py | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 8f51ed5eee..227bea4504 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -488,11 +488,20 @@ def extract_all_content( out.append(cast(ChatCompletionContentPartInputAudioParam, audio_part)) elif isinstance(c, dict) and c.get("type") == "input_file": casted_file_param = cast(ResponseInputFileParam, c) - if "file_data" not in casted_file_param or not casted_file_param["file_data"]: + # The Chat Completions file content part accepts either inline file_data or a + # reference to an uploaded file_id. Prefer inline data when present, otherwise + # fall back to the file id (the SDK's own ToolOutputFileContent emits file-id + # only input_file items). A file_url is not representable here. + file_data = casted_file_param.get("file_data") + file_id = casted_file_param.get("file_id") + if file_data: + filedata = FileFile(file_data=file_data) + elif file_id: + filedata = FileFile(file_id=file_id) + else: raise UserError( - f"Only file_data is supported for input_file {casted_file_param}" + f"Only file_data or file_id is supported for input_file {casted_file_param}" ) - filedata = FileFile(file_data=casted_file_param["file_data"]) if "filename" in casted_file_param and casted_file_param["filename"]: filedata["filename"] = casted_file_param["filename"] diff --git a/tests/models/test_openai_chatcompletions_converter.py b/tests/models/test_openai_chatcompletions_converter.py index e75f3298cf..7a542fe2e9 100644 --- a/tests/models/test_openai_chatcompletions_converter.py +++ b/tests/models/test_openai_chatcompletions_converter.py @@ -691,6 +691,56 @@ def test_extract_all_content_rejects_invalid_input_audio(): Converter.extract_all_content([audio_missing_data]) +def test_extract_all_content_supports_input_file_file_id(): + """ + An input_file that references an uploaded file by id is representable by the + Chat Completions ``file`` content part, so it must convert rather than raise. + """ + content: list[dict[str, Any]] = [ + { + "type": "input_file", + "file_id": "file-abc123", + "filename": "hello.txt", + } + ] + + parts = Converter.extract_all_content(content) + + assert parts == [ + { + "type": "file", + "file": {"file_id": "file-abc123", "filename": "hello.txt"}, + } + ] + + +def test_extract_all_content_prefers_input_file_data_over_file_id(): + """When both file_data and file_id are present, file_data is used (prior behavior).""" + content: list[dict[str, Any]] = [ + { + "type": "input_file", + "file_data": "data:text/plain;base64,SGVsbG8=", + "file_id": "file-abc123", + } + ] + + parts = Converter.extract_all_content(content) + + assert parts == [ + { + "type": "file", + "file": {"file_data": "data:text/plain;base64,SGVsbG8="}, + } + ] + + +def test_extract_all_content_rejects_input_file_without_data_or_id(): + """An input_file that carries neither file_data nor file_id cannot be represented.""" + content: list[dict[str, Any]] = [{"type": "input_file", "filename": "hello.txt"}] + with pytest.raises(UserError): + Converter.extract_all_content(content) + + def test_items_to_messages_handles_system_and_developer_roles(): """ Roles other than `user` (e.g. `system` and `developer`) need to be From d3830f754ab88a8a7ab6e4ab0f032534e63c14c7 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Sat, 8 Aug 2026 05:38:48 +0530 Subject: [PATCH 229/473] fix(realtime): compute G.711 audio length for typed and mapping format spellings (#4292) --- src/agents/realtime/_util.py | 34 ++++++++++++++++-- tests/realtime/test_playback_tracker.py | 46 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/agents/realtime/_util.py b/src/agents/realtime/_util.py index 4de38f06fc..f3a16933d5 100644 --- a/src/agents/realtime/_util.py +++ b/src/agents/realtime/_util.py @@ -1,20 +1,48 @@ from __future__ import annotations +from collections.abc import Mapping + +from openai.types.realtime.realtime_audio_formats import AudioPCMA, AudioPCMU + from .config import RealtimeAudioFormat PCM16_SAMPLE_RATE_HZ = 24_000 PCM16_SAMPLE_WIDTH_BYTES = 2 G711_SAMPLE_RATE_HZ = 8_000 +# Every spelling of the two G.711 families accepted across the SDK: the legacy session-config +# names, the GA wire-format names, and their bare suffixes. Kept in sync with the session +# normalizer in `audio_formats.to_realtime_audio_format`. +_G711_FORMAT_NAMES = frozenset( + {"g711_ulaw", "g711_alaw", "audio/pcmu", "audio/pcma", "pcmu", "pcma"} +) + + +def _is_g711_format(format: RealtimeAudioFormat | None) -> bool: + """Whether the format is G.711 (8 kHz, one byte per sample) in any of its spellings. + + The format may arrive as a legacy string (``"g711_ulaw"``), a GA wire-format mapping + (``{"type": "audio/pcmu"}``), or a typed ``AudioPCMU`` / ``AudioPCMA`` object, depending on + how the session was configured and which path delivered it. + """ + if isinstance(format, str): + return format.lower() in _G711_FORMAT_NAMES or format.lower().startswith("g711") + if isinstance(format, AudioPCMU | AudioPCMA): + return True + if isinstance(format, Mapping): + format_type = format.get("type") + return isinstance(format_type, str) and format_type.lower() in _G711_FORMAT_NAMES + format_type = getattr(format, "type", None) + return isinstance(format_type, str) and format_type.lower() in _G711_FORMAT_NAMES + def calculate_audio_length_ms(format: RealtimeAudioFormat | None, audio_bytes: bytes) -> float: if not audio_bytes: return 0.0 - normalized_format = format.lower() if isinstance(format, str) else None - - if normalized_format and normalized_format.startswith("g711"): + if _is_g711_format(format): return (len(audio_bytes) / G711_SAMPLE_RATE_HZ) * 1000 + # PCM16 at 24 kHz, which also serves as the fallback for unknown formats. samples = len(audio_bytes) / PCM16_SAMPLE_WIDTH_BYTES return (samples / PCM16_SAMPLE_RATE_HZ) * 1000 diff --git a/tests/realtime/test_playback_tracker.py b/tests/realtime/test_playback_tracker.py index 2e426230a2..1dd70e22c2 100644 --- a/tests/realtime/test_playback_tracker.py +++ b/tests/realtime/test_playback_tracker.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, patch import pytest +from openai.types.realtime.realtime_audio_formats import AudioPCM, AudioPCMA, AudioPCMU from agents.realtime._default_tracker import ModelAudioTracker from agents.realtime.model import RealtimePlaybackTracker @@ -259,3 +260,48 @@ def test_audio_length_calculation_with_different_formats(self): # Test None format (defaults to PCM) none_length = calculate_audio_length_ms(None, pcm_bytes) assert none_length == pytest.approx(expected_pcm, rel=0, abs=1e-6) + + @pytest.mark.parametrize( + "audio_format", + [ + AudioPCMU(type="audio/pcmu"), + AudioPCMA(type="audio/pcma"), + {"type": "audio/pcmu"}, + {"type": "audio/pcma"}, + "audio/pcmu", + "audio/pcma", + ], + ids=["typed-ulaw", "typed-alaw", "mapping-ulaw", "mapping-alaw", "str-ulaw", "str-alaw"], + ) + def test_g711_length_is_correct_for_every_format_spelling(self, audio_format): + """G.711 is one byte per sample at 8 kHz regardless of how the format is spelled. + + Only the legacy `"g711_*"` strings were recognized, so the GA wire-format mapping and + the typed objects fell through to PCM16 math: 2 bytes per sample at 24 kHz, a 6x + shorter duration. That skews playback and interruption tracking for telephony + sessions, which are exactly the sessions that use G.711. + """ + from agents.realtime._util import calculate_audio_length_ms + + # 8000 bytes of G.711 is exactly one second of audio. + assert calculate_audio_length_ms(audio_format, b"\x00" * 8000) == 1000.0 + + @pytest.mark.parametrize( + "audio_format", + [AudioPCM(type="audio/pcm", rate=24000), {"type": "audio/pcm"}, "audio/pcm"], + ids=["typed", "mapping", "str"], + ) + def test_pcm_spellings_keep_pcm16_math(self, audio_format): + from agents.realtime._util import calculate_audio_length_ms + + # 48000 bytes of PCM16 at 24 kHz is exactly one second of audio. + assert calculate_audio_length_ms(audio_format, b"\x00" * 48000) == 1000.0 + + def test_playback_tracker_measures_g711_with_a_typed_format(self): + """End to end: a tracker configured with the GA typed format reports 8 kHz progress.""" + playback_tracker = RealtimePlaybackTracker() + playback_tracker.set_audio_format(AudioPCMU(type="audio/pcmu")) + + playback_tracker.on_play_bytes("item_1", 0, b"\x00" * 4000) + + assert playback_tracker.get_state()["elapsed_ms"] == 500.0 From f30f7baca082a2c3ec0cc4d467db78e66e01e365 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 19:59:45 -0500 Subject: [PATCH 230/473] fix(sandbox): keep source entry metadata when lazily loading a skill (#4294) --- src/agents/sandbox/capabilities/skills.py | 8 +- .../capabilities/test_skills_capability.py | 81 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/agents/sandbox/capabilities/skills.py b/src/agents/sandbox/capabilities/skills.py index dabfdb1cfb..e02071b916 100644 --- a/src/agents/sandbox/capabilities/skills.py +++ b/src/agents/sandbox/capabilities/skills.py @@ -253,7 +253,13 @@ async def load_skill( "path": str(metadata.path).replace("\\", "/"), } - await LocalDir(src=src_root / metadata.path.name).apply( + # Materialize through a copy of the configured source so the loaded skill keeps the + # entry metadata (permissions, group) that the eager `from_` path already applies. + skill_source = self.source.model_copy( + update={"src": src_root / metadata.path.name}, + deep=True, + ) + await skill_source.apply( session, skill_dest, base_dir=Path.cwd(), diff --git a/tests/sandbox/capabilities/test_skills_capability.py b/tests/sandbox/capabilities/test_skills_capability.py index 2eca35d219..6d220179ad 100644 --- a/tests/sandbox/capabilities/test_skills_capability.py +++ b/tests/sandbox/capabilities/test_skills_capability.py @@ -19,8 +19,8 @@ from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.sandbox_session import SandboxSession from agents.sandbox.snapshot import NoopSnapshot -from agents.sandbox.types import ExecResult, Permissions, User -from agents.sandbox.workspace_paths import coerce_posix_path +from agents.sandbox.types import ExecResult, FileMode, Group, Permissions, User +from agents.sandbox.workspace_paths import coerce_posix_path, sandbox_path_str from agents.tool import FunctionTool from agents.tool_context import ToolContext from agents.tracing import trace @@ -142,6 +142,20 @@ async def read(self, path: Path, *, user: object = None) -> io.BytesIO: raise WorkspaceReadNotFoundError(path=path, cause=exc) from exc +class _ExecRecordingSkillsSession(_SkillsSession): + def __init__(self, manifest: Manifest) -> None: + super().__init__(manifest) + self.commands: list[tuple[str, ...]] = [] + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + self.commands.append(tuple(str(part) for part in command)) + return await super()._exec_internal(*command, timeout=timeout) + + class _ArchiveReadErrorSkillsSession(_SkillsSession): async def read(self, path: Path, *, user: object = None) -> io.BytesIO: self.read_users.append(_user_name(user)) @@ -542,6 +556,69 @@ async def test_lazy_local_dir_load_skill_tool_materializes_single_skill( loaded_skill = workspace_root / ".agents" / "dynamic-skill" / "SKILL.md" assert loaded_skill.read_text(encoding="utf-8") == "# dynamic skill\n" + @pytest.mark.asyncio + async def test_lazy_local_dir_load_skill_applies_source_metadata(self, tmp_path: Path) -> None: + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + src_root = tmp_path / "skills" + skill_dir = src_root / "dynamic-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8") + + source = LocalDir( + src=src_root, + permissions=Permissions(owner=FileMode.ALL, group=0, other=0), + group=Group(name="staff", users=[]), + ) + capability = Skills(lazy_from=LocalDirLazySkillSource(source=source)) + manifest = capability.process_manifest( + _source_granted_manifest(workspace_root, source=src_root) + ) + session = _ExecRecordingSkillsSession(manifest) + capability.bind(session) + tool = cast(FunctionTool, capability.tools()[0]) + + await tool.on_invoke_tool( + cast(ToolContext[object], None), + '{"skill_name":"dynamic-skill"}', + ) + + skill_dest = sandbox_path_str(workspace_root / ".agents" / "dynamic-skill") + assert ("chmod", "0700", skill_dest) in session.commands + assert ("chgrp", "staff", skill_dest) in session.commands + # The configured source entry must not be repointed at the loaded skill. + assert source.src == src_root + + @pytest.mark.asyncio + async def test_lazy_local_dir_load_skill_keeps_default_permissions( + self, tmp_path: Path + ) -> None: + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + src_root = tmp_path / "skills" + skill_dir = src_root / "dynamic-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8") + + capability = Skills( + lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)), + ) + manifest = capability.process_manifest( + _source_granted_manifest(workspace_root, source=src_root) + ) + session = _ExecRecordingSkillsSession(manifest) + capability.bind(session) + tool = cast(FunctionTool, capability.tools()[0]) + + await tool.on_invoke_tool( + cast(ToolContext[object], None), + '{"skill_name":"dynamic-skill"}', + ) + + skill_dest = sandbox_path_str(workspace_root / ".agents" / "dynamic-skill") + assert ("chmod", "0755", skill_dest) in session.commands + assert not any(command[:1] == ("chgrp",) for command in session.commands) + class TestSkillsLazyLoading: def test_tools_returns_empty_without_lazy_source(self) -> None: From fd4db5609c2fdfb0b5926617878966d13a014517 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 12:07:09 +0900 Subject: [PATCH 231/473] chore: strengthen maintainer review probes and practical-impact gating --- .agents/skills/maintainer-review/SKILL.md | 42 +++++++++++------- .../maintainer-review/agents/openai.yaml | 2 +- .../references/evaluation-framework.md | 43 ++++++++++++++----- 3 files changed, 61 insertions(+), 26 deletions(-) diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index 4fc2218018..4f511633bc 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -24,7 +24,7 @@ Make a maintainer decision, not a generic code-review summary. Separate these qu Treat an issue's requested field, callback, flag, class, or implementation strategy as a proposed mechanism, not as the accepted requirement. Do not begin by asking how to implement it. First prove that a concrete user outcome is not already supported and that the proposed mechanism is better than the available alternatives. -Lead with the current review state. Use `Preliminary assessment` while runtime approval or evidence is pending, and `Maintainer decision` only when the review can be concluded. Use the diff, issue narrative, or contributor effort as evidence, not as a proxy for impact. +Lead with the current review state. Use `Preliminary assessment` while approval-gated runtime work or decision-relevant evidence is pending, and `Maintainer decision` only when the review can be concluded. Use the diff, issue narrative, or contributor effort as evidence, not as a proxy for impact. ## Workflow @@ -52,13 +52,22 @@ First assign one `Need evidence` status: Only `Demonstrated` need may receive `Merge-worthy as-is` or `Merge-worthy after focused changes`. For `Plausible but unproven`, prefer `Needs evidence` or `Not worth completing`; for `Already covered` or `Unsupported`, prefer closure or the relevant simpler alternative. +Before assigning `Demonstrated`, require one of these evidence paths: + +1. **Observed impact**: A supported scenario, real-path reproduction, or credible user report shows a meaningful user-visible, operational, compatibility, or durable-state consequence. +2. **Material prevention**: A supported or ordinary failure path can reach the condition, the violated invariant protects against intrinsically material harm, and a complete code-path trace or realistic probe establishes that consequence. A known incident is not required for this path. + +For both paths, trace `realistic trigger -> supported execution path -> observable or durable effect`. A local intermediate inconsistency, constructible branch, redundant operation, defensive improvement, or theoretically cleaner invariant is not a demonstrated need without a meaningful downstream effect. A small diff, technically correct patch, or inexpensive test does not lower this threshold. Material preventive outcomes include security or privacy exposure, credential leakage, persistent data or state corruption, duplicate external side effects, unrecoverable compatibility breaks, deadlock or indefinite hangs, and realistically repeatable resource exhaustion. + +When a report establishes only a harmless or speculative logic-level improvement, prefer `Not worth completing` or `Close` rather than requesting implementation refinements. Use `Needs evidence` only when a specific missing reproduction or consequence trace could realistically change the practical-impact decision. + 1. Restate the desired user outcome without naming the requested API, class, file, option, or implementation. Separate the actual constraint from the reporter's preferred mechanism. 2. Trace the closest supported ways to achieve that outcome in the current release and current target. Inspect the owning code path, public API, tests, and relevant docs rather than assuming that an unfamiliar capability is missing. Consider configuration, composition, cloning, callbacks, extension points, provider adapters, and doing the work at a caller-owned layer. 3. Determine whether the report shows a capability gap, an ergonomics or discoverability problem, an unsupported use case, or no demonstrated problem. A more convenient spelling is not automatically a missing capability. 4. Compare the proposed solution against the strongest existing approach and at least one better-design candidate: no code change, clearer documentation or validation, a narrower fix, reuse of an existing abstraction, or enforcement at a more coherent shared boundary. 5. For each viable approach, compare whether it satisfies the concrete scenario, what new public or internal contract it creates, cross-path consistency, compatibility, and permanent maintenance cost. -Do not treat a test proving that new code can work as evidence that the feature is needed. A `FakeModel` response, manually constructed provider item, mock, or new regression test can establish code-path reachability and implementation correctness; it does not by itself establish realistic provider behavior, user reach, frequency, practical consequence, or demand. +Do not treat a test proving that new code can work as evidence that the feature is needed. A `FakeModel` response, manually constructed provider item, mock, or new regression test can establish code-path reachability and implementation correctness; it does not by itself establish realistic provider behavior, user reach, frequency, practical consequence, demand, or a material preventive outcome. API symmetry, naming consistency, and parity with an adjacent tool, provider, or output type are design arguments, not evidence of need. Parity may justify work when it removes existing complexity or enforces a broad demonstrated invariant, but adding branches, tests, documentation, or public behavior requires independent practical justification. @@ -87,9 +96,9 @@ Use this evidence order across the two stages: 1. Trace the closest existing supported capabilities and determine whether they already satisfy the underlying user outcome. 2. Inspect existing tests and complete the code-path trace, including the mandatory interleaving and ownership pass when triggered, without executing code. -3. With explicit user approval, run a focused local reproduction of the exact claim when the desk-review rules below require it. +3. Proactively run a focused local reproduction of the exact claim when the desk-review rules below require it and it stays within the local-probe authorization below. 4. A comparison with the released version, base branch, or known-good control. -5. A broader runtime matrix only when the maintainer decision remains uncertain and the user approves it. +5. A broader runtime matrix only when the maintainer decision remains uncertain and the additional cost and scope are justified; request approval when the expansion crosses the authorization boundary below. #### Stage 1: desk review @@ -112,24 +121,27 @@ If any answer is missing and could change whether code should exist at all, do n Run this pass before any positive PR assessment when a patch adds, removes, or reorders cleanup, retry, reconnect, cancellation, listeners, shared futures or tasks, connections or streams, state flags, or mutable state across an `await`, callback, event, or deferred completion. -1. Name each shared resource or state value and the operation that owns it. Include listeners, futures, tasks, connections, streams, locks, caches, state flags, persistence, and telemetry. -2. Trace at least two overlapping operations, `A` and `B`, across every suspension or re-entry point. Check `A pending -> B starts -> A fails -> B succeeds`, `A pending -> B starts -> B fails -> A succeeds`, close or cancellation between setup and completion, and a stale completion arriving after newer work. -3. For every cleanup or rollback, identify the exact attempt and resource generation it is allowed to dispose. Treat unconditional cleanup after a suspension point as a regression candidate until the code proves it cannot tear down newer or surviving work. -4. Compare base and head for the survivor invariant. Replacing duplicated work with missing handlers, a closed shared resource, reverted state, or a failed surviving task is a regression, not successful cleanup. -5. Inspect tests for controlled interleavings using deferred futures, callbacks, or events. Require assertions about the surviving operation's observable behavior and final resource state, not only listener counts or individual exception results. +1. Name each shared resource or state value and enumerate every path that can mutate it, including distinct public methods and wrapper or delegate paths. Include listeners, futures, tasks, connections, streams, locks, caches, state flags, persistence, and telemetry. +2. Trace at least two overlapping operations, `A` and `B`, across every suspension or re-entry point. Choose `B` from the strongest distinct mutator, not only a second invocation of `A`. Check `A pending -> B starts -> A fails -> B succeeds`, `A pending -> B starts -> B fails -> A succeeds`, close or cancellation between setup and completion, and a stale completion arriving after newer work. +3. For snapshot-based cleanup or rollback, always trace `A snapshots -> A destructively mutates -> B commits newer state -> A rollback resumes`. Require the pre-`A` state plus `B`'s committed mutation to survive in the correct order, with persisted state, caches, indexes, flags, and related ownership state agreeing. +4. For every cleanup or rollback, identify the exact attempt and resource generation it is allowed to dispose. Require an ownership token, generation, identity check, compare-and-swap, transaction, proven serialization, or an equivalent invariant at the actual mutation boundary. Do not infer exclusivity from intended usage; treat overlap as unsupported only when an explicit contract or fail-fast validation enforces that restriction. +5. Compare base and head for the survivor invariant. Replacing duplicated work with missing handlers, a closed shared resource, reverted state, or a failed surviving task is a regression, not successful cleanup. Do not dismiss stale cleanup as pre-existing when the patch newly invokes it for another failure, cancellation, or retry path. +6. Inspect tests for controlled interleavings using deferred futures, callbacks, or events. Require assertions about the failing and surviving operations' observable behavior and final resource coherence, not only listener counts or individual exception results. -Do not mark a concurrency-sensitive patch `Merge-worthy as-is` merely because sequential reconnect, retry, failure, and close tests pass. If the code trace proves an unsafe interleaving, conclude from static evidence and request a focused fix and regression test. If ownership remains ambiguous, keep the result preliminary and request approval for the smallest decisive runtime probe. +Do not mark a concurrency-sensitive patch `Merge-worthy as-is` merely because sequential reconnect, retry, failure, and close tests pass. A triggered ownership pass is incomplete unless the evidence records the complete mutation surface, concrete ownership mechanism, strongest distinct-mutator interleaving, and survivor and coherence result. If the code trace proves an unsafe interleaving, conclude from static evidence and request a focused fix and regression test. If ownership remains ambiguous, keep the result preliminary until the smallest decisive runtime probe or equivalent evidence resolves it. - If the claim or PR is decisively negative from a complete reachable code-path trace, conclude the review without a runtime probe. Examples include an impossible or unsupported path, duplicated existing handling, a demonstrated no-op, a direct compatibility break, or a clearly wrong abstraction. Do not call an ambiguous result negative merely to avoid a probe. - If the initial result is positive and there is no unresolved runtime concern, and any triggered interleaving and ownership pass is complete, the desk review may be sufficient for a final maintainer decision. Do not run a probe only to restate evidence that cannot plausibly change the decision. -- If the initial result is positive but there is any unresolved runtime concern that could plausibly change claim validity, severity, merge-worthiness, required changes, or the preferred competing PR, stop before executing code. Report a `Preliminary assessment`, name the concern, propose the smallest decisive probe and control, and ask the user for approval to run it. +- If there is any unresolved runtime concern that could plausibly change claim validity, severity, merge-worthiness, required changes, or the preferred competing PR, run the smallest decisive local probe and control when authorized below. If the probe requires approval or is not practical, report a `Preliminary assessment`, name the concern, and explain the exact evidence still needed. - A purely stylistic, documentation, CI-status, or repository-readiness concern does not trigger a runtime probe unless it masks a runtime question. -Do not issue a definitive positive maintainer decision while a decision-relevant runtime concern remains unresolved. If the user declines the probe, keep the result preliminary and state the exact confidence limitation. +Do not issue a definitive positive maintainer decision while a decision-relevant runtime concern remains unresolved. If an approval-gated probe is declined or a material concern is practically probeable but remains untested, keep the result preliminary and state the exact confidence limitation. + +#### Stage 2: focused runtime probe -#### Stage 2: approved runtime probe +Invocation of this skill authorizes focused local-only probes that use existing dependencies and temporary or disposable data, do not use credentials, live APIs, or external services, do not modify tracked repository content or persistent external state, and remain narrowly scoped to the review question. Announce the probe before running it, then exercise the real public or internal path and include a base, release, or known-good control when relevant. Do not wait for separate approval for a qualifying local probe, and do not stop at a happy-path smoke check when failure behavior determines the decision. -After explicit approval, run only the smallest probe needed to resolve the stated concern. Exercise the real public or internal path and include a base, release, or known-good control when relevant. Do not stop at a happy-path smoke check when failure behavior determines the decision. Return to the user for separate approval before expanding materially beyond the approved probe. +Ask for explicit approval before using credentials, a live API, or an external service; installing dependencies; modifying tracked repository content or persistent external state; or starting a materially broad, expensive, or long-running probe. Return to the user for separate approval before expanding an authorized local probe across one of those boundaries. For latency, timeout, buffering, backpressure, or cleanup claims, measure at least one observable elapsed-time or state-transition path when feasible. Do not assume that a mocked unit test exercises real scheduling or provider behavior. Prefer a local probe first; use an approval-gated live-service probe only when local evidence cannot settle the decision. @@ -194,7 +206,7 @@ Choose the assessment language using this precedence: Do not infer the assessment language from the GitHub URL, contributor, code, or browser locale. Maintainer comment drafts remain English regardless of the assessment language. Keep the report decision-oriented and compact. Use no more than five evidence bullets by default; add more only when the decision genuinely depends on them. -Use the matching compact report variant in `references/evaluation-framework.md`. While runtime approval is pending, use its preliminary-assessment variant and end with the approval request instead of presenting a final recommendation. Collapse sections for simple cases rather than padding the answer. Put unexpected or negative runtime findings first, and name the preferred PR or approach explicitly when candidates compete. +Use the matching compact report variant in `references/evaluation-framework.md`. While approval-gated runtime work or decision-relevant evidence is pending, use its preliminary-assessment variant and end with the approval request or evidence limitation instead of presenting a final recommendation. Collapse sections for simple cases rather than padding the answer. Put unexpected or negative runtime findings first, and name the preferred PR or approach explicitly when candidates compete. For PRs, put `Need evidence` before code recommendation. When the need is not `Demonstrated`, lead with that result, omit repository readiness, and avoid presenting patch fixes as the primary maintainer action. diff --git a/.agents/skills/maintainer-review/agents/openai.yaml b/.agents/skills/maintainer-review/agents/openai.yaml index 549b0a989e..5b2212b12d 100644 --- a/.agents/skills/maintainer-review/agents/openai.yaml +++ b/.agents/skills/maintainer-review/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Maintainer Review" short_description: "Gate PR value on demonstrated user need" - default_prompt: "Use $maintainer-review with this GitHub issue or PR URL. Before evaluating implementation quality, verify that linked evidence matches the exact runtime variant and assign Need evidence as Demonstrated, Plausible but unproven, Already covered, or Unsupported. Only a Demonstrated need may receive a merge-worthy recommendation; synthetic tests, API parity, and contributor effort do not establish need. Then compare existing and alternative approaches, complete the desk review and required lifecycle ownership checks, request approval before any decision-relevant runtime probe, compare credible competing PRs, recommend the best maintainer action, and include an English comment draft when closure or changes are needed." + default_prompt: "Use $maintainer-review with this GitHub issue or PR URL. Before evaluating implementation quality, verify that linked evidence matches the exact runtime variant and assign Need evidence as Demonstrated, Plausible but unproven, Already covered, or Unsupported. Require either observed practical impact or a complete realistic trigger-to-material-consequence trace; reject harmless speculative logic-only improvements even when the patch is small and correct. Then compare existing and alternative approaches, complete the desk review and required lifecycle ownership checks, proactively run focused local-only probes for decision-relevant concerns, request approval before live API, credentialed, external, mutating, or materially broad runtime work, compare credible competing PRs, recommend the best maintainer action, and include an English comment draft when closure or changes are needed." diff --git a/.agents/skills/maintainer-review/references/evaluation-framework.md b/.agents/skills/maintainer-review/references/evaluation-framework.md index 2d93b3e7c3..fcb8fdbaf8 100644 --- a/.agents/skills/maintainer-review/references/evaluation-framework.md +++ b/.agents/skills/maintainer-review/references/evaluation-framework.md @@ -20,7 +20,7 @@ Use this reference when a claim is ambiguous, severity is disputed, or a PR is t ## Decision model -Treat validity, severity, and merge-worthiness as separate results. Also distinguish a `Preliminary assessment`, which may still require approved runtime evidence, from a final `Maintainer decision`. Do not label a provisional positive result as a verdict or final decision. +Treat validity, severity, and merge-worthiness as separate results. Also distinguish a `Preliminary assessment`, which may still require approval-gated runtime work or other decision-relevant evidence, from a final `Maintainer decision`. Do not label a provisional positive result as a verdict or final decision. | Dimension | Questions | Strong evidence | |---|---|---| @@ -29,7 +29,7 @@ Treat validity, severity, and merge-worthiness as separate results. Also disting | Consequence | What fails, and is the result silent or recoverable? | Observed output/error/state plus downstream effect | | Breadth | Who is affected? | Supported providers, platforms, versions, and configurations identified precisely | | Frequency | Is this normal, intermittent, or pathological? | Repeat runs, telemetry or reports when available, deterministic preconditions | -| Need evidence | Is the exact scope demonstrated, merely plausible, already covered, or unsupported? | Same-scope user scenario, real-path reproduction, released compatibility requirement, repeated demand, or broad consequential invariant | +| Need evidence | Is the exact scope demonstrated, merely plausible, already covered, or unsupported? | Observed impact or a complete realistic trigger-to-material-consequence trace for prevention | | Unmet need | What user outcome cannot be achieved through supported behavior today? | Concrete scenario plus a trace showing why the closest existing path is insufficient | | Existing capability | Can configuration, composition, cloning, callbacks, extension points, or a caller-owned layer already satisfy the outcome? | Current release code, tests, docs, and an exact supported workflow | | Compatibility | Is released behavior or durable state changed? | Latest release comparison and explicit contract inspection | @@ -82,6 +82,25 @@ Assign one status before deep implementation review: Only `Demonstrated` need can support a merge-worthy code recommendation. `Plausible but unproven` maps to `Needs evidence` or `Not worth completing`, even when the patch is technically correct and its remaining fixes are bounded. `Already covered` and `Unsupported` normally map to closure or a simpler non-core alternative. +### Practical-impact gate + +Do not accept a change merely because desk review identifies a local logical flaw, defensive improvement, or constructible edge case. Trace the complete consequence chain: + +`realistic trigger -> supported execution path -> observable or durable effect` + +A local intermediate inconsistency, redundant operation, surprising branch, or theoretically cleaner invariant is not a demonstrated need when it has no meaningful downstream effect. Reachability, a passing new test, a small diff, and low implementation cost establish neither practical impact nor maintenance value. + +Use one of these evidence paths: + +| Evidence path | Required proof | Insufficient proof | +|---|---|---| +| **Observed impact** | A supported scenario, real-path reproduction, or credible user report shows a meaningful user-visible, operational, compatibility, or durable-state consequence. | An internal state difference without a downstream effect, a synthetic branch, or a test that only proves the patch executes. | +| **Material prevention** | A supported or ordinary failure path reaches the condition; the violated invariant protects against intrinsically material harm; and a complete code-path trace or realistic probe establishes the concrete consequence and how the patch prevents it. | A statement that the condition "could" cause harm, an unsupported or malformed input, a mock-only scenario, or severity language without a complete consequence chain. | + +A known incident, user report, frequency estimate, or production reproduction is not required for a material-prevention case. Require credible reachability and a concrete consequence such as security or privacy exposure, credential leakage, persistent data or state corruption, duplicate external side effects, an unrecoverable compatibility break, deadlock or indefinite hangs, or realistically repeatable resource exhaustion. Do not wait for those outcomes to occur before accepting a proportionate preventive fix. + +When the trace ends in a harmless intermediate state, fully recoverable behavior without meaningful operational cost, theoretical cleanliness, or an unsupported scenario, classify the need as `Plausible but unproven`, `Unsupported`, or `No demonstrated gap` as appropriate. Prefer `Not worth completing` or `Close` over requesting implementation refinements. Use `Needs evidence` only when one specific missing reproduction or consequence trace could realistically change the practical-impact decision. + Before accepting an issue or recommending a PR, record: | Question | Required evidence | @@ -121,7 +140,7 @@ When requesting evidence, ask only for information that could change the disposi Assess these independently: -1. **Need**: Same-scope issue or runtime evidence demonstrates a concrete unmet user outcome that the closest supported capability cannot reasonably satisfy. Do not inherit evidence from an adjacent variant or already-fixed scenario. +1. **Need**: Same-scope evidence demonstrates either observed practical impact or a material preventive outcome that the closest supported capability cannot reasonably address. Do not inherit evidence from an adjacent variant or already-fixed scenario. 2. **Correctness**: The fix works for the reported case and meaningful boundaries. 3. **Placement**: The invariant is enforced once at the right layer instead of duplicating existing functionality, patching locally, or moving caller- or provider-owned policy into the core SDK. 4. **Consistency**: Equivalent sync/async, streaming/non-streaming, provider, serialization, and resume paths remain aligned where applicable. @@ -171,18 +190,22 @@ Use a two-operation interleaving matrix during desk review: | `A pending -> B starts -> A fails -> B succeeds` | Can A's cleanup remove or revert anything B needs? | | `A pending -> B starts -> B fails -> A succeeds` | Can B's cleanup leave A successful but non-functional? | | `A succeeds -> B starts -> stale A completion` | Can stale A overwrite B's newer state or generation? | +| `A snapshots -> A mutates -> B commits -> A rolls back` | Does A restore only its own state while preserving B's commit, ordering, and derived state? | | setup -> close/cancel -> late completion | Can late work resurrect listeners, state, tasks, or connections after teardown? | For each ordering: +- Enumerate every path that can mutate the resource, including distinct public operations and wrapper or delegate paths. Choose `B` from the strongest distinct mutator rather than assuming a second invocation of `A` is sufficient. - Identify the resource owner before and after every suspension point. - Distinguish per-attempt resources from shared runner, session, transport, cache, or listener state. -- Require cleanup to carry an ownership token, generation, identity check, serialization guarantee, or another invariant that prevents cross-attempt disposal. -- Compare base and head on the survivor invariant. Fewer duplicates do not justify losing the only active handler, connection, task, or state update. -- Require a controlled interleaving test when the ordering is reachable. The test must assert both the failing operation and the surviving operation's observable behavior after all completions settle. +- Require cleanup to carry an ownership token, generation, identity check, compare-and-swap, transaction, proven serialization guarantee, or another invariant at the actual mutation boundary that prevents cross-attempt disposal. +- Compare base and head on the survivor invariant. Fewer duplicates do not justify losing the only active handler, connection, task, or state update. Preserving or restoring `A` does not justify deleting, reverting, reordering, or hiding `B`. +- Require a controlled interleaving test when the ordering is reachable. The test must assert both the failing operation and the surviving operation's observable behavior after all completions settle, including agreement between persisted state and caches, indexes, flags, or other derived state. An unscoped `finally`, `except`, close handler, cancellation callback, or rollback that mutates shared state after a suspension point is merge-blocking when another operation can still own or use that state. +Do not infer exclusive access from intended usage. Treat overlapping operations as unsupported only when documentation, public typing, construction-time validation, or fail-fast runtime enforcement establishes that restriction. Do not dismiss stale cleanup as pre-existing when a patch newly makes that cleanup reachable from another failure, cancellation, retry, or interruption path. + ## Better-alternative prompts Start with the strongest existing supported path, then test at least one additional alternative against the proposed patch. Do not complete a positive review without this comparison. @@ -278,9 +301,9 @@ I am going to close this for now. If you can provide - Probe: - Control: -- Scope: +- Approval boundary: ## Approval request - + ``` ### Issue From 4da5ddb8eb46449df4f77494f077a36300164dfe Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 13:08:23 +0900 Subject: [PATCH 232/473] docs(skills): formalize finding-derived complexity resets --- .../skills/implementation-strategy/SKILL.md | 37 ++++++++++++++++--- .../agents/openai.yaml | 2 +- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index 3715a9daf4..9354df1769 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -50,6 +50,8 @@ Classify each finding as a required-behavior defect, supported compatibility req If a second related finding would add another condition, protocol hop, compatibility case, or test permutation to the same abstraction, stop patching and run the complexity reset. Continue only when concrete evidence puts the exact case in the required or supported contract. +After a reset spec is frozen, classify each later finding as a violation of that spec, an evidence-backed reason to revise it, an intentionally unsupported case, or an unrelated issue. Do not resume incremental patching merely because the new finding is locally fixable. + Example: if successive findings require traversing a direct wrapper, partial, nested wrapper, descriptor, and bound method, do not add another hop. Unless arbitrary wrapper graphs are supported, retain the required plain callable behavior and reject ambiguous wrappers before invocation. ## Core decision rules @@ -77,11 +79,34 @@ Stop extending the current design when: When a trigger fires: -1. Stop addressing comments one by one. -2. Group findings by root cause and re-read the original requirement and scope contract. -3. Compare the complete diff with the intended merge base or latest release tag. -4. Delete unnecessary branch-local machinery, narrow the contract, and reject unsupported cases before side effects. -5. Rebuild tests around required behavior and representative unsupported categories. +1. Stop editing and freeze the current revision for analysis instead of addressing comments one by one. +2. Group findings by root cause and re-read the original requirement, scope contract, and supported release or durable boundaries. +3. Write a candidate finding-derived reset spec using those inputs. Do not treat accumulated review explanations, branch-local machinery, or same-branch tests as requirements. +4. Audit the candidate spec against every affected entry point and the nearest existing supported paths. Revise it as needed, then freeze it before resuming edits. +5. Compare the complete diff with the intended merge base or latest release tag, and map each abstraction, branch, and test to the frozen spec as `retain`, `replace`, or `delete`. +6. Delete machinery with no mapping, narrow the contract, and reject unsupported cases before side effects. +7. Rebuild tests around required behavior, supported compatibility, cross-entry-point consistency, and representative unsupported categories. +8. Evaluate later findings against the frozen spec. Stop and record new contract evidence before changing the spec or widening the behavior space. + +Use this compact reset spec in the plan or working notes: + +```text +Finding-derived reset spec: +- Original required outcome: +- Supported release or durable boundaries: +- Grouped findings and common root cause: +- Invariants across affected entry points: +- Allowed states and behavior: +- Rejected states, failure timing, and side-effect boundary: +- Trusted and untrusted boundaries: +- Single sources of truth: +- Persistence, resume, cleanup, or other lifecycle semantics: +- Non-goals and supported alternatives: +- Representative test categories: +- Diff reset: retain / replace / delete: +``` + +The candidate spec is a falsifiable design hypothesis, not a record of the current implementation. The audit may correct it before it is frozen. Once frozen, require explicit evidence to revise it and re-run the complete diff mapping after any revision. Do not wait for the user or reviewer to request this reset when the signals are already present. @@ -95,6 +120,7 @@ Before declaring the design complete, answer all of these with concrete evidence - Are unsupported neighboring cases rejected before side effects with an existing alternative identified? - Do the complete diff and tests cover the contract without making every constructible permutation supported? - Does the latest review revision shrink or preserve the behavior space rather than widen it without evidence? +- When a complexity reset occurred, does every retained abstraction, branch, and test map to the frozen reset spec, with later findings classified against it? ## SDK-specific decision rules @@ -123,3 +149,4 @@ When this skill materially affects the implementation approach, state the decisi - `Compatibility boundary: latest release tag v0.x.y; branch-local interface rewrite, no shim needed.` - `Implementation scope contract: support X; preserve Y; reject Z before side effects; use supported alternative W, or none exists.` - `Complexity reset: repeated edge-case combinations show the approach is too broad; redesign from the original requirement instead of adding another branch.` +- `Finding-derived reset spec: findings F1-F3 expose invariant X across entry points A-C; freeze that contract, delete unmapped machinery, and review later findings against it.` diff --git a/.agents/skills/implementation-strategy/agents/openai.yaml b/.agents/skills/implementation-strategy/agents/openai.yaml index bce8346568..2acfd1c432 100644 --- a/.agents/skills/implementation-strategy/agents/openai.yaml +++ b/.agents/skills/implementation-strategy/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Implementation Strategy" short_description: "Choose a compatibility-aware implementation plan" - default_prompt: "Use $implementation-strategy before initial runtime or API edits and each review-feedback batch to check the full diff, supported contract, and convergence before patching." + default_prompt: "Use $implementation-strategy before initial runtime or API edits and each review-feedback batch to check the full diff, supported contract, convergence, and any finding-derived reset spec before patching." From ef1d202f0f29f5973225bdaca5b126e3bce100f0 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 7 Aug 2026 23:35:51 -0500 Subject: [PATCH 233/473] fix(memory): preserve falsey compaction decision hooks (#4299) --- .../openai_responses_compaction_session.py | 4 +- ...est_openai_responses_compaction_session.py | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index 8263a81036..e59090a263 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -125,7 +125,9 @@ def __init__( self.model = model self.compaction_mode = compaction_mode self.should_trigger_compaction = ( - should_trigger_compaction or default_should_trigger_compaction + should_trigger_compaction + if should_trigger_compaction is not None + else default_should_trigger_compaction ) # cache for incremental candidate tracking diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index 16b7ce8718..cf4453fc4d 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -156,6 +156,43 @@ async def test_run_compaction_requires_response_id(self) -> None: with pytest.raises(ValueError, match="previous_response_id compaction"): await session.run_compaction() + @pytest.mark.asyncio + async def test_run_compaction_honors_falsey_decision_hook(self) -> None: + class FalseyDecisionHook: + def __init__(self) -> None: + self.calls = 0 + + def __bool__(self) -> bool: + return False + + def __call__(self, context: dict[str, Any]) -> bool: + self.calls += 1 + return False + + items = [ + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": f"message {index}"}, + ) + for index in range(DEFAULT_COMPACTION_THRESHOLD) + ] + underlying = SimpleListSession(history=items) + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock() + decision_hook = FalseyDecisionHook() + session = OpenAIResponsesCompactionSession( + session_id="test", + underlying_session=underlying, + client=mock_client, + compaction_mode="input", + should_trigger_compaction=decision_hook, + ) + + await session.run_compaction() + + assert decision_hook.calls == 1 + mock_client.responses.compact.assert_not_awaited() + @pytest.mark.asyncio async def test_run_compaction_input_mode_without_response_id(self) -> None: mock_session = self.create_mock_session() From 98c363743aa5840ca661e3d9f833f0df62be9fd3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 17:55:23 +0900 Subject: [PATCH 234/473] Fix falsy optional reference handling (#4305) --- .github/scripts/check_optional_truthiness.py | 544 ++++++++++++ Makefile | 1 + src/agents/_tool_invocation.py | 6 +- src/agents/agent.py | 4 +- src/agents/agent_tool_input.py | 6 +- .../extensions/experimental/codex/codex.py | 12 +- .../experimental/codex/codex_tool.py | 15 +- .../extensions/experimental/codex/thread.py | 6 +- .../experimental/hosted_multi_agent/model.py | 2 +- .../memory/advanced_sqlite_session.py | 2 +- src/agents/extensions/models/any_llm_model.py | 26 +- src/agents/extensions/models/litellm_model.py | 16 +- .../extensions/sandbox/blaxel/sandbox.py | 4 +- .../extensions/sandbox/cloudflare/sandbox.py | 4 +- .../extensions/sandbox/daytona/sandbox.py | 4 +- src/agents/extensions/sandbox/e2b/sandbox.py | 6 +- .../extensions/sandbox/modal/sandbox.py | 8 +- .../extensions/sandbox/runloop/sandbox.py | 12 +- .../extensions/sandbox/vercel/sandbox.py | 4 +- src/agents/extensions/visualization.py | 4 +- src/agents/items.py | 6 +- src/agents/mcp/util.py | 2 +- .../memory/openai_conversations_session.py | 6 +- .../openai_responses_compaction_session.py | 3 +- src/agents/models/_response_terminal.py | 4 +- src/agents/models/chatcmpl_converter.py | 2 +- src/agents/models/chatcmpl_stream_handler.py | 4 +- .../models/openai_agent_registration.py | 4 +- src/agents/models/openai_chatcompletions.py | 20 +- src/agents/models/openai_provider.py | 23 +- src/agents/models/openai_responses.py | 12 +- src/agents/realtime/openai_realtime.py | 30 +- src/agents/realtime/session.py | 6 +- src/agents/result.py | 12 +- src/agents/retry.py | 10 +- src/agents/run.py | 48 +- src/agents/run_context.py | 32 +- .../run_internal/agent_runner_helpers.py | 6 +- src/agents/run_internal/model_retry.py | 28 +- src/agents/run_internal/run_loop.py | 59 +- .../run_internal/session_persistence.py | 8 +- src/agents/run_internal/tool_actions.py | 46 +- src/agents/run_internal/tool_execution.py | 17 +- src/agents/run_internal/tool_planning.py | 18 +- src/agents/run_internal/tool_use_tracker.py | 4 +- src/agents/run_internal/turn_resolution.py | 43 +- src/agents/run_state.py | 27 +- src/agents/sandbox/capabilities/skills.py | 4 +- src/agents/sandbox/runtime_session_manager.py | 8 +- src/agents/sandbox/sandboxes/docker.py | 8 +- src/agents/sandbox/sandboxes/unix_local.py | 6 +- src/agents/sandbox/session/manager.py | 2 +- src/agents/sandbox/session/sandbox_session.py | 4 +- src/agents/sandbox/snapshot.py | 3 +- src/agents/sandbox/snapshot_defaults.py | 2 +- src/agents/tool.py | 10 +- src/agents/tracing/context.py | 7 +- src/agents/tracing/create.py | 2 +- src/agents/tracing/provider.py | 4 +- src/agents/tracing/scope.py | 2 +- src/agents/tracing/span_data.py | 2 +- src/agents/util/_error_tracing.py | 2 +- .../voice/models/openai_model_provider.py | 17 +- src/agents/voice/models/openai_stt.py | 10 +- src/agents/voice/result.py | 15 +- src/agents/voice/workflow.py | 2 +- .../test_openai_conversations_session.py | 15 + ...est_openai_responses_compaction_session.py | 16 +- tests/models/test_any_llm_model.py | 27 + tests/models/test_litellm_extra_body.py | 12 + tests/models/test_model_retry.py | 21 + tests/models/test_openai_chatcompletions.py | 33 +- tests/models/test_openai_responses.py | 10 +- tests/realtime/test_openai_realtime.py | 30 + .../test_session_payload_and_formats.py | 30 + tests/test_agent_hooks.py | 32 + tests/test_agent_runner.py | 40 +- tests/test_cancel_streaming.py | 41 + tests/test_check_optional_truthiness.py | 831 ++++++++++++++++++ tests/test_config.py | 12 + tests/test_hitl_error_scenarios.py | 100 ++- tests/test_process_model_response.py | 41 + tests/test_run_context_wrapper.py | 39 + tests/test_run_state.py | 82 +- tests/test_tool_output_conversion.py | 11 + tests/test_tool_use_tracker.py | 7 +- tests/tracing/test_trace_context.py | 79 +- tests/tracing/test_tracing_env_disable.py | 29 + tests/voice/test_openai_model_provider.py | 14 + tests/voice/test_pipeline.py | 28 + 90 files changed, 2547 insertions(+), 309 deletions(-) create mode 100644 .github/scripts/check_optional_truthiness.py create mode 100644 tests/test_check_optional_truthiness.py diff --git a/.github/scripts/check_optional_truthiness.py b/.github/scripts/check_optional_truthiness.py new file mode 100644 index 0000000000..169c94e397 --- /dev/null +++ b/.github/scripts/check_optional_truthiness.py @@ -0,0 +1,544 @@ +from __future__ import annotations + +import argparse +import ast +import sys +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +_CALLABLE_MODULES = {"collections.abc", "typing"} + +FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef +ReferenceKind = Literal["callable", "class"] + + +@dataclass(frozen=True, order=True) +class Violation: + path: Path + line: int + column: int + expression: str + + def format(self) -> str: + return ( + f"{self.path}:{self.line}:{self.column}: optional object uses truthiness: " + f"{self.expression}" + ) + + +@dataclass +class _ClassInfo: + fields: set[str] = field(default_factory=set) + + +@dataclass +class _FunctionInfo: + node: FunctionNode + owner: int | None + signature_bindings: dict[str, ReferenceKind] + body_bindings: dict[str, ReferenceKind] + + +@dataclass +class _ModuleInfo: + path: Path + classes: dict[int, _ClassInfo] + functions: dict[int, _FunctionInfo] + + +def _walk_scope(body: list[ast.stmt]) -> Iterable[ast.AST]: + stack: list[ast.AST] = list(reversed(body)) + while stack: + current = stack.pop() + yield current + if isinstance( + current, + ast.FunctionDef + | ast.AsyncFunctionDef + | ast.ClassDef + | ast.Lambda + | ast.ListComp + | ast.SetComp + | ast.DictComp + | ast.GeneratorExp, + ): + continue + stack.extend(reversed(list(ast.iter_child_nodes(current)))) + + +def _walk_comprehension_bindings(nodes: Iterable[ast.AST]) -> Iterable[ast.AST]: + stack = list(reversed(list(nodes))) + while stack: + current = stack.pop() + if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): + continue + if isinstance(current, ast.NamedExpr): + yield current.target + stack.append(current.value) + continue + stack.extend(reversed(list(ast.iter_child_nodes(current)))) + + +def _walk_scope_bindings(body: list[ast.stmt]) -> Iterable[ast.AST]: + stack: list[ast.AST] = list(reversed(body)) + while stack: + current = stack.pop() + yield current + if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef): + defining_expressions: list[ast.AST] = [ + *current.decorator_list, + current.args, + ] + if current.returns is not None: + defining_expressions.append(current.returns) + stack.extend(reversed(defining_expressions)) + continue + if isinstance(current, ast.ClassDef): + defining_expressions = [ + *current.decorator_list, + *current.bases, + *(keyword.value for keyword in current.keywords), + ] + stack.extend(reversed(defining_expressions)) + continue + if isinstance(current, ast.Lambda): + stack.append(current.args) + continue + if isinstance(current, ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp): + first_generator, *remaining_generators = current.generators + stack.append(first_generator.iter) + nested_expressions: list[ast.AST] = [ + *(condition for generator in current.generators for condition in generator.ifs), + *(generator.iter for generator in remaining_generators), + ] + if isinstance(current, ast.DictComp): + nested_expressions.extend((current.key, current.value)) + else: + nested_expressions.append(current.elt) + yield from _walk_comprehension_bindings(nested_expressions) + continue + stack.extend(reversed(list(ast.iter_child_nodes(current)))) + + +def _walk_function(node: FunctionNode) -> Iterable[ast.AST]: + yield from _walk_scope(node.body) + + +def _is_static_method(function: FunctionNode) -> bool: + return any( + isinstance(decorator, ast.Name) + and decorator.id == "staticmethod" + or isinstance(decorator, ast.Attribute) + and isinstance(decorator.value, ast.Name) + and decorator.value.id == "builtins" + and decorator.attr == "staticmethod" + for decorator in function.decorator_list + ) + + +def _owns_instance_fields(function: FunctionNode) -> bool: + return not _is_static_method(function) + + +def _is_none_annotation(node: ast.expr) -> bool: + return ( + isinstance(node, ast.Constant) + and node.value is None + or isinstance(node, ast.Name) + and node.id == "None" + ) + + +def _optional_payload(annotation: ast.expr) -> ast.expr | None: + if not isinstance(annotation, ast.BinOp) or not isinstance(annotation.op, ast.BitOr): + return None + members: list[ast.expr] = [] + + def collect(node: ast.expr) -> None: + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + collect(node.left) + collect(node.right) + else: + members.append(node) + + collect(annotation) + payloads = [member for member in members if not _is_none_annotation(member)] + if len(payloads) != 1 or len(payloads) == len(members): + return None + return payloads[0] + + +def _is_direct_optional_reference( + annotation: ast.expr, + *, + bindings: dict[str, ReferenceKind], +) -> bool: + payload = _optional_payload(annotation) + if payload is None: + return False + if ( + isinstance(payload, ast.Subscript) + and isinstance(payload.value, ast.Name) + and bindings.get(payload.value.id) == "callable" + ): + return True + if not isinstance(payload, ast.Name): + return False + return bindings.get(payload.id) == "class" + + +def _reference_bindings_for_scope( + body: list[ast.stmt], + inherited_bindings: dict[str, ReferenceKind], + *, + arguments: Iterable[ast.arg] = (), + forced_shadows: Iterable[str] = (), + supported_classes: Mapping[int, str] | None = None, +) -> dict[str, ReferenceKind]: + supported_classes = supported_classes or {} + tracked_names = {*inherited_bindings, *supported_classes.values(), "Callable"} + supported_bindings: dict[str, ReferenceKind] = {} + shadowed_names = { + name + for name in [*(argument.arg for argument in arguments), *forced_shadows] + if name in tracked_names + } + + def record_supported(name: str, kind: ReferenceKind) -> None: + existing = supported_bindings.get(name) + if existing is not None: + shadowed_names.add(name) + else: + supported_bindings[name] = kind + + for node in _walk_scope_bindings(body): + if isinstance(node, ast.Import | ast.ImportFrom): + for imported in node.names: + if isinstance(node, ast.ImportFrom) and imported.name == "*": + shadowed_names.update(tracked_names) + continue + local_name = imported.asname or imported.name.split(".")[0] + is_standard_callable = ( + isinstance(node, ast.ImportFrom) + and node.level == 0 + and node.module in _CALLABLE_MODULES + and imported.name == "Callable" + and imported.asname is None + ) + if is_standard_callable: + record_supported("Callable", "callable") + elif local_name in tracked_names: + shadowed_names.add(local_name) + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + if isinstance(node, ast.ClassDef) and id(node) in supported_classes: + record_supported(node.name, "class") + elif node.name in tracked_names: + shadowed_names.add(node.name) + elif ( + isinstance(node, ast.Name) + and isinstance(node.ctx, ast.Store | ast.Del) + and node.id in tracked_names + ): + shadowed_names.add(node.id) + elif ( + isinstance(node, ast.ExceptHandler) + and isinstance(node.name, str) + and node.name in tracked_names + ): + shadowed_names.add(node.name) + elif isinstance(node, ast.MatchAs | ast.MatchStar) and node.name in tracked_names: + shadowed_names.add(node.name) + elif isinstance(node, ast.MatchMapping) and node.rest in tracked_names: + shadowed_names.add(node.rest) + + bindings = { + name: kind for name, kind in inherited_bindings.items() if name not in shadowed_names + } + bindings.update( + (name, kind) for name, kind in supported_bindings.items() if name not in shadowed_names + ) + return bindings + + +def _cross_scope_declared_names(tree: ast.Module) -> set[str]: + return { + name + for node in ast.walk(tree) + if isinstance(node, ast.Global | ast.Nonlocal) + for name in node.names + } + + +def _type_parameter_names(node: FunctionNode | ast.ClassDef) -> set[str]: + return { + name + for type_parameter in getattr(node, "type_params", ()) + if isinstance(name := getattr(type_parameter, "name", None), str) + } + + +def _collect_module_info(path: Path, tree: ast.Module) -> _ModuleInfo: + classes: dict[int, _ClassInfo] = {} + functions: dict[int, _FunctionInfo] = {} + cross_scope_shadows = _cross_scope_declared_names(tree) + + def is_simple_reference_class(node: ast.ClassDef) -> bool: + return not node.bases or all( + isinstance(base, ast.Name) and base.id == "object" for base in node.bases + ) + + supported_classes = { + id(node): node.name + for node in tree.body + if isinstance(node, ast.ClassDef) and is_simple_reference_class(node) + } + module_bindings = _reference_bindings_for_scope( + tree.body, + {}, + forced_shadows=cross_scope_shadows, + supported_classes=supported_classes, + ) + + def register_function( + function: FunctionNode, + *, + signature_bindings: dict[str, ReferenceKind], + inherited_body_bindings: dict[str, ReferenceKind], + owner: int | None, + ) -> None: + forced_shadows = cross_scope_shadows | _type_parameter_names(function) + signature_bindings = { + name: kind for name, kind in signature_bindings.items() if name not in forced_shadows + } + body_bindings = _reference_bindings_for_scope( + function.body, + inherited_body_bindings, + arguments=_arguments(function), + forced_shadows=forced_shadows, + ) + functions[id(function)] = _FunctionInfo( + node=function, + owner=owner, + signature_bindings=signature_bindings, + body_bindings=body_bindings, + ) + if owner is not None and _owns_instance_fields(function): + class_info = classes[owner] + for item in _walk_function(function): + if ( + isinstance(item, ast.AnnAssign) + and isinstance(item.target, ast.Attribute) + and isinstance(item.target.value, ast.Name) + and item.target.value.id == "self" + and _is_direct_optional_reference( + item.annotation, + bindings=body_bindings, + ) + ): + class_info.fields.add(item.target.attr) + for item in _walk_function(function): + if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef): + register_function( + item, + signature_bindings=body_bindings, + inherited_body_bindings=body_bindings, + owner=None, + ) + elif isinstance(item, ast.ClassDef): + register_class( + item, + inherited_body_bindings=body_bindings, + ) + + def register_class( + node: ast.ClassDef, + *, + inherited_body_bindings: dict[str, ReferenceKind], + ) -> None: + class_info = _ClassInfo() + classes[id(node)] = class_info + forced_shadows = cross_scope_shadows | _type_parameter_names(node) + nested_body_bindings = { + name: kind + for name, kind in inherited_body_bindings.items() + if name not in forced_shadows + } + class_bindings = _reference_bindings_for_scope( + node.body, + nested_body_bindings, + forced_shadows=forced_shadows, + ) + for item in _walk_scope(node.body): + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + if _is_direct_optional_reference( + item.annotation, + bindings=class_bindings, + ): + class_info.fields.add(item.target.id) + elif isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef): + register_function( + item, + signature_bindings=class_bindings, + inherited_body_bindings=nested_body_bindings, + owner=id(node), + ) + elif isinstance(item, ast.ClassDef): + register_class( + item, + inherited_body_bindings=nested_body_bindings, + ) + + for node in _walk_scope(tree.body): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + register_function( + node, + signature_bindings=module_bindings, + inherited_body_bindings=module_bindings, + owner=None, + ) + elif isinstance(node, ast.ClassDef): + register_class( + node, + inherited_body_bindings=module_bindings, + ) + + return _ModuleInfo(path, classes, functions) + + +def _arguments(function: FunctionNode) -> list[ast.arg]: + arguments = [ + *function.args.posonlyargs, + *function.args.args, + *function.args.kwonlyargs, + ] + if function.args.vararg is not None: + arguments.append(function.args.vararg) + if function.args.kwarg is not None: + arguments.append(function.args.kwarg) + return arguments + + +def _function_declarations(function: _FunctionInfo) -> set[str]: + declarations = { + argument.arg + for argument in _arguments(function.node) + if argument.annotation is not None + and _is_direct_optional_reference( + argument.annotation, + bindings=function.signature_bindings, + ) + } + for node in _walk_function(function.node): + if ( + isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and _is_direct_optional_reference( + node.annotation, + bindings=function.body_bindings, + ) + ): + declarations.add(node.target.id) + return declarations + + +def _truthiness_atoms(node: ast.expr) -> Iterable[ast.expr]: + if isinstance(node, ast.Name | ast.Attribute): + yield node + elif isinstance(node, ast.NamedExpr): + yield from _truthiness_atoms(node.value) + elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): + yield from _truthiness_atoms(node.operand) + elif isinstance(node, ast.BoolOp): + for value in node.values: + yield from _truthiness_atoms(value) + + +def _tested_expressions(function: FunctionNode) -> Iterable[ast.expr]: + for node in _walk_function(function): + if isinstance(node, ast.If | ast.While | ast.Assert | ast.IfExp): + yield from _truthiness_atoms(node.test) + elif isinstance(node, ast.match_case) and node.guard is not None: + yield from _truthiness_atoms(node.guard) + elif isinstance(node, ast.BoolOp): + for value in node.values[:-1]: + yield from _truthiness_atoms(value) + elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): + yield from _truthiness_atoms(node.operand) + + +def _is_declared_reference( + expression: ast.expr, + declarations: set[str], + owner: _ClassInfo | None, +) -> bool: + if isinstance(expression, ast.Name): + return expression.id in declarations + return ( + isinstance(expression, ast.Attribute) + and isinstance(expression.value, ast.Name) + and expression.value.id == "self" + and owner is not None + and expression.attr in owner.fields + ) + + +def _find_tree_violations(module: _ModuleInfo) -> list[Violation]: + violations: dict[tuple[int, int], Violation] = {} + for function in module.functions.values(): + declarations = _function_declarations(function) + owner = ( + module.classes.get(function.owner) + if function.owner is not None and _owns_instance_fields(function.node) + else None + ) + for expression in _tested_expressions(function.node): + if not _is_declared_reference(expression, declarations, owner): + continue + violation = Violation( + path=module.path, + line=expression.lineno, + column=expression.col_offset + 1, + expression=ast.unparse(expression), + ) + violations[(violation.line, violation.column)] = violation + return sorted(violations.values()) + + +def find_violations(paths: Iterable[Path]) -> list[Violation]: + files = sorted( + { + file + for path in paths + for file in (path.rglob("*.py") if path.is_dir() else [path]) + if file.suffix == ".py" + } + ) + violations: list[Violation] = [] + for path in files: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + violations.extend(_find_tree_violations(_collect_module_info(path, tree))) + return sorted(violations) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Reject truthiness checks on directly declared optional references." + ) + parser.add_argument("paths", nargs="+", type=Path) + args = parser.parse_args(argv) + violations = find_violations(args.paths) + for violation in violations: + print(violation.format()) + if violations: + print( + "Use an explicit `is None` or `is not None` check so falsy user objects are preserved.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Makefile b/Makefile index 247bbb977e..769556baaa 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ format-check: .PHONY: lint lint: uv run ruff check + uv run python .github/scripts/check_optional_truthiness.py src/agents .PHONY: mypy mypy: diff --git a/src/agents/_tool_invocation.py b/src/agents/_tool_invocation.py index eb36068741..f1be784b86 100644 --- a/src/agents/_tool_invocation.py +++ b/src/agents/_tool_invocation.py @@ -252,7 +252,11 @@ def tool_invocation_approval_scope( if invocation_role is not None: payload["invocation_role"] = invocation_role if invocation_type == "function_call": - resolved_lookup_key = tool_lookup_key or get_function_tool_lookup_key_for_call(mapping) + resolved_lookup_key = ( + tool_lookup_key + if tool_lookup_key is not None + else get_function_tool_lookup_key_for_call(mapping) + ) if resolved_lookup_key is None: return None payload["tool_lookup_key"] = _normalize_value(resolved_lookup_key) diff --git a/src/agents/agent.py b/src/agents/agent.py index c5989f32d0..d3e7cc81e9 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -870,7 +870,7 @@ def _nested_approvals_status( stream_handler = on_stream run_result_streaming = Runner.run_streamed( starting_agent=cast(Agent[Any], self), - input=resume_state or resolved_input, + input=resume_state if resume_state is not None else resolved_input, # On resume, pass the parent application context so # resolve_resumed_context can update the nested restored # wrapper's .context without dropping nested approvals. @@ -942,7 +942,7 @@ async def enqueue_stream_events() -> None: else: run_result = await Runner.run( starting_agent=cast(Agent[Any], self), - input=resume_state or resolved_input, + input=resume_state if resume_state is not None else resolved_input, # On resume, pass the parent application context so # resolve_resumed_context can update the nested restored # wrapper's .context without dropping nested approvals. diff --git a/src/agents/agent_tool_input.py b/src/agents/agent_tool_input.py index 992752e9c9..0de81ab92b 100644 --- a/src/agents/agent_tool_input.py +++ b/src/agents/agent_tool_input.py @@ -84,15 +84,15 @@ async def resolve_agent_tool_input( ) -> str | list[TResponseInputItem]: """Resolve structured tool input into a string or list of input items.""" should_build_structured_input = input_builder is not None or bool( - schema_info and (schema_info.summary or schema_info.json_schema) + schema_info is not None and (schema_info.summary or schema_info.json_schema) ) if should_build_structured_input: builder = input_builder if input_builder is not None else default_tool_input_builder result = builder( { "params": params, - "summary": schema_info.summary if schema_info else None, - "json_schema": schema_info.json_schema if schema_info else None, + "summary": schema_info.summary if schema_info is not None else None, + "json_schema": schema_info.json_schema if schema_info is not None else None, } ) if inspect.isawaitable(result): diff --git a/src/agents/extensions/experimental/codex/codex.py b/src/agents/extensions/experimental/codex/codex.py index 32e58cb6cd..3b43b367bc 100644 --- a/src/agents/extensions/experimental/codex/codex.py +++ b/src/agents/extensions/experimental/codex/codex.py @@ -58,7 +58,9 @@ def __init__( ) if has_kwargs: options = {key: value for key, value in kw_values.items() if value is not _UNSET} - resolved_options = coerce_codex_options(options) or CodexOptions() + resolved_options = coerce_codex_options(options) + if resolved_options is None: + resolved_options = CodexOptions() self._exec = CodexExec( executable_path=resolved_options.codex_path_override, env=_normalize_env(resolved_options), @@ -67,7 +69,9 @@ def __init__( self._options = resolved_options def start_thread(self, options: ThreadOptions | Mapping[str, Any] | None = None) -> Thread: - resolved_options = coerce_thread_options(options) or ThreadOptions() + resolved_options = coerce_thread_options(options) + if resolved_options is None: + resolved_options = ThreadOptions() return Thread( exec_client=self._exec, options=self._options, @@ -77,7 +81,9 @@ def start_thread(self, options: ThreadOptions | Mapping[str, Any] | None = None) def resume_thread( self, thread_id: str, options: ThreadOptions | Mapping[str, Any] | None = None ) -> Thread: - resolved_options = coerce_thread_options(options) or ThreadOptions() + resolved_options = coerce_thread_options(options) + if resolved_options is None: + resolved_options = ThreadOptions() return Thread( exec_client=self._exec, options=self._options, diff --git a/src/agents/extensions/experimental/codex/codex_tool.py b/src/agents/extensions/experimental/codex/codex_tool.py index 62c9eea334..f97aca0b16 100644 --- a/src/agents/extensions/experimental/codex/codex_tool.py +++ b/src/agents/extensions/experimental/codex/codex_tool.py @@ -585,7 +585,7 @@ def _resolve_codex_options( options: CodexOptions | Mapping[str, Any] | None, ) -> CodexOptions | None: options = coerce_codex_options(options) - if options and options.api_key: + if options is not None and options.api_key: return options api_key = _resolve_default_codex_api_key(options) @@ -605,10 +605,10 @@ def _resolve_codex_options( def _resolve_default_codex_api_key(options: CodexOptions | None) -> str | None: - if options and options.api_key: + if options is not None and options.api_key: return options.api_key - env_override = options.env if options else None + env_override = options.env if options is not None else None if env_override: env_codex = env_override.get("CODEX_API_KEY") if env_codex: @@ -656,12 +656,17 @@ def _resolve_thread_options( skip_git_repo_check: bool | None, ) -> ThreadOptions | None: defaults = coerce_thread_options(defaults) - if not defaults and not sandbox_mode and not working_directory and skip_git_repo_check is None: + if ( + defaults is None + and not sandbox_mode + and not working_directory + and skip_git_repo_check is None + ): return None return ThreadOptions( **{ - **(defaults.__dict__ if defaults else {}), + **(defaults.__dict__ if defaults is not None else {}), **({"sandbox_mode": sandbox_mode} if sandbox_mode else {}), **({"working_directory": working_directory} if working_directory else {}), **( diff --git a/src/agents/extensions/experimental/codex/thread.py b/src/agents/extensions/experimental/codex/thread.py index d6f8d69b63..a76c166b76 100644 --- a/src/agents/extensions/experimental/codex/thread.py +++ b/src/agents/extensions/experimental/codex/thread.py @@ -90,7 +90,7 @@ def id(self) -> str | None: async def run_streamed( self, input: Input, turn_options: TurnOptions | None = None ) -> StreamedTurn: - options = turn_options or TurnOptions() + options = turn_options if turn_options is not None else TurnOptions() return StreamedTurn(events=self._run_streamed_internal(input, options)) async def _run_streamed_internal( @@ -161,7 +161,7 @@ async def _run_streamed_internal( async def run(self, input: Input, turn_options: TurnOptions | None = None) -> Turn: # Aggregate events into a single Turn result (matching the TS SDK behavior). - options = turn_options or TurnOptions() + options = turn_options if turn_options is not None else TurnOptions() generator = self._run_streamed_internal(input, options) items: list[ThreadItem] = [] final_response = "" @@ -182,7 +182,7 @@ async def run(self, input: Input, turn_options: TurnOptions | None = None) -> Tu elif isinstance(event, ThreadErrorEvent): raise RuntimeError(f"Codex stream error: {event.message}") - if turn_failure: + if turn_failure is not None: raise RuntimeError(turn_failure.message) return Turn(items=items, final_response=final_response, usage=usage) diff --git a/src/agents/extensions/experimental/hosted_multi_agent/model.py b/src/agents/extensions/experimental/hosted_multi_agent/model.py index ba7360108c..320944c9b8 100644 --- a/src/agents/extensions/experimental/hosted_multi_agent/model.py +++ b/src/agents/extensions/experimental/hosted_multi_agent/model.py @@ -556,7 +556,7 @@ async def _close_active_response( self, active: _ActiveWebSocketResponse | None = None, ) -> None: - target = active or self._active_response + target = active if active is not None else self._active_response if target is None: return if self._active_response is target: diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index c67e3f8a6a..d9858eb134 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -81,7 +81,7 @@ def __init__( # branch pointer is established or a write begins. A mismatch means # another instance cleared the session, so the local pointer resets to main. self._generation = 0 - self._logger = logger or logging.getLogger(__name__) + self._logger = logger if logger is not None else logging.getLogger(__name__) def _commit_branch_pointer(self, branch_id: str, generation: int) -> bool: """Set the current-branch pointer unless a clear has committed meanwhile. diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index e11bdea618..33610bc676 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -419,7 +419,7 @@ async def _get_response_via_responses( input_tokens_details=response.usage.input_tokens_details, output_tokens_details=response.usage.output_tokens_details, ) - if response.usage + if response.usage is not None # The request completed, so it counts even when the provider omits usage. else Usage(requests=1) ) @@ -517,12 +517,12 @@ async def _stream_response_via_responses( if final_response is not None: span_response.span_data.usage = model_usage_to_span_usage( _response_usage_to_usage(final_response.usage) - if final_response.usage + if final_response.usage is not None else Usage( requests=_requests_for_response_without_usage(final_response) ) ) - if tracing.include_data() and final_response: + if tracing.include_data() and final_response is not None: span_response.span_data.response = final_response span_response.span_data.input = input if terminal_failure_error is not None: @@ -614,7 +614,7 @@ async def _get_response_via_chat( json.dumps(message.model_dump(), indent=2, ensure_ascii=False), ) else: - finish_reason = first_choice.finish_reason if first_choice else "-" + finish_reason = first_choice.finish_reason if first_choice is not None else "-" logger.debug("LLM resp had no message. finish_reason: %s", finish_reason) usage = ( @@ -626,7 +626,7 @@ async def _get_response_via_chat( input_tokens_details=response.usage.prompt_tokens_details, # type: ignore[arg-type] output_tokens_details=response.usage.completion_tokens_details, # type: ignore[arg-type] ) - if response.usage + if response.usage is not None # The request completed, so it counts even when the provider omits usage. else Usage(requests=1) ) @@ -671,7 +671,11 @@ async def _get_response_via_chat( ) logprob_models = None - if first_choice and first_choice.logprobs and first_choice.logprobs.content: + if ( + first_choice is not None + and first_choice.logprobs is not None + and first_choice.logprobs.content + ): logprob_models = ChatCmplHelpers.convert_logprobs_for_output_text( first_choice.logprobs.content ) @@ -787,7 +791,7 @@ def _populate_chat_generation_span( if tracing.include_data(): span_generation.span_data.output = [final_response.model_dump()] - if final_response.usage: + if final_response.usage is not None: span_generation.span_data.usage = { "requests": 1, "input_tokens": final_response.usage.input_tokens, @@ -795,12 +799,12 @@ def _populate_chat_generation_span( "total_tokens": final_response.usage.total_tokens, "input_tokens_details": ( final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details + if final_response.usage.input_tokens_details is not None else {"cached_tokens": 0, "cache_write_tokens": 0} ), "output_tokens_details": ( final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details + if final_response.usage.output_tokens_details is not None else {"reasoning_tokens": 0} ), } @@ -905,7 +909,9 @@ async def _fetch_chat_response( response_format, ) - reasoning_effort = model_settings.reasoning.effort if model_settings.reasoning else None + reasoning_effort = ( + model_settings.reasoning.effort if model_settings.reasoning is not None else None + ) if reasoning_effort is None and model_settings.extra_args: reasoning_effort = cast(Any, model_settings.extra_args.get("reasoning_effort")) diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 017224f286..35ad0e5879 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -179,7 +179,7 @@ def _get_reasoning_effort(self, model_settings: ModelSettings) -> Any | None: """ reasoning_effort: Any | None = None - if model_settings.reasoning: + if model_settings.reasoning is not None: reasoning_effort = model_settings.reasoning.effort if model_settings.reasoning.summary is not None: logger.warning( @@ -267,7 +267,7 @@ async def get_response( json.dumps(message.model_dump(), indent=2, ensure_ascii=False), ) else: - finish_reason = first_choice.finish_reason if first_choice else "-" + finish_reason = first_choice.finish_reason if first_choice is not None else "-" logger.debug("LLM resp had no message. finish_reason: %s", finish_reason) if hasattr(response, "usage"): @@ -294,7 +294,7 @@ async def get_response( or 0 ), ) - if response.usage + if response_usage is not None # The request completed, so it counts even when the provider omits usage. else Usage(requests=1) ) @@ -353,7 +353,9 @@ async def get_response( # LiteLLM's Choices omits the logprobs attribute entirely when it was not requested, # so access it defensively (mirrors the finish_reason handling above). logprob_models = None - choice_logprobs = getattr(first_choice, "logprobs", None) if first_choice else None + choice_logprobs = ( + getattr(first_choice, "logprobs", None) if first_choice is not None else None + ) if choice_logprobs is not None and getattr(choice_logprobs, "content", None): logprob_models = ChatCmplHelpers.convert_logprobs_for_output_text( choice_logprobs.content @@ -466,7 +468,7 @@ def _populate_stream_generation_span( if tracing.include_data(): span_generation.span_data.output = [final_response.model_dump()] - if final_response.usage: + if final_response.usage is not None: span_generation.span_data.usage = { "requests": 1, "input_tokens": final_response.usage.input_tokens, @@ -474,12 +476,12 @@ def _populate_stream_generation_span( "total_tokens": final_response.usage.total_tokens, "input_tokens_details": ( final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details + if final_response.usage.input_tokens_details is not None else {"cached_tokens": 0, "cache_write_tokens": 0} ), "output_tokens_details": ( final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details + if final_response.usage.output_tokens_details is not None else {"reasoning_tokens": 0} ), } diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index 97145e4563..0e08ca26e2 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -1052,7 +1052,9 @@ def __init__( ) -> None: # Validate that the Blaxel SDK is importable. _import_blaxel_sdk() - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies self._token = token or os.environ.get("BL_API_KEY") diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index d0a5f83d87..dd70bb4624 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -1435,7 +1435,9 @@ def __init__( request_timeout_s: float = _DEFAULT_REQUEST_TIMEOUT_S, ) -> None: super().__init__() - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies self._exec_timeout_s = exec_timeout_s self._request_timeout_s = request_timeout_s diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py index 388685c61e..0282f096a3 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -1195,7 +1195,9 @@ def __init__( AsyncDaytona, DaytonaConfig, _, _ = _import_daytona_sdk() config = DaytonaConfig(api_key=api_key, api_url=api_url) if (api_key or api_url) else None self._daytona = AsyncDaytona(config) - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies async def _build_create_params( diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py index 036f136657..3e866016de 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -1680,7 +1680,9 @@ def __init__( instrumentation: Instrumentation | None = None, dependencies: Dependencies | None = None, ) -> None: - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies async def create( @@ -1692,7 +1694,7 @@ async def create( ) -> SandboxSession: if options is None: raise ValueError("E2BSandboxClient.create requires options") - manifest = manifest or Manifest() + manifest = manifest if manifest is not None else Manifest() sandbox_type = _coerce_sandbox_type(options.sandbox_type) diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index d3a3665885..9705bc6d7e 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -679,7 +679,7 @@ async def _ensure_sandbox(self) -> bool: create_if_missing=True, call_timeout=10.0, ) - if not self._image: + if self._image is None: image_id = self.state.image_id if image_id: self._image = modal.Image.from_id(image_id) @@ -1947,7 +1947,9 @@ def __init__( ) -> None: self._default_image = image self._default_sandbox = sandbox - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies def _validate_manifest_for_workspace_persistence( @@ -2002,7 +2004,7 @@ async def create( if options is None: raise ValueError("ModalSandboxClient.create requires options with app_name") - manifest = manifest or Manifest() + manifest = manifest if manifest is not None else Manifest() app_name = options.app_name if not app_name: raise ValueError("ModalSandboxClient.create requires a valid app_name") diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py index 53a8bea05e..cde7315096 100644 --- a/src/agents/extensions/sandbox/runloop/sandbox.py +++ b/src/agents/extensions/sandbox/runloop/sandbox.py @@ -1545,7 +1545,9 @@ def __init__( ) -> None: self._sdk = _import_runloop_sdk().async_sdk(bearer_token=bearer_token, base_url=base_url) self._platform = RunloopPlatformClient(self._sdk) - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies @property @@ -1567,7 +1569,7 @@ async def create( configured blueprint selection or user profile when provisioning the devbox. The returned session follows the shared sandbox lifecycle and must be started before direct operations. """ - resolved_options = options or RunloopSandboxClientOptions() + resolved_options = options if options is not None else RunloopSandboxClientOptions() if ( resolved_options.blueprint_id is not None and resolved_options.blueprint_name is not None @@ -1577,7 +1579,11 @@ async def create( ) user_parameters = _normalize_runloop_user_parameters(resolved_options.user_parameters) - manifest = manifest or Manifest(root=_default_runloop_manifest_root(user_parameters)) + manifest = ( + manifest + if manifest is not None + else Manifest(root=_default_runloop_manifest_root(user_parameters)) + ) _validate_runloop_manifest_root(manifest, user_parameters=user_parameters) timeouts_in = resolved_options.timeouts diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 4da5fb4164..f01969d836 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -1326,7 +1326,9 @@ def __init__( self._token = token self._project_id = project_id self._team_id = team_id - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies def _wrap_session( diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index e94b0b3ea6..71e6d3dfa6 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -67,7 +67,7 @@ def get_all_nodes( parts = [] # Start and end the graph - if not parent: + if parent is None: parts.append( '"__start__" [label="__start__", shape=ellipse, style=filled, ' "fillcolor=lightblue, width=0.5, height=0.3];" @@ -142,7 +142,7 @@ def get_all_edges( agent_name = _escape_label(agent.name) - if not parent: + if parent is None: parts.append(f'"__start__" -> "{agent_name}";') for tool in agent.tools: diff --git a/src/agents/items.py b/src/agents/items.py index f68a88741b..7e298ae0b7 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -928,7 +928,9 @@ def _convert_tool_output_as_structured( # An empty list/tuple has no structured items; ``all([])`` is ``True``, # so guard against it to avoid emitting an empty structured-output list # (which would drop the tool result) and stringify instead. - if maybe_converted_output_list and all(maybe_converted_output_list): + if maybe_converted_output_list and all( + item is not None for item in maybe_converted_output_list + ): return [ cls._convert_single_tool_output_pydantic_model(item) for item in maybe_converted_output_list @@ -937,7 +939,7 @@ def _convert_tool_output_as_structured( return None maybe_converted_output = cls._maybe_get_output_as_structured_function_output(output) - if maybe_converted_output: + if maybe_converted_output is not None: return [cls._convert_single_tool_output_pydantic_model(maybe_converted_output)] return None diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 8c675b6c2f..e05f05c916 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -806,7 +806,7 @@ async def invoke_mcp_tool( context._custom_data = custom_data current_span = get_current_span() - if current_span: + if current_span is not None: if isinstance(current_span.span_data, FunctionSpanData): if not isinstance(context, ToolContext) or ( context.run_config is None or context.run_config.trace_include_sensitive_data diff --git a/src/agents/memory/openai_conversations_session.py b/src/agents/memory/openai_conversations_session.py index 186c004e5d..2718e7e0aa 100644 --- a/src/agents/memory/openai_conversations_session.py +++ b/src/agents/memory/openai_conversations_session.py @@ -15,7 +15,8 @@ async def start_openai_conversations_session(openai_client: AsyncOpenAI | None = None) -> str: _maybe_openai_client = openai_client if openai_client is None: - _maybe_openai_client = get_default_openai_client() or AsyncOpenAI() + default_client = get_default_openai_client() + _maybe_openai_client = default_client if default_client is not None else AsyncOpenAI() # this never be None here _openai_client: AsyncOpenAI = _maybe_openai_client # type: ignore [assignment] @@ -42,7 +43,8 @@ def __init__( ) _openai_client = openai_client if _openai_client is None: - _openai_client = get_default_openai_client() or AsyncOpenAI() + default_client = get_default_openai_client() + _openai_client = default_client if default_client is not None else AsyncOpenAI() # this never be None here self._openai_client: AsyncOpenAI = _openai_client diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index e59090a263..a8dff17e33 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -140,7 +140,8 @@ def __init__( @property def client(self) -> AsyncOpenAI: if self._client is None: - self._client = get_default_openai_client() or AsyncOpenAI() + default_client = get_default_openai_client() + self._client = default_client if default_client is not None else AsyncOpenAI() return self._client def _resolve_compaction_mode_for_response( diff --git a/src/agents/models/_response_terminal.py b/src/agents/models/_response_terminal.py index 57f11cfe16..752242a71c 100644 --- a/src/agents/models/_response_terminal.py +++ b/src/agents/models/_response_terminal.py @@ -20,10 +20,10 @@ def format_response_terminal_failure( if status: details.append(f"status={status}") error = getattr(response, "error", None) - if error: + if error is not None: details.append(f"error={error}") incomplete_details = getattr(response, "incomplete_details", None) - if incomplete_details: + if incomplete_details is not None: details.append(f"incomplete_details={incomplete_details}") if details: diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 227bea4504..bd6832c9a4 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -106,7 +106,7 @@ def convert_tool_choice( def convert_response_format( cls, final_output_schema: AgentOutputSchemaBase | None ) -> ResponseFormat | Omit: - if not final_output_schema or final_output_schema.is_plain_text(): + if final_output_schema is None or final_output_schema.is_plain_text(): return omit return { diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 077b0e377f..11f5b72fc0 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -863,12 +863,12 @@ async def handle_stream( ) delta_logprobs = ( ChatCmplHelpers.convert_logprobs_for_text_delta( - choice_logprobs.content if choice_logprobs else None + choice_logprobs.content if choice_logprobs is not None else None ) or [] ) output_logprobs = ChatCmplHelpers.convert_logprobs_for_output_text( - choice_logprobs.content if choice_logprobs else None + choice_logprobs.content if choice_logprobs is not None else None ) # Emit the delta for this segment of content yield ResponseTextDeltaEvent( diff --git a/src/agents/models/openai_agent_registration.py b/src/agents/models/openai_agent_registration.py index e0578739bc..7b76c25934 100644 --- a/src/agents/models/openai_agent_registration.py +++ b/src/agents/models/openai_agent_registration.py @@ -43,8 +43,8 @@ def resolve_openai_agent_registration_config( config = _coerce_openai_agent_registration_config(config) default = get_default_openai_agent_registration_config() harness_id = _resolve_str( - explicit=config.harness_id if config else None, - default=default.harness_id if default else None, + explicit=config.harness_id if config is not None else None, + default=default.harness_id if default is not None else None, env_name=_ENV_HARNESS_ID, ) if harness_id is None: diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index e3bfc3bebf..229cd65513 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -282,7 +282,7 @@ async def get_response( json.dumps(message.model_dump(), indent=2, ensure_ascii=False), ) else: - finish_reason = first_choice.finish_reason if first_choice else "-" + finish_reason = first_choice.finish_reason if first_choice is not None else "-" logger.debug("LLM resp had no message. finish_reason: %s", finish_reason) usage = ( @@ -295,7 +295,7 @@ async def get_response( input_tokens_details=response.usage.prompt_tokens_details, # type: ignore[arg-type] output_tokens_details=response.usage.completion_tokens_details, # type: ignore[arg-type] ) - if response.usage + if response.usage is not None # The request completed, so it counts even when the provider omits usage. else Usage(requests=1) ) @@ -342,7 +342,11 @@ async def get_response( ) logprob_models = None - if first_choice and first_choice.logprobs and first_choice.logprobs.content: + if ( + first_choice is not None + and first_choice.logprobs is not None + and first_choice.logprobs.content + ): logprob_models = ChatCmplHelpers.convert_logprobs_for_output_text( first_choice.logprobs.content ) @@ -506,7 +510,7 @@ def _populate_stream_generation_span( if tracing.include_data(): span_generation.span_data.output = [final_response.model_dump()] - if final_response.usage: + if final_response.usage is not None: span_generation.span_data.usage = { "requests": 1, "input_tokens": final_response.usage.input_tokens, @@ -514,12 +518,12 @@ def _populate_stream_generation_span( "total_tokens": final_response.usage.total_tokens, "input_tokens_details": ( final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details + if final_response.usage.input_tokens_details is not None else {"cached_tokens": 0, "cache_write_tokens": 0} ), "output_tokens_details": ( final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details + if final_response.usage.output_tokens_details is not None else {"reasoning_tokens": 0} ), } @@ -666,7 +670,9 @@ async def _fetch_response( response_format, ) - reasoning_effort = model_settings.reasoning.effort if model_settings.reasoning else None + reasoning_effort = ( + model_settings.reasoning.effort if model_settings.reasoning is not None else None + ) store = ChatCmplHelpers.get_store_param(self._get_client(), model_settings) stream_options = ChatCmplHelpers.get_stream_options_param( diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index cc88d14ef1..703be44ee2 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -134,15 +134,20 @@ def agent_registration(self) -> ResolvedOpenAIAgentRegistrationConfig | None: # AsyncOpenAI() raises an error if you don't have an API key set. def _get_client(self) -> AsyncOpenAI: if self._client is None: - self._client = _openai_shared.get_default_openai_client() or AsyncOpenAI( - api_key=self._stored_api_key or _openai_shared.get_default_openai_key(), - base_url=self._stored_base_url or os.getenv("OPENAI_BASE_URL"), - websocket_base_url=( - self._stored_websocket_base_url or os.getenv("OPENAI_WEBSOCKET_BASE_URL") - ), - organization=self._stored_organization, - project=self._stored_project, - http_client=shared_http_client(), + default_client = _openai_shared.get_default_openai_client() + self._client = ( + default_client + if default_client is not None + else AsyncOpenAI( + api_key=self._stored_api_key or _openai_shared.get_default_openai_key(), + base_url=self._stored_base_url or os.getenv("OPENAI_BASE_URL"), + websocket_base_url=( + self._stored_websocket_base_url or os.getenv("OPENAI_WEBSOCKET_BASE_URL") + ), + organization=self._stored_organization, + project=self._stored_project, + http_client=shared_http_client(), + ) ) return self._client diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 95b3e2f426..7a4bc29442 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -527,8 +527,12 @@ async def get_response( ), ) - usage = _response_usage_to_usage(response.usage) if response.usage else Usage() - if response.usage: + usage = ( + _response_usage_to_usage(response.usage) + if response.usage is not None + else Usage() + ) + if response.usage is not None: span_response.span_data.usage = model_usage_to_span_usage(usage) if tracing.include_data(): @@ -661,10 +665,10 @@ async def stream_response( if terminal_failure_error is not None: raise terminal_failure_error - if final_response and tracing.include_data(): + if final_response is not None and tracing.include_data(): span_response.span_data.response = final_response span_response.span_data.input = input - if final_response and final_response.usage: + if final_response is not None and final_response.usage is not None: span_response.span_data.usage = model_usage_to_span_usage( _response_usage_to_usage(final_response.usage) ) diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index b0af25ad40..43877e9d59 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -251,7 +251,11 @@ def response_control(self) -> Literal["free", "create_requested", "cancel_reques @property def pending_response_create_event_id(self) -> str | None: - return self._pending_response_create.event_id if self._pending_response_create else None + return ( + self._pending_response_create.event_id + if self._pending_response_create is not None + else None + ) def _next_pending_request_version(self) -> int | None: return min(self._pending_request_versions) if self._pending_request_versions else None @@ -981,7 +985,7 @@ async def _send_tool_output(self, event: RealtimeModelSendToolOutput) -> None: self._start_response_create(request_version) def _get_playback_state(self) -> RealtimePlaybackState: - if self._playback_tracker: + if self._playback_tracker is not None: return self._playback_tracker.get_state() if last_audio_item_id := self._audio_state_tracker.get_last_audio_item(): @@ -1112,7 +1116,7 @@ async def _interrupt_audio_playback( self._audio_state_tracker.on_response_interrupted(event.response_id) else: self._audio_state_tracker.on_interrupted() - if self._playback_tracker: + if self._playback_tracker is not None: latest_playback_state = self._playback_tracker.get_state() latest_item_id = latest_playback_state.get("current_item_id") latest_content_index = latest_playback_state.get("current_item_content_index") or 0 @@ -1141,7 +1145,7 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: if not event.playback_only: session = self._created_session automatic_response_cancellation_enabled = ( - session + session is not None and session.audio is not None and session.audio.input is not None and session.audio.input.turn_detection is not None @@ -1438,14 +1442,14 @@ async def _handle_ws_event(self, event: dict[str, Any]): # Reset trackers so subsequent playback state queries don't # reference audio that has been interrupted client‑side. self._audio_state_tracker.on_interrupted() - if self._playback_tracker: + if self._playback_tracker is not None: self._playback_tracker.on_interrupted() # If server isn't configured to auto‑interrupt/cancel, cancel the # response to prevent further audio. session = self._created_session automatic_response_cancellation_enabled = ( - session + session is not None and session.audio is not None and session.audio.input is not None and session.audio.input.turn_detection is not None @@ -1551,7 +1555,7 @@ def _update_created_session( ) -> None: # Only store/playback-format information for realtime sessions (not transcription-only) normalized_session = self._normalize_session_payload(session) - if not normalized_session: + if normalized_session is None: return self._created_session = normalized_session @@ -1560,7 +1564,7 @@ def _update_created_session( return self._audio_state_tracker.set_audio_format(normalized_format) - if self._playback_tracker: + if self._playback_tracker is not None: self._playback_tracker.set_audio_format(normalized_format) @staticmethod @@ -1604,7 +1608,7 @@ def _is_transcription_session(payload: Mapping[str, object]) -> bool: @staticmethod def _extract_audio_format(session: OpenAISessionCreateRequest) -> str | None: audio = session.audio - if not audio or not audio.output or not audio.output.format: + if audio is None or audio.output is None or audio.output.format is None: return None return OpenAIRealtimeWebSocketModel._normalize_audio_format(audio.output.format) @@ -1866,8 +1870,12 @@ async def build_initial_session_payload( This helper can be used to accept SIP-originated calls by forwarding the returned payload to the Realtime Calls API without duplicating session setup logic. """ - run_config_settings = (run_config or {}).get("model_settings") or {} - initial_model_settings = (model_config or {}).get("initial_model_settings") or {} + run_config_settings: RealtimeSessionModelSettings = ( + run_config.get("model_settings") if run_config is not None else None + ) or {} + initial_model_settings: RealtimeSessionModelSettings = ( + model_config.get("initial_model_settings") if model_config is not None else None + ) or {} base_settings: RealtimeSessionModelSettings = { **run_config_settings, **initial_model_settings, diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 91a1aa2917..c610c04394 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -1062,7 +1062,7 @@ async def _handle_tool_call( """Handle a tool call event.""" mark_completed = False agent = dispatch_snapshot.agent if dispatch_snapshot is not None else agent_snapshot - agent = agent or self._current_agent + agent = agent if agent is not None else self._current_agent recorded_route = self._tool_invocation_routes.get(event.call_id) recorded_role = recorded_route[1] if recorded_route is not None else None if ( @@ -1636,7 +1636,7 @@ async def _run_output_guardrails( if self._closing or self._closed: return False - source_agent = agent_snapshot or self._current_agent + source_agent = agent_snapshot if agent_snapshot is not None else self._current_agent combined_guardrails = source_agent.output_guardrails + self._run_config.get( "output_guardrails", [] ) @@ -1832,7 +1832,7 @@ def _on_guardrail_task_done(self, task: asyncio.Task[Any], *, response_id: str) # Check for exceptions and propagate as events if not task.cancelled(): exception = task.exception() - if exception: + if exception is not None: # Create an exception event instead of raising self._put_event_nowait( RealtimeError( diff --git a/src/agents/result.py b/src/agents/result.py index bb6f4ef4a9..0ca4456a31 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -157,7 +157,7 @@ def _populate_state_from_result( trace_state = getattr(result, "_trace_state", None) if trace_state is None: trace_state = TraceState.from_trace(getattr(result, "trace", None)) - state._trace_state = copy.deepcopy(trace_state) if trace_state else None + state._trace_state = copy.deepcopy(trace_state) if trace_state is not None else None sandbox_resume_state = getattr(result, "_sandbox_resume_state", None) if isinstance(sandbox_resume_state, dict): state._sandbox = copy.deepcopy(sandbox_resume_state) @@ -865,7 +865,7 @@ def register_current_consumer() -> None: self._stored_exception is not None and _should_drain_stream_events_before_raising(self._stored_exception) ) - if self._stored_exception and ( + if self._stored_exception is not None and ( not should_drain_queued_events or self._event_queue.empty() ): logger.debug("Breaking due to stored exception") @@ -935,7 +935,7 @@ def register_current_consumer() -> None: self._drain_input_guardrail_queue() stored_exception = self._stored_exception - if stored_exception: + if stored_exception is not None: if _is_error_data_redacted(stored_exception): _detach_data_redacted_error_traceback(stored_exception) # The streaming result retains caller-visible run data. Drop the local reference @@ -988,7 +988,7 @@ def _check_errors(self): if self.run_loop_task and self.run_loop_task.done(): if not self.run_loop_task.cancelled(): run_impl_exc = self.run_loop_task.exception() - if run_impl_exc and isinstance(run_impl_exc, Exception): + if isinstance(run_impl_exc, Exception): if ( isinstance(run_impl_exc, AgentsException) and run_impl_exc.run_data is None @@ -1000,7 +1000,7 @@ def _check_errors(self): if self._input_guardrails_task and self._input_guardrails_task.done(): if not self._input_guardrails_task.cancelled(): in_guard_exc = self._input_guardrails_task.exception() - if in_guard_exc and isinstance(in_guard_exc, Exception): + if isinstance(in_guard_exc, Exception): if ( isinstance(in_guard_exc, AgentsException) and in_guard_exc.run_data is None @@ -1012,7 +1012,7 @@ def _check_errors(self): if self._output_guardrails_task and self._output_guardrails_task.done(): if not self._output_guardrails_task.cancelled(): out_guard_exc = self._output_guardrails_task.exception() - if out_guard_exc and isinstance(out_guard_exc, Exception): + if isinstance(out_guard_exc, Exception): if ( isinstance(out_guard_exc, AgentsException) and out_guard_exc.run_data is None diff --git a/src/agents/retry.py b/src/agents/retry.py index a3e9ba7b5b..ee3d0d7605 100644 --- a/src/agents/retry.py +++ b/src/agents/retry.py @@ -151,11 +151,11 @@ def _mark_retry_capabilities( def retry_policy_retries_safe_transport_errors(policy: RetryPolicy | None) -> bool: - return bool(policy and getattr(policy, _RETRIES_SAFE_TRANSPORT_ERRORS_ATTR, False)) + return bool(policy is not None and getattr(policy, _RETRIES_SAFE_TRANSPORT_ERRORS_ATTR, False)) def retry_policy_retries_all_transient_errors(policy: RetryPolicy | None) -> bool: - return bool(policy and getattr(policy, _RETRIES_ALL_TRANSIENT_ERRORS_ATTR, False)) + return bool(policy is not None and getattr(policy, _RETRIES_ALL_TRANSIENT_ERRORS_ATTR, False)) @pydantic_dataclass @@ -345,7 +345,11 @@ async def policy(context: RetryPolicyContext) -> bool | RetryDecision: continue last_negative = decision - return first_positive or last_negative or RetryDecision(retry=False) + if first_positive is not None: + return first_positive + if last_negative is not None: + return last_negative + return RetryDecision(retry=False) return _mark_retry_capabilities( policy, diff --git a/src/agents/run.py b/src/agents/run.py index 6831334bf2..ca39410856 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -169,7 +169,7 @@ def set_default_agent_runner(runner: AgentRunner | None) -> None: It should not be used directly. """ global DEFAULT_AGENT_RUNNER - DEFAULT_AGENT_RUNNER = runner or AgentRunner() + DEFAULT_AGENT_RUNNER = runner if runner is not None else AgentRunner() def get_default_agent_runner() -> AgentRunner: @@ -721,7 +721,7 @@ async def run( current_task_span: Span[TaskSpanData] | None = ( task_span(name=trace_workflow_name) if use_task_and_turn_spans else None ) - if current_task_span: + if current_task_span is not None: current_task_span.start(mark_as_current=True) task_usage_start = snapshot_usage(context_wrapper.usage) @@ -843,7 +843,7 @@ def _finalize_result(result: RunResult) -> RunResult: ) session_input_items_for_persistence = [] except BaseException: - if current_task_span: + if current_task_span is not None: attach_usage_to_span( current_task_span, usage_delta(task_usage_start, context_wrapper.usage), @@ -1155,7 +1155,7 @@ def _finalize_result(result: RunResult) -> RunResult: ) if current_span is None: - if output_schema := get_output_schema(execution_agent): + if (output_schema := get_output_schema(execution_agent)) is not None: output_type_name = output_schema.name() else: output_type_name = "str" @@ -1294,7 +1294,7 @@ def _finalize_result(result: RunResult) -> RunResult: if use_task_and_turn_spans else None ) - if current_turn_span: + if current_turn_span is not None: current_turn_span.start(mark_as_current=True) try: if current_turn <= 1: @@ -1417,7 +1417,7 @@ def _finalize_result(result: RunResult) -> RunResult: agent_span=current_span, ) finally: - if current_turn_span: + if current_turn_span is not None: attach_usage_to_span( current_turn_span, usage_delta(turn_usage_start, context_wrapper.usage), @@ -1481,7 +1481,7 @@ def _finalize_result(result: RunResult) -> RunResult: call_id in output_call_ids and item not in items_to_save_turn and not ( - run_state + run_state is not None and run_state._current_turn_persisted_item_count > 0 ) ): @@ -1759,9 +1759,9 @@ def _finalize_result(result: RunResult) -> RunResult: await dispose_resolved_computers(run_context=context_wrapper) except Exception as error: log_tool_action_warning(logger, "Failed to dispose computers after run", error) - if current_span: + if current_span is not None: current_span.finish(reset_current=True) - if current_task_span: + if current_task_span is not None: attach_usage_to_span( current_task_span, usage_delta(task_usage_start, context_wrapper.usage), @@ -1987,7 +1987,7 @@ def run_streamed( reattach_resumed_trace=is_resumed_state, ) if run_state is not None: - run_state.set_trace(new_trace or get_current_trace()) + run_state.set_trace(new_trace if new_trace is not None else get_current_trace()) sandbox_runtime = SandboxRuntime( starting_agent=starting_agent, @@ -2001,7 +2001,9 @@ def run_streamed( ) schema_agent = ( - run_state._current_agent if run_state and run_state._current_agent else starting_agent + run_state._current_agent + if run_state is not None and run_state._current_agent is not None + else starting_agent ) sandbox_runtime.assert_agent_supported(schema_agent) output_schema = get_output_schema(schema_agent) @@ -2017,22 +2019,28 @@ def run_streamed( # primeFromState will mark items as sent so prepareInput skips them. # Copy it: the streamed loop appends to new_items, and the caller still # owns the state as a resumable snapshot. - new_items=list(run_state._session_items) if run_state else [], + new_items=list(run_state._session_items) if run_state is not None else [], current_agent=schema_agent, - raw_responses=run_state._model_responses if run_state else [], + raw_responses=run_state._model_responses if run_state is not None else [], final_output=None, is_complete=False, - current_turn=run_state._current_turn if run_state else 0, + current_turn=run_state._current_turn if run_state is not None else 0, max_turns=max_turns, - input_guardrail_results=(list(run_state._input_guardrail_results) if run_state else []), + input_guardrail_results=( + list(run_state._input_guardrail_results) if run_state is not None else [] + ), output_guardrail_results=( - list(run_state._output_guardrail_results) if run_state else [] + list(run_state._output_guardrail_results) if run_state is not None else [] ), tool_input_guardrail_results=( - list(getattr(run_state, "_tool_input_guardrail_results", [])) if run_state else [] + list(getattr(run_state, "_tool_input_guardrail_results", [])) + if run_state is not None + else [] ), tool_output_guardrail_results=( - list(getattr(run_state, "_tool_output_guardrail_results", [])) if run_state else [] + list(getattr(run_state, "_tool_output_guardrail_results", [])) + if run_state is not None + else [] ), _current_agent_output_schema=output_schema, trace=new_trace, @@ -2042,13 +2050,13 @@ def run_streamed( # If a cross-SDK state omits the counter, fall back to len(generated_items) # to avoid duplication. _current_turn_persisted_item_count=( - run_state._current_turn_persisted_item_count if run_state else 0 + run_state._current_turn_persisted_item_count if run_state is not None else 0 ), # When resuming from RunState, preserve the original input from the state # This ensures originalInput in serialized state reflects the first turn's input _original_input=( copy_input_items(run_state._original_input) - if run_state and run_state._original_input is not None + if run_state is not None and run_state._original_input is not None else copy_input_items(streamed_input) ), ) diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 962771fe01..136e327030 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -805,15 +805,19 @@ def get_rejection_message( pending_namespace = ( self._resolve_tool_namespace(existing_pending) if existing_pending is not None else None ) - pending_key = self._resolve_approval_key(existing_pending) if existing_pending else None - pending_tool_name = self._resolve_tool_name(existing_pending) if existing_pending else None + pending_key = ( + self._resolve_approval_key(existing_pending) if existing_pending is not None else None + ) + pending_tool_name = ( + self._resolve_tool_name(existing_pending) if existing_pending is not None else None + ) pending_keys = ( list(self._resolve_approval_keys(existing_pending)) if existing_pending is not None else [] ) - if existing_pending and pending_key is not None: + if existing_pending is not None and pending_key is not None: candidates.append(pending_key) explicit_keys = ( list( @@ -840,7 +844,7 @@ def get_rejection_message( and tool_name not in candidates ): candidates.append(tool_name) - if existing_pending: + if existing_pending is not None: for pending_candidate in pending_keys: if pending_candidate not in candidates: candidates.append(pending_candidate) @@ -1063,7 +1067,9 @@ def get_approval_status( hosted_status, _ = self._resolve_hosted_mcp_approval_decision(existing_pending) if hosted_status is None: return None - effective_invocation = current_invocation or existing_pending + effective_invocation = ( + current_invocation if current_invocation is not None else existing_pending + ) binding_status = self._approved_tool_invocation_status( effective_invocation.raw_item, tool_lookup_key=effective_invocation.tool_lookup_key, @@ -1078,15 +1084,19 @@ def get_approval_status( pending_namespace = ( self._resolve_tool_namespace(existing_pending) if existing_pending is not None else None ) - pending_key = self._resolve_approval_key(existing_pending) if existing_pending else None - pending_tool_name = self._resolve_tool_name(existing_pending) if existing_pending else None + pending_key = ( + self._resolve_approval_key(existing_pending) if existing_pending is not None else None + ) + pending_tool_name = ( + self._resolve_tool_name(existing_pending) if existing_pending is not None else None + ) pending_keys = ( list(self._resolve_approval_keys(existing_pending)) if existing_pending is not None else [] ) - if existing_pending and pending_key is not None: + if existing_pending is not None and pending_key is not None: candidates.append(pending_key) explicit_keys = ( list( @@ -1113,7 +1123,7 @@ def get_approval_status( and tool_name not in candidates ): candidates.append(tool_name) - if existing_pending: + if existing_pending is not None: for pending_candidate in pending_keys: if pending_candidate not in candidates: candidates.append(pending_candidate) @@ -1131,7 +1141,9 @@ def get_approval_status( if status is not None: matched_record = self._approvals.get(candidate) break - selected_invocation = current_invocation or existing_pending + selected_invocation = ( + current_invocation if current_invocation is not None else existing_pending + ) if status is None or matched_record is None or selected_invocation is None: return status is_sticky = isinstance(matched_record.approved, bool) or isinstance( diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 2803c4695e..a8b65e57d6 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -260,7 +260,7 @@ def resolve_trace_settings( metadata: dict[str, Any] | None = run_config.trace_metadata tracing: TracingConfig | None = run_config.tracing - if trace_state: + if trace_state is not None: if workflow_name == default_workflow_name and trace_state.workflow_name: workflow_name = trace_state.workflow_name if trace_id is None: @@ -367,7 +367,9 @@ def build_resumed_stream_debug_extra( """Build the logger extra payload when resuming a streamed run.""" return { "current_turn": run_state._current_turn, - "current_agent": run_state._current_agent.name if run_state._current_agent else None, + "current_agent": ( + run_state._current_agent.name if run_state._current_agent is not None else None + ), "generated_items_count": len(run_state._generated_items), "generated_items_types": [item.type for item in run_state._generated_items], "generated_items_details": build_generated_items_details( diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index 4e37139329..0b1d8b57a0 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -284,7 +284,8 @@ async def _evaluate_retry( or (provider_advice is not None and provider_advice.replay_safety == "unsafe") ): return RetryDecision( - retry=False, reason=provider_advice.reason if provider_advice else None + retry=False, + reason=provider_advice.reason if provider_advice is not None else None, ) if retry_policy is None: @@ -310,7 +311,8 @@ async def _evaluate_retry( if replay_unsafe_request and not decision._approves_replay and not provider_marks_replay_safe: return RetryDecision( retry=False, - reason=decision.reason or (provider_advice.reason if provider_advice else None), + reason=decision.reason + or (provider_advice.reason if provider_advice is not None else None), ) return RetryDecision( @@ -324,7 +326,7 @@ async def _evaluate_retry( else _default_retry_delay(attempt, retry_backoff) ) ), - reason=decision.reason or (provider_advice.reason if provider_advice else None), + reason=decision.reason or (provider_advice.reason if provider_advice is not None else None), ) @@ -485,9 +487,11 @@ async def get_response_with_retry( decision = await _evaluate_retry( error=error, attempt=policy_attempt, - max_retries=max(retry_settings.max_retries or 0, 0) if retry_settings else 0, - retry_policy=retry_settings.policy if retry_settings else None, - retry_backoff=retry_settings.backoff if retry_settings else None, + max_retries=( + max(retry_settings.max_retries or 0, 0) if retry_settings is not None else 0 + ), + retry_policy=retry_settings.policy if retry_settings is not None else None, + retry_backoff=retry_settings.backoff if retry_settings is not None else None, stream=False, replay_unsafe_request=stateful_request or replay_unsafe_request, emitted_retry_unsafe_event=False, @@ -501,7 +505,7 @@ async def get_response_with_retry( decision.delay, policy_attempt, retry_settings.max_retries - if retry_settings and retry_settings.max_retries is not None + if retry_settings is not None and retry_settings.max_retries is not None else 0, ) await rewind() @@ -608,9 +612,11 @@ async def stream_response_with_retry( decision = await _evaluate_retry( error=error, attempt=policy_attempt, - max_retries=max(retry_settings.max_retries or 0, 0) if retry_settings else 0, - retry_policy=retry_settings.policy if retry_settings else None, - retry_backoff=retry_settings.backoff if retry_settings else None, + max_retries=( + max(retry_settings.max_retries or 0, 0) if retry_settings is not None else 0 + ), + retry_policy=retry_settings.policy if retry_settings is not None else None, + retry_backoff=retry_settings.backoff if retry_settings is not None else None, stream=True, replay_unsafe_request=stateful_request or replay_unsafe_request, emitted_retry_unsafe_event=emitted_retry_unsafe_event, @@ -624,7 +630,7 @@ async def stream_response_with_retry( decision.delay, policy_attempt, retry_settings.max_retries - if retry_settings and retry_settings.max_retries is not None + if retry_settings is not None and retry_settings.max_retries is not None else 0, ) await rewind() diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 998075c7e9..ca2bfaddba 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -612,10 +612,11 @@ async def start_streaming( sandbox_runtime: SandboxRuntime[TContext] | None = None, ): """Run the streaming loop for a run result.""" - if streamed_result.trace: + if streamed_result.trace is not None: streamed_result.trace.start(mark_as_current=True) if run_state is not None: - run_state.set_trace(get_current_trace() or streamed_result.trace) + current_trace = get_current_trace() + run_state.set_trace(current_trace if current_trace is not None else streamed_result.trace) streamed_result._trace_state = run_state._trace_state if is_resumed_state and run_state is not None: @@ -634,7 +635,7 @@ async def start_streaming( current_task_span: Span[TaskSpanData] | None = ( task_span(name=trace_workflow_name) if use_task_and_turn_spans else None ) - if current_task_span: + if current_task_span is not None: current_task_span.start(mark_as_current=True) task_usage_start = snapshot_usage(context_wrapper.usage) @@ -830,13 +831,13 @@ async def _save_stream_items_without_count( store=store_setting, ) except BaseException: - if current_task_span: + if current_task_span is not None: attach_usage_to_span( current_task_span, usage_delta(task_usage_start, context_wrapper.usage), ) current_task_span.finish(reset_current=True) - if streamed_result.trace: + if streamed_result.trace is not None: streamed_result.trace.finish(reset_current=True) if not streamed_result.is_complete: streamed_result.is_complete = True @@ -915,7 +916,7 @@ async def _save_stream_items_without_count( if is_resumed_state and run_state is not None and run_state._current_step is not None: if isinstance(run_state._current_step, NextStepInterruption): - if not run_state._model_responses or not run_state._last_processed_response: + if not run_state._model_responses or run_state._last_processed_response is None: raise UserError("No model response found in previous state") last_model_response = run_state._model_responses[-1] @@ -1014,7 +1015,7 @@ async def _save_stream_items_without_count( if run_state is not None: run_state._current_agent = current_agent _publish_streamed_result_agent(streamed_result, current_agent) - if current_span: + if current_span is not None: current_span.finish(reset_current=True) current_span = None should_run_agent_start_hooks = True @@ -1072,7 +1073,7 @@ async def _save_stream_items_without_count( ) if current_span is None: - if output_schema := get_output_schema(execution_agent): + if (output_schema := get_output_schema(execution_agent)) is not None: output_type_name = output_schema.name() else: output_type_name = "str" @@ -1088,7 +1089,7 @@ async def _save_stream_items_without_count( current_turn += 1 streamed_result.current_turn = current_turn streamed_result._current_turn_persisted_item_count = 0 - if run_state: + if run_state is not None: run_state._current_turn_persisted_item_count = 0 if max_turns is not None and current_turn > max_turns: @@ -1223,7 +1224,7 @@ async def _save_stream_items_without_count( if use_task_and_turn_spans else None ) - if current_turn_span: + if current_turn_span is not None: current_turn_span.start(mark_as_current=True) try: if ( @@ -1254,7 +1255,7 @@ async def _save_stream_items_without_count( agent_span=current_span, ) finally: - if current_turn_span: + if current_turn_span is not None: attach_usage_to_span( current_turn_span, usage_delta(turn_usage_start, context_wrapper.usage), @@ -1312,7 +1313,7 @@ async def _save_stream_items_without_count( if isinstance(turn_result.next_step, NextStepRunAgain): streamed_result._current_turn_persisted_item_count = 0 - if run_state: + if run_state is not None: run_state._current_turn_persisted_item_count = 0 if server_conversation_tracker is not None: @@ -1455,15 +1456,15 @@ async def _save_stream_items_without_count( await dispose_resolved_computers(run_context=context_wrapper) except Exception as error: log_tool_action_warning(logger, "Failed to dispose computers after streamed run", error) - if current_span: + if current_span is not None: current_span.finish(reset_current=True) - if current_task_span: + if current_task_span is not None: attach_usage_to_span( current_task_span, usage_delta(task_usage_start, context_wrapper.usage), ) current_task_span.finish(reset_current=True) - if streamed_result.trace: + if streamed_result.trace is not None: streamed_result.trace.finish(reset_current=True) if not streamed_result.is_complete: @@ -1526,7 +1527,7 @@ async def raise_if_input_guardrail_tripwire_known() -> None: hooks.on_agent_start(agent_hook_context, public_agent), ( public_agent.hooks.on_start(agent_hook_context, public_agent) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), ) @@ -1611,7 +1612,7 @@ async def raise_if_input_guardrail_tripwire_known() -> None: filtered.instructions, filtered.input, ) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), ) @@ -1641,12 +1642,14 @@ async def raise_if_input_guardrail_tripwire_known() -> None: previous_response_id = ( server_conversation_tracker.previous_response_id - if server_conversation_tracker + if server_conversation_tracker is not None and server_conversation_tracker.previous_response_id is not None else None ) conversation_id = ( - server_conversation_tracker.conversation_id if server_conversation_tracker else None + server_conversation_tracker.conversation_id + if server_conversation_tracker is not None + else None ) if conversation_id: logger.debug("Using conversation_id=%s", conversation_id) @@ -1751,7 +1754,7 @@ async def rewind_model_request() -> None: if isinstance(event, ResponseOutputItemDoneEvent): streamed_response_output.append(event.item) - if not final_response: + if final_response is None: raise ModelBehaviorError("Model did not produce a final response!") context_wrapper.usage.add(final_response.usage) @@ -1773,7 +1776,7 @@ async def after_invocation_validation( await gather_with_cancel( ( public_agent.hooks.on_llm_end(context_wrapper, public_agent, final_response) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), hooks.on_llm_end(context_wrapper, public_agent, final_response), @@ -1852,7 +1855,7 @@ async def run_single_turn( hooks.on_agent_start(agent_hook_context, public_agent), ( public_agent.hooks.on_start(agent_hook_context, public_agent) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), ) @@ -1907,7 +1910,7 @@ async def after_invocation_validation( await gather_with_cancel( ( public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), hooks.on_llm_end(context_wrapper, public_agent, new_response), @@ -1979,19 +1982,21 @@ async def get_new_response( filtered.instructions, filtered.input, ) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), ) previous_response_id = ( server_conversation_tracker.previous_response_id - if server_conversation_tracker + if server_conversation_tracker is not None and server_conversation_tracker.previous_response_id is not None else None ) conversation_id = ( - server_conversation_tracker.conversation_id if server_conversation_tracker else None + server_conversation_tracker.conversation_id + if server_conversation_tracker is not None + else None ) if conversation_id: logger.debug("Using conversation_id=%s", conversation_id) @@ -2059,7 +2064,7 @@ async def rewind_model_request() -> None: await gather_with_cancel( ( public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), hooks.on_llm_end(context_wrapper, public_agent, new_response), diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 55745db55d..04dd211209 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -442,7 +442,7 @@ async def save_result_to_session( Returns: The number of new run items persisted for this call. """ - already_persisted = run_state._current_turn_persisted_item_count if run_state else 0 + already_persisted = run_state._current_turn_persisted_item_count if run_state is not None else 0 if session is None: return 0 @@ -454,7 +454,7 @@ async def save_result_to_session( new_run_items = [] else: new_run_items = new_items[already_persisted:] - if run_state and new_items and new_run_items: + if run_state is not None and new_items and new_run_items: missing_outputs = [ item for item in new_items @@ -525,13 +525,13 @@ async def save_result_to_session( ] if len(items_to_save) == 0: - if run_state: + if run_state is not None: run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count return saved_run_items_count await _session_add_items(session, items_to_save, wrapper=wrapper) - if run_state: + if run_state is not None: run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count if response_id and is_openai_responses_compaction_aware_session(session): diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py index cea85b4645..9bb1201b2f 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -119,7 +119,7 @@ async def execute( trace_tool_name = get_tool_trace_name_for_tool(action.computer_tool) or cls.TRACE_TOOL_NAME async def _run_action(span: Any | None) -> RunItem: - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.input = _serialize_trace_payload( cls._get_trace_input_payload(action.tool_call) ) @@ -132,7 +132,7 @@ async def _run_action(span: Any | None) -> RunItem: hooks.on_tool_start(context_wrapper, agent, action.computer_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, action.computer_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -145,7 +145,7 @@ async def _run_action(span: Any | None) -> RunItem: trace_include_sensitive_data=config.trace_include_sensitive_data, error_message=error_text, ) - if span: + if span is not None: span.set_error( SpanError( message="Error running tool", @@ -191,12 +191,12 @@ async def _run_action(span: Any | None) -> RunItem: hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output), ( agent_hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.output = image_url return output_item @@ -407,7 +407,7 @@ async def execute( hooks.on_tool_start(context_wrapper, agent, call.local_shell_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, call.local_shell_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -436,7 +436,7 @@ async def execute( hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result), ( agent_hooks.on_tool_end(context_wrapper, agent, call.local_shell_tool, result) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -468,7 +468,7 @@ async def execute( ) async def _run_call(span: Any | None) -> RunItem: - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.input = _serialize_trace_payload( dataclasses.asdict(shell_call.action) ) @@ -530,7 +530,7 @@ async def _run_call(span: Any | None) -> RunItem: hooks.on_tool_start(context_wrapper, agent, shell_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, shell_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -585,7 +585,7 @@ async def _run_call(span: Any | None) -> RunItem: trace_include_sensitive_data=config.trace_include_sensitive_data, error_message=output_text, ) - if span: + if span is not None: span.set_error( SpanError( message="Error running tool", @@ -641,12 +641,12 @@ async def _run_call(span: Any | None) -> RunItem: hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text), ( agent_hooks.on_tool_end(context_wrapper, agent, call.shell_tool, output_text) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.output = output_text return output_item @@ -696,7 +696,7 @@ async def execute( ) async def _run_call(span: Any | None) -> RunItem: - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.input = tool_input approval_status = context_wrapper.get_approval_status( @@ -757,7 +757,7 @@ async def _run_call(span: Any | None) -> RunItem: hooks.on_tool_start(tool_context, agent, custom_tool), ( agent_hooks.on_tool_start(tool_context, agent, custom_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -772,7 +772,7 @@ async def _run_call(span: Any | None) -> RunItem: trace_include_sensitive_data=config.trace_include_sensitive_data, error_message=output_text, ) - if span: + if span is not None: span.set_error( SpanError( message="Error running tool", @@ -813,12 +813,12 @@ async def _run_call(span: Any | None) -> RunItem: hooks.on_tool_end(tool_context, agent, custom_tool, output_text), ( agent_hooks.on_tool_end(tool_context, agent, custom_tool, output_text) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.output = output_text return output_item @@ -895,7 +895,7 @@ async def execute( ) async def _run_call(span: Any | None) -> RunItem: - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.input = _serialize_trace_payload( [ { @@ -964,7 +964,7 @@ async def _run_call(span: Any | None) -> RunItem: hooks.on_tool_start(context_wrapper, agent, apply_patch_tool), ( agent_hooks.on_tool_start(context_wrapper, agent, apply_patch_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -989,7 +989,7 @@ async def _run_call(span: Any | None) -> RunItem: awaited = await result if inspect.isawaitable(result) else result normalized = normalize_apply_patch_result(awaited) - if normalized: + if normalized is not None: if normalized.status == "failed": status = "failed" elif normalized.status == "completed" and status != "failed": @@ -1004,7 +1004,7 @@ async def _run_call(span: Any | None) -> RunItem: trace_include_sensitive_data=config.trace_include_sensitive_data, error_message=output_text, ) - if span: + if span is not None: span.set_error( SpanError( message="Error running tool", @@ -1050,12 +1050,12 @@ async def _run_call(span: Any | None) -> RunItem: hooks.on_tool_end(context_wrapper, agent, apply_patch_tool, output_text), ( agent_hooks.on_tool_end(context_wrapper, agent, apply_patch_tool, output_text) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) - if span and config.trace_include_sensitive_data: + if span is not None and config.trace_include_sensitive_data: span.span_data.output = output_text return output_item diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 3bf3d7a940..af257b165f 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -879,7 +879,7 @@ def is_apply_patch_name(name: str | None, tool: ApplyPatchTool | None) -> bool: candidate = name.strip().lower() if candidate.startswith("apply_patch"): return True - if tool and candidate == tool.name.strip().lower(): + if tool is not None and candidate == tool.name.strip().lower(): return True return False @@ -1188,7 +1188,7 @@ async def resolve_approval_status( tool_lookup_key=tool_lookup_key, current_invocation=approval_item, ) - if approval_status is None and on_approval: + if approval_status is None and on_approval is not None: decision_result = on_approval(context_wrapper, approval_item) if inspect.isawaitable(decision_result): decision_result = await decision_result @@ -1588,7 +1588,9 @@ def __init__( self.propagating_failure: BaseException | None = None self.available_function_tools: list[FunctionTool] = [] self.max_function_tool_concurrency = ( - config.tool_execution.max_function_tool_concurrency if config.tool_execution else None + config.tool_execution.max_function_tool_concurrency + if config.tool_execution is not None + else None ) async def execute( @@ -2021,7 +2023,7 @@ async def _execute_single_tool_body( self.hooks.on_tool_start(tool_context, self.public_agent, func_tool), ( agent_hooks.on_tool_start(tool_context, self.public_agent, func_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -2157,7 +2159,7 @@ async def _invoke_tool_and_run_post_invoke( self.hooks.on_tool_end(tool_context, self.public_agent, func_tool, final_result), ( agent_hooks.on_tool_end(tool_context, self.public_agent, func_tool, final_result) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -2456,7 +2458,10 @@ async def execute_computer_actions( tool_name=action.computer_tool.name, ) acknowledged: list[ComputerCallOutputAcknowledgedSafetyCheck] | None = None - if action.tool_call.pending_safety_checks and action.computer_tool.on_safety_check: + if ( + action.tool_call.pending_safety_checks + and action.computer_tool.on_safety_check is not None + ): acknowledged = [] for check in action.tool_call.pending_safety_checks: data = ComputerToolSafetyCheckData( diff --git a/src/agents/run_internal/tool_planning.py b/src/agents/run_internal/tool_planning.py index 89463b9b53..cfbc55c187 100644 --- a/src/agents/run_internal/tool_planning.py +++ b/src/agents/run_internal/tool_planning.py @@ -581,7 +581,7 @@ def _partition_mcp_approval_requests( manual: list[ToolRunMCPApprovalRequest] = [] for request in requests: if ( - request.mcp_tool.on_approval_request + request.mcp_tool.on_approval_request is not None and tool_invocation_identity(request.request_item) is not None ): with_callback.append(request) @@ -774,7 +774,7 @@ async def _collect_runs_by_approval( rejection_items: list[RunItem] = [] for run in runs: call_id = call_id_extractor(run) - if output_exists_checker and output_exists_checker(call_id): + if output_exists_checker is not None and output_exists_checker(call_id): continue tool_name = tool_name_resolver(run) existing_pending = approval_items_by_call_id.get(call_id) @@ -803,7 +803,7 @@ async def _collect_runs_by_approval( ) needs_approval = True - if approval_status is None and needs_approval_checker: + if approval_status is None and needs_approval_checker is not None: try: needs_approval = await needs_approval_checker(run) except UserError: @@ -834,7 +834,7 @@ async def _collect_runs_by_approval( approved_runs.append(run) continue - pending_item = existing_pending or current_item + pending_item = existing_pending if existing_pending is not None else current_item pending_interruption_adder(pending_item) return approved_runs, rejection_items @@ -901,11 +901,12 @@ async def _select_function_tool_runs_for_resume( continue current_item = pending_item_builder(run) + existing_pending = approval_items_by_call_id.get(call_id) approval_status = context_wrapper.get_approval_status( run.function_tool.name, call_id, tool_namespace=get_tool_call_namespace(run.tool_call), - existing_pending=approval_items_by_call_id.get(call_id), + existing_pending=existing_pending, tool_lookup_key=current_item.tool_lookup_key, current_invocation=current_item, ) @@ -917,7 +918,7 @@ async def _select_function_tool_runs_for_resume( run.function_tool.name, call_id, tool_namespace=get_tool_call_namespace(run.tool_call), - existing_pending=approval_items_by_call_id.get(call_id), + existing_pending=existing_pending, tool_lookup_key=current_item.tool_lookup_key, current_invocation=current_item, ) @@ -934,9 +935,8 @@ async def _select_function_tool_runs_for_resume( selected.append(run) continue - pending_interruption_adder( - approval_items_by_call_id.get(run.tool_call.call_id) or current_item - ) + pending_item = existing_pending if existing_pending is not None else current_item + pending_interruption_adder(pending_item) return selected diff --git a/src/agents/run_internal/tool_use_tracker.py b/src/agents/run_internal/tool_use_tracker.py index db165a300d..c84545d8b5 100644 --- a/src/agents/run_internal/tool_use_tracker.py +++ b/src/agents/run_internal/tool_use_tracker.py @@ -158,7 +158,9 @@ def hydrate_tool_use_tracker( agent_map = _build_agent_map(starting_agent) agent_identity_map = _build_agent_identity_map(starting_agent) for agent_name, tool_names in snapshot.items(): - agent = agent_identity_map.get(agent_name) or agent_map.get(agent_name) + agent = agent_identity_map.get(agent_name) + if agent is None: + agent = agent_map.get(agent_name) if agent is None: continue tool_use_tracker.add_tool_use(agent, list(tool_names)) diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 58a184f583..9ad8143a25 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -232,7 +232,7 @@ async def _maybe_finalize_from_tool_results( if not check_tool_use.is_final_output: return None - if not public_agent.output_type or public_agent.output_type is str: + if public_agent.output_type is None or public_agent.output_type is str: check_tool_use.final_output = str(check_tool_use.final_output) if check_tool_use.final_output is None: @@ -348,7 +348,7 @@ async def run_final_output_hooks( await gather_with_cancel( hooks.on_agent_end(agent_hook_context, agent, final_output), agent.hooks.on_end(agent_hook_context, agent, final_output) - if agent.hooks + if agent.hooks is not None else _coro.noop_coroutine(), ) @@ -371,7 +371,11 @@ async def execute_final_output_step( | None = None, ) -> SingleStepResult: """Finalize a turn once final output is known and run end hooks.""" - final_output_hooks = run_final_output_hooks_fn or run_final_output_hooks + final_output_hooks = ( + run_final_output_hooks_fn + if run_final_output_hooks_fn is not None + else run_final_output_hooks + ) await final_output_hooks(public_agent, hooks, context_wrapper, final_output) return SingleStepResult( @@ -618,7 +622,7 @@ def nest_history( agent=new_agent, source=public_agent, ) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), ) @@ -981,7 +985,7 @@ async def execute_tools_and_side_effects( tool_input_guardrail_results=tool_input_guardrail_results, tool_output_guardrail_results=tool_output_guardrail_results, ) - if output_schema and not output_schema.is_plain_text(): + if output_schema is not None and not output_schema.is_plain_text(): if potential_final_output_text: validation_error: ModelBehaviorError | None = None try: @@ -1058,7 +1062,7 @@ async def execute_tools_and_side_effects( tool_input_guardrail_results=tool_input_guardrail_results, tool_output_guardrail_results=tool_output_guardrail_results, ) - if not output_schema or output_schema.is_plain_text(): + if output_schema is None or output_schema.is_plain_text(): return await execute_final_output_call( public_agent=public_agent, original_input=original_input, @@ -2330,8 +2334,7 @@ def _commit_tool_output(item: RunItem) -> None: ): append_if_new(item) for pending_item in pending_interruptions: - if pending_item: - append_if_new(pending_item) + append_if_new(pending_item) for shell_rejection in rejected_shell_results: append_if_new(shell_rejection) for custom_tool_rejection in rejected_custom_tool_results: @@ -2368,9 +2371,7 @@ def _commit_missing_state(result: SingleStepResult) -> SingleStepResult: model_response=new_response, pre_step_items=original_pre_step_items, new_step_items=new_items, - next_step=NextStepInterruption( - interruptions=[item for item in pending_interruptions if item] - ), + next_step=NextStepInterruption(interruptions=list(pending_interruptions)), tool_input_guardrail_results=tool_input_guardrail_results, tool_output_guardrail_results=tool_output_guardrail_results, processed_response=processed_response, @@ -2691,7 +2692,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: "created_by": get_mapping_or_attr(output, "created_by"), } shell_call_raw.pop("created_by", None) - if not shell_tool: + if shell_tool is None: tools_used.append("shell") _error_tracing.attach_error_to_current_span( SpanError( @@ -2743,7 +2744,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: tool_name=shell_tool.name if shell_tool is not None else "shell", agent_name=agent.name, ) - tools_used.append(shell_tool.name if shell_tool else "shell") + tools_used.append(shell_tool.name if shell_tool is not None else "shell") if isinstance(output, dict): shell_output_raw = dict(output) else: @@ -2777,7 +2778,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: "created_by": get_mapping_or_attr(output, "created_by"), } apply_patch_call_raw.pop("created_by", None) - if apply_patch_tool: + if apply_patch_tool is not None: ensure_tool_caller_allowed( tool_call=apply_patch_call_raw, allowed_callers=apply_patch_tool.allowed_callers, @@ -2880,7 +2881,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: elif isinstance(output, ResponseReasoningItem): items.append(ReasoningItem(raw_item=output, agent=agent)) elif isinstance(output, ResponseComputerToolCall): - if not computer_tool: + if computer_tool is None: tools_used.append("computer") _error_tracing.attach_error_to_current_span( SpanError( @@ -2929,7 +2930,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: mcp_tool=server, ) ) - if not server.on_approval_request: + if server.on_approval_request is None: logger.debug( "Hosted MCP server %s has no on_approval_request hook; approvals will be " "surfaced as interruptions for the caller to handle.", @@ -3001,7 +3002,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: items.append(ToolCallItem(raw_item=output, agent=agent)) tools_used.append("code_interpreter") elif isinstance(output, LocalShellCall): - if local_shell_tool: + if local_shell_tool is not None: ensure_tool_caller_allowed( tool_call=output, allowed_callers=None, @@ -3019,7 +3020,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: local_shell_calls.append( ToolRunLocalShellCall(tool_call=output, local_shell_tool=local_shell_tool) ) - elif shell_tool: + elif shell_tool is not None: ensure_tool_caller_allowed( tool_call=output, allowed_callers=shell_tool.allowed_callers, @@ -3061,7 +3062,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: elif is_apply_patch_name(output.name, apply_patch_tool): pseudo_call = normalize_apply_patch_fallback_call(output) assert pseudo_call is not None - if apply_patch_tool: + if apply_patch_tool is not None: ensure_tool_caller_allowed( tool_call=pseudo_call, allowed_callers=apply_patch_tool.allowed_callers, @@ -3110,7 +3111,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]: ): pseudo_call = normalize_apply_patch_fallback_call(output) assert pseudo_call is not None - if apply_patch_tool: + if apply_patch_tool is not None: ensure_tool_caller_allowed( tool_call=pseudo_call, allowed_callers=apply_patch_tool.allowed_callers, @@ -3295,7 +3296,7 @@ def _preflight_response_invocations_after_processing_error( elif output_type == "shell_call": tool_name = shell_tool.name if shell_tool is not None else None elif isinstance(output, LocalShellCall): - selected_shell_tool = local_shell_tool or shell_tool + selected_shell_tool = local_shell_tool if local_shell_tool is not None else shell_tool tool_name = selected_shell_tool.name if selected_shell_tool is not None else None elif output_type == "apply_patch_call": tool_name = apply_patch_tool.name if apply_patch_tool is not None else None diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 71ceef3154..9ab9e2a47b 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -873,7 +873,7 @@ def _serialize_tool_input(self, tool_input: Any) -> Any: def _current_generated_items_merge_marker(self) -> str | None: """Return a marker for the processed response already reflected in _generated_items.""" - if not (self._last_processed_response and self._last_processed_response.new_items): + if self._last_processed_response is None or not self._last_processed_response.new_items: return None latest_response_id = ( @@ -909,7 +909,7 @@ def _clear_generated_items_last_processed_marker(self) -> None: def _merge_generated_items_with_processed(self) -> list[RunItem]: """Merge persisted and newly processed items without duplication.""" generated_items = list(self._generated_items) - if not (self._last_processed_response and self._last_processed_response.new_items): + if self._last_processed_response is None or not self._last_processed_response.new_items: return generated_items current_merge_marker = self._current_generated_items_merge_marker() @@ -1094,7 +1094,7 @@ def to_json( strict_context=strict_context, include_tracing_api_key=include_tracing_api_key, ) - if self._last_processed_response + if self._last_processed_response is not None else None ) result["current_turn_persisted_item_count"] = self._current_turn_persisted_item_count @@ -1326,7 +1326,7 @@ def set_trace(self, trace: Trace | None) -> None: self._trace_state = TraceState.from_trace(trace) def _serialize_trace_data(self, *, include_tracing_api_key: bool) -> dict[str, Any] | None: - if not self._trace_state: + if self._trace_state is None: return None return self._trace_state.to_json(include_tracing_api_key=include_tracing_api_key) @@ -2146,7 +2146,7 @@ def _deserialize_actions( deserialized: list[TAction] = [] for entry in entries or []: tool_container = entry.get(tool_key, {}) if isinstance(entry, Mapping) else {} - if name_resolver: + if name_resolver is not None: tool_name = name_resolver(entry) else: if isinstance(tool_container, Mapping): @@ -2165,7 +2165,7 @@ def _deserialize_actions( bare_lookup_key = get_function_tool_lookup_key(bare_name) if bare_lookup_key is not None: tool = tool_map.get(bare_lookup_key) - if not tool: + if tool is None: continue tool_call_data_raw = entry.get("tool_call", {}) if isinstance(entry, Mapping) else {} @@ -2400,7 +2400,7 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]: mcp_tool = mcp_tools_map.get(request_item.server_label) - if mcp_tool: + if mcp_tool is not None: _ensure_restored_tool_call_allowed( tool_call=request_item, allowed_callers=mcp_tool.tool_config.get("allowed_callers"), @@ -2557,7 +2557,8 @@ def _resolve_agent_from_data( resolved = agent_identity_map.get(agent_name) if resolved is not None: return resolved - return agent_map.get(agent_name) or fallback_agent + resolved = agent_map.get(agent_name) + return resolved if resolved is not None else fallback_agent return fallback_agent @@ -2989,7 +2990,7 @@ async def _build_run_state_from_json( agent_map, agent_identity_map=agent_identity_map, ) - if not current_agent: + if current_agent is None: raise UserError(f"Agent {current_agent_name} not found in agent map") context_data = state_json["context"] @@ -3650,7 +3651,7 @@ def _iter_agent_graph(initial_agent: Agent[Any]) -> Iterator[Agent[Any]]: continue tool_agent = getattr(tool, "_agent_instance", None) tool_agent_name = getattr(tool_agent, "name", None) - if tool_agent and tool_agent_name: + if tool_agent is not None and tool_agent_name: queue.append(tool_agent) @@ -4156,7 +4157,7 @@ def _resolve_agent_info( agent_map, agent_identity_map, ) - if agent_candidate: + if agent_candidate is not None: return agent_candidate, agent_candidate.name return None, candidate_name @@ -4168,7 +4169,7 @@ def _resolve_agent_info( continue agent, agent_name = _resolve_agent_info(item_data, item_type) - if not agent: + if agent is None: if agent_name: log_model_and_tool_data_warning( logger, @@ -4273,7 +4274,7 @@ def _resolve_agent_info( ) # If we cannot resolve both agents, skip this item gracefully - if not source_agent or not target_agent: + if source_agent is None or target_agent is None: source_name = item_data.get("source_agent") target_name = item_data.get("target_agent") log_model_and_tool_data_warning( diff --git a/src/agents/sandbox/capabilities/skills.py b/src/agents/sandbox/capabilities/skills.py index e02071b916..e68c437559 100644 --- a/src/agents/sandbox/capabilities/skills.py +++ b/src/agents/sandbox/capabilities/skills.py @@ -592,7 +592,7 @@ def process_manifest(self, manifest: Manifest) -> Manifest: skills_root = posix_path_as_path(coerce_posix_path(self.skills_path)) existing_paths = _manifest_entry_paths(manifest) - if self.lazy_from: + if self.lazy_from is not None: # Lazy sources do not claim `skills_root` in the manifest up front, so reserve the # whole namespace here and fail fast if any existing manifest entry is equal to, # above, or below that path. @@ -612,7 +612,7 @@ def process_manifest(self, manifest: Manifest) -> Manifest: ) return manifest - if self.from_: + if self.from_ is not None: if skills_root in existing_paths: existing_entry = _get_manifest_entry_by_path(manifest, skills_root) if existing_entry is None: diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index c3a4fda9ad..4c958fc90f 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -369,7 +369,7 @@ async def _create_resources( if effective_manifest is not None or run_as_user is not None: effective_manifest = self._process_manifest( capabilities, - effective_manifest or Manifest(), + effective_manifest if effective_manifest is not None else Manifest(), run_as_user=run_as_user, ) @@ -530,7 +530,11 @@ def _resolve_trusted_resume_manifest( agent: SandboxAgent[TContext], ) -> Manifest | None: sandbox_config = self._require_sandbox_config() - return sandbox_config.manifest or agent.default_manifest + return ( + sandbox_config.manifest + if sandbox_config.manifest is not None + else agent.default_manifest + ) @staticmethod def _process_manifest( diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index ac7100399b..7478372aae 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -1457,7 +1457,9 @@ def __init__( ) -> None: super().__init__() self.docker_client = docker_client - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies async def create( @@ -1469,7 +1471,7 @@ async def create( ) -> SandboxSession: image = options.image session_id = uuid.uuid4() - manifest = manifest or Manifest() + manifest = manifest if manifest is not None else Manifest() _validate_docker_path_grants(manifest) container = await self._create_container( @@ -1583,7 +1585,7 @@ async def _create_container( assert self.image_exists(image) environment: dict[str, str] | None = None - if manifest: + if manifest is not None: environment = await manifest.environment.resolve() create_kwargs: dict[str, object] = { "entrypoint": ["tail"], diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 615987da5a..45df5ad799 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -1090,7 +1090,9 @@ def __init__( instrumentation: Instrumentation | None = None, dependencies: Dependencies | None = None, ) -> None: - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._dependencies = dependencies async def create( @@ -1100,7 +1102,7 @@ async def create( manifest: Manifest | None = None, options: UnixLocalSandboxClientOptions | None = None, ) -> SandboxSession: - resolved_options = options or UnixLocalSandboxClientOptions() + resolved_options = options if options is not None else UnixLocalSandboxClientOptions() if manifest is not None: _assert_unix_local_host_path_grants_unsupported(manifest) # For local execution, runner-created sessions should always get an isolated temp root diff --git a/src/agents/sandbox/session/manager.py b/src/agents/sandbox/session/manager.py index 1248ce19b9..8bb838b322 100644 --- a/src/agents/sandbox/session/manager.py +++ b/src/agents/sandbox/session/manager.py @@ -24,7 +24,7 @@ def __init__( payload_policy_by_op: dict[OpName, EventPayloadPolicy] | None = None, ) -> None: self._sinks: list[EventSink] = list(sinks or []) - self.payload_policy = payload_policy or EventPayloadPolicy() + self.payload_policy = payload_policy if payload_policy is not None else EventPayloadPolicy() self.payload_policy_by_op = payload_policy_by_op or {} self._tasks: set[asyncio.Task[None]] = set() diff --git a/src/agents/sandbox/session/sandbox_session.py b/src/agents/sandbox/session/sandbox_session.py index 6aba057642..4b2ba109df 100644 --- a/src/agents/sandbox/session/sandbox_session.py +++ b/src/agents/sandbox/session/sandbox_session.py @@ -229,7 +229,9 @@ def __init__( ) -> None: self._inner = inner self._inner.set_dependencies(dependencies) - self._instrumentation = instrumentation or Instrumentation() + self._instrumentation = ( + instrumentation if instrumentation is not None else Instrumentation() + ) self._seq = 0 self._bind_session_to_sinks() diff --git a/src/agents/sandbox/snapshot.py b/src/agents/sandbox/snapshot.py index ae7b062cd7..134a57fec3 100644 --- a/src/agents/sandbox/snapshot.py +++ b/src/agents/sandbox/snapshot.py @@ -257,4 +257,5 @@ def build(self, snapshot_id: str) -> SnapshotBase: def resolve_snapshot(spec: SnapshotBase | SnapshotSpec | None, snapshot_id: str) -> SnapshotBase: if isinstance(spec, SnapshotBase): return spec - return (spec or NoopSnapshotSpec()).build(snapshot_id) + resolved_spec = spec if spec is not None else NoopSnapshotSpec() + return resolved_spec.build(snapshot_id) diff --git a/src/agents/sandbox/snapshot_defaults.py b/src/agents/sandbox/snapshot_defaults.py index 4391116ff2..2565dac3dc 100644 --- a/src/agents/sandbox/snapshot_defaults.py +++ b/src/agents/sandbox/snapshot_defaults.py @@ -29,7 +29,7 @@ def default_local_snapshot_base_dir( platform: str | None = None, os_name: str | None = None, ) -> Path: - resolved_home = home or Path.home() + resolved_home = home if home is not None else Path.home() resolved_env = os.environ if env is None else env resolved_platform = platform or sys.platform resolved_os_name = os_name or os.name diff --git a/src/agents/tool.py b/src/agents/tool.py index 5552d5b11d..10c178d41e 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -758,7 +758,11 @@ def get_function_tool_origin(function_tool: FunctionTool) -> ToolOrigin | None: """Return scalar origin metadata for a function tool.""" if not function_tool._emit_tool_origin: return None - return function_tool._tool_origin or ToolOrigin(type=ToolOriginType.FUNCTION) + return ( + function_tool._tool_origin + if function_tool._tool_origin is not None + else ToolOrigin(type=ToolOriginType.FUNCTION) + ) @dataclass @@ -903,7 +907,7 @@ async def resolve_computer( else None ) initializer: ComputerCreate[Any] | None = None - disposer: ComputerDispose[Any] | None = lifecycle.dispose if lifecycle else None + disposer: ComputerDispose[Any] | None = lifecycle.dispose if lifecycle is not None else None if lifecycle is not None: initializer = lifecycle.create @@ -914,7 +918,7 @@ async def resolve_computer( initializer = lifecycle_provider.create disposer = lifecycle_provider.dispose - if initializer: + if initializer is not None: computer_candidate = initializer(run_context=run_context) computer = ( await computer_candidate diff --git a/src/agents/tracing/context.py b/src/agents/tracing/context.py index c265dda3f9..fea8fe2910 100644 --- a/src/agents/tracing/context.py +++ b/src/agents/tracing/context.py @@ -57,7 +57,7 @@ def create_trace_for_run( ) -> Trace | None: """Return a trace object for this run when one is not already active.""" current_trace = get_current_trace() - if current_trace: + if current_trace is not None: return None if ( @@ -123,11 +123,10 @@ def __enter__(self) -> TraceCtxManager: trace_state=self.trace_state, reattach_resumed_trace=self.reattach_resumed_trace, ) - if self.trace: - assert self.trace is not None + if self.trace is not None: self.trace.start(mark_as_current=True) return self def __exit__(self, exc_type, exc_val, exc_tb): - if self.trace: + if self.trace is not None: self.trace.finish(reset_current=True) diff --git a/src/agents/tracing/create.py b/src/agents/tracing/create.py index 6585eebf7a..0a0be2d557 100644 --- a/src/agents/tracing/create.py +++ b/src/agents/tracing/create.py @@ -61,7 +61,7 @@ def trace( The newly created trace object. """ current_trace = get_trace_provider().get_current_trace() - if current_trace: + if current_trace is not None: logger.warning( "Trace already exists. Creating a new trace, but this is probably a mistake." ) diff --git a/src/agents/tracing/provider.py b/src/agents/tracing/provider.py index 57817642f8..29bf0d2d5a 100644 --- a/src/agents/tracing/provider.py +++ b/src/agents/tracing/provider.py @@ -430,7 +430,7 @@ def create_span( logger.debug("Span id is no-op, returning NoOpSpan") return NoOpSpan(span_data) - if not parent: + if parent is None: current_span = Scope.get_current_span() current_trace = Scope.get_current_trace() if current_trace is None: @@ -450,7 +450,7 @@ def create_span( ) return NoOpSpan(span_data) - parent_id = current_span.span_id if current_span else None + parent_id = current_span.span_id if current_span is not None else None trace_id = current_trace.trace_id tracing_api_key = current_trace.tracing_api_key # Trace is an interface; custom implementations may omit metadata. diff --git a/src/agents/tracing/scope.py b/src/agents/tracing/scope.py index b530c5e791..4295570c6c 100644 --- a/src/agents/tracing/scope.py +++ b/src/agents/tracing/scope.py @@ -40,7 +40,7 @@ def get_current_trace(cls) -> "Trace | None": @classmethod def set_current_trace(cls, trace: "Trace | None") -> "contextvars.Token[Trace | None]": - logger.debug("Setting current trace: %s", trace.trace_id if trace else None) + logger.debug("Setting current trace: %s", trace.trace_id if trace is not None else None) return _current_trace.set(trace) @classmethod diff --git a/src/agents/tracing/span_data.py b/src/agents/tracing/span_data.py index 39d3a2a58c..872388a736 100644 --- a/src/agents/tracing/span_data.py +++ b/src/agents/tracing/span_data.py @@ -236,7 +236,7 @@ def type(self) -> str: def export(self) -> dict[str, Any]: return { "type": self.type, - "response_id": self.response.id if self.response else None, + "response_id": self.response.id if self.response is not None else None, "usage": self.usage, } diff --git a/src/agents/util/_error_tracing.py b/src/agents/util/_error_tracing.py index 27eae75ec2..1967b6d31f 100644 --- a/src/agents/util/_error_tracing.py +++ b/src/agents/util/_error_tracing.py @@ -25,7 +25,7 @@ def attach_error_to_span(span: Span[Any], error: SpanError) -> None: def attach_error_to_current_span(error: SpanError) -> None: span = get_current_span() - if span: + if span is not None: attach_error_to_span(span, error) elif _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: logger.warning("No active span; trace error was not attached") diff --git a/src/agents/voice/models/openai_model_provider.py b/src/agents/voice/models/openai_model_provider.py index 2736afbf57..e58ebe9d66 100644 --- a/src/agents/voice/models/openai_model_provider.py +++ b/src/agents/voice/models/openai_model_provider.py @@ -78,12 +78,17 @@ def agent_registration(self) -> ResolvedOpenAIAgentRegistrationConfig | None: # AsyncOpenAI() raises an error if you don't have an API key set. def _get_client(self) -> AsyncOpenAI: if self._client is None: - self._client = _openai_shared.get_default_openai_client() or AsyncOpenAI( - api_key=self._stored_api_key or _openai_shared.get_default_openai_key(), - base_url=self._stored_base_url, - organization=self._stored_organization, - project=self._stored_project, - http_client=shared_http_client(), + default_client = _openai_shared.get_default_openai_client() + self._client = ( + default_client + if default_client is not None + else AsyncOpenAI( + api_key=self._stored_api_key or _openai_shared.get_default_openai_key(), + base_url=self._stored_base_url, + organization=self._stored_organization, + project=self._stored_project, + http_client=shared_http_client(), + ) ) return self._client diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index f14db755c7..24ab3e9b49 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -133,7 +133,7 @@ def _start_turn(self) -> None: self._tracing_span.start() def _end_turn(self, _transcript: str) -> None: - if self._tracing_span: + if self._tracing_span is not None: # Only encode audio if tracing is enabled AND buffer is not empty if self._trace_include_sensitive_audio_data and self._turn_audio_buffer: self._tracing_span.span_data.input = _audio_to_base64(self._turn_audio_buffer) @@ -332,7 +332,7 @@ def _check_errors(self) -> None: and not self._connection_task.cancelled() ): exc = self._connection_task.exception() - if exc and isinstance(exc, Exception): + if isinstance(exc, Exception): self._stored_exception = exc if ( @@ -341,7 +341,7 @@ def _check_errors(self) -> None: and not self._process_events_task.cancelled() ): exc = self._process_events_task.exception() - if exc and isinstance(exc, Exception): + if isinstance(exc, Exception): self._stored_exception = exc if ( @@ -350,7 +350,7 @@ def _check_errors(self) -> None: and not self._stream_audio_task.cancelled() ): exc = self._stream_audio_task.exception() - if exc and isinstance(exc, Exception): + if isinstance(exc, Exception): self._stored_exception = exc if ( @@ -359,7 +359,7 @@ def _check_errors(self) -> None: and not self._listener_task.cancelled() ): exc = self._listener_task.exception() - if exc and isinstance(exc, Exception): + if isinstance(exc, Exception): self._stored_exception = exc async def _cleanup_tasks(self) -> None: diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 329c53c311..dcd1661fc5 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -156,7 +156,7 @@ async def _stream_audio( audio_np = self._transform_audio_buffer( [combined], self.tts_settings.dtype ) - if self.tts_settings.transform_data: + if self.tts_settings.transform_data is not None: audio_np = self.tts_settings.transform_data(audio_np) await local_queue.put( VoiceStreamEventAudio(data=audio_np) @@ -172,7 +172,7 @@ async def _stream_audio( if len(combined) % 2 != 0: combined += b"\x00" audio_np = self._transform_audio_buffer([combined], self.tts_settings.dtype) - if self.tts_settings.transform_data: + if self.tts_settings.transform_data is not None: audio_np = self.tts_settings.transform_data(audio_np) await local_queue.put(VoiceStreamEventAudio(data=audio_np)) # Use local queue @@ -245,7 +245,7 @@ async def _turn_done(self): await asyncio.gather(*self._tasks) def _finish_turn(self): - if self._tracing_span: + if self._tracing_span is not None: if self._voice_pipeline_config.trace_include_sensitive_data: self._tracing_span.span_data.input = self._turn_text_buffer else: @@ -321,8 +321,9 @@ async def _cleanup_tasks(self) -> None: def _check_errors(self): for task in self._tasks: if task.done() and not task.cancelled(): - if task.exception(): - self._stored_exception = task.exception() + error = task.exception() + if error is not None: + self._stored_exception = error break async def stream(self) -> AsyncIterator[VoiceStreamEvent]: @@ -353,7 +354,7 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: break self._check_errors() - if self._stored_exception: + if self._stored_exception is not None: raise self._stored_exception except BaseException as exc: primary_exception = exc @@ -423,5 +424,5 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: raise exception_to_raise self._check_errors() - if self._stored_exception: + if self._stored_exception is not None: raise self._stored_exception diff --git a/src/agents/voice/workflow.py b/src/agents/voice/workflow.py index 538676ad1d..b3b9734e98 100644 --- a/src/agents/voice/workflow.py +++ b/src/agents/voice/workflow.py @@ -78,7 +78,7 @@ def __init__(self, agent: Agent[Any], callbacks: SingleAgentWorkflowCallbacks | self._callbacks = callbacks async def run(self, transcription: str) -> AsyncIterator[str]: - if self._callbacks: + if self._callbacks is not None: self._callbacks.on_run(self, transcription) # Add the transcription to the input history diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index 42d3716220..db803260c8 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -81,6 +81,21 @@ async def test_start_with_none_client(self): mock_get_default.assert_called_once() mock_default_client.conversations.create.assert_called_once_with(items=[]) + @pytest.mark.asyncio + async def test_start_preserves_falsy_default_client(self): + mock_default_client = AsyncMock() + mock_default_client.__bool__.return_value = False + mock_default_client.conversations.create.return_value = MagicMock(id="default_client_id") + + with patch( + "agents.memory.openai_conversations_session.get_default_openai_client", + return_value=mock_default_client, + ): + conversation_id = await start_openai_conversations_session(None) + + assert conversation_id == "default_client_id" + mock_default_client.conversations.create.assert_awaited_once_with(items=[]) + @pytest.mark.asyncio async def test_start_with_none_client_fallback(self): """Test starting a conversation session when get_default_openai_client returns None.""" diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index cf4453fc4d..b0b20bad28 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -4,7 +4,7 @@ import warnings as warnings_module from types import SimpleNamespace from typing import Any, cast -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -87,6 +87,20 @@ def test_excludes_easy_user_messages_without_type(self) -> None: class TestOpenAIResponsesCompactionSession: + def test_client_preserves_falsy_default_client(self) -> None: + mock_client = MagicMock() + mock_client.__bool__.return_value = False + session = OpenAIResponsesCompactionSession( + session_id="test", + underlying_session=self.create_mock_session(), + ) + + with patch( + "agents.memory.openai_responses_compaction_session.get_default_openai_client", + return_value=mock_client, + ): + assert session.client is mock_client + def create_mock_session(self) -> MagicMock: mock = MagicMock(spec=Session) mock.session_id = "test-session" diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 210685d986..9a37cb17c1 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -281,6 +281,33 @@ async def test_user_agent_header_any_llm_chat(override_ua: str | None, monkeypat assert provider.chat_calls[0]["extra_headers"]["User-Agent"] == expected_ua +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_preserves_falsy_reasoning(monkeypatch: pytest.MonkeyPatch) -> None: + class FalsyReasoning(Reasoning): + def __bool__(self) -> bool: + return False + + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model="openrouter/openai/gpt-5.4-mini") + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(reasoning=FalsyReasoning(effort="low")), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert provider.chat_calls[0]["reasoning_effort"] == "low" + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("provider_name", ["gemini", "vertexai"]) diff --git a/tests/models/test_litellm_extra_body.py b/tests/models/test_litellm_extra_body.py index 948a8cf192..a51808b595 100644 --- a/tests/models/test_litellm_extra_body.py +++ b/tests/models/test_litellm_extra_body.py @@ -3,6 +3,7 @@ import litellm import pytest from litellm.types.utils import Choices, Message, ModelResponse, Usage +from openai.types.shared import Reasoning from agents import function_tool from agents.extensions.models.litellm_model import LitellmModel @@ -10,6 +11,17 @@ from agents.models.interface import ModelTracing +def test_falsy_reasoning_effort_is_preserved() -> None: + class FalsyReasoning(Reasoning): + def __bool__(self) -> bool: + return False + + model = LitellmModel(model="test-model") + settings = ModelSettings(reasoning=FalsyReasoning(effort="low")) + + assert model._get_reasoning_effort(settings) == "low" + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_extra_body_is_forwarded(monkeypatch): diff --git a/tests/models/test_model_retry.py b/tests/models/test_model_retry.py index c81f4e3dc3..671ceee866 100644 --- a/tests/models/test_model_retry.py +++ b/tests/models/test_model_retry.py @@ -24,6 +24,8 @@ RetryDecision, RetryPolicyContext, retry_policies, + retry_policy_retries_all_transient_errors, + retry_policy_retries_safe_transport_errors, ) from agents.run_internal.model_retry import get_response_with_retry, stream_response_with_retry from agents.usage import Usage @@ -56,6 +58,25 @@ def test_model_retry_backoff_settings_allow_zero_values() -> None: assert backoff.multiplier == 0 +def test_retry_capabilities_preserve_falsey_policy() -> None: + class FalseyPolicy: + _openai_agents_retries_safe_transport_errors: bool + _openai_agents_retries_all_transient_errors: bool + + def __bool__(self) -> bool: + return False + + def __call__(self, _context: RetryPolicyContext) -> bool: + return False + + policy = FalseyPolicy() + policy._openai_agents_retries_safe_transport_errors = True + policy._openai_agents_retries_all_transient_errors = True + + assert retry_policy_retries_safe_transport_errors(policy) is True + assert retry_policy_retries_all_transient_errors(policy) is True + + def _connection_error(message: str = "connection error") -> APIConnectionError: return APIConnectionError( message=message, diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 57d89cd57f..1c764f388d 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -112,6 +112,20 @@ def __init__(self, completions: DummyCompletions) -> None: return completions.kwargs +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_falsy_reasoning_is_forwarded() -> None: + class FalsyReasoning(Reasoning): + def __bool__(self) -> bool: + return False + + kwargs = await _run_chat_completions_model_with_custom_base_url( + ModelSettings(reasoning=FalsyReasoning(effort="low")) + ) + + assert kwargs["reasoning_effort"] == "low" + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_get_response_with_text_message(monkeypatch) -> None: @@ -663,12 +677,20 @@ def __init__(self) -> None: @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_get_response_attaches_logprobs(monkeypatch) -> None: + class FalsyChoiceLogprobs(ChoiceLogprobs): + def __bool__(self) -> bool: + return False + + class FalsyCompletionUsage(CompletionUsage): + def __bool__(self) -> bool: + return False + msg = ChatCompletionMessage(role="assistant", content="Hi!") choice = Choice( index=0, finish_reason="stop", message=msg, - logprobs=ChoiceLogprobs( + logprobs=FalsyChoiceLogprobs( content=[ ChatCompletionTokenLogprob( token="Hi", @@ -691,7 +713,11 @@ async def test_get_response_attaches_logprobs(monkeypatch) -> None: model="fake", object="chat.completion", choices=[choice], - usage=None, + usage=FalsyCompletionUsage( + completion_tokens=2, + prompt_tokens=3, + total_tokens=5, + ), ) async def patched_fetch_response(self, *args, **kwargs): @@ -717,6 +743,9 @@ async def patched_fetch_response(self, *args, **kwargs): assert isinstance(text_part, ResponseOutputText) assert text_part.logprobs is not None assert [lp.token for lp in text_part.logprobs] == ["Hi", "!"] + assert resp.usage.input_tokens == 3 + assert resp.usage.output_tokens == 2 + assert resp.usage.total_tokens == 5 @pytest.mark.allow_call_model_methods diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 34bcc7cb5e..429abdfbbb 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -10,6 +10,7 @@ from openai import NOT_GIVEN, APIConnectionError, AsyncOpenAI, RateLimitError, omit from openai.types.responses import ResponseCompletedEvent, ResponseErrorEvent from openai.types.responses.response_create_params import ContextManagement, PromptCacheOptions +from openai.types.responses.response_usage import ResponseUsage from openai.types.shared.reasoning import Reasoning from agents import ( @@ -268,13 +269,20 @@ def __init__(self): @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_get_response_span_exports_usage(): + class FalsyResponseUsage(ResponseUsage): + def __bool__(self) -> bool: + return False + class DummyResponses: async def create(self, **kwargs): - return get_response_obj( + response = get_response_obj( [], response_id="resp-usage", usage=Usage(requests=1, input_tokens=10, output_tokens=4, total_tokens=14), ) + assert response.usage is not None + response.usage = FalsyResponseUsage.model_validate(response.usage.model_dump()) + return response class DummyResponsesClient: def __init__(self): diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index b4a12ae639..c6c2b5d9cf 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -8,6 +8,7 @@ import pytest import websockets +from openai.types.realtime.realtime_session_create_request import RealtimeSessionCreateRequest from pydantic import TypeAdapter from agents import Agent, WebSearchTool, function_tool @@ -1272,6 +1273,35 @@ async def test_interrupt_force_cancel_overrides_auto_cancellation(self, model, m assert model._response_control == "free" assert model._audio_state_tracker.get_last_audio_item() is None + @pytest.mark.asyncio + async def test_interrupt_honors_falsy_present_session_auto_cancellation( + self, model, monkeypatch + ): + class FalsySession(RealtimeSessionCreateRequest): + def __bool__(self) -> bool: + return False + + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta("item_1", 0, b"\x00" * 4800) + await model._mark_response_created() + model._created_session = FalsySession.model_construct( + type="realtime", + model="gpt-realtime-2.1", + audio=SimpleNamespace( + input=SimpleNamespace(turn_detection=SimpleNamespace(interrupt_response=True)) + ), + ) + + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + + await model._send_interrupt(RealtimeModelSendInterrupt()) + + assert send_raw.await_count == 1 + sent = send_raw.await_args + assert sent is not None + assert sent.args[0].type == "conversation.item.truncate" + @pytest.mark.asyncio async def test_response_only_interrupt_targets_response_without_touching_audio( self, model, monkeypatch diff --git a/tests/realtime/test_session_payload_and_formats.py b/tests/realtime/test_session_payload_and_formats.py index eb4f71f12d..d20985b1c9 100644 --- a/tests/realtime/test_session_payload_and_formats.py +++ b/tests/realtime/test_session_payload_and_formats.py @@ -75,6 +75,36 @@ def test_extract_audio_format_from_session_objects() -> None: assert Model._extract_audio_format(s_none) is None +def test_extract_audio_format_preserves_falsy_present_models() -> None: + class FalsyAudioConfig(RealtimeAudioConfig): + def __bool__(self) -> bool: + return False + + class FalsyAudioPCM(AudioPCM): + def __bool__(self) -> bool: + return False + + audio = FalsyAudioConfig(output=cast(Any, {"format": AudioPCM(type="audio/pcm")})) + falsy_audio_session = RealtimeSessionCreateRequest( + type="realtime", + model="gpt-realtime-2.1", + audio=audio, + ) + fmt = FalsyAudioPCM(type="audio/pcm") + falsy_format_session = RealtimeSessionCreateRequest( + type="realtime", + model="gpt-realtime-2.1", + audio=RealtimeAudioConfig(output=cast(Any, {"format": fmt})), + ) + + assert falsy_audio_session.audio is audio + assert Model._extract_audio_format(falsy_audio_session) == "pcm16" + assert falsy_format_session.audio is not None + assert falsy_format_session.audio.output is not None + assert falsy_format_session.audio.output.format is fmt + assert Model._extract_audio_format(falsy_format_session) == "pcm16" + + def test_normalize_audio_format_fallbacks() -> None: # String passthrough assert Model._normalize_audio_format("pcm24") == "pcm24" diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index a580fb2633..4974ce980a 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -74,6 +74,38 @@ async def on_tool_end( self.tool_context_ids.append(context.tool_call_id) +class FalsyAgentHooks(AgentHooksForTests): + def __bool__(self) -> bool: + return False + + +@pytest.mark.asyncio +async def test_falsy_agent_hooks_are_invoked() -> None: + hooks = FalsyAgentHooks() + model = FakeModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("some_function", "result")], + hooks=hooks, + ) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + [get_text_message("done")], + ] + ) + + await Runner.run(agent, input="user_message") + + assert hooks.events == { + "on_start": 1, + "on_tool_start": 1, + "on_tool_end": 1, + "on_end": 1, + } + + @pytest.mark.asyncio async def test_non_streamed_agent_hooks(): hooks = AgentHooksForTests() diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 3553815da9..203726ec7c 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -23,6 +23,7 @@ import agents._debug as _debug from agents import ( Agent, + AgentOutputSchema, GuardrailFunctionOutput, Handoff, HandoffInputData, @@ -68,6 +69,7 @@ from agents.run import AgentRunner, get_default_agent_runner, set_default_agent_runner from agents.run_config import _default_trace_include_sensitive_data from agents.run_internal.agent_bindings import bind_public_agent +from agents.run_internal.agent_runner_helpers import build_resumed_stream_debug_extra from agents.run_internal.items import ( TOOL_CALL_SESSION_DESCRIPTION_KEY, TOOL_CALL_SESSION_TITLE_KEY, @@ -598,6 +600,38 @@ def test_set_default_agent_runner_roundtrip(): assert isinstance(get_default_agent_runner(), AgentRunner) +def test_set_default_agent_runner_preserves_falsey_runner(): + class FalseyRunner(AgentRunner): + def __bool__(self) -> bool: + return False + + original_runner = get_default_agent_runner() + runner = FalseyRunner() + try: + set_default_agent_runner(runner) + assert get_default_agent_runner() is runner + finally: + set_default_agent_runner(original_runner) + + +def test_resumed_stream_debug_extra_preserves_falsy_current_agent() -> None: + class FalsyAgent(Agent[Any]): + def __bool__(self) -> bool: + return False + + agent: Agent[Any] = FalsyAgent(name="falsy") + state: RunState[None] = RunState( + context=RunContextWrapper(context=None), + original_input="input", + starting_agent=agent, + max_turns=1, + ) + + extra = build_resumed_stream_debug_extra(state, include_tool_output=False) + + assert extra["current_agent"] == "falsy" + + def test_run_streamed_preserves_legacy_positional_previous_response_id(): captured: dict[str, Any] = {} @@ -4530,13 +4564,17 @@ def test_tool_two(): @pytest.mark.asyncio async def test_tool_use_behavior_first_output(): + class FalsyAgentOutputSchema(AgentOutputSchema): + def __bool__(self) -> bool: + return False + model = FakeModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result"), test_tool_one, test_tool_two], tool_use_behavior="stop_on_first_tool", - output_type=Foo, + output_type=FalsyAgentOutputSchema(Foo), ) model.add_multiple_turn_outputs( diff --git a/tests/test_cancel_streaming.py b/tests/test_cancel_streaming.py index 87c094947f..b912f05130 100644 --- a/tests/test_cancel_streaming.py +++ b/tests/test_cancel_streaming.py @@ -6,6 +6,7 @@ from openai.types.responses import ResponseCompletedEvent from agents import Agent, Runner +from agents.guardrail import input_guardrail from agents.stream_events import RawResponsesStreamEvent from .fake_model import FakeModel @@ -269,3 +270,43 @@ async def stream_response(self, *args, **kwargs): assert result.run_loop_exception is not None assert isinstance(result.run_loop_exception, RuntimeError) assert "run loop boom" in str(result.run_loop_exception) + + +@pytest.mark.asyncio +async def test_falsy_run_loop_exception_is_surfaced_after_stream() -> None: + class FalsyRuntimeError(RuntimeError): + def __bool__(self) -> bool: + return False + + class BoomModel(FakeModel): + async def stream_response(self, *args, **kwargs): + raise FalsyRuntimeError("falsy run loop boom") + yield + + result = Runner.run_streamed(Agent(name="A", model=BoomModel()), input="hi") + + with pytest.raises(FalsyRuntimeError, match="falsy run loop boom"): + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_falsy_input_guardrail_exception_is_surfaced_after_stream() -> None: + class FalsyRuntimeError(RuntimeError): + def __bool__(self) -> bool: + return False + + @input_guardrail + async def raising_guardrail(context, agent, input): + raise FalsyRuntimeError("falsy guardrail boom") + + model = FakeModel() + model.set_next_output([get_text_message("done")]) + result = Runner.run_streamed( + Agent(name="A", model=model, input_guardrails=[raising_guardrail]), + input="hi", + ) + + with pytest.raises(FalsyRuntimeError, match="falsy guardrail boom"): + async for _ in result.stream_events(): + pass diff --git a/tests/test_check_optional_truthiness.py b/tests/test_check_optional_truthiness.py new file mode 100644 index 0000000000..8cacf26f8f --- /dev/null +++ b/tests/test_check_optional_truthiness.py @@ -0,0 +1,831 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +import pytest + + +def _load_checker() -> ModuleType: + path = Path(__file__).parents[1] / ".github/scripts/check_optional_truthiness.py" + spec = importlib.util.spec_from_file_location("check_optional_truthiness", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +checker = _load_checker() + + +def _violations(tmp_path: Path, source: str) -> list[Any]: + path = tmp_path / "example.py" + path.write_text(source, encoding="utf-8") + return cast(list[Any], checker.find_violations([path])) + + +@pytest.mark.parametrize( + ("statement", "expected_expression"), + [ + ("if callback:\n callback()", "callback"), + ("while callback:\n break", "callback"), + ("assert callback", "callback"), + ("return callback if callback else fallback", "callback"), + ("return not callback", "callback"), + ("return callback and callback()", "callback"), + ("return callback or fallback", "callback"), + ], +) +def test_rejects_supported_boolean_forms( + tmp_path: Path, + statement: str, + expected_expression: str, +) -> None: + violations = _violations( + tmp_path, + f""" +from collections.abc import Callable + +def invoke(callback: Callable[[], str] | None, fallback: Callable[[], str]): + {statement} +""", + ) + + assert [violation.expression for violation in violations] == [expected_expression] + + +def test_rejects_direct_local_class_parameter_and_local(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +class Model: ... + +def select(model: Model | None): + current: Model | None = model + if model: + return model + return current or Model() +""", + ) + + assert {violation.expression for violation in violations} == {"model", "current"} + + +def test_rejects_callable_imported_in_function_scope(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +def select(fallback): + from typing import Callable + + callback: Callable[[], str] | None = None + return callback or fallback +""", + ) + + assert [violation.expression for violation in violations] == ["callback"] + + +def test_rejects_callable_imported_in_class_scope_without_leaking(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +class Runner: + from typing import Callable + + callback: Callable[[], str] | None = None + + def select(self, override: Callable[[], str] | None): + return self.callback or override or fallback + +def unrelated(callback: Callable[[], str] | None): + return callback or fallback +""", + ) + + assert {violation.expression for violation in violations} == { + "self.callback", + "override", + } + + +def test_class_scope_callable_shadow_suppresses_only_that_class(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from typing import Callable + +class Runner: + from vendor import Callable + + callback: Callable[[], str] | None = None + + def select(self, override: Callable[[], str] | None): + return self.callback or override or fallback + +def unrelated(callback: Callable[[], str] | None): + return callback or fallback +""", + ) + + assert [violation.expression for violation in violations] == ["callback"] + + +@pytest.mark.parametrize( + "source", + [ + """ +from typing import Callable + +def select(callback: Callable[[], str] | None): + return callback or fallback + +Callable = replacement +""", + """ +from typing import Callable + +class Runner: + def select(self, callback: Callable[[], str] | None): + return callback or fallback + + Callable = replacement +""", + ], + ids=["module", "class"], +) +def test_rebound_callable_scope_remains_unclassified(tmp_path: Path, source: str) -> None: + assert _violations(tmp_path, source) == [] + + +def test_rejects_callbacks_in_nested_class_scopes(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from typing import Callable + +def outer(): + class Runner: + def select(self, callback: Callable[[], str] | None): + return callback or fallback + +class Container: + class Runner: + def select(self, callback: Callable[[], str] | None): + return callback or fallback +""", + ) + + assert [violation.expression for violation in violations] == ["callback", "callback"] + + +def test_redefined_same_file_class_remains_unclassified(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +class Base: ... +class Model(Base): ... + +def select(model: Model | None): + return model or fallback + +class Model: ... +""", + ) + + assert violations == [] + + +@pytest.mark.parametrize( + "source", + [ + """ +class Model: ... +from vendor import Model + +def select(): + value: Model | None = None + return value or fallback +""", + """ +class Model: ... + +class Runner: + from vendor import Model + value: Model | None = None + + def select(self): + return self.value or fallback +""", + """ +class Model: ... + +def select(): + from vendor import Model + value: Model | None = None + return value or fallback +""", + """ +class Model: ... + +def outer(): + from vendor import Model + + def inner(): + value: Model | None = None + return value or fallback + + return inner() +""", + ], + ids=["module", "class", "function", "nested-function"], +) +def test_same_file_class_shadow_remains_unclassified(tmp_path: Path, source: str) -> None: + assert _violations(tmp_path, source) == [] + + +@pytest.mark.parametrize("module", [".typing", ".collections.abc"]) +def test_relative_callable_import_remains_unclassified(tmp_path: Path, module: str) -> None: + violations = _violations( + tmp_path, + f""" +from {module} import Callable + +def select(callback: Callable[[], str] | None): + return callback or fallback +""", + ) + + assert violations == [] + + +def test_comprehension_binding_does_not_shadow_enclosing_scope(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from typing import Callable + +def select(values): + ignored = [Callable for Callable in values] + callback: Callable[[], str] | None = None + return callback or fallback + +class Runner: + ignored = [Callable for Callable in values] + callback: Callable[[], str] | None = None + + def select(self): + return self.callback or fallback +""", + ) + + assert {violation.expression for violation in violations} == { + "callback", + "self.callback", + } + + +def test_comprehension_walrus_shadows_enclosing_scope(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from typing import Callable + +def select(values): + ignored = [value for value in values if (Callable := value)] + callback: Callable[[], str] | None = None + return callback or fallback +""", + ) + + assert violations == [] + + +@pytest.mark.parametrize( + "definition", + [ + "def nested(value=(Callable := factory)): ...", + "factory = lambda value=(Callable := factory): value", + "@((Callable := decorate))\ndef nested(): ...", + "class Nested((Callable := Base)): ...", + ], + ids=["function-default", "lambda-default", "decorator", "class-base"], +) +def test_definition_expression_walrus_shadows_enclosing_scope( + tmp_path: Path, + definition: str, +) -> None: + indented_definition = definition.replace("\n", "\n ") + violations = _violations( + tmp_path, + f""" +def outer(): + from typing import Callable + + {indented_definition} + callback: Callable[[], str] | None = None + return callback or fallback +""", + ) + + assert violations == [] + + +@pytest.mark.parametrize( + "pattern", + ["Callable", "[*Callable]", "{**Callable}"], + ids=["match-as", "match-star", "match-mapping-rest"], +) +def test_match_capture_shadows_callable_binding(tmp_path: Path, pattern: str) -> None: + violations = _violations( + tmp_path, + f""" +from typing import Callable + +def select(subject): + match subject: + case {pattern}: + pass + callback: Callable[[], str] | None = None + return callback or fallback +""", + ) + + assert violations == [] + + +@pytest.mark.parametrize( + "binding", + [ + "Callable = make_type()", + "def Callable(): ...", + "from vendor import *", + ], + ids=["assignment", "definition", "star-import"], +) +def test_callable_shadow_forms_remain_unclassified( + tmp_path: Path, + binding: str, +) -> None: + violations = _violations( + tmp_path, + f""" +from typing import Callable + +def select(): + {binding} + callback: Callable[[], str] | None = None + return callback or fallback +""", + ) + + assert violations == [] + + +def test_rejects_direct_self_fields_declared_in_class_or_method(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from collections.abc import Callable + +class Runner: + callback: Callable[[], str] | None = None + + def configure(self): + self.fallback: Callable[[], str] | None = None + + def run(self): + if self.callback: + self.callback() + return self.fallback or default_callback +""", + ) + + assert {violation.expression for violation in violations} == { + "self.callback", + "self.fallback", + } + + +def test_static_method_parameter_is_checked_without_instance_field_inference( + tmp_path: Path, +) -> None: + violations = _violations( + tmp_path, + """ +from collections.abc import Callable + +class Runner: + callback: Callable[[], str] | None = None + + @staticmethod + def run(callback: Callable[[], str] | None): + if callback: + callback() +""", + ) + + assert [violation.expression for violation in violations] == ["callback"] + + +def test_static_method_receiver_field_declaration_is_not_inferred(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from collections.abc import Callable + +class Runner: + @staticmethod + def configure(self): + self.callback: Callable[[], str] | None = None + + def run(self): + return self.callback or default_callback +""", + ) + + assert violations == [] + + +def test_qualified_static_method_receiver_field_declaration_is_not_inferred( + tmp_path: Path, +) -> None: + violations = _violations( + tmp_path, + """ +import builtins +from collections.abc import Callable + +class Runner: + @builtins.staticmethod + def configure(self): + self.callback: Callable[[], str] | None = None + + def run(self): + return self.callback or default_callback +""", + ) + + assert violations == [] + + +@pytest.mark.parametrize( + "source", + [ + """ +from typing import Callable + +def mutate(): + global Callable + Callable = replacement + +def select(callback: Callable[[], str] | None): + return callback or fallback +""", + """ +def outer(): + from typing import Callable + + def mutate(): + nonlocal Callable + Callable = replacement + + callback: Callable[[], str] | None = None + return callback or fallback +""", + ], + ids=["global", "nonlocal"], +) +def test_outer_scope_rebinding_remains_unclassified(tmp_path: Path, source: str) -> None: + assert _violations(tmp_path, source) == [] + + +@pytest.mark.parametrize( + "source", + [ + """ +from typing import Callable + +def inspect(): + global Callable + return Callable + +def select(callback: Callable[[], str] | None): + return callback or fallback +""", + """ +Callable = replacement + +def configure(): + global Callable + from typing import Callable + +def select(callback: Callable[[], str] | None): + return callback or fallback +""", + """ +def outer(): + from typing import Callable + + callback: Callable[[], str] | None = None + + def middle(): + from typing import Callable + + def inner(): + nonlocal Callable + Callable = replacement + + return callback or fallback +""", + """ +def outer(): + from typing import Callable + + callback: Callable[[], str] | None = None + + class Mutator: + nonlocal Callable + Callable = replacement + + return callback or fallback +""", + """ +def outer(): + from typing import Callable + + callback: Callable[[], str] | None = None + + def middle(): + def inner(): + nonlocal Callable + Callable = replacement + + return callback or fallback +""", + ], + ids=[ + "read-only-global", + "global-import", + "deep-nonlocal-with-binding", + "class-nonlocal", + "deep-nonlocal-without-binding", + ], +) +def test_cross_scope_declarations_remain_unclassified(tmp_path: Path, source: str) -> None: + assert _violations(tmp_path, source) == [] + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+") +@pytest.mark.parametrize( + "source", + [ + """ +from typing import Callable + +def select[Callable](callback: Callable[[], str] | None): + return callback or fallback +""", + """ +from typing import Callable + +class Runner[Callable]: + callback: Callable[[], str] | None = None + + def select(self): + return self.callback or fallback +""", + """ +class Model: ... + +class Runner[Model]: + def select(self): + value: Model | None = None + return value or fallback +""", + """ +from typing import Callable + +class Runner[Callable]: + def select(self): + callback: Callable[[], str] | None = None + return callback or fallback +""", + ], + ids=["function", "class-field", "class-method-model", "class-method-callable"], +) +def test_type_parameter_bindings_remain_unclassified(tmp_path: Path, source: str) -> None: + assert _violations(tmp_path, source) == [] + + +def test_rejects_match_guard_truthiness(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from typing import Callable + +def select(value, callback: Callable[[], str] | None): + match value: + case _ if callback: + return callback() +""", + ) + + assert [violation.expression for violation in violations] == ["callback"] + + +def test_allows_explicit_none_checks_and_direct_value_truthiness(tmp_path: Path) -> None: + violations = _violations( + tmp_path, + """ +from collections.abc import Callable + +def select( + callback: Callable[[], str] | None, + name: str | None, + count: int | None, + values: list[str] | None, + options: dict[str, str] | None, +): + if callback is not None: + callback() + return name or "default", count or 1, values or [], options or {} +""", + ) + + assert violations == [] + + +@pytest.mark.parametrize( + "source", + [ + """ +from collections.abc import Callable +Callback = Callable[[], str] +def select(callback: Callback | None): + return callback or fallback +""", + """ +from vendor import ImportedModel +def select(model: ImportedModel | None): + return model or fallback +""", + """ +import typing +def select(callback: typing.Callable[[], str] | None): + return callback or fallback +""", + """ +from typing import Annotated, Callable +def select(callback: Annotated[Callable[[], str] | None, "meta"]): + return callback or fallback +""", + """ +from typing import Callable, Optional +def select(callback: Optional[Callable[[], str]]): + return callback or fallback +""", + """ +from collections.abc import Callable +class Base: + callback: Callable[[], str] | None = None +class Child(Base): + def select(self): + return self.callback or fallback +""", + """ +from collections.abc import Callable +def select(callback: Callable[[], str] | None): + current = callback + return current or fallback +""", + """ +from collections.abc import Callable +class Runner: + callback: Callable[[], str] | None = None + def select(self, other: Runner): + return other.callback or fallback +""", + """ +from collections.abc import Callable +def current() -> Callable[[], str] | None: + return None +def select(): + return current() or fallback +""", + """ +from collections.abc import Callable +class Runner: + @property + def callback(self) -> Callable[[], str] | None: + return None + def select(self): + return self.callback or fallback +""", + """ +from collections.abc import Callable +def select(callback: Callable | None): + return callback or fallback +""", + """ +class Box: ... +def select(value: Box[int] | None): + return value or fallback +""", + """ +from typing import Callable as Callback +def select(callback: Callback[[], str] | None): + return callback or fallback +""", + """ +from collections.abc import Callable +def select(callback: Callable[[], str] | None): + return bool(callback) +""", + """ +from collections.abc import Callable +def select(callback: Callable[[], str] | None): + first = True and callback + second = False or callback + return first, second +""", + """ +from collections.abc import Callable +def outer(callback: Callable[[], str] | None): + def inner(value=callback or fallback): + return value +""", + """ +from collections.abc import Callable +def outer(callback: Callable[[], str] | None): + @decorate(callback or fallback) + def inner(): + pass +""", + """ +from collections.abc import Callable +def outer(callback: Callable[[], str] | None): + return lambda value=callback or fallback: value +""", + """ +from collections.abc import Callable +def outer(callback: Callable[[], str] | None): + class Inner(callback or fallback): + pass +""", + """ +from collections.abc import Callable +def outer(callback: Callable[[], str] | None): + return [value for value in (callback or fallback)] +""", + ], + ids=[ + "type-alias", + "imported-type", + "qualified-type", + "annotated", + "legacy-optional", + "inherited-field", + "assignment-flow", + "nested-receiver", + "call-return", + "descriptor-return", + "bare-callable", + "parameterized-local-class", + "aliased-callable-import", + "bool-call", + "final-boolean-operand", + "nested-function-default", + "nested-function-decorator", + "lambda-default", + "nested-class-base", + "comprehension-first-iterator", + ], +) +def test_leaves_each_unsupported_category_unclassified(tmp_path: Path, source: str) -> None: + assert _violations(tmp_path, source) == [] + + +def test_cli_reports_actionable_failure(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + path = tmp_path / "example.py" + path.write_text( + """ +from collections.abc import Callable + +def invoke(callback: Callable[[], str] | None): + return callback or fallback +""", + encoding="utf-8", + ) + + result = checker.main([str(path)]) + captured = capsys.readouterr() + + assert result == 1 + assert "optional object uses truthiness: callback" in captured.out + assert "explicit `is None` or `is not None`" in captured.err diff --git a/tests/test_config.py b/tests/test_config.py index 0eefc367f9..797580f14f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ import gc import os import weakref +from typing import Any, cast import openai import pytest @@ -39,6 +40,17 @@ def test_cc_set_default_openai_client(): assert chat_model._client.api_key == "test_key" # type: ignore +def test_provider_preserves_falsy_default_client(monkeypatch): + class FalsyClient: + def __bool__(self) -> bool: + return False + + client = cast(Any, FalsyClient()) + monkeypatch.setattr(_openai_shared, "get_default_openai_client", lambda: client) + + assert OpenAIProvider()._get_client() is client + + def test_resp_no_default_key_errors(monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) assert os.getenv("OPENAI_API_KEY") is None diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index 483346c8b9..2d0e573c35 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -1558,6 +1558,52 @@ async def _record_rejection( assert rejections == ["rejected-call"] +@pytest.mark.asyncio +async def test_function_resume_reuses_falsy_pending_item() -> None: + class FalsyToolApprovalItem(ToolApprovalItem): + def __bool__(self) -> bool: + return False + + @function_tool(needs_approval=True) + async def approve_me(value: str) -> str: + return value + + tool_call = make_function_tool_call( + approve_me.name, + call_id="pending-function", + arguments='{"value":"a"}', + ) + agent = Agent(name="agent", tools=[approve_me]) + existing_pending = FalsyToolApprovalItem(agent=agent, raw_item=tool_call) + run = ToolRunFunction(tool_call=tool_call, function_tool=approve_me) + pending: list[ToolApprovalItem] = [] + + async def _needs_approval_checker(_run: ToolRunFunction) -> bool: + return True + + async def _record_rejection( + _call_id: str | None, + _tool_call: ResponseFunctionToolCall, + _tool: FunctionTool, + ) -> None: + raise AssertionError("unresolved approval must not be recorded as a rejection") + + selected = await _select_function_tool_runs_for_resume( + [run], + approval_items_by_call_id={tool_call.call_id: existing_pending}, + context_wrapper=make_context_wrapper(), + needs_approval_checker=_needs_approval_checker, + output_exists_checker=lambda _run: False, + record_rejection=_record_rejection, + pending_interruption_adder=pending.append, + pending_item_builder=lambda _run: ToolApprovalItem(agent=agent, raw_item=tool_call), + ) + + assert selected == [] + assert pending == [existing_pending] + assert pending[0] is existing_pending + + @pytest.mark.asyncio async def test_resume_rechecks_rejection_after_function_approval_checker() -> None: """A rejection recorded while the checker waits must prevent another interruption.""" @@ -2135,6 +2181,51 @@ async def _build_rejection(run: ToolRunShellCall, call_id: str) -> RunItem: assert len(rejections) == 1 +@pytest.mark.asyncio +async def test_collect_runs_by_approval_reuses_falsy_pending_item() -> None: + class FalsyToolApprovalItem(ToolApprovalItem): + def __bool__(self) -> bool: + return False + + shell_tool = ShellTool(executor=lambda _req: "ok", needs_approval=True) + shell_call = make_shell_call("pending-shell") + agent = Agent(name="agent") + existing_pending = FalsyToolApprovalItem( + agent=agent, + raw_item=cast(dict[str, Any], shell_call), + tool_name=shell_tool.name, + ) + run = ToolRunShellCall(tool_call=shell_call, shell_tool=shell_tool) + pending: list[ToolApprovalItem] = [] + + async def _build_rejection(_run: ToolRunShellCall, call_id: str) -> RunItem: + return ToolCallOutputItem( + output="rejected", + raw_item={"type": "function_call_output", "call_id": call_id, "output": "rejected"}, + agent=agent, + ) + + async def _needs_approval(_run: ToolRunShellCall) -> bool: + return True + + approved, rejections = await _collect_runs_by_approval( + [run], + call_id_extractor=lambda item: item.tool_call["call_id"], + tool_name_resolver=lambda item: item.shell_tool.name, + rejection_builder=_build_rejection, + context_wrapper=make_context_wrapper(), + approval_items_by_call_id={"pending-shell": existing_pending}, + agent=agent, + pending_interruption_adder=pending.append, + needs_approval_checker=_needs_approval, + ) + + assert approved == [] + assert rejections == [] + assert pending == [existing_pending] + assert pending[0] is existing_pending + + @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) @pytest.mark.asyncio async def test_resume_apply_patch_uses_concurrent_decision_without_reinterrupting( @@ -3229,6 +3320,10 @@ async def test_resume_skips_computer_actions_with_existing_output() -> None: async def test_rebuild_function_runs_handles_pending_and_rejections() -> None: """Rebuilt function runs should surface pending approvals and emit rejections.""" + class FalsyToolApprovalItem(ToolApprovalItem): + def __bool__(self) -> bool: + return False + @function_tool(needs_approval=True) def reject_me(text: str = "nope") -> str: return text @@ -3254,7 +3349,7 @@ def pending_me(text: str = "wait") -> str: } rejected_item = ToolApprovalItem(agent=agent, raw_item=rejected_raw) - pending_item = ToolApprovalItem(agent=agent, raw_item=pending_raw) + pending_item = FalsyToolApprovalItem(agent=agent, raw_item=pending_raw) context_wrapper.reject_tool(rejected_item) run_state = make_state_with_interruptions(agent, [rejected_item, pending_item]) @@ -3284,7 +3379,8 @@ def pending_me(text: str = "wait") -> str: ) assert isinstance(result.next_step, NextStepInterruption) - assert pending_item in result.next_step.interruptions + assert any(item is pending_item for item in result.next_step.interruptions) + assert any(item is pending_item for item in result.new_step_items) rejection_outputs = [ item for item in result.new_step_items diff --git a/tests/test_process_model_response.py b/tests/test_process_model_response.py index ff70db72ac..78049d2c5f 100644 --- a/tests/test_process_model_response.py +++ b/tests/test_process_model_response.py @@ -89,6 +89,27 @@ def test_process_model_response_shell_call_without_tool_raises() -> None: ) +def test_process_model_response_dispatches_falsy_shell_tool() -> None: + class FalsyShellTool(ShellTool): + def __bool__(self) -> bool: + return False + + shell_tool = FalsyShellTool(environment={"type": "container_auto"}) + shell_call = make_shell_call("shell-falsy") + + processed = run_loop.process_model_response( + agent=Agent(name="falsy-shell", tools=[shell_tool]), + all_tools=[shell_tool], + response=_response([shell_call]), + output_schema=None, + handoffs=[], + ) + + assert processed.tools_used == [shell_tool.name] + assert isinstance(processed.new_items[0], ToolCallItem) + assert processed.new_items[0]._resolved_tool_name == shell_tool.name + + def test_process_model_response_sets_title_for_local_mcp_function_tool() -> None: agent = Agent(name="local-mcp", model=FakeModel()) mcp_tool = MCPTool(name="search_docs", inputSchema={}, description=None, title="Search Docs") @@ -376,6 +397,26 @@ def test_process_model_response_queues_apply_patch_call() -> None: assert converted_call.get("type") == "apply_patch_call" +def test_process_model_response_dispatches_falsy_apply_patch_tool() -> None: + class FalsyApplyPatchTool(ApplyPatchTool): + def __bool__(self) -> bool: + return False + + apply_patch_tool = FalsyApplyPatchTool(editor=RecordingEditor()) + apply_patch_call = make_apply_patch_dict("apply-falsy") + + processed = run_loop.process_model_response( + agent=Agent(name="falsy-apply", tools=[apply_patch_tool]), + all_tools=[apply_patch_tool], + response=_response([apply_patch_call]), + output_schema=None, + handoffs=[], + ) + + assert processed.tools_used == [apply_patch_tool.name] + assert processed.apply_patch_calls[0].apply_patch_tool is apply_patch_tool + + def test_process_model_response_queues_hosted_apply_patch_from_custom_tool_call() -> None: editor = RecordingEditor() apply_patch_tool = ApplyPatchTool(editor=editor) diff --git a/tests/test_run_context_wrapper.py b/tests/test_run_context_wrapper.py index 6623675a19..a023feeea6 100644 --- a/tests/test_run_context_wrapper.py +++ b/tests/test_run_context_wrapper.py @@ -10,6 +10,11 @@ def __str__(self) -> str: raise RuntimeError("broken") +class FalsyToolApprovalItem(ToolApprovalItem): + def __bool__(self) -> bool: + return False + + def test_run_context_to_str_or_none_handles_errors() -> None: assert RunContextWrapper._to_str_or_none("ok") == "ok" assert RunContextWrapper._to_str_or_none(123) == "123" @@ -85,6 +90,40 @@ def test_run_context_honors_global_approval_and_rejection() -> None: assert wrapper.is_tool_approved("tool_call", "call-3") is False +def test_run_context_uses_falsy_pending_item_for_sticky_decisions() -> None: + approval = FalsyToolApprovalItem( + agent=make_agent(), + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": "call-1", + "arguments": "{}", + }, + ) + + approved: RunContextWrapper[None] = RunContextWrapper(context=None) + approved.approve_tool(approval, always_approve=True) + assert ( + approved.get_approval_status( + "tool_call", + "call-2", + existing_pending=approval, + ) + is True + ) + + rejected: RunContextWrapper[None] = RunContextWrapper(context=None) + rejected.reject_tool(approval, always_reject=True, rejection_message="Denied") + assert ( + rejected.get_rejection_message( + "tool_call", + "call-2", + existing_pending=approval, + ) + == "Denied" + ) + + def test_run_context_stores_per_call_rejection_messages() -> None: wrapper: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) agent = make_agent() diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 2754a2c72e..9552f9e9fb 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -130,6 +130,7 @@ ToolOutputGuardrail, ToolOutputGuardrailResult, ) +from agents.tracing.traces import TraceState from agents.usage import Usage from tests.utils.factories import TestSessionState @@ -303,6 +304,37 @@ def set_last_processed_response( class TestRunState: """Test RunState initialization, serialization, and core functionality.""" + @pytest.mark.asyncio + async def test_results_to_state_preserve_falsy_trace_state(self) -> None: + class FalsyTraceState(TraceState): + def __bool__(self) -> bool: + return False + + trace_state = FalsyTraceState(trace_id="trace_falsy") + + model = FakeModel() + model.set_next_output([get_final_output_message("done")]) + result = await Runner.run(Agent(name="test", model=model), "input") + result._trace_state = trace_state + + restored = result.to_state()._trace_state + assert isinstance(restored, FalsyTraceState) + assert restored.trace_id == "trace_falsy" + + streaming_model = FakeModel() + streaming_model.set_next_output([get_final_output_message("done")]) + streaming_result = Runner.run_streamed( + Agent(name="streaming-test", model=streaming_model), + "input", + ) + async for _ in streaming_result.stream_events(): + pass + streaming_result._trace_state = trace_state + + streaming_restored = streaming_result.to_state()._trace_state + assert isinstance(streaming_restored, FalsyTraceState) + assert streaming_restored.trace_id == "trace_falsy" + def test_initializes_with_default_values(self): """Test that RunState initializes with correct default values.""" context = RunContextWrapper(context={"foo": "bar"}) @@ -319,6 +351,18 @@ def test_initializes_with_default_values(self): assert state._context is not None assert state._context.context == {"foo": "bar"} + def test_to_json_preserves_falsy_processed_response(self) -> None: + class FalsyProcessedResponse(ProcessedResponse): + def __bool__(self) -> bool: + return False + + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(Agent(name="test"), context=context) + processed = make_processed_response() + state._last_processed_response = FalsyProcessedResponse(**vars(processed)) + + assert state.to_json()["last_processed_response"] is not None + def test_set_tool_use_tracker_snapshot_filters_non_strings(self): """Test that set_tool_use_tracker_snapshot filters out non-string agent names and tools.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) @@ -501,6 +545,31 @@ async def test_from_json_restores_bare_duplicate_name_current_agent_via_identity restored = await RunState.from_json(root, json_data) assert restored._current_agent is second + @pytest.mark.asyncio + async def test_from_json_restores_falsy_current_agent_via_identity_map(self): + class FalsyAgent(Agent[Any]): + def __bool__(self) -> bool: + return False + + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + first = Agent(name="duplicate", instructions="zeta") + second = FalsyAgent(name="duplicate", instructions="alpha") + root = Agent(name="triage", handoffs=[first, second]) + first.handoffs = [root] + second.handoffs = [root] + + state = make_state(root, context=context, original_input="input1", max_turns=2) + state._current_agent = second + + json_data = state.to_json() + assert json_data["current_agent"] == { + "name": "duplicate", + "identity": "duplicate#2", + } + + restored = await RunState.from_json(root, json_data) + assert restored._current_agent is second + def test_build_agent_identity_map_uses_tool_use_behavior_for_duplicate_names(self) -> None: """Duplicate-name identities should stay stable when only tool_use_behavior differs.""" @@ -6130,10 +6199,14 @@ async def test_deserialize_processed_response_shell_action_with_validation_error context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) agent = Agent(name="TestAgent") + class FalsyShellTool(ShellTool): + def __bool__(self) -> bool: + return False + async def shell_executor(request: Any) -> Any: return {"output": "test output"} - shell_tool = ShellTool(executor=shell_executor) + shell_tool = FalsyShellTool(executor=shell_executor) agent.tools = [shell_tool] # Create invalid tool_call_data that will cause ValidationError @@ -6168,6 +6241,7 @@ async def shell_executor(request: Any) -> Any: assert len(result.shell_calls) == 1 # shell_call should have raw tool_call_data (dict) instead of validated LocalShellCall assert isinstance(result.shell_calls[0].tool_call, dict) + assert result.shell_calls[0].shell_tool is shell_tool async def test_deserialize_processed_response_apply_patch_action_with_exception(self): """Test deserialization of ProcessedResponse with apply patch action Exception.""" @@ -8811,7 +8885,11 @@ async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: @pytest.mark.asyncio async def test_hosted_mcp_approval_request_restores_matching_server_tool() -> None: - server_a = HostedMCPTool( + class FalsyHostedMCPTool(HostedMCPTool): + def __bool__(self) -> bool: + return False + + server_a = FalsyHostedMCPTool( tool_config=Mcp( type="mcp", server_label="server-a", diff --git a/tests/test_tool_output_conversion.py b/tests/test_tool_output_conversion.py index 292c7a0414..79261b58bd 100644 --- a/tests/test_tool_output_conversion.py +++ b/tests/test_tool_output_conversion.py @@ -28,6 +28,17 @@ def test_tool_call_output_item_text_model() -> None: assert item["text"] == "hello" +def test_tool_call_output_item_list_preserves_falsy_structured_model() -> None: + class FalsyToolOutputText(ToolOutputText): + def __bool__(self) -> bool: + return False + + call = _make_tool_call() + payload = ItemHelpers.tool_call_output_item(call, [FalsyToolOutputText(text="hello")]) + + assert payload["output"] == [{"type": "input_text", "text": "hello"}] + + def test_tool_call_output_item_image_model() -> None: call = _make_tool_call() out = ToolOutputImage(image_url="data:image/png;base64,AAAA") diff --git a/tests/test_tool_use_tracker.py b/tests/test_tool_use_tracker.py index 9e6cf4c850..09134ccdae 100644 --- a/tests/test_tool_use_tracker.py +++ b/tests/test_tool_use_tracker.py @@ -17,6 +17,11 @@ from .test_responses import get_function_tool_call +class FalsyAgent(Agent[Any]): + def __bool__(self) -> bool: + return False + + def test_tool_use_tracker_as_serializable_uses_agent_map_or_runtime_snapshot() -> None: tracker = AgentToolUseTracker() tracker.agent_map = {"agent-a": {"tool-b", "tool-a"}} @@ -40,7 +45,7 @@ def test_tool_use_tracker_from_and_serialize_snapshots() -> None: def test_serialize_and_hydrate_tool_use_tracker_preserves_duplicate_agent_identity() -> None: - second = Agent(name="duplicate") + second = FalsyAgent(name="duplicate") first = Agent(name="duplicate", handoffs=[second]) second.handoffs = [first] diff --git a/tests/tracing/test_trace_context.py b/tests/tracing/test_trace_context.py index 56f2ad6053..507d2e9473 100644 --- a/tests/tracing/test_trace_context.py +++ b/tests/tracing/test_trace_context.py @@ -3,10 +3,13 @@ import logging from uuid import uuid4 +from openai.types.responses import Response + import agents.tracing.traces as trace_module from agents.tracing import TracingConfig, set_tracing_disabled, trace -from agents.tracing.context import create_trace_for_run +from agents.tracing.context import TraceCtxManager, create_trace_for_run from agents.tracing.scope import Scope +from agents.tracing.span_data import ResponseSpanData from agents.tracing.traces import ( NoOpTrace, ReattachedTrace, @@ -54,6 +57,16 @@ def _mark_trace_as_started( return trace_state +def test_response_span_data_preserves_falsy_response_id() -> None: + class FalsyResponse(Response): + def __bool__(self) -> bool: + return False + + response = FalsyResponse.model_construct(id="resp_falsy") + + assert ResponseSpanData(response=response).export()["response_id"] == "resp_falsy" + + def test_create_trace_for_run_reattaches_matching_started_trace() -> None: trace_state = _mark_trace_as_started(tracing_api_key="trace-key") @@ -235,6 +248,44 @@ def test_create_trace_for_run_uses_existing_current_trace() -> None: assert created is None +def test_trace_context_manager_starts_and_finishes_falsy_trace(monkeypatch) -> None: + class FalsyTrace: + def __init__(self) -> None: + self.started = False + self.finished = False + + def __bool__(self) -> bool: + return False + + def start(self, *, mark_as_current: bool) -> None: + assert mark_as_current is True + self.started = True + + def finish(self, *, reset_current: bool) -> None: + assert reset_current is True + self.finished = True + + falsy_trace = FalsyTrace() + monkeypatch.setattr( + "agents.tracing.context.create_trace_for_run", + lambda **kwargs: falsy_trace, + ) + + manager = TraceCtxManager( + workflow_name="workflow", + trace_id=None, + group_id=None, + metadata=None, + tracing=None, + disabled=False, + ) + with manager: + pass + + assert falsy_trace.started is True + assert falsy_trace.finished is True + + def test_trace_logs_warning_when_current_trace_exists( caplog, ) -> None: @@ -250,6 +301,32 @@ def test_trace_logs_warning_when_current_trace_exists( assert "Trace already exists" in caplog.text +def test_trace_logs_warning_when_current_trace_is_falsy( + monkeypatch, + caplog, +) -> None: + class FalsyTrace: + def __bool__(self) -> bool: + return False + + created_trace = object() + + class Provider: + def get_current_trace(self): + return FalsyTrace() + + def create_trace(self, **_kwargs): + return created_trace + + monkeypatch.setattr("agents.tracing.create.get_trace_provider", lambda: Provider()) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + result = trace(workflow_name="inner") + + assert result is created_trace + assert "Trace already exists" in caplog.text + + def test_started_trace_id_cache_is_bounded(monkeypatch) -> None: _clear_started_trace_ids() monkeypatch.setattr(trace_module, "_MAX_STARTED_TRACE_IDS", 2) diff --git a/tests/tracing/test_tracing_env_disable.py b/tests/tracing/test_tracing_env_disable.py index e49b11ea2f..2c62de1bf2 100644 --- a/tests/tracing/test_tracing_env_disable.py +++ b/tests/tracing/test_tracing_env_disable.py @@ -132,3 +132,32 @@ def test_noop_current_span_id_does_not_become_parent_id(): Scope.reset_current_trace(trace_token) assert isinstance(span, NoOpSpan) + + +def test_falsy_current_span_becomes_parent() -> None: + class FalsySpan(SpanImpl[AgentSpanData]): + def __bool__(self) -> bool: + return False + + Scope.set_current_trace(None) + Scope.set_current_span(None) + provider = DefaultTraceProvider() + trace = provider.create_trace("active", trace_id="trace_123") + parent = FalsySpan( + trace_id="trace_123", + span_id="span_parent", + parent_id=None, + processor=provider._multi_processor, + span_data=AgentSpanData(name="parent"), + tracing_api_key=None, + ) + trace_token = Scope.set_current_trace(trace) + span_token = Scope.set_current_span(parent) + try: + child = provider.create_span(AgentSpanData(name="child")) + finally: + Scope.reset_current_span(span_token) + Scope.reset_current_trace(trace_token) + + assert isinstance(child, SpanImpl) + assert child.parent_id == "span_parent" diff --git a/tests/voice/test_openai_model_provider.py b/tests/voice/test_openai_model_provider.py index 2d9de3ae24..9906b25b51 100644 --- a/tests/voice/test_openai_model_provider.py +++ b/tests/voice/test_openai_model_provider.py @@ -1,9 +1,12 @@ # Tests for the OpenAI voice model provider (OpenAIVoiceModelProvider). +from typing import Any, cast + import openai import pytest from agents.exceptions import UserError +from agents.models import _openai_shared from agents.voice.models.openai_model_provider import OpenAIVoiceModelProvider @@ -27,3 +30,14 @@ def test_voice_provider_accepts_client_without_conflicting_args(): client = openai.AsyncOpenAI(api_key="test_key") provider = OpenAIVoiceModelProvider(openai_client=client) assert provider._get_client() is client + + +def test_voice_provider_preserves_falsy_default_client(monkeypatch): + class FalsyClient: + def __bool__(self) -> bool: + return False + + client = cast(Any, FalsyClient()) + monkeypatch.setattr(_openai_shared, "get_default_openai_client", lambda: client) + + assert OpenAIVoiceModelProvider()._get_client() is client diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 4125d0f27b..98275e5e07 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -68,6 +68,34 @@ def test_streamed_audio_result_odd_length_buffer_int16() -> None: assert transformed.tolist() == [1] +@pytest.mark.asyncio +async def test_streamed_audio_result_raises_falsy_task_exception() -> None: + class FalsyError(RuntimeError): + def __bool__(self) -> bool: + return False + + result = StreamedAudioResult( + FakeTTS(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + error = FalsyError("failed") + + async def fail() -> None: + raise error + + task = asyncio.create_task(fail()) + result._tasks.append(task) + await asyncio.gather(task, return_exceptions=True) + await result._queue.put(cast(Any, None)) + + with pytest.raises(FalsyError) as exc_info: + async for _ in result.stream(): + pass + + assert exc_info.value is error + + @pytest.mark.asyncio async def test_streamed_audio_result_propagates_consumer_cancellation(monkeypatch) -> None: result = StreamedAudioResult( From f5d20e5e2f24e53c5d2c6c6b72daab2f4be777c8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 8 Aug 2026 22:44:19 +0900 Subject: [PATCH 235/473] docs: improve translation source clarity (#4306) --- AGENTS.md | 3 + docs/agents.md | 16 +- docs/config.md | 12 +- docs/context.md | 6 +- docs/examples.md | 22 +- docs/guardrails.md | 10 +- docs/handoffs.md | 8 +- docs/human_in_the_loop.md | 14 +- docs/index.md | 8 +- docs/mcp.md | 10 +- docs/models/index.md | 28 +- docs/multi_agent.md | 16 +- docs/realtime/guide.md | 18 +- docs/realtime/quickstart.md | 4 +- docs/realtime/transport.md | 6 +- docs/release.md | 28 +- docs/results.md | 14 +- docs/running_agents.md | 26 +- docs/sandbox/clients.md | 20 +- docs/sandbox/guide.md | 20 +- docs/sandbox/memory.md | 4 +- docs/sandbox_agents.md | 8 +- docs/scripts/translate_docs.py | 311 ++++++++++++++++++----- docs/sessions/advanced_sqlite_session.md | 6 +- docs/sessions/index.md | 18 +- docs/sessions/sqlalchemy_session.md | 2 +- docs/streaming.md | 6 +- docs/tools.md | 16 +- docs/tracing.md | 16 +- docs/usage.md | 8 +- docs/visualization.md | 4 +- docs/voice/pipeline.md | 2 +- docs/voice/quickstart.md | 4 +- 33 files changed, 447 insertions(+), 247 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a85124533f..aa00d9547f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,9 @@ Treat the parameter and dataclass field order of exported runtime APIs as a comp ### Platform, Docs, and Security Review - Documentation is published to the live site, so coordinate SDK behavior changes and docs carefully. If docs describe behavior that is not released yet, either delay the docs change until the SDK release is available or split it into a follow-up PR. +- Treat translation-safe English as a documentation compatibility requirement. In new or materially rewritten translatable prose under `docs/` (excluding generated API reference pages), state the actor, scope, ownership, ordering, modality, and lifecycle boundary explicitly whenever they affect the meaning. Use exact API identifiers in inline code, and replace ambiguous pronouns, overloaded nouns, or shorthand when a small clarification can prevent a materially different translation. Do not change the documented behavior merely to make a sentence easier to translate. +- Before declaring new or materially rewritten translatable prose complete, run `docs/scripts/translate_docs.py --mode full --file ` for every affected English page, inspect the generated Japanese, Korean, and Chinese against the English source, and revise the English or the narrowly applicable translation controls until no decision-relevant ambiguity, scope drift, identifier corruption, or unstable terminology remains. A successful translation command without semantic review is not sufficient. Pure link, formatting, or typo corrections that do not change translatable meaning may skip this translation review. +- Do not hand-edit or commit generated files under `docs/ja`, `docs/ko`, or `docs/zh`; restore them after translation review. Add or change a fixed translation mapping only when actual cross-document translation evidence shows that one stable target term is correct across contexts. Prefer contextual guidance and established target-language developer terminology, including standard English terms, over a large or rigid mapping table. If the required translation credentials or review capability are unavailable, report the validation as incomplete instead of claiming the documentation change is ready. - Treat runnable docs snippets as API compatibility checks. Before adding OpenAI API, provider, Responses, Realtime, WebSocket, or SDK constructor examples, verify the shown arguments and call shape against the actual implementation. - When adding or updating code in `examples/` or runnable `docs/` snippets, import Agents SDK decorators from `agents.decorators`. Prefer `tool` over `function_tool`; keep non-decorator SDK imports on their existing public import paths. - Do not let untrusted sandbox manifests opt themselves out of host filesystem or base-directory boundaries. Escape hatches for local source materialization must be controlled by trusted application code at the call site, not by serialized manifest data. diff --git a/docs/agents.md b/docs/agents.md index f5946643bb..7d36e5245a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -2,7 +2,7 @@ Agents are the core building block in your apps. An agent is a large language model (LLM) configured with instructions, tools, and optional runtime behavior such as handoffs, guardrails, and structured outputs. -Use this page when you want to define or customize a single plain `Agent`. If you are deciding how multiple agents should collaborate, read [Agent orchestration](multi_agent.md). If the agent should run inside an isolated workspace with manifest-defined files and sandbox-native capabilities, read [Sandbox agent concepts](sandbox/guide.md). +Use this page when you want to define or customize a single base `Agent` rather than a `SandboxAgent`. If you are deciding how multiple agents should collaborate, read [Agent orchestration](multi_agent.md). If the agent should run inside an isolated workspace with manifest-defined files and sandbox-native capabilities, read [Sandbox agent concepts](sandbox/guide.md). The SDK uses the Responses API by default for OpenAI models, but the distinction here is orchestration: `Agent` plus `Runner` lets the SDK manage turns, tools, guardrails, handoffs, and sessions for you. If you want to own that loop yourself, use the Responses API directly instead. @@ -35,8 +35,8 @@ The most common properties of an agent are: | `model` | no | Which LLM to use. See [Models](models/index.md). | | `model_settings` | no | Model tuning parameters such as `temperature`, `top_p`, and `tool_choice`. | | `tools` | no | Tools the agent can call. See [Tools](tools.md). | -| `mcp_servers` | no | MCP-backed tools for the agent. See the [MCP guide](mcp.md). | -| `mcp_config` | no | Fine-tune how MCP tools are prepared, such as strict schema conversion and MCP failure formatting. See the [MCP guide](mcp.md#agent-level-mcp-configuration). | +| `mcp_servers` | no | MCP servers that provide MCP-backed tools to the agent. See the [MCP guide](mcp.md). | +| `mcp_config` | no | Fine-tune how MCP tools are prepared, such as converting their schemas to strict mode and formatting MCP failures. See the [MCP guide](mcp.md#agent-level-mcp-configuration). | | `input_guardrails` | no | Guardrails that run on the first user input for this agent chain. See [Guardrails](guardrails.md). | | `output_guardrails` | no | Guardrails that run on the final output for this agent. See [Guardrails](guardrails.md). | | `output_type` | no | Structured output type instead of plain text. See [Output types](#output-types). | @@ -65,7 +65,7 @@ Everything in this section applies to `Agent`. `SandboxAgent` builds on the same ## Prompt templates -You can reference a prompt template created in the OpenAI platform by setting `prompt`. This works with OpenAI models using the Responses API. +You can reference a prompt template created in the OpenAI platform by setting `prompt`. This works when OpenAI models are accessed through the Responses API. To use it, please: @@ -215,7 +215,7 @@ customer_facing_agent = Agent( ### Handoffs -Handoffs are sub‑agents the agent can delegate to. When a handoff occurs, the delegated agent receives the conversation history and takes over the conversation. This pattern enables modular, specialized agents that excel at a single task. Read more in the [handoffs](handoffs.md) documentation. +Configured handoff targets are sub‑agents to which the agent can delegate. When a handoff occurs, the delegated agent receives the conversation history and takes over the conversation. This pattern enables modular, specialized agents that excel at a single task. Read more in the [handoffs](handoffs.md) documentation. ```python from agents import Agent @@ -269,12 +269,12 @@ The callback context also changes depending on the event: Typical hook timing: -- `on_agent_start` / `on_agent_end`: when a specific agent begins or finishes producing a final output. +- `on_agent_start`: when a specific agent begins running; `on_agent_end`: when that agent finishes producing a final output. - `on_llm_start` / `on_llm_end`: immediately around each model call. - `on_tool_start` / `on_tool_end`: around each local tool invocation. For function tools, the hook `context` is typically a `ToolContext`, so you can inspect tool-call metadata such as `tool_call_id`. - `on_handoff`: when control moves from one agent to another. -Use `RunHooks` when you want a single observer for the whole workflow, and `AgentHooks` when one agent needs custom side effects. +Use `RunHooks` when you want a single observer for the whole workflow, and `AgentHooks` when you want lifecycle callbacks scoped to a specific agent. ```python from agents import Agent, RunHooks, Runner @@ -396,7 +396,7 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`: A custom function that processes tool results and decides whether to stop or continue with the LLM. +- `ToolsToFinalOutputFunction`: A custom function that processes tool results and decides whether to end the run with a final output or continue processing with the LLM. ```python from agents import Agent, FunctionToolResult, RunContextWrapper diff --git a/docs/config.md b/docs/config.md index cbdbdbd539..1d95561082 100644 --- a/docs/config.md +++ b/docs/config.md @@ -14,7 +14,7 @@ If you need to configure a specific agent or run instead, start with: ## Configuration objects and dictionaries -SDK-owned configuration parameters generally accept either their typed settings object or a dictionary containing the same fields. This applies across agent, run, model, session, sandbox, and voice configuration boundaries whose type annotations include a dictionary. Nested SDK-owned settings can also use dictionaries. +Configuration parameters defined by the SDK generally accept either their typed settings object or a dictionary containing the same fields. This applies across agent, run, model, session, sandbox, and voice configuration boundaries whose type annotations include a dictionary. Nested settings types defined by the SDK can also use dictionaries. ```python from agents import Agent @@ -29,7 +29,7 @@ agent = Agent( ) ``` -The SDK normalizes these dictionaries into the corresponding settings objects. Unknown fields in SDK-owned dataclass configurations raise `TypeError`, which helps catch misspelled option names early. Check the parameter's type annotation or API reference to confirm whether a specific boundary accepts a dictionary. +The SDK normalizes these dictionaries into the corresponding settings objects. Unknown fields in dataclass configuration types defined by the SDK raise `TypeError`, which helps catch misspelled option names early. Check the parameter's type annotation or API reference to confirm whether a specific boundary accepts a dictionary. ## API keys and clients @@ -68,7 +68,7 @@ set_default_openai_api("chat_completions") ## OpenAI provider defaults -OpenAI-backed providers also read SDK-wide defaults when they resolve model names. Use [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] to make OpenAI Responses models use websocket transport by default: +Providers that use the SDK's OpenAI backend also read SDK-wide defaults when they map model-name strings to models. Use [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] to make OpenAI Responses models use websocket transport by default: ```python from agents import set_default_openai_responses_transport @@ -76,7 +76,7 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -This affects OpenAI Responses models resolved by the default OpenAI provider. For provider-level setup, connection reuse, keepalive options, and custom websocket endpoints, see [Responses WebSocket transport](models/index.md#responses-websocket-transport). +This affects OpenAI Responses models that result when the default OpenAI provider resolves a model name. For provider-level setup, connection reuse, keepalive options, and custom websocket endpoints, see [Responses WebSocket transport](models/index.md#responses-websocket-transport). If your OpenAI setup expects provider-level agent registration metadata, configure a default harness ID once at startup: @@ -96,7 +96,7 @@ set_default_openai_agent_registration( ) ``` -If no SDK default is set, OpenAI-backed providers fall back to the `OPENAI_AGENT_HARNESS_ID` environment variable. When a harness ID is configured, the SDK adds it to trace metadata as `agent_harness_id` unless that key is already present in `RunConfig.trace_metadata`. +If no SDK default is set, providers that use the SDK's OpenAI backend fall back to the `OPENAI_AGENT_HARNESS_ID` environment variable. When a harness ID is configured, the SDK adds it to trace metadata as `agent_harness_id` unless that key is already present in `RunConfig.trace_metadata`. ## Tracing @@ -219,4 +219,4 @@ export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -These flags also control whether affected failures retain payload-bearing diagnostic details. For example, with tool-data redaction enabled, invalid function-tool arguments raise a generic `ModelBehaviorError` without chaining the underlying validation error. Setting either variable to `0` can expose raw model or tool data in logs, exception messages, exception chains, and other diagnostic context, so enable it only in a controlled development environment. +These flags also control whether affected failures retain payload-bearing diagnostic details. For example, with tool-data redaction enabled, invalid arguments for a `FunctionTool` raise a generic `ModelBehaviorError` without chaining the underlying validation error. Setting either variable to `0` can expose raw model or tool data in logs, exception messages, exception chains, and other diagnostic context, so enable it only in a controlled development environment. diff --git a/docs/context.md b/docs/context.md index 0ba5a99659..fcd5e9034c 100644 --- a/docs/context.md +++ b/docs/context.md @@ -11,9 +11,9 @@ This is represented via the [`RunContextWrapper`][agents.run_context.RunContextW 1. You create any Python object you want. A common pattern is to use a dataclass or a Pydantic object. 2. You pass that object to the various run methods (e.g. `Runner.run(..., context=whatever)`). -3. All your tool calls, lifecycle hooks etc will be passed a wrapper object, `RunContextWrapper[T]`, where `T` represents your context object type which you can access via `wrapper.context`. +3. All your tool calls, lifecycle hooks etc will be passed a wrapper object, `RunContextWrapper[T]`, where `T` represents the type of your context object; the object itself is available via `wrapper.context`. -For some runtime-specific callbacks, the SDK may pass a more specialized subclass of `RunContextWrapper[T]`. For example, function-tool lifecycle hooks typically receive `ToolContext`, which also exposes tool-call metadata like `tool_call_id`, `tool_name`, and `tool_arguments`. +For some runtime-specific callbacks, the SDK may pass a more specialized subclass of `RunContextWrapper[T]`. For example, lifecycle hooks for `FunctionTool` instances typically receive `ToolContext`, which also exposes tool-call metadata like `tool_call_id`, `tool_name`, and `tool_arguments`. The **most important** thing to be aware of: every agent, tool function, lifecycle etc for a given agent run must use the same _type_ of context. @@ -142,5 +142,5 @@ When an LLM is called, the **only** data it can see is from the conversation his 1. You can add it to the Agent `instructions`. This is also known as a "system prompt" or "developer message". System prompts can be static strings, or they can be dynamic functions that receive the context and output a string. This is a common tactic for information that is always useful (for example, the user's name or the current date). 2. Add it to the `input` when calling the `Runner.run` functions. This is similar to the `instructions` tactic, but allows you to have messages that are lower in the [chain of command](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command). -3. Expose it via function tools. This is useful for _on-demand_ context - the LLM decides when it needs some data, and can call the tool to fetch that data. +3. Expose it through `FunctionTool` instances. This is useful for _on-demand_ context - the LLM decides when it needs some data, and can call the tool to fetch that data. 4. Use retrieval or web search. These are special tools that are able to fetch relevant data from files or databases (retrieval), or from the web (web search). This is useful for "grounding" the response in relevant contextual data. diff --git a/docs/examples.md b/docs/examples.md index 54f605499f..d10079c8ee 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,6 +1,6 @@ # Examples -Check out a variety of sample implementations of the SDK in the examples section of the [repo](https://github.com/openai/openai-agents-python/tree/main/examples). The examples are organized into several categories that demonstrate different patterns and capabilities. +Check out a variety of sample implementations that use the SDK in the examples section of the [repo](https://github.com/openai/openai-agents-python/tree/main/examples). The examples are organized into several categories that demonstrate different patterns and capabilities. ## Categories @@ -12,7 +12,7 @@ Check out a variety of sample implementations of the SDK in the examples section - Agents as tools with structured input parameters (`examples/agent_patterns/agents_as_tools_structured.py`) - Parallel agent execution - Conditional tool usage - - Forcing tool use with different behaviors (`examples/agent_patterns/forcing_tool_use.py`) + - Forcing tool use while demonstrating different tool-use behaviors (`examples/agent_patterns/forcing_tool_use.py`) - Input/output guardrails - LLM as a judge - Routing @@ -25,11 +25,11 @@ Check out a variety of sample implementations of the SDK in the examples section - Hello world examples (Default model, GPT-5, open-weight model) - Agent lifecycle management - - Run hooks and agent hooks lifecycle example (`examples/basic/lifecycle_example.py`) + - Agent and run lifecycle example using `RunHooks` and `AgentHooks` (`examples/basic/lifecycle_example.py`) - Dynamic system prompts - Basic tool usage (`examples/basic/tools.py`) - Tool input/output guardrails (`examples/basic/tool_guardrails.py`) - - Image tool output (`examples/basic/image_tool_output.py`) + - Returning an image as tool output (`examples/basic/image_tool_output.py`) - Streaming outputs (text, items, function call args) - Responses websocket transport with a shared session helper across turns (`examples/basic/stream_ws.py`) - Prompt templates @@ -42,7 +42,7 @@ Check out a variety of sample implementations of the SDK in the examples section - **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** Example customer service system for an airline. -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** A financial research agent that demonstrates structured research workflows with agents and tools for financial data analysis. +- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** A financial research agent that demonstrates structured research workflows for financial data analysis using agents and tools. - **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** Practical examples of agent handoffs with message filtering, including: @@ -54,7 +54,7 @@ Check out a variety of sample implementations of the SDK in the examples section - Simple hosted MCP without approval (`examples/hosted_mcp/simple.py`) - MCP connectors such as Google Calendar (`examples/hosted_mcp/connectors.py`) - Human-in-the-loop with interruption-based approvals (`examples/hosted_mcp/human_in_the_loop.py`) - - On-approval callback for MCP tool calls (`examples/hosted_mcp/on_approval.py`) + - Callback for MCP tool approval requests (`examples/hosted_mcp/on_approval.py`) - **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** Learn how to build agents with MCP (Model Context Protocol), including: @@ -67,7 +67,7 @@ Check out a variety of sample implementations of the SDK in the examples section - Streamable HTTP remote connection (`examples/mcp/streamable_http_remote_example`) - Custom HTTP client factory for Streamable HTTP (`examples/mcp/streamablehttp_custom_client_example`) - Prefetching all MCP tools with `MCPUtil.get_all_function_tools` (`examples/mcp/get_all_mcp_tools_example`) - - MCPServerManager with FastAPI (`examples/mcp/manager_example`) + - Using `MCPServerManager` in a FastAPI application (`examples/mcp/manager_example`) - MCP tool filtering (`examples/mcp/tool_filter_example`) - **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** Examples of different memory implementations for agents, including: @@ -94,7 +94,7 @@ Check out a variety of sample implementations of the SDK in the examples section - Web application patterns with structured text and image messages - Command-line audio loops and playback handling - Twilio Media Streams integration over WebSocket - - Twilio SIP integration using Realtime Calls API attach flows + - Twilio SIP integration using the Realtime Calls API's `attach` flows - **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** Examples demonstrating how to work with reasoning content, including: @@ -112,7 +112,7 @@ Check out a variety of sample implementations of the SDK in the examples section - Sandbox memory and snapshot resume (`examples/sandbox/memory.py`) - Sandbox agents exposed as tools (`examples/sandbox/sandbox_agents_as_tools.py`) -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** Learn how to implement OAI hosted tools and experimental Codex tooling such as: +- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** Learn how to implement OpenAI-hosted tools and experimental Codex tooling. Examples include: - Web search and web search with filters - File search @@ -123,11 +123,11 @@ Check out a variety of sample implementations of the SDK in the examples section - Hosted container shell with inline skills (`examples/tools/container_shell_inline_skill.py`) - Hosted container shell with skill references (`examples/tools/container_shell_skill_reference.py`) - Local shell with local skills (`examples/tools/local_shell_skill.py`) - - Tool search with namespaces and deferred tools (`examples/tools/tool_search.py`) + - Tool search with namespaces and tools that use deferred loading (`examples/tools/tool_search.py`) - Programmatic Tool Calling with concurrent structured tool calls (`examples/tools/programmatic_tool_calling.py`) - Computer use - Image generation - Experimental Codex tool workflows (`examples/tools/codex.py`) - - Experimental Codex same-thread workflows (`examples/tools/codex_same_thread.py`) + - Experimental Codex workflows that reuse the same Codex conversation thread (`examples/tools/codex_same_thread.py`) - **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** See examples of voice agents, using our TTS and STT models, including streamed voice examples. diff --git a/docs/guardrails.md b/docs/guardrails.md index ac95e748d1..70bb0d7e3b 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -1,6 +1,6 @@ # Guardrails -Guardrails enable you to do checks and validations of user input and agent output. For example, imagine you have an agent that uses a very smart (and hence slow/expensive) model to help with customer requests. You wouldn't want malicious users to ask the model to help them with their math homework. So, you can run a guardrail with a fast/cheap model. If the guardrail detects malicious usage, it can immediately raise an error and prevent the expensive model from running, saving you time and money (**when using blocking guardrails; for parallel guardrails, the expensive model may have already started running before the guardrail completes. See "Execution modes" below for details**). +Guardrails enable you to do checks and validations of user input and agent output. For example, imagine you have an agent that uses a very smart (and hence slow/expensive) model to help with customer requests. You wouldn't want malicious users to ask the model to help them with their math homework. So, you can run a guardrail with a fast/cheap model. If the guardrail detects malicious usage, it can immediately raise an error, saving time and money. Blocking execution guarantees that the expensive model does not start; with parallel execution, the expensive model may already have started before the guardrail completes. See "Execution modes" below for details. There are two kinds of guardrails: @@ -15,7 +15,7 @@ Guardrails are attached to agents and tools, but they do not all run at the same - **Output guardrails** run only for the agent that produces the final output. - **Tool guardrails** run on every custom function-tool invocation, with input guardrails before execution and output guardrails after execution. -If you need checks around each custom function-tool call in a workflow that includes managers, handoffs, or delegated specialists, use tool guardrails instead of relying only on agent-level input/output guardrails. +If you need checks before and/or after each custom function-tool call in a workflow that includes managers, handoffs, or delegated specialists, use tool guardrails instead of relying only on agent-level input/output guardrails. ## Input guardrails @@ -33,7 +33,7 @@ Input guardrails run in 3 steps: Input guardrails support two execution modes: -- **Parallel execution** (default, `run_in_parallel=True`): The guardrail runs concurrently with the agent's execution. This provides the best latency since both start at the same time. However, if the guardrail fails, the agent may have already consumed tokens and executed tools before being cancelled. +- **Parallel execution** (default, `run_in_parallel=True`): The guardrail runs concurrently with the agent's execution. This provides the best latency since both start at the same time. However, if the guardrail's tripwire is triggered, the agent may have already consumed tokens and executed tools before being cancelled. - **Blocking execution** (`run_in_parallel=False`): The guardrail runs and completes *before* the agent starts. If the guardrail tripwire is triggered, the agent never executes, preventing token consumption and tool execution. This is ideal for cost optimization and when you want to avoid potential side effects from tool calls. @@ -53,7 +53,7 @@ Output guardrails run in 3 steps: ## Tool guardrails -Tool guardrails wrap **function tools** and let you validate or block tool calls before and after execution. They are configured on the tool itself and run every time that tool is invoked. +Tool guardrails wrap **`FunctionTool` instances** and let you validate or block calls to those tools before and after execution. They are configured on the tool itself and run every time that tool is invoked. - Input tool guardrails run before the tool executes and can skip the call, replace the output with a message, or raise a tripwire. - Output tool guardrails run after the tool executes and can replace the output or raise a tripwire. @@ -68,7 +68,7 @@ If an agent input or output fails a guardrail, the guardrail can signal this wit For agent-level tripwires, the exception's `guardrail_result` identifies the guardrail that triggered the tripwire. For an input tripwire raised by the runner, `exception.run_data.input_guardrail_results` contains every input guardrail result completed before the run stopped, including the result that triggered the tripwire. Output tripwires provide the equivalent accumulated results through `exception.run_data.output_guardrail_results`. -Tool tripwire exceptions instead expose the triggering `guardrail` and `output` directly. Their `run_data.tool_input_guardrail_results` and `run_data.tool_output_guardrail_results` lists preserve results accumulated from completed turns before the failure; the triggering result is available through the exception's `output`. Other runner-managed failures, such as `MaxTurnsExceeded`, also preserve completed tool guardrail results in these lists. After `stream_events()` raises, the streamed result exposes the same accumulated agent and tool guardrail result lists. `run_data` can be `None` when an exception is raised outside a runner-managed execution path. +Tool tripwire exceptions instead expose the triggering `guardrail` and `output` directly. Their `run_data.tool_input_guardrail_results` and `run_data.tool_output_guardrail_results` lists preserve results accumulated from completed turns before the failure; the triggering result is available through the exception's `output`. Other runner-managed failures, such as `MaxTurnsExceeded`, also preserve completed tool guardrail results in these lists. After `stream_events()` raises an exception, the streamed result exposes the same accumulated agent and tool guardrail result lists. `run_data` can be `None` when an exception is raised outside a runner-managed execution path. ## Implementing a guardrail diff --git a/docs/handoffs.md b/docs/handoffs.md index 88e95abbad..093c9a1cdd 100644 --- a/docs/handoffs.md +++ b/docs/handoffs.md @@ -2,7 +2,7 @@ Handoffs allow an agent to delegate tasks to another agent. This is particularly useful in scenarios where different agents specialize in distinct areas. For example, a customer support app might have agents that each specifically handle tasks like order status, refunds, FAQs, etc. -Handoffs are represented as tools to the LLM. So if there's a handoff to an agent named `Refund Agent`, the tool would be called `transfer_to_refund_agent`. +Handoffs are represented as tools to the LLM. So if there's a handoff to an agent named `Refund Agent`, the tool would be named `transfer_to_refund_agent`. ## Creating a handoff @@ -39,7 +39,7 @@ The [`handoff()`][agents.handoffs.handoff] function lets you customize things. - `input_type`: The schema for the handoff tool-call arguments. When set, the parsed payload is passed to `on_handoff`. - `input_filter`: This lets you filter the input received by the next agent. See below for more. - `is_enabled`: Whether the handoff is enabled. This can be a boolean or a function that returns a boolean, allowing you to dynamically enable or disable the handoff at runtime. -- `nest_handoff_history`: Optional per-call override for the RunConfig-level `nest_handoff_history` setting. If `None`, the value defined in the active run configuration is used instead. +- `nest_handoff_history`: Optional per-handoff override for the RunConfig-level `nest_handoff_history` setting. If `None`, the value defined in the active run configuration is used instead. The [`handoff()`][agents.handoffs.handoff] helper always transfers control to the specific `agent` you passed in. If you have multiple possible destinations, register one handoff per destination and let the model choose among them. Use a custom [`Handoff`][agents.handoffs.Handoff] only when your own handoff code must decide which agent to return at invocation time. @@ -112,7 +112,7 @@ When a handoff occurs, it's as though the new agent takes over the conversation, - `input_items`: optional items to forward to the next agent instead of `new_items`, allowing you to filter model input while keeping `new_items` intact for session history. - `run_context`: the active [`RunContextWrapper`][agents.run_context.RunContextWrapper] at the time the handoff was invoked. -Nested handoffs are available as an opt-in beta and are disabled by default while we stabilize them. When you enable [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history], the runner compacts summarizable history into ordered assistant summary segments while preserving lossless message items in their original positions. Each generated summary segment uses the `` wrapper, and later handoffs flatten earlier generated segments before rebuilding the ordered transcript. Sessions, `RunState`, and `RunResult.to_input_list()` track exact message occurrences moved into this SDK-default history so those occurrences are not appended twice; separate identical messages are still preserved. You can provide your own mapping function via [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] to return the exact list of input items for the next agent instead of using the built-in segmentation. The opt-in only applies when neither the handoff nor the run supplies an explicit `input_filter`, so existing code that already customizes the payload (including the examples in this repository) keeps its current behavior without changes. You can override the nesting behaviour for a single handoff by passing `nest_handoff_history=True` or `False` to [`handoff(...)`][agents.handoffs.handoff], which sets [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]. If you just need to change the wrapper text for generated summary segments, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] (and optionally [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]) before running your agents. +Nested handoff history is available as an opt-in beta and is disabled by default while we stabilize it. When you enable [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history], the runner compacts summarizable history into ordered assistant summary segments while preserving lossless message items in their original positions. Each generated summary segment uses the `` wrapper, and later handoffs flatten earlier generated segments before rebuilding the ordered transcript. Sessions, `RunState`, and `RunResult.to_input_list()` track exact message occurrences moved into this SDK-default history so those occurrences are not appended twice; separate identical messages are still preserved. You can provide your own mapping function via [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] to return the exact list of input items for the next agent instead of using the built-in segmentation. The opt-in applies only when neither the handoff's `input_filter` nor the active run's `RunConfig.handoff_input_filter` is set, so existing code that already customizes the payload (including the examples in this repository) keeps its current behavior without changes. You can override the nesting behaviour for a single handoff by passing `nest_handoff_history=True` or `False` to [`handoff(...)`][agents.handoffs.handoff], which sets [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]. If you just need to change the wrapper text for generated summary segments, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] before running your agents. Call [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] before a later run when you need to restore the default wrappers. If both the handoff and the active [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] define a filter, the per-handoff [`input_filter`][agents.handoffs.Handoff.input_filter] takes precedence for that specific handoff. @@ -134,7 +134,7 @@ handoff_obj = handoff( ) ``` -1. This will automatically remove all tools from the history when `FAQ agent` is called. +1. This will automatically remove all tool-related items from the history when `FAQ agent` is called. ## Recommended prompts diff --git a/docs/human_in_the_loop.md b/docs/human_in_the_loop.md index 9e12ccaed7..17b4e89200 100644 --- a/docs/human_in_the_loop.md +++ b/docs/human_in_the_loop.md @@ -1,6 +1,6 @@ # Human-in-the-loop -Use the human-in-the-loop (HITL) flow to pause agent execution until a person approves or rejects sensitive tool calls. Tools declare when they need approval, run results surface pending approvals as interruptions, and `RunState` lets you serialize and resume runs after decisions are made. +Use the human-in-the-loop (HITL) flow to pause agent execution until a person approves or rejects sensitive tool calls. Tools declare when they need approval, run results surface pending approvals as interruptions, and `RunState` lets you serialize paused runs and resume them after decisions are made. That approval surface is run-wide, not limited to the current top-level agent. The same pattern applies when the tool belongs to the current agent, to an agent reached through a handoff, or to a nested [`Agent.as_tool()`][agents.agent.Agent.as_tool] execution. In the nested `Agent.as_tool()` case, the interruption still surfaces on the outer run, so you approve or reject it on the outer `RunState` and resume the original top-level run. @@ -46,7 +46,7 @@ agent = Agent( 1. When the model emits a tool call, the runner evaluates its approval rule (`needs_approval`, `require_approval`, or the hosted MCP equivalent). 2. If an approval decision for that tool call is already stored in the [`RunContextWrapper`][agents.run_context.RunContextWrapper], the runner proceeds without prompting. Per-call approvals are scoped to the specific call ID; pass `always_approve=True` or `always_reject=True` to persist the same decision for future calls to that tool during the rest of the run. -3. Otherwise, execution pauses and `RunResult.interruptions` (or `RunResultStreaming.interruptions`) contains [`ToolApprovalItem`][agents.items.ToolApprovalItem] entries with details such as `agent.name`, `tool_name`, and `arguments`. This includes approvals raised after a handoff or inside nested `Agent.as_tool()` executions. +3. If the approval rule requires approval and no decision for that tool call is stored, execution pauses, and `RunResult.interruptions` (or `RunResultStreaming.interruptions`) contains [`ToolApprovalItem`][agents.items.ToolApprovalItem] entries with details such as `agent.name`, `tool_name`, and `arguments`. This includes approvals raised after a handoff or inside nested `Agent.as_tool()` executions. 4. Convert the result to a `RunState` with `result.to_state()`, call `state.approve(...)` or `state.reject(...)`, and then resume with `Runner.run(agent, state)` or `Runner.run_streamed(agent, state)`, where `agent` is the original top-level agent for the run. 5. The resumed run continues where it left off and will re-enter this flow if new approvals are needed. @@ -98,7 +98,7 @@ When these callbacks return a decision, the run continues without pausing for a The same interruption flow works in streaming runs. After a streamed run pauses, keep consuming [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] until the iterator finishes, inspect [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions], resolve them, and resume with [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] if you want the resumed output to keep streaming. See [Streaming](streaming.md) for the streamed version of this pattern. -If you are also using a session, keep passing the same session instance when you resume from `RunState`, or pass another session object that points at the same backing store. The resumed turn is then appended to the same stored conversation history. See [Sessions](sessions/index.md) for the session lifecycle details. +If you are also using a session, keep passing the same session instance when you resume from `RunState`, or pass another session object configured for the same session ID and backing store. The resumed turn is then appended to the same stored conversation history. See [Sessions](sessions/index.md) for the session lifecycle details. ## Example: pause, approve, resume @@ -169,16 +169,16 @@ if __name__ == "__main__": In this example, `prompt_approval` is synchronous because it uses `input()` and is executed with `run_in_executor(...)`. If your approval source is already asynchronous (for example, an HTTP request or async database query), you can use an `async def` function and `await` it directly instead. -To stream output while waiting for approvals, call `Runner.run_streamed`, consume `result.stream_events()` until it completes, and then follow the same `result.to_state()` and resume steps shown above. +To use streaming in a run that may pause for approvals, call `Runner.run_streamed`, consume `result.stream_events()` until it completes, and then follow the same `result.to_state()` and resume steps shown above. ## Repository patterns and examples - **Streaming approvals**: `examples/agent_patterns/human_in_the_loop_stream.py` shows how to drain `stream_events()` and then approve pending tool calls before resuming with `Runner.run_streamed(agent, state)`. - **Custom rejection text**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` shows how to combine run-level `tool_error_formatter` with per-call `rejection_message` overrides when approvals are rejected. - **Agent as tool approvals**: `Agent.as_tool(..., needs_approval=...)` applies the same interruption flow when delegated agent tasks need review. Nested interruptions still surface on the outer run, so resume the original top-level agent rather than the nested one. -- **Local shell and apply_patch tools**: `ShellTool` and `ApplyPatchTool` also support `needs_approval`. Use `state.approve(interruption, always_approve=True)` or `state.reject(..., always_reject=True)` to cache the decision for future calls. For automatic decisions, provide `on_approval` (see `examples/tools/shell.py`); for manual decisions, handle interruptions (see `examples/tools/shell_human_in_the_loop.py`). Hosted shell environments do not support `needs_approval` or `on_approval`; see the [tools guide](tools.md). +- **Local shell and apply_patch tools**: `ShellTool` and `ApplyPatchTool` also support `needs_approval`. Use `state.approve(interruption, always_approve=True)` or `state.reject(..., always_reject=True)` to cache the decision for future calls to that tool during the rest of the run. For automatic decisions, provide `on_approval` (see `examples/tools/shell.py`); for manual decisions, handle interruptions (see `examples/tools/shell_human_in_the_loop.py`). Hosted shell environments do not support `needs_approval` or `on_approval`; see the [tools guide](tools.md). - **Local MCP servers**: Use `require_approval` on `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` to gate MCP tool calls (see `examples/mcp/get_all_mcp_tools_example/main.py` and `examples/mcp/tool_filter_example/main.py`). -- **Hosted MCP servers**: Set `require_approval` to `"always"` on `HostedMCPTool` to force HITL, optionally providing `on_approval_request` to auto-approve or reject (see `examples/hosted_mcp/human_in_the_loop.py` and `examples/hosted_mcp/on_approval.py`). Use `"never"` for trusted servers (`examples/hosted_mcp/simple.py`). +- **Hosted MCP servers**: Set `tool_config={"require_approval": "always"}` on `HostedMCPTool` to force HITL, optionally providing `on_approval_request` to auto-approve or reject (see `examples/hosted_mcp/human_in_the_loop.py` and `examples/hosted_mcp/on_approval.py`). Use `"never"` for trusted servers (`examples/hosted_mcp/simple.py`). - **Sessions and memory**: Pass a session to `Runner.run` so approvals and conversation history survive multiple turns. SQLite and OpenAI Conversations session variants are in `examples/memory/memory_session_hitl_example.py` and `examples/memory/openai_session_hitl_example.py`. - **Realtime agents**: The realtime demo exposes WebSocket messages that approve or reject tool calls via `approve_tool_call` / `reject_tool_call` on the `RealtimeSession` (see `examples/realtime/app/server.py` for the server-side handlers and [Realtime guide](realtime/guide.md#tool-approvals) for the API surface). @@ -190,7 +190,7 @@ Useful serialization options: - `context_serializer`: Customize how non-mapping context objects are serialized. - `context_deserializer`: Rebuild non-mapping context objects when loading state with `RunState.from_json(...)` or `RunState.from_string(...)`. -- `strict_context=True`: Fail serialization or deserialization unless the context is already a mapping or you provide the appropriate serializer/deserializer. +- `strict_context=True`: Fail serialization unless the context is already a mapping or you provide `context_serializer`; fail deserialization unless the context is already a mapping or you provide `context_deserializer`. - `context_override`: Replace the serialized context when loading state. This is useful when you do not want to restore the original context object, but it does not remove that context from an already serialized payload. - `include_tracing_api_key=True`: Include the tracing API key in the serialized trace payload when you need resumed work to keep exporting traces with the same credentials. diff --git a/docs/index.md b/docs/index.md index a660769cc7..b86658216e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,21 +18,21 @@ The SDK has two driving design principles: Here are the main features of the SDK: - **Agents**: Build agents with instructions, tools, guardrails, handoffs, and a built-in loop that continues until the task is complete. -- **Sandbox agents**: Run specialists inside real isolated workspaces with manifest-defined files, sandbox client choice, and resumable sandbox sessions. +- **Sandbox agents**: Run specialists inside real isolated workspaces. Sandbox agents support manifest-defined files, sandbox client selection, and resumable sandbox sessions. - **Realtime agents**: Build powerful voice agents with `gpt-realtime-2.1`, automatic interruption detection, context management, guardrails, and more. - **Voice agents**: Build voice pipelines that combine speech-to-text, an agent workflow, and text-to-speech. - **Python-first**: Use built-in language features to orchestrate and chain agents, rather than needing to learn new abstractions. - **Agents as tools / Handoffs**: A powerful mechanism for coordinating and delegating work across multiple agents. - **Guardrails**: Run input validation and safety checks in parallel with agent execution, and fail fast when checks do not pass. - **Function tools**: Turn any Python function into a tool with automatic schema generation and Pydantic-powered validation. -- **MCP server tool calling**: Built-in MCP server tool integration that works the same way as function tools. +- **MCP server tool calling**: Built-in integration that exposes remote MCP tools to agents alongside function tools. - **Sessions**: A persistent memory layer for maintaining working context within an agent loop. -- **Human in the loop**: Built-in mechanisms for involving humans across agent runs. +- **Human in the loop**: Built-in mechanisms for involving humans during agent runs. - **Tracing**: Built-in tracing for visualizing, debugging, and monitoring workflows, with support for the OpenAI suite of evaluation, fine-tuning, and distillation tools. ## Agents SDK or Responses API? -The SDK uses the Responses API by default for OpenAI models, but it adds a higher-level runtime around model calls. +The SDK uses the Responses API by default for OpenAI models, but it wraps model calls in a higher-level runtime. Use the Responses API directly when: diff --git a/docs/mcp.md b/docs/mcp.md index fecc00709a..38255d3d8f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -54,7 +54,7 @@ Notes: - `failure_error_function` controls how MCP tool call failures are surfaced to the model. - When `failure_error_function` is unset, the SDK uses the default tool error formatter. - Server-level `failure_error_function` overrides `Agent.mcp_config["failure_error_function"]` for that server. -- `include_server_in_tool_names` is opt-in. When enabled, each local MCP tool is exposed to the model with a deterministic server-prefixed name, which helps avoid collisions when multiple MCP servers publish tools with the same name. Generated names are ASCII-safe, stay within the function-tool name length limit, and avoid existing local function tool and enabled handoff names on the same agent. The SDK still invokes the original MCP tool name on the original server. +- `include_server_in_tool_names` is opt-in. When enabled, each local MCP tool is exposed to the model with a deterministic server-prefixed name, which helps avoid collisions when multiple MCP servers publish tools with the same name. Generated names are ASCII-safe, stay within the name-length limit for `FunctionTool` instances, and do not collide with the configured names of local `FunctionTool` instances or enabled handoffs on the same agent. The SDK still invokes the original MCP tool name on the original server. ## Shared patterns across transports @@ -229,7 +229,7 @@ The constructor accepts additional options: Supported forms: - `"always"` or `"never"` for all tools. -- `True` / `False` (equivalent to always/never). +- `True` requires approval for all tools, and `False` requires approval for none (equivalent to `"always"` and `"never"`, respectively). - A per-tool map, for example `{"delete_file": "always", "read_file": "never"}`. - A grouped object: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`. @@ -271,7 +271,7 @@ If your run context is a Pydantic model, dataclass, or custom class, read the te ### MCP tool outputs: text and images -When an MCP tool returns image content, the SDK maps it to image tool output entries automatically. Mixed text/image responses are forwarded as a list of output items, so agents can consume MCP image results the same way they consume image output from regular function tools. +When an MCP tool returns image content, the SDK automatically maps it to image-type entries in the tool output. Mixed text/image responses are forwarded as a list of output items, so agents can consume MCP image results the same way they consume image output from regular function tools. ## 3. HTTP with SSE MCP servers @@ -336,7 +336,7 @@ async with MCPServerStdio( ## 5. MCP server manager -When you have multiple MCP servers, use `MCPServerManager` to connect them up front and expose the connected subset to your agents. See the [MCPServerManager API reference](ref/mcp/manager.md) for constructor options and reconnect behavior. +When you have multiple MCP servers, use `MCPServerManager` to connect them up front and expose the successfully connected subset of those servers to your agents. See the [MCPServerManager API reference](ref/mcp/manager.md) for constructor options and reconnect behavior. ```python from agents import Agent, Runner @@ -449,7 +449,7 @@ agent = Agent( ## Pagination -The built-in local MCP server classes automatically follow `nextCursor` when listing tools and prompts. `list_tools()` returns the complete tool list before applying filters or populating its cache, and `list_prompts()` returns one combined result with `nextCursor=None`. If a later page fails or a server repeats a cursor, the operation raises an error instead of exposing or caching partial results. +The built-in local MCP server classes automatically follow `nextCursor` when listing tools and prompts. `list_tools()` collects the complete tool list before applying filters or populating its cache, and `list_prompts()` returns one combined result with `nextCursor=None`. If a later page fails or a server repeats a cursor, the operation raises an error instead of exposing or caching partial results. Resources remain explicitly paginated. Pass the `nextCursor` from `list_resources()` or `list_resource_templates()` back as the `cursor` argument to retrieve the next page. diff --git a/docs/models/index.md b/docs/models/index.md index 65522451b9..447e206150 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -73,7 +73,7 @@ my_agent = Agent( For lower latency, using `reasoning.effort="none"` with GPT-5 models is recommended. -GPT-5.6 also supports reasoning mode, persisted reasoning context, and the `"max"` effort level through the existing `reasoning` setting. These controls are available on the Responses API path: +GPT-5.6 also supports reasoning mode, reasoning context carried across conversation turns, and the `"max"` effort level through the existing `reasoning` setting. These controls are available on the Responses API path: ```python from openai.types.shared import Reasoning @@ -94,13 +94,13 @@ agent = Agent( `reasoning.mode` and `reasoning.context` are Responses-only settings. Chat Completions uses only `reasoning.effort`, and the supported effort levels depend on the model and API surface. Use the Responses API for GPT-5.6 `"max"` effort. The Chat Completions adapter ignores mode and context with a warning; set `strict_feature_validation=True` on the OpenAI provider to turn that warning into an error. -When using `context="all_turns"`, preserve the conversation through `previous_response_id`, a server-side conversation, or by replaying prior reasoning items. For stateless `store=False` calls, include `reasoning.encrypted_content` in the response and replay those reasoning items on the next request. +When using `context="all_turns"`, preserve the conversation through `previous_response_id`, a server-side Responses API conversation, or by including prior reasoning items in the next request. For stateless `store=False` calls, request `reasoning.encrypted_content` in the response, then include those reasoning items as input in the next request. #### ComputerTool model selection If an agent includes [`ComputerTool`][agents.tool.ComputerTool], the effective model on the actual Responses request determines which computer-tool payload the SDK sends. Explicit `gpt-5.5` requests use the GA built-in `computer` tool, while explicit `computer-use-preview` requests keep the older `computer_use_preview` payload. -Prompt-managed calls are the main exception. If a prompt template owns the model and the SDK omits `model` from the request, the SDK defaults to the preview-compatible computer payload so it does not guess which model the prompt pins. To keep the GA path in that flow, either make `model="gpt-5.5"` explicit on the request or force the GA selector with `ModelSettings(tool_choice="computer")` or `ModelSettings(tool_choice="computer_use")`. +Prompt-managed calls are the main exception. If a prompt template specifies the model and the SDK omits `model` from the request, the SDK defaults to the preview-compatible computer payload so it does not guess which model the prompt pins. To keep the GA path in that flow, either make `model="gpt-5.5"` explicit on the request or force the GA selector with `ModelSettings(tool_choice="computer")` or `ModelSettings(tool_choice="computer_use")`. With a registered [`ComputerTool`][agents.tool.ComputerTool], `tool_choice="computer"`, `"computer_use"`, and `"computer_use_preview"` are normalized to the built-in selector that matches the effective request model. If no `ComputerTool` is registered, those strings continue to behave like ordinary function names. @@ -123,7 +123,7 @@ These features are rejected on Chat Completions models and on non-Responses back ### Responses WebSocket transport -By default, OpenAI Responses API requests use HTTP transport. You can opt in to websocket transport when using OpenAI-backed models. +By default, OpenAI Responses API requests use HTTP transport. You can opt in to websocket transport when using the OpenAI Responses provider path. #### Basic setup @@ -133,7 +133,7 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -This affects OpenAI Responses models resolved by the default OpenAI provider (including string model names such as `"gpt-5.6-sol"`). +This affects OpenAI Responses models that result when the default OpenAI provider resolves a model name (including string model names such as `"gpt-5.6-sol"`). Transport selection happens when the SDK resolves a model name into a model instance. If you pass a concrete [`Model`][agents.models.interface.Model] object, its transport is already fixed: [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] uses websocket, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] uses HTTP, and [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] stays on Chat Completions. If you pass `RunConfig(model_provider=...)`, that provider controls transport selection instead of the global default. @@ -160,7 +160,7 @@ result = await Runner.run( ) ``` -OpenAI-backed providers also accept optional agent registration config. This is an advanced option for cases where your OpenAI setup expects provider-level registration metadata such as a harness ID. +Providers that route through the SDK's OpenAI integration also accept optional agent registration config. This is an advanced option for cases where your OpenAI setup expects provider-level registration metadata such as a harness ID. ```python from agents import ( @@ -227,19 +227,19 @@ If you use a custom OpenAI-compatible endpoint or proxy, websocket transport als #### Notes -- This is the Responses API over websocket transport, not the [Realtime API](../realtime/guide.md). It does not apply to Chat Completions or non-OpenAI providers unless they support the Responses websocket `/responses` endpoint. +- This is the Responses API over websocket transport, not the [Realtime API](../realtime/guide.md). It does not apply to Chat Completions. It applies to non-OpenAI providers only if they support the Responses websocket `/responses` endpoint. - Install the `websockets` package if it is not already available in your environment. - You can use [`Runner.run_streamed()`][agents.run.Runner.run_streamed] directly after enabling websocket transport. For multi-turn workflows where you want to reuse the same websocket connection across turns (and nested agent-as-tool calls), the [`responses_websocket_session()`][agents.responses_websocket_session] helper is recommended. See the [Running agents](../running_agents.md) guide and [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py). - For long reasoning turns or networks with latency spikes, customize websocket keepalive behavior with `responses_websocket_options`. Increase `ping_timeout` to tolerate delayed pong frames, or set `ping_timeout=None` to disable heartbeat timeouts while keeping pings enabled. Prefer HTTP/SSE transport when reliability is more important than websocket latency. - By default the SDK disables the incoming message-size limit (`max_size=None`). For long-lived agent processes behind proxies or in memory-constrained containers, set `responses_websocket_options={"max_size": 8 * 1024 * 1024}` to bound per-message memory usage. - The [Responses API WebSocket service](https://developers.openai.com/api/docs/guides/websocket-mode) processes one response at a time on each connection and limits each connection to 60 minutes. Open a new connection after that limit; use multiple connections when you need parallel runs. -- The service keeps only the most recent response in connection-local memory. A failed `4xx` or `5xx` turn evicts the referenced `previous_response_id`. After reconnecting, a stored response can still be continued when available, but `store=False` and ZDR flows have no persisted fallback. Start a new chain with `previous_response_id=None` and send the full input context, or rebuild that context from locally managed session state. +- The service keeps only the most recent response in connection-local memory. A failed `4xx` or `5xx` turn evicts from that memory the response referenced by `previous_response_id`. After reconnecting, a stored response can still be continued when available, but `store=False` and ZDR flows have no persisted fallback. Start a new chain with `previous_response_id=None` and send the full input context, or rebuild that context from locally managed session state. ### Hosted multi-agent (experimental) The OpenAI Responses API hosted multi-agent beta lets a GPT-5.6 root model create and coordinate server-hosted subagents. The Agents SDK can keep using its normal `Runner`: hosted orchestration stays on the service, while developer-defined function tools execute in your application. -This integration is experimental and uses the Responses WebSocket transport so local function outputs can be returned to an active hosted agent with `response.inject`. It requires `openai[realtime]>=2.45.0`, including a beta build that exposes `client.beta.responses.connect`. The interface and beta item schemas may change before general availability. +This integration is experimental and uses the Responses WebSocket transport so local function outputs can be returned to an active hosted agent with `response.inject`. It requires a build of `openai[realtime]` version 2.45.0 or later that exposes `client.beta.responses.connect`. The interface and beta item schemas may change before general availability. #### Configure the model @@ -285,7 +285,7 @@ Hosted agent names are observational metadata, not a local routing mechanism. Ro Only a message attributed to `/root` with phase `final_answer` becomes a normal final message. The experimental adapter filters subagent messages and hosted orchestration records out of the high-level `RunResult`; the SDK never executes those records as local functions. -Raw streaming continues to expose beta Responses events, including hosted output items and `response.inject.created` acknowledgements. The adapter divides one active provider response into SDK-visible logical model turns when a function call is ready, then resumes that same provider response after the Runner produces an output. Use `get_hosted_agent_metadata()` with a raw hosted item or a `ToolContext` to inspect attribution. +Raw streaming continues to expose beta Responses events, including hosted output items and `response.inject.created` acknowledgements. The adapter divides one active provider response into SDK-visible logical model turns when a function call is ready, then resumes that same provider response after the Runner produces an output. Use `get_hosted_agent_metadata()` with a raw hosted item or a `ToolContext` to identify the hosted agent to which the item or tool call is attributed. #### Relationship to SDK orchestration @@ -314,7 +314,7 @@ If you need a non-OpenAI provider, start with the SDK's built-in provider integr | [`set_default_openai_client`][agents.set_default_openai_client] | One OpenAI-compatible endpoint should be the default for most or all agents | Global default | | [`ModelProvider`][agents.models.interface.ModelProvider] | One custom provider should apply to a single run | Per run | | [`Agent.model`][agents.agent.Agent.model] | Different agents need different providers or concrete model objects | Per agent | -| Third-party adapter | You need adapter-managed provider coverage or routing that the built-in paths do not provide | See [Third-party adapters](#third-party-adapters) | +| Third-party adapter | You need provider coverage or routing from an adapter because the built-in paths do not provide it | See [Third-party adapters](#third-party-adapters) | You can integrate other LLM providers with these built-in paths: @@ -604,7 +604,7 @@ The SDK uses the Responses API by default, but many other LLM providers still do ### Chat Completions compatibility options -When you route through Chat Completions, the SDK preserves compatibility by silently dropping Responses-only fields that Chat Completions cannot send, such as `previous_response_id`, `conversation_id`, prompts, or non-text-only tool outputs. If you want those mismatches to fail fast during development, enable strict feature validation on the OpenAI provider: +When you route through Chat Completions, the SDK preserves compatibility by silently dropping Responses-only fields that Chat Completions cannot send, such as `previous_response_id`, `conversation_id`, the Responses API `prompt` field, or tool outputs that are not text-only. If you want those mismatches to fail fast during development, enable strict feature validation on the OpenAI provider: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -659,7 +659,7 @@ You need to be aware of feature differences between model providers, or you may ## Third-party adapters -Reach for a third-party adapter only when the SDK's built-in provider integration points are not enough. If you are using OpenAI models only with this SDK, prefer the built-in [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] path instead of Any-LLM or LiteLLM. Third-party adapters are for cases where you need to combine OpenAI models with non-OpenAI providers, or need adapter-managed provider coverage or routing that the built-in paths do not provide. Adapters add another compatibility layer between the SDK and the upstream model provider, so feature support and request semantics can vary by provider. The SDK currently includes Any-LLM and LiteLLM as best-effort, beta adapter integrations. +Reach for a third-party adapter only when the SDK's built-in provider integration points are not enough. If you are using OpenAI models only with this SDK, prefer the built-in [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] path instead of Any-LLM or LiteLLM. Third-party adapters are for cases where you need to combine OpenAI models with non-OpenAI providers, or need provider coverage or routing that only an adapter provides. Adapters add another compatibility layer between the SDK and the upstream model provider, so feature support and request semantics can vary by provider. The SDK currently includes Any-LLM and LiteLLM as best-effort, beta adapter integrations. ### Any-LLM @@ -677,7 +677,7 @@ LiteLLM support is included on a best-effort, beta basis for cases where you nee If you need LiteLLM, install `openai-agents[litellm]`, then start from [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) or [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py). You can use `litellm/...` model names or instantiate [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] directly. -Some LiteLLM-backed providers do not populate SDK usage metrics by default. If you need usage reporting, pass `ModelSettings(include_usage=True)` and validate the exact provider backend you plan to deploy if you depend on structured outputs, tool calling, usage reporting, or adapter-specific routing behavior. +Some providers accessed through the LiteLLM adapter do not populate SDK usage metrics by default. If you need usage reporting, pass `ModelSettings(include_usage=True)` and validate the exact provider backend you plan to deploy if you depend on structured outputs, tool calling, usage reporting, or adapter-specific routing behavior. If LiteLLM emits Pydantic serializer warnings for response objects, you can opt in to the SDK's compatibility patch before importing the LiteLLM adapter: diff --git a/docs/multi_agent.md b/docs/multi_agent.md index 4e5b0bd809..7ee2b700a2 100644 --- a/docs/multi_agent.md +++ b/docs/multi_agent.md @@ -1,6 +1,6 @@ # Agent orchestration -Orchestration refers to the flow of agents in your app. Which agents run, in what order, and how do they decide what happens next? There are two main ways to orchestrate agents: +Orchestration refers to the flow of agents in your app. Which agents run, in what order, and how is the next step decided? There are two main ways to orchestrate agents: 1. Allowing the LLM to make decisions: this uses the intelligence of an LLM to plan, reason, and decide on what steps to take based on that. 2. Orchestrating via code: determining the flow of agents via your code. @@ -9,10 +9,10 @@ You can mix and match these patterns. Each has their own tradeoffs, described be ## Orchestrating via LLM -An agent is an LLM equipped with instructions, tools and handoffs. This means that given an open-ended task, the LLM can autonomously plan how it will tackle the task, using tools to take actions and acquire data, and using handoffs to delegate tasks to sub-agents. For example, a research agent could be equipped with tools like: +An agent is an LLM equipped with instructions, tools and handoffs. This means that given an open-ended task, the LLM can autonomously plan how it will tackle the task, using tools to take actions and acquire data, and using handoffs to delegate tasks to sub-agents. For example, a research agent could be equipped with capabilities like: - Web search to find information online -- File search and retrieval to search through proprietary data and connections +- File search and retrieval to search through proprietary data and connected data sources - Computer use to take actions on a computer - Code execution to do data analysis - Handoffs to specialized agents that are great at planning, report writing and more. @@ -23,16 +23,16 @@ In the Python SDK, two orchestration patterns come up most often: | Pattern | How it works | Best when | | --- | --- | --- | -| Agents as tools | A manager agent keeps control of the conversation and calls specialist agents through `Agent.as_tool()`. | You want one agent to own the final answer, combine outputs from multiple specialists, or enforce shared guardrails in one place. | -| Handoffs | A triage agent routes the conversation to a specialist, and that specialist becomes the active agent for the rest of the turn. | You want the specialist to respond directly, keep prompts focused, or swap instructions without the manager narrating the result. | +| Agents as tools | A manager agent keeps control of the conversation and calls specialist agents through `Agent.as_tool()`. | You want one agent to own the final answer, combine outputs from multiple specialists, or enforce shared SDK guardrails in one place. | +| Handoffs | A triage agent routes the conversation to a specialist, and that specialist becomes the active agent for the rest of the turn. | You want the specialist to respond directly, keep prompts focused, or have the handoff switch the active instructions without requiring the manager to narrate the result. | -Use **agents as tools** when a specialist should help with a bounded subtask but should not take over the user-facing conversation. Use **handoffs** when routing itself is part of the workflow and you want the chosen specialist to own the next part of the interaction. +Use **agents as tools** when a specialist should help with a bounded subtask but should not take over the user-facing conversation. Use **handoffs** when routing itself is part of the workflow and you want the chosen specialist to own the remainder of the current turn. You can also combine the two. A triage agent might hand off to a specialist, and that specialist can still call other agents as tools for narrow subtasks. This pattern is great when the task is open-ended and you want to rely on the intelligence of an LLM. The most important tactics here are: -1. Invest in good prompts. Make it clear what tools are available, how to use them, and what parameters it must operate within. +1. Invest in good prompts. Make it clear what tools are available, how to use them, and what constraints the agent must follow. 2. Monitor your app and iterate on it. See where things go wrong, and iterate on your prompts. 3. Allow the agent to introspect and improve. For example, run it in a loop, and let it critique itself; or, provide error messages and let it improve. 4. Have specialized agents that excel in one task, rather than having a general purpose agent that is expected to be good at anything. @@ -46,7 +46,7 @@ While orchestrating via LLM is powerful, orchestrating via code makes tasks more - Using [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) to generate well formed data that you can inspect with your code. For example, you might ask an agent to classify the task into a few categories, and then pick the next agent based on the category. - Chaining multiple agents by transforming the output of one into the input of the next. You can decompose a task like writing a blog post into a series of steps - do research, write an outline, write the blog post, critique it, and then improve it. -- Running the agent that performs the task in a `while` loop with an agent that evaluates and provides feedback, until the evaluator says the output passes certain criteria. +- In each iteration of a `while` loop, run the task agent to produce an output, then run an evaluator agent to assess that output and provide feedback; stop when the evaluator says the output passes the required criteria. - Running multiple agents in parallel, e.g. via Python primitives like `asyncio.gather`. This is useful for speed when you have multiple tasks that don't depend on each other. We have a number of examples in [`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns). diff --git a/docs/realtime/guide.md b/docs/realtime/guide.md index 22d9af72b5..5b275cf3e7 100644 --- a/docs/realtime/guide.md +++ b/docs/realtime/guide.md @@ -123,13 +123,13 @@ If server-side turn detection is disabled, you are responsible for marking turn await session.send_audio(audio_bytes, commit=True) ``` -If you need lower-level control, you can also send raw client events such as `input_audio_buffer.commit` through the underlying model transport. +If you need lower-level control, you can also send Realtime API client events such as `input_audio_buffer.commit` directly through the underlying model transport. ### Manual response control -`session.send_message()` sends user input using the high-level path and starts a response for you. Raw audio buffering does **not** automatically do the same in every configuration. +`session.send_message()` sends user input using the high-level path and starts a response for you. In some configurations, raw audio buffering does **not** automatically do the same. -At the Realtime API level, manual turn control means clearing `turn_detection` with a raw `session.update`, then sending `input_audio_buffer.commit` and `response.create` yourself. +At the Realtime API level, manual turn control means sending a `session.update` event that sets `turn_detection` to `null`, then sending `input_audio_buffer.commit` and `response.create` yourself. If you are managing turns manually, you can send raw client events through the model transport: @@ -173,7 +173,7 @@ The most useful events for UI state are usually `history_added` and `history_upd ### Usage accounting -When a completed model response includes usage, the OpenAI realtime model emits a [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] inside a `raw_model_event`. Its `usage` field contains the token counts for that response, while `input_tokens_details` and `output_tokens_details` provide optional modality breakdowns. +When a completed model response includes usage, the SDK's OpenAI `RealtimeModel` transport emits a [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] inside a `raw_model_event`. Its `usage` field contains the token counts for that response, while `input_tokens_details` and `output_tokens_details` provide optional modality breakdowns. The session also adds each response's usage to the shared [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]. Read it from `event.info.context.usage` on a subsequent high-level event such as `agent_end` to inspect cumulative usage for the live session. @@ -199,7 +199,7 @@ Usage is reported only when the model provider includes it in the completed resp When the user interrupts the assistant, the session emits `audio_interrupted` and updates history so the server-side conversation stays aligned with what the user actually heard. -In low-latency local playback, the default playback tracker is often enough. In remote or delayed playback scenarios, especially telephony, use [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] so interruption truncation is based on actual playback progress rather than assuming all generated audio has already been heard. +In low-latency local playback, the default playback tracker is often enough. In remote or delayed playback scenarios, especially telephony, use [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] so the interrupted response is truncated at the actual playback position rather than assuming all generated audio has already been heard. The Twilio example in [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) shows this pattern. @@ -264,11 +264,11 @@ main_agent = RealtimeAgent( ) ``` -Bare `RealtimeAgent` handoffs are auto-wrapped, and `realtime_handoff(...)` lets you customize names, descriptions, validation, callbacks, and availability. Realtime handoffs do **not** support the regular handoff `input_filter`. +`RealtimeAgent` objects used directly as handoffs are auto-wrapped, and `realtime_handoff(...)` lets you customize names, descriptions, validation, callbacks, and availability. Realtime handoffs do **not** support the regular handoff `input_filter`. ### Guardrails -Realtime agents support output guardrails on agent responses and input guardrails on function-tool calls. Output guardrails run on debounced accumulation of output-text and audio-transcript deltas rather than on every partial delta, and they emit `guardrail_tripped` instead of raising an exception. +Realtime agents support output guardrails on agent responses and input guardrails on function-tool calls. Output guardrail checks are debounced: each check runs on accumulated output-text and audio-transcript deltas rather than on every partial delta, and emits `guardrail_tripped` instead of raising an exception. ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -288,9 +288,9 @@ agent = RealtimeAgent( ) ``` -When a realtime output guardrail trips on an audio transcript, the session interrupts the active response, forces `response.cancel`, emits `guardrail_tripped`, and sends a follow-up user message that names the triggered guardrail so the model can produce a replacement response. Your audio player should still listen for `audio_interrupted` and stop local playback immediately, because some audio may already be buffered when the tripwire fires. With the built-in OpenAI Realtime transports, if the guardrail finishes after its source response has ended, the session interrupts only that response's buffered playback and does not cancel a newer response. For text-only output, the session instead sends a response-scoped `response.cancel`; it does not emit `audio_interrupted` because there is no audio playback to stop. The same `guardrail_tripped` event and follow-up user message are emitted for the text-only path when using the built-in OpenAI Realtime models. +When a realtime output guardrail trips on an audio transcript, the session interrupts the active response, forces `response.cancel`, emits `guardrail_tripped`, and sends a follow-up user message that names the triggered guardrail so the model can produce a replacement response. Your audio player should still listen for `audio_interrupted` and stop local playback immediately, because some audio may already be buffered when the tripwire fires. With the built-in OpenAI Realtime transports, if the guardrail check finishes after the response it is checking has ended, the session interrupts only that response's buffered playback and does not cancel any response that started later. For text-only output, the session instead sends a response-scoped `response.cancel`; it does not emit `audio_interrupted` because there is no audio playback to stop. The same `guardrail_tripped` event and follow-up user message are emitted for the text-only path when using the built-in OpenAI Realtime models. -Custom `RealtimeModel` transports must honor `RealtimeModelSendInterrupt.response_id` and `playback_only` to provide the same source-scoped audio interruption behavior. They must also override `RealtimeModel.send_event_if()` to support the text-only recovery message. The implementation must recheck or serialize the supplied condition at the transport's actual event commit boundary. The default implementation safely skips the recovery message because checking the condition before awaiting `send_event()` would allow a newer response to start before the message is committed; response cancellation and the `guardrail_tripped` event still occur. +Custom `RealtimeModel` transports must honor `RealtimeModelSendInterrupt.response_id` and `playback_only` to provide the same source-scoped audio interruption behavior. They must also override `RealtimeModel.send_event_if()` to support the recovery message for the text-only output path. The implementation must either recheck the supplied condition at the transport's actual event commit boundary or serialize the condition check together with the event commit. The default implementation safely skips the recovery message because, if it checked the condition once and then sent the event separately, another response could start between that check and the event commit; response cancellation and the `guardrail_tripped` event still occur. ## SIP and telephony diff --git a/docs/realtime/quickstart.md b/docs/realtime/quickstart.md index fd44254221..d63ded52c3 100644 --- a/docs/realtime/quickstart.md +++ b/docs/realtime/quickstart.md @@ -118,7 +118,7 @@ Once the basic session works, the settings most people reach for next are: The older flat aliases such as `input_audio_format`, `output_audio_format`, `input_audio_transcription`, and `turn_detection` still work, but nested `audio` settings are preferred for new code. -For manual turn control, use a raw `session.update` / `input_audio_buffer.commit` / `response.create` flow as described in the [Realtime agents guide](guide.md#manual-response-control). +For manual turn control, use the low-level `session.update` / `input_audio_buffer.commit` / `response.create` flow described in the [Realtime agents guide](guide.md#manual-response-control). For the full schema, see [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] and [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]. @@ -145,7 +145,7 @@ session = await runner.run(model_config={"api_key": "your-api-key"}) If you pass `headers` explicitly, the SDK will **not** inject an `Authorization` header for you. -When connecting to Azure OpenAI, pass a GA Realtime endpoint URL in `model_config["url"]` and explicit headers. Avoid the legacy beta path (`/openai/realtime?api-version=...`) with realtime agents. See the [Realtime agents guide](guide.md#low-level-access-and-custom-endpoints) for details. +When connecting to Azure OpenAI, set `model_config["url"]` to a GA Realtime endpoint URL and pass headers explicitly. Avoid the legacy beta path (`/openai/realtime?api-version=...`) with realtime agents. See the [Realtime agents guide](guide.md#low-level-access-and-custom-endpoints) for details. ## Next steps diff --git a/docs/realtime/transport.md b/docs/realtime/transport.md index e1a83908a6..c68d95ffa0 100644 --- a/docs/realtime/transport.md +++ b/docs/realtime/transport.md @@ -22,7 +22,7 @@ That means the standard Python topology looks like this: 1. Your Python service creates a `RealtimeRunner`. 2. `await runner.run()` returns a `RealtimeSession`. -3. Enter the session and send text, structured messages, or audio. +3. Enter the `RealtimeSession` as an async context manager, then send text, structured messages, or audio. 4. Consume `RealtimeSessionEvent` items and forward audio or transcripts to your application. This is the topology used by the core demo app, the CLI example, and the Twilio Media Streams example: @@ -86,14 +86,14 @@ If your app's primary client is a browser using Realtime WebRTC: - Treat it as outside the scope of the Python SDK docs in this repository. - Use the official [Realtime API with WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc/) and [Realtime conversations](https://developers.openai.com/api/docs/guides/realtime-conversations/) docs for the client-side flow and event model. -- Use the official [Realtime server-side controls](https://developers.openai.com/api/docs/guides/realtime-server-controls/) guide if you need a sideband server connection on top of a browser WebRTC client. +- Use the official [Realtime server-side controls](https://developers.openai.com/api/docs/guides/realtime-server-controls/) guide if, in addition to a browser WebRTC client, you need a sideband server connection. - Do not expect this repository to provide a browser-side `RTCPeerConnection` abstraction or a ready-made browser WebRTC sample. This repository also does not currently ship a browser WebRTC plus Python sideband example. ## Custom endpoints and attach points -The transport configuration surface in [`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] lets you adapt the default paths: +The transport configuration surface in [`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] lets you customize the default transport behavior: - `url`: Override the WebSocket endpoint - `headers`: Provide explicit headers such as Azure auth headers diff --git a/docs/release.md b/docs/release.md index 468575e64f..369d8f9219 100644 --- a/docs/release.md +++ b/docs/release.md @@ -25,12 +25,12 @@ This minor release does **not** introduce a breaking change. The minor version b Highlights: -- Added [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], which lets supported OpenAI Responses models generate JavaScript to coordinate eligible tools. It supports per-tool `allowed_callers`, structured function-tool outputs, and integration with Runner streaming, guardrails, approvals, sessions, and `RunState`. See [Programmatic Tool Calling](tools.md#programmatic-tool-calling) for setup and constraints. -- Added the public `agents.decorators` module and the shorter `@tool` alias alongside the existing function and guardrail decorators. Function tools now also support async callable objects. +- Added [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], which lets supported OpenAI Responses models generate JavaScript to coordinate tools eligible for Programmatic Tool Calling. It supports per-tool `allowed_callers`, structured outputs from `FunctionTool` instances, and integration with Runner streaming, guardrails, approvals, sessions, and `RunState`. See [Programmatic Tool Calling](tools.md#programmatic-tool-calling) for setup and constraints. +- Added the public `agents.decorators` module and `@tool` as a shorter alias for the existing `@function_tool` decorator, alongside the existing guardrail decorators. `FunctionTool` instances now also support async callable objects. - SDK configuration now consistently accepts either typed settings objects or dictionaries across agents, runs, models, sessions, sandboxes, and voice pipelines, with validation for unknown settings. - Hardened error and diagnostic logging across models, tools, MCP, Realtime, sessions, sandboxes, and tracing to avoid exposing raw sensitive payloads while preserving useful debugging context. -- Improved AnyLLM, LiteLLM, and Chat Completions compatibility, preserved session history across model retries, and added provider retry guidance for WebSocket overloads that occur before a response starts so opt-in Runner retry policies can act when replay is permitted. -- Added [create-time-only S3 mounts for Vercel sandboxes](sandbox/clients.md#mounts-and-remote-storage) through `VercelCloudBucketMountStrategy`. Mounted sessions exclude bucket contents from workspace persistence and intentionally do not support dynamic mount changes or session resume. +- Improved AnyLLM, LiteLLM, and Chat Completions compatibility, preserved session history across model retries, and added provider retry guidance for WebSocket overloads that occur before a response starts, so opt-in Runner retry policies can replay the failed attempt when permitted. +- Added [S3 mounts that can be configured only when a Vercel sandbox is created](sandbox/clients.md#mounts-and-remote-storage) through `VercelCloudBucketMountStrategy`. Mounted sessions exclude bucket contents from workspace persistence and intentionally do not support dynamic mount changes or session resume. ### 0.18.0 @@ -111,7 +111,7 @@ This minor release does **not** introduce a breaking change, but it adds a major Highlights: - Added a new beta sandbox runtime surface centered on `SandboxAgent`, `Manifest`, and `SandboxRunConfig`, letting agents work inside persistent isolated workspaces with files, directories, Git repos, mounts, snapshots, and resume support. -- Added sandbox execution backends for local and containerized development via `UnixLocalSandboxClient` and `DockerSandboxClient`, plus hosted provider integrations for Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, and Vercel through optional extras. +- Added sandbox execution backends for local and containerized development via `UnixLocalSandboxClient` and `DockerSandboxClient`, plus hosted provider integrations for Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, and Vercel through optional dependency extras in the Python package. - Added sandbox memory support so future runs can reuse lessons from prior runs, with progressive disclosure, multi-turn grouping, configurable isolation boundaries, and persisted-memory examples including S3-backed workflows. - Added a broader workspace and resume model, including local and synthetic workspace entries, remote storage mounts for S3/R2/GCS/Azure Blob Storage/S3 Files, portable snapshots, and resume flows via `RunState`, `SandboxSessionState`, or saved snapshots. - Added substantial sandbox examples and tutorials under `examples/sandbox/`, covering coding tasks with skills, handoffs, memory, provider-specific setups, and end-to-end workflows such as code review, dataroom QA, and website cloning. @@ -124,9 +124,9 @@ This minor release does **not** introduce a breaking change, but it includes a n Highlights: - The default websocket Realtime model is now `gpt-realtime-1.5`, so new Realtime agent setups use the newer model without extra configuration. -- `MCPServer` now exposes `list_resources()`, `list_resource_templates()`, and `read_resource()`, and `MCPServerStreamableHttp` now exposes `session_id` so streamable HTTP sessions can be resumed across reconnects or stateless workers. -- Chat Completions integrations can now opt into reasoning-content replay via `should_replay_reasoning_content`, improving provider-specific reasoning/tool-call continuity for adapters such as LiteLLM/DeepSeek. -- Fixed several runtime and session edge cases, including concurrent first writes in `SQLAlchemySession`, compaction requests with orphaned assistant message IDs after reasoning stripping, `remove_all_tools()` leaving MCP/reasoning items behind, and a race in the function-tool batch executor. +- `MCPServer` now exposes `list_resources()`, `list_resource_templates()`, and `read_resource()`, and `MCPServerStreamableHttp` now exposes `session_id` so sessions using the MCP Streamable HTTP transport can be resumed across reconnects or stateless workers. +- Chat Completions integrations can now opt into re-sending existing reasoning content via `should_replay_reasoning_content`, improving provider-specific reasoning/tool-call continuity for adapters such as LiteLLM/DeepSeek. +- Fixed several runtime and session edge cases, including concurrent first writes in `SQLAlchemySession`, compaction requests with orphaned assistant message IDs after reasoning stripping, `remove_all_tools()` leaving MCP/reasoning items behind, and a race in the batch executor for `FunctionTool` instances. ### 0.12.0 @@ -156,7 +156,7 @@ Additionally, the type hint for the value returned from the `Agent#as_tool()` me In this version, two runtime behavior changes may require migration work: -- Function tools wrapping **synchronous** Python callables now execute on worker threads via `asyncio.to_thread(...)` instead of running on the event loop thread. If your tool logic depends on thread-local state or thread-affine resources, migrate to an async tool implementation or make thread affinity explicit in your tool code. +- `FunctionTool` instances wrapping **synchronous** Python callables now execute on worker threads via `asyncio.to_thread(...)` instead of running on the event loop thread. If your tool logic depends on thread-local state or thread-affine resources, migrate to an async tool implementation or make thread affinity explicit in your tool code. - Local MCP tool failure handling is now configurable, and the default behavior can return model-visible error output instead of failing the whole run. If you rely on fail-fast semantics, set `mcp_config={"failure_error_function": None}`. Server-level `failure_error_function` values override the agent-level setting, so set `failure_error_function=None` on each local MCP server that has an explicit handler. ### 0.7.0 @@ -168,14 +168,14 @@ In this version, there were a few behavior changes that can affect existing appl ### 0.6.0 -In this version, the default handoff history is now packaged into a single assistant message instead of exposing the raw user/assistant turns, giving downstream agents a concise, predictable recap -- The existing single-message handoff transcript now by default starts with "For context, here is the conversation so far between the user and the previous agent:" before the `` block, so downstream agents get a clearly labeled recap +In this version, the default handoff history is now packaged into a single assistant message rather than passing the user and assistant turns as separate messages, giving downstream agents a concise, predictable recap +- The existing single-message handoff transcript now starts by default with the exact literal text `For context, here is the conversation so far between the user and the previous agent:` before the `` block, so downstream agents get a clearly labeled recap ### 0.5.0 This version doesn’t introduce any visible breaking changes, but it includes new features and a few significant updates under the hood: -- Added support for `RealtimeRunner` to handle [SIP protocol connections](https://platform.openai.com/docs/guides/realtime-sip) +- Added support in `RealtimeRunner` for handling [SIP protocol connections](https://platform.openai.com/docs/guides/realtime-sip). - Significantly revised the internal logic of `Runner#run_sync` for Python 3.14 compatibility ### 0.4.0 @@ -188,8 +188,8 @@ In this version, the Realtime API support migrates to gpt-realtime model and its ### 0.2.0 -In this version, a few places that used to take `Agent` as an arg, now take `AgentBase` as an arg instead. For example, the `list_tools()` call in MCP servers. This is a purely typing change, you will still receive `Agent` objects. To update, just fix type errors by replacing `Agent` with `AgentBase`. +In this version, a few places that used to take `Agent` as an arg, now take `AgentBase` as an arg instead. For example, this applies to the `list_tools()` method signature in MCP servers. This is a purely typing change, you will still receive `Agent` objects. To update, just fix type errors by replacing `Agent` with `AgentBase`. ### 0.1.0 -In this version, [`MCPServer.list_tools()`][agents.mcp.server.MCPServer] has two new params: `run_context` and `agent`. You'll need to add these params to any classes that subclass `MCPServer`. +In this version, [`MCPServer.list_tools()`][agents.mcp.server.MCPServer] has two new params: `run_context` and `agent`. You'll need to add these params to every overridden `MCPServer.list_tools()` method in subclasses of `MCPServer`. diff --git a/docs/results.md b/docs/results.md index 1e1aa86a66..d6c6985a31 100644 --- a/docs/results.md +++ b/docs/results.md @@ -59,9 +59,9 @@ In practice: When SDK-default nested handoff history preserves a message item verbatim, Sessions, `RunState`, and `to_input_list()` track the exact owned occurrence rather than deduplicating by content. Identical messages that occurred separately remain separate; only the already-owned occurrence is kept from being appended a second time. -Unlike the JavaScript SDK, Python does not expose a separate `output` property for the model-shaped delta only. Use `new_items` when you need SDK metadata, or inspect `raw_responses` when you need the raw model payloads. +Unlike the JavaScript SDK, Python does not expose a separate `output` property containing only the model-format items newly generated during the run. Use `new_items` when you need SDK metadata, or inspect `raw_responses` when you need the raw model payloads. -Computer-tool replay follows the raw Responses payload shape. Preview-model `computer_call` items preserve a single `action`, while `gpt-5.5` computer calls can preserve batched `actions[]`. [`to_input_list()`][agents.result.RunResultBase.to_input_list] and [`RunState`][agents.run_state.RunState] keep whichever shape the model produced, so manual replay, pause/resume flows, and stored transcripts continue to work across both preview and GA computer-tool calls. Local execution results still appear as `computer_call_output` items in `new_items`. +Resubmitting computer-tool items as conversation input uses the raw Responses payload shape. Preview-model `computer_call` items preserve a single `action`, while `gpt-5.5` computer calls can preserve batched `actions[]`. [`to_input_list()`][agents.result.RunResultBase.to_input_list] and [`RunState`][agents.run_state.RunState] keep whichever shape the model produced, so manually resubmitting those items as conversation input, pause/resume flows, and stored transcripts continue to work across both preview and GA computer-tool calls. Local execution results still appear as `computer_call_output` items in `new_items`. ### New items @@ -103,7 +103,7 @@ caller_id = ( ) ``` -For a program-owned child call, `caller` has type `program`, and `caller_id` identifies the parent program call. +For a program-owned child call, the `type` field of `caller` is `program`, and `caller_id` identifies the parent program call. ## Continue or resume the conversation @@ -142,7 +142,7 @@ If you already continue the conversation with `to_input_list()`, `session`, or ` ## Agent-as-tool metadata -When a result comes from a nested [`Agent.as_tool()`][agents.agent.Agent.as_tool] run, [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] exposes immutable metadata about the outer tool call: +When a result comes from a nested [`Agent.as_tool()`][agents.agent.Agent.as_tool] run, [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] exposes immutable metadata about the enclosing `Agent.as_tool()` call: - `tool_name` - `tool_call_id` @@ -150,9 +150,9 @@ When a result comes from a nested [`Agent.as_tool()`][agents.agent.Agent.as_tool For ordinary top-level runs, `agent_tool_invocation` is `None`. -This is especially useful inside `custom_output_extractor`, where you may need the outer tool name, call ID, or raw arguments while post-processing the nested result. See [Tools](tools.md) for the surrounding `Agent.as_tool()` patterns. +This is especially useful inside `custom_output_extractor`, where you may need the enclosing `Agent.as_tool()` call's tool name, call ID, or raw arguments while post-processing the nested result. See [Tools](tools.md) for the surrounding `Agent.as_tool()` patterns. -If you also need the parsed structured input for that nested run, read `context_wrapper.tool_input`. That is the field [`RunState`][agents.run_state.RunState] serializes generically for nested tool input, while `agent_tool_invocation` is the live result accessor for the current nested invocation. +If you also need the parsed structured input for that nested run, read `context_wrapper.tool_input`. That is the field [`RunState`][agents.run_state.RunState] serializes generically for nested tool input, while `agent_tool_invocation` exposes metadata for the current nested invocation directly on the result. ## Streaming lifecycle and diagnostics @@ -167,7 +167,7 @@ Keep consuming `stream_events()` until the async iterator finishes. A streaming If you call `cancel()`, continue consuming `stream_events()` so cancellation and cleanup can finish correctly. -Python does not expose a separate streamed `completed` promise or `error` property. Terminal streaming failures are surfaced by raising from `stream_events()`, and `is_complete` reflects whether the run has reached its terminal state. +Python does not expose a separate streamed `completed` promise or `error` property. Streaming failures that terminate the run are raised by `stream_events()`, and `is_complete` reflects whether the run has reached its terminal state. ### Raw responses diff --git a/docs/running_agents.md b/docs/running_agents.md index 3b0643ee50..32bd335219 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -25,7 +25,7 @@ Read more in the [results guide](results.md). ### The agent loop -When you use the run method in `Runner`, you pass in a starting agent and input. The input can be: +When you call any of the three `Runner` methods above, you pass in a starting agent and input. The input can be: - a string (treated as a user message), - a list of input items in the OpenAI Responses API format, or @@ -35,8 +35,8 @@ The runner then runs a loop: 1. We call the LLM for the current agent, with the current input. 2. The LLM produces its output. - 1. If the LLM returns a `final_output`, the loop ends and we return the result. - 2. If the LLM does a handoff, we update the current agent and input, and re-run the loop. + 1. If the runner classifies the LLM's output as final output, the loop ends and we return the result. + 2. If the LLM requests a handoff, we update the current agent and input, and re-run the loop. 3. If the LLM produces tool calls, we run those tool calls, append the results, and re-run the loop. 3. If we exceed the `max_turns` passed, we raise a [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] exception. Pass `max_turns=None` to disable this turn limit. @@ -135,7 +135,7 @@ Use `RunConfig` to override behavior for a single run without changing each agen - [`model_provider`][agents.run.RunConfig.model_provider]: A model provider for looking up model names, which defaults to OpenAI. - [`model_settings`][agents.run.RunConfig.model_settings]: Overrides agent-specific settings. For example, you can set a global `temperature` or `top_p`. - [`session_settings`][agents.run.RunConfig.session_settings]: Overrides session-level defaults (for example, `SessionSettings(limit=...)`) when retrieving history during a run. -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Customize how new user input is merged with session history before each turn when using Sessions. The callback can be sync or async. +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Customize how new user input is merged with session history before each `Runner` run when using Sessions. The callback can be sync or async. ##### Guardrails, handoffs, and model input shaping @@ -156,9 +156,9 @@ Use `RunConfig` to override behavior for a single run without changing each agen ##### Tool execution, approval, and tool error behavior -- [`tool_execution`][agents.run.RunConfig.tool_execution]: Configure SDK-side execution behavior for local tool calls, such as limiting how many function tools run at once. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: Configure how the runner handles unresolved function tool calls emitted by the model. The default raises `ModelBehaviorError`; opt in to return a model-visible error output instead. -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: Configure how the runner handles bare function-tool and handoff names that collide. The default, `"warn"`, logs an actionable warning and exposes only the current dispatch winner; `"error"` raises `UserError` before the model is called. Strict validation for namespaced and deferred-loading tools is unchanged. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: Configure SDK-side execution behavior for local tool calls, such as limiting how many local function tool calls run at once. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: Configure how the runner handles model-emitted function tool calls whose tool name does not match any function tool available to the current agent. The default raises `ModelBehaviorError`; opt in to return a model-visible error output instead. +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: Configure how the runner handles unnamespaced function-tool and handoff names that collide. The default, `"warn"`, logs an actionable warning and exposes only the current dispatch winner; `"error"` raises `UserError` before the model is called. Strict validation for namespaced and deferred-loading tools is unchanged. - [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: Customize model-visible tool error messages, such as approval rejections and opt-in tool-not-found outputs. Nested handoffs are available as an opt-in beta. Enable ordered transcript compaction by passing `RunConfig(nest_handoff_history=True)` or set `handoff(..., nest_handoff_history=True)` to turn it on for a specific handoff. The built-in mapper places generated assistant summary segments around lossless message items instead of collapsing the whole transcript into one message. If you prefer to keep the raw transcript (the default), leave the flag unset or provide a `handoff_input_filter` (or `handoff_history_mapper`) that forwards the conversation exactly as you need. To change the wrapper text used in generated summary segments without writing a custom mapper, call [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] (and [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] to restore the defaults). @@ -186,7 +186,7 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` preserves the default behavior: when a model emits multiple function tool calls in a turn, the SDK starts all emitted local function tool calls. Set an integer value to cap how many of those local function tools run at once. +`max_function_tool_concurrency=None` preserves the default behavior: when a model emits multiple function tool calls in a turn, the SDK starts all emitted local function tool calls. Set an integer value to cap how many of those local function tool calls run at once. This is separate from provider-side [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]. `parallel_tool_calls` controls whether the model is allowed to emit multiple tool calls in a single response. `tool_execution.max_function_tool_concurrency` controls how the SDK executes local function tool calls after the model has emitted them. @@ -210,7 +210,7 @@ result = await Runner.run( ) ``` -This option currently applies to unresolved function tool calls only. Other invalid tool payloads continue to use their existing error behavior. +This option currently applies only to function tool calls that fail tool-name lookup. Other invalid tool payloads continue to use their existing error behavior. ##### `tool_error_formatter` @@ -569,7 +569,7 @@ For tool approval pause/resume patterns, start with the dedicated [Human-in-the- ### Dapr -You can use the Agents SDK [Dapr](https://dapr.io) Diagrid integration to run durable, long running agents that automatically recover from failures with human-in-the-loop support. Dapr is a vendor-neutral, [CNCF](https://cncf.io) workflow orchestrator. Get started with Dapr and OpenAI agents [here](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai). +You can use the Agents SDK [Dapr](https://dapr.io) Diagrid integration to run durable, long-running agents that automatically recover from failures and support human-in-the-loop workflows. Dapr is a vendor-neutral, [CNCF](https://cncf.io) workflow orchestrator. Get started with Dapr and OpenAI agents [here](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai). ### Temporal @@ -587,11 +587,11 @@ You can use the Agents SDK [DBOS](https://dbos.dev/) integration to run reliable The SDK raises exceptions in certain cases. The full list is in [`agents.exceptions`][]. As an overview: -- [`AgentsException`][agents.exceptions.AgentsException]: This is the base class for all exceptions raised within the SDK. It serves as a generic type from which all other specific exceptions are derived. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: This exception is raised when the agent's run exceeds the `max_turns` limit passed to the `Runner.run`, `Runner.run_sync`, or `Runner.run_streamed` methods. It indicates that the agent could not complete its task within the specified number of interaction turns. Set `max_turns=None` to disable the limit. +- [`AgentsException`][agents.exceptions.AgentsException]: This is the base class for all exceptions that the SDK raises. It serves as a generic type from which all other specific exceptions are derived. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: This exception is raised when the agent's run exceeds the `max_turns` limit passed to the `Runner.run`, `Runner.run_sync`, or `Runner.run_streamed` methods. It indicates that the agent could not complete its task within the specified number of agent-loop turns (LLM calls). Set `max_turns=None` to disable the limit. - [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: This exception occurs when the underlying model (LLM) produces unexpected or invalid outputs. This can include: - Malformed JSON: When the model provides a malformed JSON structure for tool calls or in its direct output, especially if a specific `output_type` is defined. - Unexpected tool-related failures: When the model fails to use tools in an expected manner - [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: This exception is raised when a function tool call exceeds its configured timeout and the tool uses `timeout_behavior="raise_exception"`. - [`UserError`][agents.exceptions.UserError]: This exception is raised when you (the person writing code using the SDK) make an error while using the SDK. This typically results from incorrect code implementation, invalid configuration, or misuse of the SDK's API. -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: This exception is raised when the conditions of an input guardrail or output guardrail are met, respectively. Input guardrails check incoming messages before processing, while output guardrails check the agent's final response before delivery. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: `InputGuardrailTripwireTriggered` is raised when an input guardrail's conditions are met, and `OutputGuardrailTripwireTriggered` is raised when an output guardrail's conditions are met. Input guardrails check incoming messages before processing, while output guardrails check the agent's final response before delivery. diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index 45b90f04c4..7102eb917c 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -27,7 +27,7 @@ For most users, start with one of these two sandbox clients: | Client | Install | Choose it when | Example | | --- | --- | --- | --- | | `UnixLocalSandboxClient` | none | Fastest local iteration on macOS or Linux. Good default for local development. | [Unix-local starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | You want container isolation or a specific image for local parity. | [Docker starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | You want container isolation or a specific image to reproduce a target environment locally. | [Docker starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | @@ -52,7 +52,7 @@ run_config = RunConfig( ) ``` -Use this when you want container isolation or image parity. See [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py). +Use this when you want container isolation or want the sandbox image to match the image used in another environment. See [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py). ## Mounts and remote storage @@ -76,7 +76,7 @@ Generic local/container strategies: | `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | The image has `mount-s3` and you want Mountpoint-style S3 or S3-compatible access. | Supports `S3Mount` and `GCSMount`. | | `InContainerMountStrategy(pattern=FuseMountPattern(...))` | The image has `blobfuse2` and FUSE support. | Supports `AzureBlobMount`. | | `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | The image has `mount.s3files` and can reach an existing S3 Files mount target. | Supports `S3FilesMount`. | -| `DockerVolumeMountStrategy(driver=...)` | Docker should attach a volume-driver-backed mount before the container starts. | Docker-only. S3, GCS, R2, Azure Blob, and Box support `rclone`; S3 and GCS also support `mountpoint`. | +| `DockerVolumeMountStrategy(driver=...)` | Docker should attach a volume-driver-backed mount before the container starts. | Docker-only. S3, GCS, R2, Azure Blob, and Box can be mounted through `rclone`; S3 and GCS can also be mounted through `mountpoint`. | @@ -109,13 +109,13 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac | Backend | Mount notes | | --- | --- | | Docker | Supports `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, and `S3FilesMount` with local strategies such as `InContainerMountStrategy` and `DockerVolumeMountStrategy`. | -| `ModalSandboxClient` | Supports Modal cloud bucket mounts with `ModalCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. You can use inline credentials or a named Modal Secret. | -| `CloudflareSandboxClient` | Supports Cloudflare bucket mounts with `CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. | -| `BlaxelSandboxClient` | Supports cloud bucket mounts with `BlaxelCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and `GCSMount`. Also supports persistent Blaxel Drives with `BlaxelDriveMount` and `BlaxelDriveMountStrategy` from `agents.extensions.sandbox.blaxel`. | -| `DaytonaSandboxClient` | Supports rclone-backed cloud storage mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | -| `E2BSandboxClient` | Supports rclone-backed cloud storage mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | -| `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | -| `VercelSandboxClient` | Supports create-time-only S3 and S3-compatible bucket mounts with `VercelCloudBucketMountStrategy` on `S3Mount`; mounted sessions cannot be resumed, and inline credentials require `allow_s3_credential_exposure=True`. | +| `ModalSandboxClient` | Supports cloud bucket mounts by using `ModalCloudBucketMountStrategy` with `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. You can use inline credentials or a named Modal Secret. | +| `CloudflareSandboxClient` | Supports bucket mounts by using `CloudflareBucketMountStrategy` with `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. | +| `BlaxelSandboxClient` | Supports cloud bucket mounts by pairing `BlaxelCloudBucketMountStrategy` with an `S3Mount`, `R2Mount`, or `GCSMount` entry. Also supports persistent Blaxel Drives with `BlaxelDriveMount` and `BlaxelDriveMountStrategy`, both available from `agents.extensions.sandbox.blaxel`. | +| `DaytonaSandboxClient` | Supports mounting cloud storage through `rclone` by using `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | +| `E2BSandboxClient` | Supports mounting cloud storage through `rclone` by using `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | +| `RunloopSandboxClient` | Supports mounting cloud storage through `rclone` by using `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | +| `VercelSandboxClient` | Supports create-time-only S3 and S3-compatible bucket mounts by pairing `VercelCloudBucketMountStrategy` with an `S3Mount` entry; mounted sessions cannot be resumed, and inline credentials require `allow_s3_credential_exposure=True`. | diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md index 2e6e84a5d7..42733d630d 100644 --- a/docs/sandbox/guide.md +++ b/docs/sandbox/guide.md @@ -26,7 +26,7 @@ You define the workspace around the data the agent needs. It can start from GitH Throughout this page, "sandbox session" means the live execution environment managed by a sandbox client. It is different from the SDK's conversational [`Session`][agents.memory.session.Session] interfaces described in [Sessions](../sessions/index.md). -The outer runtime still owns approvals, tracing, handoffs, and resume bookkeeping. The sandbox session owns commands, file changes, and environment isolation. That split is a core part of the model. +The outer runtime still owns approvals, tracing, handoffs, and tracking the state needed to resume runs. The sandbox session owns commands, file changes, and environment isolation. That split is a core part of the model. ### How the pieces fit together @@ -54,7 +54,7 @@ Think about the lifecycle in three phases: 2. Execute a run by giving `Runner` a `SandboxRunConfig` that injects, resumes, or creates the sandbox session. 3. Continue later from runner-managed `RunState`, explicit sandbox `session_state`, or a saved workspace snapshot. -If shell access is only one occasional tool, start with hosted shell in the [tools guide](../tools.md). Reach for sandbox agents when workspace isolation, sandbox client choice, or sandbox-session resume behavior are part of the design. +If shell access is just one tool that you use occasionally, start with hosted shell in the [tools guide](../tools.md). Reach for sandbox agents when workspace isolation, sandbox client choice, or sandbox-session resume behavior are part of the design. ## When to use them @@ -66,7 +66,7 @@ Sandbox agents are a good fit for workspace-centric workflows, for example: - isolated multi-agent patterns, for example giving each reviewer or coding sub-agent its own workspace - multi-step workspace tasks, for example fixing a bug in one run and adding a regression test later, or resuming from snapshot or sandbox session state -If you do not need access to files or a living filesystem, keep using `Agent`. If shell access is just one occasional capability, add hosted shell; if the workspace boundary itself is part of the feature, use sandbox agents. +If you do not need access to files or a stateful, mutable filesystem, keep using `Agent`. If shell access is just one occasional capability, add hosted shell; if the workspace boundary itself is part of the feature, use sandbox agents. ## Choose a sandbox client @@ -119,7 +119,7 @@ At run time, the runner turns that definition into a concrete sandbox-backed run 4. It builds the final instructions in a fixed order: the SDK's default sandbox prompt, or `base_instructions` if you explicitly override it, then `instructions`, then capability instruction fragments, then any remote-mount policy text, then a rendered filesystem tree. 5. It binds capability tools to the live sandbox session and runs the prepared agent through the normal `Runner` APIs. -Sandboxing does not change what a turn means. A turn is still a model step, not a single shell command or sandbox action. There is no fixed 1:1 mapping between sandbox-side operations and turns: some work may stay inside the sandbox execution layer, while other actions return tool results, approvals, or other state that requires another model step. As a practical rule, another turn is consumed only when the agent runtime needs another model response after sandbox work has happened. +Sandboxing does not change what a turn means. A turn is still a model step, not a single shell command or sandbox action. There is no fixed 1:1 mapping between sandbox-side operations and turns: some work may stay inside the sandbox execution layer, while other actions return information that requires another model step, such as a tool result, an approval, or another kind of state. As a practical rule, another turn is consumed only when the agent runtime needs another model response after sandbox work has happened. Those preparation steps are why `default_manifest`, `instructions`, `base_instructions`, `capabilities`, and `run_as` are the main sandbox-specific options to think about when designing a `SandboxAgent`. @@ -188,7 +188,7 @@ Built-in capabilities include: | `Shell` | The agent needs shell access. | Adds `exec_command`, plus `write_stdin` when the sandbox client supports PTY interaction. | | `Filesystem` | The agent needs to edit files or inspect local images. | Adds `apply_patch` and `view_image`; patch paths are workspace-root-relative. | | `Skills` | You want skill discovery and materialization in the sandbox. | Prefer this over manually mounting `.agents` or `.agents/skills`; `Skills` indexes and materializes skills into the sandbox for you. | -| `Memory` | Follow-on runs should read or generate memory artifacts. | Requires `Shell`; live updates also require `Filesystem`. | +| `Memory` | Follow-on runs should read or generate memory artifacts. | Requires `Shell`; updating memory artifacts during a run also requires `Filesystem`. | | `Compaction` | Long-running flows need context trimming after compaction items. | Adjusts model sampling and input handling. | @@ -237,7 +237,7 @@ Mount entries describe what storage to expose; mount strategies describe how a s Good manifest design usually means keeping the workspace contract narrow, putting long task recipes in workspace files such as `repo/task.md`, and using relative workspace paths in instructions, for example `repo/task.md` or `output/report.md`. If the agent edits files with the `Filesystem` capability's `apply_patch` tool, remember that patch paths are relative to the sandbox workspace root, not the shell `workdir`. -Use `extra_path_grants` only when the agent needs a concrete absolute path outside the workspace or the manifest needs to copy a trusted local source outside the SDK process working directory. Examples include `/tmp` for temporary tool output, `/opt/toolchain` for a read-only runtime, or a generated skills directory that should be materialized into the sandbox. A grant applies to local source materialization, SDK file APIs, and shell execution where the backend can enforce filesystem policy: +Use `extra_path_grants` only when the agent needs a concrete absolute path outside the workspace or the manifest needs to copy a trusted local source outside the SDK process working directory. Examples include `/tmp` for temporary tool output, `/opt/toolchain` for a read-only runtime, or a generated skills directory that should be materialized into the sandbox. A grant applies to local source materialization and SDK file APIs. It also applies to shell execution when the backend can enforce filesystem policy: ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -387,7 +387,7 @@ sequenceDiagram -Use SDK-owned lifecycle when the sandbox only needs to live for one run. Pass a `client`, optional `manifest`, optional `snapshot`, and client `options`; the runner creates or resumes the sandbox, starts it, runs the agent, persists snapshot-backed workspace state, shuts the sandbox down, and lets the client clean up runner-owned resources. +Use SDK-owned lifecycle when the sandbox only needs to live for one run. Pass a `client`, optionally a `manifest` and `snapshot`, and any client `options` you need; the runner creates or resumes the sandbox, starts it, runs the agent, persists snapshot-backed workspace state, ends the sandbox session, and lets the client clean up runner-owned resources. ```python result = await Runner.run( @@ -447,7 +447,7 @@ These options decide whether the runner should reuse, resume, or create the sand | --- | --- | --- | | `client` | You want the runner to create, resume, and clean up sandbox sessions for you. | Required unless you provide a live sandbox `session`. | | `session` | You already created a live sandbox session yourself. | The caller owns lifecycle; the runner reuses that live sandbox session. | -| `session_state` | You have serialized sandbox session state but not a live sandbox session object. | Requires `client`; the runner resumes from that explicit state as an owning session. | +| `session_state` | You have serialized sandbox session state but not a live sandbox session object. | Requires `client`; the runner resumes from that explicit state and owns the resumed session's lifecycle. | @@ -668,7 +668,7 @@ run_config = RunConfig( ) ``` -Use this when a fresh run should start from saved workspace contents rather than only `agent.default_manifest`. See [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) for a local snapshot flow and [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) for a remote snapshot client. +Use this when a run that creates a fresh sandbox session should start from saved workspace contents rather than only `agent.default_manifest`. See [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) for a local snapshot flow and [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) for a remote snapshot client. ### Load skills from Git @@ -687,7 +687,7 @@ Use this when the skills bundle has its own release cadence or should be shared ### Expose as tools -Tool-agents can either get their own sandbox boundary or reuse a live sandbox from the parent run. Reuse is useful for a fast read-only explorer agent: it can inspect the exact workspace the parent is using without paying to create, hydrate, or snapshot another sandbox. +Tool-agents can either get their own sandbox boundary or reuse a live sandbox from the parent run. Reuse is useful for a fast read-only explorer agent: it can inspect the exact workspace the parent run is using without paying to create, hydrate, or snapshot another sandbox. ```python from agents import Runner diff --git a/docs/sandbox/memory.md b/docs/sandbox/memory.md index 94086fcaec..45d9d518a2 100644 --- a/docs/sandbox/memory.md +++ b/docs/sandbox/memory.md @@ -42,7 +42,7 @@ If read is enabled, `Memory()` requires `Shell()`, which lets the agent read and By default, memory artifacts are stored in the sandbox workspace under `memories/`. To reuse them in a later run, preserve and reuse the whole configured memories directory by keeping the same live sandbox session or resuming from a persisted session state or snapshot; a fresh empty sandbox starts with empty memory. -`Memory()` enables both reading and generating memories. Use `Memory(generate=None)` for agents that should read memory but should not generate new memories: for example, an internal agent, subagent, checker, or one-off tool agent whose run doesn't add much signal. Use `Memory(read=None)` when the run should generate memory for later, but the user doesn't want the run to be influenced by existing memory. +`Memory()` enables both reading and generating memories. Use `Memory(generate=None)` for agents that should read memory but should not generate new memories—for example, when runs by internal agents, subagents, checkers, or one-off tool agents do not add much signal. Use `Memory(read=None)` when the run should generate memory for later, but the user doesn't want the run to be influenced by existing memory. ## Read memory @@ -128,7 +128,7 @@ async with sandbox: ) ``` -Both runs append to one memory conversation file because they pass the same SDK conversation session (`session=conversation_session`) and therefore share the same `session.session_id`. This is different from the sandbox (`sandbox`), which identifies the live workspace and is not used as the memory conversation ID. Phase 1 sees the accumulated conversation when the sandbox session closes, so it can extract memory from the whole exchange instead of two isolated turns. +Both runs pass the same SDK conversation session (`session=conversation_session`) and therefore share the same `session.session_id`. As a result, both runs append to one memory conversation file. This is different from the sandbox (`sandbox`), which identifies the live workspace and is not used as the memory conversation ID. Phase 1 sees the accumulated conversation when the sandbox session closes, so it can extract memory from the whole exchange instead of two isolated turns. If you want multiple `Runner.run(...)` calls to become one memory conversation, pass a stable identifier across those calls. When memory associates a run with a conversation, it resolves in this order: diff --git a/docs/sandbox_agents.md b/docs/sandbox_agents.md index 96b2578c6a..9177ef5db7 100644 --- a/docs/sandbox_agents.md +++ b/docs/sandbox_agents.md @@ -30,7 +30,7 @@ pip install "openai-agents[docker]" ## Create a local sandbox agent -This example stages a local repo under `repo/`, loads local skills lazily, and lets the runner create a Unix-local sandbox session for the run. +This example stages a local repo under `repo/`, loads local skills lazily, and has the runner create a Unix-local sandbox session for the run. ```python import asyncio @@ -99,8 +99,8 @@ Once the basic run works, the choices most people reach for next are: - `default_manifest`: the files, repos, directories, and mounts for fresh sandbox sessions - `instructions`: short workflow rules that should apply across prompts - `base_instructions`: an advanced escape hatch for replacing the SDK sandbox prompt -- `capabilities`: sandbox-native tools such as filesystem editing/image inspection, shell, skills, memory, and compaction -- `run_as`: the sandbox user identity for model-facing tools +- `capabilities`: sandbox-native tools such as filesystem editing/image inspection, shell, skills, memory, and the SDK's compaction mechanism +- `run_as`: the sandbox user account under which model-facing tools execute - `SandboxRunConfig.client`: the sandbox backend - `SandboxRunConfig.session`, `session_state`, or `snapshot`: how later runs reconnect to prior work @@ -110,4 +110,4 @@ Once the basic run works, the choices most people reach for next are: - [Sandbox clients](sandbox/clients.md): choose Unix-local, Docker, hosted providers, and mount strategies. - [Agent memory](sandbox/memory.md): preserve and reuse lessons from previous sandbox runs. -If shell access is only one occasional tool, start with hosted shell in the [tools guide](tools.md). Reach for sandbox agents when workspace isolation, sandbox client choice, or sandbox-session resume behavior are part of the design. +If shell access is just one tool that you use occasionally, start with hosted shell in the [tools guide](tools.md). Reach for sandbox agents when workspace isolation, sandbox client choice, or sandbox-session resume behavior are part of the design. diff --git a/docs/scripts/translate_docs.py b/docs/scripts/translate_docs.py index 3857714fb8..adaf49cc2e 100644 --- a/docs/scripts/translate_docs.py +++ b/docs/scripts/translate_docs.py @@ -1,8 +1,10 @@ # ruff: noqa -import os -import sys import argparse +import os +import re import subprocess +import sys +from collections import Counter from pathlib import Path from openai import OpenAI from concurrent.futures import ThreadPoolExecutor @@ -98,7 +100,6 @@ "orchestrating multiple agents": "에이전트 오케스트레이션", "handoffs": "핸드오프", "function tools": "함수 도구", - "function calling": "함수 호출", "tracing": "트레이싱", "code examples": "코드 예제", "vector store": "벡터 스토어", @@ -118,7 +119,6 @@ "Human in the loop": "휴먼인더루프 (HITL)", "Hosted tool": "호스티드 툴", "Hosted MCP server tools": "호스티드 MCP 서버 도구", - "raw": "원문", "Realtime Agents": "실시간 에이전트", "Build your first agent in minutes.": "단 몇 분 만에 첫 에이전트를 만들 수 있습니다", "Let's build": "시작하기", @@ -132,16 +132,13 @@ "well formed data": "格式良好的数据", "guardrail": "安全防护措施", "handoffs": "任务转移", - "function tools": "工具调用", + "function tools": "函数工具", "tracing": "追踪", "code examples": "代码示例", "vector store": "向量存储", "deep research": "深度研究", - "category": "目录", "user": "用户", "parameter": "参数", - "processor": "进程", - "server": "服务", "web search": "网络检索", "file search": "文件检索", "streaming": "流式传输", @@ -155,6 +152,9 @@ "common": [ "* The term 'examples' must be code examples when the page mentions the code examples in the repo, it can be translated as either 'code examples' or 'sample code'.", "* The term 'primitives' can be translated as basic components.", + "* Prefer established technical usage in the target language. Do not invent an awkward localized alternative solely to avoid an English term when that English term is standard in developer documentation.", + "* Preserve distinctions between SDK concepts. For example, a function tool is not a tool call, a processor is not a process, and a server is not automatically a service.", + "* In Python packaging contexts, 'extras' means installable optional-dependency extras, not dependency groups. Keep 'extras' in English when a literal translation would be unfamiliar or ambiguous.", "* When the terms 'instructions' and 'tools' are mentioned as API parameter names, they must be kept as is.", "* The terms 'temperature', 'top_p', 'max_tokens', 'presence_penalty', 'frequency_penalty' as parameter names must be kept as is.", "* Keep the original structure like `* **The thing**: foo`; this needs to be translated as `* **(translation)**: (translation)`", @@ -168,6 +168,7 @@ "ko": [ "* 공손하고 중립적인 문체(합니다/입니다체)를 일관되게 사용하세요.", "* 개발자 문서이므로 자연스러운 의역을 허용하되 정확성을 유지하세요.", + "* 기술 문맥의 'raw'는 가공되지 않은 저수준 데이터라는 뜻입니다. 문맥에 따라 자연스럽게 번역하거나 영어 'raw'를 유지하되, 원문(source text)이라는 뜻으로 번역하지 마세요.", "* 'instructions', 'tools' 같은 API 매개변수와 temperature, top_p, max_tokens, presence_penalty, frequency_penalty 등은 영문 그대로 유지하세요.", "* 문장이 아닌 불릿 항목 끝에는 마침표를 찍지 마세요.", ], @@ -210,7 +211,7 @@ def built_instructions(target_language: str, lang_code: str) -> str: - Do not change the markdown data structure, including the indentations. - Section titles starting with # or ## must be a noun form rather than a sentence. - Section titles must be translated except for the Do-Not-Translate list. -- Keep all placeholders such as `CODE_BLOCK_*` and `CODE_LINE_PREFIX` unchanged. +- Keep all placeholders such as `CODE_BLOCK_*`, `INLINE_CODE_*`, and `CODE_LINE_PREFIX` unchanged. - Convert asset paths: `./assets/…` → `../assets/…`. *Example:* `![img](./assets/pic.png)` → `![img](../assets/pic.png)` - Treat the **Do‑Not‑Translate list** and **Term‑Specific list** as case‑insensitive; preserve the original casing you see. @@ -223,6 +224,7 @@ def built_instructions(target_language: str, lang_code: str) -> str: ## HARD CONSTRAINTS ## ######################### - Never insert spaces immediately inside emphasis markers. Use `**bold**`, not `** bold **`. +- Preserve every source inline-code span exactly once. Do not add, remove, duplicate, split, merge, or translate inline-code spans. Keep each span with the text it describes, but move it when target-language grammar requires a different word order. - Preserve the number of emphasis markers from the source: if the source uses `**` or `__`, keep the same pair count. - Ensure one space after heading markers: `##Heading` -> `## Heading`. - Ensure one space after list markers: `-Item` -> `- Item`, `*Item` -> `* Item` (does not apply to `**`). @@ -292,77 +294,272 @@ def built_instructions(target_language: str, lang_code: str) -> str: """ +FENCE_OPENING_PATTERN = re.compile(r"^[ \t]*(?P`{3,}|~{3,})(?P.*)$") + + +def opening_fence(line: str) -> tuple[str, int] | None: + match = FENCE_OPENING_PATTERN.match(line) + if match is None: + return None + marker = match.group("marker") + if marker[0] == "`" and "`" in match.group("info"): + return None + return marker[0], len(marker) + + +def is_closing_fence(line: str, marker: str, minimum_length: int) -> bool: + return re.fullmatch(rf"[ \t]*{re.escape(marker)}{{{minimum_length},}}[ \t]*", line) is not None + + +def fenced_code_ranges(markdown: str) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + open_fence: tuple[str, int] | None = None + block_start = 0 + offset = 0 + for line_with_ending in markdown.splitlines(keepends=True): + line = line_with_ending.rstrip("\r\n") + line_end = offset + len(line) + if open_fence is None: + opening = opening_fence(line) + if opening is not None: + open_fence = opening + block_start = offset + elif is_closing_fence(line, *open_fence): + ranges.append((block_start, line_end)) + open_fence = None + offset += len(line_with_ending) + if open_fence is not None: + raise ValueError("Unclosed fenced code block") + return ranges + + +def fenced_code_blocks(markdown: str) -> list[str]: + return [markdown[start:end] for start, end in fenced_code_ranges(markdown)] + + +def remove_fenced_code_blocks(markdown: str) -> str: + parts: list[str] = [] + cursor = 0 + for start, end in fenced_code_ranges(markdown): + parts.append(markdown[cursor:start]) + cursor = end + parts.append(markdown[cursor:]) + return "".join(parts) + + +def protect_fenced_code(markdown: str, *, namespace: str) -> tuple[str, list[str]]: + parts: list[str] = [] + code_blocks: list[str] = [] + cursor = 0 + for index, (start, end) in enumerate(fenced_code_ranges(markdown)): + parts.append(markdown[cursor:start]) + parts.append(code_block_placeholder(namespace, index)) + code_blocks.append(markdown[start:end]) + cursor = end + parts.append(markdown[cursor:]) + return "".join(parts), code_blocks + + +def backtick_run_end(markdown: str, start: int) -> int: + end = start + 1 + while end < len(markdown) and markdown[end] == "`": + end += 1 + return end + + +def inline_code_ranges(markdown: str) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + cursor = 0 + while cursor < len(markdown): + opener_start = markdown.find("`", cursor) + if opener_start < 0: + break + opener_end = backtick_run_end(markdown, opener_start) + delimiter_length = opener_end - opener_start + search_from = opener_end + matching_closer_end: int | None = None + while search_from < len(markdown): + closer_start = markdown.find("`", search_from) + if closer_start < 0: + break + closer_end = backtick_run_end(markdown, closer_start) + if closer_end - closer_start == delimiter_length: + matching_closer_end = closer_end + break + search_from = closer_end + if matching_closer_end is None: + cursor = opener_end + else: + ranges.append((opener_start, matching_closer_end)) + cursor = matching_closer_end + return ranges + + +def inline_code_spans(markdown: str) -> list[str]: + without_fences = remove_fenced_code_blocks(markdown) + return [without_fences[start:end] for start, end in inline_code_ranges(without_fences)] + + +def inline_code_spans_match(source: str, translated: str) -> bool: + try: + return Counter(inline_code_spans(source)) == Counter(inline_code_spans(translated)) + except ValueError: + return False + + +def fenced_code_blocks_match(source: str, translated: str) -> bool: + try: + return fenced_code_blocks(source) == fenced_code_blocks(translated) + except ValueError: + return False + + +def placeholder_namespace(markdown: str) -> str: + namespace_index = 0 + while True: + namespace = f"T{namespace_index}_" + if f"CODE_BLOCK_{namespace}" not in markdown and f"INLINE_CODE_{namespace}" not in markdown: + return namespace + namespace_index += 1 + + +def code_block_placeholder(namespace: str, index: int) -> str: + return f"CODE_BLOCK_{namespace}{index:03}" + + +def inline_code_placeholder(namespace: str, index: int) -> str: + return f"`INLINE_CODE_{namespace}{index:04}`" + + +def restore_placeholders(markdown: str, replacements: dict[str, str]) -> str: + if not replacements: + return markdown + placeholders = sorted(replacements, key=len, reverse=True) + pattern = re.compile("|".join(re.escape(value) for value in placeholders)) + return pattern.sub(lambda match: replacements[match.group(0)], markdown) + + +def placeholders_preserved(markdown: str, placeholders: list[str]) -> bool: + return all(markdown.count(placeholder) == 1 for placeholder in placeholders) + + +def protect_inline_code( + markdown: str, *, namespace: str = "", start_index: int = 0 +) -> tuple[str, list[str]]: + parts: list[str] = [] + inline_codes: list[str] = [] + cursor = 0 + for start, end in inline_code_ranges(markdown): + parts.append(markdown[cursor:start]) + parts.append(inline_code_placeholder(namespace, start_index + len(inline_codes))) + inline_codes.append(markdown[start:end]) + cursor = end + parts.append(markdown[cursor:]) + return "".join(parts), inline_codes + + +def restore_inline_code(markdown: str, inline_codes: list[str], *, namespace: str = "") -> str: + replacements = { + inline_code_placeholder(namespace, idx): inline_code + for idx, inline_code in enumerate(inline_codes) + } + return restore_placeholders(markdown, replacements) + + +def restore_code_blocks(markdown: str, code_blocks: list[str], *, namespace: str) -> str: + replacements = { + code_block_placeholder(namespace, idx): code_block + for idx, code_block in enumerate(code_blocks) + } + return restore_placeholders(markdown, replacements) + + +def translate_chunk(chunk: str, instructions: str) -> str: + if OPENAI_MODEL.startswith("gpt-5"): + response = openai_client.responses.create( + model=OPENAI_MODEL, + instructions=instructions, + input=chunk, + reasoning={"effort": "high"}, + text={"verbosity": "medium"}, + ) + elif OPENAI_MODEL.startswith("o"): + response = openai_client.responses.create( + model=OPENAI_MODEL, + instructions=instructions, + input=chunk, + ) + else: + response = openai_client.responses.create( + model=OPENAI_MODEL, + instructions=instructions, + input=chunk, + temperature=0.0, + ) + return response.output_text + + # Function to translate and save files def translate_file(file_path: str, target_path: str, lang_code: str) -> None: print(f"Translating {file_path} into a different language: {lang_code}") with open(file_path, encoding="utf-8") as f: content = f.read() + namespace = placeholder_namespace(content) + + if ENABLE_CODE_SNIPPET_EXCLUSION is True: + protected_content, code_blocks = protect_fenced_code(content, namespace=namespace) + else: + protected_content = content + code_blocks = [] # Split content into lines - lines: list[str] = content.splitlines() + lines: list[str] = protected_content.splitlines() chunks: list[str] = [] current_chunk: list[str] = [] # Split content into chunks of up to 120 lines, ensuring splits occur before section titles - in_code_block = False - code_blocks: list[str] = [] - code_block_chunks: list[str] = [] for line in lines: if ( ENABLE_SMALL_CHUNK_TRANSLATION is True and len(current_chunk) >= 120 # required for gpt-4.5 - and not in_code_block and line.startswith("#") ): chunks.append("\n".join(current_chunk)) current_chunk = [] - if ENABLE_CODE_SNIPPET_EXCLUSION is True and line.strip().startswith("```"): - code_block_chunks.append(line) - if in_code_block is True: - code_blocks.append("\n".join(code_block_chunks)) - current_chunk.append(f"CODE_BLOCK_{(len(code_blocks) - 1):03}") - code_block_chunks.clear() - in_code_block = not in_code_block - continue - if in_code_block is True: - code_block_chunks.append(line) - else: - current_chunk.append(line) + current_chunk.append(line) if current_chunk: chunks.append("\n".join(current_chunk)) - # Translate each chunk separately and combine results - translated_content: list[str] = [] + inline_codes: list[str] = [] + protected_chunks: list[str] = [] for chunk in chunks: - instructions = built_instructions(languages[lang_code], lang_code) - if OPENAI_MODEL.startswith("gpt-5"): - response = openai_client.responses.create( - model=OPENAI_MODEL, - instructions=instructions, - input=chunk, - reasoning={"effort": "high"}, - text={"verbosity": "medium"}, - ) - translated_content.append(response.output_text) - elif OPENAI_MODEL.startswith("o"): - response = openai_client.responses.create( - model=OPENAI_MODEL, - instructions=instructions, - input=chunk, - ) - translated_content.append(response.output_text) - else: - response = openai_client.responses.create( - model=OPENAI_MODEL, - instructions=instructions, - input=chunk, - temperature=0.0, - ) - translated_content.append(response.output_text) - - translated_text = "\n".join(translated_content) - for idx, code_block in enumerate(code_blocks): - translated_text = translated_text.replace(f"CODE_BLOCK_{idx:03}", code_block) + protected_chunk, chunk_inline_codes = protect_inline_code( + chunk, namespace=namespace, start_index=len(inline_codes) + ) + protected_chunks.append(protected_chunk) + inline_codes.extend(chunk_inline_codes) + chunks = protected_chunks + + instructions = built_instructions(languages[lang_code], lang_code) + translated_text = "" + for _attempt in range(3): + translated_text = "\n".join(translate_chunk(chunk, instructions) for chunk in chunks) + placeholders = [ + *(code_block_placeholder(namespace, idx) for idx in range(len(code_blocks))), + *(inline_code_placeholder(namespace, idx) for idx in range(len(inline_codes))), + ] + if not placeholders_preserved(translated_text, placeholders): + continue + translated_text = restore_inline_code(translated_text, inline_codes, namespace=namespace) + translated_text = restore_code_blocks(translated_text, code_blocks, namespace=namespace) + if inline_code_spans_match(content, translated_text) and fenced_code_blocks_match( + content, translated_text + ): + break + else: + raise ValueError( + f"Protected Markdown changed after 3 translation attempts for {file_path} to {lang_code}" + ) # FIXME: enable mkdocs search plugin to seamlessly work with i18n plugin translated_text = SEARCH_EXCLUSION + translated_text diff --git a/docs/sessions/advanced_sqlite_session.md b/docs/sessions/advanced_sqlite_session.md index b10082b960..1247c772dc 100644 --- a/docs/sessions/advanced_sqlite_session.md +++ b/docs/sessions/advanced_sqlite_session.md @@ -81,7 +81,7 @@ session = AdvancedSQLiteSession( ### Parameters - `session_id` (str): Unique identifier for the conversation session -- `db_path` (str | Path): Path to SQLite database file. Defaults to `:memory:` for in-memory storage +- `db_path` (str | Path): Path to SQLite database file. Defaults to `:memory:`, which uses in-memory storage - `create_tables` (bool): Whether to automatically create the advanced tables. Defaults to `False` - `logger` (logging.Logger | None): Custom logger for the session. Defaults to module logger @@ -245,7 +245,7 @@ for turn in matching_turns: The session automatically tracks message structure including: -- Message types (user, assistant, tool_call, etc.) +- Message type values (`user`, `assistant`, `tool_call`, etc.) - Tool names for tool calls - Turn numbers and sequence numbers - Branch associations @@ -284,7 +284,7 @@ CREATE TABLE branch_reservations ( ); ``` -This table atomically reserves branch IDs, including branches whose copied prefix is empty. Reservation rows are retained after branch deletion and session clearing so stale session instances cannot merge history into a later branch that reused the same ID. +This table atomically reserves branch IDs, including branches whose copied prefix is empty. Reservation rows are retained both when a branch is deleted and when the session is cleared, so stale session instances cannot merge history into a later branch that reused the same ID. ### turn_usage table diff --git a/docs/sessions/index.md b/docs/sessions/index.md index 2c0cd5f2c1..b9acdaf9cd 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -4,7 +4,7 @@ The Agents SDK provides built-in session memory to automatically maintain conver Sessions stores conversation history for a specific session, allowing agents to maintain context without requiring explicit manual memory management. This is particularly useful for building chat applications or multi-turn conversations where you want the agent to remember previous interactions. -Use sessions when you want the SDK to manage client-side memory for you. Sessions cannot be combined with `conversation_id`, `previous_response_id`, or `auto_previous_response_id` in the same run. If you want OpenAI server-managed continuation instead, choose one of those mechanisms rather than layering a session on top. +Use sessions when you want the SDK to manage client-side memory for you. In the same run, a session cannot be combined with the run-level continuation options `conversation_id`, `previous_response_id`, or `auto_previous_response_id`. If you want OpenAI server-managed continuation instead, choose one of those mechanisms rather than layering a session on top. ## Quick start @@ -47,7 +47,7 @@ print(result.final_output) # "Approximately 39 million" ## Resuming interrupted runs with the same session -If a run pauses for approval, resume it with the same session instance (or another session instance that points at the same backing store) so the resumed turn continues the same stored conversation history. +If a run pauses for approval, resume it with the same session instance (or another instance configured with the same session ID and the same underlying storage backend) so the resumed turn continues the same stored conversation history. ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -130,7 +130,7 @@ result = await Runner.run( ) ``` -If your session implementation exposes default session settings, `RunConfig.session_settings` overrides any non-`None` values for that run. This is useful for long conversations where you want to cap retrieval size without changing the session's default behavior. +If your session implementation exposes default session settings, each non-`None` value in `RunConfig.session_settings` overrides the corresponding default for that run. This is useful for long conversations where you want to cap retrieval size without changing the session's default behavior. ## Memory operations @@ -274,9 +274,9 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -By default, compaction runs after each turn once the candidate threshold is reached. +By default, after each turn, the SDK checks whether the compaction candidate meets the threshold and compacts only if it does. -`compaction_mode="previous_response_id"` works best when you are already chaining turns with Responses API response IDs. `compaction_mode="input"` rebuilds the compaction request from the current session items instead, which is useful when the response chain is unavailable or you want the session contents to be the source of truth. The default `"auto"` chooses the safest available option. +`compaction_mode="previous_response_id"` uses Responses API response IDs retained by the compaction session and works best while that response chain remains available. `compaction_mode="input"` rebuilds the compaction request from the current session items instead, which is useful when the response chain is unavailable or you want the session contents to be the source of truth. The default `"auto"` chooses the safest available option. If your agent runs with `ModelSettings(store=False)`, the Responses API does not retain the last response for later lookup. In that stateless setup, the default `"auto"` mode falls back to input-based compaction instead of relying on `previous_response_id`. See [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py) for a complete example. @@ -390,7 +390,7 @@ See [SQLAlchemy Sessions](sqlalchemy_session.md) for detailed documentation. ### Dapr sessions -Use `DaprSession` when you already run Dapr sidecars or want session storage that can move across different state-store backends without changing your agent code. +Use `DaprSession` when you already run Dapr sidecars or want to switch the configured state-store backend without changing your agent code. ```bash pip install openai-agents[dapr] @@ -415,7 +415,7 @@ Notes: - `from_address(...)` creates and owns the Dapr client for you. If your app already manages one, construct `DaprSession(...)` directly with `dapr_client=...`. - Exiting the context or calling `close()` makes an owned-client session terminal; subsequent session operations raise `RuntimeError`, while repeated or concurrent `close()` calls are safe. With an injected client, `close()` is a no-op and the session remains usable. -- Pass `ttl=...` to let the backing state store expire old session data automatically when the store supports TTL. +- If the backing state store supports TTL, pass `ttl=...` so it automatically applies TTL expiration to the session data. - Pass `consistency=DAPR_CONSISTENCY_STRONG` when you need stronger read-after-write guarantees. - The Dapr Python SDK also checks the HTTP sidecar endpoint. In local development, start Dapr with `--dapr-http-port 3500` as well as the gRPC port used in `dapr_address`. - See [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py) for a full setup walkthrough, including local components and troubleshooting. @@ -448,7 +448,7 @@ await session.close() Notes: -- `from_uri(...)` creates and owns the `AsyncMongoClient` and closes it on `session.close()`. An owned-client session is terminal after `close()`, and subsequent session operations raise `RuntimeError`. If your application already manages a client, construct `MongoDBSession(...)` directly with `client=...`; in that case `session.close()` is a no-op, and lifecycle plus session usability stay with the caller. +- `from_uri(...)` creates and owns the `AsyncMongoClient` and closes it on `session.close()`. An owned-client session is terminal after `close()`, and subsequent session operations raise `RuntimeError`. If your application already manages a client, construct `MongoDBSession(...)` directly with `client=...`; in that case, `session.close()` is a no-op, the caller retains responsibility for the client lifecycle, and the session remains usable. - Connect to [MongoDB Atlas](https://www.mongodb.com/products/platform) by passing an `mongodb+srv://user:password@cluster.example.mongodb.net` URI to `from_uri(...)` with no other changes. - Two collections are used and both names are configurable via `sessions_collection=` (default `agent_sessions`) and `messages_collection=` (default `agent_messages`). Indexes are created automatically on first use. Each non-empty `add_items()` call writes one logical-batch document whose monotonically increasing `seq` orders the batch by its final item; legacy per-item message documents remain readable. A logical batch must fit within MongoDB's single-document size limit; an oversized batch fails atomically without storing a partial batch. - Use `await session.ping()` to verify connectivity before your first run. @@ -526,7 +526,7 @@ Use meaningful session IDs that help you organize conversations: - Use Redis-backed sessions (`RedisSession.from_url("session_id", url="redis://...")`) for shared, low-latency session memory - Use SQLAlchemy-powered sessions (`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) for production systems with existing databases supported by SQLAlchemy - Use MongoDB sessions (`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) for applications already using MongoDB or needing multi-process, horizontally-scalable session storage -- Use Dapr state store sessions (`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) for production cloud-native deployments with support for 30+ database backends with built-in telemetry, tracing, and data isolation +- Use Dapr state store sessions (`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) for production cloud-native deployments with built-in telemetry, tracing, and data isolation and support for 30+ database backends - Use OpenAI-hosted storage (`OpenAIConversationsSession()`) when you prefer to store history in the OpenAI Conversations API - Use encrypted sessions (`EncryptedSession(session_id, underlying_session, encryption_key)`) to wrap any session with transparent encryption and TTL-based expiration - Consider implementing custom session backends for other production systems (for example, Django) for more advanced use cases diff --git a/docs/sessions/sqlalchemy_session.md b/docs/sessions/sqlalchemy_session.md index c5eeab73fe..1511d3ff0a 100644 --- a/docs/sessions/sqlalchemy_session.md +++ b/docs/sessions/sqlalchemy_session.md @@ -4,7 +4,7 @@ ## Installation -SQLAlchemy sessions require the `sqlalchemy` extra: +SQLAlchemy sessions require the `sqlalchemy` optional-dependency extra from the `openai-agents` package: ```bash pip install openai-agents[sqlalchemy] diff --git a/docs/streaming.md b/docs/streaming.md index 2e7f408373..f4abec97cc 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -8,7 +8,7 @@ Keep consuming `result.stream_events()` until the async iterator finishes. A str ## Raw response events -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] are raw events passed directly from the LLM. They are in OpenAI Responses API format, which means each event has a type (like `response.created`, `response.output_text.delta`, etc) and data. These events are useful if you want to stream response messages to the user as soon as they are generated. +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] objects wrap raw events passed directly from the LLM. Each object's `data` field contains an OpenAI Responses API event with a type such as `response.created` or `response.output_text.delta`. These events are useful if you want to stream response messages to the user as soon as they are generated. Computer-tool raw events keep the same preview-vs-GA distinction as stored results. Preview flows stream `computer_call` items with one `action`, while `gpt-5.5` can stream `computer_call` items with batched `actions[]`. The higher-level [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] surface does not add a special computer-only event name for this: both shapes still surface as `tool_called`, and the screenshot result comes back as `tool_output` wrapping a `computer_call_output` item. @@ -61,7 +61,7 @@ If you need to stop a streaming run in the middle, call [`result.cancel()`][agen A streamed run is not complete until `result.stream_events()` finishes. The SDK may still be persisting session items, finalizing approval state, or compacting history after the last visible token. -If you are manually continuing from [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list], and `cancel(mode="after_turn")` stops after a tool turn, continue that unfinished turn by rerunning `result.last_agent` with that normalized input instead of appending a fresh user turn right away. +If you are manually continuing from [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list], and `cancel(mode="after_turn")` stops after a tool turn, rerun `result.last_agent` with that normalized input to continue the unfinished existing user turn instead of appending a fresh user turn right away. - If a streamed run stopped for tool approval, do not treat that as a new turn. Finish draining the stream, inspect `result.interruptions`, and resume from `result.to_state()` instead. - Use [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] to customize how retrieved session history and the new user input are merged before the next model call. If you rewrite new-turn items there, the rewritten version is what gets persisted for that turn. @@ -91,7 +91,7 @@ A handoff call is emitted only as `handoff_requested`; it is not also emitted as When you use hosted tool search, `tool_search_called` is emitted when the model issues a tool-search request and `tool_search_output_created` is emitted when the Responses API returns the loaded subset. -With Programmatic Tool Calling, `tool_called` is emitted for the generated `program` and for ordinary program-owned child tool calls. `tool_output` is emitted for child tool outputs and the matching `program_output`. Program-owned hosted MCP `mcp_approval_request` and `mcp_list_tools` items are exceptions: they are emitted as `mcp_approval_requested` and `mcp_list_tools`, wrapping [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] and [`MCPListToolsItem`][agents.items.MCPListToolsItem], respectively. Inspect the raw item's `type` to distinguish the remaining items; program-owned child calls also carry a `caller` whose type is `program` and whose caller ID identifies the parent program. +With Programmatic Tool Calling, `tool_called` is emitted for the generated `program` and for ordinary program-owned child tool calls. `tool_output` is emitted for child tool outputs and for the `program_output` that matches the generated `program`. Program-owned hosted MCP `mcp_approval_request` and `mcp_list_tools` items are exceptions: they are emitted as `mcp_approval_requested` and `mcp_list_tools`, wrapping [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] and [`MCPListToolsItem`][agents.items.MCPListToolsItem], respectively. Inspect the raw item's `type` to distinguish the remaining items; program-owned child calls also carry a `caller` whose type is `program` and whose caller ID identifies the parent program. For example, this will ignore raw events and stream updates to the user. diff --git a/docs/tools.md b/docs/tools.md index 8829b40d03..9f16e93846 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -2,9 +2,9 @@ Tools let agents take actions: things like fetching data, running code, calling external APIs, and even using a computer. The SDK supports five categories: -- Hosted OpenAI tools: run alongside the model on OpenAI servers. +- Hosted OpenAI tools: execute for the model on OpenAI servers. - Local/runtime execution tools: `ComputerTool` and `ApplyPatchTool` always run in your environment, while `ShellTool` can run locally or in a hosted container. -- Function calling: wrap any Python function as a tool. +- `FunctionTool` instances: wrap any Python function as a tool. - Agents as tools: expose an agent as a callable tool without a full handoff. - Experimental: Codex tool: run workspace-scoped Codex tasks from a tool call. @@ -167,7 +167,7 @@ What to know: - Add at most one `ProgrammaticToolCallingTool()` to an agent. The agent must also expose at least one programmatically callable tool, a `ToolSearchTool()` backed by a namespace, deferred function, or deferred hosted MCP server, or an opaque prompt-managed tool surface. A bare `ToolSearchTool()` without a searchable surface is rejected. - `allowed_callers` controls how a tool may be invoked. Omitting it allows direct model calls only. Use `["programmatic"]` for program-only access or `["direct", "programmatic"]` to allow both. - SDK tool types that can opt in are `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, and `CodeInterpreterTool`. Function, custom, shell, and apply-patch tools expose `allowed_callers` directly. For hosted MCP and code interpreter, set `allowed_callers` inside `tool_config`. -- For `@function_tool(allowed_callers=[...])`, a structured return annotation such as a Pydantic model, TypedDict, or dataclass automatically becomes a strict object output schema and is validated before the value is returned to the program. Use `output_type=...` when the function has no usable annotation, or the lower-level `output_json_schema={...}` escape hatch when you already have a strict object schema. `output_type` and `output_json_schema` are mutually exclusive. Plain `str`, `Any`, and `None` returns remain untyped. For a schema-backed program-owned call, the default failure formatter is disabled because its free-form text does not satisfy the output schema. A handler exception therefore propagates unless you provide a custom `failure_error_function` that returns schema-conforming JSON. +- For `@function_tool(allowed_callers=[...])`, a structured return annotation such as a Pydantic model, TypedDict, or dataclass automatically becomes a strict object output schema, and the returned value is validated against that schema before it is returned to the program. Use `output_type=...` when the function has no usable annotation, or the lower-level `output_json_schema={...}` escape hatch when you already have a strict object schema. `output_type` and `output_json_schema` are mutually exclusive. Return annotations of `str`, `Any`, or `None` do not create an output schema. For a schema-backed program-owned call, the default failure formatter is disabled because its free-form text does not satisfy the output schema. A handler exception therefore propagates unless you provide a custom `failure_error_function` that returns schema-conforming JSON. - Program-owned SDK tools still use the normal Runner lifecycle. Tool input and output guardrails, hooks, timeouts, concurrency limits, approvals, sessions, and `RunState` pause/resume behavior continue to apply, and the SDK preserves each child call's program caller relationship. - Model-request retries use a stricter replay-safety boundary whenever `ProgrammaticToolCallingTool()` is present, even before a program executes. The SDK disables provider-managed retries and WebSocket pre-event retries for these requests. A Runner retry policy retries only when provider advice explicitly marks the replay safe; `retry_policies.network_error()` by itself does not override this boundary. - Approval-sensitive or high-impact tools are usually better kept as direct calls so a person can review each action before it becomes part of a larger program. If a program-owned call pauses for approval, resolve the interruption through `RunState` and resume the original run as usual. @@ -245,7 +245,7 @@ Shell action timeouts use positive integer milliseconds for a finite timeout. Th `ComputerTool` is still a local harness: you provide a [`Computer`][agents.computer.Computer] or [`AsyncComputer`][agents.computer.AsyncComputer] implementation, and the SDK maps that harness onto the OpenAI Responses API computer surface. -For explicit [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) requests, the SDK sends the GA built-in tool payload `{"type": "computer"}`. The older `computer-use-preview` model keeps the preview payload `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`. This mirrors the platform migration described in OpenAI's [Computer use guide](https://developers.openai.com/api/docs/guides/tools-computer-use/): +For explicit [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) requests, the SDK sends the GA built-in tool payload `{"type": "computer"}`. For requests to the older `computer-use-preview` model, the SDK continues to send the preview payload `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`. This mirrors the platform migration described in OpenAI's [Computer use guide](https://developers.openai.com/api/docs/guides/tools-computer-use/): - Model: `computer-use-preview` -> `gpt-5.5` - Tool selector: `computer_use_preview` -> `computer` @@ -256,7 +256,7 @@ The SDK chooses that wire shape from the effective model on the actual Responses When a [`ComputerTool`][agents.tool.ComputerTool] is present, `tool_choice="computer"`, `"computer_use"`, and `"computer_use_preview"` are all accepted and normalized to the built-in selector that matches the effective request model. Without a `ComputerTool`, those strings still behave like ordinary function names. -This distinction matters when `ComputerTool` is backed by a [`ComputerProvider`][agents.tool.ComputerProvider] factory. The GA `computer` payload does not need `environment` or dimensions at serialization time, so unresolved factories are fine. Preview-compatible serialization still needs a resolved `Computer` or `AsyncComputer` instance so the SDK can send `environment`, `display_width`, and `display_height`. +This distinction matters when `ComputerTool` is backed by a [`ComputerProvider`][agents.tool.ComputerProvider] factory. The GA `computer` payload does not need `environment` or dimensions at serialization time, so serialization can occur before a factory has produced a `Computer` or `AsyncComputer` instance. Preview-compatible serialization still needs a resolved `Computer` or `AsyncComputer` instance so the SDK can send `environment`, `display_width`, and `display_height`. At runtime, both paths still use the same local harness. Preview responses emit `computer_call` items with a single `action`; `gpt-5.5` can emit batched `actions[]`, and the SDK executes them in order before producing a `computer_call_output` screenshot item. See `examples/tools/computer_use.py` for a runnable Playwright-based harness. @@ -368,7 +368,7 @@ for tool in agent.tools: 1. You can use any Python types as arguments to your functions, and the function can be sync or async. 2. Docstrings, if present, are used to capture descriptions and argument descriptions -3. Functions can optionally take the `context` (must be the first argument). You can also set overrides, like the name of the tool, description, which docstring style to use, etc. +3. Functions can optionally take the run context as their first argument. You can also set overrides, like the name of the tool, description, which docstring style to use, etc. 4. You can pass the decorated functions to the list of tools. ??? note "Expand to see output" @@ -653,7 +653,7 @@ if __name__ == "__main__": ### Customizing tool-agents -The `agent.as_tool` function is a convenience method to make it easy to turn an agent into a tool. It supports common runtime options such as `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, and `needs_approval`. It also supports structured input with `parameters`, `input_builder`, and `include_input_schema`. +`agent.as_tool` is a convenience method for turning an agent into a tool. It supports common runtime options such as `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, and `needs_approval`. It also supports structured input with `parameters`, `input_builder`, and `include_input_schema`. The state options configure the nested agent run started by the tool call; the parent run's conversation state is not inherited automatically. To share client-managed history between the parent and nested runs, explicitly pass the same `session` to both. As with `Runner.run`, choose one state strategy for the nested run: a client-managed `session`, or server-managed continuation through `previous_response_id` or `conversation_id`. @@ -679,7 +679,7 @@ async def run_my_agent() -> str: ### Structured input for tool-agents -By default, `Agent.as_tool()` expects a single string input (`{"input": "..."}`), but you can expose a structured schema by passing `parameters` (a Pydantic model or dataclass type). +By default, `Agent.as_tool()` expects an object with one string field, `input` (`{"input": "..."}`), but you can expose a structured schema by passing `parameters` (a Pydantic model type or a dataclass type). Additional options: diff --git a/docs/tracing.md b/docs/tracing.md index 2fb493ad82..fbddad2eb9 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -10,12 +10,12 @@ The Agents SDK includes built-in tracing, collecting a comprehensive record of e 2. You can globally disable tracing in code with [`set_tracing_disabled(True)`][agents.set_tracing_disabled] 3. You can disable tracing for a single run by setting [`agents.run.RunConfig.tracing_disabled`][] to `True` -***For organizations operating under a Zero Data Retention (ZDR) policy using OpenAI's APIs, tracing is unavailable.*** +***Tracing is unavailable for organizations that use OpenAI's APIs under a Zero Data Retention (ZDR) policy.*** ## Traces and spans - **Traces** represent a single end-to-end operation of a "workflow". They're composed of Spans. Traces have the following properties: - - `workflow_name`: This is the logical workflow or app. For example "Code generation" or "Customer service". + - `workflow_name`: This is the name of the logical workflow or app. For example "Code generation" or "Customer service". - `trace_id`: A unique ID for the trace. Automatically generated if you don't pass one. Must have the format `trace_<32_alphanumeric>`. - `group_id`: Optional group ID, to link multiple traces from the same conversation. For example, you might use a chat thread ID. - `disabled`: If True, the trace will not be recorded. @@ -40,9 +40,9 @@ By default, the SDK traces the following: - Handoffs are wrapped in `handoff_span()` - Audio inputs (speech-to-text) are wrapped in a `transcription_span()` - Audio outputs (text-to-speech) are wrapped in a `speech_span()` -- Related audio spans may be parented under a `speech_group_span()` +- The SDK may parent related audio spans under a `speech_group_span()` -By default, the trace is named "Agent workflow". You can set this name if you use `trace`, or you can configure the name and other properties with the [`RunConfig`][agents.run.RunConfig]. +By default, the trace name is the literal string `Agent workflow`. You can set this name if you use `trace`, or you can configure the name and other properties with the [`RunConfig`][agents.run.RunConfig]. If you want a more compact hierarchy, disable the automatic task and turn spans for a run. Agent, generation, function, guardrail, handoff, and custom spans are still recorded. @@ -118,7 +118,7 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. Because the two calls to `Runner.run` are wrapped in a `with trace()`, the individual runs will be part of the overall trace rather than creating two traces. +1. Because the two calls to `Runner.run` are wrapped in a `with trace()`, both runs become part of one overall trace instead of each creating a separate trace. ## Creating traces @@ -127,7 +127,7 @@ You can use the [`trace()`][agents.tracing.trace] function to create a trace. Tr 1. **Recommended**: use the trace as a context manager, i.e. `with trace(...) as my_trace`. This will automatically start and end the trace at the right time. 2. You can also manually call [`trace.start()`][agents.tracing.Trace.start] and [`trace.finish()`][agents.tracing.Trace.finish]. -The current trace is tracked via a Python [`contextvar`](https://docs.python.org/3/library/contextvars.html). This means that it works with concurrency automatically. If you manually start/end a trace, you'll need to pass `mark_as_current` and `reset_current` to `start()`/`finish()` to update the current trace. +The current trace is tracked via a Python [`contextvar`](https://docs.python.org/3/library/contextvars.html). This means that it works with concurrency automatically. If you manually start and finish a trace, pass `mark_as_current` to `start()` and `reset_current` to `finish()` to update the current trace. ## Creating spans @@ -160,7 +160,7 @@ To customize this default setup, to send traces to alternative or additional bac ## Tracing with non-OpenAI models -You can use an OpenAI API key with non-OpenAI models to enable free tracing in the OpenAI Traces dashboard without needing to disable tracing. See the [Third-party adapters](models/index.md#third-party-adapters) section in the Models guide for adapter selection and setup caveats. +When using non-OpenAI models, you can provide an OpenAI API key to the tracing exporter to enable free tracing in the OpenAI Traces dashboard without disabling tracing. See the [Third-party adapters](models/index.md#third-party-adapters) section in the Models guide for adapter selection and setup caveats. ```python import os @@ -199,7 +199,7 @@ await Runner.run( ## Ecosystem integrations -The following community and vendor integrations support the OpenAI Agents SDK tracing surface. +The following community and vendor integrations support the tracing API surface of the OpenAI Agents SDK. ### External tracing processors list diff --git a/docs/usage.md b/docs/usage.md index 78bc6de58c..fbeb68e2b7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,16 +27,16 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -Usage is aggregated across all model calls during the run (including tool calls and handoffs). +Usage is aggregated across all model calls during the run, including model calls that produce tool calls or handoffs. ### Enabling usage with third-party adapters -Usage reporting varies across third-party adapters and provider backends. If you rely on adapter-backed models and need accurate `result.context_wrapper.usage` values: +Usage reporting varies across third-party adapters and provider backends. If you access models through third-party adapters and need accurate `result.context_wrapper.usage` values: -- With `AnyLLMModel`, usage is propagated automatically when the upstream provider returns it. For streamed Chat Completions backends, you may need `ModelSettings(include_usage=True)` before usage chunks are emitted. +- With `AnyLLMModel`, usage is propagated automatically when the upstream provider returns it. When streaming responses from a Chat Completions backend, you may need `ModelSettings(include_usage=True)` for usage chunks to be emitted. - With `LitellmModel`, some provider backends do not report usage by default, so `ModelSettings(include_usage=True)` is often required. -Review the adapter-specific notes in the [Third-party adapters](models/index.md#third-party-adapters) section of the Models guide and validate the exact provider backend you plan to deploy. +Review the adapter-specific notes in the [Third-party adapters](models/index.md#third-party-adapters) section of the Models guide and validate usage reporting on the exact provider backend you plan to deploy. ## Per-request usage tracking diff --git a/docs/visualization.md b/docs/visualization.md index c3fa6c8da6..9b5e015697 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -1,6 +1,6 @@ # Agent visualization -Agent visualization allows you to generate a structured graphical representation of agents and their relationships using **Graphviz**. This is useful for understanding how agents, tools, and handoffs interact within an application. +Agent visualization allows you to generate a structured graphical representation of agents and their connections to other agents, tools, and MCP servers using **Graphviz**. This is useful for understanding how agents, tools, and handoffs interact within an application. ## Installation @@ -83,7 +83,7 @@ The generated graph includes: - **Dashed arrows** for MCP server invocations. - An **end node** (`__end__`) indicating where execution terminates. -**Note:** MCP servers are rendered in recent versions of the `agents` package (verified in **v0.2.8**). If you don’t see MCP boxes in your visualization, upgrade to the latest release. +**Note:** MCP servers are rendered in recent versions of the `agents` package, including **v0.2.8**, where this behavior was verified. If you don’t see MCP boxes in your visualization, upgrade to the latest release. ## Customizing the graph diff --git a/docs/voice/pipeline.md b/docs/voice/pipeline.md index f2001a2414..2a003293bf 100644 --- a/docs/voice/pipeline.md +++ b/docs/voice/pipeline.md @@ -74,4 +74,4 @@ async for event in result.stream(): ### Interruptions -The Agents SDK currently does not provide any built-in interruption handling for [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]. Instead, every detected turn triggers a separate run of your workflow. If you want to handle interruptions inside your application, you can listen to the [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] events. `turn_started` indicates that a new turn was transcribed and processing is beginning. `turn_ended` triggers after all the audio was dispatched for a respective turn. You could use these events to mute the speaker's microphone when the model starts a turn and unmute it after you flush all the related audio for a turn. +The Agents SDK currently does not provide any built-in interruption handling for [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]. Instead, every detected turn triggers a separate run of your workflow. If you want to handle interruptions inside your application, you can listen to the [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] events. `turn_started` indicates that a new turn was transcribed and processing is beginning. `turn_ended` triggers after all the audio was dispatched for a respective turn. You could use these events to mute the speaker's microphone when the model starts a turn and unmute it after your application finishes playing all audio related to that turn. diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index aff583ab57..0e33bc1f7e 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -50,7 +50,7 @@ graph LR ## Agents -First, let's set up some Agents. This should feel familiar to you if you've built any agents with this SDK. We'll have a couple of Agents, a handoff, and a tool. +First, let's set up some Agents. This should feel familiar to you if you've built any agents with this SDK. We'll have two Agents, a configured handoff, and a tool. ```python import random @@ -190,4 +190,4 @@ if __name__ == "__main__": asyncio.run(main()) ``` -If you run this example, the agent will speak to you! Check out the example in [examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static) to see a demo where you can speak to the agent yourself. +If you run this example, the agent will produce spoken audio for you to hear! Check out the example in [examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static) to see a demo where you can speak to the agent yourself. From 39d1529a167021bb82acaba1971813aa8388aca7 Mon Sep 17 00:00:00 2001 From: Lucca Boas <86315612+Luccacvb@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:50:09 -0300 Subject: [PATCH 236/473] fix(chat-completions): raise on audio output in the streamed chat completions path (#4309) --- src/agents/models/chatcmpl_stream_handler.py | 10 +++++++- .../test_openai_chatcompletions_converter.py | 23 ++++++++++++++++++- .../test_openai_chatcompletions_stream.py | 23 ++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 11f5b72fc0..decdf78f6d 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -48,7 +48,7 @@ ) from openai.types.responses.response_usage import OutputTokensDetails -from ..exceptions import ModelBehaviorError, UserError +from ..exceptions import AgentsException, ModelBehaviorError, UserError from ..items import TResponseStreamEvent from ..logger import logger from ..usage import ( @@ -299,6 +299,9 @@ def _delta_has_passthrough_output(delta: ChoiceDelta | None) -> bool: if getattr(delta, "annotations", None): return True + if getattr(delta, "audio", None): + return True + return False @staticmethod @@ -673,6 +676,11 @@ async def handle_stream( delta = choice.delta choice_logprobs = choice.logprobs + if getattr(delta, "audio", None): + # The sync converter rejects audio output; a silent empty stream would + # diverge from that released behavior. + raise AgentsException("Audio is not currently supported") + # Handle thinking blocks from Anthropic (for preserving signatures) if hasattr(delta, "thinking_blocks") and delta.thinking_blocks: has_thinking_block = False diff --git a/tests/models/test_openai_chatcompletions_converter.py b/tests/models/test_openai_chatcompletions_converter.py index 7a542fe2e9..cc8b469b0c 100644 --- a/tests/models/test_openai_chatcompletions_converter.py +++ b/tests/models/test_openai_chatcompletions_converter.py @@ -47,7 +47,7 @@ from openai.types.responses.response_input_item_param import FunctionCallOutput from agents.agent_output import AgentOutputSchema -from agents.exceptions import UserError +from agents.exceptions import AgentsException, UserError from agents.items import TResponseInputItem from agents.models.chatcmpl_converter import Converter from agents.models.fake_id import FAKE_RESPONSES_ID @@ -74,6 +74,27 @@ def test_message_to_output_items_with_text_only(): assert text_part.text == "Hello" +def test_message_to_output_items_rejects_audio(): + """ + Audio output is unsupported by the Chat Completions converter, and the failure + must be loud so callers do not receive a silently truncated message. + """ + msg = ChatCompletionMessage.model_validate( + { + "role": "assistant", + "content": None, + "audio": { + "id": "audio-1", + "data": "AAA=", + "expires_at": 1, + "transcript": "hi", + }, + } + ) + with pytest.raises(AgentsException, match="Audio is not currently supported"): + Converter.message_to_output_items(msg) + + def test_message_to_output_items_keeps_url_citation_annotations(): """ URL citations reported on the Chat Completions message should survive as diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 174486760a..dc929c412d 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -35,7 +35,7 @@ ) from agents import Agent, Runner, function_tool, trace -from agents.exceptions import ModelBehaviorError, UserError +from agents.exceptions import AgentsException, ModelBehaviorError, UserError from agents.model_settings import ModelSettings from agents.models.chatcmpl_converter import Converter from agents.models.chatcmpl_stream_handler import ( @@ -782,6 +782,26 @@ def test_finish_reasoning_summary_part_clears_invalid_active_index() -> None: assert state.active_reasoning_summary_index is None +@pytest.mark.asyncio +async def test_audio_delta_raises_like_the_sync_path() -> None: + """Audio output must fail loudly on the streamed path, matching the sync converter.""" + chunk = _annotated_chunk({"content": "partial", "audio": {"id": "audio-1", "transcript": "hi"}}) + + with pytest.raises(AgentsException, match="Audio is not currently supported"): + await _collect_handler_events(chunk) + + +@pytest.mark.asyncio +async def test_buffered_audio_only_delta_raises_instead_of_completing_empty() -> None: + """Tool-call buffering must not swallow an audio-only delta into a silent empty run.""" + audio_chunk = _annotated_chunk({"audio": {"id": "audio-1", "transcript": "hi"}}) + + buffered = ChatCmplStreamHandler.buffer_tool_call_stream(_completion_stream(audio_chunk)) + with pytest.raises(AgentsException, match="Audio is not currently supported"): + async for _ in ChatCmplStreamHandler.handle_stream(_empty_response(), cast(Any, buffered)): + pass + + @pytest.mark.asyncio async def test_buffer_tool_call_stream_preserves_empty_choice_chunks() -> None: chunk = ChatCompletionChunk( @@ -842,6 +862,7 @@ async def test_buffer_tool_call_stream_keeps_passthrough_index_passthrough() -> (ChoiceDelta.model_construct(reasoning_content="summary"), True), (ChoiceDelta.model_construct(reasoning="scratchpad"), True), (ChoiceDelta.model_construct(thinking_blocks=[{"thinking": "hidden"}]), True), + (ChoiceDelta.model_construct(audio={"id": "audio-1"}), True), ], ) def test_stream_handler_detects_passthrough_delta_shapes( From 347fec1a65e5e5e387b2d436b58adb4b9d86c723 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 06:59:52 +0900 Subject: [PATCH 237/473] docs: update translated pages --- docs/ja/agents.md | 106 +++--- docs/ja/config.md | 70 ++-- docs/ja/context.md | 88 ++--- docs/ja/examples.md | 250 +++++++-------- docs/ja/guardrails.md | 56 ++-- docs/ja/handoffs.md | 62 ++-- docs/ja/human_in_the_loop.md | 88 ++--- docs/ja/index.md | 64 ++-- docs/ja/mcp.md | 140 ++++---- docs/ja/models/index.md | 284 ++++++++--------- docs/ja/multi_agent.md | 64 ++-- docs/ja/realtime/guide.md | 138 ++++---- docs/ja/realtime/quickstart.md | 52 +-- docs/ja/realtime/transport.md | 74 ++--- docs/ja/release.md | 132 ++++---- docs/ja/results.md | 104 +++--- docs/ja/running_agents.md | 244 +++++++------- docs/ja/sandbox/clients.md | 72 ++--- docs/ja/sandbox/guide.md | 318 +++++++++--------- docs/ja/sandbox/memory.md | 58 ++-- docs/ja/sandbox_agents.md | 38 +-- docs/ja/sessions/advanced_sqlite_session.md | 48 +-- docs/ja/sessions/index.md | 146 ++++----- docs/ja/sessions/sqlalchemy_session.md | 14 +- docs/ja/streaming.md | 66 ++-- docs/ja/tools.md | 306 +++++++++--------- docs/ja/tracing.md | 102 +++--- docs/ja/usage.md | 38 +-- docs/ja/visualization.md | 44 +-- docs/ja/voice/pipeline.md | 22 +- docs/ja/voice/quickstart.md | 25 +- docs/ko/agents.md | 120 +++---- docs/ko/config.md | 68 ++-- docs/ko/context.md | 76 ++--- docs/ko/examples.md | 240 +++++++------- docs/ko/guardrails.md | 52 +-- docs/ko/handoffs.md | 66 ++-- docs/ko/human_in_the_loop.md | 92 +++--- docs/ko/index.md | 76 ++--- docs/ko/mcp.md | 156 ++++----- docs/ko/models/index.md | 305 +++++++++--------- docs/ko/multi_agent.md | 60 ++-- docs/ko/realtime/guide.md | 120 +++---- docs/ko/realtime/quickstart.md | 48 +-- docs/ko/realtime/transport.md | 74 ++--- docs/ko/release.md | 124 +++---- docs/ko/results.md | 136 ++++---- docs/ko/running_agents.md | 235 +++++++------- docs/ko/sandbox/clients.md | 78 ++--- docs/ko/sandbox/guide.md | 322 +++++++++---------- docs/ko/sandbox/memory.md | 64 ++-- docs/ko/sandbox_agents.md | 40 +-- docs/ko/sessions/advanced_sqlite_session.md | 30 +- docs/ko/sessions/index.md | 150 ++++----- docs/ko/sessions/sqlalchemy_session.md | 12 +- docs/ko/streaming.md | 34 +- docs/ko/tools.md | 270 ++++++++-------- docs/ko/tracing.md | 90 +++--- docs/ko/usage.md | 36 +-- docs/ko/visualization.md | 22 +- docs/ko/voice/pipeline.md | 32 +- docs/ko/voice/quickstart.md | 19 +- docs/zh/agents.md | 134 ++++---- docs/zh/config.md | 68 ++-- docs/zh/context.md | 78 ++--- docs/zh/examples.md | 128 ++++---- docs/zh/guardrails.md | 52 +-- docs/zh/handoffs.md | 62 ++-- docs/zh/human_in_the_loop.md | 98 +++--- docs/zh/index.md | 80 ++--- docs/zh/mcp.md | 177 +++++----- docs/zh/models/index.md | 337 ++++++++++---------- docs/zh/multi_agent.md | 66 ++-- docs/zh/realtime/guide.md | 154 ++++----- docs/zh/realtime/quickstart.md | 50 +-- docs/zh/realtime/transport.md | 88 ++--- docs/zh/release.md | 120 +++---- docs/zh/results.md | 150 ++++----- docs/zh/running_agents.md | 226 ++++++------- docs/zh/sandbox/clients.md | 82 ++--- docs/zh/sandbox/guide.md | 306 +++++++++--------- docs/zh/sandbox/memory.md | 62 ++-- docs/zh/sandbox_agents.md | 32 +- docs/zh/sessions/advanced_sqlite_session.md | 48 +-- docs/zh/sessions/index.md | 220 ++++++------- docs/zh/sessions/sqlalchemy_session.md | 14 +- docs/zh/streaming.md | 62 ++-- docs/zh/tools.md | 272 ++++++++-------- docs/zh/tracing.md | 118 +++---- docs/zh/usage.md | 58 ++-- docs/zh/visualization.md | 40 +-- docs/zh/voice/pipeline.md | 36 +-- docs/zh/voice/quickstart.md | 17 +- 93 files changed, 5008 insertions(+), 4987 deletions(-) diff --git a/docs/ja/agents.md b/docs/ja/agents.md index 078e6fe6fd..4503987b4b 100644 --- a/docs/ja/agents.md +++ b/docs/ja/agents.md @@ -4,26 +4,26 @@ search: --- # エージェント -エージェントは、アプリの中核となる構成要素です。エージェントとは、指示、ツール、およびハンドオフ、ガードレール、structured outputsなどのオプションのランタイム動作を設定した大規模言語モデル(LLM)です。 +エージェントは、アプリの中核となる構成要素です。エージェントとは、指示、ツール、およびハンドオフ、ガードレール、structured outputs などの任意の実行時動作を設定した大規模言語モデル(LLM)です。 -単一のシンプルな `Agent` を定義またはカスタマイズする場合は、このページを使用してください。複数のエージェントをどのように連携させるかを検討している場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。マニフェストで定義されたファイルとサンドボックスネイティブの機能を備えた分離ワークスペース内でエージェントを実行する場合は、[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 +`SandboxAgent` ではなく、単一の基本 `Agent` を定義またはカスタマイズする場合は、このページを使用してください。複数のエージェントをどのように連携させるかを決定する場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。マニフェストで定義されたファイルとサンドボックスネイティブの機能を備えた分離ワークスペース内でエージェントを実行する場合は、[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 -SDK は、OpenAI モデルに対してデフォルトで Responses API を使用しますが、ここで重要なのはオーケストレーションです。`Agent` と `Runner` を組み合わせることで、SDK がターン、ツール、ガードレール、ハンドオフ、セッションを管理できます。このループを自分で管理する場合は、代わりに Responses API を直接使用してください。 +SDK は、OpenAIモデルに対してデフォルトで Responses API を使用しますが、ここで重要なのはオーケストレーションです。`Agent` と `Runner` を組み合わせることで、SDK がターン、ツール、ガードレール、ハンドオフ、セッションを管理できます。このループを自身で管理する場合は、代わりに Responses API を直接使用してください。 ## 次のガイドの選択 -このページをエージェント定義のハブとして使用してください。次に行う判断に対応する関連ガイドへ進んでください。 +このページは、エージェント定義のハブとして使用してください。次に決定する必要がある内容に応じて、関連するガイドに進んでください。 | 目的 | 次に読むガイド | | --- | --- | | モデルまたはプロバイダーの設定を選択する | [モデル](models/index.md) | | エージェントに機能を追加する | [ツール](tools.md) | -| 実際のリポジトリ、ドキュメント一式、または分離ワークスペースでエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md) | +| 実際のリポジトリ、ドキュメント一式、または分離ワークスペースを対象にエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md) | | マネージャー方式のオーケストレーションとハンドオフのどちらを使用するか決定する | [エージェントオーケストレーション](multi_agent.md) | | ハンドオフの動作を設定する | [ハンドオフ](handoffs.md) | | ターンの実行、イベントのストリーミング、または会話状態の管理を行う | [エージェントの実行](running_agents.md) | | 最終出力、実行項目、または再開可能な状態を確認する | [実行結果](results.md) | -| ローカルの依存関係とランタイム状態を共有する | [コンテキスト管理](context.md) | +| ローカルの依存関係と実行時状態を共有する | [コンテキスト管理](context.md) | ## 基本設定 @@ -31,22 +31,22 @@ SDK は、OpenAI モデルに対してデフォルトで Responses API を使用 | プロパティ | 必須 | 説明 | | --- | --- | --- | -| `name` | はい | 人間が読めるエージェント名です。 | +| `name` | はい | 人が読める形式のエージェント名です。 | | `instructions` | いいえ | システムプロンプトまたは動的な指示のコールバックです。使用を強く推奨します。[動的な指示](#dynamic-instructions)を参照してください。 | -| `prompt` | いいえ | OpenAI Responses API のプロンプト設定です。静的なプロンプトオブジェクトまたは関数を受け入れます。[プロンプトテンプレート](#prompt-templates)を参照してください。 | +| `prompt` | いいえ | OpenAIの Responses API 用プロンプト設定です。静的なプロンプトオブジェクトまたは関数を受け取ります。[プロンプトテンプレート](#prompt-templates)を参照してください。 | | `handoff_description` | いいえ | このエージェントがハンドオフ先として提示される際に公開される短い説明です。 | -| `handoffs` | いいえ | 会話を専門エージェントに委任します。[ハンドオフ](handoffs.md)を参照してください。 | -| `model` | いいえ | 使用する LLM です。[モデル](models/index.md)を参照してください。 | +| `handoffs` | いいえ | 会話を専門エージェントに委譲します。[ハンドオフ](handoffs.md)を参照してください。 | +| `model` | いいえ | 使用するLLMです。[モデル](models/index.md)を参照してください。 | | `model_settings` | いいえ | `temperature`、`top_p`、`tool_choice` などのモデル調整パラメーターです。 | | `tools` | いいえ | エージェントが呼び出せるツールです。[ツール](tools.md)を参照してください。 | -| `mcp_servers` | いいえ | エージェント用の MCP ベースのツールです。[MCP ガイド](mcp.md)を参照してください。 | -| `mcp_config` | いいえ | 厳密なスキーマ変換や MCP エラーの形式設定など、MCP ツールの準備方法を詳細に調整します。[MCP ガイド](mcp.md#agent-level-mcp-configuration)を参照してください。 | +| `mcp_servers` | いいえ | MCP対応ツールをエージェントに提供するMCPサーバーです。[MCPガイド](mcp.md)を参照してください。 | +| `mcp_config` | いいえ | スキーマの strict モードへの変換やMCPエラーの形式調整など、MCPツールの準備方法を詳細に調整します。[MCPガイド](mcp.md#agent-level-mcp-configuration)を参照してください。 | | `input_guardrails` | いいえ | このエージェントチェーンへの最初のユーザー入力に対して実行されるガードレールです。[ガードレール](guardrails.md)を参照してください。 | | `output_guardrails` | いいえ | このエージェントの最終出力に対して実行されるガードレールです。[ガードレール](guardrails.md)を参照してください。 | | `output_type` | いいえ | プレーンテキストの代わりに使用する構造化された出力型です。[出力型](#output-types)を参照してください。 | -| `hooks` | いいえ | エージェントスコープのライフサイクルコールバックです。[ライフサイクルイベント(フック)](#lifecycle-events-hooks)を参照してください。 | -| `tool_use_behavior` | いいえ | ツールの実行結果をモデルへ戻してループを継続するか、実行を終了するかを制御します。[ツール使用時の動作](#tool-use-behavior)を参照してください。 | -| `reset_tool_choice` | いいえ | ツール使用のループを回避するため、ツール呼び出し後に `tool_choice` をリセットします(デフォルト: `True`)。[ツール使用の強制](#forcing-tool-use)を参照してください。 | +| `hooks` | いいえ | エージェント単位のライフサイクルコールバックです。[ライフサイクルイベント(フック)](#lifecycle-events-hooks)を参照してください。 | +| `tool_use_behavior` | いいえ | ツールの実行結果をモデルに戻してループを継続するか、実行を終了するかを制御します。[ツール使用時の動作](#tool-use-behavior)を参照してください。 | +| `reset_tool_choice` | いいえ | ツール使用ループを回避するため、ツール呼び出し後に `tool_choice` をリセットします(デフォルト:`True`)。[ツール使用の強制](#forcing-tool-use)を参照してください。 | ```python from agents import Agent @@ -65,16 +65,16 @@ agent = Agent( ) ``` -このセクションの内容はすべて `Agent` に適用されます。`SandboxAgent` は同じ考え方を基盤とし、ワークスペースをスコープとする実行向けに `default_manifest`、`base_instructions`、`capabilities`、`run_as` を追加します。[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 +このセクションの内容はすべて `Agent` に適用されます。`SandboxAgent` は同じ考え方を基盤とし、ワークスペース単位の実行向けに `default_manifest`、`base_instructions`、`capabilities`、`run_as` を追加します。[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 ## プロンプトテンプレート -`prompt` を設定すると、OpenAI プラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API を使用する OpenAI モデルで機能します。 +`prompt` を設定することで、OpenAIプラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API 経由でOpenAIモデルにアクセスする場合に機能します。 -使用するには、次の手順を実行してください。 +使用手順は次のとおりです。 1. https://platform.openai.com/playground/prompts にアクセスします。 -2. `poem_style` という新しいプロンプト変数を作成します。 +2. 新しいプロンプト変数 `poem_style` を作成します。 3. 次の内容でシステムプロンプトを作成します。 ``` @@ -128,7 +128,7 @@ result = await Runner.run( ## コンテキスト -エージェントの `context` 型はジェネリックです。コンテキストは依存性注入のための仕組みです。作成したオブジェクトを `Runner.run()` に渡すと、すべてのエージェント、ツール、ハンドオフなどに渡され、エージェント実行に必要な依存関係と状態をまとめて保持します。任意の Python オブジェクトをコンテキストとして指定できます。 +エージェントは、その `context` 型に関してジェネリックです。コンテキストは依存性注入の仕組みです。自身で作成して `Runner.run()` に渡すオブジェクトであり、すべてのエージェント、ツール、ハンドオフなどに渡されます。また、エージェント実行に必要な依存関係と状態をまとめる柔軟なコンテナとして機能します。コンテキストには任意の Python オブジェクトを指定できます。 `RunContextWrapper` の全機能、共有の使用量追跡、ネストされた `tool_input`、シリアライズに関する注意事項については、[コンテキストガイド](context.md)を参照してください。 @@ -156,7 +156,7 @@ agent = Agent[UserContext]( ## 出力型 -デフォルトでは、エージェントはプレーンテキスト(つまり `str`)の出力を生成します。エージェントに特定の型の出力を生成させる場合は、`output_type` パラメーターを使用できます。一般的には [Pydantic](https://docs.pydantic.dev/) オブジェクトを使用しますが、Pydantic の [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) でラップできる任意の型(データクラス、リスト、TypedDict など)をサポートしています。 +デフォルトでは、エージェントはプレーンテキスト(つまり `str`)形式の出力を生成します。エージェントに特定の型の出力を生成させる場合は、`output_type` パラメーターを使用できます。一般的には [Pydantic](https://docs.pydantic.dev/) オブジェクトを使用しますが、Pydantic の [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) でラップできる任意の型をサポートしています。これには、dataclass、リスト、TypedDict などが含まれます。 ```python from pydantic import BaseModel @@ -181,16 +181,16 @@ agent = Agent( ## マルチエージェントシステムの設計パターン -マルチエージェントシステムの設計方法は多数ありますが、一般的に広く適用できる次の 2 つのパターンがよく使用されます。 +マルチエージェントシステムには多くの設計方法がありますが、一般的には広く適用できる次の 2 つのパターンが使用されます。 -1. マネージャー(agents as tools): 中央のマネージャーまたはオーケストレーターが、専門のサブエージェントをツールとして呼び出し、会話の制御を維持します。 -2. ハンドオフ: 対等なエージェントが、会話を引き継ぐ専門エージェントへ制御をハンドオフします。これは分散型の方式です。 +1. マネージャー(agents as tools):中央のマネージャー/オーケストレーターが専門サブエージェントをツールとして呼び出し、会話の制御を維持します。 +2. ハンドオフ:対等なエージェントが、会話を引き継ぐ専門エージェントに制御をハンドオフします。これは分散型のパターンです。 詳細については、[エージェント構築の実践ガイド](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)を参照してください。 ### マネージャー(agents as tools) -`customer_facing_agent` はすべてのユーザー対応を処理し、ツールとして公開された専門のサブエージェントを呼び出します。詳しくは、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 +`customer_facing_agent` はすべてのユーザー操作を処理し、ツールとして公開された専門サブエージェントを呼び出します。詳細については、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 ```python from agents import Agent @@ -219,7 +219,7 @@ customer_facing_agent = Agent( ### ハンドオフ -ハンドオフとは、エージェントが処理を委任できるサブエージェントです。ハンドオフが発生すると、委任先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに優れたモジュール式の専門エージェントを構築できます。詳しくは、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 +設定されたハンドオフ先は、エージェントが処理を委譲できるサブエージェントです。ハンドオフが発生すると、委譲先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに特化したモジュール式の専門エージェントを構築できます。詳細については、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 ```python from agents import Agent @@ -243,6 +243,8 @@ triage_agent = Agent( ほとんどの場合、エージェントの作成時に指示を指定できます。ただし、関数を使用して動的な指示を指定することもできます。この関数はエージェントとコンテキストを受け取り、プロンプトを返す必要があります。通常の関数と `async` 関数の両方を使用できます。 ```python +from agents import Agent, RunContextWrapper + def dynamic_instructions( context: RunContextWrapper[UserContext], agent: Agent[UserContext] ) -> str: @@ -257,26 +259,26 @@ agent = Agent[UserContext]( ## ライフサイクルイベント(フック) -エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生した際に、イベントのログ記録、データの事前取得、使用量の記録を行えます。 +エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生したときに、イベントのログ記録、データの事前取得、使用量の記録を行う場合です。 フックには次の 2 つのスコープがあります。 -- [`RunHooks`][agents.lifecycle.RunHooks] は、他のエージェントへのハンドオフを含む `Runner.run(...)` の呼び出し全体を監視します。 -- [`AgentHooks`][agents.lifecycle.AgentHooks] は、`agent.hooks` を介して特定のエージェントインスタンスに関連付けられます。 +- [`RunHooks`][agents.lifecycle.RunHooks] は、他のエージェントへのハンドオフを含む `Runner.run(...)` 呼び出し全体を監視します。 +- [`AgentHooks`][agents.lifecycle.AgentHooks] は、`agent.hooks` を介して特定のエージェントインスタンスにアタッチされます。 -コールバックのコンテキストもイベントによって異なります。 +コールバックのコンテキストも、イベントに応じて変わります。 -- エージェントの開始/終了フックは、元のコンテキストをラップして共有の実行使用量状態を保持する [`AgentHookContext`][agents.run_context.AgentHookContext] を受け取ります。 -- LLM、ツール、ハンドオフの各フックは [`RunContextWrapper`][agents.run_context.RunContextWrapper] を受け取ります。 +- エージェントの開始/終了フックは、元のコンテキストをラップし、共有の実行使用量状態を保持する [`AgentHookContext`][agents.run_context.AgentHookContext] を受け取ります。 +- LLM、ツール、ハンドオフの各フックは、[`RunContextWrapper`][agents.run_context.RunContextWrapper] を受け取ります。 一般的なフックのタイミングは次のとおりです。 -- `on_agent_start` / `on_agent_end`: 特定のエージェントが最終出力の生成を開始または完了するとき。 -- `on_llm_start` / `on_llm_end`: 各モデル呼び出しの直前と直後。 -- `on_tool_start` / `on_tool_end`: 各ローカルツール呼び出しの前後。関数ツールの場合、フックの `context` は通常 `ToolContext` であるため、`tool_call_id` などのツール呼び出しメタデータを確認できます。 -- `on_handoff`: 制御があるエージェントから別のエージェントへ移るとき。 +- `on_agent_start`:特定のエージェントが実行を開始したとき。`on_agent_end`:そのエージェントが最終出力の生成を完了したとき。 +- `on_llm_start` / `on_llm_end`:各モデル呼び出しの直前と直後。 +- `on_tool_start` / `on_tool_end`:各ローカルツール呼び出しの前後。関数ツールの場合、フックの `context` は通常 `ToolContext` であるため、`tool_call_id` などのツール呼び出しメタデータを確認できます。 +- `on_handoff`:制御があるエージェントから別のエージェントに移ったとき。 -ワークフロー全体を 1 つのオブザーバーで監視する場合は `RunHooks` を使用し、1 つのエージェントに独自の副作用が必要な場合は `AgentHooks` を使用します。 +ワークフロー全体を単一のオブザーバーで監視する場合は `RunHooks` を使用し、特定のエージェントに限定されたライフサイクルコールバックが必要な場合は `AgentHooks` を使用してください。 ```python from agents import Agent, RunHooks, Runner @@ -302,9 +304,9 @@ print(result.final_output) ## ガードレール -ガードレールを使用すると、エージェントの実行と並行してユーザー入力のチェック/検証を行い、エージェントの出力が生成された後にその出力をチェック/検証できます。たとえば、ユーザー入力とエージェント出力が関連性のある内容かどうかを確認できます。詳しくは、[ガードレール](guardrails.md)のドキュメントを参照してください。 +ガードレールを使用すると、エージェントの実行と並行してユーザー入力に対するチェック/検証を実行し、生成後のエージェント出力に対してもチェック/検証を実行できます。たとえば、ユーザー入力とエージェント出力の関連性を確認できます。詳細については、[ガードレール](guardrails.md)のドキュメントを参照してください。 -## エージェントのクローン/コピー +## エージェントのクローン/コピー エージェントの `clone()` メソッドを使用すると、エージェントを複製し、必要に応じて任意のプロパティを変更できます。 @@ -323,14 +325,14 @@ robot_agent = pirate_agent.clone( ## ツール使用の強制 -ツールのリストを指定しても、LLM が必ずツールを使用するとは限りません。[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] を設定すると、ツールの使用を強制できます。有効な値は次のとおりです。 +ツールのリストを指定しても、LLMが必ずツールを使用するとは限りません。[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] を設定することで、ツールの使用を強制できます。有効な値は次のとおりです。 -1. `auto`: ツールを使用するかどうかを LLM が判断できます。 -2. `required`: LLM にツールの使用を必須とします。ただし、使用するツールは LLM が適切に判断できます。 -3. `none`: LLM がツールを _使用しない_ ことを必須とします。 -4. `my_tool` などの特定の文字列を設定すると、LLM にその特定のツールの使用を必須とします。 +1. `auto`:ツールを使用するかどうかをLLMが判断できます。 +2. `required`:LLMにツールの使用を要求しますが、どのツールを使用するかはLLMが適切に判断できます。 +3. `none`:LLMにツールを _使用させない_ ことを要求します。 +4. `my_tool` などの特定の文字列を設定すると、LLMにその特定のツールの使用を要求します。 -OpenAI Responses のツール検索を使用する場合、名前を指定したツール選択にはより多くの制限があります。`tool_choice` では、未修飾の名前空間名や遅延専用ツールを対象にできず、`tool_choice="tool_search"` で [`ToolSearchTool`][agents.tool.ToolSearchTool] を対象にすることもできません。このような場合は、`auto` または `required` を優先してください。Responses 固有の制約については、[ホスト型ツール検索](tools.md#hosted-tool-search)を参照してください。 +OpenAI Responses のツール検索を使用する場合、名前付きツールの選択にはさらに制約があります。`tool_choice` では、修飾なしの名前空間名や遅延のみのツールを指定できず、`tool_choice="tool_search"` では [`ToolSearchTool`][agents.tool.ToolSearchTool] を指定できません。このような場合は、`auto` または `required` を使用してください。Responses 固有の制約については、[ホスト型ツール検索](tools.md#hosted-tool-search)を参照してください。 ```python from agents import Agent, ModelSettings @@ -353,8 +355,8 @@ agent = Agent( `Agent` 設定の `tool_use_behavior` パラメーターは、ツール出力の処理方法を制御します。 -- `"run_llm_again"`: デフォルトです。ツールを実行し、LLM がその実行結果を処理して最終レスポンスを生成します。 -- `"stop_on_first_tool"`: 最初のツール呼び出しの出力を、追加の LLM 処理を行わずに最終レスポンスとして使用します。 +- `"run_llm_again"`:デフォルトです。ツールが実行され、その結果をLLMが処理して最終レスポンスを生成します。 +- `"stop_on_first_tool"`:最初のツール呼び出しの出力を、LLMによる追加処理なしで最終レスポンスとして使用します。 ```python from agents import Agent @@ -373,12 +375,12 @@ agent = Agent( ) ``` -- `StopAtTools(stop_at_tool_names=[...])`: 指定したツールのいずれかが呼び出された場合に停止し、その出力を最終レスポンスとして使用します。 +- `StopAtTools(stop_at_tool_names=[...])`:指定されたツールのいずれかが呼び出されると停止し、その出力を最終レスポンスとして使用します。 ```python from agents import Agent -from agents.decorators import tool from agents.agent import StopAtTools +from agents.decorators import tool @tool def get_weather(city: str) -> str: @@ -398,12 +400,12 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`: ツールの実行結果を処理し、停止するか LLM での処理を継続するかを決定するカスタム関数です。 +- `ToolsToFinalOutputFunction`:ツールの実行結果を処理し、最終出力で実行を終了するか、LLMによる処理を続行するかを決定するカスタム関数です。 ```python from agents import Agent, FunctionToolResult, RunContextWrapper -from agents.decorators import tool from agents.agent import ToolsToFinalOutputResult +from agents.decorators import tool from typing import List, Any @tool @@ -437,4 +439,4 @@ agent = Agent( !!! note - 無限ループを防ぐため、フレームワークはツール呼び出し後に `tool_choice` を自動的に「auto」へリセットします。この動作は [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice] で設定できます。無限ループが発生する理由は、ツールの実行結果が LLM に送信され、`tool_choice` によって LLM が別のツール呼び出しを生成し、この処理が延々と繰り返されるためです。 \ No newline at end of file + 無限ループを防ぐため、フレームワークはツール呼び出し後に `tool_choice` を自動的に「auto」にリセットします。この動作は、[`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice] で設定できます。無限ループが発生する理由は、ツールの実行結果がLLMに送信された後、`tool_choice` によってLLMがさらに別のツール呼び出しを生成し続けるためです。 \ No newline at end of file diff --git a/docs/ja/config.md b/docs/ja/config.md index 98932d11c6..ceb2beab36 100644 --- a/docs/ja/config.md +++ b/docs/ja/config.md @@ -2,23 +2,23 @@ search: exclude: true --- -# 設定 +# 構成 -このページでは、デフォルトの OpenAI キーやクライアント、デフォルトの OpenAI API 形式、トレーシングのエクスポート設定、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 +このページでは、デフォルトのOpenAIキーやクライアント、デフォルトのOpenAI API 形式、トレーシングのエクスポートに関するデフォルト設定、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 -これらのデフォルトはサンドボックスベースのワークフローにも適用されますが、サンドボックスのワークスペース、サンドボックスクライアント、セッションの再利用は個別に設定します。 +これらのデフォルトはサンドボックスベースのワークフローにも適用されますが、サンドボックスワークスペース、サンドボックスクライアント、セッションの再利用は個別に構成します。 -代わりに特定のエージェントや実行を設定する必要がある場合は、以下を参照してください。 +特定のエージェントや実行を構成する必要がある場合は、次のページから確認してください。 -- 標準的な `Agent` の instructions、tools、出力型、ハンドオフ、ガードレールについては、[エージェント](agents.md)を参照してください。 +- 通常の `Agent` における instructions、ツール、出力型、ハンドオフ、ガードレールについては、[エージェント](agents.md)を参照してください。 - `RunConfig`、セッション、会話状態のオプションについては、[エージェントの実行](running_agents.md)を参照してください。 -- `SandboxRunConfig`、マニフェスト、機能、サンドボックスクライアント固有のワークスペース設定については、[サンドボックスエージェント](sandbox/guide.md)を参照してください。 -- モデルの選択とプロバイダーの設定については、[モデル](models/index.md)を参照してください。 +- `SandboxRunConfig`、マニフェスト、ケイパビリティ、サンドボックスクライアント固有のワークスペース設定については、[サンドボックスエージェント](sandbox/guide.md)を参照してください。 +- モデルの選択とプロバイダーの構成については、[モデル](models/index.md)を参照してください。 - 実行ごとのトレーシングメタデータとカスタムトレースプロセッサーについては、[トレーシング](tracing.md)を参照してください。 -## 設定オブジェクトと辞書 +## 構成オブジェクトと辞書 -SDK が管理する設定パラメーターは通常、型付き設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け入れます。これは、型アノテーションに辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各設定境界に適用されます。ネストされた SDK 管理の設定でも辞書を使用できます。 +SDK で定義された構成パラメーターは、通常、型付き設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け付けます。これは、型アノテーションに辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各構成境界に適用されます。SDK で定義されたネストされた設定型でも、辞書を使用できます。 ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK はこれらの辞書を対応する設定オブジェクトに正規化します。SDK が管理する dataclass 設定に不明なフィールドがあると `TypeError` が発生するため、オプション名の入力ミスを早期に検出できます。特定の設定境界が辞書を受け入れるかどうかを確認するには、パラメーターの型アノテーションまたは API リファレンスを参照してください。 +SDK は、これらの辞書を対応する設定オブジェクトに正規化します。SDK で定義されたデータクラス構成型に不明なフィールドがあると `TypeError` が発生するため、オプション名の入力ミスを早期に検出できます。特定の境界が辞書を受け付けるかどうかを確認するには、そのパラメーターの型アノテーションまたは API リファレンスを参照してください。 ## API キーとクライアント -デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が初めて OpenAI クライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリケーションの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 +デフォルトでは、SDK は LLMリクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が最初にOpenAIクライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -また、使用する OpenAI クライアントを設定することもできます。デフォルトでは、SDK は環境変数の API キー、または上記で設定したデフォルトキーを使用して `AsyncOpenAI` インスタンスを作成します。[set_default_openai_client()][agents.set_default_openai_client] 関数を使用すると、これを変更できます。 +代わりに、使用するOpenAIクライアントを構成することもできます。デフォルトでは、SDK は環境変数の API キーまたは上記で設定したデフォルトキーを使用して、`AsyncOpenAI` インスタンスを作成します。[set_default_openai_client()][agents.set_default_openai_client] 関数を使用すると、この動作を変更できます。 ```python from openai import AsyncOpenAI @@ -55,14 +55,14 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -環境変数によるエンドポイント設定を使用する場合、デフォルトの OpenAI プロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses の WebSocket トランスポートを有効にすると、WebSocket の `/responses` エンドポイント用に `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 +環境ベースのエンドポイント構成を使用する場合、デフォルトのOpenAIプロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses の websocket トランスポートを有効にすると、websocket の `/responses` エンドポイント用に `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -最後に、使用する OpenAI API をカスタマイズすることもできます。デフォルトでは、OpenAI Responses API を使用します。[set_default_openai_api()][agents.set_default_openai_api] 関数を使用すると、これを上書きして Chat Completions API を使用できます。 +最後に、使用するOpenAI API をカスタマイズすることもできます。デフォルトでは、OpenAI Responses API を使用します。[set_default_openai_api()][agents.set_default_openai_api] 関数を使用すると、これをオーバーライドして Chat Completions API を使用できます。 ```python from agents import set_default_openai_api @@ -70,9 +70,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI プロバイダーのデフォルト +## OpenAIプロバイダーのデフォルト -OpenAI ベースのプロバイダーも、モデル名を解決するときに SDK 全体のデフォルトを読み取ります。OpenAI Responses モデルでデフォルトで WebSocket トランスポートを使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 +SDK のOpenAIバックエンドを使用するプロバイダーは、モデル名の文字列をモデルにマッピングするときに、SDK 全体のデフォルトも読み取ります。OpenAI Responses モデルでデフォルトとして websocket トランスポートを使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 ```python from agents import set_default_openai_responses_transport @@ -80,9 +80,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトの OpenAI プロバイダーによって解決される OpenAI Responses モデルに影響します。プロバイダーレベルの設定、接続の再利用、キープアライブオプション、カスタム WebSocket エンドポイントについては、[Responses WebSocket トランスポート](models/index.md#responses-websocket-transport)を参照してください。 +これは、デフォルトのOpenAIプロバイダーがモデル名を解決した結果として得られるOpenAI Responses モデルに影響します。プロバイダーレベルの設定、接続の再利用、キープアライブオプション、カスタム websocket エンドポイントについては、[Responses WebSocket トランスポート](models/index.md#responses-websocket-transport)を参照してください。 -OpenAI の設定でプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度設定します。 +OpenAIの設定でプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度構成します。 ```python from agents import set_default_openai_harness @@ -100,11 +100,11 @@ set_default_openai_agent_registration( ) ``` -SDK のデフォルトが設定されていない場合、OpenAI ベースのプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、そのキーが `RunConfig.trace_metadata` にすでに存在しない限り、SDK はトレースメタデータに `agent_harness_id` として追加します。 +SDK のデフォルトが設定されていない場合、SDK のOpenAIバックエンドを使用するプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が構成されている場合、`RunConfig.trace_metadata` にそのキーがすでに存在しない限り、SDK はトレースメタデータに `agent_harness_id` として追加します。 ## トレーシング -トレーシングはデフォルトで有効です。デフォルトでは、上記のセクションにあるモデルリクエストと同じ OpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用すると、トレーシングに使用する API キーを個別に設定できます。 +トレーシングはデフォルトで有効です。デフォルトでは、前のセクションで説明したモデルリクエストと同じOpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。トレーシングに使用する API キーを明示的に設定するには、[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用します。 ```python from agents import set_tracing_export_api_key @@ -112,7 +112,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -モデルの通信ではあるキーまたはクライアントを使用し、トレーシングでは別の OpenAI キーを使用する必要がある場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡してから、トレーシングを個別に設定します。カスタムクライアントを使用していない場合は、[`set_default_openai_key()`][agents.set_default_openai_key] でも同じ方法を使用できます。 +モデルのトラフィックではあるキーまたはクライアントを使用し、トレーシングでは別のOpenAIキーを使用する必要がある場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡してから、トレーシングを個別に構成します。カスタムクライアントを使用していない場合は、[`set_default_openai_key()`][agents.set_default_openai_key] でも同じ方法を使用できます。 ```python from openai import AsyncOpenAI @@ -127,14 +127,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -デフォルトのエクスポーターを使用する際に、トレースを特定の組織またはプロジェクトに関連付ける必要がある場合は、アプリケーションの起動前に以下の環境変数を設定します。 +デフォルトのエクスポーターを使用するときに、トレースを特定の組織またはプロジェクトに関連付ける必要がある場合は、アプリの起動前に次の環境変数を設定します。 ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -グローバルエクスポーターを変更せずに、実行ごとにトレーシング用の API キーを設定することもできます。 +グローバルエクスポーターを変更せずに、実行ごとにトレーシング API キーを設定することもできます。 ```python from agents import Runner, RunConfig @@ -146,7 +146,7 @@ await Runner.run( ) ``` -[`set_tracing_disabled()`][agents.set_tracing_disabled] 関数を使用して、トレーシングを完全に無効にすることもできます。 +[`set_tracing_disabled()`][agents.set_tracing_disabled] 関数を使用すると、トレーシングを完全に無効にすることもできます。 ```python from agents import set_tracing_disabled @@ -154,7 +154,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -トレーシングを有効なままにしながら、機密情報を含む可能性がある入出力をトレースペイロードから除外する場合は、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定します。 +トレーシングを有効にしたまま、機密情報が含まれる可能性のある入力や出力をトレースペイロードから除外するには、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定します。 ```python from agents import Runner, RunConfig @@ -166,19 +166,19 @@ await Runner.run( ) ``` -アプリケーションの起動前に以下の環境変数を設定すると、コードを使用せずにデフォルトを変更することもできます。 +アプリの起動前に次の環境変数を設定することで、コードを変更せずにデフォルトを変更することもできます。 ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -トレーシングのすべての制御方法については、[トレーシングガイド](tracing.md)を参照してください。 +トレーシングのすべての制御については、[トレーシングガイド](tracing.md)を参照してください。 ## デバッグログ -SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーを追加しません。ログは、アプリケーションの Python ロギング設定に従います。 +SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーを追加しません。ログは、アプリケーションの Python ログ構成に従います。 -詳細なログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 +詳細ログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 ```python from agents import enable_verbose_stdout_logging @@ -186,7 +186,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -また、ハンドラー、フィルター、フォーマッターなどを追加して、ログをカスタマイズすることもできます。詳しくは、[Python ロギングガイド](https://docs.python.org/3/howto/logging.html)を参照してください。 +または、ハンドラー、フィルター、フォーマッターなどを追加してログをカスタマイズできます。詳細については、[Python ログガイド](https://docs.python.org/3/howto/logging.html)を参照してください。 ```python import logging @@ -205,22 +205,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### ログと診断情報内の機密データ +### ログと診断に含まれる機密データ -一部のログや診断例外には、機密データ(モデルまたはツールの入出力など)が含まれる場合があります。 +一部のログや診断例外には、機密データ(モデルまたはツールの入力や出力など)が含まれる場合があります。 -デフォルトでは、SDK は LLM の入出力やツールの入出力を **ログに記録しません**。これらの保護は、以下によって制御されます。 +デフォルトでは、SDK は LLMの入力や出力、およびツールの入力や出力をログに記録 **しません**。これらの保護は、次の設定によって制御されます。 ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -デバッグのために一時的にこのデータを含める必要がある場合は、アプリケーションの起動前にいずれかの変数を `0`(または `false`)に設定します。 +デバッグのためにこのデータを一時的に含める必要がある場合は、アプリの起動前にいずれかの変数を `0`(または `false`)に設定します。 ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -これらのフラグは、影響を受ける失敗に、ペイロードを含む診断の詳細を保持するかどうかも制御します。たとえば、ツールデータの編集が有効な場合、関数ツールへの無効な引数によって、元の検証エラーを例外チェーンに含まない汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、未加工のモデルまたはツールデータが、ログ、例外メッセージ、例外チェーン、その他の診断コンテキストに露出する可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file +これらのフラグは、影響を受ける失敗でペイロードを含む診断情報を保持するかどうかも制御します。たとえば、ツールデータの秘匿化が有効な場合、`FunctionTool` の引数が無効であると、基になる検証エラーを例外チェーンに含めず、汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、未加工のモデルデータやツールデータがログ、例外メッセージ、例外チェーン、その他の診断コンテキストに露出する可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file diff --git a/docs/ja/context.md b/docs/ja/context.md index ba2bffc485..99d52f9d76 100644 --- a/docs/ja/context.md +++ b/docs/ja/context.md @@ -4,49 +4,49 @@ search: --- # コンテキスト管理 -コンテキストは多義的な用語です。考慮すべきコンテキストには、主に 2 つの種類があります: +コンテキストは多義的な用語です。考慮すべきコンテキストには、主に 2 つのカテゴリーがあります。 -1. コードからローカルに利用できるコンテキスト: これは、ツール関数の実行時、`on_handoff` のようなコールバック内、ライフサイクルフック内などで必要になる可能性のあるデータや依存関係です。 -2. LLM が利用できるコンテキスト: これは、LLM が応答を生成するときに参照するデータです。 +1. コードからローカルに利用できるコンテキスト: ツール関数の実行時、`on_handoff` などのコールバック時、ライフサイクルフック内などで必要となる可能性があるデータや依存関係です。 +2. LLM が利用できるコンテキスト: LLM が応答を生成するときに参照するデータです。 ## ローカルコンテキスト -これは [`RunContextWrapper`][agents.run_context.RunContextWrapper] クラス、およびその中の [`context`][agents.run_context.RunContextWrapper.context] プロパティで表現されます。仕組みは次のとおりです: +これは、[`RunContextWrapper`][agents.run_context.RunContextWrapper] クラスと、そのクラス内の [`context`][agents.run_context.RunContextWrapper.context] プロパティによって表されます。仕組みは次のとおりです。 -1. 任意の Python オブジェクトを作成します。一般的なパターンは dataclass や Pydantic オブジェクトを使用することです。 -2. そのオブジェクトを各種実行メソッドに渡します (例: `Runner.run(..., context=whatever)`)。 -3. すべてのツール呼び出し、ライフサイクルフックなどには、ラッパーオブジェクト `RunContextWrapper[T]` が渡されます。ここで `T` はコンテキストオブジェクトの型を表し、`wrapper.context` を通じてアクセスできます。 +1. 任意の Python オブジェクトを作成します。一般的なパターンとして、dataclass または Pydantic オブジェクトを使用します。 +2. そのオブジェクトをさまざまな実行メソッド(例: `Runner.run(..., context=whatever)` )に渡します。 +3. すべてのツール呼び出しやライフサイクルフックなどには、ラッパーオブジェクト `RunContextWrapper[T]` が渡されます。ここで `T` はコンテキストオブジェクトの型を表し、オブジェクト自体は `wrapper.context` から利用できます。 -一部のランタイム固有のコールバックでは、SDK はより特殊化された `RunContextWrapper[T]` のサブクラスを渡す場合があります。たとえば、関数ツールのライフサイクルフックは通常 `ToolContext` を受け取り、これは `tool_call_id`、`tool_name`、`tool_arguments` などのツール呼び出しメタデータも公開します。 +一部のランタイム固有のコールバックでは、SDK が `RunContextWrapper[T]` のより特化したサブクラスを渡す場合があります。たとえば、`FunctionTool` インスタンスのライフサイクルフックは通常、`ToolContext` を受け取ります。これにより、`tool_call_id`、`tool_name`、`tool_arguments` などのツール呼び出しメタデータも利用できます。 -認識すべき **最も重要な** 点は、あるエージェント実行におけるすべてのエージェント、ツール関数、ライフサイクルなどが、同じ _型_ のコンテキストを使用しなければならないということです。 +認識しておくべき **最も重要な** 点は、特定のエージェント実行におけるすべてのエージェント、ツール関数、ライフサイクル処理などで、同じ _型_ のコンテキストを使用する必要があることです。 -コンテキストは、たとえば次の用途に使用できます: +コンテキストは、次のような用途に使用できます。 -- 実行時のコンテキストデータ (例: ユーザー名 / uid や、ユーザーに関するその他の情報) -- 依存関係 (例: ロガーオブジェクト、データ取得器など) +- 実行に関するコンテキストデータ(例: ユーザー名 / uid、またはユーザーに関するその他の情報) +- 依存関係(例: ロガーオブジェクト、データ取得オブジェクトなど) - ヘルパー関数 !!! danger "注記" - コンテキストオブジェクトは LLM に **送信されません**。これは完全にローカルなオブジェクトであり、読み取り、書き込み、メソッドの呼び出しができます。 + コンテキストオブジェクトが LLM に送信されることは **ありません** 。これは純粋にローカルなオブジェクトであり、データの読み取りや書き込み、メソッドの呼び出しが可能です。 -1 回の実行内では、派生したラッパーは同じ基盤となるアプリコンテキスト、承認状態、使用状況の追跡を共有します。ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行では異なる `tool_input` を付加する場合がありますが、デフォルトではアプリ状態の分離コピーは取得しません。 +単一の実行内では、派生したラッパーは基盤となるアプリコンテキスト、承認状態、使用量追跡を共有します。ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行では、別の `tool_input` を関連付けることができますが、デフォルトではアプリ状態の独立したコピーは作成されません。 -### `RunContextWrapper` の公開内容 +### `RunContextWrapper` の公開情報 -[`RunContextWrapper`][agents.run_context.RunContextWrapper] は、アプリで定義したコンテキストオブジェクトを包むラッパーです。実際には、ほとんどの場合、次のものを使用します: +[`RunContextWrapper`][agents.run_context.RunContextWrapper] は、アプリで定義したコンテキストオブジェクトのラッパーです。実際には、主に次のものを使用します。 -- [`wrapper.context`][agents.run_context.RunContextWrapper.context]: 独自の可変なアプリ状態と依存関係に使用します。 -- [`wrapper.usage`][agents.run_context.RunContextWrapper.usage]: 現在の実行全体で集計されたリクエストおよびトークン使用量に使用します。 -- [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input]: 現在の実行が [`Agent.as_tool()`][agents.agent.Agent.as_tool] の内部で実行されている場合の構造化入力に使用します。 -- [`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool]: 承認状態をプログラムで更新する必要がある場合に使用します。 +- 独自の変更可能なアプリ状態と依存関係には、[`wrapper.context`][agents.run_context.RunContextWrapper.context] を使用します。 +- 現在の実行全体で集計されたリクエストとトークンの使用量には、[`wrapper.usage`][agents.run_context.RunContextWrapper.usage] を使用します。 +- 現在の実行が [`Agent.as_tool()`][agents.agent.Agent.as_tool] 内で行われている場合の構造化入力には、[`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input] を使用します。 +- 承認状態をプログラムから更新する必要がある場合は、[`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool] を使用します。 -アプリで定義したオブジェクトは `wrapper.context` のみです。その他のフィールドは SDK が管理するランタイムメタデータです。 +アプリで定義するオブジェクトは `wrapper.context` だけです。その他のフィールドは、SDK が管理するランタイムメタデータです。 -後で human-in-the-loop や耐久ジョブワークフローのために [`RunState`][agents.run_state.RunState] をシリアライズする場合、そのランタイムメタデータは状態とともに保存されます。シリアライズされた状態を永続化または送信する予定がある場合、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] にシークレットを入れないでください。 +後でヒューマンインザループまたは永続的なジョブのワークフロー用に [`RunState`][agents.run_state.RunState] をシリアライズすると、そのランタイムメタデータも状態とともに保存されます。シリアライズした状態を永続化または送信する場合は、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] に機密情報を格納しないでください。 -会話状態は別の関心事です。ターンをどのように引き継ぐかに応じて、`result.to_input_list()`、`session`、`conversation_id`、または `previous_response_id` を使用してください。その判断については、[実行結果](results.md)、[エージェントの実行](running_agents.md)、[セッション](sessions/index.md) を参照してください。 +会話状態は別の考慮事項です。ターンをどのように引き継ぐかに応じて、`result.to_input_list()`、`session`、`conversation_id`、または `previous_response_id` を使用してください。この判断については、[実行結果](results.md)、[エージェントの実行](running_agents.md)、[セッション](sessions/index.md)を参照してください。 ```python import asyncio @@ -86,18 +86,18 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. これがコンテキストオブジェクトです。ここでは dataclass を使用していますが、任意の型を使用できます。 -2. これはツールです。`RunContextWrapper[UserInfo]` を受け取っていることがわかります。ツール実装はコンテキストから読み取ります。 -3. 型チェッカーがエラーを検出できるように、エージェントにジェネリック `UserInfo` を指定します (たとえば、異なるコンテキスト型を受け取るツールを渡そうとした場合)。 +1. これはコンテキストオブジェクトです。ここでは dataclass を使用していますが、任意の型を使用できます。 +2. これはツールです。`RunContextWrapper[UserInfo]` を受け取ることが分かります。ツールの実装はコンテキストからデータを読み取ります。 +3. 型チェッカーがエラーを検出できるように、エージェントにジェネリック型 `UserInfo` を指定します(たとえば、異なるコンテキスト型を受け取るツールを渡そうとした場合)。 4. コンテキストは `run` 関数に渡されます。 -5. エージェントは正しくツールを呼び出し、年齢を取得します。 +5. エージェントはツールを正しく呼び出し、年齢を取得します。 --- -### 高度な内容: `ToolContext` +### 高度な機能: `ToolContext` -場合によっては、実行中のツールに関する追加メタデータ (名前、呼び出し ID、生の引数文字列など) にアクセスしたいことがあります。 -この場合、`RunContextWrapper` を拡張する [`ToolContext`][agents.tool_context.ToolContext] クラスを使用できます。 +場合によっては、実行中のツールについて、その名前、呼び出し ID、raw 引数文字列などの追加メタデータにアクセスしたいことがあります。 +その場合は、`RunContextWrapper` を拡張する [`ToolContext`][agents.tool_context.ToolContext] クラスを使用できます。 ```python from typing import Annotated @@ -126,25 +126,25 @@ agent = Agent( ) ``` -`ToolContext` は `RunContextWrapper` と同じ `.context` プロパティを提供し、 -現在のツール呼び出しに固有の追加フィールドも提供します: +`ToolContext` は、`RunContextWrapper` と同じ `.context` プロパティに加えて、 +現在のツール呼び出しに固有の次のフィールドを提供します。 -- `tool_name` – 呼び出されているツールの名前 -- `tool_call_id` – このツール呼び出しの一意の識別子 -- `tool_arguments` – ツールに渡された生の引数文字列 -- `tool_namespace` – ツールが `tool_namespace()` または別の名前空間付きサーフェスを通じて読み込まれた場合の、ツール呼び出しに対する Responses 名前空間 -- `qualified_tool_name` – 名前空間が利用できる場合に、その名前空間で修飾されたツール名 +- `tool_name` – 呼び出されるツールの名前 +- `tool_call_id` – このツール呼び出しの一意な識別子 +- `tool_arguments` – ツールに渡された raw 引数文字列 +- `tool_namespace` – ツールが `tool_namespace()` または名前空間付きの別のインターフェースを通じて読み込まれた場合の、そのツール呼び出しの Responses 名前空間 +- `qualified_tool_name` – 名前空間を利用できる場合に、その名前空間で修飾されたツール名 -実行中にツールレベルのメタデータが必要な場合は、`ToolContext` を使用してください。 -エージェントとツール間で一般的なコンテキスト共有を行うには、`RunContextWrapper` のままで十分です。`ToolContext` は `RunContextWrapper` を拡張しているため、ネストされた `Agent.as_tool()` 実行が構造化入力を提供した場合には `.tool_input` も公開できます。 +実行中にツールレベルのメタデータが必要な場合は、`ToolContext` を使用します。 +エージェントとツール間で一般的なコンテキストを共有する場合は、引き続き `RunContextWrapper` で十分です。`ToolContext` は `RunContextWrapper` を拡張しているため、ネストされた `Agent.as_tool()` の実行で構造化入力が指定された場合は、`.tool_input` も公開できます。 --- ## エージェント / LLM コンテキスト -LLM が呼び出されるとき、その LLM が参照できる **唯一の** データは会話履歴に含まれるものです。つまり、新しいデータを LLM に利用可能にしたい場合は、その履歴内で利用可能になるような方法で行う必要があります。これにはいくつかの方法があります: +LLM が呼び出されたとき、LLM が参照できるのは会話履歴に含まれるデータ **だけ** です。つまり、新しいデータを LLM から利用可能にするには、その履歴に含まれる形で提供する必要があります。これには、次のような方法があります。 -1. エージェントの `instructions` に追加できます。これは「システムプロンプト」または「開発者メッセージ」とも呼ばれます。システムプロンプトは静的文字列にも、コンテキストを受け取って文字列を出力する動的関数にもできます。これは、常に役立つ情報 (たとえば、ユーザーの名前や現在の日付) に対する一般的な手法です。 -2. `Runner.run` 関数を呼び出すときに `input` に追加します。これは `instructions` の手法に似ていますが、[指揮系統](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command) においてより下位のメッセージにできます。 -3. 関数ツールを介して公開します。これは _オンデマンド_ のコンテキストに便利です。LLM がデータを必要とするタイミングを判断し、そのデータを取得するためにツールを呼び出せます。 -4. リトリーバルまたは Web 検索を使用します。これらは、ファイルやデータベースから関連データを取得する (リトリーバル)、または Web から取得する (Web 検索) ことができる特殊なツールです。これは、関連するコンテキストデータに基づいて応答を「グラウンディング」するのに便利です。 \ No newline at end of file +1. エージェントの `instructions` に追加できます。これは「システムプロンプト」または「開発者メッセージ」とも呼ばれます。システムプロンプトには静的な文字列を使用できるほか、コンテキストを受け取って文字列を出力する動的な関数も使用できます。常に有用な情報(たとえば、ユーザーの名前や現在の日付)に対してよく使用される方法です。 +2. `Runner.run` 関数の呼び出し時に、`input` に追加します。これは `instructions` を使用する方法と似ていますが、[指揮系統](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)における優先度がより低いメッセージを使用できます。 +3. `FunctionTool` インスタンスを通じて公開します。これは _オンデマンド_ のコンテキストに便利です。LLM がデータを必要とするタイミングを判断し、ツールを呼び出してそのデータを取得できます。 +4. 情報取得または Web 検索を使用します。これらは、ファイルやデータベースから関連データを取得したり(情報取得)、Web から関連データを取得したり(Web 検索)できる特別なツールです。関連するコンテキストデータに基づいて応答を「グラウンディング」する場合に役立ちます。 \ No newline at end of file diff --git a/docs/ja/examples.md b/docs/ja/examples.md index 28c464934e..0320e0ae9d 100644 --- a/docs/ja/examples.md +++ b/docs/ja/examples.md @@ -4,134 +4,134 @@ search: --- # コード例 -[リポジトリ](https://github.com/openai/openai-agents-python/tree/main/examples)の examples セクションでは、SDK のさまざまな実装例を確認できます。コード例は、各種パターンや機能を示す複数のカテゴリーに分類されています。 +SDK を使用したさまざまなサンプル実装は、[リポジトリ](https://github.com/openai/openai-agents-python/tree/main/examples)の examples セクションで確認できます。コード例は、さまざまなパターンや機能を示す複数のカテゴリーに分かれています。 ## カテゴリー -- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** このカテゴリーのコード例では、以下のような一般的なエージェント設計パターンを示します。 - - - 決定論的ワークフロー - - Agents as tools - - ストリーミングイベントを使用する Agents as tools(`examples/agent_patterns/agents_as_tools_streaming.py`) - - 構造化入力パラメーターを使用する Agents as tools(`examples/agent_patterns/agents_as_tools_structured.py`) - - エージェントの並列実行 - - 条件付きツール使用 - - 異なる動作によるツール使用の強制(`examples/agent_patterns/forcing_tool_use.py`) - - 入出力ガードレール - - 判定役としての LLM - - ルーティング - - ストリーミングガードレール - - ツール承認と状態のシリアライズを伴うヒューマンインザループ(`examples/agent_patterns/human_in_the_loop.py`) - - ストリーミングを伴うヒューマンインザループ(`examples/agent_patterns/human_in_the_loop_stream.py`) - - 承認フロー用のカスタム拒否メッセージ(`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) - -- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** これらのコード例では、以下のような SDK の基本機能を紹介します。 - - - Hello World のコード例(デフォルトモデル、GPT-5、オープンウェイトモデル) - - エージェントのライフサイクル管理 - - 実行フックとエージェントフックのライフサイクルのコード例(`examples/basic/lifecycle_example.py`) - - 動的なシステムプロンプト - - 基本的なツール使用(`examples/basic/tools.py`) - - ツールの入出力ガードレール(`examples/basic/tool_guardrails.py`) - - 画像ツールの出力(`examples/basic/image_tool_output.py`) - - 出力のストリーミング(テキスト、項目、関数呼び出しの引数) - - ターン間で共有セッションヘルパーを使用する Responses WebSocket トランスポート(`examples/basic/stream_ws.py`) - - プロンプトテンプレート - - ファイル処理(ローカルとリモート、画像と PDF) - - 使用量の追跡 - - Runner が管理する再試行設定(`examples/basic/retry.py`) - - サードパーティ製アダプターを介して Runner が管理する再試行(`examples/basic/retry_litellm.py`) - - 非厳密な出力型 - - 以前のレスポンス ID の使用 +- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** このカテゴリーのコード例では、次のような一般的なエージェント設計パターンを示します。 + + - 決定論的ワークフロー + - Agents as tools + - ストリーミングイベントを使用する Agents as tools (`examples/agent_patterns/agents_as_tools_streaming.py`) + - 構造化された入力パラメーターを使用する Agents as tools (`examples/agent_patterns/agents_as_tools_structured.py`) + - エージェントの並列実行 + - 条件に応じたツールの使用 + - さまざまなツール使用動作を示しながらツールの使用を強制 (`examples/agent_patterns/forcing_tool_use.py`) + - 入出力ガードレール + - 判定者としての LLM + - ルーティング + - ストリーミングガードレール + - ツールの承認と状態のシリアル化を伴うヒューマン・イン・ザ・ループ (`examples/agent_patterns/human_in_the_loop.py`) + - ストリーミングを伴うヒューマン・イン・ザ・ループ (`examples/agent_patterns/human_in_the_loop_stream.py`) + - 承認フロー向けのカスタム拒否メッセージ (`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) + +- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** これらのコード例では、次のような SDK の基本機能を紹介します。 + + - Hello world のコード例(デフォルトモデル、GPT-5、オープンウェイトモデル) + - エージェントのライフサイクル管理 + - `RunHooks` と `AgentHooks` を使用したエージェントおよび実行のライフサイクルのコード例 (`examples/basic/lifecycle_example.py`) + - 動的なシステムプロンプト + - 基本的なツールの使用 (`examples/basic/tools.py`) + - ツールの入出力ガードレール (`examples/basic/tool_guardrails.py`) + - ツール出力としての画像の返却 (`examples/basic/image_tool_output.py`) + - 出力のストリーミング(テキスト、項目、関数呼び出し引数) + - ターン間で共有セッションヘルパーを使用する Responses の WebSocket トランスポート (`examples/basic/stream_ws.py`) + - プロンプトテンプレート + - ファイル処理(ローカルとリモート、画像と PDF) + - 使用量の追跡 + - Runner が管理する再試行設定 (`examples/basic/retry.py`) + - サードパーティアダプターを介して Runner が管理する再試行 (`examples/basic/retry_litellm.py`) + - 非厳密な出力型 + - 以前のレスポンス ID の使用 - **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 航空会社向けカスタマーサービスシステムのコード例です。 -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 財務データ分析用のエージェントとツールを活用した構造化リサーチワークフローを示す、財務リサーチエージェントです。 - -- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** メッセージフィルタリングを伴うエージェントのハンドオフの実践的なコード例です。以下が含まれます: - - - メッセージフィルターのコード例(`examples/handoffs/message_filter.py`) - - ストリーミングを伴うメッセージフィルター(`examples/handoffs/message_filter_streaming.py`) - -- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API でホスト型 MCP(Model Context Protocol)を使用する方法を示すコード例です。以下が含まれます: - - - 承認なしのシンプルなホスト型 MCP(`examples/hosted_mcp/simple.py`) - - Google Calendar などの MCP コネクター(`examples/hosted_mcp/connectors.py`) - - 割り込みベースの承認を伴うヒューマンインザループ(`examples/hosted_mcp/human_in_the_loop.py`) - - MCP ツール呼び出し用の承認時コールバック(`examples/hosted_mcp/on_approval.py`) - -- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** MCP(Model Context Protocol)を使用してエージェントを構築する方法を学べます。以下が含まれます: - - - ファイルシステムのコード例 - - Git のコード例 - - MCP プロンプトサーバーのコード例 - - SSE(Server-Sent Events)のコード例 - - SSE リモートサーバー接続(`examples/mcp/sse_remote_example`) - - Streamable HTTP のコード例 - - Streamable HTTP リモート接続(`examples/mcp/streamable_http_remote_example`) - - Streamable HTTP 用のカスタム HTTP クライアントファクトリー(`examples/mcp/streamablehttp_custom_client_example`) - - `MCPUtil.get_all_function_tools` を使用したすべての MCP ツールの事前取得(`examples/mcp/get_all_mcp_tools_example`) - - FastAPI と MCPServerManager(`examples/mcp/manager_example`) - - MCP ツールのフィルタリング(`examples/mcp/tool_filter_example`) - -- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** エージェント向けのさまざまなメモリ実装のコード例です。以下が含まれます: - - - SQLite セッションストレージ - - 高度な SQLite セッションストレージ - - Redis セッションストレージ - - SQLAlchemy セッションストレージ - - Dapr ステートストアセッションストレージ - - 暗号化セッションストレージ - - OpenAI Conversations セッションストレージ - - Responses 圧縮セッションストレージ - - `ModelSettings(store=False)` を使用したステートレスな Responses 圧縮(`examples/memory/compaction_session_stateless_example.py`) - - ファイルベースのセッションストレージ(`examples/memory/file_session.py`) - - ヒューマンインザループを伴うファイルベースのセッション(`examples/memory/file_hitl_example.py`) - - ヒューマンインザループを伴う SQLite インメモリセッション(`examples/memory/memory_session_hitl_example.py`) - - ヒューマンインザループを伴う OpenAI Conversations セッション(`examples/memory/openai_session_hitl_example.py`) - - セッションをまたぐ HITL の承認/拒否シナリオ(`examples/memory/hitl_session_scenario.py`) - -- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** カスタムプロバイダーやサードパーティ製アダプターを含め、OpenAI 以外のモデルを SDK で使用する方法を確認できます。 - -- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** SDK を使用してリアルタイム体験を構築する方法を示すコード例です。以下が含まれます: - - - 構造化されたテキストメッセージと画像メッセージを扱う Web アプリケーションパターン - - コマンドラインの音声ループと再生処理 - - WebSocket を介した Twilio Media Streams 連携 - - Realtime Calls API のアタッチフローを使用する Twilio SIP 連携 - -- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 推論コンテンツの扱い方を示すコード例です。以下が含まれます: - - - Runner API での推論コンテンツ(ストリーミングと非ストリーミング)(`examples/reasoning_content/runner_example.py`) - - OpenRouter を介した OSS モデルでの推論コンテンツ(`examples/reasoning_content/gpt_oss_stream.py`) - - 基本的な推論コンテンツのコード例(`examples/reasoning_content/main.py`) - -- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 複雑なマルチエージェントのリサーチワークフローを示す、シンプルなディープリサーチのクローンです。 - -- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 分離されたワークスペースでエージェントを実行するためのコード例です。以下が含まれます: - - - 基本的なサンドボックスエージェントのセットアップ(`examples/sandbox/basic.py`) - - Unix ローカルおよび Docker サンドボックスのライフサイクルのコード例 - - サンドボックスを使用するハンドオフ(`examples/sandbox/handoffs.py`) - - サンドボックスのメモリとスナップショットからの再開(`examples/sandbox/memory.py`) - - ツールとして公開されるサンドボックスエージェント(`examples/sandbox/sandbox_agents_as_tools.py`) - -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** OpenAI がホストするツールや、以下のような試験的な Codex ツール機能の実装方法を学べます: - - - Web 検索とフィルター付き Web 検索 - - ファイル検索 - - Code interpreter - - ファイル編集と承認を伴うパッチ適用ツール(`examples/tools/apply_patch.py`) - - 承認コールバックを伴うシェルツールの実行(`examples/tools/shell.py`) - - ヒューマンインザループによる割り込みベースの承認を伴うシェルツール(`examples/tools/shell_human_in_the_loop.py`) - - インラインスキルを使用するホスト型コンテナーシェル(`examples/tools/container_shell_inline_skill.py`) - - スキル参照を使用するホスト型コンテナーシェル(`examples/tools/container_shell_skill_reference.py`) - - ローカルスキルを使用するローカルシェル(`examples/tools/local_shell_skill.py`) - - 名前空間と遅延ツールを使用するツール検索(`examples/tools/tool_search.py`) - - 構造化ツール呼び出しを並行実行するプログラムによるツール呼び出し(`examples/tools/programmatic_tool_calling.py`) - - コンピュータ操作 - - 画像生成 - - 試験的な Codex ツールワークフロー(`examples/tools/codex.py`) - - 試験的な Codex の同一スレッドワークフロー(`examples/tools/codex_same_thread.py`) - -- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** OpenAI の TTS モデルと STT モデルを使用する音声エージェントのコード例を確認できます。音声ストリーミングのコード例も含まれます。 \ No newline at end of file +- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** エージェントとツールを使用して、財務データ分析のための構造化された調査ワークフローを示す財務調査エージェントです。 + +- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** メッセージフィルタリングを伴うエージェントのハンドオフの実践的なコード例です。次の内容が含まれます。 + + - メッセージフィルターのコード例 (`examples/handoffs/message_filter.py`) + - ストリーミングを伴うメッセージフィルター (`examples/handoffs/message_filter_streaming.py`) + +- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API でホスト型 MCP(Model Context Protocol)を使用する方法を示すコード例です。次の内容が含まれます。 + + - 承認なしのシンプルなホスト型 MCP (`examples/hosted_mcp/simple.py`) + - Google カレンダーなどの MCP コネクター (`examples/hosted_mcp/connectors.py`) + - 割り込みベースの承認を伴うヒューマン・イン・ザ・ループ (`examples/hosted_mcp/human_in_the_loop.py`) + - MCP ツール承認リクエスト用のコールバック (`examples/hosted_mcp/on_approval.py`) + +- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** MCP(Model Context Protocol)を使用してエージェントを構築する方法を学習できます。次の内容が含まれます。 + + - ファイルシステムのコード例 + - Git のコード例 + - MCP プロンプトサーバーのコード例 + - SSE(Server-Sent Events)のコード例 + - SSE リモートサーバー接続 (`examples/mcp/sse_remote_example`) + - Streamable HTTP のコード例 + - Streamable HTTP リモート接続 (`examples/mcp/streamable_http_remote_example`) + - Streamable HTTP 向けのカスタム HTTP クライアントファクトリー (`examples/mcp/streamablehttp_custom_client_example`) + - `MCPUtil.get_all_function_tools` を使用したすべての MCP ツールのプリフェッチ (`examples/mcp/get_all_mcp_tools_example`) + - FastAPI アプリケーションでの `MCPServerManager` の使用 (`examples/mcp/manager_example`) + - MCP ツールのフィルタリング (`examples/mcp/tool_filter_example`) + +- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** エージェント向けのさまざまなメモリ実装のコード例です。次の内容が含まれます。 + + - SQLite セッションストレージ + - 高度な SQLite セッションストレージ + - Redis セッションストレージ + - SQLAlchemy セッションストレージ + - Dapr 状態ストアのセッションストレージ + - 暗号化されたセッションストレージ + - OpenAI Conversations セッションストレージ + - Responses 圧縮セッションストレージ + - `ModelSettings(store=False)` を使用したステートレスな Responses 圧縮 (`examples/memory/compaction_session_stateless_example.py`) + - ファイルベースのセッションストレージ (`examples/memory/file_session.py`) + - ヒューマン・イン・ザ・ループを伴うファイルベースのセッション (`examples/memory/file_hitl_example.py`) + - ヒューマン・イン・ザ・ループを伴う SQLite インメモリセッション (`examples/memory/memory_session_hitl_example.py`) + - ヒューマン・イン・ザ・ループを伴う OpenAI Conversations セッション (`examples/memory/openai_session_hitl_example.py`) + - セッションをまたぐ HITL の承認/拒否シナリオ (`examples/memory/hitl_session_scenario.py`) + +- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** カスタムプロバイダーやサードパーティアダプターなど、OpenAI 以外のモデルを SDK で使用する方法を確認できます。 + +- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** SDK を使用してリアルタイム体験を構築する方法を示すコード例です。次の内容が含まれます。 + + - 構造化されたテキストおよび画像メッセージを使用する Web アプリケーションパターン + - コマンドラインの音声ループと再生処理 + - WebSocket 経由の Twilio Media Streams 連携 + - Realtime Calls API の `attach` フローを使用した Twilio SIP 連携 + +- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 推論コンテンツの扱い方を示すコード例です。次の内容が含まれます。 + + - Runner API での推論コンテンツ(ストリーミングおよび非ストリーミング) (`examples/reasoning_content/runner_example.py`) + - OpenRouter 経由の OSS モデルを使用した推論コンテンツ (`examples/reasoning_content/gpt_oss_stream.py`) + - 基本的な推論コンテンツのコード例 (`examples/reasoning_content/main.py`) + +- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 複雑なマルチエージェント調査ワークフローを示す、シンプルなディープリサーチのクローンです。 + +- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 分離されたワークスペースでエージェントを実行するためのコード例です。次の内容が含まれます。 + + - 基本的なサンドボックスエージェントのセットアップ (`examples/sandbox/basic.py`) + - Unix ローカルおよび Docker サンドボックスのライフサイクルのコード例 + - サンドボックスを基盤とするハンドオフ (`examples/sandbox/handoffs.py`) + - サンドボックスのメモリとスナップショットからの再開 (`examples/sandbox/memory.py`) + - ツールとして公開されるサンドボックスエージェント (`examples/sandbox/sandbox_agents_as_tools.py`) + +- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** OpenAI がホストするツールと実験的な Codex ツール機能の実装方法を学習できます。コード例には次の内容が含まれます。 + + - Web 検索とフィルター付き Web 検索 + - ファイル検索 + - Code interpreter + - ファイル編集と承認を伴う Apply patch ツール (`examples/tools/apply_patch.py`) + - 承認コールバックを伴う Shell ツールの実行 (`examples/tools/shell.py`) + - ヒューマン・イン・ザ・ループによる割り込みベースの承認を伴う Shell ツール (`examples/tools/shell_human_in_the_loop.py`) + - インラインスキルを使用するホスト型コンテナー Shell (`examples/tools/container_shell_inline_skill.py`) + - スキル参照を使用するホスト型コンテナー Shell (`examples/tools/container_shell_skill_reference.py`) + - ローカルスキルを使用するローカル Shell (`examples/tools/local_shell_skill.py`) + - 名前空間と遅延読み込みを使用するツールを備えたツール検索 (`examples/tools/tool_search.py`) + - 構造化されたツール呼び出しを並行実行するプログラムによるツール呼び出し (`examples/tools/programmatic_tool_calling.py`) + - コンピュータ操作 + - 画像生成 + - 実験的な Codex ツールワークフロー (`examples/tools/codex.py`) + - 同じ Codex 会話スレッドを再利用する実験的な Codex ワークフロー (`examples/tools/codex_same_thread.py`) + +- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** TTS および STT モデルを使用する音声エージェントのコード例を確認できます。音声をストリーミングするコード例も含まれます。 \ No newline at end of file diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index 190462bf48..bc65e8947e 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -4,79 +4,79 @@ search: --- # ガードレール -ガードレールを使用すると、ユーザー入力とエージェント出力の検査および検証を行えます。たとえば、非常に高性能である一方、低速でコストの高いモデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーが、数学の宿題を手伝うようモデルに依頼できる状態は避けたいでしょう。そのため、高速で低コストのモデルを使用してガードレールを実行できます。ガードレールが不正利用を検出した場合、ただちにエラーを発生させ、高コストのモデルが実行されるのを防ぐことで、時間と費用を節約できます( **ブロッキングガードレールを使用する場合。並列ガードレールでは、ガードレールが完了する前に高コストのモデルがすでに実行を開始している可能性があります。詳細については、以下の「実行モード」を参照してください** )。 +ガードレールを使用すると、ユーザー入力とエージェント出力のチェックや検証を実行できます。たとえば、非常に高性能な(したがって低速でコストも高い)モデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストのモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、即座にエラーを送出できるため、時間とコストを節約できます。ブロッキング実行では高コストのモデルが起動しないことが保証されますが、並列実行ではガードレールが完了する前に高コストのモデルがすでに起動している可能性があります。詳しくは、以下の「実行モード」を参照してください。 -ガードレールには、次の 2 種類があります。 +ガードレールには次の 2 種類があります。 1. 入力ガードレールは、最初のユーザー入力に対して実行されます 2. 出力ガードレールは、最終的なエージェント出力に対して実行されます ## ワークフローの境界 -ガードレールはエージェントとツールに関連付けられますが、すべてがワークフロー内の同じ時点で実行されるわけではありません。 +ガードレールはエージェントとツールに設定されますが、ワークフロー内のすべての同じ時点で実行されるわけではありません。 - **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 - **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 - **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前、出力ガードレールは実行後に実行されます。 -マネージャー、ハンドオフ、または処理を委任されたスペシャリストを含むワークフローで、カスタム関数ツールの呼び出しごとに検査が必要な場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 +マネージャー、ハンドオフ、または処理を委任されたスペシャリストを含むワークフローで、カスタム関数ツールの各呼び出し前後にチェックが必要な場合は、エージェントレベルの入出力ガードレールだけに依存せず、ツールガードレールを使用してください。 ## 入力ガードレール 入力ガードレールは、次の 3 ステップで実行されます。 -1. 最初に、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 -2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成します。その後、これは [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合は、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 +1. まず、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 +2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合は [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が送出されるため、ユーザーに適切に応答したり、例外を処理したりできます。 !!! Note - 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初の* エージェントである場合にのみ実行されます。なぜ `guardrails` プロパティを `Runner.run` に渡すのではなく、エージェントに設定するのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初* のエージェントである場合にのみ実行されます。なぜ `guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェントに設定されるのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 ### 実行モード -入力ガードレールは、次の 2 つの実行モードをサポートします。 +入力ガードレールは、次の 2 つの実行モードをサポートしています。 -- **並列実行**(デフォルト、`run_in_parallel=True`): ガードレールはエージェントの実行と同時に実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールが不合格になった場合でも、エージェントがキャンセルされる前に、すでにトークンを消費し、ツールを実行している可能性があります。 +- **並列実行** (デフォルト、`run_in_parallel=True`):ガードレールは、エージェントの実行と同時に実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールのトリップワイヤーが作動した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 -- **ブロッキング実行**(`run_in_parallel=False`): ガードレールは、エージェントが開始される *前に* 実行され、完了します。ガードレールのトリップワイヤーがトリガーされた場合、エージェントは実行されないため、トークンの消費とツールの実行を防げます。これは、コストを最適化する場合や、ツール呼び出しによる潜在的な副作用を回避したい場合に最適です。 +- **ブロッキング実行** (`run_in_parallel=False`):ガードレールは、エージェントが起動する *前* に実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防止できます。これは、コストを最適化する場合や、ツール呼び出しによる潜在的な副作用を回避したい場合に最適です。 ## 出力ガードレール 出力ガードレールは、次の 3 ステップで実行されます。 -1. 最初に、ガードレールはエージェントが生成した出力を受け取ります。 -2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成します。その後、これは [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が `true` かどうかを確認します。`true` の場合は、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 +1. まず、ガードレールはエージェントが生成した出力を受け取ります。 +2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合は [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が送出されるため、ユーザーに適切に応答したり、例外を処理したりできます。 !!! Note - 出力ガードレールは最終的なエージェント出力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最後の* エージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 出力ガードレールは最終的なエージェント出力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最後* のエージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 - 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターはサポートされません。 + 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターはサポートしていません。 ## ツールガードレール -ツールガードレールは **関数ツール** をラップし、実行前後にツール呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 +ツールガードレールは **`FunctionTool` のインスタンス** をラップし、ツールの実行前後にその呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 -- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、出力のメッセージへの置き換え、またはトリップワイヤーの発生が可能です。 -- 出力ツールガードレールはツールの実行後に実行され、出力の置き換えまたはトリップワイヤーの発生が可能です。 -- 関数ツールに承認が必要な場合、通常、入力ツールガードレールは承認後、実行直前に実行されます。保留中の承認による中断が発生する前にこれらの入力検査を実行する場合は、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定します。この承認前検査に合格した呼び出しも、ツールの実行前に承認後の再検査を受けます。 -- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく、SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)と組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)もこのガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 +- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、出力のメッセージへの置き換え、またはトリップワイヤーの送出が可能です。 +- 出力ツールガードレールはツールの実行後に実行され、出力の置き換えまたはトリップワイヤーの送出が可能です。 +- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に実行されます。承認保留による中断が発生する前にこれらの入力チェックを実行するには、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定します。この承認前チェックを通過した呼び出しは、ツールの実行前に、承認後にも再度チェックされます。 +- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)と組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)もこのガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 -詳細については、以下のコードスニペットを参照してください。 +詳しくは、以下のコードスニペットを参照してください。 ## トリップワイヤー -エージェントの入力または出力がガードレール検査に不合格になった場合、ガードレールはトリップワイヤーを使用して通知できます。ランナーはただちに `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を発生させ、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外を使用します。 +エージェントの入力または出力がガードレールを通過しなかった場合、ガードレールはトリップワイヤーを使用して通知できます。ランナーは即座に `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を送出し、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外が使用されます。 -エージェントレベルのトリップワイヤーでは、例外の `guardrail_result` によって、トリップワイヤーをトリガーしたガードレールを特定できます。ランナーによって入力トリップワイヤーが発生した場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの結果が含まれます。これには、トリップワイヤーをトリガーした結果も含まれます。出力トリップワイヤーでは、`exception.run_data.output_guardrail_results` を通じて、同等の累積結果が提供されます。 +エージェントレベルのトリップワイヤーでは、例外の `guardrail_result` によって、トリップワイヤーを作動させたガードレールを特定できます。ランナーが入力トリップワイヤーを送出した場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレール結果が含まれます。これには、トリップワイヤーを作動させた結果も含まれます。出力トリップワイヤーでは、`exception.run_data.output_guardrail_results` を通じて、これに相当する累積結果が提供されます。 -一方、ツールのトリップワイヤー例外では、トリガーした `guardrail` と `output` が直接公開されます。`run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` のリストには、エラーが発生する前に完了したターンで蓄積された結果が保持されます。トリガーした結果は、例外の `output` から取得できます。`MaxTurnsExceeded` など、ランナーによって管理されるその他のエラーでも、完了したツールガードレールの結果がこれらのリストに保持されます。`stream_events()` が例外を発生させた後、ストリーミング結果からも、同じく蓄積されたエージェントおよびツールガードレールの結果リストを取得できます。ランナーによって管理される実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 +一方、ツールトリップワイヤーの例外は、トリガーとなった `guardrail` と `output` を直接公開します。その `run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` リストには、失敗前に完了したターンから蓄積された結果が保持されます。トリガーとなった結果は、例外の `output` から取得できます。`MaxTurnsExceeded` など、ランナーが管理するその他の失敗でも、完了したツールガードレールの結果がこれらのリストに保持されます。`stream_events()` が例外を送出した後も、ストリーミング結果では、同じように蓄積されたエージェントおよびツールガードレールの結果リストを取得できます。ランナーが管理する実行パスの外部で例外が送出された場合、`run_data` は `None` になる可能性があります。 ## ガードレールの実装 -入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することでこれを実現します。 +入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行してこれを実現します。 ```python from pydantic import BaseModel @@ -130,8 +130,8 @@ async def main(): ``` 1. ガードレール関数でこのエージェントを使用します。 -2. これは、エージェントの入力/コンテキストを受け取り、結果を返すガードレール関数です。 -3. ガードレールの結果に追加情報を含めることができます。 +2. これは、エージェントの入力とコンテキストを受け取り、結果を返すガードレール関数です。 +3. ガードレールの結果には追加情報を含めることができます。 4. これは、ワークフローを定義する実際のエージェントです。 出力ガードレールも同様です。 diff --git a/docs/ja/handoffs.md b/docs/ja/handoffs.md index cb2bc81411..5adf89bf29 100644 --- a/docs/ja/handoffs.md +++ b/docs/ja/handoffs.md @@ -4,21 +4,21 @@ search: --- # ハンドオフ -ハンドオフを使用すると、エージェントはタスクを別のエージェントに委任できます。これは、異なるエージェントがそれぞれ異なる領域に特化しているシナリオで特に役立ちます。たとえば、カスタマーサポートアプリでは、注文状況、返金、よくある質問などのタスクを、それぞれ専任のエージェントが処理できます。 +ハンドオフを使用すると、エージェントはタスクを別のエージェントに委任できます。これは、異なるエージェントがそれぞれ別の領域を専門とするシナリオで特に役立ちます。たとえば、カスタマーサポートアプリでは、注文状況、返金、FAQ などのタスクをそれぞれ専門に処理するエージェントを用意できます。 -ハンドオフは、LLM に対してツールとして表現されます。そのため、`Refund Agent` という名前のエージェントへのハンドオフがある場合、そのツールは `transfer_to_refund_agent` と呼ばれます。 +ハンドオフは、LLM に対してツールとして表現されます。そのため、`Refund Agent` という名前のエージェントへのハンドオフがある場合、ツール名は `transfer_to_refund_agent` になります。 ## ハンドオフの作成 すべてのエージェントには [`handoffs`][agents.agent.Agent.handoffs] パラメーターがあり、`Agent` を直接受け取ることも、ハンドオフをカスタマイズする `Handoff` オブジェクトを受け取ることもできます。 -`Agent` インスタンスをそのまま渡した場合、その [`handoff_description`][agents.agent.Agent.handoff_description] が設定されていれば、デフォルトのツール説明に追加されます。完全な `handoff()` オブジェクトを記述せずに、モデルがそのハンドオフを選択すべきタイミングを示すために使用できます。 +単純な `Agent` インスタンスを渡した場合、その [`handoff_description`][agents.agent.Agent.handoff_description] が設定されていれば、デフォルトのツール説明に追加されます。完全な `handoff()` オブジェクトを記述せずに、モデルがそのハンドオフを選択すべきタイミングを示すために使用できます。 -Agents SDK が提供する [`handoff()`][agents.handoffs.handoff] 関数を使用して、ハンドオフを作成できます。この関数では、ハンドオフ先のエージェントに加えて、任意のオーバーライドや入力フィルターを指定できます。 +Agents SDK が提供する [`handoff()`][agents.handoffs.handoff] 関数を使用して、ハンドオフを作成できます。この関数では、ハンドオフ先のエージェントに加えて、オプションのオーバーライドと入力フィルターを指定できます。 -### 基本的な使用法 +### 基本的な使用方法 -簡単なハンドオフは、次のように作成できます。 +簡単なハンドオフは次のように作成できます。 ```python from agents import Agent, handoff @@ -36,16 +36,16 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun [`handoff()`][agents.handoffs.handoff] 関数を使用すると、さまざまな項目をカスタマイズできます。 -- `agent`: ハンドオフ先となるエージェントです。 -- `tool_name_override`: デフォルトでは `Handoff.default_tool_name()` 関数が使用され、`transfer_to_` に解決されます。これはオーバーライドできます。 +- `agent`: ハンドオフ先のエージェントです。 +- `tool_name_override`: デフォルトでは、`transfer_to_` に解決される `Handoff.default_tool_name()` 関数が使用されます。これはオーバーライドできます。 - `tool_description_override`: `Handoff.default_tool_description()` のデフォルトのツール説明をオーバーライドします。 -- `on_handoff`: ハンドオフが呼び出されたときに実行されるコールバック関数です。ハンドオフが呼び出されることが判明した時点で、データ取得を開始する場合などに役立ちます。この関数はエージェントコンテキストを受け取り、必要に応じて LLM が生成した入力も受け取れます。入力データは `input_type` パラメーターによって制御されます。 -- `input_type`: ハンドオフのツール呼び出し引数のスキーマです。設定すると、解析済みのペイロードが `on_handoff` に渡されます。 +- `on_handoff`: ハンドオフが呼び出されたときに実行されるコールバック関数です。ハンドオフが呼び出されることが判明した時点で、データ取得などを開始する場合に便利です。この関数はエージェントコンテキストを受け取り、オプションで LLM が生成した入力も受け取れます。入力データは `input_type` パラメーターによって制御されます。 +- `input_type`: ハンドオフのツール呼び出し引数のスキーマです。設定すると、解析されたペイロードが `on_handoff` に渡されます。 - `input_filter`: 次のエージェントが受け取る入力をフィルタリングできます。詳細は以下を参照してください。 -- `is_enabled`: ハンドオフが有効かどうかを指定します。真偽値、または真偽値を返す関数を指定でき、実行時にハンドオフを動的に有効化または無効化できます。 -- `nest_handoff_history`: `RunConfig` レベルの `nest_handoff_history` 設定を呼び出し単位でオーバーライドする任意の設定です。`None` の場合は、代わりにアクティブな実行設定で定義されている値が使用されます。 +- `is_enabled`: ハンドオフを有効にするかどうかを指定します。ブール値、またはブール値を返す関数を指定できるため、実行時にハンドオフを動的に有効化または無効化できます。 +- `nest_handoff_history`: RunConfig レベルの `nest_handoff_history` 設定をハンドオフごとにオーバーライドするためのオプションです。`None` の場合、アクティブな実行設定で定義された値が代わりに使用されます。 -[`handoff()`][agents.handoffs.handoff] ヘルパーは、渡された特定の `agent` に常に制御を移します。複数の移行先が考えられる場合は、移行先ごとにハンドオフを 1 つ登録し、モデルに選択させてください。呼び出し時にどのエージェントを返すかを独自のハンドオフコードで決定する必要がある場合にのみ、カスタムの [`Handoff`][agents.handoffs.Handoff] を使用してください。 +[`handoff()`][agents.handoffs.handoff] ヘルパーは、渡された特定の `agent` に常に制御を移します。移行先の候補が複数ある場合は、移行先ごとに 1 つのハンドオフを登録し、モデルに選択させます。独自のハンドオフコードが呼び出し時に返すエージェントを決定する必要がある場合にのみ、カスタムの [`Handoff`][agents.handoffs.Handoff] を使用してください。 ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## ハンドオフ入力 -状況によっては、LLM がハンドオフを呼び出す際に、何らかのデータを提供するようにしたい場合があります。たとえば、「エスカレーションエージェント」へのハンドオフを想定します。ログに記録できるよう、モデルに理由を提供させることができます。 +状況によっては、LLM がハンドオフを呼び出す際に、何らかのデータを提供するようにしたい場合があります。たとえば、「エスカレーションエージェント」へのハンドオフを考えてみましょう。ログに記録できるよう、モデルに理由を提供させることができます。 ```python from pydantic import BaseModel @@ -87,44 +87,44 @@ handoff_obj = handoff( ) ``` -`input_type` は、ハンドオフのツール呼び出し自体の引数を記述します。SDK はそのスキーマをハンドオフツールの `parameters` としてモデルに公開し、返された JSON をローカルで検証して、解析済みの値を `on_handoff` に渡します。 +`input_type` は、ハンドオフのツール呼び出し自体の引数を記述します。SDK はそのスキーマをハンドオフツールの `parameters` としてモデルに公開し、返された JSON をローカルで検証して、解析された値を `on_handoff` に渡します。 -これは次のエージェントのメイン入力を置き換えるものではなく、別の移行先を選択するものでもありません。[`handoff()`][agents.handoffs.handoff] ヘルパーは引き続き、ラップした特定のエージェントに制御を移し、受け取る側のエージェントは、[`input_filter`][agents.handoffs.Handoff.input_filter] またはネストされたハンドオフ履歴の設定で変更しない限り、引き続き会話履歴を参照できます。 +これは次のエージェントのメイン入力を置き換えるものでも、別の移行先を選択するものでもありません。[`handoff()`][agents.handoffs.handoff] ヘルパーは引き続きラップされた特定のエージェントに制御を移し、受け取り側のエージェントも、[`input_filter`][agents.handoffs.Handoff.input_filter] またはネストされたハンドオフ履歴の設定で変更しない限り、会話履歴を引き続き参照できます。 -`input_type` は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] とも別のものです。`input_type` は、すでにローカルに存在するアプリケーションの状態や依存関係ではなく、ハンドオフ時にモデルが決定するメタデータに使用してください。 +`input_type` は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] とも異なります。ローカルにすでに存在するアプリケーションの状態や依存関係ではなく、ハンドオフ時にモデルが決定するメタデータには `input_type` を使用してください。 -### `input_type` の使用場面 +### `input_type` の使用タイミング -ハンドオフに `reason`、`language`、`priority`、`summary` など、モデルが生成する少量のメタデータが必要な場合は、`input_type` を使用します。たとえば、トリアージエージェントは `{ "reason": "duplicate_charge", "priority": "high" }` を指定して返金エージェントにハンドオフでき、返金エージェントが引き継ぐ前に、`on_handoff` でそのメタデータをログに記録したり永続化したりできます。 +ハンドオフに、`reason`、`language`、`priority`、`summary` など、モデルが生成する小さなメタデータが必要な場合は、`input_type` を使用します。たとえば、トリアージエージェントは `{ "reason": "duplicate_charge", "priority": "high" }` を伴って返金エージェントにハンドオフでき、返金エージェントが引き継ぐ前に `on_handoff` でそのメタデータをログに記録したり永続化したりできます。 目的が異なる場合は、別の仕組みを選択してください。 - 既存のアプリケーションの状態と依存関係は、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] に格納します。[コンテキストガイド](context.md)を参照してください。 -- 受け取る側のエージェントが参照する履歴を変更する場合は、[`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]、または [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] を使用します。 -- 複数の専門エージェントが移行先の候補となる場合は、移行先ごとにハンドオフを 1 つ登録します。`input_type` は選択されたハンドオフにメタデータを追加できますが、移行先を振り分けるものではありません。 -- 会話を移行せず、ネストされた専門エージェントに構造化された入力を渡す場合は、[`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool] を使用することを推奨します。[ツール](tools.md#structured-input-for-tool-agents)を参照してください。 +- 受け取り側のエージェントが参照する履歴を変更する場合は、[`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]、または [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] を使用します。 +- 専門エージェントの候補が複数ある場合は、移行先ごとに 1 つのハンドオフを登録します。`input_type` は選択されたハンドオフにメタデータを追加できますが、移行先を振り分けるものではありません。 +- 会話を移行せずに、ネストされた専門エージェントへ構造化入力を渡す場合は、[`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool] の使用を推奨します。[ツール](tools.md#structured-input-for-tool-agents)を参照してください。 ## 入力フィルター -ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、それまでの会話履歴全体を参照できるようになります。これを変更する場合は、[`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、[`HandoffInputData`][agents.handoffs.HandoffInputData] を介して既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 +ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、それまでの会話履歴全体を参照できる状態になります。これを変更するには、[`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、[`HandoffInputData`][agents.handoffs.HandoffInputData] を介して既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 [`HandoffInputData`][agents.handoffs.HandoffInputData] には、以下が含まれます。 - `input_history`: `Runner.run(...)` が開始される前の入力履歴です。 - `pre_handoff_items`: ハンドオフが呼び出されたエージェントターンより前に生成された項目です。 - `new_items`: ハンドオフ呼び出しとハンドオフ出力項目を含む、現在のターン中に生成された項目です。 -- `input_items`: `new_items` の代わりに次のエージェントへ転送する任意の項目です。セッション履歴では `new_items` をそのまま維持しながら、モデル入力をフィルタリングできます。 -- `run_context`: ハンドオフが呼び出された時点でアクティブな [`RunContextWrapper`][agents.run_context.RunContextWrapper] です。 +- `input_items`: `new_items` の代わりに次のエージェントへ転送するオプションの項目です。セッション履歴では `new_items` をそのまま維持しながら、モデル入力をフィルタリングできます。 +- `run_context`: ハンドオフが呼び出された時点でアクティブだった [`RunContextWrapper`][agents.run_context.RunContextWrapper] です。 -ネストされたハンドオフは、オプトインのベータ機能として利用できますが、安定化を進めている間はデフォルトで無効になっています。[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] を有効にすると、ランナーは要約可能な履歴を順序付けられたアシスタント要約セグメントに圧縮する一方で、情報を失わないメッセージ項目を元の位置に保持します。生成される各要約セグメントでは `` ラッパーが使用され、後続のハンドオフでは、順序付きの会話記録を再構築する前に、以前に生成されたセグメントがフラット化されます。セッション、`RunState`、および `RunResult.to_input_list()` は、この SDK のデフォルト履歴に移されたメッセージの各出現を正確に追跡するため、それらが二重に追加されることはありません。一方、内容が同一でも別個のメッセージは引き続き保持されます。組み込みのセグメント化を使用せず、次のエージェントに渡す入力項目の正確なリストを返す独自のマッピング関数を、[`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] で指定できます。このオプトイン設定は、ハンドオフと実行のどちらにも明示的な `input_filter` が指定されていない場合にのみ適用されます。そのため、このリポジトリ内のコード例を含め、ペイロードをすでにカスタマイズしている既存のコードでは、変更せずに現在の動作が維持されます。単一のハンドオフに対してネスト動作をオーバーライドするには、[`handoff(...)`][agents.handoffs.handoff] に `nest_handoff_history=True` または `False` を渡します。これにより、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] が設定されます。生成される要約セグメントのラッパーテキストのみを変更する場合は、エージェントを実行する前に [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。必要に応じて、[`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] も呼び出せます。 +ネストされたハンドオフ履歴はオプトインのベータ機能として利用でき、安定化を進めている間はデフォルトで無効になっています。[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] を有効にすると、ランナーは要約可能な履歴を順序付けられたアシスタント要約セグメントに圧縮しつつ、情報を失わないメッセージ項目を元の位置に保持します。生成された各要約セグメントでは `` ラッパーが使用され、後続のハンドオフでは、順序付けられたトランスクリプトを再構築する前に、以前に生成されたセグメントがフラット化されます。セッション、`RunState`、`RunResult.to_input_list()` は、この SDK デフォルトの履歴に移動されたメッセージの出現箇所を正確に追跡するため、それらが二重に追加されることはありません。一方、内容が同一でも別個のメッセージは保持されます。組み込みのセグメント化を使用せず、次のエージェントに渡す入力項目の正確なリストを返す独自のマッピング関数を、[`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] で指定できます。このオプトインは、ハンドオフの `input_filter` とアクティブな実行の `RunConfig.handoff_input_filter` のどちらも設定されていない場合にのみ適用されます。そのため、ペイロードをすでにカスタマイズしている既存のコード(このリポジトリのコード例を含む)は、変更なしで現在の動作を維持します。[`handoff(...)`][agents.handoffs.handoff] に `nest_handoff_history=True` または `False` を渡すことで、単一のハンドオフについてネストの挙動をオーバーライドできます。これにより、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] が設定されます。生成される要約セグメントのラッパーテキストのみを変更する場合は、エージェントを実行する前に [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。後続の実行でデフォルトのラッパーに戻す必要がある場合は、その実行前に [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します。 -ハンドオフとアクティブな [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] の両方でフィルターが定義されている場合、その特定のハンドオフでは、ハンドオフ単位の [`input_filter`][agents.handoffs.Handoff.input_filter] が優先されます。 +ハンドオフとアクティブな [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] の両方でフィルターが定義されている場合、その特定のハンドオフでは、ハンドオフごとの [`input_filter`][agents.handoffs.Handoff.input_filter] が優先されます。 !!! note - ハンドオフは単一の実行内で行われます。入力ガードレールは引き続きチェーン内の最初のエージェントにのみ適用され、出力ガードレールは最終出力を生成するエージェントにのみ適用されます。ワークフロー内の各カスタム関数ツール呼び出しをチェックする必要がある場合は、ツールガードレールを使用してください。 + ハンドオフは単一の実行内にとどまります。入力ガードレールは引き続きチェーンの最初のエージェントにのみ適用され、出力ガードレールは最終出力を生成するエージェントにのみ適用されます。ワークフロー内の各カスタム関数ツール呼び出しに対してチェックが必要な場合は、ツールガードレールを使用してください。 -履歴からすべてのツール呼び出しを削除するなど、いくつかの一般的なパターンがあり、[`agents.extensions.handoff_filters`][] に実装されています。 +一般的なパターンの一部(たとえば、履歴からすべてのツール呼び出しを削除する処理)は、[`agents.extensions.handoff_filters`][] に実装されています。 ```python from agents import Agent, handoff @@ -138,11 +138,11 @@ handoff_obj = handoff( ) ``` -1. これにより、`FAQ agent` が呼び出されたときに、履歴からすべてのツールが自動的に削除されます。 +1. `FAQ agent` が呼び出されると、履歴からツール関連の項目がすべて自動的に削除されます。 ## 推奨プロンプト -LLM がハンドオフを適切に理解できるよう、エージェントにハンドオフに関する情報を含めることを推奨します。[`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] に推奨プレフィックスが用意されています。または、[`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] を呼び出して、推奨情報をプロンプトに自動的に追加できます。 +LLM がハンドオフを正しく理解できるようにするため、エージェントにハンドオフに関する情報を含めることを推奨します。[`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] に推奨プレフィックスが用意されています。また、[`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] を呼び出して、推奨データをプロンプトに自動的に追加することもできます。 ```python from agents import Agent diff --git a/docs/ja/human_in_the_loop.md b/docs/ja/human_in_the_loop.md index db48eafb09..521379759c 100644 --- a/docs/ja/human_in_the_loop.md +++ b/docs/ja/human_in_the_loop.md @@ -4,19 +4,19 @@ search: --- # ヒューマン・イン・ザ・ループ -人間が承認または拒否するまでエージェントの実行を一時停止するには、ヒューマン・イン・ザ・ループ (HITL) フローを使用します。ツールは承認が必要となる条件を宣言し、実行結果では保留中の承認が中断として提示されます。また、`RunState` を使用すると、決定後に実行をシリアライズして再開できます。 +ヒューマン・イン・ザ・ループ (HITL) フローを使用すると、機密性の高いツール呼び出しを人が承認または拒否するまで、エージェントの実行を一時停止できます。ツールは承認が必要なタイミングを宣言し、実行結果では保留中の承認が中断として提示されます。また、`RunState` を使用すると、一時停止した実行をシリアル化し、判断後に再開できます。 -この承認の適用範囲は実行全体であり、現在のトップレベルエージェントだけに限定されません。ツールが現在のエージェントに属する場合、ハンドオフ先のエージェントに属する場合、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行に属する場合でも、同じパターンが適用されます。ネストされた `Agent.as_tool()` の場合も、中断は外側の実行に提示されるため、外側の `RunState` で承認または拒否し、元のトップレベル実行を再開します。 +この承認機構は実行全体に適用され、現在の最上位エージェントだけに限定されません。ツールが現在のエージェントに属する場合、ハンドオフで到達したエージェントに属する場合、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行に属する場合にも、同じパターンが適用されます。ネストされた `Agent.as_tool()` の場合も、中断は外側の実行に提示されるため、外側の `RunState` で承認または拒否し、元の最上位の実行を再開します。 -`Agent.as_tool()` では、承認が 2 つの異なるレイヤーで発生する可能性があります。エージェントツール自体が `Agent.as_tool(..., needs_approval=...)` による承認を必要とする場合と、ネストされた実行の開始後に、ネストされたエージェント内のツールが独自の承認を要求する場合です。どちらも、外側の実行における同じ中断フローで処理されます。 +`Agent.as_tool()` では、承認が 2 つの異なるレイヤーで発生する場合があります。エージェントツール自体が `Agent.as_tool(..., needs_approval=...)` を介して承認を要求できるほか、ネストされた実行の開始後に、その内部のツールが独自の承認を要求することもできます。どちらも、外側の実行における同じ中断フローで処理されます。 -このページでは、`interruptions` を使用する手動承認フローに焦点を当てます。アプリケーションがコード内で判断できる場合、一部のツールタイプではプログラムによる承認コールバックもサポートされており、実行を一時停止せずに続行できます。 +このページでは、`interruptions` を介した手動承認フローを中心に説明します。アプリがコード内で判断できる場合、一部のツールタイプではプログラムによる承認コールバックもサポートされているため、実行を一時停止せずに続行できます。 ## 承認が必要なツールの指定 -常に承認を要求するには、`needs_approval` を `True` に設定します。または、呼び出しごとに判断する非同期関数を指定します。この呼び出し可能オブジェクトは、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 +常に承認を要求するには `needs_approval` を `True` に設定します。または、呼び出しごとに判断する非同期関数を指定します。この callable は、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 -SDK が引数を安全に検査できない場合、呼び出し可能な承認ルールは安全側に倒れ、承認を必須とします。引数が不正な JSON である場合、有効な JSON でもオブジェクトではない場合(たとえば、`null` やリスト)、または `NaN`、`Infinity`、`-Infinity` などの非標準定数が含まれる場合、呼び出し可能オブジェクトは実行されず、その呼び出しには手動承認が必要です。この動作は、Runner と Realtime のツール呼び出しで同じです。 +SDK が引数を安全に検査できない場合、callable の承認ルールは安全側に倒れ、承認が必須になります。引数が不正な JSON、正しい JSON でもオブジェクトではないもの(たとえば `null` やリスト)、または `NaN`、`Infinity`、`-Infinity` などの非標準定数を含む場合、callable は呼び出されず、その呼び出しには手動承認が必要です。この動作は、Runner と Realtime のツール呼び出しで共通です。 ```python from agents import Agent @@ -44,26 +44,26 @@ agent = Agent( ) ``` -`needs_approval` は、[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]、[`ApplyPatchTool`][agents.tool.ApplyPatchTool] で使用できます。ローカル MCP サーバーも、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] の `require_approval` を通じて承認をサポートします。ホスト型 MCP サーバーでは、[`HostedMCPTool`][agents.tool.HostedMCPTool] に `tool_config={"require_approval": "always"}` とオプションの `on_approval_request` コールバックを指定することで、承認をサポートします。中断を提示せずに自動承認または自動拒否する場合、Shell および apply_patch ツールは `on_approval` コールバックを受け取ります。 +`needs_approval` は、[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]、[`ApplyPatchTool`][agents.tool.ApplyPatchTool] で利用できます。ローカル MCP サーバーも、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] の `require_approval` を介した承認をサポートします。ホスト型 MCP サーバーでは、`tool_config={"require_approval": "always"}` とオプションの `on_approval_request` コールバックを指定した [`HostedMCPTool`][agents.tool.HostedMCPTool] を介して承認をサポートします。中断を提示せずに自動承認または自動拒否する場合、シェルツールおよび apply_patch ツールでは `on_approval` コールバックを使用できます。 ## 承認フローの仕組み -1. モデルがツール呼び出しを出力すると、ランナーはその承認ルール(`needs_approval`、`require_approval`、またはホスト型 MCP における同等の設定)を評価します。 -2. そのツール呼び出しに対する承認決定が [`RunContextWrapper`][agents.run_context.RunContextWrapper] にすでに保存されている場合、ランナーは確認を求めずに続行します。呼び出し単位の承認は、特定の呼び出し ID に限定されます。実行の残りの期間、そのツールに対する今後の呼び出しにも同じ決定を適用するには、`always_approve=True` または `always_reject=True` を渡します。 -3. それ以外の場合、実行は一時停止し、`RunResult.interruptions`(または `RunResultStreaming.interruptions`)には、`agent.name`、`tool_name`、`arguments` などの詳細を含む [`ToolApprovalItem`][agents.items.ToolApprovalItem] エントリが格納されます。これには、ハンドオフ後またはネストされた `Agent.as_tool()` の実行内で要求された承認も含まれます。 -4. `result.to_state()` を使用して実行結果を `RunState` に変換し、`state.approve(...)` または `state.reject(...)` を呼び出した後、`Runner.run(agent, state)` または `Runner.run_streamed(agent, state)` で再開します。ここで `agent` は、その実行における元のトップレベルエージェントです。 -5. 再開された実行は中断箇所から続行され、新たな承認が必要になった場合は、このフローに再度入ります。 +1. モデルがツール呼び出しを生成すると、ランナーはその承認ルール(`needs_approval`、`require_approval`、またはホスト型 MCP に相当するもの)を評価します。 +2. そのツール呼び出しに対する承認判断がすでに [`RunContextWrapper`][agents.run_context.RunContextWrapper] に保存されている場合、ランナーは確認を求めずに続行します。呼び出し単位の承認は特定の呼び出し ID に限定されます。実行の残りの期間、そのツールに対する今後の呼び出しにも同じ判断を保持するには、`always_approve=True` または `always_reject=True` を渡します。 +3. 承認ルールで承認が必要とされ、そのツール呼び出しに対する判断が保存されていない場合、実行は一時停止します。`RunResult.interruptions`(または `RunResultStreaming.interruptions`)には、`agent.name`、`tool_name`、`arguments` などの詳細を含む [`ToolApprovalItem`][agents.items.ToolApprovalItem] エントリが格納されます。これには、ハンドオフ後またはネストされた `Agent.as_tool()` の実行内で発生した承認も含まれます。 +4. `result.to_state()` を使用して実行結果を `RunState` に変換し、`state.approve(...)` または `state.reject(...)` を呼び出します。その後、`Runner.run(agent, state)` または `Runner.run_streamed(agent, state)` を使用して再開します。ここで、`agent` はその実行の元の最上位エージェントです。 +5. 再開した実行は中断箇所から続行し、新たな承認が必要になった場合はこのフローに再度入ります。 -`always_approve=True` または `always_reject=True` によって固定化された決定は実行状態に保存されるため、同じ一時停止中の実行を後で再開する際、`state.to_string()` / `RunState.from_string(...)` および `state.to_json()` / `RunState.from_json(...)` を使用しても保持されます。 +`always_approve=True` または `always_reject=True` で作成された固定判断は実行状態に保存されるため、同じ一時停止中の実行を後で再開するときに、`state.to_string()` / `RunState.from_string(...)` および `state.to_json()` / `RunState.from_json(...)` を経ても保持されます。 -1 回の処理ですべての保留中の承認を解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認が混在することがあります。一部の項目のみを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決の項目は `interruptions` に残り、実行は再び一時停止します。 +同じ処理回ですべての保留中の承認を解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認を混在させることができます。一部の項目だけを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決のものは `interruptions` に残り、実行は再び一時停止します。 ## カスタム拒否メッセージ -デフォルトでは、拒否されたツール呼び出しに対して、SDK の標準拒否テキストが実行に返されます。このメッセージは、次の 2 つのレイヤーでカスタマイズできます。 +デフォルトでは、拒否されたツール呼び出しについて、SDK の標準的な拒否テキストが実行内に返されます。このメッセージは 2 つのレイヤーでカスタマイズできます。 -- 実行全体のフォールバック:[`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter] を設定すると、実行全体における承認拒否について、モデルに表示されるデフォルトメッセージを制御できます。 -- 呼び出し単位のオーバーライド:特定の拒否されたツール呼び出しに別のメッセージを返す場合は、`state.reject(...)` に `rejection_message=...` を渡します。 +- 実行全体のフォールバック: [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter] を設定すると、実行全体にわたる承認拒否について、モデルに表示されるデフォルトメッセージを制御できます。 +- 呼び出し単位のオーバーライド: 特定の拒否されたツール呼び出しだけに異なるメッセージを提示する場合は、`state.reject(...)` に `rejection_message=...` を渡します。 両方が指定されている場合、呼び出し単位の `rejection_message` が実行全体のフォーマッターより優先されます。 @@ -88,25 +88,25 @@ state.reject( 両方のレイヤーを組み合わせた完全なコード例については、[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py) を参照してください。 -## 自動承認の決定 +## 承認判断の自動化 -手動の `interruptions` は最も一般的なパターンですが、唯一の方法ではありません。 +手動の `interruptions` は最も汎用的なパターンですが、唯一の方法ではありません。 -- ローカルの [`ShellTool`][agents.tool.ShellTool] と [`ApplyPatchTool`][agents.tool.ApplyPatchTool] では、`on_approval` を使用してコード内で即座に承認または拒否できます。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] では、`tool_config={"require_approval": "always"}` と `on_approval_request` を組み合わせて、同様にプログラムで決定できます。 -- 通常の [`function_tool`][agents.tool.function_tool] ツールと [`Agent.as_tool()`][agents.agent.Agent.as_tool] では、このページで説明する手動中断フローを使用します。 +- ローカルの [`ShellTool`][agents.tool.ShellTool] および [`ApplyPatchTool`][agents.tool.ApplyPatchTool] では、`on_approval` を使用してコード内で即座に承認または拒否できます。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] では、`tool_config={"require_approval": "always"}` と `on_approval_request` を組み合わせて、同様にプログラムによる判断を行えます。 +- 通常の [`function_tool`][agents.tool.function_tool] ツールおよび [`Agent.as_tool()`][agents.agent.Agent.as_tool] では、このページで説明する手動中断フローを使用します。 -これらのコールバックが決定を返すと、人間の応答を待って一時停止することなく実行が続行されます。Realtime および音声セッション API については、[Realtime ガイド](realtime/guide.md)の承認フローを参照してください。 +これらのコールバックが判断を返すと、人の応答を待つために一時停止することなく実行が続行されます。Realtime および音声セッション API については、[Realtime ガイド](realtime/guide.md)の承認フローを参照してください。 ## ストリーミングとセッション -同じ中断フローは、ストリーミング実行でも機能します。ストリーミング実行が一時停止した後、イテレーターが完了するまで [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] を消費し続け、[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] を確認して各項目を解決します。再開後の出力も引き続きストリーミングする場合は、[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] で再開します。このパターンのストリーミング版については、[ストリーミング](streaming.md)を参照してください。 +同じ中断フローは、ストリーミング実行でも機能します。ストリーミング実行が一時停止したら、イテレーターが終了するまで [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を続け、[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] を確認して解決します。再開後の出力でもストリーミングを継続する場合は、[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] を使用して再開します。このパターンのストリーミング版については、[ストリーミング](streaming.md)を参照してください。 -セッションも使用している場合は、`RunState` から再開するときに同じセッションインスタンスを渡し続けるか、同じバックエンドストアを参照する別のセッションオブジェクトを渡します。これにより、再開後のターンが、保存済みの同じ会話履歴に追加されます。セッションのライフサイクルの詳細については、[セッション](sessions/index.md)を参照してください。 +セッションも使用している場合は、`RunState` から再開するときに同じセッションインスタンスを渡し続けるか、同じセッション ID とバッキングストアを使用するように構成された別のセッションオブジェクトを渡します。再開されたターンは、同じ保存済み会話履歴に追加されます。セッションのライフサイクルの詳細については、[セッション](sessions/index.md)を参照してください。 ## 一時停止、承認、再開の例 -以下のスニペットは、JavaScript の HITL ガイドと同じ流れを示しています。ツールに承認が必要な場合に一時停止し、状態をディスクに永続化して再読み込みし、決定を取得した後に再開します。 +以下のスニペットは JavaScript の HITL ガイドと同じ流れです。ツールに承認が必要な場合に一時停止し、状態をディスクに保存して再読み込みし、判断を取得した後に再開します。 ```python import asyncio @@ -171,35 +171,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -この例では、`prompt_approval` は `input()` を使用し、`run_in_executor(...)` で実行されるため、同期関数です。承認元がすでに非同期である場合(たとえば、HTTP リクエストや非同期データベースクエリ)、`async def` 関数を使用し、直接 `await` できます。 +このコード例では、`prompt_approval` は `input()` を使用し、`run_in_executor(...)` で実行されるため同期関数です。承認元がすでに非同期の場合(たとえば、HTTP リクエストや非同期データベースクエリ)は、`async def` 関数を使用し、`await` で直接待機できます。 -承認を待機しながら出力をストリーミングするには、`Runner.run_streamed` を呼び出し、完了するまで `result.stream_events()` を消費した後、上記と同じ `result.to_state()` および再開の手順に従います。 +承認のために一時停止する可能性がある実行でストリーミングを使用するには、`Runner.run_streamed` を呼び出し、完了するまで `result.stream_events()` を消費した後、上記と同じ `result.to_state()` および再開手順に従います。 ## リポジトリのパターンとコード例 -- **ストリーミング承認**: `examples/agent_patterns/human_in_the_loop_stream.py` は、`stream_events()` を最後まで消費し、保留中のツール呼び出しを承認してから、`Runner.run_streamed(agent, state)` で再開する方法を示します。 +- **ストリーミング承認**: `examples/agent_patterns/human_in_the_loop_stream.py` は、`stream_events()` を最後まで消費し、保留中のツール呼び出しを承認してから `Runner.run_streamed(agent, state)` で再開する方法を示します。 - **カスタム拒否テキスト**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` は、承認が拒否された場合に、実行レベルの `tool_error_formatter` と呼び出し単位の `rejection_message` オーバーライドを組み合わせる方法を示します。 -- **エージェントツールの承認**: `Agent.as_tool(..., needs_approval=...)` は、委任されたエージェントタスクにレビューが必要な場合も、同じ中断フローを適用します。ネストされた中断も外側の実行に提示されるため、ネストされたエージェントではなく、元のトップレベルエージェントを再開してください。 -- **ローカルの Shell および apply_patch ツール**: `ShellTool` と `ApplyPatchTool` も `needs_approval` をサポートします。今後の呼び出しに対する決定をキャッシュするには、`state.approve(interruption, always_approve=True)` または `state.reject(..., always_reject=True)` を使用します。自動決定には `on_approval` を指定し(`examples/tools/shell.py` を参照)、手動決定には中断を処理します(`examples/tools/shell_human_in_the_loop.py` を参照)。ホスト型 Shell 環境は `needs_approval` または `on_approval` をサポートしていません。[ツールガイド](tools.md)を参照してください。 -- **ローカル MCP サーバー**: `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` の `require_approval` を使用して、MCP ツール呼び出しを承認対象として制御します(`examples/mcp/get_all_mcp_tools_example/main.py` および `examples/mcp/tool_filter_example/main.py` を参照)。 -- **ホスト型 MCP サーバー**: HITL を強制するには、`HostedMCPTool` の `require_approval` を `"always"` に設定します。必要に応じて、自動承認または自動拒否のために `on_approval_request` を指定できます(`examples/hosted_mcp/human_in_the_loop.py` および `examples/hosted_mcp/on_approval.py` を参照)。信頼できるサーバーには `"never"` を使用します(`examples/hosted_mcp/simple.py`)。 -- **セッションとメモリ**: 承認と会話履歴を複数のターンにわたって保持するには、`Runner.run` にセッションを渡します。SQLite および OpenAI Conversations のセッション版は、`examples/memory/memory_session_hitl_example.py` と `examples/memory/openai_session_hitl_example.py` にあります。 -- **Realtime エージェント**: Realtime デモでは、`RealtimeSession` の `approve_tool_call` / `reject_tool_call` を介してツール呼び出しを承認または拒否する WebSocket メッセージを公開しています(サーバー側のハンドラーについては `examples/realtime/app/server.py`、API の仕様については [Realtime ガイド](realtime/guide.md#tool-approvals)を参照)。 +- **エージェントをツールとして使用する場合の承認**: `Agent.as_tool(..., needs_approval=...)` は、委任されたエージェントタスクにレビューが必要な場合に、同じ中断フローを適用します。ネストされた中断も外側の実行に提示されるため、ネストされたエージェントではなく、元の最上位エージェントを再開します。 +- **ローカルのシェルツールと apply_patch ツール**: `ShellTool` と `ApplyPatchTool` も `needs_approval` をサポートします。実行の残りの期間、そのツールに対する今後の呼び出しに判断をキャッシュするには、`state.approve(interruption, always_approve=True)` または `state.reject(..., always_reject=True)` を使用します。自動判断には `on_approval` を指定します(`examples/tools/shell.py` を参照)。手動判断では中断を処理します(`examples/tools/shell_human_in_the_loop.py` を参照)。ホスト型シェル環境は `needs_approval` または `on_approval` をサポートしていません。[ツールガイド](tools.md)を参照してください。 +- **ローカル MCP サーバー**: MCP ツール呼び出しを制御するには、`MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` で `require_approval` を使用します(`examples/mcp/get_all_mcp_tools_example/main.py` および `examples/mcp/tool_filter_example/main.py` を参照)。 +- **ホスト型 MCP サーバー**: HITL を強制するには、`HostedMCPTool` で `tool_config={"require_approval": "always"}` を設定し、必要に応じて自動承認または自動拒否を行う `on_approval_request` を指定します(`examples/hosted_mcp/human_in_the_loop.py` および `examples/hosted_mcp/on_approval.py` を参照)。信頼できるサーバーには `"never"` を使用します(`examples/hosted_mcp/simple.py`)。 +- **セッションとメモリ**: `Runner.run` にセッションを渡すと、承認と会話履歴が複数のターンにわたって保持されます。SQLite および OpenAI Conversations のセッションバリアントは、`examples/memory/memory_session_hitl_example.py` と `examples/memory/openai_session_hitl_example.py` にあります。 +- **Realtime エージェント**: Realtime デモでは、`RealtimeSession` 上の `approve_tool_call` / `reject_tool_call` を介してツール呼び出しを承認または拒否する WebSocket メッセージを公開しています(サーバー側のハンドラーについては `examples/realtime/app/server.py`、API のインターフェースについては [Realtime ガイド](realtime/guide.md#tool-approvals)を参照)。 ## 長時間にわたる承認 -`RunState` は、永続的に使用できるよう設計されています。`state.to_json()` または `state.to_string()` を使用して保留中の作業をデータベースやキューに保存し、後から `RunState.from_json(...)` または `RunState.from_string(...)` で復元できます。 +`RunState` は永続性を考慮して設計されています。`state.to_json()` または `state.to_string()` を使用して保留中の作業をデータベースやキューに保存し、後で `RunState.from_json(...)` または `RunState.from_string(...)` を使用して再作成します。 -便利なシリアライズオプションは次のとおりです。 +便利なシリアル化オプションは次のとおりです。 -- `context_serializer`: マッピングではないコンテキストオブジェクトのシリアライズ方法をカスタマイズします。 +- `context_serializer`: マッピングではないコンテキストオブジェクトのシリアル化方法をカスタマイズします。 - `context_deserializer`: `RunState.from_json(...)` または `RunState.from_string(...)` で状態を読み込む際に、マッピングではないコンテキストオブジェクトを再構築します。 -- `strict_context=True`: コンテキストがすでにマッピングであるか、適切なシリアライザーまたはデシリアライザーが指定されていない限り、シリアライズまたはデシリアライズを失敗させます。 -- `context_override`: 状態の読み込み時に、シリアライズされたコンテキストを置き換えます。元のコンテキストオブジェクトを復元したくない場合に便利ですが、すでにシリアライズ済みのペイロードからそのコンテキストを削除するものではありません。 -- `include_tracing_api_key=True`: 再開した作業で同じ認証情報を使用してトレースのエクスポートを継続する必要がある場合、シリアライズされたトレースペイロードにトレーシング API キーを含めます。 +- `strict_context=True`: コンテキストがすでにマッピングであるか、`context_serializer` を指定していない限り、シリアル化を失敗させます。また、コンテキストがすでにマッピングであるか、`context_deserializer` を指定していない限り、デシリアル化を失敗させます。 +- `context_override`: 状態の読み込み時に、シリアル化されたコンテキストを置き換えます。元のコンテキストオブジェクトを復元したくない場合に便利ですが、すでにシリアル化されたペイロードからそのコンテキストが削除されるわけではありません。 +- `include_tracing_api_key=True`: 再開された作業で同じ認証情報を使用してトレースをエクスポートし続ける必要がある場合、シリアル化されたトレースペイロードにトレーシング API キーを含めます。 -シリアライズされた実行状態には、アプリケーションのコンテキストに加え、承認、使用量、シリアライズされた `tool_input`、ネストされたエージェントツール実行の再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK が管理するランタイムメタデータが含まれます。シリアライズされた状態を保存または送信する場合は、`RunContextWrapper.context` を永続化対象データとして扱い、状態とともに意図的に保持または送信したい場合を除き、そこに機密情報を保存しないでください。 +シリアル化された実行状態には、アプリのコンテキストに加え、承認、使用量、シリアル化された `tool_input`、ネストされたエージェントをツールとして使用する実行の再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK が管理するランタイムメタデータが含まれます。シリアル化された状態を保存または送信する場合、`RunContextWrapper.context` を永続化データとして扱い、意図的に状態とともに移動させる場合を除き、そこにシークレットを格納しないでください。 -## 保留中タスクのバージョン管理 +## 保留タスクのバージョニング -承認が長期間保留される可能性がある場合は、シリアライズされた状態とともに、エージェント定義または SDK のバージョンマーカーを保存してください。これにより、モデル、プロンプト、ツール定義が変更された場合でも、デシリアライズ処理を対応するコードパスに振り分け、非互換性を回避できます。 \ No newline at end of file +承認が長期間保留される可能性がある場合は、エージェント定義または SDK のバージョンマーカーをシリアル化された状態とともに保存します。これにより、デシリアル化を対応するコードパスに振り分け、モデル、プロンプト、ツール定義が変更された際の非互換性を回避できます。 \ No newline at end of file diff --git a/docs/ja/index.md b/docs/ja/index.md index 048e36fddc..1f8824479f 100644 --- a/docs/ja/index.md +++ b/docs/ja/index.md @@ -4,52 +4,52 @@ search: --- # OpenAI Agents SDK -[OpenAI Agents SDK](https://github.com/openai/openai-agents-python) を使用すると、抽象化を最小限に抑えた軽量で使いやすいパッケージで、エージェント型 AI アプリを構築できます。これは、以前のエージェント向け実験プロジェクトである [Swarm](https://github.com/openai/swarm/tree/main) を本番環境向けに進化させたものです。Agents SDK は、ごく少数の基本コンポーネントで構成されています。 +[OpenAI Agents SDK](https://github.com/openai/openai-agents-python) を使用すると、抽象化を最小限に抑えた軽量で使いやすいパッケージで、エージェント型 AI アプリを構築できます。これは、以前のエージェント向け実験プロジェクトである [Swarm](https://github.com/openai/swarm/tree/main) を本番環境向けにアップグレードしたものです。Agents SDK は、非常に少数の基本コンポーネントで構成されています。 - **エージェント**: 指示とツールを備えた LLM -- **Agents as tools / ハンドオフ**: エージェントが特定のタスクをほかのエージェントに委任できる仕組み -- **ガードレール**: エージェントの入力と出力を検証する仕組み +- **Agents as tools / ハンドオフ**: エージェントが特定のタスクを別のエージェントに委任できる仕組み +- **ガードレール**: エージェントの入力と出力を検証できる仕組み -Python と組み合わせることで、これらの基本コンポーネントは、ツールとエージェントの複雑な関係を表現するのに十分な能力を発揮し、学習負担を抑えながら実用的なアプリケーションを構築できます。さらに SDK には、エージェントフローの可視化とデバッグに加え、評価やアプリケーション向けのモデルのファインチューニングまで可能にする組み込みの **トレーシング** が用意されています。 +これらの基本コンポーネントを Python と組み合わせることで、ツールとエージェント間の複雑な関係を表現し、学習コストを抑えながら実用的なアプリケーションを構築できます。さらに、SDK には組み込みの **トレーシング** が含まれており、エージェント型フローの可視化とデバッグに加え、評価やアプリケーション向けモデルのファインチューニングも行えます。 ## Agents SDK を使用する理由 -SDK は、次の 2 つの設計原則に基づいています。 +SDK には、設計を支える 2 つの原則があります。 -1. 利用する価値がある十分な機能を備えつつ、すぐに習得できるよう基本コンポーネントを少数に絞ること。 -2. そのままでも優れた動作を提供しながら、処理内容を必要に応じて細かくカスタマイズできること。 +1. 使用する価値があるだけの機能を備えつつ、すぐに習得できるよう基本コンポーネントを十分に少なくすること。 +2. 初期設定のままでも適切に動作しながら、実際の処理を詳細にカスタマイズできること。 SDK の主な機能は次のとおりです。 -- **エージェント**: 指示、ツール、ガードレール、ハンドオフ、およびタスクが完了するまで継続する組み込みループを使用してエージェントを構築できます。 -- **サンドボックスエージェント**: マニフェストで定義されたファイル、選択可能なサンドボックスクライアント、再開可能なサンドボックスセッションを備えた、実際に隔離されたワークスペース内で専門エージェントを実行できます。 -- **リアルタイムエージェント**: `gpt-realtime-2.1`、自動中断検出、コンテキスト管理、ガードレールなどを使用して、強力な音声エージェントを構築できます。 +- **エージェント**: 指示、ツール、ガードレール、ハンドオフ、およびタスクが完了するまで継続する組み込みループを備えたエージェントを構築できます。 +- **サンドボックスエージェント**: 実際の隔離されたワークスペース内で専門エージェントを実行できます。サンドボックスエージェントは、マニフェストで定義されたファイル、サンドボックスクライアントの選択、再開可能なサンドボックスセッションをサポートします。 +- **Realtime エージェント**: `gpt-realtime-2.1`、自動中断検出、コンテキスト管理、ガードレールなどを使用して、強力な音声エージェントを構築できます。 - **音声エージェント**: 音声テキスト変換、エージェントワークフロー、テキスト音声変換を組み合わせた音声パイプラインを構築できます。 -- **Python ファースト**: 新しい抽象化を習得する代わりに、組み込みの言語機能を使用してエージェントオーケストレーションとエージェントの連携を実現できます。 +- **Python ファースト**: 新しい抽象化を学ぶ必要はなく、組み込みの言語機能を使用してエージェントをオーケストレーションし、連鎖させることができます。 - **Agents as tools / ハンドオフ**: 複数のエージェント間で作業を調整し、委任するための強力な仕組みです。 -- **ガードレール**: エージェントの実行と並行して入力検証と安全性チェックを行い、チェックに合格しなかった場合は即座に失敗させます。 -- **関数ツール**: 自動スキーマ生成と Pydantic による検証を使用して、任意の Python 関数をツールに変換できます。 -- **MCP サーバーツール呼び出し**: 関数ツールと同じ方法で動作する、組み込みの MCP サーバーツール統合です。 +- **ガードレール**: エージェントの実行と並行して入力検証と安全性チェックを実行し、チェックに合格しない場合は即座に失敗させます。 +- **関数ツール**: スキーマの自動生成と Pydantic を利用した検証により、任意の Python 関数をツールに変換できます。 +- **MCP サーバーツール呼び出し**: リモートの MCP ツールを関数ツールとともにエージェントへ公開するための組み込み統合です。 - **セッション**: エージェントループ内で作業コンテキストを維持するための永続的なメモリレイヤーです。 -- **ヒューマンインザループ**: エージェントの実行に人間が関与するための組み込みの仕組みです。 -- **トレーシング**: ワークフローを可視化、デバッグ、監視するための組み込みのトレーシングです。OpenAI の評価、ファインチューニング、蒸留ツールスイートにも対応しています。 +- **Human in the loop**: エージェントの実行中に人間を関与させるための組み込みの仕組みです。 +- **トレーシング**: ワークフローを可視化、デバッグ、監視するための組み込みのトレーシングです。OpenAI の評価、ファインチューニング、蒸留ツール群をサポートしています。 ## Agents SDK と Responses API の選択 -SDK は OpenAI モデルに対してデフォルトで Responses API を使用しますが、モデル呼び出しを囲む、より高レベルのランタイムも提供します。 +SDK は、OpenAI モデルに対してデフォルトで Responses API を使用しますが、モデル呼び出しをより高レベルのランタイムでラップします。 次の場合は、Responses API を直接使用します。 - ループ、ツールのディスパッチ、状態管理を自分で制御したい場合 -- ワークフローが短時間で完了し、主な目的がモデルの応答を返すことである場合 +- ワークフローの実行時間が短く、主な目的がモデルの応答を返すことである場合 次の場合は、Agents SDK を使用します。 - ターン、ツール実行、ガードレール、ハンドオフ、またはセッションをランタイムに管理させたい場合 -- エージェントが成果物を生成する、または連携された複数のステップにわたって動作する必要がある場合 -- [サンドボックスエージェント](sandbox_agents.md)を通じて、実際のワークスペースや再開可能な実行が必要な場合 +- エージェントが成果物を生成する場合や、連携された複数のステップにわたって動作する必要がある場合 +- [サンドボックスエージェント](sandbox_agents.md)を通じて、実際のワークスペースまたは再開可能な実行が必要な場合 -アプリケーション全体でどちらか一方を選択する必要はありません。多くのアプリケーションでは、管理されたワークフローに SDK を使用し、より低レベルの処理では Responses API を直接呼び出します。 +アプリケーション全体で、どちらか一方だけを選択する必要はありません。多くのアプリケーションでは、管理されたワークフローに SDK を使用し、より低レベルの処理では Responses API を直接呼び出します。 ## インストール @@ -72,7 +72,7 @@ print(result.final_output) # Infinite loop's dance. ``` -(_これを実行する場合は、`OPENAI_API_KEY` 環境変数が設定されていることを確認してください_) +(_これを実行する場合は、環境変数 `OPENAI_API_KEY` を設定してください_) ```bash export OPENAI_API_KEY=sk-... @@ -81,22 +81,22 @@ export OPENAI_API_KEY=sk-... ## はじめに - [クイックスタート](quickstart.md)で、最初のテキストベースのエージェントを構築します。 -- 次に、[エージェントの実行](running_agents.md#choose-a-memory-strategy)で、ターン間の状態を維持する方法を決定します。 +- 次に、[エージェントの実行](running_agents.md#choose-a-memory-strategy)で、ターン間で状態を引き継ぐ方法を決定します。 - タスクが実際のファイル、リポジトリ、またはエージェントごとに隔離されたワークスペースの状態に依存する場合は、[サンドボックスエージェントのクイックスタート](sandbox_agents.md)を参照してください。 -- ハンドオフとマネージャー型オーケストレーションのどちらを使用するか検討している場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。 +- ハンドオフとマネージャー型オーケストレーションのどちらを使用するか決める場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。 ## 目的別ガイド -実行したい処理は決まっていても、どのページで説明されているか分からない場合は、次の表を使用してください。 +実行したい作業は決まっていても、説明がどのページにあるか分からない場合は、次の表を使用してください。 -| 目的 | 参照先 | +| 目的 | 最初に参照するページ | | --- | --- | -| 最初のテキストエージェントを構築し、一連の完全な実行を確認する | [クイックスタート](quickstart.md) | -| 関数ツール、ホスト型ツール、または agents as tools を追加する | [ツール](tools.md) | -| 実際に隔離されたワークスペース内で、コーディング、レビュー、またはドキュメント処理を行うエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md)および[サンドボックスクライアント](sandbox/clients.md) | +| 最初のテキストエージェントを構築し、一連の実行全体を確認する | [クイックスタート](quickstart.md) | +| 関数ツール、ホスト型ツール、または Agents as tools を追加する | [ツール](tools.md) | +| 実際の隔離されたワークスペース内で、コーディング、レビュー、またはドキュメント処理を行うエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md)と[サンドボックスクライアント](sandbox/clients.md) | | ハンドオフとマネージャー型オーケストレーションのどちらを使用するか決定する | [エージェントオーケストレーション](multi_agent.md) | -| ターン間でメモリを維持する | [エージェントの実行](running_agents.md#choose-a-memory-strategy)および[セッション](sessions/index.md) | +| ターン間でメモリを保持する | [エージェントの実行](running_agents.md#choose-a-memory-strategy)と[セッション](sessions/index.md) | | OpenAI モデル、WebSocket トランスポート、または OpenAI 以外のプロバイダーを使用する | [モデル](models/index.md) | | 出力、実行項目、中断、再開状態を確認する | [実行結果](results.md) | -| `gpt-realtime-2.1` を使用して低レイテンシーの音声エージェントを構築する | [リアルタイムエージェントのクイックスタート](realtime/quickstart.md)および[リアルタイムトランスポート](realtime/transport.md) | -| 音声テキスト変換 / エージェント / テキスト音声変換のパイプラインを構築する | [音声パイプラインのクイックスタート](voice/quickstart.md) | \ No newline at end of file +| `gpt-realtime-2.1` を使用して低レイテンシーの音声エージェントを構築する | [Realtime エージェントのクイックスタート](realtime/quickstart.md)と[Realtime トランスポート](realtime/transport.md) | +| 音声テキスト変換、エージェント、テキスト音声変換を組み合わせたパイプラインを構築する | [音声パイプラインのクイックスタート](voice/quickstart.md) | \ No newline at end of file diff --git a/docs/ja/mcp.md b/docs/ja/mcp.md index 0f9c211ce4..d8a228a669 100644 --- a/docs/ja/mcp.md +++ b/docs/ja/mcp.md @@ -7,27 +7,27 @@ search: [Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)は、アプリケーションがツールとコンテキストを言語モデルに公開する方法を標準化します。公式ドキュメントからの引用です。 > MCP は、アプリケーションが LLM にコンテキストを提供する方法を標準化するオープンプロトコルです。MCP は、AI -> アプリケーション向けの USB-C ポートのようなものと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続するための標準化された方法を提供するのと同様に、MCP +> アプリケーションにおける USB-C ポートのようなものだと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続するための標準化された方法を提供するのと同様に、MCP > は AI モデルをさまざまなデータソースやツールに接続するための標準化された方法を提供します。 -Agents Python SDK は複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、独自のサーバーを構築して、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開したりできます。 +Agents Python SDK は複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開する独自のサーバーを構築したりできます。 !!! warning "接続前の MCP サーバーの信頼性確認" - MCP ツールは、モデルコンテキストのデータを公開し、提供された認証情報を使用してアクションを実行できます。信頼できるサーバーにのみ接続し、最小権限の認証情報を使用し、アクセストークンは URL ではなく認証フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)を参照してください。 + MCP ツールはモデルコンテキストのデータを公開し、指定された認証情報を使用して操作を実行できます。信頼できるサーバーにのみ接続し、最小権限の認証情報を使用してください。また、アクセストークンは URL ではなく認可フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)を参照してください。 ## MCP 統合の選択 -MCP サーバーをエージェントに接続する前に、ツール呼び出しをどこで実行するか、またどのトランスポートにアクセスできるかを決定してください。以下の表は、Python SDK がサポートする選択肢をまとめたものです。 +MCP サーバーをエージェントに接続する前に、ツール呼び出しを実行する場所と、利用可能なトランスポートを決定します。以下の表は、Python SDK がサポートする選択肢をまとめたものです。 | 必要なこと | 推奨オプション | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI の Responses API がモデルに代わって、公開アクセス可能な MCP サーバーを呼び出す| [`HostedMCPTool`][agents.tool.HostedMCPTool] を使用する **ホスト型 MCP サーバーツール** | +| OpenAI の Responses API がモデルに代わって、パブリックにアクセス可能な MCP サーバーを呼び出す| [`HostedMCPTool`][agents.tool.HostedMCPTool] を使用する **ホステッド MCP サーバーツール** | | ローカルまたはリモートで実行する Streamable HTTP サーバーに接続する | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用する **Streamable HTTP MCP サーバー** | -| Server-Sent Events 対応 HTTP を実装するサーバーと通信する | [`MCPServerSse`][agents.mcp.server.MCPServerSse] を使用する **SSE 対応 HTTP MCP サーバー** | -| ローカルプロセスを起動し、stdin/stdout 経由で通信する | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用する **stdio MCP サーバー** | +| Server-Sent Events を使用する HTTP を実装したサーバーと通信する | [`MCPServerSse`][agents.mcp.server.MCPServerSse] を使用する **SSE 対応 HTTP MCP サーバー** | +| ローカルプロセスを起動し、stdin/stdout を介して通信する | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用する **stdio MCP サーバー** | -以下のセクションでは、各オプション、その設定方法、および各トランスポートを選ぶべき状況について説明します。 +以下のセクションでは、各オプション、その設定方法、および各トランスポートを選択すべき状況について説明します。 ## エージェントレベルの MCP 設定 @@ -51,32 +51,32 @@ agent = Agent( ) ``` -注意事項: +注記: -- `convert_schemas_to_strict` はベストエフォート方式です。スキーマを変換できない場合は、元のスキーマが使用されます。 +- `convert_schemas_to_strict` はベストエフォートです。スキーマを変換できない場合は、元のスキーマが使用されます。 - `failure_error_function` は、MCP ツール呼び出しの失敗をモデルにどのように提示するかを制御します。 - `failure_error_function` が未設定の場合、SDK はデフォルトのツールエラーフォーマッターを使用します。 -- サーバーレベルの `failure_error_function` は、そのサーバーに対する `Agent.mcp_config["failure_error_function"]` を上書きします。 -- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバー接頭辞付きの名前でモデルに公開されます。これにより、複数の MCP サーバーが同名のツールを公開する場合の名前の衝突を回避できます。生成される名前は ASCII セーフで、関数ツール名の長さ制限内に収まり、同じエージェント上にある既存のローカル関数ツール名や有効なハンドオフ名との衝突も回避します。SDK は引き続き、元のサーバー上で元の MCP ツール名を使用して呼び出します。 +- サーバーレベルの `failure_error_function` は、そのサーバーについて `Agent.mcp_config["failure_error_function"]` を上書きします。 +- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバープレフィックス付きの名前でモデルに公開されます。これにより、複数の MCP サーバーが同じ名前のツールを公開する場合の衝突を回避しやすくなります。生成される名前は ASCII で安全に使用でき、`FunctionTool` インスタンスの名前の長さ制限内に収まり、ローカルの `FunctionTool` インスタンスに設定された名前や、同じエージェントで有効になっているハンドオフとは衝突しません。SDK は引き続き、元のサーバー上で元の MCP ツール名を呼び出します。 ## トランスポート間で共通するパターン -トランスポートを選択した後、ほとんどの統合では、次の事項も決定する必要があります。 +トランスポートを選択した後、ほとんどの統合では次の事項も決定する必要があります。 -- ツールの一部のみを公開する方法([ツールのフィルタリング](#tool-filtering))。 +- ツールの一部のみを公開する方法([ツールフィルタリング](#tool-filtering))。 - サーバーが再利用可能なプロンプトも提供するかどうか([プロンプト](#prompts))。 - `list_tools()` をキャッシュするかどうか([キャッシュ](#caching))。 - MCP のアクティビティをトレースにどのように表示するか([トレーシング](#tracing))。 -ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP のセクションに最も完全なコード例を示していますが、同じパターンを他のローカルトランスポートにも適用できます。 +ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通する概念です。Streamable HTTP のセクションでは最も完全な例を示しています。同じパターンは、ほかのローカルトランスポートにも適用できます。 -## 1. ホスト型 MCP サーバーツール +## 1. ホステッド MCP サーバーツール -ホスト型ツールでは、ツール呼び出しの往復処理全体が OpenAI のインフラストラクチャ内で実行されます。コード側でツールを一覧取得して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベル(および任意のコネクターメタデータ)を Responses API に転送します。モデルは、Python プロセスへの追加のコールバックなしで、リモートサーバーのツールを一覧取得して呼び出します。現在、ホスト型ツールは、Responses API のホスト型 MCP 統合をサポートする OpenAI モデルで動作します。 +ホステッドツールでは、ツールとの一連のやり取り全体が OpenAI のインフラストラクチャ内で実行されます。コードでツールを一覧取得して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベルとオプションのコネクターメタデータを Responses API に転送します。モデルはリモートサーバーのツールを一覧取得し、Python プロセスへの追加のコールバックなしで呼び出します。現在、ホステッドツールは、Responses API のホステッド MCP 統合をサポートする OpenAI モデルで使用できます。 -### 基本的なホスト型 MCP ツール +### 基本的なホステッド MCP ツール -エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加して、ホスト型ツールを作成します。`tool_config` +エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加して、ホステッドツールを作成します。`tool_config` 辞書は、REST API に送信する JSON と同じ構造です。 ```python @@ -109,14 +109,13 @@ async def main() -> None: asyncio.run(main()) ``` -ホスト型サーバーはツールを自動的に公開するため、`mcp_servers` に追加する必要はありません。 +ホステッドサーバーはツールを自動的に公開するため、`mcp_servers` に追加する必要はありません。 -ホスト型ツール検索でホスト型 MCP サーバーを遅延読み込みする場合は、`tool_config["defer_loading"] = True` を設定し、[`ToolSearchTool`][agents.tool.ToolSearchTool] をエージェントに追加します。これは OpenAI Responses モデルでのみサポートされます。ツール検索の完全な設定と制約については、[ツール](tools.md#hosted-tool-search)を参照してください。 +ホステッドツール検索でホステッド MCP サーバーを遅延読み込みする場合は、`tool_config["defer_loading"] = True` を設定し、[`ToolSearchTool`][agents.tool.ToolSearchTool] をエージェントに追加します。これは OpenAI Responses モデルでのみサポートされます。ツール検索の完全な設定と制約については、[ツール](tools.md#hosted-tool-search)を参照してください。 -### ホスト型 MCP 実行結果のストリーミング +### ホステッド MCP 結果のストリーミング -ホスト型ツールは、関数ツールとまったく同じ方法で実行結果のストリーミングをサポートします。モデルがまだ処理中でも、`Runner.run_streamed` を使用して -増分 MCP 出力を受け取れます。 +ホステッドツールは、関数ツールとまったく同じ方法で実行結果のストリーミングをサポートします。モデルの処理中に増分 MCP 出力を受け取るには、`Runner.run_streamed` を使用します。 ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -126,9 +125,9 @@ async for event in result.stream_events(): print(result.final_output) ``` -### 任意の承認フロー +### オプションの承認フロー -サーバーが機密性の高い操作を実行できる場合、ツールを実行するたびに、人またはプログラムによる承認を必須にできます。`tool_config` の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名をポリシーに対応付ける辞書を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 +サーバーが機密性の高い操作を実行できる場合は、各ツールの実行前に人間またはプログラムによる承認を必須にできます。`tool_config` 内の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名をポリシーにマッピングする辞書を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -158,9 +157,9 @@ agent = Agent( コールバックは同期または非同期にでき、モデルが実行を継続するために承認データを必要とするたびに呼び出されます。 -### コネクターを基盤とするホスト型サーバー +### コネクターを基盤とするホステッドサーバー -ホスト型 MCP は OpenAI コネクターもサポートします。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 +ホステッド MCP は OpenAI コネクターにも対応しています。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホステッドサーバーがコネクターのツールを公開します。 ```python import os @@ -176,11 +175,11 @@ HostedMCPTool( ) ``` -ストリーミング、承認、コネクターを含む、完全に動作するホスト型ツールのサンプルは、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) にあります。 +ストリーミング、承認、コネクターを含む完全に動作するホステッドツールのサンプルは、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)にあります。 ## 2. Streamable HTTP MCP サーバー -ネットワーク接続を自分で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを制御する場合や、低レイテンシーを維持しながら独自のインフラストラクチャ内でサーバーを実行する場合に適しています。 +ネットワーク接続を自分で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。トランスポートを管理する場合や、低レイテンシーを維持しながら独自のインフラストラクチャ内でサーバーを実行する場合には、Streamable HTTP サーバーが最適です。 ```python import asyncio @@ -215,26 +214,26 @@ async def main() -> None: asyncio.run(main()) ``` -コンストラクターでは、追加のオプションを指定できます。 +コンストラクターでは、次の追加オプションを使用できます。 -- `client_session_timeout_seconds` は、MCP ClientSession の読み取りタイムアウトを制御します。`datetime.timedelta` で表現可能かつ 1 マイクロ秒以上の正の有限値を指定すると有限のタイムアウトが設定され、`None` と `0` を指定すると無効になります。それ以外の値は、サーバーの構築時に拒否されます。 +- `client_session_timeout_seconds` は MCP ClientSession の読み取りタイムアウトを制御します。`datetime.timedelta` で表現できる 1 マイクロ秒以上の正の有限値を指定すると、有限のタイムアウトが設定されます。`None` と `0` を指定すると無効になります。それ以外の値は、サーバーの構築時に拒否されます。 - `use_structured_content` は、テキスト出力より `tool_result.structured_content` を優先するかどうかを切り替えます。 - `max_retry_attempts` と `retry_backoff_seconds_base` は、`list_tools()` と `call_tool()` に自動再試行を追加します。 -- `tool_filter` を使用すると、ツールの一部のみを公開できます([ツールのフィルタリング](#tool-filtering)を参照)。 -- `require_approval` は、ローカル MCP ツールで人間参加型の承認ポリシーを有効にします。 -- `failure_error_function` は、モデルに表示される MCP ツール失敗メッセージをカスタマイズします。代わりにエラーを送出するには、`None` に設定します。 +- `tool_filter` を使用すると、ツールの一部のみを公開できます([ツールフィルタリング](#tool-filtering)を参照)。 +- `require_approval` は、ローカル MCP ツールに対する人間参加型の承認ポリシーを有効にします。 +- `failure_error_function` は、モデルに表示される MCP ツールの失敗メッセージをカスタマイズします。代わりにエラーを発生させるには、`None` に設定します。 - `tool_meta_resolver` は、`call_tool()` の前に、呼び出しごとの MCP `_meta` ペイロードを挿入します。 ### ローカル MCP サーバーの承認ポリシー -`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` は、いずれも `require_approval` を受け付けます。 +`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` は、いずれも `require_approval` を受け取ります。 -サポートされる形式: +サポートされる形式: - すべてのツールに対する `"always"` または `"never"`。 -- `True` / `False`(常に承認する/承認しないのと同等)。 -- ツールごとのマップ。例:`{"delete_file": "always", "read_file": "never"}`。 -- グループ化されたオブジェクト:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 +- `True` はすべてのツールに承認を必須とし、`False` はどのツールにも承認を必須としません(それぞれ `"always"` および `"never"` と同等です)。 +- ツールごとのマップ。例: `{"delete_file": "always", "read_file": "never"}`。 +- グループ化されたオブジェクト: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 ```python async with MCPServerStreamableHttp( @@ -245,11 +244,11 @@ async with MCPServerStreamableHttp( ... ``` -一時停止/再開を含む完全なフローについては、[人間参加型](human_in_the_loop.md)および `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 +完全な一時停止と再開のフローについては、[人間参加型処理](human_in_the_loop.md)と `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 ### `tool_meta_resolver` による呼び出しごとのメタデータ -MCP サーバーが `_meta` 内にリクエストメタデータ(テナント ID やトレースコンテキストなど)を必要とする場合は、`tool_meta_resolver` を使用します。以下のコード例では、`Runner.run(...)` に `context` として `dict` を渡すことを前提としています。 +MCP サーバーが `_meta` 内にリクエストメタデータ(テナント ID やトレースコンテキストなど)を必要とする場合は、`tool_meta_resolver` を使用します。以下の例では、`dict` を `context` として `Runner.run(...)` に渡すことを前提としています。 ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -272,17 +271,17 @@ server = MCPServerStreamableHttp( 実行コンテキストが Pydantic モデル、dataclass、またはカスタムクラスの場合は、属性アクセスを使用してテナント ID を読み取ります。 -### MCP ツールの出力:テキストと画像 +### MCP ツールの出力: テキストと画像 -MCP ツールが画像コンテンツを返すと、SDK はそれを画像ツールの出力エントリーに自動的にマッピングします。テキストと画像が混在するレスポンスは出力項目のリストとして転送されるため、エージェントは通常の関数ツールからの画像出力と同じ方法で、MCP の画像実行結果を利用できます。 +MCP ツールが画像コンテンツを返すと、SDK は自動的にツール出力内の画像タイプのエントリーへマッピングします。テキストと画像が混在するレスポンスは出力項目のリストとして転送されるため、エージェントは通常の関数ツールからの画像出力と同じ方法で MCP の画像結果を利用できます。 ## 3. SSE 対応 HTTP MCP サーバー !!! warning - MCP プロジェクトでは、Server-Sent Events トランスポートは非推奨になっています。新しい統合には Streamable HTTP または stdio を使用し、SSE はレガシーサーバーにのみ使用してください。 + MCP プロジェクトでは Server-Sent Events トランスポートが非推奨になっています。新しい統合には Streamable HTTP または stdio を使用し、SSE はレガシーサーバーでのみ使用してください。 -MCP サーバーが SSE 対応 HTTP トランスポートを実装している場合は、[`MCPServerSse`][agents.mcp.server.MCPServerSse] をインスタンス化します。トランスポートを除き、API は Streamable HTTP サーバーと同一です。 +MCP サーバーが SSE 対応 HTTP トランスポートを実装している場合は、[`MCPServerSse`][agents.mcp.server.MCPServerSse] をインスタンス化します。トランスポートを除けば、API は Streamable HTTP サーバーと同一です。 ```python @@ -311,7 +310,7 @@ async with MCPServerSse( ## 4. stdio MCP サーバー -ローカルのサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを起動し、パイプを開いた状態に保ち、コンテキストマネージャーの終了時に自動的に閉じます。このオプションは、簡単な概念実証を行う場合や、サーバーがコマンドラインのエントリーポイントのみを公開している場合に役立ちます。 +ローカルのサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを生成し、パイプを開いたまま維持し、コンテキストマネージャーの終了時に自動的に閉じます。このオプションは、簡単な概念実証を行う場合や、サーバーがコマンドラインのエントリーポイントのみを公開する場合に便利です。 ```python from pathlib import Path @@ -339,7 +338,7 @@ async with MCPServerStdio( ## 5. MCP サーバーマネージャー -複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、接続済みのサーバーのみをエージェントに公開します。コンストラクターのオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 +複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、正常に接続できたサーバーのみをエージェントに公開します。コンストラクターのオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 ```python from agents import Agent, Runner @@ -360,25 +359,25 @@ async with MCPServerManager(servers) as manager: print(result.final_output) ``` -主な動作: +主な動作: -- `drop_failed_servers=True`(デフォルト)の場合、`active_servers` には正常に接続されたサーバーのみが含まれます。 +- `drop_failed_servers=True` の場合(デフォルト)、`active_servers` には正常に接続されたサーバーのみが含まれます。 - 失敗は `failed_servers` と `errors` に記録されます。 -- 最初の接続失敗時に例外を送出するには、`strict=True` を設定します。 +- 最初の接続失敗時に例外を発生させるには、`strict=True` を設定します。 - 失敗したサーバーを再試行するには `reconnect(failed_only=True)` を、すべてのサーバーを再起動するには `reconnect(failed_only=False)` を呼び出します。 -- ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。ライフサイクルのタイムアウトには、正の有限秒数、または無効化するための `None` を指定できます。これらは構築時と代入時の両方で検証されます。ゼロを指定すると即時の期限が設定されてしまうため、拒否されます。 +- ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。ライフサイクルのタイムアウトには、正の有限秒数を指定できます。無効にするには `None` を指定します。値は構築時と代入時の両方で検証されます。ゼロは即時の期限を設定することになるため、拒否されます。 ## 共通のサーバー機能 -以下のセクションは、MCP サーバーの各トランスポートに共通して適用されます(利用できる具体的な API はサーバークラスによって異なります)。 +以下のセクションは、MCP サーバーの各トランスポートに共通して適用されますが、具体的な API はサーバークラスによって異なります。 -## ツールのフィルタリング +## ツールフィルタリング -各 MCP サーバーはツールフィルターをサポートしているため、エージェントに必要な関数のみを公開できます。フィルタリングは構築時に行うことも、実行ごとに動的に行うこともできます。 +各 MCP サーバーはツールフィルターをサポートしているため、エージェントが必要とする関数のみを公開できます。フィルタリングは、構築時または実行ごとに動的に行えます。 -### 静的なツールのフィルタリング +### 静的ツールフィルタリング -単純な許可/ブロックリストを設定するには、[`create_static_tool_filter`][agents.mcp.create_static_tool_filter] を使用します。 +単純な許可リストとブロックリストを設定するには、[`create_static_tool_filter`][agents.mcp.create_static_tool_filter] を使用します。 ```python from pathlib import Path @@ -396,11 +395,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names` と `blocked_tool_names` の両方を指定した場合、SDK は最初に許可リストを適用し、その後、残りのセットからブロックされたツールを削除します。 +`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK は最初に許可リストを適用し、その後、残った集合からブロック対象のツールを削除します。 -### 動的なツールのフィルタリング +### 動的ツールフィルタリング -より複雑なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る呼び出し可能オブジェクトを渡します。この呼び出し可能オブジェクトは同期または非同期にでき、ツールを公開する場合に `True` を返します。 +より複雑なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る callable を渡します。callable は同期または非同期にでき、ツールを公開する場合は `True` を返します。 ```python from pathlib import Path @@ -428,11 +427,10 @@ async with MCPServerStdio( ## プロンプト -MCP サーバーは、エージェントへの指示を動的に生成するプロンプトも提供できます。プロンプトをサポートするサーバーは、次の 2 つの -メソッドを公開します。 +MCP サーバーは、エージェントの instructions を動的に生成するプロンプトも提供できます。プロンプトをサポートするサーバーは、次の 2 つのメソッドを公開します。 - `list_prompts()` は、利用可能なプロンプトテンプレートを列挙します。 -- `get_prompt(name, arguments)` は、必要に応じてパラメーターを指定し、具体的なプロンプトを取得します。 +- `get_prompt(name, arguments)` は、必要に応じてパラメーターを指定して、具体的なプロンプトを取得します。 ```python from agents import Agent @@ -452,25 +450,25 @@ agent = Agent( ## ページネーション -組み込みのローカル MCP サーバークラスは、ツールとプロンプトの一覧取得時に `nextCursor` を自動的にたどります。`list_tools()` は、フィルターの適用またはキャッシュへの格納前にツールの完全なリストを返し、`list_prompts()` は `nextCursor=None` の 1 つに統合された実行結果を返します。後続ページの取得に失敗した場合、またはサーバーが同じカーソルを繰り返した場合、部分的な実行結果を公開またはキャッシュする代わりに、操作はエラーを送出します。 +組み込みのローカル MCP サーバークラスは、ツールとプロンプトを一覧取得する際に `nextCursor` を自動的にたどります。`list_tools()` は、フィルターの適用またはキャッシュへの格納前にツールの完全なリストを収集し、`list_prompts()` は `nextCursor=None` を含む 1 つの統合された実行結果を返します。後続ページの取得に失敗した場合やサーバーがカーソルを繰り返した場合は、部分的な実行結果を公開またはキャッシュせず、エラーを発生させます。 -リソースは引き続き明示的にページ分割されます。次のページを取得するには、`list_resources()` または `list_resource_templates()` から返された `nextCursor` を `cursor` 引数として渡します。 +リソースは引き続き明示的にページ分割されます。次のページを取得するには、`list_resources()` または `list_resource_templates()` の `nextCursor` を、`cursor` 引数として渡します。 ## キャッシュ -エージェントを実行するたびに、各 MCP サーバーで `list_tools()` が呼び出されます。リモートサーバーでは無視できないレイテンシーが生じる可能性があるため、すべての MCP サーバークラスが `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新のリストを強制的に取得するには、サーバーインスタンスの `invalidate_tools_cache()` を呼び出します。 +各エージェント実行では、それぞれの MCP サーバーで `list_tools()` が呼び出されます。リモートサーバーでは無視できないレイテンシーが発生する可能性があるため、すべての MCP サーバークラスで `cache_tools_list` オプションが公開されています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新のリストを強制的に取得するには、サーバーインスタンスで `invalidate_tools_cache()` を呼び出します。 ## トレーシング -[トレーシング](./tracing.md)では、次の項目を含む MCP のアクティビティが自動的に記録されます。 +[トレーシング](./tracing.md)では、以下を含む MCP のアクティビティが自動的に記録されます。 -1. ツール一覧を取得するための MCP サーバー呼び出し。 +1. ツールを一覧取得するための MCP サーバーへの呼び出し。 2. ツール呼び出しに関する MCP 関連情報。 ![MCP トレーシングのスクリーンショット](../assets/images/mcp-tracing.jpg) -## 関連資料 +## 関連情報 -- [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様および設計ガイド。 +- [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様と設計ガイド。 - [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 実行可能な stdio、SSE、Streamable HTTP のサンプル。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認とコネクターを含む、完全なホスト型 MCP のデモ。 \ No newline at end of file +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認とコネクターを含む、ホステッド MCP の完全なデモ。 \ No newline at end of file diff --git a/docs/ja/models/index.md b/docs/ja/models/index.md index 84e06ad40e..e3ad1325c0 100644 --- a/docs/ja/models/index.md +++ b/docs/ja/models/index.md @@ -4,32 +4,32 @@ search: --- # モデル -Agents SDKには、OpenAIモデルがすぐに利用できる形で、次の 2 種類用意されています。 +Agents SDKには、すぐに使用できるOpenAIモデルのサポートが、次の 2 種類用意されています。 -- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 -- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 +- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用してOpenAI APIを呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 +- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用してOpenAI APIを呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 ## モデル設定の選択 -まず、環境に適した最もシンプルな方法から始めてください。 +設定に適した最もシンプルな方法から始めてください。 | 目的 | 推奨される方法 | 詳細 | | --- | --- | --- | -| OpenAIモデルのみを使用する | デフォルトの OpenAIプロバイダーを Responses モデル経由で使用する | [OpenAIモデル](#openai-models) | -| OpenAI Responses API を WebSocket トランスポート経由で使用する | Responses モデル経由を維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | -| OpenAIがホストするサブエージェントを使用する | 実験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | +| OpenAIモデルのみを使用する | デフォルトのOpenAIプロバイダーと Responses モデルパスを使用する | [OpenAIモデル](#openai-models) | +| WebSocket トランスポート経由でOpenAI Responses APIを使用する | Responses モデルパスを維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | +| OpenAIがホストするサブエージェントを使用する | 実験的なホステッドマルチエージェントモデルを使用する | [ホステッドマルチエージェント](#hosted-multi-agent-experimental) | | OpenAI以外のプロバイダーを 1 つ使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI以外のモデル](#non-openai-models) | -| エージェント間でモデルやプロバイダーを組み合わせる | 実行ごと、またはエージェントごとにプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[プロバイダー間でのモデルの組み合わせ](#mixing-models-across-providers) | -| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses 経由で `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | -| OpenAI以外、または複数プロバイダーのルーティングにサードパーティー製アダプターを使用する | サポート対象のベータ版アダプターを比較し、リリース予定のプロバイダー経路を検証する | [サードパーティー製アダプター](#third-party-adapters) | +| エージェント間でモデルやプロバイダーを組み合わせる | 実行ごと、またはエージェントごとにプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[プロバイダーをまたいだモデルの組み合わせ](#mixing-models-across-providers) | +| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses パスで `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | +| OpenAI以外のプロバイダーまたは複数プロバイダーのルーティングにサードパーティアダプターを使用する | サポートされているベータ版アダプターを比較し、リリース予定のプロバイダーパスを検証する | [サードパーティアダプター](#third-party-adapters) | ## OpenAIモデル -OpenAIのみを使用するほとんどのアプリでは、デフォルトの OpenAIプロバイダーで文字列のモデル名を使用し、Responses モデル経由を維持することを推奨します。 +OpenAIのみを使用するほとんどのアプリでは、デフォルトのOpenAIプロバイダーで文字列のモデル名を使用し、Responses モデルパスを維持する方法を推奨します。 -`Agent` の初期化時にモデルを指定しない場合、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` および `verbosity="low"` を設定した [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。利用できる場合は、明示的な `model_settings` を維持しながら、より高品質な `gpt-5.6-sol` をエージェントに設定することを推奨します。 +`Agent` の初期化時にモデルを指定しない場合は、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` と `verbosity="low"` を指定した [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。アクセス権がある場合は、明示的な `model_settings` を維持しつつ、品質を高めるためにエージェントを `gpt-5.6-sol` に設定することを推奨します。 -`gpt-5.6-sol` などの別のモデルに切り替える場合、エージェントを設定する方法は 2 つあります。 +`gpt-5.6-sol` などの別のモデルへ切り替える場合、エージェントを設定する方法は 2 つあります。 ### デフォルトモデル @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 モデル -この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースに最適な設定が使用されます。デフォルトモデルの推論 effort を調整するには、独自の `ModelSettings` を渡します。 +この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。これは、ほとんどのユースケースで最適に機能する設定です。デフォルトモデルの推論労力を調整するには、独自の `ModelSettings` を渡します。 ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -レイテンシーを抑えるには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 +レイテンシーを低くするには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 -GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、永続化された推論コンテキスト、および `"max"` effort レベルもサポートします。これらの制御は Responses API 経由で利用できます。 +GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、会話ターン間で引き継がれる推論コンテキスト、および `"max"` の労力レベルもサポートします。これらの制御は Responses API パスで利用できます。 ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされる effort レベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` effort には Responses API を使用してください。Chat Completions アダプターは警告を表示して mode と context を無視します。この警告をエラーにするには、OpenAIプロバイダーで `strict_feature_validation=True` を設定します。 +`reasoning.mode` と `reasoning.context` は、Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされる労力レベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` 労力には Responses APIを使用してください。Chat Completions アダプターは、警告を表示してモードとコンテキストを無視します。その警告をエラーに変えるには、OpenAIプロバイダーで `strict_feature_validation=True` を設定してください。 -`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の会話、または以前の推論項目の再送信によって会話を維持します。ステートレスな `store=False` 呼び出しでは、レスポンスに `reasoning.encrypted_content` を含め、次のリクエストでそれらの推論項目を再送信します。 +`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の Responses API 会話、または前回の推論項目を次のリクエストに含めることで会話を維持してください。ステートレスな `store=False` 呼び出しでは、レスポンス内の `reasoning.encrypted_content` をリクエストし、それらの推論項目を次のリクエストの入力に含めてください。 #### ComputerTool のモデル選択 -エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは GA 版の組み込み `computer` ツールが使用されますが、明示的な `computer-use-preview` リクエストでは従来の `computer_use_preview` ペイロードが維持されます。 +エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれている場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは、GA の組み込み `computer` ツールが使用されます。一方、明示的な `computer-use-preview` リクエストでは、従来の `computer_use_preview` ペイロードが維持されます。 -主な例外は、プロンプト管理の呼び出しです。プロンプトテンプレートがモデルを管理し、SDK がリクエストから `model` を省略する場合、プロンプトが固定するモデルを SDK が推測しないよう、デフォルトでプレビュー互換のコンピューターペイロードが使用されます。このフローで GA 経路を維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制します。 +主な例外は、プロンプトによって管理される呼び出しです。プロンプトテンプレートでモデルを指定し、SDK がリクエストから `model` を省略する場合、プロンプトに固定されているモデルを推測しないように、SDK はプレビュー互換のコンピューターペイロードをデフォルトで使用します。このフローで GA パスを維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制してください。 -[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルと一致する組み込みセレクターに正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 -プレビュー互換のリクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエストを送信する前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 +プレビュー互換のリクエストでは、`environment` とディスプレイ寸法を事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエストを送信する前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 #### GPT-5 以外のモデル -カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はすべてのモデルと互換性のある汎用的な `ModelSettings` に戻します。 +カスタム `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はどのモデルとも互換性のある汎用の `ModelSettings` に戻します。 ### Responses 専用のツール機能 -次のツール機能は、OpenAI Responses モデルでのみサポートされます。 +次のツール機能は、OpenAI Responses モデルでのみサポートされています。 - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` およびその他の遅延読み込み型 Responses ツールサーフェス +- `@function_tool(defer_loading=True)` および、遅延読み込みに対応するその他の Responses ツールサーフェス - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`、および `tool_choice="programmatic_tool_calling"` -これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、単独の名前空間名や遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 +これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、ネームスペース名のみ、または遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませてください。設定の詳細と現在の制約については、[ホステッドツール検索](../tools.md#hosted-tool-search)および[プログラマティックツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 ### Responses WebSocket トランスポート -デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAIを基盤とするモデルを使用する場合は、WebSocket トランスポートを有効にできます。 +デフォルトでは、OpenAI Responses APIリクエストは HTTP トランスポートを使用します。OpenAI Responses プロバイダーパスを使用する場合、WebSocket トランスポートをオプトインで有効にできます。 #### 基本設定 @@ -137,11 +137,11 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトの OpenAIプロバイダーによって解決される OpenAI Responses モデルに影響します。`"gpt-5.6-sol"` などの文字列モデル名も含まれます。 +これは、デフォルトのOpenAIプロバイダーがモデル名を解決した結果として得られるOpenAI Responses モデルに影響します。これには、`"gpt-5.6-sol"` などの文字列モデル名も含まれます。 -トランスポートは、SDK がモデル名をモデルインスタンスに解決するときに選択されます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡す場合、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 +トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡す場合、グローバルデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 -#### プロバイダーまたは実行レベルの設定 +#### プロバイダー単位または実行単位の設定 プロバイダーごと、または実行ごとに WebSocket トランスポートを設定することもできます。 @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAIを基盤とするプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAIの設定でハーネス ID などのプロバイダーレベルの登録メタデータが必要な場合に使用する高度なオプションです。 +SDK のOpenAI統合を経由するプロバイダーは、オプションのエージェント登録設定も受け入れます。これは、OpenAI設定でハーネス ID などのプロバイダーレベルの登録メタデータが必要となる場合の高度なオプションです。 ```python from agents import ( @@ -188,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider` を使用した高度なルーティング +#### `MultiProvider` による高度なルーティング -プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 +プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定してください。 -`MultiProvider` は、従来からの 2 つのデフォルト動作を維持します。 +`MultiProvider` は、過去から引き継がれた次の 2 つのデフォルト動作を維持します。 -- `openai/...` は OpenAIプロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 +- `openai/...` はOpenAIプロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 - 不明なプレフィックスは、そのまま渡されるのではなく `UserError` を発生させます。 -リテラルな名前空間付きモデル ID を必要とする OpenAI互換エンドポイントに OpenAIプロバイダーを接続する場合は、パススルー動作を明示的に有効にしてください。WebSocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を維持します。 +OpenAIプロバイダーを、リテラルなネームスペース付きモデル ID を要求するOpenAI互換エンドポイントへ接続する場合は、パススルー動作を明示的にオプトインしてください。WebSocket を有効にした設定では、`MultiProvider` でも `openai_use_responses_websocket=True` を維持してください。 ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -バックエンドがリテラルな `openai/...` 文字列を必要とする場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を必要とする場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも機能します。この例では、このセクションで説明しているトランスポート設定の一部であるため、WebSocket を有効なままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 +バックエンドがリテラルな `openai/...` 文字列を要求する場合は、`openai_prefix_mode="model_id"` を使用してください。バックエンドが `openrouter/openai/gpt-4.1-mini` などの他のネームスペース付きモデル ID を要求する場合は、`unknown_prefix_mode="model_id"` を使用してください。これらのオプションは、WebSocket トランスポート外の `MultiProvider` でも機能します。この例で WebSocket を有効なままにしているのは、このセクションで説明しているトランスポート設定の一部であるためです。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 -`MultiProvider` を介してルーティングしながら同じプロバイダーレベルの登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡します。これは基盤となる OpenAIプロバイダーに転送されます。 +`MultiProvider` を通じてルーティングしながら、同じプロバイダーレベルの登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡してください。これは基盤となるOpenAIプロバイダーへ転送されます。 -カスタムの OpenAI互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 +カスタムのOpenAI互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 #### 注意事項 -- これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions や OpenAI以外のプロバイダーには、それらが Responses WebSocket `/responses` エンドポイントをサポートしていない限り適用されません。 +- これは WebSocket トランスポート経由の Responses APIであり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions には適用されません。OpenAI以外のプロバイダーには、そのプロバイダーが Responses WebSocket の `/responses` エンドポイントをサポートしている場合にのみ適用されます。 - 環境にまだ存在しない場合は、`websockets` パッケージをインストールしてください。 -- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。ターン間、およびネストされた Agents as tools の呼び出し間で同じ WebSocket 接続を再利用するマルチターンワークフローでは、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 -- 長時間の推論ターンやレイテンシーの急増があるネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズします。遅延した pong フレームを許容するには `ping_timeout` を増やすか、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。WebSocket のレイテンシーよりも信頼性が重要な場合は、HTTP/SSE トランスポートを優先してください。 -- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後で動作する長寿命のエージェントプロセスやメモリー制約のあるコンテナーでは、`responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定して、メッセージごとのメモリー使用量に上限を設けます。 -- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。その制限後は新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用します。 -- サービスは、接続ローカルのメモリーに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、参照された `previous_response_id` が削除されます。再接続後も、保存済みレスポンスが利用可能であれば継続できますが、`store=False` および ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` を指定して新しいチェーンを開始し、完全な入力コンテキストを送信するか、ローカルで管理しているセッション状態からそのコンテキストを再構築してください。 +- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンにわたり同じ WebSocket 接続を再利用するワークフローでは、ネストされた Agents-as-tools 呼び出しも含め、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 +- 長時間の推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズしてください。遅延した pong フレームを許容するには `ping_timeout` を増やします。ping を有効にしたままハートビートのタイムアウトを無効にするには、`ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性を重視する場合は、HTTP/SSE トランスポートを使用してください。 +- SDK はデフォルトで、受信メッセージサイズの上限を無効にします(`max_size=None`)。プロキシの背後にある長時間稼働するエージェントプロセスや、メモリーが制限されたコンテナでは、メッセージごとのメモリー使用量を制限するために `responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定してください。 +- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。その上限を超えたら新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用してください。 +- サービスは、接続ローカルのメモリーに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、`previous_response_id` が参照するレスポンスがそのメモリーから削除されます。再接続後も、保存済みのレスポンスが利用可能であれば処理を継続できますが、`store=False` と ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` で新しいチェーンを開始して入力コンテキスト全体を送信するか、ローカルで管理するセッション状態からそのコンテキストを再構築してください。 -### ホスト型マルチエージェント(実験的) +### ホステッドマルチエージェント(実験的) -OpenAI Responses API のホスト型マルチエージェントベータ版では、GPT-5.6 ルートモデルがサーバーでホストされるサブエージェントを作成し、連携させることができます。Agents SDKは通常の `Runner` を引き続き使用できます。ホスト型オーケストレーションはサービス上で実行され、開発者が定義した関数ツールはアプリケーション内で実行されます。 +OpenAI Responses APIのホステッドマルチエージェントベータでは、GPT-5.6 のルートモデルが、サーバーでホストされるサブエージェントを作成して連携させることができます。Agents SDKは通常の `Runner` を引き続き使用できます。ホステッドオーケストレーションはサービス上に留まり、開発者が定義した関数ツールはアプリケーション内で実行されます。 -この統合は実験的であり、ローカル関数の出力を `response.inject` によってアクティブなホスト型エージェントへ返せるように、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開するベータビルドを含む `openai[realtime]>=2.45.0` が必要です。一般提供までに、インターフェースとベータ版の項目スキーマが変更される可能性があります。 +この統合は実験的であり、ローカル関数の出力を `response.inject` によりアクティブなホステッドエージェントへ返せるように、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開する、バージョン 2.45.0 以降の `openai[realtime]` ビルドが必要です。インターフェースとベータ項目のスキーマは、一般提供までに変更される可能性があります。 #### モデルの設定 @@ -260,13 +260,13 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` が指定されていない限り、モデルはデフォルトの OpenAIクライアントを使用します。`max_concurrent_subagents` を省略すると、サービスのデフォルト値が使用されます。 +`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` を指定しない場合、モデルはデフォルトのOpenAIクライアントを使用します。`max_concurrent_subagents` を省略すると、サービスのデフォルトが使用されます。 #### ローカル関数ツール -すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスに挿入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、引き続き Runner の通常のガードレール、フック、および失敗時の変換が適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエスト送信前に拒否されます。 +すべてのホステッドエージェントは、リクエストに設定されたモデルとツールを共有します。Responses APIは、どのホステッドエージェントが関数を呼び出すかを決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスへ挿入します。これにより、サービスは元のホステッド呼び出し元を再開できます。関数の実行には、Runner の通常のガードレール、フック、および失敗時の変換が引き続き適用されます。SDK のツール承認による中断はサポートされていません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 -呼び出し元を考慮したログ記録や認可がツールに必要な場合は、`get_hosted_agent_metadata()` を使用します。 +呼び出し元を考慮したログ記録や認可がツールに必要な場合は、`get_hosted_agent_metadata()` を使用してください。 ```python from typing import Any @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -ホスト型エージェント名は観測用のメタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、必要な認可をツールの実行前または実行中にアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 +ホステッドエージェント名は観測用のメタデータであり、ローカルルーティングの仕組みではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用のあるツールでは、その呼び出し ID を冪等性キーとして使用し、必要な認可をツール実行前または実行中にアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses APIの境界を越えます。 #### 出力とストリーミングの動作 -`final_answer` フェーズで `/root` に帰属するメッセージのみが、通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がそれらのレコードをローカル関数として実行することはありません。 +フェーズが `final_answer` で、`/root` に帰属するメッセージのみが、通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホステッドオーケストレーションの記録を高レベルの `RunResult` から除外します。SDK がそれらの記録をローカル関数として実行することはありません。 -raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含むベータ版 Responses イベントが引き続き公開されます。関数呼び出しの準備ができると、アダプターは 1 つのアクティブなプロバイダーレスポンスを SDK から見える論理モデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。帰属情報を確認するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用します。 +raw ストリーミングでは、ホステッド出力項目や `response.inject.created` の確認応答を含む、ベータ版 Responses イベントが引き続き公開されます。アダプターは、関数呼び出しの準備が整うと、アクティブな 1 つのプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。raw のホステッド項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用すると、項目またはツール呼び出しが帰属するホステッドエージェントを識別できます。 #### SDK オーケストレーションとの関係 -ホスト型マルチエージェントは、SDK のハンドオフおよび Agents as tools とは別のものです。 +ホステッドマルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 -- ホスト型マルチエージェントは、OpenAIサービス上にサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 -- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この実験的モデルを使用すると、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が発生するため、ハンドオフは拒否されます。 -- Agents as tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 +- ホステッドマルチエージェントは、OpenAIサービス上にサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 +- SDK のハンドオフは、アクティブなローカル SDK の `Agent` を変更します。すべてのホステッドエージェントが同じハンドオフツールを受け取り、所有権の競合が生じるため、この実験的モデルを使用している場合は拒否されます。 +- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 #### 現在の制限事項 -実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定した `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスが各ホスト型エージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 +実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされていません。ただし、サービスが各ホステッドエージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 -1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に保持できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待っている間に実行を中止した場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 +1 つの `OpenAIHostedMultiAgentModel` インスタンスが所有できるアクティブなホステッドレスポンスは、一度に最大 1 つです。ローカル関数の出力を待機中に実行が放棄された場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホステッドレスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 -基盤となる Responses API ベータ版の動作については、[OpenAIマルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 +基盤となる Responses APIベータの動作については、[OpenAIマルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 ## OpenAI以外のモデル -OpenAI以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティー製アダプターを追加しなくても十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 +OpenAI以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティアダプターを追加しなくてもこれで十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 ### OpenAI以外のプロバイダーの統合方法 -| アプローチ | 使用する場合 | 適用範囲 | +| 方法 | 使用する状況 | 適用範囲 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | +| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つのOpenAI互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | | [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行単位 | | [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェント単位 | -| サードパーティー製アダプター | 組み込み経路では提供されない、アダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合 | [サードパーティー製アダプター](#third-party-adapters)を参照 | +| サードパーティアダプター | 組み込みの方法では提供されないプロバイダー対応やルーティングが必要な場合 | [サードパーティアダプター](#third-party-adapters)を参照 | -次の組み込み経路を使用して、他の LLM プロバイダーを統合できます。 +次の組み込み方法で、他の LLM プロバイダーを統合できます。 -1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に役立ちます。これは、LLM プロバイダーに OpenAI互換の API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 +1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーにOpenAI互換 API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで使用します。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスにモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせられます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 -`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` でトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 +`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` を使用してトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,11 +341,11 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses API をサポートしている場合は、Responses の使用を推奨します。 + これらのコード例では、多くの LLM プロバイダーがまだ Responses APIをサポートしていないため、Chat Completions APIおよびモデルを使用しています。LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 ## 1 つのワークフローでのモデルの組み合わせ -単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する際は、次のいずれかの方法で特定のモデルを選択できます。 +単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小さく高速なモデルを使用し、複雑なタスクには大きく高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定するときは、次のいずれかの方法で特定のモデルを選択できます。 1. モデル名を渡します。 2. 任意のモデル名と、その名前を Model インスタンスにマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡します。 @@ -353,7 +353,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、それぞれがサポートする機能とツールのセットは異なります。そのため、ワークフローごとに単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 + SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、両者ではサポートする機能とツールの組み合わせが異なるため、ワークフローごとに単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 ```python import asyncio @@ -394,7 +394,7 @@ if __name__ == "__main__": 1. OpenAIモデルの名前を直接設定します。 2. [`Model`][agents.models.interface.Model] の実装を指定します。 -エージェントが使用するモデルをさらに設定する場合は、[`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。これにより、temperature などのオプションのモデル設定パラメーターを指定できます。 +エージェントが使用するモデルをさらに設定する場合は、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。 ```python from agents import Agent, ModelSettings @@ -409,22 +409,22 @@ english_agent = Agent( ## OpenAI Responses の高度な設定 -OpenAI Responses 経由でより詳細な制御が必要な場合は、まず `ModelSettings` を使用します。 +OpenAI Responses パスを使用しており、より細かな制御が必要な場合は、`ModelSettings` から始めてください。 ### 一般的な高度な `ModelSettings` オプション -OpenAI Responses API を使用する場合、いくつかのリクエストフィールドには対応する `ModelSettings` フィールドがすでに用意されているため、それらに `extra_args` を使用する必要はありません。 +OpenAI Responses APIを使用している場合、いくつかのリクエストフィールドには対応する `ModelSettings` フィールドがすでに用意されているため、それらに `extra_args` を使用する必要はありません。 - `parallel_tool_calls`: 同じターンで複数のツール呼び出しを許可または禁止します。 -- `truncation`: `"auto"` を設定すると、コンテキストが上限を超える場合に失敗する代わりに、Responses API が最も古い会話項目を削除します。 -- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに影響します。 -- `context_management`: `compact_threshold` を使用した Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 -- `prompt_cache_retention`: 以前のモデルファミリー向けに、たとえば - `"24h"` を指定して保持期間の延長を設定します。 +- `truncation`: コンテキストが上限を超える場合に失敗する代わりに、Responses APIが最も古い会話項目を削除できるよう、`"auto"` を設定します。 +- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに関係します。 +- `context_management`: `compact_threshold` による Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 +- `prompt_cache_retention`: 以前のモデルファミリー向けの延長保持を設定します。たとえば、 + `"24h"` を使用します。 - `prompt_cache_options`: 暗黙的または明示的なプロンプトキャッシュを選択し、GPT-5.6 では `"30m"` のキャッシュ TTL を設定します。 - `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードをリクエストします。 -- `top_logprobs`: 出力テキストの上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 -- `retry`: モデル呼び出しに対して、Runner が管理する再試行設定を有効にします。[Runner 管理の再試行](#runner-managed-retries)を参照してください。 +- `top_logprobs`: 出力テキストについて上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 +- `retry`: モデル呼び出しについて Runner が管理する再試行設定をオプトインで有効にします。[Runner が管理する再試行](#runner-managed-retries)を参照してください。 ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -明示的なプロンプトキャッシュでは、再利用可能なプレフィックスが終了するコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストでそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、およびファイルのコンテンツ部分にあるブレークポイントを維持します。 +明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストにそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、およびファイルのコンテンツ部分にあるブレークポイントを維持します。 ```python from agents import Runner @@ -470,18 +470,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーでも引き続き利用できます。 -直接指定する `ModelSettings` フィールドと同じキーを `extra_args` に含めないでください。 +従来の保持制御を使用する以前のモデルファミリーでは、`prompt_cache_retention` を引き続き利用できます。 +直接指定する `ModelSettings` フィールドと、`extra_args` 内の同じキーを組み合わせないでください。 -`store=False` を設定すると、Responses API は、そのレスポンスを後でサーバー側から取得できるようには保持しません。これはステートレスまたはゼロデータ保持形式のフローに役立ちますが、通常ならレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、最後のレスポンスが保存されていない場合、デフォルトの `"auto"` 圧縮経路を入力ベースの圧縮に切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 +`store=False` を設定すると、Responses APIはそのレスポンスを後でサーバー側から取得できる状態で保持しません。これは、ステートレスまたはゼロデータ保持形式のフローに便利ですが、通常ならレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、最後のレスポンスが保存されていない場合、デフォルトの `"auto"` 圧縮パスを入力ベースの圧縮に切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間で独立した `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 +サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses APIリクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間でスタンドアロンの `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 ### `extra_args` の受け渡し -SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 +SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用してください。 -OpenAIモデルを使用する場合、`extra_args` を使用して、Responses API と Chat Completions API の両方にオプションのパラメーター(たとえば `user` や `service_tier`)を渡せます。サポート対象のモデルで[高速モード](https://developers.openai.com/api/docs/guides/fast-mode)を使用するには、`extra_args={"service_tier": "fast"}` を設定します。`"priority"` も同等です。同じリクエストフィールドを、直接指定する `ModelSettings` フィールドにも設定しないでください。 +OpenAIモデルを使用する場合、`extra_args` は Responses APIと Chat Completions APIの両方にオプションのパラメーターを渡せます。たとえば、`user` や `service_tier` です。サポートされているモデルで [Fast モード](https://developers.openai.com/api/docs/guides/fast-mode)を使用するには、`extra_args={"service_tier": "fast"}` を設定してください。`"priority"` も同等です。同じリクエストフィールドを、直接指定する `ModelSettings` フィールドでも設定しないでください。 ```python from agents import Agent, ModelSettings @@ -497,11 +497,11 @@ english_agent = Agent( ) ``` -## Runner 管理の再試行 +## Runner が管理する再試行 -再試行はランタイム専用で、明示的に有効にする必要があります。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 +再試行はランタイム専用であり、オプトイン方式です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 -Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` は、レスポンス前の過負荷フレームとコードのない `server_error` フレームを再試行の提案として認識します。これだけでは再試行は有効になりません。引き続き `ModelRetrySettings` が必要で、通常の再送信安全性チェックも適用されます。レスポンスイベントが 1 つでもすでに到着している場合、SDK はリクエストを再送信しません。 +Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` はレスポンス前の過負荷フレームと、コードのない `server_error` フレームを再試行の提案として認識します。これだけでは再試行は有効になりません。引き続き `ModelRetrySettings` が必要であり、通常のリプレイ安全性チェックも適用されます。レスポンスイベントが 1 つでも到着済みの場合、SDK はリクエストをリプレイしません。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -529,85 +529,85 @@ agent = Agent( ) ``` -`ModelRetrySettings` には 3 つのフィールドがあります。 +`ModelRetrySettings` には、次の 3 つのフィールドがあります。
-| フィールド | 型 | 注意事項 | +| フィールド | 型 | 注記 | | --- | --- | --- | | `max_retries` | `int | None` | 最初のリクエスト後に許可される再試行回数です。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合の、デフォルトの遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみに上限を設定します。ポリシーから返される明示的な遅延や retry-after ヒントには上限を設定しません。 | -| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドはランタイム専用で、シリアライズされません。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合のデフォルト遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみを制限します。ポリシーから返された明示的な遅延や retry-after ヒントは制限しません。 | +| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドはランタイム専用であり、シリアライズされません。 |
再試行ポリシーは、次の情報を持つ [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 -- `attempt` と `max_retries`。試行回数を考慮した判断に使用できます。 -- `stream`。ストリーミング動作と非ストリーミング動作を分岐できます。 -- `error`。raw の内容を確認できます。 -- `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの正規化された情報。 -- 基盤となるモデルアダプターが再試行のガイダンスを提供できる場合の `provider_advice`。 +- `attempt` と `max_retries`。これにより、試行回数を考慮した判断ができます。 +- `stream`。これにより、ストリーミング動作と非ストリーミング動作を分岐できます。 +- raw の検査に使用する `error`。 +- `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの `normalized` 情報。 +- 基盤となるモデルアダプターが再試行の指針を提供できる場合の `provider_advice`。 ポリシーは、次のいずれかを返せます。 -- 単純な再試行判断を表す `True` / `False`。 +- 単純な再試行判断を示す `True` / `False`。 - 遅延をオーバーライドするか、診断理由を付加する場合の [`RetryDecision`][agents.retry.RetryDecision]。 -SDK は、`retry_policies` にすぐに使用できるヘルパーを提供しています。 +SDK は、`retry_policies` で既製のヘルパーをエクスポートします。 | ヘルパー | 動作 | | --- | --- | -| `retry_policies.never()` | 常に再試行しません。 | +| `retry_policies.never()` | 常にオプトアウトします。 | | `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行アドバイスに従います。 | -| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウトに一致します。 | -| `retry_policies.http_status([...])` | 選択した HTTP ステータスコードに一致します。 | -| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合のみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` による上限は適用されません。 | -| `retry_policies.any(...)` | ネストされたポリシーのいずれかが再試行を選択した場合に再試行します。 | -| `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合のみ再試行します。 | +| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウト障害に一致します。 | +| `retry_policies.http_status([...])` | 選択された HTTP ステータスコードに一致します。 | +| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合にのみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` はその値を制限しません。 | +| `retry_policies.any(...)` | ネストされたポリシーのいずれかがオプトインした場合に再試行します。 | +| `retry_policies.all(...)` | ネストされたすべてのポリシーがオプトインした場合にのみ再試行します。 | -ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の構成要素です。これは、プロバイダーが拒否判断と再送信安全性の承認を区別できる場合に、それらを維持するためです。 +ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の構成要素です。プロバイダーがそれらを区別できる場合に、プロバイダーによる拒否とリプレイ安全性の承認を維持するためです。 ##### 安全性の境界 -一部の失敗は、自動的に再試行されることはありません。 +一部の障害は自動的に再試行されません。 - 中止エラー。 -- プロバイダーのアドバイスで再送信が安全でないと判断されたリクエスト。 -- 出力がすでに開始され、再送信が安全でなくなるストリーミング実行。 +- プロバイダーのアドバイスでリプレイが安全でないと示されたリクエスト。 +- 出力がすでに開始され、リプレイが安全でなくなるストリーミング実行。 -`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` などのプロバイダー以外の述語だけでは不十分です。再試行ポリシーには、通常は `retry_policies.provider_suggested()` を通じて、プロバイダーによる再送信安全性の承認を含める必要があります。 +`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` など、プロバイダー由来ではない述語だけでは不十分です。再試行ポリシーには、通常は `retry_policies.provider_suggested()` を通じて、プロバイダーからリプレイが安全であるという承認を含める必要があります。 ##### Runner とエージェントのマージ動作 -`retry` は、Runner レベルとエージェントレベルの `ModelSettings` 間でディープマージされます。 +`retry` は、Runner レベルとエージェントレベルの `ModelSettings` の間でディープマージされます。 -- エージェントは `retry.max_retries` のみをオーバーライドし、Runner の `policy` を継承できます。 +- エージェントは `retry.max_retries` のみをオーバーライドし、Runner の `policy` を引き継げます。 - エージェントは `retry.backoff` の一部のみをオーバーライドし、Runner の他のバックオフフィールドを維持できます。 - `policy` はランタイム専用であるため、シリアライズされた `ModelSettings` は `max_retries` と `backoff` を維持しますが、コールバック自体は省略します。 -より詳しいコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを使用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 +さらに詳しいコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを利用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 ## OpenAI以外のプロバイダーのトラブルシューティング -### トレーシングクライアントのエラー 401 +### トレーシングクライアントエラー 401 -トレーシング関連のエラーが発生する場合、トレースが OpenAIサーバーへアップロードされる一方で、OpenAI API キーが設定されていないことが原因です。これを解決するには、次の 3 つの方法があります。 +トレーシングに関連するエラーが発生する場合、トレースがOpenAIサーバーへアップロードされる一方で、OpenAI API キーがないことが原因です。これを解決する方法は 3 つあります。 1. トレーシングを完全に無効にします: [`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. トレーシング用の OpenAIキーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードのみに使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 +2. トレーシング用のOpenAIキーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 3. OpenAI以外のトレースプロセッサーを使用します。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 -### Responses API のサポート +### Responses APIのサポート -SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだサポートしていません。そのため、404 エラーや同様の問題が発生する場合があります。解決するには、次の 2 つの方法があります。 +SDK はデフォルトで Responses APIを使用しますが、他の多くの LLM プロバイダーはまだこれをサポートしていません。その結果、404 や同様の問題が発生する場合があります。解決方法は 2 つあります。 -1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数で `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 +1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数を通じて `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] を使用します。コード例は[こちら](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)にあります。 ### Chat Completions の互換性オプション -Chat Completions を介してルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、プロンプト、またはテキスト以外を含むツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に削除して互換性を維持します。開発中にこれらの不一致を即座に失敗させるには、OpenAIプロバイダーで厳密な機能検証を有効にします。 +Chat Completions を通じてルーティングする場合、SDK は Chat Completions では送信できない Responses 専用フィールドを警告なしに削除することで互換性を維持します。たとえば、`previous_response_id`、`conversation_id`、Responses APIの `prompt` フィールド、またはテキストのみではないツール出力などです。開発中にこれらの不一致を即座に失敗させる場合は、OpenAIプロバイダーで厳密な機能検証を有効にしてください。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -625,9 +625,9 @@ result = await Runner.run( ) ``` -[`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡します。 +[`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡してください。 -一部の OpenAI互換 Chat Completions プロバイダーは、SDK が増分処理するには信頼性が十分でないチャンクでツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされるツール呼び出しのバッファリングを有効にし、プロバイダーのストリーム終了後にのみ SDK がツール呼び出しを生成するようにします。 +一部のOpenAI互換 Chat Completions プロバイダーは、SDK による増分処理には信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされるツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが終了した後でのみ SDK がツール呼び出しを生成するようにしてください。 ```python from agents import OpenAIProvider @@ -638,11 +638,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider] では、`openai_buffer_streamed_tool_calls=True` を使用します。 +[`MultiProvider`][agents.MultiProvider] では、`openai_buffer_streamed_tool_calls=True` を使用してください。 ### structured outputs のサポート -一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)をサポートしていません。この場合、次のようなエラーが発生することがあります。 +一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)をサポートしていません。その結果、次のようなエラーが発生する場合があります。 ``` @@ -650,42 +650,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていても、出力に使用する `json_schema` を指定できません。現在この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを使用することを推奨します。そうしないと、不正な形式の JSON によってアプリが頻繁に動作しなくなるためです。 +これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていますが、出力に使用する `json_schema` を指定できません。現在この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを利用することを推奨します。そうでない場合、不正な形式の JSON によりアプリケーションが頻繁に動作しなくなるためです。 -## プロバイダー間でのモデルの組み合わせ +## プロバイダーをまたいだモデルの組み合わせ -モデルプロバイダー間の機能差を把握しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAIは structured outputs、マルチモーダル入力、ホスト型のファイル検索および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 +モデルプロバイダー間の機能差を認識しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAIは structured outputs、マルチモーダル入力、ホステッドファイル検索、および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 -- サポートされていない `tools` を、それらを理解できないプロバイダーに送信しないでください +- 未対応の `tools` を、それを理解しないプロバイダーへ送信しないでください - テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください -- 構造化 JSON 出力をサポートしていないプロバイダーは、無効な JSON を生成する場合があることに注意してください。 +- 構造化 JSON 出力をサポートしないプロバイダーは、無効な JSON を生成する場合があることに注意してください。 -## サードパーティー製アダプター +## サードパーティアダプター -SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティー製アダプターを使用してください。この SDK で OpenAIモデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 経路を優先してください。サードパーティー製アダプターは、OpenAIモデルと OpenAI以外のプロバイダーを組み合わせる必要がある場合や、組み込み経路では提供されないアダプター管理のプロバイダーカバレッジまたはルーティングが必要な場合に使用します。アダプターは SDK と上流のモデルプロバイダーの間に互換性レイヤーを追加するため、機能のサポート状況やリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 +SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティアダプターを使用してください。この SDK でOpenAIモデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] パスを使用してください。サードパーティアダプターは、OpenAIモデルとOpenAI以外のプロバイダーを組み合わせる必要がある場合や、アダプターでのみ提供されるプロバイダー対応またはルーティングが必要な場合に使用します。アダプターは SDK と上流のモデルプロバイダーの間に互換性レイヤーを追加するため、機能のサポートとリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 ### Any-LLM -Any-LLM のサポートは、Any-LLM が管理するプロバイダーカバレッジまたはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 +Any-LLM のサポートは、Any-LLM が管理するプロバイダー対応またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として含まれています。 -上流のプロバイダー経路に応じて、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 +上流のプロバイダーパスに応じて、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 -Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡します。 +Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡してください。 -Any-LLM は引き続きサードパーティー製のアダプターレイヤーであるため、プロバイダーの依存関係や機能の差異は SDK ではなく、上流の Any-LLM によって定義されます。上流プロバイダーが使用量指標を返す場合、それらは自動的に伝播されますが、ストリーミング対応の Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +Any-LLM は引き続きサードパーティアダプターレイヤーであるため、プロバイダーの依存関係と機能上の不足は SDK ではなく、上流の Any-LLM によって定義されます。上流プロバイダーが使用量メトリクスを返す場合、それらは自動的に伝播されます。ただし、ストリーミングされる Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要な場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 ### LiteLLM -LiteLLM のサポートは、LiteLLM 固有のプロバイダーカバレッジまたはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 +LiteLLM のサポートは、LiteLLM 固有のプロバイダー対応またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として含まれています。 LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールし、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` モデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 -LiteLLM を基盤とする一部のプロバイダーは、デフォルトでは SDK の使用量指標を設定しません。使用量レポートが必要な場合は `ModelSettings(include_usage=True)` を渡し、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +LiteLLM アダプターを通じてアクセスする一部のプロバイダーは、デフォルトでは SDK の使用量メトリクスを設定しません。使用量レポートが必要な場合は `ModelSettings(include_usage=True)` を渡し、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 -LiteLLM がレスポンスオブジェクトに関する Pydantic シリアライザーの警告を生成する場合は、LiteLLM アダプターをインポートする前に SDK の互換性パッチを有効にできます。 +LiteLLM がレスポンスオブジェクトについて Pydantic シリアライザーの警告を出力する場合、LiteLLM アダプターをインポートする前に、SDK の互換性パッチをオプトインで有効にできます。 ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -このパッチはデフォルトで無効であり、値が `1` または `true` の場合にのみ有効になります。これは、LiteLLM の非公開ログヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。LiteLLM の非公開 API に依存しているため、LiteLLM をアップグレードするときは再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file +このパッチはデフォルトでは無効で、`1` または `true` の値に対してのみ有効になります。非公開の LiteLLM ロギングヘルパーをラップすることで、LiteLLM のレスポンスシリアライズに関する特定の種類の警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。非公開の LiteLLM APIに依存しているため、LiteLLM をアップグレードするときは再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file diff --git a/docs/ja/multi_agent.md b/docs/ja/multi_agent.md index 3e1f149b34..95abe8d70b 100644 --- a/docs/ja/multi_agent.md +++ b/docs/ja/multi_agent.md @@ -4,61 +4,61 @@ search: --- # エージェントオーケストレーション -オーケストレーションとは、アプリ内でのエージェントの流れを指します。どのエージェントを、どの順序で実行し、次に何が起こるかをどのように決定するか、ということです。エージェントをオーケストレーションする主な方法は 2 つあります: +オーケストレーションとは、アプリ内でのエージェントのフローを指します。どのエージェントをどの順序で実行し、次のステップをどのように決定するのでしょうか。エージェントオーケストレーションには、主に 2 つの方法があります。 -1. LLM に判断を任せる:LLM の知能を使って計画と推論を行い、それに基づいて取る手順を決定します。 -2. コードによるオーケストレーション:コードでエージェントの流れを決定します。 +1. LLM に判断を任せる方法:LLM の知能を活用して計画と推論を行い、それに基づいて実行するステップを決定します。 +2. コードによるオーケストレーション:コードを使用してエージェントのフローを決定します。 -これらのパターンは組み合わせることができます。それぞれにトレードオフがあり、以下で説明します。 +これらのパターンは組み合わせて使用できます。それぞれにトレードオフがあり、以下で説明します。 ## LLM によるオーケストレーション -エージェントとは、instructions、tools、ハンドオフを備えた LLM です。つまり、自由度の高いタスクが与えられた場合、LLM は、tools を使ってアクションを実行しデータを取得し、ハンドオフを使ってサブエージェントにタスクを委任しながら、そのタスクへの取り組み方を自律的に計画できます。たとえば、リサーチエージェントには次のようなツールを備えられます: +エージェントは、指示、ツール、ハンドオフを備えた LLM です。つまり、オープンエンドなタスクが与えられると、LLM はそのタスクへの取り組み方を自律的に計画できます。ツールを使用してアクションの実行やデータの取得を行い、ハンドオフを使用してサブエージェントにタスクを委任します。たとえば、リサーチエージェントには次のような機能を持たせることができます。 - オンラインで情報を見つけるための Web 検索 -- 独自データや接続先を検索するためのファイル検索と取得 +- 独自データや接続されたデータソースを検索するためのファイル検索と取得 - コンピューター上でアクションを実行するためのコンピュータ操作 - データ分析を行うためのコード実行 -- 計画、レポート作成などに優れた専門エージェントへのハンドオフ。 +- 計画やレポート作成などを得意とする専門エージェントへのハンドオフ -### コア SDK パターン +### SDK の主要パターン -Python SDK では、次の 2 つのオーケストレーションパターンが最もよく登場します: +Python SDK では、次の 2 つのオーケストレーションパターンが最もよく使用されます。 -| パターン | 仕組み | 最適な場合 | +| パターン | 仕組み | 最適な状況 | | --- | --- | --- | -| Agents as tools | マネージャーエージェントが会話の制御を維持し、`Agent.as_tool()` を通じて専門エージェントを呼び出します。 | 1 つのエージェントに最終回答を担わせたい場合、複数の専門エージェントからの出力を統合したい場合、または共通のガードレールを 1 か所で適用したい場合。 | -| ハンドオフ | トリアージエージェントが会話を専門エージェントにルーティングし、その専門エージェントがそのターンの残りでアクティブなエージェントになります。 | 専門エージェントに直接応答させたい場合、プロンプトを焦点の絞られた状態に保ちたい場合、またはマネージャーが結果を説明することなく instructions を切り替えたい場合。 | +| Agents as tools | マネージャーエージェントが会話の制御を維持し、`Agent.as_tool()` を通じて専門エージェントを呼び出します。 | 1 つのエージェントに最終回答を担当させる場合、複数の専門エージェントからの出力を統合する場合、または共通の SDK ガードレールを 1 か所で適用する場合。 | +| ハンドオフ | トリアージエージェントが会話を専門エージェントに振り分け、その専門エージェントがターンの残りの間、アクティブなエージェントになります。 | 専門エージェントに直接応答させる場合、プロンプトの焦点を絞る場合、またはマネージャーに実行結果を説明させることなく、ハンドオフによってアクティブな指示を切り替える場合。 | -専門エージェントが範囲の限定されたサブタスクを支援するべきだが、ユーザー向けの会話を引き継ぐべきではない場合は、 **agents as tools** を使用します。ルーティング自体がワークフローの一部であり、選ばれた専門エージェントにインタラクションの次の部分を担わせたい場合は、 **ハンドオフ** を使用します。 +専門エージェントに範囲が限定されたサブタスクを支援させつつ、ユーザーとの会話を引き継がせたくない場合は、 **agents as tools** を使用します。ルーティング自体がワークフローの一部であり、選択された専門エージェントに現在のターンの残りを担当させたい場合は、 **ハンドオフ** を使用します。 -この 2 つを組み合わせることもできます。トリアージエージェントが専門エージェントにハンドオフし、その専門エージェントがさらに狭いサブタスクのために他のエージェントをツールとして呼び出すこともできます。 +この 2 つを組み合わせることもできます。トリアージエージェントから専門エージェントにハンドオフし、その専門エージェントが限定的なサブタスクのために、さらに別のエージェントをツールとして呼び出すこともできます。 -このパターンは、タスクの自由度が高く、LLM の知能に頼りたい場合に最適です。ここで最も重要な戦術は次のとおりです: +このパターンは、タスクがオープンエンドであり、LLM の知能を活用したい場合に適しています。ここで最も重要な戦術は次のとおりです。 -1. 優れたプロンプトに投資します。利用できるツール、その使い方、そしてエージェントが従うべきパラメーターを明確にします。 -2. アプリを監視し、反復改善します。どこで問題が起こるかを確認し、プロンプトを改善します。 -3. エージェントが内省して改善できるようにします。たとえば、ループ内で実行して自己批評させる、またはエラーメッセージを提供して改善させます。 -4. 何でも得意であることを期待される汎用エージェントではなく、1 つのタスクに秀でた専門エージェントを用意します。 -5. [evals](https://platform.openai.com/docs/guides/evals) に投資します。これにより、エージェントをトレーニングして改善し、タスクの遂行能力を高めることができます。 +1. 優れたプロンプトの作成に注力します。利用可能なツール、その使用方法、エージェントが従う必要のある制約を明確にします。 +2. アプリを監視し、反復的に改善します。問題が発生している箇所を確認し、プロンプトを改善します。 +3. エージェントが自己評価して改善できるようにします。たとえば、エージェントをループで実行して自己批評させたり、エラーメッセージを提供して改善させたりします。 +4. あらゆるタスクをこなせることを期待した汎用エージェントではなく、1 つのタスクに秀でた専門エージェントを用意します。 +5. [evals](https://platform.openai.com/docs/guides/evals) に注力します。これにより、エージェントをトレーニングして、タスクの遂行能力を向上させることができます。 -このスタイルのオーケストレーションを支えるコア SDK の基本コンポーネントを知りたい場合は、[ツール](tools.md)、[ハンドオフ](handoffs.md)、[エージェントの実行](running_agents.md) から始めてください。 +このオーケストレーション方式の基盤となる SDK の基本コンポーネントについては、[ツール](tools.md)、[ハンドオフ](handoffs.md)、[エージェントの実行](running_agents.md)から参照してください。 ## コードによるオーケストレーション -LLM によるオーケストレーションは強力ですが、コードによるオーケストレーションは、速度、コスト、パフォーマンスの観点でタスクをより決定論的で予測可能にします。ここでの一般的なパターンは次のとおりです: +LLM によるオーケストレーションは強力ですが、コードによるオーケストレーションでは、速度、コスト、パフォーマンスの面でタスクをより決定論的かつ予測可能にできます。一般的なパターンは次のとおりです。 -- [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) を使って、コードで検査できる適切な形式のデータを生成します。たとえば、エージェントにタスクをいくつかのカテゴリーに分類させ、そのカテゴリーに基づいて次のエージェントを選択できます。 -- 複数のエージェントをチェーンし、あるエージェントの出力を次のエージェントの入力に変換します。ブログ記事を書くようなタスクを一連のステップに分解できます - リサーチする、アウトラインを書く、ブログ記事を書く、批評し、それから改善します。 -- タスクを実行するエージェントを `while` ループ内で、評価してフィードバックを提供するエージェントと一緒に実行し、評価者が出力が特定の基準を満たしたと言うまで続けます。 -- 複数のエージェントを並列に実行します。たとえば、`asyncio.gather` のような Python の基本コンポーネントを使います。これは、互いに依存しない複数のタスクがある場合に高速化に役立ちます。 +- [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) を使用して、コードで検査できる適切な形式のデータを生成します。たとえば、エージェントにタスクをいくつかのカテゴリーに分類させ、そのカテゴリーに基づいて次のエージェントを選択できます。 +- あるエージェントの出力を次のエージェントの入力に変換して、複数のエージェントを連結します。ブログ記事の執筆のようなタスクを、調査、アウトラインの作成、ブログ記事の執筆、批評、改善という一連のステップに分解できます。 +- `while` ループの各反復で、タスクエージェントを実行して出力を生成し、次に評価エージェントを実行してその出力を評価し、フィードバックを提供します。評価エージェントが、出力が必要な基準を満たしたと判断した時点で停止します。 +- 複数のエージェントを並列に実行します。たとえば、`asyncio.gather` のような Python の基本コンポーネントを使用します。これは、互いに依存しない複数のタスクがある場合に、処理を高速化するうえで役立ちます。 -[`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns) には多数のコード例があります。 +[`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns) には、多数のコード例があります。 ## 関連ガイド -- 構成パターンとエージェント設定については、[エージェント](agents.md) を参照してください。 -- `Agent.as_tool()` とマネージャースタイルのオーケストレーションについては、[ツール](tools.md#agents-as-tools) を参照してください。 -- 専門エージェント間の委任については、[ハンドオフ](handoffs.md) を参照してください。 -- 実行ごとのオーケストレーション制御と会話状態については、[エージェントの実行](running_agents.md) を参照してください。 -- 最小限のエンドツーエンドのハンドオフ例については、[クイックスタート](quickstart.md) を参照してください。 \ No newline at end of file +- 構成パターンとエージェント設定については、[エージェント](agents.md)を参照してください。 +- `Agent.as_tool()` とマネージャー方式のオーケストレーションについては、[ツール](tools.md#agents-as-tools)を参照してください。 +- 専門エージェント間の委任については、[ハンドオフ](handoffs.md)を参照してください。 +- 実行ごとのオーケストレーション制御と会話状態については、[エージェントの実行](running_agents.md)を参照してください。 +- 最小限のエンドツーエンドのハンドオフ例については、[クイックスタート](quickstart.md)を参照してください。 \ No newline at end of file diff --git a/docs/ja/realtime/guide.md b/docs/ja/realtime/guide.md index 95d3c2ad3a..e19d09aff2 100644 --- a/docs/ja/realtime/guide.md +++ b/docs/ja/realtime/guide.md @@ -4,48 +4,48 @@ search: --- # リアルタイムエージェントガイド -このガイドでは、OpenAI Agents SDK のリアルタイムレイヤーが OpenAI Realtime API にどのように対応しているか、また Python SDK がその上にどのような追加動作を提供するかを説明します。 +このガイドでは、OpenAI Agents SDK のリアルタイムレイヤーと OpenAI Realtime API の対応関係、および Python SDK が追加する動作について説明します。 -!!! note "まずはこちら" +!!! note "最初にお読みください" - デフォルトの Python の利用方法を確認する場合は、まず [クイックスタート](quickstart.md)をお読みください。アプリでサーバー側の WebSocket と SIP のどちらを使用すべきか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK には含まれていません。 + デフォルトの Python の手順を使用する場合は、最初に[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用するか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれません。 ## 概要 -リアルタイムエージェントは Realtime API への長時間接続を維持します。これにより、モデルはテキストと音声を逐次処理し、音声出力をストリーミングし、ツールを呼び出し、ターンごとに新しいリクエストを開始し直すことなく中断を処理できます。 +リアルタイムエージェントは Realtime API への長時間接続を維持するため、モデルはターンごとに新しいリクエストを開始し直すことなく、テキストと音声を段階的に処理し、音声出力をストリーミングし、ツールを呼び出し、中断を処理できます。 -主な SDK コンポーネントは次のとおりです。 +SDK の主なコンポーネントは次のとおりです。 -- **RealtimeAgent**: 1 つのリアルタイム専門エージェントに対する指示、ツール、出力ガードレール、ハンドオフ +- **RealtimeAgent**: 1 つのリアルタイムスペシャリストに対する指示、ツール、出力ガードレール、ハンドオフ - **RealtimeRunner**: 開始エージェントをリアルタイムトランスポートに接続するセッションファクトリー - **RealtimeSession**: 入力の送信、イベントの受信、履歴の追跡、ツールの実行を行うライブセッション - **RealtimeModel**: トランスポートの抽象化。デフォルトは OpenAI のサーバー側 WebSocket 実装です。 ## セッションのライフサイクル -一般的なリアルタイムセッションは次のようになります。 +一般的なリアルタイムセッションの流れは次のとおりです。 1. 1 つ以上の `RealtimeAgent` を作成します。 2. 開始エージェントを指定して `RealtimeRunner` を作成します。 -3. `await runner.run()` を呼び出して `RealtimeSession` を取得します。 +3. `RealtimeSession` を取得するために `await runner.run()` を呼び出します。 4. `async with session:` または `await session.enter()` を使用してセッションに入ります。 5. `send_message()` または `send_audio()` を使用してユーザー入力を送信します。 6. 会話が終了するまでセッションイベントを反復処理します。 -テキストのみの実行とは異なり、`runner.run()` は最終実行結果をすぐには生成しません。代わりに、ローカル履歴、バックグラウンドでのツール実行、ガードレールの状態、アクティブなエージェント設定をトランスポートレイヤーと同期し続けるライブセッションオブジェクトを返します。 +テキストのみの実行とは異なり、`runner.run()` は最終的な実行結果をすぐには生成しません。代わりに、ローカル履歴、バックグラウンドでのツール実行、ガードレールの状態、アクティブなエージェント設定をトランスポートレイヤーと同期し続けるライブセッションオブジェクトを返します。 -デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、デフォルトの Python の利用方法では Realtime API へのサーバー側 WebSocket 接続が使用されます。別の `RealtimeModel` を渡した場合でも、接続の仕組みを変更しつつ、同じセッションライフサイクルとエージェント機能を利用できます。 +デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、デフォルトの Python の手順では Realtime API へのサーバー側 WebSocket 接続が使用されます。別の `RealtimeModel` を渡した場合も、接続方法は変更できますが、同じセッションライフサイクルとエージェント機能が適用されます。 ## エージェントとセッションの設定 -`RealtimeAgent` は通常の `Agent` 型よりも意図的に機能範囲が限定されています。 +`RealtimeAgent` は、通常の `Agent` 型よりも意図的に対象範囲が限定されています。 -- モデルの選択はエージェント単位ではなく、セッションレベルで設定します。 -- Structured outputs はサポートされていません。 -- 音声は設定できますが、セッションが音声を生成した後は変更できません。 -- 指示、関数ツール、ハンドオフ、フック、出力ガードレールはすべて引き続き利用できます。 +- モデルの選択は、エージェント単位ではなくセッションレベルで設定します。 +- structured outputs はサポートされていません。 +- 音声は設定できますが、セッションが音声を一度生成した後は変更できません。 +- 指示、関数ツール、ハンドオフ、フック、出力ガードレールは引き続きすべて機能します。 -`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と従来のフラットなエイリアスの両方をサポートします。新しいコードではネスト形式を使用し、新しいリアルタイムエージェントには `gpt-realtime-2.1` を使用することを推奨します。 +`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と、従来のフラットなエイリアスの両方をサポートします。新しいコードではネスト形式を推奨します。また、新しいリアルタイムエージェントでは `gpt-realtime-2.1` から始めてください。 ```python runner = RealtimeRunner( @@ -67,19 +67,19 @@ runner = RealtimeRunner( ) ``` -便利なセッションレベルの設定には、次のものがあります。 +便利なセッションレベルの設定には次のものがあります。 -- `audio.input.format`, `audio.output.format` +- `audio.input.format`、`audio.output.format` - `audio.input.transcription` - `audio.input.noise_reduction` - `audio.input.turn_detection` -- `audio.output.voice`, `audio.output.speed` +- `audio.output.voice`、`audio.output.speed` - `output_modalities` - `tool_choice` - `prompt` - `tracing` -`RealtimeRunner(config=...)` で使用できる便利な実行レベルの設定には、次のものがあります。 +`RealtimeRunner(config=...)` で利用できる便利な実行レベルの設定には次のものがあります。 - `async_tool_calls` - `output_guardrails` @@ -87,13 +87,13 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -型付けされた設定項目の全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 +型付けされた API 全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]を参照してください。 ## 入出力 ### テキストと構造化ユーザーメッセージ -プレーンテキストまたは構造化されたリアルタイムメッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] を使用します。 +プレーンテキストまたは構造化されたリアルタイムメッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]を使用します。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +111,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -構造化メッセージは、リアルタイム会話に画像入力を含めるための主な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) の Web デモ例では、この方法で `input_image` メッセージを転送します。 +構造化メッセージは、リアルタイムの会話に画像入力を含めるための主な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)の Web デモのコード例では、この方法で `input_image` メッセージを転送します。 ### 音声入力 -生の音声バイトをストリーミングするには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用します。 +raw 音声バイトをストリーミングするには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]を使用します。 ```python await session.send_audio(audio_bytes) ``` -サーバー側のターン検出を無効にしている場合は、ターンの境界を自身で指定する必要があります。高レベルの便利な方法は次のとおりです。 +サーバー側のターン検出が無効な場合は、ターンの境界を自分で指定する必要があります。高レベルの便利な方法は次のとおりです。 ```python await session.send_audio(audio_bytes, commit=True) ``` -より低レベルの制御が必要な場合は、基盤となるモデルトランスポートを介して `input_audio_buffer.commit` などの生のクライアントイベントを送信することもできます。 +より低レベルの制御が必要な場合は、基盤となるモデルトランスポートを通じて、`input_audio_buffer.commit` などの Realtime API クライアントイベントを直接送信することもできます。 ### 手動レスポンス制御 -`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを開始します。生の音声バッファリングでは、すべての設定で同じ処理が **自動的に行われるわけではありません**。 +`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを自動的に開始します。一部の設定では、raw 音声のバッファリングだけでは同じ動作が**自動的には**行われません。 -Realtime API レベルでターンを手動制御するには、生の `session.update` で `turn_detection` をクリアし、その後に `input_audio_buffer.commit` と `response.create` を自身で送信します。 +Realtime API レベルでターンを手動制御するには、`turn_detection` を `null` に設定する `session.update` イベントを送信し、その後に `input_audio_buffer.commit` と `response.create` を自分で送信します。 -ターンを手動で管理する場合は、モデルトランスポートを介して生のクライアントイベントを送信できます。 +ターンを手動で管理する場合は、モデルトランスポートを通じて raw クライアントイベントを送信できます。 ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -151,35 +151,35 @@ await session.model.send_event( このパターンは、次の場合に役立ちます。 -- `turn_detection` が無効で、モデルが応答するタイミングを自身で決定したい場合 -- レスポンスを開始する前にユーザー入力を検査または制限したい場合 -- 会話外のレスポンスにカスタムプロンプトが必要な場合 +- `turn_detection` が無効で、モデルが応答するタイミングを決定したい場合 +- レスポンスをトリガーする前に、ユーザー入力を検査または制御したい場合 +- 帯域外レスポンスにカスタムプロンプトが必要な場合 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) の SIP の例では、生の `response.create` を使用して最初の挨拶を強制的に生成します。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)の SIP のコード例では、最初の挨拶を強制するために raw `response.create` を使用しています。 ## イベント、履歴、中断 -`RealtimeSession` は、必要に応じて生のモデルイベントも転送しながら、より高レベルな SDK イベントを発行します。 +`RealtimeSession` は高レベルの SDK イベントを生成しつつ、必要に応じて raw モデルイベントも転送します。 -特に重要なセッションイベントには、次のものがあります。 +特に重要なセッションイベントには次のものがあります。 -- `audio`, `audio_end`, `audio_interrupted` -- `agent_start`, `agent_end` -- `tool_start`, `tool_end`, `tool_approval_required` +- `audio`、`audio_end`、`audio_interrupted` +- `agent_start`、`agent_end` +- `tool_start`、`tool_end`、`tool_approval_required` - `handoff` -- `history_added`, `history_updated` +- `history_added`、`history_updated` - `guardrail_tripped` - `input_audio_timeout_triggered` - `error` - `raw_model_event` -UI の状態に最も有用なイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しを含むセッションのローカル履歴を `RealtimeItem` オブジェクトとして公開します。 +UI の状態管理に最も役立つイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しを含むセッションのローカル履歴を、`RealtimeItem` オブジェクトとして公開します。 ### 使用量の集計 -完了したモデルレスポンスに使用量が含まれている場合、OpenAI のリアルタイムモデルは `raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] を発行します。その `usage` フィールドには当該レスポンスのトークン数が含まれ、`input_tokens_details` と `output_tokens_details` ではモダリティ別の内訳がオプションで提供されます。 +完了したモデルレスポンスに使用量が含まれている場合、SDK の OpenAI `RealtimeModel` トランスポートは、`raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]を生成します。その `usage` フィールドには、そのレスポンスのトークン数が含まれ、`input_tokens_details` と `output_tokens_details` には任意のモダリティ別内訳が含まれます。 -また、セッションは各レスポンスの使用量を共有の [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] に加算します。ライブセッションの累積使用量を確認するには、`agent_end` など、後続の高レベルイベントにある `event.info.context.usage` から読み取ります。 +また、セッションは各レスポンスの使用量を共有の [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]に追加します。ライブセッションの累積使用量を確認するには、`agent_end` など、その後に発生する高レベルイベントの `event.info.context.usage` から読み取ります。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,15 +197,15 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めている場合にのみ報告されます。累積値の対象は、その `RealtimeSession` が受信したレスポンスです。複数のセッションをまたぐ合計値ではありません。 +使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めた場合にのみ報告されます。累積値は、その `RealtimeSession` が受信したレスポンスを対象とし、複数のセッションをまたぐ合計ではありません。 -### 中断と再生追跡 +### 中断と再生位置の追跡 -ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を発行し、サーバー側の会話がユーザーに実際に聞こえた内容と一致するように履歴を更新します。 +ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を生成し、ユーザーが実際に聞いた内容とサーバー側の会話が一致するように履歴を更新します。 -低遅延のローカル再生では、通常、デフォルトの再生トラッカーで十分です。リモート再生や遅延再生、特にテレフォニーのシナリオでは、生成されたすべての音声がすでに再生されたと仮定するのではなく、実際の再生進捗に基づいて中断時の切り詰めを行うために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] を使用します。 +低遅延のローカル再生では、多くの場合、デフォルトの再生トラッカーで十分です。リモート再生や遅延再生のシナリオ、特に電話通信では、生成済みの音声がすべて再生されたと仮定するのではなく、実際の再生位置で中断されたレスポンスを切り詰めるために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]を使用します。 -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) の Twilio の例で、このパターンを確認できます。 +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)の Twilio のコード例で、このパターンを確認できます。 ## ツール、承認、ハンドオフ、ガードレール @@ -232,9 +232,9 @@ agent = RealtimeAgent( ### ツールの承認 -関数ツールでは、実行前に人間の承認を必須にできます。その場合、セッションは `tool_approval_required` を発行し、`approve_tool_call()` または `reject_tool_call()` が呼び出されるまでツール実行を一時停止します。 +関数ツールでは、実行前に人間による承認を必須にできます。その場合、セッションは `tool_approval_required` を生成し、`approve_tool_call()` または `reject_tool_call()` を呼び出すまでツールの実行を一時停止します。 -ツールに入力ガードレールも設定されている場合、それらのガードレールは承認後、実行直前に実行されます。承認イベントが発行される前に実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を使用してランナーを作成します。この承認前チェックに合格した呼び出しは、承認後、実行前に再度チェックされます。 +ツールに入力ガードレールも設定されている場合、それらのガードレールは承認後、実行直前に動作します。承認イベントが生成される前に実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を指定してランナーを作成します。この承認前チェックに合格した呼び出しも、承認後かつ実行前に再度チェックされます。 ```python async for event in session: @@ -242,11 +242,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) を参照してください。ヒューマンインザループのドキュメントにある[ヒューマンインザループ](../human_in_the_loop.md)でも、このフローを参照しています。 +具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)を参照してください。Human-in-the-loop のドキュメントでも、[Human-in-the-loop](../human_in_the_loop.md)でこのフローを参照しています。 ### ハンドオフ -リアルタイムハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を転送できます。 +リアルタイムハンドオフでは、あるエージェントから別のスペシャリストへライブ会話を引き継ぐことができます。 ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +268,11 @@ main_agent = RealtimeAgent( ) ``` -単体の `RealtimeAgent` を指定したハンドオフは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。リアルタイムハンドオフは、通常のハンドオフの `input_filter` を **サポートしていません**。 +ハンドオフとして直接使用される `RealtimeAgent` オブジェクトは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。リアルタイムハンドオフは、通常のハンドオフの `input_filter` をサポートして**いません**。 ### ガードレール -リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートします。出力ガードレールは、部分的な差分ごとではなく、出力テキストと音声文字起こしの差分をデバウンスして蓄積した単位で実行され、例外を発生させる代わりに `guardrail_tripped` を発行します。 +リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートします。出力ガードレールのチェックにはデバウンスが適用されます。各チェックは部分的な差分ごとではなく、蓄積された出力テキストと音声文字起こしの差分に対して実行され、例外を送出する代わりに `guardrail_tripped` を生成します。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,15 +292,15 @@ agent = RealtimeAgent( ) ``` -音声文字起こしに対してリアルタイム出力ガードレールが作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制実行して `guardrail_tripped` を発行します。さらに、作動したガードレールの名前を含む後続のユーザーメッセージを送信し、モデルが代替レスポンスを生成できるようにします。トリップワイヤーが作動した時点で一部の音声がすでにバッファリングされている可能性があるため、音声プレイヤーでは引き続き `audio_interrupted` を監視し、ローカル再生を直ちに停止する必要があります。組み込みの OpenAI Realtime トランスポートでは、ガードレールの処理が元のレスポンスの終了後に完了した場合、そのレスポンスのバッファリング済み再生のみを中断し、それより新しいレスポンスはキャンセルしません。テキストのみの出力では、セッションは代わりにレスポンス単位の `response.cancel` を送信します。停止すべき音声再生がないため、`audio_interrupted` は発行しません。組み込みの OpenAI Realtime モデルを使用している場合、テキストのみの経路でも同じ `guardrail_tripped` イベントと後続のユーザーメッセージが発行されます。 +リアルタイム出力ガードレールが音声文字起こしに対して作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制し、`guardrail_tripped` を生成して、作動したガードレールの名前を含むフォローアップのユーザーメッセージを送信します。これにより、モデルは代替レスポンスを生成できます。トリップワイヤーが作動した時点ですでに一部の音声がバッファリングされている可能性があるため、音声プレーヤーは引き続き `audio_interrupted` を監視し、ローカル再生を直ちに停止する必要があります。組み込みの OpenAI Realtime トランスポートでは、チェック対象のレスポンスが終了した後にガードレールチェックが完了した場合、セッションはそのレスポンスのバッファリング済み再生のみを中断し、後から開始されたレスポンスはキャンセルしません。テキストのみの出力では、代わりにレスポンス単位の `response.cancel` が送信されます。停止すべき音声再生がないため、`audio_interrupted` は生成されません。組み込みの OpenAI Realtime モデルを使用している場合、テキストのみの経路でも、同じ `guardrail_tripped` イベントとフォローアップのユーザーメッセージが生成されます。 -カスタム `RealtimeModel` トランスポートは、同じように元のレスポンス単位で音声を中断できるよう、`RealtimeModelSendInterrupt.response_id` と `playback_only` の指定に従う必要があります。また、テキストのみの復旧メッセージをサポートするには、`RealtimeModel.send_event_if()` もオーバーライドする必要があります。実装では、トランスポートが実際にイベントをコミットする境界で、指定された条件を再確認するか、その条件の処理を直列化する必要があります。デフォルト実装は復旧メッセージを安全にスキップします。これは、`send_event()` を待機する前に条件を確認すると、メッセージがコミットされる前に新しいレスポンスが開始される可能性があるためです。レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 +カスタム `RealtimeModel` トランスポートで同じ発生元レスポンス単位の音声中断動作を実現するには、`RealtimeModelSendInterrupt.response_id` と `playback_only` に従う必要があります。また、テキストのみの出力経路で復旧メッセージをサポートするには、`RealtimeModel.send_event_if()` をオーバーライドする必要があります。実装では、トランスポートが実際にイベントをコミットする境界で指定された条件を再確認するか、条件チェックとイベントのコミットをまとめて直列化する必要があります。デフォルト実装は、復旧メッセージを安全にスキップします。条件を一度チェックしてからイベントを別途送信すると、条件チェックとイベントのコミットの間に別のレスポンスが開始される可能性があるためです。ただし、レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 -## SIP とテレフォニー +## SIP と電話通信 -Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] を介したファーストクラスの SIP アタッチフローが含まれています。 +Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]を通じた正式サポートの SIP アタッチフローが含まれています。 -Realtime Calls API を介して通話を受信し、生成された `call_id` にエージェントセッションをアタッチする場合に使用します。 +Realtime Calls API を通じて着信があり、その結果生成された `call_id` にエージェントセッションをアタッチする場合に使用します。 ```python from agents.realtime import RealtimeRunner @@ -317,20 +317,20 @@ async with await runner.run( ... ``` -最初に通話を受け入れる必要があり、受け入れ時のペイロードをエージェントから派生したセッション設定と一致させたい場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) に示されています。 +先に通話を受け付け、受付時のペイロードをエージェントから導出されたセッション設定と一致させる必要がある場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)で確認できます。 ## 低レベルアクセスとカスタムエンドポイント -基盤となるトランスポートオブジェクトには、`session.model` を介してアクセスできます。 +`session.model` を通じて、基盤となるトランスポートオブジェクトにアクセスできます。 -次の処理が必要な場合に使用します。 +これは、次のものが必要な場合に使用します。 -- `session.model.add_listener(...)` を介したカスタムリスナー -- `response.create` や `session.update` などの生のクライアントイベント -- `model_config` を介したカスタムの `url`、`headers`、`api_key` の処理 +- `session.model.add_listener(...)` を使用したカスタムリスナー +- `response.create` や `session.update` などの raw クライアントイベント +- `model_config` を通じたカスタムの `url`、`headers`、`api_key` の処理 - 既存のリアルタイム通話への `call_id` のアタッチ -`RealtimeModelConfig` は次の項目をサポートします。 +`RealtimeModelConfig` は次のものをサポートします。 - `api_key` - `url` @@ -339,9 +339,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -このリポジトリに含まれる `call_id` の例は SIP です。より広範な Realtime API でも、一部のサーバー側制御フローで `call_id` が使用されますが、ここでは Python の例として提供されていません。 +このリポジトリに同梱されている `call_id` のコード例は SIP です。より広範な Realtime API でも一部のサーバー側制御フローに `call_id` が使用されますが、ここでは Python のコード例としてパッケージ化されていません。 -Azure OpenAI に接続する場合は、GA 版の Realtime エンドポイント URL と明示的なヘッダーを渡します。例: +Azure OpenAI に接続する場合は、GA の Realtime エンドポイント URL と明示的なヘッダーを渡します。次に例を示します。 ```python session = await runner.run( @@ -363,9 +363,9 @@ session = await runner.run( ) ``` -`headers` を渡した場合、SDK は `Authorization` を自動的に追加しません。リアルタイムエージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を使用しないでください。 +`headers` を渡した場合、SDK は `Authorization` を自動的には追加しません。リアルタイムエージェントでは、従来のベータ版のパス(`/openai/realtime?api-version=...`)を使用しないでください。 -## 関連情報 +## 関連資料 - [リアルタイムトランスポート](transport.md) - [クイックスタート](quickstart.md) diff --git a/docs/ja/realtime/quickstart.md b/docs/ja/realtime/quickstart.md index c2d93a5fa4..34703627f0 100644 --- a/docs/ja/realtime/quickstart.md +++ b/docs/ja/realtime/quickstart.md @@ -4,21 +4,21 @@ search: --- # クイックスタート -Python SDK のリアルタイムエージェントは、WebSocket トランスポート経由の OpenAI Realtime API 上に構築された、サーバー側の低レイテンシーエージェントです。 +Python SDK のリアルタイムエージェントは、WebSocket トランスポート経由の OpenAI Realtime APIを基盤とする、サーバー側で動作する低レイテンシーのエージェントです。 !!! note "Python SDK の境界" - Python SDK は、ブラウザーの WebRTC トランスポートを **提供していません** 。このページでは、サーバー側 WebSocket を介して Python で管理されるリアルタイムセッションのみを扱います。この SDK は、サーバー側のオーケストレーション、ツール、承認、テレフォニー連携に使用してください。併せて [リアルタイムトランスポート](transport.md) も参照してください。 + Python SDK は、ブラウザー向け WebRTC トランスポートを **提供しません** 。このページでは、サーバー側の WebSocket を介して Python で管理されるリアルタイムセッションのみを扱います。この SDK は、サーバー側のオーケストレーション、ツール、承認、テレフォニー統合に使用してください。[リアルタイムトランスポート](transport.md)も参照してください。 ## 前提条件 -- Python 3.10 以上 +- Python 3.10 以降 - OpenAI API キー -- OpenAI Agents SDK の基本的な知識 +- OpenAI Agents SDKの基本的な知識 ## インストール -まだインストールしていない場合は、OpenAI Agents SDK をインストールしてください: +まだインストールしていない場合は、OpenAI Agents SDKをインストールします。 ```bash pip install openai-agents @@ -45,7 +45,7 @@ agent = RealtimeAgent( ### 3. ランナーの設定 -新しいコードでは、ネストされた `audio.input` / `audio.output` のセッション設定形式を推奨します。新しいリアルタイムエージェントでは、`gpt-realtime-2.1` から始めてください。 +新しいコードでは、ネストされた `audio.input` / `audio.output` セッション設定形式を推奨します。新しいリアルタイムエージェントでは、`gpt-realtime-2.1` から始めてください。 ```python runner = RealtimeRunner( @@ -74,7 +74,7 @@ runner = RealtimeRunner( ### 4. セッションの開始と入力の送信 -`runner.run()` は `RealtimeSession` を返します。セッションコンテキストに入ると接続が開かれます。 +`runner.run()` は `RealtimeSession` を返します。セッションコンテキストに入ると、接続が開かれます。 ```python async def main() -> None: @@ -100,59 +100,59 @@ if __name__ == "__main__": asyncio.run(main()) ``` -`session.send_message()` は、プレーン文字列または構造化されたリアルタイムメッセージのいずれかを受け取ります。生の音声チャンクには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用してください。 +`session.send_message()` は、プレーン文字列または構造化されたリアルタイムメッセージを受け付けます。raw オーディオチャンクには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用してください。 -## このクイックスタートに含まれない内容 +## 本クイックスタートの対象外 -- マイクのキャプチャおよびスピーカー再生のコード。[`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) のリアルタイムのコード例を参照してください。 -- SIP / テレフォニーのアタッチフロー。[リアルタイムトランスポート](transport.md) と [SIP セクション](guide.md#sip-and-telephony) を参照してください。 +- マイク入力とスピーカー再生のコード。[`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) のリアルタイムコード例を参照してください。 +- SIP / テレフォニーの接続フロー。[リアルタイムトランスポート](transport.md)および [SIP セクション](guide.md#sip-and-telephony)を参照してください。 -## 主要設定 +## 主要な設定 -基本的なセッションが動作するようになったら、多くの方が次に利用する設定は次のとおりです: +基本的なセッションが動作した後、多くの場合に次に使用される設定は以下のとおりです。 - `model_name` - `audio.input.format`, `audio.output.format` - `audio.input.transcription` - `audio.input.noise_reduction` -- `audio.input.turn_detection`(自動ターン検出用) +- 自動ターン検出用の `audio.input.turn_detection` - `audio.output.voice` - `tool_choice`, `prompt`, `tracing` - `async_tool_calls`, `tool_execution.pre_approval_tool_input_guardrails`, `guardrails_settings.debounce_text_length`, `tool_error_formatter` -`input_audio_format`、`output_audio_format`、`input_audio_transcription`、`turn_detection` などの古いフラットなエイリアスも引き続き機能しますが、新しいコードではネストされた `audio` 設定が推奨されます。 +`input_audio_format`、`output_audio_format`、`input_audio_transcription`、`turn_detection` などの従来のフラットなエイリアスも引き続き機能しますが、新しいコードではネストされた `audio` 設定を推奨します。 -手動のターン制御には、[リアルタイムエージェントガイド](guide.md#manual-response-control) で説明されている raw な `session.update` / `input_audio_buffer.commit` / `response.create` フローを使用してください。 +ターンを手動で制御するには、[リアルタイムエージェントガイド](guide.md#manual-response-control)で説明されている低レベルの `session.update` / `input_audio_buffer.commit` / `response.create` フローを使用してください。 完全なスキーマについては、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 ## 接続オプション -API キーを環境変数に設定してください: +環境変数に API キーを設定します。 ```bash export OPENAI_API_KEY="your-api-key-here" ``` -または、セッションの開始時に直接渡します: +または、セッションの開始時に直接渡します。 ```python session = await runner.run(model_config={"api_key": "your-api-key"}) ``` -`model_config` は以下にも対応しています: +`model_config` は、以下もサポートしています。 - `url`: カスタム WebSocket エンドポイント - `headers`: カスタムリクエストヘッダー -- `call_id`: 既存のリアルタイムコールにアタッチします。このリポジトリでドキュメント化されているアタッチフローは SIP です。 -- `playback_tracker`: ユーザーが実際に聞いた音声量を報告します +- `call_id`: 既存のリアルタイム通話への接続。このリポジトリで文書化されている接続フローは SIP です。 +- `playback_tracker`: ユーザーが実際に聞いたオーディオ量の報告 -`headers` を明示的に渡す場合、SDK は `Authorization` ヘッダーを **挿入しません** 。 +`headers` を明示的に渡した場合、SDK は `Authorization` ヘッダーを自動的に **挿入しません** 。 -Azure OpenAI に接続する場合は、`model_config["url"]` に GA Realtime エンドポイント URL を指定し、明示的なヘッダーも渡してください。リアルタイムエージェントでは、レガシーな beta パス(`/openai/realtime?api-version=...`)の使用は避けてください。詳細については、[リアルタイムエージェントガイド](guide.md#low-level-access-and-custom-endpoints) を参照してください。 +Azure OpenAIに接続する場合は、`model_config["url"]` を GA 版 Realtime エンドポイント URL に設定し、ヘッダーを明示的に渡してください。リアルタイムエージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を避けてください。詳細については、[リアルタイムエージェントガイド](guide.md#low-level-access-and-custom-endpoints)を参照してください。 ## 次のステップ -- サーバー側 WebSocket と SIP のどちらを選ぶかを判断するには、[リアルタイムトランスポート](transport.md) をお読みください。 -- ライフサイクル、構造化入力、承認、ハンドオフ、ガードレール、低レベル制御については、[リアルタイムエージェントガイド](guide.md) をお読みください。 -- [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) のコード例をご覧ください。 \ No newline at end of file +- サーバー側 WebSocket と SIP のどちらを使用するか選択するには、[リアルタイムトランスポート](transport.md)をお読みください。 +- ライフサイクル、構造化入力、承認、ハンドオフ、ガードレール、低レベル制御については、[リアルタイムエージェントガイド](guide.md)をお読みください。 +- [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) のコード例を参照してください。 \ No newline at end of file diff --git a/docs/ja/realtime/transport.md b/docs/ja/realtime/transport.md index 77b8e99306..c887ff3adb 100644 --- a/docs/ja/realtime/transport.md +++ b/docs/ja/realtime/transport.md @@ -4,42 +4,42 @@ search: --- # リアルタイムトランスポート -リアルタイムエージェントを Python アプリケーションにどのように組み込むかを判断するには、このページを使用してください。 +リアルタイムエージェントを Python アプリケーションにどのように組み込むかを判断する際は、このページを参照してください。 -!!! note "Python SDK の対象範囲" +!!! note "Python SDK の境界" - Python SDK には、ブラウザー向け WebRTC トランスポートは **含まれていません** 。このページでは、Python SDK におけるトランスポートの選択肢である、サーバーサイド WebSocket と SIP アタッチフローのみを扱います。ブラウザー WebRTC は別のプラットフォームトピックです。詳細については、公式の [WebRTC を使用した Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) ガイドを参照してください。 + Python SDK には、ブラウザー向け WebRTC トランスポートは **含まれていません** 。このページでは、Python SDK のトランスポートの選択肢である、サーバー側 WebSocket と SIP 接続フローのみを扱います。ブラウザー WebRTC は別のプラットフォームトピックであり、公式の [WebRTC を使用する Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) ガイドに記載されています。 -## 判断ガイド +## 選択ガイド | 目的 | 最初に参照するもの | 理由 | | --- | --- | --- | -| サーバー管理型のリアルタイムアプリを構築する | [クイックスタート](quickstart.md) | Python のデフォルト経路は、`RealtimeRunner` が管理するサーバーサイド WebSocket セッションです。 | -| 選択すべきトランスポートとデプロイ形態を理解する | このページ | トランスポートまたはデプロイ形態を決定する前に、このページを参照してください。 | -| エージェントを電話または SIP 通話にアタッチする | [リアルタイムガイド](guide.md)および [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | このリポジトリには、`call_id` によって駆動される SIP アタッチフローが含まれています。 | +| サーバー管理型のリアルタイムアプリを構築する | [クイックスタート](quickstart.md) | Python のデフォルトパスは、`RealtimeRunner` によって管理されるサーバー側 WebSocket セッションです。 | +| 選択すべきトランスポートとデプロイ構成を理解する | このページ | トランスポートまたはデプロイ構成を決定する前に、このページを参照してください。 | +| エージェントを電話または SIP 通話に接続する | [リアルタイムガイド](guide.md)および [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | このリポジトリには、`call_id` によって駆動される SIP 接続フローが含まれています。 | -## Python のデフォルト経路としてのサーバーサイド WebSocket +## Python のデフォルトパスとなるサーバー側 WebSocket カスタムの `RealtimeModel` を渡さない限り、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用します。 -つまり、標準的な Python トポロジーは次のようになります。 +したがって、標準的な Python トポロジーは次のようになります。 1. Python サービスが `RealtimeRunner` を作成します。 2. `await runner.run()` が `RealtimeSession` を返します。 -3. セッションに入り、テキスト、構造化メッセージ、または音声を送信します。 +3. `RealtimeSession` を非同期コンテキストマネージャーとして開始し、テキスト、構造化メッセージ、または音声を送信します。 4. `RealtimeSessionEvent` の項目を処理し、音声または文字起こしをアプリケーションに転送します。 -このトポロジーは、コアデモアプリ、CLI のコード例、および Twilio Media Streams のコード例で使用されています。 +これは、コアデモアプリ、CLI のコード例、および Twilio Media Streams のコード例で使用されているトポロジーです。 - [`examples/realtime/app`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app) - [`examples/realtime/cli`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/cli) - [`examples/realtime/twilio`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio) -サーバーが音声パイプライン、ツール実行、承認フロー、および履歴処理を管理する場合は、この経路を使用してください。 +サーバーが音声パイプライン、ツール実行、承認フロー、および履歴処理を担う場合は、このパスを使用してください。 -### 低レベル WebSocket チューニング +### 低レベル WebSocket の調整 -基盤となるサーバーサイド WebSocket 接続を調整する必要がある場合は、`transport_config` を `OpenAIRealtimeWebSocketModel` に渡します。 +基盤となるサーバー側 WebSocket 接続を調整する必要がある場合は、`OpenAIRealtimeWebSocketModel` に `transport_config` を渡します。 ```python from agents.realtime import ( @@ -60,49 +60,49 @@ model = OpenAIRealtimeWebSocketModel( runner = RealtimeRunner(starting_agent=agent, model=model) ``` -サポートされるオプションは次のとおりです。 +サポートされているオプションは次のとおりです。 -- `ping_interval`: クライアントのキープアライブ ping の間隔(秒)。ping を無効にするには `None` を設定します。 -- `ping_timeout`: 切断するまで pong を待機する秒数。ハートビートタイムアウトを発生させずに pong の遅延を許容するには、`None` を設定します。 -- `handshake_timeout`: 最初の接続ハンドシェイクを待機する秒数。 -- `max_size`: 受信する WebSocket メッセージの最大サイズ(バイト単位)。SDK のデフォルトは `None` で、受信メッセージのサイズは無制限になります。メッセージ単位のメモリ使用量を制限する必要がある場合は、明示的な上限を設定します。 +- `ping_interval`: クライアントのキープアライブ ping 間隔(秒)です。ping を無効にするには、`None` に設定します。 +- `ping_timeout`: 切断するまで pong を待機する秒数です。ハートビートのタイムアウトを発生させずに pong の遅延を許容するには、`None` に設定します。 +- `handshake_timeout`: 最初の接続ハンドシェイクを待機する秒数です。 +- `max_size`: 受信 WebSocket メッセージの最大サイズ(バイト)です。SDK のデフォルトは `None` で、受信メッセージのサイズは無制限になります。メッセージごとのメモリ使用量を制限する必要がある場合は、明示的な上限を設定してください。 -これらの設定は、Realtime API セッションではなく、クライアント接続を構成します。エンドポイント、認証、通話のアタッチ、および再生設定には、引き続き `RealtimeModelConfig` を使用してください。 +これらの設定は Realtime APIセッションではなく、クライアント接続を構成します。エンドポイント、認証、通話への接続、および再生設定には、引き続き `RealtimeModelConfig` を使用してください。 -## テレフォニー経路としての SIP アタッチ +## 電話通信向けの SIP 接続 -このリポジトリに記載されているテレフォニーフローでは、Python SDK は `call_id` を介して既存のリアルタイム通話にアタッチします。 +このリポジトリに記載されている電話通信フローでは、Python SDK は `call_id` を介して既存のリアルタイム通話に接続します。 このトポロジーは次のようになります。 -1. OpenAI が `realtime.call.incoming` などの Webhook をサービスに送信します。 -2. サービスが Realtime Calls API を介して通話を受け入れます。 +1. OpenAIが `realtime.call.incoming` などの Webhook をサービスに送信します。 +2. サービスが Realtime Calls API を介して通話を受け付けます。 3. Python サービスが `RealtimeRunner(..., model=OpenAIRealtimeSIPModel())` を開始します。 4. セッションが `model_config={"call_id": ...}` を使用して接続し、その後は他のリアルタイムセッションと同様にイベントを処理します。 これは、[`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) に示されているトポロジーです。 -Realtime API 全般でも、一部のサーバーサイド制御パターンで `call_id` が使用されますが、このリポジトリに含まれるアタッチのコード例は SIP です。 +より広範な Realtime APIでは、一部のサーバー側制御パターンに `call_id` も使用しますが、このリポジトリに含まれる接続のコード例では SIP を使用しています。 -## Python SDK 対象外のブラウザー WebRTC +## SDK の対象外となるブラウザー WebRTC -アプリの主要なクライアントが Realtime WebRTC を使用するブラウザーである場合は、次の点に注意してください。 +アプリの主要クライアントが Realtime WebRTC を使用するブラウザーである場合は、次の点に注意してください。 - このリポジトリの Python SDK ドキュメントの対象外として扱ってください。 -- クライアント側のフローとイベントモデルについては、公式の [WebRTC を使用した Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/)および [リアルタイム会話](https://developers.openai.com/api/docs/guides/realtime-conversations/)のドキュメントを参照してください。 -- ブラウザー WebRTC クライアントに加えてサイドバンドサーバー接続が必要な場合は、公式の [リアルタイムサーバーサイド制御](https://developers.openai.com/api/docs/guides/realtime-server-controls/)ガイドを参照してください。 -- このリポジトリでは、ブラウザー側の `RTCPeerConnection` 抽象化や、すぐに使用できるブラウザー WebRTC のコード例は提供されません。 +- クライアント側のフローとイベントモデルについては、公式の [WebRTC を使用する Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/)および[リアルタイム会話](https://developers.openai.com/api/docs/guides/realtime-conversations/)のドキュメントを参照してください。 +- ブラウザー WebRTC クライアントに加えてサイドバンドサーバー接続が必要な場合は、公式の [Realtime のサーバー側制御](https://developers.openai.com/api/docs/guides/realtime-server-controls/)ガイドを参照してください。 +- このリポジトリでは、ブラウザー側の `RTCPeerConnection` 抽象化や、すぐに利用できるブラウザー WebRTC のコード例は提供されていません。 また、このリポジトリには現在、ブラウザー WebRTC と Python サイドバンドを組み合わせたコード例も含まれていません。 -## カスタムエンドポイントとアタッチポイント +## カスタムエンドポイントと接続ポイント -[`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] のトランスポート設定項目を使用すると、デフォルトの経路を調整できます。 +[`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] のトランスポート設定インターフェースを使用すると、デフォルトのトランスポート動作をカスタマイズできます。 -- `url`: WebSocket エンドポイントを上書き -- `headers`: Azure 認証ヘッダーなどの明示的なヘッダーを指定 -- `api_key`: API キーを直接、またはコールバックを介して渡す -- `call_id`: 既存のリアルタイム通話にアタッチ。このリポジトリに記載されているコード例は SIP です。 -- `playback_tracker`: 割り込み処理のために実際の再生進捗を報告 +- `url`: WebSocket エンドポイントを上書きします +- `headers`: Azure 認証ヘッダーなどの明示的なヘッダーを指定します +- `api_key`: API キーを直接、またはコールバック経由で渡します +- `call_id`: 既存のリアルタイム通話に接続します。このリポジトリに記載されているコード例では SIP を使用します。 +- `playback_tracker`: 割り込み処理のために実際の再生進行状況を報告します トポロジーを選択した後の詳細なライフサイクルと機能範囲については、[リアルタイムエージェントガイド](guide.md)を参照してください。 \ No newline at end of file diff --git a/docs/ja/release.md b/docs/ja/release.md index 6383f4a619..d48bf2f1b0 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -4,51 +4,51 @@ search: --- # リリースプロセス/変更履歴 -このプロジェクトでは、`0.Y.Z` 形式のセマンティックバージョニングを一部変更して使用しています。先頭の `0` は、SDK が現在も急速に進化していることを示します。各要素は次のように更新します。 +このプロジェクトでは、`0.Y.Z` 形式を使用する、セマンティックバージョニングを若干変更した方式に従います。先頭の `0` は、SDK がまだ急速に進化していることを示します。各構成要素は次のように更新します。 ## マイナー(`Y`)バージョン -ベータとしてマークされていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を上げます。たとえば、`0.0.x` から `0.1.x` への移行には、破壊的変更が含まれる可能性があります。 +ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を増やします。たとえば、`0.0.x` から `0.1.x` への移行には、破壊的変更が含まれる可能性があります。 -破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 +破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することを推奨します。 ## パッチ(`Z`)バージョン -破壊的変更ではない次の変更については、`Z` を上げます。 +破壊的でない変更については、`Z` を増やします。 -- バグ修正 -- 新機能 -- 非公開インターフェースの変更 -- ベータ機能の更新 +- バグ修正 +- 新機能 +- 非公開インターフェースへの変更 +- ベータ機能の更新 ## 破壊的変更の変更履歴 ### 0.19.0 -このマイナーリリースでは、破壊的変更は **ありません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域であるプログラマティックツール呼び出しを反映したものです。 +このマイナーリリースには、破壊的変更は **ありません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域であるプログラマティックツール呼び出しを反映したものです。 -注目点: +主な変更点: -- サポート対象の OpenAI Responses モデルが JavaScript を生成して、利用可能なツールを連携できるようにする [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。ツールごとの `allowed_callers`、構造化された関数ツール出力、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。セットアップと制約については、[プログラマティックツール呼び出し](tools.md#programmatic-tool-calling)を参照してください。 -- 公開 `agents.decorators` モジュールと、既存の関数およびガードレール用デコレーターに加えて、より短い `@tool` エイリアスを追加しました。関数ツールは、非同期 callable オブジェクトもサポートするようになりました。 -- SDK の設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプライン全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け入れるようになり、不明な設定に対する検証も追加されました。 -- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体のエラーおよび診断ログを強化し、有用なデバッグコンテキストを維持しながら、機密性の高い raw ペイロードが公開されることを防止しました。 -- AnyLLM、LiteLLM、Chat Completions との互換性を改善し、モデルの再試行をまたいでセッション履歴を維持するようにしました。また、レスポンス開始前に発生する WebSocket 過負荷に対するプロバイダー再試行のガイダンスを追加し、再実行が許可されている場合に、オプトインの Runner 再試行ポリシーが機能できるようにしました。 -- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックス向けの作成時限定 S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外されます。また、動的なマウント変更やセッション再開は意図的にサポートされていません。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。これにより、対応する OpenAI Responses モデルは JavaScript を生成し、プログラマティックツール呼び出しの対象となるツールを連携させることができます。ツールごとの `allowed_callers`、`FunctionTool` インスタンスからの structured outputs、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。セットアップと制約については、[プログラマティックツール呼び出し](tools.md#programmatic-tool-calling)を参照してください。 +- 公開 `agents.decorators` モジュールと、既存の `@function_tool` デコレーターの短いエイリアスである `@tool` を、既存のガードレールデコレーターと併せて追加しました。`FunctionTool` インスタンスは、非同期 callable オブジェクトもサポートするようになりました。 +- SDK 設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け付けるようになり、不明な設定も検証されます。 +- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体のエラーおよび診断ログを強化し、有用なデバッグコンテキストを維持しながら、raw な機密ペイロードが公開されないようにしました。 +- AnyLLM、LiteLLM、Chat Completions との互換性を向上し、モデルの再試行間でセッション履歴を保持するようにしました。また、レスポンス開始前に発生する WebSocket の過負荷に関するプロバイダー再試行ガイダンスを追加し、許可されている場合には、オプトインの Runner 再試行ポリシーで失敗した試行を再実行できるようにしました。 +- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントされたセッションでは、バケットの内容がワークスペースの永続化対象から除外され、動的なマウント変更やセッションの再開は意図的にサポートされません。 ### 0.18.0 -このマイナーリリースでは、破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデルの更新のみを反映したものです。 +このマイナーリリースには、破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを反映したものです。 -注目点: +主な変更点: -- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime セットアップでは、追加設定なしで最新の推奨モデルが使用されるようになりました。 +- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime セットアップでは追加設定なしで最新の推奨モデルが使用されるようになりました。 ### 0.17.0 -このバージョンでは、サンドボックスのローカルソースを実体化する際、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` が実体化先の `base_dir` 内に維持されます。`base_dir` は、Manifest が適用された時点での SDK プロセスの現在の作業ディレクトリです。相対的なローカルソースはそのディレクトリを基準に解決され、絶対パスのローカルソースは、あらかじめそのディレクトリ内または明示的に許可された範囲内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼済みのホストファイルやディレクトリを、そのベースディレクトリの外部からサンドボックスワークスペースへ意図的にコピーするアプリケーションには影響する可能性があります。 +このバージョンでは、サンドボックスのローカルソースの実体化において、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` が実体化の `base_dir` 内に維持されます。`base_dir` は、マニフェストが適用される時点での SDK プロセスの現在の作業ディレクトリです。相対ローカルソースはそのディレクトリを基準に解決されますが、絶対ローカルソースは、あらかじめそのディレクトリ内または明示的な許可対象内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼できるホストのファイルやディレクトリを、そのベースディレクトリ外からサンドボックスワークスペースへ意図的にコピーするアプリケーションに影響する可能性があります。 -移行するには、Manifest レベルで `SandboxPathGrant` を使用して信頼済みのホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 +移行するには、マニフェストレベルで `SandboxPathGrant` を使用して、信頼できるホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 ```python from pathlib import Path @@ -75,11 +75,11 @@ manifest = Manifest( ) ``` -`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できない Manifest 入力から許可設定を追加しないでください。 +`extra_path_grants` は、信頼できるアプリケーション設定として扱ってください。アプリケーションが対象のホストパスをすでに承認している場合を除き、モデル出力やその他の信頼できないマニフェスト入力から許可設定を作成しないでください。 ### 0.16.0 -このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントおよび実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルト値が含まれるようになりました。 +このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には `reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルト値が含まれるようになりました。 以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に設定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 @@ -87,16 +87,16 @@ manifest = Manifest( agent = Agent(name="Assistant", model="gpt-4.1") ``` -注目点: +主な変更点: -- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の上限を無効化できるようになりました。 -- ローカル、Docker、プロバイダー提供のサンドボックス実装全体で、サンドボックスワークスペースのハイドレーション時に、絶対パスのシンボリックリンク先を含め、アーカイブのルート外を指すシンボリックリンクを含む tar アーカイブが拒否されるようになりました。 +- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の上限を無効にできるようになりました。 +- ローカル、Docker、プロバイダーを利用する各サンドボックス実装において、サンドボックスワークスペースのハイドレーションで、絶対パスのシンボリックリンク先を含め、アーカイブルート外を指すシンボリックリンクを含む tar アーカイブが拒否されるようになりました。 ### 0.15.0 -このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` になるまで実行ループが再試行されたりする代わりに、`ModelRefusalError` として明示的に通知されるようになりました。 +このバージョンでは、モデルによる拒否が、空のテキスト出力として扱われたり、structured outputs の場合に実行ループが `MaxTurnsExceeded` まで再試行されたりするのではなく、`ModelRefusalError` として明示的に公開されるようになりました。 -これは、拒否のみのモデルレスポンスが `final_output == ""` で完了することを期待していたコードに影響します。例外を発生させずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 +これは、拒否のみを含むモデルレスポンスが `final_output == ""` で完了することを想定していたコードに影響します。例外を送出せずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 ```python result = Runner.run_sync( @@ -106,94 +106,94 @@ result = Runner.run_sync( ) ``` -structured outputs を使用するエージェントでは、ハンドラーがエージェントの出力スキーマに一致する値を返すことができ、SDK はほかの実行エラーハンドラーの最終出力と同様に検証します。 +structured outputs を使用するエージェントの場合、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 ### 0.14.0 -このマイナーリリースでは破壊的変更は **ありません** が、主要な新しいベータ機能領域であるサンドボックスエージェントに加え、ローカル、コンテナ化、ホスト環境全体で使用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されています。 +このマイナーリリースには破壊的変更は **ありません** が、主要な新しいベータ機能領域としてサンドボックスエージェントが追加され、ローカル、コンテナ化、ホスト環境で利用するために必要なランタイム、バックエンド、ドキュメントのサポートも追加されました。 -注目点: +主な変更点: -- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースを追加しました。これにより、エージェントは、ファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開機能を備えた永続的な分離ワークスペース内で作業できます。 -- `UnixLocalSandboxClient` と `DockerSandboxClient` によるローカルおよびコンテナ化された開発向けのサンドボックス実行バックエンドに加え、オプションの extras を通じて Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合を追加しました。 -- サンドボックスメモリのサポートを追加し、段階的開示、複数ターンのグループ化、設定可能な分離境界、S3 を利用するワークフローを含む永続化メモリのコード例により、今後の実行で過去の実行から得られた知見を再利用できるようにしました。 -- ローカルおよび合成ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files 向けのリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットを使用する再開フローなど、より広範なワークスペースおよび再開モデルを追加しました。 -- `examples/sandbox/` 配下に多数のサンドボックスのコード例とチュートリアルを追加しました。スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有のセットアップ、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドワークフローを扱っています。 -- サンドボックス対応のセッション準備、機能のバインディング、状態のシリアル化、統合トレーシング、プロンプトキャッシュキーのデフォルト値、機密性の高い MCP 出力をより安全に秘匿する処理により、コアランタイムとトレーシングスタックを拡張しました。 +- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムサーフェスを追加しました。これにより、エージェントはファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開サポートを備えた、永続的で隔離されたワークスペース内で作業できます。 +- `UnixLocalSandboxClient` と `DockerSandboxClient` により、ローカル開発およびコンテナ化された開発向けのサンドボックス実行バックエンドを追加しました。また、Python パッケージのオプション依存関係 extras を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合も追加しました。 +- サンドボックスメモリのサポートを追加し、今後の実行で以前の実行から得た知見を再利用できるようになりました。段階的開示、複数ターンのグループ化、設定可能な隔離境界、および S3 を利用するワークフローを含む永続メモリのコード例を備えています。 +- ローカルおよび synthetic ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files 向けのリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、保存済みスナップショットを使用する再開フローを含む、より包括的なワークスペースおよび再開モデルを追加しました。 +- `examples/sandbox/` 配下に多数のサンドボックスコード例とチュートリアルを追加しました。スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有のセットアップ、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドワークフローを扱っています。 +- サンドボックス対応のセッション準備、機能のバインド、状態のシリアル化、統合トレーシング、プロンプトキャッシュキーのデフォルト値、機密性の高い MCP 出力をより安全に秘匿する処理により、コアランタイムとトレーシングスタックを拡張しました。 ### 0.13.0 -このマイナーリリースでは破壊的変更は **ありません** が、注目すべき Realtime のデフォルト設定の更新、新しい MCP 機能、ランタイムの安定性向上が含まれています。 +このマイナーリリースには破壊的変更は **ありません** が、注目すべき Realtime のデフォルト更新に加え、新しい MCP 機能とランタイムの安定性向上が含まれています。 -注目点: +主な変更点: -- デフォルトの WebSocket Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェントのセットアップでは、追加設定なしでより新しいモデルが使用されるようになりました。 -- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開されるようになりました。また、`MCPServerStreamableHttp` で `session_id` が公開され、ストリーミング可能な HTTP セッションを再接続後やステートレスワーカー間で再開できるようになりました。 -- Chat Completions 統合では、`should_replay_reasoning_content` を通じて推論コンテンツのリプレイをオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論およびツール呼び出しの継続性が向上しました。 -- `SQLAlchemySession` における最初の書き込みの同時実行、推論除去後に孤立したアシスタントメッセージ ID を含む圧縮リクエスト、`remove_all_tools()` の実行後に残る MCP/推論項目、関数ツールのバッチ実行機構における競合状態など、ランタイムおよびセッションの複数のエッジケースを修正しました。 +- デフォルトの WebSocket Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェントのセットアップでは追加設定なしで新しいモデルが使用されるようになりました。 +- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開され、`MCPServerStreamableHttp` で `session_id` が公開されるようになりました。これにより、MCP Streamable HTTP トランスポートを使用するセッションを、再接続やステートレスワーカーをまたいで再開できます。 +- Chat Completions 統合では、`should_replay_reasoning_content` を通じて既存の推論内容の再送信をオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論およびツール呼び出しの継続性が向上しました。 +- `SQLAlchemySession` での同時初回書き込み、推論除去後に孤立した assistant メッセージ ID を含む圧縮リクエスト、MCP/推論項目を残していた `remove_all_tools()`、`FunctionTool` インスタンスのバッチ実行機構における競合など、複数のランタイムおよびセッションのエッジケースを修正しました。 ### 0.12.0 -このマイナーリリースでは、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 +このマイナーリリースには、破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 ### 0.11.0 -このマイナーリリースでは、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 +このマイナーリリースには、破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 ### 0.10.0 -このマイナーリリースでは破壊的変更は **ありません** が、OpenAI Responses のユーザー向けの重要な新機能領域として、Responses API の WebSocket トランスポート対応が含まれています。 +このマイナーリリースには破壊的変更は **ありません** が、OpenAI Responses ユーザー向けの重要な新機能領域として、Responses API の WebSocket トランスポートサポートが含まれています。 -注目点: +主な変更点: -- OpenAI Responses モデルに WebSocket トランスポート対応を追加しました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 -- 複数ターンの実行間で、共有の WebSocket 対応プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 -- ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングのコード例(`examples/basic/stream_ws.py`)を追加しました。 +- OpenAI Responses モデル向けの WebSocket トランスポートサポートを追加しました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 +- 複数ターンの実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 +- ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングコード例(`examples/basic/stream_ws.py`)を追加しました。 ### 0.9.0 -このバージョンでは、Python 3.9 のメジャーバージョンが 3 か月前に EOL に達したため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンにアップグレードしてください。 +このバージョンでは、Python 3.9 がサポート対象外になりました。このメジャーバージョンが 3 か月前に EOL に達したためです。より新しいランタイムバージョンにアップグレードしてください。 -さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に限定されました。通常、この変更が破壊的な問題を引き起こすことはありませんが、コードがより広範なユニオン型に依存している場合は、調整が必要になる可能性があります。 +さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に絞り込まれました。通常、この変更によって破壊的な問題が発生することはありませんが、コードがより広い union 型に依存している場合は、コード側で調整が必要になることがあります。 ### 0.8.0 -このバージョンでは、ランタイムの動作に関する次の 2 つの変更により、移行作業が必要になる可能性があります。 +このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる可能性があります。 -- **同期** Python callable をラップする関数ツールは、イベントループスレッド上で実行される代わりに、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールのロジックがスレッドローカル状態やスレッドアフィンなリソースに依存している場合は、非同期ツール実装へ移行するか、ツールコード内でスレッドアフィニティを明示してください。 -- ローカル MCP ツールの失敗処理が設定可能になり、デフォルトの動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返す場合があります。フェイルファストのセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` の値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 +- **同期** Python callable をラップする `FunctionTool` インスタンスは、イベントループスレッド上で実行されるのではなく、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールロジックがスレッドローカル状態またはスレッドアフィニティのあるリソースに依存する場合は、非同期ツール実装に移行するか、ツールコードでスレッドアフィニティを明示してください。 +- ローカル MCP ツールの失敗処理が設定可能になり、デフォルトの動作では、実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返せるようになりました。即時失敗のセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 ### 0.7.0 このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかあります。 -- ネストされたハンドオフ履歴は **オプトイン** になりました(デフォルトでは無効)。v0.6.x でデフォルトだったネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 -- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルト設定で構成されていた以前のデフォルト値 `"low"` から `"none"` に変更されました。プロンプトまたは品質/コスト特性が `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 +- ネストされたハンドオフ履歴が **オプトイン** になりました(デフォルトでは無効です)。v0.6.x のデフォルトだったネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 +- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルト値として設定されていた従来の `"low"` から `"none"` に変更されました。プロンプトまたは品質/コスト特性が `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 ### 0.6.0 -このバージョンでは、デフォルトのハンドオフ履歴が、raw なユーザー/アシスタントのターンを公開する代わりに、単一のアシスタントメッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます -- 既存の単一メッセージによるハンドオフ記録は、デフォルトで `` ブロックの前に "For context, here is the conversation so far between the user and the previous agent:" から始まるようになり、後続のエージェントが明確にラベル付けされた要約を受け取れるようになりました +このバージョンでは、デフォルトのハンドオフ履歴は、ユーザーと assistant のターンを個別のメッセージとして渡すのではなく、単一の assistant メッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約を提供します +- 既存の単一メッセージ形式のハンドオフトランスクリプトでは、デフォルトで `` ブロックの前に、正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` が置かれるようになり、後続のエージェントは明確なラベル付きの要約を受け取れます ### 0.5.0 -このバージョンでは、目に見える破壊的変更は導入されていませんが、新機能と内部実装に関するいくつかの重要な更新が含まれています。 +このバージョンでは、外部から確認できる破壊的変更は導入されていませんが、新機能と内部実装に関する重要な更新がいくつか含まれています。 -- `RealtimeRunner` が [SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理できるようになりました -- Python 3.14 との互換性のために、`Runner#run_sync` の内部ロジックを大幅に改訂しました +- `RealtimeRunner` に、[SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートを追加しました。 +- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックを大幅に改訂しました ### 0.4.0 -このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x バージョンはサポートされなくなりました。この SDK と併用する場合は、openai v2.x を使用してください。 +このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x バージョンはサポート対象外になりました。この SDK では openai v2.x を使用してください。 ### 0.3.0 -このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA 版)へ移行します。 +このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA 版)に移行します。 ### 0.2.0 -このバージョンでは、以前は `Agent` を引数として受け取っていたいくつかの箇所が、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、MCP サーバーの `list_tools()` 呼び出しです。これは純粋に型付けのみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正するだけです。 +このバージョンでは、以前 `Agent` を引数として受け取っていた箇所の一部が、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、これは MCP サーバーの `list_tools()` メソッドシグネチャに適用されます。これは純粋に型付け上の変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 ### 0.1.0 -このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` を継承するすべてのクラスに、これらのパラメーターを追加する必要があります。 \ No newline at end of file +このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされているすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file diff --git a/docs/ja/results.md b/docs/ja/results.md index e495ebcec1..f1af84c249 100644 --- a/docs/ja/results.md +++ b/docs/ja/results.md @@ -11,11 +11,11 @@ search: どちらも [`RunResultBase`][agents.result.RunResultBase] を継承しており、`final_output`、`new_items`、`last_agent`、`raw_responses`、`to_state()` などの共通の実行結果インターフェースを公開します。 -`RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御機能が追加されています。 +`RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御が追加されています。 ## 適切な実行結果インターフェースの選択 -ほとんどのアプリケーションでは、少数の実行結果プロパティまたはヘルパーのみが必要です。 +ほとんどのアプリケーションで必要となる実行結果のプロパティやヘルパーは、ごくわずかです。 | 必要なもの | 使用するもの | | --- | --- | @@ -23,69 +23,69 @@ search: | ローカルの完全なトランスクリプトを含む、再実行可能な次ターンの入力リスト | `to_input_list()` | | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な実行項目 | `new_items` | | 通常、次のユーザーターンを処理するエージェント | `last_agent` | -| `previous_response_id` を使用した OpenAI Responses API のチェーン | `last_response_id` | +| `previous_response_id` を使用した OpenAI Responses API の連鎖 | `last_response_id` | | 保留中の承認と再開可能なスナップショット | `interruptions` と `to_state()` | | 現在のネストされた `Agent.as_tool()` 呼び出しに関するメタデータ | `agent_tool_invocation` | -| raw モデル呼び出しまたはガードレールの診断 | `raw_responses` とガードレールの実行結果配列 | +| raw のモデル呼び出しまたはガードレールの診断情報 | `raw_responses` とガードレールの実行結果配列 | ## 最終出力 -[`final_output`][agents.result.RunResultBase.final_output] プロパティには、最後に実行されたエージェントの最終出力が格納されます。これは次のいずれかです。 +[`final_output`][agents.result.RunResultBase.final_output] プロパティには、最後に実行されたエージェントの最終出力が格納されます。次のいずれかになります。 -- 最後のエージェントに `output_type` が定義されていなかった場合は `str` -- 最後のエージェントに出力型が定義されていた場合は、`last_agent.output_type` 型のオブジェクト -- 承認待ちの中断で一時停止した場合など、最終出力が生成される前に実行が停止した場合は `None` +- 最後のエージェントに `output_type` が定義されていない場合は `str` +- 最後のエージェントに出力型が定義されている場合は、`last_agent.output_type` 型のオブジェクト +- 承認による中断で一時停止した場合など、最終出力が生成される前に実行が停止した場合は `None` !!! note - `final_output` の型は `Any` です。ハンドオフによって実行を完了するエージェントが変わる可能性があるため、SDK は考えられる出力型の完全な集合を静的に把握できません。 + `final_output` の型は `Any` です。ハンドオフによって実行を完了するエージェントが変わる可能性があるため、SDK は可能性のある出力型の完全な集合を静的に把握できません。 ストリーミングモードでは、ストリームの処理が完了するまで `final_output` は `None` のままです。イベントごとのフローについては、[ストリーミング](streaming.md)を参照してください。 -## 入力、次ターンの履歴、新規項目 +## 入力、次ターンの履歴、新しい項目 これらのインターフェースは、それぞれ異なる目的に対応します。 -| プロパティまたはヘルパー | 格納される内容 | 最適な用途 | +| プロパティまたはヘルパー | 内容 | 最適な用途 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基本入力。ハンドオフ入力フィルターが履歴を書き換えた場合は、実行の続行に使用されたフィルター済みの入力が反映されます。 | この実行で実際に使用された入力の監査 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として表したもの。デフォルトの `mode="preserve_all"` では、`new_items` から変換された履歴が維持されます。ただし、SDK デフォルトのネストされたハンドオフ履歴へすでに移された同一のセッション項目は、再度追加されません。`mode="normalized"` では、ハンドオフのフィルタリングによってモデル履歴が書き換えられた場合、正規の継続入力が優先されます。 | 手動のチャットループ、クライアント管理の会話状態、プレーンな項目履歴の確認 | -| [`new_items`][agents.result.RunResultBase.new_items] | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な [`RunItem`][agents.items.RunItem] ラッパー。 | ログ、UI、監査、デバッグ | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行中の各モデル呼び出しから得られた raw [`ModelResponse`][agents.items.ModelResponse] オブジェクト。 | プロバイダーレベルの診断または raw レスポンスの確認 | +| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基礎入力です。ハンドオフ入力フィルターによって履歴が書き換えられた場合は、実行の続行に使用されたフィルター済み入力が反映されます。 | この実行で実際に使用された入力の監査 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として表現したものです。デフォルトの `mode="preserve_all"` では、`new_items` から変換された履歴が保持されます。ただし、SDK のデフォルトのネストされたハンドオフ履歴へすでに移動されたセッション項目と完全に同一の出現箇所が、再度追加されることはありません。ハンドオフのフィルタリングによってモデル履歴が書き換えられる場合、`mode="normalized"` は正規の継続入力を優先します。 | 手動のチャットループ、クライアント管理の会話状態、プレーン項目の履歴確認 | +| [`new_items`][agents.result.RunResultBase.new_items] | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な [`RunItem`][agents.items.RunItem] ラッパーです。 | ログ、UI、監査、デバッグ | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行内の各モデル呼び出しから得られた raw の [`ModelResponse`][agents.items.ModelResponse] オブジェクトです。 | プロバイダーレベルの診断または raw レスポンスの確認 | 実際には、次のように使い分けます。 - 実行をプレーンな入力項目として確認する場合は、`to_input_list()` を使用します。 -- ハンドオフのフィルタリングやネストされたハンドオフ履歴の書き換え後、次の `Runner.run(..., input=...)` 呼び出しに使用する正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 +- ハンドオフのフィルタリングまたはネストされたハンドオフ履歴の書き換え後に、次の `Runner.run(..., input=...)` 呼び出しで使用する正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 - SDK に履歴の読み込みと保存を任せる場合は、[`session=...`](sessions/index.md) を使用します。 -- `conversation_id` または `previous_response_id` を使って OpenAI のサーバー管理状態を使用している場合、通常は `to_input_list()` を再送せず、新しいユーザー入力のみを渡して保存済みの ID を再利用します。 -- ログ、UI、監査のために変換済みの完全な履歴が必要な場合は、デフォルトモードの `to_input_list()` または `new_items` を使用します。 +- `conversation_id` または `previous_response_id` を使用して OpenAI のサーバー管理状態を利用している場合は、通常、`to_input_list()` を再送信するのではなく、新しいユーザー入力のみを渡して保存済みの ID を再利用します。 +- ログ、UI、監査のために変換済みの完全な履歴が必要な場合は、デフォルトの `to_input_list()` モードまたは `new_items` を使用します。 -SDK デフォルトのネストされたハンドオフ履歴でメッセージ項目がそのまま保持される場合、Sessions、`RunState`、`to_input_list()` は、内容で重複排除するのではなく、所有対象となる個々の出現を追跡します。個別に発生した同一のメッセージは別々のものとして維持され、すでに所有されている出現のみが再度追加されないように処理されます。 +SDK のデフォルトのネストされたハンドオフ履歴でメッセージ項目がそのまま保持される場合、Sessions、`RunState`、`to_input_list()` は、内容で重複排除するのではなく、所有対象となる個々の出現箇所を正確に追跡します。同じメッセージが別々に出現した場合、それぞれが別のものとして保持されます。すでに所有されている出現箇所だけが、再度追加されないようになります。 -JavaScript SDK とは異なり、Python ではモデル形式の差分のみを表す独立した `output` プロパティは公開されていません。SDK のメタデータが必要な場合は `new_items` を使用し、raw モデルペイロードが必要な場合は `raw_responses` を確認してください。 +JavaScript SDK とは異なり、Python には、実行中に新しく生成されたモデル形式の項目だけを含む独立した `output` プロパティはありません。SDK のメタデータが必要な場合は `new_items` を使用し、raw のモデルペイロードが必要な場合は `raw_responses` を確認してください。 -コンピュータツールの再実行では、raw Responses ペイロードの形式が使用されます。プレビューモデルの `computer_call` 項目は単一の `action` を保持しますが、`gpt-5.5` のコンピュータ呼び出しはバッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] は、モデルが生成した形式をそのまま維持するため、手動の再実行、一時停止と再開のフロー、保存済みトランスクリプトは、プレビュー版と GA 版の両方のコンピュータツール呼び出しで引き続き機能します。ローカルでの実行結果は、引き続き `new_items` 内の `computer_call_output` 項目として表示されます。 +コンピューターツールの項目を会話入力として再送信する場合は、raw の Responses ペイロード形式が使用されます。プレビューモデルの `computer_call` 項目では単一の `action` が保持されますが、`gpt-5.5` のコンピューター呼び出しでは、バッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] はモデルが生成した形式をそのまま保持するため、それらの項目を会話入力として手動で再送信する場合、一時停止と再開のフロー、および保存されたトランスクリプトは、プレビュー版と GA 版の両方のコンピューターツール呼び出しで引き続き動作します。ローカルの実行結果は、引き続き `new_items` 内の `computer_call_output` 項目として表示されます。 -### 新規項目 +### 新しい項目 -[`new_items`][agents.result.RunResultBase.new_items] を使用すると、実行中に起きたことを最も詳細に確認できます。一般的な項目型は次のとおりです。 +[`new_items`][agents.result.RunResultBase.new_items] では、実行中に発生した内容を最も詳細に確認できます。一般的な項目の型は次のとおりです。 - アシスタントメッセージを表す [`MessageOutputItem`][agents.items.MessageOutputItem] - 推論項目を表す [`ReasoningItem`][agents.items.ReasoningItem] - Responses のツール検索リクエストと読み込まれたツール検索結果を表す [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] と [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] - ツール呼び出しとその実行結果を表す [`ToolCallItem`][agents.items.ToolCallItem] と [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 承認のために一時停止したツール呼び出しを表す [`ToolApprovalItem`][agents.items.ToolApprovalItem] +- 承認待ちで一時停止したツール呼び出しを表す [`ToolApprovalItem`][agents.items.ToolApprovalItem] - ホスト型 MCP の承認とツールカタログを表す [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem]、[`MCPListToolsItem`][agents.items.MCPListToolsItem] - ハンドオフリクエストと完了した転送を表す [`HandoffCallItem`][agents.items.HandoffCallItem] と [`HandoffOutputItem`][agents.items.HandoffOutputItem] -エージェントとの関連付け、ツール出力、ハンドオフ境界、承認境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 +エージェントとの関連付け、ツールの出力、ハンドオフの境界、承認の境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 -ホスト型ツール検索を使用する場合、モデルが生成した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を、該当ターンで読み込まれた名前空間、関数、ホスト型 MCP サーバーを確認するには `ToolSearchOutputItem.raw_item` を調べます。 +ホスト型ツール検索を使用する場合は、モデルが発行した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を、どの名前空間、関数、ホスト型 MCP サーバーがそのターン用に読み込まれたかを確認するには `ToolSearchOutputItem.raw_item` を参照してください。 -プログラムによるツール呼び出し (Programmatic Tool Calling) では、生成された `program` は `ToolCallItem` であり、そのプログラムが所有する通常の子ツール呼び出しも `ToolCallItem` エントリです。また、対応する `program_output` は `ToolCallOutputItem` です。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外で、それぞれ `MCPApprovalRequestItem` エントリと `MCPListToolsItem` エントリになります。 +プログラムによるツール呼び出しでは、生成された `program` は `ToolCallItem` となり、そのプログラムが所有する通常の子ツール呼び出しも `ToolCallItem` エントリとなり、対応する `program_output` は `ToolCallOutputItem` となります。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外で、それぞれ `MCPApprovalRequestItem` エントリと `MCPListToolsItem` エントリになります。 -raw 項目には、型付きの Responses オブジェクトまたはマッピングを使用できます。特に、プログラムが所有する shell 呼び出しと apply-patch 呼び出しではマッピングが使用されます。マッピングでも安全な次の検査パターンを使用してください。 +raw 項目は、型付きの Responses オブジェクトまたはマッピングの場合があります。特に、プログラムが所有するシェル呼び出しとパッチ適用呼び出しではマッピングが使用されます。マッピングでも安全に確認できるパターンを使用してください。 ```python from collections.abc import Mapping @@ -107,7 +107,7 @@ caller_id = ( ) ``` -プログラムが所有する子呼び出しでは、`caller` の型は `program` で、`caller_id` は親プログラムの呼び出しを識別します。 +プログラムが所有する子呼び出しでは、`caller` の `type` フィールドは `program` となり、`caller_id` は親プログラム呼び出しを識別します。 ## 会話の続行または再開 @@ -115,13 +115,13 @@ caller_id = ( [`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが格納されます。多くの場合、ハンドオフ後の次のユーザーターンで再利用するエージェントとして最適です。 -ストリーミングモードでは、実行の進行に合わせて [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が更新されるため、ストリームが完了する前にハンドオフを確認できます。 +ストリーミングモードでは、実行の進行に応じて [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が更新されるため、ストリームが完了する前にハンドオフを確認できます。 ### 中断と実行状態 -ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接使用されたツール、ハンドオフ後に到達したツール、ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって発生した承認が含まれる場合があります。 +ツールで承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接使用されたツール、ハンドオフ後に到達したツール、ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって発生した承認が含まれる場合があります。 -[`to_state()`][agents.result.RunResult.to_state] を呼び出して再開可能な [`RunState`][agents.run_state.RunState] を取得し、保留中の項目を承認または拒否してから、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 +再開可能な [`RunState`][agents.run_state.RunState] を取得するには [`to_state()`][agents.result.RunResult.to_state] を呼び出し、保留中の項目を承認または拒否してから、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 ```python from agents import Agent, Runner @@ -136,17 +136,17 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了し、その後で `result.interruptions` を確認して `result.to_state()` から再開します。承認フローの全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 +ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了してから `result.interruptions` を確認し、`result.to_state()` から再開します。承認フローの全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 ### サーバー管理による継続 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行で得られた最新のモデルレスポンス ID です。OpenAI Responses API のチェーンを継続する場合は、次のターンで `previous_response_id` として渡します。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API の連鎖を継続する場合は、次のターンで `previous_response_id` として渡します。 -すでに `to_input_list()`、`session`、`conversation_id` を使用して会話を継続している場合、通常は `last_response_id` は必要ありません。複数ステップの実行に含まれるすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 +すでに `to_input_list()`、`session`、`conversation_id` を使用して会話を継続している場合は、通常 `last_response_id` は必要ありません。複数ステップの実行に含まれるすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 -## ツールとしてのエージェントのメタデータ +## エージェントをツールとして使用する際のメタデータ -ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から実行結果が返された場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は外側のツール呼び出しに関する不変のメタデータを公開します。 +ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から実行結果が返された場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は、それを囲む `Agent.as_tool()` 呼び出しに関する次の不変メタデータを公開します。 - `tool_name` - `tool_call_id` @@ -154,30 +154,30 @@ if result.interruptions: 通常のトップレベル実行では、`agent_tool_invocation` は `None` です。 -これは特に `custom_output_extractor` 内で役立ちます。ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、raw 引数が必要になる場合があるためです。関連する `Agent.as_tool()` のパターンについては、[ツール](tools.md)を参照してください。 +これは特に `custom_output_extractor` 内で便利です。ネストされた実行結果を後処理する際に、それを囲む `Agent.as_tool()` 呼び出しのツール名、呼び出し ID、raw 引数が必要になる場合があります。関連する `Agent.as_tool()` のパターンについては、[ツール](tools.md)を参照してください。 -そのネストされた実行の解析済み構造化入力も必要な場合は、`context_wrapper.tool_input` を参照してください。これは [`RunState`][agents.run_state.RunState] がネストされたツール入力として汎用的にシリアライズするフィールドです。一方、`agent_tool_invocation` は現在のネストされた呼び出しに対する実行結果のライブアクセサーです。 +そのネストされた実行で解析済みの構造化入力も必要な場合は、`context_wrapper.tool_input` を参照してください。これは、[`RunState`][agents.run_state.RunState] がネストされたツール入力用に汎用的にシリアル化するフィールドです。一方、`agent_tool_invocation` は、現在のネストされた呼び出しのメタデータを実行結果上で直接公開します。 ## ストリーミングのライフサイクルと診断 -[`RunResultStreaming`][agents.result.RunResultStreaming] は前述の実行結果インターフェースを継承し、さらにストリーミング固有の次の制御機能を追加します。 +[`RunResultStreaming`][agents.result.RunResultStreaming] は上記と同じ実行結果インターフェースを継承しますが、次のストリーミング固有の制御が追加されています。 -- 意味レベルのストリームイベントを消費するための [`stream_events()`][agents.result.RunResultStreaming.stream_events] -- 実行中にアクティブなエージェントを追跡するための [`current_agent`][agents.result.RunResultStreaming.current_agent] +- セマンティックなストリームイベントを消費するための [`stream_events()`][agents.result.RunResultStreaming.stream_events] +- 実行中のアクティブなエージェントを追跡するための [`current_agent`][agents.result.RunResultStreaming.current_agent] - ストリーミング実行が完全に終了したかどうかを確認するための [`is_complete`][agents.result.RunResultStreaming.is_complete] -- 実行を即座に、または現在のターンの完了後に停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] +- 実行を直ちに、または現在のターンの後に停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] -非同期イテレーターが終了するまで `stream_events()` を消費し続けてください。そのイテレーターが終了するまでストリーミング実行は完了していません。また、最後の可視トークンが到着した後も、`final_output`、`interruptions`、`raw_responses` などの要約プロパティや、セッション永続化の副作用が確定処理中である可能性があります。 +非同期イテレーターが完了するまで `stream_events()` を消費し続けてください。このイテレーターが終了するまで、ストリーミング実行は完了していません。最後に表示されるトークンが到着した後も、`final_output`、`interruptions`、`raw_responses` などの概要プロパティや、セッション永続化の副作用が確定処理中の場合があります。 -`cancel()` を呼び出した場合も、キャンセルとクリーンアップが正しく完了するよう、`stream_events()` を引き続き消費してください。 +`cancel()` を呼び出した場合は、キャンセルとクリーンアップが正しく完了するように、`stream_events()` を引き続き消費してください。 -Python では、ストリーミング用の独立した `completed` Promise や `error` プロパティは公開されていません。ストリーミングの終端エラーは `stream_events()` から例外が送出されることで通知され、`is_complete` は実行が終端状態に達したかどうかを示します。 +Python には、ストリーミング用の独立した `completed` Promise や `error` プロパティはありません。実行を終了させるストリーミングエラーは `stream_events()` によって送出され、`is_complete` は実行が終端状態に達したかどうかを示します。 -### Raw レスポンス +### raw レスポンス -[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが格納されます。複数ステップの実行では、ハンドオフやモデル、ツール、モデルの反復サイクルなどにより、複数のレスポンスが生成されることがあります。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw のモデルレスポンスが格納されます。複数ステップの実行では、ハンドオフや、モデル、ツール、モデルというサイクルの繰り返しなどによって、複数のレスポンスが生成される場合があります。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリに含まれる ID にすぎません。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリの ID にすぎません。 ### ガードレールの実行結果 @@ -185,10 +185,10 @@ Python では、ストリーミング用の独立した `completed` Promise や ツールのガードレールは、[`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] と [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] として個別に公開されます。 -これらの配列には実行全体の情報が蓄積されるため、判断内容のログ記録、追加のガードレールメタデータの保存、実行がブロックされた理由のデバッグに役立ちます。 +これらの配列は実行全体を通じて蓄積されるため、判断のログ記録、追加のガードレールメタデータの保存、実行がブロックされた理由のデバッグに役立ちます。 ### コンテキストと使用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、アプリケーションのコンテキストと、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータをまとめて公開します。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータとともに、アプリのコンテキストを公開します。 -使用量は `context_wrapper.usage` で追跡されます。ストリーミング実行では、ストリームの最後のチャンクが処理されるまで使用量の合計値の反映が遅れる場合があります。ラッパーの完全な形式と永続化に関する注意事項については、[コンテキスト管理](context.md)を参照してください。 \ No newline at end of file +使用量は `context_wrapper.usage` で追跡されます。ストリーミング実行では、ストリームの最後のチャンクが処理されるまで、使用量の合計値の反映が遅れる場合があります。ラッパーの完全な形式と永続化に関する注意事項については、[コンテキスト管理](context.md)を参照してください。 \ No newline at end of file diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index 79b3c44dec..5b1721fddf 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -4,11 +4,11 @@ search: --- # エージェントの実行 -[`Runner`][agents.run.Runner] クラスを介してエージェントを実行できます。次の 3 つの方法があります。 +[`Runner`][agents.run.Runner] クラスを使用してエージェントを実行できます。次の 3 つの方法があります。 1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行し、[`RunResult`][agents.result.RunResult] を返します。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドであり、内部では `.run()` を実行します。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、イベントを受信すると順次ストリーミングします。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドで、内部では単に `.run()` を実行します。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントをそのままストリーミングします。 ```python from agents import Agent, Runner @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -詳しくは、[実行結果ガイド](results.md)をご覧ください。 +詳細については、[実行結果ガイド](results.md)を参照してください。 -## Runner のライフサイクルと設定 +## ランナーのライフサイクルと設定 ### エージェントループ -`Runner` の run メソッドを使用する際は、開始エージェントと入力を渡します。入力には次のものを指定できます。 +上記 3 つの `Runner` メソッドのいずれかを呼び出す際は、開始エージェントと入力を渡します。入力には次のものを使用できます。 - 文字列(ユーザーメッセージとして扱われます) - OpenAI Responses API 形式の入力項目のリスト -- 中断された実行を再開する場合は [`RunState`][agents.run_state.RunState] +- 中断された実行を再開する場合は、[`RunState`][agents.run_state.RunState] -その後、Runner は次のループを実行します。 +その後、ランナーは次のループを実行します。 -1. 現在のエージェントについて、現在の入力で LLM を呼び出します。 +1. 現在のエージェントに対し、現在の入力を使用して LLM を呼び出します。 2. LLM が出力を生成します。 - 1. LLM が `final_output` を返した場合、ループを終了して実行結果を返します。 - 2. LLM がハンドオフを行った場合、現在のエージェントと入力を更新し、ループを再実行します。 - 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行して実行結果を追加し、ループを再実行します。 + 1. ランナーが LLM の出力を最終出力と判定した場合、ループを終了して実行結果を返します。 + 2. LLM がハンドオフを要求した場合、現在のエージェントと入力を更新し、ループを再実行します。 + 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行し、実行結果を追加して、ループを再実行します。 3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note - LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが存在しないことです。 + LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力を生成し、ツール呼び出しが存在しないことです。 ### ストリーミング -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳しくは、[ストリーミングガイド](streaming.md)をご覧ください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 #### Responses WebSocket トランスポート(オプションのヘルパー) -OpenAI Responses WebSocket トランスポートを有効にしても、通常の `Runner` API を引き続き使用できます。接続を再利用するには WebSocket セッションヘルパーの使用を推奨しますが、必須ではありません。 +OpenAI Responses の WebSocket トランスポートを有効にしても、通常の `Runner` API を引き続き使用できます。接続を再利用する場合は WebSocket セッションヘルパーを推奨しますが、必須ではありません。 これは WebSocket トランスポート経由の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 -トランスポートの選択ルール、および具象モデルオブジェクトやカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 +トランスポートの選択ルール、および具象モデルオブジェクトやカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)を参照してください。 ##### パターン 1:セッションヘルパーなし(利用可能) -WebSocket トランスポートのみが必要で、共有プロバイダーやセッションを SDK に管理させる必要がない場合に使用します。 +WebSocket トランスポートのみを使用し、共有プロバイダーやセッションを SDK で管理する必要がない場合は、この方法を使用します。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -このパターンは単一の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 +このパターンは単発の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 ##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) -複数の実行にわたって WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 +複数の実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、ネストされたエージェントツール呼び出しも対象です。 ```python import asyncio @@ -119,59 +119,59 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果の消費を完了してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 +コンテキストを終了する前に、ストリーミングされた実行結果を最後まで取得してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 -サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約を取り除くものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストで新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。完全な復旧動作については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)をご覧ください。 +サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約を取り除くものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。復元動作の詳細については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)を参照してください。 -長時間の推論ターンで WebSocket の keepalive タイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。WebSocket のレイテンシーより信頼性を重視する実行には、HTTP/SSE トランスポートを使用してください。 +長時間の推論ターンで WebSocket のキープアライブがタイムアウトする場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket のレイテンシよりも信頼性を重視する実行には、HTTP/SSE トランスポートを使用してください。 ### 実行設定 -`run_config` パラメーターを使用すると、エージェント実行に関するいくつかのグローバル設定を構成できます。 +`run_config` パラメーターを使用すると、エージェント実行の一部のグローバル設定を構成できます。 #### 一般的な実行設定のカテゴリー -各エージェントの定義を変更せずに、単一の実行に対する動作を上書きするには、`RunConfig` を使用します。 +各エージェントの定義を変更せずに単一の実行の動作をオーバーライドするには、`RunConfig` を使用します。 -##### モデル、プロバイダー、セッションのデフォルト +##### モデル、プロバイダー、セッションのデフォルト設定 -- [`model`][agents.run.RunConfig.model]:各 Agent に設定されている `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 +- [`model`][agents.run.RunConfig.model]:各エージェントが持つ `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 - [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 -- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` や `top_p` を設定できます。 -- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(例:`SessionSettings(limit=...)`)を上書きします。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions を使用する際に、各ターンの前に新しいユーザー入力をセッション履歴へマージする方法をカスタマイズします。コールバックは同期または非同期にできます。 +- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定をオーバーライドします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 +- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト設定(たとえば、`SessionSettings(limit=...)`)をオーバーライドします。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions を使用する各 `Runner` 実行の前に、新しいユーザー入力をセッション履歴と統合する方法をカスタマイズします。コールバックは同期または非同期にできます。 -##### ガードレール、ハンドオフ、モデル入力の整形 +##### ガードレール、ハンドオフ、モデル入力の調整 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに独自のフィルターが設定されていない場合に、すべてのハンドオフへ適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントへ送信される入力を編集できます。詳しくは、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、元の位置にあるメッセージ項目を欠損なく保持しながら、要約可能な履歴を順序付きの assistant 要約セグメントへ圧縮するオプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、raw のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、`RunResult.to_input_list()` は、SDK のデフォルトで生成されたネスト済み履歴に同一のメッセージ出現箇所がすでに含まれている場合、そのメッセージを重複して追加しません。一方、内容が同一でも別々のメッセージは保持します。[Runner のすべてのメソッド][agents.run.Runner]は、指定されていない場合に `RunConfig` を自動作成するため、クイックスタートやコード例ではデフォルトで無効のままです。また、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックによる上書きも引き続き有効です。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を介してこの設定を上書きできます。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` を有効にした際に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを記述せずに、組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の切り詰めやシステムプロンプトの注入に使用できます。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力へ変換する際に、推論項目の ID を保持するか省略するかを制御します。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだ設定されていない場合に、すべてのハンドオフへ適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信する入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントを参照してください。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、ロスレスなメッセージ項目を元の位置に保持しながら、要約可能な履歴を順序付けられたアシスタント要約セグメントへ圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、raw なトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、および `RunResult.to_input_list()` では、SDK のデフォルトのネスト履歴に同一のメッセージ出現箇所がすでに含まれている場合、そのメッセージを重複して追加しません。一方で、内容が同一でも別個のメッセージは保持されます。すべての [Runner メソッド][agents.run.Runner]は、明示的に渡さなかった場合に `RunConfig` を自動的に作成するため、クイックスタートとコード例ではデフォルトが無効のまま維持されます。また、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは、引き続きこの設定をオーバーライドします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を通じてこの設定をオーバーライドできます。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインした場合に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを記述することなく、組み込みの順序付けられた要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の短縮やシステムプロンプトの挿入に使用できます。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:ランナーが以前の出力を次のターンのモデル入力へ変換する際に、推論項目 ID を保持するか省略するかを制御します。 -##### トレーシングとオブザーバビリティ +##### トレーシングと可観測性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 -- [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入力/出力など、機密情報である可能性のあるデータをトレースに含めるかどうかを設定します。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にまたがるトレースを関連付けるためのオプションフィールドです。 +- [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定をオーバーライドするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入力と出力など、機密である可能性のあるデータをトレースに含めるかどうかを設定します。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 -##### ツール実行、承認、ツールエラーの動作 +##### ツールの実行、承認、エラー動作 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行する関数ツールの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した未解決の関数ツール呼び出しを Runner が処理する方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから確認できるエラー出力を返すようオプトインできます。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、Runner が処理する方法を設定します。デフォルトの `"warn"` は、対応方法を示す警告をログに記録し、現在ディスパッチ対象となっているものだけを公開します。`"error"` は、モデルが呼び出される前に `UserError` を発生させます。名前空間付きツールと遅延読み込みツールに対する厳密な検証は変更されません。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから確認できるツールエラーメッセージをカスタマイズします。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出しの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合に、ランナーが処理する方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。オプトインすると、代わりにモデルから見えるエラー出力を返します。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、ランナーが処理する方法を設定します。デフォルトの `"warn"` では、対処方法を示す警告をログに記録し、現在のディスパッチ先として選択されたものだけを公開します。`"error"` では、モデルを呼び出す前に `UserError` が発生します。名前空間付きツールと遅延読み込みツールに対する厳密な検証は変更されません。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから見えるツールエラーメッセージをカスタマイズします。 -ネストされたハンドオフは、オプトインのベータ機能として利用できます。`RunConfig(nest_handoff_history=True)` を渡すか、`handoff(..., nest_handoff_history=True)` を設定すると、特定のハンドオフについて順序付きのトランスクリプト圧縮を有効にできます。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージへ圧縮するのではなく、欠損のないメッセージ項目を囲むように、生成された assistant 要約セグメントを配置します。raw のトランスクリプトを保持する場合(デフォルト)は、フラグを設定しないか、必要な形式で会話を転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成された要約セグメントで使用されるラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。デフォルトへ戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します。 +ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付けられたトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡すか、特定のハンドオフに対して有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントをロスレスなメッセージ項目の前後に配置します。raw なトランスクリプトを保持する場合(デフォルト)は、フラグを未設定のままにするか、必要な形式で会話をそのまま転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成される要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 #### 実行設定の詳細 ##### `tool_execution` -実行中のローカル関数ツールの並行処理数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 +実行時のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成すると、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツールの数を制限するには、整数値を設定します。 +`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成した場合、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツール呼び出し数の上限を設定するには、整数値を指定します。 -これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別の設定です。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルが生成した後に、SDK がローカル関数ツール呼び出しを実行する方法を制御します。 +これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別のものです。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがツール呼び出しを生成した後に、SDK がローカル関数ツール呼び出しを実行する方法を制御します。 -`pre_approval_tool_input_guardrails=False` は、デフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後の実行直前にのみ実行されます。保留中の承認による中断が生成される前に、関数ツールの入力ガードレールを実行する場合は `True` を設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間依存のチェックは実行前に再検証されます。 +`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、承認後、実行直前にのみツール入力ガードレールが実行されます。保留中の承認による中断が生成される前に関数ツール入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間依存のチェックは実行前に再検証されます。 ##### `tool_not_found_behavior` -デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 +デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合、ランナーは `ModelBehaviorError` を発生させます。 -実行を復旧可能な状態に保つには、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は未解決のツール呼び出しに対する `function_call_output` を追加し、モデルを再実行します。これにより、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 +実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は解決できなかったツール呼び出しに対して `function_call_output` を追加し、モデルを再度実行します。これにより、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 ```python from agents import Agent, RunConfig, Runner @@ -214,22 +214,22 @@ result = await Runner.run( ) ``` -現在、このオプションは未解決の関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、既存のエラー動作が引き続き使用されます。 +現在、このオプションはツール名の検索に失敗した関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードには、引き続き既存のエラー動作が適用されます。 ##### `tool_error_formatter` -SDK がモデルから確認できるツールエラー出力を作成する際に、モデルへ返されるメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 +SDK がモデルから見えるツールエラー出力を作成したときにモデルへ返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 -フォーマッターは、次の情報を含む [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 +フォーマッターは、次の内容を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 -- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリー。 -- `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)。 -- `tool_name`:ツール名。 -- `call_id`:ツール呼び出し ID。 -- `default_message`:モデルから確認できる SDK のデフォルトメッセージ。 -- `run_context`:アクティブな実行コンテキストラッパー。 +- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリーです。 +- `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)です。 +- `tool_name`:ツール名です。 +- `call_id`:ツール呼び出し ID です。 +- `default_message`:SDK のデフォルトの、モデルから見えるメッセージです。 +- `run_context`:有効な実行コンテキストラッパーです。 -メッセージを置き換えるには文字列を返し、SDK のデフォルトを使用するには `None` を返します。 +メッセージを置き換える文字列を返すか、SDK のデフォルトを使用する場合は `None` を返します。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,16 +256,16 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` は、Runner が履歴を次へ引き継ぐ際に、推論項目を次のターンのモデル入力へ変換する方法を制御します。たとえば、`RunResult.to_input_list()` を使用する場合や、セッションを利用した実行が対象です。 +`reasoning_item_id_policy` は、ランナーが履歴を次へ引き継ぐ際(たとえば、`RunResult.to_input_list()` やセッションを利用した実行を使用する場合)に、推論項目を次のターンのモデル入力へ変換する方法を制御します。 -- `None` または `"preserve"`(デフォルト):推論項目の ID を保持します。 -- `"omit"`:生成される次のターンの入力から、推論項目の ID を削除します。 +- `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 +- `"omit"`:生成される次のターンの入力から推論項目 ID を削除します。 -`"omit"` は主に、推論項目が `id` 付きで送信されたものの、後続に必要な項目がない場合に発生する Responses API の 400 エラーへのオプトインの緩和策として使用します。たとえば、`Item 'rs_...' of type 'reasoning' was provided without its required following item.` というエラーです。 +`"omit"` は主に、推論項目が `id` とともに送信されているものの、後続に必要な項目(たとえば、`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)がない場合に発生する、Responses API の 400 エラーの一種に対するオプトインの緩和策として使用します。 -これは、SDK が以前の出力から後続の入力を構築する複数ターンのエージェント実行で発生する可能性があります。対象には、セッションの永続化、サーバー管理の会話差分、ストリーミング/非ストリーミングの後続ターン、再開パスが含まれます。推論項目の ID が保持されていても、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合に発生します。 +これは、SDK が以前の出力から後続の入力を構築する複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。推論項目 ID が保持されている一方で、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合に発生します。 -`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` が削除されます。これにより、SDK が生成する後続入力でその API 不変条件に抵触することを回避できます。 +`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` は削除されます。これにより、SDK が生成する後続入力で、その API の不変条件に抵触することを回避できます。 適用範囲に関する注意事項: @@ -275,33 +275,33 @@ result = Runner.run_sync( ## 状態と会話の管理 -### メモリー戦略の選択 +### メモリ戦略の選択 状態を次のターンへ引き継ぐ一般的な方法は 4 つあります。 -| 戦略 | 状態の保存場所 | 適した用途 | 次のターンで渡すもの | +| 戦略 | 状態の保存場所 | 最適な用途 | 次のターンで渡すもの | | --- | --- | --- | --- | -| `result.to_input_list()` | アプリのメモリー | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | +| `result.to_input_list()` | アプリケーションのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | | `session` | ストレージと SDK | 永続的なチャット状態、再開可能な実行、カスタムストア | 同じ `session` インスタンス、または同じストアを参照する別のインスタンス | -| `conversation_id` | OpenAI Conversations API | ワーカーやサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | -| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに使用する、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | +| `conversation_id` | OpenAI Conversations API | ワーカーまたはサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | +| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに行う、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | -`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両方のレイヤーを意図的に調整している場合を除き、コンテキストが重複する可能性があります。 +`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI によって管理され、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両方のレイヤーを意図的に調整している場合を除き、コンテキストが重複する可能性があります。 !!! note - セッションの永続化は、サーバー管理の会話設定 - (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)と - 同じ実行内で併用できません。呼び出しごとに 1 つの方法を選択してください。 + 同じ実行内で、セッションの永続化とサーバー管理の会話設定 + (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)を + 組み合わせることはできません。呼び出しごとにいずれか 1 つの方法を選択してください。 -### 会話/チャットスレッド +### 会話とチャットスレッド -いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される場合があり、その結果として 1 回以上の LLM 呼び出しが発生する可能性があります。ただし、チャット会話においては論理的に 1 つのターンを表します。たとえば、次のようになります。 +いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があり、その結果、LLM が 1 回以上呼び出されることがあります。ただし、これはチャット会話における論理的な 1 ターンを表します。例: -1. ユーザーターン:ユーザーがテキストを入力します -2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 +1. ユーザーターン:ユーザーがテキストを入力します。 +2. ランナー実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力だけを表示することもできます。いずれの場合も、ユーザーが追加の質問をする可能性があり、その際は run メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力のみを表示することもできます。いずれの場合も、その後ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 #### 手動による会話管理 @@ -353,24 +353,24 @@ async def main(): # California ``` -Sessions は、次の処理を自動的に行います。 +Sessions は次の処理を自動的に行います。 - 各実行前に会話履歴を取得します - 各実行後に新しいメッセージを保存します - セッション ID ごとに個別の会話を維持します -詳しくは、[Sessions のドキュメント](sessions/index.md)をご覧ください。 +詳細については、[Sessions のドキュメント](sessions/index.md)を参照してください。 #### サーバー管理の会話 -`to_input_list()` や `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能にサーバー側の会話状態を管理させることもできます。これにより、過去のすべてのメッセージを手動で再送信することなく、会話履歴を保持できます。以下のどちらのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳しくは、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 +`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能にサーバー側の会話状態を管理させることもできます。これにより、過去のすべてのメッセージを手動で再送信することなく、会話履歴を保持できます。以下のいずれのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)を参照してください。 -OpenAI は、ターン間で状態を追跡するための方法を 2 つ提供しています。 +OpenAI では、ターンをまたいで状態を追跡する方法を 2 つ提供しています。 ##### 1. `conversation_id` の使用 -まず OpenAI Conversations API を使用して会話を作成し、その後の各呼び出しでその ID を再利用します。 +最初に OpenAI Conversations API を使用して会話を作成し、その後のすべての呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -393,7 +393,7 @@ async def main(): ##### 2. `previous_response_id` の使用 -もう 1 つの方法は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID へ明示的に関連付けます。 +もう 1 つの選択肢は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的に関連付けます。 ```python from agents import Agent, Runner @@ -418,31 +418,31 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を維持するため、再開したターンは同じサーバー管理の会話内で継続されます。 +実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を維持するため、再開したターンは同じサーバー管理の会話で継続されます。 -`conversation_id` と `previous_response_id` は相互排他的です。システム間で共有できる名前付きの会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 +`conversation_id` と `previous_response_id` は相互排他的です。システム間で共有できる名前付きの会話リソースが必要な場合は、`conversation_id` を使用します。ターン間を継続するための最も軽量な Responses API の基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 !!! note - SDK は `conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話を使用する実行では、再試行前に内部の会話トラッカー入力を巻き戻し、 - 準備済みの同じ項目を問題なく再送信できるようにします。 + SDK は、`conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の + 会話を使用した実行では、再試行前に内部の会話トラッカー入力を巻き戻すため、 + 同じ準備済み項目を問題なく再送信できます。 ローカルのセッションベースの実行(`conversation_id`、 - `previous_response_id`、または `auto_previous_response_id` とは併用不可)では、SDK は - 再試行後の履歴項目の重複を減らすため、直近に永続化された入力項目の - ベストエフォートなロールバックも行います。 + `previous_response_id`、または `auto_previous_response_id` とは組み合わせられません)では、 + SDK は最近永続化された入力項目をベストエフォートでロールバックし、 + 再試行後の履歴エントリの重複を減らします。 この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも行われます。モデルリクエストに対する - より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)をご覧ください。 + より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 ## フックとカスタマイズ -### モデル呼び出し入力フィルター +### モデル呼び出しの入力フィルター -モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、および結合済みの入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 +モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。フックは、現在のエージェント、コンテキスト、および統合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 -戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。それ以外の形式を返すと `UserError` が発生します。 +戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。その他の形式を返すと `UserError` が発生します。 ```python from agents import Agent, Runner, RunConfig @@ -461,19 +461,19 @@ result = Runner.run_sync( ) ``` -Runner は準備済み入力リストのコピーをフックへ渡すため、呼び出し元の元のリストをその場で変更せずに、切り詰め、置き換え、並べ替えを行えます。 +ランナーは準備済み入力リストのコピーをフックへ渡すため、呼び出し元の元のリストをその場で変更することなく、項目を短縮、置換、または並べ替えできます。 -セッションを使用している場合、`call_model_input_filter` はセッション履歴が読み込まれ、現在のターンとマージされた後に実行されます。それより前のマージ処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 +セッションを使用している場合、`call_model_input_filter` はセッション履歴がすでに読み込まれ、現在のターンと統合された後に実行されます。それより前の統合ステップ自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 -`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目だけが、そのサーバー管理の継続処理で送信済みとして記録されます。 +`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。このペイロードは、過去の履歴の完全な再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続処理で送信済みとして記録されます。 -機密データの秘匿化、長い履歴の切り詰め、追加のシステムガイダンスの注入を行うには、`run_config` を介して実行ごとにフックを設定します。 +機密データの削除、長い履歴の短縮、追加のシステムガイダンスの挿入を行うには、`run_config` を使用して実行ごとにフックを設定します。 ## エラーと復旧 ### エラーハンドラー -すべての `Runner` エントリーポイントは、エラー種別をキーとする dict である `error_handlers` を受け入れます。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 +すべての `Runner` エントリポイントは、エラー種別をキーとする dict である `error_handlers` を受け取ります。サポートされるキーは、`"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 ```python from agents import ( @@ -502,7 +502,7 @@ result = Runner.run_sync( print(result.final_output) ``` -モデルのメッセージがエージェントの構造化された `output_type` に対する検証を通過しない場合、またはモデルが構造化された最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を辞退します。フォールバックがない場合、空でない出力の検証失敗では引き続き `ModelBehaviorError` が発生し、空の構造化レスポンスでは既存の次ターンの動作が維持されます。 +モデルメッセージがエージェントの structured `output_type` に対して検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を行いません。フォールバックがない場合、空でないレスポンスの検証失敗では引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 ```python from pydantic import BaseModel @@ -534,9 +534,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数のハンドラーでは、生成されたフォールバック出力を会話履歴へ追加し、設定済みのセッションに永続化します。実行結果の履歴やセッションストレージへ追加せず、呼び出し元へフォールバックを返す場合は、`include_in_history=False` を設定します。 +`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数のハンドラーでは、合成されたフォールバック出力を会話履歴に追加し、設定されたセッションへ永続化します。実行結果の履歴やセッションストレージに追加せず、フォールバックを呼び出し元へ返す場合は、`include_in_history=False` を設定します。 -モデルの拒否によって `ModelRefusalError` で実行を終了する代わりに、アプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 +モデルによる拒否が発生した際に、`ModelRefusalError` で実行を終了する代わりにアプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 ```python from pydantic import BaseModel @@ -568,35 +568,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続実行の統合とヒューマンインザループ +## 永続的な実行の統合とヒューマンインザループ -ツールの承認に関する一時停止/再開パターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長い待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 +ツール承認の一時停止と再開のパターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長時間の待機、再試行、またはプロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 ### Dapr -Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、ヒューマンインザループをサポートし、障害から自動的に復旧する、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの利用は、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 +Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、ヒューマンインザループのワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの使用を[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 ### Temporal -Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。長時間実行タスクを完了するために Temporal と Agents SDK が連携して動作するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)です。 +Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモを[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認し、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)から参照できます。 ### Restate -Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実行できます。この統合は Restate の単一バイナリランタイムを依存関係として必要とし、エージェントをプロセス/コンテナまたはサーバーレス関数として実行できます。詳しくは、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 +Agents SDK の [Restate](https://restate.dev/) 統合は、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントに使用できます。この統合では、依存関係として Restate の単一バイナリランタイムが必要です。また、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)を参照してください。 ### DBOS -Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートしています。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは SQLite または Postgres データベースだけです。詳しくは、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 +Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進捗を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは、SQLite または Postgres データベースのみです。詳細については、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)を参照してください。 ## 例外 -SDK は特定の場合に例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 +SDK は特定の状況で例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 -- [`AgentsException`][agents.exceptions.AgentsException]:SDK 内で発生するすべての例外の基底クラスです。その他すべての特定の例外は、この汎用型から派生します。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が `Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドへ渡された `max_turns` 制限を超えた場合に発生します。指定された対話ターン数以内に、エージェントがタスクを完了できなかったことを示します。制限を無効にするには `max_turns=None` を設定します。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない、または無効な出力を生成した場合に発生します。次のようなケースが含まれます。 - - 不正な形式の JSON:モデルがツール呼び出しまたは直接出力で不正な形式の JSON 構造を生成した場合。特に、特定の `output_type` が定義されている場合が該当します。 - - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定済みのタイムアウトを超え、そのツールで `timeout_behavior="raise_exception"` が使用されている場合に発生します。 -- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードの作成者が、SDK の使用時に誤りを犯した場合に発生します。通常は、不適切なコード実装、無効な設定、SDK API の誤用が原因です。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:それぞれ、入力ガードレールまたは出力ガードレールの条件が満たされた場合に発生します。入力ガードレールは処理前に受信メッセージをチェックし、出力ガードレールは配信前にエージェントの最終レスポンスをチェックします。 \ No newline at end of file +- [`AgentsException`][agents.exceptions.AgentsException]:SDK が発生させるすべての例外の基底クラスです。他のすべての具体的な例外の派生元となる汎用型です。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドへ渡された `max_turns` 制限を超えた場合に発生します。指定されたエージェントループのターン数(LLM 呼び出し回数)以内に、エージェントがタスクを完了できなかったことを示します。この制限を無効にするには、`max_turns=None` を設定します。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。これには次のものが含まれます。 + - 不正な JSON:モデルがツール呼び出しまたは直接の出力で、不正な JSON 構造を提供した場合です。特に、特定の `output_type` が定義されている場合に該当します。 + - 予期しないツール関連の障害:モデルが想定された方法でツールを使用できなかった場合です +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定されたタイムアウトを超え、そのツールが `timeout_behavior="raise_exception"` を使用している場合に発生します。 +- [`UserError`][agents.exceptions.UserError]:SDK を使用してコードを記述する人が、SDK の使用時に誤りを犯した場合に発生します。通常は、不適切なコード実装、無効な設定、または SDK API の誤用が原因です。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:入力ガードレールの条件を満たすと `InputGuardrailTripwireTriggered` が発生し、出力ガードレールの条件を満たすと `OutputGuardrailTripwireTriggered` が発生します。入力ガードレールは処理前に受信メッセージを確認し、出力ガードレールは配信前にエージェントの最終レスポンスを確認します。 \ No newline at end of file diff --git a/docs/ja/sandbox/clients.md b/docs/ja/sandbox/clients.md index 69a3224158..44d52ffe75 100644 --- a/docs/ja/sandbox/clients.md +++ b/docs/ja/sandbox/clients.md @@ -4,7 +4,7 @@ search: --- # サンドボックスクライアント -このページでは、サンドボックスでの処理を実行する場所を選択します。ほとんどの場合、`SandboxAgent` の定義はそのまま使用し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] のサンドボックスクライアントとクライアント固有のオプションのみを変更します。 +このページでは、サンドボックスでの処理を実行する場所を選択できます。ほとんどの場合、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントとクライアント固有のオプションのみを変更し、`SandboxAgent` の定義はそのまま使用します。 !!! warning "ベータ機能" @@ -14,32 +14,32 @@ search:
-| 目的 | 最初に使用するもの | 理由 | +| 目的 | 最初の選択肢 | 理由 | | --- | --- | --- | -| macOS または Linux で最速のローカル反復開発 | `UnixLocalSandboxClient` | 追加のインストールが不要で、ローカルファイルシステムを使用した開発が簡単です。 | -| 基本的なコンテナ分離 | `DockerSandboxClient` | 指定したイメージを使用して Docker 内で処理を実行します。 | -| ホスト環境での実行または本番環境相当の分離 | ホスト型サンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境へ移します。 | +| macOS または Linux での最速のローカル反復 | `UnixLocalSandboxClient` | 追加インストールが不要で、ローカルファイルシステムを使用した開発が容易です。 | +| 基本的なコンテナ分離 | `DockerSandboxClient` | 指定したイメージを使用し、Docker 内で処理を実行します。 | +| ホスト実行または本番環境相当の分離 | ホスト型サンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境へ移します。 |
## ローカルクライアント -ほとんどのユーザーは、次の 2 つのサンドボックスクライアントのいずれかから開始することをお勧めします。 +ほとんどのユーザーには、次の 2 つのサンドボックスクライアントのいずれかを最初に使用することをお勧めします。
-| クライアント | インストール | 適している状況 | コード例 | +| クライアント | インストール | 選択する場合 | コード例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカル反復開発を行う場合。ローカル開発に適したデフォルトです。 | [Unix-local スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離が必要な場合、またはローカル環境との整合性を保つために特定のイメージを使用する場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカル反復を行う場合。ローカル開発の優れたデフォルトです。 | [Unix ローカルのスターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離が必要な場合や、対象環境をローカルで再現するために特定のイメージを使用する場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local は、ローカルファイルシステムを対象とした開発を開始する最も簡単な方法です。より強力な環境分離や本番環境相当の整合性が必要になった場合は、Docker またはホスト型プロバイダーへ移行してください。 +Unix ローカルは、ローカルファイルシステムを対象とした開発を始める最も簡単な方法です。より強力な環境分離や本番環境相当の一貫性が必要になった場合は、Docker またはホスト型プロバイダーへ移行してください。 -`SandboxPathGrant.host_path` は Docker 専用であり、ホスト上のパスをコンテナ内の別の POSIX パスへマッピングします。Unix-local では、同一パスへの許可のみがサポートされます。詳細については、[マニフェストのパス許可](guide.md#manifest)を参照してください。 +`SandboxPathGrant.host_path` は Docker 専用で、ホスト上のパスをコンテナ内の別の POSIX パスにマッピングします。Unix ローカルでは、同一パスの許可のみがサポートされます。詳細については、[マニフェストのパス許可](guide.md#manifest)を参照してください。 -Unix-local から Docker へ切り替えるには、エージェント定義をそのまま維持し、実行設定のみを変更します。 +Unix ローカルから Docker に切り替えるには、エージェント定義をそのまま維持し、実行設定のみを変更します。 ```python from docker import from_env as docker_from_env @@ -56,41 +56,41 @@ run_config = RunConfig( ) ``` -コンテナ分離またはイメージの整合性が必要な場合に使用してください。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)を参照してください。 +コンテナ分離が必要な場合や、サンドボックスイメージを別の環境で使用されるイメージと一致させる場合に使用します。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 ## マウントとリモートストレージ -マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホスト型プロバイダー向けの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 +マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホスト型プロバイダーの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 一般的なマウントオプションは次のとおりです。 -- `mount_path`: サンドボックス内でストレージが配置される場所です。相対パスはマニフェストのルートを基準に解決され、絶対パスはそのまま使用されます。 -- `read_only`: デフォルトは `True` です。サンドボックスからマウント済みストレージへ書き戻す必要がある場合にのみ、`False` に設定してください。 +- `mount_path`: サンドボックス内でストレージが表示される場所です。相対パスはマニフェストルートを基準に解決され、絶対パスはそのまま使用されます。 +- `read_only`: デフォルトは `True` です。サンドボックスからマウント済みストレージへ書き戻す必要がある場合のみ、`False` を設定します。 - `mount_strategy`: 必須です。マウントエントリとサンドボックスバックエンドの両方に適合する戦略を使用してください。 -マウントは、一時的なワークスペースエントリとして扱われます。スナップショットと永続化のフローでは、マウントされたリモートストレージを保存済みワークスペースへコピーする代わりに、マウント済みパスを切り離すかスキップします。 +マウントは一時的なワークスペースエントリとして扱われます。スナップショットと永続化のフローでは、マウント済みのリモートストレージを保存対象のワークスペースへコピーするのではなく、マウント済みパスを切り離すかスキップします。 -汎用のローカル/コンテナ戦略は次のとおりです。 +汎用的なローカル/コンテナ戦略は次のとおりです。
-| 戦略またはパターン | 適している状況 | 注記 | +| 戦略またはパターン | 使用する場合 | 注記 | | --- | --- | --- | | `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | サンドボックスイメージで `rclone` を実行できる場合。 | S3、GCS、R2、Azure Blob、Box をサポートします。`RcloneMountPattern` は `fuse` モードまたは `nfs` モードで実行できます。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` が含まれており、Mountpoint 形式で S3 または S3 互換ストレージへアクセスする場合。 | `S3Mount` と `GCSMount` をサポートします。 | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | イメージに `blobfuse2` と FUSE のサポートが含まれている場合。 | `AzureBlobMount` をサポートします。 | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | イメージに `mount.s3files` が含まれており、既存の S3 Files マウントターゲットへ接続できる場合。 | `S3FilesMount` をサポートします。 | -| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを使用したマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` をサポートし、S3 と GCS は `mountpoint` もサポートします。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` があり、Mountpoint 形式で S3 または S3 互換ストレージへアクセスする場合。 | `S3Mount` と `GCSMount` をサポートします。 | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | イメージに `blobfuse2` があり、FUSE をサポートしている場合。 | `AzureBlobMount` をサポートします。 | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | イメージに `mount.s3files` があり、既存の S3 Files マウントターゲットへ接続できる場合。 | `S3FilesMount` をサポートします。 | +| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを利用するマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` を使用してマウントできます。S3 と GCS は `mountpoint` を使用してマウントすることもできます。 |
## サポート対象のホスト型プラットフォーム -ホスト型環境が必要な場合でも、通常は同じ `SandboxAgent` 定義をそのまま使用でき、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] のサンドボックスクライアントのみを変更します。 +ホスト型環境が必要な場合、通常は同じ `SandboxAgent` の定義をそのまま使用し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントのみを変更します。 -このリポジトリのチェックアウトではなく公開版 SDK を使用している場合は、対応するパッケージの追加依存関係を通じてサンドボックスクライアントの依存関係をインストールしてください。 +このリポジトリをチェックアウトしたものではなく、公開版 SDK を使用している場合は、対応するパッケージの extra を使用してサンドボックスクライアントの依存関係をインストールしてください。 -プロバイダー固有の設定に関する注記と、リポジトリに含まれる拡張機能のコード例へのリンクについては、[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)を参照してください。 +プロバイダー固有のセットアップに関する注記と、リポジトリに含まれる拡張機能のコード例へのリンクについては、[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md) を参照してください。
@@ -106,20 +106,20 @@ run_config = RunConfig(
-ホスト型サンドボックスクライアントは、プロバイダー固有のマウント戦略を提供します。ストレージプロバイダーに最も適したバックエンドとマウント戦略を選択してください。 +ホスト型サンドボックスクライアントは、プロバイダー固有のマウント戦略を公開します。ストレージプロバイダーに最適なバックエンドとマウント戦略を選択してください。
| バックエンド | マウントに関する注記 | | --- | --- | -| Docker | `InContainerMountStrategy` や `DockerVolumeMountStrategy` などのローカル戦略を使用して、`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` をサポートします。 | -| `ModalSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証済みの `GCSMount` で、`ModalCloudBucketMountStrategy` を使用した Modal クラウドバケットのマウントをサポートします。インライン認証情報または名前付き Modal Secret を使用できます。 | -| `CloudflareSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証済みの `GCSMount` で、`CloudflareBucketMountStrategy` を使用した Cloudflare バケットのマウントをサポートします。 | -| `BlaxelSandboxClient` | `S3Mount`、`R2Mount`、`GCSMount` で、`BlaxelCloudBucketMountStrategy` を使用したクラウドバケットのマウントをサポートします。また、`agents.extensions.sandbox.blaxel` の `BlaxelDriveMount` と `BlaxelDriveMountStrategy` を使用した永続的な Blaxel Drive もサポートします。 | -| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` を使用した、rclone ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | -| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` を使用した、rclone ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | -| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` を使用した、rclone ベースのクラウドストレージマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用してください。 | -| `VercelSandboxClient` | `S3Mount` で `VercelCloudBucketMountStrategy` を使用した、作成時のみの S3 および S3 互換バケットのマウントをサポートします。マウントされたセッションは再開できません。また、インライン認証情報を使用するには `allow_s3_credential_exposure=True` が必要です。 | +| Docker | `InContainerMountStrategy` や `DockerVolumeMountStrategy` などのローカル戦略で、`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` をサポートします。 | +| `ModalSandboxClient` | `ModalCloudBucketMountStrategy` を `S3Mount`、`R2Mount`、HMAC 認証の `GCSMount` とともに使用することで、クラウドバケットのマウントをサポートします。インライン認証情報または名前付き Modal Secret を使用できます。 | +| `CloudflareSandboxClient` | `CloudflareBucketMountStrategy` を `S3Mount`、`R2Mount`、HMAC 認証の `GCSMount` とともに使用することで、バケットのマウントをサポートします。 | +| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy` と `S3Mount`、`R2Mount`、または `GCSMount` のエントリを組み合わせることで、クラウドバケットのマウントをサポートします。また、`BlaxelDriveMount` と `BlaxelDriveMountStrategy` による永続的な Blaxel Drives もサポートします。どちらも `agents.extensions.sandbox.blaxel` から利用できます。 | +| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` を使用して `rclone` 経由でクラウドストレージをマウントできます。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` とともに使用してください。 | +| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` を使用して `rclone` 経由でクラウドストレージをマウントできます。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` とともに使用してください。 | +| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` を使用して `rclone` 経由でクラウドストレージをマウントできます。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` とともに使用してください。 | +| `VercelSandboxClient` | `VercelCloudBucketMountStrategy` と `S3Mount` のエントリを組み合わせることで、作成時に限り S3 および S3 互換バケットのマウントをサポートします。マウント済みセッションは再開できません。また、インライン認証情報には `allow_s3_credential_exposure=True` が必要です。 |
@@ -140,4 +140,4 @@ run_config = RunConfig( -実行可能なコード例をさらに確認するには、ローカル、コーディング、メモリ、ハンドオフ、エージェント構成のパターンについては [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)を、ホスト型サンドボックスクライアントについては [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)を参照してください。 \ No newline at end of file +その他の実行可能なコード例については、ローカル、コーディング、メモリ、ハンドオフ、エージェント構成のパターンを扱う [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) と、ホスト型サンドボックスクライアントを扱う [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) を参照してください。 \ No newline at end of file diff --git a/docs/ja/sandbox/guide.md b/docs/ja/sandbox/guide.md index 242f74389c..7de2178362 100644 --- a/docs/ja/sandbox/guide.md +++ b/docs/ja/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "ベータ機能" - サンドボックスエージェントはベータ版です。一般提供の開始前に、API の詳細、デフォルト値、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 + サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果的に機能します。 **サンドボックスエージェント** は、専用ツールやシェルコマンドを使用して、大規模なドキュメントセットの検索と操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために使用できる永続的なワークスペースをモデルに提供します。Agents SDK のサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。適切なファイルをファイルシステム上に配置し、サンドボックスをオーケストレーションして、大規模なタスクを容易に開始、停止、再開できます。 +最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果を発揮します。**サンドボックスエージェント**は、専用ツールとシェルコマンドを使用して、大規模なドキュメント群の検索や操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために使用できる永続的なワークスペースをモデルに提供します。Agents SDK のサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。また、適切なファイルをファイルシステムに配置し、サンドボックスをオーケストレーションすることで、大規模なタスクの開始、停止、再開を容易に行えます。 -エージェントに必要なデータを中心にワークスペースを定義します。GitHub リポジトリ、ローカルのファイルとディレクトリ、合成されたタスクファイル、S3 や Azure Blob Storage などのリモートファイルシステム、および指定したその他のサンドボックス入力から開始できます。 +エージェントが必要とするデータに基づいてワークスペースを定義します。GitHub リポジトリ、ローカルのファイルやディレクトリ、合成されたタスクファイル、S3 や Azure Blob Storage などのリモートファイルシステム、および指定したその他のサンドボックス入力から開始できます。
-![コンピュート機能を備えたサンドボックスエージェントのハーネス](../assets/images/harness_with_compute.png) +![コンピュート機能を備えたサンドボックスエージェントハーネス](../assets/images/harness_with_compute.png)
-`SandboxAgent` も引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントのインターフェースを維持し、通常の `Runner` API を通じて実行されます。異なるのは実行境界です。 +`SandboxAgent` は引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントインターフェースを維持し、通常の `Runner` API を介して実行されます。変更されるのは実行境界です。 -- `SandboxAgent` はエージェント自体を定義します。これには、通常のエージェント設定に加え、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト値と、ファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能が含まれます。 -- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースで求められる初期コンテンツとレイアウトを宣言します。 -- サンドボックスセッションは、コマンドが実行され、ファイルが変更される、稼働中の分離環境です。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、実行でそのサンドボックスセッションを取得する方法を決定します。たとえば、直接注入する、シリアライズされたサンドボックスセッション状態から再接続する、サンドボックスクライアントを通じて新しいサンドボックスセッションを作成する、などです。 -- 保存されたサンドボックス状態とスナップショットにより、後続の実行で以前の作業に再接続したり、保存されたコンテンツを使用して新しいサンドボックスセッションを初期化したりできます。 +- `SandboxAgent` は、エージェント自体を定義します。通常のエージェント設定に加え、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト、およびファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能を定義します。 +- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースの初期コンテンツとレイアウトを宣言します。 +- サンドボックスセッションは、コマンドが実行され、ファイルが変更される稼働中の分離環境です。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、実行がサンドボックスセッションを取得する方法を決定します。たとえば、サンドボックスセッションを直接注入する、シリアライズされたサンドボックスセッション状態から再接続する、サンドボックスクライアントを介して新しいサンドボックスセッションを作成する、といった方法があります。 +- 保存済みのサンドボックス状態とスナップショットを使用すると、後続の実行で以前の作業に再接続したり、保存済みコンテンツから新しいサンドボックスセッションを初期化したりできます。 -`Manifest` は新規セッションのワークスペースに関する契約であり、稼働中のすべてのサンドボックスに対する完全な信頼できる情報源ではありません。実行で有効になるワークスペースは、再利用されたサンドボックスセッション、シリアライズされたサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合もあります。 +`Manifest` は新規セッションのワークスペース契約であり、稼働中の各サンドボックスに関する完全な信頼できる情報源ではありません。実行における有効なワークスペースは、再利用されたサンドボックスセッション、シリアライズされたサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合があります。 -このページ全体で「サンドボックスセッション」とは、サンドボックスクライアントが管理する稼働中の実行環境を意味します。これは、[セッション](../sessions/index.md)で説明している SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 +このページでは、「サンドボックスセッション」とは、サンドボックスクライアントによって管理される稼働中の実行環境を指します。これは、[セッション](../sessions/index.md)で説明されている SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 -外側のランタイムは、引き続き承認、トレーシング、ハンドオフ、再開用の記録を管理します。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を管理します。この分担はモデルの中核部分です。 +外側のランタイムは引き続き、承認、トレーシング、ハンドオフ、および実行の再開に必要な状態の追跡を担当します。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担当します。この分離は、モデルの中核をなす要素です。 ### 各要素の関係 -サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備し、稼働中のサンドボックスセッションにバインドし、後続の実行用に状態を保存できます。 +サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備し、稼働中のサンドボックスセッションにバインドし、後続の実行に備えて状態を保存できます。 ```mermaid flowchart LR @@ -50,175 +50,175 @@ flowchart LR sandbox --> saved ``` -サンドボックス固有のデフォルト値は `SandboxAgent` に保持します。実行ごとのサンドボックスセッションの選択は `SandboxRunConfig` に保持します。 +サンドボックス固有のデフォルトは `SandboxAgent` に保持します。実行ごとのサンドボックスセッションの選択は `SandboxRunConfig` に保持します。 -ライフサイクルは、次の 3 つのフェーズで考えます。 +ライフサイクルは、次の 3 つのフェーズに分けて考えます。 -1. `SandboxAgent`、`Manifest`、各種機能を使用して、エージェントと新規ワークスペースに関する契約を定義します。 -2. `Runner` に、サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を指定して、実行を開始します。 -3. ランナーが管理する `RunState`、明示的なサンドボックスの `session_state`、または保存されたワークスペーススナップショットから後で続行します。 +1. `SandboxAgent`、`Manifest`、および各種機能を使用して、エージェントと新規ワークスペース契約を定義します。 +2. サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を `Runner` に指定して、実行を開始します。 +3. ランナーが管理する `RunState`、明示的なサンドボックス `session_state`、または保存済みのワークスペーススナップショットから、後で処理を続行します。 -シェルアクセスが時折使用するツールの 1 つにすぎない場合は、[ツールガイド](../tools.md)のホステッドシェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 +シェルアクセスがときどき使用するツールの 1 つにすぎない場合は、[ツールガイド](../tools.md)のホステッドシェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 -## 使用に適したケース +## 使用場面 サンドボックスエージェントは、次のようなワークスペース中心のワークフローに適しています。 -- コーディングとデバッグ。たとえば、GitHub リポジトリの問題報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 -- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォーム案を作成する場合 -- ファイルに基づくレビューや分析。たとえば、回答する前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 -- 分離されたマルチエージェントパターン。たとえば、各レビュー担当エージェントやコーディングサブエージェントに専用のワークスペースを割り当てる場合 -- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正して後で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 +- コーディングとデバッグ。たとえば、GitHub リポジトリ内の課題報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 +- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォームの下書きを作成する場合 +- ファイルに基づくレビューや分析。たとえば、回答前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 +- 分離されたマルチエージェントパターン。たとえば、各レビュアーやコーディング用サブエージェントに個別のワークスペースを与える場合 +- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正し、後で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 -ファイルや稼働状態を維持するファイルシステムへのアクセスが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスが時折使用する機能の 1 つにすぎない場合はホステッドシェルを追加し、ワークスペース境界自体が機能の一部である場合はサンドボックスエージェントを使用してください。 +ファイルや、状態を保持して変更可能なファイルシステムへのアクセスが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスがときどき使用する機能の 1 つにすぎない場合は、ホステッドシェルを追加します。ワークスペース境界自体が機能の一部である場合は、サンドボックスエージェントを使用します。 ## サンドボックスクライアントの選択 -macOS または Linux でのローカル開発には、`UnixLocalSandboxClient` から始めてください。Windows では、代わりに `DockerSandboxClient` またはホステッドプロバイダーを使用してください。サポートされているどのプラットフォームでも、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に移行し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーに移行してください。 +macOS または Linux でのローカル開発には、`UnixLocalSandboxClient` から始めてください。Windows では、`DockerSandboxClient` またはホステッドプロバイダーを使用してください。サポートされている任意のプラットフォームで、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に移行し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーに移行してください。 -ほとんどの場合、`SandboxAgent` の定義は同じままで、サンドボックスクライアントとそのオプションのみを [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] で変更します。ローカル、Docker、ホステッド、リモートマウントの各オプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +ほとんどの場合、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントとそのオプションを変更しても、`SandboxAgent` の定義は同じままです。ローカル、Docker、ホステッド、リモートマウントのオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 ## 中核要素
-| レイヤー | 主な SDK 要素 | 回答する問い | +| レイヤー | SDK の主要要素 | 回答する内容 | | --- | --- | --- | -| エージェント定義 | `SandboxAgent`、`Manifest`、各種機能 | どのエージェントを実行し、どの新規セッション用ワークスペース契約から開始するか? | -| サンドボックス実行 | `SandboxRunConfig`、サンドボックスクライアント、稼働中のサンドボックスセッション | この実行は稼働中のサンドボックスセッションをどのように取得し、作業はどこで実行されるか? | -| 保存されたサンドボックス状態 | `RunState` のサンドボックスペイロード、`session_state`、スナップショット | このワークフローは、以前のサンドボックス作業にどのように再接続するか、または保存されたコンテンツから新しいサンドボックスセッションをどのように初期化するか? | +| エージェント定義 | `SandboxAgent`、`Manifest`、各種機能 | どのエージェントを実行し、どの新規セッション用ワークスペース契約から開始しますか? | +| サンドボックス実行 | `SandboxRunConfig`、サンドボックスクライアント、稼働中のサンドボックスセッション | この実行は稼働中のサンドボックスセッションをどのように取得し、作業はどこで実行されますか? | +| 保存済みサンドボックス状態 | `RunState` サンドボックスペイロード、`session_state`、スナップショット | このワークフローは以前のサンドボックス作業にどのように再接続し、保存済みコンテンツから新しいサンドボックスセッションをどのように初期化しますか? |
-主な SDK 要素は、これらのレイヤーに次のように対応します。 +SDK の主要要素は、次のように各レイヤーに対応します。
-| 要素 | 管理対象 | 確認する問い | +| 要素 | 管理対象 | 確認する内容 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を行い、どのデフォルト値を引き継ぐべきか? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッション用ワークスペースのファイルとフォルダー | 実行開始時に、ファイルシステム上にどのファイルとフォルダーが存在すべきか? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、instructions の断片、またはランタイム動作をこのエージェントに関連付けるべきか? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションのソース | この実行では、サンドボックスセッションを注入、再開、作成のどれにするか? | -| [`RunState`][agents.run_state.RunState] | ランナーが管理する保存済みサンドボックス状態 | 以前のランナー管理ワークフローを再開し、そのサンドボックス状態を自動的に引き継いでいるか? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部ですでにシリアライズしたサンドボックス状態から再開するか? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション用に保存されたワークスペースコンテンツ | 新しいサンドボックスセッションを、保存されたファイルと成果物から開始するか? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を行い、どのデフォルト設定を引き継ぐ必要がありますか? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッション用ワークスペースのファイルとフォルダー | 実行開始時に、どのファイルとフォルダーがファイルシステム上に存在する必要がありますか? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、指示フラグメント、ランタイム動作をこのエージェントに関連付けますか? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションの取得元 | この実行では、サンドボックスセッションを注入、再開、作成のいずれで取得しますか? | +| [`RunState`][agents.run_state.RunState] | ランナーが管理する保存済みサンドボックス状態 | 以前のランナー管理ワークフローを再開し、そのサンドボックス状態を自動的に引き継ぎますか? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部ですでにシリアライズしたサンドボックス状態から再開しますか? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション向けに保存されたワークスペースコンテンツ | 新しいサンドボックスセッションを、保存済みのファイルや成果物から開始しますか? |
実用的な設計順序は次のとおりです。 -1. `Manifest` を使用して、新規セッションのワークスペースに関する契約を定義します。 -2. `SandboxAgent` を使用してエージェントを定義します。 -3. 組み込みまたはカスタムの機能を追加します。 +1. `Manifest` で新規セッション用ワークスペース契約を定義します。 +2. `SandboxAgent` でエージェントを定義します。 +3. 組み込み機能またはカスタム機能を追加します。 4. `RunConfig(sandbox=SandboxRunConfig(...))` で、各実行がサンドボックスセッションを取得する方法を決定します。 -## サンドボックス実行の準備方法 +## サンドボックス実行の準備 -実行時に、ランナーはその定義を具体的なサンドボックス対応の実行へ変換します。 +実行時に、ランナーはその定義を具体的なサンドボックスベースの実行に変換します。 1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡した場合は、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 -2. 実行で有効になるワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは 1 回限りのマニフェストオーバーライドまたは `agent.default_manifest` から開始します。このため、`Manifest` だけでは、すべての実行における最終的な稼働中のワークスペースは定義されません。 -3. 各機能が、生成されたマニフェストを処理できるようにします。これにより、最終的なエージェントが準備される前に、各機能がファイル、マウント、その他のワークスペーススコープの動作を追加できます。 -4. 固定された順序で最終的な instructions を構築します。まず SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、機能の instructions 断片、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 -5. 機能のツールを稼働中のサンドボックスセッションにバインドし、準備されたエージェントを通常の `Runner` API を通じて実行します。 +2. 実行に対して有効なワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは 1 回限りのマニフェストオーバーライドまたは `agent.default_manifest` から開始します。このため、`Manifest` だけでは、すべての実行における最終的な稼働中ワークスペースは定義されません。 +3. 各機能が、結果として得られたマニフェストを処理できるようにします。これにより、最終的なエージェントが準備される前に、各機能がファイル、マウント、その他のワークスペーススコープの動作を追加できます。 +4. 最終的な指示を固定順序で構築します。最初に SDK のデフォルトサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、機能の指示フラグメント、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 +5. 機能のツールを稼働中のサンドボックスセッションにバインドし、通常の `Runner` API を介して準備済みのエージェントを実行します。 -サンドボックス化によってターンの意味が変わることはありません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックス操作ではありません。サンドボックス側の操作とターンの間には、固定された 1 対 1 の対応関係はありません。一部の作業はサンドボックス実行レイヤー内に留まる場合がありますが、他のアクションでは、別のモデルステップを必要とするツール結果、承認、その他の状態が返されます。実用上の原則として、サンドボックス作業の発生後にエージェントランタイムが別のモデル応答を必要とする場合にのみ、次のターンが消費されます。 +サンドボックス化によってターンの意味が変わることはありません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックス操作ではありません。サンドボックス側の操作とターンの間に固定された 1 対 1 の対応関係はありません。一部の作業はサンドボックス実行レイヤー内にとどまる場合があり、その他のアクションではツールの実行結果、承認、別の種類の状態など、次のモデルステップを必要とする情報が返されます。実用上は、サンドボックスでの作業後にエージェントランタイムが別のモデル応答を必要とする場合にのみ、もう 1 ターン消費されます。 -これらの準備手順があるため、`SandboxAgent` を設計する際には、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` が、検討すべき主なサンドボックス固有のオプションです。 +これらの準備ステップがあるため、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` は、`SandboxAgent` を設計する際に検討すべき主要なサンドボックス固有オプションです。 ## `SandboxAgent` のオプション -通常の `Agent` フィールドに加えて、次のサンドボックス固有のオプションがあります。 +通常の `Agent` フィールドに加えて、次のサンドボックス固有オプションがあります。
| オプション | 最適な用途 | | --- | --- | | `default_manifest` | ランナーが作成する新しいサンドボックスセッションのデフォルトワークスペース。 | -| `instructions` | SDK のサンドボックスプロンプトの後に追加される、ロール、ワークフロー、成功条件。 | +| `instructions` | SDK のサンドボックスプロンプトの後に追加される、役割、ワークフロー、成功基準。 | | `base_instructions` | SDK のサンドボックスプロンプトを置き換える高度なエスケープハッチ。 | -| `capabilities` | このエージェントとともに引き継ぐサンドボックスネイティブのツールと動作。 | -| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けのサンドボックスツールに使用するユーザー ID。 | +| `capabilities` | このエージェントとともに引き継ぐ必要があるサンドボックスネイティブのツールと動作。 | +| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けサンドボックスツールのユーザー ID。 |
-サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェント上ではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に属します。 +サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェント上ではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に指定します。 ### `default_manifest` -`default_manifest` は、ランナーがこのエージェント用に新しいサンドボックスセッションを作成するときに使用されるデフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。エージェントが通常、作業開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 +`default_manifest` は、ランナーがこのエージェント用に新しいサンドボックスセッションを作成するときに使用される、デフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。エージェントが通常開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 -これはデフォルトにすぎません。実行では `SandboxRunConfig(manifest=...)` を使用してオーバーライドでき、再利用または再開されたサンドボックスセッションでは既存のワークスペース状態が維持されます。 +これはデフォルトにすぎません。実行では `SandboxRunConfig(manifest=...)` を使用してオーバーライドでき、再利用または再開されたサンドボックスセッションは既存のワークスペース状態を維持します。 ### `instructions` と `base_instructions` -異なるプロンプトでも維持すべき短いルールには `instructions` を使用します。`SandboxAgent` では、これらの instructions が SDK のサンドボックスベースプロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しながら、独自のロール、ワークフロー、成功条件を追加できます。 +異なるプロンプトでも維持する必要がある短いルールには、`instructions` を使用します。`SandboxAgent` では、これらの指示が SDK のサンドボックス基本プロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しながら、独自の役割、ワークフロー、成功基準を追加できます。 -SDK のサンドボックスベースプロンプトを置き換える場合にのみ、`base_instructions` を使用してください。ほとんどのエージェントでは設定すべきではありません。 +SDK のサンドボックス基本プロンプトを置き換える場合にのみ、`base_instructions` を使用します。ほとんどのエージェントでは設定しないでください。
| 配置先 | 用途 | 例 | | --- | --- | --- | -| `instructions` | エージェントの安定したロール、ワークフロールール、成功条件。 | 「オンボーディング文書を確認してから、ハンドオフしてください。」、「最終ファイルを `output/` に書き込んでください。」 | -| `base_instructions` | SDK のサンドボックスベースプロンプトの完全な置き換え。 | カスタムの低レベルサンドボックスラッパープロンプト。 | +| `instructions` | エージェントの安定した役割、ワークフロールール、成功基準。 | 「オンボーディング書類を確認してから、ハンドオフしてください。」、「最終ファイルを `output/` に書き込んでください。」 | +| `base_instructions` | SDK のサンドボックス基本プロンプトの完全な置き換え。 | カスタムの低レベルサンドボックスラッパープロンプト。 | | ユーザープロンプト | この実行に対する 1 回限りのリクエスト。 | 「このワークスペースを要約してください。」 | -| マニフェスト内のワークスペースファイル | 長いタスク仕様、リポジトリローカルの instructions、または範囲を限定した参照資料。 | `repo/task.md`、ドキュメントバンドル、サンプル資料一式。 | +| マニフェスト内のワークスペースファイル | より長いタスク仕様、リポジトリローカルの指示、範囲を限定した参照資料。 | `repo/task.md`、ドキュメントバンドル、サンプルパケット。 |
`instructions` の適切な使用例は次のとおりです。 -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY の状態が重要な場合に、エージェントを 1 つの対話型プロセス内に維持します。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスのレビュー担当エージェントが確認後にユーザーへ直接回答することを禁止します。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的な記入済みファイルが実際に `output/` に配置されることを必須にします。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY 状態が重要な場合にエージェントを単一の対話型プロセス内に維持します。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスレビュアーが確認後にユーザーへ直接回答することを禁止します。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的に記入されたファイルが実際に `output/` に配置されることを求めます。 - [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) では、正確な検証コマンドを固定し、ワークスペースルート相対のパッチパスを明確にします。 -ユーザーの 1 回限りのタスクを `instructions` にコピーすること、マニフェストに含めるべき長い参照資料を埋め込むこと、組み込み機能がすでに注入するツールドキュメントを再記述すること、モデルが実行時に必要としないローカルインストール手順を混在させることは避けてください。 +ユーザーの 1 回限りのタスクを `instructions` にコピーすること、マニフェストに含めるべき長い参照資料を埋め込むこと、組み込み機能がすでに注入しているツールドキュメントを繰り返すこと、実行時にモデルが必要としないローカルインストール情報を混在させることは避けてください。 -`instructions` を省略しても、SDK にはデフォルトのサンドボックスプロンプトが含まれます。これは低レベルのラッパーには十分ですが、ユーザー向けエージェントのほとんどでは、引き続き明示的な `instructions` を指定する必要があります。 +`instructions` を省略しても、SDK にはデフォルトのサンドボックスプロンプトが含まれます。低レベルのラッパーにはそれで十分ですが、ほとんどのユーザー向けエージェントでは、引き続き明示的な `instructions` を指定する必要があります。 ### `capabilities` -機能は、サンドボックスネイティブの動作を `SandboxAgent` に関連付けます。実行開始前にワークスペースを構成し、サンドボックス固有の instructions を追加し、稼働中のサンドボックスセッションにバインドするツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 +各機能は、サンドボックスネイティブの動作を `SandboxAgent` に関連付けます。実行開始前にワークスペースを構成し、サンドボックス固有の指示を追加し、稼働中のサンドボックスセッションにバインドされるツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 組み込み機能には次のものがあります。
-| 機能 | 追加する場合 | 備考 | +| 機能 | 追加する場合 | 注記 | | --- | --- | --- | -| `Shell` | エージェントにシェルアクセスが必要な場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 対話をサポートする場合は `write_stdin` も追加します。 | +| `Shell` | エージェントがシェルアクセスを必要とする場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 操作をサポートする場合は `write_stdin` も追加します。 | | `Filesystem` | エージェントがファイルを編集したり、ローカル画像を確認したりする必要がある場合。 | `apply_patch` と `view_image` を追加します。パッチパスはワークスペースルート相対です。 | -| `Skills` | サンドボックス内でスキルの検出と実体化を行う場合。 | `.agents` または `.agents/skills` を手動でマウントするより、こちらを推奨します。`Skills` がスキルをインデックス化し、サンドボックス内に実体化します。 | -| `Memory` | 後続の実行でメモリ成果物を読み取る、または生成する必要がある場合。 | `Shell` が必要です。ライブ更新には `Filesystem` も必要です。 | +| `Skills` | サンドボックス内でスキルを検出し、実体化する場合。 | `.agents` や `.agents/skills` を手動でマウントするよりも、こちらを推奨します。`Skills` がスキルをインデックス化し、サンドボックス内に実体化します。 | +| `Memory` | 後続の実行でメモリ成果物を読み取るか生成する場合。 | `Shell` が必要です。実行中にメモリ成果物を更新するには、`Filesystem` も必要です。 | | `Compaction` | 長時間実行されるフローで、コンパクション項目の後にコンテキストを削減する必要がある場合。 | モデルのサンプリングと入力処理を調整します。 |
-デフォルトでは、`SandboxAgent.capabilities` は `Capabilities.default()` を使用し、これには `Filesystem()`、`Shell()`、`Compaction()` が含まれます。`capabilities=[...]` を渡すと、そのリストによってデフォルトが置き換えられるため、引き続き使用するデフォルト機能を含めてください。 +デフォルトでは、`SandboxAgent.capabilities` は `Capabilities.default()` を使用し、これには `Filesystem()`、`Shell()`、`Compaction()` が含まれます。`capabilities=[...]` を渡すと、そのリストがデフォルトを置き換えるため、引き続き必要なデフォルト機能を含めてください。 -スキルでは、実体化の方法に応じてソースを選択します。 +スキルについては、実体化する方法に応じて取得元を選択します。 -- `Skills(lazy_from=LocalDirLazySkillSource(...))` は、モデルが最初にインデックスを検出し、必要なものだけを読み込めるため、大規模なローカルスキルディレクトリの適切なデフォルトです。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージまたはワークスペース内にのみ存在するパスではなく、元のホスト側のスキルディレクトリを渡してください。 +- `Skills(lazy_from=LocalDirLazySkillSource(...))` は、モデルが最初にインデックスを検出し、必要なものだけを読み込めるため、規模の大きなローカルスキルディレクトリに適したデフォルトです。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージまたはワークスペース内にのみ存在するパスではなく、ホスト側の元のスキルディレクトリを渡してください。 - `Skills(from_=LocalDir(src=...))` は、事前にステージングする小規模なローカルバンドルに適しています。 - `Skills(from_=GitRepo(repo=..., ref=...))` は、スキル自体をリポジトリから取得する場合に適しています。 -`LocalDir.src` は SDK ホスト上のソースパスです。`skills_path` は、`load_skill` の呼び出し時にスキルがステージングされる、サンドボックスワークスペース内の相対的な宛先パスです。 +`LocalDir.src` は SDK ホスト上のソースパスです。`skills_path` は、`load_skill` が呼び出されたときにスキルがステージングされる、サンドボックスワークスペース内の相対的な宛先パスです。 -スキルがすでに `.agents/skills//SKILL.md` のような場所にディスク上で存在する場合は、`LocalDir(...)` でそのソースルートを指定し、引き続き `Skills(...)` を使用して公開してください。既存のワークスペース契約が別のサンドボックス内レイアウトに依存していない限り、デフォルトの `skills_path=".agents"` を維持してください。 +スキルがすでに `.agents/skills//SKILL.md` のような場所のディスク上に存在する場合は、`LocalDir(...)` でそのソースルートを指定し、公開には引き続き `Skills(...)` を使用します。サンドボックス内の異なるレイアウトに依存する既存のワークスペース契約がない限り、デフォルトの `skills_path=".agents"` を維持してください。 -適合する場合は組み込み機能を優先してください。組み込み機能では対応できない、サンドボックス固有のツールまたは instructions のインターフェースが必要な場合にのみ、カスタム機能を作成してください。 +適合する場合は、組み込み機能を優先してください。組み込み機能で対応できないサンドボックス固有のツールや指示インターフェースが必要な場合にのみ、カスタム機能を作成してください。 ## 概念 ### マニフェスト -[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーまたはグループの定義、ワークスペース外の特定の絶対パスへのアクセス許可を行えます。 +[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` を設定し、ファイルやディレクトリを宣言し、ローカルファイルをコピーし、Git リポジトリをクローンし、リモートストレージのマウントを接続し、環境変数を設定し、ユーザーやグループを定義し、ワークスペース外の特定の絶対パスへのアクセスを許可できます。 -マニフェストエントリのパスはワークスペース相対です。絶対パスを指定したり、`..` を使用してワークスペース外へ移動したりすることはできません。これにより、ローカル、Docker、ホステッドの各クライアント間でワークスペース契約の移植性が維持されます。 +マニフェストエントリのパスは、ワークスペース相対です。絶対パスにすることや、`..` を使用してワークスペース外へ移動することはできません。これにより、ローカル、Docker、ホステッドクライアント間でワークスペース契約の移植性が維持されます。 作業開始前にエージェントが必要とする素材には、マニフェストエントリを使用します。 @@ -227,21 +227,21 @@ SDK のサンドボックスベースプロンプトを置き換える場合に | マニフェストエントリ | 用途 | | --- | --- | | `File`、`Dir` | 小規模な合成入力、補助ファイル、出力ディレクトリ。 | -| `LocalFile`、`LocalDir` | サンドボックス内に実体化するホストのファイルまたはディレクトリ。 | -| `GitRepo` | ワークスペースに取得するリポジトリ。 | -| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` などのマウント | サンドボックス内に表示する外部ストレージ。 | +| `LocalFile`、`LocalDir` | サンドボックス内に実体化する必要があるホストのファイルやディレクトリ。 | +| `GitRepo` | ワークスペースに取得する必要があるリポジトリ。 | +| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` などのマウント | サンドボックス内に表示する必要がある外部ストレージ。 | -`Dir` は、合成された子要素から、または出力先としてサンドボックスワークスペース内にディレクトリを作成します。ホストファイルシステムから読み取るものではありません。既存のホストディレクトリをサンドボックスワークスペースにコピーする場合は、`LocalDir` を使用してください。 +`Dir` は、合成された子要素から、または出力先としてサンドボックスワークスペース内にディレクトリを作成します。ホストのファイルシステムから読み取るものではありません。既存のホストディレクトリをサンドボックスワークスペースにコピーする場合は、`LocalDir` を使用します。 -`LocalFile.src` と `LocalDir.src` は、デフォルトでは SDK プロセスの作業ディレクトリを基準に解決されます。ソースは、`extra_path_grants` の対象でない限り、そのベースディレクトリ内に留まる必要があります。これにより、ローカルソースの実体化が、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に維持されます。 +デフォルトでは、`LocalFile.src` と `LocalDir.src` は SDK プロセスの作業ディレクトリを基準に解決されます。ソースは、`extra_path_grants` の対象でない限り、そのベースディレクトリ内に収める必要があります。これにより、ローカルソースの実体化が、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に維持されます。 マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。マウントオプションとプロバイダーのサポートについては、[サンドボックスクライアント](clients.md#mounts-and-remote-storage)を参照してください。 -適切なマニフェスト設計では通常、ワークスペース契約を狭く保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、instructions では `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールでファイルを編集する場合、パッチパスはシェルの `workdir` ではなく、サンドボックスワークスペースのルートを基準とすることに注意してください。 +適切なマニフェスト設計では通常、ワークスペース契約を限定的に保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、指示内で `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールでファイルを編集する場合、パッチパスはシェルの `workdir` ではなく、サンドボックスワークスペースのルートを基準とすることに注意してください。 -エージェントがワークスペース外の具体的な絶対パスを必要とする場合、またはマニフェストが SDK プロセスの作業ディレクトリ外にある信頼済みのローカルソースをコピーする必要がある場合にのみ、`extra_path_grants` を使用してください。例として、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内に実体化する生成済みスキルディレクトリなどがあります。許可は、ローカルソースの実体化、SDK のファイル API、およびバックエンドがファイルシステムポリシーを適用できる場合のシェル実行に適用されます。 +エージェントがワークスペース外の具体的な絶対パスを必要とする場合、またはマニフェストが SDK プロセスの作業ディレクトリ外にある信頼済みのローカルソースをコピーする必要がある場合にのみ、`extra_path_grants` を使用します。たとえば、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内に実体化する生成済みスキルディレクトリなどがあります。許可は、ローカルソースの実体化と SDK のファイル API に適用されます。また、バックエンドがファイルシステムポリシーを適用できる場合は、シェル実行にも適用されます。 ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +254,17 @@ manifest = Manifest( ) ``` -Docker がコンテナ内の絶対 POSIX `path` に別の絶対ホストパスをバインドマウントする必要がある場合は、`host_path` を設定します。`UnixLocalSandboxClient` は、両方のパスが同一であるパスのみの許可に対応し、`host_path` を拒否します。サンドボックスによる変更を禁止するホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用してください。 +Docker で別の絶対ホストパスをコンテナ内の絶対 POSIX `path` にバインドマウントする場合は、`host_path` を設定します。`UnixLocalSandboxClient` は両方のパスが同じであるパスのみの許可だけをサポートし、`host_path` を拒否します。サンドボックスで変更させないホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用します。 -`extra_path_grants` を含むマニフェストは、信頼済みの設定として扱ってください。アプリケーションが対象のホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 +`extra_path_grants` を含むマニフェストは、信頼済みの設定として扱ってください。アプリケーションが該当するホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 -スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスはランタイムアクセスであり、永続的なワークスペース状態ではありません。 +スナップショットと `persist_workspace()` には、引き続きワークスペースルートだけが含まれます。追加で許可されたパスはランタイムアクセスであり、永続的なワークスペース状態ではありません。 ### 権限 -`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これはサンドボックスが実体化するファイルに関するものであり、モデルの権限、承認ポリシー、API 認証情報に関するものではありません。 +`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これはサンドボックスが実体化するファイルに関する設定であり、モデルの権限、承認ポリシー、API 認証情報に関する設定ではありません。 -デフォルトでは、マニフェストエントリは所有者による読み取り、書き込み、実行が可能で、グループとその他のユーザーによる読み取り、実行が可能です。ステージングされたファイルを非公開、読み取り専用、または実行可能にする場合は、これをオーバーライドします。 +デフォルトでは、マニフェストエントリについて、所有者には読み取り、書き込み、実行が許可され、グループとその他のユーザーには読み取りと実行が許可されます。ステージングされたファイルを非公開、読み取り専用、または実行可能にする必要がある場合は、これをオーバーライドします。 ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +280,9 @@ private_notes = File( ) ``` -`Permissions` は、所有者、グループ、その他のユーザーごとに個別のビットを保持し、エントリがディレクトリであるかどうかも保持します。直接構築するか、`Permissions.from_str(...)` を使用してモード文字列から解析するか、`Permissions.from_mode(...)` を使用して OS モードから導出できます。 +`Permissions` は、所有者、グループ、その他のユーザーそれぞれのビットと、エントリがディレクトリかどうかを格納します。直接構築するか、`Permissions.from_str(...)` を使用してモード文字列から解析するか、`Permissions.from_mode(...)` を使用して OS モードから取得できます。 -ユーザーは、サンドボックス内で作業を実行できる ID です。その ID をサンドボックス内に存在させる場合は、マニフェストに `User` を追加します。次に、シェルコマンド、ファイル読み取り、パッチなど、モデル向けのサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指している場合、ランナーが有効なマニフェストにそのユーザーを追加します。 +ユーザーは、作業を実行できるサンドボックス ID です。その ID をサンドボックス内に存在させる場合は、マニフェストに `User` を追加します。次に、シェルコマンド、ファイル読み取り、パッチなどのモデル向けサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指している場合、ランナーがそのユーザーを有効なマニフェストに追加します。 ```python from agents import Runner @@ -334,13 +334,13 @@ result = await Runner.run( ) ``` -ファイルレベルの共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` ユーザーはサンドボックスネイティブのアクションを実行するユーザーを制御し、`Permissions` はサンドボックスがワークスペースを実体化した後、そのユーザーが読み取り、書き込み、実行できるファイルを制御します。 +ファイルレベルの共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` ユーザーは、サンドボックスネイティブのアクションを実行するユーザーを制御します。`Permissions` は、サンドボックスがワークスペースを実体化した後、そのユーザーがどのファイルを読み取り、書き込み、実行できるかを制御します。 ### SnapshotSpec -`SnapshotSpec` は、保存済みのワークスペースコンテンツをどこから新しいサンドボックスセッションに復元し、どこへ永続化するかを指定します。これはサンドボックスワークスペースのスナップショットポリシーです。一方、`session_state` は、特定のサンドボックスバックエンドを再開するためにシリアライズされた接続状態です。 +`SnapshotSpec` は、新しいサンドボックスセッションに対して、保存済みのワークスペースコンテンツをどこから復元し、どこへ永続化するかを指定します。これはサンドボックスワークスペースのスナップショットポリシーです。一方、`session_state` は、特定のサンドボックスバックエンドを再開するためのシリアライズされた接続状態です。 -ローカルの永続スナップショットには `LocalSnapshotSpec` を使用し、アプリケーションがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。ワークスペーススナップショットの永続化を望まない高度な呼び出し元は、これを明示的に使用することもできます。 +ローカルで永続化されるスナップショットには `LocalSnapshotSpec` を使用し、アプリがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。ワークスペーススナップショットの永続化を必要としない高度な呼び出し元は、これを明示的に使用することもできます。 ```python from pathlib import Path @@ -357,13 +357,13 @@ run_config = RunConfig( ) ``` -ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントはそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、実行が続行される前に、サンドボックスは保存済みのワークスペースコンテンツを復元します。クリーンアップ時には、ランナーが所有するサンドボックスセッションがワークスペースをアーカイブし、スナップショットを通じて再度永続化します。 +ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントがそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、サンドボックスは実行を続行する前に保存済みのワークスペースコンテンツを復元します。クリーンアップ時には、ランナーが所有するサンドボックスセッションがワークスペースをアーカイブし、スナップショットを介して再び永続化します。 `snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存場所を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時的なパスは、永続的なワークスペースコンテンツとしてスナップショットにコピーされません。 ### サンドボックスのライフサイクル -ライフサイクルには、 **SDK 所有** と **開発者所有** の 2 つのモードがあります。 +ライフサイクルには、**SDK 所有**と**開発者所有**の 2 つのモードがあります。
@@ -391,7 +391,7 @@ sequenceDiagram
-サンドボックスが 1 回の実行中だけ存続すればよい場合は、SDK 所有のライフサイクルを使用します。`client`、任意の `manifest`、任意の `snapshot`、クライアントの `options` を渡します。ランナーはサンドボックスを作成または再開し、起動してエージェントを実行し、スナップショットに基づくワークスペース状態を永続化し、サンドボックスをシャットダウンして、ランナーが所有するリソースをクライアントにクリーンアップさせます。 +サンドボックスを 1 回の実行中だけ存続させる必要がある場合は、SDK 所有のライフサイクルを使用します。`client`、必要に応じて `manifest` と `snapshot`、および必要なクライアントの `options` を渡します。ランナーはサンドボックスを作成または再開し、起動してエージェントを実行し、スナップショットベースのワークスペース状態を永続化し、サンドボックスセッションを終了して、ランナーが所有するリソースをクライアントにクリーンアップさせます。 ```python result = await Runner.run( @@ -403,7 +403,7 @@ result = await Runner.run( ) ``` -サンドボックスを事前に作成する場合、稼働中の 1 つのサンドボックスを複数の実行で再利用する場合、実行後にファイルを確認する場合、自分で作成したサンドボックス上でストリーミングする場合、またはクリーンアップのタイミングを厳密に決める場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中のサンドボックスを使用しますが、自動的には閉じません。 +サンドボックスを事前に作成する場合、稼働中の 1 つのサンドボックスを複数の実行で再利用する場合、実行後にファイルを確認する場合、自分で作成したサンドボックス上でストリーミングする場合、またはクリーンアップのタイミングを正確に決める場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中のサンドボックスを使用しますが、代わりに閉じることはありません。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリケーションでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 +通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 ```python sandbox = await client.create( @@ -435,30 +435,30 @@ finally: await sandbox.aclose() ``` -`stop()` は、スナップショットに基づくワークスペースコンテンツを永続化するだけで、サンドボックスを破棄しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースをシャットダウンして、セッションスコープの依存関係を閉じます。 +`stop()` は、スナップショットベースのワークスペースコンテンツだけを永続化し、サンドボックスを終了しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースを停止し、セッションスコープの依存関係を閉じます。 ## `SandboxRunConfig` のオプション [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションの取得元と、新しいセッションの初期化方法を決定する実行ごとのオプションを保持します。 -### サンドボックスのソース +### サンドボックスの取得元 -次のオプションは、ランナーがサンドボックスセッションを再利用、再開、作成のいずれにするかを決定します。 +次のオプションは、ランナーがサンドボックスセッションを再利用、再開、作成のいずれで取得するかを決定します。
-| オプション | 使用する場合 | 備考 | +| オプション | 使用する場合 | 注記 | | --- | --- | --- | | `client` | ランナーにサンドボックスセッションの作成、再開、クリーンアップを任せる場合。 | 稼働中のサンドボックス `session` を指定しない限り必須です。 | -| `session` | 稼働中のサンドボックスセッションをすでに自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中のサンドボックスセッションを再利用します。 | -| `session_state` | シリアライズされたサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。ランナーは、その明示的な状態から所有セッションとして再開します。 | +| `session` | 稼働中のサンドボックスセッションを自分ですでに作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中のサンドボックスセッションを再利用します。 | +| `session_state` | シリアライズされたサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。ランナーはその明示的な状態から再開し、再開されたセッションのライフサイクルを所有します。 |
実際には、ランナーは次の順序でサンドボックスセッションを解決します。 1. `run_config.sandbox.session` を注入した場合、その稼働中のサンドボックスセッションを直接再利用します。 -2. それ以外で、実行が `RunState` から再開される場合は、保存されたサンドボックスセッション状態を再開します。 +2. それ以外で、`RunState` から実行を再開する場合は、保存されているサンドボックスセッション状態を再開します。 3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的にシリアライズされたサンドボックスセッション状態から再開します。 4. それ以外の場合、ランナーは新しいサンドボックスセッションを作成します。その新しいセッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 @@ -468,31 +468,31 @@ finally:
-| オプション | 使用する場合 | 備考 | +| オプション | 使用する場合 | 注記 | | --- | --- | --- | -| `manifest` | 新規セッション用ワークスペースを 1 回限りでオーバーライドする場合。 | 省略すると `agent.default_manifest` にフォールバックします。 | +| `manifest` | 新規セッションのワークスペースを 1 回限りでオーバーライドする場合。 | 省略すると `agent.default_manifest` にフォールバックします。 | | `snapshot` | 新しいサンドボックスセッションをスナップショットから初期化する場合。 | 再開に似たフローやリモートスナップショットクライアントに便利です。 | -| `options` | サンドボックスクライアントが作成時のオプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウト、および同様のクライアント固有設定でよく使用します。 | +| `options` | サンドボックスクライアントが作成時のオプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウトなど、クライアント固有の設定で一般的です。 |
### 実体化の制御 -`concurrency_limits` は、並列実行できるサンドボックス実体化作業の量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。特定の制限を無効にするには、対応する値を `None` に設定します。 +`concurrency_limits` は、並列実行できるサンドボックス実体化処理の量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。いずれかの値を `None` に設定すると、その特定の制限を無効にできます。 -`archive_limits` は、アーカイブ抽出に対する SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定し、アーカイブでより厳密なリソース制御が必要な場合は、`SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` のように明示的な値を渡します。SDK のアーカイブリソース制限を設けないデフォルト動作を維持するには `archive_limits=None` のままにし、個別の制限だけを無効にするには対応するフィールドを `None` に設定します。 +`archive_limits` は、アーカイブ展開に対する SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定し、アーカイブに対してより厳密なリソース制御が必要な場合は `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` などの明示的な値を渡します。SDK のアーカイブリソース制限がないデフォルト動作を維持するには `archive_limits=None` のままにし、特定の制限だけを無効にするには個々のフィールドを `None` に設定します。 次の点に注意してください。 - 新規セッション:`manifest=` と `snapshot=` は、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 -- 再開とスナップショット:`session_state=` は以前にシリアライズされたサンドボックス状態へ再接続します。一方、`snapshot=` は保存されたワークスペースコンテンツから新しいサンドボックスセッションを初期化します。 +- 再開とスナップショット:`session_state=` は以前にシリアライズされたサンドボックス状態に再接続します。一方、`snapshot=` は保存済みのワークスペースコンテンツから新しいサンドボックスセッションを初期化します。 - クライアント固有のオプション:`options=` はサンドボックスクライアントによって異なります。Docker と多くのホステッドクライアントでは必須です。 -- 注入された稼働中のセッション:実行中のサンドボックス `session` を渡した場合、機能によるマニフェスト更新で、互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリタイプの置き換え、マウントエントリの追加または変更はできません。 -- ランナー API:`SandboxAgent` の実行でも、通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 +- 注入された稼働中セッション:実行中のサンドボックス `session` を渡した場合、機能によるマニフェスト更新で、互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリ型の置き換え、マウントエントリの追加や変更はできません。 +- ランナー API:`SandboxAgent` の実行では、引き続き通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 ## 完全な例:コーディングタスク -このコーディング形式の例は、デフォルトの出発点として適しています。 +このコーディング形式の例は、適切なデフォルトの開始点です。 ```python import asyncio @@ -571,19 +571,19 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。この例では、Unix ローカル実行で決定論的に検証できるように、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリは、もちろん Python、JavaScript、その他の任意のものを使用できます。 +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。この例では、Unix ローカル実行間で決定論的に検証できるよう、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリには、もちろん Python、JavaScript、その他の任意のものを使用できます。 ## 一般的なパターン -上記の完全な例から始めてください。多くの場合、同じ `SandboxAgent` をそのまま維持し、サンドボックスクライアント、サンドボックスセッションのソース、またはワークスペースのソースだけを変更できます。 +上記の完全な例から始めてください。多くの場合、サンドボックスクライアント、サンドボックスセッションの取得元、またはワークスペースの取得元だけを変更し、同じ `SandboxAgent` をそのまま維持できます。 ### サンドボックスクライアントの切り替え -エージェント定義は同じままにして、実行設定だけを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +エージェント定義はそのまま維持し、実行設定だけを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 ### ワークスペースのオーバーライド -エージェント定義は同じままにして、新規セッションのマニフェストだけを入れ替えます。 +エージェント定義はそのまま維持し、新規セッションのマニフェストだけを置き換えます。 ```python from agents.run import RunConfig @@ -603,11 +603,11 @@ run_config = RunConfig( ) ``` -エージェントを再構築せずに、同じエージェントのロールを異なるリポジトリ、資料一式、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディング例では、1 回限りのオーバーライドではなく `default_manifest` を使用して同じパターンを示しています。 +エージェントを再構築せず、同じエージェントの役割を異なるリポジトリ、パケット、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディング例では、1 回限りのオーバーライドの代わりに `default_manifest` を使用して同じパターンを示しています。 ### サンドボックスセッションの注入 -ライフサイクルの明示的な制御、実行後の確認、出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 +ライフサイクルの明示的な制御、実行後の確認、または出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 ```python from agents import Runner @@ -628,11 +628,11 @@ async with sandbox: ) ``` -実行後にワークスペースを確認する場合、またはすでに起動済みのサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) と [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 +実行後にワークスペースを確認する場合や、すでに起動しているサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) および [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 ### セッション状態からの再開 -`RunState` の外部でサンドボックス状態をすでにシリアライズしている場合は、その状態からランナーを再接続させます。 +`RunState` の外部ですでにサンドボックス状態をシリアライズしている場合は、その状態からランナーを再接続させます。 ```python from agents.run import RunConfig @@ -649,13 +649,13 @@ run_config = RunConfig( ) ``` -サンドボックス状態が独自のストレージまたはジョブシステムにあり、`Runner` でそこから直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) を参照してください。 +サンドボックス状態を独自のストレージやジョブシステムに保存し、`Runner` でその状態から直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) を参照してください。 -セッション状態のシリアライズでは、ネイティブの `host_path` 値が省略されます。ホストに基づく許可を再開するには、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` で指定してください。指定しない場合、サンドボックスの起動前に再開が失敗します。シリアライズされた入力やその他の信頼できない入力からホストパスを導出しないでください。 +セッション状態のシリアライズでは、ネイティブの `host_path` 値が省略されます。ホストベースの許可を再開するには、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` で指定してください。指定しない場合、サンドボックスが起動する前に再開が失敗します。シリアライズされた入力やその他の信頼できない入力から、ホストパスを決して生成しないでください。 ### スナップショットからの開始 -保存済みのファイルと成果物から新しいサンドボックスを初期化します。 +保存済みのファイルや成果物から新しいサンドボックスを初期化します。 ```python from pathlib import Path @@ -672,11 +672,11 @@ run_config = RunConfig( ) ``` -新しい実行を `agent.default_manifest` だけでなく、保存済みのワークスペースコンテンツから開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 +新しいサンドボックスセッションを作成する実行で、`agent.default_manifest` だけではなく、保存済みのワークスペースコンテンツから開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 -### Git からのスキル読み込み +### Git からのスキルの読み込み -ローカルのスキルソースを、リポジトリに基づくソースと入れ替えます。 +ローカルのスキル取得元を、リポジトリベースの取得元に置き換えます。 ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -687,11 +687,11 @@ capabilities = Capabilities.default() + [ ] ``` -スキルバンドルに独自のリリースサイクルがある場合、または複数のサンドボックス間で共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 +スキルバンドルに独自のリリースサイクルがある場合や、複数のサンドボックス間で共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 ### ツールとしての公開 -ツールエージェントには、独自のサンドボックス境界を与えることも、親実行の稼働中のサンドボックスを再利用させることもできます。再利用は、高速な読み取り専用の探索エージェントに便利です。別のサンドボックスを作成、初期化、スナップショット化するコストをかけずに、親が使用しているものと同一のワークスペースを確認できます。 +ツールエージェントには、独自のサンドボックス境界を与えることも、親の実行から稼働中のサンドボックスを再利用させることもできます。再利用は、高速な読み取り専用の探索エージェントに便利です。別のサンドボックスを作成、ハイドレーション、スナップショットするコストをかけずに、親の実行が使用しているものとまったく同じワークスペースを確認できます。 ```python from agents import Runner @@ -773,9 +773,9 @@ async with sandbox: ) ``` -ここでは、親エージェントが `coordinator` として実行され、探索用ツールエージェントが同じ稼働中のサンドボックスセッション内で `explorer` として実行されます。`pricing_packet/` のエントリは `other` ユーザーが読み取り可能なため、探索エージェントは迅速に確認できますが、書き込み権限はありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループだけが利用できるため、探索エージェントを読み取り専用のまま維持しながら、親は最終成果物を書き込めます。 +ここでは、親エージェントは `coordinator` として実行され、探索ツールエージェントは同じ稼働中のサンドボックスセッション内で `explorer` として実行されます。`pricing_packet/` のエントリは `other` ユーザーが読み取れるため、探索エージェントはすばやく確認できますが、書き込みビットはありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループだけが利用できるため、探索エージェントを読み取り専用に保ちながら、親は最終成果物を書き込めます。 -ツールエージェントに実際の分離が必要な場合は、独自のサンドボックス `RunConfig` を指定します。 +ツールエージェントに実際の分離が必要な場合は、独自のサンドボックス `RunConfig` を与えます。 ```python from docker import from_env as docker_from_env @@ -801,11 +801,11 @@ rollout_agent.as_tool( ) ``` -ツールエージェントが自由に変更を加える場合、信頼できないコマンドを実行する場合、または異なるバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 +ツールエージェントが自由に変更を行う場合、信頼できないコマンドを実行する場合、または異なるバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 ### ローカルツールおよび MCP との組み合わせ -同じエージェントで通常のツールも使用しながら、サンドボックスワークスペースを維持します。 +サンドボックスワークスペースを維持しながら、同じエージェントで通常のツールも使用します。 ```python from agents.sandbox import SandboxAgent @@ -820,46 +820,46 @@ agent = SandboxAgent( ) ``` -ワークスペースの確認がエージェントの作業の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 +ワークスペースの確認がエージェントの仕事の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 ## メモリ -将来のサンドボックスエージェントの実行で、以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは SDK の会話用 `Session` メモリとは異なります。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 +今後のサンドボックスエージェント実行で以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは、SDK の会話用 `Session` メモリとは別のものです。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 -セットアップ、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 +設定、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 ## 構成パターン -単一エージェントのパターンを理解した後は、より大規模なシステムのどこにサンドボックス境界を配置するかが次の設計上の問いになります。 +単一エージェントのパターンを理解した後は、より大規模なシステム内のどこにサンドボックス境界を配置するかを検討します。 サンドボックスエージェントは、引き続き SDK の他の要素と組み合わせられます。 -- [ハンドオフ](../handoffs.md):ドキュメント量の多い作業を、サンドボックスを使用しない受付エージェントからサンドボックスのレビュー担当エージェントに引き継ぎます。 -- [Agents as tools](../tools.md#agents-as-tools):複数のサンドボックスエージェントをツールとして公開します。通常は、各 `Agent.as_tool(...)` 呼び出しで `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡し、各ツールに独自のサンドボックス境界を割り当てます。 -- [MCP](../mcp.md) と通常の関数ツール:サンドボックス機能は、`mcp_servers` や通常の Python ツールと共存できます。 +- [ハンドオフ](../handoffs.md):ドキュメント量の多い作業を、サンドボックスを使用しない受付エージェントからサンドボックスレビュアーへハンドオフします。 +- [Agents as tools](../tools.md#agents-as-tools):複数のサンドボックスエージェントをツールとして公開します。通常は、各ツールに独自のサンドボックス境界を与えるため、`Agent.as_tool(...)` の各呼び出しで `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡します。 +- [MCP](../mcp.md) と通常の関数ツール:サンドボックス機能は、`mcp_servers` および通常の Python ツールと共存できます。 - [エージェントの実行](../running_agents.md):サンドボックス実行でも通常の `Runner` API を使用します。 特に一般的なパターンは次の 2 つです。 -- ワークフローのうちワークスペースの分離が必要な部分だけを、サンドボックスを使用しないエージェントからサンドボックスエージェントへハンドオフする -- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は各 `Agent.as_tool(...)` 呼び出しに個別のサンドボックス `RunConfig` を指定して、各ツールに独自の分離ワークスペースを割り当てる +- サンドボックスを使用しないエージェントから、ワークスペースの分離が必要なワークフロー部分だけをサンドボックスエージェントにハンドオフするパターン +- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は `Agent.as_tool(...)` の呼び出しごとに別のサンドボックス `RunConfig` を使用して、各ツールに独自の分離されたワークスペースを与えるパターン ### ターンとサンドボックス実行 ハンドオフとエージェントをツールとして呼び出す場合は、分けて説明すると理解しやすくなります。 -ハンドオフでは、トップレベルの実行とトップレベルのターンループは引き続き 1 つです。アクティブなエージェントは変わりますが、実行がネストされるわけではありません。サンドボックスを使用しない受付エージェントがサンドボックスのレビュー担当エージェントにハンドオフすると、同じ実行内の次のモデル呼び出しがサンドボックスエージェント用に準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフは、同じ実行の次のターンをどのエージェントが担当するかを変更します。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) を参照してください。 +ハンドオフでは、トップレベルの実行とトップレベルのターンループはそれぞれ 1 つのままです。アクティブなエージェントは変わりますが、実行はネストされません。サンドボックスを使用しない受付エージェントがサンドボックスレビュアーにハンドオフすると、同じ実行内の次のモデル呼び出しがサンドボックスエージェント向けに準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフは、同じ実行の次のターンを担当するエージェントを変更します。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) を参照してください。 -`Agent.as_tool(...)` では、関係が異なります。外側のオーケストレーターは、外側の 1 ターンを使用してツールの呼び出しを決定し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認、および通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合も、複数ターンを要する場合もあります。外側のオーケストレーターから見ると、これらの作業はすべて 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増加することはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 +`Agent.as_tool(...)` では関係が異なります。外側のオーケストレーターは、ツールを呼び出すことを決定するために外側の 1 ターンを使用し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認があり、通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合もあれば、複数ターンかかる場合もあります。外側のオーケストレーターから見ると、これらの作業はすべて 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増えることはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 -承認の動作も同じ区分に従います。 +承認の動作も同じように分かれます。 -- ハンドオフでは、サンドボックスエージェントがその実行のアクティブなエージェントになるため、承認は同じトップレベルの実行に留まります -- `Agent.as_tool(...)` では、サンドボックスのツールエージェント内で発生した承認も外側の実行に公開されますが、保存されたネスト済み実行状態から取得され、外側の実行が再開されるとネストされたサンドボックス実行も再開されます +- ハンドオフでは、サンドボックスエージェントがその実行のアクティブなエージェントになるため、承認は同じトップレベルの実行上に維持されます。 +- `Agent.as_tool(...)` では、サンドボックスツールエージェント内で発生した承認も外側の実行に表示されますが、保存されたネスト済み実行状態から取得され、外側の実行が再開されるとネストされたサンドボックス実行も再開されます。 ## 関連資料 - [クイックスタート](../sandbox_agents.md):サンドボックスエージェントを 1 つ実行します。 -- [サンドボックスクライアント](clients.md):ローカル、Docker、ホステッド、マウントの各オプションを選択します。 -- [エージェントメモリ](memory.md):以前のサンドボックス実行から得た学習内容を保持し、再利用します。 -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成の各パターン。 \ No newline at end of file +- [サンドボックスクライアント](clients.md):ローカル、Docker、ホステッド、マウントのオプションを選択します。 +- [エージェントメモリ](memory.md):以前のサンドボックス実行から得た学習内容を保持して再利用します。 +- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成の各パターンです。 \ No newline at end of file diff --git a/docs/ja/sandbox/memory.md b/docs/ja/sandbox/memory.md index 9f949c72dc..3588af3201 100644 --- a/docs/ja/sandbox/memory.md +++ b/docs/ja/sandbox/memory.md @@ -4,23 +4,23 @@ search: --- # エージェントメモリ -メモリにより、今後の sandbox エージェントの実行は過去の実行から学習できます。これは、メッセージ履歴を保存する SDK の会話用 [`Session`](../sessions/index.md) メモリとは別のものです。メモリは、過去の実行から得た学びを sandbox ワークスペース内のファイルに要約します。 +メモリを使用すると、今後のサンドボックスエージェントの実行で過去の実行から学習できます。これは、メッセージ履歴を保存する Agents SDK の会話用 [`Session`](../sessions/index.md) メモリとは別のものです。メモリは、過去の実行から得た知見を抽出し、サンドボックスワークスペース内のファイルに保存します。 !!! warning "ベータ機能" - Sandbox エージェントはベータ版です。一般提供までに API の詳細、デフォルト値、サポートされる機能が変更される可能性があり、時間とともにさらに高度な機能が追加されることも想定してください。 + サンドボックスエージェントはベータ版です。一般提供の開始までに、API の詳細、デフォルト、対応機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -メモリは、今後の実行における 3 種類のコストを削減できます。 +メモリは、今後の実行にかかる次の 3 種類のコストを削減できます。 -1. エージェントのコスト: エージェントがワークフローの完了に長い時間を要した場合、次回の実行では探索が少なくて済むはずです。これにより、トークン使用量と完了までの時間を削減できます。 -2. ユーザーのコスト: ユーザーがエージェントを修正したり好みを表明したりした場合、今後の実行でそのフィードバックを記憶できます。これにより、人による介入を削減できます。 -3. コンテキストのコスト: エージェントが以前にタスクを完了していて、ユーザーがそのタスクを発展させたい場合、ユーザーは以前のスレッドを探したり、すべてのコンテキストを再入力したりする必要がないはずです。これにより、タスク説明を短くできます。 +1. エージェントのコスト: エージェントがワークフローの完了に長い時間を要した場合、次回の実行では調査を減らせるはずです。これにより、トークン使用量と完了までの時間を削減できます。 +2. ユーザーのコスト: ユーザーがエージェントを修正したり、好みを伝えたりした場合、今後の実行でそのフィードバックを記憶できます。これにより、人手による介入を減らせます。 +3. コンテキストのコスト: エージェントが以前にタスクを完了しており、ユーザーがそのタスクを基に作業を続けたい場合、以前のスレッドを探したり、すべてのコンテキストを再入力したりする必要がなくなります。これにより、タスクの説明を短くできます。 -バグを修正し、メモリを生成し、スナップショットを再開し、そのメモリを後続の検証実行で使用する、2 回の実行からなる完全なコード例については、[examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を参照してください。独立したメモリレイアウトを持つマルチターン、マルチエージェントのコード例については、[examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py) を参照してください。 +バグの修正、メモリの生成、スナップショットの再開、そのメモリを使用したフォローアップの検証実行を含む、完全な 2 回実行のコード例については、[examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を参照してください。メモリレイアウトを分離したマルチターン、マルチエージェントのコード例については、[examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py) を参照してください。 ## メモリの有効化 -sandbox エージェントに機能として `Memory()` を追加します。 +サンドボックスエージェントのケイパビリティとして `Memory()` を追加します。 ```python from pathlib import Path @@ -42,28 +42,28 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d ) ``` -読み取りが有効な場合、`Memory()` には `Shell()` が必要です。これにより、挿入されたサマリーだけでは不十分なときに、エージェントがメモリファイルを読み取り、検索できます。ライブメモリ更新が有効な場合(デフォルト)、`Filesystem()` も必要です。これにより、エージェントが古くなったメモリを発見した場合や、ユーザーがメモリの更新を依頼した場合に、`memories/MEMORY.md` を更新できます。 +読み取りが有効な場合、`Memory()` には `Shell()` が必要です。これにより、注入されたサマリーだけでは不十分なときに、エージェントがメモリファイルを読み取って検索できます。ライブメモリ更新が有効な場合(デフォルト)には、`Filesystem()` も必要です。これにより、エージェントが古くなったメモリを検出した場合や、ユーザーからメモリの更新を依頼された場合に、`memories/MEMORY.md` を更新できます。 -デフォルトでは、メモリアーティファクトは sandbox ワークスペースの `memories/` 配下に保存されます。後の実行で再利用するには、同じライブ sandbox セッションを維持するか、永続化されたセッション状態またはスナップショットから再開することで、設定済みのメモリディレクトリ全体を保持して再利用してください。新しい空の sandbox は空のメモリで開始されます。 +デフォルトでは、メモリアーティファクトはサンドボックスワークスペース内の `memories/` 配下に保存されます。後続の実行で再利用するには、同じライブサンドボックスセッションを維持するか、永続化されたセッション状態またはスナップショットから再開し、設定済みのメモリディレクトリ全体を保持して再利用してください。新しい空のサンドボックスでは、メモリも空の状態から開始します。 -`Memory()` は、メモリの読み取りと生成の両方を有効にします。メモリを読み取るが新しいメモリは生成すべきでないエージェントには、`Memory(generate=None)` を使用します。たとえば、内部エージェント、サブエージェント、チェッカー、または実行から得られるシグナルが多くない 1 回限りのツールエージェントです。後で使うメモリを生成する必要はあるものの、ユーザーが既存メモリによる影響を望まない場合は、`Memory(read=None)` を使用します。 +`Memory()` は、メモリの読み取りと生成の両方を有効にします。メモリを読み取る必要はあるものの、新しいメモリを生成すべきでないエージェントには、`Memory(generate=None)` を使用します。たとえば、内部エージェント、サブエージェント、チェッカー、単発のツールエージェントによる実行では、有用な情報があまり追加されない場合があります。後で使用するメモリを実行で生成する必要はあるものの、既存のメモリがその実行に影響することをユーザーが望まない場合は、`Memory(read=None)` を使用します。 ## メモリの読み取り -メモリ読み取りでは段階的開示を使用します。実行の開始時に、SDK は一般的に役立つヒント、ユーザーの好み、利用可能なメモリの小さなサマリー(`memory_summary.md`)を、エージェントの developer プロンプトに挿入します。これにより、エージェントは過去の作業が関連しそうかどうかを判断するのに十分なコンテキストを得られます。 +メモリの読み取りには段階的開示が使用されます。実行開始時に、SDK は一般的に役立つヒント、ユーザーの好み、利用可能なメモリをまとめた小さなサマリー(`memory_summary.md`)をエージェントの開発者プロンプトに注入します。これにより、エージェントは過去の作業が関連する可能性を判断するのに十分なコンテキストを得られます。 -過去の作業が関連しそうな場合、エージェントは現在のタスクからキーワードを抽出して、設定されたメモリインデックス(`memories_dir` 配下の `MEMORY.md`)を検索します。より詳細が必要な場合にのみ、設定された `rollout_summaries/` ディレクトリ配下にある対応する過去のロールアウトサマリーを開きます。 +過去の作業が関連していると思われる場合、エージェントは現在のタスクのキーワードを使用して、設定済みのメモリインデックス(`memories_dir` 配下の `MEMORY.md`)を検索します。さらに詳細な情報がタスクに必要な場合に限り、設定済みの `rollout_summaries/` ディレクトリにある、対応する過去のロールアウトサマリーを開きます。 -メモリは古くなることがあります。エージェントには、メモリをガイダンスとしてのみ扱い、現在の環境を信頼するよう指示されています。デフォルトでは、メモリ読み取りでは `live_update` が有効です。そのため、エージェントが古くなったメモリを発見した場合、同じ実行内で設定済みの `MEMORY.md` を更新できます。実行中にメモリを読み取るが変更してほしくない場合、たとえばレイテンシに敏感な実行では、ライブ更新を無効にしてください。 +メモリは古くなる可能性があります。エージェントは、メモリをあくまで参考情報として扱い、現在の環境を信頼するよう指示されます。デフォルトでは、メモリの読み取りで `live_update` が有効になっているため、エージェントが古くなったメモリを検出すると、同じ実行内で設定済みの `MEMORY.md` を更新できます。エージェントがメモリを読み取る必要はあるものの、実行中に変更すべきでない場合は、ライブ更新を無効にしてください。たとえば、レイテンシーが重視される実行が該当します。 ## メモリの生成 -実行が完了すると、sandbox ランタイムはその実行セグメントを会話ファイルに追記します。蓄積された会話ファイルは、sandbox セッションが閉じられるときに処理されます。 +実行が終了すると、サンドボックスランタイムはその実行セグメントを会話ファイルに追記します。蓄積された会話ファイルは、サンドボックスセッションの終了時に処理されます。 メモリ生成には 2 つのフェーズがあります。 -1. フェーズ 1: 会話の抽出。メモリ生成モデルが、蓄積された 1 つの会話ファイルを処理し、会話サマリーを生成します。system、developer、reasoning のコンテンツは省略されます。会話が長すぎる場合は、先頭と末尾を保持したうえで、コンテキストウィンドウに収まるよう切り詰められます。また、未加工のメモリ抽出も生成します。これは、フェーズ 2 が統合できる会話からの簡潔なメモです。 -2. フェーズ 2: レイアウトの統合。統合エージェントは、1 つのメモリレイアウトに対応する未加工のメモリを読み取り、より多くの根拠が必要な場合は会話サマリーを開き、パターンを `MEMORY.md` と `memory_summary.md` に抽出します。 +1. フェーズ 1: 会話の抽出。メモリ生成モデルが、蓄積された 1 つの会話ファイルを処理し、会話サマリーを生成します。システム、開発者、推論のコンテンツは除外されます。会話が長すぎる場合は、冒頭と末尾を維持しながら、コンテキストウィンドウに収まるよう切り詰められます。また、未加工のメモリ抽出も生成されます。これは、フェーズ 2 で統合できる、会話から得た簡潔なメモです。 +2. フェーズ 2: レイアウトの統合。統合エージェントが 1 つのメモリレイアウトにある未加工のメモリを読み取り、さらに根拠が必要な場合は会話サマリーを開き、パターンを抽出して `MEMORY.md` と `memory_summary.md` に格納します。 デフォルトのワークスペースレイアウトは次のとおりです。 @@ -83,7 +83,7 @@ workspace/ └── skills/ ``` -`MemoryGenerateConfig` でメモリ生成を設定できます。 +`MemoryGenerateConfig` を使用して、メモリ生成を設定できます。 ```python from agents.sandbox import MemoryGenerateConfig @@ -97,13 +97,13 @@ memory = Memory( ) ``` -`extra_prompt` を使用して、GTM エージェント向けの顧客や会社の詳細など、ユースケースで最も重要なシグナルをメモリ生成器に伝えます。 +`extra_prompt` を使用すると、ユースケースで最も重要なシグナルをメモリ生成機能に指定できます。たとえば、GTM エージェント向けの顧客や企業の詳細情報などです。 -最近の未加工メモリが `max_raw_memories_for_consolidation`(デフォルトは 256)を超える場合、フェーズ 2 は最新の会話のメモリだけを保持し、古いものを削除します。新しさは、会話が最後に更新された時刻に基づきます。この忘却メカニズムにより、メモリが最新の環境を反映しやすくなります。 +最近の未加工メモリが `max_raw_memories_for_consolidation`(デフォルトは 256)を超えると、フェーズ 2 は最新の会話から得たメモリのみを保持し、それより古いものを削除します。新しさは、会話が最後に更新された時刻に基づきます。この忘却メカニズムにより、メモリに最新の環境を反映しやすくなります。 ## マルチターン会話 -マルチターンの sandbox チャットでは、同じライブ sandbox セッションとともに通常の SDK `Session` を使用します。 +マルチターンのサンドボックスチャットでは、通常の SDK `Session` を同じライブサンドボックスセッションと組み合わせて使用します。 ```python from agents import Runner, SQLiteSession @@ -132,20 +132,20 @@ async with sandbox: ) ``` -どちらの実行も、同じ SDK 会話セッション(`session=conversation_session`)を渡すため、1 つのメモリ会話ファイルに追記され、したがって同じ `session.session_id` を共有します。これはライブワークスペースを識別する sandbox(`sandbox`)とは異なります。`sandbox` はメモリ会話 ID としては使用されません。sandbox セッションが閉じられると、フェーズ 1 は蓄積された会話を参照するため、2 つの孤立したターンではなく、やり取り全体からメモリを抽出できます。 +両方の実行で同じ SDK 会話セッション(`session=conversation_session`)が渡されるため、同じ `session.session_id` が共有されます。その結果、両方の実行が 1 つのメモリ会話ファイルに追記されます。これは、ライブワークスペースを識別し、メモリの会話 ID としては使用されないサンドボックス(`sandbox`)とは異なります。フェーズ 1 はサンドボックスセッションの終了時に蓄積された会話を参照するため、分離された 2 つのターンではなく、やり取り全体からメモリを抽出できます。 -複数の `Runner.run(...)` 呼び出しを 1 つのメモリ会話にしたい場合は、それらの呼び出し全体で安定した識別子を渡してください。メモリが実行を会話に関連付けるときは、次の順序で解決します。 +複数の `Runner.run(...)` 呼び出しを 1 つのメモリ会話として扱うには、それらの呼び出し全体で安定した識別子を渡します。メモリが実行を会話に関連付ける際は、次の順序で解決します。 1. `conversation_id`(`Runner.run(...)` に渡した場合) 2. `session.session_id`(`SQLiteSession` などの SDK `Session` を渡した場合) -3. `RunConfig.group_id`(上記のどちらも存在しない場合) -4. 生成された実行ごとの ID(安定した識別子が存在しない場合) +3. `RunConfig.group_id`(上記のいずれも存在しない場合) +4. 安定した識別子が存在しない場合は、実行ごとに生成される ID -## エージェントごとのメモリ分離における異なるレイアウトの利用 +## 異なるレイアウトによるエージェントごとのメモリ分離 -メモリの分離はエージェント名ではなく `MemoryLayoutConfig` に基づきます。同じレイアウトと同じメモリ会話 ID を持つエージェントは、1 つのメモリ会話と 1 つの統合済みメモリを共有します。異なるレイアウトを持つエージェントは、同じ sandbox ワークスペースを共有している場合でも、別々のロールアウトファイル、未加工メモリ、`MEMORY.md`、`memory_summary.md` を保持します。 +メモリの分離は、エージェント名ではなく `MemoryLayoutConfig` に基づきます。同じレイアウトと同じメモリ会話 ID を持つエージェントは、1 つのメモリ会話と 1 つの統合済みメモリを共有します。異なるレイアウトを持つエージェントは、同じサンドボックスワークスペースを共有している場合でも、ロールアウトファイル、未加工メモリ、`MEMORY.md`、`memory_summary.md` を個別に保持します。 -複数のエージェントが 1 つの sandbox を共有するものの、メモリは共有すべきでない場合は、別々のレイアウトを使用します。 +複数のエージェントが 1 つのサンドボックスを共有していても、メモリは共有すべきでない場合は、別々のレイアウトを使用します。 ```python from agents import SQLiteSession @@ -186,4 +186,4 @@ gtm_session = SQLiteSession("gtm-q2-pipeline-review") engineering_session = SQLiteSession("eng-invoice-test-fix") ``` -これにより、GTM 分析がエンジニアリングのバグ修正メモリに統合されたり、その逆が起きたりすることを防げます。 \ No newline at end of file +これにより、GTM 分析がエンジニアリングのバグ修正メモリに統合されることも、その逆も防げます。 \ No newline at end of file diff --git a/docs/ja/sandbox_agents.md b/docs/ja/sandbox_agents.md index c0b7a0035e..90a6b30ef1 100644 --- a/docs/ja/sandbox_agents.md +++ b/docs/ja/sandbox_agents.md @@ -8,25 +8,25 @@ search: サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -最新のエージェントは、ファイルシステム上の実ファイルを操作できるときに最大限の力を発揮します。Agents SDK の **サンドボックスエージェント** は、大規模なドキュメント群の検索、ファイルの編集、コマンドの実行、成果物の生成、保存済みのサンドボックス状態からの作業再開が可能な永続ワークスペースをモデルに提供します。 +最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果的に機能します。Agents SDK の **サンドボックスエージェント** は、大規模なドキュメント群の検索、ファイルの編集、コマンドの実行、成果物の生成、保存されたサンドボックス状態からの作業再開が可能な永続的ワークスペースをモデルに提供します。 -SDK は、ファイルのステージング、ファイルシステムツール、シェルアクセス、サンドボックスのライフサイクル、スナップショット、プロバイダー固有の連携コードを自分で組み合わせることなく、この実行基盤を提供します。通常の `Agent` と `Runner` のフローを維持しながら、ワークスペース用の `Manifest`、サンドボックスネイティブツール用の機能、作業の実行場所を指定する `SandboxRunConfig` を追加できます。 +SDK は、ファイルのステージング、ファイルシステムツール、シェルアクセス、サンドボックスのライフサイクル、スナップショット、プロバイダー固有の連携を自分で組み合わせることなく、この実行基盤を提供します。通常の `Agent` と `Runner` のフローを維持したまま、ワークスペース用の `Manifest`、サンドボックスネイティブツールの機能、作業の実行場所を指定する `SandboxRunConfig` を追加します。 ## 前提条件 - Python 3.10 以降 - OpenAI Agents SDK に関する基本的な知識 -- サンドボックスクライアント。ローカル開発では、まず `UnixLocalSandboxClient` を使用してください。 +- サンドボックスクライアント。ローカル開発では、まず `UnixLocalSandboxClient` を使用します。 ## インストール -SDK をまだインストールしていない場合は、次を実行します。 +SDK をまだインストールしていない場合: ```bash pip install openai-agents ``` -Docker ベースのサンドボックスの場合は、次を実行します。 +Docker ベースのサンドボックスの場合: ```bash pip install "openai-agents[docker]" @@ -34,7 +34,7 @@ pip install "openai-agents[docker]" ## ローカルサンドボックスエージェントの作成 -この例では、ローカルリポジトリを `repo/` 配下にステージングし、ローカルスキルを遅延読み込みして、実行時にランナーが Unix ローカルのサンドボックスセッションを作成できるようにします。 +この例では、`repo/` 配下にローカルリポジトリをステージングし、ローカルスキルを遅延読み込みして、実行時にランナーが Unix ローカルのサンドボックスセッションを作成します。 ```python import asyncio @@ -94,24 +94,24 @@ if __name__ == "__main__": asyncio.run(main()) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。この例では、Unix ローカルでの実行間で決定論的に検証できるように、シェルベースの小規模なリポジトリを使用しています。 +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。このコード例では、シェルベースの小さなリポジトリを使用しているため、Unix ローカルでの実行全体にわたって決定論的に検証できます。 ## 主な選択肢 -基本的な実行が動作した後、多くの場合、次の選択肢を検討します。 +基本的な実行が機能した後、多くの方が次に検討する選択肢は以下のとおりです。 -- `default_manifest`: 新規サンドボックスセッション向けのファイル、リポジトリ、ディレクトリ、マウント -- `instructions`: 複数のプロンプトにわたって適用する短いワークフロールール -- `base_instructions`: SDK のサンドボックスプロンプトを置き換えるための高度な回避手段 -- `capabilities`: ファイルシステムの編集/画像検査、シェル、スキル、メモリ、コンパクションなどのサンドボックスネイティブツール -- `run_as`: モデル向けツールで使用するサンドボックスのユーザー ID -- `SandboxRunConfig.client`: サンドボックスのバックエンド -- `SandboxRunConfig.session`、`session_state`、または `snapshot`: 後続の実行を以前の作業へ再接続する方法 +- `default_manifest`:新しいサンドボックスセッションで使用するファイル、リポジトリ、ディレクトリ、マウント +- `instructions`:複数のプロンプトにわたって適用する短いワークフロールール +- `base_instructions`:SDK のサンドボックスプロンプトを置き換えるための高度なエスケープハッチ +- `capabilities`:ファイルシステムの編集/画像検査、シェル、スキル、メモリ、SDK の圧縮メカニズムなどのサンドボックスネイティブツール +- `run_as`:モデル向けツールの実行に使用されるサンドボックスのユーザーアカウント +- `SandboxRunConfig.client`:サンドボックスのバックエンド +- `SandboxRunConfig.session`、`session_state`、または `snapshot`:後続の実行を以前の作業に再接続する方法 ## 次のステップ -- [概念](sandbox/guide.md): マニフェスト、機能、権限、スナップショット、実行設定、構成パターンについて理解します。 -- [サンドボックスクライアント](sandbox/clients.md): Unix ローカル、Docker、ホステッドプロバイダー、マウント戦略を選択します。 -- [エージェントメモリ](sandbox/memory.md): 過去のサンドボックス実行から得た知見を保持し、再利用します。 +- [概念](sandbox/guide.md):マニフェスト、機能、権限、スナップショット、実行設定、構成パターンについて説明します。 +- [サンドボックスクライアント](sandbox/clients.md):Unix ローカル、Docker、ホステッドプロバイダー、マウント戦略を選択します。 +- [エージェントメモリ](sandbox/memory.md):以前のサンドボックス実行から得た知見を保持し、再利用します。 -シェルアクセスを一時的なツールとしてのみ使用する場合は、[ツールガイド](tools.md)のホステッドシェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計に含まれる場合は、サンドボックスエージェントを使用してください。 \ No newline at end of file +シェルアクセスをときどき使用するツールの 1 つとしてのみ必要とする場合は、[ツールガイド](tools.md)のホステッドシェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、サンドボックスセッションの再開動作が設計の一部となる場合は、サンドボックスエージェントを使用してください。 \ No newline at end of file diff --git a/docs/ja/sessions/advanced_sqlite_session.md b/docs/ja/sessions/advanced_sqlite_session.md index 6fd3af277b..bb53fc024d 100644 --- a/docs/ja/sessions/advanced_sqlite_session.md +++ b/docs/ja/sessions/advanced_sqlite_session.md @@ -4,15 +4,15 @@ search: --- # 高度な SQLite セッション -`AdvancedSQLiteSession` は、基本的な `SQLiteSession` の拡張版であり、会話の分岐、詳細な使用状況分析、構造化された会話クエリなど、高度な会話管理機能を提供します。 +`AdvancedSQLiteSession` は、基本的な `SQLiteSession` の拡張版であり、会話の分岐、詳細な使用量分析、構造化された会話クエリなど、高度な会話管理機能を提供します。 ## 機能 -- **会話の分岐**: 任意のユーザーメッセージから別の会話経路を作成 -- **使用状況の追跡**: ターンごとの詳細なトークン使用状況分析と完全な JSON 内訳 -- **構造化クエリ**: ターン単位の会話、ツール使用状況の統計などを取得 -- **ブランチ管理**: 独立したブランチの切り替えと管理 -- **メッセージ構造のメタデータ**: メッセージタイプ、ツールの使用状況、会話フローを追跡 +- **会話の分岐**: 任意のユーザーメッセージから別の会話経路を作成できます +- **使用量の追跡**: ターンごとの詳細なトークン使用量分析と、JSON 形式の完全な内訳を提供します +- **構造化クエリ**: ターンごとの会話、ツール使用統計などを取得できます +- **ブランチ管理**: ブランチを個別に切り替えて管理できます +- **メッセージ構造メタデータ**: メッセージタイプ、ツール使用状況、会話フローを追跡できます ## クイックスタート @@ -85,15 +85,15 @@ session = AdvancedSQLiteSession( ### パラメーター - `session_id` (str): 会話セッションの一意な識別子 -- `db_path` (str | Path): SQLite データベースファイルへのパス。インメモリストレージの場合、デフォルトは `:memory:` です -- `create_tables` (bool): 高度なテーブルを自動的に作成するかどうか。デフォルトは `False` です +- `db_path` (str | Path): SQLite データベースファイルへのパス。デフォルトは、インメモリストレージを使用する `:memory:` です +- `create_tables` (bool): 拡張テーブルを自動的に作成するかどうか。デフォルトは `False` です - `logger` (logging.Logger | None): セッション用のカスタムロガー。デフォルトはモジュールロガーです -## 使用状況の追跡 +## 使用量の追跡 -AdvancedSQLiteSession は、会話の各ターンのトークン使用状況データを保存することで、詳細な使用状況分析を提供します。**これは、エージェントの実行後に毎回 `store_run_usage` メソッドが呼び出されることに全面的に依存します。** +AdvancedSQLiteSession は、会話の各ターンのトークン使用量データを保存することで、詳細な使用量分析を提供します。 **この機能は、各エージェント実行後に `store_run_usage` メソッドが呼び出されることに全面的に依存します。** -### 使用状況データの保存 +### 使用量データの保存 ```python # After each agent run, store the usage data @@ -107,7 +107,7 @@ await session.store_run_usage(result) # - Detailed JSON token information (if available) ``` -### 使用状況統計の取得 +### 使用統計の取得 ```python # Get session-level usage (all branches) @@ -137,7 +137,7 @@ turn_2_usage = await session.get_turn_usage(user_turn_number=2) ## 会話の分岐 -AdvancedSQLiteSession の主な機能の 1 つは、任意のユーザーメッセージから会話のブランチを作成し、別の会話経路を探索できることです。 +AdvancedSQLiteSession の主要機能の 1 つは、任意のユーザーメッセージから会話のブランチを作成し、別の会話経路を探索できることです。 ### ブランチの作成 @@ -165,7 +165,7 @@ branch_id = await session.create_branch_from_content( ) ``` -ブランチ ID は、セッション ID の存続期間を通じて一意です。ブランチを削除したりセッションをクリアしたりすると、その会話データは削除されますが、以前使用したブランチ ID が再び使用可能になるわけではありません。別のブランチを作成する際は、新しい名前を使用してください。 +ブランチ ID は、セッション ID が存続する間、一意です。ブランチを削除したりセッションをクリアしたりすると、その会話データは削除されますが、以前に使用したブランチ ID が再び利用可能になるわけではありません。別のブランチを作成するときは、新しい名前を使用してください。 ### ブランチ管理 @@ -184,7 +184,7 @@ await session.switch_to_branch(branch_id) await session.delete_branch(branch_id, force=True) # force=True allows deleting current branch ``` -### ブランチワークフローの例 +### ブランチのワークフロー例 ```python # Original conversation @@ -247,9 +247,9 @@ for turn in matching_turns: ### メッセージ構造 -セッションは、以下を含むメッセージ構造を自動的に追跡します。 +セッションでは、以下を含むメッセージ構造が自動的に追跡されます。 -- メッセージタイプ(user、assistant、tool_call など) +- メッセージタイプの値(`user`、`assistant`、`tool_call` など) - ツール呼び出しのツール名 - ターン番号とシーケンス番号 - ブランチとの関連付け @@ -259,7 +259,7 @@ for turn in matching_turns: AdvancedSQLiteSession は、基本的な SQLite スキーマを 3 つの追加テーブルで拡張します。 -### `message_structure` テーブル +### message_structure テーブル ```sql CREATE TABLE message_structure ( @@ -278,7 +278,7 @@ CREATE TABLE message_structure ( ); ``` -### `branch_reservations` テーブル +### branch_reservations テーブル ```sql CREATE TABLE branch_reservations ( @@ -288,9 +288,9 @@ CREATE TABLE branch_reservations ( ); ``` -このテーブルは、コピーされた接頭部分が空のブランチを含め、ブランチ ID をアトミックに予約します。予約行はブランチの削除後やセッションのクリア後も保持されるため、古いセッションインスタンスが、同じ ID を再利用した後続のブランチに履歴をマージすることはありません。 +このテーブルは、コピーされたプレフィックスが空のブランチも含め、ブランチ ID をアトミックに予約します。予約行は、ブランチが削除された場合もセッションがクリアされた場合も保持されるため、古いセッションインスタンスが、同じ ID を再利用した後続のブランチに履歴をマージすることはできません。 -### `turn_usage` テーブル +### turn_usage テーブル ```sql CREATE TABLE turn_usage ( @@ -310,12 +310,12 @@ CREATE TABLE turn_usage ( ); ``` -## 完全な例 +## 完全なコード例 -すべての機能を包括的に紹介する[完全な例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)をご確認ください。 +すべての機能を包括的に紹介する[完全なコード例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)をご覧ください。 ## API リファレンス - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - メインクラス -- [`Session`][agents.memory.session.Session] - 基本セッションプロトコル \ No newline at end of file +- [`Session`][agents.memory.session.Session] - 基底セッションプロトコル \ No newline at end of file diff --git a/docs/ja/sessions/index.md b/docs/ja/sessions/index.md index baa26b0a16..2d8a3c62ed 100644 --- a/docs/ja/sessions/index.md +++ b/docs/ja/sessions/index.md @@ -4,11 +4,11 @@ search: --- # セッション -Agents SDK には、複数回のエージェント実行にわたって会話履歴を自動的に維持する組み込みのセッションメモリが用意されているため、ターン間で `.to_input_list()` を手動で処理する必要がありません。 +Agents SDKには、複数回のエージェント実行にわたって会話履歴を自動的に維持する組み込みのセッションメモリが用意されており、ターン間で `.to_input_list()` を手動で処理する必要がありません。 -セッションは特定のセッションの会話履歴を保存し、明示的な手動のメモリ管理を必要とせずに、エージェントがコンテキストを維持できるようにします。これは、エージェントに以前のやり取りを記憶させたいチャットアプリケーションや、複数ターンの会話を構築する場合に特に便利です。 +セッションは特定のセッションの会話履歴を保存するため、明示的にメモリを手動管理しなくても、エージェントはコンテキストを維持できます。これは、エージェントに以前のやり取りを記憶させたいチャットアプリケーションや、複数ターンの会話を構築する場合に特に便利です。 -SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内でセッションを `conversation_id`、`previous_response_id`、または `auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI のサーバーで管理される継続機能を使用したい場合は、セッションと重ねて使用せず、これらの仕組みのいずれかを選択してください。 +SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内では、セッションを実行レベルの継続オプション `conversation_id`、`previous_response_id`、`auto_previous_response_id` と組み合わせることはできません。代わりに OpenAIサーバー管理の継続を使用したい場合は、セッションと重ねて使用せず、これらのメカニズムのいずれかを選択してください。 ## クイックスタート @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 同じセッションによる中断された実行の再開 -実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または同じバックエンドストアを参照する別のセッションインスタンス)を使用して再開し、再開後のターンが保存済みの同じ会話履歴を引き継ぐようにしてください。 +承認待ちで実行が一時停止した場合は、同じセッションインスタンス、または同じセッション ID と同じ基盤ストレージバックエンドで構成された別のインスタンスを使用して再開します。これにより、再開されたターンで同じ保存済み会話履歴が引き継がれます。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -65,29 +65,29 @@ if result.interruptions: ## セッションの基本動作 -セッションメモリが有効な場合、次のように動作します。 +セッションメモリが有効な場合: -1. **各実行前**: ランナーはセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 -2. **各実行後**: 実行中に生成されたすべての新しい項目(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 -3. **コンテキストの保持**: 同じセッションを使用する後続の各実行には完全な会話履歴が含まれるため、エージェントはコンテキストを維持できます。 +1. **各実行の前**: Runner はセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 +2. **各実行の後**: 実行中に生成されたすべての新しい項目(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 +3. **コンテキストの保持**: 同じセッションを使用する後続の各実行には会話履歴全体が含まれるため、エージェントはコンテキストを維持できます。 -これにより、`.to_input_list()` を手動で呼び出し、実行間の会話状態を管理する必要がなくなります。 +これにより、`.to_input_list()` を手動で呼び出して実行間の会話状態を管理する必要がなくなります。 -## 履歴と新しい入力のマージ制御 +## 履歴と新しい入力のマージ方法の制御 -セッションを渡すと、通常、ランナーは次の順序でモデル入力を準備します。 +セッションを渡すと、Runner は通常、次の順序でモデル入力を準備します。 1. セッション履歴(`session.get_items(...)` から取得) 2. 新しいターンの入力 モデル呼び出し前のマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 -- `history`: 取得したセッション履歴(入力項目形式に正規化済み) +- `history`: 取得されたセッション履歴(入力項目形式に正規化済み) - `new_input`: 現在のターンの新しい入力項目 -モデルに送信する入力項目の最終的なリストを返してください。 +モデルに送信する入力項目の最終リストを返します。 -コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストによってそのターンのモデル入力が決まりますが、SDK が永続化するのは新しいターンに属する項目だけです。したがって、古い履歴を並べ替えたりフィルタリングしたりしても、古いセッション項目が新しい入力として再び保存されることはありません。 +コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストはそのターンのモデル入力を制御しますが、SDK が永続化するのは新しいターンに属する項目だけです。そのため、古い履歴を並べ替えたりフィルタリングしたりしても、古いセッション項目が新しい入力として再度保存されることはありません。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -セッションによる項目の保存方法を変更せずに、履歴の独自の枝刈り、並べ替え、または選択的な追加が必要な場合に使用します。モデル呼び出しの直前に後段の最終処理が必要な場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 +セッションによる項目の保存方法を変更せずに、履歴のカスタム整理、並べ替え、または選択的な追加が必要な場合に使用します。モデル呼び出しの直前に最終的な処理を追加する必要がある場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 ## 取得する履歴の制限 各実行前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 -- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッション項目を取得します -- `SessionSettings(limit=N)`: 最新の `N` 項目のみを取得します +- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッション項目を取得 +- `SessionSettings(limit=N)`: 最新の `N` 個の項目のみを取得 -これは、[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] を使用して実行ごとに適用できます。 +[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] を使用して、実行ごとに適用できます。 ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` はその実行について、`None` ではない値を上書きします。これは、セッションのデフォルト動作を変更せずに取得サイズを制限したい長い会話で便利です。 +セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` 内の `None` 以外の各値が、その実行に対応するデフォルト値を上書きします。これは、セッションのデフォルト動作を変更せずに取得件数を制限したい長い会話で便利です。 ## メモリ操作 ### 基本操作 -セッションでは、会話履歴を管理するために複数の操作を使用できます。 +セッションは、会話履歴を管理するための複数の操作をサポートしています。 ```python from agents import SQLiteSession @@ -165,7 +165,7 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### `pop_item` を使用した修正 +### 修正での pop_item の使用 `pop_item` メソッドは、会話の最後の項目を取り消したり変更したりする場合に特に便利です。 @@ -196,34 +196,34 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 組み込みセッション実装 +## 組み込みのセッション実装 -SDK には、さまざまなユースケースに対応する複数のセッション実装が用意されています。 +SDK は、さまざまなユースケース向けに複数のセッション実装を提供しています。 ### 組み込みセッション実装の選択 -以下の詳細な例を読む前に、この表を使用して開始点を選択してください。 +以下の詳細な例を読む前に、開始点を選ぶためにこの表を使用してください。 | セッションタイプ | 最適な用途 | 備考 | | --- | --- | --- | -| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量。ファイルベースまたはインメモリ | +| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量、ファイルベースまたはインメモリ | | `AsyncSQLiteSession` | `aiosqlite` を使用する非同期 SQLite | 非同期ドライバーをサポートする拡張バックエンド | -| `RedisSession` | ワーカーやサービス間の共有メモリ | 低レイテンシーの分散デプロイに最適 | -| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースに対応 | -| `MongoDBSession` | MongoDB をすでに使用しているアプリ、またはマルチプロセスストレージが必要なアプリ | 非同期 pymongo。順序付け用のアトミックなシーケンスカウンター | -| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数の状態ストアに加え、TTL と整合性の制御をサポート | -| `OpenAIConversationsSession` | OpenAI でのサーバー管理ストレージ | OpenAI Conversations API をバックエンドとする履歴 | -| `OpenAIResponsesCompactionSession` | 自動圧縮を伴う長い会話 | 別のセッションバックエンドをラップ | -| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析が必要な場合 | より高度な機能セット。専用ページを参照 | -| `EncryptedSession` | 別のセッションに追加する暗号化と TTL | ラッパー。最初に基盤となるバックエンドを選択 | +| `RedisSession` | ワーカーやサービス間での共有メモリ | 低レイテンシーの分散デプロイに適しています | +| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作します | +| `MongoDBSession` | MongoDB をすでに使用しているアプリ、またはマルチプロセスストレージが必要なアプリ | 非同期 pymongo、順序付け用のアトミックなシーケンスカウンター | +| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数のステートストア、TTL、整合性制御をサポートします | +| `OpenAIConversationsSession` | OpenAI内のサーバー管理ストレージ | OpenAI Conversations API を基盤とする履歴 | +| `OpenAIResponsesCompactionSession` | 自動圧縮を使用する長い会話 | 別のセッションバックエンドをラップします | +| `AdvancedSQLiteSession` | SQLite と分岐/分析 | より多機能です。専用ページを参照してください | +| `EncryptedSession` | 別のセッションに追加する暗号化と TTL | ラッパーです。最初に基盤となるバックエンドを選択してください | -一部の実装には、追加の詳細を記載した専用ページがあります。それぞれのサブセクション内にリンクがあります。 +一部の実装には詳細を記載した専用ページがあり、各サブセクション内にリンクがあります。 -ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドと項目を永続化するために `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[ChatKit データストアの実装に関する `chatkit-python` ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 +ChatKit 用の Pythonサーバーを実装する場合は、ChatKit のスレッドと項目を永続化するために `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDKセッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[`chatkit-python` による ChatKit データストアの実装ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 ### OpenAI Conversations API セッション -`OpenAIConversationsSession` を通じて、[OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations)を使用します。 +`OpenAIConversationsSession` を通じて [OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations)を使用します。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -259,9 +259,9 @@ print(result.final_output) # "California" ### OpenAI Responses 圧縮セッション -Responses API(`responses.compact`)を使用して保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動圧縮できます。`OpenAIConversationsSession` をラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 +Responses API(`responses.compact`)を使用して保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動的に圧縮できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は、異なる方法で履歴を管理します。 -#### 典型的な使用方法(自動圧縮) +#### 一般的な使用方法(自動圧縮) ```python from agents import Agent, Runner, SQLiteSession @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -デフォルトでは、候補のしきい値に達すると、各ターン後に圧縮が実行されます。 +デフォルトでは、各ターン後に SDK が圧縮候補がしきい値を満たしているかを確認し、満たしている場合にのみ圧縮します。 -Responses API のレスポンス ID を使用してすでにターンを連結している場合は、`compaction_mode="previous_response_id"` が最適です。`compaction_mode="input"` は、代わりに現在のセッション項目から圧縮リクエストを再構築します。これは、レスポンスチェーンを利用できない場合や、セッションの内容を信頼できる情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 +`compaction_mode="previous_response_id"` は圧縮セッションが保持する Responses API のレスポンス ID を使用し、そのレスポンスチェーンが利用可能な間に最も効果的に動作します。一方、`compaction_mode="input"` は現在のセッション項目から圧縮リクエストを再構築します。これは、レスポンスチェーンが利用できない場合や、セッションの内容を信頼できる唯一の情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 -エージェントが `ModelSettings(store=False)` で実行されている場合、Responses API は後から参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースの圧縮にフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 +エージェントを `ModelSettings(store=False)` で実行すると、Responses API は後で参照できるように最後のレスポンスを保持しません。このステートレス構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースの圧縮にフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 #### 自動圧縮によるストリーミングのブロック -圧縮はセッション履歴を消去して書き換えるため、SDK は圧縮が完了するまで実行を完了と見なしません。ストリーミングモードでは、圧縮処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 +圧縮ではセッション履歴を消去して再書き込みするため、SDK は圧縮が完了するまで実行を完了と見なしません。ストリーミングモードでは、圧縮処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 -低レイテンシーのストリーミングや素早いターン移行が必要な場合は、自動圧縮を無効にし、ターン間またはアイドル時に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、圧縮を強制するタイミングを決定できます。 +低レイテンシーのストリーミングや迅速なターン切り替えが必要な場合は、自動圧縮を無効にし、ターン間(またはアイドル時)に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、圧縮を強制するタイミングを決定できます。 ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 非同期 SQLite セッション -`aiosqlite` をバックエンドとする SQLite 永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 +`aiosqlite` を基盤とする SQLite の永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 ```bash pip install aiosqlite @@ -349,7 +349,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis セッション -複数のワーカーやサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 +複数のワーカーまたはサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 ```bash pip install openai-agents[redis] @@ -368,11 +368,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)` は Redis クライアントを作成して所有します。`close()` の後、セッションは終了状態となり、後続のセッション操作では `RuntimeError` が発生します。`close()` を繰り返し呼び出したり、同時に呼び出したりしても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築してください。その場合、`close()` は何も行わず、呼び出し元がクライアントの所有権を保持し、セッションも引き続き利用できます。 +`from_url(...)` は Redis クライアントを作成し、その所有権を持ちます。`close()` の後、セッションは終了状態となり、以降のセッション操作では `RuntimeError` が発生します。`close()` の呼び出しは、反復または並行して行っても安全です。アプリケーションが Redis クライアントをすでに管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築します。その場合、`close()` は何もせず、呼び出し元がクライアントの所有権を保持し、セッションも引き続き使用できます。 ### SQLAlchemy セッション -SQLAlchemy がサポートする任意のデータベースを使用する、本番環境対応の Agents SDK セッション永続化です。 +SQLAlchemy がサポートする任意のデータベースを使用する、本番環境向けの Agents SDKセッション永続化です。 ```python from agents.extensions.memory import SQLAlchemySession @@ -394,7 +394,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ### Dapr セッション -すでに Dapr サイドカーを実行している場合や、エージェントコードを変更せずに異なる状態ストアのバックエンド間で移行できるセッションストレージが必要な場合は、`DaprSession` を使用します。 +Dapr サイドカーをすでに実行している場合や、エージェントのコードを変更せずに構成済みのステートストアバックエンドを切り替えたい場合は、`DaprSession` を使用します。 ```bash pip install openai-agents[dapr] @@ -415,14 +415,14 @@ async with DaprSession.from_address( print(result.final_output) ``` -注記: +注意事項: -- `from_address(...)` は Dapr クライアントを作成して所有します。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を指定して `DaprSession(...)` を直接構築してください。 -- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを使用するセッションは終了状態になります。後続のセッション操作では `RuntimeError` が発生しますが、`close()` を繰り返し呼び出したり、同時に呼び出したりしても安全です。注入されたクライアントを使用する場合、`close()` は何も行わず、セッションは引き続き利用できます。 -- バックエンドの状態ストアが TTL をサポートしている場合に、古いセッションデータを自動的に期限切れにするには、`ttl=...` を渡します。 -- 書き込み後の読み取りについて、より強い一貫性保証が必要な場合は、`consistency=DAPR_CONSISTENCY_STRONG` を渡します。 +- `from_address(...)` は Dapr クライアントを作成し、その所有権を持ちます。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を指定して `DaprSession(...)` を直接構築します。 +- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを持つセッションは終了状態になります。以降のセッション操作では `RuntimeError` が発生しますが、`close()` の呼び出しは、反復または並行して行っても安全です。注入されたクライアントを使用する場合、`close()` は何もせず、セッションは引き続き使用できます。 +- 基盤となるステートストアが TTL をサポートしている場合は、`ttl=...` を渡すことで、セッションデータに TTL による有効期限が自動的に適用されます。 +- 書き込み後の読み取りについて、より強い保証が必要な場合は `consistency=DAPR_CONSISTENCY_STRONG` を渡します。 - Dapr Python SDK は HTTP サイドカーエンドポイントも確認します。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` を指定して Dapr を起動してください。 -- ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)を参照してください。 +- ローカルコンポーネントやトラブルシューティングを含む設定手順の全体については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)を参照してください。 ### MongoDB セッション @@ -450,16 +450,16 @@ print(result.final_output) await session.close() ``` -注記: +注意事項: -- `from_uri(...)` は `AsyncMongoClient` を作成して所有し、`session.close()` で閉じます。所有クライアントを使用するセッションは `close()` 後に終了状態となり、後続のセッション操作では `RuntimeError` が発生します。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築してください。その場合、`session.close()` は何も行わず、ライフサイクルとセッションの利用可否は呼び出し元が管理します。 -- ほかに変更を加えることなく、`mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すことで、[MongoDB Atlas](https://www.mongodb.com/products/platform)に接続できます。 -- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。各メッセージドキュメントには単調増加する `seq` カウンターが含まれ、同時実行される書き込み元やプロセス間で順序を維持します。 +- `from_uri(...)` は `AsyncMongoClient` を作成してその所有権を持ち、`session.close()` の際に閉じます。所有クライアントを持つセッションは `close()` の後に終了状態となり、以降のセッション操作では `RuntimeError` が発生します。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築します。その場合、`session.close()` は何もせず、呼び出し元がクライアントのライフサイクルに対する責任を保持し、セッションは引き続き使用できます。 +- その他の変更を行わずに、`mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すことで、[MongoDB Atlas](https://www.mongodb.com/products/platform)に接続できます。 +- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。空でない `add_items()` の各呼び出しでは、単調増加する `seq` によって最後の項目を基準にバッチが順序付けられた、1 つの論理バッチドキュメントが書き込まれます。従来の項目単位のメッセージドキュメントも引き続き読み取れます。論理バッチは MongoDB の単一ドキュメントのサイズ制限内に収まる必要があります。サイズを超過したバッチは、部分的なバッチを保存することなくアトミックに失敗します。 - 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 ### 高度な SQLite セッション -会話の分岐、使用状況分析、構造化クエリに対応した拡張 SQLite セッションです。 +会話の分岐、使用状況分析、構造化クエリを備えた拡張 SQLite セッションです。 ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -483,7 +483,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 ### 暗号化セッション -任意のセッション実装に対応する透過的な暗号化ラッパーです。 +任意のセッション実装向けの透過的な暗号化ラッパーです。 ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -516,7 +516,7 @@ result = await Runner.run(agent, "Hello", session=session) ### セッション ID の命名 -会話の整理に役立つ、意味のあるセッション ID を使用してください。 +会話を整理しやすい、意味のあるセッション ID を使用します。 - ユーザーベース: `"user_12345"` - スレッドベース: `"thread_abc123"` @@ -527,13 +527,13 @@ result = await Runner.run(agent, "Hello", session=session) - 一時的な会話には、インメモリ SQLite(`SQLiteSession("session_id")`)を使用します - 永続的な会話には、ファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します - `aiosqlite` ベースの実装が必要な場合は、非同期 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)を使用します -- 共有可能で低レイテンシーのセッションメモリには、Redis をバックエンドとするセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します +- 共有された低レイテンシーのセッションメモリには、Redis ベースのセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します - SQLAlchemy がサポートする既存のデータベースを使用する本番システムには、SQLAlchemy ベースのセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)を使用します - MongoDB をすでに使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要な場合は、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します -- 組み込みのテレメトリ、トレーシング、データ分離機能と 30 種類超のデータベースバックエンドのサポートが必要な本番環境のクラウドネイティブデプロイには、Dapr 状態ストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します -- OpenAI Conversations API に履歴を保存したい場合は、OpenAI がホストするストレージ(`OpenAIConversationsSession()`)を使用します -- 任意のセッションを透過的な暗号化と TTL ベースの期限切れ機能でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します -- より高度なユースケースでは、ほかの本番システム(Django など)向けのカスタムセッションバックエンドの実装を検討してください +- 組み込みのテレメトリ、トレーシング、データ分離、30 種類を超えるデータベースバックエンドのサポートが必要な本番環境のクラウドネイティブデプロイには、Dapr ステートストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します +- OpenAI Conversations API に履歴を保存したい場合は、OpenAIがホストするストレージ(`OpenAIConversationsSession()`)を使用します +- 任意のセッションに透過的な暗号化と TTL ベースの有効期限を追加するには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します +- より高度なユースケースでは、他の本番システム(Django など)向けのカスタムセッションバックエンドの実装を検討してください ### 複数のセッション @@ -581,7 +581,7 @@ result2 = await Runner.run( ## 完全な例 -セッションメモリの実際の動作を示す完全な例を次に示します。 +セッションメモリの動作を示す完全な例を以下に示します。 ```python import asyncio @@ -696,7 +696,7 @@ result = await Runner.run( |---------|-------------| | [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)向けの Django ORM ベースのセッション | -セッション実装を構築した場合は、ここに追加するためのドキュメント PR をぜひ送信してください。 +セッション実装を構築した場合は、ここに追加するためのドキュメント PR をぜひ提出してください。 ## API リファレンス @@ -707,9 +707,9 @@ result = await Runner.run( - [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 圧縮ラッパー - [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基本的な SQLite 実装 - [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` ベースの非同期 SQLite 実装 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis をバックエンドとするセッション実装 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis ベースのセッション実装 - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy ベースの実装 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB をバックエンドとするセッション実装 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状態ストア実装 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析に対応した拡張 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッションに対応する暗号化ラッパー \ No newline at end of file +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB ベースのセッション実装 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr ステートストア実装 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析を備えた拡張 SQLite +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッション向けの暗号化ラッパー \ No newline at end of file diff --git a/docs/ja/sessions/sqlalchemy_session.md b/docs/ja/sessions/sqlalchemy_session.md index 85d63416ba..2baa2aca3b 100644 --- a/docs/ja/sessions/sqlalchemy_session.md +++ b/docs/ja/sessions/sqlalchemy_session.md @@ -4,11 +4,11 @@ search: --- # SQLAlchemy セッション -`SQLAlchemySession` は SQLAlchemy を使用して、本番環境に対応したセッション実装を提供します。これにより、SQLAlchemy がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)をセッションストレージとして使用できます。 +`SQLAlchemySession` は SQLAlchemy を使用して本番環境対応のセッション実装を提供し、SQLAlchemy がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)をセッションストレージとして使用できるようにします。 ## インストール -SQLAlchemy セッションには、`sqlalchemy` extra が必要です。 +SQLAlchemy セッションには、`openai-agents` パッケージの optional-dependency extra `sqlalchemy` が必要です。 ```bash pip install openai-agents[sqlalchemy] @@ -18,7 +18,7 @@ pip install openai-agents[sqlalchemy] ### データベース URL の使用 -最も簡単に使い始める方法は次のとおりです。 +最も簡単に開始する方法は次のとおりです。 ```python import asyncio @@ -42,7 +42,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 既存エンジンの使用 +### 既存のエンジンの使用 既存の SQLAlchemy エンジンを使用するアプリケーションの場合は、次のようにします。 @@ -75,9 +75,9 @@ if __name__ == "__main__": ## 非 ASCII テキストの保存 -デフォルトでは、`SQLAlchemySession` はセッション項目を JSON にシリアライズする際に、非 ASCII 文字をエスケープします。これにより、項目の読み込み時に元のテキストへ復元できる状態を維持しながら、従来の保存形式が保持されます。 +デフォルトでは、`SQLAlchemySession` はセッション項目を JSON にシリアライズする際に、非 ASCII 文字をエスケープします。これにより、従来の保存形式を維持しながら、項目の読み込み時には元のテキストを復元できます。 -保存される JSON 内で多言語テキストを読みやすい状態に保つには、`ensure_ascii=False` を設定します。 +保存された JSON 内で多言語テキストを読み取り可能な状態に保つには、`ensure_ascii=False` を設定します。 ```python session = SQLAlchemySession.from_url( @@ -88,7 +88,7 @@ session = SQLAlchemySession.from_url( ) ``` -既存のエンジンを使用する場合は、同じオプションを `SQLAlchemySession(...)` に直接渡すこともできます。この設定で変更されるのは、データベースに保存される JSON 表現のみです。セッションメソッドが返す値は変更されません。 +既存のエンジンを使用する場合は、同じオプションを `SQLAlchemySession(...)` に直接渡すことができます。この設定によって変更されるのはデータベースに保存される JSON 表現のみであり、セッションメソッドが返す値は変更されません。 ## API リファレンス diff --git a/docs/ja/streaming.md b/docs/ja/streaming.md index 26c8ad49a1..17ebe63b46 100644 --- a/docs/ja/streaming.md +++ b/docs/ja/streaming.md @@ -4,19 +4,19 @@ search: --- # ストリーミング -ストリーミングを使用すると、エージェントの実行中に更新を受け取れます。これは、エンドユーザーに進行状況の更新や部分的なレスポンスを表示する場合に役立ちます。 +ストリーミングを使用すると、進行中のエージェント実行の更新を購読できます。これは、エンドユーザーに進捗状況の更新や部分的なレスポンスを表示する場合に便利です。 -ストリーミングするには、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を呼び出します。これにより、[`RunResultStreaming`][agents.result.RunResultStreaming] が返されます。`result.stream_events()` を呼び出すと、以下で説明する [`StreamEvent`][agents.stream_events.StreamEvent] オブジェクトの非同期ストリームが返されます。 +ストリーミングするには、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を呼び出します。これにより、[`RunResultStreaming`][agents.result.RunResultStreaming] が得られます。`result.stream_events()` を呼び出すと、以下で説明する [`StreamEvent`][agents.stream_events.StreamEvent] オブジェクトの非同期ストリームが得られます。 -非同期イテレーターが終了するまで、`result.stream_events()` を処理し続けてください。ストリーミング実行は、イテレーターが終了するまで完了しません。また、セッションの永続化、承認状態の記録管理、履歴の圧縮などの後処理は、最後の可視トークンが到着した後に完了する場合があります。ループが終了すると、`result.is_complete` に最終的な実行状態が反映されます。 +非同期イテレーターが終了するまで、`result.stream_events()` を消費し続けてください。ストリーミング実行は、イテレーターが終了するまで完了しません。また、セッションの永続化、承認情報の記録、履歴の圧縮などの後処理は、最後に表示されるトークンが到着した後に完了する場合があります。ループを抜けると、`result.is_complete` は最終的な実行状態を反映します。 -## raw レスポンスイベント +## Raw レスポンスイベント -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] は、LLM から直接渡される raw イベントです。これらは OpenAI Responses API 形式であるため、各イベントにはタイプ(`response.created`、`response.output_text.delta` など)とデータがあります。これらのイベントは、レスポンスメッセージが生成され次第、ユーザーにストリーミングする場合に役立ちます。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] オブジェクトは、LLM から直接渡される raw イベントをラップします。各オブジェクトの `data` フィールドには、`response.created` や `response.output_text.delta` などの型を持つ OpenAI Responses API イベントが格納されます。これらのイベントは、レスポンスメッセージを生成され次第ユーザーにストリーミングする場合に便利です。 -コンピュータツールの raw イベントでは、保存された実行結果と同じく、プレビュー版と GA 版が区別されます。プレビュー版のフローでは、1 つの `action` を持つ `computer_call` アイテムがストリーミングされます。一方、`gpt-5.5` では、バッチ化された `actions[]` を持つ `computer_call` アイテムがストリーミングされる場合があります。上位レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] インターフェースでは、これに対してコンピュータ専用の特別なイベント名は追加されません。どちらの形式も引き続き `tool_called` として公開され、スクリーンショットの実行結果は `computer_call_output` アイテムをラップする `tool_output` として返されます。 +コンピュータツールの raw イベントでは、保存済みの結果と同様に、プレビュー版と GA 版の区別が維持されます。プレビューのフローでは、1 つの `action` を持つ `computer_call` アイテムをストリーミングします。一方、`gpt-5.5` では、バッチ化された `actions[]` を持つ `computer_call` アイテムをストリーミングできます。上位レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] インターフェースでは、このためにコンピュータ専用の特別なイベント名は追加されません。どちらの形式も引き続き `tool_called` として公開され、スクリーンショットの結果は `computer_call_output` アイテムをラップする `tool_output` として返されます。 -たとえば、次のコードは LLM が生成したテキストをトークン単位で出力します。 +たとえば、次の例では LLM が生成したテキストをトークン単位で出力します。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## ストリーミングと承認 -ストリーミングは、ツールの承認待ちで一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` は終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] に公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 +ストリーミングは、ツールの承認のために一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` が終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] に公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,47 +57,47 @@ if result.interruptions: pass ``` -一時停止と再開の手順全体については、[ヒューマンインザループのガイド](human_in_the_loop.md)を参照してください。 +一時停止と再開の完全な手順については、[人間参加型のガイド](human_in_the_loop.md)を参照してください。 -## 現在のターン終了後のストリーミング停止 +## 現在のターン完了後のストリーミング停止 -ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行は即座に停止します。現在のターンを正常に完了させてから停止するには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 +ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行はすぐに停止します。現在のターンを正常に完了させてから停止するには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 -ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後の可視トークンの後も、SDK がセッションアイテムの永続化、承認状態の確定、履歴の圧縮を行っている場合があります。 +ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後に表示されるトークンの後も、SDK ではセッションアイテムの永続化、承認状態の確定、履歴の圧縮が続いている可能性があります。 -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で処理を継続しており、`cancel(mode="after_turn")` によってツールターンの後で停止した場合は、すぐに新しいユーザーターンを追加するのではなく、その正規化された入力で `result.last_agent` を再実行して、未完了のターンを継続してください。 -- ストリーミング実行がツールの承認待ちで停止した場合、それを新しいターンとして扱わないでください。ストリームを最後まで処理し、`result.interruptions` を確認して、`result.to_state()` から再開してください。 -- 取得したセッション履歴と新しいユーザー入力を、次のモデル呼び出しの前にどのように統合するかをカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。そこで新しいターンのアイテムを書き換えた場合、そのターンでは書き換え後のバージョンが永続化されます。 +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で続行している場合に、`cancel(mode="after_turn")` がツールのターン後に停止したときは、新しいユーザーターンをすぐに追加するのではなく、その正規化済み入力を指定して `result.last_agent` を再実行し、未完了の既存ユーザーターンを続行してください。 +- ストリーミング実行がツールの承認のために停止した場合、それを新しいターンとして扱わないでください。ストリームを最後まで消費し、`result.interruptions` を確認して、`result.to_state()` から再開してください。 +- [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用すると、取得したセッション履歴と新しいユーザー入力を、次回のモデル呼び出し前にどのように統合するかをカスタマイズできます。そこで新しいターンのアイテムを書き換えると、そのターンでは書き換え後のバージョンが永続化されます。 ## 実行アイテムイベントとエージェントイベント -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、上位レベルのイベントです。アイテムの生成が完全に完了すると通知されます。これにより、トークンごとではなく、「メッセージ生成済み」や「ツール実行済み」などの単位で進行状況の更新を送信できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更された場合(ハンドオフの結果など)に更新を通知します。 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より上位レベルのイベントです。アイテムの生成が完全に完了したときに通知されます。これにより、各トークン単位ではなく、「メッセージが生成された」「ツールが実行された」などの単位で進捗状況の更新を送信できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(たとえば、ハンドオフの結果として)に更新を提供します。 -### 実行アイテムのイベント名 +### 実行アイテムイベント名 -`RunItemStreamEvent.name` では、固定されたセマンティックイベント名のセットを使用します。 +`RunItemStreamEvent.name` は、固定のセマンティックイベント名を使用します。 -- `message_output_created` -- `handoff_requested` -- `handoff_occured` -- `tool_called` -- `tool_search_called` -- `tool_search_output_created` -- `tool_output` -- `reasoning_item_created` -- `mcp_approval_requested` -- `mcp_approval_response` -- `mcp_list_tools` +- `message_output_created` +- `handoff_requested` +- `handoff_occured` +- `tool_called` +- `tool_search_called` +- `tool_search_output_created` +- `tool_output` +- `reasoning_item_created` +- `mcp_approval_requested` +- `mcp_approval_response` +- `mcp_list_tools` -`handoff_occured` は、後方互換性のため意図的にスペルが誤っています。 +`handoff_occured` は、後方互換性のために意図的にスペルが誤っています。 ハンドオフ呼び出しは `handoff_requested` としてのみ発行され、`tool_called` として重複して発行されることはありません。同じターン内の通常の関数ツール呼び出しでは、引き続き `tool_called` が発行されます。 -ホスト型ツール検索を使用すると、モデルがツール検索リクエストを発行したときに `tool_search_called` が発行され、Responses API が読み込まれたサブセットを返したときに `tool_search_output_created` が発行されます。 +ホスト型ツール検索を使用する場合、モデルがツール検索リクエストを発行すると `tool_search_called` が発行され、Responses API が読み込まれたサブセットを返すと `tool_search_output_created` が発行されます。 -Programmatic Tool Calling では、生成された `program` と、通常のプログラム配下の子ツール呼び出しに対して `tool_called` が発行されます。子ツールの出力と、それに対応する `program_output` に対しては、`tool_output` が発行されます。プログラム配下のホスト型 MCP の `mcp_approval_request` アイテムと `mcp_list_tools` アイテムは例外です。これらは、それぞれ [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] と [`MCPListToolsItem`][agents.items.MCPListToolsItem] をラップし、`mcp_approval_requested` と `mcp_list_tools` として発行されます。残りのアイテムを区別するには、raw アイテムの `type` を確認してください。プログラム配下の子呼び出しには、タイプが `program` で、呼び出し元 ID が親プログラムを識別する `caller` も含まれます。 +プログラムによるツール呼び出しでは、生成された `program` と、通常のプログラム所有の子ツール呼び出しに対して `tool_called` が発行されます。子ツールの出力と、生成された `program` に対応する `program_output` に対しては、`tool_output` が発行されます。プログラム所有のホスト型 MCP の `mcp_approval_request` アイテムと `mcp_list_tools` アイテムは例外です。それぞれ、[`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] をラップする `mcp_approval_requested` と、[`MCPListToolsItem`][agents.items.MCPListToolsItem] をラップする `mcp_list_tools` として発行されます。残りのアイテムを区別するには、raw アイテムの `type` を確認してください。プログラム所有の子呼び出しには `caller` も含まれ、その型は `program` で、呼び出し元 ID によって親プログラムが識別されます。 -たとえば、次のコードは raw イベントを無視し、更新をユーザーにストリーミングします。 +たとえば、次の例では raw イベントを無視し、更新をユーザーにストリーミングします。 ```python import asyncio diff --git a/docs/ja/tools.md b/docs/ja/tools.md index 81efb977c0..fa71beea2e 100644 --- a/docs/ja/tools.md +++ b/docs/ja/tools.md @@ -4,43 +4,43 @@ search: --- # ツール -ツールを使用すると、エージェントはデータの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作などのアクションを実行できます。SDK は 5 つのカテゴリーをサポートしています。 +ツールを使うと、データの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作などをエージェントに実行させることができます。SDK は、次の 5 つのカテゴリーをサポートしています。 -- OpenAI がホストするツール:OpenAI のサーバー上でモデルと並行して実行されます。 -- ローカル/ランタイム実行ツール:`ComputerTool` と `ApplyPatchTool` は常にお使いの環境で実行され、`ShellTool` はローカルまたはホスト型コンテナで実行できます。 -- Function Calling:任意の Python 関数をツールとしてラップします。 -- Agents as tools:完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 -- 実験的機能:Codex ツール:ツール呼び出しからワークスペース単位の Codex タスクを実行します。 +- OpenAI がホストするツール: OpenAI のサーバー上でモデルのために実行されます。 +- ローカル/ランタイム実行ツール: `ComputerTool` と `ApplyPatchTool` は常にお使いの環境で実行され、`ShellTool` はローカルまたはホストされたコンテナで実行できます。 +- `FunctionTool` インスタンス: 任意の Python 関数をツールとしてラップします。 +- Agents as tools: 完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 +- 実験的機能: Codex ツール: ツール呼び出しからワークスペーススコープの Codex タスクを実行します。 ## ツールタイプの選択 -このページをカタログとして使用し、制御するランタイムに対応するセクションへ進んでください。 +このページをカタログとして使用し、管理するランタイムに対応するセクションに進んでください。 -| 実行したいこと | 参照先 | +| 目的 | 参照先 | | --- | --- | -| OpenAI が管理するツール(Web 検索、ファイル検索、Code Interpreter、ホスト型 MCP、画像生成)を使用する | [ホスト型ツール](#hosted-tools) | -| ツール検索を使用して、大規模なツール群の読み込みをランタイムまで遅延する | [ホスト型ツール検索](#hosted-tool-search) | -| 生成された JavaScript から複数のツール呼び出しを調整する | [プログラムによるツール呼び出し](#programmatic-tool-calling) | -| 独自のプロセスまたは環境でツールを実行する | [ローカルランタイムツール](#local-runtime-tools) | -| Python 関数をツールとしてラップする | [関数ツール](#function-tools) | -| ハンドオフなしで、あるエージェントから別のエージェントを呼び出せるようにする | [Agents as tools](#agents-as-tools) | -| エージェントからワークスペース単位の Codex タスクを実行する | [実験的機能:Codex ツール](#experimental-codex-tool) | +| OpenAI が管理するツール(Web 検索、ファイル検索、Code Interpreter、ホストされた MCP、画像生成)の使用 | [ホストされたツール](#hosted-tools) | +| ツール検索を使用して、大規模なツールセットの読み込みをランタイムまで遅延 | [ホストされたツール検索](#hosted-tool-search) | +| 生成された JavaScript から複数のツール呼び出しを調整 | [プログラムによるツール呼び出し](#programmatic-tool-calling) | +| 独自のプロセスまたは環境でツールを実行 | [ローカルランタイムツール](#local-runtime-tools) | +| Python 関数をツールとしてラップ | [関数ツール](#function-tools) | +| ハンドオフせずに、あるエージェントから別のエージェントを呼び出し | [Agents as tools](#agents-as-tools) | +| エージェントからワークスペーススコープの Codex タスクを実行 | [実験的機能: Codex ツール](#experimental-codex-tool) | -## ホスト型ツール +## ホストされたツール -OpenAI は、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する際に、いくつかの組み込みツールを提供しています。 +[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合、OpenAI はいくつかの組み込みツールを提供します。 - [`WebSearchTool`][agents.tool.WebSearchTool] を使用すると、エージェントが Web を検索できます。 - [`FileSearchTool`][agents.tool.FileSearchTool] を使用すると、OpenAI ベクトルストアから情報を取得できます。 - [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] を使用すると、LLM がサンドボックス環境でコードを実行できます。 - [`HostedMCPTool`][agents.tool.HostedMCPTool] は、リモート MCP サーバーのツールをモデルに公開します。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] は、プロンプトから画像を生成します。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルは遅延されたツール、名前空間、またはホスト型 MCP サーバーを必要に応じて読み込めます。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルは生成された JavaScript から対象ツールを調整できます。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルが必要に応じて遅延ツール、名前空間、またはホストされた MCP サーバーを読み込めます。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルが生成した JavaScript から対象ツールを調整できます。 -ホスト型検索の高度なオプション: +ホストされた検索の高度なオプション: -- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。`max_num_results` には 1~50 の整数を設定します。`None` またはゼロの場合は、プロバイダーのデフォルトが使用されます。 +- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。`max_num_results` には 1 から 50 までの整数を設定してください。`None` または 0 を指定すると、プロバイダーのデフォルト値が使用されます。 - `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートします。 ```python @@ -62,11 +62,11 @@ async def main(): print(result.final_output) ``` -### ホスト型ツール検索 +### ホストされたツール検索 -ツール検索を使用すると、OpenAI Responses モデルは大規模なツール群の読み込みをランタイムまで遅延できるため、現在のターンで必要なサブセットのみを読み込めます。多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールをあらかじめ公開せずにツールスキーマのトークン数を削減したい場合に役立ちます。 +ツール検索を使用すると、OpenAI Responses モデルは大規模なツールセットの読み込みをランタイムまで遅延できるため、モデルは現在のターンに必要なサブセットのみを読み込みます。多数の関数ツール、名前空間グループ、またはホストされた MCP サーバーがあり、すべてのツールを事前に公開せずにツールスキーマのトークン数を削減したい場合に役立ちます。 -エージェントを構築する時点で候補となるツールがすでに分かっている場合は、ホスト型ツール検索を使用してください。アプリケーション側で読み込む対象を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしていますが、標準の `Runner` はこのモードを自動実行しません。 +エージェントを構築する時点で候補ツールがすでに判明している場合は、ホストされたツール検索から始めてください。アプリケーションで読み込む内容を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしますが、標準の `Runner` では、このモードは自動実行されません。 ```python from typing import Annotated @@ -109,28 +109,28 @@ result = await Runner.run(agent, "Look up customer_42 and list their open orders print(result.final_output) ``` -留意事項: +留意事項: -- ホスト型ツール検索は、OpenAI Responses モデルでのみ使用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 -- エージェントで遅延読み込み対象を設定する場合は、`ToolSearchTool()` を 1 つだけ追加します。 +- ホストされたツール検索は、OpenAI Responses モデルでのみ利用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 +- エージェントに遅延読み込み対象を設定する場合は、`ToolSearchTool()` を 1 つだけ追加してください。 - 検索可能な対象には、`@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])`、`HostedMCPTool(tool_config={..., "defer_loading": True})` が含まれます。 -- 遅延読み込みを行う関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成でも `ToolSearchTool()` を使用し、モデルが必要に応じて適切なグループを読み込めるようにできます。 -- `tool_namespace()` は、複数の `FunctionTool` インスタンスを共通の名前空間名と説明の下にまとめます。`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に通常最も適しています。 -- OpenAI の公式ベストプラクティスガイダンスでは、[可能な場合は名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことを推奨しています。 -- 可能であれば、個別に遅延される多数の関数よりも、名前空間またはホスト型 MCP サーバーを優先してください。通常、モデルにとって高水準で検索しやすい対象となり、トークンもより効果的に節約できます。 -- 名前空間には、即時利用可能なツールと遅延ツールを混在させられます。`defer_loading=True` が指定されていないツールはすぐに呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 -- 目安として、各名前空間は十分に小さく保ち、できれば関数を 10 個未満にしてください。 -- 名前付きの `tool_choice` では、単独の名前空間名や遅延専用ツールを指定できません。`auto`、`required`、または実際にトップレベルで呼び出し可能なツール名を使用してください。 -- `ToolSearchTool(execution="client")` は、Responses を手動でオーケストレーションするためのものです。モデルがクライアント実行型の `tool_search_call` を生成した場合、標準の `Runner` はそれを自動実行せず、例外を発生させます。 -- ツール検索のアクティビティは、[`RunResult.new_items`](results.md#new-items) および [`RunItemStreamEvent`](streaming.md#run-item-event-names) に、専用の項目タイプとイベントタイプとして表示されます。 +- 遅延読み込みする関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成では、モデルが必要に応じて適切なグループを読み込めるように、`ToolSearchTool()` も使用できます。 +- `tool_namespace()` は、`FunctionTool` インスタンスを共通の名前空間名と説明の下にグループ化します。通常、`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に最適です。 +- OpenAI の公式ベストプラクティスは、[可能な場合は名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことです。 +- 可能な場合は、個別に遅延される多数の関数よりも、名前空間またはホストされた MCP サーバーを優先してください。通常、モデルにとってより優れた高レベルの検索対象となり、トークンもより節約できます。 +- 名前空間には、即時利用可能なツールと遅延ツールを混在させられます。`defer_loading=True` のないツールは引き続き即座に呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 +- 目安として、各名前空間は十分に小さく保ち、理想的には関数を 10 個未満にしてください。 +- 名前付きの `tool_choice` では、単独の名前空間名や遅延専用ツールを対象にできません。`auto`、`required`、または実際に呼び出し可能なトップレベルのツール名を優先してください。 +- `ToolSearchTool(execution="client")` は、手動の Responses オーケストレーション用です。モデルがクライアント実行型の `tool_search_call` を出力すると、標準の `Runner` は代わりに実行せず、例外を発生させます。 +- ツール検索のアクティビティは、専用の項目タイプとイベントタイプにより、[`RunResult.new_items`](results.md#new-items) および [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 - 名前空間による読み込みとトップレベルの遅延ツールの両方を扱う、完全に実行可能なコード例については、`examples/tools/tool_search.py` を参照してください。 -- 公式プラットフォームガイド:[ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 +- 公式プラットフォームガイド: [ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 ### プログラムによるツール呼び出し -プログラムによるツール呼び出しを使用すると、対応する OpenAI Responses モデルが JavaScript を生成し、対象ツールを呼び出して出力を結合し、1 つの結果をモデルに返せます。各ツール呼び出しの後にモデルとのラウンドトリップを行うことなく、ループ、分岐、並列呼び出し、中間計算を活用できる、範囲の明確なワークフローに役立ちます。 +プログラムによるツール呼び出しを使用すると、対応する OpenAI Responses モデルが JavaScript を生成し、対象ツールを呼び出して、その出力を組み合わせ、1 つの結果をモデルに返せます。ツール呼び出しのたびにモデルとのラウンドトリップを行わず、ループ、分岐、並列呼び出し、中間計算を活用できる範囲限定のワークフローに役立ちます。 -生成されたプログラムは、新しいホスト型 V8 環境で実行されます。Node.js API、ファイルシステムやネットワークへのアクセス、永続プロセスは利用できません。プログラムが操作できるのは、明示的に許可したツールのみです。 +生成されたプログラムは、新しいホスト済み V8 環境で実行されます。Node.js API、ファイルシステム、ネットワークへのアクセス、永続プロセスは利用できません。プログラムが操作できるのは、明示的に許可したツールだけです。 ```python from pydantic import BaseModel @@ -165,24 +165,24 @@ result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it. print(result.final_output) ``` -留意事項: +留意事項: -- プログラムによるツール呼び出しは、対応する OpenAI Responses モデルでのみ使用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 -- エージェントに追加できる `ProgrammaticToolCallingTool()` は最大 1 つです。また、エージェントは、プログラムから呼び出し可能なツールを少なくとも 1 つ、名前空間、遅延関数、遅延されたホスト型 MCP サーバーを基盤とする `ToolSearchTool()`、またはプロンプトで管理される不透明なツール群のいずれかを公開する必要があります。検索可能な対象がない単独の `ToolSearchTool()` は拒否されます。 -- `allowed_callers` は、ツールをどのように呼び出せるかを制御します。省略した場合、モデルからの直接呼び出しのみが許可されます。プログラムからのみアクセス可能にするには `["programmatic"]` を使用し、両方を許可するには `["direct", "programmatic"]` を使用します。 -- オプトインできる SDK ツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数、カスタム、シェル、パッチ適用ツールでは、`allowed_callers` を直接公開します。ホスト型 MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定します。 -- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、dataclass などの構造化された戻り値アノテーションが、自動的に厳密なオブジェクト出力スキーマになり、値がプログラムに返される前に検証されます。関数に使用可能なアノテーションがない場合は `output_type=...` を使用します。厳密なオブジェクトスキーマがすでにある場合は、低水準のエスケープハッチである `output_json_schema={...}` を使用します。`output_type` と `output_json_schema` は同時に指定できません。単純な `str`、`Any`、`None` の戻り値には型が付きません。スキーマに基づくプログラム所有の呼び出しでは、自由形式のテキストが出力スキーマを満たさないため、デフォルトの失敗フォーマッターは無効になります。そのため、スキーマに準拠する JSON を返すカスタム `failure_error_function` を指定しない限り、ハンドラーの例外は伝播します。 -- プログラム所有の SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入出力ガードレール、フック、タイムアウト、同時実行制限、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 -- `ProgrammaticToolCallingTool()` が存在する場合、プログラムが実行される前であっても、モデルリクエストの再試行にはより厳格なリプレイ安全性の境界が適用されます。SDK は、これらのリクエストについて、プロバイダー管理の再試行と WebSocket のイベント前再試行を無効にします。Runner の再試行ポリシーは、プロバイダーの助言でリプレイが安全であると明示された場合にのみ再試行します。`retry_policies.network_error()` だけでは、この境界を上書きできません。 -- 承認が重要なツールや影響の大きいツールは通常、より大きなプログラムの一部になる前に各アクションを人が確認できるよう、直接呼び出しとして維持することを推奨します。プログラム所有の呼び出しが承認待ちで一時停止した場合は、通常どおり `RunState` を通じて中断を解決し、元の実行を再開します。 -- プログラムによるツール呼び出しは、[ホスト型ツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムから遅延ツールを呼び出すには、モデルが先にそのツールを読み込む必要があります。 -- `program` 項目と、その通常のプログラム所有の子ツール呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。一方、ホスト型 MCP の承認リクエストとツールカタログでは、専用の MCP 項目とストリームイベントが使用されます。確認方法の詳細については、[実行結果](results.md#new-items)と[ストリーミング](streaming.md#run-item-event-names)を参照してください。 -- 並行処理を行う在庫計画の完全なコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 -- 公式プラットフォームガイド:[プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 +- プログラムによるツール呼び出しは、対応する OpenAI Responses モデルでのみ利用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 +- エージェントには `ProgrammaticToolCallingTool()` を最大 1 つ追加できます。また、エージェントは、プログラムから呼び出し可能なツール、名前空間、遅延関数、遅延されたホスト済み MCP サーバーを基盤とする `ToolSearchTool()`、または不透明なプロンプト管理型ツールセットのうち、少なくとも 1 つを公開する必要があります。検索可能な対象がない単独の `ToolSearchTool()` は拒否されます。 +- `allowed_callers` は、ツールを呼び出す方法を制御します。省略すると、モデルによる直接呼び出しのみが許可されます。プログラムからのみアクセス可能にするには `["programmatic"]`、両方を許可するには `["direct", "programmatic"]` を使用してください。 +- オプトインできる SDK ツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数、カスタム、シェル、パッチ適用の各ツールは、`allowed_callers` を直接公開します。ホストされた MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定してください。 +- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、dataclass などの構造化された戻り値アノテーションが、自動的に厳格なオブジェクト出力スキーマになります。返された値は、プログラムに返される前にそのスキーマに対して検証されます。関数に使用可能なアノテーションがない場合は `output_type=...` を使用し、厳格なオブジェクトスキーマがすでにある場合は、低レベルのエスケープハッチである `output_json_schema={...}` を使用してください。`output_type` と `output_json_schema` は相互排他的です。`str`、`Any`、`None` の戻り値アノテーションでは、出力スキーマは作成されません。スキーマを基盤とするプログラム所有の呼び出しでは、自由形式のテキストが出力スキーマを満たさないため、デフォルトの失敗フォーマッターは無効になります。そのため、スキーマに準拠した JSON を返すカスタム `failure_error_function` を指定しない限り、ハンドラーの例外は伝播します。 +- プログラム所有の SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入力および出力ガードレール、フック、タイムアウト、同時実行数の制限、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 +- `ProgrammaticToolCallingTool()` が存在する場合、プログラムが実行される前でも、モデルリクエストの再試行にはより厳格なリプレイ安全性の境界が使用されます。SDK は、これらのリクエストに対してプロバイダー管理の再試行と WebSocket のイベント前再試行を無効にします。Runner の再試行ポリシーは、プロバイダーの通知でリプレイが安全であると明示された場合にのみ再試行します。`retry_policies.network_error()` だけでは、この境界を上書きしません。 +- 承認が重要なツールや影響の大きいツールは、通常、直接呼び出しとして維持する方が適しています。これにより、大きなプログラムの一部になる前に、各アクションを人が確認できます。プログラム所有の呼び出しが承認待ちで一時停止した場合は、`RunState` を通じて中断を解決し、通常どおり元の実行を再開してください。 +- プログラムによるツール呼び出しは、[ホストされたツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムが遅延ツールを呼び出す前に、モデルがそれらを読み込む必要があります。 +- `program` 項目と、その通常のプログラム所有の子ツール呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。ホストされた MCP の承認リクエストとツールカタログでは、代わりに専用の MCP 項目とストリームイベントが使用されます。確認方法の詳細については、[実行結果](results.md#new-items)および[ストリーミング](streaming.md#run-item-event-names)を参照してください。 +- 完全な並行在庫計画のコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 +- 公式プラットフォームガイド: [プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 -### ホスト型コンテナシェルとスキル +### ホストされたコンテナシェルとスキル -`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートします。ローカルランタイムではなく、管理されたコンテナでモデルにシェルコマンドを実行させたい場合は、このモードを使用します。 +`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートします。ローカルランタイムではなく、管理されたコンテナでモデルにシェルコマンドを実行させたい場合は、このモードを使用してください。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -215,54 +215,54 @@ result = await Runner.run( print(result.final_output) ``` -既存のコンテナを後続の実行で再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 +後続の実行で既存のコンテナを再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 -留意事項: +留意事項: -- ホスト型シェルは、Responses API のシェルツールを通じて利用できます。 +- ホストされたシェルは、Responses API のシェルツールを通じて利用できます。 - `container_auto` はリクエスト用のコンテナをプロビジョニングし、`container_reference` は既存のコンテナを再利用します。 - `container_auto` には、`file_ids` と `memory_limit` も含められます。 - `environment.skills` は、スキル参照とインラインスキルバンドルを受け付けます。 -- ホスト型環境では、`ShellTool` に `executor`、`needs_approval`、`on_approval` を設定しないでください。 +- ホストされた環境では、`ShellTool` に `executor`、`needs_approval`、`on_approval` を設定しないでください。 - `network_policy` は、`disabled` モードと `allowlist` モードをサポートします。 -- allowlist モードでは、`network_policy.domain_secrets` により、ドメイン単位のシークレットを名前で注入できます。 +- 許可リストモードでは、`network_policy.domain_secrets` がドメインスコープのシークレットを名前で注入できます。 - 完全なコード例については、`examples/tools/container_shell_skill_reference.py` と `examples/tools/container_shell_inline_skill.py` を参照してください。 -- OpenAI プラットフォームガイド:[シェル](https://platform.openai.com/docs/guides/tools-shell)および[スキル](https://platform.openai.com/docs/guides/tools-skills)。 +- OpenAI プラットフォームガイド: [シェル](https://platform.openai.com/docs/guides/tools-shell)と[スキル](https://platform.openai.com/docs/guides/tools-skills)。 ## ローカルランタイムツール -ローカルランタイムツールは、モデルレスポンス自体の外部で実行されます。呼び出すタイミングは引き続きモデルが決定しますが、実際の処理はアプリケーションまたは設定済みの実行環境が行います。 +ローカルランタイムツールは、モデルのレスポンス自体の外部で実行されます。モデルが呼び出すタイミングを決定する点は変わりませんが、実際の処理はアプリケーションまたは設定された実行環境が行います。 -`ComputerTool` と `ApplyPatchTool` には、常に利用者が提供するローカル実装が必要です。`ShellTool` は両方のモードに対応しています。管理された実行が必要な場合は前述のホスト型コンテナ設定を使用し、独自のプロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用します。 +`ComputerTool` と `ApplyPatchTool` には、常にお客様が提供するローカル実装が必要です。`ShellTool` は両方のモードに対応します。管理された実行を使用する場合は上記のホスト済みコンテナ設定を使用し、独自プロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用してください。 -ローカルランタイムツールでは、次の実装を提供する必要があります。 +ローカルランタイムツールでは、実装を提供する必要があります。 -- [`ComputerTool`][agents.tool.ComputerTool]:GUI/ブラウザーの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 -- [`ShellTool`][agents.tool.ShellTool]:ローカル実行とホスト型コンテナ実行の両方に対応する最新のシェルツールです。 -- [`LocalShellTool`][agents.tool.LocalShellTool]:従来のローカルシェル統合です。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:差分をローカルで適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 +- [`ComputerTool`][agents.tool.ComputerTool]: GUI/ブラウザの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 +- [`ShellTool`][agents.tool.ShellTool]: ローカル実行とホストされたコンテナ実行の両方に対応する最新のシェルツールです。 +- [`LocalShellTool`][agents.tool.LocalShellTool]: 従来のローカルシェル統合です。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 差分をローカルで適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 - ローカルシェルスキルは、`ShellTool(environment={"type": "local", "skills": [...]})` で利用できます。 -シェルアクションのタイムアウトでは、有限のタイムアウトとして正の整数のミリ秒値を使用します。ゼロは executor 実装間で共通の意味を持たないため、ローカルの `ShellTool` executor を呼び出す前に、SDK は `0` と `None` の両方を明示的なタイムアウトなしとして扱います。それ以外の値は、executor の呼び出し前に拒否されます。これはタイムアウトフィールドに固有の動作です。`max_output_length=0` は、取得する出力を空にするリクエストとして引き続きサポートされます。 +有限のシェルアクションタイムアウトには、正の整数のミリ秒値を使用します。0 は実行プログラムの実装間で共通の意味を持たないため、SDK はローカルの `ShellTool` 実行プログラムを呼び出す前に、`0` と `None` の両方を明示的なタイムアウトなしとして扱います。その他の値は、実行プログラムの呼び出し前に拒否されます。これはタイムアウトフィールドに固有の動作です。キャプチャされる出力を空にするリクエストとして、`max_output_length=0` は引き続きサポートされます。 ### ComputerTool と Responses のコンピュータツール -`ComputerTool` は引き続きローカルハーネスです。利用者が [`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供すると、SDK がそのハーネスを OpenAI Responses API のコンピュータ機能にマッピングします。 +`ComputerTool` は引き続きローカルハーネスです。[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供すると、SDK がそのハーネスを OpenAI Responses API のコンピュータ操作インターフェースにマッピングします。 -明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストの場合、SDK は GA の組み込みツールペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルでは、プレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` が引き続き使用されます。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)に記載されているプラットフォーム移行を反映しています。 +明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストでは、SDK は GA 版の組み込みツールペイロード `{"type": "computer"}` を送信します。旧モデル `computer-use-preview` へのリクエストでは、SDK は引き続きプレビュー版ペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` を送信します。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)で説明されているプラットフォーム移行を反映しています。 -- モデル:`computer-use-preview` -> `gpt-5.5` -- ツールセレクター:`computer_use_preview` -> `computer` -- コンピュータ呼び出しの形式:`computer_call` ごとに 1 つの `action` -> `computer_call` 上の一括 `actions[]` -- 切り詰め:プレビューパスでは `ModelSettings(truncation="auto")` が必須 -> GA パスでは不要 +- モデル: `computer-use-preview` -> `gpt-5.5` +- ツールセレクター: `computer_use_preview` -> `computer` +- コンピュータ呼び出し形式: `computer_call` ごとに 1 つの `action` -> `computer_call` 上のバッチ化された `actions[]` +- 切り詰め: プレビューパスでは `ModelSettings(truncation="auto")` が必須 -> GA パスでは不要 -SDK は、実際の Responses リクエストで有効なモデルに基づいて、そのワイヤー形式を選択します。プロンプトテンプレートを使用し、モデルがプロンプト側で指定されているためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示的に維持するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 +SDK は、実際の Responses リクエストで有効なモデルに基づいて、このワイヤー形式を選択します。プロンプトテンプレートを使用しており、モデルがプロンプト側で管理されるためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示的に維持するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 -[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに対応する組み込みセレクターへ正規化されます。`ComputerTool` がない場合、これらの文字列は引き続き通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに一致する組み込みセレクターに正規化されます。`ComputerTool` がない場合、これらの文字列は引き続き通常の関数名として動作します。 -この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを基盤としている場合に重要です。GA の `computer` ペイロードでは、シリアライズ時に `environment` や寸法が不要なため、未解決のファクトリーでも問題ありません。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるよう、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 +この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリを基盤としている場合に重要です。GA 版の `computer` ペイロードでは、シリアライズ時に `environment` や寸法が不要なため、ファクトリが `Computer` または `AsyncComputer` インスタンスを生成する前にシリアライズできます。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるように、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 -ランタイムでは、どちらのパスも同じローカルハーネスを使用します。プレビューレスポンスは、単一の `action` を持つ `computer_call` 項目を生成します。`gpt-5.5` は一括 `actions[]` を生成でき、SDK は `computer_call_output` のスクリーンショット項目を生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 +ランタイムでは、どちらのパスも同じローカルハーネスを使用します。プレビュー版のレスポンスは、単一の `action` を持つ `computer_call` 項目を出力します。`gpt-5.5` はバッチ化された `actions[]` を出力でき、SDK は `computer_call_output` スクリーンショット項目を生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -309,15 +309,15 @@ agent = Agent( 任意の Python 関数をツールとして使用できます。Agents SDK がツールを自動的に設定します。 - ツール名には Python 関数の名前が使用されます(名前を指定することもできます) -- ツールの説明は関数の docstring から取得されます(説明を指定することもできます) +- ツールの説明は、関数の docstring から取得されます(説明を指定することもできます) - 関数入力のスキーマは、関数の引数から自動的に作成されます - 無効にしない限り、各入力の説明は関数の docstring から取得されます -`@tool` で作成されたツールは、読み取り専用の `__wrapped__` 属性を通じて、元の Python callable を公開します。これは調査やテストに役立ちますが、直接呼び出すと、スキーマ検証、コンテキスト注入、ガードレール、タイムアウト、失敗処理、トレーシングを含むツールランタイムパイプラインを迂回します。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 +`@tool` で作成されたツールは、読み取り専用の `__wrapped__` 属性を通じて、元の Python 呼び出し可能オブジェクトを公開します。これは検査やテストに役立ちますが、直接呼び出すと、スキーマ検証、コンテキスト注入、ガードレール、タイムアウト、失敗処理、トレーシングなどのツールランタイムパイプラインがバイパスされます。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 -Python の `inspect` モジュールを使用して関数シグネチャを抽出し、[`griffe`](https://mkdocstrings.github.io/griffe/) で docstring を解析し、`pydantic` でスキーマを作成します。 +関数シグネチャの抽出には Python の `inspect` モジュールを使用し、docstring の解析には [`griffe`](https://mkdocstrings.github.io/griffe/)、スキーマの作成には `pydantic` を使用します。 -OpenAI Responses モデルを使用する場合、`@function_tool(defer_loading=True)` は、`ToolSearchTool()` が読み込むまで関数ツールを非表示にします。また、[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 +OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は `ToolSearchTool()` によって読み込まれるまで関数ツールを非表示にします。また、[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホストされたツール検索](#hosted-tool-search)を参照してください。 ```python import json @@ -370,10 +370,10 @@ for tool in agent.tools: ``` -1. 関数の引数には任意の Python 型を使用でき、関数は同期または非同期にできます。 -2. docstring が存在する場合は、説明と引数の説明を取得するために使用されます。 -3. 関数は任意で `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring スタイルなどのオーバーライドも設定できます。 -4. デコレーターを適用した関数をツールのリストに渡せます。 +1. 関数の引数には任意の Python 型を使用でき、関数は同期でも非同期でも構いません。 +2. docstring がある場合は、説明と引数の説明を取得するために使用されます。 +3. 関数は、オプションで実行コンテキストを最初の引数として受け取れます。また、ツール名、説明、使用する docstring スタイルなどを上書き設定できます。 +4. デコレートした関数をツールのリストに渡せます。 ??? note "出力を表示するには展開してください" @@ -447,11 +447,11 @@ for tool in agent.tools: ### 関数ツールからの画像またはファイルの返却 -テキスト出力に加えて、1 つ以上の画像やファイルを関数ツールの出力として返せます。そのためには、次のいずれかを返します。 +テキスト出力に加えて、関数ツールの出力として 1 つ以上の画像またはファイルを返せます。そのためには、次のいずれかを返します。 -- 画像:[`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) -- ファイル:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- テキスト:文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- 画像: [`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) +- ファイル: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) +- テキスト: 文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### カスタム関数ツール @@ -459,8 +459,8 @@ Python 関数をツールとして使用したくない場合もあります。 - `name` - `description` -- `params_json_schema`:引数の JSON スキーマ -- `on_invoke_tool`:[`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数。 +- 引数の JSON スキーマである `params_json_schema` +- [`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化ツール出力オブジェクト、出力のリストなど)を返す非同期関数である `on_invoke_tool` ```python from typing import Any @@ -495,16 +495,16 @@ tool = FunctionTool( ### 引数と docstring の自動解析 -前述のように、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと個々の引数の説明を抽出します。留意点は次のとおりです。 +前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと個々の引数の説明を抽出します。これについて、いくつか留意点があります。 -1. シグネチャの解析は `inspect` モジュールを使用して行われます。型アノテーションを使用して引数の型を把握し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートします。 -2. docstring の解析には `griffe` を使用します。サポートされる docstring 形式は、`google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートです。`function_tool` を呼び出す際に明示的に設定することもできます。また、`use_docstring_info` を `False` に設定すると、docstring の解析を無効にできます。Google スタイルの docstring では、要約テキストの直後に空行を挟まず配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーが受け付けます。 +1. シグネチャの解析は、`inspect` モジュールを介して行われます。型アノテーションを使用して引数の型を理解し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートします。 +2. docstring の解析には `griffe` を使用します。サポートされる docstring 形式は、`google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートです。`function_tool` の呼び出し時に明示的に設定することもできます。また、`use_docstring_info` を `False` に設定すると、docstring の解析を無効にできます。Google スタイルの docstring では、概要テキストの直後に空行を挟まず配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーで受け付けられます。 スキーマ抽出のコードは、[`agents.function_schema`][] にあります。 ### Pydantic Field による引数の制約と説明 -Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用して、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値ベース(`arg: int = Field(..., ge=1)`)と `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)の両方の形式がサポートされます。生成される JSON スキーマと検証には、これらの制約が含まれます。 +Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用すると、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値ベースの形式(`arg: int = Field(..., ge=1)`)と `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)の両方がサポートされます。生成される JSON スキーマと検証には、これらの制約が含まれます。 ```python from typing import Annotated @@ -524,7 +524,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr ### 関数ツールのタイムアウト -`@function_tool(timeout=...)` を使用して、非同期関数ツールに呼び出し単位のタイムアウトを設定できます。 +`@function_tool(timeout=...)` を使用すると、非同期関数ツールに呼び出し単位のタイムアウトを設定できます。 ```python import asyncio @@ -545,13 +545,13 @@ agent = Agent( ) ``` -タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから確認できるタイムアウトメッセージ(例:`Tool 'slow_lookup' timed out after 2 seconds.`)が送信されます。 +タイムアウトに達した場合のデフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから見えるタイムアウトメッセージ(例: `Tool 'slow_lookup' timed out after 2 seconds.`)を送信します。 タイムアウト処理は次のように制御できます。 -- `timeout_behavior="error_as_result"`(デフォルト):モデルが回復できるように、タイムアウトメッセージをモデルへ返します。 -- `timeout_behavior="raise_exception"`:[`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 -- `timeout_error_function=...`:`error_as_result` を使用する場合のタイムアウトメッセージをカスタマイズします。 +- `timeout_behavior="error_as_result"`(デフォルト): モデルが復旧できるように、タイムアウトメッセージをモデルへ返します。 +- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 +- `timeout_error_function=...`: `error_as_result` を使用する場合のタイムアウトメッセージをカスタマイズします。 ```python import asyncio @@ -579,11 +579,11 @@ except ToolTimeoutError as e: ### 関数ツールのエラー処理 -`@function_tool` を使用して関数ツールを作成する場合、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 +`@function_tool` を介して関数ツールを作成する場合、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 -- デフォルトでは(つまり何も渡さない場合)、エラーが発生したことを LLM に通知する `default_tool_error_function` が実行されます。 +- デフォルトでは(何も渡さなかった場合)、エラーが発生したことを LLM に通知する `default_tool_error_function` が実行されます。 - 独自のエラー関数を渡した場合は、代わりにその関数が実行され、レスポンスが LLM に送信されます。 -- 明示的に `None` を渡すと、ツール呼び出しのエラーが再送出され、利用者側で処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` になるなど、状況によって異なります。 +- `None` を明示的に渡すと、ツール呼び出しのエラーが再度発生し、独自に処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` などが発生する可能性があります。 ```python from agents import RunContextWrapper @@ -611,7 +611,7 @@ def get_user_profile(user_id: str) -> str: ## Agents as tools -一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントから特化したエージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 +ワークフローによっては、制御をハンドオフするのではなく、中央のエージェントで専門エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 ```python import asyncio @@ -657,9 +657,9 @@ if __name__ == "__main__": ### ツールエージェントのカスタマイズ -`agent.as_tool` 関数は、エージェントを簡単にツールへ変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` を使用した構造化入力もサポートします。 +`agent.as_tool` は、エージェントをツールに変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` による構造化入力もサポートします。 -状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親の実行の会話状態は自動的には継承されません。クライアント管理の履歴を親とネストされた実行との間で共有するには、両方に同じ `session` を明示的に渡します。`Runner.run` と同様に、ネストされた実行では、クライアント管理の `session`、または `previous_response_id` か `conversation_id` を介したサーバー管理の継続のいずれか 1 つの状態戦略を選択してください。 +状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は自動的には継承されません。クライアント管理の履歴を親実行とネストされた実行の間で共有するには、同じ `session` を両方に明示的に渡してください。`Runner.run` と同様に、ネストされた実行には、クライアント管理の `session`、または `previous_response_id` か `conversation_id` によるサーバー管理の継続のいずれか 1 つの状態戦略を選択してください。 ```python from agents.decorators import tool @@ -683,13 +683,13 @@ async def run_my_agent() -> str: ### ツールエージェントの構造化入力 -デフォルトでは、`Agent.as_tool()` は単一の文字列入力(`{"input": "..."}`)を想定しますが、`parameters`(Pydantic モデルまたは dataclass 型)を渡すことで構造化スキーマを公開できます。 +デフォルトでは、`Agent.as_tool()` は文字列フィールド `input`(`{"input": "..."}`)を 1 つ持つオブジェクトを想定しますが、`parameters`(Pydantic モデル型または dataclass 型)を渡すことで、構造化スキーマを公開できます。 -追加オプション: +追加オプション: -- `include_input_schema=True` を指定すると、生成されるネストされた入力に完全な JSON Schema が含まれます。 -- `input_builder=...` を使用すると、構造化されたツール引数をネストされたエージェント入力へ変換する方法を完全にカスタマイズできます。 -- `RunContextWrapper.tool_input` には、ネストされた実行コンテキスト内で解析済みの構造化ペイロードが含まれます。 +- `include_input_schema=True` は、生成されるネストされた入力に完全な JSON Schema を含めます。 +- `input_builder=...` を使用すると、構造化されたツール引数をネストされたエージェント入力に変換する方法を完全にカスタマイズできます。 +- `RunContextWrapper.tool_input` には、ネストされた実行コンテキスト内で解析された構造化ペイロードが含まれます。 ```python from pydantic import BaseModel, Field @@ -713,15 +713,15 @@ translator_tool = translator_agent.as_tool( ### ツールエージェントの承認ゲート -`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行は一時停止し、保留中の項目が `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開します。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合は実行が一時停止し、保留中の項目が `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開してください。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 ### カスタム出力抽出 -場合によっては、ツールエージェントの出力を中央のエージェントへ返す前に変更したいことがあります。これは、次のような場合に役立ちます。 +場合によっては、中央のエージェントに返す前に、ツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 -- サブエージェントのチャット履歴から特定の情報(JSON ペイロードなど)を抽出する。 -- エージェントの最終回答を変換または再フォーマットする(Markdown をプレーンテキストや CSV に変換するなど)。 -- 出力を検証する、またはエージェントのレスポンスが欠落している場合や形式が不正な場合にフォールバック値を提供する。 +- サブエージェントのチャット履歴から特定の情報(JSON ペイロードなど)を抽出する場合。 +- エージェントの最終回答を変換または再フォーマットする場合(Markdown をプレーンテキストや CSV に変換するなど)。 +- 出力を検証する場合、またはエージェントのレスポンスが欠落している、あるいは不正な形式の場合にフォールバック値を提供する場合。 これを行うには、`as_tool` メソッドに `custom_output_extractor` 引数を指定します。 @@ -742,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -カスタム抽出関数内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、または raw 引数が必要な場合に役立ちます。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 +カスタム抽出プログラム内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、raw 引数が必要な場合に役立ちます。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 ### ネストされたエージェント実行のストリーミング -`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが生成するストリーミングイベントを受け取りながら、ストリーム完了後に最終出力を返せます。 +`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが出力するストリーミングイベントをリッスンしながら、ストリームの完了後に最終出力を返せます。 ```python from agents import AgentToolStreamEvent @@ -764,15 +764,15 @@ billing_agent_tool = billing_agent.as_tool( ) ``` -想定される動作: +想定される動作: -- イベントタイプは `StreamEvent["type"]` を反映します:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 +- イベントタイプは、`StreamEvent["type"]` と同様に `raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event` です。 - `on_stream` を指定すると、ネストされたエージェントが自動的にストリーミングモードで実行され、最終出力を返す前にストリームが最後まで処理されます。 -- ハンドラーは同期または非同期にできます。各イベントは到着順に配信されます。 -- モデルのツール呼び出しを介してツールが呼び出された場合は `tool_call` が存在します。直接呼び出しの場合は `None` になることがあります。 -- 完全に実行可能なコード例については、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 +- ハンドラーは同期でも非同期でも構いません。各イベントは到着順に配信されます。 +- モデルのツール呼び出しを介してツールが呼び出された場合、`tool_call` が存在します。直接呼び出した場合は、`None` のままになる可能性があります。 +- 完全に実行可能なサンプルについては、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 -### 条件付きのツール有効化 +### 条件付きツール有効化 `is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的に絞り込めます。 @@ -829,24 +829,24 @@ async def main(): asyncio.run(main()) ``` -`is_enabled` パラメーターは次を受け付けます。 +`is_enabled` パラメーターは、次を受け付けます。 -- **ブール値**:`True`(常に有効)または `False`(常に無効) -- **呼び出し可能な関数**:`(context, agent)` を受け取り、ブール値を返す関数 -- **非同期関数**:複雑な条件ロジックのための非同期関数 +- **ブール値**: `True`(常に有効)または `False`(常に無効) +- **呼び出し可能な関数**: `(context, agent)` を受け取り、ブール値を返す関数 +- **非同期関数**: 複雑な条件ロジックに使用する非同期関数 -無効化されたツールはランタイムで LLM から完全に非表示になるため、次の用途に役立ちます。 +無効なツールはランタイムで LLM から完全に隠されるため、次の用途に役立ちます。 -- ユーザー権限に基づく機能ゲーティング +- ユーザー権限に基づく機能制限 - 環境固有のツール可用性(開発環境と本番環境) - 異なるツール設定の A/B テスト -- ランタイム状態に基づく動的なツールフィルタリング +- ランタイム状態に基づく動的なツール絞り込み -## 実験的機能:Codex ツール +## 実験的機能: Codex ツール -`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペース単位のタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。この機能は実験的であり、今後変更される可能性があります。 +`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペーススコープのタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。このインターフェースは実験的機能であり、変更される可能性があります。 -メインエージェントが現在の実行を離れることなく、範囲の明確なワークスペースタスクを Codex に委任する場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合、`codex` または `codex_` で始まる名前にする必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 +現在の実行を離れずに、メインエージェントから Codex へ範囲限定のワークスペースタスクを委任したい場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合は、`codex` であるか、`codex_` で始まる必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 ```python from agents import Agent @@ -877,31 +877,31 @@ agent = Agent( まず、次のオプショングループを確認してください。 -- 実行対象:`sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定します。 -- スレッドのデフォルト:`default_thread_options=ThreadOptions(...)` は、モデル、推論エフォート、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 -- ターンのデフォルト:`default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` や任意のキャンセル用 `signal` など、ターン単位の動作を設定します。 -- ツール I/O:ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` 項目を少なくとも 1 つ含める必要があります。`output_schema` を使用すると、Codex の構造化されたレスポンスを必須にできます。 +- 実行対象: `sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらを組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定してください。 +- スレッドのデフォルト設定: `default_thread_options=ThreadOptions(...)` は、モデル、推論の労力、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 +- ターンのデフォルト設定: `default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` やオプションのキャンセル用 `signal` など、ターン単位の動作を設定します。 +- ツール I/O: ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` 項目を少なくとも 1 つ含める必要があります。`output_schema` を使用すると、構造化された Codex レスポンスを必須にできます。 -スレッドの再利用と永続化は別々に制御されます。 +スレッドの再利用と永続化は、個別の制御です。 - `persist_session=True` は、同じツールインスタンスへの反復呼び出しで 1 つの Codex スレッドを再利用します。 -- `use_run_context_thread_id=True` は、同じ変更可能なコンテキストオブジェクトを共有する複数の実行にわたり、実行コンテキスト内でスレッド ID を保存して再利用します。 -- スレッド ID の優先順位は、呼び出し単位の `thread_id`、実行コンテキストのスレッド ID(有効な場合)、設定済みの `thread_id` オプションの順です。 -- デフォルトの実行コンテキストキーは、`name="codex"` の場合は `codex_thread_id`、`name="codex_"` の場合は `codex_thread_id_` です。`run_context_thread_id_key` を使用して上書きできます。 +- `use_run_context_thread_id=True` は、同じ可変コンテキストオブジェクトを共有する複数の実行間で、スレッド ID を実行コンテキストに保存して再利用します。 +- スレッド ID の優先順位は、呼び出し単位の `thread_id`、実行コンテキストのスレッド ID(有効な場合)、設定された `thread_id` オプションの順です。 +- デフォルトの実行コンテキストキーは、`name="codex"` では `codex_thread_id`、`name="codex_"` では `codex_thread_id_` です。`run_context_thread_id_key` で上書きできます。 -ランタイム設定: +ランタイム設定: -- 認証:`CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 -- ランタイム:`codex_options.base_url` は CLI のベース URL を上書きします。 -- バイナリの解決:CLI のパスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、見つからなければ同梱のベンダーバイナリを使用します。 -- 環境:`codex_options.env` はサブプロセス環境を完全に制御します。指定した場合、サブプロセスは `os.environ` を継承しません。 -- ストリーム制限:`codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの上限を制御します。有効範囲は `65536`~`67108864` で、デフォルトは `8388608` です。 -- ストリーミング:`on_stream` は、スレッド/ターンのライフサイクルイベントと項目イベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` の項目更新)を受け取ります。 -- 出力:実行結果には `response`、`usage`、`thread_id` が含まれます。使用量は `RunContextWrapper.usage` に追加されます。 +- 認証: `CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 +- ランタイム: `codex_options.base_url` は、CLI のベース URL を上書きします。 +- バイナリ解決: CLI パスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、解決できなければバンドルされているベンダーバイナリにフォールバックします。 +- 環境: `codex_options.env` は、サブプロセス環境を完全に制御します。これが指定されている場合、サブプロセスは `os.environ` を継承しません。 +- ストリーム制限: `codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの制限を制御します。有効範囲は `65536` から `67108864` までで、デフォルトは `8388608` です。 +- ストリーミング: `on_stream` は、スレッド/ターンのライフサイクルイベントと項目イベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` の項目更新)を受け取ります。 +- 出力: 実行結果には `response`、`usage`、`thread_id` が含まれ、使用量は `RunContextWrapper.usage` に追加されます。 -リファレンス: +リファレンス: - [Codex ツール API リファレンス](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions リファレンス](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions リファレンス](ref/extensions/experimental/codex/turn_options.md) -- 完全に実行可能なコード例については、`examples/tools/codex.py` と `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file +- 完全に実行可能なサンプルについては、`examples/tools/codex.py` と `examples/tools/codex_same_thread.py` を参照してください。 \ No newline at end of file diff --git a/docs/ja/tracing.md b/docs/ja/tracing.md index 3cc65a61e7..6e52fc63b8 100644 --- a/docs/ja/tracing.md +++ b/docs/ja/tracing.md @@ -4,51 +4,51 @@ search: --- # トレーシング -Agents SDK にはトレーシングが組み込まれており、エージェントの実行中に発生するイベント(LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベント)を包括的に記録します。[Traces ダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境および本番環境でワークフローをデバッグ、可視化、監視できます。 +Agents SDKには組み込みのトレーシングが含まれており、エージェントの実行中に発生するイベント(LLM生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベントまで)の包括的な記録を収集します。[トレースダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境と本番環境でワークフローをデバッグ、可視化、監視できます。 !!!note - トレーシングはデフォルトで有効です。一般的な次の 3 つの方法で無効にできます。 + トレーシングはデフォルトで有効です。一般的な無効化方法は次の 3 つです: - 1. 環境変数 `OPENAI_AGENTS_DISABLE_TRACING=1` を設定すると、トレーシングをグローバルに無効化できます - 2. コード内で [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用すると、トレーシングをグローバルに無効化できます - 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定すると、単一の実行についてトレーシングを無効化できます + 1. 環境変数 `OPENAI_AGENTS_DISABLE_TRACING=1` を設定して、トレーシングをグローバルに無効化できます + 2. [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用して、コード内でトレーシングをグローバルに無効化できます + 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定して、単一の実行に対するトレーシングを無効化できます -***OpenAI の API を使用し、Zero Data Retention(ZDR)ポリシーの下で運用している組織では、トレーシングを利用できません。*** +***Zero Data Retention (ZDR) ポリシーの下でOpenAIの API を使用する組織では、トレーシングを利用できません。*** ## トレースとスパン -- **トレース**は、「ワークフロー」における単一のエンドツーエンド操作を表します。トレースはスパンで構成され、次のプロパティがあります。 - - `workflow_name`: 論理的なワークフローまたはアプリです。たとえば、「コード生成」や「カスタマーサービス」です。 - - `trace_id`: トレースの一意な ID です。指定しない場合は自動生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 - - `group_id`: 同じ会話の複数のトレースを関連付けるための、オプションのグループ ID です。たとえば、チャットスレッド ID を使用できます。 +- **トレース**: 「ワークフロー」における単一のエンドツーエンド操作を表します。トレースは複数のスパンで構成されます。トレースには次のプロパティがあります: + - `workflow_name`: 論理的なワークフローまたはアプリの名前です。たとえば、「コード生成」や「カスタマーサービス」です。 + - `trace_id`: トレースの一意な ID です。指定しなかった場合は自動的に生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 + - `group_id`: 同じ会話の複数のトレースを関連付けるための任意のグループ ID です。たとえば、チャットスレッド ID を使用できます。 - `disabled`: True の場合、トレースは記録されません。 - - `metadata`: トレースのオプションのメタデータです。 -- **スパン**は、開始時刻と終了時刻を持つ操作を表します。スパンには次の情報があります。 - - `started_at` および `ended_at` のタイムスタンプ。 - - `trace_id`: スパンが属するトレースを表します + - `metadata`: トレースの任意のメタデータです。 +- **スパン**: 開始時刻と終了時刻を持つ操作を表します。スパンには次のものがあります: + - `started_at` と `ended_at` のタイムスタンプ。 + - `trace_id`: 所属するトレースを表します - `parent_id`: このスパンの親スパン(存在する場合)を指します - - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 + - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` にはLLM生成に関する情報が含まれます。 ## デフォルトのトレーシング -デフォルトでは、SDK は次の項目をトレースします。 +デフォルトでは、SDK は次の項目をトレースします: - `Runner.{run, run_sync, run_streamed}()` 全体が `trace()` でラップされます。 -- 各ランナー呼び出しが `task_span()` でラップされます。 -- 各モデルターンが `turn_span()` でラップされます。 +- Runner の各呼び出しが `task_span()` でラップされます。 +- モデルの各ターンが `turn_span()` でラップされます。 - エージェントが実行されるたびに、`agent_span()` でラップされます -- LLM 生成は `generation_span()` でラップされます +- LLM生成は `generation_span()` でラップされます - 各関数ツール呼び出しは `function_span()` でラップされます - ガードレールは `guardrail_span()` でラップされます - ハンドオフは `handoff_span()` でラップされます - 音声入力(音声テキスト変換)は `transcription_span()` でラップされます - 音声出力(テキスト音声変換)は `speech_span()` でラップされます -- 関連する音声スパンは `speech_group_span()` の配下に配置される場合があります +- SDK は、関連する音声スパンを `speech_group_span()` の子として配置する場合があります -デフォルトでは、トレース名は「Agent workflow」です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して名前やその他のプロパティを設定することもできます。 +デフォルトでは、トレース名はリテラル文字列 `Agent workflow` です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して名前やその他のプロパティを設定できます。 -よりコンパクトな階層にする場合は、その実行についてタスクスパンとターンスパンの自動作成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、カスタムの各スパンは引き続き記録されます。 +よりコンパクトな階層にする場合は、実行に対するタスクスパンとターンスパンの自動作成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、およびカスタムの各スパンは引き続き記録されます。 ```python from agents import RunConfig, Runner @@ -60,11 +60,11 @@ result = await Runner.run( ) ``` -さらに、[カスタムトレーシングプロセッサー](#custom-tracing-processors)を設定して、トレースを別の送信先に送信できます(置き換え先または追加の送信先として使用できます)。 +さらに、[カスタムトレーシングプロセッサー](#custom-tracing-processors)を設定し、別の送信先(代替またはセカンダリの送信先)へトレースを送信できます。 -## 長時間実行ワーカーと即時エクスポート +## 長時間稼働ワーカーと即時エクスポート -デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのしきい値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセス終了時には最後のフラッシュを実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後には Traces ダッシュボードに表示されない場合があります。 +デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのしきい値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセスの終了時に最終フラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなど、長時間稼働するワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 作業単位の終了時に即時配信を保証する必要がある場合は、トレースコンテキストの終了後に [`flush_traces()`][agents.tracing.flush_traces] を呼び出します。 @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンのエクスポートが完了するまでブロックします。そのため、構築途中のトレースをフラッシュしないように、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題がない場合は、この呼び出しを省略できます。 +[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンのエクスポートが完了するまでブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 ## 上位レベルのトレース -複数回の `run()` 呼び出しを単一のトレースに含めたい場合があります。その場合は、コード全体を `trace()` でラップします。 +複数の `run()` 呼び出しを 1 つのトレースに含めたい場合があります。その場合は、コード全体を `trace()` でラップします。 ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 2 回の `Runner.run` 呼び出しが `with trace()` でラップされているため、個別の実行によって 2 つのトレースが作成されるのではなく、全体のトレースに含まれます。 +1. `Runner.run` の 2 回の呼び出しが `with trace()` でラップされているため、それぞれが別個のトレースを作成するのではなく、両方の実行が 1 つの全体的なトレースに含まれます。 ## トレースの作成 -[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始して終了する必要があります。これには次の 2 つの方法があります。 +[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始して終了する必要があります。その方法は次の 2 つです: -1. **推奨**: `with trace(...) as my_trace` のように、トレースをコンテキストマネージャーとして使用します。これにより、適切なタイミングでトレースが自動的に開始および終了します。 +1. **推奨**: トレースをコンテキストマネージャーとして使用します(例:`with trace(...) as my_trace`)。これにより、適切なタイミングでトレースが自動的に開始および終了されます。 2. [`trace.start()`][agents.tracing.Trace.start] と [`trace.finish()`][agents.tracing.Trace.finish] を手動で呼び出すこともできます。 -現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始/終了する場合は、現在のトレースを更新するために、`start()`/`finish()` に `mark_as_current` と `reset_current` を渡す必要があります。 +現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始および終了する場合は、現在のトレースを更新するため、`start()` に `mark_as_current` を渡し、`finish()` に `reset_current` を渡します。 ## スパンの作成 -各種 [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を利用できます。 +さまざまな [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を使用できます。 -スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの下にネストされます。 +スパンは自動的に現在のトレースに含まれ、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの下にネストされます。 ## 機密データ -特定のスパンでは、機密性の高い可能性があるデータが取得される場合があります。 +一部のスパンでは、機密である可能性のあるデータを取得する場合があります。 -`generation_span()` は LLM 生成の入力/出力を保存し、`function_span()` は関数呼び出しの入力/出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータの取得を無効にできます。 +`generation_span()` はLLM生成の入力と出力を保存し、`function_span()` は関数呼び出しの入力と出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータの取得を無効にできます。 -同様に、音声スパンには、デフォルトで入出力音声の base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定すると、この音声データの取得を無効にできます。 +同様に、音声スパンには、デフォルトで入力音声と出力音声の base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定することで、この音声データの取得を無効にできます。 -デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定することで、コードを変更せずにデフォルト値を設定できます。 +デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定してエクスポートすると、コードを使用せずにデフォルト値を設定できます。 ## カスタムトレーシングプロセッサー -トレーシングの上位レベルのアーキテクチャは次のとおりです。 +トレーシングの上位レベルのアーキテクチャは次のとおりです: - 初期化時に、トレースの作成を担うグローバルな [`TraceProvider`][agents.tracing.provider.TraceProvider] を作成します。 -- [`TraceProvider`][agents.tracing.provider.TraceProvider] に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。このプロセッサーはトレース/スパンをバッチ単位で [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、同エクスポーターがスパンとトレースをバッチ単位で OpenAI バックエンドにエクスポートします。 +- `TraceProvider` に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。これは、トレースとスパンをバッチで [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、そこからスパンとトレースをバッチでOpenAIバックエンドへエクスポートします。 -このデフォルト設定をカスタマイズし、トレースを代替または追加のバックエンドに送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 +このデフォルト設定をカスタマイズし、代替または追加のバックエンドへトレースを送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります: -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備ができたトレースとスパンを受け取る**追加の**トレースプロセッサーを追加できます。これにより、トレースを OpenAI のバックエンドへ送信する処理に加えて、独自の処理も実行できます。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで**置き換える**ことができます。この場合、OpenAI バックエンドへ送信する `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備ができたトレースとスパンを受け取る**追加の**トレースプロセッサーを追加できます。これにより、OpenAIバックエンドへのトレース送信に加えて、独自の処理を実行できます。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで**置き換える**ことができます。その場合、トレースを送信する `TracingProcessor` を含めない限り、トレースはOpenAIバックエンドへ送信されません。 -## OpenAI 以外のモデルでのトレーシング +## OpenAI以外のモデルでのトレーシング -OpenAI 以外のモデルでも OpenAI API キーを使用すれば、トレーシングを無効にすることなく、OpenAI Traces ダッシュボードで無料のトレーシングを有効にできます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティ製アダプター](models/index.md#third-party-adapters)セクションを参照してください。 +OpenAI以外のモデルを使用する場合、トレーシングを無効にすることなく、OpenAI Traces ダッシュボードで無料のトレーシングを有効にするため、トレーシングエクスポーターにOpenAI API キーを指定できます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティアダプター](models/index.md#third-party-adapters)セクションを参照してください。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -単一の実行にのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡してください。 +単一の実行に対してのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡します。 ```python from agents import Runner, RunConfig @@ -197,21 +197,21 @@ await Runner.run( ) ``` -## 追加情報 -- OpenAI Traces ダッシュボードで無料のトレースを確認できます。 +## 補足事項 +- OpenAI Traces ダッシュボードで無料のトレースを表示できます。 -## エコシステム統合 +## エコシステム連携 -以下のコミュニティおよびベンダーの統合は、OpenAI Agents SDK のトレーシングインターフェースをサポートしています。 +以下のコミュニティおよびベンダーによる連携は、OpenAI Agents SDKのトレーシング API サーフェスをサポートしています。 ### 外部トレーシングプロセッサー一覧 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) -- [MLflow(セルフホスト/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow(Databricks ホスト)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) +- [MLflow (セルフホスト/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow (Databricks ホスト)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) diff --git a/docs/ja/usage.md b/docs/ja/usage.md index 53ef1eb4f4..987cfe0108 100644 --- a/docs/ja/usage.md +++ b/docs/ja/usage.md @@ -4,13 +4,13 @@ search: --- # 使用量 -Agents SDK は、すべての実行についてトークン使用量を自動的に追跡します。使用量には実行コンテキストからアクセスでき、コストの監視、制限の適用、分析の記録に利用できます。 +Agents SDK は、実行ごとのトークン使用量を自動的に追跡します。実行コンテキストから使用量にアクセスし、コストの監視、上限の適用、分析データの記録に利用できます。 ## 追跡対象 -- **requests**: 実行された LLM API 呼び出しの数 +- **requests**: LLM API の呼び出し回数 - **input_tokens**: 送信された入力トークンの合計 -- **output_tokens**: 受信された出力トークンの合計 +- **output_tokens**: 受信した出力トークンの合計 - **total_tokens**: 入力 + 出力 - **request_usage_entries**: リクエストごとの使用量内訳のリスト - **details**: @@ -19,7 +19,7 @@ Agents SDK は、すべての実行についてトークン使用量を自動的 ## 実行からの使用量へのアクセス -`Runner.run(...)` の後、使用量には `result.context_wrapper.usage` 経由でアクセスします。 +`Runner.run(...)` の実行後、`result.context_wrapper.usage` から使用量にアクセスできます。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -31,20 +31,20 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -使用量は、この実行中のすべてのモデル呼び出し(ツール呼び出しやハンドオフを含む)にわたって集計されます。 +使用量は、ツール呼び出しやハンドオフを生成するモデル呼び出しを含め、実行中のすべてのモデル呼び出しを通じて集計されます。 -### サードパーティ製アダプターでの使用量の有効化 +### サードパーティーアダプターでの使用量の有効化 -使用量のレポートは、サードパーティ製アダプターやプロバイダーバックエンドによって異なります。アダプター経由のモデルに依存しており、正確な `result.context_wrapper.usage` の値が必要な場合は: +使用量レポートは、サードパーティーアダプターやプロバイダーのバックエンドによって異なります。サードパーティーアダプターを介してモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、以下を確認してください。 -- `AnyLLMModel` では、上流プロバイダーが使用量を返す場合、使用量は自動的に伝播されます。ストリーミングされた Chat Completions バックエンドでは、使用量チャンクが出力される前に `ModelSettings(include_usage=True)` が必要になる場合があります。 -- `LitellmModel` では、一部のプロバイダーバックエンドはデフォルトで使用量を報告しないため、`ModelSettings(include_usage=True)` が必要になることがよくあります。 +- `AnyLLMModel` では、上流のプロバイダーが使用量を返すと、その情報が自動的に伝播されます。Chat Completions バックエンドからのレスポンスをストリーミングする場合、使用量チャンクを出力するために `ModelSettings(include_usage=True)` が必要になることがあります。 +- `LitellmModel` では、一部のプロバイダーのバックエンドがデフォルトで使用量を報告しないため、多くの場合 `ModelSettings(include_usage=True)` が必要です。 -Models ガイドの [サードパーティ製アダプター](models/index.md#third-party-adapters) セクションにあるアダプター固有の注記を確認し、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注記を確認し、デプロイ予定のプロバイダーのバックエンドで使用量レポートを検証してください。 ## リクエストごとの使用量追跡 -SDK は、各 API リクエストの使用量を `request_usage_entries` で自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウ消費量の監視に役立ちます。 +SDK は、各 API リクエストの使用量を `request_usage_entries` で自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウの消費量の監視に役立ちます。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -55,7 +55,7 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): ## セッションでの使用量へのアクセス -`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` の各呼び出しは、その特定の実行の使用量を返します。セッションはコンテキスト用に会話履歴を保持しますが、各実行の使用量は独立しています。 +`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その特定の実行の使用量が返されます。セッションはコンテキストとして会話履歴を保持しますが、各実行の使用量は独立しています。 ```python session = SQLiteSession("my_conversation") @@ -67,11 +67,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -セッションは実行間の会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しから返される使用量メトリクスは、その特定の実行のみを表します。セッションでは、以前のメッセージが各実行に入力として再投入される場合があり、これにより以降のターンにおける入力トークン数に影響します。 +セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用量メトリクスは、その実行のみを表します。セッションでは、以前のメッセージが各実行への入力として再度渡される場合があり、それによって後続ターンの入力トークン数が増加します。 ## フックでの使用量の利用 -`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、主要なライフサイクルのタイミングで使用量をログ記録できます。 +`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの主要な時点で使用量をログに記録できます。 ```python class MyHooks(RunHooks): @@ -82,9 +82,9 @@ class MyHooks(RunHooks): ## API リファレンス -詳細な API ドキュメントについては、以下を参照してください: +詳細な API ドキュメントについては、以下を参照してください。 -- [`Usage`][agents.usage.Usage] - 使用量追跡データ構造 -- [`RequestUsage`][agents.usage.RequestUsage] - リクエストごとの使用量詳細 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストからの使用量へのアクセス -- [`RunHooks`][agents.run.RunHooks] - 使用量追跡ライフサイクルへのフック \ No newline at end of file +- [`Usage`][agents.usage.Usage] - 使用量追跡のデータ構造 +- [`RequestUsage`][agents.usage.RequestUsage] - リクエストごとの使用量の詳細 +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストから使用量にアクセス +- [`RunHooks`][agents.run.RunHooks] - 使用量追跡のライフサイクルへのフック \ No newline at end of file diff --git a/docs/ja/visualization.md b/docs/ja/visualization.md index 28d86feb9b..d6e5de230d 100644 --- a/docs/ja/visualization.md +++ b/docs/ja/visualization.md @@ -4,11 +4,11 @@ search: --- # エージェントの可視化 -エージェントの可視化では、 **Graphviz** を使用してエージェントとその関係を構造化されたグラフィカルな表現として生成できます。これは、アプリケーション内でエージェント、ツール、ハンドオフがどのように相互作用するかを理解するのに役立ちます。 +エージェントの可視化では、 **Graphviz** を使用して、エージェントと、他のエージェント、ツール、MCPサーバーとの接続を構造化されたグラフィカル表現として生成できます。これは、アプリケーション内でエージェント、ツール、ハンドオフがどのように連携するかを理解するのに役立ちます。 ## インストール -オプションの `viz` 依存関係グループをインストールします: +オプションの `viz` 依存関係グループをインストールします。 ```bash pip install "openai-agents[viz]" @@ -16,12 +16,12 @@ pip install "openai-agents[viz]" ## グラフの生成 -`draw_graph` 関数を使用して、エージェントの可視化を生成できます。この関数は、次のように表現される有向グラフを作成します。 +`draw_graph` 関数を使用して、エージェントの可視化を生成できます。この関数は、次のような有向グラフを作成します。 -- **エージェント** は黄色のボックスとして表されます。 -- **MCP サーバー** は灰色のボックスとして表されます。 -- **ツール** は緑色の楕円として表されます。 -- **ハンドオフ** は、あるエージェントから別のエージェントへの有向エッジです。 +- **エージェント** は黄色のボックスで表されます。 +- **MCPサーバー** は灰色のボックスで表されます。 +- **ツール** は緑色の楕円で表されます。 +- **ハンドオフ** は、あるエージェントから別のエージェントへの有向エッジで表されます。 ### 使用例 @@ -70,36 +70,36 @@ draw_graph(triage_agent) ![エージェントグラフ](../assets/images/graph.png) -これにより、 **トリアージエージェント** の構造と、サブエージェントおよびツールへの接続を視覚的に表すグラフが生成されます。 +これにより、 **トリアージエージェント** の構造と、サブエージェントおよびツールとの接続を視覚的に表すグラフが生成されます。 -## 可視化の理解 +## 可視化の構成 -生成されたグラフには次のものが含まれます。 +生成されるグラフには、次の要素が含まれます。 -- エントリーポイントを示す **開始ノード** ( `__start__` )。 -- 黄色で塗りつぶされた **長方形** として表されるエージェント。 -- 緑色で塗りつぶされた **楕円** として表されるツール。 -- 灰色で塗りつぶされた **長方形** として表される MCP サーバー。 -- 相互作用を示す有向エッジ: - - エージェント間ハンドオフを示す **実線の矢印** 。 - - ツール呼び出しを示す **点線の矢印** 。 - - MCP サーバー呼び出しを示す **破線の矢印** 。 -- 実行が終了する場所を示す **終了ノード** ( `__end__` )。 +- エントリーポイントを示す **開始ノード** (`__start__`)。 +- 黄色で塗りつぶされた **長方形** で表されるエージェント。 +- 緑色で塗りつぶされた **楕円** で表されるツール。 +- 灰色で塗りつぶされた **長方形** で表されるMCPサーバー。 +- インタラクションを示す有向エッジ。 + - エージェント間のハンドオフを示す **実線の矢印**。 + - ツール呼び出しを示す **点線の矢印**。 + - MCPサーバー呼び出しを示す **破線の矢印**。 +- 実行が終了する場所を示す **終了ノード** (`__end__`)。 -**注:** MCP サーバーは、最近のバージョンの `agents` パッケージで描画されます( **v0.2.8** で確認済み)。可視化で MCP ボックスが表示されない場合は、最新リリースにアップグレードしてください。 +**注:** MCPサーバーは、 **v0.2.8** を含む最近のバージョンの `agents` パッケージでレンダリングされ、この動作が確認されています。可視化にMCPサーバーのボックスが表示されない場合は、最新リリースにアップグレードしてください。 ## グラフのカスタマイズ ### グラフの表示 -デフォルトでは、 `draw_graph` はグラフをインラインで表示します。グラフを別ウィンドウで表示するには、次のように記述します: +デフォルトでは、 `draw_graph` はグラフをインラインで表示します。グラフを別のウィンドウに表示するには、次のように記述します。 ```python draw_graph(triage_agent).view() ``` ### グラフの保存 -デフォルトでは、 `draw_graph` はグラフをインラインで表示します。ファイルとして保存するには、ファイル名を指定します: +デフォルトでは、 `draw_graph` はグラフをインラインで表示します。ファイルとして保存するには、ファイル名を指定します。 ```python draw_graph(triage_agent, filename="agent_graph") diff --git a/docs/ja/voice/pipeline.md b/docs/ja/voice/pipeline.md index fd675c616c..f3c079e39a 100644 --- a/docs/ja/voice/pipeline.md +++ b/docs/ja/voice/pipeline.md @@ -4,7 +4,7 @@ search: --- # パイプラインとワークフロー -[`VoicePipeline`][agents.voice.pipeline.VoicePipeline] は、エージェント型ワークフローを音声アプリに変換しやすくするクラスです。実行するワークフローを渡すと、パイプラインが入力音声の文字起こし、音声の終了検出、適切なタイミングでのワークフロー呼び出し、ワークフロー出力の音声への変換を処理します。 +[`VoicePipeline`][agents.voice.pipeline.VoicePipeline] は、エージェント型ワークフローを音声アプリに簡単に変換できるクラスです。実行するワークフローを渡すと、パイプラインが入力音声の文字起こし、音声終了の検出、適切なタイミングでのワークフローの呼び出し、ワークフロー出力の音声への変換を行います。 ```mermaid graph LR @@ -34,25 +34,25 @@ graph LR ## パイプラインの設定 -パイプラインを作成するときに、いくつかの項目を設定できます。 +パイプラインを作成するときは、次の項目を設定できます。 1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase]。新しい音声が文字起こしされるたびに実行されるコードです。 -2. 使用される [`speech-to-text`][agents.voice.model.STTModel] モデルと [`text-to-speech`][agents.voice.model.TTSModel] モデル +2. 使用する [`speech-to-text`][agents.voice.model.STTModel] および [`text-to-speech`][agents.voice.model.TTSModel] モデル 3. [`config`][agents.voice.pipeline_config.VoicePipelineConfig]。次のような項目を設定できます。 - - モデル名をモデルにマッピングできるモデルプロバイダー - - トレーシング。トレーシングを無効にするか、音声ファイルをアップロードするか、ワークフロー名、トレース ID などを含みます。 - - TTS モデルと STT モデルの設定。プロンプト、言語、使用するデータ型などです。 + - モデル名をモデルに対応付けることができるモデルプロバイダー + - トレーシングを無効にするかどうか、音声ファイルをアップロードするかどうか、ワークフロー名、トレース ID などを含むトレーシング設定 + - プロンプト、言語、使用するデータ型など、TTS および STT モデルの設定 ## パイプラインの実行 -[`run()`][agents.voice.pipeline.VoicePipeline.run] メソッドを通じてパイプラインを実行できます。このメソッドでは、音声入力を 2 つの形式で渡せます。 +[`run()`][agents.voice.pipeline.VoicePipeline.run] メソッドを使用してパイプラインを実行できます。このメソッドでは、次の 2 つの形式で音声入力を渡せます。 -1. [`AudioInput`][agents.voice.input.AudioInput] は、完全な音声入力があり、それに対する実行結果だけを生成したい場合に使用します。これは、話者が話し終えたタイミングを検出する必要がない場合に便利です。たとえば、事前に録音された音声がある場合や、ユーザーが話し終えたことが明確なプッシュ・トゥ・トークアプリの場合です。 -2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] は、ユーザーが話し終えたタイミングを検出する必要がある場合に使用します。検出された音声チャンクをプッシュでき、音声パイプラインは「アクティビティ検出」と呼ばれるプロセスを通じて、適切なタイミングでエージェントワークフローを自動的に実行します。 +1. [`AudioInput`][agents.voice.input.AudioInput] は、完全な音声入力があり、その入力に対する結果だけを生成したい場合に使用します。これは、話者が話し終えたタイミングを検出する必要がない場合に便利です。たとえば、事前に録音された音声がある場合や、ユーザーが話し終えたタイミングが明確なプッシュトゥトークアプリの場合です。 +2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] は、ユーザーが話し終えたタイミングを検出する必要がある場合に使用します。検出された音声チャンクを順次送信でき、音声パイプラインは「アクティビティ検出」と呼ばれる処理を通じて、適切なタイミングでエージェントのワークフローを自動的に実行します。 ## 実行結果 -音声パイプライン実行の実行結果は [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult] です。これは、イベントが発生したときにストリーミングできるオブジェクトです。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] には、次のような種類があります。 +音声パイプラインの実行結果は [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult] です。これは、イベントの発生時にそのイベントをストリーミングできるオブジェクトです。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] には、次のようないくつかの種類があります。 1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio]。音声チャンクを含みます。 2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle]。ターンの開始や終了などのライフサイクルイベントを通知します。 @@ -78,4 +78,4 @@ async for event in result.stream(): ### 割り込み -Agents SDK は現在、[`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 向けの組み込みの割り込み処理を提供していません。代わりに、検出された各ターンがワークフローの個別の実行をトリガーします。アプリケーション内で割り込みを処理したい場合は、[`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] イベントをリッスンできます。`turn_started` は、新しいターンが文字起こしされ、処理が開始されることを示します。`turn_ended` は、対応するターンのすべての音声が送出された後にトリガーされます。これらのイベントを使用して、モデルがターンを開始したときに話者のマイクをミュートし、そのターンに関連するすべての音声をフラッシュした後にミュートを解除できます。 \ No newline at end of file +Agents SDKには現在、[`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 用の組み込みの割り込み処理はありません。代わりに、検出された各ターンによってワークフローが個別に実行されます。アプリケーション内で割り込みを処理する場合は、[`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] イベントをリッスンできます。`turn_started` は、新しいターンが文字起こしされ、処理が開始されたことを示します。`turn_ended` は、該当するターンのすべての音声が送信された後にトリガーされます。これらのイベントを使用して、モデルがターンを開始したときに話者のマイクをミュートし、アプリケーションがそのターンに関連するすべての音声の再生を完了した後にミュートを解除できます。 \ No newline at end of file diff --git a/docs/ja/voice/quickstart.md b/docs/ja/voice/quickstart.md index 1aa7592b77..c2514e3014 100644 --- a/docs/ja/voice/quickstart.md +++ b/docs/ja/voice/quickstart.md @@ -6,19 +6,25 @@ search: ## 前提条件 -Agents SDK の基本的な[クイックスタート手順](../quickstart.md)に従い、仮想環境をセットアップしていることを確認してください。次に、SDK のオプションの音声依存パッケージをインストールします。 +Agents SDKの基本的な[クイックスタート手順](../quickstart.md)に従い、仮想環境をセットアップしていることを確認してください。次に、SDK からオプションの音声依存関係をインストールします。 ```bash pip install 'openai-agents[voice]' ``` -## 基本概念 +以下のデモコードでは、マイクとスピーカーの I/O に [`sounddevice`](https://pypi.org/project/sounddevice/) も使用します。これは `voice` extra には含まれていません。 -知っておくべき主要な概念は、3 ステップのプロセスである [`VoicePipeline`][agents.voice.pipeline.VoicePipeline] です。 +```bash +pip install sounddevice +``` + +## 概念 -1. 音声テキスト変換モデルを実行し、音声をテキストに変換します。 -2. 通常はエージェント型ワークフローであるコードを実行し、結果を生成します。 -3. テキスト音声変換モデルを実行し、結果のテキストを音声に戻します。 +理解しておくべき主な概念は [`VoicePipeline`][agents.voice.pipeline.VoicePipeline] です。これは次の 3 ステップのプロセスです。 + +1. 音声テキスト変換モデルを実行して、音声をテキストに変換します。 +2. 通常はエージェントワークフローであるコードを実行して、結果を生成します。 +3. テキスト音声変換モデルを実行して、結果のテキストを音声に戻します。 ```mermaid graph LR @@ -48,7 +54,7 @@ graph LR ## エージェント -まず、いくつかのエージェントをセットアップします。この SDK でエージェントを構築した経験があれば、馴染みのある作業でしょう。ここでは、2 つのエージェント、1 つのハンドオフ、1 つのツールを用意します。 +まず、複数のエージェントをセットアップします。この SDK でエージェントを構築したことがあれば、見慣れた内容です。2 つのエージェント、設定済みのハンドオフ、ツールを 1 つ用意します。 ```python import random @@ -58,7 +64,6 @@ from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions - @tool def get_weather(city: str) -> str: """Get the weather for a given city.""" @@ -89,7 +94,7 @@ agent = Agent( ## 音声パイプライン -ワークフローとして [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] を使用し、シンプルな音声パイプラインをセットアップします。 +ワークフローに [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] を使用して、シンプルな音声パイプラインをセットアップします。 ```python from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline @@ -189,4 +194,4 @@ if __name__ == "__main__": asyncio.run(main()) ``` -この例を実行すると、エージェントが話しかけてきます!自分でエージェントに話しかけられるデモについては、[examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static) の例をご覧ください。 \ No newline at end of file +このコード例を実行すると、エージェントが音声を生成し、実際に聞くことができます。自分でエージェントに話しかけられるデモについては、[examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static) のコード例をご覧ください。 \ No newline at end of file diff --git a/docs/ko/agents.md b/docs/ko/agents.md index 270f5504d8..2b5108adde 100644 --- a/docs/ko/agents.md +++ b/docs/ko/agents.md @@ -4,22 +4,22 @@ search: --- # 에이전트 -에이전트는 앱의 핵심 구성 요소입니다. 에이전트는 instructions, tools, 그리고 핸드오프, 가드레일, structured outputs 같은 선택적 런타임 동작으로 구성된 대규모 언어 모델(LLM)입니다. +에이전트는 앱의 핵심 구성 요소입니다. 에이전트는 지침, 도구 및 핸드오프, 가드레일, structured outputs와 같은 선택적 런타임 동작으로 구성된 대규모 언어 모델(LLM)입니다. -하나의 일반 `Agent`를 정의하거나 사용자 지정하려면 이 페이지를 사용하세요. 여러 에이전트의 협업 방식을 결정하려면 [에이전트 오케스트레이션](multi_agent.md)을 읽어보세요. 에이전트가 매니페스트에 정의된 파일과 샌드박스 네이티브 기능을 갖춘 격리된 워크스페이스에서 실행되어야 한다면 [샌드박스 에이전트 개념](sandbox/guide.md)을 읽어보세요. +`SandboxAgent`가 아닌 단일 기본 `Agent`을 정의하거나 사용자 지정하려면 이 페이지를 사용하세요. 여러 에이전트의 협업 방식을 결정하려면 [에이전트 오케스트레이션](multi_agent.md)을 읽어보세요. 에이전트가 매니페스트에 정의된 파일과 샌드박스 네이티브 기능을 갖춘 격리된 워크스페이스 내에서 실행되어야 한다면 [샌드박스 에이전트 개념](sandbox/guide.md)을 읽어보세요. -SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기서 중요한 차이는 오케스트레이션입니다. `Agent`와 `Runner`를 함께 사용하면 SDK가 턴, 도구, 가드레일, 핸드오프, 세션을 대신 관리합니다. 이 루프를 직접 제어하려면 Responses API를 직접 사용하세요. +SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기서 중요한 차이는 오케스트레이션입니다. `Agent`와 `Runner`을 사용하면 SDK가 턴, 도구, 가드레일, 핸드오프 및 세션을 대신 관리할 수 있습니다. 이 루프를 직접 관리하려면 Responses API를 직접 사용하세요. ## 다음 가이드 선택 -이 페이지를 에이전트 정의의 허브로 사용하세요. 다음으로 내려야 할 결정에 맞는 관련 가이드로 이동하세요. +이 페이지를 에이전트 정의의 중심 가이드로 활용하세요. 다음에 내려야 할 결정에 맞는 인접 가이드로 이동하세요. -| 원하는 작업 | 다음으로 읽을 문서 | +| 원하는 작업 | 다음 문서 | | --- | --- | | 모델 또는 제공자 설정 선택 | [모델](models/index.md) | | 에이전트에 기능 추가 | [도구](tools.md) | | 실제 저장소, 문서 번들 또는 격리된 워크스페이스에서 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) | -| 관리자 방식 오케스트레이션과 핸드오프 중 선택 | [에이전트 오케스트레이션](multi_agent.md) | +| 관리자 스타일 오케스트레이션과 핸드오프 중 선택 | [에이전트 오케스트레이션](multi_agent.md) | | 핸드오프 동작 구성 | [핸드오프](handoffs.md) | | 턴 실행, 이벤트 스트리밍 또는 대화 상태 관리 | [에이전트 실행](running_agents.md) | | 최종 출력, 실행 항목 또는 재개 가능한 상태 검사 | [결과](results.md) | @@ -27,26 +27,26 @@ SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기 ## 기본 구성 -에이전트에서 가장 일반적으로 사용하는 속성은 다음과 같습니다. +에이전트의 가장 일반적인 속성은 다음과 같습니다. | 속성 | 필수 여부 | 설명 | | --- | --- | --- | | `name` | 예 | 사람이 읽을 수 있는 에이전트 이름 | -| `instructions` | 아니요 | 시스템 프롬프트 또는 동적 instructions 콜백. 사용을 강력히 권장합니다. [동적 instructions](#dynamic-instructions)를 참조하세요. | -| `prompt` | 아니요 | OpenAI Responses API 프롬프트 구성. 정적 프롬프트 객체 또는 함수를 받습니다. [프롬프트 템플릿](#prompt-templates)을 참조하세요. | -| `handoff_description` | 아니요 | 이 에이전트가 핸드오프 대상으로 제공될 때 노출되는 간단한 설명 | +| `instructions` | 아니요 | 시스템 프롬프트 또는 동적 지침 콜백. 사용을 강력히 권장합니다. [동적 지침](#dynamic-instructions)을 참조하세요. | +| `prompt` | 아니요 | OpenAI Responses API 프롬프트 구성. 정적 프롬프트 객체 또는 함수를 허용합니다. [프롬프트 템플릿](#prompt-templates)을 참조하세요. | +| `handoff_description` | 아니요 | 이 에이전트가 핸드오프 대상으로 제공될 때 표시되는 간단한 설명 | | `handoffs` | 아니요 | 대화를 전문 에이전트에게 위임합니다. [핸드오프](handoffs.md)를 참조하세요. | -| `model` | 아니요 | 사용할 LLM입니다. [모델](models/index.md)을 참조하세요. | -| `model_settings` | 아니요 | `temperature`, `top_p`, `tool_choice` 같은 모델 조정 매개변수 | -| `tools` | 아니요 | 에이전트가 호출할 수 있는 도구입니다. [도구](tools.md)를 참조하세요. | -| `mcp_servers` | 아니요 | 에이전트용 MCP 기반 도구입니다. [MCP 가이드](mcp.md)를 참조하세요. | -| `mcp_config` | 아니요 | 엄격한 스키마 변환 및 MCP 실패 형식 지정 등 MCP 도구가 준비되는 방식을 세부 조정합니다. [MCP 가이드](mcp.md#agent-level-mcp-configuration)를 참조하세요. | -| `input_guardrails` | 아니요 | 이 에이전트 체인의 첫 번째 사용자 입력에 실행되는 가드레일입니다. [가드레일](guardrails.md)을 참조하세요. | -| `output_guardrails` | 아니요 | 이 에이전트의 최종 출력에 실행되는 가드레일입니다. [가드레일](guardrails.md)을 참조하세요. | -| `output_type` | 아니요 | 일반 텍스트 대신 사용할 구조화된 출력 타입입니다. [출력 타입](#output-types)을 참조하세요. | -| `hooks` | 아니요 | 에이전트 범위의 수명 주기 콜백입니다. [수명 주기 이벤트(훅)](#lifecycle-events-hooks)를 참조하세요. | +| `model` | 아니요 | 사용할 LLM. [모델](models/index.md)을 참조하세요. | +| `model_settings` | 아니요 | `temperature`, `top_p`, `tool_choice`과 같은 모델 조정 매개변수 | +| `tools` | 아니요 | 에이전트가 호출할 수 있는 도구. [도구](tools.md)를 참조하세요. | +| `mcp_servers` | 아니요 | 에이전트에 MCP 기반 도구를 제공하는 MCP 서버. [MCP 가이드](mcp.md)를 참조하세요. | +| `mcp_config` | 아니요 | 스키마를 엄격 모드로 변환하고 MCP 실패 형식을 지정하는 등 MCP 도구가 준비되는 방식을 세부 조정합니다. [MCP 가이드](mcp.md#agent-level-mcp-configuration)를 참조하세요. | +| `input_guardrails` | 아니요 | 이 에이전트 체인의 첫 번째 사용자 입력에서 실행되는 가드레일. [가드레일](guardrails.md)을 참조하세요. | +| `output_guardrails` | 아니요 | 이 에이전트의 최종 출력에서 실행되는 가드레일. [가드레일](guardrails.md)을 참조하세요. | +| `output_type` | 아니요 | 일반 텍스트 대신 사용할 구조화된 출력 타입. [출력 타입](#output-types)을 참조하세요. | +| `hooks` | 아니요 | 에이전트 범위의 수명 주기 콜백. [수명 주기 이벤트(훅)](#lifecycle-events-hooks)를 참조하세요. | | `tool_use_behavior` | 아니요 | 도구 결과를 모델로 다시 전달할지 또는 실행을 종료할지 제어합니다. [도구 사용 동작](#tool-use-behavior)을 참조하세요. | -| `reset_tool_choice` | 아니요 | 도구 사용 루프를 방지하기 위해 도구 호출 후 `tool_choice`를 재설정합니다(기본값: `True`). [도구 사용 강제](#forcing-tool-use)를 참조하세요. | +| `reset_tool_choice` | 아니요 | 도구 사용 루프를 방지하기 위해 도구 호출 후 `tool_choice`을 재설정합니다(기본값: `True`). [도구 사용 강제](#forcing-tool-use)를 참조하세요. | ```python from agents import Agent @@ -65,17 +65,17 @@ agent = Agent( ) ``` -이 섹션의 모든 내용은 `Agent`에 적용됩니다. `SandboxAgent`는 동일한 개념을 기반으로 하며, 워크스페이스 범위 실행을 위한 `default_manifest`, `base_instructions`, `capabilities`, `run_as`를 추가합니다. [샌드박스 에이전트 개념](sandbox/guide.md)을 참조하세요. +이 섹션의 모든 내용은 `Agent`에 적용됩니다. `SandboxAgent`은 동일한 개념을 기반으로 하며, 워크스페이스 범위 실행을 위한 `default_manifest`, `base_instructions`, `capabilities`, `run_as`을 추가합니다. [샌드박스 에이전트 개념](sandbox/guide.md)을 참조하세요. ## 프롬프트 템플릿 -`prompt`를 설정하여 OpenAI 플랫폼에서 생성한 프롬프트 템플릿을 참조할 수 있습니다. 이 기능은 Responses API를 사용하는 OpenAI 모델에서 작동합니다. +`prompt`을 설정하여 OpenAI 플랫폼에서 생성한 프롬프트 템플릿을 참조할 수 있습니다. 이 기능은 Responses API를 통해 OpenAI 모델에 접근할 때 작동합니다. 사용 방법은 다음과 같습니다. 1. https://platform.openai.com/playground/prompts 로 이동합니다. -2. 새 프롬프트 변수 `poem_style`을 생성합니다. -3. 다음 콘텐츠로 시스템 프롬프트를 생성합니다. +2. 새 프롬프트 변수 `poem_style`를 생성합니다. +3. 다음 내용으로 시스템 프롬프트를 생성합니다. ``` Write a poem in {{poem_style}} @@ -128,9 +128,9 @@ result = await Runner.run( ## 컨텍스트 -에이전트는 `context` 타입에 대해 제네릭입니다. 컨텍스트는 종속성 주입 도구입니다. 컨텍스트는 사용자가 생성하여 `Runner.run()`에 전달하는 객체이며, 모든 에이전트, 도구, 핸드오프 등에 전달되어 에이전트 실행에 필요한 종속성과 상태를 담는 역할을 합니다. 어떤 Python 객체든 컨텍스트로 제공할 수 있습니다. +에이전트는 `context` 타입에 대해 제네릭입니다. 컨텍스트는 종속성 주입 도구입니다. 컨텍스트는 사용자가 생성하여 `Runner.run()`에 전달하는 객체로, 모든 에이전트, 도구, 핸드오프 등에 전달되며 에이전트 실행에 필요한 종속성과 상태를 담는 컨테이너 역할을 합니다. 모든 Python 객체를 컨텍스트로 제공할 수 있습니다. -전체 `RunContextWrapper` 인터페이스, 공유 사용량 추적, 중첩된 `tool_input`, 직렬화 시 주의 사항은 [컨텍스트 가이드](context.md)를 참조하세요. +전체 `RunContextWrapper` 인터페이스, 공유 사용량 추적, 중첩된 `tool_input` 및 직렬화 시 주의 사항은 [컨텍스트 가이드](context.md)를 참조하세요. ```python from dataclasses import dataclass @@ -156,7 +156,7 @@ agent = Agent[UserContext]( ## 출력 타입 -기본적으로 에이전트는 일반 텍스트(즉, `str`) 출력을 생성합니다. 에이전트가 특정 타입의 출력을 생성하도록 하려면 `output_type` 매개변수를 사용할 수 있습니다. 일반적으로 [Pydantic](https://docs.pydantic.dev/) 객체를 사용하지만, 데이터 클래스, 목록, TypedDict 등 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)로 래핑할 수 있는 모든 타입을 지원합니다. +기본적으로 에이전트는 일반 텍스트(즉, `str`) 출력을 생성합니다. 에이전트가 특정 타입의 출력을 생성하도록 하려면 `output_type` 매개변수를 사용할 수 있습니다. 일반적으로 [Pydantic](https://docs.pydantic.dev/) 객체를 사용하지만, 데이터 클래스, 리스트, TypedDict 등 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)로 래핑할 수 있는 모든 타입을 지원합니다. ```python from pydantic import BaseModel @@ -177,20 +177,20 @@ agent = Agent( !!! note - `output_type`을 전달하면 모델이 일반적인 일반 텍스트 응답 대신 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 사용하도록 지정합니다. + `output_type`을 전달하면 모델이 일반적인 일반 텍스트 응답 대신 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 사용하도록 지정합니다. ## 다중 에이전트 시스템 설계 패턴 다중 에이전트 시스템을 설계하는 방법은 다양하지만, 일반적으로 폭넓게 적용할 수 있는 다음 두 가지 패턴이 사용됩니다. -1. 관리자(agents as tools): 중앙 관리자 또는 오케스트레이터가 전문 하위 에이전트를 도구로 호출하고 대화 제어권을 유지합니다. -2. 핸드오프: 동등한 위치의 에이전트가 대화 제어권을 전문 에이전트에게 넘깁니다. 이는 분산형 방식입니다. +1. 관리자(Agents as tools): 중앙 관리자/오케스트레이터가 전문 하위 에이전트를 도구로 호출하고 대화의 제어권을 유지합니다. +2. 핸드오프: 동등한 에이전트가 대화의 제어권을 인계받을 전문 에이전트에게 제어권을 핸드오프합니다. 이는 탈중앙화된 방식입니다. -자세한 내용은 [에이전트 구축 실무 가이드](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)를 참조하세요. +자세한 내용은 [에이전트 구축 실전 가이드](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)를 참조하세요. -### 관리자(agents as tools) +### 관리자(Agents as tools) -`customer_facing_agent`는 모든 사용자 상호작용을 처리하고 도구로 노출된 전문 하위 에이전트를 호출합니다. 자세한 내용은 [도구](tools.md#agents-as-tools) 문서를 참조하세요. +`customer_facing_agent`은 모든 사용자 상호작용을 처리하고 도구로 노출된 전문 하위 에이전트를 호출합니다. 자세한 내용은 [도구](tools.md#agents-as-tools) 문서를 참조하세요. ```python from agents import Agent @@ -219,7 +219,7 @@ customer_facing_agent = Agent( ### 핸드오프 -핸드오프는 에이전트가 작업을 위임할 수 있는 하위 에이전트입니다. 핸드오프가 발생하면 위임받은 에이전트가 대화 기록을 전달받아 대화를 이어갑니다. 이 패턴을 사용하면 단일 작업에 뛰어난 모듈식 전문 에이전트를 구현할 수 있습니다. 자세한 내용은 [핸드오프](handoffs.md) 문서를 참조하세요. +구성된 핸드오프 대상은 에이전트가 작업을 위임할 수 있는 하위 에이전트입니다. 핸드오프가 발생하면 위임받은 에이전트가 대화 기록을 전달받아 대화를 이어갑니다. 이 패턴을 사용하면 단일 작업에 특화된 모듈식 전문 에이전트를 구성할 수 있습니다. 자세한 내용은 [핸드오프](handoffs.md) 문서를 참조하세요. ```python from agents import Agent @@ -238,11 +238,13 @@ triage_agent = Agent( ) ``` -## 동적 instructions +## 동적 지침 -대부분의 경우 에이전트를 생성할 때 instructions를 제공할 수 있습니다. 하지만 함수를 통해 동적 instructions를 제공할 수도 있습니다. 이 함수는 에이전트와 컨텍스트를 받아 프롬프트를 반환해야 합니다. 일반 함수와 `async` 함수를 모두 사용할 수 있습니다. +대부분의 경우 에이전트를 생성할 때 지침을 제공할 수 있습니다. 하지만 함수를 통해 동적 지침을 제공할 수도 있습니다. 함수는 에이전트와 컨텍스트를 전달받으며 프롬프트를 반환해야 합니다. 일반 함수와 `async` 함수가 모두 허용됩니다. ```python +from agents import Agent, RunContextWrapper + def dynamic_instructions( context: RunContextWrapper[UserContext], agent: Agent[UserContext] ) -> str: @@ -259,24 +261,24 @@ agent = Agent[UserContext]( 에이전트의 수명 주기를 관찰해야 하는 경우가 있습니다. 예를 들어 특정 이벤트가 발생할 때 이벤트를 기록하거나, 데이터를 미리 가져오거나, 사용량을 기록할 수 있습니다. -훅의 범위는 두 가지입니다. +훅에는 두 가지 범위가 있습니다. -- [`RunHooks`][agents.lifecycle.RunHooks]는 다른 에이전트로의 핸드오프를 포함한 전체 `Runner.run(...)` 호출을 관찰합니다. -- [`AgentHooks`][agents.lifecycle.AgentHooks]는 `agent.hooks`를 통해 특정 에이전트 인스턴스에 연결됩니다. +- [`RunHooks`][agents.lifecycle.RunHooks]은 다른 에이전트로의 핸드오프를 포함하여 전체 `Runner.run(...)` 호출을 관찰합니다. +- [`AgentHooks`][agents.lifecycle.AgentHooks]는 `agent.hooks`을 통해 특정 에이전트 인스턴스에 연결됩니다. 콜백 컨텍스트도 이벤트에 따라 달라집니다. -- 에이전트 시작/종료 훅은 [`AgentHookContext`][agents.run_context.AgentHookContext]를 받습니다. 이 컨텍스트는 원래 컨텍스트를 래핑하고 공유 실행 사용량 상태를 포함합니다. -- LLM, 도구, 핸드오프 훅은 [`RunContextWrapper`][agents.run_context.RunContextWrapper]를 받습니다. +- 에이전트 시작/종료 훅은 원래 컨텍스트를 래핑하고 공유 실행 사용량 상태를 포함하는 [`AgentHookContext`][agents.run_context.AgentHookContext]을 전달받습니다. +- LLM, 도구 및 핸드오프 훅은 [`RunContextWrapper`][agents.run_context.RunContextWrapper]를 전달받습니다. 일반적인 훅 실행 시점은 다음과 같습니다. -- `on_agent_start` / `on_agent_end`: 특정 에이전트가 최종 출력 생성을 시작하거나 완료할 때 +- `on_agent_start`: 특정 에이전트가 실행을 시작할 때, `on_agent_end`: 해당 에이전트가 최종 출력 생성을 마쳤을 때 - `on_llm_start` / `on_llm_end`: 각 모델 호출 직전과 직후 -- `on_tool_start` / `on_tool_end`: 각 로컬 도구 호출 직전과 직후. 함수 도구의 경우 훅 `context`는 일반적으로 `ToolContext`이므로 `tool_call_id` 같은 도구 호출 메타데이터를 검사할 수 있습니다. -- `on_handoff`: 한 에이전트에서 다른 에이전트로 제어권이 이동할 때 +- `on_tool_start` / `on_tool_end`: 각 로컬 도구 호출 전후. 함수 도구의 경우 `context` 훅은 일반적으로 `ToolContext`이므로 `tool_call_id`과 같은 도구 호출 메타데이터를 검사할 수 있습니다. +- `on_handoff`: 제어권이 한 에이전트에서 다른 에이전트로 이동할 때 -전체 워크플로를 관찰하는 단일 관찰자가 필요하면 `RunHooks`를 사용하고, 특정 에이전트에 사용자 지정 부수 효과가 필요하면 `AgentHooks`를 사용하세요. +전체 워크플로를 관찰하는 단일 관찰자가 필요하면 `RunHooks`을 사용하고, 특정 에이전트로 범위가 한정된 수명 주기 콜백이 필요하면 `AgentHooks`을 사용하세요. ```python from agents import Agent, RunHooks, Runner @@ -302,11 +304,11 @@ print(result.final_output) ## 가드레일 -가드레일을 사용하면 에이전트가 실행되는 동안 사용자 입력에 대한 검사와 검증을 병렬로 수행하고, 에이전트 출력이 생성된 후 해당 출력도 검사할 수 있습니다. 예를 들어 사용자 입력과 에이전트 출력이 관련성이 있는지 확인할 수 있습니다. 자세한 내용은 [가드레일](guardrails.md) 문서를 참조하세요. +가드레일을 사용하면 에이전트 실행과 병렬로 사용자 입력에 대한 검사/검증을 실행하고, 에이전트 출력이 생성된 후 해당 출력을 검사할 수 있습니다. 예를 들어 사용자 입력과 에이전트 출력의 관련성을 확인할 수 있습니다. 자세한 내용은 [가드레일](guardrails.md) 문서를 참조하세요. ## 에이전트 복제/복사 -에이전트의 `clone()` 메서드를 사용하면 Agent를 복제하고 원하는 속성을 선택적으로 변경할 수 있습니다. +에이전트의 `clone()` 메서드를 사용하면 에이전트를 복제하고 원하는 속성을 선택적으로 변경할 수 있습니다. ```python pirate_agent = Agent( @@ -323,14 +325,14 @@ robot_agent = pirate_agent.clone( ## 도구 사용 강제 -도구 목록을 제공하더라도 LLM이 항상 도구를 사용하는 것은 아닙니다. [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]를 설정하여 도구 사용을 강제할 수 있습니다. 유효한 값은 다음과 같습니다. +도구 목록을 제공한다고 해서 LLM이 항상 도구를 사용하는 것은 아닙니다. [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]을 설정하여 도구 사용을 강제할 수 있습니다. 유효한 값은 다음과 같습니다. -1. `auto`: 도구 사용 여부를 LLM이 결정할 수 있습니다. -2. `required`: LLM이 도구를 사용해야 합니다. 단, 어떤 도구를 사용할지는 지능적으로 결정할 수 있습니다. -3. `none`: LLM이 도구를 _사용하지 않도록_ 강제합니다. -4. `my_tool` 같은 특정 문자열을 설정하면 LLM이 해당 도구를 사용하도록 강제합니다. +1. `auto`: LLM이 도구 사용 여부를 결정할 수 있습니다. +2. `required`: LLM이 도구를 사용해야 하지만 사용할 도구는 지능적으로 결정할 수 있습니다. +3. `none`: LLM이 도구를 사용하지 _않도록_ 지정합니다. +4. `my_tool`과 같은 특정 문자열 설정: LLM이 해당 도구를 사용하도록 지정합니다. -OpenAI Responses 도구 검색을 사용할 때는 이름이 지정된 도구 선택에 더 많은 제약이 있습니다. `tool_choice`를 사용하여 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없으며, `tool_choice="tool_search"`는 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 대상으로 하지 않습니다. 이러한 경우에는 `auto` 또는 `required`를 사용하는 것이 좋습니다. Responses 관련 제약 조건은 [호스티드 툴 검색](tools.md#hosted-tool-search)을 참조하세요. +OpenAI Responses 도구 검색을 사용할 때는 이름이 지정된 도구 선택에 더 많은 제약이 있습니다. `tool_choice`을 사용하여 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없으며, `tool_choice="tool_search"`은 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 대상으로 지정하지 않습니다. 이러한 경우에는 `auto` 또는 `required`을 사용하는 것이 좋습니다. Responses 관련 제약 조건은 [호스티드 툴 검색](tools.md#hosted-tool-search)을 참조하세요. ```python from agents import Agent, ModelSettings @@ -353,8 +355,8 @@ agent = Agent( `Agent` 구성의 `tool_use_behavior` 매개변수는 도구 출력의 처리 방식을 제어합니다. -- `"run_llm_again"`: 기본값입니다. 도구를 실행하고 LLM이 결과를 처리하여 최종 응답을 생성합니다. -- `"stop_on_first_tool"`: 추가 LLM 처리 없이 첫 번째 도구 호출의 출력을 최종 응답으로 사용합니다. +- `"run_llm_again"`: 기본값입니다. 도구가 실행되며 LLM이 결과를 처리하여 최종 응답을 생성합니다. +- `"stop_on_first_tool"`: 추가적인 LLM 처리 없이 첫 번째 도구 호출의 출력을 최종 응답으로 사용합니다. ```python from agents import Agent @@ -377,8 +379,8 @@ agent = Agent( ```python from agents import Agent -from agents.decorators import tool from agents.agent import StopAtTools +from agents.decorators import tool @tool def get_weather(city: str) -> str: @@ -398,12 +400,12 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`: 도구 결과를 처리하고 LLM을 중지할지 계속 실행할지 결정하는 사용자 지정 함수입니다. +- `ToolsToFinalOutputFunction`: 도구 결과를 처리하고 최종 출력으로 실행을 종료할지, LLM으로 처리를 계속할지 결정하는 사용자 지정 함수입니다. ```python from agents import Agent, FunctionToolResult, RunContextWrapper -from agents.decorators import tool from agents.agent import ToolsToFinalOutputResult +from agents.decorators import tool from typing import List, Any @tool @@ -437,4 +439,4 @@ agent = Agent( !!! note - 무한 루프를 방지하기 위해 프레임워크는 도구 호출 후 `tool_choice`를 자동으로 "auto"로 재설정합니다. 이 동작은 [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]를 통해 구성할 수 있습니다. 무한 루프가 발생하는 이유는 도구 결과가 LLM으로 전송된 후 `tool_choice`로 인해 LLM이 또 다른 도구 호출을 생성하고 이 과정이 무한히 반복되기 때문입니다. \ No newline at end of file + 무한 루프를 방지하기 위해 프레임워크는 도구 호출 후 `tool_choice`을 자동으로 "auto"로 재설정합니다. 이 동작은 [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]를 통해 구성할 수 있습니다. 무한 루프는 도구 결과가 LLM으로 전송된 후 `tool_choice`으로 인해 LLM이 또 다른 도구 호출을 생성하는 과정이 끝없이 반복되기 때문에 발생합니다. \ No newline at end of file diff --git a/docs/ko/config.md b/docs/ko/config.md index 486321d651..96ca5a73f5 100644 --- a/docs/ko/config.md +++ b/docs/ko/config.md @@ -4,21 +4,21 @@ search: --- # 구성 -이 페이지에서는 기본 OpenAI 키 또는 클라이언트, 기본 OpenAI API 형식, 트레이싱 내보내기 기본값, 로깅 동작처럼 일반적으로 애플리케이션 시작 시 한 번 설정하는 SDK 전체 기본값을 설명합니다. +이 페이지에서는 기본 OpenAI 키 또는 클라이언트, 기본 OpenAI API 형식, 트레이싱 내보내기 기본값, 로깅 동작 등 애플리케이션 시작 시 일반적으로 한 번 설정하는 SDK 전역 기본값을 다룹니다. -이러한 기본값은 샌드박스 기반 워크플로에도 적용되지만, 샌드박스 워크스페이스, 샌드박스 클라이언트 및 세션 재사용은 별도로 구성합니다. +이러한 기본값은 샌드박스 기반 워크플로에도 적용되지만, 샌드박스 워크스페이스, 샌드박스 클라이언트, 세션 재사용은 별도로 구성합니다. -대신 특정 에이전트나 실행을 구성해야 한다면 다음 문서부터 확인하세요. +특정 에이전트나 실행을 구성해야 하는 경우 다음 문서부터 참조하세요. -- 일반 `Agent`의 instructions, tools, 출력 유형, 핸드오프 및 가드레일에 대해서는 [에이전트](agents.md) -- `RunConfig`, 세션 및 대화 상태 옵션에 대해서는 [에이전트 실행](running_agents.md) -- `SandboxRunConfig`, 매니페스트, 기능 및 샌드박스 클라이언트별 워크스페이스 설정에 대해서는 [샌드박스 에이전트](sandbox/guide.md) -- 모델 선택 및 프로바이더 구성에 대해서는 [모델](models/index.md) -- 실행별 트레이싱 메타데이터 및 사용자 지정 트레이스 프로세서에 대해서는 [트레이싱](tracing.md) +- 일반 에이전트 `Agent` 관련 instructions, tools, 출력 유형, 핸드오프, 가드레일은 [에이전트](agents.md)를 참조하세요. +- `RunConfig`, 세션, 대화 상태 옵션은 [에이전트 실행](running_agents.md)을 참조하세요. +- `SandboxRunConfig`, 매니페스트, 기능, 샌드박스 클라이언트별 워크스페이스 설정은 [샌드박스 에이전트](sandbox/guide.md)를 참조하세요. +- 모델 선택과 공급자 구성은 [모델](models/index.md)을 참조하세요. +- 실행별 트레이싱 메타데이터와 사용자 지정 트레이스 프로세서는 [트레이싱](tracing.md)을 참조하세요. -## 구성 객체 및 딕셔너리 +## 구성 객체와 딕셔너리 -SDK 소유 구성 매개변수는 일반적으로 형식이 지정된 설정 객체 또는 동일한 필드를 포함하는 딕셔너리를 허용합니다. 이는 형식 주석에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 구성 인터페이스 전반에 적용됩니다. 중첩된 SDK 소유 설정에도 딕셔너리를 사용할 수 있습니다. +SDK에서 정의한 구성 매개변수는 일반적으로 형식이 지정된 설정 객체나 동일한 필드를 포함하는 딕셔너리 중 하나를 허용합니다. 이는 형식 주석에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스, 음성 구성 경계 전반에 적용됩니다. SDK에서 정의한 중첩 설정 유형에도 딕셔너리를 사용할 수 있습니다. ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK 소유 데이터클래스 구성에 알 수 없는 필드가 있으면 `TypeError`가 발생하므로, 옵션 이름의 오타를 조기에 발견할 수 있습니다. 특정 인터페이스에서 딕셔너리를 허용하는지 확인하려면 매개변수의 형식 주석 또는 API 레퍼런스를 확인하세요. +SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK에서 정의한 dataclass 구성 유형에 알 수 없는 필드가 있으면 `TypeError` 오류가 발생하므로, 옵션 이름의 오타를 조기에 발견할 수 있습니다. 특정 경계에서 딕셔너리를 허용하는지 확인하려면 매개변수의 형식 주석이나 API 레퍼런스를 확인하세요. -## API 키 및 클라이언트 +## API 키와 클라이언트 -기본적으로 SDK는 LLM 요청 및 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. 키는 SDK가 OpenAI 클라이언트를 처음 생성할 때 확인되므로(지연 초기화), 첫 번째 모델 호출 전에 환경 변수를 설정하세요. 앱이 시작되기 전에 해당 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용해 키를 설정할 수 있습니다. +기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. SDK가 처음 OpenAI 클라이언트를 생성할 때 키가 확인되므로(지연 초기화), 첫 번째 모델 호출 전에 환경 변수를 설정하세요. 앱이 시작되기 전에 이 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용하여 키를 설정할 수 있습니다. ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -또는 사용할 OpenAI 클라이언트를 구성할 수도 있습니다. 기본적으로 SDK는 환경 변수의 API 키나 위에서 설정한 기본 키를 사용하여 `AsyncOpenAI` 인스턴스를 생성합니다. [set_default_openai_client()][agents.set_default_openai_client] 함수를 사용해 이를 변경할 수 있습니다. +또는 사용할 OpenAI 클라이언트를 구성할 수도 있습니다. 기본적으로 SDK는 환경 변수의 API 키나 위에서 설정한 기본 키를 사용하여 `AsyncOpenAI` 인스턴스를 생성합니다. [set_default_openai_client()][agents.set_default_openai_client] 함수를 사용하여 이를 변경할 수 있습니다. ```python from openai import AsyncOpenAI @@ -55,14 +55,14 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -환경 기반 엔드포인트 구성을 선호한다면 기본 OpenAI 프로바이더는 `OPENAI_BASE_URL`도 읽습니다. Responses WebSocket 전송을 활성화하면 WebSocket `/responses` 엔드포인트에 사용할 `OPENAI_WEBSOCKET_BASE_URL`도 읽습니다. +환경 기반 엔드포인트 구성을 선호하는 경우 기본 OpenAI 공급자는 `OPENAI_BASE_URL` 환경 변수도 읽습니다. Responses WebSocket 전송을 활성화하면 WebSocket `/responses` 엔드포인트에 사용할 `OPENAI_WEBSOCKET_BASE_URL` 환경 변수도 읽습니다. ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -마지막으로 사용할 OpenAI API도 사용자 지정할 수 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용하면 이를 재정의하여 Chat Completions API를 사용할 수 있습니다. +마지막으로 사용되는 OpenAI API도 사용자 지정할 수 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용하면 이를 재정의하여 Chat Completions API를 사용할 수 있습니다. ```python from agents import set_default_openai_api @@ -70,9 +70,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 프로바이더 기본값 +## OpenAI 공급자 기본값 -OpenAI 기반 프로바이더는 모델 이름을 확인할 때 SDK 전체 기본값도 읽습니다. OpenAI Responses 모델에서 WebSocket 전송을 기본으로 사용하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]를 사용하세요. +SDK의 OpenAI 백엔드를 사용하는 공급자는 모델 이름 문자열을 모델에 매핑할 때 SDK 전역 기본값도 읽습니다. OpenAI Responses 모델이 기본적으로 WebSocket 전송을 사용하도록 하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]를 사용하세요. ```python from agents import set_default_openai_responses_transport @@ -80,9 +80,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 프로바이더가 확인하는 OpenAI Responses 모델에 영향을 줍니다. 프로바이더 수준 설정, 연결 재사용, 연결 유지 옵션 및 사용자 지정 WebSocket 엔드포인트에 대해서는 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참조하세요. +이는 기본 OpenAI 공급자가 모델 이름을 해석할 때 생성되는 OpenAI Responses 모델에 영향을 줍니다. 공급자 수준 설정, 연결 재사용, keepalive 옵션, 사용자 지정 WebSocket 엔드포인트에 관한 자세한 내용은 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참조하세요. -OpenAI 설정에서 프로바이더 수준의 에이전트 등록 메타데이터가 필요하다면 시작 시 기본 하네스 ID를 한 번 구성하세요. +OpenAI 설정에 공급자 수준의 에이전트 등록 메타데이터가 필요한 경우 시작 시 기본 하네스 ID를 한 번 구성하세요. ```python from agents import set_default_openai_harness @@ -100,11 +100,11 @@ set_default_openai_agent_registration( ) ``` -SDK 기본값이 설정되지 않은 경우 OpenAI 기반 프로바이더는 `OPENAI_AGENT_HARNESS_ID` 환경 변수를 대신 사용합니다. 하네스 ID가 구성되어 있으면 해당 키가 `RunConfig.trace_metadata`에 이미 존재하지 않는 한 SDK는 이를 `agent_harness_id`로 트레이스 메타데이터에 추가합니다. +SDK 기본값이 설정되지 않은 경우 SDK의 OpenAI 백엔드를 사용하는 공급자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수를 대신 사용합니다. 하네스 ID가 구성되어 있으면 `RunConfig.trace_metadata` 내에 해당 키가 이미 존재하지 않는 한 SDK는 이를 `agent_harness_id` 항목으로 트레이스 메타데이터에 추가합니다. ## 트레이싱 -트레이싱은 기본적으로 활성화됩니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수나 사용자가 설정한 기본 키를 사용합니다. [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하면 트레이싱에 사용할 API 키를 별도로 설정할 수 있습니다. +트레이싱은 기본적으로 활성화되어 있습니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수 또는 설정한 기본 키를 사용합니다. 트레이싱에 사용할 API 키는 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하여 별도로 설정할 수 있습니다. ```python from agents import set_tracing_export_api_key @@ -112,7 +112,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -모델 트래픽에는 특정 키나 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 한다면, 기본 키나 클라이언트를 설정할 때 `use_for_tracing=False`를 전달한 다음 트레이싱을 별도로 구성하세요. 사용자 지정 클라이언트를 사용하지 않는 경우 [`set_default_openai_key()`][agents.set_default_openai_key]에도 같은 패턴을 적용할 수 있습니다. +모델 트래픽에는 특정 키나 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 하는 경우 기본 키나 클라이언트를 설정할 때 `use_for_tracing=False` 옵션을 전달한 다음 트레이싱을 별도로 구성하세요. 사용자 지정 클라이언트를 사용하지 않는 경우 [`set_default_openai_key()`][agents.set_default_openai_key]에도 동일한 패턴을 적용할 수 있습니다. ```python from openai import AsyncOpenAI @@ -127,14 +127,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -기본 내보내기를 사용할 때 트레이스를 특정 조직이나 프로젝트에 귀속해야 한다면 앱이 시작되기 전에 다음 환경 변수를 설정하세요. +기본 익스포터를 사용할 때 트레이스를 특정 조직이나 프로젝트에 귀속해야 하는 경우 앱이 시작되기 전에 다음 환경 변수를 설정하세요. ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -전역 내보내기를 변경하지 않고 실행별로 트레이싱 API 키를 설정할 수도 있습니다. +전역 익스포터를 변경하지 않고 실행별 트레이싱 API 키를 설정할 수도 있습니다. ```python from agents import Runner, RunConfig @@ -146,7 +146,7 @@ await Runner.run( ) ``` -[`set_tracing_disabled()`][agents.set_tracing_disabled] 함수를 사용해 트레이싱을 완전히 비활성화할 수도 있습니다. +[`set_tracing_disabled()`][agents.set_tracing_disabled] 함수를 사용하여 트레이싱을 완전히 비활성화할 수도 있습니다. ```python from agents import set_tracing_disabled @@ -154,7 +154,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -트레이싱은 활성화된 상태로 유지하면서 잠재적으로 민감한 입력/출력을 트레이스 페이로드에서 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 `False`로 설정하세요. +트레이싱은 활성화된 상태로 유지하면서 잠재적으로 민감한 입력과 출력을 트레이스 페이로드에서 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 설정에 `False` 값을 지정하세요. ```python from agents import Runner, RunConfig @@ -166,13 +166,13 @@ await Runner.run( ) ``` -앱이 시작되기 전에 다음 환경 변수를 설정하여 코드 없이 기본값을 변경할 수도 있습니다. +앱이 시작되기 전에 다음 환경 변수를 설정하면 코드 없이 기본값을 변경할 수도 있습니다. ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -전체 트레이싱 제어 기능에 대해서는 [트레이싱 가이드](tracing.md)를 참조하세요. +트레이싱의 모든 제어 옵션은 [트레이싱 가이드](tracing.md)를 참조하세요. ## 디버그 로깅 @@ -205,22 +205,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 로그 및 진단의 민감한 데이터 +### 로그와 진단의 민감한 데이터 -특정 로그와 진단 예외에는 민감한 데이터(예: 모델 또는 도구의 입력 및 출력)가 포함될 수 있습니다. +일부 로그와 진단 예외에는 민감한 데이터(예: 모델 또는 도구의 입력과 출력)가 포함될 수 있습니다. -기본적으로 SDK는 LLM 입력/출력이나 도구 입력/출력을 **기록하지 않습니다**. 이러한 보호 기능은 다음 항목으로 제어합니다. +기본적으로 SDK는 LLM 입력과 출력 또는 도구 입력과 출력을 **로그에 기록하지 않습니다**. 이러한 보호 기능은 다음 설정으로 제어합니다. ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -디버깅을 위해 이 데이터를 일시적으로 포함해야 한다면 앱이 시작되기 전에 둘 중 하나의 변수를 `0`(또는 `false`)으로 설정하세요. +디버깅을 위해 이 데이터를 일시적으로 포함해야 하는 경우 앱이 시작되기 전에 두 변수 중 하나에 `0` 값(또는 `false`)을 설정하세요. ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -이러한 플래그는 영향을 받는 실패에서 페이로드가 포함된 진단 세부 정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 교정이 활성화된 상태에서 함수 도구 인수가 유효하지 않으면, 내부 유효성 검사 오류를 예외 체인으로 연결하지 않고 일반적인 `ModelBehaviorError`가 발생합니다. 둘 중 하나의 변수를 `0`으로 설정하면 로그, 예외 메시지, 예외 체인 및 기타 진단 컨텍스트에 모델 또는 도구의 원문 데이터가 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file +이 플래그는 영향을 받는 오류가 페이로드를 포함한 진단 세부정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 비식별화가 활성화된 상태에서 `FunctionTool` 인수가 유효하지 않으면, 근본적인 유효성 검사 오류를 예외 체인에 연결하지 않고 일반적인 `ModelBehaviorError` 오류가 발생합니다. 두 변수 중 하나에 `0` 값을 설정하면 가공되지 않은 모델 또는 도구 데이터가 로그, 예외 메시지, 예외 체인, 기타 진단 컨텍스트에 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file diff --git a/docs/ko/context.md b/docs/ko/context.md index eae5dedf19..398329a491 100644 --- a/docs/ko/context.md +++ b/docs/ko/context.md @@ -4,49 +4,49 @@ search: --- # 컨텍스트 관리 -컨텍스트는 여러 의미로 쓰이는 용어입니다. 주로 고려해야 할 컨텍스트에는 두 가지 주요 유형이 있습니다. +컨텍스트는 여러 의미로 사용되는 용어입니다. 여기서 고려할 수 있는 컨텍스트는 크게 두 가지로 나뉩니다. -1. 코드에서 로컬로 사용할 수 있는 컨텍스트: 도구 함수가 실행될 때, `on_handoff` 같은 콜백 중에, 생명주기 훅 등에서 필요할 수 있는 데이터와 의존성입니다. -2. LLM이 사용할 수 있는 컨텍스트: LLM이 응답을 생성할 때 보는 데이터입니다. +1. 코드에서 로컬로 사용할 수 있는 컨텍스트: 도구 함수가 실행될 때, `on_handoff` 같은 콜백이나 수명 주기 훅 등에서 필요할 수 있는 데이터와 종속성입니다. +2. LLM에서 사용할 수 있는 컨텍스트: 응답을 생성할 때 LLM이 확인하는 데이터입니다. ## 로컬 컨텍스트 이는 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 클래스와 그 안의 [`context`][agents.run_context.RunContextWrapper.context] 속성으로 표현됩니다. 작동 방식은 다음과 같습니다. -1. 원하는 Python 객체를 만듭니다. 일반적인 패턴은 dataclass나 Pydantic 객체를 사용하는 것입니다. -2. 해당 객체를 다양한 run 메서드에 전달합니다(예: `Runner.run(..., context=whatever)`). -3. 모든 도구 호출, 생명주기 훅 등에는 래퍼 객체인 `RunContextWrapper[T]`가 전달되며, 여기서 `T`는 `wrapper.context`를 통해 접근할 수 있는 컨텍스트 객체 타입을 나타냅니다. +1. 원하는 Python 객체를 생성합니다. 일반적으로 데이터 클래스나 Pydantic 객체를 사용합니다. +2. 해당 객체를 다양한 실행 메서드(예: `Runner.run(..., context=whatever)`)에 전달합니다. +3. 모든 도구 호출, 수명 주기 훅 등에는 래퍼 객체인 `RunContextWrapper[T]`가 전달됩니다. 여기서 `T`는 컨텍스트 객체의 유형을 나타내며, 객체 자체는 `wrapper.context`을 통해 사용할 수 있습니다. -일부 런타임별 콜백의 경우 SDK가 `RunContextWrapper[T]`의 더 특화된 서브클래스를 전달할 수 있습니다. 예를 들어 함수 도구 생명주기 훅은 일반적으로 `ToolContext`를 받으며, 이는 `tool_call_id`, `tool_name`, `tool_arguments` 같은 도구 호출 메타데이터도 노출합니다. +일부 런타임 전용 콜백에서는 SDK가 `RunContextWrapper[T]`의 더 특화된 하위 클래스를 전달할 수 있습니다. 예를 들어 `FunctionTool` 인스턴스의 수명 주기 훅은 일반적으로 `ToolContext`를 받으며, 이 객체는 `tool_call_id`, `tool_name`, `tool_arguments`와 같은 도구 호출 메타데이터도 제공합니다. -알아두어야 할 **가장 중요한** 점은 특정 에이전트 실행에 포함되는 모든 에이전트, 도구 함수, 생명주기 등은 동일한 컨텍스트 _타입_을 사용해야 한다는 것입니다. +알아두어야 할 **가장 중요한** 사항은 특정 에이전트 실행에 사용되는 모든 에이전트, 도구 함수, 수명 주기 요소 등이 동일한 컨텍스트 _유형_을 사용해야 한다는 것입니다. 컨텍스트는 다음과 같은 용도로 사용할 수 있습니다. -- 실행을 위한 컨텍스트 데이터(예: 사용자 이름/uid 또는 사용자에 대한 기타 정보) -- 의존성(예: 로거 객체, 데이터 페처 등) +- 실행에 필요한 컨텍스트 데이터(예: 사용자 이름/uid 또는 사용자에 관한 기타 정보) +- 종속성(예: 로거 객체, 데이터 페처 등) - 헬퍼 함수 !!! danger "참고" - 컨텍스트 객체는 LLM으로 전송되지 **않습니다**. 이는 오직 로컬 객체이며, 읽고 쓰거나 해당 객체의 메서드를 호출할 수 있습니다. + 컨텍스트 객체는 LLM으로 **전송되지 않습니다**. 이는 데이터를 읽고 쓰거나 메서드를 호출할 수 있는 순수한 로컬 객체입니다. -단일 실행 내에서 파생된 래퍼들은 동일한 기본 앱 컨텍스트, 승인 상태, 사용량 추적을 공유합니다. 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행은 다른 `tool_input`을 붙일 수 있지만, 기본적으로 앱 상태의 격리된 복사본을 받지는 않습니다. +단일 실행 내에서 파생된 래퍼는 동일한 기본 애플리케이션 컨텍스트, 승인 상태, 사용량 추적을 공유합니다. 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에는 다른 `tool_input`가 연결될 수 있지만, 기본적으로 애플리케이션 상태의 격리된 사본이 제공되지는 않습니다. -### `RunContextWrapper`의 노출 항목 +### `RunContextWrapper`에서 제공되는 항목 -[`RunContextWrapper`][agents.run_context.RunContextWrapper]는 앱에서 정의한 컨텍스트 객체를 감싸는 래퍼입니다. 실제로는 대부분 다음을 사용하게 됩니다. +[`RunContextWrapper`][agents.run_context.RunContextWrapper]는 애플리케이션에서 정의한 컨텍스트 객체의 래퍼입니다. 실제로는 다음 항목을 가장 자주 사용합니다. -- [`wrapper.context`][agents.run_context.RunContextWrapper.context]: 직접 사용하는 변경 가능한 앱 상태와 의존성 -- [`wrapper.usage`][agents.run_context.RunContextWrapper.usage]: 현재 실행 전반의 집계된 요청 및 토큰 사용량 -- [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input]: 현재 실행이 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 안에서 실행 중일 때의 구조화된 입력 -- [`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool]: 승인 상태를 프로그래밍 방식으로 업데이트해야 할 때 사용 +- 변경 가능한 자체 애플리케이션 상태와 종속성을 위한 [`wrapper.context`][agents.run_context.RunContextWrapper.context] +- 현재 실행 전체에서 집계된 요청 및 토큰 사용량을 위한 [`wrapper.usage`][agents.run_context.RunContextWrapper.usage] +- 현재 실행이 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 내부에서 수행될 때 구조화된 입력을 위한 [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input] +- 프로그래밍 방식으로 승인 상태를 업데이트해야 할 때 사용하는 [`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool] -`wrapper.context`만 앱에서 정의한 객체입니다. 다른 필드는 SDK가 관리하는 런타임 메타데이터입니다. +`wrapper.context`만 애플리케이션에서 정의한 객체입니다. 다른 필드는 SDK가 관리하는 런타임 메타데이터입니다. -나중에 휴먼인더루프 (HITL) 또는 내구성 있는 작업 워크플로를 위해 [`RunState`][agents.run_state.RunState]를 직렬화하면, 해당 런타임 메타데이터가 상태와 함께 저장됩니다. 직렬화된 상태를 영속화하거나 전송할 계획이라면 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 비밀 정보를 넣지 마세요. +나중에 휴먼인더루프 (HITL) 또는 내구성 있는 작업 워크플로를 위해 [`RunState`][agents.run_state.RunState]를 직렬화하면 해당 런타임 메타데이터도 상태와 함께 저장됩니다. 직렬화된 상태를 영구 저장하거나 전송하려는 경우 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 비밀 정보를 넣지 마세요. -대화 상태는 별개의 문제입니다. 턴을 이어가는 방식에 따라 `result.to_input_list()`, `session`, `conversation_id`, 또는 `previous_response_id`를 사용하세요. 이 결정에 대해서는 [결과](results.md), [에이전트 실행](running_agents.md), [세션](sessions/index.md)을 참고하세요. +대화 상태는 별개의 사안입니다. 대화 턴을 이어가는 방식에 따라 `result.to_input_list()`, `session`, `conversation_id` 또는 `previous_response_id`를 사용하세요. 이러한 선택에 관한 자세한 내용은 [결과](results.md), [에이전트 실행](running_agents.md), [세션](sessions/index.md)을 참고하세요. ```python import asyncio @@ -86,9 +86,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 이것이 컨텍스트 객체입니다. 여기서는 dataclass를 사용했지만, 어떤 타입이든 사용할 수 있습니다. -2. 이것은 도구입니다. `RunContextWrapper[UserInfo]`를 받는 것을 볼 수 있습니다. 도구 구현은 컨텍스트에서 읽습니다. -3. 에이전트에 제네릭 `UserInfo`를 표시하여, 타입 검사기가 오류를 잡을 수 있도록 합니다(예를 들어 다른 컨텍스트 타입을 받는 도구를 전달하려고 한 경우). +1. 컨텍스트 객체입니다. 여기서는 데이터 클래스를 사용했지만 어떤 유형이든 사용할 수 있습니다. +2. 도구입니다. `RunContextWrapper[UserInfo]`을 받는 것을 확인할 수 있습니다. 도구 구현은 컨텍스트에서 데이터를 읽습니다. +3. 에이전트에 제네릭 `UserInfo`을 지정하여 타입 검사기가 오류를 감지할 수 있도록 합니다. 예를 들어 다른 컨텍스트 유형을 받는 도구를 전달하려 하면 오류를 감지할 수 있습니다. 4. 컨텍스트가 `run` 함수에 전달됩니다. 5. 에이전트가 도구를 올바르게 호출하고 나이를 가져옵니다. @@ -96,8 +96,8 @@ if __name__ == "__main__": ### 고급: `ToolContext` -어떤 경우에는 실행 중인 도구에 대한 추가 메타데이터(예: 이름, 호출 ID, 원문 인수 문자열)에 접근하고 싶을 수 있습니다. -이를 위해 `RunContextWrapper`를 확장하는 [`ToolContext`][agents.tool_context.ToolContext] 클래스를 사용할 수 있습니다. +경우에 따라 실행 중인 도구의 이름, 호출 ID 또는 가공되지 않은 인수 문자열 같은 추가 메타데이터에 액세스해야 할 수 있습니다. +이를 위해 `RunContextWrapper`를 확장한 [`ToolContext`][agents.tool_context.ToolContext] 클래스를 사용할 수 있습니다. ```python from typing import Annotated @@ -126,25 +126,25 @@ agent = Agent( ) ``` -`ToolContext`는 `RunContextWrapper`와 동일한 `.context` 속성을 제공하며, -현재 도구 호출에 특화된 추가 필드도 제공합니다. +`ToolContext`은 `RunContextWrapper`과 동일한 `.context` 속성을 제공하며, +현재 도구 호출에 특화된 다음과 같은 추가 필드도 제공합니다. - `tool_name` – 호출되는 도구의 이름 - `tool_call_id` – 이 도구 호출의 고유 식별자 -- `tool_arguments` – 도구에 전달된 원문 인수 문자열 -- `tool_namespace` – 도구가 `tool_namespace()` 또는 다른 네임스페이스가 지정된 표면을 통해 로드된 경우, 도구 호출의 Responses 네임스페이스 -- `qualified_tool_name` – 네임스페이스가 있을 때 해당 네임스페이스로 한정된 도구 이름 +- `tool_arguments` – 도구에 전달된 가공되지 않은 인수 문자열 +- `tool_namespace` – 도구가 `tool_namespace()` 또는 네임스페이스를 사용하는 다른 인터페이스를 통해 로드된 경우 도구 호출의 Responses 네임스페이스 +- `qualified_tool_name` – 네임스페이스가 있는 경우 해당 네임스페이스로 한정된 도구 이름 -실행 중에 도구 수준 메타데이터가 필요할 때 `ToolContext`를 사용하세요. -에이전트와 도구 간의 일반적인 컨텍스트 공유에는 `RunContextWrapper`로 충분합니다. `ToolContext`는 `RunContextWrapper`를 확장하므로, 중첩된 `Agent.as_tool()` 실행이 구조화된 입력을 제공한 경우 `.tool_input`도 노출할 수 있습니다. +실행 중에 도구 수준 메타데이터가 필요하면 `ToolContext`를 사용하세요. +에이전트와 도구 간에 일반적인 컨텍스트를 공유하는 용도로는 `RunContextWrapper`만으로도 충분합니다. `ToolContext`은 `RunContextWrapper`을 확장하므로, 중첩된 `Agent.as_tool()` 실행에서 구조화된 입력을 제공한 경우 `.tool_input`도 제공할 수 있습니다. --- ## 에이전트/LLM 컨텍스트 -LLM이 호출될 때 LLM이 볼 수 있는 **유일한** 데이터는 대화 기록에 있는 데이터입니다. 즉, LLM이 어떤 새 데이터를 사용할 수 있게 하려면 해당 기록에서 사용할 수 있는 방식으로 제공해야 합니다. 이를 수행하는 방법은 몇 가지가 있습니다. +LLM이 호출될 때 확인할 수 있는 데이터는 대화 기록에 있는 데이터**뿐**입니다. 따라서 LLM이 새로운 데이터를 사용할 수 있게 하려면 해당 데이터가 대화 기록에 포함되도록 해야 합니다. 이를 수행하는 방법은 몇 가지가 있습니다. -1. Agent `instructions`에 추가할 수 있습니다. 이는 "시스템 프롬프트" 또는 "개발자 메시지"라고도 합니다. 시스템 프롬프트는 정적 문자열일 수도 있고, 컨텍스트를 받아 문자열을 출력하는 동적 함수일 수도 있습니다. 이는 항상 유용한 정보(예: 사용자의 이름 또는 현재 날짜)에 흔히 사용하는 전략입니다. -2. `Runner.run` 함수를 호출할 때 `input`에 추가합니다. 이는 `instructions` 전략과 유사하지만, [명령 체계](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)에서 더 낮은 위치의 메시지를 사용할 수 있게 해줍니다. -3. 함수 도구를 통해 노출합니다. 이는 _온디맨드_ 컨텍스트에 유용합니다. LLM이 어떤 데이터가 필요한 시점을 결정하고, 해당 데이터를 가져오기 위해 도구를 호출할 수 있습니다. -4. 검색 또는 웹 검색을 사용합니다. 이는 파일이나 데이터베이스에서 관련 데이터를 가져올 수 있는 특수 도구(검색) 또는 웹에서 가져올 수 있는 특수 도구(웹 검색)입니다. 이는 응답을 관련 컨텍스트 데이터에 "근거화"하는 데 유용합니다. \ No newline at end of file +1. 에이전트의 `instructions`에 추가할 수 있습니다. 이는 "시스템 프롬프트" 또는 "개발자 메시지"라고도 합니다. 시스템 프롬프트는 정적 문자열일 수도 있고, 컨텍스트를 받아 문자열을 출력하는 동적 함수일 수도 있습니다. 항상 유용한 정보(예: 사용자의 이름이나 현재 날짜)를 제공할 때 흔히 사용하는 방법입니다. +2. `Runner.run` 함수를 호출할 때 `input`에 추가합니다. 이는 `instructions` 방식과 유사하지만, [지시 계층](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)에서 더 낮은 위치의 메시지를 사용할 수 있습니다. +3. `FunctionTool` 인스턴스를 통해 제공합니다. 이는 _필요할 때 사용하는_ 컨텍스트에 유용합니다. LLM이 데이터가 필요한 시점을 판단하고 도구를 호출하여 해당 데이터를 가져올 수 있습니다. +4. 검색 또는 웹 검색을 사용합니다. 파일이나 데이터베이스에서 관련 데이터를 가져오는 검색이나 웹에서 데이터를 가져오는 웹 검색은 이를 위한 특수 도구입니다. 이는 응답이 관련 컨텍스트 데이터에 근거하도록 하는 데 유용합니다. \ No newline at end of file diff --git a/docs/ko/examples.md b/docs/ko/examples.md index 949f933983..80562b8d45 100644 --- a/docs/ko/examples.md +++ b/docs/ko/examples.md @@ -4,134 +4,134 @@ search: --- # 예제 -[리포지토리](https://github.com/openai/openai-agents-python/tree/main/examples)의 examples 섹션에서 다양한 SDK 샘플 구현을 확인해 보세요. 예제는 서로 다른 패턴과 기능을 보여 주는 여러 카테고리로 구성되어 있습니다. +[저장소](https://github.com/openai/openai-agents-python/tree/main/examples)의 examples 섹션에서 SDK를 사용하는 다양한 샘플 구현을 확인해 보세요. 예제는 서로 다른 패턴과 기능을 보여 주는 여러 카테고리로 구성되어 있습니다. ## 카테고리 - **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** 이 카테고리의 예제는 다음과 같은 일반적인 에이전트 설계 패턴을 보여 줍니다. - - 결정론적 워크플로 - - Agents as tools - - 스트리밍 이벤트가 포함된 Agents as tools (`examples/agent_patterns/agents_as_tools_streaming.py`) - - 구조화된 입력 매개변수가 포함된 Agents as tools (`examples/agent_patterns/agents_as_tools_structured.py`) - - 병렬 에이전트 실행 - - 조건부 도구 사용 - - 서로 다른 동작으로 도구 사용 강제 (`examples/agent_patterns/forcing_tool_use.py`) - - 입출력 가드레일 - - 평가자로서의 LLM - - 라우팅 - - 스트리밍 가드레일 - - 도구 승인 및 상태 직렬화를 사용하는 휴먼인더루프 (HITL) (`examples/agent_patterns/human_in_the_loop.py`) - - 스트리밍을 사용하는 휴먼인더루프 (HITL) (`examples/agent_patterns/human_in_the_loop_stream.py`) - - 승인 흐름을 위한 사용자 지정 거부 메시지 (`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) - -- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** 이 예제는 다음과 같은 SDK의 기본 기능을 보여 줍니다. - - - Hello world 예제(기본 모델, GPT-5, 오픈 웨이트 모델) - - 에이전트 수명 주기 관리 - - 실행 훅 및 에이전트 훅 수명 주기 예제 (`examples/basic/lifecycle_example.py`) - - 동적 시스템 프롬프트 - - 기본적인 도구 사용 (`examples/basic/tools.py`) - - 도구 입출력 가드레일 (`examples/basic/tool_guardrails.py`) - - 이미지 도구 출력 (`examples/basic/image_tool_output.py`) - - 스트리밍 출력(텍스트, 항목, 함수 호출 인수) - - 여러 턴에서 공유 세션 도우미를 사용하는 Responses WebSocket 전송 (`examples/basic/stream_ws.py`) - - 프롬프트 템플릿 - - 파일 처리(로컬 및 원격, 이미지 및 PDF) - - 사용량 추적 - - Runner가 관리하는 재시도 설정 (`examples/basic/retry.py`) - - 서드 파티 어댑터를 통해 Runner가 관리하는 재시도 (`examples/basic/retry_litellm.py`) - - 비엄격 출력 유형 - - 이전 응답 ID 사용 - -- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 항공사를 위한 고객 서비스 시스템 예제입니다. - -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 금융 데이터 분석용 에이전트와 도구를 활용한 구조화된 리서치 워크플로를 보여 주는 금융 리서치 에이전트입니다. - -- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** 메시지 필터링을 사용하는 에이전트 핸드오프의 실용적인 예제는 다음과 같습니다. - - - 메시지 필터 예제 (`examples/handoffs/message_filter.py`) - - 스트리밍을 사용하는 메시지 필터 (`examples/handoffs/message_filter_streaming.py`) - -- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API와 함께 호스티드 MCP(Model Context Protocol)를 사용하는 방법을 보여 주는 예제는 다음과 같습니다. - - - 승인이 없는 간단한 호스티드 MCP (`examples/hosted_mcp/simple.py`) - - Google Calendar와 같은 MCP 커넥터 (`examples/hosted_mcp/connectors.py`) - - 인터럽션(중단 처리) 기반 승인을 사용하는 휴먼인더루프 (HITL) (`examples/hosted_mcp/human_in_the_loop.py`) - - MCP 도구 호출을 위한 승인 시 콜백 (`examples/hosted_mcp/on_approval.py`) + - 결정적 워크플로 + - Agents as tools + - 스트리밍 이벤트를 사용하는 Agents as tools (`examples/agent_patterns/agents_as_tools_streaming.py`) + - 구조화된 입력 매개변수를 사용하는 Agents as tools (`examples/agent_patterns/agents_as_tools_structured.py`) + - 병렬 에이전트 실행 + - 조건부 도구 사용 + - 서로 다른 도구 사용 동작을 보여 주면서 도구 사용 강제 (`examples/agent_patterns/forcing_tool_use.py`) + - 입력/출력 가드레일 + - 판정자로서의 LLM + - 라우팅 + - 스트리밍 가드레일 + - 도구 승인 및 상태 직렬화를 사용하는 휴먼인더루프 (HITL) (`examples/agent_patterns/human_in_the_loop.py`) + - 스트리밍을 사용하는 휴먼인더루프 (HITL) (`examples/agent_patterns/human_in_the_loop_stream.py`) + - 승인 흐름을 위한 사용자 지정 거부 메시지 (`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) + +- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** 다음과 같은 SDK의 기본 기능을 보여 주는 예제입니다. + + - Hello world 예제(기본 모델, GPT-5, 오픈 웨이트 모델) + - 에이전트 수명 주기 관리 + - `RunHooks` 및 `AgentHooks` 사용을 보여 주는 에이전트 및 실행 수명 주기 예제 (`examples/basic/lifecycle_example.py`) + - 동적 시스템 프롬프트 + - 기본 도구 사용 (`examples/basic/tools.py`) + - 도구 입력/출력 가드레일 (`examples/basic/tool_guardrails.py`) + - 도구 출력으로 이미지 반환 (`examples/basic/image_tool_output.py`) + - 출력 스트리밍(텍스트, 항목, 함수 호출 인수) + - 여러 턴에서 공유 세션 헬퍼를 사용하는 Responses WebSocket 전송 (`examples/basic/stream_ws.py`) + - 프롬프트 템플릿 + - 파일 처리(로컬 및 원격, 이미지 및 PDF) + - 사용량 추적 + - Runner 관리형 재시도 설정 (`examples/basic/retry.py`) + - 서드 파티 어댑터를 통한 Runner 관리형 재시도 (`examples/basic/retry_litellm.py`) + - 비엄격 출력 유형 + - 이전 응답 ID 사용 + +- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 항공사 고객 서비스 시스템 예제입니다. + +- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 에이전트와 도구를 사용해 금융 데이터 분석을 위한 구조화된 리서치 워크플로를 보여 주는 금융 리서치 에이전트입니다. + +- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** 메시지 필터링을 포함한 에이전트 핸드오프의 실용적인 예제입니다. + + - 메시지 필터 예제 (`examples/handoffs/message_filter.py`) + - 스트리밍을 사용하는 메시지 필터 (`examples/handoffs/message_filter_streaming.py`) + +- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** OpenAI Responses API에서 호스티드 MCP(Model Context Protocol)를 사용하는 방법을 보여 주는 예제이며, 다음을 포함합니다. + + - 승인 없이 사용하는 간단한 호스티드 MCP (`examples/hosted_mcp/simple.py`) + - Google Calendar와 같은 MCP 커넥터 (`examples/hosted_mcp/connectors.py`) + - 인터럽션(중단 처리) 기반 승인을 사용하는 휴먼인더루프 (HITL) (`examples/hosted_mcp/human_in_the_loop.py`) + - MCP 도구 승인 요청을 위한 콜백 (`examples/hosted_mcp/on_approval.py`) - **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** 다음을 포함하여 MCP(Model Context Protocol)로 에이전트를 구축하는 방법을 알아봅니다. - - 파일 시스템 예제 - - Git 예제 - - MCP 프롬프트 서버 예제 - - SSE(Server-Sent Events) 예제 - - SSE 원격 서버 연결 (`examples/mcp/sse_remote_example`) - - Streamable HTTP 예제 - - Streamable HTTP 원격 연결 (`examples/mcp/streamable_http_remote_example`) - - Streamable HTTP용 사용자 지정 HTTP 클라이언트 팩토리 (`examples/mcp/streamablehttp_custom_client_example`) - - `MCPUtil.get_all_function_tools`를 사용하여 모든 MCP 도구 미리 가져오기 (`examples/mcp/get_all_mcp_tools_example`) - - FastAPI를 사용하는 MCPServerManager (`examples/mcp/manager_example`) - - MCP 도구 필터링 (`examples/mcp/tool_filter_example`) - -- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** 에이전트를 위한 다양한 메모리 구현 예제는 다음과 같습니다. - - - SQLite 세션 스토리지 - - 고급 SQLite 세션 스토리지 - - Redis 세션 스토리지 - - SQLAlchemy 세션 스토리지 - - Dapr 상태 저장소 세션 스토리지 - - 암호화된 세션 스토리지 - - OpenAI Conversations 세션 스토리지 - - Responses 압축 세션 스토리지 - - `ModelSettings(store=False)`를 사용하는 무상태 Responses 압축 (`examples/memory/compaction_session_stateless_example.py`) - - 파일 기반 세션 스토리지 (`examples/memory/file_session.py`) - - 휴먼인더루프 (HITL)를 사용하는 파일 기반 세션 (`examples/memory/file_hitl_example.py`) - - 휴먼인더루프 (HITL)를 사용하는 SQLite 인메모리 세션 (`examples/memory/memory_session_hitl_example.py`) - - 휴먼인더루프 (HITL)를 사용하는 OpenAI Conversations 세션 (`examples/memory/openai_session_hitl_example.py`) - - 여러 세션에 걸친 HITL 승인/거부 시나리오 (`examples/memory/hitl_session_scenario.py`) - -- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** 사용자 지정 제공업체와 서드 파티 어댑터를 포함하여 SDK에서 OpenAI 이외의 모델을 사용하는 방법을 살펴봅니다. - -- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** SDK를 사용해 실시간 환경을 구축하는 방법을 보여 주는 예제는 다음과 같습니다. - - - 구조화된 텍스트 및 이미지 메시지를 사용하는 웹 애플리케이션 패턴 - - 명령줄 오디오 루프 및 재생 처리 - - WebSocket을 통한 Twilio Media Streams 통합 - - Realtime Calls API 연결 흐름을 사용하는 Twilio SIP 통합 - -- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 추론 콘텐츠를 다루는 방법을 보여 주는 예제는 다음과 같습니다. - - - Runner API를 사용하는 스트리밍 및 비스트리밍 추론 콘텐츠 (`examples/reasoning_content/runner_example.py`) - - OpenRouter를 통해 OSS 모델을 사용하는 추론 콘텐츠 (`examples/reasoning_content/gpt_oss_stream.py`) - - 기본 추론 콘텐츠 예제 (`examples/reasoning_content/main.py`) + - 파일 시스템 예제 + - Git 예제 + - MCP 프롬프트 서버 예제 + - SSE(Server-Sent Events) 예제 + - SSE 원격 서버 연결 (`examples/mcp/sse_remote_example`) + - Streamable HTTP 예제 + - Streamable HTTP 원격 연결 (`examples/mcp/streamable_http_remote_example`) + - Streamable HTTP용 사용자 지정 HTTP 클라이언트 팩토리 (`examples/mcp/streamablehttp_custom_client_example`) + - `MCPUtil.get_all_function_tools` 사용을 통한 모든 MCP 도구 사전 가져오기 (`examples/mcp/get_all_mcp_tools_example`) + - FastAPI 애플리케이션에서 `MCPServerManager` 사용 (`examples/mcp/manager_example`) + - MCP 도구 필터링 (`examples/mcp/tool_filter_example`) + +- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** 다음을 포함한 다양한 에이전트 메모리 구현 예제입니다. + + - SQLite 세션 스토리지 + - 고급 SQLite 세션 스토리지 + - Redis 세션 스토리지 + - SQLAlchemy 세션 스토리지 + - Dapr 상태 저장소 세션 스토리지 + - 암호화된 세션 스토리지 + - OpenAI Conversations 세션 스토리지 + - Responses 컴팩션 세션 스토리지 + - `ModelSettings(store=False)` 사용을 통한 상태 비저장 Responses 컴팩션 (`examples/memory/compaction_session_stateless_example.py`) + - 파일 기반 세션 스토리지 (`examples/memory/file_session.py`) + - 휴먼인더루프 (HITL)를 사용하는 파일 기반 세션 (`examples/memory/file_hitl_example.py`) + - 휴먼인더루프 (HITL)를 사용하는 SQLite 인메모리 세션 (`examples/memory/memory_session_hitl_example.py`) + - 휴먼인더루프 (HITL)를 사용하는 OpenAI Conversations 세션 (`examples/memory/openai_session_hitl_example.py`) + - 여러 세션에 걸친 HITL 승인/거부 시나리오 (`examples/memory/hitl_session_scenario.py`) + +- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** 사용자 지정 제공자와 서드 파티 어댑터를 포함하여 SDK에서 OpenAI 외 모델을 사용하는 방법을 살펴봅니다. + +- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** 다음을 포함하여 SDK로 실시간 경험을 구축하는 방법을 보여 주는 예제입니다. + + - 구조화된 텍스트 및 이미지 메시지를 사용하는 웹 애플리케이션 패턴 + - 명령줄 오디오 루프 및 재생 처리 + - WebSocket을 통한 Twilio Media Streams 통합 + - Realtime Calls API의 `attach` 흐름을 사용하는 Twilio SIP 통합 + +- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 추론 콘텐츠를 다루는 방법을 보여 주는 예제이며, 다음을 포함합니다. + + - Runner API에서 스트리밍 및 비스트리밍 방식으로 추론 콘텐츠 사용 (`examples/reasoning_content/runner_example.py`) + - OpenRouter를 통해 OSS 모델에서 추론 콘텐츠 사용 (`examples/reasoning_content/gpt_oss_stream.py`) + - 기본 추론 콘텐츠 예제 (`examples/reasoning_content/main.py`) - **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 복잡한 멀티 에이전트 리서치 워크플로를 보여 주는 간단한 딥 리서치 클론입니다. -- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 격리된 작업 공간에서 에이전트를 실행하는 예제는 다음과 같습니다. - - - 기본 샌드박스 에이전트 설정 (`examples/sandbox/basic.py`) - - Unix 로컬 및 Docker 샌드박스 수명 주기 예제 - - 샌드박스 기반 핸드오프 (`examples/sandbox/handoffs.py`) - - 샌드박스 메모리 및 스냅샷 재개 (`examples/sandbox/memory.py`) - - 도구로 노출된 샌드박스 에이전트 (`examples/sandbox/sandbox_agents_as_tools.py`) - -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** 다음과 같은 OpenAI 호스트하는 도구 및 실험적 Codex 도구를 구현하는 방법을 알아봅니다. - - - 웹 검색 및 필터가 적용된 웹 검색 - - 파일 검색 - - Code interpreter - - 파일 편집 및 승인을 지원하는 패치 적용 도구 (`examples/tools/apply_patch.py`) - - 승인 콜백을 사용하는 셸 도구 실행 (`examples/tools/shell.py`) - - 휴먼인더루프 (HITL) 인터럽션(중단 처리) 기반 승인을 사용하는 셸 도구 (`examples/tools/shell_human_in_the_loop.py`) - - 인라인 스킬을 사용하는 호스티드 컨테이너 셸 (`examples/tools/container_shell_inline_skill.py`) - - 스킬 참조를 사용하는 호스티드 컨테이너 셸 (`examples/tools/container_shell_skill_reference.py`) - - 로컬 스킬을 사용하는 로컬 셸 (`examples/tools/local_shell_skill.py`) - - 네임스페이스 및 지연된 도구를 사용하는 도구 검색 (`examples/tools/tool_search.py`) - - 동시 구조화 도구 호출을 사용하는 프로그래밍 방식 도구 호출 (`examples/tools/programmatic_tool_calling.py`) - - 컴퓨터 사용 - - 이미지 생성 - - 실험적 Codex 도구 워크플로 (`examples/tools/codex.py`) - - 실험적 Codex 동일 스레드 워크플로 (`examples/tools/codex_same_thread.py`) - -- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** 스트리밍 음성 예제를 포함하여 TTS 및 STT 모델을 사용하는 음성 에이전트 예제를 살펴봅니다. \ No newline at end of file +- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 격리된 작업 공간에서 에이전트를 실행하는 예제이며, 다음을 포함합니다. + + - 기본 샌드박스 에이전트 설정 (`examples/sandbox/basic.py`) + - Unix 로컬 및 Docker 샌드박스 수명 주기 예제 + - 샌드박스 기반 핸드오프 (`examples/sandbox/handoffs.py`) + - 샌드박스 메모리 및 스냅샷 재개 (`examples/sandbox/memory.py`) + - 도구로 노출된 샌드박스 에이전트 (`examples/sandbox/sandbox_agents_as_tools.py`) + +- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** OpenAI에서 호스팅하는 도구와 실험적 Codex 툴링을 구현하는 방법을 알아봅니다. 예제는 다음과 같습니다. + + - 웹 검색 및 필터를 적용한 웹 검색 + - 파일 검색 + - Code interpreter + - 파일 편집 및 승인을 지원하는 패치 적용 도구 (`examples/tools/apply_patch.py`) + - 승인 콜백을 사용하는 셸 도구 실행 (`examples/tools/shell.py`) + - 휴먼인더루프 (HITL) 인터럽션(중단 처리) 기반 승인을 사용하는 셸 도구 (`examples/tools/shell_human_in_the_loop.py`) + - 인라인 스킬을 사용하는 호스티드 컨테이너 셸 (`examples/tools/container_shell_inline_skill.py`) + - 스킬 참조를 사용하는 호스티드 컨테이너 셸 (`examples/tools/container_shell_skill_reference.py`) + - 로컬 스킬을 사용하는 로컬 셸 (`examples/tools/local_shell_skill.py`) + - 네임스페이스 및 지연 로딩을 사용하는 도구를 활용한 도구 검색 (`examples/tools/tool_search.py`) + - 동시 구조화 도구 호출을 사용하는 프로그래밍 방식 도구 호출 (`examples/tools/programmatic_tool_calling.py`) + - 컴퓨터 사용 + - 이미지 생성 + - 실험적 Codex 도구 워크플로 (`examples/tools/codex.py`) + - 동일한 Codex 대화 스레드를 재사용하는 실험적 Codex 워크플로 (`examples/tools/codex_same_thread.py`) + +- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** 스트리밍 음성 예제를 포함하여 TTS 및 STT 모델을 사용하는 음성 에이전트 예제를 확인하세요. \ No newline at end of file diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index 4ff34139f7..0d68dc9988 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -4,12 +4,12 @@ search: --- # 가드레일 -가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 속도가 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 것은 원하지 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시키고 고비용 모델이 실행되지 않도록 하여 시간과 비용을 절약할 수 있습니다(**블로킹 가드레일을 사용하는 경우에 해당합니다. 병렬 가드레일의 경우 가드레일이 완료되기 전에 고비용 모델이 이미 실행되기 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요**). +가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 것은 원하지 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 시간과 비용을 절약할 수 있습니다. 차단 실행은 비용이 많이 드는 모델이 시작되지 않도록 보장하지만, 병렬 실행에서는 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 시작되었을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요. 가드레일에는 두 가지 종류가 있습니다. -1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다 -2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다 +1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다. +2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다. ## 워크플로 경계 @@ -19,64 +19,64 @@ search: - **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. - **도구 가드레일**은 사용자 지정 함수 도구를 호출할 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. -관리자, 핸드오프 또는 위임된 전문가가 포함된 워크플로에서 각 사용자 지정 함수 도구 호출을 검사해야 한다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. +관리자, 핸드오프 또는 위임된 전문가가 포함된 워크플로에서 각 사용자 지정 함수 도구 호출 전후에 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. ## 입력 가드레일 입력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트에 전달된 것과 동일한 입력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 출력은 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 결과는 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다. 3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *첫 번째* 에이전트인 경우에만 실행됩니다. 가드레일을 `Runner.run`에 전달하지 않고 에이전트의 `guardrails` 속성에 지정하는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성에 도움이 됩니다. + 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로, 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 왜 `guardrails` 속성이 `Runner.run`에 전달되지 않고 에이전트에 있는지 궁금할 수 있습니다. 이는 가드레일이 대개 실제 에이전트와 관련되어 있기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로, 코드를 함께 배치하면 가독성에 유용합니다. ### 실행 모드 입력 가드레일은 두 가지 실행 모드를 지원합니다. -- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 같은 시점에 시작하므로 지연 시간이 가장 짧습니다. 그러나 가드레일 검사가 실패하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. +- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘이 동시에 시작되므로 지연 시간이 가장 짧습니다. 하지만 가드레일의 트립와이어가 트리거되면 취소되기 전에 에이전트가 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. -- **블로킹 실행**(`run_in_parallel=False`): 가드레일이 에이전트가 시작되기 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트가 전혀 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출에서 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. +- **차단 실행**(`run_in_parallel=False`): 가드레일이 에이전트가 시작되기 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 전혀 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하고 도구 호출로 인한 잠재적인 부작용을 방지하려는 경우에 적합합니다. ## 출력 가드레일 출력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트가 생성한 출력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 출력은 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 결과는 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다. +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]이 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *마지막* 에이전트인 경우에만 실행됩니다. 입력 가드레일과 마찬가지로 이렇게 하는 이유는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성에 도움이 됩니다. + 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로, 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로 가드레일은 대개 실제 에이전트와 관련되어 있기 때문에 이렇게 동작합니다. 에이전트마다 서로 다른 가드레일을 실행하므로, 코드를 함께 배치하면 가독성에 유용합니다. - 출력 가드레일은 항상 에이전트 실행이 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. + 출력 가드레일은 항상 에이전트가 완료된 후 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. ## 도구 가드레일 -도구 가드레일은 **함수 도구**를 래핑하고 실행 전후에 도구 호출을 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. +도구 가드레일은 **`FunctionTool` 인스턴스**를 래핑하며, 해당 도구 호출을 실행 전후에 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. - 입력 도구 가드레일은 도구가 실행되기 전에 실행되며, 호출을 건너뛰거나 출력을 메시지로 대체하거나 트립와이어를 발생시킬 수 있습니다. -- 출력 도구 가드레일은 도구가 실행된 후에 실행되며, 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. -- 함수 도구에 승인이 필요한 경우, 입력 도구 가드레일은 일반적으로 승인 후 실행 직전에 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 도구가 실행되기 전 승인 후에 다시 검사됩니다. -- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성한 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 통해 실행되므로, 도구 가드레일은 핸드오프 호출 자체에는 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, 현재 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 도구 가드레일 옵션을 직접 제공하지 않습니다. +- 출력 도구 가드레일은 도구가 실행된 후 실행되며, 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. +- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후, 실행 직전에 실행됩니다. 승인 대기 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 도구 실행 전 승인 후에 다시 검사됩니다. +- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 거치므로, 도구 가드레일은 핸드오프 호출 자체에 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 현재 도구 가드레일 옵션을 직접 노출하지 않습니다. -자세한 내용은 아래 코드 조각을 참조하세요. +자세한 내용은 아래 코드 스니펫을 참조하세요. ## 트립와이어 -에이전트 입력 또는 출력이 가드레일 검사를 통과하지 못하면 가드레일이 트립와이어를 통해 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 각각 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. +에이전트 입력 또는 출력이 가드레일을 통과하지 못하면 가드레일은 트립와이어로 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 이에 대응하는 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. -에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`는 트립와이어를 트리거한 가드레일을 나타냅니다. 러너가 발생시킨 입력 트립와이어의 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 여기에는 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어는 `exception.run_data.output_guardrail_results`를 통해 이에 상응하는 누적 결과를 제공합니다. +에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`은 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 입력 트립와이어를 발생시킨 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어는 `exception.run_data.output_guardrail_results`을 통해 이에 상응하는 누적 결과를 제공합니다. -반면 도구 트립와이어 예외는 트립와이어를 트리거한 `guardrail`과 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록에는 실패하기 전에 완료된 턴에서 누적된 결과가 보존되며, 트립와이어를 트리거한 결과는 예외의 `output`을 통해 확인할 수 있습니다. `MaxTurnsExceeded`와 같이 러너가 관리하는 다른 실패에서도 완료된 도구 가드레일 결과가 이 목록에 보존됩니다. `stream_events()`에서 예외가 발생한 후에도 스트리밍된 결과는 동일하게 누적된 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생하면 `run_data`는 `None`일 수 있습니다. +반면 도구 트립와이어 예외는 트리거한 `guardrail` 및 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록에는 실패 전에 완료된 턴에서 누적된 결과가 보존되며, 트리거한 결과는 예외의 `output`을 통해 확인할 수 있습니다. `MaxTurnsExceeded`과 같은 러너 관리형 실패도 완료된 도구 가드레일 결과를 이러한 목록에 보존합니다. `stream_events()`이 예외를 발생시킨 후 스트리밍된 결과는 누적된 동일한 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생한 경우 `run_data`은 `None`일 수 있습니다. ## 가드레일 구현 -입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행해 이를 구현합니다. +입력을 받고 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행하여 이를 구현합니다. ```python from pydantic import BaseModel @@ -130,7 +130,7 @@ async def main(): ``` 1. 이 에이전트를 가드레일 함수에서 사용합니다. -2. 에이전트의 입력/컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. +2. 에이전트의 입력/컨텍스트를 받고 결과를 반환하는 가드레일 함수입니다. 3. 가드레일 결과에 추가 정보를 포함할 수 있습니다. 4. 워크플로를 정의하는 실제 에이전트입니다. @@ -189,10 +189,10 @@ async def main(): 1. 실제 에이전트의 출력 유형입니다. 2. 가드레일의 출력 유형입니다. -3. 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. +3. 에이전트의 출력을 받고 결과를 반환하는 가드레일 함수입니다. 4. 워크플로를 정의하는 실제 에이전트입니다. -마지막으로 도구 가드레일의 예제입니다. +마지막으로 도구 가드레일의 예제는 다음과 같습니다. ```python import json diff --git a/docs/ko/handoffs.md b/docs/ko/handoffs.md index 9c330759a4..7e55bcb173 100644 --- a/docs/ko/handoffs.md +++ b/docs/ko/handoffs.md @@ -4,17 +4,17 @@ search: --- # 핸드오프 -핸드오프를 사용하면 에이전트가 작업을 다른 에이전트에 위임할 수 있습니다. 이는 서로 다른 에이전트가 각기 다른 영역을 전문적으로 처리하는 시나리오에서 특히 유용합니다. 예를 들어 고객 지원 앱에는 주문 상태, 환불, FAQ 등의 작업을 각각 전문적으로 처리하는 에이전트가 있을 수 있습니다. +핸드오프를 사용하면 에이전트가 다른 에이전트에 작업을 위임할 수 있습니다. 이는 서로 다른 에이전트가 각기 다른 영역을 전문적으로 처리하는 시나리오에서 특히 유용합니다. 예를 들어 고객 지원 앱에는 주문 상태, 환불, FAQ 등의 작업을 각각 전문적으로 처리하는 에이전트가 있을 수 있습니다. -핸드오프는 LLM에 도구로 표현됩니다. 따라서 `Refund Agent`라는 에이전트로 핸드오프하는 경우 도구의 이름은 `transfer_to_refund_agent`가 됩니다. +핸드오프는 LLM에 도구로 표시됩니다. 따라서 `Refund Agent`라는 에이전트로 핸드오프하는 경우 도구 이름은 `transfer_to_refund_agent`이 됩니다. ## 핸드오프 생성 모든 에이전트에는 [`handoffs`][agents.agent.Agent.handoffs] 매개변수가 있으며, `Agent`를 직접 받거나 핸드오프를 사용자 지정하는 `Handoff` 객체를 받을 수 있습니다. -일반 `Agent` 인스턴스를 전달하면 해당 인스턴스의 [`handoff_description`][agents.agent.Agent.handoff_description]이 설정된 경우 기본 도구 설명에 추가됩니다. 완전한 `handoff()` 객체를 작성하지 않고도 모델이 언제 해당 핸드오프를 선택해야 하는지 알려주는 데 사용할 수 있습니다. +일반 `Agent` 인스턴스를 전달하면 해당 인스턴스의 [`handoff_description`][agents.agent.Agent.handoff_description]가 설정된 경우 기본 도구 설명에 추가됩니다. 완전한 `handoff()` 객체를 작성하지 않고 모델이 해당 핸드오프를 선택해야 하는 시점을 알려주는 데 사용합니다. -Agents SDK에서 제공하는 [`handoff()`][agents.handoffs.handoff] 함수를 사용하여 핸드오프를 생성할 수 있습니다. 이 함수로 핸드오프할 에이전트와 선택적 재정의 및 입력 필터를 지정할 수 있습니다. +Agents SDK에서 제공하는 [`handoff()`][agents.handoffs.handoff] 함수를 사용하여 핸드오프를 생성할 수 있습니다. 이 함수를 사용하면 선택적 재정의 및 입력 필터와 함께 핸드오프할 에이전트를 지정할 수 있습니다. ### 기본 사용법 @@ -30,22 +30,22 @@ refund_agent = Agent(name="Refund agent") triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)]) ``` -1. 에이전트를 직접 사용할 수도 있고(`billing_agent`의 경우처럼), `handoff()` 함수를 사용할 수도 있습니다. +1. 에이전트를 직접 사용하거나(`billing_agent`에서처럼) `handoff()` 함수를 사용할 수 있습니다. ### `handoff()` 함수를 통한 핸드오프 사용자 지정 [`handoff()`][agents.handoffs.handoff] 함수를 사용하면 여러 항목을 사용자 지정할 수 있습니다. -- `agent`: 핸드오프할 대상 에이전트입니다. -- `tool_name_override`: 기본적으로 `transfer_to_`으로 해석되는 `Handoff.default_tool_name()` 함수가 사용됩니다. 이를 재정의할 수 있습니다. -- `tool_description_override`: `Handoff.default_tool_description()`의 기본 도구 설명을 재정의합니다 -- `on_handoff`: 핸드오프가 호출될 때 실행되는 콜백 함수입니다. 핸드오프가 호출된다는 사실을 확인하는 즉시 데이터 가져오기와 같은 작업을 시작하는 데 유용합니다. 이 함수는 에이전트 컨텍스트를 받으며, 선택적으로 LLM이 생성한 입력도 받을 수 있습니다. 입력 데이터는 `input_type` 매개변수로 제어됩니다. +- `agent`: 작업을 핸드오프할 대상 에이전트입니다. +- `tool_name_override`: 기본적으로 `transfer_to_`으로 해석되는 `Handoff.default_tool_name()` 함수를 사용합니다. 이를 재정의할 수 있습니다. +- `tool_description_override`: `Handoff.default_tool_description()`의 기본 도구 설명을 재정의합니다. +- `on_handoff`: 핸드오프가 호출될 때 실행되는 콜백 함수입니다. 핸드오프가 호출되는 것을 확인하는 즉시 데이터 가져오기 등을 시작할 때 유용합니다. 이 함수는 에이전트 컨텍스트를 받으며, 선택적으로 LLM이 생성한 입력도 받을 수 있습니다. 입력 데이터는 `input_type` 매개변수로 제어합니다. - `input_type`: 핸드오프 도구 호출 인수의 스키마입니다. 설정하면 파싱된 페이로드가 `on_handoff`에 전달됩니다. - `input_filter`: 다음 에이전트가 받는 입력을 필터링할 수 있습니다. 자세한 내용은 아래를 참조하세요. - `is_enabled`: 핸드오프의 활성화 여부입니다. 불리언 또는 불리언을 반환하는 함수일 수 있으므로 런타임에 핸드오프를 동적으로 활성화하거나 비활성화할 수 있습니다. -- `nest_handoff_history`: RunConfig 수준의 `nest_handoff_history` 설정을 호출별로 재정의하는 선택적 항목입니다. `None`이면 활성 실행 구성에 정의된 값이 대신 사용됩니다. +- `nest_handoff_history`: RunConfig 수준의 `nest_handoff_history` 설정을 핸드오프별로 재정의하는 선택적 항목입니다. 값이 `None`이면 활성 실행 구성에 정의된 값을 대신 사용합니다. -[`handoff()`][agents.handoffs.handoff] 헬퍼는 항상 전달한 특정 `agent`로 제어권을 이전합니다. 가능한 대상이 여러 개인 경우 대상마다 하나의 핸드오프를 등록하고 모델이 그중에서 선택하게 하세요. 호출 시 자체 핸드오프 코드에서 반환할 에이전트를 결정해야 하는 경우에만 사용자 지정 [`Handoff`][agents.handoffs.Handoff]를 사용하세요. +[`handoff()`][agents.handoffs.handoff] 헬퍼는 항상 전달된 특정 `agent`로 제어권을 이전합니다. 가능한 대상이 여러 개라면 대상마다 하나의 핸드오프를 등록하고 모델이 그중에서 선택하도록 합니다. 자체 핸드오프 코드가 호출 시점에 반환할 에이전트를 결정해야 하는 경우에만 사용자 지정 [`Handoff`][agents.handoffs.Handoff]을 사용합니다. ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## 핸드오프 입력 -특정 상황에서는 LLM이 핸드오프를 호출할 때 일부 데이터를 제공하도록 할 수 있습니다. 예를 들어 "에스컬레이션 에이전트"로 핸드오프한다고 가정해 보겠습니다. 모델이 사유를 제공하도록 하여 이를 기록할 수 있습니다. +특정 상황에서는 LLM이 핸드오프를 호출할 때 일부 데이터를 제공하도록 해야 할 수 있습니다. 예를 들어 "에스컬레이션 에이전트"로 핸드오프한다고 가정해 보겠습니다. 모델이 이유를 제공하도록 하여 이를 기록할 수 있습니다. ```python from pydantic import BaseModel @@ -87,44 +87,44 @@ handoff_obj = handoff( ) ``` -`input_type`은 핸드오프 도구 호출 자체의 인수를 설명합니다. SDK는 해당 스키마를 핸드오프 도구의 `parameters`로 모델에 노출하고, 반환된 JSON을 로컬에서 검증한 후 파싱된 값을 `on_handoff`에 전달합니다. +`input_type` 항목은 핸드오프 도구 호출 자체의 인수를 설명합니다. SDK는 해당 스키마를 핸드오프 도구의 `parameters`로 모델에 노출하고, 반환된 JSON을 로컬에서 검증한 후 파싱된 값을 `on_handoff`에 전달합니다. -이는 다음 에이전트의 기본 입력을 대체하지 않으며 다른 대상을 선택하지도 않습니다. [`handoff()`][agents.handoffs.handoff] 헬퍼는 여전히 래핑한 특정 에이전트로 제어권을 이전하며, [`input_filter`][agents.handoffs.Handoff.input_filter] 또는 중첩된 핸드오프 기록 설정으로 변경하지 않는 한 수신 에이전트는 계속 대화 기록을 볼 수 있습니다. +이는 다음 에이전트의 기본 입력을 대체하지 않으며 다른 대상을 선택하지도 않습니다. [`handoff()`][agents.handoffs.handoff] 헬퍼는 여전히 래핑한 특정 에이전트로 제어권을 이전하며, [`input_filter`][agents.handoffs.Handoff.input_filter] 또는 중첩 핸드오프 히스토리 설정을 사용하여 변경하지 않는 한 수신 에이전트는 계속 대화 히스토리를 확인합니다. -또한 `input_type`은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]와 별개입니다. 로컬에 이미 있는 애플리케이션 상태나 종속성이 아니라, 모델이 핸드오프 시점에 결정하는 메타데이터에 `input_type`을 사용하세요. +`input_type` 항목은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]와도 별개입니다. 이미 로컬에 있는 애플리케이션 상태나 종속성이 아니라, 핸드오프 시점에 모델이 결정하는 메타데이터에 `input_type`을 사용합니다. ### `input_type` 사용 시점 -핸드오프에 `reason`, `language`, `priority`, `summary`처럼 모델이 생성한 소량의 메타데이터가 필요한 경우 `input_type`을 사용하세요. 예를 들어 분류 에이전트는 `{ "reason": "duplicate_charge", "priority": "high" }`와 함께 환불 에이전트로 핸드오프할 수 있으며, 환불 에이전트가 작업을 이어받기 전에 `on_handoff`가 해당 메타데이터를 기록하거나 저장할 수 있습니다. +핸드오프에 `reason`, `language`, `priority`, `summary` 같은 소량의 모델 생성 메타데이터가 필요한 경우 `input_type`을 사용합니다. 예를 들어 분류 에이전트는 `{ "reason": "duplicate_charge", "priority": "high" }`와 함께 환불 에이전트로 핸드오프할 수 있으며, 환불 에이전트가 작업을 넘겨받기 전에 `on_handoff`에서 해당 메타데이터를 기록하거나 저장할 수 있습니다. -목적이 다르다면 다른 메커니즘을 선택하세요. +목적이 다른 경우에는 다른 메커니즘을 선택합니다. -- 기존 애플리케이션 상태와 종속성은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 넣으세요. [컨텍스트 가이드](context.md)를 참조하세요. -- 수신 에이전트에 표시되는 기록을 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter], [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 또는 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 사용하세요. -- 가능한 전문 에이전트가 여러 개인 경우 대상마다 하나의 핸드오프를 등록하세요. `input_type`은 선택된 핸드오프에 메타데이터를 추가할 수 있지만 대상 간 디스패치를 수행하지는 않습니다. +- 기존 애플리케이션 상태와 종속성은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 넣습니다. [컨텍스트 가이드](context.md)를 참조하세요. +- 수신 에이전트에 표시되는 히스토리를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter], [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 또는 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 사용합니다. +- 가능한 전문 에이전트가 여러 개라면 대상마다 하나의 핸드오프를 등록합니다. `input_type`을 사용하면 선택된 핸드오프에 메타데이터를 추가할 수 있지만 대상 간 디스패치를 수행하지는 않습니다. - 대화를 이전하지 않고 중첩된 전문 에이전트에 구조화된 입력을 제공하려면 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]을 사용하는 것이 좋습니다. [도구](tools.md#structured-input-for-tool-agents)를 참조하세요. ## 입력 필터 -핸드오프가 발생하면 새 에이전트가 대화를 이어받아 이전의 전체 대화 기록을 볼 수 있게 됩니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]를 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받고 새로운 `HandoffInputData`를 반환해야 하는 함수입니다. +핸드오프가 발생하면 새 에이전트가 대화를 넘겨받아 이전의 전체 대화 히스토리를 확인하는 것과 같습니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]을 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받고 새로운 `HandoffInputData`를 반환해야 하는 함수입니다. -[`HandoffInputData`][agents.handoffs.HandoffInputData]에는 다음이 포함됩니다. +[`HandoffInputData`][agents.handoffs.HandoffInputData]에는 다음 항목이 포함됩니다. -- `input_history`: `Runner.run(...)`이 시작되기 전의 입력 기록입니다. -- `pre_handoff_items`: 핸드오프가 호출된 에이전트 턴 이전에 생성된 항목입니다. -- `new_items`: 핸드오프 호출 및 핸드오프 출력 항목을 포함하여 현재 턴 중에 생성된 항목입니다. -- `input_items`: `new_items` 대신 다음 에이전트로 전달할 선택적 항목입니다. 세션 기록에서 `new_items`를 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. -- `run_context`: 핸드오프가 호출된 시점의 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper]입니다. +- `input_history`: `Runner.run(...)` 시작 전의 입력 히스토리 +- `pre_handoff_items`: 핸드오프가 호출된 에이전트 턴 이전에 생성된 항목 +- `new_items`: 핸드오프 호출 및 핸드오프 출력 항목을 포함해 현재 턴 중에 생성된 항목 +- `input_items`: `new_items` 대신 다음 에이전트에 전달할 선택적 항목으로, 세션 히스토리의 `new_items`은 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. +- `run_context`: 핸드오프가 호출된 시점의 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper] -중첩된 핸드오프는 선택적으로 활성화할 수 있는 베타 기능이며, 안정화가 진행되는 동안 기본적으로 비활성화되어 있습니다. [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]를 활성화하면 러너는 무손실 메시지 항목을 원래 위치에 보존하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축합니다. 생성된 각 요약 세그먼트에는 `` 래퍼가 사용되며, 이후의 핸드오프는 순서가 지정된 대화 기록을 다시 구성하기 전에 이전에 생성된 세그먼트를 평면화합니다. 세션, `RunState`, `RunResult.to_input_list()`는 동일한 항목이 두 번 추가되지 않도록 이 SDK 기본 기록으로 이동된 정확한 메시지 발생 항목을 추적합니다. 별개의 동일한 메시지는 계속 보존됩니다. [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 통해 자체 매핑 함수를 제공하면 기본 제공 세분화 기능을 사용하는 대신 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환할 수 있습니다. 이 선택적 기능은 핸드오프와 실행 어느 쪽에도 명시적인 `input_filter`가 없는 경우에만 적용되므로, 이미 페이로드를 사용자 지정하는 기존 코드(이 저장소의 코드 예제 포함)는 변경 없이 현재 동작을 유지합니다. [`handoff(...)`][agents.handoffs.handoff]에 `nest_handoff_history=True` 또는 `False`를 전달하여 단일 핸드오프의 중첩 동작을 재정의할 수 있으며, 이 값은 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 설정합니다. 생성된 요약 세그먼트의 래퍼 텍스트만 변경하려면 에이전트를 실행하기 전에 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 필요에 따라 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]도 호출할 수 있습니다. +중첩 핸드오프 히스토리는 옵트인 베타로 제공되며 안정화가 진행되는 동안 기본적으로 비활성화됩니다. [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]을 활성화하면 러너는 요약 가능한 히스토리를 순서가 지정된 어시스턴트 요약 세그먼트로 압축하면서, 무손실 메시지 항목은 원래 위치에 보존합니다. 생성된 각 요약 세그먼트는 `` 래퍼를 사용하며, 이후 핸드오프에서는 순서가 지정된 트랜스크립트를 다시 구성하기 전에 이전에 생성된 세그먼트를 평면화합니다. 세션, `RunState`, `RunResult.to_input_list()`는 이 SDK 기본 히스토리로 이동된 정확한 메시지 출현 항목을 추적하여 해당 항목이 두 번 추가되지 않도록 합니다. 별도로 존재하는 동일한 메시지는 계속 보존됩니다. 내장 세그먼트화 대신 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환하도록 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]을 통해 자체 매핑 함수를 제공할 수 있습니다. 이 옵트인은 핸드오프의 `input_filter`과 활성 실행의 `RunConfig.handoff_input_filter`가 모두 설정되지 않은 경우에만 적용되므로, 이미 페이로드를 사용자 지정하는 기존 코드(이 리포지토리의 코드 예제 포함)는 변경 없이 현재 동작을 유지합니다. [`handoff(...)`][agents.handoffs.handoff]에 `nest_handoff_history=True` 또는 `False`를 전달하여 단일 핸드오프의 중첩 동작을 재정의할 수 있으며, 이렇게 하면 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]이 설정됩니다. 생성된 요약 세그먼트의 래퍼 텍스트만 변경하려면 에이전트를 실행하기 전에 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출합니다. 이후 실행에서 기본 래퍼를 복원해야 하는 경우 실행 전에 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출합니다. -핸드오프와 활성 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]가 모두 필터를 정의한 경우, 해당 핸드오프에는 핸드오프별 [`input_filter`][agents.handoffs.Handoff.input_filter]가 우선 적용됩니다. +핸드오프와 활성 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]이 모두 필터를 정의한 경우 핸드오프별 [`input_filter`][agents.handoffs.Handoff.input_filter]이 해당 핸드오프에서 우선합니다. !!! note - 핸드오프는 단일 실행 내에서 유지됩니다. 입력 가드레일은 여전히 체인의 첫 번째 에이전트에만 적용되고 출력 가드레일은 최종 출력을 생성하는 에이전트에만 적용됩니다. 워크플로 내의 각 사용자 지정 함수 도구 호출 전후에 검사가 필요한 경우 도구 가드레일을 사용하세요. + 핸드오프는 단일 실행 내에서 유지됩니다. 입력 가드레일은 여전히 체인의 첫 번째 에이전트에만 적용되고, 출력 가드레일은 최종 출력을 생성하는 에이전트에만 적용됩니다. 워크플로 내의 각 사용자 지정 함수 도구 호출을 검사해야 하는 경우 도구 가드레일을 사용합니다. -기록에서 모든 도구 호출을 제거하는 것과 같은 몇 가지 일반적인 패턴은 [`agents.extensions.handoff_filters`][]에 구현되어 있습니다 +히스토리에서 모든 도구 호출을 제거하는 것과 같은 몇 가지 일반적인 패턴은 [`agents.extensions.handoff_filters`][]에 구현되어 있습니다. ```python from agents import Agent, handoff @@ -138,11 +138,11 @@ handoff_obj = handoff( ) ``` -1. 이렇게 하면 `FAQ agent`가 호출될 때 기록에서 모든 도구가 자동으로 제거됩니다. +1. `FAQ agent` 호출 시 히스토리에서 모든 도구 관련 항목을 자동으로 제거합니다. ## 권장 프롬프트 -LLM이 핸드오프를 올바르게 이해하도록 하려면 에이전트에 핸드오프 관련 정보를 포함하는 것이 좋습니다. [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][]에 권장 접두사가 있으며, [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][]를 호출하여 프롬프트에 권장 내용을 자동으로 추가할 수도 있습니다. +LLM이 핸드오프를 올바르게 이해하도록 하려면 에이전트에 핸드오프 관련 정보를 포함하는 것이 좋습니다. [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][]에 권장 접두사가 있으며, [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][]을 호출하여 프롬프트에 권장 데이터를 자동으로 추가할 수도 있습니다. ```python from agents import Agent diff --git a/docs/ko/human_in_the_loop.md b/docs/ko/human_in_the_loop.md index bd1e4602db..253e92d394 100644 --- a/docs/ko/human_in_the_loop.md +++ b/docs/ko/human_in_the_loop.md @@ -4,19 +4,19 @@ search: --- # 휴먼인더루프 (HITL) -휴먼인더루프 (HITL) 흐름을 사용하면 사람이 민감한 도구 호출을 승인하거나 거부할 때까지 에이전트 실행을 일시 중지할 수 있습니다. 도구는 승인이 필요한 시점을 선언하고, 실행 결과는 대기 중인 승인을 인터럽션(중단 처리)으로 표시하며, `RunState`를 사용하면 결정이 내려진 후 실행을 직렬화하고 재개할 수 있습니다. +휴먼인더루프 (HITL) 흐름을 사용하면 사람이 민감한 도구 호출을 승인하거나 거부할 때까지 에이전트 실행을 일시 중지할 수 있습니다. 도구는 승인이 필요한 시점을 선언하고, 실행 결과는 보류 중인 승인을 인터럽션(중단 처리)으로 표시하며, `RunState`을 사용하면 일시 중지된 실행을 직렬화하고 결정이 내려진 후 재개할 수 있습니다. -이 승인 적용 범위는 현재 최상위 에이전트로 제한되지 않고 전체 실행에 적용됩니다. 도구가 현재 에이전트에 속한 경우, 핸드오프를 통해 도달한 에이전트에 속한 경우, 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에 속한 경우 모두 동일한 패턴이 적용됩니다. 중첩된 `Agent.as_tool()`의 경우에도 인터럽션(중단 처리)은 외부 실행에 표시되므로, 외부 `RunState`에서 이를 승인하거나 거부한 다음 원래의 최상위 실행을 재개합니다. +이 승인 메커니즘의 범위는 현재 최상위 에이전트에 국한되지 않고 실행 전체에 적용됩니다. 도구가 현재 에이전트, 핸드오프를 통해 도달한 에이전트 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에 속하는 경우에도 동일한 패턴이 적용됩니다. 중첩된 `Agent.as_tool()`의 경우에도 인터럽션(중단 처리)은 외부 실행에 표시되므로, 외부 `RunState`에서 이를 승인하거나 거부한 다음 원래의 최상위 실행을 재개합니다. -`Agent.as_tool()`을 사용할 때는 두 계층에서 승인이 발생할 수 있습니다. 에이전트 도구 자체가 `Agent.as_tool(..., needs_approval=...)`을 통해 승인을 요구할 수 있으며, 중첩된 실행이 시작된 후 중첩된 에이전트 내부의 도구가 자체 승인을 요청할 수도 있습니다. 두 경우 모두 동일한 외부 실행의 인터럽션(중단 처리) 흐름을 통해 처리됩니다. +`Agent.as_tool()`를 사용하면 두 계층에서 승인이 발생할 수 있습니다. 에이전트 도구 자체가 `Agent.as_tool(..., needs_approval=...)`를 통해 승인을 요구할 수 있으며, 중첩된 실행이 시작된 후 중첩된 에이전트 내부의 도구가 자체 승인을 요청할 수도 있습니다. 두 경우 모두 동일한 외부 실행 인터럽션(중단 처리) 흐름을 통해 처리됩니다. -이 페이지에서는 `interruptions`를 통한 수동 승인 흐름에 중점을 둡니다. 애플리케이션이 코드에서 결정할 수 있다면 일부 도구 유형은 프로그래밍 방식의 승인 콜백도 지원하므로 실행을 일시 중지하지 않고 계속할 수 있습니다. +이 페이지에서는 `interruptions`을 통한 수동 승인 흐름을 중점적으로 설명합니다. 애플리케이션이 코드에서 결정할 수 있다면, 일부 도구 유형은 프로그래밍 방식의 승인 콜백도 지원하므로 실행을 일시 중지하지 않고 계속할 수 있습니다. ## 승인이 필요한 도구 표시 -항상 승인을 요구하려면 `needs_approval`을 `True`로 설정하고, 호출마다 결정하려면 비동기 함수를 제공합니다. 호출 가능 객체는 실행 컨텍스트, 파싱된 도구 매개변수, 도구 호출 ID를 받습니다. +항상 승인을 요구하려면 `needs_approval`을 `True`로 설정하거나, 호출별로 결정하는 비동기 함수를 제공합니다. 호출 가능 객체는 실행 컨텍스트, 파싱된 도구 매개변수, 도구 호출 ID를 전달받습니다. -SDK가 인수를 안전하게 검사할 수 없는 경우 호출 가능 승인 규칙은 승인 필요 상태로 안전하게 실패합니다. 인수가 잘못된 JSON이거나, 유효한 JSON이지만 객체가 아닌 경우(예: `null` 또는 목록), 혹은 `NaN`, `Infinity`, `-Infinity` 같은 비표준 상수를 포함하는 경우 호출 가능 객체는 실행되지 않으며 해당 호출에는 수동 승인이 필요합니다. 이 동작은 Runner 및 Realtime 도구 호출에서 동일합니다. +SDK가 인수를 안전하게 검사할 수 없는 경우 호출 가능 승인 규칙은 안전을 위해 승인을 요구합니다. 인수가 잘못된 JSON이거나, 유효한 JSON이지만 객체가 아니거나(예: `null` 또는 목록), `NaN`, `Infinity`, `-Infinity` 같은 비표준 상수를 포함하면 호출 가능 객체가 호출되지 않으며 해당 호출에는 수동 승인이 필요합니다. 이 동작은 Runner와 Realtime 도구 호출에서 동일합니다. ```python from agents import Agent @@ -44,28 +44,28 @@ agent = Agent( ) ``` -`needs_approval`은 [`function_tool`][agents.tool.function_tool], [`Agent.as_tool`][agents.agent.Agent.as_tool], [`ShellTool`][agents.tool.ShellTool], [`ApplyPatchTool`][agents.tool.ApplyPatchTool]에서 사용할 수 있습니다. 로컬 MCP 서버도 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]의 `require_approval`을 통해 승인을 지원합니다. 호스티드 MCP 서버는 `tool_config={"require_approval": "always"}` 및 선택적 `on_approval_request` 콜백과 함께 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 사용하여 승인을 지원합니다. 인터럽션(중단 처리)을 표시하지 않고 자동으로 승인하거나 거부하려면 셸 및 apply_patch 도구에 `on_approval` 콜백을 전달할 수 있습니다. +`needs_approval`은 [`function_tool`][agents.tool.function_tool], [`Agent.as_tool`][agents.agent.Agent.as_tool], [`ShellTool`][agents.tool.ShellTool], [`ApplyPatchTool`][agents.tool.ApplyPatchTool]에서 사용할 수 있습니다. 로컬 MCP 서버도 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]의 `require_approval`을 통해 승인을 지원합니다. 호스티드 MCP 서버는 [`HostedMCPTool`][agents.tool.HostedMCPTool]에서 `tool_config={"require_approval": "always"}`과 선택적인 `on_approval_request` 콜백을 통해 승인을 지원합니다. 인터럽션(중단 처리)을 표시하지 않고 자동 승인하거나 자동 거부하려는 경우 셸 및 apply_patch 도구에서 `on_approval` 콜백을 사용할 수 있습니다. -## 승인 흐름 +## 승인 흐름의 작동 방식 -1. 모델이 도구 호출을 생성하면 러너는 해당 승인 규칙(`needs_approval`, `require_approval` 또는 이에 상응하는 호스티드 MCP 설정)을 평가합니다. -2. 해당 도구 호출의 승인 결정이 이미 [`RunContextWrapper`][agents.run_context.RunContextWrapper]에 저장되어 있으면 러너는 확인을 요청하지 않고 계속 진행합니다. 호출별 승인은 특정 호출 ID에 한정됩니다. 실행의 나머지 부분에서 해당 도구에 대한 향후 호출에도 동일한 결정을 유지하려면 `always_approve=True` 또는 `always_reject=True`를 전달합니다. -3. 그렇지 않으면 실행이 일시 중지되고 `RunResult.interruptions`(또는 `RunResultStreaming.interruptions`)에 `agent.name`, `tool_name`, `arguments` 등의 세부 정보가 포함된 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 항목이 들어갑니다. 여기에는 핸드오프 후 또는 중첩된 `Agent.as_tool()` 실행 내부에서 발생한 승인도 포함됩니다. -4. `result.to_state()`를 사용하여 결과를 `RunState`로 변환하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 다음, 실행의 원래 최상위 에이전트인 `agent`와 함께 `Runner.run(agent, state)` 또는 `Runner.run_streamed(agent, state)`를 사용하여 재개합니다. -5. 재개된 실행은 중단된 지점부터 계속되며 새로운 승인이 필요하면 이 흐름으로 다시 진입합니다. +1. 모델이 도구 호출을 내보내면 Runner가 해당 승인 규칙(`needs_approval`, `require_approval` 또는 이에 대응하는 호스티드 MCP 규칙)을 평가합니다. +2. 해당 도구 호출에 대한 승인 결정이 이미 [`RunContextWrapper`][agents.run_context.RunContextWrapper]에 저장되어 있으면 Runner는 승인 요청 없이 진행합니다. 호출별 승인은 특정 호출 ID로 범위가 제한됩니다. 남은 실행 동안 해당 도구의 향후 호출에도 동일한 결정을 유지하려면 `always_approve=True` 또는 `always_reject=True`을 전달합니다. +3. 승인 규칙상 승인이 필요하지만 해당 도구 호출에 대한 결정이 저장되어 있지 않으면 실행이 일시 중지되고, `RunResult.interruptions`(또는 `RunResultStreaming.interruptions`)에 `agent.name`, `tool_name`, `arguments` 등의 세부 정보가 포함된 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 항목이 담깁니다. 여기에는 핸드오프 이후 또는 중첩된 `Agent.as_tool()` 실행 내부에서 발생한 승인도 포함됩니다. +4. `result.to_state()`를 사용하여 결과를 `RunState`로 변환하고, `state.approve(...)` 또는 `state.reject(...)`을 호출한 다음, `Runner.run(agent, state)` 또는 `Runner.run_streamed(agent, state)`으로 재개합니다. 여기서 `agent`는 해당 실행의 원래 최상위 에이전트입니다. +5. 재개된 실행은 중단된 지점부터 계속되며, 새 승인이 필요하면 이 흐름에 다시 진입합니다. -`always_approve=True` 또는 `always_reject=True`로 생성된 지속적 결정은 실행 상태에 저장되므로, 나중에 동일한 일시 중지된 실행을 재개할 때 `state.to_string()` / `RunState.from_string(...)` 및 `state.to_json()` / `RunState.from_json(...)`을 거쳐도 유지됩니다. +`always_approve=True` 또는 `always_reject=True`으로 생성된 고정 결정은 실행 상태에 저장되므로, 나중에 동일한 일시 중지 실행을 재개할 때 `state.to_string()` / `RunState.from_string(...)` 및 `state.to_json()` / `RunState.from_json(...)`을 거쳐도 유지됩니다. -대기 중인 모든 승인을 한 번에 처리할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 함께 포함될 수 있습니다. 일부 항목만 승인하거나 거부한 후 다시 실행하면 처리된 호출은 계속 진행되고, 처리되지 않은 호출은 `interruptions`에 남아 실행을 다시 일시 중지합니다. +보류 중인 모든 승인을 한 번에 처리할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 함께 포함될 수 있습니다. 일부 항목만 승인하거나 거부한 후 다시 실행하면 처리된 호출은 계속 진행되고, 처리되지 않은 호출은 `interruptions`에 남아 실행을 다시 일시 중지할 수 있습니다. ## 사용자 지정 거부 메시지 기본적으로 거부된 도구 호출은 SDK의 표준 거부 텍스트를 실행에 반환합니다. 다음 두 계층에서 이 메시지를 사용자 지정할 수 있습니다. -- 실행 전체의 대체 설정: 전체 실행에서 승인 거부 시 모델에 표시되는 기본 메시지를 제어하려면 [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]를 설정합니다. -- 호출별 재정의: 특정 거부 도구 호출 하나에 다른 메시지를 표시하려면 `state.reject(...)`에 `rejection_message=...`를 전달합니다. +- 실행 전체의 대체 동작: [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]를 설정하여 전체 실행에서 승인 거부 시 모델에 표시되는 기본 메시지를 제어합니다. +- 호출별 재정의: 특정 거부된 도구 호출 하나에 다른 메시지를 표시하려면 `state.reject(...)`에 `rejection_message=...`를 전달합니다. -둘 다 제공되면 호출별 `rejection_message`가 실행 전체 포매터보다 우선합니다. +둘 다 제공하면 호출별 `rejection_message`이 실행 전체 포매터보다 우선합니다. ```python from agents import RunConfig, ToolErrorFormatterArgs @@ -86,27 +86,27 @@ state.reject( ) ``` -두 계층을 함께 보여주는 전체 예제는 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)를 참조하세요. +두 계층을 함께 사용하는 전체 예제는 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)에서 확인할 수 있습니다. ## 자동 승인 결정 -수동 `interruptions`가 가장 일반적인 패턴이지만 유일한 방법은 아닙니다. +수동 `interruptions`은 가장 일반적인 패턴이지만 유일한 방식은 아닙니다. -- 로컬 [`ShellTool`][agents.tool.ShellTool] 및 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]은 `on_approval`을 사용하여 코드에서 즉시 승인하거나 거부할 수 있습니다. -- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 동일한 종류의 프로그래밍 방식 결정을 위해 `tool_config={"require_approval": "always"}`와 `on_approval_request`를 함께 사용할 수 있습니다. -- 일반 [`function_tool`][agents.tool.function_tool] 도구 및 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 이 페이지의 수동 인터럽션(중단 처리) 흐름을 사용합니다. +- 로컬 [`ShellTool`][agents.tool.ShellTool] 및 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]은 `on_approval`를 사용하여 코드에서 즉시 승인하거나 거부할 수 있습니다. +- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 `tool_config={"require_approval": "always"}`와 `on_approval_request`를 함께 사용하여 동일한 방식으로 프로그래밍 방식의 결정을 내릴 수 있습니다. +- 일반 [`function_tool`][agents.tool.function_tool] 도구와 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 이 페이지의 수동 인터럽션(중단 처리) 흐름을 사용합니다. -이러한 콜백이 결정을 반환하면 사람의 응답을 기다리기 위해 일시 중지하지 않고 실행을 계속합니다. Realtime 및 음성 세션 API의 경우 [Realtime 가이드](realtime/guide.md)의 승인 흐름을 참조하세요. +이러한 콜백이 결정을 반환하면 사람의 응답을 기다리기 위해 일시 중지하지 않고 실행이 계속됩니다. Realtime 및 음성 세션 API의 경우 [Realtime 가이드](realtime/guide.md)의 승인 흐름을 참조하세요. ## 스트리밍 및 세션 -동일한 인터럽션(중단 처리) 흐름이 스트리밍 실행에서도 작동합니다. 스트리밍 실행이 일시 중지된 후 반복자가 완료될 때까지 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events]를 계속 소비하고, [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]를 검사하여 처리한 다음, 재개된 출력도 계속 스트리밍하려면 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]로 재개합니다. 이 패턴의 스트리밍 버전은 [스트리밍](streaming.md)을 참조하세요. +동일한 인터럽션(중단 처리) 흐름이 스트리밍 실행에서도 작동합니다. 스트리밍된 실행이 일시 중지되면 반복자가 끝날 때까지 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events]을 계속 소비하고, [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]을 검사하여 처리한 다음, 재개된 출력도 계속 스트리밍하려면 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]으로 재개합니다. 이 패턴의 스트리밍 버전은 [스트리밍](streaming.md)을 참조하세요. -세션도 사용하고 있다면 `RunState`에서 재개할 때 동일한 세션 인스턴스를 계속 전달하거나, 동일한 백엔드 저장소를 가리키는 다른 세션 객체를 전달합니다. 그러면 재개된 턴이 저장된 동일한 대화 기록에 추가됩니다. 세션 수명 주기에 대한 자세한 내용은 [세션](sessions/index.md)을 참조하세요. +세션도 사용 중이라면 `RunState`에서 재개할 때 동일한 세션 인스턴스를 계속 전달하거나, 동일한 세션 ID와 백업 스토어를 사용하도록 구성된 다른 세션 객체를 전달합니다. 그러면 재개된 턴이 저장된 동일한 대화 기록에 추가됩니다. 세션 수명 주기에 대한 자세한 내용은 [세션](sessions/index.md)을 참조하세요. ## 예제: 일시 중지, 승인 및 재개 -아래 코드 조각은 JavaScript HITL 가이드와 동일한 흐름을 보여줍니다. 도구에 승인이 필요할 때 일시 중지하고, 상태를 디스크에 저장한 후 다시 불러오며, 결정을 받은 뒤 실행을 재개합니다. +아래 스니펫은 JavaScript HITL 가이드의 흐름을 재현합니다. 도구에 승인이 필요하면 실행을 일시 중지하고, 상태를 디스크에 저장한 후 다시 불러오며, 결정을 수집한 다음 실행을 재개합니다. ```python import asyncio @@ -171,35 +171,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -이 예제에서 `prompt_approval`은 `input()`을 사용하고 `run_in_executor(...)`로 실행되므로 동기 함수입니다. 승인 소스가 이미 비동기 방식인 경우(예: HTTP 요청 또는 비동기 데이터베이스 쿼리) `async def` 함수를 사용하고 직접 `await`할 수 있습니다. +이 예제에서 `prompt_approval`은 `input()`을 사용하고 `run_in_executor(...)`로 실행되므로 동기식입니다. 승인 소스가 이미 비동기 방식이라면(예: HTTP 요청 또는 비동기 데이터베이스 쿼리) `async def` 함수를 사용하고 이를 직접 `await`할 수 있습니다. -승인을 기다리는 동안 출력을 스트리밍하려면 `Runner.run_streamed`를 호출하고, 완료될 때까지 `result.stream_events()`를 소비한 다음 위에 표시된 것과 동일한 `result.to_state()` 및 재개 단계를 따릅니다. +승인을 위해 일시 중지될 수 있는 실행에서 스트리밍을 사용하려면 `Runner.run_streamed`을 호출하고 완료될 때까지 `result.stream_events()`을 소비한 다음, 위에 표시된 것과 동일하게 `result.to_state()` 및 재개 단계를 수행합니다. -## 저장소 패턴 및 예제 +## 저장소 패턴 및 코드 예제 -- **스트리밍 승인**: `examples/agent_patterns/human_in_the_loop_stream.py`는 `stream_events()`를 모두 소비한 다음 대기 중인 도구 호출을 승인하고 `Runner.run_streamed(agent, state)`로 재개하는 방법을 보여줍니다. -- **사용자 지정 거부 텍스트**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py`는 승인이 거부될 때 실행 수준의 `tool_error_formatter`와 호출별 `rejection_message` 재정의를 결합하는 방법을 보여줍니다. -- **Agents as tools 승인**: `Agent.as_tool(..., needs_approval=...)`은 위임된 에이전트 작업에 검토가 필요할 때 동일한 인터럽션(중단 처리) 흐름을 적용합니다. 중첩된 인터럽션(중단 처리)도 외부 실행에 표시되므로 중첩된 에이전트가 아닌 원래의 최상위 에이전트를 재개합니다. -- **로컬 셸 및 apply_patch 도구**: `ShellTool`과 `ApplyPatchTool`도 `needs_approval`을 지원합니다. 향후 호출을 위해 결정을 캐시하려면 `state.approve(interruption, always_approve=True)` 또는 `state.reject(..., always_reject=True)`를 사용합니다. 자동 결정에는 `on_approval`을 제공하고(`examples/tools/shell.py` 참조), 수동 결정에는 인터럽션(중단 처리)을 처리합니다(`examples/tools/shell_human_in_the_loop.py` 참조). 호스티드 셸 환경은 `needs_approval` 또는 `on_approval`을 지원하지 않습니다. [도구 가이드](tools.md)를 참조하세요. -- **로컬 MCP 서버**: MCP 도구 호출을 제어하려면 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp`에서 `require_approval`을 사용합니다(`examples/mcp/get_all_mcp_tools_example/main.py` 및 `examples/mcp/tool_filter_example/main.py` 참조). -- **호스티드 MCP 서버**: HITL을 강제하려면 `HostedMCPTool`의 `require_approval`을 `"always"`로 설정하고, 필요에 따라 자동 승인 또는 거부를 위한 `on_approval_request`를 제공합니다(`examples/hosted_mcp/human_in_the_loop.py` 및 `examples/hosted_mcp/on_approval.py` 참조). 신뢰할 수 있는 서버에는 `"never"`를 사용합니다(`examples/hosted_mcp/simple.py`). -- **세션 및 메모리**: 승인 및 대화 기록이 여러 턴에 걸쳐 유지되도록 `Runner.run`에 세션을 전달합니다. SQLite 및 OpenAI Conversations 세션 변형은 `examples/memory/memory_session_hitl_example.py` 및 `examples/memory/openai_session_hitl_example.py`에 있습니다. -- **실시간 에이전트**: Realtime 데모는 `RealtimeSession`의 `approve_tool_call` / `reject_tool_call`을 통해 도구 호출을 승인하거나 거부하는 WebSocket 메시지를 제공합니다. 서버 측 핸들러는 `examples/realtime/app/server.py`를, API 인터페이스는 [Realtime 가이드](realtime/guide.md#tool-approvals)를 참조하세요. +- **스트리밍 승인**: `examples/agent_patterns/human_in_the_loop_stream.py`은 `stream_events()`을 끝까지 소비한 다음, `Runner.run_streamed(agent, state)`로 재개하기 전에 보류 중인 도구 호출을 승인하는 방법을 보여줍니다. +- **사용자 지정 거부 텍스트**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py`은 승인이 거부될 때 실행 수준의 `tool_error_formatter`와 호출별 `rejection_message` 재정의를 결합하는 방법을 보여줍니다. +- **에이전트 도구 승인**: `Agent.as_tool(..., needs_approval=...)`은 위임된 에이전트 작업에 검토가 필요할 때 동일한 인터럽션(중단 처리) 흐름을 적용합니다. 중첩된 인터럽션(중단 처리)도 외부 실행에 표시되므로 중첩된 에이전트가 아니라 원래의 최상위 에이전트를 재개합니다. +- **로컬 셸 및 apply_patch 도구**: `ShellTool` 및 `ApplyPatchTool`도 `needs_approval`을 지원합니다. 남은 실행 동안 해당 도구의 향후 호출을 위해 결정을 캐시하려면 `state.approve(interruption, always_approve=True)` 또는 `state.reject(..., always_reject=True)`을 사용합니다. 자동 결정의 경우 `on_approval`을 제공합니다(`examples/tools/shell.py` 참조). 수동 결정의 경우 인터럽션(중단 처리)을 처리합니다(`examples/tools/shell_human_in_the_loop.py` 참조). 호스티드 셸 환경은 `needs_approval` 또는 `on_approval`을 지원하지 않습니다. [도구 가이드](tools.md)를 참조하세요. +- **로컬 MCP 서버**: MCP 도구 호출을 제한하려면 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp`에서 `require_approval`을 사용합니다(`examples/mcp/get_all_mcp_tools_example/main.py` 및 `examples/mcp/tool_filter_example/main.py` 참조). +- **호스티드 MCP 서버**: HITL을 강제하려면 `HostedMCPTool`에서 `tool_config={"require_approval": "always"}`을 설정하고, 선택적으로 `on_approval_request`을 제공하여 자동 승인하거나 거부합니다(`examples/hosted_mcp/human_in_the_loop.py` 및 `examples/hosted_mcp/on_approval.py` 참조). 신뢰할 수 있는 서버에는 `"never"`을 사용합니다(`examples/hosted_mcp/simple.py` 참조). +- **세션 및 메모리**: 승인과 대화 기록이 여러 턴에 걸쳐 유지되도록 `Runner.run`에 세션을 전달합니다. SQLite 및 OpenAI Conversations 세션 변형은 `examples/memory/memory_session_hitl_example.py`과 `examples/memory/openai_session_hitl_example.py`에 있습니다. +- **실시간 에이전트**: Realtime 데모는 `RealtimeSession`의 `approve_tool_call` / `reject_tool_call`을 통해 도구 호출을 승인하거나 거부하는 WebSocket 메시지를 제공합니다. 서버 측 핸들러는 `examples/realtime/app/server.py`을, API 인터페이스는 [Realtime 가이드](realtime/guide.md#tool-approvals)를 참조하세요. ## 장기 실행 승인 -`RunState`는 지속성을 갖도록 설계되었습니다. `state.to_json()` 또는 `state.to_string()`을 사용하여 대기 중인 작업을 데이터베이스나 큐에 저장하고, 나중에 `RunState.from_json(...)` 또는 `RunState.from_string(...)`을 사용하여 다시 생성합니다. +`RunState`은 지속성을 고려하여 설계되었습니다. `state.to_json()` 또는 `state.to_string()`을 사용하여 보류 중인 작업을 데이터베이스나 큐에 저장하고, 나중에 `RunState.from_json(...)` 또는 `RunState.from_string(...)`로 다시 생성합니다. 유용한 직렬화 옵션은 다음과 같습니다. - `context_serializer`: 매핑이 아닌 컨텍스트 객체가 직렬화되는 방식을 사용자 지정합니다. -- `context_deserializer`: `RunState.from_json(...)` 또는 `RunState.from_string(...)`으로 상태를 불러올 때 매핑이 아닌 컨텍스트 객체를 다시 구성합니다. -- `strict_context=True`: 컨텍스트가 이미 매핑이거나 적절한 직렬화 도구/역직렬화 도구를 제공한 경우가 아니면 직렬화 또는 역직렬화가 실패하도록 합니다. -- `context_override`: 상태를 불러올 때 직렬화된 컨텍스트를 교체합니다. 원래의 컨텍스트 객체를 복원하지 않으려는 경우 유용하지만, 이미 직렬화된 페이로드에서 해당 컨텍스트를 제거하지는 않습니다. -- `include_tracing_api_key=True`: 재개된 작업이 동일한 자격 증명으로 트레이스를 계속 내보내야 하는 경우 직렬화된 트레이스 페이로드에 트레이싱 API 키를 포함합니다. +- `context_deserializer`: `RunState.from_json(...)` 또는 `RunState.from_string(...)`로 상태를 불러올 때 매핑이 아닌 컨텍스트 객체를 다시 구성합니다. +- `strict_context=True`: 컨텍스트가 이미 매핑이거나 `context_serializer`을 제공한 경우가 아니면 직렬화에 실패합니다. 컨텍스트가 이미 매핑이거나 `context_deserializer`을 제공한 경우가 아니면 역직렬화에 실패합니다. +- `context_override`: 상태를 불러올 때 직렬화된 컨텍스트를 대체합니다. 원래 컨텍스트 객체를 복원하지 않으려는 경우 유용하지만, 이미 직렬화된 페이로드에서 해당 컨텍스트를 제거하지는 않습니다. +- `include_tracing_api_key=True`: 재개된 작업이 동일한 자격 증명으로 트레이스를 계속 내보내야 할 때 직렬화된 트레이스 페이로드에 트레이싱 API 키를 포함합니다. -직렬화된 실행 상태에는 애플리케이션 컨텍스트뿐 아니라 승인, 사용량, 직렬화된 `tool_input`, 중첩된 Agents as tools 재개 정보, 트레이스 메타데이터, 서버에서 관리하는 대화 설정 등 SDK가 관리하는 런타임 메타데이터가 포함됩니다. 직렬화된 상태를 저장하거나 전송하려는 경우 `RunContextWrapper.context`를 영구 저장되는 데이터로 취급하고, 의도적으로 상태와 함께 전달하려는 경우가 아니라면 비밀 정보를 넣지 마세요. +직렬화된 실행 상태에는 애플리케이션 컨텍스트뿐 아니라 승인, 사용량, 직렬화된 `tool_input`, 중첩된 에이전트 도구 실행의 재개 정보, 트레이스 메타데이터, 서버 관리형 대화 설정 등 SDK가 관리하는 런타임 메타데이터도 포함됩니다. 직렬화된 상태를 저장하거나 전송할 계획이라면 `RunContextWrapper.context`을 영구 저장 데이터로 취급하고, 의도적으로 상태와 함께 전달하려는 경우가 아니라면 그 안에 비밀 정보를 넣지 마세요. -## 대기 중인 작업의 버전 관리 +## 보류 중인 작업의 버전 관리 -승인이 장시간 대기할 수 있다면 직렬화된 상태와 함께 에이전트 정의 또는 SDK의 버전 마커를 저장합니다. 그러면 모델, 프롬프트 또는 도구 정의가 변경될 때 비호환성을 방지하도록 역직렬화를 일치하는 코드 경로로 라우팅할 수 있습니다. \ No newline at end of file +승인이 한동안 보류될 수 있다면 직렬화된 상태와 함께 에이전트 정의 또는 SDK의 버전 표시자를 저장합니다. 그러면 모델, 프롬프트 또는 도구 정의가 변경될 때 비호환성을 방지하도록 역직렬화 과정을 일치하는 코드 경로로 라우팅할 수 있습니다. \ No newline at end of file diff --git a/docs/ko/index.md b/docs/ko/index.md index ab8d3d3aec..6acb6674fe 100644 --- a/docs/ko/index.md +++ b/docs/ko/index.md @@ -4,52 +4,52 @@ search: --- # OpenAI Agents SDK -[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)를 사용하면 최소한의 추상화만 제공하는 가볍고 사용하기 쉬운 패키지로 에이전트형 AI 앱을 구축할 수 있습니다. 이전 에이전트 실험 프로젝트인 [Swarm](https://github.com/openai/swarm/tree/main)을 프로덕션 환경에서 사용할 수 있도록 개선한 버전입니다. Agents SDK에는 매우 적은 수의 기본 구성 요소가 있습니다: +[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)를 사용하면 추상화를 최소화한 가볍고 사용하기 쉬운 패키지로 에이전트 기반 AI 앱을 구축할 수 있습니다. 이는 이전의 에이전트 실험 프로젝트인 [Swarm](https://github.com/openai/swarm/tree/main)을 프로덕션 환경에 사용할 수 있도록 개선한 버전입니다. Agents SDK는 매우 적은 수의 기본 구성 요소로 이루어져 있습니다. - **에이전트**: 지침과 도구를 갖춘 LLM -- **Agents as tools / 핸드오프**: 에이전트가 특정 작업을 다른 에이전트에게 위임할 수 있도록 하는 기능 -- **가드레일**: 에이전트 입력과 출력의 검증을 지원하는 기능 +- **Agents as tools / 핸드오프**: 에이전트가 특정 작업을 다른 에이전트에 위임할 수 있도록 하는 기능 +- **가드레일**: 에이전트의 입력과 출력을 검증할 수 있도록 하는 기능 -이러한 기본 구성 요소는 Python과 함께 사용하면 도구와 에이전트 간의 복잡한 관계를 표현하기에 충분히 강력하며, 가파른 학습 곡선 없이 실제 애플리케이션을 구축할 수 있게 합니다. 또한 SDK에는 에이전트형 흐름을 시각화하고 디버깅할 수 있는 내장 **트레이싱** 기능이 포함되어 있으며, 이를 통해 흐름을 평가하고 애플리케이션에 맞게 모델을 미세 조정할 수도 있습니다. +이러한 기본 구성 요소를 Python과 함께 사용하면 도구와 에이전트 간의 복잡한 관계를 표현할 수 있으며, 가파른 학습 곡선 없이 실제 애플리케이션을 구축할 수 있습니다. 또한 SDK에는 에이전트 기반 흐름을 시각화하고 디버깅할 뿐만 아니라 평가하고 애플리케이션에 맞게 모델을 파인튜닝할 수도 있는 **트레이싱** 기능이 내장되어 있습니다. ## Agents SDK를 사용하는 이유 -SDK는 다음 두 가지 설계 원칙을 따릅니다: +SDK는 다음 두 가지 설계 원칙을 따릅니다. -1. 사용할 가치가 있을 만큼 충분한 기능을 제공하면서도 빠르게 학습할 수 있도록 기본 구성 요소를 최소화합니다. -2. 별도의 설정 없이도 원활하게 작동하지만, 동작을 원하는 대로 세밀하게 사용자 지정할 수 있습니다. +1. 사용할 가치가 있을 만큼 충분한 기능을 제공하면서도, 빠르게 배울 수 있도록 기본 구성 요소의 수를 최소화합니다. +2. 별도의 설정 없이도 원활하게 작동하면서, 필요한 동작을 정확하게 맞춤 설정할 수 있습니다. -SDK의 주요 기능은 다음과 같습니다: +SDK의 주요 기능은 다음과 같습니다. -- **에이전트**: instructions, 도구, 가드레일, 핸드오프와 작업이 완료될 때까지 계속되는 내장 루프를 갖춘 에이전트를 구축합니다. -- **샌드박스 에이전트**: 매니페스트에 정의된 파일, 샌드박스 클라이언트 선택 기능, 재개 가능한 샌드박스 세션을 갖춘 실제 격리 작업 공간에서 전문 에이전트를 실행합니다. -- **실시간 에이전트**: `gpt-realtime-2.1`, 자동 인터럽션(중단 처리) 감지, 컨텍스트 관리, 가드레일 등을 활용해 강력한 음성 에이전트를 구축합니다. -- **음성 에이전트**: 음성-텍스트 변환, 에이전트 워크플로, 텍스트-음성 변환을 결합한 음성 파이프라인을 구축합니다. -- **파이썬 우선**: 새로운 추상화를 학습하는 대신 내장 언어 기능을 사용하여 에이전트를 오케스트레이션하고 연결합니다. -- **Agents as tools / 핸드오프**: 여러 에이전트 간의 작업을 조율하고 위임하기 위한 강력한 메커니즘입니다. -- **가드레일**: 에이전트 실행과 병렬로 입력 검증 및 안전성 검사를 수행하고, 검사를 통과하지 못하면 빠르게 실패 처리합니다. +- **에이전트**: 지침, 도구, 가드레일, 핸드오프와 작업이 완료될 때까지 계속 실행되는 내장 루프를 사용하여 에이전트를 구축합니다. +- **샌드박스 에이전트**: 실제 격리된 워크스페이스에서 전문 에이전트를 실행합니다. 샌드박스 에이전트는 매니페스트에 정의된 파일, 샌드박스 클라이언트 선택, 재개 가능한 샌드박스 세션을 지원합니다. +- **실시간 에이전트**: `gpt-realtime-2.1`, 자동 인터럽션 감지, 컨텍스트 관리, 가드레일 등을 활용하여 강력한 음성 에이전트를 구축합니다. +- **음성 에이전트**: 음성 텍스트 변환, 에이전트 워크플로, 텍스트 음성 변환을 결합한 음성 파이프라인을 구축합니다. +- **파이썬 우선**: 새로운 추상화를 학습할 필요 없이 내장된 언어 기능을 사용하여 에이전트를 오케스트레이션하고 연결합니다. +- **Agents as tools / 핸드오프**: 여러 에이전트 간의 작업을 조율하고 위임하는 강력한 메커니즘입니다. +- **가드레일**: 에이전트 실행과 병렬로 입력 검증 및 안전성 검사를 수행하고, 검사를 통과하지 못하면 즉시 실패 처리합니다. - **함수 도구**: 자동 스키마 생성과 Pydantic 기반 검증을 통해 모든 Python 함수를 도구로 변환합니다. -- **MCP 서버 도구 호출**: 함수 도구와 동일한 방식으로 작동하는 내장 MCP 서버 도구 통합입니다. +- **MCP 서버 도구 호출**: 원격 MCP 도구를 함수 도구와 함께 에이전트에 제공하는 내장 통합 기능입니다. - **세션**: 에이전트 루프 내에서 작업 컨텍스트를 유지하기 위한 영구 메모리 계층입니다. -- **휴먼인더루프 (HITL)**: 여러 에이전트 실행에 사람을 참여시키기 위한 내장 메커니즘입니다. -- **트레이싱**: 워크플로를 시각화, 디버깅 및 모니터링하기 위한 내장 트레이싱 기능으로, OpenAI의 평가, 미세 조정 및 증류 도구 모음을 지원합니다. +- **휴먼인더루프 (HITL)**: 에이전트 실행 중 사람이 참여할 수 있도록 하는 내장 메커니즘입니다. +- **트레이싱**: 워크플로를 시각화하고 디버깅하며 모니터링하기 위한 내장 트레이싱 기능으로, OpenAI의 평가, 파인튜닝, 증류 도구 모음을 지원합니다. -## Agents SDK와 Responses API 비교 +## Agents SDK와 Responses API의 선택 -SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 모델 호출을 둘러싼 더 높은 수준의 런타임을 추가로 제공합니다. +SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 모델 호출을 더 높은 수준의 런타임으로 래핑합니다. -다음과 같은 경우 Responses API를 직접 사용하세요: +다음과 같은 경우 Responses API를 직접 사용합니다. -- 루프, 도구 디스패치 및 상태 처리를 직접 제어하려는 경우 -- 워크플로가 단기적으로 실행되며 주로 모델 응답을 반환하는 데 중점을 두는 경우 +- 루프, 도구 디스패치, 상태 처리를 직접 관리하려는 경우 +- 워크플로가 단기적으로 실행되며 주로 모델의 응답을 반환하는 경우 -다음과 같은 경우 Agents SDK를 사용하세요: +다음과 같은 경우 Agents SDK를 사용합니다. - 런타임에서 턴, 도구 실행, 가드레일, 핸드오프 또는 세션을 관리하도록 하려는 경우 -- 에이전트가 결과물을 생성하거나 여러 단계에 걸쳐 조율된 방식으로 작동해야 하는 경우 -- [샌드박스 에이전트](sandbox_agents.md)를 통해 실제 작업 공간이나 재개 가능한 실행이 필요한 경우 +- 에이전트가 결과물을 생성하거나 조율된 여러 단계에 걸쳐 작동해야 하는 경우 +- [샌드박스 에이전트](sandbox_agents.md)를 통해 실제 워크스페이스 또는 재개 가능한 실행이 필요한 경우 -둘 중 하나만 전역적으로 선택할 필요는 없습니다. 많은 애플리케이션이 관리형 워크플로에는 SDK를 사용하고, 저수준 경로에는 Responses API를 직접 호출합니다. +전체 애플리케이션에서 하나만 선택할 필요는 없습니다. 많은 애플리케이션이 관리형 워크플로에는 SDK를 사용하고, 저수준 경로에는 Responses API를 직접 호출합니다. ## 설치 @@ -72,7 +72,7 @@ print(result.final_output) # Infinite loop's dance. ``` -(_이를 실행하는 경우 `OPENAI_API_KEY` 환경 변수를 설정했는지 확인하세요_) +(_이를 실행하려면 `OPENAI_API_KEY` 환경 변수를 설정해야 합니다_) ```bash export OPENAI_API_KEY=sk-... @@ -80,23 +80,23 @@ export OPENAI_API_KEY=sk-... ## 시작 안내 -- [빠른 시작](quickstart.md)을 통해 첫 번째 텍스트 기반 에이전트를 구축합니다. +- [빠른 시작](quickstart.md)에서 첫 번째 텍스트 기반 에이전트를 구축합니다. - 그런 다음 [에이전트 실행](running_agents.md#choose-a-memory-strategy)에서 턴 간 상태를 유지할 방법을 결정합니다. -- 작업이 실제 파일, 리포지토리 또는 에이전트별로 격리된 작업 공간 상태에 의존하는 경우 [샌드박스 에이전트 빠른 시작](sandbox_agents.md)을 읽어보세요. -- 핸드오프와 관리자 스타일 오케스트레이션 중 하나를 결정하려는 경우 [에이전트 오케스트레이션](multi_agent.md)을 읽어보세요. +- 작업이 실제 파일, 리포지토리 또는 에이전트별로 격리된 워크스페이스 상태에 의존한다면 [샌드박스 에이전트 빠른 시작](sandbox_agents.md)을 읽어 보세요. +- 핸드오프와 관리자 스타일 오케스트레이션 중 하나를 선택하려면 [에이전트 오케스트레이션](multi_agent.md)을 읽어 보세요. ## 경로 선택 -수행하려는 작업은 알고 있지만 어떤 페이지에서 설명하는지 모를 때 이 표를 사용하세요. +수행하려는 작업은 알지만 어느 페이지에서 설명하는지 모를 때 이 표를 사용하세요. | 목표 | 시작 지점 | | --- | --- | | 첫 번째 텍스트 에이전트를 구축하고 전체 실행 과정 확인 | [빠른 시작](quickstart.md) | -| 함수 도구, 호스티드 툴 또는 agents as tools 추가 | [도구](tools.md) | -| 실제 격리 작업 공간에서 코딩, 검토 또는 문서 작업 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) 및 [샌드박스 클라이언트](sandbox/clients.md) | +| 함수 도구, 호스티드 툴 또는 Agents as tools 추가 | [도구](tools.md) | +| 실제 격리된 워크스페이스에서 코딩, 검토 또는 문서 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) 및 [샌드박스 클라이언트](sandbox/clients.md) | | 핸드오프와 관리자 스타일 오케스트레이션 중 선택 | [에이전트 오케스트레이션](multi_agent.md) | | 턴 간 메모리 유지 | [에이전트 실행](running_agents.md#choose-a-memory-strategy) 및 [세션](sessions/index.md) | -| OpenAI 모델, WebSocket 트랜스포트 또는 OpenAI 외 제공업체 사용 | [모델](models/index.md) | -| 출력, 실행 항목, 인터럽션(중단 처리) 및 재개 상태 검토 | [결과](results.md) | -| `gpt-realtime-2.1`을 사용하는 저지연 음성 에이전트 구축 | [실시간 에이전트 빠른 시작](realtime/quickstart.md) 및 [실시간 트랜스포트](realtime/transport.md) | -| 음성-텍스트 변환 / 에이전트 / 텍스트-음성 변환 파이프라인 구축 | [음성 파이프라인 빠른 시작](voice/quickstart.md) | \ No newline at end of file +| OpenAI 모델, WebSocket 전송 또는 OpenAI 이외의 제공업체 사용 | [모델](models/index.md) | +| 출력, 실행 항목, 인터럽션(중단 처리), 재개 상태 검토 | [결과](results.md) | +| `gpt-realtime-2.1`를 사용하여 지연 시간이 짧은 음성 에이전트 구축 | [실시간 에이전트 빠른 시작](realtime/quickstart.md) 및 [실시간 전송](realtime/transport.md) | +| 음성 텍스트 변환 / 에이전트 / 텍스트 음성 변환 파이프라인 구축 | [음성 파이프라인 빠른 시작](voice/quickstart.md) | \ No newline at end of file diff --git a/docs/ko/mcp.md b/docs/ko/mcp.md index e93dd32abd..52d21834d6 100644 --- a/docs/ko/mcp.md +++ b/docs/ko/mcp.md @@ -5,34 +5,34 @@ search: # Model context protocol (MCP) [Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)은 애플리케이션이 언어 모델에 도구와 -컨텍스트를 제공하는 방식을 표준화합니다. 공식 문서에서는 다음과 같이 설명합니다. +컨텍스트를 노출하는 방식을 표준화합니다. 공식 문서에서는 다음과 같이 설명합니다. > MCP는 애플리케이션이 LLM에 컨텍스트를 제공하는 방식을 표준화하는 개방형 프로토콜입니다. MCP를 AI -> 애플리케이션용 USB-C 포트라고 생각하면 됩니다. USB-C가 기기를 다양한 주변 장치 및 액세서리에 연결하는 표준화된 방식을 제공하는 것처럼, MCP는 +> 애플리케이션용 USB-C 포트라고 생각하면 됩니다. USB-C가 기기를 다양한 주변 장치 및 액세서리에 연결하는 표준화된 방식을 제공하듯이, MCP는 > AI 모델을 다양한 데이터 소스와 도구에 연결하는 표준화된 방식을 제공합니다. -Agents Python SDK는 여러 MCP 전송 방식을 지원합니다. 따라서 기존 MCP 서버를 재사용하거나 자체 서버를 구축하여 파일 시스템, HTTP 또는 커넥터 기반 도구를 에이전트에 제공할 수 있습니다. +Python용 Agents SDK는 여러 MCP 전송 방식을 지원합니다. 따라서 기존 MCP 서버를 재사용하거나 자체 서버를 구축하여 파일 시스템, HTTP 또는 커넥터 기반 도구를 에이전트에 노출할 수 있습니다. !!! warning "연결 전 MCP 서버 신뢰성 확인" - MCP 도구는 모델 컨텍스트의 데이터를 노출하고 사용자가 제공한 자격 증명으로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 자격 증명을 사용하며, 액세스 토큰은 URL이 아닌 인증 필드 또는 헤더에 보관하고, 민감한 작업에는 승인을 요구해야 합니다. [OpenAI MCP 보안 지침](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)을 참고하세요. + MCP 도구는 모델 컨텍스트의 데이터를 노출하고 제공된 인증 정보로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 인증 정보를 사용하며, 액세스 토큰은 URL이 아닌 authorization 필드나 헤더에 보관하고, 민감한 작업에는 승인을 요구해야 합니다. [OpenAI MCP 보안 가이드](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)를 참고하세요. ## MCP 통합 선택 -MCP 서버를 에이전트에 연결하기 전에 도구 호출을 어디에서 실행할지와 접근 가능한 전송 방식을 결정해야 합니다. 아래 표에는 Python SDK가 지원하는 옵션이 요약되어 있습니다. +MCP 서버를 에이전트에 연결하기 전에 도구 호출을 어디에서 실행할지와 어떤 전송 방식에 접근할 수 있는지 결정해야 합니다. 아래 표에는 Python SDK가 지원하는 옵션이 요약되어 있습니다. -| 필요한 기능 | 권장 옵션 | +| 필요한 작업 | 권장 옵션 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI의 Responses API가 모델을 대신해 공개적으로 접근 가능한 MCP 서버를 호출하도록 설정| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | -| 로컬 또는 원격에서 직접 실행하는 스트리밍 가능 HTTP 서버에 연결 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 통한 **스트리밍 가능 HTTP MCP 서버** | -| Server-Sent Events를 사용하는 HTTP를 구현한 서버와 통신 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **SSE 기반 HTTP MCP 서버** | -| 로컬 프로세스를 실행하고 stdin/stdout으로 통신 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 통한 **stdio MCP 서버** | +| OpenAI Responses API가 모델을 대신하여 공개적으로 접근 가능한 MCP 서버를 호출하도록 함| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | +| 로컬 또는 원격에서 실행하는 Streamable HTTP 서버에 연결 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 통한 **Streamable HTTP MCP 서버** | +| Server-Sent Events 방식의 HTTP를 구현한 서버와 통신 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **SSE 기반 HTTP MCP 서버** | +| 로컬 프로세스를 실행하고 stdin/stdout을 통해 통신 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 통한 **stdio MCP 서버** | -아래 섹션에서는 각 옵션의 구성 방법과 특정 전송 방식을 선택해야 하는 경우를 설명합니다. +아래 섹션에서는 각 옵션과 구성 방법, 각 전송 방식을 선택해야 하는 경우를 설명합니다. ## 에이전트 수준 MCP 구성 -전송 방식을 선택하는 것 외에도 `Agent.mcp_config`를 설정하여 MCP 도구가 준비되는 방식을 조정할 수 있습니다. +전송 방식을 선택하는 것 외에도 `Agent.mcp_config`을 설정하여 MCP 도구의 준비 방식을 조정할 수 있습니다. ```python from agents import Agent @@ -54,31 +54,31 @@ agent = Agent( 참고: -- `convert_schemas_to_strict`는 최선의 방식으로 변환을 시도합니다. 스키마를 변환할 수 없으면 원래 스키마가 사용됩니다. +- `convert_schemas_to_strict`은 최선형 방식으로 동작합니다. 스키마를 변환할 수 없으면 원래 스키마를 사용합니다. - `failure_error_function`은 MCP 도구 호출 실패가 모델에 표시되는 방식을 제어합니다. - `failure_error_function`을 설정하지 않으면 SDK는 기본 도구 오류 포매터를 사용합니다. -- 서버 수준의 `failure_error_function`은 해당 서버에 대한 `Agent.mcp_config["failure_error_function"]`을 재정의합니다. -- `include_server_in_tool_names`는 선택적으로 활성화해야 합니다. 활성화하면 각 로컬 MCP 도구가 결정론적인 서버 접두사 이름으로 모델에 제공되므로, 여러 MCP 서버가 동일한 이름의 도구를 게시할 때 발생하는 충돌을 방지하는 데 도움이 됩니다. 생성된 이름은 ASCII에 안전하고 함수 도구 이름의 길이 제한을 준수하며, 동일한 에이전트에 있는 기존 로컬 함수 도구 및 활성화된 핸드오프 이름과의 충돌을 방지합니다. SDK는 여전히 원래 서버에서 원래 MCP 도구 이름을 호출합니다. +- 서버 수준의 `failure_error_function`은 해당 서버의 `Agent.mcp_config["failure_error_function"]`보다 우선합니다. +- `include_server_in_tool_names`은 옵트인 방식입니다. 활성화하면 각 로컬 MCP 도구가 결정론적인 서버 접두사 이름으로 모델에 노출되므로 여러 MCP 서버가 동일한 이름의 도구를 게시할 때 충돌을 방지하는 데 도움이 됩니다. 생성된 이름은 ASCII에 안전하고 `FunctionTool` 인스턴스의 이름 길이 제한을 준수하며, 로컬 `FunctionTool` 인스턴스에 구성된 이름이나 동일한 에이전트에서 활성화된 핸드오프와 충돌하지 않습니다. SDK는 계속해서 원래 서버에서 원래 MCP 도구 이름을 호출합니다. ## 전송 방식 전반의 공통 패턴 -전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 동일한 후속 결정을 내려야 합니다. +전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 후속 사항을 결정해야 합니다. -- 도구의 일부만 제공하는 방법([도구 필터링](#tool-filtering)) -- 서버가 재사용 가능한 프롬프트도 제공하는지 여부([프롬프트](#prompts)) -- `list_tools()`를 캐시할지 여부([캐싱](#caching)) +- 도구의 일부만 노출하는 방법([도구 필터링](#tool-filtering)) +- 서버에서 재사용 가능한 프롬프트도 제공할지 여부([프롬프트](#prompts)) +- `list_tools()`을 캐시할지 여부([캐싱](#caching)) - MCP 활동이 트레이스에 표시되는 방식([트레이싱](#tracing)) -로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. 스트리밍 가능 HTTP 섹션에서 가장 완전한 예제를 보여 주며, 동일한 패턴이 다른 로컬 전송 방식에도 적용됩니다. +로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. Streamable HTTP 섹션에서 가장 완전한 코드 예제를 제공하며, 다른 로컬 전송 방식에도 동일한 패턴이 적용됩니다. ## 1. 호스티드 MCP 서버 도구 -호스티드 툴은 전체 도구 왕복 과정을 OpenAI 인프라로 이전합니다. 코드에서 도구를 나열하고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API에 전달합니다. 모델은 Python 프로세스에 추가 콜백을 보내지 않고 원격 서버의 도구를 나열하고 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. +호스티드 툴은 도구의 전체 왕복 과정을 OpenAI 인프라 내부에서 처리합니다. 코드에서 도구 목록을 조회하고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API에 전달합니다. 모델은 Python 프로세스에 추가 콜백하지 않고 원격 서버의 도구 목록을 조회하고 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. ### 기본 호스티드 MCP 도구 -에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 생성합니다. `tool_config` -딕셔너리는 REST API에 전송할 JSON과 동일한 구조를 사용합니다. +에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 만듭니다. `tool_config` +딕셔너리는 REST API에 전송하는 JSON과 동일한 구조입니다. ```python import asyncio @@ -110,14 +110,14 @@ async def main() -> None: asyncio.run(main()) ``` -호스티드 서버는 자체 도구를 자동으로 제공하므로 `mcp_servers`에 추가하지 않습니다. +호스티드 서버는 도구를 자동으로 노출하므로 `mcp_servers`에 추가할 필요가 없습니다. -호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하도록 하려면 `tool_config["defer_loading"] = True`를 설정하고 에이전트에 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 추가합니다. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 설정과 제약 조건은 [도구](tools.md#hosted-tool-search)를 참고하세요. +호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하도록 하려면 `tool_config["defer_loading"] = True`을 설정하고 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 에이전트에 추가합니다. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 설정과 제한 사항은 [도구](tools.md#hosted-tool-search)를 참고하세요. ### 호스티드 MCP 결과 스트리밍 -호스티드 툴은 함수 도구와 완전히 동일한 방식으로 스트리밍 결과를 지원합니다. 모델이 계속 작업하는 동안 -증분 MCP 출력을 사용하려면 `Runner.run_streamed`를 사용합니다. +호스티드 툴은 함수 도구와 완전히 동일한 방식으로 결과 스트리밍을 지원합니다. 모델이 계속 작업하는 동안 증분 MCP 출력을 +사용하려면 `Runner.run_streamed`을 사용합니다. ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -129,7 +129,7 @@ print(result.final_output) ### 선택적 승인 흐름 -서버가 민감한 작업을 수행할 수 있다면 각 도구 실행 전에 사람 또는 프로그램을 통한 승인을 요구할 수 있습니다. `tool_config`의 `require_approval`을 단일 정책(`"always"`, `"never"`) 또는 도구 이름을 정책에 매핑하는 딕셔너리로 구성합니다. Python 내에서 결정을 내리려면 `on_approval_request` 콜백을 제공합니다. +서버에서 민감한 작업을 수행할 수 있는 경우 각 도구 실행 전에 사람의 승인 또는 프로그래밍 방식의 승인을 요구할 수 있습니다. `tool_config`의 `require_approval`에 단일 정책(`"always"`, `"never"`) 또는 도구 이름을 정책에 매핑하는 딕셔너리를 구성합니다. Python 내부에서 결정하려면 `on_approval_request` 콜백을 제공합니다. ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -157,11 +157,11 @@ agent = Agent( ) ``` -콜백은 동기식 또는 비동기식일 수 있으며, 모델이 실행을 계속하기 위해 승인 데이터가 필요할 때마다 호출됩니다. +콜백은 동기식 또는 비동기식일 수 있으며 모델이 실행을 계속하기 위해 승인 데이터가 필요할 때마다 호출됩니다. ### 커넥터 기반 호스티드 서버 -호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`와 액세스 토큰을 제공합니다. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 제공합니다. +호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`과 액세스 토큰을 제공합니다. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 노출합니다. ```python import os @@ -177,11 +177,11 @@ HostedMCPTool( ) ``` -스트리밍, 승인, 커넥터를 포함하여 완전히 작동하는 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에서 확인할 수 있습니다. +스트리밍, 승인, 커넥터를 포함하여 완전히 실행 가능한 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에 있습니다. -## 2. 스트리밍 가능 HTTP MCP 서버 +## 2. Streamable HTTP MCP 서버 -네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 사용합니다. 스트리밍 가능 HTTP 서버는 전송 방식을 직접 제어하거나 낮은 지연 시간을 유지하면서 자체 인프라 내에서 서버를 실행하려는 경우에 적합합니다. +네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 사용합니다. Streamable HTTP 서버는 전송 방식을 직접 제어하거나 짧은 지연 시간을 유지하면서 자체 인프라 내부에서 서버를 실행하려는 경우에 적합합니다. ```python import asyncio @@ -216,26 +216,26 @@ async def main() -> None: asyncio.run(main()) ``` -생성자는 다음과 같은 추가 옵션을 허용합니다. +생성자는 다음과 같은 추가 옵션을 받습니다. -- `client_session_timeout_seconds`는 MCP ClientSession 읽기 제한 시간을 제어합니다. `datetime.timedelta`로 표현할 수 있고 1마이크로초 이상인 양의 유한 값은 유한한 제한 시간을 설정하며, `None`과 `0`은 이를 비활성화합니다. 그 밖의 값은 서버를 생성할 때 거부됩니다. -- `use_structured_content`는 텍스트 출력보다 `tool_result.structured_content`를 우선할지 여부를 전환합니다. -- `max_retry_attempts`와 `retry_backoff_seconds_base`는 `list_tools()` 및 `call_tool()`에 자동 재시도를 추가합니다. -- `tool_filter`를 사용하면 도구의 일부만 제공할 수 있습니다([도구 필터링](#tool-filtering) 참고). +- `client_session_timeout_seconds`은 MCP ClientSession 읽기 타임아웃을 제어합니다. `datetime.timedelta`으로 표현할 수 있으며 1마이크로초 이상인 양의 유한 값은 유한 타임아웃을 설정하고, `None`과 `0`은 이를 비활성화합니다. 그 외의 값은 서버 생성 시 거부됩니다. +- `use_structured_content`은 텍스트 출력보다 `tool_result.structured_content`을 우선할지 여부를 전환합니다. +- `max_retry_attempts`과 `retry_backoff_seconds_base`은 `list_tools()` 및 `call_tool()`에 자동 재시도를 추가합니다. +- `tool_filter`을 사용하면 도구의 일부만 노출할 수 있습니다([도구 필터링](#tool-filtering) 참고). - `require_approval`은 로컬 MCP 도구에 휴먼인더루프 (HITL) 승인 정책을 활성화합니다. -- `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 지정합니다. 오류를 대신 발생시키려면 `None`으로 설정합니다. -- `tool_meta_resolver`는 `call_tool()` 전에 호출별 MCP `_meta` 페이로드를 삽입합니다. +- `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 지정합니다. 대신 오류를 발생시키려면 `None`로 설정합니다. +- `tool_meta_resolver`은 `call_tool()` 전에 호출별 MCP `_meta` 페이로드를 삽입합니다. -### 로컬 MCP 서버의 승인 정책 +### 로컬 MCP 서버 승인 정책 -`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`는 모두 `require_approval`을 허용합니다. +`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`은 모두 `require_approval`을 받습니다. -지원되는 형식: +지원되는 형식은 다음과 같습니다. -- 모든 도구에 적용되는 `"always"` 또는 `"never"` -- `True` / `False`(항상/안 함과 동일) -- 도구별 맵(예: `{"delete_file": "always", "read_file": "never"}`) -- 그룹화된 객체: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}` +- 모든 도구에 대해 `"always"` 또는 `"never"`을 지정할 수 있습니다. +- `True`은 모든 도구에 승인을 요구하고, `False`은 어떤 도구에도 승인을 요구하지 않습니다. 각각 `"always"` 및 `"never"`과 동일합니다. +- 도구별 맵을 사용할 수 있습니다. 예: `{"delete_file": "always", "read_file": "never"}` +- 그룹화된 객체를 사용할 수 있습니다. 예: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}` ```python async with MCPServerStreamableHttp( @@ -246,11 +246,11 @@ async with MCPServerStreamableHttp( ... ``` -전체 일시 중지/재개 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md) 및 `examples/mcp/get_all_mcp_tools_example/main.py`를 참고하세요. +전체 일시 중지/재개 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md) 및 `examples/mcp/get_all_mcp_tools_example/main.py`을 참고하세요. -### `tool_meta_resolver`를 통한 호출별 메타데이터 +### `tool_meta_resolver`을 사용한 호출별 메타데이터 -MCP 서버가 `_meta`에 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 요구하는 경우 `tool_meta_resolver`를 사용합니다. 아래 예제에서는 `Runner.run(...)`에 `context`로 `dict`를 전달한다고 가정합니다. +MCP 서버가 `_meta`에서 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 기대하는 경우 `tool_meta_resolver`을 사용합니다. 아래 코드 예제에서는 `dict`을 `Runner.run(...)`의 `context`로 전달한다고 가정합니다. ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -271,19 +271,19 @@ server = MCPServerStreamableHttp( ) ``` -실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 지정 클래스인 경우에는 속성 접근을 사용하여 테넌트 ID를 읽습니다. +실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 지정 클래스라면 속성 접근 방식으로 테넌트 ID를 읽습니다. ### MCP 도구 출력: 텍스트와 이미지 -MCP 도구가 이미지 콘텐츠를 반환하면 SDK가 이를 이미지 도구 출력 항목에 자동으로 매핑합니다. 텍스트와 이미지가 혼합된 응답은 출력 항목 목록으로 전달되므로, 에이전트는 일반 함수 도구의 이미지 출력을 사용하는 것과 동일한 방식으로 MCP 이미지 결과를 사용할 수 있습니다. +MCP 도구가 이미지 콘텐츠를 반환하면 SDK가 이를 도구 출력의 이미지 유형 항목에 자동으로 매핑합니다. 텍스트와 이미지가 혼합된 응답은 출력 항목 목록으로 전달되므로 에이전트는 일반 함수 도구의 이미지 출력을 사용하는 것과 같은 방식으로 MCP 이미지 결과를 사용할 수 있습니다. ## 3. SSE 기반 HTTP MCP 서버 !!! warning - MCP 프로젝트에서는 Server-Sent Events 전송 방식을 더 이상 권장하지 않습니다. 새로운 통합에는 스트리밍 가능 HTTP 또는 stdio를 사용하고, SSE는 레거시 서버에만 유지하세요. + MCP 프로젝트는 Server-Sent Events 전송 방식을 지원 중단으로 지정했습니다. 신규 통합에는 Streamable HTTP 또는 stdio를 사용하고, SSE는 레거시 서버에만 유지하는 것이 좋습니다. -MCP 서버가 SSE 기반 HTTP 전송 방식을 구현하는 경우 [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 인스턴스화합니다. 전송 방식을 제외하면 API는 스트리밍 가능 HTTP 서버와 동일합니다. +MCP 서버가 SSE 기반 HTTP 전송 방식을 구현한다면 [`MCPServerSse`][agents.mcp.server.MCPServerSse]을 인스턴스화합니다. 전송 방식을 제외하면 API는 Streamable HTTP 서버와 동일합니다. ```python @@ -312,7 +312,7 @@ async with MCPServerSse( ## 4. stdio MCP 서버 -로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 사용합니다. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며, 컨텍스트 관리자가 종료될 때 자동으로 닫습니다. 이 옵션은 빠른 개념 증명을 만들거나 서버가 명령줄 진입점만 제공하는 경우에 유용합니다. +로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]을 사용합니다. SDK가 프로세스를 생성하고 파이프를 열린 상태로 유지하며 컨텍스트 관리자가 종료되면 자동으로 닫습니다. 이 옵션은 빠른 개념 증명이나 서버가 명령줄 엔트리 포인트만 노출하는 경우에 유용합니다. ```python from pathlib import Path @@ -340,7 +340,7 @@ async with MCPServerStdio( ## 5. MCP 서버 관리자 -MCP 서버가 여러 개라면 `MCPServerManager`를 사용하여 서버에 미리 연결하고 연결된 서버의 일부를 에이전트에 제공합니다. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참고하세요. +MCP 서버가 여러 개라면 `MCPServerManager`을 사용하여 서버를 미리 연결하고, 성공적으로 연결된 서버만 에이전트에 노출합니다. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참고하세요. ```python from agents import Agent, Runner @@ -361,25 +361,25 @@ async with MCPServerManager(servers) as manager: print(result.final_output) ``` -주요 동작: +주요 동작은 다음과 같습니다. -- `drop_failed_servers=True`(기본값)이면 `active_servers`에는 연결에 성공한 서버만 포함됩니다. -- 실패는 `failed_servers`와 `errors`에서 추적됩니다. -- 첫 번째 연결 실패 시 오류를 발생시키려면 `strict=True`로 설정합니다. -- 실패한 서버를 재시도하려면 `reconnect(failed_only=True)`를 호출하고, 모든 서버를 다시 시작하려면 `reconnect(failed_only=False)`를 호출합니다. -- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정합니다. 수명 주기 제한 시간에는 양의 유한 초 단위 값 또는 이를 비활성화하는 `None`을 사용할 수 있으며, 생성 및 할당 시 모두 검증됩니다. `0`은 즉시 기한이 만료되므로 거부됩니다. +- `drop_failed_servers=True`인 경우(기본값) `active_servers`에는 성공적으로 연결된 서버만 포함됩니다. +- 실패는 `failed_servers`과 `errors`에서 추적됩니다. +- 첫 번째 연결 실패 시 오류를 발생시키려면 `strict=True`을 설정합니다. +- 실패한 서버를 다시 시도하려면 `reconnect(failed_only=True)`을 호출하고, 모든 서버를 다시 시작하려면 `reconnect(failed_only=False)`을 호출합니다. +- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정합니다. 수명 주기 타임아웃에는 양의 유한 초 또는 타임아웃을 비활성화하는 `None`을 사용할 수 있으며, 생성 시점과 할당 시점 모두에서 유효성을 검사합니다. 0은 즉시 기한이 만료되므로 거부됩니다. ## 공통 서버 기능 -아래 섹션은 MCP 서버 전송 방식 전반에 적용됩니다. 단, 정확한 API 범위는 서버 클래스에 따라 달라집니다. +아래 섹션은 모든 MCP 서버 전송 방식에 적용됩니다. 단, 정확한 API 범위는 서버 클래스에 따라 달라집니다. ## 도구 필터링 -각 MCP 서버는 도구 필터를 지원하므로 에이전트에 필요한 함수만 제공할 수 있습니다. 필터링은 생성 시점에 수행하거나 실행별로 동적으로 수행할 수 있습니다. +각 MCP 서버는 에이전트에 필요한 기능만 노출할 수 있도록 도구 필터를 지원합니다. 필터링은 생성 시점에 수행하거나 실행마다 동적으로 수행할 수 있습니다. ### 정적 도구 필터링 -간단한 허용/차단 목록을 구성하려면 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter]를 사용합니다. +간단한 허용/차단 목록을 구성하려면 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter]을 사용합니다. ```python from pathlib import Path @@ -397,11 +397,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names`와 `blocked_tool_names`가 모두 제공되면 SDK는 먼저 허용 목록을 적용한 다음 남은 집합에서 차단된 도구를 제거합니다. +`allowed_tool_names`과 `blocked_tool_names`을 모두 제공하면 SDK는 먼저 허용 목록을 적용한 다음 남은 집합에서 차단된 도구를 제거합니다. ### 동적 도구 필터링 -더 정교한 로직이 필요하면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]를 받는 호출 가능 객체를 전달합니다. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며, 도구를 제공해야 하는 경우 `True`를 반환합니다. +더 정교한 로직이 필요하면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]을 받는 호출 가능 객체를 전달합니다. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며 도구를 노출해야 할 때 `True`을 반환합니다. ```python from pathlib import Path @@ -425,15 +425,15 @@ async with MCPServerStdio( ... ``` -필터 컨텍스트는 활성 `run_context`, 도구를 요청하는 `agent`, `server_name`을 제공합니다. +필터 컨텍스트는 활성 `run_context`, 도구를 요청하는 `agent`, `server_name`을 노출합니다. ## 프롬프트 MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 다음 두 가지 -메서드를 제공합니다. +메서드를 노출합니다. -- `list_prompts()`는 사용 가능한 프롬프트 템플릿을 열거합니다. -- `get_prompt(name, arguments)`는 선택적으로 매개변수를 사용하여 구체적인 프롬프트를 가져옵니다. +- `list_prompts()`은 사용 가능한 프롬프트 템플릿을 열거합니다. +- `get_prompt(name, arguments)`은 구체적인 프롬프트를 가져오며, 선택적으로 매개변수를 받을 수 있습니다. ```python from agents import Agent @@ -453,25 +453,25 @@ agent = Agent( ## 페이지네이션 -기본 제공 로컬 MCP 서버 클래스는 도구와 프롬프트를 나열할 때 자동으로 `nextCursor`를 따라갑니다. `list_tools()`는 필터를 적용하거나 캐시를 채우기 전에 전체 도구 목록을 반환하며, `list_prompts()`는 `nextCursor=None`인 하나의 결합된 결과를 반환합니다. 이후 페이지에서 오류가 발생하거나 서버가 커서를 반복하면 일부 결과를 제공하거나 캐시하는 대신 작업에서 오류가 발생합니다. +기본 제공 로컬 MCP 서버 클래스는 도구와 프롬프트 목록을 조회할 때 `nextCursor`을 자동으로 따라갑니다. `list_tools()`은 필터를 적용하거나 캐시를 채우기 전에 전체 도구 목록을 수집하고, `list_prompts()`은 `nextCursor=None`을 포함하는 하나의 결합된 결과를 반환합니다. 이후 페이지에서 실패하거나 서버가 커서를 반복하면 부분 결과를 노출하거나 캐시하는 대신 오류가 발생합니다. -리소스에는 명시적 페이지네이션이 계속 적용됩니다. 다음 페이지를 가져오려면 `list_resources()` 또는 `list_resource_templates()`의 `nextCursor`를 `cursor` 인수로 다시 전달합니다. +리소스에는 계속 명시적 페이지네이션이 적용됩니다. 다음 페이지를 가져오려면 `list_resources()` 또는 `list_resource_templates()`에서 반환된 `nextCursor`을 `cursor` 인수로 다시 전달합니다. ## 캐싱 -에이전트를 실행할 때마다 각 MCP 서버에서 `list_tools()`가 호출됩니다. 원격 서버는 눈에 띄는 지연 시간을 유발할 수 있으므로 모든 MCP 서버 클래스가 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신할 때만 이를 `True`로 설정하세요. 나중에 최신 목록을 강제로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`를 호출합니다. +모든 에이전트 실행은 각 MCP 서버에서 `list_tools()`을 호출합니다. 원격 서버는 상당한 지연 시간을 유발할 수 있으므로 모든 MCP 서버 클래스가 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신할 때만 `True`로 설정합니다. 나중에 최신 목록을 강제로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`을 호출합니다. ## 트레이싱 [트레이싱](./tracing.md)은 다음을 포함한 MCP 활동을 자동으로 캡처합니다. -1. 도구를 나열하기 위한 MCP 서버 호출 -2. 도구 호출의 MCP 관련 정보 +1. 도구 목록을 조회하기 위한 MCP 서버 호출입니다. +2. 도구 호출의 MCP 관련 정보입니다. ![MCP 트레이싱 스크린샷](../assets/images/mcp-tracing.jpg) ## 추가 자료 - [Model Context Protocol](https://modelcontextprotocol.io/) – 사양 및 설계 가이드 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE 및 스트리밍 가능 HTTP 샘플 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인 및 커넥터를 포함한 완전한 호스티드 MCP 데모 \ No newline at end of file +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE, Streamable HTTP 샘플 코드 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인과 커넥터를 포함한 완전한 호스티드 MCP 데모 \ No newline at end of file diff --git a/docs/ko/models/index.md b/docs/ko/models/index.md index 822a097849..fef3d52b13 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -4,43 +4,43 @@ search: --- # 모델 -Agents SDK는 두 가지 방식으로 OpenAI 모델을 즉시 사용할 수 있도록 지원합니다. +Agents SDK는 기본적으로 다음 두 가지 유형의 OpenAI 모델을 지원합니다. -- **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] -- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] +- **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] +- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] ## 모델 설정 선택 -설정에 적합한 가장 간단한 방법부터 시작하세요. +설정에 맞는 가장 간단한 경로부터 시작하세요. -| 목표 | 권장 방법 | 자세히 보기 | +| 목표 | 권장 경로 | 자세히 알아보기 | | --- | --- | --- | -| OpenAI 모델만 사용 | 기본 OpenAI 프로바이더와 Responses 모델 경로 사용 | [OpenAI 모델](#openai-models) | +| OpenAI 모델만 사용 | 기본 OpenAI 공급자를 Responses 모델 경로와 함께 사용 | [OpenAI 모델](#openai-models) | | WebSocket 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 WebSocket 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | -| OpenAI에서 호스팅되는 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | -| OpenAI가 아닌 하나의 프로바이더 사용 | 기본 제공 프로바이더 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | -| 에이전트 간에 모델 또는 프로바이더 혼합 | 실행별 또는 에이전트별로 프로바이더를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [프로바이더 간 모델 혼합](#mixing-models-across-providers) | +| OpenAI 호스트 서브에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | +| OpenAI 이외의 공급자 하나 사용 | 기본 제공 공급자 통합 지점부터 시작 | [OpenAI 이외의 모델](#non-openai-models) | +| 에이전트 전반에서 모델 또는 공급자 혼합 | 실행별 또는 에이전트별로 공급자를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [공급자 간 모델 혼합](#mixing-models-across-providers) | | 고급 OpenAI Responses 요청 설정 조정 | OpenAI Responses 경로에서 `ModelSettings` 사용 | [고급 OpenAI Responses 설정](#advanced-openai-responses-settings) | -| OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 배포할 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | +| OpenAI 이외의 공급자 또는 혼합 공급자 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 출시하려는 공급자 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | ## OpenAI 모델 -OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하면서 Responses 모델 경로를 유지하는 것이 좋습니다. +OpenAI만 사용하는 대부분의 앱에는 기본 OpenAI 공급자와 함께 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 방식을 권장합니다. -`Agent`를 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위해 `reasoning.effort="none"` 및 `verbosity="low"`가 설정된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 액세스 권한이 있다면 명시적인 `model_settings`를 유지하면서 더 높은 품질을 얻을 수 있도록 에이전트 모델을 `gpt-5.6-sol`로 설정하는 것이 좋습니다. +`Agent`을 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위한 `reasoning.effort="none"` 및 `verbosity="low"`이 적용된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 사용할 수 있다면 명시적인 `model_settings`을 유지하면서 더 높은 품질을 위해 에이전트를 `gpt-5.6-sol`로 설정하는 것을 권장합니다. -`gpt-5.6-sol`과 같은 다른 모델로 전환하려는 경우 에이전트를 구성하는 두 가지 방법이 있습니다. +`gpt-5.6-sol` 같은 다른 모델로 전환하려면 두 가지 방법으로 에이전트를 구성할 수 있습니다. ### 기본 모델 -첫째, 사용자 지정 모델이 설정되지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. +첫째, 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정합니다. ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -둘째, `RunConfig`를 통해 실행의 기본 모델을 설정할 수 있습니다. 에이전트에 모델을 설정하지 않으면 이 실행의 모델이 사용됩니다. +둘째, `RunConfig`을 통해 실행의 기본 모델을 설정할 수 있습니다. 에이전트에 모델을 설정하지 않으면 이 실행의 모델이 사용됩니다. ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 모델 -이 방식으로 `gpt-5.6-sol`과 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`를 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 적용됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`를 전달하세요. +이 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`을 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 적용됩니다. 기본 모델의 추론 노력을 조정하려면 자체 `ModelSettings`을 전달합니다. ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -지연 시간을 줄이려면 GPT-5 모델에서 `reasoning.effort="none"`을 사용하는 것이 좋습니다. +지연 시간을 줄이려면 GPT-5 모델과 함께 `reasoning.effort="none"`을 사용하는 것을 권장합니다. -GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 유지되는 추론 컨텍스트 및 `"max"` 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. +GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 대화 턴 간에 이어지는 추론 컨텍스트, `"max"` 노력 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode`와 `reasoning.context`는 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 인터페이스에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고와 함께 모드 및 컨텍스트를 무시합니다. 이 경고를 오류로 전환하려면 OpenAI 프로바이더에 `strict_feature_validation=True`를 설정하세요. +`reasoning.mode`과 `reasoning.context`은 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 노력 수준은 모델과 API 표면에 따라 달라집니다. GPT-5.6의 `"max"` 노력 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고를 표시하며 모드와 컨텍스트를 무시합니다. 해당 경고를 오류로 전환하려면 OpenAI 공급자에서 `strict_feature_validation=True`을 설정하세요. -`context="all_turns"`를 사용할 때는 `previous_response_id`, 서버 측 대화 또는 이전 추론 항목 재실행을 통해 대화를 보존하세요. 상태 비저장 `store=False` 호출에서는 응답에 `reasoning.encrypted_content`를 포함하고 다음 요청에서 해당 추론 항목을 다시 전달하세요. +`context="all_turns"`을 사용할 때는 `previous_response_id`, 서버 측 Responses API 대화를 통해 대화를 유지하거나 이전 추론 항목을 다음 요청에 포함하세요. 상태 비저장 `store=False` 호출에서는 응답에 `reasoning.encrypted_content`을 요청한 다음, 해당 추론 항목을 다음 요청의 입력에 포함하세요. #### ComputerTool 모델 선택 -에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청에서 유효한 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 GA 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. +에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청에서 유효한 모델에 따라 SDK가 전송할 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 GA 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. -프롬프트가 관리하는 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 소유하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트에 고정된 모델을 추측하지 않도록 프리뷰 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 GA 경로를 유지하려면 요청에 `model="gpt-5.5"`를 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 GA 선택기를 강제하세요. +프롬프트로 관리되는 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 지정하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 고정한 모델을 추측하지 않도록 미리보기 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 GA 경로를 유지하려면 요청에 `model="gpt-5.5"`을 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`을 사용하여 GA 선택기를 강제하세요. -등록된 [`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`는 유효한 요청 모델과 일치하는 기본 제공 선택기로 정규화됩니다. `ComputerTool`이 등록되어 있지 않으면 이러한 문자열은 일반 함수 이름처럼 계속 동작합니다. +[`ComputerTool`][agents.tool.ComputerTool]이 등록된 경우 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`은 유효한 요청 모델에 맞는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 해당 문자열은 계속 일반 함수 이름처럼 동작합니다. -프리뷰 호환 요청은 `environment`와 디스플레이 크기를 미리 직렬화해야 합니다. 따라서 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 전송하기 전에 GA 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. +미리보기 호환 요청은 `environment`과 디스플레이 크기를 미리 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 GA 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. #### GPT-5 이외의 모델 -사용자 지정 `model_settings` 없이 GPT-5가 아닌 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. +사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`으로 되돌아갑니다. ### Responses 전용 도구 기능 다음 도구 기능은 OpenAI Responses 모델에서만 지원됩니다. -- [`ToolSearchTool`][agents.tool.ToolSearchTool] -- [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` 및 지연 로딩을 사용하는 기타 Responses 도구 인터페이스 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers` 및 `tool_choice="programmatic_tool_calling"` +- [`ToolSearchTool`][agents.tool.ToolSearchTool] +- [`tool_namespace()`][agents.tool.tool_namespace] +- `@function_tool(defer_loading=True)` 및 그 밖의 지연 로딩 Responses 도구 표면 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers`, `tool_choice="programmatic_tool_calling"` -이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 네임스페이스 이름만 또는 지연 전용 함수 이름을 강제하는 대신 `auto`나 `required` 도구 선택을 통해 모델이 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 사항은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. +이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 단순 네임스페이스 이름이나 지연 전용 함수 이름을 강제하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 사항은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. ### Responses WebSocket 전송 -기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 WebSocket 전송을 사용하도록 설정할 수 있습니다. +기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI Responses 공급자 경로를 사용할 때 WebSocket 전송을 선택적으로 활성화할 수 있습니다. #### 기본 설정 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 프로바이더가 확인하는 OpenAI Responses 모델에 영향을 줍니다. 여기에는 `"gpt-5.6-sol"`과 같은 문자열 모델 이름도 포함됩니다. +이는 기본 OpenAI 공급자가 모델 이름을 해석할 때 생성되는 OpenAI Responses 모델에 적용됩니다(`"gpt-5.6-sol"` 같은 문자열 모델 이름 포함). -SDK가 모델 이름을 모델 인스턴스로 확인할 때 전송 방식이 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 계속 사용합니다. `RunConfig(model_provider=...)`를 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식을 제어합니다. +전송 방식은 SDK가 모델 이름을 모델 인스턴스로 해석할 때 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 계속 사용합니다. `RunConfig(model_provider=...)`을 전달하면 전역 기본값 대신 해당 공급자가 전송 방식 선택을 제어합니다. -#### 프로바이더 또는 실행 수준 설정 +#### 공급자 또는 실행 수준 설정 -프로바이더별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. +공급자별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI 기반 프로바이더는 선택적인 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에 하네스 ID와 같은 프로바이더 수준 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. +SDK의 OpenAI 통합을 통해 라우팅하는 공급자는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID 같은 공급자 수준의 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. ```python from agents import ( @@ -188,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider`를 사용한 고급 라우팅 +#### `MultiProvider`을 사용한 고급 라우팅 -접두사 기반 모델 라우팅이 필요한 경우, 예를 들어 한 번의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름을 혼합하려면 [`MultiProvider`][agents.MultiProvider]를 사용하고 여기에서 `openai_use_responses_websocket=True`를 설정하세요. +접두사 기반 모델 라우팅이 필요한 경우(예: 한 번의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]을 사용하고 거기에서 `openai_use_responses_websocket=True`을 설정하세요. -`MultiProvider`는 기존의 두 가지 기본 동작을 유지합니다. +`MultiProvider`은 다음 두 가지 기존 기본 동작을 유지합니다. -- `openai/...`는 OpenAI 프로바이더의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`로 라우팅됩니다. -- 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`를 발생시킵니다. +- `openai/...`은 OpenAI 공급자의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`으로 라우팅됩니다. +- 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`을 발생시킵니다. -OpenAI 프로바이더가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키는 경우, 통과 동작을 명시적으로 활성화하세요. WebSocket이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`를 유지하세요. +리터럴 네임스페이스 모델 ID가 필요한 OpenAI 호환 엔드포인트를 OpenAI 공급자에 지정할 때는 통과 동작을 명시적으로 활성화하세요. WebSocket이 활성화된 설정에서는 `MultiProvider`에서도 `openai_use_responses_websocket=True`을 유지하세요. ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,31 +223,31 @@ result = await Runner.run( ) ``` -백엔드가 리터럴 `openai/...` 문자열을 요구하면 `openai_prefix_mode="model_id"`를 사용하세요. 백엔드가 `openrouter/openai/gpt-4.1-mini`와 같은 다른 네임스페이스 모델 ID를 요구하면 `unknown_prefix_mode="model_id"`를 사용하세요. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화된 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. +백엔드에 리터럴 `openai/...` 문자열이 필요한 경우 `openai_prefix_mode="model_id"`을 사용하세요. 백엔드에 `openrouter/openai/gpt-4.1-mini` 같은 다른 네임스페이스 모델 ID가 필요한 경우 `unknown_prefix_mode="model_id"`을 사용하세요. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화한 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. -`MultiProvider`를 통해 라우팅하면서 동일한 프로바이더 수준 등록 메타데이터가 필요하면 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`을 전달하세요. 이 값은 기본 OpenAI 프로바이더로 전달됩니다. +`MultiProvider`을 통해 라우팅하면서 동일한 공급자 수준의 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`을 전달하면 내부 OpenAI 공급자에 전달됩니다. 사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 WebSocket 전송에는 호환되는 WebSocket `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. #### 참고 사항 -- 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 통한 Responses API입니다. Chat Completions 또는 OpenAI 이외의 프로바이더가 Responses WebSocket `/responses` 엔드포인트를 지원하지 않는 한 적용되지 않습니다. -- 환경에 아직 설치되어 있지 않다면 `websockets` 패키지를 설치하세요. -- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 직접 사용할 수 있습니다. 여러 턴과 중첩된 Agents-as-tools 호출에서 동일한 WebSocket 연결을 재사용하려는 멀티턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 헬퍼를 사용하는 것이 좋습니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. -- 추론 턴이 길거나 네트워크 지연이 급증하는 경우 `responses_websocket_options`로 WebSocket 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화된 상태로 유지하면서 하트비트 시간 제한을 비활성화하려면 `ping_timeout=None`을 설정하세요. WebSocket 지연 시간보다 안정성이 더 중요할 때는 HTTP/SSE 전송을 사용하는 것이 좋습니다. -- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 장기간 실행되는 에이전트 프로세스나 메모리가 제한된 컨테이너에서는 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`를 설정하세요. -- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 각 연결을 60분으로 제한합니다. 이 제한 이후에는 새 연결을 여세요. 병렬 실행이 필요하면 여러 연결을 사용하세요. -- 서비스는 연결 로컬 메모리에 가장 최근 응답만 유지합니다. 실패한 `4xx` 또는 `5xx` 턴은 참조된 `previous_response_id`를 제거합니다. 다시 연결한 후에도 저장된 응답이 있다면 이어서 처리할 수 있지만, `store=False`와 ZDR 흐름에는 유지된 대체 데이터가 없습니다. `previous_response_id=None`으로 새 체인을 시작하고 전체 입력 컨텍스트를 전송하거나 로컬에서 관리하는 세션 상태로 해당 컨텍스트를 다시 구성하세요. +- 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않습니다. OpenAI 이외의 공급자가 Responses WebSocket `/responses` 엔드포인트를 지원하는 경우에만 해당 공급자에 적용됩니다. +- 환경에서 아직 사용할 수 없다면 `websockets` 패키지를 설치하세요. +- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 직접 사용할 수 있습니다. 여러 턴에 걸쳐 동일한 WebSocket 연결을 재사용하려는 멀티턴 워크플로에는(중첩된 에이전트 도구 호출 포함) [`responses_websocket_session()`][agents.responses_websocket_session] 헬퍼를 권장합니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)을 참조하세요. +- 추론 턴이 길거나 지연 시간이 급증하는 네트워크에서는 `responses_websocket_options`을 사용하여 WebSocket 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화한 상태에서 하트비트 시간 초과를 비활성화하려면 `ping_timeout=None`을 설정하세요. WebSocket 지연 시간보다 안정성이 더 중요하면 HTTP/SSE 전송을 권장합니다. +- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 실행되거나 메모리가 제한된 컨테이너에서 장시간 실행되는 에이전트 프로세스의 경우 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`을 설정하세요. +- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 연결당 시간을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 여세요. 병렬 실행이 필요하면 여러 연결을 사용하세요. +- 서비스는 연결 로컬 메모리에 가장 최근 응답만 보관합니다. 실패한 `4xx` 또는 `5xx` 턴은 `previous_response_id`이 참조한 응답을 해당 메모리에서 제거합니다. 재연결 후에도 저장된 응답을 사용할 수 있으면 계속 진행할 수 있지만, `store=False` 및 ZDR 흐름에는 영구 저장된 대체 경로가 없습니다. `previous_response_id=None`로 새 체인을 시작하고 전체 입력 컨텍스트를 보내거나 로컬에서 관리하는 세션 상태로 해당 컨텍스트를 다시 구성하세요. ### 호스티드 멀티 에이전트(실험적) -OpenAI Responses API의 호스티드 멀티 에이전트 베타에서는 GPT-5.6 루트 모델이 서버에서 호스팅되는 하위 에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`를 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. +OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 서브에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`을 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. -이 통합은 실험적이며 로컬 함수 출력을 `response.inject`를 통해 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`를 노출하는 베타 빌드를 포함하여 `openai[realtime]>=2.45.0`이 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. +이 통합은 실험적이며, 로컬 함수 출력을 `response.inject`을 통해 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`을 노출하는 `openai[realtime]` 버전 2.45.0 이상의 빌드가 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. #### 모델 구성 -실험적 모듈에서 모델을 가져와 SDK `Agent`에 할당하세요. +실험적 모듈에서 모델을 가져와 SDK `Agent`에 할당합니다. ```python from agents import Agent @@ -260,13 +260,13 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`가 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`가 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`를 생략하면 서비스 기본값이 사용됩니다. +`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`이 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`이 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`을 생략하면 서비스 기본값이 사용됩니다. #### 로컬 함수 도구 -모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. 어떤 호스티드 에이전트가 함수를 호출할지는 Responses API가 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 포함된 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 이를 통해 서비스가 원래 호스티드 호출자의 처리를 재개할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 훅 및 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`가 아닌 함수 도구는 요청이 전송되기 전에 거부됩니다. +모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. 어떤 호스티드 에이전트가 함수를 호출할지는 Responses API가 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 포함된 `function_call_output`을 활성 WebSocket 응답에 삽입하여 서비스가 원래 호스티드 호출자를 재개할 수 있도록 합니다. 함수 실행에는 여전히 Runner의 일반 가드레일, 훅, 실패 변환이 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`이 아닌 함수 도구는 요청을 보내기 전에 거부됩니다. -도구에서 호출자를 인식하는 로깅이나 권한 부여가 필요하면 `get_hosted_agent_metadata()`를 사용하세요. +도구에 호출자 인식 로깅 또는 권한 부여가 필요하면 `get_hosted_agent_metadata()`을 사용하세요. ```python from typing import Any @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID로 출력을 라우팅하세요. 부작용이 있는 도구에서는 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. +호스티드 에이전트 이름은 관찰용 메타데이터이지 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID를 사용하여 출력을 라우팅하세요. 부작용이 있는 도구에서는 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. #### 출력 및 스트리밍 동작 -단계가 `final_answer`인 `/root`에 귀속된 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 이러한 레코드를 로컬 함수로 실행하지 않습니다. +단계가 `final_answer`인 `/root`의 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 고수준 `RunResult`에서 서브에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 이러한 레코드를 로컬 함수로 실행하지 않습니다. -원문 스트리밍은 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트를 계속 노출합니다. 함수 호출이 준비되면 어댑터는 하나의 활성 프로바이더 응답을 SDK에 표시되는 논리적 모델 턴으로 나누고, Runner가 출력을 생성한 후 동일한 프로바이더 응답을 재개합니다. 귀속 정보를 확인하려면 원문 호스티드 항목이나 `ToolContext`와 함께 `get_hosted_agent_metadata()`를 사용하세요. +raw 스트리밍은 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트를 계속 노출합니다. 어댑터는 함수 호출이 준비되면 하나의 활성 공급자 응답을 SDK에 표시되는 논리적 모델 턴으로 나눈 다음, Runner가 출력을 생성하면 동일한 공급자 응답을 재개합니다. 항목 또는 도구 호출이 어떤 호스티드 에이전트에 귀속되는지 식별하려면 raw 호스티드 항목이나 `ToolContext`과 함께 `get_hosted_agent_metadata()`을 사용하세요. #### SDK 오케스트레이션과의 관계 호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. -- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 이러한 하위 에이전트를 생성하거나 예약하지 않습니다. -- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 이 실험적 모델을 사용할 때는 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 핸드오프가 거부됩니다. -- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용 및 도구 노출을 신중하게 평가하세요. +- 호스티드 멀티 에이전트는 OpenAI 서비스에서 서브에이전트를 생성합니다. 애플리케이션은 해당 서브에이전트를 생성하거나 예약하지 않습니다. +- SDK 핸드오프는 활성 로컬 SDK `Agent`을 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 핸드오프가 거부됩니다. +- Agents-as-tools는 계속 사용할 수 있지만, 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. #### 현재 제한 사항 -실험적 모델은 `reasoning.summary`, `max_tool_calls` 및 호출자가 제공한 `multi_agent`나 `betas` 재정의를 거부합니다. 명시적인 `context_management.compact_threshold`는 사용할 수 있지만 Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하기 때문입니다. +실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공하는 `multi_agent` 또는 `betas` 재정의를 거부합니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`은 사용할 수 있지만, Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. -하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답만 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단된 경우 `await model.close()`를 호출하여 WebSocket을 해제하세요. 현재는 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원할 수 없습니다. +하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답만 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단되면 `await model.close()`을 호출하여 WebSocket을 해제하세요. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. -기본 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)를 참조하세요. +기반이 되는 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)을 참조하세요. ## OpenAI 이외의 모델 -OpenAI 이외의 프로바이더가 필요하면 SDK의 기본 제공 프로바이더 통합 지점으로 시작하세요. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 이것으로 충분합니다. 각 패턴의 코드 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. +OpenAI 이외의 공급자가 필요한 경우 SDK에 기본 제공되는 공급자 통합 지점부터 시작하세요. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. -### OpenAI 이외의 프로바이더 통합 방법 +### OpenAI 이외의 공급자 통합 방식 | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | | [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트를 대부분 또는 모든 에이전트의 기본값으로 사용해야 할 때 | 전역 기본값 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 프로바이더를 단일 실행에 적용해야 할 때 | 실행별 | -| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 서로 다른 프로바이더나 구체적인 모델 객체가 필요할 때 | 에이전트별 | -| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 어댑터 관리형 프로바이더 지원 범위나 라우팅이 필요할 때 | [서드 파티 어댑터](#third-party-adapters) 참조 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 공급자를 단일 실행에 적용해야 할 때 | 실행별 | +| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 서로 다른 공급자 또는 구체적인 모델 객체가 필요할 때 | 에이전트별 | +| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 공급자 지원 범위 또는 라우팅이 필요할 때 | [서드 파티 어댑터](#third-party-adapters) 참조 | -다음 기본 제공 경로를 사용하여 다른 LLM 프로바이더를 통합할 수 있습니다. +다음 기본 제공 경로를 사용하여 다른 LLM 공급자를 통합할 수 있습니다. -1. [`set_default_openai_client`][agents.set_default_openai_client]는 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우 유용합니다. LLM 프로바이더에 OpenAI 호환 API 엔드포인트가 있고 `base_url` 및 `api_key`를 설정할 수 있는 경우에 적합합니다. 구성 가능한 코드 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. -2. [`ModelProvider`][agents.models.interface.ModelProvider]는 `Runner.run` 수준에서 사용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 프로바이더 사용"을 지정할 수 있습니다. 구성 가능한 코드 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. -3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트마다 서로 다른 프로바이더를 조합하여 사용할 수 있습니다. 구성 가능한 코드 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. +1. [`set_default_openai_client`][agents.set_default_openai_client]은 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우에 유용합니다. 이는 LLM 공급자에 OpenAI 호환 API 엔드포인트가 있고 `base_url` 및 `api_key`을 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. +2. [`ModelProvider`][agents.models.interface.ModelProvider]은 `Runner.run` 수준에 있습니다. 이를 사용하면 "이 실행의 모든 에이전트에 사용자 지정 모델 공급자를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. +3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에서 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 공급자를 조합할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. -`platform.openai.com`에서 발급한 API 키가 없는 경우 `set_tracing_disabled()`를 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것이 좋습니다. +`platform.openai.com`의 API 키가 없는 경우 `set_tracing_disabled()`을 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것을 권장합니다. ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 이 코드 예제에서는 아직 많은 LLM 프로바이더가 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 프로바이더가 Responses API를 지원한다면 Responses를 사용하는 것이 좋습니다. + 이 예제에서는 많은 LLM 공급자가 아직 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 공급자가 Responses를 지원한다면 Responses를 사용하는 것을 권장합니다. ## 하나의 워크플로에서 모델 혼합 -단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용해야 할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고 복잡한 작업에는 더 크고 성능이 우수한 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. +단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]을 구성할 때 다음 중 한 가지 방식으로 특정 모델을 선택할 수 있습니다. -1. 모델 이름 전달 -2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider] 전달 -3. [`Model`][agents.models.interface.Model] 구현 직접 제공 +1. 모델 이름을 전달합니다. +2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider]을 전달합니다. +3. [`Model`][agents.models.interface.Model] 구현을 직접 제공합니다. !!! note - SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 및 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식에서 지원하는 기능과 도구 집합이 다르므로 각 워크플로에서는 단일 모델 형식을 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용하는 모든 기능을 양쪽에서 사용할 수 있는지 확인하세요. + SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]과 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식은 서로 다른 기능과 도구 집합을 지원하므로 각 워크플로에서 하나의 모델 형식을 사용하는 것을 권장합니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용 중인 모든 기능을 양쪽 모두에서 사용할 수 있는지 확인하세요. ```python import asyncio @@ -391,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAI 모델 이름을 직접 설정합니다. -2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. +1. OpenAI 모델 이름을 직접 설정합니다. +2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. -에이전트에 사용되는 모델을 추가로 구성하려면 temperature와 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.model_settings.ModelSettings]를 전달할 수 있습니다. +에이전트에 사용되는 모델을 추가로 구성하려면 temperature 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.model_settings.ModelSettings]을 전달할 수 있습니다. ```python from agents import Agent, ModelSettings @@ -409,22 +409,21 @@ english_agent = Agent( ## 고급 OpenAI Responses 설정 -OpenAI Responses 경로를 사용하면서 더 세밀한 제어가 필요하다면 `ModelSettings`부터 시작하세요. +OpenAI Responses 경로에서 더 세밀한 제어가 필요하면 `ModelSettings`부터 시작하세요. ### 일반적인 고급 `ModelSettings` 옵션 -OpenAI Responses API를 사용할 때 여러 요청 필드에는 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 `extra_args`를 사용할 필요가 없습니다. +OpenAI Responses API를 사용할 때는 여러 요청 필드에 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 해당 필드에 `extra_args`이 필요하지 않습니다. - `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출을 허용하거나 금지합니다. -- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 제거하도록 `"auto"`를 설정합니다. +- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 제거하도록 `"auto"`을 설정합니다. - `store`: 생성된 응답을 나중에 검색할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로와 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. -- `context_management`: `compact_threshold`를 사용하는 Responses 압축과 같은 서버 측 컨텍스트 처리를 구성합니다. -- `prompt_cache_retention`: 이전 모델 제품군의 연장된 보존 기간을 구성합니다. 예를 들면 - `"24h"`입니다. -- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고 GPT-5.6에서는 `"30m"` 캐시 TTL을 구성합니다. -- `response_include`: `web_search_call.action.sources`, `file_search_call.results` 또는 `reasoning.encrypted_content`와 같은 더 풍부한 응답 페이로드를 요청합니다. +- `context_management`: `compact_threshold`을 사용한 Responses 압축 같은 서버 측 컨텍스트 처리를 구성합니다. +- `prompt_cache_retention`: 예를 들어 `"24h"`을 사용하여 이전 모델 제품군의 확장 보존을 구성합니다. +- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고, GPT-5.6의 경우 `"30m"` 캐시 TTL을 구성합니다. +- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 같은 더 풍부한 응답 페이로드를 요청합니다. - `top_logprobs`: 출력 텍스트의 상위 토큰 logprobs를 요청합니다. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. -- `retry`: 모델 호출에 대한 Runner 관리형 재시도 설정을 활성화합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. +- `retry`: 모델 호출에 Runner가 관리하는 재시도 설정을 사용하도록 선택합니다. [Runner 관리 재시도](#runner-managed-retries)를 참조하세요. ```python from agents import Agent, ModelSettings @@ -444,7 +443,7 @@ research_agent = Agent( ) ``` -명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드가 Responses 및 Chat Completions 요청에 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오 및 파일 콘텐츠 부분의 중단점을 보존합니다. +명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 유지합니다. ```python from agents import Runner @@ -471,17 +470,17 @@ result = await Runner.run( ``` `prompt_cache_retention`은 레거시 보존 제어를 사용하는 이전 모델 제품군에서 계속 사용할 수 있습니다. -직접적인 `ModelSettings` 필드와 동일한 키를 `extra_args`에서 함께 사용하지 마세요. +직접 지정한 `ModelSettings` 필드를 `extra_args`의 동일한 키와 함께 사용하지 마세요. -`store=False`를 설정하면 Responses API는 나중에 서버 측에서 검색할 수 있도록 해당 응답을 보관하지 않습니다. 이는 상태 비저장 또는 데이터 무보존 방식의 흐름에 유용하지만, 원래 응답 ID를 재사용하는 기능은 대신 로컬에서 관리하는 상태를 사용해야 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않았을 때 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. +`store=False`을 설정하면 Responses API는 나중에 서버 측에서 검색할 수 있도록 해당 응답을 보관하지 않습니다. 이는 상태 비저장 또는 데이터 무보존 방식의 흐름에 유용하지만, 그렇지 않으면 응답 ID를 재사용하는 기능이 로컬에서 관리하는 상태에 의존해야 한다는 의미이기도 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않은 경우 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. -서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`는 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. +서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`은 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. ### `extra_args` 전달 -SDK가 아직 최상위 수준에서 직접 노출하지 않는 프로바이더별 요청 필드나 최신 요청 필드가 필요할 때 `extra_args`를 사용하세요. +SDK가 아직 최상위 수준에서 직접 노출하지 않는 공급자별 요청 필드나 최신 요청 필드가 필요하면 `extra_args`을 사용하세요. -OpenAI 모델을 사용할 때 `extra_args`는 Responses API와 Chat Completions API 모두에 선택적 매개변수를 전달할 수 있습니다. 예를 들면 `user` 및 `service_tier`입니다. 지원되는 모델에서는 [Fast 모드](https://developers.openai.com/api/docs/guides/fast-mode)를 사용하도록 `extra_args={"service_tier": "fast"}`를 설정하세요. `"priority"`도 동일하게 동작합니다. 동일한 요청 필드를 직접적인 `ModelSettings` 필드에도 설정하지 마세요. +OpenAI 모델을 사용할 때 `extra_args`은 Responses API와 Chat Completions API 모두에 선택적 매개변수를 전달할 수 있습니다(예: `user` 및 `service_tier`). 지원되는 모델에서 [Fast mode](https://developers.openai.com/api/docs/guides/fast-mode)를 사용하려면 `extra_args={"service_tier": "fast"}`을 설정하세요. `"priority"`도 동일하게 동작합니다. 직접 지정하는 `ModelSettings` 필드를 통해 동일한 요청 필드를 함께 설정하지 마세요. ```python from agents import Agent, ModelSettings @@ -497,11 +496,11 @@ english_agent = Agent( ) ``` -## Runner 관리형 재시도 +## Runner 관리 재시도 -재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`를 설정하고 재시도 정책에서 재시도를 선택하지 않으면 SDK는 일반 모델 요청을 재시도하지 않습니다. +재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`을 설정하고 재시도 정책에서 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. -Responses WebSocket 전송에서 `retry_policies.provider_suggested()`는 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`가 필요하며 일반적인 재실행 안전성 검사도 계속 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. +Responses WebSocket 전송에서 `retry_policies.provider_suggested()`은 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`이 필요하며 일반적인 재실행 안전성 검사도 그대로 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -529,70 +528,70 @@ agent = Agent( ) ``` -`ModelRetrySettings`에는 세 개의 필드가 있습니다. +`ModelRetrySettings`에는 세 가지 필드가 있습니다.
-| 필드 | 유형 | 참고 사항 | +| 필드 | 유형 | 참고 | | --- | --- | --- | -| `max_retries` | `int | None` | 최초 요청 후 허용되는 재시도 횟수 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`는 계산된 이 백오프 지연만 제한합니다. 정책이 반환한 명시적 지연이나 retry-after 힌트는 제한하지 않습니다. | +| `max_retries` | `int | None` | 최초 요청 이후 허용되는 재시도 횟수 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`은 이렇게 계산된 백오프 지연만 제한합니다. 정책에서 반환한 명시적 지연이나 retry-after 힌트는 제한하지 않습니다. | | `policy` | `RetryPolicy | None` | 재시도 여부를 결정하는 콜백입니다. 이 필드는 런타임 전용이며 직렬화되지 않습니다. |
-재시도 정책은 다음 항목이 포함된 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. +재시도 정책은 다음이 포함된 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. -- 시도 횟수를 고려한 결정을 내릴 수 있도록 제공되는 `attempt` 및 `max_retries` -- 스트리밍 및 비스트리밍 동작을 분기할 수 있도록 제공되는 `stream` -- 원문 검사를 위한 `error` -- `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort`와 같은 정규화된 정보가 포함된 `normalized` -- 기본 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용되는 `provider_advice` +- `attempt` 및 `max_retries`: 시도 횟수를 고려한 결정을 내리는 데 사용합니다. +- `stream`: 스트리밍 동작과 비스트리밍 동작을 분기하는 데 사용합니다. +- `error`: raw 검사에 사용합니다. +- `normalized`: `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort` 같은 사실을 제공합니다. +- `provider_advice`: 내부 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용합니다. 정책은 다음 중 하나를 반환할 수 있습니다. - 간단한 재시도 결정을 위한 `True` / `False` - 지연 시간을 재정의하거나 진단 사유를 첨부하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] -SDK는 `retry_policies`에 바로 사용할 수 있는 헬퍼를 제공합니다. +SDK는 `retry_policies`에서 바로 사용할 수 있는 헬퍼를 내보냅니다. | 헬퍼 | 동작 | | --- | --- | | `retry_policies.never()` | 항상 재시도하지 않습니다. | -| `retry_policies.provider_suggested()` | 사용 가능한 경우 프로바이더의 재시도 지침을 따릅니다. | -| `retry_policies.network_error()` | 일시적인 전송 및 시간 제한 오류와 일치합니다. | +| `retry_policies.provider_suggested()` | 공급자의 재시도 권고가 있으면 이를 따릅니다. | +| `retry_policies.network_error()` | 일시적인 전송 및 시간 초과 실패와 일치합니다. | | `retry_policies.http_status([...])` | 선택한 HTTP 상태 코드와 일치합니다. | -| `retry_policies.retry_after()` | retry-after 힌트를 사용할 수 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 헬퍼는 retry-after 값을 명시적인 정책 지연으로 처리하므로 `backoff.max_delay`가 이를 제한하지 않습니다. | +| `retry_policies.retry_after()` | retry-after 힌트를 사용할 수 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 헬퍼는 retry-after 값을 명시적 정책 지연으로 처리하므로 `backoff.max_delay`이 이를 제한하지 않습니다. | | `retry_policies.any(...)` | 중첩된 정책 중 하나라도 재시도를 선택하면 재시도합니다. | -| `retry_policies.all(...)` | 중첩된 모든 정책이 재시도를 선택할 때만 재시도합니다. | +| `retry_policies.all(...)` | 모든 중첩 정책이 재시도를 선택할 때만 재시도합니다. | -정책을 조합할 때는 `provider_suggested()`가 가장 안전한 첫 번째 기본 구성 요소입니다. 프로바이더가 거부 및 재실행 안전성 승인을 구분할 수 있을 때 이를 보존하기 때문입니다. +정책을 조합할 때는 `provider_suggested()`이 가장 안전한 첫 번째 구성 요소입니다. 공급자가 재실행 거부와 재실행 안전 승인을 구분할 수 있을 때 이를 유지하기 때문입니다. ##### 안전 경계 일부 실패는 자동으로 재시도되지 않습니다. - 중단 오류 -- 프로바이더 지침이 재실행을 안전하지 않다고 표시한 요청 -- 출력이 이미 시작되어 재실행이 안전하지 않은 스트리밍 실행 +- 공급자 권고에서 재실행이 안전하지 않다고 표시한 요청 +- 재실행이 안전하지 않을 정도로 출력이 이미 시작된 스트리밍 실행 -`previous_response_id` 또는 `conversation_id`를 사용하는 상태 유지형 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()`나 `http_status([500])` 같은 프로바이더 외부 조건만으로는 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`를 통한 프로바이더의 재실행 안전성 승인이 포함되어야 합니다. +`previous_response_id` 또는 `conversation_id`을 사용하는 상태 저장 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 공급자 외부 조건자만으로 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`을 통해 공급자가 제공한 재실행 안전 승인이 포함되어야 합니다. -##### Runner와 에이전트의 병합 동작 +##### Runner 및 에이전트 병합 동작 -`retry`는 Runner 수준 및 에이전트 수준 `ModelSettings` 사이에서 깊은 병합됩니다. +`retry`은 Runner 수준과 에이전트 수준의 `ModelSettings` 간에 심층 병합됩니다. -- 에이전트는 `retry.max_retries`만 재정의하면서 Runner의 `policy`를 상속할 수 있습니다. -- 에이전트는 `retry.backoff`의 일부만 재정의하고 Runner의 다른 백오프 필드를 유지할 수 있습니다. -- `policy`는 런타임 전용이므로 직렬화된 `ModelSettings`는 `max_retries` 및 `backoff`를 유지하지만 콜백 자체는 생략합니다. +- 에이전트는 `retry.max_retries`만 재정의하면서 Runner의 `policy`을 계속 상속할 수 있습니다. +- 에이전트는 `retry.backoff`의 일부만 재정의하고 Runner의 형제 백오프 필드를 유지할 수 있습니다. +- `policy`은 런타임 전용이므로 직렬화된 `ModelSettings`은 `max_retries`과 `backoff`을 유지하지만 콜백 자체는 생략합니다. -더 자세한 코드 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 코드 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. +더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. -## OpenAI 이외의 프로바이더 문제 해결 +## OpenAI 이외의 공급자 문제 해결 ### 트레이싱 클라이언트 오류 401 -트레이싱 관련 오류가 발생한다면 트레이스가 OpenAI 서버로 업로드되는데 OpenAI API 키가 없기 때문입니다. 이 문제를 해결하는 방법은 세 가지입니다. +트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. 1. 트레이싱을 완전히 비활성화합니다: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] 2. 트레이싱용 OpenAI 키를 설정합니다: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급한 키여야 합니다. @@ -600,14 +599,14 @@ SDK는 `retry_policies`에 바로 사용할 수 있는 헬퍼를 제공합니다 ### Responses API 지원 -SDK는 기본적으로 Responses API를 사용하지만, 아직 많은 다른 LLM 프로바이더가 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 이를 해결하는 방법은 두 가지입니다. +SDK는 기본적으로 Responses API를 사용하지만 다른 많은 LLM 공급자는 아직 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. 1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]를 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. -2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 코드 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. +2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. ### Chat Completions 호환성 옵션 -Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, 프롬프트 또는 텍스트 전용이 아닌 도구 출력처럼 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 자동으로 제거하여 호환성을 유지합니다. 개발 중 이러한 불일치가 즉시 실패하도록 하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화하세요. +Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, Responses API의 `prompt` 필드, 텍스트 전용이 아닌 도구 출력처럼 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 별도 알림 없이 삭제하여 호환성을 유지합니다. 개발 중에 이러한 불일치가 즉시 실패하도록 하려면 OpenAI 공급자에서 엄격한 기능 검증을 활성화하세요. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -625,9 +624,9 @@ result = await Runner.run( ) ``` -[`MultiProvider`][agents.MultiProvider]를 사용하는 경우 대신 `openai_strict_feature_validation=True`를 전달하세요. +[`MultiProvider`][agents.MultiProvider]을 사용하는 경우 대신 `openai_strict_feature_validation=True`을 전달하세요. -일부 OpenAI 호환 Chat Completions 프로바이더는 점진적인 SDK 처리에 충분히 신뢰할 수 없는 청크 형태로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍된 도구 호출 버퍼링을 활성화하여 프로바이더 스트림이 종료된 후에만 SDK가 도구 호출을 내보내도록 하세요. +일부 OpenAI 호환 Chat Completions 공급자는 증분 SDK 처리에 충분히 안정적이지 않은 청크로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 공급자 스트림이 끝난 후에만 SDK가 도구 호출을 내보내도록 하세요. ```python from agents import OpenAIProvider @@ -638,11 +637,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider]에서는 `openai_buffer_streamed_tool_calls=True`를 사용하세요. +[`MultiProvider`][agents.MultiProvider]에서는 `openai_buffer_streamed_tool_calls=True`을 사용하세요. ### structured outputs 지원 -일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이 경우 다음과 유사한 오류가 발생할 수 있습니다. +일부 모델 공급자는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 지원하지 않습니다. 이로 인해 때때로 다음과 유사한 오류가 발생합니다. ``` @@ -650,42 +649,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`는 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. +이는 일부 모델 공급자의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`을 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 공급자를 사용하는 것을 권장합니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. -## 프로바이더 간 모델 혼합 +## 공급자 간 모델 혼합 -모델 프로바이더 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. +모델 공급자 간의 기능 차이를 알고 있어야 하며, 그렇지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 공급자는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. -- 이해하지 못하는 프로바이더에 지원되지 않는 `tools`를 전송하지 마세요. -- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. -- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 유효하지 않은 JSON을 생성할 수 있다는 점에 유의하세요. +- 이해할 수 없는 공급자에 지원되지 않는 `tools`을 보내지 마세요. +- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. +- 구조화된 JSON 출력을 지원하지 않는 공급자는 때때로 유효하지 않은 JSON을 생성한다는 점에 유의하세요. ## 서드 파티 어댑터 -SDK의 기본 제공 프로바이더 통합 지점으로 충분하지 않을 때만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM이나 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델과 OpenAI 이외의 프로바이더를 결합해야 하거나 기본 제공 경로에서 제공하지 않는 어댑터 관리형 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위한 것입니다. 어댑터는 SDK와 업스트림 모델 프로바이더 사이에 또 다른 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 프로바이더마다 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선형 베타 어댑터 통합으로 포함되어 있습니다. +SDK에 기본 제공되는 공급자 통합 지점만으로 충분하지 않을 때만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 권장합니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 공급자와 결합해야 하거나 어댑터에서만 제공하는 공급자 지원 범위 또는 라우팅이 필요한 경우에 사용합니다. 어댑터는 SDK와 업스트림 모델 공급자 사이에 또 다른 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 공급자마다 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선 지원 방식의 베타 어댑터 통합으로 포함되어 있습니다. ### Any-LLM -Any-LLM 지원은 Any-LLM이 관리하는 프로바이더 지원 범위나 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. +Any-LLM 지원은 Any-LLM이 관리하는 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 지원 방식의 베타로 포함됩니다. -업스트림 프로바이더 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 프로바이더별 호환성 계층을 사용할 수 있습니다. +업스트림 공급자 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 공급자별 호환성 계층을 사용할 수 있습니다. -Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 후 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)부터 시작하세요. [`MultiProvider`][agents.MultiProvider]와 함께 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`를 사용할 수 있습니다. 모델 인터페이스를 명시적으로 고정해야 한다면 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`를 전달하세요. +Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)부터 시작하세요. [`MultiProvider`][agents.MultiProvider]에서 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`을 사용할 수 있습니다. 모델 표면을 명시적으로 고정해야 한다면 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`을 전달하세요. -Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의됩니다. 업스트림 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드는 사용량 청크를 내보내기 전에 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 전용 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. +Any-LLM은 서드 파티 어댑터 계층이므로 공급자 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의됩니다. 업스트림 공급자가 사용량 지표를 반환하면 자동으로 전달되지만, 스트리밍 Chat Completions 백엔드는 사용량 청크를 내보내기 전에 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses별 동작에 의존한다면 배포하려는 정확한 공급자 백엔드를 검증하세요. ### LiteLLM -LiteLLM 지원은 LiteLLM별 프로바이더 지원 범위나 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. +LiteLLM 지원은 LiteLLM별 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 지원 방식의 베타로 포함됩니다. -LiteLLM이 필요하면 `openai-agents[litellm]`을 설치한 후 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)부터 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. +LiteLLM이 필요하면 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)부터 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. -일부 LiteLLM 기반 프로바이더는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요하면 `ModelSettings(include_usage=True)`를 전달하세요. structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. +LiteLLM 어댑터를 통해 접근하는 일부 공급자는 기본적으로 SDK 사용량 지표를 채우지 않습니다. 사용량 보고가 필요한 경우 `ModelSettings(include_usage=True)`을 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 공급자 백엔드를 검증하세요. -LiteLLM이 응답 객체에 대한 Pydantic 직렬화 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. +LiteLLM이 응답 객체에 대해 Pydantic 직렬 변환기 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -패치는 기본적으로 비활성화되어 있으며 값이 `1` 또는 `true`인 경우에만 활성화됩니다. 비공개 LiteLLM 로깅 헬퍼를 래핑하여 특정 종류의 LiteLLM 응답 직렬화 경고를 억제하므로, 일반적인 직렬화 설정이 아니라 특정 문제를 위한 우회 방법으로 사용하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file +이 패치는 기본적으로 비활성화되어 있으며 `1` 또는 `true` 값에 대해서만 활성화됩니다. 비공개 LiteLLM 로깅 헬퍼를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로 일반적인 직렬화 설정이 아닌 목적이 제한된 우회책으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file diff --git a/docs/ko/multi_agent.md b/docs/ko/multi_agent.md index fb10e32834..9e443f8ddc 100644 --- a/docs/ko/multi_agent.md +++ b/docs/ko/multi_agent.md @@ -4,61 +4,61 @@ search: --- # 에이전트 오케스트레이션 -오케스트레이션은 앱에서 에이전트가 흐르는 방식을 의미합니다. 어떤 에이전트가 어떤 순서로 실행되며, 다음에 무엇이 일어날지 어떻게 결정할까요? 에이전트를 오케스트레이션하는 주요 방법은 두 가지입니다. +오케스트레이션은 앱에서 에이전트가 동작하는 흐름을 의미합니다. 어떤 에이전트가 어떤 순서로 실행되며, 다음 단계는 어떻게 결정될까요? 에이전트를 오케스트레이션하는 방식은 크게 두 가지입니다. -1. LLM이 결정을 내리도록 허용: LLM의 지능을 사용해 계획하고, 추론하고, 이를 바탕으로 어떤 단계를 수행할지 결정합니다. +1. LLM이 결정하도록 허용하는 방식: LLM의 지능을 활용하여 계획하고 추론한 뒤, 이를 바탕으로 수행할 단계를 결정합니다. 2. 코드를 통한 오케스트레이션: 코드로 에이전트의 흐름을 결정합니다. -이러한 패턴은 함께 조합해 사용할 수 있습니다. 각 방식에는 아래에 설명된 고유한 장단점이 있습니다. +이러한 패턴을 조합하여 사용할 수도 있습니다. 각 패턴에는 아래에서 설명하는 장단점이 있습니다. ## LLM을 통한 오케스트레이션 -에이전트는 instructions, tools 및 핸드오프를 갖춘 LLM입니다. 즉, 개방형 작업이 주어지면 LLM은 tools를 사용해 작업을 수행하고 데이터를 얻으며, 핸드오프를 사용해 하위 에이전트에게 작업을 위임하면서, 작업을 어떻게 처리할지 자율적으로 계획할 수 있습니다. 예를 들어 연구 에이전트에는 다음과 같은 도구를 장착할 수 있습니다. +에이전트는 지침, 도구, 핸드오프가 제공된 LLM입니다. 즉, 개방형 작업이 주어지면 LLM은 작업을 어떻게 처리할지 자율적으로 계획하고, 도구를 사용하여 작업을 수행하고 데이터를 수집하며, 핸드오프를 사용하여 하위 에이전트에 작업을 위임할 수 있습니다. 예를 들어 리서치 에이전트에는 다음과 같은 기능을 제공할 수 있습니다. - 온라인에서 정보를 찾기 위한 웹 검색 -- 독점 데이터와 연결을 검색하기 위한 파일 검색 및 검색 +- 독점 데이터와 연결된 데이터 소스를 검색하기 위한 파일 검색 및 검색 결과 가져오기 - 컴퓨터에서 작업을 수행하기 위한 컴퓨터 사용 - 데이터 분석을 위한 코드 실행 -- 기획, 보고서 작성 등에 뛰어난 전문 에이전트로의 핸드오프 +- 계획 수립, 보고서 작성 등에 뛰어난 전문 에이전트로의 핸드오프. ### 핵심 SDK 패턴 -Python SDK에서는 두 가지 오케스트레이션 패턴이 가장 자주 사용됩니다. +Python SDK에서는 다음 두 가지 오케스트레이션 패턴이 가장 많이 사용됩니다. -| 패턴 | 작동 방식 | 가장 적합한 경우 | +| 패턴 | 작동 방식 | 적합한 경우 | | --- | --- | --- | -| Agents as tools | 관리자 에이전트가 대화의 제어권을 유지하고 `Agent.as_tool()`을 통해 전문 에이전트를 호출합니다. | 하나의 에이전트가 최종 답변을 담당하거나, 여러 전문가의 출력을 결합하거나, 공유 가드레일을 한곳에서 적용하도록 하고 싶을 때 | -| 핸드오프 | 트리아지 에이전트가 대화를 전문가에게 라우팅하고, 해당 전문가가 나머지 턴 동안 활성 에이전트가 됩니다. | 전문가가 직접 응답하거나, 프롬프트를 집중된 상태로 유지하거나, 관리자가 결과를 설명하지 않고 instructions를 전환하도록 하고 싶을 때 | +| Agents as tools | 관리자 에이전트가 대화의 제어권을 유지하면서 `Agent.as_tool()`을 통해 전문 에이전트를 호출합니다. | 하나의 에이전트가 최종 답변을 담당하거나, 여러 전문가의 출력을 결합하거나, 공유 SDK 가드레일을 한곳에서 적용하도록 하려는 경우 | +| 핸드오프 | 트리아지 에이전트가 대화를 전문 에이전트로 라우팅하고, 해당 전문 에이전트가 나머지 턴 동안 활성 에이전트가 됩니다. | 전문 에이전트가 직접 응답하거나, 프롬프트의 초점을 유지하거나, 관리자가 결과를 설명하지 않아도 핸드오프를 통해 활성 지침을 전환하도록 하려는 경우 | -전문가가 제한된 하위 작업을 도와야 하지만 사용자와 직접 마주하는 대화를 인수해서는 안 되는 경우 **agents as tools**를 사용합니다. 라우팅 자체가 워크플로의 일부이고 선택된 전문가가 상호작용의 다음 부분을 담당하도록 하고 싶을 때는 **핸드오프**를 사용합니다. +전문 에이전트가 범위가 명확한 하위 작업을 지원하되 사용자와의 대화를 넘겨받아서는 안 되는 경우 **agents as tools**를 사용합니다. 라우팅 자체가 워크플로의 일부이고 선택된 전문 에이전트가 현재 턴의 나머지를 담당하도록 하려는 경우 **핸드오프**를 사용합니다. -두 가지를 조합할 수도 있습니다. 트리아지 에이전트가 전문가에게 핸드오프할 수 있으며, 해당 전문가는 여전히 좁은 범위의 하위 작업을 위해 다른 에이전트를 도구로 호출할 수 있습니다. +두 방식을 함께 사용할 수도 있습니다. 트리아지 에이전트가 전문 에이전트로 핸드오프한 뒤, 해당 전문 에이전트가 범위가 좁은 하위 작업을 위해 다른 에이전트를 도구로 호출할 수 있습니다. -이 패턴은 작업이 개방형이고 LLM의 지능에 의존하고자 할 때 유용합니다. 여기서 가장 중요한 전략은 다음과 같습니다. +이 패턴은 작업이 개방형이고 LLM의 지능을 활용하려는 경우 매우 적합합니다. 여기서 가장 중요한 전략은 다음과 같습니다. -1. 좋은 프롬프트에 투자합니다. 어떤 도구를 사용할 수 있는지, 어떻게 사용해야 하는지, 어떤 매개변수 범위 내에서 작동해야 하는지 명확히 합니다. -2. 앱을 모니터링하고 반복적으로 개선합니다. 문제가 발생하는 지점을 파악하고 프롬프트를 반복 개선합니다. -3. 에이전트가 스스로 성찰하고 개선하도록 허용합니다. 예를 들어 루프 안에서 실행하고 스스로 비평하게 하거나, 오류 메시지를 제공하고 개선하게 합니다. -4. 무엇이든 잘하도록 기대되는 범용 에이전트보다, 하나의 작업에 탁월한 전문 에이전트를 둡니다. -5. [평가](https://platform.openai.com/docs/guides/evals)에 투자합니다. 이를 통해 에이전트를 훈련해 작업 수행 능력을 개선하고 향상시킬 수 있습니다. +1. 좋은 프롬프트 작성에 투자합니다. 사용할 수 있는 도구, 도구 사용 방법, 에이전트가 준수해야 하는 제약 조건을 명확하게 설명합니다. +2. 앱을 모니터링하고 반복적으로 개선합니다. 문제가 발생하는 지점을 확인하고 프롬프트를 반복적으로 개선합니다. +3. 에이전트가 스스로 성찰하고 개선할 수 있도록 합니다. 예를 들어 루프에서 실행하여 스스로 비평하게 하거나, 오류 메시지를 제공하여 개선하도록 합니다. +4. 어떤 작업이든 잘할 것으로 기대되는 범용 에이전트보다 하나의 작업에 뛰어난 전문 에이전트를 구성합니다. +5. [평가](https://platform.openai.com/docs/guides/evals)에 투자합니다. 이를 통해 에이전트를 훈련하여 개선하고 작업 수행 능력을 높일 수 있습니다. -이러한 오케스트레이션 방식의 핵심 SDK 기본 구성 요소를 알고 싶다면 [도구](tools.md), [핸드오프](handoffs.md), [에이전트 실행](running_agents.md)부터 시작하세요. +이러한 오케스트레이션 방식의 기반이 되는 핵심 SDK 기본 구성 요소를 알아보려면 [도구](tools.md), [핸드오프](handoffs.md), [에이전트 실행](running_agents.md)부터 살펴보세요. ## 코드를 통한 오케스트레이션 -LLM을 통한 오케스트레이션은 강력하지만, 코드를 통한 오케스트레이션은 속도, 비용, 성능 측면에서 작업을 더 결정적이고 예측 가능하게 만듭니다. 여기서 흔히 사용되는 패턴은 다음과 같습니다. +LLM을 통한 오케스트레이션은 강력하지만, 코드를 통한 오케스트레이션을 사용하면 속도, 비용 및 성능 측면에서 작업을 더욱 결정론적이고 예측 가능하게 만들 수 있습니다. 일반적인 패턴은 다음과 같습니다. -- [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 사용해 코드로 검사할 수 있는 적절한 형식의 데이터를 생성합니다. 예를 들어 에이전트에게 작업을 몇 가지 카테고리로 분류하게 한 다음, 해당 카테고리를 바탕으로 다음 에이전트를 선택할 수 있습니다. -- 하나의 에이전트 출력을 다음 에이전트의 입력으로 변환해 여러 에이전트를 체이닝합니다. 블로그 게시물 작성 같은 작업을 연구하기, 개요 작성하기, 블로그 게시물 작성하기, 비평하기, 개선하기와 같은 일련의 단계로 분해할 수 있습니다. -- 평가하고 피드백을 제공하는 에이전트와 함께, 작업을 수행하는 에이전트를 `while` 루프에서 실행하여 평가자가 출력이 특정 기준을 통과했다고 말할 때까지 반복합니다. -- 여러 에이전트를 병렬로 실행합니다. 예를 들어 `asyncio.gather` 같은 Python 기본 구성 요소를 사용할 수 있습니다. 서로 의존하지 않는 여러 작업이 있을 때 속도 측면에서 유용합니다. +- [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 사용하여 코드로 검사할 수 있는 적절한 형식의 데이터를 생성합니다. 예를 들어 에이전트에게 작업을 몇 가지 카테고리로 분류하도록 요청한 다음, 해당 카테고리에 따라 다음 에이전트를 선택할 수 있습니다. +- 한 에이전트의 출력을 다음 에이전트의 입력으로 변환하여 여러 에이전트를 연결합니다. 블로그 게시물 작성과 같은 작업을 리서치 수행, 개요 작성, 블로그 게시물 작성, 비평, 개선 등의 일련의 단계로 분해할 수 있습니다. +- `while` 루프의 각 반복에서 작업 에이전트를 실행하여 출력을 생성한 다음, 평가 에이전트를 실행하여 해당 출력을 평가하고 피드백을 제공하도록 합니다. 평가 에이전트가 출력이 필수 기준을 충족한다고 판단하면 중지합니다. +- 예를 들어 `asyncio.gather` 같은 Python 기본 구성 요소를 사용하여 여러 에이전트를 병렬로 실행합니다. 서로 의존하지 않는 여러 작업이 있을 때 속도를 높이는 데 유용합니다. -[`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns)에 여러 코드 예제가 있습니다. +[`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns)에서 다양한 코드 예제를 확인할 수 있습니다. ## 관련 가이드 -- 구성 패턴과 에이전트 설정은 [에이전트](agents.md)를 참조하세요. -- `Agent.as_tool()` 및 관리자 스타일 오케스트레이션은 [도구](tools.md#agents-as-tools)를 참조하세요. -- 전문 에이전트 간 위임은 [핸드오프](handoffs.md)를 참조하세요. -- 실행별 오케스트레이션 제어와 대화 상태는 [에이전트 실행](running_agents.md)을 참조하세요. -- 최소한의 엔드투엔드 핸드오프 예제는 [빠른 시작](quickstart.md)을 참조하세요. \ No newline at end of file +- 구성 패턴 및 에이전트 설정은 [에이전트](agents.md)를 참고하세요. +- `Agent.as_tool()` 및 관리자 스타일 오케스트레이션은 [도구](tools.md#agents-as-tools)를 참고하세요. +- 전문 에이전트 간 위임은 [핸드오프](handoffs.md)를 참고하세요. +- 실행별 오케스트레이션 제어 및 대화 상태는 [에이전트 실행](running_agents.md)을 참고하세요. +- 최소한의 엔드 투 엔드 핸드오프 예제는 [빠른 시작](quickstart.md)을 참고하세요. \ No newline at end of file diff --git a/docs/ko/realtime/guide.md b/docs/ko/realtime/guide.md index af9570410d..2c6fced902 100644 --- a/docs/ko/realtime/guide.md +++ b/docs/ko/realtime/guide.md @@ -6,46 +6,46 @@ search: 이 가이드에서는 OpenAI Agents SDK의 실시간 계층이 OpenAI Realtime API에 어떻게 매핑되는지와 파이썬 SDK가 추가로 제공하는 동작을 설명합니다. -!!! note "여기서 시작하기" +!!! note "여기서 시작" - 기본 파이썬 경로를 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어보세요. 애플리케이션에서 서버 측 WebSocket과 SIP 중 무엇을 사용해야 할지 결정하려면 [Realtime 전송](transport.md)을 읽어보세요. 브라우저 WebRTC 전송은 파이썬 SDK에 포함되지 않습니다. + 기본 파이썬 방식을 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어보세요. 앱에서 서버 측 WebSocket과 SIP 중 무엇을 사용해야 할지 결정하려면 [실시간 전송](transport.md)을 읽어보세요. 브라우저 WebRTC 전송은 파이썬 SDK에 포함되지 않습니다. ## 개요 -실시간 에이전트는 Realtime API와 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하고, 도구를 호출하고, 매 턴마다 새 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. +실시간 에이전트는 Realtime API와 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하며, 도구를 호출하고, 매 턴마다 새 요청을 다시 시작하지 않고 인터럽션(중단 처리)을 처리할 수 있습니다. 주요 SDK 구성 요소는 다음과 같습니다. -- **RealtimeAgent**: 하나의 실시간 전문 에이전트를 위한 instructions, 도구, 출력 가드레일, 핸드오프 +- **RealtimeAgent**: 하나의 실시간 전문가를 위한 instructions, 도구, 출력 가드레일, 핸드오프 - **RealtimeRunner**: 시작 에이전트를 실시간 전송에 연결하는 세션 팩토리 -- **RealtimeSession**: 입력을 전송하고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 활성 세션 +- **RealtimeSession**: 입력을 보내고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 라이브 세션 - **RealtimeModel**: 전송 추상화입니다. 기본값은 OpenAI의 서버 측 WebSocket 구현입니다. ## 세션 수명 주기 일반적인 실시간 세션은 다음과 같이 진행됩니다. -1. 하나 이상의 `RealtimeAgent`를 생성합니다. -2. 시작 에이전트로 `RealtimeRunner`를 생성합니다. +1. 하나 이상의 `RealtimeAgent`을 생성합니다. +2. 시작 에이전트로 `RealtimeRunner`을 생성합니다. 3. `await runner.run()`을 호출하여 `RealtimeSession`을 가져옵니다. -4. `async with session:` 또는 `await session.enter()`을 사용해 세션에 진입합니다. -5. `send_message()` 또는 `send_audio()`로 사용자 입력을 전송합니다. +4. `async with session:` 또는 `await session.enter()`로 세션에 진입합니다. +5. `send_message()` 또는 `send_audio()`로 사용자 입력을 보냅니다. 6. 대화가 끝날 때까지 세션 이벤트를 순회합니다. -텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태, 활성 에이전트 구성을 전송 계층과 동기화하는 활성 세션 객체를 반환합니다. +텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태, 활성 에이전트 구성을 전송 계층과 동기화하는 라이브 세션 객체를 반환합니다. -기본적으로 `RealtimeRunner`는 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 파이썬 경로는 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달해도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 방식만 달라질 수 있습니다. +기본적으로 `RealtimeRunner`은 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 파이썬 방식은 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달하더라도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 방식만 달라질 수 있습니다. ## 에이전트 및 세션 구성 -`RealtimeAgent`는 의도적으로 일반 `Agent` 타입보다 지원 범위가 좁습니다. +`RealtimeAgent`은 의도적으로 일반 `Agent` 타입보다 범위가 좁습니다. - 모델 선택은 에이전트별이 아니라 세션 수준에서 구성합니다. - structured outputs은 지원되지 않습니다. -- 음성을 구성할 수 있지만 세션에서 음성 오디오를 이미 생성한 후에는 변경할 수 없습니다. +- 음성을 구성할 수 있지만 세션에서 음성 오디오가 생성된 후에는 변경할 수 없습니다. - Instructions, 함수 도구, 핸드오프, 훅, 출력 가드레일은 모두 계속 작동합니다. -`RealtimeSessionModelSettings`는 새로운 중첩 `audio` 구성과 이전의 평면 별칭을 모두 지원합니다. 새 코드에는 중첩 구조를 사용하는 것이 좋으며, 새 실시간 에이전트에는 `gpt-realtime-2.1`부터 시작하세요. +`RealtimeSessionModelSettings`은 최신 중첩 `audio` 구성과 이전의 플랫 별칭을 모두 지원합니다. 새 코드에는 중첩 형태를 사용하는 것이 좋으며, 새로운 실시간 에이전트에는 `gpt-realtime-2.1`으로 시작하세요. ```python runner = RealtimeRunner( @@ -79,7 +79,7 @@ runner = RealtimeRunner( - `prompt` - `tracing` -`RealtimeRunner(config=...)`에서 유용한 실행 수준 설정은 다음과 같습니다. +`RealtimeRunner(config=...)`의 유용한 실행 수준 설정은 다음과 같습니다. - `async_tool_calls` - `output_guardrails` @@ -87,13 +87,13 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -전체 타입 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]와 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]를 참조하세요. +전체 타입 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]를 참조하세요. ## 입력 및 출력 ### 텍스트 및 구조화된 사용자 메시지 -일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]를 사용합니다. +일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]을 사용합니다. ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +111,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -구조화된 메시지는 실시간 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 이 방식으로 `input_image` 메시지를 전달합니다. +구조화된 메시지는 실시간 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 `input_image` 메시지를 이 방식으로 전달합니다. ### 오디오 입력 -원문 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]를 사용합니다. +원시 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용합니다. ```python await session.send_audio(audio_bytes) ``` -서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 상위 수준 편의 기능은 다음과 같습니다. +서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 상위 수준의 편의 기능은 다음과 같습니다. ```python await session.send_audio(audio_bytes, commit=True) ``` -더 세밀하게 제어해야 하는 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 원문 클라이언트 이벤트를 전송할 수도 있습니다. +더 낮은 수준의 제어가 필요한 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 Realtime API 클라이언트 이벤트를 직접 보낼 수도 있습니다. ### 수동 응답 제어 -`session.send_message()`는 상위 수준 경로를 사용하여 사용자 입력을 전송하고 응답을 시작합니다. 원문 오디오 버퍼링은 모든 구성에서 동일한 작업을 **자동으로** 수행하지는 않습니다. +`session.send_message()`은 상위 수준 방식을 사용하여 사용자 입력을 보내고 응답을 시작합니다. 일부 구성에서는 원시 오디오 버퍼링이 동일한 동작을 **자동으로 수행하지 않습니다**. -Realtime API 수준에서 수동 턴 제어를 수행하려면 원문 `session.update`로 `turn_detection`을 지운 다음 `input_audio_buffer.commit`과 `response.create`를 직접 전송해야 합니다. +Realtime API 수준에서 수동 턴 제어란 `turn_detection`을 `null`로 설정하는 `session.update` 이벤트를 보낸 다음, `input_audio_buffer.commit`와 `response.create`을 직접 보내는 것을 의미합니다. -턴을 수동으로 관리하는 경우 모델 전송을 통해 원문 클라이언트 이벤트를 전송할 수 있습니다. +턴을 수동으로 관리하는 경우 모델 전송을 통해 원시 클라이언트 이벤트를 보낼 수 있습니다. ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -151,15 +151,15 @@ await session.model.send_event( 이 패턴은 다음과 같은 경우에 유용합니다. -- `turn_detection`이 비활성화되어 있고 모델이 응답할 시점을 직접 결정하려는 경우 -- 응답을 트리거하기 전에 사용자 입력을 검사하거나 제어하려는 경우 -- 대역 외 응답에 사용자 지정 프롬프트가 필요한 경우 +- `turn_detection`이 비활성화되어 있고 모델의 응답 시점을 직접 결정하려는 경우 +- 응답을 트리거하기 전에 사용자 입력을 검사하거나 차단하려는 경우 +- 대역 외 응답을 위한 사용자 지정 프롬프트가 필요한 경우 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제에서는 원문 `response.create`를 사용하여 첫 인사말을 강제로 생성합니다. +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제에서는 원시 `response.create`을 사용하여 첫 인사말을 강제로 생성합니다. ## 이벤트, 기록 및 인터럽션(중단 처리) -`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내는 동시에, 필요한 경우 원문 모델 이벤트도 계속 전달합니다. +`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내는 동시에, 필요할 때 원시 모델 이벤트도 계속 전달합니다. 중요한 세션 이벤트는 다음과 같습니다. @@ -173,13 +173,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`와 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지, 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. +UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`과 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지, 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. ### 사용량 집계 -완료된 모델 응답에 사용량이 포함되면 OpenAI 실시간 모델은 `raw_model_event` 내부에 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]를 내보냅니다. 해당 `usage` 필드에는 그 응답의 토큰 수가 포함되며, `input_tokens_details`와 `output_tokens_details`는 선택적인 모달리티별 세부 내역을 제공합니다. +완료된 모델 응답에 사용량이 포함된 경우 SDK의 OpenAI `RealtimeModel` 전송은 `raw_model_event` 내부에서 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]를 내보냅니다. `usage` 필드에는 해당 응답의 토큰 수가 포함되며, `input_tokens_details`과 `output_tokens_details`는 선택적인 모달리티별 분석을 제공합니다. -세션은 각 응답의 사용량도 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. 활성 세션의 누적 사용량을 확인하려면 이후에 발생하는 `agent_end`와 같은 상위 수준 이벤트에서 `event.info.context.usage`를 읽으세요. +또한 세션은 각 응답의 사용량을 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. 라이브 세션의 누적 사용량을 확인하려면 `agent_end`과 같은 후속 상위 수준 이벤트의 `event.info.context.usage`에서 이를 읽으세요. ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,13 +197,13 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -사용량은 모델 제공자가 완료된 응답에 이를 포함한 경우에만 보고됩니다. 누적 값에는 해당 `RealtimeSession`이 수신한 응답이 포함되며, 세션 간 합계는 아닙니다. +사용량은 모델 제공자가 완료된 응답에 포함한 경우에만 보고됩니다. 누적 값은 해당 `RealtimeSession`에서 수신한 응답에 적용되며, 여러 세션에 걸친 합계가 아닙니다. ### 인터럽션(중단 처리) 및 재생 추적 -사용자가 어시스턴트의 응답을 중단하면 세션은 `audio_interrupted`를 내보내고 기록을 업데이트하여 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 유지합니다. +사용자가 어시스턴트의 응답을 중단하면 세션은 `audio_interrupted`을 내보내고 기록을 업데이트하여 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 합니다. -지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기만으로도 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 오디오가 모두 이미 재생되었다고 가정하는 대신 실제 재생 진행률을 기준으로 인터럽션(중단 처리) 시점의 잘라내기를 수행하도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]를 사용하세요. +지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기로 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 모든 오디오를 이미 들었다고 가정하는 대신 실제 재생 위치에서 중단된 응답을 잘라내도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]을 사용하세요. [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)의 Twilio 예제에서 이 패턴을 확인할 수 있습니다. @@ -211,7 +211,7 @@ async for event in session: ### 함수 도구 -실시간 에이전트는 실시간 대화 중 함수 도구를 지원합니다. +실시간 에이전트는 라이브 대화 중 함수 도구를 지원합니다. ```python from agents.decorators import tool @@ -232,9 +232,9 @@ agent = RealtimeAgent( ### 도구 승인 -함수 도구는 실행 전에 사람의 승인을 요구할 수 있습니다. 이 경우 세션은 `tool_approval_required`를 내보내고 `approve_tool_call()` 또는 `reject_tool_call()`을 호출할 때까지 도구 실행을 일시 중지합니다. +함수 도구는 실행 전에 사람의 승인을 요구할 수 있습니다. 이 경우 세션은 `tool_approval_required`을 내보내고 `approve_tool_call()` 또는 `reject_tool_call()`을 호출할 때까지 도구 실행을 일시 중지합니다. -도구에 입력 가드레일도 있는 경우 승인 후 실행 직전에 해당 가드레일이 실행됩니다. 승인 이벤트가 발생하기 전에 입력 가드레일을 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 실행 전에 다시 검사됩니다. +도구에 입력 가드레일도 있는 경우, 이러한 가드레일은 승인 후 실행 직전에 실행됩니다. 승인 이벤트가 발생하기 전에 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성하세요. 이 사전 승인 검사를 통과한 호출도 실행 전 승인 후 다시 검사됩니다. ```python async for event in session: @@ -242,11 +242,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)를 참조하세요. 휴먼인더루프 문서에서도 [휴먼인더루프 (HITL)](../human_in_the_loop.md)의 이 흐름을 다시 안내합니다. +구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)을 참조하세요. 휴먼인더루프 문서에서도 [휴먼인더루프 (HITL)](../human_in_the_loop.md)에 이 흐름을 안내합니다. ### 핸드오프 -실시간 핸드오프를 사용하면 한 에이전트가 활성 대화를 다른 전문 에이전트에게 전달할 수 있습니다. +실시간 핸드오프를 사용하면 한 에이전트가 라이브 대화를 다른 전문가에게 전달할 수 있습니다. ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +268,11 @@ main_agent = RealtimeAgent( ) ``` -별도 래핑되지 않은 `RealtimeAgent` 핸드오프는 자동으로 래핑되며, `realtime_handoff(...)`를 사용하면 이름, 설명, 검증, 콜백, 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프의 `input_filter`를 지원하지 **않습니다**. +핸드오프로 직접 사용되는 `RealtimeAgent` 객체는 자동으로 래핑되며, `realtime_handoff(...)`을 사용하면 이름, 설명, 검증, 콜백, 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프 `input_filter`을 지원하지 **않습니다**. ### 가드레일 -실시간 에이전트는 에이전트 응답의 출력 가드레일과 함수 도구 호출의 입력 가드레일을 지원합니다. 출력 가드레일은 모든 부분 델타마다 실행되는 대신 출력 텍스트 및 오디오 트랜스크립트 델타가 디바운스 방식으로 누적될 때 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`를 내보냅니다. +실시간 에이전트는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일 검사는 디바운스됩니다. 각 검사는 모든 부분 델타가 아니라 누적된 출력 텍스트 및 오디오 트랜스크립트 델타에서 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`을 내보냅니다. ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,15 +292,15 @@ agent = RealtimeAgent( ) ``` -실시간 출력 가드레일이 오디오 트랜스크립트에서 작동하면 세션은 활성 응답을 중단하고, `response.cancel`을 강제로 실행하고, `guardrail_tripped`를 내보내고, 트리거된 가드레일의 이름을 포함한 후속 사용자 메시지를 전송하여 모델이 대체 응답을 생성할 수 있도록 합니다. 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되어 있을 수 있으므로 오디오 플레이어는 계속 `audio_interrupted`를 수신하고 로컬 재생을 즉시 중지해야 합니다. 내장 OpenAI Realtime 전송을 사용할 때 가드레일이 원본 응답이 종료된 후 완료되면 세션은 해당 응답의 버퍼링된 재생만 중단하고 더 새로운 응답은 취소하지 않습니다. 텍스트 전용 출력의 경우 세션은 대신 응답 범위의 `response.cancel`을 전송합니다. 중지할 오디오 재생이 없으므로 `audio_interrupted`는 내보내지 않습니다. 내장 OpenAI Realtime 모델을 사용할 때 텍스트 전용 경로에서도 동일한 `guardrail_tripped` 이벤트와 후속 사용자 메시지가 내보내집니다. +실시간 출력 가드레일이 오디오 트랜스크립트에서 트리거되면 세션은 활성 응답을 중단하고 `response.cancel`을 강제로 실행하며, `guardrail_tripped`을 내보내고, 트리거된 가드레일의 이름이 포함된 후속 사용자 메시지를 보내 모델이 대체 응답을 생성할 수 있게 합니다. 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되었을 수 있으므로, 오디오 플레이어는 계속 `audio_interrupted`을 수신하고 로컬 재생을 즉시 중지해야 합니다. 기본 제공 OpenAI Realtime 전송을 사용하는 경우, 검사 대상 응답이 종료된 후 가드레일 검사가 완료되면 세션은 해당 응답의 버퍼링된 재생만 중단하고 이후에 시작된 응답은 취소하지 않습니다. 텍스트 전용 출력에서는 대신 응답 범위의 `response.cancel`을 보냅니다. 중지할 오디오 재생이 없으므로 `audio_interrupted`은 내보내지 않습니다. 기본 제공 OpenAI Realtime 모델을 사용할 때 텍스트 전용 경로에서도 동일한 `guardrail_tripped` 이벤트와 후속 사용자 메시지가 발생합니다. -사용자 지정 `RealtimeModel` 전송은 동일한 원본 응답 범위의 오디오 인터럽션(중단 처리) 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`와 `playback_only`를 준수해야 합니다. 또한 텍스트 전용 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`를 재정의해야 합니다. 구현에서는 전송의 실제 이벤트 커밋 경계에서 제공된 조건을 다시 검사하거나 직렬화해야 합니다. `send_event()`를 기다리기 전에 조건을 검사하면 메시지가 커밋되기 전에 더 새로운 응답이 시작될 수 있으므로 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. +사용자 지정 `RealtimeModel` 전송은 동일한 소스 범위 오디오 인터럽션(중단 처리) 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`과 `playback_only`을 준수해야 합니다. 텍스트 전용 출력 경로의 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`도 재정의해야 합니다. 구현에서는 제공된 조건을 전송의 실제 이벤트 커밋 경계에서 다시 검사하거나, 조건 검사를 이벤트 커밋과 함께 직렬화해야 합니다. 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 조건을 한 번 검사한 다음 이벤트를 별도로 보내면 해당 검사와 이벤트 커밋 사이에 다른 응답이 시작될 수 있기 때문입니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. ## SIP 및 전화 통신 -파이썬 SDK는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통한 일급 SIP 연결 흐름을 제공합니다. +파이썬 SDK에는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통한 일급 SIP 연결 흐름이 포함되어 있습니다. -Realtime Calls API를 통해 전화가 수신되고 결과 `call_id`에 에이전트 세션을 연결하려는 경우 사용합니다. +Realtime Calls API를 통해 전화가 수신되고 생성된 `call_id`에 에이전트 세션을 연결하려는 경우 사용하세요. ```python from agents.realtime import RealtimeRunner @@ -317,20 +317,20 @@ async with await runner.run( ... ``` -먼저 전화를 수락해야 하며 수락 페이로드가 에이전트에서 파생된 세션 구성과 일치하도록 하려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`를 사용하세요. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. +먼저 전화를 수락해야 하고 수락 페이로드를 에이전트에서 파생된 세션 구성과 일치시키려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`을 사용하세요. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. -## 저수준 액세스 및 사용자 지정 엔드포인트 +## 저수준 접근 및 사용자 지정 엔드포인트 -`session.model`을 통해 기본 전송 객체에 액세스할 수 있습니다. +`session.model`을 통해 기본 전송 객체에 접근할 수 있습니다. -다음과 같은 경우 사용합니다. +다음이 필요한 경우 사용하세요. -- `session.model.add_listener(...)`를 통한 사용자 지정 리스너 -- `response.create` 또는 `session.update`와 같은 원문 클라이언트 이벤트 -- `model_config`를 통한 사용자 지정 `url`, `headers` 또는 `api_key` 처리 -- 기존 실시간 호출에 `call_id` 연결 +- `session.model.add_listener(...)`을 통한 사용자 지정 리스너 +- `response.create` 또는 `session.update`과 같은 원시 클라이언트 이벤트 +- `model_config`을 통한 사용자 지정 `url`, `headers` 또는 `api_key` 처리 +- 기존 실시간 호출에 대한 `call_id` 연결 -`RealtimeModelConfig`는 다음을 지원합니다. +`RealtimeModelConfig`은 다음을 지원합니다. - `api_key` - `url` @@ -339,9 +339,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -이 리포지토리에 포함된 `call_id` 예제는 SIP입니다. 더 광범위한 Realtime API에서도 일부 서버 측 제어 흐름에 `call_id`를 사용하지만, 여기에는 파이썬 예제로 패키징되어 있지 않습니다. +이 저장소에서 제공하는 `call_id` 예제는 SIP입니다. 더 광범위한 Realtime API에서도 일부 서버 측 제어 흐름에 `call_id`을 사용하지만, 여기에서는 파이썬 예제로 패키징되어 있지 않습니다. -Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적 헤더를 전달하세요. 예시는 다음과 같습니다. +Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적 헤더를 전달하세요. 예를 들면 다음과 같습니다. ```python session = await runner.run( @@ -352,7 +352,7 @@ session = await runner.run( ) ``` -토큰 기반 인증에는 `headers`의 bearer 토큰을 사용합니다. +토큰 기반 인증의 경우 `headers`에 전달자 토큰을 사용하세요. ```python session = await runner.run( @@ -363,11 +363,11 @@ session = await runner.run( ) ``` -`headers`를 전달하면 SDK는 `Authorization`을 자동으로 추가하지 않습니다. 실시간 에이전트에서는 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. +`headers`을 전달하면 SDK는 `Authorization`을 자동으로 추가하지 않습니다. 실시간 에이전트에서 레거시 베타 경로(`/openai/realtime?api-version=...`)는 사용하지 마세요. ## 추가 자료 -- [Realtime 전송](transport.md) +- [실시간 전송](transport.md) - [빠른 시작](quickstart.md) - [OpenAI Realtime 대화](https://developers.openai.com/api/docs/guides/realtime-conversations/) - [OpenAI Realtime 서버 측 제어](https://developers.openai.com/api/docs/guides/realtime-server-controls/) diff --git a/docs/ko/realtime/quickstart.md b/docs/ko/realtime/quickstart.md index 2c062de314..f477a694fb 100644 --- a/docs/ko/realtime/quickstart.md +++ b/docs/ko/realtime/quickstart.md @@ -4,21 +4,21 @@ search: --- # 빠른 시작 -Python SDK의 실시간 에이전트는 WebSocket 전송 기반의 OpenAI Realtime API 위에 구축된 서버 측 저지연 에이전트입니다. +Python SDK의 실시간 에이전트는 WebSocket 전송을 통해 OpenAI Realtime API를 기반으로 구축된 서버 측 저지연 에이전트입니다. !!! note "Python SDK 범위" - Python SDK는 브라우저 WebRTC 전송을 제공하지 **않습니다**. 이 페이지에서는 서버 측 WebSocket을 통한 Python 관리 실시간 세션만 다룹니다. 서버 측 오케스트레이션, 도구, 승인, 전화 통신 통합에는 이 SDK를 사용하세요. [Realtime 전송](transport.md)도 참조하세요. + Python SDK는 브라우저 WebRTC 전송을 제공하지 **않습니다**. 이 페이지에서는 서버 측 WebSocket을 통해 Python으로 관리하는 실시간 세션만 다룹니다. 서버 측 오케스트레이션, 도구, 승인 및 전화 통신 통합에는 이 SDK를 사용하세요. [실시간 전송](transport.md)도 참고하세요. -## 전제 조건 +## 사전 요구 사항 - Python 3.10 이상 - OpenAI API 키 -- OpenAI Agents SDK에 대한 기본적인 이해 +- OpenAI Agents SDK에 대한 기본 지식 ## 설치 -아직 설치하지 않았다면 OpenAI Agents SDK를 설치하세요: +아직 설치하지 않았다면 OpenAI Agents SDK를 설치합니다. ```bash pip install openai-agents @@ -45,7 +45,7 @@ agent = RealtimeAgent( ### 3. 러너 구성 -새 코드에서는 중첩된 `audio.input` / `audio.output` 세션 설정 구조를 사용하는 것을 권장합니다. 새 실시간 에이전트에는 `gpt-realtime-2.1`로 시작하세요. +새 코드에는 중첩된 `audio.input` / `audio.output` 세션 설정 구조를 사용하는 것이 좋습니다. 새 실시간 에이전트에는 `gpt-realtime-2.1`부터 사용하세요. ```python runner = RealtimeRunner( @@ -74,7 +74,7 @@ runner = RealtimeRunner( ### 4. 세션 시작 및 입력 전송 -`runner.run()`은 `RealtimeSession`을 반환합니다. 세션 컨텍스트에 들어가면 연결이 열립니다. +`runner.run()`는 `RealtimeSession`를 반환합니다. 세션 컨텍스트에 진입하면 연결이 열립니다. ```python async def main() -> None: @@ -100,16 +100,16 @@ if __name__ == "__main__": asyncio.run(main()) ``` -`session.send_message()`는 일반 문자열 또는 구조화된 실시간 메시지를 받습니다. 원문 오디오 청크에는 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]를 사용하세요. +`session.send_message()`는 일반 문자열 또는 구조화된 실시간 메시지를 받습니다. raw 오디오 청크에는 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용하세요. -## 이 빠른 시작에 포함되지 않는 내용 +## 이 빠른 시작에서 다루지 않는 내용 -- 마이크 캡처 및 스피커 재생 코드. [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime)의 실시간 코드 예제를 참조하세요. -- SIP / 전화 통신 연결 흐름. [Realtime 전송](transport.md) 및 [SIP 섹션](guide.md#sip-and-telephony)을 참조하세요. +- 마이크 캡처 및 스피커 재생 코드. [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime)의 실시간 코드 예제를 참고하세요. +- SIP / 전화 통신 연결 흐름. [실시간 전송](transport.md) 및 [SIP 섹션](guide.md#sip-and-telephony)을 참고하세요. ## 주요 설정 -기본 세션이 작동하면, 대부분의 사람들이 다음으로 찾는 설정은 다음과 같습니다: +기본 세션이 작동한 후 일반적으로 가장 먼저 사용하는 설정은 다음과 같습니다. - `model_name` - `audio.input.format`, `audio.output.format` @@ -120,39 +120,39 @@ if __name__ == "__main__": - `tool_choice`, `prompt`, `tracing` - `async_tool_calls`, `tool_execution.pre_approval_tool_input_guardrails`, `guardrails_settings.debounce_text_length`, `tool_error_formatter` -`input_audio_format`, `output_audio_format`, `input_audio_transcription`, `turn_detection` 같은 이전의 플랫 별칭도 여전히 작동하지만, 새 코드에는 중첩된 `audio` 설정을 권장합니다. +`input_audio_format`, `output_audio_format`, `input_audio_transcription`, `turn_detection`와 같은 이전의 플랫 별칭도 계속 작동하지만, 새 코드에는 중첩된 `audio` 설정을 사용하는 것이 좋습니다. -수동 턴 제어에는 [실시간 에이전트 가이드](guide.md#manual-response-control)에 설명된 원문 `session.update` / `input_audio_buffer.commit` / `response.create` 흐름을 사용하세요. +수동 턴 제어에는 [실시간 에이전트 가이드](guide.md#manual-response-control)에 설명된 저수준 `session.update` / `input_audio_buffer.commit` / `response.create` 흐름을 사용하세요. -전체 스키마는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]를 참조하세요. +전체 스키마는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]을 참고하세요. ## 연결 옵션 -환경에서 API 키를 설정하세요: +환경에 API 키를 설정합니다. ```bash export OPENAI_API_KEY="your-api-key-here" ``` -또는 세션을 시작할 때 직접 전달하세요: +또는 세션을 시작할 때 직접 전달합니다. ```python session = await runner.run(model_config={"api_key": "your-api-key"}) ``` -`model_config`는 다음도 지원합니다: +`model_config`는 다음 옵션도 지원합니다. - `url`: 사용자 지정 WebSocket 엔드포인트 - `headers`: 사용자 지정 요청 헤더 -- `call_id`: 기존 실시간 호출에 연결합니다. 이 리포지토리에서 문서화된 연결 흐름은 SIP입니다. -- `playback_tracker`: 사용자가 실제로 들은 오디오 양을 보고합니다 +- `call_id`: 기존 실시간 통화에 연결합니다. 이 저장소에 문서화된 연결 흐름은 SIP입니다. +- `playback_tracker`: 사용자가 실제로 들은 오디오의 양을 보고합니다 -`headers`를 명시적으로 전달하면 SDK는 `Authorization` 헤더를 대신 삽입해 주지 **않습니다**. +`headers`을 명시적으로 전달하면 SDK는 `Authorization` 헤더를 자동으로 추가하지 **않습니다**. -Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL을 `model_config["url"]`에 전달하고 명시적인 헤더도 전달하세요. 실시간 에이전트에서는 레거시 베타 경로(`/openai/realtime?api-version=...`)를 피하세요. 자세한 내용은 [실시간 에이전트 가이드](guide.md#low-level-access-and-custom-endpoints)를 참조하세요. +Azure OpenAI에 연결할 때는 `model_config["url"]`를 GA Realtime 엔드포인트 URL로 설정하고 헤더를 명시적으로 전달하세요. 실시간 에이전트에는 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. 자세한 내용은 [실시간 에이전트 가이드](guide.md#low-level-access-and-custom-endpoints)를 참고하세요. ## 다음 단계 -- 서버 측 WebSocket과 SIP 중 선택하려면 [Realtime 전송](transport.md)을 읽어 보세요. -- 수명 주기, 구조화된 입력, 승인, 핸드오프, 가드레일, 저수준 제어에 대해서는 [실시간 에이전트 가이드](guide.md)를 읽어 보세요. +- 서버 측 WebSocket과 SIP 중에서 선택하려면 [실시간 전송](transport.md)을 읽어보세요. +- 수명 주기, 구조화된 입력, 승인, 핸드오프, 가드레일 및 저수준 제어에 관한 내용은 [실시간 에이전트 가이드](guide.md)를 읽어보세요. - [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime)의 코드 예제를 살펴보세요. \ No newline at end of file diff --git a/docs/ko/realtime/transport.md b/docs/ko/realtime/transport.md index 579f87ac76..4ecec74d50 100644 --- a/docs/ko/realtime/transport.md +++ b/docs/ko/realtime/transport.md @@ -2,44 +2,44 @@ search: exclude: true --- -# 실시간 전송 방식 +# 실시간 트랜스포트 -이 페이지에서는 실시간 에이전트를 Python 애플리케이션에 통합하는 방법을 결정할 수 있습니다. +이 페이지를 사용하여 실시간 에이전트를 Python 애플리케이션에 통합하는 방법을 결정할 수 있습니다. -!!! note "Python SDK 범위" +!!! note "Python SDK 경계" - Python SDK에는 브라우저 WebRTC 전송 기능이 **포함되어 있지 않습니다**. 이 페이지에서는 Python SDK의 전송 방식인 서버 측 WebSocket과 SIP 연결 흐름만 다룹니다. 브라우저 WebRTC는 별도의 플랫폼 주제이며, 공식 [WebRTC를 사용하는 Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) 가이드에 설명되어 있습니다. + Python SDK에는 브라우저 WebRTC 트랜스포트가 포함되어 있지 **않습니다**. 이 페이지에서는 Python SDK의 트랜스포트 선택지인 서버 측 WebSocket과 SIP 연결 흐름만 다룹니다. 브라우저 WebRTC는 별도의 플랫폼 주제이며, 공식 [WebRTC를 사용하는 Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) 가이드에 문서화되어 있습니다. ## 선택 가이드 -| 목표 | 시작점 | 이유 | +| 목표 | 시작 지점 | 이유 | | --- | --- | --- | -| 서버에서 관리하는 실시간 앱 구축 | [빠른 시작](quickstart.md) | 기본 Python 경로는 `RealtimeRunner`가 관리하는 서버 측 WebSocket 세션입니다. | -| 선택할 전송 방식과 배포 구조 파악 | 이 페이지 | 전송 방식이나 배포 구조를 확정하기 전에 이 페이지를 참조하세요. | -| 에이전트를 전화 또는 SIP 통화에 연결 | [실시간 가이드](guide.md) 및 [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | 저장소에는 `call_id`로 구동되는 SIP 연결 흐름이 포함되어 있습니다. | +| 서버에서 관리하는 실시간 앱 구축 | [빠른 시작](quickstart.md) | 기본 Python 경로는 `RealtimeRunner`에서 관리하는 서버 측 WebSocket 세션입니다. | +| 선택할 트랜스포트와 배포 구조 파악 | 이 페이지 | 트랜스포트나 배포 구조를 확정하기 전에 이 페이지를 참조합니다. | +| 에이전트를 전화 또는 SIP 통화에 연결 | [실시간 가이드](guide.md) 및 [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | 저장소는 `call_id`에서 구동하는 SIP 연결 흐름을 제공합니다. | -## 기본 Python 경로인 서버 측 WebSocket +## 서버 측 WebSocket 기반의 기본 Python 경로 -사용자 지정 `RealtimeModel`을 전달하지 않으면 `RealtimeRunner`는 `OpenAIRealtimeWebSocketModel`을 사용합니다. +사용자 지정 `RealtimeModel`를 전달하지 않으면 `RealtimeRunner`은 `OpenAIRealtimeWebSocketModel`를 사용합니다. -즉, 표준 Python 토폴로지는 다음과 같습니다. +따라서 표준 Python 토폴로지는 다음과 같습니다. -1. Python 서비스가 `RealtimeRunner`를 생성합니다. -2. `await runner.run()`이 `RealtimeSession`을 반환합니다. -3. 세션에 진입하여 텍스트, 구조화된 메시지 또는 오디오를 전송합니다. -4. `RealtimeSessionEvent` 항목을 처리하고 오디오 또는 트랜스크립트를 애플리케이션에 전달합니다. +1. Python 서비스에서 `RealtimeRunner`을 생성합니다. +2. `await runner.run()`은 `RealtimeSession`을 반환합니다. +3. `RealtimeSession`을 비동기 컨텍스트 관리자로 진입한 다음 텍스트, 구조화된 메시지 또는 오디오를 전송합니다. +4. `RealtimeSessionEvent` 항목을 소비하고 오디오 또는 트랜스크립트를 애플리케이션에 전달합니다. -핵심 데모 앱, CLI 예제 및 Twilio Media Streams 예제에서 사용하는 토폴로지는 다음과 같습니다. +핵심 데모 앱, CLI 예제 및 Twilio Media Streams 예제에서 이 토폴로지를 사용합니다. - [`examples/realtime/app`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app) - [`examples/realtime/cli`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/cli) - [`examples/realtime/twilio`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio) -서버에서 오디오 파이프라인, 도구 실행, 승인 흐름 및 기록 처리를 담당하는 경우 이 경로를 사용하세요. +서버에서 오디오 파이프라인, 도구 실행, 승인 흐름 및 기록 처리를 담당하는 경우 이 경로를 사용합니다. ### 저수준 WebSocket 조정 -기반이 되는 서버 측 WebSocket 연결을 조정해야 할 때는 `OpenAIRealtimeWebSocketModel`에 `transport_config`를 전달합니다. +기반 서버 측 WebSocket 연결을 조정해야 할 때 `transport_config`를 `OpenAIRealtimeWebSocketModel`에 전달합니다. ```python from agents.realtime import ( @@ -62,47 +62,47 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 지원되는 옵션은 다음과 같습니다. -- `ping_interval`: 클라이언트 연결 유지 ping 사이의 시간(초)입니다. ping을 비활성화하려면 `None`으로 설정합니다. -- `ping_timeout`: 연결을 끊기 전에 pong을 기다리는 시간(초)입니다. 하트비트 시간 초과 없이 지연된 pong을 허용하려면 `None`으로 설정합니다. +- `ping_interval`: 클라이언트의 연결 유지 핑 간격(초)입니다. 핑을 비활성화하려면 `None`로 설정합니다. +- `ping_timeout`: 연결을 끊기 전에 pong을 기다리는 시간(초)입니다. 하트비트 시간 초과 없이 지연된 pong을 허용하려면 `None`로 설정합니다. - `handshake_timeout`: 초기 연결 핸드셰이크를 기다리는 시간(초)입니다. -- `max_size`: 수신 WebSocket 메시지의 최대 크기(바이트)입니다. SDK 기본값은 `None`이며 수신 메시지 크기를 제한하지 않습니다. 메시지별 메모리 사용량을 제한해야 하는 경우 명시적으로 한도를 설정하세요. +- `max_size`: 수신 WebSocket 메시지의 최대 크기(바이트)입니다. SDK 기본값은 `None`이며 수신 메시지 크기를 제한하지 않습니다. 메시지별 메모리 사용량을 제한해야 할 때는 명시적인 제한을 설정합니다. -이 설정은 Realtime API 세션이 아니라 클라이언트 연결을 구성합니다. 엔드포인트, 인증, 통화 연결 및 재생 설정에는 계속 `RealtimeModelConfig`를 사용하세요. +이 설정은 Realtime API 세션이 아닌 클라이언트 연결을 구성합니다. 엔드포인트, 인증, 통화 연결 및 재생 설정에는 계속해서 `RealtimeModelConfig`을 사용합니다. -## 전화 통신을 위한 SIP 연결 +## 텔레포니 경로인 SIP 연결 -이 저장소에 문서화된 전화 통신 흐름에서 Python SDK는 `call_id`를 통해 기존 실시간 통화에 연결됩니다. +이 저장소에 문서화된 텔레포니 흐름에서 Python SDK는 `call_id`를 통해 기존 실시간 통화에 연결합니다. 이 토폴로지는 다음과 같습니다. -1. OpenAI가 `realtime.call.incoming`과 같은 웹훅을 서비스에 전송합니다. +1. OpenAI가 `realtime.call.incoming`와 같은 웹훅을 서비스로 전송합니다. 2. 서비스가 Realtime Calls API를 통해 통화를 수락합니다. -3. Python 서비스가 `RealtimeRunner(..., model=OpenAIRealtimeSIPModel())`를 시작합니다. -4. 세션이 `model_config={"call_id": ...}`로 연결된 후 다른 실시간 세션과 동일하게 이벤트를 처리합니다. +3. Python 서비스가 `RealtimeRunner(..., model=OpenAIRealtimeSIPModel())`을 시작합니다. +4. 세션이 `model_config={"call_id": ...}`을 사용하여 연결된 다음 다른 실시간 세션과 마찬가지로 이벤트를 처리합니다. 이 토폴로지는 [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip)에 나와 있습니다. -더 광범위한 Realtime API에서도 일부 서버 측 제어 패턴에 `call_id`를 사용하지만, 이 저장소에 포함된 연결 예제는 SIP를 사용합니다. +더 광범위한 Realtime API에서는 일부 서버 측 제어 패턴에 `call_id`도 사용하지만, 이 저장소에서 제공하는 연결 예제는 SIP입니다. ## SDK 범위 밖의 브라우저 WebRTC -앱의 기본 클라이언트가 Realtime WebRTC를 사용하는 브라우저인 경우 다음 사항을 따르세요. +앱의 기본 클라이언트가 Realtime WebRTC를 사용하는 브라우저인 경우 다음 사항에 유의합니다. -- 이 저장소의 Python SDK 문서 범위 밖으로 간주하세요. -- 클라이언트 측 흐름과 이벤트 모델은 공식 [WebRTC를 사용하는 Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) 및 [실시간 대화](https://developers.openai.com/api/docs/guides/realtime-conversations/) 문서를 참조하세요. -- 브라우저 WebRTC 클라이언트에 사이드밴드 서버 연결을 추가해야 하는 경우 공식 [실시간 서버 측 제어](https://developers.openai.com/api/docs/guides/realtime-server-controls/) 가이드를 참조하세요. -- 이 저장소에서 브라우저 측 `RTCPeerConnection` 추상화 또는 즉시 사용할 수 있는 브라우저 WebRTC 샘플을 제공할 것으로 기대해서는 안 됩니다. +- 이 저장소의 Python SDK 문서 범위 밖으로 간주합니다. +- 클라이언트 측 흐름과 이벤트 모델은 공식 [WebRTC를 사용하는 Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) 및 [실시간 대화](https://developers.openai.com/api/docs/guides/realtime-conversations/) 문서를 참조합니다. +- 브라우저 WebRTC 클라이언트 외에 사이드밴드 서버 연결이 필요하다면 공식 [실시간 서버 측 제어](https://developers.openai.com/api/docs/guides/realtime-server-controls/) 가이드를 참조합니다. +- 이 저장소에서 브라우저 측 `RTCPeerConnection` 추상화 또는 즉시 사용할 수 있는 브라우저 WebRTC 샘플을 제공한다고 기대해서는 안 됩니다. -또한 이 저장소는 현재 브라우저 WebRTC와 Python 사이드밴드를 함께 사용하는 예제를 제공하지 않습니다. +현재 이 저장소는 브라우저 WebRTC와 Python 사이드밴드를 함께 사용하는 예제도 제공하지 않습니다. ## 사용자 지정 엔드포인트 및 연결 지점 -[`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig]에서 제공하는 전송 설정 인터페이스를 사용하면 기본 경로를 조정할 수 있습니다. +[`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig]의 트랜스포트 구성 인터페이스를 사용하면 기본 트랜스포트 동작을 사용자 지정할 수 있습니다. - `url`: WebSocket 엔드포인트 재정의 - `headers`: Azure 인증 헤더와 같은 명시적 헤더 제공 - `api_key`: API 키를 직접 또는 콜백을 통해 전달 -- `call_id`: 기존 실시간 통화에 연결. 이 저장소에 문서화된 예제는 SIP를 사용합니다. +- `call_id`: 기존 실시간 통화에 연결. 이 저장소에 문서화된 예제는 SIP입니다. - `playback_tracker`: 인터럽션(중단 처리)을 위해 실제 재생 진행 상황 보고 -토폴로지를 선택한 후의 상세한 수명 주기와 기능 범위는 [실시간 에이전트 가이드](guide.md)를 참조하세요. \ No newline at end of file +토폴로지를 선택한 후 자세한 수명 주기와 기능 범위는 [실시간 에이전트 가이드](guide.md)를 참조합니다. \ No newline at end of file diff --git a/docs/ko/release.md b/docs/ko/release.md index 300b87637a..5e352c4a94 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -4,51 +4,51 @@ search: --- # 릴리스 프로세스/변경 로그 -이 프로젝트는 `0.Y.Z` 형식을 사용하는 약간 변형된 시맨틱 버저닝을 따릅니다. 맨 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. +이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 유의적 버전 관리를 따릅니다. 맨 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. ## 마이너(`Y`) 버전 -베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 발생하면 마이너 버전 `Y`를 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경할 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. +베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 발생하면 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. 호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. ## 패치(`Z`) 버전 -하위 호환성을 유지하는 다음 변경 사항에는 `Z`를 증가시킵니다. +호환성을 깨지 않는 다음 변경 사항에는 `Z`을 증가시킵니다. -- 버그 수정 -- 새로운 기능 -- 비공개 인터페이스 변경 -- 베타 기능 업데이트 +- 버그 수정 +- 새로운 기능 +- 비공개 인터페이스 변경 +- 베타 기능 업데이트 -## 호환성을 깨는 변경 사항의 변경 로그 +## 호환성 변경 로그 ### 0.19.0 -이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 상향은 OpenAI Responses의 주요 신규 기능 영역인 프로그래밍 방식 도구 호출(Programmatic Tool Calling)을 반영합니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새 기능 영역인 프로그래매틱 도구 호출을 반영합니다. 주요 내용: -- 지원되는 OpenAI Responses 모델이 대상 도구를 조정하는 JavaScript를 생성할 수 있게 해주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 추가했습니다. 도구별 `allowed_callers`, 구조화된 함수 도구 출력, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState`와의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래밍 방식 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. -- 공개 `agents.decorators` 모듈과 기존 함수 및 가드레일 데코레이터보다 짧은 `@tool` 별칭을 추가했습니다. 이제 함수 도구는 비동기 호출 가능 객체도 지원합니다. -- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인 전반에서 타입이 지정된 설정 객체나 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. -- 유용한 디버깅 컨텍스트는 유지하면서 민감한 원문 페이로드가 노출되지 않도록 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅을 강화했습니다. -- AnyLLM, LiteLLM 및 Chat Completions 호환성을 개선하고, 모델 재시도 중에도 세션 기록을 유지하도록 했으며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 공급자 재시도 지침을 추가했습니다. 이를 통해 요청 재생이 허용되는 경우 명시적으로 활성화한 Runner 재시도 정책이 작동할 수 있습니다. -- `VercelCloudBucketMountStrategy`를 통해 [Vercel 샌드박스에서 생성 시에만 사용할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)를 추가했습니다. 마운트된 세션에서는 버킷 내용이 워크스페이스 영속화 대상에서 제외되며, 의도적으로 동적 마운트 변경이나 세션 재개를 지원하지 않습니다. +- 지원되는 OpenAI Responses 모델이 프로그래매틱 도구 호출을 사용할 수 있는 도구를 조정하는 JavaScript를 생성할 수 있게 해 주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]이 추가되었습니다. 도구별 `allowed_callers`, `FunctionTool` 인스턴스의 structured outputs, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState`와의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래매틱 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. +- 공개 `agents.decorators` 모듈과 기존 `@function_tool` 데코레이터의 짧은 별칭인 `@tool`가 기존 가드레일 데코레이터와 함께 추가되었습니다. 이제 `FunctionTool` 인스턴스는 비동기 호출 가능 객체도 지원합니다. +- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인 전반에서 타입이 지정된 설정 객체나 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. +- 유용한 디버깅 컨텍스트를 유지하면서 가공되지 않은 민감한 페이로드가 노출되지 않도록 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅이 강화되었습니다. +- AnyLLM, LiteLLM 및 Chat Completions 호환성이 개선되었고, 모델 재시도 간에 세션 기록이 유지되며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 제공업체 재시도 지침이 추가되었습니다. 따라서 명시적으로 활성화한 Runner 재시도 정책은 허용되는 경우 실패한 시도를 다시 실행할 수 있습니다. +- `VercelCloudBucketMountStrategy`을 통해 [Vercel 샌드박스 생성 시에만 구성할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)가 추가되었습니다. 마운트가 적용된 세션에서는 버킷 콘텐츠가 워크스페이스 영속화 대상에서 제외되며, 동적 마운트 변경이나 세션 재개는 의도적으로 지원되지 않습니다. ### 0.18.0 -이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 상향은 Realtime agents의 기본 모델 업데이트만 반영합니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만 반영합니다. 주요 내용: -- 이제 Realtime agents는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서는 별도의 구성 없이 최신 권장 모델을 사용합니다. +- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새 Realtime 설정에서는 추가 구성 없이 최신 권장 모델을 사용합니다. ### 0.17.0 -이 버전에서는 샌드박스 로컬 소스 구체화 시 소스 경로가 `Manifest.extra_path_grants`의 적용 대상이 아닌 한 `LocalFile.src`와 `LocalDir.src`를 구체화 `base_dir` 내부로 제한합니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 경로 로컬 소스는 이 디렉터리를 기준으로 해석되며, 절대 경로 로컬 소스는 이미 이 디렉터리 내부에 있거나 명시적으로 권한이 부여된 경로 아래에 있어야 합니다. 이 변경으로 로컬 아티팩트 경계 문제가 해결되지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에는 영향을 줄 수 있습니다. +이 버전에서 샌드박스의 로컬 소스 구체화는 소스 경로가 `Manifest.extra_path_grants`에 포함되지 않는 한 `LocalFile.src`와 `LocalDir.src`을 구체화 `base_dir` 내부에 유지합니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 경로 로컬 소스는 이미 그 내부에 있거나 명시적인 허용 범위 아래에 있어야 합니다. 이 변경으로 로컬 아티팩트 경계 문제가 해결되지만, 신뢰할 수 있는 호스트 파일이나 디렉터리를 해당 기본 디렉터리 외부에서 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에는 영향을 줄 수 있습니다. -마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`를 사용하여 신뢰할 수 있는 호스트 루트에 권한을 부여하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우에는 읽기 전용 권한을 사용하는 것이 좋습니다. +마이그레이션하려면 `SandboxPathGrant`를 사용하여 매니페스트 수준에서 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우 읽기 전용으로 설정하는 것이 좋습니다. ```python from pathlib import Path @@ -75,13 +75,13 @@ manifest = Manifest( ) ``` -`extra_path_grants`는 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 신뢰할 수 없는 다른 매니페스트 입력으로 권한을 채우면 안 됩니다. +`extra_path_grants`를 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 신뢰할 수 없는 다른 매니페스트 입력으로 허용 범위를 채우지 마세요. ### 0.16.0 -이 버전에서는 SDK 기본 모델이 `gpt-4.1`에서 `gpt-5.4-mini`로 변경되었습니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새로운 기본 모델이 GPT-5 모델이므로 암시적 기본 모델 설정에도 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. +이 버전에서 SDK 기본 모델은 이제 `gpt-4.1` 대신 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본값이 GPT-5 모델이므로, 명시하지 않은 기본 모델 설정에는 이제 `reasoning.effort="none"` 및 `verbosity="low"` 같은 GPT-5 기본값이 포함됩니다. -이전의 기본 모델 동작을 유지해야 한다면 에이전트나 실행 구성에서 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. +이전 기본 모델 동작을 유지해야 한다면 에이전트 또는 실행 구성에서 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. ```python agent = Agent(name="Assistant", model="gpt-4.1") @@ -89,14 +89,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 주요 내용: -- 이제 `Runner.run`, `Runner.run_sync` 및 `Runner.run_streamed`에서 `max_turns=None`을 지정하여 턴 제한을 비활성화할 수 있습니다. -- 이제 로컬, Docker 및 공급자 기반 샌드박스 구현 전반에서 샌드박스 워크스페이스 하이드레이션이 절대 경로 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. +- 이제 `Runner.run`, `Runner.run_sync` 및 `Runner.run_streamed`에서 `max_turns=None`을 사용하여 턴 제한을 비활성화할 수 있습니다. +- 이제 로컬, Docker 및 제공업체 기반 샌드박스 구현 전반에서 샌드박스 워크스페이스를 채울 때 절대 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. ### 0.15.0 -이 버전에서는 모델 거부 응답이 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`에 도달할 때까지 재시도하게 만드는 대신, 이제 `ModelRefusalError`로 명시적으로 노출됩니다. +이 버전에서는 모델의 거부 응답을 빈 텍스트 출력으로 처리하거나, structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하도록 하는 대신 이제 `ModelRefusalError`로 명시적으로 노출합니다. -이는 이전에 거부 응답만 포함된 모델 응답이 `final_output == ""`인 상태로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부 응답을 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. +이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. ```python result = Runner.run_sync( @@ -106,94 +106,94 @@ result = Runner.run_sync( ) ``` -구조화된 출력을 사용하는 에이전트의 경우 핸들러가 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. +structured outputs 에이전트의 경우 핸들러는 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. ### 0.14.0 -이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 새로운 주요 베타 기능 영역인 샌드박스 에이전트(Sandbox Agents)와 이를 로컬, 컨테이너화 및 호스팅 환경 전반에서 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 샌드박스 에이전트라는 중요한 새 베타 기능 영역과 로컬, 컨테이너화 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. 주요 내용: -- `SandboxAgent`, `Manifest` 및 `SandboxRunConfig`를 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스를 추가하여 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 기능을 갖춘 영구 격리 워크스페이스 내부에서 작업할 수 있도록 했습니다. -- `UnixLocalSandboxClient` 및 `DockerSandboxClient`를 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드를 추가하고, 선택적 추가 종속성을 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel의 호스팅 공급자 통합을 추가했습니다. -- 이후 실행에서 이전 실행의 교훈을 재사용할 수 있도록 샌드박스 메모리 지원을 추가했습니다. 여기에는 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계 및 S3 기반 워크플로를 포함한 영속 메모리 예제가 포함됩니다. -- 로컬 및 합성 워크스페이스 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하여 워크스페이스 및 재개 모델을 확장했습니다. -- `examples/sandbox/` 아래에 스킬, 핸드오프, 메모리를 사용하는 코딩 작업, 공급자별 설정, 코드 리뷰, 데이터룸 QA 및 웹사이트 복제와 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제와 튜토리얼을 추가했습니다. -- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더욱 안전한 민감한 MCP 출력 마스킹을 통해 핵심 런타임과 트레이싱 스택을 확장했습니다. +- `SandboxAgent`, `Manifest` 및 `SandboxRunConfig`을 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스가 추가되어 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 기능을 갖춘 영속적이고 격리된 워크스페이스 내에서 작업할 수 있습니다. +- `UnixLocalSandboxClient` 및 `DockerSandboxClient`을 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드가 추가되었으며, Python 패키지의 선택적 의존성 extras를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel용 호스팅 제공업체 통합도 추가되었습니다. +- 이후 실행에서 이전 실행의 교훈을 재사용할 수 있도록 샌드박스 메모리 지원이 추가되었으며, 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계 및 S3 기반 워크플로를 포함한 영속 메모리 코드 예제가 제공됩니다. +- 로컬 및 합성 워크스페이스 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하는 확장된 워크스페이스 및 재개 모델이 추가되었습니다. +- `examples/sandbox/` 아래에 기술을 활용한 코딩 작업, 핸드오프, 메모리, 제공업체별 설정과 코드 검토, 데이터룸 QA 및 웹사이트 복제 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제와 튜토리얼이 추가되었습니다. +- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더 안전한 민감한 MCP 출력 마스킹 기능으로 핵심 런타임 및 트레이싱 스택이 확장되었습니다. ### 0.13.0 -이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정 사항이 포함되었습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함되었습니다. 주요 내용: -- 이제 기본 WebSocket Realtime 모델은 `gpt-realtime-1.5`이므로, 새로운 Realtime 에이전트 설정에서는 별도의 구성 없이 더 최신 모델을 사용합니다. -- 이제 `MCPServer`는 `list_resources()`, `list_resource_templates()` 및 `read_resource()`를 제공하며, `MCPServerStreamableHttp`는 `session_id`를 제공하므로 스트리밍 가능 HTTP 세션을 재연결하거나 상태 비저장 워커 간에 재개할 수 있습니다. -- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`를 통해 추론 콘텐츠 재생을 선택적으로 활성화할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 공급자별 추론/도구 호출 연속성이 향상됩니다. -- `SQLAlchemySession`에서 동시에 수행되는 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, `remove_all_tools()`가 MCP/추론 항목을 남기는 문제, 함수 도구 배치 실행기의 경합 조건을 포함하여 여러 런타임 및 세션 경계 사례를 수정했습니다. +- 기본 WebSocket Realtime 모델은 이제 `gpt-realtime-1.5`이므로, 새 Realtime 에이전트 설정에서는 추가 구성 없이 최신 모델을 사용합니다. +- 이제 `MCPServer`은 `list_resources()`, `list_resource_templates()` 및 `read_resource()`을 노출하고, `MCPServerStreamableHttp`는 `session_id`을 노출합니다. 따라서 MCP Streamable HTTP 전송을 사용하는 세션을 재연결이나 상태 비저장 워커 간에 재개할 수 있습니다. +- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠를 다시 전송하도록 선택할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 제공업체별 추론 및 도구 호출 연속성이 개선됩니다. +- `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 연결 대상이 없는 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경합 상태를 포함한 여러 런타임 및 세션 경계 사례가 수정되었습니다. ### 0.12.0 -이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 참조하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. ### 0.11.0 -이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 참조하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. ### 0.10.0 -이번 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, OpenAI Responses 사용자를 위한 주요 신규 기능 영역인 Responses API의 WebSocket 전송 지원이 포함되었습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함되었습니다. 주요 내용: -- OpenAI Responses 모델에 대한 WebSocket 전송 지원을 추가했습니다. 이는 선택적으로 활성화할 수 있으며, HTTP는 계속 기본 전송 방식으로 사용됩니다. -- 멀티턴 실행 전반에서 공유 WebSocket 지원 공급자와 `RunConfig`를 재사용할 수 있도록 `responses_websocket_session()` 헬퍼와 `ResponsesWebSocketSession`을 추가했습니다. -- 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 예제(`examples/basic/stream_ws.py`)를 추가했습니다. +- OpenAI Responses 모델을 위한 WebSocket 전송 지원이 추가되었습니다. 명시적으로 활성화해야 하며 HTTP는 계속 기본 전송 방식입니다. +- 여러 턴에 걸친 실행에서 공유 WebSocket 지원 제공업체와 `RunConfig`을 재사용하기 위한 `responses_websocket_session()` 헬퍼/`ResponsesWebSocketSession`이 추가되었습니다. +- 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 코드 예제(`examples/basic/stream_ws.py`)가 추가되었습니다. ### 0.9.0 -이 버전에서는 Python 3.9가 더 이상 지원되지 않습니다. 해당 메이저 버전이 3개월 전에 지원 종료(EOL)에 도달했기 때문입니다. 더 최신 런타임 버전으로 업그레이드하세요. +이 버전에서는 해당 메이저 버전이 3개월 전에 지원 종료(EOL)에 도달했으므로 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. -또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁아졌습니다. 이 변경은 일반적으로 호환성을 깨는 문제를 일으키지 않지만, 코드에서 더 넓은 유니온 타입에 의존하는 경우 일부 조정이 필요할 수 있습니다. +또한 `Agent#as_tool()` 메서드에서 반환되는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁혀졌습니다. 일반적으로 이 변경으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니온 타입에 의존한다면 일부 조정이 필요할 수 있습니다. ### 0.8.0 -이 버전에서는 다음 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. +이 버전에서는 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. -- **동기식** Python 호출 가능 객체를 래핑하는 함수 도구는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`를 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태나 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시적으로 지정하세요. -- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 빠른 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로, 명시적 핸들러가 있는 각 로컬 MCP 서버에서 `failure_error_function=None`을 설정하세요. +- **동기식** Python 호출 가능 객체를 래핑하는 `FunctionTool` 인스턴스는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태나 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시적으로 지정하세요. +- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존하는 경우 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로 명시적인 핸들러가 있는 각 로컬 MCP 서버에서 `failure_error_function=None`을 설정하세요. ### 0.7.0 -이 버전에서는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작이 변경되었습니다. +이 버전에는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경 사항이 있습니다. -- 중첩 핸드오프 기록은 이제 **선택적 활성화 방식**이며 기본적으로 비활성화됩니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`를 명시적으로 설정하세요. -- `gpt-5.1` / `gpt-5.2`의 기본 `reasoning.effort`가 SDK 기본값으로 구성되었던 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트나 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. +- 이제 중첩된 핸드오프 기록은 **명시적으로 활성화**해야 하며 기본적으로 비활성화되어 있습니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. +- `gpt-5.1`/`gpt-5.2`의 기본 `reasoning.effort`이 SDK 기본값으로 구성되던 이전 기본값 `"low"`에서 `"none"`로 변경되었습니다. 프롬프트나 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. ### 0.6.0 -이 버전에서는 기본 핸드오프 기록이 사용자/어시스턴트 턴 원문을 노출하는 대신 단일 어시스턴트 메시지로 묶이므로 다운스트림 에이전트에 간결하고 예측 가능한 요약을 제공합니다 -- 기존 단일 메시지 핸드오프 기록은 이제 기본적으로 `` 블록 앞에서 "For context, here is the conversation so far between the user and the previous agent:"로 시작하므로 다운스트림 에이전트가 명확한 레이블이 지정된 요약을 받습니다 +이 버전에서는 사용자와 어시스턴트 턴을 별도의 메시지로 전달하는 대신 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여 이후 에이전트에 간결하고 예측 가능한 요약을 제공합니다 +- 이제 기존의 단일 메시지 핸드오프 트랜스크립트는 기본적으로 `` 블록 앞에 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`로 시작하므로 이후 에이전트에 명확히 표시된 요약이 제공됩니다 ### 0.5.0 -이 버전에는 눈에 보이는 호환성을 깨는 변경 사항이 없지만, 새로운 기능과 몇 가지 중요한 내부 업데이트가 포함되었습니다. +이 버전은 눈에 띄는 호환성 변경 사항을 도입하지 않지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되었습니다. -- `RealtimeRunner`에서 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리할 수 있도록 지원을 추가했습니다. -- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직을 대폭 수정했습니다. +- [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리하기 위한 지원이 `RealtimeRunner`에 추가되었습니다. +- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직이 크게 개정되었습니다 ### 0.4.0 -이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전이 더 이상 지원되지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. +이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전을 더 이상 지원하지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. ### 0.3.0 -이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 전환됩니다. +이 버전에서는 Realtime API 지원이 gpt-realtime 모델 및 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. ### 0.2.0 -이 버전에서는 이전에 `Agent`를 인수로 받던 몇몇 부분이 이제 `AgentBase`를 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 호출이 이에 해당합니다. 이는 순수한 타입 변경이며, 계속해서 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`를 `AgentBase`로 바꿔 타입 오류를 수정하면 됩니다. +이 버전에서는 이전에 `Agent`을 인수로 받던 일부 위치에서 이제 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처에 적용됩니다. 이는 타입만 변경된 것이며, 계속 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`을 `AgentBase`로 교체하여 타입 오류를 수정하면 됩니다. ### 0.1.0 -이 버전에서는 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에 `run_context`와 `agent`라는 새로운 매개변수 두 개가 추가되었습니다. `MCPServer`를 상속하는 모든 클래스에 이러한 매개변수를 추가해야 합니다. \ No newline at end of file +이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context` 및 `agent`이라는 두 개의 새 매개변수가 있습니다. `MCPServer`의 하위 클래스에서 재정의된 모든 `MCPServer.list_tools()` 메서드에 이 매개변수를 추가해야 합니다. \ No newline at end of file diff --git a/docs/ko/results.md b/docs/ko/results.md index 1d51706ab8..f646741215 100644 --- a/docs/ko/results.md +++ b/docs/ko/results.md @@ -4,88 +4,88 @@ search: --- # 결과 -`Runner.run` 메서드를 호출하면 다음 두 결과 타입 중 하나를 받습니다. +`Runner.run` 메서드를 호출하면 다음 두 가지 결과 유형 중 하나를 받습니다. - `Runner.run(...)` 또는 `Runner.run_sync(...)`에서 [`RunResult`][agents.result.RunResult] - `Runner.run_streamed(...)`에서 [`RunResultStreaming`][agents.result.RunResultStreaming] -둘 다 [`RunResultBase`][agents.result.RunResultBase]를 상속하며, `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()`와 같은 공통 결과 인터페이스를 제공합니다. +둘 다 [`RunResultBase`][agents.result.RunResultBase]를 상속하며, `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()` 같은 공통 결과 인터페이스를 제공합니다. -`RunResultStreaming`에는 [`stream_events()`][agents.result.RunResultStreaming.stream_events], [`current_agent`][agents.result.RunResultStreaming.current_agent], [`is_complete`][agents.result.RunResultStreaming.is_complete], [`cancel(...)`][agents.result.RunResultStreaming.cancel]과 같은 스트리밍 전용 제어 기능이 추가됩니다. +`RunResultStreaming`에는 [`stream_events()`][agents.result.RunResultStreaming.stream_events], [`current_agent`][agents.result.RunResultStreaming.current_agent], [`is_complete`][agents.result.RunResultStreaming.is_complete], [`cancel(...)`][agents.result.RunResultStreaming.cancel] 같은 스트리밍 전용 제어 기능이 추가됩니다. -## 적절한 결과 인터페이스 선택 +## 적합한 결과 인터페이스 선택 대부분의 애플리케이션에는 몇 가지 결과 속성이나 헬퍼만 필요합니다. -| 필요한 항목 | 사용 대상 | +| 필요한 항목 | 사용 항목 | | --- | --- | | 사용자에게 표시할 최종 답변 | `final_output` | -| 전체 로컬 대화 기록이 포함되어 다음 턴 재실행에 바로 사용할 수 있는 입력 목록 | `to_input_list()` | -| 에이전트, 도구, 핸드오프 및 승인 메타데이터가 포함된 상세 실행 항목 | `new_items` | +| 전체 로컬 대화 기록이 포함된, 재생 가능한 다음 턴 입력 목록 | `to_input_list()` | +| 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 실행 항목 | `new_items` | | 일반적으로 다음 사용자 턴을 처리해야 하는 에이전트 | `last_agent` | -| `previous_response_id`를 사용하는 OpenAI Responses API 체인 연결 | `last_response_id` | -| 대기 중인 승인 및 재개 가능한 스냅샷 | `interruptions` 및 `to_state()` | +| `previous_response_id`을 사용한 OpenAI Responses API 체이닝 | `last_response_id` | +| 대기 중인 승인과 재개 가능한 스냅샷 | `interruptions` 및 `to_state()` | | 현재 중첩된 `Agent.as_tool()` 호출에 관한 메타데이터 | `agent_tool_invocation` | -| 원문 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | +| 가공되지 않은 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | ## 최종 출력 -[`final_output`][agents.result.RunResultBase.final_output] 속성에는 마지막으로 실행된 에이전트의 최종 출력이 포함됩니다. 다음 중 하나입니다. +[`final_output`][agents.result.RunResultBase.final_output] 속성에는 마지막으로 실행된 에이전트의 최종 출력이 들어 있습니다. 다음 중 하나입니다. - 마지막 에이전트에 `output_type`이 정의되지 않은 경우 `str` -- 마지막 에이전트에 출력 타입이 정의된 경우 `last_agent.output_type` 타입의 객체 -- 승인 인터럽션(중단 처리)으로 일시 중지된 경우처럼 최종 출력이 생성되기 전에 실행이 중단된 경우 `None` +- 마지막 에이전트에 출력 유형이 정의된 경우 `last_agent.output_type` 유형의 객체 +- 예를 들어 승인 인터럽션(중단 처리)으로 일시 중지되어 최종 출력이 생성되기 전에 실행이 중단된 경우 `None` !!! note - `final_output`의 타입은 `Any`입니다. 핸드오프에 따라 실행을 완료하는 에이전트가 달라질 수 있으므로 SDK는 가능한 모든 출력 타입을 정적으로 알 수 없습니다. + `final_output`의 유형은 `Any`입니다. 핸드오프로 인해 실행을 완료하는 에이전트가 바뀔 수 있으므로 SDK는 가능한 출력 유형 전체를 정적으로 알 수 없습니다. -스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`이 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참조하세요. +스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`가 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참조하세요. ## 입력, 다음 턴 기록 및 새 항목 -이 인터페이스들은 서로 다른 질문에 답합니다. +다음 인터페이스는 각각 서로 다른 질문에 답합니다. | 속성 또는 헬퍼 | 포함 내용 | 적합한 용도 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성한 경우, 실행이 계속될 때 사용한 필터링된 입력이 반영됩니다. | 이 실행에서 실제로 사용한 입력 감사 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행을 입력 항목 형태로 보여 줍니다. 기본 `mode="preserve_all"`은 `new_items`에서 변환된 기록을 유지하지만, SDK 기본 중첩 핸드오프 기록으로 이미 이동된 동일한 세션 항목 인스턴스는 두 번째로 추가하지 않습니다. `mode="normalized"`는 핸드오프 필터링이 모델 기록을 다시 작성할 때 표준 연속 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리형 대화 상태 및 일반 항목 기록 검사 | -| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프 및 승인 메타데이터가 포함된 상세 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사 및 디버깅 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 얻은 원문 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 제공자 수준 진단 또는 원문 응답 검사 | +| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성한 경우 실행을 계속할 때 사용된 필터링된 입력이 반영됩니다. | 이 실행에서 실제로 입력으로 사용한 항목 감사 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행을 입력 항목 형태로 보여줍니다. 기본 `mode="preserve_all"`는 `new_items`에서 변환된 기록을 유지하지만, SDK 기본 중첩 핸드오프 기록으로 이미 이동된 정확히 동일한 세션 항목 인스턴스를 두 번째로 추가하지는 않습니다. 핸드오프 필터링으로 모델 기록을 다시 작성하는 경우 `mode="normalized"`은 정규 연속 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리형 대화 상태 및 일반 항목 기록 검사 | +| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사 및 디버깅 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 가져온 가공되지 않은 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 제공자 수준 진단 또는 가공되지 않은 응답 검사 | 실제로는 다음과 같이 사용합니다. -- 실행을 일반 입력 항목 형태로 확인하려면 `to_input_list()`를 사용합니다. -- 핸드오프 필터링이나 중첩 핸드오프 기록 재작성 후 다음 `Runner.run(..., input=...)` 호출에 사용할 표준 로컬 입력이 필요하면 `to_input_list(mode="normalized")`를 사용합니다. -- SDK가 기록을 로드하고 저장하도록 하려면 [`session=...`](sessions/index.md)을 사용합니다. -- `conversation_id` 또는 `previous_response_id`를 사용하여 OpenAI 서버 관리형 상태를 이용하는 경우에는 일반적으로 `to_input_list()`를 다시 보내는 대신 새 사용자 입력만 전달하고 저장된 ID를 재사용합니다. -- 로그, UI 또는 감사에 필요한 전체 변환 기록이 필요하면 기본 `to_input_list()` 모드 또는 `new_items`를 사용합니다. +- 실행을 일반 입력 항목 형태로 확인하려면 `to_input_list()`을 사용합니다. +- 핸드오프 필터링이나 중첩 핸드오프 기록 재작성 후 다음 `Runner.run(..., input=...)` 호출에 사용할 정규 로컬 입력이 필요하면 `to_input_list(mode="normalized")`을 사용합니다. +- SDK에서 기록을 로드하고 저장하도록 하려면 [`session=...`](sessions/index.md)를 사용합니다. +- `conversation_id` 또는 `previous_response_id`을 사용하여 OpenAI 서버 관리형 상태를 이용하는 경우에는 일반적으로 `to_input_list()`을 다시 보내는 대신 새 사용자 입력만 전달하고 저장된 ID를 재사용합니다. +- 로그, UI 또는 감사를 위해 변환된 전체 기록이 필요하면 기본 `to_input_list()` 모드 또는 `new_items`를 사용합니다. -SDK 기본 중첩 핸드오프 기록이 메시지 항목을 그대로 보존하는 경우, 세션, `RunState`, `to_input_list()`는 콘텐츠를 기준으로 중복을 제거하는 대신 정확히 소유된 항목 인스턴스를 추적합니다. 별도로 발생한 동일한 메시지는 별도 항목으로 유지되며, 이미 소유된 항목 인스턴스만 두 번째로 추가되지 않습니다. +SDK 기본 중첩 핸드오프 기록이 메시지 항목을 그대로 보존할 때 Sessions, `RunState`, `to_input_list()`은 콘텐츠를 기준으로 중복 제거하지 않고 소유된 정확한 인스턴스를 추적합니다. 별도로 발생한 동일한 메시지는 별도로 유지되며, 이미 소유된 인스턴스만 두 번째로 추가되지 않습니다. -JavaScript SDK와 달리 Python은 모델 형식의 델타만을 위한 별도의 `output` 속성을 제공하지 않습니다. SDK 메타데이터가 필요하면 `new_items`를 사용하고, 원문 모델 페이로드가 필요하면 `raw_responses`를 검사하세요. +JavaScript SDK와 달리 Python은 실행 중 새로 생성된 모델 형식 항목만 포함하는 별도의 `output` 속성을 제공하지 않습니다. SDK 메타데이터가 필요하면 `new_items`을 사용하고, 가공되지 않은 모델 페이로드가 필요하면 `raw_responses`을 검사합니다. -컴퓨터 도구 재실행은 원문 Responses 페이로드 형식을 따릅니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 유지하는 반면, `gpt-5.5` 컴퓨터 호출은 배치된 `actions[]`를 유지할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list]와 [`RunState`][agents.run_state.RunState]는 모델이 생성한 형식을 그대로 유지하므로 수동 재실행, 일시 중지/재개 흐름 및 저장된 대화 기록이 프리뷰와 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 계속해서 `new_items`에 `computer_call_output` 항목으로 표시됩니다. +컴퓨터 도구 항목을 대화 입력으로 다시 제출할 때는 가공되지 않은 Responses 페이로드 형식을 사용합니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 보존하는 반면, `gpt-5.5` 컴퓨터 호출은 일괄 처리된 `actions[]`을 보존할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list]와 [`RunState`][agents.run_state.RunState]는 모델이 생성한 형식을 그대로 유지하므로, 해당 항목을 대화 입력으로 수동 재제출하는 작업, 일시 중지/재개 흐름, 저장된 대화 기록이 프리뷰 및 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 계속해서 `new_items`에 `computer_call_output` 항목으로 표시됩니다. ### 새 항목 -[`new_items`][agents.result.RunResultBase.new_items]는 실행 중 발생한 일을 가장 상세하게 보여 줍니다. 일반적인 항목 타입은 다음과 같습니다. +[`new_items`][agents.result.RunResultBase.new_items]은 실행 중 발생한 작업을 가장 풍부한 형태로 보여줍니다. 일반적인 항목 유형은 다음과 같습니다. -- 어시스턴트 메시지용 [`MessageOutputItem`][agents.items.MessageOutputItem] -- 추론 항목용 [`ReasoningItem`][agents.items.ReasoningItem] -- Responses 도구 검색 요청 및 로드된 도구 검색 결과용 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem]과 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- 도구 호출 및 그 결과용 [`ToolCallItem`][agents.items.ToolCallItem]과 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 승인을 위해 일시 중지된 도구 호출용 [`ToolApprovalItem`][agents.items.ToolApprovalItem] -- 호스티드 MCP 승인 및 도구 카탈로그용 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem], [`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem], [`MCPListToolsItem`][agents.items.MCPListToolsItem] -- 핸드오프 요청 및 완료된 전달용 [`HandoffCallItem`][agents.items.HandoffCallItem]과 [`HandoffOutputItem`][agents.items.HandoffOutputItem] +- 어시스턴트 메시지를 나타내는 [`MessageOutputItem`][agents.items.MessageOutputItem] +- 추론 항목을 나타내는 [`ReasoningItem`][agents.items.ReasoningItem] +- Responses 도구 검색 요청과 로드된 도구 검색 결과를 나타내는 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 및 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- 도구 호출과 그 결과를 나타내는 [`ToolCallItem`][agents.items.ToolCallItem] 및 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- 승인을 위해 일시 중지된 도구 호출을 나타내는 [`ToolApprovalItem`][agents.items.ToolApprovalItem] +- 호스티드 MCP 승인 및 도구 카탈로그를 나타내는 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem], [`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem], [`MCPListToolsItem`][agents.items.MCPListToolsItem] +- 핸드오프 요청과 완료된 전달을 나타내는 [`HandoffCallItem`][agents.items.HandoffCallItem] 및 [`HandoffOutputItem`][agents.items.HandoffOutputItem] -에이전트 연결 정보, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 `to_input_list()` 대신 `new_items`를 선택하세요. +에이전트 연결 관계, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 `to_input_list()`보다 `new_items`을 선택합니다. -호스티드 도구 검색을 사용하는 경우, 모델이 생성한 검색 요청을 확인하려면 `ToolSearchCallItem.raw_item`을 검사하고 해당 턴에 로드된 네임스페이스, 함수 또는 호스티드 MCP 서버를 확인하려면 `ToolSearchOutputItem.raw_item`을 검사하세요. +호스티드 도구 검색을 사용할 때는 `ToolSearchCallItem.raw_item`을 검사하여 모델이 생성한 검색 요청을 확인하고, `ToolSearchOutputItem.raw_item`를 검사하여 해당 턴에 어떤 네임스페이스, 함수 또는 호스티드 MCP 서버가 로드되었는지 확인합니다. -Programmatic Tool Calling을 사용하면 생성된 `program`은 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출도 `ToolCallItem` 항목이며, 이에 대응하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외로, 각각 `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. +프로그래밍 방식 도구 호출을 사용할 때 생성된 `program`는 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출 역시 `ToolCallItem` 항목이며, 이에 대응하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램이 소유한 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외로, 각각 `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. -원문 항목은 타입이 지정된 Responses 객체이거나 매핑일 수 있습니다. 특히 프로그램 소유의 셸 및 패치 적용 호출은 매핑을 사용합니다. 매핑에도 안전한 검사 패턴을 사용하세요. +가공되지 않은 항목은 유형이 지정된 Responses 객체 또는 매핑일 수 있습니다. 특히 프로그램이 소유한 셸 및 패치 적용 호출은 매핑을 사용합니다. 다음과 같이 매핑을 안전하게 검사하는 패턴을 사용합니다. ```python from collections.abc import Mapping @@ -107,21 +107,21 @@ caller_id = ( ) ``` -프로그램 소유의 하위 호출에서 `caller`의 타입은 `program`이며, `caller_id`는 상위 프로그램 호출을 식별합니다. +프로그램이 소유한 하위 호출의 경우 `caller`에서 `type` 필드는 `program`이고, `caller_id`은 상위 프로그램 호출을 식별합니다. ## 대화 계속 또는 재개 ### 다음 턴 에이전트 -[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 핸드오프 후 다음 사용자 턴에 재사용하기에 가장 적합한 에이전트인 경우가 많습니다. +[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 들어 있습니다. 핸드오프 후 다음 사용자 턴에 재사용할 에이전트로 가장 적합한 경우가 많습니다. -스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로 스트림이 완료되기 전에 핸드오프를 관찰할 수 있습니다. +스트리밍 모드에서는 실행 진행에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로 스트림이 완료되기 전에 핸드오프를 확인할 수 있습니다. ### 인터럽션(중단 처리) 및 실행 상태 -도구에 승인이 필요한 경우, 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 호출된 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. +도구에 승인이 필요한 경우 승인 대기 항목은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 호출된 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. -[`to_state()`][agents.result.RunResult.to_state]를 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]를 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`으로 재개합니다. +[`to_state()`][agents.result.RunResult.to_state]을 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]를 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`으로 재개합니다. ```python from agents import Agent, Runner @@ -136,59 +136,59 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -스트리밍 실행에서는 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events]를 끝까지 소비한 다음 `result.interruptions`를 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)를 참조하세요. +스트리밍 실행의 경우 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 사용을 완료한 다음 `result.interruptions`을 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프(HITL)](human_in_the_loop.md)를 참조하세요. ### 서버 관리형 연속 실행 -[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 얻은 최신 모델 응답 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이를 `previous_response_id`로 다시 전달합니다. +[`last_response_id`][agents.result.RunResultBase.last_response_id]은 실행에서 가장 최근 모델 응답의 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 `previous_response_id`로 다시 전달합니다. -이미 `to_input_list()`, `session` 또는 `conversation_id`를 사용하여 대화를 계속하고 있다면 일반적으로 `last_response_id`가 필요하지 않습니다. 여러 단계 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`를 검사하세요. +이미 `to_input_list()`, `session` 또는 `conversation_id`로 대화를 계속하고 있다면 일반적으로 `last_response_id`은 필요하지 않습니다. 여러 단계로 구성된 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`을 검사합니다. -## 도구로 사용되는 에이전트의 메타데이터 +## 도구로서의 에이전트 메타데이터 -중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 나온 경우, [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 외부 도구 호출에 관한 변경 불가능한 메타데이터를 제공합니다. +중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 생성된 경우 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 해당 결과를 둘러싼 `Agent.as_tool()` 호출에 관한 불변 메타데이터를 제공합니다. - `tool_name` - `tool_call_id` - `tool_arguments` -일반적인 최상위 실행에서 `agent_tool_invocation`은 `None`입니다. +일반적인 최상위 실행에서는 `agent_tool_invocation`이 `None`입니다. -이는 중첩된 결과를 후처리하면서 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 수 있는 `custom_output_extractor` 내부에서 특히 유용합니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참조하세요. +이는 `custom_output_extractor` 내에서 특히 유용합니다. 중첩된 결과를 후처리할 때 이를 둘러싼 `Agent.as_tool()` 호출의 도구 이름, 호출 ID 또는 가공되지 않은 인수가 필요할 수 있기 때문입니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참조하세요. -해당 중첩 실행에 대해 파싱된 구조화 입력도 필요하면 `context_wrapper.tool_input`을 읽으세요. 이는 [`RunState`][agents.run_state.RunState]가 중첩 도구 입력을 범용 방식으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출을 위한 실시간 결과 접근자입니다. +해당 중첩 실행에 대해 파싱된 구조화 입력도 필요한 경우 `context_wrapper.tool_input`을 읽습니다. 이는 [`RunState`][agents.run_state.RunState]가 중첩 도구 입력에 대해 일반적으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출의 메타데이터를 결과에 직접 노출합니다. ## 스트리밍 수명 주기 및 진단 -[`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 인터페이스를 상속하면서 다음과 같은 스트리밍 전용 제어 기능을 추가합니다. +[`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 인터페이스를 상속하지만 다음과 같은 스트리밍 전용 제어 기능을 추가합니다. -- 의미론적 스트림 이벤트를 소비하는 [`stream_events()`][agents.result.RunResultStreaming.stream_events] -- 실행 중 활성 에이전트를 추적하는 [`current_agent`][agents.result.RunResultStreaming.current_agent] -- 스트리밍 실행이 완전히 완료되었는지 확인하는 [`is_complete`][agents.result.RunResultStreaming.is_complete] -- 실행을 즉시 또는 현재 턴 이후에 중지하는 [`cancel(...)`][agents.result.RunResultStreaming.cancel] +- 의미론적 스트림 이벤트를 사용하기 위한 [`stream_events()`][agents.result.RunResultStreaming.stream_events] +- 실행 도중 활성 에이전트를 추적하기 위한 [`current_agent`][agents.result.RunResultStreaming.current_agent] +- 스트리밍 실행이 완전히 종료되었는지 확인하기 위한 [`is_complete`][agents.result.RunResultStreaming.is_complete] +- 실행을 즉시 또는 현재 턴 이후 중단하기 위한 [`cancel(...)`][agents.result.RunResultStreaming.cancel] -비동기 이터레이터가 끝날 때까지 `stream_events()`를 계속 소비하세요. 이 이터레이터가 종료되기 전에는 스트리밍 실행이 완료된 것이 아니며, 마지막으로 표시되는 토큰이 도착한 후에도 `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 영속화 부수 효과가 아직 처리 중일 수 있습니다. +비동기 이터레이터가 끝날 때까지 `stream_events()`을 계속 사용합니다. 해당 이터레이터가 끝날 때까지 스트리밍 실행은 완료된 것이 아니며, 마지막으로 표시되는 토큰이 도착한 후에도 `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 영속화 부수 효과가 아직 처리 중일 수 있습니다. -`cancel()`을 호출한 경우에도 취소 및 정리가 올바르게 완료될 수 있도록 `stream_events()`를 계속 소비하세요. +`cancel()`을 호출하는 경우 취소 및 정리가 올바르게 완료될 수 있도록 `stream_events()`을 계속 사용합니다. -Python은 스트리밍용으로 별도의 `completed` 프로미스나 `error` 속성을 제공하지 않습니다. 스트리밍을 종료시키는 오류는 `stream_events()`에서 예외를 발생시키는 방식으로 노출되며, `is_complete`는 실행이 종료 상태에 도달했는지를 나타냅니다. +Python은 별도의 스트리밍된 `completed` 프로미스나 `error` 속성을 제공하지 않습니다. 실행을 종료시키는 스트리밍 오류는 `stream_events()`에서 발생하며, `is_complete`은 실행이 종료 상태에 도달했는지를 나타냅니다. -### 원문 응답 +### 가공되지 않은 응답 -[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 원문 모델 응답이 포함됩니다. 여러 단계 실행에서는 핸드오프나 반복되는 모델/도구/모델 주기 등으로 인해 둘 이상의 응답이 생성될 수 있습니다. +[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 가공되지 않은 모델 응답이 들어 있습니다. 여러 단계로 구성된 실행에서는 핸드오프나 반복되는 모델/도구/모델 주기 등으로 인해 둘 이상의 응답이 생성될 수 있습니다. -[`last_response_id`][agents.result.RunResultBase.last_response_id]는 `raw_responses`의 마지막 항목에 있는 ID일 뿐입니다. +[`last_response_id`][agents.result.RunResultBase.last_response_id]은 `raw_responses`의 마지막 항목에서 가져온 ID일 뿐입니다. ### 가드레일 결과 에이전트 수준 가드레일은 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 및 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results]로 노출됩니다. -도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도 노출됩니다. +도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도로 노출됩니다. -이 배열들은 실행 전반에 걸쳐 누적되므로 판단 기록, 추가 가드레일 메타데이터 저장 또는 실행이 차단된 이유를 디버깅하는 데 유용합니다. +이 배열은 실행 전체에 걸쳐 누적되므로 의사 결정을 로깅하거나, 추가 가드레일 메타데이터를 저장하거나, 실행이 차단된 이유를 디버깅하는 데 유용합니다. ### 컨텍스트 및 사용량 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper]는 승인, 사용량, 중첩된 `tool_input`과 같은 SDK 관리형 런타임 메타데이터와 함께 앱 컨텍스트를 제공합니다. +[`context_wrapper`][agents.result.RunResultBase.context_wrapper]은 승인, 사용량, 중첩된 `tool_input` 같은 SDK 관리형 런타임 메타데이터와 함께 애플리케이션 컨텍스트를 제공합니다. -사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행에서는 스트림의 마지막 청크가 처리될 때까지 사용량 합계 반영이 늦어질 수 있습니다. 전체 래퍼 구조 및 영속성 관련 주의 사항은 [컨텍스트 관리](context.md)를 참조하세요. \ No newline at end of file +사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행에서는 스트림의 마지막 청크가 처리될 때까지 사용량 합계 반영이 지연될 수 있습니다. 전체 래퍼 구조와 영속화 관련 주의 사항은 [컨텍스트 관리](context.md)를 참조하세요. \ No newline at end of file diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index 4a9cc6ff7d..b61ecca626 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -6,9 +6,9 @@ search: [`Runner`][agents.run.Runner] 클래스를 통해 에이전트를 실행할 수 있습니다. 다음 3가지 옵션이 있습니다. -1. [`Runner.run()`][agents.run.Runner.run]: 비동기 방식으로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 방식의 메서드이며 내부적으로 `.run()`을 실행합니다. -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기 방식으로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고 이벤트가 수신되는 대로 스트리밍합니다. +1. [`Runner.run()`][agents.run.Runner.run]: 비동기로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 메서드이며 내부적으로 `.run()`를 실행합니다. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고 수신되는 이벤트를 스트리밍합니다. ```python from agents import Agent, Runner @@ -29,40 +29,40 @@ async def main(): ### 에이전트 루프 -`Runner`의 실행 메서드를 사용할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. +위의 세 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. - 문자열(사용자 메시지로 처리) - OpenAI Responses API 형식의 입력 항목 목록 -- 인터럽션된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState] +- 인터럽션된 실행을 재개하는 경우 [`RunState`][agents.run_state.RunState] -그런 다음 Runner는 다음 루프를 실행합니다. +그런 다음 Runner가 루프를 실행합니다. -1. 현재 에이전트에 대해 현재 입력으로 LLM을 호출합니다. +1. 현재 입력으로 현재 에이전트의 LLM을 호출합니다. 2. LLM이 출력을 생성합니다. - 1. LLM이 `final_output`을 반환하면 루프가 종료되고 결과를 반환합니다. - 2. LLM이 핸드오프를 수행하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. - 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. -3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. + 1. Runner가 LLM 출력을 최종 출력으로 분류하면 루프가 종료되고 결과를 반환합니다. + 2. LLM이 핸드오프를 요청하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. + 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 뒤 루프를 다시 실행합니다. +3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외를 발생시킵니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. + LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 타입의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. ### 스트리밍 -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 받을 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 생성된 모든 새 출력을 포함한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 생성된 모든 새 출력을 포함하여 실행에 관한 전체 정보가 들어 있습니다. 스트리밍 이벤트에는 `.stream_events()`을 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. #### Responses WebSocket 전송(선택적 헬퍼) -OpenAI Responses WebSocket 전송을 활성화해도 일반적인 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 WebSocket 세션 헬퍼 사용을 권장하지만 필수는 아닙니다. +OpenAI Responses websocket 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용에는 websocket 세션 헬퍼를 권장하지만 필수는 아닙니다. -이는 WebSocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. +이는 websocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. -전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 공급자에 관한 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. +전송 선택 규칙과 구체적인 모델 객체 또는 커스텀 제공자 관련 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 헬퍼 없음(사용 가능) +##### 패턴 1: 세션 헬퍼 없음(작동 가능) -WebSocket 전송만 필요하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. +websocket 전송만 필요하고 SDK가 공유 제공자/세션을 관리할 필요가 없을 때 사용합니다. ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 직접 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. +이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 같은 `RunConfig` / 제공자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. -##### 패턴 2: `responses_websocket_session()` 사용(여러 턴에서 재사용 시 권장) +##### 패턴 2: `responses_websocket_session()` 사용(멀티턴 재사용에 권장) -여러 실행에서 WebSocket을 지원하는 공급자와 `RunConfig`를 공유하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 도구로서의 에이전트 호출도 포함됩니다. +여러 실행에서 공유할 수 있는 websocket 지원 제공자와 `RunConfig`이 필요할 때 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`을 상속하는 중첩된 Agents-as-tools 호출도 포함됩니다. ```python import asyncio @@ -119,11 +119,11 @@ async def main(): asyncio.run(main()) ``` -컨텍스트가 종료되기 전에 스트리밍 결과 사용을 완료하세요. WebSocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. +컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 소비하세요. websocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. -서비스는 각 WebSocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 없애지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`를 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용해 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. +서비스는 각 websocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`을 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용해 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. -긴 추론 턴에서 WebSocket 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. WebSocket 지연 시간보다 안정성이 중요한 실행에는 HTTP/SSE 전송을 사용하세요. +긴 추론 턴에서 websocket 연결 유지 타임아웃이 발생하면 `ping_timeout`를 늘리거나 `ping_timeout=None`로 설정하여 하트비트 타임아웃을 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. ### 실행 구성 @@ -131,47 +131,47 @@ asyncio.run(main()) #### 일반적인 실행 구성 카테고리 -각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`를 사용하세요. +각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`을 사용하세요. -##### 모델, 공급자 및 세션 기본값 +##### 모델, 제공자 및 세션 기본값 - [`model`][agents.run.RunConfig.model]: 각 에이전트에 설정된 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. -- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며 기본값은 OpenAI입니다. -- [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`를 설정할 수 있습니다. +- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 제공자이며 기본값은 OpenAI입니다. +- [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`을 설정할 수 있습니다. - [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 턴 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기 방식일 수 있습니다. +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions 사용 시 각 `Runner` 실행 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. ##### 가드레일, 핸드오프 및 모델 입력 구성 - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 입력 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 손실 없이 보존되는 메시지 항목을 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화됩니다. 활성화하려면 `True`로 설정하고, 원문 트랜스크립트를 그대로 전달하려면 `False`로 두세요. Sessions, `RunState`, `RunResult.to_input_list()`는 SDK 기본 중첩 기록이 이미 소유한 정확히 동일한 메시지 인스턴스를 두 번 추가하지 않으면서도 별도의 동일 메시지는 유지합니다. [Runner 메서드][agents.run.Runner]는 명시적으로 전달하지 않으면 모두 자동으로 `RunConfig`를 생성하므로 빠른 시작과 코드 예제에서는 이 기능이 기본적으로 비활성화되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정보다 우선합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 활성화할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순차 요약 세그먼트를 대체하여 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 잘라내거나 시스템 프롬프트를 삽입할 수 있습니다. -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 유지할지 생략할지 제어합니다. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트에 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 무손실 메시지 항목의 원래 위치를 보존하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩 핸드오프를 안정화하는 동안 기본적으로 비활성화되어 있습니다. 활성화하려면 `True`으로 설정하고, 가공되지 않은 트랜스크립트를 그대로 전달하려면 `False`로 두세요. Sessions, `RunState` 및 `RunResult.to_input_list()`은 SDK 기본 중첩 기록에 이미 포함된 정확히 동일한 메시지 인스턴스를 두 번 추가하지 않으면서 별도의 동일 메시지는 보존합니다. 모든 [Runner 메서드][agents.run.Runner]는 전달된 값이 없을 때 자동으로 `RunConfig`을 생성하므로 빠른 시작과 코드 예제에서는 기본값이 비활성화된 상태로 유지되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`을 옵트인할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순서형 요약 세그먼트를 대체하여 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. ##### 트레이싱 및 관측 가능성 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에 대해 [트레이싱](tracing.md)을 비활성화할 수 있습니다. -- [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]를 전달합니다. -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출의 입력/출력과 같은 잠재적으로 민감한 데이터를 포함할지 구성합니다. +- [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]을 전달합니다. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출 입출력과 같이 민감할 수 있는 데이터를 포함할지 구성합니다. - [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`은 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. ##### 도구 실행, 승인 및 도구 오류 동작 -- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행되는 함수 도구 수 제한과 같이 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 해결되지 않은 함수 도구 호출을 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`가 발생하며, 대신 모델에 표시되는 오류 출력을 반환하도록 옵트인할 수 있습니다. -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구 이름과 핸드오프 이름이 충돌할 때 Runner가 처리하는 방식을 구성합니다. 기본값인 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치에서 선택된 항목만 노출합니다. `"error"`는 모델 호출 전에 `UserError`를 발생시킵니다. 네임스페이스가 지정된 도구와 지연 로딩 도구에 대한 엄격한 검증은 변경되지 않습니다. -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인된 도구를 찾을 수 없음 출력과 같이 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행할 로컬 함수 도구 호출 수 제한 등 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않을 때 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`를 발생시킵니다. 대신 모델에 표시되는 오류 출력을 반환하려면 옵트인하세요. +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구 이름과 핸드오프 이름이 충돌할 때 Runner가 처리하는 방식을 구성합니다. 기본값 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치 대상으로 선택된 항목만 노출합니다. `"error"`은 모델 호출 전에 `UserError`를 발생시킵니다. 네임스페이스가 있는 도구와 지연 로딩 도구에 대한 엄격한 검증은 변경되지 않습니다. +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인한 도구 없음 출력 등 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. -중첩된 핸드오프는 옵트인 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`를 전달하여 순서가 지정된 트랜스크립트 압축을 활성화하거나 `handoff(..., nest_handoff_history=True)`를 설정하여 특정 핸드오프에서 활성화하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하는 대신, 손실 없이 보존되는 메시지 항목 전후에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 원문 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 필요한 방식 그대로 대화를 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]를 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]를 호출하세요. +중첩 핸드오프는 옵트인 베타로 제공됩니다. `RunConfig(nest_handoff_history=True)`을 전달하여 순서형 트랜스크립트 압축을 활성화하거나, 특정 핸드오프에서 사용하려면 `handoff(..., nest_handoff_history=True)`를 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하는 대신 무손실 메시지 항목 주위에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 기본값인 가공되지 않은 트랜스크립트를 유지하려면 플래그를 설정하지 않거나 대화를 필요한 형태 그대로 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)을 제공하세요. 커스텀 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출하세요. #### 실행 구성 세부 정보 ##### `tool_execution` -로컬 함수 도구의 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. 예를 들어 실행 중 로컬 함수 도구의 동시 실행 수를 제한할 수 있습니다. +실행의 로컬 함수 도구 동시 실행 수 제한 등 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에서 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 수를 제한하려면 정숫값을 설정하세요. +`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에 여러 함수 도구 호출을 생성하면 SDK가 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행할 로컬 함수 도구 호출 수를 제한하려면 정숫값을 설정하세요. -이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 로컬 함수 도구 호출을 생성한 후 SDK가 이를 실행하는 방식을 제어합니다. +이는 제공자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]과 별개입니다. `parallel_tool_calls`은 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. -`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 실행이 먼저 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 작동합니다. 대기 중인 승인 인터럽션(중단 처리)이 생성되기 전에 함수 도구 입력 가드레일을 실행하려면 이를 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 동일한 입력 가드레일을 다시 실행하므로, 시간에 민감한 검사가 실행 전에 다시 검증됩니다. +`pre_approval_tool_input_guardrails=False`은 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 먼저 실행이 일시 중지되고, 승인 후 실행 직전에 도구 입력 가드레일이 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 생성되기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 동일한 입력 가드레일을 다시 실행하므로, 실행 전에 시간에 민감한 검사를 다시 검증합니다. ##### `tool_not_found_behavior` -기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 Runner에서 `ModelBehaviorError`가 발생합니다. +기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 Runner가 `ModelBehaviorError`을 발생시킵니다. -실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`로 설정하세요. 이 모드에서는 SDK가 해결되지 않은 도구 호출에 대한 `function_call_output`을 추가하고 모델을 다시 실행하므로 모델이 사용 가능한 도구를 선택하거나 해당 도구 없이 응답할 수 있습니다. +실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서 SDK는 해결되지 않은 도구 호출에 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. ```python from agents import Agent, RunConfig, Runner @@ -214,22 +214,22 @@ result = await Runner.run( ) ``` -현재 이 옵션은 해결되지 않은 함수 도구 호출에만 적용됩니다. 그 밖의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 밖의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. ##### `tool_error_formatter` -SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델로 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`를 사용하세요. +SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`을 사용하세요. -포매터는 다음 항목을 포함하는 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. +포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. -- `kind`: `"approval_rejected"` 또는 `"tool_not_found"` 같은 오류 카테고리 -- `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`) -- `tool_name`: 도구 이름 -- `call_id`: 도구 호출 ID -- `default_message`: 모델에 표시되는 SDK의 기본 메시지 -- `run_context`: 활성 실행 컨텍스트 래퍼 +- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`와 같은 오류 카테고리입니다. +- `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`)입니다. +- `tool_name`: 도구 이름입니다. +- `call_id`: 도구 호출 ID입니다. +- `default_message`: SDK의 기본 모델 표시 메시지입니다. +- `run_context`: 활성 실행 컨텍스트 래퍼입니다. -메시지를 대체하려면 문자열을 반환하고, SDK 기본값을 사용하려면 `None`을 반환하세요. +메시지를 대체하려면 문자열을 반환하고, SDK 기본값을 사용하려면 `None`를 반환하세요. ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,22 +256,22 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`는 Runner가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행 사용 시) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. +`reasoning_item_id_policy`은 Runner가 기록을 다음 턴으로 전달할 때 추론 항목을 다음 턴 모델 입력으로 변환하는 방식을 제어합니다. 예를 들어 `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때 적용됩니다. -- `None` 또는 `"preserve"`(기본값): 추론 항목 ID 유지 -- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID 제거 +- `None` 또는 `"preserve"`(기본값): 추론 항목 ID를 유지합니다. +- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID를 제거합니다. -`"omit"`은 주로 추론 항목이 `id`와 함께 전송되지만 필수 후속 항목은 없는 경우 발생하는 Responses API 400 오류 유형을 완화하기 위한 옵트인 방식으로 사용합니다. 예를 들면 `Item 'rs_...' of type 'reasoning' was provided without its required following item.` 오류가 있습니다. +추론 항목이 `id`과 함께 전송되지만 필수 후속 항목(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)은 없는 경우 발생하는 Responses API 400 오류 유형을 완화하려면 주로 옵트인 방식으로 `"omit"`를 사용하세요. -이 오류는 SDK가 이전 출력에서 후속 입력을 구성하는 여러 턴의 에이전트 실행에서 발생할 수 있습니다. 여기에는 세션 영속성, 서버 관리형 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID는 유지되지만 공급자가 해당 ID를 대응하는 후속 항목과 계속 쌍으로 유지하도록 요구할 수 있습니다. +이 오류는 SDK가 이전 출력으로 후속 입력을 구성하는 멀티턴 에이전트 실행에서 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID는 보존되지만 제공자는 해당 ID가 대응하는 후속 항목과 계속 쌍을 이루도록 요구할 수 있습니다. -`reasoning_item_id_policy="omit"`으로 설정하면 추론 콘텐츠는 유지하면서 추론 항목의 `id`를 제거하므로 SDK가 생성한 후속 입력에서 해당 API 불변 조건이 위반되는 것을 방지할 수 있습니다. +`reasoning_item_id_policy="omit"`을 설정하면 추론 콘텐츠는 유지하면서 추론 항목의 `id`을 제거하므로, SDK가 생성한 후속 입력에서 해당 API 불변 조건이 위반되는 것을 방지할 수 있습니다. 적용 범위 참고 사항: - SDK가 후속 입력을 구성할 때 생성하거나 전달하는 추론 항목만 변경합니다. - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. -- 이 정책이 적용된 후에도 `call_model_input_filter`에서 의도적으로 추론 ID를 다시 도입할 수 있습니다. +- 이 정책을 적용한 후에도 `call_model_input_filter`에서 의도적으로 추론 ID를 다시 추가할 수 있습니다. ## 상태 및 대화 관리 @@ -279,33 +279,33 @@ result = Runner.run_sync( 다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | +| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달할 항목 | | --- | --- | --- | --- | -| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`에서 반환된 목록과 다음 사용자 메시지 | -| `session` | 자체 스토리지 및 SDK | 영구적인 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 동일한 저장소를 가리키는 다른 인스턴스 | -| `conversation_id` | OpenAI Conversations API | 작업자 또는 서비스 간에 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 전달 | +| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 제공자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | +| `session` | 자체 스토리지와 SDK | 지속적인 채팅 상태, 재개 가능한 실행, 커스텀 저장소 | 동일한 `session` 인스턴스 또는 같은 저장소를 가리키는 다른 인스턴스 | +| `conversation_id` | OpenAI Conversations API | 여러 워커나 서비스에서 공유하려는 이름 있는 서버 측 대화 | 동일한 `conversation_id`과 새 사용자 턴만 전달 | | `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 실행 | `result.last_response_id`와 새 사용자 턴만 전달 | -`result.to_input_list()`와 `session`은 클라이언트에서 관리합니다. `conversation_id`와 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 영속성 전략을 선택하세요. 클라이언트 관리형 기록과 OpenAI 관리형 상태를 혼합하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. +`result.to_input_list()`과 `session`은 클라이언트에서 관리합니다. `conversation_id`과 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화별로 하나의 지속성 전략을 선택하세요. 클라이언트 관리형 기록과 OpenAI 관리형 상태를 함께 사용하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. !!! note - 세션 영속성은 동일한 실행에서 서버 관리형 대화 설정 - (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 함께 사용할 수 - 없습니다. 호출마다 하나의 접근 방식을 선택하세요. + 같은 실행에서 세션 지속성과 서버 관리 대화 설정 + (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)을 + 함께 사용할 수 없습니다. 호출마다 한 가지 방식을 선택하세요. ### 대화/채팅 스레드 -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있고, 이에 따라 하나 이상의 LLM 호출이 발생할 수 있지만 채팅 대화에서는 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며, 이에 따라 하나 이상의 LLM 호출이 발생할 수 있습니다. 하지만 이는 채팅 대화에서 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. -1. 사용자 턴: 사용자가 텍스트 입력 -2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프하며, 두 번째 에이전트가 추가 도구를 실행하고 출력을 생성 +1. 사용자 턴: 사용자가 텍스트를 입력합니다. +2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 뒤 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트가 추가 도구를 실행한 후 출력을 생성합니다. 에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 경우든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. #### 수동 대화 관리 -[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 메서드로 다음 턴의 입력을 가져와 대화 기록을 수동으로 관리할 수 있습니다. +[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 메서드를 사용하여 다음 턴의 입력을 가져오고 대화 기록을 수동으로 관리할 수 있습니다. ```python from agents import Agent, Runner, trace @@ -329,7 +329,7 @@ async def main(): #### 세션을 통한 자동 대화 관리 -더 간단한 방식으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 직접 호출하지 않고도 대화 기록을 자동으로 처리할 수 있습니다. +더 간단한 방식으로는 `.to_input_list()`를 수동 호출하지 않고도 [Sessions](sessions/index.md)를 사용하여 대화 기록을 자동으로 처리할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession, trace @@ -353,24 +353,24 @@ async def main(): # California ``` -Sessions는 다음 작업을 자동으로 수행합니다. +Sessions는 자동으로 다음 작업을 수행합니다. -- 각 실행 전에 대화 기록을 가져옵니다 -- 각 실행 후에 새 메시지를 저장합니다 -- 서로 다른 세션 ID별로 별도의 대화를 유지합니다 +- 각 실행 전에 대화 기록 조회 +- 각 실행 후 새 메시지 저장 +- 서로 다른 세션 ID에 대해 별도 대화 유지 자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. -#### 서버 관리형 대화 +#### 서버 관리 대화 -`to_input_list()` 또는 `Sessions`를 사용해 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 직접 다시 전송하지 않고도 대화 기록을 유지할 수 있습니다. 아래의 서버 관리형 방식 중 어느 것을 사용하든 각 요청에는 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. +`to_input_list()` 또는 `Sessions`을 사용하여 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 매번 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래 서버 관리 방식 중 하나를 사용할 때는 각 요청에서 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. -OpenAI는 여러 턴에 걸쳐 상태를 추적하는 두 가지 방법을 제공합니다. +OpenAI는 턴 간 상태를 추적하는 두 가지 방법을 제공합니다. ##### 1. `conversation_id` 사용 -먼저 OpenAI Conversations API로 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. +먼저 OpenAI Conversations API를 사용하여 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. ```python from agents import Agent, Runner @@ -418,30 +418,31 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개되는 경우 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리형 대화에서 계속됩니다. +실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개되는 경우 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 같은 서버 관리 대화에서 계속됩니다. -`conversation_id`와 `previous_response_id`는 함께 사용할 수 없습니다. 시스템 간에 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`를 사용하세요. +`conversation_id`과 `previous_response_id`는 함께 사용할 수 없습니다. 여러 시스템에서 공유할 수 있는 이름 있는 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`을 사용하세요. !!! note - SDK는 `conversation_locked` 오류를 백오프와 함께 자동으로 재시도합니다. 서버 관리형 - 대화 실행에서는 재시도 전에 내부 대화 추적기의 입력을 되돌려 동일하게 준비된 - 항목을 문제없이 다시 전송할 수 있도록 합니다. + SDK는 `conversation_locked` 오류를 백오프 방식으로 자동 재시도합니다. 서버 관리 + 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌려 준비된 동일 항목을 + 문제없이 다시 전송할 수 있도록 합니다. - 로컬 세션 기반 실행(`conversation_id`, `previous_response_id` 또는 - `auto_previous_response_id`와 함께 사용할 수 없음)에서도 SDK는 재시도 후 기록 항목이 - 중복되는 것을 줄이기 위해 최근에 저장된 입력 항목을 최선의 방식으로 롤백합니다. + 로컬 세션 기반 실행(`conversation_id`, + `previous_response_id` 또는 `auto_previous_response_id`과 함께 사용할 수 없음)에서도 SDK는 + 재시도 후 기록 항목이 중복되는 것을 줄이기 위해 최근에 저장된 입력 항목을 최선의 방식으로 + 롤백합니다. - 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 + 이 호환성 재시도는 `ModelSettings.retry`을 구성하지 않아도 수행됩니다. 모델 요청에 대한 더 광범위한 옵트인 재시도 동작은 [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. ## 훅 및 사용자 지정 ### 모델 호출 입력 필터 -모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 이 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 받고 새로운 `ModelInputData`를 반환합니다. +모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 받아 새로운 `ModelInputData`을 반환합니다. -반환값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`가 발생합니다. +반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`이 발생합니다. ```python from agents import Agent, Runner, RunConfig @@ -460,19 +461,19 @@ result = Runner.run_sync( ) ``` -Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원본 목록을 제자리에서 변경하지 않고도 항목을 잘라내거나 대체하거나 순서를 변경할 수 있습니다. +Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원래 목록을 제자리에서 변경하지 않고도 항목을 줄이거나 대체하거나 재정렬할 수 있습니다. -세션을 사용하는 경우 `call_model_input_filter`는 세션 기록이 이미 로드되어 현재 턴과 병합된 후 실행됩니다. 이보다 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. +세션을 사용하는 경우 `call_model_input_filter`은 세션 기록이 이미 로드되어 현재 턴과 병합된 후 실행됩니다. 이전 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. -`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`로 OpenAI 서버 관리형 대화 상태를 사용하는 경우 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 이 페이로드는 이전 기록 전체의 재생이 아니라 이미 새 턴의 델타만 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리형 연속 실행에서 전송된 것으로 표시됩니다. +`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`과 함께 OpenAI 서버 관리 대화 상태를 사용하는 경우 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 이 페이로드는 이전 기록 전체를 다시 재생하는 대신 새 턴의 델타만 나타낼 수도 있습니다. 반환한 항목만 해당 서버 관리 연속 실행에 전송된 것으로 표시됩니다. -민감한 데이터를 수정하거나 긴 기록을 잘라내거나 추가 시스템 지침을 삽입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. +민감한 데이터를 편집하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 삽입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. ## 오류 및 복구 ### 오류 핸들러 -모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 허용합니다. 지원되는 키는 `"max_turns"`, `"model_refusal"`, `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. +모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. ```python from agents import ( @@ -501,7 +502,7 @@ result = Runner.run_sync( print(result.final_output) ``` -모델 메시지가 에이전트의 구조화된 `output_type`에 대해 유효성 검사를 통과하지 못하거나 모델이 구조화된 최종 메시지를 반환하지 않을 때 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 출력을 반환할 수 있으며 SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 다시 시도하거나 도구의 부수 효과를 재실행하지 않습니다. `None`을 반환하면 복구를 수행하지 않습니다. 대체 출력 없이 비어 있지 않은 값의 검증이 실패하면 계속 `ModelBehaviorError`가 발생하며, 비어 있는 구조화된 응답에는 기존의 다음 턴 동작이 유지됩니다. +모델 메시지가 에이전트의 structured `output_type`에 대해 유효성 검사를 통과하지 못하거나 모델이 structured 최종 메시지를 반환하지 않는 경우 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 값을 반환할 수 있으며 SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지는 않습니다. `None`을 반환하면 복구를 거부합니다. 대체 값이 없으면 비어 있지 않은 유효성 검사 실패는 계속 `ModelBehaviorError`를 발생시키며, 비어 있는 structured 응답에는 기존 다음 턴 동작이 유지됩니다. ```python from pydantic import BaseModel @@ -533,9 +534,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 수 핸들러의 경우 이렇게 하면 생성된 대체 출력이 대화 기록에 추가되고 구성된 세션에 저장됩니다. 대체 출력을 결과 기록이나 세션 스토리지에 추가하지 않고 호출자에게만 반환하려면 `include_in_history=False`로 설정하세요. +`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 핸들러에서는 합성된 대체 출력을 대화 기록에 추가하고 구성된 세션에 저장합니다. 대체 값을 결과 기록이나 세션 스토리지에 추가하지 않고 호출자에게 반환하려면 `include_in_history=False`를 설정하세요. -모델의 응답 거부가 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 출력을 생성해야 할 때 `"model_refusal"`을 사용하세요. +모델 거부 시 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 출력을 생성하려면 `"model_refusal"`을 사용하세요. ```python from pydantic import BaseModel @@ -569,33 +570,33 @@ print(result.final_output) ## 내구성 있는 실행 통합 및 휴먼인더루프 (HITL) -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 실행될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)에서 시작하세요. 아래 통합은 실행에 긴 대기, 재시도 또는 프로세스 재시작이 포함될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. ### Dapr -Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 휴먼인더루프를 지원하고 실패에서 자동으로 복구되는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr은 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr 및 OpenAI 에이전트 시작 방법은 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)를 참조하세요. +Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 실패에서 자동으로 복구되고 휴먼인더루프 (HITL) 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 벤더 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. ### Temporal -Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 작업을 포함한 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. +Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함하여 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. ### Restate -Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프, 세션 관리를 포함한 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합에는 Restate의 단일 바이너리 런타임이 종속성으로 필요하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. +Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)를 읽거나 [문서](https://docs.restate.dev/ai)를 참조하세요. ### DBOS -Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 실패 및 재시작 후에도 진행 상황을 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents) 및 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. +Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 실패와 재시작 중에도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지터리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. ## 예외 -SDK는 특정 상황에서 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. +SDK는 특정 경우에 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. -- [`AgentsException`][agents.exceptions.AgentsException]: SDK 내에서 발생하는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 유형입니다. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과할 때 발생하는 예외입니다. 지정된 상호작용 턴 수 내에 에이전트가 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`으로 설정하세요. -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 유효하지 않은 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 상황이 포함될 수 있습니다. - - 잘못된 형식의 JSON: 모델이 도구 호출이나 직접 출력에서 잘못된 형식의 JSON 구조를 제공하는 경우로, 특히 특정 `output_type`이 정의되어 있을 때 발생합니다. - - 예상하지 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못하는 경우 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생하는 예외입니다. +- [`AgentsException`][agents.exceptions.AgentsException]: SDK가 발생시키는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 타입입니다. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과할 때 발생하는 예외입니다. 에이전트가 지정된 에이전트 루프 턴(LLM 호출) 수 안에 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`를 설정하세요. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 유효하지 않은 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 경우가 포함될 수 있습니다. + - 잘못된 형식의 JSON: 특히 특정 `output_type`이 정의된 경우 모델이 도구 호출이나 직접 출력에 잘못된 형식의 JSON 구조를 제공하는 경우 + - 예상하지 못한 도구 관련 실패: 모델이 예상한 방식으로 도구를 사용하지 못한 경우 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 타임아웃을 초과하고 해당 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생하는 예외입니다. - [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성하는 사람인 사용자가 SDK 사용 중 오류를 범했을 때 발생하는 예외입니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API 오용으로 인해 발생합니다. -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 각각 입력 가드레일 또는 출력 가드레일의 조건이 충족될 때 발생하는 예외입니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. \ No newline at end of file +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 입력 가드레일의 조건이 충족되면 `InputGuardrailTripwireTriggered`이 발생하고, 출력 가드레일의 조건이 충족되면 `OutputGuardrailTripwireTriggered`가 발생합니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. \ No newline at end of file diff --git a/docs/ko/sandbox/clients.md b/docs/ko/sandbox/clients.md index b1cc4a278a..51237a0639 100644 --- a/docs/ko/sandbox/clients.md +++ b/docs/ko/sandbox/clients.md @@ -4,21 +4,21 @@ search: --- # 샌드박스 클라이언트 -이 페이지를 사용하여 샌드박스 작업을 실행할 위치를 선택하세요. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. +이 페이지에서 샌드박스 작업을 실행할 위치를 선택합니다. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 사항, 기본값, 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 예정입니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 사항, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지나면서 더 많은 고급 기능이 추가될 예정입니다. -## 선택 가이드 +## 결정 가이드
-| 목표 | 시작 옵션 | 이유 | +| 목표 | 시작할 항목 | 이유 | | --- | --- | --- | -| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치가 필요 없으며 로컬 파일 시스템에서 간단하게 개발할 수 있습니다. | -| 기본적인 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지가 적용된 Docker 내부에서 작업을 실행합니다. | -| 호스티드 실행 또는 프로덕션 수준의 격리 | 호스티드 샌드박스 클라이언트 | 워크스페이스 경계를 제공업체가 관리하는 환경으로 이동합니다. | +| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치 없이 간단한 로컬 파일 시스템에서 개발할 수 있습니다. | +| 기본적인 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지를 사용하는 Docker 내부에서 작업을 실행합니다. | +| 호스티드 실행 또는 프로덕션 수준의 격리 | 호스티드 샌드박스 클라이언트 | 작업 공간 경계를 공급자가 관리하는 환경으로 이동합니다. |
@@ -28,18 +28,18 @@ search:
-| 클라이언트 | 설치 | 선택하는 경우 | 예제 | +| 클라이언트 | 설치 | 선택할 상황 | 예제 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠르게 로컬 반복 개발을 수행하려는 경우입니다. 로컬 개발을 위한 좋은 기본 옵션입니다. | [Unix 로컬 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 컨테이너 격리가 필요하거나 로컬 환경의 동등성을 위해 특정 이미지를 사용하려는 경우입니다. | [Docker 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠르게 로컬 반복 개발을 진행하려는 경우. 로컬 개발에 적합한 기본 선택지입니다. | [Unix-local 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 컨테이너 격리가 필요하거나 대상 환경을 로컬에서 재현하기 위해 특정 이미지를 사용하려는 경우. | [Docker 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix 로컬은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리나 프로덕션 수준의 동등성이 필요할 때 Docker 또는 호스티드 제공업체로 전환하세요. +Unix-local은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리 또는 프로덕션 수준의 환경 일치가 필요하면 Docker나 호스티드 공급자로 전환합니다. -`SandboxPathGrant.host_path` 는 Docker에서만 사용할 수 있으며 호스트 경로를 컨테이너 내부의 다른 POSIX 경로에 매핑합니다. Unix 로컬에서는 동일 경로 허용만 지원합니다. 자세한 내용은 [매니페스트 경로 허용](guide.md#manifest)을 참조하세요. +`SandboxPathGrant.host_path`은 Docker 전용이며 호스트 경로를 컨테이너 내부의 다른 POSIX 경로에 매핑합니다. Unix-local은 동일 경로 권한 부여만 지원합니다. 자세한 내용은 [매니페스트 경로 권한 부여](guide.md#manifest)를 참조하세요. -Unix 로컬에서 Docker로 전환하려면 에이전트 정의는 그대로 유지하고 실행 구성만 변경합니다. +Unix-local에서 Docker로 전환하려면 에이전트 정의는 그대로 유지하고 실행 구성만 변경합니다. ```python from docker import from_env as docker_from_env @@ -56,41 +56,41 @@ run_config = RunConfig( ) ``` -컨테이너 격리 또는 이미지 동등성이 필요한 경우 이 방식을 사용하세요. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. +컨테이너 격리가 필요하거나 샌드박스 이미지가 다른 환경에서 사용하는 이미지와 일치해야 할 때 이 방법을 사용합니다. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. ## 마운트 및 원격 스토리지 -마운트 항목은 노출할 스토리지를 정의하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 정의합니다. 기본 제공 마운트 항목과 범용 전략은 `agents.sandbox.entries` 에서 가져옵니다. 호스티드 제공업체 전략은 `agents.extensions.sandbox` 또는 제공업체별 확장 패키지에서 사용할 수 있습니다. +마운트 항목은 노출할 스토리지를 나타내고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 나타냅니다. 기본 제공 마운트 항목과 범용 전략은 `agents.sandbox.entries`에서 가져옵니다. 호스티드 공급자용 전략은 `agents.extensions.sandbox` 또는 공급자별 확장 패키지에서 사용할 수 있습니다. 일반적인 마운트 옵션은 다음과 같습니다. -- `mount_path`: 샌드박스에서 스토리지가 표시되는 위치입니다. 상대 경로는 매니페스트 루트를 기준으로 해석되며, 절대 경로는 그대로 사용됩니다. -- `read_only`: 기본값은 `True` 입니다. 샌드박스가 마운트된 스토리지에 다시 기록해야 하는 경우에만 `False` 로 설정하세요. -- `mount_strategy`: 필수 항목입니다. 마운트 항목과 샌드박스 백엔드 모두에 적합한 전략을 사용하세요. +- `mount_path`: 샌드박스에서 스토리지가 나타나는 위치입니다. 상대 경로는 매니페스트 루트를 기준으로 해석되며, 절대 경로는 그대로 사용됩니다. +- `read_only`: 기본값은 `True`입니다. 샌드박스가 마운트된 스토리지에 변경 사항을 다시 기록해야 하는 경우에만 `False`을 설정합니다. +- `mount_strategy`: 필수입니다. 마운트 항목과 샌드박스 백엔드 모두에 맞는 전략을 사용합니다. -마운트는 임시 워크스페이스 항목으로 처리됩니다. 스냅샷 및 영속성 처리 과정에서는 마운트된 원격 스토리지를 저장된 워크스페이스에 복사하지 않고 마운트된 경로를 분리하거나 건너뜁니다. +마운트는 임시 작업 공간 항목으로 처리됩니다. 스냅샷 및 영속성 처리 과정에서는 마운트된 원격 스토리지를 저장된 작업 공간에 복사하지 않고 마운트된 경로를 분리하거나 건너뜁니다. 범용 로컬/컨테이너 전략은 다음과 같습니다.
-| 전략 또는 패턴 | 사용하는 경우 | 참고 사항 | +| 전략 또는 패턴 | 사용할 상황 | 참고 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone` 을 실행할 수 있는 경우입니다. | S3, GCS, R2, Azure Blob, Box를 지원합니다. `RcloneMountPattern` 은 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3` 가 있고 Mountpoint 방식으로 S3 또는 S3 호환 스토리지에 액세스하려는 경우입니다. | `S3Mount` 및 `GCSMount` 를 지원합니다. | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2` 및 FUSE 지원이 있는 경우입니다. | `AzureBlobMount` 를 지원합니다. | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 이미지에 `mount.s3files` 가 있고 기존 S3 Files 마운트 대상에 접근할 수 있는 경우입니다. | `S3FilesMount` 를 지원합니다. | -| `DockerVolumeMountStrategy(driver=...)` | 컨테이너가 시작되기 전에 Docker가 볼륨 드라이버 기반 마운트를 연결해야 하는 경우입니다. | Docker 전용입니다. S3, GCS, R2, Azure Blob, Box는 `rclone` 을 지원하며, S3와 GCS는 `mountpoint` 도 지원합니다. | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone`을 실행할 수 있는 경우. | S3, GCS, R2, Azure Blob 및 Box를 지원합니다. `RcloneMountPattern`은 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3`이 있고 Mountpoint 방식의 S3 또는 S3 호환 액세스가 필요한 경우. | `S3Mount` 및 `GCSMount`을 지원합니다. | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2` 및 FUSE 지원이 있는 경우. | `AzureBlobMount`을 지원합니다. | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 이미지에 `mount.s3files`이 있고 기존 S3 Files 마운트 대상에 연결할 수 있는 경우. | `S3FilesMount`를 지원합니다. | +| `DockerVolumeMountStrategy(driver=...)` | 컨테이너가 시작되기 전에 Docker가 볼륨 드라이버 기반 마운트를 연결해야 하는 경우. | Docker 전용입니다. S3, GCS, R2, Azure Blob 및 Box는 `rclone`을 통해 마운트할 수 있으며, S3와 GCS는 `mountpoint`를 통해서도 마운트할 수 있습니다. |
## 지원되는 호스티드 플랫폼 -호스티드 환경이 필요한 경우에도 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용할 수 있으며 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에서 샌드박스 클라이언트만 변경하면 됩니다. +호스티드 환경이 필요한 경우 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트만 변경합니다. -이 저장소의 체크아웃 대신 배포된 SDK를 사용하는 경우, 해당 패키지 extra를 통해 샌드박스 클라이언트 의존성을 설치하세요. +이 저장소의 체크아웃 대신 배포된 SDK를 사용하는 경우 일치하는 패키지 extra를 통해 샌드박스 클라이언트 종속성을 설치합니다. -저장소에 포함된 확장 코드 예제에 대한 제공업체별 설정 참고 사항과 링크는 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)를 참조하세요. +저장소에 포함된 확장 코드 예제의 공급자별 설정 참고 사항과 링크는 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)를 참조하세요.
@@ -106,24 +106,24 @@ run_config = RunConfig(
-호스티드 샌드박스 클라이언트는 제공업체별 마운트 전략을 제공합니다. 스토리지 제공업체에 가장 적합한 백엔드와 마운트 전략을 선택하세요. +호스티드 샌드박스 클라이언트는 공급자별 마운트 전략을 제공합니다. 스토리지 공급자에 가장 적합한 백엔드와 마운트 전략을 선택합니다.
| 백엔드 | 마운트 참고 사항 | | --- | --- | -| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy` 같은 로컬 전략을 사용하여 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 를 지원합니다. | -| `ModalSandboxClient` | `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount` 에서 `ModalCloudBucketMountStrategy` 를 사용하는 Modal 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | -| `CloudflareSandboxClient` | `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount` 에서 `CloudflareBucketMountStrategy` 를 사용하는 Cloudflare 버킷 마운트를 지원합니다. | -| `BlaxelSandboxClient` | `S3Mount`, `R2Mount`, `GCSMount` 에서 `BlaxelCloudBucketMountStrategy` 를 사용하는 클라우드 버킷 마운트를 지원합니다. 또한 `agents.extensions.sandbox.blaxel` 의 `BlaxelDriveMount` 및 `BlaxelDriveMountStrategy` 를 사용하여 영속적 Blaxel Drive를 지원합니다. | -| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` 를 사용하는 rclone 기반 클라우드 스토리지 마운트를 지원하며, `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount` 와 함께 사용할 수 있습니다. | -| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` 를 사용하는 rclone 기반 클라우드 스토리지 마운트를 지원하며, `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount` 와 함께 사용할 수 있습니다. | -| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` 를 사용하는 rclone 기반 클라우드 스토리지 마운트를 지원하며, `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount` 와 함께 사용할 수 있습니다. | -| `VercelSandboxClient` | `S3Mount` 에서 `VercelCloudBucketMountStrategy` 를 사용하는, 생성 시점에만 적용 가능한 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True` 가 필요합니다. | +| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy` 같은 로컬 전략을 사용하여 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount`를 지원합니다. | +| `ModalSandboxClient` | `S3Mount`, `R2Mount` 및 HMAC 인증 방식의 `GCSMount`과 함께 `ModalCloudBucketMountStrategy`을 사용하여 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | +| `CloudflareSandboxClient` | `S3Mount`, `R2Mount` 및 HMAC 인증 방식의 `GCSMount`과 함께 `CloudflareBucketMountStrategy`을 사용하여 버킷 마운트를 지원합니다. | +| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy`을 `S3Mount`, `R2Mount` 또는 `GCSMount` 항목과 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 또한 `BlaxelDriveMount` 및 `BlaxelDriveMountStrategy`을 사용하여 영구 Blaxel Drives를 지원하며, 둘 다 `agents.extensions.sandbox.blaxel`에서 사용할 수 있습니다. | +| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy`을 사용해 `rclone`을 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용합니다. | +| `E2BSandboxClient` | `E2BCloudBucketMountStrategy`를 사용해 `rclone`를 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | +| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy`을 사용해 `rclone`를 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | +| `VercelSandboxClient` | `VercelCloudBucketMountStrategy`을 `S3Mount` 항목과 함께 사용하여 생성 시점에만 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True`가 필요합니다. |
-다음 표에는 각 백엔드가 직접 마운트할 수 있는 원격 스토리지 항목이 요약되어 있습니다. +아래 표에는 각 백엔드가 직접 마운트할 수 있는 원격 스토리지 항목이 요약되어 있습니다.
@@ -140,4 +140,4 @@ run_config = RunConfig(
-실행 가능한 더 많은 코드 예제를 보려면 로컬, 코딩, 메모리, 핸드오프, 에이전트 구성 패턴은 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)에서, 호스티드 샌드박스 클라이언트는 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)에서 확인하세요. \ No newline at end of file +실행 가능한 코드 예제를 더 살펴보려면 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴은 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)에서, 호스티드 샌드박스 클라이언트는 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)에서 확인하세요. \ No newline at end of file diff --git a/docs/ko/sandbox/guide.md b/docs/ko/sandbox/guide.md index aeb2deaaac..36fc497b0f 100644 --- a/docs/ko/sandbox/guide.md +++ b/docs/ko/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "베타 기능" - 샌드박스 에이전트는 베타 기능입니다. 정식 출시 전까지 API 세부 사항, 기본값 및 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API의 세부 사항, 기본값 및 지원 기능이 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 수 있습니다. -현대적인 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특수 도구와 셸 명령을 사용하여 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 아티팩트를 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 에이전트가 사용자를 대신해 작업할 수 있는 영구 작업 공간을 모델에 제공합니다. Agents SDK의 샌드박스 에이전트를 사용하면 샌드박스 환경과 결합된 에이전트를 쉽게 실행할 수 있으며, 필요한 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모로 작업을 쉽게 시작, 중지, 재개할 수 있습니다. +최신 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특수 도구와 셸 명령을 사용하여 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 결과물을 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 모델에 지속성 있는 워크스페이스를 제공하며, 에이전트는 이를 사용해 사용자를 대신하여 작업할 수 있습니다. Agents SDK의 샌드박스 에이전트를 사용하면 샌드박스 환경과 결합된 에이전트를 쉽게 실행할 수 있으며, 적절한 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모로 작업을 쉽게 시작, 중지 및 재개할 수 있습니다. -에이전트에 필요한 데이터를 중심으로 작업 공간을 정의합니다. GitHub 저장소, 로컬 파일 및 디렉터리, 합성 작업 파일, S3 또는 Azure Blob Storage 같은 원격 파일 시스템 및 사용자가 제공하는 기타 샌드박스 입력에서 시작할 수 있습니다. +에이전트에 필요한 데이터를 중심으로 워크스페이스를 정의합니다. GitHub 저장소, 로컬 파일 및 디렉터리, 합성 작업 파일, S3나 Azure Blob Storage 같은 원격 파일 시스템 및 사용자가 제공하는 기타 샌드박스 입력으로 시작할 수 있습니다.
-![컴퓨팅 기능이 포함된 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png) +![컴퓨팅 환경이 포함된 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png)
-`SandboxAgent`도 여전히 `Agent`입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일, 훅과 같은 일반적인 에이전트 인터페이스를 유지하며, 일반적인 `Runner` API를 통해 실행됩니다. 달라지는 점은 실행 경계입니다. +`SandboxAgent`은 여전히 `Agent`입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일 및 훅과 같은 일반적인 에이전트 인터페이스를 유지하며, 일반적인 `Runner` API를 통해 계속 실행됩니다. 달라지는 부분은 실행 경계입니다. -- `SandboxAgent`는 에이전트 자체를 정의합니다. 일반적인 에이전트 구성뿐 아니라 `default_manifest`, `base_instructions`, `run_as` 같은 샌드박스별 기본값과 파일 시스템 도구, 셸 액세스, 스킬, 메모리 또는 압축 같은 기능도 포함합니다. -- `Manifest`는 파일, 저장소, 마운트, 환경을 포함하여 새 샌드박스 작업 공간의 원하는 초기 콘텐츠와 레이아웃을 선언합니다. +- `SandboxAgent`은 에이전트 자체를 정의합니다. 여기에는 일반적인 에이전트 구성뿐 아니라 `default_manifest`, `base_instructions`, `run_as`과 같은 샌드박스 전용 기본값, 파일 시스템 도구, 셸 액세스, 스킬, 메모리 또는 압축 같은 기능이 포함됩니다. +- `Manifest`는 파일, 저장소, 마운트 및 환경을 포함해 새 샌드박스 워크스페이스에 필요한 초기 콘텐츠와 레이아웃을 선언합니다. - 샌드박스 세션은 명령이 실행되고 파일이 변경되는 활성 격리 환경입니다. - [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 실행에서 샌드박스 세션을 가져오는 방법을 결정합니다. 예를 들어 세션을 직접 주입하거나, 직렬화된 샌드박스 세션 상태에서 다시 연결하거나, 샌드박스 클라이언트를 통해 새 샌드박스 세션을 생성할 수 있습니다. -- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행에서 이전 작업에 다시 연결하거나 저장된 콘텐츠를 기반으로 새 샌드박스 세션을 시작할 수 있습니다. +- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행에서 이전 작업에 다시 연결하거나 저장된 콘텐츠로 새 샌드박스 세션을 초기화할 수 있습니다. -`Manifest`는 새 세션의 작업 공간 계약이며, 모든 활성 샌드박스에 대한 완전한 정보 원본은 아닙니다. 실행의 실질적인 작업 공간은 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택한 스냅샷에서 가져올 수도 있습니다. +`Manifest`은 새 세션의 워크스페이스 계약이며, 모든 활성 샌드박스에 대한 완전한 정보 소스는 아닙니다. 실행의 실질적인 워크스페이스는 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택된 스냅샷에서 가져올 수도 있습니다. 이 페이지에서 "샌드박스 세션"은 샌드박스 클라이언트가 관리하는 활성 실행 환경을 의미합니다. 이는 [세션](../sessions/index.md)에서 설명하는 SDK의 대화형 [`Session`][agents.memory.session.Session] 인터페이스와 다릅니다. -외부 런타임은 계속해서 승인, 트레이싱, 핸드오프 및 재개 관련 기록 관리를 담당합니다. 샌드박스 세션은 명령, 파일 변경 및 환경 격리를 담당합니다. 이러한 역할 분리는 이 모델의 핵심 요소입니다. +외부 런타임은 여전히 승인, 트레이싱, 핸드오프 및 실행 재개에 필요한 상태 추적을 담당합니다. 샌드박스 세션은 명령, 파일 변경 및 환경 격리를 담당합니다. 이러한 역할 분리는 모델의 핵심 요소입니다. -### 구성 요소의 관계 +### 구성 요소의 결합 방식 -샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 활성 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. +샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 활성 샌드박스 세션에 연결하며, 이후 실행을 위해 상태를 저장할 수 있습니다. ```mermaid flowchart LR @@ -50,96 +50,96 @@ flowchart LR sandbox --> saved ``` -샌드박스별 기본값은 `SandboxAgent`에 유지합니다. 실행별 샌드박스 세션 선택 사항은 `SandboxRunConfig`에 유지합니다. +샌드박스 전용 기본값은 `SandboxAgent`에 유지합니다. 실행별 샌드박스 세션 선택은 `SandboxRunConfig`에 유지합니다. -수명 주기는 다음 세 단계로 나눌 수 있습니다. +수명 주기는 다음 세 단계로 생각할 수 있습니다. -1. `SandboxAgent`, `Manifest` 및 기능을 사용해 에이전트와 새 작업 공간 계약을 정의합니다. -2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig`를 `Runner`에 제공하여 실행합니다. -3. 러너가 관리하는 `RunState`, 명시적인 샌드박스 `session_state` 또는 저장된 작업 공간 스냅샷에서 나중에 작업을 계속합니다. +1. `SandboxAgent`, `Manifest` 및 기능을 사용해 에이전트와 새 워크스페이스 계약을 정의합니다. +2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig`을 `Runner`에 제공하여 실행합니다. +3. 러너가 관리하는 `RunState`, 명시적인 샌드박스 `session_state` 또는 저장된 워크스페이스 스냅샷에서 나중에 작업을 계속합니다. -셸 액세스가 가끔 사용하는 도구 중 하나에 불과하다면 [도구 가이드](../tools.md)의 호스티드 셸로 시작하세요. 작업 공간 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. +셸 액세스를 가끔 사용하는 하나의 도구로만 활용한다면 [도구 가이드](../tools.md)의 호스티드 셸부터 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. ## 사용 시점 -샌드박스 에이전트는 다음과 같은 작업 공간 중심 워크플로에 적합합니다. +샌드박스 에이전트는 다음과 같은 워크스페이스 중심 워크플로에 적합합니다. -- 코딩 및 디버깅(예: GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 대상 테스트 실행) -- 문서 처리 및 편집(예: 사용자의 재무 문서에서 정보를 추출하고 작성된 세금 양식 초안 생성) -- 파일 기반 검토 또는 분석(예: 답변 전 온보딩 문서 묶음, 생성된 보고서 또는 아티팩트 번들 확인) -- 격리된 멀티 에이전트 패턴(예: 각 검토자 또는 코딩 하위 에이전트에 자체 작업 공간 제공) -- 여러 단계로 이루어진 작업 공간 작업(예: 한 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개) +- 코딩 및 디버깅. 예를 들어 GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 대상 테스트 실행 +- 문서 처리 및 편집. 예를 들어 사용자의 금융 문서에서 정보를 추출하고 작성이 완료된 세금 양식 초안 생성 +- 파일 기반 검토 또는 분석. 예를 들어 답변 전에 온보딩 자료, 생성된 보고서 또는 결과물 번들 확인 +- 격리된 다중 에이전트 패턴. 예를 들어 각 검토자 또는 코딩 하위 에이전트에 자체 워크스페이스 제공 +- 다단계 워크스페이스 작업. 예를 들어 한 번의 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개 -파일이나 지속적으로 변경되는 파일 시스템에 액세스할 필요가 없다면 계속 `Agent`를 사용하세요. 셸 액세스가 가끔 필요한 기능에 불과하다면 호스티드 셸을 추가하고, 작업 공간 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. +파일이나 상태를 유지하며 변경 가능한 파일 시스템에 액세스할 필요가 없다면 계속 `Agent`을 사용하세요. 셸 액세스가 가끔 필요한 기능 중 하나일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. ## 샌드박스 클라이언트 선택 -macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 제공자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리 또는 이미지 일관성이 필요하면 `DockerSandboxClient`로 전환하고, 제공자가 관리하는 실행이 필요하면 호스티드 제공자로 전환하세요. +macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 공급자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리나 이미지 동등성이 필요하면 `DockerSandboxClient`로 전환하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자로 전환하세요. -대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 해당 옵션만 변경합니다. 로컬, Docker, 호스티드 및 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. +대부분의 경우 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 해당 옵션만 변경하고 `SandboxAgent` 정의는 동일하게 유지할 수 있습니다. 로컬, Docker, 호스티드 및 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. ## 핵심 구성 요소
-| 계층 | 주요 SDK 구성 요소 | 답변하는 질문 | +| 계층 | 주요 SDK 구성 요소 | 답하는 질문 | | --- | --- | --- | -| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트가 실행되며, 어떤 새 세션 작업 공간 계약에서 시작해야 하는가? | -| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트 및 활성 샌드박스 세션 | 이 실행은 어떻게 활성 샌드박스 세션을 가져오며, 작업은 어디에서 실행되는가? | -| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state` 및 스냅샷 | 이 워크플로는 이전 샌드박스 작업에 어떻게 다시 연결하거나 저장된 콘텐츠를 기반으로 새 샌드박스 세션을 시작하는가? | +| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트가 실행되며, 어떤 새 세션 워크스페이스 계약으로 시작해야 합니까? | +| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트 및 활성 샌드박스 세션 | 이 실행은 어떻게 활성 샌드박스 세션을 가져오며, 작업은 어디에서 실행됩니까? | +| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state` 및 스냅샷 | 이 워크플로는 어떻게 이전 샌드박스 작업에 다시 연결하거나 저장된 콘텐츠로 새 샌드박스 세션을 초기화합니까? |
-주요 SDK 구성 요소는 다음과 같이 각 계층에 대응합니다. +주요 SDK 구성 요소는 다음과 같이 해당 계층에 대응합니다.
| 구성 요소 | 담당 영역 | 확인할 질문 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트는 무엇을 해야 하며, 어떤 기본값을 함께 유지해야 하는가? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션 작업 공간의 파일 및 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 하는가? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 어떤 도구, 지침 조각 또는 런타임 동작을 이 에이전트에 연결해야 하는가? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트 및 샌드박스 세션 소스 | 이 실행은 샌드박스 세션을 주입, 재개 또는 생성해야 하는가? | -| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 러너가 관리하던 이전 워크플로를 재개하고 그 샌드박스 상태를 자동으로 이어갈 것인가? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태로부터 재개할 것인가? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위한 저장된 작업 공간 콘텐츠 | 새 샌드박스 세션을 저장된 파일과 아티팩트에서 시작할 것인가? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트는 무엇을 해야 하며, 어떤 기본값을 함께 유지해야 합니까? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션 워크스페이스의 파일 및 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 합니까? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 어떤 도구, 지침 조각 또는 런타임 동작을 이 에이전트에 연결해야 합니까? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트 및 샌드박스 세션 소스 | 이 실행은 샌드박스 세션을 주입, 재개 또는 생성해야 합니까? | +| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 이전에 러너가 관리하던 워크플로를 재개하고 해당 샌드박스 상태를 자동으로 전달하고 있습니까? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태를 재개하려고 합니까? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위해 저장된 워크스페이스 콘텐츠 | 새 샌드박스 세션이 저장된 파일과 결과물에서 시작해야 합니까? |
실용적인 설계 순서는 다음과 같습니다. -1. `Manifest`로 새 세션 작업 공간 계약을 정의합니다. -2. `SandboxAgent`로 에이전트를 정의합니다. +1. `Manifest`로 새 세션 워크스페이스 계약을 정의합니다. +2. `SandboxAgent`으로 에이전트를 정의합니다. 3. 기본 제공 또는 사용자 지정 기능을 추가합니다. -4. 각 실행이 `RunConfig(sandbox=SandboxRunConfig(...))`에서 샌드박스 세션을 가져오는 방법을 결정합니다. +4. `RunConfig(sandbox=SandboxRunConfig(...))`에서 각 실행이 샌드박스 세션을 가져올 방법을 결정합니다. -## 샌드박스 실행 준비 과정 +## 샌드박스 실행 준비 방식 실행 시 러너는 해당 정의를 구체적인 샌드박스 기반 실행으로 변환합니다. -1. `SandboxRunConfig`에서 샌드박스 세션을 확인합니다. `session=...`을 전달하면 해당 활성 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...`를 사용하여 세션을 생성하거나 재개합니다. -2. 실행에 실질적으로 적용할 작업 공간 입력을 결정합니다. 실행이 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest`에서 시작합니다. 이 때문에 `Manifest`만으로는 모든 실행의 최종 활성 작업 공간을 정의할 수 없습니다. -3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트를 준비하기 전에 기능이 파일, 마운트 또는 기타 작업 공간 범위의 동작을 추가할 수 있습니다. -4. 고정된 순서로 최종 지침을 구성합니다. 먼저 SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`를 사용하고, 이어서 `instructions`, 기능의 지침 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리를 추가합니다. -5. 기능 도구를 활성 샌드박스 세션에 바인딩하고 일반적인 `Runner` API를 통해 준비된 에이전트를 실행합니다. +1. `SandboxRunConfig`에서 샌드박스 세션을 확인합니다. `session=...`를 전달하면 해당 활성 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...`을 사용해 세션을 생성하거나 재개합니다. +2. 실행에 실질적으로 적용할 워크스페이스 입력을 결정합니다. 실행에서 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest`에서 시작합니다. 이 때문에 모든 실행의 최종 활성 워크스페이스가 `Manifest`만으로 정의되지는 않습니다. +3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트를 준비하기 전에 기능에서 파일, 마운트 또는 기타 워크스페이스 범위 동작을 추가할 수 있습니다. +4. 다음과 같은 고정된 순서로 최종 지침을 구성합니다. SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`, 그다음 `instructions`, 기능 지침 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리 순입니다. +5. 기능 도구를 활성 샌드박스 세션에 연결하고 일반적인 `Runner` API를 통해 준비된 에이전트를 실행합니다. -샌드박싱은 턴의 의미를 변경하지 않습니다. 턴은 여전히 하나의 셸 명령이나 샌드박스 작업이 아니라 모델 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내부에서 처리될 수 있지만, 다른 작업은 또 다른 모델 단계가 필요한 도구 결과, 승인 또는 기타 상태를 반환할 수 있습니다. 실용적인 원칙으로, 샌드박스 작업이 수행된 후 에이전트 런타임에 또 다른 모델 응답이 필요할 때만 턴이 하나 더 소비됩니다. +샌드박스를 사용해도 턴의 의미는 달라지지 않습니다. 턴은 여전히 단일 셸 명령이나 샌드박스 작업이 아니라 모델의 한 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내에서 처리될 수 있지만, 도구 결과, 승인 또는 다른 종류의 상태처럼 추가 모델 단계가 필요한 정보를 반환하는 작업도 있습니다. 실용적인 원칙으로는 샌드박스 작업이 발생한 후 에이전트 런타임에 또 다른 모델 응답이 필요한 경우에만 추가 턴이 소비됩니다. -이러한 준비 단계 때문에 `SandboxAgent`를 설계할 때 고려해야 할 주요 샌드박스별 옵션은 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as`입니다. +이러한 준비 단계 때문에 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as`은 `SandboxAgent`을 설계할 때 고려해야 할 주요 샌드박스 전용 옵션입니다. ## `SandboxAgent` 옵션 -다음은 일반적인 `Agent` 필드에 추가되는 샌드박스별 옵션입니다. +일반적인 `Agent` 필드에 더해 사용할 수 있는 샌드박스 전용 옵션은 다음과 같습니다.
-| 옵션 | 가장 적합한 용도 | +| 옵션 | 적합한 용도 | | --- | --- | -| `default_manifest` | 러너가 생성하는 새 샌드박스 세션의 기본 작업 공간 | +| `default_manifest` | 러너가 생성하는 새 샌드박스 세션의 기본 워크스페이스 | | `instructions` | SDK 샌드박스 프롬프트 뒤에 추가되는 역할, 워크플로 및 성공 기준 | -| `base_instructions` | SDK 샌드박스 프롬프트를 대체하는 고급 이스케이프 해치 | -| `capabilities` | 이 에이전트와 함께 유지되어야 하는 샌드박스 네이티브 도구 및 동작 | -| `run_as` | 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구의 사용자 ID | +| `base_instructions` | SDK 샌드박스 프롬프트를 대체하는 고급 탈출구 | +| `capabilities` | 이 에이전트와 함께 유지해야 하는 샌드박스 네이티브 도구 및 동작 | +| `run_as` | 셸 명령, 파일 읽기 및 패치와 같이 모델에 노출되는 샌드박스 도구의 사용자 ID |
@@ -147,15 +147,15 @@ macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 ### `default_manifest` -`default_manifest`는 러너가 이 에이전트를 위해 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest]입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리 및 마운트에 사용하세요. +`default_manifest`은 러너가 이 에이전트에 대한 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest]입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리 및 마운트에 사용하세요. -이는 기본값일 뿐입니다. 실행에서 `SandboxRunConfig(manifest=...)`를 사용해 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 작업 공간 상태를 유지합니다. +이는 기본값일 뿐입니다. 실행 시 `SandboxRunConfig(manifest=...)`으로 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 워크스페이스 상태를 유지합니다. ### `instructions` 및 `base_instructions` -여러 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`를 사용하세요. `SandboxAgent`에서 이러한 지침은 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 역할, 워크플로 및 성공 기준을 추가할 수 있습니다. +여러 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`을 사용하세요. `SandboxAgent`에서 이러한 지침은 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로 및 성공 기준을 추가할 수 있습니다. -SDK 샌드박스 기본 프롬프트를 교체하려는 경우에만 `base_instructions`를 사용하세요. 대부분의 에이전트에서는 이를 설정하지 않는 것이 좋습니다. +SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions`을 사용하세요. 대부분의 에이전트에서는 설정하지 않는 것이 좋습니다.
@@ -163,25 +163,25 @@ SDK 샌드박스 기본 프롬프트를 교체하려는 경우에만 `base_instr | --- | --- | --- | | `instructions` | 에이전트의 안정적인 역할, 워크플로 규칙 및 성공 기준 | "온보딩 문서를 검사한 다음 핸드오프하세요.", "최종 파일을 `output/`에 작성하세요." | | `base_instructions` | SDK 샌드박스 기본 프롬프트의 완전한 대체 | 사용자 지정 저수준 샌드박스 래퍼 프롬프트 | -| 사용자 프롬프트 | 이 실행을 위한 일회성 요청 | "이 작업 공간을 요약하세요." | -| 매니페스트의 작업 공간 파일 | 더 긴 작업 명세, 저장소 로컬 지침 또는 범위가 제한된 참고 자료 | `repo/task.md`, 문서 번들, 샘플 문서 묶음 | +| 사용자 프롬프트 | 이번 실행의 일회성 요청 | "이 워크스페이스를 요약하세요." | +| 매니페스트의 워크스페이스 파일 | 긴 작업 명세, 저장소 로컬 지침 또는 범위가 제한된 참고 자료 | `repo/task.md`, 문서 번들, 샘플 자료 |
-`instructions`의 적절한 사용 예시는 다음과 같습니다. +`instructions`의 적절한 사용 예는 다음과 같습니다. - [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py)는 PTY 상태가 중요할 때 에이전트를 하나의 대화형 프로세스에 유지합니다. - [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)는 샌드박스 검토자가 검사 후 사용자에게 직접 답변하지 못하도록 합니다. -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)는 작성이 완료된 최종 파일이 실제로 `output/`에 저장되도록 요구합니다. -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)는 정확한 검증 명령을 고정하고 작업 공간 루트 기준 패치 경로를 명확히 설명합니다. +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)는 최종 작성 파일이 실제로 `output/`에 저장되도록 요구합니다. +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)는 정확한 검증 명령을 고정하고 워크스페이스 루트 기준 패치 경로를 명확히 합니다. -사용자의 일회성 작업을 `instructions`에 복사하거나, 매니페스트에 속해야 하는 긴 참고 자료를 포함하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 반복하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. +사용자의 일회성 작업을 `instructions`에 복사하거나, 매니페스트에 포함해야 할 긴 참고 자료를 삽입하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 반복하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. -`instructions`를 생략해도 SDK는 기본 샌드박스 프롬프트를 포함합니다. 저수준 래퍼에는 이것만으로 충분하지만, 대부분의 사용자 대상 에이전트는 여전히 명시적인 `instructions`를 제공해야 합니다. +`instructions`을 생략해도 SDK에는 기본 샌드박스 프롬프트가 포함됩니다. 저수준 래퍼에는 이것만으로 충분하지만, 대부분의 사용자 대상 에이전트에서는 여전히 명시적인 `instructions`을 제공해야 합니다. ### `capabilities` -기능은 샌드박스 네이티브 동작을 `SandboxAgent`에 연결합니다. 실행이 시작되기 전에 작업 공간을 구성하고, 샌드박스별 지침을 추가하고, 활성 샌드박스 세션에 바인딩되는 도구를 노출하고, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. +기능은 샌드박스 네이티브 동작을 `SandboxAgent`에 연결합니다. 실행 시작 전에 워크스페이스를 구성하고, 샌드박스 전용 지침을 추가하고, 활성 샌드박스 세션에 연결되는 도구를 노출하고, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. 기본 제공 기능은 다음과 같습니다. @@ -189,38 +189,38 @@ SDK 샌드박스 기본 프롬프트를 교체하려는 경우에만 `base_instr | 기능 | 추가 시점 | 참고 사항 | | --- | --- | --- | -| `Shell` | 에이전트에 셸 액세스가 필요한 경우 | `exec_command`를 추가하며, 샌드박스 클라이언트가 PTY 상호 작용을 지원하는 경우 `write_stdin`도 추가합니다. | -| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 하는 경우 | `apply_patch` 및 `view_image`를 추가합니다. 패치 경로는 작업 공간 루트를 기준으로 합니다. | -| `Skills` | 샌드박스에서 스킬 탐색 및 구체화를 사용하려는 경우 | `.agents` 또는 `.agents/skills`를 수동으로 마운트하는 대신 이를 사용하는 것이 좋습니다. `Skills`가 스킬을 인덱싱하고 샌드박스에 구체화합니다. | -| `Memory` | 후속 실행에서 메모리 아티팩트를 읽거나 생성해야 하는 경우 | `Shell`이 필요하며, 실시간 업데이트에는 `Filesystem`도 필요합니다. | -| `Compaction` | 장기 실행 흐름에서 압축 항목 후 컨텍스트를 축소해야 하는 경우 | 모델 샘플링 및 입력 처리를 조정합니다. | +| `Shell` | 에이전트에 셸 액세스가 필요할 때 | `exec_command`을 추가하며, 샌드박스 클라이언트가 PTY 상호작용을 지원하면 `write_stdin`도 추가합니다. | +| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 할 때 | `apply_patch` 및 `view_image`를 추가합니다. 패치 경로는 워크스페이스 루트를 기준으로 합니다. | +| `Skills` | 샌드박스에서 스킬 검색 및 구체화를 사용하려 할 때 | `.agents` 또는 `.agents/skills`을 수동으로 마운트하는 대신 이를 사용하는 것이 좋습니다. `Skills`은 스킬의 인덱스를 생성하고 샌드박스에 구체화합니다. | +| `Memory` | 후속 실행에서 메모리 결과물을 읽거나 생성해야 할 때 | `Shell`이 필요합니다. 실행 중 메모리 결과물을 업데이트하려면 `Filesystem`도 필요합니다. | +| `Compaction` | 장기 실행 흐름에서 압축 항목 이후 컨텍스트를 축소해야 할 때 | 모델 샘플링 및 입력 처리를 조정합니다. | -기본적으로 `SandboxAgent.capabilities`는 `Filesystem()`, `Shell()`, `Compaction()`을 포함하는 `Capabilities.default()`를 사용합니다. `capabilities=[...]`를 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용하려는 기본 기능을 포함하세요. +기본적으로 `SandboxAgent.capabilities`는 `Capabilities.default()`를 사용하며, 여기에는 `Filesystem()`, `Shell()`, `Compaction()`이 포함됩니다. `capabilities=[...]`을 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용할 기본 기능도 포함해야 합니다. -스킬의 경우 원하는 구체화 방식에 따라 소스를 선택하세요. +스킬의 경우 구체화하려는 방식에 따라 소스를 선택하세요. -- `Skills(lazy_from=LocalDirLazySkillSource(...))`는 모델이 먼저 인덱스를 탐색하고 필요한 항목만 로드할 수 있으므로 규모가 큰 로컬 스킬 디렉터리에 적합한 기본 선택입니다. -- `LocalDirLazySkillSource(source=LocalDir(src=...))`는 SDK 프로세스가 실행되는 파일 시스템에서 읽습니다. 샌드박스 이미지나 작업 공간 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. +- `Skills(lazy_from=LocalDirLazySkillSource(...))`은 모델이 먼저 인덱스를 검색하고 필요한 항목만 로드할 수 있으므로 규모가 큰 로컬 스킬 디렉터리에 적합한 기본값입니다. +- `LocalDirLazySkillSource(source=LocalDir(src=...))`은 SDK 프로세스가 실행 중인 파일 시스템에서 읽습니다. 샌드박스 이미지나 워크스페이스 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. - `Skills(from_=LocalDir(src=...))`는 미리 스테이징하려는 소규모 로컬 번들에 더 적합합니다. -- `Skills(from_=GitRepo(repo=..., ref=...))`는 스킬 자체를 저장소에서 가져와야 할 때 적합합니다. +- `Skills(from_=GitRepo(repo=..., ref=...))`은 스킬 자체를 저장소에서 가져와야 할 때 적합합니다. -`LocalDir.src`는 SDK 호스트의 소스 경로입니다. `skills_path`는 `load_skill`이 호출될 때 스킬이 스테이징되는 샌드박스 작업 공간 내부의 상대 대상 경로입니다. +`LocalDir.src`은 SDK 호스트의 소스 경로입니다. `skills_path`는 `load_skill` 호출 시 스킬이 스테이징되는 샌드박스 워크스페이스 내부의 상대 대상 경로입니다. -스킬이 이미 `.agents/skills//SKILL.md` 같은 디스크 경로에 있다면 `LocalDir(...)`이 해당 소스 루트를 가리키도록 하고, 스킬을 노출할 때는 계속 `Skills(...)`를 사용하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 작업 공간 계약이 없다면 기본 `skills_path=".agents"`를 유지하세요. +스킬이 이미 `.agents/skills//SKILL.md`과 같은 디스크 경로에 있다면 `LocalDir(...)`이 해당 소스 루트를 가리키도록 하고, 계속 `Skills(...)`을 사용해 스킬을 노출하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 워크스페이스 계약이 없다면 기본 `skills_path=".agents"`을 유지하세요. -적합한 기본 제공 기능이 있다면 이를 우선 사용하세요. 기본 제공 기능이 지원하지 않는 샌드박스별 도구 또는 지침 인터페이스가 필요할 때만 사용자 지정 기능을 작성하세요. +기본 제공 기능이 요구 사항에 맞는다면 우선 사용하세요. 기본 제공 기능에서 다루지 않는 샌드박스 전용 도구 또는 지침 인터페이스가 필요한 경우에만 사용자 지정 기능을 작성하세요. ## 개념 ### 매니페스트 -[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 작업 공간을 설명합니다. 작업 공간 `root`를 설정하고, 파일과 디렉터리를 선언하고, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자 또는 그룹을 정의하고, 작업 공간 외부의 특정 절대 경로에 대한 액세스를 허용할 수 있습니다. +[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root`을 설정하고, 파일 및 디렉터리를 선언하고, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자 또는 그룹을 정의하고, 워크스페이스 외부의 특정 절대 경로에 대한 액세스 권한을 부여할 수 있습니다. -매니페스트 항목 경로는 작업 공간 기준 상대 경로입니다. 절대 경로를 사용할 수 없으며 `..`을 사용해 작업 공간을 벗어날 수도 없습니다. 따라서 로컬, Docker 및 호스티드 클라이언트 간에 작업 공간 계약의 이식성을 유지할 수 있습니다. +매니페스트 항목 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..`을 통해 워크스페이스를 벗어날 수 없습니다. 이를 통해 로컬, Docker 및 호스티드 클라이언트 간에 워크스페이스 계약의 이식성을 유지할 수 있습니다. -작업을 시작하기 전에 에이전트에 필요한 자료에는 매니페스트 항목을 사용하세요. +작업 시작 전에 에이전트에 필요한 자료에는 매니페스트 항목을 사용하세요.
@@ -228,20 +228,20 @@ SDK 샌드박스 기본 프롬프트를 교체하려는 경우에만 `base_instr | --- | --- | | `File`, `Dir` | 소규모 합성 입력, 보조 파일 또는 출력 디렉터리 | | `LocalFile`, `LocalDir` | 샌드박스에 구체화해야 하는 호스트 파일 또는 디렉터리 | -| `GitRepo` | 작업 공간으로 가져와야 하는 저장소 | +| `GitRepo` | 워크스페이스로 가져와야 하는 저장소 | | `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 같은 마운트 | 샌드박스 내부에 표시해야 하는 외부 스토리지 |
-`Dir`은 합성 하위 항목으로 샌드박스 작업 공간 내부에 디렉터리를 생성하거나 출력 위치를 생성합니다. 호스트 파일 시스템에서 읽지는 않습니다. 기존 호스트 디렉터리를 샌드박스 작업 공간으로 복사해야 할 때는 `LocalDir`을 사용하세요. +`Dir`는 합성 하위 항목으로 샌드박스 워크스페이스 내부에 디렉터리를 만들거나 출력 위치를 생성합니다. 호스트 파일 시스템에서는 읽지 않습니다. 기존 호스트 디렉터리를 샌드박스 워크스페이스로 복사해야 할 때는 `LocalDir`을 사용하세요. -기본적으로 `LocalFile.src` 및 `LocalDir.src`는 SDK 프로세스의 작업 디렉터리를 기준으로 해석됩니다. 소스가 `extra_path_grants`에 포함되지 않는 한 해당 기본 디렉터리 아래에 있어야 합니다. 이를 통해 로컬 소스 구체화가 나머지 샌드박스 매니페스트와 동일한 호스트 경로 신뢰 경계 내부에서 이루어집니다. +기본적으로 `LocalFile.src` 및 `LocalDir.src`은 SDK 프로세스 작업 디렉터리를 기준으로 확인됩니다. 소스는 `extra_path_grants`의 적용을 받지 않는 한 해당 기본 디렉터리 아래에 있어야 합니다. 이를 통해 로컬 소스 구체화가 나머지 샌드박스 매니페스트와 동일한 호스트 경로 신뢰 경계 내에 유지됩니다. -마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방법을 설명합니다. 마운트 옵션과 제공자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참조하세요. +마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방법을 설명합니다. 마운트 옵션과 공급자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참고하세요. -좋은 매니페스트 설계는 일반적으로 작업 공간 계약을 좁게 유지하고, 긴 작업 절차를 `repo/task.md` 같은 작업 공간 파일에 배치하며, 지침에서 `repo/task.md` 또는 `output/report.md` 같은 작업 공간 상대 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집하는 경우 패치 경로는 셸 `workdir`이 아니라 샌드박스 작업 공간 루트를 기준으로 한다는 점에 유의하세요. +일반적으로 적절한 매니페스트 설계란 워크스페이스 계약의 범위를 좁게 유지하고, 긴 작업 절차는 `repo/task.md`과 같은 워크스페이스 파일에 배치하며, 지침에서는 `repo/task.md` 또는 `output/report.md`와 같은 상대 워크스페이스 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집하는 경우, 패치 경로는 셸의 `workdir`가 아니라 샌드박스 워크스페이스 루트를 기준으로 한다는 점에 유의하세요. -에이전트가 작업 공간 외부의 구체적인 절대 경로를 필요로 하거나 매니페스트가 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 하는 경우에만 `extra_path_grants`를 사용하세요. 예를 들어 임시 도구 출력용 `/tmp`, 읽기 전용 런타임용 `/opt/toolchain`, 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리가 있습니다. 백엔드에서 파일 시스템 정책을 적용할 수 있는 경우 권한 부여는 로컬 소스 구체화, SDK 파일 API 및 셸 실행에 적용됩니다. +에이전트가 워크스페이스 외부의 구체적인 절대 경로에 액세스해야 하거나 매니페스트에서 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 할 때만 `extra_path_grants`을 사용하세요. 예를 들면 임시 도구 출력용 `/tmp`, 읽기 전용 런타임용 `/opt/toolchain` 또는 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리가 있습니다. 권한 부여는 로컬 소스 구체화와 SDK 파일 API에 적용됩니다. 백엔드에서 파일 시스템 정책을 적용할 수 있는 경우 셸 실행에도 적용됩니다. ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +254,17 @@ manifest = Manifest( ) ``` -Docker가 컨테이너 내부의 절대 POSIX `path`에 다른 절대 호스트 경로를 바인드 마운트해야 하는 경우 `host_path`를 설정하세요. `UnixLocalSandboxClient`는 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path`를 거부합니다. 샌드박스가 수정해서는 안 되는 호스트 데이터에는 `read_only=True`를 사용하고, 복사본으로 충분하다면 `LocalFile` 또는 `LocalDir`을 사용하세요. +Docker가 컨테이너 내부의 절대 POSIX `path`에 다른 절대 호스트 경로를 바인드 마운트해야 할 때 `host_path`을 설정하세요. `UnixLocalSandboxClient`은 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path`는 거부합니다. 샌드박스에서 수정하면 안 되는 호스트 데이터에는 `read_only=True`을 사용하고, 복사만으로 충분하다면 `LocalFile` 또는 `LocalDir`를 사용하세요. -`extra_path_grants`가 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. +`extra_path_grants`이 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. -스냅샷과 `persist_workspace()`에는 여전히 작업 공간 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스이며, 영구적인 작업 공간 상태가 아닙니다. +스냅샷과 `persist_workspace()`에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스용이며 지속성 있는 워크스페이스 상태가 아닙니다. ### 권한 -`Permissions`는 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스가 구체화하는 파일에 관한 것으로, 모델 권한, 승인 정책 또는 API 자격 증명과는 관련이 없습니다. +`Permissions`은 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스에서 구체화하는 파일에 관한 것이며, 모델 권한, 승인 정책 또는 API 자격 증명에 관한 것이 아닙니다. -기본적으로 매니페스트 항목은 소유자가 읽기/쓰기/실행할 수 있고 그룹 및 기타 사용자가 읽기/실행할 수 있습니다. 스테이징된 파일이 비공개, 읽기 전용 또는 실행 가능해야 할 때 이 설정을 재정의하세요. +기본적으로 매니페스트 항목은 소유자가 읽고 쓰고 실행할 수 있으며, 그룹과 기타 사용자는 읽고 실행할 수 있습니다. 스테이징된 파일을 비공개, 읽기 전용 또는 실행 가능하게 만들어야 할 때 이를 재정의하세요. ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +280,9 @@ private_notes = File( ) ``` -`Permissions`는 소유자, 그룹 및 기타 사용자 비트를 각각 저장하며, 해당 항목이 디렉터리인지 여부도 저장합니다. 직접 생성하거나, `Permissions.from_str(...)`을 사용해 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)`를 사용해 OS 모드에서 파생할 수 있습니다. +`Permissions`은 소유자, 그룹 및 기타 사용자의 비트를 각각 저장하며, 항목이 디렉터리인지 여부도 저장합니다. 직접 구성하거나, `Permissions.from_str(...)`으로 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)`으로 OS 모드에서 파생할 수 있습니다. -사용자는 작업을 실행할 수 있는 샌드박스 ID입니다. 해당 ID가 샌드박스에 존재하도록 하려면 매니페스트에 `User`를 추가하고, 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구를 해당 사용자로 실행해야 한다면 `SandboxAgent.run_as`를 설정하세요. `run_as`가 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 실질적인 매니페스트에 자동으로 추가합니다. +사용자는 샌드박스에서 작업을 실행할 수 있는 ID입니다. 해당 ID가 샌드박스에 존재하도록 하려면 매니페스트에 `User`를 추가한 다음, 셸 명령, 파일 읽기 및 패치와 같이 모델에 노출되는 샌드박스 도구를 해당 사용자로 실행해야 할 때 `SandboxAgent.run_as`을 설정하세요. `run_as`이 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 실질적인 매니페스트에 자동으로 추가합니다. ```python from agents import Runner @@ -334,13 +334,13 @@ result = await Runner.run( ) ``` -파일 수준의 공유 규칙도 필요하다면 사용자와 매니페스트 그룹 및 항목의 `group` 메타데이터를 함께 사용하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하며, `Permissions`는 샌드박스가 작업 공간을 구체화한 후 해당 사용자가 어떤 파일을 읽고, 쓰고, 실행할 수 있는지 제어합니다. +파일 수준 공유 규칙도 필요하다면 사용자를 매니페스트 그룹 및 항목 `group` 메타데이터와 결합하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하고, `Permissions`은 샌드박스에서 워크스페이스를 구체화한 후 해당 사용자가 읽고 쓰고 실행할 수 있는 파일을 제어합니다. ### SnapshotSpec -`SnapshotSpec`은 새 샌드박스 세션에 저장된 작업 공간 콘텐츠를 복원할 위치와 다시 영구 저장할 위치를 지정합니다. 이는 샌드박스 작업 공간의 스냅샷 정책이며, `session_state`는 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. +`SnapshotSpec`은 새 샌드박스 세션에서 저장된 워크스페이스 콘텐츠를 복원할 위치와 다시 저장할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state`은 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. -로컬 영구 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 애플리케이션에서 원격 스냅샷 클라이언트를 제공하는 경우 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷을 설정할 수 없을 때는 무작업 스냅샷을 대체 수단으로 사용하며, 작업 공간 스냅샷을 영구 저장하지 않으려는 고급 호출자는 이를 명시적으로 사용할 수도 있습니다. +로컬 지속성 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 앱에서 원격 스냅샷 클라이언트를 제공하는 경우 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷을 설정할 수 없으면 아무 작업도 하지 않는 스냅샷이 대체 수단으로 사용되며, 워크스페이스 스냅샷의 지속성이 필요하지 않은 고급 호출자는 이를 명시적으로 사용할 수 있습니다. ```python from pathlib import Path @@ -357,13 +357,13 @@ run_config = RunConfig( ) ``` -러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작 시 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 샌드박스가 저장된 작업 공간 콘텐츠를 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 작업 공간을 보관하고 스냅샷을 통해 다시 영구 저장합니다. +러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트는 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작할 때 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 저장된 워크스페이스 콘텐츠를 복원합니다. 정리할 때 러너가 소유한 샌드박스 세션은 워크스페이스를 보관하고 스냅샷을 통해 다시 저장합니다. -`snapshot`을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 시도합니다. 이를 설정할 수 없으면 무작업 스냅샷으로 대체합니다. 마운트된 경로와 임시 경로는 영구 작업 공간 콘텐츠로 스냅샷에 복사되지 않습니다. +`snapshot`를 생략하면 런타임은 가능할 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 이를 설정할 수 없으면 아무 작업도 하지 않는 스냅샷으로 대체됩니다. 마운트된 경로와 임시 경로는 지속성 있는 워크스페이스 콘텐츠로 스냅샷에 복사되지 않습니다. ### 샌드박스 수명 주기 -수명 주기 모드는 **SDK 소유**와 **개발자 소유** 두 가지입니다. +수명 주기에는 **SDK 소유**와 **개발자 소유**라는 두 가지 모드가 있습니다.
@@ -391,7 +391,7 @@ sequenceDiagram
-샌드박스가 한 번의 실행 동안만 유지되면 되는 경우 SDK 소유 수명 주기를 사용하세요. `client`, 선택적 `manifest`, 선택적 `snapshot` 및 클라이언트 `options`를 전달하면 러너가 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 작업 공간 상태를 영구 저장하고, 샌드박스를 종료한 후 클라이언트가 러너 소유 리소스를 정리하도록 합니다. +샌드박스를 한 번의 실행 동안만 유지하면 되는 경우 SDK 소유 수명 주기를 사용하세요. `client`, 선택적으로 `manifest` 및 `snapshot`, 그리고 필요한 클라이언트 `options`을 전달합니다. 러너는 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 저장하고, 샌드박스 세션을 종료하고, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. ```python result = await Runner.run( @@ -403,7 +403,7 @@ result = await Runner.run( ) ``` -샌드박스를 미리 생성하거나, 여러 실행에서 하나의 활성 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너가 해당 활성 샌드박스를 사용하지만 대신 닫지는 않습니다. +샌드박스를 미리 생성하거나, 여러 실행에서 하나의 활성 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려는 경우 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너는 해당 활성 샌드박스를 사용하지만 대신 닫지는 않습니다. ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -일반적으로는 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 애플리케이션에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. +일반적으로 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 앱에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. ```python sandbox = await client.create( @@ -435,32 +435,32 @@ finally: await sandbox.aclose() ``` -`stop()`은 스냅샷 기반 작업 공간 콘텐츠만 영구 저장하며 샌드박스를 해제하지 않습니다. `aclose()`는 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. +`stop()`은 스냅샷 기반 워크스페이스 콘텐츠만 저장하며 샌드박스를 종료하지 않습니다. `aclose()`은 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. ## `SandboxRunConfig` 옵션 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 샌드박스 세션의 출처와 새 세션 초기화 방법을 결정하는 실행별 옵션을 보유합니다. +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 샌드박스 세션의 출처와 새 세션의 초기화 방식을 결정하는 실행별 옵션을 포함합니다. ### 샌드박스 소스 -다음 옵션은 러너가 샌드박스 세션을 재사용, 재개 또는 생성해야 하는지 결정합니다. +다음 옵션은 러너가 샌드박스 세션을 재사용, 재개 또는 생성할지 결정합니다.
| 옵션 | 사용 시점 | 참고 사항 | | --- | --- | --- | -| `client` | 러너가 샌드박스 세션을 생성, 재개 및 정리하도록 하려는 경우 | 활성 샌드박스 `session`을 제공하지 않는 한 필수입니다. | -| `session` | 활성 샌드박스 세션을 이미 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 활성 샌드박스 세션을 재사용합니다. | -| `session_state` | 직렬화된 샌드박스 세션 상태는 있지만 활성 샌드박스 세션 객체는 없는 경우 | `client`가 필요하며, 러너는 명시된 상태에서 소유 세션으로 재개합니다. | +| `client` | 러너가 샌드박스 세션을 생성, 재개 및 정리하도록 하려는 경우 | 활성 샌드박스 `session`를 제공하지 않는 한 필수입니다. | +| `session` | 이미 활성 샌드박스 세션을 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 활성 샌드박스 세션을 재사용합니다. | +| `session_state` | 직렬화된 샌드박스 세션 상태는 있지만 활성 샌드박스 세션 객체는 없는 경우 | `client`이 필요합니다. 러너는 해당 명시적 상태에서 재개하고 재개된 세션의 수명 주기를 소유합니다. |
실제로 러너는 다음 순서로 샌드박스 세션을 확인합니다. 1. `run_config.sandbox.session`을 주입하면 해당 활성 샌드박스 세션을 직접 재사용합니다. -2. 그렇지 않고 실행이 `RunState`에서 재개되는 경우 저장된 샌드박스 세션 상태를 재개합니다. -3. 그렇지 않고 `run_config.sandbox.session_state`를 전달하면 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. -4. 그렇지 않으면 러너가 새 샌드박스 세션을 생성합니다. 새 세션에는 제공된 경우 `run_config.sandbox.manifest`를 사용하고, 제공되지 않으면 `agent.default_manifest`를 사용합니다. +2. 그렇지 않고 실행이 `RunState`에서 재개되는 경우, 저장된 샌드박스 세션 상태를 재개합니다. +3. 그렇지 않고 `run_config.sandbox.session_state`을 전달한 경우, 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. +4. 그렇지 않으면 러너가 새 샌드박스 세션을 생성합니다. 해당 새 세션에는 `run_config.sandbox.manifest`이 제공되면 이를 사용하고, 그렇지 않으면 `agent.default_manifest`를 사용합니다. ### 새 세션 입력 @@ -470,29 +470,29 @@ finally: | 옵션 | 사용 시점 | 참고 사항 | | --- | --- | --- | -| `manifest` | 새 세션 작업 공간을 일회성으로 재정의하려는 경우 | 생략하면 `agent.default_manifest`로 대체됩니다. | -| `snapshot` | 스냅샷을 기반으로 새 샌드박스 세션을 시작해야 하는 경우 | 재개와 유사한 흐름 또는 원격 스냅샷 클라이언트에 유용합니다. | -| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 유사한 클라이언트별 설정에서 흔히 사용됩니다. | +| `manifest` | 일회성 새 세션 워크스페이스 재정의가 필요한 경우 | 생략하면 `agent.default_manifest`로 대체됩니다. | +| `snapshot` | 새 샌드박스 세션을 스냅샷에서 초기화해야 하는 경우 | 재개와 유사한 흐름이나 원격 스냅샷 클라이언트에 유용합니다. | +| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 유사한 클라이언트별 설정에서 일반적으로 사용됩니다. | ### 구체화 제어 -`concurrency_limits`는 병렬로 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요한 경우 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`를 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None`으로 설정하세요. +`concurrency_limits`은 병렬로 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요할 때 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`을 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None`로 설정하세요. -`archive_limits`는 아카이브 추출에 대한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임계값을 활성화하려면 `archive_limits=SandboxArchiveLimits()`를 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요하면 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한이 없는 기본 동작을 유지하려면 `archive_limits=None`으로 두고, 개별 제한만 비활성화하려면 해당 필드를 `None`으로 설정하세요. +`archive_limits`은 아카이브 추출을 위한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임계값을 활성화하려면 `archive_limits=SandboxArchiveLimits()`로 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요한 경우 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)`와 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한이 없는 기본 동작을 유지하려면 `archive_limits=None`으로 두고, 해당 제한만 비활성화하려면 개별 필드를 `None`로 설정하세요. -다음과 같은 몇 가지 사항에 유의해야 합니다. +유의해야 할 몇 가지 사항은 다음과 같습니다. - 새 세션: `manifest=` 및 `snapshot=`은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. -- 재개와 스냅샷의 차이: `session_state=`는 이전에 직렬화된 샌드박스 상태에 다시 연결하는 반면, `snapshot=`은 저장된 작업 공간 콘텐츠를 기반으로 새 샌드박스 세션을 시작합니다. -- 클라이언트별 옵션: `options=`는 샌드박스 클라이언트에 따라 달라집니다. Docker 및 많은 호스티드 클라이언트에서 필수입니다. -- 주입된 활성 세션: 실행 중인 샌드박스 `session`을 전달하면 기능 기반 매니페스트 업데이트를 통해 호환되는 비마운트 항목을 추가할 수 있습니다. 하지만 `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups`를 변경하거나, 기존 항목을 제거하거나, 항목 유형을 교체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. -- 러너 API: `SandboxAgent` 실행은 계속 일반적인 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API를 사용합니다. +- 재개와 스냅샷의 차이: `session_state=`은 이전에 직렬화된 샌드박스 상태에 다시 연결하지만, `snapshot=`은 저장된 워크스페이스 콘텐츠로 새 샌드박스 세션을 초기화합니다. +- 클라이언트별 옵션: `options=`은 샌드박스 클라이언트에 따라 달라지며, Docker와 다수의 호스티드 클라이언트에서 필요합니다. +- 주입된 활성 세션: 실행 중인 샌드박스 `session`을 전달하면 기능 기반 매니페스트 업데이트에서 호환되는 비마운트 항목을 추가할 수 있습니다. `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups`을 변경하거나, 기존 항목을 제거하거나, 항목 유형을 대체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. +- 러너 API: `SandboxAgent` 실행에는 여전히 일반적인 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API가 사용됩니다. ## 전체 예제: 코딩 작업 -다음 코딩 스타일 예제는 기본 시작점으로 적합합니다. +다음 코딩 스타일 예제는 적절한 기본 시작점입니다. ```python import asyncio @@ -571,19 +571,19 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참조하세요. 이 예제는 Unix 로컬 실행에서 결정론적으로 검증할 수 있도록 간단한 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 어떤 언어로도 구성할 수 있습니다. +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제에서는 Unix 로컬 실행 전반에서 결정론적으로 검증할 수 있도록 작은 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 무엇이든 사용할 수 있습니다. -## 일반 패턴 +## 일반적인 패턴 -위의 전체 예제에서 시작하세요. 많은 경우 샌드박스 클라이언트, 샌드박스 세션 소스 또는 작업 공간 소스만 변경하면서 동일한 `SandboxAgent`를 그대로 유지할 수 있습니다. +위의 전체 예제에서 시작하세요. 많은 경우 동일한 `SandboxAgent`을 그대로 유지하면서 샌드박스 클라이언트, 샌드박스 세션 소스 또는 워크스페이스 소스만 변경할 수 있습니다. ### 샌드박스 클라이언트 전환 -에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리 또는 이미지 일관성이 필요하면 Docker를 사용하고, 제공자가 관리하는 실행을 원하면 호스티드 제공자를 사용하세요. 예제와 제공자 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. +에이전트 정의는 동일하게 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 동등성이 필요하면 Docker를 사용하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자를 사용하세요. 예제와 공급자 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. -### 작업 공간 재정의 +### 워크스페이스 재정의 -에이전트 정의는 그대로 유지하고 새 세션 매니페스트만 교체하세요. +에이전트 정의는 동일하게 유지하고 새 세션 매니페스트만 교체하세요. ```python from agents.run import RunConfig @@ -603,11 +603,11 @@ run_config = RunConfig( ) ``` -에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 서로 다른 저장소, 문서 묶음 또는 작업 번들에 실행하려면 이 방식을 사용하세요. 위의 검증된 코딩 예제에서는 일회성 재정의 대신 `default_manifest`를 사용해 동일한 패턴을 보여 줍니다. +에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 여러 저장소, 자료 또는 작업 번들에서 실행해야 할 때 사용하세요. 위의 검증된 코딩 예제에서는 일회성 재정의 대신 `default_manifest`을 사용해 동일한 패턴을 보여 줍니다. ### 샌드박스 세션 주입 -수명 주기를 명시적으로 제어하거나, 실행 후 검사하거나, 출력을 복사해야 한다면 활성 샌드박스 세션을 주입하세요. +명시적인 수명 주기 제어, 실행 후 검사 또는 출력 복사가 필요한 경우 활성 샌드박스 세션을 주입하세요. ```python from agents import Runner @@ -628,7 +628,7 @@ async with sandbox: ) ``` -실행 후 작업 공간을 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려면 이 방식을 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 및 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. +실행 후 워크스페이스를 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려는 경우 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 및 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참고하세요. ### 세션 상태에서 재개 @@ -649,13 +649,13 @@ run_config = RunConfig( ) ``` -샌드박스 상태가 자체 스토리지나 작업 시스템에 있고 `Runner`가 해당 상태에서 직접 재개하도록 하려면 이 방식을 사용하세요. 직렬화/역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참조하세요. +샌드박스 상태가 자체 스토리지나 작업 시스템에 있고 `Runner`에서 직접 재개하도록 하려는 경우 사용하세요. 직렬화 및 역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참고하세요. -세션 상태 직렬화에서는 네이티브 `host_path` 값이 생략됩니다. 호스트 기반 권한 부여를 재개하려면 현재 신뢰할 수 있는 매니페스트를 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`를 통해 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 입력 또는 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생해서는 안 됩니다. +세션 상태 직렬화에서는 네이티브 `host_path` 값이 생략됩니다. 호스트 기반 권한 부여를 재개하려면 현재 신뢰할 수 있는 매니페스트를 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`를 통해 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개에 실패합니다. 직렬화된 입력이나 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생하지 마세요. ### 스냅샷에서 시작 -저장된 파일과 아티팩트를 기반으로 새 샌드박스를 시작하세요. +저장된 파일과 결과물로 새 샌드박스를 초기화하세요. ```python from pathlib import Path @@ -672,7 +672,7 @@ run_config = RunConfig( ) ``` -새 실행이 `agent.default_manifest`만이 아니라 저장된 작업 공간 콘텐츠에서 시작해야 할 때 이 방식을 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참조하세요. +새 샌드박스 세션을 생성하는 실행에서 `agent.default_manifest`만 사용하는 대신 저장된 워크스페이스 콘텐츠로 시작해야 할 때 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참고하세요. ### Git에서 스킬 로드 @@ -687,11 +687,11 @@ capabilities = Capabilities.default() + [ ] ``` -스킬 번들에 자체 릴리스 주기가 있거나 여러 샌드박스에서 공유해야 할 때 이 방식을 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참조하세요. +스킬 번들에 자체 릴리스 주기가 있거나 여러 샌드박스에서 공유해야 할 때 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참고하세요. ### 도구로 노출 -도구 에이전트에는 자체 샌드박스 경계를 제공하거나 상위 실행의 활성 샌드박스를 재사용할 수 있습니다. 빠른 읽기 전용 탐색 에이전트에는 재사용이 유용합니다. 다른 샌드박스를 생성하고, 구성하고, 스냅샷으로 저장하는 비용 없이 상위 에이전트가 사용하는 정확한 작업 공간을 검사할 수 있습니다. +도구 에이전트에는 자체 샌드박스 경계를 부여하거나 상위 실행의 활성 샌드박스를 재사용하도록 할 수 있습니다. 재사용은 빠른 읽기 전용 탐색기 에이전트에 유용합니다. 다른 샌드박스를 생성, 초기화 또는 스냅샷으로 저장하는 비용 없이 상위 실행에서 사용하는 정확한 워크스페이스를 검사할 수 있습니다. ```python from agents import Runner @@ -773,7 +773,7 @@ async with sandbox: ) ``` -여기서 상위 에이전트는 `coordinator`로 실행되고, 탐색 도구 에이전트는 동일한 활성 샌드박스 세션 내부에서 `explorer`로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색 에이전트가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자/그룹만 사용할 수 있으므로, 탐색 에이전트가 읽기 전용으로 유지되는 동안 상위 에이전트가 최종 아티팩트를 작성할 수 있습니다. +여기서 상위 에이전트는 동일한 활성 샌드박스 세션 내에서 `coordinator`로 실행되고, 탐색기 도구 에이전트는 `explorer`로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색기가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자/그룹만 사용할 수 있으므로, 탐색기는 읽기 전용으로 유지되는 동안 상위 에이전트가 최종 결과물을 작성할 수 있습니다. 도구 에이전트에 실제 격리가 필요하다면 자체 샌드박스 `RunConfig`를 제공하세요. @@ -801,11 +801,11 @@ rollout_agent.as_tool( ) ``` -도구 에이전트가 자유롭게 변경 작업을 수행하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드/이미지를 사용해야 한다면 별도의 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참조하세요. +도구 에이전트가 자유롭게 변경하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드/이미지를 사용해야 할 때 별도의 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. ### 로컬 도구 및 MCP와의 결합 -샌드박스 작업 공간을 유지하면서 동일한 에이전트에서 일반 도구도 사용하세요. +샌드박스 워크스페이스를 유지하면서 동일한 에이전트에서 일반 도구도 계속 사용하세요. ```python from agents.sandbox import SandboxAgent @@ -820,46 +820,46 @@ agent = SandboxAgent( ) ``` -작업 공간 검사가 에이전트 작업의 일부에 불과할 때 이 방식을 사용하세요. [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)를 참조하세요. +워크스페이스 검사가 에이전트 작업의 일부일 뿐인 경우 사용하세요. [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)를 참고하세요. ## 메모리 -향후 샌드박스 에이전트 실행이 이전 실행에서 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 메모리는 학습한 내용을 샌드박스 작업 공간 내부의 파일로 정제하며, 이후 실행에서 해당 파일을 읽을 수 있습니다. +향후 샌드박스 에이전트 실행에서 이전 실행의 내용을 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제한 다음 이후 실행에서 해당 파일을 읽을 수 있습니다. -설정, 읽기/생성 동작, 멀티턴 대화 및 레이아웃 격리는 [에이전트 메모리](memory.md)를 참조하세요. +설정, 읽기/생성 동작, 다중 턴 대화 및 레이아웃 격리에 대해서는 [에이전트 메모리](memory.md)를 참고하세요. ## 구성 패턴 -단일 에이전트 패턴을 이해한 후에는 더 큰 시스템에서 샌드박스 경계를 어디에 둘 것인지 결정해야 합니다. +단일 에이전트 패턴을 이해했다면 다음 설계 질문은 더 큰 시스템에서 샌드박스 경계를 어디에 배치할 것인지입니다. -샌드박스 에이전트는 계속 SDK의 나머지 요소와 결합할 수 있습니다. +샌드박스 에이전트는 여전히 SDK의 나머지 요소와 함께 구성할 수 있습니다. - [핸드오프](../handoffs.md): 샌드박스를 사용하지 않는 접수 에이전트에서 문서 중심 작업을 샌드박스 검토자에게 핸드오프합니다. -- [Agents as tools](../tools.md#agents-as-tools): 여러 샌드박스 에이전트를 도구로 노출합니다. 일반적으로 각 `Agent.as_tool(...)` 호출에 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`를 전달하여 각 도구에 자체 샌드박스 경계를 제공합니다. +- [Agents as tools](../tools.md#agents-as-tools): 여러 샌드박스 에이전트를 도구로 노출합니다. 일반적으로 각 도구가 자체 샌드박스 경계를 갖도록 각 `Agent.as_tool(...)` 호출에 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`를 전달합니다. - [MCP](../mcp.md) 및 일반 함수 도구: 샌드박스 기능은 `mcp_servers` 및 일반 Python 도구와 함께 사용할 수 있습니다. -- [에이전트 실행](../running_agents.md): 샌드박스 실행도 일반적인 `Runner` API를 사용합니다. +- [에이전트 실행](../running_agents.md): 샌드박스 실행에서도 일반적인 `Runner` API를 사용합니다. -특히 다음 두 가지 패턴이 일반적입니다. +특히 일반적인 두 가지 패턴은 다음과 같습니다. -- 작업 공간 격리가 필요한 워크플로 부분에만 샌드박스를 사용하지 않는 에이전트가 샌드박스 에이전트로 핸드오프하는 패턴 -- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하고, 일반적으로 각 `Agent.as_tool(...)` 호출에 별도의 샌드박스 `RunConfig`를 사용하여 각 도구에 자체 격리 작업 공간을 제공하는 패턴 +- 워크스페이스 격리가 필요한 워크플로 부분에서만 샌드박스를 사용하지 않는 에이전트가 샌드박스 에이전트로 핸드오프 +- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하며, 일반적으로 각 도구가 자체적으로 격리된 워크스페이스를 갖도록 각 `Agent.as_tool(...)` 호출마다 별도의 샌드박스 `RunConfig` 사용 ### 턴과 샌드박스 실행 -핸드오프와 `Agent.as_tool(...)` 호출을 구분해 설명하면 이해하기 쉽습니다. +핸드오프와 에이전트 도구 호출을 별도로 설명하면 이해하기 쉽습니다. -핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프가 있습니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면 동일한 실행의 다음 모델 호출이 샌드박스 에이전트용으로 준비되며, 해당 샌드박스 에이전트가 다음 턴을 수행합니다. 즉, 핸드오프는 동일한 실행의 다음 턴을 담당할 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참조하세요. +핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프만 존재합니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면 동일한 실행의 다음 모델 호출이 샌드박스 에이전트에 맞게 준비되고, 해당 샌드박스 에이전트가 다음 턴을 수행합니다. 즉, 핸드오프는 동일한 실행에서 다음 턴을 담당할 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참고하세요. -`Agent.as_tool(...)`에서는 관계가 다릅니다. 외부 오케스트레이터는 하나의 외부 턴을 사용해 도구 호출을 결정하고, 해당 도구 호출은 샌드박스 에이전트의 중첩 실행을 시작합니다. 중첩 실행에는 자체 턴 루프, `max_turns`, 승인 및 일반적으로 자체 샌드박스 `RunConfig`가 있습니다. 하나의 중첩 턴에서 완료될 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터의 관점에서는 이 모든 작업이 여전히 한 번의 도구 호출 뒤에서 수행되므로 중첩된 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참조하세요. +`Agent.as_tool(...)`에서는 관계가 다릅니다. 외부 오케스트레이터는 하나의 외부 턴을 사용하여 도구 호출을 결정하고, 해당 도구 호출은 샌드박스 에이전트에 대한 중첩 실행을 시작합니다. 중첩 실행은 자체 턴 루프, `max_turns`, 승인 및 일반적으로 자체 샌드박스 `RunConfig`을 갖습니다. 중첩된 한 번의 턴에서 완료될 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터 관점에서는 이 모든 작업이 여전히 하나의 도구 호출 뒤에서 이루어지므로, 중첩된 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. 승인 동작도 동일한 구분을 따릅니다. -- 핸드오프에서는 샌드박스 에이전트가 해당 실행의 활성 에이전트가 되므로 승인이 동일한 최상위 실행에 유지됩니다. -- `Agent.as_tool(...)`에서는 샌드박스 도구 에이전트 내부에서 발생한 승인도 외부 실행에 표시되지만, 저장된 중첩 실행 상태에서 발생하며 외부 실행이 재개될 때 중첩된 샌드박스 실행을 재개합니다. +- 핸드오프의 경우 샌드박스 에이전트가 해당 실행의 활성 에이전트가 되므로 승인은 동일한 최상위 실행에 유지됩니다. +- `Agent.as_tool(...)`의 경우 샌드박스 도구 에이전트 내부에서 발생한 승인도 외부 실행에 표시되지만, 저장된 중첩 실행 상태에서 발생하며 외부 실행이 재개될 때 중첩된 샌드박스 실행을 재개합니다. ## 추가 자료 -- [빠른 시작](../sandbox_agents.md): 샌드박스 에이전트 하나를 실행합니다. +- [빠른 시작](../sandbox_agents.md): 하나의 샌드박스 에이전트를 실행합니다. - [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드 및 마운트 옵션을 선택합니다. -- [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 학습한 내용을 보존하고 재사용합니다. +- [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 얻은 내용을 보존하고 재사용합니다. - [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 실행 가능한 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴입니다. \ No newline at end of file diff --git a/docs/ko/sandbox/memory.md b/docs/ko/sandbox/memory.md index 1f2fb44256..eeeedbff3f 100644 --- a/docs/ko/sandbox/memory.md +++ b/docs/ko/sandbox/memory.md @@ -4,23 +4,23 @@ search: --- # 에이전트 메모리 -메모리는 향후 sandbox-agent 실행이 이전 실행에서 학습할 수 있게 합니다. 이는 메시지 기록을 저장하는 SDK의 대화형 [`Session`](../sessions/index.md) 메모리와는 별개입니다. 메모리는 이전 실행에서 얻은 교훈을 샌드박스 워크스페이스의 파일로 정제합니다. +메모리를 사용하면 향후 샌드박스 에이전트 실행이 이전 실행에서 학습할 수 있습니다. 메모리는 메시지 기록을 저장하는 SDK의 대화형 [`Session`](../sessions/index.md) 메모리와는 별개입니다. 메모리는 이전 실행에서 얻은 교훈을 샌드박스 워크스페이스의 파일로 정제합니다. !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. API, 기본값, 지원 기능의 세부 사항은 정식 출시 전에 변경될 수 있으며, 시간이 지나면서 더 고급 기능이 추가될 예정입니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 사항, 기본값 및 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 예정입니다. -메모리는 향후 실행에서 세 가지 비용을 줄일 수 있습니다. +메모리는 향후 실행에서 다음 세 가지 비용을 줄일 수 있습니다. -1. 에이전트 비용: 에이전트가 워크플로를 완료하는 데 오랜 시간이 걸렸다면, 다음 실행에서는 탐색이 덜 필요해야 합니다. 이를 통해 토큰 사용량과 완료까지 걸리는 시간을 줄일 수 있습니다. -2. 사용자 비용: 사용자가 에이전트를 수정했거나 선호 사항을 표현했다면, 향후 실행에서 해당 피드백을 기억할 수 있습니다. 이를 통해 사람의 개입을 줄일 수 있습니다. -3. 컨텍스트 비용: 에이전트가 이전에 작업을 완료했고 사용자가 그 작업을 이어서 진행하려는 경우, 사용자가 이전 스레드를 찾거나 모든 컨텍스트를 다시 입력할 필요가 없어야 합니다. 이를 통해 작업 설명을 더 짧게 만들 수 있습니다. +1. 에이전트 비용: 에이전트가 워크플로를 완료하는 데 오랜 시간이 걸렸다면 다음 실행에서는 탐색이 덜 필요합니다. 이를 통해 토큰 사용량과 완료까지 걸리는 시간을 줄일 수 있습니다. +2. 사용자 비용: 사용자가 에이전트를 수정하거나 선호 사항을 표현했다면 향후 실행에서 해당 피드백을 기억할 수 있습니다. 이를 통해 사람의 개입을 줄일 수 있습니다. +3. 컨텍스트 비용: 에이전트가 이전에 작업을 완료했고 사용자가 해당 작업을 이어서 진행하려는 경우, 이전 스레드를 찾거나 모든 컨텍스트를 다시 입력할 필요가 없습니다. 이를 통해 작업 설명이 더 짧아집니다. -버그를 수정하고, 메모리를 생성하고, 스냅샷을 재개한 뒤, 후속 검증 실행에서 해당 메모리를 사용하는 완전한 2회 실행 예제는 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를 참고하세요. 별도의 메모리 레이아웃을 사용하는 멀티턴, 멀티 에이전트 예제는 [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py)를 참고하세요. +버그를 수정하고, 메모리를 생성하고, 스냅샷을 재개하고, 후속 검증 실행에서 해당 메모리를 사용하는 완전한 2회 실행 예제는 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를 참고하세요. 메모리 레이아웃을 분리한 멀티턴 및 멀티 에이전트 예제는 [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py)를 참고하세요. ## 메모리 활성화 -샌드박스 에이전트에 기능으로 `Memory()`를 추가합니다. +샌드박스 에이전트에 `Memory()`을 기능으로 추가합니다. ```python from pathlib import Path @@ -42,28 +42,28 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d ) ``` -읽기가 활성화된 경우 `Memory()`에는 `Shell()`이 필요합니다. 이는 주입된 요약만으로 충분하지 않을 때 에이전트가 메모리 파일을 읽고 검색할 수 있게 합니다. 라이브 메모리 업데이트가 활성화된 경우(기본값), `Filesystem()`도 필요합니다. 이는 에이전트가 오래된 메모리를 발견하거나 사용자가 메모리 업데이트를 요청할 때 `memories/MEMORY.md`를 업데이트할 수 있게 합니다. +읽기가 활성화된 경우 `Memory()`에는 `Shell()`이 필요합니다. 이를 통해 주입된 요약만으로 충분하지 않을 때 에이전트가 메모리 파일을 읽고 검색할 수 있습니다. 실시간 메모리 업데이트가 활성화된 경우(기본값)에는 `Filesystem()`도 필요합니다. 이를 통해 에이전트가 오래된 메모리를 발견하거나 사용자가 메모리 업데이트를 요청할 경우 `memories/MEMORY.md`을 업데이트할 수 있습니다. -기본적으로 메모리 아티팩트는 샌드박스 워크스페이스의 `memories/` 아래에 저장됩니다. 나중 실행에서 이를 재사용하려면 동일한 라이브 샌드박스 세션을 유지하거나, 영구 저장된 세션 상태 또는 스냅샷에서 재개하여 구성된 memories 디렉터리 전체를 보존하고 재사용하세요. 새 빈 샌드박스는 빈 메모리로 시작합니다. +기본적으로 메모리 아티팩트는 샌드박스 워크스페이스의 `memories/` 아래에 저장됩니다. 이후 실행에서 이를 재사용하려면 동일한 실시간 샌드박스 세션을 유지하거나, 저장된 세션 상태 또는 스냅샷에서 재개하여 구성된 전체 메모리 디렉터리를 보존하고 재사용하세요. 새로 생성한 빈 샌드박스는 빈 메모리로 시작합니다. -`Memory()`는 메모리 읽기와 생성을 모두 활성화합니다. 메모리를 읽어야 하지만 새 메모리를 생성해서는 안 되는 에이전트에는 `Memory(generate=None)`을 사용하세요. 예를 들어 내부 에이전트, 서브에이전트, 검사기, 또는 실행이 많은 신호를 추가하지 않는 일회성 도구 에이전트가 이에 해당합니다. 실행이 나중을 위한 메모리는 생성해야 하지만, 기존 메모리의 영향을 받는 것을 사용자가 원하지 않는 경우에는 `Memory(read=None)`을 사용하세요. +`Memory()`은 메모리 읽기와 생성을 모두 활성화합니다. 내부 에이전트, 하위 에이전트, 검사기 또는 일회성 도구 에이전트의 실행처럼 새로운 신호를 크게 추가하지 않는 실행에서 메모리를 읽되 새 메모리는 생성하지 않아야 하는 에이전트에는 `Memory(generate=None)`을 사용하세요. 이후 사용할 메모리는 생성해야 하지만 사용자가 기존 메모리의 영향을 받지 않기를 원하는 실행에는 `Memory(read=None)`을 사용하세요. ## 메모리 읽기 -메모리 읽기는 점진적 공개 방식을 사용합니다. 실행 시작 시 SDK는 일반적으로 유용한 팁, 사용자 선호 사항, 사용 가능한 메모리에 대한 작은 요약(`memory_summary.md`)을 에이전트의 개발자 프롬프트에 주입합니다. 이를 통해 에이전트는 이전 작업이 관련될 수 있는지 판단하기에 충분한 컨텍스트를 얻습니다. +메모리 읽기에는 점진적 공개 방식이 사용됩니다. 실행이 시작될 때 SDK는 일반적으로 유용한 팁, 사용자 선호 사항 및 사용 가능한 메모리의 간단한 요약(`memory_summary.md`)을 에이전트의 개발자 프롬프트에 주입합니다. 이를 통해 에이전트는 이전 작업이 관련될 수 있는지 판단하기에 충분한 컨텍스트를 얻습니다. -이전 작업이 관련 있어 보이면, 에이전트는 현재 작업의 키워드로 구성된 메모리 인덱스(`memories_dir` 아래의 `MEMORY.md`)를 검색합니다. 작업에 더 자세한 정보가 필요할 때만 구성된 `rollout_summaries/` 디렉터리 아래의 해당 이전 롤아웃 요약을 엽니다. +이전 작업이 관련 있어 보이면 에이전트는 현재 작업의 키워드를 사용하여 구성된 메모리 인덱스(`memories_dir` 아래의 `MEMORY.md`)를 검색합니다. 작업에 더 자세한 정보가 필요할 때만 구성된 `rollout_summaries/` 디렉터리 아래의 해당 이전 롤아웃 요약을 엽니다. -메모리는 오래될 수 있습니다. 에이전트는 메모리를 지침으로만 취급하고 현재 환경을 신뢰하도록 지시받습니다. 기본적으로 메모리 읽기에는 `live_update`가 활성화되어 있으므로, 에이전트가 오래된 메모리를 발견하면 동일한 실행에서 구성된 `MEMORY.md`를 업데이트할 수 있습니다. 에이전트가 메모리를 읽어야 하지만 실행 중에 수정해서는 안 되는 경우, 예를 들어 실행이 지연 시간에 민감한 경우에는 라이브 업데이트를 비활성화하세요. +메모리는 오래되어 현재 상태와 맞지 않을 수 있습니다. 에이전트는 메모리를 지침으로만 활용하고 현재 환경을 신뢰하도록 지시받습니다. 기본적으로 메모리 읽기에는 `live_update`이 활성화되어 있으므로, 에이전트가 오래된 메모리를 발견하면 같은 실행에서 구성된 `MEMORY.md`을 업데이트할 수 있습니다. 에이전트가 메모리를 읽되 실행 중에는 수정하지 않아야 하는 경우(예: 지연 시간에 민감한 실행) 실시간 업데이트를 비활성화하세요. ## 메모리 생성 -실행이 끝나면 샌드박스 런타임은 해당 실행 세그먼트를 대화 파일에 추가합니다. 누적된 대화 파일은 샌드박스 세션이 닫힐 때 처리됩니다. +실행이 끝나면 샌드박스 런타임이 해당 실행 구간을 대화 파일에 추가합니다. 누적된 대화 파일은 샌드박스 세션이 종료될 때 처리됩니다. -메모리 생성에는 두 단계가 있습니다. +메모리 생성은 다음 두 단계로 이루어집니다. -1. 1단계: 대화 추출. 메모리 생성 모델이 누적된 대화 파일 하나를 처리하고 대화 요약을 생성합니다. 시스템, 개발자, 추론 내용은 생략됩니다. 대화가 너무 길면 컨텍스트 창에 맞도록 잘리며, 시작과 끝은 보존됩니다. 또한 원문 메모리 추출도 생성합니다. 이는 2단계에서 통합할 수 있는 대화의 간결한 노트입니다. -2. 2단계: 레이아웃 통합. 통합 에이전트가 하나의 메모리 레이아웃에 대한 원문 메모리를 읽고, 더 많은 근거가 필요할 때 대화 요약을 연 다음, 패턴을 `MEMORY.md`와 `memory_summary.md`로 추출합니다. +1. 1단계: 대화 추출. 메모리 생성 모델이 누적된 대화 파일 하나를 처리하고 대화 요약을 생성합니다. 시스템, 개발자 및 추론 콘텐츠는 제외됩니다. 대화가 너무 길면 시작과 끝을 보존하면서 컨텍스트 윈도우에 맞도록 잘립니다. 또한 2단계에서 통합할 수 있도록 대화에서 추출한 간결한 메모인 raw 메모리 추출본을 생성합니다. +2. 2단계: 레이아웃 통합. 통합 에이전트가 하나의 메모리 레이아웃에 대한 raw 메모리를 읽고, 더 많은 근거가 필요할 때 대화 요약을 열어 패턴을 `MEMORY.md`과 `memory_summary.md`로 추출합니다. 기본 워크스페이스 레이아웃은 다음과 같습니다. @@ -83,7 +83,7 @@ workspace/ └── skills/ ``` -`MemoryGenerateConfig`로 메모리 생성을 구성할 수 있습니다. +`MemoryGenerateConfig`을 사용하여 메모리 생성을 구성할 수 있습니다. ```python from agents.sandbox import MemoryGenerateConfig @@ -97,13 +97,13 @@ memory = Memory( ) ``` -`extra_prompt`를 사용하여 메모리 생성기에 사용 사례에서 가장 중요한 신호를 알려줄 수 있습니다. 예를 들어 GTM 에이전트의 경우 고객 및 회사 세부 정보가 해당됩니다. +GTM 에이전트에서 고객 및 회사 세부 정보처럼 사용 사례에 가장 중요한 신호를 메모리 생성기에 알리려면 `extra_prompt`을 사용하세요. -최근 원문 메모리가 `max_raw_memories_for_consolidation`(기본값 256)을 초과하면, 2단계는 가장 최신 대화의 메모리만 유지하고 더 오래된 메모리는 제거합니다. 최신성은 대화가 마지막으로 업데이트된 시간을 기준으로 합니다. 이 망각 메커니즘은 메모리가 최신 환경을 반영하는 데 도움이 됩니다. +최근 raw 메모리 수가 `max_raw_memories_for_consolidation`(기본값 256)을 초과하면 2단계에서는 가장 최근 대화의 메모리만 유지하고 오래된 메모리는 제거합니다. 최신성은 대화가 마지막으로 업데이트된 시간을 기준으로 결정됩니다. 이 망각 메커니즘은 메모리가 최신 환경을 반영하도록 지원합니다. ## 멀티턴 대화 -멀티턴 샌드박스 채팅에는 일반 SDK `Session`을 동일한 라이브 샌드박스 세션과 함께 사용하세요. +멀티턴 샌드박스 채팅에서는 동일한 실시간 샌드박스 세션과 함께 일반 SDK `Session`을 사용하세요. ```python from agents import Runner, SQLiteSession @@ -132,20 +132,20 @@ async with sandbox: ) ``` -두 실행은 동일한 SDK 대화 세션(`session=conversation_session`)을 전달하므로 같은 `session.session_id`를 공유하고, 따라서 하나의 메모리 대화 파일에 추가됩니다. 이는 라이브 워크스페이스를 식별하며 메모리 대화 ID로 사용되지 않는 샌드박스(`sandbox`)와 다릅니다. 1단계는 샌드박스 세션이 닫힐 때 누적된 대화를 보므로, 서로 분리된 두 턴이 아니라 전체 교환에서 메모리를 추출할 수 있습니다. +두 실행 모두 동일한 SDK 대화 세션(`session=conversation_session`)을 전달하므로 동일한 `session.session_id`을 공유합니다. 따라서 두 실행 모두 하나의 메모리 대화 파일에 추가됩니다. 이는 실시간 워크스페이스를 식별하며 메모리 대화 ID로 사용되지 않는 샌드박스(`sandbox`)와는 다릅니다. 1단계에서는 샌드박스 세션이 종료될 때 누적된 대화를 확인하므로, 서로 분리된 두 턴이 아니라 전체 대화에서 메모리를 추출할 수 있습니다. -여러 `Runner.run(...)` 호출이 하나의 메모리 대화가 되도록 하려면 해당 호출들에 안정적인 식별자를 전달하세요. 메모리가 실행을 대화와 연결할 때는 다음 순서로 확인합니다. +여러 `Runner.run(...)` 호출을 하나의 메모리 대화로 만들려면 해당 호출 전체에 안정적인 식별자를 전달하세요. 메모리가 실행을 대화와 연결할 때는 다음 순서로 식별자를 결정합니다. -1. `Runner.run(...)`에 전달한 경우 `conversation_id` -2. `SQLiteSession` 같은 SDK `Session`을 전달한 경우 `session.session_id` -3. 위 둘 중 어느 것도 없을 경우 `RunConfig.group_id` -4. 안정적인 식별자가 없을 경우 생성된 실행별 ID +1. `Runner.run(...)`에 전달한 `conversation_id` +2. `SQLiteSession`과 같은 SDK `Session`을 전달한 경우의 `session.session_id` +3. 위 항목이 모두 없는 경우의 `RunConfig.group_id` +4. 안정적인 식별자가 없는 경우 실행별로 생성되는 ID -## 서로 다른 에이전트의 메모리를 격리하기 위한 서로 다른 레이아웃 사용 +## 에이전트별 메모리 격리를 위한 서로 다른 레이아웃 사용 -메모리 격리는 에이전트 이름이 아니라 `MemoryLayoutConfig`를 기준으로 합니다. 동일한 레이아웃과 동일한 메모리 대화 ID를 가진 에이전트는 하나의 메모리 대화와 하나의 통합된 메모리를 공유합니다. 서로 다른 레이아웃을 가진 에이전트는 동일한 샌드박스 워크스페이스를 공유하더라도 별도의 롤아웃 파일, 원문 메모리, `MEMORY.md`, `memory_summary.md`를 유지합니다. +메모리 격리는 에이전트 이름이 아니라 `MemoryLayoutConfig`을 기준으로 합니다. 레이아웃과 메모리 대화 ID가 같은 에이전트는 하나의 메모리 대화와 통합 메모리를 공유합니다. 레이아웃이 다른 에이전트는 같은 샌드박스 워크스페이스를 공유하더라도 롤아웃 파일, raw 메모리, `MEMORY.md` 및 `memory_summary.md`을 별도로 유지합니다. -여러 에이전트가 하나의 샌드박스를 공유하지만 메모리는 공유해서는 안 되는 경우 별도의 레이아웃을 사용하세요. +여러 에이전트가 하나의 샌드박스를 공유하지만 메모리는 공유하지 않아야 하는 경우 별도의 레이아웃을 사용하세요. ```python from agents import SQLiteSession @@ -186,4 +186,4 @@ gtm_session = SQLiteSession("gtm-q2-pipeline-review") engineering_session = SQLiteSession("eng-invoice-test-fix") ``` -이렇게 하면 GTM 분석이 엔지니어링 버그 수정 메모리로 통합되거나 그 반대가 되는 일을 방지할 수 있습니다. \ No newline at end of file +이렇게 하면 GTM 분석이 엔지니어링 버그 수정 메모리에 통합되거나 그 반대의 상황이 발생하는 것을 방지할 수 있습니다. \ No newline at end of file diff --git a/docs/ko/sandbox_agents.md b/docs/ko/sandbox_agents.md index d5715a9953..8d562c10d9 100644 --- a/docs/ko/sandbox_agents.md +++ b/docs/ko/sandbox_agents.md @@ -6,21 +6,21 @@ search: !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지나면서 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 사항, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 수 있습니다. -최신 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. Agents SDK의 **샌드박스 에이전트**는 모델이 대규모 문서 집합을 검색하고, 파일을 편집하고, 명령을 실행하고, 결과물을 생성하고, 저장된 샌드박스 상태에서 작업을 다시 이어갈 수 있는 영구 워크스페이스를 제공합니다. +최신 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. Agents SDK의 **샌드박스 에이전트**는 모델에 영구적인 작업 공간을 제공하여 대규모 문서 모음을 검색하고, 파일을 편집하고, 명령을 실행하고, 결과물을 생성하고, 저장된 샌드박스 상태에서 작업을 재개할 수 있게 합니다. -SDK는 파일 스테이징, 파일 시스템 도구, 셸 액세스, 샌드박스 수명 주기, 스냅샷, 공급자별 연동 코드를 직접 구성하지 않아도 이러한 실행 하네스를 제공합니다. 기존 `Agent` 및 `Runner` 흐름을 유지하면서 워크스페이스용 `Manifest`, 샌드박스 네이티브 도구용 기능, 작업 실행 위치를 지정하는 `SandboxRunConfig`를 추가하면 됩니다. +SDK는 파일 스테이징, 파일 시스템 도구, 셸 액세스, 샌드박스 수명 주기, 스냅샷, 제공업체별 연동 코드를 직접 연결하지 않아도 이러한 실행 하네스를 제공합니다. 기존 `Agent` 및 `Runner` 흐름을 유지하면서 작업 공간용 `Manifest`, 샌드박스 네이티브 도구의 기능, 작업이 실행될 위치를 지정하는 `SandboxRunConfig`을 추가하면 됩니다. ## 사전 요구 사항 - Python 3.10 이상 -- OpenAI Agents SDK에 대한 기본적인 이해 -- 샌드박스 클라이언트. 로컬 개발의 경우 `UnixLocalSandboxClient`로 시작합니다. +- OpenAI Agents SDK에 대한 기본 지식 +- 샌드박스 클라이언트. 로컬 개발에서는 `UnixLocalSandboxClient`로 시작 ## 설치 -SDK를 아직 설치하지 않았다면 다음을 실행합니다. +아직 SDK를 설치하지 않았다면 다음을 실행합니다. ```bash pip install openai-agents @@ -34,7 +34,7 @@ pip install "openai-agents[docker]" ## 로컬 샌드박스 에이전트 생성 -이 예제는 로컬 저장소를 `repo/` 아래에 스테이징하고, 로컬 스킬을 지연 로드하며, 러너가 실행을 위한 Unix 로컬 샌드박스 세션을 생성하도록 합니다. +이 예제는 `repo/` 아래에 로컬 저장소를 스테이징하고, 로컬 스킬을 지연 로드하며, 러너가 실행을 위한 Unix 로컬 샌드박스 세션을 생성하도록 합니다. ```python import asyncio @@ -94,24 +94,24 @@ if __name__ == "__main__": asyncio.run(main()) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제는 Unix 로컬 실행에서 결정론적으로 검증할 수 있도록 셸 기반의 작은 저장소를 사용합니다. +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제는 소규모 셸 기반 저장소를 사용하므로 Unix 로컬 실행 전반에서 결정론적으로 검증할 수 있습니다. -## 핵심 선택 사항 +## 주요 선택 사항 -기본 실행이 정상적으로 작동한 후 일반적으로 고려하는 선택 사항은 다음과 같습니다. +기본 실행이 정상적으로 작동한 후 대부분 다음 항목을 선택합니다. -- `default_manifest`: 새로운 샌드박스 세션에 사용할 파일, 저장소, 디렉터리, 마운트 -- `instructions`: 여러 프롬프트에 공통으로 적용할 간단한 워크플로 규칙 -- `base_instructions`: SDK 샌드박스 프롬프트를 대체하기 위한 고급 확장 수단 -- `capabilities`: 파일 시스템 편집/이미지 검사, 셸, 스킬, 메모리, 압축과 같은 샌드박스 네이티브 도구 -- `run_as`: 모델이 사용하는 도구의 샌드박스 사용자 ID +- `default_manifest`: 새 샌드박스 세션에서 사용할 파일, 저장소, 디렉터리 및 마운트 +- `instructions`: 여러 프롬프트에 걸쳐 적용할 간단한 워크플로 규칙 +- `base_instructions`: SDK 샌드박스 프롬프트를 대체하기 위한 고급 우회 수단 +- `capabilities`: 파일 시스템 편집/이미지 검사, 셸, 스킬, 메모리 및 SDK의 압축 메커니즘과 같은 샌드박스 네이티브 도구 +- `run_as`: 모델에 노출되는 도구가 실행되는 샌드박스 사용자 계정 - `SandboxRunConfig.client`: 샌드박스 백엔드 -- `SandboxRunConfig.session`, `session_state`, 또는 `snapshot`: 후속 실행이 이전 작업에 다시 연결되는 방식 +- `SandboxRunConfig.session`, `session_state` 또는 `snapshot`: 후속 실행에서 이전 작업에 다시 연결하는 방법 ## 다음 단계 -- [개념](sandbox/guide.md): 매니페스트, 기능, 권한, 스냅샷, 실행 구성, 구성 패턴을 이해합니다. -- [샌드박스 클라이언트](sandbox/clients.md): Unix 로컬, Docker, 호스팅 공급자 및 마운트 전략을 선택합니다. -- [에이전트 메모리](sandbox/memory.md): 이전 샌드박스 실행에서 얻은 내용을 보존하고 재사용합니다. +- [개념](sandbox/guide.md): 매니페스트, 기능, 권한, 스냅샷, 실행 구성 및 구성 패턴을 이해합니다. +- [샌드박스 클라이언트](sandbox/clients.md): Unix 로컬, Docker, 호스티드 제공업체 및 마운트 전략을 선택합니다. +- [에이전트 메모리](sandbox/memory.md): 이전 샌드박스 실행에서 얻은 교훈을 보존하고 재사용합니다. -셸 액세스가 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](tools.md)의 호스팅 셸로 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. \ No newline at end of file +셸 액세스를 가끔 사용하는 도구 중 하나로만 활용한다면 [도구 가이드](tools.md)의 호스티드 셸부터 시작하세요. 작업 공간 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. \ No newline at end of file diff --git a/docs/ko/sessions/advanced_sqlite_session.md b/docs/ko/sessions/advanced_sqlite_session.md index ca10beb8b3..e5d1419efe 100644 --- a/docs/ko/sessions/advanced_sqlite_session.md +++ b/docs/ko/sessions/advanced_sqlite_session.md @@ -4,7 +4,7 @@ search: --- # 고급 SQLite 세션 -`AdvancedSQLiteSession`은 기본 `SQLiteSession`을 개선한 버전으로, 대화 브랜칭, 상세한 사용량 분석, 구조화된 대화 쿼리 등 고급 대화 관리 기능을 제공합니다. +`AdvancedSQLiteSession`은 기본 `SQLiteSession`의 향상된 버전으로, 대화 브랜칭, 상세한 사용량 분석, 구조화된 대화 쿼리 등 고급 대화 관리 기능을 제공합니다. ## 기능 @@ -85,13 +85,13 @@ session = AdvancedSQLiteSession( ### 매개변수 - `session_id` (str): 대화 세션의 고유 식별자 -- `db_path` (str | Path): SQLite 데이터베이스 파일 경로. 인메모리 저장소의 경우 기본값은 `:memory:` -- `create_tables` (bool): 고급 테이블을 자동으로 생성할지 여부. 기본값은 `False` -- `logger` (logging.Logger | None): 세션의 사용자 지정 로거. 기본값은 모듈 로거 +- `db_path` (str | Path): SQLite 데이터베이스 파일 경로. 기본값은 인메모리 스토리지를 사용하는 `:memory:`입니다 +- `create_tables` (bool): 고급 테이블을 자동으로 생성할지 여부. 기본값은 `False`입니다 +- `logger` (logging.Logger | None): 세션의 사용자 지정 로거. 기본적으로 모듈 로거를 사용합니다 ## 사용량 추적 -AdvancedSQLiteSession은 대화 턴별 토큰 사용량 데이터를 저장하여 상세한 사용량 분석을 제공합니다. **이 기능은 각 에이전트 실행 후 `store_run_usage` 메서드를 호출하는지 여부에 전적으로 달려 있습니다.** +AdvancedSQLiteSession은 대화 턴별 토큰 사용량 데이터를 저장하여 상세한 사용량 분석을 제공합니다. **이 기능은 각 에이전트 실행 후 `store_run_usage` 메서드를 호출하는 것에 전적으로 의존합니다.** ### 사용량 데이터 저장 @@ -137,7 +137,7 @@ turn_2_usage = await session.get_turn_usage(user_turn_number=2) ## 대화 브랜칭 -AdvancedSQLiteSession의 주요 기능 중 하나는 모든 사용자 메시지에서 대화 브랜치를 생성하여 대체 대화 경로를 탐색할 수 있다는 점입니다. +AdvancedSQLiteSession의 핵심 기능 중 하나는 모든 사용자 메시지에서 대화 브랜치를 생성하여 대체 대화 경로를 탐색할 수 있다는 것입니다. ### 브랜치 생성 @@ -165,7 +165,7 @@ branch_id = await session.create_branch_from_content( ) ``` -브랜치 ID는 세션 ID의 수명 동안 고유합니다. 브랜치를 삭제하거나 세션을 지우면 해당 대화 데이터는 제거되지만, 이전에 사용한 브랜치 ID를 다시 사용할 수 있는 것은 아닙니다. 다른 브랜치를 생성할 때는 새로운 이름을 사용하세요. +브랜치 ID는 세션 ID의 전체 수명 동안 고유합니다. 브랜치를 삭제하거나 세션을 지우면 해당 대화 데이터는 제거되지만, 이전에 사용한 브랜치 ID를 다시 사용할 수 있게 되지는 않습니다. 다른 브랜치를 생성할 때는 새 이름을 사용하세요. ### 브랜치 관리 @@ -249,17 +249,17 @@ for turn in matching_turns: 세션은 다음을 포함한 메시지 구조를 자동으로 추적합니다. -- 메시지 유형(사용자, 어시스턴트, `tool_call` 등) -- 도구 호출에 사용된 도구 이름 +- 메시지 유형 값(`user`, `assistant`, `tool_call` 등) +- 도구 호출의 도구 이름 - 턴 번호 및 시퀀스 번호 - 브랜치 연결 관계 - 타임스탬프 ## 데이터베이스 스키마 -AdvancedSQLiteSession은 세 개의 테이블을 추가하여 기본 SQLite 스키마를 확장합니다. +AdvancedSQLiteSession은 기본 SQLite 스키마에 세 개의 테이블을 추가합니다. -### `message_structure` 테이블 +### message_structure 테이블 ```sql CREATE TABLE message_structure ( @@ -278,7 +278,7 @@ CREATE TABLE message_structure ( ); ``` -### `branch_reservations` 테이블 +### branch_reservations 테이블 ```sql CREATE TABLE branch_reservations ( @@ -288,9 +288,9 @@ CREATE TABLE branch_reservations ( ); ``` -이 테이블은 복사된 접두사가 비어 있는 브랜치를 포함하여 브랜치 ID를 원자적으로 예약합니다. 브랜치를 삭제하거나 세션을 지운 후에도 예약 행이 유지되므로, 오래된 세션 인스턴스가 동일한 ID를 재사용한 이후의 브랜치에 기록을 병합할 수 없습니다. +이 테이블은 복사된 접두사가 비어 있는 브랜치를 포함하여 브랜치 ID를 원자적으로 예약합니다. 예약 행은 브랜치를 삭제하거나 세션을 지운 경우에도 유지되므로, 오래된 세션 인스턴스가 같은 ID를 재사용한 이후의 브랜치에 기록을 병합할 수 없습니다. -### `turn_usage` 테이블 +### turn_usage 테이블 ```sql CREATE TABLE turn_usage ( @@ -312,7 +312,7 @@ CREATE TABLE turn_usage ( ## 전체 예제 -모든 기능을 종합적으로 살펴보려면 [전체 예제](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)를 확인하세요. +모든 기능에 대한 포괄적인 데모는 [전체 예제](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)를 참조하세요. ## API 레퍼런스 diff --git a/docs/ko/sessions/index.md b/docs/ko/sessions/index.md index d6b4e699b1..ea8b1636f2 100644 --- a/docs/ko/sessions/index.md +++ b/docs/ko/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 세션 -Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 기본 제공 세션 메모리를 지원하므로, 턴 사이에 `.to_input_list()`를 수동으로 처리할 필요가 없습니다. +Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 기본 제공 세션 메모리를 지원하므로, 턴 사이에서 `.to_input_list()`을 수동으로 처리할 필요가 없습니다. -세션은 특정 세션의 대화 기록을 저장하므로, 명시적인 수동 메모리 관리 없이도 에이전트가 컨텍스트를 유지할 수 있습니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. +세션은 특정 세션의 대화 기록을 저장하여, 명시적으로 메모리를 수동 관리하지 않아도 에이전트가 컨텍스트를 유지할 수 있게 합니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. -SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용하세요. 동일한 실행에서 세션을 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`와 함께 사용할 수 없습니다. 대신 OpenAI 서버가 관리하는 연속 실행을 원한다면 세션을 추가로 계층화하지 말고 이러한 메커니즘 중 하나를 선택하세요. +SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용하세요. 동일한 실행에서 세션은 실행 수준 연속 실행 옵션인 `conversation_id`, `previous_response_id`, `auto_previous_response_id`과 함께 사용할 수 없습니다. 대신 OpenAI 서버 관리형 연속 실행을 사용하려면 세션을 추가로 적용하지 말고 이러한 메커니즘 중 하나를 선택하세요. ## 빠른 시작 @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 동일한 세션을 사용한 인터럽션된 실행 재개 +## 동일한 세션을 사용한 인터럽션(중단 처리)된 실행 재개 -승인을 위해 실행이 일시 중지된 경우, 재개된 턴이 저장된 동일한 대화 기록을 이어가도록 동일한 세션 인스턴스(또는 동일한 백엔드 저장소를 가리키는 다른 세션 인스턴스)를 사용하여 실행을 재개하세요. +승인을 위해 실행이 일시 중지되면, 재개된 턴이 저장된 동일한 대화 기록을 이어서 사용하도록 동일한 세션 인스턴스 또는 동일한 세션 ID와 동일한 기본 스토리지 백엔드로 구성된 다른 인스턴스를 사용하여 재개하세요. ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -65,29 +65,29 @@ if result.interruptions: ## 핵심 세션 동작 -세션 메모리가 활성화되면 다음과 같이 동작합니다. +세션 메모리가 활성화된 경우: 1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 가져와 입력 항목 앞에 추가합니다. 2. **각 실행 후**: 실행 중 생성된 모든 새 항목(사용자 입력, 어시스턴트 응답, 도구 호출 등)이 세션에 자동으로 저장됩니다. -3. **컨텍스트 유지**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. +3. **컨텍스트 보존**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. -따라서 `.to_input_list()`를 수동으로 호출하거나 실행 사이의 대화 상태를 직접 관리할 필요가 없습니다. +따라서 `.to_input_list()`을 수동으로 호출하고 실행 사이의 대화 상태를 관리할 필요가 없습니다. ## 기록과 새 입력의 병합 방식 제어 -세션을 전달하면 러너는 일반적으로 다음 순서로 모델 입력을 준비합니다. +세션을 전달하면 러너는 일반적으로 다음과 같이 모델 입력을 준비합니다. 1. 세션 기록(`session.get_items(...)`에서 가져옴) 2. 새 턴 입력 -모델을 호출하기 전에 이 병합 단계를 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 콜백은 다음 두 목록을 받습니다. +모델 호출 전에 이 병합 단계를 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 콜백은 다음 두 목록을 받습니다. - `history`: 가져온 세션 기록(이미 입력 항목 형식으로 정규화됨) - `new_input`: 현재 턴의 새 입력 항목 -모델로 전송할 최종 입력 항목 목록을 반환하세요. +모델에 전송할 최종 입력 항목 목록을 반환하세요. -콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속한 항목만 저장합니다. 따라서 이전 기록의 순서를 바꾸거나 필터링해도 이전 세션 항목이 새로운 입력으로 다시 저장되지 않습니다. +콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속하는 항목만 저장합니다. 따라서 이전 기록을 재정렬하거나 필터링해도 이전 세션 항목이 새 입력으로 다시 저장되지 않습니다. ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -세션의 항목 저장 방식을 변경하지 않고 기록을 사용자 지정 방식으로 정리하거나, 순서를 바꾸거나, 선택적으로 포함해야 할 때 사용하세요. 모델 호출 직전에 최종 처리 단계가 추가로 필요하다면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. +세션의 항목 저장 방식을 변경하지 않고 기록을 사용자 지정하여 정리, 재정렬 또는 선택적으로 포함해야 할 때 사용하세요. 모델 호출 직전에 추가적인 최종 처리 단계가 필요하다면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. -## 가져올 기록 제한 +## 가져올 기록의 제한 -각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]를 사용하세요. +각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]을 사용하세요. -- `SessionSettings(limit=None)`(기본값): 사용 가능한 모든 세션 항목 가져오기 -- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 가져오기 +- `SessionSettings(limit=None)` (기본값): 사용 가능한 모든 세션 항목을 가져옴 +- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 가져옴 -[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]를 통해 실행별로 적용할 수 있습니다. +[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]을 통해 실행별로 적용할 수 있습니다. ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -세션 구현이 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`는 해당 실행에서 `None`이 아닌 모든 값을 재정의합니다. 이는 세션의 기본 동작을 변경하지 않으면서 긴 대화에서 가져올 기록의 크기를 제한하려는 경우 유용합니다. +세션 구현이 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`에서 `None`이 아닌 각 값은 해당 실행의 대응하는 기본값을 재정의합니다. 세션의 기본 동작을 변경하지 않고 가져올 기록의 크기를 제한하려는 긴 대화에 유용합니다. ## 메모리 작업 ### 기본 작업 -세션은 대화 기록 관리를 위한 여러 작업을 지원합니다. +세션은 대화 기록을 관리하기 위한 여러 작업을 지원합니다. ```python from agents import SQLiteSession @@ -165,7 +165,7 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 수정 시 pop_item 사용 +### 수정을 위한 pop_item 사용 `pop_item` 메서드는 대화의 마지막 항목을 실행 취소하거나 수정하려는 경우 특히 유용합니다. @@ -202,24 +202,24 @@ SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다 ### 기본 제공 세션 구현 선택 -아래의 상세한 예제를 읽기 전에 이 표를 사용하여 시작할 구현을 선택하세요. +아래의 자세한 예제를 읽기 전에 이 표를 사용하여 시작점을 선택하세요. -| 세션 유형 | 적합한 용도 | 참고 사항 | +| 세션 유형 | 적합한 용도 | 참고 | | --- | --- | --- | -| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 기본 제공, 경량, 파일 기반 또는 인메모리 | -| `AsyncSQLiteSession` | `aiosqlite`를 사용하는 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | -| `RedisSession` | 여러 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | +| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 기본 제공되며 가볍고, 파일 기반 또는 인메모리 방식 | +| `AsyncSQLiteSession` | `aiosqlite`을 사용하는 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | +| `RedisSession` | 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | | `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 호환 | -| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 저장소가 필요한 앱 | 비동기 pymongo 사용, 순서 유지를 위한 원자적 시퀀스 카운터 | +| `MongoDBSession` | 이미 MongoDB를 사용하거나 멀티프로세스 스토리지가 필요한 앱 | 비동기 pymongo, 순서 지정을 위한 원자적 시퀀스 카운터 | | `DaprSession` | Dapr 사이드카를 사용하는 클라우드 네이티브 배포 | 여러 상태 저장소와 TTL 및 일관성 제어 지원 | -| `OpenAIConversationsSession` | OpenAI의 서버 관리형 저장소 | OpenAI Conversations API 기반 기록 | +| `OpenAIConversationsSession` | OpenAI의 서버 관리형 스토리지 | OpenAI Conversations API 기반 기록 | | `OpenAIResponsesCompactionSession` | 자동 압축이 필요한 긴 대화 | 다른 세션 백엔드를 감싸는 래퍼 | -| `AdvancedSQLiteSession` | SQLite 및 분기/분석 | 더 많은 기능 제공, 전용 페이지 참조 | -| `EncryptedSession` | 다른 세션 위에 암호화 및 TTL 추가 | 래퍼, 먼저 기반 백엔드 선택 필요 | +| `AdvancedSQLiteSession` | SQLite와 분기/분석 | 더 많은 기능을 제공하며 전용 페이지 참고 | +| `EncryptedSession` | 다른 세션에 암호화와 TTL 추가 | 래퍼이므로 먼저 기본 백엔드 선택 필요 | 일부 구현에는 추가 세부 정보를 제공하는 전용 페이지가 있으며, 해당 하위 섹션에 링크되어 있습니다. -ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성을 위해 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만, ChatKit 저장소를 그대로 대체할 수는 없습니다. [`chatkit-python`의 ChatKit 데이터 저장소 구현 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참조하세요. +ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속화를 위해 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만, ChatKit 스토어를 그대로 대체할 수는 없습니다. [ChatKit 데이터 스토어 구현에 관한 `chatkit-python` 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참고하세요. ### OpenAI Conversations API 세션 @@ -259,7 +259,7 @@ print(result.final_output) # "California" ### OpenAI Responses 압축 세션 -Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 세션은 기반 세션을 감싸며 `should_trigger_compaction`에 따라 각 턴 후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 세션으로 감싸지 마세요. 두 기능은 기록을 서로 다른 방식으로 관리합니다. +Responses API(`responses.compact`)를 사용하여 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 세션은 기본 세션을 감싸며 `should_trigger_compaction`을 기준으로 각 턴 이후 자동 압축할 수 있습니다. `OpenAIConversationsSession`을 이 세션으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. #### 일반적인 사용법(자동 압축) @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -기본적으로 후보 항목 수가 임계값에 도달하면 각 턴 후에 압축이 실행됩니다. +기본적으로 SDK는 각 턴 이후 압축 대상이 임계값을 충족하는지 확인하고, 충족할 때만 압축합니다. -Responses API 응답 ID로 이미 턴을 연결하고 있다면 `compaction_mode="previous_response_id"`가 가장 적합합니다. 반면 `compaction_mode="input"`은 현재 세션 항목에서 압축 요청을 다시 구성합니다. 이는 응답 체인을 사용할 수 없거나 세션 콘텐츠를 단일 진실 공급원으로 사용하려는 경우 유용합니다. 기본값인 `"auto"`는 사용 가능한 옵션 중 가장 안전한 옵션을 선택합니다. +`compaction_mode="previous_response_id"`은 압축 세션이 보관한 Responses API 응답 ID를 사용하며, 해당 응답 체인을 계속 사용할 수 있을 때 가장 효과적입니다. 반면 `compaction_mode="input"`은 현재 세션 항목을 바탕으로 압축 요청을 다시 구성하므로, 응답 체인을 사용할 수 없거나 세션 콘텐츠를 단일 진실 공급원으로 사용하려는 경우 유용합니다. 기본값인 `"auto"`은 사용 가능한 가장 안전한 옵션을 선택합니다. -에이전트가 `ModelSettings(store=False)`로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보관하지 않습니다. 이러한 무상태 설정에서는 기본 `"auto"` 모드가 `previous_response_id`에 의존하지 않고 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)를 참조하세요. +에이전트가 `ModelSettings(store=False)`으로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보관하지 않습니다. 이러한 무상태 설정에서 기본 `"auto"` 모드는 `previous_response_id`에 의존하지 않고 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)을 참고하세요. -#### 자동 압축으로 인한 스트리밍 차단 +#### 자동 압축에 의한 스트리밍 차단 -압축은 세션 기록을 지우고 다시 작성하므로, SDK는 압축이 완료될 때까지 실행이 완료된 것으로 간주하지 않습니다. 스트리밍 모드에서는 압축 작업이 많은 경우 마지막 출력 토큰 후에도 `run.stream_events()`가 몇 초 동안 열린 상태로 유지될 수 있습니다. +압축은 세션 기록을 지우고 다시 작성하므로 SDK는 실행이 완료된 것으로 간주하기 전에 압축이 끝날 때까지 기다립니다. 스트리밍 모드에서는 압축 작업이 무거운 경우 마지막 출력 토큰 이후에도 `run.stream_events()`이 몇 초간 열린 상태로 유지될 수 있습니다. -지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요한 경우 자동 압축을 비활성화하고 턴 사이(또는 유휴 시간)에 직접 `run_compaction()`을 호출하세요. 자체 기준에 따라 압축을 강제로 실행할 시점을 결정할 수 있습니다. +지연 시간이 짧은 스트리밍이나 빠른 턴 전환을 원한다면 자동 압축을 비활성화하고 턴 사이 또는 유휴 시간에 `run_compaction()`을 직접 호출하세요. 자체 기준에 따라 압축을 강제할 시점을 결정할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +332,7 @@ result = await Runner.run( ### 비동기 SQLite 세션 -`aiosqlite` 기반 SQLite 영속성이 필요한 경우 `AsyncSQLiteSession`을 사용하세요. +`aiosqlite` 기반의 SQLite 영속화를 원한다면 `AsyncSQLiteSession`을 사용하세요. ```bash pip install aiosqlite @@ -368,11 +368,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 후에는 세션이 종료 상태가 되며 이후 세션 작업에서 `RuntimeError`가 발생합니다. `close()`를 반복해서 또는 동시에 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하고 있다면 `redis_client=...`를 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`는 아무 작업도 수행하지 않으며 호출자가 클라이언트의 소유권과 세션의 사용 가능 상태를 모두 유지합니다. +`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후 세션은 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. `close()`을 반복하거나 동시에 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리한다면 `redis_client=...`을 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`은 아무 작업도 하지 않으며, 호출자가 클라이언트 소유권과 세션 사용 가능 상태를 모두 유지합니다. ### SQLAlchemy 세션 -SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션용 Agents SDK 세션 영속성 구현입니다. +SQLAlchemy가 지원하는 모든 데이터베이스를 활용하는 프로덕션용 Agents SDK 세션 영속화입니다. ```python from agents.extensions.memory import SQLAlchemySession @@ -390,11 +390,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -자세한 내용은 [SQLAlchemy 세션](sqlalchemy_session.md)을 참조하세요. +자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참고하세요. ### Dapr 세션 -이미 Dapr 사이드카를 실행하고 있거나 에이전트 코드를 변경하지 않고 여러 상태 저장소 백엔드 간에 이동할 수 있는 세션 저장소가 필요한 경우 `DaprSession`을 사용하세요. +이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 구성된 상태 저장소 백엔드를 전환하려면 `DaprSession`을 사용하세요. ```bash pip install openai-agents[dapr] @@ -415,19 +415,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -참고 사항: +참고: -- `from_address(...)`는 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리하고 있다면 `dapr_client=...`를 사용하여 `DaprSession(...)`을 직접 생성하세요. -- 컨텍스트에서 나가거나 `close()`를 호출하면 소유 클라이언트 세션이 종료 상태가 되며 이후 세션 작업에서 `RuntimeError`가 발생합니다. 단, `close()`를 반복해서 또는 동시에 호출해도 안전합니다. 주입된 클라이언트를 사용하는 경우 `close()`는 아무 작업도 수행하지 않으며 세션은 계속 사용할 수 있습니다. -- 기반 상태 저장소에서 TTL을 지원하는 경우 `ttl=...`을 전달하면 오래된 세션 데이터가 자동으로 만료됩니다. -- 쓰기 직후 읽기에 대한 더 강력한 보장이 필요한 경우 `consistency=DAPR_CONSISTENCY_STRONG`을 전달하세요. -- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발 시 `dapr_address`에서 사용하는 gRPC 포트뿐 아니라 `--dapr-http-port 3500`을 지정하여 Dapr를 시작하세요. -- 로컬 구성 요소와 문제 해결을 포함한 전체 설정 절차는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참조하세요. +- `from_address(...)`은 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리한다면 `dapr_client=...`을 사용하여 `DaprSession(...)`을 직접 생성하세요. +- 컨텍스트를 종료하거나 `close()`을 호출하면 소유 클라이언트를 사용하는 세션이 종료 상태가 됩니다. 이후 세션 작업은 `RuntimeError`을 발생시키지만, `close()`을 반복하거나 동시에 호출해도 안전합니다. 주입된 클라이언트를 사용하면 `close()`은 아무 작업도 하지 않으며 세션은 계속 사용할 수 있습니다. +- 기본 상태 저장소가 TTL을 지원하는 경우 `ttl=...`을 전달하면 세션 데이터에 TTL 만료가 자동으로 적용됩니다. +- 쓰기 후 읽기에 대한 더 강력한 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG`을 전달하세요. +- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발에서는 `dapr_address`에서 사용하는 gRPC 포트와 함께 `--dapr-http-port 3500`으로 Dapr를 시작하세요. +- 로컬 컴포넌트와 문제 해결을 포함한 전체 설정 절차는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)을 참고하세요. ### MongoDB 세션 -이미 MongoDB를 사용하는 애플리케이션이나 수평 확장이 가능한 다중 프로세스 세션 저장소가 필요한 경우 `MongoDBSession`을 사용하세요. +이미 MongoDB를 사용하거나 수평 확장이 가능한 멀티프로세스 세션 스토리지가 필요한 애플리케이션에는 `MongoDBSession`을 사용하세요. ```bash pip install openai-agents[mongodb] @@ -450,16 +450,16 @@ print(result.final_output) await session.close() ``` -참고 사항: +참고: -- `from_uri(...)`는 `AsyncMongoClient`를 생성하고 소유하며 `session.close()` 호출 시 이를 닫습니다. 소유 클라이언트 세션은 `close()` 후 종료 상태가 되며 이후 세션 작업에서 `RuntimeError`가 발생합니다. 애플리케이션에서 이미 클라이언트를 관리하고 있다면 `client=...`를 사용하여 `MongoDBSession(...)`을 직접 생성하세요. 이 경우 `session.close()`는 아무 작업도 수행하지 않으며 수명 주기 및 세션 사용 가능 여부는 호출자가 관리합니다. -- 다른 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결할 수 있습니다. -- 두 개의 컬렉션이 사용되며, 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`) 및 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 각 메시지 문서에는 단조 증가하는 `seq` 카운터가 포함되어 동시 작성자와 프로세스 전반에서 순서를 유지합니다. -- 첫 번째 실행 전에 `await session.ping()`을 사용하여 연결 상태를 확인하세요. +- `from_uri(...)`은 `AsyncMongoClient`을 생성하고 소유하며 `session.close()`에서 닫습니다. 소유 클라이언트를 사용하는 세션은 `close()` 이후 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. 애플리케이션에서 이미 클라이언트를 관리한다면 `client=...`을 사용하여 `MongoDBSession(...)`을 직접 생성하세요. 이 경우 `session.close()`은 아무 작업도 하지 않고, 호출자가 클라이언트 수명 주기를 관리할 책임을 유지하며, 세션은 계속 사용할 수 있습니다. +- 다른 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결하세요. +- 두 개의 컬렉션이 사용되며 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`)과 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 비어 있지 않은 각 `add_items()` 호출은 단조 증가하는 `seq`이 최종 항목을 기준으로 배치 순서를 정하는 하나의 논리적 배치 문서를 작성합니다. 기존의 항목별 메시지 문서도 계속 읽을 수 있습니다. 논리적 배치는 MongoDB의 단일 문서 크기 제한 내에 있어야 하며, 크기를 초과한 배치는 일부만 저장되지 않고 원자적으로 실패합니다. +- 첫 실행 전에 연결 상태를 확인하려면 `await session.ping()`을 사용하세요. ### 고급 SQLite 세션 -대화 분기, 사용량 분석 및 구조화된 쿼리를 제공하는 향상된 SQLite 세션입니다. +대화 분기, 사용량 분석 및 구조화된 쿼리를 지원하는 향상된 SQLite 세션입니다. ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -479,11 +479,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -자세한 내용은 [고급 SQLite 세션](advanced_sqlite_session.md)을 참조하세요. +자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참고하세요. ### 암호화된 세션 -모든 세션 구현을 위한 투명한 암호화 래퍼입니다. +모든 세션 구현에 사용할 수 있는 투명한 암호화 래퍼입니다. ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -506,34 +506,34 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -자세한 내용은 [암호화된 세션](encrypted_session.md)을 참조하세요. +자세한 문서는 [암호화된 세션](encrypted_session.md)을 참고하세요. ### 기타 세션 유형 -이 밖에도 몇 가지 기본 제공 옵션이 있습니다. `examples/memory/` 및 `extensions/memory/` 아래의 소스 코드를 참조하세요. +몇 가지 기본 제공 옵션이 더 있습니다. `examples/memory/`과 `extensions/memory/` 아래의 소스 코드를 참고하세요. ## 운영 패턴 -### 세션 ID 명명 +### 세션 ID 명명법 -대화를 체계적으로 정리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. +대화를 체계적으로 관리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. - 사용자 기반: `"user_12345"` - 스레드 기반: `"thread_abc123"` - 컨텍스트 기반: `"support_ticket_456"` -### 메모리 영속성 +### 메모리 영속화 - 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`) 사용 - 영구 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) 사용 -- `aiosqlite` 기반 구현이 필요한 경우 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) 사용 -- 공유되는 지연 시간이 짧은 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`) 사용 +- `aiosqlite` 기반 구현이 필요하면 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) 사용 +- 지연 시간이 짧은 공유 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`) 사용 - SQLAlchemy가 지원하는 기존 데이터베이스를 사용하는 프로덕션 시스템에는 SQLAlchemy 기반 세션(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) 사용 -- 이미 MongoDB를 사용하거나 다중 프로세스 및 수평 확장이 가능한 세션 저장소가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) 사용 -- 기본 제공 텔레메트리, 트레이싱 및 데이터 격리와 30개 이상의 데이터베이스 백엔드를 지원하는 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 -- OpenAI Conversations API에 기록을 저장하려는 경우 OpenAI 호스트 저장소(`OpenAIConversationsSession()`) 사용 -- 모든 세션을 투명한 암호화 및 TTL 기반 만료 기능으로 감싸려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 -- 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 사용자 지정 세션 백엔드 구현 고려 +- 이미 MongoDB를 사용하거나 수평 확장이 가능한 멀티프로세스 세션 스토리지가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) 사용 +- 기본 제공 텔레메트리, 트레이싱, 데이터 격리 및 30개 이상의 데이터베이스 백엔드 지원이 필요한 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 +- OpenAI Conversations API에 기록을 저장하려면 OpenAI 호스트 스토리지(`OpenAIConversationsSession()`) 사용 +- 투명한 암호화와 TTL 기반 만료를 모든 세션에 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 +- 더 고급 사용 사례를 위해 다른 프로덕션 시스템(예: Django)용 사용자 지정 세션 백엔드 구현 고려 ### 여러 세션 @@ -581,7 +581,7 @@ result2 = await Runner.run( ## 전체 예제 -다음은 세션 메모리의 실제 동작을 보여주는 전체 예제입니다. +다음은 세션 메모리의 동작을 보여주는 전체 예제입니다. ```python import asyncio @@ -696,11 +696,11 @@ result = await Runner.run( |---------|-------------| | [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 위한 Django ORM 기반 세션 | -세션 구현을 구축했다면 여기에 추가할 수 있도록 언제든지 문서 PR을 제출해 주세요! +세션 구현을 만들었다면 여기에 추가할 수 있도록 문서 PR을 자유롭게 제출해 주세요! ## API 레퍼런스 -자세한 API 문서는 다음을 참조하세요. +자세한 API 문서는 다음을 참고하세요. - [`Session`][agents.memory.session.Session] - 프로토콜 인터페이스 - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 구현 @@ -711,5 +711,5 @@ result = await Runner.run( - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy 기반 구현 - [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 기반 세션 구현 - [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 상태 저장소 구현 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기 및 분석 기능을 갖춘 향상된 SQLite +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기와 분석 기능을 갖춘 향상된 SQLite - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 모든 세션을 위한 암호화 래퍼 \ No newline at end of file diff --git a/docs/ko/sessions/sqlalchemy_session.md b/docs/ko/sessions/sqlalchemy_session.md index 86d8cd1ef7..71cb6d10ec 100644 --- a/docs/ko/sessions/sqlalchemy_session.md +++ b/docs/ko/sessions/sqlalchemy_session.md @@ -4,11 +4,11 @@ search: --- # SQLAlchemy 세션 -`SQLAlchemySession`은 SQLAlchemy를 사용하여 프로덕션 환경에 적합한 세션 구현을 제공합니다. 따라서 SQLAlchemy가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 세션 스토리지로 사용할 수 있습니다. +`SQLAlchemySession`는 SQLAlchemy를 사용하여 프로덕션 환경에서 바로 사용할 수 있는 세션 구현을 제공하므로, SQLAlchemy가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 세션 스토리지로 사용할 수 있습니다. ## 설치 -SQLAlchemy 세션에는 `sqlalchemy` 추가 의존성이 필요합니다. +SQLAlchemy 세션을 사용하려면 `openai-agents` 패키지의 `sqlalchemy` optional-dependency extra가 필요합니다. ```bash pip install openai-agents[sqlalchemy] @@ -18,7 +18,7 @@ pip install openai-agents[sqlalchemy] ### 데이터베이스 URL 사용 -가장 간단하게 시작하는 방법은 다음과 같습니다. +시작하는 가장 간단한 방법은 다음과 같습니다. ```python import asyncio @@ -75,9 +75,9 @@ if __name__ == "__main__": ## 비 ASCII 텍스트 저장 -기본적으로 `SQLAlchemySession`은 세션 항목을 JSON으로 직렬화할 때 비 ASCII 문자를 이스케이프합니다. 이렇게 하면 기존 스토리지 형식을 유지하면서도 항목을 로드할 때 원본 텍스트를 그대로 복원할 수 있습니다. +기본적으로 `SQLAlchemySession`는 세션 항목을 JSON으로 직렬화할 때 비 ASCII 문자를 이스케이프합니다. 이렇게 하면 기존 스토리지 형식을 유지하면서도 항목을 로드할 때 원래 텍스트를 그대로 복원할 수 있습니다. -저장된 JSON에서 다국어 텍스트를 읽을 수 있는 형태로 유지하려면 `ensure_ascii=False`를 설정합니다. +저장된 JSON에서 다국어 텍스트를 읽을 수 있는 상태로 유지하려면 `ensure_ascii=False`를 설정합니다. ```python session = SQLAlchemySession.from_url( @@ -93,5 +93,5 @@ session = SQLAlchemySession.from_url( ## API 레퍼런스 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 기본 클래스 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 주요 클래스 - [`Session`][agents.memory.session.Session] - 기본 세션 프로토콜 \ No newline at end of file diff --git a/docs/ko/streaming.md b/docs/ko/streaming.md index e68a6fa24d..2fbcdbb360 100644 --- a/docs/ko/streaming.md +++ b/docs/ko/streaming.md @@ -6,15 +6,15 @@ search: 스트리밍을 사용하면 에이전트 실행이 진행되는 동안 업데이트를 구독할 수 있습니다. 최종 사용자에게 진행 상황 업데이트와 부분 응답을 표시할 때 유용합니다. -스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]를 호출합니다. 그러면 [`RunResultStreaming`][agents.result.RunResultStreaming]이 반환됩니다. `result.stream_events()`를 호출하면 아래에서 설명하는 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림이 반환됩니다. +스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 호출합니다. 그러면 [`RunResultStreaming`][agents.result.RunResultStreaming]이 반환됩니다. `result.stream_events()`를 호출하면 아래에서 설명하는 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림을 얻을 수 있습니다. -비동기 반복자가 종료될 때까지 `result.stream_events()`를 계속 소비해야 합니다. 스트리밍 실행은 반복자가 종료될 때까지 완료되지 않으며, 세션 영속화, 승인 상태 관리 또는 기록 압축과 같은 후처리는 마지막으로 표시되는 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. +비동기 반복자가 완료될 때까지 `result.stream_events()`를 계속 소비하세요. 반복자가 끝나기 전까지 스트리밍 실행은 완료된 것이 아니며, 세션 지속성, 승인 기록 관리 또는 기록 압축과 같은 후처리는 마지막으로 표시되는 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. -## 원문 응답 이벤트 +## 원시 응답 이벤트 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent]는 LLM에서 직접 전달되는 원문 이벤트입니다. OpenAI Responses API 형식이므로 각 이벤트에는 유형(예: `response.created`, `response.output_text.delta` 등)과 데이터가 있습니다. 이 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 객체는 LLM에서 직접 전달된 원시 이벤트를 래핑합니다. 각 객체의 `data` 필드에는 `response.created` 또는 `response.output_text.delta` 같은 유형의 OpenAI Responses API 이벤트가 포함됩니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. -컴퓨터 도구의 원문 이벤트는 저장된 결과와 동일하게 프리뷰와 GA를 구분합니다. 프리뷰 흐름에서는 하나의 `action`이 있는 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 일괄 처리된 `actions[]`가 있는 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 인터페이스는 이를 위한 컴퓨터 전용 이벤트 이름을 별도로 추가하지 않습니다. 두 형식 모두 계속 `tool_called`로 표시되며, 스크린샷 결과는 `computer_call_output` 항목을 래핑한 `tool_output`으로 반환됩니다. +컴퓨터 도구의 원시 이벤트는 저장된 결과와 동일하게 프리뷰와 GA를 구분합니다. 프리뷰 흐름은 하나의 `action`이 포함된 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 일괄 처리된 `actions[]`가 포함된 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 인터페이스는 이를 위해 컴퓨터 전용 이벤트 이름을 별도로 추가하지 않습니다. 두 형태 모두 여전히 `tool_called`으로 노출되며, 스크린샷 결과는 `computer_call_output` 항목을 래핑하는 `tool_output`로 반환됩니다. 예를 들어 다음 코드는 LLM이 생성한 텍스트를 토큰 단위로 출력합니다. @@ -41,7 +41,7 @@ if __name__ == "__main__": ## 스트리밍과 승인 -스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요하면 `result.stream_events()`가 종료되고 대기 중인 승인이 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 표시됩니다. `result.to_state()`를 사용하여 결과를 [`RunState`][agents.run_state.RunState]로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`를 사용하여 재개합니다. +스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요한 경우 `result.stream_events()`가 완료되고, 보류 중인 승인은 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. `result.to_state()`를 사용해 결과를 [`RunState`][agents.run_state.RunState]으로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`으로 재개하세요. ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -61,21 +61,21 @@ if result.interruptions: ## 현재 턴 이후 스트리밍 취소 -스트리밍 실행을 도중에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출합니다. 기본적으로 실행이 즉시 중지됩니다. 중지하기 전에 현재 턴이 정상적으로 완료되도록 하려면 대신 `result.cancel(mode="after_turn")`을 호출합니다. +스트리밍 실행을 도중에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출하세요. 기본적으로 실행은 즉시 중지됩니다. 중지하기 전에 현재 턴이 정상적으로 완료되도록 하려면 대신 `result.cancel(mode="after_turn")`를 호출하세요. -스트리밍 실행은 `result.stream_events()`가 종료될 때까지 완료되지 않습니다. 마지막으로 표시되는 토큰 이후에도 SDK가 세션 항목을 영속화하거나, 승인 상태를 확정하거나, 기록을 압축하고 있을 수 있습니다. +`result.stream_events()`가 완료되기 전까지 스트리밍 실행은 완료된 것이 아닙니다. 마지막으로 표시되는 토큰 이후에도 SDK에서 세션 항목을 저장하거나, 승인 상태를 확정하거나, 기록을 압축하고 있을 수 있습니다. -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하는 중이고 `cancel(mode="after_turn")`이 도구 턴 이후 중지된 경우, 즉시 새로운 사용자 턴을 추가하지 말고 정규화된 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 턴을 계속 진행합니다. -- 스트리밍 실행이 도구 승인을 위해 중지된 경우 이를 새 턴으로 취급하지 마세요. 스트림을 끝까지 소비하고 `result.interruptions`를 확인한 다음 `result.to_state()`에서 재개합니다. -- [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하면 검색한 세션 기록과 새 사용자 입력을 다음 모델 호출 전에 병합하는 방식을 사용자 지정할 수 있습니다. 여기에서 새 턴 항목을 다시 작성하면 다시 작성된 버전이 해당 턴에 대해 영속화됩니다. +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하는 중이고 `cancel(mode="after_turn")`가 도구 턴 이후 중지되는 경우, 즉시 새로운 사용자 턴을 추가하는 대신 정규화된 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 기존 사용자 턴을 계속 진행하세요. +- 도구 승인을 위해 스트리밍 실행이 중지된 경우 이를 새로운 턴으로 처리하지 마세요. 스트림 소비를 끝까지 완료하고 `result.interruptions`을 확인한 다음 `result.to_state()`에서 재개하세요. +- 다음 모델 호출 전에 가져온 세션 기록과 새로운 사용자 입력을 병합하는 방식을 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]를 사용하세요. 여기에서 새로운 턴의 항목을 다시 작성하면 다시 작성된 버전이 해당 턴에 저장됩니다. ## 실행 항목 이벤트와 에이전트 이벤트 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 상위 수준의 이벤트입니다. 항목이 완전히 생성되면 이를 알려 줍니다. 따라서 각 토큰 대신 "메시지 생성됨", "도구 실행됨" 등의 수준으로 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과로 변경될 때) 업데이트를 제공합니다. +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]은 상위 수준의 이벤트입니다. 항목 생성이 완전히 완료되면 이를 알려줍니다. 따라서 각 토큰 대신 "메시지 생성 완료", "도구 실행 완료" 등의 수준에서 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과로 변경될 때) 업데이트를 제공합니다. ### 실행 항목 이벤트 이름 -`RunItemStreamEvent.name`은 다음과 같이 고정된 의미론적 이벤트 이름 집합을 사용합니다. +`RunItemStreamEvent.name`는 고정된 의미론적 이벤트 이름 집합을 사용합니다. - `message_output_created` - `handoff_requested` @@ -91,13 +91,13 @@ if result.interruptions: `handoff_occured`는 이전 버전과의 호환성을 위해 의도적으로 철자가 잘못 표기되어 있습니다. -핸드오프 호출은 `handoff_requested`로만 내보내지며 `tool_called`로도 내보내지는 않습니다. 동일한 턴에 있는 일반 함수 도구 호출은 계속 `tool_called`를 내보냅니다. +핸드오프 호출은 `handoff_requested`로만 발생하며, `tool_called`로도 함께 발생하지는 않습니다. 동일한 턴의 일반 함수 도구 호출은 계속 `tool_called`을 발생시킵니다. -호스티드 툴 검색을 사용하는 경우 모델이 도구 검색 요청을 실행할 때 `tool_search_called`가 내보내지고, Responses API가 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 내보내집니다. +호스티드 툴 검색을 사용하는 경우 모델이 도구 검색 요청을 실행할 때 `tool_search_called`이 발생하고, Responses API가 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 발생합니다. -Programmatic Tool Calling을 사용하면 생성된 `program`과 프로그램이 소유한 일반 하위 도구 호출에 대해 `tool_called`가 내보내집니다. 하위 도구 출력과 이에 대응하는 `program_output`에 대해서는 `tool_output`이 내보내집니다. 프로그램이 소유한 호스티드 MCP의 `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외입니다. 각각 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 및 [`MCPListToolsItem`][agents.items.MCPListToolsItem]을 래핑한 `mcp_approval_requested` 및 `mcp_list_tools`로 내보내집니다. 나머지 항목을 구분하려면 원문 항목의 `type`을 확인하세요. 프로그램이 소유한 하위 호출에는 유형이 `program`이고 호출자 ID가 상위 프로그램을 식별하는 `caller`도 포함됩니다. +프로그래밍 방식 도구 호출에서는 생성된 `program`와 프로그램 소유의 일반 하위 도구 호출에 대해 `tool_called`이 발생합니다. 하위 도구 출력과 생성된 `program`에 대응하는 `program_output`에 대해서는 `tool_output`가 발생합니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외입니다. 이 항목들은 각각 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]와 [`MCPListToolsItem`][agents.items.MCPListToolsItem]를 래핑하는 `mcp_approval_requested` 및 `mcp_list_tools`로 발생합니다. 나머지 항목을 구분하려면 원시 항목의 `type`를 확인하세요. 프로그램 소유의 하위 호출에는 유형이 `program`이고 호출자 ID가 상위 프로그램을 식별하는 `caller`도 포함됩니다. -예를 들어 다음 코드는 원문 이벤트를 무시하고 업데이트를 사용자에게 스트리밍합니다. +예를 들어 다음 코드는 원시 이벤트를 무시하고 업데이트를 사용자에게 스트리밍합니다. ```python import asyncio diff --git a/docs/ko/tools.md b/docs/ko/tools.md index 61a2776ca1..abe26a3894 100644 --- a/docs/ko/tools.md +++ b/docs/ko/tools.md @@ -4,11 +4,11 @@ search: --- # 도구 -도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용 등의 작업을 수행할 수 있습니다. SDK는 다음 다섯 가지 카테고리를 지원합니다. +도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용과 같은 작업을 수행할 수 있습니다. SDK는 다음 다섯 가지 카테고리를 지원합니다. -- 호스티드 OpenAI 도구: OpenAI 서버에서 모델과 함께 실행됩니다. -- 로컬/런타임 실행 도구: `ComputerTool`과 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`은 로컬 또는 호스티드 컨테이너에서 실행할 수 있습니다. -- 함수 호출: 모든 Python 함수를 도구로 래핑합니다. +- OpenAI 호스티드 툴: OpenAI 서버에서 모델을 위해 실행됩니다. +- 로컬/런타임 실행 도구: `ComputerTool` 및 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`는 로컬 또는 호스티드 컨테이너에서 실행할 수 있습니다. +- `FunctionTool` 인스턴스: 모든 Python 함수를 도구로 래핑합니다. - Agents as tools: 전체 핸드오프 없이 에이전트를 호출 가능한 도구로 노출합니다. - 실험적 기능: Codex 도구: 도구 호출을 통해 워크스페이스 범위의 Codex 작업을 실행합니다. @@ -16,10 +16,10 @@ search: 이 페이지를 카탈로그로 활용한 다음, 제어하는 런타임에 해당하는 섹션으로 이동하세요. -| 원하는 작업 | 시작할 위치 | +| 원하는 작업 | 시작 위치 | | --- | --- | -| OpenAI 관리형 도구 사용(웹 검색, 파일 검색, 코드 인터프리터, 호스티드 MCP, 이미지 생성) | [호스티드 툴](#hosted-tools) | -| 도구 검색을 사용해 대규모 도구 집합의 로딩을 런타임까지 지연 | [호스티드 도구 검색](#hosted-tool-search) | +| OpenAI 관리 도구 사용(웹 검색, 파일 검색, Code Interpreter, 호스티드 MCP, 이미지 생성) | [호스티드 툴](#hosted-tools) | +| 도구 검색을 사용해 대규모 도구 범위를 런타임까지 지연 | [호스티드 도구 검색](#hosted-tool-search) | | 생성된 JavaScript에서 여러 도구 호출 조정 | [프로그래밍 방식 도구 호출](#programmatic-tool-calling) | | 자체 프로세스 또는 환경에서 도구 실행 | [로컬 런타임 도구](#local-runtime-tools) | | Python 함수를 도구로 래핑 | [함수 도구](#function-tools) | @@ -28,20 +28,20 @@ search: ## 호스티드 툴 -OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 몇 가지 기본 제공 도구를 제공합니다. +OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 다음과 같은 기본 제공 도구를 제공합니다. -- [`WebSearchTool`][agents.tool.WebSearchTool]을 사용하면 에이전트가 웹을 검색할 수 있습니다. -- [`FileSearchTool`][agents.tool.FileSearchTool]을 사용하면 OpenAI 벡터 스토어에서 정보를 검색할 수 있습니다. -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool]을 사용하면 LLM이 샌드박스 환경에서 코드를 실행할 수 있습니다. -- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 원격 MCP 서버의 도구를 모델에 노출합니다. -- [`ImageGenerationTool`][agents.tool.ImageGenerationTool]은 프롬프트에서 이미지를 생성합니다. -- [`ToolSearchTool`][agents.tool.ToolSearchTool]을 사용하면 모델이 지연된 도구, 네임스페이스 또는 호스티드 MCP 서버를 필요할 때 로드할 수 있습니다. -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 사용하면 모델이 생성된 JavaScript에서 사용 가능한 도구를 조정할 수 있습니다. +- [`WebSearchTool`][agents.tool.WebSearchTool]를 사용하면 에이전트가 웹을 검색할 수 있습니다. +- [`FileSearchTool`][agents.tool.FileSearchTool]를 사용하면 OpenAI 벡터 스토어에서 정보를 검색할 수 있습니다. +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool]를 사용하면 LLM이 샌드박스 환경에서 코드를 실행할 수 있습니다. +- [`HostedMCPTool`][agents.tool.HostedMCPTool]는 원격 MCP 서버의 도구를 모델에 노출합니다. +- [`ImageGenerationTool`][agents.tool.ImageGenerationTool]는 프롬프트로 이미지를 생성합니다. +- [`ToolSearchTool`][agents.tool.ToolSearchTool]를 사용하면 모델이 지연된 도구, 네임스페이스 또는 호스티드 MCP 서버를 필요할 때 불러올 수 있습니다. +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]를 사용하면 모델이 생성된 JavaScript에서 사용 가능한 도구를 조정할 수 있습니다. 고급 호스티드 검색 옵션: -- `FileSearchTool`은 `vector_store_ids` 및 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. `max_num_results`를 1부터 50 사이의 정수로 설정하세요. `None` 또는 0이면 공급자의 기본값을 사용합니다. -- `WebSearchTool`은 `filters`, `user_location`, `search_context_size`를 지원합니다. +- `FileSearchTool`는 `vector_store_ids` 및 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. `max_num_results`을 1에서 50 사이의 정수로 설정하세요. `None` 또는 0을 사용하면 공급자 기본값이 적용됩니다. +- `WebSearchTool`는 `filters`, `user_location`, `search_context_size`을 지원합니다. ```python from agents import Agent, FileSearchTool, Runner, WebSearchTool @@ -64,9 +64,9 @@ async def main(): ### 호스티드 도구 검색 -도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 집합의 로딩을 런타임까지 지연할 수 있으므로, 모델은 현재 턴에 필요한 일부만 로드합니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많고 모든 도구를 미리 노출하지 않으면서 도구 스키마 토큰을 줄이려는 경우 유용합니다. +도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 범위의 로드를 런타임까지 지연하여 현재 턴에 필요한 하위 집합만 불러올 수 있습니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많을 때 모든 도구를 미리 노출하지 않고 도구 스키마 토큰을 줄이는 데 유용합니다. -에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 도구 검색부터 사용하세요. 애플리케이션에서 무엇을 로드할지 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행 도구 검색도 지원하지만, 표준 `Runner`는 이 모드를 자동 실행하지 않습니다. +에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 도구 검색으로 시작하세요. 애플리케이션에서 무엇을 불러올지 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행 도구 검색도 지원하지만, 표준 `Runner`는 해당 모드를 자동으로 실행하지 않습니다. ```python from typing import Annotated @@ -111,26 +111,26 @@ print(result.final_output) 알아둘 사항: -- 호스티드 도구 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원은 `openai>=2.25.0`에 따라 달라집니다. -- 에이전트에 지연 로딩 대상을 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. -- 검색 가능한 대상에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`가 포함됩니다. -- 지연 로딩 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 구성에서도 모델이 필요할 때 적절한 그룹을 로드하도록 `ToolSearchTool()`을 사용할 수 있습니다. +- 호스티드 도구 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원 여부는 `openai>=2.25.0`에 따라 달라집니다. +- 에이전트에서 지연 로딩 범위를 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. +- 검색 가능한 범위에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`이 포함됩니다. +- 지연 로딩 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 설정에서는 모델이 필요할 때 올바른 그룹을 불러올 수 있도록 `ToolSearchTool()`도 사용할 수 있습니다. - `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름과 설명 아래에 그룹화합니다. `crm`, `billing`, `shipping`처럼 관련 도구가 많은 경우 일반적으로 가장 적합합니다. -- OpenAI의 공식 모범 사례 지침은 [가능하면 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. -- 가능하면 개별적으로 지연된 함수를 많이 사용하는 대신 네임스페이스 또는 호스티드 MCP 서버를 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 대상을 제공하고 더 많은 토큰을 절약합니다. -- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`가 없는 도구는 즉시 호출할 수 있고, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. -- 일반적으로 각 네임스페이스를 비교적 작게 유지하고, 함수 수는 10개 미만으로 구성하는 것이 좋습니다. -- 이름이 지정된 `tool_choice`는 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 사용하세요. -- `ToolSearchTool(execution="client")`는 수동 Responses 오케스트레이션용입니다. 모델이 클라이언트 실행 `tool_search_call`을 생성하면 표준 `Runner`는 이를 대신 실행하지 않고 예외를 발생시킵니다. -- 도구 검색 활동은 [`RunResult.new_items`](results.md#new-items)와 전용 항목 및 이벤트 유형을 사용하는 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 표시됩니다. -- 네임스페이스 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 코드 예제는 `examples/tools/tool_search.py`를 참조하세요. +- OpenAI의 공식 권장 지침은 [가능하면 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. +- 가능하면 개별적으로 지연된 여러 함수보다 네임스페이스나 호스티드 MCP 서버를 우선 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 범위를 제공하고 토큰을 더 많이 절약할 수 있습니다. +- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`이 없는 도구는 즉시 호출할 수 있으며, 같은 네임스페이스에 있는 지연된 도구는 도구 검색을 통해 불러옵니다. +- 일반적으로 각 네임스페이스를 비교적 작게 유지하며, 이상적으로는 함수 수를 10개 미만으로 제한하세요. +- 이름이 지정된 `tool_choice`은 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 우선 사용하세요. +- `ToolSearchTool(execution="client")`은 수동 Responses 오케스트레이션에 사용합니다. 모델이 클라이언트 실행 `tool_search_call`를 내보내면 표준 `Runner`는 대신 실행하지 않고 예외를 발생시킵니다. +- 도구 검색 활동은 전용 항목 및 이벤트 유형과 함께 [`RunResult.new_items`](results.md#new-items) 및 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 표시됩니다. +- 네임스페이스 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 예제는 `examples/tools/tool_search.py`을 참고하세요. - 공식 플랫폼 가이드: [도구 검색](https://developers.openai.com/api/docs/guides/tools-tool-search) ### 프로그래밍 방식 도구 호출 -프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 JavaScript를 생성하여 사용 가능한 도구를 호출하고, 출력을 결합한 후, 하나의 결과를 모델에 반환할 수 있습니다. 모든 도구 호출 후 모델을 왕복하지 않고도 반복문, 분기, 병렬 호출 또는 중간 계산을 활용할 수 있는 범위가 제한된 워크플로에 유용합니다. +프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 출력을 결합하고, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델과의 왕복 없이 루프, 분기, 병렬 호출 또는 중간 계산을 활용하는 범위가 제한된 워크플로에 유용합니다. -생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. Node.js API, 파일 시스템 또는 네트워크에 액세스할 수 없으며 프로세스도 지속되지 않습니다. 프로그램은 명시적으로 허용한 도구와만 상호 작용할 수 있습니다. +생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. Node.js API, 파일 시스템 또는 네트워크에 접근할 수 없으며 프로세스도 지속되지 않습니다. 프로그램은 명시적으로 허용한 도구하고만 상호작용할 수 있습니다. ```python from pydantic import BaseModel @@ -167,22 +167,22 @@ print(result.final_output) 알아둘 사항: -- 프로그래밍 방식 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. Chat Completions 모델과 Responses가 아닌 백엔드에서는 `ProgrammaticToolCallingTool()` 및 `tool_choice="programmatic_tool_calling"`이 거부됩니다. -- 에이전트에 `ProgrammaticToolCallingTool()`을 최대 하나 추가하세요. 에이전트는 프로그래밍 방식으로 호출할 수 있는 도구를 하나 이상 노출해야 하며, 네임스페이스, 지연된 함수 또는 지연된 호스티드 MCP 서버를 기반으로 하는 `ToolSearchTool()`이나 프롬프트로 관리되는 불투명한 도구 집합을 노출할 수도 있습니다. 검색 가능한 대상이 없는 단독 `ToolSearchTool()`은 거부됩니다. -- `allowed_callers`는 도구 호출 방식을 제어합니다. 이를 생략하면 모델의 직접 호출만 허용됩니다. 프로그램에서만 액세스하려면 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`을 사용하세요. -- 사용을 선택할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸, 패치 적용 도구는 `allowed_callers`를 직접 노출합니다. 호스티드 MCP와 코드 인터프리터에서는 `tool_config` 내부에 `allowed_callers`를 설정하세요. -- `@function_tool(allowed_callers=[...])`에서 Pydantic 모델, TypedDict 또는 데이터 클래스 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 값이 프로그램에 반환되기 전에 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없으면 `output_type=...`을 사용하고, 엄격한 객체 스키마가 이미 있다면 하위 수준의 우회 수단인 `output_json_schema={...}`를 사용하세요. `output_type`과 `output_json_schema`는 함께 사용할 수 없습니다. 일반 `str`, `Any`, `None` 반환은 유형이 지정되지 않은 상태로 유지됩니다. 스키마를 기반으로 하며 프로그램이 소유하는 호출에서는 자유 형식 텍스트가 출력 스키마를 충족하지 않으므로 기본 실패 포매터가 비활성화됩니다. 따라서 스키마를 준수하는 JSON을 반환하는 사용자 지정 `failure_error_function`을 제공하지 않으면 핸들러 예외가 전파됩니다. -- 프로그램이 소유하는 SDK 도구도 일반적인 Runner 수명 주기를 사용합니다. 도구 입력 및 출력 가드레일, 훅, 제한 시간, 동시성 제한, 승인, 세션, `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출과 프로그램 호출자 간의 관계를 보존합니다. -- `ProgrammaticToolCallingTool()`이 있으면 프로그램이 실행되기 전이라도 모델 요청 재시도에 더 엄격한 재실행 안전성 경계가 적용됩니다. SDK는 이러한 요청에 대해 공급자 관리형 재시도와 WebSocket 사전 이벤트 재시도를 비활성화합니다. Runner 재시도 정책은 공급자의 지침에서 재실행이 안전하다고 명시적으로 표시한 경우에만 재시도합니다. `retry_policies.network_error()`만으로는 이 경계를 재정의하지 않습니다. -- 승인이 중요하거나 영향이 큰 도구는 더 큰 프로그램의 일부가 되기 전에 사람이 각 작업을 검토할 수 있도록 직접 호출로 유지하는 것이 일반적으로 더 좋습니다. 프로그램이 소유하는 호출이 승인을 위해 일시 중지되면 `RunState`를 통해 인터럽션(중단 처리)을 해결하고 평소처럼 원래 실행을 재개하세요. -- 프로그래밍 방식 도구 호출은 [호스티드 도구 검색](#hosted-tool-search)과 결합할 수 있습니다. 생성된 프로그램이 지연된 도구를 호출하려면 모델이 먼저 해당 도구를 로드해야 합니다. -- `program` 항목과 프로그램이 소유하는 일반 하위 도구 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 이에 대응하는 `program_output`은 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 호스티드 MCP 승인 요청과 도구 카탈로그는 대신 특수 MCP 항목과 스트림 이벤트를 사용합니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items) 및 [스트리밍](streaming.md#run-item-event-names)을 참조하세요. -- 완전한 동시 실행 재고 계획 코드 예제는 `examples/tools/programmatic_tool_calling.py`를 참조하세요. +- 프로그래밍 방식 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. `ProgrammaticToolCallingTool()` 및 `tool_choice="programmatic_tool_calling"`은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. +- 에이전트에 `ProgrammaticToolCallingTool()`을 최대 하나만 추가하세요. 또한 에이전트는 프로그래밍 방식으로 호출할 수 있는 도구를 하나 이상 노출하거나, 네임스페이스·지연 함수·지연된 호스티드 MCP 서버가 뒷받침하는 `ToolSearchTool()` 또는 불투명한 프롬프트 관리 도구 범위를 노출해야 합니다. 검색 가능한 범위가 없는 단독 `ToolSearchTool()`은 거부됩니다. +- `allowed_callers`는 도구를 호출할 수 있는 방식을 제어합니다. 생략하면 모델의 직접 호출만 허용됩니다. 프로그램 전용 접근에는 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`를 사용하세요. +- 선택적으로 사용할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸 및 패치 적용 도구는 `allowed_callers`을 직접 노출합니다. 호스티드 MCP와 Code Interpreter의 경우 `tool_config` 내부에 `allowed_callers`를 설정하세요. +- `@function_tool(allowed_callers=[...])`의 경우 Pydantic 모델, TypedDict 또는 데이터 클래스와 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 반환 값은 프로그램에 반환되기 전에 해당 스키마에 따라 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없으면 `output_type=...`를 사용하고, 엄격한 객체 스키마를 이미 가지고 있다면 저수준 우회 수단인 `output_json_schema={...}`을 사용하세요. `output_type`과 `output_json_schema`은 함께 사용할 수 없습니다. `str`, `Any`, `None` 반환 어노테이션은 출력 스키마를 생성하지 않습니다. 스키마가 적용되고 프로그램이 소유하는 호출의 경우 자유 형식 텍스트가 출력 스키마를 충족하지 않으므로 기본 실패 포매터가 비활성화됩니다. 따라서 스키마에 부합하는 JSON을 반환하는 사용자 지정 `failure_error_function`를 제공하지 않으면 핸들러 예외가 전파됩니다. +- 프로그램 소유 SDK 도구도 일반 Runner 수명 주기를 사용합니다. 도구 입출력 가드레일, 훅, 시간 제한, 동시성 제한, 승인, 세션 및 `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출과 프로그램 호출자 간의 관계를 유지합니다. +- `ProgrammaticToolCallingTool()`가 있으면 프로그램 실행 전이라도 모델 요청 재시도에 더 엄격한 재실행 안전 경계가 적용됩니다. SDK는 이러한 요청에 대해 공급자 관리 재시도와 WebSocket 사전 이벤트 재시도를 비활성화합니다. Runner 재시도 정책은 공급자의 지침에서 재실행이 안전하다고 명시적으로 표시된 경우에만 재시도합니다. `retry_policies.network_error()`만으로는 이 경계를 재정의하지 않습니다. +- 승인에 민감하거나 영향이 큰 도구는 일반적으로 직접 호출로 유지하여 더 큰 프로그램의 일부가 되기 전에 각 작업을 사람이 검토할 수 있게 하는 편이 좋습니다. 프로그램 소유 호출이 승인을 위해 일시 중지되면 `RunState`을 통해 인터럽션(중단 처리)을 해결하고 평소처럼 원래 실행을 재개하세요. +- 프로그래밍 방식 도구 호출은 [호스티드 도구 검색](#hosted-tool-search)과 함께 사용할 수 있습니다. 모델은 생성된 프로그램이 지연된 도구를 호출하기 전에 해당 도구를 불러와야 합니다. +- `program` 항목과 그에 속한 일반적인 프로그램 소유 하위 도구 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 이에 대응하는 `program_output`는 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 호스티드 MCP 승인 요청과 도구 카탈로그는 대신 특수 MCP 항목과 스트림 이벤트를 사용합니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items) 및 [스트리밍](streaming.md#run-item-event-names)을 참고하세요. +- 완전한 동시성 재고 계획 예제는 `examples/tools/programmatic_tool_calling.py`을 참고하세요. - 공식 플랫폼 가이드: [프로그래밍 방식 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) ### 호스티드 컨테이너 셸 및 스킬 -`ShellTool`은 OpenAI 호스티드 컨테이너 실행도 지원합니다. 모델이 로컬 런타임 대신 관리형 컨테이너에서 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. +`ShellTool`는 OpenAI 호스티드 컨테이너 실행도 지원합니다. 로컬 런타임 대신 관리형 컨테이너에서 모델이 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -215,7 +215,7 @@ result = await Runner.run( print(result.final_output) ``` -이후 실행에서 기존 컨테이너를 재사용하려면 `environment={"type": "container_reference", "container_id": "cntr_..."}`를 설정하세요. +이후 실행에서 기존 컨테이너를 재사용하려면 `environment={"type": "container_reference", "container_id": "cntr_..."}`을 설정하세요. 알아둘 사항: @@ -223,46 +223,46 @@ print(result.final_output) - `container_auto`는 요청을 위한 컨테이너를 프로비저닝하고, `container_reference`는 기존 컨테이너를 재사용합니다. - `container_auto`에는 `file_ids` 및 `memory_limit`도 포함할 수 있습니다. - `environment.skills`는 스킬 참조와 인라인 스킬 번들을 허용합니다. -- 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval` 또는 `on_approval`을 설정하지 마세요. +- 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval`, `on_approval`를 설정하지 마세요. - `network_policy`는 `disabled` 및 `allowlist` 모드를 지원합니다. -- 허용 목록 모드에서 `network_policy.domain_secrets`는 이름을 기준으로 도메인 범위의 비밀 값을 주입할 수 있습니다. -- 완전한 코드 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참조하세요. +- 허용 목록 모드에서는 `network_policy.domain_secrets`이 이름을 기준으로 도메인 범위의 시크릿을 주입할 수 있습니다. +- 완전한 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참고하세요. - OpenAI 플랫폼 가이드: [셸](https://platform.openai.com/docs/guides/tools-shell) 및 [스킬](https://platform.openai.com/docs/guides/tools-skills) ## 로컬 런타임 도구 -로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 언제 호출할지는 계속 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. +로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 호출 시점을 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. -`ComputerTool`과 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`은 두 모드를 모두 지원합니다. 관리형 실행을 원하면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. +`ComputerTool` 및 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`는 두 모드를 모두 지원합니다. 관리형 실행을 원하면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. -로컬 런타임 도구를 사용하려면 구현을 제공해야 합니다. +로컬 런타임 도구에는 다음 구현을 제공해야 합니다. - [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 활성화하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현하세요. -- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행을 모두 지원하는 최신 셸 도구입니다. +- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행 모두를 위한 최신 셸 도구입니다. - [`LocalShellTool`][agents.tool.LocalShellTool]: 레거시 로컬 셸 통합입니다. -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: diff를 로컬에 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현하세요. -- 로컬 셸 스킬은 `ShellTool(environment={"type": "local", "skills": [...]})`에서 사용할 수 있습니다. +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현하세요. +- 로컬 셸 스킬은 `ShellTool(environment={"type": "local", "skills": [...]})`과 함께 사용할 수 있습니다. -셸 작업 제한 시간에는 유한한 제한 시간을 나타내는 양의 정수 밀리초를 사용합니다. 0은 실행기 구현 전반에서 이식 가능한 의미가 없으므로, SDK는 로컬 `ShellTool` 실행기를 호출하기 전에 `0`과 `None`을 모두 명시적인 제한 시간이 없는 것으로 처리합니다. 다른 값은 실행기를 호출하기 전에 거부됩니다. 이는 제한 시간 필드에만 해당하며, `max_output_length=0`은 캡처된 빈 출력을 요청하는 값으로 계속 지원됩니다. +셸 작업 시간 제한은 유한한 시간 제한에 양의 정수 밀리초를 사용합니다. 0은 실행기 구현 간에 이식 가능한 의미를 갖지 않으므로 SDK는 로컬 `ShellTool` 실행기를 호출하기 전에 `0`과 `None`를 모두 명시적 시간 제한 없음으로 처리합니다. 그 밖의 값은 실행기 호출 전에 거부됩니다. 이는 시간 제한 필드에만 해당합니다. `max_output_length=0`는 캡처된 빈 출력 요청으로 계속 지원됩니다. -### `ComputerTool`과 Responses 컴퓨터 도구 +### ComputerTool과 Responses 컴퓨터 도구 -`ComputerTool`은 계속 로컬 하네스 역할을 합니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API 컴퓨터 인터페이스에 매핑합니다. +`ComputerTool`는 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API 컴퓨터 인터페이스에 매핑합니다. -명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델은 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`를 유지합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. +명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델에 대한 요청의 경우 SDK는 계속해서 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`을 전송합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. - 모델: `computer-use-preview` -> `gpt-5.5` - 도구 선택자: `computer_use_preview` -> `computer` -- 컴퓨터 호출 형식: 각 `computer_call`에 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` +- 컴퓨터 호출 형식: `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` - 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 불필요 -SDK는 실제 Responses 요청의 유효 모델을 기준으로 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하고 프롬프트가 모델을 소유하므로 요청에서 `model`을 생략하는 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않으면 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. +SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하고 프롬프트에서 모델을 소유하므로 요청에서 `model`을 생략한 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않는 한 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. -[`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`가 모두 허용되며 유효 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. +[`ComputerTool`][agents.tool.ComputerTool]가 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`이 모두 허용되며 유효 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`가 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. -`ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩터리를 기반으로 할 때는 이 차이가 중요합니다. GA `computer` 페이로드는 직렬화 시 `environment` 또는 크기가 필요하지 않으므로 해결되지 않은 팩터리도 사용할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`를 전송할 수 있도록 해결된 `Computer` 또는 `AsyncComputer` 인스턴스가 계속 필요합니다. +이 차이는 `ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩터리를 기반으로 할 때 중요합니다. GA `computer` 페이로드는 직렬화 시점에 `environment`이나 크기가 필요하지 않으므로 팩터리가 `Computer` 또는 `AsyncComputer` 인스턴스를 생성하기 전에 직렬화할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`을 전송할 수 있도록 확인된 `Computer` 또는 `AsyncComputer` 인스턴스가 여전히 필요합니다. -런타임에서 두 경로는 계속 동일한 로컬 하네스를 사용합니다. 프리뷰 응답은 단일 `action`이 있는 `computer_call` 항목을 생성합니다. `gpt-5.5`는 일괄 처리된 `actions[]`를 생성할 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`를 참조하세요. +런타임에서 두 경로는 계속 동일한 로컬 하네스를 사용합니다. 프리뷰 응답은 단일 `action`를 포함하는 `computer_call` 항목을 내보냅니다. `gpt-5.5`은 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`을 참고하세요. ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -308,16 +308,16 @@ agent = Agent( 모든 Python 함수를 도구로 사용할 수 있습니다. Agents SDK가 도구를 자동으로 설정합니다. -- 도구 이름은 Python 함수의 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다. +- 도구 이름은 Python 함수 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다. - 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 제공할 수 있습니다. -- 함수 입력 스키마는 함수의 인수에서 자동으로 생성됩니다. +- 함수 입력 스키마는 함수 인수에서 자동으로 생성됩니다. - 비활성화하지 않는 한 각 입력의 설명은 함수의 docstring에서 가져옵니다. -`@tool`로 생성된 도구는 읽기 전용 `__wrapped__` 속성을 통해 원래 Python 호출 가능 객체를 노출합니다. 이는 검사 및 테스트에 유용하지만, 직접 호출하면 스키마 검증, 컨텍스트 주입, 가드레일, 제한 시간, 실패 처리, 트레이싱을 포함한 도구 런타임 파이프라인을 우회합니다. 직접 생성한 `FunctionTool` 인스턴스는 `__wrapped__`를 노출하지 않습니다. +`@tool`로 생성한 도구는 읽기 전용 `__wrapped__` 속성을 통해 원래 Python 호출 가능 객체를 노출합니다. 이는 검사와 테스트에 유용하지만, 이를 직접 호출하면 스키마 검증, 컨텍스트 주입, 가드레일, 시간 제한, 실패 처리, 트레이싱을 포함한 도구 런타임 파이프라인을 우회합니다. 직접 만든 `FunctionTool` 인스턴스는 `__wrapped__`을 노출하지 않습니다. -함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하고, docstring을 파싱하기 위해 [`griffe`](https://mkdocstrings.github.io/griffe/)를, 스키마 생성을 위해 `pydantic`을 사용합니다. +함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하고, docstring 구문 분석에는 [`griffe`](https://mkdocstrings.github.io/griffe/), 스키마 생성에는 `pydantic`을 사용합니다. -OpenAI Responses 모델을 사용할 때 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`이 로드할 때까지 함수 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정과 제약 조건은 [호스티드 도구 검색](#hosted-tool-search)을 참조하세요. +OpenAI Responses 모델을 사용하는 경우 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`가 불러올 때까지 함수 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정과 제약 조건은 [호스티드 도구 검색](#hosted-tool-search)을 참고하세요. ```python import json @@ -370,12 +370,12 @@ for tool in agent.tools: ``` -1. 모든 Python 유형을 함수의 인수로 사용할 수 있으며 함수는 동기 또는 비동기일 수 있습니다. +1. 모든 Python 유형을 함수의 인수로 사용할 수 있으며, 함수는 동기 또는 비동기일 수 있습니다. 2. docstring이 있으면 설명과 인수 설명을 가져오는 데 사용됩니다. -3. 함수는 선택적으로 `context`를 받을 수 있으며 반드시 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의도 설정할 수 있습니다. -4. 데코레이팅된 함수를 도구 목록에 전달할 수 있습니다. +3. 함수는 선택적으로 실행 컨텍스트를 첫 번째 인수로 받을 수 있습니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의도 설정할 수 있습니다. +4. 데코레이트된 함수를 도구 목록에 전달할 수 있습니다. -??? note "출력을 보려면 펼치기" +??? note "출력 펼쳐 보기" ``` fetch_weather @@ -445,22 +445,22 @@ for tool in agent.tools: } ``` -### 함수 도구의 이미지 또는 파일 반환 +### 함수 도구에서 이미지 또는 파일 반환 -텍스트 출력 외에도 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 다음 중 하나를 반환할 수 있습니다. +텍스트 출력뿐 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 이를 위해 다음 중 하나를 반환할 수 있습니다. - 이미지: [`ToolOutputImage`][agents.tool.ToolOutputImage] 또는 TypedDict 버전인 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict] - 파일: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent] 또는 TypedDict 버전인 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict] -- 텍스트: 문자열, 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText]나 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] +- 텍스트: 문자열, 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText] 또는 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] ### 사용자 지정 함수 도구 -Python 함수를 도구로 사용하지 않으려는 경우도 있습니다. 원한다면 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. +Python 함수를 도구로 사용하지 않으려는 경우도 있습니다. 원하는 경우 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. - `name` - `description` -- `params_json_schema`: 인수의 JSON 스키마 -- `on_invoke_tool`: [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형식의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수 +- 인수의 JSON 스키마인 `params_json_schema` +- [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형식의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` ```python from typing import Any @@ -493,12 +493,12 @@ tool = FunctionTool( ) ``` -### 자동 인수 및 docstring 파싱 +### 자동 인수 및 docstring 구문 분석 -앞서 설명했듯이 도구 스키마를 추출하기 위해 함수 시그니처를 자동으로 파싱하고, 도구와 개별 인수의 설명을 추출하기 위해 docstring을 파싱합니다. 관련 참고 사항은 다음과 같습니다. +앞서 설명한 것처럼 함수 시그니처를 자동으로 구문 분석하여 도구 스키마를 추출하고, docstring을 구문 분석하여 도구와 개별 인수의 설명을 추출합니다. 다음 사항을 참고하세요. -1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용하여 인수 유형을 파악하고, 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 타입, Pydantic 모델, TypedDict 등을 포함한 대부분의 유형을 지원합니다. -2. docstring 파싱에는 `griffe`를 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 시도하지만 최선의 방식으로만 처리되므로, `function_tool`을 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. Google 스타일 docstring에서는 요약 텍스트 바로 뒤에 빈 줄 없이 나오는 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 파서가 허용합니다. +1. 시그니처 구문 분석은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용해 인수 유형을 파악하고 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 타입, Pydantic 모델, TypedDict 등 대부분의 유형을 지원합니다. +2. docstring 구문 분석에는 `griffe`을 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 시도하지만 최선형 방식이므로 `function_tool`를 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`으로 설정하여 docstring 구문 분석을 비활성화할 수도 있습니다. Google 스타일 docstring의 경우 파서는 요약 텍스트 바로 뒤에 빈 줄 없이 이어지는 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 허용합니다. 스키마 추출 코드는 [`agents.function_schema`][]에 있습니다. @@ -522,9 +522,9 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr return f"Score recorded: {score}" ``` -### 함수 도구 제한 시간 +### 함수 도구 시간 제한 -`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구의 호출별 제한 시간을 설정할 수 있습니다. +`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구의 호출별 시간 제한을 설정할 수 있습니다. ```python import asyncio @@ -545,13 +545,13 @@ agent = Agent( ) ``` -제한 시간에 도달했을 때의 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델이 볼 수 있는 제한 시간 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. +시간 제한에 도달하면 기본 동작은 `timeout_behavior="error_as_result"`이며, 모델에 표시되는 시간 초과 메시지(예: `Tool 'slow_lookup' timed out after 2 seconds.`)를 전송합니다. -제한 시간 처리를 제어할 수 있습니다. +시간 초과 처리를 제어할 수 있습니다. -- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 제한 시간 메시지를 반환합니다. +- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 시간 초과 메시지를 반환합니다. - `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]를 발생시키고 실행을 실패 처리합니다. -- `timeout_error_function=...`: `error_as_result`를 사용할 때 제한 시간 메시지를 사용자 지정합니다. +- `error_as_result`을 사용할 때 `timeout_error_function=...`로 시간 초과 메시지를 사용자 지정합니다. ```python import asyncio @@ -575,15 +575,15 @@ except ToolTimeoutError as e: !!! note - 제한 시간 구성은 비동기 `@function_tool` 핸들러에만 지원됩니다. + 시간 제한 구성은 비동기 `@function_tool` 핸들러에서만 지원됩니다. -### 함수 도구의 오류 처리 +### 함수 도구 오류 처리 -`@function_tool`을 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 도구 호출이 중단되는 경우 이 함수가 LLM에 오류 응답을 제공합니다. +`@function_tool`를 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이는 도구 호출이 중단되는 경우 LLM에 오류 응답을 제공하는 함수입니다. -- 기본적으로, 즉 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`을 실행합니다. -- 자체 오류 함수를 전달하면 해당 함수를 대신 실행하고 응답을 LLM에 전송합니다. -- `None`을 명시적으로 전달하면 모든 도구 호출 오류가 다시 발생하므로 직접 처리할 수 있습니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`일 수 있고, 코드가 중단된 경우 `UserError`일 수 있습니다. +- 기본적으로, 즉 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`이 실행됩니다. +- 자체 오류 함수를 전달하면 해당 함수가 대신 실행되고 응답이 LLM에 전송됩니다. +- `None`을 명시적으로 전달하면 모든 도구 호출 오류가 다시 발생하여 직접 처리할 수 있습니다. 모델이 유효하지 않은 JSON을 생성한 경우 `ModelBehaviorError`, 코드 실행이 중단된 경우 `UserError` 등이 발생할 수 있습니다. ```python from agents import RunContextWrapper @@ -607,11 +607,11 @@ def get_user_profile(user_id: str) -> str: ``` -`FunctionTool` 객체를 수동으로 생성하는 경우 `on_invoke_tool` 함수 내에서 오류를 처리해야 합니다. +`FunctionTool` 객체를 수동으로 생성하는 경우 `on_invoke_tool` 함수 내부에서 오류를 처리해야 합니다. ## Agents as tools -일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 구성할 수 있습니다. 에이전트를 도구로 모델링하면 이를 구현할 수 있습니다. +일부 워크플로에서는 제어를 핸드오프하는 대신 중앙 에이전트가 특화된 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. ```python import asyncio @@ -657,9 +657,9 @@ if __name__ == "__main__": ### 도구 에이전트 사용자 지정 -`agent.as_tool` 함수는 에이전트를 도구로 쉽게 변환할 수 있는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval` 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`를 사용하는 구조화된 입력도 지원합니다. +`agent.as_tool`은 에이전트를 도구로 변환하는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`을 통한 구조화된 입력도 지원합니다. -상태 옵션은 도구 호출로 시작되는 중첩 에이전트 실행을 구성합니다. 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리형 기록을 공유하려면 두 실행에 동일한 `session`을 명시적으로 전달하세요. `Runner.run`과 마찬가지로 중첩 실행에는 클라이언트 관리형 `session` 또는 `previous_response_id`나 `conversation_id`를 통한 서버 관리형 연속 실행 중 하나의 상태 전략을 선택하세요. +상태 옵션은 도구 호출로 시작되는 중첩 에이전트 실행을 구성합니다. 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리 기록을 공유하려면 동일한 `session`를 두 실행 모두에 명시적으로 전달하세요. `Runner.run`와 마찬가지로 중첩 실행에는 클라이언트 관리 `session` 또는 `previous_response_id`이나 `conversation_id`을 통한 서버 관리 연속 처리 중 하나의 상태 전략을 선택하세요. ```python from agents.decorators import tool @@ -683,13 +683,13 @@ async def run_my_agent() -> str: ### 도구 에이전트의 구조화된 입력 -기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 예상하지만, `parameters`에 Pydantic 모델 또는 데이터 클래스 유형을 전달하여 구조화된 스키마를 노출할 수 있습니다. +기본적으로 `Agent.as_tool()`는 하나의 문자열 필드 `input`(`{"input": "..."}`)이 있는 객체를 예상하지만, Pydantic 모델 유형 또는 데이터 클래스 유형인 `parameters`를 전달하여 구조화된 스키마를 노출할 수 있습니다. 추가 옵션: -- `include_input_schema=True`는 생성된 중첩 입력에 전체 JSON 스키마를 포함합니다. +- `include_input_schema=True`은 생성된 중첩 입력에 전체 JSON Schema를 포함합니다. - `input_builder=...`를 사용하면 구조화된 도구 인수를 중첩 에이전트 입력으로 변환하는 방식을 완전히 사용자 지정할 수 있습니다. -- `RunContextWrapper.tool_input`에는 중첩 실행 컨텍스트 내부에서 파싱된 구조화 페이로드가 포함됩니다. +- `RunContextWrapper.tool_input`에는 중첩 실행 컨텍스트 내부에서 구문 분석된 구조화 페이로드가 포함됩니다. ```python from pydantic import BaseModel, Field @@ -709,21 +709,21 @@ translator_tool = translator_agent.as_tool( ) ``` -완전한 실행 가능 코드 예제는 `examples/agent_patterns/agents_as_tools_structured.py`를 참조하세요. +완전한 실행 가능 예제는 `examples/agent_patterns/agents_as_tools_structured.py`을 참고하세요. ### 도구 에이전트의 승인 게이트 -`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요하면 실행이 일시 중지되고 대기 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`를 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 가이드](human_in_the_loop.md)를 참조하세요. +`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요하면 실행이 일시 중지되고 대기 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`을 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참고하세요. ### 사용자 지정 출력 추출 -경우에 따라 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 경우에 유용합니다. +특정한 경우 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 경우에 유용합니다. - 하위 에이전트의 채팅 기록에서 특정 정보(예: JSON 페이로드) 추출 -- 에이전트의 최종 답변 변환 또는 형식 변경(예: Markdown을 일반 텍스트나 CSV로 변환) -- 출력 검증 또는 에이전트의 응답이 누락되었거나 형식이 잘못된 경우 대체 값 제공 +- 에이전트의 최종 답변 변환 또는 재구성(예: Markdown을 일반 텍스트나 CSV로 변환) +- 에이전트의 응답이 누락되었거나 형식이 잘못된 경우 출력 검증 또는 대체 값 제공 -`as_tool` 메서드에 `custom_output_extractor` 인수를 제공하여 이를 수행할 수 있습니다. +`as_tool` 메서드에 `custom_output_extractor` 인수를 제공하여 이를 구현할 수 있습니다. ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -742,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 이는 중첩 결과를 후처리하면서 외부 도구 이름, 호출 ID 또는 원문 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참조하세요. +사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 중첩 결과를 후처리하는 동안 외부 도구 이름, 호출 ID 또는 raw 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참고하세요. -### 중첩 에이전트 실행의 스트리밍 +### 중첩 에이전트 실행 스트리밍 -스트림이 완료된 후에도 최종 출력을 반환하면서 중첩 에이전트가 생성하는 스트리밍 이벤트를 수신하려면 `on_stream` 콜백을 `as_tool`에 전달하세요. +스트림이 완료되면 최종 출력을 반환하면서 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하려면 `as_tool`에 `on_stream` 콜백을 전달하세요. ```python from agents import AgentToolStreamEvent @@ -766,15 +766,15 @@ billing_agent_tool = billing_agent.as_tool( 예상 동작: -- 이벤트 유형은 `StreamEvent["type"]`의 `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event`와 동일합니다. -- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드에서 실행되고, 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. +- 이벤트 유형은 `StreamEvent["type"]`와 동일합니다. `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event` +- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드에서 실행되며, 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. - 핸들러는 동기 또는 비동기일 수 있으며, 각 이벤트는 도착하는 순서대로 전달됩니다. -- 도구가 모델의 도구 호출을 통해 호출되면 `tool_call`이 존재합니다. 직접 호출에서는 `None`일 수 있습니다. -- 완전한 실행 가능 샘플은 `examples/agent_patterns/agents_as_tools_streaming.py`를 참조하세요. +- 모델 도구 호출을 통해 도구가 호출되면 `tool_call`가 존재합니다. 직접 호출에서는 `None`일 수 있습니다. +- 완전한 실행 가능 샘플은 `examples/agent_patterns/agents_as_tools_streaming.py`을 참고하세요. ### 조건부 도구 활성화 -`is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 환경 설정 또는 런타임 조건에 따라 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. +`is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 기본 설정 또는 런타임 조건을 기준으로 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. ```python import asyncio @@ -831,11 +831,11 @@ asyncio.run(main()) `is_enabled` 매개변수는 다음을 허용합니다. -- **부울 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) -- **호출 가능 함수**: `(context, agent)`를 받아 부울 값을 반환하는 함수 +- **불리언 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) +- **호출 가능 함수**: `(context, agent)`을 받아 불리언 값을 반환하는 함수 - **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 -비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 경우에 유용합니다. +비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음 용도에 유용합니다. - 사용자 권한에 따른 기능 게이팅 - 환경별 도구 가용성(개발 환경과 프로덕션 환경) @@ -844,9 +844,9 @@ asyncio.run(main()) ## 실험적 기능: Codex 도구 -`codex_tool`은 에이전트가 도구 호출 중 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있도록 Codex CLI를 래핑합니다. 이 인터페이스는 실험적이며 변경될 수 있습니다. +`codex_tool`는 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있게 합니다. 이 인터페이스는 실험적이며 변경될 수 있습니다. -기본 에이전트가 현재 실행을 벗어나지 않고 범위가 제한된 워크스페이스 작업을 Codex에 위임하도록 하려면 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함되면 각 도구에 고유한 이름을 사용해야 합니다. +기본 에이전트가 현재 실행을 벗어나지 않고 범위가 제한된 워크스페이스 작업을 Codex에 위임하도록 하려면 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함된 경우 각 도구에 고유한 이름을 사용해야 합니다. ```python from agents import Agent @@ -877,31 +877,31 @@ agent = Agent( 다음 옵션 그룹부터 시작하세요. -- 실행 대상: `sandbox_mode`와 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`를 설정하세요. -- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 수준, 승인 정책, 추가 디렉터리, 네트워크 액세스, 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`를 사용하는 것이 좋습니다. -- 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal` 같은 턴별 동작을 구성합니다. -- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }`가 포함된 `inputs` 항목이 하나 이상 있어야 합니다. `output_schema`를 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. +- 실행 범위: `sandbox_mode` 및 `working_directory`은 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`을 설정하세요. +- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 강도, 승인 정책, 추가 디렉터리, 네트워크 접근 및 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`을 우선 사용하세요. +- 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal`와 같은 턴별 동작을 구성합니다. +- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }`을 포함하는 `inputs` 항목이 하나 이상 있어야 합니다. `output_schema`을 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. -스레드 재사용과 영속성은 별도의 제어 옵션입니다. +스레드 재사용과 지속성은 별도의 제어 항목입니다. -- `persist_session=True`는 동일한 도구 인스턴스를 반복 호출할 때 하나의 Codex 스레드를 재사용합니다. -- `use_run_context_thread_id=True`는 동일한 변경 가능 컨텍스트 객체를 공유하는 여러 실행에서 스레드 ID를 실행 컨텍스트에 저장하고 재사용합니다. +- `persist_session=True`는 동일한 도구 인스턴스의 반복 호출에 하나의 Codex 스레드를 재사용합니다. +- `use_run_context_thread_id=True`은 동일한 변경 가능 컨텍스트 객체를 공유하는 여러 실행에서 실행 컨텍스트에 스레드 ID를 저장하고 재사용합니다. - 스레드 ID 우선순위는 호출별 `thread_id`, 실행 컨텍스트 스레드 ID(활성화된 경우), 구성된 `thread_id` 옵션 순입니다. -- 기본 실행 컨텍스트 키는 `name="codex"`의 경우 `codex_thread_id`이고, `name="codex_"`의 경우 `codex_thread_id_`입니다. `run_context_thread_id_key`를 사용하여 재정의할 수 있습니다. +- 기본 실행 컨텍스트 키는 `name="codex"`의 경우 `codex_thread_id`, `name="codex_"`의 경우 `codex_thread_id_`입니다. `run_context_thread_id_key`로 재정의할 수 있습니다. 런타임 구성: -- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`를 전달하세요. +- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`을 전달하세요. - 런타임: `codex_options.base_url`은 CLI 기본 URL을 재정의합니다. -- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`를 설정하세요. 그렇지 않으면 SDK는 `PATH`에서 `codex`를 확인한 후 번들로 제공되는 벤더 바이너리를 사용합니다. -- 환경: `codex_options.env`는 하위 프로세스 환경을 완전히 제어합니다. 이를 제공하면 하위 프로세스는 `os.environ`을 상속하지 않습니다. -- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`부터 `67108864`까지이며 기본값은 `8388608`입니다. +- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`을 설정하세요. 그렇지 않으면 SDK가 `PATH`에서 `codex`를 확인한 다음 번들로 제공되는 공급업체 바이너리를 사용합니다. +- 환경: `codex_options.env`은 하위 프로세스 환경을 완전히 제어합니다. 이 값을 제공하면 하위 프로세스가 `os.environ`을 상속하지 않습니다. +- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`부터 `67108864`까지이며, 기본값은 `8388608`입니다. - 스트리밍: `on_stream`은 스레드/턴 수명 주기 이벤트와 항목 이벤트(`reasoning`, `command_execution`, `mcp_tool_call`, `file_change`, `web_search`, `todo_list`, `error` 항목 업데이트)를 수신합니다. -- 출력: 결과에는 `response`, `usage`, `thread_id`가 포함되며 사용량은 `RunContextWrapper.usage`에 추가됩니다. +- 출력: 결과에는 `response`, `usage`, `thread_id`이 포함되며, 사용량은 `RunContextWrapper.usage`에 추가됩니다. 참조: - [Codex 도구 API 레퍼런스](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions 레퍼런스](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions 레퍼런스](ref/extensions/experimental/codex/turn_options.md) -- 완전한 실행 가능 샘플은 `examples/tools/codex.py` 및 `examples/tools/codex_same_thread.py`를 참조하세요. \ No newline at end of file +- 완전한 실행 가능 샘플은 `examples/tools/codex.py` 및 `examples/tools/codex_same_thread.py`을 참고하세요. \ No newline at end of file diff --git a/docs/ko/tracing.md b/docs/ko/tracing.md index 2a03cbf13d..d17d7aa7e1 100644 --- a/docs/ko/tracing.md +++ b/docs/ko/tracing.md @@ -4,51 +4,51 @@ search: --- # 트레이싱 -Agents SDK에는 에이전트 실행 중 발생하는 LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지 포괄적으로 기록하는 트레이싱 기능이 내장되어 있습니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고, 시각화하고, 모니터링할 수 있습니다. +Agents SDK에는 에이전트 실행 중 발생하는 LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지 포괄적으로 기록하는 트레이싱 기능이 기본으로 포함되어 있습니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고 시각화하며 모니터링할 수 있습니다. !!!note - 트레이싱은 기본적으로 활성화되어 있습니다. 다음과 같은 세 가지 일반적인 방법으로 비활성화할 수 있습니다. + 트레이싱은 기본적으로 활성화되어 있습니다. 다음 세 가지 일반적인 방법으로 비활성화할 수 있습니다. - 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역적으로 비활성화할 수 있습니다 - 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]를 사용하여 트레이싱을 전역적으로 비활성화할 수 있습니다 - 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다 + 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역으로 비활성화할 수 있습니다 + 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]을 사용하여 트레이싱을 전역으로 비활성화할 수 있습니다 + 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`으로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다 -***OpenAI API를 사용하며 제로 데이터 보존(Zero Data Retention, ZDR) 정책에 따라 운영되는 조직에서는 트레이싱을 사용할 수 없습니다.*** +***OpenAI API를 데이터 미보존(Zero Data Retention, ZDR) 정책에 따라 사용하는 조직에서는 트레이싱을 사용할 수 없습니다.*** ## 트레이스와 스팬 -- **트레이스**는 하나의 "워크플로"에 대한 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 갖습니다. - - `workflow_name`: 논리적 워크플로 또는 앱입니다. 예를 들면 "코드 생성"이나 "고객 서비스"입니다. - - `trace_id`: 트레이스의 고유 ID입니다. 값을 전달하지 않으면 자동으로 생성됩니다. 형식은 `trace_<32_alphanumeric>`이어야 합니다. +- **트레이스**는 하나의 "워크플로"에서 이루어지는 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 가집니다. + - `workflow_name`: 논리적 워크플로 또는 앱의 이름입니다. 예를 들면 "코드 생성" 또는 "고객 서비스"입니다. + - `trace_id`: 트레이스의 고유 ID입니다. 전달하지 않으면 자동으로 생성됩니다. 형식은 `trace_<32_alphanumeric>`이어야 합니다. - `group_id`: 동일한 대화의 여러 트레이스를 연결하기 위한 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. - `disabled`: True이면 트레이스가 기록되지 않습니다. - - `metadata`: 트레이스의 선택적 메타데이터 -- **스팬**은 시작 및 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음이 포함됩니다. + - `metadata`: 트레이스의 선택적 메타데이터입니다. +- **스팬**은 시작 및 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음 항목이 있습니다. - `started_at` 및 `ended_at` 타임스탬프 - - 소속된 트레이스를 나타내는 `trace_id` - - 이 스팬의 상위 스팬을 가리키는 `parent_id`(있는 경우) - - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트에 관한 정보가 포함되고, `GenerationSpanData`에는 LLM 생성에 관한 정보가 포함됩니다. + - 자신이 속한 트레이스를 나타내는 `trace_id` + - 이 스팬의 부모 스팬이 있는 경우 이를 가리키는 `parent_id` + - 스팬에 대한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트에 대한 정보가, `GenerationSpanData`에는 LLM 생성에 대한 정보가 포함됩니다. ## 기본 트레이싱 SDK는 기본적으로 다음 항목을 트레이싱합니다. -- 전체 `Runner.{run, run_sync, run_streamed}()`은 `trace()`로 래핑됩니다. +- 전체 `Runner.{run, run_sync, run_streamed}()`은 `trace()`으로 래핑됩니다. - 각 러너 호출은 `task_span()`으로 래핑됩니다. - 각 모델 턴은 `turn_span()`으로 래핑됩니다. -- 에이전트가 실행될 때마다 `agent_span()`으로 래핑됩니다 -- LLM 생성은 `generation_span()`으로 래핑됩니다 +- 에이전트가 실행될 때마다 `agent_span()`로 래핑됩니다 +- LLM 생성은 `generation_span()`로 래핑됩니다 - 각 함수 도구 호출은 `function_span()`으로 래핑됩니다 -- 가드레일은 `guardrail_span()`으로 래핑됩니다 -- 핸드오프는 `handoff_span()`으로 래핑됩니다 +- 가드레일은 `guardrail_span()`로 래핑됩니다 +- 핸드오프는 `handoff_span()`로 래핑됩니다 - 오디오 입력(음성 텍스트 변환)은 `transcription_span()`으로 래핑됩니다 -- 오디오 출력(텍스트 음성 변환)은 `speech_span()`으로 래핑됩니다 -- 관련 오디오 스팬은 `speech_group_span()` 아래에 배치될 수 있습니다 +- 오디오 출력(텍스트 음성 변환)은 `speech_span()`로 래핑됩니다 +- SDK는 관련 오디오 스팬의 부모로 `speech_group_span()`을 지정할 수 있습니다 -기본적으로 트레이스의 이름은 "Agent workflow"입니다. `trace`를 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]를 사용하여 이름과 기타 속성을 구성할 수도 있습니다. +기본적으로 트레이스 이름은 리터럴 문자열 `Agent workflow`입니다. `trace`을 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]을 사용하여 이름과 기타 속성을 구성할 수도 있습니다. -더 간결한 계층 구조가 필요하다면 실행 시 자동 태스크 및 턴 스팬을 비활성화하세요. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. +더 간결한 계층 구조가 필요하다면 실행의 자동 태스크 및 턴 스팬을 비활성화하세요. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -또한 [사용자 지정 트레이싱 프로세서](#custom-tracing-processors)를 설정하여 트레이스를 다른 대상으로 보낼 수 있습니다. 이 대상은 기존 대상을 대체하거나 보조 대상으로 사용할 수 있습니다. +또한 트레이스를 다른 대상으로 전송하도록 [사용자 지정 트레이싱 프로세서](#custom-tracing-processors)를 설정할 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 추가할 수 있습니다. ## 장기 실행 워커와 즉시 내보내기 -기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 인메모리 큐가 크기 임계값에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 태스크와 같은 장기 실행 워커에서는 별도의 코드 없이도 일반적으로 트레이스가 자동으로 내보내집니다. 다만 각 작업이 완료된 직후 트레이스 대시보드에 나타나지 않을 수 있습니다. +기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내거나, 인메모리 큐가 크기 트리거에 도달하면 더 일찍 내보내며, 프로세스가 종료될 때 최종 플러시도 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 태스크와 같은 장기 실행 워커에서는 일반적으로 추가 코드 없이 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후 트레이스 대시보드에 표시되지 않을 수 있습니다. -작업 단위가 끝날 때 즉시 전달되도록 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]를 호출하세요. +작업 단위가 끝날 때 즉시 전달되도록 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]을 호출하세요. ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces]는 현재 버퍼링된 트레이스와 스팬을 모두 내보낼 때까지 차단하므로, 일부만 구성된 트레이스가 플러시되지 않도록 `trace()`가 종료된 후 호출하세요. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. +[`flush_traces()`][agents.tracing.flush_traces]은 현재 버퍼링된 트레이스와 스팬을 모두 내보낼 때까지 실행을 차단합니다. 따라서 부분적으로 생성된 트레이스가 플러시되지 않도록 `trace()`가 닫힌 후 호출하세요. 기본 내보내기 지연을 허용할 수 있다면 이 호출을 생략할 수 있습니다. ## 상위 수준 트레이스 -여러 `run()` 호출을 하나의 트레이스에 포함해야 할 때가 있습니다. 전체 코드를 `trace()`로 래핑하면 됩니다. +여러 `run()` 호출을 하나의 트레이스에 포함하려는 경우가 있습니다. 전체 코드를 `trace()`로 래핑하면 됩니다. ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 두 `Runner.run` 호출이 `with trace()`로 래핑되어 있으므로, 두 개의 트레이스를 생성하는 대신 각 실행이 전체 트레이스의 일부가 됩니다. +1. 두 `Runner.run` 호출이 `with trace()`로 래핑되므로 각 실행이 별도의 트레이스를 생성하는 대신 두 실행 모두 하나의 전체 트레이스에 포함됩니다. ## 트레이스 생성 [`trace()`][agents.tracing.trace] 함수를 사용하여 트레이스를 생성할 수 있습니다. 트레이스는 시작하고 종료해야 합니다. 다음 두 가지 방법을 사용할 수 있습니다. -1. **권장 방식**: `with trace(...) as my_trace`와 같이 트레이스를 컨텍스트 관리자로 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. +1. **권장**: 트레이스를 컨텍스트 관리자로 사용합니다. 즉, `with trace(...) as my_trace`을 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. 2. [`trace.start()`][agents.tracing.Trace.start]와 [`trace.finish()`][agents.tracing.Trace.finish]를 직접 호출할 수도 있습니다. -현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 수동으로 시작하거나 종료하는 경우 현재 트레이스를 업데이트하려면 `start()`/`finish()`에 `mark_as_current`와 `reset_current`를 전달해야 합니다. +현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하고 종료하는 경우 현재 트레이스를 업데이트하려면 `start()`에 `mark_as_current`를 전달하고 `finish()`에 `reset_current`을 전달하세요. ## 스팬 생성 다양한 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 직접 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적할 수 있도록 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. -스팬은 자동으로 현재 트레이스에 포함되며 가장 가까운 현재 스팬 아래에 중첩됩니다. 현재 스팬은 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. +스팬은 자동으로 현재 트레이스에 포함되며, Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)을 통해 추적되는 가장 가까운 현재 스팬 아래에 중첩됩니다. ## 민감한 데이터 일부 스팬은 잠재적으로 민감한 데이터를 캡처할 수 있습니다. -`generation_span()`은 LLM 생성의 입력과 출력을 저장하고, `function_span()`은 함수 호출의 입력과 출력을 저장합니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. +`generation_span()`는 LLM 생성의 입력과 출력을 저장하고, `function_span()`은 함수 호출의 입력과 출력을 저장합니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. -마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 Base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. +마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. -기본적으로 `trace_include_sensitive_data`는 `True`입니다. 코드를 변경하지 않고 기본값을 설정하려면 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 됩니다. +기본적으로 `trace_include_sensitive_data`은 `True`입니다. 코드 없이 기본값을 설정하려면 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 됩니다. ## 사용자 지정 트레이싱 프로세서 트레이싱의 상위 수준 아키텍처는 다음과 같습니다. -- 초기화 시 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.provider.TraceProvider]를 생성합니다. -- 트레이스와 스팬을 배치 단위로 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]에 전송하는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]를 사용하여 `TraceProvider`를 구성합니다. `BackendSpanExporter`는 스팬과 트레이스를 배치 단위로 OpenAI 백엔드에 내보냅니다. +- 초기화할 때 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.provider.TraceProvider]를 생성합니다. +- 트레이스와 스팬을 배치 단위로 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]에 전송하는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]로 `TraceProvider`를 구성합니다. 이 익스포터는 스팬과 트레이스를 OpenAI 백엔드로 일괄 내보냅니다. -트레이스를 대체 또는 추가 백엔드로 전송하거나 익스포터 동작을 변경하는 등 이 기본 설정을 사용자 지정하려면 다음 두 가지 방법을 사용할 수 있습니다. +트레이스를 대체 또는 추가 백엔드로 전송하거나 익스포터 동작을 수정하는 등 이 기본 설정을 사용자 지정하는 방법은 두 가지입니다. -1. [`add_trace_processor()`][agents.tracing.add_trace_processor]를 사용하면 준비된 트레이스와 스팬을 수신할 **추가** 트레이싱 프로세서를 등록할 수 있습니다. 이를 통해 트레이스를 OpenAI 백엔드에 전송하는 것과 별도로 자체 처리를 수행할 수 있습니다. -2. [`set_trace_processors()`][agents.tracing.set_trace_processors]를 사용하면 기본 프로세서를 자체 트레이싱 프로세서로 **교체**할 수 있습니다. 이 경우 OpenAI 백엔드로 전송하는 `TracingProcessor`를 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. +1. [`add_trace_processor()`][agents.tracing.add_trace_processor]를 사용하면 준비된 트레이스와 스팬을 수신할 **추가** 트레이싱 프로세서를 등록할 수 있습니다. 이를 통해 트레이스를 OpenAI 백엔드로 전송하는 동시에 자체 처리를 수행할 수 있습니다. +2. [`set_trace_processors()`][agents.tracing.set_trace_processors]을 사용하면 기본 프로세서를 자체 트레이싱 프로세서로 **대체**할 수 있습니다. 이 경우 해당 작업을 수행하는 `TracingProcessor`을 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. -## 비 OpenAI 모델을 사용한 트레이싱 +## 비OpenAI 모델을 사용한 트레이싱 -비 OpenAI 모델과 함께 OpenAI API 키를 사용하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 활성화할 수 있습니다. 어댑터 선택 및 설정 시 유의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참고하세요. +비OpenAI 모델을 사용할 때 트레이싱 익스포터에 OpenAI API 키를 제공하면 트레이싱을 비활성화하지 않고 OpenAI 트레이스 대시보드에서 무료 트레이싱을 사용할 수 있습니다. 어댑터 선택 및 설정 시 주의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참조하세요. ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -단일 실행에만 다른 트레이싱 키가 필요한 경우 전역 익스포터를 변경하지 말고 `RunConfig`를 통해 전달하세요. +단일 실행에만 다른 트레이싱 키가 필요한 경우 전역 익스포터를 변경하는 대신 `RunConfig`을 통해 전달하세요. ```python from agents import Runner, RunConfig @@ -201,9 +201,9 @@ await Runner.run( - OpenAI 트레이스 대시보드에서 무료 트레이스를 확인할 수 있습니다. -## 생태계 통합 +## 에코시스템 통합 -다음 커뮤니티 및 공급업체 통합은 OpenAI Agents SDK 트레이싱 인터페이스를 지원합니다. +다음 커뮤니티 및 공급업체 통합은 OpenAI Agents SDK의 트레이싱 API 인터페이스를 지원합니다. ### 외부 트레이싱 프로세서 목록 diff --git a/docs/ko/usage.md b/docs/ko/usage.md index b67df4b95c..83f8a80a63 100644 --- a/docs/ko/usage.md +++ b/docs/ko/usage.md @@ -4,7 +4,7 @@ search: --- # 사용량 -Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 이를 접근하여 비용을 모니터링하고, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. +Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 액세스하여 비용을 모니터링하거나, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. ## 추적 항목 @@ -12,14 +12,14 @@ Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. - **input_tokens**: 전송된 총 입력 토큰 수 - **output_tokens**: 수신된 총 출력 토큰 수 - **total_tokens**: 입력 + 출력 -- **request_usage_entries**: 요청별 사용량 세부 내역 목록 +- **request_usage_entries**: 요청별 사용량 분석 목록 - **details**: - `input_tokens_details.cached_tokens` - `output_tokens_details.reasoning_tokens` -## 실행에서 사용량 접근 +## 실행에서 사용량 액세스 -`Runner.run(...)` 이후에는 `result.context_wrapper.usage`를 통해 사용량에 접근합니다. +`Runner.run(...)` 실행 후 `result.context_wrapper.usage`을 통해 사용량에 액세스합니다. ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -31,20 +31,20 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -사용량은 실행 중 모든 모델 호출(도구 호출 및 핸드오프 포함)에 걸쳐 집계됩니다. +사용량은 도구 호출이나 핸드오프를 생성하는 모델 호출을 포함하여 실행 중 발생한 모든 모델 호출에 걸쳐 집계됩니다. ### 서드 파티 어댑터에서 사용량 활성화 -사용량 보고는 서드 파티 어댑터와 공급자 백엔드마다 다릅니다. 어댑터 기반 모델을 사용하며 정확한 `result.context_wrapper.usage` 값이 필요한 경우: +사용량 보고 방식은 서드 파티 어댑터와 공급자 백엔드에 따라 다릅니다. 서드 파티 어댑터를 통해 모델에 액세스하고 정확한 `result.context_wrapper.usage` 값이 필요한 경우 다음을 참고하세요. -- `AnyLLMModel`에서는 업스트림 공급자가 사용량을 반환하면 사용량이 자동으로 전파됩니다. 스트리밍 Chat Completions 백엔드의 경우 사용량 청크가 방출되기 전에 `ModelSettings(include_usage=True)`가 필요할 수 있습니다. -- `LitellmModel`에서는 일부 공급자 백엔드가 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`가 필요한 경우가 많습니다. +- `AnyLLMModel` 사용 시 업스트림 공급자가 사용량을 반환하면 자동으로 전파됩니다. Chat Completions 백엔드에서 응답을 스트리밍할 때 사용량 청크가 생성되도록 하려면 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. +- `LitellmModel` 사용 시 일부 공급자 백엔드는 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`이 필요한 경우가 많습니다. -Models 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 검토하고, 배포하려는 정확한 공급자 백엔드를 검증하세요. +모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 검토하고, 배포하려는 정확한 공급자 백엔드에서 사용량 보고를 검증하세요. ## 요청별 사용량 추적 -SDK는 `request_usage_entries`에서 각 API 요청의 사용량을 자동으로 추적하며, 이는 상세한 비용 계산과 컨텍스트 윈도우 사용량 모니터링에 유용합니다. +SDK는 각 API 요청의 사용량을 `request_usage_entries`에서 자동으로 추적합니다. 이는 상세한 비용 계산과 컨텍스트 창 사용량 모니터링에 유용합니다. ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -53,9 +53,9 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 세션에서 사용량 접근 +## 세션에서 사용량 액세스 -`Session`(예: `SQLiteSession`)을 사용하는 경우, `Runner.run(...)`을 호출할 때마다 해당 특정 실행의 사용량이 반환됩니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만, 각 실행의 사용량은 독립적입니다. +`Session`(예: `SQLiteSession`)을 사용하면 `Runner.run(...)`에 대한 각 호출이 해당 실행의 사용량을 반환합니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만, 각 실행의 사용량은 독립적입니다. ```python session = SQLiteSession("my_conversation") @@ -67,11 +67,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -세션은 실행 간 대화 컨텍스트를 보존하지만, 각 `Runner.run()` 호출이 반환하는 사용량 지표는 해당 특정 실행만을 나타냅니다. 세션에서는 이전 메시지가 각 실행의 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. +세션은 실행 간 대화 컨텍스트를 유지하지만 각 `Runner.run()` 호출에서 반환되는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행의 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. ## 훅에서 사용량 활용 -`RunHooks`를 사용하는 경우, 각 훅에 전달되는 `context` 객체에는 `usage`가 포함됩니다. 이를 통해 주요 라이프사이클 시점에 사용량을 기록할 수 있습니다. +`RunHooks`을 사용하는 경우 각 훅에 전달되는 `context` 객체에는 `usage`이 포함됩니다. 이를 통해 주요 수명 주기 시점의 사용량을 기록할 수 있습니다. ```python class MyHooks(RunHooks): @@ -80,11 +80,11 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API 참조 +## API 레퍼런스 -자세한 API 문서는 다음을 참조하세요. +자세한 API 문서는 다음을 참고하세요. - [`Usage`][agents.usage.Usage] - 사용량 추적 데이터 구조 - [`RequestUsage`][agents.usage.RequestUsage] - 요청별 사용량 세부 정보 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 실행 컨텍스트에서 사용량 접근 -- [`RunHooks`][agents.run.RunHooks] - 사용량 추적 라이프사이클에 훅 연결 \ No newline at end of file +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 실행 컨텍스트에서 사용량 액세스 +- [`RunHooks`][agents.run.RunHooks] - 사용량 추적 수명 주기에 훅 연결 \ No newline at end of file diff --git a/docs/ko/visualization.md b/docs/ko/visualization.md index 87fd0821f7..35c5eaab0b 100644 --- a/docs/ko/visualization.md +++ b/docs/ko/visualization.md @@ -4,11 +4,11 @@ search: --- # 에이전트 시각화 -에이전트 시각화를 사용하면 **Graphviz**로 에이전트와 그 관계를 구조화된 그래픽 표현으로 생성할 수 있습니다. 이는 애플리케이션 내에서 에이전트, 도구, 핸드오프가 어떻게 상호작용하는지 이해하는 데 유용합니다. +에이전트 시각화를 사용하면 **Graphviz**를 통해 에이전트와 다른 에이전트, 도구 및 MCP 서버 간 연결을 구조화된 그래픽 표현으로 생성할 수 있습니다. 이는 애플리케이션 내에서 에이전트, 도구, 핸드오프가 상호작용하는 방식을 이해하는 데 유용합니다. ## 설치 -선택적 `viz` 의존성 그룹을 설치합니다. +선택적 `viz` 종속성 그룹을 설치합니다. ```bash pip install "openai-agents[viz]" @@ -16,14 +16,14 @@ pip install "openai-agents[viz]" ## 그래프 생성 -`draw_graph` 함수를 사용하여 에이전트 시각화를 생성할 수 있습니다. 이 함수는 다음과 같은 방향 그래프를 만듭니다. +`draw_graph` 함수를 사용하여 에이전트 시각화를 생성할 수 있습니다. 이 함수는 다음과 같은 방향 그래프를 생성합니다. - **에이전트**는 노란색 상자로 표시됩니다. - **MCP 서버**는 회색 상자로 표시됩니다. -- **도구**는 초록색 타원으로 표시됩니다. -- **핸드오프**는 한 에이전트에서 다른 에이전트로 향하는 방향성 간선입니다. +- **도구**는 녹색 타원으로 표시됩니다. +- **핸드오프**는 한 에이전트에서 다른 에이전트로 향하는 방향성 간선으로 표시됩니다. -### 사용 예 +### 사용 예시 ```python import os @@ -70,7 +70,7 @@ draw_graph(triage_agent) ![에이전트 그래프](../assets/images/graph.png) -이는 **트리아지 에이전트**의 구조와 하위 에이전트 및 도구와의 연결을 시각적으로 나타내는 그래프를 생성합니다. +이 코드는 **트리아지 에이전트**의 구조와 하위 에이전트 및 도구와의 연결을 시각적으로 나타내는 그래프를 생성합니다. ## 시각화 이해 @@ -79,7 +79,7 @@ draw_graph(triage_agent) - 진입점을 나타내는 **시작 노드**(`__start__`) - 노란색으로 채워진 **직사각형**으로 표시되는 에이전트 -- 초록색으로 채워진 **타원**으로 표시되는 도구 +- 녹색으로 채워진 **타원**으로 표시되는 도구 - 회색으로 채워진 **직사각형**으로 표시되는 MCP 서버 - 상호작용을 나타내는 방향성 간선: - 에이전트 간 핸드오프를 나타내는 **실선 화살표** @@ -87,12 +87,12 @@ draw_graph(triage_agent) - MCP 서버 호출을 나타내는 **파선 화살표** - 실행이 종료되는 위치를 나타내는 **종료 노드**(`__end__`) -**참고:** MCP 서버는 최신 버전의 `agents` 패키지에서 렌더링됩니다(**v0.2.8**에서 확인됨). 시각화에서 MCP 상자가 보이지 않는다면 최신 릴리스로 업그레이드하세요. +**참고:** MCP 서버는 이 동작이 확인된 **v0.2.8**을 포함하여 최신 버전의 `agents` 패키지에서 렌더링됩니다. 시각화에 MCP 상자가 표시되지 않으면 최신 릴리스로 업그레이드하세요. ## 그래프 사용자 지정 ### 그래프 표시 -기본적으로 `draw_graph`는 그래프를 인라인으로 표시합니다. 그래프를 별도 창에 표시하려면 다음과 같이 작성합니다. +기본적으로 `draw_graph`는 그래프를 인라인으로 표시합니다. 별도의 창에 그래프를 표시하려면 다음과 같이 작성합니다. ```python draw_graph(triage_agent).view() @@ -105,4 +105,4 @@ draw_graph(triage_agent).view() draw_graph(triage_agent, filename="agent_graph") ``` -그러면 작업 디렉터리에 `agent_graph.png`가 생성됩니다. \ No newline at end of file +그러면 작업 디렉터리에 `agent_graph.png`이 생성됩니다. \ No newline at end of file diff --git a/docs/ko/voice/pipeline.md b/docs/ko/voice/pipeline.md index 23fadc570b..4f8e9925b6 100644 --- a/docs/ko/voice/pipeline.md +++ b/docs/ko/voice/pipeline.md @@ -2,9 +2,9 @@ search: exclude: true --- -# 파이프라인과 워크플로 +# 파이프라인 및 워크플로 -[`VoicePipeline`][agents.voice.pipeline.VoicePipeline]은 에이전트 기반 워크플로를 음성 앱으로 쉽게 전환할 수 있게 해 주는 클래스입니다. 실행할 워크플로를 전달하면, 파이프라인이 입력 오디오 전사, 오디오 종료 감지, 적절한 시점에 워크플로 호출, 워크플로 출력을 다시 오디오로 변환하는 작업을 처리합니다. +[`VoicePipeline`][agents.voice.pipeline.VoicePipeline]은 에이전트 워크플로를 음성 앱으로 쉽게 전환할 수 있게 해주는 클래스입니다. 실행할 워크플로를 전달하면 파이프라인이 입력 오디오 전사, 오디오 종료 감지, 적절한 시점의 워크플로 호출, 워크플로 출력을 다시 오디오로 변환하는 작업을 처리합니다. ```mermaid graph LR @@ -34,29 +34,29 @@ graph LR ## 파이프라인 구성 -파이프라인을 만들 때 몇 가지를 설정할 수 있습니다. +파이프라인을 생성할 때 다음과 같은 항목을 설정할 수 있습니다. -1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase]: 새 오디오가 전사될 때마다 실행되는 코드입니다. -2. 사용되는 [`speech-to-text`][agents.voice.model.STTModel] 및 [`text-to-speech`][agents.voice.model.TTSModel] 모델 -3. [`config`][agents.voice.pipeline_config.VoicePipelineConfig]: 다음과 같은 항목을 구성할 수 있습니다. +1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase]은 새 오디오가 전사될 때마다 실행되는 코드입니다. +2. 사용할 [`speech-to-text`][agents.voice.model.STTModel] 및 [`text-to-speech`][agents.voice.model.TTSModel] 모델 +3. 다음과 같은 항목을 구성할 수 있는 [`config`][agents.voice.pipeline_config.VoicePipelineConfig] - 모델 이름을 모델에 매핑할 수 있는 모델 제공자 - - 트레이싱 비활성화 여부, 오디오 파일 업로드 여부, 워크플로 이름, 트레이스 ID 등을 포함한 트레이싱 - - 프롬프트, 언어, 사용되는 데이터 타입 등 TTS 및 STT 모델 설정 + - 트레이싱 비활성화 여부, 오디오 파일 업로드 여부, 워크플로 이름, 트레이스 ID 등을 포함한 트레이싱 설정 + - 프롬프트, 언어, 사용되는 데이터 유형과 같은 TTS 및 STT 모델 설정 ## 파이프라인 실행 -[`run()`][agents.voice.pipeline.VoicePipeline.run] 메서드를 통해 파이프라인을 실행할 수 있으며, 이 메서드는 두 가지 형태의 오디오 입력을 전달할 수 있게 해 줍니다. +[`run()`][agents.voice.pipeline.VoicePipeline.run] 메서드를 통해 파이프라인을 실행할 수 있으며, 다음 두 가지 형식으로 오디오 입력을 전달할 수 있습니다. -1. [`AudioInput`][agents.voice.input.AudioInput]은 완전한 오디오 입력이 있고 그에 대한 결과만 생성하려는 경우에 사용합니다. 화자가 말하기를 끝낸 시점을 감지할 필요가 없는 경우에 유용합니다. 예를 들어 사전 녹음된 오디오가 있거나, 사용자가 말하기를 끝낸 시점이 명확한 push-to-talk 앱에서 사용할 수 있습니다. -2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]은 사용자가 말하기를 끝낸 시점을 감지해야 할 수 있는 경우에 사용합니다. 감지되는 대로 오디오 청크를 푸시할 수 있으며, 음성 파이프라인은 "활동 감지"라는 프로세스를 통해 적절한 시점에 에이전트 워크플로를 자동으로 실행합니다. +1. [`AudioInput`][agents.voice.input.AudioInput]은 완전한 오디오 입력이 있고 해당 입력에 대한 결과만 생성하려는 경우에 사용합니다. 화자가 말하기를 마친 시점을 감지할 필요가 없는 경우에 유용합니다. 예를 들어 사전 녹음된 오디오가 있거나 사용자가 말하기를 마친 시점이 명확한 푸시투토크 앱에서 사용할 수 있습니다. +2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]은 사용자가 말하기를 마친 시점을 감지해야 할 수 있는 경우에 사용합니다. 오디오 청크가 감지되는 대로 전달할 수 있으며, 음성 파이프라인은 "활동 감지"라는 프로세스를 통해 적절한 시점에 에이전트 워크플로를 자동으로 실행합니다. ## 결과 -음성 파이프라인 실행 결과는 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]입니다. 이는 이벤트가 발생할 때 이를 스트리밍할 수 있게 해 주는 객체입니다. [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent]에는 다음을 포함해 몇 가지 종류가 있습니다. +음성 파이프라인 실행 결과는 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]입니다. 이는 이벤트가 발생하는 대로 스트리밍할 수 있는 객체입니다. [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent]에는 다음과 같은 몇 가지 유형이 있습니다. -1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio]: 오디오 청크를 포함합니다. -2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle]: 턴 시작 또는 종료와 같은 수명 주기 이벤트를 알려 줍니다. -3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError]: 오류 이벤트입니다. +1. 오디오 청크를 포함하는 [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio] +2. 턴 시작이나 종료와 같은 수명 주기 이벤트를 알려주는 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] +3. 오류 이벤트인 [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError] ```python @@ -78,4 +78,4 @@ async for event in result.stream(): ### 인터럽션(중단 처리) -Agents SDK는 현재 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]에 대한 내장 인터럽션(중단 처리) 처리를 제공하지 않습니다. 대신 감지된 각 턴은 워크플로의 별도 실행을 트리거합니다. 애플리케이션 내부에서 인터럽션(중단 처리)을 처리하려면 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 이벤트를 수신할 수 있습니다. `turn_started`는 새 턴이 전사되었고 처리가 시작됨을 나타냅니다. `turn_ended`는 해당 턴에 대한 모든 오디오가 디스패치된 후 트리거됩니다. 이러한 이벤트를 사용해 모델이 턴을 시작할 때 화자의 마이크를 음소거하고, 턴과 관련된 모든 오디오를 플러시한 후 음소거를 해제할 수 있습니다. \ No newline at end of file +현재 Agents SDK는 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]에 대한 내장 인터럽션(중단 처리) 기능을 제공하지 않습니다. 대신 감지된 각 턴마다 워크플로가 별도로 실행됩니다. 애플리케이션 내에서 인터럽션(중단 처리)을 처리하려면 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 이벤트를 수신할 수 있습니다. `turn_started`은 새 턴이 전사되어 처리가 시작됨을 나타냅니다. `turn_ended`은 해당 턴의 모든 오디오가 전송된 후 트리거됩니다. 이러한 이벤트를 사용하여 모델이 턴을 시작할 때 화자의 마이크를 음소거하고, 애플리케이션이 해당 턴과 관련된 모든 오디오 재생을 마친 후 음소거를 해제할 수 있습니다. \ No newline at end of file diff --git a/docs/ko/voice/quickstart.md b/docs/ko/voice/quickstart.md index 6a6a525da4..6fd5cb4f7f 100644 --- a/docs/ko/voice/quickstart.md +++ b/docs/ko/voice/quickstart.md @@ -6,18 +6,24 @@ search: ## 사전 요구 사항 -Agents SDK의 기본 [빠른 시작 지침](../quickstart.md)을 따르고 가상 환경을 설정했는지 확인합니다. 그런 다음 SDK에서 선택적 음성 종속성을 설치합니다. +Agents SDK의 기본 [빠른 시작 지침](../quickstart.md)을 따르고 가상 환경을 설정했는지 확인합니다. 그런 다음 SDK에서 선택적 음성 의존성을 설치합니다. ```bash pip install 'openai-agents[voice]' ``` +아래 데모 코드는 마이크 및 스피커 I/O에 [`sounddevice`](https://pypi.org/project/sounddevice/)도 사용하며, 이는 `voice` extra에 포함되지 않습니다. + +```bash +pip install sounddevice +``` + ## 개념 알아야 할 주요 개념은 3단계 프로세스인 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline]입니다. 1. 음성-텍스트 변환 모델을 실행하여 오디오를 텍스트로 변환합니다. -2. 일반적으로 에이전트 워크플로인 코드를 실행하여 결과를 생성합니다. +2. 일반적으로 에이전틱 워크플로인 코드를 실행하여 결과를 생성합니다. 3. 텍스트-음성 변환 모델을 실행하여 결과 텍스트를 다시 오디오로 변환합니다. ```mermaid @@ -48,7 +54,7 @@ graph LR ## 에이전트 -먼저 몇 가지 에이전트를 설정하겠습니다. 이 SDK로 에이전트를 만들어 본 적이 있다면 익숙할 것입니다. 몇 개의 에이전트와 하나의 핸드오프, 하나의 도구를 사용합니다. +먼저 에이전트를 설정해 보겠습니다. 이 SDK로 에이전트를 만들어 본 적이 있다면 익숙하게 느껴질 것입니다. 두 개의 에이전트, 구성된 핸드오프, 도구 하나를 사용합니다. ```python import random @@ -58,7 +64,6 @@ from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions - @tool def get_weather(city: str) -> str: """Get the weather for a given city.""" @@ -89,7 +94,7 @@ agent = Agent( ## 음성 파이프라인 -[`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow]를 워크플로로 사용하여 간단한 음성 파이프라인을 설정합니다. +[`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow]을 워크플로로 사용하여 간단한 음성 파이프라인을 설정합니다. ```python from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline @@ -121,7 +126,7 @@ async for event in result.stream(): ``` -## 전체 구성 +## 전체 코드 통합 ```python import asyncio @@ -189,4 +194,4 @@ if __name__ == "__main__": asyncio.run(main()) ``` -이 예제를 실행하면 에이전트가 사용자에게 음성으로 응답합니다! 에이전트와 직접 대화할 수 있는 데모는 [examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static)의 코드 예제를 확인하세요. \ No newline at end of file +이 코드 예제를 실행하면 에이전트가 사용자가 들을 수 있는 음성 오디오를 생성합니다! 직접 에이전트와 대화할 수 있는 데모는 [examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static)의 코드 예제를 확인하세요. \ No newline at end of file diff --git a/docs/zh/agents.md b/docs/zh/agents.md index 9fbe88c1dd..119c5e8f26 100644 --- a/docs/zh/agents.md +++ b/docs/zh/agents.md @@ -4,22 +4,22 @@ search: --- # 智能体 -智能体是应用中的核心构建块。智能体是配置了指令、工具以及可选运行时行为(例如任务转移、安全防护措施和 structured outputs)的大语言模型(LLM)。 +智能体是应用中的核心构建块。智能体是配置了指令、工具和可选运行时行为(如任务转移、安全防护措施和 structured outputs)的大语言模型(LLM)。 -当你希望定义或自定义单个普通`Agent`时,请使用本页面。如果你正在决定多个智能体应如何协作,请阅读[智能体编排](multi_agent.md)。如果智能体应在具有清单定义文件和沙箱原生能力的隔离工作区中运行,请阅读[沙箱智能体概念](sandbox/guide.md)。 +如果你要定义或自定义单个基础 `Agent`,而不是 `SandboxAgent`,请使用本页面。如果你正在决定多个智能体应如何协作,请阅读[智能体编排](multi_agent.md)。如果智能体应在具有清单定义文件和沙箱原生能力的隔离工作区中运行,请阅读[沙箱智能体概念](sandbox/guide.md)。 -对于OpenAI模型,SDK默认使用 Responses API,但这里的区别在于编排方式:`Agent`加`Runner`可让 SDK 代你管理轮次、工具、安全防护措施、任务转移和会话。如果你希望自行管理该循环,请直接使用 Responses API。 +对于OpenAI模型,SDK 默认使用 Responses API,但这里的区别在于编排:`Agent` 加上 `Runner`,可让 SDK 为你管理轮次、工具、安全防护措施、任务转移和会话。如果你希望自行控制该循环,请改为直接使用 Responses API。 ## 后续指南选择 -请将本页面作为定义智能体的中心入口。根据下一步需要做出的决策,前往相应的相邻指南。 +可将本页面作为定义智能体的中心入口。根据你接下来需要做出的决策,跳转至相应的相邻指南。 -| 如果你希望…… | 接下来阅读 | +| 如果你想要…… | 接下来阅读 | | --- | --- | -| 选择模型或提供商配置 | [模型](models/index.md) | +| 选择模型或提供商设置 | [模型](models/index.md) | | 为智能体添加能力 | [工具](tools.md) | | 让智能体针对真实代码仓库、文档包或隔离工作区运行 | [沙箱智能体快速入门](sandbox_agents.md) | -| 在管理器式编排和任务转移之间做出选择 | [智能体编排](multi_agent.md) | +| 在管理器式编排与任务转移之间进行选择 | [智能体编排](multi_agent.md) | | 配置任务转移行为 | [任务转移](handoffs.md) | | 运行轮次、流式传输事件或管理对话状态 | [运行智能体](running_agents.md) | | 检查最终输出、运行项或可恢复状态 | [结果](results.md) | @@ -27,26 +27,26 @@ search: ## 基本配置 -智能体最常见的属性包括: +智能体最常用的属性包括: | 属性 | 必需 | 说明 | | --- | --- | --- | -| `name` | 是 | 易于理解的智能体名称。 | +| `name` | 是 | 便于人类阅读的智能体名称。 | | `instructions` | 否 | 系统提示词或动态指令回调。强烈建议设置。请参阅[动态指令](#dynamic-instructions)。 | | `prompt` | 否 | OpenAI Responses API 提示词配置。接受静态提示词对象或函数。请参阅[提示词模板](#prompt-templates)。 | -| `handoff_description` | 否 | 当此智能体作为任务转移目标提供时展示的简短说明。 | +| `handoff_description` | 否 | 当此智能体作为任务转移目标提供时显示的简短说明。 | | `handoffs` | 否 | 将对话委派给专业智能体。请参阅[任务转移](handoffs.md)。 | -| `model` | 否 | 要使用的LLM。请参阅[模型](models/index.md)。 | -| `model_settings` | 否 | 模型调优参数,例如`temperature`、`top_p`和`tool_choice`。 | +| `model` | 否 | 要使用的 LLM。请参阅[模型](models/index.md)。 | +| `model_settings` | 否 | 模型调优参数,例如 `temperature`、`top_p` 和 `tool_choice`。 | | `tools` | 否 | 智能体可以调用的工具。请参阅[工具](tools.md)。 | -| `mcp_servers` | 否 | 智能体使用的 MCP 支持工具。请参阅[MCP 指南](mcp.md)。 | -| `mcp_config` | 否 | 微调 MCP 工具的准备方式,例如严格模式的 schema 转换和 MCP 失败信息格式。请参阅[MCP 指南](mcp.md#agent-level-mcp-configuration)。 | -| `input_guardrails` | 否 | 针对此智能体链首次用户输入运行的安全防护措施。请参阅[安全防护措施](guardrails.md)。 | -| `output_guardrails` | 否 | 针对此智能体最终输出运行的安全防护措施。请参阅[安全防护措施](guardrails.md)。 | -| `output_type` | 否 | 使用结构化输出类型,而非纯文本。请参阅[输出类型](#output-types)。 | -| `hooks` | 否 | 作用于智能体范围的生命周期回调。请参阅[生命周期事件(钩子)](#lifecycle-events-hooks)。 | -| `tool_use_behavior` | 否 | 控制工具结果是返回模型继续处理,还是结束本次运行。请参阅[工具使用行为](#tool-use-behavior)。 | -| `reset_tool_choice` | 否 | 在工具调用后重置`tool_choice`(默认值:`True`),以避免工具使用循环。请参阅[工具的强制使用](#forcing-tool-use)。 | +| `mcp_servers` | 否 | 为智能体提供基于 MCP 的工具的 MCP 服务器。请参阅 [MCP 指南](mcp.md)。 | +| `mcp_config` | 否 | 微调 MCP 工具的准备方式,例如将其 schema 转换为严格模式,以及设置 MCP 失败信息的格式。请参阅 [MCP 指南](mcp.md#agent-level-mcp-configuration)。 | +| `input_guardrails` | 否 | 针对此智能体链的第一个用户输入运行的安全防护措施。请参阅[安全防护措施](guardrails.md)。 | +| `output_guardrails` | 否 | 针对此智能体的最终输出运行的安全防护措施。请参阅[安全防护措施](guardrails.md)。 | +| `output_type` | 否 | 用于替代纯文本的 structured outputs 类型。请参阅[输出类型](#output-types)。 | +| `hooks` | 否 | 智能体作用域内的生命周期回调。请参阅[生命周期事件(钩子)](#lifecycle-events-hooks)。 | +| `tool_use_behavior` | 否 | 控制是将工具结果送回模型继续循环,还是结束运行。请参阅[工具使用行为](#tool-use-behavior)。 | +| `reset_tool_choice` | 否 | 在工具调用后重置 `tool_choice`(默认值:`True`),以避免工具使用循环。请参阅[强制使用工具](#forcing-tool-use)。 | ```python from agents import Agent @@ -65,23 +65,23 @@ agent = Agent( ) ``` -本节中的所有内容都适用于`Agent`。`SandboxAgent`基于相同理念构建,并额外添加了`default_manifest`、`base_instructions`、`capabilities`和`run_as`,用于工作区范围的运行。请参阅[沙箱智能体概念](sandbox/guide.md)。 +本节中的所有内容均适用于 `Agent`。`SandboxAgent` 基于相同理念构建,并额外添加了 `default_manifest`、`base_instructions`、`capabilities` 和 `run_as`,用于工作区作用域内的运行。请参阅[沙箱智能体概念](sandbox/guide.md)。 ## 提示词模板 -你可以通过设置`prompt`来引用在OpenAI平台中创建的提示词模板。此功能适用于使用 Responses API 的OpenAI模型。 +通过设置 `prompt`,你可以引用在OpenAI平台中创建的提示词模板。当通过 Responses API 访问OpenAI模型时,此功能可用。 -使用步骤如下: +要使用此功能,请执行以下操作: 1. 前往 https://platform.openai.com/playground/prompts -2. 创建一个新的提示词变量`poem_style`。 +2. 创建一个新的提示词变量 `poem_style`。 3. 创建包含以下内容的系统提示词: ``` Write a poem in {{poem_style}} ``` -4. 使用`--prompt-id`标志运行代码示例。 +4. 使用 `--prompt-id` 标志运行代码示例。 ```python from agents import Agent @@ -128,9 +128,9 @@ result = await Runner.run( ## 上下文 -智能体的`context`类型是泛型。上下文是一种依赖注入工具:它是由你创建并传递给`Runner.run()`的对象,随后会被传递给每个智能体、工具和任务转移等,并作为智能体运行所需依赖项和状态的集合。你可以将任意 Python 对象作为上下文提供。 +智能体以其 `context` 类型作为泛型参数。上下文是一种依赖注入工具:它是由你创建并传递给 `Runner.run()` 的对象,随后会传递给每个智能体、工具、任务转移等,并作为智能体运行所需依赖项和状态的集合。你可以提供任何 Python 对象作为上下文。 -有关完整的`RunContextWrapper`功能、共享使用量追踪、嵌套`tool_input`以及序列化注意事项,请阅读[上下文指南](context.md)。 +有关完整的 `RunContextWrapper` 接口、共享用量追踪、嵌套的 `tool_input` 以及序列化注意事项,请阅读[上下文指南](context.md)。 ```python from dataclasses import dataclass @@ -156,7 +156,7 @@ agent = Agent[UserContext]( ## 输出类型 -默认情况下,智能体生成纯文本(即`str`)输出。如果你希望智能体生成特定类型的输出,可以使用`output_type`参数。常见选择是使用[Pydantic](https://docs.pydantic.dev/)对象,但我们支持任何可封装在 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)中的类型,例如数据类、列表、TypedDict 等。 +默认情况下,智能体生成纯文本(即 `str`)输出。如果你希望智能体生成特定类型的输出,可以使用 `output_type` 参数。常见选择是使用 [Pydantic](https://docs.pydantic.dev/) 对象,但我们支持任何可以封装在 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) 中的类型,例如 dataclass、列表、TypedDict 等。 ```python from pydantic import BaseModel @@ -177,20 +177,20 @@ agent = Agent( !!! note - 当你传入`output_type`时,即表示要求模型使用[structured outputs](https://platform.openai.com/docs/guides/structured-outputs),而不是常规的纯文本响应。 + 传入 `output_type` 后,即表示要求模型使用 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs),而不是常规纯文本响应。 ## 多智能体系统设计模式 -多智能体系统有许多设计方式,但我们通常会看到两种具有广泛适用性的模式: +多智能体系统有多种设计方式,但我们通常会看到两种具有广泛适用性的模式: -1. 管理器(agents as tools):由中央管理器/编排器将专业子智能体作为工具调用,并保留对话控制权。 +1. 管理器(agents as tools):中央管理器/编排器将专业子智能体作为工具调用,并保留对对话的控制权。 2. 任务转移:对等智能体将控制权转移给接管对话的专业智能体。这是一种去中心化模式。 -有关更多详细信息,请参阅[构建智能体的实用指南](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)。 +有关更多详细信息,请参阅[智能体构建实用指南](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)。 ### 管理器(agents as tools) -`customer_facing_agent`负责处理所有用户交互,并调用以工具形式公开的专业子智能体。请在[工具](tools.md#agents-as-tools)文档中了解更多信息。 +`customer_facing_agent` 负责处理所有用户交互,并调用作为工具公开的专业子智能体。请在[工具](tools.md#agents-as-tools)文档中了解更多信息。 ```python from agents import Agent @@ -219,7 +219,7 @@ customer_facing_agent = Agent( ### 任务转移 -任务转移是智能体可以委派任务的子智能体。发生任务转移时,被委派的智能体会接收对话历史记录并接管对话。此模式支持模块化的专业智能体,使其能够出色完成单一任务。请在[任务转移](handoffs.md)文档中了解更多信息。 +配置的任务转移目标是智能体可以委派任务的子智能体。发生任务转移时,被委派的智能体会接收对话历史记录并接管对话。此模式支持模块化的专业智能体,使其能够出色完成单一任务。请在[任务转移](handoffs.md)文档中了解更多信息。 ```python from agents import Agent @@ -240,9 +240,11 @@ triage_agent = Agent( ## 动态指令 -大多数情况下,你可以在创建智能体时提供指令。不过,你也可以通过函数提供动态指令。该函数将接收智能体和上下文,并且必须返回提示词。普通函数和`async`函数均可使用。 +在大多数情况下,你可以在创建智能体时提供指令。不过,你也可以通过函数提供动态指令。该函数将接收智能体和上下文,并且必须返回提示词。普通函数和 `async` 函数均可接受。 ```python +from agents import Agent, RunContextWrapper + def dynamic_instructions( context: RunContextWrapper[UserContext], agent: Agent[UserContext] ) -> str: @@ -257,26 +259,26 @@ agent = Agent[UserContext]( ## 生命周期事件(钩子) -有时,你可能希望观察智能体的生命周期。例如,你可能希望在特定事件发生时记录事件日志、预取数据或记录使用量。 +有时,你可能希望观察智能体的生命周期。例如,你可能希望在特定事件发生时记录事件日志、预取数据或记录用量。 -钩子有两种作用域: +钩子分为两个作用域: -- [`RunHooks`][agents.lifecycle.RunHooks]观察整个`Runner.run(...)`调用,包括向其他智能体的任务转移。 -- [`AgentHooks`][agents.lifecycle.AgentHooks]通过`agent.hooks`附加到特定的智能体实例。 +- [`RunHooks`][agents.lifecycle.RunHooks] 观察整个 `Runner.run(...)` 调用,包括向其他智能体进行的任务转移。 +- [`AgentHooks`][agents.lifecycle.AgentHooks] 通过 `agent.hooks` 附加到特定智能体实例。 -回调上下文也会因事件而异: +回调上下文也会随事件而变化: -- 智能体开始/结束钩子接收[`AgentHookContext`][agents.run_context.AgentHookContext],它会封装你的原始上下文,并携带共享的运行使用量状态。 -- LLM、工具和任务转移钩子接收[`RunContextWrapper`][agents.run_context.RunContextWrapper]。 +- 智能体开始/结束钩子接收 [`AgentHookContext`][agents.run_context.AgentHookContext],它会封装你的原始上下文,并携带共享的运行用量状态。 +- LLM、工具和任务转移钩子接收 [`RunContextWrapper`][agents.run_context.RunContextWrapper]。 -典型的钩子触发时机: +典型的钩子触发时机如下: -- `on_agent_start` / `on_agent_end`:特定智能体开始或完成最终输出生成时。 -- `on_llm_start` / `on_llm_end`:每次模型调用前后立即触发。 -- `on_tool_start` / `on_tool_end`:每次本地工具调用前后触发。对于工具调用,钩子的`context`通常是`ToolContext`,因此你可以检查`tool_call_id`等工具调用元数据。 +- `on_agent_start`:特定智能体开始运行时;`on_agent_end`:该智能体完成最终输出时。 +- `on_llm_start` / `on_llm_end`:紧邻每次模型调用的前后触发。 +- `on_tool_start` / `on_tool_end`:在每次本地工具调用前后触发。对于函数工具,钩子 `context` 通常是 `ToolContext`,因此你可以检查工具调用元数据,例如 `tool_call_id`。 - `on_handoff`:控制权从一个智能体转移到另一个智能体时。 -如果你希望使用单个观察器监控整个工作流,请使用`RunHooks`;如果某个智能体需要自定义副作用,请使用`AgentHooks`。 +如果希望为整个工作流设置一个统一观察器,请使用 `RunHooks`;如果希望将生命周期回调限定到特定智能体,请使用 `AgentHooks`。 ```python from agents import Agent, RunHooks, Runner @@ -298,15 +300,15 @@ result = await Runner.run(agent, "Explain quines", hooks=LoggingHooks()) print(result.final_output) ``` -有关完整的回调功能,请参阅[生命周期 API 参考](ref/lifecycle.md)。 +有关完整的回调接口,请参阅[生命周期 API 参考](ref/lifecycle.md)。 ## 安全防护措施 -安全防护措施允许你在智能体运行的同时并行检查/验证用户输入,并在智能体生成输出后检查其输出。例如,你可以筛查用户输入和智能体输出的相关性。请在[安全防护措施](guardrails.md)文档中了解更多信息。 +安全防护措施允许你在智能体运行的同时并行检查/验证用户输入,并在智能体生成输出后对其进行检查/验证。例如,你可以筛查用户输入和智能体输出是否与任务相关。请在[安全防护措施](guardrails.md)文档中了解更多信息。 -## 智能体的克隆/复制 +## 智能体克隆与复制 -通过对智能体使用`clone()`方法,你可以复制一个智能体,并可选择更改任意属性。 +通过在智能体上使用 `clone()` 方法,你可以复制一个智能体,并可选择更改任意属性。 ```python pirate_agent = Agent( @@ -321,16 +323,16 @@ robot_agent = pirate_agent.clone( ) ``` -## 工具的强制使用 +## 强制使用工具 -提供工具列表并不总是意味着LLM会使用工具。你可以通过设置[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]强制使用工具。有效值包括: +提供工具列表并不总是意味着 LLM 会使用工具。你可以通过设置 [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] 强制使用工具。有效值包括: -1. `auto`,允许LLM自行决定是否使用工具。 -2. `required`,要求LLM使用工具(但可以智能地决定使用哪个工具)。 -3. `none`,要求LLM_不_使用工具。 -4. 设置特定字符串,例如`my_tool`,要求LLM使用该特定工具。 +1. `auto`,允许 LLM 决定是否使用工具。 +2. `required`,要求 LLM 使用工具(但它可以智能决定使用哪个工具)。 +3. `none`,要求 LLM _不_使用工具。 +4. 设置特定字符串,例如 `my_tool`,要求 LLM 使用该特定工具。 -使用 OpenAI Responses 工具搜索时,按名称指定工具的选择方式受到更多限制:你不能通过`tool_choice`指定裸命名空间名称或仅延迟加载的工具,而且`tool_choice="tool_search"`不会指定[`ToolSearchTool`][agents.tool.ToolSearchTool]。在这些情况下,建议使用`auto`或`required`。有关 Responses 特有的限制,请参阅[托管工具搜索](tools.md#hosted-tool-search)。 +使用 OpenAI Responses 工具搜索时,具名工具选项受到更多限制:不能通过 `tool_choice` 指定纯命名空间名称或仅延迟加载的工具,且 `tool_choice="tool_search"` 不会指定 [`ToolSearchTool`][agents.tool.ToolSearchTool]。在这些情况下,建议使用 `auto` 或 `required`。有关 Responses 特有的限制,请参阅[托管工具搜索](tools.md#hosted-tool-search)。 ```python from agents import Agent, ModelSettings @@ -351,10 +353,10 @@ agent = Agent( ## 工具使用行为 -`Agent`配置中的`tool_use_behavior`参数控制工具输出的处理方式: +`Agent` 配置中的 `tool_use_behavior` 参数控制工具输出的处理方式: -- `"run_llm_again"`:默认行为。运行工具后,由LLM处理结果并生成最终响应。 -- `"stop_on_first_tool"`:将第一个工具调用的输出用作最终响应,不再由LLM进一步处理。 +- `"run_llm_again"`:默认行为。运行工具后,由 LLM 处理结果并生成最终响应。 +- `"stop_on_first_tool"`:将第一次工具调用的输出用作最终响应,不再由 LLM 进行后续处理。 ```python from agents import Agent @@ -373,12 +375,12 @@ agent = Agent( ) ``` -- `StopAtTools(stop_at_tool_names=[...])`:如果调用了任何指定工具,则停止运行,并将其输出用作最终响应。 +- `StopAtTools(stop_at_tool_names=[...])`:如果调用了任一指定工具,则停止运行,并将其输出用作最终响应。 ```python from agents import Agent -from agents.decorators import tool from agents.agent import StopAtTools +from agents.decorators import tool @tool def get_weather(city: str) -> str: @@ -398,12 +400,12 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`:用于处理工具结果,并决定是停止还是交由LLM继续处理的自定义函数。 +- `ToolsToFinalOutputFunction`:自定义函数,用于处理工具结果,并决定是以最终输出结束运行,还是让 LLM 继续处理。 ```python from agents import Agent, FunctionToolResult, RunContextWrapper -from agents.decorators import tool from agents.agent import ToolsToFinalOutputResult +from agents.decorators import tool from typing import List, Any @tool @@ -437,4 +439,4 @@ agent = Agent( !!! note - 为防止无限循环,框架会在工具调用后自动将`tool_choice`重置为“auto”。此行为可通过[`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]配置。之所以会发生无限循环,是因为工具结果会发送给LLM,随后LLM由于`tool_choice`而再次生成工具调用,如此无限重复。 \ No newline at end of file + 为防止无限循环,框架会在工具调用后自动将 `tool_choice` 重置为“auto”。此行为可通过 [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice] 配置。产生无限循环的原因是工具结果会发送给 LLM,而 LLM 随后会由于 `tool_choice` 再次生成工具调用,如此无限重复。 \ No newline at end of file diff --git a/docs/zh/config.md b/docs/zh/config.md index cb291df7d7..2893c94d1a 100644 --- a/docs/zh/config.md +++ b/docs/zh/config.md @@ -4,21 +4,21 @@ search: --- # 配置 -本页介绍通常在应用启动时一次性设置的 SDK 全局默认值,例如默认OpenAI密钥或客户端、默认OpenAI API 形式、追踪导出默认值以及日志记录行为。 +本页介绍通常在应用启动时仅需设置一次的 SDK 全局默认配置,例如默认 OpenAI 密钥或客户端、默认 OpenAI API 形式、追踪导出默认值以及日志记录行为。 -这些默认值同样适用于基于沙箱的工作流,但沙箱工作区、沙箱客户端和会话复用需单独配置。 +这些默认配置仍适用于基于沙箱的工作流,但沙箱工作区、沙箱客户端和会话复用需单独配置。 -如果需要配置特定智能体或运行,请先参阅: +如果需要配置特定的智能体或运行,请从以下内容开始: -- [智能体](agents.md):了解普通 `Agent` 的指令、工具、输出类型、任务转移和安全防护措施。 -- [运行智能体](running_agents.md):了解 `RunConfig`、会话和对话状态选项。 -- [沙箱智能体](sandbox/guide.md):了解 `SandboxRunConfig`、清单、能力和沙箱客户端专用的工作区设置。 -- [模型](models/index.md):了解模型选择和提供商配置。 -- [追踪](tracing.md):了解每次运行的追踪元数据和自定义追踪进程。 +- [智能体](agents.md):了解普通 `Agent` 的指令、工具、输出类型、任务转移和安全防护措施。 +- [运行智能体](running_agents.md):了解 `RunConfig`、会话和对话状态选项。 +- [沙箱智能体](sandbox/guide.md):了解 `SandboxRunConfig`、清单、能力以及特定于沙箱客户端的工作区设置。 +- [模型](models/index.md):了解模型选择和提供商配置。 +- [追踪](tracing.md):了解每次运行的追踪元数据和自定义追踪处理器。 ## 配置对象与字典 -SDK 管理的配置参数通常既接受类型化设置对象,也接受包含相同字段的字典。这适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。嵌套的 SDK 管理设置也可以使用字典。 +SDK 定义的配置参数通常既接受其类型化设置对象,也接受包含相同字段的字典。这适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。SDK 定义的嵌套设置类型也可以使用字典。 ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK 会将这些字典规范化为相应的设置对象。SDK 管理的 dataclass 配置中出现未知字段时会引发 `TypeError`,这有助于尽早发现拼写错误的选项名称。请查看参数的类型注解或 API 参考文档,以确认特定边界是否接受字典。 +SDK 会将这些字典规范化为相应的设置对象。对于 SDK 定义的数据类配置类型,未知字段会引发 `TypeError`,这有助于尽早发现拼写错误的选项名称。请检查参数的类型注解或 API 参考文档,以确认特定边界是否接受字典。 ## API 密钥与客户端 -默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理 LLM 请求和追踪。SDK 首次创建OpenAI客户端时才会解析该密钥(延迟初始化),因此请在首次调用模型之前设置此环境变量。如果无法在应用启动前设置该环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 +默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理 LLM 请求和追踪。SDK 首次创建 OpenAI 客户端时才会解析该密钥(延迟初始化),因此请在首次调用模型之前设置此环境变量。如果无法在应用启动前设置该环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -或者,您也可以配置要使用的OpenAI客户端。默认情况下,SDK 会创建一个 `AsyncOpenAI` 实例,并使用环境变量中的 API 密钥或上文设置的默认密钥。您可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此行为。 +或者,也可以配置要使用的 OpenAI 客户端。默认情况下,SDK 会创建一个 `AsyncOpenAI` 实例,并使用环境变量中的 API 密钥或上面设置的默认密钥。可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此行为。 ```python from openai import AsyncOpenAI @@ -55,14 +55,14 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -如果您倾向于通过环境变量配置端点,默认OpenAI提供商还会读取 `OPENAI_BASE_URL`。启用 Responses WebSocket 传输时,它还会读取 `OPENAI_WEBSOCKET_BASE_URL`,作为 WebSocket `/responses` 端点。 +如果偏好基于环境变量的端点配置,默认 OpenAI 提供商还会读取 `OPENAI_BASE_URL`。启用 Responses WebSocket 传输时,它还会读取 WebSocket `/responses` 端点的 `OPENAI_WEBSOCKET_BASE_URL`。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -最后,您还可以自定义所使用的OpenAI API。默认情况下,我们使用OpenAI Responses API。您可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数覆盖此设置,改用Chat Completions API。 +最后,还可以自定义使用的 OpenAI API。默认情况下,我们使用 OpenAI Responses API。可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数将其覆盖为 Chat Completions API。 ```python from agents import set_default_openai_api @@ -70,9 +70,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI提供商默认值 +## OpenAI 提供商默认配置 -基于OpenAI的提供商在解析模型名称时,也会读取 SDK 全局默认值。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使OpenAI Responses 模型默认使用 WebSocket 传输: +使用 SDK OpenAI 后端的提供商在将模型名称字符串映射到模型时,也会读取 SDK 全局默认配置。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使 OpenAI Responses 模型默认使用 WebSocket 传输: ```python from agents import set_default_openai_responses_transport @@ -80,9 +80,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响由默认OpenAI提供商解析的OpenAI Responses 模型。有关提供商级设置、连接复用、保活选项和自定义 WebSocket 端点,请参阅 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 +当默认 OpenAI 提供商解析模型名称时,这会影响由此生成的 OpenAI Responses 模型。有关提供商级别的设置、连接复用、keepalive 选项和自定义 WebSocket 端点,请参阅 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 -如果您的OpenAI设置需要提供商级智能体注册元数据,请在启动时一次性配置默认 harness ID: +如果 OpenAI 设置需要提供商级别的智能体注册元数据,请在启动时一次性配置默认 harness ID: ```python from agents import set_default_openai_harness @@ -90,7 +90,7 @@ from agents import set_default_openai_harness set_default_openai_harness("your-harness-id") ``` -您也可以传入完整的注册对象: +也可以传入完整的注册对象: ```python from agents import OpenAIAgentRegistrationConfig, set_default_openai_agent_registration @@ -100,11 +100,11 @@ set_default_openai_agent_registration( ) ``` -如果未设置 SDK 默认值,基于OpenAI的提供商会回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 +如果未设置 SDK 默认值,使用 SDK OpenAI 后端的提供商会回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 ## 追踪 -追踪默认启用。默认情况下,它使用与上一节模型请求相同的OpenAI API 密钥(即环境变量中的密钥或您设置的默认密钥)。您可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 +追踪默认启用。默认情况下,它使用上一节中模型请求所用的同一 OpenAI API 密钥(即环境变量中的密钥或设置的默认密钥)。可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 ```python from agents import set_tracing_export_api_key @@ -112,7 +112,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -如果模型流量使用一个密钥或客户端,而追踪需要使用另一个OpenAI密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果未使用自定义客户端,则 [`set_default_openai_key()`][agents.set_default_openai_key] 也适用相同的模式。 +如果模型流量使用一个密钥或客户端,但追踪应使用另一个 OpenAI 密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果不使用自定义客户端,也可以对 [`set_default_openai_key()`][agents.set_default_openai_key] 使用相同模式。 ```python from openai import AsyncOpenAI @@ -127,14 +127,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -使用默认导出器时,如果需要将追踪归属到特定组织或项目,请在应用启动前设置以下环境变量: +使用默认导出器时,如果需要将追踪归属于特定组织或项目,请在应用启动前设置以下环境变量: ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -您也可以为每次运行设置追踪 API 密钥,而无需更改全局导出器。 +也可以为每次运行设置追踪 API 密钥,而无需更改全局导出器。 ```python from agents import Runner, RunConfig @@ -146,7 +146,7 @@ await Runner.run( ) ``` -您还可以使用 [`set_tracing_disabled()`][agents.set_tracing_disabled] 函数完全禁用追踪。 +还可以使用 [`set_tracing_disabled()`][agents.set_tracing_disabled] 函数完全禁用追踪。 ```python from agents import set_tracing_disabled @@ -154,7 +154,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -如果希望保持启用追踪,但从追踪载荷中排除可能包含敏感信息的输入和输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: +如果希望保持追踪启用,但从追踪载荷中排除可能敏感的输入/输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: ```python from agents import Runner, RunConfig @@ -166,7 +166,7 @@ await Runner.run( ) ``` -您也可以在应用启动前设置以下环境变量,无需编写代码即可更改默认值: +也可以在应用启动前设置以下环境变量,从而无需编写代码即可更改默认值: ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 @@ -176,7 +176,7 @@ export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ## 调试日志 -SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加任何处理器。日志遵循应用的 Python 日志配置。 +SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加处理器。日志遵循应用的 Python 日志配置。 如需启用详细日志记录,请使用 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 函数。 @@ -186,7 +186,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -或者,您也可以通过添加处理器、过滤器、格式化器等来自定义日志。有关更多信息,请参阅 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 +或者,也可以通过添加处理器、过滤器、格式化程序等来自定义日志。更多信息请参阅 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 ```python import logging @@ -205,22 +205,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 日志与诊断中的敏感数据 +### 日志和诊断中的敏感数据 -某些日志和诊断异常可能包含敏感数据,例如模型或工具的输入和输出。 +某些日志和诊断异常可能包含敏感数据(例如模型或工具的输入和输出)。 -默认情况下,SDK **不会**记录 LLM 输入/输出或工具输入/输出。这些保护措施由以下配置控制: +默认情况下,SDK **不会**记录 LLM 输入/输出或工具输入/输出。这些保护由以下设置控制: ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -如果需要暂时包含这些数据以便调试,请在应用启动前将任一变量设置为 `0`(或 `false`): +如果需要在调试期间临时包含这些数据,请在应用启动前将任一变量设置为 `0`(或 `false`): ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -这些标志还控制受影响的故障是否保留包含载荷的诊断详情。例如,启用工具数据编校后,工具调用的参数无效会引发通用的 `ModelBehaviorError`,且不会将底层验证错误链接为异常链。将任一变量设置为 `0` 可能会在日志、异常消息、异常链及其他诊断上下文中暴露原始模型或工具数据,因此请仅在受控的开发环境中启用。 \ No newline at end of file +这些标志还控制受影响的故障是否保留包含载荷的诊断详细信息。例如,启用工具数据脱敏后,`FunctionTool` 的无效参数会引发通用的 `ModelBehaviorError`,而不会以异常链形式附带底层验证错误。将任一变量设置为 `0` 可能会在日志、异常消息、异常链和其他诊断上下文中暴露原始模型或工具数据,因此只能在受控的开发环境中启用。 \ No newline at end of file diff --git a/docs/zh/context.md b/docs/zh/context.md index c8f273f046..c98f5dc806 100644 --- a/docs/zh/context.md +++ b/docs/zh/context.md @@ -4,49 +4,49 @@ search: --- # 上下文管理 -上下文是一个含义丰富的术语。你可能关心的上下文主要有两类: +上下文是一个含义宽泛的术语。你可能需要关注两类主要的上下文: -1. 代码本地可用的上下文:这是工具函数运行时、`on_handoff` 等回调中、生命周期钩子中等可能需要的数据和依赖项。 -2. LLM 可用的上下文:这是 LLM 在生成响应时看到的数据。 +1. 你的代码在本地可用的上下文:这是工具函数运行时、`on_handoff` 等回调中、生命周期钩子中可能需要的数据和依赖项。 +2. LLM 可用的上下文:这是 LLM 在生成响应时能够看到的数据。 ## 本地上下文 -这通过 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 类及其中的 [`context`][agents.run_context.RunContextWrapper.context] 属性来表示。它的工作方式如下: +本地上下文由 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 类及其中的 [`context`][agents.run_context.RunContextWrapper.context] 属性表示。其工作方式如下: -1. 你创建任意所需的 Python 对象。常见模式是使用 dataclass 或 Pydantic 对象。 -2. 你将该对象传递给各种 run 方法(例如 `Runner.run(..., context=whatever)`)。 -3. 你的所有工具调用、生命周期钩子等都会收到一个包装对象 `RunContextWrapper[T]`,其中 `T` 表示你的上下文对象类型,你可以通过 `wrapper.context` 访问它。 +1. 创建任意所需的 Python 对象。常见模式是使用 dataclass 或 Pydantic 对象。 +2. 将该对象传递给各种运行方法(例如 `Runner.run(..., context=whatever)`)。 +3. 所有工具调用、生命周期钩子等都会收到一个包装器对象 `RunContextWrapper[T]`,其中 `T` 表示上下文对象的类型;该对象本身可通过 `wrapper.context` 获取。 -对于一些运行时特定的回调,SDK 可能会传递 `RunContextWrapper[T]` 的更专用子类。例如,工具调用生命周期钩子通常会接收 `ToolContext`,它还会公开工具调用元数据,例如 `tool_call_id`、`tool_name` 和 `tool_arguments`。 +对于某些特定于运行时的回调,SDK 可能会传入 `RunContextWrapper[T]` 的特定子类。例如,`FunctionTool` 实例的生命周期钩子通常会收到 `ToolContext`,它还会公开 `tool_call_id`、`tool_name` 和 `tool_arguments` 等工具调用元数据。 -需要注意的**最重要**事项:对于给定的智能体运行,其每个智能体、工具函数、生命周期等都必须使用相同的上下文_类型_。 +需要注意的**最重要**事项是:对于一次给定的智能体运行,其中的每个智能体、工具函数、生命周期等都必须使用相同的上下文_类型_。 -你可以将上下文用于以下用途: +你可以将上下文用于以下方面: -- 运行所需的上下文数据(例如用户名/uid 或关于用户的其他信息) +- 运行所需的上下文数据(例如用户名/uid 或其他用户相关信息) - 依赖项(例如日志记录器对象、数据获取器等) - 辅助函数 !!! danger "注意" - 上下文对象**不会**发送给 LLM。它纯粹是一个本地对象,你可以从中读取、向其写入,并调用其方法。 + 上下文对象**不会**发送给 LLM。它完全是一个本地对象,你可以读取和写入该对象,也可以调用其方法。 -在单次运行中,派生包装器共享相同的底层应用上下文、审批状态和用量跟踪。嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行可能会附加不同的 `tool_input`,但默认情况下不会获得应用状态的隔离副本。 +在单次运行中,派生的包装器共享相同的底层应用上下文、审批状态和用量追踪。嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行可以附加不同的 `tool_input`,但默认情况下,它们不会获得应用状态的独立副本。 -### `RunContextWrapper` 公开的内容 +### `RunContextWrapper` 提供的内容 -[`RunContextWrapper`][agents.run_context.RunContextWrapper] 是围绕你应用定义的上下文对象的包装器。实践中你最常使用的是: +[`RunContextWrapper`][agents.run_context.RunContextWrapper] 是应用自定义上下文对象的包装器。实际使用中,你最常用到的是: -- [`wrapper.context`][agents.run_context.RunContextWrapper.context]:用于你自己的可变应用状态和依赖项。 -- [`wrapper.usage`][agents.run_context.RunContextWrapper.usage]:用于当前运行中聚合的请求和 token 用量。 -- [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input]:用于当前运行在 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 内执行时的结构化输入。 -- [`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool]:当你需要以编程方式更新审批状态时使用。 +- [`wrapper.context`][agents.run_context.RunContextWrapper.context],用于应用自身的可变状态和依赖项。 +- [`wrapper.usage`][agents.run_context.RunContextWrapper.usage],用于当前运行期间聚合的请求用量和 token 用量。 +- [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input],用于当前运行在 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 内部执行时的结构化输入。 +- [`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool],用于以编程方式更新审批状态。 -只有 `wrapper.context` 是你应用定义的对象。其他字段都是由 SDK 管理的运行时元数据。 +只有 `wrapper.context` 是应用自定义对象。其他字段均为 SDK 管理的运行时元数据。 -如果你之后为了人工介入或持久化作业工作流而序列化 [`RunState`][agents.run_state.RunState],这些运行时元数据会随状态一起保存。如果你打算持久化或传输序列化状态,请避免在 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] 中放入密钥。 +如果之后要为人机协同或持久化任务工作流序列化 [`RunState`][agents.run_state.RunState],这些运行时元数据会随状态一起保存。如果打算持久化或传输序列化后的状态,请避免在 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] 中存放机密信息。 -会话状态是另一个单独的问题。根据你希望如何延续多轮对话,可以使用 `result.to_input_list()`、`session`、`conversation_id` 或 `previous_response_id`。有关该决策,请参见[结果](results.md)、[运行智能体](running_agents.md)和[会话](sessions/index.md)。 +对话状态是另一个独立的问题。请根据所需的对话轮次延续方式,使用 `result.to_input_list()`、`session`、`conversation_id` 或 `previous_response_id`。有关如何选择,请参阅[结果](results.md)、[运行智能体](running_agents.md)和[会话](sessions/index.md)。 ```python import asyncio @@ -86,18 +86,18 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 这是上下文对象。这里我们使用了 dataclass,但你可以使用任意类型。 -2. 这是一个工具。你可以看到它接收 `RunContextWrapper[UserInfo]`。工具实现会从上下文中读取信息。 -3. 我们用泛型 `UserInfo` 标记该智能体,这样类型检查器就能捕获错误(例如,如果我们尝试传入一个接收不同上下文类型的工具)。 +1. 这是上下文对象。此处使用了 dataclass,但你可以使用任意类型。 +2. 这是一个工具。可以看到,它接收 `RunContextWrapper[UserInfo]`。工具实现会从上下文中读取数据。 +3. 我们使用泛型 `UserInfo` 标记智能体,以便类型检查器捕获错误(例如,如果尝试传入一个接收不同上下文类型的工具)。 4. 上下文会传递给 `run` 函数。 -5. 智能体会正确调用该工具并获得年龄。 +5. 智能体正确调用工具并获取年龄。 --- -### 高级:`ToolContext` +### 高级用法:`ToolContext` -在某些情况下,你可能希望访问有关正在执行的工具的额外元数据,例如其名称、调用 ID 或原始参数字符串。 -为此,你可以使用 [`ToolContext`][agents.tool_context.ToolContext] 类,它扩展了 `RunContextWrapper`。 +在某些情况下,你可能需要访问有关正在执行的工具的额外元数据,例如工具名称、调用 ID 或原始参数字符串。 +为此,可以使用 [`ToolContext`][agents.tool_context.ToolContext] 类,它扩展了 `RunContextWrapper`。 ```python from typing import Annotated @@ -127,24 +127,24 @@ agent = Agent( ``` `ToolContext` 提供与 `RunContextWrapper` 相同的 `.context` 属性, -此外还提供当前工具调用特有的额外字段: +此外还提供当前工具调用特有的字段: - `tool_name` – 被调用工具的名称 - `tool_call_id` – 此工具调用的唯一标识符 - `tool_arguments` – 传递给工具的原始参数字符串 -- `tool_namespace` – 工具调用的 Responses 命名空间,当工具通过 `tool_namespace()` 或其他带命名空间的表面加载时可用 -- `qualified_tool_name` – 当有可用命名空间时,带命名空间限定的工具名称 +- `tool_namespace` – 工具调用的 Responses 命名空间,适用于通过 `tool_namespace()` 或其他带命名空间的接口加载工具的情况 +- `qualified_tool_name` – 存在命名空间时,以命名空间限定的工具名称 -当你在执行期间需要工具级元数据时,请使用 `ToolContext`。 -对于智能体与工具之间的一般上下文共享,`RunContextWrapper` 仍然足够。由于 `ToolContext` 扩展了 `RunContextWrapper`,当嵌套的 `Agent.as_tool()` 运行提供了结构化输入时,它也可以公开 `.tool_input`。 +如果在执行期间需要工具级元数据,请使用 `ToolContext`。 +对于智能体与工具之间的常规上下文共享,`RunContextWrapper` 仍然足够。由于 `ToolContext` 扩展了 `RunContextWrapper`,当嵌套的 `Agent.as_tool()` 运行提供结构化输入时,它也可以公开 `.tool_input`。 --- ## 智能体/LLM 上下文 -调用 LLM 时,它**唯一**能看到的数据来自对话历史。这意味着,如果你想让一些新数据可供 LLM 使用,就必须以能让这些数据出现在该历史中的方式来提供。有几种方式可以做到这一点: +调用 LLM 时,它**唯一**能看到的数据来自对话历史记录。这意味着,如果希望 LLM 能够使用某些新数据,就必须以某种方式让这些数据出现在该历史记录中。具体有以下几种方式: -1. 你可以将其添加到智能体的 `instructions` 中。这也称为“系统提示词”或“开发者消息”。系统提示词可以是静态字符串,也可以是接收上下文并输出字符串的动态函数。对于始终有用的信息(例如用户姓名或当前日期),这是一种常见策略。 -2. 在调用 `Runner.run` 函数时将其添加到 `input` 中。这类似于 `instructions` 策略,但允许你使用在[指令链](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)中层级较低的消息。 -3. 通过工具调用公开它。这对于_按需_上下文很有用——LLM 决定何时需要某些数据,并可以调用工具来获取这些数据。 -4. 使用检索或网络检索。这些是特殊工具,能够从文件或数据库中获取相关数据(检索),或从网络获取相关数据(网络检索)。这对于将响应“锚定”在相关上下文数据中很有用。 \ No newline at end of file +1. 可以将其添加到智能体的 `instructions` 中。这也称为“系统提示词”或“开发者消息”。系统提示词可以是静态字符串,也可以是接收上下文并输出字符串的动态函数。这是处理始终有用的信息时常用的策略(例如用户姓名或当前日期)。 +2. 调用 `Runner.run` 函数时,将其添加到 `input` 中。这与 `instructions` 策略类似,但可以使用在[指令层级](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)中优先级较低的消息。 +3. 通过 `FunctionTool` 实例公开这些数据。这对于_按需_上下文非常有用——LLM 会自行判断何时需要某些数据,并可调用工具来获取这些数据。 +4. 使用检索或网络检索。这些是能够从文件或数据库中获取相关数据(检索),或者从网络获取相关数据(网络检索)的特殊工具。这有助于让响应以相关上下文数据为依据。 \ No newline at end of file diff --git a/docs/zh/examples.md b/docs/zh/examples.md index 7dc394e948..5516a08ed3 100644 --- a/docs/zh/examples.md +++ b/docs/zh/examples.md @@ -4,77 +4,77 @@ search: --- # 代码示例 -请查看[仓库](https://github.com/openai/openai-agents-python/tree/main/examples)的 examples 目录,了解 SDK 的各种示例实现。这些代码示例分为多个目录,展示了不同的模式和功能。 +请查看[仓库](https://github.com/openai/openai-agents-python/tree/main/examples)的代码示例部分,其中提供了多种使用 SDK 的实现。代码示例分为多个类别,展示了不同的模式和功能。 -## 目录 +## 类别 -- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** 此目录中的代码示例展示了常见的智能体设计模式,例如 +- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):**此类别中的代码示例展示了常见的智能体设计模式,例如 - 确定性工作流 - Agents as tools - - 具备流式传输事件的Agents as tools(`examples/agent_patterns/agents_as_tools_streaming.py`) - - 具备结构化输入参数的Agents as tools(`examples/agent_patterns/agents_as_tools_structured.py`) + - Agents as tools与流式传输事件结合(`examples/agent_patterns/agents_as_tools_streaming.py`) + - Agents as tools与结构化输入参数结合(`examples/agent_patterns/agents_as_tools_structured.py`) - 并行执行智能体 - 有条件地使用工具 - - 以不同的行为强制使用工具(`examples/agent_patterns/forcing_tool_use.py`) + - 强制使用工具,同时展示不同的工具使用行为(`examples/agent_patterns/forcing_tool_use.py`) - 输入/输出安全防护措施 - - LLM作为评审 + - 将LLM用作评判器 - 路由 - 流式传输安全防护措施 - - 通过工具审批和状态序列化实现人机协同(`examples/agent_patterns/human_in_the_loop.py`) - - 通过流式传输实现人机协同(`examples/agent_patterns/human_in_the_loop_stream.py`) + - 结合工具审批和状态序列化的人工介入(`examples/agent_patterns/human_in_the_loop.py`) + - 结合流式传输的人工介入(`examples/agent_patterns/human_in_the_loop_stream.py`) - 审批流程的自定义拒绝消息(`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) -- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** 这些代码示例展示了 SDK 的基础功能,例如 +- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):**这些代码示例展示了 SDK 的基础功能,例如 - Hello world代码示例(默认模型、GPT-5、开放权重模型) - 智能体生命周期管理 - - 运行钩子和智能体钩子的生命周期代码示例(`examples/basic/lifecycle_example.py`) + - 使用`RunHooks`和`AgentHooks`的智能体与运行生命周期代码示例(`examples/basic/lifecycle_example.py`) - 动态系统提示词 - - 基础工具使用(`examples/basic/tools.py`) + - 基本工具用法(`examples/basic/tools.py`) - 工具输入/输出安全防护措施(`examples/basic/tool_guardrails.py`) - - 图像工具输出(`examples/basic/image_tool_output.py`) - - 流式传输输出(文本、项目、函数调用参数) - - 使用跨轮次共享会话辅助工具的 Responses WebSocket 传输(`examples/basic/stream_ws.py`) + - 将图像作为工具输出返回(`examples/basic/image_tool_output.py`) + - 流式传输输出(文本、条目、函数调用参数) + - 跨轮次使用共享会话辅助工具的 Responses WebSocket 传输(`examples/basic/stream_ws.py`) - 提示词模板 - 文件处理(本地和远程文件、图像和 PDF) - - 用量追踪 + - 用量跟踪 - 由 Runner 管理的重试设置(`examples/basic/retry.py`) - - 通过第三方适配器实现由 Runner 管理的重试(`examples/basic/retry_litellm.py`) + - 通过第三方适配器进行由 Runner 管理的重试(`examples/basic/retry_litellm.py`) - 非严格输出类型 - - 上一响应 ID 的使用 + - 上一个响应 ID 的用法 -- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** 航空公司客户服务系统代码示例。 +- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):**航空公司客户服务系统代码示例。 -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** 一个金融研究智能体,展示了使用智能体和工具进行金融数据分析的结构化研究工作流。 +- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):**一个金融研究智能体,展示了如何使用智能体和工具构建用于金融数据分析的结构化研究工作流。 -- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** 包含消息过滤功能的智能体任务转移实用代码示例,包括: +- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):**包含消息筛选的智能体任务转移实用代码示例,包括: - - 消息过滤器代码示例(`examples/handoffs/message_filter.py`) - - 采用流式传输的消息过滤器(`examples/handoffs/message_filter_streaming.py`) + - 消息筛选器代码示例(`examples/handoffs/message_filter.py`) + - 结合流式传输的消息筛选器(`examples/handoffs/message_filter_streaming.py`) -- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** 展示如何结合OpenAI Responses API 使用托管式MCP(Model Context Protocol)的代码示例,包括: +- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):**展示如何通过 OpenAI Responses API 使用托管式MCP(Model Context Protocol)的代码示例,包括: - 无需审批的简单托管式MCP(`examples/hosted_mcp/simple.py`) - Google Calendar 等MCP连接器(`examples/hosted_mcp/connectors.py`) - - 通过基于中断的审批实现人机协同(`examples/hosted_mcp/human_in_the_loop.py`) - - MCP工具调用的审批回调(`examples/hosted_mcp/on_approval.py`) + - 采用基于中断审批的人工介入(`examples/hosted_mcp/human_in_the_loop.py`) + - 用于MCP工具审批请求的回调(`examples/hosted_mcp/on_approval.py`) -- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** 了解如何使用MCP(Model Context Protocol)构建智能体,包括: +- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):**了解如何使用MCP(Model Context Protocol)构建智能体,包括: - 文件系统代码示例 - Git 代码示例 - - MCP提示词服务代码示例 - - SSE(服务发送事件)代码示例 - - SSE 远程服务连接(`examples/mcp/sse_remote_example`) - - 可流式传输 HTTP 代码示例 - - 可流式传输 HTTP 远程连接(`examples/mcp/streamable_http_remote_example`) - - 用于可流式传输 HTTP 的自定义 HTTP 客户端工厂(`examples/mcp/streamablehttp_custom_client_example`) - - 使用 `MCPUtil.get_all_function_tools` 预取全部MCP工具(`examples/mcp/get_all_mcp_tools_example`) - - 结合 FastAPI 使用MCPServerManager(`examples/mcp/manager_example`) - - MCP工具过滤(`examples/mcp/tool_filter_example`) - -- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** 智能体不同内存实现的代码示例,包括: + - MCP提示词服务器代码示例 + - SSE(服务器发送事件)代码示例 + - SSE 远程服务器连接(`examples/mcp/sse_remote_example`) + - Streamable HTTP 代码示例 + - Streamable HTTP 远程连接(`examples/mcp/streamable_http_remote_example`) + - 用于 Streamable HTTP 的自定义 HTTP 客户端工厂(`examples/mcp/streamablehttp_custom_client_example`) + - 使用`MCPUtil.get_all_function_tools`预取所有MCP工具(`examples/mcp/get_all_mcp_tools_example`) + - 在 FastAPI 应用中使用`MCPServerManager`(`examples/mcp/manager_example`) + - MCP工具筛选(`examples/mcp/tool_filter_example`) + +- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):**智能体的不同记忆实现代码示例,包括: - SQLite 会话存储 - 高级 SQLite 会话存储 @@ -82,56 +82,56 @@ search: - SQLAlchemy 会话存储 - Dapr 状态存储会话存储 - 加密会话存储 - - OpenAI Conversations会话存储 + - OpenAI Conversations 会话存储 - Responses 压缩会话存储 - - 使用 `ModelSettings(store=False)` 的无状态 Responses 压缩(`examples/memory/compaction_session_stateless_example.py`) + - 使用`ModelSettings(store=False)`的无状态 Responses 压缩(`examples/memory/compaction_session_stateless_example.py`) - 基于文件的会话存储(`examples/memory/file_session.py`) - - 支持人机协同的基于文件的会话(`examples/memory/file_hitl_example.py`) - - 支持人机协同的 SQLite 内存会话(`examples/memory/memory_session_hitl_example.py`) - - 支持人机协同的OpenAI Conversations会话(`examples/memory/openai_session_hitl_example.py`) + - 结合人工介入的基于文件的会话(`examples/memory/file_hitl_example.py`) + - 结合人工介入的 SQLite 内存会话(`examples/memory/memory_session_hitl_example.py`) + - 结合人工介入的 OpenAI Conversations 会话(`examples/memory/openai_session_hitl_example.py`) - 跨会话的 HITL 审批/拒绝场景(`examples/memory/hitl_session_scenario.py`) -- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** 探索如何在 SDK 中使用非OpenAI模型,包括自定义提供商和第三方适配器。 +- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):**探索如何通过 SDK 使用非OpenAI模型,包括自定义提供商和第三方适配器。 -- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** 展示如何使用 SDK 构建实时体验的代码示例,包括: +- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):**展示如何使用 SDK 构建实时体验的代码示例,包括: - 使用结构化文本和图像消息的 Web 应用模式 - 命令行音频循环和播放处理 - 通过 WebSocket 集成 Twilio Media Streams - - 使用 Realtime Calls API 附加流程的 Twilio SIP 集成 + - 使用 Realtime Calls API 的`attach`流程集成 Twilio SIP -- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** 展示如何处理推理内容的代码示例,包括: +- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):**展示如何处理推理内容的代码示例,包括: - - 使用 Runner API 处理推理内容,支持流式传输与非流式传输(`examples/reasoning_content/runner_example.py`) + - 通过 Runner API 以流式传输和非流式传输方式处理推理内容(`examples/reasoning_content/runner_example.py`) - 通过 OpenRouter 使用 OSS 模型处理推理内容(`examples/reasoning_content/gpt_oss_stream.py`) - - 基础推理内容代码示例(`examples/reasoning_content/main.py`) + - 基本推理内容代码示例(`examples/reasoning_content/main.py`) -- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** 简单的深度研究复刻项目,展示了复杂的多智能体研究工作流。 +- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):**简单的深度研究复刻实现,展示了复杂的多智能体研究工作流。 -- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** 在隔离工作区中运行智能体的代码示例,包括: +- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):**在隔离工作区中运行智能体的代码示例,包括: - - 基础沙箱智能体设置(`examples/sandbox/basic.py`) + - 基本沙箱智能体设置(`examples/sandbox/basic.py`) - Unix 本地沙箱和 Docker 沙箱的生命周期代码示例 - 基于沙箱的任务转移(`examples/sandbox/handoffs.py`) - - 沙箱内存和快照恢复(`examples/sandbox/memory.py`) + - 沙箱记忆和快照恢复(`examples/sandbox/memory.py`) - 作为工具公开的沙箱智能体(`examples/sandbox/sandbox_agents_as_tools.py`) -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** 了解如何实现由OpenAI托管的工具和实验性 Codex 工具功能,例如: +- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):**了解如何实现由OpenAI托管的工具和实验性 Codex 工具功能。代码示例包括: - 网络检索以及带筛选条件的网络检索 - 文件检索 - Code interpreter - - 具备文件编辑和审批功能的补丁应用工具(`examples/tools/apply_patch.py`) - - 使用审批回调执行 Shell 工具(`examples/tools/shell.py`) - - 通过基于中断的审批实现人机协同的 Shell 工具(`examples/tools/shell_human_in_the_loop.py`) - - 具备内联技能的托管容器 Shell(`examples/tools/container_shell_inline_skill.py`) - - 具备技能引用的托管容器 Shell(`examples/tools/container_shell_skill_reference.py`) - - 具备本地技能的本地 Shell(`examples/tools/local_shell_skill.py`) - - 具备命名空间和延迟加载工具的工具搜索(`examples/tools/tool_search.py`) - - 支持并发结构化工具调用的程序化工具调用(`examples/tools/programmatic_tool_calling.py`) + - 支持文件编辑和审批的补丁应用工具(`examples/tools/apply_patch.py`) + - 带审批回调的 Shell 工具执行(`examples/tools/shell.py`) + - 采用基于中断的人工介入审批的 Shell 工具(`examples/tools/shell_human_in_the_loop.py`) + - 带内联技能的托管容器 Shell(`examples/tools/container_shell_inline_skill.py`) + - 带技能引用的托管容器 Shell(`examples/tools/container_shell_skill_reference.py`) + - 带本地技能的本地 Shell(`examples/tools/local_shell_skill.py`) + - 使用命名空间的工具搜索,以及采用延迟加载的工具(`examples/tools/tool_search.py`) + - 具有并发结构化工具调用的程序化工具调用(`examples/tools/programmatic_tool_calling.py`) - 计算机操作 - 图像生成 - 实验性 Codex 工具工作流(`examples/tools/codex.py`) - - 实验性 Codex 同一线程工作流(`examples/tools/codex_same_thread.py`) + - 重复使用同一 Codex 对话线程的实验性 Codex 工作流(`examples/tools/codex_same_thread.py`) -- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** 查看使用我们的 TTS 和 STT 模型构建语音智能体的代码示例,包括流式语音代码示例。 \ No newline at end of file +- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):**查看使用我们的 TTS 和 STT 模型构建语音智能体的代码示例,其中包括流式传输语音代码示例。 \ No newline at end of file diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index 3b2e548c4d..4626f82596 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -4,79 +4,79 @@ search: --- # 安全防护措施 -安全防护措施可用于检查和验证用户输入及智能体输出。例如,假设你有一个使用非常智能(因而速度较慢、成本较高)的模型来协助处理客户请求的智能体。你不会希望恶意用户要求模型帮助他们完成数学作业。因此,你可以使用一个快速且成本较低的模型来运行安全防护措施。如果安全防护措施检测到恶意使用,它可以立即引发错误并阻止高成本模型运行,从而为你节省时间和费用(**使用阻塞式安全防护措施时;对于并行安全防护措施,高成本模型可能在安全防护措施完成前就已开始运行。有关详细信息,请参阅下文的“执行模式”**)。 +安全防护措施可用于检查和验证用户输入及智能体输出。例如,假设您有一个智能体,它使用非常智能(因而速度较慢且成本较高)的模型来协助处理客户请求。您不会希望恶意用户要求该模型帮助他们完成数学作业。因此,您可以使用一个快速且成本较低的模型运行安全防护措施。如果安全防护措施检测到恶意使用行为,它可以立即引发错误,从而节省时间和费用。阻塞执行可保证高成本模型不会启动;采用并行执行时,高成本模型可能在安全防护措施完成前就已启动。有关详细信息,请参阅下文的“执行模式”。 -安全防护措施分为两类: +安全防护措施分为两种: 1. 输入安全防护措施针对初始用户输入运行 2. 输出安全防护措施针对智能体的最终输出运行 ## 工作流边界 -安全防护措施会附加到智能体和工具,但它们并不全都在工作流中的相同节点运行: +安全防护措施会附加到智能体和工具,但它们并非都在工作流中的相同节点运行: - **输入安全防护措施**仅针对链中的第一个智能体运行。 - **输出安全防护措施**仅针对生成最终输出的智能体运行。 -- **工具安全防护措施**会在每次自定义工具调用时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 +- **工具安全防护措施**会在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 -如果需要检查包含管理智能体、任务转移或受委派专家的工作流中的每次自定义工具调用,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 +如果工作流包含管理器、任务转移或受委派的专家,并且您需要在每次自定义函数工具调用之前和/或之后执行检查,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 ## 输入安全防护措施 输入安全防护措施分 3 个步骤运行: 1. 首先,安全防护措施接收传递给智能体的同一输入。 -2. 接下来,安全防护措施函数运行并生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你向用户作出适当响应或处理该异常。 +2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便您适当地回应用户或处理该异常。 -!!! 注意 +!!! Note - 输入安全防护措施旨在针对用户输入运行,因此只有当该智能体是*第一个*智能体时,它的安全防护措施才会运行。你可能会想,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与实际智能体相关——不同的智能体会运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输入安全防护措施旨在针对用户输入运行,因此只有当某个智能体是*第一个*智能体时,其安全防护措施才会运行。您可能会想,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与具体的智能体相关——您会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 ### 执行模式 输入安全防护措施支持两种执行模式: -- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体并发执行。由于两者同时启动,因此这种模式可实现最低延迟。不过,如果安全防护措施未通过,智能体可能已经消耗了 token 并执行了工具,之后才被取消。 +- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体并发执行。由于两者同时启动,因此这种模式可实现最低延迟。但是,如果安全防护措施的触发器被触发,智能体在被取消前可能已经消耗了 token 并执行了工具。 -- **阻塞式执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果触发了安全防护措施的触发器,智能体将完全不会执行,从而避免消耗 token 和执行工具。这种模式非常适合优化成本,以及避免工具调用可能产生的副作用。 +- **阻塞执行**(`run_in_parallel=False`):安全防护措施在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将永远不会执行,从而避免消耗 token 和执行工具。这非常适合优化成本,以及希望避免工具调用产生潜在副作用的场景。 ## 输出安全防护措施 输出安全防护措施分 3 个步骤运行: 1. 首先,安全防护措施接收智能体生成的输出。 -2. 接下来,安全防护措施函数运行并生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你向用户作出适当响应或处理该异常。 +2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便您适当地回应用户或处理该异常。 -!!! 注意 +!!! Note - 输出安全防护措施旨在针对智能体的最终输出运行,因此只有当该智能体是*最后一个*智能体时,它的安全防护措施才会运行。与输入安全防护措施类似,这样做是因为安全防护措施通常与实际智能体相关——不同的智能体会运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输出安全防护措施旨在针对智能体的最终输出运行,因此只有当某个智能体是*最后一个*智能体时,其安全防护措施才会运行。与输入安全防护措施类似,我们这样做是因为安全防护措施通常与具体的智能体相关——您会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 输出安全防护措施始终在智能体完成后运行,因此不支持 `run_in_parallel` 参数。 ## 工具安全防护措施 -工具安全防护措施会封装**工具调用**,让你能够在执行前后验证或阻止工具调用。它们配置在工具本身,并在每次调用该工具时运行。 +工具安全防护措施会包装 **`FunctionTool` 实例**,让您可以在执行前后验证或阻止对这些工具的调用。它们在工具本身上配置,并在每次调用该工具时运行。 -- 工具输入安全防护措施在工具执行前运行,可以跳过调用、使用消息替换输出,或触发安全机制。 -- 工具输出安全防护措施在工具执行后运行,可以替换输出或触发安全机制。 -- 如果工具调用需要审批,工具输入安全防护措施通常会在审批后、即将执行前运行。如果希望这些输入检查在发出待审批中断之前运行,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此次审批前检查的调用仍会在审批后、工具执行前再次接受检查。 -- 工具安全防护措施仅适用于通过 [`function_tool`][agents.tool.function_tool] 创建的工具调用。任务转移通过 SDK 的任务转移管线运行,而不是通过常规工具调用管线,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管线,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不会直接公开工具安全防护措施选项。 +- 输入工具安全防护措施在工具执行前运行,可以跳过调用、使用一条消息替换输出,或触发触发器。 +- 输出工具安全防护措施在工具执行后运行,可以替换输出或触发触发器。 +- 如果函数工具需要审批,输入工具安全防护措施通常会在审批后、执行前立即运行。如果您希望在发出待审批中断之前运行这些输入检查,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此次审批前检查的调用仍会在审批后、工具执行前再次接受检查。 +- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的函数工具。任务转移通过 SDK 的任务转移管道运行,而不是通过常规的函数工具管道运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管道,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前也不直接提供工具安全防护措施选项。 有关详细信息,请参阅下面的代码片段。 -## 触发机制 +## 触发器 如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即引发 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止智能体执行。工具安全防护措施使用对应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 -对于智能体级别的触发器,异常的 `guardrail_result` 用于标识触发该机制的安全防护措施。对于运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,其中包括触发该机制的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供对应的累计结果。 +对于智能体级别的触发器,异常的 `guardrail_result` 会标识触发该触发器的安全防护措施。对于由运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,其中包括触发该触发器的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供等效的累积结果。 -工具触发器异常则会直接公开触发该机制的 `guardrail` 和 `output`。其 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留失败前已完成轮次中累计的结果;触发该机制的结果可通过异常的 `output` 获取。其他由运行器管理的失败(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。在 `stream_events()` 引发异常后,流式结果会公开相同的智能体和工具安全防护措施累计结果列表。如果异常是在运行器管理的执行路径之外引发的,`run_data` 可能为 `None`。 +工具触发器异常则直接公开触发异常的 `guardrail` 和 `output`。其 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留故障发生前已完成轮次中累积的结果;触发异常的结果可通过异常的 `output` 获取。其他由运行器管理的故障(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。`stream_events()` 引发异常后,流式结果会公开同样的智能体和工具安全防护措施累积结果列表。如果异常是在由运行器管理的执行路径之外引发的,`run_data` 可以是 `None`。 -## 安全防护措施实现 +## 安全防护措施的实现 -你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将通过在底层运行一个智能体来实现这一点。 +您需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在内部运行一个智能体来实现这一点。 ```python from pydantic import BaseModel @@ -134,7 +134,7 @@ async def main(): 3. 我们可以在安全防护措施结果中包含额外信息。 4. 这是定义工作流的实际智能体。 -输出安全防护措施与之类似。 +输出安全防护措施与此类似。 ```python from pydantic import BaseModel diff --git a/docs/zh/handoffs.md b/docs/zh/handoffs.md index d6e45e5eab..e825f2d438 100644 --- a/docs/zh/handoffs.md +++ b/docs/zh/handoffs.md @@ -4,17 +4,17 @@ search: --- # 任务转移 -任务转移允许一个智能体将任务委派给另一个智能体。这在不同智能体分别专注于不同领域的场景中尤其有用。例如,客户支持应用可能包含多个智能体,分别专门处理订单状态、退款、常见问题等任务。 +任务转移允许一个智能体将任务委派给另一个智能体。这在不同智能体分别擅长不同领域的场景中特别有用。例如,一个客户支持应用可能包含多个智能体,分别专门处理订单状态、退款、常见问题等任务。 -任务转移会以工具的形式呈现给LLM。因此,如果要将任务转移给名为 `Refund Agent` 的智能体,该工具将被命名为 `transfer_to_refund_agent`。 +任务转移以工具的形式呈现给LLM。因此,如果任务转移的目标是名为 `Refund Agent` 的智能体,则该工具将命名为 `transfer_to_refund_agent`。 ## 任务转移的创建 -所有智能体都有一个 [`handoffs`][agents.agent.Agent.handoffs] 参数,它既可以直接接受 `Agent`,也可以接受用于自定义任务转移的 `Handoff` 对象。 +所有智能体都有一个 [`handoffs`][agents.agent.Agent.handoffs] 参数,该参数既可以直接接收 `Agent`,也可以接收用于自定义任务转移的 `Handoff` 对象。 -如果传入普通的 `Agent` 实例,其 [`handoff_description`][agents.agent.Agent.handoff_description](如果已设置)将附加到默认工具描述中。可以使用它来提示模型何时应选择该任务转移,而无须编写完整的 `handoff()` 对象。 +如果传入普通的 `Agent` 实例,则其 [`handoff_description`][agents.agent.Agent.handoff_description](如果已设置)会附加到默认工具描述中。可使用该属性提示模型应在何时选择该任务转移,而无需编写完整的 `handoff()` 对象。 -你可以使用Agents SDK提供的 [`handoff()`][agents.handoffs.handoff] 函数创建任务转移。此函数允许你指定任务要转移到的智能体,以及可选的覆盖项和输入过滤器。 +你可以使用 Agents SDK 提供的 [`handoff()`][agents.handoffs.handoff] 函数创建任务转移。此函数允许你指定任务要转移到的智能体,以及可选的覆盖项和输入过滤器。 ### 基本用法 @@ -34,18 +34,18 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun ### 通过 `handoff()` 函数自定义任务转移 -[`handoff()`][agents.handoffs.handoff] 函数允许你自定义任务转移。 +[`handoff()`][agents.handoffs.handoff] 函数支持自定义以下内容。 - `agent`:任务将转移到的智能体。 -- `tool_name_override`:默认使用 `Handoff.default_tool_name()` 函数,其结果为 `transfer_to_`。你可以覆盖此设置。 +- `tool_name_override`:默认使用 `Handoff.default_tool_name()` 函数,其解析结果为 `transfer_to_`。你可以覆盖此设置。 - `tool_description_override`:覆盖来自 `Handoff.default_tool_description()` 的默认工具描述。 -- `on_handoff`:调用任务转移时执行的回调函数。它适用于在确认调用任务转移后立即启动数据获取等操作。此函数接收智能体上下文,也可以选择接收LLM生成的输入。输入数据由 `input_type` 参数控制。 -- `input_type`:任务转移工具调用参数的架构。设置后,解析后的有效负载会传递给 `on_handoff`。 -- `input_filter`:用于过滤下一个智能体接收的输入。更多信息请参见下文。 -- `is_enabled`:是否启用任务转移。它可以是布尔值,也可以是返回布尔值的函数,因此你可以在运行时动态启用或禁用任务转移。 -- `nest_handoff_history`:对 RunConfig 级别 `nest_handoff_history` 设置的可选单次调用覆盖。如果为 `None`,则改用当前运行配置中定义的值。 +- `on_handoff`:调用任务转移时执行的回调函数。它适用于在确认调用任务转移后立即启动数据获取等操作。此函数接收智能体上下文,还可以选择接收LLM生成的输入。输入数据由 `input_type` 参数控制。 +- `input_type`:任务转移工具调用参数的 schema。设置后,解析后的载荷将传递给 `on_handoff`。 +- `input_filter`:用于过滤下一个智能体接收的输入。详见下文。 +- `is_enabled`:是否启用任务转移。该值可以是布尔值,也可以是返回布尔值的函数,因此你可以在运行时动态启用或禁用任务转移。 +- `nest_handoff_history`:针对单次任务转移,对 RunConfig 级别 `nest_handoff_history` 设置的可选覆盖。如果为 `None`,则改用当前运行配置中定义的值。 -[`handoff()`][agents.handoffs.handoff] 辅助函数始终会将控制权转移给你传入的特定 `agent`。如果存在多个可能的目标,请为每个目标注册一个任务转移,并让模型从中选择。只有当你自己的任务转移代码必须在调用时决定返回哪个智能体时,才应使用自定义的 [`Handoff`][agents.handoffs.Handoff]。 +[`handoff()`][agents.handoffs.handoff] 辅助函数始终将控制权转移给你所传入的特定 `agent`。如果有多个可能的目标,请为每个目标注册一个任务转移,并让模型从中选择。仅当你自己的任务转移代码必须在调用时决定返回哪个智能体时,才使用自定义的 [`Handoff`][agents.handoffs.Handoff]。 ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## 任务转移输入 -在某些情况下,你希望LLM在调用任务转移时提供一些数据。例如,假设要将任务转移给一个“升级处理智能体”。你可能希望模型提供原因,以便将其记录下来。 +在某些情况下,你希望LLM在调用任务转移时提供一些数据。例如,假设要将任务转移给“升级处理智能体”。你可能希望模型提供原因,以便记录日志。 ```python from pydantic import BaseModel @@ -87,44 +87,44 @@ handoff_obj = handoff( ) ``` -`input_type` 描述任务转移工具调用本身的参数。SDK会将该架构作为任务转移工具的 `parameters` 提供给模型,在本地验证返回的 JSON,并将解析后的值传递给 `on_handoff`。 +`input_type` 描述任务转移工具调用本身的参数。SDK 会将该 schema 作为任务转移工具的 `parameters` 提供给模型,在本地验证返回的 JSON,并将解析后的值传递给 `on_handoff`。 -它不会替换下一个智能体的主要输入,也不会选择其他目标。[`handoff()`][agents.handoffs.handoff] 辅助函数仍会将任务转移给你封装的特定智能体,而接收任务的智能体仍会看到对话历史记录,除非你使用 [`input_filter`][agents.handoffs.Handoff.input_filter] 或嵌套任务转移历史记录设置对其进行更改。 +它不会替换下一个智能体的主要输入,也不会选择不同的目标。[`handoff()`][agents.handoffs.handoff] 辅助函数仍会将任务转移给你封装的特定智能体,而接收智能体仍会看到对话历史记录,除非你通过 [`input_filter`][agents.handoffs.Handoff.input_filter] 或嵌套任务转移历史记录设置对其进行更改。 -`input_type` 也独立于 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。请将 `input_type` 用于模型在任务转移时决定的元数据,而不是你在本地已有的应用状态或依赖项。 +`input_type` 也独立于 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。`input_type` 应用于模型在任务转移时决定的元数据,而不是你已在本地拥有的应用状态或依赖项。 ### `input_type` 的适用场景 -当任务转移需要少量由模型生成的元数据(例如 `reason`、`language`、`priority` 或 `summary`)时,请使用 `input_type`。例如,分流智能体可以将任务转移给退款智能体,同时附带 `{ "reason": "duplicate_charge", "priority": "high" }`;在退款智能体接管任务前,`on_handoff` 可以记录或持久化这些元数据。 +当任务转移需要少量由模型生成的元数据(例如 `reason`、`language`、`priority` 或 `summary`)时,请使用 `input_type`。例如,分流智能体可以通过 `{ "reason": "duplicate_charge", "priority": "high" }` 将任务转移给退款智能体,而 `on_handoff` 可以在退款智能体接管之前记录或持久化该元数据。 如果目标不同,请选择其他机制: - 将现有应用状态和依赖项放入 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。请参阅[上下文指南](context.md)。 -- 如果要更改接收任务的智能体所看到的历史记录,请使用 [`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 或 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]。 -- 如果存在多个可能的专业智能体,请为每个目标注册一个任务转移。`input_type` 可以向选定的任务转移添加元数据,但不会在不同目标之间进行分派。 -- 如果你希望向嵌套的专业智能体提供结构化输入,而不转移对话,请优先使用 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]。请参阅[工具](tools.md#structured-input-for-tool-agents)。 +- 如果要更改接收智能体看到的历史记录,请使用 [`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 或 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]。 +- 如果存在多个可能的专业智能体,请为每个目标注册一个任务转移。`input_type` 可以向所选任务转移添加元数据,但不会在不同目标之间进行分派。 +- 如果希望在不转移对话的情况下为嵌套的专业智能体提供结构化输入,建议使用 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]。请参阅[工具](tools.md#structured-input-for-tool-agents)。 ## 输入过滤器 -发生任务转移时,新智能体就像接管了对话一样,可以看到此前的完整对话历史记录。如果要更改这一行为,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回新的 `HandoffInputData`。 +发生任务转移时,就像新智能体接管了对话,并且可以查看此前的完整对话历史记录。如果要更改这一行为,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回新的 `HandoffInputData`。 [`HandoffInputData`][agents.handoffs.HandoffInputData] 包括: -- `input_history`:`Runner.run(...)` 启动前的输入历史记录。 +- `input_history`:`Runner.run(...)` 启动之前的输入历史记录。 - `pre_handoff_items`:调用任务转移的智能体轮次之前生成的项目。 -- `new_items`:当前轮次中生成的项目,包括任务转移调用和任务转移输出项目。 -- `input_items`:可选项目,用于代替 `new_items` 转发给下一个智能体,让你能够过滤模型输入,同时保持 `new_items` 不变以用于会话历史记录。 +- `new_items`:当前轮次期间生成的项目,包括任务转移调用和任务转移输出项目。 +- `input_items`:可选项目,用于转发给下一个智能体以代替 `new_items`,从而可以过滤模型输入,同时保持会话历史记录中的 `new_items` 不变。 - `run_context`:调用任务转移时处于活动状态的 [`RunContextWrapper`][agents.run_context.RunContextWrapper]。 -嵌套任务转移是一项可选择启用的 Beta 功能;在我们对其进行稳定化期间,默认处于禁用状态。启用 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 后,运行器会将可总结的历史记录压缩为按顺序排列的助手摘要片段,同时将无损消息项目保留在其原始位置。每个生成的摘要片段都使用 `` 包装器;后续任务转移会先展平之前生成的片段,然后再重新构建有序的对话记录。会话、`RunState` 和 `RunResult.to_input_list()` 会追踪已移入此 SDK 默认历史记录中的确切消息实例,从而避免重复附加这些实例;内容相同但彼此独立的消息仍会保留。你可以通过 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] 提供自己的映射函数,以返回下一个智能体所需的确切输入项目列表,而不使用内置分段机制。仅当任务转移和运行均未提供显式 `input_filter` 时,此可选功能才会生效,因此,已经自定义有效负载的现有代码(包括此代码库中的代码示例)无须更改即可保持当前行为。你可以通过向 [`handoff(...)`][agents.handoffs.handoff] 传入 `nest_handoff_history=True` 或 `False`,为单次任务转移覆盖嵌套行为;这会设置 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]。如果只需更改所生成摘要片段的包装器文本,请在运行智能体之前调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](还可选择调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers])。 +嵌套任务转移历史记录以选择加入的测试版功能提供,在我们使其达到稳定状态期间,默认处于禁用状态。启用 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 后,运行器会将可总结的历史记录压缩为有序的助手摘要片段,同时将无损消息项目保留在原始位置。每个生成的摘要片段都使用 `` 包装器,后续任务转移会先展开此前生成的片段,再重新构建有序的对话记录。会话、`RunState` 和 `RunResult.to_input_list()` 会追踪已移入此 SDK 默认历史记录的确切消息实例,以免重复追加这些实例;不同但内容相同的消息仍会保留。你可以通过 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] 提供自己的映射函数,返回下一个智能体所需的确切输入项目列表,而不使用内置分段机制。只有在任务转移的 `input_filter` 和当前运行的 `RunConfig.handoff_input_filter` 均未设置时,选择加入才会生效,因此已经自定义载荷的现有代码(包括此代码仓库中的代码示例)无需更改即可保持当前行为。你可以向 [`handoff(...)`][agents.handoffs.handoff] 传递 `nest_handoff_history=True` 或 `False`,为单次任务转移覆盖嵌套行为,这会设置 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]。如果只需更改生成的摘要片段所用的包装文本,请在运行智能体之前调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]。如果需要在之后的运行中恢复默认包装器,请在运行前调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]。 -如果任务转移和当前 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] 都定义了过滤器,则对于该次特定的任务转移,任务转移级别的 [`input_filter`][agents.handoffs.Handoff.input_filter] 优先。 +如果任务转移和当前 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] 都定义了过滤器,则对于该特定任务转移,单次任务转移的 [`input_filter`][agents.handoffs.Handoff.input_filter] 优先。 !!! note - 任务转移始终在单次运行内进行。输入安全防护措施仍然仅适用于链中的第一个智能体,而输出安全防护措施仅适用于生成最终输出的智能体。如果需要对工作流中的每次自定义函数工具调用执行检查,请使用工具安全防护措施。 + 任务转移始终位于单次运行内。输入安全防护措施仍然仅适用于链中的第一个智能体,输出安全防护措施仅适用于生成最终输出的智能体。如果需要检查工作流中每次自定义函数工具调用,请使用工具安全防护措施。 -有一些常见模式(例如从历史记录中移除所有工具调用),[`agents.extensions.handoff_filters`][] 已为你实现这些模式。 +有一些常见模式(例如从历史记录中移除所有工具调用)已在 [`agents.extensions.handoff_filters`][] 中实现。 ```python from agents import Agent, handoff @@ -138,11 +138,11 @@ handoff_obj = handoff( ) ``` -1. 调用 `FAQ agent` 时,这会自动从历史记录中移除所有工具。 +1. 调用 `FAQ agent` 时,这会自动从历史记录中移除所有与工具相关的项目。 ## 推荐提示词 -为了确保LLM正确理解任务转移,我们建议在智能体中加入有关任务转移的信息。我们在 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] 中提供了建议的前缀,你也可以调用 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][],自动向提示词添加建议的数据。 +为确保LLM正确理解任务转移,我们建议在智能体中加入有关任务转移的信息。我们在 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] 中提供了建议的前缀,你也可以调用 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][],自动将建议的数据添加到提示词中。 ```python from agents import Agent diff --git a/docs/zh/human_in_the_loop.md b/docs/zh/human_in_the_loop.md index 7891388fb4..918e5daeb2 100644 --- a/docs/zh/human_in_the_loop.md +++ b/docs/zh/human_in_the_loop.md @@ -4,19 +4,19 @@ search: --- # 人工介入 -使用人工介入(HITL)流程暂停智能体执行,直到相关人员批准或拒绝敏感的工具调用。工具会声明何时需要审批,运行结果会以中断形式呈现待处理的审批,而`RunState`则允许你在作出决定后序列化并恢复运行。 +使用人工介入(HITL)流程暂停智能体执行,直到有人批准或拒绝敏感工具调用。工具会声明其何时需要审批,运行结果会以中断项的形式显示待处理的审批,而 `RunState` 可让你序列化已暂停的运行,并在作出决定后恢复运行。 -该审批机制适用于整个运行,并不限于当前顶层智能体。无论工具属于当前智能体、通过任务转移到达的智能体,还是嵌套的[`Agent.as_tool()`][agents.agent.Agent.as_tool]执行,都适用相同的模式。在嵌套的`Agent.as_tool()`场景中,中断仍会出现在外层运行中,因此你需要在外层`RunState`上批准或拒绝它,然后恢复原始顶层运行。 +该审批机制适用于整个运行,并不限于当前的顶层智能体。无论工具属于当前智能体、通过任务转移到达的智能体,还是嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 执行,都采用相同的模式。在嵌套的 `Agent.as_tool()` 情况下,中断仍会显示在外层运行中,因此你需要在外层 `RunState` 上批准或拒绝它,然后恢复原始顶层运行。 -使用`Agent.as_tool()`时,审批可能发生在两个不同层级:智能体工具本身可以通过`Agent.as_tool(..., needs_approval=...)`要求审批,而嵌套智能体内的工具也可以在嵌套运行开始后发起自己的审批。两者都通过同一个外层运行中断流程处理。 +使用 `Agent.as_tool()` 时,审批可能发生在两个不同层级:智能体工具本身可以通过 `Agent.as_tool(..., needs_approval=...)` 要求审批,而嵌套智能体中的工具可以在嵌套运行开始后提出各自的审批请求。二者都通过相同的外层运行中断流程处理。 -本页重点介绍通过`interruptions`实现的手动审批流程。如果你的应用能够通过代码作出决定,某些工具类型也支持程序化审批回调,使运行无需暂停即可继续。 +本页重点介绍通过 `interruptions` 进行的人工审批流程。如果你的应用可以通过代码作出决定,某些工具类型也支持程序化审批回调,使运行无需暂停即可继续。 ## 需要审批的工具标记 -将`needs_approval`设置为`True`可始终要求审批,也可以提供一个异步函数来逐次决定。该可调用对象会接收运行上下文、已解析的工具参数和工具调用 ID。 +将 `needs_approval` 设置为 `True`,可始终要求审批;也可以提供一个异步函数,按每次调用作出决定。该可调用对象会接收运行上下文、已解析的工具参数和工具调用 ID。 -当 SDK 无法安全检查参数时,可调用审批规则会采用失败关闭策略。如果参数是格式错误的 JSON、是有效 JSON 但并非对象(例如`null`或列表),或者包含`NaN`、`Infinity`或`-Infinity`等非标准常量,则不会调用该可调用对象,而是要求手动审批。Runner 和 Realtime 工具调用的行为相同。 +当 SDK 无法安全检查参数时,可调用的审批规则会采取默认拒绝策略。如果参数是格式错误的 JSON、是有效 JSON 但并非对象(例如 `null` 或列表),或者包含 `NaN`、`Infinity` 或 `-Infinity` 等非标准常量,则不会调用该可调用对象,并且该调用需要人工审批。Runner 和 Realtime 工具调用的行为相同。 ```python from agents import Agent @@ -44,28 +44,28 @@ agent = Agent( ) ``` -[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]和[`ApplyPatchTool`][agents.tool.ApplyPatchTool]均支持`needs_approval`。本地MCP服务也支持通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]和[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]上的`require_approval`进行审批。托管式MCP服务通过[`HostedMCPTool`][agents.tool.HostedMCPTool]支持审批,可设置`tool_config={"require_approval": "always"}`,并可选择提供`on_approval_request`回调。如果希望自动批准或自动拒绝,而不呈现中断,Shell 和 apply_patch 工具可接受`on_approval`回调。 +`needs_approval` 可用于 [`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]。本地 MCP服务器也通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse] 和 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 上的 `require_approval` 支持审批。托管式 MCP服务器通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 支持审批,该工具使用 `tool_config={"require_approval": "always"}` 和可选的 `on_approval_request` 回调。如果你希望自动批准或自动拒绝,而不触发中断,Shell 和 apply_patch 工具可接受 `on_approval` 回调。 -## 审批流程机制 +## 审批流程的工作原理 -1. 当模型发出工具调用时,运行器会评估其审批规则(`needs_approval`、`require_approval`或托管式MCP的对应设置)。 -2. 如果该工具调用的审批决定已经存储在[`RunContextWrapper`][agents.run_context.RunContextWrapper]中,运行器会直接继续而不发出提示。单次调用审批仅适用于特定调用 ID;传入`always_approve=True`或`always_reject=True`,可在本次运行剩余期间对该工具之后的调用持续应用同一决定。 -3. 否则,执行会暂停,`RunResult.interruptions`(或`RunResultStreaming.interruptions`)中会包含[`ToolApprovalItem`][agents.items.ToolApprovalItem]条目,其中提供`agent.name`、`tool_name`和`arguments`等详细信息。这也包括任务转移后或嵌套`Agent.as_tool()`执行中发起的审批。 -4. 使用`result.to_state()`将结果转换为`RunState`,调用`state.approve(...)`或`state.reject(...)`,然后通过`Runner.run(agent, state)`或`Runner.run_streamed(agent, state)`恢复运行,其中`agent`是本次运行的原始顶层智能体。 -5. 恢复后的运行会从暂停处继续,并在需要新审批时重新进入此流程。 +1. 当模型发出工具调用时,运行器会评估其审批规则(`needs_approval`、`require_approval` 或托管式 MCP 的对应规则)。 +2. 如果该工具调用的审批决定已存储在 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 中,运行器将直接继续执行,不再提示。每次调用的审批仅适用于特定调用 ID;传入 `always_approve=True` 或 `always_reject=True`,可在本次运行剩余期间,为以后对该工具的调用保留相同决定。 +3. 如果审批规则要求审批,但尚未存储该工具调用的决定,执行会暂停,并且 `RunResult.interruptions`(或 `RunResultStreaming.interruptions`)会包含 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 条目,其中具有 `agent.name`、`tool_name` 和 `arguments` 等详细信息。这包括任务转移后或嵌套 `Agent.as_tool()` 执行中提出的审批请求。 +4. 使用 `result.to_state()` 将结果转换为 `RunState`,调用 `state.approve(...)` 或 `state.reject(...)`,然后使用 `Runner.run(agent, state)` 或 `Runner.run_streamed(agent, state)` 恢复运行,其中 `agent` 是该运行的原始顶层智能体。 +5. 恢复后的运行会从暂停处继续;如果需要新的审批,则会再次进入此流程。 -使用`always_approve=True`或`always_reject=True`创建的持久决定会存储在运行状态中,因此当你之后恢复同一暂停运行时,这些决定会通过`state.to_string()` / `RunState.from_string(...)`和`state.to_json()` / `RunState.from_json(...)`保留下来。 +使用 `always_approve=True` 或 `always_reject=True` 创建的持久决定会存储在运行状态中,因此之后恢复同一已暂停的运行时,它们可以在 `state.to_string()` / `RunState.from_string(...)` 和 `state.to_json()` / `RunState.from_json(...)` 过程中继续保留。 -你无需在同一次处理中解决所有待审批项。`interruptions`中可以同时包含常规工具调用、托管式MCP审批和嵌套的`Agent.as_tool()`审批。如果你仅批准或拒绝其中部分项目后重新运行,已处理的调用可以继续,而未处理的项目仍会保留在`interruptions`中并再次暂停运行。 +你不必在同一次处理中解决所有待审批项。`interruptions` 可以同时包含常规函数工具、托管式 MCP 审批和嵌套的 `Agent.as_tool()` 审批。如果你仅批准或拒绝部分条目后重新运行,已解决的调用可以继续执行,而未解决的调用会保留在 `interruptions` 中,并再次暂停运行。 ## 自定义拒绝消息 默认情况下,被拒绝的工具调用会将 SDK 的标准拒绝文本返回到运行中。你可以在两个层级自定义该消息: -- 运行级后备设置:设置[`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter],以控制整个运行中审批被拒绝时默认向模型显示的消息。 -- 单次调用覆盖:当你希望某个被拒绝的特定工具调用呈现不同消息时,将`rejection_message=...`传给`state.reject(...)`。 +- 整个运行的回退设置:设置 [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter],以控制整个运行中审批被拒绝时模型可见的默认消息。 +- 单次调用覆盖:如果希望某个特定的被拒绝工具调用显示不同的消息,请将 `rejection_message=...` 传给 `state.reject(...)`。 -如果两者均已提供,则单次调用的`rejection_message`优先于运行级格式化器。 +如果二者都已提供,则单次调用的 `rejection_message` 优先于整个运行的格式化程序。 ```python from agents import RunConfig, ToolErrorFormatterArgs @@ -86,27 +86,27 @@ state.reject( ) ``` -有关同时展示这两个层级的完整代码示例,请参阅[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)。 +有关同时展示这两个层级的完整代码示例,请参阅 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)。 -## 自动审批决策 +## 自动审批决定 -手动`interruptions`是最通用的模式,但并非唯一选择: +手动处理 `interruptions` 是最通用的模式,但并非唯一模式: -- 本地[`ShellTool`][agents.tool.ShellTool]和[`ApplyPatchTool`][agents.tool.ApplyPatchTool]可以使用`on_approval`在代码中立即批准或拒绝。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool]可以将`tool_config={"require_approval": "always"}`与`on_approval_request`结合使用,实现同类程序化决策。 -- 普通[`function_tool`][agents.tool.function_tool]工具和[`Agent.as_tool()`][agents.agent.Agent.as_tool]使用本页介绍的手动中断流程。 +- 本地 [`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool] 可以使用 `on_approval`,立即在代码中批准或拒绝。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] 可以结合使用 `tool_config={"require_approval": "always"}` 和 `on_approval_request`,作出同类程序化决定。 +- 普通 [`function_tool`][agents.tool.function_tool] 工具和 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 使用本页介绍的手动中断流程。 -当这些回调返回决定时,运行会继续,而无需暂停以等待人工响应。对于 Realtime 和语音会话 API,请参阅[Realtime 指南](realtime/guide.md)中的审批流程。 +当这些回调返回决定时,运行会继续,而无需暂停以等待人工响应。对于 Realtime 和语音会话 API,请参阅 [Realtime 指南](realtime/guide.md)中的审批流程。 ## 流式传输与会话 -相同的中断流程也适用于流式传输运行。流式运行暂停后,继续消费[`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events],直到迭代器结束;然后检查[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions],处理其中的中断。如果希望恢复后的输出继续进行流式传输,请使用[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]恢复。有关此模式的流式传输版本,请参阅[流式传输](streaming.md)。 +相同的中断流程也适用于流式运行。流式运行暂停后,继续使用 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events],直到迭代器结束;然后检查 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]、处理中断项,并使用 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] 恢复运行,以使恢复后的输出继续进行流式传输。有关该模式的流式版本,请参阅[流式传输](streaming.md)。 -如果你还在使用会话,从`RunState`恢复时应继续传入同一个会话实例,或者传入指向同一底层存储的另一个会话对象。恢复后的轮次会追加到同一份已存储对话历史中。有关会话生命周期的详细信息,请参阅[会话](sessions/index.md)。 +如果你还在使用会话,从 `RunState` 恢复时,请继续传入同一个会话实例,或传入另一个为相同会话 ID 和后端存储配置的会话对象。恢复后的轮次随后会追加到同一份已存储的对话历史中。有关会话生命周期的详细信息,请参阅[会话](sessions/index.md)。 ## 示例:暂停、批准与恢复 -以下代码片段与 JavaScript HITL 指南中的流程一致:当工具需要审批时暂停运行,将状态持久化到磁盘,重新加载状态,并在获得决定后恢复运行。 +下面的代码片段与 JavaScript HITL 指南中的流程一致:当工具需要审批时暂停,将状态持久化到磁盘,重新加载状态,并在收集到决定后恢复运行。 ```python import asyncio @@ -171,35 +171,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -在此代码示例中,`prompt_approval`是同步函数,因为它使用`input()`,并通过`run_in_executor(...)`执行。如果你的审批来源已经是异步的(例如 HTTP 请求或异步数据库查询),则可以使用`async def`函数并直接对其使用`await`。 +在此示例中,`prompt_approval` 是同步函数,因为它使用 `input()`,并通过 `run_in_executor(...)` 执行。如果你的审批来源本身已经是异步的(例如 HTTP 请求或异步数据库查询),则可以使用 `async def` 函数,并直接对其执行 `await`。 -若要在等待审批时以流式传输方式输出,请调用`Runner.run_streamed`,消费`result.stream_events()`直至完成,然后按照上文所示执行相同的`result.to_state()`和恢复步骤。 +要在可能因审批而暂停的运行中使用流式传输,请调用 `Runner.run_streamed`,持续使用 `result.stream_events()` 直至完成,然后执行上文所示的相同 `result.to_state()` 和恢复步骤。 ## 仓库模式与代码示例 -- **流式传输审批**:`examples/agent_patterns/human_in_the_loop_stream.py`展示了如何读取完`stream_events()`,然后批准待处理的工具调用,再通过`Runner.run_streamed(agent, state)`恢复运行。 -- **自定义拒绝文本**:`examples/agent_patterns/human_in_the_loop_custom_rejection.py`展示了审批被拒绝时,如何将运行级`tool_error_formatter`与单次调用的`rejection_message`覆盖结合使用。 -- **智能体作为工具的审批**:当委托的智能体任务需要审核时,`Agent.as_tool(..., needs_approval=...)`会应用相同的中断流程。嵌套中断仍会出现在外层运行中,因此应恢复原始顶层智能体,而不是嵌套智能体。 -- **本地 shell 和 apply_patch 工具**:`ShellTool`和`ApplyPatchTool`也支持`needs_approval`。使用`state.approve(interruption, always_approve=True)`或`state.reject(..., always_reject=True)`,可为之后的调用缓存该决定。对于自动决策,请提供`on_approval`(参阅`examples/tools/shell.py`);对于手动决策,请处理中断(参阅`examples/tools/shell_human_in_the_loop.py`)。托管 shell 环境不支持`needs_approval`或`on_approval`;请参阅[工具指南](tools.md)。 -- **本地MCP服务**:使用`MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp`上的`require_approval`为MCP工具调用设置审批门槛(参阅`examples/mcp/get_all_mcp_tools_example/main.py`和`examples/mcp/tool_filter_example/main.py`)。 -- **托管式MCP服务**:将`HostedMCPTool`上的`require_approval`设置为`"always"`,可强制启用 HITL;也可以提供`on_approval_request`以自动批准或拒绝(参阅`examples/hosted_mcp/human_in_the_loop.py`和`examples/hosted_mcp/on_approval.py`)。对于可信服务,请使用`"never"`(`examples/hosted_mcp/simple.py`)。 -- **会话与记忆**:将会话传给`Runner.run`,使审批和对话历史能够跨多个轮次保留。SQLite 和 OpenAI Conversations 会话变体位于`examples/memory/memory_session_hitl_example.py`和`examples/memory/openai_session_hitl_example.py`中。 -- **Realtime智能体**:Realtime 演示提供了 WebSocket 消息,可通过`RealtimeSession`上的`approve_tool_call` / `reject_tool_call`批准或拒绝工具调用(有关服务端处理程序,请参阅`examples/realtime/app/server.py`;有关 API 接口,请参阅[Realtime 指南](realtime/guide.md#tool-approvals))。 +- **流式审批**:`examples/agent_patterns/human_in_the_loop_stream.py` 展示如何完整消费 `stream_events()`,然后批准待处理的工具调用,再使用 `Runner.run_streamed(agent, state)` 恢复运行。 +- **自定义拒绝文本**:`examples/agent_patterns/human_in_the_loop_custom_rejection.py` 展示审批被拒绝时,如何将运行级 `tool_error_formatter` 与单次调用的 `rejection_message` 覆盖结合使用。 +- **智能体作为工具的审批**:当委派给智能体的任务需要审核时,`Agent.as_tool(..., needs_approval=...)` 会应用相同的中断流程。嵌套中断仍会显示在外层运行中,因此应恢复原始顶层智能体,而非嵌套智能体。 +- **本地 Shell 和 apply_patch 工具**:`ShellTool` 和 `ApplyPatchTool` 也支持 `needs_approval`。使用 `state.approve(interruption, always_approve=True)` 或 `state.reject(..., always_reject=True)`,可在本次运行剩余期间缓存决定,供以后对该工具的调用使用。对于自动决定,请提供 `on_approval`(参阅 `examples/tools/shell.py`);对于手动决定,请处理中断项(参阅 `examples/tools/shell_human_in_the_loop.py`)。托管式 Shell 环境不支持 `needs_approval` 或 `on_approval`;请参阅[工具指南](tools.md)。 +- **本地 MCP服务器**:使用 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` 上的 `require_approval`,对 MCP 工具调用设置审批门控(参阅 `examples/mcp/get_all_mcp_tools_example/main.py` 和 `examples/mcp/tool_filter_example/main.py`)。 +- **托管式 MCP服务器**:在 `HostedMCPTool` 上设置 `tool_config={"require_approval": "always"}` 以强制执行 HITL,也可以选择提供 `on_approval_request` 以自动批准或拒绝(参阅 `examples/hosted_mcp/human_in_the_loop.py` 和 `examples/hosted_mcp/on_approval.py`)。对于可信服务器,请使用 `"never"`(`examples/hosted_mcp/simple.py`)。 +- **会话与记忆**:将会话传给 `Runner.run`,使审批和对话历史能够跨多个轮次保留。SQLite 和 OpenAI Conversations 会话变体位于 `examples/memory/memory_session_hitl_example.py` 和 `examples/memory/openai_session_hitl_example.py` 中。 +- **实时智能体**:实时演示提供了 WebSocket 消息,可通过 `RealtimeSession` 上的 `approve_tool_call` / `reject_tool_call` 批准或拒绝工具调用(有关服务器端处理程序,请参阅 `examples/realtime/app/server.py`;有关 API 接口,请参阅 [Realtime 指南](realtime/guide.md#tool-approvals))。 -## 长时审批 +## 长时间运行的审批 -`RunState`采用持久化设计。使用`state.to_json()`或`state.to_string()`将待处理工作存储在数据库或队列中,之后再通过`RunState.from_json(...)`或`RunState.from_string(...)`重新创建。 +`RunState` 采用持久化设计。使用 `state.to_json()` 或 `state.to_string()` 将待处理工作存储在数据库或队列中,之后再使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 重新创建它。 -实用的序列化选项: +可用的序列化选项: -- `context_serializer`:自定义非映射类型上下文对象的序列化方式。 -- `context_deserializer`:使用`RunState.from_json(...)`或`RunState.from_string(...)`加载状态时,重新构建非映射类型上下文对象。 -- `strict_context=True`:除非上下文本身已是映射类型,或者你提供了相应的序列化器/反序列化器,否则序列化或反序列化会失败。 -- `context_override`:加载状态时替换已序列化的上下文。当你不想恢复原始上下文对象时,此选项非常有用,但它不会从已序列化的有效载荷中移除该上下文。 -- `include_tracing_api_key=True`:当你需要恢复后的工作继续使用相同凭据导出追踪数据时,将追踪 API 密钥包含在已序列化的追踪有效载荷中。 +- `context_serializer`:自定义非映射上下文对象的序列化方式。 +- `context_deserializer`:使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 加载状态时,重新构建非映射上下文对象。 +- `strict_context=True`:除非上下文本身已是映射或你提供了 `context_serializer`,否则序列化失败;除非上下文本身已是映射或你提供了 `context_deserializer`,否则反序列化失败。 +- `context_override`:加载状态时替换已序列化的上下文。如果你不想恢复原始上下文对象,此选项会很有用,但它不会从已序列化的载荷中移除该上下文。 +- `include_tracing_api_key=True`:当恢复的工作需要继续使用相同凭据导出追踪数据时,在已序列化的追踪载荷中包含追踪 API 密钥。 -已序列化的运行状态包含应用上下文,以及由 SDK 管理的运行时元数据,例如审批、使用量、已序列化的`tool_input`、嵌套的智能体工具恢复信息、追踪元数据和服务端管理的对话设置。如果你计划存储或传输已序列化状态,应将`RunContextWrapper.context`视为持久化数据,并避免在其中放置机密信息,除非你明确希望这些信息随状态一同传递。 +已序列化的运行状态包括你的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量、已序列化的 `tool_input`、嵌套的智能体作为工具的恢复信息、追踪元数据和服务器管理的对话设置。如果你计划存储或传输已序列化的状态,请将 `RunContextWrapper.context` 视为持久化数据,并避免在其中放置机密信息,除非你确实希望这些机密随状态一起传输。 -## 待处理任务版本管理 +## 待处理任务的版本控制 -如果审批可能会搁置一段时间,请将智能体定义或 SDK 的版本标记与已序列化状态一起存储。之后,你可以将反序列化路由到匹配的代码路径,以避免模型、提示词或工具定义发生变化时出现不兼容问题。 \ No newline at end of file +如果审批可能搁置一段时间,请将智能体定义或 SDK 的版本标记与已序列化状态一同存储。随后,你可以将反序列化路由到匹配的代码路径,以避免模型、提示词或工具定义发生变化时出现不兼容问题。 \ No newline at end of file diff --git a/docs/zh/index.md b/docs/zh/index.md index f0a4653ef8..aeab85e8de 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -4,52 +4,52 @@ search: --- # OpenAI Agents SDK -[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)让你能够通过一个轻量、易用且仅包含少量抽象概念的软件包构建智能体式 AI 应用。它是我们此前智能体实验项目[Swarm](https://github.com/openai/swarm/tree/main)面向生产环境的升级版本。Agents SDK仅包含一组非常精简的基本组件: +[OpenAI Agents SDK](https://github.com/openai/openai-agents-python)让您能够使用一个轻量、易用且仅包含极少抽象概念的软件包,构建智能体式 AI 应用。它是我们之前智能体实验项目[Swarm](https://github.com/openai/swarm/tree/main)的生产就绪升级版。Agents SDK 仅包含一小组基础组件: -- **智能体**,即配备指令和工具的LLM +- **智能体**,即配备了指令和工具的 LLM - **Agents as tools / 任务转移**,允许智能体将特定任务委派给其他智能体 - **安全防护措施**,用于验证智能体的输入和输出 -这些基本组件与 Python 结合后,足以表达工具与智能体之间的复杂关系,让你无需经历陡峭的学习曲线即可构建实际应用。此外,SDK 还内置了**追踪**功能,可用于可视化和调试智能体流程、对其进行评估,甚至针对你的应用微调模型。 +这些基础组件与 Python 结合使用时,足以表达工具与智能体之间的复杂关系,让您无需经历陡峭的学习曲线即可构建实际应用。此外,SDK 还内置了**追踪**功能,让您能够可视化和调试智能体流程、对其进行评估,甚至针对您的应用微调模型。 -## 使用Agents SDK的理由 +## Agents SDK 的使用理由 SDK 遵循两项核心设计原则: -1. 提供足够丰富、值得使用的功能,同时保持基本组件精简,以便快速上手。 -2. 开箱即用,同时允许你精确自定义具体行为。 +1. 提供足以带来使用价值的功能,同时将基础组件控制在较少数量,以便快速学习。 +2. 开箱即用,同时允许您精确自定义具体行为。 -SDK 的主要功能包括: +以下是 SDK 的主要功能: -- **智能体**:使用指令、工具、安全防护措施和任务转移构建智能体,并通过内置循环持续运行,直至任务完成。 -- **沙箱智能体**:在真实的隔离工作区中运行专业智能体,支持由清单定义的文件、沙箱客户端选择,以及可恢复的沙箱会话。 -- **实时智能体**:使用`gpt-realtime-2.1`构建强大的语音智能体,支持自动中断检测、上下文管理、安全防护措施等功能。 +- **智能体**:使用指令、工具、安全防护措施、任务转移以及持续运行直至任务完成的内置循环来构建智能体。 +- **沙箱智能体**:在真正隔离的工作区中运行专项智能体。沙箱智能体支持由清单定义的文件、沙箱客户端选择,以及可恢复的沙箱会话。 +- **实时智能体**:使用`gpt-realtime-2.1`、自动中断检测、上下文管理、安全防护措施等功能构建强大的语音智能体。 - **语音智能体**:构建结合语音转文本、智能体工作流和文本转语音的语音管线。 -- **Python 优先**:使用内置语言功能编排和串联智能体,无需学习新的抽象概念。 -- **Agents as tools / 任务转移**:一种强大的机制,用于在多个智能体之间协调和委派工作。 -- **安全防护措施**:在智能体执行的同时并行运行输入验证和安全检查,并在检查未通过时快速失败。 -- **工具调用**:将任意 Python 函数转换为工具,并自动生成模式,同时使用 Pydantic 进行验证。 -- **MCP服务工具调用**:内置MCP服务工具集成,使用方式与工具调用相同。 -- **会话**:一种持久化记忆层,用于在智能体循环中维护工作上下文。 -- **人在回路**:内置在多次智能体运行中引入人工参与的机制。 -- **追踪**:内置追踪功能,用于可视化、调试和监控工作流,并支持OpenAI的一整套评估、微调和蒸馏工具。 +- **Python 优先**:使用内置语言特性编排和串联智能体,无需学习新的抽象概念。 +- **Agents as tools / 任务转移**:一种在多个智能体之间协调和委派工作的强大机制。 +- **安全防护措施**:在执行智能体的同时并行运行输入验证和安全检查,并在检查未通过时快速失败。 +- **函数工具**:通过自动生成模式和由 Pydantic 提供支持的验证,将任意 Python 函数转换为工具。 +- **MCP 服务器工具调用**:内置集成,可同时向智能体提供远程 MCP 工具和函数工具。 +- **会话**:用于在智能体循环中维护工作上下文的持久化记忆层。 +- **人在回路中**:用于在智能体运行期间引入人工参与的内置机制。 +- **追踪**:用于可视化、调试和监控工作流的内置追踪功能,并支持 OpenAI 的评估、微调和蒸馏工具套件。 -## Agents SDK与Responses API的选择 +## Agents SDK 与 Responses API 的选择 -对于OpenAI模型,SDK 默认使用 Responses API,但在模型调用之外增加了更高级别的运行时。 +对于 OpenAI 模型,SDK 默认使用 Responses API,但它会将模型调用封装在更高层级的运行时中。 -以下情况可直接使用 Responses API: +以下情况适合直接使用 Responses API: -- 你希望自行控制循环、工具分派和状态处理 -- 你的工作流生命周期较短,主要目标是返回模型响应 +- 您希望自行掌控循环、工具分派和状态处理 +- 您的工作流生命周期较短,主要目标是返回模型响应 -以下情况可使用Agents SDK: +以下情况适合使用 Agents SDK: -- 你希望由运行时管理轮次、工具执行、安全防护措施、任务转移或会话 -- 你的智能体需要生成产物,或通过多个协调步骤执行操作 -- 你需要通过[沙箱智能体](sandbox_agents.md)使用真实工作区或可恢复执行 +- 您希望由运行时管理轮次、工具执行、安全防护措施、任务转移或会话 +- 您的智能体需要生成产物,或通过多个协调步骤完成操作 +- 您需要通过[沙箱智能体](sandbox_agents.md)获得真实工作区或可恢复执行能力 -你不必在整个应用中只选择其中一种。许多应用使用 SDK 处理受管理的工作流,并针对较底层的路径直接调用 Responses API。 +您无需在整个应用中只选择一种方式。许多应用会使用 SDK 管理工作流,同时针对较低层级的执行路径直接调用 Responses API。 ## 安装 @@ -57,7 +57,7 @@ SDK 的主要功能包括: pip install openai-agents ``` -## Hello world示例 +## Hello world 示例 ```python from agents import Agent, Runner @@ -72,31 +72,31 @@ print(result.final_output) # Infinite loop's dance. ``` -(_运行此示例时,请确保已设置`OPENAI_API_KEY`环境变量_) +(_运行此代码时,请确保已设置`OPENAI_API_KEY`环境变量_) ```bash export OPENAI_API_KEY=sk-... ``` -## 入门指南 +## 入门 -- 通过[快速入门](quickstart.md)构建你的第一个文本智能体。 -- 然后在[运行智能体](running_agents.md#choose-a-memory-strategy)中确定如何跨轮次保留状态。 -- 如果任务依赖真实文件、代码仓库或每个智能体独立的工作区状态,请阅读[沙箱智能体快速入门](sandbox_agents.md)。 -- 如果你正在任务转移与管理器式编排之间进行选择,请阅读[智能体编排](multi_agent.md)。 +- 通过[快速入门](quickstart.md)构建您的第一个文本智能体。 +- 然后在[运行智能体](running_agents.md#choose-a-memory-strategy)中决定如何跨轮次传递状态。 +- 如果任务依赖真实文件、仓库或每个智能体独立的隔离工作区状态,请阅读[沙箱智能体快速入门](sandbox_agents.md)。 +- 如果您正在任务转移与管理器式编排之间进行选择,请阅读[智能体编排](multi_agent.md)。 ## 路径选择 -当你知道要完成什么工作,但不确定哪个页面提供相关说明时,可使用下表。 +当您明确想完成的工作,但不确定应该参阅哪个页面时,请使用此表。 | 目标 | 入门页面 | | --- | --- | | 构建第一个文本智能体并查看一次完整运行 | [快速入门](quickstart.md) | -| 添加工具调用、托管工具或Agents as tools | [工具](tools.md) | -| 在真实的隔离工作区中运行编码、审查或文档智能体 | [沙箱智能体快速入门](sandbox_agents.md)和[沙箱客户端](sandbox/clients.md) | +| 添加函数工具、托管工具或 agents as tools | [工具](tools.md) | +| 在真正隔离的工作区中运行编码、审查或文档智能体 | [沙箱智能体快速入门](sandbox_agents.md)和[沙箱客户端](sandbox/clients.md) | | 在任务转移与管理器式编排之间进行选择 | [智能体编排](multi_agent.md) | | 跨轮次保留记忆 | [运行智能体](running_agents.md#choose-a-memory-strategy)和[会话](sessions/index.md) | -| 使用OpenAI模型、WebSocket 传输或非OpenAI提供商 | [模型](models/index.md) | -| 查看输出、运行项、中断和恢复状态 | [结果](results.md) | +| 使用 OpenAI 模型、WebSocket 传输或非 OpenAI 提供商 | [模型](models/index.md) | +| 检查输出、运行项、中断和恢复状态 | [结果](results.md) | | 使用`gpt-realtime-2.1`构建低延迟语音智能体 | [实时智能体快速入门](realtime/quickstart.md)和[实时传输](realtime/transport.md) | | 构建语音转文本 / 智能体 / 文本转语音管线 | [语音管线快速入门](voice/quickstart.md) | \ No newline at end of file diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md index 28bbd9ab86..e6e857487b 100644 --- a/docs/zh/mcp.md +++ b/docs/zh/mcp.md @@ -4,34 +4,35 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)对应用如何向语言模型公开工具和上下文进行了标准化。官方文档中的定义如下: +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)规范了应用程序向语言模型公开工具和 +上下文的方式。根据官方文档: -> MCP是一种开放协议,对应用如何向LLMs提供上下文进行了标准化。可以将MCP视为AI -> 应用的 USB-C 端口。正如 USB-C 提供了一种将设备连接到各种外围设备和配件的标准化方式,MCP -> 也提供了一种将 AI 模型连接到不同数据源和工具的标准化方式。 +> MCP是一种开放协议,用于规范应用程序向LLM提供上下文的方式。可以将MCP视为AI +> 应用程序的USB-C端口。正如USB-C提供了一种将设备连接到各种外围设备和配件的标准化方式,MCP +> 也提供了一种将AI模型连接到不同数据源和工具的标准化方式。 -Agents Python SDK支持多种MCP传输方式。这样,你可以复用现有的MCP服务,也可以构建自己的服务,向智能体公开由文件系统、HTTP 或连接器支持的工具。 +Agents Python SDK支持多种MCP传输方式。这样,你既可以复用现有MCP服务器,也可以构建自己的服务器,向智能体公开由文件系统、HTTP或连接器支持的工具。 -!!! warning "连接前信任MCP服务" +!!! warning "连接前请确认MCP服务器可信" - MCP工具可以公开模型上下文中的数据,并使用你提供的凭据执行操作。请仅连接到你信任的服务,使用最小权限凭据,将访问令牌放在授权字段或标头中而非 URL 中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 + MCP工具可能会公开模型上下文中的数据,并使用你提供的凭证执行操作。请仅连接到你信任的服务器,使用最小权限凭证,将访问令牌放在授权字段或标头中而不是URL中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 -## MCP集成方案选择 +## MCP集成选项 -在将MCP服务接入智能体之前,需要确定工具调用应在何处执行,以及你可以访问哪些传输方式。下表汇总了 Python SDK支持的选项。 +在将MCP服务器接入智能体之前,请确定工具调用应在何处执行,以及你可以访问哪些传输方式。下表汇总了Python SDK支持的选项。 | 你的需求 | 推荐选项 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| 让OpenAI的 Responses API代表模型调用可公开访问的MCP服务| 通过[`HostedMCPTool`][agents.tool.HostedMCPTool]使用**托管式MCP服务工具** | -| 连接到你在本地或远程运行的 Streamable HTTP 服务 | 通过[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]使用**Streamable HTTP MCP服务** | -| 与实现了基于 Server-Sent Events 的 HTTP 的服务通信 | 通过[`MCPServerSse`][agents.mcp.server.MCPServerSse]使用**基于 SSE 的 HTTP MCP服务** | -| 启动本地进程并通过 stdin/stdout 通信 | 通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]使用**stdio MCP服务** | +| 让OpenAI的Responses API代表模型调用可公开访问的MCP服务器| 通过[`HostedMCPTool`][agents.tool.HostedMCPTool]使用**托管式MCP服务器工具** | +| 连接到你在本地或远程运行的Streamable HTTP服务器 | 通过[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]使用**Streamable HTTP MCP服务器** | +| 与实现了使用Server-Sent Events的HTTP协议的服务器通信 | 通过[`MCPServerSse`][agents.mcp.server.MCPServerSse]使用**使用SSE的HTTP MCP服务器** | +| 启动本地进程并通过stdin/stdout通信 | 通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]使用**stdio MCP服务器** | -以下各节将介绍每种选项、配置方式,以及何时应优先选择某种传输方式。 +以下各节将逐一介绍每个选项、配置方式,以及何时应优先选择某种传输方式。 ## 智能体级MCP配置 -除了选择传输方式之外,还可以通过设置 `Agent.mcp_config` 来调整MCP工具的准备方式。 +除了选择传输方式之外,你还可以通过设置`Agent.mcp_config`来调整MCP工具的准备方式。 ```python from agents import Agent @@ -53,31 +54,31 @@ agent = Agent( 注意: -- `convert_schemas_to_strict` 会尽力执行转换。如果某个架构无法转换,则使用原始架构。 -- `failure_error_function` 控制如何向模型呈现MCP工具调用失败。 -- 未设置 `failure_error_function` 时,SDK使用默认的工具错误格式化程序。 -- 服务级 `failure_error_function` 会覆盖该服务的 `Agent.mcp_config["failure_error_function"]`。 -- `include_server_in_tool_names` 需要显式启用。启用后,每个本地MCP工具都会以带有确定性服务前缀的名称公开给模型,这有助于避免多个MCP服务发布同名工具时发生冲突。生成的名称符合 ASCII 安全要求,不超过工具调用名称的长度限制,并且不会与同一智能体上现有的本地工具调用及已启用的任务转移名称冲突。SDK仍会在原始服务上调用原始MCP工具名称。 +- `convert_schemas_to_strict`采用尽力而为的方式。如果某个模式无法转换,则使用原始模式。 +- `failure_error_function`控制如何向模型呈现MCP工具调用失败。 +- 未设置`failure_error_function`时,SDK使用默认的工具错误格式化器。 +- 服务器级的`failure_error_function`会覆盖该服务器的`Agent.mcp_config["failure_error_function"]`。 +- `include_server_in_tool_names`需要主动启用。启用后,每个本地MCP工具都会以带有确定性服务器前缀的名称向模型公开,这有助于避免多个MCP服务器发布同名工具时发生冲突。生成的名称兼容ASCII,并且不超过`FunctionTool`实例的名称长度限制,也不会与同一智能体上本地`FunctionTool`实例的已配置名称或已启用的任务转移发生冲突。SDK仍会在原始服务器上调用具有原始名称的MCP工具。 ## 各传输方式的通用模式 -选择传输方式后,大多数集成还需要做出相同的后续决策: +选择传输方式后,大多数集成都需要做出相同的后续决策: -- 如何仅公开工具的一个子集([工具筛选](#tool-filtering))。 -- 服务是否还提供可复用的提示词([提示词](#prompts))。 -- 是否应缓存 `list_tools()`([缓存](#caching))。 -- MCP活动如何显示在追踪记录中([追踪](#tracing))。 +- 如何仅公开部分工具([工具筛选](#tool-filtering))。 +- 服务器是否还提供可复用的提示词([提示词](#prompts))。 +- 是否应缓存`list_tools()`([缓存](#caching))。 +- MCP活动如何显示在追踪中([追踪](#tracing))。 -对于本地MCP服务(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的 `_meta` 负载也是通用概念。Streamable HTTP 一节展示了最完整的代码示例,同样的模式也适用于其他本地传输方式。 +对于本地MCP服务器(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的`_meta`载荷也是通用概念。Streamable HTTP一节提供了最完整的代码示例,同样的模式也适用于其他本地传输方式。 -## 1. 托管式MCP服务工具 +## 1. 托管式MCP服务器工具 -托管工具会将整个工具往返流程转移到OpenAI的基础设施中。你的代码无需列出并调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool] 会将服务标签(以及可选的连接器元数据)转发给 Responses API。模型会列出远程服务的工具并调用它们,无需再回调你的 Python 进程。托管工具目前可与支持 Responses API托管式MCP集成的OpenAI模型配合使用。 +托管式工具将整个工具往返过程交由OpenAI的基础设施处理。你的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool]会将服务器标签(以及可选的连接器元数据)转发给Responses API。模型会列出远程服务器的工具并调用它们,而无需额外回调你的Python进程。托管式工具目前适用于支持Responses API托管式MCP集成的OpenAI模型。 ### 基础托管式MCP工具 -将 [`HostedMCPTool`][agents.tool.HostedMCPTool] 添加到智能体的 `tools` 列表中,即可创建托管工具。`tool_config` -字典与发送给 REST API的 JSON 相对应: +将[`HostedMCPTool`][agents.tool.HostedMCPTool]添加到智能体的`tools`列表中,即可创建托管式工具。`tool_config` +字典与发送到REST API的JSON一致: ```python import asyncio @@ -109,14 +110,14 @@ async def main() -> None: asyncio.run(main()) ``` -托管服务会自动公开其工具;无需将其添加到 `mcp_servers`。 +托管式服务器会自动公开其工具;你无需将其添加到`mcp_servers`。 -如果希望托管工具搜索延迟加载托管式MCP服务,请设置 `tool_config["defer_loading"] = True`,并将 [`ToolSearchTool`][agents.tool.ToolSearchTool] 添加到智能体。此功能仅受OpenAI Responses 模型支持。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 +如果希望托管式工具搜索以延迟方式加载托管式MCP服务器,请设置`tool_config["defer_loading"] = True`,并将[`ToolSearchTool`][agents.tool.ToolSearchTool]添加到智能体。只有OpenAI Responses模型支持此功能。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 ### 托管式MCP结果的流式传输 -托管工具支持与工具调用完全相同的流式传输结果方式。使用 `Runner.run_streamed` -可以在模型仍在工作时使用增量MCP输出: +托管式工具支持流式传输结果,其方式与函数工具完全相同。使用`Runner.run_streamed` +可在模型仍在工作时接收增量MCP输出: ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -128,7 +129,7 @@ print(result.final_output) ### 可选审批流程 -如果服务可以执行敏感操作,可以要求在每次执行工具前进行人工或程序化审批。在 `tool_config` 中配置 `require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。若要在 Python 中做出决定,请提供 `on_approval_request` 回调。 +如果服务器可以执行敏感操作,你可以要求每次执行工具前都进行人工或程序化审批。在`tool_config`中配置`require_approval`,可使用单一策略(`"always"`、`"never"`),也可以使用将工具名称映射到策略的字典。若要在Python中做出决策,请提供`on_approval_request`回调。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -156,11 +157,11 @@ agent = Agent( ) ``` -该回调可以是同步或异步的,并会在模型需要审批数据以继续运行时调用。 +该回调可以是同步或异步的;每当模型需要审批数据才能继续运行时,就会调用它。 -### 由连接器支持的托管服务 +### 连接器支持的托管式服务器 -托管式MCP也支持OpenAI连接器。无需指定 `server_url`,只需提供 `connector_id` 和访问令牌。Responses API负责处理身份验证,托管服务则公开连接器的工具。 +托管式MCP还支持OpenAI连接器。无需指定`server_url`,只需提供`connector_id`和访问令牌。Responses API会处理身份验证,托管式服务器则会公开连接器的工具。 ```python import os @@ -176,11 +177,11 @@ HostedMCPTool( ) ``` -完整可运行的托管工具示例(包括流式传输、审批和连接器)位于 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 +完整可运行的托管式工具示例(包括流式传输、审批和连接器)位于[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 -## 2. Streamable HTTP MCP服务 +## 2. Streamable HTTP MCP服务器 -如果希望自行管理网络连接,请使用 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你需要控制传输方式,或希望在自己的基础设施中运行服务并保持低延迟时,Streamable HTTP 服务是理想选择。 +如果希望自行管理网络连接,请使用[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你需要控制传输方式,或希望在自己的基础设施中运行服务器并保持较低延迟时,Streamable HTTP服务器是理想选择。 ```python import asyncio @@ -217,23 +218,23 @@ asyncio.run(main()) 构造函数还接受以下选项: -- `client_session_timeout_seconds` 控制MCP ClientSession 的读取超时。可由 `datetime.timedelta` 表示且不小于一微秒的有限正数会设置有限超时;`None` 和 `0` 会禁用超时。构造服务时,其他值将被拒绝。 -- `use_structured_content` 控制是否优先使用 `tool_result.structured_content`,而不是文本输出。 -- `max_retry_attempts` 和 `retry_backoff_seconds_base` 为 `list_tools()` 和 `call_tool()` 添加自动重试。 -- `tool_filter` 允许你仅公开工具的一个子集(请参阅[工具筛选](#tool-filtering))。 -- `require_approval` 为本地MCP工具启用人工介入审批策略。 -- `failure_error_function` 自定义模型可见的MCP工具失败消息;将其设为 `None` 则会改为抛出错误。 -- `tool_meta_resolver` 在调用 `call_tool()` 前注入每次调用的MCP `_meta` 负载。 +- `client_session_timeout_seconds`控制MCP ClientSession的读取超时。可由`datetime.timedelta`表示且至少为一微秒的正有限值会设置有限超时;`None`和`0`会禁用超时。构造服务器时会拒绝其他值。 +- `use_structured_content`控制是否优先使用`tool_result.structured_content`而不是文本输出。 +- `max_retry_attempts`和`retry_backoff_seconds_base`为`list_tools()`和`call_tool()`添加自动重试。 +- `tool_filter`允许你仅公开部分工具(请参阅[工具筛选](#tool-filtering))。 +- `require_approval`为本地MCP工具启用人工参与的审批策略。 +- `failure_error_function`用于自定义模型可见的MCP工具失败消息;将其设置为`None`则改为抛出错误。 +- `tool_meta_resolver`会在`call_tool()`之前注入每次调用的MCP `_meta`载荷。 -### 本地MCP服务的审批策略 +### 本地MCP服务器的审批策略 -`MCPServerStdio`、`MCPServerSse` 和 `MCPServerStreamableHttp` 均接受 `require_approval`。 +`MCPServerStdio`、`MCPServerSse`和`MCPServerStreamableHttp`都接受`require_approval`。 -支持的形式: +支持以下形式: -- 对所有工具使用 `"always"` 或 `"never"`。 -- `True` / `False`(分别等同于始终审批/从不审批)。 -- 按工具配置的映射,例如 `{"delete_file": "always", "read_file": "never"}`。 +- 对所有工具使用`"always"`或`"never"`。 +- `True`要求审批所有工具,而`False`不要求审批任何工具(分别等同于`"always"`和`"never"`)。 +- 按工具配置的映射,例如`{"delete_file": "always", "read_file": "never"}`。 - 分组对象:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 ```python @@ -245,11 +246,11 @@ async with MCPServerStreamableHttp( ... ``` -有关完整的暂停/恢复流程,请参阅[人工介入](human_in_the_loop.md)和 `examples/mcp/get_all_mcp_tools_example/main.py`。 +有关完整的暂停/恢复流程,请参阅[人工参与](human_in_the_loop.md)和`examples/mcp/get_all_mcp_tools_example/main.py`。 -### 使用 `tool_meta_resolver` 配置每次调用的元数据 +### 使用`tool_meta_resolver`传递每次调用的元数据 -当MCP服务期望在 `_meta` 中接收请求元数据(例如租户 ID 或追踪上下文)时,请使用 `tool_meta_resolver`。以下示例假设你将 `dict` 作为 `context` 传递给 `Runner.run(...)`。 +当MCP服务器要求在`_meta`中提供请求元数据(例如租户ID或追踪上下文)时,请使用`tool_meta_resolver`。以下代码示例假定你将`dict`作为`context`传递给`Runner.run(...)`。 ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -270,19 +271,19 @@ server = MCPServerStreamableHttp( ) ``` -如果运行上下文是 Pydantic 模型、数据类或自定义类,请改用属性访问来读取租户 ID。 +如果运行上下文是Pydantic模型、数据类或自定义类,请改用属性访问读取租户ID。 ### MCP工具输出:文本和图像 -当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合的文本/图像响应会作为输出项列表转发,因此智能体使用MCP图像结果的方式,与使用常规工具调用所产生的图像输出相同。 +当MCP工具返回图像内容时,SDK会自动将其映射为工具输出中的图像类型条目。混合文本/图像响应会作为输出项列表转发,因此智能体可以像使用常规函数工具的图像输出一样使用MCP图像结果。 -## 3. 基于 SSE 的 HTTP MCP服务 +## 3. 使用SSE的HTTP MCP服务器 !!! warning - MCP项目已弃用 Server-Sent Events 传输。对于新集成,请优先使用 Streamable HTTP 或 stdio,仅为旧版服务保留 SSE。 + MCP项目已弃用Server-Sent Events传输方式。对于新集成,请优先使用Streamable HTTP或stdio,并仅为旧版服务器保留SSE。 -如果MCP服务实现了基于 SSE 的 HTTP 传输,请实例化 [`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其 API 与 Streamable HTTP 服务相同。 +如果MCP服务器实现了使用SSE的HTTP传输方式,请实例化[`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其API与Streamable HTTP服务器完全相同。 ```python @@ -309,9 +310,9 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP服务 +## 4. stdio MCP服务器 -对于以本地子进程方式运行的MCP服务,请使用 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会生成进程、保持管道打开,并在上下文管理器退出时自动将其关闭。此选项适用于快速概念验证,或服务仅公开命令行入口点的情况。 +对于作为本地子进程运行的MCP服务器,请使用[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会生成进程、保持管道打开,并在退出上下文管理器时自动关闭管道。此选项适用于快速进行概念验证,或服务器仅公开命令行入口点的情况。 ```python from pathlib import Path @@ -337,9 +338,9 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP服务管理器 +## 5. MCP服务器管理器 -如果有多个MCP服务,请使用 `MCPServerManager` 预先连接它们,并将已连接的服务子集公开给智能体。有关构造函数选项和重新连接行为,请参阅 [MCPServerManager API参考](ref/mcp/manager.md)。 +如果有多个MCP服务器,请使用`MCPServerManager`预先连接它们,并向智能体公开其中成功连接的服务器子集。有关构造函数选项和重新连接行为,请参阅[MCPServerManager API参考](ref/mcp/manager.md)。 ```python from agents import Agent, Runner @@ -362,23 +363,23 @@ async with MCPServerManager(servers) as manager: 关键行为: -- 当 `drop_failed_servers=True`(默认值)时,`active_servers` 仅包含成功连接的服务。 -- 连接失败会记录在 `failed_servers` 和 `errors` 中。 -- 设置 `strict=True` 可在首次连接失败时抛出异常。 -- 调用 `reconnect(failed_only=True)` 可重试失败的服务,调用 `reconnect(failed_only=False)` 则会重启所有服务。 -- 设置 `connect_timeout_seconds`、`cleanup_timeout_seconds` 和 `connect_in_parallel` 可调整生命周期行为。生命周期超时接受有限正秒数,也可以设为 `None` 以禁用超时,并且会在构造和赋值时进行验证;不接受零,因为零会创建立即到期的截止时间。 +- 当`drop_failed_servers=True`为默认值时,`active_servers`仅包含成功连接的服务器。 +- 连接失败会记录在`failed_servers`和`errors`中。 +- 设置`strict=True`可在首次连接失败时抛出异常。 +- 调用`reconnect(failed_only=True)`可重试连接失败的服务器,调用`reconnect(failed_only=False)`可重启所有服务器。 +- 设置`connect_timeout_seconds`、`cleanup_timeout_seconds`和`connect_in_parallel`可调整生命周期行为。生命周期超时接受有限的正秒数,也可以使用`None`禁用超时;这些值会在构造和赋值时进行验证。零值会被拒绝,因为它会导致立即到达截止时间。 -## 通用服务能力 +## 通用服务器功能 -以下各节适用于各种MCP服务传输方式(具体 API 范围取决于服务类)。 +以下各节适用于不同的MCP服务器传输方式(具体API接口取决于服务器类)。 ## 工具筛选 -每个MCP服务都支持工具筛选器,因此你可以只公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 +每个MCP服务器都支持工具筛选器,因此你可以仅公开智能体所需的函数。筛选既可以在构造时进行,也可以在每次运行时动态进行。 ### 静态工具筛选 -使用 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter] 配置简单的允许/阻止列表: +使用[`create_static_tool_filter`][agents.mcp.create_static_tool_filter]配置简单的允许列表/阻止列表: ```python from pathlib import Path @@ -396,11 +397,11 @@ filesystem_server = MCPServerStdio( ) ``` -当同时提供 `allowed_tool_names` 和 `blocked_tool_names` 时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 +同时提供`allowed_tool_names`和`blocked_tool_names`时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 ### 动态工具筛选 -如需更复杂的逻辑,请传入一个接收 [`ToolFilterContext`][agents.mcp.ToolFilterContext] 的可调用对象。该可调用对象可以是同步或异步的,并在应公开该工具时返回 `True`。 +对于更复杂的逻辑,请传入一个接收[`ToolFilterContext`][agents.mcp.ToolFilterContext]的可调用对象。该可调用对象可以是同步或异步的,并在应公开工具时返回`True`。 ```python from pathlib import Path @@ -424,15 +425,15 @@ async with MCPServerStdio( ... ``` -筛选器上下文会公开活动的 `run_context`、请求工具的 `agent` 和 `server_name`。 +筛选器上下文会公开当前的`run_context`、请求这些工具的`agent`以及`server_name`。 ## 提示词 -MCP服务还可以提供动态生成智能体指令的提示词。支持提示词的服务会公开两种 +MCP服务器还可以提供动态生成智能体指令的提示词。支持提示词的服务器会公开两种 方法: -- `list_prompts()` 枚举可用的提示词模板。 -- `get_prompt(name, arguments)` 获取具体的提示词,可选择提供参数。 +- `list_prompts()`列举可用的提示词模板。 +- `get_prompt(name, arguments)`获取具体的提示词,并可选择传入参数。 ```python from agents import Agent @@ -452,25 +453,25 @@ agent = Agent( ## 分页 -内置的本地MCP服务类在列出工具和提示词时会自动跟随 `nextCursor`。`list_tools()` 会在应用筛选器或填充缓存前返回完整的工具列表,而 `list_prompts()` 会返回一个合并结果,其中 `nextCursor=None`。如果后续页面失败或服务重复返回某个游标,该操作会抛出错误,而不会公开或缓存部分结果。 +内置的本地MCP服务器类会在列出工具和提示词时自动跟随`nextCursor`。`list_tools()`会先收集完整的工具列表,再应用筛选器或填充缓存;`list_prompts()`则返回一个`nextCursor=None`的合并结果。如果后续页面失败或服务器重复游标,该操作会抛出错误,而不是公开或缓存部分结果。 -资源仍会明确分页。将 `list_resources()` 或 `list_resource_templates()` 返回的 `nextCursor` 作为 `cursor` 参数传回,即可获取下一页。 +资源仍需显式分页。将`list_resources()`或`list_resource_templates()`中的`nextCursor`作为`cursor`参数传回,即可获取下一页。 ## 缓存 -每次智能体运行都会在每个MCP服务上调用 `list_tools()`。远程服务可能会产生明显的延迟,因此所有MCP服务类都公开了 `cache_tools_list` 选项。只有在确信工具定义不会频繁更改时,才应将其设为 `True`。若之后需要强制获取最新列表,请在服务实例上调用 `invalidate_tools_cache()`。 +每次智能体运行都会在每个MCP服务器上调用`list_tools()`。远程服务器可能引入明显延迟,因此所有MCP服务器类都提供`cache_tools_list`选项。只有在确信工具定义不会频繁变化时,才应将其设置为`True`。若之后需要强制获取新列表,请在服务器实例上调用`invalidate_tools_cache()`。 ## 追踪 [追踪](./tracing.md)会自动捕获MCP活动,包括: -1. 为列出工具而对MCP服务进行的调用。 +1. 为列出工具而向MCP服务器发出的调用。 2. 工具调用中与MCP相关的信息。 ![MCP追踪截图](../assets/images/mcp-tracing.jpg) ## 延伸阅读 -- [Model Context Protocol](https://modelcontextprotocol.io/) – 规范和设计指南。 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的 stdio、SSE 和 Streamable HTTP 示例。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 完整的托管式MCP演示,包括审批和连接器。 \ No newline at end of file +- [Model Context Protocol](https://modelcontextprotocol.io/)——规范和设计指南。 +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp)——可运行的stdio、SSE和Streamable HTTP示例。 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)——完整的托管式MCP演示,包括审批和连接器。 \ No newline at end of file diff --git a/docs/zh/models/index.md b/docs/zh/models/index.md index da163bd1a0..9b818ab69e 100644 --- a/docs/zh/models/index.md +++ b/docs/zh/models/index.md @@ -4,43 +4,43 @@ search: --- # 模型 -Agents SDK 原生支持两种 OpenAI 模型: +Agents SDK原生支持两种形式的OpenAI模型: -- **推荐**:使用新 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API 的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 -- 使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API 的 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 +- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],通过新的[Responses API](https://platform.openai.com/docs/api-reference/responses)调用OpenAI API。 +- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],通过[Chat Completions API](https://platform.openai.com/docs/api-reference/chat)调用OpenAI API。 -## 模型设置选择 +## 模型配置选择 -请从符合您设置的最简单路径开始: +从符合你配置需求的最简单路径开始: -| 如果您希望…… | 推荐路径 | 更多信息 | +| 如果你希望…… | 推荐路径 | 更多信息 | | --- | --- | --- | -| 仅使用 OpenAI模型 | 使用默认 OpenAI提供商和 Responses 模型路径 | [OpenAI模型](#openai-models) | -| 通过 WebSocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径并启用 WebSocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | +| 仅使用OpenAI模型 | 使用默认OpenAI提供商和Responses模型路径 | [OpenAI模型](#openai-models) | +| 通过 websocket 传输使用OpenAI Responses API | 保持使用Responses模型路径并启用 websocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | | 使用由OpenAI托管的子智能体 | 使用实验性托管式多智能体模型 | [托管式多智能体](#hosted-multi-agent-experimental) | -| 使用一个非 OpenAI提供商 | 从内置提供商集成点开始 | [非 OpenAI模型](#non-openai-models) | +| 使用一个非OpenAI提供商 | 从内置提供商集成点开始 | [非OpenAI模型](#non-openai-models) | | 在多个智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | -| 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径上使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | -| 使用第三方适配器实现非 OpenAI或混合提供商路由 | 比较受支持的测试版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | +| 调整高级OpenAI Responses请求设置 | 在OpenAI Responses路径上使用`ModelSettings` | [高级OpenAI Responses设置](#advanced-openai-responses-settings) | +| 使用第三方适配器进行非OpenAI或混合提供商路由 | 比较受支持的 beta 适配器,并验证你计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | ## OpenAI模型 -对于大多数仅使用 OpenAI的应用,推荐路径是配合默认 OpenAI提供商使用字符串模型名称,并继续使用 Responses 模型路径。 +对于大多数仅使用OpenAI的应用,推荐路径是将字符串模型名称与默认OpenAI提供商搭配使用,并继续采用Responses模型路径。 -如果初始化 `Agent` 时未指定模型,将使用默认模型。目前的默认模型是 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并使用 `reasoning.effort="none"` 和 `verbosity="low"`,以适应低延迟智能体工作流。如果您拥有访问权限,我们建议将智能体设置为 `gpt-5.6-sol`,以获得更高质量,同时继续显式设置 `model_settings`。 +初始化`Agent`时,如果未指定模型,则会使用默认模型。当前默认模型为[`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并搭配`reasoning.effort="none"`和`verbosity="low"`,适用于低延迟智能体工作流。如果你有权访问,我们建议将智能体设置为`gpt-5.6-sol`,以便在显式保留`model_settings`的同时获得更高质量。 -如果您希望切换到 `gpt-5.6-sol` 等其他模型,可通过两种方式配置智能体。 +如果要切换到`gpt-5.6-sol`等其他模型,可通过两种方式配置智能体。 ### 默认模型 -首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 +首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置`OPENAI_DEFAULT_MODEL`环境变量。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -其次,您可以通过 `RunConfig` 为一次运行设置默认模型。如果未为某个智能体设置模型,将使用本次运行的模型。 +其次,可以通过`RunConfig`为一次运行设置默认模型。如果没有为智能体设置模型,则会使用本次运行的模型。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 模型 -以这种方式使用 `gpt-5.6-sol` 等任意 GPT-5 模型时,SDK 会应用默认的 `ModelSettings`,其中包含最适合大多数用例的设置。要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: +以这种方式使用任何 GPT-5 模型(如`gpt-5.6-sol`)时,SDK 会应用默认的`ModelSettings`。它会设置最适合大多数用例的值。要调整默认模型的推理强度,请传入你自己的`ModelSettings`: ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -为降低延迟,建议对 GPT-5 模型使用 `reasoning.effort="none"`。 +为了降低延迟,建议将`reasoning.effort="none"`与 GPT-5 模型搭配使用。 -GPT-5.6 还通过现有的 `reasoning` 设置支持推理模式、持久化推理上下文以及 `"max"` 强度级别。这些控制项可用于 Responses API 路径: +GPT-5.6 还通过现有的`reasoning`设置支持推理模式、跨对话轮次保留的推理上下文,以及`"max"`强度级别。这些控制项可在Responses API路径上使用: ```python from openai.types.shared import Reasoning @@ -96,40 +96,40 @@ agent = Agent( ) ``` -`reasoning.mode` 和 `reasoning.context` 是仅适用于 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,受支持的强度级别取决于模型和 API 接口。请使用 Responses API 设置 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;可在 OpenAI提供商上设置 `strict_feature_validation=True`,将该警告转为错误。 +`reasoning.mode`和`reasoning.context`是仅限Responses的设置。Chat Completions仅使用`reasoning.effort`,且支持的强度级别取决于模型和 API 接口。请使用Responses API来设置 GPT-5.6 的`"max"`强度。Chat Completions适配器会忽略模式和上下文并发出警告;在OpenAI提供商上设置`strict_feature_validation=True`可将该警告转为错误。 -使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端会话或重放先前的推理项来保留对话。对于 `store=False` 的无状态调用,请在响应中包含 `reasoning.encrypted_content`,并在下一个请求中重放这些推理项。 +使用`context="all_turns"`时,请通过`previous_response_id`、服务端Responses API对话,或在下一个请求中包含先前的推理项来保留对话。对于无状态的`store=False`调用,请在响应中请求`reasoning.encrypted_content`,然后在下一个请求中将这些推理项作为输入。 #### ComputerTool 模型选择 -如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],实际 Responses 请求中的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求继续使用较旧的 `computer_use_preview` 载荷。 +如果智能体包含[`ComputerTool`][agents.tool.ComputerTool],则实际Responses请求上的有效模型决定 SDK 发送哪种计算机工具载荷。显式的`gpt-5.5`请求使用正式版内置`computer`工具,而显式的`computer-use-preview`请求继续使用较旧的`computer_use_preview`载荷。 -由提示词管理的调用是主要例外。如果提示词模板决定模型,且 SDK 在请求中省略 `model`,SDK 将默认使用兼容预览版的计算机载荷,以避免猜测提示词锁定了哪个模型。要在该流程中继续使用正式发布路径,请在请求中显式设置 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制使用正式发布选择器。 +由提示词管理的调用是主要例外。如果提示词模板指定了模型,而 SDK 在请求中省略了`model`,SDK 会默认使用与预览版兼容的计算机载荷,以避免猜测提示词固定的是哪个模型。要在此流程中继续使用正式版路径,请在请求中显式指定`model="gpt-5.5"`,或使用`ModelSettings(tool_choice="computer")`或`ModelSettings(tool_choice="computer_use")`强制选择正式版。 -注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与有效请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串将继续按普通函数名称处理。 +注册[`ComputerTool`][agents.tool.ComputerTool]后,`tool_choice="computer"`、`"computer_use"`和`"computer_use_preview"`会被规范化为与有效请求模型匹配的内置选择器。如果未注册`ComputerTool`,这些字符串会继续像普通函数名称一样运作。 -兼容预览版的请求必须预先序列化 `environment` 和显示尺寸,因此,使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂且由提示词管理的流程,应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制使用正式发布选择器。有关完整迁移详情,请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 +与预览版兼容的请求必须预先序列化`environment`和显示尺寸,因此,使用[`ComputerProvider`][agents.tool.ComputerProvider]工厂的提示词管理流程应传入具体的`Computer`或`AsyncComputer`实例,或在发送请求前强制使用正式版选择器。完整迁移详情请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 #### 非 GPT-5 模型 -如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 将恢复使用与任意模型兼容的通用 `ModelSettings`。 +如果传入非 GPT-5 模型名称且未提供自定义`model_settings`,SDK 会恢复为与任何模型兼容的通用`ModelSettings`。 -### Responses 专属工具功能 +### 仅限Responses的工具功能 -以下工具功能仅受 OpenAI Responses 模型支持: +以下工具功能仅受OpenAI Responses模型支持: - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` 和其他延迟加载的 Responses 工具接口 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers` 和 `tool_choice="programmatic_tool_calling"` +- `@function_tool(defer_loading=True)`及其他延迟加载的Responses工具接口 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`和`tool_choice="programmatic_tool_calling"` -Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请向智能体添加 `ToolSearchTool()`,并让模型通过 `auto` 或 `required` 工具选择加载工具,而不是强制指定单独的命名空间名称或仅延迟加载的函数名称。有关设置详情和当前限制,请参阅[托管式工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 +Chat Completions模型和非Responses后端会拒绝这些功能。使用延迟加载工具时,请将`ToolSearchTool()`添加到智能体,并让模型通过`auto`或`required`工具选择来加载工具,而不是强制使用单独的命名空间名称或仅限延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管式工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 ### Responses WebSocket 传输 -默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用由OpenAI支持的模型时,您可以选择启用 WebSocket 传输。 +默认情况下,OpenAI Responses API请求使用 HTTP 传输。使用OpenAI Responses提供商路径时,你可以选择启用 websocket 传输。 -#### 基本设置 +#### 基本配置 ```python from agents import set_default_openai_responses_transport @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响由默认 OpenAI提供商解析的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 +这会影响默认OpenAI提供商解析模型名称时得到的OpenAI Responses模型,包括`"gpt-5.6-sol"`等字符串模型名称。 -SDK 将模型名称解析为模型实例时,会进行传输方式选择。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已固定:[‌`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 继续使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商控制传输方式选择,而不是全局默认设置。 +SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的[`Model`][agents.models.interface.Model]对象,其传输方式已固定:[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]使用 websocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]使用 HTTP,而[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]继续使用Chat Completions。如果传入`RunConfig(model_provider=...)`,则由该提供商控制传输方式的选择,而不是使用全局默认设置。 -#### 提供商或运行级设置 +#### 提供商级或运行级配置 -您还可以按提供商或按运行配置 WebSocket 传输: +你也可以按提供商或按运行配置 websocket 传输: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -由OpenAI支持的提供商还接受可选的智能体注册配置。这是一个高级选项,适用于 OpenAI设置需要提供商级注册元数据(例如测试框架 ID)的场景。 +通过 SDK 的OpenAI集成进行路由的提供商也接受可选的智能体注册配置。这是一个高级选项,适用于OpenAI配置需要提供商级注册元数据(如测试框架 ID)的情况。 ```python from agents import ( @@ -188,16 +188,16 @@ result = await Runner.run( ) ``` -#### 使用 `MultiProvider` 的高级路由 +#### 使用`MultiProvider`的高级路由 -如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 +如果需要基于前缀的模型路由,例如在一次运行中混用`openai/...`和`any-llm/...`模型名称,请使用[`MultiProvider`][agents.MultiProvider]并在其中设置`openai_use_responses_websocket=True`。 -`MultiProvider` 保留了两项历史默认行为: +`MultiProvider`保留了两个历史默认设置: -- `openai/...` 被视为 OpenAI提供商的别名,因此 `openai/gpt-4.1` 会作为模型 `gpt-4.1` 进行路由。 -- 未知前缀会引发 `UserError`,而不是直接透传。 +- `openai/...`被视为OpenAI提供商的别名,因此`openai/gpt-4.1`会作为模型`gpt-4.1`进行路由。 +- 未知前缀会引发`UserError`,而不是按原样传递。 -当 OpenAI提供商指向要求使用字面命名空间模型 ID 的 OpenAI兼容端点时,请显式启用透传行为。在启用了 WebSocket 的设置中,还应在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: +将OpenAI提供商指向需要字面命名空间模型 ID 的OpenAI兼容端点时,请显式启用按原样传递行为。在启用 websocket 的配置中,也要在`MultiProvider`上保留`openai_use_responses_websocket=True`: ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -当后端要求使用字面的 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端要求使用其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项也适用于 WebSocket 传输之外的 `MultiProvider`;此示例继续启用 WebSocket,是因为它属于本节介绍的传输设置。相同选项也可用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 +后端需要字面量`openai/...`字符串时,请使用`openai_prefix_mode="model_id"`。后端需要`openrouter/openai/gpt-4.1-mini`等其他命名空间模型 ID 时,请使用`unknown_prefix_mode="model_id"`。这些选项也可在 websocket 传输之外的`MultiProvider`上使用;此代码示例继续启用 websocket,是因为它属于本节所述的传输配置。相同选项也可用于[`responses_websocket_session()`][agents.responses_websocket_session]。 -如果通过 `MultiProvider` 进行路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发到底层 OpenAI提供商。 +如果通过`MultiProvider`进行路由时需要相同的提供商级注册元数据,请传入`openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发到底层OpenAI提供商。 -如果使用自定义 OpenAI兼容端点或代理,WebSocket 传输还需要兼容的 WebSocket `/responses` 端点。在这些设置中,您可能需要显式设置 `websocket_base_url`。 +如果使用自定义OpenAI兼容端点或代理,websocket 传输还需要兼容的 websocket `/responses`端点。在这些配置中,你可能需要显式设置`websocket_base_url`。 #### 注意事项 -- 这是通过 WebSocket 传输使用的 Responses API,并非 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions 或非 OpenAI提供商,除非它们支持 Responses WebSocket `/responses` 端点。 -- 如果您的环境中尚未安装 `websockets` 软件包,请安装它。 -- 启用 WebSocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望在多个轮次间复用同一 WebSocket 连接的多轮工作流,包括嵌套的“智能体作为工具”调用,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助函数。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 -- 对于耗时较长的推理轮次或延迟偶发突增的网络,可通过 `responses_websocket_options` 自定义 WebSocket 保活行为。增大 `ping_timeout` 以容忍延迟的 pong 帧,或设置 `ping_timeout=None`,在继续启用 ping 的同时禁用心跳超时。当可靠性比 WebSocket 延迟更重要时,优先使用 HTTP/SSE 传输。 -- 默认情况下,SDK 会禁用传入消息大小限制(`max_size=None`)。对于位于代理之后或运行在内存受限容器中的长生命周期智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`,以限制每条消息的内存使用量。 +- 这是通过 websocket 传输的Responses API,而不是[Realtime API](../realtime/guide.md)。它不适用于Chat Completions。它仅适用于支持Responses websocket `/responses`端点的非OpenAI提供商。 +- 如果环境中尚未提供`websockets`包,请安装该包。 +- 启用 websocket 传输后,可以直接使用[`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次复用同一 websocket 连接的多轮工作流,包括嵌套的智能体工具调用,建议使用[`responses_websocket_session()`][agents.responses_websocket_session]辅助工具。请参阅[运行智能体](../running_agents.md)指南和[`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 +- 对于较长的推理轮次或延迟偶发激增的网络,请使用`responses_websocket_options`自定义 websocket 保活行为。增大`ping_timeout`可容忍延迟的 pong 帧,或将`ping_timeout=None`设置为禁用心跳超时,同时继续启用 ping。当可靠性比 websocket 延迟更重要时,优先使用 HTTP/SSE 传输。 +- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于位于代理之后或在内存受限容器中运行的长生命周期智能体进程,请设置`responses_websocket_options={"max_size": 8 * 1024 * 1024}`以限制每条消息的内存用量。 - [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每个连接上一次处理一个响应,并将每个连接限制为 60 分钟。达到该限制后请打开新连接;需要并行运行时,请使用多个连接。 -- 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会逐出引用的 `previous_response_id`。重新连接后,如果存储的响应仍然可用,依然可以继续该响应;但 `store=False` 和 ZDR 流程没有持久化回退方案。请使用 `previous_response_id=None` 启动新的响应链并发送完整输入上下文,或通过本地管理的会话状态重建该上下文。 +- 该服务仅在连接本地内存中保留最近的响应。失败的`4xx`或`5xx`轮次会从该内存中逐出`previous_response_id`所引用的响应。重新连接后,存储的响应若仍可用,依然可以继续,但`store=False`和 ZDR 流程没有持久化回退方案。请使用`previous_response_id=None`启动新链并发送完整输入上下文,或从本地管理的会话状态重建该上下文。 ### 托管式多智能体(实验性) -OpenAI Responses API 托管式多智能体测试版允许 GPT-5.6 根模型创建并协调由服务托管的子智能体。Agents SDK 可以继续使用常规 `Runner`:托管式编排保留在服务上,而开发者定义的工具调用则在您的应用中执行。 +OpenAI Responses API托管式多智能体 beta 允许 GPT-5.6 根模型创建和协调由服务端托管的子智能体。Agents SDK可以继续使用常规的`Runner`:托管式编排在服务端进行,而开发者定义的函数工具在你的应用程序中执行。 -此集成是实验性的,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活动的托管智能体。它要求使用 `openai[realtime]>=2.45.0`,其中包括公开 `client.beta.responses.connect` 的测试版本。在正式发布前,接口和测试版项目架构可能发生变化。 +此集成为实验性功能,并使用Responses WebSocket传输,以便通过`response.inject`将本地函数输出返回给活跃的托管式智能体。它要求`openai[realtime]`版本为 2.45.0 或更高版本,且该构建需公开`client.beta.responses.connect`。接口和 beta 项目架构可能会在正式发布前发生变化。 #### 模型配置 @@ -260,13 +260,13 @@ agent = Agent( ) ``` -构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则该模型会使用默认 OpenAI客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 +构造`OpenAIHostedMultiAgentModel`会启用`multi_agent.enabled`并发送`OpenAI-Beta: responses_multi_agent=v1`WebSocket 标头。除非提供`openai_client`,否则模型会使用默认OpenAI客户端。如果省略`max_concurrent_subagents`,则使用服务默认值。 -#### 本地工具调用 +#### 本地函数工具 -所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 在本地执行函数,并将具有相同调用 ID 的 `function_call_output` 注入活动 WebSocket 响应,使服务能够恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。SDK 不支持工具审批中断:任何 `needs_approval` 设置不为 `False` 的工具调用,都会在发送请求前被拒绝。 +所有托管式智能体共享为请求配置的模型和工具。Responses API决定由哪个托管式智能体调用函数。常规 SDK Runner 会在本地执行函数,并将具有相同调用 ID 的`function_call_output`注入活跃的 WebSocket 响应,从而让服务恢复原始托管式调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。SDK 工具审批中断不受支持:任何`needs_approval`设置不为`False`的函数工具都会在发送请求前被拒绝。 -当工具需要感知调用方的日志记录或授权时,请使用 `get_hosted_agent_metadata()`: +当工具需要感知调用方的日志记录或授权时,请使用`get_hosted_agent_metadata()`: ```python from typing import Any @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -托管智能体名称是观察性元数据,并非本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于有副作用的工具,请将该调用 ID 用作幂等键,并在执行工具之前或期间,通过应用代码执行所有必要的授权;请勿对该模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 +托管式智能体名称是观测元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于具有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间通过应用程序代码实施所需的授权;不要将`needs_approval`与此模型搭配使用。工具参数和输出会跨越Responses API边界。 #### 输出与流式传输行为 -只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为常规最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管式编排记录;SDK 绝不会将这些记录作为本地函数执行。 +只有归属于`/root`且阶段为`final_answer`的消息才会成为普通最终消息。实验性适配器会从高级`RunResult`中过滤掉子智能体消息和托管式编排记录;SDK 绝不会将这些记录作为本地函数执行。 -原始流式传输会继续公开测试版 Responses 事件,包括托管输出项和 `response.inject.created` 确认。函数调用就绪时,适配器会将一个活动的提供商响应拆分为 SDK 可见的逻辑模型轮次;Runner 生成输出后,再恢复同一个提供商响应。请将 `get_hosted_agent_metadata()` 与原始托管项或 `ToolContext` 配合使用,以检查归属信息。 +原始流式传输仍会公开 beta Responses事件,包括托管式输出项和`response.inject.created`确认。函数调用准备就绪时,适配器会将一个活跃提供商响应划分为 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。使用`get_hosted_agent_metadata()`与原始托管项或`ToolContext`可识别该项或工具调用所归属的托管式智能体。 #### 与 SDK 编排的关系 -托管式多智能体与 SDK 任务转移及 agents-as-tools 不同: +托管式多智能体不同于 SDK 任务转移和Agents-as-tools: -- 托管式多智能体会在 OpenAI服务上创建子智能体。您的应用不会创建或调度这些子智能体。 -- SDK 任务转移会更改当前活动的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,从而造成所有权冲突。 -- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估额外的延迟、成本和工具暴露。 +- 托管式多智能体在OpenAI服务上创建子智能体。你的应用程序不会创建或调度这些子智能体。 +- SDK 任务转移会更改活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管式智能体都会收到相同的任务转移工具,从而导致所有权冲突。 +- Agents-as-tools仍然可用,但使用它们会创建嵌套的客户端编排和服务端编排。请审慎评估额外的延迟、成本和工具暴露。 #### 当前限制 -实验性模型不接受 `reasoning.summary`、`max_tool_calls` 以及调用方提供的 `multi_agent` 或 `betas` 覆盖值。测试版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 +实验性模型会拒绝`reasoning.summary`、`max_tool_calls`,以及调用方提供的`multi_agent`或`betas`覆盖值。beta 不支持Responses `/compact`端点,但可以使用显式的`context_management.compact_threshold`,因为服务会自动独立压缩每个托管式智能体的上下文。 -一个 `OpenAIHostedMultiAgentModel` 实例同一时间最多拥有一个活动的托管响应。如果运行在等待本地函数输出时被放弃,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复正在进行的托管响应。 +一个`OpenAIHostedMultiAgentModel`实例同一时间最多拥有一个活跃的托管式响应。如果运行在等待本地函数输出时被放弃,请调用`await model.close()`释放其 WebSocket。目前不支持在其他进程或事件循环中恢复进行中的托管式响应。 -有关底层 Responses API 测试版行为,请参阅 [OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 +有关底层Responses API beta 行为,请参阅[OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 -## 非 OpenAI模型 +## 非OpenAI模型 -如果需要非 OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多设置中,无需添加第三方适配器即可满足需求。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 +如果需要非OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多配置中,无需添加第三方适配器即可满足需求。每种模式的代码示例都位于[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)中。 -### 非 OpenAI提供商的集成方式 +### 非OpenAI提供商集成方式 | 方式 | 适用场景 | 作用域 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 一个 OpenAI兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 一个OpenAI兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | | [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应应用于单次运行 | 每次运行 | -| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同的提供商或具体模型对象 | 每个智能体 | -| 第三方适配器 | 您需要由适配器管理的提供商覆盖或内置路径未提供的路由功能 | 请参阅[第三方适配器](#third-party-adapters) | +| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同提供商或具体模型对象 | 每个智能体 | +| 第三方适配器 | 由于内置路径无法提供所需能力,因此需要适配器提供的提供商覆盖范围或路由 | 请参阅[第三方适配器](#third-party-adapters) | -您可以通过以下内置路径集成其他 LLM 提供商: +你可以通过以下内置路径集成其他 LLM 提供商: -1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望在全局范围内使用 `AsyncOpenAI` 实例作为 LLM 客户端的场景。它适用于 LLM 提供商拥有 OpenAI兼容 API 端点,并且您可以设置 `base_url` 和 `api_key` 的情况。可配置的代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 层级。您可以借此指定“本次运行中的所有智能体均使用自定义模型提供商”。可配置的代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 -3. [`Agent.model`][agents.agent.Agent.model] 允许您为特定 Agent 实例指定模型,从而为不同智能体灵活混用不同的提供商。可配置的代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 +1. [`set_default_openai_client`][agents.set_default_openai_client]适用于希望在全局范围内使用`AsyncOpenAI`实例作为 LLM 客户端的情况。这适用于 LLM 提供商具有OpenAI兼容 API 端点,并且你可以设置`base_url`和`api_key`的场景。可配置的代码示例请参阅[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 +2. [`ModelProvider`][agents.models.interface.ModelProvider]位于`Runner.run`级别。这样你可以指定“本次运行中的所有智能体都使用自定义模型提供商”。可配置的代码示例请参阅[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 +3. [`Agent.model`][agents.agent.Agent.model]允许你在特定 Agent 实例上指定模型。这样可以为不同智能体灵活搭配不同提供商。可配置的代码示例请参阅[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 -如果您没有来自 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或设置[其他追踪进程](../tracing.md)。 +如果你没有`platform.openai.com`的 API 密钥,建议通过`set_tracing_disabled()`禁用追踪,或配置[其他追踪处理器](../tracing.md)。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持 Responses API,我们建议使用 Responses。 + 在这些代码示例中,我们使用Chat Completions API/模型,因为许多 LLM 提供商仍不支持Responses API。如果你的 LLM 提供商支持它,我们建议使用Responses。 ## 在一个工作流中混用模型 -在单个工作流中,您可能希望为每个智能体使用不同的模型。例如,可以使用较小、较快的模型进行分流,同时使用较大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: +在单个工作流中,你可能希望为每个智能体使用不同模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置[`Agent`][agents.Agent]时,可以通过以下任一方式选择特定模型: 1. 传入模型名称。 -2. 传入任意模型名称以及可将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 -3. 直接提供 [`Model`][agents.models.interface.Model] 实现。 +2. 传入任意模型名称和一个可将该名称映射到 Model 实例的[`ModelProvider`][agents.models.interface.ModelProvider]。 +3. 直接提供[`Model`][agents.models.interface.Model]实现。 !!! note - 虽然我们的 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 形式,但由于这两种形式支持不同的功能和工具集,我们建议每个工作流仅使用一种模型形式。如果您的工作流需要混用模型形式,请确保您使用的所有功能均受二者支持。 + 虽然我们的 SDK 同时支持[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]和[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]形式,但我们建议每个工作流仅使用一种模型形式,因为这两种形式支持的功能和工具集合不同。如果工作流需要混合搭配不同的模型形式,请确保使用的所有功能在两者上均可用。 ```python import asyncio @@ -391,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 直接设置 OpenAI模型的名称。 -2. 提供 [`Model`][agents.models.interface.Model] 实现。 +1. 直接设置OpenAI模型的名称。 +2. 提供[`Model`][agents.models.interface.Model]实现。 -如果希望进一步配置智能体所用的模型,可以传入 [`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 +如果希望进一步配置智能体使用的模型,可以传入[`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 ```python from agents import Agent, ModelSettings @@ -407,24 +407,24 @@ english_agent = Agent( ) ``` -## 高级 OpenAI Responses 设置 +## 高级OpenAI Responses设置 -当您使用 OpenAI Responses 路径并需要更多控制时,请首先使用 `ModelSettings`。 +当使用OpenAI Responses路径并需要更多控制时,请从`ModelSettings`开始。 -### 常用高级 `ModelSettings` 选项 +### 常用高级`ModelSettings`选项 -使用 OpenAI Responses API 时,多个请求字段已经拥有直接对应的 `ModelSettings` 字段,因此无需为它们使用 `extra_args`。 +使用OpenAI Responses API时,多个请求字段已具有对应的直接`ModelSettings`字段,因此无需为它们使用`extra_args`。 -- `parallel_tool_calls`:允许或禁止在同一轮中进行多次工具调用。 -- `truncation`:设置为 `"auto"`,让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是让请求失败。 -- `store`:控制生成的响应是否存储在服务端,以便稍后检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 -- `context_management`:配置服务端上下文处理,例如通过 `compact_threshold` 进行 Responses 压缩。 +- `parallel_tool_calls`:允许或禁止在同一轮中进行多个工具调用。 +- `truncation`:设置`"auto"`,让Responses API在上下文即将溢出时丢弃最旧的对话项,而不是失败。 +- `store`:控制生成的响应是否存储在服务端以供日后检索。这对于依赖响应 ID 的后续工作流,以及在`store=False`时可能需要回退到本地输入的会话压缩流程非常重要。 +- `context_management`:配置服务端上下文处理,例如使用`compact_threshold`进行Responses压缩。 - `prompt_cache_retention`:为较早的模型系列配置延长保留时间,例如 - 使用 `"24h"`。 -- `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置 `"30m"` 缓存 TTL。 -- `response_include`:请求更丰富的响应载荷,例如 `web_search_call.action.sources`、`file_search_call.results` 或 `reasoning.encrypted_content`。 -- `top_logprobs`:请求输出文本中概率最高的 token 对数概率。SDK 还会自动添加 `message.output_text.logprobs`。 -- `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅 [Runner 管理的重试](#runner-managed-retries)。 + 使用`"24h"`。 +- `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置`"30m"`缓存 TTL。 +- `response_include`:请求更丰富的响应载荷,例如`web_search_call.action.sources`、`file_search_call.results`或`reasoning.encrypted_content`。 +- `top_logprobs`:请求输出文本的 top-token logprobs。SDK 还会自动添加`message.output_text.logprobs`。 +- `retry`:选择启用由 runner 管理的模型调用重试设置。请参阅[Runner 管理的重试](#runner-managed-retries)。 ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。同一个 `ModelSettings.prompt_cache_options` 字段会透传给 Responses 和 Chat Completions 请求,且 Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 +使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。同一`ModelSettings.prompt_cache_options`字段会原样传递到Responses和Chat Completions请求中,而Chat Completions转换器会保留文本、图像、音频和文件内容部分上的断点。 ```python from agents import Runner @@ -470,17 +470,18 @@ result = await Runner.run( ) ``` -对于使用旧版保留控制的较早模型系列,`prompt_cache_retention` 仍然可用。请勿将直接的 `ModelSettings` 字段与 `extra_args` 中的同名键结合使用。 +对于使用旧版保留控制的较早模型系列,`prompt_cache_retention`仍然可用。不要将直接的`ModelSettings`字段与 +`extra_args`中的相同键组合使用。 -设置 `store=False` 时,Responses API 不会保留该响应供服务端稍后检索。这对于无状态或零数据保留类型的流程很有用,但也意味着原本会复用响应 ID 的功能必须改为依赖本地管理的状态。例如,当最后一个响应未被存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 +设置`store=False`后,Responses API不会保留该响应以供后续服务端检索。这对于无状态或零数据保留风格的流程很有用,但也意味着原本会复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当最后一个响应未存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]会将其默认`"auto"`压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 -服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每个 Responses API 请求一同发送;当渲染后的上下文超过阈值时,API 可以将压缩项作为响应的一部分发出。`OpenAIResponsesCompactionSession` 则会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 +服务端压缩不同于[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]`会随每个Responses API请求发送,当渲染后的上下文超过阈值时,API 可以在响应中生成压缩项。`OpenAIResponsesCompactionSession`会在轮次之间调用独立的`responses.compact`端点,并重写本地会话历史记录。 -### `extra_args` 传递 +### `extra_args`的传递 -当您需要 SDK 尚未在顶层直接公开的提供商专属或较新的请求字段时,请使用 `extra_args`。 +当你需要 SDK 尚未直接在顶层公开的提供商特定字段或较新的请求字段时,请使用`extra_args`。 -使用 OpenAI模型时,`extra_args` 可以向 Responses API 和 Chat Completions API 传递可选参数,例如 `user` 和 `service_tier`。对于受支持的模型,可设置 `extra_args={"service_tier": "fast"}` 以使用[快速模式](https://developers.openai.com/api/docs/guides/fast-mode);`"priority"` 仍与之等效。请勿同时通过直接的 `ModelSettings` 字段设置同一请求字段。 +使用OpenAI模型时,`extra_args`可以向Responses API和Chat Completions API传递可选参数,例如`user`和`service_tier`。对于受支持的模型,请设置`extra_args={"service_tier": "fast"}`以使用[快速模式](https://developers.openai.com/api/docs/guides/fast-mode);`"priority"`仍与其等效。不要同时通过直接的`ModelSettings`字段设置同一个请求字段。 ```python from agents import Agent, ModelSettings @@ -498,9 +499,9 @@ english_agent = Agent( ## Runner 管理的重试 -重试仅在运行时生效,并且需要主动启用。除非您设置 `ModelSettings(retry=...)`,且您的重试策略选择进行重试,否则 SDK 不会重试常规模型请求。 +重试仅在运行时生效,并且需要主动启用。除非设置`ModelSettings(retry=...)`且重试策略选择重试,否则 SDK 不会重试一般模型请求。 -在 Responses WebSocket 传输中,`retry_policies.provider_suggested()` 会将响应前的过载帧和无代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:您仍然需要 `ModelRetrySettings`,且常规的重放安全检查仍然适用。如果已经收到任何响应事件,SDK 就不会重放请求。 +在Responses websocket传输中,`retry_policies.provider_suggested()`会将响应前的过载帧和无代码的`server_error`帧识别为重试建议。这本身不会启用重试:你仍需设置`ModelRetrySettings`,且常规重放安全检查仍然适用。如果已经收到任何响应事件,SDK 不会重放请求。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -528,44 +529,44 @@ agent = Agent( ) ``` -`ModelRetrySettings` 包含三个字段: +`ModelRetrySettings`包含三个字段:
| 字段 | 类型 | 说明 | | --- | --- | --- | | `max_retries` | `int | None` | 初始请求后允许的重试次数。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略选择重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制该计算得出的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | -| `policy` | `RetryPolicy | None` | 决定是否重试的回调。该字段仅在运行时生效,不会被序列化。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 策略决定重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay`仅限制计算所得的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | +| `policy` | `RetryPolicy | None` | 决定是否重试的回调。此字段仅在运行时使用,不会被序列化。 |
-重试策略会收到一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: +重试策略会接收一个[`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: -- `attempt` 和 `max_retries`,以便根据尝试次数做出决策。 -- `stream`,以便区分流式和非流式行为。 -- `error`,用于原始检查。 -- `normalized` 信息,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 -- `provider_advice`,用于底层模型适配器能够提供重试指导的情况。 +- `attempt`和`max_retries`,供你根据尝试次数作出决策。 +- `stream`,供你区分流式与非流式行为。 +- `error`,用于原始数据检查。 +- `normalized`信息,例如`status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`和`is_abort`。 +- `provider_advice`,在底层模型适配器能够提供重试指导时使用。 策略可以返回以下任一内容: -- `True` / `False`,表示简单的重试决策。 -- 当您希望覆盖延迟或附加诊断原因时,返回 [`RetryDecision`][agents.retry.RetryDecision]。 +- `True`/`False`,用于简单的重试决策。 +- [`RetryDecision`][agents.retry.RetryDecision],用于覆盖延迟或附加诊断原因。 -SDK 在 `retry_policies` 上导出了现成的辅助函数: +SDK 在`retry_policies`上导出了现成的辅助工具: -| 辅助函数 | 行为 | +| 辅助工具 | 行为 | | --- | --- | -| `retry_policies.never()` | 始终不重试。 | -| `retry_policies.provider_suggested()` | 在有可用建议时遵循提供商的重试建议。 | -| `retry_policies.network_error()` | 匹配暂时性传输失败和超时失败。 | +| `retry_policies.never()` | 始终不启用重试。 | +| `retry_policies.provider_suggested()` | 在可用时遵循提供商的重试建议。 | +| `retry_policies.network_error()` | 匹配暂时性传输故障和超时故障。 | | `retry_policies.http_status([...])` | 匹配选定的 HTTP 状态码。 | -| `retry_policies.retry_after()` | 仅在存在 retry-after 提示时重试,并使用该延迟。该辅助函数将 retry-after 值视为显式策略延迟,因此 `backoff.max_delay` 不会限制它。 | -| `retry_policies.any(...)` | 任一嵌套策略选择重试时进行重试。 | -| `retry_policies.all(...)` | 仅当所有嵌套策略均选择重试时进行重试。 | +| `retry_policies.retry_after()` | 仅在存在 retry-after 提示时重试,并使用该延迟。此辅助工具将 retry-after 值视为显式策略延迟,因此`backoff.max_delay`不会限制它。 | +| `retry_policies.any(...)` | 任意嵌套策略选择启用时即重试。 | +| `retry_policies.all(...)` | 仅在所有嵌套策略都选择启用时重试。 | -组合策略时,`provider_suggested()` 是最安全的首选基础组件,因为当提供商能够区分拒绝重试和重放安全批准时,它会保留这些信息。 +组合策略时,`provider_suggested()`是最安全的首选基础组件,因为当提供商可以区分否决意见和重放安全批准时,它会保留这些信息。 ##### 安全边界 @@ -573,40 +574,40 @@ SDK 在 `retry_policies` 上导出了现成的辅助函数: - 中止错误。 - 提供商建议将重放标记为不安全的请求。 -- 输出已开始,且重放会变得不安全的流式运行。 +- 已开始输出且重放会不安全的流式运行。 -使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求也会得到更保守的处理。对于这些请求,`network_error()` 或 `http_status([500])` 等非提供商判断条件本身并不足够。重试策略应包含来自提供商的重放安全批准,通常通过 `retry_policies.provider_suggested()` 实现。 +使用`previous_response_id`或`conversation_id`的有状态后续请求也会以更保守的方式处理。对于这些请求,`network_error()`或`http_status([500])`等非提供商谓词本身并不足够。重试策略应包含提供商给出的重放安全批准,通常通过`retry_policies.provider_suggested()`实现。 ##### Runner 与智能体的合并行为 -Runner 级与智能体级 `ModelSettings` 之间会深度合并 `retry`: +Runner 级和智能体级`ModelSettings`之间会深度合并`retry`: -- 智能体可以仅覆盖 `retry.max_retries`,并继续继承 Runner 的 `policy`。 -- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留来自 Runner 的其他同级退避字段。 -- `policy` 仅在运行时生效,因此序列化后的 `ModelSettings` 会保留 `max_retries` 和 `backoff`,但省略回调本身。 +- 智能体可以仅覆盖`retry.max_retries`,同时继承 Runner 的`policy`。 +- 智能体可以仅覆盖`retry.backoff`的一部分,并保留 Runner 中同级的其他退避字段。 +- `policy`仅在运行时使用,因此序列化的`ModelSettings`会保留`max_retries`和`backoff`,但省略回调本身。 -有关更完整的代码示例,请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[由适配器支持的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 +更多代码示例请参阅[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py)和[基于适配器的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 -## 非 OpenAI提供商故障排除 +## 非OpenAI提供商故障排除 -### 追踪客户端 401 错误 +### 追踪客户端错误 401 -如果出现与追踪相关的错误,这是因为追踪数据会上传到 OpenAI服务,而您没有 OpenAI API 密钥。您可以通过以下三种方式解决: +如果遇到与追踪相关的错误,这是因为追踪数据会上传到OpenAI服务器,而你没有OpenAI API 密钥。可通过以下三种方式解决: 1. 完全禁用追踪:[`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. 为追踪设置 OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 -3. 使用非 OpenAI追踪进程。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 +2. 为追踪设置OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,且必须来自[platform.openai.com](https://platform.openai.com/)。 +3. 使用非OpenAI追踪处理器。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 -### Responses API 支持 +### Responses API支持 -SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,您可能会遇到 404 或类似问题。您可以通过以下两种方式解决: +SDK 默认使用Responses API,但许多其他 LLM 提供商仍不支持它。因此,你可能会看到 404 或类似问题。可通过以下两种方式解决: -1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方式有效。 -2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)提供了代码示例。 +1. 调用[`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果你通过环境变量设置`OPENAI_API_KEY`和`OPENAI_BASE_URL`,此方法适用。 +2. 使用[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)提供了代码示例。 -### Chat Completions 兼容性选项 +### Chat Completions兼容性选项 -通过 Chat Completions 进行路由时,SDK 会静默丢弃 Chat Completions 无法发送的 Responses 专属字段,以保持兼容性,例如 `previous_response_id`、`conversation_id`、提示词或非纯文本工具输出。如果您希望这些不匹配问题在开发期间快速失败,请在 OpenAI提供商上启用严格功能验证: +通过Chat Completions进行路由时,SDK 会静默丢弃Chat Completions无法发送的仅限Responses字段,例如`previous_response_id`、`conversation_id`、Responses API的`prompt`字段,或并非纯文本的工具输出,以保持兼容性。如果希望这些不匹配问题在开发期间快速失败,请在OpenAI提供商上启用严格功能验证: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -624,9 +625,9 @@ result = await Runner.run( ) ``` -如果使用 [`MultiProvider`][agents.MultiProvider],请改为传入 `openai_strict_feature_validation=True`。 +如果使用[`MultiProvider`][agents.MultiProvider],请改为传入`openai_strict_feature_validation=True`。 -某些 OpenAI兼容 Chat Completions 提供商会分块流式传输工具调用增量,但这些数据不足以支持可靠的 SDK 增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后发出工具调用: +一些OpenAI兼容的Chat Completions提供商会分块传输工具调用增量,但这些分块不够可靠,无法供 SDK 进行增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后生成工具调用: ```python from agents import OpenAIProvider @@ -637,11 +638,11 @@ provider = OpenAIProvider( ) ``` -对于 [`MultiProvider`][agents.MultiProvider],请使用 `openai_buffer_streamed_tool_calls=True`。 +对于[`MultiProvider`][agents.MultiProvider],请使用`openai_buffer_streamed_tool_calls=True`。 -### structured outputs 支持 +### structured outputs支持 -部分模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: +某些模型提供商不支持[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: ``` @@ -649,42 +650,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -这是部分模型提供商的不足之处——它们支持 JSON 输出,但不允许您指定用于输出的 `json_schema`。我们正在解决此问题,但建议依赖支持 JSON schema 输出的提供商,否则您的应用经常会因 JSON 格式错误而中断。 +这是某些模型提供商的局限:它们支持 JSON 输出,但不允许你指定输出所使用的`json_schema`。我们正在修复此问题,但建议依赖支持 JSON schema 输出的提供商,否则应用程序通常会因格式错误的 JSON 而中断。 ## 跨提供商混用模型 -您需要注意模型提供商之间的功能差异,否则可能遇到错误。例如,OpenAI支持 structured outputs、多模态输入以及托管式文件检索和网络检索,但许多其他提供商并不支持这些功能。请注意以下限制: +你需要了解模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI支持structured outputs、多模态输入,以及托管式文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: -- 不要向无法理解的提供商发送不受支持的 `tools` -- 调用纯文本模型前,请过滤掉多模态输入 -- 请注意,不支持结构化 JSON 输出的提供商偶尔会生成无效 JSON。 +- 不要向无法理解相应`tools`的提供商发送它们 +- 在调用纯文本模型之前过滤掉多模态输入 +- 请注意,不支持结构化 JSON 输出的提供商有时会生成无效 JSON。 ## 第三方适配器 -仅当 SDK 的内置提供商集成点无法满足需求时,才应使用第三方适配器。如果您仅通过此 SDK 使用 OpenAI模型,请优先使用内置 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI模型与非 OpenAI提供商结合使用,或需要由适配器管理的提供商覆盖或内置路径未提供的路由功能的场景。适配器会在 SDK 与上游模型提供商之间增加一个兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的测试版集成形式包含 Any-LLM 和 LiteLLM。 +仅当 SDK 的内置提供商集成点不足以满足需求时,才使用第三方适配器。如果此 SDK 仅使用OpenAI模型,请优先使用内置[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将OpenAI模型与非OpenAI提供商结合使用,或需要仅由适配器提供的提供商覆盖范围或路由的情况。适配器在 SDK 与上游模型提供商之间增加了一个兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的 beta 适配器集成形式提供 Any-LLM 和 LiteLLM。 ### Any-LLM -对于需要由 Any-LLM 管理提供商覆盖或路由的场景,我们会以尽力支持的测试版形式提供 Any-LLM 支持。 +Any-LLM 支持以尽力支持的 beta 形式提供,适用于需要由 Any-LLM 管理提供商覆盖范围或路由的情况。 -根据上游提供商路径,Any-LLM 可能使用 Responses API、Chat Completions 兼容 API 或提供商专属兼容层。 +根据上游提供商路径,Any-LLM 可能会使用Responses API、Chat Completions兼容 API 或提供商特定的兼容层。 -如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。您可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 配合使用、直接实例化 `AnyLLMModel`,或在运行作用域中使用 `AnyLLMProvider`。如果需要显式锁定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 +如果需要 Any-LLM,请安装`openai-agents[any-llm]`,然后从[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py)或[`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)开始。你可以将`any-llm/...`模型名称与[`MultiProvider`][agents.MultiProvider]搭配使用,直接实例化`AnyLLMModel`,或在运行作用域使用`AnyLLMProvider`。如果需要显式固定模型接口,请在构造`AnyLLMModel`时传入`api="responses"`或`api="chat_completions"`。 -Any-LLM 仍然是第三方适配层,因此提供商依赖项和能力缺口由上游 Any-LLM 定义,而非 SDK。上游提供商返回使用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会发出使用量数据块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 专属行为,请验证计划部署的确切提供商后端。 +Any-LLM 仍是第三方适配器层,因此提供商依赖项和功能缺口由上游 Any-LLM 定义,而非由 SDK 定义。当上游提供商返回使用量指标时,系统会自动传播这些指标,但流式Chat Completions后端可能需要`ModelSettings(include_usage=True)`才会生成使用量数据块。如果你依赖structured outputs、工具调用、使用量报告或Responses特定行为,请验证计划部署的具体提供商后端。 ### LiteLLM -对于需要 LiteLLM 专属提供商覆盖或路由的场景,我们会以尽力支持的测试版形式提供 LiteLLM 支持。 +LiteLLM 支持以尽力支持的 beta 形式提供,适用于需要 LiteLLM 特定提供商覆盖范围或路由的情况。 -如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。您可以使用 `litellm/...` 模型名称,也可以直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 +如果需要 LiteLLM,请安装`openai-agents[litellm]`,然后从[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py)或[`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)开始。你可以使用`litellm/...`模型名称,也可以直接实例化[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 -某些由 LiteLLM 支持的提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器专属路由行为,请验证计划部署的确切提供商后端。 +通过 LiteLLM 适配器访问的某些提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入`ModelSettings(include_usage=True)`;如果你依赖structured outputs、工具调用、使用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 -如果 LiteLLM 针对响应对象发出 Pydantic 序列化器警告,您可以在导入 LiteLLM 适配器前选择启用 SDK 的兼容性补丁: +如果 LiteLLM 为响应对象生成 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器之前选择启用 SDK 的兼容性补丁: ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -该补丁默认禁用,仅在值为 `1` 或 `true` 时启用。它通过包装 LiteLLM 的私有日志辅助函数,抑制一类特定的 LiteLLM 响应序列化警告,因此应将其视为针对性权宜方案,而非通用序列化设置。由于它依赖 LiteLLM 的私有 API,升级 LiteLLM 时请重新验证该补丁;当上游不再出现该警告时,请移除该环境变量。 \ No newline at end of file +该补丁默认禁用,仅在值为`1`或`true`时启用。它通过包装一个私有 LiteLLM 日志辅助工具来抑制特定类型的 LiteLLM 响应序列化警告,因此应将其视为针对性解决方案,而不是通用序列化设置。由于它依赖私有 LiteLLM API,升级 LiteLLM 时请重新验证该补丁,并在上游警告不再出现后移除该环境变量。 \ No newline at end of file diff --git a/docs/zh/multi_agent.md b/docs/zh/multi_agent.md index f2947077ce..2d3eaa81af 100644 --- a/docs/zh/multi_agent.md +++ b/docs/zh/multi_agent.md @@ -4,54 +4,54 @@ search: --- # 智能体编排 -编排指的是应用中智能体的流程:哪些智能体会运行、以什么顺序运行,以及它们如何决定接下来发生什么?编排智能体主要有两种方式: +编排是指应用中智能体的运行流程:哪些智能体运行、以何种顺序运行,以及如何决定下一步?智能体编排主要有两种方式: -1. 让 LLM 做决策:利用 LLM 的智能进行规划、推理,并据此决定要采取哪些步骤。 -2. 通过代码编排:通过你的代码来确定智能体的流程。 +1. 让 LLM 做出决策:利用 LLM 的智能进行规划和推理,并据此决定要采取的步骤。 +2. 通过代码进行编排:通过代码确定智能体的运行流程。 -你也可以混合搭配这些模式。它们各有取舍,如下所述。 +你可以混合搭配使用这些模式。每种模式都有各自的权衡,具体如下所述。 -## 通过 LLM 编排 +## 基于 LLM 的智能体编排 -智能体是配备了 instructions、tools 和任务转移的 LLM。这意味着,面对开放式任务时,LLM 可以自主规划如何处理任务:使用工具执行操作并获取数据,并使用任务转移将任务委派给子智能体。例如,一个研究智能体可以配备如下工具: +智能体是配备了指令、工具和任务转移能力的 LLM。这意味着,面对开放式任务时,LLM 可以自主规划如何处理该任务,使用工具执行操作和获取数据,并通过任务转移将任务委派给子智能体。例如,研究智能体可以配备以下能力: -- 网络检索,用于在线查找信息 -- 文件检索与检索,用于搜索专有数据和连接 -- 计算机操作,用于在计算机上执行操作 -- 代码执行,用于进行数据分析 -- 任务转移,用于转交给擅长规划、报告撰写等工作的专门智能体。 +- 通过网络检索查找在线信息 +- 通过文件检索和提取,搜索专有数据及已连接的数据源 +- 通过计算机操作在计算机上执行操作 +- 通过代码执行进行数据分析 +- 将任务转移给擅长规划、报告撰写等工作的专业智能体。 -### 核心 SDK 模式 +### SDK 核心模式 -在 Python SDK 中,最常见的两种编排模式是: +在 Python SDK 中,最常见的是以下两种编排模式: -| 模式 | 工作方式 | 最适合的场景 | +| 模式 | 工作方式 | 最适用的情况 | | --- | --- | --- | -| Agents as tools | 管理器智能体保持对对话的控制,并通过 `Agent.as_tool()` 调用专门智能体。 | 你希望由一个智能体负责最终答案、组合多个专门智能体的输出,或在一个地方统一执行共享的安全防护措施。 | -| 任务转移 | 分诊智能体将对话路由到专门智能体,而该专门智能体会在本轮剩余过程中成为活跃智能体。 | 你希望专门智能体直接响应、保持提示词聚焦,或在不由管理器叙述结果的情况下切换 instructions。 | +| Agents as tools | 管理智能体持续控制对话,并通过 `Agent.as_tool()` 调用专业智能体。 | 你希望由一个智能体负责最终回答、整合多个专业智能体的输出,或在一个位置实施共享的 SDK 安全防护措施。 | +| 任务转移 | 分流智能体将对话路由给专业智能体,后者会成为该回合剩余阶段的活动智能体。 | 你希望专业智能体直接响应、保持提示词专注,或通过任务转移切换活动指令,而无需管理智能体转述结果。 | -当专门智能体应协助完成一个有边界的子任务、但不应接管面向用户的对话时,请使用**agents as tools**。当路由本身是工作流的一部分,并且你希望被选中的专门智能体负责交互的下一部分时,请使用**任务转移**。 +当专业智能体只需协助完成范围明确的子任务,而不应接管面向用户的对话时,请使用 **agents as tools**。当路由本身是工作流的一部分,并且你希望选定的专业智能体负责当前回合的剩余部分时,请使用**任务转移**。 -你也可以将两者结合使用。分诊智能体可以任务转移给专门智能体,而该专门智能体仍然可以将其他智能体作为工具来调用,以处理范围较窄的子任务。 +你也可以将两者结合使用。分流智能体可以将任务转移给专业智能体,而该专业智能体仍可将其他智能体作为工具调用,以完成范围较窄的子任务。 -当任务是开放式的,并且你希望依赖 LLM 的智能时,这种模式非常适合。这里最重要的策略是: +当任务是开放式的,并且你希望依赖 LLM 的智能时,这种模式非常适用。以下是其中最重要的策略: -1. 投入精力编写好的提示词。明确说明有哪些工具可用、如何使用它们,以及必须在哪些参数范围内运行。 -2. 监控你的应用并不断迭代。观察问题出现在哪里,并迭代你的提示词。 -3. 允许智能体自省并改进。例如,让它在循环中运行并自我评议;或者提供错误消息,让它改进。 -4. 使用在某一项任务上表现出色的专门智能体,而不是期望一个通用智能体擅长所有事情。 -5. 投入使用[评估](https://platform.openai.com/docs/guides/evals)。这可以帮助你训练智能体,使其改进并更擅长完成任务。 +1. 投入精力设计优质提示词。明确说明有哪些工具可用、如何使用这些工具,以及智能体必须遵循哪些约束。 +2. 监控应用并持续迭代。找出问题所在,并持续改进提示词。 +3. 允许智能体进行自省和改进。例如,让它循环运行并进行自我评析;或者提供错误消息,让它自行改进。 +4. 使用专注于并擅长单一任务的专业智能体,而不是期望一个通用智能体能胜任所有任务。 +5. 投入精力进行[评估](https://platform.openai.com/docs/guides/evals)。这有助于训练智能体,使其不断改进并更好地完成任务。 -如果你想了解这种编排方式背后的核心 SDK 基础组件,请从[工具](tools.md)、[任务转移](handoffs.md)和[运行智能体](running_agents.md)开始。 +如果你想了解这种编排方式背后的 SDK 核心基础组件,请先参阅[工具](tools.md)、[任务转移](handoffs.md)和[运行智能体](running_agents.md)。 -## 通过代码编排 +## 基于代码的智能体编排 -虽然通过 LLM 编排非常强大,但从速度、成本和性能角度来看,通过代码编排可以让任务更具确定性和可预测性。这里的常见模式包括: +虽然基于 LLM 的编排功能强大,但基于代码的编排可以让任务在速度、成本和性能方面更具确定性和可预测性。常见模式包括: -- 使用 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) 生成格式良好的数据,供你的代码检查。例如,你可以要求智能体将任务归类到几个目录中,然后基于目录选择下一个智能体。 -- 通过将一个智能体的输出转换为下一个智能体的输入来串联多个智能体。你可以将撰写博客文章这样的任务分解为一系列步骤——做研究、写大纲、撰写博客文章、进行评议,然后改进它。 -- 将执行任务的智能体放在 `while` 循环中运行,并配合一个负责评估和提供反馈的智能体,直到评估者认为输出满足某些标准。 -- 并行运行多个智能体,例如通过 Python 的 `asyncio.gather` 等基础组件实现。当你有多个彼此不依赖的任务时,这有助于提升速度。 +- 使用 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) 生成可通过代码检查的格式良好的数据。例如,你可以让智能体将任务分类到若干类别中,然后根据类别选择下一个智能体。 +- 将一个智能体的输出转换为下一个智能体的输入,从而串联多个智能体。你可以将撰写博客文章这样的任务拆分为一系列步骤——开展研究、编写大纲、撰写博客文章、评析文章,然后进行改进。 +- 在 `while` 循环的每次迭代中,运行任务智能体以生成输出,然后运行评估智能体来评估该输出并提供反馈;当评估智能体判定输出符合所需标准时停止。 +- 并行运行多个智能体,例如通过 `asyncio.gather` 等 Python 基础组件。当多个任务彼此不依赖时,这种方式有助于提高速度。 我们在 [`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns) 中提供了许多代码示例。 @@ -59,6 +59,6 @@ search: - [智能体](agents.md):组合模式和智能体配置。 - [工具](tools.md#agents-as-tools):`Agent.as_tool()` 和管理器式编排。 -- [任务转移](handoffs.md):专门智能体之间的委派。 +- [任务转移](handoffs.md):专业智能体之间的任务委派。 - [运行智能体](running_agents.md):每次运行的编排控制和对话状态。 -- [快速入门](quickstart.md):一个最小化的端到端任务转移示例。 \ No newline at end of file +- [快速入门](quickstart.md):最简端到端任务转移示例。 \ No newline at end of file diff --git a/docs/zh/realtime/guide.md b/docs/zh/realtime/guide.md index 9ff7153887..0ebeede866 100644 --- a/docs/zh/realtime/guide.md +++ b/docs/zh/realtime/guide.md @@ -4,48 +4,48 @@ search: --- # 实时智能体指南 -本指南说明OpenAI Agents SDK的实时层如何映射到OpenAI Realtime API,以及Python SDK在此基础上增加了哪些额外行为。 +本指南介绍 OpenAI Agents SDK 的实时层如何映射到 OpenAI Realtime API,以及 Python SDK 在此基础上增加的额外行为。 !!! note "从这里开始" - 如果你想使用默认的Python路径,请先阅读[快速入门](quickstart.md)。如果你正在确定应用应使用服务端WebSocket还是SIP,请阅读[实时传输](transport.md)。浏览器WebRTC传输不属于Python SDK的一部分。 + 如果你希望使用默认的 Python 路径,请先阅读[快速入门](quickstart.md)。如果你正在决定应用应使用服务端 WebSocket 还是 SIP,请阅读[实时传输](transport.md)。浏览器 WebRTC 传输不属于 Python SDK。 ## 概述 -实时智能体会与Realtime API保持长连接,使模型能够增量处理文本和音频、流式传输音频输出、调用工具,并处理打断,而无需在每一轮都重新发起请求。 +实时智能体会与 Realtime API 保持长期连接,以便模型增量处理文本和音频、以流式方式输出音频、调用工具并处理中断,而无需每轮都重新发起请求。 -主要SDK组件包括: +主要 SDK 组件包括: -- **RealtimeAgent**: 单个实时专用智能体的指令、工具、输出安全防护措施和任务转移 -- **RealtimeRunner**: 将起始智能体连接到实时传输层的会话工厂 -- **RealtimeSession**: 用于发送输入、接收事件、跟踪历史记录和执行工具的实时会话 -- **RealtimeModel**: 传输抽象。默认实现是OpenAI的服务端WebSocket实现。 +- **RealtimeAgent**:一名实时专用智能体的指令、工具、输出安全防护措施和任务转移 +- **RealtimeRunner**:将起始智能体连接到实时传输的会话工厂 +- **RealtimeSession**:发送输入、接收事件、追踪历史记录并执行工具的实时会话 +- **RealtimeModel**:传输抽象。默认实现是 OpenAI 的服务端 WebSocket。 ## 会话生命周期 典型的实时会话如下: -1. 创建一个或多个`RealtimeAgent`。 -2. 使用起始智能体创建`RealtimeRunner`。 -3. 调用`await runner.run()`以获取`RealtimeSession`。 -4. 使用`async with session:`或`await session.enter()`进入会话。 -5. 使用`send_message()`或`send_audio()`发送用户输入。 -6. 迭代会话事件,直到对话结束。 +1. 创建一个或多个 `RealtimeAgent`。 +2. 使用起始智能体创建 `RealtimeRunner`。 +3. 调用 `await runner.run()` 获取 `RealtimeSession`。 +4. 使用 `async with session:` 或 `await session.enter()` 进入会话。 +5. 使用 `send_message()` 或 `send_audio()` 发送用户输入。 +6. 迭代处理会话事件,直到对话结束。 -与纯文本运行不同,`runner.run()`不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和当前智能体配置与传输层保持同步。 +与纯文本运行不同,`runner.run()` 不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和当前智能体配置与传输层保持同步。 -默认情况下,`RealtimeRunner`使用`OpenAIRealtimeWebSocketModel`,因此默认Python路径是通过服务端WebSocket连接到Realtime API。如果传入其他`RealtimeModel`,相同的会话生命周期和智能体功能仍然适用,但连接机制可以不同。 +默认情况下,`RealtimeRunner` 使用 `OpenAIRealtimeWebSocketModel`,因此默认 Python 路径是连接到 Realtime API 的服务端 WebSocket。如果传入不同的 `RealtimeModel`,仍可使用相同的会话生命周期和智能体功能,但连接机制可以有所不同。 ## 智能体与会话配置 -与常规`Agent`类型相比,`RealtimeAgent`的范围有意设计得更窄: +`RealtimeAgent` 的范围有意设计得比常规 `Agent` 类型更窄: - 模型选择在会话级别配置,而不是按智能体配置。 -- 不支持structured outputs。 -- 可以配置语音,但会话生成语音音频后便无法更改。 -- 指令、工具调用、任务转移、钩子和输出安全防护措施仍然可用。 +- 不支持 Structured outputs。 +- 可以配置语音,但在会话已经生成语音后无法更改。 +- 指令、函数工具、任务转移、钩子和输出安全防护措施仍然可用。 -`RealtimeSessionModelSettings`既支持较新的嵌套`audio`配置,也支持较旧的扁平别名。新代码应优先使用嵌套结构,并使用`gpt-realtime-2.1`开始构建新的实时智能体: +`RealtimeSessionModelSettings` 同时支持较新的嵌套 `audio` 配置和旧版扁平别名。新代码应优先使用嵌套形式,并为新的实时智能体从 `gpt-realtime-2.1` 开始: ```python runner = RealtimeRunner( @@ -67,19 +67,19 @@ runner = RealtimeRunner( ) ``` -常用的会话级别设置包括: +常用的会话级设置包括: -- `audio.input.format`, `audio.output.format` +- `audio.input.format`、`audio.output.format` - `audio.input.transcription` - `audio.input.noise_reduction` - `audio.input.turn_detection` -- `audio.output.voice`, `audio.output.speed` +- `audio.output.voice`、`audio.output.speed` - `output_modalities` - `tool_choice` - `prompt` - `tracing` -`RealtimeRunner(config=...)`中常用的运行级别设置包括: +`RealtimeRunner(config=...)` 上常用的运行级设置包括: - `async_tool_calls` - `output_guardrails` @@ -87,13 +87,13 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -如需了解完整的类型化接口,请参阅[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]和[`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 +有关完整的类型化接口,请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 ## 输入与输出 ### 文本与结构化用户消息 -使用[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]发送纯文本或结构化实时消息。 +使用 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] 发送纯文本或结构化实时消息。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +111,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -结构化消息是在实时对话中加入图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)中的Web演示代码通过这种方式转发`input_image`消息。 +结构化消息是在实时对话中加入图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) 中的 Web 演示代码示例会以这种方式转发 `input_image` 消息。 ### 音频输入 -使用[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]流式传输原始音频字节: +使用 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] 流式传输原始音频字节: ```python await session.send_audio(audio_bytes) ``` -如果禁用了服务端轮次检测,则需要自行标记轮次边界。高层便捷方法如下: +如果禁用了服务端轮次检测,你需要负责标记轮次边界。高层便捷方法如下: ```python await session.send_audio(audio_bytes, commit=True) ``` -如果需要更底层的控制,也可以通过底层模型传输层发送原始客户端事件,例如`input_audio_buffer.commit`。 +如果需要更底层的控制,也可以通过底层模型传输直接发送 Realtime API 客户端事件,例如 `input_audio_buffer.commit`。 ### 手动响应控制 -`session.send_message()`通过高层路径发送用户输入,并自动启动响应。原始音频缓冲在所有配置中**并不**都会自动执行相同操作。 +`session.send_message()` 会通过高层路径发送用户输入,并为你启动响应。在某些配置中,原始音频缓冲**不会**自动执行相同操作。 -在Realtime API层面,手动控制轮次意味着通过原始`session.update`清除`turn_detection`,然后自行发送`input_audio_buffer.commit`和`response.create`。 +在 Realtime API 层面,手动轮次控制意味着发送一个 `session.update` 事件,将 `turn_detection` 设置为 `null`,然后自行发送 `input_audio_buffer.commit` 和 `response.create`。 -如果你正在手动管理轮次,可以通过模型传输层发送原始客户端事件: +如果你正在手动管理轮次,可以通过模型传输发送原始客户端事件: ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -151,35 +151,35 @@ await session.model.send_event( 此模式适用于以下情况: -- 已禁用`turn_detection`,并且你希望自行决定模型何时响应 -- 希望在触发响应前检查或控制用户输入 +- 禁用了 `turn_detection`,且你希望自行决定模型何时响应 +- 希望在触发响应之前检查或管控用户输入 - 需要为带外响应使用自定义提示词 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)中的SIP代码示例使用原始`response.create`强制生成开场问候语。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) 中的 SIP 代码示例使用原始 `response.create` 强制生成开场问候。 -## 事件、历史记录与打断 +## 事件、历史记录与中断 -`RealtimeSession`会发出更高层的SDK事件,同时仍会转发原始模型事件,以便在需要时使用。 +`RealtimeSession` 会发出更高层的 SDK 事件,同时仍会在需要时转发原始模型事件。 重要的会话事件包括: -- `audio`, `audio_end`, `audio_interrupted` -- `agent_start`, `agent_end` -- `tool_start`, `tool_end`, `tool_approval_required` +- `audio`、`audio_end`、`audio_interrupted` +- `agent_start`、`agent_end` +- `tool_start`、`tool_end`、`tool_approval_required` - `handoff` -- `history_added`, `history_updated` +- `history_added`、`history_updated` - `guardrail_tripped` - `input_audio_timeout_triggered` - `error` - `raw_model_event` -对UI状态最有用的事件通常是`history_added`和`history_updated`。它们以`RealtimeItem`对象的形式公开会话的本地历史记录,其中包括用户消息、助手消息和工具调用。 +对 UI 状态最有用的事件通常是 `history_added` 和 `history_updated`。它们以 `RealtimeItem` 对象的形式公开会话的本地历史记录,包括用户消息、助手消息和工具调用。 ### 用量统计 -当已完成的模型响应包含用量信息时,OpenAI实时模型会在`raw_model_event`中发出[`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其`usage`字段包含该响应的令牌计数,而`input_tokens_details`和`output_tokens_details`提供可选的模态细分信息。 +当已完成的模型响应包含用量信息时,SDK 的 OpenAI `RealtimeModel` 传输会在 `raw_model_event` 内发出 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其 `usage` 字段包含该响应的 token 数量,而 `input_tokens_details` 和 `output_tokens_details` 则提供可选的模态细分。 -会话还会将每个响应的用量添加到共享的[`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]中。可以从后续高层事件(例如`agent_end`)的`event.info.context.usage`中读取该值,以检查实时会话的累计用量。 +会话还会将每个响应的用量添加到共享的 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] 中。若要查看实时会话的累计用量,可在后续的高层事件(例如 `agent_end`)中从 `event.info.context.usage` 读取。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,21 +197,21 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -只有当模型提供商在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该`RealtimeSession`收到的响应,并不是跨会话的总量。 +只有当模型提供商在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该 `RealtimeSession` 收到的响应,并非跨会话总量。 -### 打断与播放跟踪 +### 中断与播放追踪 -当用户打断助手时,会话会发出`audio_interrupted`并更新历史记录,使服务端对话与用户实际听到的内容保持一致。 +当用户打断助手时,会话会发出 `audio_interrupted` 并更新历史记录,使服务端对话与用户实际听到的内容保持一致。 -对于低延迟本地播放,默认播放跟踪器通常已经足够。在远程或延迟播放场景中,尤其是电话场景,应使用[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],使打断时的截断操作基于实际播放进度,而不是假定所有已生成音频均已播放给用户。 +对于低延迟本地播放,默认播放追踪器通常已足够。对于远程或延迟播放场景,尤其是电话场景,请使用 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],这样被中断的响应会在实际播放位置处截断,而不是假定所有已生成的音频都已播放给用户。 -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)中的Twilio代码示例展示了此模式。 +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) 中的 Twilio 代码示例展示了此模式。 ## 工具、审批、任务转移与安全防护措施 -### 工具调用 +### 函数工具 -实时智能体支持在实时对话期间使用工具调用: +实时智能体支持在实时对话期间使用函数工具: ```python from agents.decorators import tool @@ -232,9 +232,9 @@ agent = RealtimeAgent( ### 工具审批 -工具调用可以要求在执行前进行人工审批。发生这种情况时,会话会发出`tool_approval_required`,并暂停工具执行,直到你调用`approve_tool_call()`或`reject_tool_call()`。 +函数工具可以要求在执行前获得人工审批。出现这种情况时,会话会发出 `tool_approval_required`,并暂停工具运行,直到你调用 `approve_tool_call()` 或 `reject_tool_call()`。 -如果工具还具有输入安全防护措施,这些安全防护措施会在审批后、执行前立即运行。若要在发出审批事件前运行它们,请使用`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`创建运行器。通过此审批前检查的调用,在审批后、执行前仍会再次接受检查。 +如果工具还配置了输入安全防护措施,这些安全防护措施会在获得审批后、执行前立即运行。若要在发出审批事件前运行它们,请使用 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` 创建运行器。通过此次审批前检查的调用,在获得审批后、执行前仍会再次接受检查。 ```python async for event in session: @@ -242,11 +242,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -有关具体的服务端审批循环,请参阅[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。人工介入文档中的[人工介入](../human_in_the_loop.md)也会引用此流程。 +有关具体的服务端审批循环,请参阅 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。人工介入文档中的[人工介入](../human_in_the_loop.md)也会引用此流程。 ### 任务转移 -实时任务转移允许一个智能体将实时对话转交给另一个专用智能体: +实时任务转移允许一个智能体将实时对话移交给另一个专用智能体: ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +268,11 @@ main_agent = RealtimeAgent( ) ``` -直接使用的`RealtimeAgent`任务转移会被自动包装,而`realtime_handoff(...)`允许自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的`input_filter`。 +直接用作任务转移的 `RealtimeAgent` 对象会被自动封装,而 `realtime_handoff(...)` 可用于自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的 `input_filter`。 ### 安全防护措施 -实时智能体支持针对智能体响应的输出安全防护措施,以及针对工具调用的输入安全防护措施。输出安全防护措施会在经过防抖处理的输出文本和音频转录增量累积内容上运行,而不是在每个部分增量上运行;触发时会发出`guardrail_tripped`,而不是抛出异常。 +实时智能体支持对智能体响应实施输出安全防护措施,以及对函数工具调用实施输入安全防护措施。输出安全防护措施检查采用防抖机制:每次检查都会基于累积的输出文本和音频转录增量运行,而不是针对每个局部增量运行,并且会发出 `guardrail_tripped`,而不是抛出异常。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,15 +292,15 @@ agent = RealtimeAgent( ) ``` -当实时输出安全防护措施因音频转录而触发时,会话会打断当前响应,强制发出`response.cancel`,发出`guardrail_tripped`,并发送一条注明已触发安全防护措施的后续用户消息,以便模型生成替代响应。音频播放器仍应监听`audio_interrupted`并立即停止本地播放,因为触发条件生效时,部分音频可能已经进入缓冲区。使用内置OpenAI实时传输实现时,如果安全防护措施在其源响应结束后才完成,会话只会打断该响应的缓冲播放,而不会取消较新的响应。对于纯文本输出,会话会改为发送仅针对该响应的`response.cancel`;由于没有需要停止的音频播放,因此不会发出`audio_interrupted`。使用内置OpenAI实时模型时,纯文本路径也会发出相同的`guardrail_tripped`事件和后续用户消息。 +当实时输出安全防护措施因音频转录而触发时,会话会中断当前响应、强制执行 `response.cancel`、发出 `guardrail_tripped`,并发送一条后续用户消息,其中指明被触发的安全防护措施,以便模型生成替代响应。音频播放器仍应监听 `audio_interrupted` 并立即停止本地播放,因为触发机制启动时可能已有部分音频进入缓冲区。使用内置的 OpenAI Realtime 传输时,如果安全防护措施检查在其所检查的响应结束后才完成,会话只会中断该响应的缓冲播放,而不会取消之后启动的任何响应。对于纯文本输出,会话会改为发送一个响应范围内的 `response.cancel`;由于没有音频播放需要停止,因此不会发出 `audio_interrupted`。使用内置 OpenAI Realtime 模型时,纯文本路径也会发出相同的 `guardrail_tripped` 事件和后续用户消息。 -自定义`RealtimeModel`传输实现必须遵循`RealtimeModelSendInterrupt.response_id`和`playback_only`,以提供相同的、限定于源响应的音频打断行为。它们还必须重写`RealtimeModel.send_event_if()`,以支持纯文本恢复消息。实现必须在传输层的实际事件提交边界重新检查给定条件,或对该条件的检查进行串行化。默认实现会安全地跳过恢复消息,因为如果在等待`send_event()`前检查条件,较新的响应可能会在消息提交前启动;响应取消和`guardrail_tripped`事件仍会发生。 +自定义 `RealtimeModel` 传输必须遵循 `RealtimeModelSendInterrupt.response_id` 和 `playback_only`,以提供相同的、限定来源范围的音频中断行为。它们还必须重写 `RealtimeModel.send_event_if()`,以支持纯文本输出路径的恢复消息。实现必须在传输的实际事件提交边界重新检查所提供的条件,或者将条件检查与事件提交串行化。默认实现会安全地跳过恢复消息,因为如果它只检查一次条件,随后再单独发送事件,那么在条件检查与事件提交之间可能会启动另一个响应;响应取消和 `guardrail_tripped` 事件仍会发生。 -## SIP与电话 +## SIP 与电话 -Python SDK通过[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]提供一流的SIP附加流程。 +Python SDK 通过 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] 提供原生 SIP 挂接流程。 -当呼叫通过Realtime Calls API到达,并且你希望将智能体会话附加到生成的`call_id`时,请使用该流程: +当呼叫通过 Realtime Calls API 到达,并且你希望将智能体会话挂接到生成的 `call_id` 时,请使用此流程: ```python from agents.realtime import RealtimeRunner @@ -317,20 +317,20 @@ async with await runner.run( ... ``` -如果需要先接受呼叫,并希望接受载荷与根据智能体生成的会话配置保持一致,请使用`OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程见[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 +如果需要先接听呼叫,并希望接听请求载荷与从智能体派生的会话配置一致,请使用 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程可参阅 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 ## 底层访问与自定义端点 -可以通过`session.model`访问底层传输对象。 +可以通过 `session.model` 访问底层传输对象。 以下情况可使用此对象: -- 通过`session.model.add_listener(...)`添加自定义监听器 -- 发送原始客户端事件,例如`response.create`或`session.update` -- 通过`model_config`自定义`url`、`headers`或`api_key`处理 -- 使用`call_id`附加到现有实时呼叫 +- 通过 `session.model.add_listener(...)` 添加自定义监听器 +- 发送原始客户端事件,例如 `response.create` 或 `session.update` +- 通过 `model_config` 自定义 `url`、`headers` 或 `api_key` 的处理方式 +- 使用 `call_id` 挂接到现有实时呼叫 -`RealtimeModelConfig`支持: +`RealtimeModelConfig` 支持: - `api_key` - `url` @@ -339,9 +339,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -本仓库随附的`call_id`代码示例使用SIP。更广泛的Realtime API也会将`call_id`用于某些服务端控制流程,但此处并未将这些流程作为Python代码示例提供。 +此代码仓库提供的 `call_id` 代码示例使用 SIP。更广泛的 Realtime API 还会在某些服务端控制流程中使用 `call_id`,但这里并未将其作为 Python 代码示例提供。 -连接Azure OpenAI时,请传入正式版Realtime端点URL和显式标头。例如: +连接 Azure OpenAI时,请传入正式发布版 Realtime 端点 URL 和显式请求头。例如: ```python session = await runner.run( @@ -352,7 +352,7 @@ session = await runner.run( ) ``` -对于基于令牌的身份验证,请在`headers`中使用Bearer令牌: +对于基于 token 的身份验证,请在 `headers` 中使用 Bearer token: ```python session = await runner.run( @@ -363,12 +363,12 @@ session = await runner.run( ) ``` -如果传入`headers`,SDK不会自动添加`Authorization`。使用实时智能体时,请避免使用旧版Beta路径(`/openai/realtime?api-version=...`)。 +如果传入 `headers`,SDK 不会自动添加 `Authorization`。实时智能体应避免使用旧版 beta 路径(`/openai/realtime?api-version=...`)。 ## 延伸阅读 - [实时传输](transport.md) - [快速入门](quickstart.md) -- [OpenAI实时对话](https://developers.openai.com/api/docs/guides/realtime-conversations/) -- [OpenAI实时服务端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/) +- [OpenAI Realtime对话](https://developers.openai.com/api/docs/guides/realtime-conversations/) +- [OpenAI Realtime服务端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/) - [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) \ No newline at end of file diff --git a/docs/zh/realtime/quickstart.md b/docs/zh/realtime/quickstart.md index 7c1897908e..12c3e57fbb 100644 --- a/docs/zh/realtime/quickstart.md +++ b/docs/zh/realtime/quickstart.md @@ -4,17 +4,17 @@ search: --- # 快速入门 -Python SDK 中的实时智能体是基于 WebSocket 传输之上的 OpenAI Realtime API 构建的服务端低延迟智能体。 +Python SDK 中的实时智能体是在服务端运行的低延迟智能体,基于通过 WebSocket 传输的 OpenAI Realtime API 构建。 -!!! note "Python SDK 边界" +!!! note "Python SDK 的适用边界" - Python SDK **不**提供浏览器 WebRTC 传输。本页仅介绍由 Python 管理、通过服务端 WebSocket 进行的实时会话。使用此 SDK 进行服务端编排、工具、审批以及电话集成。另请参阅[实时传输](transport.md)。 + Python SDK **不**提供浏览器 WebRTC 传输。本页仅介绍通过服务端 WebSocket、由 Python 管理的实时会话。此 SDK 适用于服务端编排、工具、审批和电话集成。另请参阅[实时传输](transport.md)。 ## 前提条件 - Python 3.10 或更高版本 - OpenAI API 密钥 -- 对 OpenAI Agents SDK 有基本了解 +- 基本熟悉 OpenAI Agents SDK ## 安装 @@ -24,9 +24,9 @@ Python SDK 中的实时智能体是基于 WebSocket 传输之上的 OpenAI Realt pip install openai-agents ``` -## 服务端实时会话创建 +## 服务端实时会话的创建 -### 1. 实时组件导入 +### 1. 实时组件的导入 ```python import asyncio @@ -34,7 +34,7 @@ import asyncio from agents.realtime import RealtimeAgent, RealtimeRunner ``` -### 2. 起始智能体定义 +### 2. 起始智能体的定义 ```python agent = RealtimeAgent( @@ -43,9 +43,9 @@ agent = RealtimeAgent( ) ``` -### 3. runner 配置 +### 3. 运行器的配置 -对于新代码,优先使用嵌套的 `audio.input` / `audio.output` 会话设置结构。对于新的实时智能体,请从 `gpt-realtime-2.1` 开始。 +对于新代码,建议采用嵌套的 `audio.input` / `audio.output` 会话设置结构。对于新的实时智能体,请从 `gpt-realtime-2.1` 开始。 ```python runner = RealtimeRunner( @@ -72,9 +72,9 @@ runner = RealtimeRunner( ) ``` -### 4. 会话启动与输入发送 +### 4. 会话的启动与输入的发送 -`runner.run()` 返回一个 `RealtimeSession`。当你进入会话上下文时,连接会被打开。 +`runner.run()` 返回一个 `RealtimeSession`。进入会话上下文时,连接将建立。 ```python async def main() -> None: @@ -104,37 +104,37 @@ if __name__ == "__main__": ## 本快速入门未包含的内容 -- 麦克风采集和扬声器播放代码。请参阅 [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) 中的实时代码示例。 -- SIP / 电话附加流程。请参阅[实时传输](transport.md)和 [SIP 部分](guide.md#sip-and-telephony)。 +- 麦克风采集和扬声器播放代码。请参阅 [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) 中的实时功能代码示例。 +- SIP / 电话接入流程。请参阅[实时传输](transport.md)和 [SIP 部分](guide.md#sip-and-telephony)。 ## 关键设置 -基本会话运行后,大多数人接下来会用到的设置包括: +基本会话正常运行后,大多数人接下来会用到以下设置: - `model_name` - `audio.input.format`、`audio.output.format` - `audio.input.transcription` - `audio.input.noise_reduction` -- `audio.input.turn_detection` 用于自动轮次检测 +- 用于自动轮次检测的 `audio.input.turn_detection` - `audio.output.voice` - `tool_choice`、`prompt`、`tracing` - `async_tool_calls`、`tool_execution.pre_approval_tool_input_guardrails`、`guardrails_settings.debounce_text_length`、`tool_error_formatter` -较旧的扁平别名(如 `input_audio_format`、`output_audio_format`、`input_audio_transcription` 和 `turn_detection`)仍然可用,但新代码首选嵌套的 `audio` 设置。 +较旧的扁平别名(例如 `input_audio_format`、`output_audio_format`、`input_audio_transcription` 和 `turn_detection`)仍然可用,但对于新代码,建议使用嵌套的 `audio` 设置。 -对于手动轮次控制,请使用原始的 `session.update` / `input_audio_buffer.commit` / `response.create` 流程,具体如[实时智能体指南](guide.md#manual-response-control)中所述。 +对于手动轮次控制,请使用[实时智能体指南](guide.md#manual-response-control)中介绍的底层 `session.update` / `input_audio_buffer.commit` / `response.create` 流程。 -完整模式请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 +有关完整 schema,请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 ## 连接选项 -在环境中设置你的 API 密钥: +在环境中设置 API 密钥: ```bash export OPENAI_API_KEY="your-api-key-here" ``` -或在启动会话时直接传入: +或者在启动会话时直接传入: ```python session = await runner.run(model_config={"api_key": "your-api-key"}) @@ -143,16 +143,16 @@ session = await runner.run(model_config={"api_key": "your-api-key"}) `model_config` 还支持: - `url`:自定义 WebSocket 端点 -- `headers`:自定义请求头 -- `call_id`:附加到现有实时通话。在此仓库中,已记录的附加流程是 SIP。 +- `headers`:自定义请求标头 +- `call_id`:接入现有的实时通话。在此代码仓库中,文档介绍的接入流程为 SIP。 - `playback_tracker`:报告用户实际听到的音频量 -如果你显式传入 `headers`,SDK 将**不会**为你注入 `Authorization` 标头。 +如果显式传入 `headers`,SDK 将**不会**自动注入 `Authorization` 标头。 -连接到 Azure OpenAI 时,请在 `model_config["url"]` 中传入 GA Realtime 端点 URL,并显式传入 headers。对于实时智能体,请避免使用旧版 beta 路径(`/openai/realtime?api-version=...`)。详情请参阅[实时智能体指南](guide.md#low-level-access-and-custom-endpoints)。 +连接 Azure OpenAI 时,请将 `model_config["url"]` 设置为正式发布版 Realtime 端点 URL,并显式传入标头。使用实时智能体时,请避免使用旧版 beta 路径(`/openai/realtime?api-version=...`)。有关详细信息,请参阅[实时智能体指南](guide.md#low-level-access-and-custom-endpoints)。 ## 后续步骤 -- 阅读[实时传输](transport.md),以在服务端 WebSocket 和 SIP 之间做出选择。 +- 阅读[实时传输](transport.md),以便在服务端 WebSocket 和 SIP 之间进行选择。 - 阅读[实时智能体指南](guide.md),了解生命周期、结构化输入、审批、任务转移、安全防护措施和底层控制。 - 浏览 [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) 中的代码示例。 \ No newline at end of file diff --git a/docs/zh/realtime/transport.md b/docs/zh/realtime/transport.md index c2117eda0a..6c2fc1702a 100644 --- a/docs/zh/realtime/transport.md +++ b/docs/zh/realtime/transport.md @@ -4,42 +4,42 @@ search: --- # 实时传输 -本页帮助您确定如何将实时智能体集成到 Python 应用中。 +使用本页面确定如何将实时智能体集成到 Python 应用程序中。 !!! note "Python SDK 边界" - Python SDK **不**包含浏览器 WebRTC 传输。本页仅介绍 Python SDK 的传输方式:服务端 WebSocket 和 SIP 接入流程。浏览器 WebRTC 属于单独的平台主题,详见官方[Realtime API 与 WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc/)指南。 + Python SDK **不**包含浏览器 WebRTC 传输。本页面仅介绍 Python SDK 的传输选择:服务器端 WebSocket 和 SIP 接入流程。浏览器 WebRTC 属于独立的平台主题,相关内容请参阅官方 [Realtime API 与 WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc/)指南。 -## 决策指南 +## 选择指南 -| 目标 | 入门文档 | 原因 | +| 目标 | 入门资源 | 原因 | | --- | --- | --- | -| 构建由服务端管理的实时应用 | [快速入门](quickstart.md) | Python 的默认路径是由 `RealtimeRunner` 管理的服务端 WebSocket 会话。 | -| 了解应选择的传输方式和部署架构 | 本页 | 在确定传输方式或部署架构之前,请先阅读本页。 | -| 将智能体接入电话或 SIP 通话 | [实时指南](guide.md)和[`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | 代码仓库提供了由 `call_id` 驱动的 SIP 接入流程。 | +| 构建由服务器管理的实时应用 | [快速入门](quickstart.md) | 默认的 Python 路径是由 `RealtimeRunner` 管理的服务器端 WebSocket 会话。 | +| 了解应选择的传输方式和部署形态 | 本页面 | 在确定传输方式或部署形态之前,请先阅读本页面。 | +| 将智能体接入电话或 SIP 通话 | [实时指南](guide.md)和 [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | 该仓库提供了由 `call_id` 驱动的 SIP 接入流程。 | -## 服务端 WebSocket:Python 的默认路径 +## 默认的 Python 路径:服务器端 WebSocket 除非传入自定义 `RealtimeModel`,否则 `RealtimeRunner` 会使用 `OpenAIRealtimeWebSocketModel`。 -这意味着标准 Python 拓扑结构如下: +这意味着标准 Python 拓扑如下: 1. 您的 Python 服务创建一个 `RealtimeRunner`。 2. `await runner.run()` 返回一个 `RealtimeSession`。 -3. 进入会话并发送文本、结构化消息或音频。 -4. 使用 `RealtimeSessionEvent` 项,并将音频或转录文本转发到您的应用。 +3. 将 `RealtimeSession` 作为异步上下文管理器进入,然后发送文本、结构化消息或音频。 +4. 消费 `RealtimeSessionEvent` 项,并将音频或转录文本转发到您的应用程序。 -核心演示应用、CLI 代码示例和 Twilio Media Streams 代码示例均使用此拓扑结构: +核心演示应用、CLI 示例和 Twilio Media Streams 示例均使用此拓扑: -- [`examples/realtime/app`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app) -- [`examples/realtime/cli`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/cli) -- [`examples/realtime/twilio`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio) +- [`examples/realtime/app`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app) +- [`examples/realtime/cli`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/cli) +- [`examples/realtime/twilio`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio) -当您的服务负责音频管线、工具执行、审批流程和历史记录处理时,请使用此路径。 +当您的服务器负责音频管线、工具执行、审批流程和历史记录处理时,请使用此路径。 ### 底层 WebSocket 调优 -如需调整底层服务端 WebSocket 连接,请将 `transport_config` 传递给 `OpenAIRealtimeWebSocketModel`: +需要调优底层服务器端 WebSocket 连接时,请将 `transport_config` 传递给 `OpenAIRealtimeWebSocketModel`: ```python from agents.realtime import ( @@ -62,47 +62,47 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 支持的选项包括: -- `ping_interval`:客户端保活 ping 之间的秒数。设置为 `None` 可禁用 ping。 -- `ping_timeout`:断开连接前等待 pong 的秒数。设置为 `None` 可容忍 pong 延迟,而不会触发心跳超时。 -- `handshake_timeout`:等待初始连接握手的秒数。 -- `max_size`:传入 WebSocket 消息的最大字节数。SDK 默认值为 `None`,即不限制传入消息的大小;如需限制单条消息的内存用量,请设置明确的上限。 +- `ping_interval`:客户端保活 ping 之间的秒数。设置为 `None` 可禁用 ping。 +- `ping_timeout`:断开连接前等待 pong 的秒数。设置为 `None` 可容忍延迟的 pong,而不会触发心跳超时。 +- `handshake_timeout`:等待初始连接握手的秒数。 +- `max_size`:传入 WebSocket 消息的最大字节数。SDK 默认值为 `None`,即不限制传入消息的大小;如需限制每条消息的内存使用量,请设置明确的上限。 -这些设置用于配置客户端连接,而不是 Realtime API 会话。对于端点、身份验证、通话接入和播放设置,请继续使用 `RealtimeModelConfig`。 +这些设置配置的是客户端连接,而不是 Realtime API 会话。端点、身份验证、通话接入和播放设置仍应使用 `RealtimeModelConfig`。 -## SIP 接入:电话通信路径 +## 电话通信路径:SIP 接入 -对于此代码仓库中记录的电话通信流程,Python SDK 会通过 `call_id` 接入现有的实时通话。 +对于本仓库中记录的电话通信流程,Python SDK 通过 `call_id` 接入现有的实时通话。 -此拓扑结构如下: +此拓扑如下: -1. OpenAI 向您的服务发送 `realtime.call.incoming` 等 webhook。 +1. OpenAI 向您的服务发送 Webhook,例如 `realtime.call.incoming`。 2. 您的服务通过 Realtime Calls API 接听通话。 -3. 您的 Python 服务启动 `RealtimeRunner(..., model=OpenAIRealtimeSIPModel())`。 -4. 会话使用 `model_config={"call_id": ...}` 建立连接,随后像其他实时会话一样处理事件。 +3. 您的 Python 服务启动一个 `RealtimeRunner(..., model=OpenAIRealtimeSIPModel())`。 +4. 会话通过 `model_config={"call_id": ...}` 建立连接,然后像其他实时会话一样处理事件。 -[`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) 展示了此拓扑结构。 +[`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) 展示了此拓扑。 -更广泛的 Realtime API 也会在某些服务端控制模式中使用 `call_id`,但此代码仓库提供的接入代码示例采用 SIP。 +更广泛的 Realtime API 也会将 `call_id` 用于某些服务器端控制模式,但本仓库提供的接入示例使用的是 SIP。 ## SDK 范围之外的浏览器 WebRTC -如果应用的主要客户端是使用实时 WebRTC 的浏览器: +如果您的应用主要使用 Realtime WebRTC 浏览器客户端: -- 应将其视为超出此代码仓库中 Python SDK 文档的范围。 -- 客户端流程和事件模型请参阅官方[Realtime API 与 WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc/)和[实时对话](https://developers.openai.com/api/docs/guides/realtime-conversations/)文档。 -- 如果需要在浏览器 WebRTC 客户端之外建立旁路服务端连接,请参阅官方[实时服务端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/)指南。 -- 不应期望此代码仓库提供浏览器端 `RTCPeerConnection` 抽象或现成的浏览器 WebRTC 代码示例。 +- 请将其视为不在本仓库 Python SDK 文档的范围内。 +- 有关客户端流程和事件模型,请参阅官方 [Realtime API 与 WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc/)和[实时对话](https://developers.openai.com/api/docs/guides/realtime-conversations/)文档。 +- 如果除浏览器 WebRTC 客户端外还需要旁路服务器连接,请参阅官方[实时服务器端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/)指南。 +- 不要期望本仓库提供浏览器端 `RTCPeerConnection` 抽象或现成的浏览器 WebRTC 示例。 -此代码仓库目前也未提供浏览器 WebRTC 与 Python 旁路连接组合使用的代码示例。 +本仓库目前也未提供浏览器 WebRTC 与 Python 旁路连接结合使用的示例。 -## 自定义端点与接入点 +## 自定义端点和接入点 -[`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] 中的传输配置选项可用于调整默认路径: +[`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] 中的传输配置接口允许您自定义默认传输行为: -- `url`:覆盖 WebSocket 端点 -- `headers`:提供明确的标头,例如 Azure 身份验证标头 -- `api_key`:直接传入 API 密钥或通过回调传入 -- `call_id`:接入现有的实时通话。此代码仓库中记录的代码示例采用 SIP。 -- `playback_tracker`:报告实际播放进度,以便处理中断 +- `url`:覆盖 WebSocket 端点 +- `headers`:提供显式请求头,例如 Azure 身份验证请求头 +- `api_key`:直接传入 API 密钥,或通过回调传入 +- `call_id`:接入现有实时通话。本仓库记录的示例使用 SIP。 +- `playback_tracker`:报告实际播放进度,以便处理中断 -选择拓扑结构后,请参阅[实时智能体指南](guide.md),了解详细的生命周期和功能范围。 \ No newline at end of file +选择拓扑后,请参阅[实时智能体指南](guide.md),了解详细的生命周期和功能接口。 \ No newline at end of file diff --git a/docs/zh/release.md b/docs/zh/release.md index b5416a10d9..7a056152a9 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -4,51 +4,51 @@ search: --- # 发布流程/变更日志 -本项目采用略作修改的语义化版本控制,版本格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各组成部分按以下规则递增: +本项目采用略作修改的语义化版本控制,格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各组成部分按以下方式递增: ## 次版本(`Y`) -对于任何未标记为 beta 的公共接口,如果发生**破坏性变更**,我们将递增次版本 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 +对于任何未标记为 beta 的公共接口发生的**破坏性变更**,我们将递增次版本号 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 -如果不希望引入破坏性变更,建议在项目中将版本锁定为 `0.0.x`。 +如果您不希望遇到破坏性变更,建议在项目中固定使用 `0.0.x` 版本。 -## 修订版本(`Z`) +## 补丁版本(`Z`) 对于非破坏性变更,我们将递增 `Z`: -- Bug 修复 -- 新功能 -- 私有接口变更 -- beta 功能更新 +- bug 修复 +- 新功能 +- 私有接口变更 +- beta 功能更新 ## 破坏性变更日志 ### 0.19.0 -此次次版本发布**没有**引入破坏性变更。次版本号的提升反映了 OpenAI Responses 的一个重要新功能领域:程序化工具调用。 +此此次版本发布**不**包含破坏性变更。次版本号的提升是为了体现一个重要的OpenAI Responses新功能领域:程序化工具调用。 亮点: -- 新增 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],允许受支持的 OpenAI Responses 模型生成 JavaScript,以协调符合条件的工具。它支持按工具配置 `allowed_callers`、结构化工具调用输出,并可与 Runner 流式传输、安全防护措施、审批、会话和 `RunState` 集成。有关设置方式和限制,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 -- 新增公共 `agents.decorators` 模块,并在现有工具调用和安全防护措施装饰器之外,增加了更简短的 `@tool` 别名。工具调用现在也支持异步可调用对象。 -- SDK 配置现在统一支持在智能体、运行、模型、会话、沙箱和语音流水线中使用类型化设置对象或字典,并会验证未知设置。 -- 加强了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误及诊断日志记录,以避免暴露原始敏感载荷,同时保留有用的调试上下文。 -- 改进了 AnyLLM、LiteLLM 和 Chat Completions兼容性,在模型重试期间保留会话历史,并针对响应开始前发生的 WebSocket 过载新增了提供商重试指引,以便在允许重放时,由选择启用的 Runner 重试策略进行处理。 -- 通过 `VercelCloudBucketMountStrategy` 新增了[仅可在创建时使用的 Vercel 沙箱 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。挂载了存储桶的会话不会将存储桶内容纳入工作区持久化,并且有意不支持动态更改挂载或恢复会话。 +- 新增了 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],它允许受支持的OpenAI Responses模型生成 JavaScript,以协调符合程序化工具调用条件的工具。它支持每个工具的 `allowed_callers`、来自 `FunctionTool` 实例的 structured outputs,以及与 Runner 流式传输、安全防护措施、审批、会话和 `RunState` 的集成。有关设置和约束,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 +- 新增了公共 `agents.decorators` 模块和 `@tool`,后者是现有 `@function_tool` 装饰器的较短别名,与现有安全防护措施装饰器并列提供。`FunctionTool` 实例现在也支持异步可调用对象。 +- SDK 配置现在可在智能体、运行、模型、会话、沙箱和语音管线中一致地接受类型化设置对象或字典,并会验证未知设置。 +- 强化了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误及诊断日志记录,可在保留有用调试上下文的同时避免暴露原始敏感载荷。 +- 改进了 AnyLLM、LiteLLM 和 Chat Completions兼容性,在模型重试期间保留会话历史,并针对响应开始前发生的 WebSocket 过载添加了提供商重试指南,因此在允许的情况下,选择启用的 Runner 重试策略可以重新执行失败的尝试。 +- 通过 `VercelCloudBucketMountStrategy` 新增了[只能在创建 Vercel 沙箱时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。使用挂载的会话不会将存储桶内容纳入工作区持久化,并且特意不支持动态更改挂载或恢复会话。 ### 0.18.0 -此次次版本发布**没有**引入破坏性变更。次版本号的提升仅用于 Realtime智能体默认模型更新。 +此此次版本发布**不**包含破坏性变更。次版本号的提升仅用于 Realtime 智能体默认模型更新。 亮点: -- Realtime智能体现在默认使用 `gpt-realtime-2.1` 模型,因此新的 Realtime 设置无需额外配置即可使用最新的推荐模型。 +- Realtime 智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 配置无需额外设置即可使用最新推荐模型。 ### 0.17.0 -在此版本中,沙箱本地源物化会将 `LocalFile.src` 和 `LocalDir.src` 限制在物化 `base_dir` 内,除非源路径已包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源路径从该目录解析,而绝对本地源路径必须已位于该目录内或某个明确授权的目录下。此变更修复了本地工件边界问题,但可能会影响有意将该基础目录以外的可信主机文件或目录复制到沙箱工作区的应用。 +在此版本中,沙箱本地源实例化会将 `LocalFile.src` 和 `LocalDir.src` 限制在实例化 `base_dir` 内,除非源路径包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源从该目录解析,而绝对本地源必须已位于该目录内,或位于明确授权的目录下。此变更修复了本地产物边界问题,但可能影响有意将该基础目录之外的可信主机文件或目录复制到沙箱工作区中的应用程序。 -迁移时,请使用 `SandboxPathGrant` 在清单级别授权可信主机根目录;如果沙箱只需读取这些文件,最好将其设置为只读: +迁移时,请在清单级别使用 `SandboxPathGrant` 授予对可信主机根目录的访问权限;如果沙箱只需读取这些文件,最好授予只读权限: ```python from pathlib import Path @@ -75,11 +75,11 @@ manifest = Manifest( ) ``` -请将 `extra_path_grants` 视为可信的应用配置。除非应用已批准相应主机路径,否则不要根据模型输出或其他不可信的清单输入填充授权。 +请将 `extra_path_grants` 视为可信应用程序配置。除非您的应用程序已经批准了这些主机路径,否则不要使用模型输出或其他不可信的清单输入来填充授权。 ### 0.16.0 -在此版本中,SDK 默认模型由 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含 GPT-5 的默认值,例如 `reasoning.effort="none"` 和 `verbosity="low"`。 +在此版本中,SDK 默认模型现已从 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包括 `reasoning.effort="none"` 和 `verbosity="low"` 等 GPT-5 默认值。 如果需要保留之前的默认模型行为,请在智能体或运行配置中显式设置模型,或者设置 `OPENAI_DEFAULT_MODEL` 环境变量: @@ -89,14 +89,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 亮点: -- `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 -- 对于本地、Docker 和由提供商支持的沙箱实现,沙箱工作区初始化现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 +- `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 +- 现在,本地、Docker 和提供商支持的沙箱实现中的沙箱工作区内容填充都会拒绝包含指向归档根目录之外的符号链接的 tar 归档,其中包括使用绝对路径作为目标的符号链接。 ### 0.15.0 -在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于structured outputs,也不会再导致运行循环持续重试,直至触发 `MaxTurnsExceeded`。 +在此版本中,模型拒绝现在会显式作为 `ModelRefusalError` 抛出,而不再被视为空文本输出;对于 structured outputs,也不会再导致运行循环不断重试直至 `MaxTurnsExceeded`。 -这会影响之前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。如需在不抛出异常的情况下处理拒绝,请提供 `model_refusal` 运行错误处理程序: +这会影响之前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不抛出异常,请提供 `model_refusal` 运行错误处理程序: ```python result = Runner.run_sync( @@ -106,81 +106,81 @@ result = Runner.run_sync( ) ``` -对于使用structured outputs的智能体,处理程序可以返回符合智能体输出架构的值,SDK 将像验证其他运行错误处理程序的最终输出一样对其进行验证。 +对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出模式匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样对其进行验证。 ### 0.14.0 -此次次版本发布**没有**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 +此此次版本发布**不**包含破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 亮点: -- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化的隔离工作区内处理文件、目录、Git 仓库、挂载和快照,并支持恢复。 -- 新增通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 实现的本地及容器化开发沙箱执行后端,并通过可选扩展提供 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 的托管提供商集成。 -- 新增沙箱记忆支持,使后续运行可以复用此前运行中的经验,并支持渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆代码示例。 -- 新增更广泛的工作区和恢复模型,包括本地及合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 -- 在 `examples/sandbox/` 下新增大量沙箱代码示例和教程,涵盖使用技能完成编码任务、任务转移、记忆、提供商特定设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 -- 扩展了核心运行时和追踪技术栈,新增沙箱感知的会话准备、能力绑定、状态序列化、统一追踪、提示缓存键默认值,以及更安全的敏感MCP输出遮蔽。 +- 新增了以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化的隔离工作区中处理文件、目录、Git 仓库、挂载和快照,并支持恢复。 +- 新增了通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 支持本地与容器化开发的沙箱执行后端,并通过 Python 软件包中的可选依赖 extras,为 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 提供托管提供商集成。 +- 新增了沙箱记忆支持,使未来的运行能够复用之前运行中的经验,并支持渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆示例。 +- 新增了更全面的工作区和恢复模型,包括本地与合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 +- 在 `examples/sandbox/` 下新增了大量沙箱代码示例和教程,涵盖使用技能、任务转移和记忆的编码任务、特定提供商的设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 +- 扩展了核心运行时和追踪技术栈,新增了感知沙箱的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出遮盖。 ### 0.13.0 -此次次版本发布**没有**引入破坏性变更,但包含一项值得注意的 Realtime 默认设置更新,以及新的MCP能力和运行时稳定性修复。 +此此次版本发布**不**包含破坏性变更,但包括一项重要的 Realtime 默认值更新,以及新的 MCP 能力和运行时稳定性修复。 亮点: -- 默认 WebSocket Realtime 模型现在是 `gpt-realtime-1.5`,因此新的 Realtime智能体设置无需额外配置即可使用较新的模型。 -- `MCPServer` 现在公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在公开 `session_id`,因此可流式 HTTP 会话可在重新连接后或无状态工作进程之间恢复。 -- Chat Completions集成现在可以通过 `should_replay_reasoning_content` 选择启用推理内容重放,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理及工具调用连续性。 -- 修复了多项运行时和会话边界情况,包括 `SQLAlchemySession` 中并发首次写入、移除推理内容后压缩请求包含孤立的助手消息 ID、`remove_all_tools()` 遗留MCP/推理项,以及工具调用批处理执行器中的竞态问题。 +- 默认 WebSocket Realtime 模型现为 `gpt-realtime-1.5`,因此新的 Realtime 智能体配置无需额外设置即可使用较新的模型。 +- `MCPServer` 现在会公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在会公开 `session_id`,因此使用 MCP Streamable HTTP 传输的会话可以在重新连接或无状态工作进程之间恢复。 +- Chat Completions集成现在可以通过 `should_replay_reasoning_content` 选择重新发送现有推理内容,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理和工具调用连续性。 +- 修复了多个运行时和会话边缘情况,包括 `SQLAlchemySession` 中的并发首次写入、移除推理内容后存在孤立助手消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项目,以及 `FunctionTool` 实例的批处理执行器中的竞态条件。 ### 0.12.0 -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 +此此次版本发布**不**包含破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 ### 0.11.0 -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 +此此次版本发布**不**包含破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 ### 0.10.0 -此次次版本发布**没有**引入破坏性变更,但为 OpenAI Responses用户新增了一个重要功能领域:Responses API 的 WebSocket 传输支持。 +此此次版本发布**不**包含破坏性变更,但为OpenAI Responses用户引入了一个重要的新功能领域:Responses API 的 WebSocket 传输支持。 亮点: -- 新增 OpenAI Responses模型的 WebSocket 传输支持(需选择启用;HTTP 仍是默认传输方式)。 -- 新增 `responses_websocket_session()` 辅助函数/`ResponsesWebSocketSession`,用于在多轮运行之间复用支持 WebSocket 的共享提供商和 `RunConfig`。 -- 新增 WebSocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 +- 为OpenAI Responses模型新增了 WebSocket 传输支持(需选择启用;HTTP 仍是默认传输方式)。 +- 新增了 `responses_websocket_session()` 辅助程序/`ResponsesWebSocketSession`,用于在多轮运行中复用共享的支持 WebSocket 的提供商和 `RunConfig`。 +- 新增了一个 WebSocket 流式传输示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 ### 0.9.0 -在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前结束生命周期(EOL)。请升级到较新的运行时版本。 +在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前终止支持。请升级到较新的运行时版本。 -此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 缩窄为 `FunctionTool`。此变更通常不会导致破坏性问题,但如果代码依赖更宽泛的联合类型,则可能需要进行相应调整。 +此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会引发破坏性问题,但如果您的代码依赖较宽泛的联合类型,可能需要进行一些调整。 ### 0.8.0 -在此版本中,两项运行时行为变更可能需要执行迁移: +在此版本中,有两项运行时行为变更可能需要迁移: -- 封装**同步** Python 可调用对象的工具调用现在通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中显式指定线程亲和性。 -- 本地MCP工具的失败处理现在可配置,并且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务级 `failure_error_function` 值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地MCP服务上设置 `failure_error_function=None`。 +- 包装**同步** Python 可调用对象的 `FunctionTool` 实例现在会通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果您的工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中明确处理线程亲和性。 +- 本地 MCP 工具失败处理现在可以配置,默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果您依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务器级别的 `failure_error_function` 值会覆盖智能体级别的设置,因此请在每个具有显式处理程序的本地 MCP 服务器上设置 `failure_error_function=None`。 ### 0.7.0 -在此版本中,有几项行为变更可能会影响现有应用: +在此版本中,有几项可能影响现有应用程序的行为变更: -- 嵌套任务转移历史现在需要**选择启用**(默认禁用)。如果依赖 v0.6.x 默认的嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 -- `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已从 SDK 默认值所配置的 `"low"` 更改为 `"none"`。如果提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置。 +- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果您依赖 v0.6.x 中默认的嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 +- `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(之前的默认值为 SDK 默认设置所配置的 `"low"`)。如果您的提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置它。 ### 0.6.0 -在此版本中,默认任务转移历史现在会封装为一条助手消息,而不再公开原始用户/助手轮次,从而为下游智能体提供简洁、可预测的回顾 -- 现有的单消息任务转移对话记录现在默认会在 `` 块之前以“For context, here is the conversation so far between the user and the previous agent:”开头,从而为下游智能体提供带有清晰标签的回顾 +在此版本中,默认任务转移历史记录现在会打包为一条助手消息,而不再将用户和助手轮次作为单独消息传递,从而为下游智能体提供简洁且可预测的回顾 +- 现有的单消息任务转移记录现在默认以确切的字面文本 `For context, here is the conversation so far between the user and the previous agent:` 开头,后面紧接 `` 块,从而为下游智能体提供带有清晰标签的回顾 ### 0.5.0 -此版本未引入任何可见的破坏性变更,但新增了功能,并对内部实现进行了几项重要更新: +此版本未引入任何可见的破坏性变更,但包含新功能以及一些重要的底层更新: -- 新增 `RealtimeRunner` 对处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持 -- 大幅调整了 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 +- `RealtimeRunner` 新增了处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 +- 大幅修改了 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 ### 0.4.0 @@ -188,12 +188,12 @@ result = Runner.run_sync( ### 0.3.0 -在此版本中,Realtime API支持迁移至 gpt-realtime 模型及其 API 接口(正式发布版本)。 +在此版本中,Realtime API支持迁移到 gpt-realtime 模型及其 API 接口(正式发布版本)。 ### 0.2.0 -在此版本中,少数原本将 `Agent` 作为参数的位置现在改为使用 `AgentBase`。例如,MCP服务中的 `list_tools()` 调用。这纯粹是类型层面的变更,实际仍会收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase` 以修复类型错误。 +在此版本中,少数之前接受 `Agent` 作为参数的位置现在改为接受 `AgentBase`。例如,这适用于 MCP 服务器中的 `list_tools()` 方法签名。这只是类型层面的变更,您仍将收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase` 来修复类型错误。 ### 0.1.0 -在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。任何继承 `MCPServer` 的类都需要添加这些参数。 \ No newline at end of file +在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。您需要将这些参数添加到 `MCPServer` 子类中每个被重写的 `MCPServer.list_tools()` 方法。 \ No newline at end of file diff --git a/docs/zh/results.md b/docs/zh/results.md index 5be8048b37..f7a8ac739e 100644 --- a/docs/zh/results.md +++ b/docs/zh/results.md @@ -6,86 +6,86 @@ search: 调用 `Runner.run` 方法时,你会收到以下两种结果类型之一: -- [`RunResult`][agents.result.RunResult],来自 `Runner.run(...)` 或 `Runner.run_sync(...)` -- [`RunResultStreaming`][agents.result.RunResultStreaming],来自 `Runner.run_streamed(...)` +- 从 `Runner.run(...)` 或 `Runner.run_sync(...)` 获得的 [`RunResult`][agents.result.RunResult] +- 从 `Runner.run_streamed(...)` 获得的 [`RunResultStreaming`][agents.result.RunResultStreaming] -两者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者提供共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 +两者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者公开了共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 -`RunResultStreaming` 还添加了流式传输专用控制功能,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 +`RunResultStreaming` 增加了流式传输专用控制功能,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 -## 结果接口的选择 +## 合适结果接口的选择 -大多数应用只需要少数几个结果属性或辅助方法: +大多数应用只需要少量结果属性或辅助方法: -| 如果需要…… | 使用 | +| 需求 | 使用 | | --- | --- | | 向用户显示最终答案 | `final_output` | -| 包含完整本地对话记录、可用于重放的下一轮输入列表 | `to_input_list()` | -| 包含智能体、工具、任务转移和审批元数据的丰富运行项 | `new_items` | -| 通常应处理下一轮用户输入的智能体 | `last_agent` | -| 使用 `previous_response_id` 串联 OpenAI Responses API | `last_response_id` | +| 包含完整本地对话记录、可直接用于重放的下一轮输入列表 | `to_input_list()` | +| 包含智能体、工具、任务转移和审批元数据的丰富运行条目 | `new_items` | +| 通常应处理下一轮用户交互的智能体 | `last_agent` | +| 使用 `previous_response_id` 进行 OpenAI Responses API 链式调用 | `last_response_id` | | 待处理的审批和可恢复快照 | `interruptions` 和 `to_state()` | | 当前嵌套 `Agent.as_tool()` 调用的元数据 | `agent_tool_invocation` | | 原始模型调用或安全防护措施诊断信息 | `raw_responses` 和安全防护措施结果数组 | ## 最终输出 -[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后运行的智能体所生成的最终输出。它可能是: +[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后一个运行的智能体所生成的最终输出。其类型可能是: -- `str`,如果最后一个智能体未定义 `output_type` -- `last_agent.output_type` 类型的对象,如果最后一个智能体定义了输出类型 -- `None`,如果运行在生成最终输出之前停止,例如因审批中断而暂停 +- 如果最后一个智能体未定义 `output_type`,则为 `str` +- 如果最后一个智能体定义了输出类型,则为 `last_agent.output_type` 类型的对象 +- 如果运行在生成最终输出前停止,则为 `None`,例如运行因审批中断而暂停 !!! note - `final_output` 的类型标注为 `Any`。任务转移可能会改变最终完成运行的智能体,因此 SDK 无法静态确定所有可能的输出类型。 + `final_output` 的类型为 `Any`。任务转移可能会改变最终完成运行的智能体,因此 SDK 无法静态确定所有可能的输出类型。 -在流式传输模式下,`final_output` 会一直保持为 `None`,直到流处理完成。有关逐事件处理流程,请参阅[流式传输](streaming.md)。 +在流式传输模式下,`final_output` 会保持为 `None`,直到流处理完毕。有关逐事件处理流程,请参阅[流式传输](streaming.md)。 -## 输入、下一轮历史记录和新项目 +## 输入、下一轮历史记录与新条目 -这些接口分别回答不同的问题: +这些接口分别用于回答不同的问题: -| 属性或辅助方法 | 包含的内容 | 最适合 | +| 属性或辅助方法 | 包含的内容 | 最适合的用途 | | --- | --- | --- | | [`input`][agents.result.RunResultBase.input] | 此运行片段的基础输入。如果任务转移输入过滤器重写了历史记录,此属性会反映运行继续执行时所使用的过滤后输入。 | 审计此运行实际使用的输入 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 运行的输入项视图。默认的 `mode="preserve_all"` 会保留从 `new_items` 转换而来的历史记录,但不会再次追加已被移入 SDK 默认嵌套任务转移历史记录的同一会话项;当任务转移过滤重写模型历史记录时,`mode="normalized"` 会优先使用规范化的延续输入。 | 手动聊天循环、由客户端管理的对话状态和普通项目历史记录检查 | -| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 包装对象。 | 日志、UI、审计和调试 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供方级别的诊断或原始响应检查 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 运行的输入条目视图。默认的 `mode="preserve_all"` 会保留来自 `new_items` 的转换后历史记录,但不会再次追加已经移入 SDK 默认嵌套任务转移历史记录中的同一个会话条目实例;当任务转移过滤重写模型历史记录时,`mode="normalized"` 优先使用规范的续接输入。 | 手动聊天循环、由客户端管理的对话状态以及纯条目历史记录检查 | +| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 封装对象。 | 日志、UI、审计和调试 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供商级诊断或原始响应检查 | -实际使用时: +在实践中: -- 当你需要运行的普通输入项视图时,使用 `to_input_list()`。 -- 当你需要在任务转移过滤或嵌套任务转移历史记录重写后,将规范化本地输入用于下一次 `Runner.run(..., input=...)` 调用时,使用 `to_input_list(mode="normalized")`。 -- 当你希望 SDK 自动加载和保存历史记录时,使用 [`session=...`](sessions/index.md)。 -- 如果你正在使用通过 `conversation_id` 或 `previous_response_id` 实现的 OpenAI 服务端托管状态,通常只需传入新的用户输入并复用已存储的 ID,而不必重新发送 `to_input_list()`。 -- 当你需要用于日志、UI 或审计的完整转换后历史记录时,使用默认的 `to_input_list()` 模式或 `new_items`。 +- 当你需要运行的纯输入条目视图时,使用 `to_input_list()`。 +- 在任务转移过滤或嵌套任务转移历史记录重写后,当你需要用于下一次 `Runner.run(..., input=...)` 调用的规范本地输入时,使用 `to_input_list(mode="normalized")`。 +- 当你希望 SDK 为你加载和保存历史记录时,使用 [`session=...`](sessions/index.md)。 +- 如果你正在通过 `conversation_id` 或 `previous_response_id` 使用由 OpenAI 服务器管理的状态,通常只需传入新的用户输入并复用存储的 ID,而无需重新发送 `to_input_list()`。 +- 当你需要用于日志、UI 或审计的完整转换后历史记录时,使用默认的 `to_input_list()` 模式或 `new_items`。 -当 SDK 默认的嵌套任务转移历史记录逐字保留某个消息项时,Sessions、`RunState` 和 `to_input_list()` 会追踪该项由其拥有的确切实例,而不是按内容进行去重。分别出现的相同消息仍会保持独立;只有已被拥有的实例不会被再次追加。 +当 SDK 默认的嵌套任务转移历史记录逐字保留消息条目时,会话、`RunState` 和 `to_input_list()` 会追踪实际归属的条目实例,而不是按内容去重。分别出现的相同消息仍会保持独立;系统只会避免再次追加已归属的条目实例。 -与 JavaScript SDK 不同,Python 不会为仅包含模型形态增量的内容提供单独的 `output` 属性。当你需要 SDK 元数据时,请使用 `new_items`;当你需要原始模型载荷时,请检查 `raw_responses`。 +与 JavaScript SDK 不同,Python 不会公开单独的 `output` 属性来仅包含运行期间新生成的模型格式条目。需要 SDK 元数据时,请使用 `new_items`;需要原始模型载荷时,请检查 `raw_responses`。 -计算机工具重放遵循原始 Responses 载荷结构。预览模型的 `computer_call` 项会保留单个 `action`,而 `gpt-5.5` 的计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的结构,因此手动重放、暂停/恢复流程和存储的对话记录都能同时适用于预览版和正式版(GA)的计算机工具调用。本地执行结果仍会作为 `computer_call_output` 项出现在 `new_items` 中。 +将计算机工具条目作为对话输入重新提交时,会使用原始 Responses 载荷格式。预览模型的 `computer_call` 条目会保留单个 `action`,而 `gpt-5.5` 计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的格式,因此,无论是预览版还是正式发布版的计算机工具调用,手动将这些条目重新提交为对话输入、执行暂停/恢复流程以及使用已存储的对话记录都可以继续正常工作。本地执行结果仍会在 `new_items` 中显示为 `computer_call_output` 条目。 -### 新项目 +### 新条目 -[`new_items`][agents.result.RunResultBase.new_items] 提供运行期间所发生事件的最丰富视图。常见的项目类型包括: +[`new_items`][agents.result.RunResultBase.new_items] 提供运行期间所发生事件的最丰富视图。常见条目类型包括: -- 用于助手消息的 [`MessageOutputItem`][agents.items.MessageOutputItem] -- 用于推理项目的 [`ReasoningItem`][agents.items.ReasoningItem] -- 用于 Responses 工具搜索请求和已加载工具搜索结果的 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 和 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- 用于工具调用及其结果的 [`ToolCallItem`][agents.items.ToolCallItem] 和 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 用于因等待审批而暂停的工具调用的 [`ToolApprovalItem`][agents.items.ToolApprovalItem] -- 用于托管 MCP 审批和工具目录的 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem] -- 用于任务转移请求和已完成转移的 [`HandoffCallItem`][agents.items.HandoffCallItem] 和 [`HandoffOutputItem`][agents.items.HandoffOutputItem] +- 用于助手消息的 [`MessageOutputItem`][agents.items.MessageOutputItem] +- 用于推理条目的 [`ReasoningItem`][agents.items.ReasoningItem] +- 用于 Responses 工具搜索请求和已加载工具搜索结果的 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 和 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- 用于工具调用及其结果的 [`ToolCallItem`][agents.items.ToolCallItem] 和 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- 用于因等待审批而暂停的工具调用的 [`ToolApprovalItem`][agents.items.ToolApprovalItem] +- 用于托管 MCP 审批和工具目录的 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem] +- 用于任务转移请求和已完成转移的 [`HandoffCallItem`][agents.items.HandoffCallItem] 和 [`HandoffOutputItem`][agents.items.HandoffOutputItem] -只要你需要智能体关联信息、工具输出、任务转移边界或审批边界,就应选择 `new_items` 而不是 `to_input_list()`。 +当你需要智能体关联信息、工具输出、任务转移边界或审批边界时,应选择 `new_items`,而不是 `to_input_list()`。 -使用托管工具搜索时,检查 `ToolSearchCallItem.raw_item` 可查看模型发出的搜索请求,检查 `ToolSearchOutputItem.raw_item` 可查看该轮加载了哪些命名空间、函数或托管 MCP 服务。 +使用托管工具搜索时,检查 `ToolSearchCallItem.raw_item` 可查看模型发出的搜索请求,检查 `ToolSearchOutputItem.raw_item` 可查看该轮加载了哪些命名空间、函数或托管 MCP 服务器。 -使用程序化工具调用时,生成的 `program` 是一个 `ToolCallItem`,归该程序所有的普通子工具调用也会作为 `ToolCallItem` 项,而匹配的 `program_output` 则是一个 `ToolCallOutputItem`。程序拥有的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 项属于例外:它们会分别成为 `MCPApprovalRequestItem` 和 `MCPListToolsItem` 项。 +使用程序化工具调用时,生成的 `program` 是 `ToolCallItem`,归属于该程序的普通子工具调用也是 `ToolCallItem` 条目,而对应的 `program_output` 是 `ToolCallOutputItem`。归属于程序的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 条目属于例外:它们会成为 `MCPApprovalRequestItem` 和 `MCPListToolsItem` 条目。 -原始项目可以是带类型的 Responses 对象,也可以是映射。特别是,程序拥有的 shell 和 apply-patch 调用会使用映射。请使用对映射安全的检查模式: +原始条目可以是带类型的 Responses 对象或映射。特别是,归属于程序的 shell 和补丁应用调用会使用映射。请使用可安全处理映射的检查模式: ```python from collections.abc import Mapping @@ -107,21 +107,21 @@ caller_id = ( ) ``` -对于程序拥有的子调用,`caller` 的类型为 `program`,而 `caller_id` 用于标识父程序调用。 +对于归属于程序的子调用,`caller` 的 `type` 字段为 `program`,而 `caller_id` 用于标识父程序调用。 -## 对话的继续与恢复 +## 对话的继续或恢复 ### 下一轮智能体 -[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。在任务转移后,它通常是下一轮用户输入中最适合复用的智能体。 +[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后一个运行的智能体。在任务转移后,它通常是下一轮用户交互中最适合复用的智能体。 -在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行推进而更新,因此你可以在流结束之前观察任务转移。 +在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行推进而更新,因此你可以在流结束前观察任务转移。 ### 中断与运行状态 -如果某个工具需要审批,待处理的审批会通过 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 提供。其中可能包括直接工具发起的审批、任务转移后访问的工具发起的审批,或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行发起的审批。 +如果工具需要审批,待处理的审批会公开在 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中。其中可能包括由直接调用的工具、任务转移后调用的工具或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行触发的审批。 -调用 [`to_state()`][agents.result.RunResult.to_state] 可获取可恢复的 [`RunState`][agents.run_state.RunState],审批或拒绝待处理项目,然后使用 `Runner.run(...)` 或 `Runner.run_streamed(...)` 恢复运行。 +调用 [`to_state()`][agents.result.RunResult.to_state] 以捕获可恢复的 [`RunState`][agents.run_state.RunState],批准或拒绝待处理条目,然后使用 `Runner.run(...)` 或 `Runner.run_streamed(...)` 恢复运行。 ```python from agents import Agent, Runner @@ -136,59 +136,59 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -对于流式运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions`,并从 `result.to_state()` 恢复。有关完整的审批流程,请参阅[人在回路](human_in_the_loop.md)。 +对于流式传输运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions` 并从 `result.to_state()` 恢复。有关完整审批流程,请参阅[人工介入](human_in_the_loop.md)。 -### 服务端管理的延续 +### 服务器管理的续接 -[`last_response_id`][agents.result.RunResultBase.last_response_id] 是此次运行中最新模型响应的 ID。如果希望继续串联 OpenAI Responses API,请在下一轮将其作为 `previous_response_id` 传回。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] 是此次运行中最新的模型响应 ID。若要继续 OpenAI Responses API 链,请在下一轮将其作为 `previous_response_id` 传回。 -如果你已经通过 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 +如果你已经使用 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 ## 智能体工具元数据 -当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会提供关于外层工具调用的不可变元数据: +当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会公开有关外层 `Agent.as_tool()` 调用的不可变元数据: -- `tool_name` -- `tool_call_id` -- `tool_arguments` +- `tool_name` +- `tool_call_id` +- `tool_arguments` 对于普通的顶层运行,`agent_tool_invocation` 为 `None`。 -这在 `custom_output_extractor` 中尤其有用,因为在对嵌套结果进行后处理时,你可能需要外层工具名称、调用 ID 或原始参数。有关相关的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 +这在 `custom_output_extractor` 内尤其有用,因为在对嵌套结果进行后处理时,你可能需要外层 `Agent.as_tool()` 调用的工具名称、调用 ID 或原始参数。有关相关的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 -如果还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 为嵌套工具输入进行通用序列化的字段,而 `agent_tool_invocation` 是当前嵌套调用的实时结果访问接口。 +如果还需要该嵌套运行解析后的结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 用于通用序列化嵌套工具输入的字段,而 `agent_tool_invocation` 会直接在结果上公开当前嵌套调用的元数据。 ## 流式传输生命周期与诊断 -[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同的结果接口,同时添加了流式传输专用控制功能: +[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同的结果接口,同时增加了流式传输专用控制功能: -- [`stream_events()`][agents.result.RunResultStreaming.stream_events],用于消费语义流事件 -- [`current_agent`][agents.result.RunResultStreaming.current_agent],用于在运行过程中追踪活动智能体 -- [`is_complete`][agents.result.RunResultStreaming.is_complete],用于查看流式运行是否已完全结束 -- [`cancel(...)`][agents.result.RunResultStreaming.cancel],用于立即停止运行或在当前轮结束后停止运行 +- 使用 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 消费语义流事件 +- 使用 [`current_agent`][agents.result.RunResultStreaming.current_agent] 追踪运行期间的活动智能体 +- 使用 [`is_complete`][agents.result.RunResultStreaming.is_complete] 查看流式传输运行是否已完全结束 +- 使用 [`cancel(...)`][agents.result.RunResultStreaming.cancel] 立即停止运行或在当前轮结束后停止运行 -持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式运行才算完成;在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在处理中。 +持续消费 `stream_events()`,直到异步迭代器结束。该迭代器结束前,流式传输运行不算完成;在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在处理。 -如果调用 `cancel()`,请继续消费 `stream_events()`,以确保取消和清理操作能够正确完成。 +如果调用 `cancel()`,请继续消费 `stream_events()`,以便正确完成取消和清理。 -Python 不提供单独的流式 `completed` promise 或 `error` 属性。流式传输的终止性故障会通过 `stream_events()` 抛出,而 `is_complete` 则反映运行是否已到达终止状态。 +Python 不会公开单独的流式 `completed` Promise 或 `error` 属性。终止运行的流式传输故障会由 `stream_events()` 抛出,而 `is_complete` 会反映运行是否已到达终止状态。 ### 原始响应 -[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能会生成多个响应,例如跨任务转移或重复的模型/工具/模型循环。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能产生多个响应,例如在任务转移或重复的模型/工具/模型循环中。 [`last_response_id`][agents.result.RunResultBase.last_response_id] 只是 `raw_responses` 中最后一个条目的 ID。 ### 安全防护措施结果 -智能体级安全防护措施通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 提供。 +智能体级安全防护措施通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 公开。 -工具安全防护措施则通过 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 和 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] 单独提供。 +工具安全防护措施则通过 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 和 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] 单独公开。 -这些数组会在整个运行期间持续累积,因此可用于记录决策、存储额外的安全防护措施元数据,或调试运行被阻止的原因。 +这些数组会在整个运行期间持续累积,因此适合用于记录决策、存储额外的安全防护措施元数据,或调试运行被阻止的原因。 ### 上下文与用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 提供应用上下文以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 会公开你的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 -用量记录在 `context_wrapper.usage` 中。对于流式运行,在处理完流的最后几个数据块之前,用量总计可能会有所滞后。有关完整的包装对象结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file +用量记录在 `context_wrapper.usage` 中。对于流式传输运行,用量总计可能要等到流的最终数据块处理完毕后才会更新。有关完整的封装结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index b7a13cfff2..802f699ea2 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -7,7 +7,7 @@ search: 你可以通过 [`Runner`][agents.run.Runner] 类运行智能体。你有 3 种选择: 1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回 [`RunResult`][agents.result.RunResult]。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其内部只是运行 `.run()`。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其底层仅运行 `.run()`。 3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 ```python @@ -23,24 +23,24 @@ async def main(): # Infinite loop's dance ``` -请在[结果指南](results.md)中了解更多信息。 +更多信息请参阅[结果指南](results.md)。 ## Runner 生命周期与配置 ### 智能体循环 -使用 `Runner` 中的运行方法时,你需要传入一个起始智能体和输入。输入可以是: +调用上述三个 `Runner` 方法中的任何一个时,需要传入起始智能体和输入。输入可以是: - 字符串(视为用户消息), -- OpenAI Responses API 格式的输入项列表,或 +- OpenAI Responses API格式的输入项列表,或 - 恢复中断的运行时使用的 [`RunState`][agents.run_state.RunState]。 -随后,运行器会执行一个循环: +然后,runner 会运行一个循环: 1. 我们使用当前输入为当前智能体调用 LLM。 2. LLM 生成输出。 - 1. 如果 LLM 返回 `final_output`,循环结束并返回结果。 - 2. 如果 LLM 执行任务转移,我们会更新当前智能体和输入,然后重新运行循环。 + 1. 如果 runner 将 LLM 的输出归类为最终输出,则循环结束,并返回结果。 + 2. 如果 LLM 请求任务转移,我们会更新当前智能体和输入,并重新运行循环。 3. 如果 LLM 生成工具调用,我们会运行这些工具调用、追加结果,然后重新运行循环。 3. 如果超过传入的 `max_turns`,我们会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 @@ -50,17 +50,17 @@ async def main(): ### 流式传输 -流式传输允许你在 LLM 运行时额外接收流式传输事件。流式传输结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含此次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式传输事件。请在[流式传输指南](streaming.md)中了解更多信息。 +流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含本次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。更多信息请参阅[流式传输指南](streaming.md)。 #### Responses WebSocket 传输(可选辅助工具) -如果启用 OpenAI Responses websocket 传输,你仍然可以继续使用常规的 `Runner` API。建议使用 websocket 会话辅助工具来复用连接,但这不是必需的。 +如果启用 OpenAI Responses websocket 传输,你仍可继续使用常规的 `Runner` API。建议使用 websocket 会话辅助工具来复用连接,但这不是必需的。 -这是基于 websocket 传输的 Responses API,而不是 [Realtime API](realtime/guide.md)。 +这是通过 websocket 传输使用的 Responses API,而不是 [Realtime API](realtime/guide.md)。 -有关传输选择规则以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 +有关传输选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 -##### 模式 1:不使用会话辅助工具(可行) +##### 模式 1:无会话辅助工具(可用) 如果你只需要 websocket 传输,而不需要 SDK 为你管理共享提供商或会话,请使用此模式。 @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -此模式适用于单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个 `RunConfig` / 提供商实例,否则每次运行都可能重新连接。 +此模式适合单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个 `RunConfig` / 提供商实例,否则每次运行都可能重新连接。 ##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) -如果希望在多次运行之间共享支持 websocket 的提供商和 `RunConfig`(包括继承同一 `run_config` 的嵌套智能体工具调用),请使用 [`responses_websocket_session()`][agents.responses_websocket_session]。 +如果希望在多次运行中共享支持 websocket 的提供商和 `RunConfig`,请使用 [`responses_websocket_session()`][agents.responses_websocket_session];这也包括继承同一个 `run_config` 的嵌套“智能体作为工具”调用。 ```python import asyncio @@ -119,59 +119,59 @@ async def main(): asyncio.run(main()) ``` -请在退出上下文之前完成流式传输结果的消费。如果 websocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 +请在上下文退出前完成流式结果的消费。如果在 websocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 -服务会在每个 websocket 连接上一次处理一个响应,并将每个连接的时长限制为 60 分钟。该辅助工具会复用连接,但不会解除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文启动一条新链,或根据本地管理的会话状态重建该链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 +服务在每个 websocket 连接上一次处理一个响应,并将连接时长限制为 60 分钟。该辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文启动新链,或根据本地管理的会话状态重建该链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 -如果长时间推理轮次触发 websocket keepalive 超时,请增大 `ping_timeout`,或设置 `ping_timeout=None` 以禁用心跳超时。对于可靠性比 websocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 +如果长时间推理轮次触发 websocket keepalive 超时,请增大 `ping_timeout`,或将 `ping_timeout=None` 设为禁用心跳超时。对于可靠性比 websocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 ### 运行配置 `run_config` 参数可用于配置智能体运行的一些全局设置: -#### 常用运行配置目录 +#### 常见运行配置类别 使用 `RunConfig` 可覆盖单次运行的行为,而无需更改每个智能体的定义。 -##### 模型、提供商和会话默认值 +##### 模型、提供商与会话默认设置 -- [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个 Agent 所设置的 `model`。 -- [`model_provider`][agents.run.RunConfig.model_provider]:用于按名称查找模型的模型提供商,默认为 OpenAI。 -- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,你可以设置全局 `temperature` 或 `top_p`。 -- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认值(例如 `SessionSettings(limit=...)`)。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每轮开始前将新用户输入与会话历史记录合并的方式。该回调可以是同步或异步的。 +- [`model`][agents.run.RunConfig.model]:可设置全局使用的 LLM 模型,而不受各个智能体所设 `model` 的影响。 +- [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 +- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,可以设置全局 `temperature` 或 `top_p`。 +- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认设置(例如 `SessionSettings(limit=...)`)。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每次 `Runner` 运行前如何将新的用户输入与会话历史记录合并。该回调可以是同步或异步的。 -##### 安全防护措施、任务转移和模型输入塑形 +##### 安全防护措施、任务转移与模型输入调整 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要在所有运行中包含的输入或输出安全防护措施列表。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未设置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详情,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 的文档。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选启用的测试版功能,在调用下一个智能体之前,将可总结的历史记录压缩为有序的助手摘要片段,同时在原始位置无损保留消息项。在我们完善嵌套任务转移期间,此功能默认禁用;设置为 `True` 可启用,保留为 `False` 则会直接传递原始记录。当 SDK 默认的嵌套历史记录已包含某条消息时,Sessions、`RunState` 和 `RunResult.to_input_list()` 会避免再次追加完全相同的消息实例,同时仍会保留彼此独立但内容相同的消息。如果你未传入 `RunConfig`,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,而任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。各项任务转移可通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:一个可选的可调用对象,在你选择启用 `nest_handoff_history` 时接收规范化记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的准确输入项列表,从而替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:用于在调用模型前立即编辑已完全准备好的模型输入(instructions 和输入项)的钩子,例如裁剪历史记录或注入系统提示词。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制运行器将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:应用于所有任务转移的全局输入过滤器,前提是该任务转移尚未设置过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详细信息,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 中的文档。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选择启用的 Beta 功能。在调用下一个智能体之前,它会将可摘要的历史记录压缩为按顺序排列的助手摘要片段,同时将无损消息项保留在原始位置。在我们完善嵌套任务转移期间,此功能默认禁用;将其设为 `True` 可启用,保持 `False` 则会原样传递原始记录。当 SDK 默认的嵌套历史记录已包含某条消息的确切实例时,Sessions、`RunState` 和 `RunResult.to_input_list()` 会避免将其重复追加两次,同时仍保留彼此独立但内容相同的消息。如果未传入 [Runner 方法][agents.run.Runner]所需的 `RunConfig`,所有这些方法都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。各个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:一个可选的可调用对象;每当你选择启用 `nest_handoff_history` 时,它都会接收规范化的对话记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的准确输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如用于裁剪历史记录或注入系统提示词。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 ##### 追踪与可观测性 -- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许为整个运行禁用[追踪](tracing.md)。 -- [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],以覆盖追踪导出设置,例如每次运行使用的追踪 API 密钥。 +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许你为整个运行禁用[追踪](tracing.md)。 +- [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],以覆盖追踪导出设置,例如每次运行的追踪 API 密钥。 - [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在的敏感数据,例如 LLM 和工具调用的输入/输出。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行的追踪。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:为运行设置追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行的追踪。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 ##### 工具执行、审批与工具错误行为 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 端的执行行为,例如限制同时运行的工具调用数量。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置运行器如何处理模型发出的、无法解析的工具调用。默认行为是引发 `ModelBehaviorError`;也可以选择改为返回模型可见的错误输出。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置运行器如何处理发生冲突的无命名空间工具调用名称和任务转移名称。默认值 `"warn"` 会记录一条可操作的警告,并且只公开当前分派的胜出项;`"error"` 会在调用模型之前引发 `UserError`。对具有命名空间和延迟加载工具的严格验证保持不变。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒和选择启用的工具未找到输出。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置 SDK 端执行本地工具调用的行为,例如限制同时运行的本地函数工具调用数量。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置当模型生成的函数工具调用名称与当前智能体可用的任何函数工具都不匹配时,runner 如何处理。默认行为是引发 `ModelBehaviorError`;可以选择改为返回模型可见的错误输出。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置当未设置命名空间的函数工具名称与任务转移名称发生冲突时,runner 如何处理。默认值 `"warn"` 会记录一条可操作的警告,并且仅公开当前的分派胜出项;`"error"` 会在调用模型前引发 `UserError`。对具有命名空间和延迟加载工具的严格验证保持不变。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝和选择启用后的工具未找到输出。 -嵌套任务转移是一项可选启用的测试版功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序记录压缩,或设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段放置在无损消息项周围,而不是将整个记录合并为一条消息。如果希望保留原始记录(默认行为),请不要设置此标志,或提供按需准确转发对话的 `handoff_input_filter`(或 `handoff_history_mapper`)。如果希望更改生成的摘要片段所使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 恢复默认值)。 +嵌套任务转移以可选择启用的 Beta 功能提供。传入 `RunConfig(nest_handoff_history=True)` 可启用有序对话记录压缩,也可以设置 `handoff(..., nest_handoff_history=True)`,为特定任务转移启用该功能。内置映射器会将生成的助手摘要片段放在无损消息项周围,而不是将整个对话记录折叠成一条消息。如果希望保留原始对话记录(默认行为),请勿设置该标志,或提供按需原样转发对话的 `handoff_input_filter`(或 `handoff_history_mapper`)。如需更改生成摘要片段中使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 可恢复默认设置)。 #### 运行配置详情 ##### `tool_execution` -如果希望配置本地工具调用在 SDK 端的行为,例如限制一次运行中本地工具调用的并发数量,请使用 `tool_execution`。 +如果希望配置 SDK 端对本地函数工具的行为,例如限制一次运行中的本地函数工具并发数,请使用 `tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中发出多个工具调用时,SDK 会启动所有已发出的本地工具调用。将其设置为整数值,可限制同时运行的本地工具调用数量。 +`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中生成多个函数工具调用时,SDK 会启动所有已生成的本地函数工具调用。设置整数值可限制这些本地函数工具调用同时运行的数量。 -这与提供商端的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地工具调用后,SDK 如何执行这些调用。 +这与提供商端的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中生成多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型生成本地函数工具调用后,SDK 如何执行这些调用。 -`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果工具调用需要审批,运行会先暂停,并且工具输入安全防护措施仅在审批后、紧接执行前运行。如果希望工具调用输入安全防护措施在发出待审批中断前运行,请将其设置为 `True`。通过此审批前检查的调用仍会在审批后再次运行相同的输入安全防护措施,因此执行前会重新验证时效性检查。 +`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果函数工具需要审批,运行会先暂停,并且工具输入安全防护措施仅在审批通过后、执行前立即运行。如果希望函数工具输入安全防护措施在发出待审批中断前运行,请将其设为 `True`。通过此审批前检查的调用在审批后仍会再次运行相同的输入安全防护措施,因此会在执行前重新验证时效性检查。 ##### `tool_not_found_behavior` -默认情况下,如果模型发出的工具调用与当前智能体可用的任何工具调用都不匹配,运行器会引发 `ModelBehaviorError`。 +默认情况下,如果模型生成的函数工具调用与当前智能体可用的任何函数工具都不匹配,runner 会引发 `ModelBehaviorError`。 -如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在该模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,以便模型选择可用工具,或在不使用该工具的情况下作答。 +如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,并再次运行模型,使模型能够选择可用工具,或在不使用该工具的情况下作答。 ```python from agents import Agent, RunConfig, Runner @@ -214,22 +214,22 @@ result = await Runner.run( ) ``` -此选项目前仅适用于无法解析的工具调用。其他无效工具有效负载仍沿用现有的错误处理行为。 +此选项目前仅适用于工具名称查找失败的函数工具调用。其他无效的工具载荷会继续使用其现有的错误处理行为。 ##### `tool_error_formatter` 使用 `tool_error_formatter` 可自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 -格式化器接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: +格式化器会接收包含以下内容的 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]: -- `kind`:错误目录,例如 `"approval_rejected"` 或 `"tool_not_found"`。 +- `kind`:错误类别,例如 `"approval_rejected"` 或 `"tool_not_found"`。 - `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"` 或 `"custom"`)。 - `tool_name`:工具名称。 - `call_id`:工具调用 ID。 - `default_message`:SDK 默认的模型可见消息。 - `run_context`:当前运行上下文包装器。 -返回字符串可替换该消息;返回 `None` 则使用 SDK 默认值。 +返回字符串可替换该消息,返回 `None` 则使用 SDK 默认值。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,56 +256,56 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` 控制运行器向后传递历史记录时,如何将推理项转换为下一轮模型输入(例如使用 `RunResult.to_input_list()` 或由会话支持的运行时)。 +当 runner 向前传递历史记录时(例如使用 `RunResult.to_input_list()` 或由会话支持的运行时),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 - `None` 或 `"preserve"`(默认):保留推理项 ID。 - `"omit"`:从生成的下一轮输入中移除推理项 ID。 -`"omit"` 主要用作一种可选启用的缓解措施,用于处理一类 Responses API 400 错误:发送的推理项包含 `id`,但缺少所需的后续项(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` 主要用作一类 Responses API 400 错误的可选择启用缓解措施:推理项携带 `id` 发送,但后面缺少必需的项(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -这种情况可能发生在多轮智能体运行中:SDK 根据先前输出构建后续输入(包括会话持久化、服务端管理的对话增量、流式传输/非流式传输的后续轮次以及恢复路径),并保留了推理项 ID,但提供商要求该 ID 必须与其对应的后续项保持配对。 +在多轮智能体运行中,如果 SDK 根据先前输出构建后续输入(包括会话持久化、服务器管理的会话增量、流式/非流式后续轮次以及恢复路径),并且保留了推理项 ID,但提供商要求该 ID 必须继续与其对应的后续项配对,就可能发生这种情况。 -设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量。 +设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量约束。 -作用范围说明: +适用范围说明: - 这只会更改 SDK 构建后续输入时生成或转发的推理项。 - 它不会重写用户提供的初始输入项。 - 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 -## 状态与对话管理 +## 状态与会话管理 -### 记忆策略选择 +### 内存策略选择 将状态带入下一轮通常有四种方式: -| 策略 | 状态存储位置 | 最适用场景 | 下一轮传入内容 | +| 策略 | 状态存储位置 | 最适合 | 下一轮传入的内容 | | --- | --- | --- | --- | | `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()` 返回的列表加上下一条用户消息 | -| `session` | 你的存储加上 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | -| `conversation_id` | OpenAI Conversations API | 希望在多个工作进程或服务之间共享的具名服务端对话 | 同一个 `conversation_id`,并且只传入新的用户轮次 | -| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务端管理续接 | `result.last_response_id`,并且只传入新的用户轮次 | +| `session` | 你的存储加 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | +| `conversation_id` | OpenAI Conversations API | 希望跨工作进程或服务共享的具名服务器端会话 | 同一个 `conversation_id`,且仅传入新的用户轮次 | +| `previous_response_id` | OpenAI Responses API | 无需创建会话资源的轻量级服务器管理延续 | `result.last_response_id`,且仅传入新的用户轮次 | -`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅在使用 OpenAI Responses API 时适用。在大多数应用中,每个对话应选择一种持久化策略。除非你有意协调这两个层级,否则混合使用客户端管理的历史记录与 OpenAI 管理的状态可能导致上下文重复。 +`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI管理,并且仅在使用 OpenAI Responses API时适用。在大多数应用中,每个会话应选择一种持久化策略。混用客户端管理的历史记录与 OpenAI管理的状态可能导致上下文重复,除非你有意协调这两个层级。 !!! note - 会话持久化不能在同一次运行中与服务端管理的对话设置 + 同一次运行中,会话持久化不能与服务器管理的会话设置 (`conversation_id`、`previous_response_id` 或 `auto_previous_response_id`) 结合使用。每次调用请选择一种方式。 -### 对话/聊天线程 +### 会话与聊天线程 -调用任何运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但这在聊天对话中只代表一个逻辑轮次。例如: +调用任何运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但它表示聊天会话中的单个逻辑轮次。例如: 1. 用户轮次:用户输入文本 -2. 运行器运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 +2. Runner 运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 -智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论采用哪种方式,用户之后都可能提出后续问题,此时你可以再次调用运行方法。 +智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的所有新项目,也可以只显示最终输出。无论采用哪种方式,用户随后都可能提出后续问题,此时可以再次调用运行方法。 -#### 手动对话管理 +#### 手动会话管理 -你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法手动管理对话历史记录,以获取下一轮的输入: +你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法获取下一轮的输入,从而手动管理会话历史记录: ```python from agents import Agent, Runner, trace @@ -327,9 +327,9 @@ async def main(): # California ``` -#### 使用会话自动管理对话 +#### 使用会话的自动会话管理 -若要采用更简单的方式,可以使用 [Sessions](sessions/index.md) 自动处理对话历史记录,而无需手动调用 `.to_input_list()`: +若要采用更简单的方法,可以使用 [Sessions](sessions/index.md) 自动处理会话历史记录,而无需手动调用 `.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -355,22 +355,22 @@ async def main(): Sessions 会自动: -- 在每次运行前检索对话历史记录 +- 在每次运行前检索会话历史记录 - 在每次运行后存储新消息 -- 为不同的会话 ID 维护独立的对话 +- 为不同的会话 ID 维护独立会话 -有关更多详情,请参阅 [Sessions 文档](sessions/index.md)。 +更多详细信息请参阅 [Sessions 文档](sessions/index.md)。 -#### 服务端管理的对话 +#### 服务器管理的会话 -你也可以让 OpenAI 对话状态功能在服务端管理对话状态,而不是使用 `to_input_list()` 或 `Sessions` 在本地处理。这让你无需手动重新发送所有历史消息,即可保留对话历史记录。使用以下任一服务端管理方式时,每次请求只需传入新轮次的输入并复用保存的 ID。有关更多详情,请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 +你也可以让 OpenAI会话状态功能在服务器端管理会话状态,而不是使用 `to_input_list()` 或 `Sessions` 在本地处理。这样无需手动重新发送所有历史消息,即可保留会话历史记录。对于下述任一服务器管理方式,每次请求仅传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅 [OpenAI会话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 -OpenAI 提供两种跨轮次追踪状态的方式: +OpenAI提供两种跨轮次追踪状态的方式: ##### 1. 使用 `conversation_id` -首先使用 OpenAI Conversations API 创建对话,然后在后续每次调用中复用其 ID: +首先使用 OpenAI Conversations API创建会话,然后在之后的每次调用中复用其 ID: ```python from agents import Agent, Runner @@ -393,7 +393,7 @@ async def main(): ##### 2. 使用 `previous_response_id` -另一种方式是**响应链式衔接**,其中每一轮都会显式链接到上一轮的响应 ID。 +另一种方式是**响应链式关联**,其中每一轮都会显式链接到上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -418,30 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,使恢复后的轮次继续使用同一个服务端管理的对话。 +如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,使恢复后的轮次继续在同一个服务器管理的会话中运行。 -`conversation_id` 和 `previous_response_id` 互斥。如果需要一个可跨系统共享的具名对话资源,请使用 `conversation_id`。如果希望使用最轻量的 Responses API 基本组件从一个轮次续接到下一轮,请使用 `previous_response_id`。 +`conversation_id` 和 `previous_response_id` 互斥。如果需要可跨系统共享的具名会话资源,请使用 `conversation_id`。如果需要从一轮延续到下一轮的最轻量 Responses API基本组件,请使用 `previous_response_id`。 !!! note - SDK 会自动以退避方式重试 `conversation_locked` 错误。在服务端管理的 - 对话运行中,它会在重试前回退内部对话追踪器输入,以便 - 清晰地重新发送同一批已准备好的项目。 + SDK 会自动通过退避机制重试 `conversation_locked` 错误。在服务器管理的 + 会话运行中,它会在重试前回退内部会话追踪器的输入,以便可以完整地重新发送 + 相同的已准备项目。 - 在基于本地会话的运行中(它不能与 `conversation_id`、 - `previous_response_id` 或 `auto_previous_response_id` 结合使用),SDK 还会尽最大努力 - 回滚最近持久化的输入项,以减少重试后产生重复的历史记录条目。 + 在基于本地会话的运行中(不能与 `conversation_id`、 + `previous_response_id` 或 `auto_previous_response_id` 结合使用),SDK 还会尽力 + 回滚最近持久化的输入项,以减少重试后重复的历史记录条目。 - 即使未配置 `ModelSettings.retry`,也会执行此兼容性重试。有关 - 更广泛的可选模型请求重试行为,请参阅[由 Runner 管理的重试](models/index.md#runner-managed-retries)。 + 即使未配置 `ModelSettings.retry`,也会执行此兼容性重试。有关更广泛、可选择启用的 + 模型请求重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 ## 钩子与自定义 ### 模型调用输入过滤器 -使用 `call_model_input_filter` 可在模型调用前编辑模型输入。该钩子接收当前智能体、上下文以及合并后的输入项(包括会话历史记录,如有),并返回新的 `ModelInputData`。 +使用 `call_model_input_filter` 可在调用模型前立即编辑模型输入。该钩子会接收当前智能体、上下文和合并后的输入项(包括存在时的会话历史记录),并返回新的 `ModelInputData`。 -返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填项,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 +返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段是必需的,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 ```python from agents import Agent, Runner, RunConfig @@ -460,19 +460,19 @@ result = Runner.run_sync( ) ``` -运行器会将已准备好的输入列表副本传给钩子,因此你可以对其进行裁剪、替换或重新排序,而不会就地修改调用方的原始列表。 +Runner 会将已准备输入列表的副本传给该钩子,因此你可以裁剪、替换或重新排序该列表,而不会原地修改调用方的原始列表。 -如果使用会话,`call_model_input_filter` 会在会话历史记录加载并与当前轮次合并后运行。如果希望自定义前面的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 +如果使用会话,`call_model_input_filter` 会在会话历史记录已加载并与当前轮次合并后运行。如果希望自定义更早的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 -如果通过 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 使用 OpenAI 服务端管理的对话状态,该钩子会针对下一次 Responses API 调用已准备好的有效负载运行。该有效负载可能已经只表示新轮次的增量,而不是对先前完整历史记录的重放。只有你返回的项目会被标记为已发送,以用于该服务端管理的续接。 +如果使用由 OpenAI服务器管理的会话状态,并设置了 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id`,该钩子会针对下一次 Responses API调用已准备的载荷运行。该载荷可能已仅表示新轮次的增量,而不是完整重放早期历史记录。只有你返回的项目才会被标记为已发送至该服务器管理的延续流程。 -通过 `run_config` 为每次运行设置该钩子,可用于遮盖敏感数据、裁剪过长的历史记录或注入额外的系统指导信息。 +通过 `run_config` 为每次运行设置该钩子,以遮盖敏感数据、裁剪过长的历史记录或注入额外的系统指导。 ## 错误与恢复 -### 错误处理器 +### 错误处理程序 -所有 `Runner` 入口点都接受 `error_handlers`,它是一个以错误类型为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以对应错误结束运行,请使用这些键。 +所有 `Runner` 入口点都接受 `error_handlers`,这是一个以错误类型为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些键。 ```python from agents import ( @@ -501,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -当模型消息无法通过智能体结构化 `output_type` 的验证,或模型没有返回结构化最终消息时,请使用 `"invalid_final_output"`。处理器可以返回应用特定的回退值,SDK 会使用相同的 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空验证失败仍会引发 `ModelBehaviorError`,而空结构化响应会保留现有的下一轮行为。 +当模型消息无法通过智能体的结构化 `output_type` 验证,或模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。处理程序可以返回应用特定的回退值,SDK 会根据同一个 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空验证失败仍会引发 `ModelBehaviorError`,而空结构化响应会保留现有的下一轮行为。 ```python from pydantic import BaseModel @@ -533,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理器,这会将合成的回退输出追加到对话历史记录中,并将其持久化到已配置的会话。如果希望将回退值返回给调用方,但不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 +`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理程序,这会将合成的回退输出追加到会话历史记录中,并将其持久化至已配置的会话。如果希望向调用方返回回退值,而不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 -如果模型拒绝响应时应生成应用特定的回退值,而不是以 `ModelRefusalError` 结束运行,请使用 `"model_refusal"`。 +如果希望模型拒绝时生成应用特定的回退值,而不是以 `ModelRefusalError` 结束运行,请使用 `"model_refusal"`。 ```python from pydantic import BaseModel @@ -567,35 +567,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人在回路 +## 持久执行集成与人工介入 -有关工具审批的暂停/恢复模式,请先参阅专门的[人在回路指南](human_in_the_loop.md)。以下集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 +有关工具审批的暂停/恢复模式,请先参阅专门的[人工介入指南](human_in_the_loop.md)。以下集成适用于运行可能跨越长时间等待、重试或进程重启的持久编排。 ### Dapr -你可以使用 Agents SDK 的 [Dapr](https://dapr.io) Diagrid 集成,运行持久、长时间运行的智能体。这些智能体支持人在回路,并能自动从故障中恢复。Dapr 是一个厂商中立的 [CNCF](https://cncf.io) 工作流编排器。可从[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI 智能体。 +你可以使用 Agents SDK [Dapr](https://dapr.io) Diagrid 集成来运行持久的长时间运行智能体,这些智能体可自动从故障中恢复并支持人工介入工作流。Dapr 是一个供应商中立的 [CNCF](https://cncf.io) 工作流编排器。[在此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI智能体。 ### Temporal -你可以使用 Agents SDK 的 [Temporal](https://temporal.io/) 集成运行持久、长时间运行的工作流,包括人在回路任务。你可以在[此视频](https://www.youtube.com/watch?v=fFBZqzT4DD8)中查看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 +你可以使用 Agents SDK [Temporal](https://temporal.io/) 集成来运行持久的长时间运行工作流,包括人工介入任务。你可以[在此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并[在此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 ### Restate -你可以使用 Agents SDK 的 [Restate](https://restate.dev/) 集成实现轻量级持久智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务函数运行。有关更多详情,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 +你可以使用 Agents SDK [Restate](https://restate.dev/) 集成来运行轻量级持久智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单一二进制运行时,并支持将智能体作为进程/容器或无服务器函数运行。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 ### DBOS -你可以使用 Agents SDK 的 [DBOS](https://dbos.dev/) 集成运行可靠的智能体,使其在故障和重启后仍能保留进度。它支持长时间运行的智能体、人在回路工作流和任务转移,同时支持同步和异步方法。该集成只需要一个 SQLite 或 Postgres 数据库。有关更多详情,请查看集成[仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 +你可以使用 Agents SDK [DBOS](https://dbos.dev/) 集成来运行可靠的智能体,在发生故障和重启时仍可保留进度。它支持长时间运行的智能体、人工介入工作流和任务转移,并同时支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 ## 异常 -SDK 会在特定情况下引发异常。完整列表位于 [`agents.exceptions`][]。概述如下: +SDK 会在特定情况下引发异常。完整列表请参阅 [`agents.exceptions`][]。概述如下: -- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 内引发的所有异常的基类。它是一个通用类型,所有其他特定异常均派生自此类。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。它表示智能体无法在指定的交互轮次数内完成任务。设置 `max_turns=None` 可禁用此限制。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效输出时,会发生此异常。这可能包括: - - 格式错误的 JSON:模型为工具调用或直接输出提供了格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 时。 - - 意外的工具相关故障:模型未能以预期方式使用工具 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会引发此异常。 -- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,会引发此异常。这通常是由代码实现不正确、配置无效或误用 SDK API 导致的。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:分别在满足输入安全防护措施或输出安全防护措施的条件时引发这些异常。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file +- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 引发的所有异常的基类。它是一种通用类型,其他所有具体异常都派生自该类型。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。它表示智能体无法在指定的智能体循环轮次数(LLM 调用次数)内完成任务。设置 `max_turns=None` 可禁用该限制。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。这可能包括: + - 格式错误的 JSON:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 时。 + - 意外的工具相关故障:模型未按预期方式使用工具 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当函数工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会引发此异常。 +- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,会引发此异常。这通常是由不正确的代码实现、无效配置或误用 SDK API 导致的。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:当满足输入安全防护措施的条件时,会引发 `InputGuardrailTripwireTriggered`;当满足输出安全防护措施的条件时,会引发 `OutputGuardrailTripwireTriggered`。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file diff --git a/docs/zh/sandbox/clients.md b/docs/zh/sandbox/clients.md index 3c57a36bfa..1f86ffb167 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -4,42 +4,42 @@ search: --- # 沙箱客户端 -使用本页选择沙箱任务的运行位置。在大多数情况下,`SandboxAgent` 定义保持不变,只需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端及客户端特定选项。 +使用本页选择沙箱工作应在哪里运行。在大多数情况下,`SandboxAgent` 定义保持不变,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端和客户端特定选项。 !!! warning "Beta 功能" - 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,未来还将提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认值和支持的功能可能会发生变化,并且预计未来会提供更多高级功能。 ## 决策指南
-| 目标 | 首选方案 | 原因 | +| 目标 | 首选 | 原因 | | --- | --- | --- | | 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于使用本地文件系统进行开发。 | -| 基础容器隔离 | `DockerSandboxClient` | 使用指定镜像在 Docker 内运行任务。 | -| 托管执行或生产环境级隔离 | 托管沙箱客户端 | 将工作区边界迁移到由供应商管理的环境中。 | +| 基本的容器隔离 | `DockerSandboxClient` | 使用特定镜像在 Docker 内运行工作。 | +| 托管执行或生产环境级隔离 | 托管式沙箱客户端 | 将工作区边界移至由提供商管理的环境。 |
## 本地客户端 -对于大多数用户,建议从以下两个沙箱客户端之一开始: +对于大多数用户,建议从以下两种沙箱客户端之一开始:
| 客户端 | 安装 | 适用场景 | 示例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 无 | 需要在 macOS 或 Linux 上实现最快的本地迭代。适合作为本地开发的默认选择。 | [Unix 本地入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 需要容器隔离,或需要使用特定镜像以确保本地环境的一致性。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上实现最快的本地迭代。适合作为本地开发的默认选择。 | [Unix 本地入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 需要容器隔离,或需要使用特定镜像在本地复现目标环境。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix 本地模式是基于本地文件系统开始开发的最简便方式。当需要更强的环境隔离或与生产环境保持一致时,请迁移到 Docker 或托管供应商。 +Unix-local 是基于本地文件系统进行开发的最简便方式。当需要更强的环境隔离或与生产环境保持一致时,请改用 Docker 或托管提供商。 -`SandboxPathGrant.host_path` 仅适用于 Docker,可将主机路径映射到容器内的另一个 POSIX 路径。Unix 本地模式仅支持相同路径的授权。有关详细信息,请参阅[清单路径授权](guide.md#manifest)。 +`SandboxPathGrant.host_path` 仅适用于 Docker,用于将主机路径映射到容器内的另一个 POSIX 路径。Unix-local 仅支持同路径授权。详情请参阅[清单路径授权](guide.md#manifest)。 -要从 Unix 本地模式切换到 Docker,请保持智能体定义不变,仅更改运行配置: +要从 Unix-local 切换到 Docker,请保持智能体定义不变,仅更改运行配置: ```python from docker import from_env as docker_from_env @@ -56,17 +56,17 @@ run_config = RunConfig( ) ``` -当需要容器隔离或镜像一致性时,请使用此配置。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 +当需要容器隔离,或希望沙箱镜像与其他环境中使用的镜像保持一致时,请使用此方式。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 ## 挂载与远程存储 -挂载条目描述要公开的存储;挂载策略描述沙箱后端如何附加该存储。请从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管供应商策略可从 `agents.extensions.sandbox` 或供应商特定的扩展包中获取。 +挂载条目描述要公开的存储;挂载策略描述沙箱后端如何连接该存储。可从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管提供商策略可从 `agents.extensions.sandbox` 或特定于提供商的扩展包中获取。 常用挂载选项: -- `mount_path`:存储在沙箱中的显示位置。相对路径基于清单根目录解析;绝对路径则按原样使用。 -- `read_only`:默认为 `True`。仅当沙箱需要将数据写回已挂载存储时,才将其设为 `False`。 -- `mount_strategy`:必填。请使用同时与挂载条目和沙箱后端匹配的策略。 +- `mount_path`:存储在沙箱中显示的位置。相对路径基于清单根目录解析;绝对路径则按原样使用。 +- `read_only`:默认为 `True`。仅当沙箱应将更改写回已挂载存储时,才设置为 `False`。 +- `mount_strategy`:必填。所用策略必须同时匹配挂载条目和沙箱后端。 挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不会将已挂载的远程存储复制到保存的工作区中。 @@ -76,54 +76,54 @@ run_config = RunConfig( | 策略或模式 | 适用场景 | 说明 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙箱镜像可以运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可以在 `fuse` 模式或 `nfs` 模式下运行。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像包含 `mount-s3`,并且需要以 Mountpoint 方式访问 S3 或 S3 兼容存储。 | 支持 `S3Mount` 和 `GCSMount`。 | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像包含 `blobfuse2` 并支持 FUSE。 | 支持 `AzureBlobMount`。 | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像包含 `mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | -| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前附加由卷驱动程序支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 支持 `rclone`;S3 和 GCS 还支持 `mountpoint`。 | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙箱镜像可以运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可在 `fuse` 模式或 `nfs` 模式下运行。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像中包含 `mount-s3`,并且需要 Mountpoint 风格的 S3 或 S3 兼容访问。 | 支持 `S3Mount` 和 `GCSMount`。 | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像中包含 `blobfuse2` 并支持 FUSE。 | 支持 `AzureBlobMount`。 | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像中包含 `mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | +| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前连接由卷驱动程序支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 可通过 `rclone` 挂载;S3 和 GCS 也可通过 `mountpoint` 挂载。 | ## 支持的托管平台 -当需要托管环境时,通常可以沿用同一个 `SandboxAgent` 定义,只需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端。 +当需要托管环境时,通常可以沿用同一份 `SandboxAgent` 定义,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端。 -如果使用已发布的 SDK,而不是当前仓库的检出版本,请通过匹配的软件包附加项安装沙箱客户端依赖项。 +如果使用的是已发布的 SDK,而非此仓库的检出版本,请通过匹配的软件包 extra 安装沙箱客户端依赖项。 -有关供应商特定的设置说明及仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。 +有关特定于提供商的设置说明以及仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。
| 客户端 | 安装 | 示例 | | --- | --- | --- | -| `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | -| `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | -| `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) | -| `E2BSandboxClient` | `openai-agents[e2b]` | [E2B 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) | -| `ModalSandboxClient` | `openai-agents[modal]` | [Modal 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) | -| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) | -| `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel 运行程序](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) | +| `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | +| `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | +| `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) | +| `E2BSandboxClient` | `openai-agents[e2b]` | [E2B 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) | +| `ModalSandboxClient` | `openai-agents[modal]` | [Modal 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) | +| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) | +| `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel 运行器](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) |
-托管沙箱客户端提供供应商特定的挂载策略。请选择最适合存储供应商的后端和挂载策略: +托管式沙箱客户端会公开特定于提供商的挂载策略。请选择最适合所用存储提供商的后端和挂载策略:
| 后端 | 挂载说明 | | --- | --- | | Docker | 支持将 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` 和 `S3FilesMount` 与 `InContainerMountStrategy`、`DockerVolumeMountStrategy` 等本地策略配合使用。 | -| `ModalSandboxClient` | 支持通过 `ModalCloudBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和采用 HMAC 身份验证的 `GCSMount` 上挂载 Modal 云存储桶。可以使用内联凭据或具名 Modal Secret。 | -| `CloudflareSandboxClient` | 支持通过 `CloudflareBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和采用 HMAC 身份验证的 `GCSMount` 上挂载 Cloudflare 存储桶。 | -| `BlaxelSandboxClient` | 支持通过 `BlaxelCloudBucketMountStrategy`,在 `S3Mount`、`R2Mount` 和 `GCSMount` 上挂载云存储桶。还支持使用 `agents.extensions.sandbox.blaxel` 中的 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 挂载持久化 Blaxel Drive。 | -| `DaytonaSandboxClient` | 支持通过 `DaytonaCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `E2BSandboxClient` | 支持通过 `E2BCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `RunloopSandboxClient` | 支持通过 `RunloopCloudBucketMountStrategy` 挂载由 rclone 支持的云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `VercelSandboxClient` | 支持通过 `VercelCloudBucketMountStrategy`,在 `S3Mount` 上挂载仅能在创建时指定的 S3 和 S3 兼容存储桶;已挂载的会话无法恢复,并且使用内联凭据时需要设置 `allow_s3_credential_exposure=True`。 | +| `ModalSandboxClient` | 支持使用 `ModalCloudBucketMountStrategy` 以及 `S3Mount`、`R2Mount` 和通过 HMAC 身份验证的 `GCSMount` 挂载云存储桶。可以使用内联凭据或已命名的 Modal Secret。 | +| `CloudflareSandboxClient` | 支持使用 `CloudflareBucketMountStrategy` 以及 `S3Mount`、`R2Mount` 和通过 HMAC 身份验证的 `GCSMount` 挂载存储桶。 | +| `BlaxelSandboxClient` | 支持将 `BlaxelCloudBucketMountStrategy` 与 `S3Mount`、`R2Mount` 或 `GCSMount` 条目配对,以挂载云存储桶。还支持通过 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 使用持久化 Blaxel Drives,二者均可从 `agents.extensions.sandbox.blaxel` 获取。 | +| `DaytonaSandboxClient` | 支持使用 `DaytonaCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `E2BSandboxClient` | 支持使用 `E2BCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `RunloopSandboxClient` | 支持使用 `RunloopCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `VercelSandboxClient` | 支持将 `VercelCloudBucketMountStrategy` 与 `S3Mount` 条目配对,以挂载仅能在创建时配置的 S3 和 S3 兼容存储桶;已挂载的会话无法恢复,并且内联凭据需要 `allow_s3_credential_exposure=True`。 |
-下表汇总了各后端可以直接挂载的远程存储条目。 +下表总结了每种后端可直接挂载哪些远程存储条目。
@@ -140,4 +140,4 @@ run_config = RunConfig(
-如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),其中包含本地、编码、记忆、任务转移和智能体组合模式;托管沙箱客户端的代码示例请参阅 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)。 \ No newline at end of file +如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),其中包含本地运行、编码、记忆、任务转移和智能体组合模式;有关托管式沙箱客户端,请浏览 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)。 \ No newline at end of file diff --git a/docs/zh/sandbox/guide.md b/docs/zh/sandbox/guide.md index a7c853b3fa..538d2d543a 100644 --- a/docs/zh/sandbox/guide.md +++ b/docs/zh/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "Beta 功能" - 沙箱智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认设置和支持的功能可能会发生变化,并且随着时间推移还会提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且未来还会逐步提供更多高级功能。 -现代智能体在能够操作文件系统中的真实文件时效果最佳。**沙箱智能体**可以利用专用工具和 shell 命令检索和处理大型文档集、编辑文件、生成工件并运行命令。沙箱为模型提供持久工作区,智能体可在其中代表您完成工作。Agents SDK 中的沙箱智能体可帮助您轻松运行与沙箱环境配套的智能体,方便在文件系统中准备所需文件,并编排沙箱,从而轻松地大规模启动、停止和恢复任务。 +现代智能体若能在文件系统中操作真实文件,通常可以发挥最佳效果。**沙箱智能体**可以使用专用工具和 shell 命令搜索和处理大型文档集、编辑文件、生成产物以及运行命令。沙箱为模型提供持久化工作区,智能体可以在其中代您执行工作。Agents SDK 中的沙箱智能体可帮助您轻松运行与沙箱环境配对的智能体,便于将正确的文件放入文件系统,并编排沙箱,从而大规模启动、停止和恢复任务。 您可以围绕智能体所需的数据定义工作区。工作区可以从 GitHub 仓库、本地文件和目录、合成任务文件、S3 或 Azure Blob Storage 等远程文件系统,以及您提供的其他沙箱输入开始构建。
-![带计算环境的沙箱智能体执行框架](../assets/images/harness_with_compute.png) +![带计算环境的沙箱智能体运行框架](../assets/images/harness_with_compute.png)
-`SandboxAgent` 仍然是一个 `Agent`。它保留了常规智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规的 `Runner` API 运行。变化在于执行边界: +`SandboxAgent` 仍然是 `Agent`。它保留常规的智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规 `Runner` API 运行。变化的是执行边界: -- `SandboxAgent` 定义智能体本身:包括常规智能体配置,以及 `default_manifest`、`base_instructions`、`run_as` 等沙箱专用默认设置,还有文件系统工具、shell 访问、技能、记忆或压缩等能力。 -- `Manifest` 声明新沙箱工作区所需的初始内容和布局,包括文件、仓库、挂载和环境。 +- `SandboxAgent` 定义智能体本身:常规智能体配置,以及 `default_manifest`、`base_instructions`、`run_as` 等沙箱专用默认值和文件系统工具、shell 访问、技能、记忆或压缩等能力。 +- `Manifest` 声明新沙箱工作区预期的初始内容和布局,包括文件、仓库、挂载和环境。 - 沙箱会话是运行命令和修改文件的实时隔离环境。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定运行如何获得该沙箱会话,例如直接注入会话、从已序列化的沙箱会话状态重新连接,或通过沙箱客户端创建新的沙箱会话。 -- 保存的沙箱状态和快照让后续运行能够重新连接到先前的工作,或使用已保存的内容初始化新的沙箱会话。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定本次运行如何获取该沙箱会话,例如直接注入会话、从序列化的沙箱会话状态重新连接,或通过沙箱客户端创建新的沙箱会话。 +- 已保存的沙箱状态和快照可让后续运行重新连接到先前的工作,或使用已保存的内容初始化新的沙箱会话。 -`Manifest` 是新会话的工作区约定,而不是每个实时沙箱的完整事实来源。一次运行的实际工作区也可以来自复用的沙箱会话、已序列化的沙箱会话状态,或运行时选择的快照。 +`Manifest` 是新会话工作区的约定,并非每个实时沙箱的完整事实来源。一次运行的实际工作区也可以来自复用的沙箱会话、序列化的沙箱会话状态,或运行时选择的快照。 -在本页中,“沙箱会话”是指由沙箱客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中所述的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 +在本页中,“沙箱会话”是指由沙箱客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中介绍的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 -外层运行时仍负责审批、追踪、任务转移和恢复记录。沙箱会话负责命令、文件变更和环境隔离。这种职责划分是该模型的核心组成部分。 +外层运行时仍负责审批、追踪、任务转移,以及跟踪恢复运行所需的状态。沙箱会话负责命令、文件更改和环境隔离。这种职责划分是该模型的核心组成部分。 -### 各组成部分的协作方式 +### 组件之间的关系 -沙箱运行将智能体定义与每次运行的沙箱配置结合起来。运行器会准备智能体,将其绑定到实时沙箱会话,并可保存状态以供后续运行使用。 +沙箱运行将智能体定义与每次运行的沙箱配置结合起来。运行器会准备智能体、将其绑定到实时沙箱会话,并可保存状态供后续运行使用。 ```mermaid flowchart LR @@ -50,96 +50,96 @@ flowchart LR sandbox --> saved ``` -沙箱专用默认设置保留在 `SandboxAgent` 上。每次运行的沙箱会话选项保留在 `SandboxRunConfig` 中。 +沙箱专用默认值保留在 `SandboxAgent` 上。每次运行的沙箱会话选项保留在 `SandboxRunConfig` 中。 -可以将生命周期分为三个阶段: +可以将生命周期理解为三个阶段: -1. 使用 `SandboxAgent`、`Manifest` 和能力定义智能体以及新工作区约定。 -2. 通过向 `Runner` 提供 `SandboxRunConfig` 来执行运行,由其注入、恢复或创建沙箱会话。 -3. 稍后从运行器管理的 `RunState`、显式沙箱 `session_state` 或已保存的工作区快照继续运行。 +1. 使用 `SandboxAgent`、`Manifest` 和各项能力定义智能体与新工作区约定。 +2. 向 `Runner` 提供一个 `SandboxRunConfig`,由其注入、恢复或创建沙箱会话,从而执行一次运行。 +3. 后续从运行器管理的 `RunState`、显式沙箱 `session_state` 或已保存的工作区快照继续运行。 -如果只是偶尔需要将 shell 访问作为一种工具,请从[工具指南](../tools.md)中的托管 shell 开始。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,请使用沙箱智能体。 +如果 shell 访问只是偶尔使用的一项工具,请先使用[工具指南](../tools.md)中的托管 shell。如果工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分,则应使用沙箱智能体。 ## 适用场景 沙箱智能体非常适合以工作区为中心的工作流,例如: - 编码和调试,例如针对 GitHub 仓库中的问题报告编排自动修复并运行针对性测试 -- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完成的税表草稿 -- 基于文件的审核或分析,例如在回答前检查入职资料包、生成的报告或工件包 -- 隔离的多智能体模式,例如为每个审核智能体或编码子智能体提供独立工作区 +- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完毕的税表草稿 +- 基于文件的审查或分析,例如在回答前检查入职资料包、生成的报告或产物包 +- 隔离的多智能体模式,例如为每个审查智能体或编码子智能体提供各自的工作区 - 多步骤工作区任务,例如在一次运行中修复错误,之后再添加回归测试,或从快照或沙箱会话状态恢复 -如果不需要访问文件或持续存在的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔使用的一项能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 +如果不需要访问文件或使用有状态、可变的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔使用的一项能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 ## 沙箱客户端的选择 -在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,如果需要容器隔离或镜像一致性,请转用 `DockerSandboxClient`;如果需要由提供商管理执行,请转用托管提供商。 +在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,如果需要容器隔离或镜像一致性,请改用 `DockerSandboxClient`;如果需要由提供商管理执行,则改用托管提供商。 大多数情况下,`SandboxAgent` 定义保持不变,只需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙箱客户端](clients.md)。 -## 核心组成部分 +## 核心组件
-| 层级 | 主要 SDK 组成部分 | 解答的问题 | +| 层级 | 主要 SDK 组件 | 回答的问题 | | --- | --- | --- | -| 智能体定义 | `SandboxAgent`、`Manifest`、能力 | 将运行什么智能体,它应从什么新会话工作区约定开始? | +| 智能体定义 | `SandboxAgent`、`Manifest`、能力 | 将运行哪个智能体,以及它应从什么新会话工作区约定开始? | | 沙箱执行 | `SandboxRunConfig`、沙箱客户端和实时沙箱会话 | 本次运行如何获得实时沙箱会话,工作在哪里执行? | -| 保存的沙箱状态 | `RunState` 沙箱载荷、`session_state` 和快照 | 此工作流如何重新连接到先前的沙箱工作,或使用已保存的内容初始化新的沙箱会话? | +| 已保存的沙箱状态 | `RunState` 沙箱载荷、`session_state` 和快照 | 此工作流如何重新连接到先前的沙箱工作,或使用已保存的内容初始化新的沙箱会话? |
-主要 SDK 组成部分与这些层级的对应关系如下: +主要 SDK 组件与这些层级的对应关系如下:
-| 组成部分 | 负责的内容 | 应提出的问题 | +| 组件 | 负责的内容 | 应考虑的问题 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应执行什么操作,哪些默认设置应随它一起使用? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 新会话工作区的文件和文件夹 | 运行开始时,文件系统中应存在哪些文件和文件夹? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应该做什么,哪些默认值应随其一同使用? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新会话工作区的文件和文件夹 | 运行开始时,文件系统中应该有哪些文件和文件夹? | | [`Capability`][agents.sandbox.capabilities.capability.Capability] | 沙箱原生行为 | 应为此智能体附加哪些工具、指令片段或运行时行为? | | [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 每次运行的沙箱客户端和沙箱会话来源 | 本次运行应注入、恢复还是创建沙箱会话? | -| [`RunState`][agents.run_state.RunState] | 运行器管理的已保存沙箱状态 | 我是否正在恢复先前由运行器管理的工作流,并自动将其沙箱状态延续下去? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙箱会话状态 | 我是否希望从已在 `RunState` 外部序列化的沙箱状态恢复? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于新沙箱会话的已保存工作区内容 | 新沙箱会话是否应从已保存的文件和工件开始? | +| [`RunState`][agents.run_state.RunState] | 由运行器管理的已保存沙箱状态 | 我是否正在恢复由运行器管理的先前工作流,并自动沿用其沙箱状态? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙箱会话状态 | 我是否要从已在 `RunState` 外部序列化的沙箱状态恢复? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于新沙箱会话的已保存工作区内容 | 新沙箱会话是否应从已保存的文件和产物开始? |
-实际的设计顺序如下: +实用的设计顺序如下: 1. 使用 `Manifest` 定义新会话工作区约定。 2. 使用 `SandboxAgent` 定义智能体。 3. 添加内置或自定义能力。 -4. 决定每次运行应如何在 `RunConfig(sandbox=SandboxRunConfig(...))` 中获取其沙箱会话。 +4. 在 `RunConfig(sandbox=SandboxRunConfig(...))` 中决定每次运行应如何获取沙箱会话。 -## 沙箱运行的准备过程 +## 沙箱运行的准备流程 -运行时,运行器会将该定义转换为由沙箱支持的具体运行: +在运行时,运行器会将该定义转换为由沙箱支持的具体运行: 1. 它从 `SandboxRunConfig` 解析沙箱会话。如果传入 `session=...`,则复用该实时沙箱会话。否则,它使用 `client=...` 创建或恢复会话。 -2. 它确定本次运行的实际工作区输入。如果运行注入或恢复沙箱会话,则以现有沙箱状态为准。否则,运行器会从一次性清单覆盖项或 `agent.default_manifest` 开始。这就是为什么仅靠 `Manifest` 无法定义每次运行的最终实时工作区。 -3. 它让能力处理生成的清单。这样,能力就可以在最终智能体准备完成前添加文件、挂载或其他工作区范围内的行为。 -4. 它按固定顺序构建最终指令:首先是 SDK 的默认沙箱提示词,或在您显式覆盖时使用 `base_instructions`;然后是 `instructions`;接着是能力指令片段;之后是任何远程挂载策略文本;最后是渲染后的文件系统树。 +2. 它确定本次运行的实际工作区输入。如果运行注入或恢复了沙箱会话,则以该现有沙箱状态为准。否则,运行器从一次性清单覆盖项或 `agent.default_manifest` 开始。因此,仅靠 `Manifest` 无法定义每次运行的最终实时工作区。 +3. 它让各项能力处理生成的清单。这样,能力便可在准备最终智能体之前添加文件、挂载或其他工作区范围内的行为。 +4. 它按固定顺序构建最终指令:SDK 的默认沙箱提示词;如果显式覆盖,则使用 `base_instructions`;随后是 `instructions`、能力指令片段、所有远程挂载策略文本,最后是渲染后的文件系统树。 5. 它将能力工具绑定到实时沙箱会话,并通过常规 `Runner` API 运行准备好的智能体。 -沙箱不会改变轮次的含义。一个轮次仍是一个模型步骤,而不是单条 shell 命令或单个沙箱操作。沙箱侧操作与轮次之间不存在固定的 1:1 映射:部分工作可能保留在沙箱执行层内,而其他操作则会返回工具结果、审批或其他需要额外模型步骤的状态。实际而言,只有在完成沙箱工作后,智能体运行时还需要另一个模型响应时,才会消耗额外轮次。 +沙箱不会改变轮次的含义。一个轮次仍然是一次模型步骤,而不是一条 shell 命令或一次沙箱操作。沙箱侧操作与轮次之间不存在固定的 1:1 映射:有些工作可能完全在沙箱执行层内完成,而其他操作则会返回需要另一次模型步骤的信息,例如工具结果、审批或其他类型的状态。实际判断原则是:只有在沙箱工作完成后,智能体运行时需要模型再次响应时,才会消耗另一个轮次。 -这些准备步骤说明了为什么在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 是需要重点考虑的沙箱专用选项。 +正因为存在这些准备步骤,在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 才是需要重点考虑的主要沙箱专用选项。 ## `SandboxAgent` 选项 -除常规 `Agent` 字段之外,还提供以下沙箱专用选项: +除常规 `Agent` 字段外,还提供以下沙箱专用选项:
| 选项 | 最佳用途 | | --- | --- | -| `default_manifest` | 运行器创建的新沙箱会话所使用的默认工作区。 | -| `instructions` | 追加在 SDK 沙箱提示词之后的额外角色、工作流和成功标准。 | -| `base_instructions` | 用于替换 SDK 沙箱提示词的高级应急选项。 | -| `capabilities` | 应随此智能体一起使用的沙箱原生工具和行为。 | -| `run_as` | 面向模型的沙箱工具所使用的用户身份,例如 shell 命令、文件读取和补丁。 | +| `default_manifest` | 由运行器创建的新沙箱会话的默认工作区。 | +| `instructions` | 附加在 SDK 沙箱提示词之后的其他角色、工作流和成功标准。 | +| `base_instructions` | 用于替换 SDK 沙箱提示词的高级逃生舱选项。 | +| `capabilities` | 应随此智能体一同使用的沙箱原生工具和行为。 | +| `run_as` | 用于 shell 命令、文件读取和补丁等面向模型的沙箱工具的用户身份。 |
@@ -147,41 +147,41 @@ flowchart LR ### `default_manifest` -`default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。可使用它指定智能体通常应在启动时具备的文件、仓库、辅助材料、输出目录和挂载。 +`default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。使用它定义智能体通常应从哪些文件、仓库、辅助材料、输出目录和挂载开始。 -这只是默认设置。运行可以通过 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 +这只是默认值。运行可以通过 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 ### `instructions` 和 `base_instructions` -对于应在不同提示词下保持不变的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会追加到 SDK 的沙箱基础提示词之后,因此您可以保留内置沙箱指导,并添加自己的角色、工作流和成功标准。 +对于应在不同提示词之间保留的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会附加到 SDK 沙箱基础提示词之后,因此您可以保留内置沙箱指导,同时添加自己的角色、工作流和成功标准。 -仅当您希望替换 SDK 的沙箱基础提示词时,才使用 `base_instructions`。大多数智能体都不应设置它。 +仅当您希望替换 SDK 沙箱基础提示词时,才使用 `base_instructions`。大多数智能体不应设置该选项。
| 放置位置 | 用途 | 示例 | | --- | --- | --- | | `instructions` | 智能体的稳定角色、工作流规则和成功标准。 | “检查入职文档,然后进行任务转移。”、“将最终文件写入 `output/`。” | -| `base_instructions` | 完整替换 SDK 的沙箱基础提示词。 | 自定义底层沙箱包装器提示词。 | +| `base_instructions` | 完整替换 SDK 沙箱基础提示词。 | 自定义底层沙箱封装提示词。 | | 用户提示词 | 本次运行的一次性请求。 | “总结此工作区。” | -| 清单中的工作区文件 | 较长的任务规范、仓库本地指令或范围有限的参考资料。 | `repo/task.md`、文档包、样本资料包。 | +| 清单中的工作区文件 | 较长的任务规范、仓库本地指令或范围受限的参考材料。 | `repo/task.md`、文档包、示例资料包。 |
`instructions` 的良好用法包括: -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 在 PTY 状态很重要时,让智能体始终处于同一个交互式进程中。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙箱审核智能体在检查后直接回答用户。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写的文件实际写入 `output/`。 -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定准确的验证命令,并明确补丁路径是相对于工作区根目录的。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 在 PTY 状态很重要时,让智能体保持在同一个交互式进程中。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙箱审查智能体在检查后直接回复用户。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写完成的文件实际写入 `output/`。 +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定确切的验证命令,并明确相对于工作区根目录的补丁路径。 -请避免将用户的一次性任务复制到 `instructions` 中、嵌入本应放入清单的长篇参考资料、重复内置能力已经注入的工具文档,或混入模型在运行时并不需要的本地安装说明。 +请避免将用户的一次性任务复制到 `instructions`、嵌入本应放入清单的长篇参考材料、重复内置能力已注入的工具文档,或混入模型在运行时不需要的本地安装说明。 -如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。对于底层包装器而言,这已经足够,但大多数面向用户的智能体仍应提供明确的 `instructions`。 +如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。对于底层封装,这已经足够;但大多数面向用户的智能体仍应提供显式的 `instructions`。 ### `capabilities` -能力会将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、追加沙箱专用指令、公开绑定到实时沙箱会话的工具,并调整该智能体的模型行为或输入处理方式。 +能力会将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、附加沙箱专用指令、公开绑定到实时沙箱会话的工具,并调整该智能体的模型行为或输入处理方式。 内置能力包括: @@ -191,57 +191,57 @@ flowchart LR | --- | --- | --- | | `Shell` | 智能体需要 shell 访问。 | 添加 `exec_command`;当沙箱客户端支持 PTY 交互时,还会添加 `write_stdin`。 | | `Filesystem` | 智能体需要编辑文件或检查本地图像。 | 添加 `apply_patch` 和 `view_image`;补丁路径相对于工作区根目录。 | -| `Skills` | 您希望在沙箱中发现并具现化技能。 | 应优先使用它,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您将技能编入索引并具现化到沙箱中。 | -| `Memory` | 后续运行应读取或生成记忆工件。 | 需要 `Shell`;实时更新还需要 `Filesystem`。 | +| `Skills` | 您希望在沙箱中发现并物化技能。 | 优先使用它,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您将技能编入索引并物化到沙箱中。 | +| `Memory` | 后续运行应读取或生成记忆产物。 | 需要 `Shell`;在运行期间更新记忆产物还需要 `Filesystem`。 | | `Compaction` | 长时间运行的流程需要在压缩项之后裁剪上下文。 | 调整模型采样和输入处理。 | -默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包括 `Filesystem()`、`Shell()` 和 `Compaction()`。如果传入 `capabilities=[...]`,该列表会替换默认列表,因此请包含仍希望使用的所有默认能力。 +默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包含 `Filesystem()`、`Shell()` 和 `Compaction()`。如果传入 `capabilities=[...]`,该列表将替换默认列表,因此请包含您仍需要的所有默认能力。 -对于技能,请根据希望采用的具现化方式选择来源: +对于技能,请根据期望的物化方式选择来源: -- `Skills(lazy_from=LocalDirLazySkillSource(...))` 是较大本地技能目录的良好默认选择,因为模型可以先发现索引,然后仅加载所需内容。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从运行 SDK 进程的文件系统读取内容。请传入原始主机端技能目录,而不是仅存在于沙箱镜像或工作区中的路径。 -- `Skills(from_=LocalDir(src=...))` 更适合希望预先暂存的小型本地技能包。 -- 当技能本身应来自仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 是合适的选择。 +- `Skills(lazy_from=LocalDirLazySkillSource(...))` 非常适合作为大型本地技能目录的默认选项,因为模型可以先发现索引,然后仅加载所需内容。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从 SDK 进程运行所在的文件系统读取。请传入原始主机侧技能目录,而不是仅存在于沙箱镜像或工作区内的路径。 +- `Skills(from_=LocalDir(src=...))` 更适合希望预先暂存的小型本地包。 +- 当技能本身应来自某个仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 最为合适。 -`LocalDir.src` 是 SDK 主机上的源路径。`skills_path` 是沙箱工作区中的相对目标路径,调用 `load_skill` 时,技能会暂存到该路径。 +`LocalDir.src` 是 SDK 主机上的源路径。`skills_path` 是沙箱工作区内的相对目标路径,调用 `load_skill` 时,技能会暂存到该路径。 -如果您的技能已存储在磁盘上的 `.agents/skills//SKILL.md` 等位置,请将 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 公开这些技能。除非现有工作区约定依赖其他沙箱内布局,否则请保留默认的 `skills_path=".agents"`。 +如果您的技能已位于类似 `.agents/skills//SKILL.md` 的磁盘路径下,请让 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 将其公开。除非现有工作区约定依赖不同的沙箱内布局,否则请保留默认的 `skills_path=".agents"`。 -如果内置能力能够满足需求,应优先使用它们。只有在需要内置能力未涵盖的沙箱专用工具或指令接口时,才编写自定义能力。 +如果内置能力满足需求,请优先使用。只有在需要内置能力未涵盖的沙箱专用工具或指令接口时,才编写自定义能力。 ## 概念 ### 清单 -[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 +[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,并授予对工作区外特定绝对路径的访问权限。 -清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 逸出工作区,这可使工作区约定在本地、Docker 和托管客户端之间保持可移植性。 +清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 逃逸工作区,这可以让工作区约定在本地、Docker 和托管客户端之间保持可移植性。 -使用清单条目指定智能体在开始工作前所需的材料: +使用清单条目定义智能体开始工作前所需的材料:
| 清单条目 | 用途 | | --- | --- | | `File`、`Dir` | 小型合成输入、辅助文件或输出目录。 | -| `LocalFile`、`LocalDir` | 应具现化到沙箱中的主机文件或目录。 | -| `GitRepo` | 应提取到工作区中的仓库。 | +| `LocalFile`、`LocalDir` | 应物化到沙箱中的主机文件或目录。 | +| `GitRepo` | 应提取到工作区的仓库。 | | `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` 等挂载 | 应显示在沙箱内的外部存储。 |
-`Dir` 会根据合成子项在沙箱工作区内创建目录,或创建一个输出位置;它不会从主机文件系统读取内容。现有主机目录需要复制到沙箱工作区时,请使用 `LocalDir`。 +`Dir` 根据合成子项在沙箱工作区内创建目录,或将其用作输出位置;它不会从主机文件系统读取内容。如果应将现有主机目录复制到沙箱工作区,请使用 `LocalDir`。 -默认情况下,`LocalFile.src` 和 `LocalDir.src` 会相对于 SDK 进程的工作目录进行解析。源必须位于该基础目录下,除非它包含在 `extra_path_grants` 中。这样可确保本地源材料的具现化与沙箱清单的其余部分位于相同的主机路径信任边界内。 +默认情况下,`LocalFile.src` 和 `LocalDir.src` 相对于 SDK 进程工作目录进行解析。除非源路径由 `extra_path_grants` 覆盖,否则它必须位于该基础目录下。这样,本地源物化就会与沙箱清单的其余部分保持在同一主机路径信任边界内。 -挂载条目描述要公开的存储;挂载策略描述沙箱后端如何附加该存储。有关挂载选项和提供商支持,请参阅[沙箱客户端](clients.md#mounts-and-remote-storage)。 +挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。有关挂载选项和提供商支持,请参阅[沙箱客户端](clients.md#mounts-and-remote-storage)。 -良好的清单设计通常意味着保持工作区约定精简,将较长的任务流程放入 `repo/task.md` 等工作区文件,并在指令中使用相对工作区路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 能力的 `apply_patch` 工具编辑文件,请记住,补丁路径相对于沙箱工作区根目录,而不是 shell 的 `workdir`。 +良好的清单设计通常意味着保持工作区约定精简,将较长的任务步骤放入 `repo/task.md` 等工作区文件,并在指令中使用相对工作区路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 能力的 `apply_patch` 工具编辑文件,请记住补丁路径相对于沙箱工作区根目录,而不是 shell 的 `workdir`。 -仅当智能体需要访问工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录之外的受信任本地源时,才使用 `extra_path_grants`。例如,用于临时工具输出的 `/tmp`、用于只读运行时的 `/opt/toolchain`,或应具现化到沙箱中的已生成技能目录。授权适用于本地源具现化、SDK 文件 API,以及后端能够实施文件系统策略时的 shell 执行: +仅当智能体需要工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录外的可信本地源时,才使用 `extra_path_grants`。例如用于临时工具输出的 `/tmp`、用于只读运行时的 `/opt/toolchain`,或应物化到沙箱中的已生成技能目录。授权适用于本地源物化和 SDK 文件 API。当后端可以实施文件系统策略时,它也适用于 shell 执行: ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +254,17 @@ manifest = Manifest( ) ``` -当 Docker 应将其他绝对主机路径绑定挂载到容器内的绝对 POSIX `path` 时,请设置 `host_path`。`UnixLocalSandboxClient` 仅支持两个路径相同的纯路径授权,并会拒绝 `host_path`。对于沙箱不应修改的主机数据,请使用 `read_only=True`;如果复制即可满足需求,请使用 `LocalFile` 或 `LocalDir`。 +如果 Docker 应将不同的绝对主机路径绑定挂载到容器内的绝对 POSIX `path`,请设置 `host_path`。`UnixLocalSandboxClient` 仅支持两个路径相同的纯路径授权,并拒绝 `host_path`。对于沙箱不应修改的主机数据,请使用 `read_only=True`;如果复制即可满足需求,请使用 `LocalFile` 或 `LocalDir`。 -请将包含 `extra_path_grants` 的清单视为受信任配置。除非应用程序已经批准这些主机路径,否则请勿从模型输出或其他不受信任的载荷加载授权。 +请将包含 `extra_path_grants` 的清单视为可信配置。除非您的应用已批准相应主机路径,否则不要从模型输出或其他不可信载荷加载授权。 -快照和 `persist_workspace()` 仍然只包含工作区根目录。额外授权的路径属于运行时访问权限,而不是持久工作区状态。 +快照和 `persist_workspace()` 仍只包含工作区根目录。额外授权路径属于运行时访问,而不是持久工作区状态。 ### 权限 -`Permissions` 控制清单条目的文件系统权限。它针对沙箱具现化的文件,而不是模型权限、审批策略或 API 凭据。 +`Permissions` 控制清单条目的文件系统权限。它针对沙箱物化的文件,而不是模型权限、审批策略或 API 凭据。 -默认情况下,清单条目的所有者具有读取、写入和执行权限,组和其他用户具有读取和执行权限。当暂存文件应为私有、只读或可执行时,请覆盖此设置: +默认情况下,清单条目的所有者可读、可写、可执行,组和其他用户可读、可执行。当暂存文件应设为私有、只读或可执行时,请覆盖该默认值: ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +280,9 @@ private_notes = File( ) ``` -`Permissions` 分别存储所有者、组和其他用户的权限位,以及条目是否为目录。您可以直接构建它,通过 `Permissions.from_str(...)` 从模式字符串解析,或通过 `Permissions.from_mode(...)` 从操作系统模式派生。 +`Permissions` 分别存储所有者、组和其他用户的权限位,以及该条目是否为目录。您可以直接构建它,使用 `Permissions.from_str(...)` 从模式字符串解析,或使用 `Permissions.from_mode(...)` 从操作系统模式派生。 -用户是可在沙箱中执行工作的身份。当您希望某个身份存在于沙箱中时,请将 `User` 添加到清单;然后,当 shell 命令、文件读取和补丁等面向模型的沙箱工具应以该用户身份运行时,设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到实际清单中。 +用户是可以执行工作的沙箱身份。如果希望某个身份存在于沙箱中,请向清单添加 `User`,然后在 shell 命令、文件读取和补丁等面向模型的沙箱工具应以该用户身份运行时设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到实际清单中。 ```python from agents import Runner @@ -334,13 +334,13 @@ result = await Runner.run( ) ``` -如果还需要文件级共享规则,请将用户与清单组以及条目 `group` 元数据结合使用。`run_as` 用户控制由谁执行沙箱原生操作;`Permissions` 控制沙箱具现化工作区后,该用户可以读取、写入或执行哪些文件。 +如果还需要文件级共享规则,请将用户与清单组及条目 `group` 元数据结合使用。`run_as` 用户控制由谁执行沙箱原生操作;`Permissions` 控制沙箱物化工作区后,该用户可以读取、写入或执行哪些文件。 ### SnapshotSpec -`SnapshotSpec` 指定新沙箱会话应从哪里恢复已保存的工作区内容,以及应将其持久化回哪里。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 +`SnapshotSpec` 指定新沙箱会话应从何处恢复已保存的工作区内容,以及将内容持久化回何处。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 -对于本地持久快照,请使用 `LocalSnapshotSpec`;当应用程序提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。本地快照设置不可用时,会使用空操作快照作为回退;当高级调用方不希望持久化工作区快照时,也可以显式使用空操作快照。 +使用 `LocalSnapshotSpec` 创建本地持久快照;当应用提供远程快照客户端时,使用 `RemoteSnapshotSpec`。当本地快照设置不可用时,会使用空操作快照作为后备;当不希望持久化工作区快照时,高级调用方也可以显式使用空操作快照。 ```python from pathlib import Path @@ -357,9 +357,9 @@ run_config = RunConfig( ) ``` -当运行器创建新沙箱会话时,沙箱客户端会为该会话构建快照实例。启动时,如果快照可恢复,沙箱会先恢复已保存的工作区内容,然后再继续运行。清理时,运行器拥有的沙箱会话会归档工作区,并通过快照将其持久化。 +当运行器创建新沙箱会话时,沙箱客户端会为该会话构建快照实例。启动时,如果快照可以恢复,沙箱会先恢复已保存的工作区内容,然后继续运行。清理时,运行器拥有的沙箱会话会归档工作区,并通过快照将其持久化。 -如果省略 `snapshot`,运行时会尽可能尝试使用默认本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 +如果省略 `snapshot`,运行时会在可行时尝试使用默认本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 ### 沙箱生命周期 @@ -391,7 +391,7 @@ sequenceDiagram -当沙箱只需在一次运行期间存在时,请使用 SDK 所有的生命周期。传入 `client`、可选的 `manifest`、可选的 `snapshot` 和客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、关闭沙箱,并让客户端清理运行器拥有的资源。 +如果沙箱只需在一次运行期间存在,请使用 SDK 所有的生命周期。传入 `client`,以及可选的 `manifest` 和 `snapshot`,再加上所需的任何客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、结束沙箱会话,并让客户端清理运行器拥有的资源。 ```python result = await Runner.run( @@ -403,7 +403,7 @@ result = await Runner.run( ) ``` -当您希望提前创建沙箱、在多次运行间复用同一个实时沙箱、在运行后检查文件、通过自行创建的沙箱进行流式传输,或准确决定何时进行清理时,请使用开发者所有的生命周期。传入 `session=...` 会让运行器使用该实时沙箱,但运行器不会替您关闭它。 +如果希望提前创建沙箱、跨多次运行复用同一个实时沙箱、在运行后检查文件、通过自行创建的沙箱进行流式传输,或精确决定清理时机,请使用开发者所有的生命周期。传入 `session=...` 会指示运行器使用该实时沙箱,但运行器不会替您关闭它。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -上下文管理器是常见用法:进入时启动沙箱,退出时运行会话清理生命周期。如果您的应用无法使用上下文管理器,请直接调用生命周期方法: +通常应使用上下文管理器:它会在进入时启动沙箱,并在退出时执行会话清理生命周期。如果应用无法使用上下文管理器,请直接调用生命周期方法: ```python sandbox = await client.create( @@ -435,11 +435,11 @@ finally: await sandbox.aclose() ``` -`stop()` 只会持久化由快照支持的工作区内容;它不会拆除沙箱。`aclose()` 是完整的会话清理路径:它会运行停止前钩子、调用 `stop()`、关闭沙箱资源,并关闭会话范围内的依赖项。 +`stop()` 只会持久化由快照支持的工作区内容;它不会关闭沙箱。`aclose()` 是完整的会话清理路径:它运行停止前钩子、调用 `stop()`、关闭沙箱资源,并关闭会话范围内的依赖项。 ## `SandboxRunConfig` 选项 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 保存每次运行的选项,这些选项决定沙箱会话的来源,以及应如何初始化新会话。 +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 包含每次运行的选项,用于决定沙箱会话的来源,以及应如何初始化新会话。 ### 沙箱来源 @@ -447,52 +447,52 @@ finally:
-| 选项 | 适用场景 | 说明 | +| 选项 | 使用时机 | 说明 | | --- | --- | --- | -| `client` | 您希望运行器代您创建、恢复和清理沙箱会话。 | 除非提供实时沙箱 `session`,否则为必需项。 | -| `session` | 您已经自行创建了实时沙箱会话。 | 调用方拥有生命周期;运行器会复用该实时沙箱会话。 | -| `session_state` | 您拥有已序列化的沙箱会话状态,但没有实时沙箱会话对象。 | 需要 `client`;运行器会从该显式状态恢复为其拥有的会话。 | +| `client` | 您希望运行器为您创建、恢复和清理沙箱会话。 | 除非提供实时沙箱 `session`,否则为必填项。 | +| `session` | 您已自行创建实时沙箱会话。 | 调用方拥有生命周期;运行器复用该实时沙箱会话。 | +| `session_state` | 您拥有序列化的沙箱会话状态,但没有实时沙箱会话对象。 | 需要 `client`;运行器从该显式状态恢复,并拥有恢复后会话的生命周期。 |
实际使用中,运行器按以下顺序解析沙箱会话: 1. 如果注入 `run_config.sandbox.session`,则直接复用该实时沙箱会话。 -2. 否则,如果运行正在从 `RunState` 恢复,则恢复其中存储的沙箱会话状态。 +2. 否则,如果运行从 `RunState` 恢复,则恢复其中存储的沙箱会话状态。 3. 否则,如果传入 `run_config.sandbox.session_state`,运行器会从该显式序列化的沙箱会话状态恢复。 4. 否则,运行器会创建新的沙箱会话。对于该新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 ### 新会话输入 -以下选项仅在运行器创建新沙箱会话时有效: +以下选项仅在运行器创建新沙箱会话时生效:
-| 选项 | 适用场景 | 说明 | +| 选项 | 使用时机 | 说明 | | --- | --- | --- | -| `manifest` | 您希望对新会话工作区进行一次性覆盖。 | 省略时回退到 `agent.default_manifest`。 | +| `manifest` | 您希望为新会话提供一次性工作区覆盖。 | 省略时回退到 `agent.default_manifest`。 | | `snapshot` | 新沙箱会话应从快照初始化。 | 适用于类似恢复的流程或远程快照客户端。 | | `options` | 沙箱客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时及类似的客户端专用设置。 |
-### 具现化控制 +### 物化控制 -`concurrency_limits` 控制可以并行运行的沙箱具现化工作量。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用相应限制。 +`concurrency_limits` 控制可以并行运行多少项沙箱物化工作。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用该特定限制。 -`archive_limits` 控制 SDK 端对归档提取的资源检查。设置 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可以传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持不设 SDK 归档资源限制的默认行为;也可以将单个字段设置为 `None`,仅禁用该项限制。 +`archive_limits` 控制 SDK 侧针对归档提取的资源检查。将其设置为 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可以传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持不应用 SDK 归档资源限制的默认行为;也可以将单个字段设置为 `None`,仅禁用该项限制。 需要注意以下几点: - 新会话:`manifest=` 和 `snapshot=` 仅在运行器创建新沙箱会话时适用。 -- 恢复与快照:`session_state=` 重新连接到先前序列化的沙箱状态,而 `snapshot=` 使用已保存的工作区内容初始化新的沙箱会话。 -- 客户端专用选项:`options=` 取决于沙箱客户端;Docker 和许多托管客户端都要求提供它。 -- 注入的实时会话:如果传入正在运行的沙箱 `session`,由能力驱动的清单更新可以添加兼容的非挂载条目。它们不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;不能删除现有条目;不能替换条目类型;也不能添加或更改挂载条目。 -- 运行器 API:`SandboxAgent` 执行仍使用常规的 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API。 +- 恢复与快照:`session_state=` 会重新连接到先前序列化的沙箱状态,而 `snapshot=` 会使用已保存的工作区内容初始化新的沙箱会话。 +- 客户端专用选项:`options=` 取决于沙箱客户端;Docker 和许多托管客户端都需要该选项。 +- 注入的实时会话:如果传入正在运行的沙箱 `session`,由能力驱动的清单更新可以添加兼容的非挂载条目。它们不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;不能移除现有条目;不能替换条目类型;也不能添加或更改挂载条目。 +- 运行器 API:`SandboxAgent` 执行仍使用常规 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API。 ## 完整示例:编码任务 -以下编码风格示例是很好的默认起点: +以下编码风格示例是一个很好的默认起点: ```python import asyncio @@ -571,15 +571,15 @@ if __name__ == "__main__": ) ``` -请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用基于 shell 的微型仓库,因此可以在 Unix 本地运行中以确定性方式验证该示例。当然,您的实际任务仓库可以使用 Python、JavaScript 或任何其他技术。 +请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 shell 的小型仓库,因此可以在 Unix 本地运行中以确定性方式验证该示例。实际任务仓库当然可以使用 Python、JavaScript 或任何其他语言。 ## 常见模式 -请从上面的完整示例开始。在许多情况下,可以保持同一个 `SandboxAgent` 不变,只更改沙箱客户端、沙箱会话来源或工作区来源。 +请从上面的完整示例开始。很多情况下,可以保持同一个 `SandboxAgent` 不变,只更改沙箱客户端、沙箱会话来源或工作区来源。 ### 沙箱客户端的切换 -保持智能体定义不变,只更改运行配置。当您需要容器隔离或镜像一致性时,请使用 Docker;当您需要由提供商管理执行时,请使用托管提供商。有关示例和提供商选项,请参阅[沙箱客户端](clients.md)。 +保持智能体定义不变,只更改运行配置。如果需要容器隔离或镜像一致性,请使用 Docker;如果需要由提供商管理执行,请使用托管提供商。有关示例和提供商选项,请参阅[沙箱客户端](clients.md)。 ### 工作区的覆盖 @@ -603,11 +603,11 @@ run_config = RunConfig( ) ``` -当同一个智能体角色应针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了使用 `default_manifest` 而非一次性覆盖项的相同模式。 +当同一智能体角色应针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了相同模式,但使用的是 `default_manifest`,而不是一次性覆盖。 ### 沙箱会话的注入 -当您需要显式控制生命周期、在运行后检查或复制输出时,请注入实时沙箱会话: +当需要显式控制生命周期、在运行后检查或复制输出时,请注入实时沙箱会话: ```python from agents import Runner @@ -628,11 +628,11 @@ async with sandbox: ) ``` -当您希望在运行后检查工作区,或通过已经启动的沙箱会话进行流式传输时,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 +如果希望在运行后检查工作区,或通过已启动的沙箱会话进行流式传输,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 -### 从会话状态恢复 +### 会话状态的恢复 -如果您已经在 `RunState` 外部序列化了沙箱状态,请让运行器从该状态重新连接: +如果已在 `RunState` 外部序列化沙箱状态,可以让运行器从该状态重新连接: ```python from agents.run import RunConfig @@ -649,13 +649,13 @@ run_config = RunConfig( ) ``` -当沙箱状态保存在您自己的存储或作业系统中,并且希望 `Runner` 直接从中恢复时,请使用此模式。有关序列化和反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 +如果沙箱状态位于您自己的存储或作业系统中,并且希望 `Runner` 直接从中恢复,请使用此模式。有关序列化/反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 -会话状态序列化会省略原生 `host_path` 值。要恢复由主机支持的授权,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单;否则,恢复会在沙箱启动前失败。切勿从序列化输入或其他不受信任的输入派生主机路径。 +会话状态序列化会省略原生 `host_path` 值。若要恢复由主机支持的授权,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前可信清单;否则会在沙箱启动前恢复失败。切勿从序列化输入或其他不可信输入派生主机路径。 -### 从快照启动 +### 快照的使用 -使用已保存的文件和工件初始化新沙箱: +使用已保存的文件和产物初始化新沙箱: ```python from pathlib import Path @@ -672,11 +672,11 @@ run_config = RunConfig( ) ``` -当新运行应从已保存的工作区内容开始,而不是仅使用 `agent.default_manifest` 时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 +当创建新沙箱会话的运行应从已保存的工作区内容开始,而不仅仅使用 `agent.default_manifest` 时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 ### 从 Git 加载技能 -将本地技能来源替换为仓库支持的来源: +将本地技能来源替换为由仓库支持的来源: ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -687,11 +687,11 @@ capabilities = Capabilities.default() + [ ] ``` -当技能包有自己的发布周期,或应在多个沙箱之间共享时,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 +如果技能包有自己的发布节奏,或应在多个沙箱之间共享,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 -### 作为工具公开 +### 工具形式的公开 -工具智能体既可以拥有自己的沙箱边界,也可以复用父运行中的实时沙箱。对于快速的只读探索智能体,复用很有用:它可以检查父智能体正在使用的确切工作区,而无需承担创建、填充或快照另一个沙箱的开销。 +工具智能体既可以拥有自己的沙箱边界,也可以复用父运行中的实时沙箱。复用适用于快速的只读探索智能体:它可以检查父运行正在使用的确切工作区,而无需为创建、填充或快照另一个沙箱付出成本。 ```python from agents import Runner @@ -773,9 +773,9 @@ async with sandbox: ) ``` -此处,父智能体以 `coordinator` 身份运行,探索工具智能体则在同一个实时沙箱会话中以 `explorer` 身份运行。`pricing_packet/` 条目可由 `other` 用户读取,因此探索智能体可以快速检查这些条目,但没有写入权限位。`work/` 目录仅对协调智能体的用户/组可用,因此父智能体可以写入最终工件,而探索智能体保持只读。 +这里,父智能体以 `coordinator` 身份运行,探索工具智能体以 `explorer` 身份在同一个实时沙箱会话内运行。`pricing_packet/` 条目可由 `other` 用户读取,因此探索智能体可以快速检查它们,但没有写入权限位。`work/` 目录仅对协调器的用户/组可用,因此父智能体可以写入最终产物,而探索智能体保持只读。 -当工具智能体需要真正隔离时,请为其提供独立的沙箱 `RunConfig`: +当工具智能体需要真正的隔离时,请为其提供自己的沙箱 `RunConfig`: ```python from docker import from_env as docker_from_env @@ -801,11 +801,11 @@ rollout_agent.as_tool( ) ``` -当工具智能体应自由修改内容、运行不受信任的命令或使用不同后端/镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +当工具智能体应自由修改内容、运行不可信命令或使用不同后端/镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -### 与本地工具及 MCP 的组合 +### 与本地工具和 MCP 的组合 -在保留沙箱工作区的同时,仍可在同一个智能体上使用常规工具: +保留沙箱工作区,同时在同一智能体上继续使用常规工具: ```python from agents.sandbox import SandboxAgent @@ -820,46 +820,46 @@ agent = SandboxAgent( ) ``` -当工作区检查只是智能体工作的一部分时,请使用此模式。请参阅 [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)。 +如果工作区检查只是智能体工作的一部分,请使用此模式。请参阅 [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)。 ## 记忆 -当未来的沙箱智能体运行应从先前运行中学习时,请使用 `Memory` 能力。记忆与 SDK 的对话式 `Session` 记忆不同:它会将经验提炼到沙箱工作区内的文件中,供后续运行读取。 +如果未来的沙箱智能体运行应从先前运行中学习,请使用 `Memory` 能力。记忆不同于 SDK 的对话式 `Session` 记忆:它会将经验提炼为沙箱工作区内的文件,供后续运行读取。 有关设置、读取/生成行为、多轮对话和布局隔离,请参阅[智能体记忆](memory.md)。 ## 组合模式 -明确单智能体模式后,下一个设计问题是沙箱边界在更大系统中应位于何处。 +明确单智能体模式后,下一个设计问题就是沙箱边界在更大系统中的位置。 -沙箱智能体仍可与 SDK 的其他部分组合: +沙箱智能体仍可与 SDK 的其余部分组合: -- [任务转移](../handoffs.md):将文档密集型工作从非沙箱接收智能体转移给沙箱审核智能体。 -- [Agents as tools](../tools.md#agents-as-tools):将多个沙箱智能体作为工具公开,通常是在每次调用 `Agent.as_tool(...)` 时传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,从而让每个工具拥有自己的沙箱边界。 -- [MCP](../mcp.md) 和常规工具调用:沙箱能力可以与 `mcp_servers` 和普通 Python 工具共存。 +- [任务转移](../handoffs.md):将文档密集型工作从非沙箱接收智能体转移给沙箱审查智能体。 +- [Agents as tools](../tools.md#agents-as-tools):将多个沙箱智能体公开为工具,通常是在每次 `Agent.as_tool(...)` 调用中传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,以便每个工具拥有自己的沙箱边界。 +- [MCP](../mcp.md) 和常规函数工具:沙箱能力可以与 `mcp_servers` 和普通 Python 工具共存。 - [运行智能体](../running_agents.md):沙箱运行仍使用常规 `Runner` API。 以下两种模式尤其常见: - 非沙箱智能体仅针对工作流中需要工作区隔离的部分,将任务转移给沙箱智能体 -- 编排智能体将多个沙箱智能体作为工具公开,通常为每次 `Agent.as_tool(...)` 调用提供独立的沙箱 `RunConfig`,从而让每个工具获得自己的隔离工作区 +- 编排器将多个沙箱智能体公开为工具,通常为每次 `Agent.as_tool(...)` 调用分别提供一个沙箱 `RunConfig`,使每个工具都有自己的隔离工作区 ### 轮次与沙箱运行 -分别说明任务转移和智能体工具调用会更容易理解。 +分别解释任务转移和智能体工具调用会更清晰。 -使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活动智能体会发生变化,但运行不会变成嵌套运行。如果非沙箱接收智能体将任务转移给沙箱审核智能体,则同一运行中的下一次模型调用会针对沙箱智能体进行准备,而该沙箱智能体会成为执行下一轮的智能体。换言之,任务转移会改变由哪个智能体负责同一次运行的下一轮。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 +使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活跃智能体会发生变化,但运行不会变成嵌套运行。如果非沙箱接收智能体将任务转移给沙箱审查智能体,则同一次运行中的下一次模型调用会为沙箱智能体做准备,并由该沙箱智能体执行下一个轮次。换言之,任务转移会改变由哪个智能体负责同一次运行的下一个轮次。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 -使用 `Agent.as_tool(...)` 时,关系有所不同。外层编排智能体使用一个外层轮次来决定调用工具,该工具调用会为沙箱智能体启动嵌套运行。嵌套运行拥有自己的轮次循环、`max_turns`、审批,并且通常拥有自己的沙箱 `RunConfig`。它可能在一个嵌套轮次内完成,也可能需要多个轮次。从外层编排智能体的角度看,所有这些工作仍隐藏在一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +使用 `Agent.as_tool(...)` 时,两者的关系有所不同。外层编排器使用一个外层轮次来决定调用工具,而该工具调用会为沙箱智能体启动一个嵌套运行。嵌套运行拥有自己的轮次循环、`max_turns`、审批,通常还有自己的沙箱 `RunConfig`。它可能在一个嵌套轮次中完成,也可能需要多个轮次。从外层编排器的角度看,所有这些工作仍位于一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -审批行为也遵循相同的区分: +审批行为也遵循相同的划分: -- 使用任务转移时,审批保留在同一个顶层运行中,因为沙箱智能体现已成为该运行中的活动智能体 +- 使用任务转移时,审批仍位于同一个顶层运行中,因为沙箱智能体此时已成为该运行中的活跃智能体 - 使用 `Agent.as_tool(...)` 时,沙箱工具智能体内部触发的审批仍会显示在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 ## 延伸阅读 - [快速入门](../sandbox_agents.md):运行一个沙箱智能体。 - [沙箱客户端](clients.md):选择本地、Docker、托管和挂载选项。 -- [智能体记忆](memory.md):保留和复用先前沙箱运行中的经验。 +- [智能体记忆](memory.md):保留并复用先前沙箱运行中的经验。 - [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):可运行的本地、编码、记忆、任务转移和智能体组合模式。 \ No newline at end of file diff --git a/docs/zh/sandbox/memory.md b/docs/zh/sandbox/memory.md index 8df63cd7a4..5b05f66197 100644 --- a/docs/zh/sandbox/memory.md +++ b/docs/zh/sandbox/memory.md @@ -4,23 +4,23 @@ search: --- # 智能体记忆 -记忆让未来的沙盒智能体运行能够从先前运行中学习。它与 SDK 的对话式 [`Session`](../sessions/index.md) 记忆分开,后者用于存储消息历史。记忆会将先前运行中的经验提炼为沙盒工作区中的文件。 +记忆可让未来的沙盒智能体运行从先前的运行中学习。它独立于 SDK 的对话式 [`Session`](../sessions/index.md) 记忆,后者用于存储消息历史记录。记忆会将先前运行中的经验提炼为沙盒工作区中的文件。 !!! warning "Beta 功能" - 沙盒智能体处于 Beta 阶段。在正式可用之前,API、默认值和支持能力的细节可能会发生变化,并且随着时间推移会提供更高级的功能。 + 沙盒智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,未来也将提供更高级的功能。 -记忆可以为未来运行降低三类成本: +记忆可以降低未来运行中的三类成本: -1. 智能体成本:如果智能体花了很长时间才完成某个工作流,下一次运行应该需要更少探索。这可以减少 token 使用量和完成时间。 -2. 用户成本:如果用户纠正了智能体,或表达了偏好,未来运行可以记住这些反馈。这可以减少人工干预。 -3. 上下文成本:如果智能体之前完成过一项任务,而用户想在该任务基础上继续推进,用户就不需要找到之前的线程或重新输入全部上下文。这会让任务描述更短。 +1. 智能体成本:如果智能体花费很长时间才完成某个工作流,下一次运行所需的探索应该会更少。这可以减少 token 使用量和完成时间。 +2. 用户成本:如果用户纠正了智能体或表达了偏好,未来的运行可以记住这些反馈。这可以减少人工干预。 +3. 上下文成本:如果智能体之前完成过某项任务,而用户希望在此基础上继续推进,则用户无需查找先前的对话或重新输入所有上下文。这可以缩短任务描述。 -请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py),其中包含一个完整的两次运行代码示例:修复 bug、生成记忆、恢复快照,并在后续验证器运行中使用该记忆。请参阅 [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py),了解一个多轮、多智能体示例,其中包含独立的记忆布局。 +有关完整的两次运行代码示例,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)。该示例会修复一个错误、生成记忆、恢复快照,并在后续验证器运行中使用该记忆。有关采用独立记忆布局的多轮、多智能体代码示例,请参阅 [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py)。 -## 记忆启用 +## 记忆的启用 -将 `Memory()` 作为一项能力添加到沙盒智能体。 +将 `Memory()` 作为一项功能添加到沙盒智能体中。 ```python from pathlib import Path @@ -42,28 +42,28 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d ) ``` -如果启用了读取,`Memory()` 需要 `Shell()`,这样当注入的摘要不足够时,智能体就可以读取和搜索记忆文件。当启用实时记忆更新时(默认启用),它还需要 `Filesystem()`,这样如果智能体发现记忆已过时,或用户要求它更新记忆,智能体就可以更新 `memories/MEMORY.md`。 +如果启用了读取,`Memory()` 需要 `Shell()`,这样当注入的摘要信息不足时,智能体便可以读取和搜索记忆文件。启用实时记忆更新时(默认启用),还需要 `Filesystem()`,这样当智能体发现记忆已过时或用户要求更新记忆时,智能体便可以更新 `memories/MEMORY.md`。 -默认情况下,记忆产物存储在沙盒工作区的 `memories/` 下。要在后续运行中复用它们,请通过保持同一个实时沙盒会话,或从持久化的会话状态或快照恢复,来保留并复用整个已配置的记忆目录;全新的空沙盒会从空记忆开始。 +默认情况下,记忆产物存储在沙盒工作区的 `memories/` 下。要在后续运行中复用这些产物,请通过保持使用同一个实时沙盒会话,或从已持久化的会话状态或快照中恢复,来保留并复用整个已配置的记忆目录;全新的空白沙盒最初没有任何记忆。 -`Memory()` 同时启用读取记忆和生成记忆。对于应该读取记忆但不应生成新记忆的智能体,请使用 `Memory(generate=None)`:例如,内部智能体、子智能体、检查器,或运行不会增加太多信号的一次性工具智能体。当运行应该为之后生成记忆,但用户不希望该运行受现有记忆影响时,请使用 `Memory(read=None)`。 +`Memory()` 会同时启用记忆读取和生成。对于应读取记忆但不应生成新记忆的智能体,请使用 `Memory(generate=None)`——例如,由内部智能体、子智能体、检查器或一次性工具智能体执行的运行通常不会提供太多有价值的信息。如果运行应生成供日后使用的记忆,但用户不希望该运行受现有记忆影响,请使用 `Memory(read=None)`。 -## 记忆读取 +## 记忆的读取 -记忆读取采用渐进式披露。在运行开始时,SDK 会将一份小型摘要(`memory_summary.md`)注入到智能体的开发者提示词中,其中包含通常有用的提示、用户偏好和可用记忆。这会为智能体提供足够的上下文,以判断先前工作是否可能相关。 +记忆读取采用渐进式披露方式。在运行开始时,SDK 会将一个简短摘要(`memory_summary.md`)注入智能体的开发者提示词,其中包含普遍有用的技巧、用户偏好以及可用记忆。这可为智能体提供足够的上下文,使其能够判断先前工作是否可能相关。 -当先前工作看起来相关时,智能体会在已配置的记忆索引(`memories_dir` 下的 `MEMORY.md`)中搜索当前任务的关键词。只有当任务需要更多细节时,它才会打开已配置的 `rollout_summaries/` 目录下对应的先前 rollout 摘要。 +当先前工作看起来相关时,智能体会使用当前任务中的关键词,在已配置的记忆索引(`memories_dir` 下的 `MEMORY.md`)中进行搜索。只有在任务需要更多细节时,它才会打开已配置的 `rollout_summaries/` 目录下相应的先前运行摘要。 -记忆可能会过时。智能体会被指示仅将记忆作为指导,并信任当前环境。默认情况下,记忆读取启用 `live_update`,因此如果智能体发现记忆已过时,它可以在同一次运行中更新已配置的 `MEMORY.md`。当智能体应读取记忆但不应在运行期间修改记忆时,请禁用实时更新,例如该运行对延迟敏感时。 +记忆可能会过时。智能体会被要求仅将记忆视为参考,并以当前环境为准。默认情况下,记忆读取会启用 `live_update`,因此如果智能体发现记忆已过时,可以在同一次运行中更新已配置的 `MEMORY.md`。如果智能体应读取记忆但不应在运行期间修改记忆,请禁用实时更新,例如对延迟敏感的运行。 -## 记忆生成 +## 记忆的生成 -运行结束后,沙盒运行时会将该运行片段追加到一个对话文件中。累积的对话文件会在沙盒会话关闭时被处理。 +一次运行结束后,沙盒运行时会将该运行片段追加到对话文件中。累积的对话文件会在沙盒会话关闭时进行处理。 记忆生成分为两个阶段: -1. 阶段 1:对话提取。生成记忆的模型会处理一个累积的对话文件,并生成对话摘要。system、developer 和 reasoning 内容会被省略。如果对话过长,它会被截断以适配上下文窗口,同时保留开头和结尾。它还会生成原始记忆摘录:来自对话的紧凑笔记,供阶段 2 进行整合。 -2. 阶段 2:布局整合。整合智能体会读取某个记忆布局的原始记忆,在需要更多证据时打开对话摘要,并将模式提取到 `MEMORY.md` 和 `memory_summary.md` 中。 +1. 阶段 1:对话提取。记忆生成模型会处理一个累积的对话文件并生成对话摘要。系统、开发者和推理内容会被省略。如果对话过长,则会截断对话以适应上下文窗口,同时保留开头和结尾。模型还会生成原始记忆提取内容,即从对话中提取的精简笔记,供阶段 2 整合。 +2. 阶段 2:布局整合。整合智能体会读取某个记忆布局的原始记忆,在需要更多依据时打开对话摘要,并将其中的模式提取到 `MEMORY.md` 和 `memory_summary.md` 中。 默认工作区布局如下: @@ -97,13 +97,13 @@ memory = Memory( ) ``` -使用 `extra_prompt` 告诉记忆生成器哪些信号对你的用例最重要,例如 GTM 智能体所需的客户和公司详细信息。 +使用 `extra_prompt` 告知记忆生成器哪些信息对你的使用场景最为重要,例如面向市场推广(GTM)智能体的客户和公司详细信息。 -如果最近的原始记忆超过 `max_raw_memories_for_consolidation`(默认为 256),阶段 2 只保留来自最新对话的记忆,并移除较旧的记忆。新近程度基于对话上次更新的时间。这种遗忘机制有助于让记忆反映最新环境。 +如果近期原始记忆数量超过 `max_raw_memories_for_consolidation`(默认值为 256),阶段 2 将只保留最新对话中的记忆并删除较旧的记忆。新旧顺序以对话最后更新时间为准。这种遗忘机制有助于让记忆反映最新环境。 ## 多轮对话 -对于多轮沙盒聊天,请将常规 SDK `Session` 与同一个实时沙盒会话一起使用: +对于多轮沙盒聊天,请将常规 SDK `Session` 与同一个实时沙盒会话结合使用: ```python from agents import Runner, SQLiteSession @@ -132,18 +132,18 @@ async with sandbox: ) ``` -两次运行都会追加到同一个记忆对话文件,因为它们传入了同一个 SDK 对话会话(`session=conversation_session`),因此共享同一个 `session.session_id`。这不同于沙盒(`sandbox`),后者标识实时工作区,不会被用作记忆对话 ID。阶段 1 会在沙盒会话关闭时看到累积的对话,因此它可以从整个交流中提取记忆,而不是从两个孤立的轮次中提取。 +两次运行都会传入同一个 SDK 对话会话(`session=conversation_session`),因此共享同一个 `session.session_id`。所以,两次运行都会追加到同一个记忆对话文件中。这不同于沙盒(`sandbox`),后者用于标识实时工作区,不会用作记忆对话 ID。沙盒会话关闭时,阶段 1 会处理累积的对话,因此可以从整个交流过程而不是两个孤立的轮次中提取记忆。 -如果你希望多个 `Runner.run(...)` 调用成为同一个记忆对话,请在这些调用之间传入一个稳定标识符。当记忆将某次运行与一个对话关联时,它会按以下顺序解析: +如果你希望多次 `Runner.run(...)` 调用形成一次记忆对话,请在这些调用中传入一个稳定标识符。当记忆将一次运行与某个对话关联时,会按以下顺序解析: -1. `conversation_id`,当你将其传给 `Runner.run(...)` 时 +1. `conversation_id`,当你将其传入 `Runner.run(...)` 时 2. `session.session_id`,当你传入 SDK `Session`(例如 `SQLiteSession`)时 -3. `RunConfig.group_id`,当上述两者都不存在时 -4. 生成的每次运行 ID,当不存在稳定标识符时 +3. `RunConfig.group_id`,当上述两者均不存在时 +4. 为每次运行生成的 ID,当不存在稳定标识符时 -## 用于隔离不同智能体记忆的不同布局 +## 不同智能体的记忆隔离布局 -记忆隔离基于 `MemoryLayoutConfig`,而不是智能体名称。具有相同布局和相同记忆对话 ID 的智能体会共享一个记忆对话和一份整合后的记忆。具有不同布局的智能体会保留独立的 rollout 文件、原始记忆、`MEMORY.md` 和 `memory_summary.md`,即使它们共享同一个沙盒工作区也是如此。 +记忆隔离基于 `MemoryLayoutConfig`,而不是智能体名称。具有相同布局和相同记忆对话 ID 的智能体会共享一个记忆对话和一份整合后的记忆。具有不同布局的智能体则会分别保存各自的运行文件、原始记忆、`MEMORY.md` 和 `memory_summary.md`,即使它们共享同一个沙盒工作区也是如此。 当多个智能体共享一个沙盒但不应共享记忆时,请使用独立布局: @@ -186,4 +186,4 @@ gtm_session = SQLiteSession("gtm-q2-pipeline-review") engineering_session = SQLiteSession("eng-invoice-test-fix") ``` -这可以防止 GTM 分析被整合到工程缺陷修复记忆中,反之亦然。 \ No newline at end of file +这样可以防止 GTM 分析被整合到工程错误修复记忆中,反之亦然。 \ No newline at end of file diff --git a/docs/zh/sandbox_agents.md b/docs/zh/sandbox_agents.md index e48e3fb6c9..e362504e48 100644 --- a/docs/zh/sandbox_agents.md +++ b/docs/zh/sandbox_agents.md @@ -4,19 +4,19 @@ search: --- # 快速入门 -!!! warning "测试版功能" +!!! warning "Beta 功能" - 沙箱智能体目前处于测试阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且未来将逐步提供更高级的功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认设置和支持的能力可能会发生变化,后续也将逐步提供更高级的功能。 -现代智能体能够在文件系统中操作真实文件时,往往能发挥最佳效果。Agents SDK 中的**沙箱智能体**为模型提供持久化工作区,使其能够检索大型文档集、编辑文件、运行命令、生成产物,并从已保存的沙箱状态继续工作。 +现代智能体只有能够操作文件系统中的真实文件,才能发挥最佳效果。Agents SDK 中的**沙箱智能体**为模型提供持久化工作区,使其能够检索大型文档集、编辑文件、运行命令、生成产物,并从保存的沙箱状态继续工作。 -SDK 为你提供这一执行框架,无需自行整合文件暂存、文件系统工具、shell 访问、沙箱生命周期、快照以及特定于提供商的适配代码。你可以继续使用常规的 `Agent` 和 `Runner` 流程,然后为工作区添加 `Manifest`,为沙箱原生工具添加 capabilities,并使用 `SandboxRunConfig` 指定工作运行的位置。 +SDK 提供了这套执行框架,无需你自行整合文件暂存、文件系统工具、Shell 访问、沙箱生命周期、快照以及特定于提供商的适配逻辑。你可以继续使用常规的 `Agent` 和 `Runner` 流程,然后添加用于工作区的 `Manifest`、沙箱原生工具所需的能力,以及用于指定工作运行位置的 `SandboxRunConfig`。 ## 前置条件 - Python 3.10 或更高版本 - 基本熟悉 OpenAI Agents SDK -- 沙箱客户端。对于本地开发,请从 `UnixLocalSandboxClient` 开始。 +- 一个沙箱客户端。进行本地开发时,可从 `UnixLocalSandboxClient` 开始。 ## 安装 @@ -34,7 +34,7 @@ pip install "openai-agents[docker]" ## 本地沙箱智能体的创建 -此代码示例将本地仓库存放到 `repo/` 下,延迟加载本地技能,并允许运行器为本次运行创建 Unix 本地沙箱会话。 +此代码示例将本地仓库存放到 `repo/` 下,按需延迟加载本地技能,并让运行器为本次运行创建 Unix 本地沙箱会话。 ```python import asyncio @@ -94,24 +94,24 @@ if __name__ == "__main__": asyncio.run(main()) ``` -请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 shell 的小型仓库,因此可以在多次 Unix 本地运行中以确定性方式验证该代码示例。 +请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 Shell 的微型仓库,因此可在不同的 Unix 本地运行中以确定性方式验证该代码示例。 ## 关键选项 基本运行正常后,大多数人接下来会使用以下选项: -- `default_manifest`:全新沙箱会话所使用的文件、仓库、目录和挂载项 -- `instructions`:应适用于所有提示词的简短工作流规则 -- `base_instructions`:用于替换 SDK 沙箱提示词的高级应急选项 -- `capabilities`:沙箱原生工具,例如文件系统编辑/图像检查、shell、技能、记忆和压缩 -- `run_as`:面向模型的工具所使用的沙箱用户身份 +- `default_manifest`:用于新沙箱会话的文件、仓库、目录和挂载 +- `instructions`:应适用于不同提示词的简短工作流规则 +- `base_instructions`:用于替换 SDK 沙箱提示词的高级扩展入口 +- `capabilities`:沙箱原生工具,例如文件系统编辑、图像检查、Shell、技能、记忆,以及 SDK 的压缩机制 +- `run_as`:面向模型的工具执行时使用的沙箱用户账户 - `SandboxRunConfig.client`:沙箱后端 -- `SandboxRunConfig.session`、`session_state` 或 `snapshot`:后续运行如何重新连接到先前的工作 +- `SandboxRunConfig.session`、`session_state` 或 `snapshot`:后续运行重新连接到先前工作的方式 ## 后续步骤 -- [概念](sandbox/guide.md):了解清单、capabilities、权限、快照、运行配置和组合模式。 +- [概念](sandbox/guide.md):了解清单、能力、权限、快照、运行配置和组合模式。 - [沙箱客户端](sandbox/clients.md):选择 Unix 本地、Docker、托管提供商和挂载策略。 -- [智能体记忆](sandbox/memory.md):保留并复用以往沙箱运行中获得的经验。 +- [智能体记忆](sandbox/memory.md):保留并复用之前沙箱运行中获得的经验。 -如果 shell 访问只是偶尔使用的工具,请先从[工具指南](tools.md)中的托管 shell 开始。如果工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分,请使用沙箱智能体。 \ No newline at end of file +如果 Shell 访问只是你偶尔使用的一项工具,请先从[工具指南](tools.md)中的托管 Shell 开始。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,请使用沙箱智能体。 \ No newline at end of file diff --git a/docs/zh/sessions/advanced_sqlite_session.md b/docs/zh/sessions/advanced_sqlite_session.md index 923ca0f63d..7bc897967b 100644 --- a/docs/zh/sessions/advanced_sqlite_session.md +++ b/docs/zh/sessions/advanced_sqlite_session.md @@ -4,15 +4,15 @@ search: --- # 高级 SQLite 会话 -`AdvancedSQLiteSession` 是基础 `SQLiteSession` 的增强版本,提供高级会话管理功能,包括会话分支、详细的使用情况分析和结构化会话查询。 +`AdvancedSQLiteSession` 是基础版 `SQLiteSession` 的增强版本,提供高级对话管理功能,包括对话分支、详细的用量分析和结构化对话查询。 ## 功能 -- **会话分支**:从任意用户消息创建不同的会话路径 -- **使用情况追踪**:提供每轮详细的 token 使用情况分析及完整的 JSON 明细 -- **结构化查询**:按轮次获取会话、工具使用情况统计等信息 +- **对话分支**:从任意用户消息创建不同的对话路径 +- **用量追踪**:按轮次提供详细的 token 用量分析及完整的 JSON 明细 +- **结构化查询**:按轮次获取对话、工具使用统计信息等 - **分支管理**:独立切换和管理分支 -- **消息结构元数据**:追踪消息类型、工具使用情况和会话流程 +- **消息结构元数据**:追踪消息类型、工具使用情况和对话流程 ## 快速开始 @@ -84,16 +84,16 @@ session = AdvancedSQLiteSession( ### 参数 -- `session_id` (str):会话 session 的唯一标识符 -- `db_path` (str | Path):SQLite 数据库文件的路径。默认值为 `:memory:`,用于内存存储 -- `create_tables` (bool):是否自动创建高级数据表。默认值为 `False` -- `logger` (logging.Logger | None):会话的自定义日志记录器。默认使用模块日志记录器 +- `session_id`(str):对话会话的唯一标识符 +- `db_path`(str | Path):SQLite 数据库文件的路径。默认为 `:memory:`,即使用内存存储 +- `create_tables`(bool):是否自动创建高级表。默认为 `False` +- `logger`(logging.Logger | None):会话的自定义日志记录器。默认为模块日志记录器 -## 使用情况追踪 +## 用量追踪 -AdvancedSQLiteSession 通过存储每轮会话的 token 使用情况数据,提供详细的使用情况分析。**这完全取决于是否在每次智能体运行后调用 `store_run_usage` 方法。** +AdvancedSQLiteSession 通过存储每个对话轮次的 token 用量数据,提供详细的用量分析。**这完全依赖于在每次智能体运行后调用 `store_run_usage` 方法。** -### 使用情况数据存储 +### 用量数据存储 ```python # After each agent run, store the usage data @@ -107,7 +107,7 @@ await session.store_run_usage(result) # - Detailed JSON token information (if available) ``` -### 使用情况统计检索 +### 用量统计信息检索 ```python # Get session-level usage (all branches) @@ -135,9 +135,9 @@ for turn_data in turn_usage: turn_2_usage = await session.get_turn_usage(user_turn_number=2) ``` -## 会话分支 +## 对话分支 -AdvancedSQLiteSession 的一项关键功能是能够从任意用户消息创建会话分支,以便探索不同的会话路径。 +AdvancedSQLiteSession 的主要功能之一是能够从任意用户消息创建对话分支,让你可以探索不同的对话路径。 ### 分支创建 @@ -165,7 +165,7 @@ branch_id = await session.create_branch_from_content( ) ``` -在一个会话 ID 的整个生命周期内,分支 ID 都是唯一的。删除分支或清除会话会移除其会话数据,但不会使之前使用过的分支 ID 再次可用;创建其他分支时,请使用新名称。 +分支 ID 在会话 ID 的整个生命周期内保持唯一。删除分支或清除会话会移除其对话数据,但不会让之前使用过的分支 ID 再次可用;创建其他分支时,请使用新名称。 ### 分支管理 @@ -219,9 +219,9 @@ await session.store_run_usage(result) ## 结构化查询 -AdvancedSQLiteSession 提供多种方法,用于分析会话的结构和内容。 +AdvancedSQLiteSession 提供了多种用于分析对话结构和内容的方法。 -### 会话分析 +### 对话分析 ```python # Get conversation organized by turns @@ -249,15 +249,15 @@ for turn in matching_turns: 会话会自动追踪消息结构,包括: -- 消息类型(用户、助手、工具调用等) -- 工具调用对应的工具名称 +- 消息类型值(`user`、`assistant`、`tool_call` 等) +- 工具调用的工具名称 - 轮次编号和序列编号 -- 分支关联关系 +- 分支关联 - 时间戳 ## 数据库架构 -AdvancedSQLiteSession 在基础 SQLite 架构之上新增了三个表: +AdvancedSQLiteSession 在基础 SQLite 架构上扩展了三个附加表: ### message_structure 表 @@ -288,7 +288,7 @@ CREATE TABLE branch_reservations ( ); ``` -此表以原子方式预留分支 ID,也包括所复制前缀为空的分支。删除分支和清除会话后,预留记录仍会保留,从而防止过期的会话实例将历史记录合并到之后复用同一 ID 的分支中。 +此表以原子方式预留分支 ID,包括复制前缀为空的分支。删除分支或清除会话时,预留记录都会保留,从而防止过期的会话实例将历史记录合并到之后复用同一 ID 的分支中。 ### turn_usage 表 @@ -312,7 +312,7 @@ CREATE TABLE turn_usage ( ## 完整示例 -请查看[完整示例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py),全面了解所有功能。 +请查看[完整示例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py),了解所有功能的综合演示。 ## API 参考 diff --git a/docs/zh/sessions/index.md b/docs/zh/sessions/index.md index f72d8150b6..b67c17fbd1 100644 --- a/docs/zh/sessions/index.md +++ b/docs/zh/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 会话 -Agents SDK提供内置会话内存,可在多次智能体运行之间自动维护对话历史记录,无需在不同轮次之间手动处理`.to_input_list()`。 +Agents SDK提供内置会话记忆,可在多次智能体运行之间自动维护对话历史记录,无需在各轮之间手动处理`.to_input_list()`。 -会话存储特定会话的对话历史记录,使智能体无需显式手动管理内存即可保持上下文。这对于构建聊天应用或多轮对话尤其有用,因为在这些场景中,你希望智能体能够记住先前的交互。 +会话存储特定会话的对话历史记录,使智能体无需显式的手动记忆管理即可保持上下文。这对于构建希望智能体记住先前交互的聊天应用或多轮对话尤其有用。 -如果希望由 SDK 为你管理客户端内存,请使用会话。在同一次运行中,会话不能与`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果希望改用由OpenAI服务端管理的延续机制,请选择其中一种机制,而不要在其上叠加会话。 +如果希望由SDK为你管理客户端侧记忆,请使用会话。在同一次运行中,会话不能与运行级续接选项`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果你希望改用由OpenAI服务器管理的续接,请选择其中一种机制,而不要在其上叠加会话。 ## 快速入门 @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 中断运行的同会话恢复 +## 使用同一会话恢复中断的运行 -如果运行因等待批准而暂停,请使用同一个会话实例(或指向同一底层存储的另一个会话实例)恢复运行,以便恢复后的轮次能够延续相同的已存储对话历史记录。 +如果运行因等待审批而暂停,请使用同一会话实例恢复运行(或使用配置了相同会话ID和相同底层存储后端的另一个实例),以便恢复后的轮次继续沿用同一份已存储的对话历史记录。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -63,31 +63,31 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## 核心会话行为 +## 会话的核心行为 -启用会话内存后: +启用会话记忆后: -1. **每次运行之前**:运行器会自动检索会话的对话历史记录,并将其添加到输入项之前。 -2. **每次运行之后**:运行期间生成的所有新项目(用户输入、助手回复、工具调用等)都会自动存储到会话中。 -3. **上下文保留**:之后使用同一会话的每次运行都会包含完整的对话历史记录,使智能体能够保持上下文。 +1. **每次运行前**:运行器会自动检索该会话的对话历史记录,并将其添加到输入项之前。 +2. **每次运行后**:运行期间生成的所有新项目(用户输入、助手响应、工具调用等)都会自动存储在会话中。 +3. **上下文保留**:使用同一会话的每次后续运行都会包含完整的对话历史记录,使智能体能够保持上下文。 -这样便无需手动调用`.to_input_list()`并在不同运行之间管理对话状态。 +这样便无需手动调用`.to_input_list()`以及在运行之间管理对话状态。 ## 历史记录与新输入的合并控制 -传入会话时,运行器通常会按以下顺序准备模型输入: +传入会话时,运行器通常按以下顺序准备模型输入: 1. 会话历史记录(从`session.get_items(...)`检索) -2. 新轮次输入 +2. 新一轮输入 -使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可在调用模型之前自定义该合并步骤。该回调接收两个列表: +使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可在调用模型之前自定义该合并步骤。回调接收两个列表: - `history`:检索到的会话历史记录(已规范化为输入项格式) - `new_input`:当前轮次的新输入项 返回应发送给模型的最终输入项列表。 -该回调接收这两个列表的副本,因此你可以安全地修改它们。返回的列表控制该轮次的模型输入,但 SDK 仍然只会持久化属于新轮次的项目。因此,重新排序或筛选旧历史记录不会导致旧会话项再次作为新输入保存。 +回调接收的是两个列表的副本,因此你可以安全地修改它们。返回的列表控制该轮次的模型输入,但SDK仍只会持久化属于新轮次的项目。因此,对旧历史记录重新排序或进行筛选不会导致旧会话项目再次作为新输入保存。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -如果需要自定义历史记录的裁剪、重新排序或选择性纳入方式,同时又不改变会话存储项目的方式,请使用此功能。如果需要在调用模型前立即进行最后一次处理,请使用[运行智能体指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 +当你需要自定义历史记录的删减、重新排序或选择性包含方式,但不想更改会话存储项目的方式时,请使用此功能。如果需要在模型调用前立即进行后续的最终处理,请使用[智能体运行指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 ## 检索历史记录限制 -使用[`SessionSettings`][agents.memory.SessionSettings]控制每次运行前获取的历史记录量。 +使用[`SessionSettings`][agents.memory.SessionSettings]控制每次运行前获取的历史记录数量。 -- `SessionSettings(limit=None)`(默认):检索所有可用的会话项 +- `SessionSettings(limit=None)`(默认):检索所有可用的会话项目 - `SessionSettings(limit=N)`:仅检索最近的`N`个项目 -你可以通过[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]按运行应用此设置: +你可以通过[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]将其应用于每次运行: ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,9 +134,9 @@ result = await Runner.run( ) ``` -如果会话实现提供默认会话设置,`RunConfig.session_settings`会在该次运行中覆盖所有非`None`值。这适用于较长的对话,可在不更改会话默认行为的情况下限制检索量。 +如果你的会话实现提供默认会话设置,则`RunConfig.session_settings`中每个非`None`值都会覆盖该次运行对应的默认值。对于长对话,如果希望限制检索数量而不改变会话的默认行为,此功能非常有用。 -## 内存操作 +## 记忆操作 ### 基本操作 @@ -165,9 +165,9 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 使用 pop_item 进行修正 +### 基于pop_item的更正 -当需要撤销或修改对话中的最后一个项目时,`pop_item`方法尤其有用: +当你希望撤销或修改对话中的最后一个项目时,`pop_item`方法尤其有用: ```python from agents import Agent, Runner, SQLiteSession @@ -198,32 +198,32 @@ print(f"Agent: {result.final_output}") ## 内置会话实现 -SDK 针对不同使用场景提供了多种会话实现: +SDK针对不同用例提供了多种会话实现: ### 内置会话实现的选择 -阅读下方详细示例之前,可使用此表选择一个起点。 +在阅读下方的详细示例之前,可使用此表选择起点。 -| 会话类型 | 最适用场景 | 说明 | +| 会话类型 | 适用场景 | 说明 | | --- | --- | --- | -| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可使用文件或内存作为后端 | -| `AsyncSQLiteSession` | 通过`aiosqlite`使用异步 SQLite | 支持异步驱动程序的扩展后端 | -| `RedisSession` | 跨工作进程或服务共享内存 | 适合低延迟分布式部署 | -| `SQLAlchemySession` | 使用现有数据库的生产应用 | 支持 SQLAlchemy 所支持的数据库 | -| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;使用原子序列计数器保证顺序 | -| `DaprSession` | 使用 Dapr 边车的云原生部署 | 支持多种状态存储以及 TTL 和一致性控制 | -| `OpenAIConversationsSession` | 由OpenAI服务端管理的存储 | 基于OpenAI Conversations API的历史记录 | -| `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 对另一种会话后端的封装 | -| `AdvancedSQLiteSession` | 需要分支和分析功能的 SQLite | 功能集较为丰富;请参阅专属页面 | -| `EncryptedSession` | 在另一种会话之上提供加密和 TTL | 封装器;请先选择底层后端 | +| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可基于文件或内存 | +| `AsyncSQLiteSession` | 使用`aiosqlite`的异步SQLite | 支持异步驱动程序的扩展后端 | +| `RedisSession` | 跨工作进程或服务共享记忆 | 适合低延迟分布式部署 | +| `SQLAlchemySession` | 使用现有数据库的生产应用 | 适用于SQLAlchemy支持的数据库 | +| `MongoDBSession` | 已使用MongoDB或需要多进程存储的应用 | 异步pymongo;使用原子序列计数器排序 | +| `DaprSession` | 使用Dapr边车的云原生部署 | 支持多种状态存储以及TTL和一致性控制 | +| `OpenAIConversationsSession` | OpenAI中的服务器托管存储 | 由OpenAI Conversations API支持的历史记录 | +| `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 对另一会话后端的封装 | +| `AdvancedSQLiteSession` | SQLite以及分支和分析 | 功能集较为丰富;请参阅专门页面 | +| `EncryptedSession` | 在另一会话上添加加密和TTL | 封装实现;请先选择底层后端 | -某些实现具有包含更多详细信息的专属页面,其链接已内嵌在相应小节中。 +部分实现有提供更多详细信息的专门页面,这些页面已在相应小节中以内联方式链接。 -如果你正在为 ChatKit 实现 Python 服务,请使用`chatkit.store.Store`实现来持久化 ChatKit 的线程和项目。`SQLAlchemySession`等Agents SDK会话负责管理 SDK 侧的对话历史记录,但不能直接替代 ChatKit 的存储。请参阅[`chatkit-python`中的 ChatKit 数据存储实现指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 +如果你正在为ChatKit实现Python服务器,请使用`chatkit.store.Store`实现来持久化ChatKit的线程和项目。`SQLAlchemySession`等Agents SDK会话管理SDK侧的对话历史记录,但不能直接替代ChatKit的存储。请参阅[`chatkit-python` ChatKit数据存储实现指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 -### OpenAI Conversations API 会话 +### OpenAI Conversations API会话 -通过`OpenAIConversationsSession`使用[OpenAI的 Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 +通过`OpenAIConversationsSession`使用[OpenAI的Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -257,9 +257,9 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses 压缩会话 +### OpenAI Responses压缩会话 -使用`OpenAIResponsesCompactionSession`通过 Responses API(`responses.compact`)压缩已存储的对话历史记录。它会封装底层会话,并可根据`should_trigger_compaction`在每个轮次后自动进行压缩。请勿用它封装`OpenAIConversationsSession`;这两项功能采用不同的方式管理历史记录。 +使用`OpenAIResponsesCompactionSession`通过Responses API(`responses.compact`)压缩已存储的对话历史记录。它封装了底层会话,并可在每轮结束后根据`should_trigger_compaction`自动进行压缩。不要使用它封装`OpenAIConversationsSession`;这两项功能以不同方式管理历史记录。 #### 典型用法(自动压缩) @@ -278,17 +278,17 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -默认情况下,达到候选阈值后,每个轮次结束时都会运行压缩。 +默认情况下,SDK会在每轮结束后检查待压缩内容是否达到阈值,并仅在达到阈值时进行压缩。 -当你已通过 Responses API 响应 ID 串联各轮次时,`compaction_mode="previous_response_id"`效果最佳。`compaction_mode="input"`则会根据当前会话项重新构建压缩请求,适用于响应链不可用或希望将会话内容作为权威数据源的情况。默认值`"auto"`会选择最安全的可用选项。 +`compaction_mode="previous_response_id"`使用压缩会话保留的Responses API响应ID,并且在该响应链仍可用时效果最佳。`compaction_mode="input"`则根据当前会话项目重新构建压缩请求,适用于响应链不可用或希望以会话内容作为权威数据源的情况。默认的`"auto"`会选择最安全的可用选项。 -如果智能体使用`ModelSettings(store=False)`运行,Responses API 不会保留最后一次响应以供后续查找。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不依赖`previous_response_id`。完整示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 +如果智能体使用`ModelSettings(store=False)`运行,Responses API不会保留最后一个响应以供后续查询。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不依赖`previous_response_id`。完整示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 #### 自动压缩对流式传输的阻塞 -压缩会清除并重写会话历史记录,因此 SDK 会等待压缩完成后,才将运行视为完成。在流式传输模式下,如果压缩任务较重,这意味着`run.stream_events()`可能会在输出最后一个 token 后继续保持打开数秒。 +压缩会清除并重写会话历史记录,因此SDK会等待压缩完成后才将运行视为已完成。在流式传输模式下,如果压缩任务较重,这意味着在最后一个输出词元产生后,`run.stream_events()`可能仍会保持打开数秒。 -如果希望实现低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲期间)自行调用`run_compaction()`。你可以根据自己的标准决定何时强制压缩。 +如果你需要低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲时间)自行调用`run_compaction()`。你可以根据自己的条件决定何时强制压缩。 ```python from agents import Agent, Runner, SQLiteSession @@ -309,9 +309,9 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite 会话 +### SQLite会话 -使用 SQLite 的默认轻量级会话实现: +使用SQLite的默认轻量级会话实现: ```python from agents import SQLiteSession @@ -330,9 +330,9 @@ result = await Runner.run( ) ``` -### 异步 SQLite 会话 +### 异步SQLite会话 -如果希望使用由`aiosqlite`支持的 SQLite 持久化,请使用`AsyncSQLiteSession`。 +如果希望使用由`aiosqlite`支持的SQLite持久化,请使用`AsyncSQLiteSession`。 ```bash pip install aiosqlite @@ -347,9 +347,9 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis 会话 +### Redis会话 -使用`RedisSession`可在多个工作进程或服务之间共享会话内存。 +使用`RedisSession`可在多个工作进程或服务之间共享会话记忆。 ```bash pip install openai-agents[redis] @@ -368,11 +368,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)`会创建并拥有 Redis 客户端。调用`close()`后,会话将进入终止状态,后续会话操作会引发`RuntimeError`;重复或并发调用`close()`是安全的。如果应用已经管理 Redis 客户端,请通过`redis_client=...`直接构造`RedisSession(...)`。在这种情况下,`close()`不会执行任何操作,调用方仍拥有客户端,并且会话仍然可用。 +`from_url(...)`会创建并拥有Redis客户端。调用`close()`后,会话进入终止状态,后续会话操作将引发`RuntimeError`;重复或并发调用`close()`是安全的。如果应用已经管理Redis客户端,请直接使用`redis_client=...`构造`RedisSession(...)`。在这种情况下,`close()`不会执行任何操作,调用方仍拥有客户端,并且会话仍可使用。 -### SQLAlchemy 会话 +### SQLAlchemy会话 -使用 SQLAlchemy 所支持的任意数据库,为Agents SDK提供可用于生产环境的会话持久化: +使用SQLAlchemy支持的任意数据库,为Agents SDK提供可用于生产环境的会话持久化: ```python from agents.extensions.memory import SQLAlchemySession @@ -390,11 +390,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -有关详细文档,请参阅[SQLAlchemy 会话](sqlalchemy_session.md)。 +详细文档请参阅[SQLAlchemy会话](sqlalchemy_session.md)。 -### Dapr 会话 +### Dapr会话 -如果你已经运行 Dapr 边车,或希望会话存储能在不同状态存储后端之间迁移而无需更改智能体代码,请使用`DaprSession`。 +如果你已运行Dapr边车,或希望在不更改智能体代码的情况下切换配置的状态存储后端,请使用`DaprSession`。 ```bash pip install openai-agents[dapr] @@ -415,19 +415,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -注意: +注意事项: -- `from_address(...)`会为你创建并拥有 Dapr 客户端。如果应用已经管理 Dapr 客户端,请通过`dapr_client=...`直接构造`DaprSession(...)`。 -- 退出上下文或调用`close()`会使拥有客户端的会话进入终止状态;后续会话操作会引发`RuntimeError`,但重复或并发调用`close()`是安全的。使用注入客户端时,`close()`不会执行任何操作,会话仍然可用。 -- 传入`ttl=...`可在底层状态存储支持 TTL 时,使其自动让旧会话数据过期。 -- 当需要更强的写后读保证时,传入`consistency=DAPR_CONSISTENCY_STRONG`。 -- Dapr Python SDK 还会检查 HTTP 边车端点。在本地开发中,启动 Dapr 时,除了`dapr_address`中使用的 gRPC 端口外,还应指定`--dapr-http-port 3500`。 -- 有关包括本地组件和问题排查在内的完整设置演练,请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 +- `from_address(...)`会为你创建并拥有Dapr客户端。如果应用已经管理客户端,请直接使用`dapr_client=...`构造`DaprSession(...)`。 +- 退出上下文或调用`close()`后,拥有客户端的会话会进入终止状态;后续会话操作将引发`RuntimeError`,而重复或并发调用`close()`是安全的。使用注入的客户端时,`close()`不会执行任何操作,会话仍可使用。 +- 如果底层状态存储支持TTL,请传入`ttl=...`,以自动对会话数据应用TTL过期机制。 +- 如果需要更强的写后读保证,请传入`consistency=DAPR_CONSISTENCY_STRONG`。 +- Dapr Python SDK还会检查HTTP边车端点。在本地开发中,启动Dapr时,除`dapr_address`中使用的gRPC端口外,还应使用`--dapr-http-port 3500`。 +- 包含本地组件和故障排除的完整设置演练,请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 -### MongoDB 会话 +### MongoDB会话 -对于已经使用 MongoDB 或需要可横向扩展的多进程会话存储的应用,请使用`MongoDBSession`。 +对于已使用MongoDB或需要可横向扩展的多进程会话存储的应用,请使用`MongoDBSession`。 ```bash pip install openai-agents[mongodb] @@ -450,16 +450,16 @@ print(result.final_output) await session.close() ``` -注意: +注意事项: -- `from_uri(...)`会创建并拥有`AsyncMongoClient`,并在调用`session.close()`时将其关闭。调用`close()`后,拥有客户端的会话将进入终止状态,后续会话操作会引发`RuntimeError`。如果应用已经管理客户端,请通过`client=...`直接构造`MongoDBSession(...)`;在这种情况下,`session.close()`不会执行任何操作,生命周期管理和会话可用性由调用方负责。 -- 要连接到[MongoDB Atlas](https://www.mongodb.com/products/platform),只需向`from_uri(...)`传入`mongodb+srv://user:password@cluster.example.mongodb.net`URI,无需进行其他更改。 -- 系统会使用两个集合,其名称都可分别通过`sessions_collection=`(默认为`agent_sessions`)和`messages_collection=`(默认为`agent_messages`)进行配置。首次使用时会自动创建索引。每个消息文档都带有单调递增的`seq`计数器,可在并发写入进程和多个进程之间保持顺序。 -- 首次运行之前,使用`await session.ping()`验证连接。 +- `from_uri(...)`会创建并拥有`AsyncMongoClient`,并在`session.close()`时将其关闭。拥有客户端的会话在`close()`后会进入终止状态,后续会话操作将引发`RuntimeError`。如果应用已经管理客户端,请直接使用`client=...`构造`MongoDBSession(...)`;在这种情况下,`session.close()`不会执行任何操作,调用方仍负责客户端生命周期,并且会话仍可使用。 +- 将`mongodb+srv://user:password@cluster.example.mongodb.net` URI传递给`from_uri(...)`即可连接到[MongoDB Atlas](https://www.mongodb.com/products/platform),无需进行其他更改。 +- 系统会使用两个集合,二者的名称均可通过`sessions_collection=`(默认为`agent_sessions`)和`messages_collection=`(默认为`agent_messages`)进行配置。首次使用时会自动创建索引。每次非空的`add_items()`调用都会写入一个逻辑批次文档,其单调递增的`seq`会按该批次的最后一个项目对批次进行排序;旧版的逐项目消息文档仍可读取。逻辑批次必须符合MongoDB的单文档大小限制;过大的批次会以原子方式失败,不会存储部分批次。 +- 在首次运行前,使用`await session.ping()`验证连接。 -### 高级 SQLite 会话 +### 高级SQLite会话 -支持对话分支、用量分析和结构化查询的增强型 SQLite 会话: +增强型SQLite会话,支持对话分支、用量分析和结构化查询: ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -479,11 +479,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -有关详细文档,请参阅[高级 SQLite 会话](advanced_sqlite_session.md)。 +详细文档请参阅[高级SQLite会话](advanced_sqlite_session.md)。 ### 加密会话 -适用于任何会话实现的透明加密封装器: +适用于任意会话实现的透明加密封装: ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -506,36 +506,36 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -有关详细文档,请参阅[加密会话](encrypted_session.md)。 +详细文档请参阅[加密会话](encrypted_session.md)。 ### 其他会话类型 -还有少量其他内置选项。请参阅`examples/memory/`和`extensions/memory/`下的源代码。 +还有一些其他内置选项。请参阅`examples/memory/`以及`extensions/memory/`下的源代码。 -## 操作模式 +## 运维模式 -### 会话 ID 命名 +### 会话ID命名 -使用有意义的会话 ID 来帮助组织对话: +使用有意义的会话ID来帮助组织对话: - 基于用户:`"user_12345"` - 基于线程:`"thread_abc123"` - 基于上下文:`"support_ticket_456"` -### 内存持久化 +### 记忆持久化 -- 对临时对话使用内存 SQLite(`SQLiteSession("session_id")`) -- 对持久对话使用基于文件的 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) -- 需要基于`aiosqlite`的实现时,使用异步 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) -- 对共享的低延迟会话内存使用 Redis 后端会话(`RedisSession.from_url("session_id", url="redis://...")`) -- 对使用 SQLAlchemy 所支持现有数据库的生产系统,使用由 SQLAlchemy 提供支持的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) -- 对已经使用 MongoDB 或需要多进程、可横向扩展会话存储的应用,使用 MongoDB 会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) -- 对生产环境中的云原生部署,使用 Dapr 状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),支持 30 多种数据库后端,并内置遥测、追踪和数据隔离功能 +- 对于临时对话,使用内存SQLite(`SQLiteSession("session_id")`) +- 对于持久对话,使用基于文件的SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) +- 如果需要基于`aiosqlite`的实现,请使用异步SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) +- 对于共享的低延迟会话记忆,使用Redis支持的会话(`RedisSession.from_url("session_id", url="redis://...")`) +- 对于使用SQLAlchemy所支持现有数据库的生产系统,使用由SQLAlchemy提供支持的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) +- 对于已使用MongoDB或需要多进程、可横向扩展会话存储的应用,使用MongoDB会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) +- 对于生产级云原生部署,使用Dapr状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),它提供内置遥测、追踪和数据隔离,并支持30多种数据库后端 - 如果希望将历史记录存储在OpenAI Conversations API中,请使用由OpenAI托管的存储(`OpenAIConversationsSession()`) -- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)封装任意会话,以提供透明加密和基于 TTL 的过期机制 -- 对于更高级的使用场景,可考虑为其他生产系统(例如 Django)实现自定义会话后端 +- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)封装任意会话,以提供透明加密和基于TTL的过期机制 +- 对于更高级的用例,可考虑为其他生产系统(例如Django)实现自定义会话后端 -### 多会话 +### 多个会话 ```python from agents import Agent, Runner, SQLiteSession @@ -581,7 +581,7 @@ result2 = await Runner.run( ## 完整示例 -以下完整示例展示了会话内存的实际工作方式: +以下完整示例展示了会话记忆的实际运作方式: ```python import asyncio @@ -645,7 +645,7 @@ if __name__ == "__main__": ## 自定义会话实现 -你可以创建遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话内存: +你可以创建遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话记忆: ```python from agents.memory.session import SessionABC @@ -690,26 +690,26 @@ result = await Runner.run( ## 社区会话实现 -社区开发了更多会话实现: +社区已开发其他会话实现: -| 软件包 | 说明 | +| 软件包 | 描述 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 适用于 Django 所支持任意数据库(PostgreSQL、MySQL、SQLite 等)的基于 Django ORM 的会话 | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 基于Django ORM的会话,适用于Django支持的任意数据库(PostgreSQL、MySQL、SQLite等) | -如果你构建了会话实现,欢迎提交文档 PR,将其添加到此处! +如果你已构建会话实现,欢迎提交文档PR,将其添加到此处! -## API 参考 +## API参考 -有关详细的 API 文档,请参阅: +有关详细的API文档,请参阅: - [`Session`][agents.memory.session.Session] - 协议接口 - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API实现 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 压缩封装器 -- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础 SQLite 实现 -- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于`aiosqlite`的异步 SQLite 实现 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 后端会话实现 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 由 SQLAlchemy 提供支持的实现 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 后端会话实现 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状态存储实现 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析功能的增强型 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任何会话的加密封装器 \ No newline at end of file +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API压缩封装 +- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础SQLite实现 +- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于`aiosqlite`的异步SQLite实现 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis支持的会话实现 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 由SQLAlchemy提供支持的实现 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB支持的会话实现 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr状态存储实现 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析的增强型SQLite实现 +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任意会话的加密封装 \ No newline at end of file diff --git a/docs/zh/sessions/sqlalchemy_session.md b/docs/zh/sessions/sqlalchemy_session.md index ec2aaf0241..8528fea9ad 100644 --- a/docs/zh/sessions/sqlalchemy_session.md +++ b/docs/zh/sessions/sqlalchemy_session.md @@ -8,7 +8,7 @@ search: ## 安装 -SQLAlchemy 会话需要安装 `sqlalchemy` 可选依赖: +SQLAlchemy 会话需要 `openai-agents` 软件包中的 `sqlalchemy` 可选依赖 extra: ```bash pip install openai-agents[sqlalchemy] @@ -16,7 +16,7 @@ pip install openai-agents[sqlalchemy] ## 快速入门 -### 数据库 URL 的使用 +### 数据库 URL 最简单的入门方式: @@ -42,7 +42,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 现有引擎的使用 +### 现有引擎 对于已有 SQLAlchemy 引擎的应用程序: @@ -73,9 +73,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 非 ASCII 文本的存储 +## 非 ASCII 文本存储 -默认情况下,`SQLAlchemySession` 在将会话项序列化为 JSON 时会转义非 ASCII 字符。这样既能保留原有的存储格式,也能在加载会话项时无损还原原始文本。 +默认情况下,`SQLAlchemySession` 在将会话条目序列化为 JSON 时会转义非 ASCII 字符。这会保留原有的存储格式,同时在加载条目时仍能无损还原原始文本。 如果希望多语言文本在存储的 JSON 中保持可读,请设置 `ensure_ascii=False`: @@ -88,10 +88,10 @@ session = SQLAlchemySession.from_url( ) ``` -使用现有引擎时,也可以将相同的选项直接传递给 `SQLAlchemySession(...)`。此设置仅会更改数据库中存储的 JSON 表示形式,不会更改会话方法返回的值。 +使用现有引擎时,也可以将相同的选项直接传递给 `SQLAlchemySession(...)`。此设置仅会更改数据库中存储的 JSON 表示形式;不会更改会话方法返回的值。 ## API 参考 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 主类 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 主要类 - [`Session`][agents.memory.session.Session] - 基础会话协议 \ No newline at end of file diff --git a/docs/zh/streaming.md b/docs/zh/streaming.md index 7fb4e47080..a9880ab6d8 100644 --- a/docs/zh/streaming.md +++ b/docs/zh/streaming.md @@ -4,19 +4,19 @@ search: --- # 流式传输 -流式传输允许你在智能体运行过程中订阅其更新。这对于向最终用户展示进度更新和部分响应非常有用。 +流式传输允许你订阅智能体运行过程中的更新。这对于向最终用户展示进度更新和部分响应非常有用。 -要使用流式传输,可以调用[`Runner.run_streamed()`][agents.run.Runner.run_streamed],它将返回[`RunResultStreaming`][agents.result.RunResultStreaming]。调用`result.stream_events()`会提供一个由[`StreamEvent`][agents.stream_events.StreamEvent]对象组成的异步流,这些对象将在下文中介绍。 +要进行流式传输,可以调用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed],它会返回一个 [`RunResultStreaming`][agents.result.RunResultStreaming]。调用 `result.stream_events()` 会得到由 [`StreamEvent`][agents.stream_events.StreamEvent] 对象组成的异步流,下文将对其进行说明。 -请持续消费`result.stream_events()`,直到异步迭代器结束。流式运行在迭代器结束前并未完成;会话持久化、审批状态记录或历史压缩等后处理可能会在最后一个可见 token 到达后完成。循环退出时,`result.is_complete`会反映最终的运行状态。 +持续消费 `result.stream_events()`,直到异步迭代器结束。只有当迭代器结束时,流式运行才算完成;会话持久化、审批记录或历史压缩等后处理可能会在最后一个可见 token 到达后才完成。循环退出时,`result.is_complete` 会反映最终的运行状态。 ## 原始响应事件 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent]是直接从LLM传递而来的原始事件。它们采用OpenAI Responses API格式,这意味着每个事件都有一个类型(例如`response.created`、`response.output_text.delta`等)和相应数据。如果你希望在响应消息生成后立即将其流式传输给用户,这些事件会很有用。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 对象封装了直接从 LLM 传递的原始事件。每个对象的 `data` 字段都包含一个 OpenAI Responses API 事件,其类型可能是 `response.created` 或 `response.output_text.delta`。如果你希望在响应消息生成后立即将其以流式方式传输给用户,这些事件会很有用。 -计算机工具的原始事件会保留与存储结果相同的预览版与正式版差异。预览版流程会流式传输包含单个`action`的`computer_call`项目,而`gpt-5.5`可以流式传输包含批量`actions[]`的`computer_call`项目。更高层级的[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]接口不会为此添加仅限计算机工具的特殊事件名称:这两种形式仍会以`tool_called`呈现,而截图结果则以封装`computer_call_output`项目的`tool_output`返回。 +计算机工具的原始事件与存储结果保持相同的预览版与 GA 版差异。预览版流程会传输包含一个 `action` 的 `computer_call` 项,而 `gpt-5.5` 可以传输包含批量 `actions[]` 的 `computer_call` 项。更高层级的 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 接口不会为此添加仅供计算机工具使用的特殊事件名称:这两种形式仍会以 `tool_called` 的形式呈现,而截图结果会以封装 `computer_call_output` 项的 `tool_output` 形式返回。 -例如,以下代码将逐 token 输出LLM生成的文本。 +例如,以下代码会逐个 token 输出 LLM 生成的文本。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## 流式传输与审批 -流式传输与因工具审批而暂停的运行兼容。如果某个工具需要审批,`result.stream_events()`会结束,并且待处理的审批会在[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]中公开。使用`result.to_state()`将结果转换为[`RunState`][agents.run_state.RunState],批准或拒绝中断,然后通过`Runner.run_streamed(...)`恢复运行。 +流式传输兼容因工具审批而暂停的运行。如果某个工具需要审批,`result.stream_events()` 会结束,并且待处理的审批会通过 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 暴露。使用 `result.to_state()` 将结果转换为 [`RunState`][agents.run_state.RunState],批准或拒绝该中断,然后使用 `Runner.run_streamed(...)` 恢复运行。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,47 +57,47 @@ if result.interruptions: pass ``` -有关完整的暂停和恢复演示,请参阅[人在回路指南](human_in_the_loop.md)。 +如需查看完整的暂停与恢复演示,请参阅[人在回路指南](human_in_the_loop.md)。 ## 当前轮次结束后的流式传输取消 -如果需要中途停止流式运行,请调用[`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。若要让当前轮次完整结束后再停止,请改为调用`result.cancel(mode="after_turn")`。 +如果需要中途停止流式运行,请调用 [`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。若要让当前轮次在停止前正常完成,请改为调用 `result.cancel(mode="after_turn")`。 -在`result.stream_events()`结束之前,流式运行尚未完成。在最后一个可见 token 出现后,SDK可能仍在持久化会话项目、完成审批状态处理或压缩历史记录。 +只有当 `result.stream_events()` 结束时,流式运行才算完成。在最后一个可见 token 到达后,SDK 可能仍在持久化会话项目、最终确定审批状态或压缩历史记录。 -如果你正通过[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]手动继续运行,并且`cancel(mode="after_turn")`在某个工具轮次后停止,请使用该规范化输入重新运行`result.last_agent`,以继续这一未完成的轮次,而不是立即追加一个新的用户轮次。 -- 如果流式运行因工具审批而停止,请勿将其视为新轮次。应先消费完流,检查`result.interruptions`,然后从`result.to_state()`恢复运行。 -- 使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可以自定义在下一次模型调用前,如何合并检索到的会话历史与新的用户输入。如果在此处重写新轮次项目,该轮次将持久化重写后的版本。 +如果你要从 [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] 手动继续运行,并且 `cancel(mode="after_turn")` 在工具轮次结束后停止,请使用该规范化输入重新运行 `result.last_agent`,以继续尚未完成的现有用户轮次,而不是立即追加一个新的用户轮次。 +- 如果流式运行因工具审批而停止,请勿将其视为新轮次。先完成流的消费,检查 `result.interruptions`,然后从 `result.to_state()` 恢复运行。 +- 使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 自定义在下一次模型调用之前,如何合并检索到的会话历史记录与新的用户输入。如果你在其中重写新轮次的项目,该轮次将持久化重写后的版本。 ## 运行项目事件与智能体事件 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]是更高层级的事件。它们会在项目完全生成后通知你。这样,你就可以按“消息已生成”“工具已运行”等粒度向用户推送进度更新,而不是逐 token 推送。同样,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]会在当前智能体发生变化时提供更新(例如由任务转移导致的变化)。 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在某个项目完全生成后通知你。借助这些事件,你可以按“消息已生成”“工具已运行”等粒度推送进度更新,而不必逐个 token 推送。同样,当当前智能体发生变化时(例如任务转移导致的变化),[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会向你提供更新。 ### 运行项目事件名称 -`RunItemStreamEvent.name`使用一组固定的语义事件名称: +`RunItemStreamEvent.name` 使用一组固定的语义事件名称: -- `message_output_created` -- `handoff_requested` -- `handoff_occured` -- `tool_called` -- `tool_search_called` -- `tool_search_output_created` -- `tool_output` -- `reasoning_item_created` -- `mcp_approval_requested` -- `mcp_approval_response` -- `mcp_list_tools` +- `message_output_created` +- `handoff_requested` +- `handoff_occured` +- `tool_called` +- `tool_search_called` +- `tool_search_output_created` +- `tool_output` +- `reasoning_item_created` +- `mcp_approval_requested` +- `mcp_approval_response` +- `mcp_list_tools` -为了向后兼容,`handoff_occured`被有意拼错。 +为了向后兼容,`handoff_occured` 被有意拼错。 -任务转移调用仅以`handoff_requested`发出,不会同时以`tool_called`发出。同一轮次中的普通工具调用仍会发出`tool_called`。 +任务转移调用只会以 `handoff_requested` 的形式发出,不会同时以 `tool_called` 的形式发出。同一轮次中的普通函数工具调用仍会发出 `tool_called`。 -使用托管工具搜索时,模型发出工具搜索请求会触发`tool_search_called`,Responses API返回已加载的子集时会触发`tool_search_output_created`。 +使用托管工具搜索时,模型发出工具搜索请求时会发出 `tool_search_called`,而 Responses API 返回已加载的子集时会发出 `tool_search_output_created`。 -使用程序化工具调用时,生成的`program`以及程序拥有的普通子工具调用都会触发`tool_called`。子工具输出和对应的`program_output`会触发`tool_output`。程序拥有的托管MCP `mcp_approval_request`和`mcp_list_tools`项目属于例外:它们分别以`mcp_approval_requested`和`mcp_list_tools`发出,并分别封装[`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]和[`MCPListToolsItem`][agents.items.MCPListToolsItem]。检查原始项目的`type`以区分其余项目;程序拥有的子调用还会携带一个`caller`,其类型为`program`,其调用方ID用于标识父程序。 +使用程序化工具调用时,生成的 `program` 和普通的程序所属子工具调用会发出 `tool_called`。子工具输出以及与生成的 `program` 相匹配的 `program_output` 会发出 `tool_output`。程序所属的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 项属于例外:它们会分别以 `mcp_approval_requested` 和 `mcp_list_tools` 的形式发出,并分别封装 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem]。检查原始项目的 `type` 以区分其余项目;程序所属的子调用还会携带一个 `caller`,其类型为 `program`,其调用方 ID 用于标识父程序。 -例如,以下代码将忽略原始事件,并向用户流式传输更新。 +例如,以下代码会忽略原始事件,并以流式方式向用户传输更新。 ```python import asyncio diff --git a/docs/zh/tools.md b/docs/zh/tools.md index b397fbdadc..6726b6c4e0 100644 --- a/docs/zh/tools.md +++ b/docs/zh/tools.md @@ -4,43 +4,43 @@ search: --- # 工具 -工具让智能体能够执行操作,例如获取数据、运行代码、调用外部 API,甚至操作计算机。SDK 支持五个目录: +工具让智能体能够执行操作,例如获取数据、运行代码、调用外部 API,甚至操作计算机。SDK 支持五类工具: -- 由OpenAI托管的工具:与模型一同在OpenAI服务上运行。 +- 由OpenAI托管的工具:在 OpenAI 服务器上为模型执行。 - 本地/运行时执行工具:`ComputerTool` 和 `ApplyPatchTool` 始终在你的环境中运行,而 `ShellTool` 可以在本地或托管容器中运行。 -- Function Calling:将任意 Python 函数封装为工具。 +- `FunctionTool` 实例:将任意 Python 函数封装为工具。 - Agents as tools:将智能体公开为可调用工具,而无需完整的任务转移。 -- 实验性 Codex 工具:通过工具调用运行限定于工作区范围的 Codex 任务。 +- 实验性 Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 -## 工具类型的选择 +## 工具类型选择 -请将本页用作目录,然后跳转到与你所控制的运行时相匹配的部分。 +将此页面用作目录,然后跳转到与你所控制的运行时相匹配的部分。 -| 如果你希望…… | 从这里开始 | +| 如果你想要…… | 从这里开始 | | --- | --- | | 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管 MCP、图像生成) | [托管工具](#hosted-tools) | -| 通过工具搜索将大型工具集合延迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | -| 通过生成的 JavaScript 协调多个工具调用 | [程序化工具调用](#programmatic-tool-calling) | -| 在你自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | -| 将 Python 函数封装为工具 | [工具调用](#function-tools) | -| 让一个智能体在不进行任务转移的情况下调用另一个智能体 | [Agents as tools](#agents-as-tools) | -| 从智能体运行限定于工作区范围的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | +| 使用工具搜索将大型工具集合推迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | +| 通过生成的 JavaScript 协调多个工具调用 | [编程式工具调用](#programmatic-tool-calling) | +| 在自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | +| 将 Python 函数封装为工具 | [函数工具](#function-tools) | +| 让一个智能体调用另一个智能体,而不进行任务转移 | [Agents as tools](#agents-as-tools) | +| 从智能体运行限定于工作区的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | ## 托管工具 使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI 提供了一些内置工具: -- [`WebSearchTool`][agents.tool.WebSearchTool] 让智能体能够搜索网络。 -- [`FileSearchTool`][agents.tool.FileSearchTool] 允许从你的 OpenAI 向量存储中检索信息。 -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 让 LLM 能够在沙盒环境中执行代码。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] 向模型公开远程 MCP 服务的工具。 +- [`WebSearchTool`][agents.tool.WebSearchTool] 允许智能体搜索网络。 +- [`FileSearchTool`][agents.tool.FileSearchTool] 允许从你的 OpenAI向量存储中检索信息。 +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 允许 LLM 在沙盒环境中执行代码。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] 将远程 MCP 服务器的工具公开给模型。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] 根据提示词生成图像。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] 让模型能够按需加载延迟加载的工具、命名空间或托管 MCP 服务。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 让模型能够通过生成的 JavaScript 协调符合条件的工具。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] 允许模型按需加载延迟加载的工具、命名空间或托管 MCP 服务器。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 允许模型通过生成的 JavaScript 协调符合条件的工具。 高级托管搜索选项: -- 除 `vector_store_ids` 和 `max_num_results` 外,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。将 `max_num_results` 设置为 1 到 50 之间的整数;`None` 或零将使用提供商默认值。 +- 除 `vector_store_ids` 和 `max_num_results` 外,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。将 `max_num_results` 设置为 1 到 50 之间的整数;`None` 或零会使用提供商的默认值。 - `WebSearchTool` 支持 `filters`、`user_location` 和 `search_context_size`。 ```python @@ -64,9 +64,9 @@ async def main(): ### 托管工具搜索 -工具搜索让 OpenAI Responses 模型能够将大型工具集合延迟到运行时加载,使模型仅加载当前轮次所需的子集。当你拥有许多工具调用、命名空间组或托管 MCP 服务,并希望在不预先公开所有工具的情况下减少工具架构所占的 token 时,这会很有用。 +工具搜索允许 OpenAI Responses 模型将大型工具集合推迟到运行时加载,使模型只加载当前轮次所需的子集。当你有许多函数工具、命名空间组或托管 MCP 服务器,并且希望减少工具架构所占用的 token,而不预先公开所有工具时,这非常有用。 -如果构建智能体时已经知道候选工具,请优先使用托管工具搜索。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 +如果构建智能体时已经知道候选工具,请从托管工具搜索开始。如果应用程序需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 ```python from typing import Annotated @@ -111,26 +111,26 @@ print(result.final_output) 注意事项: -- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 的支持依赖于 `openai>=2.25.0`。 -- 在智能体上配置延迟加载集合时,只添加一个 `ToolSearchTool()`。 -- 可搜索的集合包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 -- 延迟加载的工具调用必须与 `ToolSearchTool()` 配合使用。仅包含命名空间的设置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 -- `tool_namespace()` 将 `FunctionTool` 实例归入具有共享名称和描述的命名空间。当你拥有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最佳选择。 +- 托管工具搜索仅适用于 OpenAI Responses 模型。目前的 Python SDK 支持依赖于 `openai>=2.25.0`。 +- 为智能体配置延迟加载的工具集合时,只添加一个 `ToolSearchTool()`。 +- 可搜索的工具集合包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 +- 延迟加载的函数工具必须与 `ToolSearchTool()` 配对。仅包含命名空间的配置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 +- `tool_namespace()` 将 `FunctionTool` 实例归入一个具有共同名称和描述的命名空间。当你有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最合适的选择。 - OpenAI 的官方最佳实践指南是[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 -- 如果可能,应优先使用命名空间或托管 MCP 服务,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高层搜索范围,并节省更多 token。 -- 命名空间可以混合包含立即可用和延迟加载的工具。未设置 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟加载工具则通过工具搜索进行加载。 -- 根据经验,每个命名空间应保持相对较小,最好少于 10 个函数。 -- 具名 `tool_choice` 不能以单独的命名空间名称或仅支持延迟加载的工具为目标。应优先使用 `auto`、`required` 或真正的顶层可调用工具名称。 -- `ToolSearchTool(execution="client")` 用于手动编排 Responses。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会抛出异常,而不会替你执行它。 -- 工具搜索活动会出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中,并使用专门的条目和事件类型。 +- 如果可能,优先使用命名空间或托管 MCP 服务器,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高层搜索界面,并节省更多 token。 +- 命名空间可以混合包含立即可用的工具和延迟加载的工具。没有 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟加载工具则通过工具搜索进行加载。 +- 经验法则是让每个命名空间保持较小规模,最好少于 10 个函数。 +- 具名 `tool_choice` 不能以单独的命名空间名称或仅延迟加载的工具为目标。优先使用 `auto`、`required` 或真实的顶层可调用工具名称。 +- `ToolSearchTool(execution="client")` 用于手动进行 Responses 编排。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会抛出异常,而不会代你执行。 +- 工具搜索活动会以专用的项目和事件类型出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中。 - 有关涵盖命名空间加载和顶层延迟加载工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 - 官方平台指南:[工具搜索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 -### 程序化工具调用 +### 编程式工具调用 -程序化工具调用让受支持的 OpenAI Responses 模型能够生成 JavaScript,以调用符合条件的工具、合并其输出,并向模型返回一个结果。它适用于范围明确且可受控的工作流,这类工作流可通过循环、分支、并行调用或中间计算获益,而无需在每次工具调用后都与模型往返交互。 +编程式工具调用允许受支持的 OpenAI Responses 模型生成 JavaScript,以调用符合条件的工具、合并其输出,并向模型返回一个结果。它适用于范围明确且可从循环、分支、并行调用或中间计算中获益的工作流,无需在每次工具调用后都与模型往返交互。 -生成的程序在全新的托管 V8 环境中运行。它无法使用 Node.js API,不能访问文件系统或网络,也没有持久化进程。程序只能与显式允许的工具交互。 +生成的程序在全新的托管 V8 环境中运行。它无法使用 Node.js API,无法访问文件系统或网络,也没有持久化进程。该程序只能与显式允许的工具交互。 ```python from pydantic import BaseModel @@ -167,22 +167,22 @@ print(result.final_output) 注意事项: -- 程序化工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 -- 每个智能体最多添加一个 `ProgrammaticToolCallingTool()`。该智能体还必须公开至少一个可通过编程方式调用的工具、一个由命名空间、延迟加载函数或延迟加载托管 MCP 服务支持的 `ToolSearchTool()`,或一个由提示词管理的不透明工具集合。没有可搜索集合的单独 `ToolSearchTool()` 会被拒绝。 -- `allowed_callers` 控制工具可以如何被调用。省略该参数时,仅允许模型直接调用。使用 `["programmatic"]` 可仅允许程序访问,使用 `["direct", "programmatic"]` 则同时允许两种方式。 -- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、shell 和补丁应用工具直接公开 `allowed_callers`。对于托管 MCP 和 Code Interpreter,请在 `tool_config` 中设置 `allowed_callers`。 -- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或数据类等结构化返回注解会自动转换为严格的对象输出架构,并在将值返回给程序之前进行验证。如果函数没有可用的注解,请使用 `output_type=...`;如果你已经有严格的对象架构,则可使用更底层的 `output_json_schema={...}` 逃生通道。`output_type` 和 `output_json_schema` 互斥。普通的 `str`、`Any` 和 `None` 返回值仍不带类型。对于由架构支持且归程序所有的调用,默认失败格式化器会被禁用,因为其自由格式文本不符合输出架构。因此,除非你提供返回符合架构 JSON 的自定义 `failure_error_function`,否则处理程序异常会继续向上传播。 -- 归程序所有的 SDK 工具仍使用正常的 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,SDK 也会保留每个子调用与程序调用方之间的关系。 -- 只要存在 `ProgrammaticToolCallingTool()`,模型请求重试就会使用更严格的重放安全边界,即使程序尚未执行也是如此。SDK 会为这些请求禁用由提供商管理的重试和 WebSocket 事件前重试。仅当提供商的建议明确标记重放安全时,Runner 重试策略才会重试;单独使用 `retry_policies.network_error()` 无法覆盖此边界。 -- 对于需要审批或影响较大的工具,通常更适合保留为直接调用,以便人员在每项操作成为更大程序的一部分之前进行审核。如果归程序所有的调用因等待审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 -- 程序化工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。生成的程序必须先由模型加载延迟加载的工具,之后才能调用它们。 -- `program` 条目及其常规的、归程序所有的子工具调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。托管 MCP 审批请求和工具目录则使用专门的 MCP 条目和流式事件。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 +- 编程式工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 +- 每个智能体最多添加一个 `ProgrammaticToolCallingTool()`。该智能体还必须公开至少一个可通过编程方式调用的工具、一个由命名空间、延迟函数或延迟托管 MCP 服务器支持的 `ToolSearchTool()`,或一个由提示词管理的不透明工具集合。不包含可搜索工具集合的单独 `ToolSearchTool()` 会被拒绝。 +- `allowed_callers` 控制工具的调用方式。省略它时,仅允许模型直接调用。使用 `["programmatic"]` 表示仅允许程序访问,或使用 `["direct", "programmatic"]` 同时允许两者。 +- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、shell 和应用补丁工具直接公开 `allowed_callers`。对于托管 MCP 和 Code Interpreter,请在 `tool_config` 内设置 `allowed_callers`。 +- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或 dataclass 等结构化返回注解会自动成为严格对象输出架构,并且返回值在返回给程序之前会依据该架构进行验证。当函数没有可用注解时,请使用 `output_type=...`;如果你已有严格对象架构,则可以使用更底层的 `output_json_schema={...}` 逃生舱。`output_type` 和 `output_json_schema` 互斥。`str`、`Any` 或 `None` 的返回注解不会创建输出架构。对于由架构支持且归程序所有的调用,默认失败格式化程序会被禁用,因为其自由格式文本不符合输出架构。因此,处理程序异常会继续传播,除非你提供自定义 `failure_error_function`,使其返回符合架构的 JSON。 +- 归程序所有的 SDK 工具仍使用常规 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,并且 SDK 会保留每个子调用与程序调用方的关系。 +- 只要存在 `ProgrammaticToolCallingTool()`,模型请求重试就会采用更严格的重放安全边界,即使程序尚未执行也是如此。SDK 会针对这些请求禁用由提供商管理的重试和 WebSocket 事件前重试。只有当提供商建议明确将重放标记为安全时,Runner 重试策略才会重试;仅设置 `retry_policies.network_error()` 不会覆盖此边界。 +- 对审批敏感或影响较大的工具通常更适合作为直接调用,以便人员可在每项操作成为更大程序的一部分之前进行审核。如果归程序所有的调用因等待审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 +- 编程式工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。生成的程序必须先由模型加载延迟工具,之后才能调用它们。 +- `program` 项目及其普通的归程序所有的子工具调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。托管 MCP 审批请求和工具目录则使用专用的 MCP 项目和流事件。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 - 有关完整的并发库存规划代码示例,请参阅 `examples/tools/programmatic_tool_calling.py`。 -- 官方平台指南:[程序化工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 +- 官方平台指南:[编程式工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 ### 托管容器 shell 与技能 -`ShellTool` 也支持由OpenAI托管的容器执行。如果你希望模型在托管容器中而不是本地运行时中执行 shell 命令,请使用此模式。 +`ShellTool` 还支持由OpenAI托管的容器执行。当你希望模型在托管容器中运行 shell 命令,而不是在本地运行时中运行时,请使用此模式。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -219,11 +219,11 @@ print(result.final_output) 注意事项: -- 可通过 Responses API 的 shell 工具使用托管 shell。 -- `container_auto` 会为请求预配容器;`container_reference` 会复用现有容器。 -- `container_auto` 还可以包含 `file_ids` 和 `memory_limit`。 +- 托管 shell 可通过 Responses API 的 shell 工具使用。 +- `container_auto` 为请求配置一个容器;`container_reference` 复用现有容器。 +- `container_auto` 还可以包括 `file_ids` 和 `memory_limit`。 - `environment.skills` 接受技能引用和内联技能包。 -- 使用托管环境时,不要在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 +- 对于托管环境,请勿在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 - `network_policy` 支持 `disabled` 和 `allowlist` 模式。 - 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于域的密钥。 - 有关完整代码示例,请参阅 `examples/tools/container_shell_skill_reference.py` 和 `examples/tools/container_shell_inline_skill.py`。 @@ -231,38 +231,38 @@ print(result.final_output) ## 本地运行时工具 -本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用或配置的执行环境完成。 +本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用程序或已配置的执行环境完成。 -`ComputerTool` 和 `ApplyPatchTool` 始终需要你提供本地实现。`ShellTool` 横跨两种模式:如果需要托管执行,请使用上述托管容器配置;如果希望命令在你自己的进程中运行,请使用下述本地运行时配置。 +`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 涵盖两种模式:如果需要托管执行,请使用上面的托管容器配置;如果希望命令在自己的进程中运行,请使用下面的本地运行时配置。 本地运行时工具要求你提供实现: - [`ComputerTool`][agents.tool.ComputerTool]:实现 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 接口,以启用 GUI/浏览器自动化。 -- [`ShellTool`][agents.tool.ShellTool]:用于本地执行和托管容器执行的最新 shell 工具。 +- [`ShellTool`][agents.tool.ShellTool]:同时用于本地执行和托管容器执行的最新 shell 工具。 - [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 shell 集成。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] 以在本地应用差异。 -- 通过 `ShellTool(environment={"type": "local", "skills": [...]})` 可以使用本地 shell 技能。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor],以便在本地应用差异。 +- 本地 shell 技能可通过 `ShellTool(environment={"type": "local", "skills": [...]})` 使用。 -对于有限超时,shell 操作超时使用正整数毫秒值。调用本地 `ShellTool` 执行器前,SDK 会将 `0` 和 `None` 都视为未显式设置超时,因为零在不同执行器实现中没有可移植的统一含义;其他值会在调用执行器前被拒绝。此规则仅适用于超时字段:`max_output_length=0` 仍是受支持的请求,表示捕获空输出。 +对于 shell 操作超时,使用正整数毫秒值表示有限超时。在调用本地 `ShellTool` 执行器之前,SDK 会将 `0` 和 `None` 都视为未显式设置超时,因为零在不同执行器实现中没有可移植的统一含义;其他值会在调用执行器之前被拒绝。这仅适用于超时字段:`max_output_length=0` 仍是受支持的空捕获输出请求。 ### ComputerTool 与 Responses 计算机工具 -`ComputerTool` 仍是本地执行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该框架映射到 OpenAI Responses API 的计算机操作接口。 +`ComputerTool` 仍是本地工具框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该框架映射到 OpenAI Responses API 的计算机操作界面。 -对于显式的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布的内置工具载荷 `{"type": "computer"}`。较旧的 `computer-use-preview` 模型仍使用预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI 的[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中描述的平台迁移一致: +对于显式的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布版内置工具载荷 `{"type": "computer"}`。对于发往旧版 `computer-use-preview` 模型的请求,SDK 会继续发送预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI 的[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中所述的平台迁移一致: - 模型:`computer-use-preview` -> `gpt-5.5` - 工具选择器:`computer_use_preview` -> `computer` -- 计算机调用结构:每个 `computer_call` 包含一个 `action` -> `computer_call` 上的批量 `actions[]` -- 截断:预览路径要求使用 `ModelSettings(truncation="auto")` -> 正式发布路径不要求 +- 计算机调用结构:每个 `computer_call` 对应一个 `action` -> `computer_call` 上的批量 `actions[]` +- 截断:预览版路径要求使用 `ModelSettings(truncation="auto")` -> 正式发布版路径不要求 -SDK 会根据实际 Responses 请求中的有效模型选择对应的传输格式。如果你使用提示词模板,而请求因模型由提示词指定而省略 `model`,SDK 会保留与预览版兼容的计算机载荷,除非你显式保留 `model="gpt-5.5"`,或通过 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制使用正式发布的选择器。 +SDK 根据实际 Responses 请求中的有效模型选择该线路结构。如果你使用提示词模板,并且由于提示词本身指定模型而使请求省略 `model`,SDK 会继续使用兼容预览版的计算机载荷,除非你显式保留 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版选择器。 -存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 均可接受,并会规范化为与有效请求模型匹配的内置选择器。不存在 `ComputerTool` 时,这些字符串仍会被视为普通函数名称。 +存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 都会被接受,并规范化为与有效请求模型匹配的内置选择器。如果没有 `ComputerTool`,这些字符串仍会作为普通函数名称处理。 -当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别非常重要。正式发布的 `computer` 载荷在序列化时不需要 `environment` 或尺寸,因此工厂尚未解析也没有问题。与预览版兼容的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 发送 `environment`、`display_width` 和 `display_height`。 +当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别非常重要。正式发布版 `computer` 载荷在序列化时不需要 `environment` 或尺寸信息,因此可以在工厂生成 `Computer` 或 `AsyncComputer` 实例之前完成序列化。兼容预览版的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 发送 `environment`、`display_width` 和 `display_height`。 -在运行时,两条路径仍使用同一本地执行框架。预览版响应会发出包含单个 `action` 的 `computer_call` 条目;`gpt-5.5` 可以发出批量 `actions[]`,SDK 会按顺序执行它们,然后生成 `computer_call_output` 截图条目。有关基于 Playwright 且可运行的执行框架,请参阅 `examples/tools/computer_use.py`。 +在运行时,两条路径仍使用相同的本地工具框架。预览版响应会发出包含单个 `action` 的 `computer_call` 项目;`gpt-5.5` 可以发出批量 `actions[]`,SDK 会按顺序执行它们,然后生成 `computer_call_output` 截图项目。有关基于 Playwright 的可运行工具框架,请参阅 `examples/tools/computer_use.py`。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -304,12 +304,12 @@ agent = Agent( ) ``` -## 工具调用 +## 函数工具 你可以将任意 Python 函数用作工具。Agents SDK 会自动设置该工具: -- 工具名称将采用 Python 函数的名称(你也可以提供名称) -- 工具描述将取自函数的文档字符串(你也可以提供描述) +- 工具名称将采用 Python 函数的名称(也可以自行提供名称) +- 工具描述将取自函数的文档字符串(也可以自行提供描述) - 函数输入的架构会根据函数参数自动创建 - 除非禁用,否则每个输入的描述都取自函数的文档字符串 @@ -317,7 +317,7 @@ agent = Agent( 我们使用 Python 的 `inspect` 模块提取函数签名,同时使用 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,并使用 `pydantic` 创建架构。 -使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏函数工具,直至 `ToolSearchTool()` 加载它。你也可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关工具调用进行分组。有关完整设置和限制,请参阅[托管工具搜索](#hosted-tool-search)。 +使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏函数工具,直到 `ToolSearchTool()` 加载它。你还可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关函数工具进行分组。有关完整设置和约束,请参阅[托管工具搜索](#hosted-tool-search)。 ```python import json @@ -370,12 +370,12 @@ for tool in agent.tools: ``` -1. 你可以将任意 Python 类型用作函数参数,并且函数可以是同步或异步的。 -2. 如果存在文档字符串,则使用它来获取描述和参数描述 -3. 函数可以选择接受 `context`(必须是第一个参数)。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 -4. 你可以将装饰后的函数传递给工具列表。 +1. 函数参数可以使用任意 Python 类型,并且函数可以是同步或异步函数。 +2. 如果存在文档字符串,则会用它来获取描述和参数描述 +3. 函数可以选择将运行上下文作为第一个参数。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 +4. 你可以将经过装饰的函数传入工具列表。 -??? note "展开查看输出" +??? note "展开以查看输出" ``` fetch_weather @@ -445,17 +445,17 @@ for tool in agent.tools: } ``` -### 工具调用返回的图像或文件 +### 函数工具的图像或文件返回 -除了返回文本输出外,你还可以将一张或多张图像或一个或多个文件作为函数工具的输出返回。为此,你可以返回以下任意内容: +除了返回文本输出之外,你还可以返回一个或多个图像或文件作为函数工具的输出。为此,可以返回以下任意内容: - 图像:[`ToolOutputImage`][agents.tool.ToolOutputImage](或 TypedDict 版本 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) - 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) - 文本:字符串、可转换为字符串的对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) -### 自定义工具调用 +### 自定义函数工具 -有时,你可能不希望使用 Python 函数作为工具。如果愿意,可以直接创建 [`FunctionTool`][agents.tool.FunctionTool]。你需要提供: +有时,你可能不希望将 Python 函数用作工具。如果愿意,可以直接创建 [`FunctionTool`][agents.tool.FunctionTool]。你需要提供: - `name` - `description` @@ -495,16 +495,16 @@ tool = FunctionTool( ### 参数和文档字符串的自动解析 -如前所述,我们会自动解析函数签名以提取工具架构,并解析文档字符串以提取工具及各个参数的描述。相关注意事项如下: +如前所述,我们会自动解析函数签名以提取工具架构,并解析文档字符串以提取工具和各个参数的描述。相关注意事项如下: -1. 签名解析通过 `inspect` 模块完成。我们使用类型注解理解参数类型,并动态构建 Pydantic 模型来表示整体架构。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 -2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这只是尽力而为,你也可以在调用 `function_tool` 时显式设置格式。还可以通过将 `use_docstring_info` 设置为 `False` 来禁用文档字符串解析。对于 Google 风格的文档字符串,解析器也接受紧接在摘要文本之后、其间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 部分。 +1. 签名解析通过 `inspect` 模块完成。我们使用类型注解来理解参数类型,并动态构建一个 Pydantic 模型来表示整体架构。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 +2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这只是尽力而为;你可以在调用 `function_tool` 时显式设置格式。还可以通过将 `use_docstring_info` 设置为 `False` 来禁用文档字符串解析。对于 Google 风格的文档字符串,解析器还接受紧接在摘要文本之后且中间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 部分。 -架构提取的代码位于 [`agents.function_schema`][]。 +架构提取代码位于 [`agents.function_schema`][] 中。 ### 使用 Pydantic Field 约束和描述参数 -你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,两种形式均受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated` 形式(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 架构和验证会包含这些约束。 +你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,两种形式都受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 架构和验证会包含这些约束。 ```python from typing import Annotated @@ -524,7 +524,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr ### 函数工具超时 -你可以使用 `@function_tool(timeout=...)` 为异步函数工具设置每次调用的超时。 +你可以使用 `@function_tool(timeout=...)` 为异步函数工具设置单次调用超时。 ```python import asyncio @@ -549,7 +549,7 @@ agent = Agent( 你可以控制超时处理方式: -- `timeout_behavior="error_as_result"`(默认):向模型返回超时消息,使其能够恢复。 +- `timeout_behavior="error_as_result"`(默认):向模型返回超时消息,以便模型进行恢复。 - `timeout_behavior="raise_exception"`:抛出 [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] 并使运行失败。 - `timeout_error_function=...`:使用 `error_as_result` 时自定义超时消息。 @@ -577,13 +577,13 @@ except ToolTimeoutError as e: 超时配置仅支持异步 `@function_tool` 处理程序。 -### 工具调用中的错误处理 +### 函数工具错误处理 -通过 `@function_tool` 创建函数工具时,可以传入 `failure_error_function`。如果工具调用崩溃,该函数会向 LLM 提供错误响应。 +通过 `@function_tool` 创建函数工具时,可以传入 `failure_error_function`。这是一个在工具调用崩溃时向 LLM 提供错误响应的函数。 -- 默认情况下(即你未传入任何内容时),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 -- 如果传入自己的错误函数,则改为运行该函数,并将响应发送给 LLM。 -- 如果显式传入 `None`,任何工具调用错误都会重新抛出,供你处理。如果模型生成了无效 JSON,错误可能是 `ModelBehaviorError`;如果你的代码崩溃,则可能是 `UserError`,等等。 +- 默认情况下(即未传入任何内容),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 +- 如果传入自己的错误函数,则会改为运行该函数,并将响应发送给 LLM。 +- 如果显式传入 `None`,则会重新抛出所有工具调用错误,由你进行处理。例如,如果模型生成了无效 JSON,可能会抛出 `ModelBehaviorError`;如果你的代码崩溃,可能会抛出 `UserError`,等等。 ```python from agents import RunContextWrapper @@ -607,11 +607,11 @@ def get_user_profile(user_id: str) -> str: ``` -如果你手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内处理错误。 +如果手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内部处理错误。 ## Agents as tools -在某些工作流中,你可能希望由一个中心智能体编排由多个专业智能体组成的网络,而不是转移控制权。为此,你可以将智能体建模为工具。 +在某些工作流中,你可能希望由一个中央智能体编排由多个专业智能体组成的网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 ```python import asyncio @@ -655,11 +655,11 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 工具智能体的自定义 +### 工具智能体自定义 -`agent.as_tool` 函数是一种便捷方法,可轻松将智能体转换为工具。它支持常见运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 +`agent.as_tool` 是一种将智能体转换为工具的便捷方法。它支持常见的运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 -这些状态选项用于配置由工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请显式向两者传递相同的 `session`。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:由客户端管理的 `session`,或通过 `previous_response_id` 或 `conversation_id` 实现由服务管理的连续运行。 +状态选项用于配置工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请显式将同一个 `session` 传给两者。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:由客户端管理的 `session`,或者通过 `previous_response_id` 或 `conversation_id` 进行由服务器管理的延续。 ```python from agents.decorators import tool @@ -683,13 +683,13 @@ async def run_my_agent() -> str: ### 工具智能体的结构化输入 -默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或数据类类型)公开结构化架构。 +默认情况下,`Agent.as_tool()` 预期接收一个包含单个字符串字段 `input`(`{"input": "..."}`)的对象,但你可以通过传入 `parameters`(Pydantic 模型类型或 dataclass 类型)公开结构化架构。 其他选项: -- `include_input_schema=True` 会在生成的嵌套输入中包含完整的 JSON Schema。 -- `input_builder=...` 让你能够完全自定义如何将结构化工具参数转换为嵌套智能体输入。 -- `RunContextWrapper.tool_input` 包含嵌套运行上下文中解析后的结构化载荷。 +- `include_input_schema=True` 在生成的嵌套输入中包含完整 JSON Schema。 +- `input_builder=...` 允许你完全自定义如何将结构化工具参数转换为嵌套智能体输入。 +- `RunContextWrapper.tool_input` 在嵌套运行上下文中包含已解析的结构化载荷。 ```python from pydantic import BaseModel, Field @@ -711,19 +711,19 @@ translator_tool = translator_agent.as_tool( 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_structured.py`。 -### 工具智能体的审批关卡 +### 工具智能体的审批门控 -`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理条目将显示在 `result.interruptions` 中;随后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复。有关完整的暂停/恢复模式,请参阅[人在回路指南](human_in_the_loop.md)。 +`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理项目将出现在 `result.interruptions` 中;随后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人在回路指南](human_in_the_loop.md)。 ### 自定义输出提取 -在某些情况下,你可能希望在将工具智能体的输出返回给中心智能体之前对其进行修改。以下情况可能会用到此功能: +在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中央智能体。这在以下场景中可能很有用: -- 从子智能体的聊天历史记录中提取特定信息(例如 JSON 载荷)。 -- 转换智能体的最终答案或重新设置其格式(例如将 Markdown 转换为纯文本或 CSV)。 -- 验证输出,或在智能体响应缺失或格式错误时提供回退值。 +- 从子智能体的聊天历史中提取特定信息(例如 JSON 载荷)。 +- 转换或重新格式化智能体的最终答案(例如将 Markdown 转换为纯文本或 CSV)。 +- 验证输出,或在智能体的响应缺失或格式错误时提供回退值。 -你可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现: +你可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现此目的: ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -742,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -在自定义提取器内部,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在后处理嵌套结果时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 +在自定义提取器中,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在后处理嵌套结果时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 ### 嵌套智能体运行的流式传输 -向 `as_tool` 传入 `on_stream` 回调,可以监听嵌套智能体发出的流式事件,同时仍会在流结束后返回其最终输出。 +将 `on_stream` 回调传给 `as_tool`,即可监听嵌套智能体发出的流式事件,同时仍会在流完成后返回其最终输出。 ```python from agents import AgentToolStreamEvent @@ -767,14 +767,14 @@ billing_agent_tool = billing_agent.as_tool( 预期行为: - 事件类型与 `StreamEvent["type"]` 一致:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 -- 提供 `on_stream` 后,嵌套智能体会自动以流式传输模式运行,并在返回最终输出前耗尽整个流。 -- 处理程序可以是同步或异步的;每个事件都会按照到达顺序依次传递。 -- 通过模型工具调用来调用工具时会存在 `tool_call`;直接调用时其值可能为 `None`。 -- 有关完整的可运行示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 +- 提供 `on_stream` 会自动以流式模式运行嵌套智能体,并在返回最终输出前耗尽流。 +- 处理程序可以是同步或异步的;每个事件都会按到达顺序传递。 +- 通过模型工具调用来调用该工具时,`tool_call` 会存在;直接调用时,其值可能为 `None`。 +- 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 -### 工具的条件性启用 +### 条件式工具启用 -你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选哪些工具可供 LLM 使用。 +你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选对 LLM 可用的工具。 ```python import asyncio @@ -832,21 +832,21 @@ asyncio.run(main()) `is_enabled` 参数接受: - **布尔值**:`True`(始终启用)或 `False`(始终禁用) -- **可调用函数**:接受 `(context, agent)` 并返回布尔值的函数 +- **可调用函数**:接收 `(context, agent)` 并返回布尔值的函数 - **异步函数**:用于复杂条件逻辑的异步函数 -被禁用的工具在运行时对 LLM 完全隐藏,因此适用于: +禁用的工具会在运行时对 LLM 完全隐藏,因此适用于: -- 根据用户权限实施功能准入控制 -- 针对特定环境控制工具可用性(开发环境与生产环境) +- 根据用户权限设置功能门控 +- 特定于环境的工具可用性(开发环境与生产环境) - 对不同工具配置进行 A/B 测试 - 根据运行时状态动态筛选工具 ## 实验性 Codex 工具 -`codex_tool` 封装了 Codex CLI,使智能体能够在工具调用期间运行限定于工作区范围的任务(shell、文件编辑、MCP 工具)。此接口仍处于实验阶段,可能会发生变化。 +`codex_tool` 封装了 Codex CLI,使智能体可以在工具调用期间运行限定于工作区的任务(shell、文件编辑、MCP 工具)。此功能目前处于实验阶段,可能会发生变化。 -如果你希望主智能体在不离开当前运行的情况下,将范围明确且可受控的工作区任务委托给 Codex,请使用此工具。默认情况下,工具名称为 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当智能体包含多个 Codex 工具时,每个工具都必须使用唯一名称。 +当你希望主智能体将范围明确的工作区任务委托给 Codex,同时不离开当前运行时,请使用它。默认工具名称是 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当一个智能体包含多个 Codex 工具时,每个工具都必须使用唯一名称。 ```python from agents import Agent @@ -877,31 +877,31 @@ agent = Agent( 请从以下选项组开始: -- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以在哪里操作。请将两者配合设置;如果工作目录不在 Git 仓库内,请设置 `skip_git_repo_check=True`。 -- 线程默认值:`default_thread_options=ThreadOptions(...)` 用于配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。应优先使用 `web_search_mode`,而不是旧版 `web_search_enabled`。 -- 轮次默认值:`default_turn_options=TurnOptions(...)` 用于配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 -- 工具输入/输出:工具调用必须至少包含一个 `inputs` 条目,其格式为 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }`。`output_schema` 让你能够要求 Codex 返回结构化响应。 +- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以进行操作的位置。请将两者配合使用;当工作目录不在 Git 仓库内时,请设置 `skip_git_repo_check=True`。 +- 线程默认值:`default_thread_options=ThreadOptions(...)` 配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。优先使用 `web_search_mode`,而不是旧版 `web_search_enabled`。 +- 轮次默认值:`default_turn_options=TurnOptions(...)` 配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 +- 工具输入/输出:工具调用必须至少包含一个带有 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }` 的 `inputs` 项目。`output_schema` 允许你要求 Codex 返回结构化响应。 线程复用和持久化是两个独立的控制项: -- `persist_session=True` 会为对同一工具实例的重复调用复用一个 Codex 线程。 -- `use_run_context_thread_id=True` 会在共享同一可变上下文对象的多次运行之间,将线程 ID 存储在运行上下文中并复用。 -- 线程 ID 的优先级依次为:每次调用的 `thread_id`、运行上下文线程 ID(如果启用),最后是配置的 `thread_id` 选项。 -- 当 `name="codex"` 时,默认运行上下文键为 `codex_thread_id`;当 `name="codex_"` 时,则为 `codex_thread_id_`。可使用 `run_context_thread_id_key` 覆盖该键。 +- `persist_session=True` 为对同一工具实例的重复调用复用同一个 Codex 线程。 +- `use_run_context_thread_id=True` 在共享同一可变上下文对象的多次运行之间,将线程 ID 存储在运行上下文中并进行复用。 +- 线程 ID 的优先级为:每次调用的 `thread_id`,其次是运行上下文中的线程 ID(如果启用),最后是已配置的 `thread_id` 选项。 +- `name="codex"` 的默认运行上下文键是 `codex_thread_id`,`name="codex_"` 的默认运行上下文键是 `codex_thread_id_`。可使用 `run_context_thread_id_key` 覆盖它。 运行时配置: -- 身份验证:设置 `CODEX_API_KEY`(推荐)或 `OPENAI_API_KEY`,或者传入 `codex_options={"api_key": "..."}`。 -- 运行时:`codex_options.base_url` 会覆盖 CLI 基础 URL。 -- 二进制文件解析:设置 `codex_options.codex_path_override`(或 `CODEX_PATH`)以固定 CLI 路径。否则,SDK 会先从 `PATH` 中解析 `codex`,然后回退到捆绑的供应商二进制文件。 -- 环境:`codex_options.env` 完全控制子进程环境。提供该选项后,子进程不会继承 `os.environ`。 +- 身份验证:设置 `CODEX_API_KEY`(首选)或 `OPENAI_API_KEY`,或者传入 `codex_options={"api_key": "..."}`。 +- 运行时:`codex_options.base_url` 覆盖 CLI 基础 URL。 +- 二进制文件解析:设置 `codex_options.codex_path_override`(或 `CODEX_PATH`)以固定 CLI 路径。否则,SDK 会先从 `PATH` 解析 `codex`,然后回退到随附的供应商二进制文件。 +- 环境:`codex_options.env` 完全控制子进程环境。提供该选项时,子进程不会继承 `os.environ`。 - 流限制:`codex_options.codex_subprocess_stream_limit_bytes`(或 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)控制 stdout/stderr 读取器限制。有效范围为 `65536` 到 `67108864`;默认值为 `8388608`。 -- 流式传输:`on_stream` 接收线程/轮次生命周期事件和条目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 和 `error` 条目更新)。 -- 输出:结果包含 `response`、`usage` 和 `thread_id`;使用量会添加到 `RunContextWrapper.usage`。 +- 流式传输:`on_stream` 接收线程/轮次生命周期事件和项目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 以及 `error` 项目更新)。 +- 输出:结果包括 `response`、`usage` 和 `thread_id`;用量会添加到 `RunContextWrapper.usage`。 参考资料: - [Codex 工具 API 参考](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions 参考](ref/extensions/experimental/codex/thread_options.md) - [TurnOptions 参考](ref/extensions/experimental/codex/turn_options.md) -- 有关完整的可运行示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file +- 有关完整的可运行代码示例,请参阅 `examples/tools/codex.py` 和 `examples/tools/codex_same_thread.py`。 \ No newline at end of file diff --git a/docs/zh/tracing.md b/docs/zh/tracing.md index db2cd31f26..8e431427bd 100644 --- a/docs/zh/tracing.md +++ b/docs/zh/tracing.md @@ -4,51 +4,51 @@ search: --- # 追踪 -Agents SDK内置追踪功能,可在智能体运行期间收集完整的事件记录,包括LLM生成、工具调用、任务转移、安全防护措施,乃至发生的自定义事件。使用[追踪记录仪表板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化和监控工作流。 +Agents SDK内置追踪功能,可收集智能体运行期间的完整事件记录:LLM生成、工具调用、任务转移、安全防护措施,乃至发生的自定义事件。借助[追踪仪表板](https://platform.openai.com/traces),您可以在开发和生产环境中调试、可视化并监控工作流。 !!!note - 默认启用追踪。你可以通过以下三种常见方式将其禁用: + 默认启用追踪。您可以通过以下三种常见方式禁用追踪: - 1. 设置环境变量`OPENAI_AGENTS_DISABLE_TRACING=1`,在全局禁用追踪 - 2. 在代码中使用[`set_tracing_disabled(True)`][agents.set_tracing_disabled],在全局禁用追踪 - 3. 将[`agents.run.RunConfig.tracing_disabled`][]设置为`True`,针对单次运行禁用追踪 + 1. 设置环境变量 `OPENAI_AGENTS_DISABLE_TRACING=1`,在全局范围内禁用追踪 + 2. 在代码中使用 [`set_tracing_disabled(True)`][agents.set_tracing_disabled],在全局范围内禁用追踪 + 3. 将 [`agents.run.RunConfig.tracing_disabled`][] 设置为 `True`,为单次运行禁用追踪 -***对于使用OpenAI API并遵循零数据保留(ZDR)政策的组织,追踪功能不可用。*** +***对于根据零数据保留(ZDR)政策使用OpenAI API的组织,追踪不可用。*** ## 追踪与跨度 -- **追踪**表示一次“工作流”的端到端操作。追踪由多个跨度组成,并具有以下属性: - - `workflow_name`:逻辑工作流或应用。例如“代码生成”或“客户服务”。 - - `trace_id`:追踪的唯一 ID。如果未传入,则会自动生成。格式必须为`trace_<32_alphanumeric>`。 - - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,可以使用聊天线程 ID。 +- **追踪**表示一次“工作流”的端到端操作。它们由跨度组成。追踪具有以下属性: + - `workflow_name`:逻辑工作流或应用的名称。例如,“代码生成”或“客户服务”。 + - `trace_id`:追踪的唯一 ID。如果您未传入,则会自动生成。格式必须为 `trace_<32_alphanumeric>`。 + - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,您可以使用聊天线程 ID。 - `disabled`:如果为 True,则不会记录该追踪。 - `metadata`:追踪的可选元数据。 -- **跨度**表示具有开始和结束时间的操作。跨度具有以下属性: - - `started_at`和`ended_at`时间戳。 - - `trace_id`,表示其所属的追踪 - - `parent_id`,指向此跨度的父跨度(如果有) - - `span_data`,即有关跨度的信息。例如,`AgentSpanData`包含有关智能体的信息,`GenerationSpanData`包含有关LLM生成的信息,依此类推。 +- **跨度**表示具有开始和结束时间的操作。跨度具有: + - `started_at` 和 `ended_at` 时间戳。 + - `trace_id`,表示它们所属的追踪 + - `parent_id`,指向此跨度的父跨度(如果存在) + - `span_data`,即有关跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关LLM生成的信息,依此类推。 ## 默认追踪 默认情况下,SDK 会追踪以下内容: -- 整个`Runner.{run, run_sync, run_streamed}()`都会封装在`trace()`中。 -- 每次运行器调用都会封装在`task_span()`中。 -- 每个模型轮次都会封装在`turn_span()`中。 -- 每次智能体运行都会封装在`agent_span()`中 -- LLM生成都会封装在`generation_span()`中 -- 每次函数工具调用都会封装在`function_span()`中 -- 安全防护措施都会封装在`guardrail_span()`中 -- 任务转移都会封装在`handoff_span()`中 -- 音频输入(语音转文本)都会封装在`transcription_span()`中 -- 音频输出(文本转语音)都会封装在`speech_span()`中 -- 相关的音频跨度可以将`speech_group_span()`设为父级 +- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在一个 `trace()` 中。 +- 每次运行器调用都封装在一个 `task_span()` 中。 +- 每个模型轮次都封装在一个 `turn_span()` 中。 +- 每次智能体运行时,都会封装在 `agent_span()` 中 +- LLM生成封装在 `generation_span()` 中 +- 每个函数工具调用都封装在 `function_span()` 中 +- 安全防护措施封装在 `guardrail_span()` 中 +- 任务转移封装在 `handoff_span()` 中 +- 音频输入(语音转文本)封装在一个 `transcription_span()` 中 +- 音频输出(文本转语音)封装在一个 `speech_span()` 中 +- SDK 可能会将相关音频跨度作为 `speech_group_span()` 的子跨度 -默认情况下,追踪名称为“Agent workflow”。使用`trace`时可以设置此名称,也可以通过[`RunConfig`][agents.run.RunConfig]配置名称及其他属性。 +默认情况下,追踪名称是字面字符串 `Agent workflow`。如果您使用 `trace`,则可以设置此名称;也可以通过 [`RunConfig`][agents.run.RunConfig] 配置名称和其他属性。 -如果希望使用更紧凑的层级结构,可以针对某次运行禁用自动创建的任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 +如果您希望层级结构更紧凑,可禁用某次运行的自动任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -此外,你还可以设置[自定义追踪进程](#custom-tracing-processors),将追踪推送到其他目标位置,以替代原目标位置或作为辅助目标位置。 +此外,您可以设置[自定义追踪处理器](#custom-tracing-processors),将追踪推送到其他目标位置(作为替代目标或次要目标)。 -## 长时间运行的工作进程与即时导出 +## 长时间运行的工作器与即时导出 -默认的[`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]每隔几秒在后台导出追踪;当内存队列达到其大小触发阈值时,会提前导出;进程退出时,还会执行最后一次刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时间运行的工作进程,这意味着通常无需任何额外代码即可自动导出追踪,但每项作业完成后,它们可能不会立即显示在追踪记录仪表板中。 +默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出追踪;当内存队列达到其大小触发阈值时,则会更早导出;此外,还会在进程退出时执行最后一次刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时间运行的工作器,这意味着通常无需任何额外代码即可自动导出追踪,但每项作业完成后,追踪可能不会立即显示在追踪仪表板中。 -如果需要确保在一个工作单元结束时立即交付,请在追踪上下文退出后调用[`flush_traces()`][agents.tracing.flush_traces]。 +如果您需要确保在一个工作单元结束时立即交付,请在追踪上下文退出后调用 [`flush_traces()`][agents.tracing.flush_traces]。 ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces]会阻塞,直到当前已缓冲的追踪和跨度全部导出。因此,请在`trace()`关闭后调用它,以避免刷新尚未构建完成的追踪。如果默认导出延迟可以接受,则可以跳过此调用。 +[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此请在 `trace()` 关闭后调用它,以免刷新尚未完整构建的追踪。如果默认导出延迟可以接受,则可以跳过此调用。 ## 高层级追踪 -有时,你可能希望多次调用`run()`都属于同一个追踪。为此,可以将整个代码封装在`trace()`中。 +有时,您可能希望对 `run()` 的多次调用都属于同一个追踪。为此,您可以将整个代码封装在一个 `trace()` 中。 ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 由于对`Runner.run`的两次调用都封装在`with trace()`中,因此各次运行将成为整体追踪的一部分,而不会创建两个追踪。 +1. 由于对 `Runner.run` 的两次调用都封装在一个 `with trace()` 中,因此这两次运行会成为同一个整体追踪的一部分,而不是各自创建单独的追踪。 ## 追踪的创建 -可以使用[`trace()`][agents.tracing.trace]函数创建追踪。追踪需要启动和结束,有以下两种方式: +您可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。为此,您有以下两种选择: -1. **推荐**:将追踪用作上下文管理器,即`with trace(...) as my_trace`。这会在适当的时间自动启动和结束追踪。 -2. 也可以手动调用[`trace.start()`][agents.tracing.Trace.start]和[`trace.finish()`][agents.tracing.Trace.finish]。 +1. **推荐**:将追踪用作上下文管理器,即 `with trace(...) as my_trace`。这样会在正确的时机自动启动和结束追踪。 +2. 您也可以手动调用 [`trace.start()`][agents.tracing.Trace.start] 和 [`trace.finish()`][agents.tracing.Trace.finish]。 -当前追踪通过 Python 的[`contextvar`](https://docs.python.org/3/library/contextvars.html)进行跟踪,这意味着它可以自动适配并发场景。如果手动启动或结束追踪,则需要将`mark_as_current`和`reset_current`传递给`start()`/`finish()`,以更新当前追踪。 +当前追踪通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它会自动支持并发。如果您手动启动和结束追踪,请将 `mark_as_current` 传给 `start()`,并将 `reset_current` 传给 `finish()`,以更新当前追踪。 ## 跨度的创建 -可以使用各种[`*_span()`][agents.tracing.create]方法创建跨度。通常不需要手动创建跨度。可以使用[`custom_span()`][agents.tracing.custom_span]函数跟踪自定义跨度信息。 +您可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。一般而言,您无需手动创建跨度。您可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 -跨度会自动成为当前追踪的一部分,并嵌套在最近的当前跨度之下;当前跨度通过 Python 的[`contextvar`](https://docs.python.org/3/library/contextvars.html)进行跟踪。 +跨度会自动成为当前追踪的一部分,并嵌套在最近的当前跨度之下;当前跨度通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 ## 敏感数据 某些跨度可能会捕获潜在的敏感数据。 -`generation_span()`会存储LLM生成的输入/输出,`function_span()`会存储函数调用的输入/输出。这些内容可能包含敏感数据,因此可以通过[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]禁用此类数据的捕获。 +`generation_span()` 会存储LLM生成的输入和输出,`function_span()` 会存储函数调用的输入和输出。这些内容可能包含敏感数据,因此您可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获这些数据。 -同样,默认情况下,音频跨度包含以 base64 编码的输入和输出音频 PCM 数据。可以通过配置[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]禁用对此类音频数据的捕获。 +同样,默认情况下,音频跨度会包含输入和输出音频的 base64 编码 PCM 数据。您可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data],禁止捕获这些音频数据。 -默认情况下,`trace_include_sensitive_data`为`True`。无需修改代码,只需在运行应用前将`OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA`环境变量导出为`true/1`或`false/0`,即可设置默认值。 +默认情况下,`trace_include_sensitive_data` 为 `True`。您可以在运行应用之前,将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,从而在不编写代码的情况下设置默认值。 -## 自定义追踪进程 +## 自定义追踪处理器 -追踪功能的高层架构如下: +追踪的高层级架构如下: -- 初始化时,会创建全局[`TraceProvider`][agents.tracing.provider.TraceProvider],负责创建追踪。 -- 我们会为`TraceProvider`配置一个[`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],由它将追踪/跨度分批发送到[`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter];后者会将跨度和追踪分批导出到OpenAI后端。 +- 初始化时,我们会创建一个全局 [`TraceProvider`][agents.tracing.provider.TraceProvider],负责创建追踪。 +- 我们为 `TraceProvider` 配置一个 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],它会将追踪和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者会将跨度和追踪分批导出到OpenAI后端。 -若要自定义此默认设置,将追踪发送到其他或更多后端,或修改导出器行为,有以下两种方式: +如果要自定义此默认设置,以便将追踪发送到替代或额外的后端,或修改导出器行为,您有以下两种选择: -1. [`add_trace_processor()`][agents.tracing.add_trace_processor]允许添加一个**额外的**追踪进程,它会在追踪和跨度准备就绪时接收它们。这样,除了将追踪发送到OpenAI后端之外,还可以执行自己的处理。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors]允许使用自己的追踪进程**替换**默认进程。这意味着,除非包含一个负责发送到OpenAI后端的`TracingProcessor`,否则追踪不会发送到OpenAI后端。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 允许您添加一个**额外的**追踪处理器,该处理器会在追踪和跨度准备就绪时接收它们。这样,除了将追踪发送到OpenAI后端之外,您还可以自行处理它们。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许您使用自己的追踪处理器**替换**默认处理器。这意味着,除非您包含一个能够发送追踪的 `TracingProcessor`,否则追踪不会发送到OpenAI后端。 ## 非OpenAI模型的追踪 -可以将OpenAI API 密钥与非OpenAI模型配合使用,从而在OpenAI追踪记录仪表板中启用免费追踪,而无需禁用追踪。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 +使用非OpenAI模型时,您可以向追踪导出器提供 OpenAI API 密钥,从而在不禁用追踪的情况下,在OpenAI追踪仪表板中启用免费追踪。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -如果仅需为单次运行使用不同的追踪密钥,请通过`RunConfig`传入,而不要更改全局导出器。 +如果您只需要为单次运行使用不同的追踪密钥,请通过 `RunConfig` 传入该密钥,而不要更改全局导出器。 ```python from agents import Runner, RunConfig @@ -197,21 +197,21 @@ await Runner.run( ) ``` -## 补充说明 -- 可在OpenAI追踪记录仪表板中查看免费的追踪记录。 +## 其他说明 +- 在OpenAI追踪仪表板中查看免费追踪。 ## 生态系统集成 -以下社区和供应商集成支持OpenAI Agents SDK追踪接口。 +以下社区和供应商集成支持OpenAI Agents SDK的追踪 API 接口。 -### 外部追踪进程列表 +### 外部追踪处理器列表 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) -- [MLflow(自托管/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow(Databricks 托管)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) +- [MLflow (self-hosted/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow (Databricks hosted)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) diff --git a/docs/zh/usage.md b/docs/zh/usage.md index 661e3b1ba6..ef88f129c4 100644 --- a/docs/zh/usage.md +++ b/docs/zh/usage.md @@ -2,24 +2,24 @@ search: exclude: true --- -# 使用量 +# 用量 -Agents SDK会自动追踪每次运行的token使用量。您可以从运行上下文中访问它,并用它来监控成本、强制执行限制或记录分析数据。 +Agents SDK会自动追踪每次运行的 token 用量。你可以从运行上下文中访问这些信息,用于监控成本、强制执行限制或记录分析数据。 ## 追踪内容 -- **requests**: 发起的LLM API调用次数 -- **input_tokens**: 发送的输入token总数 -- **output_tokens**: 接收的输出token总数 -- **total_tokens**: 输入 + 输出 -- **request_usage_entries**: 每个请求的使用量明细列表 -- **details**: +- **requests**:发出的 LLM API 调用次数 +- **input_tokens**:发送的输入 token 总数 +- **output_tokens**:接收的输出 token 总数 +- **total_tokens**:输入 + 输出 +- **request_usage_entries**:每个请求的用量明细列表 +- **details**: - `input_tokens_details.cached_tokens` - `output_tokens_details.reasoning_tokens` -## 运行中的使用量访问 +## 运行中的用量访问 -在`Runner.run(...)`之后,通过`result.context_wrapper.usage`访问使用量。 +在`Runner.run(...)`完成后,通过`result.context_wrapper.usage`访问用量。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -31,20 +31,20 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -使用量会在运行期间的所有模型调用中汇总(包括工具调用和任务转移)。 +用量会汇总运行期间的所有模型调用,包括产生工具调用或任务转移的模型调用。 -### 第三方适配器的使用量启用 +### 第三方适配器的用量启用 -使用量报告会因第三方适配器和提供商后端而异。如果您依赖由适配器支持的模型,并且需要准确的`result.context_wrapper.usage`值: +不同第三方适配器和提供商后端的用量报告方式各不相同。如果你通过第三方适配器访问模型,并且需要准确的`result.context_wrapper.usage`值: -- 使用`AnyLLMModel`时,当上游提供商返回使用量数据时,使用量会自动传递。对于流式传输的Chat Completions后端,可能需要设置`ModelSettings(include_usage=True)`后才会发出使用量数据块。 -- 使用`LitellmModel`时,某些提供商后端默认不报告使用量,因此通常需要`ModelSettings(include_usage=True)`。 +- 使用`AnyLLMModel`时,如果上游提供商返回用量数据,系统会自动传递该数据。从 Chat Completions后端流式传输响应时,可能需要设置`ModelSettings(include_usage=True)`,才能发出用量数据块。 +- 使用`LitellmModel`时,某些提供商后端默认不报告用量,因此通常需要`ModelSettings(include_usage=True)`。 -请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)部分的适配器特定说明,并验证您计划部署的具体提供商后端。 +请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)一节的适配器特定说明,并在你计划部署的确切提供商后端上验证用量报告。 -## 按请求的使用量追踪 +## 逐请求用量追踪 -SDK会在`request_usage_entries`中自动追踪每个API请求的使用量,可用于详细的成本计算和监控上下文窗口消耗。 +SDK 会自动在`request_usage_entries`中追踪每个 API 请求的用量,这有助于详细计算成本和监控上下文窗口占用情况。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -53,9 +53,9 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 会话中的使用量访问 +## 会话中的用量访问 -当使用`Session`(例如`SQLiteSession`)时,每次调用`Runner.run(...)`都会返回该特定运行的使用量。会话会维护用于上下文的对话历史,但每次运行的使用量都是独立的。 +使用`Session`(例如`SQLiteSession`)时,每次调用`Runner.run(...)`都会返回该次特定运行的用量。会话会保留对话历史记录以提供上下文,但每次运行的用量彼此独立。 ```python session = SQLiteSession("my_conversation") @@ -67,11 +67,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -请注意,尽管会话会在运行之间保留对话上下文,但每次`Runner.run()`调用返回的使用量指标仅代表该特定执行。在会话中,之前的消息可能会作为输入重新提供给每次运行,这会影响后续轮次中的输入token数量。 +请注意,虽然会话会在不同运行之间保留对话上下文,但每次调用`Runner.run()`返回的用量指标仅代表该次执行。在会话中,之前的消息可能会作为输入重新送入每次运行,这会影响后续轮次的输入 token 数量。 -## 钩子中的使用量访问 +## 钩子中的用量信息 -如果您使用`RunHooks`,传递给每个钩子的`context`对象都包含`usage`。这使您能够在关键生命周期时刻记录使用量。 +如果你使用`RunHooks`,传递给每个钩子的`context`对象都包含`usage`。这使你可以在生命周期的关键时刻记录用量。 ```python class MyHooks(RunHooks): @@ -80,11 +80,11 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API参考 +## API 参考 -有关详细的API文档,请参阅: +有关详细的 API 文档,请参阅: -- [`Usage`][agents.usage.Usage] - 使用量追踪数据结构 -- [`RequestUsage`][agents.usage.RequestUsage] - 每个请求的使用量详情 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文访问使用量 -- [`RunHooks`][agents.run.RunHooks] - 接入使用量追踪生命周期 \ No newline at end of file +- [`Usage`][agents.usage.Usage] - 用量追踪数据结构 +- [`RequestUsage`][agents.usage.RequestUsage] - 每个请求的用量详情 +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文中访问用量 +- [`RunHooks`][agents.run.RunHooks] - 接入用量追踪生命周期 \ No newline at end of file diff --git a/docs/zh/visualization.md b/docs/zh/visualization.md index 0ca7bbc69f..08b2baeadf 100644 --- a/docs/zh/visualization.md +++ b/docs/zh/visualization.md @@ -4,7 +4,7 @@ search: --- # 智能体可视化 -智能体可视化允许你使用 **Graphviz** 生成智能体及其关系的结构化图形表示。这有助于理解智能体、工具和任务转移在应用程序中的交互方式。 +智能体可视化功能允许你使用 **Graphviz**,生成智能体及其与其他智能体、工具和MCP服务器之间连接关系的结构化图形表示。这有助于理解应用程序中智能体、工具和任务转移之间的交互方式。 ## 安装 @@ -14,16 +14,16 @@ search: pip install "openai-agents[viz]" ``` -## 图的生成 +## 图形生成 -你可以使用 `draw_graph` 函数生成智能体可视化图。此函数会创建一个有向图,其中: +你可以使用 `draw_graph` 函数生成智能体可视化图形。此函数会创建一个有向图,其中: - **智能体**表示为黄色方框。 -- **MCP 服务**表示为灰色方框。 +- **MCP服务器**表示为灰色方框。 - **工具**表示为绿色椭圆。 - **任务转移**表示为从一个智能体指向另一个智能体的有向边。 -### 用法示例 +### 使用示例 ```python import os @@ -70,39 +70,39 @@ draw_graph(triage_agent) ![智能体图](../assets/images/graph.png) -这会生成一张图,用于直观表示**分诊智能体**的结构,以及它与子智能体和工具的连接关系。 +这会生成一幅图形,以可视化方式表示**分诊智能体**的结构及其与子智能体和工具的连接关系。 -## 可视化结果解析 +## 可视化解读 -生成的图包括: +生成的图形包括: -- 一个**起始节点**(`__start__`),表示入口点。 +- 表示入口点的**起始节点**(`__start__`)。 - 以黄色填充的**矩形**表示智能体。 - 以绿色填充的**椭圆**表示工具。 -- 以灰色填充的**矩形**表示 MCP 服务。 +- 以灰色填充的**矩形**表示MCP服务器。 - 表示交互的有向边: - - **实线箭头**表示智能体到智能体的任务转移。 + - **实线箭头**表示智能体之间的任务转移。 - **点线箭头**表示工具调用。 - - **虚线箭头**表示 MCP 服务调用。 -- 一个**结束节点**(`__end__`),表示执行终止的位置。 + - **虚线箭头**表示MCP服务器调用。 +- 表示执行终止位置的**结束节点**(`__end__`)。 -**注意:**MCP 服务会在较新版本的 `agents` 包中渲染(已在 **v0.2.8** 中验证)。如果你在可视化中没有看到 MCP 方框,请升级到最新版本。 +**注意:**较新版本的 `agents` 软件包会渲染MCP服务器,包括已验证此行为的 **v0.2.8**。如果可视化图形中没有显示MCP服务器方框,请升级到最新版本。 -## 图的自定义 +## 图形自定义 -### 图的显示 -默认情况下,`draw_graph` 会内联显示图。要在单独的窗口中显示图,请编写以下内容: +### 图形显示 +默认情况下,`draw_graph` 会内联显示图形。若要在单独的窗口中显示图形,请编写以下代码: ```python draw_graph(triage_agent).view() ``` -### 图的保存 -默认情况下,`draw_graph` 会内联显示图。要将其保存为文件,请指定文件名: +### 图形保存 +默认情况下,`draw_graph` 会内联显示图形。若要将其保存为文件,请指定文件名: ```python draw_graph(triage_agent, filename="agent_graph") ``` -这将在工作目录中生成 `agent_graph.png`。 \ No newline at end of file +这会在工作目录中生成 `agent_graph.png`。 \ No newline at end of file diff --git a/docs/zh/voice/pipeline.md b/docs/zh/voice/pipeline.md index 94d1ed1ba1..868ad94066 100644 --- a/docs/zh/voice/pipeline.md +++ b/docs/zh/voice/pipeline.md @@ -2,9 +2,9 @@ search: exclude: true --- -# 管道和工作流 +# 流水线与工作流 -[`VoicePipeline`][agents.voice.pipeline.VoicePipeline] 是一个类,可让你轻松将智能体式工作流转换为语音应用。你传入要运行的工作流,管道会负责转写输入音频、检测音频何时结束、在合适的时机调用你的工作流,并将工作流输出转换回音频。 +[`VoicePipeline`][agents.voice.pipeline.VoicePipeline] 是一个类,可让您轻松地将智能体工作流转变为语音应用。您传入要运行的工作流,流水线则负责转录输入音频、检测音频何时结束、在适当的时间调用工作流,以及将工作流输出转换回音频。 ```mermaid graph LR @@ -32,31 +32,31 @@ graph LR ``` -## 管道配置 +## 流水线配置 -创建管道时,你可以设置以下几项: +创建流水线时,您可以设置以下几项: -1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase],即每当有新的音频被转写时运行的代码。 -2. 使用的 [`speech-to-text`][agents.voice.model.STTModel] 和 [`text-to-speech`][agents.voice.model.TTSModel] 模型 +1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase],即每次转录新音频时运行的代码。 +2. 所使用的 [`speech-to-text`][agents.voice.model.STTModel] 和 [`text-to-speech`][agents.voice.model.TTSModel] 模型。 3. [`config`][agents.voice.pipeline_config.VoicePipelineConfig],可用于配置以下内容: - - 模型提供方,可将模型名称映射到模型 - - 追踪,包括是否禁用追踪、是否上传音频文件、工作流名称、追踪 ID 等。 - - TTS 和 STT 模型的设置,例如提示词、语言以及使用的数据类型。 + - 模型提供商,可将模型名称映射到模型 + - 追踪,包括是否禁用追踪、是否上传音频文件、工作流名称、追踪 ID 等 + - TTS 和 STT 模型的设置,例如提示词、语言和所使用的数据类型。 -## 管道运行 +## 流水线运行 -你可以通过 [`run()`][agents.voice.pipeline.VoicePipeline.run] 方法运行管道,该方法允许你以两种形式传入音频输入: +您可以通过 [`run()`][agents.voice.pipeline.VoicePipeline.run] 方法运行流水线。该方法允许您传入以下两种形式的音频输入: -1. 当你已有完整的音频输入,并且只想为其生成结果时,可以使用 [`AudioInput`][agents.voice.input.AudioInput]。这适用于不需要检测说话者何时说完的场景;例如,你有预录音频,或者在按键通话应用中,用户何时说完是明确的。 -2. 当你可能需要检测用户何时说完时,可以使用 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]。它允许你在检测到音频片段时将其推送进去,语音管道会通过一个称为“活动检测”的过程,在合适的时机自动运行智能体工作流。 +1. 当您已有完整的音频输入,只想为其生成结果时,请使用 [`AudioInput`][agents.voice.input.AudioInput]。它适用于无需检测说话者何时说完的情况,例如已有预录音频,或在按键通话应用中,可以明确知道用户何时说完。 +2. 当您可能需要检测用户何时说完时,请使用 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]。它允许您在检测到音频块时将其推送进来,语音流水线会通过名为“活动检测”的过程,在适当的时间自动运行智能体工作流。 ## 结果 -语音管道运行的结果是 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]。这是一个对象,可让你在事件发生时以流式方式传输这些事件。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] 有几种类型,包括: +语音流水线的运行结果是 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]。借助此对象,您可以在事件发生时以流式方式获取事件。它包含以下几种 [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent]: -1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio],其中包含一个音频片段。 -2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle],用于告知你轮次开始或结束等生命周期事件。 -3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError],这是一种错误事件。 +1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio],其中包含一个音频块。 +2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle],用于通知您轮次开始或结束等生命周期事件。 +3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError],表示错误事件。 ```python @@ -78,4 +78,4 @@ async for event in result.stream(): ### 中断 -Agents SDK 目前没有为 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 提供任何内置的中断处理。相反,每个检测到的轮次都会触发你的工作流单独运行一次。如果你想在应用内处理中断,可以监听 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 事件。`turn_started` 表示已转写出新的轮次,且处理即将开始。`turn_ended` 会在相应轮次的所有音频都分发完毕后触发。你可以使用这些事件,在模型开始一个轮次时将说话者的麦克风静音,并在该轮次的所有相关音频都刷新完后取消静音。 \ No newline at end of file +Agents SDK 目前未针对 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 提供任何内置的中断处理机制。相反,检测到的每个轮次都会触发工作流的一次独立运行。如果您希望在应用程序内处理中断,可以监听 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 事件。`turn_started` 表示新轮次已转录完毕并开始处理。`turn_ended` 会在相应轮次的所有音频分发完毕后触发。您可以利用这些事件,在模型开始一个轮次时将说话者的麦克风静音,并在应用程序播放完与该轮次相关的所有音频后取消静音。 \ No newline at end of file diff --git a/docs/zh/voice/quickstart.md b/docs/zh/voice/quickstart.md index 808e9b8b42..a875a1b99a 100644 --- a/docs/zh/voice/quickstart.md +++ b/docs/zh/voice/quickstart.md @@ -4,7 +4,7 @@ search: --- # 快速入门 -## 前置条件 +## 前提条件 请确保已按照 Agents SDK 的基础[快速入门说明](../quickstart.md)完成操作,并设置好虚拟环境。然后,从 SDK 安装可选的语音依赖项: @@ -12,12 +12,18 @@ search: pip install 'openai-agents[voice]' ``` +下面的演示代码还使用了 [`sounddevice`](https://pypi.org/project/sounddevice/) 处理麦克风和扬声器 I/O,但它不属于 `voice` extra: + +```bash +pip install sounddevice +``` + ## 概念 -需要了解的核心概念是 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline],它包含以下三个步骤: +需要了解的主要概念是 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline],它包含三个步骤: 1. 运行语音转文本模型,将音频转换为文本。 -2. 运行你的代码(通常是智能体工作流)以生成结果。 +2. 运行您的代码(通常是智能体工作流)以生成结果。 3. 运行文本转语音模型,将结果文本转换回音频。 ```mermaid @@ -48,7 +54,7 @@ graph LR ## 智能体 -首先,我们来设置一些智能体。如果你曾使用此 SDK 构建过智能体,这部分应该会很熟悉。我们将使用两个智能体、一次任务转移和一个工具。 +首先,我们来设置一些智能体。如果您曾使用此 SDK 构建过智能体,这些内容应该会很熟悉。我们将设置两个智能体、一项已配置的任务转移和一个工具。 ```python import random @@ -58,7 +64,6 @@ from agents.decorators import tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions - @tool def get_weather(city: str) -> str: """Get the weather for a given city.""" @@ -189,4 +194,4 @@ if __name__ == "__main__": asyncio.run(main()) ``` -运行此示例后,智能体就会与你进行语音交流!请查看 [examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static) 中的示例,了解如何亲自与智能体进行语音交流。 \ No newline at end of file +运行此代码示例后,智能体将生成可供您收听的语音音频!请查看 [examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static) 中的代码示例,了解如何亲自与智能体进行语音对话。 \ No newline at end of file From f3ad9f30bb620b7f90573508e1594743465f1624 Mon Sep 17 00:00:00 2001 From: hari Date: Sun, 9 Aug 2026 03:42:57 +0530 Subject: [PATCH 238/473] fix(sessions): restore session history when compaction replacement is cancelled (#4298) --- .../openai_responses_compaction_session.py | 125 ++++-- ...est_openai_responses_compaction_session.py | 365 ++++++++++++++++++ 2 files changed, 454 insertions(+), 36 deletions(-) diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index a8dff17e33..1e8456bb46 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -1,7 +1,8 @@ from __future__ import annotations +import asyncio import logging -from collections.abc import Callable +from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, Literal, cast from openai import AsyncOpenAI @@ -136,6 +137,9 @@ def __init__( self._response_id: str | None = None self._deferred_response_id: str | None = None self._last_unstored_response_id: str | None = None + # Serialize wrapper mutations against compaction snapshot/replace/restore so a + # cancellation rollback cannot rewrite past a newer concurrent write. + self._mutation_lock = asyncio.Lock() @property def client(self) -> AsyncOpenAI: @@ -226,21 +230,21 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None _normalize_compaction_output_items(compacted.output or []) ) - previous_items = await self._get_all_underlying_session_items() - await self._replace_underlying_session_items( - output_items=output_items, - previous_items=previous_items, - ) - - self._compaction_candidate_items = select_compaction_candidate_items(output_items) - self._session_items = output_items + async with self._mutation_lock: + previous_items = await self._get_all_underlying_session_items() + await self._replace_underlying_session_items( + output_items=output_items, + previous_items=previous_items, + ) + self._compaction_candidate_items = select_compaction_candidate_items(output_items) + self._session_items = output_items logger.debug( "compact: done for %s (mode=%s, output=%s, candidates=%s)", self._response_id, resolved_mode, len(output_items), - len(self._compaction_candidate_items), + len(self._compaction_candidate_items or []), ) async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: @@ -255,25 +259,71 @@ async def _replace_underlying_session_items( output_items: list[TResponseInputItem], previous_items: list[TResponseInputItem], ) -> None: + # Treat clear → add as one replacement transaction. Exception and CancelledError + # both restore previous history, and restore settlement is always drained so a + # cancel during restore cannot leave an empty session. + cleared = False try: await self.underlying_session.clear_session() - except Exception as clear_error: - await self._restore_underlying_session_items_after_failed_clear( - previous_items, clear_error + cleared = True + if output_items: + await self.underlying_session.add_items(output_items) + except Exception as error: + await self._recover_from_failed_replacement( + previous_items=previous_items, + error=error, + cleared=cleared, ) raise + except asyncio.CancelledError as error: + await self._recover_from_failed_replacement( + previous_items=previous_items, + error=error, + cleared=cleared, + ) + raise + + async def _recover_from_failed_replacement( + self, + *, + previous_items: list[TResponseInputItem], + error: BaseException, + cleared: bool, + ) -> None: + if not cleared: + restore = self._restore_underlying_session_items_after_failed_clear( + previous_items, error + ) + else: + restore = self._restore_underlying_session_items(previous_items, error) + await self._await_restore_despite_cancellation(restore) + + async def _await_restore_despite_cancellation(self, restore: Awaitable[None]) -> None: + """Await restore even when the current task keeps receiving cancellation. + ``asyncio.shield`` alone is not enough: a second ``task.cancel()`` makes + ``await asyncio.shield(restore)`` raise immediately while restore is still + running. Keep re-awaiting the shielded task until it settles, then + re-raise ``CancelledError`` so callers still observe cancellation. + """ + restore_task = asyncio.ensure_future(restore) try: - if output_items: - await self.underlying_session.add_items(output_items) - except Exception as replacement_error: - await self._restore_underlying_session_items(previous_items, replacement_error) + await asyncio.shield(restore_task) + except asyncio.CancelledError: + while not restore_task.done(): + try: + await asyncio.shield(restore_task) + except asyncio.CancelledError: + continue + # Retrieve the restore outcome so a failed restore does not warn about an + # unretrieved task exception after we re-raise cancellation. + _ = restore_task.exception() if not restore_task.cancelled() else None raise async def _restore_underlying_session_items_after_failed_clear( self, previous_items: list[TResponseInputItem], - clear_error: Exception, + clear_error: BaseException, ) -> None: try: current_items = await self._get_all_underlying_session_items() @@ -295,7 +345,7 @@ async def _restore_underlying_session_items_after_failed_clear( async def _restore_underlying_session_items( self, previous_items: list[TResponseInputItem], - replacement_error: Exception, + replacement_error: BaseException, *, clear_existing_items: bool = True, ) -> None: @@ -345,27 +395,30 @@ def _clear_deferred_compaction(self) -> None: self._deferred_response_id = None async def add_items(self, items: list[TResponseInputItem]) -> None: - await self.underlying_session.add_items(items) - if self._compaction_candidate_items is not None: - new_items = _normalize_compaction_session_items(items) - new_candidates = select_compaction_candidate_items(new_items) - if new_candidates: - self._compaction_candidate_items.extend(new_candidates) - if self._session_items is not None: - self._session_items.extend(_normalize_compaction_session_items(items)) + async with self._mutation_lock: + await self.underlying_session.add_items(items) + if self._compaction_candidate_items is not None: + new_items = _normalize_compaction_session_items(items) + new_candidates = select_compaction_candidate_items(new_items) + if new_candidates: + self._compaction_candidate_items.extend(new_candidates) + if self._session_items is not None: + self._session_items.extend(_normalize_compaction_session_items(items)) async def pop_item(self) -> TResponseInputItem | None: - popped = await self.underlying_session.pop_item() - if popped: - self._compaction_candidate_items = None - self._session_items = None - return popped + async with self._mutation_lock: + popped = await self.underlying_session.pop_item() + if popped: + self._compaction_candidate_items = None + self._session_items = None + return popped async def clear_session(self) -> None: - await self.underlying_session.clear_session() - self._compaction_candidate_items = [] - self._session_items = [] - self._deferred_response_id = None + async with self._mutation_lock: + await self.underlying_session.clear_session() + self._compaction_candidate_items = [] + self._session_items = [] + self._deferred_response_id = None async def _ensure_compaction_candidates( self, diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index b0b20bad28..ae0c7e2028 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging import warnings as warnings_module from types import SimpleNamespace @@ -15,6 +16,7 @@ OpenAIResponsesCompactionSession, Session, SessionSettings, + SQLiteSession, is_openai_responses_compaction_aware_session, ) from agents.memory.openai_responses_compaction_session import ( @@ -595,6 +597,369 @@ async def clear_session(self) -> None: assert failing_session.clear_calls == 2 assert failing_session.add_calls == 2 + @pytest.mark.asyncio + async def test_run_compaction_restores_history_when_replacement_add_is_cancelled( + self, + ) -> None: + """CancelledError after clear must restore history (BaseException, not Exception).""" + history: list[TResponseInputItem] = [ + cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}), + cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "call_123", + "name": "lookup", + "arguments": "{}", + TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup private records.", + }, + ), + ] + compacted_items: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "compacted"}, + ) + ] + + class CancelOnReplacementAddSession(SimpleListSession): + def __init__(self, history: list[TResponseInputItem]) -> None: + super().__init__(history=history) + self.add_calls = 0 + self.clear_calls = 0 + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.add_calls += 1 + if self.add_calls == 1: + raise asyncio.CancelledError() + await super().add_items(items) + + async def clear_session(self) -> None: + self.clear_calls += 1 + await super().clear_session() + + failing_session = CancelOnReplacementAddSession(history=history) + + mock_compact_response = MagicMock() + mock_compact_response.output = compacted_items + + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock(return_value=mock_compact_response) + + session = OpenAIResponsesCompactionSession( + session_id="test", + underlying_session=failing_session, + client=mock_client, + compaction_mode="input", + ) + + with pytest.raises(asyncio.CancelledError): + await session.run_compaction({"force": True}) + + assert await failing_session.get_items() == history + assert failing_session.clear_calls == 2 + assert failing_session.add_calls == 2 + + @pytest.mark.asyncio + async def test_run_compaction_restores_history_when_clear_is_cancelled_after_mutation( + self, + ) -> None: + """CancelledError after a mutating clear must restore without a second destructive clear.""" + history: list[TResponseInputItem] = [ + cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}), + ] + compacted_items: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "compacted"}, + ) + ] + + class CancelAfterMutatingClearSession(SimpleListSession): + def __init__(self, history: list[TResponseInputItem]) -> None: + super().__init__(history=history) + self.add_calls = 0 + self.clear_calls = 0 + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.add_calls += 1 + await super().add_items(items) + + async def clear_session(self) -> None: + self.clear_calls += 1 + await super().clear_session() + raise asyncio.CancelledError() + + failing_session = CancelAfterMutatingClearSession(history=history) + + mock_compact_response = MagicMock() + mock_compact_response.output = compacted_items + + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock(return_value=mock_compact_response) + + session = OpenAIResponsesCompactionSession( + session_id="test", + underlying_session=failing_session, + client=mock_client, + compaction_mode="input", + ) + + with pytest.raises(asyncio.CancelledError): + await session.run_compaction({"force": True}) + + assert await failing_session.get_items() == history + assert failing_session.clear_calls == 1 + assert failing_session.add_calls == 1 + + @pytest.mark.asyncio + async def test_run_compaction_restores_history_when_cancelled_again_during_restore( + self, + ) -> None: + """A second cancel during restore must still finish rewriting previous history.""" + history: list[TResponseInputItem] = [ + cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}), + cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": "reply"}), + ] + compacted_items: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "compacted"}, + ) + ] + + class CancelThenGateRestoreSession(SimpleListSession): + def __init__(self, history: list[TResponseInputItem]) -> None: + super().__init__(history=history) + self.add_calls = 0 + self.clear_calls = 0 + self.restore_add_started = asyncio.Event() + self.allow_restore_add = asyncio.Event() + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.add_calls += 1 + if self.add_calls == 1: + raise asyncio.CancelledError() + # Second add is the restore rewrite after clear. + self.restore_add_started.set() + await self.allow_restore_add.wait() + await super().add_items(items) + + async def clear_session(self) -> None: + self.clear_calls += 1 + await super().clear_session() + + def snapshot(self) -> list[TResponseInputItem]: + return list(self._items) + + failing_session = CancelThenGateRestoreSession(history=history) + + mock_compact_response = MagicMock() + mock_compact_response.output = compacted_items + + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock(return_value=mock_compact_response) + + session = OpenAIResponsesCompactionSession( + session_id="test", + underlying_session=failing_session, + client=mock_client, + compaction_mode="input", + ) + + compaction_task = asyncio.create_task(session.run_compaction({"force": True})) + await failing_session.restore_add_started.wait() + # Deliver a second cancel while restore add is still blocked on the gate. + compaction_task.cancel() + await asyncio.sleep(0) + assert not failing_session.allow_restore_add.is_set() + assert failing_session.snapshot() == [] + + failing_session.allow_restore_add.set() + + with pytest.raises(asyncio.CancelledError): + await compaction_task + + # History must already be restored when CancelledError surfaces — not later + # via an orphaned background rewrite after the await returns. + assert failing_session.snapshot() == history + assert failing_session.clear_calls == 2 + assert failing_session.add_calls == 2 + + @pytest.mark.asyncio + async def test_cancel_restore_waits_for_mutation_lock_before_newer_writes( + self, tmp_path + ) -> None: + """Newer wrapper writes must wait out cancel-restore and survive chronologically.""" + history: list[TResponseInputItem] = [ + cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}), + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "reply"}, + ), + ] + newer_item: TResponseInputItem = cast( + TResponseInputItem, + {"type": "message", "role": "user", "content": "newer-after-cancel"}, + ) + compacted_items: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "compacted"}, + ) + ] + + class GatedSQLiteSession(SQLiteSession): + def __init__(self, session_id: str, db_path: str) -> None: + super().__init__(session_id, db_path) + self.add_calls = 0 + self.clear_calls = 0 + self.cancel_replacement_add = False + self.restore_clear_started = asyncio.Event() + self.allow_restore_clear = asyncio.Event() + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.add_calls += 1 + if self.cancel_replacement_add and self.add_calls == 1: + raise asyncio.CancelledError() + await super().add_items(items) + + async def clear_session(self) -> None: + self.clear_calls += 1 + if self.clear_calls == 2: + self.restore_clear_started.set() + await self.allow_restore_clear.wait() + await super().clear_session() + + underlying = GatedSQLiteSession("lock-test", str(tmp_path / "compaction_lock.db")) + await underlying.add_items(history) + underlying.add_calls = 0 + underlying.clear_calls = 0 + underlying.cancel_replacement_add = True + + mock_compact_response = MagicMock() + mock_compact_response.output = compacted_items + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock(return_value=mock_compact_response) + + session = OpenAIResponsesCompactionSession( + session_id="lock-test", + underlying_session=underlying, + client=mock_client, + compaction_mode="input", + ) + # Warm wrapper caches so later add_items updates _session_items in place. + await session._ensure_compaction_candidates() + + compaction_task = asyncio.create_task(session.run_compaction({"force": True})) + await underlying.restore_clear_started.wait() + + newer_write = asyncio.create_task(session.add_items([newer_item])) + await asyncio.sleep(0) + assert not newer_write.done() + assert underlying.clear_calls == 2 + assert not underlying.allow_restore_clear.is_set() + + underlying.allow_restore_clear.set() + + with pytest.raises(asyncio.CancelledError): + await compaction_task + await newer_write + + stored = await session.get_items() + assert stored == [*history, newer_item] + assert session._session_items == [*history, newer_item] + assert underlying.clear_calls == 2 + # 1) cancelled replacement add, 2) restore rewrite, 3) newer wrapper write. + assert underlying.add_calls == 3 + + @pytest.mark.asyncio + async def test_exception_restore_drains_when_compaction_is_cancelled(self, tmp_path) -> None: + """Cancel during Exception-path restore must still finish rewriting history.""" + history: list[TResponseInputItem] = [ + cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}), + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "reply"}, + ), + ] + compacted_items: list[TResponseInputItem] = [ + cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "compacted"}, + ) + ] + + class ExceptionThenGateRestoreSQLiteSession(SQLiteSession): + def __init__(self, session_id: str, db_path: str) -> None: + super().__init__(session_id, db_path) + self.add_calls = 0 + self.clear_calls = 0 + self.fail_replacement_add = False + self.restore_add_started = asyncio.Event() + self.allow_restore_add = asyncio.Event() + self.restore_tasks_seen: list[asyncio.Task[Any]] = [] + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.add_calls += 1 + if self.fail_replacement_add and self.add_calls == 1: + raise RuntimeError("replacement failed") + if self.fail_replacement_add and self.add_calls == 2: + current = asyncio.current_task() + if current is not None: + self.restore_tasks_seen.append(current) + self.restore_add_started.set() + await self.allow_restore_add.wait() + await super().add_items(items) + + async def clear_session(self) -> None: + self.clear_calls += 1 + await super().clear_session() + + underlying = ExceptionThenGateRestoreSQLiteSession( + "exception-cancel-restore", str(tmp_path / "exception_cancel_restore.db") + ) + await underlying.add_items(history) + underlying.add_calls = 0 + underlying.clear_calls = 0 + underlying.fail_replacement_add = True + + mock_compact_response = MagicMock() + mock_compact_response.output = compacted_items + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock(return_value=mock_compact_response) + + session = OpenAIResponsesCompactionSession( + session_id="exception-cancel-restore", + underlying_session=underlying, + client=mock_client, + compaction_mode="input", + ) + await session._ensure_compaction_candidates() + warmed_items = list(session._session_items or []) + + compaction_task = asyncio.create_task(session.run_compaction({"force": True})) + await underlying.restore_add_started.wait() + + compaction_task.cancel() + await asyncio.sleep(0) + assert not compaction_task.done() + assert not underlying.allow_restore_add.is_set() + assert await underlying.get_items() == [] + + underlying.allow_restore_add.set() + + with pytest.raises(asyncio.CancelledError): + await compaction_task + + stored = await session.get_items() + assert stored == history + assert session._session_items == warmed_items + assert not session._mutation_lock.locked() + assert underlying.clear_calls == 2 + assert underlying.add_calls == 2 + assert all(task.done() for task in underlying.restore_tasks_seen) + @pytest.mark.asyncio async def test_run_compaction_restores_full_history_when_session_limit_applies( self, From c761dd602c26558890ee10593ed8d499a26e497d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 07:32:12 +0900 Subject: [PATCH 239/473] fix: keep FunctionTool subclasses copyable (#4311) Co-authored-by: LeSingh1 --- src/agents/sandbox/capabilities/capability.py | 4 +- src/agents/tool.py | 22 +-- tests/sandbox/test_runtime.py | 59 +++++++- tests/test_function_tool.py | 137 ++++++++++++++++++ 4 files changed, 210 insertions(+), 12 deletions(-) diff --git a/src/agents/sandbox/capabilities/capability.py b/src/agents/sandbox/capabilities/capability.py index c547227f23..e0b169463c 100644 --- a/src/agents/sandbox/capabilities/capability.py +++ b/src/agents/sandbox/capabilities/capability.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field from ...items import TResponseInputItem -from ...tool import Tool +from ...tool import FunctionTool, Tool from ..manifest import Manifest from ..session.base_sandbox_session import BaseSandboxSession from ..types import User @@ -90,6 +90,8 @@ def _clone_capability_value(value: Any) -> Any: if hasattr(value, "__dict__"): cloned = copy.copy(value) for name, nested in value.__dict__.items(): + if isinstance(value, FunctionTool) and name == "on_invoke_tool": + continue setattr(cloned, name, _clone_capability_value(nested)) return cloned try: diff --git a/src/agents/tool.py b/src/agents/tool.py index 10c178d41e..768a8e32bf 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -3,7 +3,6 @@ import ast import asyncio import copy -import dataclasses import functools import inspect import json @@ -600,15 +599,18 @@ def __post_init__(self): _validate_function_tool_timeout_config(self) def __copy__(self) -> FunctionTool: - copied_tool = dataclasses.replace(self) - dataclass_field_names = {tool_field.name for tool_field in dataclasses.fields(FunctionTool)} - for tool_field in dataclasses.fields(FunctionTool): - if tool_field.init: - continue - setattr(copied_tool, tool_field.name, getattr(self, tool_field.name)) - for attr_name, attr_value in self.__dict__.items(): - if attr_name not in dataclass_field_names: - setattr(copied_tool, attr_name, attr_value) + # Rebuild the instance state directly instead of re-running the constructor, so + # FunctionTool subclasses that define their own __init__ signature stay copyable. + copied_tool = object.__new__(type(self)) + copied_tool.__dict__.update(self.__dict__) + # A subclass may pass one of its own bound methods as the invoker, e.g. + # on_invoke_tool=self._invoke. Copying the __dict__ carries that binding over + # unchanged, so the copy would run against the original instance's state. + invoker = copied_tool.__dict__.get("on_invoke_tool") + if inspect.ismethod(invoker) and getattr(invoker, "__self__", None) is self: + copied_tool.on_invoke_tool = invoker.__func__.__get__(copied_tool, type(copied_tool)) + # Reapply FunctionTool normalization without rerunning subclass lifecycle hooks. + FunctionTool.__post_init__(copied_tool) return copied_tool diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 55810a5b52..6339fc4b4c 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -87,7 +87,8 @@ from agents.sandbox.snapshot import LocalSnapshotSpec, NoopSnapshot, SnapshotBase from agents.sandbox.types import ExecResult from agents.stream_events import RunItemStreamEvent -from agents.tool import Tool +from agents.tool import FunctionTool, Tool +from agents.tool_context import ToolContext from agents.tracing import trace from tests.fake_model import FakeModel from tests.test_responses import ( @@ -666,6 +667,25 @@ def __init__(self) -> None: ) +class _StatefulFunctionTool(FunctionTool): + def __init__(self, state: str) -> None: + self.state = state + super().__init__( + name="stateful_tool", + description="Return state from the tool instance.", + params_json_schema={}, + on_invoke_tool=self._invoke, + ) + + async def _invoke(self, _ctx: ToolContext[Any], _raw_input: str) -> str: + return self.state + + +class _ToolStateCapability(Capability): + type: Literal["tool-state"] = "tool-state" + tool: Any + + class _AwaitableSessionCapability(Capability): type: str = "awaitable-session" bound_session: BaseSandboxSession | None = None @@ -4669,6 +4689,43 @@ def test_capability_clone_preserves_session_field_identity() -> None: assert cloned.model_dump() == {"type": "shell"} +@pytest.mark.asyncio +async def test_capability_clone_preserves_function_tool_invoker_owner() -> None: + original_tool = _StatefulFunctionTool("original") + capability = _ToolStateCapability(tool=original_tool) + + cloned = cast(_ToolStateCapability, capability.clone()) + cloned_tool = cast(_StatefulFunctionTool, cloned.tool) + cloned_tool.state = "cloned" + + assert cloned_tool is not original_tool + assert getattr(cloned_tool.on_invoke_tool, "__self__", None) is cloned_tool + assert ( + await cloned_tool.on_invoke_tool( + ToolContext( + None, + tool_name=cloned_tool.name, + tool_call_id="1", + tool_arguments="{}", + ), + "{}", + ) + == "cloned" + ) + assert ( + await original_tool.on_invoke_tool( + ToolContext( + None, + tool_name=original_tool.name, + tool_call_id="2", + tool_arguments="{}", + ), + "{}", + ) + == "original" + ) + + @pytest.mark.asyncio async def test_apply_manifest_raises_on_account_provisioning_failures() -> None: session = _ProvisioningFailureSession( diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index 6fafb77890..f3b5aea683 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -820,6 +820,143 @@ def boom() -> None: assert cast(Any, copied_tool).custom_state is custom_state +@dataclasses.dataclass(init=False) +class _CustomConstructorFunctionTool(FunctionTool): + """FunctionTool subclass with its own constructor, like the sandbox shell tools.""" + + session: Any = dataclasses.field(init=False, repr=False, compare=False) + + def __init__(self, *, session: Any) -> None: + self.session = session + super().__init__( + name="custom_constructor_tool", + description="Tool with a custom constructor.", + params_json_schema={ + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + }, + on_invoke_tool=self._invoke, + ) + + async def _invoke(self, _ctx: ToolContext[Any], raw_input: str) -> str: + # Reads instance state so a copy that is still bound to the original is visible. + return f"{self.session}:{raw_input}" + + +@dataclasses.dataclass +class _PostInitStateFunctionTool(FunctionTool): + """FunctionTool subclass whose lifecycle hook owns additional shallow state.""" + + post_init_calls: int = dataclasses.field(default=0, init=False) + derived_state: list[str] = dataclasses.field(default_factory=list, init=False) + + def __post_init__(self) -> None: + super().__post_init__() + self.post_init_calls += 1 + self.derived_state.append(f"initialized-{self.post_init_calls}") + + +def _tool_context(tool: FunctionTool) -> ToolContext[Any]: + return ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments="{}") + + +@pytest.mark.asyncio +async def test_shallow_copy_supports_function_tool_subclass_constructors() -> None: + session = object() + original_tool = _CustomConstructorFunctionTool(session=session) + + copied_tool = copy.copy(original_tool) + + assert isinstance(copied_tool, _CustomConstructorFunctionTool) + assert copied_tool is not original_tool + assert copied_tool.session is session + assert copied_tool.name == original_tool.name + assert await copied_tool.on_invoke_tool(_tool_context(copied_tool), "{}") == f"{session}:{{}}" + + +@pytest.mark.asyncio +async def test_shallow_copied_subclass_invoker_uses_the_copied_instance_state() -> None: + original_tool = _CustomConstructorFunctionTool(session="original-session") + + copied_tool = copy.copy(original_tool) + copied_tool.session = "copied-session" + + # The subclass passes its own bound method as the invoker, so the copy must be + # rebound; otherwise it keeps reading the original instance's session. + assert await copied_tool.on_invoke_tool(_tool_context(copied_tool), "{}") == ( + "copied-session:{}" + ) + assert await original_tool.on_invoke_tool(_tool_context(original_tool), "{}") == ( + "original-session:{}" + ) + + +@pytest.mark.asyncio +async def test_shallow_copy_preserves_callable_with_bound_method_metadata() -> None: + async def invoke(_ctx: ToolContext[Any], raw_input: str) -> str: + return raw_input + + async def metadata_target(_tool: FunctionTool, _ctx: ToolContext[Any], _raw_input: str) -> str: + return "metadata-target" + + class CallableProxy: + def __init__(self, owner: FunctionTool) -> None: + self.__self__ = owner + self.__func__ = metadata_target + + async def __call__(self, _ctx: ToolContext[Any], _raw_input: str) -> str: + return "proxy-call" + + original_tool = FunctionTool( + name="callable_proxy_tool", + description="Tool with bound-method-like callable metadata.", + params_json_schema={}, + on_invoke_tool=invoke, + ) + proxy = CallableProxy(original_tool) + original_tool.on_invoke_tool = proxy + + copied_tool = copy.copy(original_tool) + + assert copied_tool.on_invoke_tool is proxy + assert await copied_tool.on_invoke_tool(_tool_context(copied_tool), "{}") == "proxy-call" + + +def test_shallow_copy_does_not_rerun_subclass_post_init() -> None: + async def invoke(_ctx: ToolContext[Any], raw_input: str) -> str: + return raw_input + + original_tool = _PostInitStateFunctionTool( + name="post_init_tool", + description="Tool with subclass post-init state.", + params_json_schema={}, + on_invoke_tool=invoke, + ) + original_tool.derived_state.append("mutated") + + copied_tool = copy.copy(original_tool) + + assert copied_tool.post_init_calls == 1 + assert copied_tool.derived_state is original_tool.derived_state + assert copied_tool.derived_state == ["initialized-1", "mutated"] + + +def test_tool_namespace_supports_function_tool_subclass_constructors() -> None: + original_tool = _CustomConstructorFunctionTool(session=object()) + + namespaced_tool = tool_namespace( + name="workspace", + description="Workspace tools.", + tools=[original_tool], + )[0] + + assert isinstance(namespaced_tool, _CustomConstructorFunctionTool) + assert namespaced_tool.qualified_name == "workspace.custom_constructor_tool" + assert original_tool.qualified_name == "custom_constructor_tool" + + @pytest.mark.asyncio @pytest.mark.parametrize("copy_style", ["replace", "shallow_copy"]) async def test_copied_function_tool_invalid_input_uses_current_name(copy_style: str) -> None: From cdde4d651d7109096482153bbc48368e91857134 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 08:06:28 +0900 Subject: [PATCH 240/473] fix(run): preserve streamed guardrail session state (#4312) Co-authored-by: LeSingh1 --- src/agents/exceptions.py | 16 +- src/agents/result.py | 16 +- src/agents/run_internal/run_loop.py | 59 ++++- tests/test_agent_runner_streamed.py | 311 ++++++++++++++++++++++++++ tests/test_error_logging_redaction.py | 155 +++++++++++++ 5 files changed, 535 insertions(+), 22 deletions(-) diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index 887ea910ba..ed7af430dc 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -24,33 +24,35 @@ _DATA_REDACTED_ERROR_MESSAGE = "Error details are redacted." -def _mark_error_to_drain_stream_events(error: Exception) -> None: +def _mark_error_to_drain_stream_events(error: BaseException) -> None: setattr(error, _DRAIN_STREAM_EVENTS_ATTR, True) -def _should_drain_stream_events_before_raising(error: Exception) -> bool: +def _should_drain_stream_events_before_raising(error: BaseException) -> bool: return bool(getattr(error, _DRAIN_STREAM_EVENTS_ATTR, False)) -def _mark_error_data_redacted(error: Exception) -> None: +def _mark_error_data_redacted(error: BaseException) -> None: setattr(error, _DATA_REDACTED_ATTR, True) -def _is_error_data_redacted(error: Exception) -> bool: +def _is_error_data_redacted(error: BaseException) -> bool: return bool(getattr(error, _DATA_REDACTED_ATTR, False)) -def _clear_data_redacted_error_traceback(error: Exception) -> None: +def _clear_data_redacted_error_traceback(error: BaseException) -> None: if _is_error_data_redacted(error) and error.__traceback__ is not None: traceback.clear_frames(error.__traceback__) -def _detach_data_redacted_error_traceback(error: Exception) -> None: +def _detach_data_redacted_error_traceback(error: BaseException) -> None: if _is_error_data_redacted(error): error.__traceback__ = None + error.__cause__ = None + error.__context__ = None -def _raise_data_redacted_error(error: Exception) -> NoReturn: +def _raise_data_redacted_error(error: BaseException) -> NoReturn: """Raise a detached redacted error from a frame that owns no payload data.""" raise error from None diff --git a/src/agents/result.py b/src/agents/result.py index 0ca4456a31..6fa06a331a 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -575,7 +575,7 @@ class RunResultStreaming(RunResultBase): _input_guardrails_task: asyncio.Task[Any] | None = field(default=None, repr=False) _triggered_input_guardrail_result: InputGuardrailResult | None = field(default=None, repr=False) _output_guardrails_task: asyncio.Task[Any] | None = field(default=None, repr=False) - _stored_exception: Exception | None = field(default=None, repr=False) + _stored_exception: BaseException | None = field(default=None, repr=False) _cancel_mode: Literal["none", "immediate", "after_turn"] = field(default="none", repr=False) _last_processed_response: ProcessedResponse | None = field(default=None, repr=False) """The last processed model response. This is needed for resuming from interruptions.""" @@ -988,7 +988,7 @@ def _check_errors(self): if self.run_loop_task and self.run_loop_task.done(): if not self.run_loop_task.cancelled(): run_impl_exc = self.run_loop_task.exception() - if isinstance(run_impl_exc, Exception): + if run_impl_exc is not None: if ( isinstance(run_impl_exc, AgentsException) and run_impl_exc.run_data is None @@ -1009,18 +1009,6 @@ def _check_errors(self): in_guard_exc.run_data = self._create_error_details() self._stored_exception = in_guard_exc - if self._output_guardrails_task and self._output_guardrails_task.done(): - if not self._output_guardrails_task.cancelled(): - out_guard_exc = self._output_guardrails_task.exception() - if isinstance(out_guard_exc, Exception): - if ( - isinstance(out_guard_exc, AgentsException) - and out_guard_exc.run_data is None - and not _is_error_data_redacted(out_guard_exc) - ): - out_guard_exc.run_data = self._create_error_details() - self._stored_exception = out_guard_exc - def _cleanup_tasks(self): if self.run_loop_task and not self.run_loop_task.done(): self.run_loop_task.cancel() diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index ca2bfaddba..936637fe20 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -27,6 +27,7 @@ from ..agent import Agent from ..agent_output import AgentOutputSchemaBase from ..exceptions import ( + _DATA_REDACTED_ERROR_MESSAGE, AgentsException, InputGuardrailTripwireTriggered, MaxTurnsExceeded, @@ -37,6 +38,7 @@ _clear_data_redacted_error_traceback, _detach_data_redacted_error_traceback, _is_error_data_redacted, + _mark_error_data_redacted, ) from ..handoffs import Handoff from ..items import ( @@ -517,6 +519,7 @@ async def _finalize_streamed_final_output( store_setting: bool | None, persist_before_output_guardrails: bool, ) -> None: + redacted_persistence_error: BaseException | None = None if persist_before_output_guardrails: # A resumed approval has already committed the tool side effect, so keep its call/output # pair even when an agent output guardrail blocks delivery of the final result. @@ -530,7 +533,7 @@ async def _finalize_streamed_final_output( context_wrapper=context_wrapper, streamed_result=streamed_result, ) - except Exception: + except OutputGuardrailTripwireTriggered: # The blocked output itself is not persisted, but a tool that already ran is: the next run # has to see that side effect rather than re-issue it. This turn reaches here with tool # items when `tool_use_behavior="stop_on_first_tool"` (or `stop_at_tool_names`, or a custom @@ -540,6 +543,60 @@ async def _finalize_streamed_final_output( if retained_items: await save_items(retained_items, response_id, store_setting) raise + except Exception as guardrail_error: + # Only a tripwire means the output was judged undeliverable. A guardrail error leaves the + # verdict unknown, so the completed final turn is persisted whole and remains replayable. + # `asyncio.CancelledError` is deliberately not caught here: `cancel()` in its default + # immediate mode has to stay prompt, and awaiting a session write would block + # `stream_events()` on an arbitrary backend. `after_turn` is the mode that finishes the + # turn and saves. + guardrail_error_is_redacted = _is_error_data_redacted(guardrail_error) + if guardrail_error_is_redacted: + _detach_data_redacted_error_traceback(guardrail_error) + if not persist_before_output_guardrails: + try: + await save_items(items, response_id, store_setting) + except (Exception, asyncio.CancelledError) as persistence_error: + if guardrail_error_is_redacted: + if isinstance(persistence_error, asyncio.CancelledError): + safe_persistence_error: BaseException = asyncio.CancelledError( + _DATA_REDACTED_ERROR_MESSAGE + ) + else: + safe_persistence_error = UserError(_DATA_REDACTED_ERROR_MESSAGE) + _mark_error_data_redacted(safe_persistence_error) + if ( + isinstance(safe_persistence_error, asyncio.CancelledError) + and streamed_result._cancel_mode != "immediate" + ): + # A cancelled session write is distinct from the caller requesting + # immediate cancellation. Retain a safe cancellation for `stream_events()` + # without completing the run-loop task with the payload-bearing backend + # exception. + streamed_result._stored_exception = safe_persistence_error + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + return + if isinstance(safe_persistence_error, asyncio.CancelledError): + # Public immediate cancellation already owns stream completion and must + # not surface a recovery failure. + return + redacted_persistence_error = safe_persistence_error + if ( + isinstance(persistence_error, asyncio.CancelledError) + and streamed_result._cancel_mode != "immediate" + ): + # A cancelled session write is distinct from the caller requesting immediate + # cancellation. The run-loop task itself becomes cancelled, so retain the + # backend cancellation for `stream_events()` to surface. + streamed_result._stored_exception = persistence_error + if redacted_persistence_error is None: + raise + if redacted_persistence_error is None: + raise + + if redacted_persistence_error is not None: + raise redacted_persistence_error from None streamed_result.output_guardrail_results = output_guardrail_results streamed_result.final_output = output diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 36a5695332..f7e32bf850 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import json import logging from collections.abc import AsyncIterator @@ -2419,6 +2420,231 @@ async def run_once() -> Any: assert saved == ["user", "message", "function_call", "function_call_output"] +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.asyncio +async def test_failing_output_guardrail_keeps_the_whole_final_turn( + mode: str, +) -> None: + """A guardrail *error* is not a tripwire: the completed final turn stays replayable. + + Only a tripwire means the output was judged undeliverable. An ordinary guardrail exception + leaves the verdict unknown, so the turn must be persisted whole, exactly as the non-streamed + path does. + """ + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + return "committed-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + raise RuntimeError("guardrail failed") + + model = FakeModel() + model.set_next_output( + [ + get_text_message("assistant-preamble"), + get_function_tool_call("commit_tool", "{}", call_id="call-mixed"), + ] + ) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + + async def run_once() -> None: + if mode == "non_streamed": + await Runner.run(agent, "Use commit_tool", session=session) + else: + result = Runner.run_streamed(agent, "Use commit_tool", session=session) + await consume_stream(result) + + with pytest.raises(RuntimeError, match="guardrail failed"): + await run_once() + + saved_items = await session.get_items() + saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] + assert saved == ["user", "message", "function_call", "function_call_output"] + + +@pytest.mark.asyncio +async def test_streamed_session_save_error_takes_precedence_over_output_guardrail_error() -> None: + guardrail_failed = False + final_turn_save_attempted = False + + class FailingFinalTurnSession(SimpleListSession): + async def add_items(self, items: list[TResponseInputItem]) -> None: + nonlocal final_turn_save_attempted + if guardrail_failed: + final_turn_save_attempted = True + raise LookupError("session save failed") + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + raise RuntimeError("guardrail failed") + + model = FakeModel() + model.set_next_output([get_text_message("assistant-preamble")]) + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = FailingFinalTurnSession() + result = Runner.run_streamed(agent, "Hello", session=session) + + with pytest.raises(LookupError, match="session save failed") as exc_info: + await consume_stream(result) + + assert final_turn_save_attempted is True + assert isinstance(exc_info.value.__context__, RuntimeError) + assert str(exc_info.value.__context__) == "guardrail failed" + assert result.run_loop_exception is exc_info.value + assert await session.get_items() == [{"content": "Hello", "role": "user"}] + + +@pytest.mark.asyncio +async def test_streamed_session_save_cancellation_is_not_a_public_immediate_cancel() -> None: + guardrail_failed = False + + class CancellingFinalTurnSession(SimpleListSession): + async def add_items(self, items: list[TResponseInputItem]) -> None: + if guardrail_failed: + raise asyncio.CancelledError("session save cancelled") + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + raise RuntimeError("guardrail failed") + + model = FakeModel(initial_output=[get_text_message("assistant-preamble")]) + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = CancellingFinalTurnSession() + result = Runner.run_streamed(agent, "Hello", session=session) + + with pytest.raises(asyncio.CancelledError, match="session save cancelled") as exc_info: + await consume_stream(result) + + assert result._cancel_mode == "none" + assert result._stored_exception is exc_info.value + assert isinstance(exc_info.value.__context__, RuntimeError) + assert str(exc_info.value.__context__) == "guardrail failed" + assert await session.get_items() == [{"content": "Hello", "role": "user"}] + + +@pytest.mark.asyncio +async def test_streamed_session_save_direct_base_exception_is_terminal() -> None: + guardrail_failed = False + + class DirectAbort(BaseException): + pass + + class AbortingFinalTurnSession(SimpleListSession): + async def add_items(self, items: list[TResponseInputItem]) -> None: + if guardrail_failed: + raise DirectAbort("session save aborted") + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + raise RuntimeError("guardrail failed") + + agent = Agent( + name="test", + model=FakeModel(initial_output=[get_text_message("assistant-preamble")]), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = AbortingFinalTurnSession() + result = Runner.run_streamed(agent, "Hello", session=session) + + with pytest.raises(DirectAbort, match="session save aborted") as exc_info: + await consume_stream(result) + + assert result._stored_exception is exc_info.value + assert result.run_loop_exception is exc_info.value + assert await session.get_items() == [{"content": "Hello", "role": "user"}] + + +@pytest.mark.asyncio +async def test_public_immediate_cancel_during_guardrail_recovery_save_stays_prompt() -> None: + guardrail_failed = False + save_started = asyncio.Event() + save_cancelled = asyncio.Event() + never_set = asyncio.Event() + + class BlockingFinalTurnSession(SimpleListSession): + async def add_items(self, items: list[TResponseInputItem]) -> None: + if guardrail_failed: + save_started.set() + try: + await never_set.wait() + finally: + save_cancelled.set() + return + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + raise RuntimeError("guardrail failed") + + model = FakeModel(initial_output=[get_text_message("assistant-preamble")]) + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = BlockingFinalTurnSession() + result = Runner.run_streamed(agent, "Hello", session=session) + drain_task = asyncio.create_task(consume_stream(result)) + + try: + await asyncio.wait_for(save_started.wait(), timeout=1) + result.cancel() + await asyncio.wait_for(drain_task, timeout=1) + finally: + if not drain_task.done(): + result.cancel() + drain_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await drain_task + + assert save_cancelled.is_set() + assert result._stored_exception is None + assert await session.get_items() == [{"content": "Hello", "role": "user"}] + + @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"]) @pytest.mark.asyncio @@ -3213,3 +3439,88 @@ def approval_tool() -> str: assert len(streamed.tool_output_guardrail_results) == len( non_streamed.tool_output_guardrail_results ) + + +@pytest.mark.asyncio +async def test_streamed_cancel_during_output_guardrail_starts_no_final_turn_write() -> None: + """Immediate cancel() must not start a final-turn session write. + + `cancel()` in its default immediate mode cancels outstanding work; `after_turn` is the mode + that finishes the turn and saves. A cancellation raised inside an in-flight output guardrail + must therefore not be treated like a guardrail error, or `stream_events()` would stay blocked + on whatever the session backend does. + """ + entered_guardrail = asyncio.Event() + never_set = asyncio.Event() + cancelled = False + tool_call_count = 0 + + async def parked_output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + entered_guardrail.set() + await never_set.wait() + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + class BlockingAfterCancelSession(SimpleListSession): + """Writes before the cancel are the turn's own; any write after it would hang the stream.""" + + def __init__(self) -> None: + super().__init__() + self.wrote_after_cancel = False + + async def add_items(self, items: list[TResponseInputItem]) -> None: + if cancelled: + self.wrote_after_cancel = True + await never_set.wait() + await super().add_items(items) + + @function_tool(name_override="commit_tool") + def commit_tool() -> str: + nonlocal tool_call_count + tool_call_count += 1 + return "committed-result" + + model = FakeModel() + model.set_next_output( + [ + get_text_message("assistant-preamble"), + get_function_tool_call("commit_tool", "{}", call_id="call-cancel"), + ] + ) + agent = Agent( + name="test", + model=model, + tools=[commit_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=parked_output_guardrail)], + ) + session = BlockingAfterCancelSession() + + result = Runner.run_streamed(agent, "Use commit_tool", session=session) + + async def drain() -> None: + with contextlib.suppress(asyncio.CancelledError): + async for _event in result.stream_events(): + pass + + drain_task = asyncio.create_task(drain()) + try: + await asyncio.wait_for(entered_guardrail.wait(), timeout=1) + cancelled = True + result.cancel() + # A final-turn write here would block on never_set and hang the stream. + await asyncio.wait_for(drain_task, timeout=1) + finally: + if not drain_task.done(): + drain_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await drain_task + + assert session.wrote_after_cancel is False + assert tool_call_count == 1 + saved_items = await session.get_items() + saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] + assert saved == ["user"] diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index 821907a4ce..afbacf9bbe 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -1398,6 +1398,161 @@ def output_guardrail( _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) +@pytest.mark.parametrize("redacted", [False, True]) +@pytest.mark.parametrize("persistence_failure", ["error", "cancelled"]) +@pytest.mark.asyncio +async def test_streamed_session_error_after_output_guardrail_respects_redaction( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + redacted: bool, + persistence_failure: Literal["error", "cancelled"], +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + guardrail_failed = False + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + + class FailingFinalTurnSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if guardrail_failed: + cause = RuntimeError(f"session save cause: {_MODEL_OUTPUT_SECRET}") + if persistence_failure == "cancelled": + raise asyncio.CancelledError( + f"session save cancelled: {_MODEL_OUTPUT_SECRET}" + ) from cause + raise LookupError(f"session save failed: {_MODEL_OUTPUT_SECRET}") from cause + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + caplog.set_level(logging.ERROR, logger="openai.agents") + model = FakeModel(initial_output=[get_text_message(_MODEL_OUTPUT_SECRET)]) + agent = Agent( + name="A", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + result = Runner.run_streamed(agent, "go", session=FailingFinalTurnSession()) + run_loop_callback_errors: list[BaseException] = [] + run_loop_done = asyncio.Event() + + if redacted and persistence_failure == "cancelled": + assert result.run_loop_task is not None + + def inspect_run_loop_task(task: asyncio.Task[Any]) -> None: + try: + task.result() + except BaseException as error: + run_loop_callback_errors.append(error) + finally: + run_loop_done.set() + + result.run_loop_task.add_done_callback(inspect_run_loop_task) + expected_error_type = ( + asyncio.CancelledError + if persistence_failure == "cancelled" + else UserError + if redacted + else LookupError + ) + expected_message = ( + "Error details are redacted." + if redacted + else "session save cancelled" + if persistence_failure == "cancelled" + else "session save failed" + ) + + with pytest.raises(expected_error_type, match=expected_message) as exc_info: + async for _ in result.stream_events(): + pass + + error = exc_info.value + guardrail_error = error.__context__ + + if redacted: + assert guardrail_error is None + assert error.__cause__ is None + assert _MODEL_OUTPUT_SECRET not in str(error) + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + for record in caplog.records: + assert _MODEL_OUTPUT_SECRET not in repr(record.__dict__) + assert _MODEL_OUTPUT_SECRET not in logging.Formatter().format(record) + assert record.exc_info is None + else: + assert isinstance(guardrail_error, ModelBehaviorError) + assert error.__cause__ is not None + assert _MODEL_OUTPUT_SECRET in str(error.__cause__) + assert _MODEL_OUTPUT_SECRET in str(guardrail_error) + assert any( + _MODEL_OUTPUT_SECRET in repr(frame) for frame in _agents_traceback_frame_locals(error) + ) + + if persistence_failure == "cancelled": + assert result._stored_exception is error + assert result.run_loop_exception is None + if redacted: + await asyncio.wait_for(run_loop_done.wait(), timeout=1) + assert run_loop_callback_errors == [] + else: + assert result.run_loop_exception is error + if redacted: + assert error.__traceback__ is None + + +@pytest.mark.asyncio +async def test_streamed_session_hostile_error_after_redacted_output_guardrail_is_replaced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + persistence_secret = "HOSTILE_SESSION_FAILURE_SECRET" + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + guardrail_failed = False + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + + class FailingFinalTurnSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if guardrail_failed: + raise _HostileAttributeWriteException(persistence_secret) + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + agent = Agent( + name="A", + model=FakeModel(initial_output=[get_text_message(_MODEL_OUTPUT_SECRET)]), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + result = Runner.run_streamed(agent, "go", session=FailingFinalTurnSession()) + + with pytest.raises(UserError, match="Error details are redacted.") as exc_info: + async for _ in result.stream_events(): + pass + + error = exc_info.value + assert error.__cause__ is None + assert error.__context__ is None + assert persistence_secret not in str(error) + _assert_secret_absent_from_agents_traceback(error, persistence_secret) + _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) + + @pytest.mark.asyncio async def test_streamed_input_guardrail_omits_run_data_from_redacted_error( monkeypatch: pytest.MonkeyPatch, From 1d7c4b8a532b963414bf7c3919dc8ce8a115a4d9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 09:16:52 +0900 Subject: [PATCH 241/473] fix(sandbox): reject unsafe mount credential configurations (#4255) fix(sandbox): reject unsafe mount credentials --- examples/run_examples.py | 1 - .../docker/mounts/azure_mount_read_write.py | 29 - .../docker/mounts/gcs_mount_read_write.py | 37 - .../mounts/s3_files_mount_read_write.py | 72 - .../docker/mounts/s3_mount_read_write.py | 33 - .../extensions/daytona/daytona_runner.py | 5 +- examples/sandbox/memory_s3.py | 4 +- .../extensions/sandbox/blaxel/mounts.py | 23 +- .../extensions/sandbox/blaxel/sandbox.py | 7 +- .../extensions/sandbox/cloudflare/sandbox.py | 193 +- .../extensions/sandbox/daytona/mounts.py | 28 +- .../extensions/sandbox/daytona/sandbox.py | 4 + src/agents/extensions/sandbox/e2b/mounts.py | 16 + src/agents/extensions/sandbox/e2b/sandbox.py | 5 + .../extensions/sandbox/modal/sandbox.py | 37 + .../extensions/sandbox/runloop/mounts.py | 16 + .../extensions/sandbox/runloop/sandbox.py | 6 + .../extensions/sandbox/vercel/mounts.py | 91 +- .../extensions/sandbox/vercel/sandbox.py | 216 +- src/agents/run_state.py | 127 +- src/agents/sandbox/_mount_security.py | 1605 ++++++++++ src/agents/sandbox/entries/mounts/base.py | 47 +- src/agents/sandbox/entries/mounts/patterns.py | 2 + .../entries/mounts/providers/azure_blob.py | 2 +- .../sandbox/entries/mounts/providers/gcs.py | 11 +- .../sandbox/entries/mounts/providers/r2.py | 2 +- .../sandbox/entries/mounts/providers/s3.py | 2 +- src/agents/sandbox/runtime_session_manager.py | 81 +- src/agents/sandbox/sandboxes/docker.py | 203 +- src/agents/sandbox/sandboxes/unix_local.py | 36 +- .../sandbox/session/base_sandbox_session.py | 123 +- src/agents/sandbox/session/mount_lifecycle.py | 182 +- src/agents/sandbox/session/sandbox_client.py | 69 +- src/agents/sandbox/session/sandbox_session.py | 38 +- .../sandbox/session/sandbox_session_state.py | 267 +- tests/extensions/sandbox/test_blaxel.py | 31 + tests/extensions/sandbox/test_cloudflare.py | 484 +++ tests/extensions/sandbox/test_daytona.py | 67 +- tests/extensions/sandbox/test_e2b.py | 16 + tests/extensions/sandbox/test_modal.py | 211 +- tests/extensions/sandbox/test_runloop.py | 62 +- tests/extensions/sandbox/test_vercel.py | 1036 ++++++- tests/sandbox/integration_tests/_helpers.py | 7 - tests/sandbox/test_docker.py | 1319 ++++++++- tests/sandbox/test_mount_lifecycle.py | 229 ++ tests/sandbox/test_mount_security.py | 2614 +++++++++++++++++ tests/sandbox/test_mounts.py | 95 +- tests/sandbox/test_runtime.py | 755 ++++- tests/sandbox/test_session_state_roundtrip.py | 125 +- tests/test_run_examples_script.py | 1 - tests/test_run_state.py | 636 +++- 51 files changed, 10623 insertions(+), 685 deletions(-) delete mode 100644 examples/sandbox/docker/mounts/s3_files_mount_read_write.py create mode 100644 src/agents/sandbox/_mount_security.py create mode 100644 tests/sandbox/test_mount_security.py diff --git a/examples/run_examples.py b/examples/run_examples.py index 3387e4cc9a..8a139af41e 100644 --- a/examples/run_examples.py +++ b/examples/run_examples.py @@ -77,7 +77,6 @@ "examples/sandbox/misc/reference_policy_mcp_server.py", "examples/sandbox/docker/mounts/azure_mount_read_write.py", "examples/sandbox/docker/mounts/gcs_mount_read_write.py", - "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", # Blaxel 0.3.2 still imports an MCP v1 module that was removed in MCP v2. "examples/sandbox/extensions/blaxel_runner.py", diff --git a/examples/sandbox/docker/mounts/azure_mount_read_write.py b/examples/sandbox/docker/mounts/azure_mount_read_write.py index f29e5b9cdc..4cc7f07c18 100644 --- a/examples/sandbox/docker/mounts/azure_mount_read_write.py +++ b/examples/sandbox/docker/mounts/azure_mount_read_write.py @@ -11,9 +11,6 @@ from agents.sandbox.entries import ( AzureBlobMount, DockerVolumeMountStrategy, - FuseMountPattern, - InContainerMountStrategy, - RcloneMountPattern, ) from examples.sandbox.docker.mounts.mount_smoke import ( MountSmokeCase, @@ -43,32 +40,6 @@ def _mount_cases() -> list[MountSmokeCase]: read_only=False, ), ), - MountSmokeCase( - name="in_container/rclone", - mount_dir="azure-in-container-rclone", - mount=AzureBlobMount( - account=account, - container=container, - endpoint=endpoint, - identity_client_id=identity_client_id, - account_key=account_key, - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), - read_only=False, - ), - ), - MountSmokeCase( - name="in_container/fuse", - mount_dir="azure-in-container-fuse", - mount=AzureBlobMount( - account=account, - container=container, - endpoint=endpoint, - identity_client_id=identity_client_id, - account_key=account_key, - mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), - read_only=False, - ), - ), ] diff --git a/examples/sandbox/docker/mounts/gcs_mount_read_write.py b/examples/sandbox/docker/mounts/gcs_mount_read_write.py index d9cbc81ef7..adf94456a0 100644 --- a/examples/sandbox/docker/mounts/gcs_mount_read_write.py +++ b/examples/sandbox/docker/mounts/gcs_mount_read_write.py @@ -11,9 +11,6 @@ from agents.sandbox.entries import ( DockerVolumeMountStrategy, GCSMount, - InContainerMountStrategy, - MountpointMountPattern, - RcloneMountPattern, ) from examples.sandbox.docker.mounts.mount_smoke import ( MountSmokeCase, @@ -51,40 +48,6 @@ def _mount_cases() -> list[MountSmokeCase]: read_only=False, ), ), - MountSmokeCase( - name="in_container/rclone", - mount_dir="gcs-in-container-rclone", - mount=GCSMount( - bucket=bucket, - access_id=access_id, - secret_access_key=secret_access_key, - prefix=prefix, - region=region, - endpoint_url=endpoint_url, - service_account_file=service_account_file, - service_account_credentials=service_account_credentials, - access_token=access_token, - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), - read_only=False, - ), - ), - MountSmokeCase( - name="in_container/mountpoint", - mount_dir="gcs-in-container-mountpoint", - mount=GCSMount( - bucket=bucket, - access_id=access_id, - secret_access_key=secret_access_key, - prefix=prefix, - region=region, - endpoint_url=endpoint_url, - service_account_file=service_account_file, - service_account_credentials=service_account_credentials, - access_token=access_token, - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), - read_only=False, - ), - ), ] diff --git a/examples/sandbox/docker/mounts/s3_files_mount_read_write.py b/examples/sandbox/docker/mounts/s3_files_mount_read_write.py deleted file mode 100644 index bfda18087f..0000000000 --- a/examples/sandbox/docker/mounts/s3_files_mount_read_write.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Smoke-test an Amazon S3 Files file-system mount in Docker. - -Required: - - S3_FILES_FILE_SYSTEM_ID=fs-... - -Common optional settings: - - S3_FILES_MOUNT_TARGET_IP=10.0.0.123 - AWS_REGION=us-east-1 - S3_FILES_ACCESS_POINT=fsap-... - S3_FILES_SUBPATH=/path/in/file-system - -Example: - - S3_FILES_FILE_SYSTEM_ID=fs-... \ - S3_FILES_MOUNT_TARGET_IP=10.0.0.123 \ - AWS_REGION=us-east-1 \ - uv run python examples/sandbox/docker/mounts/s3_files_mount_read_write.py -""" - -from __future__ import annotations - -import asyncio -import os -import sys -from pathlib import Path - -if __package__ is None or __package__ == "": - sys.path.insert(0, str(Path(__file__).resolve().parents[4])) - -from agents.sandbox.entries import ( - InContainerMountStrategy, - S3FilesMount, - S3FilesMountPattern, -) -from examples.sandbox.docker.mounts.mount_smoke import ( - MountSmokeCase, - require_env, - run_mount_smoke_test, -) - - -def _mount_cases() -> list[MountSmokeCase]: - file_system_id = require_env("S3_FILES_FILE_SYSTEM_ID") - return [ - MountSmokeCase( - name="in_container/s3files", - mount_dir="s3-files-in-container", - mount=S3FilesMount( - file_system_id=file_system_id, - subpath=os.getenv("S3_FILES_SUBPATH"), - mount_target_ip=os.getenv("S3_FILES_MOUNT_TARGET_IP"), - access_point=os.getenv("S3_FILES_ACCESS_POINT"), - region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"), - mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), - read_only=False, - ), - ) - ] - - -async def main() -> None: - await run_mount_smoke_test( - provider="s3-files", - agent_name="S3 Files Mount Smoke Test", - mount_cases=_mount_cases(), - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/sandbox/docker/mounts/s3_mount_read_write.py b/examples/sandbox/docker/mounts/s3_mount_read_write.py index 47b98089b8..4cfa6d2372 100644 --- a/examples/sandbox/docker/mounts/s3_mount_read_write.py +++ b/examples/sandbox/docker/mounts/s3_mount_read_write.py @@ -10,9 +10,6 @@ from agents.sandbox.entries import ( DockerVolumeMountStrategy, - InContainerMountStrategy, - MountpointMountPattern, - RcloneMountPattern, S3Mount, ) from examples.sandbox.docker.mounts.mount_smoke import ( @@ -40,36 +37,6 @@ def _mount_cases() -> list[MountSmokeCase]: read_only=False, ), ), - MountSmokeCase( - name="in_container/rclone", - mount_dir="s3-in-container-rclone", - mount=S3Mount( - bucket=bucket, - access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), - secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), - session_token=os.getenv("AWS_SESSION_TOKEN"), - prefix=os.getenv("S3_MOUNT_PREFIX"), - region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"), - endpoint_url=os.getenv("S3_ENDPOINT_URL"), - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), - read_only=False, - ), - ), - MountSmokeCase( - name="in_container/mountpoint", - mount_dir="s3-in-container-mountpoint", - mount=S3Mount( - bucket=bucket, - access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), - secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), - session_token=os.getenv("AWS_SESSION_TOKEN"), - prefix=os.getenv("S3_MOUNT_PREFIX"), - region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"), - endpoint_url=os.getenv("S3_ENDPOINT_URL"), - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), - read_only=False, - ), - ), ] diff --git a/examples/sandbox/extensions/daytona/daytona_runner.py b/examples/sandbox/extensions/daytona/daytona_runner.py index 277305afd2..caa6ac4ad4 100644 --- a/examples/sandbox/extensions/daytona/daytona_runner.py +++ b/examples/sandbox/extensions/daytona/daytona_runner.py @@ -74,9 +74,6 @@ def _build_manifest( manifest.entries["cloud-bucket"] = S3Mount( bucket=cloud_bucket_name, - access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), - secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), - session_token=os.environ.get("AWS_SESSION_TOKEN"), endpoint_url=cloud_bucket_endpoint_url, prefix=cloud_bucket_key_prefix, mount_path=Path(cloud_bucket_mount_path) if cloud_bucket_mount_path is not None else None, @@ -172,7 +169,7 @@ async def main( parser.add_argument( "--cloud-bucket-name", default=None, - help="S3 bucket name to mount into the sandbox.", + help="Public S3 bucket name to mount anonymously into the sandbox.", ) parser.add_argument( "--cloud-bucket-mount-path", diff --git a/examples/sandbox/memory_s3.py b/examples/sandbox/memory_s3.py index 946ce56689..0016a236d8 100644 --- a/examples/sandbox/memory_s3.py +++ b/examples/sandbox/memory_s3.py @@ -18,7 +18,7 @@ SandboxRunConfig, ) from agents.sandbox.capabilities import Filesystem, Memory, Shell -from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount +from agents.sandbox.entries import DockerVolumeMountStrategy, File, S3Mount from agents.sandbox.sandboxes.docker import ( DockerSandboxClient, DockerSandboxClientOptions, @@ -146,7 +146,7 @@ def _build_manifest( prefix=config.prefix, region=config.region, endpoint_url=config.endpoint_url, - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), read_only=False, ), } diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index dba5ecbe40..a48dd2fa07 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -3,10 +3,9 @@ Two strategies are provided: -* **BlaxelCloudBucketMountStrategy** -- mounts S3, R2, and GCS buckets via - FUSE tools (``s3fs``, ``gcsfuse``) executed inside the sandbox. Credentials - are written to ephemeral temp files, referenced by the FUSE tool, and deleted - immediately after the mount succeeds. +* **BlaxelCloudBucketMountStrategy** -- mounts credentialless S3, R2, and GCS + buckets via FUSE tools (``s3fs``, ``gcsfuse``) executed inside the sandbox. + Authenticated mounts require an external or provider-native mount strategy. * **BlaxelDriveMountStrategy** -- mounts Blaxel Drives (persistent network volumes) into the sandbox using the sandbox ``drives`` API @@ -27,6 +26,10 @@ from .... import _debug from ....logger import log_tool_action_warning +from ....sandbox._mount_security import ( + redact_mount_error_data, + validate_mount_activation_credential_boundary, +) from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountConfigError @@ -74,6 +77,7 @@ class BlaxelCloudBucketMountStrategy(MountStrategyBase): def validate_mount(self, mount: Mount) -> None: _build_mount_config(mount, mount_path="/validate") + @redact_mount_error_data async def activate( self, mount: Mount, @@ -81,6 +85,11 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="blaxel", + ) _assert_blaxel_session(session) _ = base_dir mount_path = mount._resolve_mount_path(session, dest) @@ -110,12 +119,18 @@ async def teardown_for_snapshot( _ = mount await _unmount_bucket(session, sandbox_path_str(path)) + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="blaxel", + ) _assert_blaxel_session(session) config = _build_mount_config(mount, mount_path=sandbox_path_str(path)) await _mount_bucket(session, config) diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index 0e08ca26e2..aee3211fbd 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -30,6 +30,7 @@ from pydantic import BaseModel, Field from ....logger import log_tool_action_debug, log_tool_action_warning +from ....sandbox._mount_security import redact_mount_error_data from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecTimeoutError, @@ -433,7 +434,9 @@ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: # -- lifecycle ----------------------------------------------------------- + @redact_mount_error_data async def start(self) -> None: + await self._validate_manifest_application() # When resuming a paused sandbox, _skip_start is set by the client to # avoid reapplying the full manifest over files that may have changed # while the sandbox was paused. @@ -1058,6 +1061,7 @@ def __init__( self._dependencies = dependencies self._token = token or os.environ.get("BL_API_KEY") + @redact_mount_error_data async def create( self, *, @@ -1067,6 +1071,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_BLAXEL_WORKSPACE_ROOT) + self._validate_manifest_for_create(manifest) timeouts_in = options.timeouts if isinstance(timeouts_in, BlaxelTimeouts): @@ -1134,6 +1139,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: log_tool_action_warning(logger, "Shutdown failed during delete (non-fatal)", e) return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, @@ -1148,7 +1154,6 @@ async def resume( if not isinstance(state, BlaxelSandboxSessionState): raise TypeError("BlaxelSandboxClient.resume expects a BlaxelSandboxSessionState") state.assert_path_grants_rebound() - SandboxInstance = _import_blaxel_sdk() blaxel_sandbox = None reconnected = False diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index dd70bb4624..8efad06a2e 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -31,6 +31,10 @@ from .... import _debug from ....logger import log_tool_action_debug +from ....sandbox._mount_security import ( + _manifest_has_configured_mount_authority, + redact_mount_error_data, +) from ....sandbox.errors import ( ConfigurationError, ErrorCode, @@ -38,10 +42,12 @@ ExecTransportError, ExposedPortUnavailableError, MountConfigError, + SandboxRuntimeError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, WorkspaceStartError, + WorkspaceStopError, WorkspaceWriteTypeError, ) from ....sandbox.manifest import Manifest @@ -49,7 +55,10 @@ from ....sandbox.session.base_sandbox_session import BaseSandboxSession from ....sandbox.session.dependencies import Dependencies from ....sandbox.session.manager import Instrumentation -from ....sandbox.session.mount_lifecycle import with_ephemeral_mounts_removed +from ....sandbox.session.mount_lifecycle import ( + _settle_mount_transition, + with_ephemeral_mounts_removed, +) from ....sandbox.session.pty_types import ( PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, @@ -355,6 +364,16 @@ class CloudflareSandboxSessionState(SandboxSessionState): worker_url: str sandbox_id: str + def _sanitize_persisted_provider_identity( + self, + data: dict[str, Any], + *, + mount_authority_redacted: bool, + ) -> None: + if mount_authority_redacted: + data["sandbox_id"] = "" + data["workspace_root_ready"] = False + @dataclass class _CloudflarePtyProcessEntry: @@ -385,6 +404,7 @@ class CloudflareSandboxSession(BaseSandboxSession): # Tracks whether the worker was running when resume began so snapshot restore can # detach any active ephemeral mounts before hydrating the workspace. _restore_workspace_was_running: bool + _mount_transition_terminal: bool def __init__( self, @@ -404,6 +424,7 @@ def __init__( self._pty_processes = {} self._reserved_pty_process_ids = set() self._restore_workspace_was_running = False + self._mount_transition_terminal = False @classmethod def from_state( @@ -421,7 +442,15 @@ def from_state( request_timeout_s=request_timeout_s, ) - def _session(self) -> aiohttp.ClientSession: + def _session(self, *, allow_terminal: bool = False) -> aiohttp.ClientSession: + if self._mount_transition_terminal and not allow_terminal: + raise SandboxRuntimeError( + message="sandbox session is unavailable after an ambiguous mount transition", + error_code=ErrorCode.MOUNT_FAILED, + op="shutdown", + context={"backend": "cloudflare"}, + retryable=False, + ) if self._http is None or self._http.closed: headers: dict[str, str] = {} if api_key := self._api_key or os.environ.get("CLOUDFLARE_SANDBOX_API_KEY"): @@ -668,31 +697,23 @@ async def _can_reuse_restorable_snapshot_workspace(self) -> bool: async def _restore_snapshot_into_workspace_on_resume(self) -> None: root = self._workspace_root_path() - detached_mounts: list[tuple[Any, Path]] = [] - if self._restore_workspace_was_running: - for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets(): - try: - await mount_entry.mount_strategy.teardown_for_snapshot( - mount_entry, self, mount_path - ) - except Exception as e: - raise WorkspaceStartError(path=root, cause=e) from e - detached_mounts.append((mount_entry, mount_path)) - workspace_archive: io.IOBase | None = None + + async def restore_workspace() -> None: + nonlocal workspace_archive + try: + await self._clear_workspace_root_on_resume() + workspace_archive = await self.state.snapshot.restore( + dependencies=self.dependencies + ) + await self._hydrate_workspace_via_http(workspace_archive) + except asyncio.CancelledError: + raise + except Exception as exc: + raise WorkspaceStartError(path=root, cause=exc) from exc + try: - await self._clear_workspace_root_on_resume() - workspace_archive = await self.state.snapshot.restore(dependencies=self.dependencies) - await self._hydrate_workspace_via_http(workspace_archive) - except Exception: - for mount_entry, mount_path in reversed(detached_mounts): - try: - await mount_entry.mount_strategy.restore_after_snapshot( - mount_entry, self, mount_path - ) - except Exception: - pass - raise + await restore_workspace() finally: if workspace_archive is not None: try: @@ -700,17 +721,82 @@ async def _restore_snapshot_into_workspace_on_resume(self) -> None: except Exception: pass + async def _restore_snapshot_and_reapply_ephemeral_on_resume(self) -> None: + if not self._restore_workspace_was_running: + await super()._restore_snapshot_and_reapply_ephemeral_on_resume() + return + + async def restore_snapshot_and_accounts() -> None: + await self._restore_snapshot_into_workspace_on_resume() + if self.should_provision_manifest_accounts_on_resume(): + await self.provision_manifest_accounts() + + transition_error, caller_cancelled = await _settle_mount_transition( + self, + with_ephemeral_mounts_removed( + self, + restore_snapshot_and_accounts, + error_path=self._workspace_root_path(), + error_cls=WorkspaceStartError, + operation_error_context_key=None, + restore_on_success=False, + ), + ) + if transition_error is not None: + raise transition_error + await self._reapply_ephemeral_manifest_on_resume() + if caller_cancelled: + raise asyncio.CancelledError() from None + + async def _reapply_ephemeral_manifest_on_resume(self) -> None: + transition_error, caller_cancelled = await _settle_mount_transition( + self, + super()._reapply_ephemeral_manifest_on_resume(), + ) + if transition_error is not None: + terminal_error, _terminal_cancelled = await _settle_mount_transition( + self, + self._terminate_ambiguous_mount_transition(), + ) + if terminal_error is not None: + if isinstance(terminal_error, WorkspaceStopError): + raise terminal_error + raise WorkspaceStopError( + path=self._workspace_root_path(), + context={ + "backend": "cloudflare", + "reason": "terminal_cleanup_failed", + }, + cause=terminal_error, + ) from terminal_error + raise transition_error + if caller_cancelled: + raise asyncio.CancelledError() from None + async def _after_stop(self) -> None: await self._close_http() async def _shutdown_backend(self) -> None: + has_protected_mount_authority = ( + _manifest_has_configured_mount_authority(self.state.manifest) + or self._runtime_has_protected_mount_authority() + ) try: - http = self._session() + http = self._session(allow_terminal=True) url = self.state.worker_url.rstrip("/") + f"/v1/sandbox/{self.state.sandbox_id}" async with http.delete(url) as resp: if resp.status < 400 or resp.status == 404: return - if _debug.DONT_LOG_TOOL_DATA: + if self._mount_transition_terminal: + raise WorkspaceStopError( + path=self._workspace_root_path(), + context={ + "backend": "cloudflare", + "reason": "terminal_delete_failed", + "http_status": resp.status, + }, + ) + if has_protected_mount_authority or _debug.DONT_LOG_TOOL_DATA: logger.debug("Failed to delete Cloudflare sandbox on shutdown") else: detail = await _read_cloudflare_response_body(resp) @@ -719,11 +805,47 @@ async def _shutdown_backend(self) -> None: _cloudflare_http_error_message("DELETE /sandbox", resp.status, detail), ) except Exception as exc: - log_tool_action_debug(logger, "Failed to delete Cloudflare sandbox on shutdown", exc) + if self._mount_transition_terminal: + if isinstance(exc, WorkspaceStopError): + raise + raise WorkspaceStopError( + path=self._workspace_root_path(), + context={ + "backend": "cloudflare", + "reason": "terminal_delete_failed", + }, + cause=exc, + ) from exc + if has_protected_mount_authority: + logger.debug("Failed to delete Cloudflare sandbox on shutdown") + else: + log_tool_action_debug( + logger, "Failed to delete Cloudflare sandbox on shutdown", exc + ) async def _after_shutdown(self) -> None: await self._close_http() + async def _terminate_ambiguous_mount_transition(self) -> None: + self._mount_transition_terminal = True + cleanup_error: BaseException | None = None + try: + await self._before_shutdown() + except BaseException as exc: + cleanup_error = exc + try: + await self._shutdown_backend() + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + try: + await self._after_shutdown() + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + if cleanup_error is not None: + raise cleanup_error + async def _exec_internal( self, *command: str | Path, @@ -1397,6 +1519,7 @@ async def _hydrate_workspace_via_http(self, data: io.IOBase) -> None: except Exception as e: raise WorkspaceArchiveWriteError(path=root, cause=e) from e + @redact_mount_error_data async def persist_workspace(self) -> io.IOBase: root = self._workspace_root_path() return await with_ephemeral_mounts_removed( @@ -1407,6 +1530,7 @@ async def persist_workspace(self) -> io.IOBase: operation_error_context_key="snapshot_error_before_remount_corruption", ) + @redact_mount_error_data async def hydrate_workspace(self, data: io.IOBase) -> None: root = self._workspace_root_path() await with_ephemeral_mounts_removed( @@ -1442,6 +1566,7 @@ def __init__( self._exec_timeout_s = exec_timeout_s self._request_timeout_s = request_timeout_s + @redact_mount_error_data async def create( self, *, @@ -1459,6 +1584,7 @@ async def create( if manifest is None: manifest = Manifest() + self._validate_manifest_for_create(manifest) if manifest.root != "/workspace": raise ConfigurationError( message=( @@ -1503,12 +1629,23 @@ async def delete(self, session: SandboxSession) -> SandboxSession: await inner.shutdown() return session + @redact_mount_error_data async def resume(self, state: SandboxSessionState) -> SandboxSession: if not isinstance(state, CloudflareSandboxSessionState): raise TypeError( "CloudflareSandboxClient.resume expects a CloudflareSandboxSessionState" ) state.assert_path_grants_rebound() + if state.mount_authority_rebound or _manifest_has_configured_mount_authority( + state.manifest + ): + raise MountConfigError( + message=( + "Cloudflare sandbox sessions with protected bucket configuration cannot " + "be resumed; create a new session from current trusted configuration" + ), + context={"backend": "cloudflare"}, + ) inner = CloudflareSandboxSession.from_state( state, exec_timeout_s=self._exec_timeout_s, diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 038473e70e..93fc6952f0 100644 --- a/src/agents/extensions/sandbox/daytona/mounts.py +++ b/src/agents/extensions/sandbox/daytona/mounts.py @@ -4,7 +4,8 @@ :class:`InContainerMountStrategy` that ensures ``rclone`` is installed inside the sandbox before delegating to :class:`RcloneMountPattern`. -Supports S3, R2, GCS, Azure Blob, and Box mounts through a single code path. +Supports credentialless S3, R2, GCS, and Azure Blob mounts through a single code path. +Authenticated mounts require an external or provider-native mount strategy. """ from __future__ import annotations @@ -13,6 +14,10 @@ from pathlib import Path from typing import Literal +from ....sandbox._mount_security import ( + redact_mount_error_data, + validate_mount_activation_credential_boundary, +) from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern from ....sandbox.errors import MountConfigError @@ -164,9 +169,10 @@ class DaytonaCloudBucketMountStrategy(MountStrategyBase): """Mount rclone-backed cloud storage in Daytona sandboxes. Wraps :class:`InContainerMountStrategy` with automatic ``rclone`` - provisioning. Use with any rclone-backed provider mount (``S3Mount``, - ``R2Mount``, ``GCSMount``, ``AzureBlobMount``, ``BoxMount``) and let the - generic framework handle config generation and mount execution. + provisioning. Use with rclone-backed provider mounts that support anonymous access + (``S3Mount``, ``R2Mount``, ``GCSMount``, ``AzureBlobMount``) and let the + generic framework handle anonymous config generation and mount execution. Explicit cloud + credentials are not supported because the delegated helper executes inside the sandbox. Usage:: @@ -175,8 +181,6 @@ class DaytonaCloudBucketMountStrategy(MountStrategyBase): mount = S3Mount( bucket="my-bucket", - access_key_id="...", - secret_access_key="...", mount_path=Path("/mnt/bucket"), mount_strategy=DaytonaCloudBucketMountStrategy(), ) @@ -191,6 +195,7 @@ def _delegate(self) -> InContainerMountStrategy: def validate_mount(self, mount: Mount) -> None: self._delegate().validate_mount(mount) + @redact_mount_error_data async def activate( self, mount: Mount, @@ -198,6 +203,11 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="daytona", + ) _assert_daytona_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -223,12 +233,18 @@ async def teardown_for_snapshot( _assert_daytona_session(session) await self._delegate().teardown_for_snapshot(mount, session, path) + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="daytona", + ) _assert_daytona_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py index 0282f096a3..d62c5021ad 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -27,6 +27,7 @@ from pydantic import BaseModel, Field from ....logger import log_tool_action_debug +from ....sandbox._mount_security import redact_mount_error_data from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecTimeoutError, @@ -1248,6 +1249,7 @@ async def _build_create_params( auto_stop_interval=auto_stop_interval, ) + @redact_mount_error_data async def create( self, *, @@ -1257,6 +1259,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT) + self._validate_manifest_for_create(manifest) timeouts_in = options.timeouts if isinstance(timeouts_in, DaytonaSandboxTimeouts): @@ -1322,6 +1325,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, diff --git a/src/agents/extensions/sandbox/e2b/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py index 94b0a3bbb4..018691d7ed 100644 --- a/src/agents/extensions/sandbox/e2b/mounts.py +++ b/src/agents/extensions/sandbox/e2b/mounts.py @@ -5,6 +5,10 @@ from pathlib import Path from typing import Literal +from ....sandbox._mount_security import ( + redact_mount_error_data, + validate_mount_activation_credential_boundary, +) from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern from ....sandbox.errors import MountConfigError @@ -77,6 +81,7 @@ async def _delegate_for_session(self, session: BaseSandboxSession) -> InContaine def validate_mount(self, mount: Mount) -> None: self._delegate().validate_mount(mount) + @redact_mount_error_data async def activate( self, mount: Mount, @@ -84,6 +89,11 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="e2b", + ) _assert_e2b_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -110,12 +120,18 @@ async def teardown_for_snapshot( _assert_e2b_session(session) await self._delegate().teardown_for_snapshot(mount, session, path) + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="e2b", + ) _assert_e2b_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py index 3e866016de..389b665c44 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -35,6 +35,7 @@ from pydantic import BaseModel, Field from ....logger import log_tool_action_warning +from ....sandbox._mount_security import redact_mount_error_data from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecNonZeroError, @@ -1685,6 +1686,7 @@ def __init__( ) self._dependencies = dependencies + @redact_mount_error_data async def create( self, *, @@ -1695,6 +1697,7 @@ async def create( if options is None: raise ValueError("E2BSandboxClient.create requires options") manifest = manifest if manifest is not None else Manifest() + self._validate_manifest_for_create(manifest) sandbox_type = _coerce_sandbox_type(options.sandbox_type) @@ -1766,6 +1769,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: raise TypeError("E2BSandboxClient.delete expects an E2BSandboxSession") return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, @@ -1812,6 +1816,7 @@ async def resume( lifecycle=_e2b_lifecycle(state.on_timeout, auto_resume=state.auto_resume), mcp=state.mcp, ) + if not reconnected: state.sandbox_id = str(_sandbox_id(sandbox)) state.workspace_root_ready = False diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index 9705bc6d7e..d1351ff4b5 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -34,6 +34,11 @@ from modal.container_process import ContainerProcess from ....logger import log_tool_action_warning +from ....sandbox._mount_security import ( + _manifest_has_configured_mount_authority, + _mark_mount_validation_error, + redact_mount_error_data, +) from ....sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from ....sandbox.entries import Mount from ....sandbox.errors import ( @@ -450,6 +455,16 @@ class ModalSandboxSessionState(SandboxSessionState): image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION idle_timeout: int | None = None + def _sanitize_persisted_provider_identity( + self, + data: dict[str, Any], + *, + mount_authority_redacted: bool, + ) -> None: + if mount_authority_redacted: + data["sandbox_id"] = None + data["workspace_root_ready"] = False + @dataclass class _ModalPtyProcessEntry: @@ -1246,6 +1261,7 @@ async def running(self) -> bool: except Exception: return False + @redact_mount_error_data async def persist_workspace(self) -> io.IOBase: if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM: return await self._persist_workspace_via_snapshot_filesystem() @@ -1253,6 +1269,7 @@ async def persist_workspace(self) -> io.IOBase: return await self._persist_workspace_via_snapshot_directory() return await self._persist_workspace_via_tar() + @redact_mount_error_data async def hydrate_workspace(self, data: io.IOBase) -> None: if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM: return await self._hydrate_workspace_via_snapshot_filesystem(data) @@ -1978,6 +1995,7 @@ def _validate_manifest_for_workspace_persistence( }, ) + @redact_mount_error_data async def create( self, *, @@ -2005,6 +2023,7 @@ async def create( if options is None: raise ValueError("ModalSandboxClient.create requires options with app_name") manifest = manifest if manifest is not None else Manifest() + self._validate_manifest_for_create(manifest) app_name = options.app_name if not app_name: raise ValueError("ModalSandboxClient.create requires a valid app_name") @@ -2173,6 +2192,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, @@ -2180,6 +2200,23 @@ async def resume( if not isinstance(state, ModalSandboxSessionState): raise TypeError("ModalSandboxClient.resume expects a ModalSandboxSessionState") state.assert_path_grants_rebound() + if _manifest_has_configured_mount_authority(state.manifest) and not ( + state.mount_authority_rebound + ): + error = MountConfigError( + message=( + "Modal sandbox sessions with protected volume configuration cannot " + "be resumed; create a new session so the volume is created from the " + "current trusted configuration" + ), + context={"backend": "modal"}, + ) + _mark_mount_validation_error(error) + raise error + if state.mount_authority_rebound: + state.sandbox_id = None + state.session_id = uuid.uuid4() + state.workspace_root_ready = False inner = ModalSandboxSession.from_state(state) reconnected = await inner._ensure_sandbox() if reconnected: diff --git a/src/agents/extensions/sandbox/runloop/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py index 66116794c8..f87e0b6f4f 100644 --- a/src/agents/extensions/sandbox/runloop/mounts.py +++ b/src/agents/extensions/sandbox/runloop/mounts.py @@ -5,6 +5,10 @@ from pathlib import Path from typing import Literal +from ....sandbox._mount_security import ( + redact_mount_error_data, + validate_mount_activation_credential_boundary, +) from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern from ....sandbox.errors import MountConfigError @@ -123,6 +127,7 @@ async def _delegate_for_session(self, session: BaseSandboxSession) -> InContaine def validate_mount(self, mount: Mount) -> None: self._delegate().validate_mount(mount) + @redact_mount_error_data async def activate( self, mount: Mount, @@ -130,6 +135,11 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="runloop", + ) _assert_runloop_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -156,12 +166,18 @@ async def teardown_for_snapshot( _assert_runloop_session(session) await self._delegate().teardown_for_snapshot(mount, session, path) + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + validate_mount_activation_credential_boundary( + mount, + self, + provider_backend_id="runloop", + ) _assert_runloop_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py index cde7315096..dae79d1e2f 100644 --- a/src/agents/extensions/sandbox/runloop/sandbox.py +++ b/src/agents/extensions/sandbox/runloop/sandbox.py @@ -34,6 +34,7 @@ UserParameters as _RunloopSdkUserParameters, ) +from ....sandbox._mount_security import redact_mount_error_data from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecTimeoutError, @@ -734,6 +735,7 @@ def _coerce_exec_timeout(self, timeout_s: float | None) -> float: return 0.001 return float(timeout_s) + @redact_mount_error_data async def start(self) -> None: """Resume a reconnected Runloop devbox without replaying full setup when possible. @@ -741,6 +743,7 @@ async def start(self) -> None: In that path, Runloop reuses the live machine and only reapplies snapshot or ephemeral manifest state if the cached workspace fingerprint no longer matches. """ + await self._validate_manifest_application() if self._skip_start: if await self.state.snapshot.restorable(dependencies=self.dependencies): is_running = await self.running() @@ -1554,6 +1557,7 @@ def __init__( def platform(self) -> RunloopPlatformClient: return self._platform + @redact_mount_error_data async def create( self, *, @@ -1585,6 +1589,7 @@ async def create( else Manifest(root=_default_runloop_manifest_root(user_parameters)) ) _validate_runloop_manifest_root(manifest, user_parameters=user_parameters) + self._validate_manifest_for_create(manifest) timeouts_in = resolved_options.timeouts if isinstance(timeouts_in, RunloopTimeouts): @@ -1666,6 +1671,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, diff --git a/src/agents/extensions/sandbox/vercel/mounts.py b/src/agents/extensions/sandbox/vercel/mounts.py index b11954f112..876adb1203 100644 --- a/src/agents/extensions/sandbox/vercel/mounts.py +++ b/src/agents/extensions/sandbox/vercel/mounts.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Literal, NoReturn +from ....exceptions import _mark_error_data_redacted +from ....sandbox._mount_security import discard_mount_source_exception, redact_mount_error_data from ....sandbox.entries import Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountCommandError, MountConfigError @@ -23,6 +25,7 @@ _MOUNTPOINT_MINIMUM_VERSION = (1, 21, 0) _MOUNTPOINT_INSTALL_TIMEOUT_S = 300.0 _MOUNTPOINT_COMMAND_TIMEOUT_S = 120.0 +_CREDENTIALED_MOUNT_FAILURE_MESSAGE = "sandbox provider command failed" def _require_vercel_session(session: BaseSandboxSession) -> VercelSandboxSession: @@ -37,13 +40,6 @@ def _require_vercel_session(session: BaseSandboxSession) -> VercelSandboxSession return session -def _redact_sensitive_values(text: str, values: tuple[str, ...]) -> str: - redacted = text - for value in sorted({value for value in values if value}, key=len, reverse=True): - redacted = redacted.replace(value, "REDACTED") - return redacted - - async def _run_vercel_command( session: VercelSandboxSession, command: str, @@ -53,6 +49,8 @@ async def _run_vercel_command( timeout: float = _MOUNTPOINT_COMMAND_TIMEOUT_S, ) -> ExecResult: command_text = shlex.join([command, *args]) + sensitive_values = session._runtime_s3_mount_sensitive_values() + protected_error: MountCommandError | None = None try: sandbox = await session._ensure_sandbox() @@ -68,12 +66,29 @@ async def run_and_collect_output() -> ExecResult: return await asyncio.wait_for(run_and_collect_output(), timeout=timeout) except Exception as exc: - raise MountCommandError( + if not sensitive_values: + raise MountCommandError( + command=command_text, + stderr=f"{type(exc).__name__}: {exc}", + context={"backend": "vercel"}, + retryable=session._runtime_provider_retryability(exc), + ) from None + try: + retryable = session._runtime_provider_retryability(exc) + except BaseException: + retryable = None + protected_error = MountCommandError( command=command_text, - stderr=f"{type(exc).__name__}: {exc}", + stderr=_CREDENTIALED_MOUNT_FAILURE_MESSAGE, context={"backend": "vercel"}, - retryable=session._runtime_provider_retryability(exc), - ) from None + retryable=retryable, + ) + _mark_error_data_redacted(protected_error) + discard_mount_source_exception(exc) + + del sensitive_values + assert protected_error is not None + raise protected_error from None def _raise_command_failure( @@ -123,20 +138,23 @@ async def _run_credentialed_mount_command( context: dict[str, object], ) -> ExecResult | MountCommandError | asyncio.CancelledError: env = session._runtime_s3_mount_environment(mount_path) - sensitive_values = tuple(env.values()) command_text = shlex.join([_MOUNTPOINT_BINARY, *args]) try: sandbox = await session._ensure_sandbox() async def run_and_collect_output() -> ExecResult: - finished = await sandbox.run_command( - _MOUNTPOINT_BINARY, - args, - env=env, - sudo=True, - ) - stdout = (await finished.stdout()).encode("utf-8") - stderr = (await finished.stderr()).encode("utf-8") + try: + finished = await sandbox.run_command( + _MOUNTPOINT_BINARY, + args, + env=env, + sudo=True, + ) + stdout = (await finished.stdout()).encode("utf-8") + stderr = (await finished.stderr()).encode("utf-8") + except asyncio.CancelledError as error: + discard_mount_source_exception(error) + raise asyncio.CancelledError() from None return ExecResult(stdout=stdout, stderr=stderr, exit_code=finished.exit_code) result = await asyncio.wait_for( @@ -145,39 +163,36 @@ async def run_and_collect_output() -> ExecResult: ) except (Exception, asyncio.CancelledError) as exc: cancelled = isinstance(exc, asyncio.CancelledError) - retryable = session._runtime_provider_retryability(exc) - failure_message = _redact_sensitive_values( - f"{type(exc).__name__}: {exc}", - sensitive_values, - ) - exc.__traceback__ = None - exc.__context__ = None - exc.__cause__ = None + try: + retryable = session._runtime_provider_retryability(exc) + except BaseException: + retryable = None + discard_mount_source_exception(exc) if cancelled: return asyncio.CancelledError() - return MountCommandError( + protected_error = MountCommandError( command=command_text, - stderr=failure_message, + stderr=_CREDENTIALED_MOUNT_FAILURE_MESSAGE, context={"backend": "vercel", **context}, retryable=retryable, ) + _mark_error_data_redacted(protected_error) + return protected_error if result.ok(): return result - failure_message = _redact_sensitive_values( - result.stderr.decode("utf-8", errors="replace"), - sensitive_values, - ) - return MountCommandError( + protected_error = MountCommandError( command=command_text, - stderr=failure_message, + stderr=_CREDENTIALED_MOUNT_FAILURE_MESSAGE, context={ "backend": "vercel", "exit_code": result.exit_code, **context, }, ) + _mark_error_data_redacted(protected_error) + return protected_error def _parse_mountpoint_version(raw: str) -> tuple[int, int, int] | None: @@ -486,6 +501,7 @@ def supports_native_snapshot_detach(self, mount: Mount) -> bool: _ = mount return False + @redact_mount_error_data async def activate( self, mount: Mount, @@ -515,6 +531,7 @@ async def activate( vercel_session._runtime_record_s3_mount_active(mount_path) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -535,6 +552,7 @@ async def deactivate( raise vercel_session._runtime_record_s3_mount_inactive(mount_path) + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, @@ -552,6 +570,7 @@ async def teardown_for_snapshot( raise vercel_session._runtime_record_s3_mount_detached(path) + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index f01969d836..978b06fd93 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -28,6 +28,14 @@ from pydantic import TypeAdapter, field_serializer, field_validator from vercel import sandbox as vercel_sandbox +from ....sandbox._mount_security import ( + _mark_mount_error_for_manifest, + _mark_mount_validation_error, + _validate_manifest_mount_provenance, + _validate_mount_provenance, + redact_mount_error_data, + redact_mount_error_data_sync, +) from ....sandbox.entries import BaseEntry, Dir, S3Mount, resolve_workspace_path from ....sandbox.errors import ( ConfigurationError, @@ -49,7 +57,10 @@ from ....sandbox.session.base_sandbox_session import BaseSandboxSession from ....sandbox.session.dependencies import Dependencies from ....sandbox.session.manager import Instrumentation -from ....sandbox.session.mount_lifecycle import with_ephemeral_mounts_removed +from ....sandbox.session.mount_lifecycle import ( + current_task_owns_mount_transition, + with_ephemeral_mounts_removed, +) from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot @@ -301,6 +312,16 @@ def _vercel_s3_mount_map(manifest: Manifest) -> dict[str, S3Mount]: return mounts +def _vercel_s3_mount_topology(manifest: Manifest) -> dict[str, tuple[str, S3Mount]]: + mounts_by_path = _vercel_s3_mount_map(manifest) + paths_by_mount_id = {id(mount): path for path, mount in mounts_by_path.items()} + return { + logical_path.as_posix(): (paths_by_mount_id[id(entry)], entry) + for logical_path, entry in manifest.iter_entries() + if isinstance(entry, S3Mount) and entry.mount_strategy.type == "vercel_cloud_bucket" + } + + def _strip_vercel_mount_inline_credentials(value: object) -> None: if isinstance(value, dict): mount_strategy = value.get("mount_strategy") @@ -328,18 +349,6 @@ def _manifest_without_vercel_s3_credentials(manifest: Manifest) -> Manifest: return sanitized -def _manifest_has_vercel_s3_credentials(manifest: Manifest) -> bool: - return any( - credential is not None - for mount in _vercel_s3_mounts(manifest) - for credential in ( - mount.access_key_id, - mount.secret_access_key, - mount.session_token, - ) - ) - - class VercelSandboxClientOptions(BaseSandboxClientOptions): """Client options for the Vercel sandbox backend.""" @@ -417,6 +426,16 @@ class VercelSandboxSessionState(SandboxSessionState): network_policy: NetworkPolicy | None = None s3_mounts_non_resumable: bool = False + def _sanitize_persisted_provider_identity( + self, + data: dict[str, Any], + *, + mount_authority_redacted: bool, + ) -> None: + if mount_authority_redacted or self.s3_mounts_non_resumable: + data["sandbox_id"] = "" + data["workspace_root_ready"] = False + @field_serializer("manifest") def _serialize_manifest_without_inline_credentials( self, @@ -466,6 +485,7 @@ class VercelSandboxSession(BaseSandboxSession): _s3_mount_operation_lock: asyncio.Lock _s3_mount_operation_owner: asyncio.Task[Any] | None + @redact_mount_error_data_sync def __init__( self, *, @@ -474,7 +494,13 @@ def __init__( token: str | None = None, allow_s3_credential_exposure: bool = False, trusted_s3_mounts: dict[str, S3Mount] | None = None, + trusted_manifest: Manifest | None = None, ) -> None: + _validate_manifest_mount_provenance(state.manifest) + if trusted_manifest is not None: + _validate_manifest_mount_provenance(trusted_manifest) + for mount in (trusted_s3_mounts or {}).values(): + _validate_mount_provenance(mount) resolved_trusted_s3_mounts: dict[str, S3Mount] = {} trusted_s3_mount_credentials: dict[ str, @@ -506,8 +532,28 @@ def __init__( ), context={"backend": "vercel"}, ) - declared_mount_paths = set(_vercel_s3_mount_map(state.manifest)) - if declared_mount_paths != set(resolved_trusted_s3_mounts): + if resolved_trusted_s3_mounts and trusted_manifest is None: + raise MountConfigError( + message=( + "Vercel S3 mounts require a trusted create-time manifest so persisted " + "session state cannot reconstruct their topology" + ), + context={"backend": "vercel"}, + ) + resolved_trusted_manifest = ( + _manifest_without_vercel_s3_credentials(trusted_manifest) + if trusted_manifest is not None + else state.manifest.model_copy(deep=True) + ) + declared_topology = _vercel_s3_mount_topology(state.manifest) + trusted_topology = _vercel_s3_mount_topology(resolved_trusted_manifest) + trusted_manifest_mounts = _vercel_s3_mount_map(resolved_trusted_manifest) + trusted_topology_matches = ( + state.manifest.root == resolved_trusted_manifest.root + and declared_topology == trusted_topology + and trusted_manifest_mounts == resolved_trusted_s3_mounts + ) + if not trusted_topology_matches: raise MountConfigError( message=( "Vercel S3 mount topology must match trusted create-time configuration; " @@ -515,7 +561,7 @@ def __init__( ), context={ "backend": "vercel", - "declared_mount_paths": sorted(declared_mount_paths), + "declared_mount_paths": sorted(_vercel_s3_mount_map(state.manifest)), "trusted_mount_paths": sorted(resolved_trusted_s3_mounts), }, ) @@ -527,13 +573,14 @@ def __init__( self._detached_s3_mount_paths = set() self._trusted_s3_mounts = resolved_trusted_s3_mounts self._trusted_s3_mount_credentials = trusted_s3_mount_credentials - self._trusted_manifest = state.manifest.model_copy(deep=True) + self._trusted_manifest = resolved_trusted_manifest self._s3_mount_session_closed = False self._s3_mount_failure = None self._s3_mount_operation_lock = asyncio.Lock() self._s3_mount_operation_owner = None @classmethod + @redact_mount_error_data_sync def from_state( cls, state: VercelSandboxSessionState, @@ -542,6 +589,7 @@ def from_state( token: str | None = None, allow_s3_credential_exposure: bool = False, trusted_s3_mounts: dict[str, S3Mount] | None = None, + trusted_manifest: Manifest | None = None, ) -> VercelSandboxSession: return cls( state=state, @@ -549,6 +597,7 @@ def from_state( token=token, allow_s3_credential_exposure=allow_s3_credential_exposure, trusted_s3_mounts=trusted_s3_mounts, + trusted_manifest=trusted_manifest, ) @staticmethod @@ -579,6 +628,30 @@ def _runtime_s3_mount_is_authenticated(self, path: Path) -> bool: credentials = self._trusted_s3_mount_credentials.get(key) return credentials is not None and credentials[0] is not None + def _runtime_has_protected_mount_authority(self) -> bool: + return any( + credential is not None + for credentials in getattr(self, "_trusted_s3_mount_credentials", {}).values() + for credential in credentials + ) + + def _runtime_s3_mount_sensitive_values(self) -> tuple[str, ...]: + return tuple( + credential + for credentials in self._trusted_s3_mount_credentials.values() + for credential in credentials + if credential is not None + ) + + def _runtime_s3_mount_topology_matches(self, manifest: Manifest) -> bool: + candidate_topology = _vercel_s3_mount_topology(manifest) + trusted_topology = _vercel_s3_mount_topology(self._trusted_manifest) + if not trusted_topology: + return not candidate_topology + return manifest.root == self._trusted_manifest.root and ( + candidate_topology == trusted_topology + ) + def _runtime_s3_mount_environment(self, path: Path) -> dict[str, str]: key = self._s3_mount_path_key(path) credentials = self._trusted_s3_mount_credentials.get(key) @@ -614,12 +687,7 @@ async def _runtime_fail_s3_mount_transition(self, error: BaseException) -> None: await stop_task def _runtime_assert_s3_mount_topology(self) -> None: - topology_changed = ( - self.state.manifest != self._trusted_manifest - if self._trusted_s3_mounts - else bool(_vercel_s3_mounts(self.state.manifest)) - ) - if topology_changed: + if not self._runtime_s3_mount_topology_matches(self.state.manifest): raise MountConfigError( message="Vercel S3 mount topology cannot change after sandbox creation", context={"backend": "vercel"}, @@ -652,7 +720,9 @@ async def _s3_mount_operation( current_task = asyncio.current_task() assert current_task is not None - if self._s3_mount_operation_owner is current_task: + if self._s3_mount_operation_owner is current_task or current_task_owns_mount_transition( + self + ): yield return @@ -725,7 +795,7 @@ async def _apply_manifest( if self._runtime_s3_mount_activation_allowed() and self._trusted_s3_mounts: return await manifest_ops.apply_manifest( self, - manifest=_manifest_without_vercel_s3_mounts(self._trusted_manifest), + manifest=_manifest_without_vercel_s3_mounts(self.state.manifest), only_ephemeral=only_ephemeral, provision_accounts=provision_accounts, ) @@ -734,18 +804,49 @@ async def _apply_manifest( provision_accounts=provision_accounts, ) - async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: + async def _validate_manifest_application( + self, + *, + only_ephemeral: bool = False, + manifest: Manifest | None = None, + session_running: bool | None = None, + ) -> None: + await super()._validate_manifest_application( + only_ephemeral=only_ephemeral, + manifest=manifest, + session_running=session_running, + ) _ = only_ephemeral + validates_delta = manifest is not None + validated_manifest = manifest or self.state.manifest if not self._runtime_s3_mount_activation_allowed() and ( - self._trusted_s3_mounts or _vercel_s3_mounts(self.state.manifest) + not self._runtime_s3_mount_topology_matches(validated_manifest) + or (not validates_delta and self._trusted_s3_mounts) ): - raise MountConfigError( + error = MountConfigError( message=( "Vercel S3 mount topology is fixed when the sandbox is created; " "dynamic manifest application is not supported" ), context={"backend": "vercel"}, ) + _mark_mount_validation_error(error) + raise error + if ( + validates_delta + and self._trusted_s3_mounts + and not self._runtime_s3_mount_activation_allowed() + and session_running is not True + ): + error = MountConfigError( + message=( + "Vercel sessions with fixed S3 mounts must be running before non-mount " + "manifest entries can be applied; create a new session instead" + ), + context={"backend": "vercel"}, + ) + _mark_mount_validation_error(error) + raise error def supports_pty(self) -> bool: return False @@ -921,6 +1022,7 @@ async def running(self) -> bool: return False return bool(sandbox.status == SandboxStatus.RUNNING) + @redact_mount_error_data async def shutdown(self) -> None: async with self._s3_mount_operation(validate_topology=False): if self._s3_mount_session_closed: @@ -947,10 +1049,22 @@ async def _shutdown_with_s3_mounts(self) -> None: first_error = exc try: await self._stop_attached_sandbox() - except (Exception, asyncio.CancelledError) as exc: + except asyncio.CancelledError as exc: if self._detached_s3_mount_paths: await self._runtime_fail_s3_mount_transition(exc) raise + except Exception as exc: + if self._detached_s3_mount_paths: + try: + await self._runtime_fail_s3_mount_transition(exc) + except asyncio.CancelledError: + raise + except Exception: + if first_error is None: + raise + if first_error is not None: + raise first_error from None + raise self._active_s3_mount_paths.clear() self._detached_s3_mount_paths.clear() if self._trusted_s3_mounts: @@ -1034,6 +1148,7 @@ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: tls=tls, ) + @redact_mount_error_data async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase: async with self._s3_mount_operation(): return await self._read_with_s3_mounts(path, user=user) @@ -1061,6 +1176,7 @@ async def _read_with_s3_mounts( raise WorkspaceReadNotFoundError(path=normalized_path) return io.BytesIO(payload) + @redact_mount_error_data async def write( self, path: Path, @@ -1101,6 +1217,7 @@ async def _write_with_s3_mounts( retryable=_vercel_provider_retryability(exc), ) from exc + @redact_mount_error_data async def persist_workspace(self) -> io.IOBase: async with self._s3_mount_operation(validate_topology=False): self._runtime_assert_s3_mount_topology() @@ -1180,6 +1297,7 @@ async def _persist_workspace_internal(self) -> io.IOBase: except Exception: pass + @redact_mount_error_data async def hydrate_workspace(self, data: io.IOBase) -> None: async with self._s3_mount_operation(validate_topology=False): self._runtime_assert_s3_mount_topology() @@ -1289,9 +1407,9 @@ async def _write_files_with_retry(self, files: list[dict[str, object]]) -> None: class _VercelSandboxSessionWrapper(SandboxSession): - async def aclose(self) -> None: + async def _aclose_impl(self) -> None: try: - await super().aclose() + await super()._aclose_impl() except BaseException as error: inner = cast(VercelSandboxSession, self._inner) if inner._trusted_s3_mounts and inner._sandbox is not None: @@ -1343,6 +1461,7 @@ def _wrap_session( dependencies=self._resolve_dependencies(), ) + @redact_mount_error_data async def create( self, *, @@ -1351,21 +1470,21 @@ async def create( options: VercelSandboxClientOptions, ) -> SandboxSession: resolved_manifest = _resolve_manifest_root(manifest) - if ( - _manifest_has_vercel_s3_credentials(resolved_manifest) - and not options.allow_s3_credential_exposure - ): - raise MountConfigError( - message=( - "Vercel S3 mounts expose inline credentials to code running in the sandbox; " - "set allow_s3_credential_exposure=True only for credentials scoped to that " - "sandbox" + try: + self._validate_manifest_for_create( + resolved_manifest, + allowed_in_container_credential_strategy_types=( + frozenset({"vercel_cloud_bucket"}) + if options.allow_s3_credential_exposure + else frozenset() ), - context={"backend": "vercel"}, ) - trusted_s3_mounts = _vercel_s3_mount_map(resolved_manifest) - for mount in trusted_s3_mounts.values(): - mount.mount_strategy.validate_mount(mount) + trusted_s3_mounts = _vercel_s3_mount_map(resolved_manifest) + for mount in trusted_s3_mounts.values(): + mount.mount_strategy.validate_mount(mount) + except MountConfigError as error: + _mark_mount_error_for_manifest(error, resolved_manifest) + raise state_manifest = _manifest_without_vercel_s3_credentials(resolved_manifest) resolved_token = self._token resolved_project_id = options.project_id or self._project_id @@ -1399,20 +1518,20 @@ async def create( token=resolved_token, allow_s3_credential_exposure=options.allow_s3_credential_exposure, trusted_s3_mounts=trusted_s3_mounts, + trusted_manifest=resolved_manifest, ) await inner._ensure_sandbox() return self._wrap_session(inner, instrumentation=self._instrumentation) + @redact_mount_error_data async def delete(self, session: SandboxSession) -> SandboxSession: inner = session._inner if not isinstance(inner, VercelSandboxSession): raise TypeError("VercelSandboxClient.delete expects a VercelSandboxSession") - try: - await inner.shutdown() - except Exception: - pass + await inner.shutdown() return session + @redact_mount_error_data async def resume(self, state: SandboxSessionState) -> SandboxSession: if not isinstance(state, VercelSandboxSessionState): raise TypeError("VercelSandboxClient.resume expects a VercelSandboxSessionState") @@ -1425,7 +1544,6 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: ), context={"backend": "vercel"}, ) - resolved_token = self._token resolved_project_id = state.project_id or self._project_id resolved_team_id = state.team_id or self._team_id diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 9ab9e2a47b..38a2f9f1df 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -57,7 +57,15 @@ tool_output_identity, ) from .agent import Agent -from .exceptions import ModelBehaviorError, UserError +from .exceptions import ( + ModelBehaviorError, + UserError, + _clear_data_redacted_error_traceback, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, + _mark_error_data_redacted, + _raise_data_redacted_error, +) from .guardrail import ( GuardrailFunctionOutput, InputGuardrail, @@ -182,7 +190,10 @@ "flows." ), "1.14": "Scopes hosted MCP approvals and restored requests by server label.", - "1.15": "Persists canonical tool invocation identity and lifecycle across resume flows.", + "1.15": ( + "Persists canonical tool invocation identity plus sanitized mount authority and trusted " + "rebind metadata across resume flows." + ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -1102,7 +1113,17 @@ def to_json( include_tracing_api_key=include_tracing_api_key ) if self._sandbox is not None: - result["sandbox"] = copy.deepcopy(self._sandbox) + from .sandbox._mount_security import ( + _raise_invalid_run_state_sandbox_envelope, + sanitize_run_state_sandbox_mount_authority, + ) + + if not isinstance(self._sandbox, Mapping): + self._sandbox = None + _raise_invalid_run_state_sandbox_envelope() + + sanitized_sandbox, _redacted = sanitize_run_state_sandbox_mount_authority(self._sandbox) + result["sandbox"] = sanitized_sandbox return result @@ -1382,18 +1403,43 @@ async def from_string( Raises: UserError: If the string is invalid JSON or has incompatible schema version. """ + parse_error: UserError | None = None try: state_json = json.loads(state_string) except json.JSONDecodeError as e: - raise UserError(f"Failed to parse run state JSON: {e}") from e + message = ( + "Failed to parse run state JSON at " + f"line {e.lineno}, column {e.colno}, character {e.pos}" + ) + e.doc = "" + e.__traceback__ = None + state_string = "" + parse_error = UserError(message) - return await RunState.from_json( - initial_agent=initial_agent, - state_json=state_json, - context_override=context_override, - context_deserializer=context_deserializer, - strict_context=strict_context, - ) + state_string = "" + if parse_error is not None: + _mark_error_data_redacted(parse_error) + _raise_data_redacted_error(parse_error) + + safe_error: Exception | None = None + try: + return await RunState.from_json( + initial_agent=initial_agent, + state_json=state_json, + context_override=context_override, + context_deserializer=context_deserializer, + strict_context=strict_context, + ) + except Exception as error: + if not _is_error_data_redacted(error): + raise + _clear_data_redacted_error_traceback(error) + _detach_data_redacted_error_traceback(error) + safe_error = error + + state_json = cast(Any, None) + assert safe_error is not None + _raise_data_redacted_error(safe_error) @staticmethod async def from_json( @@ -1423,6 +1469,38 @@ async def from_json( Raises: UserError: If the dict has incompatible schema version. """ + if not isinstance(state_json, dict): + state_json = cast(Any, None) + error = UserError("Run state JSON must be an object") + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + + schema_error: UserError | None = None + try: + _validate_run_state_schema_version(state_json) + except UserError as error: + _mark_error_data_redacted(error) + _clear_data_redacted_error_traceback(error) + _detach_data_redacted_error_traceback(error) + schema_error = error + + if schema_error is not None: + state_json = cast(Any, None) + _raise_data_redacted_error(schema_error) + + from .sandbox._mount_security import ( + _raise_invalid_run_state_sandbox_envelope, + sanitize_run_state_sandbox_mount_authority, + ) + + if "sandbox" in state_json: + if not isinstance(state_json["sandbox"], Mapping): + state_json["sandbox"] = {} + _raise_invalid_run_state_sandbox_envelope() + sanitized_sandbox, _redacted = sanitize_run_state_sandbox_mount_authority( + state_json["sandbox"] + ) + state_json["sandbox"] = sanitized_sandbox return await _build_run_state_from_json( initial_agent=initial_agent, state_json=state_json, @@ -2937,6 +3015,22 @@ def _tool_output_guardrail_fn( return deserialized +def _validate_run_state_schema_version(state_json: Mapping[str, Any]) -> str: + schema_version = state_json.get("$schemaVersion") + if not schema_version: + raise UserError("Run state is missing schema version") + if not isinstance(schema_version, str): + raise UserError("Run state schema version has an invalid type") + if schema_version not in SUPPORTED_SCHEMA_VERSIONS: + supported_versions = ", ".join(sorted(SUPPORTED_SCHEMA_VERSIONS)) + raise UserError( + "Run state schema version is not supported. " + f"Supported versions are: {supported_versions}. " + f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}." + ) + return schema_version + + async def _build_run_state_from_json( initial_agent: Agent[Any], state_json: dict[str, Any], @@ -2956,16 +3050,7 @@ async def _build_run_state_from_json( safely, this function warns or raises (in ``strict_context`` mode) rather than silently claiming that the rebuilt mapping is equivalent to the original object. """ - schema_version = state_json.get("$schemaVersion") - if not schema_version: - raise UserError("Run state is missing schema version") - if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - supported_versions = ", ".join(sorted(SUPPORTED_SCHEMA_VERSIONS)) - raise UserError( - f"Run state schema version {schema_version} is not supported. " - f"Supported versions are: {supported_versions}. " - f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}." - ) + schema_version = _validate_run_state_schema_version(state_json) schema_major, schema_minor = (int(part) for part in schema_version.split(".", maxsplit=1)) programmatic_major, programmatic_minor = ( int(part) for part in _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION.split(".", maxsplit=1) diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py new file mode 100644 index 0000000000..b08ebeff36 --- /dev/null +++ b/src/agents/sandbox/_mount_security.py @@ -0,0 +1,1605 @@ +from __future__ import annotations + +import asyncio +import copy +import dataclasses +import importlib +import re +import traceback +from collections.abc import Callable, Collection, Coroutine, Iterable, Mapping +from functools import wraps +from pathlib import PurePosixPath +from typing import Any, NoReturn, ParamSpec, TypeVar, cast +from urllib.parse import urlsplit + +from ..exceptions import ( + _clear_data_redacted_error_traceback, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, + _mark_error_data_redacted, + _raise_data_redacted_error, +) +from .entries import ( + AzureBlobMount, + BaseEntry, + BoxMount, + Dir, + File, + GCSMount, + LocalDir, + Mount, + R2Mount, + S3FilesMount, + S3Mount, +) +from .entries.mounts.base import MountStrategyBase +from .entries.mounts.patterns import ( + FuseMountPattern, + MountPatternBase, + MountpointMountPattern, + RcloneMountPattern, + S3FilesMountPattern, +) +from .errors import MountConfigError +from .manifest import Manifest + +REDACTED_MOUNT_AUTHORITY_KEY = "__openai_agents_redacted_mount_authority" +CREDENTIALLESS_MOUNT_AUTHORITY_KEY = "__openai_agents_credentialless_mount_authority" + +# These fields are authority, not merely secrets. Identifiers such as an Azure managed-identity +# client ID or a provider secret name can grant a mount access even though they are not secret +# values by themselves. +_AUTHORITY_FIELDS_BY_MOUNT_TYPE: dict[str, tuple[str, ...]] = { + "azure_blob_mount": ("identity_client_id", "account_key"), + "box_mount": ( + "client_id", + "client_secret", + "access_token", + "token", + "box_config_file", + "config_credentials", + ), + "gcs_mount": ( + "access_id", + "secret_access_key", + "service_account_file", + "service_account_credentials", + "access_token", + ), + "r2_mount": ("access_key_id", "secret_access_key"), + "s3_mount": ("access_key_id", "secret_access_key", "session_token"), +} +_AUTHORITY_FILE_FIELDS_BY_MOUNT_TYPE: dict[str, tuple[str, ...]] = { + "box_mount": ("box_config_file",), + "gcs_mount": ("service_account_file",), +} +_URL_FIELDS_BY_MOUNT_TYPE: dict[str, tuple[str, ...]] = { + "azure_blob_mount": ("endpoint",), + "gcs_mount": ("endpoint_url",), + "r2_mount": ("custom_domain",), + "s3_mount": ("endpoint_url",), +} +_BLAXEL_S3FS_OPTION_FIELDS_BY_MOUNT_TYPE: dict[str, tuple[str, ...]] = { + "r2_mount": ("custom_domain", "account_id"), + "s3_mount": ("endpoint_url", "region"), +} +# Every free-form value interpolated into an rclone configuration line must remain a single line. +# Keep this table aligned with the built-in providers' ``_rclone_required_lines`` methods. +_RCLONE_CONFIG_VALUE_FIELDS_BY_MOUNT_TYPE: dict[str, tuple[str, ...]] = { + "azure_blob_mount": ("account", "endpoint", "identity_client_id", "account_key"), + "box_mount": ( + "client_id", + "client_secret", + "access_token", + "token", + "box_config_file", + "config_credentials", + "root_folder_id", + "impersonate", + "owned_by", + ), + "gcs_mount": ( + "access_id", + "secret_access_key", + "region", + "endpoint_url", + "service_account_file", + "service_account_credentials", + "access_token", + ), + "r2_mount": ("account_id", "access_key_id", "secret_access_key", "custom_domain"), + "s3_mount": ( + "s3_provider", + "endpoint_url", + "region", + "access_key_id", + "secret_access_key", + "session_token", + ), +} +_CANONICAL_MOUNT_TYPES: tuple[tuple[type[Mount], str], ...] = ( + (AzureBlobMount, "azure_blob_mount"), + (BoxMount, "box_mount"), + (GCSMount, "gcs_mount"), + (R2Mount, "r2_mount"), + (S3FilesMount, "s3_files_mount"), + (S3Mount, "s3_mount"), +) + +# Opaque third-party configuration cannot be classified safely by option name. The complete field +# is therefore live authority: it is allowed only at a trusted external executor and is removed +# from durable state as a unit. +_OPAQUE_STRATEGY_AUTHORITY_FIELDS: dict[str, tuple[str, ...]] = { + "docker_volume": ("driver_options",), + "modal_cloud_bucket": ("secret_name", "secret_environment_name"), +} + +# SDK-owned extension entry types are not necessarily imported when raw RunState is restored. +# Keep this list closed so documented extension mounts remain import-order independent without +# treating arbitrary unregistered entries as trusted mounts. +_SDK_EXTENSION_MOUNT_CLASSIFICATION_BY_TYPE: dict[str, tuple[str, str]] = { + "blaxel_drive_mount": ( + "agents.extensions.sandbox.blaxel.mounts", + "BlaxelDriveMount", + ), +} +_SDK_EXTENSION_MOUNT_SERIALIZED_FIELDS_BY_TYPE: dict[str, frozenset[str]] = { + "blaxel_drive_mount": frozenset( + { + "type", + "description", + "ephemeral", + "group", + "is_dir", + "permissions", + "mount_path", + "read_only", + "mount_strategy", + "drive_name", + "drive_mount_path", + "drive_path", + "drive_read_only", + } + ), +} +_SDK_EXTENSION_MOUNT_ENTRY_TYPES = frozenset(_SDK_EXTENSION_MOUNT_CLASSIFICATION_BY_TYPE) + +# This closed table is the source of truth for SDK-owned execution boundaries. Class provenance +# keeps module reloads stable while ordinary custom subclasses cannot promote themselves into a +# trusted boundary by overriding strategy attributes. +_STRATEGY_CLASSIFICATION_BY_TYPE: dict[str, tuple[str, str | None, str, str]] = { + "in_container": ( + "in_container", + None, + "agents.sandbox.entries.mounts.base", + "InContainerMountStrategy", + ), + "docker_volume": ( + "external", + "docker", + "agents.sandbox.entries.mounts.base", + "DockerVolumeMountStrategy", + ), + "blaxel_cloud_bucket": ( + "in_container", + "blaxel", + "agents.extensions.sandbox.blaxel.mounts", + "BlaxelCloudBucketMountStrategy", + ), + "blaxel_drive": ( + "external", + "blaxel", + "agents.extensions.sandbox.blaxel.mounts", + "BlaxelDriveMountStrategy", + ), + "cloudflare_bucket_mount": ( + "external", + "cloudflare", + "agents.extensions.sandbox.cloudflare.mounts", + "CloudflareBucketMountStrategy", + ), + "daytona_cloud_bucket": ( + "in_container", + "daytona", + "agents.extensions.sandbox.daytona.mounts", + "DaytonaCloudBucketMountStrategy", + ), + "e2b_cloud_bucket": ( + "in_container", + "e2b", + "agents.extensions.sandbox.e2b.mounts", + "E2BCloudBucketMountStrategy", + ), + "modal_cloud_bucket": ( + "external", + "modal", + "agents.extensions.sandbox.modal.mounts", + "ModalCloudBucketMountStrategy", + ), + "runloop_cloud_bucket": ( + "in_container", + "runloop", + "agents.extensions.sandbox.runloop.mounts", + "RunloopCloudBucketMountStrategy", + ), + "vercel_cloud_bucket": ( + "in_container", + "vercel", + "agents.extensions.sandbox.vercel.mounts", + "VercelCloudBucketMountStrategy", + ), +} +_SERIALIZED_FIELDS_BY_STRATEGY_TYPE: dict[str, frozenset[str]] = { + "in_container": frozenset({"type", "pattern"}), + "docker_volume": frozenset({"type", "driver", "driver_options"}), + "blaxel_cloud_bucket": frozenset({"type"}), + "blaxel_drive": frozenset({"type"}), + "cloudflare_bucket_mount": frozenset({"type"}), + "daytona_cloud_bucket": frozenset({"type", "pattern"}), + "e2b_cloud_bucket": frozenset({"type", "pattern"}), + "modal_cloud_bucket": frozenset({"type", "secret_name", "secret_environment_name"}), + "runloop_cloud_bucket": frozenset({"type", "pattern"}), + "vercel_cloud_bucket": frozenset({"type"}), +} +_SERIALIZED_PATTERN_CLASS_BY_TYPE: dict[str, type[MountPatternBase]] = { + "fuse": FuseMountPattern, + "mountpoint": MountpointMountPattern, + "rclone": RcloneMountPattern, + "s3files": S3FilesMountPattern, +} +_SERIALIZED_OPTIONS_CLASS_BY_PATTERN_TYPE = { + "mountpoint": MountpointMountPattern.MountpointOptions, + "s3files": S3FilesMountPattern.S3FilesOptions, +} +_TRUSTED_IN_CONTAINER_OPT_IN_FIELDS: dict[str, frozenset[str]] = { + "vercel_cloud_bucket": frozenset({"access_key_id", "secret_access_key", "session_token"}), +} +_RCLONE_SAFE_FLAG_ARGS = frozenset({"allow-other"}) +_RCLONE_SAFE_VALUE_ARGS = frozenset({"buffer-size", "gid", "uid"}) +_SAFE_MOUNT_VALIDATION_MESSAGE_ATTR = "_agents_safe_mount_validation_message" + +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +class _InvalidRawMountManifestError(ValueError): + pass + + +def redact_mount_error_data( + function: Callable[_P, Coroutine[Any, Any, _T]], +) -> Callable[_P, Coroutine[Any, Any, _T]]: + """Replace marked validation failures after clearing payload-bearing async frames.""" + + @wraps(function) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + call_has_authority = _call_has_configured_mount_authority(args, kwargs) + safe_error: Exception | None = None + safe_cancel: asyncio.CancelledError | None = None + try: + return await function(*args, **kwargs) + except asyncio.CancelledError as error: + if not call_has_authority: + raise + discard_mount_source_exception(error) + safe_cancel = asyncio.CancelledError() + except Exception as error: + if isinstance(error, MountConfigError) and _is_error_data_redacted(error): + safe_error = _replace_mount_error(error) + elif _is_error_data_redacted(error): + _clear_data_redacted_error_traceback(error) + _detach_data_redacted_error_traceback(error) + error.__cause__ = None + error.__context__ = None + safe_error = error + elif call_has_authority: + safe_error = _replace_mount_operation_error(error) + else: + raise + + del args, kwargs, call_has_authority + if safe_cancel is not None: + raise safe_cancel from None + assert safe_error is not None + _raise_data_redacted_error(safe_error) + + return wrapper + + +def redact_mount_error_data_sync(function: Callable[_P, _T]) -> Callable[_P, _T]: + """Replace marked validation failures after clearing payload-bearing sync frames.""" + + @wraps(function) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + call_has_authority = _call_has_configured_mount_authority(args, kwargs) + safe_error: Exception | None = None + try: + return function(*args, **kwargs) + except Exception as error: + if isinstance(error, MountConfigError) and _is_error_data_redacted(error): + safe_error = _replace_mount_error(error) + elif _is_error_data_redacted(error): + _clear_data_redacted_error_traceback(error) + _detach_data_redacted_error_traceback(error) + error.__cause__ = None + error.__context__ = None + safe_error = error + elif call_has_authority: + safe_error = _replace_mount_operation_error(error) + else: + raise + + del args, kwargs, call_has_authority + assert safe_error is not None + _raise_data_redacted_error(safe_error) + + return wrapper + + +def _replace_mount_error(error: MountConfigError) -> MountConfigError: + message = ( + error.message + if getattr(error, _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR, False) + else "sandbox mount configuration is invalid" + ) + safe_error = MountConfigError(message=message) + _mark_error_data_redacted(safe_error) + _clear_data_redacted_error_traceback(error) + _detach_data_redacted_error_traceback(error) + error.__cause__ = None + error.__context__ = None + error.args = ("Error details are redacted.",) + error.context = {} + return safe_error + + +def _replace_mount_operation_error(error: Exception) -> RuntimeError: + discard_mount_source_exception(error) + safe_error = RuntimeError( + "sandbox operation failed while using a protected mount configuration" + ) + _mark_error_data_redacted(safe_error) + return safe_error + + +def discard_mount_source_exception(error: BaseException) -> None: + """Clear source frames without consulting provider-defined exception attributes.""" + + pending = [error] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + + linked: list[BaseException] = [] + for descriptor in ( + cast(Any, BaseException.__cause__), + cast(Any, BaseException.__context__), + ): + try: + candidate = descriptor.__get__(current, type(current)) + except BaseException: + continue + if isinstance(candidate, BaseException): + linked.append(candidate) + + try: + source_traceback = cast(Any, BaseException.__traceback__).__get__( + current, type(current) + ) + except BaseException: + source_traceback = None + if source_traceback is not None: + try: + traceback.clear_frames(source_traceback) + except BaseException: + pass + try: + BaseException.__init__(current) + except BaseException: + pass + for descriptor, value in ( + (cast(Any, BaseException.args), ()), + (cast(Any, BaseException.__traceback__), None), + (cast(Any, BaseException.__cause__), None), + (cast(Any, BaseException.__context__), None), + ): + try: + descriptor.__set__(current, value) + except BaseException: + pass + pending.extend(linked) + + +def _url_contains_inline_authority(value: object) -> bool: + if value is None: + return False + if not isinstance(value, str): + return True + if "@" in value: + return True + try: + parsed = urlsplit(value) + except ValueError: + return True + return parsed.username is not None or parsed.password is not None or bool(parsed.query) + + +def _rclone_extra_args_analysis( + extra_args: Iterable[object], +) -> tuple[bool, tuple[str, ...], bool]: + """Classify the supported subset and return exact caller-provided config paths.""" + + args = tuple(extra_args) + config_paths: list[str] = [] + safe = True + invalid_config_path = False + index = 0 + while index < len(args): + arg = args[index] + if not isinstance(arg, str) or not arg.startswith("-"): + safe = False + index += 1 + continue + option, separator, value = arg.lstrip("-").partition("=") + normalized = option.lower().replace("_", "-") + if normalized == "config": + safe = False + if separator and value: + config_paths.append(value) + index += 1 + elif not separator and index + 1 < len(args): + next_arg = args[index + 1] + if isinstance(next_arg, str) and not next_arg.startswith("-"): + config_paths.append(next_arg) + else: + invalid_config_path = True + index += 2 + else: + invalid_config_path = True + index += 1 + continue + if normalized in _RCLONE_SAFE_FLAG_ARGS and not separator: + index += 1 + continue + if normalized in _RCLONE_SAFE_VALUE_ARGS: + if separator and value: + index += 1 + continue + if not separator and index + 1 < len(args): + next_arg = args[index + 1] + if isinstance(next_arg, str) and not next_arg.startswith("-"): + index += 2 + continue + safe = False + index += 1 + return safe, tuple(config_paths), invalid_config_path + + +def _rclone_extra_args_are_safe(extra_args: Iterable[object]) -> bool: + return _rclone_extra_args_analysis(extra_args)[0] + + +def _rclone_remote_name_is_safe(remote_name: object) -> bool: + if remote_name is None or remote_name == "": + return True + return ( + isinstance(remote_name, str) + and remote_name == remote_name.strip() + and re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_. -]*", remote_name) is not None + ) + + +def _canonical_mount_type(entry_class: type[BaseEntry]) -> str | None: + for canonical_class, canonical_type in _CANONICAL_MOUNT_TYPES: + if issubclass(entry_class, canonical_class): + return canonical_type + return None + + +def _canonical_mount_class(entry_class: type[BaseEntry]) -> type[Mount] | None: + for canonical_class, _canonical_type in _CANONICAL_MOUNT_TYPES: + if issubclass(entry_class, canonical_class): + return canonical_class + return None + + +def _mount_entry_class_is_trusted(entry_class: type[BaseEntry]) -> bool: + canonical_class = _canonical_mount_class(entry_class) + if canonical_class is not None: + return entry_class is canonical_class + return any( + entry_class is _trusted_extension_mount_class(mount_type) + for mount_type in _SDK_EXTENSION_MOUNT_ENTRY_TYPES + ) + + +def _configured_pydantic_extra_fields(value: object) -> tuple[str, ...]: + extra = getattr(value, "model_extra", None) + if not isinstance(extra, Mapping): + return () + return tuple( + name + for name, configured in extra.items() + if isinstance(name, str) and configured is not None + ) + + +def _trusted_strategy_class(strategy_type: str) -> type[MountStrategyBase] | None: + classification = _STRATEGY_CLASSIFICATION_BY_TYPE.get(strategy_type) + if classification is None: + return None + _boundary, _backend_id, module_name, class_name = classification + try: + strategy_class = getattr(importlib.import_module(module_name), class_name) + except (AttributeError, ImportError, TypeError): + return None + if not isinstance(strategy_class, type) or not issubclass(strategy_class, MountStrategyBase): + return None + return strategy_class + + +def _trusted_extension_mount_class(mount_type: str) -> type[Mount] | None: + classification = _SDK_EXTENSION_MOUNT_CLASSIFICATION_BY_TYPE.get(mount_type) + if classification is None: + return None + module_name, class_name = classification + try: + mount_class = getattr(importlib.import_module(module_name), class_name) + except (AttributeError, ImportError, TypeError, ValueError): + return None + if not isinstance(mount_class, type) or not issubclass(mount_class, Mount): + return None + return mount_class + + +def _mount_class_is_trusted(mount: Mount) -> bool: + return _mount_entry_class_is_trusted(type(mount)) + + +def _pattern_class_is_trusted(pattern: object) -> bool: + return any( + type(pattern) is pattern_class and getattr(pattern, "type", None) == pattern_type + for pattern_type, pattern_class in _SERIALIZED_PATTERN_CLASS_BY_TYPE.items() + ) + + +def _configured_custom_mount_fields(mount: Mount) -> tuple[str, ...]: + canonical_class = _canonical_mount_class(type(mount)) + if canonical_class is not None and type(mount) is canonical_class: + return () + if canonical_class is None: + if type(mount) is _trusted_extension_mount_class(mount.type): + return () + safe_fields = Mount.model_fields + else: + safe_fields = canonical_class.model_fields + configured_fields = [ + name + for name in type(mount).model_fields + if name not in safe_fields and getattr(mount, name, None) is not None + ] + configured_fields.extend(_configured_pydantic_extra_fields(mount)) + return tuple(dict.fromkeys(configured_fields)) + + +def _configured_custom_pattern_fields(pattern: object) -> tuple[str, ...]: + canonical_class = next( + ( + pattern_class + for pattern_class in _SERIALIZED_PATTERN_CLASS_BY_TYPE.values() + if isinstance(pattern, pattern_class) + ), + None, + ) + if canonical_class is not None and type(pattern) is canonical_class: + return () + safe_fields: Collection[str] = ( + canonical_class.model_fields if canonical_class is not None else {"type"} + ) + pattern_fields = getattr(type(pattern), "model_fields", {}) + configured_fields = [ + name + for name in pattern_fields + if name not in safe_fields and getattr(pattern, name, None) is not None + ] + configured_fields.extend(_configured_pydantic_extra_fields(pattern)) + return tuple(dict.fromkeys(configured_fields)) + + +def _value_contains_config_line_break(value: object) -> bool: + return isinstance(value, str) and ("\r" in value or "\n" in value) + + +def _value_contains_s3fs_option_delimiter(value: object) -> bool: + return isinstance(value, str) and "," in value + + +def _configured_rclone_line_fields(mount: Mount, mount_type: str) -> tuple[str, ...]: + return tuple( + name + for name in _RCLONE_CONFIG_VALUE_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()) + if _value_contains_config_line_break(getattr(mount, name, None)) + ) + + +def _configured_blaxel_s3fs_option_fields( + mount: Mount, + mount_type: str, +) -> tuple[str, ...]: + if mount.mount_strategy.type != "blaxel_cloud_bucket": + return () + return tuple( + name + for name in _BLAXEL_S3FS_OPTION_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()) + if _value_contains_s3fs_option_delimiter(getattr(mount, name, None)) + ) + + +def _configured_unknown_strategy_fields(strategy: MountStrategyBase) -> tuple[str, ...]: + if _strategy_classification(strategy)[0] != "unknown": + return () + configured_fields = [ + name + for name in type(strategy).model_fields + if name != "type" and getattr(strategy, name, None) is not None + ] + configured_fields.extend(_configured_pydantic_extra_fields(strategy)) + return tuple(dict.fromkeys(configured_fields)) + + +def _configured_mount_authority_fields(mount: Mount) -> tuple[str, ...]: + mount_type = _canonical_mount_type(type(mount)) or mount.type + fields = [ + name + for name in _AUTHORITY_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()) + if getattr(mount, name, None) is not None + ] + fields.extend( + name + for name in _URL_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()) + if _url_contains_inline_authority(getattr(mount, name, None)) + ) + fields.extend(_configured_rclone_line_fields(mount, mount_type)) + fields.extend(_configured_custom_mount_fields(mount)) + + strategy = mount.mount_strategy + strategy_boundary, _strategy_backend_id = _strategy_classification(strategy) + fields.extend(_configured_blaxel_s3fs_option_fields(mount, mount_type)) + if strategy_boundary == "unknown": + fields.extend( + f"mount_strategy.{name}" for name in _configured_unknown_strategy_fields(strategy) + ) + else: + fields.extend( + f"mount_strategy.{name}" + for name in _OPAQUE_STRATEGY_AUTHORITY_FIELDS.get(strategy.type, ()) + if getattr(strategy, name, None) + ) + pattern = getattr(strategy, "pattern", None) + fields.extend( + f"mount_strategy.pattern.{name}" for name in _configured_custom_pattern_fields(pattern) + ) + if isinstance(pattern, RcloneMountPattern): + if not _rclone_remote_name_is_safe(pattern.remote_name): + fields.append("mount_strategy.pattern.remote_name") + if pattern.config_file_path is not None: + fields.append("mount_strategy.pattern.config_file_path") + if not _rclone_extra_args_are_safe(pattern.extra_args): + fields.append("mount_strategy.pattern.extra_args") + elif isinstance(pattern, MountpointMountPattern) and _url_contains_inline_authority( + pattern.options.endpoint_url + ): + fields.append("mount_strategy.pattern.options.endpoint_url") + elif isinstance(pattern, S3FilesMountPattern) and pattern.options.extra_options: + fields.append("mount_strategy.pattern.options.extra_options") + if mount_type == "s3_files_mount" and getattr(mount, "extra_options", None): + fields.append("extra_options") + return tuple(dict.fromkeys(fields)) + + +def _manifest_has_configured_mount_authority(manifest: Manifest) -> bool: + pending = list(manifest.entries.values()) + while pending: + entry = pending.pop() + if isinstance(entry, Mount) and _mount_has_or_may_hide_configured_authority(entry): + return True + if isinstance(entry, Dir): + pending.extend(entry.children.values()) + return False + + +def _mount_has_or_may_hide_configured_authority(mount: Mount) -> bool: + """Classify untrusted mount implementations without reading their configuration.""" + + if not _mount_class_is_trusted(mount): + return True + strategy = mount.mount_strategy + if _strategy_classification(strategy)[0] == "unknown": + return True + pattern = getattr(strategy, "pattern", None) + if pattern is not None and not _pattern_class_is_trusted(pattern): + return True + return bool(_configured_mount_authority_fields(mount)) + + +def _call_has_configured_mount_authority( + args: tuple[object, ...], kwargs: Mapping[str, object] +) -> bool: + """Inspect only SDK call-boundary manifest owners.""" + + try: + for value in (*args, *kwargs.values()): + candidates = [value] + state = getattr(value, "state", None) + if state is not None: + candidates.append(state) + default_manifest = getattr(value, "default_manifest", None) + if default_manifest is not None: + candidates.append(default_manifest) + sandbox_config = getattr(value, "_sandbox_config", None) + if sandbox_config is not None: + candidates.append(sandbox_config) + configured_state = getattr(sandbox_config, "session_state", None) + if configured_state is not None: + candidates.append(configured_state) + configured_session = getattr(sandbox_config, "session", None) + if configured_session is not None: + candidates.append(configured_session) + configured_session_state = getattr(configured_session, "state", None) + if configured_session_state is not None: + candidates.append(configured_session_state) + for candidate in candidates: + has_runtime_authority = getattr( + candidate, + "_runtime_has_protected_mount_authority", + None, + ) + if ( + not isinstance(candidate, type) + and callable(has_runtime_authority) + and has_runtime_authority() + ): + return True + if isinstance(candidate, Mount): + if _mount_has_or_may_hide_configured_authority(candidate): + return True + continue + if isinstance(candidate, Mapping) and any( + isinstance(item, Mount) and _mount_has_or_may_hide_configured_authority(item) + for item in candidate.values() + ): + return True + manifest = ( + candidate + if isinstance(candidate, Manifest) + else getattr(candidate, "manifest", None) + ) + if isinstance(manifest, Manifest) and _manifest_has_configured_mount_authority( + manifest + ): + return True + except Exception: + return True + return False + + +def _strategy_classification(strategy: MountStrategyBase) -> tuple[str, str | None]: + for strategy_type, classification in _STRATEGY_CLASSIFICATION_BY_TYPE.items(): + if type(strategy) is _trusted_strategy_class(strategy_type): + if strategy.type != strategy_type: + return "unknown", None + boundary, backend_id, _module_name, _class_name = classification + return boundary, backend_id + return "unknown", None + + +def _redact_mount_serialization_error(error: Exception) -> MountConfigError: + discard_mount_source_exception(error) + safe_error = MountConfigError( + message="sandbox session state containing mount authority could not be serialized" + ) + _mark_error_data_redacted(safe_error) + return safe_error + + +def _redact_mount_state_validation_error(error: Exception, *, message: str) -> ValueError: + discard_mount_source_exception(error) + safe_error = ValueError(message) + _mark_error_data_redacted(safe_error) + return safe_error + + +def _mark_mount_error_for_manifest(error: MountConfigError, manifest: Manifest) -> None: + if _manifest_has_configured_mount_authority(manifest): + _mark_error_data_redacted(error) + + +def _mark_mount_validation_error(error: MountConfigError) -> None: + _mark_error_data_redacted(error) + setattr(error, _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR, True) + + +def _absolute_manifest_path(root: str, value: str) -> str: + path = PurePosixPath(value) + if not path.is_absolute(): + path = PurePosixPath(root) / path + normalized: list[str] = [] + for part in path.parts: + if part in {"", ".", "/"}: + continue + if part == "..": + if normalized: + normalized.pop() + continue + normalized.append(part) + return "/" + "/".join(normalized) + + +def _manifest_materializes_path(manifest: Manifest, target: str) -> bool: + """Return whether an entry can place content at a workspace credential path.""" + + target_path = PurePosixPath(_absolute_manifest_path(manifest.root, target)) + for path, entry in manifest.iter_entries(): + entry_path = PurePosixPath(_absolute_manifest_path(manifest.root, path.as_posix())) + overlaps_target = entry_path == target_path or entry_path in target_path.parents + if not overlaps_target: + continue + proven_structural_directory = type(entry) is Dir or ( + type(entry) is LocalDir and entry.src is None + ) + if not proven_structural_directory: + return True + return False + + +def _manifest_mount_provenance_error(manifest: Manifest) -> MountConfigError | None: + """Reject unsupported mount classes before invoking their behavior or serializers.""" + + for _path, entry in manifest.iter_entries(): + if not isinstance(entry, Mount): + continue + if error := _mount_provenance_error(entry): + return error + return None + + +def _mount_provenance_error( + mount: Mount, + strategy: MountStrategyBase | None = None, +) -> MountConfigError | None: + """Validate exact SDK class provenance without copying or inspecting configuration values.""" + + if not _mount_class_is_trusted(mount): + return MountConfigError( + message=( + "custom mount implementations are not supported at the sandbox credential boundary" + ) + ) + resolved_strategy = strategy if strategy is not None else mount.mount_strategy + if _strategy_classification(resolved_strategy)[0] == "unknown": + return MountConfigError( + message="custom mount strategies are not supported at the sandbox credential boundary" + ) + pattern = getattr(resolved_strategy, "pattern", None) + if pattern is not None and not _pattern_class_is_trusted(pattern): + return MountConfigError( + message="custom mount patterns are not supported at the sandbox credential boundary" + ) + return None + + +def _validate_mount_provenance( + mount: Mount, + strategy: MountStrategyBase | None = None, +) -> None: + error = _mount_provenance_error(mount, strategy) + if error is None: + return + _mark_mount_validation_error(error) + mount = cast(Any, None) + strategy = cast(Any, None) + _raise_data_redacted_error(error) + + +def _validate_manifest_mount_provenance(manifest: Manifest) -> None: + error = _manifest_mount_provenance_error(manifest) + if error is None: + return + _mark_mount_validation_error(error) + manifest = cast(Any, None) + _raise_data_redacted_error(error) + + +def _manifest_boundary_error( + manifest: Manifest, + *, + allowed_in_container_credential_strategy_types: frozenset[str], + provider_backend_id: str | None, +) -> MountConfigError | None: + provenance_error = _manifest_mount_provenance_error(manifest) + if provenance_error is not None: + return provenance_error + for mount, _mount_path in manifest.mount_targets(): + mount_type = _canonical_mount_type(type(mount)) or mount.type + strategy = mount.mount_strategy + strategy_boundary, strategy_backend_id = _strategy_classification(strategy) + pattern = getattr(strategy, "pattern", None) + executes_in_container = strategy_boundary == "in_container" + if ( + provider_backend_id is not None + and strategy_backend_id is not None + and strategy_backend_id != provider_backend_id + ): + return MountConfigError( + message=( + "docker-volume mounts are not supported by this sandbox backend" + if strategy.type == "docker_volume" + else "mount strategy is not supported by this sandbox backend" + ), + context={ + "mount_type": mount.type, + "strategy_type": strategy.type, + "sandbox_backend": provider_backend_id, + }, + ) + + for field_name in _AUTHORITY_FILE_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()): + value = getattr(mount, field_name, None) + if isinstance(value, str) and value and _manifest_materializes_path(manifest, value): + return MountConfigError( + message=( + "credential files stored in the manifest are not supported for cloud " + "mounts; configure credentials outside the sandbox manifest" + ), + context={"mount_type": mount.type, "credential_field": field_name}, + ) + + invalid_rclone_fields = _configured_rclone_line_fields(mount, mount_type) + if ( + executes_in_container + and isinstance(pattern, RcloneMountPattern) + and invalid_rclone_fields + ): + return MountConfigError( + message="cloud mount configuration values must not contain line breaks", + context={ + "mount_type": mount.type, + "configuration_fields": invalid_rclone_fields, + }, + ) + if executes_in_container and mount_type == "box_mount": + return MountConfigError( + message=( + "Box mounts require credentials and are not supported by helpers that run " + "inside the sandbox; use an external/provider-native mount strategy" + ), + context={"mount_type": mount.type, "strategy_type": strategy.type}, + ) + if executes_in_container and isinstance(pattern, FuseMountPattern): + return MountConfigError( + message=( + "credentialless blobfuse mounts are not supported inside the sandbox; " + "use RcloneMountPattern or an external/provider-native mount strategy" + ), + context={"mount_type": mount.type, "strategy_type": strategy.type}, + ) + if executes_in_container and isinstance(pattern, S3FilesMountPattern): + return MountConfigError( + message=( + "S3 Files mounts are not supported inside the sandbox because the helper " + "requires ambient IAM credentials; use an external/provider-native strategy" + ), + context={"mount_type": mount.type, "strategy_type": strategy.type}, + ) + invalid_s3fs_fields = _configured_blaxel_s3fs_option_fields(mount, mount_type) + if invalid_s3fs_fields: + return MountConfigError( + message="cloud mount configuration values must not contain s3fs option delimiters", + context={ + "mount_type": mount.type, + "configuration_fields": invalid_s3fs_fields, + }, + ) + + authority_fields = _configured_mount_authority_fields(mount) + trusted_opt_in_fields = _TRUSTED_IN_CONTAINER_OPT_IN_FIELDS.get(strategy.type, frozenset()) + exact_trusted_opt_in = ( + strategy_boundary == "in_container" + and strategy_backend_id is not None + and strategy_backend_id == provider_backend_id + and strategy.type in allowed_in_container_credential_strategy_types + and frozenset(authority_fields).issubset(trusted_opt_in_fields) + ) + if authority_fields and strategy_boundary != "external" and not exact_trusted_opt_in: + return MountConfigError( + message=( + "cloud credentials are not supported by a mount helper that runs inside " + "the sandbox; use an external/provider-native mount strategy" + ), + context={"mount_type": mount.type, "credential_fields": authority_fields}, + ) + + return None + + +def validate_manifest_mount_credential_boundaries( + manifest: Manifest, + *, + allowed_in_container_credential_strategy_types: frozenset[str] = frozenset(), + provider_backend_id: str | None = None, +) -> None: + """Validate all mount authority before a sandbox or helper has side effects.""" + + error = _manifest_boundary_error( + manifest, + allowed_in_container_credential_strategy_types=( + allowed_in_container_credential_strategy_types + ), + provider_backend_id=provider_backend_id, + ) + if error is None: + return + _mark_mount_validation_error(error) + del manifest, allowed_in_container_credential_strategy_types + provider_backend_id = None + _raise_data_redacted_error(error) + + +def validate_mount_activation_credential_boundary( + mount: Mount, + strategy: MountStrategyBase, + *, + provider_backend_id: str | None = None, +) -> None: + """Revalidate the strategy that is about to execute inside a sandbox.""" + + _validate_mount_provenance(mount, strategy) + activation_mount = mount.model_copy(deep=True, update={"mount_strategy": strategy}) + validate_manifest_mount_credential_boundaries( + Manifest(entries={"mount": activation_mount}), + provider_backend_id=provider_backend_id, + ) + + +def sanitize_manifest_mount_authority(manifest: Manifest) -> tuple[Manifest, bool]: + """Return a typed manifest whose durable form contains no mount authority.""" + + provenance_error = _manifest_mount_provenance_error(manifest) + if provenance_error is not None: + _mark_mount_validation_error(provenance_error) + manifest = cast(Any, None) + _raise_data_redacted_error(provenance_error) + + safe_error: MountConfigError | None = None + try: + raw_manifest = manifest.model_dump(mode="json") + except Exception as error: + if not _manifest_has_configured_mount_authority(manifest): + raise + safe_error = _redact_mount_serialization_error(error) + + if safe_error is not None: + manifest = cast(Any, None) + _raise_data_redacted_error(safe_error) + + sanitized, redacted = sanitize_raw_manifest_mount_authority(raw_manifest) + assert isinstance(sanitized, dict) + return Manifest.model_validate(sanitized), redacted + + +def rebind_manifest_mount_authority( + persisted_manifest: Manifest, + trusted_manifest: Manifest, + *, + provider_backend_id: str, +) -> Manifest: + """Restore external live authority after exact credential-free topology matching.""" + + error = _manifest_boundary_error( + trusted_manifest, + allowed_in_container_credential_strategy_types=frozenset(), + provider_backend_id=provider_backend_id, + ) + sanitized_persisted, _ = sanitize_manifest_mount_authority(persisted_manifest) + sanitized_trusted, _ = sanitize_manifest_mount_authority(trusted_manifest) + persisted_mounts = { + path.as_posix(): entry + for path, entry in sanitized_persisted.iter_entries() + if isinstance(entry, Mount) + } + trusted_mounts = { + path.as_posix(): entry + for path, entry in sanitized_trusted.iter_entries() + if isinstance(entry, Mount) + } + topology_matches = ( + sanitized_persisted.root == sanitized_trusted.root + and persisted_mounts.keys() == trusted_mounts.keys() + and all( + persisted_mounts[path].model_dump(mode="json") + == trusted_mounts[path].model_dump(mode="json") + for path in persisted_mounts + ) + ) + if error is None and not topology_matches: + error = MountConfigError( + message=( + "sandbox mount configuration can be rebound only from a current trusted " + "external mount configuration with exactly matching credential-free topology" + ), + context={"sandbox_backend": provider_backend_id}, + ) + if error is not None: + _mark_mount_validation_error(error) + persisted_manifest = cast(Any, None) + trusted_manifest = cast(Any, None) + sanitized_persisted = cast(Any, None) + sanitized_trusted = cast(Any, None) + persisted_mounts = {} + trusted_mounts = {} + provider_backend_id = "" + _raise_data_redacted_error(error) + + rebound = persisted_manifest.model_copy(deep=True) + trusted_entries = { + path.as_posix(): entry + for path, entry in trusted_manifest.iter_entries() + if isinstance(entry, Mount) + } + for path, entry in rebound.iter_entries(): + if isinstance(entry, Mount): + entry.__dict__.update(trusted_entries[path.as_posix()].model_copy(deep=True).__dict__) + return rebound + + +def _iter_raw_entries( + entries: object, + registered_entry_types: Mapping[str, type[BaseEntry]], + parent: PurePosixPath | None = None, +) -> Iterable[tuple[PurePosixPath, dict[str, Any]]]: + parent = parent or PurePosixPath() + if not isinstance(entries, Mapping): + return + for name, value in entries.items(): + if not isinstance(value, dict): + continue + path = parent / PurePosixPath(str(name)) + yield path, value + entry_type = value.get("type") + entry_class = ( + registered_entry_types.get(entry_type) if isinstance(entry_type, str) else None + ) + if entry_type in _SDK_EXTENSION_MOUNT_ENTRY_TYPES or ( + entry_class is not None and not issubclass(entry_class, Dir) + ): + continue + children = value.get("children") + if isinstance(children, Mapping): + yield from _iter_raw_entries(children, registered_entry_types, path) + + +def _raw_entry_tree_is_valid( + entries: object, + registered_entry_types: Mapping[str, type[BaseEntry]], +) -> bool: + if not isinstance(entries, Mapping): + return False + for entry in entries.values(): + if not isinstance(entry, Mapping): + return False + entry_type = entry.get("type") + if not isinstance(entry_type, str): + return False + entry_class = registered_entry_types.get(entry_type) + if entry_type in _SDK_EXTENSION_MOUNT_ENTRY_TYPES or ( + entry_class is not None and not issubclass(entry_class, Dir) + ): + continue + if "children" in entry and not _raw_entry_tree_is_valid( + entry["children"], registered_entry_types + ): + return False + return True + + +def _raw_entry_is_structural_directory( + entry: Mapping[str, Any], entry_class: type[BaseEntry] | None +) -> bool: + if entry_class is Dir: + return True + return entry_class is LocalDir and entry.get("src") is None + + +def _sanitize_raw_credential_file_sources( + *, + root: str, + raw_entries: Iterable[tuple[PurePosixPath, dict[str, Any]]], + authority_file_paths: Iterable[str], + registered_entry_types: Mapping[str, type[BaseEntry]], +) -> bool: + """Remove inline credential content and reject non-inline materializers.""" + + entries = tuple(raw_entries) + redacted = False + for authority_file_path in authority_file_paths: + target = PurePosixPath(_absolute_manifest_path(root, authority_file_path)) + for path, entry in entries: + entry_path = PurePosixPath(_absolute_manifest_path(root, path.as_posix())) + entry_type = entry.get("type") + entry_class = ( + registered_entry_types.get(entry_type) if isinstance(entry_type, str) else None + ) + if entry_path == target and entry_class is File: + entry["content"] = "" + redacted = True + continue + if entry_path == target or entry_path in target.parents: + if _raw_entry_is_structural_directory(entry, entry_class): + continue + error = _InvalidRawMountManifestError( + "sandbox manifest credential-file source cannot be restored safely" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + return redacted + + +def _strip_raw_configuration( + configuration: dict[str, Any], + safe_fields: Collection[str], +) -> bool: + opaque_fields = tuple(name for name in configuration if name not in safe_fields) + for name in opaque_fields: + configuration.pop(name, None) + return bool(opaque_fields) + + +def _strip_raw_strategy_configuration(strategy: dict[str, Any]) -> bool: + return _strip_raw_configuration(strategy, {"type"}) + + +def _sanitize_raw_mount( + entry: dict[str, Any], + entry_class: type[BaseEntry] | None, +) -> tuple[bool, tuple[str, ...]]: + raw_mount_type = entry.get("type") + canonical_mount_type = _canonical_mount_type(entry_class) if entry_class is not None else None + mount_type = canonical_mount_type or (raw_mount_type if isinstance(raw_mount_type, str) else "") + redacted = False + authority_file_paths: list[str] = [] + canonical_mount_class = _canonical_mount_class(entry_class) if entry_class is not None else None + safe_mount_fields: Collection[str] | None = None + if canonical_mount_class is not None: + safe_mount_fields = canonical_mount_class.model_fields + elif mount_type in _SDK_EXTENSION_MOUNT_SERIALIZED_FIELDS_BY_TYPE: + safe_mount_fields = _SDK_EXTENSION_MOUNT_SERIALIZED_FIELDS_BY_TYPE[mount_type] + if safe_mount_fields is not None: + opaque_mount_fields = tuple(name for name in entry if name not in safe_mount_fields) + for name in opaque_mount_fields: + entry.pop(name, None) + redacted = redacted or bool(opaque_mount_fields) + authority_file_fields = _AUTHORITY_FILE_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()) + for name in _AUTHORITY_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()): + value = entry.get(name) + if value is None: + continue + entry[name] = None + redacted = True + if name in authority_file_fields: + if not isinstance(value, str): + error = _InvalidRawMountManifestError( + "sandbox manifest credential-file path has an invalid shape" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + authority_file_paths.append(value) + for name in _URL_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()): + if _url_contains_inline_authority(entry.get(name)): + entry[name] = None + redacted = True + for name in _RCLONE_CONFIG_VALUE_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()): + if _value_contains_config_line_break(entry.get(name)): + entry[name] = "" + redacted = True + if mount_type == "s3_files_mount" and entry.get("extra_options"): + entry["extra_options"] = {} + redacted = True + + strategy = entry.get("mount_strategy") + if not isinstance(strategy, dict): + if strategy is not None: + error = _InvalidRawMountManifestError( + "sandbox manifest mount strategy has an invalid shape" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + return redacted, tuple(authority_file_paths) + raw_strategy_type = strategy.get("type") + if isinstance(raw_strategy_type, str): + strategy_type = raw_strategy_type + else: + strategy["type"] = None + strategy_type = "" + redacted = True + invalid_s3fs_fields = tuple( + name + for name in _BLAXEL_S3FS_OPTION_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()) + if _value_contains_s3fs_option_delimiter(entry.get(name)) + ) + if strategy_type == "blaxel_cloud_bucket" and invalid_s3fs_fields: + error = _InvalidRawMountManifestError( + "sandbox manifest cloud mount configuration contains an s3fs option delimiter" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + serialized_strategy_fields = _SERIALIZED_FIELDS_BY_STRATEGY_TYPE.get(strategy_type) + if isinstance(raw_strategy_type, str) and serialized_strategy_fields is None: + error = _InvalidRawMountManifestError("sandbox manifest mount strategy has an unknown type") + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + trusted_strategy_class = _trusted_strategy_class(strategy_type) + if serialized_strategy_fields is not None and ( + MountStrategyBase._subclass_registry.get(strategy_type) is not trusted_strategy_class + ): + error = _InvalidRawMountManifestError("custom mount strategies cannot be restored safely") + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + strip_unknown_strategy_fields = serialized_strategy_fields is None + if serialized_strategy_fields is not None: + redacted = _strip_raw_configuration(strategy, serialized_strategy_fields) or redacted + opaque_fields = _OPAQUE_STRATEGY_AUTHORITY_FIELDS.get( + strategy_type, + (), + ) + for name in opaque_fields: + value = strategy.get(name) + if value: + strategy[name] = {} if isinstance(value, Mapping) else None + redacted = True + pattern = strategy.get("pattern") + if not isinstance(pattern, dict): + if pattern is not None: + error = _InvalidRawMountManifestError( + "sandbox manifest mount pattern has an invalid shape" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + if strip_unknown_strategy_fields: + redacted = _strip_raw_strategy_configuration(strategy) or redacted + return redacted, tuple(authority_file_paths) + pattern_type = pattern.get("type") + if not isinstance(pattern_type, str): + pattern["type"] = None + redacted = True + serialized_pattern_class = ( + _SERIALIZED_PATTERN_CLASS_BY_TYPE.get(pattern_type) + if isinstance(pattern_type, str) + else None + ) + if isinstance(pattern_type, str) and serialized_pattern_class is None: + error = _InvalidRawMountManifestError("sandbox manifest mount pattern has an unknown type") + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + serialized_pattern_fields: Collection[str] = ( + serialized_pattern_class.model_fields if serialized_pattern_class is not None else {"type"} + ) + if not _rclone_remote_name_is_safe(pattern.get("remote_name")): + pattern["remote_name"] = None + redacted = True + config_path = pattern.get("config_file_path") + if config_path is not None: + pattern["config_file_path"] = None + redacted = True + if not isinstance(config_path, str): + error = _InvalidRawMountManifestError( + "sandbox manifest rclone config-file path has an invalid shape" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + authority_file_paths.append(config_path) + extra_args = pattern.get("extra_args", []) + safe_extra_args = False + extra_config_paths: tuple[str, ...] = () + invalid_config_path = False + if isinstance(extra_args, list | tuple): + safe_extra_args, extra_config_paths, invalid_config_path = _rclone_extra_args_analysis( + extra_args + ) + if invalid_config_path: + error = _InvalidRawMountManifestError( + "sandbox manifest rclone config argument has an invalid shape" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + if not safe_extra_args: + if extra_args: + pattern["extra_args"] = [] + redacted = True + authority_file_paths.extend(extra_config_paths) + options = pattern.get("options") + if isinstance(options, dict): + if _url_contains_inline_authority(options.get("endpoint_url")): + options["endpoint_url"] = None + redacted = True + if options.get("extra_options") and ( + mount_type == "s3_files_mount" + or pattern_type == "s3files" + or not isinstance(pattern_type, str) + ): + options["extra_options"] = {} + redacted = True + serialized_options_class = ( + _SERIALIZED_OPTIONS_CLASS_BY_PATTERN_TYPE.get(pattern_type) + if isinstance(pattern_type, str) + else None + ) + serialized_options_fields = ( + tuple(field.name for field in dataclasses.fields(serialized_options_class)) + if serialized_options_class is not None + else () + ) + redacted = _strip_raw_configuration(options, serialized_options_fields) or redacted + elif options is not None: + pattern["options"] = {} + redacted = True + redacted = _strip_raw_configuration(pattern, serialized_pattern_fields) or redacted + if strip_unknown_strategy_fields: + redacted = _strip_raw_strategy_configuration(strategy) or redacted + return redacted, tuple(authority_file_paths) + + +def sanitize_raw_manifest_mount_authority(payload: object) -> tuple[object, bool]: + """Sanitize the documented raw manifest shape without importing provider state classes.""" + + if not isinstance(payload, Mapping): + return payload, False + manifest = copy.deepcopy(dict(payload)) + registered_entry_types = BaseEntry.registered_types() + if "entries" in manifest and not _raw_entry_tree_is_valid( + manifest["entries"], registered_entry_types + ): + if isinstance(payload, dict): + payload.clear() + error = _InvalidRawMountManifestError("sandbox manifest entries have an invalid shape") + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + root_value = manifest.get("root") + root = root_value if isinstance(root_value, str) else "/workspace" + raw_entries = list(_iter_raw_entries(manifest.get("entries"), registered_entry_types)) + redacted = False + authority_file_paths: set[str] = set() + for _path, entry in raw_entries: + entry_type = entry.get("type") + entry_class = ( + registered_entry_types.get(entry_type) if isinstance(entry_type, str) else None + ) + if entry_class is None: + if entry_type not in _SDK_EXTENSION_MOUNT_ENTRY_TYPES: + if "mount_strategy" not in entry: + continue + error = _InvalidRawMountManifestError( + "sandbox manifest contains an unknown mount-like entry" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + elif not issubclass(entry_class, Mount): + continue + elif not _mount_entry_class_is_trusted(entry_class): + error = _InvalidRawMountManifestError( + "custom mount implementations cannot be restored safely" + ) + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + entry_redacted, file_paths = _sanitize_raw_mount(entry, entry_class) + redacted = redacted or entry_redacted + authority_file_paths.update(_absolute_manifest_path(root, value) for value in file_paths) + redacted = ( + _sanitize_raw_credential_file_sources( + root=root, + raw_entries=raw_entries, + authority_file_paths=authority_file_paths, + registered_entry_types=registered_entry_types, + ) + or redacted + ) + + return manifest, redacted + + +def sanitize_raw_session_state_mount_authority(payload: object) -> tuple[object, bool]: + if not isinstance(payload, Mapping): + return payload, False + state = copy.deepcopy(dict(payload)) + if "manifest" in state and not isinstance(state["manifest"], Mapping): + if isinstance(payload, dict): + payload.clear() + error = _InvalidRawMountManifestError("sandbox manifest has an invalid shape") + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) + state.pop(CREDENTIALLESS_MOUNT_AUTHORITY_KEY, None) + manifest, redacted = sanitize_raw_manifest_mount_authority(state.get("manifest")) + if "manifest" in state: + state["manifest"] = manifest + if redacted or state.get(REDACTED_MOUNT_AUTHORITY_KEY) is True: + state[REDACTED_MOUNT_AUTHORITY_KEY] = True + redacted = True + return state, redacted + + +def _run_state_sandbox_envelope_is_valid(payload: object) -> bool: + if not isinstance(payload, Mapping): + return False + if "session_state" in payload and not isinstance(payload["session_state"], Mapping): + return False + sessions_by_agent = payload.get("sessions_by_agent") + if sessions_by_agent is None: + return True + if not isinstance(sessions_by_agent, Mapping): + return False + return all( + isinstance(entry, Mapping) + and ("session_state" not in entry or isinstance(entry["session_state"], Mapping)) + for entry in sessions_by_agent.values() + ) + + +def _sanitize_run_state_sandbox_mount_authority(payload: object) -> tuple[object, bool]: + """Sanitize only the documented sandbox resume-state envelope.""" + + if not _run_state_sandbox_envelope_is_valid(payload): + if isinstance(payload, dict | list): + payload.clear() + _raise_invalid_run_state_sandbox_envelope() + assert isinstance(payload, Mapping) + sandbox = copy.deepcopy(dict(payload)) + redacted = False + + if "session_state" in sandbox: + session_state, state_redacted = sanitize_raw_session_state_mount_authority( + sandbox["session_state"] + ) + sandbox["session_state"] = session_state + redacted = redacted or state_redacted + + sessions_by_agent = sandbox.get("sessions_by_agent") + if isinstance(sessions_by_agent, Mapping): + sanitized_sessions = copy.deepcopy(dict(sessions_by_agent)) + for key, entry in sanitized_sessions.items(): + assert isinstance(entry, Mapping) + entry_copy = copy.deepcopy(dict(entry)) + raw_state = entry_copy.get("session_state", entry_copy) + sanitized_state, entry_redacted = sanitize_raw_session_state_mount_authority(raw_state) + if "session_state" in entry_copy: + entry_copy["session_state"] = sanitized_state + sanitized_sessions[key] = entry_copy + else: + sanitized_sessions[key] = sanitized_state + redacted = redacted or entry_redacted + sandbox["sessions_by_agent"] = sanitized_sessions + return sandbox, redacted + + +def sanitize_run_state_sandbox_mount_authority(payload: object) -> tuple[object, bool]: + safe_error: ValueError | None = None + try: + return _sanitize_run_state_sandbox_mount_authority(payload) + except _InvalidRawMountManifestError as error: + safe_error = _redact_mount_state_validation_error( + error, + message="RunState sandbox resume state contains an invalid manifest", + ) + + if isinstance(payload, dict | list): + payload.clear() + payload = None + assert safe_error is not None + _raise_data_redacted_error(safe_error) + + +def _raise_invalid_run_state_sandbox_envelope() -> NoReturn: + error = ValueError("RunState sandbox resume state has an invalid envelope") + _mark_error_data_redacted(error) + _raise_data_redacted_error(error) diff --git a/src/agents/sandbox/entries/mounts/base.py b/src/agents/sandbox/entries/mounts/base.py index 9c8bcf1705..88a0a15012 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -4,9 +4,10 @@ import builtins import inspect import warnings -from collections.abc import Mapping +from collections.abc import Callable, Coroutine, Mapping +from functools import wraps from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, ParamSpec, TypeVar from pydantic import BaseModel, Field, SerializeAsAny, field_validator @@ -20,6 +21,32 @@ if TYPE_CHECKING: from ...session.base_sandbox_session import BaseSandboxSession +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +def _redact_mount_lifecycle_error( + function: Callable[_P, Coroutine[Any, Any, _T]], +) -> Callable[_P, Coroutine[Any, Any, _T]]: + """Load the mount error boundary lazily to avoid an entries/security import cycle.""" + + protected: Callable[_P, Coroutine[Any, Any, _T]] | None = None + + @wraps(function) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + nonlocal protected + if protected is None: + from ..._mount_security import redact_mount_error_data + + protected = redact_mount_error_data(function) + try: + return await protected(*args, **kwargs) + except BaseException: + del args, kwargs + raise + + return wrapper + class InContainerMountAdapter: """Default adapter for mounts materialized by commands inside the sandbox. @@ -227,6 +254,7 @@ class InContainerMountStrategy(MountStrategyBase): def validate_mount(self, mount: Mount) -> None: mount.in_container_adapter().validate(self) + @_redact_mount_lifecycle_error async def activate( self, mount: Mount, @@ -234,6 +262,9 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + from ..._mount_security import validate_mount_activation_credential_boundary + + validate_mount_activation_credential_boundary(mount, self) return await mount.in_container_adapter().activate(self, session, dest, base_dir) async def deactivate( @@ -253,12 +284,16 @@ async def teardown_for_snapshot( ) -> None: await mount.in_container_adapter().teardown_for_snapshot(self, session, path) + @_redact_mount_lifecycle_error async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + from ..._mount_security import validate_mount_activation_credential_boundary + + validate_mount_activation_credential_boundary(mount, self) await mount.in_container_adapter().restore_after_snapshot(self, session, path) def build_docker_volume_driver_config( @@ -409,6 +444,7 @@ def docker_volume_adapter(self) -> DockerVolumeMountAdapter: return DockerVolumeMountAdapter(self) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -421,6 +457,13 @@ async def apply( intentionally no-ops because the backend attaches them before the session starts. """ + from ..._mount_security import validate_mount_activation_credential_boundary + + validate_mount_activation_credential_boundary( + self, + self.mount_strategy, + provider_backend_id=session.state.type, + ) return await self.mount_strategy.activate(self, session, dest, base_dir) async def unmount( diff --git a/src/agents/sandbox/entries/mounts/patterns.py b/src/agents/sandbox/entries/mounts/patterns.py index 6aeeea974b..276ea23f2c 100644 --- a/src/agents/sandbox/entries/mounts/patterns.py +++ b/src/agents/sandbox/entries/mounts/patterns.py @@ -455,6 +455,8 @@ async def apply( await session.mkdir(path, parents=True) cmd: list[str] = ["mount-s3"] + if not (mountpoint_config.access_key_id and mountpoint_config.secret_access_key): + cmd.append("--no-sign-request") if mountpoint_config.read_only: cmd.append("--read-only") elif mountpoint_config.mount_type in {"s3_mount", "gcs_mount"}: diff --git a/src/agents/sandbox/entries/mounts/providers/azure_blob.py b/src/agents/sandbox/entries/mounts/providers/azure_blob.py index 7623c39958..e8d0352d04 100644 --- a/src/agents/sandbox/entries/mounts/providers/azure_blob.py +++ b/src/agents/sandbox/entries/mounts/providers/azure_blob.py @@ -97,7 +97,7 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: if self.account_key: lines.append(f"key = {self.account_key}") else: - lines.append("use_msi = true") + lines.append("use_msi = false") if self.identity_client_id: lines.append(f"msi_client_id = {self.identity_client_id}") return lines diff --git a/src/agents/sandbox/entries/mounts/providers/gcs.py b/src/agents/sandbox/entries/mounts/providers/gcs.py index 8e3838b3bc..5c6351d793 100644 --- a/src/agents/sandbox/entries/mounts/providers/gcs.py +++ b/src/agents/sandbox/entries/mounts/providers/gcs.py @@ -166,14 +166,9 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: lines.append(f"service_account_credentials = {self.service_account_credentials}") if self.access_token: lines.append(f"access_token = {self.access_token}") - if ( - self.service_account_file is None - and self.service_account_credentials is None - and self.access_token is None - ): - lines.append("env_auth = true") - else: - lines.append("env_auth = false") + if not (self.service_account_file or self.service_account_credentials or self.access_token): + lines.append("anonymous = true") + lines.append("env_auth = false") return lines def _s3_compatible_rclone_required_lines(self, remote_name: str) -> list[str]: diff --git a/src/agents/sandbox/entries/mounts/providers/r2.py b/src/agents/sandbox/entries/mounts/providers/r2.py index 33490eaf29..9e39cbf6ca 100644 --- a/src/agents/sandbox/entries/mounts/providers/r2.py +++ b/src/agents/sandbox/entries/mounts/providers/r2.py @@ -96,5 +96,5 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: lines.append(f"access_key_id = {self.access_key_id}") lines.append(f"secret_access_key = {self.secret_access_key}") else: - lines.append("env_auth = true") + lines.append("env_auth = false") return lines diff --git a/src/agents/sandbox/entries/mounts/providers/s3.py b/src/agents/sandbox/entries/mounts/providers/s3.py index e44d95ba2b..89a314d82b 100644 --- a/src/agents/sandbox/entries/mounts/providers/s3.py +++ b/src/agents/sandbox/entries/mounts/providers/s3.py @@ -129,5 +129,5 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: if self.session_token: lines.append(f"session_token = {self.session_token}") else: - lines.append("env_auth = true") + lines.append("env_auth = false") return lines diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index 4c958fc90f..07ac13afb7 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -9,6 +9,7 @@ from typing import Any, Generic, cast from ..agent import Agent +from ..exceptions import _raise_data_redacted_error from ..run_config import SandboxArchiveLimits, SandboxConcurrencyLimits, SandboxRunConfig from ..run_context import TContext from ..run_state import ( @@ -17,6 +18,13 @@ _build_agent_identity_keys_by_id, ) from ..tracing import custom_span, get_current_trace +from ._mount_security import ( + _manifest_has_configured_mount_authority, + _replace_mount_operation_error, + _validate_manifest_mount_provenance, + redact_mount_error_data, + validate_manifest_mount_credential_boundaries, +) from .capabilities import Capability from .entries import BaseEntry, Dir, Mount, resolve_workspace_path from .manifest import Manifest @@ -67,6 +75,7 @@ async def ensure_started(self) -> None: await self._session.start() self._started = True + @redact_mount_error_data async def cleanup(self) -> None: if not self._owns_session: return @@ -80,11 +89,12 @@ async def cleanup(self) -> None: await self._session.run_pre_stop_hooks() except BaseException as exc: # pragma: no cover cleanup_error = exc - try: - await self._session.stop() - except BaseException as exc: # pragma: no cover - if cleanup_error is None: - cleanup_error = exc + if cleanup_error is None and not self._session._pre_stop_hooks_failed: + try: + await self._session.stop() + except BaseException as exc: # pragma: no cover + if cleanup_error is None: + cleanup_error = exc try: await self._session.shutdown() except BaseException as exc: # pragma: no cover @@ -194,6 +204,7 @@ def acquire_agent(self, agent: SandboxAgent[TContext]) -> None: self._acquired_agents[agent_id] = agent self._ensure_resume_key(agent) + @redact_mount_error_data async def ensure_session( self, *, @@ -232,7 +243,6 @@ def serialize_resume_state(self) -> dict[str, object] | None: resources = self._resources_by_agent.get(self._current_agent_id) if resources is None: return existing_payload - client = self._resolve_client() current_agent = self._acquired_agents.get(self._current_agent_id) if current_agent is None: @@ -293,15 +303,11 @@ async def _create_resources( concurrency_limits=concurrency_limits, archive_limits=archive_limits, ) - running = await sandbox_config.session.running() - manifest_update = self._process_live_session_manifest( + manifest_update = await self._process_live_session_manifest( agent=agent, capabilities=capabilities, session=sandbox_config.session, - running=running, ) - if manifest_update.processed_manifest is not None: - await sandbox_config.session._validate_manifest_application() if manifest_update.entries_to_apply: await sandbox_config.session._apply_entry_batch( manifest_update.entries_to_apply, @@ -339,6 +345,7 @@ async def _create_resources( capabilities=capabilities, session_state=explicit_state, trusted_manifest=self._resolve_trusted_resume_manifest(agent=agent), + provider_backend_id=client.backend_id, ) span_cm = ( custom_span( @@ -545,22 +552,36 @@ def _process_manifest( ) -> Manifest | None: if manifest is None: return None + _validate_manifest_mount_provenance(manifest) processed_manifest = SandboxRuntimeSessionManager._manifest_with_run_as_user( manifest.model_copy(deep=True), run_as_user, ) for capability in capabilities: - processed_manifest = capability.process_manifest(processed_manifest) + safe_error: RuntimeError | None = None + try: + processed_manifest = capability.process_manifest(processed_manifest) + except Exception as error: + if not _manifest_has_configured_mount_authority(processed_manifest): + raise + safe_error = _replace_mount_operation_error(error) + + if safe_error is not None: + capabilities = [] + capability = cast(Any, None) + manifest = None + processed_manifest = cast(Any, None) + run_as_user = None + _raise_data_redacted_error(safe_error) return processed_manifest @classmethod - def _process_live_session_manifest( + async def _process_live_session_manifest( cls, *, agent: SandboxAgent[TContext], capabilities: list[Capability], session: BaseSandboxSession, - running: bool, ) -> _LiveSessionManifestUpdate: current_manifest = session.state.manifest processed_manifest = cls._process_manifest( @@ -569,12 +590,30 @@ def _process_live_session_manifest( run_as_user=cls._agent_run_as_user(agent), ) if processed_manifest is None or processed_manifest == current_manifest: + validate_manifest_mount_credential_boundaries( + current_manifest, + provider_backend_id=session.state.type, + ) + running = await session.running() + await session._validate_manifest_application( + manifest=current_manifest, + session_running=running, + ) return _LiveSessionManifestUpdate(processed_manifest=None, entries_to_apply=[]) cls._validate_live_session_host_path_grants( current_manifest=current_manifest, processed_manifest=processed_manifest, ) + validate_manifest_mount_credential_boundaries( + processed_manifest, + provider_backend_id=session.state.type, + ) + running = await session.running() + await session._validate_manifest_application( + manifest=processed_manifest, + session_running=running, + ) entries_to_apply: list[tuple[Path, BaseEntry]] = [] if running: @@ -775,6 +814,7 @@ def _process_resumed_state_manifest( capabilities: list[Capability], session_state: SandboxSessionState, trusted_manifest: Manifest | None, + provider_backend_id: str, ) -> SandboxSessionState: resume_manifest = session_state.manifest if session_state.path_grants_require_rebind and trusted_manifest is not None: @@ -793,7 +833,18 @@ def _process_resumed_state_manifest( if processed_manifest is None: return session_state processed_state = session_state.model_copy(update={"manifest": processed_manifest}) - return processed_state.rebind_persisted_path_grants(processed_manifest) + processed_state = processed_state.rebind_persisted_path_grants(processed_manifest) + if not processed_state.mount_authority_redacted: + return processed_state + processed_trusted_manifest = cls._process_manifest( + capabilities, + trusted_manifest, + run_as_user=cls._agent_run_as_user(agent), + ) + return processed_state.rebind_persisted_mount_authority( + processed_trusted_manifest, + provider_backend_id=provider_backend_id, + ) @staticmethod def _agent_run_as_user(agent: SandboxAgent[Any]) -> User | None: diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 7478372aae..3a8411b6f6 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -12,7 +12,7 @@ import time import uuid from collections import deque -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from pathlib import Path @@ -26,6 +26,10 @@ from docker.types import DriverConfig, Mount as DockerSDKMount # type: ignore[import-untyped] from docker.utils import parse_repository_tag +from .._mount_security import ( + _manifest_has_configured_mount_authority, + redact_mount_error_data, +) from ..entries import ( Mount, resolve_workspace_path, @@ -170,6 +174,20 @@ class DockerSandboxSessionState(SandboxSessionState): image: str container_id: str + def _sanitize_persisted_provider_identity( + self, + data: dict[str, Any], + *, + mount_authority_redacted: bool, + ) -> None: + if mount_authority_redacted: + data["container_id"] = "" + data["session_id"] = uuid.uuid5( + uuid.NAMESPACE_URL, + f"openai-agents:mount-authority-redacted:{self.session_id}", + ) + data["workspace_root_ready"] = False + class DockerSandboxClientOptions(BaseSandboxClientOptions): type: Literal["docker"] = "docker" @@ -1302,6 +1320,7 @@ async def exists(self) -> bool: except docker.errors.NotFound: return False + @redact_mount_error_data @retry_async( retry_if=lambda exc, self: exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES) ) @@ -1333,6 +1352,7 @@ async def persist_workspace(self) -> io.IOBase: retryable=retryable, ) from e + @redact_mount_error_data async def hydrate_workspace(self, data: io.IOBase) -> None: root = self._workspace_root_path() error_root = posix_path_for_error(root) @@ -1462,6 +1482,7 @@ def __init__( ) self._dependencies = dependencies + @redact_mount_error_data async def create( self, *, @@ -1472,36 +1493,63 @@ async def create( image = options.image session_id = uuid.uuid4() manifest = manifest if manifest is not None else Manifest() + self._validate_manifest_for_create(manifest) _validate_docker_path_grants(manifest) + volume_names = _docker_volume_names_for_manifest(manifest, session_id=session_id) + container: Container | None = None + try: + container = await self._create_container( + image, + manifest=manifest, + exposed_ports=options.exposed_ports, + session_id=session_id, + ) + container.start() + container_id = container.id + assert container_id is not None + snapshot_id = str(session_id) + snapshot_instance = resolve_snapshot(snapshot, snapshot_id) + state = DockerSandboxSessionState( + session_id=session_id, + manifest=manifest, + image=image, + snapshot=snapshot_instance, + container_id=container_id, + exposed_ports=options.exposed_ports, + ) + inner = DockerSandboxSession( + docker_client=self.docker_client, + container=container, + state=state, + ) + return self._wrap_session(inner, instrumentation=self._instrumentation) + except BaseException: + self._cleanup_failed_create_resources( + container=container, + volume_names=volume_names, + ) + raise - container = await self._create_container( - image, - manifest=manifest, - exposed_ports=options.exposed_ports, - session_id=session_id, - ) - container.start() - - container_id = container.id - assert container_id is not None - snapshot_id = str(session_id) - snapshot_instance = resolve_snapshot(snapshot, snapshot_id) - state = DockerSandboxSessionState( - session_id=session_id, - manifest=manifest, - image=image, - snapshot=snapshot_instance, - container_id=container_id, - exposed_ports=options.exposed_ports, - ) + def _cleanup_failed_create_resources( + self, + *, + container: Container | None, + volume_names: Iterable[str], + ) -> None: + """Best-effort cleanup when Docker resource acquisition does not return a session.""" - inner = DockerSandboxSession( - docker_client=self.docker_client, - container=container, - state=state, - ) - return self._wrap_session(inner, instrumentation=self._instrumentation) + if container is not None: + try: + container.remove(force=True) + except Exception: + pass + for volume_name in volume_names: + try: + self.docker_client.volumes.get(volume_name).remove() + except Exception: + pass + @redact_mount_error_data async def delete(self, session: SandboxSession) -> SandboxSession: inner = session._inner if not isinstance(inner, DockerSandboxSession): @@ -1510,29 +1558,50 @@ async def delete(self, session: SandboxSession) -> SandboxSession: inner.state.manifest, session_id=inner.state.session_id, ) + cleanup_error: BaseException | None = None + try: + await inner.shutdown() + except BaseException as exc: + cleanup_error = exc + try: container = self.docker_client.containers.get(inner.state.container_id) except docker.errors.NotFound: container = None + except BaseException as exc: + container = None + if cleanup_error is None: + cleanup_error = exc else: - # Ensure teardown happens before removal. - try: - await inner.shutdown() - except Exception: - pass try: container.remove() except docker.errors.NotFound: pass + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc for volume_name in volume_names: try: volume = self.docker_client.volumes.get(volume_name) except docker.errors.NotFound: continue - volume.remove() + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + continue + try: + volume.remove() + except docker.errors.NotFound: + continue + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + if cleanup_error is not None: + raise cleanup_error from None return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, @@ -1541,29 +1610,61 @@ async def resume( raise TypeError("DockerSandboxClient.resume expects a DockerSandboxSessionState") state.assert_path_grants_rebound() _validate_docker_path_grants(state.manifest) - container = self.get_container(state.container_id) + configured_authority = _manifest_has_configured_mount_authority(state.manifest) + requires_fresh_resource = state.mount_authority_rebound or configured_authority + container = None if requires_fresh_resource else self.get_container(state.container_id) reused_existing_container = container is not None if container is not None: _assert_existing_container_path_grants_match(container, state.manifest) - if container is None: - container = await self._create_container( - state.image, - manifest=state.manifest, - exposed_ports=state.exposed_ports, - session_id=state.session_id, + owns_replacement = container is None + replacement_session_id = ( + uuid.uuid4() + if owns_replacement and (requires_fresh_resource or configured_authority) + else state.session_id + ) + replacement_volume_names = ( + _docker_volume_names_for_manifest( + state.manifest, + session_id=replacement_session_id, ) - container_id = container.id - assert container_id is not None - state.container_id = container_id - state.workspace_root_ready = False - - # Use the existing container (or the one we just created). - inner = DockerSandboxSession( - container=container, docker_client=self.docker_client, state=state + if owns_replacement + else () ) - inner._resume_workspace_probe_pending = True - inner._set_start_state_preserved(reused_existing_container) - return self._wrap_session(inner, instrumentation=self._instrumentation) + replacement_volumes_prepared = False + original_container_id = state.container_id + original_session_id = state.session_id + original_workspace_root_ready = state.workspace_root_ready + try: + if container is None: + replacement_volumes_prepared = True + state.session_id = replacement_session_id + container = await self._create_container( + state.image, + manifest=state.manifest, + exposed_ports=state.exposed_ports, + session_id=replacement_session_id, + ) + container_id = container.id + assert container_id is not None + state.container_id = container_id + state.workspace_root_ready = False + + inner = DockerSandboxSession( + container=container, docker_client=self.docker_client, state=state + ) + inner._resume_workspace_probe_pending = True + inner._set_start_state_preserved(reused_existing_container) + return self._wrap_session(inner, instrumentation=self._instrumentation) + except BaseException: + if owns_replacement: + state.container_id = original_container_id + state.session_id = original_session_id + state.workspace_root_ready = original_workspace_root_ready + self._cleanup_failed_create_resources( + container=container, + volume_names=(replacement_volume_names if replacement_volumes_prepared else ()), + ) + raise def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, DockerSandboxSessionState) diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 45df5ad799..4d8595b15e 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -29,6 +29,7 @@ from typing import Literal, cast from ...logger import log_tool_action_warning +from .._mount_security import redact_mount_error_data from ..errors import ( ExecNonZeroError, ExecTimeoutError, @@ -151,6 +152,21 @@ def __init__(self, *, state: UnixLocalSandboxSessionState) -> None: def from_state(cls, state: UnixLocalSandboxSessionState) -> "UnixLocalSandboxSession": return cls(state=state) + async def _validate_manifest_application( + self, + *, + only_ephemeral: bool = False, + manifest: Manifest | None = None, + session_running: bool | None = None, + ) -> None: + _ = (only_ephemeral, session_running) + from .._mount_security import validate_manifest_mount_credential_boundaries + + validate_manifest_mount_credential_boundaries( + manifest or self.state.manifest, + provider_backend_id="unix_local", + ) + async def _prepare_backend_workspace(self) -> None: workspace = Path(self.state.manifest.root) try: @@ -188,12 +204,6 @@ async def _apply_manifest( provision_accounts=provision_accounts, ) - async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult: - return await self._apply_manifest( - only_ephemeral=only_ephemeral, - provision_accounts=not only_ephemeral, - ) - async def provision_manifest_accounts(self) -> None: if self.state.manifest.users or self.state.manifest.groups: raise ValueError( @@ -1095,6 +1105,7 @@ def __init__( ) self._dependencies = dependencies + @redact_mount_error_data async def create( self, *, @@ -1103,18 +1114,16 @@ async def create( options: UnixLocalSandboxClientOptions | None = None, ) -> SandboxSession: resolved_options = options if options is not None else UnixLocalSandboxClientOptions() - if manifest is not None: - _assert_unix_local_host_path_grants_unsupported(manifest) + manifest = manifest if manifest is not None else Manifest() + _assert_unix_local_host_path_grants_unsupported(manifest) + self._validate_manifest_for_create(manifest) # For local execution, runner-created sessions should always get an isolated temp root # unless the caller explicitly chose a custom host path. workspace_root_owned = False - if manifest is None or manifest.root == _DEFAULT_MANIFEST_ROOT: + if manifest.root == _DEFAULT_MANIFEST_ROOT: workspace_dir = tempfile.mkdtemp(prefix=_DEFAULT_WORKSPACE_PREFIX) workspace_root_owned = True - if manifest is None: - manifest = Manifest(root=workspace_dir) - else: - manifest = manifest.model_copy(update={"root": workspace_dir}, deep=True) + manifest = manifest.model_copy(update={"root": workspace_dir}, deep=True) session_id = uuid.uuid4() snapshot_id = str(session_id) @@ -1158,6 +1167,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, diff --git a/src/agents/sandbox/session/base_sandbox_session.py b/src/agents/sandbox/session/base_sandbox_session.py index e497c610b9..d377bea9ef 100644 --- a/src/agents/sandbox/session/base_sandbox_session.py +++ b/src/agents/sandbox/session/base_sandbox_session.py @@ -1,4 +1,5 @@ import abc +import asyncio import io import shlex from collections.abc import Awaitable, Callable, Mapping, Sequence @@ -14,6 +15,7 @@ SandboxArchiveLimits, SandboxConcurrencyLimits, ) +from .._mount_security import redact_mount_error_data, validate_manifest_mount_credential_boundaries from ..apply_patch import PatchFormat, WorkspaceEditor from ..entries import BaseEntry from ..errors import ( @@ -201,6 +203,9 @@ class BaseSandboxSession(abc.ABC): _runtime_persist_workspace_skip_relpaths: set[Path] | None = None _pre_stop_hooks: list[Callable[[], Awaitable[None]]] | None = None _pre_stop_hooks_ran: bool = False + _pre_stop_hooks_failed: bool = False + _pre_stop_hooks_lock: asyncio.Lock | None = None + _aclose_lock: asyncio.Lock | None = None _runtime_helpers_installed: set[PurePath] | None = None _runtime_helper_cache_key: object = _RUNTIME_HELPER_CACHE_KEY_UNSET _workspace_path_policy_cache: ( @@ -219,7 +224,19 @@ class BaseSandboxSession(abc.ABC): _max_local_dir_file_concurrency: int | None = DEFAULT_MAX_LOCAL_DIR_FILE_CONCURRENCY _archive_limits: SandboxArchiveLimits | None = None + def _runtime_has_protected_mount_authority(self) -> bool: + """Return whether SDK-owned runtime state contains live mount authority.""" + + return False + + @redact_mount_error_data async def start(self) -> None: + from .._mount_security import validate_manifest_mount_credential_boundaries + + validate_manifest_mount_credential_boundaries( + self.state.manifest, + provider_backend_id=self.state.type, + ) try: await self._ensure_backend_started() self._start_workspace_root_ready = self.state.workspace_root_ready @@ -304,6 +321,10 @@ def _system_state_preserved_on_start(self) -> bool: async def _start_workspace(self) -> None: """Restore snapshot or apply manifest state after backend startup is complete.""" + validate_manifest_mount_credential_boundaries( + self.state.manifest, + provider_backend_id=self.state.type, + ) if await self.state.snapshot.restorable(dependencies=self.dependencies): can_reuse_workspace = await self._can_reuse_restorable_snapshot_workspace() if can_reuse_workspace: @@ -313,10 +334,7 @@ async def _start_workspace(self) -> None: else: # Fresh workspaces and drifted preserved workspaces both need the durable snapshot # restored before ephemeral state is rebuilt. - await self._restore_snapshot_into_workspace_on_resume() - if self.should_provision_manifest_accounts_on_resume(): - await self.provision_manifest_accounts() - await self._reapply_ephemeral_manifest_on_resume() + await self._restore_snapshot_and_reapply_ephemeral_on_resume() elif self._can_reuse_preserved_workspace_on_resume(): # There is no durable snapshot to restore, but a reconnected backend may still need # ephemeral mounts/files refreshed without reapplying the full manifest. @@ -327,6 +345,12 @@ async def _start_workspace(self) -> None: provision_accounts=self.should_provision_manifest_accounts_on_resume() ) + async def _restore_snapshot_and_reapply_ephemeral_on_resume(self) -> None: + await self._restore_snapshot_into_workspace_on_resume() + if self.should_provision_manifest_accounts_on_resume(): + await self.provision_manifest_accounts() + await self._reapply_ephemeral_manifest_on_resume() + async def _can_reuse_restorable_snapshot_workspace(self) -> bool: """Return whether a restorable snapshot can be skipped for this start.""" @@ -358,6 +382,7 @@ def _wrap_start_error(self, error: Exception) -> Exception: return error + @redact_mount_error_data async def stop(self) -> None: """ Persist/snapshot the workspace. @@ -366,6 +391,10 @@ async def stop(self) -> None: sandbox resources (Docker containers, remote sessions, etc.) should implement `shutdown()` instead. """ + validate_manifest_mount_credential_boundaries( + self.state.manifest, + provider_backend_id=self.state.type, + ) try: try: await self._before_stop() @@ -406,6 +435,7 @@ def supports_docker_volume_mounts(self) -> bool: def supports_pty(self) -> bool: return False + @redact_mount_error_data async def shutdown(self) -> None: """ Tear down sandbox resources (best-effort). @@ -431,10 +461,16 @@ async def _after_shutdown(self) -> None: return + async def _terminate_ambiguous_mount_transition(self) -> None: + """Make a session unusable after a mount transition has an unknown outcome.""" + + await self.shutdown() + async def __aenter__(self) -> Self: await self.start() return self + @redact_mount_error_data async def aclose(self) -> None: """Run the session cleanup lifecycle outside of ``async with``. @@ -444,12 +480,35 @@ async def aclose(self) -> None: ``delete()`` separately for backend-specific deletion such as removing a Docker container or deleting a temporary host workspace. """ + + lock = self._aclose_lock + if lock is None: + lock = asyncio.Lock() + self._aclose_lock = lock + async with lock: + await self._aclose_impl() + + async def _aclose_impl(self) -> None: + cleanup_error: BaseException | None = None try: await self.run_pre_stop_hooks() - await self.stop() + except BaseException as exc: + cleanup_error = exc + try: + if cleanup_error is None and not self._pre_stop_hooks_failed: + await self.stop() await self.shutdown() + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc finally: - await self._aclose_dependencies() + try: + await self._aclose_dependencies() + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + if cleanup_error is not None: + raise cleanup_error async def __aexit__( self, @@ -484,22 +543,29 @@ def register_pre_stop_hook(self, hook: Callable[[], Awaitable[None]]) -> None: hooks.append(hook) self._pre_stop_hooks_ran = False + @redact_mount_error_data async def run_pre_stop_hooks(self) -> None: """Run registered pre-stop hooks once before workspace persistence.""" - hooks = self._pre_stop_hooks - if hooks is None or self._pre_stop_hooks_ran: - return - self._pre_stop_hooks_ran = True - cleanup_error: BaseException | None = None - for hook in hooks: - try: - await hook() - except BaseException as exc: - if cleanup_error is None: - cleanup_error = exc - if cleanup_error is not None: - raise cleanup_error + lock = self._pre_stop_hooks_lock + if lock is None: + lock = asyncio.Lock() + self._pre_stop_hooks_lock = lock + async with lock: + hooks = self._pre_stop_hooks + if hooks is None or self._pre_stop_hooks_ran: + return + self._pre_stop_hooks_ran = True + cleanup_error: BaseException | None = None + for hook in hooks: + try: + await hook() + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + if cleanup_error is not None: + self._pre_stop_hooks_failed = True + raise cleanup_error async def _run_pre_stop_hooks(self) -> None: await self.run_pre_stop_hooks() @@ -571,6 +637,7 @@ def _persist_workspace_skip_relpaths(self) -> set[Path]: skip_paths.update(self._runtime_persist_workspace_skip_relpaths) return skip_paths + @redact_mount_error_data async def exec( self, *command: str | Path, @@ -594,6 +661,7 @@ async def exec( sanitized_command = self._prepare_exec_command(*command, shell=shell, user=user) return await self._exec_internal(*sanitized_command, timeout=timeout) + @redact_mount_error_data async def resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: self._assert_exposed_port_configured(port) return await self._resolve_exposed_port(port) @@ -1200,9 +1268,22 @@ async def _apply_manifest( provision_accounts=provision_accounts, ) - async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: - _ = only_ephemeral + async def _validate_manifest_application( + self, + *, + only_ephemeral: bool = False, + manifest: Manifest | None = None, + session_running: bool | None = None, + ) -> None: + _ = (only_ephemeral, session_running) + from .._mount_security import validate_manifest_mount_credential_boundaries + + validate_manifest_mount_credential_boundaries( + manifest or self.state.manifest, + provider_backend_id=self.state.type, + ) + @redact_mount_error_data async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult: await self._validate_manifest_application(only_ephemeral=only_ephemeral) return await self._apply_manifest( diff --git a/src/agents/sandbox/session/mount_lifecycle.py b/src/agents/sandbox/session/mount_lifecycle.py index bf32d82a17..1db9522c19 100644 --- a/src/agents/sandbox/session/mount_lifecycle.py +++ b/src/agents/sandbox/session/mount_lifecycle.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio from collections.abc import Awaitable, Callable +from contextvars import ContextVar from pathlib import Path from typing import TYPE_CHECKING, TypeAlias, TypeVar, cast @@ -8,17 +10,26 @@ WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceIOError, + WorkspaceStartError, ) if TYPE_CHECKING: from ..entries import Mount from .base_sandbox_session import BaseSandboxSession -ArchiveError: TypeAlias = WorkspaceArchiveReadError | WorkspaceArchiveWriteError -ArchiveErrorClass: TypeAlias = type[WorkspaceArchiveReadError] | type[WorkspaceArchiveWriteError] +ArchiveError: TypeAlias = ( + WorkspaceArchiveReadError | WorkspaceArchiveWriteError | WorkspaceStartError +) +ArchiveErrorClass: TypeAlias = ( + type[WorkspaceArchiveReadError] | type[WorkspaceArchiveWriteError] | type[WorkspaceStartError] +) _ResultT = TypeVar("_ResultT") _MISSING = object() +_MOUNT_TRANSITION_OWNER: ContextVar[tuple[object, asyncio.Task[object]] | None] = ContextVar( + "sandbox_mount_transition_owner", + default=None, +) async def with_ephemeral_mounts_removed( @@ -28,36 +39,68 @@ async def with_ephemeral_mounts_removed( error_path: Path, error_cls: ArchiveErrorClass, operation_error_context_key: str | None, + restore_on_success: bool = True, ) -> _ResultT: detached_mounts: list[tuple[Mount, Path]] = [] detach_error: ArchiveError | None = None + detach_transition_ambiguous = False + caller_cancelled = False for mount_entry, mount_path in session.state.manifest.ephemeral_mount_targets(): - try: - await mount_entry.mount_strategy.teardown_for_snapshot(mount_entry, session, mount_path) - except Exception as exc: - detach_error = error_cls(path=error_path, cause=exc) + transition_error, transition_cancelled = await _settle_mount_transition( + session, + mount_entry.mount_strategy.teardown_for_snapshot(mount_entry, session, mount_path), + ) + caller_cancelled = caller_cancelled or transition_cancelled + if transition_error is not None: + detach_error = _mount_transition_error( + error_cls, + error_path=error_path, + transition_error=transition_error, + reason="mount_teardown_cancelled", + ) + detach_transition_ambiguous = True break detached_mounts.append((mount_entry, mount_path)) + if caller_cancelled: + break - operation_error: ArchiveError | None = None + operation_error: BaseException | None = None operation_result: object = _MISSING - if detach_error is None: + if detach_error is None and not caller_cancelled: try: operation_result = await operation() + except asyncio.CancelledError: + caller_cancelled = True except WorkspaceIOError as exc: - if not isinstance(exc, error_cls): - raise - operation_error = cast(ArchiveError, exc) + operation_error = exc + except BaseException as exc: + operation_error = exc - restore_error = await restore_detached_mounts( - session, - detached_mounts, - error_path=error_path, - error_cls=error_cls, + restore_error: ArchiveError | None = None + should_restore = ( + (operation_result is not _MISSING and restore_on_success is True) + or detach_error is not None + or operation_error is not None + or caller_cancelled ) + if should_restore: + restore_error, restore_cancelled = await _restore_detached_mounts_settled( + session, + detached_mounts, + error_path=error_path, + error_cls=error_cls, + ) + caller_cancelled = caller_cancelled or restore_cancelled + if detach_transition_ambiguous and restore_error is None: + terminal_error = await _terminate_ambiguous_mount_session(session) + if terminal_error is not None and detach_error is not None: + detach_error.context["terminal_cleanup_failed"] = True if restore_error is not None: - if operation_error is not None and operation_error_context_key is not None: + if ( + isinstance(operation_error, WorkspaceIOError) + and operation_error_context_key is not None + ): restore_error.context[operation_error_context_key] = { "message": operation_error.message } @@ -66,6 +109,8 @@ async def with_ephemeral_mounts_removed( raise detach_error if operation_error is not None: raise operation_error + if caller_cancelled: + raise asyncio.CancelledError() from None assert operation_result is not _MISSING return cast(_ResultT, operation_result) @@ -78,14 +123,41 @@ async def restore_detached_mounts( error_path: Path, error_cls: ArchiveErrorClass, ) -> ArchiveError | None: + restore_error, caller_cancelled = await _restore_detached_mounts_settled( + session, + detached_mounts, + error_path=error_path, + error_cls=error_cls, + ) + if restore_error is not None: + return restore_error + if caller_cancelled: + raise asyncio.CancelledError() from None + return None + + +async def _restore_detached_mounts_settled( + session: BaseSandboxSession, + detached_mounts: list[tuple[Mount, Path]], + *, + error_path: Path, + error_cls: ArchiveErrorClass, +) -> tuple[ArchiveError | None, bool]: restore_error: ArchiveError | None = None + caller_cancelled = False for mount_entry, mount_path in reversed(detached_mounts): - try: - await mount_entry.mount_strategy.restore_after_snapshot( - mount_entry, session, mount_path + transition_error, transition_cancelled = await _settle_mount_transition( + session, + mount_entry.mount_strategy.restore_after_snapshot(mount_entry, session, mount_path), + ) + caller_cancelled = caller_cancelled or transition_cancelled + if transition_error is not None: + current_error = _mount_transition_error( + error_cls, + error_path=error_path, + transition_error=transition_error, + reason="mount_restore_cancelled", ) - except Exception as exc: - current_error = error_cls(path=error_path, cause=exc) if restore_error is None: restore_error = current_error else: @@ -94,7 +166,71 @@ async def restore_detached_mounts( ) assert isinstance(additional_errors, list) additional_errors.append(workspace_archive_error_summary(current_error)) - return restore_error + if restore_error is not None: + terminal_error = await _terminate_ambiguous_mount_session(session) + if terminal_error is not None: + restore_error.context["terminal_cleanup_failed"] = True + return restore_error, caller_cancelled + + +async def _settle_mount_transition( + session: BaseSandboxSession, + operation: Awaitable[None], +) -> tuple[BaseException | None, bool]: + async def run_registered_transition() -> None: + current_task = asyncio.current_task() + assert current_task is not None + owner_token = _MOUNT_TRANSITION_OWNER.set( + (session, cast(asyncio.Task[object], current_task)) + ) + try: + await operation + finally: + _MOUNT_TRANSITION_OWNER.reset(owner_token) + + task = asyncio.create_task(run_registered_transition()) + caller_cancelled = False + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + if not task.cancelled(): + caller_cancelled = True + except Exception: + break + try: + task.result() + except BaseException as exc: + return exc, caller_cancelled + return None, caller_cancelled + + +def current_task_owns_mount_transition(session: BaseSandboxSession) -> bool: + owner = _MOUNT_TRANSITION_OWNER.get() + current_task = asyncio.current_task() + return owner is not None and owner[0] is session and owner[1] is current_task + + +async def _terminate_ambiguous_mount_session( + session: BaseSandboxSession, +) -> BaseException | None: + terminal_error, _caller_cancelled = await _settle_mount_transition( + session, + session._terminate_ambiguous_mount_transition(), + ) + return terminal_error + + +def _mount_transition_error( + error_cls: ArchiveErrorClass, + *, + error_path: Path, + transition_error: BaseException, + reason: str, +) -> ArchiveError: + if isinstance(transition_error, asyncio.CancelledError): + return error_cls(path=error_path, context={"reason": reason}) + return error_cls(path=error_path, cause=transition_error) def workspace_archive_error_summary(error: ArchiveError) -> dict[str, str]: diff --git a/src/agents/sandbox/session/sandbox_client.py b/src/agents/sandbox/session/sandbox_client.py index fe92fae55e..177c77e0c4 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -5,6 +5,9 @@ from pydantic import BaseModel, ConfigDict, model_serializer +from ...exceptions import _raise_data_redacted_error +from .._mount_security import redact_mount_error_data_sync +from ..errors import MountConfigError from ..manifest import Manifest from ..snapshot import SnapshotBase, SnapshotSpec from .base_sandbox_session import BaseSandboxSession @@ -126,6 +129,23 @@ def _wrap_session( dependencies=self._resolve_dependencies(), ) + def _validate_manifest_for_create( + self, + manifest: Manifest, + *, + allowed_in_container_credential_strategy_types: frozenset[str] = frozenset(), + ) -> Manifest: + from .._mount_security import validate_manifest_mount_credential_boundaries + + validate_manifest_mount_credential_boundaries( + manifest, + allowed_in_container_credential_strategy_types=( + allowed_in_container_credential_strategy_types + ), + provider_backend_id=self.backend_id, + ) + return manifest + @abc.abstractmethod async def create( self, @@ -173,8 +193,33 @@ async def resume( `session=` when you want to reuse an already-running sandbox session. """ + @redact_mount_error_data_sync def serialize_session_state(self, state: SandboxSessionState) -> dict[str, object]: """Serialize backend-specific sandbox state into a JSON-compatible payload.""" + from ...exceptions import _raise_data_redacted_error + from .._mount_security import ( + _manifest_has_configured_mount_authority, + _manifest_mount_provenance_error, + _mark_mount_validation_error, + _redact_mount_serialization_error, + ) + + provenance_error = _manifest_mount_provenance_error(state.manifest) + if provenance_error is not None: + _mark_mount_validation_error(provenance_error) + state = cast(Any, None) + _raise_data_redacted_error(provenance_error) + + try: + return self._serialize_session_state(state) + except MountConfigError: + raise + except Exception as error: + if not _manifest_has_configured_mount_authority(state.manifest): + raise + raise _redact_mount_serialization_error(error) from None + + def _serialize_session_state(self, state: SandboxSessionState) -> dict[str, object]: redacted_paths = set(state.path_grants_require_rebind) persistent_grants = [] for grant in state.manifest.extra_path_grants: @@ -197,7 +242,29 @@ def _deserialize_session_state_payload( payload: dict[str, object], state_class: type[SandboxSessionState], ) -> SandboxSessionState: - state = state_class.model_validate(payload) + from .._mount_security import ( + _redact_mount_state_validation_error, + sanitize_raw_session_state_mount_authority, + ) + + safe_error: ValueError | None = None + try: + sanitized, _redacted = sanitize_raw_session_state_mount_authority(payload) + if isinstance(sanitized, dict): + payload.clear() + payload.update(sanitized) + state = state_class.model_validate(payload) + except Exception as error: + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state payload is invalid", + ) + + if safe_error is not None: + payload.clear() + sanitized = cast(Any, None) + state_class = cast(Any, None) + _raise_data_redacted_error(safe_error) return SandboxSessionState._mark_persisted_path_grants(state, payload=payload) @abc.abstractmethod diff --git a/src/agents/sandbox/session/sandbox_session.py b/src/agents/sandbox/session/sandbox_session.py index 4b2ba109df..923f025857 100644 --- a/src/agents/sandbox/session/sandbox_session.py +++ b/src/agents/sandbox/session/sandbox_session.py @@ -12,8 +12,13 @@ from ...run_config import SandboxArchiveLimits, SandboxConcurrencyLimits from ...tracing import Span, custom_span, get_current_trace +from .._mount_security import ( + redact_mount_error_data, + validate_manifest_mount_credential_boundaries, +) from ..errors import OpName, SandboxError from ..files import FileEntry +from ..manifest import Manifest from ..materialization import MaterializationResult from ..types import ExecResult, ExposedPortEndpoint, User from .base_sandbox_session import BaseSandboxSession @@ -256,6 +261,9 @@ def state(self) -> SandboxSessionState: def state(self, value: SandboxSessionState) -> None: # pragma: no cover self._inner.state = value + def _runtime_has_protected_mount_authority(self) -> bool: + return self._inner._runtime_has_protected_mount_authority() + @property def dependencies(self) -> Dependencies: return self._inner.dependencies @@ -283,9 +291,9 @@ def register_persist_workspace_skip_path(self, path: Path | str) -> Path: def supports_pty(self) -> bool: return self._inner.supports_pty() - async def aclose(self) -> None: + async def _aclose_impl(self) -> None: try: - await super().aclose() + await super()._aclose_impl() finally: await self._instrumentation.flush() @@ -520,15 +528,27 @@ async def start(self) -> None: await self._inner.start() @instrumented_op("stop") + @redact_mount_error_data async def stop(self) -> None: await self._inner.stop() @instrumented_op("shutdown") + @redact_mount_error_data async def shutdown(self) -> None: await self._inner.shutdown() - async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: - await self._inner._validate_manifest_application(only_ephemeral=only_ephemeral) + async def _validate_manifest_application( + self, + *, + only_ephemeral: bool = False, + manifest: Manifest | None = None, + session_running: bool | None = None, + ) -> None: + await self._inner._validate_manifest_application( + only_ephemeral=only_ephemeral, + manifest=manifest, + session_running=session_running, + ) async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult: return await super().apply_manifest(only_ephemeral=only_ephemeral) @@ -679,14 +699,24 @@ async def resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: data=_persist_start_data, finish_data=_persist_finish_data, ) + @redact_mount_error_data async def persist_workspace(self) -> io.IOBase: + validate_manifest_mount_credential_boundaries( + self._inner.state.manifest, + provider_backend_id=self._inner.state.type, + ) return await self._inner.persist_workspace() @instrumented_op( "hydrate_workspace", data=_hydrate_start_data, ) + @redact_mount_error_data async def hydrate_workspace(self, data: io.IOBase) -> None: + validate_manifest_mount_credential_boundaries( + self._inner.state.manifest, + provider_backend_id=self._inner.state.type, + ) await self._inner.hydrate_workspace(data) diff --git a/src/agents/sandbox/session/sandbox_session_state.py b/src/agents/sandbox/session/sandbox_session_state.py index f5f38583e1..678fb64df6 100644 --- a/src/agents/sandbox/session/sandbox_session_state.py +++ b/src/agents/sandbox/session/sandbox_session_state.py @@ -1,8 +1,9 @@ from __future__ import annotations +import json import uuid -from collections.abc import Iterable -from typing import Any, ClassVar, Literal, get_args, get_origin +from collections.abc import Iterable, Mapping +from typing import Any, ClassVar, Literal, cast, get_args, get_origin from pydantic import ( BaseModel, @@ -12,8 +13,11 @@ SerializeAsAny, field_validator, model_serializer, + model_validator, ) +from typing_extensions import Self +from .._mount_security import redact_mount_error_data_sync from ..manifest import Manifest from ..snapshot import SnapshotBase @@ -22,7 +26,7 @@ class SandboxSessionState(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict(arbitrary_types_allowed=True, hide_input_in_errors=True) type: str session_id: uuid.UUID = Field(default_factory=uuid.uuid4) snapshot: SerializeAsAny[SnapshotBase] @@ -34,11 +38,33 @@ class SandboxSessionState(BaseModel): _subclass_registry: ClassVar[dict[str, SessionStateClass]] = {} _path_grants_require_rebind: tuple[str, ...] = PrivateAttr(default=()) + _mount_authority_redacted: bool = PrivateAttr(default=False) + _mount_authority_rebound: bool = PrivateAttr(default=False) @property def path_grants_require_rebind(self) -> tuple[str, ...]: return self._path_grants_require_rebind + @property + def mount_authority_redacted(self) -> bool: + return self._mount_authority_redacted + + @property + def mount_authority_rebound(self) -> bool: + """Whether persisted mount topology was rebound from current trusted configuration.""" + + return self._mount_authority_rebound + + def _sanitize_persisted_provider_identity( + self, + data: dict[str, Any], + *, + mount_authority_redacted: bool, + ) -> None: + """Remove provider identity when this state cannot safely reconnect it.""" + + _ = (data, mount_authority_redacted) + @classmethod def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: """Auto-register every subclass by its ``type`` field default.""" @@ -76,21 +102,99 @@ def parse(cls, payload: object) -> SandboxSessionState: payload = payload.model_dump() if isinstance(payload, dict): - state_type = payload.get("type") - if not isinstance(state_type, str): - raise ValueError("sandbox session state payload must include a string `type`") + from ...exceptions import _raise_data_redacted_error + from .._mount_security import ( + _redact_mount_state_validation_error, + sanitize_raw_session_state_mount_authority, + ) - subclass = SandboxSessionState._subclass_registry.get(state_type) - if subclass is None: - raise ValueError(f"unknown sandbox session state type `{state_type}`") + safe_error: ValueError | None = None + sanitized: object = None + state_type: object = None + subclass: SessionStateClass | None = None + try: + sanitized, _redacted = sanitize_raw_session_state_mount_authority(payload) + if not isinstance(sanitized, dict): + raise ValueError("sandbox session state payload has an invalid shape") + payload = sanitized + state_type = payload.get("type") + if not isinstance(state_type, str): + raise ValueError("sandbox session state payload must include a string `type`") - return cls._mark_persisted_path_grants( - subclass.model_validate(payload), - payload=payload, - ) + subclass = SandboxSessionState._subclass_registry.get(state_type) + if subclass is None: + raise ValueError("unknown sandbox session state type") + + return cls._mark_persisted_path_grants( + subclass.model_validate(payload), + payload=payload, + ) + except Exception as error: + payload.clear() + if isinstance(sanitized, dict): + sanitized.clear() + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state payload is invalid", + ) + payload = cast(Any, None) + sanitized = None + state_type = None + subclass = None + assert safe_error is not None + _raise_data_redacted_error(safe_error) + + payload = cast(Any, None) raise TypeError("session state payload must be a SandboxSessionState or dict") + @classmethod + def model_validate_json( + cls, + json_data: str | bytes | bytearray, + *, + strict: bool | None = None, + extra: Literal["allow", "ignore", "forbid"] | None = None, + context: Any | None = None, + by_alias: bool | None = None, + by_name: bool | None = None, + ) -> Self: + """Validate JSON without retaining malformed input in a public error.""" + + from ...exceptions import _raise_data_redacted_error + from .._mount_security import _redact_mount_state_validation_error + + decoded: object = None + safe_error: ValueError | None = None + try: + decoded = json.loads(json_data) + except Exception as error: + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state JSON is invalid", + ) + + if safe_error is not None: + if isinstance(decoded, dict | list): + decoded.clear() + decoded = None + json_data = cast(Any, None) + _raise_data_redacted_error(safe_error) + + decoded = None + try: + return super().model_validate_json( + json_data, + strict=strict, + extra=extra, + context=context, + by_alias=by_alias, + by_name=by_name, + ) + except Exception: + json_data = cast(Any, None) + raise + @classmethod def _mark_persisted_path_grants( cls, @@ -98,6 +202,8 @@ def _mark_persisted_path_grants( *, payload: dict[str, object], ) -> SandboxSessionState: + from .._mount_security import REDACTED_MOUNT_AUTHORITY_KEY + redacted_value = payload.get(REDACTED_HOST_PATH_GRANT_PATHS_KEY) marker_paths = ( tuple(path for path in redacted_value if isinstance(path, str)) @@ -123,6 +229,9 @@ def _mark_persisted_path_grants( ) ) ) + marked._mount_authority_redacted = bool( + state.mount_authority_redacted or payload.get(REDACTED_MOUNT_AUTHORITY_KEY) is True + ) return marked def rebind_persisted_path_grants( @@ -166,7 +275,49 @@ def rebind_persisted_path_grants( rebound._path_grants_require_rebind = () return rebound + @redact_mount_error_data_sync + def rebind_persisted_mount_authority( + self, + trusted_manifest: Manifest | None, + *, + provider_backend_id: str, + ) -> SandboxSessionState: + """Restore redacted mount authority from an exact current trusted manifest.""" + + if not self.mount_authority_redacted: + return self + if trusted_manifest is None: + raise ValueError( + "Sandbox session state contains redacted cloud mount credentials and requires " + "a current trusted manifest before resume" + ) + + from .._mount_security import rebind_manifest_mount_authority + + rebound_manifest = rebind_manifest_mount_authority( + self.manifest, + trusted_manifest, + provider_backend_id=provider_backend_id, + ) + rebound = self.model_copy(update={"manifest": rebound_manifest}) + rebound._mount_authority_redacted = False + rebound._mount_authority_rebound = True + return rebound + + @redact_mount_error_data_sync def assert_path_grants_rebound(self) -> None: + from .._mount_security import validate_manifest_mount_credential_boundaries + + validate_manifest_mount_credential_boundaries( + self.manifest, + provider_backend_id=self.type, + ) + + if self.mount_authority_redacted: + raise ValueError( + "Sandbox session state with cloud mount credentials cannot be resumed; " + "resume through Runner with the current trusted manifest" + ) if not self.path_grants_require_rebind: return raise ValueError( @@ -176,13 +327,101 @@ def assert_path_grants_rebound(self) -> None: @model_serializer(mode="wrap") def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: - data: dict[str, Any] = handler(self) + from ...exceptions import _raise_data_redacted_error + from .._mount_security import ( + REDACTED_MOUNT_AUTHORITY_KEY, + _manifest_has_configured_mount_authority, + _manifest_mount_provenance_error, + _mark_mount_validation_error, + _redact_mount_serialization_error, + sanitize_raw_manifest_mount_authority, + ) + + data: dict[str, Any] | None = None + safe_error: Exception | None = None + provenance_error = _manifest_mount_provenance_error(self.manifest) + if provenance_error is not None: + _mark_mount_validation_error(provenance_error) + safe_error = provenance_error + else: + try: + data = handler(self) + sanitized_manifest, mount_authority_redacted = ( + sanitize_raw_manifest_mount_authority( + cast(dict[str, Any], data).get("manifest") + ) + ) + except Exception as error: + if not _manifest_has_configured_mount_authority(self.manifest): + raise + safe_error = _redact_mount_serialization_error(error) + + if safe_error is not None: + data = None + self = cast(Any, None) + _raise_data_redacted_error(safe_error) + + assert data is not None + if "manifest" in data: + data["manifest"] = sanitized_manifest + requires_mount_authority_rebind = mount_authority_redacted or self.mount_authority_redacted + if requires_mount_authority_rebind: + data[REDACTED_MOUNT_AUTHORITY_KEY] = True if self.type: data["type"] = self.type if self.session_id: data["session_id"] = self.session_id + self._sanitize_persisted_provider_identity( + data, + mount_authority_redacted=requires_mount_authority_rebind, + ) return data + @model_validator(mode="wrap") + @classmethod + def _restore_mount_authority_marker(cls, value: Any, handler: Any) -> SandboxSessionState: + from ...exceptions import _raise_data_redacted_error + from .._mount_security import ( + REDACTED_MOUNT_AUTHORITY_KEY, + _redact_mount_state_validation_error, + sanitize_raw_session_state_mount_authority, + ) + + marker = isinstance(value, Mapping) and value.get(REDACTED_MOUNT_AUTHORITY_KEY) is True + state: SandboxSessionState | None = None + sanitized: object = None + safe_error: ValueError | None = None + if ( + isinstance(value, Mapping) + and "manifest" in value + and not isinstance(value.get("manifest"), Manifest) + ): + try: + sanitized, redacted = sanitize_raw_session_state_mount_authority(value) + marker = marker or redacted + state = handler(sanitized) + except Exception as error: + if isinstance(value, dict): + value.clear() + if isinstance(sanitized, dict): + sanitized.clear() + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state payload is invalid", + ) + else: + state = handler(value) + + if safe_error is not None: + value = cast(Any, None) + sanitized = None + _raise_data_redacted_error(safe_error) + + assert state is not None + if marker: + state._mount_authority_redacted = True + return state + @field_validator("snapshot", mode="before") @classmethod def _coerce_snapshot(cls, value: object) -> SnapshotBase: diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 99efdeb779..5e4176adfe 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -19,11 +19,13 @@ from agents.run_config import SandboxRunConfig from agents.sandbox import Manifest, SandboxPathGrant from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE +from agents.sandbox.entries import InContainerMountStrategy, RcloneMountPattern, S3Mount from agents.sandbox.errors import ( ExecTimeoutError, ExecTransportError, ExposedPortUnavailableError, InvalidManifestPathError, + MountConfigError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, @@ -833,6 +835,7 @@ async def test_resume_reconnects(self, monkeypatch: pytest.MonkeyPatch) -> None: client = mod.BlaxelSandboxClient(token="test-token") state = _make_state(sandbox_name="resume-sandbox", pause_on_exit=True) + state = client.deserialize_session_state(client.serialize_session_state(state)) session = await client.resume(state) assert session is not None @@ -1626,6 +1629,34 @@ async def test_resolved_envs(self, fake_sandbox: _FakeSandboxInstance) -> None: class TestStartLifecycle: + @pytest.mark.asyncio + @pytest.mark.parametrize("skip_start", [False, True]) + async def test_start_rejects_unsafe_mount_before_provider_work( + self, + fake_sandbox: _FakeSandboxInstance, + skip_start: bool, + ) -> None: + sentinel = "blaxel-start-secret" + state = _make_state() + state.manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + session = _make_session(fake_sandbox, state=state) + session._skip_start = skip_start + + with pytest.raises(MountConfigError) as exc: + await session.start() + + assert fake_sandbox.process.exec_calls == [] + assert sentinel not in str(exc.value) + @pytest.mark.asyncio async def test_start_mkdir_failure_suppressed(self, fake_sandbox: _FakeSandboxInstance) -> None: session = _make_session(fake_sandbox) diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index 3f707ba3c9..f9ba9f62b7 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -33,10 +33,12 @@ InvalidManifestPathError, MountConfigError, PtySessionNotFoundError, + SandboxRuntimeError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, WorkspaceStartError, + WorkspaceStopError, WorkspaceWriteTypeError, ) from agents.sandbox.manifest import Environment, Manifest @@ -552,6 +554,10 @@ async def _running(self: CloudflareSandboxSession) -> bool: client = CloudflareSandboxClient(exec_timeout_s=11.0, request_timeout_s=77.0) state = _make_state() + state = cast( + CloudflareSandboxSessionState, + client.deserialize_session_state(client.serialize_session_state(state)), + ) session = await client.resume(state) inner = cast(CloudflareSandboxSession, session._inner) assert session.state is state @@ -560,6 +566,63 @@ async def _running(self: CloudflareSandboxSession) -> bool: assert inner._request_timeout_s == 77.0 +@pytest.mark.asyncio +async def test_cloudflare_protected_mount_state_drops_identity_before_resume() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + client = CloudflareSandboxClient() + payload = client.serialize_session_state(_make_state(manifest=manifest)) + + assert payload["sandbox_id"] == "" + assert payload["workspace_root_ready"] is False + restored = client.deserialize_session_state(payload) + rebound = restored.rebind_persisted_mount_authority( + manifest, + provider_backend_id="cloudflare", + ) + + with pytest.raises(RuntimeError, match="protected mount configuration"): + await client.resume(rebound) + + +@pytest.mark.asyncio +async def test_cloudflare_resume_rejects_direct_state_with_configured_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + provider_calls = 0 + + async def running(self: CloudflareSandboxSession) -> bool: + nonlocal provider_calls + _ = self + provider_calls += 1 + return True + + monkeypatch.setattr(CloudflareSandboxSession, "running", running) + + with pytest.raises(RuntimeError, match="protected mount configuration"): + await CloudflareSandboxClient().resume(_make_state(manifest=manifest)) + + assert provider_calls == 0 + + @pytest.mark.asyncio @pytest.mark.parametrize( ("is_running", "workspace_root_ready", "workspace_preserved", "workspace_reusable"), @@ -1194,6 +1257,62 @@ async def _gate(*, is_running: bool) -> bool: ] +@pytest.mark.asyncio +async def test_cloudflare_resume_start_settles_skipped_hydrate_reapply_cancellation() -> None: + mount_started = asyncio.Event() + release_mount = asyncio.Event() + + class _BlockingMountResponse(_FakeResponse): + async def __aenter__(self) -> _FakeResponse: + mount_started.set() + await release_mount.wait() + return self + + fake_http = _FakeHttp( + { + "GET /running": _FakeResponse(status=200, json_body={"running": True}), + "POST /mount": _BlockingMountResponse(status=200, json_body={"ok": True}), + } + ) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session(state=_make_state(manifest=manifest), fake_http=fake_http) + sess.state.snapshot = _RestorableSnapshot(id="snapshot") + sess.state.workspace_root_ready = True + sess._start_workspace_root_ready = True # noqa: SLF001 + sess._set_start_state_preserved(True) # noqa: SLF001 + + async def _exec_internal(*command: str | Path, timeout: float | None = None) -> ExecResult: + _ = (command, timeout) + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + async def _gate(*, is_running: bool) -> bool: + assert is_running is True + return True + + sess._exec_internal = _exec_internal # type: ignore[method-assign] + sess._can_skip_snapshot_restore_on_resume = _gate # type: ignore[method-assign] + task = asyncio.create_task(sess.start()) + await mount_started.wait() + task.cancel() + release_mount.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert [call["url"].split("/")[-1] for call in fake_http.calls] == [ + "running", + "mount", + ] + assert cast(Any, sess._session()) is fake_http + + @pytest.mark.asyncio async def test_cloudflare_resume_start_unmounts_before_hydrate_when_sandbox_is_running() -> None: fake_http = _FakeHttp( @@ -1234,6 +1353,266 @@ async def _exec_internal(*command: str | Path, timeout: float | None = None) -> ] +@pytest.mark.asyncio +async def test_cloudflare_resume_start_restores_mount_after_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_http = _FakeHttp( + { + "GET /running": _FakeResponse(status=200, json_body={"running": True}), + "POST /unmount": _FakeResponse(status=200, json_body={"ok": True}), + "POST /mount": _FakeResponse(status=200, json_body={"ok": True}), + } + ) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + prefix="nested/prefix/", + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session(state=_make_state(manifest=manifest), fake_http=fake_http) + sess.state.snapshot = _RestorableSnapshot(id="snapshot") + sess.state.workspace_root_ready = True + sess._start_workspace_root_ready = True # noqa: SLF001 + sess._set_start_state_preserved(True) # noqa: SLF001 + restore_started = asyncio.Event() + release_restore = asyncio.Event() + + async def blocking_restore( + self: _RestorableSnapshot, + *, + dependencies: Dependencies | None = None, + ) -> io.IOBase: + _ = (self, dependencies) + restore_started.set() + await release_restore.wait() + return io.BytesIO(self.payload) + + async def _exec_internal(*command: str | Path, timeout: float | None = None) -> ExecResult: + _ = (command, timeout) + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + monkeypatch.setattr(_RestorableSnapshot, "restore", blocking_restore) + sess._exec_internal = _exec_internal # type: ignore[method-assign] + task = asyncio.create_task(sess.start()) + await restore_started.wait() + task.cancel() + release_restore.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert [call["url"].split("/")[-1] for call in fake_http.calls] == [ + "running", + "unmount", + "hydrate", + "mount", + ] + + +@pytest.mark.asyncio +async def test_cloudflare_resume_start_settles_reapply_after_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + mount_started = asyncio.Event() + release_mount = asyncio.Event() + + class _BlockingMountResponse(_FakeResponse): + async def __aenter__(self) -> _FakeResponse: + mount_started.set() + await release_mount.wait() + return self + + fake_http = _FakeHttp( + { + "GET /running": _FakeResponse(status=200, json_body={"running": True}), + "POST /unmount": _FakeResponse(status=200, json_body={"ok": True}), + "POST /hydrate": _FakeResponse(status=200, json_body={"ok": True}), + } + ) + original_post = fake_http.post + mount_responses = [_BlockingMountResponse(status=200, json_body={"ok": True})] + + def sequenced_post(url: str, **kwargs: Any) -> _FakeResponse | _FakeSSEResponse: + if "/mount" not in url: + return original_post(url, **kwargs) + fake_http.calls.append({"method": "POST", "url": url, **kwargs}) + return mount_responses.pop(0) + + monkeypatch.setattr(fake_http, "post", sequenced_post) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session(state=_make_state(manifest=manifest), fake_http=fake_http) + sess.state.snapshot = _RestorableSnapshot(id="snapshot") + sess.state.workspace_root_ready = True + sess._start_workspace_root_ready = True # noqa: SLF001 + sess._set_start_state_preserved(True) # noqa: SLF001 + + async def _exec_internal(*command: str | Path, timeout: float | None = None) -> ExecResult: + _ = (command, timeout) + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + sess._exec_internal = _exec_internal # type: ignore[method-assign] + task = asyncio.create_task(sess.start()) + await mount_started.wait() + task.cancel() + release_mount.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert [(call["method"], call["url"].split("/")[-1]) for call in fake_http.calls] == [ + ("GET", "running"), + ("POST", "unmount"), + ("POST", "hydrate"), + ("POST", "mount"), + ] + assert cast(Any, sess._session()) is fake_http + + +@pytest.mark.asyncio +async def test_cloudflare_resume_start_does_not_restore_after_terminal_reapply_failure() -> None: + fake_http = _FakeHttp( + { + "GET /running": _FakeResponse(status=200, json_body={"running": True}), + "POST /unmount": _FakeResponse(status=200, json_body={"ok": True}), + "POST /hydrate": _FakeResponse(status=200, json_body={"ok": True}), + "POST /mount": _FakeResponse(status=502, json_body={"error": "mount failed"}), + "DELETE /v1/sandbox/": _FakeResponse(status=200, json_body={"ok": True}), + } + ) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session(state=_make_state(manifest=manifest), fake_http=fake_http) + sess.state.snapshot = _RestorableSnapshot(id="snapshot") + sess.state.workspace_root_ready = True + sess._start_workspace_root_ready = True # noqa: SLF001 + sess._set_start_state_preserved(True) # noqa: SLF001 + + async def _exec_internal(*command: str | Path, timeout: float | None = None) -> ExecResult: + _ = (command, timeout) + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + sess._exec_internal = _exec_internal # type: ignore[method-assign] + + with pytest.raises(MountConfigError, match="cloudflare bucket mount failed"): + await sess.start() + + assert [(call["method"], call["url"].split("/")[-1]) for call in fake_http.calls] == [ + ("GET", "running"), + ("POST", "unmount"), + ("POST", "hydrate"), + ("POST", "mount"), + ("DELETE", "abc123"), + ] + assert fake_http.closed is True + with pytest.raises(SandboxRuntimeError, match="ambiguous mount transition"): + sess._session() + + +@pytest.mark.asyncio +async def test_cloudflare_resume_reapply_surfaces_terminal_delete_failure() -> None: + delete_response = _FakeResponse(status=502, raw_body=b"provider response must not be read") + fake_http = _FakeHttp( + { + "POST /mount": _FakeResponse(status=502, json_body={"error": "mount failed"}), + "DELETE /v1/sandbox/": delete_response, + } + ) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session(state=_make_state(manifest=manifest), fake_http=fake_http) + + async def _exec_internal(*command: str | Path, timeout: float | None = None) -> ExecResult: + _ = (command, timeout) + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + sess._exec_internal = _exec_internal # type: ignore[method-assign] + + with pytest.raises(WorkspaceStopError) as exc_info: + await sess._reapply_ephemeral_manifest_on_resume() + + assert exc_info.value.context["backend"] == "cloudflare" + assert exc_info.value.context["reason"] == "terminal_delete_failed" + assert exc_info.value.context["http_status"] == 502 + assert [(call["method"], call["url"].split("/")[-1]) for call in fake_http.calls] == [ + ("POST", "mount"), + ("DELETE", "abc123"), + ] + assert delete_response.read_calls == 0 + assert fake_http.closed is True + with pytest.raises(SandboxRuntimeError, match="ambiguous mount transition"): + sess._session() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["persist", "hydrate"]) +async def test_cloudflare_direct_persistence_redacts_protected_remount_failure( + operation: str, +) -> None: + sentinel = "cloudflare-direct-persistence-secret" + fake_http = _FakeHttp( + { + "POST /unmount": _FakeResponse(status=200, json_body={"ok": True}), + "POST /persist": _FakeResponse(status=200, raw_body=b"fake-tar"), + "POST /hydrate": _FakeResponse(status=200, json_body={"ok": True}), + "POST /mount": _FakeResponse( + status=500, + json_body={"error": f"provider echoed {sentinel}"}, + ), + } + ) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session(state=_make_state(manifest=manifest), fake_http=fake_http) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + if operation == "persist": + await sess.persist_workspace() + else: + await sess.hydrate_workspace(io.BytesIO(_valid_tar_bytes())) + + assert sentinel not in str(exc_info.value) + assert sentinel not in repr(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + traceback = exc_info.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio async def test_cloudflare_persist_preserves_hidden_exclude_paths() -> None: fake_http = _FakeHttp({"POST /persist": _FakeResponse(status=200, raw_body=b"fake-tar")}) @@ -1573,6 +1952,111 @@ async def test_cloudflare_shutdown_logs_respect_tool_data_policy( assert response.read_calls == (0 if redacted else 1) +@pytest.mark.asyncio +async def test_cloudflare_shutdown_does_not_log_protected_mount_authority( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify that protected mount authority disables DELETE response detail logging.""" + import logging + + sentinel = "cloudflare-secret-access-key" + monkeypatch.setattr("agents._debug.DONT_LOG_TOOL_DATA", False) + response = _FakeResponse(status=502, raw_body=f"provider echoed {sentinel}".encode()) + manifest = Manifest( + entries={ + "remote": R2Mount( + bucket="bucket", + account_id="account-id", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session( + state=_make_state(manifest=manifest), + fake_http=_FakeHttp({"DELETE /v1/sandbox/": response}), + ) + + with caplog.at_level(logging.DEBUG, logger="agents.extensions.sandbox.cloudflare.sandbox"): + await sess._shutdown_backend() + + assert sentinel not in caplog.text + assert response.read_calls == 0 + + +@pytest.mark.asyncio +async def test_cloudflare_shutdown_does_not_log_protected_mount_exception( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify that protected mount authority disables DELETE exception detail logging.""" + import logging + + sentinel = "cloudflare-secret-access-key" + monkeypatch.setattr("agents._debug.DONT_LOG_TOOL_DATA", False) + + class _FailingDeleteHttp(_FakeHttp): + def delete(self, url: str, **kwargs: Any) -> Any: + raise aiohttp.ClientError(f"provider echoed {sentinel}") + + manifest = Manifest( + entries={ + "remote": R2Mount( + bucket="bucket", + account_id="account-id", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=CloudflareBucketMountStrategy(), + ) + } + ) + sess = _make_session(state=_make_state(manifest=manifest), fake_http=_FailingDeleteHttp()) + + with caplog.at_level(logging.DEBUG, logger="agents.extensions.sandbox.cloudflare.sandbox"): + await sess._shutdown_backend() + + assert sentinel not in caplog.text + + +@pytest.mark.asyncio +async def test_cloudflare_ambiguous_mount_terminal_delete_failure_is_observable() -> None: + response = _FakeResponse(status=502, raw_body=b"provider response must not be read") + sess = _make_session(fake_http=_FakeHttp({"DELETE /v1/sandbox/": response})) + + with pytest.raises(WorkspaceStopError) as exc_info: + await sess._terminate_ambiguous_mount_transition() + + assert exc_info.value.context["backend"] == "cloudflare" + assert exc_info.value.context["reason"] == "terminal_delete_failed" + assert exc_info.value.context["http_status"] == 502 + assert response.read_calls == 0 + with pytest.raises(SandboxRuntimeError, match="ambiguous mount transition"): + sess._session() + + +@pytest.mark.asyncio +async def test_cloudflare_ambiguous_mount_terminalizes_before_cleanup_failure() -> None: + fake_http = _FakeHttp( + {"DELETE /v1/sandbox/": _FakeResponse(status=200, json_body={"ok": True})} + ) + sess = _make_session(fake_http=fake_http) + + async def fail_before_shutdown() -> None: + raise asyncio.CancelledError() + + sess._before_shutdown = fail_before_shutdown # type: ignore[method-assign] + + with pytest.raises(asyncio.CancelledError): + await sess._terminate_ambiguous_mount_transition() + + assert [call["method"] for call in fake_http.calls] == ["DELETE"] + assert fake_http.closed is True + with pytest.raises(SandboxRuntimeError, match="ambiguous mount transition"): + sess._session() + + def _decode_in_chunks(stream: str, size: int) -> list[str]: decoder = _SSELineDecoder() lines: list[str] = [] diff --git a/tests/extensions/sandbox/test_daytona.py b/tests/extensions/sandbox/test_daytona.py index 02dcb3254d..7f2df5bb4f 100644 --- a/tests/extensions/sandbox/test_daytona.py +++ b/tests/extensions/sandbox/test_daytona.py @@ -365,6 +365,18 @@ def test_daytona_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPa assert package_module.DaytonaSandboxClient is daytona_module.DaytonaSandboxClient +@pytest.fixture(autouse=True) +def _trust_recording_mounts_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: + from agents.sandbox import _mount_security + + original = _mount_security._mount_class_is_trusted + monkeypatch.setattr( + _mount_security, + "_mount_class_is_trusted", + lambda mount: isinstance(mount, _RecordingMount) or original(mount), + ) + + class _RecordingMount(Mount): type: str = "daytona_recording_mount" mount_strategy: InContainerMountStrategy = Field( @@ -749,7 +761,10 @@ async def test_resume_reconnects_paused_sandbox_and_preserves_state( session = await client.create( options=daytona_module.DaytonaSandboxClientOptions(pause_on_exit=True), ) - state = session.state + state = cast( + Any, + client.deserialize_session_state(client.serialize_session_state(session.state)), + ) _FakeAsyncDaytona.create_calls.clear() resumed = await client.resume(state) @@ -1899,7 +1914,7 @@ async def test_ensure_rclone_installs_when_missing() -> None: @pytest.mark.asyncio async def test_activate_calls_preflights_and_delegates() -> None: strategy = DaytonaCloudBucketMountStrategy() - mount = MagicMock() + mount = S3Mount(bucket="public-bucket", mount_strategy=strategy) session = _FakePreflightSession() dest = Path("/workspace") base_dir = Path("/workspace") @@ -1917,6 +1932,29 @@ async def test_activate_calls_preflights_and_delegates() -> None: delegate_mock.assert_awaited_once() +@pytest.mark.asyncio +async def test_activate_rejects_credentials_before_preflights() -> None: + strategy = DaytonaCloudBucketMountStrategy() + mount = S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=strategy, + ) + session = _FakePreflightSession() + + with ( + patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock, + patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock, + pytest.raises(MountConfigError), + ): + await strategy.activate(mount, session, Path("/workspace"), Path("/workspace")) + + fuse_mock.assert_not_awaited() + rclone_mock.assert_not_awaited() + assert session.exec_calls == [] + + @pytest.mark.asyncio async def test_deactivate_delegates_without_preflights() -> None: strategy = DaytonaCloudBucketMountStrategy() @@ -1961,7 +1999,7 @@ async def test_teardown_delegates_without_preflights() -> None: @pytest.mark.asyncio async def test_restore_after_snapshot_reruns_preflights() -> None: strategy = DaytonaCloudBucketMountStrategy() - mount = MagicMock() + mount = S3Mount(bucket="public-bucket", mount_strategy=strategy) session = _FakePreflightSession() path = Path("/workspace/bucket") @@ -1978,6 +2016,29 @@ async def test_restore_after_snapshot_reruns_preflights() -> None: delegate_mock.assert_awaited_once() +@pytest.mark.asyncio +async def test_restore_after_snapshot_rejects_credentials_before_preflights() -> None: + strategy = DaytonaCloudBucketMountStrategy() + mount = S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=strategy, + ) + session = _FakePreflightSession() + + with ( + patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock, + patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock, + pytest.raises(MountConfigError), + ): + await strategy.restore_after_snapshot(mount, session, Path("/workspace/data")) + + fuse_mock.assert_not_awaited() + rclone_mock.assert_not_awaited() + assert session.exec_calls == [] + + def test_build_docker_volume_driver_config_returns_none() -> None: strategy = DaytonaCloudBucketMountStrategy() mount = MagicMock() diff --git a/tests/extensions/sandbox/test_e2b.py b/tests/extensions/sandbox/test_e2b.py index f830546517..67dc301eef 100644 --- a/tests/extensions/sandbox/test_e2b.py +++ b/tests/extensions/sandbox/test_e2b.py @@ -544,6 +544,18 @@ async def restorable(self, *, dependencies: Dependencies | None = None) -> bool: return True +@pytest.fixture(autouse=True) +def _trust_recording_mounts_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: + from agents.sandbox import _mount_security + + original = _mount_security._mount_class_is_trusted + monkeypatch.setattr( + _mount_security, + "_mount_class_is_trusted", + lambda mount: isinstance(mount, _RecordingMount) or original(mount), + ) + + class _RecordingMount(Mount): type: str = "recording_mount" mount_strategy: InContainerMountStrategy = Field( @@ -1336,6 +1348,10 @@ async def connect(*, sandbox_id: str, timeout: int | None = None) -> _FakeE2BSan auto_resume=True, pause_on_exit=False, ) + state = cast( + E2BSandboxSessionState, + client.deserialize_session_state(client.serialize_session_state(state)), + ) resumed = await client.resume(state) diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index a63582319c..6838a60a29 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -43,6 +43,7 @@ RESOLVE_WORKSPACE_PATH_HELPER, WORKSPACE_FINGERPRINT_HELPER, ) +from agents.sandbox.session.sandbox_session_state import SandboxSessionState from agents.sandbox.snapshot import LocalSnapshot from agents.sandbox.types import ExecResult @@ -62,6 +63,18 @@ def _set_aio_attr(obj: object, name: str, fn: Callable[..., object]) -> None: setattr(obj, name, _with_aio(fn)) +@pytest.fixture(autouse=True) +def _trust_recording_mounts_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: + from agents.sandbox import _mount_security + + original = _mount_security._mount_class_is_trusted + monkeypatch.setattr( + _mount_security, + "_mount_class_is_trusted", + lambda mount: isinstance(mount, _RecordingMount) or original(mount), + ) + + class _RecordingMount(Mount): type: str = "modal_recording_mount" mount_strategy: InContainerMountStrategy = Field( @@ -548,6 +561,45 @@ def test_modal_deserialize_session_state_defaults_missing_idle_timeout( assert restored.idle_timeout is None +@pytest.mark.asyncio +async def test_modal_deserialize_discards_surviving_resource_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + client = modal_module.ModalSandboxClient() + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy( + secret_name="protected-secret" + ), + ) + }, + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id="sb-survivor", + workspace_root_ready=True, + ) + payload = client.serialize_session_state(state) + cast(dict[str, object], payload["manifest"])["entries"] = {} + payload.pop("__openai_agents_redacted_mount_authority", None) + + restored = client.deserialize_session_state(payload) + assert restored.sandbox_id is None + assert restored.workspace_root_ready is False + session = await client.resume(restored) + + assert restored.sandbox_id == session.state.sandbox_id + assert restored.sandbox_id != "sb-survivor" + assert restored.workspace_root_ready is False + assert sys.modules["modal"].Sandbox.from_id_calls == [] + assert len(create_calls) == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize( ("probe_exit_code", "expected_error"), @@ -1124,15 +1176,24 @@ async def test_modal_resume_eagerly_reconnects_sandbox( monkeypatch: pytest.MonkeyPatch, ) -> None: modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + client = modal_module.ModalSandboxClient() state = modal_module.ModalSandboxSessionState( - manifest=Manifest(root="/workspace"), + manifest=Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ) + }, + ), snapshot=modal_module.resolve_snapshot(None, "snapshot"), app_name="sandbox-tests", sandbox_id="sb-existing", ) + state = client.deserialize_session_state(client.serialize_session_state(state)) - client = modal_module.ModalSandboxClient() session = await client.resume(state) assert session._inner._sandbox is not None # noqa: SLF001 @@ -1140,6 +1201,114 @@ async def test_modal_resume_eagerly_reconnects_sandbox( assert sys.modules["modal"].Sandbox.from_id_calls == ["sb-existing"] +@pytest.mark.asyncio +async def test_modal_resume_reconnects_deserialized_credentialless_external_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + trusted_manifest = Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ) + }, + ) + state = modal_module.ModalSandboxSessionState( + manifest=trusted_manifest, + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id="sb-existing", + ) + client = modal_module.ModalSandboxClient() + restored = client.deserialize_session_state(client.serialize_session_state(state)) + session = await client.resume(restored) + + assert session._inner._sandbox is not None # noqa: SLF001 + assert restored.mount_authority_rebound is False + assert create_calls == [] + assert sys.modules["modal"].Sandbox.from_id_calls == ["sb-existing"] + + +@pytest.mark.asyncio +async def test_modal_resume_reconnects_generically_parsed_credentialless_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + trusted_manifest = Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ) + }, + ) + state = modal_module.ModalSandboxSessionState( + manifest=trusted_manifest, + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id="sb-existing", + ) + client = modal_module.ModalSandboxClient() + restored = SandboxSessionState.parse(client.serialize_session_state(state)) + assert isinstance(restored, modal_module.ModalSandboxSessionState) + session = await client.resume(restored) + + assert session._inner._sandbox is not None # noqa: SLF001 + assert restored.mount_authority_rebound is False + assert create_calls == [] + assert sys.modules["modal"].Sandbox.from_id_calls == ["sb-existing"] + + +@pytest.mark.asyncio +async def test_modal_resume_creates_fresh_sandbox_for_rebound_mount_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + trusted_manifest = Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy( + secret_name="current-secret" + ), + ) + }, + ) + state = modal_module.ModalSandboxSessionState( + manifest=trusted_manifest, + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id="sb-existing", + ) + client = modal_module.ModalSandboxClient() + serialized = client.serialize_session_state(state) + assert "current-secret" not in repr(serialized) + restored = client.deserialize_session_state(serialized) + assert restored.mount_authority_redacted is True + rebound = restored.rebind_persisted_mount_authority( + trusted_manifest, + provider_backend_id="modal", + ) + assert rebound.mount_authority_redacted is False + + original_session_id = rebound.session_id + session = await client.resume(rebound) + + assert session._inner._sandbox is not None # noqa: SLF001 + assert rebound.session_id != original_session_id + assert rebound.sandbox_id == "sb-123" + assert len(create_calls) == 1 + assert sys.modules["modal"].Sandbox.from_id_calls == [] + volumes = cast(dict[str, object], create_calls[0]["volumes"]) + assert volumes.keys() == {"/workspace/remote"} + mount = cast(Any, volumes["/workspace/remote"]) + assert mount.secret.name == "current-secret" + + @pytest.mark.asyncio async def test_modal_resume_marks_reconnected_sandbox_preserved_before_snapshot_reuse( monkeypatch: pytest.MonkeyPatch, @@ -3899,3 +4068,41 @@ async def _raise_cancelled() -> None: assert sandbox.process.terminate_calls == 1 assert session._pty_processes == {} # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_modal_direct_persist_redacts_protected_mount_provider_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + sentinel = "direct-modal-persist-secret" + source_error = RuntimeError(f"provider echoed {sentinel}") + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy(secret_name=sentinel), + ) + }, + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id="sb-direct-persist", + ) + session = modal_module.ModalSandboxSession.from_state(state) + + async def fail_persist() -> io.IOBase: + raise source_error + + monkeypatch.setattr(session, "_persist_workspace_via_tar", fail_persist) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await session.persist_workspace() + + assert sentinel not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert source_error.args == () + assert source_error.__traceback__ is None diff --git a/tests/extensions/sandbox/test_runloop.py b/tests/extensions/sandbox/test_runloop.py index ec6999d252..516e8d8daf 100644 --- a/tests/extensions/sandbox/test_runloop.py +++ b/tests/extensions/sandbox/test_runloop.py @@ -21,8 +21,16 @@ from agents.sandbox import Manifest, SandboxPathGrant from agents.sandbox.capabilities import Shell from agents.sandbox.capabilities.tools.shell_tool import ExecCommandArgs, ExecCommandTool -from agents.sandbox.entries import File, InContainerMountStrategy, Mount, MountpointMountPattern +from agents.sandbox.entries import ( + File, + InContainerMountStrategy, + Mount, + MountpointMountPattern, + RcloneMountPattern, + S3Mount, +) from agents.sandbox.entries.mounts.base import InContainerMountAdapter +from agents.sandbox.errors import MountConfigError from agents.sandbox.manifest import Environment from agents.sandbox.materialization import MaterializedFile from agents.sandbox.session.base_sandbox_session import BaseSandboxSession @@ -1295,6 +1303,18 @@ def test_runloop_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPa assert package_module.RunloopUserParameters is runloop_module.RunloopUserParameters +@pytest.fixture(autouse=True) +def _trust_recording_mounts_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: + from agents.sandbox import _mount_security + + original = _mount_security._mount_class_is_trusted + monkeypatch.setattr( + _mount_security, + "_mount_class_is_trusted", + lambda mount: isinstance(mount, _RecordingMount) or original(mount), + ) + + class _RecordingMount(Mount): type: str = "runloop_recording_mount" mount_strategy: InContainerMountStrategy = Field( @@ -2049,7 +2069,10 @@ async def test_resume_reconnects_suspended_devbox_and_skips_start( session = await client.create( options=runloop_module.RunloopSandboxClientOptions(pause_on_exit=True), ) - state = session.state + state = cast( + Any, + client.deserialize_session_state(client.serialize_session_state(session.state)), + ) sdk = _FakeAsyncRunloopSDK.created_instances[-1] sdk.devbox.create_calls.clear() sdk.devbox.devboxes[state.devbox_id].status = "suspended" @@ -2060,6 +2083,41 @@ async def test_resume_reconnects_suspended_devbox_and_skips_start( assert sdk.devbox.create_calls == [] assert resumed._inner._skip_start is True # noqa: SLF001 + @pytest.mark.asyncio + async def test_skip_start_rejects_unsafe_mount_before_provider_work( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + sentinel = "runloop-start-secret" + runloop_module = _load_runloop_module(monkeypatch) + + async with runloop_module.RunloopSandboxClient() as client: + session = await client.create(options=runloop_module.RunloopSandboxClientOptions()) + sdk = _FakeAsyncRunloopSDK.created_instances[-1] + devbox = sdk.devbox.devboxes[session.state.devbox_id] + session.state.manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + session._inner._skip_start = True # noqa: SLF001 + exec_calls_before = len(devbox.exec_calls) + resume_calls_before = devbox.resume_calls + snapshot_calls_before = len(devbox.snapshot_calls) + + with pytest.raises(MountConfigError) as exc: + await session.start() + + assert len(devbox.exec_calls) == exec_calls_before + assert devbox.resume_calls == resume_calls_before + assert len(devbox.snapshot_calls) == snapshot_calls_before + assert sentinel not in str(exc.value) + @pytest.mark.asyncio async def test_resume_reconnects_running_devbox_without_pause( self, diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 0cac7d9969..6293662e26 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -8,6 +8,7 @@ import sys import tarfile import types +from collections.abc import Callable from pathlib import Path from typing import Any, Literal, cast @@ -15,13 +16,15 @@ import pytest from pydantic import BaseModel, PrivateAttr -from agents.sandbox import Manifest, SandboxPathGrant +from agents.sandbox import Manifest, SandboxAgent, SandboxPathGrant, SandboxRunConfig +from agents.sandbox.capabilities import Capability from agents.sandbox.entries import ( Dir, File, InContainerMountStrategy, Mount, MountpointMountPattern, + RcloneMountPattern, S3Mount, ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter @@ -33,6 +36,7 @@ ) from agents.sandbox.manifest import EnvEntry, Environment, StrEnvValue from agents.sandbox.materialization import MaterializedFile +from agents.sandbox.runtime_session_manager import SandboxRuntimeSessionManager from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.dependencies import Dependencies from agents.sandbox.session.manager import Instrumentation @@ -40,6 +44,7 @@ from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase from agents.sandbox.types import User from tests._fake_workspace_paths import resolve_fake_workspace_path +from tests.fake_model import FakeModel class _FakeNetworkPolicyRule(BaseModel): @@ -62,6 +67,14 @@ class _FakeNetworkPolicyCustom(BaseModel): NetworkPolicySubnets = _FakeNetworkPolicySubnets +class _AddFileCapability(Capability): + type: str = "vercel-add-file" + + def process_manifest(self, manifest: Manifest) -> Manifest: + manifest.entries["capability.txt"] = File(content=b"capability") + return manifest + + class Resources(BaseModel): memory: int | None = None @@ -113,6 +126,43 @@ def __init__(self, message: str = "validation failed") -> None: super().__init__(message) +def _install_hostile_exception_descriptors( + error_type: type[BaseException], + *, + reject_stringification: bool, +) -> None: + def get_base_args(error: BaseException) -> tuple[object, ...]: + return cast( + tuple[object, ...], + cast(Any, BaseException.args).__get__(error, type(error)), + ) + + def reject_slot_access(error: BaseException) -> object: + _ = error + raise AssertionError("provider-defined exception descriptor was accessed") + + type.__setattr__(error_type, "args", property(get_base_args)) + for name in ("__traceback__", "__cause__", "__context__"): + type.__setattr__(error_type, name, property(reject_slot_access)) + if reject_stringification: + + def reject_string_access(error: BaseException) -> str: + _ = error + raise RuntimeError("provider exception stringification failed") + + type.__setattr__(error_type, "__str__", reject_string_access) + + +def _assert_base_exception_slots_cleared(error: BaseException) -> None: + assert cast(Any, BaseException.args).__get__(error, type(error)) == () + for descriptor in ( + cast(Any, BaseException.__traceback__), + cast(Any, BaseException.__cause__), + cast(Any, BaseException.__context__), + ): + assert descriptor.__get__(error, type(error)) is None + + class _MemorySnapshot(SnapshotBase): type: Literal["test-vercel-memory"] = "test-vercel-memory" payload: bytes = b"" @@ -393,9 +443,20 @@ async def snapshot(self, *, expiration: int | None = None) -> _FakeAsyncSnapshot return _FakeAsyncSnapshot(snapshot_id) +@pytest.fixture(autouse=True) +def _trust_recording_mounts_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: + from agents.sandbox import _mount_security + + original = _mount_security._mount_class_is_trusted + monkeypatch.setattr( + _mount_security, + "_mount_class_is_trusted", + lambda mount: isinstance(mount, _RecordingMount) or original(mount), + ) + + class _RecordingMount(Mount): type: str = "test_vercel_recording_mount" - bucket: str = "bucket" _events: list[tuple[str, str]] = PrivateAttr(default_factory=list) def supported_in_container_patterns( @@ -555,6 +616,280 @@ def test_vercel_s3_mount_validates_credentials_and_lifecycle( ) +def test_vercel_from_state_rejects_mismatched_trusted_mount_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + manifest = _vercel_s3_manifest(package_module) + trusted_s3_mounts = vercel_module._vercel_s3_mount_map(manifest) + trusted_mount = next(iter(trusted_s3_mounts.values())).model_copy(deep=True) + trusted_mount.bucket = "different-bucket" + trusted_s3_mounts = {next(iter(trusted_s3_mounts)): trusted_mount} + state = vercel_module.VercelSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + + with pytest.raises(MountConfigError, match="topology must match"): + vercel_module.VercelSandboxSession.from_state( + state, + trusted_s3_mounts=trusted_s3_mounts, + trusted_manifest=manifest, + ) + + +def test_vercel_from_state_requires_trusted_manifest_for_mounts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + manifest = _vercel_s3_manifest(package_module) + state = vercel_module.VercelSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + + with pytest.raises(MountConfigError, match="trusted create-time manifest"): + vercel_module.VercelSandboxSession.from_state( + state, + trusted_s3_mounts=vercel_module._vercel_s3_mount_map(manifest), + ) + + +def test_vercel_from_state_rejects_custom_mount_before_deepcopy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + manifest = _vercel_s3_manifest(package_module) + deepcopy_called = False + + class CustomS3Mount(S3Mount): + type: Literal["custom_vercel_s3_mount"] = "custom_vercel_s3_mount" # type: ignore[assignment] + + def __deepcopy__(self, memo: dict[int, Any] | None = None) -> CustomS3Mount: + _ = memo + nonlocal deepcopy_called + deepcopy_called = True + raise AssertionError("custom mount deepcopy must not run") + + state = vercel_module.VercelSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + trusted_mount = CustomS3Mount( + bucket="test-bucket", + mount_path=Path("/vercel/sandbox/remote"), + mount_strategy=package_module.VercelCloudBucketMountStrategy(), + ) + + with pytest.raises(MountConfigError, match="sandbox mount configuration is invalid"): + vercel_module.VercelSandboxSession.from_state( + state, + trusted_s3_mounts={"/vercel/sandbox/remote": trusted_mount}, + trusted_manifest=manifest, + ) + + assert deepcopy_called is False + + +@pytest.mark.parametrize("mismatch", ["logical_path", "root"]) +def test_vercel_from_state_rejects_mismatched_trusted_mount_topology( + monkeypatch: pytest.MonkeyPatch, + mismatch: str, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + mount = S3Mount( + bucket="test-bucket", + mount_path=Path("/vercel/sandbox/shared"), + mount_strategy=package_module.VercelCloudBucketMountStrategy(), + ) + trusted_manifest = Manifest( + root="/vercel/sandbox", + entries={"trusted": mount}, + ) + state_manifest = Manifest( + root=("/vercel" if mismatch == "root" else "/vercel/sandbox"), + entries={("declared" if mismatch == "logical_path" else "trusted"): mount}, + ) + state = vercel_module.VercelSandboxSessionState( + manifest=state_manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + + with pytest.raises(MountConfigError, match="topology must match"): + vercel_module.VercelSandboxSession.from_state( + state, + trusted_s3_mounts=vercel_module._vercel_s3_mount_map(trusted_manifest), + trusted_manifest=trusted_manifest, + ) + + +@pytest.mark.parametrize( + ("allow_s3_credential_exposure", "mismatched_topology"), + [(False, False), (True, True)], +) +def test_vercel_from_state_redacts_trusted_mount_credentials_from_failure_tracebacks( + monkeypatch: pytest.MonkeyPatch, + allow_s3_credential_exposure: bool, + mismatched_topology: bool, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + sentinel = "trusted-from-state-secret" + manifest = _vercel_s3_manifest(package_module) + trusted_s3_mounts = vercel_module._vercel_s3_mount_map(manifest) + trusted_mount = next(iter(trusted_s3_mounts.values())).model_copy(deep=True) + trusted_mount.access_key_id = "trusted-access-key" + trusted_mount.secret_access_key = sentinel + if mismatched_topology: + trusted_mount.bucket = "different-bucket" + trusted_s3_mounts = {next(iter(trusted_s3_mounts)): trusted_mount} + state = vercel_module.VercelSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + vercel_module.VercelSandboxSession.from_state( + state, + allow_s3_credential_exposure=allow_s3_credential_exposure, + trusted_s3_mounts=trusted_s3_mounts, + trusted_manifest=manifest, + ) + + assert sentinel not in str(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.parametrize( + ("allow_s3_credential_exposure", "mismatched_topology"), + [(False, False), (True, True)], +) +def test_vercel_constructor_redacts_trusted_mount_credentials_from_failure_tracebacks( + monkeypatch: pytest.MonkeyPatch, + allow_s3_credential_exposure: bool, + mismatched_topology: bool, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + sentinel = "trusted-constructor-secret" + manifest = _vercel_s3_manifest(package_module) + trusted_s3_mounts = vercel_module._vercel_s3_mount_map(manifest) + trusted_mount = next(iter(trusted_s3_mounts.values())).model_copy(deep=True) + trusted_mount.access_key_id = "trusted-access-key" + trusted_mount.secret_access_key = sentinel + if mismatched_topology: + trusted_mount.bucket = "different-bucket" + trusted_s3_mounts = {next(iter(trusted_s3_mounts)): trusted_mount} + state = vercel_module.VercelSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + vercel_module.VercelSandboxSession( + state=state, + allow_s3_credential_exposure=allow_s3_credential_exposure, + trusted_s3_mounts=trusted_s3_mounts, + trusted_manifest=manifest, + ) + + assert sentinel not in str(exc_info.value) + assert sentinel not in repr(exc_info.value.args) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + traceback = exc_info.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["exec", "read", "write", "resolve_exposed_port"]) +async def test_vercel_protected_session_public_operations_redact_provider_failures( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module, credentials=True), + options=vercel_module.VercelSandboxClientOptions( + allow_s3_credential_exposure=True, + exposed_ports=(3000,), + ), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + sentinel = f"public-{operation}-provider-secret" + source_error = RuntimeError(sentinel) + + async def fail_async(*args: object, **kwargs: object) -> Any: + _ = (args, kwargs) + raise source_error + + if operation == "exec": + monkeypatch.setattr(sandbox, "run_command", fail_async) + + async def invoke() -> object: + return await session.exec("true", shell=False) + + elif operation == "read": + monkeypatch.setattr(sandbox, "read_file", fail_async) + + async def invoke() -> object: + return await session.read(Path("/vercel/sandbox/file.txt")) + + elif operation == "write": + monkeypatch.setattr(sandbox, "write_files", fail_async) + + async def invoke() -> object: + return await session.write( + Path("/vercel/sandbox/file.txt"), + io.BytesIO(b"content"), + ) + + else: + + def fail_domain(port: int) -> str: + _ = port + raise source_error + + monkeypatch.setattr(sandbox, "domain", fail_domain) + + async def invoke() -> object: + return await session.resolve_exposed_port(3000) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await invoke() + + assert sentinel not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + _assert_base_exception_slots_cleared(source_error) + traceback = exc_info.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio async def test_vercel_create_requires_explicit_s3_credential_exposure( monkeypatch: pytest.MonkeyPatch, @@ -562,14 +897,184 @@ async def test_vercel_create_requires_explicit_s3_credential_exposure( vercel_module = _load_vercel_module(monkeypatch) package_module = importlib.import_module("agents.extensions.sandbox.vercel") client = vercel_module.VercelSandboxClient() + manifest = _vercel_s3_manifest(package_module, credentials=True) - with pytest.raises(MountConfigError, match="allow_s3_credential_exposure"): + with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: await client.create( - manifest=_vercel_s3_manifest(package_module, credentials=True), + manifest=manifest, options=vercel_module.VercelSandboxClientOptions(), ) assert _FakeAsyncSandbox.create_calls == [] + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert "test-secret-key" not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_vercel_injected_session_accepts_unchanged_s3_manifest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + client = vercel_module.VercelSandboxClient() + session = await client.create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=session), + run_state=None, + ) + + manager.acquire_agent(agent) + restored = await manager.ensure_session( + agent=agent, + capabilities=[Capability(type="noop")], + is_resumed_state=False, + ) + + assert restored is session + + +@pytest.mark.asyncio +async def test_vercel_injected_session_revalidates_preexisting_s3_topology_mutation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + mount = cast(S3Mount, session.state.manifest.entries["remote"]) + mount.bucket = "tampered-bucket" + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=session), + run_state=None, + ) + + manager.acquire_agent(agent) + with pytest.raises(MountConfigError, match="dynamic manifest application"): + await manager.ensure_session( + agent=agent, + capabilities=[Capability(type="noop")], + is_resumed_state=False, + ) + + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + assert sandbox.write_files_calls == [] + + +@pytest.mark.asyncio +async def test_vercel_injected_session_applies_non_mount_delta_with_fixed_s3_topology( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + client = vercel_module.VercelSandboxClient() + session = await client.create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=session), + run_state=None, + ) + + manager.acquire_agent(agent) + restored = await manager.ensure_session( + agent=agent, + capabilities=[_AddFileCapability()], + is_resumed_state=False, + ) + + assert restored is session + assert restored.state.manifest.entries["capability.txt"] == File(content=b"capability") + assert sandbox.write_files_calls == [ + [{"path": "/vercel/sandbox/capability.txt", "content": b"capability"}] + ] + session._inner._runtime_assert_s3_mount_topology() + + +@pytest.mark.asyncio +async def test_vercel_live_manifest_update_uses_one_running_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + running_calls = 0 + + async def running_once() -> bool: + nonlocal running_calls + running_calls += 1 + if running_calls > 1: + raise AssertionError("live manifest processing queried running state more than once") + return True + + monkeypatch.setattr(session, "running", running_once) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + + update = await SandboxRuntimeSessionManager._process_live_session_manifest( + agent=agent, + capabilities=[_AddFileCapability()], + session=session, + ) + + assert running_calls == 1 + assert update.processed_manifest is not None + assert update.processed_manifest.entries["capability.txt"] == File(content=b"capability") + assert update.entries_to_apply == [ + (Path("/vercel/sandbox/capability.txt"), File(content=b"capability")) + ] + + +@pytest.mark.asyncio +async def test_vercel_stopped_injected_session_rejects_non_mount_delta_before_state_update( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + client = vercel_module.VercelSandboxClient() + session = await client.create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + sandbox.status = "stopped" + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=session), + run_state=None, + ) + + manager.acquire_agent(agent) + with pytest.raises(MountConfigError, match="must be running"): + await manager.ensure_session( + agent=agent, + capabilities=[_AddFileCapability()], + is_resumed_state=False, + ) + + assert "capability.txt" not in session.state.manifest.entries + assert sandbox.write_files_calls == [] + assert len(_FakeAsyncSandbox.create_calls) == 1 + session._inner._runtime_assert_s3_mount_topology() @pytest.mark.asyncio @@ -591,6 +1096,95 @@ async def test_vercel_create_revalidates_mutated_s3_mount( assert _FakeAsyncSandbox.create_calls == [] +@pytest.mark.asyncio +async def test_vercel_credential_opt_in_does_not_allow_signed_endpoint_urls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + manifest = _vercel_s3_manifest(package_module, credentials=True) + mount = cast(S3Mount, manifest.entries["remote"]) + mount.endpoint_url = "https://example.test?signature=endpoint-secret" + + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + await vercel_module.VercelSandboxClient().create( + manifest=manifest, + options=vercel_module.VercelSandboxClientOptions( + allow_s3_credential_exposure=True, + ), + ) + + assert _FakeAsyncSandbox.create_calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid_configuration", ["partial_credentials", "workspace_root"]) +async def test_vercel_credential_opt_in_redacts_provider_validation_errors( + monkeypatch: pytest.MonkeyPatch, + invalid_configuration: str, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + sentinel = "vercel-validation-secret" + manifest = _vercel_s3_manifest(package_module, credentials=True) + mount = cast(S3Mount, manifest.entries["remote"]) + mount.secret_access_key = sentinel + if invalid_configuration == "partial_credentials": + mount.access_key_id = None + else: + manifest.root = "/custom-workspace" + mount.mount_path = Path("/custom-workspace") + + with pytest.raises(MountConfigError) as exc: + await vercel_module.VercelSandboxClient().create( + manifest=manifest, + options=vercel_module.VercelSandboxClientOptions( + allow_s3_credential_exposure=True, + ), + ) + + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + assert _FakeAsyncSandbox.create_calls == [] + + +@pytest.mark.asyncio +async def test_vercel_apply_manifest_uses_central_mount_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + state = vercel_module.VercelSandboxSessionState( + session_id="00000000-0000-0000-0000-000000000300", + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-central-validation", + ) + sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-central-validation") + session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + await session.apply_manifest() + + assert sandbox.run_command_calls == [] + + @pytest.mark.asyncio async def test_vercel_rejects_root_and_overlapping_s3_mounts( monkeypatch: pytest.MonkeyPatch, @@ -718,9 +1312,15 @@ async def test_vercel_s3_mount_is_create_time_only_and_credentials_are_not_seria "remote": remote_mount, } write_call_count = len(sandbox.write_files_calls) - with pytest.raises(MountConfigError, match="dynamic manifest application"): + with pytest.raises(MountConfigError, match="dynamic manifest application") as exc: await session.apply_manifest(only_ephemeral=True) assert len(sandbox.write_files_calls) == write_call_count + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert "test-secret-key" not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next session.state.manifest.entries.pop("remote") mutated_payload = client.serialize_session_state(session.state) @@ -1033,6 +1633,33 @@ async def test_vercel_s3_nested_activation_serializes_workspace_commands( await session.shutdown() +@pytest.mark.asyncio +async def test_vercel_s3_mount_lock_does_not_leak_to_child_tasks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + inner = session._inner + child_entered = asyncio.Event() + + async def enter_from_child() -> None: + async with inner._s3_mount_operation(force_lock=True): + child_entered.set() + + async with inner._s3_mount_operation(force_lock=True): + child_task = asyncio.create_task(enter_from_child()) + await asyncio.sleep(0) + assert not child_entered.is_set() + + await asyncio.wait_for(child_task, timeout=1) + assert child_entered.is_set() + await session.shutdown() + + @pytest.mark.asyncio async def test_vercel_s3_manifest_sanitization_preserves_typed_environment( monkeypatch: pytest.MonkeyPatch, @@ -1243,6 +1870,40 @@ async def test_vercel_s3_aclose_retries_failed_transition_stop( assert len(_FakeAsyncSandbox.create_calls) == create_count +@pytest.mark.asyncio +async def test_vercel_s3_shutdown_preserves_first_stop_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + _queue_successful_s3_mounts(sandbox) + sandbox.command_results.update( + { + "/usr/bin/findmnt": [ + _FakeCommandFinished(stdout="mountpoint-s3"), + _FakeCommandFinished(stdout="mountpoint-s3"), + ], + "/usr/bin/umount": [_FakeCommandFinished(stderr="busy", exit_code=32)], + } + ) + sandbox.stop_failures = [ + RuntimeError("first stop failed"), + RuntimeError("second stop failed"), + ] + await session.start() + + with pytest.raises(RuntimeError, match="first stop failed"): + await session.shutdown() + + assert sandbox.stop_calls == 2 + assert sandbox.stop_blocking_calls == [True, True] + + @pytest.mark.asyncio async def test_vercel_s3_missing_tracked_mount_stops_session( monkeypatch: pytest.MonkeyPatch, @@ -1303,7 +1964,7 @@ async def test_vercel_s3_mount_disappearing_during_unmount_stops_session( @pytest.mark.asyncio -async def test_vercel_s3_unexpected_persist_error_stops_session( +async def test_vercel_s3_unexpected_persist_error_restores_mount( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) @@ -1315,7 +1976,7 @@ async def test_vercel_s3_unexpected_persist_error_stops_session( options=vercel_module.VercelSandboxClientOptions(), ) sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) - _queue_successful_s3_mounts(sandbox) + _queue_successful_s3_mounts(sandbox, count=2) await session.start() sandbox.command_results.update( { @@ -1333,12 +1994,19 @@ async def missing_archive(_path: str, *, cwd: str | None = None) -> bytes | None with pytest.raises(vercel_module.WorkspaceReadNotFoundError): await session.persist_workspace() - assert sandbox.stop_calls == 1 - assert session._inner._sandbox is None - with pytest.raises(vercel_module.WorkspaceStartError) as exc_info: - await session.exec("true", shell=False) - assert exc_info.value.context["reason"] == "mount_transition_failed" + assert sandbox.stop_calls == 0 + assert session._inner._sandbox is sandbox + assert session._inner._active_s3_mount_paths == {"/vercel/sandbox/remote"} + assert session._inner._detached_s3_mount_paths == set() + assert (await session.exec("true", shell=False)).ok() + sandbox.command_results.update( + { + "/usr/bin/findmnt": [_FakeCommandFinished(stdout="mountpoint-s3")], + "/usr/bin/umount": [_FakeCommandFinished()], + } + ) await session.shutdown() + assert sandbox.stop_calls == 1 @pytest.mark.asyncio @@ -1523,7 +2191,7 @@ async def test_vercel_s3_closed_session_does_not_recreate_sandbox( @pytest.mark.asyncio -async def test_vercel_s3_mount_cancellation_stops_and_marks_session_unusable( +async def test_vercel_s3_mount_cancellation_settles_and_restores_mount( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) @@ -1535,7 +2203,7 @@ async def test_vercel_s3_mount_cancellation_stops_and_marks_session_unusable( options=vercel_module.VercelSandboxClientOptions(), ) sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) - _queue_successful_s3_mounts(sandbox) + _queue_successful_s3_mounts(sandbox, count=2) output_started = asyncio.Event() hold_output = asyncio.Event() @@ -1556,15 +2224,24 @@ async def stdout(self) -> str: stop_task = asyncio.create_task(session.stop()) await asyncio.wait_for(output_started.wait(), timeout=1) stop_task.cancel() + hold_output.set() with pytest.raises(asyncio.CancelledError): await stop_task - create_count = len(_FakeAsyncSandbox.create_calls) - with pytest.raises(vercel_module.WorkspaceStartError, match="failed to start session"): - await session.exec("true", shell=False) - assert sandbox.stop_calls == 1 - assert len(_FakeAsyncSandbox.create_calls) == create_count + assert sandbox.stop_calls == 0 + assert session._inner._sandbox is sandbox + assert session._inner._active_s3_mount_paths == {"/vercel/sandbox/remote"} + assert session._inner._detached_s3_mount_paths == set() + assert session._inner._s3_mount_failure is None + assert (await session.exec("true", shell=False)).ok() + sandbox.command_results.update( + { + "/usr/bin/findmnt": [_FakeCommandFinished(stdout="mountpoint-s3")], + "/usr/bin/umount": [_FakeCommandFinished()], + } + ) await session.shutdown() + assert sandbox.stop_calls == 1 @pytest.mark.asyncio @@ -1606,7 +2283,7 @@ async def test_vercel_s3_shutdown_cancellation_finishes_stop_and_marks_session_u @pytest.mark.asyncio -async def test_vercel_s3_archive_cancellation_stops_detached_session( +async def test_vercel_s3_archive_cancellation_restores_mount( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) @@ -1618,7 +2295,7 @@ async def test_vercel_s3_archive_cancellation_stops_detached_session( options=vercel_module.VercelSandboxClientOptions(), ) sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) - _queue_successful_s3_mounts(sandbox) + _queue_successful_s3_mounts(sandbox, count=2) sandbox.command_results.update( { "/usr/bin/findmnt": [_FakeCommandFinished(stdout="mountpoint-s3")], @@ -1637,12 +2314,20 @@ async def test_vercel_s3_archive_cancellation_stops_detached_session( with pytest.raises(asyncio.CancelledError): await stop_task - create_count = len(_FakeAsyncSandbox.create_calls) - with pytest.raises(vercel_module.WorkspaceStartError, match="failed to start session"): - await session.exec("true", shell=False) - assert sandbox.stop_calls == 1 - assert len(_FakeAsyncSandbox.create_calls) == create_count + assert sandbox.stop_calls == 0 + assert session._inner._sandbox is sandbox + assert session._inner._active_s3_mount_paths == {"/vercel/sandbox/remote"} + assert session._inner._detached_s3_mount_paths == set() + assert session._inner._s3_mount_failure is None + assert (await session.exec("true", shell=False)).ok() + sandbox.command_results.update( + { + "/usr/bin/findmnt": [_FakeCommandFinished(stdout="mountpoint-s3")], + "/usr/bin/umount": [_FakeCommandFinished()], + } + ) await session.shutdown() + assert sandbox.stop_calls == 1 @pytest.mark.asyncio @@ -1874,7 +2559,8 @@ async def test_vercel_s3_mount_failure_redacts_full_activation_traceback( "/usr/bin/find": [_FakeCommandFinished()], } secrets = ("test-access-key", "test-secret-key", "test-session-token") - provider_error = _FakeVercelSandboxRateLimitError(f"provider rejected {secrets[1]}") + transformed_secret = "test%2Dsecret%2Dkey" + provider_error = _FakeVercelSandboxRateLimitError(f"provider rejected {transformed_secret}") original_run_command = sandbox.run_command def assert_activation_traceback_is_redacted(error: BaseException) -> None: @@ -1910,9 +2596,9 @@ async def fail_command( with pytest.raises(MountCommandError) as exc_info: await session.start() - assert exc_info.value.context["stderr"] == ( - "_FakeVercelSandboxRateLimitError: provider rejected REDACTED" - ) + assert exc_info.value.context["stderr"] == "sandbox provider command failed" + assert transformed_secret not in str(exc_info.value) + assert transformed_secret not in repr(exc_info.value.context) assert exc_info.value.retryable is True assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None @@ -1923,6 +2609,140 @@ async def fail_command( assert sandbox.stop_calls == 1 +@pytest.mark.asyncio +async def test_vercel_s3_mount_failure_discards_transformed_stderr( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module, credentials=True), + options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + transformed_secret = "test%2Dsecret%2Dkey" + sandbox.command_results = { + "/usr/bin/test": [_FakeCommandFinished()], + "/usr/bin/rpm": [_FakeCommandFinished(stdout="1.21.0")], + "/usr/bin/mount-s3": [ + _FakeCommandFinished( + stderr=f"provider echoed {transformed_secret}", + exit_code=1, + ) + ], + "/usr/bin/find": [_FakeCommandFinished()], + } + + with pytest.raises(MountCommandError) as exc_info: + await session.start() + + assert exc_info.value.context["stderr"] == "sandbox provider command failed" + assert exc_info.value.context["exit_code"] == 1 + assert transformed_secret not in str(exc_info.value) + assert transformed_secret not in repr(exc_info.value.context) + assert sandbox.stop_calls == 1 + + +@pytest.mark.asyncio +async def test_vercel_s3_mount_failure_ignores_hostile_exception_descriptors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module, credentials=True), + options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + sandbox.command_results = { + "/usr/bin/test": [_FakeCommandFinished()], + "/usr/bin/rpm": [_FakeCommandFinished(stdout="1.21.0")], + "/usr/bin/find": [_FakeCommandFinished()], + } + + class HostileProviderError(_FakeVercelSandboxRateLimitError): + pass + + _install_hostile_exception_descriptors( + HostileProviderError, + reject_stringification=True, + ) + provider_error = HostileProviderError("provider returned test-secret-key") + original_run_command = sandbox.run_command + + async def fail_command( + cmd: str, + args: list[str] | None = None, + *, + cwd: str | None = None, + env: dict[str, str] | None = None, + sudo: bool = False, + ) -> _FakeCommandFinished: + if cmd == "/usr/bin/mount-s3": + raise provider_error + return await original_run_command(cmd, args, cwd=cwd, env=env, sudo=sudo) + + monkeypatch.setattr(sandbox, "run_command", fail_command) + + with pytest.raises(MountCommandError) as exc_info: + await session.start() + + assert type(exc_info.value) is MountCommandError + assert exc_info.value.context["stderr"] == "sandbox provider command failed" + assert exc_info.value.retryable is True + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + _assert_base_exception_slots_cleared(provider_error) + assert sandbox.stop_calls == 1 + + +@pytest.mark.asyncio +async def test_vercel_s3_shutdown_redacts_provider_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module, credentials=True), + options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + _queue_successful_s3_mounts(sandbox) + await session.start() + provider_error = RuntimeError("provider cleanup failed with test-secret-key") + original_run_command = sandbox.run_command + + async def fail_findmnt( + cmd: str, + args: list[str] | None = None, + *, + cwd: str | None = None, + env: dict[str, str] | None = None, + sudo: bool = False, + ) -> _FakeCommandFinished: + if cmd == "/usr/bin/findmnt": + raise provider_error + return await original_run_command(cmd, args, cwd=cwd, env=env, sudo=sudo) + + monkeypatch.setattr(sandbox, "run_command", fail_findmnt) + + with pytest.raises(MountCommandError) as exc_info: + await session.shutdown() + + assert "test-secret-key" not in str(exc_info.value) + assert "test-secret-key" not in repr(exc_info.value.context) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + _assert_base_exception_slots_cleared(provider_error) + traceback = exc_info.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert "test-secret-key" not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + assert sandbox.stop_calls == 1 + + @pytest.mark.asyncio async def test_vercel_s3_mount_cancellation_redacts_full_activation_traceback( monkeypatch: pytest.MonkeyPatch, @@ -1961,6 +2781,66 @@ async def test_vercel_s3_mount_cancellation_redacts_full_activation_traceback( assert sandbox.stop_calls == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize("cancellation_source", ["run_command", "stdout"]) +async def test_vercel_s3_mount_cancellation_ignores_hostile_exception_descriptors( + monkeypatch: pytest.MonkeyPatch, + cancellation_source: Literal["run_command", "stdout"], +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module, credentials=True), + options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), + ) + sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) + sandbox.command_results = { + "/usr/bin/test": [_FakeCommandFinished()], + "/usr/bin/rpm": [_FakeCommandFinished(stdout="1.21.0")], + "/usr/bin/find": [_FakeCommandFinished()], + } + + class HostileCancelledError(asyncio.CancelledError): + pass + + _install_hostile_exception_descriptors( + HostileCancelledError, + reject_stringification=True, + ) + provider_error = HostileCancelledError("provider cancelled with test-secret-key") + original_run_command = sandbox.run_command + + class CancelledOutput(_FakeCommandFinished): + async def stdout(self) -> str: + raise provider_error + + async def cancel_command( + cmd: str, + args: list[str] | None = None, + *, + cwd: str | None = None, + env: dict[str, str] | None = None, + sudo: bool = False, + ) -> _FakeCommandFinished: + if cmd == "/usr/bin/mount-s3": + raise provider_error + return await original_run_command(cmd, args, cwd=cwd, env=env, sudo=sudo) + + if cancellation_source == "run_command": + monkeypatch.setattr(sandbox, "run_command", cancel_command) + else: + sandbox.command_results["/usr/bin/mount-s3"] = [CancelledOutput()] + + with pytest.raises(asyncio.CancelledError) as exc_info: + await session.start() + + assert type(exc_info.value) is asyncio.CancelledError + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + _assert_base_exception_slots_cleared(provider_error) + assert sandbox.stop_calls == 1 + + @pytest.mark.asyncio async def test_vercel_exec_timeout_includes_output_collection_and_releases_mount_lock( monkeypatch: pytest.MonkeyPatch, @@ -2729,9 +3609,39 @@ async def test_vercel_serialized_session_state_omits_token_and_resume_uses_live_ } assert restored.network_policy == network_policy assert _FakeAsyncSandbox.get_calls[-1]["token"] == "token-from-client" + assert len(_FakeAsyncSandbox.create_calls) == 1 assert resumed._inner.state.sandbox_id == session._inner.state.sandbox_id +@pytest.mark.asyncio +async def test_vercel_deserialize_discards_surviving_resource_identity_after_mount_erasure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + client = vercel_module.VercelSandboxClient() + session = await client.create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), + ) + original_sandbox_id = session.state.sandbox_id + payload = client.serialize_session_state(session.state) + cast(dict[str, object], payload["manifest"])["entries"] = {} + payload.pop("__openai_agents_redacted_mount_authority", None) + payload["s3_mounts_non_resumable"] = False + + restored = client.deserialize_session_state(payload) + assert restored.sandbox_id == "" + assert restored.workspace_root_ready is False + resumed = await client.resume(restored) + + assert restored.sandbox_id == resumed.state.sandbox_id + assert restored.sandbox_id != original_sandbox_id + assert restored.workspace_root_ready is False + assert _FakeAsyncSandbox.get_calls == [] + assert len(_FakeAsyncSandbox.create_calls) == 2 + + @pytest.mark.asyncio async def test_vercel_tar_persistence_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: vercel_module = _load_vercel_module(monkeypatch) @@ -3111,3 +4021,69 @@ async def test_vercel_snapshot_hydrate_replaces_and_stops_superseded_sandbox( assert session.state.sandbox_id == "vercel-sandbox-1" restored = await session.read(Path("restored.txt")) assert restored.read() == b"after" + + +@pytest.mark.asyncio +async def test_vercel_direct_persist_redacts_protected_mount_provider_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + session = await vercel_module.VercelSandboxClient().create( + manifest=_vercel_s3_manifest(package_module, credentials=True), + options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), + ) + inner = cast(Any, session._inner) + sentinel = "direct-vercel-persist-secret" + source_error = RuntimeError(f"provider echoed {sentinel}") + + async def run_operation( + _session: object, + operation: Callable[[], Any], + **_kwargs: object, + ) -> Any: + return await operation() + + async def fail_persist() -> io.IOBase: + raise source_error + + monkeypatch.setattr(vercel_module, "with_ephemeral_mounts_removed", run_operation) + monkeypatch.setattr(inner, "_persist_workspace_internal", fail_persist) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await inner.persist_workspace() + + assert sentinel not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert source_error.args == () + assert source_error.__traceback__ is None + + +@pytest.mark.asyncio +async def test_vercel_client_delete_propagates_redacted_shutdown_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + client = vercel_module.VercelSandboxClient() + session = await client.create( + manifest=_vercel_s3_manifest(package_module, credentials=True), + options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), + ) + sentinel = "vercel-delete-secret" + source_error = RuntimeError(f"provider echoed {sentinel}") + + async def fail_shutdown() -> None: + raise source_error + + monkeypatch.setattr(session._inner, "shutdown", fail_shutdown) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await client.delete(session) + + assert sentinel not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert source_error.args == () + assert source_error.__traceback__ is None diff --git a/tests/sandbox/integration_tests/_helpers.py b/tests/sandbox/integration_tests/_helpers.py index 5eae1c43b2..681001afda 100644 --- a/tests/sandbox/integration_tests/_helpers.py +++ b/tests/sandbox/integration_tests/_helpers.py @@ -200,27 +200,20 @@ def build_manifest_with_all_entry_types(*, workspace_root: Path, source_root: Pa "repo": GitRepo(repo="openai/mock-sandbox-fixture", ref="main"), "mounts/s3": S3Mount( bucket="s3-bucket", - access_key_id="s3-access-key-id", - secret_access_key="s3-secret-access-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), "mounts/gcs": GCSMount( bucket="gcs-bucket", - access_id="gcs-access-id", - secret_access_key="gcs-secret-access-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), "mounts/r2": R2Mount( bucket="r2-bucket", account_id="r2-account-id", - access_key_id="r2-access-key-id", - secret_access_key="r2-secret-access-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), "mounts/azure": AzureBlobMount( account="azure-account", container="azure-container", - account_key="azure-account-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), }, diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index 005dbefd34..b599677757 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -21,6 +21,7 @@ import agents.sandbox.sandboxes.docker as docker_sandbox from agents.sandbox import SandboxPathGrant +from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.entries import ( AzureBlobMount, @@ -55,6 +56,7 @@ from agents.sandbox.materialization import MaterializedFile from agents.sandbox.sandboxes.docker import ( DockerSandboxClient, + DockerSandboxClientOptions, DockerSandboxSession, DockerSandboxSessionState, ) @@ -236,15 +238,41 @@ def __init__(self, container: object) -> None: class _DeleteVolume: def __init__(self) -> None: self.remove_calls = 0 + self._on_remove: Callable[[], None] | None = None + + def bind_remove(self, callback: Callable[[], None]) -> None: + self._on_remove = callback + + def remove(self) -> None: + self.remove_calls += 1 + if self._on_remove is not None: + self._on_remove() + + +class _FailingDeleteVolume(_DeleteVolume): + def __init__(self, error: BaseException) -> None: + super().__init__() + self._error = error def remove(self) -> None: self.remove_calls += 1 + raise self._error class _DeleteVolumeCollection: def __init__(self, volumes: dict[str, _DeleteVolume]) -> None: - self._volumes = volumes + self._volumes: dict[str, _DeleteVolume] = {} self.get_calls: list[str] = [] + for name, volume in volumes.items(): + self.set(name, volume) + + def set(self, name: str, volume: _DeleteVolume) -> None: + self._volumes[name] = volume + + def remove_from_collection() -> None: + self._volumes.pop(name, None) + + volume.bind_remove(remove_from_collection) def get(self, name: str) -> _DeleteVolume: self.get_calls.append(name) @@ -270,6 +298,34 @@ def remove(self, **kwargs: object) -> None: self.remove_calls.append(kwargs) +class _FailingDeleteContainer(_DeleteContainer): + def __init__(self, error: BaseException) -> None: + super().__init__() + self._error = error + + def remove(self, **kwargs: object) -> None: + super().remove(**kwargs) + raise self._error + + +class _FailedStartContainer(_DeleteContainer): + id = "failed-start-container" + + def start(self) -> None: + raise RuntimeError("container startup failed") + + +class _StartedContainer(_DeleteContainer): + id = "started-container" + + def __init__(self) -> None: + super().__init__() + self.start_calls = 0 + + def start(self) -> None: + self.start_calls += 1 + + class _DeleteContainerCollection: def __init__(self, container: _DeleteContainer) -> None: self._container = container @@ -292,6 +348,29 @@ def __init__( self.volumes = _DeleteVolumeCollection(volumes) +class _MissingDeleteContainerCollection: + def get(self, container_id: str) -> object: + _ = container_id + raise docker.errors.NotFound("container not found") + + +class _FailingDeleteContainerCollection: + def __init__(self, error: BaseException) -> None: + self._error = error + self.get_calls: list[str] = [] + + def get(self, container_id: str) -> object: + self.get_calls.append(container_id) + raise self._error + + +class _MissingDeleteDockerClient(_FakeDockerClient): + def __init__(self, *, volumes: dict[str, _DeleteVolume]) -> None: + super().__init__() + self.containers = _MissingDeleteContainerCollection() + self.volumes = _DeleteVolumeCollection(volumes) + + class _HostBackedDockerSession(DockerSandboxSession): def __init__( self, @@ -502,6 +581,18 @@ async def _rm_best_effort(self, path: Path) -> None: await super()._rm_best_effort(path) +@pytest.fixture(autouse=True) +def _trust_recording_mounts_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: + from agents.sandbox import _mount_security + + original = _mount_security._mount_class_is_trusted + monkeypatch.setattr( + _mount_security, + "_mount_class_is_trusted", + lambda mount: isinstance(mount, _RecordingMount) or original(mount), + ) + + class _RecordingMount(Mount): type: str = f"recording_mount_{uuid.uuid4().hex}" mount_strategy: MountStrategy = Field( @@ -2277,25 +2368,26 @@ async def test_docker_delete_removes_generated_docker_volumes() -> None: @pytest.mark.asyncio -async def test_docker_clear_workspace_root_on_resume_preserves_nested_docker_volume_mounts( +async def test_docker_direct_persist_redacts_protected_mount_provider_error( monkeypatch: pytest.MonkeyPatch, ) -> None: - class _LsEntry: - def __init__(self, path: str, kind: EntryKind) -> None: - self.path = path - self.kind = kind - + sentinel = "direct-docker-persist-secret" + source_error = docker.errors.APIError(f"provider echoed {sentinel}") manifest = Manifest( entries={ - "a/b": S3Mount( + "data": S3Mount( bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, mount_strategy=DockerVolumeMountStrategy(driver="rclone"), - ), + ) } ) + container = _DeleteContainer() + docker_client = _DeleteDockerClient(container=container, volumes={}) session = DockerSandboxSession( - docker_client=object(), - container=_ResumeContainer(status="running", workspace_exists=True), + docker_client=cast(object, docker_client), + container=container, state=DockerSandboxSessionState( manifest=manifest, snapshot=NoopSnapshot(id="snapshot"), @@ -2303,170 +2395,870 @@ def __init__(self, path: str, kind: EntryKind) -> None: container_id="container", ), ) - ls_calls: list[Path] = [] - rm_calls: list[tuple[Path, bool]] = [] - - async def _fake_ls(path: Path | str) -> list[_LsEntry]: - rendered = Path(path) - ls_calls.append(rendered) - if rendered == Path("/workspace"): - return [ - _LsEntry("/workspace/a", EntryKind.DIRECTORY), - _LsEntry("/workspace/root.txt", EntryKind.FILE), - ] - if rendered == Path("/workspace/a"): - return [ - _LsEntry("/workspace/a/b", EntryKind.DIRECTORY), - _LsEntry("/workspace/a/local.txt", EntryKind.FILE), - ] - raise AssertionError(f"unexpected ls path: {rendered}") - async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None: - rm_calls.append((Path(path), recursive)) + async def fail_stage_workspace_copy(**_kwargs: object) -> tuple[Path, Path]: + raise source_error - monkeypatch.setattr(session, "ls", _fake_ls) - monkeypatch.setattr(session, "rm", _fake_rm) + monkeypatch.setattr(session, "_stage_workspace_copy", fail_stage_workspace_copy) - await session._clear_workspace_root_on_resume() + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await session.persist_workspace() - assert ls_calls == [Path("/workspace"), Path("/workspace/a")] - assert rm_calls == [ - (Path("/workspace/a/local.txt"), True), - (Path("/workspace/root.txt"), True), - ] + assert sentinel not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert source_error.args == () + assert source_error.__traceback__ is None -def test_docker_volume_name_is_collision_safe_for_separator_aliases() -> None: +@pytest.mark.asyncio +async def test_docker_delete_redacts_first_failure_and_settles_all_volumes( + monkeypatch: pytest.MonkeyPatch, +) -> None: session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") - - assert ( - docker_sandbox._docker_volume_name( - session_id=session_id, - mount_path=Path("/workspace/a_b"), - ) - == "sandbox_12345678123456781234567812345678_e00b2d707edb_workspace_a_b" + sentinel = "delete-boundary-secret" + source_error = RuntimeError(f"shutdown echoed {sentinel}") + secondary_error = RuntimeError("secondary container removal failed") + manifest = Manifest( + entries={ + "left": S3Mount( + bucket="left-bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + "middle": S3Mount( + bucket="middle-bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + "right": S3Mount( + bucket="right-bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } ) - assert ( - docker_sandbox._docker_volume_name( + volume_names = docker_sandbox._docker_volume_names_for_manifest( # noqa: SLF001 + manifest, + session_id=session_id, + ) + first_volume = _FailingDeleteVolume(RuntimeError("secondary volume removal failed")) + second_volume = _DeleteVolume() + third_volume = _DeleteVolume() + container = _FailingDeleteContainer(secondary_error) + docker_client = _DeleteDockerClient( + container=container, + volumes=dict( + zip( + volume_names, + (first_volume, second_volume, third_volume), + strict=True, + ) + ), + ) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + inner = DockerSandboxSession( + docker_client=cast(object, docker_client), + container=container, + state=DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", session_id=session_id, - mount_path=Path("/workspace/a/b"), - ) - == "sandbox_12345678123456781234567812345678_212366248685_workspace_a_b" + ), ) + session = client._wrap_session(inner, instrumentation=client._instrumentation) + async def fail_shutdown() -> None: + raise source_error -def test_docker_volume_name_uses_strictly_safe_suffix_characters() -> None: - assert ( - docker_sandbox._docker_volume_name( - session_id=None, - mount_path=Path("/workspace/data set/@prod"), - ) - == "sandbox_fe44fda0e4f6_workspace_data_set__prod" - ) + monkeypatch.setattr(inner, "shutdown", fail_shutdown) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await client.delete(session) + + assert sentinel not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert source_error.args == () + assert source_error.__traceback__ is None + assert secondary_error.args == ("secondary container removal failed",) + assert container.remove_calls == [{}] + assert first_volume.remove_calls == 1 + assert second_volume.remove_calls == 1 + assert third_volume.remove_calls == 1 @pytest.mark.asyncio -async def test_docker_create_container_rejects_unknown_mount_subclasses( +@pytest.mark.parametrize( + "lookup_error", + [ + docker.errors.NotFound("container not found"), + RuntimeError("container lookup failed"), + ], +) +async def test_docker_delete_runs_shutdown_and_volume_cleanup_after_lookup_failure( monkeypatch: pytest.MonkeyPatch, + lookup_error: BaseException, ) -> None: - container = _ResumeContainer(status="created") - docker_client = _FakeCreateDockerClient(container) - client = DockerSandboxClient(docker_client=cast(object, docker_client)) + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") manifest = Manifest( entries={ - "custom": _RecordingMount(mount_strategy=DockerVolumeMountStrategy(driver="rclone")) + "data": S3Mount( + bucket="bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) } ) + volume_names = docker_sandbox._docker_volume_names_for_manifest( # noqa: SLF001 + manifest, + session_id=session_id, + ) + volume = _DeleteVolume() + container = _DeleteContainer() + docker_client = _DeleteDockerClient( + container=container, + volumes={volume_names[0]: volume}, + ) + failing_containers = _FailingDeleteContainerCollection(lookup_error) + docker_client.containers = failing_containers # type: ignore[assignment] + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + inner = DockerSandboxSession( + docker_client=cast(object, docker_client), + container=container, + state=DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + session_id=session_id, + ), + ) + session = client._wrap_session(inner, instrumentation=client._instrumentation) + shutdown_calls = 0 - monkeypatch.setattr(client, "image_exists", lambda _image: True) - - with pytest.raises( - MountConfigError, - match="docker-volume mounts are not supported for this mount type", - ): - await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) + async def record_shutdown() -> None: + nonlocal shutdown_calls + shutdown_calls += 1 - assert docker_client.containers.calls == [] + monkeypatch.setattr(inner, "shutdown", record_shutdown) + if isinstance(lookup_error, docker.errors.NotFound): + assert await client.delete(session) is session + else: + with pytest.raises(RuntimeError, match="container lookup failed"): + await client.delete(session) -def test_s3_files_mount_rejects_docker_volume_mount() -> None: - with pytest.raises( - MountConfigError, - match="invalid Docker volume driver", - ): - S3FilesMount( - file_system_id="fs-1234567890abcdef0", - mount_strategy=DockerVolumeMountStrategy(driver="rclone"), - ) + assert shutdown_calls == 1 + assert failing_containers.get_calls == ["container"] + assert docker_client.volumes.get_calls == list(volume_names) + assert volume.remove_calls == 1 @pytest.mark.asyncio -async def test_docker_create_container_grants_fuse_for_in_container_rclone_mount( +async def test_docker_create_cleans_generated_volumes_after_start_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: - container = _ResumeContainer(status="created") - docker_client = _FakeCreateDockerClient(container) - client = DockerSandboxClient(docker_client=cast(object, docker_client)) + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") manifest = Manifest( entries={ "data": S3Mount( bucket="bucket", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"s3-secret-access-key": "driver-secret"}, + ), ) } ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + container = _FailedStartContainer() + volume = _DeleteVolume() + docker_client = _DeleteDockerClient( + container=container, + volumes={expected_volume_name: volume}, + ) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) - monkeypatch.setattr(client, "image_exists", lambda _image: True) + async def create_container(*args: object, **kwargs: object) -> _FailedStartContainer: + _ = (args, kwargs) + return container - created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) + monkeypatch.setattr(uuid, "uuid4", lambda: session_id) + monkeypatch.setattr(client, "_create_container", create_container) - assert created is container - assert docker_client.containers.calls == [ - { - "entrypoint": ["tail"], - "image": DEFAULT_PYTHON_SANDBOX_IMAGE, - "detach": True, - "command": ["-f", "/dev/null"], - "environment": {}, - "devices": ["/dev/fuse"], - "cap_add": ["SYS_ADMIN"], - "security_opt": ["apparmor:unconfined"], - } - ] + with pytest.raises(RuntimeError, match="protected mount configuration"): + await client.create( + manifest=manifest, + options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), + ) + + assert container.remove_calls == [{"force": True}] + assert docker_client.volumes.get_calls == [expected_volume_name] + assert volume.remove_calls == 1 @pytest.mark.asyncio -async def test_docker_create_container_grants_sys_admin_for_s3_files_mount( +async def test_docker_create_cleans_generated_volumes_when_container_acquisition_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: - container = _ResumeContainer(status="created") - docker_client = _FakeCreateDockerClient(container) - client = DockerSandboxClient(docker_client=cast(object, docker_client)) + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") manifest = Manifest( entries={ - "data": S3FilesMount( - file_system_id="fs-1234567890abcdef0", - mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) } ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + volume = _DeleteVolume() + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) - monkeypatch.setattr(client, "image_exists", lambda _image: True) + async def create_container(*args: object, **kwargs: object) -> object: + _ = (args, kwargs) + raise RuntimeError("container acquisition failed with secret-key") - created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) + monkeypatch.setattr(uuid, "uuid4", lambda: session_id) + monkeypatch.setattr(client, "_create_container", create_container) - assert created is container - assert docker_client.containers.calls == [ - { - "entrypoint": ["tail"], - "image": DEFAULT_PYTHON_SANDBOX_IMAGE, - "detach": True, - "command": ["-f", "/dev/null"], - "environment": {}, - "cap_add": ["SYS_ADMIN"], - "security_opt": ["apparmor:unconfined"], - } - ] + with pytest.raises(RuntimeError, match="protected mount configuration"): + await client.create( + manifest=manifest, + options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), + ) + + assert docker_client.volumes.get_calls == [expected_volume_name] + assert volume.remove_calls == 1 + + +@pytest.mark.asyncio +async def test_docker_create_cleans_resources_after_post_start_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + container = _StartedContainer() + volume = _DeleteVolume() + docker_client = _DeleteDockerClient( + container=container, + volumes={expected_volume_name: volume}, + ) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + + async def create_container(*args: object, **kwargs: object) -> _StartedContainer: + _ = (args, kwargs) + return container + + def fail_snapshot_resolution(*args: object, **kwargs: object) -> object: + _ = (args, kwargs) + raise RuntimeError("snapshot resolution failed with secret-key") + + monkeypatch.setattr(uuid, "uuid4", lambda: session_id) + monkeypatch.setattr(client, "_create_container", create_container) + monkeypatch.setattr(docker_sandbox, "resolve_snapshot", fail_snapshot_resolution) + + with pytest.raises(RuntimeError, match="protected mount configuration"): + await client.create( + manifest=manifest, + options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), + ) + + assert container.start_calls == 1 + assert container.remove_calls == [{"force": True}] + assert docker_client.volumes.get_calls == [expected_volume_name] + assert volume.remove_calls == 1 + + +@pytest.mark.asyncio +async def test_docker_resume_uses_fresh_volume_identity_and_cleans_partial_replacement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + replacement_session_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + replacement_volume_name = "sandbox_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_ac6cdb3eb035_workspace_data" + stale_volume = _DeleteVolume() + partial_replacement_volume = _DeleteVolume() + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: stale_volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + persisted_state = DockerSandboxSessionState( + session_id=session_id, + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="missing-container", + workspace_root_ready=True, + ) + payload = client.serialize_session_state(persisted_state) + payload["session_id"] = str(session_id) + state = cast( + DockerSandboxSessionState, + client.deserialize_session_state(payload).rebind_persisted_mount_authority( + manifest, + provider_backend_id="docker", + ), + ) + + async def create_container( + *args: object, session_id: uuid.UUID | None = None, **kwargs: object + ) -> object: + _ = (args, kwargs) + assert session_id == replacement_session_id + assert stale_volume.remove_calls == 0 + docker_client.volumes.set(replacement_volume_name, partial_replacement_volume) + raise RuntimeError("replacement acquisition failed with secret-key") + + monkeypatch.setattr(uuid, "uuid4", lambda: replacement_session_id) + monkeypatch.setattr(client, "_create_container", create_container) + + with pytest.raises(RuntimeError, match="protected mount configuration"): + await client.resume(state) + + assert state.container_id == "" + assert state.session_id == session_id + assert state.workspace_root_ready is False + assert docker_client.volumes.get_calls == [replacement_volume_name] + assert stale_volume.remove_calls == 0 + assert partial_replacement_volume.remove_calls == 1 + assert docker_client.volumes._volumes == {expected_volume_name: stale_volume} + + +@pytest.mark.asyncio +async def test_docker_resume_applies_current_authority_with_fresh_volume_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + replacement_session_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="current-access-key", + secret_access_key="current-secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + replacement_volume_name = "sandbox_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_ac6cdb3eb035_workspace_data" + stale_volume = _DeleteVolume() + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: stale_volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + persisted_state = DockerSandboxSessionState( + session_id=session_id, + manifest=Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="previous-access-key", + secret_access_key="previous-secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="missing-container", + ) + restored_state = client.deserialize_session_state( + client.serialize_session_state(persisted_state) + ) + state = cast( + DockerSandboxSessionState, + restored_state.rebind_persisted_mount_authority( + manifest, + provider_backend_id="docker", + ), + ) + replacement = _StartedContainer() + replacement_volume = _DeleteVolume() + + async def create_container( + image: str, + *, + manifest: Manifest | None = None, + exposed_ports: tuple[int, ...] = (), + session_id: uuid.UUID | None = None, + ) -> _StartedContainer: + _ = (image, exposed_ports) + assert session_id == replacement_session_id + assert stale_volume.remove_calls == 0 + assert manifest is state.manifest + assert manifest is not None + current_mount = manifest.entries["data"] + assert isinstance(current_mount, S3Mount) + assert current_mount.access_key_id == "current-access-key" + assert current_mount.secret_access_key == "current-secret-key" + docker_client.volumes.set(replacement_volume_name, replacement_volume) + return replacement + + monkeypatch.setattr(uuid, "uuid4", lambda: replacement_session_id) + monkeypatch.setattr(client, "_create_container", create_container) + + resumed = await client.resume(state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert state.session_id == replacement_session_id + assert docker_client.volumes.get_calls == [] + assert stale_volume.remove_calls == 0 + assert docker_client.volumes._volumes == { + expected_volume_name: stale_volume, + replacement_volume_name: replacement_volume, + } + + +@pytest.mark.asyncio +async def test_docker_resume_does_not_remove_persisted_volume_selected_by_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + replacement_session_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + stale_volume = _FailingDeleteVolume(AssertionError("persisted volume must not be removed")) + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: stale_volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + state = DockerSandboxSessionState( + session_id=session_id, + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="missing-container", + workspace_root_ready=True, + ) + replacement = _StartedContainer() + + async def create_container( + *args: object, session_id: uuid.UUID | None = None, **kwargs: object + ) -> object: + _ = (args, kwargs) + assert session_id == replacement_session_id + return replacement + + monkeypatch.setattr(uuid, "uuid4", lambda: replacement_session_id) + monkeypatch.setattr(client, "_create_container", create_container) + + resumed = await client.resume(state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert state.container_id == replacement.id + assert state.session_id == replacement_session_id + assert state.workspace_root_ready is False + assert docker_client.volumes.get_calls == [] + assert stale_volume.remove_calls == 0 + assert docker_client.volumes._volumes == {expected_volume_name: stale_volume} + + +@pytest.mark.asyncio +async def test_docker_resume_preserves_direct_credentialless_volume_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + existing_volume = _DeleteVolume() + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: existing_volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + state = DockerSandboxSessionState( + session_id=session_id, + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="missing-container", + ) + replacement = _StartedContainer() + + async def create_container( + *args: object, session_id: uuid.UUID | None = None, **kwargs: object + ) -> _StartedContainer: + _ = (args, kwargs) + assert session_id == state.session_id + return replacement + + monkeypatch.setattr(client, "_create_container", create_container) + + resumed = await client.resume(state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert state.session_id == session_id + assert docker_client.volumes.get_calls == [] + assert existing_volume.remove_calls == 0 + assert docker_client.volumes._volumes == {expected_volume_name: existing_volume} + + +@pytest.mark.asyncio +async def test_docker_resume_cancellation_cleans_partial_volume_before_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + first_replacement_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + second_replacement_id = uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + first_volume_name = "sandbox_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_ac6cdb3eb035_workspace_data" + second_volume_name = "sandbox_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_ac6cdb3eb035_workspace_data" + stale_volume = _DeleteVolume() + partial_volume = _DeleteVolume() + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: stale_volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + state = DockerSandboxSessionState( + session_id=session_id, + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="missing-container", + workspace_root_ready=True, + ) + replacement = _StartedContainer() + replacement_volume = _DeleteVolume() + create_attempts = 0 + + async def create_container( + *args: object, session_id: uuid.UUID | None = None, **kwargs: object + ) -> _StartedContainer: + nonlocal create_attempts + _ = (args, kwargs) + create_attempts += 1 + if create_attempts == 1: + assert session_id == first_replacement_id + docker_client.volumes.set(first_volume_name, partial_volume) + raise asyncio.CancelledError() + assert session_id == second_replacement_id + assert partial_volume.remove_calls == 1 + docker_client.volumes.set(second_volume_name, replacement_volume) + return replacement + + replacement_ids = iter((first_replacement_id, second_replacement_id)) + monkeypatch.setattr(uuid, "uuid4", lambda: next(replacement_ids)) + monkeypatch.setattr(client, "_create_container", create_container) + + with pytest.raises(asyncio.CancelledError): + await client.resume(state) + + assert state.container_id == "missing-container" + assert state.session_id == session_id + assert state.workspace_root_ready is True + assert stale_volume.remove_calls == 0 + assert partial_volume.remove_calls == 1 + + resumed = await client.resume(state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert state.container_id == replacement.id + assert state.session_id == second_replacement_id + assert state.workspace_root_ready is False + assert docker_client.volumes.get_calls == [first_volume_name] + assert partial_volume.remove_calls == 1 + assert docker_client.volumes._volumes == { + expected_volume_name: stale_volume, + second_volume_name: replacement_volume, + } + + +@pytest.mark.asyncio +async def test_docker_resume_removes_replacement_volumes_when_wrapping_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + replacement_session_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + replacement_volume_name = "sandbox_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_ac6cdb3eb035_workspace_data" + replacement = _StartedContainer() + stale_volume = _DeleteVolume() + replacement_volume = _DeleteVolume() + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: stale_volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + state = DockerSandboxSessionState( + session_id=session_id, + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="missing-container", + workspace_root_ready=True, + ) + + async def create_container( + *args: object, session_id: uuid.UUID | None = None, **kwargs: object + ) -> _StartedContainer: + _ = (args, kwargs) + assert session_id == replacement_session_id + assert stale_volume.remove_calls == 0 + docker_client.volumes.set(replacement_volume_name, replacement_volume) + return replacement + + def fail_wrap(*args: object, **kwargs: object) -> object: + _ = (args, kwargs) + raise RuntimeError("instrumentation binding failed with secret-key") + + monkeypatch.setattr(uuid, "uuid4", lambda: replacement_session_id) + monkeypatch.setattr(client, "_create_container", create_container) + monkeypatch.setattr(client, "_wrap_session", fail_wrap) + + with pytest.raises(RuntimeError, match="protected mount configuration"): + await client.resume(state) + + assert state.container_id == "missing-container" + assert state.session_id == session_id + assert state.workspace_root_ready is True + assert replacement.remove_calls == [{"force": True}] + assert docker_client.volumes.get_calls == [replacement_volume_name] + assert stale_volume.remove_calls == 0 + assert replacement_volume.remove_calls == 1 + assert docker_client.volumes._volumes == {expected_volume_name: stale_volume} + + +@pytest.mark.asyncio +async def test_docker_clear_workspace_root_on_resume_preserves_nested_docker_volume_mounts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _LsEntry: + def __init__(self, path: str, kind: EntryKind) -> None: + self.path = path + self.kind = kind + + manifest = Manifest( + entries={ + "a/b": S3Mount( + bucket="bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ) + session = DockerSandboxSession( + docker_client=object(), + container=_ResumeContainer(status="running", workspace_exists=True), + state=DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ), + ) + ls_calls: list[Path] = [] + rm_calls: list[tuple[Path, bool]] = [] + + async def _fake_ls(path: Path | str) -> list[_LsEntry]: + rendered = Path(path) + ls_calls.append(rendered) + if rendered == Path("/workspace"): + return [ + _LsEntry("/workspace/a", EntryKind.DIRECTORY), + _LsEntry("/workspace/root.txt", EntryKind.FILE), + ] + if rendered == Path("/workspace/a"): + return [ + _LsEntry("/workspace/a/b", EntryKind.DIRECTORY), + _LsEntry("/workspace/a/local.txt", EntryKind.FILE), + ] + raise AssertionError(f"unexpected ls path: {rendered}") + + async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None: + rm_calls.append((Path(path), recursive)) + + monkeypatch.setattr(session, "ls", _fake_ls) + monkeypatch.setattr(session, "rm", _fake_rm) + + await session._clear_workspace_root_on_resume() + + assert ls_calls == [Path("/workspace"), Path("/workspace/a")] + assert rm_calls == [ + (Path("/workspace/a/local.txt"), True), + (Path("/workspace/root.txt"), True), + ] + + +def test_docker_volume_name_is_collision_safe_for_separator_aliases() -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + + assert ( + docker_sandbox._docker_volume_name( + session_id=session_id, + mount_path=Path("/workspace/a_b"), + ) + == "sandbox_12345678123456781234567812345678_e00b2d707edb_workspace_a_b" + ) + assert ( + docker_sandbox._docker_volume_name( + session_id=session_id, + mount_path=Path("/workspace/a/b"), + ) + == "sandbox_12345678123456781234567812345678_212366248685_workspace_a_b" + ) + + +def test_docker_volume_name_uses_strictly_safe_suffix_characters() -> None: + assert ( + docker_sandbox._docker_volume_name( + session_id=None, + mount_path=Path("/workspace/data set/@prod"), + ) + == "sandbox_fe44fda0e4f6_workspace_data_set__prod" + ) + + +@pytest.mark.asyncio +async def test_docker_create_container_rejects_unknown_mount_subclasses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest( + entries={ + "custom": _RecordingMount(mount_strategy=DockerVolumeMountStrategy(driver="rclone")) + } + ) + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + with pytest.raises( + MountConfigError, + match="docker-volume mounts are not supported for this mount type", + ): + await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) + + assert docker_client.containers.calls == [] + + +def test_s3_files_mount_rejects_docker_volume_mount() -> None: + with pytest.raises( + MountConfigError, + match="invalid Docker volume driver", + ): + S3FilesMount( + file_system_id="fs-1234567890abcdef0", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + + +@pytest.mark.asyncio +async def test_docker_create_container_grants_fuse_for_in_container_rclone_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) + + assert created is container + assert docker_client.containers.calls == [ + { + "entrypoint": ["tail"], + "image": DEFAULT_PYTHON_SANDBOX_IMAGE, + "detach": True, + "command": ["-f", "/dev/null"], + "environment": {}, + "devices": ["/dev/fuse"], + "cap_add": ["SYS_ADMIN"], + "security_opt": ["apparmor:unconfined"], + } + ] + + +@pytest.mark.asyncio +async def test_docker_create_container_grants_sys_admin_for_s3_files_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest( + entries={ + "data": S3FilesMount( + file_system_id="fs-1234567890abcdef0", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ) + } + ) + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) + + assert created is container + assert docker_client.containers.calls == [ + { + "entrypoint": ["tail"], + "image": DEFAULT_PYTHON_SANDBOX_IMAGE, + "detach": True, + "command": ["-f", "/dev/null"], + "environment": {}, + "cap_add": ["SYS_ADMIN"], + "security_opt": ["apparmor:unconfined"], + } + ] class _ExecRunContainer: @@ -2955,26 +3747,24 @@ async def test_docker_resume_preserves_workspace_readiness_from_state() -> None: client = DockerSandboxClient( docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) ) - - ready_session = await client.resume( - DockerSandboxSessionState( - manifest=Manifest(root="/workspace"), - snapshot=NoopSnapshot(id="snapshot"), - image=DEFAULT_PYTHON_SANDBOX_IMAGE, - container_id="container", - workspace_root_ready=True, - ) + ready_state = DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + workspace_root_ready=True, ) - not_ready_session = await client.resume( - DockerSandboxSessionState( - manifest=Manifest(root="/workspace"), - snapshot=NoopSnapshot(id="snapshot"), - image=DEFAULT_PYTHON_SANDBOX_IMAGE, - container_id="container", - workspace_root_ready=False, - ) + not_ready_state = DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + workspace_root_ready=False, ) + ready_session = await client.resume(ready_state) + not_ready_session = await client.resume(not_ready_state) + assert isinstance(ready_session._inner, DockerSandboxSession) assert ready_session._inner._workspace_root_ready is True assert ready_session._inner.should_provision_manifest_accounts_on_resume() is False @@ -2983,6 +3773,247 @@ async def test_docker_resume_preserves_workspace_readiness_from_state() -> None: assert not_ready_session._inner.should_provision_manifest_accounts_on_resume() is False +@pytest.mark.asyncio +async def test_docker_resume_reconnects_serialized_credentialless_external_mount() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = DockerSandboxClient( + docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) + ) + state = DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ) + + restored = cast( + DockerSandboxSessionState, + client.deserialize_session_state(client.serialize_session_state(state)), + ) + resumed = await client.resume(restored) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert restored.mount_authority_redacted is False + assert restored.mount_authority_rebound is False + assert restored.session_id == state.session_id + assert restored.container_id == "container" + + +@pytest.mark.asyncio +async def test_docker_resume_does_not_reconnect_identity_from_tampered_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="previous-access-key", + secret_access_key="previous-secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = DockerSandboxClient(docker_client=_PositionalOnlyMissingDockerClient()) + state = DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="surviving-container", + ) + original_session_id = state.session_id + payload = client.serialize_session_state(state) + assert payload["container_id"] == "" + protected_session_id = uuid.UUID(str(payload["session_id"])) + assert protected_session_id != original_session_id + payload.pop(REDACTED_MOUNT_AUTHORITY_KEY, None) + payload_manifest = cast(dict[str, object], payload["manifest"]) + cast(dict[str, object], payload_manifest["entries"]).pop("data") + restored = cast(DockerSandboxSessionState, client.deserialize_session_state(payload)) + replacement = _StartedContainer() + + async def create_container(*args: object, **kwargs: object) -> _StartedContainer: + _ = args + assert kwargs["session_id"] == protected_session_id + return replacement + + monkeypatch.setattr(client, "_create_container", create_container) + + resumed = await client.resume(restored) + + assert restored.container_id == replacement.id + assert restored.session_id == protected_session_id + assert isinstance(resumed._inner, DockerSandboxSession) + assert resumed._inner._container is replacement # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_docker_resume_recreates_rebound_authority_for_existing_container( + monkeypatch: pytest.MonkeyPatch, +) -> None: + previous_manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="previous-access-key", + secret_access_key="previous-secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + current_manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="current-access-key", + secret_access_key="current-secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = DockerSandboxClient( + docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) + ) + persisted_state = DockerSandboxSessionState( + manifest=previous_manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ) + restored_state = client.deserialize_session_state( + client.serialize_session_state(persisted_state) + ) + rebound_state = cast( + DockerSandboxSessionState, + restored_state.rebind_persisted_mount_authority( + current_manifest, + provider_backend_id="docker", + ), + ) + replacement_session_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + replacement = _StartedContainer() + + async def create_container(*args: object, **kwargs: object) -> _StartedContainer: + _ = args + assert kwargs["session_id"] == replacement_session_id + manifest = kwargs["manifest"] + assert isinstance(manifest, Manifest) + mount = manifest.entries["data"] + assert isinstance(mount, S3Mount) + assert mount.access_key_id == "current-access-key" + assert mount.secret_access_key == "current-secret-key" + return replacement + + monkeypatch.setattr(uuid, "uuid4", lambda: replacement_session_id) + monkeypatch.setattr(client, "_create_container", create_container) + + resumed = await client.resume(rebound_state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert rebound_state.mount_authority_rebound is True + assert rebound_state.session_id == replacement_session_id + assert rebound_state.container_id == replacement.id + + +@pytest.mark.asyncio +async def test_docker_resume_accepts_live_credentialless_external_mount() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = DockerSandboxClient( + docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) + ) + state = DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ) + + resumed = await client.resume(state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert resumed._inner.state.manifest == manifest + + +@pytest.mark.asyncio +async def test_docker_resume_recreates_direct_state_with_configured_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="current-access-key", + secret_access_key="current-secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = DockerSandboxClient( + docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) + ) + state = DockerSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="existing-container", + ) + replacement_session_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + replacement = _StartedContainer() + + async def create_container(*args: object, **kwargs: object) -> _StartedContainer: + _ = args + assert kwargs["session_id"] == replacement_session_id + assert kwargs["manifest"] == manifest + return replacement + + monkeypatch.setattr(uuid, "uuid4", lambda: replacement_session_id) + monkeypatch.setattr(client, "_create_container", create_container) + + resumed = await client.resume(state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert state.session_id == replacement_session_id + assert state.container_id == replacement.id + assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_docker_resume_reconnects_serialized_credentialless_state() -> None: + container = _ResumeContainer(status="running", container_id="container") + client = DockerSandboxClient(docker_client=_ResumeDockerClient(container)) + state = DockerSandboxSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + workspace_root_ready=True, + ) + + restored = cast( + DockerSandboxSessionState, + client.deserialize_session_state(client.serialize_session_state(state)), + ) + resumed = await client.resume(restored) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert resumed._inner._container is container # noqa: SLF001 + assert restored.container_id == "container" + assert restored.workspace_root_ready is True + + @pytest.mark.asyncio async def test_docker_resume_requires_existing_host_mount_to_match_trusted_state( tmp_path: Path, @@ -3139,22 +4170,20 @@ async def _fake_create_container( @pytest.mark.asyncio -async def test_docker_resume_recovers_workspace_workdir_when_root_already_exists( +async def test_docker_resume_recovers_workspace_workdir_for_direct_state( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="running", workspace_exists=True) client = DockerSandboxClient(docker_client=_ResumeDockerClient(container)) - payload = DockerSandboxSessionState( + state = DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", - workspace_root_ready=True, - ).model_dump(mode="json") - payload.pop("workspace_root_ready") + ) - resumed = await client.resume(client.deserialize_session_state(payload)) + resumed = await client.resume(state) assert isinstance(resumed._inner, DockerSandboxSession) loop = asyncio.get_running_loop() diff --git a/tests/sandbox/test_mount_lifecycle.py b/tests/sandbox/test_mount_lifecycle.py index 4fea072847..3e93392053 100644 --- a/tests/sandbox/test_mount_lifecycle.py +++ b/tests/sandbox/test_mount_lifecycle.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from pathlib import Path from typing import Any, cast @@ -67,6 +68,13 @@ def __init__(self, manifest: _FakeManifest) -> None: class _FakeSession: def __init__(self, manifest: _FakeManifest) -> None: self.state = _FakeState(manifest) + self.shutdown_calls = 0 + + async def shutdown(self) -> None: + self.shutdown_calls += 1 + + async def _terminate_ambiguous_mount_transition(self) -> None: + await self.shutdown() @pytest.mark.asyncio @@ -133,3 +141,224 @@ async def operation() -> bytes: "message": operation_error.message, } assert isinstance(exc_info.value.cause, RuntimeError) + assert session.shutdown_calls == 1 + + +@pytest.mark.asyncio +async def test_with_ephemeral_mounts_removed_restores_after_unexpected_operation_error() -> None: + events: list[str] = [] + mount = _FakeMount(_FakeMountStrategy(events, name="mount")) + session = _FakeSession(_FakeManifest([(mount, Path("/workspace/mount"))])) + operation_error = RuntimeError("unexpected persistence failure") + + async def operation() -> bytes: + events.append("operation") + raise operation_error + + with pytest.raises(RuntimeError) as exc_info: + await with_ephemeral_mounts_removed( + cast(Any, session), + operation, + error_path=Path("/workspace"), + error_cls=WorkspaceArchiveReadError, + operation_error_context_key=None, + ) + + assert exc_info.value is operation_error + assert events == ["teardown:mount", "operation", "restore:mount"] + assert session.shutdown_calls == 0 + + +@pytest.mark.asyncio +async def test_with_ephemeral_mounts_removed_restores_before_ambiguous_shutdown() -> None: + events: list[str] = [] + left = _FakeMount(_FakeMountStrategy(events, name="left")) + right = _FakeMount(_FakeMountStrategy(events, name="right", fail_teardown=True)) + session = _FakeSession( + _FakeManifest( + [ + (left, Path("/workspace/left")), + (right, Path("/workspace/right")), + ] + ) + ) + + async def operation() -> None: + raise AssertionError("operation must not run after teardown failure") + + with pytest.raises(WorkspaceArchiveReadError): + await with_ephemeral_mounts_removed( + cast(Any, session), + operation, + error_path=Path("/workspace"), + error_cls=WorkspaceArchiveReadError, + operation_error_context_key=None, + ) + + assert events == ["teardown:left", "teardown:right", "restore:left"] + assert session.shutdown_calls == 1 + + +@pytest.mark.asyncio +async def test_with_ephemeral_mounts_removed_settles_ambiguous_shutdown_after_cancellation() -> ( + None +): + events: list[str] = [] + shutdown_started = asyncio.Event() + release_shutdown = asyncio.Event() + + class _BlockingShutdownSession(_FakeSession): + async def shutdown(self) -> None: + self.shutdown_calls += 1 + shutdown_started.set() + await release_shutdown.wait() + events.append("shutdown-complete") + + mount = _FakeMount(_FakeMountStrategy(events, name="mount", fail_teardown=True)) + session = _BlockingShutdownSession(_FakeManifest([(mount, Path("/workspace/mount"))])) + + async def operation() -> None: + raise AssertionError("operation must not run after teardown failure") + + task = asyncio.create_task( + with_ephemeral_mounts_removed( + cast(Any, session), + operation, + error_path=Path("/workspace"), + error_cls=WorkspaceArchiveReadError, + operation_error_context_key=None, + ) + ) + await shutdown_started.wait() + task.cancel() + release_shutdown.set() + + with pytest.raises(WorkspaceArchiveReadError): + await task + + assert session.shutdown_calls == 1 + assert events == ["teardown:mount", "shutdown-complete"] + + +@pytest.mark.asyncio +async def test_with_ephemeral_mounts_removed_restores_after_operation_cancellation() -> None: + events: list[str] = [] + mount = _FakeMount(_FakeMountStrategy(events, name="mount")) + session = _FakeSession(_FakeManifest([(mount, Path("/workspace/mount"))])) + operation_started = asyncio.Event() + + async def operation() -> None: + events.append("operation") + operation_started.set() + await asyncio.Event().wait() + + task = asyncio.create_task( + with_ephemeral_mounts_removed( + cast(Any, session), + operation, + error_path=Path("/workspace"), + error_cls=WorkspaceArchiveReadError, + operation_error_context_key=None, + ) + ) + await operation_started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await task + + assert exc_info.value.args == () + assert events == ["teardown:mount", "operation", "restore:mount"] + + +@pytest.mark.asyncio +async def test_with_ephemeral_mounts_removed_settles_cancelled_teardown() -> None: + events: list[str] = [] + teardown_started = asyncio.Event() + release_teardown = asyncio.Event() + + class _BlockingTeardownStrategy(_FakeMountStrategy): + async def teardown_for_snapshot( + self, + mount: object, + session: object, + path: Path, + ) -> None: + _ = (mount, session, path) + events.append("teardown:mount") + teardown_started.set() + await release_teardown.wait() + events.append("teardown-complete:mount") + + mount = _FakeMount(_BlockingTeardownStrategy(events, name="mount")) + session = _FakeSession(_FakeManifest([(mount, Path("/workspace/mount"))])) + + async def operation() -> None: + events.append("operation") + + task = asyncio.create_task( + with_ephemeral_mounts_removed( + cast(Any, session), + operation, + error_path=Path("/workspace"), + error_cls=WorkspaceArchiveReadError, + operation_error_context_key=None, + ) + ) + await teardown_started.wait() + task.cancel() + release_teardown.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert events == ["teardown:mount", "teardown-complete:mount", "restore:mount"] + + +@pytest.mark.asyncio +async def test_with_ephemeral_mounts_removed_settles_cancelled_restore() -> None: + events: list[str] = [] + restore_started = asyncio.Event() + release_restore = asyncio.Event() + + class _BlockingRestoreStrategy(_FakeMountStrategy): + async def restore_after_snapshot( + self, + mount: object, + session: object, + path: Path, + ) -> None: + _ = (mount, session, path) + events.append("restore:mount") + restore_started.set() + await release_restore.wait() + events.append("restore-complete:mount") + + mount = _FakeMount(_BlockingRestoreStrategy(events, name="mount")) + session = _FakeSession(_FakeManifest([(mount, Path("/workspace/mount"))])) + + async def operation() -> None: + events.append("operation") + + task = asyncio.create_task( + with_ephemeral_mounts_removed( + cast(Any, session), + operation, + error_path=Path("/workspace"), + error_cls=WorkspaceArchiveReadError, + operation_error_context_key=None, + ) + ) + await restore_started.wait() + task.cancel() + release_restore.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert events == [ + "teardown:mount", + "operation", + "restore:mount", + "restore-complete:mount", + ] diff --git a/tests/sandbox/test_mount_security.py b/tests/sandbox/test_mount_security.py new file mode 100644 index 0000000000..50057e0851 --- /dev/null +++ b/tests/sandbox/test_mount_security.py @@ -0,0 +1,2614 @@ +from __future__ import annotations + +import asyncio +import builtins +import importlib +from pathlib import Path +from typing import Any, ClassVar, Literal, cast + +import pytest +from pydantic import ConfigDict, PrivateAttr, model_serializer, model_validator + +from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy +from agents.extensions.sandbox.daytona.mounts import DaytonaCloudBucketMountStrategy +from agents.extensions.sandbox.e2b.mounts import E2BCloudBucketMountStrategy +from agents.extensions.sandbox.modal.mounts import ModalCloudBucketMountStrategy +from agents.extensions.sandbox.runloop.mounts import RunloopCloudBucketMountStrategy +from agents.sandbox import Manifest +from agents.sandbox._mount_security import ( + CREDENTIALLESS_MOUNT_AUTHORITY_KEY, + REDACTED_MOUNT_AUTHORITY_KEY, + redact_mount_error_data, + redact_mount_error_data_sync, + sanitize_manifest_mount_authority, + sanitize_raw_session_state_mount_authority, + validate_manifest_mount_credential_boundaries, + validate_mount_activation_credential_boundary, +) +from agents.sandbox.entries import ( + AzureBlobMount, + BaseEntry, + BoxMount, + DockerVolumeMountStrategy, + File, + FuseMountPattern, + GCSMount, + GitRepo, + InContainerMountStrategy, + LocalDir, + LocalFile, + Mount, + MountpointMountPattern, + MountStrategyBase, + R2Mount, + RcloneMountPattern, + S3FilesMount, + S3FilesMountPattern, + S3Mount, +) +from agents.sandbox.entries.mounts.base import InContainerMountAdapter +from agents.sandbox.entries.mounts.patterns import ( + MountPattern, + MountPatternConfig, + RcloneMountConfig, +) +from agents.sandbox.errors import MountConfigError +from agents.sandbox.manifest import Environment +from agents.sandbox.session.sandbox_client import BaseSandboxClient +from agents.sandbox.session.sandbox_session import SandboxSession +from agents.sandbox.session.sandbox_session_state import SandboxSessionState +from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase, SnapshotSpec +from tests.utils.factories import TestSessionState + + +class _SecurityTestClient(BaseSandboxClient[None]): + backend_id = "test" + + async def create( + self, + *, + snapshot: SnapshotSpec | SnapshotBase | None = None, + manifest: Manifest | None = None, + options: None, + ) -> SandboxSession: + _ = (snapshot, manifest, options) + raise AssertionError("create() is not used in these tests") + + async def delete(self, session: SandboxSession) -> SandboxSession: + raise AssertionError(f"delete() is not used in these tests: {session!r}") + + async def resume(self, state: SandboxSessionState) -> SandboxSession: + raise AssertionError(f"resume() is not used in these tests: {state!r}") + + def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: + return self._deserialize_session_state_payload(payload, TestSessionState) + + +class _CustomTokenEntry(BaseEntry): + type: Literal["custom_token_entry"] = "custom_token_entry" + token: str + + async def apply(self, session: Any, dest: Path, base_dir: Path) -> list[Any]: + _ = (session, dest, base_dir) + return [] + + +def _install_hostile_exception_descriptors(error_type: type[BaseException]) -> None: + def get_base_args(error: BaseException) -> tuple[object, ...]: + return cast( + tuple[object, ...], + cast(Any, BaseException.args).__get__(error, type(error)), + ) + + def reject_traceback_access(error: BaseException) -> object: + _ = error + raise AssertionError("provider-defined traceback descriptor was accessed") + + type.__setattr__(error_type, "args", property(get_base_args)) + type.__setattr__(error_type, "__traceback__", property(reject_traceback_access)) + + +class _CustomChildrenEntry(BaseEntry): + type: Literal["custom_children_entry"] = "custom_children_entry" + children: Any + + async def apply(self, session: Any, dest: Path, base_dir: Path) -> list[Any]: + _ = (session, dest, base_dir) + return [] + + +class _CustomCredentialSourceEntry(BaseEntry): + type: Literal["custom_credential_source_entry"] = "custom_credential_source_entry" + content: str + source_token: str + + async def apply(self, session: Any, dest: Path, base_dir: Path) -> list[Any]: + _ = (session, dest, base_dir) + return [] + + +class _DirectCustomMount(Mount): + type: Literal["direct_custom_mount"] = "direct_custom_mount" + bucket: str + api_token: str + + def in_container_adapter(self) -> InContainerMountAdapter: + return InContainerMountAdapter(self) + + def supported_in_container_patterns( + self, + ) -> tuple[builtins.type[RcloneMountPattern], ...]: + return (RcloneMountPattern,) + + def supported_docker_volume_drivers(self) -> frozenset[str]: + return frozenset({"rclone"}) + + async def build_in_container_mount_config( + self, + session: Any, + pattern: MountPattern, + *, + include_config_text: bool, + ) -> MountPatternConfig: + _ = (session, pattern, include_config_text) + return RcloneMountConfig( + remote_name="direct-custom", + remote_path=self.bucket, + remote_kind="s3", + mount_type=self.type, + config_text=f"api_token = {self.api_token}\n", + ) + + +class _CustomPatternStrategy(MountStrategyBase): + type: Literal["custom_pattern_strategy"] = "custom_pattern_strategy" + pattern: dict[str, Any] + api_token: str | None = None + + def validate_mount(self, mount: Any) -> None: + _ = mount + + async def activate(self, mount: Any, session: Any, dest: Path, base_dir: Path) -> list[Any]: + _ = (mount, session, dest, base_dir) + return [] + + async def deactivate(self, mount: Any, session: Any, dest: Path, base_dir: Path) -> None: + _ = (mount, session, dest, base_dir) + + async def teardown_for_snapshot(self, mount: Any, session: Any, path: Path) -> None: + _ = (mount, session, path) + + async def restore_after_snapshot(self, mount: Any, session: Any, path: Path) -> None: + _ = (mount, session, path) + + def build_docker_volume_driver_config( + self, mount: Any + ) -> tuple[str, dict[str, str], bool] | None: + _ = mount + return None + + +class _CustomInContainerStrategy(InContainerMountStrategy): + type: Literal["custom_in_container_strategy"] = "custom_in_container_strategy" # type: ignore[assignment] + + +class _CustomDockerVolumeStrategy(DockerVolumeMountStrategy): + type: Literal["custom_docker_volume_strategy"] = "custom_docker_volume_strategy" # type: ignore[assignment] + + +class _CustomModalCloudBucketStrategy(ModalCloudBucketMountStrategy): + type: Literal["custom_modal_cloud_bucket_strategy"] = "custom_modal_cloud_bucket_strategy" # type: ignore[assignment] + + +def _s3_mount( + *, + strategy: InContainerMountStrategy | DockerVolumeMountStrategy, + credentialed: bool = False, +) -> S3Mount: + return S3Mount( + bucket="example-bucket", + access_key_id="example-access-key" if credentialed else None, + secret_access_key="example-secret-key" if credentialed else None, + mount_strategy=strategy, + ) + + +def test_rejects_explicit_credentials_for_in_container_mounts() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentialed=True, + ) + } + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + validate_manifest_mount_credential_boundaries(manifest) + + assert exc.value.context["credential_fields"] == ( + "access_key_id", + "secret_access_key", + ) + assert "example-secret-key" not in str(exc.value) + assert "example-secret-key" not in repr(exc.value.context) + + +def test_builtin_mount_subclass_is_rejected_by_execution_provenance() -> None: + class CustomS3Mount(S3Mount): + type: Literal["custom_s3_mount"] = "custom_s3_mount" # type: ignore[assignment] + api_token: str | None = None + + def _rclone_required_lines(self, remote_name: str) -> list[str]: + lines = super()._rclone_required_lines(remote_name) + if self.api_token is not None: + lines.append(f"api_token = {self.api_token}") + return lines + + in_container = Manifest( + entries={ + "data": CustomS3Mount( + bucket="example-bucket", + api_token="custom-mount-secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="custom mount implementations"): + validate_manifest_mount_credential_boundaries(in_container) + + external = Manifest( + entries={ + "data": CustomS3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + api_token="custom-mount-secret", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + with pytest.raises(MountConfigError, match="custom mount implementations") as exc_info: + sanitize_manifest_mount_authority(external) + + assert "example-secret-key" not in repr(exc_info.value) + assert "custom-mount-secret" not in repr(exc_info.value) + + +def test_builtin_mount_subclass_rejects_pydantic_extra_configuration() -> None: + class ExtraS3Mount(S3Mount): + type: Literal["extra_s3_mount"] = "extra_s3_mount" # type: ignore[assignment] + model_config = ConfigDict(extra="allow") + + def _rclone_required_lines(self, remote_name: str) -> list[str]: + lines = super()._rclone_required_lines(remote_name) + lines.append(f"api_token = {cast(Any, self).api_token}") + return lines + + mount = ExtraS3Mount.model_validate( + { + "bucket": "example-bucket", + "api_token": "custom-mount-extra-secret", + "mount_strategy": InContainerMountStrategy(pattern=RcloneMountPattern()), + } + ) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc: + validate_manifest_mount_credential_boundaries(Manifest(entries={"data": mount})) + + assert "custom-mount-extra-secret" not in str(exc.value) + + +def test_direct_custom_mount_configuration_is_opaque_authority() -> None: + sentinel = "direct-custom-mount-secret" + mount = _DirectCustomMount( + bucket="bucket", + api_token=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc: + validate_manifest_mount_credential_boundaries(Manifest(entries={"data": mount})) + + assert sentinel not in str(exc.value) + + +def test_behavior_only_mount_subclass_is_rejected_before_config_generation() -> None: + class BehaviorOnlyS3Mount(S3Mount): + type: Literal["behavior_only_s3_mount"] = "behavior_only_s3_mount" # type: ignore[assignment] + config_called: ClassVar[bool] = False + + def _rclone_required_lines(self, remote_name: str) -> list[str]: + type(self).config_called = True + return [f"[{remote_name}]", "type = s3", "env_auth = true"] + + mount = BehaviorOnlyS3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + + with pytest.raises(MountConfigError, match="custom mount implementations"): + validate_manifest_mount_credential_boundaries(Manifest(entries={"data": mount})) + + assert BehaviorOnlyS3Mount.config_called is False + + +def test_custom_mount_is_rejected_before_mount_path_resolution() -> None: + sentinel = "custom-mount-path-secret" + + class CustomPathS3Mount(S3Mount): + type: Literal["custom_path_s3_mount"] = "custom_path_s3_mount" # type: ignore[assignment] + _private_authority: str = PrivateAttr(default=sentinel) + resolver_called: ClassVar[bool] = False + + def _resolve_mount_path_for_root(self, root: Path, dest: Path) -> Path: + _ = (root, dest) + type(self).resolver_called = True + raise RuntimeError(self._private_authority) + + mount = CustomPathS3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc_info: + validate_manifest_mount_credential_boundaries(Manifest(entries={"data": mount})) + + assert CustomPathS3Mount.resolver_called is False + assert sentinel not in repr(exc_info.value) + + +def test_custom_mount_cannot_self_declare_a_trusted_credential_boundary() -> None: + sentinel = "private-custom-secret" + + class SelfDeclaredTrustedS3Mount(S3Mount): + type: Literal["self_declared_trusted_s3_mount"] = "self_declared_trusted_s3_mount" # type: ignore[assignment] + _trusted_application_credential_boundary: ClassVar[bool] = True + _private_credential: str = PrivateAttr(default=sentinel) + config_called: ClassVar[bool] = False + + def _rclone_required_lines(self, remote_name: str) -> list[str]: + type(self).config_called = True + return [ + f"[{remote_name}]", + "type = s3", + f"secret_access_key = {self._private_credential}", + ] + + mount = SelfDeclaredTrustedS3Mount( + bucket="public-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc_info: + validate_manifest_mount_credential_boundaries(Manifest(entries={"data": mount})) + + assert sentinel not in str(exc_info.value) + assert SelfDeclaredTrustedS3Mount.config_called is False + + +def test_direct_custom_mount_configuration_cannot_enter_durable_state() -> None: + sentinel = "direct-custom-durable-secret" + state = TestSessionState( + manifest=Manifest( + entries={ + "data": _DirectCustomMount( + bucket="bucket", + api_token=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError) as exc: + _SecurityTestClient().serialize_session_state(state) + + assert sentinel not in str(exc.value) + assert sentinel not in repr(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +def test_custom_mount_is_rejected_before_durable_serializer_runs() -> None: + sentinel = "custom-mount-serializer-secret" + + class CustomSerializedS3Mount(S3Mount): + type: Literal["custom_serialized_s3_mount"] = "custom_serialized_s3_mount" # type: ignore[assignment] + _private_authority: str = PrivateAttr(default=sentinel) + serializer_called: ClassVar[bool] = False + + @model_serializer(mode="wrap") + def _serialize(self, handler: Any) -> Any: + _ = handler + type(self).serializer_called = True + raise RuntimeError(self._private_authority) + + state = TestSessionState( + manifest=Manifest( + entries={ + "data": CustomSerializedS3Mount( + bucket="bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc_info: + _SecurityTestClient().serialize_session_state(state) + + assert CustomSerializedS3Mount.serializer_called is False + assert sentinel not in repr(exc_info.value) + + +def test_public_mount_error_redactor_discards_untrusted_mount_discriminator() -> None: + sentinel = "custom-mount-type-secret" + + class CustomS3Mount(S3Mount): + type: Literal["custom-mount-type-secret"] = sentinel # type: ignore[assignment] + api_token: str | None = None + + manifest = Manifest( + entries={ + "data": CustomS3Mount( + bucket="example-bucket", + api_token="configured", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + @redact_mount_error_data_sync + def validate(*, manifest: Manifest) -> None: + validate_manifest_mount_credential_boundaries(manifest) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc: + validate(manifest=manifest) + + assert exc.value.context == {} + assert sentinel not in repr(exc.value) + + +def test_public_mount_error_redactor_discards_untrusted_field_names() -> None: + sentinel = "custom-mount-field-secret" + + class ExtraS3Mount(S3Mount): + type: Literal["custom_extra_s3_mount"] = "custom_extra_s3_mount" # type: ignore[assignment] + model_config = ConfigDict(extra="allow") + + mount = ExtraS3Mount.model_validate( + { + "bucket": "example-bucket", + sentinel: "configured", + "mount_strategy": InContainerMountStrategy(pattern=RcloneMountPattern()), + } + ) + manifest = Manifest(entries={"data": mount}) + + @redact_mount_error_data_sync + def validate(*, manifest: Manifest) -> None: + validate_manifest_mount_credential_boundaries(manifest) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc: + validate(manifest=manifest) + + assert exc.value.context == {} + assert sentinel not in repr(exc.value) + + +def test_public_mount_error_redactor_rejects_before_custom_attribute_access() -> None: + class CustomS3Mount(S3Mount): + type: Literal["custom_attribute_s3_mount"] = "custom_attribute_s3_mount" # type: ignore[assignment] + authority_accessed: ClassVar[bool] = False + + def __getattribute__(self, name: str) -> Any: + if name == "access_key_id": + type(self).authority_accessed = True + return super().__getattribute__(name) + + mount = CustomS3Mount( + bucket="example-bucket", + access_key_id="access-key", + secret_access_key="custom-attribute-secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + CustomS3Mount.authority_accessed = False + manifest = Manifest(entries={"data": mount}) + + @redact_mount_error_data_sync + def validate(*, manifest: Manifest) -> None: + validate_manifest_mount_credential_boundaries(manifest) + + with pytest.raises(MountConfigError, match="custom mount implementations"): + validate(manifest=manifest) + + assert CustomS3Mount.authority_accessed is False + + +def test_rejection_redacts_sdk_traceback_frames_without_mutating_trusted_manifest() -> None: + sentinel = "traceback-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError) as exc: + validate_manifest_mount_credential_boundaries(manifest) + + mount = manifest.entries["data"] + assert isinstance(mount, S3Mount) + assert mount.access_key_id == "access-key" + assert mount.secret_access_key == sentinel + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_authority_detection_keeps_invalid_manifest_paths_inside_redaction_boundary() -> None: + sentinel = "invalid-path-secret" + manifest = Manifest( + entries={ + "../data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + @redact_mount_error_data + async def validate(*, manifest: Manifest) -> None: + validate_manifest_mount_credential_boundaries(manifest) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await validate(manifest=manifest) + + assert sentinel not in str(exc.value) + traceback_cursor = exc.value.__traceback__ + while traceback_cursor is not None: + module_name = traceback_cursor.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback_cursor.tb_frame.f_locals) + traceback_cursor = traceback_cursor.tb_next + + +def test_preserves_credentialless_in_container_and_credentialed_docker_mounts() -> None: + anonymous = Manifest( + entries={"data": _s3_mount(strategy=InContainerMountStrategy(pattern=RcloneMountPattern()))} + ) + docker = Manifest( + entries={ + "data": _s3_mount( + strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"vfs-cache-mode": "off"}, + ), + credentialed=True, + ) + } + ) + + validate_manifest_mount_credential_boundaries(anonymous) + validate_manifest_mount_credential_boundaries(docker, provider_backend_id="docker") + + +@pytest.mark.parametrize( + ("backend_id", "strategy"), + [ + ("blaxel", BlaxelCloudBucketMountStrategy()), + ("daytona", DaytonaCloudBucketMountStrategy()), + ("e2b", E2BCloudBucketMountStrategy()), + ("runloop", RunloopCloudBucketMountStrategy()), + ], +) +def test_preserves_credentialless_hosted_mount_strategies( + backend_id: str, + strategy: MountStrategyBase, +) -> None: + manifest = Manifest(entries={"data": S3Mount(bucket="example-bucket", mount_strategy=strategy)}) + + validate_manifest_mount_credential_boundaries( + manifest, + provider_backend_id=backend_id, + ) + + credentialed = manifest.model_copy(deep=True) + mount = credentialed.entries["data"] + assert isinstance(mount, S3Mount) + mount.access_key_id = "example-access-key" + mount.secret_access_key = "example-secret-key" + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + validate_manifest_mount_credential_boundaries( + credentialed, + provider_backend_id=backend_id, + ) + + +def test_custom_strategy_cannot_declare_itself_external() -> None: + class ForgedExternalStrategy(InContainerMountStrategy): + type: Literal["forged_external"] = "forged_external" # type: ignore[assignment] + _credential_boundary: ClassVar[str] = "external" + + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=ForgedExternalStrategy(pattern=RcloneMountPattern()), + credentialed=True, + ) + } + ) + + with pytest.raises(MountConfigError, match="custom mount strategies"): + validate_manifest_mount_credential_boundaries(manifest, provider_backend_id="unix_local") + + +@pytest.mark.parametrize( + "strategy", + [ + _CustomDockerVolumeStrategy( + driver="rclone", + driver_options={"vfs-cache-mode": "off"}, + ), + _CustomModalCloudBucketStrategy(secret_name="named-modal-secret"), + ], +) +def test_unknown_sdk_strategy_subclasses_cannot_retain_opaque_authority( + strategy: MountStrategyBase, +) -> None: + manifest = Manifest(entries={"data": S3Mount(bucket="bucket", mount_strategy=strategy)}) + + with pytest.raises(MountConfigError, match="custom mount strategies"): + validate_manifest_mount_credential_boundaries(manifest, provider_backend_id="docker") + + +def test_custom_strategy_rejects_pydantic_extra_configuration() -> None: + class ExtraStrategy(DockerVolumeMountStrategy): + type: Literal["extra_strategy"] = "extra_strategy" # type: ignore[assignment] + model_config = ConfigDict(extra="allow") + + strategy = ExtraStrategy.model_validate( + {"driver": "rclone", "api_token": "custom-strategy-extra-secret"} + ) + manifest = Manifest(entries={"data": S3Mount(bucket="bucket", mount_strategy=strategy)}) + + with pytest.raises(MountConfigError, match="custom mount strategies") as exc: + validate_manifest_mount_credential_boundaries(manifest) + + assert "custom-strategy-extra-secret" not in str(exc.value) + + +def test_custom_strategy_cannot_forge_builtin_class_provenance() -> None: + original_class = MountStrategyBase._subclass_registry["in_container"] + forged_class = type( + "InContainerMountStrategy", + (InContainerMountStrategy,), + { + "__module__": InContainerMountStrategy.__module__, + "__qualname__": InContainerMountStrategy.__qualname__, + "__annotations__": {"api_token": str | None}, + "api_token": None, + }, + ) + try: + strategy = cast(Any, forged_class)( + pattern=RcloneMountPattern(), + api_token="forged-strategy-secret", + ) + manifest = Manifest(entries={"data": S3Mount(bucket="bucket", mount_strategy=strategy)}) + + with pytest.raises(MountConfigError, match="custom mount strategies"): + validate_manifest_mount_credential_boundaries(manifest) + finally: + MountStrategyBase._subclass_registry["in_container"] = original_class + + +def test_behavior_only_mount_strategy_is_rejected_before_activate() -> None: + class BehaviorOnlyStrategy(InContainerMountStrategy): + type: Literal["behavior_only_strategy"] = "behavior_only_strategy" # type: ignore[assignment] + activate_called: ClassVar[bool] = False + + async def activate( + self, + mount: Mount, + session: Any, + dest: Path, + base_dir: Path, + ) -> list[Any]: + _ = (mount, session, dest, base_dir) + type(self).activate_called = True + return [] + + strategy = BehaviorOnlyStrategy(pattern=RcloneMountPattern()) + manifest = Manifest(entries={"data": S3Mount(bucket="bucket", mount_strategy=strategy)}) + + with pytest.raises(MountConfigError, match="custom mount strategies"): + validate_manifest_mount_credential_boundaries(manifest) + + assert BehaviorOnlyStrategy.activate_called is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("credentialed", [False, True]) +async def test_mount_apply_rejects_behavior_only_mount_strategy( + credentialed: bool, +) -> None: + sentinel = "direct-apply-secret" + + class BehaviorOnlyStrategy(InContainerMountStrategy): + type: Literal["direct_apply_behavior_only"] = "direct_apply_behavior_only" # type: ignore[assignment] + activate_called: ClassVar[bool] = False + + async def activate( + self, + mount: Mount, + session: Any, + dest: Path, + base_dir: Path, + ) -> list[Any]: + _ = (mount, session, dest, base_dir) + type(self).activate_called = True + return [] + + mount = S3Mount( + bucket="bucket", + access_key_id="access-key" if credentialed else None, + secret_access_key=sentinel if credentialed else None, + mount_strategy=BehaviorOnlyStrategy(pattern=RcloneMountPattern()), + ) + session = cast(Any, type("Session", (), {"state": type("State", (), {"type": "test"})()})()) + + with pytest.raises(MountConfigError, match="custom mount strategies") as exc: + await mount.apply(session, Path("/workspace/data"), Path("/workspace")) + + assert BehaviorOnlyStrategy.activate_called is False + assert sentinel not in str(exc.value) + + +def test_custom_mount_pattern_fields_are_rejected_before_apply() -> None: + class CustomRclonePattern(RcloneMountPattern): + api_token: str | None = None + apply_called: ClassVar[bool] = False + + async def apply(self, session: Any, path: Path, config: Any) -> None: + _ = (session, path, config) + type(self).apply_called = True + + pattern = CustomRclonePattern(api_token="custom-pattern-secret") + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=pattern), + ) + } + ) + + with pytest.raises(MountConfigError, match="custom mount patterns") as exc: + validate_manifest_mount_credential_boundaries(manifest) + + assert CustomRclonePattern.apply_called is False + assert "custom-pattern-secret" not in str(exc.value) + + +def test_behavior_only_mount_pattern_is_rejected_before_apply() -> None: + class BehaviorOnlyRclonePattern(RcloneMountPattern): + apply_called: ClassVar[bool] = False + + async def apply(self, session: Any, path: Path, config: Any) -> None: + _ = (session, path, config) + type(self).apply_called = True + + pattern = BehaviorOnlyRclonePattern() + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=pattern), + ) + } + ) + + with pytest.raises(MountConfigError, match="custom mount patterns"): + validate_manifest_mount_credential_boundaries(manifest) + + assert BehaviorOnlyRclonePattern.apply_called is False + + +def test_mount_activation_rejects_custom_pattern_before_deepcopy() -> None: + sentinel = "custom-pattern-deepcopy-secret" + + class CustomRclonePattern(RcloneMountPattern): + deepcopy_called: ClassVar[bool] = False + + def __deepcopy__(self, memo: dict[int, Any] | None = None) -> CustomRclonePattern: + _ = memo + type(self).deepcopy_called = True + raise RuntimeError(sentinel) + + strategy = InContainerMountStrategy(pattern=CustomRclonePattern()) + mount = S3Mount(bucket="bucket", mount_strategy=strategy) + + with pytest.raises(MountConfigError, match="custom mount patterns") as exc_info: + validate_mount_activation_credential_boundary(mount, strategy) + + assert CustomRclonePattern.deepcopy_called is False + assert sentinel not in repr(exc_info.value) + + +def test_ignores_environment_values_already_exposed_to_the_sandbox() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount(strategy=InContainerMountStrategy(pattern=MountpointMountPattern())) + }, + environment=Environment( + value={"AWS_SECRET_ACCESS_KEY": "secret", "GITHUB_TOKEN": "unrelated"} + ), + ) + + validate_manifest_mount_credential_boundaries(manifest) + + +def test_rejects_credentialless_blobfuse_mounts() -> None: + manifest = Manifest( + entries={ + "data": AzureBlobMount( + account="example", + container="public", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="credentialless blobfuse mounts"): + validate_manifest_mount_credential_boundaries(manifest) + + +def test_rejects_s3_files_before_ambient_iam_can_be_used() -> None: + safe = Manifest( + entries={ + "data": S3FilesMount( + file_system_id="fs-123", + extra_options={"tlsport": "4049"}, + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ) + } + ) + with pytest.raises(MountConfigError, match="requires ambient IAM credentials"): + validate_manifest_mount_credential_boundaries(safe) + + +@pytest.mark.parametrize( + "mount", + [ + AzureBlobMount( + account="example", + container="public", + mount_strategy=_CustomInContainerStrategy(pattern=FuseMountPattern()), + ), + S3FilesMount( + file_system_id="fs-123", + mount_strategy=_CustomInContainerStrategy(pattern=S3FilesMountPattern()), + ), + ], +) +def test_rejects_credential_required_patterns_in_inherited_in_container_strategies( + mount: Any, +) -> None: + with pytest.raises(MountConfigError, match="custom mount strategies"): + validate_manifest_mount_credential_boundaries(Manifest(entries={"data": mount})) + + +def test_trusted_opt_in_requires_a_matching_provider_owned_strategy() -> None: + strategy = InContainerMountStrategy(pattern=RcloneMountPattern()) + cast(Any, strategy).type = "vercel_cloud_bucket" + manifest = Manifest(entries={"data": _s3_mount(strategy=strategy, credentialed=True)}) + + with pytest.raises(MountConfigError, match="custom mount strategies"): + validate_manifest_mount_credential_boundaries( + manifest, + allowed_in_container_credential_strategy_types=frozenset({"vercel_cloud_bucket"}), + provider_backend_id="vercel", + ) + + +@pytest.mark.parametrize( + "extra_args", + [ + ["--config=/workspace/credentials.conf"], + ["--s3-env-auth=true"], + ["--s3-profile=production"], + ["--azureblob-use-msi=true"], + ["--header", "Authorization: Bearer secret"], + ], +) +def test_rejects_rclone_credential_source_overrides(extra_args: list[str]) -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(extra_args=extra_args), + ) + ) + } + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + validate_manifest_mount_credential_boundaries(manifest) + + +@pytest.mark.parametrize( + ("strategy", "backend_id"), + [ + (InContainerMountStrategy(pattern=RcloneMountPattern()), None), + (DaytonaCloudBucketMountStrategy(), "daytona"), + ], +) +def test_rejects_box_mounts_that_execute_inside_the_sandbox( + strategy: MountStrategyBase, + backend_id: str | None, +) -> None: + manifest = Manifest(entries={"data": BoxMount(mount_strategy=strategy)}) + + with pytest.raises(MountConfigError, match="Box mounts require credentials"): + validate_manifest_mount_credential_boundaries( + manifest, + provider_backend_id=backend_id, + ) + + +def test_preserves_box_mounts_with_an_external_strategy() -> None: + manifest = Manifest( + entries={ + "data": BoxMount( + access_token="box-access-token", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + validate_manifest_mount_credential_boundaries(manifest, provider_backend_id="docker") + + +def test_preserves_multiline_external_mount_credentials() -> None: + manifest = Manifest( + entries={ + "data": GCSMount( + bucket="bucket", + service_account_credentials='{"private_key":"line-1\nline-2"}', + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + validate_manifest_mount_credential_boundaries(manifest, provider_backend_id="docker") + + +@pytest.mark.parametrize( + ("module_name", "environment"), + [ + ( + "examples.sandbox.docker.mounts.azure_mount_read_write", + { + "AZURE_STORAGE_ACCOUNT": "account", + "AZURE_STORAGE_CONTAINER": "container", + "AZURE_STORAGE_ACCOUNT_KEY": "example-key", + }, + ), + ( + "examples.sandbox.docker.mounts.gcs_mount_read_write", + { + "GCS_MOUNT_BUCKET": "bucket", + "GCS_ACCESS_ID": "example-access-id", + "GCS_SECRET_ACCESS_KEY": "example-secret-key", + }, + ), + ], +) +def test_docker_mount_examples_use_supported_external_strategies( + monkeypatch: pytest.MonkeyPatch, + module_name: str, + environment: dict[str, str], +) -> None: + for name, value in environment.items(): + monkeypatch.setenv(name, value) + module = importlib.import_module(module_name) + + cases = module._mount_cases() + + assert [case.name for case in cases] == ["docker_volume/rclone"] + for case in cases: + assert isinstance(case.mount.mount_strategy, DockerVolumeMountStrategy) + validate_manifest_mount_credential_boundaries( + Manifest(entries={case.mount_dir: case.mount}), + provider_backend_id="docker", + ) + + +@pytest.mark.parametrize( + ("mount", "field_name"), + [ + ( + S3Mount( + bucket="bucket", + s3_provider="AWS\naccess_key_id = injected-value", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + "s3_provider", + ), + ( + AzureBlobMount( + account="account\nkey = injected-value", + container="container", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + "account", + ), + ( + R2Mount( + bucket="bucket", + account_id="account\nsecret_access_key = injected-value", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + "account_id", + ), + ], +) +def test_rejects_and_redacts_rclone_config_line_injection( + mount: S3Mount | AzureBlobMount | R2Mount, + field_name: str, +) -> None: + manifest = Manifest(entries={"data": mount}) + + with pytest.raises(MountConfigError, match="must not contain line breaks") as exc: + validate_manifest_mount_credential_boundaries(manifest) + + assert exc.value.context["configuration_fields"] == (field_name,) + assert "injected-value" not in str(exc.value) + + sanitized, redacted = sanitize_manifest_mount_authority(manifest) + sanitized_mount = sanitized.entries["data"] + assert redacted is True + assert getattr(sanitized_mount, field_name) == "" + assert "injected-value" not in repr(sanitized) + + +@pytest.mark.parametrize( + ("mount", "field_name"), + [ + ( + S3Mount( + bucket="bucket", + endpoint_url=("https://s3.example,public_bucket=0,passwd_file=/workspace/creds"), + mount_strategy=BlaxelCloudBucketMountStrategy(), + ), + "endpoint_url", + ), + ( + S3Mount( + bucket="bucket", + region="us-east-1,public_bucket=0,passwd_file=/workspace/creds", + mount_strategy=BlaxelCloudBucketMountStrategy(), + ), + "region", + ), + ( + R2Mount( + bucket="bucket", + account_id="account", + custom_domain=("https://r2.example,public_bucket=0,passwd_file=/workspace/creds"), + mount_strategy=BlaxelCloudBucketMountStrategy(), + ), + "custom_domain", + ), + ( + R2Mount( + bucket="bucket", + account_id="account,public_bucket=0,passwd_file=/workspace/creds", + mount_strategy=BlaxelCloudBucketMountStrategy(), + ), + "account_id", + ), + ], +) +def test_rejects_blaxel_s3fs_endpoint_option_injection( + mount: S3Mount | R2Mount, + field_name: str, +) -> None: + sentinel = "s3fs-endpoint-secret" + manifest = Manifest( + entries={ + "creds": File(content=sentinel.encode()), + "data": mount, + } + ) + + with pytest.raises(MountConfigError, match="must not contain s3fs option delimiters") as exc: + validate_manifest_mount_credential_boundaries( + manifest, + provider_backend_id="blaxel", + ) + + assert exc.value.context["configuration_fields"] == (field_name,) + assert sentinel not in str(exc.value) + + state = TestSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + with pytest.raises(MountConfigError) as serialization_exc: + _SecurityTestClient().serialize_session_state(state) + assert sentinel not in str(serialization_exc.value) + + +def test_rejects_rclone_on_the_fly_remote_name() -> None: + sentinel = "remote-name-secret" + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy( + pattern=RcloneMountPattern( + remote_name=f":s3,access_key_id=access,secret_access_key={sentinel}" + ) + ) + ) + } + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + validate_manifest_mount_credential_boundaries(manifest) + + assert sentinel not in str(exc.value) + + +def test_serialization_redacts_rclone_on_the_fly_remote_name() -> None: + sentinel = "serialized-remote-name-secret" + state = TestSessionState( + manifest=Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(remote_name=f":s3,secret_access_key={sentinel}") + ) + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = _SecurityTestClient().serialize_session_state(state) + + pattern = payload["manifest"]["entries"]["data"]["mount_strategy"]["pattern"] # type: ignore[index] + assert pattern["remote_name"] is None + assert payload[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert sentinel not in repr(payload) + + +def test_preserves_ordinary_rclone_remote_name() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(remote_name="public bucket-1") + ) + ) + } + ) + + validate_manifest_mount_credential_boundaries(manifest) + sanitized, redacted = sanitize_raw_session_state_mount_authority( + { + "type": "test", + "manifest": manifest.model_dump(mode="json"), + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + ) + + assert redacted is False + assert ( + sanitized["manifest"]["entries"]["data"]["mount_strategy"]["pattern"][ # type: ignore[index] + "remote_name" + ] + == "public bucket-1" + ) + + +def test_preserves_supported_credentialless_rclone_extra_args() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy( + pattern=RcloneMountPattern( + extra_args=[ + "--allow-other", + "--uid", + "123", + "--gid=456", + "--buffer-size", + "0", + ] + ), + ) + ) + } + ) + + validate_manifest_mount_credential_boundaries(manifest) + + +@pytest.mark.parametrize( + "endpoint_url", + [ + "https://user:malformed-secret@[invalid", + "https:user:malformed-secret@example.test", + ], +) +def test_rejects_malformed_inline_credential_url_without_mutating_trusted_manifest( + endpoint_url: str, +) -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + endpoint_url=endpoint_url, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + validate_manifest_mount_credential_boundaries(manifest) + + mount = manifest.entries["data"] + assert isinstance(mount, S3Mount) + assert mount.endpoint_url == endpoint_url + + +@pytest.mark.parametrize( + "endpoint_url", + [ + "https://user:pattern-secret@example.test", + "https://example.test?signature=pattern-secret", + ], +) +def test_rejects_mountpoint_endpoint_authority(endpoint_url: str) -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy( + pattern=MountpointMountPattern( + options=MountpointMountPattern.MountpointOptions( + endpoint_url=endpoint_url, + ) + ) + ) + ) + } + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + validate_manifest_mount_credential_boundaries(manifest) + + assert exc.value.context["credential_fields"] == ( + "mount_strategy.pattern.options.endpoint_url", + ) + assert "pattern-secret" not in str(exc.value) + + +@pytest.mark.parametrize( + ("mount", "credential_path"), + [ + ( + GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + "/workspace/credentials.json", + ), + ( + BoxMount( + box_config_file="credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + "credentials.json", + ), + ], +) +def test_rejects_manifest_backed_credential_files( + mount: GCSMount | BoxMount, + credential_path: str, +) -> None: + _ = credential_path + manifest = Manifest( + entries={ + "credentials.json": File(content=b"credential-file-secret"), + "data": mount, + } + ) + + with pytest.raises(MountConfigError, match="credential files stored in the manifest"): + validate_manifest_mount_credential_boundaries( + manifest, + provider_backend_id="docker", + ) + + +@pytest.mark.parametrize( + ("credential_path", "source"), + [ + ("/workspace/credentials.json", LocalFile(src=Path("credentials.json"))), + ("/workspace/imported/credentials.json", LocalDir(src=Path("imported"))), + ( + "/workspace/repository/credentials.json", + GitRepo(repo="example/repository", ref="main"), + ), + ( + "/workspace/secrets/credentials.json", + S3Mount( + bucket="secret-bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + ), + ], +) +def test_rejects_credential_files_from_manifest_materialization_sources( + credential_path: str, + source: BaseEntry, +) -> None: + source_path = credential_path.removeprefix("/workspace/").split("/", 1)[0] + if credential_path == "/workspace/credentials.json": + source_path = "credentials.json" + manifest = Manifest( + entries={ + source_path: source, + "data": GCSMount( + bucket="bucket", + service_account_file=credential_path, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ) + + with pytest.raises(MountConfigError, match="credential files stored in the manifest"): + validate_manifest_mount_credential_boundaries( + manifest, + provider_backend_id="docker", + ) + + +def test_session_state_serialization_redacts_complete_opaque_authority_fields() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "vfs-cache-mode": "off", + "s3-secret-access-key": "driver-secret", + "s3-env-auth": "true", + "config": "/host/rclone.conf", + }, + ), + credentialed=True, + ) + } + ) + state = TestSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + client = _SecurityTestClient() + + payload = client.serialize_session_state(state) + serialized_mount = payload["manifest"]["entries"]["data"] # type: ignore[index] + + assert payload[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert serialized_mount["access_key_id"] is None + assert serialized_mount["secret_access_key"] is None + assert serialized_mount["mount_strategy"]["driver_options"] == {} + assert "example-secret-key" not in repr(payload) + assert "driver-secret" not in repr(payload) + + restored = client.deserialize_session_state(payload) + assert restored.mount_authority_redacted is True + + trusted_manifest = manifest.model_copy(deep=True) + rebound = restored.rebind_persisted_mount_authority( + trusted_manifest, + provider_backend_id="docker", + ) + rebound_mount = rebound.manifest.entries["data"] + assert isinstance(rebound_mount, S3Mount) + trusted_mount = trusted_manifest.entries["data"] + assert isinstance(trusted_mount, S3Mount) + assert rebound_mount.access_key_id == "example-access-key" + assert rebound_mount.secret_access_key == "example-secret-key" + assert rebound_mount.mount_strategy == trusted_mount.mount_strategy + assert rebound.mount_authority_redacted is False + assert rebound.mount_authority_rebound is True + validate_manifest_mount_credential_boundaries( + rebound.manifest, + provider_backend_id="docker", + ) + + +def test_session_state_round_trip_preserves_credentialless_external_mount() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + state = TestSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + client = _SecurityTestClient() + + payload = client.serialize_session_state(state) + restored = client.deserialize_session_state(payload) + + assert CREDENTIALLESS_MOUNT_AUTHORITY_KEY not in payload + assert REDACTED_MOUNT_AUTHORITY_KEY not in payload + assert restored.manifest == manifest + assert restored.mount_authority_redacted is False + assert restored.mount_authority_rebound is False + + +def test_credentialless_marker_does_not_override_configured_mount_authority() -> None: + sentinel = "configured-secret-access-key" + payload: dict[str, object] = { + "type": "test", + "manifest": Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ).model_dump(mode="json"), + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + CREDENTIALLESS_MOUNT_AUTHORITY_KEY: True, + } + + restored = _SecurityTestClient().deserialize_session_state(payload) + + assert restored.mount_authority_redacted is True + assert CREDENTIALLESS_MOUNT_AUTHORITY_KEY not in payload + assert payload[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert sentinel not in repr(payload) + + +def test_session_state_serialization_preserves_custom_non_mount_fields() -> None: + state = TestSessionState( + manifest=Manifest(entries={"custom": _CustomTokenEntry(token="ordinary-token-value")}), + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = _SecurityTestClient().serialize_session_state(state) + restored = _SecurityTestClient().deserialize_session_state(payload) + + assert REDACTED_MOUNT_AUTHORITY_KEY not in payload + assert payload["manifest"]["entries"]["custom"]["token"] == "ordinary-token-value" # type: ignore[index] + entry = restored.manifest.entries["custom"] + assert isinstance(entry, _CustomTokenEntry) + assert entry.token == "ordinary-token-value" + + +@pytest.mark.parametrize( + "children", + [ + "ordinary-metadata", + { + "nested": { + "type": "s3_mount", + "access_key_id": "ordinary-access-metadata", + "secret_access_key": "ordinary-secret-metadata", + } + }, + ], +) +def test_session_state_serialization_preserves_custom_non_dir_children(children: Any) -> None: + state = TestSessionState( + manifest=Manifest(entries={"custom": _CustomChildrenEntry(children=children)}), + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = _SecurityTestClient().serialize_session_state(state) + restored = _SecurityTestClient().deserialize_session_state(payload) + + assert REDACTED_MOUNT_AUTHORITY_KEY not in payload + assert payload["manifest"]["entries"]["custom"]["children"] == children # type: ignore[index] + entry = restored.manifest.entries["custom"] + assert isinstance(entry, _CustomChildrenEntry) + assert entry.children == children + + +def test_session_state_serialization_rejects_registered_custom_strategy_configuration() -> None: + pattern = { + "type": "custom_pattern", + "extra_args": ["--ordinary-option"], + "remote_name": "ordinary-remote", + "options": {"endpoint_url": "https://public.example.test"}, + } + state = TestSessionState( + manifest=Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=_CustomPatternStrategy( + pattern=pattern, + api_token="custom-strategy-secret", + ), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError, match="custom mount strategies") as exc: + _SecurityTestClient().serialize_session_state(state) + + assert "custom-strategy-secret" not in str(exc.value) + + +def test_session_state_serialization_redacts_custom_strategy_with_known_discriminator() -> None: + strategy = _CustomPatternStrategy( + pattern={"type": "custom_pattern"}, + api_token="custom-strategy-secret", + ) + cast(Any, strategy).type = "docker_volume" + state = TestSessionState( + manifest=Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=strategy, + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError, match="custom mount strategies") as exc_info: + _SecurityTestClient().serialize_session_state(state) + + assert "custom-strategy-secret" not in repr(exc_info.value) + + +def test_rejects_configured_custom_mount_strategies_before_side_effects() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=_CustomPatternStrategy( + pattern={}, + api_token="custom-strategy-secret", + ), + ) + } + ) + + with pytest.raises(MountConfigError, match="custom mount strategies") as exc: + validate_manifest_mount_credential_boundaries(manifest) + + assert "custom-strategy-secret" not in str(exc.value) + + +def test_custom_entry_at_credential_file_path_is_rejected() -> None: + manifest = Manifest( + entries={ + "credentials.json": _CustomTokenEntry(token="custom-source"), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ) + + with pytest.raises(MountConfigError, match="credential files stored in the manifest"): + validate_manifest_mount_credential_boundaries( + manifest, + provider_backend_id="docker", + ) + + +def test_session_state_serialization_rejects_custom_credential_file_materializer() -> None: + sentinel = "custom-source-secondary-secret" + state = TestSessionState( + manifest=Manifest( + entries={ + "credentials.json": _CustomCredentialSourceEntry( + content="ordinary-content", + source_token=sentinel, + ), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError) as exc: + _SecurityTestClient().serialize_session_state(state) + + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +def test_structural_local_dir_credential_path_remains_serializable() -> None: + sentinel = "structural-local-dir-secret" + state = TestSessionState( + manifest=Manifest( + entries={ + "credentials": LocalDir(src=None), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials/key.json", + service_account_credentials=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + validate_manifest_mount_credential_boundaries(state.manifest, provider_backend_id="docker") + payload = _SecurityTestClient().serialize_session_state(state) + + assert payload[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert sentinel not in repr(payload) + + +@pytest.mark.parametrize( + "backend_id", + ["docker", "modal"], +) +def test_opaque_external_authority_remains_resumable_through_trusted_rebind( + backend_id: str, +) -> None: + if backend_id == "docker": + strategy: MountStrategyBase = DockerVolumeMountStrategy( + driver="rclone", + driver_options={"vfs-cache-mode": "off"}, + ) + else: + modal_mounts = importlib.import_module("agents.extensions.sandbox.modal.mounts") + strategy = modal_mounts.ModalCloudBucketMountStrategy( + secret_name="named-modal-secret", + secret_environment_name="staging", + ) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=strategy, + ) + } + ) + client = _SecurityTestClient() + state = TestSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + + payload = client.serialize_session_state(state) + restored = client.deserialize_session_state(payload) + rebound = restored.rebind_persisted_mount_authority( + manifest, + provider_backend_id=backend_id, + ) + + assert payload[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert rebound.manifest == manifest + assert rebound.mount_authority_redacted is False + + +def test_mount_authority_rebind_requires_exact_credential_free_topology() -> None: + original = Manifest( + entries={ + "data": _s3_mount( + strategy=DockerVolumeMountStrategy(driver="rclone"), + credentialed=True, + ) + } + ) + state = TestSessionState(manifest=original, snapshot=NoopSnapshot(id="snapshot")) + client = _SecurityTestClient() + restored = client.deserialize_session_state(client.serialize_session_state(state)) + mismatched = original.model_copy(deep=True) + mount = mismatched.entries["data"] + assert isinstance(mount, S3Mount) + mount.bucket = "different-bucket" + + with pytest.raises(MountConfigError, match="exactly matching"): + restored.rebind_persisted_mount_authority( + mismatched, + provider_backend_id="docker", + ) + + trusted_mount = mismatched.entries["data"] + assert isinstance(trusted_mount, S3Mount) + assert trusted_mount.access_key_id == "example-access-key" + assert trusted_mount.secret_access_key == "example-secret-key" + + root_mismatched = original.model_copy(deep=True) + root_mismatched.root = "/different-workspace" + + with pytest.raises(MountConfigError, match="exactly matching"): + restored.rebind_persisted_mount_authority( + root_mismatched, + provider_backend_id="docker", + ) + + +def test_resume_validation_rejects_wrong_provider_strategy() -> None: + state = TestSessionState( + manifest=Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=DaytonaCloudBucketMountStrategy(), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError, match="not supported by this sandbox backend"): + state.assert_path_grants_rebound() + + +def test_session_state_serialization_redacts_pattern_authority() -> None: + manifest = Manifest( + entries={ + "credentials.conf": File(content=b"credential-file-secret"), + "rclone": _s3_mount( + strategy=InContainerMountStrategy( + pattern=RcloneMountPattern( + extra_args=[ + "--vfs-cache-mode=off", + "--config=/workspace/credentials.conf", + ] + ) + ) + ), + "s3files": S3FilesMount( + file_system_id="fs-123", + mount_strategy=InContainerMountStrategy( + pattern=S3FilesMountPattern( + options=S3FilesMountPattern.S3FilesOptions( + extra_options={ + "tlsport": "4049", + "secret_access_key": "pattern-secret", + } + ) + ) + ), + ), + "mountpoint": _s3_mount( + strategy=InContainerMountStrategy( + pattern=MountpointMountPattern( + options=MountpointMountPattern.MountpointOptions( + endpoint_url="https://example.test?signature=pattern-secret" + ) + ) + ) + ), + } + ) + state = TestSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + + payload = _SecurityTestClient().serialize_session_state(state) + entries = payload["manifest"]["entries"] # type: ignore[index] + + assert payload[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert entries["credentials.conf"]["content"] == "" + assert entries["rclone"]["mount_strategy"]["pattern"]["extra_args"] == [] + assert entries["s3files"]["mount_strategy"]["pattern"]["options"]["extra_options"] == {} + assert entries["mountpoint"]["mount_strategy"]["pattern"]["options"]["endpoint_url"] is None + assert "credential-file-secret" not in repr(payload) + assert "pattern-secret" not in repr(payload) + + +def test_session_state_rejects_inherited_in_container_strategy() -> None: + manifest = Manifest( + entries={ + "credentials.conf": File(content=b"credential-file-secret"), + "data": _s3_mount( + strategy=_CustomInContainerStrategy( + pattern=RcloneMountPattern( + extra_args=["--config=/workspace/credentials.conf"], + ) + ) + ), + } + ) + + with pytest.raises(MountConfigError, match="custom mount strategies") as exc: + _SecurityTestClient().serialize_session_state( + TestSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + ) + + assert "credential-file-secret" not in str(exc.value) + + +def test_raw_state_sanitization_preserves_explicit_sandbox_environment() -> None: + manifest = Manifest( + entries={ + "credentials.json": File(content=b"credential-file-secret"), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + ), + } + ) + payload: dict[str, object] = { + "type": "test", + "manifest": manifest.model_dump(mode="json"), + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + "base_envs": { + "AWS_SECRET_ACCESS_KEY": "ambient-secret", + "GITHUB_TOKEN": "unrelated", + }, + } + + sanitized, redacted = sanitize_raw_session_state_mount_authority(payload) + + assert redacted is True + assert isinstance(sanitized, dict) + assert sanitized[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert sanitized["manifest"]["entries"]["credentials.json"]["content"] == "" + assert sanitized["base_envs"] == { + "AWS_SECRET_ACCESS_KEY": "ambient-secret", + "GITHUB_TOKEN": "unrelated", + } + assert "credential-file-secret" not in repr(sanitized) + assert "ambient-secret" in repr(sanitized) + + +def test_raw_state_sanitization_rejects_credential_content_without_file_discriminator() -> None: + manifest = Manifest( + entries={ + "credentials.json": File(content=b"credential-file-secret"), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ).model_dump(mode="json") + manifest["entries"]["credentials.json"]["type"] = "unknown_file" + payload: dict[str, object] = {"manifest": manifest} + + with pytest.raises(ValueError) as exc: + sanitize_raw_session_state_mount_authority(payload) + + assert "credential-file-secret" not in str(exc.value) + + +def test_legacy_non_inline_credential_file_source_cannot_survive_deserialization() -> None: + manifest = Manifest( + entries={ + "credentials.json": LocalFile(src=Path("trusted/credentials.json")), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ) + payload: dict[str, object] = { + "type": "test", + "manifest": manifest.model_dump(mode="json"), + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError, match="sandbox session state payload is invalid"): + _SecurityTestClient().deserialize_session_state(payload) + + assert payload == {} + + +def test_raw_state_sanitization_rejects_unknown_pattern_discriminator() -> None: + sentinel = "unknown-pattern-secret" + manifest = Manifest( + entries={ + "data": _s3_mount(strategy=InContainerMountStrategy(pattern=RcloneMountPattern())), + } + ).model_dump(mode="json") + manifest["entries"]["data"]["mount_strategy"]["pattern"]["type"] = sentinel + payload: dict[str, object] = {"manifest": manifest} + + with pytest.raises(ValueError, match="unknown type") as exc_info: + sanitize_raw_session_state_mount_authority(payload) + + assert sentinel not in str(exc_info.value) + + +def test_raw_state_rejects_registered_custom_mount_before_validation() -> None: + class CustomValidatedS3Mount(S3Mount): + type: Literal["custom_validated_s3_mount"] = "custom_validated_s3_mount" # type: ignore[assignment] + validator_called: ClassVar[bool] = False + + @model_validator(mode="before") + @classmethod + def _record_validation(cls, value: Any) -> Any: + cls.validator_called = True + return value + + payload: dict[str, object] = { + "type": "test", + "manifest": { + "entries": { + "data": { + "type": "custom_validated_s3_mount", + "bucket": "example-bucket", + "access_key_id": "access-key", + "secret_access_key": "custom-validator-secret", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + } + } + }, + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError, match="sandbox session state payload is invalid") as exc_info: + _SecurityTestClient().deserialize_session_state(payload) + + assert CustomValidatedS3Mount.validator_called is False + assert payload == {} + assert "custom-validator-secret" not in str(exc_info.value) + + +def test_raw_state_rejects_replaced_strategy_registry_before_validation() -> None: + class CustomValidatedStrategy(InContainerMountStrategy): + type: Literal["custom_validated_strategy"] = "custom_validated_strategy" # type: ignore[assignment] + validator_called: ClassVar[bool] = False + + @model_validator(mode="before") + @classmethod + def _record_validation(cls, value: Any) -> Any: + cls.validator_called = True + return value + + original_class = MountStrategyBase._subclass_registry["in_container"] + MountStrategyBase._subclass_registry["in_container"] = CustomValidatedStrategy + payload: dict[str, object] = { + "type": "test", + "manifest": { + "entries": { + "data": { + "type": "s3_mount", + "bucket": "example-bucket", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + } + } + }, + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + try: + with pytest.raises( + ValueError, match="sandbox session state payload is invalid" + ) as exc_info: + _SecurityTestClient().deserialize_session_state(payload) + finally: + MountStrategyBase._subclass_registry["in_container"] = original_class + MountStrategyBase._subclass_registry.pop("custom_validated_strategy", None) + + assert CustomValidatedStrategy.validator_called is False + assert payload == {} + assert "custom-validator-secret" not in str(exc_info.value) + + +def test_raw_state_rejects_malformed_credential_file_locator() -> None: + manifest = Manifest( + entries={ + "credentials.json": File(content=b"credential-file-secret"), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ).model_dump(mode="json") + manifest["entries"]["data"]["service_account_file"] = ["credentials.json"] + payload: dict[str, object] = { + "type": "test", + "manifest": manifest, + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError, match="sandbox session state payload is invalid") as exc: + _SecurityTestClient().deserialize_session_state(payload) + + assert payload == {} + assert "credential-file-secret" not in str(exc.value) + + +@pytest.mark.asyncio +async def test_operation_error_with_mount_authority_is_replaced() -> None: + sentinel = "provider-operation-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class HostileProviderError(RuntimeError): + pass + + _install_hostile_exception_descriptors(HostileProviderError) + provider_error = HostileProviderError(f"provider failed with {sentinel}") + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise provider_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await fail(manifest=manifest) + + assert type(exc.value) is RuntimeError + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + assert cast(Any, BaseException.args).__get__(provider_error, type(provider_error)) == () + assert ( + cast(Any, BaseException.__traceback__).__get__(provider_error, type(provider_error)) is None + ) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +def test_sync_operation_error_with_mount_authority_clears_source_arguments() -> None: + sentinel = "sync-provider-operation-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class HostileProviderError(RuntimeError): + pass + + _install_hostile_exception_descriptors(HostileProviderError) + provider_error = HostileProviderError(f"provider failed with {sentinel}") + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise provider_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + fail(manifest=manifest) + + assert type(exc.value) is RuntimeError + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + assert cast(Any, BaseException.args).__get__(provider_error, type(provider_error)) == () + assert ( + cast(Any, BaseException.__traceback__).__get__(provider_error, type(provider_error)) is None + ) + + +@pytest.mark.asyncio +async def test_operation_error_with_read_only_provider_attributes_is_replaced() -> None: + sentinel = "read-only-provider-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class ReadOnlyProviderError(Exception): + @property + def context(self) -> str: + return sentinel + + @property + def cause(self) -> str: + return sentinel + + provider_error = ReadOnlyProviderError(sentinel) + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise provider_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await fail(manifest=manifest) + + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + assert provider_error.__traceback__ is None + traceback_cursor = exc.value.__traceback__ + while traceback_cursor is not None: + frame_path = Path(traceback_cursor.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback_cursor.tb_frame.f_locals) + traceback_cursor = traceback_cursor.tb_next + + +@pytest.mark.asyncio +async def test_cancellation_with_mount_authority_preserves_redacted_cancellation() -> None: + sentinel = "cancelled-provider-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class HostileCancelledError(asyncio.CancelledError): + pass + + _install_hostile_exception_descriptors(HostileCancelledError) + provider_error = HostileCancelledError(sentinel) + + @redact_mount_error_data + async def cancel(*, manifest: Manifest) -> None: + _ = manifest + raise provider_error + + with pytest.raises(asyncio.CancelledError) as exc: + await cancel(manifest=manifest) + + assert type(exc.value) is asyncio.CancelledError + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + assert cast(Any, BaseException.args).__get__(provider_error, type(provider_error)) == () + assert ( + cast(Any, BaseException.__traceback__).__get__(provider_error, type(provider_error)) is None + ) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +def test_generic_session_state_parser_sanitizes_legacy_mount_authority() -> None: + sentinel = "legacy-session-state-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + payload: dict[str, object] = { + "type": "test", + "manifest": manifest.model_dump(mode="json"), + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + restored = SandboxSessionState.parse(payload) + + assert restored.mount_authority_redacted is True + mount = restored.manifest.entries["data"] + assert isinstance(mount, S3Mount) + assert mount.access_key_id is None + assert mount.secret_access_key is None + assert sentinel not in repr(restored) + + +def test_direct_session_state_round_trip_redacts_mount_authority() -> None: + sentinel = "direct-state-secret" + state = TestSessionState( + manifest=Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = state.model_dump_json() + restored = TestSessionState.model_validate_json(payload) + + assert sentinel not in payload + assert REDACTED_MOUNT_AUTHORITY_KEY in payload + assert restored.mount_authority_redacted is True + with pytest.raises(ValueError, match="requires a current trusted manifest"): + restored.rebind_persisted_mount_authority(None, provider_backend_id="docker") + + +def test_raw_state_sanitization_clears_pattern_authority() -> None: + manifest = Manifest( + entries={ + "credentials.conf": File(content=b"credential-file-secret"), + "rclone": _s3_mount( + strategy=InContainerMountStrategy( + pattern=RcloneMountPattern( + extra_args=["--config", "/workspace/credentials.conf"] + ) + ) + ), + "s3files": S3FilesMount( + file_system_id="fs-123", + mount_strategy=InContainerMountStrategy( + pattern=S3FilesMountPattern( + options=S3FilesMountPattern.S3FilesOptions( + extra_options={ + "tlsport": "4049", + "secret_access_key": "pattern-secret", + } + ) + ) + ), + ), + } + ) + payload: dict[str, object] = { + "type": "test", + "manifest": manifest.model_dump(mode="json"), + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + sanitized, redacted = sanitize_raw_session_state_mount_authority(payload) + + assert redacted is True + assert isinstance(sanitized, dict) + entries = sanitized["manifest"]["entries"] + assert entries["credentials.conf"]["content"] == "" + assert entries["rclone"]["mount_strategy"]["pattern"]["extra_args"] == [] + assert entries["s3files"]["mount_strategy"]["pattern"]["options"]["extra_options"] == {} + assert "credential-file-secret" not in repr(sanitized) + assert "pattern-secret" not in repr(sanitized) + + +@pytest.mark.parametrize("location", ["strategy", "pattern"]) +def test_raw_state_sanitization_rejects_unknown_nested_discriminators( + location: str, +) -> None: + sentinel = f"unknown-{location}-secret" + manifest = Manifest( + entries={ + "docker": S3Mount( + bucket="bucket", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"password": "driver-secret"}, + ), + ), + "s3files": S3FilesMount( + file_system_id="fs-123", + mount_strategy=InContainerMountStrategy( + pattern=S3FilesMountPattern( + options=S3FilesMountPattern.S3FilesOptions( + extra_options={"password": "pattern-secret"} + ) + ) + ), + ), + } + ).model_dump(mode="json") + if location == "strategy": + manifest["entries"]["docker"]["mount_strategy"]["type"] = sentinel + else: + manifest["entries"]["s3files"]["mount_strategy"]["pattern"]["type"] = sentinel + + with pytest.raises(ValueError, match="unknown type") as exc_info: + sanitize_raw_session_state_mount_authority({"type": "test", "manifest": manifest}) + + assert sentinel not in str(exc_info.value) + + +def test_raw_state_sanitization_strips_opaque_fields_with_known_strategy_type() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ).model_dump(mode="json") + manifest["entries"]["data"]["mount_strategy"]["api_token"] = "raw-strategy-secret" + + sanitized, redacted = sanitize_raw_session_state_mount_authority( + {"type": "test", "manifest": manifest} + ) + + strategy = sanitized["manifest"]["entries"]["data"]["mount_strategy"] # type: ignore[index] + assert redacted is True + assert "api_token" not in strategy + assert "raw-strategy-secret" not in repr(sanitized) + + +def test_raw_state_sanitization_strips_opaque_nested_pattern_fields() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).model_dump(mode="json") + pattern = manifest["entries"]["data"]["mount_strategy"]["pattern"] + pattern["api_token"] = "nested-pattern-secret" + pattern["options"] = {"authorization": "nested-options-secret"} + + sanitized, redacted = sanitize_raw_session_state_mount_authority( + {"type": "test", "manifest": manifest} + ) + + sanitized_pattern = sanitized["manifest"]["entries"]["data"]["mount_strategy"]["pattern"] # type: ignore[index] + assert redacted is True + assert "api_token" not in sanitized_pattern + assert "options" not in sanitized_pattern + assert "nested-pattern-secret" not in repr(sanitized) + assert "nested-options-secret" not in repr(sanitized) + + +def test_deserialization_sanitizes_input_before_validation_errors() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=DockerVolumeMountStrategy(driver="rclone"), + credentialed=True, + ) + } + ) + payload: dict[str, Any] = { + "type": "test", + "session_id": "not-a-uuid", + "manifest": manifest.model_dump(mode="json"), + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError): + _SecurityTestClient().deserialize_session_state(payload) + + assert payload == {} + + +def test_deserialization_sanitizes_non_string_endpoint_before_validation_errors() -> None: + sentinel = "raw-endpoint-secret" + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).model_dump(mode="json") + manifest["entries"]["data"]["endpoint_url"] = {"credential": sentinel} + payload: dict[str, Any] = { + "type": "test", + "session_id": "not-a-uuid", + "manifest": manifest, + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError) as exc: + _SecurityTestClient().deserialize_session_state(payload) + + assert payload == {} + assert sentinel not in str(exc.value) + + +def test_serialization_failure_redacts_mount_authority_from_sdk_traceback_frames() -> None: + sentinel = "typed-serialization-secret" + state = TestSessionState( + snapshot=NoopSnapshot(id="snapshot"), + manifest=Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ), + ) + state.snapshot = cast(Any, object()) + + with pytest.raises(MountConfigError) as exc: + _SecurityTestClient().serialize_session_state(state) + + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +def test_direct_state_serialization_replaces_manifest_sanitizer_failure() -> None: + sentinel = "direct-state-sanitizer-secret" + state = TestSessionState( + manifest=Manifest( + entries={ + "credentials.json": LocalFile(src=Path("credentials.json")), + "data": GCSMount( + bucket="bucket", + service_account_file="/workspace/credentials.json", + service_account_credentials=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(Exception) as exc: + state.model_dump(mode="json") + + assert sentinel not in str(exc.value) + error: BaseException | None = exc.value + while error is not None: + traceback = error.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + error = error.__cause__ + + +def test_deserialization_scrubs_authority_before_invalid_strategy_discriminator() -> None: + sentinel = "malformed-strategy-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"password": sentinel}, + ), + ) + } + ).model_dump(mode="json") + manifest["entries"]["data"]["mount_strategy"]["type"] = {"invalid": "discriminator"} + payload: dict[str, Any] = { + "type": "test", + "manifest": manifest, + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError) as exc: + _SecurityTestClient().deserialize_session_state(payload) + + assert payload == {} + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.parametrize("location", ["strategy", "pattern"]) +def test_deserialization_rejects_unknown_string_discriminators_without_values( + location: str, +) -> None: + sentinel = f"unknown-{location}-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).model_dump(mode="json") + strategy = manifest["entries"]["data"]["mount_strategy"] + if location == "strategy": + strategy["type"] = sentinel + else: + strategy["pattern"]["type"] = sentinel + payload: dict[str, Any] = { + "type": "test", + "manifest": manifest, + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError, match="payload is invalid") as exc: + _SecurityTestClient().deserialize_session_state(payload) + + assert payload == {} + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +def test_deserialization_rejects_malformed_entry_container_without_values() -> None: + sentinel = "malformed-entry-container-secret" + payload: dict[str, Any] = { + "type": "test", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": [sentinel], + "environment": {"value": {}}, + }, + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + } + + with pytest.raises(ValueError, match="payload is invalid") as exc: + _SecurityTestClient().deserialize_session_state(payload) + + assert payload == {} + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index c65d118fd4..032115fc95 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -468,6 +468,73 @@ async def test_s3_mountpoint_writable_mode_enables_overwrite_and_delete() -> Non assert "bucket /workspace/remote" in mount_command[2] +@pytest.mark.asyncio +async def test_mountpoint_credentialless_mode_disables_request_signing() -> None: + session = _MountpointApplySession() + pattern = MountpointMountPattern() + + await pattern.apply( + session, + Path("/workspace/remote"), + MountpointMountConfig( + bucket="public-bucket", + access_key_id=None, + secret_access_key=None, + session_token=None, + prefix=None, + region="us-east-1", + endpoint_url=None, + mount_type="s3_mount", + ), + ) + + assert "--no-sign-request" in session.exec_calls[-1][2] + assert session.write_calls == [] + + +@pytest.mark.asyncio +async def test_mount_apply_rejects_credentials_before_side_effects() -> None: + sentinel = "direct-mount-apply-secret" + session = _MountpointApplySession() + mount = S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + ) + + with pytest.raises(MountConfigError) as exc: + await mount.apply(session, Path("/workspace/data"), Path("/workspace")) + + assert session.write_calls == [] + assert session.exec_calls == [] + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_mount_restore_rejects_credentials_before_side_effects() -> None: + session = _MountpointApplySession() + strategy = InContainerMountStrategy(pattern=MountpointMountPattern()) + mount = S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=strategy, + ) + + with pytest.raises(MountConfigError): + await strategy.restore_after_snapshot(mount, session, Path("/workspace/data")) + + assert session.write_calls == [] + assert session.exec_calls == [] + + @pytest.mark.asyncio async def test_gcs_mountpoint_writable_mode_enables_overwrite_and_delete() -> None: session = _MountpointApplySession() @@ -631,6 +698,32 @@ async def test_s3_files_pattern_mounts_with_helper_options() -> None: ] +@pytest.mark.asyncio +async def test_gcs_mount_builds_anonymous_native_rclone_config_without_credentials() -> None: + session_id = uuid.uuid4() + pattern = RcloneMountPattern() + remote_name = pattern.resolve_remote_name( + session_id=session_id.hex, + remote_kind="gcs", + mount_type="gcs_mount", + ) + mount = GCSMount( + bucket="public-bucket", + mount_strategy=InContainerMountStrategy(pattern=pattern), + ) + + config = await mount.build_in_container_mount_config( + _MountConfigSession(session_id=session_id), + pattern, + include_config_text=True, + ) + + assert isinstance(config, RcloneMountConfig) + assert config.config_text == ( + f"[{remote_name}]\ntype = google cloud storage\nanonymous = true\nenv_auth = false\n" + ) + + @pytest.mark.asyncio async def test_gcs_mount_builds_native_rclone_config_with_service_account_auth() -> None: session_id = uuid.uuid4() @@ -976,7 +1069,7 @@ async def test_r2_mount_builds_env_auth_config_with_custom_domain() -> None: "provider = Cloudflare\n" "endpoint = https://eu.r2.cloudflarestorage.com\n" "acl = private\n" - "env_auth = true\n" + "env_auth = false\n" ) diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 6339fc4b4c..52ea029ffa 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -13,7 +13,7 @@ import uuid from collections.abc import Sequence from pathlib import Path -from typing import Any, Literal, TypedDict, cast +from typing import Any, ClassVar, Literal, TypedDict, cast import pytest from openai.types.responses.response_output_item import LocalShellCall, LocalShellCallAction @@ -43,6 +43,7 @@ SandboxRunConfig, User, ) +from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY from agents.sandbox.capabilities import ( Capability, Compaction, @@ -53,25 +54,31 @@ ) from agents.sandbox.entries import ( BaseEntry, + DockerVolumeMountStrategy, File, InContainerMountStrategy, MountpointMountPattern, + RcloneMountPattern, S3Mount, ) from agents.sandbox.errors import ( ExecNonZeroError, ExecTransportError, InvalidManifestPathError, + MountConfigError, WorkspaceArchiveWriteError, ) from agents.sandbox.files import EntryKind, FileEntry -from agents.sandbox.materialization import MaterializedFile +from agents.sandbox.materialization import MaterializationResult, MaterializedFile from agents.sandbox.remote_mount_policy import ( REMOTE_MOUNT_POLICY, ) from agents.sandbox.runtime import SandboxRuntime from agents.sandbox.runtime_agent_preparation import get_default_sandbox_instructions -from agents.sandbox.runtime_session_manager import SandboxRuntimeSessionManager +from agents.sandbox.runtime_session_manager import ( + SandboxRuntimeSessionManager, + _SandboxSessionResources, +) from agents.sandbox.sandboxes import unix_local as unix_local_module from agents.sandbox.sandboxes.unix_local import ( UnixLocalSandboxClient, @@ -102,6 +109,30 @@ from tests.utils.simple_session import SimpleListSession +def test_process_manifest_rejects_custom_pattern_before_deepcopy() -> None: + class CustomRclonePattern(RcloneMountPattern): + deepcopy_called: ClassVar[bool] = False + + def __deepcopy__(self, memo: dict[int, Any] | None = None) -> CustomRclonePattern: + _ = memo + type(self).deepcopy_called = True + raise AssertionError("custom pattern deepcopy must not run") + + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=CustomRclonePattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="custom mount patterns"): + SandboxRuntimeSessionManager._process_manifest([], manifest) + + assert CustomRclonePattern.deepcopy_called is False + + class _FakeSession(BaseSandboxSession): def __init__( self, @@ -115,6 +146,7 @@ def __init__( ) self._start_gate = start_gate self._running = False + self.running_calls = 0 self.start_calls = 0 self.stop_calls = 0 self.shutdown_calls = 0 @@ -144,6 +176,7 @@ async def shutdown(self) -> None: self.shutdown_calls += 1 async def running(self) -> bool: + self.running_calls += 1 return self._running async def read(self, path: Path, *, user: object = None) -> io.BytesIO: @@ -179,6 +212,86 @@ async def stop(self) -> None: raise RuntimeError("stop failed") +def _external_mount_manifest(secret_access_key: str) -> Manifest: + return Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=secret_access_key, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + +def _assert_mount_error_redacted( + error: BaseException, + *, + source_error: BaseException, + sentinel: str, +) -> None: + assert sentinel not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + assert cast(Any, BaseException.args).__get__(source_error, type(source_error)) == () + assert cast(Any, BaseException.__traceback__).__get__(source_error, type(source_error)) is None + traceback = error.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +class _WorkspacePersistenceProbeSession(_FakeSession): + def __init__(self, manifest: Manifest) -> None: + super().__init__(manifest) + self.persist_calls = 0 + self.hydrate_calls = 0 + + async def persist_workspace(self) -> io.IOBase: + self.persist_calls += 1 + return io.BytesIO() + + async def hydrate_workspace(self, data: io.IOBase) -> None: + _ = data + self.hydrate_calls += 1 + + +class _ManifestApplyProbeSession(_FakeSession): + def __init__(self, manifest: Manifest) -> None: + super().__init__(manifest) + self.materialize_calls = 0 + + async def _apply_manifest( + self, + *, + only_ephemeral: bool = False, + provision_accounts: bool = True, + ) -> MaterializationResult: + _ = (only_ephemeral, provision_accounts) + self.materialize_calls += 1 + return MaterializationResult(files=[]) + + +class _FailingBackendStartSession(_ManifestApplyProbeSession): + async def _ensure_backend_started(self) -> None: + raise RuntimeError("backend failed with protected-start-secret") + + +class _FailingSnapshotSession(_FakeSession): + def __init__(self, manifest: Manifest) -> None: + super().__init__(manifest) + self.persist_calls = 0 + + async def _persist_snapshot(self) -> None: + self.persist_calls += 1 + mount = self.state.manifest.entries["data"] + assert isinstance(mount, S3Mount) + raise RuntimeError(f"snapshot failed with {mount.secret_access_key}") + + class _LiveSessionDeltaRecorder(_FakeSession): def __init__(self, manifest: Manifest, *, fail_entry_batch_times: int = 0) -> None: super().__init__(manifest) @@ -208,8 +321,14 @@ async def _apply_entry_batch( class _RejectingLiveSessionDeltaRecorder(_LiveSessionDeltaRecorder): - async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: - _ = only_ephemeral + async def _validate_manifest_application( + self, + *, + only_ephemeral: bool = False, + manifest: Manifest | None = None, + session_running: bool | None = None, + ) -> None: + _ = (only_ephemeral, manifest, session_running) raise RuntimeError("live manifest update rejected") @@ -389,6 +508,245 @@ async def test_sandbox_session_aclose_closes_dependencies_when_stop_fails() -> N assert inner.close_dependency_calls == 1 +@pytest.mark.asyncio +async def test_sandbox_session_aclose_redacts_pre_stop_hook_failure() -> None: + sentinel = "pre-stop-hook-secret" + source_error = RuntimeError(f"pre-stop hook failed with {sentinel}") + inner = _FakeSession(_external_mount_manifest(sentinel)) + session = SandboxSession(inner) + + async def failing_hook() -> None: + raise source_error + + session.register_pre_stop_hook(failing_hook) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await session.aclose() + + _assert_mount_error_redacted(exc.value, source_error=source_error, sentinel=sentinel) + assert inner.stop_calls == 0 + assert inner.shutdown_calls == 1 + assert inner.close_dependency_calls == 1 + + +@pytest.mark.asyncio +async def test_sandbox_session_aclose_redacts_dependency_close_failure() -> None: + sentinel = "dependency-close-secret" + source_error = RuntimeError(f"dependency close failed with {sentinel}") + inner = _FakeSession(_external_mount_manifest(sentinel)) + session = SandboxSession(inner) + + async def failing_dependency_close() -> None: + inner.close_dependency_calls += 1 + raise source_error + + inner._aclose_dependencies = failing_dependency_close # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await session.aclose() + + _assert_mount_error_redacted(exc.value, source_error=source_error, sentinel=sentinel) + assert inner.stop_calls == 1 + assert inner.shutdown_calls == 1 + assert inner.close_dependency_calls == 1 + + +@pytest.mark.asyncio +async def test_runner_owned_cleanup_redacts_pre_stop_hook_failure() -> None: + sentinel = "runner-pre-stop-hook-secret" + source_error = RuntimeError(f"pre-stop hook failed with {sentinel}") + session = _FakeSession(_external_mount_manifest(sentinel)) + resources = _SandboxSessionResources(session=session, client=None, owns_session=True) + + async def failing_hook() -> None: + raise source_error + + session.register_pre_stop_hook(failing_hook) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await resources.cleanup() + + _assert_mount_error_redacted(exc.value, source_error=source_error, sentinel=sentinel) + assert session.stop_calls == 0 + assert session.shutdown_calls == 1 + assert session.close_dependency_calls == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("runner_owned", [False, True]) +async def test_pre_stop_cancellation_skips_persistence_and_completes_cleanup( + runner_owned: bool, +) -> None: + inner = _FakeSession(Manifest()) + client: _FakeClient | None = None + + async def cancelled_hook() -> None: + raise asyncio.CancelledError() + + if runner_owned: + client = _FakeClient(inner) + client.session.register_pre_stop_hook(cancelled_hook) + with pytest.raises(asyncio.CancelledError): + await _SandboxSessionResources( + session=client.session, + client=client, + owns_session=True, + ).cleanup() + else: + session = SandboxSession(inner) + session.register_pre_stop_hook(cancelled_hook) + with pytest.raises(asyncio.CancelledError): + await session.aclose() + + assert inner.stop_calls == 0 + assert inner.shutdown_calls == 1 + assert inner.close_dependency_calls == 1 + if client is not None: + assert client.delete_calls == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancelled", [False, True]) +async def test_repeated_direct_aclose_never_persists_after_pre_stop_failure( + cancelled: bool, +) -> None: + session = _FakeSession(Manifest()) + source_error: BaseException + if cancelled: + source_error = asyncio.CancelledError() + else: + source_error = RuntimeError("pre-stop hook failed") + + async def failing_hook() -> None: + raise source_error + + session.register_pre_stop_hook(failing_hook) + + with pytest.raises(type(source_error)): + await session.aclose() + await session.aclose() + + assert session.stop_calls == 0 + assert session.shutdown_calls == 2 + assert session.close_dependency_calls == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancelled", [False, True]) +@pytest.mark.parametrize("cancel_waiter", [False, True]) +async def test_concurrent_direct_aclose_waits_for_pre_stop_failure( + cancelled: bool, + cancel_waiter: bool, +) -> None: + first_hook_started = asyncio.Event() + second_cleanup_started = asyncio.Event() + release_hook = asyncio.Event() + + class ConcurrentCleanupSession(_FakeSession): + aclose_calls = 0 + + async def aclose(self) -> None: + self.aclose_calls += 1 + if self.aclose_calls == 2: + second_cleanup_started.set() + await super().aclose() + + session = ConcurrentCleanupSession(Manifest()) + source_error: BaseException + if cancelled: + source_error = asyncio.CancelledError() + else: + source_error = RuntimeError("pre-stop hook failed") + + async def failing_hook() -> None: + first_hook_started.set() + await release_hook.wait() + raise source_error + + session.register_pre_stop_hook(failing_hook) + first_cleanup = asyncio.create_task(session.aclose()) + await first_hook_started.wait() + second_cleanup = asyncio.create_task(session.aclose()) + await second_cleanup_started.wait() + if cancel_waiter: + second_cleanup.cancel() + release_hook.set() + + first_result, second_result = await asyncio.gather( + first_cleanup, + second_cleanup, + return_exceptions=True, + ) + + assert isinstance(first_result, type(source_error)) + if cancel_waiter: + assert isinstance(second_result, asyncio.CancelledError) + else: + assert second_result is None + assert session.stop_calls == 0 + assert session.shutdown_calls == (1 if cancel_waiter else 2) + assert session.close_dependency_calls == (1 if cancel_waiter else 2) + + +@pytest.mark.asyncio +async def test_runner_owned_cleanup_redacts_client_delete_failure() -> None: + sentinel = "client-delete-secret" + source_error = RuntimeError(f"delete failed with {sentinel}") + inner = _FakeSession(_external_mount_manifest(sentinel)) + + class FailingDeleteClient(_FakeClient): + async def delete(self, session: SandboxSession) -> SandboxSession: + self.delete_calls += 1 + raise source_error + + client = FailingDeleteClient(inner) + resources = _SandboxSessionResources( + session=client.session, + client=client, + owns_session=True, + ) + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await resources.cleanup() + + _assert_mount_error_redacted(exc.value, source_error=source_error, sentinel=sentinel) + assert inner.stop_calls == 1 + assert inner.shutdown_calls == 1 + assert client.delete_calls == 1 + assert inner.close_dependency_calls == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["persist", "hydrate"]) +async def test_sandbox_session_rejects_unsafe_manifest_before_workspace_persistence( + operation: str, +) -> None: + sentinel = "workspace-persistence-secret" + inner = _WorkspacePersistenceProbeSession( + Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + ) + session = SandboxSession(inner) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + if operation == "persist": + await session.persist_workspace() + else: + await session.hydrate_workspace(io.BytesIO(b"archive")) + + assert inner.persist_calls == 0 + assert inner.hydrate_calls == 0 + assert sentinel not in str(exc.value) + + @pytest.mark.asyncio async def test_sandbox_session_routes_helper_path_checks_to_inner_session() -> None: inner = _PathGuardingSession(Manifest(root="/workspace")) @@ -773,6 +1131,54 @@ def process_manifest(self, manifest: Manifest) -> Manifest: return manifest +class _CredentialedMountCapability(Capability): + type: str = "credentialed-mount" + + def __init__(self) -> None: + super().__init__(type="credentialed-mount") + + def process_manifest(self, manifest: Manifest) -> Manifest: + manifest.entries["remote"] = S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + return manifest + + +class _ManifestFailureCapability(Capability): + type: str = "manifest-failure" + + def __init__(self) -> None: + super().__init__(type="manifest-failure") + + def process_manifest(self, manifest: Manifest) -> Manifest: + mount = manifest.entries["data"] + assert isinstance(mount, S3Mount) + raise RuntimeError(f"capability failed with {mount.secret_access_key}") + + +class _ManifestMutationFailureCapability(Capability): + type: str = "manifest-mutation-failure" + sentinel: str + + def __init__(self, sentinel: str) -> None: + super().__init__( + type="manifest-mutation-failure", + **cast(Any, {"sentinel": sentinel}), + ) + + def process_manifest(self, manifest: Manifest) -> Manifest: + manifest.entries["data"] = S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key=self.sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + raise RuntimeError("capability failed after manifest mutation") + + class _ManifestUsersCapability(Capability): type: str = "manifest-users" @@ -2297,11 +2703,12 @@ async def test_unix_local_client_delete_preserves_caller_owned_workspace_root() @pytest.mark.asyncio async def test_unix_local_runner_cleanup_preserves_resumed_caller_owned_workspace_root() -> None: workspace_root = Path(tempfile.mkdtemp(prefix="resumed-owned-")) - state = UnixLocalSandboxSessionState( - session_id=uuid.uuid4(), + client = UnixLocalSandboxClient() + created = await client.create( manifest=_unix_local_manifest(root=str(workspace_root)), - snapshot=NoopSnapshot(id=str(uuid.uuid4())), + options=None, ) + state = cast(UnixLocalSandboxSessionState, created.state) agent = SandboxAgent( name="sandbox", model=FakeModel(initial_output=[get_final_output_message("done")]), @@ -3355,6 +3762,79 @@ async def test_session_manager_rebinds_persisted_path_grants_from_current_manife assert client.resume_state.path_grants_require_rebind == () +@pytest.mark.asyncio +async def test_session_manager_rebinds_redacted_external_mount_authority() -> None: + trusted_manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = _FakeClient(_FakeSession(Manifest())) + client.backend_id = "docker" + agent = SandboxAgent( + name="worker", + model=FakeModel(), + instructions="Worker.", + default_manifest=trusted_manifest, + ) + session_state = TestSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + serialized_state = client.serialize_session_state(session_state) + assert serialized_state[REDACTED_MOUNT_AUTHORITY_KEY] is True + run_state = cast( + RunState[Any, Agent[Any]], + RunState( + context=RunContextWrapper(context={}), + original_input="hello", + starting_agent=agent, + ), + ) + run_state._current_agent = agent + run_state._sandbox = { + "backend_id": client.backend_id, + "current_agent_key": agent.name, + "current_agent_name": agent.name, + "session_state": serialized_state, + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": serialized_state, + } + }, + } + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}), + run_state=run_state, + ) + + manager.acquire_agent(agent) + await manager.ensure_session( + agent=agent, + capabilities=[], + is_resumed_state=True, + ) + + assert client.resume_state is not None + rebound_mount = client.resume_state.manifest.entries["data"] + assert isinstance(rebound_mount, S3Mount) + assert rebound_mount.access_key_id == "example-access-key" + assert rebound_mount.secret_access_key == "example-secret-key" + assert client.resume_state.mount_authority_redacted is False + assert client.resume_state.mount_authority_rebound is True + persisted = manager.serialize_resume_state() + assert persisted is not None + assert "example-access-key" not in repr(persisted) + assert "example-secret-key" not in repr(persisted) + + @pytest.mark.asyncio async def test_session_manager_rebinds_capability_host_path_grant_once( tmp_path: Path, @@ -3600,6 +4080,136 @@ async def test_session_manager_starts_stopped_injected_session_with_manifest_mut assert payload is None +@pytest.mark.asyncio +@pytest.mark.parametrize("authority_source", ["current_manifest", "capability"]) +async def test_session_manager_rejects_unsafe_stopped_injected_session_manifest( + authority_source: str, +) -> None: + unsafe_mount = S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + initial_manifest = ( + Manifest(entries={"remote": unsafe_mount}) + if authority_source == "current_manifest" + else Manifest() + ) + capabilities: list[Capability] = ( + [_CredentialedMountCapability()] if authority_source == "capability" else [] + ) + live_session = _LiveSessionDeltaRecorder(initial_manifest) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=live_session), + run_state=None, + ) + + manager.acquire_agent(agent) + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + await manager.ensure_session( + agent=agent, + capabilities=capabilities, + is_resumed_state=False, + ) + + assert live_session.start_calls == 0 + assert live_session.running_calls == 0 + assert live_session.applied_entry_batches == [] + if authority_source == "current_manifest": + assert live_session.state.manifest.entries == {"remote": unsafe_mount} + else: + assert live_session.state.manifest.entries == {} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("manifest_source", ["run_config", "agent_default"]) +async def test_session_manager_redacts_capability_failure_with_external_mount_authority( + manifest_source: str, +) -> None: + sentinel = "manager-capability-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = _FakeClient(_FakeSession(Manifest())) + agent = SandboxAgent( + name="worker", + model=FakeModel(), + instructions="Worker.", + default_manifest=manifest if manifest_source == "agent_default" else None, + ) + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig( + client=client, + manifest=manifest if manifest_source == "run_config" else None, + options={"image": "sandbox"}, + ), + run_state=None, + ) + + manager.acquire_agent(agent) + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await manager.ensure_session( + agent=agent, + capabilities=[_ManifestFailureCapability()], + is_resumed_state=False, + ) + + assert client.create_kwargs is None + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_session_manager_redacts_authority_added_before_capability_failure() -> None: + sentinel = "capability-added-mount-secret" + client = _FakeClient(_FakeSession(Manifest())) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig( + client=client, + manifest=Manifest(), + options={"image": "sandbox"}, + ), + run_state=None, + ) + + manager.acquire_agent(agent) + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await manager.ensure_session( + agent=agent, + capabilities=[_ManifestMutationFailureCapability(sentinel)], + is_resumed_state=False, + ) + + assert client.create_kwargs is None + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio @pytest.mark.parametrize( "processed_grants", @@ -4743,6 +5353,135 @@ async def test_apply_manifest_raises_on_account_provisioning_failures() -> None: assert exc_info.value.message == "stdout: attempted useradd\nstderr: missing useradd" +@pytest.mark.asyncio +async def test_apply_manifest_rejects_mount_authority_before_materialization() -> None: + sentinel = "live-apply-secret" + session = _ManifestApplyProbeSession( + Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + await session.apply_manifest() + + assert session.materialize_calls == 0 + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_start_workspace_rejects_mount_authority_before_materialization() -> None: + session = _ManifestApplyProbeSession( + Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="start-workspace-secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + await BaseSandboxSession.start(session) + + assert session.materialize_calls == 0 + + +@pytest.mark.asyncio +async def test_session_start_redacts_external_mount_operation_failure() -> None: + sentinel = "protected-start-secret" + session = _FailingBackendStartSession( + Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + ) + cast(Any, session.state).type = "docker" + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await BaseSandboxSession.start(session) + + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_session_stop_redacts_external_mount_snapshot_failure() -> None: + sentinel = "protected-stop-secret" + session = _FailingSnapshotSession( + Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + ) + cast(Any, session.state).type = "docker" + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await BaseSandboxSession.stop(session) + + assert session.persist_calls == 1 + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_session_stop_rejects_mutated_unsafe_mount_before_snapshot_work() -> None: + session = _FailingSnapshotSession( + Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="mutated-stop-secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + ) + + with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + await BaseSandboxSession.stop(session) + + assert session.persist_calls == 0 + + @pytest.mark.asyncio async def test_apply_manifest_only_ephemeral_skips_account_provisioning_failures() -> None: session = _ProvisioningFailureSession( diff --git a/tests/sandbox/test_session_state_roundtrip.py b/tests/sandbox/test_session_state_roundtrip.py index cab98a9b12..670c1b5dd6 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -275,8 +275,8 @@ def test_parse_upgrades_base_instance_through_registry(self) -> None: @pytest.mark.parametrize( ("payload", "error_type", "message"), [ - ({}, ValueError, "must include a string `type`"), - ({"type": "missing"}, ValueError, "unknown sandbox session state type `missing`"), + ({}, ValueError, "sandbox session state payload is invalid"), + ({"type": "missing"}, ValueError, "sandbox session state payload is invalid"), ("not-a-state", TypeError, "session state payload must be"), ], ) @@ -289,6 +289,127 @@ def test_parse_rejects_invalid_payloads( with pytest.raises(error_type, match=message): SandboxSessionState.parse(payload) + @pytest.mark.parametrize( + "payload", + [ + {"type": "session-state-parse-secret"}, + { + "type": "simple-roundtrip", + "snapshot": {"type": "noop", "id": "snapshot"}, + "manifest": { + "entries": { + "data": { + "type": "unknown", + "token": "session-state-parse-secret", + } + } + }, + }, + ], + ) + def test_parse_redacts_malformed_payload_errors(self, payload: dict[str, object]) -> None: + sentinel = "session-state-parse-secret" + + with pytest.raises(ValueError, match="sandbox session state payload is invalid") as exc: + SandboxSessionState.parse(payload) + + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.parametrize("as_json", [False, True]) + def test_direct_model_validation_redacts_malformed_mount_authority( + self, + as_json: bool, + ) -> None: + sentinel = "direct-model-validation-secret" + payload: dict[str, object] = { + "type": "simple-roundtrip", + "session_id": [], + "snapshot": {"type": "noop", "id": "snapshot"}, + "manifest": { + "entries": { + "data": { + "type": "s3_mount", + "bucket": "bucket", + "secret_access_key": {"secret": sentinel}, + "mount_strategy": {"type": "docker_volume", "driver": "rclone"}, + } + } + }, + } + model_input: object = json.dumps(payload) if as_json else payload + + with pytest.raises(ValidationError) as exc: + if as_json: + _SimpleSessionState.model_validate_json(cast(str, model_input)) + else: + _SimpleSessionState.model_validate(model_input) + + assert sentinel not in str(exc.value) + assert sentinel not in repr(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.parametrize("as_json", [False, True]) + def test_direct_model_validation_redacts_non_mapping_manifest( + self, + as_json: bool, + ) -> None: + sentinel = "non-mapping-manifest-secret" + payload: dict[str, object] = { + "type": "simple-roundtrip", + "snapshot": {"type": "noop", "id": "snapshot"}, + "manifest": [sentinel], + } + model_input: object = json.dumps(payload) if as_json else payload + + with pytest.raises(ValidationError) as exc: + if as_json: + _SimpleSessionState.model_validate_json(cast(str, model_input)) + else: + _SimpleSessionState.model_validate(model_input) + + assert sentinel not in str(exc.value) + assert sentinel not in repr(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + def test_model_validate_json_redacts_malformed_json(self) -> None: + sentinel = "malformed-session-state-secret" + malformed_json = ( + '{"type":"simple-roundtrip","manifest":{"entries":{"data":' + f'{{"secret_access_key":"{sentinel}"}}}}' + ) + + with pytest.raises(ValueError, match="sandbox session state JSON is invalid") as exc: + _SimpleSessionState.model_validate_json(malformed_json) + + assert sentinel not in str(exc.value) + assert sentinel not in repr(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + def test_subclass_registration_skips_non_literal_or_empty_type_defaults(self) -> None: assert "plain-type" not in SandboxSessionState._subclass_registry assert "" not in SandboxSessionState._subclass_registry diff --git a/tests/test_run_examples_script.py b/tests/test_run_examples_script.py index 19bdcbca93..51f73e2c46 100644 --- a/tests/test_run_examples_script.py +++ b/tests/test_run_examples_script.py @@ -9,7 +9,6 @@ def test_default_auto_skip_excludes_prerequisite_bound_examples() -> None: expected = { "examples/sandbox/docker/mounts/azure_mount_read_write.py", "examples/sandbox/docker/mounts/gcs_mount_read_write.py", - "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", "examples/sandbox/extensions/blaxel_runner.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 9552f9e9fb..0d28ed1cd3 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3,6 +3,7 @@ from __future__ import annotations import gc +import importlib import io import json import logging @@ -106,6 +107,7 @@ ) from agents.sandbox import Manifest from agents.sandbox.capabilities.capability import Capability +from agents.sandbox.entries import BaseEntry, Mount, MountStrategyBase from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxSessionState from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot @@ -878,7 +880,7 @@ async def test_throws_error_if_schema_version_is_missing_or_invalid(self): with pytest.raises( Exception, match=( - f"Run state schema version 0.1 is not supported. " + "Run state schema version is not supported. " f"Supported versions are: {supported_versions}. " f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}." ), @@ -4463,9 +4465,18 @@ async def inner_sensitive_tool(text: str) -> str: async def test_json_decode_error_handling(self): """Test that invalid JSON raises appropriate error.""" agent = Agent(name="TestAgent") + sentinel = "malformed-json-secret" - with pytest.raises(Exception, match="Failed to parse run state JSON"): - await RunState.from_string(agent, "{ invalid json }") + with pytest.raises(UserError, match="Failed to parse run state JSON") as exc: + await RunState.from_string(agent, f'{{ "sandbox": "{sentinel}" ') + + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next async def test_missing_agent_in_map_error(self): """Test error when agent not found in agent map.""" @@ -6447,11 +6458,62 @@ async def test_from_json_unsupported_schema_version(self, schema_version: str): "generated_items": [], } - with pytest.raises( - UserError, match=f"Run state schema version {schema_version} is not supported" - ): + with pytest.raises(UserError, match="Run state schema version is not supported"): + await RunState.from_json(agent, state_json) + + @pytest.mark.asyncio + async def test_from_json_checks_schema_before_sandbox_envelope(self): + agent = Agent(name="TestAgent") + state_json: dict[str, Any] = { + "$schemaVersion": "9.9", + "sandbox": ["future-sandbox-value"], + } + original = deepcopy(state_json) + + with pytest.raises(UserError, match="Run state schema version is not supported"): await RunState.from_json(agent, state_json) + assert state_json == original + + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["from_json", "from_string"]) + @pytest.mark.parametrize( + ("payload", "message"), + [ + ([{"secret_access_key": "malformed-schema-secret"}], "must be an object"), + ( + {"$schemaVersion": {"value": "malformed-schema-secret"}}, + "schema version has an invalid type", + ), + ( + {"$schemaVersion": "malformed-schema-secret"}, + "schema version is not supported", + ), + ], + ) + async def test_malformed_schema_shape_redacts_public_errors( + self, + operation: str, + payload: object, + message: str, + ) -> None: + agent = Agent(name="TestAgent") + sentinel = "malformed-schema-secret" + + with pytest.raises(UserError, match=message) as exc: + if operation == "from_json": + await RunState.from_json(agent, cast(Any, deepcopy(payload))) + else: + await RunState.from_string(agent, json.dumps(payload)) + + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + @pytest.mark.asyncio async def test_from_json_accepts_previous_schema_version(self): """Test that from_json accepts a previous, explicitly supported schema version.""" @@ -7409,6 +7471,568 @@ async def test_run_state_round_trip_preserves_serialized_sandbox_session_snapsho assert isinstance(restored_session_state.snapshot, LocalSnapshot) assert restored_session_state.snapshot.base_path == Path("/tmp/snapshots") + @pytest.mark.asyncio + async def test_run_state_sanitizes_raw_mount_credentials_without_provider_imports(self): + agent = Agent(name="TestAgent") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state: RunState[Any, Agent[Any]] = make_state(agent, context=context, original_input="test") + raw_session_state = { + "type": "unimported-provider", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": { + "malformed-parent": { + "type": "unknown-parent", + "children": { + "data": { + "type": "s3_mount", + "access_key_id": "raw-access-key", + "secret_access_key": "raw-secret-key", + "mount_strategy": { + "type": {"invalid": "raw-strategy-discriminator-secret"}, + "driver": "rclone", + "driver_options": { + "vfs-cache-mode": "off", + "s3-secret-access-key": "raw-driver-secret", + }, + "pattern": { + "type": {"invalid": "pattern-discriminator"}, + "config_file_path": "/workspace/raw-pattern-secret", + "extra_args": [ + "--header", + "Authorization: raw-header-secret", + ], + "options": { + "endpoint_url": {"credential": "raw-endpoint-secret"}, + "extra_options": {"password": "raw-option-secret"}, + }, + }, + }, + } + }, + }, + }, + "environment": {"value": {}}, + }, + } + state._sandbox = { + "backend_id": "unimported-provider", + "session_state": raw_session_state, + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": raw_session_state, + } + }, + } + + serialized = state.to_json() + serialized_text = json.dumps(serialized) + + assert "raw-access-key" not in serialized_text + assert "raw-secret-key" not in serialized_text + assert "raw-driver-secret" not in serialized_text + assert "raw-pattern-secret" not in serialized_text + assert "raw-header-secret" not in serialized_text + assert "raw-endpoint-secret" not in serialized_text + assert "raw-option-secret" not in serialized_text + assert "raw-strategy-discriminator-secret" not in serialized_text + assert "vfs-cache-mode" not in serialized_text + serialized_session = serialized["sandbox"]["session_state"] + assert serialized_session["__openai_agents_redacted_mount_authority"] is True + + serialized["sandbox"]["session_state"] = raw_session_state + restored = await RunState.from_json(agent, serialized) + + assert restored._sandbox is not None + assert "raw-secret-key" not in repr(restored._sandbox) + assert "raw-strategy-discriminator-secret" not in repr(restored._sandbox) + assert "raw-secret-key" not in repr(serialized) + assert "raw-strategy-discriminator-secret" not in repr(serialized) + + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["to_json", "from_json"]) + async def test_run_state_rejects_non_string_mount_entry_type_without_values( + self, + operation: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "malformed-mount-entry-type-secret" + sandbox = { + "backend_id": "unimported-provider", + "session_state": { + "type": "unimported-provider", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": { + "data": { + "type": {"invalid": "discriminator"}, + "secret_access_key": sentinel, + "mount_strategy": {"type": "in_container"}, + } + }, + "environment": {"value": {}}, + }, + }, + } + if operation == "to_json": + state._sandbox = sandbox + serialized = None + else: + serialized = state.to_json() + serialized["sandbox"] = sandbox + + with pytest.raises(ValueError, match="invalid manifest") as exc_info: + if operation == "to_json": + state.to_json() + else: + assert serialized is not None + await RunState.from_json(agent, serialized) + + assert sandbox == {} + assert sentinel not in str(exc_info.value) + assert sentinel not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio + @pytest.mark.parametrize("collision_kind", ["strategy", "extension_entry"]) + async def test_run_state_rejects_reserved_mount_registration_collision_without_values( + self, + monkeypatch: pytest.MonkeyPatch, + collision_kind: str, + ) -> None: + sentinel = f"reserved-{collision_kind}-collision-secret" + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + entries: dict[str, Any] + if collision_kind == "strategy": + entries = { + "data": { + "type": "s3_mount", + "bucket": "bucket", + "access_key_id": "access-key", + "secret_access_key": sentinel, + "mount_strategy": {"type": "cloudflare_bucket_mount"}, + } + } + else: + entries = { + "drive": { + "type": "blaxel_drive_mount", + "drive_name": "drive", + "drive_mount_path": "/data", + "drive_path": "/", + "drive_read_only": True, + "mount_strategy": {"type": "blaxel_drive"}, + }, + "data": { + "type": "s3_mount", + "bucket": "bucket", + "access_key_id": "access-key", + "secret_access_key": sentinel, + "mount_strategy": {"type": "docker_volume", "driver": "rclone"}, + }, + } + state_json = state.to_json() + state_json["sandbox"] = { + "backend_id": "cloudflare", + "session_state": { + "type": "cloudflare", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": entries, + "environment": {"value": {}}, + }, + }, + } + original_import_module = importlib.import_module + + def import_module_with_registration_collision(name: str, package: str | None = None) -> Any: + if ( + collision_kind == "strategy" + and name == "agents.extensions.sandbox.cloudflare.mounts" + ): + raise TypeError("mount strategy type is already registered") + if ( + collision_kind == "extension_entry" + and name == "agents.extensions.sandbox.blaxel.mounts" + ): + raise ValueError("artifact type is already registered") + return original_import_module(name, package) + + if collision_kind == "strategy": + monkeypatch.setitem( + MountStrategyBase._subclass_registry, + "cloudflare_bucket_mount", + cast(Any, object()), + ) + else: + monkeypatch.setitem( + BaseEntry._subclass_registry, + "blaxel_drive_mount", + Mount, + ) + monkeypatch.setattr( + importlib, + "import_module", + import_module_with_registration_collision, + ) + + with pytest.raises(ValueError) as exc_info: + await RunState.from_json(agent, state_json) + + assert sentinel not in str(exc_info.value) + assert sentinel not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider_entry_registered", [False, True]) + async def test_run_state_preserves_blaxel_drive_mount( + self, + monkeypatch: pytest.MonkeyPatch, + provider_entry_registered: bool, + ) -> None: + if provider_entry_registered: + from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMount + + monkeypatch.setitem( + BaseEntry._subclass_registry, + "blaxel_drive_mount", + BlaxelDriveMount, + ) + else: + monkeypatch.delitem(BaseEntry._subclass_registry, "blaxel_drive_mount", raising=False) + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + raw_session_state = { + "type": "blaxel", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": { + "drive": { + "type": "blaxel_drive_mount", + "drive_name": "shared-drive", + "drive_mount_path": "/data", + "drive_path": "/", + "drive_read_only": True, + "mount_strategy": {"type": "blaxel_drive"}, + } + }, + "environment": {"value": {}}, + }, + } + state._sandbox = { + "backend_id": "blaxel", + "session_state": raw_session_state, + } + + serialized = state.to_json() + restored = await RunState.from_json(agent, serialized) + + assert restored._sandbox is not None + restored_session = cast(dict[str, object], restored._sandbox["session_state"]) + restored_manifest = cast(dict[str, object], restored_session["manifest"]) + restored_entries = cast(dict[str, object], restored_manifest["entries"]) + expected_manifest = cast(dict[str, object], raw_session_state["manifest"]) + expected_entries = cast(dict[str, object], expected_manifest["entries"]) + assert restored_entries["drive"] == expected_entries["drive"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["to_json", "from_json"]) + async def test_run_state_rejects_malformed_manifest_entry_containers_without_values( + self, + operation: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "malformed-entry-container-secret" + sandbox = { + "backend_id": "unimported-provider", + "session_state": { + "type": "unimported-provider", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": [sentinel], + "environment": {"value": {}}, + }, + }, + } + if operation == "to_json": + state._sandbox = sandbox + serialized = None + else: + serialized = state.to_json() + serialized["sandbox"] = sandbox + + with pytest.raises(ValueError, match="invalid manifest") as exc: + if operation == "to_json": + state.to_json() + else: + assert serialized is not None + await RunState.from_json(agent, serialized) + + assert sandbox == {} + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["to_json", "from_json"]) + async def test_run_state_rejects_non_mapping_session_manifest( + self, + operation: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "non-mapping-manifest-secret" + sandbox = { + "backend_id": "unimported-provider", + "session_state": { + "type": "unimported-provider", + "manifest": [{"secret_access_key": sentinel}], + }, + } + if operation == "to_json": + state._sandbox = sandbox + serialized = None + else: + serialized = state.to_json() + serialized["sandbox"] = sandbox + + with pytest.raises(ValueError, match="invalid manifest") as exc: + if operation == "to_json": + state.to_json() + else: + assert serialized is not None + await RunState.from_json(agent, serialized) + + assert sandbox == {} + assert sentinel not in str(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["to_json", "from_json"]) + @pytest.mark.parametrize("location", ["strategy", "pattern"]) + async def test_run_state_rejects_unknown_mount_discriminators_without_values( + self, + operation: str, + location: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = f"unknown-{location}-discriminator-secret" + raw_session_state: dict[str, Any] = { + "type": "unimported-provider", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": { + "data": { + "type": "s3_mount", + "bucket": "bucket", + "mount_strategy": { + "type": "in_container", + "pattern": { + "type": "rclone", + }, + }, + }, + }, + "environment": {"value": {}}, + }, + } + strategy = cast( + dict[str, Any], + raw_session_state["manifest"]["entries"]["data"]["mount_strategy"], + ) + if location == "strategy": + strategy["type"] = sentinel + else: + cast(dict[str, Any], strategy["pattern"])["type"] = sentinel + sandbox = { + "backend_id": "unimported-provider", + "session_state": raw_session_state, + } + + if operation == "to_json": + state._sandbox = sandbox + serialized = None + else: + serialized = state.to_json() + serialized["sandbox"] = sandbox + + with pytest.raises(ValueError, match="invalid manifest") as exc_info: + if operation == "to_json": + state.to_json() + else: + assert serialized is not None + await RunState.from_json(agent, serialized) + + assert sandbox == {} + assert sentinel not in str(exc_info.value) + assert sentinel not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + def test_run_state_redacts_unknown_mount_strategy_configuration(self) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + state._sandbox = { + "backend_id": "unimported-provider", + "session_state": { + "type": "unimported-provider", + "manifest": { + "version": 1, + "root": "/workspace", + "entries": { + "data": { + "type": "s3_mount", + "bucket": "bucket", + "mount_strategy": { + "type": "in_container", + "api_token": "custom-strategy-secret", + "pattern": { + "type": "rclone", + "api_token": "nested-pattern-secret", + "options": { + "authorization": "nested-options-secret", + }, + }, + }, + } + }, + "environment": {"value": {}}, + }, + }, + } + + serialized = state.to_json() + + strategy = serialized["sandbox"]["session_state"]["manifest"]["entries"]["data"][ + "mount_strategy" + ] + assert strategy["type"] == "in_container" + assert strategy["pattern"]["type"] == "rclone" + assert "api_token" not in strategy + assert "api_token" not in strategy["pattern"] + assert "options" not in strategy["pattern"] + assert "custom-strategy-secret" not in repr(serialized) + assert "nested-pattern-secret" not in repr(serialized) + assert "nested-options-secret" not in repr(serialized) + + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["to_json", "from_json"]) + @pytest.mark.parametrize("location", ["top_level", "current", "sessions_by_agent"]) + async def test_run_state_rejects_malformed_sandbox_session_envelopes_without_values( + self, + operation: str, + location: str, + ) -> None: + agent = Agent(name="TestAgent") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state: RunState[Any, Agent[Any]] = make_state(agent, context=context, original_input="test") + sentinel = "malformed-sandbox-secret" + if location == "top_level": + malformed: object = sentinel + elif location == "current": + malformed = {"session_state": [sentinel]} + else: + malformed = { + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": [sentinel], + } + } + } + + if operation == "to_json": + state._sandbox = cast(Any, malformed) + serialized = None + else: + serialized = state.to_json() + serialized["sandbox"] = malformed + + with pytest.raises(ValueError, match="invalid envelope") as exc: + if operation == "to_json": + state.to_json() + else: + assert serialized is not None + await RunState.from_json(agent, serialized) + + if isinstance(malformed, dict): + assert malformed == {} + elif operation == "to_json": + assert state._sandbox is None + else: + assert serialized is not None + assert serialized["sandbox"] == {} + assert sentinel not in str(exc.value) + assert sentinel not in repr(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + module_name = traceback.tb_frame.f_globals.get("__name__", "") + if isinstance(module_name, str) and module_name.startswith("agents."): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + @pytest.mark.asyncio async def test_from_json_agent_not_found(self): """Test that from_json raises error when agent is not found in agent map.""" From b79bf94defa42b80c4f764069af6b6730d34e954 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 10:05:26 +0900 Subject: [PATCH 242/473] fix(modal): settle snapshot directory mount transitions (#4315) --- .../extensions/sandbox/modal/sandbox.py | 179 ++++-- src/agents/sandbox/session/mount_lifecycle.py | 27 +- tests/extensions/sandbox/test_modal.py | 608 ++++++++++++++++++ 3 files changed, 749 insertions(+), 65 deletions(-) diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index d1351ff4b5..71c7c551b8 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -58,6 +58,12 @@ from ....sandbox.session.base_sandbox_session import BaseSandboxSession from ....sandbox.session.dependencies import Dependencies from ....sandbox.session.manager import Instrumentation +from ....sandbox.session.mount_lifecycle import ( + _mount_transition_error, + _restore_detached_mounts_settled, + _settle_mount_transition, + _terminate_ambiguous_mount_session, +) from ....sandbox.session.pty_types import ( PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, @@ -1412,6 +1418,8 @@ async def _persist_workspace_via_snapshot_directory(self) -> io.IOBase: self._modal_snapshot_ephemeral_backup = None self._modal_snapshot_ephemeral_backup_path = None detached_mounts: list[tuple[Mount, Path]] = [] + caller_cancelled = False + teardown_transition_ambiguous = False async def restore_ephemeral_paths() -> WorkspaceArchiveReadError | None: backup_path = self._modal_snapshot_ephemeral_backup_path @@ -1439,34 +1447,13 @@ async def restore_ephemeral_paths() -> WorkspaceArchiveReadError | None: ) return None - async def restore_detached_mounts() -> WorkspaceArchiveReadError | None: - remount_error: WorkspaceArchiveReadError | None = None - for mount_entry, mount_path in reversed(detached_mounts): - try: - await mount_entry.mount_strategy.restore_after_snapshot( - mount_entry, - self, - mount_path, - ) - except Exception as e: - current_error = WorkspaceArchiveReadError(path=error_root, cause=e) - if remount_error is None: - remount_error = current_error - else: - additional_remount_errors = remount_error.context.setdefault( - "additional_remount_errors", [] - ) - assert isinstance(additional_remount_errors, list) - additional_remount_errors.append( - { - "message": current_error.message, - "cause_type": type(e).__name__, - "cause": str(e), - } - ) - return remount_error + async def restore_ephemeral_paths_or_raise() -> None: + restore_error = await restore_ephemeral_paths() + if restore_error is not None: + raise restore_error snapshot_error: WorkspaceArchiveReadError | None = None + cleanup_error: WorkspaceArchiveReadError | None = None snapshot_id: str | None = None try: if skip_abs: @@ -1513,24 +1500,40 @@ async def restore_detached_mounts() -> WorkspaceArchiveReadError | None: ) for mount_entry, mount_path in self._snapshot_directory_mount_targets_to_restore(root): - await mount_entry.mount_strategy.teardown_for_snapshot( - mount_entry, + transition_error, transition_cancelled = await _settle_mount_transition( self, - mount_path, + mount_entry.mount_strategy.teardown_for_snapshot( + mount_entry, + self, + mount_path, + ), ) + caller_cancelled = caller_cancelled or transition_cancelled + if transition_error is not None: + snapshot_error = WorkspaceArchiveReadError( + path=error_root, + cause=transition_error, + ) + teardown_transition_ambiguous = True + break detached_mounts.append((mount_entry, mount_path)) + if caller_cancelled: + break - snapshot_sandbox = await self._refresh_sandbox_handle_for_snapshot() - snap_coro = snapshot_sandbox.snapshot_directory.aio(root.as_posix()) - if self.state.snapshot_filesystem_timeout_s is None: - snap = await snap_coro - else: - snap = await asyncio.wait_for( - snap_coro, timeout=self.state.snapshot_filesystem_timeout_s + if snapshot_error is None and not caller_cancelled: + snapshot_sandbox = await self._refresh_sandbox_handle_for_snapshot() + snap_coro = snapshot_sandbox.snapshot_directory.aio(root.as_posix()) + if self.state.snapshot_filesystem_timeout_s is None: + snap = await snap_coro + else: + snap = await asyncio.wait_for( + snap_coro, timeout=self.state.snapshot_filesystem_timeout_s + ) + snapshot_id, snapshot_error = self._extract_modal_snapshot_id( + snap=snap, root=root, snapshot_kind="snapshot_directory" ) - snapshot_id, snapshot_error = self._extract_modal_snapshot_id( - snap=snap, root=root, snapshot_kind="snapshot_directory" - ) + except asyncio.CancelledError: + caller_cancelled = True except WorkspaceArchiveReadError as e: snapshot_error = e except Exception as e: @@ -1538,8 +1541,34 @@ async def restore_detached_mounts() -> WorkspaceArchiveReadError | None: path=error_root, context={"reason": "snapshot_directory_failed"}, cause=e ) finally: - remount_error = await restore_detached_mounts() - restore_error = await restore_ephemeral_paths() + remount_result, remount_cancelled = await _restore_detached_mounts_settled( + self, + detached_mounts, + error_path=error_root, + error_cls=WorkspaceArchiveReadError, + ) + remount_error = cast(WorkspaceArchiveReadError | None, remount_result) + caller_cancelled = caller_cancelled or remount_cancelled + + restore_error: WorkspaceArchiveReadError | None + if remount_error is None: + restore_transition_error, restore_cancelled = await _settle_mount_transition( + self, + restore_ephemeral_paths_or_raise(), + ) + caller_cancelled = caller_cancelled or restore_cancelled + if isinstance(restore_transition_error, WorkspaceArchiveReadError): + restore_error = restore_transition_error + elif restore_transition_error is not None: + restore_error = WorkspaceArchiveReadError( + path=error_root, + cause=restore_transition_error, + ) + else: + restore_error = None + else: + restore_error = None + cleanup_error = remount_error if restore_error is not None: if cleanup_error is None: @@ -1566,7 +1595,23 @@ async def restore_detached_mounts() -> WorkspaceArchiveReadError | None: cleanup_error.context["snapshot_error_before_restore_corruption"] = { "message": snapshot_error.message } - raise cleanup_error + + if teardown_transition_ambiguous and remount_error is None: + termination_error, termination_cancelled = await _terminate_ambiguous_mount_session( + self + ) + caller_cancelled = caller_cancelled or termination_cancelled + if termination_error is not None and not caller_cancelled: + cleanup_error = cleanup_error or WorkspaceArchiveReadError( + path=error_root, + context={"reason": "snapshot_directory_terminal_cleanup_failed"}, + cause=termination_error, + ) + + if caller_cancelled: + raise asyncio.CancelledError() from None + if cleanup_error is not None: + raise cleanup_error if snapshot_error is not None: raise snapshot_error @@ -1823,21 +1868,49 @@ async def _restore_snapshot_directory_image(self, *, snapshot_id: str, root: Pat sandbox = self._sandbox async def _run_restore() -> None: + caller_cancelled = False + transition_error: BaseException | None = None image = modal.Image.from_id(snapshot_id) - await self._call_modal( - sandbox.mount_image, - root.as_posix(), - image, - call_timeout=self.state.snapshot_filesystem_restore_timeout_s, + image_error, image_cancelled = await _settle_mount_transition( + self, + self._call_modal( + sandbox.mount_image, + root.as_posix(), + image, + call_timeout=self.state.snapshot_filesystem_restore_timeout_s, + ), ) - for mount_entry, mount_path in reversed( - self._snapshot_directory_mount_targets_to_restore(root) - ): - await mount_entry.mount_strategy.restore_after_snapshot( - mount_entry, + caller_cancelled = image_cancelled + if image_error is not None: + if isinstance(image_error, asyncio.CancelledError): + transition_error = _mount_transition_error( + WorkspaceArchiveWriteError, + error_path=root, + transition_error=image_error, + reason="mount_image_cancelled", + ) + else: + transition_error = image_error + ( + _termination_error, + termination_cancelled, + ) = await _terminate_ambiguous_mount_session(self) + caller_cancelled = caller_cancelled or termination_cancelled + else: + remount_error, remount_cancelled = await _restore_detached_mounts_settled( self, - mount_path, + self._snapshot_directory_mount_targets_to_restore(root), + error_path=root, + error_cls=WorkspaceArchiveWriteError, ) + caller_cancelled = caller_cancelled or remount_cancelled + if remount_error is not None: + transition_error = remount_error + + if caller_cancelled: + raise asyncio.CancelledError() from None + if transition_error is not None: + raise transition_error try: await asyncio.wait_for( diff --git a/src/agents/sandbox/session/mount_lifecycle.py b/src/agents/sandbox/session/mount_lifecycle.py index 1db9522c19..2a82f4d08d 100644 --- a/src/agents/sandbox/session/mount_lifecycle.py +++ b/src/agents/sandbox/session/mount_lifecycle.py @@ -92,7 +92,8 @@ async def with_ephemeral_mounts_removed( ) caller_cancelled = caller_cancelled or restore_cancelled if detach_transition_ambiguous and restore_error is None: - terminal_error = await _terminate_ambiguous_mount_session(session) + terminal_error, terminal_cancelled = await _terminate_ambiguous_mount_session(session) + caller_cancelled = caller_cancelled or terminal_cancelled if terminal_error is not None and detach_error is not None: detach_error.context["terminal_cleanup_failed"] = True @@ -167,7 +168,8 @@ async def _restore_detached_mounts_settled( assert isinstance(additional_errors, list) additional_errors.append(workspace_archive_error_summary(current_error)) if restore_error is not None: - terminal_error = await _terminate_ambiguous_mount_session(session) + terminal_error, terminal_cancelled = await _terminate_ambiguous_mount_session(session) + caller_cancelled = caller_cancelled or terminal_cancelled if terminal_error is not None: restore_error.context["terminal_cleanup_failed"] = True return restore_error, caller_cancelled @@ -188,16 +190,18 @@ async def run_registered_transition() -> None: finally: _MOUNT_TRANSITION_OWNER.reset(owner_token) - task = asyncio.create_task(run_registered_transition()) + task = asyncio.create_task( + run_registered_transition(), + name="agents.mount_transition", + ) + completion = asyncio.create_task(asyncio.wait((task,))) caller_cancelled = False - while not task.done(): + while not completion.done(): try: - await asyncio.shield(task) + await asyncio.shield(completion) except asyncio.CancelledError: - if not task.cancelled(): - caller_cancelled = True - except Exception: - break + caller_cancelled = True + completion.result() try: task.result() except BaseException as exc: @@ -213,12 +217,11 @@ def current_task_owns_mount_transition(session: BaseSandboxSession) -> bool: async def _terminate_ambiguous_mount_session( session: BaseSandboxSession, -) -> BaseException | None: - terminal_error, _caller_cancelled = await _settle_mount_transition( +) -> tuple[BaseException | None, bool]: + return await _settle_mount_transition( session, session._terminate_ambiguous_mount_transition(), ) - return terminal_error def _mount_transition_error( diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index 6838a60a29..2a78623845 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -75,6 +75,16 @@ def _trust_recording_mounts_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: ) +class _AsyncGate: + def __init__(self, started: asyncio.Event, release: asyncio.Event) -> None: + self.started = started + self.release = release + + def __deepcopy__(self, memo: dict[int, object]) -> _AsyncGate: + _ = memo + return self + + class _RecordingMount(Mount): type: str = "modal_recording_mount" mount_strategy: InContainerMountStrategy = Field( @@ -82,6 +92,10 @@ class _RecordingMount(Mount): ) _events: list[tuple[str, str]] = PrivateAttr(default_factory=list) _teardown_error: str | None = PrivateAttr(default=None) + _teardown_gate: _AsyncGate | None = PrivateAttr(default=None) + _restore_error: str | None = PrivateAttr(default=None) + _restore_cancelled: bool = PrivateAttr(default=False) + _restore_gate: _AsyncGate | None = PrivateAttr(default=None) def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount: self._events = events @@ -91,6 +105,38 @@ def bind_teardown_error(self, message: str) -> _RecordingMount: self._teardown_error = message return self + def bind_restore_error(self, message: str) -> _RecordingMount: + self._restore_error = message + return self + + def bind_restore_cancellation( + self, + started: asyncio.Event, + release: asyncio.Event, + ) -> _RecordingMount: + self._restore_gate = _AsyncGate(started, release) + self._restore_cancelled = True + return self + + def bind_teardown_gate( + self, + started: asyncio.Event, + release: asyncio.Event, + ) -> _RecordingMount: + self._teardown_gate = _AsyncGate(started, release) + return self + + def bind_restore_gate( + self, + started: asyncio.Event, + release: asyncio.Event, + *, + error: str | None = None, + ) -> _RecordingMount: + self._restore_gate = _AsyncGate(started, release) + self._restore_error = error + return self + def supported_in_container_patterns( self, ) -> tuple[builtins.type[MountpointMountPattern], ...]: @@ -142,6 +188,9 @@ async def teardown_for_snapshot( if mount._teardown_error is not None: raise RuntimeError(mount._teardown_error) mount._events.append(("unmount", path.as_posix())) + if mount._teardown_gate is not None: + mount._teardown_gate.started.set() + await mount._teardown_gate.release.wait() async def restore_after_snapshot( self, @@ -151,10 +200,25 @@ async def restore_after_snapshot( ) -> None: _ = (strategy, session) mount._events.append(("mount", path.as_posix())) + if mount._restore_gate is not None: + mount._restore_gate.started.set() + await mount._restore_gate.release.wait() + if mount._restore_cancelled: + raise asyncio.CancelledError() + if mount._restore_error is not None: + raise RuntimeError(mount._restore_error) return _Adapter(self) +def _unfinished_mount_transition_tasks() -> list[asyncio.Task[object]]: + return [ + task + for task in asyncio.all_tasks() + if task.get_name() == "agents.mount_transition" and not task.done() + ] + + def _load_modal_module( monkeypatch: pytest.MonkeyPatch, ) -> tuple[Any, list[dict[str, object]], list[str]]: @@ -2434,6 +2498,13 @@ class _FakeSnapshotSandbox: object_id = "sb-123" snapshot_directory: Any + def __init__(self) -> None: + self.terminate_calls = 0 + self.terminate = _with_aio(self._terminate) + + def _terminate(self) -> None: + self.terminate_calls += 1 + sandbox = _FakeSnapshotSandbox() state = modal_module.ModalSandboxSessionState( manifest=Manifest( @@ -2494,6 +2565,9 @@ async def _fake_exec( assert commands[2][0:2] == ["sh", "-lc"] assert "modal-snapshot-directory-ephemeral.tar" in commands[2][2] assert "tar xf" in commands[2][2] + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None @pytest.mark.asyncio @@ -3497,6 +3571,540 @@ async def test_modal_snapshot_directory_persist_only_detaches_durable_workspace_ assert events == [("unmount", "/workspace/actual"), ("mount", "/workspace/actual")] +@pytest.mark.asyncio +async def test_modal_snapshot_directory_persist_settles_cancelled_teardown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + events: list[tuple[str, str]] = [] + teardown_started = asyncio.Event() + teardown_release = asyncio.Event() + mount = ( + _RecordingMount(mount_path=Path("actual"), ephemeral=False) + .bind_events(events) + .bind_teardown_gate(teardown_started, teardown_release) + ) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace", entries={"remote": mount}), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + + persist_task = asyncio.create_task(session.persist_workspace()) + try: + await asyncio.wait_for(teardown_started.wait(), timeout=1) + persist_task.cancel() + await asyncio.sleep(0) + finally: + teardown_release.set() + + with pytest.raises(asyncio.CancelledError): + await persist_task + + assert events == [("unmount", "/workspace/actual"), ("mount", "/workspace/actual")] + assert session._sandbox is not None # noqa: SLF001 + assert session.state.sandbox_id == "sb-123" + assert session._sandbox.terminate_calls == 0 # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_persist_settles_cancelled_remount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + events: list[tuple[str, str]] = [] + restore_started = asyncio.Event() + restore_release = asyncio.Event() + mount = ( + _RecordingMount(mount_path=Path("actual"), ephemeral=False) + .bind_events(events) + .bind_restore_gate(restore_started, restore_release) + ) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace", entries={"remote": mount}), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + + persist_task = asyncio.create_task(session.persist_workspace()) + try: + await asyncio.wait_for(restore_started.wait(), timeout=1) + persist_task.cancel() + await asyncio.sleep(0) + finally: + restore_release.set() + + with pytest.raises(asyncio.CancelledError): + await persist_task + + assert events == [("unmount", "/workspace/actual"), ("mount", "/workspace/actual")] + assert session._sandbox is not None # noqa: SLF001 + assert session.state.sandbox_id == "sb-123" + assert session._sandbox.terminate_calls == 0 # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_persist_terminates_cancelled_failed_remount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + events: list[tuple[str, str]] = [] + restore_started = asyncio.Event() + restore_release = asyncio.Event() + first = _RecordingMount(mount_path=Path("first"), ephemeral=False).bind_events(events) + second = ( + _RecordingMount(mount_path=Path("second"), ephemeral=False) + .bind_events(events) + .bind_restore_gate(restore_started, restore_release, error="remount failed") + ) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + root="/workspace", + entries={"first": first, "second": second}, + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + + persist_task = asyncio.create_task(session.persist_workspace()) + try: + await asyncio.wait_for(restore_started.wait(), timeout=1) + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + persist_task.cancel() + await asyncio.sleep(0) + finally: + restore_release.set() + + with pytest.raises(asyncio.CancelledError): + await persist_task + + assert events == [ + ("unmount", "/workspace/first"), + ("unmount", "/workspace/second"), + ("mount", "/workspace/second"), + ("mount", "/workspace/first"), + ] + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert session._running is False # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_persist_distinguishes_simultaneous_cancellations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + events: list[tuple[str, str]] = [] + restore_started = asyncio.Event() + restore_release = asyncio.Event() + mount = ( + _RecordingMount(mount_path=Path("actual"), ephemeral=False) + .bind_events(events) + .bind_restore_cancellation(restore_started, restore_release) + ) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace", entries={"remote": mount}), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + + persist_task = asyncio.create_task(session.persist_workspace()) + await asyncio.wait_for(restore_started.wait(), timeout=1) + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + restore_release.set() + persist_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await persist_task + + assert events == [("unmount", "/workspace/actual"), ("mount", "/workspace/actual")] + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert session._running is False # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_failed_remount_does_not_create_replacement_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + events: list[tuple[str, str]] = [] + mount = ( + _RecordingMount(mount_path=Path("actual"), ephemeral=False) + .bind_events(events) + .bind_restore_error("remount failed") + ) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + root="/workspace", + entries={ + "tmp.txt": File(content=b"skip", ephemeral=True), + "remote": mount, + }, + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + await session._ensure_sandbox() # noqa: SLF001 + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + + with pytest.raises(WorkspaceArchiveReadError) as exc_info: + await session.persist_workspace() + + assert isinstance(exc_info.value.cause, RuntimeError) + assert str(exc_info.value.cause) == "remount failed" + assert events == [("unmount", "/workspace/actual"), ("mount", "/workspace/actual")] + assert len(create_calls) == 1 + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert session._running is False # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_persist_propagates_cancelled_terminal_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + mount = _RecordingMount( + mount_path=Path("actual"), + ephemeral=False, + ).bind_teardown_error("unmount failed") + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace", entries={"remote": mount}), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + await session._ensure_sandbox() # noqa: SLF001 + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + termination_started = asyncio.Event() + termination_release = asyncio.Event() + + async def _terminate(**kwargs: object) -> None: + sandbox.terminate_calls += 1 + sandbox.terminate_kwargs.append(kwargs) + termination_started.set() + await termination_release.wait() + + sandbox.terminate.aio = _terminate + persist_task = asyncio.create_task(session.persist_workspace()) + try: + await asyncio.wait_for(termination_started.wait(), timeout=1) + persist_task.cancel() + await asyncio.sleep(0) + finally: + termination_release.set() + + with pytest.raises(asyncio.CancelledError): + await persist_task + + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert session._running is False # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_hydrate_settles_cancelled_image_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + events: list[tuple[str, str]] = [] + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + root="/workspace", + entries={ + "remote": _RecordingMount(mount_path=Path("actual"), ephemeral=False).bind_events( + events + ) + }, + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + await session._ensure_sandbox() # noqa: SLF001 + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + mount_started = asyncio.Event() + mount_release = asyncio.Event() + + async def _mount_image(path: str, image: object) -> None: + sandbox.mount_image_calls.append((path, getattr(image, "object_id", None))) + mount_started.set() + await mount_release.wait() + + sandbox.mount_image.aio = _mount_image + hydrate_task = asyncio.create_task( + session.hydrate_workspace( + io.BytesIO(modal_module._encode_snapshot_directory_ref(snapshot_id="snap-dir-123")) + ) + ) + try: + await asyncio.wait_for(mount_started.wait(), timeout=1) + hydrate_task.cancel() + await asyncio.sleep(0) + finally: + mount_release.set() + + with pytest.raises(asyncio.CancelledError): + await hydrate_task + + assert sandbox.mount_image_calls == [("/workspace", "snap-dir-123")] + assert events == [("mount", "/workspace/actual")] + assert session._sandbox is sandbox # noqa: SLF001 + assert session.state.sandbox_id == "sb-123" + assert sandbox.terminate_calls == 0 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_hydrate_terminates_cancelled_failed_image_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + await session._ensure_sandbox() # noqa: SLF001 + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + mount_started = asyncio.Event() + mount_release = asyncio.Event() + + async def _mount_image(path: str, image: object) -> None: + sandbox.mount_image_calls.append((path, getattr(image, "object_id", None))) + mount_started.set() + await mount_release.wait() + raise RuntimeError("mount image failed") + + sandbox.mount_image.aio = _mount_image + hydrate_task = asyncio.create_task( + session.hydrate_workspace( + io.BytesIO(modal_module._encode_snapshot_directory_ref(snapshot_id="snap-dir-123")) + ) + ) + try: + await asyncio.wait_for(mount_started.wait(), timeout=1) + hydrate_task.cancel() + await asyncio.sleep(0) + finally: + mount_release.set() + + with pytest.raises(asyncio.CancelledError): + await hydrate_task + + assert sandbox.mount_image_calls == [("/workspace", "snap-dir-123")] + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_hydrate_maps_inner_image_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + await session._ensure_sandbox() # noqa: SLF001 + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + + async def _mount_image(_path: str, _image: object) -> None: + raise asyncio.CancelledError() + + sandbox.mount_image.aio = _mount_image + + with pytest.raises(WorkspaceArchiveWriteError) as exc_info: + await session.hydrate_workspace( + io.BytesIO(modal_module._encode_snapshot_directory_ref(snapshot_id="snap-dir-123")) + ) + + assert isinstance(exc_info.value.cause, WorkspaceArchiveWriteError) + assert exc_info.value.cause.context["reason"] == "mount_image_cancelled" + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert session._running is False # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_hydrate_distinguishes_simultaneous_cancellations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + await session._ensure_sandbox() # noqa: SLF001 + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + mount_started = asyncio.Event() + mount_release = asyncio.Event() + + async def _mount_image(_path: str, _image: object) -> None: + mount_started.set() + await mount_release.wait() + raise asyncio.CancelledError() + + sandbox.mount_image.aio = _mount_image + hydrate_task = asyncio.create_task( + session.hydrate_workspace( + io.BytesIO(modal_module._encode_snapshot_directory_ref(snapshot_id="snap-dir-123")) + ) + ) + await asyncio.wait_for(mount_started.wait(), timeout=1) + mount_release.set() + hydrate_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await hydrate_task + + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert session._running is False # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_hydrate_propagates_cancelled_terminal_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + await session._ensure_sandbox() # noqa: SLF001 + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + termination_started = asyncio.Event() + termination_release = asyncio.Event() + + async def _mount_image(_path: str, _image: object) -> None: + raise RuntimeError("mount image failed") + + async def _terminate(**kwargs: object) -> None: + sandbox.terminate_calls += 1 + sandbox.terminate_kwargs.append(kwargs) + termination_started.set() + await termination_release.wait() + + sandbox.mount_image.aio = _mount_image + sandbox.terminate.aio = _terminate + hydrate_task = asyncio.create_task( + session.hydrate_workspace( + io.BytesIO(modal_module._encode_snapshot_directory_ref(snapshot_id="snap-dir-123")) + ) + ) + try: + await asyncio.wait_for(termination_started.wait(), timeout=1) + hydrate_task.cancel() + await asyncio.sleep(0) + finally: + termination_release.set() + + with pytest.raises(asyncio.CancelledError): + await hydrate_task + + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert session._running is False # noqa: SLF001 + assert _unfinished_mount_transition_tasks() == [] + + +@pytest.mark.asyncio +async def test_modal_snapshot_directory_hydrate_terminates_cancelled_failed_remount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + events: list[tuple[str, str]] = [] + restore_started = asyncio.Event() + restore_release = asyncio.Event() + mount = ( + _RecordingMount(mount_path=Path("actual"), ephemeral=False) + .bind_events(events) + .bind_restore_gate(restore_started, restore_release, error="remount failed") + ) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace", entries={"remote": mount}), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ) + session = modal_module.ModalSandboxSession.from_state(state) + + hydrate_task = asyncio.create_task( + session.hydrate_workspace( + io.BytesIO(modal_module._encode_snapshot_directory_ref(snapshot_id="snap-dir-123")) + ) + ) + try: + await asyncio.wait_for(restore_started.wait(), timeout=1) + assert session._sandbox is not None # noqa: SLF001 + sandbox = session._sandbox # noqa: SLF001 + hydrate_task.cancel() + await asyncio.sleep(0) + finally: + restore_release.set() + + with pytest.raises(asyncio.CancelledError): + await hydrate_task + + assert sandbox.mount_image_calls == [("/workspace", "snap-dir-123")] + assert events == [("mount", "/workspace/actual")] + assert sandbox.terminate_calls == 1 + assert session._sandbox is None # noqa: SLF001 + assert session.state.sandbox_id is None + assert _unfinished_mount_transition_tasks() == [] + + @pytest.mark.asyncio async def test_modal_create_allows_snapshot_filesystem_with_modal_cloud_bucket_mounts( monkeypatch: pytest.MonkeyPatch, From dcd170519004a469fd5ea4b04fbf0c8ceac5c441 Mon Sep 17 00:00:00 2001 From: Ribhav Jain Date: Sun, 9 Aug 2026 05:08:17 +0400 Subject: [PATCH 243/473] fix(run_state): keep acknowledged safety checks serializable after restore (#4316) --- src/agents/run_state.py | 9 ++++++++- tests/test_run_state.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 38a2f9f1df..295624dd77 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -2712,7 +2712,14 @@ def _deserialize_tool_call_output_raw_item( if output_type == "function_call_output": return _FUNCTION_OUTPUT_ADAPTER.validate_python(normalized_raw_item) if output_type == "computer_call_output": - return _COMPUTER_OUTPUT_ADAPTER.validate_python(normalized_raw_item) + # ComputerCallOutput declares acknowledged_safety_checks as an Iterable, so pydantic + # validation wraps it in a lazy one-shot iterator. Convert it back to plain data so + # the restored state stays JSON-serializable and the acknowledged safety-check + # record survives repeated reads. + return cast( + ComputerCallOutput, + _to_dump_compatible(_COMPUTER_OUTPUT_ADAPTER.validate_python(normalized_raw_item)), + ) if output_type == "local_shell_call_output": return _LOCAL_SHELL_OUTPUT_ADAPTER.validate_python(normalized_raw_item) if output_type == "program_output": diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 0d28ed1cd3..07469bfb57 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3089,6 +3089,44 @@ async def test_deserializes_custom_tool_call_output_items(self): assert restored_item.raw_item == custom_tool_output assert restored_item.output == "custom result" + async def test_deserializes_computer_call_output_acknowledged_safety_checks(self): + """Acknowledged safety checks should survive repeated RunState roundtrips.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ItemAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + + computer_tool_output = { + "type": "computer_call_output", + "call_id": "call_computer_1", + "output": {"type": "computer_screenshot", "image_url": "img"}, + "acknowledged_safety_checks": [ + {"id": "sc_1", "code": "malicious_instructions", "message": "confirm"} + ], + } + state._generated_items.append( + ToolCallOutputItem( + agent=agent, + raw_item=cast(Any, computer_tool_output), + output="done", + ) + ) + + new_state = await RunState.from_json(agent, state.to_json()) + + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolCallOutputItem) + raw_item = cast("dict[str, Any]", restored_item.raw_item) + expected_checks = [{"id": "sc_1", "code": "malicious_instructions", "message": "confirm"}] + assert raw_item["acknowledged_safety_checks"] == expected_checks + # Reading the field twice must not exhaust it. + assert list(raw_item["acknowledged_safety_checks"]) == expected_checks + + # A restored state must serialize again for repeated pause/resume cycles. + roundtripped = await RunState.from_string(agent, new_state.to_string()) + raw_item_again = cast("dict[str, Any]", roundtripped._generated_items[0].raw_item) + assert raw_item_again["acknowledged_safety_checks"] == expected_checks + json.dumps(roundtripped.to_json()) + async def test_deserializes_tool_call_output_custom_data(self): """SDK-only tool output custom data should survive RunState roundtrips.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) From 2b115b66bc2dd1b8b5e2f191de1020583cf891de Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 10:39:32 +0900 Subject: [PATCH 244/473] fix: preserve free-form MCP object schemas (#4310) Co-authored-by: abhay-codes07 --- src/agents/mcp/util.py | 6 +- src/agents/strict_schema.py | 38 +++++- tests/mcp/test_mcp_util.py | 241 ++++++++++++++++++++++++++++++++++++ tests/test_strict_schema.py | 34 +++++ 4 files changed, 313 insertions(+), 6 deletions(-) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index e05f05c916..d1edfd5504 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -536,6 +536,7 @@ def to_function_tool( failure_error_function ) schema, is_strict = copy.deepcopy(tool_input_schema(tool)), False + input_schema_is_empty = schema == {} # MCP spec doesn't require the inputSchema to have `properties`, but OpenAI spec does. if "properties" not in schema: @@ -548,7 +549,10 @@ def to_function_tool( # non-strict. Convert a separate copy so the non-strict fallback keeps # the original schema intact. try: - schema = ensure_strict_json_schema(copy.deepcopy(schema)) + schema = ensure_strict_json_schema( + copy.deepcopy(schema), + _reject_open_objects=not input_schema_is_empty, + ) is_strict = True except Exception as e: if _debug.DONT_LOG_TOOL_DATA: diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 1bab745cb5..6c9ce4bfb1 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -27,12 +27,22 @@ "to not use a strict schema." ) +_OPEN_OBJECT_ERROR = ( + "JSON schema contains an object that permits undeclared properties and cannot be converted " + "to a strict schema without changing its accepted values." +) + +_UNVALIDATED_REF_ERROR = ( + "JSON schema contains a reference whose target was not validated for strict mode." +) + class _NodeBudget: - """Tracks the remaining schema-node expansion budget across the recursion.""" + """Tracks conversion state across the recursion.""" - def __init__(self, limit: int) -> None: + def __init__(self, limit: int, *, reject_open_objects: bool = False) -> None: self.remaining = limit + self.reject_open_objects = reject_open_objects def spend(self) -> None: self.remaining -= 1 @@ -46,16 +56,23 @@ def spend(self) -> None: def ensure_strict_json_schema( schema: dict[str, Any], + *, + _reject_open_objects: bool = False, ) -> dict[str, Any]: """Mutates the given JSON schema to ensure it conforms to the `strict` standard that the OpenAI API expects. """ if schema == {}: return copy.deepcopy(_EMPTY_SCHEMA) - converted = _ensure_strict_json_schema( - schema, path=(), root=schema, budget=_NodeBudget(_MAX_SCHEMA_NODES) + budget = _NodeBudget(_MAX_SCHEMA_NODES, reject_open_objects=_reject_open_objects) + return _ensure_strict_root( + _ensure_strict_json_schema( + schema, + path=(), + root=schema, + budget=budget, + ) ) - return _ensure_strict_root(converted) def _ensure_strict_root(schema: dict[str, Any]) -> dict[str, Any]: @@ -116,6 +133,14 @@ def _ensure_strict_json_schema( elif typ is None and json_schema.get("additionalProperties", False) is not False: raise UserError(_ADDITIONAL_PROPERTIES_ERROR) is_object = typ == "object" or (is_list(typ) and "object" in typ) + has_no_declared_properties = "properties" not in json_schema or properties == {} + if ( + budget.reject_open_objects + and is_object + and has_no_declared_properties + and json_schema.get("additionalProperties") is not False + ): + raise UserError(_OPEN_OBJECT_ERROR) if is_object and "additionalProperties" not in json_schema: json_schema["additionalProperties"] = False elif ( @@ -223,6 +248,9 @@ def _ensure_strict_json_schema( # we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid return _ensure_strict_json_schema(json_schema, path=path, root=root, budget=budget) + if budget.reject_open_objects and "$ref" in json_schema: + raise UserError(_UNVALIDATED_REF_ERROR) + return json_schema diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 0400490581..607d8df19d 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -1,4 +1,5 @@ import asyncio +import copy import dataclasses import json import logging @@ -1867,6 +1868,246 @@ def test_to_function_tool_failed_strict_conversion_keeps_original_schema(): } +@pytest.mark.parametrize( + "free_form_schema", + [ + {"type": "object", "description": "key/value pairs"}, + {"type": "object", "properties": {}}, + {"type": "object", "properties": {}, "required": []}, + { + "type": "object", + "properties": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema", + }, + {"type": "object", "properties": {}, "$comment": "Arbitrary values."}, + ], + ids=[ + "properties-omitted", + "properties-empty", + "required-empty", + "schema-metadata", + "comment-metadata", + ], +) +def test_to_function_tool_free_form_object_arg_falls_back_to_non_strict(free_form_schema): + schema = { + "type": "object", + "properties": { + "target": {"type": "string"}, + "keysAndValues": free_form_schema, + }, + "required": ["target", "keysAndValues"], + } + tool = MCPTool(name="set_properties", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +@pytest.mark.parametrize( + "schema", + [ + {"type": "object", "description": "Arbitrary key/value pairs"}, + {"type": "object", "properties": {}}, + {"type": "object", "properties": {}, "required": []}, + { + "type": "object", + "properties": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema", + }, + {"type": "object", "properties": {}, "$comment": "Arbitrary values."}, + ], + ids=[ + "properties-omitted", + "properties-empty", + "required-empty", + "schema-metadata", + "comment-metadata", + ], +) +def test_to_function_tool_free_form_root_falls_back_to_non_strict(schema): + tool = MCPTool(name="set_properties", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == {**schema, "properties": {}} + + +def test_to_function_tool_finds_free_form_object_in_array_items(): + schema = { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "$comment": "Arbitrary values.", + }, + }, + }, + } + tool = MCPTool(name="set_properties", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_does_not_convert_schema_when_conversion_is_disabled(): + schema = {"type": "object", "properties": {"value": {"type": "string"}}} + tool = MCPTool(name="non_strict", inputSchema=schema) + + with patch( + "agents.mcp.util.ensure_strict_json_schema", + side_effect=AssertionError("Strict conversion should not run."), + ): + function_tool = MCPUtil.to_function_tool( + tool, FakeMCPServer(), convert_schemas_to_strict=False + ) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +@pytest.mark.parametrize( + "schema", + [ + {}, + {"type": "object", "additionalProperties": False}, + { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + ], + ids=["empty-schema", "explicitly-closed", "declared-property"], +) +def test_to_function_tool_strictable_closed_and_shaped_objects_stay_strict(schema): + tool = MCPTool(name="strictable", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is True + assert function_tool.params_json_schema["additionalProperties"] is False + + +def test_to_function_tool_advanced_open_root_falls_back_without_schema_evaluation(): + schema = { + "type": "object", + "allOf": [{"type": "object", "properties": {"value": {"type": "string"}}}], + } + tool = MCPTool(name="advanced", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == {**schema, "properties": {}} + + +@pytest.mark.parametrize( + "schema", + [ + { + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "object", "properties": {}}, + {"type": "null"}, + ] + } + }, + }, + { + "type": "object", + "properties": { + "value": { + "oneOf": [ + {"type": "object", "properties": {}}, + {"type": "null"}, + ] + } + }, + }, + { + "type": "object", + "properties": { + "value": {"allOf": [{"type": "object", "properties": {}}]}, + }, + }, + { + "type": "object", + "properties": {"value": {"$ref": "#/$defs/value"}}, + "$defs": {"value": {"type": "object", "properties": {}}}, + }, + { + "type": "object", + "properties": {"value": {"$ref": "#/definitions/value"}}, + "definitions": {"value": {"type": "object", "properties": {}}}, + }, + { + "type": "object", + "properties": {"value": {"$ref": "#/components/schemas/value"}}, + "components": {"schemas": {"value": {"type": "object", "properties": {}}}}, + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/value", + "description": "Arbitrary values.", + } + }, + "components": {"schemas": {"value": {"type": "object", "properties": {}}}}, + }, + ], + ids=[ + "any-of", + "one-of", + "all-of", + "defs", + "definitions", + "pure-ref-unvisited-target", + "ref-reentry", + ], +) +def test_to_function_tool_finds_free_form_objects_in_supported_schema_nodes(schema): + tool = MCPTool(name="nested", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +@pytest.mark.parametrize( + ("ref", "definitions"), + [ + ("#/$defs/value", {"value": {"type": "string"}}), + ("#/$defs/a%20b", {"a b": {"type": "string"}}), + ], + ids=["ordinary", "percent-encoded"], +) +def test_to_function_tool_preserved_pure_refs_fall_back_to_non_strict(ref, definitions): + schema = { + "$defs": definitions, + "type": "object", + "properties": {"payload": {"$ref": ref}}, + } + original_schema = copy.deepcopy(schema) + tool = MCPTool(name="pure_ref", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == original_schema + assert schema == original_schema + + def test_to_function_tool_nullable_root_falls_back_to_non_strict(): schema = { "anyOf": [ diff --git a/tests/test_strict_schema.py b/tests/test_strict_schema.py index 43b6f57461..04093ea02e 100644 --- a/tests/test_strict_schema.py +++ b/tests/test_strict_schema.py @@ -1,3 +1,5 @@ +import copy + import pytest from agents.exceptions import UserError @@ -45,6 +47,38 @@ def test_object_without_additional_properties(): assert result["properties"]["a"] == {"type": "string"} +def test_open_object_rejection_is_opt_in(): + schema = {"type": "object", "properties": {}} + + result = ensure_strict_json_schema(schema.copy()) + + assert result["additionalProperties"] is False + with pytest.raises(UserError, match="permits undeclared properties"): + ensure_strict_json_schema(schema.copy(), _reject_open_objects=True) + + +@pytest.mark.parametrize( + ("ref", "definitions"), + [ + ("#/$defs/value", {"value": {"type": "string"}}), + ("#/$defs/a%20b", {"a b": {"type": "string"}}), + ], + ids=["ordinary", "percent-encoded"], +) +def test_open_object_rejection_rejects_preserved_pure_refs(ref, definitions): + schema = { + "$defs": definitions, + "type": "object", + "properties": {"value": {"$ref": ref}}, + } + + default_result = ensure_strict_json_schema(copy.deepcopy(schema)) + + assert default_result["properties"]["value"] == {"$ref": ref} + with pytest.raises(UserError, match="reference whose target was not validated"): + ensure_strict_json_schema(copy.deepcopy(schema), _reject_open_objects=True) + + def test_typeless_root_is_normalized_to_object(): result = ensure_strict_json_schema({"properties": {"a": {"type": "string"}}}) From 5d3324918fb0cc9abff956309a249dc8fe2dc665 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 11:30:47 +0900 Subject: [PATCH 245/473] docs: adjust the documentation policies --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa00d9547f..b921f249bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,8 @@ Treat the parameter and dataclass field order of exported runtime APIs as a comp - Documentation is published to the live site, so coordinate SDK behavior changes and docs carefully. If docs describe behavior that is not released yet, either delay the docs change until the SDK release is available or split it into a follow-up PR. - Treat translation-safe English as a documentation compatibility requirement. In new or materially rewritten translatable prose under `docs/` (excluding generated API reference pages), state the actor, scope, ownership, ordering, modality, and lifecycle boundary explicitly whenever they affect the meaning. Use exact API identifiers in inline code, and replace ambiguous pronouns, overloaded nouns, or shorthand when a small clarification can prevent a materially different translation. Do not change the documented behavior merely to make a sentence easier to translate. -- Before declaring new or materially rewritten translatable prose complete, run `docs/scripts/translate_docs.py --mode full --file ` for every affected English page, inspect the generated Japanese, Korean, and Chinese against the English source, and revise the English or the narrowly applicable translation controls until no decision-relevant ambiguity, scope drift, identifier corruption, or unstable terminology remains. A successful translation command without semantic review is not sufficient. Pure link, formatting, or typo corrections that do not change translatable meaning may skip this translation review. -- Do not hand-edit or commit generated files under `docs/ja`, `docs/ko`, or `docs/zh`; restore them after translation review. Add or change a fixed translation mapping only when actual cross-document translation evidence shows that one stable target term is correct across contexts. Prefer contextual guidance and established target-language developer terminology, including standard English terms, over a large or rigid mapping table. If the required translation credentials or review capability are unavailable, report the validation as incomplete instead of claiming the documentation change is ready. +- For new or materially rewritten translatable prose, use a lightweight cross-language review of only the changed English sentences and their immediate context. Have an independent reviewer or review pass inspect the source from Japanese, Korean, and Chinese translation perspectives and report only concrete risks such as an ambiguous actor, scope, ownership, ordering, modality, lifecycle boundary, overloaded SDK term, or identifier corruption. Resolve concrete findings in the English source and review the revised lines once. Do not generate full localized pages for routine documentation changes. Pure link, formatting, typo, and other edits that do not change translatable meaning may skip this review. +- If a concrete concern cannot be resolved confidently from the English source, use a temporary translation of only the disputed sentence or paragraph as a focused probe; do not write or commit generated localized files. Reserve `docs/scripts/translate_docs.py --mode full --file ` and broader Japanese, Korean, and Chinese output review for changes to the translation tooling or translation controls, explicit localization work, or an explicitly requested broad translation audit. Add or change a fixed translation mapping only when actual cross-document evidence shows that one stable target term is correct across contexts. Prefer contextual guidance and established target-language developer terminology, including standard English terms, over a large or rigid mapping table. - Treat runnable docs snippets as API compatibility checks. Before adding OpenAI API, provider, Responses, Realtime, WebSocket, or SDK constructor examples, verify the shown arguments and call shape against the actual implementation. - When adding or updating code in `examples/` or runnable `docs/` snippets, import Agents SDK decorators from `agents.decorators`. Prefer `tool` over `function_tool`; keep non-decorator SDK imports on their existing public import paths. - Do not let untrusted sandbox manifests opt themselves out of host filesystem or base-directory boundaries. Escape hatches for local source materialization must be controlled by trusted application code at the call site, not by serialized manifest data. From d619466fad39bcb693b6bbadc558f2616cb72d58 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 8 Aug 2026 23:00:29 -0500 Subject: [PATCH 246/473] fix(run_state): preserve default-valued fields in serialized tool output (#4307) --- src/agents/run_state.py | 9 ++++- tests/test_run_state.py | 88 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 295624dd77..4437a44e63 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -1230,7 +1230,14 @@ def _serialize_item( serialized_output = item.output try: if hasattr(serialized_output, "model_dump"): - serialized_output = serialized_output.model_dump(exclude_unset=True) + # ``output`` is the tool's actual return value, not a wire item, so keep + # fields left at their defaults. ``exclude_unset`` would drop them and make + # the restored ``.output`` disagree with the full model-facing ``raw_item``. + # Stay in Python mode and let ``_ensure_json_compatible`` handle JSON + # conversion below: ``mode="json"`` raises on values like non-UTF-8 bytes, + # which would trip the fallback and replace the whole structured output with + # an opaque string instead of a dict. + serialized_output = serialized_output.model_dump() elif dataclasses.is_dataclass(serialized_output): serialized_output = dataclasses.asdict(serialized_output) # type: ignore[arg-type] serialized_output = _ensure_json_compatible(serialized_output) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 07469bfb57..e1a083d46a 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3158,6 +3158,94 @@ async def test_deserializes_tool_call_output_custom_data(self): assert isinstance(restored_item, ToolCallOutputItem) assert restored_item.custom_data == {"ui": {"kind": "chart"}, "ids": ["a", "b"]} + async def test_pydantic_tool_output_preserves_default_fields(self): + """A structured tool output's default-valued fields must survive RunState roundtrips. + + ``ToolCallOutputItem.output`` holds the tool's actual return value. Serializing it with + ``exclude_unset`` drops fields left at their defaults, so a resumed run would expose an + incomplete ``.output`` that disagrees with the full model-facing ``raw_item`` payload. + """ + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ItemAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + + class WeatherReport(BaseModel): + temperature: int + unit: str = "celsius" + humidity: int | None = None + + # Only ``temperature`` is set explicitly; ``unit`` and ``humidity`` keep their defaults. + output = WeatherReport(temperature=20) + raw_tool_output = { + "type": "function_call_output", + "call_id": "call_weather", + "output": '{"temperature":20,"unit":"celsius","humidity":null}', + } + state._generated_items.append( + ToolCallOutputItem(agent=agent, raw_item=raw_tool_output, output=output) + ) + + json_data = state.to_json() + assert json_data["generated_items"][0]["output"] == { + "temperature": 20, + "unit": "celsius", + "humidity": None, + } + + new_state = await RunState.from_json(agent, json_data) + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolCallOutputItem) + assert restored_item.output == { + "temperature": 20, + "unit": "celsius", + "humidity": None, + } + + async def test_non_utf8_bytes_tool_output_keeps_dict_shape(self): + """A structured output with non-UTF-8 bytes must stay a dict, not collapse to a string. + + Serializing in Python mode keeps default-valued fields and lets ``_ensure_json_compatible`` + stringify only the offending value. Dumping with ``mode="json"`` would instead raise on the + non-UTF-8 bytes, trip the broad fallback, and replace the whole structured output with an + opaque ``str(item.output)``. + """ + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ItemAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + + class BlobResult(BaseModel): + payload: bytes + label: str = "default-label" + note: str | None = None + + # An untyped function tool can return an arbitrary Pydantic model; here one field holds + # non-UTF-8 bytes while ``label``/``note`` are left at their defaults. + output = BlobResult(payload=b"\xff\xfe") + raw_tool_output = { + "type": "function_call_output", + "call_id": "call_blob", + "output": "blob stored", + } + state._generated_items.append( + ToolCallOutputItem(agent=agent, raw_item=raw_tool_output, output=output) + ) + + expected = { + "payload": str(b"\xff\xfe"), + "label": "default-label", + "note": None, + } + + json_data = state.to_json() + serialized_output = json_data["generated_items"][0]["output"] + assert isinstance(serialized_output, dict) + assert serialized_output == expected + + new_state = await RunState.from_json(agent, json_data) + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolCallOutputItem) + assert restored_item.output == expected + async def test_serializes_original_input_with_function_call_output(self): """Test that original_input with function_call_output items is preserved.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) From aec2dfaa127a811ec84d401bf2f5e72558c350f7 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 8 Aug 2026 23:55:41 -0500 Subject: [PATCH 247/473] fix(items): strip output-only created_by when replaying model output as input (#4308) --- src/agents/items.py | 38 +++++++++--- tests/test_items_helpers.py | 119 +++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/src/agents/items.py b/src/agents/items.py index 7e298ae0b7..115be2a639 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -211,7 +211,7 @@ def _tool_search_item_to_input_item( def _output_item_to_input_item(raw_item: Any) -> TResponseInputItem: - """Convert an output item into replayable input, normalizing tool_search items.""" + """Convert an output item into replayable input, stripping output-only metadata.""" item_type = ( raw_item.get("type") if isinstance(raw_item, dict) else getattr(raw_item, "type", None) ) @@ -219,11 +219,32 @@ def _output_item_to_input_item(raw_item: Any) -> TResponseInputItem: return _tool_search_item_to_input_item(raw_item) if isinstance(raw_item, dict): - return cast(TResponseInputItem, dict(raw_item)) - if isinstance(raw_item, BaseModel): - return cast(TResponseInputItem, raw_item.model_dump(exclude_unset=True)) + payload = dict(raw_item) + elif isinstance(raw_item, BaseModel): + payload = raw_item.model_dump(exclude_unset=True) + else: + raise AgentsException(f"Unexpected raw item type: {type(raw_item)}") - raise AgentsException(f"Unexpected raw item type: {type(raw_item)}") + # ``created_by`` is server-assigned, output-only metadata that is absent from the Responses + # input-item schema, so it must not be replayed back to the API. Several output item types + # carry it (apply_patch/shell calls and tool-call outputs); the tool_search branch above + # already drops it, so do the same for every other item type. + payload.pop("created_by", None) + if item_type == "shell_call_output": + # ``shell_call_output.output`` is a list of content chunks that each carry their own + # output-only ``created_by``. ``payload`` was only shallow-copied above, so rebuild the + # list with fresh chunk copies to strip the nested field without mutating the caller's + # original mapping. Mirrors the two-level stripping the runner already does in + # ``turn_resolution``. + chunks = payload.get("output") + if isinstance(chunks, list): + payload["output"] = [ + {key: value for key, value in chunk.items() if key != "created_by"} + if isinstance(chunk, dict) + else chunk + for chunk in chunks + ] + return cast(TResponseInputItem, payload) def _copy_tool_search_mapping(raw_item: Mapping[str, Any]) -> dict[str, Any]: @@ -694,9 +715,10 @@ class ModelResponse: def to_input_items(self) -> list[TResponseInputItem]: """Convert the output into a list of input items suitable for passing to the model.""" - # Most output items can be replayed via a direct model_dump. Tool-search items carry - # output-only metadata such as `created_by`, so they must go through the same replay - # sanitizer used elsewhere in the runtime. + # Most output items can be replayed via a direct model_dump, but several types (tool + # search, apply_patch/shell calls, and tool-call outputs) carry output-only metadata + # such as `created_by` that is not part of the input schema, so they go through the + # replay sanitizer that strips it before the items are sent back to the model. return [_output_item_to_input_item(it) for it in self.output] diff --git a/tests/test_items_helpers.py b/tests/test_items_helpers.py index 2dafcf8c3d..264f631092 100644 --- a/tests/test_items_helpers.py +++ b/tests/test_items_helpers.py @@ -7,6 +7,7 @@ import pytest from openai.types.responses.computer_action import Click as BatchedClick, Type as BatchedType +from openai.types.responses.response_apply_patch_tool_call import ResponseApplyPatchToolCall from openai.types.responses.response_computer_tool_call import ( ActionScreenshot, ResponseComputerToolCall, @@ -16,7 +17,13 @@ from openai.types.responses.response_file_search_tool_call_param import ( ResponseFileSearchToolCallParam, ) +from openai.types.responses.response_function_shell_tool_call_output import ( + ResponseFunctionShellToolCallOutput, +) from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call_output_item import ( + ResponseFunctionToolCallOutputItem, +) from openai.types.responses.response_function_tool_call_param import ResponseFunctionToolCallParam from openai.types.responses.response_function_web_search import ( ActionSearch, @@ -46,7 +53,7 @@ TResponseInputItem, Usage, ) -from agents.items import ToolCallItem, ToolCallOutputItem +from agents.items import ToolCallItem, ToolCallOutputItem, TResponseOutputItem def make_message( @@ -561,6 +568,116 @@ def test_to_input_items_for_tool_search_strips_created_by() -> None: ] +def test_to_input_items_strips_created_by_for_non_tool_search_items() -> None: + """Output-only ``created_by`` must be stripped for every replayed item, not just tool search. + + ``created_by`` is server-assigned metadata that is absent from the Responses input-item + schema, so replaying it back to the API is invalid. The tool-search branch already strips + it; apply-patch calls and tool-call outputs (which also carry the field) must behave the + same way. + """ + apply_patch_call = ResponseApplyPatchToolCall.model_validate( + { + "id": "apc_1", + "call_id": "call_1", + "type": "apply_patch_call", + "status": "completed", + "operation": {"type": "delete_file", "path": "foo.py"}, + "created_by": "program_1", + } + ) + function_call_output = ResponseFunctionToolCallOutputItem.model_validate( + { + "id": "fco_1", + "call_id": "call_1", + "type": "function_call_output", + "output": "done", + "status": "completed", + "created_by": "program_1", + } + ) + + resp = ModelResponse( + output=[apply_patch_call, function_call_output], usage=Usage(), response_id=None + ) + input_items = resp.to_input_items() + + assert input_items == [ + { + "id": "apc_1", + "call_id": "call_1", + "operation": {"path": "foo.py", "type": "delete_file"}, + "status": "completed", + "type": "apply_patch_call", + }, + { + "id": "fco_1", + "call_id": "call_1", + "output": "done", + "status": "completed", + "type": "function_call_output", + }, + ] + assert all("created_by" not in item for item in input_items) + + +def test_to_input_items_strips_nested_created_by_from_shell_call_output() -> None: + """``shell_call_output`` carries ``created_by`` at the item level and inside each output chunk. + + Both levels are output-only and absent from the input schema, so both must be stripped on + replay (mirroring the two-level stripping the runner does in ``turn_resolution``). The + original mapping input must not be mutated in the process. + """ + shell_output = ResponseFunctionShellToolCallOutput.model_validate( + { + "id": "sco_1", + "call_id": "call_1", + "type": "shell_call_output", + "status": "completed", + "created_by": "program_top", + "output": [ + { + "outcome": {"type": "exit", "exit_code": 0}, + "stdout": "hi", + "stderr": "", + "created_by": "program_chunk", + } + ], + } + ) + # A dict-form item shares its nested chunk dicts with the caller, so replaying must not + # mutate them. + raw_item = shell_output.model_dump(exclude_unset=True) + original_chunk = raw_item["output"][0] + + resp = ModelResponse( + output=[cast(TResponseOutputItem, raw_item)], usage=Usage(), response_id=None + ) + input_items = resp.to_input_items() + + assert input_items == [ + { + "id": "sco_1", + "call_id": "call_1", + "type": "shell_call_output", + "status": "completed", + "output": [ + { + "outcome": {"type": "exit", "exit_code": 0}, + "stdout": "hi", + "stderr": "", + } + ], + } + ] + replayed = cast(dict[str, Any], input_items[0]) + assert "created_by" not in replayed + assert "created_by" not in replayed["output"][0] + # The caller's original mapping (and its nested chunk) is untouched. + assert original_chunk["created_by"] == "program_chunk" + assert raw_item["created_by"] == "program_top" + + def test_input_to_new_input_list_copies_the_ones_produced_by_pydantic() -> None: """Validated input items should be copied and made JSON dump compatible.""" original = ResponseOutputMessageParam( From 8bf878a69fd6b356576bea4384e90952e216d2f8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 14:01:43 +0900 Subject: [PATCH 248/473] docs: clarify the docs change timing in PRs --- AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b921f249bd..b885b79026 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,10 @@ Work in the user's current checkout and on the current branch by default. If the If isolation or a different checkout is needed, explain why and ask the user before changing Git state. This requirement also applies when another rule or workflow recommends a linked worktree: stop and request approval instead of choosing or creating one automatically. +### Documentation Release Timing + +When a feature or bug fix introduces behavior that is not yet available in the latest published release, do not include `docs/` changes that describe that unreleased behavior in the feature or bug-fix pull request, and do not expect those changes as part of that pull request. Handle them in a separate docs-only pull request so maintainers can coordinate its merge timing with the release that makes the documentation accurate. This exception applies only when the documentation would be incorrect for the latest published release; documentation that is already accurate for released behavior remains part of the normal change scope. + ### Scope Discipline and Complexity Reset - Implement the narrowest explicitly stated set of behaviors that satisfies the request. Do not interpret every shape accepted by a host-language protocol, third-party library, or reflection API unless those shapes are required by the task or supported behavior shipped in the latest release. @@ -87,7 +91,6 @@ Treat the parameter and dataclass field order of exported runtime APIs as a comp ### Platform, Docs, and Security Review -- Documentation is published to the live site, so coordinate SDK behavior changes and docs carefully. If docs describe behavior that is not released yet, either delay the docs change until the SDK release is available or split it into a follow-up PR. - Treat translation-safe English as a documentation compatibility requirement. In new or materially rewritten translatable prose under `docs/` (excluding generated API reference pages), state the actor, scope, ownership, ordering, modality, and lifecycle boundary explicitly whenever they affect the meaning. Use exact API identifiers in inline code, and replace ambiguous pronouns, overloaded nouns, or shorthand when a small clarification can prevent a materially different translation. Do not change the documented behavior merely to make a sentence easier to translate. - For new or materially rewritten translatable prose, use a lightweight cross-language review of only the changed English sentences and their immediate context. Have an independent reviewer or review pass inspect the source from Japanese, Korean, and Chinese translation perspectives and report only concrete risks such as an ambiguous actor, scope, ownership, ordering, modality, lifecycle boundary, overloaded SDK term, or identifier corruption. Resolve concrete findings in the English source and review the revised lines once. Do not generate full localized pages for routine documentation changes. Pure link, formatting, typo, and other edits that do not change translatable meaning may skip this review. - If a concrete concern cannot be resolved confidently from the English source, use a temporary translation of only the disputed sentence or paragraph as a focused probe; do not write or commit generated localized files. Reserve `docs/scripts/translate_docs.py --mode full --file ` and broader Japanese, Korean, and Chinese output review for changes to the translation tooling or translation controls, explicit localization work, or an explicitly requested broad translation audit. Add or change a fixed translation mapping only when actual cross-document evidence shows that one stable target term is correct across contexts. Prefer contextual guidance and established target-language developer terminology, including standard English terms, over a large or rigid mapping table. @@ -259,7 +262,7 @@ make tests - Use the template at `.github/PULL_REQUEST_TEMPLATE/pull_request_template.md`; include a summary, test plan, and issue number if applicable. - In copy-ready GitHub text, use native issue and pull-request references: exactly `#123` for this repository and `owner/repo#123` for another repository. Do not qualify same-repository references as `openai/openai-agents-python#123`. Preserve closing forms such as `Fixes #123` or `Resolves #123`. Never wrap these references in Markdown links such as `[PR #123](https://github.com/owner/repo/pull/123)` or `[#123](...)`; those Codex-friendly links require manual cleanup after pasting into GitHub. Use descriptive Markdown links only for external resources or GitHub targets that cannot be expressed as a native issue or pull-request reference. -- Add tests for new behavior when feasible and update documentation for user-facing changes. +- Add tests for new behavior when feasible. Update documentation for user-facing changes, except unreleased-behavior documentation that must follow the separate docs-only pull request policy above. - Run `make format`, `make lint`, `make typecheck`, and `make tests` before marking work ready. - Commit messages should be concise and written in the imperative mood. Small, focused commits are preferred. From a6548287cfcd5bcd06dde2f5beeea02b08fab9b0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 13:59:30 +0900 Subject: [PATCH 249/473] perf: streamline implementation final review --- .../implementation-final-review/SKILL.md | 50 +++---- .../references/reviewer-brief.md | 74 +++++++--- .../scripts/test_skill_contract.py | 135 +++++++++++++++++- AGENTS.md | 2 + 4 files changed, 211 insertions(+), 50 deletions(-) diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index b2b1664e73..8449c5c136 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -1,6 +1,6 @@ --- name: implementation-final-review -description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use self-contained reviewer briefs and concurrent independent reviewers for elevated-risk changes, overlap non-mutating final repository verification with reviewer waits on the same frozen fingerprint, preserve clean evidence for unchanged review components, batch and fix actionable findings, trigger a complexity reset when related fixes expand the design, and escalate a non-converging loop after at most six fingerprint rounds. +description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use compact self-contained reviewer packets and two concurrent no-history independent reviewers per round, overlap non-mutating final repository verification with long event-driven reviewer waits on the same frozen fingerprint, preserve clean evidence for unchanged semantic components, close repeated root-cause groups instead of accumulating local patches, and enforce one task-global bounded round ledger. --- # Implementation Final Review @@ -13,8 +13,10 @@ Treat implementation and final review as separate phases. Reconstruct the change - Use the merge-base three-dot diff for patch ownership and the latest release tag separately for released compatibility. - Require independent review. A same-context self-review cannot satisfy the clean-review gate. - Freeze task-owned content while reviewers inspect a fingerprint. +- Start independent reviewers without inherited conversation history. Fresh judgment does not require repeatedly replaying the implementer's context. - Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. - Never weaken final repository verification. Component-aware review invalidation reduces repeated review, not required build or test gates. +- Keep one task-global round ledger and one bounded review budget across pauses, compaction, handoff, renaming, and resumed work. ## Workflow @@ -43,13 +45,13 @@ Treat implementation and final review as separate phases. Reconstruct the change - unnecessary machinery or duplicated source of truth; - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. -10. Start a fingerprint-round counter at 1. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Separate pathspecs into `runtime`, `tests-examples`, and `release-metadata` components when those boundaries exist; use repository-appropriate names otherwise. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file runtime=runtime.paths --component-pathspec-file tests-examples=tests.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by root cause, severity, action, and whether each finding is new, repeated, or reintroduced. -11. Prepare one self-contained reviewer brief per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. -12. Freeze task-owned content while reviewers for a round are running. For normal risk, dispatch one independent reviewer. For elevated risk or a prior P0/P1, dispatch two independent reviewers concurrently on the same fingerprint and give them complementary primary dimensions. A broad multi-boundary normal-risk diff may also use two concurrent specialists when that is likely to collect findings in one round. Every reviewer sees the complete raw diff and may report blockers outside its specialty. Wait for every reviewer in the round before editing so findings can be grouped and fixed as one batch. Use one multi-target wait or the platform's first-completion wait when available; do not poll reviewers separately, ask for progress, or make them repeat shared evidence collection. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. During an iterative review round, use the narrowest of three evidence-based choices: for changes unrelated to every `review_optional` owner, run `make tests-review`; for a leaf subsystem change, run `make tests-review` plus that subsystem's complete test file or directory without a marker filter; for cross-cutting core or shared test-infrastructure changes, run `make tests`. Inspect the current marker owners before choosing. The reduced check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete `make tests` gate. If the affected boundary is uncertain, run `make tests`. Record combined and component fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when both fingerprints match the reviewed fingerprint exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit for the changed fingerprint. -13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the round and all clean credit. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. If only tests, examples, or release metadata changed without changing required behavior, compatibility, assertions about runtime behavior, or the scope contract, preserve clean credit for unchanged components and require delta reviews of every changed component plus its boundary with runtime using the original risk tier: one independent reviewer for normal risk or two concurrent independent reviewers for elevated risk. Any ambiguity invalidates the affected clean credit. -14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. A valid response must state the verdict, exact reviewed fingerprints, dimensions actually checked, coverage of every assigned inventory row and changed public/shared-state surface, focused probes run or explicitly none, and remaining uncertainty. A bare `clean`, generic checklist, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared coverage and reject the round if any inventory row or selected high-risk dimension remains unreviewed. -15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. If the repository requires a separate strategy pass, rerun it when the fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification for every affected boundary. -16. If a second related finding adds another condition, state, resolver step, protocol hop, or test permutation to the same abstraction, stop local patching and run the complexity reset. +10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget is six fingerprint rounds for the entire task. Only explicit user authorization may start another bounded budget, and the existing ledger and root-cause history must remain attached. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. +11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. +12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Prefer one event-driven wait of 180-300 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages; after one reviewer completes, continue waiting only for the remaining reviewer. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. During an iterative review round, use the narrowest evidence-based affected-boundary check: for changes unrelated to every `review_optional` owner, run `make tests-review`; for a leaf subsystem change, run `make tests-review` plus that subsystem's complete test file or directory without a marker filter; for cross-cutting core or shared test-infrastructure changes, run `make tests`. Inspect the current marker owners before choosing. Prefer an already successful same-fingerprint check over rerunning it, and never replay cumulative historical verification. The reduced check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete `make tests` gate. If the affected boundary is uncertain, run `make tests`. Record combined and component fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when both fingerprints match the reviewed fingerprint exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit only for changed or dependency-invalidated components and for any final gate whose fingerprint no longer matches. +13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged. Require two concurrent independent delta reviews of every changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. +14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. +15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. +16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the root-cause ID closed. Do not reopen it for another local patch without new contract evidence; if it cannot be closed coherently, escalate instead of consuming more rounds. 17. Increment the fingerprint round and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> review without waiting for another user prompt. 18. Apply the non-convergence guard before another local fix: - If the same root-cause group produces another P0/P1 after a complexity reset, return to the merge base and replace task-owned branch-local machinery with the narrowest coherent implementation. @@ -57,26 +59,26 @@ Treat implementation and final review as separate phases. Reconstruct the change - If the same root-cause group produces actionable findings in three finding-bearing rounds, or the narrower reimplementation still produces the same root-cause P0/P1, escalate early rather than consuming the round budget. - If four rounds complete without a shrinking or stable diff and falling finding severity, escalate early. 19. Stop successfully only after the required clean-review condition is met on the exact reviewed content and every required reviewer output has passed the acceptance gate: - - normal-risk change: one independent clean review; - - elevated-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint; launch them concurrently rather than serially. - - component-only post-review edit: clean credit for every unchanged component plus clean independent delta reviews covering all changed components and their runtime boundary, using one reviewer for normal risk or two concurrent reviewers for elevated risk. + - normal-risk change: two independent clean reviews of the same fingerprint, launched concurrently; + - elevated-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint with complementary high-risk specialties, launched concurrently. + - component-only post-review edit: clean credit for every unchanged component plus two concurrent clean independent delta reviews covering all changed components and their runtime boundary. 20. After the clean-review condition is met, complete the repository's code-change verification or accept the overlapped result from step 12 only when every mandatory command succeeded in the repository-required order against the exact clean-reviewed fingerprint, execution did not mutate reviewed content or create an ambiguous repository-state change, and the final combined and component fingerprints still match. If verification was still running, wait for it; do not rerun successful exact-fingerprint work merely because review completed later. If reviewer findings caused an edit, run the required verification again for the new fingerprint. Classify any final-gate edit before invalidating review evidence: - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean or delta-review evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. -21. Stop the autonomous loop after six fingerprint rounds. This is an absolute cap, not a target. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or continue with another bounded loop. +21. Stop the autonomous loop when the task-global ledger reaches its current six-round budget. This is an absolute cap, not a target, and it does not reset when execution pauses or context changes. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or explicitly authorize another bounded budget. If the user authorizes continuation, append the new budget to the same ledger rather than replacing its history. -Maintain one compact round ledger throughout the loop: +Maintain one compact round ledger throughout the loop and persist it as a durable, task-global artifact: `Round | component fingerprints | root-cause groups | highest severity | complexity delta | action | clean credit` -Update it only at a meaningful state transition: round start, accepted finding batch, complexity reset, clean result, or verification result. Do not emit repeated waiting messages when neither reviewer state nor repository content changed. +Persist enough task identity, used and authorized round budgets, fingerprints, root-cause closure state, and clean credit to resume without reconstructing prior rounds. Update it only at a meaningful state transition: round start, accepted finding batch, complexity reset, clean result, or verification result. Do not emit repeated waiting messages when neither reviewer state nor repository content changed. ## Independent reviewer -An independent review uses a fresh context that did not implement the fingerprinted content and is not given prior reviewer findings or implementer conclusions. Prefer a distinct agent. A same-context self-review is not independent and cannot satisfy the clean-review gate. +An independent review uses a fresh no-history context that did not implement the fingerprinted content and is not given prior reviewer findings or implementer conclusions. Prefer a distinct agent and set `fork_turns: "none"` when the platform exposes that control. A same-context self-review or full-history fork is not independent and cannot satisfy the clean-review gate. - Give the reviewer the original requirement, implementation scope contract, base and head identifiers, canonical component manifest and fingerprints, raw repository state, and relevant architecture references. - Give the reviewer the precomputed contract-surface and await-boundary or authority/data-flow inventories. These are coverage maps, not conclusions; require the reviewer to validate every row against the raw diff and surrounding source. @@ -84,12 +86,12 @@ An independent review uses a fresh context that did not implement the fingerprin - Do not give the reviewer the implementer's conclusions, suspected bugs, intended fixes, or a list of expected findings. - Ask for exactly one read-only review round. The reviewer must not edit or stage files, run the autonomous review loop recursively, spawn another reviewer, or perform the final repository verification. The implementer owns finding validation, edits, loop control, and final verification. - Give every reviewer for a round the same review-state fingerprint and keep the diff frozen until all of them finish. Reject output produced from a different or changing state instead of merging partial observations across revisions. -- Give every reviewer the self-contained brief and one exact revalidation command. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, the reviewer must report it and cannot return a creditable clean verdict. Tell reviewers not to inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag unless supplied evidence is inconsistent or decision-relevant. Reopening source cannot replace missing packet contents. This preserves fresh judgment while avoiding repeated setup work. +- Give every reviewer the compact self-contained control-plane brief, indexed evidence paths and digests, and one exact revalidation command. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, the reviewer must report it and cannot return a creditable clean verdict. Tell reviewers not to inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag unless supplied evidence is inconsistent or decision-relevant. Reopening source cannot replace missing packet contents. This preserves fresh judgment while avoiding repeated setup work. - Use fresh reviewers for every round when possible. Do not reveal findings or conclusions from prior rounds; provide only the updated requirement, scope contract, raw final diff, component manifest, and relevant references. -- Use one fresh reviewer for normal risk. Use two concurrent reviewers for the high-risk conditions in step 12, assigning complementary specialties while requiring each to inspect the complete diff. Multiple reviewers of the same unchanged diff are one fingerprint round. Do not duplicate broad test execution. +- Use two concurrent fresh reviewers for every round. For the high-risk conditions in step 12, assign complementary high-risk specialties while requiring each reviewer to inspect the complete diff. Both reviewers of the same unchanged diff are one fingerprint round. Do not duplicate broad test execution. - Concurrent reviewers receive the same fingerprint and raw context but different primary specialties. They must not communicate during the round. - Give the reviewer existing verification commands and results as raw evidence. The reviewer should inspect code and tests, then run only focused probes needed to resolve a decision-relevant uncertainty. A probe must be demonstrably non-mutating or run in an isolated temporary checkout; any mutation of the reviewed worktree invalidates the round. Do not rerun the repository's broad test, typecheck, lint, build, or integration suites merely to reconfirm the implementer's evidence; the implementer runs the complete stack once after the clean-review gate. -- Require evidence-bearing output. `clean` alone is never sufficient: the reviewer must return the exact fingerprint, assigned inventory coverage, high-risk dimensions checked, probes or `none`, and unresolved uncertainty or `none`. +- Require the structured JSON output from the reviewer brief. `clean` alone is never sufficient: the reviewer must return the exact fingerprint, checked and unchecked inventory IDs, high-risk dimensions checked, probes or `none`, unresolved uncertainty or `none`, findings, sibling-scenario scan, and inspection-budget accounting. - After fixes, review the exact final diff again. Preserve earlier clean credit only under the explicit component-delta rule; do not infer that a change is isolated merely from its file location. When an independent reviewer is unavailable, rebuild context from the original request, scope contract, source, and complete diff before a best-effort self-review. Explicitly discard incremental-review assumptions, label the result non-independent, and do not count it toward the clean-review gate. Report the unavailable gate at handoff instead of silently weakening it. @@ -160,14 +162,4 @@ Run a complexity reset when related findings keep expanding the same design, a n ## Review output -Lead with one verdict: `clean`, `findings require fixes`, or `complexity reset required`. - -For each finding provide: - -- priority and concise title; -- exact file and line or symbol; -- concrete failure scenario and user-visible consequence; -- contract/support basis plus baseline-versus-patch evidence when the scenario is outside the original requirement; -- smallest safe correction. - -If no actionable findings remain, say so directly and list the high-risk dimensions actually checked. Keep unverified runtime uncertainty explicit. Do not claim implementation completion until the clean post-fix review and required verification both apply to the exact final state. +Return exactly one JSON object using the schema in `references/reviewer-brief.md`. Put the verdict in `verdict`; put each actionable finding in `findings` with its priority, title, location, concrete failure scenario, user-visible consequence, support basis, baseline-versus-patch evidence when applicable, smallest safe correction, and stable root-cause ID. Account for every assigned inventory ID and keep unverified runtime uncertainty explicit. Do not claim implementation completion until both structured clean reviews and required verification apply to the exact final state. diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index d1a771d039..9161445d24 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -1,6 +1,6 @@ # Independent Reviewer Brief -Use this template to prepare one self-contained, factual packet per fingerprint round. Fill every field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Fill it once, reuse the shared body byte-for-byte for every reviewer, and vary only the final specialty assignment. Do not include implementer conclusions, suspected bugs, prior findings, or intended fixes. +Use this template to prepare one self-contained, factual snapshot packet per fingerprint round. Fill every field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Fill it once, reuse the shared body byte-for-byte for every reviewer, and vary only the final specialty assignment. Keep this control-plane brief near 12 KB when practical. Store larger evidence in indexed files and reference each file by exact path and SHA-256 digest. Do not omit decision-relevant evidence merely to meet the soft size target. Do not include implementer conclusions, suspected bugs, prior findings, or intended fixes. ## Shared evidence @@ -15,22 +15,26 @@ Use this template to prepare one self-contained, factual packet per fingerprint - HEAD: - Latest release boundary when relevant: - Risk tier and reason: +- Task-global ledger path, task identity, current round, and remaining authorized budget: - Canonical task manifest: - Component manifests: +- Semantic component dependency map and invalidation reasons: - Combined, component, and repository fingerprints: - Exact fingerprint revalidation command: - Raw repository status: - Complete three-dot diff command: +- Indexed evidence manifest (`ID | exact path | SHA-256 | purpose`): - Focused preflight commands and results: +- Same-fingerprint verification already credited, or `none`: - Eligible concurrent final-gate commands and non-mutation basis: - Gates deferred because they may mutate task-owned content, or `none`: - Selected architecture references or exact relevant excerpts: ## Contract-surface inventory -One row per changed public symbol, configuration field, event, serialized field, wire value, or documented behavior. +Give every row a stable ID. Use one row per changed public symbol, configuration field, event, serialized field, wire value, or documented behavior. -`surface | producers/constructors | consumers/forwarding branches/adapters | default/missing/invalid behavior | package exports/generated public surfaces | adjacent docs/examples | caller-visible tests` +`ID | surface | producers/constructors | consumers/forwarding branches/adapters | default/missing/invalid behavior | package exports/generated public surfaces | adjacent docs/examples | caller-visible tests` Include adjacent surfaces found outside the current diff. If a required update is absent, add it to the task manifest before freezing the review. @@ -38,33 +42,65 @@ Include adjacent surfaces found outside the current diff. If a required update i For concurrency, cancellation, reentrancy, or lifecycle state: -`operation | state snapshot | await/blocking point | events/operations possible while suspended | monotonic evidence retained | revalidation | side effects/invariant` +`ID | operation | state snapshot | await/blocking point | events/operations possible while suspended | monotonic evidence retained | revalidation | side effects/invariant` Populate supported states including source completion, newer active operation with known or unknown identity, newer operation started then completed, and awaited-action failure or cancellation. If the contract depends on whether something ever happened, identify the monotonic evidence or the serialization proof. For protocol, security, or persistence instead use: -`input/authority | validation | in-memory state | persisted/serialized state | retry/replay | output | exception/log/telemetry exposure | cleanup/revocation` +`ID | input/authority | validation | in-memory state | persisted/serialized state | retry/replay | output | exception/log/telemetry exposure | cleanup/revocation` ## Reviewer instructions -Perform exactly one read-only review round on the frozen fingerprint. First run the supplied revalidation command and calculate the merge base. Then inspect the complete raw diff, surrounding source, tests, and supplied references. Validate every assigned inventory row rather than trusting the implementer. You may report blockers outside your specialty. - -Do not edit or stage files, recursively invoke the review workflow, spawn another reviewer, run broad repository verification, inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, report the missing field and do not return a creditable clean verdict. Reopen primary source or released evidence only when supplied evidence is inconsistent or leaves a decision-relevant uncertainty; do not use reopening to replace missing packet contents. Run only focused non-mutating probes needed to resolve such uncertainty. - -Return: - -1. Verdict: `clean`, `findings require fixes`, or `complexity reset required`. -2. Exact reviewed combined and component fingerprints. -3. Assigned inventory rows and high-risk dimensions checked. -4. Focused probes run, or `none`. -5. Remaining uncertainty, or `none`. -6. Findings in the skill's required format when applicable. - -A bare `clean` or generic checklist is incomplete and earns no clean credit. +Perform exactly one read-only review round on the frozen fingerprint. Your context must be created with no inherited implementer conversation; the dispatcher uses `fork_turns: "none"` when available. First run the supplied revalidation command and calculate the merge base. Then inspect the complete raw diff, surrounding source, tests, and supplied references. Validate every assigned inventory row rather than trusting the implementer. You may report blockers outside your specialty. + +Do not edit or stage files, recursively invoke the review workflow, spawn another reviewer, run broad repository verification, inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag. Inherit the supplied implementation scope contract; if it is inconsistent or leaves a decision-relevant ambiguity, report that uncertainty to the implementer instead of launching a strategy pass. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, report the missing field and do not return a creditable clean verdict. Reopen primary source or released evidence only when supplied evidence is inconsistent or leaves a decision-relevant uncertainty; do not use reopening to replace missing packet contents. Run only focused non-mutating probes needed to resolve such uncertainty. + +Use approximately 12 source-inspection tool calls as a soft budget. Exceed it whenever decision-relevant uncertainty requires more evidence, but record a concise reason. Do not skip evidence or lower review quality to stay within the budget. + +Return exactly one JSON object with this shape and no prose outside it: + +```json +{ + "verdict": "clean | findings require fixes | complexity reset required | incomplete packet", + "reviewed_fingerprints": { + "combined": "...", + "components": {"component-name": "..."} + }, + "checked_inventory_ids": ["..."], + "unchecked_inventory_ids": [{"id": "...", "reason": "..."}], + "high_risk_dimensions_checked": ["..."], + "focused_probes": [{"command": "...", "result": "..."}], + "remaining_uncertainty": ["..."], + "findings": [ + { + "priority": "P0 | P1 | P2 | P3", + "title": "...", + "location": "path:line or symbol", + "failure_scenario": "...", + "user_consequence": "...", + "support_basis": "...", + "baseline_patch_evidence": "... | not applicable", + "smallest_safe_correction": "...", + "root_cause_id": "..." + } + ], + "sibling_scenario_scan": [{"root_cause_id": "...", "inventory_ids": ["..."], "result": "..."}], + "inspection_call_count": 0, + "inspection_budget_reason": "none | ..." +} +``` + +Use empty arrays for `focused_probes`, `remaining_uncertainty`, `findings`, or `sibling_scenario_scan` when there are none. Every assigned inventory ID must appear in either `checked_inventory_ids` or `unchecked_inventory_ids`. A `clean` verdict requires an empty `unchecked_inventory_ids`, `remaining_uncertainty`, and `findings` array. + +Every `focused_probes[].command` must contain the exact executable command that ran. For a non-shell tool call, provide the complete tool name and arguments. Prose-only labels, omitted arguments, and placeholders such as `` are incomplete and earn no clean credit. If the exact command would be too large to return, place the probe code in an indexed evidence artifact before execution and return its path, SHA-256 digest, and exact execution command. + +A bare `clean` or generic checklist is incomplete and earns no clean credit. A malformed JSON object or missing required field is equally incomplete. ## Specialty assignment - Primary dimensions: - Required inventory rows: +- Expected component boundaries: +- Evidence items expected to be sufficient: - Complementary reviewer assignment, if any: diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index e72a36d892..0df6665b6f 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -14,6 +14,7 @@ def setUpClass(cls) -> None: cls.skill = (cls.skill_root / "SKILL.md").read_text() cls.agent_config = (cls.skill_root / "agents" / "openai.yaml").read_text() cls.reviewer_brief = (cls.skill_root / "references" / "reviewer-brief.md").read_text() + cls.repo_instructions = (cls.skill_root.parents[2] / "AGENTS.md").read_text() def test_repo_local_metadata_matches_skill(self) -> None: self.assertEqual(self.skill.splitlines()[1], "name: implementation-final-review") @@ -87,7 +88,7 @@ def test_full_verification_can_overlap_review_without_weakening_freeze(self) -> "every eligible non-mutating final repository gate", "exact frozen content", "`make lint`, `make typecheck`, and `make tests` during review", - "discard verification credit for the changed fingerprint", + "discard verification credit only for changed or dependency-invalidated components", "accept the overlapped result from step 12", "exact clean-reviewed fingerprint", "do not rerun successful exact-fingerprint work", @@ -161,11 +162,17 @@ def test_shared_typescript_improvements_keep_python_boundaries(self) -> None: self.assertNotIn("$changeset-validation", self.skill) self.assertNotIn("browser/Node/workerd", self.skill) - def test_final_clean_condition_uses_canonical_elevated_tier(self) -> None: + def test_final_clean_condition_preserves_two_independent_reviews(self) -> None: + self.assertIn( + "normal-risk change: two independent clean reviews of the same fingerprint, " + "launched concurrently", + self.skill, + ) self.assertIn( "elevated-risk change or any loop that produced a P0/P1 finding", self.skill, ) + self.assertIn("Use two concurrent fresh reviewers for every round", self.skill) self.assertNotIn( "released compatibility, or any loop that produced a P0/P1 finding", self.skill, @@ -179,6 +186,130 @@ def test_cross_references_use_current_step_numbers(self) -> None: ) self.assertNotIn("high-risk conditions in step 10", self.skill) + def test_independent_review_uses_no_history_and_event_driven_waits(self) -> None: + required_skill_text = ( + 'dispatch every reviewer with `fork_turns: "none"`', + "never pass the implementer's accumulated conversation or use a full-history fork", + "Launch both reviewers before waiting", + "one event-driven wait of 180-300 seconds", + "Do not poll with `list_agents`, separate short waits, progress questions, or no-op " + "`followup_task` messages", + "after one reviewer completes, continue waiting only for the remaining reviewer", + ) + for text in required_skill_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + self.assertIn('dispatcher uses `fork_turns: "none"`', self.reviewer_brief) + + def test_round_budget_is_task_global_and_cannot_silently_reset(self) -> None: + required_text = ( + "Resume or create the task-global review ledger", + "Use the Codex task or thread ID as the stable task identity when available", + "Store the ledger as an ignored operational file at a stable absolute path", + "preserve the same file when work moves to another worktree", + "Never initialize a new counter merely because the task was paused, compacted, " + "handed off, renamed, moved to another worktree, or resumed in another context", + "default autonomous budget is six fingerprint rounds for the entire task", + "Only explicit user authorization may start another bounded budget", + "append the new budget to the same ledger rather than replacing its history", + "Persist enough task identity, used and authorized round budgets", + ) + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_second_related_finding_closes_the_root_cause_group(self) -> None: + required_text = ( + "Treat a second related finding in one root-cause group as a closure gate", + "run the complexity reset once", + "scan the complete inventory for sibling scenarios", + "mark the root-cause ID closed", + "Do not reopen it for another local patch without new contract evidence", + ) + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_snapshot_packet_and_structured_output_bound_repeated_work(self) -> None: + required_skill_text = ( + "approximately 12 KB as a soft target", + "indexed evidence files", + "exact paths plus SHA-256 digests", + "Assign stable IDs to every inventory row and evidence item", + "Require one structured JSON object", + "inspection call count", + "approximately 12 source-inspection tool calls per reviewer as a soft budget", + ) + required_brief_text = ( + "Indexed evidence manifest (`ID | exact path | SHA-256 | purpose`)", + "Semantic component dependency map and invalidation reasons", + '"checked_inventory_ids"', + '"unchecked_inventory_ids"', + '"sibling_scenario_scan"', + '"inspection_call_count"', + '"inspection_budget_reason"', + "A `clean` verdict requires an empty `unchecked_inventory_ids`, " + "`remaining_uncertainty`, and `findings` array", + "Every `focused_probes[].command` must contain the exact executable command that ran", + "Prose-only labels, omitted arguments, and placeholders such as ``", + "return its path, SHA-256 digest, and exact execution command", + ) + for text in required_skill_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + for text in required_brief_text: + with self.subTest(text=text): + self.assertIn(text, self.reviewer_brief) + + def test_semantic_clean_credit_fails_closed_on_dependency_changes(self) -> None: + required_text = ( + "Partition the manifest by the narrowest stable semantic boundaries", + "`api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, " + "`integration-runner`, `tests-examples`, and `release-metadata`", + "fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, " + "and risk tier are all unchanged", + "changed or dependency-invalidated component", + "Any ambiguity invalidates the affected clean credit", + "Do not invalidate unrelated components solely because a neighboring file or coarse " + "directory changed", + ) + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_intermediate_verification_is_cost_aware_but_final_gate_is_complete(self) -> None: + required_text = ( + "Prefer an already successful same-fingerprint check over rerunning it", + "never replay cumulative historical verification", + "The reduced check earns no final-gate credit", + "the exact clean-reviewed fingerprint must still pass the complete `make tests` gate", + "complete the repository's code-change verification", + ) + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_final_reviewers_inherit_strategy_evidence(self) -> None: + self.assertIn( + "The implementer owns `$implementation-strategy` and supplies its current scope " + "contract in the packet", + self.skill, + ) + self.assertIn( + "Reviewers inherit that contract and must not rerun the strategy workflow", + self.skill, + ) + self.assertIn( + "Independent reviewers dispatched by `$implementation-final-review` inherit the " + "implementer's recorded implementation scope contract", + self.repo_instructions, + ) + self.assertIn( + "The implementer remains responsible for rerunning `$implementation-strategy`", + self.repo_instructions, + ) + if __name__ == "__main__": unittest.main() diff --git a/AGENTS.md b/AGENTS.md index b885b79026..54b47c9cac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ Before changing or reviewing runtime code, exported APIs, external configuration Repeat the skill before editing each new review-feedback batch; an earlier strategy decision is stale when a comment would widen the supported contract or add another compatibility branch, resolver condition, or test permutation. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. +Independent reviewers dispatched by `$implementation-final-review` inherit the implementer's recorded implementation scope contract and do not rerun `$implementation-strategy` in their fresh review contexts. They report inconsistent or decision-incomplete strategy evidence as uncertainty to the implementer. The implementer remains responsible for rerunning `$implementation-strategy` before any review-feedback batch that widens the supported contract, adds a compatibility branch, changes ownership or protocol behavior, expands test permutations, or triggers a complexity reset. + #### `$implementation-final-review` After implementing runtime code, tests, examples, build/test behavior, or behavior-impacting docs and completing focused tests, run `$implementation-final-review` before final `$code-change-verification` and `$pr-draft-summary` work and before declaring the task complete. This repository instruction authorizes automatic invocation without a separate user mention. Do not invoke it for planning, investigation, review, or report-only tasks, repo-meta changes, or docs without behavior impact. The skill's clean-review gate does not replace any other mandatory repository skill or verification gate. From 4d29c44098eb53c4700f3ee7b782df3dfab5af8d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 15:24:19 +0900 Subject: [PATCH 250/473] docs: update sandbox reference for agents --- .agents/references/sandbox-runtime-boundary.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/references/sandbox-runtime-boundary.md b/.agents/references/sandbox-runtime-boundary.md index 221c7e16ec..82d51a7d86 100644 --- a/.agents/references/sandbox-runtime-boundary.md +++ b/.agents/references/sandbox-runtime-boundary.md @@ -31,6 +31,9 @@ Resolve the session source in this order: injected live session, resumable sandb ## Filesystem Trust Boundary - Manifest entry destinations are workspace-relative and must not escape the workspace. The workspace root itself must be absolute where the backend requires an absolute runtime root. +- Treat every path visible inside a sandbox as a POSIX path, regardless of the host operating system. Do not use `str(Path(...))` or `str(PurePath(...))` to produce, validate, compare, or serialize a sandbox path because those calls emit backslashes on Windows. Convert typed path objects with `PurePath.as_posix()` or the canonical helpers in `workspace_paths.py`. +- Preserve the trust distinction between typed path objects and raw string input. A native Windows `Path` or `PurePath` may be converted to its POSIX sandbox representation, while a raw string containing backslashes may still need to be rejected when the public contract requires explicit POSIX syntax. Do not make an input-validation failure disappear by silently canonicalizing every string. +- Keep host filesystem conversion at an explicit host/backend boundary. Code that resolves manifests, mount targets, archive exclusions, snapshots, grants, or provider paths must not let the host implementation of `Path` change the identity of a sandbox path. - `LocalFile` and `LocalDir` sources are host-side inputs. Resolve them against a trusted base directory, require explicit application-controlled `extra_path_grants` outside that base, and reject untrusted manifests that try to authorize their own host access. - Validate local sources at use time, not only when parsing the manifest. Defend against symlinked sources, parent-directory swaps, platform path aliases, and archive members that change meaning between validation and extraction. - Archive extraction must reject traversal, unsafe links, and unsupported member types before writing, and enforce entry, byte, and expansion limits without materializing an unbounded member list. @@ -59,6 +62,7 @@ Provider adapters may deliberately support a narrower lifecycle. Document that b 3. Verify handoffs, duplicate agent names, interruption resume, and cleanup failure preserve the intended session mapping. 4. Test host-path, symlink, traversal, archive-limit, and credential-redaction boundaries on applicable platforms. 5. Exercise the public `Runner` path so agent preparation, capability binding, persistence, and cleanup run together. +6. For every new sandbox-path validation, normalization, comparison, or serialization path, test a `PureWindowsPath` input on every host and confirm that raw backslash strings retain their intended validation behavior. ## Sources From 6115461c6b0f661398d63b1de2b3ecc9390a5a09 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 16:27:33 +0900 Subject: [PATCH 251/473] feat(retry): allow applications to approve unsafe replays (#4319) Co-authored-by: LeSingh1 --- src/agents/models/openai_responses.py | 4 +- src/agents/retry.py | 109 ++++- src/agents/run_internal/model_retry.py | 90 ++++- tests/models/test_model_retry.py | 538 +++++++++++++++++++++++++ tests/models/test_openai_responses.py | 42 ++ tests/test_agent_runner.py | 51 +++ 6 files changed, 806 insertions(+), 28 deletions(-) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 7a4bc29442..1f5e41e9ab 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -1034,11 +1034,13 @@ def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice stateful_request = bool(request.previous_response_id or request.conversation_id) wrapped_replay_safety = _get_wrapped_websocket_replay_safety(request.error) if wrapped_replay_safety == "unsafe": - if stateful_request or _did_start_websocket_response(request.error): + response_started = _did_start_websocket_response(request.error) + if stateful_request or response_started: return ModelRetryAdvice( suggested=False, replay_safety="unsafe", reason=str(request.error), + response_started=response_started, ) return ModelRetryAdvice( suggested=True, diff --git a/src/agents/retry.py b/src/agents/retry.py index ee3d0d7605..5ad40f68ac 100644 --- a/src/agents/retry.py +++ b/src/agents/retry.py @@ -98,6 +98,8 @@ class ModelRetryAdvice: replay_safety: str | None = None reason: str | None = None normalized: ModelRetryNormalizedError | None = None + response_started: bool = False + """Whether the provider had begun emitting the response when the failure occurred.""" @dataclass @@ -118,10 +120,25 @@ class RetryDecision: retry: bool delay: float | None = None reason: str | None = None + approve_unsafe_replay: bool = False + """Explicit application approval to replay a request the provider marked replay-unsafe. + + This is deliberately separate from ``retry``: an ordinary ``RetryDecision(retry=True)`` + never bypasses replay protection. Set this only for workloads where repeating + provider-side work that may already have happened is acceptable. + """ _hard_veto: bool = field(default=False, init=False, repr=False, compare=False) + _delegable_replay_veto: bool = field(default=False, init=False, repr=False, compare=False) _approves_replay: bool = field(default=False, init=False, repr=False, compare=False) +@dataclass(frozen=True) +class _ProviderRetryAuthority: + suggested: bool | None + replay_safety: str + response_started: bool + + @dataclass class RetryPolicyContext: """Context passed to runtime retry policy callbacks.""" @@ -132,6 +149,37 @@ class RetryPolicyContext: stream: bool normalized: ModelRetryNormalizedError provider_advice: ModelRetryAdvice | None = None + previous_response_id: str | None = None + conversation_id: str | None = None + _provider_authority: _ProviderRetryAuthority = field( + init=False, + repr=False, + compare=False, + ) + + def __post_init__(self) -> None: + advice = self.provider_advice + replay_safety = advice.replay_safety if advice is not None else None + self._provider_authority = _ProviderRetryAuthority( + suggested=advice.suggested if advice is not None else None, + replay_safety=(replay_safety if replay_safety in {"safe", "unsafe"} else "unknown"), + response_started=advice.response_started if advice is not None else False, + ) + + @property + def response_started(self) -> bool: + """Whether the provider had begun emitting the response when the failure occurred.""" + return self._provider_authority.response_started + + @property + def replay_safety(self) -> str: + """Provider replay classification: ``"safe"``, ``"unsafe"`` or ``"unknown"``.""" + return self._provider_authority.replay_safety + + @property + def stateful_request(self) -> bool: + """Whether the request carried ``previous_response_id`` or ``conversation_id``.""" + return bool(self.previous_response_id or self.conversation_id) RetryPolicy: TypeAlias = Callable[[RetryPolicyContext], MaybeAwaitable[bool | RetryDecision]] @@ -203,6 +251,12 @@ def _with_hard_veto(decision: RetryDecision) -> RetryDecision: return decision +def _with_delegable_replay_veto(decision: RetryDecision) -> RetryDecision: + decision._hard_veto = True + decision._delegable_replay_veto = True + return decision + + def _with_replay_safe_approval(decision: RetryDecision) -> RetryDecision: decision._approves_replay = True return decision @@ -216,6 +270,7 @@ def _merge_positive_retry_decisions( retry=True, delay=existing.delay, reason=existing.reason, + approve_unsafe_replay=existing.approve_unsafe_replay or incoming.approve_unsafe_replay, ) if existing._approves_replay: merged = _with_replay_safe_approval(merged) @@ -228,6 +283,24 @@ def _merge_positive_retry_decisions( return merged +def _resolve_delegable_replay_veto( + veto: RetryDecision, + approving: RetryDecision, +) -> RetryDecision: + if not approving.retry or not approving.approve_unsafe_replay: + return veto + + resolved = RetryDecision( + retry=True, + delay=approving.delay, + reason=approving.reason or veto.reason, + approve_unsafe_replay=True, + ) + if approving._approves_replay: + resolved = _with_replay_safe_approval(resolved) + return resolved + + class _RetryPolicies: def never(self) -> RetryPolicy: def policy(_context: RetryPolicyContext) -> bool: @@ -241,13 +314,18 @@ def policy(_context: RetryPolicyContext) -> bool: def provider_suggested(self) -> RetryPolicy: def policy(context: RetryPolicyContext) -> bool | RetryDecision: + authority = context._provider_authority advice = context.provider_advice - if advice is None or advice.suggested is None: + reason = advice.reason if advice is not None else None + retry_after = advice.retry_after if advice is not None else None + if authority.suggested is None: return False - if advice.suggested is False: - return _with_hard_veto(RetryDecision(retry=False, reason=advice.reason)) - decision = RetryDecision(retry=True, delay=advice.retry_after, reason=advice.reason) - if advice.replay_safety == "safe": + if authority.suggested is False: + if authority.replay_safety == "unsafe": + return _with_delegable_replay_veto(RetryDecision(retry=False, reason=reason)) + return _with_hard_veto(RetryDecision(retry=False, reason=reason)) + decision = RetryDecision(retry=True, delay=retry_after, reason=reason) + if authority.replay_safety == "safe": return _with_replay_safe_approval(decision) return decision @@ -301,9 +379,14 @@ def all(self, *policies: RetryPolicy) -> RetryPolicy: async def policy(context: RetryPolicyContext) -> bool | RetryDecision: merged = RetryDecision(retry=True) + delegable_replay_veto: RetryDecision | None = None for predicate in policies: decision = await _evaluate_policy(predicate, context) if decision._hard_veto: + if decision._delegable_replay_veto: + if delegable_replay_veto is None: + delegable_replay_veto = decision + continue return decision if not decision.retry: return decision @@ -311,9 +394,13 @@ async def policy(context: RetryPolicyContext) -> bool | RetryDecision: merged.delay = decision.delay if decision.reason is not None: merged.reason = decision.reason + if decision.approve_unsafe_replay: + merged.approve_unsafe_replay = True if decision._approves_replay: merged = _with_replay_safe_approval(merged) + if delegable_replay_veto is not None: + return _resolve_delegable_replay_veto(delegable_replay_veto, merged) return merged return _mark_retry_capabilities( @@ -333,9 +420,14 @@ def any(self, *policies: RetryPolicy) -> RetryPolicy: async def policy(context: RetryPolicyContext) -> bool | RetryDecision: first_positive: RetryDecision | None = None last_negative: RetryDecision | None = None + delegable_replay_veto: RetryDecision | None = None for predicate in policies: decision = await _evaluate_policy(predicate, context) if decision._hard_veto: + if decision._delegable_replay_veto: + if delegable_replay_veto is None: + delegable_replay_veto = decision + continue return decision if decision.retry: if first_positive is None: @@ -345,6 +437,13 @@ async def policy(context: RetryPolicyContext) -> bool | RetryDecision: continue last_negative = decision + if delegable_replay_veto is not None: + if first_positive is None: + return delegable_replay_veto + return _resolve_delegable_replay_veto( + delegable_replay_veto, + first_positive, + ) if first_positive is not None: return first_positive if last_negative is not None: diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index 0b1d8b57a0..168060e648 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -126,13 +126,16 @@ def _normalize_retry_error( "message", "request_id", "retry_after", - "is_abort", "is_network_error", "is_timeout", ): if field_name in getattr(override, "_explicit_fields", ()): override_value = getattr(override, field_name) setattr(normalized, field_name, override_value) + if "is_abort" in getattr(override, "_explicit_fields", ()): + # Provider normalization may add abort evidence but cannot clear an abort + # inferred from the raw exception. + normalized.is_abort = normalized.is_abort or override.is_abort return normalized @@ -273,16 +276,36 @@ async def _evaluate_retry( replay_unsafe_request: bool, emitted_retry_unsafe_event: bool, provider_advice: ModelRetryAdvice | None, + previous_response_id: str | None = None, + conversation_id: str | None = None, ) -> RetryDecision: if attempt > max_retries: return RetryDecision(retry=False) normalized = _normalize_retry_error(error, provider_advice) - if ( - normalized.is_abort - or emitted_retry_unsafe_event - or (provider_advice is not None and provider_advice.replay_safety == "unsafe") - ): + context = RetryPolicyContext( + error=error, + attempt=attempt, + max_retries=max_retries, + stream=stream, + normalized=normalized, + provider_advice=provider_advice, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + ) + provider_marks_replay_unsafe = context.replay_safety == "unsafe" + provider_marks_replay_safe = context.replay_safety == "safe" + # Aborts, and failures that already emitted user-visible streamed output, are absolute + # vetoes. No application decision can make replaying those safe. + if normalized.is_abort or emitted_retry_unsafe_event: + return RetryDecision( + retry=False, + reason=provider_advice.reason if provider_advice is not None else None, + ) + # A provider-unsafe streamed failure and a request with a separate local-side-effect veto + # stay blocked before the policy runs. Only a non-streamed request without that separate + # veto can ask the application to approve provider-side replay risk. + if provider_marks_replay_unsafe and (stream or replay_unsafe_request): return RetryDecision( retry=False, reason=provider_advice.reason if provider_advice is not None else None, @@ -291,29 +314,48 @@ async def _evaluate_retry( if retry_policy is None: return RetryDecision(retry=False) - decision = await _call_retry_policy( - retry_policy, - RetryPolicyContext( - error=error, - attempt=attempt, - max_retries=max_retries, - stream=stream, - normalized=normalized, - provider_advice=provider_advice, - ), - ) + decision = await _call_retry_policy(retry_policy, context) if not decision.retry: return decision - provider_marks_replay_safe = ( - provider_advice is not None and provider_advice.replay_safety == "safe" - ) + stateful_request = bool(previous_response_id or conversation_id) + # Three separate vetoes, deliberately not folded together. + # + # 1. A request-level replay veto (Programmatic Tool Calling, for example) covers + # application-local side effects that may already have run. That is outside what + # `approve_unsafe_replay` authorizes, so only the provider-owned approval lifts it. if replay_unsafe_request and not decision._approves_replay and not provider_marks_replay_safe: return RetryDecision( retry=False, reason=decision.reason or (provider_advice.reason if provider_advice is not None else None), ) + # 2. A stateful request (`previous_response_id` / `conversation_id`, including the + # `auto_previous_response_id` case) fails closed by default because the follow-up + # depends on server-side state. It carries no application-local side effects, so an + # application approval can accept it — but only for the provider-marked unsafe failure + # this option is scoped to. When replay safety is unknown the request stays blocked: + # there is no provider-unsafe failure for the approval to be about. + if stateful_request and not ( + decision._approves_replay + or provider_marks_replay_safe + or (decision.approve_unsafe_replay and provider_marks_replay_unsafe) + ): + return RetryDecision( + retry=False, + reason=decision.reason + or (provider_advice.reason if provider_advice is not None else None), + ) + # 3. Provider-marked replay unsafety is the case `approve_unsafe_replay` exists for. + # Both approvals are explicit; an ordinary `retry=True` is neither. + if provider_marks_replay_unsafe and not ( + decision._approves_replay or decision.approve_unsafe_replay + ): + return RetryDecision( + retry=False, + reason=decision.reason + or (provider_advice.reason if provider_advice is not None else None), + ) return RetryDecision( retry=True, @@ -493,9 +535,11 @@ async def get_response_with_retry( retry_policy=retry_settings.policy if retry_settings is not None else None, retry_backoff=retry_settings.backoff if retry_settings is not None else None, stream=False, - replay_unsafe_request=stateful_request or replay_unsafe_request, + replay_unsafe_request=replay_unsafe_request, emitted_retry_unsafe_event=False, provider_advice=provider_advice, + previous_response_id=previous_response_id, + conversation_id=conversation_id, ) if not decision.retry: raise @@ -618,9 +662,11 @@ async def stream_response_with_retry( retry_policy=retry_settings.policy if retry_settings is not None else None, retry_backoff=retry_settings.backoff if retry_settings is not None else None, stream=True, - replay_unsafe_request=stateful_request or replay_unsafe_request, + replay_unsafe_request=replay_unsafe_request, emitted_retry_unsafe_event=emitted_retry_unsafe_event, provider_advice=provider_advice, + previous_response_id=previous_response_id, + conversation_id=conversation_id, ) if not decision.retry: raise diff --git a/tests/models/test_model_retry.py b/tests/models/test_model_retry.py index 671ceee866..a17e98c968 100644 --- a/tests/models/test_model_retry.py +++ b/tests/models/test_model_retry.py @@ -2466,3 +2466,541 @@ async def rewind() -> None: await outer_stream.aclose() assert stream.close_calls == 1 + + +def _ws_response_started_advice(request: ModelRetryAdviceRequest) -> ModelRetryAdvice: + """Advice matching the Responses WebSocket adapter after a response-started disconnect.""" + return ModelRetryAdvice( + suggested=False, + replay_safety="unsafe", + reason=str(request.error), + response_started=True, + ) + + +def test_retry_policy_context_normalizes_unknown_provider_replay_safety() -> None: + context = RetryPolicyContext( + error=_connection_error(), + attempt=1, + max_retries=1, + stream=False, + normalized=ModelRetryNormalizedError(), + provider_advice=ModelRetryAdvice(replay_safety="conditional"), + ) + + assert context.replay_safety == "unknown" + + +def test_provider_suggested_preserves_public_veto_for_unsafe_advice() -> None: + decision = retry_policies.provider_suggested()( + RetryPolicyContext( + error=_connection_error(), + attempt=1, + max_retries=1, + stream=False, + normalized=ModelRetryNormalizedError(is_network_error=True), + provider_advice=ModelRetryAdvice( + suggested=False, + replay_safety="unsafe", + reason="provider veto", + ), + ) + ) + + assert isinstance(decision, RetryDecision) + assert decision.retry is False + assert decision.reason == "provider veto" + + +@pytest.mark.asyncio +async def test_provider_unsafe_replay_retries_when_policy_approves_explicitly() -> None: + calls = 0 + seen: list[RetryPolicyContext] = [] + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + if calls == 1: + raise _connection_error("no close frame received or sent") + return ModelResponse(output=[get_text_message("ok")], usage=Usage(), response_id="resp") + + def policy(context: RetryPolicyContext) -> RetryDecision: + seen.append(context) + return RetryDecision( + retry=True, + approve_unsafe_replay=True, + reason="Approved one replay of a read-only model turn", + ) + + result = await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=policy + ), + get_retry_advice=_ws_response_started_advice, + previous_response_id=None, + conversation_id=None, + ) + + assert result.response_id == "resp" + assert calls == 2 + # The policy must be able to scope its approval, so it needs the structured context. + assert len(seen) == 1 + assert seen[0].stream is False + assert seen[0].response_started is True + assert seen[0].replay_safety == "unsafe" + assert seen[0].stateful_request is False + + +@pytest.mark.asyncio +async def test_provider_unsafe_replay_still_blocks_an_ordinary_retry_decision() -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error("no close frame received or sent") + + def policy(_context: RetryPolicyContext) -> RetryDecision: + # No `approve_unsafe_replay`, so this must not bypass replay protection. + return RetryDecision(retry=True) + + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=policy + ), + get_retry_advice=_ws_response_started_advice, + previous_response_id=None, + conversation_id=None, + ) + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_provider_unsafe_replay_approval_is_ignored_for_streamed_requests() -> None: + calls = 0 + + async def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal calls + calls += 1 + raise _connection_error("no close frame received or sent") + yield # pragma: no cover - generator marker + + def policy(_context: RetryPolicyContext) -> RetryDecision: + return RetryDecision(retry=True, approve_unsafe_replay=True) + + with pytest.raises(APIConnectionError): + async for _event in stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=policy + ), + get_retry_advice=_ws_response_started_advice, + previous_response_id=None, + conversation_id=None, + ): + pass + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_unsafe_replay_approval_does_not_replay_programmatic_tool_calling() -> None: + calls = 0 + policy_calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error() + + def policy(_context: RetryPolicyContext) -> RetryDecision: + nonlocal policy_calls + policy_calls += 1 + return RetryDecision(retry=True, approve_unsafe_replay=True) + + # `replay_unsafe_request` is the request-level veto the runner sets for + # Programmatic Tool Calling. Application approval must not lift it. + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=policy + ), + get_retry_advice=_ws_response_started_advice, + previous_response_id=None, + conversation_id=None, + replay_unsafe_request=True, + ) + + assert calls == 1 + assert policy_calls == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("combinator", ["all", "any"]) +@pytest.mark.parametrize("provider_first", [False, True]) +async def test_unsafe_replay_approval_resolves_provider_veto_in_policy_combinators( + combinator: str, + provider_first: bool, +) -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + if calls == 1: + raise _connection_error("no close frame received or sent") + return ModelResponse(output=[get_text_message("ok")], usage=Usage(), response_id="resp") + + def approving(_context: RetryPolicyContext) -> RetryDecision: + return RetryDecision(retry=True, approve_unsafe_replay=True) + + provider_policy = retry_policies.provider_suggested() + policies = (provider_policy, approving) if provider_first else (approving, provider_policy) + combined = getattr(retry_policies, combinator)(*policies) + + result = await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=combined + ), + get_retry_advice=_ws_response_started_advice, + previous_response_id=None, + conversation_id=None, + ) + + assert result.response_id == "resp" + assert calls == 2 + + +@pytest.mark.asyncio +async def test_provider_normalization_cannot_clear_raw_abort_veto() -> None: + class AbortError(Exception): + pass + + calls = 0 + policy_calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise AbortError("cancelled") + + async def rewind() -> None: + raise AssertionError("An abort must not rewind state") + + def policy(_context: RetryPolicyContext) -> RetryDecision: + nonlocal policy_calls + policy_calls += 1 + return RetryDecision(retry=True, approve_unsafe_replay=True) + + def get_retry_advice(_request: ModelRetryAdviceRequest) -> ModelRetryAdvice: + return ModelRetryAdvice( + suggested=False, + replay_safety="unsafe", + normalized=ModelRetryNormalizedError(is_abort=False), + ) + + with pytest.raises(AbortError, match="cancelled"): + await get_response_with_retry( + get_response=get_response, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=policy, + ), + get_retry_advice=get_retry_advice, + previous_response_id=None, + conversation_id=None, + ) + + assert calls == 1 + assert policy_calls == 0 + + +@pytest.mark.asyncio +async def test_policy_cannot_promote_unknown_provider_replay_safety() -> None: + calls = 0 + policy_calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error() + + async def rewind() -> None: + raise AssertionError("Unknown replay safety must not rewind state") + + def policy(context: RetryPolicyContext) -> RetryDecision: + nonlocal policy_calls + policy_calls += 1 + assert context.provider_advice is not None + assert context.replay_safety == "unknown" + context.provider_advice.replay_safety = "safe" + return RetryDecision(retry=True) + + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=policy, + ), + get_retry_advice=lambda _request: ModelRetryAdvice( + suggested=True, + replay_safety="conditional", + ), + previous_response_id="resp_1", + conversation_id=None, + ) + + assert calls == 1 + assert policy_calls == 1 + + +@pytest.mark.asyncio +async def test_policy_cannot_revoke_provider_safe_replay_evidence() -> None: + calls = 0 + rewinds = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + if calls == 1: + raise _connection_error() + return ModelResponse(output=[get_text_message("ok")], usage=Usage(), response_id="resp") + + async def rewind() -> None: + nonlocal rewinds + rewinds += 1 + + def policy(context: RetryPolicyContext) -> RetryDecision: + assert context.provider_advice is not None + assert context.replay_safety == "safe" + context.provider_advice.replay_safety = "conditional" + return RetryDecision(retry=True) + + result = await get_response_with_retry( + get_response=get_response, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=policy, + ), + get_retry_advice=lambda _request: ModelRetryAdvice( + suggested=True, + replay_safety="safe", + ), + previous_response_id="resp_1", + conversation_id=None, + ) + + assert result.response_id == "resp" + assert calls == 2 + assert rewinds == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("combinator", ["all", "any"]) +@pytest.mark.parametrize( + ("suggested", "initial_safety", "expected_context_safety"), + [ + (False, "unsafe", "unsafe"), + (None, "conditional", "unknown"), + ], +) +async def test_composed_policy_cannot_mutate_provider_authority( + combinator: str, + suggested: bool | None, + initial_safety: str, + expected_context_safety: str, +) -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error() + + async def rewind() -> None: + raise AssertionError("Mutated provider authority must not rewind state") + + def mutate_advice(context: RetryPolicyContext) -> RetryDecision: + assert context.provider_advice is not None + assert context.replay_safety == expected_context_safety + assert context.response_started is False + context.provider_advice.suggested = True + context.provider_advice.replay_safety = "safe" + context.provider_advice.response_started = True + assert context.replay_safety == expected_context_safety + assert context.response_started is False + return RetryDecision(retry=True) + + policy = getattr(retry_policies, combinator)( + mutate_advice, + retry_policies.provider_suggested(), + ) + + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=policy, + ), + get_retry_advice=lambda _request: ModelRetryAdvice( + suggested=suggested, + replay_safety=initial_safety, + response_started=False, + ), + previous_response_id="resp_1", + conversation_id=None, + ) + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_composed_policy_cannot_mutate_provider_authority_for_ptc() -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error() + + async def rewind() -> None: + raise AssertionError("Mutated provider authority must not rewind PTC state") + + def mutate_advice(context: RetryPolicyContext) -> RetryDecision: + assert context.provider_advice is not None + context.provider_advice.suggested = True + context.provider_advice.replay_safety = "safe" + return RetryDecision(retry=True) + + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=retry_policies.all( + mutate_advice, + retry_policies.provider_suggested(), + ), + ), + get_retry_advice=lambda _request: ModelRetryAdvice( + suggested=None, + replay_safety="conditional", + ), + previous_response_id=None, + conversation_id=None, + replay_unsafe_request=True, + ) + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_unsafe_replay_approval_applies_to_a_stateful_request() -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + if calls == 1: + raise _connection_error("no close frame received or sent") + return ModelResponse(output=[get_text_message("ok")], usage=Usage(), response_id="resp") + + def policy(_context: RetryPolicyContext) -> RetryDecision: + return RetryDecision(retry=True, approve_unsafe_replay=True) + + # `auto_previous_response_id=True` produces exactly this shape: a stateful request + # that fails closed by default but carries no application-local side effects. + result = await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=policy + ), + get_retry_advice=_ws_response_started_advice, + previous_response_id="resp_1", + conversation_id=None, + ) + + assert result.response_id == "resp" + assert calls == 2 + + +@pytest.mark.asyncio +async def test_stateful_request_still_fails_closed_without_explicit_approval() -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error("no close frame received or sent") + + def policy(_context: RetryPolicyContext) -> RetryDecision: + return RetryDecision(retry=True) + + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=policy + ), + get_retry_advice=_ws_response_started_advice, + previous_response_id=None, + conversation_id="conv_1", + ) + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_unsafe_replay_approval_does_not_lift_a_stateful_request_with_unknown_safety() -> ( + None +): + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + raise _connection_error() + + def policy(_context: RetryPolicyContext) -> RetryDecision: + return RetryDecision(retry=True, approve_unsafe_replay=True) + + # No provider advice, so replay safety is unknown rather than unsafe. The approval is + # scoped to provider-marked unsafe failures, so the stateful gate must stay closed. + with pytest.raises(APIConnectionError): + await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, backoff={"initial_delay": 0}, policy=policy + ), + get_retry_advice=lambda _request: None, + previous_response_id="resp_1", + conversation_id=None, + ) + + assert calls == 1 diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 429abdfbbb..627d65a301 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -4289,3 +4289,45 @@ async def consume() -> None: finally: release.set() task.cancel() + + +@pytest.mark.allow_call_model_methods +def test_websocket_get_retry_advice_reports_response_started() -> None: + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + error = _connection_closed_error("no close frame received or sent") + setattr(error, "_openai_agents_ws_replay_safety", "unsafe") # noqa: B010 + setattr(error, "_openai_agents_ws_response_started", True) # noqa: B010 + + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=False, + ) + ) + + assert advice is not None + assert advice.replay_safety == "unsafe" + # A retry policy needs this to tell a response-started disconnect apart from + # other replay-unsafe failures before approving a replay. + assert advice.response_started is True + + +@pytest.mark.allow_call_model_methods +def test_websocket_get_retry_advice_reports_no_response_started_for_stateful_request() -> None: + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + error = _connection_closed_error("no close frame received or sent") + setattr(error, "_openai_agents_ws_replay_safety", "unsafe") # noqa: B010 + + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=False, + previous_response_id="resp_1", + ) + ) + + assert advice is not None + assert advice.replay_safety == "unsafe" + assert advice.response_started is False diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 203726ec7c..f654aead68 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -36,6 +36,8 @@ OpenAIConversationsSession, OutputGuardrail, OutputGuardrailTripwireTriggered, + RetryDecision, + RetryPolicyContext, RunConfig, RunContextWrapper, Runner, @@ -5039,6 +5041,55 @@ def get_retry_advice(self, request): assert last_input[0].get("type") == "function_call_output" +@pytest.mark.asyncio +async def test_auto_previous_response_id_retries_when_policy_approves_unsafe_replay(): + seen: list[RetryPolicyContext] = [] + + class StatefulRetryUnsafeFakeModel(FakeModel): + def get_retry_advice(self, request): + if request.previous_response_id or request.conversation_id: + return ModelRetryAdvice( + suggested=False, + replay_safety="unsafe", + response_started=True, + ) + return None + + def policy(context: RetryPolicyContext) -> RetryDecision: + seen.append(context) + return RetryDecision(retry=True, approve_unsafe_replay=True) + + model = StatefulRetryUnsafeFakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("test_func", '{"arg": "foo"}')], + APIConnectionError( + message="connection closed after response processing started", + request=httpx.Request("POST", "https://example.com"), + ), + [get_text_message("done")], + ] + ) + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("test_func", "tool_result")], + model_settings=ModelSettings( + retry=ModelRetrySettings(max_retries=1, policy=policy), + ), + ) + + result = await Runner.run(agent, input="user_message", auto_previous_response_id=True) + + assert result.final_output == "done" + assert len(seen) == 1 + assert seen[0].previous_response_id == "resp-789" + assert seen[0].conversation_id is None + assert seen[0].stateful_request is True + assert seen[0].response_started is True + assert seen[0].replay_safety == "unsafe" + + @pytest.mark.asyncio async def test_previous_response_id_only_sends_new_items_multi_turn_streamed(): """Test that previous_response_id mode only sends new items and updates From 47498d45b7fa8f3036de1c06167c8647df6df075 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 17:26:48 +0900 Subject: [PATCH 252/473] fix: preserve local shell outputs across RunState resume (#4320) Co-authored-by: Henry Su --- src/agents/run_state.py | 30 ++++-- tests/test_local_shell_tool.py | 186 ++++++++++++++++++++++++++++++++- tests/test_run_state.py | 55 +++++++++- 3 files changed, 262 insertions(+), 9 deletions(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 4437a44e63..065ed8593d 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -11,7 +11,7 @@ from collections.abc import Callable, Collection, Iterator, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, Generic, Literal, cast +from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, cast from uuid import uuid4 from openai.types.responses import ( @@ -36,8 +36,8 @@ Program, ProgramOutput, ) -from pydantic import TypeAdapter, ValidationError -from typing_extensions import TypeVar +from pydantic import StringConstraints, TypeAdapter, ValidationError +from typing_extensions import TypedDict, TypeVar from ._tool_identity import ( FunctionToolLookupKey, @@ -211,9 +211,20 @@ f"Missing summaries: {', '.join(_missing_schema_version_summaries)}" ) + +class _LocalShellCallOutputPayload(TypedDict): + """SDK-produced local-shell output shape stored in released RunState snapshots.""" + + type: Literal["local_shell_call_output"] + call_id: Annotated[str, StringConstraints(strict=True, min_length=1)] + output: Annotated[str, StringConstraints(strict=True)] + + _FUNCTION_OUTPUT_ADAPTER: TypeAdapter[FunctionCallOutput] = TypeAdapter(FunctionCallOutput) _COMPUTER_OUTPUT_ADAPTER: TypeAdapter[ComputerCallOutput] = TypeAdapter(ComputerCallOutput) -_LOCAL_SHELL_OUTPUT_ADAPTER: TypeAdapter[LocalShellCallOutput] = TypeAdapter(LocalShellCallOutput) +_LOCAL_SHELL_OUTPUT_ADAPTER: TypeAdapter[_LocalShellCallOutputPayload] = TypeAdapter( + _LocalShellCallOutputPayload +) _TOOL_CALL_OUTPUT_UNION_ADAPTER: TypeAdapter[ FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput ] = TypeAdapter(FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput) @@ -2727,14 +2738,19 @@ def _deserialize_tool_call_output_raw_item( ComputerCallOutput, _to_dump_compatible(_COMPUTER_OUTPUT_ADAPTER.validate_python(normalized_raw_item)), ) - if output_type == "local_shell_call_output": - return _LOCAL_SHELL_OUTPUT_ADAPTER.validate_python(normalized_raw_item) if output_type == "program_output": try: return ProgramOutput(**normalized_raw_item) except Exception: return normalized_raw_item - if output_type in {"shell_call_output", "apply_patch_call_output", "custom_tool_call_output"}: + if output_type == "local_shell_call_output": + _LOCAL_SHELL_OUTPUT_ADAPTER.validate_python(normalized_raw_item) + return normalized_raw_item + if output_type in { + "shell_call_output", + "apply_patch_call_output", + "custom_tool_call_output", + }: return normalized_raw_item try: diff --git a/tests/test_local_shell_tool.py b/tests/test_local_shell_tool.py index cdc0d9a7f1..872beaf96a 100644 --- a/tests/test_local_shell_tool.py +++ b/tests/test_local_shell_tool.py @@ -4,25 +4,32 @@ and that Runner.run executes local shell calls and records their outputs. """ +import json from typing import Any, cast +import httpx import pytest +from openai import AsyncOpenAI from openai.types.responses import ResponseOutputText +from openai.types.responses.response_input_param import LocalShellCallOutput from openai.types.responses.response_output_item import LocalShellCall, LocalShellCallAction from agents import ( Agent, LocalShellCommandRequest, LocalShellTool, + OpenAIResponsesModel, RunConfig, RunContextWrapper, RunHooks, Runner, + UserError, ) from agents.items import ToolCallOutputItem from agents.run_internal.run_loop import LocalShellAction, ToolRunLocalShellCall +from agents.run_state import RunState -from .fake_model import FakeModel +from .fake_model import FakeModel, get_response_obj from .test_responses import get_text_message @@ -38,6 +45,60 @@ def __call__(self, request: LocalShellCommandRequest) -> str: return self.output +async def _create_serialized_local_shell_state() -> tuple[LocalShellTool, dict[str, Any]]: + tool = LocalShellTool(executor=RecordingLocalShellExecutor(output="shell result")) + initial_model = FakeModel() + initial_agent = Agent(name="shell-agent", model=initial_model, tools=[tool]) + local_shell_call = LocalShellCall( + id="lsh_test", + action=LocalShellCallAction( + command=["bash", "-c", "echo shell"], + env={}, + type="exec", + timeout_ms=1000, + working_directory="/tmp", + ), + call_id="call_local_shell", + status="completed", + type="local_shell_call", + ) + initial_model.add_multiple_turn_outputs( + [ + [get_text_message("running shell"), local_shell_call], + [get_text_message("shell complete")], + ] + ) + result = await Runner.run(initial_agent, input="please run shell") + return tool, json.loads(json.dumps(result.to_state().to_json())) + + +def _create_recording_responses_model() -> tuple[ + OpenAIResponsesModel, list[httpx.Request], httpx.AsyncClient +]: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + content=get_response_obj([get_text_message("resumed")]).model_dump_json(), + headers={"content-type": "application/json"}, + request=request, + ) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = AsyncOpenAI( + api_key="test-key", + base_url="https://example.test/v1", + http_client=http_client, + ) + return ( + OpenAIResponsesModel(model="codex-mini-latest", openai_client=client), + requests, + http_client, + ) + + @pytest.mark.asyncio async def test_local_shell_action_execute_invokes_executor() -> None: executor = RecordingLocalShellExecutor(output="test output") @@ -156,3 +217,126 @@ async def test_runner_executes_local_shell_calls() -> None: assert result.final_output == "shell complete" assert len(result.raw_responses) == 2 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + "schema_version", + [None, "1.13"], + ids=["current", "v0.19.4"], +) +async def test_local_shell_output_survives_run_state_resume(schema_version: str | None) -> None: + tool, serialized = await _create_serialized_local_shell_state() + if schema_version is not None: + serialized["$schemaVersion"] = schema_version + + resumed_model, requests, http_client = _create_recording_responses_model() + try: + resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool]) + resumed_state = await RunState.from_json(resumed_agent, serialized) + + shell_outputs = [ + item.raw_item + for item in resumed_state._generated_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "local_shell_call_output" + ] + assert shell_outputs == [ + { + "type": "local_shell_call_output", + "call_id": "call_local_shell", + "output": "shell result", + } + ] + + await Runner.run(resumed_agent, resumed_state) + finally: + await http_client.aclose() + + assert len(requests) == 1 + request_body = json.loads(requests[0].content) + replayed = [item for item in request_body["input"] if isinstance(item, dict)] + replayed_call = next(item for item in replayed if item.get("type") == "local_shell_call") + replayed_output = next( + item for item in replayed if item.get("type") == "local_shell_call_output" + ) + assert replayed_call["call_id"] == replayed_output["call_id"] == "call_local_shell" + assert "id" not in replayed_output + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + "schema_version", + [None, "1.13"], + ids=["current", "v0.19.4"], +) +async def test_run_state_rejects_id_only_local_shell_output(schema_version: str | None) -> None: + tool, serialized = await _create_serialized_local_shell_state() + invalid_output = { + "type": "local_shell_call_output", + "id": "legacy-only", + "output": "shell result", + } + for item_group in ("generated_items", "session_items"): + output_items = [ + item + for item in serialized[item_group] + if item.get("raw_item", {}).get("type") == "local_shell_call_output" + ] + assert len(output_items) == 1 + output_items[0]["raw_item"] = invalid_output.copy() + if schema_version is not None: + serialized["$schemaVersion"] = schema_version + + resumed_model, requests, http_client = _create_recording_responses_model() + resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool]) + try: + if schema_version is None: + with pytest.raises(UserError, match="completed tool invocation 'call_local_shell'"): + await RunState.from_json(resumed_agent, serialized) + else: + resumed_state = await RunState.from_json(resumed_agent, serialized) + await Runner.run(resumed_agent, resumed_state) + finally: + await http_client.aclose() + + if schema_version is None: + assert requests == [] + else: + assert len(requests) == 1 + request_body = json.loads(requests[0].content) + replayed_types = { + item.get("type") for item in request_body["input"] if isinstance(item, dict) + } + assert "local_shell_call" not in replayed_types + assert "local_shell_call_output" not in replayed_types + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + "schema_version", + [None, "1.13"], + ids=["current", "v0.19.4"], +) +async def test_run_state_preserves_official_local_shell_original_input( + schema_version: str | None, +) -> None: + original_input: LocalShellCallOutput = { + "type": "local_shell_call_output", + "id": "lsh_output_123", + "output": "shell result", + } + model = FakeModel() + model.add_multiple_turn_outputs([[get_text_message("complete")]]) + agent = Agent(name="shell-agent", model=model) + result = await Runner.run(agent, input=[original_input]) + serialized = json.loads(json.dumps(result.to_state().to_json())) + if schema_version is not None: + serialized["$schemaVersion"] = schema_version + + restored_state = await RunState.from_json(agent, serialized) + assert restored_state.to_json()["original_input"] == [original_input] diff --git a/tests/test_run_state.py b/tests/test_run_state.py index e1a083d46a..163a9dd06d 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -37,7 +37,7 @@ ) from openai.types.responses.response_usage import InputTokensDetails from openai.types.responses.tool_param import Mcp -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from agents import Agent, Model, ModelSettings, RunConfig, RunHooks, Runner, handoff, trace from agents._tool_invocation import tool_invocation_identity_and_scope @@ -102,6 +102,7 @@ _capability_identity_signature, _deserialize_items, _deserialize_processed_response, + _deserialize_tool_call_output_raw_item, _serialize_guardrail_results, _serialize_tool_action_groups, ) @@ -5607,6 +5608,58 @@ async def test_deserialize_tool_call_output_item_different_types(self): result_shell = _deserialize_items([item_data_shell], {"TestAgent": agent}) assert len(result_shell) == 1 + assert result_shell[0].raw_item == item_data_shell["raw_item"] + + @pytest.mark.parametrize( + "raw_item", + [ + {"type": "local_shell_call_output", "call_id": "call123"}, + { + "type": "local_shell_call_output", + "id": "shell123", + "output": "result", + }, + { + "type": "local_shell_call_output", + "call_id": 123, + "output": "result", + }, + { + "type": "local_shell_call_output", + "call_id": b"call123", + "output": "result", + }, + { + "type": "local_shell_call_output", + "call_id": "", + "output": "result", + }, + { + "type": "local_shell_call_output", + "call_id": "call123", + "output": 123, + }, + { + "type": "local_shell_call_output", + "call_id": "call123", + "output": b"result", + }, + ], + ids=[ + "missing-output", + "id-only", + "invalid-call-id", + "bytes-call-id", + "empty-call-id", + "invalid-output", + "bytes-output", + ], + ) + async def test_deserialize_rejects_invalid_local_shell_call_output( + self, raw_item: dict[str, Any] + ) -> None: + with pytest.raises(ValidationError): + _deserialize_tool_call_output_raw_item(raw_item) async def test_deserialize_reasoning_item(self): """Test deserialization of reasoning_item.""" From 2eeb86036eba26c51d49dae16e8cc55cbb9c58a2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 16:43:12 +0900 Subject: [PATCH 253/473] perf: validate final review protocol artifacts --- .../implementation-final-review/SKILL.md | 15 +- .../references/reviewer-brief.md | 44 +- .../scripts/review_protocol.py | 1008 +++++++++++++++ .../scripts/review_state.py | 44 +- .../scripts/test_review_protocol.py | 1089 +++++++++++++++++ .../scripts/test_review_state.py | 30 + .../scripts/test_skill_contract.py | 119 +- 7 files changed, 2329 insertions(+), 20 deletions(-) create mode 100644 .agents/skills/implementation-final-review/scripts/review_protocol.py create mode 100644 .agents/skills/implementation-final-review/scripts/test_review_protocol.py diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index 8449c5c136..e39381a608 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -17,6 +17,7 @@ Treat implementation and final review as separate phases. Reconstruct the change - Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. - Never weaken final repository verification. Component-aware review invalidation reduces repeated review, not required build or test gates. - Keep one task-global round ledger and one bounded review budget across pauses, compaction, handoff, renaming, and resumed work. +- Trust the active implementation control plane to record actual reviewer dispatches, waits, outputs, and verification executions. The local protocol helper validates those records but does not replace platform-issued cryptographic execution attestation. ## Workflow @@ -45,13 +46,13 @@ Treat implementation and final review as separate phases. Reconstruct the change - unnecessary machinery or duplicated source of truth; - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. -10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget is six fingerprint rounds for the entire task. Only explicit user authorization may start another bounded budget, and the existing ledger and root-cause history must remain attached. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. -11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. -12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Prefer one event-driven wait of 180-300 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages; after one reviewer completes, continue waiting only for the remaining reviewer. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. During an iterative review round, use the narrowest evidence-based affected-boundary check: for changes unrelated to every `review_optional` owner, run `make tests-review`; for a leaf subsystem change, run `make tests-review` plus that subsystem's complete test file or directory without a marker filter; for cross-cutting core or shared test-infrastructure changes, run `make tests`. Inspect the current marker owners before choosing. Prefer an already successful same-fingerprint check over rerunning it, and never replay cumulative historical verification. The reduced check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete `make tests` gate. If the affected boundary is uncertain, run `make tests`. Record combined and component fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when both fingerprints match the reviewed fingerprint exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit only for changed or dependency-invalidated components and for any final gate whose fingerprint no longer matches. +10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget is six fingerprint rounds for the entire task. Only explicit user authorization may start another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. +11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the complete raw diff as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires the complete-diff digest to match its `tracked_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix and canonical root ownership, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. +12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. During an iterative review round, use the narrowest evidence-based affected-boundary check: for changes unrelated to every `review_optional` owner, run `make tests-review`; for a leaf subsystem change, run `make tests-review` plus that subsystem's complete test file or directory without a marker filter; for cross-cutting core or shared test-infrastructure changes, run `make tests`. Inspect the current marker owners before choosing. Prefer an already successful same-fingerprint check over rerunning it, and never replay cumulative historical verification. Represent reusable success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The reduced check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete `make tests` gate. If the affected boundary is uncertain, run `make tests`. Record combined, component, and repository fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when all fingerprints match the reviewed repository state exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit only for changed or dependency-invalidated components and for any final gate whose fingerprint no longer matches. 13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged. Require two concurrent independent delta reviews of every changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. -14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. +14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, and reopening a closed canonical root without evidence IDs that are new to that root. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. 15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. -16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the root-cause ID closed. Do not reopen it for another local patch without new contract evidence; if it cannot be closed coherently, escalate instead of consuming more rounds. +16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the canonical root-cause ID closed. Do not reopen it for another local patch without new contract evidence or a newly uncovered inventory ID; reject aliases, renamed IDs, and bare unknown IDs instead of treating them as new roots. If it cannot be closed coherently, escalate instead of consuming more rounds. 17. Increment the fingerprint round and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> review without waiting for another user prompt. 18. Apply the non-convergence guard before another local fix: - If the same root-cause group produces another P0/P1 after a complexity reset, return to the merge base and replace task-owned branch-local machinery with the narrowest coherent implementation. @@ -160,6 +161,10 @@ Run a complexity reset when related findings keep expanding the same design, a n 7. Rebuild tests around caller-visible invariants and representative negative cases. 8. Compare the replacement's runtime and test complexity with both the previous round and the merge base. A reset that only renames or redistributes a growing state machine is not a reset. +Review-state workspace entries must use the exact key set emitted for their `file`, `symlink`, `gitlink`, `directory`, or `missing` kind; incomplete or unknown fields fail before dispatch. + +For reusable verification credit, a receipt command must exactly match a structured command in `verification.preflight_results`; a different successful command cannot inherit verification credit. + ## Review output Return exactly one JSON object using the schema in `references/reviewer-brief.md`. Put the verdict in `verdict`; put each actionable finding in `findings` with its priority, title, location, concrete failure scenario, user-visible consequence, support basis, baseline-versus-patch evidence when applicable, smallest safe correction, and stable root-cause ID. Account for every assigned inventory ID and keep unverified runtime uncertainty explicit. Do not claim implementation completion until both structured clean reviews and required verification apply to the exact final state. diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index 9161445d24..fbfbb322cf 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -16,26 +16,48 @@ Use this template to prepare one self-contained, factual snapshot packet per fin - Latest release boundary when relevant: - Risk tier and reason: - Task-global ledger path, task identity, current round, and remaining authorized budget: +- Canonical root-cause ledger (`ID | open/closed | inventory IDs | contract evidence IDs`): - Canonical task manifest: - Component manifests: - Semantic component dependency map and invalidation reasons: - Combined, component, and repository fingerprints: - Exact fingerprint revalidation command: -- Raw repository status: +- Unfiltered repository-status artifact and explicit exclusions outside the task manifest: - Complete three-dot diff command: -- Indexed evidence manifest (`ID | exact path | SHA-256 | purpose`): +- Indexed evidence manifest (`ID | role | exact path | SHA-256 | purpose`): - Focused preflight commands and results: - Same-fingerprint verification already credited, or `none`: +- Verification receipt path and SHA-256 descriptors for credited checks, or `none`: - Eligible concurrent final-gate commands and non-mutation basis: - Gates deferred because they may mutate task-owned content, or `none`: - Selected architecture references or exact relevant excerpts: +## Machine-readable preflight + +Store the shared packet index as one JSON object and validate it before dispatch: + +`python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` + +The active implementation control plane is trusted to record real reviewer dispatches, waits, outputs, and verification executions. The local helper validates completeness, digests, identity, state transitions, and reuse against those records; it does not provide cryptographic attestation against a malicious control plane that fabricates every input. Platform-issued signed execution provenance is intentionally unsupported here and requires a separate trusted service. + +The packet object uses integer `schema_version: 1` and contains these required top-level fields: `packet_overage_reason`, `task`, `scope_contract`, `repository`, `ledger`, `manifests`, `review_state`, `verification`, `architecture_references`, `evidence_artifacts`, `inventory`, `selected_high_risk_dimensions`, and `reviewer_assignments`. Mirror the factual fields above rather than adding conclusions. Encode `verification.preflight_results` as an array of exact `command` and `result` objects; use an empty array when no focused preflight ran. Store exactly one evidence artifact with `role: "review-state"` containing the unmodified `review_state.py` JSON, exactly one with `role: "complete-diff"`, and exactly one with `role: "repository-status"` containing unfiltered porcelain-v1 `-z` status. The `review_state` packet object contains exactly `evidence_id`, which names the review-state artifact, and the exact `revalidation_command`; extra copied fingerprint or state fields are invalid. The repository object names the status artifact with `status_evidence_id` and lists every changed path outside the task manifest in `exclusions` with a concrete reason. Use two reviewer assignments whose combined IDs cover every inventory row and selected high-risk dimension. Every reviewer assignment must include every component boundary and all three control artifacts; supporting evidence may remain specialty-specific. The validator derives fingerprints from the digested review-state artifact, requires repository base and head to match it, requires the task and component manifests to match its pathspecs exactly, requires the complete-diff artifact digest to equal its `tracked_diff_sha256`, requires the status digest to equal its unfiltered status fingerprint, and requires exclusions to account exactly for every unfiltered changed path outside the task workspace. It reports the packet's actual path, byte size, SHA-256 digest, review-state path, fingerprint, components, inventory IDs, and reviewer IDs; copy that output into the dispatch record. If the packet exceeds 12 KiB, replace `packet_overage_reason: "none"` with the decision-relevant reason it could not be split further. + +The ledger contains `task_id`, `authorized_round_budgets`, `current_round`, `remaining_budget`, and `root_causes`. Supply the task ID and absolute task-global ledger path independently on every validator command. For every round after round 1, also supply the immediately preceding round's immutable ledger snapshot and its SHA-256 digest from the control plane; never derive either argument from the packet under validation. The immutable snapshot must be a distinct file, not the mutable current ledger under another argument. The validator requires the packet, current ledger, and prior ledger identity to match those control-plane arguments. It requires `current_round` plus `remaining_budget` to equal the sum of the positive integer budget history, the current budget history to preserve the prior prefix, the current round to equal the prior round for a same-round retry or advance by exactly one, every prior canonical root and its ownership to remain present, and the current ledger file's JSON object to match the packet ledger exactly. Each `ledger.root_causes` entry contains `id`, `status`, `inventory_ids`, and `contract_evidence_ids`. Every root must own at least one inventory ID, and each inventory ID has exactly one canonical root owner. Every contract evidence ID must resolve to an `evidence_artifacts[].id`; the ledger cannot establish evidence authority with an unindexed string. The implementer owns canonical IDs. Reviewers must reuse one supplied ID or propose `NEW:` with evidence or inventory not already owned by any canonical root; reviewers must not mint a renamed bare ID. Only the implementer promotes a proposal into the ledger. + +Each credited verification receipt uses integer `schema_version: 1` and integer `exit_status: 0`, and contains `command`, `environment`, `non_mutation_basis`, and exact `before` and `after` objects with `combined`, `components`, and `repository` fingerprints. JSON booleans are not integers for protocol purposes. Add an object with its absolute `path` and `sha256` digest to `verification.credited_receipts`; packet preflight rejects replacement, a failed command, task or repository-state drift, or before/after drift. The standalone check accepts only a receipt path already indexed by the validated packet; it does not grant credit to an arbitrary same-fingerprint file: + +The validator recomputes the content, component, and repository fingerprints from the complete typed workspace entries in the review-state artifact and rejects an incomplete or unknown key for any workspace kind or a non-partitioning component workspace. A credited receipt's exact command must also appear in `verification.preflight_results`; unrelated successful commands are ineligible for credit. + +`python scripts/review_protocol.py receipt --packet --receipt --task-id --ledger --prior-ledger --prior-ledger-sha256 ` + ## Contract-surface inventory Give every row a stable ID. Use one row per changed public symbol, configuration field, event, serialized field, wire value, or documented behavior. `ID | surface | producers/constructors | consumers/forwarding branches/adapters | default/missing/invalid behavior | package exports/generated public surfaces | adjacent docs/examples | caller-visible tests` +Encode those columns in each `kind: "contract"` inventory object as `surface`, `producers`, `consumers`, `behavior`, `exports`, `adjacent`, and `tests`. Each field must be a nonempty string; use `none` or `not applicable` only when that is the explicit reviewed value. + Include adjacent surfaces found outside the current diff. If a required update is absent, add it to the task manifest before freezing the review. ## Await-boundary or authority inventory @@ -44,12 +66,16 @@ For concurrency, cancellation, reentrancy, or lifecycle state: `ID | operation | state snapshot | await/blocking point | events/operations possible while suspended | monotonic evidence retained | revalidation | side effects/invariant` +Encode those columns in each `kind: "await-boundary"` inventory object as `operation`, `state_snapshot`, `blocking_point`, `suspended_events`, `monotonic_evidence`, `revalidation`, and `side_effects_invariant`. + Populate supported states including source completion, newer active operation with known or unknown identity, newer operation started then completed, and awaited-action failure or cancellation. If the contract depends on whether something ever happened, identify the monotonic evidence or the serialization proof. For protocol, security, or persistence instead use: `ID | input/authority | validation | in-memory state | persisted/serialized state | retry/replay | output | exception/log/telemetry exposure | cleanup/revocation` +Encode those columns in each `kind: "authority-data-flow"` inventory object as `input_authority`, `validation`, `in_memory_state`, `persisted_state`, `retry_replay`, `output`, `exception_exposure`, and `cleanup_revocation`. Every kind-specific field must be a nonempty string so preflight rejects a summary-only row before dispatch. + ## Reviewer instructions Perform exactly one read-only review round on the frozen fingerprint. Your context must be created with no inherited implementer conversation; the dispatcher uses `fork_turns: "none"` when available. First run the supplied revalidation command and calculate the merge base. Then inspect the complete raw diff, surrounding source, tests, and supplied references. Validate every assigned inventory row rather than trusting the implementer. You may report blockers outside your specialty. @@ -82,7 +108,11 @@ Return exactly one JSON object with this shape and no prose outside it: "support_basis": "...", "baseline_patch_evidence": "... | not applicable", "smallest_safe_correction": "...", - "root_cause_id": "..." + "root_cause_id": "CANONICAL_ID | NEW:", + "root_cause_evidence": { + "new_contract_evidence_ids": ["..."], + "new_inventory_ids": ["..."] + } } ], "sibling_scenario_scan": [{"root_cause_id": "...", "inventory_ids": ["..."], "result": "..."}], @@ -91,10 +121,14 @@ Return exactly one JSON object with this shape and no prose outside it: } ``` -Use empty arrays for `focused_probes`, `remaining_uncertainty`, `findings`, or `sibling_scenario_scan` when there are none. Every assigned inventory ID must appear in either `checked_inventory_ids` or `unchecked_inventory_ids`. A `clean` verdict requires an empty `unchecked_inventory_ids`, `remaining_uncertainty`, and `findings` array. +Use empty arrays for `focused_probes`, `remaining_uncertainty`, `findings`, or `sibling_scenario_scan` when there are none. Every assigned inventory ID must appear in either `checked_inventory_ids` or `unchecked_inventory_ids`. Each sibling-scenario scan must reuse a canonical root ID or a `NEW:` root proposed by a finding in the same output, and every scan inventory ID must resolve to an indexed inventory row. A `clean` verdict requires an empty `unchecked_inventory_ids`, `remaining_uncertainty`, and `findings` array. Every `focused_probes[].command` must contain the exact executable command that ran. For a non-shell tool call, provide the complete tool name and arguments. Prose-only labels, omitted arguments, and placeholders such as `` are incomplete and earn no clean credit. If the exact command would be too large to return, place the probe code in an indexed evidence artifact before execution and return its path, SHA-256 digest, and exact execution command. +For each finding, reuse a canonical root-cause ID supplied in the packet or propose `NEW:`. Populate both `root_cause_evidence` arrays, using empty arrays when there is no new evidence. Every submitted contract evidence ID must name an indexed `evidence_artifacts[].id`, and every submitted inventory ID must name an indexed `inventory[].id`. For a canonical root, submitted IDs must be additions owned by that root in the current ledger relative to the prior immutable snapshot; an inventory ID owned by another root cannot be reassigned as finding evidence. A new proposal requires at least one indexed contract evidence or inventory ID that is not owned by any canonical root. A closed root may be reopened only with the same kind of new evidence; renaming or aliasing it does not create a new root. If a reviewer discovers evidence that is absent from the frozen packet, add and digest that evidence in the packet, rerun packet preflight on the same fingerprint round, and then resubmit the output. The implementer validates each saved response before accepting findings or clean credit: + +`python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` + A bare `clean` or generic checklist is incomplete and earns no clean credit. A malformed JSON object or missing required field is equally incomplete. ## Specialty assignment @@ -104,3 +138,5 @@ A bare `clean` or generic checklist is incomplete and earns no clean credit. A m - Expected component boundaries: - Evidence items expected to be sufficient: - Complementary reviewer assignment, if any: +- Reviewer ID from the machine-readable packet: +- Canonical root-cause IDs and closure states: diff --git a/.agents/skills/implementation-final-review/scripts/review_protocol.py b/.agents/skills/implementation-final-review/scripts/review_protocol.py new file mode 100644 index 0000000000..d32fbe314f --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/review_protocol.py @@ -0,0 +1,1008 @@ +#!/usr/bin/env python3 +"""Validate final-review packets, reviewer outputs, and verification receipts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path +from typing import Any + +from review_state import _content_fingerprint, _repository_fingerprint + +PACKET_SOFT_LIMIT_BYTES = 12 * 1024 +SENTINELS = {"none", "not applicable"} +ROOT_CAUSE_ID = re.compile(r"[A-Z][A-Z0-9_-]*") +NEW_ROOT_CAUSE_ID = re.compile(r"NEW:[a-z0-9]+(?:-[a-z0-9]+)*") +SHA256 = re.compile(r"[0-9a-f]{64}") +PLACEHOLDER_TOKEN = re.compile(r"<(?=\S)[^<>\n]*\S>") + +REQUIRED_PACKET_TEXT = ( + "task.id", + "task.original_requirement", + "task.risk_tier", + "task.risk_reason", + "scope_contract.required_behavior", + "scope_contract.compatibility_requirements", + "scope_contract.unsupported_cases", + "scope_contract.supported_alternative", + "repository.target", + "repository.merge_base", + "repository.head", + "repository.release_boundary", + "repository.status_evidence_id", + "repository.complete_diff_command", + "ledger.path", + "manifests.task", + "manifests.dependency_map", + "review_state.evidence_id", + "review_state.revalidation_command", + "verification.eligible_concurrent_gates", + "verification.deferred_gates", +) +REVIEWER_OUTPUT_FIELDS = { + "verdict", + "reviewed_fingerprints", + "checked_inventory_ids", + "unchecked_inventory_ids", + "high_risk_dimensions_checked", + "focused_probes", + "remaining_uncertainty", + "findings", + "sibling_scenario_scan", + "inspection_call_count", + "inspection_budget_reason", +} +FINDING_FIELDS = { + "priority", + "title", + "location", + "failure_scenario", + "user_consequence", + "support_basis", + "baseline_patch_evidence", + "smallest_safe_correction", + "root_cause_id", + "root_cause_evidence", +} +INVENTORY_FIELDS = { + "contract": { + "surface", + "producers", + "consumers", + "behavior", + "exports", + "adjacent", + "tests", + }, + "await-boundary": { + "operation", + "state_snapshot", + "blocking_point", + "suspended_events", + "monotonic_evidence", + "revalidation", + "side_effects_invariant", + }, + "authority-data-flow": { + "input_authority", + "validation", + "in_memory_state", + "persisted_state", + "retry_replay", + "output", + "exception_exposure", + "cleanup_revocation", + }, +} + + +class ProtocolError(ValueError): + """Raised when a review protocol artifact is incomplete or inconsistent.""" + + +def _object(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ProtocolError(f"{context} must be an object.") + return value + + +def _array(value: Any, context: str) -> list[Any]: + if not isinstance(value, list): + raise ProtocolError(f"{context} must be an array.") + return value + + +def _text(value: Any, context: str, *, concrete: bool = False) -> str: + if not isinstance(value, str) or not value.strip(): + raise ProtocolError(f"{context} must be a nonempty string.") + if concrete and value.strip().lower() in SENTINELS: + raise ProtocolError(f"{context} must contain concrete evidence.") + return value + + +def _strings(value: Any, context: str) -> list[str]: + result = [ + _text(item, f"{context}[{index}]") for index, item in enumerate(_array(value, context)) + ] + if len(result) != len(set(result)): + raise ProtocolError(f"{context} must not contain duplicates.") + return result + + +def _integer(value: Any, context: str, *, minimum: int) -> int: + if type(value) is not int or value < minimum: + qualifier = "positive" if minimum == 1 else "nonnegative" + raise ProtocolError(f"{context} must be a {qualifier} integer.") + return value + + +def _at(value: dict[str, Any], dotted_path: str) -> Any: + current: Any = value + for part in dotted_path.split("."): + if not isinstance(current, dict) or part not in current: + raise ProtocolError(f"Missing required packet field: {dotted_path}.") + current = current[part] + return current + + +def _read_bytes(value: Any, context: str) -> tuple[Path, bytes]: + path = Path(_text(value, context, concrete=True)) + if not path.is_absolute(): + raise ProtocolError(f"{context} must be an absolute path: {path}.") + try: + return path, path.read_bytes() + except OSError as error: + raise ProtocolError(f"Cannot read {context} {path}: {error}") from error + + +def _json_bytes(data: bytes, context: str) -> dict[str, Any]: + try: + value = json.loads(data) + except (UnicodeError, json.JSONDecodeError) as error: + raise ProtocolError(f"Cannot read JSON object from {context}: {error}") from error + return _object(value, context) + + +def _load_json(path: Path) -> dict[str, Any]: + _, data = _read_bytes(str(path.resolve()), str(path)) + return _json_bytes(data, str(path)) + + +def _descriptor(value: Any, context: str) -> tuple[Path, bytes, str]: + descriptor = _object(value, context) + path, data = _read_bytes(descriptor.get("path"), f"{context}.path") + expected = _text(descriptor.get("sha256"), f"{context}.sha256") + if not SHA256.fullmatch(expected): + raise ProtocolError(f"{context}.sha256 must be a lowercase SHA-256 digest.") + actual = hashlib.sha256(data).hexdigest() + if actual != expected: + raise ProtocolError(f"{context} digest mismatch for {path}.") + return path, data, actual + + +def _pathspec_file(value: Any, context: str) -> list[str]: + _, data = _read_bytes(value, context) + try: + lines = [line for line in data.decode().splitlines() if line] + except UnicodeError as error: + raise ProtocolError(f"Cannot decode {context}: {error}") from error + if not lines or len(lines) != len(set(lines)): + raise ProtocolError(f"{context} must contain unique nonempty pathspecs.") + return lines + + +def _command_result(value: Any, context: str) -> None: + record = _object(value, context) + command = _text(record.get("command"), f"{context}.command", concrete=True) + _text(record.get("result"), f"{context}.result", concrete=True) + if PLACEHOLDER_TOKEN.search(command): + raise ProtocolError(f"{context}.command contains a placeholder token.") + + +def _sha256(value: Any, context: str) -> str: + digest = _text(value, context) + if not SHA256.fullmatch(digest): + raise ProtocolError(f"{context} must be a lowercase SHA-256 digest.") + return digest + + +def _workspace_entries(value: Any, context: str) -> dict[str, dict[str, Any]]: + entries: dict[str, dict[str, Any]] = {} + for index, raw_entry in enumerate(_array(value, context)): + entry = _object(raw_entry, f"{context}[{index}]") + path = _text(entry.get("path"), f"{context}[{index}].path") + if path in entries: + raise ProtocolError(f"{context} contains duplicate path {path!r}.") + kind = entry.get("kind") + required_fields = { + "file": {"path", "kind", "executable", "sha256"}, + "symlink": {"path", "kind", "sha256"}, + "gitlink": {"path", "kind", "head", "status_sha256"}, + "directory": {"path", "kind"}, + "missing": {"path", "kind"}, + } + if kind not in required_fields: + raise ProtocolError(f"{context}[{index}].kind is invalid: {kind!r}.") + missing = sorted(required_fields[kind] - entry.keys()) + unexpected = sorted(entry.keys() - required_fields[kind]) + if missing or unexpected: + raise ProtocolError( + f"{context}[{index}] does not match the {kind} schema: " + f"missing={missing}, unexpected={unexpected}." + ) + if kind == "file" and type(entry["executable"]) is not bool: + raise ProtocolError(f"{context}[{index}].executable must be a boolean.") + if kind in {"file", "symlink"}: + _sha256(entry["sha256"], f"{context}[{index}].sha256") + if kind == "gitlink": + head = _text(entry["head"], f"{context}[{index}].head") + if not re.fullmatch(r"[0-9a-f]{40,64}", head): + raise ProtocolError(f"{context}[{index}].head must be a Git object ID.") + _sha256(entry["status_sha256"], f"{context}[{index}].status_sha256") + entries[path] = entry + if list(entries) != sorted(entries): + raise ProtocolError(f"{context} must be sorted by path.") + return entries + + +def _workspace_paths(value: Any, context: str) -> set[str]: + return set(_workspace_entries(value, context)) + + +def _evidence_artifacts(packet: dict[str, Any]) -> dict[str, dict[str, Any]]: + artifacts: dict[str, dict[str, Any]] = {} + role_ids: dict[str, set[str]] = { + "complete-diff": set(), + "review-state": set(), + "repository-status": set(), + } + for index, raw_artifact in enumerate( + _array(packet.get("evidence_artifacts"), "evidence_artifacts") + ): + artifact = _object(raw_artifact, f"evidence_artifacts[{index}]") + artifact_id = _text(artifact.get("id"), f"evidence_artifacts[{index}].id") + if artifact_id in artifacts: + raise ProtocolError(f"Duplicate evidence artifact ID: {artifact_id}.") + path, data, digest = _descriptor(artifact, f"evidence artifact {artifact_id}") + role = artifact.get("role") + if role not in {"complete-diff", "review-state", "repository-status", "supporting"}: + raise ProtocolError(f"Evidence artifact {artifact_id} has an invalid role: {role!r}.") + _text(artifact.get("purpose"), f"evidence artifact {artifact_id}.purpose", concrete=True) + if role in role_ids: + role_ids[role].add(artifact_id) + artifacts[artifact_id] = { + "path": path, + "data": data, + "digest": digest, + "role": role, + } + for role, ids in role_ids.items(): + if len(ids) != 1: + raise ProtocolError(f"evidence_artifacts must contain exactly one {role} artifact.") + return artifacts + + +def _review_state( + packet: dict[str, Any], artifacts: dict[str, dict[str, Any]] +) -> tuple[dict[str, Any], str, dict[str, str]]: + descriptor = _object(packet.get("review_state"), "review_state") + if set(descriptor) != {"evidence_id", "revalidation_command"}: + raise ProtocolError("review_state must contain only evidence_id and revalidation_command.") + _command_result( + {"command": descriptor.get("revalidation_command"), "result": "configured"}, + "review_state.revalidation", + ) + evidence_id = _text(descriptor.get("evidence_id"), "review_state.evidence_id") + artifact = artifacts.get(evidence_id) + if artifact is None or artifact["role"] != "review-state": + raise ProtocolError("review_state.evidence_id must name the review-state artifact.") + state = _json_bytes(artifact["data"], str(artifact["path"])) + base = _text(state.get("base"), "review_state.base") + head = _text(state.get("head"), "review_state.head") + combined = _sha256(state.get("content_fingerprint"), "review_state.content_fingerprint") + if _sha256(state.get("fingerprint"), "review_state.fingerprint") != combined: + raise ProtocolError("review_state.fingerprint must match content_fingerprint.") + repository = _sha256(state.get("repository_fingerprint"), "review_state.repository_fingerprint") + status = _sha256(state.get("status_sha256"), "review_state.status_sha256") + tracked_diff = _sha256(state.get("tracked_diff_sha256"), "review_state.tracked_diff_sha256") + workspace = _workspace_entries(state.get("workspace"), "review_state.workspace") + actual_combined = _content_fingerprint(base, list(workspace.values())) + if combined != actual_combined: + raise ProtocolError("review_state.content_fingerprint does not match its workspace.") + components: dict[str, str] = {} + component_owners: dict[str, str] = {} + for name, raw_component in _object(state.get("components"), "review_state.components").items(): + component = _object(raw_component, f"review_state.components[{name!r}]") + fingerprint = _sha256( + component.get("content_fingerprint"), + f"review_state.components[{name!r}].content_fingerprint", + ) + _strings(component.get("pathspecs"), f"review_state.components[{name!r}].pathspecs") + component_workspace = _workspace_entries( + component.get("workspace"), f"review_state.components[{name!r}].workspace" + ) + actual_fingerprint = _content_fingerprint(base, list(component_workspace.values())) + if fingerprint != actual_fingerprint: + raise ProtocolError(f"Component {name!r} fingerprint does not match its workspace.") + for path, entry in component_workspace.items(): + if path not in workspace or entry != workspace[path]: + raise ProtocolError( + f"Component {name!r} workspace entry {path!r} differs from combined state." + ) + if path in component_owners: + raise ProtocolError( + f"Components {component_owners[path]!r} and {name!r} overlap on {path!r}." + ) + component_owners[path] = name + components[name] = fingerprint + if not components: + raise ProtocolError("review_state.components must not be empty.") + if set(component_owners) != set(workspace): + raise ProtocolError("review_state component workspaces must partition combined workspace.") + _strings(state.get("pathspecs"), "review_state.pathspecs") + unfiltered = _object(state.get("unfiltered"), "review_state.unfiltered") + unfiltered_status = _sha256( + unfiltered.get("status_sha256"), "review_state.unfiltered.status_sha256" + ) + unfiltered_workspace = _workspace_entries( + unfiltered.get("workspace"), "review_state.unfiltered.workspace" + ) + for path, entry in workspace.items(): + if unfiltered_workspace.get(path) != entry: + raise ProtocolError( + f"review_state.unfiltered.workspace does not preserve task entry {path!r}." + ) + unfiltered_content = _content_fingerprint(base, list(unfiltered_workspace.values())) + actual_repository = _repository_fingerprint( + content_fingerprint=combined, + head=head, + status_sha256=status, + tracked_diff_sha256=tracked_diff, + unfiltered_status_sha256=unfiltered_status, + unfiltered_content_fingerprint=unfiltered_content, + ) + if repository != actual_repository: + raise ProtocolError("review_state.repository_fingerprint does not match its state fields.") + return state, combined, components + + +def validate_receipt_data( + receipt: dict[str, Any], + expected_combined: str, + expected_components: dict[str, str], + expected_repository: str, + eligible_commands: set[str] | None = None, +) -> None: + required = { + "schema_version", + "command", + "environment", + "exit_status", + "non_mutation_basis", + "before", + "after", + } + missing = sorted(required - receipt.keys()) + if missing: + raise ProtocolError(f"Verification receipt is missing fields: {missing}.") + if type(receipt["schema_version"]) is not int or receipt["schema_version"] != 1: + raise ProtocolError("Verification receipt schema_version must be integer 1.") + if type(receipt["exit_status"]) is not int or receipt["exit_status"] != 0: + raise ProtocolError("Verification receipt requires integer exit_status 0.") + _command_result( + {"command": receipt["command"], "result": receipt["non_mutation_basis"]}, + "verification receipt", + ) + if eligible_commands is not None and receipt["command"] not in eligible_commands: + raise ProtocolError( + "Verification receipt command must exactly match a packet preflight command." + ) + _text(receipt["environment"], "verification receipt environment", concrete=True) + expected = { + "combined": expected_combined, + "components": expected_components, + "repository": expected_repository, + } + for boundary in ("before", "after"): + if receipt[boundary] != expected: + raise ProtocolError( + f"Verification receipt {boundary} fingerprints do not match the packet exactly." + ) + + +def validate_packet( + path: Path, + expected_task_id: str, + expected_ledger_path: Path, + prior_ledger_path: Path | None = None, + prior_ledger_sha256: str | None = None, +) -> dict[str, Any]: + packet = _load_json(path) + if type(packet.get("schema_version")) is not int or packet["schema_version"] != 1: + raise ProtocolError("Packet schema_version must be integer 1.") + for dotted_path in REQUIRED_PACKET_TEXT: + _text(_at(packet, dotted_path), dotted_path) + if _at(packet, "task.risk_tier") not in {"normal", "elevated"}: + raise ProtocolError("task.risk_tier must be 'normal' or 'elevated'.") + expected_task_id = _text(expected_task_id, "expected task ID", concrete=True) + expected_ledger_path = expected_ledger_path.resolve() + if _at(packet, "task.id") != expected_task_id: + raise ProtocolError("packet task.id must match the control-plane task ID.") + + packet_size = path.stat().st_size + overage_reason = _text(packet.get("packet_overage_reason"), "packet_overage_reason") + if packet_size > PACKET_SOFT_LIMIT_BYTES and overage_reason.strip().lower() in SENTINELS: + raise ProtocolError( + f"Packet is {packet_size} bytes, above {PACKET_SOFT_LIMIT_BYTES}; " + "provide an overage reason." + ) + + artifacts = _evidence_artifacts(packet) + state, combined, components = _review_state(packet, artifacts) + if _at(packet, "repository.merge_base") != state["base"]: + raise ProtocolError("repository.merge_base must match the review-state base.") + if _at(packet, "repository.head") != state["head"]: + raise ProtocolError("repository.head must match the review-state head.") + if PLACEHOLDER_TOKEN.search(_at(packet, "repository.complete_diff_command")): + raise ProtocolError("repository.complete_diff_command contains a placeholder token.") + status_evidence_id = _at(packet, "repository.status_evidence_id") + status_artifact = artifacts.get(status_evidence_id) + if status_artifact is None or status_artifact["role"] != "repository-status": + raise ProtocolError( + "repository.status_evidence_id must name the repository-status artifact." + ) + if status_artifact["digest"] != state["unfiltered"]["status_sha256"]: + raise ProtocolError( + "The repository-status artifact must match review_state.unfiltered.status_sha256." + ) + task_workspace = _workspace_paths(state["workspace"], "review_state.workspace") + full_workspace = _workspace_paths( + state["unfiltered"]["workspace"], "review_state.unfiltered.workspace" + ) + exclusions: dict[str, str] = {} + for index, raw_exclusion in enumerate( + _array(_at(packet, "repository.exclusions"), "repository.exclusions") + ): + exclusion = _object(raw_exclusion, f"repository.exclusions[{index}]") + excluded_path = _text( + exclusion.get("path"), f"repository.exclusions[{index}].path", concrete=True + ) + if excluded_path in exclusions: + raise ProtocolError(f"Duplicate repository exclusion: {excluded_path}.") + exclusions[excluded_path] = _text( + exclusion.get("reason"), f"repository.exclusions[{index}].reason", concrete=True + ) + expected_exclusions = full_workspace - task_workspace + if set(exclusions) != expected_exclusions: + raise ProtocolError( + "repository.exclusions must exactly account for unfiltered changed paths outside " + f"the task manifest: {sorted(expected_exclusions)}." + ) + + manifests = _object(packet.get("manifests"), "manifests") + if _pathspec_file(manifests.get("task"), "manifests.task") != state["pathspecs"]: + raise ProtocolError("manifests.task must match review_state.pathspecs exactly.") + component_manifests = _object(manifests.get("components"), "manifests.components") + if set(component_manifests) != set(components): + raise ProtocolError("Component manifest and review-state names must match exactly.") + for name, manifest_path in component_manifests.items(): + if ( + _pathspec_file(manifest_path, f"manifests.components[{name!r}]") + != state["components"][name]["pathspecs"] + ): + raise ProtocolError(f"Component manifest {name!r} must match review state exactly.") + + inventory_ids: set[str] = set() + for index, raw_row in enumerate(_array(packet.get("inventory"), "inventory")): + row = _object(raw_row, f"inventory[{index}]") + row_id = _text(row.get("id"), f"inventory[{index}].id", concrete=True) + if row_id in inventory_ids: + raise ProtocolError(f"Duplicate inventory ID: {row_id}.") + inventory_ids.add(row_id) + kind = row.get("kind") + if kind not in INVENTORY_FIELDS: + raise ProtocolError(f"Inventory {row_id} has invalid kind.") + _text(row.get("summary"), f"inventory[{index}].summary", concrete=True) + missing_fields = sorted(INVENTORY_FIELDS[kind] - row.keys()) + if missing_fields: + raise ProtocolError(f"Inventory {row_id} is missing {kind} fields: {missing_fields}.") + for field in INVENTORY_FIELDS[kind]: + _text(row[field], f"inventory[{index}].{field}") + if not inventory_ids: + raise ProtocolError("inventory must not be empty.") + + complete_diff_ids = { + artifact_id + for artifact_id, artifact in artifacts.items() + if artifact["role"] == "complete-diff" + } + complete_diff_id = next(iter(complete_diff_ids)) + if artifacts[complete_diff_id]["digest"] != state["tracked_diff_sha256"]: + raise ProtocolError( + f"Complete-diff artifact {complete_diff_id} must match " + "review_state.tracked_diff_sha256." + ) + + ledger = _object(packet.get("ledger"), "ledger") + ledger_path, ledger_data = _read_bytes(ledger.get("path"), "ledger.path") + if ledger_path.resolve() != expected_ledger_path: + raise ProtocolError("ledger.path must match the control-plane ledger path.") + if _json_bytes(ledger_data, str(ledger_path)) != ledger: + raise ProtocolError("ledger.path content must match the packet ledger exactly.") + if ledger.get("task_id") != expected_task_id: + raise ProtocolError("ledger.task_id must match the control-plane task ID.") + authorized_budgets = [ + _integer(value, f"ledger.authorized_round_budgets[{index}]", minimum=1) + for index, value in enumerate( + _array(ledger.get("authorized_round_budgets"), "ledger.authorized_round_budgets") + ) + ] + if not authorized_budgets: + raise ProtocolError("ledger.authorized_round_budgets must not be empty.") + current_round = _integer(ledger.get("current_round"), "ledger.current_round", minimum=1) + remaining_budget = _integer( + ledger.get("remaining_budget"), "ledger.remaining_budget", minimum=0 + ) + total_budget = sum(authorized_budgets) + if current_round > total_budget or remaining_budget != total_budget - current_round: + raise ProtocolError( + "ledger current_round and remaining_budget must match the authorized budget history." + ) + canonical_roots: dict[str, dict[str, Any]] = {} + inventory_owners: dict[str, str] = {} + for index, raw_root in enumerate(_array(ledger.get("root_causes"), "ledger.root_causes")): + root = _object(raw_root, f"ledger.root_causes[{index}]") + root_id = _text(root.get("id"), f"ledger.root_causes[{index}].id") + if not ROOT_CAUSE_ID.fullmatch(root_id) or root_id in canonical_roots: + raise ProtocolError(f"Invalid or duplicate canonical root-cause ID: {root_id!r}.") + if root.get("status") not in {"open", "closed"}: + raise ProtocolError(f"Root cause {root_id} must be open or closed.") + root_inventory = set(_strings(root.get("inventory_ids"), f"root {root_id} inventory")) + root_evidence = set(_strings(root.get("contract_evidence_ids"), f"root {root_id} evidence")) + if not root_inventory: + raise ProtocolError(f"Root cause {root_id} must own at least one inventory ID.") + unknown_inventory = sorted(root_inventory - inventory_ids) + unknown_evidence = sorted(root_evidence - artifacts.keys()) + if unknown_inventory or unknown_evidence: + raise ProtocolError( + f"Root cause {root_id} has unknown inventory={unknown_inventory} " + f"or evidence={unknown_evidence}." + ) + for inventory_id in root_inventory: + existing_root = inventory_owners.get(inventory_id) + if existing_root is not None: + raise ProtocolError( + f"Canonical roots {existing_root} and {root_id} overlap on inventory " + f"{inventory_id}." + ) + inventory_owners[inventory_id] = root_id + canonical_roots[root_id] = { + "status": root["status"], + "inventory_ids": root_inventory, + "contract_evidence_ids": root_evidence, + } + if set(inventory_owners) != inventory_ids: + raise ProtocolError( + "Every inventory ID must have exactly one canonical root owner; " + f"unowned={sorted(inventory_ids - set(inventory_owners))}." + ) + + if current_round > 1 and (prior_ledger_path is None or prior_ledger_sha256 is None): + raise ProtocolError("Rounds after 1 require a digest-bound prior ledger snapshot.") + if prior_ledger_path is not None or prior_ledger_sha256 is not None: + if prior_ledger_path is None or prior_ledger_sha256 is None: + raise ProtocolError("Prior ledger path and SHA-256 must be supplied together.") + if prior_ledger_path.resolve() == expected_ledger_path: + raise ProtocolError("Prior ledger snapshot must be distinct from the current ledger.") + prior_path, prior_data = _read_bytes(str(prior_ledger_path), "prior ledger path") + if not SHA256.fullmatch(prior_ledger_sha256): + raise ProtocolError("Prior ledger SHA-256 must be a lowercase SHA-256 digest.") + if hashlib.sha256(prior_data).hexdigest() != prior_ledger_sha256: + raise ProtocolError(f"Prior ledger digest mismatch for {prior_path}.") + prior = _json_bytes(prior_data, str(prior_path)) + if prior.get("task_id") != expected_task_id: + raise ProtocolError("Prior ledger task_id must match the control-plane task ID.") + prior_internal_path = Path(_text(prior.get("path"), "prior ledger.path", concrete=True)) + if ( + not prior_internal_path.is_absolute() + or prior_internal_path.resolve() != expected_ledger_path + ): + raise ProtocolError("Prior ledger.path must match the control-plane ledger path.") + prior_budgets = [ + _integer(value, f"prior ledger.authorized_round_budgets[{index}]", minimum=1) + for index, value in enumerate( + _array( + prior.get("authorized_round_budgets"), + "prior ledger.authorized_round_budgets", + ) + ) + ] + prior_round = _integer(prior.get("current_round"), "prior ledger.current_round", minimum=1) + prior_remaining = _integer( + prior.get("remaining_budget"), "prior ledger.remaining_budget", minimum=0 + ) + if prior_remaining != sum(prior_budgets) - prior_round: + raise ProtocolError("Prior ledger round state does not match its budget history.") + if authorized_budgets[: len(prior_budgets)] != prior_budgets: + raise ProtocolError("ledger.authorized_round_budgets must preserve the prior prefix.") + if current_round not in {prior_round, prior_round + 1}: + raise ProtocolError( + "ledger.current_round must match the prior round or advance by exactly one." + ) + prior_roots: dict[str, dict[str, Any]] = {} + for index, raw_root in enumerate( + _array(prior.get("root_causes"), "prior ledger.root_causes") + ): + prior_root = _object(raw_root, f"prior ledger.root_causes[{index}]") + prior_id = _text(prior_root.get("id"), f"prior ledger.root_causes[{index}].id") + if not ROOT_CAUSE_ID.fullmatch(prior_id) or prior_id in prior_roots: + raise ProtocolError(f"Invalid prior canonical root-cause ID: {prior_id!r}.") + if prior_root.get("status") not in {"open", "closed"}: + raise ProtocolError(f"Prior root cause {prior_id} must be open or closed.") + prior_roots[prior_id] = { + "status": prior_root["status"], + "inventory_ids": set( + _strings(prior_root.get("inventory_ids"), f"prior root {prior_id} inventory") + ), + "contract_evidence_ids": set( + _strings( + prior_root.get("contract_evidence_ids"), + f"prior root {prior_id} evidence", + ) + ), + } + for prior_id, prior_root in prior_roots.items(): + current_root = canonical_roots.get(prior_id) + if current_root is None: + raise ProtocolError(f"ledger removed prior canonical root {prior_id}.") + new_inventory = current_root["inventory_ids"] - prior_root["inventory_ids"] + new_evidence = ( + current_root["contract_evidence_ids"] - prior_root["contract_evidence_ids"] + ) + if not prior_root["inventory_ids"].issubset( + current_root["inventory_ids"] + ) or not prior_root["contract_evidence_ids"].issubset( + current_root["contract_evidence_ids"] + ): + raise ProtocolError(f"ledger regressed ownership for prior root {prior_id}.") + if ( + prior_root["status"] == "closed" + and current_root["status"] == "open" + and not (new_inventory or new_evidence) + ): + raise ProtocolError(f"ledger reopened prior root {prior_id} without new evidence.") + + selected_dimensions = set( + _strings(packet.get("selected_high_risk_dimensions"), "selected_high_risk_dimensions") + ) + assignments = _array(packet.get("reviewer_assignments"), "reviewer_assignments") + if len(assignments) != 2: + raise ProtocolError("reviewer_assignments must contain exactly two reviewers.") + reviewer_ids: set[str] = set() + assigned_inventory: set[str] = set() + assigned_dimensions: set[str] = set() + for index, raw_assignment in enumerate(assignments): + assignment = _object(raw_assignment, f"reviewer_assignments[{index}]") + reviewer_id = _text(assignment.get("reviewer_id"), f"reviewer_assignments[{index}].id") + if reviewer_id in reviewer_ids: + raise ProtocolError(f"Duplicate reviewer ID: {reviewer_id}.") + reviewer_ids.add(reviewer_id) + reviewer_inventory = set( + _strings(assignment.get("inventory_ids"), f"reviewer {reviewer_id} inventory") + ) + primary_dimensions = _strings( + assignment.get("primary_dimensions"), f"reviewer {reviewer_id} primary dimensions" + ) + if not reviewer_inventory or not primary_dimensions: + raise ProtocolError( + f"Reviewer {reviewer_id} requires inventory and a primary specialty." + ) + assigned_inventory.update(reviewer_inventory) + reviewer_dimensions = set( + _strings(assignment.get("high_risk_dimensions"), f"reviewer {reviewer_id} dimensions") + ) + assigned_dimensions.update(reviewer_dimensions) + reviewer_components = set( + _strings(assignment.get("expected_components"), f"reviewer {reviewer_id} components") + ) + reviewer_evidence = set( + _strings(assignment.get("evidence_ids"), f"reviewer {reviewer_id} evidence") + ) + required_control_evidence = { + artifact_id + for artifact_id, artifact in artifacts.items() + if artifact["role"] in {"complete-diff", "review-state", "repository-status"} + } + if ( + reviewer_components != set(components) + or not required_control_evidence.issubset(reviewer_evidence) + or not reviewer_evidence.issubset(artifacts) + ): + raise ProtocolError( + f"Reviewer {reviewer_id} must receive every component and control artifact " + "without unknown evidence." + ) + if assigned_inventory != inventory_ids or assigned_dimensions != selected_dimensions: + raise ProtocolError("Reviewer assignments must cover the exact inventory and dimensions.") + + verification = _object(packet.get("verification"), "verification") + preflight_commands: set[str] = set() + for index, result in enumerate( + _array(verification.get("preflight_results"), "verification.preflight_results") + ): + _command_result(result, f"verification.preflight_results[{index}]") + preflight_commands.add(result["command"]) + receipt_paths: set[Path] = set() + for index, raw_receipt in enumerate( + _array(verification.get("credited_receipts"), "verification.credited_receipts") + ): + receipt_path, receipt_data, _ = _descriptor( + raw_receipt, f"verification.credited_receipts[{index}]" + ) + if receipt_path in receipt_paths: + raise ProtocolError(f"Duplicate credited receipt path: {receipt_path}.") + receipt_paths.add(receipt_path) + validate_receipt_data( + _json_bytes(receipt_data, str(receipt_path)), + combined, + components, + state["repository_fingerprint"], + preflight_commands, + ) + + _strings(packet.get("architecture_references"), "architecture_references") + return { + "packet_path": str(path.resolve()), + "packet_size_bytes": packet_size, + "packet_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "review_state_path": str(artifacts[_at(packet, "review_state.evidence_id")]["path"]), + "combined_fingerprint": combined, + "components": components, + "inventory_ids": sorted(inventory_ids), + "reviewer_ids": sorted(reviewer_ids), + "credited_receipt_paths": sorted( + str(receipt_path.resolve()) for receipt_path in receipt_paths + ), + } + + +def validate_reviewer_output( + packet_path: Path, + reviewer_id: str, + output_path: Path, + expected_task_id: str, + expected_ledger_path: Path, + prior_ledger_path: Path | None = None, + prior_ledger_sha256: str | None = None, +) -> dict[str, Any]: + summary = validate_packet( + packet_path, + expected_task_id, + expected_ledger_path, + prior_ledger_path, + prior_ledger_sha256, + ) + packet = _load_json(packet_path) + output = _load_json(output_path) + missing = sorted(REVIEWER_OUTPUT_FIELDS - output.keys()) + if missing: + raise ProtocolError(f"Reviewer output is missing fields: {missing}.") + if output["verdict"] not in { + "clean", + "findings require fixes", + "complexity reset required", + "incomplete packet", + }: + raise ProtocolError(f"Invalid reviewer verdict: {output['verdict']!r}.") + expected_fingerprints = { + "combined": summary["combined_fingerprint"], + "components": summary["components"], + } + if output["reviewed_fingerprints"] != expected_fingerprints: + raise ProtocolError("Reviewer fingerprints do not match the packet exactly.") + assignment = next( + (item for item in packet["reviewer_assignments"] if item.get("reviewer_id") == reviewer_id), + None, + ) + if assignment is None: + raise ProtocolError(f"Unknown reviewer ID: {reviewer_id}.") + + checked = set(_strings(output["checked_inventory_ids"], "checked_inventory_ids")) + unchecked: set[str] = set() + for index, raw_item in enumerate( + _array(output["unchecked_inventory_ids"], "unchecked_inventory_ids") + ): + item = _object(raw_item, f"unchecked_inventory_ids[{index}]") + unchecked_id = _text(item.get("id"), f"unchecked_inventory_ids[{index}].id") + if unchecked_id in unchecked: + raise ProtocolError(f"Duplicate unchecked inventory ID: {unchecked_id}.") + unchecked.add(unchecked_id) + _text(item.get("reason"), f"unchecked_inventory_ids[{index}].reason", concrete=True) + if checked & unchecked or checked | unchecked != set(assignment["inventory_ids"]): + raise ProtocolError("Reviewer inventory accounting differs from the assignment.") + if set(_strings(output["high_risk_dimensions_checked"], "high_risk_dimensions_checked")) != set( + assignment["high_risk_dimensions"] + ): + raise ProtocolError("Reviewer high-risk dimension accounting differs from the assignment.") + for index, probe in enumerate(_array(output["focused_probes"], "focused_probes")): + _command_result(probe, f"focused_probes[{index}]") + + canonical_roots = {root["id"]: root for root in packet["ledger"]["root_causes"]} + prior_canonical_roots: dict[str, dict[str, Any]] = {} + if prior_ledger_path is not None: + prior_ledger = _load_json(prior_ledger_path) + prior_canonical_roots = {root["id"]: root for root in prior_ledger["root_causes"]} + indexed_evidence = {artifact["id"] for artifact in packet["evidence_artifacts"]} + indexed_inventory = {row["id"] for row in packet["inventory"]} + owned_evidence = { + evidence_id + for root in canonical_roots.values() + for evidence_id in root["contract_evidence_ids"] + } + owned_inventory = { + inventory_id for root in canonical_roots.values() for inventory_id in root["inventory_ids"] + } + inventory_owners = { + inventory_id: root_id + for root_id, root in canonical_roots.items() + for inventory_id in root["inventory_ids"] + } + findings = _array(output["findings"], "findings") + proposed_roots: set[str] = set() + for index, raw_finding in enumerate(findings): + finding = _object(raw_finding, f"findings[{index}]") + missing_finding = sorted(FINDING_FIELDS - finding.keys()) + if missing_finding: + raise ProtocolError(f"Finding {index} is missing fields: {missing_finding}.") + if finding["priority"] not in {"P0", "P1", "P2", "P3"}: + raise ProtocolError(f"Finding {index} has an invalid priority.") + for field in FINDING_FIELDS - {"priority", "root_cause_id", "root_cause_evidence"}: + _text(finding[field], f"findings[{index}].{field}") + root_id = _text(finding["root_cause_id"], f"findings[{index}].root_cause_id") + evidence = _object(finding["root_cause_evidence"], f"findings[{index}].root_cause_evidence") + new_evidence = set( + _strings(evidence.get("new_contract_evidence_ids"), f"finding {index} evidence") + ) + new_inventory = set( + _strings(evidence.get("new_inventory_ids"), f"finding {index} inventory") + ) + if not new_evidence.issubset(indexed_evidence) or not new_inventory.issubset( + indexed_inventory + ): + raise ProtocolError(f"Finding {index} references unindexed root evidence.") + root = canonical_roots.get(root_id) + if root is not None: + foreign_inventory = sorted( + inventory_id + for inventory_id in new_inventory + if inventory_owners.get(inventory_id) != root_id + ) + if foreign_inventory: + raise ProtocolError( + f"Finding {index} reassigns inventory owned by another canonical root: " + f"{foreign_inventory}." + ) + prior_root = prior_canonical_roots.get(root_id) + prior_evidence = set(prior_root["contract_evidence_ids"]) if prior_root else set() + prior_inventory = set(prior_root["inventory_ids"]) if prior_root else set() + added_evidence = set(root["contract_evidence_ids"]) - prior_evidence + added_inventory = set(root["inventory_ids"]) - prior_inventory + if not new_evidence.issubset(added_evidence) or not new_inventory.issubset( + added_inventory + ): + raise ProtocolError( + f"Finding {index} root evidence must be new in the current ledger round." + ) + new_for_root = bool(new_evidence or new_inventory) + if root["status"] == "closed" and not new_for_root: + raise ProtocolError( + f"Finding {index} reopens closed root {root_id} without new evidence." + ) + elif not NEW_ROOT_CAUSE_ID.fullmatch(root_id): + raise ProtocolError( + f"Finding {index} must reuse a canonical root ID or propose NEW:." + ) + elif not (new_evidence - owned_evidence or new_inventory - owned_inventory): + raise ProtocolError( + f"Finding {index} proposes {root_id} without globally unowned evidence." + ) + else: + proposed_roots.add(root_id) + + uncertainty = _strings(output["remaining_uncertainty"], "remaining_uncertainty") + if ( + output["verdict"] in {"findings require fixes", "complexity reset required"} + and not findings + ): + raise ProtocolError(f"Verdict {output['verdict']!r} requires at least one finding.") + if output["verdict"] == "clean" and (unchecked or uncertainty or findings): + raise ProtocolError("A clean verdict requires no unchecked IDs, uncertainty, or findings.") + inspection_count = _integer(output["inspection_call_count"], "inspection_call_count", minimum=0) + reason = _text(output["inspection_budget_reason"], "inspection_budget_reason") + if inspection_count > 12 and reason.strip().lower() in SENTINELS: + raise ProtocolError("Inspection counts above 12 require an inspection_budget_reason.") + for index, raw_scan in enumerate( + _array(output["sibling_scenario_scan"], "sibling_scenario_scan") + ): + scan = _object(raw_scan, f"sibling_scenario_scan[{index}]") + root_id = _text(scan.get("root_cause_id"), f"sibling_scenario_scan[{index}].root_cause_id") + if root_id not in canonical_roots and root_id not in proposed_roots: + raise ProtocolError( + f"sibling_scenario_scan[{index}] must reference a canonical or proposed root." + ) + if not set( + _strings(scan.get("inventory_ids"), f"sibling_scenario_scan[{index}].inventory_ids") + ).issubset(indexed_inventory): + raise ProtocolError(f"sibling_scenario_scan[{index}] references unknown inventory IDs.") + _text(scan.get("result"), f"sibling_scenario_scan[{index}].result", concrete=True) + return { + "reviewer_id": reviewer_id, + "verdict": output["verdict"], + "combined_fingerprint": summary["combined_fingerprint"], + "finding_count": len(findings), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + packet_parser = commands.add_parser("packet") + packet_parser.add_argument("--packet", type=Path, required=True) + output_parser = commands.add_parser("reviewer-output") + output_parser.add_argument("--packet", type=Path, required=True) + output_parser.add_argument("--reviewer", required=True) + output_parser.add_argument("--output", type=Path, required=True) + receipt_parser = commands.add_parser("receipt") + receipt_parser.add_argument("--packet", type=Path, required=True) + receipt_parser.add_argument("--receipt", type=Path, required=True) + for command_parser in (packet_parser, output_parser, receipt_parser): + command_parser.add_argument("--task-id", required=True) + command_parser.add_argument("--ledger", type=Path, required=True) + command_parser.add_argument("--prior-ledger", type=Path) + command_parser.add_argument("--prior-ledger-sha256") + args = parser.parse_args() + try: + if args.command == "packet": + result = validate_packet( + args.packet, + args.task_id, + args.ledger, + args.prior_ledger, + args.prior_ledger_sha256, + ) + elif args.command == "reviewer-output": + result = validate_reviewer_output( + args.packet, + args.reviewer, + args.output, + args.task_id, + args.ledger, + args.prior_ledger, + args.prior_ledger_sha256, + ) + else: + summary = validate_packet( + args.packet, + args.task_id, + args.ledger, + args.prior_ledger, + args.prior_ledger_sha256, + ) + receipt_path = str(args.receipt.resolve()) + if receipt_path not in summary["credited_receipt_paths"]: + raise ProtocolError("The receipt path is not indexed by the validated packet.") + result = { + "receipt_path": receipt_path, + "combined_fingerprint": summary["combined_fingerprint"], + "reusable": True, + } + except ProtocolError as error: + parser.error(str(error)) + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/implementation-final-review/scripts/review_state.py b/.agents/skills/implementation-final-review/scripts/review_state.py index f9a0b305e3..e54aeafa89 100644 --- a/.agents/skills/implementation-final-review/scripts/review_state.py +++ b/.agents/skills/implementation-final-review/scripts/review_state.py @@ -114,6 +114,31 @@ def _content_fingerprint(base: str, workspace: list[dict[str, object]]) -> str: return _digest(canonical.encode()) +def _repository_fingerprint( + *, + content_fingerprint: str, + head: str, + status_sha256: str, + tracked_diff_sha256: str, + unfiltered_status_sha256: str, + unfiltered_content_fingerprint: str, +) -> str: + canonical = json.dumps( + { + "content_fingerprint": content_fingerprint, + "head": head, + "status_sha256": status_sha256, + "tracked_diff_sha256": tracked_diff_sha256, + "unfiltered_status_sha256": unfiltered_status_sha256, + "unfiltered_content_fingerprint": unfiltered_content_fingerprint, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return _digest(canonical.encode()) + + def review_state( repo: Path, base: str, @@ -155,6 +180,14 @@ def review_state( *pathspecs, ) workspace = _workspace_entries(repo, resolved_base, pathspecs) + unfiltered_status = _git( + repo, + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + ) + unfiltered_workspace = _workspace_entries(repo, resolved_base, ()) content_fingerprint = _content_fingerprint(resolved_base, workspace) component_states: dict[str, dict[str, object]] = {} @@ -192,10 +225,11 @@ def review_state( "status_sha256": _digest(status), "tracked_diff_sha256": _digest(tracked_diff), } - repository_canonical = json.dumps( - repository_state, ensure_ascii=False, sort_keys=True, separators=(",", ":") + repository_fingerprint = _repository_fingerprint( + **repository_state, + unfiltered_status_sha256=_digest(unfiltered_status), + unfiltered_content_fingerprint=_content_fingerprint(resolved_base, unfiltered_workspace), ) - repository_fingerprint = _digest(repository_canonical.encode()) return { "fingerprint": content_fingerprint, "content_fingerprint": content_fingerprint, @@ -204,6 +238,10 @@ def review_state( "pathspecs": list(pathspecs), "workspace": workspace, "components": component_states, + "unfiltered": { + "status_sha256": _digest(unfiltered_status), + "workspace": unfiltered_workspace, + }, **repository_state, } diff --git a/.agents/skills/implementation-final-review/scripts/test_review_protocol.py b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py new file mode 100644 index 0000000000..09d0f51473 --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py @@ -0,0 +1,1089 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import hashlib +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from review_protocol import ( + ProtocolError, + _workspace_entries, + validate_packet, + validate_receipt_data, + validate_reviewer_output, +) +from review_state import _content_fingerprint, _repository_fingerprint + + +class ReviewProtocolTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.evidence = self.root / "diff.patch" + self.evidence.write_text("diff evidence\n") + self.root_evidence = self.root / "root-evidence.txt" + self.root_evidence.write_text("root evidence\n") + self.new_evidence = self.root / "new-evidence.txt" + self.new_evidence.write_text("new evidence\n") + self.task_manifest = self.root / "task.paths" + self.task_manifest.write_text("src/example.py\n") + self.component_manifest = self.root / "api-contract.paths" + self.component_manifest.write_text("src/example.py\n") + self.status_path = self.root / "repository-status.bin" + self.status_path.write_bytes(b" M src/example.py\0") + self.ledger_path = self.root / "ledger.json" + self.packet_path = self.root / "packet.json" + self.receipt_path = self.root / "receipt.json" + self.output_path = self.root / "reviewer-output.json" + base = "1" * 40 + head = "2" * 40 + workspace = [ + { + "path": "src/example.py", + "kind": "file", + "executable": False, + "sha256": "d" * 64, + } + ] + self.combined = _content_fingerprint(base, workspace) + self.component = _content_fingerprint(base, workspace) + tracked_diff_sha256 = hashlib.sha256(self.evidence.read_bytes()).hexdigest() + status_sha256 = hashlib.sha256(self.status_path.read_bytes()).hexdigest() + self.repository = _repository_fingerprint( + content_fingerprint=self.combined, + head=head, + status_sha256=status_sha256, + tracked_diff_sha256=tracked_diff_sha256, + unfiltered_status_sha256=status_sha256, + unfiltered_content_fingerprint=_content_fingerprint(base, workspace), + ) + self.review_state_path = self.root / "review-state.json" + self.review_state = { + "fingerprint": self.combined, + "base": base, + "head": head, + "content_fingerprint": self.combined, + "repository_fingerprint": self.repository, + "status_sha256": status_sha256, + "tracked_diff_sha256": tracked_diff_sha256, + "pathspecs": ["src/example.py"], + "components": { + "api-contract": { + "content_fingerprint": self.component, + "pathspecs": ["src/example.py"], + "workspace": workspace, + } + }, + "workspace": workspace, + "unfiltered": { + "status_sha256": status_sha256, + "workspace": workspace, + }, + } + self._write_json(self.review_state_path, self.review_state) + self.packet = self._packet() + self._write_packet(self.packet_path, self.packet) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def _write_json(self, path: Path, value: object) -> None: + path.write_text(json.dumps(value, indent=2, sort_keys=True)) + + def _write_packet(self, path: Path, packet: dict[str, object]) -> None: + self._write_json(Path(packet["ledger"]["path"]), packet["ledger"]) + self._write_json(path, packet) + + def _write_review_state( + self, state: dict[str, object], packet: dict[str, object] | None = None + ) -> None: + packet = packet or copy.deepcopy(self.packet) + self._write_json(self.review_state_path, state) + state_artifact = next( + artifact for artifact in packet["evidence_artifacts"] if artifact["id"] == "E-STATE" + ) + state_artifact["sha256"] = hashlib.sha256(self.review_state_path.read_bytes()).hexdigest() + self._write_packet(self.packet_path, packet) + + def _validate_packet(self, path: Path | None = None) -> dict[str, object]: + return validate_packet(path or self.packet_path, "task-123", self.ledger_path) + + def _validate_output(self, reviewer_id: str) -> dict[str, object]: + return validate_reviewer_output( + self.packet_path, + reviewer_id, + self.output_path, + "task-123", + self.ledger_path, + ) + + def _packet(self) -> dict[str, object]: + return { + "schema_version": 1, + "packet_overage_reason": "none", + "task": { + "id": "task-123", + "original_requirement": "Preserve behavior and improve review convergence.", + "risk_tier": "normal", + "risk_reason": "Repository workflow only.", + }, + "scope_contract": { + "required_behavior": "Validate the review packet before dispatch.", + "compatibility_requirements": "Preserve the existing fingerprint format.", + "unsupported_cases": "Arbitrary Markdown parsing is unsupported.", + "supported_alternative": "Use the reviewer brief manually.", + }, + "repository": { + "target": "origin/main", + "merge_base": "1" * 40, + "head": "2" * 40, + "release_boundary": "v0.19.4", + "status_evidence_id": "E-STATUS", + "exclusions": [], + "complete_diff_command": "git diff base...HEAD -- src/example.py", + }, + "ledger": { + "path": str(self.ledger_path), + "task_id": "task-123", + "authorized_round_budgets": [6], + "current_round": 1, + "remaining_budget": 5, + "root_causes": [ + { + "id": "ROOT_EXISTING", + "status": "open", + "inventory_ids": ["INV-1"], + "contract_evidence_ids": ["E-DIFF"], + }, + { + "id": "ROOT_CLOSED", + "status": "closed", + "inventory_ids": ["INV-2"], + "contract_evidence_ids": ["E-ROOT"], + }, + ], + }, + "manifests": { + "task": str(self.task_manifest), + "components": {"api-contract": str(self.component_manifest)}, + "dependency_map": "api-contract has no task-owned dependents.", + }, + "review_state": { + "evidence_id": "E-STATE", + "revalidation_command": "uv run python review_state.py --base BASE", + }, + "verification": { + "preflight_results": [ + { + "command": "uv run python -m unittest discover -s scripts", + "result": "49 tests passed.", + } + ], + "eligible_concurrent_gates": "none", + "deferred_gates": "make format", + "credited_receipts": [], + }, + "architecture_references": [], + "evidence_artifacts": [ + { + "id": "E-DIFF", + "path": str(self.evidence), + "sha256": hashlib.sha256(self.evidence.read_bytes()).hexdigest(), + "role": "complete-diff", + "purpose": "Complete raw diff.", + }, + { + "id": "E-ROOT", + "path": str(self.root_evidence), + "sha256": hashlib.sha256(self.root_evidence.read_bytes()).hexdigest(), + "role": "supporting", + "purpose": "Existing root-cause evidence.", + }, + { + "id": "E-STATE", + "path": str(self.review_state_path), + "sha256": hashlib.sha256(self.review_state_path.read_bytes()).hexdigest(), + "role": "review-state", + "purpose": "Authoritative review-state output.", + }, + { + "id": "E-STATUS", + "path": str(self.status_path), + "sha256": hashlib.sha256(self.status_path.read_bytes()).hexdigest(), + "role": "repository-status", + "purpose": "Unfiltered repository status.", + }, + { + "id": "E-NEW", + "path": str(self.new_evidence), + "sha256": hashlib.sha256(self.new_evidence.read_bytes()).hexdigest(), + "role": "supporting", + "purpose": "Unowned evidence for a genuinely new root.", + }, + ], + "inventory": [ + { + "id": "INV-1", + "kind": "contract", + "summary": "Public contract row.", + "surface": "review packet validation", + "producers": "implementer", + "consumers": "packet validator and reviewers", + "behavior": "invalid packets fail before dispatch", + "exports": "review_protocol.py CLI", + "adjacent": "reviewer-brief.md", + "tests": "test_review_protocol.py", + }, + { + "id": "INV-2", + "kind": "authority-data-flow", + "summary": "Authority flow row.", + "input_authority": "control-plane task ID and ledger path", + "validation": "exact task, path, digest, and budget checks", + "in_memory_state": "parsed packet and ledger", + "persisted_state": "task-global ledger JSON", + "retry_replay": "same external authority is supplied again", + "output": "validated packet summary", + "exception_exposure": "concise ProtocolError without packet contents", + "cleanup_revocation": "not applicable", + }, + ], + "selected_high_risk_dimensions": [], + "reviewer_assignments": [ + { + "reviewer_id": "requirements", + "primary_dimensions": ["requirement and scope"], + "inventory_ids": ["INV-1"], + "high_risk_dimensions": [], + "expected_components": ["api-contract"], + "evidence_ids": ["E-DIFF", "E-STATE", "E-STATUS"], + }, + { + "reviewer_id": "lifecycle", + "primary_dimensions": ["security and protocol"], + "inventory_ids": ["INV-2"], + "high_risk_dimensions": [], + "expected_components": ["api-contract"], + "evidence_ids": ["E-DIFF", "E-STATE", "E-STATUS"], + }, + ], + } + + def _receipt(self) -> dict[str, object]: + fingerprints = { + "combined": self.combined, + "components": {"api-contract": self.component}, + "repository": self.repository, + } + return { + "schema_version": 1, + "command": "uv run python -m unittest discover -s scripts", + "environment": "macOS, UV_DEFAULT_INDEX=https://pypi.org/simple", + "exit_status": 0, + "non_mutation_basis": "The command is documented as non-mutating.", + "before": fingerprints, + "after": copy.deepcopy(fingerprints), + } + + def _output(self) -> dict[str, object]: + return { + "verdict": "clean", + "reviewed_fingerprints": { + "combined": self.combined, + "components": {"api-contract": self.component}, + }, + "checked_inventory_ids": ["INV-1"], + "unchecked_inventory_ids": [], + "high_risk_dimensions_checked": [], + "focused_probes": [], + "remaining_uncertainty": [], + "findings": [], + "sibling_scenario_scan": [], + "inspection_call_count": 4, + "inspection_budget_reason": "none", + } + + def _finding(self, root_cause_id: str) -> dict[str, object]: + return { + "priority": "P2", + "title": "Finding title", + "location": "src/example.py:1", + "failure_scenario": "The supported scenario fails.", + "user_consequence": "The caller sees an error.", + "support_basis": "Original requirement.", + "baseline_patch_evidence": "The baseline succeeds.", + "smallest_safe_correction": "Reuse the existing path.", + "root_cause_id": root_cause_id, + "root_cause_evidence": { + "new_contract_evidence_ids": [], + "new_inventory_ids": [], + }, + } + + def test_valid_packet_reports_dispatch_digest_and_size(self) -> None: + summary = self._validate_packet() + + self.assertEqual(summary["combined_fingerprint"], self.combined) + self.assertEqual(summary["packet_size_bytes"], self.packet_path.stat().st_size) + self.assertEqual( + summary["packet_sha256"], hashlib.sha256(self.packet_path.read_bytes()).hexdigest() + ) + + def test_packet_fails_closed_on_missing_field_or_incomplete_assignment(self) -> None: + cases = [] + missing = copy.deepcopy(self.packet) + del missing["scope_contract"] + cases.append((missing, "Missing required packet field: scope_contract.required_behavior")) + incomplete = copy.deepcopy(self.packet) + incomplete["reviewer_assignments"][1]["inventory_ids"] = ["INV-1"] + cases.append((incomplete, "must cover the exact inventory and dimensions")) + + for index, (packet, expected) in enumerate(cases): + with self.subTest(expected=expected): + path = self.root / f"invalid-{index}.json" + self._write_packet(path, packet) + with self.assertRaisesRegex(ProtocolError, re_escape(expected)): + self._validate_packet(path) + + def test_packet_requires_indexed_ledger_evidence(self) -> None: + packet = copy.deepcopy(self.packet) + packet["ledger"]["root_causes"][0]["contract_evidence_ids"] = ["REQ-UNINDEXED"] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, r"evidence=\['REQ-UNINDEXED'\]"): + self._validate_packet() + + def test_packet_resolves_manifest_and_ledger_authority(self) -> None: + missing_manifest = copy.deepcopy(self.packet) + missing_manifest["manifests"]["task"] = str(self.root / "missing-task.paths") + self._write_packet(self.packet_path, missing_manifest) + with self.assertRaisesRegex(ProtocolError, "Cannot read manifests.task"): + self._validate_packet() + + self.task_manifest.write_text("src/other.py\n") + self._write_packet(self.packet_path, self.packet) + with self.assertRaisesRegex(ProtocolError, "must match review_state.pathspecs"): + self._validate_packet() + self.task_manifest.write_text("src/example.py\n") + + missing_ledger = copy.deepcopy(self.packet) + missing_ledger["ledger"]["path"] = str(self.root / "missing-ledger.json") + self._write_json(self.packet_path, missing_ledger) + with self.assertRaisesRegex(ProtocolError, "Cannot read ledger.path"): + validate_packet(self.packet_path, "task-123", self.root / "missing-ledger.json") + + divergent = copy.deepcopy(self.packet) + self._write_packet(self.packet_path, divergent) + ledger = copy.deepcopy(divergent["ledger"]) + ledger["remaining_budget"] = 99 + self._write_json(self.ledger_path, ledger) + with self.assertRaisesRegex(ProtocolError, "must match the packet ledger exactly"): + self._validate_packet() + + self.ledger_path.write_text("{not json") + with self.assertRaisesRegex(ProtocolError, "Cannot read JSON object"): + self._validate_packet() + + def test_ledger_task_identity_must_match_packet(self) -> None: + packet = copy.deepcopy(self.packet) + packet["ledger"]["task_id"] = "another-task" + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "ledger.task_id must match"): + self._validate_packet() + + def test_control_plane_rejects_coordinated_task_and_ledger_replacement(self) -> None: + packet = copy.deepcopy(self.packet) + packet["task"]["id"] = "replacement-task" + packet["ledger"]["task_id"] = "replacement-task" + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "control-plane task ID"): + self._validate_packet() + + replacement_ledger = self.root / "replacement-ledger.json" + packet = copy.deepcopy(self.packet) + packet["ledger"]["path"] = str(replacement_ledger) + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex(ProtocolError, "control-plane ledger path"): + self._validate_packet() + + def test_ledger_budget_history_is_authoritative(self) -> None: + packet = copy.deepcopy(self.packet) + packet["ledger"]["remaining_budget"] = 99 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "authorized budget history"): + self._validate_packet() + + packet = copy.deepcopy(self.packet) + packet["ledger"]["authorized_round_budgets"] = [True] + packet["ledger"]["remaining_budget"] = 0 + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex(ProtocolError, "must be a positive integer"): + self._validate_packet() + + def test_canonical_roots_cannot_alias_the_same_ownership(self) -> None: + packet = copy.deepcopy(self.packet) + alias = copy.deepcopy(packet["ledger"]["root_causes"][0]) + alias["id"] = "RENAMED_ROOT" + packet["ledger"]["root_causes"].append(alias) + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "overlap on inventory INV-1"): + self._validate_packet() + + packet = copy.deepcopy(self.packet) + alias = copy.deepcopy(packet["ledger"]["root_causes"][0]) + alias["id"] = "RENAMED_ROOT" + alias["contract_evidence_ids"].append("E-NEW") + packet["ledger"]["root_causes"].append(alias) + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex(ProtocolError, "overlap on inventory INV-1"): + self._validate_packet() + + def test_prior_ledger_makes_history_append_only(self) -> None: + prior_path = self.root / "prior-ledger.json" + prior = copy.deepcopy(self.packet["ledger"]) + self._write_json(prior_path, prior) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + + current = copy.deepcopy(self.packet) + current["ledger"]["current_round"] = 2 + current["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, current) + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + skipped = copy.deepcopy(self.packet) + skipped["ledger"]["current_round"] = 3 + skipped["ledger"]["remaining_budget"] = 3 + self._write_packet(self.packet_path, skipped) + with self.assertRaisesRegex(ProtocolError, "advance by exactly one"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + reset = copy.deepcopy(current) + reset["ledger"]["authorized_round_budgets"] = [2] + reset["ledger"]["remaining_budget"] = 0 + self._write_packet(self.packet_path, reset) + with self.assertRaisesRegex(ProtocolError, "must preserve the prior prefix"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + removed_root = copy.deepcopy(current) + removed_root["ledger"]["root_causes"] = [ + { + "id": "REPLACEMENT_ROOT", + "status": "open", + "inventory_ids": ["INV-1", "INV-2"], + "contract_evidence_ids": ["E-DIFF"], + } + ] + self._write_packet(self.packet_path, removed_root) + with self.assertRaisesRegex(ProtocolError, "removed prior canonical root"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + def test_later_round_requires_digest_bound_prior_ledger(self) -> None: + packet = copy.deepcopy(self.packet) + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "digest-bound prior ledger"): + self._validate_packet() + + def test_current_ledger_cannot_authorize_its_own_history(self) -> None: + packet = copy.deepcopy(self.packet) + packet["ledger"]["authorized_round_budgets"] = [2] + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 0 + self._write_packet(self.packet_path, packet) + current_digest = hashlib.sha256(self.ledger_path.read_bytes()).hexdigest() + + with self.assertRaisesRegex(ProtocolError, "distinct from the current ledger"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + self.ledger_path, + current_digest, + ) + + def test_inventory_requires_kind_specific_evidence(self) -> None: + cases = ((0, "surface", "contract fields"), (1, "validation", "authority-data-flow")) + for index, field, expected in cases: + with self.subTest(field=field): + packet = copy.deepcopy(self.packet) + del packet["inventory"][index][field] + path = self.root / f"missing-inventory-{index}.json" + self._write_packet(path, packet) + with self.assertRaisesRegex(ProtocolError, expected): + self._validate_packet(path) + + def test_every_inventory_requires_one_canonical_root_owner(self) -> None: + packet = copy.deepcopy(self.packet) + orphan = copy.deepcopy(packet["inventory"][0]) + orphan["id"] = "INV-ORPHAN" + packet["inventory"].append(orphan) + packet["reviewer_assignments"][0]["inventory_ids"].append("INV-ORPHAN") + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, r"unowned=\['INV-ORPHAN'\]"): + self._validate_packet() + + def test_unfiltered_changed_paths_require_explicit_exclusions(self) -> None: + state = copy.deepcopy(self.review_state) + state["unfiltered"]["workspace"] = copy.deepcopy(state["unfiltered"]["workspace"]) + state["unfiltered"]["workspace"].insert( + 0, + { + "path": "notes/unrelated.txt", + "kind": "file", + "executable": False, + "sha256": "e" * 64, + }, + ) + state["repository_fingerprint"] = _repository_fingerprint( + content_fingerprint=state["content_fingerprint"], + head=state["head"], + status_sha256=state["status_sha256"], + tracked_diff_sha256=state["tracked_diff_sha256"], + unfiltered_status_sha256=state["unfiltered"]["status_sha256"], + unfiltered_content_fingerprint=_content_fingerprint( + state["base"], state["unfiltered"]["workspace"] + ), + ) + self._write_json(self.review_state_path, state) + packet = copy.deepcopy(self.packet) + state_artifact = next( + artifact for artifact in packet["evidence_artifacts"] if artifact["id"] == "E-STATE" + ) + state_artifact["sha256"] = hashlib.sha256(self.review_state_path.read_bytes()).hexdigest() + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "must exactly account"): + self._validate_packet() + + packet["repository"]["exclusions"] = [ + {"path": "notes/unrelated.txt", "reason": "Unrelated user-owned note."} + ] + self._write_packet(self.packet_path, packet) + self._validate_packet() + + def test_preflight_results_require_exact_command_result_records(self) -> None: + packet = copy.deepcopy(self.packet) + packet["verification"]["preflight_results"] = "Tests passed." + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex(ProtocolError, "preflight_results must be an array"): + self._validate_packet() + + packet["verification"]["preflight_results"] = [ + {"command": "uv run pytest ", "result": "passed"} + ] + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex(ProtocolError, "command contains a placeholder token"): + self._validate_packet() + + def test_every_reviewer_receives_all_components_and_complete_diff(self) -> None: + cases = [] + no_components = copy.deepcopy(self.packet) + no_components["reviewer_assignments"][0]["expected_components"] = [] + cases.append((no_components, "must receive every component")) + no_evidence = copy.deepcopy(self.packet) + no_evidence["reviewer_assignments"][0]["evidence_ids"] = [] + cases.append((no_evidence, "must receive every component")) + + for index, (packet, expected) in enumerate(cases): + with self.subTest(expected=expected): + path = self.root / f"incomplete-reviewer-{index}.json" + self._write_packet(path, packet) + with self.assertRaisesRegex(ProtocolError, re_escape(expected)): + self._validate_packet(path) + + def test_packet_and_ledger_reject_json_booleans_as_integers(self) -> None: + cases = [] + schema = copy.deepcopy(self.packet) + schema["schema_version"] = True + cases.append((schema, "schema_version must be integer 1")) + current_round = copy.deepcopy(self.packet) + current_round["ledger"]["current_round"] = True + cases.append((current_round, "current_round must be a positive integer")) + + for index, (packet, expected) in enumerate(cases): + with self.subTest(expected=expected): + path = self.root / f"boolean-integer-{index}.json" + self._write_packet(path, packet) + with self.assertRaisesRegex(ProtocolError, re_escape(expected)): + self._validate_packet(path) + + def test_packet_rejects_changed_evidence(self) -> None: + self.evidence.write_text("changed\n") + + with self.assertRaisesRegex(ProtocolError, "digest mismatch"): + self._validate_packet() + + def test_complete_diff_must_match_review_state(self) -> None: + partial_diff = self.root / "partial.diff" + partial_diff.write_text("partial diff\n") + packet = copy.deepcopy(self.packet) + packet["evidence_artifacts"][0]["path"] = str(partial_diff) + packet["evidence_artifacts"][0]["sha256"] = hashlib.sha256( + partial_diff.read_bytes() + ).hexdigest() + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "must match review_state.tracked_diff_sha256"): + self._validate_packet() + + def test_review_state_artifact_is_digest_bound(self) -> None: + packet = copy.deepcopy(self.packet) + state_artifact = next( + artifact for artifact in packet["evidence_artifacts"] if artifact["id"] == "E-STATE" + ) + state_artifact["sha256"] = "0" * 64 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "evidence artifact E-STATE digest mismatch"): + self._validate_packet() + + def test_review_state_requires_complete_typed_workspace_entries(self) -> None: + state = copy.deepcopy(self.review_state) + del state["workspace"][0]["executable"] + self._write_review_state(state) + + with self.assertRaisesRegex(ProtocolError, "file schema: missing"): + self._validate_packet() + + def test_review_state_rejects_unknown_fields_for_every_workspace_kind(self) -> None: + entries = ( + { + "path": "file", + "kind": "file", + "executable": False, + "sha256": "a" * 64, + }, + {"path": "link", "kind": "symlink", "sha256": "b" * 64}, + { + "path": "gitlink", + "kind": "gitlink", + "head": "c" * 40, + "status_sha256": "d" * 64, + }, + {"path": "directory", "kind": "directory"}, + {"path": "missing", "kind": "missing"}, + ) + for entry in entries: + with self.subTest(kind=entry["kind"]): + entry_with_unknown = {**entry, "authority": "unsupported"} + with self.assertRaisesRegex(ProtocolError, r"unexpected=\['authority'\]"): + _workspace_entries([entry_with_unknown], "review_state.workspace") + + def test_review_state_artifact_rejects_unknown_workspace_fields(self) -> None: + state = copy.deepcopy(self.review_state) + state["workspace"][0]["authority"] = "unsupported" + self._write_review_state(state) + + with self.assertRaisesRegex(ProtocolError, r"unexpected=\['authority'\]"): + self._validate_packet() + + def test_review_state_requires_component_workspace(self) -> None: + state = copy.deepcopy(self.review_state) + del state["components"]["api-contract"]["workspace"] + self._write_review_state(state) + + with self.assertRaisesRegex(ProtocolError, "workspace must be an array"): + self._validate_packet() + + def test_review_state_recomputes_content_and_repository_fingerprints(self) -> None: + content_state = copy.deepcopy(self.review_state) + content_state["workspace"][0]["sha256"] = "e" * 64 + self._write_review_state(content_state) + with self.assertRaisesRegex(ProtocolError, "does not match its workspace"): + self._validate_packet() + + repository_state = copy.deepcopy(self.review_state) + repository_state["status_sha256"] = "e" * 64 + self._write_review_state(repository_state) + with self.assertRaisesRegex(ProtocolError, "repository_fingerprint does not match"): + self._validate_packet() + + def test_review_state_descriptor_rejects_copied_authority(self) -> None: + packet = copy.deepcopy(self.packet) + packet["review_state"]["content_fingerprint"] = "0" * 64 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "must contain only evidence_id"): + self._validate_packet() + + def test_oversized_packet_requires_reason(self) -> None: + packet = copy.deepcopy(self.packet) + packet["task"]["original_requirement"] = "x" * (12 * 1024) + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "provide an overage reason"): + self._validate_packet() + + packet["packet_overage_reason"] = "The requirement is retained verbatim for review." + self._write_packet(self.packet_path, packet) + self._validate_packet() + + def test_exact_fingerprint_receipt_is_reusable(self) -> None: + receipt = self._receipt() + receipt["command"] = "make tests > /tmp/tests.log" + + validate_receipt_data( + receipt, self.combined, {"api-contract": self.component}, self.repository + ) + + receipt["after"]["combined"] = "d" * 64 + with self.assertRaisesRegex(ProtocolError, "after fingerprints do not match"): + validate_receipt_data( + receipt, self.combined, {"api-contract": self.component}, self.repository + ) + + def test_receipt_rejects_repository_fingerprint_drift(self) -> None: + receipt = self._receipt() + receipt["after"]["repository"] = "d" * 64 + + with self.assertRaisesRegex(ProtocolError, "after fingerprints do not match"): + validate_receipt_data( + receipt, self.combined, {"api-contract": self.component}, self.repository + ) + + def test_commands_allow_redirection_but_reject_placeholder_tokens(self) -> None: + packet = copy.deepcopy(self.packet) + packet["verification"]["preflight_results"] = [ + { + "command": "sort < /tmp/input.txt > /tmp/output.txt", + "result": "passed", + } + ] + self._write_packet(self.packet_path, packet) + self._validate_packet() + + output = self._output() + output["focused_probes"] = [ + {"command": "git diff > /tmp/review.diff", "result": "captured"} + ] + self._write_json(self.output_path, output) + self._validate_output("requirements") + + receipt = self._receipt() + receipt["command"] = "make tests " + with self.assertRaisesRegex(ProtocolError, "placeholder token"): + validate_receipt_data( + receipt, self.combined, {"api-contract": self.component}, self.repository + ) + + def test_receipt_rejects_boolean_exit_status(self) -> None: + receipt = self._receipt() + receipt["exit_status"] = False + + with self.assertRaisesRegex(ProtocolError, "exit_status 0"): + validate_receipt_data( + receipt, self.combined, {"api-contract": self.component}, self.repository + ) + + def test_packet_validates_every_credited_receipt(self) -> None: + self._write_json(self.receipt_path, self._receipt()) + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + { + "path": str(self.receipt_path), + "sha256": hashlib.sha256(self.receipt_path.read_bytes()).hexdigest(), + } + ] + self._write_packet(self.packet_path, packet) + + self._validate_packet() + + receipt = self._receipt() + receipt["exit_status"] = 1 + self._write_json(self.receipt_path, receipt) + packet["verification"]["credited_receipts"][0]["sha256"] = hashlib.sha256( + self.receipt_path.read_bytes() + ).hexdigest() + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex(ProtocolError, "exit_status 0"): + self._validate_packet() + + def test_packet_rejects_receipt_for_unrelated_successful_command(self) -> None: + receipt = self._receipt() + receipt["command"] = "true" + self._write_json(self.receipt_path, receipt) + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + { + "path": str(self.receipt_path), + "sha256": hashlib.sha256(self.receipt_path.read_bytes()).hexdigest(), + } + ] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "must exactly match a packet preflight command"): + self._validate_packet() + + def test_packet_binds_credited_receipt_digest(self) -> None: + receipt = self._receipt() + self._write_json(self.receipt_path, receipt) + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + { + "path": str(self.receipt_path), + "sha256": hashlib.sha256(self.receipt_path.read_bytes()).hexdigest(), + } + ] + self._write_packet(self.packet_path, packet) + self._validate_packet() + + receipt["command"] = "make lint" + self._write_json(self.receipt_path, receipt) + with self.assertRaisesRegex(ProtocolError, r"credited_receipts\[0\] digest mismatch"): + self._validate_packet() + + def test_receipt_cli_rejects_unindexed_replacement(self) -> None: + receipt = self._receipt() + self._write_json(self.receipt_path, receipt) + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + { + "path": str(self.receipt_path), + "sha256": hashlib.sha256(self.receipt_path.read_bytes()).hexdigest(), + } + ] + self._write_packet(self.packet_path, packet) + + unindexed = self.root / "unindexed-receipt.json" + receipt["command"] = "command-that-never-ran" + self._write_json(unindexed, receipt) + completed = subprocess.run( + ( + sys.executable, + str(Path(__file__).with_name("review_protocol.py")), + "receipt", + "--packet", + str(self.packet_path), + "--receipt", + str(unindexed), + "--task-id", + "task-123", + "--ledger", + str(self.ledger_path), + ), + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 2) + self.assertIn("receipt path is not indexed", completed.stderr) + + def test_clean_output_must_match_assignment_and_fingerprint(self) -> None: + output = self._output() + self._write_json(self.output_path, output) + + summary = self._validate_output("requirements") + self.assertEqual(summary["verdict"], "clean") + + output["checked_inventory_ids"] = [] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "inventory accounting differs"): + self._validate_output("requirements") + + def test_unknown_root_must_be_new_proposal_with_evidence(self) -> None: + output = self._output() + output["verdict"] = "findings require fixes" + output["findings"] = [self._finding("renamed-root")] + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "canonical root ID or propose NEW"): + self._validate_output("requirements") + + output["findings"][0]["root_cause_id"] = "NEW:new-boundary" + output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = ["INV-1"] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "without globally unowned evidence"): + self._validate_output("requirements") + + output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = [] + output["findings"][0]["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-NEW"] + self._write_json(self.output_path, output) + self._validate_output("requirements") + + def test_closed_root_requires_new_evidence(self) -> None: + output = self._output() + output["verdict"] = "findings require fixes" + output["checked_inventory_ids"] = ["INV-2"] + output["findings"] = [self._finding("ROOT_CLOSED")] + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "reopens closed root"): + self._validate_output("lifecycle") + + output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = ["INV-2"] + self._write_json(self.output_path, output) + self._validate_output("lifecycle") + + output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = ["INV-1"] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "owned by another canonical root"): + self._validate_output("lifecycle") + + output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = [] + output["findings"][0]["root_cause_evidence"]["new_contract_evidence_ids"] = [ + "DOES-NOT-EXIST" + ] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "unindexed root evidence"): + self._validate_output("lifecycle") + + output["findings"][0]["root_cause_evidence"]["new_contract_evidence_ids"] = [] + output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = ["INV-MISSING"] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "unindexed root evidence"): + self._validate_output("lifecycle") + + output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = [] + output["findings"][0]["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-DIFF"] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "must be new in the current ledger round"): + self._validate_output("lifecycle") + + output["findings"][0]["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-ROOT"] + self._write_json(self.output_path, output) + self._validate_output("lifecycle") + + def test_sibling_scan_requires_known_root_and_inventory(self) -> None: + output = self._output() + output["sibling_scenario_scan"] = [ + { + "root_cause_id": "RENAMED_ROOT", + "inventory_ids": ["INV-1"], + "result": "No sibling failure.", + } + ] + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "must reference a canonical or proposed root"): + self._validate_output("requirements") + + output["sibling_scenario_scan"][0]["root_cause_id"] = "ROOT_EXISTING" + output["sibling_scenario_scan"][0]["inventory_ids"] = ["INV-MISSING"] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "unknown inventory IDs"): + self._validate_output("requirements") + + def test_newly_promoted_root_uses_current_round_ownership(self) -> None: + prior_path = self.root / "prior-ledger.json" + prior = copy.deepcopy(self.packet["ledger"]) + prior["root_causes"] = [prior["root_causes"][0]] + self._write_json(prior_path, prior) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + + packet = copy.deepcopy(self.packet) + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, packet) + output = self._output() + output["verdict"] = "findings require fixes" + output["checked_inventory_ids"] = ["INV-2"] + finding = self._finding("ROOT_CLOSED") + finding["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-ROOT"] + output["findings"] = [finding] + self._write_json(self.output_path, output) + + validate_reviewer_output( + self.packet_path, + "lifecycle", + self.output_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + output["findings"][0]["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-NEW"] + self._write_json(self.output_path, output) + with self.assertRaisesRegex(ProtocolError, "must be new in the current ledger round"): + validate_reviewer_output( + self.packet_path, + "lifecycle", + self.output_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + def test_reviewer_output_rejects_boolean_inspection_count(self) -> None: + output = self._output() + output["inspection_call_count"] = False + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "nonnegative integer"): + self._validate_output("requirements") + + def test_cli_reports_protocol_errors_without_traceback(self) -> None: + invalid = copy.deepcopy(self.packet) + invalid["schema_version"] = 2 + self._write_packet(self.packet_path, invalid) + + completed = subprocess.run( + ( + sys.executable, + str(Path(__file__).with_name("review_protocol.py")), + "packet", + "--packet", + str(self.packet_path), + "--task-id", + "task-123", + "--ledger", + str(self.ledger_path), + ), + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 2) + self.assertIn("schema_version must be integer 1", completed.stderr) + self.assertNotIn("Traceback", completed.stderr) + + +def re_escape(value: str) -> str: + """Escape a literal string for assertRaisesRegex without importing re in each test.""" + import re + + return re.escape(value) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/implementation-final-review/scripts/test_review_state.py b/.agents/skills/implementation-final-review/scripts/test_review_state.py index 41b0d194ed..593dc8b67f 100644 --- a/.agents/skills/implementation-final-review/scripts/test_review_state.py +++ b/.agents/skills/implementation-final-review/scripts/test_review_state.py @@ -86,6 +86,36 @@ def test_component_fingerprints_invalidate_only_changed_content(self) -> None: ) self.assertNotEqual(before["content_fingerprint"], after["content_fingerprint"]) + def test_unfiltered_workspace_accounts_for_changes_outside_manifest(self) -> None: + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n") + + state = review_state(self.repo, self.base, ("src",)) + + self.assertEqual([entry["path"] for entry in state["workspace"]], ["src/runtime.py"]) + self.assertEqual( + [entry["path"] for entry in state["unfiltered"]["workspace"]], + ["src/runtime.py", "tests/test_runtime.py"], + ) + self.assertRegex(state["unfiltered"]["status_sha256"], r"^[0-9a-f]{64}$") + + def test_repository_fingerprint_includes_outside_manifest_state_and_content(self) -> None: + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + before = review_state(self.repo, self.base, ("src",)) + + outside = self.repo / "outside.txt" + outside.write_text("first\n") + after_add = review_state(self.repo, self.base, ("src",)) + outside.write_text("second\n") + after_content = review_state(self.repo, self.base, ("src",)) + + self.assertEqual(before["content_fingerprint"], after_add["content_fingerprint"]) + self.assertEqual(after_add["content_fingerprint"], after_content["content_fingerprint"]) + self.assertNotEqual(before["repository_fingerprint"], after_add["repository_fingerprint"]) + self.assertNotEqual( + after_add["repository_fingerprint"], after_content["repository_fingerprint"] + ) + def test_pathspec_file_preserves_literal_values_and_deduplicates(self) -> None: manifest = self.repo / "paths.txt" manifest.write_text("src\n\n#literal\n lead.py\nsrc\n") diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index 0df6665b6f..09f1c97f61 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -14,6 +14,7 @@ def setUpClass(cls) -> None: cls.skill = (cls.skill_root / "SKILL.md").read_text() cls.agent_config = (cls.skill_root / "agents" / "openai.yaml").read_text() cls.reviewer_brief = (cls.skill_root / "references" / "reviewer-brief.md").read_text() + cls.review_protocol = (cls.skill_root / "scripts" / "review_protocol.py").read_text() cls.repo_instructions = (cls.skill_root.parents[2] / "AGENTS.md").read_text() def test_repo_local_metadata_matches_skill(self) -> None: @@ -107,9 +108,9 @@ def test_overlapped_final_gates_preserve_fingerprint_integrity(self) -> None: required_text = ( "establish that it does not edit, format, regenerate, stage, or create any " "task-owned deliverable", - "Record combined and component fingerprints immediately before each gate starts and " - "after it exits", - "both fingerprints match the reviewed fingerprint exactly", + "Record combined, component, and repository fingerprints immediately before each " + "gate starts and after it exits", + "all fingerprints match the reviewed repository state exactly", "cancel or stop the obsolete verification when practical", "Keep `$pr-draft-summary` deferred", "Invoke `$pr-draft-summary` last", @@ -191,10 +192,14 @@ def test_independent_review_uses_no_history_and_event_driven_waits(self) -> None 'dispatch every reviewer with `fork_turns: "none"`', "never pass the implementer's accumulated conversation or use a full-history fork", "Launch both reviewers before waiting", - "one event-driven wait of 180-300 seconds", + "one event-driven wait of 240 seconds", "Do not poll with `list_agents`, separate short waits, progress questions, or no-op " "`followup_task` messages", - "after one reviewer completes, continue waiting only for the remaining reviewer", + "After one reviewer completes, continue waiting only for the remaining reviewer with " + "another event-driven 240-second wait", + "If an event-driven wait times out while reviewers remain unfinished", + "repeat without polling until a reviewer completes, needs attention, or no unfinished " + "reviewers remain", ) for text in required_skill_text: with self.subTest(text=text): @@ -224,8 +229,10 @@ def test_second_related_finding_closes_the_root_cause_group(self) -> None: "Treat a second related finding in one root-cause group as a closure gate", "run the complexity reset once", "scan the complete inventory for sibling scenarios", - "mark the root-cause ID closed", - "Do not reopen it for another local patch without new contract evidence", + "mark the canonical root-cause ID closed", + "Do not reopen it for another local patch without new contract evidence or a newly " + "uncovered inventory ID", + "reject aliases, renamed IDs, and bare unknown IDs", ) for text in required_text: with self.subTest(text=text): @@ -242,7 +249,7 @@ def test_snapshot_packet_and_structured_output_bound_repeated_work(self) -> None "approximately 12 source-inspection tool calls per reviewer as a soft budget", ) required_brief_text = ( - "Indexed evidence manifest (`ID | exact path | SHA-256 | purpose`)", + "Indexed evidence manifest (`ID | role | exact path | SHA-256 | purpose`)", "Semantic component dependency map and invalidation reasons", '"checked_inventory_ids"', '"unchecked_inventory_ids"', @@ -290,6 +297,102 @@ def test_intermediate_verification_is_cost_aware_but_final_gate_is_complete(self with self.subTest(text=text): self.assertIn(text, self.skill) + def test_machine_readable_protocol_closes_observed_convergence_gaps(self) -> None: + required_skill_text = ( + "python scripts/review_protocol.py packet --packet --task-id " + " --ledger ", + "packet path, byte size, SHA-256 digest", + "The implementer assigns every root-cause ID once", + "propose exactly `NEW:`", + "verification receipt containing the exact command, environment, exit status, " + "non-mutation basis", + "combined, component, and repository fingerprints", + "root evidence IDs absent from the packet's indexed evidence or inventory", + 'role: "complete-diff"', + 'role: "review-state"', + 'role: "repository-status"', + "review_state.evidence_id", + "repository.status_evidence_id", + "Assign every component and all three control artifacts to both reviewers", + "requires the complete-diff digest to match its `tracked_diff_sha256`", + "requires `repository.exclusions` to account exactly", + "summary-only inventory row is incomplete", + "active control plane outside the packet", + "authorized budget history", + "immediately preceding round's immutable ledger snapshot plus SHA-256 digest", + "same-round retry or an advance of exactly one round", + "never use the mutable current ledger as its own prior snapshot", + "repository fingerprint covers unfiltered status plus content identity", + "receipt command must exactly match a structured command", + "exact key set emitted for their `file`, `symlink`, `gitlink`, `directory`, or " + "`missing` kind", + "Trust the active implementation control plane to record actual reviewer dispatches", + "assigns every inventory ID to exactly one canonical root", + "sibling scans that use a renamed root or unknown inventory", + "JSON booleans in integer fields", + "add and digest it in the frozen packet", + "python scripts/review_protocol.py reviewer-output --packet --reviewer " + " --output --task-id --ledger ", + ) + required_brief_text = ( + "## Machine-readable preflight", + "If the packet exceeds 12 KiB", + "NEW:", + '"root_cause_evidence"', + "Every submitted contract evidence ID must name an indexed", + "submitted IDs must be additions owned by that root in the current ledger", + "Every contract evidence ID must resolve to an `evidence_artifacts[].id`", + "JSON booleans are not integers for protocol purposes", + "Each sibling-scenario scan must reuse a canonical root ID", + "verification.preflight_results` as an array of exact `command` and `result` objects", + "ledger file's JSON object to match the packet ledger exactly", + "not already owned by any canonical root", + "absolute `path` and `sha256` digest", + 'role: "review-state"', + 'role: "repository-status"', + "The `review_state` packet object contains exactly `evidence_id`", + "extra copied fingerprint or state fields are invalid", + "requires the complete-diff artifact digest to equal its `tracked_diff_sha256`", + "Supply the task ID and absolute task-global ledger path independently", + "requires `current_round` plus `remaining_budget` to equal the sum", + "immediately preceding round's immutable ledger snapshot and its SHA-256 digest", + "same-round retry or advance by exactly one", + "immutable snapshot must be a distinct file", + "an inventory ID owned by another root cannot be reassigned", + "does not provide cryptographic attestation against a malicious control plane", + "current budget history to preserve the prior prefix", + "each inventory ID has exactly one canonical root owner", + "accepts only a receipt path already indexed", + "task or repository-state drift", + "complete typed workspace entries", + "rejects an incomplete or unknown key for any workspace kind", + "unrelated successful commands are ineligible for credit", + 'Encode those columns in each `kind: "contract"` inventory object', + 'Encode those columns in each `kind: "authority-data-flow"` inventory object', + 'Encode those columns in each `kind: "await-boundary"` inventory object', + "requires exclusions to account exactly", + "verification.credited_receipts", + "python scripts/review_protocol.py receipt", + "python scripts/review_protocol.py reviewer-output", + ) + required_script_text = ( + "PACKET_SOFT_LIMIT_BYTES = 12 * 1024", + "NEW_ROOT_CAUSE_ID", + "validate_packet", + "validate_reviewer_output", + "validate_receipt_data", + ) + + for text in required_skill_text: + with self.subTest(source="skill", text=text): + self.assertIn(text, self.skill) + for text in required_brief_text: + with self.subTest(source="brief", text=text): + self.assertIn(text, self.reviewer_brief) + for text in required_script_text: + with self.subTest(source="script", text=text): + self.assertIn(text, self.review_protocol) + def test_final_reviewers_inherit_strategy_evidence(self) -> None: self.assertIn( "The implementer owns `$implementation-strategy` and supplies its current scope " From 192e6c7a3295bc553d469d54a20ae4422d1f5459 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 20:49:34 +0900 Subject: [PATCH 254/473] feat: add explicit mount credential exposure acknowledgements (#4321) --- .../extensions/sandbox/blaxel/mounts.py | 169 ++-- .../extensions/sandbox/daytona/mounts.py | 15 +- src/agents/extensions/sandbox/e2b/mounts.py | 4 + .../extensions/sandbox/runloop/mounts.py | 4 + .../extensions/sandbox/vercel/sandbox.py | 155 ++- src/agents/sandbox/_mount_security.py | 646 ++++++++++--- src/agents/sandbox/entries/mounts/base.py | 26 +- .../entries/mounts/providers/azure_blob.py | 5 +- src/agents/sandbox/manifest.py | 206 ++++ src/agents/sandbox/runtime_session_manager.py | 7 + src/agents/sandbox/session/sandbox_client.py | 5 - tests/extensions/sandbox/test_blaxel.py | 303 +++++- tests/extensions/sandbox/test_vercel.py | 114 ++- tests/sandbox/test_mount_security.py | 908 +++++++++++++++++- tests/sandbox/test_mounts.py | 24 + tests/sandbox/test_runtime.py | 119 ++- 16 files changed, 2431 insertions(+), 279 deletions(-) diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index a48dd2fa07..c74a883a5d 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -3,9 +3,9 @@ Two strategies are provided: -* **BlaxelCloudBucketMountStrategy** -- mounts credentialless S3, R2, and GCS - buckets via FUSE tools (``s3fs``, ``gcsfuse``) executed inside the sandbox. - Authenticated mounts require an external or provider-native mount strategy. +* **BlaxelCloudBucketMountStrategy** -- mounts S3, R2, and GCS buckets via FUSE tools + (``s3fs``, ``gcsfuse``) executed inside the sandbox. Credential-bearing mounts require + an exact-path runtime acknowledgement on the trusted manifest. * **BlaxelDriveMountStrategy** -- mounts Blaxel Drives (persistent network volumes) into the sandbox using the sandbox ``drives`` API @@ -16,17 +16,19 @@ from __future__ import annotations +import io import logging import shlex import uuid import warnings from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, Literal from .... import _debug from ....logger import log_tool_action_warning from ....sandbox._mount_security import ( + _mark_mount_error_data_safe, redact_mount_error_data, validate_mount_activation_credential_boundary, ) @@ -36,7 +38,7 @@ from ....sandbox.materialization import MaterializedFile from ....sandbox.session.base_sandbox_session import BaseSandboxSession from ....sandbox.types import FileMode, Permissions -from ....sandbox.workspace_paths import sandbox_path_str +from ....sandbox.workspace_paths import posix_path_as_path, sandbox_path_str logger = logging.getLogger(__name__) @@ -88,6 +90,8 @@ async def activate( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=lambda: mount._resolve_mount_path(session, dest), provider_backend_id="blaxel", ) _assert_blaxel_session(session) @@ -129,6 +133,8 @@ async def restore_after_snapshot( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, provider_backend_id="blaxel", ) _assert_blaxel_session(session) @@ -296,52 +302,79 @@ async def _ensure_tool(session: BaseSandboxSession, tool: str) -> None: await _install_tool(session, tool) -async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: - """Mount an S3 or R2 bucket using s3fs-fuse.""" - await _ensure_tool(session, "s3fs") +def _mount_credential_path(session: BaseSandboxSession, stem: str) -> Path: + root = PurePosixPath(session.state.manifest.root) + relative_path = Path(f".openai-agents-{stem}-{uuid.uuid4().hex[:8]}") + session.register_persist_workspace_skip_path(relative_path) + return posix_path_as_path(root / relative_path.as_posix()) - # Write credentials to a temp file. - cred_path = f"/tmp/s3fs-passwd-{uuid.uuid4().hex[:8]}" - if config.access_key_id and config.secret_access_key: - cred_content = f"{config.access_key_id}:{config.secret_access_key}" - if config.session_token: - cred_content += f":{config.session_token}" - await session.exec( - "sh", - "-c", - f"printf %s {shlex.quote(cred_content)} > {cred_path} && chmod 600 {cred_path}", + +async def _write_mount_credential_file( + session: BaseSandboxSession, + path: Path, + content: str, +) -> None: + await session.write(path, io.BytesIO(content.encode())) + result = await _exec(session, f"chmod 600 {shlex.quote(sandbox_path_str(path))}") + if result.exit_code != 0: + raise MountConfigError( + message="failed to restrict mount credential file permissions", + context={"exit_code": result.exit_code}, ) - else: - cred_path = "" - # Build the s3fs command. - bucket = config.bucket - if config.prefix: - bucket = f"{config.bucket}:/{config.prefix.strip('/')}" - mount_path = shlex.quote(config.mount_path) - opts = ["allow_other", "nonempty"] - if cred_path: - opts.append(f"passwd_file={cred_path}") - else: - opts.append("public_bucket=1") +async def _remove_mount_credential_file( + session: BaseSandboxSession, + path: Path, +) -> None: + result = await _exec(session, f"rm -f {shlex.quote(sandbox_path_str(path))}") + if result.exit_code != 0: + error = MountConfigError( + message="failed to remove mount credential file", + context={"exit_code": result.exit_code}, + ) + _mark_mount_error_data_safe(error) + raise error - if config.endpoint_url: - opts.append(f"url={config.endpoint_url}") - elif config.region: - opts.append(f"url=https://s3.{config.region}.amazonaws.com") - opts.append(f"endpoint={config.region}") - if config.provider == "r2": - opts.append("sigv4") +async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: + """Mount an S3 or R2 bucket using s3fs-fuse.""" + await _ensure_tool(session, "s3fs") + + cred_path: Path | None = None + try: + if config.access_key_id and config.secret_access_key: + cred_path = _mount_credential_path(session, "s3fs-passwd") + cred_content = f"{config.access_key_id}:{config.secret_access_key}" + if config.session_token: + cred_content += f":{config.session_token}" + await _write_mount_credential_file(session, cred_path, cred_content) + + bucket = config.bucket + if config.prefix: + bucket = f"{config.bucket}:/{config.prefix.strip('/')}" + mount_path = shlex.quote(config.mount_path) + + opts = ["allow_other", "nonempty"] + if cred_path is not None: + opts.append(f"passwd_file={sandbox_path_str(cred_path)}") + else: + opts.append("public_bucket=1") - if config.read_only: - opts.append("ro") + if config.endpoint_url: + opts.append(f"url={config.endpoint_url}") + elif config.region: + opts.append(f"url=https://s3.{config.region}.amazonaws.com") + opts.append(f"endpoint={config.region}") - opts_str = ",".join(opts) - cmd = f"s3fs {shlex.quote(bucket)} {mount_path} -o {shlex.quote(opts_str)}" + if config.provider == "r2": + opts.append("sigv4") - try: + if config.read_only: + opts.append("ro") + + opts_str = ",".join(opts) + cmd = f"s3fs {shlex.quote(bucket)} {mount_path} -o {shlex.quote(opts_str)}" await _exec(session, f"mkdir -p {mount_path}") result = await _exec(session, cmd, timeout=60) if result.exit_code != 0: @@ -351,45 +384,37 @@ async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountC context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr}, ) finally: - # Clean up credentials file. - if cred_path: - await _exec(session, f"rm -f {cred_path}") + if cred_path is not None: + await _remove_mount_credential_file(session, cred_path) async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: """Mount a GCS bucket using gcsfuse.""" await _ensure_tool(session, "gcsfuse") - mount_path = shlex.quote(config.mount_path) - bucket = shlex.quote(config.bucket) - - # Write service account key if provided. - key_path = "" - if config.service_account_key: - key_path = f"/tmp/gcs-creds-{uuid.uuid4().hex[:8]}.json" - await session.exec( - "sh", - "-c", - f"printf %s {shlex.quote(config.service_account_key)} " - f"> {key_path} && chmod 600 {key_path}", - ) + key_path: Path | None = None + try: + if config.service_account_key: + key_path = _mount_credential_path(session, "gcs-creds") + await _write_mount_credential_file(session, key_path, config.service_account_key) - opts: list[str] = [] - if key_path: - opts.append(f"--key-file={key_path}") - else: - opts.append("--anonymous-access") + mount_path = shlex.quote(config.mount_path) + bucket = shlex.quote(config.bucket) - if config.read_only: - opts.append("-o ro") + opts: list[str] = [] + if key_path is not None: + opts.append(shlex.quote(f"--key-file={sandbox_path_str(key_path)}")) + else: + opts.append("--anonymous-access") - if config.prefix: - opts.append(f"--only-dir={shlex.quote(config.prefix.strip('/'))}") + if config.read_only: + opts.append("-o ro") - opts_str = " ".join(opts) - cmd = f"gcsfuse {opts_str} {bucket} {mount_path}" + if config.prefix: + opts.append(f"--only-dir={shlex.quote(config.prefix.strip('/'))}") - try: + opts_str = " ".join(opts) + cmd = f"gcsfuse {opts_str} {bucket} {mount_path}" await _exec(session, f"mkdir -p {mount_path}") result = await _exec(session, cmd, timeout=60) if result.exit_code != 0: @@ -399,8 +424,8 @@ async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMount context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr}, ) finally: - if key_path: - await _exec(session, f"rm -f {key_path}") + if key_path is not None: + await _remove_mount_credential_file(session, key_path) async def _mount_bucket(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 93fc6952f0..262035e03e 100644 --- a/src/agents/extensions/sandbox/daytona/mounts.py +++ b/src/agents/extensions/sandbox/daytona/mounts.py @@ -5,7 +5,7 @@ the sandbox before delegating to :class:`RcloneMountPattern`. Supports credentialless S3, R2, GCS, and Azure Blob mounts through a single code path. -Authenticated mounts require an external or provider-native mount strategy. +Authenticated mounts require an exact-path runtime acknowledgement on the trusted manifest. """ from __future__ import annotations @@ -169,10 +169,11 @@ class DaytonaCloudBucketMountStrategy(MountStrategyBase): """Mount rclone-backed cloud storage in Daytona sandboxes. Wraps :class:`InContainerMountStrategy` with automatic ``rclone`` - provisioning. Use with rclone-backed provider mounts that support anonymous access - (``S3Mount``, ``R2Mount``, ``GCSMount``, ``AzureBlobMount``) and let the - generic framework handle anonymous config generation and mount execution. Explicit cloud - credentials are not supported because the delegated helper executes inside the sandbox. + provisioning. Use with rclone-backed provider mounts (``S3Mount``, ``R2Mount``, + ``GCSMount``, ``AzureBlobMount``) and let the generic framework handle config generation + and mount execution. Credential-bearing mounts require the applicable exact-path runtime + acknowledgement on the trusted manifest because the delegated helper executes inside the + sandbox. Usage:: @@ -206,6 +207,8 @@ async def activate( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=lambda: mount._resolve_mount_path(session, dest), provider_backend_id="daytona", ) _assert_daytona_session(session) @@ -243,6 +246,8 @@ async def restore_after_snapshot( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, provider_backend_id="daytona", ) _assert_daytona_session(session) diff --git a/src/agents/extensions/sandbox/e2b/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py index 018691d7ed..e08f0129a4 100644 --- a/src/agents/extensions/sandbox/e2b/mounts.py +++ b/src/agents/extensions/sandbox/e2b/mounts.py @@ -92,6 +92,8 @@ async def activate( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=lambda: mount._resolve_mount_path(session, dest), provider_backend_id="e2b", ) _assert_e2b_session(session) @@ -130,6 +132,8 @@ async def restore_after_snapshot( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, provider_backend_id="e2b", ) _assert_e2b_session(session) diff --git a/src/agents/extensions/sandbox/runloop/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py index f87e0b6f4f..2d4bd1a026 100644 --- a/src/agents/extensions/sandbox/runloop/mounts.py +++ b/src/agents/extensions/sandbox/runloop/mounts.py @@ -138,6 +138,8 @@ async def activate( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=lambda: mount._resolve_mount_path(session, dest), provider_backend_id="runloop", ) _assert_runloop_session(session) @@ -176,6 +178,8 @@ async def restore_after_snapshot( validate_mount_activation_credential_boundary( mount, self, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, provider_backend_id="runloop", ) _assert_runloop_session(session) diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 978b06fd93..9ba0a3be2e 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -35,6 +35,7 @@ _validate_mount_provenance, redact_mount_error_data, redact_mount_error_data_sync, + validate_manifest_mount_credential_boundaries, ) from ....sandbox.entries import BaseEntry, Dir, S3Mount, resolve_workspace_path from ....sandbox.errors import ( @@ -187,6 +188,41 @@ def _resolve_manifest_root(manifest: Manifest | None) -> Manifest: return manifest +def _with_released_vercel_s3_credential_exposure_compatibility( + manifest: Manifest, + allow_s3_credential_exposure: bool, +) -> Manifest: + """Translate the released boolean option into exact mount-scoped acknowledgement.""" + + if not allow_s3_credential_exposure: + return manifest + _validate_manifest_mount_provenance(manifest) + paths: set[str] = set() + for mount, mount_path in manifest.mount_targets(): + if not isinstance(mount, S3Mount) or mount.mount_strategy.type != "vercel_cloud_bucket": + continue + if any( + credential is not None + for credential in ( + mount.access_key_id, + mount.secret_access_key, + mount.session_token, + ) + ): + paths.add(mount_path.as_posix()) + if not paths: + return manifest + trusted = manifest + for path in sorted(paths): + try: + trusted = trusted.with_in_container_mount_credential_exposure_acknowledged(path) + except ValueError: + # Preserve the released option's error boundary: an invalid/root target is rejected + # by normal mount validation rather than turning acknowledgement into authorization. + continue + return trusted + + def _validate_network_policy(value: object) -> NetworkPolicy | None: if value is None: return None @@ -312,6 +348,24 @@ def _vercel_s3_mount_map(manifest: Manifest) -> dict[str, S3Mount]: return mounts +def _with_vercel_s3_mount_credentials( + manifest: Manifest, + trusted_s3_mounts: dict[str, S3Mount], +) -> Manifest: + """Overlay released session-constructor credentials onto matching trusted topology.""" + + trusted = manifest.model_copy(deep=True) + mounts_by_path = _vercel_s3_mount_map(trusted) + for path, supplied_mount in trusted_s3_mounts.items(): + mount = mounts_by_path.get(path) + if mount is None: + continue + mount.access_key_id = supplied_mount.access_key_id + mount.secret_access_key = supplied_mount.secret_access_key + mount.session_token = supplied_mount.session_token + return trusted + + def _vercel_s3_mount_topology(manifest: Manifest) -> dict[str, tuple[str, S3Mount]]: mounts_by_path = _vercel_s3_mount_map(manifest) paths_by_mount_id = {id(mount): path for path, mount in mounts_by_path.items()} @@ -497,10 +551,52 @@ def __init__( trusted_manifest: Manifest | None = None, ) -> None: _validate_manifest_mount_provenance(state.manifest) - if trusted_manifest is not None: - _validate_manifest_mount_provenance(trusted_manifest) for mount in (trusted_s3_mounts or {}).values(): _validate_mount_provenance(mount) + if trusted_manifest is not None: + _validate_manifest_mount_provenance(trusted_manifest) + if trusted_s3_mounts: + trusted_manifest = _with_vercel_s3_mount_credentials( + trusted_manifest or state.manifest, + trusted_s3_mounts, + ) + if trusted_manifest is not None: + credentialed_paths = tuple( + path + for path, mount in _vercel_s3_mount_map(trusted_manifest).items() + if any( + credential is not None + for credential in ( + mount.access_key_id, + mount.secret_access_key, + mount.session_token, + ) + ) + ) + if not allow_s3_credential_exposure and any( + not trusted_manifest._acknowledges_in_container_mount_credential_exposure( + path, + "mount_scoped", + ) + for path in credentialed_paths + ): + raise MountConfigError( + message=( + "Vercel S3 mounts expose inline credentials to code running in the " + "sandbox; set allow_s3_credential_exposure=True only for credentials " + "scoped to that sandbox, or acknowledge each exact mount path on the " + "trusted manifest" + ), + context={"backend": "vercel"}, + ) + trusted_manifest = _with_released_vercel_s3_credential_exposure_compatibility( + trusted_manifest, + allow_s3_credential_exposure, + ) + validate_manifest_mount_credential_boundaries( + trusted_manifest, + provider_backend_id="vercel", + ) resolved_trusted_s3_mounts: dict[str, S3Mount] = {} trusted_s3_mount_credentials: dict[ str, @@ -518,38 +614,30 @@ def __init__( trusted_mount.session_token = None resolved_trusted_s3_mounts[path] = trusted_mount trusted_s3_mount_credentials[path] = credentials - has_trusted_credentials = any( - credential is not None - for credentials in trusted_s3_mount_credentials.values() - for credential in credentials - ) - if has_trusted_credentials and not allow_s3_credential_exposure: - raise MountConfigError( - message=( - "Vercel S3 mounts expose inline credentials to code running in the sandbox; " - "set allow_s3_credential_exposure=True only for credentials scoped to that " - "sandbox" - ), - context={"backend": "vercel"}, - ) - if resolved_trusted_s3_mounts and trusted_manifest is None: - raise MountConfigError( - message=( - "Vercel S3 mounts require a trusted create-time manifest so persisted " - "session state cannot reconstruct their topology" - ), - context={"backend": "vercel"}, - ) resolved_trusted_manifest = ( _manifest_without_vercel_s3_credentials(trusted_manifest) if trusted_manifest is not None else state.manifest.model_copy(deep=True) ) - declared_topology = _vercel_s3_mount_topology(state.manifest) + state_has_inline_vercel_s3_credentials = any( + credential is not None + for mount in _vercel_s3_mounts(state.manifest) + for credential in ( + mount.access_key_id, + mount.secret_access_key, + mount.session_token, + ) + ) + resolved_state_manifest = ( + _manifest_without_vercel_s3_credentials(state.manifest) + if state_has_inline_vercel_s3_credentials + else state.manifest + ) + declared_topology = _vercel_s3_mount_topology(resolved_state_manifest) trusted_topology = _vercel_s3_mount_topology(resolved_trusted_manifest) trusted_manifest_mounts = _vercel_s3_mount_map(resolved_trusted_manifest) trusted_topology_matches = ( - state.manifest.root == resolved_trusted_manifest.root + resolved_state_manifest.root == resolved_trusted_manifest.root and declared_topology == trusted_topology and trusted_manifest_mounts == resolved_trusted_s3_mounts ) @@ -565,6 +653,7 @@ def __init__( "trusted_mount_paths": sorted(resolved_trusted_s3_mounts), }, ) + state.manifest = resolved_state_manifest self.state = state self._sandbox = sandbox self._token = token @@ -1469,16 +1558,12 @@ async def create( manifest: Manifest | None = None, options: VercelSandboxClientOptions, ) -> SandboxSession: - resolved_manifest = _resolve_manifest_root(manifest) + resolved_manifest = _with_released_vercel_s3_credential_exposure_compatibility( + _resolve_manifest_root(manifest), + options.allow_s3_credential_exposure, + ) try: - self._validate_manifest_for_create( - resolved_manifest, - allowed_in_container_credential_strategy_types=( - frozenset({"vercel_cloud_bucket"}) - if options.allow_s3_credential_exposure - else frozenset() - ), - ) + self._validate_manifest_for_create(resolved_manifest) trusted_s3_mounts = _vercel_s3_mount_map(resolved_manifest) for mount in trusted_s3_mounts.values(): mount.mount_strategy.validate_mount(mount) diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py index b08ebeff36..2794e102cb 100644 --- a/src/agents/sandbox/_mount_security.py +++ b/src/agents/sandbox/_mount_security.py @@ -8,8 +8,8 @@ import traceback from collections.abc import Callable, Collection, Coroutine, Iterable, Mapping from functools import wraps -from pathlib import PurePosixPath -from typing import Any, NoReturn, ParamSpec, TypeVar, cast +from pathlib import PurePath, PurePosixPath +from typing import TYPE_CHECKING, Any, NoReturn, ParamSpec, TypeVar, cast from urllib.parse import urlsplit from ..exceptions import ( @@ -41,7 +41,9 @@ S3FilesMountPattern, ) from .errors import MountConfigError -from .manifest import Manifest + +if TYPE_CHECKING: + from .manifest import Manifest REDACTED_MOUNT_AUTHORITY_KEY = "__openai_agents_redacted_mount_authority" CREDENTIALLESS_MOUNT_AUTHORITY_KEY = "__openai_agents_credentialless_mount_authority" @@ -52,7 +54,6 @@ _AUTHORITY_FIELDS_BY_MOUNT_TYPE: dict[str, tuple[str, ...]] = { "azure_blob_mount": ("identity_client_id", "account_key"), "box_mount": ( - "client_id", "client_secret", "access_token", "token", @@ -69,6 +70,25 @@ "r2_mount": ("access_key_id", "secret_access_key"), "s3_mount": ("access_key_id", "secret_access_key", "session_token"), } +# Each entry identifies the fields that activate an inline credential set and the non-empty +# fields required whenever that set is active. Keep this table aligned with provider selection +# and credential emission in the built-in mount implementations. +_IN_CONTAINER_CREDENTIAL_SET_REQUIREMENTS_BY_MOUNT_TYPE: dict[ + str, tuple[tuple[str, ...], tuple[str, ...]] +] = { + "gcs_mount": ( + ("access_id", "secret_access_key"), + ("access_id", "secret_access_key"), + ), + "r2_mount": ( + ("access_key_id", "secret_access_key"), + ("access_key_id", "secret_access_key"), + ), + "s3_mount": ( + ("access_key_id", "secret_access_key", "session_token"), + ("access_key_id", "secret_access_key"), + ), +} _AUTHORITY_FILE_FIELDS_BY_MOUNT_TYPE: dict[str, tuple[str, ...]] = { "box_mount": ("box_config_file",), "gcs_mount": ("service_account_file",), @@ -251,9 +271,136 @@ "mountpoint": MountpointMountPattern.MountpointOptions, "s3files": S3FilesMountPattern.S3FilesOptions, } -_TRUSTED_IN_CONTAINER_OPT_IN_FIELDS: dict[str, frozenset[str]] = { - "vercel_cloud_bucket": frozenset({"access_key_id", "secret_access_key", "session_token"}), -} + + +@dataclasses.dataclass(frozen=True) +class _InContainerMountCredentialCapability: + strategy_types: frozenset[str] + mount_type: str + pattern_type: str | None + mount_scoped_fields: frozenset[str] + broad_fields: frozenset[str] + enables_broad_credential_discovery: bool = False + required_any_fields: frozenset[str] = frozenset() + + +_RCLONE_STRATEGY_TYPES = frozenset( + { + "in_container", + "daytona_cloud_bucket", + "e2b_cloud_bucket", + "runloop_cloud_bucket", + } +) +_IN_CONTAINER_MOUNT_CREDENTIAL_CAPABILITIES: tuple[_InContainerMountCredentialCapability, ...] = ( + _InContainerMountCredentialCapability( + strategy_types=_RCLONE_STRATEGY_TYPES, + mount_type="s3_mount", + pattern_type="rclone", + mount_scoped_fields=frozenset({"access_key_id", "secret_access_key", "session_token"}), + broad_fields=frozenset({"mount_strategy.pattern.config_file_path"}), + ), + _InContainerMountCredentialCapability( + strategy_types=_RCLONE_STRATEGY_TYPES, + mount_type="r2_mount", + pattern_type="rclone", + mount_scoped_fields=frozenset({"access_key_id", "secret_access_key"}), + broad_fields=frozenset({"mount_strategy.pattern.config_file_path"}), + ), + _InContainerMountCredentialCapability( + strategy_types=_RCLONE_STRATEGY_TYPES, + mount_type="gcs_mount", + pattern_type="rclone", + mount_scoped_fields=frozenset( + { + "access_id", + "secret_access_key", + "service_account_credentials", + "access_token", + } + ), + broad_fields=frozenset({"service_account_file", "mount_strategy.pattern.config_file_path"}), + ), + _InContainerMountCredentialCapability( + strategy_types=_RCLONE_STRATEGY_TYPES, + mount_type="azure_blob_mount", + pattern_type="rclone", + mount_scoped_fields=frozenset({"account_key"}), + broad_fields=frozenset({"identity_client_id", "mount_strategy.pattern.config_file_path"}), + ), + _InContainerMountCredentialCapability( + strategy_types=_RCLONE_STRATEGY_TYPES, + mount_type="box_mount", + pattern_type="rclone", + mount_scoped_fields=frozenset( + {"client_secret", "access_token", "token", "config_credentials"} + ), + broad_fields=frozenset({"box_config_file", "mount_strategy.pattern.config_file_path"}), + required_any_fields=frozenset( + {"access_token", "token", "config_credentials", "box_config_file"} + ), + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"in_container"}), + mount_type="s3_mount", + pattern_type="mountpoint", + mount_scoped_fields=frozenset({"access_key_id", "secret_access_key", "session_token"}), + broad_fields=frozenset(), + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"in_container"}), + mount_type="gcs_mount", + pattern_type="mountpoint", + mount_scoped_fields=frozenset({"access_id", "secret_access_key"}), + broad_fields=frozenset(), + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"in_container"}), + mount_type="azure_blob_mount", + pattern_type="fuse", + mount_scoped_fields=frozenset({"account_key"}), + broad_fields=frozenset({"identity_client_id"}), + enables_broad_credential_discovery=True, + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"in_container"}), + mount_type="s3_files_mount", + pattern_type="s3files", + mount_scoped_fields=frozenset(), + broad_fields=frozenset({"extra_options", "mount_strategy.pattern.options.extra_options"}), + enables_broad_credential_discovery=True, + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"vercel_cloud_bucket"}), + mount_type="s3_mount", + pattern_type=None, + mount_scoped_fields=frozenset({"access_key_id", "secret_access_key", "session_token"}), + broad_fields=frozenset(), + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"blaxel_cloud_bucket"}), + mount_type="s3_mount", + pattern_type=None, + mount_scoped_fields=frozenset({"access_key_id", "secret_access_key", "session_token"}), + broad_fields=frozenset(), + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"blaxel_cloud_bucket"}), + mount_type="r2_mount", + pattern_type=None, + mount_scoped_fields=frozenset({"access_key_id", "secret_access_key"}), + broad_fields=frozenset(), + ), + _InContainerMountCredentialCapability( + strategy_types=frozenset({"blaxel_cloud_bucket"}), + mount_type="gcs_mount", + pattern_type=None, + mount_scoped_fields=frozenset( + {"access_id", "secret_access_key", "service_account_credentials"} + ), + broad_fields=frozenset(), + ), +) _RCLONE_SAFE_FLAG_ARGS = frozenset({"allow-other"}) _RCLONE_SAFE_VALUE_ARGS = frozenset({"buffer-size", "gid", "uid"}) _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR = "_agents_safe_mount_validation_message" @@ -306,8 +453,12 @@ async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: return wrapper -def redact_mount_error_data_sync(function: Callable[_P, _T]) -> Callable[_P, _T]: - """Replace marked validation failures after clearing payload-bearing sync frames.""" +def _redact_mount_error_data_sync( + function: Callable[_P, _T], + *, + preserve_value_error_type: bool, +) -> Callable[_P, _T]: + """Replace validation failures after clearing payload-bearing sync frames.""" @wraps(function) def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: @@ -324,6 +475,10 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: error.__cause__ = None error.__context__ = None safe_error = error + elif preserve_value_error_type and call_has_authority and isinstance(error, ValueError): + discard_mount_source_exception(error) + safe_error = ValueError("sandbox mount validation failed") + _mark_error_data_redacted(safe_error) elif call_has_authority: safe_error = _replace_mount_operation_error(error) else: @@ -336,6 +491,20 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: return wrapper +def redact_mount_error_data_sync(function: Callable[_P, _T]) -> Callable[_P, _T]: + """Replace marked validation failures after clearing payload-bearing sync frames.""" + + return _redact_mount_error_data_sync(function, preserve_value_error_type=False) + + +def redact_mount_validation_error_data_sync( + function: Callable[_P, _T], +) -> Callable[_P, _T]: + """Redact sync mount validation failures while preserving `ValueError` compatibility.""" + + return _redact_mount_error_data_sync(function, preserve_value_error_type=True) + + def _replace_mount_error(error: MountConfigError) -> MountConfigError: message = ( error.message @@ -700,6 +869,54 @@ def _configured_mount_authority_fields(mount: Mount) -> tuple[str, ...]: return tuple(dict.fromkeys(fields)) +def _mount_has_usable_required_authority( + mount: Mount, + required_fields: Collection[str], +) -> bool: + for field_name in required_fields: + value = getattr(mount, field_name, None) + if isinstance(value, str) and value.strip(): + return True + return False + + +def _invalid_in_container_authority_value_fields( + mount: Mount, + mount_type: str, +) -> tuple[str, ...]: + invalid: list[str] = [] + for field_name in _AUTHORITY_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()): + value = getattr(mount, field_name, None) + if value is not None and (not isinstance(value, str) or not value.strip()): + invalid.append(field_name) + return tuple(sorted(invalid)) + + +def _invalid_in_container_credential_set_fields( + mount: Mount, + mount_type: str, +) -> tuple[str, ...]: + requirement = _IN_CONTAINER_CREDENTIAL_SET_REQUIREMENTS_BY_MOUNT_TYPE.get(mount_type) + if requirement is None: + return () + + configured_fields, required_fields = requirement + values = {field_name: getattr(mount, field_name, None) for field_name in configured_fields} + if all(value is None for value in values.values()): + return () + + invalid = { + field_name + for field_name, value in values.items() + if value is not None and (not isinstance(value, str) or not value.strip()) + } + for field_name in required_fields: + value = values[field_name] + if not isinstance(value, str) or not value.strip(): + invalid.add(field_name) + return tuple(sorted(invalid)) + + def _manifest_has_configured_mount_authority(manifest: Manifest) -> bool: pending = list(manifest.entries.values()) while pending: @@ -722,6 +939,10 @@ def _mount_has_or_may_hide_configured_authority(mount: Mount) -> bool: pattern = getattr(strategy, "pattern", None) if pattern is not None and not _pattern_class_is_trusted(pattern): return True + mount_type = _canonical_mount_type(type(mount)) or mount.type + capability = _in_container_mount_credential_capability(mount_type, strategy) + if capability is not None and capability.enables_broad_credential_discovery: + return True return bool(_configured_mount_authority_fields(mount)) @@ -730,6 +951,8 @@ def _call_has_configured_mount_authority( ) -> bool: """Inspect only SDK call-boundary manifest owners.""" + from .manifest import Manifest + try: for value in (*args, *kwargs.values()): candidates = [value] @@ -817,11 +1040,16 @@ def _mark_mount_error_for_manifest(error: MountConfigError, manifest: Manifest) _mark_error_data_redacted(error) -def _mark_mount_validation_error(error: MountConfigError) -> None: +def _mark_mount_error_data_safe(error: MountConfigError) -> None: + """Mark an SDK-created mount error whose message contains no credential-derived data.""" _mark_error_data_redacted(error) setattr(error, _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR, True) +def _mark_mount_validation_error(error: MountConfigError) -> None: + _mark_mount_error_data_safe(error) + + def _absolute_manifest_path(root: str, value: str) -> str: path = PurePosixPath(value) if not path.is_absolute(): @@ -913,137 +1141,297 @@ def _validate_manifest_mount_provenance(manifest: Manifest) -> None: _raise_data_redacted_error(error) -def _manifest_boundary_error( +def _resolved_in_container_pattern_type(strategy: MountStrategyBase) -> str | None: + pattern = getattr(strategy, "pattern", None) + pattern_type = getattr(pattern, "type", None) + return pattern_type if isinstance(pattern_type, str) else None + + +def _in_container_mount_credential_capability( + mount_type: str, + strategy: MountStrategyBase, +) -> _InContainerMountCredentialCapability | None: + return _in_container_mount_credential_capability_for_types( + mount_type, + strategy.type, + _resolved_in_container_pattern_type(strategy), + ) + + +def _in_container_mount_credential_capability_for_types( + mount_type: str, + strategy_type: str, + pattern_type: str | None, +) -> _InContainerMountCredentialCapability | None: + return next( + ( + capability + for capability in _IN_CONTAINER_MOUNT_CREDENTIAL_CAPABILITIES + if strategy_type in capability.strategy_types + and mount_type == capability.mount_type + and pattern_type == capability.pattern_type + ), + None, + ) + + +def _mount_boundary_error( manifest: Manifest, + mount: Mount, + mount_path: str | PurePath, *, - allowed_in_container_credential_strategy_types: frozenset[str], provider_backend_id: str | None, ) -> MountConfigError | None: - provenance_error = _manifest_mount_provenance_error(manifest) - if provenance_error is not None: - return provenance_error - for mount, _mount_path in manifest.mount_targets(): - mount_type = _canonical_mount_type(type(mount)) or mount.type - strategy = mount.mount_strategy - strategy_boundary, strategy_backend_id = _strategy_classification(strategy) - pattern = getattr(strategy, "pattern", None) - executes_in_container = strategy_boundary == "in_container" - if ( - provider_backend_id is not None - and strategy_backend_id is not None - and strategy_backend_id != provider_backend_id - ): + mount_type = _canonical_mount_type(type(mount)) or mount.type + strategy = mount.mount_strategy + strategy_boundary, strategy_backend_id = _strategy_classification(strategy) + pattern = getattr(strategy, "pattern", None) + executes_in_container = strategy_boundary == "in_container" + if ( + provider_backend_id is not None + and strategy_backend_id is not None + and strategy_backend_id != provider_backend_id + ): + return MountConfigError( + message=( + "docker-volume mounts are not supported by this sandbox backend" + if strategy.type == "docker_volume" + else "mount strategy is not supported by this sandbox backend" + ), + context={ + "mount_type": mount.type, + "strategy_type": strategy.type, + "sandbox_backend": provider_backend_id, + }, + ) + + for field_name in _AUTHORITY_FILE_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()): + value = getattr(mount, field_name, None) + if isinstance(value, str) and value and _manifest_materializes_path(manifest, value): return MountConfigError( message=( - "docker-volume mounts are not supported by this sandbox backend" - if strategy.type == "docker_volume" - else "mount strategy is not supported by this sandbox backend" + "credential files stored in the manifest are not supported for cloud " + "mounts; configure credentials outside the sandbox manifest" ), - context={ - "mount_type": mount.type, - "strategy_type": strategy.type, - "sandbox_backend": provider_backend_id, - }, + context={"mount_type": mount.type, "credential_field": field_name}, ) + if ( + isinstance(pattern, RcloneMountPattern) + and pattern.config_file_path is not None + and _manifest_materializes_path(manifest, pattern.config_file_path.as_posix()) + ): + return MountConfigError( + message=( + "credential files stored in the manifest are not supported for cloud mounts; " + "configure credentials outside the sandbox manifest" + ), + context={ + "mount_type": mount.type, + "credential_field": "mount_strategy.pattern.config_file_path", + }, + ) - for field_name in _AUTHORITY_FILE_FIELDS_BY_MOUNT_TYPE.get(mount_type, ()): - value = getattr(mount, field_name, None) - if isinstance(value, str) and value and _manifest_materializes_path(manifest, value): - return MountConfigError( - message=( - "credential files stored in the manifest are not supported for cloud " - "mounts; configure credentials outside the sandbox manifest" - ), - context={"mount_type": mount.type, "credential_field": field_name}, - ) + invalid_rclone_fields = _configured_rclone_line_fields(mount, mount_type) + if executes_in_container and isinstance(pattern, RcloneMountPattern) and invalid_rclone_fields: + return MountConfigError( + message="cloud mount configuration values must not contain line breaks", + context={ + "mount_type": mount.type, + "configuration_fields": invalid_rclone_fields, + }, + ) + invalid_s3fs_fields = _configured_blaxel_s3fs_option_fields(mount, mount_type) + if invalid_s3fs_fields: + return MountConfigError( + message="cloud mount configuration values must not contain s3fs option delimiters", + context={ + "mount_type": mount.type, + "configuration_fields": invalid_s3fs_fields, + }, + ) - invalid_rclone_fields = _configured_rclone_line_fields(mount, mount_type) - if ( - executes_in_container - and isinstance(pattern, RcloneMountPattern) - and invalid_rclone_fields - ): - return MountConfigError( - message="cloud mount configuration values must not contain line breaks", - context={ - "mount_type": mount.type, - "configuration_fields": invalid_rclone_fields, - }, - ) - if executes_in_container and mount_type == "box_mount": - return MountConfigError( - message=( - "Box mounts require credentials and are not supported by helpers that run " - "inside the sandbox; use an external/provider-native mount strategy" - ), - context={"mount_type": mount.type, "strategy_type": strategy.type}, - ) - if executes_in_container and isinstance(pattern, FuseMountPattern): - return MountConfigError( - message=( - "credentialless blobfuse mounts are not supported inside the sandbox; " - "use RcloneMountPattern or an external/provider-native mount strategy" - ), - context={"mount_type": mount.type, "strategy_type": strategy.type}, - ) - if executes_in_container and isinstance(pattern, S3FilesMountPattern): - return MountConfigError( - message=( - "S3 Files mounts are not supported inside the sandbox because the helper " - "requires ambient IAM credentials; use an external/provider-native strategy" - ), - context={"mount_type": mount.type, "strategy_type": strategy.type}, - ) - invalid_s3fs_fields = _configured_blaxel_s3fs_option_fields(mount, mount_type) - if invalid_s3fs_fields: - return MountConfigError( - message="cloud mount configuration values must not contain s3fs option delimiters", - context={ - "mount_type": mount.type, - "configuration_fields": invalid_s3fs_fields, - }, - ) + invalid_credential_set_fields = ( + _invalid_in_container_credential_set_fields(mount, mount_type) + if executes_in_container + else () + ) + if invalid_credential_set_fields: + return MountConfigError( + message="in-container access credentials require a complete non-empty credential set", + context={ + "mount_type": mount.type, + "credential_fields": invalid_credential_set_fields, + }, + ) - authority_fields = _configured_mount_authority_fields(mount) - trusted_opt_in_fields = _TRUSTED_IN_CONTAINER_OPT_IN_FIELDS.get(strategy.type, frozenset()) - exact_trusted_opt_in = ( - strategy_boundary == "in_container" - and strategy_backend_id is not None - and strategy_backend_id == provider_backend_id - and strategy.type in allowed_in_container_credential_strategy_types - and frozenset(authority_fields).issubset(trusted_opt_in_fields) + invalid_authority_value_fields = ( + _invalid_in_container_authority_value_fields(mount, mount_type) + if executes_in_container + else () + ) + if invalid_authority_value_fields: + return MountConfigError( + message="in-container mount authentication values must not be empty or whitespace-only", + context={ + "mount_type": mount.type, + "credential_fields": invalid_authority_value_fields, + }, ) - if authority_fields and strategy_boundary != "external" and not exact_trusted_opt_in: - return MountConfigError( - message=( - "cloud credentials are not supported by a mount helper that runs inside " - "the sandbox; use an external/provider-native mount strategy" + + authority_fields = frozenset(_configured_mount_authority_fields(mount)) + if strategy_boundary == "external": + return None + + mount_scoped_acknowledged = manifest._acknowledges_in_container_mount_credential_exposure( + mount_path, + "mount_scoped", + ) + broad_acknowledged = manifest._acknowledges_in_container_mount_credential_exposure( + mount_path, + "broad", + ) + capability = _in_container_mount_credential_capability(mount_type, strategy) + requires_implicit_broad = bool( + capability is not None and capability.enables_broad_credential_discovery + ) + if ( + capability is not None + and capability.required_any_fields + and not _mount_has_usable_required_authority(mount, capability.required_any_fields) + ): + return MountConfigError( + message=( + "in-container Box mounts require a non-interactive authentication source; " + "configure a token, access token, or JWT credentials before activation" + ), + context={"mount_type": mount.type}, + ) + if ( + not authority_fields + and not mount_scoped_acknowledged + and not broad_acknowledged + and not requires_implicit_broad + ): + return None + if capability is None: + return MountConfigError( + message=( + "credential-bearing in-container mounts require an SDK-supported strategy, " + "mount type, and pattern combination before exposure can be acknowledged; " + "use a credentialless helper or an external/provider-native mount strategy" + ), + context={ + "mount_type": mount.type, + "strategy_type": strategy.type, + "pattern_type": _resolved_in_container_pattern_type(strategy), + "credential_fields": tuple(sorted(authority_fields)), + }, + ) + + supported_fields = capability.mount_scoped_fields | capability.broad_fields + unsupported_fields = authority_fields - supported_fields + if unsupported_fields: + return MountConfigError( + message=( + "the selected in-container mount capability does not support exposing the " + "configured credential fields; use supported credentials, a credentialless " + "helper, or an external/provider-native mount strategy" + ), + context={ + "mount_type": mount.type, + "strategy_type": strategy.type, + "credential_fields": tuple(sorted(unsupported_fields)), + }, + ) + + supports_mount_scoped = bool(capability.mount_scoped_fields) + supports_broad = bool(capability.broad_fields or capability.enables_broad_credential_discovery) + if mount_scoped_acknowledged and not supports_mount_scoped: + return MountConfigError( + message=( + "the selected in-container mount capability does not support mount-scoped " + "credentials" + ), + context={"mount_type": mount.type, "strategy_type": strategy.type}, + ) + if broad_acknowledged and not supports_broad: + return MountConfigError( + message=( + "the selected in-container mount capability does not support broad credential " + "authority" + ), + context={"mount_type": mount.type, "strategy_type": strategy.type}, + ) + + requires_mount_scoped = bool(authority_fields & capability.mount_scoped_fields) + requires_broad = bool(authority_fields & capability.broad_fields) or requires_implicit_broad + if requires_mount_scoped and not mount_scoped_acknowledged: + return MountConfigError( + message=( + "mount-scoped credentials cannot be exposed to a helper inside a " + "model-controlled sandbox by default; use a credentialless or " + "external/provider-native strategy, or explicitly acknowledge exposure for " + "this exact path with " + "Manifest.with_in_container_mount_credential_exposure_acknowledged()" + ), + context={ + "mount_type": mount.type, + "credential_fields": tuple( + sorted(authority_fields & capability.mount_scoped_fields) ), - context={"mount_type": mount.type, "credential_fields": authority_fields}, - ) + }, + ) + if requires_broad and not broad_acknowledged: + return MountConfigError( + message=( + "broad credential authority cannot be exposed to a helper inside a " + "model-controlled sandbox by default; use a credentialless or " + "external/provider-native strategy, or explicitly acknowledge broad exposure " + "for this exact path with " + "Manifest.with_in_container_mount_broad_credential_exposure_acknowledged()" + ), + context={ + "mount_type": mount.type, + "credential_fields": tuple(sorted(authority_fields & capability.broad_fields)), + }, + ) + return None + +def _manifest_boundary_error( + manifest: Manifest, + *, + provider_backend_id: str | None, +) -> MountConfigError | None: + provenance_error = _manifest_mount_provenance_error(manifest) + if provenance_error is not None: + return provenance_error + for mount, mount_path in manifest.mount_targets(): + if error := _mount_boundary_error( + manifest, + mount, + PurePosixPath(mount_path.as_posix()), + provider_backend_id=provider_backend_id, + ): + return error return None def validate_manifest_mount_credential_boundaries( manifest: Manifest, *, - allowed_in_container_credential_strategy_types: frozenset[str] = frozenset(), provider_backend_id: str | None = None, ) -> None: """Validate all mount authority before a sandbox or helper has side effects.""" error = _manifest_boundary_error( manifest, - allowed_in_container_credential_strategy_types=( - allowed_in_container_credential_strategy_types - ), provider_backend_id=provider_backend_id, ) if error is None: return _mark_mount_validation_error(error) - del manifest, allowed_in_container_credential_strategy_types + del manifest provider_backend_id = None _raise_data_redacted_error(error) @@ -1052,21 +1440,40 @@ def validate_mount_activation_credential_boundary( mount: Mount, strategy: MountStrategyBase, *, + manifest: Manifest | None = None, + mount_path: str | PurePath | Callable[[], str | PurePath] | None = None, provider_backend_id: str | None = None, ) -> None: """Revalidate the strategy that is about to execute inside a sandbox.""" + from .manifest import Manifest + _validate_mount_provenance(mount, strategy) activation_mount = mount.model_copy(deep=True, update={"mount_strategy": strategy}) - validate_manifest_mount_credential_boundaries( - Manifest(entries={"mount": activation_mount}), + activation_manifest = manifest or Manifest(entries={"mount": activation_mount}) + resolved_mount_path = mount_path() if callable(mount_path) else mount_path + activation_path = resolved_mount_path or PurePosixPath(activation_manifest.root) / "mount" + error = _mount_boundary_error( + activation_manifest, + activation_mount, + activation_path, provider_backend_id=provider_backend_id, ) + if error is None: + return + _mark_mount_validation_error(error) + del mount, strategy, activation_mount, activation_manifest + mount_path = None + resolved_mount_path = None + provider_backend_id = None + _raise_data_redacted_error(error) def sanitize_manifest_mount_authority(manifest: Manifest) -> tuple[Manifest, bool]: """Return a typed manifest whose durable form contains no mount authority.""" + from .manifest import Manifest + provenance_error = _manifest_mount_provenance_error(manifest) if provenance_error is not None: _mark_mount_validation_error(provenance_error) @@ -1096,11 +1503,10 @@ def rebind_manifest_mount_authority( *, provider_backend_id: str, ) -> Manifest: - """Restore external live authority after exact credential-free topology matching.""" + """Restore live authority after exact credential-free topology matching.""" error = _manifest_boundary_error( trusted_manifest, - allowed_in_container_credential_strategy_types=frozenset(), provider_backend_id=provider_backend_id, ) sanitized_persisted, _ = sanitize_manifest_mount_authority(persisted_manifest) @@ -1128,7 +1534,7 @@ def rebind_manifest_mount_authority( error = MountConfigError( message=( "sandbox mount configuration can be rebound only from a current trusted " - "external mount configuration with exactly matching credential-free topology" + "mount configuration with exactly matching credential-free topology" ), context={"sandbox_backend": provider_backend_id}, ) @@ -1152,6 +1558,7 @@ def rebind_manifest_mount_authority( for path, entry in rebound.iter_entries(): if isinstance(entry, Mount): entry.__dict__.update(trusted_entries[path.as_posix()].model_copy(deep=True).__dict__) + rebound._copy_mount_credential_exposure_policy_from(trusted_manifest) return rebound @@ -1374,6 +1781,15 @@ def _sanitize_raw_mount( if not isinstance(pattern_type, str): pattern["type"] = None redacted = True + capability = _in_container_mount_credential_capability_for_types( + mount_type, + strategy_type, + pattern_type if isinstance(pattern_type, str) else None, + ) + if capability is not None and capability.enables_broad_credential_discovery: + # Runtime-only broad acknowledgement must be rebound after serialization even when the + # helper discovers ambient authority without an explicit credential field to redact. + redacted = True serialized_pattern_class = ( _SERIALIZED_PATTERN_CLASS_BY_TYPE.get(pattern_type) if isinstance(pattern_type, str) diff --git a/src/agents/sandbox/entries/mounts/base.py b/src/agents/sandbox/entries/mounts/base.py index 88a0a15012..3f2e7bf049 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -264,7 +264,17 @@ async def activate( ) -> list[MaterializedFile]: from ..._mount_security import validate_mount_activation_credential_boundary - validate_mount_activation_credential_boundary(mount, self) + validate_mount_activation_credential_boundary( + mount, + self, + manifest=getattr(session.state, "manifest", None), + mount_path=( + (lambda: mount._resolve_mount_path(session, dest)) + if getattr(session.state, "manifest", None) is not None + else None + ), + provider_backend_id=session.state.type, + ) return await mount.in_container_adapter().activate(self, session, dest, base_dir) async def deactivate( @@ -293,7 +303,13 @@ async def restore_after_snapshot( ) -> None: from ..._mount_security import validate_mount_activation_credential_boundary - validate_mount_activation_credential_boundary(mount, self) + validate_mount_activation_credential_boundary( + mount, + self, + manifest=getattr(session.state, "manifest", None), + mount_path=path, + provider_backend_id=session.state.type, + ) await mount.in_container_adapter().restore_after_snapshot(self, session, path) def build_docker_volume_driver_config( @@ -462,6 +478,12 @@ async def apply( validate_mount_activation_credential_boundary( self, self.mount_strategy, + manifest=getattr(session.state, "manifest", None), + mount_path=( + (lambda: self._resolve_mount_path(session, dest)) + if getattr(session.state, "manifest", None) is not None + else None + ), provider_backend_id=session.state.type, ) return await self.mount_strategy.activate(self, session, dest, base_dir) diff --git a/src/agents/sandbox/entries/mounts/providers/azure_blob.py b/src/agents/sandbox/entries/mounts/providers/azure_blob.py index e8d0352d04..39b5797e16 100644 --- a/src/agents/sandbox/entries/mounts/providers/azure_blob.py +++ b/src/agents/sandbox/entries/mounts/providers/azure_blob.py @@ -96,8 +96,9 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: lines.append(f"endpoint = {self.endpoint}") if self.account_key: lines.append(f"key = {self.account_key}") + elif self.identity_client_id: + lines.append("use_msi = true") + lines.append(f"msi_client_id = {self.identity_client_id}") else: lines.append("use_msi = false") - if self.identity_client_id: - lines.append(f"msi_client_id = {self.identity_client_id}") return lines diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index 62d88e202f..97a3677442 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -1,21 +1,25 @@ import abc import inspect from collections.abc import Iterator, Mapping +from dataclasses import dataclass from pathlib import Path, PurePath, PurePosixPath from typing import Any, ClassVar, Literal from pydantic import ( BaseModel, Field, + PrivateAttr, SerializeAsAny, field_serializer, field_validator, + model_validator, ) from pydantic_core import PydanticSerializationError from typing_extensions import assert_never from .._config_coercion import coerce_pydantic_config from ..util._asyncio_tasks import gather_with_cancel +from ._mount_security import redact_mount_validation_error_data_sync from .entries import BaseEntry, Dir, Mount, resolve_workspace_path from .errors import InvalidManifestPathError from .manifest_render import render_manifest_description @@ -48,6 +52,31 @@ "rm", ] +_MOUNT_CREDENTIAL_EXPOSURE_POLICY_KEYS = frozenset( + { + "in_container_mount_credential_exposure_allowed_paths", + "_in_container_mount_credential_exposure_allowed_paths", + "inContainerMountCredentialExposureAllowedPaths", + "_inContainerMountCredentialExposureAllowedPaths", + "in_container_mount_credential_exposure_acknowledged_paths", + "_in_container_mount_credential_exposure_acknowledged_paths", + "inContainerMountCredentialExposureAcknowledgedPaths", + "_inContainerMountCredentialExposureAcknowledgedPaths", + "in_container_mount_broad_credential_exposure_acknowledged_paths", + "_in_container_mount_broad_credential_exposure_acknowledged_paths", + "inContainerMountBroadCredentialExposureAcknowledgedPaths", + "_inContainerMountBroadCredentialExposureAcknowledgedPaths", + "mount_credential_exposure_policy", + "_mount_credential_exposure_policy", + } +) + + +@dataclass(frozen=True) +class _MountCredentialExposurePolicy: + mount_scoped: frozenset[str] = frozenset() + broad: frozenset[str] = frozenset() + EnvValueClass = type["EnvValue"] @@ -225,6 +254,21 @@ class Manifest(BaseModel): remote_mount_command_allowlist: list[str] = Field( default_factory=lambda: list(DEFAULT_REMOTE_MOUNT_COMMAND_ALLOWLIST) ) + _mount_credential_exposure_policy: _MountCredentialExposurePolicy = PrivateAttr( + default_factory=_MountCredentialExposurePolicy + ) + + @model_validator(mode="before") + @classmethod + def _reject_mount_credential_exposure_policy_input(cls, value: object) -> object: + if isinstance(value, Mapping) and _MOUNT_CREDENTIAL_EXPOSURE_POLICY_KEYS.intersection( + value + ): + raise TypeError( + "In-container mount credential exposure must be configured on a trusted " + "Manifest instance, not in manifest input." + ) + return value @field_validator("entries", mode="before") @classmethod @@ -249,6 +293,168 @@ def validated_entries(self) -> dict[str | Path, BaseEntry]: pass return validated + @redact_mount_validation_error_data_sync + def with_in_container_mount_credential_exposure_acknowledged( + self, *mount_paths: str | PurePath + ) -> "Manifest": + """Acknowledge mount-scoped credential exposure for exact in-container mount paths. + + This trusted application-side policy is runtime-only and is not serialized. + """ + + return self._with_mount_credential_exposure_acknowledged( + "mount_scoped", + mount_paths, + ) + + @redact_mount_validation_error_data_sync + def with_in_container_mount_broad_credential_exposure_acknowledged( + self, *mount_paths: str | PurePath + ) -> "Manifest": + """Acknowledge broad credential exposure for exact in-container mount paths. + + Broad authority includes managed or workload identity and external credential files. + This trusted application-side policy is runtime-only and is not serialized. + """ + + return self._with_mount_credential_exposure_acknowledged( + "broad", + mount_paths, + ) + + def _with_mount_credential_exposure_acknowledged( + self, + authority: Literal["mount_scoped", "broad"], + mount_paths: tuple[str | PurePath, ...], + ) -> "Manifest": + if not mount_paths: + raise TypeError("At least one in-container mount path is required.") + + acknowledged: set[str] = set() + for path in mount_paths: + key = self._mount_credential_exposure_policy_key(path, reject_root=True) + assert key is not None + acknowledged.add(key) + from ._mount_security import _validate_manifest_mount_provenance + + _validate_manifest_mount_provenance(self) + trusted = self.model_copy(deep=True) + current = self._mount_credential_exposure_policy + trusted._mount_credential_exposure_policy = _MountCredentialExposurePolicy( + mount_scoped=( + current.mount_scoped | acknowledged + if authority == "mount_scoped" + else current.mount_scoped + ), + broad=(current.broad | acknowledged if authority == "broad" else current.broad), + ) + return trusted + + def _acknowledges_in_container_mount_credential_exposure( + self, + mount_path: str | PurePath, + authority: Literal["mount_scoped", "broad"], + ) -> bool: + key = self._mount_credential_exposure_policy_key(mount_path, reject_root=False) + if key is None: + return False + lookup_keys = {key} + kind, _, path_text = key.partition(":") + root = coerce_posix_path(self.root) + root_normalized = PurePosixPath( + "/", + *[part for part in root.parts if part not in {"/", ""}], + ) + if kind == "absolute": + try: + relative = PurePosixPath(path_text).relative_to(root_normalized) + except ValueError: + pass + else: + if relative.parts: + lookup_keys.add(f"relative:{relative.as_posix()}") + else: + absolute = root_normalized / PurePosixPath(path_text) + lookup_keys.add(f"absolute:{absolute.as_posix()}") + acknowledged = getattr(self._mount_credential_exposure_policy, authority) + return not lookup_keys.isdisjoint(acknowledged) + + def _copy_mount_credential_exposure_policy_from(self, *sources: "Manifest") -> None: + mount_scoped: set[str] = set() + broad: set[str] = set() + for source in sources: + mount_scoped.update(source._mount_credential_exposure_policy.mount_scoped) + broad.update(source._mount_credential_exposure_policy.broad) + self._mount_credential_exposure_policy = _MountCredentialExposurePolicy( + mount_scoped=frozenset(mount_scoped), + broad=frozenset(broad), + ) + + def _merge_mount_credential_exposure_policy( + self, + policy: _MountCredentialExposurePolicy, + ) -> _MountCredentialExposurePolicy: + current = self._mount_credential_exposure_policy + merged = _MountCredentialExposurePolicy( + mount_scoped=current.mount_scoped | policy.mount_scoped, + broad=current.broad | policy.broad, + ) + self._mount_credential_exposure_policy = merged + return merged + + def _mount_credential_exposure_policy_key( + self, + value: str | PurePath, + *, + reject_root: bool, + ) -> str | None: + text = value.as_posix() if isinstance(value, PurePath) else value + if not text: + if reject_root: + raise ValueError("Mount credential exposure path must identify a non-root path.") + return None + if "\\" in text: + raise ValueError("Mount credential exposure paths must use '/' separators.") + if reject_root and any(character in text for character in "*?[]"): + raise ValueError("Mount credential exposure paths must not contain wildcard syntax.") + + raw = PurePosixPath(text) + if reject_root and ".." in raw.parts: + raise ValueError("Mount credential exposure paths must not contain parent segments.") + if not raw.is_absolute(): + rel = self._normalize_rel_path_within_root( + posix_path_as_path(raw), + original=posix_path_as_path(raw), + ) + if not rel.parts: + if reject_root: + raise ValueError( + "Mount credential exposure path must identify a non-root path." + ) + return None + return f"relative:{coerce_posix_path(rel).as_posix()}" + + normalized_parts: list[str] = [] + for part in raw.parts: + if part in {"", ".", "/"}: + continue + if part == "..": + if normalized_parts: + normalized_parts.pop() + continue + normalized_parts.append(part) + normalized = PurePosixPath("/", *normalized_parts) + root = coerce_posix_path(self.root) + root_normalized = PurePosixPath( + "/", + *[part for part in root.parts if part not in {"/", ""}], + ) + if normalized == PurePosixPath("/") or normalized == root_normalized: + if reject_root: + raise ValueError("Mount credential exposure path must identify a non-root path.") + return None + return f"absolute:{normalized.as_posix()}" + def ephemeral_entry_paths(self, depth: int | None = 1) -> set[Path]: _ = depth return {path for path, artifact in self.iter_entries() if artifact.ephemeral} diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index 07ac13afb7..c670c00fa7 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -557,10 +557,16 @@ def _process_manifest( manifest.model_copy(deep=True), run_as_user, ) + mount_credential_exposure_policy = processed_manifest._mount_credential_exposure_policy for capability in capabilities: safe_error: RuntimeError | None = None try: processed_manifest = capability.process_manifest(processed_manifest) + mount_credential_exposure_policy = ( + processed_manifest._merge_mount_credential_exposure_policy( + mount_credential_exposure_policy + ) + ) except Exception as error: if not _manifest_has_configured_mount_authority(processed_manifest): raise @@ -571,6 +577,7 @@ def _process_manifest( capability = cast(Any, None) manifest = None processed_manifest = cast(Any, None) + mount_credential_exposure_policy = cast(Any, None) run_as_user = None _raise_data_redacted_error(safe_error) return processed_manifest diff --git a/src/agents/sandbox/session/sandbox_client.py b/src/agents/sandbox/session/sandbox_client.py index 177c77e0c4..2d936a6f95 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -132,16 +132,11 @@ def _wrap_session( def _validate_manifest_for_create( self, manifest: Manifest, - *, - allowed_in_container_credential_strategy_types: frozenset[str] = frozenset(), ) -> Manifest: from .._mount_security import validate_manifest_mount_credential_boundaries validate_manifest_mount_credential_boundaries( manifest, - allowed_in_container_credential_strategy_types=( - allowed_in_container_credential_strategy_types - ), provider_backend_id=self.backend_id, ) return manifest diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 5e4176adfe..1ab368bee4 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -4,6 +4,7 @@ import io import json import logging +import shlex import tarfile import time import uuid @@ -2313,15 +2314,19 @@ def test_hardlink_rejected(self) -> None: class TestTarExcludeArgsWithSkipPaths: @pytest.mark.asyncio async def test_exclude_args_with_skip_paths(self, fake_sandbox: _FakeSandboxInstance) -> None: + from agents.extensions.sandbox.blaxel.mounts import _mount_credential_path + session = _make_session(fake_sandbox) - session._runtime_persist_workspace_skip_relpaths = { - Path("node_modules"), - Path(".git"), - } + session.register_persist_workspace_skip_path(Path("node_modules")) + session.register_persist_workspace_skip_path(Path(".git")) + credential_path = _mount_credential_path(session, "s3fs-passwd") + credential_relative_path = credential_path.relative_to(Path(session.state.manifest.root)) args = session._tar_exclude_args() assert len(args) > 0 assert any("node_modules" in a for a in args) assert any(".git" in a for a in args) + assert any(credential_relative_path.as_posix() in arg for arg in args) + assert credential_relative_path in session._workspace_fingerprint_skip_relpaths() @pytest.mark.asyncio async def test_exclude_args_skips_empty_and_dot( @@ -2861,8 +2866,14 @@ class _FakeMountSession: def __init__(self) -> None: self.exec_calls: list[tuple[tuple[str, ...], dict[str, float]]] = [] + self.write_calls: list[tuple[Path, bytes]] = [] + self.persist_workspace_skip_paths: list[Path] = [] + self.credential_lifecycle_events: list[tuple[str, Path]] = [] + self.persist_workspace_skip_error: Exception | None = None self._next_results: list[_FakeExecResultForMount] = [] self._default_result = _FakeExecResultForMount() + self.state = MagicMock() + self.state.manifest = Manifest() async def exec(self, *cmd: str, timeout: float = 120) -> _FakeExecResultForMount: self.exec_calls.append((cmd, {"timeout": timeout})) @@ -2870,6 +2881,21 @@ async def exec(self, *cmd: str, timeout: float = 120) -> _FakeExecResultForMount return self._next_results.pop(0) return self._default_result + async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None: + _ = user + payload = data.read() + assert isinstance(payload, bytes) + self.credential_lifecycle_events.append(("write", path)) + self.write_calls.append((path, payload)) + + def register_persist_workspace_skip_path(self, path: Path | str) -> Path: + relative_path = Path(path) + self.credential_lifecycle_events.append(("register", relative_path)) + if self.persist_workspace_skip_error is not None: + raise self.persist_workspace_skip_error + self.persist_workspace_skip_paths.append(relative_path) + return relative_path + class __class__: __name__ = "BlaxelSandboxSession" @@ -3010,10 +3036,11 @@ async def test_mount_s3_with_credentials(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3 session = _FakeMountSession() + secret_access_key = "s3-secret-command-sentinel" # Simulate: which s3fs succeeds. session._next_results = [ _FakeExecResultForMount(exit_code=0, stdout=b"/usr/bin/s3fs"), # which s3fs - _FakeExecResultForMount(exit_code=0), # write cred file + _FakeExecResultForMount(exit_code=0), # chmod cred file _FakeExecResultForMount(exit_code=0), # mkdir _FakeExecResultForMount(exit_code=0), # s3fs mount _FakeExecResultForMount(exit_code=0), # rm cred file @@ -3024,13 +3051,93 @@ async def test_mount_s3_with_credentials(self) -> None: bucket="my-bucket", mount_path="/mnt/s3", access_key_id="AKID", - secret_access_key="SECRET", + secret_access_key=secret_access_key, region="us-east-1", prefix="data/", read_only=True, ) await _mount_s3(session, config) # type: ignore[arg-type] assert len(session.exec_calls) == 5 + assert len(session.write_calls) == 1 + credential_path, credential_payload = session.write_calls[0] + assert credential_path.parent == Path("/workspace") + assert credential_path.name.startswith(".openai-agents-s3fs-passwd-") + assert credential_payload == f"AKID:{secret_access_key}".encode() + assert secret_access_key not in repr(session.exec_calls) + + @pytest.mark.asyncio + async def test_mount_s3_fails_when_credential_cleanup_fails(self) -> None: + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3 + + session = _FakeMountSession() + secret_access_key = "s3-cleanup-secret" + session._next_results = [ + _FakeExecResultForMount(exit_code=0), # which s3fs + _FakeExecResultForMount(exit_code=0), # chmod credential file + _FakeExecResultForMount(exit_code=0), # mkdir + _FakeExecResultForMount(exit_code=0), # s3fs mount + _FakeExecResultForMount(exit_code=1), # rm credential file + ] + + config = BlaxelCloudBucketMountConfig( + provider="s3", + bucket="my-bucket", + mount_path="/mnt/s3", + access_key_id="AKID", + secret_access_key=secret_access_key, + ) + with pytest.raises(MountConfigError, match="failed to remove mount credential file"): + await _mount_s3(session, config) # type: ignore[arg-type] + + assert session.exec_calls[-1][0][2].startswith("rm -f ") + assert secret_access_key not in repr(session.exec_calls) + credential_path, _credential_payload = session.write_calls[0] + assert session.persist_workspace_skip_paths == [ + credential_path.relative_to(Path("/workspace")) + ] + assert [event for event, _path in session.credential_lifecycle_events] == [ + "register", + "write", + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider", ["s3", "gcs"]) + async def test_mount_credentials_reject_registration_before_write(self, provider: str) -> None: + from agents.extensions.sandbox.blaxel.mounts import ( + BlaxelCloudBucketMountConfig, + _mount_gcs, + _mount_s3, + ) + + session = _FakeMountSession() + session.persist_workspace_skip_error = RuntimeError("registration rejected") + session._next_results = [_FakeExecResultForMount(exit_code=0)] # which + if provider == "s3": + mount = _mount_s3 + config = BlaxelCloudBucketMountConfig( + provider="s3", + bucket="bucket", + mount_path="/mnt/data", + access_key_id="AKID", + secret_access_key="SECRET", + ) + else: + mount = _mount_gcs + config = BlaxelCloudBucketMountConfig( + provider="gcs", + bucket="bucket", + mount_path="/mnt/data", + service_account_key='{"private_key":"SECRET"}', + ) + + with pytest.raises(RuntimeError, match="registration rejected"): + await mount(session, config) # type: ignore[arg-type] + + assert [event for event, _path in session.credential_lifecycle_events] == ["register"] + assert session.persist_workspace_skip_paths == [] + assert session.write_calls == [] + assert len(session.exec_calls) == 1 + assert session.exec_calls[0][0][2].startswith("which ") @pytest.mark.asyncio async def test_mount_s3_public_bucket(self) -> None: @@ -3050,6 +3157,9 @@ async def test_mount_s3_public_bucket(self) -> None: read_only=True, ) await _mount_s3(session, config) # type: ignore[arg-type] + assert len(session.exec_calls) == 3 + assert not any(call[0][2].startswith("rm -f ") for call in session.exec_calls) + assert session.persist_workspace_skip_paths == [] @pytest.mark.asyncio async def test_mount_s3_with_endpoint(self) -> None: @@ -3118,9 +3228,10 @@ async def test_mount_gcs_with_key(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs session = _FakeMountSession() + service_account_key = '{"private_key":"gcs-secret-command-sentinel"}' session._next_results = [ _FakeExecResultForMount(exit_code=0), # which gcsfuse - _FakeExecResultForMount(exit_code=0), # write key + _FakeExecResultForMount(exit_code=0), # chmod key _FakeExecResultForMount(exit_code=0), # mkdir _FakeExecResultForMount(exit_code=0), # gcsfuse mount _FakeExecResultForMount(exit_code=0), # rm key @@ -3130,11 +3241,111 @@ async def test_mount_gcs_with_key(self) -> None: provider="gcs", bucket="gcs-bucket", mount_path="/mnt/gcs", - service_account_key='{"type":"service_account"}', + service_account_key=service_account_key, read_only=True, prefix="data/", ) await _mount_gcs(session, config) # type: ignore[arg-type] + assert len(session.exec_calls) == 5 + assert len(session.write_calls) == 1 + credential_path, credential_payload = session.write_calls[0] + assert credential_path.parent == Path("/workspace") + assert credential_path.name.startswith(".openai-agents-gcs-creds-") + assert credential_payload == service_account_key.encode() + assert "gcs-secret-command-sentinel" not in repr(session.exec_calls) + + @pytest.mark.asyncio + async def test_mount_gcs_fails_when_credential_cleanup_fails(self) -> None: + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs + + session = _FakeMountSession() + service_account_key = '{"private_key":"gcs-cleanup-secret"}' + session._next_results = [ + _FakeExecResultForMount(exit_code=0), # which gcsfuse + _FakeExecResultForMount(exit_code=0), # chmod credential file + _FakeExecResultForMount(exit_code=0), # mkdir + _FakeExecResultForMount(exit_code=0), # gcsfuse mount + _FakeExecResultForMount(exit_code=1), # rm credential file + ] + + config = BlaxelCloudBucketMountConfig( + provider="gcs", + bucket="gcs-bucket", + mount_path="/mnt/gcs", + service_account_key=service_account_key, + ) + with pytest.raises(MountConfigError, match="failed to remove mount credential file"): + await _mount_gcs(session, config) # type: ignore[arg-type] + + assert session.exec_calls[-1][0][2].startswith("rm -f ") + assert "gcs-cleanup-secret" not in repr(session.exec_calls) + credential_path, _credential_payload = session.write_calls[0] + assert session.persist_workspace_skip_paths == [ + credential_path.relative_to(Path("/workspace")) + ] + assert [event for event, _path in session.credential_lifecycle_events] == [ + "register", + "write", + ] + + @pytest.mark.asyncio + async def test_mount_gcs_quotes_generated_key_path(self) -> None: + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs + + session = _FakeMountSession() + session.state.manifest = Manifest(root="/workspace data;echo not-executed") + session._next_results = [ + _FakeExecResultForMount(exit_code=0), # which gcsfuse + _FakeExecResultForMount(exit_code=0), # chmod key + _FakeExecResultForMount(exit_code=0), # mkdir + _FakeExecResultForMount(exit_code=0), # gcsfuse mount + _FakeExecResultForMount(exit_code=0), # rm key + ] + + config = BlaxelCloudBucketMountConfig( + provider="gcs", + bucket="gcs-bucket", + mount_path="/mnt/gcs", + service_account_key='{"private_key":"gcs-secret"}', + ) + await _mount_gcs(session, config) # type: ignore[arg-type] + + credential_path, _credential_payload = session.write_calls[0] + mount_command = session.exec_calls[3][0][2] + assert f"--key-file={credential_path.as_posix()}" in shlex.split(mount_command) + assert "echo" not in shlex.split(mount_command) + + @pytest.mark.asyncio + async def test_mount_gcs_aborts_and_cleans_up_when_credential_chmod_fails(self) -> None: + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs + + session = _FakeMountSession() + service_account_key = '{"private_key":"gcs-chmod-secret"}' + session._next_results = [ + _FakeExecResultForMount(exit_code=0), # which gcsfuse + _FakeExecResultForMount(exit_code=1), # chmod key + _FakeExecResultForMount(exit_code=0), # rm key + ] + + config = BlaxelCloudBucketMountConfig( + provider="gcs", + bucket="gcs-bucket", + mount_path="/mnt/gcs", + service_account_key=service_account_key, + ) + with pytest.raises( + MountConfigError, + match="failed to restrict mount credential file permissions", + ): + await _mount_gcs(session, config) # type: ignore[arg-type] + + commands = [call[0][2] for call in session.exec_calls] + assert len(session.write_calls) == 1 + assert any(command.startswith("chmod 600 ") for command in commands) + assert any(command.startswith("rm -f ") for command in commands) + assert not any(command.startswith("mkdir -p ") for command in commands) + assert not any(command.startswith("gcsfuse ") for command in commands) + assert "gcs-chmod-secret" not in repr(session.exec_calls) @pytest.mark.asyncio async def test_mount_gcs_anonymous(self) -> None: @@ -3153,6 +3364,9 @@ async def test_mount_gcs_anonymous(self) -> None: mount_path="/mnt/pub-gcs", ) await _mount_gcs(session, config) # type: ignore[arg-type] + assert len(session.exec_calls) == 3 + assert not any(call[0][2].startswith("rm -f ") for call in session.exec_calls) + assert session.persist_workspace_skip_paths == [] @pytest.mark.asyncio async def test_mount_gcs_fails(self) -> None: @@ -3325,6 +3539,44 @@ async def test_activate(self) -> None: ) assert result == [] + @pytest.mark.asyncio + async def test_activate_preserves_safe_credential_cleanup_error(self) -> None: + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy + from agents.sandbox.entries import S3Mount + + strategy = BlaxelCloudBucketMountStrategy() + mount = S3Mount( + bucket="test", + access_key_id="AKID", + secret_access_key="s3-cleanup-secret", + mount_strategy=strategy, + ) + session = _FakeMountSession() + session.state.manifest = Manifest( + entries={"data": mount} + ).with_in_container_mount_credential_exposure_acknowledged("data") + session._next_results = [ + _FakeExecResultForMount(exit_code=0), # which + _FakeExecResultForMount(exit_code=0), # chmod credential file + _FakeExecResultForMount(exit_code=0), # mkdir + _FakeExecResultForMount(exit_code=0), # mount + _FakeExecResultForMount(exit_code=1), # rm credential file + ] + mount._resolve_mount_path = lambda s, d: Path("/workspace/data") # type: ignore[assignment] + + with pytest.raises( + MountConfigError, + match="failed to remove mount credential file", + ): + await strategy.activate( + mount, + session, # type: ignore[arg-type] + Path("/workspace/data"), + Path("/workspace"), + ) + + assert "s3-cleanup-secret" not in repr(session.exec_calls) + @pytest.mark.asyncio async def test_deactivate(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy @@ -3376,6 +3628,41 @@ async def test_restore_after_snapshot(self) -> None: Path("/workspace/mnt/s3"), ) + @pytest.mark.asyncio + async def test_restore_preserves_cleanup_error_over_mount_error(self) -> None: + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy + from agents.sandbox.entries import GCSMount + + strategy = BlaxelCloudBucketMountStrategy() + mount = GCSMount( + bucket="test", + service_account_credentials='{"private_key":"gcs-cleanup-secret"}', + mount_strategy=strategy, + ) + session = _FakeMountSession() + session.state.manifest = Manifest( + entries={"data": mount} + ).with_in_container_mount_credential_exposure_acknowledged("data") + session._next_results = [ + _FakeExecResultForMount(exit_code=0), # which + _FakeExecResultForMount(exit_code=0), # chmod credential file + _FakeExecResultForMount(exit_code=0), # mkdir + _FakeExecResultForMount(exit_code=1, stderr=b"mount failed"), # mount + _FakeExecResultForMount(exit_code=1), # rm credential file + ] + + with pytest.raises( + MountConfigError, + match="failed to remove mount credential file", + ): + await strategy.restore_after_snapshot( + mount, + session, # type: ignore[arg-type] + Path("/workspace/data"), + ) + + assert "gcs-cleanup-secret" not in repr(session.exec_calls) + # --------------------------------------------------------------------------- # SDK exception mapping tests diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 6293662e26..054f40a858 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -640,7 +640,7 @@ def test_vercel_from_state_rejects_mismatched_trusted_mount_configuration( ) -def test_vercel_from_state_requires_trusted_manifest_for_mounts( +def test_vercel_from_state_accepts_released_trusted_mount_shape( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) @@ -652,11 +652,86 @@ def test_vercel_from_state_requires_trusted_manifest_for_mounts( sandbox_id="sandbox-existing", ) - with pytest.raises(MountConfigError, match="trusted create-time manifest"): - vercel_module.VercelSandboxSession.from_state( - state, - trusted_s3_mounts=vercel_module._vercel_s3_mount_map(manifest), - ) + session = vercel_module.VercelSandboxSession.from_state( + state, + trusted_s3_mounts=vercel_module._vercel_s3_mount_map(manifest), + ) + + assert session._trusted_manifest == manifest + + +def test_vercel_from_state_accepts_released_credential_exposure_option( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + trusted_manifest = _vercel_s3_manifest(package_module, credentials=True) + state_manifest = vercel_module._manifest_without_vercel_s3_credentials(trusted_manifest) + state = vercel_module.VercelSandboxSessionState( + manifest=state_manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + + session = vercel_module.VercelSandboxSession.from_state( + state, + allow_s3_credential_exposure=True, + trusted_s3_mounts=vercel_module._vercel_s3_mount_map(trusted_manifest), + ) + + assert session._trusted_manifest.model_dump(mode="json") == state_manifest.model_dump( + mode="json" + ) + assert session._runtime_s3_mount_sensitive_values() == ( + "test-access-key", + "test-secret-key", + "test-session-token", + ) + + +@pytest.mark.parametrize("entrypoint", ["constructor", "from_state"]) +@pytest.mark.parametrize("include_trusted_manifest", [False, True]) +def test_vercel_direct_session_normalizes_released_credentialed_state_shape( + monkeypatch: pytest.MonkeyPatch, + entrypoint: str, + include_trusted_manifest: bool, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + manifest = _vercel_s3_manifest(package_module, credentials=True) + state = vercel_module.VercelSandboxSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-existing", + ) + kwargs: dict[str, Any] = { + "state": state, + "allow_s3_credential_exposure": True, + "trusted_s3_mounts": vercel_module._vercel_s3_mount_map(manifest), + } + if include_trusted_manifest: + kwargs["trusted_manifest"] = manifest + + if entrypoint == "constructor": + session = vercel_module.VercelSandboxSession(**kwargs) + else: + session = vercel_module.VercelSandboxSession.from_state(**kwargs) + + state_mount = state.manifest.entries["remote"] + assert isinstance(state_mount, S3Mount) + assert state_mount.access_key_id is None + assert state_mount.secret_access_key is None + assert state_mount.session_token is None + assert session.state is state + assert session._runtime_s3_mount_sensitive_values() == ( + "test-access-key", + "test-secret-key", + "test-session-token", + ) + payload = session.state.model_dump(mode="json") + assert "test-access-key" not in repr(payload) + assert "test-secret-key" not in repr(payload) + assert "test-session-token" not in repr(payload) def test_vercel_from_state_rejects_custom_mount_before_deepcopy( @@ -899,7 +974,7 @@ async def test_vercel_create_requires_explicit_s3_credential_exposure( client = vercel_module.VercelSandboxClient() manifest = _vercel_s3_manifest(package_module, credentials=True) - with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + with pytest.raises(MountConfigError, match="mount-scoped credentials") as exc: await client.create( manifest=manifest, options=vercel_module.VercelSandboxClientOptions(), @@ -914,6 +989,27 @@ async def test_vercel_create_requires_explicit_s3_credential_exposure( traceback = traceback.tb_next +@pytest.mark.asyncio +async def test_vercel_create_accepts_manifest_mount_scoped_acknowledgement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + client = vercel_module.VercelSandboxClient() + manifest = _vercel_s3_manifest( + package_module, + credentials=True, + ).with_in_container_mount_credential_exposure_acknowledged("remote") + + session = await client.create( + manifest=manifest, + options=vercel_module.VercelSandboxClientOptions(), + ) + + assert _FakeAsyncSandbox.create_calls + await client.delete(session) + + @pytest.mark.asyncio async def test_vercel_injected_session_accepts_unchanged_s3_manifest( monkeypatch: pytest.MonkeyPatch, @@ -1106,7 +1202,7 @@ async def test_vercel_credential_opt_in_does_not_allow_signed_endpoint_urls( mount = cast(S3Mount, manifest.entries["remote"]) mount.endpoint_url = "https://example.test?signature=endpoint-secret" - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="does not support exposing"): await vercel_module.VercelSandboxClient().create( manifest=manifest, options=vercel_module.VercelSandboxClientOptions( @@ -1179,7 +1275,7 @@ async def test_vercel_apply_manifest_uses_central_mount_validation( sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-central-validation") session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox) - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="mount-scoped credentials"): await session.apply_manifest() assert sandbox.run_command_calls == [] diff --git a/tests/sandbox/test_mount_security.py b/tests/sandbox/test_mount_security.py index 50057e0851..001189ca70 100644 --- a/tests/sandbox/test_mount_security.py +++ b/tests/sandbox/test_mount_security.py @@ -3,7 +3,7 @@ import asyncio import builtins import importlib -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any, ClassVar, Literal, cast import pytest @@ -29,6 +29,7 @@ AzureBlobMount, BaseEntry, BoxMount, + Dir, DockerVolumeMountStrategy, File, FuseMountPattern, @@ -223,7 +224,7 @@ def test_rejects_explicit_credentials_for_in_container_mounts() -> None: } ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + with pytest.raises(MountConfigError, match="mount-scoped credentials") as exc: validate_manifest_mount_credential_boundaries(manifest) assert exc.value.context["credential_fields"] == ( @@ -234,6 +235,509 @@ def test_rejects_explicit_credentials_for_in_container_mounts() -> None: assert "example-secret-key" not in repr(exc.value.context) +def test_exact_path_acknowledgement_allows_supported_mount_scoped_credentials() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentialed=True, + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + + validate_manifest_mount_credential_boundaries(manifest) + + sibling = manifest.model_copy(deep=True) + sibling.entries["other"] = sibling.entries.pop("data") + with pytest.raises(MountConfigError, match="mount-scoped credentials"): + validate_manifest_mount_credential_boundaries(sibling) + + mount = manifest.entries["data"] + assert isinstance(mount, S3Mount) + validate_mount_activation_credential_boundary( + mount, + mount.mount_strategy, + manifest=manifest, + mount_path="/workspace/data", + provider_backend_id="docker", + ) + with pytest.raises(MountConfigError, match="mount-scoped credentials"): + validate_mount_activation_credential_boundary( + mount, + mount.mount_strategy, + manifest=manifest, + mount_path="/workspace/other", + provider_backend_id="docker", + ) + + +@pytest.mark.parametrize( + ("credentials", "invalid_fields"), + [ + ({"access_key_id": "access-key"}, ("secret_access_key",)), + ({"secret_access_key": "secret-key"}, ("access_key_id",)), + ( + {"session_token": "session-token"}, + ("access_key_id", "secret_access_key"), + ), + ( + {"access_key_id": "access-key", "secret_access_key": ""}, + ("secret_access_key",), + ), + ( + {"access_key_id": " ", "secret_access_key": "secret-key"}, + ("access_key_id",), + ), + ( + { + "access_key_id": "access-key", + "secret_access_key": "secret-key", + "session_token": " ", + }, + ("session_token",), + ), + ], +) +def test_acknowledgement_rejects_incomplete_in_container_s3_credentials( + credentials: dict[str, str], + invalid_fields: tuple[str, ...], +) -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + **cast(Any, credentials), + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + + with pytest.raises(MountConfigError, match="complete non-empty credential set") as exc_info: + validate_manifest_mount_credential_boundaries(manifest) + + assert exc_info.value.context["credential_fields"] == invalid_fields + + +@pytest.mark.parametrize( + ("credentials", "invalid_fields"), + [ + ({"access_id": "access-id"}, ("secret_access_key",)), + ({"secret_access_key": "secret-key"}, ("access_id",)), + ( + {"access_id": "access-id", "secret_access_key": ""}, + ("secret_access_key",), + ), + ( + {"access_id": " ", "secret_access_key": "secret-key"}, + ("access_id",), + ), + ( + { + "access_id": "access-id", + "service_account_credentials": '{"type":"service_account"}', + }, + ("secret_access_key",), + ), + ], +) +def test_acknowledgement_rejects_incomplete_in_container_gcs_hmac_credentials( + credentials: dict[str, str], + invalid_fields: tuple[str, ...], +) -> None: + manifest = Manifest( + entries={ + "data": GCSMount( + bucket="example-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + **cast(Any, credentials), + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + + with pytest.raises(MountConfigError, match="complete non-empty credential set") as exc_info: + validate_manifest_mount_credential_boundaries(manifest) + + assert exc_info.value.context["credential_fields"] == invalid_fields + + +def test_acknowledgement_accepts_complete_in_container_gcs_hmac_credentials() -> None: + manifest = Manifest( + entries={ + "data": GCSMount( + bucket="example-bucket", + access_id="access-id", + secret_access_key="secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + + validate_manifest_mount_credential_boundaries(manifest) + + +@pytest.mark.parametrize("blank_value", ["", " "]) +@pytest.mark.parametrize( + ("mount_factory", "broad", "invalid_field"), + [ + ( + lambda value: GCSMount( + bucket="example-bucket", + access_token=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + False, + "access_token", + ), + ( + lambda value: GCSMount( + bucket="example-bucket", + service_account_credentials=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + False, + "service_account_credentials", + ), + ( + lambda value: GCSMount( + bucket="example-bucket", + service_account_file=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + True, + "service_account_file", + ), + ( + lambda value: AzureBlobMount( + account="example-account", + container="example-container", + account_key=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + False, + "account_key", + ), + ( + lambda value: AzureBlobMount( + account="example-account", + container="example-container", + identity_client_id=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + True, + "identity_client_id", + ), + ], +) +def test_acknowledgement_rejects_empty_in_container_scalar_authority( + mount_factory: Any, + broad: bool, + invalid_field: str, + blank_value: str, +) -> None: + manifest = Manifest(entries={"data": mount_factory(blank_value)}) + acknowledged = ( + manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") + if broad + else manifest.with_in_container_mount_credential_exposure_acknowledged("data") + ) + + with pytest.raises(MountConfigError, match="must not be empty or whitespace-only") as exc_info: + validate_manifest_mount_credential_boundaries(acknowledged) + + assert exc_info.value.context["credential_fields"] == (invalid_field,) + + +@pytest.mark.parametrize( + ("credentials", "invalid_fields"), + [ + ({"access_key_id": "access-key"}, ("secret_access_key",)), + ({"secret_access_key": "secret-key"}, ("access_key_id",)), + ( + {"access_key_id": "access-key", "secret_access_key": ""}, + ("secret_access_key",), + ), + ], +) +def test_acknowledgement_rejects_incomplete_in_container_r2_credentials( + credentials: dict[str, str], + invalid_fields: tuple[str, ...], +) -> None: + manifest = Manifest( + entries={ + "data": R2Mount( + bucket="example-bucket", + account_id="example-account", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + **cast(Any, credentials), + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + + with pytest.raises(MountConfigError, match="complete non-empty credential set") as exc_info: + validate_manifest_mount_credential_boundaries(manifest) + + assert exc_info.value.context["credential_fields"] == invalid_fields + + +@pytest.mark.parametrize( + "mount", + [ + S3Mount( + bucket="example-bucket", + access_key_id="access-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + GCSMount( + bucket="example-bucket", + access_id="access-id", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + R2Mount( + bucket="example-bucket", + account_id="example-account", + access_key_id="access-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + GCSMount( + bucket="example-bucket", + access_token="", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + AzureBlobMount( + account="example-account", + container="example-container", + identity_client_id=" ", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + ], +) +def test_incomplete_credentials_remain_external_provider_configuration(mount: Mount) -> None: + manifest = Manifest(entries={"data": mount}) + + validate_manifest_mount_credential_boundaries(manifest, provider_backend_id="docker") + + +def test_mount_credential_acknowledgement_is_not_a_path_prefix() -> None: + mount = _s3_mount( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentialed=True, + ) + manifest = Manifest( + entries={"parent": Dir(children={"data": mount})} + ).with_in_container_mount_credential_exposure_acknowledged("parent") + + with pytest.raises(MountConfigError, match="mount-scoped credentials"): + validate_manifest_mount_credential_boundaries(manifest) + + validate_manifest_mount_credential_boundaries( + manifest.with_in_container_mount_credential_exposure_acknowledged("parent/data") + ) + + +def test_mount_credential_acknowledgement_preserves_path_whitespace() -> None: + manifest = Manifest( + entries={ + "data ": _s3_mount( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentialed=True, + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + + with pytest.raises(MountConfigError, match="mount-scoped credentials"): + validate_manifest_mount_credential_boundaries(manifest) + + validate_manifest_mount_credential_boundaries( + manifest.with_in_container_mount_credential_exposure_acknowledged("data ") + ) + + +def test_mount_credential_acknowledgement_accepts_platform_path_objects() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentialed=True, + ) + } + ).with_in_container_mount_credential_exposure_acknowledged(PureWindowsPath("data")) + + mount = manifest.entries["data"] + assert isinstance(mount, S3Mount) + validate_mount_activation_credential_boundary( + mount, + mount.mount_strategy, + manifest=manifest, + mount_path=PureWindowsPath("/workspace/data"), + provider_backend_id="docker", + ) + assert not Manifest()._acknowledges_in_container_mount_credential_exposure( + PureWindowsPath("/workspace/data"), + "mount_scoped", + ) + + with pytest.raises(ValueError, match="use '/' separators"): + Manifest().with_in_container_mount_credential_exposure_acknowledged("data\\child") + + +@pytest.mark.parametrize( + "policy_key", + [ + "in_container_mount_credential_exposure_acknowledged_paths", + "_in_container_mount_credential_exposure_acknowledged_paths", + "inContainerMountCredentialExposureAcknowledgedPaths", + "in_container_mount_broad_credential_exposure_acknowledged_paths", + "_mount_credential_exposure_policy", + ], +) +def test_manifest_input_cannot_inject_mount_credential_acknowledgement(policy_key: str) -> None: + with pytest.raises(TypeError, match="trusted Manifest instance"): + Manifest.model_validate({policy_key: ["data"]}) + + +def test_manifest_acknowledgement_is_runtime_only_and_rejects_root() -> None: + manifest = Manifest().with_in_container_mount_credential_exposure_acknowledged("data") + payload = manifest.model_dump(mode="json") + + assert all("credential_exposure" not in key for key in payload) + restored = Manifest.model_validate(payload) + assert not restored._acknowledges_in_container_mount_credential_exposure( + "/workspace/data", "mount_scoped" + ) + with pytest.raises(ValueError, match="non-root path"): + Manifest().with_in_container_mount_credential_exposure_acknowledged("/workspace") + with pytest.raises(TypeError, match="At least one"): + Manifest().with_in_container_mount_credential_exposure_acknowledged() + + +@pytest.mark.parametrize( + "method_name", + [ + "with_in_container_mount_credential_exposure_acknowledged", + "with_in_container_mount_broad_credential_exposure_acknowledged", + ], +) +@pytest.mark.parametrize( + "path", + [ + "data/*", + "data?", + "data[0]", + "data/../other", + "/workspace/../outside", + ], +) +def test_manifest_acknowledgement_rejects_wildcard_and_parent_paths( + method_name: str, + path: str, +) -> None: + method = getattr(Manifest(), method_name) + + with pytest.raises(ValueError, match="wildcard syntax|parent segments"): + method(path) + + +def test_manifest_acknowledgement_rejects_custom_mount_before_deepcopy() -> None: + sentinel = "custom-mount-deepcopy-secret" + + class CustomS3Mount(S3Mount): + type: Literal["custom_deepcopy_s3_mount"] = "custom_deepcopy_s3_mount" # type: ignore[assignment] + deepcopy_called: ClassVar[bool] = False + + def __deepcopy__(self, memo: dict[int, Any] | None = None) -> CustomS3Mount: + _ = memo + type(self).deepcopy_called = True + raise RuntimeError(sentinel) + + manifest = Manifest( + entries={ + "data": CustomS3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="custom mount implementations") as exc_info: + manifest.with_in_container_mount_credential_exposure_acknowledged("data") + + assert CustomS3Mount.deepcopy_called is False + assert sentinel not in repr(exc_info.value) + + +def test_manifest_acknowledgement_redacts_custom_provenance_traceback_locals() -> None: + sentinel = "custom-provenance-traceback-secret" + + class CustomS3Mount(S3Mount): + type: Literal["custom_traceback_s3_mount"] = "custom_traceback_s3_mount" # type: ignore[assignment] + api_token: str | None = None + + class CustomInContainerStrategy(InContainerMountStrategy): + type: Literal["custom_traceback_strategy"] = "custom_traceback_strategy" # type: ignore[assignment] + api_token: str | None = None + + class CustomRclonePattern(RcloneMountPattern): + api_token: str | None = None + + cases = [ + ( + Manifest( + entries={ + "data": CustomS3Mount( + bucket="bucket", + api_token=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ), + "custom mount implementations", + ), + ( + Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=CustomInContainerStrategy( + api_token=sentinel, + pattern=RcloneMountPattern(), + ), + ) + } + ), + "custom mount strategies", + ), + ( + Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy( + pattern=CustomRclonePattern(api_token=sentinel) + ), + ) + } + ), + "custom mount patterns", + ), + ] + + for method_name in ( + "with_in_container_mount_credential_exposure_acknowledged", + "with_in_container_mount_broad_credential_exposure_acknowledged", + ): + for manifest, message in cases: + method = getattr(manifest, method_name) + with pytest.raises(MountConfigError, match=message) as exc: + method("data") + + traceback_cursor = exc.value.__traceback__ + while traceback_cursor is not None: + module_name = str(traceback_cursor.tb_frame.f_globals.get("__name__", "")) + if module_name.startswith("agents."): + assert sentinel not in repr(traceback_cursor.tb_frame.f_locals) + traceback_cursor = traceback_cursor.tb_next + + def test_builtin_mount_subclass_is_rejected_by_execution_provenance() -> None: class CustomS3Mount(S3Mount): type: Literal["custom_s3_mount"] = "custom_s3_mount" # type: ignore[assignment] @@ -638,7 +1142,7 @@ def test_preserves_credentialless_hosted_mount_strategies( assert isinstance(mount, S3Mount) mount.access_key_id = "example-access-key" mount.secret_access_key = "example-secret-key" - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="mount-scoped credentials"): validate_manifest_mount_credential_boundaries( credentialed, provider_backend_id=backend_id, @@ -869,7 +1373,7 @@ def test_ignores_environment_values_already_exposed_to_the_sandbox() -> None: validate_manifest_mount_credential_boundaries(manifest) -def test_rejects_credentialless_blobfuse_mounts() -> None: +def test_blobfuse_mounts_require_broad_acknowledgement() -> None: manifest = Manifest( entries={ "data": AzureBlobMount( @@ -880,11 +1384,40 @@ def test_rejects_credentialless_blobfuse_mounts() -> None: } ) - with pytest.raises(MountConfigError, match="credentialless blobfuse mounts"): + with pytest.raises(MountConfigError, match="broad credential authority"): validate_manifest_mount_credential_boundaries(manifest) + validate_manifest_mount_credential_boundaries( + manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") + ) + + +def test_blobfuse_account_key_requires_mount_scoped_and_broad_acknowledgement() -> None: + manifest = Manifest( + entries={ + "data": AzureBlobMount( + account="example", + container="private", + account_key="account-key", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ) + } + ) + + mount_scoped = manifest.with_in_container_mount_credential_exposure_acknowledged("data") + with pytest.raises(MountConfigError, match="broad credential authority"): + validate_manifest_mount_credential_boundaries(mount_scoped) + + broad = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") + with pytest.raises(MountConfigError, match="mount-scoped credentials"): + validate_manifest_mount_credential_boundaries(broad) + + validate_manifest_mount_credential_boundaries( + mount_scoped.with_in_container_mount_broad_credential_exposure_acknowledged("data") + ) -def test_rejects_s3_files_before_ambient_iam_can_be_used() -> None: + +def test_s3_files_require_broad_acknowledgement_before_ambient_iam_can_be_used() -> None: safe = Manifest( entries={ "data": S3FilesMount( @@ -894,9 +1427,13 @@ def test_rejects_s3_files_before_ambient_iam_can_be_used() -> None: ) } ) - with pytest.raises(MountConfigError, match="requires ambient IAM credentials"): + with pytest.raises(MountConfigError, match="broad credential authority"): validate_manifest_mount_credential_boundaries(safe) + validate_manifest_mount_credential_boundaries( + safe.with_in_container_mount_broad_credential_exposure_acknowledged("data") + ) + @pytest.mark.parametrize( "mount", @@ -919,17 +1456,13 @@ def test_rejects_credential_required_patterns_in_inherited_in_container_strategi validate_manifest_mount_credential_boundaries(Manifest(entries={"data": mount})) -def test_trusted_opt_in_requires_a_matching_provider_owned_strategy() -> None: +def test_acknowledgement_requires_a_matching_provider_owned_strategy() -> None: strategy = InContainerMountStrategy(pattern=RcloneMountPattern()) cast(Any, strategy).type = "vercel_cloud_bucket" manifest = Manifest(entries={"data": _s3_mount(strategy=strategy, credentialed=True)}) with pytest.raises(MountConfigError, match="custom mount strategies"): - validate_manifest_mount_credential_boundaries( - manifest, - allowed_in_container_credential_strategy_types=frozenset({"vercel_cloud_bucket"}), - provider_backend_id="vercel", - ) + manifest.with_in_container_mount_credential_exposure_acknowledged("data") @pytest.mark.parametrize( @@ -953,7 +1486,7 @@ def test_rejects_rclone_credential_source_overrides(extra_args: list[str]) -> No } ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="does not support exposing"): validate_manifest_mount_credential_boundaries(manifest) @@ -964,18 +1497,177 @@ def test_rejects_rclone_credential_source_overrides(extra_args: list[str]) -> No (DaytonaCloudBucketMountStrategy(), "daytona"), ], ) -def test_rejects_box_mounts_that_execute_inside_the_sandbox( +def test_box_mounts_with_direct_credentials_require_exact_acknowledgement( strategy: MountStrategyBase, backend_id: str | None, ) -> None: - manifest = Manifest(entries={"data": BoxMount(mount_strategy=strategy)}) + manifest = Manifest( + entries={ + "data": BoxMount( + access_token="box-access-token", + mount_strategy=strategy, + ) + } + ) - with pytest.raises(MountConfigError, match="Box mounts require credentials"): + with pytest.raises(MountConfigError, match="mount-scoped credentials"): validate_manifest_mount_credential_boundaries( manifest, provider_backend_id=backend_id, ) + validate_manifest_mount_credential_boundaries( + manifest.with_in_container_mount_credential_exposure_acknowledged("data"), + provider_backend_id=backend_id, + ) + + +def test_box_config_file_requires_broad_acknowledgement() -> None: + manifest = Manifest( + entries={ + "data": BoxMount( + box_config_file="/run/secrets/box.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="broad credential authority"): + validate_manifest_mount_credential_boundaries(manifest) + with pytest.raises(MountConfigError, match="broad credential authority"): + validate_manifest_mount_credential_boundaries( + manifest.with_in_container_mount_credential_exposure_acknowledged("data") + ) + validate_manifest_mount_credential_boundaries( + manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") + ) + + +@pytest.mark.parametrize( + "mount", + [ + BoxMount(mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern())), + BoxMount( + client_id="client-id", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + BoxMount( + client_secret="client-secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ], +) +def test_box_in_container_mount_requires_non_interactive_authentication( + mount: BoxMount, +) -> None: + manifest = Manifest(entries={"data": mount}) + + with pytest.raises(MountConfigError, match="non-interactive authentication source"): + validate_manifest_mount_credential_boundaries(manifest) + + +@pytest.mark.parametrize( + ("mount", "broad"), + [ + ( + BoxMount( + access_token=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + False, + ) + for value in ("", " ") + ] + + [ + ( + BoxMount( + token=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + False, + ) + for value in ("", " ") + ] + + [ + ( + BoxMount( + config_credentials=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + False, + ) + for value in ("", " ") + ] + + [ + ( + BoxMount( + box_config_file=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + True, + ) + for value in ("", " ") + ], +) +def test_box_in_container_mount_rejects_empty_authentication_sources( + mount: BoxMount, + broad: bool, +) -> None: + manifest = Manifest(entries={"data": mount}) + acknowledged = ( + manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") + if broad + else manifest.with_in_container_mount_credential_exposure_acknowledged("data") + ) + + with pytest.raises(MountConfigError, match="authentication values must not be empty"): + validate_manifest_mount_credential_boundaries(acknowledged) + + +@pytest.mark.parametrize( + ("mount", "broad", "invalid_field"), + [ + ( + BoxMount( + access_token="box-access-token", + box_config_file=value, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + False, + "box_config_file", + ) + for value in ("", " ") + ] + + [ + ( + BoxMount( + access_token=value, + box_config_file="/run/secrets/box.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + True, + "access_token", + ) + for value in ("", " ") + ], +) +def test_box_in_container_mount_rejects_mixed_usable_and_empty_authentication_sources( + mount: BoxMount, + broad: bool, + invalid_field: str, +) -> None: + manifest = Manifest(entries={"data": mount}) + acknowledged = ( + manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") + if broad + else manifest.with_in_container_mount_credential_exposure_acknowledged("data") + ) + + with pytest.raises(MountConfigError, match="authentication values must not be empty") as exc: + validate_manifest_mount_credential_boundaries(acknowledged) + + assert exc.value.context["credential_fields"] == (invalid_field,) + def test_preserves_box_mounts_with_an_external_strategy() -> None: manifest = Manifest( @@ -1172,7 +1864,7 @@ def test_rejects_rclone_on_the_fly_remote_name() -> None: } ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + with pytest.raises(MountConfigError, match="does not support exposing") as exc: validate_manifest_mount_credential_boundaries(manifest) assert sentinel not in str(exc.value) @@ -1273,7 +1965,7 @@ def test_rejects_malformed_inline_credential_url_without_mutating_trusted_manife } ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="does not support exposing"): validate_manifest_mount_credential_boundaries(manifest) mount = manifest.entries["data"] @@ -1303,7 +1995,7 @@ def test_rejects_mountpoint_endpoint_authority(endpoint_url: str) -> None: } ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + with pytest.raises(MountConfigError, match="does not support exposing") as exc: validate_manifest_mount_credential_boundaries(manifest) assert exc.value.context["credential_fields"] == ( @@ -1351,6 +2043,23 @@ def test_rejects_manifest_backed_credential_files( ) +def test_broad_acknowledgement_does_not_allow_manifest_backed_rclone_config() -> None: + manifest = Manifest( + entries={ + "credentials.conf": File(content=b"credential-file-secret"), + "data": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("credentials.conf")) + ), + ), + } + ).with_in_container_mount_broad_credential_exposure_acknowledged("data") + + with pytest.raises(MountConfigError, match="credential files stored in the manifest"): + validate_manifest_mount_credential_boundaries(manifest) + + @pytest.mark.parametrize( ("credential_path", "source"), [ @@ -1729,6 +2438,82 @@ def test_opaque_external_authority_remains_resumable_through_trusted_rebind( assert rebound.mount_authority_redacted is False +def test_in_container_acknowledgement_is_rebound_only_from_trusted_manifest() -> None: + manifest = Manifest( + entries={ + "data": _s3_mount( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentialed=True, + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + client = _SecurityTestClient() + state = TestSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + + payload = client.serialize_session_state(state) + restored = client.deserialize_session_state(payload) + + assert "credential_exposure" not in repr(payload) + with pytest.raises(ValueError, match="cannot be resumed"): + restored.assert_path_grants_rebound() + + rebound = restored.rebind_persisted_mount_authority( + manifest, + provider_backend_id="docker", + ) + validate_manifest_mount_credential_boundaries( + rebound.manifest, + provider_backend_id="docker", + ) + assert rebound.manifest._acknowledges_in_container_mount_credential_exposure( + "/workspace/data", + "mount_scoped", + ) + + +@pytest.mark.parametrize( + "mount", + [ + AzureBlobMount( + account="example", + container="private", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ), + S3FilesMount( + file_system_id="fs-123", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ), + ], +) +def test_implicit_broad_authority_is_rebound_only_from_trusted_manifest( + mount: Mount, +) -> None: + manifest = Manifest( + entries={"data": mount} + ).with_in_container_mount_broad_credential_exposure_acknowledged("data") + client = _SecurityTestClient() + state = TestSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + + payload = client.serialize_session_state(state) + restored = client.deserialize_session_state(payload) + + assert payload[REDACTED_MOUNT_AUTHORITY_KEY] is True + assert "credential_exposure" not in repr(payload) + assert restored.mount_authority_redacted is True + rebound = restored.rebind_persisted_mount_authority( + manifest, + provider_backend_id="docker", + ) + validate_manifest_mount_credential_boundaries( + rebound.manifest, + provider_backend_id="docker", + ) + assert rebound.manifest._acknowledges_in_container_mount_credential_exposure( + "/workspace/data", + "broad", + ) + + def test_mount_authority_rebind_requires_exact_credential_free_topology() -> None: original = Manifest( entries={ @@ -2102,6 +2887,51 @@ async def fail(*, manifest: Manifest) -> None: traceback = traceback.tb_next +@pytest.mark.parametrize( + "mount", + [ + AzureBlobMount( + account="example", + container="private", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ), + S3FilesMount( + file_system_id="fs-123", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ), + ], +) +@pytest.mark.asyncio +async def test_operation_error_with_implicit_broad_authority_is_replaced( + mount: Mount, +) -> None: + sentinel = "implicit-broad-provider-secret" + manifest = Manifest( + entries={"data": mount} + ).with_in_container_mount_broad_credential_exposure_acknowledged("data") + provider_error = RuntimeError(sentinel) + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise provider_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + await fail(manifest=manifest) + + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + assert provider_error.args == () + assert provider_error.__traceback__ is None + traceback = exc.value.__traceback__ + while traceback is not None: + frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + def test_sync_operation_error_with_mount_authority_clears_source_arguments() -> None: sentinel = "sync-provider-operation-secret" manifest = Manifest( @@ -2139,6 +2969,44 @@ def fail(*, manifest: Manifest) -> None: ) +@pytest.mark.parametrize( + "mount", + [ + AzureBlobMount( + account="example", + container="private", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ), + S3FilesMount( + file_system_id="fs-123", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ), + ], +) +def test_sync_operation_error_with_implicit_broad_authority_is_replaced( + mount: Mount, +) -> None: + sentinel = "sync-implicit-broad-provider-secret" + manifest = Manifest( + entries={"data": mount} + ).with_in_container_mount_broad_credential_exposure_acknowledged("data") + provider_error = RuntimeError(sentinel) + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise provider_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + fail(manifest=manifest) + + assert sentinel not in str(exc.value) + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + assert provider_error.args == () + assert provider_error.__traceback__ is None + + @pytest.mark.asyncio async def test_operation_error_with_read_only_provider_attributes_is_replaced() -> None: sentinel = "read-only-provider-secret" diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index 032115fc95..276c829b80 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -309,6 +309,30 @@ async def test_azure_blob_mount_builds_rclone_runtime_config_without_hidden_patt assert unmount_config.config_text is None +@pytest.mark.asyncio +async def test_azure_blob_mount_enables_rclone_msi_for_managed_identity() -> None: + session = _MountConfigSession() + pattern = RcloneMountPattern() + mount = AzureBlobMount( + account="acct", + container="container", + identity_client_id="managed-identity-client-id", + mount_strategy=InContainerMountStrategy(pattern=pattern), + ) + + config = await mount.build_in_container_mount_config( + session, + pattern, + include_config_text=True, + ) + + assert isinstance(config, RcloneMountConfig) + assert config.config_text is not None + assert "use_msi = true" in config.config_text + assert "msi_client_id = managed-identity-client-id" in config.config_text + assert "use_msi = false" not in config.config_text + + @pytest.mark.asyncio async def test_box_mount_builds_rclone_runtime_config_with_box_auth_options() -> None: session_id = uuid.uuid4() diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 52ea029ffa..161fd5ecbd 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -43,7 +43,10 @@ SandboxRunConfig, User, ) -from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY +from agents.sandbox._mount_security import ( + REDACTED_MOUNT_AUTHORITY_KEY, + validate_manifest_mount_credential_boundaries, +) from agents.sandbox.capabilities import ( Capability, Compaction, @@ -736,7 +739,7 @@ async def test_sandbox_session_rejects_unsafe_manifest_before_workspace_persiste ) session = SandboxSession(inner) - with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + with pytest.raises(MountConfigError, match="mount-scoped credentials cannot be exposed") as exc: if operation == "persist": await session.persist_workspace() else: @@ -1131,6 +1134,110 @@ def process_manifest(self, manifest: Manifest) -> Manifest: return manifest +class _ManifestReplacementCapability(Capability): + type: str = "manifest-replacement" + + def __init__(self) -> None: + super().__init__(type="manifest-replacement") + + def process_manifest(self, manifest: Manifest) -> Manifest: + return Manifest( + version=manifest.version, + root=manifest.root, + entries={**manifest.entries, "cap.txt": File(content=b"capability")}, + environment=manifest.environment.model_copy(deep=True), + users=[user.model_copy(deep=True) for user in manifest.users], + groups=[group.model_copy(deep=True) for group in manifest.groups], + extra_path_grants=tuple( + grant.model_copy(deep=True) for grant in manifest.extra_path_grants + ), + remote_mount_command_allowlist=list(manifest.remote_mount_command_allowlist), + ) + + +class _ManifestRootReplacementCapability(_ManifestReplacementCapability): + type: str = "manifest-root-replacement" + + def __init__(self) -> None: + Capability.__init__(self, type="manifest-root-replacement") + + def process_manifest(self, manifest: Manifest) -> Manifest: + replaced = super().process_manifest(manifest) + replaced.root = "/other" + return replaced + + +def test_process_manifest_preserves_mount_acknowledgement_across_replacement() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).with_in_container_mount_credential_exposure_acknowledged("data") + + processed = SandboxRuntimeSessionManager._process_manifest( + [_ManifestReplacementCapability()], + manifest, + ) + + assert processed is not None + assert processed.entries["cap.txt"] == File(content=b"capability") + validate_manifest_mount_credential_boundaries(processed) + assert processed._acknowledges_in_container_mount_credential_exposure( + "/workspace/data", + "mount_scoped", + ) + + +@pytest.mark.parametrize( + ("acknowledged_path", "expected_at_replacement_root"), + [("/workspace/data", False), ("data", True)], +) +def test_process_manifest_preserves_absolute_or_relative_acknowledgement_identity( + acknowledged_path: str, + expected_at_replacement_root: bool, +) -> None: + manifest = Manifest( + root="/workspace", + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + }, + ).with_in_container_mount_credential_exposure_acknowledged(acknowledged_path) + + processed = SandboxRuntimeSessionManager._process_manifest( + [_ManifestRootReplacementCapability()], + manifest, + ) + + assert processed is not None + assert processed.root == "/other" + assert ( + processed._acknowledges_in_container_mount_credential_exposure( + "/other/data", + "mount_scoped", + ) + is expected_at_replacement_root + ) + if expected_at_replacement_root: + validate_manifest_mount_credential_boundaries(processed) + else: + assert processed._acknowledges_in_container_mount_credential_exposure( + "/workspace/data", + "mount_scoped", + ) + with pytest.raises(MountConfigError, match="mount-scoped credentials"): + validate_manifest_mount_credential_boundaries(processed) + + class _CredentialedMountCapability(Capability): type: str = "credentialed-mount" @@ -4108,7 +4215,7 @@ async def test_session_manager_rejects_unsafe_stopped_injected_session_manifest( ) manager.acquire_agent(agent) - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="mount-scoped credentials cannot be exposed"): await manager.ensure_session( agent=agent, capabilities=capabilities, @@ -5369,7 +5476,7 @@ async def test_apply_manifest_rejects_mount_authority_before_materialization() - ) ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported") as exc: + with pytest.raises(MountConfigError, match="mount-scoped credentials cannot be exposed") as exc: await session.apply_manifest() assert session.materialize_calls == 0 @@ -5396,7 +5503,7 @@ async def test_start_workspace_rejects_mount_authority_before_materialization() ) ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="mount-scoped credentials cannot be exposed"): await BaseSandboxSession.start(session) assert session.materialize_calls == 0 @@ -5476,7 +5583,7 @@ async def test_session_stop_rejects_mutated_unsafe_mount_before_snapshot_work() ) ) - with pytest.raises(MountConfigError, match="cloud credentials are not supported"): + with pytest.raises(MountConfigError, match="mount-scoped credentials cannot be exposed"): await BaseSandboxSession.stop(session) assert session.persist_calls == 0 From 7bf73afa47ac48c1efb599d0b1505cee994e74f5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 20:59:26 +0900 Subject: [PATCH 255/473] feat: add durable pending input to RunState (#4325) --- src/agents/__init__.py | 2 + src/agents/items.py | 17 +- src/agents/result.py | 10 + src/agents/run.py | 119 ++- .../run_internal/agent_runner_helpers.py | 16 +- src/agents/run_internal/oai_conversation.py | 168 ++- src/agents/run_internal/run_loop.py | 157 ++- src/agents/run_internal/run_steps.py | 6 + .../run_internal/session_persistence.py | 118 ++- src/agents/run_internal/tool_actions.py | 4 + src/agents/run_internal/turn_resolution.py | 55 +- src/agents/run_state.py | 145 ++- tests/test_run_state_pending_input.py | 965 ++++++++++++++++++ 13 files changed, 1719 insertions(+), 63 deletions(-) create mode 100644 tests/test_run_state_pending_input.py diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 8eeb38b203..3105b4aa56 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -56,6 +56,7 @@ CompactionItem, HandoffCallItem, HandoffOutputItem, + InputItem, ItemHelpers, MCPApprovalRequestItem, MCPApprovalResponseItem, @@ -434,6 +435,7 @@ def enable_verbose_stdout_logging() -> None: "HandoffInputData", "HandoffInputFilter", "TResponseInputItem", + "InputItem", "MessageOutputItem", "ModelResponse", "RunItem", diff --git a/src/agents/items.py b/src/agents/items.py index 115be2a639..f3d2d1a464 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -6,6 +6,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, TypeVar, cast +from uuid import uuid4 import pydantic from openai.types.responses import ( @@ -157,6 +158,19 @@ def to_input_item(self) -> TResponseInputItem: raise AgentsException(f"Unexpected raw item type: {type(self.raw_item)}") +@dataclass +class InputItem(RunItemBase[TResponseInputItem]): + """Represents input admitted while resuming a run.""" + + raw_item: TResponseInputItem + """The normalized input item admitted before the next model call.""" + + type: Literal["input_item"] = "input_item" + + input_id: str = field(default_factory=lambda: uuid4().hex) + """A durable occurrence identifier used for exactly-once conversation tracking.""" + + @dataclass class MessageOutputItem(RunItemBase[ResponseOutputMessage]): """Represents a message from the LLM.""" @@ -669,7 +683,8 @@ def to_input_item(self) -> TResponseInputItem: RunItem: TypeAlias = ( - MessageOutputItem + InputItem + | MessageOutputItem | ToolSearchCallItem | ToolSearchOutputItem | HandoffCallItem diff --git a/src/agents/result.py b/src/agents/result.py index 6fa06a331a..fc5a938f78 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -146,8 +146,12 @@ def _populate_state_from_result( source_state = getattr(result, "_state", None) if isinstance(source_state, RunState): state._generated_prompt_cache_key = source_state._generated_prompt_cache_key + state._pending_input = copy.deepcopy(source_state._pending_input) + state._current_step = source_state._current_step else: state._generated_prompt_cache_key = getattr(result, "_generated_prompt_cache_key", None) + state._pending_input = copy.deepcopy(getattr(result, "_pending_input_for_state", [])) + state._current_step = getattr(result, "_current_step_for_state", None) state._reasoning_item_id_policy = getattr(result, "_reasoning_item_id_policy", None) interruptions = list(getattr(result, "interruptions", [])) @@ -299,6 +303,12 @@ class RunResultBase(abc.ABC): """Root agent graph used when converting the result back into RunState.""" _generated_prompt_cache_key: str | None = field(default=None, init=False, repr=False) """SDK-generated prompt cache key captured during the run.""" + _pending_input_for_state: list[TResponseInputItem] = field( + default_factory=list, init=False, repr=False + ) + """Pending input preserved when a non-streaming result is converted back to RunState.""" + _current_step_for_state: Any = field(default=None, init=False, repr=False) + """Current step preserved when a non-streaming result is converted back to RunState.""" @classmethod def __get_pydantic_core_schema__( diff --git a/src/agents/run.py b/src/agents/run.py index ca39410856..c2c4839fb3 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -27,7 +27,9 @@ OutputGuardrailResult, ) from .items import ( + InputItem, ItemHelpers, + ModelResponse, RunItem, TResponseInputItem, ) @@ -110,9 +112,12 @@ NextStepHandoff, NextStepInterruption, NextStepRunAgain, + ProcessedResponse, ) from .run_internal.session_persistence import ( _session_get_items, + admit_pending_input, + commit_server_pending_input, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -775,6 +780,8 @@ def _finalize_result(result: RunResult) -> RunResult: finalized_result._generated_prompt_cache_key = ( run_state._generated_prompt_cache_key ) + finalized_result._pending_input_for_state = run_state.pending_input + finalized_result._current_step_for_state = run_state._current_step finalized_result._nested_history_owned_session_item_refs = list( run_state._nested_history_owned_session_item_refs ) @@ -782,9 +789,44 @@ def _finalize_result(result: RunResult) -> RunResult: return finalized_result pending_server_items: list[RunItem] | None = None + pending_input_admission_items: list[InputItem] = [] input_guardrail_results: list[InputGuardrailResult] = ( list(run_state._input_guardrail_results) if run_state is not None else [] ) + input_guardrail_attempt_start = len(input_guardrail_results) + + def _attempt_input_guardrail_results() -> list[InputGuardrailResult]: + return input_guardrail_results[input_guardrail_attempt_start:] + + def _commit_pending_server_response( + model_response: ModelResponse, + processed_response: ProcessedResponse | None, + ) -> bool: + if ( + run_state is None + or server_conversation_tracker is None + or not pending_input_admission_items + ): + return False + return commit_server_pending_input( + run_state=run_state, + tracker=server_conversation_tracker, + admission_items=pending_input_admission_items, + generated_items=generated_items, + session_items=session_items, + model_response=model_response, + processed_response=processed_response, + current_turn=current_turn, + ) + + def _mark_response_hooks_started() -> None: + if run_state is None or not isinstance( + run_state._current_step, NextStepInterruption + ): + return + if run_state._current_step.response_accepted: + run_state._current_step.llm_end_hooks_started = True + # Output guardrails run once, at the end of the run. Accumulate their results # here so the failure handler below can report them on the raised exception. output_guardrail_results: list[OutputGuardrailResult] = [] @@ -946,11 +988,15 @@ def _finalize_result(result: RunResult) -> RunResult: if run_state is not None and run_state._current_step is not None: if isinstance(run_state._current_step, NextStepInterruption): logger.debug("Continuing from interruption") - if ( - not run_state._model_responses - or not run_state._last_processed_response - ): + if not run_state._model_responses: raise UserError("No model response found in previous state") + if run_state._last_processed_response is None: + if run_state._current_step.response_accepted: + raise UserError( + "An accepted model response could not be processed; " + "start a new run instead of retrying it" + ) + raise UserError("No processed response found in previous state") turn_result = await resolve_interrupted_turn( bindings=current_bindings, @@ -963,6 +1009,7 @@ def _finalize_result(result: RunResult) -> RunResult: run_config=run_config, server_manages_conversation=server_conversation_tracker is not None, run_state=run_state, + error_handlers=error_handlers, ) if run_state._last_processed_response is not None: @@ -1129,6 +1176,7 @@ def _finalize_result(result: RunResult) -> RunResult: wrapper=context_wrapper, ) result._original_input = copy_input_items(original_input) + run_state._current_step = None return _finalize_result(result) elif isinstance(turn_result.next_step, NextStepHandoff): current_agent = cast( @@ -1148,7 +1196,42 @@ def _finalize_result(result: RunResult) -> RunResult: if run_state is not None: if run_state._current_step is None: - run_state._current_step = NextStepRunAgain() # type: ignore[assignment] + run_state._current_step = NextStepRunAgain() + + pending_input = run_state.pending_input + if pending_input: + pending_guardrails = current_agent.input_guardrails + ( + run_config.input_guardrails or [] + ) + try: + await run_input_guardrails( + current_agent, + pending_guardrails, + pending_input, + context_wrapper, + input_guardrail_results, + ) + finally: + run_state._input_guardrail_results = list(input_guardrail_results) + + admission_items = await admit_pending_input( + run_state=run_state, + agent=current_agent, + session=session, + server_conversation_tracker=server_conversation_tracker, + store=store_setting, + wrapper=context_wrapper, + ) + generated_items.extend(admission_items) + session_items.extend(admission_items) + if pending_server_items is not None: + pending_server_items.extend(admission_items) + pending_input_admission_items = [ + item for item in admission_items if isinstance(item, InputItem) + ] + if not run_state._pending_input: + run_state._generated_items = list(generated_items) + run_state._session_items = list(session_items) all_tools = await get_all_tools(execution_agent, context_wrapper) all_tools = await initialize_computer_tools( tools=all_tools, context_wrapper=context_wrapper @@ -1343,6 +1426,9 @@ def _finalize_result(result: RunResult) -> RunResult: prompt_cache_key_resolver=prompt_cache_key_resolver, error_handlers=error_handlers, agent_span=current_span, + on_response_accepted=_commit_pending_server_response, + on_response_hooks_started=_mark_response_hooks_started, + run_state=run_state, ) ) @@ -1415,6 +1501,9 @@ def _finalize_result(result: RunResult) -> RunResult: prompt_cache_key_resolver=prompt_cache_key_resolver, error_handlers=error_handlers, agent_span=current_span, + on_response_accepted=_commit_pending_server_response, + on_response_hooks_started=_mark_response_hooks_started, + run_state=run_state, ) finally: if current_turn_span is not None: @@ -1436,6 +1525,14 @@ def _finalize_result(result: RunResult) -> RunResult: # Accumulate unfiltered items for observability. turn_session_items = session_items_for_turn(turn_result) session_items.extend(turn_session_items) + if pending_input_admission_items and run_state is not None: + run_state._generated_items = list(generated_items) + run_state._session_items = list(session_items) + run_state._model_responses = list(model_responses) + run_state._last_processed_response = turn_result.processed_response + run_state._current_turn = current_turn + run_state._mark_generated_items_merged_with_last_processed() + pending_input_admission_items = [] if run_state is not None and turn_result.nested_history_owned_items is not None: run_state._nested_history_owned_session_item_refs = ( reconcile_nested_history_owned_session_item_refs( @@ -1538,7 +1635,7 @@ def _finalize_result(result: RunResult) -> RunResult: session=session, run_state=run_state, session_persistence_enabled=session_persistence_enabled, - input_guardrail_results=input_guardrail_results, + input_guardrail_results=_attempt_input_guardrail_results(), items=_retained_items_for_blocked_output(items_to_save_turn), response_id=turn_result.model_response.response_id, store=store_setting, @@ -1552,7 +1649,7 @@ def _finalize_result(result: RunResult) -> RunResult: session=session, run_state=run_state, session_persistence_enabled=session_persistence_enabled, - input_guardrail_results=input_guardrail_results, + input_guardrail_results=_attempt_input_guardrail_results(), items=items_to_save_turn, response_id=turn_result.model_response.response_id, store=store_setting, @@ -1564,7 +1661,7 @@ def _finalize_result(result: RunResult) -> RunResult: session=session, run_state=run_state, session_persistence_enabled=session_persistence_enabled, - input_guardrail_results=input_guardrail_results, + input_guardrail_results=_attempt_input_guardrail_results(), items=items_to_save_turn, response_id=turn_result.model_response.response_id, store=store_setting, @@ -1600,10 +1697,14 @@ def _finalize_result(result: RunResult) -> RunResult: run_state._current_turn_persisted_item_count ) result._original_input = copy_input_items(original_input) + if run_state is not None: + run_state._current_step = None return _finalize_result(result) elif isinstance(turn_result.next_step, NextStepInterruption): if session_persistence_enabled: - if not input_guardrails_triggered(input_guardrail_results): + if not input_guardrails_triggered( + _attempt_input_guardrail_results() + ): # Persist session items but skip approval placeholders. input_items_for_save_interruption: list[TResponseInputItem] = ( session_input_items_for_persistence diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index a8b65e57d6..f4dca6b1ac 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -202,8 +202,20 @@ def _extract_tool_call_id(raw: Any) -> str | None: def get_unsent_tool_call_ids_for_interrupted_state(run_state: RunState[Any] | None) -> set[str]: - """Return tool call IDs whose local outputs belong to the current interruption.""" - if run_state is None or not isinstance(run_state._current_step, NextStepInterruption): + """Return tool call IDs whose local outputs have not reached a server conversation.""" + if run_state is None: + return set() + + if isinstance(run_state._current_step, NextStepRunAgain): + if not run_state._model_responses: + return set() + return { + call_id + for item in run_state._model_responses[-1].output + if (call_id := _extract_tool_call_id(item)) is not None + } + + if not isinstance(run_state._current_step, NextStepInterruption): return set() processed_response = run_state._last_processed_response diff --git a/src/agents/run_internal/oai_conversation.py b/src/agents/run_internal/oai_conversation.py index 38fa5a7d38..1d00cc79a4 100644 --- a/src/agents/run_internal/oai_conversation.py +++ b/src/agents/run_internal/oai_conversation.py @@ -9,7 +9,9 @@ from dataclasses import dataclass, field from typing import Any, cast +from ..exceptions import UserError from ..items import ( + InputItem, ItemHelpers, ModelResponse, RunItem, @@ -114,7 +116,8 @@ def _untrack_object(items: list[Any], candidate: Any) -> None: return -_PreparedItemSource = tuple[TResponseInputItem, TResponseInputItem] +_PreparedItemSource = tuple[TResponseInputItem, TResponseInputItem, str | None] +_PreparedSourceIdentity = tuple[TResponseInputItem, str | None] @dataclass @@ -145,8 +148,9 @@ class OpenAIServerConversationTracker: server_tool_call_ids: set[str] = field(default_factory=set) server_output_fingerprints: set[str] = field(default_factory=set) - # Content-based dedupe for resume/retry paths where objects are reconstructed. + # Durable occurrence and content dedupe for resume/retry paths where objects are rebuilt. sent_item_fingerprints: set[str] = field(default_factory=set) + accepted_input_item_ids: set[str] = field(default_factory=set) restored_anonymous_tool_search_fingerprints: set[str] = field(default_factory=set) sent_initial_input: bool = False remaining_initial_input: list[TResponseInputItem] | None = None @@ -157,9 +161,10 @@ class OpenAIServerConversationTracker: # mark_input_as_sent() can mark the right object identities after the model call succeeds. # Keep the prepared item alive so its object ID cannot be reused before the input is marked. prepared_item_sources: dict[int, _PreparedItemSource] = field(default_factory=dict) - prepared_item_sources_by_fingerprint: dict[str, list[TResponseInputItem]] = field( + prepared_item_sources_by_fingerprint: dict[str, list[_PreparedSourceIdentity]] = field( default_factory=dict ) + delivered_item_sources: dict[int, _PreparedItemSource] = field(default_factory=dict) def __post_init__(self): """Log initial tracker state to make conversation resume behavior debuggable.""" @@ -263,6 +268,7 @@ def hydrate_from_state( if raw_item is None: continue is_tool_call_item = run_item.type in {"tool_call_item", "handoff_call_item"} + is_input_item = isinstance(run_item, InputItem) is_tool_search_item = run_item.type in { "tool_search_call_item", "tool_search_output_item", @@ -282,6 +288,7 @@ def hydrate_from_state( should_mark = ( item_id is not None or (has_call_id and (has_output_payload or is_tool_call_item)) + or is_input_item or is_tool_search_item ) if not should_mark: @@ -289,7 +296,9 @@ def hydrate_from_state( _track_object_once(self.sent_items, raw_item) fp = _fingerprint_for_tracker(raw_item) - if fp: + if is_input_item: + self.accepted_input_item_ids.add(run_item.input_id) + elif fp: self.sent_item_fingerprints.add(fp) if is_tool_search_item: self.server_output_fingerprints.add(fp) @@ -315,6 +324,7 @@ def hydrate_from_state( should_mark = ( item_id is not None or (has_call_id and (has_output_payload or is_tool_call_item)) + or is_input_item or is_tool_search_item ) if not should_mark: @@ -322,7 +332,9 @@ def hydrate_from_state( _track_object_once(self.sent_items, raw_item) fp = _fingerprint_for_tracker(raw_item) - if fp: + if is_input_item: + self.accepted_input_item_ids.add(run_item.input_id) + elif fp: self.sent_item_fingerprints.add(fp) if is_tool_search_item: self.server_output_fingerprints.add(fp) @@ -389,7 +401,7 @@ def mark_input_as_sent(self, items: Sequence[TResponseInputItem]) -> None: for item in items: if item is None: continue - source_item = self._consume_prepared_item_source(item) + source_item, input_id = self._delivery_source(item) if _is_tracked_object(delivered_sources, source_item): continue delivered_sources.append(source_item) @@ -397,7 +409,8 @@ def mark_input_as_sent(self, items: Sequence[TResponseInputItem]) -> None: fp = _fingerprint_for_tracker(source_item) if fp: delivered_by_content.add(fp) - self.sent_item_fingerprints.add(fp) + if input_id is None: + self.sent_item_fingerprints.add(fp) if not self.remaining_initial_input: return @@ -413,6 +426,72 @@ def mark_input_as_sent(self, items: Sequence[TResponseInputItem]) -> None: self.remaining_initial_input = remaining or None + def validate_pending_input_filter(self, items: Sequence[TResponseInputItem]) -> None: + """Reject filter rewrites whose pending-input lineage cannot be determined safely.""" + pending_ids = { + input_id + for _prepared_item, _source_item, input_id in self.prepared_item_sources.values() + if input_id is not None + } + if not pending_ids: + return + + sources_by_fingerprint = { + fingerprint: list(sources) + for fingerprint, sources in self.prepared_item_sources_by_fingerprint.items() + } + filtered_counts_by_fingerprint: dict[str, int] = {} + for item in items: + fingerprint = _fingerprint_for_tracker(item) + if fingerprint: + filtered_counts_by_fingerprint[fingerprint] = ( + filtered_counts_by_fingerprint.get(fingerprint, 0) + 1 + ) + + matched_pending_ids: set[str] = set() + unmatched_filtered_item = False + for item in items: + direct_entry = self.prepared_item_sources.get(id(item)) + source: _PreparedSourceIdentity | None = None + if direct_entry is not None and direct_entry[0] is item: + source = (direct_entry[1], direct_entry[2]) + + fingerprint = _fingerprint_for_tracker(item) + candidates = ( + sources_by_fingerprint.get(fingerprint, []) if fingerprint is not None else [] + ) + if source is not None: + for index, candidate in enumerate(candidates): + if candidate[0] is source[0] and candidate[1] == source[1]: + candidates.pop(index) + break + elif candidates: + candidate_input_ids = {candidate[1] for candidate in candidates} + if ( + None in candidate_input_ids + and len(candidate_input_ids) > 1 + and fingerprint is not None + and filtered_counts_by_fingerprint.get(fingerprint, 0) < len(candidates) + ): + raise UserError( + "call_model_input_filter cannot safely associate a reconstructed item " + "with pending RunState input. Preserve the input item object, return an " + "unchanged copy, or omit the pending item." + ) + source = candidates.pop(0) + else: + unmatched_filtered_item = True + + if source is not None and source[1] is not None: + matched_pending_ids.add(source[1]) + + if pending_ids - matched_pending_ids and unmatched_filtered_item: + raise UserError( + "call_model_input_filter cannot safely associate a reconstructed item with " + "pending RunState input. Preserve the input item object, return an unchanged " + "copy, or omit the pending item." + ) + def rewind_input(self, items: Sequence[TResponseInputItem]) -> None: """Rewind previously marked inputs so they can be resent.""" if not items: @@ -422,11 +501,11 @@ def rewind_input(self, items: Sequence[TResponseInputItem]) -> None: for item in items: if item is None: continue - source_item = self._consume_prepared_item_source(item) + source_item, input_id = self._delivery_source(item) rewind_items.append(source_item) _untrack_object(self.sent_items, source_item) fp = _fingerprint_for_tracker(source_item) - if fp: + if input_id is None and fp: self.sent_item_fingerprints.discard(fp) if not rewind_items: @@ -444,10 +523,11 @@ def prepare_input( """Assemble the next model input while skipping duplicates and approvals.""" self.prepared_item_sources.clear() self.prepared_item_sources_by_fingerprint.clear() + self.delivered_item_sources.clear() prepared_initial_items: list[TResponseInputItem] = [] prepared_generated_items: list[TResponseInputItem] = [] - generated_item_sources: dict[int, TResponseInputItem] = {} + generated_item_sources: dict[int, _PreparedSourceIdentity] = {} if not self.sent_initial_input: initial_items = ItemHelpers.input_to_new_input_list(original_input) @@ -479,6 +559,10 @@ def prepare_input( if raw_item is None: continue + input_id = run_item.input_id if isinstance(run_item, InputItem) else None + if input_id is not None and input_id in self.accepted_input_item_ids: + continue + item_id = _normalize_server_item_id( raw_item.get("id") if isinstance(raw_item, dict) else getattr(raw_item, "id", None) ) @@ -505,7 +589,12 @@ def prepare_input( fp = _fingerprint_for_tracker(converted_input_item) if fp and fp in self.server_output_fingerprints: continue - if fp and self.primed_from_state and fp in self.sent_item_fingerprints: + if ( + input_id is None + and fp + and self.primed_from_state + and fp in self.sent_item_fingerprints + ): continue anonymous_tool_search_fp = _anonymous_tool_search_fingerprint(converted_input_item) if ( @@ -518,10 +607,13 @@ def prepare_input( continue prepared_generated_items.append(converted_input_item) - generated_item_sources[id(converted_input_item)] = cast(TResponseInputItem, raw_item) + generated_item_sources[id(converted_input_item)] = ( + cast(TResponseInputItem, raw_item), + input_id, + ) normalized_generated_items = normalize_input_items_for_api(prepared_generated_items) - normalized_generated_sources = { + normalized_generated_sources: dict[int, _PreparedSourceIdentity] = { id(normalized_item): generated_item_sources[id(source_item)] for normalized_item, source_item in zip( normalized_generated_items, prepared_generated_items, strict=False @@ -529,42 +621,47 @@ def prepare_input( } filtered_generated_items = drop_orphan_function_calls(normalized_generated_items) for item in filtered_generated_items: - prepared_source_item = normalized_generated_sources.get(id(item)) - if prepared_source_item is not None: - self._register_prepared_item_source(item, prepared_source_item) + prepared_source = normalized_generated_sources.get(id(item)) + if prepared_source is not None: + source_item, input_id = prepared_source + self._register_prepared_item_source(item, source_item, input_id=input_id) return prepared_initial_items + filtered_generated_items def _register_prepared_item_source( - self, prepared_item: TResponseInputItem, source_item: TResponseInputItem | None = None + self, + prepared_item: TResponseInputItem, + source_item: TResponseInputItem | None = None, + *, + input_id: str | None = None, ) -> None: if source_item is None: source_item = prepared_item - self.prepared_item_sources[id(prepared_item)] = (prepared_item, source_item) + self.prepared_item_sources[id(prepared_item)] = (prepared_item, source_item, input_id) fingerprint = _fingerprint_for_tracker(prepared_item) if fingerprint: self.prepared_item_sources_by_fingerprint.setdefault(fingerprint, []).append( - source_item + (source_item, input_id) ) - def _consume_prepared_item_source(self, item: TResponseInputItem) -> TResponseInputItem: + def _consume_prepared_item_source(self, item: TResponseInputItem) -> _PreparedSourceIdentity: direct_entry = self.prepared_item_sources.get(id(item)) - direct_source = None + direct_source: _PreparedSourceIdentity | None = None if direct_entry is not None and direct_entry[0] is item: self.prepared_item_sources.pop(id(item), None) - direct_source = direct_entry[1] + direct_source = (direct_entry[1], direct_entry[2]) fingerprint = _fingerprint_for_tracker(item) if not fingerprint: - return direct_source if direct_source is not None else item + return direct_source if direct_source is not None else (item, None) source_items = self.prepared_item_sources_by_fingerprint.get(fingerprint) if not source_items: - return direct_source if direct_source is not None else item + return direct_source if direct_source is not None else (item, None) source_item = direct_source if direct_source is not None else source_items[0] for index, candidate in enumerate(source_items): - if candidate is source_item: + if candidate[0] is source_item[0] and candidate[1] == source_item[1]: source_items.pop(index) break @@ -572,3 +669,24 @@ def _consume_prepared_item_source(self, item: TResponseInputItem) -> TResponseIn self.prepared_item_sources_by_fingerprint.pop(fingerprint, None) return source_item + + def _delivery_source(self, item: TResponseInputItem) -> _PreparedSourceIdentity: + delivered_entry = self.delivered_item_sources.get(id(item)) + if delivered_entry is not None and delivered_entry[0] is item: + return delivered_entry[1], delivered_entry[2] + + source_item, input_id = self._consume_prepared_item_source(item) + self.delivered_item_sources[id(item)] = (item, source_item, input_id) + return source_item, input_id + + def mark_input_as_accepted(self, items: Sequence[TResponseInputItem]) -> set[str]: + """Record pending-input occurrences present in a successful server request.""" + accepted_ids: set[str] = set() + for item in items: + if item is None: + continue + _source_item, input_id = self._delivery_source(item) + if input_id is not None: + accepted_ids.add(input_id) + self.accepted_input_item_ids.update(accepted_ids) + return accepted_ids diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 936637fe20..701e669535 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -42,6 +42,7 @@ ) from ..handoffs import Handoff from ..items import ( + InputItem, ItemHelpers, ModelResponse, RunItem, @@ -149,6 +150,8 @@ ) from .session_persistence import ( _session_get_items, + admit_pending_input, + commit_server_pending_input, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -786,8 +789,36 @@ def _sync_conversation_tracking_from_tracker() -> None: hydrate_tool_use_tracker(tool_use_tracker, run_state, starting_agent) pending_server_items: list[RunItem] | None = None + pending_input_admission_items: list[InputItem] = [] session_input_items_for_persistence: list[TResponseInputItem] | None = None + def _commit_pending_server_response( + model_response: ModelResponse, + processed_response: ProcessedResponse | None, + ) -> bool: + if ( + run_state is None + or server_conversation_tracker is None + or not pending_input_admission_items + ): + return False + return commit_server_pending_input( + run_state=run_state, + tracker=server_conversation_tracker, + admission_items=pending_input_admission_items, + generated_items=streamed_result._model_input_items, + session_items=streamed_result.new_items, + model_response=model_response, + processed_response=processed_response, + current_turn=current_turn, + ) + + def _mark_response_hooks_started() -> None: + if run_state is None or not isinstance(run_state._current_step, NextStepInterruption): + return + if run_state._current_step.response_accepted: + run_state._current_step.llm_end_hooks_started = True + if is_resumed_state and server_conversation_tracker is not None and run_state is not None: session_items: list[TResponseInputItem] | None = None if session is not None: @@ -973,8 +1004,15 @@ async def _save_stream_items_without_count( if is_resumed_state and run_state is not None and run_state._current_step is not None: if isinstance(run_state._current_step, NextStepInterruption): - if not run_state._model_responses or run_state._last_processed_response is None: + if not run_state._model_responses: raise UserError("No model response found in previous state") + if run_state._last_processed_response is None: + if run_state._current_step.response_accepted: + raise UserError( + "An accepted model response could not be processed; " + "start a new run instead of retrying it" + ) + raise UserError("No processed response found in previous state") last_model_response = run_state._model_responses[-1] @@ -989,6 +1027,7 @@ async def _save_stream_items_without_count( run_config=run_config, server_manages_conversation=server_conversation_tracker is not None, run_state=run_state, + error_handlers=error_handlers, ) tool_use_tracker.record_processed_response( @@ -1079,7 +1118,7 @@ async def _save_stream_items_without_count( streamed_result._event_queue.put_nowait( AgentUpdatedStreamEvent(new_agent=current_agent) ) - run_state._current_step = NextStepRunAgain() # type: ignore[assignment] + run_state._current_step = NextStepRunAgain() if await _wait_for_streamed_turn_events_and_stop_if_cancelled( streamed_result ): @@ -1099,6 +1138,7 @@ async def _save_stream_items_without_count( store_setting=store_setting, persist_before_output_guardrails=True, ) + run_state._current_step = None break if isinstance(turn_result.next_step, NextStepRunAgain): @@ -1107,7 +1147,7 @@ async def _save_stream_items_without_count( turn_result.model_response.response_id, store_setting, ) - run_state._current_step = NextStepRunAgain() # type: ignore[assignment] + run_state._current_step = NextStepRunAgain() if await _wait_for_streamed_turn_events_and_stop_if_cancelled( streamed_result ): @@ -1124,6 +1164,62 @@ async def _save_stream_items_without_count( if streamed_result.is_complete: break + if run_state is not None and run_state._pending_input: + if run_state._current_step is None: + run_state._current_step = NextStepRunAgain() + pending_input = run_state.pending_input + pending_guardrails = current_agent.input_guardrails + ( + run_config.input_guardrails or [] + ) + previous_result_count = len(streamed_result.input_guardrail_results) + try: + await run_input_guardrails_with_queue( + current_agent, + pending_guardrails, + pending_input, + context_wrapper, + streamed_result, + current_span, + ) + finally: + run_state._input_guardrail_results = list( + streamed_result.input_guardrail_results + ) + tripping_result = next( + ( + result + for result in streamed_result.input_guardrail_results[ + previous_result_count: + ] + if result.output.tripwire_triggered + ), + None, + ) + if tripping_result is not None: + raise InputGuardrailTripwireTriggered(tripping_result) + + store_setting = current_agent.model_settings.resolve( + run_config.model_settings + ).store + admission_items = await admit_pending_input( + run_state=run_state, + agent=current_agent, + session=session, + server_conversation_tracker=server_conversation_tracker, + store=store_setting, + wrapper=context_wrapper, + ) + streamed_result._model_input_items.extend(admission_items) + streamed_result.new_items.extend(admission_items) + if pending_server_items is not None: + pending_server_items.extend(admission_items) + pending_input_admission_items = [ + item for item in admission_items if isinstance(item, InputItem) + ] + if not run_state._pending_input: + run_state._generated_items = list(streamed_result._model_input_items) + run_state._session_items = list(streamed_result.new_items) + all_tools = await get_all_tools(execution_agent, context_wrapper) all_tools = await initialize_computer_tools( tools=all_tools, context_wrapper=context_wrapper @@ -1310,6 +1406,9 @@ async def _save_stream_items_without_count( prompt_cache_key_resolver=prompt_cache_key_resolver, error_handlers=error_handlers, agent_span=current_span, + on_response_accepted=_commit_pending_server_response, + on_response_hooks_started=_mark_response_hooks_started, + run_state=run_state, ) finally: if current_turn_span is not None: @@ -1348,6 +1447,14 @@ async def _save_stream_items_without_count( ) turn_session_items = session_items_for_turn(turn_result) streamed_result.new_items.extend(turn_session_items) + if pending_input_admission_items and run_state is not None: + run_state._generated_items = list(streamed_result._model_input_items) + run_state._session_items = list(streamed_result.new_items) + run_state._model_responses = list(streamed_result.raw_responses) + run_state._last_processed_response = turn_result.processed_response + run_state._current_turn = current_turn + run_state._mark_generated_items_merged_with_last_processed() + pending_input_admission_items = [] if turn_result.nested_history_owned_items is not None: owned_refs = reconcile_nested_history_owned_session_item_refs( streamed_result.new_items, @@ -1410,6 +1517,8 @@ async def _save_stream_items_without_count( store_setting=store_setting, persist_before_output_guardrails=False, ) + if run_state is not None: + run_state._current_step = None break elif isinstance(turn_result.next_step, NextStepInterruption): processed_response_for_state = turn_result.processed_response @@ -1545,6 +1654,9 @@ async def run_single_turn_streamed( prompt_cache_key_resolver: PromptCacheKeyResolver | None = None, error_handlers: RunErrorHandlers[TContext] | None = None, agent_span: Span[AgentSpanData] | None = None, + on_response_accepted: Callable[[ModelResponse, ProcessedResponse | None], bool] | None = None, + on_response_hooks_started: Callable[[], None] | None = None, + run_state: RunState[Any] | None = None, ) -> SingleStepResult: """Run a single streamed turn and emit events as results arrive.""" public_agent = bindings.public_agent @@ -1651,6 +1763,7 @@ async def raise_if_input_guardrail_tripwire_known() -> None: if isinstance(filtered.input, list): filtered.input = deduplicate_input_items_preferring_latest(filtered.input) if server_conversation_tracker is not None: + server_conversation_tracker.validate_pending_input_filter(filtered.input) logger.debug( "filtered.input has %s items; ids=%s", len(filtered.input), @@ -1820,12 +1933,22 @@ async def rewind_model_request() -> None: # Streaming uses the same rewind helper, so a successful retry must restore delivered # input tracking before the next turn computes server-managed deltas. server_conversation_tracker.mark_input_as_sent(filtered.input) + server_conversation_tracker.mark_input_as_accepted(filtered.input) server_conversation_tracker.track_server_items(final_response) + response_accepted = False + if on_response_accepted is not None: + response_accepted = on_response_accepted(final_response, None) + async def after_invocation_validation( - model_items: list[RunItem] | None, - ) -> None: - if model_items is not None: + processed_response: ProcessedResponse | None, + ) -> bool: + if response_accepted and on_response_accepted is not None: + on_response_accepted(final_response, processed_response) + if response_accepted and on_response_hooks_started is not None: + on_response_hooks_started() + if processed_response is not None: + model_items = processed_response.new_items emitted_model_item_occurrence_keys.update( _ensure_stream_event_item_occurrence_key(item) for item in model_items ) @@ -1838,6 +1961,7 @@ async def after_invocation_validation( ), hooks.on_llm_end(context_wrapper, public_agent, final_response), ) + return response_accepted async def check_input_guardrails_before_side_effects() -> None: await raise_if_input_guardrail_tripwire_known() @@ -1858,6 +1982,7 @@ async def check_input_guardrails_before_side_effects() -> None: server_manages_conversation=server_conversation_tracker is not None, after_invocation_validation=after_invocation_validation, before_side_effects=check_input_guardrails_before_side_effects, + run_state=run_state, ) items_to_filter = session_items_for_turn(single_step_result) @@ -1891,6 +2016,9 @@ async def run_single_turn( prompt_cache_key_resolver: PromptCacheKeyResolver | None = None, error_handlers: RunErrorHandlers[TContext] | None = None, agent_span: Span[AgentSpanData] | None = None, + on_response_accepted: Callable[[ModelResponse, ProcessedResponse | None], bool] | None = None, + on_response_hooks_started: Callable[[], None] | None = None, + run_state: RunState[Any] | None = None, ) -> SingleStepResult: """Run a single non-streaming turn of the agent loop.""" public_agent = bindings.public_agent @@ -1961,9 +2089,17 @@ async def run_single_turn( defer_llm_end_hooks=True, ) + response_accepted = False + if on_response_accepted is not None: + response_accepted = on_response_accepted(new_response, None) + async def after_invocation_validation( - _validated_model_items: list[RunItem] | None, - ) -> None: + _processed_response: ProcessedResponse | None, + ) -> bool: + if response_accepted and on_response_accepted is not None: + on_response_accepted(new_response, _processed_response) + if response_accepted and on_response_hooks_started is not None: + on_response_hooks_started() await gather_with_cancel( ( public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) @@ -1972,6 +2108,7 @@ async def after_invocation_validation( ), hooks.on_llm_end(context_wrapper, public_agent, new_response), ) + return response_accepted return await get_single_step_result_from_response( bindings=bindings, @@ -1988,6 +2125,7 @@ async def after_invocation_validation( tool_use_tracker=tool_use_tracker, server_manages_conversation=server_conversation_tracker is not None, after_invocation_validation=after_invocation_validation, + run_state=run_state, ) @@ -2028,6 +2166,7 @@ async def get_new_response( model_settings = maybe_reset_tool_choice(public_agent, tool_use_tracker, model_settings) if server_conversation_tracker is not None: + server_conversation_tracker.validate_pending_input_filter(filtered.input) server_conversation_tracker.mark_input_as_sent(filtered.input) await gather_with_cancel( @@ -2114,6 +2253,8 @@ async def rewind_model_request() -> None: # filtered input as delivered again once a retry succeeds so subsequent turns only send # new deltas. server_conversation_tracker.mark_input_as_sent(filtered.input) + server_conversation_tracker.mark_input_as_accepted(filtered.input) + server_conversation_tracker.track_server_items(new_response) context_wrapper.usage.add(new_response.usage) diff --git a/src/agents/run_internal/run_steps.py b/src/agents/run_internal/run_steps.py index 98df09a416..f692e1ca4e 100644 --- a/src/agents/run_internal/run_steps.py +++ b/src/agents/run_internal/run_steps.py @@ -174,6 +174,12 @@ class NextStepInterruption: interruptions: list[ToolApprovalItem] """The list of tool calls awaiting approval.""" + response_accepted: bool = False + """Whether the server accepted a response whose local processing is still incomplete.""" + + llm_end_hooks_started: bool = True + """Whether response-end hooks started before the interruption was persisted.""" + @dataclass class SingleStepResult: diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 04dd211209..df4c84a7a5 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -15,7 +15,15 @@ from .. import _debug from ..exceptions import UserError -from ..items import HandoffOutputItem, ItemHelpers, RunItem, ToolCallOutputItem, TResponseInputItem +from ..items import ( + HandoffOutputItem, + InputItem, + ItemHelpers, + ModelResponse, + RunItem, + ToolCallOutputItem, + TResponseInputItem, +) from ..logger import ( log_model_and_tool_action_debug, log_model_and_tool_action_warning, @@ -52,9 +60,11 @@ strip_internal_input_item_metadata, ) from .oai_conversation import OpenAIServerConversationTracker -from .run_steps import SingleStepResult +from .run_steps import NextStepInterruption, ProcessedResponse, SingleStepResult __all__ = [ + "admit_pending_input", + "commit_server_pending_input", "prepare_input_with_session", "persist_session_items_for_guardrail_trip", "reconcile_nested_history_owned_session_item_refs", @@ -72,6 +82,110 @@ _SESSION_LIMIT_UNSET = object() +async def admit_pending_input( + *, + run_state: RunState[Any], + agent: Any, + session: Session | None, + server_conversation_tracker: OpenAIServerConversationTracker | None, + store: bool | None, + wrapper: RunContextWrapper[Any], +) -> list[RunItem]: + """Admit staged RunState input into the active conversation ownership boundary. + + The caller must run pending-input guardrails first. Client-managed sessions accept the input + before the model call, while server-managed conversations keep it pending until a model + response confirms that the server accepted the request. + """ + pending_input = run_state.pending_input + if not pending_input: + return [] + + admission_items: list[RunItem] = [ + InputItem(agent=agent, raw_item=item) for item in pending_input + ] + + if session is not None and server_conversation_tracker is None: + await save_result_to_session( + session, + [], + admission_items, + None, + store=store, + wrapper=wrapper, + ) + if server_conversation_tracker is None: + run_state.clear_pending_input() + + return admission_items + + +def commit_server_pending_input( + *, + run_state: RunState[Any], + tracker: OpenAIServerConversationTracker, + admission_items: list[InputItem], + generated_items: list[RunItem], + session_items: list[RunItem], + model_response: ModelResponse, + processed_response: ProcessedResponse | None, + current_turn: int, +) -> bool: + """Commit only pending-input occurrences accepted by a server-managed request.""" + if not admission_items: + return False + + admission_ids = {item.input_id for item in admission_items} + accepted_ids = admission_ids & tracker.accepted_input_item_ids + + def retain_accepted_admissions(items: list[RunItem]) -> None: + items[:] = [ + item + for item in items + if not ( + isinstance(item, InputItem) + and item.input_id in admission_ids + and item.input_id not in accepted_ids + ) + ] + + retain_accepted_admissions(generated_items) + retain_accepted_admissions(session_items) + run_state._pending_input = copy.deepcopy( + [item.raw_item for item in admission_items if item.input_id not in accepted_ids] + ) + + # A model input filter may omit every staged occurrence. In that case the response does not + # acknowledge pending input, so normal turn processing owns the response and the input remains + # available for a later request. + if not accepted_ids: + return False + + state_generated_items = list(generated_items) + state_session_items = list(session_items) + + run_state._generated_items = state_generated_items + run_state._session_items = state_session_items + if not run_state._model_responses or run_state._model_responses[-1] is not model_response: + run_state._model_responses.append(model_response) + run_state._last_processed_response = processed_response + run_state._current_step = NextStepInterruption( + interruptions=( + list(processed_response.interruptions) if processed_response is not None else [] + ), + response_accepted=True, + llm_end_hooks_started=False, + ) + run_state._current_turn = current_turn + run_state._conversation_id = tracker.conversation_id + run_state._previous_response_id = tracker.previous_response_id + run_state._auto_previous_response_id = tracker.auto_previous_response_id + # The accepted model response is durable, but its processed items have not yet been merged + # because local hooks and tool work can still fail. Preserve that distinction across retries. + run_state._clear_generated_items_last_processed_marker() + return True + + async def _session_get_items( session: Session, limit: int | None | object = _SESSION_LIMIT_UNSET, diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py index 9bb1201b2f..6bffae29cd 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -128,6 +128,10 @@ async def _run_action(span: Any | None) -> RunItem: tool=action.computer_tool, run_context=context_wrapper ) agent_hooks = agent.hooks + context_wrapper._mark_tool_invocation_executed( + action.tool_call, + tool_name=action.computer_tool.name, + ) await gather_with_cancel( hooks.on_tool_start(context_wrapper, agent, action.computer_tool), ( diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 9ad8143a25..8c756b0bc9 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -795,6 +795,7 @@ async def execute_tools_and_side_effects( error_handlers: RunErrorHandlers[TContext] | None = None, server_manages_conversation: bool = False, precomputed_skipped_raw_item_ids: set[int] | None = None, + run_state: RunState[Any] | None = None, ) -> SingleStepResult: """Run one turn of the loop, coordinating tools, approvals, guardrails, and handoffs.""" public_agent = bindings.public_agent @@ -836,6 +837,15 @@ async def execute_tools_and_side_effects( skipped_raw_item_ids=skipped_raw_item_ids, ) + def _commit_accepted_response_tool_output(item: RunItem) -> None: + if run_state is None or not isinstance(run_state._current_step, NextStepInterruption): + return + if not run_state._current_step.response_accepted: + return + for target in (run_state._generated_items, run_state._session_items): + if item not in target: + target.append(item) + ( function_results, tool_input_guardrail_results, @@ -851,6 +861,7 @@ async def execute_tools_and_side_effects( hooks=hooks, context_wrapper=context_wrapper, run_config=run_config, + tool_output_committer=_commit_accepted_response_tool_output, ) new_step_items.extend( _build_tool_result_items( @@ -1132,12 +1143,49 @@ async def resolve_interrupted_turn( run_config: RunConfig, server_manages_conversation: bool = False, run_state: RunState | None = None, + error_handlers: RunErrorHandlers[TContext] | None = None, nest_handoff_history_fn: Callable[..., HandoffInputData] | None = None, ) -> SingleStepResult: """Continue a turn that was previously interrupted waiting for tool approval.""" public_agent = bindings.public_agent execution_agent = bindings.execution_agent + current_step = run_state._current_step if run_state is not None else None + if ( + isinstance(current_step, NextStepInterruption) + and current_step.response_accepted + and not current_step.llm_end_hooks_started + ): + current_step.llm_end_hooks_started = True + await gather_with_cancel( + ( + public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response) + if public_agent.hooks is not None + else _coro.noop_coroutine() + ), + hooks.on_llm_end(context_wrapper, public_agent, new_response), + ) + + if ( + isinstance(current_step, NextStepInterruption) + and current_step.response_accepted + and not processed_response.has_tools_or_approvals_to_run() + ): + return await execute_tools_and_side_effects( + bindings=bindings, + original_input=original_input, + pre_step_items=original_pre_step_items, + new_response=new_response, + processed_response=processed_response, + output_schema=get_output_schema(execution_agent), + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + error_handlers=error_handlers, + server_manages_conversation=server_manages_conversation, + run_state=run_state, + ) + execute_handoffs_call = execute_handoffs _register_tool_call_items( @@ -3363,8 +3411,10 @@ async def get_single_step_result_from_response( tool_use_tracker, error_handlers: RunErrorHandlers[TContext] | None = None, server_manages_conversation: bool = False, - after_invocation_validation: Callable[[list[RunItem] | None], Awaitable[None]] | None = None, + after_invocation_validation: Callable[[ProcessedResponse | None], Awaitable[bool]] + | None = None, before_side_effects: Callable[[], Awaitable[None]] | None = None, + run_state: RunState[Any] | None = None, ) -> SingleStepResult: item_agent = bindings.public_agent try: @@ -3406,7 +3456,7 @@ async def get_single_step_result_from_response( ) if after_invocation_validation is not None: - await after_invocation_validation(processed_response.new_items) + await after_invocation_validation(processed_response) if before_side_effects is not None: await before_side_effects() @@ -3426,4 +3476,5 @@ async def get_single_step_result_from_response( error_handlers=error_handlers, server_manages_conversation=server_manages_conversation, precomputed_skipped_raw_item_ids=skipped_raw_item_ids, + run_state=run_state, ) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 065ed8593d..e6bb61477b 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -78,6 +78,8 @@ CompactionItem, HandoffCallItem, HandoffOutputItem, + InputItem, + ItemHelpers, MCPApprovalRequestItem, MCPApprovalResponseItem, MCPListToolsItem, @@ -145,6 +147,7 @@ from .items import ModelResponse, RunItem from .run_internal.run_steps import ( NextStepInterruption, + NextStepRunAgain, ProcessedResponse, ToolRunFunction, ) @@ -192,7 +195,7 @@ "1.14": "Scopes hosted MCP approvals and restored requests by server label.", "1.15": ( "Persists canonical tool invocation identity plus sanitized mount authority and trusted " - "rebind metadata across resume flows." + "rebind metadata, durable pending input, and resumable next-model-call state." ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -280,6 +283,9 @@ class RunState(Generic[TContext, TAgent]): _session_items: list[RunItem] = field(default_factory=list) """Full, unfiltered run items for session history.""" + _pending_input: list[TResponseInputItem] = field(default_factory=list) + """Input staged for admission immediately before the next resumed model call.""" + _nested_history_owned_session_item_refs: list[NestedHistoryOwnedItemRef] = field( default_factory=list ) @@ -315,8 +321,8 @@ class RunState(Generic[TContext, TAgent]): _tool_output_guardrail_results: list[ToolOutputGuardrailResult] = field(default_factory=list) """Results from tool output guardrails applied during the run.""" - _current_step: NextStepInterruption | None = None - """Current step if the run is interrupted (e.g., for tool approval).""" + _current_step: NextStepInterruption | NextStepRunAgain | None = None + """Current resumable step, or ``None`` when the state is terminal.""" _last_processed_response: ProcessedResponse | None = None """The last processed model response. This is needed for resuming from interruptions.""" @@ -367,6 +373,7 @@ def __init__( self._model_responses = [] self._generated_items = [] self._session_items = [] + self._pending_input = [] self._nested_history_owned_session_item_refs = [] self._input_guardrail_results = [] self._output_guardrail_results = [] @@ -385,6 +392,55 @@ def __init__( self._agent_tool_state_scope_id = get_agent_tool_state_scope(context) + @property + def pending_input(self) -> list[TResponseInputItem]: + """Return a copy of input currently staged for the next resumed model call.""" + return copy.deepcopy(self._pending_input) + + def add_input(self, input: str | list[TResponseInputItem]) -> None: + """Stage input for admission immediately before the next resumed model call. + + String input is normalized to a user message. Multiple calls preserve insertion order. + The input remains pending until its guardrails and conversation ownership boundary accept + it. Terminal states reject new input before mutating the state. + """ + from .run_internal.run_steps import NextStepInterruption, NextStepRunAgain + + if not isinstance(self._current_step, NextStepInterruption | NextStepRunAgain): + raise UserError("Cannot add input to a terminal RunState") + if self._max_turns is not None and self._current_turn >= self._max_turns: + raise UserError("Cannot add input to a RunState with no remaining model turns") + if isinstance(self._current_step, NextStepInterruption): + if self._current_step.response_accepted: + raise UserError( + "Cannot add input while an accepted model response is awaiting local processing" + ) + if self._current_agent is None: + raise UserError("Cannot add input to a RunState without a current agent") + tool_use_behavior = self._current_agent.tool_use_behavior + interrupted_tool_names = { + item.tool_name + for item in self._current_step.interruptions + if item.tool_name is not None + } + stops_before_next_model = tool_use_behavior == "stop_on_first_tool" or ( + isinstance(tool_use_behavior, dict) + and bool( + interrupted_tool_names & set(tool_use_behavior.get("stop_at_tool_names", [])) + ) + ) + if stops_before_next_model or callable(tool_use_behavior): + raise UserError( + "Cannot add input to an interrupted RunState whose tool result may end the run" + ) + + normalized = ItemHelpers.input_to_new_input_list(input) + self._pending_input.extend(copy.deepcopy(normalized)) + + def clear_pending_input(self) -> None: + """Remove all input staged for the next resumed model call.""" + self._pending_input = [] + def get_interruptions(self) -> list[ToolApprovalItem]: """Return pending interruptions if the current step is an interruption.""" # Import at runtime to avoid circular import @@ -693,13 +749,13 @@ def _serialize_model_responses(self) -> list[dict[str, Any]]: for resp in self._model_responses ] - def _serialize_original_input(self) -> str | list[Any]: - """Normalize original input into the shape expected by Responses API.""" - if not isinstance(self._original_input, list): - return self._original_input + def _serialize_input(self, input: str | list[Any]) -> str | list[Any]: + """Normalize input into the shape expected by Responses API.""" + if not isinstance(input, list): + return input normalized_items = [] - for item in self._original_input: + for item in input: normalized_item = _serialize_raw_item_value(item) if isinstance(normalized_item, dict): normalized_item = dict(normalized_item) @@ -713,6 +769,10 @@ def _serialize_original_input(self) -> str | list[Any]: normalized_items.append(normalized_item) return normalized_items + def _serialize_original_input(self) -> str | list[Any]: + """Normalize original input into the shape expected by Responses API.""" + return self._serialize_input(self._original_input) + def _generated_session_item_indexes( self, generated_items: Sequence[RunItem], @@ -1063,6 +1123,7 @@ def to_json( "current_turn": self._current_turn, "current_agent": current_agent_entry, "original_input": original_input_serialized, + "pending_input": self._serialize_input(self._pending_input), "model_responses": model_responses, "context": context_entry, "tool_use_tracker": copy.deepcopy(self._tool_use_tracker_snapshot), @@ -1188,9 +1249,9 @@ def _serialize_processed_response( } def _serialize_current_step(self) -> dict[str, Any] | None: - """Serialize the current step if it's an interruption.""" + """Serialize the current resumable step.""" # Import at runtime to avoid circular import - from .run_internal.run_steps import NextStepInterruption + from .run_internal.run_steps import NextStepInterruption, NextStepRunAgain agent_identity_keys_by_id = ( _build_agent_identity_keys_by_id(cast(Agent[Any], self._starting_agent)) @@ -1198,6 +1259,9 @@ def _serialize_current_step(self) -> dict[str, Any] | None: else None ) + if isinstance(self._current_step, NextStepRunAgain): + return {"type": "next_step_run_again"} + if self._current_step is None or not isinstance(self._current_step, NextStepInterruption): return None @@ -1215,6 +1279,8 @@ def _serialize_current_step(self) -> dict[str, Any] | None: "type": "next_step_interruption", "data": { "interruptions": interruptions_data, + "response_accepted": self._current_step.response_accepted, + "llm_end_hooks_started": self._current_step.llm_end_hooks_started, }, } @@ -1236,6 +1302,9 @@ def _serialize_item( ), } + if isinstance(item, InputItem): + result["input_id"] = item.input_id + # Add additional fields based on item type if hasattr(item, "output"): serialized_output = item.output @@ -2803,6 +2872,10 @@ def _run_state_raw_items(state_json: Mapping[str, Any]) -> list[Any]: if isinstance(original_input, list): raw_items.extend(original_input) + pending_input = state_json.get("pending_input") + if isinstance(pending_input, list): + raw_items.extend(pending_input) + for response_key in ("model_responses", "last_model_response"): responses = state_json.get(response_key) if isinstance(responses, Mapping): @@ -3206,6 +3279,13 @@ async def _build_run_state_from_json( set_agent_tool_state_scope(context, state._agent_tool_state_scope_id) state._current_turn = state_json["current_turn"] + pending_input_raw = state_json.get("pending_input", []) + if not isinstance(pending_input_raw, list): + raise UserError("Run state pending_input must be a list") + state._pending_input = cast( + list[TResponseInputItem], + [dict(item) if isinstance(item, Mapping) else item for item in pending_input_raw], + ) state._model_responses = _deserialize_model_responses(state_json.get("model_responses", [])) serialized_generated_items = state_json.get("generated_items", []) state._generated_items, generated_source_indexes = _deserialize_items_with_source_indexes( @@ -3368,7 +3448,11 @@ async def _build_run_state_from_json( ) current_step_data = state_json.get("current_step") - if current_step_data and current_step_data.get("type") == "next_step_interruption": + if current_step_data and current_step_data.get("type") == "next_step_run_again": + from .run_internal.run_steps import NextStepRunAgain + + state._current_step = NextStepRunAgain() + elif current_step_data and current_step_data.get("type") == "next_step_interruption": interruptions: list[ToolApprovalItem] = [] interruptions_data = current_step_data.get("data", {}).get( "interruptions", current_step_data.get("interruptions", []) @@ -3385,8 +3469,16 @@ async def _build_run_state_from_json( from .run_internal.run_steps import NextStepInterruption state._current_step = NextStepInterruption( - interruptions=[item for item in interruptions if isinstance(item, ToolApprovalItem)] + interruptions=[item for item in interruptions if isinstance(item, ToolApprovalItem)], + response_accepted=bool( + current_step_data.get("data", {}).get("response_accepted", False) + ), + llm_end_hooks_started=bool( + current_step_data.get("data", {}).get("llm_end_hooks_started", True) + ), ) + if state._current_step.response_accepted: + state._clear_generated_items_last_processed_marker() for approval_item in state._current_step.interruptions: context._mark_restored_unbound_pending_approval(approval_item) @@ -3581,7 +3673,17 @@ def record_run_item(run_item: RunItem) -> None: if state._last_processed_response is not None: for run_item in state._last_processed_response.new_items: record_run_item(run_item) - for response in state._model_responses: + responses_for_invocation_validation = state._model_responses + if ( + state._last_processed_response is None + and getattr(state._current_step, "response_accepted", False) + and responses_for_invocation_validation + ): + # A server-accepted response is checkpointed before fallible local processing. Its raw + # invocations remain durable for diagnostics, but they are not registered runtime work + # unless response processing succeeds. + responses_for_invocation_validation = responses_for_invocation_validation[:-1] + for response in responses_for_invocation_validation: for raw_item in response.output: record_raw_item(raw_item, allow_handoff_alternative=True) if isinstance(state._original_input, list): @@ -4307,7 +4409,22 @@ def _resolve_agent_info( ) try: - if item_type == "message_output_item": + if item_type == "input_item": + input_id = item_data.get("input_id") + if isinstance(input_id, str): + input_item = InputItem( + agent=agent, + raw_item=cast(TResponseInputItem, normalized_raw_item), + input_id=input_id, + ) + else: + input_item = InputItem( + agent=agent, + raw_item=cast(TResponseInputItem, normalized_raw_item), + ) + result.append(input_item) + + elif item_type == "message_output_item": raw_item_msg = _deserialize_message_output_item(normalized_raw_item) result.append(MessageOutputItem(agent=agent, raw_item=raw_item_msg)) diff --git a/tests/test_run_state_pending_input.py b/tests/test_run_state_pending_input.py new file mode 100644 index 0000000000..1c19590010 --- /dev/null +++ b/tests/test_run_state_pending_input.py @@ -0,0 +1,965 @@ +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest +from openai.types.responses.response_computer_tool_call import ( + ActionScreenshot, + ResponseComputerToolCall, +) + +from agents import Agent, ComputerTool, InputItem, RunConfig, Runner, function_tool +from agents.exceptions import InputGuardrailTripwireTriggered, ModelBehaviorError, UserError +from agents.guardrail import GuardrailFunctionOutput, InputGuardrail +from agents.items import ModelResponse, TResponseInputItem +from agents.lifecycle import AgentHooks, RunHooks +from agents.run import CallModelData, ModelInputData +from agents.run_context import RunContextWrapper +from agents.run_internal.oai_conversation import OpenAIServerConversationTracker +from agents.run_internal.run_steps import NextStepInterruption, NextStepRunAgain +from agents.run_state import CURRENT_SCHEMA_VERSION, RunState +from agents.tool import Tool +from agents.usage import Usage + +from .fake_model import FakeModel +from .test_computer_tool_lifecycle import FakeComputer +from .test_responses import get_function_tool_call, get_text_message +from .utils.simple_session import SimpleListSession + + +def _item_type(item: TResponseInputItem) -> str | None: + if not isinstance(item, dict): + return getattr(item, "type", None) + return cast(str | None, item.get("type") or item.get("role")) + + +def _message_text(item: TResponseInputItem) -> str | None: + if not isinstance(item, dict) or item.get("role") != "user": + return None + content = item.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + str(part.get("text", "")) + for part in content + if isinstance(part, dict) and part.get("type") in {"input_text", "output_text"} + ) + return None + + +async def _make_after_turn_state( + *, + session: SimpleListSession | None = None, + auto_previous_response_id: bool = False, +) -> tuple[FakeModel, Agent[Any], RunState[Any], list[str]]: + calls: list[str] = [] + + @function_tool(name_override="record_destination") + def record_destination(destination: str) -> str: + calls.append(destination) + return f"recorded:{destination}" + + model = FakeModel() + model.set_next_output( + [ + get_function_tool_call( + "record_destination", + json.dumps({"destination": "Paris"}), + call_id="call-destination", + ) + ] + ) + agent = Agent(name="assistant", model=model, tools=[record_destination]) + streamed = Runner.run_streamed( + agent, + "Initial request", + session=session, + auto_previous_response_id=auto_previous_response_id, + ) + async for event in streamed.stream_events(): + if event.type == "run_item_stream_event" and event.name == "tool_output": + streamed.cancel(mode="after_turn") + + state = streamed.to_state() + assert isinstance(state._current_step, NextStepRunAgain) + assert calls == ["Paris"] + return model, agent, state, calls + + +@pytest.mark.asyncio +async def test_pending_input_preserves_order_and_serialization_round_trips() -> None: + agent = Agent(name="assistant") + state: RunState[Any] = RunState( + context=RunContextWrapper(context={}), + original_input="Initial request", + starting_agent=agent, + ) + state._current_step = NextStepRunAgain() + starting_turn = state._current_turn + + state.add_input("First late message") + state.add_input([{"role": "user", "content": "Second late message"}]) + assert state._current_turn == starting_turn + + assert [_message_text(item) for item in state.pending_input] == [ + "First late message", + "Second late message", + ] + detached_view = state.pending_input + cast(dict[str, Any], detached_view[0])["content"] = "mutated" + assert _message_text(state.pending_input[0]) == "First late message" + + serialized = state.to_json() + assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION + restored = await RunState.from_json(agent, serialized) + restored_from_string = await RunState.from_string(agent, state.to_string()) + + for candidate in (restored, restored_from_string): + assert isinstance(candidate._current_step, NextStepRunAgain) + assert [_message_text(item) for item in candidate.pending_input] == [ + "First late message", + "Second late message", + ] + + legacy = state.to_json() + legacy["$schemaVersion"] = "1.14" + legacy.pop("pending_input") + legacy["current_step"] = None + restored_legacy = await RunState.from_json(agent, legacy) + assert restored_legacy.pending_input == [] + + +@pytest.mark.asyncio +async def test_after_turn_resume_admits_input_after_tool_output_exactly_once() -> None: + session = SimpleListSession() + model, agent, state, calls = await _make_after_turn_state(session=session) + state.add_input("Change the destination to Tokyo") + model.set_next_output([get_text_message("Updated")]) + + result = await Runner.run(agent, state, session=session) + + assert result.final_output == "Updated" + assert calls == ["Paris"] + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_item_type(item) for item in model_input] == [ + "user", + "function_call", + "function_call_output", + "user", + ] + assert [_message_text(item) for item in model_input].count( + "Change the destination to Tokyo" + ) == 1 + assert state.pending_input == [] + + session_items = await session.get_items() + assert [_message_text(item) for item in session_items].count( + "Change the destination to Tokyo" + ) == 1 + replay_items = result.to_input_list() + assert [_message_text(item) for item in replay_items].count( + "Change the destination to Tokyo" + ) == 1 + for terminal_state in (state, result.to_state()): + with pytest.raises(UserError, match="terminal RunState"): + terminal_state.add_input("Too late") + + +@pytest.mark.asyncio +async def test_streamed_resume_matches_pending_input_ordering() -> None: + model, agent, state, calls = await _make_after_turn_state() + state.add_input("Change the destination to Tokyo") + model.set_next_output([get_text_message("Updated")]) + + result = Runner.run_streamed(agent, state) + async for _ in result.stream_events(): + pass + + assert result.final_output == "Updated" + assert calls == ["Paris"] + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_item_type(item) for item in model_input] == [ + "user", + "function_call", + "function_call_output", + "user", + ] + assert [_message_text(item) for item in model_input].count( + "Change the destination to Tokyo" + ) == 1 + assert state.pending_input == [] + for terminal_state in (state, result.to_state()): + with pytest.raises(UserError, match="terminal RunState"): + terminal_state.add_input("Too late") + + +@pytest.mark.asyncio +async def test_server_managed_resume_sends_pending_input_as_unsent_delta_once() -> None: + model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Change the destination to Tokyo") + model.set_next_output([get_text_message("Updated")]) + + result = await Runner.run(agent, state) + + assert result.final_output == "Updated" + assert calls == ["Paris"] + assert model.last_turn_args["previous_response_id"] == "resp-789" + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_item_type(item) for item in model_input] == ["function_call_output", "user"] + assert [_message_text(item) for item in model_input].count( + "Change the destination to Tokyo" + ) == 1 + assert state.pending_input == [] + + +def test_server_tracker_distinguishes_identical_input_occurrences_after_restore() -> None: + agent = Agent(name="assistant") + admitted_first = InputItem( + agent=agent, + raw_item={"role": "user", "content": "Repeat"}, + ) + admitted_second = InputItem( + agent=agent, + raw_item={"role": "user", "content": "Repeat"}, + ) + tracker = OpenAIServerConversationTracker(previous_response_id="resp-latest") + tracker.hydrate_from_state( + original_input="Initial request", + generated_items=[admitted_first], + model_responses=[ModelResponse(output=[], usage=Usage(), response_id="resp-latest")], + ) + + assert tracker.prepare_input("Initial request", [admitted_first, admitted_second]) == [ + admitted_second.raw_item + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed_second_resume", [False, True]) +async def test_server_managed_resume_sends_identical_late_input_in_later_occurrence( + streamed_second_resume: bool, +) -> None: + model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Repeat") + model.set_next_output( + [ + get_function_tool_call( + "record_destination", + json.dumps({"destination": "Rome"}), + call_id="call-second-destination", + ) + ] + ) + + first_resume = Runner.run_streamed(agent, state) + async for event in first_resume.stream_events(): + if event.type == "run_item_stream_event" and event.name == "tool_output": + first_resume.cancel(mode="after_turn") + + state = await RunState.from_json(agent, first_resume.to_state().to_json()) + admitted_before = next(item for item in state._generated_items if isinstance(item, InputItem)) + state.add_input("Repeat") + model.set_next_output([get_text_message("Done")]) + + if streamed_second_resume: + streamed_result = Runner.run_streamed(agent, state) + async for _event in streamed_result.stream_events(): + pass + final_output = streamed_result.final_output + else: + run_result = await Runner.run(agent, state) + final_output = run_result.final_output + + assert final_output == "Done" + assert calls == ["Paris", "Rome"] + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Repeat") == 1 + admitted_after = [item for item in state._generated_items if isinstance(item, InputItem)] + assert [item.input_id for item in admitted_after].count(admitted_before.input_id) == 1 + assert len({item.input_id for item in admitted_after}) == 2 + + +@pytest.mark.asyncio +async def test_unresolved_approval_keeps_pending_input_until_tool_finishes() -> None: + calls: list[str] = [] + + @function_tool(needs_approval=True) + def protected_tool(value: str) -> str: + calls.append(value) + return f"approved:{value}" + + model = FakeModel() + model.set_next_output( + [get_function_tool_call("protected_tool", '{"value":"one"}', call_id="call-protected")] + ) + agent = Agent(name="assistant", model=model, tools=[protected_tool]) + interrupted = await Runner.run(agent, "Initial request") + state = interrupted.to_state() + state.add_input("Late input") + + still_interrupted = await Runner.run(agent, state) + assert still_interrupted.interruptions + assert calls == [] + assert _message_text(state.pending_input[0]) == "Late input" + + state.approve(state.get_interruptions()[0]) + model.set_next_output([get_text_message("Done")]) + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "Done" + assert calls == ["one"] + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_item_type(item) for item in model_input][-2:] == ["function_call_output", "user"] + assert _message_text(model_input[-1]) == "Late input" + + +@pytest.mark.asyncio +async def test_streamed_after_turn_cancel_keeps_pending_input_for_next_resume() -> None: + calls: list[str] = [] + + @function_tool(needs_approval=True) + def protected_tool(value: str) -> str: + calls.append(value) + return f"approved:{value}" + + model = FakeModel() + model.set_next_output( + [get_function_tool_call("protected_tool", '{"value":"one"}', call_id="call-protected")] + ) + agent = Agent(name="assistant", model=model, tools=[protected_tool]) + interrupted = await Runner.run(agent, "Initial request") + state = interrupted.to_state() + state.add_input("Late input") + state.approve(state.get_interruptions()[0]) + + resumed = Runner.run_streamed(agent, state) + async for event in resumed.stream_events(): + if event.type == "run_item_stream_event" and event.name == "tool_output": + resumed.cancel(mode="after_turn") + + assert calls == ["one"] + assert _message_text(state.pending_input[0]) == "Late input" + + model.set_next_output([get_text_message("Done")]) + result = await Runner.run(agent, state) + assert result.final_output == "Done" + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Late input") == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize( + "tool_use_behavior", + [ + "stop_on_first_tool", + {"stop_at_tool_names": ["protected_tool"]}, + lambda _context, _results: None, + ], +) +async def test_interruption_without_guaranteed_next_model_rejects_input( + streamed: bool, + tool_use_behavior: Any, +) -> None: + @function_tool(needs_approval=True) + def protected_tool(value: str) -> str: + return value + + model = FakeModel( + initial_output=[ + get_function_tool_call( + "protected_tool", + '{"value":"one"}', + call_id="call-protected-terminal", + ) + ] + ) + agent = Agent( + name="assistant", + model=model, + tools=[protected_tool], + tool_use_behavior=cast(Any, tool_use_behavior), + ) + if streamed: + interrupted_stream = Runner.run_streamed(agent, "Initial request") + async for _event in interrupted_stream.stream_events(): + pass + state = interrupted_stream.to_state() + else: + interrupted = await Runner.run(agent, "Initial request") + state = interrupted.to_state() + + before = state.to_json() + with pytest.raises(UserError, match="tool result may end the run"): + state.add_input("Late input") + assert state.to_json() == before + + +@pytest.mark.asyncio +async def test_pending_input_guardrail_trip_keeps_input_recoverable() -> None: + model, agent, state, _calls = await _make_after_turn_state() + guarded_inputs: list[list[TResponseInputItem]] = [] + + def trip_pending_input( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + input: str | list[TResponseInputItem], + ) -> GuardrailFunctionOutput: + guarded_inputs.append(cast(list[TResponseInputItem], input)) + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + agent.input_guardrails = [InputGuardrail(guardrail_function=trip_pending_input)] + state.add_input("Unsafe late input") + model.set_next_output([get_text_message("Must not run")]) + queued_outputs = len(model.turn_outputs) + + with pytest.raises(InputGuardrailTripwireTriggered): + await Runner.run(agent, state) + + assert len(model.turn_outputs) == queued_outputs + assert [[_message_text(item) for item in batch] for batch in guarded_inputs] == [ + ["Unsafe late input"] + ] + assert _message_text(state.pending_input[0]) == "Unsafe late input" + state.clear_pending_input() + assert state.pending_input == [] + + +@pytest.mark.asyncio +async def test_pending_input_runs_agent_and_run_config_guardrails_on_only_pending() -> None: + model, agent, state, _calls = await _make_after_turn_state() + guarded_inputs: list[tuple[str, list[TResponseInputItem]]] = [] + + def inspect_agent_input( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + input: str | list[TResponseInputItem], + ) -> GuardrailFunctionOutput: + guarded_inputs.append(("agent", cast(list[TResponseInputItem], input))) + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + def inspect_config_input( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + input: str | list[TResponseInputItem], + ) -> GuardrailFunctionOutput: + guarded_inputs.append(("config", cast(list[TResponseInputItem], input))) + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + agent.input_guardrails = [InputGuardrail(guardrail_function=inspect_agent_input)] + run_config = RunConfig( + input_guardrails=[InputGuardrail(guardrail_function=inspect_config_input)] + ) + state.add_input("Guard only this") + model.set_next_output([get_text_message("Done")]) + + result = await Runner.run(agent, state, run_config=run_config) + + assert result.final_output == "Done" + assert {source for source, _batch in guarded_inputs} == {"agent", "config"} + assert [[_message_text(item) for item in batch] for _source, batch in guarded_inputs] == [ + ["Guard only this"], + ["Guard only this"], + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed_retry", [False, True]) +async def test_guardrail_retry_persists_successful_turn_with_session( + streamed_retry: bool, +) -> None: + session = SimpleListSession() + model, agent, state, _calls = await _make_after_turn_state(session=session) + should_trip = True + + def inspect_pending_input( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _input: str | list[TResponseInputItem], + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=should_trip) + + agent.input_guardrails = [InputGuardrail(guardrail_function=inspect_pending_input)] + state.add_input("Late input") + + if streamed_retry: + tripped = Runner.run_streamed(agent, state, session=session) + with pytest.raises(InputGuardrailTripwireTriggered): + async for _event in tripped.stream_events(): + pass + else: + with pytest.raises(InputGuardrailTripwireTriggered): + await Runner.run(agent, state, session=session) + + should_trip = False + model.set_next_output([get_text_message("Recovered")]) + if streamed_retry: + streamed_result = Runner.run_streamed(agent, state, session=session) + async for _event in streamed_result.stream_events(): + pass + final_output = streamed_result.final_output + else: + run_result = await Runner.run(agent, state, session=session) + final_output = run_result.final_output + + assert final_output == "Recovered" + session_items = await session.get_items() + assert [_message_text(item) for item in session_items].count("Late input") == 1 + assert _item_type(session_items[-1]) == "message" + assert cast(dict[str, Any], session_items[-1]).get("role") == "assistant" + assert [result.output.tripwire_triggered for result in state._input_guardrail_results] == [ + True, + False, + ] + + +@pytest.mark.asyncio +async def test_failed_model_request_does_not_duplicate_admitted_input_on_resume() -> None: + model, agent, state, _calls = await _make_after_turn_state() + state.add_input("Late input") + model.set_next_output(RuntimeError("model failed")) + + with pytest.raises(RuntimeError, match="model failed"): + await Runner.run(agent, state) + + assert state.pending_input == [] + admitted_items = [item for item in state._generated_items if isinstance(item, InputItem)] + assert [_message_text(item.raw_item) for item in admitted_items] == ["Late input"] + admitted_input_id = admitted_items[0].input_id + + state = await RunState.from_json(agent, state.to_json()) + assert ( + next(item.input_id for item in state._generated_items if isinstance(item, InputItem)) + == admitted_input_id + ) + model.set_next_output([get_text_message("Recovered")]) + result = await Runner.run(agent, state) + assert result.final_output == "Recovered" + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Late input") == 1 + + +@pytest.mark.asyncio +async def test_failed_model_request_with_session_persists_admitted_input_once() -> None: + session = SimpleListSession() + model, agent, state, _calls = await _make_after_turn_state(session=session) + state.add_input("Late input") + model.set_next_output(RuntimeError("model failed")) + + with pytest.raises(RuntimeError, match="model failed"): + await Runner.run(agent, state, session=session) + + assert state.pending_input == [] + assert [_message_text(item) for item in await session.get_items()].count("Late input") == 1 + + state = await RunState.from_json(agent, state.to_json()) + model.set_next_output([get_text_message("Recovered")]) + result = await Runner.run(agent, state, session=session) + assert result.final_output == "Recovered" + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Late input") == 1 + assert [_message_text(item) for item in await session.get_items()].count("Late input") == 1 + + +@pytest.mark.asyncio +async def test_failed_server_managed_request_keeps_pending_input_for_retry() -> None: + model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Late input") + model.set_next_output(RuntimeError("model failed")) + + with pytest.raises(RuntimeError, match="model failed"): + await Runner.run(agent, state) + + assert _message_text(state.pending_input[0]) == "Late input" + state = await RunState.from_json(agent, state.to_json()) + model.set_next_output([get_text_message("Recovered")]) + result = await Runner.run(agent, state) + assert result.final_output == "Recovered" + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Late input") == 1 + assert state.pending_input == [] + + +@pytest.mark.asyncio +async def test_server_filter_omission_remains_pending_for_later_nonstream_turn() -> None: + model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Late input") + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "record_destination", + json.dumps({"destination": "Rome"}), + call_id="call-filtered-destination", + ) + ], + [get_text_message("Done")], + ] + ) + filter_calls = 0 + + def omit_first_request(data: CallModelData[Any]) -> ModelInputData: + nonlocal filter_calls + filter_calls += 1 + return ModelInputData( + input=[] if filter_calls == 1 else data.model_data.input, + instructions=data.model_data.instructions, + ) + + result = await Runner.run( + agent, + state, + run_config=RunConfig(call_model_input_filter=omit_first_request), + ) + + assert result.final_output == "Done" + assert calls == ["Paris", "Rome"] + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Late input") == 1 + assert state.pending_input == [] + + +@pytest.mark.asyncio +async def test_server_filter_omission_survives_streamed_state_round_trip() -> None: + model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Late input") + model.set_next_output( + [ + get_function_tool_call( + "record_destination", + json.dumps({"destination": "Rome"}), + call_id="call-filtered-destination", + ) + ] + ) + + def omit_pending(data: CallModelData[Any]) -> ModelInputData: + return ModelInputData(input=[], instructions=data.model_data.instructions) + + filtered = Runner.run_streamed( + agent, + state, + run_config=RunConfig(call_model_input_filter=omit_pending), + ) + async for event in filtered.stream_events(): + if event.type == "run_item_stream_event" and event.name == "tool_output": + filtered.cancel(mode="after_turn") + + state = await RunState.from_json(agent, filtered.to_state().to_json()) + assert [_message_text(item) for item in state.pending_input] == ["Late input"] + assert not any(isinstance(item, InputItem) for item in state._generated_items) + + model.set_next_output([get_text_message("Done")]) + result = await Runner.run(agent, state) + assert result.final_output == "Done" + assert calls == ["Paris", "Rome"] + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Late input") == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_server_filter_reconstructed_pending_rewrite_is_rejected(streamed: bool) -> None: + model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Late input") + model.set_next_output([get_text_message("Done")]) + + def reconstruct_pending(data: CallModelData[Any]) -> ModelInputData: + rewritten = [ + {"role": "user", "content": "Filtered late input"} + if _message_text(item) == "Late input" + else item + for item in data.model_data.input + ] + return ModelInputData( + input=cast(list[TResponseInputItem], rewritten), + instructions=data.model_data.instructions, + ) + + queued_outputs = len(model.turn_outputs) + run_config = RunConfig(call_model_input_filter=reconstruct_pending) + if streamed: + failed = Runner.run_streamed(agent, state, run_config=run_config) + with pytest.raises(UserError, match="cannot safely associate"): + async for _event in failed.stream_events(): + pass + else: + with pytest.raises(UserError, match="cannot safely associate"): + await Runner.run(agent, state, run_config=run_config) + + assert len(model.turn_outputs) == queued_outputs + assert [_message_text(item) for item in state.pending_input] == ["Late input"] + + +@pytest.mark.asyncio +async def test_server_filter_in_place_pending_rewrite_preserves_occurrence() -> None: + model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Late input") + model.set_next_output([get_text_message("Done")]) + + def rewrite_pending_in_place(data: CallModelData[Any]) -> ModelInputData: + for item in data.model_data.input: + if isinstance(item, dict) and _message_text(item) == "Late input": + cast(dict[str, Any], item)["content"] = "Filtered late input" + return data.model_data + + result = await Runner.run( + agent, + state, + run_config=RunConfig(call_model_input_filter=rewrite_pending_in_place), + ) + + assert result.final_output == "Done" + model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in model_input].count("Filtered late input") == 1 + assert state.pending_input == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed_failure", [False, True]) +async def test_server_response_acceptance_commits_before_hook_failure( + streamed_failure: bool, +) -> None: + class CountAgentResponseHook(AgentHooks[Any]): + def __init__(self) -> None: + self.call_count = 0 + + async def on_llm_end( + self, + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _response: ModelResponse, + ) -> None: + self.call_count += 1 + + class FailAfterResponse(RunHooks[Any]): + async def on_llm_end( + self, + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _response: ModelResponse, + ) -> None: + raise RuntimeError("after response") + + model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) + agent_hooks = CountAgentResponseHook() + agent.hooks = agent_hooks + state.add_input("Late input") + model.set_next_output([get_text_message("Accepted")]) + + if streamed_failure: + failed = Runner.run_streamed(agent, state, hooks=FailAfterResponse()) + with pytest.raises(RuntimeError, match="after response"): + async for _event in failed.stream_events(): + pass + else: + with pytest.raises(RuntimeError, match="after response"): + await Runner.run(agent, state, hooks=FailAfterResponse()) + + accepted_model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in accepted_model_input].count("Late input") == 1 + assert state.pending_input == [] + assert isinstance(state._current_step, NextStepInterruption) + assert state._current_step.response_accepted + assert state._current_step.llm_end_hooks_started + assert agent_hooks.call_count == 1 + state = await RunState.from_json(agent, state.to_json()) + queued_outputs = len(model.turn_outputs) + + recovered = await Runner.run(agent, state) + assert recovered.final_output == "Accepted" + assert agent_hooks.call_count == 1 + assert len(model.turn_outputs) == queued_outputs + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed_failure", [False, True]) +async def test_server_acceptance_commits_before_invocation_validation_failure( + streamed_failure: bool, +) -> None: + model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Late input") + model.set_next_output( + [ + get_function_tool_call( + "record_destination", + json.dumps({"destination": "Rome"}), + call_id="call-destination", + ) + ] + ) + + if streamed_failure: + failed = Runner.run_streamed(agent, state) + with pytest.raises(ModelBehaviorError, match="completed tool call ID"): + async for _event in failed.stream_events(): + pass + else: + with pytest.raises(ModelBehaviorError, match="completed tool call ID"): + await Runner.run(agent, state) + + accepted_model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in accepted_model_input].count("Late input") == 1 + assert state.pending_input == [] + assert isinstance(state._current_step, NextStepInterruption) + assert state._current_step.response_accepted + assert state._last_processed_response is None + assert calls == ["Paris"] + + state = await RunState.from_json(agent, state.to_json()) + queued_outputs = len(model.turn_outputs) + with pytest.raises(UserError, match="accepted model response could not be processed"): + await Runner.run(agent, state) + assert len(model.turn_outputs) == queued_outputs + assert calls == ["Paris"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed_failure", [False, True]) +async def test_server_accepted_computer_start_hook_failure_is_not_replayed( + streamed_failure: bool, +) -> None: + screenshots: list[str] = [] + + class RecordingComputer(FakeComputer): + def screenshot(self) -> str: + screenshots.append("screenshot") + return "img" + + class FailComputerStart(RunHooks[Any]): + def __init__(self) -> None: + self.call_count = 0 + + async def on_tool_start( + self, + _context: RunContextWrapper[Any], + _agent: Agent[Any], + tool: Tool, + ) -> None: + if isinstance(tool, ComputerTool): + self.call_count += 1 + raise RuntimeError("computer hook failed") + + model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) + agent.tools = [ComputerTool(computer=RecordingComputer())] + state.add_input("Late input") + model.set_next_output( + [ + ResponseComputerToolCall( + id="computer-item", + type="computer_call", + action=ActionScreenshot(type="screenshot"), + call_id="computer-call", + pending_safety_checks=[], + status="completed", + ) + ] + ) + hooks = FailComputerStart() + + if streamed_failure: + failed = Runner.run_streamed(agent, state, hooks=hooks) + with pytest.raises(RuntimeError, match="computer hook failed"): + async for _event in failed.stream_events(): + pass + else: + with pytest.raises(RuntimeError, match="computer hook failed"): + await Runner.run(agent, state, hooks=hooks) + + assert hooks.call_count == 1 + assert screenshots == [] + assert isinstance(state._current_step, NextStepInterruption) + assert state._current_step.response_accepted + + state = await RunState.from_json(agent, state.to_json()) + with pytest.raises(ModelBehaviorError, match="output was not committed"): + await Runner.run(agent, state) + assert hooks.call_count == 1 + assert screenshots == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed_failure", [False, True]) +@pytest.mark.parametrize("failure_phase", ["start", "end"]) +async def test_server_accepted_tool_side_effect_failure_is_safe( + streamed_failure: bool, + failure_phase: str, +) -> None: + class FailToolHook(RunHooks[Any]): + async def on_tool_start( + self, + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _tool: Tool, + ) -> None: + if failure_phase == "start": + raise RuntimeError("tool hook failed") + + async def on_tool_end( + self, + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _tool: Tool, + _result: object, + ) -> None: + if failure_phase == "end": + raise RuntimeError("tool hook failed") + + model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) + state.add_input("Late input") + model.add_multiple_turn_outputs( + [ + [ + get_function_tool_call( + "record_destination", + json.dumps({"destination": "Rome"}), + call_id="call-retry-destination", + ) + ], + [get_text_message("Recovered")], + ] + ) + + if streamed_failure: + failed = Runner.run_streamed(agent, state, hooks=FailToolHook()) + with pytest.raises(UserError, match="tool hook failed"): + async for _event in failed.stream_events(): + pass + else: + with pytest.raises(UserError, match="tool hook failed"): + await Runner.run(agent, state, hooks=FailToolHook()) + + assert state.pending_input == [] + assert isinstance(state._current_step, NextStepInterruption) + assert state._current_step.response_accepted + assert state._current_step.llm_end_hooks_started + assert calls == (["Paris"] if failure_phase == "start" else ["Paris", "Rome"]) + + state = await RunState.from_json(agent, state.to_json()) + if failure_phase == "start": + with pytest.raises(ModelBehaviorError, match="output was not committed"): + await Runner.run(agent, state) + assert calls == ["Paris"] + return + + recovered = await Runner.run(agent, state) + assert recovered.final_output == "Recovered" + assert calls == ["Paris", "Rome"] + retry_model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert [_message_text(item) for item in retry_model_input].count("Late input") == 0 + + +@pytest.mark.asyncio +async def test_terminal_state_rejects_pending_input_without_mutation() -> None: + model = FakeModel(initial_output=[get_text_message("Done")]) + agent = Agent(name="assistant", model=model) + result = await Runner.run(agent, "Initial request") + state = result.to_state() + before = state.to_json() + + with pytest.raises(UserError, match="terminal RunState"): + state.add_input("Too late") + + assert state.to_json() == before From 443e1f5113f4ca212dc6bcaa12806392c442d4cc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 21:44:16 +0900 Subject: [PATCH 256/473] fix: prune orphaned tool outputs from limited sessions (#4324) --- src/agents/run_internal/items.py | 83 +++++++-- .../run_internal/session_persistence.py | 4 + tests/memory/test_session_limit.py | 59 +++++++ tests/test_agent_runner.py | 165 +++++++++++++++++- 4 files changed, 283 insertions(+), 28 deletions(-) diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index 9cacc50501..976bdc8666 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -172,15 +172,17 @@ def drop_orphan_function_calls( items: list[TResponseInputItem], *, pruning_indexes: set[int] | None = None, + output_pruning_indexes: set[int] | None = None, ) -> list[TResponseInputItem]: """ Remove tool and program call items that do not have corresponding outputs so resumptions or - retries do not replay stale calls. Program-owned items are removed with an orphan program, - while programs with retained hosted calls or tool outputs remain available for continuation. - Reasoning items that immediately precede a call dropped by this pass are also removed, since - the Responses API rejects reasoning items that are not followed by their associated - model-emitted item (``Item 'rs_...' of type 'reasoning' was provided without its required - following item``). + retries do not replay stale calls. When ``output_pruning_indexes`` identifies unambiguous + stored history, also remove tool outputs whose corresponding calls are no longer present after + history pruning. Program-owned items are removed with an orphan program, while programs with + retained hosted calls or tool outputs remain available for continuation. Reasoning items that + immediately precede a call dropped by this pass are also removed, since the Responses API + rejects reasoning items that are not followed by their associated model-emitted item (``Item + 'rs_...' of type 'reasoning' was provided without its required following item``). """ completed_call_ids = _completed_call_ids_by_type(items) @@ -208,54 +210,71 @@ def drop_orphan_function_calls( orphan_program_call_ids.add(call_id) dropped_indexes: set[int] = set() - filtered: list[TResponseInputItem] = [] + reasoning_trigger_indexes: set[int] = set() for index, entry in enumerate(items): if not isinstance(entry, dict): - filtered.append(entry) continue entry_type = entry.get("type") if not isinstance(entry_type, str): - filtered.append(entry) continue if pruning_indexes is not None and index not in pruning_indexes: - filtered.append(entry) continue program_caller_id = _get_program_caller_id(entry) if program_caller_id is not None and program_caller_id in orphan_program_call_ids: dropped_indexes.add(index) + reasoning_trigger_indexes.add(index) continue + call_id = entry.get("call_id") output_type = _TOOL_CALL_TO_OUTPUT_TYPE.get(entry_type) if output_type is None: - filtered.append(entry) continue - call_id = entry.get("call_id") if program_caller_id is not None and _is_pending_hosted_shell_call(entry): - filtered.append(entry) continue if entry_type == "program" and call_id in active_program_call_ids: - filtered.append(entry) continue if isinstance(call_id, str) and call_id in completed_call_ids.get(output_type, set()): - filtered.append(entry) continue if ( entry_type == "tool_search_call" and not isinstance(call_id, str) and index in matched_anonymous_tool_search_calls ): - filtered.append(entry) continue # Tool call entry will be dropped; record so we can also drop preceding reasoning items. dropped_indexes.add(index) + reasoning_trigger_indexes.add(index) + + available_call_ids = _available_call_ids_by_output_type( + items, + excluded_indexes=dropped_indexes, + ) + if output_pruning_indexes is not None: + for index in output_pruning_indexes: + if index in dropped_indexes or index < 0 or index >= len(items): + continue + entry = items[index] + if not isinstance(entry, dict): + continue + entry_type = entry.get("type") + if not isinstance(entry_type, str) or entry_type not in available_call_ids: + continue + call_id = entry.get("call_id") + if isinstance(call_id, str) and call_id not in available_call_ids[entry_type]: + dropped_indexes.add(index) if not dropped_indexes: - return filtered - return _drop_reasoning_items_preceding_dropped_calls(items, dropped_indexes) + return list(items) + return _drop_reasoning_items_preceding_dropped_calls( + items, + dropped_indexes, + reasoning_trigger_indexes, + ) def _drop_reasoning_items_preceding_dropped_calls( items: list[TResponseInputItem], dropped_indexes: set[int], + reasoning_trigger_indexes: set[int], ) -> list[TResponseInputItem]: """Drop reasoning items whose tied tool call was just dropped as orphan. @@ -278,7 +297,7 @@ def _drop_reasoning_items_preceding_dropped_calls( next_entry = items[next_index] if isinstance(next_entry, dict) and next_entry.get("type") == "reasoning": continue - if next_index in dropped_indexes: + if next_index in reasoning_trigger_indexes: drop_reasoning.add(index) break excluded = dropped_indexes | drop_reasoning @@ -959,6 +978,32 @@ def _completed_call_ids_by_type(payload: list[TResponseInputItem]) -> dict[str, return completed +def _available_call_ids_by_output_type( + payload: list[TResponseInputItem], + *, + excluded_indexes: set[int], +) -> dict[str, set[str]]: + """Return retained call ids grouped by their required output type.""" + available: dict[str, set[str]] = { + output_type: set() for output_type in _TOOL_CALL_TO_OUTPUT_TYPE.values() + } + for index, entry in enumerate(payload): + if index in excluded_indexes: + continue + if not isinstance(entry, dict): + continue + item_type = entry.get("type") + if not isinstance(item_type, str): + continue + output_type = _TOOL_CALL_TO_OUTPUT_TYPE.get(item_type) + if output_type is None: + continue + call_id = entry.get("call_id") + if isinstance(call_id, str): + available[output_type].add(call_id) + return available + + def _get_program_caller_id(entry: TResponseInputItem) -> str | None: """Return the owning program call id for a program-issued item.""" if not isinstance(entry, dict): diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index df4c84a7a5..44c39d5cef 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -372,6 +372,7 @@ async def prepare_input_with_session( ] prune_history_indexes: set[int] = set() + output_pruning_indexes: set[int] | None = None if session_input_callback is None or not include_history_in_prepared_input: prepared_items_raw: list[TResponseInputItem] = ( @@ -382,6 +383,8 @@ async def prepare_input_with_session( appended_items = list(new_input_list) if include_history_in_prepared_input: prune_history_indexes = set(range(len(converted_history))) + if session_input_callback is None and resolved_settings.limit is not None: + output_pruning_indexes = set(prune_history_indexes) else: if not callable(session_input_callback): raise UserError( @@ -465,6 +468,7 @@ async def prepare_input_with_session( filtered = drop_orphan_function_calls( prepared_as_inputs, pruning_indexes=prune_history_indexes, + output_pruning_indexes=output_pruning_indexes, ) normalized = normalize_input_items_for_api(filtered) deduplicated = deduplicate_input_items_preferring_latest(normalized) diff --git a/tests/memory/test_session_limit.py b/tests/memory/test_session_limit.py index 5b908ee967..3a2311d4be 100644 --- a/tests/memory/test_session_limit.py +++ b/tests/memory/test_session_limit.py @@ -2,10 +2,12 @@ import tempfile from pathlib import Path +from typing import cast import pytest from agents import Agent, RunConfig, SQLiteSession +from agents.items import TResponseInputItem from agents.memory import SessionSettings from tests.fake_model import FakeModel from tests.memory.test_session import run_agent_async @@ -62,6 +64,63 @@ async def test_session_limit_parameter(runner_method): session.close() +@pytest.mark.parametrize("runner_method", ["run", "run_sync", "run_streamed"]) +@pytest.mark.asyncio +async def test_session_limit_drops_unmatched_history_function_call_output(runner_method): + """A limit boundary must not pass an output whose matching call was excluded.""" + with tempfile.TemporaryDirectory() as temp_dir: + session = SQLiteSession("limit_tool_pair", Path(temp_dir) / "test_limit_tool_pair.db") + history = cast( + list[TResponseInputItem], + [ + {"role": "user", "content": "What is the weather?"}, + { + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "sunny", + }, + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "It is sunny.", + "annotations": [], + } + ], + }, + ], + ) + await session.add_items(history) + + assert await session.get_items(limit=2) == history[-2:] + + model = FakeModel() + model.set_next_output([get_text_message("Tomorrow is sunny too.")]) + agent = Agent(name="test", model=model) + + await run_agent_async( + runner_method, + agent, + "What about tomorrow?", + session=session, + run_config=RunConfig(session_settings=SessionSettings(limit=2)), + ) + + assert model.last_turn_args["input"] == [ + history[-1], + {"role": "user", "content": "What about tomorrow?"}, + ] + session.close() + + @pytest.mark.parametrize("runner_method", ["run", "run_sync", "run_streamed"]) @pytest.mark.asyncio async def test_session_limit_zero(runner_method): diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index f654aead68..1cce41035c 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -67,6 +67,7 @@ TResponseInputItem, ) from agents.lifecycle import RunHooks +from agents.memory import SessionSettings from agents.models.fake_id import FAKE_RESPONSES_ID from agents.run import AgentRunner, get_default_agent_runner, set_default_agent_runner from agents.run_config import _default_trace_include_sensitive_data @@ -2516,7 +2517,7 @@ async def guardrail_function( @pytest.mark.asyncio -async def test_prepare_input_with_session_keeps_function_call_outputs(): +async def test_prepare_input_with_session_keeps_orphan_output_without_limit(): history_item = cast( TResponseInputItem, { @@ -2529,14 +2530,160 @@ async def test_prepare_input_with_session_keeps_function_call_outputs(): prepared_input, session_items = await prepare_input_with_session("hello", session, None) - assert isinstance(prepared_input, list) - assert len(session_items) == 1 - assert cast(dict[str, Any], session_items[0]).get("role") == "user" - first_item = cast(dict[str, Any], prepared_input[0]) - last_item = cast(dict[str, Any], prepared_input[-1]) - assert first_item["type"] == "function_call_output" - assert last_item["role"] == "user" - assert last_item["content"] == "hello" + assert prepared_input == [history_item, {"role": "user", "content": "hello"}] + assert session_items == [{"role": "user", "content": "hello"}] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_drops_limited_orphan_history_function_call_outputs(): + history_item = cast( + TResponseInputItem, + { + "type": "function_call_output", + "call_id": "call_prepare", + "output": "ok", + }, + ) + session = SimpleListSession(history=[history_item]) + + prepared_input, session_items = await prepare_input_with_session( + "hello", + session, + None, + SessionSettings(limit=1), + ) + + assert prepared_input == [{"role": "user", "content": "hello"}] + assert session_items == [{"role": "user", "content": "hello"}] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_preserves_new_function_call_outputs(): + new_output = cast( + TResponseInputItem, + { + "type": "function_call_output", + "call_id": "call_prepare", + "output": "ok", + }, + ) + session = SimpleListSession() + + prepared_input, session_items = await prepare_input_with_session( + [new_output], + session, + None, + SessionSettings(limit=1), + ) + + assert prepared_input == [new_output] + assert session_items == [new_output] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_leaves_custom_callback_output_unchanged(): + history_output = cast( + TResponseInputItem, + { + "type": "function_call_output", + "call_id": "call_callback", + "output": "ok", + }, + ) + session = SimpleListSession(history=[history_output]) + + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + return history + new_input + + prepared_input, session_items = await prepare_input_with_session( + "hello", + session, + callback, + SessionSettings(limit=1), + ) + + assert prepared_input == [history_output, {"role": "user", "content": "hello"}] + assert session_items == [{"role": "user", "content": "hello"}] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_drops_output_for_program_owned_call_pruned_with_parent(): + program = cast( + TResponseInputItem, + { + "type": "program", + "call_id": "program_orphan", + "code": "return await tools.lookup({});", + "fingerprint": "fingerprint:orphan", + }, + ) + function_call = cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "call_orphan", + "name": "lookup", + "arguments": "{}", + "caller": {"type": "program", "caller_id": "program_orphan"}, + }, + ) + function_output = cast( + TResponseInputItem, + { + "type": "function_call_output", + "call_id": "call_orphan", + "output": "ok", + }, + ) + session = SimpleListSession(history=[program, function_call, function_output]) + + prepared_input, session_items = await prepare_input_with_session( + "hello", + session, + None, + SessionSettings(limit=3), + ) + + assert prepared_input == [{"role": "user", "content": "hello"}] + assert session_items == [{"role": "user", "content": "hello"}] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_keeps_paired_history_function_call_outputs(): + function_call = cast( + TResponseInputItem, + { + "type": "function_call", + "call_id": "call_prepare", + "name": "lookup", + "arguments": "{}", + }, + ) + function_call_output = cast( + TResponseInputItem, + { + "type": "function_call_output", + "call_id": "call_prepare", + "output": "ok", + }, + ) + session = SimpleListSession(history=[function_call, function_call_output]) + + prepared_input, session_items = await prepare_input_with_session( + "hello", + session, + None, + SessionSettings(limit=2), + ) + + assert prepared_input == [ + function_call, + function_call_output, + {"role": "user", "content": "hello"}, + ] + assert session_items == [{"role": "user", "content": "hello"}] @pytest.mark.asyncio From e3d7c1727bf43761afbb7954651b7f908a973a3b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 22:07:42 +0900 Subject: [PATCH 257/473] fix(litellm): omit parallel_tool_calls without tools (#4330) --- src/agents/extensions/models/litellm_model.py | 8 +--- tests/models/test_kwargs_functionality.py | 43 ++++++++++++++++++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 35ad0e5879..e64a896797 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -572,13 +572,6 @@ async def _fetch_response( if tracing.include_data(): span.span_data.input = converted_messages - parallel_tool_calls = ( - True - if model_settings.parallel_tool_calls and tools and len(tools) > 0 - else False - if model_settings.parallel_tool_calls is False - else None - ) tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) response_format = Converter.convert_response_format(output_schema) @@ -588,6 +581,7 @@ async def _fetch_response( converted_tools.append(Converter.convert_handoff_tool(handoff)) converted_tools = _to_dump_compatible(converted_tools) + parallel_tool_calls = model_settings.parallel_tool_calls if converted_tools else None if _debug.DONT_LOG_MODEL_DATA: logger.debug("Calling LLM") diff --git a/tests/models/test_kwargs_functionality.py b/tests/models/test_kwargs_functionality.py index 3b8a7cc65d..d87a063cbe 100644 --- a/tests/models/test_kwargs_functionality.py +++ b/tests/models/test_kwargs_functionality.py @@ -11,13 +11,14 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.completion_usage import CompletionUsage -from agents import Agent +from agents import Agent, function_tool, handoff from agents.extensions.models.litellm_model import LitellmModel from agents.model_settings import ModelSettings from agents.models._retry_runtime import provider_managed_retries_disabled from agents.models.interface import ModelTracing from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel from agents.retry import ModelRetryAdviceRequest, ModelRetrySettings +from agents.tool import Tool @pytest.mark.allow_call_model_methods @@ -69,6 +70,46 @@ async def fake_acompletion(model, messages=None, **kwargs): assert captured["temperature"] == 0.5 +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("parallel_tool_calls", [True, False]) +@pytest.mark.parametrize("tool_source", ["none", "function", "handoff"]) +async def test_litellm_only_forwards_parallel_tool_calls_with_converted_tools( + monkeypatch, parallel_tool_calls: bool, tool_source: str +): + captured: dict[str, object] = {} + + async def fake_acompletion(model, messages=None, **kwargs): + captured.update(kwargs) + message = Message(role="assistant", content="test response") + return ModelResponse(choices=[Choices(index=0, message=message)], usage=Usage(0, 0, 0)) + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + + tools: list[Tool] = ( + [function_tool(lambda: "ok", name_override="test_tool")] + if tool_source == "function" + else [] + ) + handoffs = [handoff(Agent(name="handoff"))] if tool_source == "handoff" else [] + + await LitellmModel(model="test-model").get_response( + system_instructions=None, + input="test input", + model_settings=ModelSettings(parallel_tool_calls=parallel_tool_calls), + tools=tools, + output_schema=None, + handoffs=handoffs, + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + ) + + expected_parallel_tool_calls = parallel_tool_calls if tool_source != "none" else None + assert captured["parallel_tool_calls"] is expected_parallel_tool_calls + assert (captured["tools"] is not None) is (tool_source != "none") + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("use_dictionary", [False, True], ids=["model-settings", "dictionary"]) From 9775b5eb0f4654bb6fc16ee079fb20c20aea4b88 Mon Sep 17 00:00:00 2001 From: Ribhav Jain Date: Mon, 10 Aug 2026 02:07:17 +0400 Subject: [PATCH 258/473] fix(mcp): serialize non-text content blocks as JSON instead of Python repr (#4338) --- src/agents/mcp/util.py | 6 ++++-- tests/mcp/model_compat.py | 6 ++++++ tests/mcp/test_mcp_util.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index d1edfd5504..3e7ade6d67 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -788,9 +788,11 @@ async def invoke_mcp_tool( ) ) else: - # Fall back to regular text content + # Fall back to text content holding the block serialized as JSON. + # ``str()`` on the dump would produce a Python repr (single quotes, + # ``None``/``True``), which the model cannot parse back as JSON. tool_output_list.append( - ToolOutputTextDict(type="text", text=str(item.model_dump(mode="json"))) + ToolOutputTextDict(type="text", text=item.model_dump_json()) ) if len(tool_output_list) == 1: tool_output = tool_output_list[0] diff --git a/tests/mcp/model_compat.py b/tests/mcp/model_compat.py index 6bd6133008..6a0f246b9e 100644 --- a/tests/mcp/model_compat.py +++ b/tests/mcp/model_compat.py @@ -4,6 +4,7 @@ from mcp import Tool as _Tool from mcp.types import ( + AudioContent as _AudioContent, CallToolResult as _CallToolResult, ImageContent as _ImageContent, InitializeResult as _InitializeResult, @@ -26,6 +27,11 @@ def __init__(self, **data: Any) -> None: super().__init__(**data) +class AudioContent(_AudioContent): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + class CallToolResult(_CallToolResult): def __init__(self, **data: Any) -> None: super().__init__(**data) diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 607d8df19d..fed250ea83 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -1764,6 +1764,37 @@ async def test_mcp_fastmcp_behavior_verification(): assert result == expected, f"Image should return {expected}, got {result}" +@pytest.mark.asyncio +async def test_non_text_content_serialized_as_json(): + """Non-text, non-image content blocks should reach the model as valid JSON, not repr.""" + + from mcp.types import ContentBlock, EmbeddedResource, ResourceLink, TextResourceContents + + from .model_compat import AudioContent + + server = FakeMCPServer() + server.add_tool("test_tool", {}) + + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="test_tool", inputSchema={}) + + resource_link = ResourceLink(type="resource_link", name="report", uri="resource://reports/1") + embedded = EmbeddedResource( + type="resource", + resource=TextResourceContents(uri="resource://reports/2", text="hello world"), + ) + audio = AudioContent(type="audio", data="AAAA", mimeType="audio/wav") + content_items: list[ContentBlock] = [resource_link, embedded, audio] + server._custom_content = content_items + result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") + + assert isinstance(result, list) and len(result) == len(content_items) + for output_item, content_item in zip(result, content_items, strict=False): + assert output_item["type"] == "text" + # The text must parse as JSON and round-trip the content block's data. + assert json.loads(output_item["text"]) == content_item.model_dump(mode="json") + + @pytest.mark.asyncio async def test_agent_convert_schemas_unset(): """Test that leaving convert_schemas_to_strict unset (defaulting to False) leaves tool schemas From 54f3f731b03737af345dee95302edbd4efe682b5 Mon Sep 17 00:00:00 2001 From: Ribhav Jain Date: Mon, 10 Aug 2026 02:10:02 +0400 Subject: [PATCH 259/473] fix(run_state): serialize containers of models and dataclasses as structured data (#4339) --- src/agents/run_state.py | 41 ++++++++++++++++++++++++++++------------- tests/test_run_state.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index e6bb61477b..87a75a7ecb 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -1307,20 +1307,8 @@ def _serialize_item( # Add additional fields based on item type if hasattr(item, "output"): - serialized_output = item.output try: - if hasattr(serialized_output, "model_dump"): - # ``output`` is the tool's actual return value, not a wire item, so keep - # fields left at their defaults. ``exclude_unset`` would drop them and make - # the restored ``.output`` disagree with the full model-facing ``raw_item``. - # Stay in Python mode and let ``_ensure_json_compatible`` handle JSON - # conversion below: ``mode="json"`` raises on values like non-UTF-8 bytes, - # which would trip the fallback and replace the whole structured output with - # an opaque string instead of a dict. - serialized_output = serialized_output.model_dump() - elif dataclasses.is_dataclass(serialized_output): - serialized_output = dataclasses.asdict(serialized_output) # type: ignore[arg-type] - serialized_output = _ensure_json_compatible(serialized_output) + serialized_output = _ensure_json_compatible(_serialize_output_value(item.output)) except Exception: serialized_output = str(item.output) result["output"] = serialized_output @@ -1736,6 +1724,33 @@ def _ensure_json_compatible(value: Any) -> Any: return str(value) +def _serialize_output_value(value: Any) -> Any: + """Convert a tool output value, including containers of models, to plain data. + + ``_ensure_json_compatible`` stringifies anything ``json.dumps`` cannot handle, so + Pydantic models and dataclasses nested in containers would otherwise degrade to + their reprs instead of structured data. Sets and models nested inside dataclass + instances intentionally keep the previous behavior and degrade through + ``_ensure_json_compatible``'s string fallback. + """ + if hasattr(value, "model_dump"): + # ``output`` is the tool's actual return value, not a wire item, so keep fields + # left at their defaults. ``exclude_unset`` would drop them and make the restored + # ``.output`` disagree with the full model-facing ``raw_item``. Stay in Python + # mode and let ``_ensure_json_compatible`` handle JSON conversion afterwards: + # ``mode="json"`` raises on values like non-UTF-8 bytes, which would trip the + # fallback and replace the whole structured output with an opaque string + # instead of a dict. + return value.model_dump() + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return dataclasses.asdict(value) + if isinstance(value, dict): + return {key: _serialize_output_value(item) for key, item in value.items()} + if isinstance(value, list | tuple): + return [_serialize_output_value(item) for item in value] + return value + + def _serialize_tool_call_data(tool_call: Any) -> Any: """Convert a tool call to a serializable dictionary.""" return _serialize_raw_item_value(tool_call) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 163a9dd06d..601a36d7cf 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3128,6 +3128,46 @@ async def test_deserializes_computer_call_output_acknowledged_safety_checks(self assert raw_item_again["acknowledged_safety_checks"] == expected_checks json.dumps(roundtripped.to_json()) + async def test_serializes_output_containers_of_models(self): + """Containers of Pydantic models and dataclasses should serialize as structured data.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ItemAgent") + + class Weather(BaseModel): + city: str + temperature: int + + @dataclass + class Reading: + value: int + label: str + + cases: list[tuple[Any, Any]] = [ + ([Weather(city="sf", temperature=18)], [{"city": "sf", "temperature": 18}]), + ( + {"today": Weather(city="sf", temperature=18)}, + {"today": {"city": "sf", "temperature": 18}}, + ), + ((Reading(value=1, label="ok"),), [{"value": 1, "label": "ok"}]), + ] + for output, expected in cases: + state = make_state(agent, context=context, original_input="test", max_turns=5) + state._generated_items.append( + ToolCallOutputItem( + agent=agent, + raw_item={"type": "function_call_output", "call_id": "c1", "output": "x"}, + output=output, + ) + ) + + json_data = state.to_json() + assert json_data["generated_items"][0]["output"] == expected + + new_state = await RunState.from_json(agent, json_data) + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolCallOutputItem) + assert restored_item.output == expected + async def test_deserializes_tool_call_output_custom_data(self): """SDK-only tool output custom data should survive RunState roundtrips.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) From 020db0addfb75a730235396ce5f0d6cf9a2b4be2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 07:21:42 +0900 Subject: [PATCH 260/473] fix: allow post-completion review feedback cycles --- .../implementation-final-review/SKILL.md | 29 +++++++++++-------- .../scripts/test_skill_contract.py | 13 ++++++--- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index e39381a608..9d398c48f7 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -1,6 +1,6 @@ --- name: implementation-final-review -description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use compact self-contained reviewer packets and two concurrent no-history independent reviewers per round, overlap non-mutating final repository verification with long event-driven reviewer waits on the same frozen fingerprint, preserve clean evidence for unchanged semantic components, close repeated root-cause groups instead of accumulating local patches, and enforce one task-global bounded round ledger. +description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use compact self-contained reviewer packets and two concurrent no-history independent reviewers per round, overlap non-mutating final repository verification with long event-driven reviewer waits on the same frozen fingerprint, preserve clean evidence for unchanged semantic components, close repeated root-cause groups instead of accumulating local patches, and enforce bounded review cycles in one task-global ledger. --- # Implementation Final Review @@ -16,9 +16,17 @@ Treat implementation and final review as separate phases. Reconstruct the change - Start independent reviewers without inherited conversation history. Fresh judgment does not require repeatedly replaying the implementer's context. - Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. - Never weaken final repository verification. Component-aware review invalidation reduces repeated review, not required build or test gates. -- Keep one task-global round ledger and one bounded review budget across pauses, compaction, handoff, renaming, and resumed work. +- Keep one task-global round ledger across pauses, compaction, handoff, renaming, resumed work, and post-completion feedback. Enforce a bounded budget for each active review cycle without discarding earlier history. - Trust the active implementation control plane to record actual reviewer dispatches, waits, outputs, and verification executions. The local protocol helper validates those records but does not replace platform-issued cryptographic execution attestation. +## Post-completion feedback boundary + +An implementation review cycle is complete only after its clean-review gate, mandatory verification, any requested local commit, and final user-facing handoff are complete. Seal that cycle at this boundary. A pause, compaction, context change, agent handoff before completion, or ordinary request to continue unfinished work does not create a new cycle or reset its budget. + +A later user message containing concrete actionable review feedback starts a post-completion feedback cycle. The feedback message itself authorizes implementing that feedback and running the repository-mandated focused tests, delta review, verification, and local commit or amendment needed to return the task to a completed state. Do not ask for separate review-budget authorization merely because the sealed implementation cycle exhausted its budget. + +Keep the same task identity and ledger, preserve its canonical root-cause history and clean credit for unchanged components, and append a default budget of two fingerprint rounds for the new feedback cycle. Ask the user again only when the feedback materially widens the requested contract, changes a released or durable compatibility boundary, requires authority beyond resolving the feedback, or exhausts the feedback-cycle budget. + ## Workflow 1. Finish the initial implementation and focused tests. Apply formatting before review when formatting can rewrite the diff. @@ -35,8 +43,7 @@ Treat implementation and final review as separate phases. Reconstruct the change 7. Build the pre-dispatch evidence required by the changed boundary: - For every changed public symbol, configuration field, event, serialized field, wire value, or documented caller-visible behavior, create a contract-surface inventory: producers and constructors; every consumer, forwarding branch, and adapter; default, missing, and invalid-value behavior; package exports and generated public surfaces when applicable; adjacent docs and examples; and caller-visible tests. Search adjacent contract surfaces even when they are absent from the diff. A required docs, example, export, adapter, or generated-surface update is a missing task deliverable, not out of scope merely because it is not yet in the manifest. - For concurrency, cancellation, reentrancy, shared lifecycle state, or a check followed by an await before a side effect, create an await-boundary matrix. For each relevant operation, record the state snapshot, blocking or await point, events and operations that may run while suspended, durable or monotonic evidence retained, revalidation before each side effect, and resulting cancel, feedback, persistence, or cleanup action. Include source completion, a newer operation active with known and unknown identity, a newer operation that starts and completes while suspended, and failure or cancellation of the awaited action when those states are supported. If correctness depends on whether something ever happened, current active state is insufficient unless serialization proves it cannot be lost; require monotonic identity, generation, tombstone, or equivalent durable evidence. - - For protocol, persistence, or security changes, create the analogous authority/data-flow inventory from input through validation, storage, retry or replay, output, exceptions, logs, telemetry, and cleanup. - Treat these as mechanical coverage artifacts, not implementation conclusions. The implementer must fill them from code and contract evidence before review; reviewers validate them independently against the complete diff and surrounding source. + - For protocol, persistence, or security changes, create the analogous authority/data-flow inventory from input through validation, storage, retry or replay, output, exceptions, logs, telemetry, and cleanup. Treat these as mechanical coverage artifacts, not implementation conclusions. The implementer must fill them from code and contract evidence before review; reviewers validate them independently against the complete diff and surrounding source. 8. Produce only concrete, patch-scoped findings that are reproducible from code, contract, documentation, or a focused probe. Do not report hypothetical extensibility or unrelated cleanup. Before concluding, account for every row in the contract-surface, await-boundary, and authority/data-flow inventories and every new or modified source of shared state. For a scenario outside the required behavior, run a differential check against the merge base or latest release and identify support evidence. Reachability through a public method, concurrent call, repeated call, host-language protocol, or third-party behavior is not by itself a supported contract. 9. Classify every finding before editing: - required-behavior defect; @@ -44,9 +51,8 @@ Treat implementation and final review as separate phases. Reconstruct the change - missing failure-path or adversarial coverage; - unsupported neighboring case that should fail earlier; - unnecessary machinery or duplicated source of truth; - - unrelated or unsupported suggestion to reject. - Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. -10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget is six fingerprint rounds for the entire task. Only explicit user authorization may start another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. + - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. +10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. 11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the complete raw diff as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires the complete-diff digest to match its `tracked_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix and canonical root ownership, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. 12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. During an iterative review round, use the narrowest evidence-based affected-boundary check: for changes unrelated to every `review_optional` owner, run `make tests-review`; for a leaf subsystem change, run `make tests-review` plus that subsystem's complete test file or directory without a marker filter; for cross-cutting core or shared test-infrastructure changes, run `make tests`. Inspect the current marker owners before choosing. Prefer an already successful same-fingerprint check over rerunning it, and never replay cumulative historical verification. Represent reusable success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The reduced check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete `make tests` gate. If the affected boundary is uncertain, run `make tests`. Record combined, component, and repository fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when all fingerprints match the reviewed repository state exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit only for changed or dependency-invalidated components and for any final gate whose fingerprint no longer matches. 13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged. Require two concurrent independent delta reviews of every changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. @@ -67,15 +73,14 @@ Treat implementation and final review as separate phases. Reconstruct the change - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. - - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. - Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean or delta-review evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. -21. Stop the autonomous loop when the task-global ledger reaches its current six-round budget. This is an absolute cap, not a target, and it does not reset when execution pauses or context changes. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or explicitly authorize another bounded budget. If the user authorizes continuation, append the new budget to the same ledger rather than replacing its history. + - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean or delta-review evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. +21. Stop the autonomous loop when the active cycle reaches its current budget: six fingerprint rounds for the initial implementation cycle or two for a post-completion feedback cycle. This is an absolute cap for the active cycle, not a target, and it does not reset when execution pauses or context changes. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or explicitly authorize another bounded budget. When concrete actionable feedback arrives after a successfully completed and sealed cycle, append the feedback cycle's default two-round budget to the same ledger without another authorization prompt. In every other case, append a user-authorized budget to the same ledger rather than replacing its history. -Maintain one compact round ledger throughout the loop and persist it as a durable, task-global artifact: +Maintain one compact round ledger throughout all review cycles and persist it as a durable, task-global artifact: `Round | component fingerprints | root-cause groups | highest severity | complexity delta | action | clean credit` -Persist enough task identity, used and authorized round budgets, fingerprints, root-cause closure state, and clean credit to resume without reconstructing prior rounds. Update it only at a meaningful state transition: round start, accepted finding batch, complexity reset, clean result, or verification result. Do not emit repeated waiting messages when neither reviewer state nor repository content changed. +Persist enough task identity, used and authorized round budgets, cycle boundaries, fingerprints, root-cause closure state, and clean credit to resume without reconstructing prior rounds. Update it only at a meaningful state transition: round start, accepted finding batch, complexity reset, clean result, verification result, sealed completion, or post-completion feedback-cycle start. Do not emit repeated waiting messages when neither reviewer state nor repository content changed. ## Independent reviewer diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index 09f1c97f61..f55bf80357 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -207,7 +207,7 @@ def test_independent_review_uses_no_history_and_event_driven_waits(self) -> None self.assertIn('dispatcher uses `fork_turns: "none"`', self.reviewer_brief) - def test_round_budget_is_task_global_and_cannot_silently_reset(self) -> None: + def test_round_budget_preserves_history_across_feedback_cycles(self) -> None: required_text = ( "Resume or create the task-global review ledger", "Use the Codex task or thread ID as the stable task identity when available", @@ -215,9 +215,14 @@ def test_round_budget_is_task_global_and_cannot_silently_reset(self) -> None: "preserve the same file when work moves to another worktree", "Never initialize a new counter merely because the task was paused, compacted, " "handed off, renamed, moved to another worktree, or resumed in another context", - "default autonomous budget is six fingerprint rounds for the entire task", - "Only explicit user authorization may start another bounded budget", - "append the new budget to the same ledger rather than replacing its history", + "default autonomous budget for the initial implementation cycle is six " + "fingerprint rounds", + "concrete actionable review feedback starts a post-completion feedback cycle", + "feedback message itself as authorization to append a default budget of two " + "fingerprint rounds to the same ledger", + "A continuation request without concrete new feedback remains in the existing cycle", + "append the feedback cycle's default two-round budget to the same ledger without " + "another authorization prompt", "Persist enough task identity, used and authorized round budgets", ) for text in required_text: From 7da5696020a82d7ee2546a557eb8990169e23815 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 08:28:33 +0900 Subject: [PATCH 261/473] fix(mcp): serialize manager lifecycle operations (#4340) Co-authored-by: Abhinav Kumar Singh --- src/agents/mcp/manager.py | 113 ++++++++-- tests/mcp/test_mcp_server_manager.py | 301 ++++++++++++++++++++++++++- 2 files changed, 394 insertions(+), 20 deletions(-) diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index c009abac5a..0a62123f86 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -39,21 +39,52 @@ def __init__(self, server: MCPServer) -> None: self._server = server self._queue: asyncio.Queue[_ServerCommand] = asyncio.Queue() self._task = asyncio.create_task(self._run()) + self._cleanup_future: asyncio.Future[None] | None = None @property def is_done(self) -> bool: return self._task.done() + @property + def is_stopping(self) -> bool: + return self._cleanup_future is not None + + @property + def cleanup_error(self) -> BaseException | None: + if ( + self._cleanup_future is None + or not self._cleanup_future.done() + or self._cleanup_future.cancelled() + ): + return None + return self._cleanup_future.exception() + + def add_done_callback(self, callback: Callable[[asyncio.Task[None]], None]) -> None: + self._task.add_done_callback(callback) + async def connect(self, timeout_seconds: float | None) -> None: await self._submit("connect", timeout_seconds) async def cleanup(self, timeout_seconds: float | None) -> None: - await self._submit("cleanup", timeout_seconds) + if self._cleanup_future is None: + loop = asyncio.get_running_loop() + self._cleanup_future = loop.create_future() + self._queue.put_nowait( + _ServerCommand( + action="cleanup", + timeout_seconds=timeout_seconds, + future=self._cleanup_future, + ) + ) + await asyncio.shield(self._cleanup_future) + + async def wait_until_stopped(self) -> None: + await asyncio.shield(self._task) async def _submit(self, action: str, timeout_seconds: float | None) -> None: loop = asyncio.get_running_loop() future: asyncio.Future[None] = loop.create_future() - await self._queue.put( + self._queue.put_nowait( _ServerCommand(action=action, timeout_seconds=timeout_seconds, future=future) ) await future @@ -177,6 +208,7 @@ def __init__( self.suppress_cancelled_error = suppress_cancelled_error self.connect_in_parallel = connect_in_parallel self._workers: dict[MCPServer, _ServerWorker] = {} + self._lifecycle_lock = asyncio.Lock() self.failed_servers: list[MCPServer] = [] self._failed_server_set: set[MCPServer] = set() @@ -225,6 +257,14 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool | None: async def connect_all(self) -> list[MCPServer]: """Connect all servers in order and return the active list.""" + if not await self._acquire_lifecycle_lock(): + return self.active_servers + try: + return await self._connect_all() + finally: + self._lifecycle_lock.release() + + async def _connect_all(self) -> list[MCPServer]: previous_connected_servers = set(self._connected_servers) previous_active_servers = list(self._active_servers) self.failed_servers = [] @@ -268,11 +308,19 @@ async def reconnect(self, *, failed_only: bool = True) -> list[MCPServer]: failed_only: If True, only retry servers that previously failed. If False, cleanup and retry all servers. """ + if not await self._acquire_lifecycle_lock(): + return self.active_servers + try: + return await self._reconnect(failed_only=failed_only) + finally: + self._lifecycle_lock.release() + + async def _reconnect(self, *, failed_only: bool) -> list[MCPServer]: if failed_only: failed_servers = self._unique_servers(self.failed_servers) servers_to_retry = await self._cleanup_servers(failed_servers) else: - await self.cleanup_all() + await self._cleanup_all() servers_to_retry = list(self._all_servers) self.failed_servers = [] self._failed_server_set = set() @@ -291,6 +339,23 @@ async def reconnect(self, *, failed_only: bool = True) -> list[MCPServer]: async def cleanup_all(self) -> None: """Cleanup all servers in reverse order.""" + if not await self._acquire_lifecycle_lock(): + return + try: + await self._cleanup_all() + finally: + self._lifecycle_lock.release() + + async def _acquire_lifecycle_lock(self) -> bool: + try: + await self._lifecycle_lock.acquire() + except asyncio.CancelledError: + if not self.suppress_cancelled_error: + raise + return False + return True + + async def _cleanup_all(self) -> None: for server in reversed(self._all_servers): try: await self._cleanup_server(server) @@ -362,23 +427,27 @@ def _record_failure(self, server: MCPServer, exc: BaseException, phase: str) -> async def _run_connect(self, server: MCPServer) -> None: if self.connect_in_parallel: - worker = self._get_worker(server) + worker = await self._get_worker(server) await worker.connect(self.connect_timeout_seconds) else: await self._run_with_timeout(server.connect, self.connect_timeout_seconds) async def _cleanup_server(self, server: MCPServer) -> None: + if ( + self.connect_in_parallel + and server not in self._workers + and server not in self._connected_servers + ): + return if self.connect_in_parallel and server in self._workers: worker = self._workers[server] - if worker.is_done: - self._workers.pop(server, None) - self._connected_servers.discard(server) - return try: await worker.cleanup(self.cleanup_timeout_seconds) finally: - self._workers.pop(server, None) - self._connected_servers.discard(server) + if worker.is_done: + self._handle_worker_done(server, worker) + elif self._workers.get(server) is worker: + self._connected_servers.discard(server) return try: await self._run_with_timeout(server.cleanup, self.cleanup_timeout_seconds) @@ -441,13 +510,33 @@ async def _connect_all_parallel(self, servers: list[MCPServer]) -> None: raise error raise RuntimeError(f"Failed to connect MCP server '{first_failure.name}'") - def _get_worker(self, server: MCPServer) -> _ServerWorker: + async def _get_worker(self, server: MCPServer) -> _ServerWorker: worker = self._workers.get(server) - if worker is None or worker.is_done: + if worker is not None and worker.is_stopping: + await worker.wait_until_stopped() + await worker.cleanup(self.cleanup_timeout_seconds) + self._discard_worker(server, worker) + worker = self._workers.get(server) + if worker is not None and worker.is_done: + self._discard_worker(server, worker) + worker = self._workers.get(server) + if worker is None: worker = _ServerWorker(server=server) self._workers[server] = worker + worker.add_done_callback(lambda _task: self._handle_worker_done(server, worker)) return worker + def _handle_worker_done(self, server: MCPServer, worker: _ServerWorker) -> None: + if worker.cleanup_error is None: + self._discard_worker(server, worker) + elif self._workers.get(server) is worker: + self._connected_servers.discard(server) + + def _discard_worker(self, server: MCPServer, worker: _ServerWorker) -> None: + if self._workers.get(server) is worker: + self._workers.pop(server, None) + self._connected_servers.discard(server) + def _remove_failed_server(self, server: MCPServer) -> None: if server in self._failed_server_set: self._failed_server_set.remove(server) diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index 7a5b677127..fb0877b1df 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -74,6 +74,54 @@ async def read_resource(self, uri: str) -> ReadResourceResult: return ReadResourceResult(contents=[]) +class BlockingCleanupServer(TaskBoundServer): + def __init__(self) -> None: + super().__init__() + self.cleanup_started = asyncio.Event() + self.allow_cleanup = asyncio.Event() + self.cleanup_finished = asyncio.Event() + self.connect_calls = 0 + self.cleanup_calls = 0 + self.active_generation: int | None = None + + async def connect(self) -> None: + await super().connect() + self.connect_calls += 1 + self.active_generation = self.connect_calls + self.cleaned = False + + async def cleanup(self) -> None: + self.cleanup_calls += 1 + self.cleanup_started.set() + await self.allow_cleanup.wait() + self.active_generation = None + try: + await super().cleanup() + finally: + self.cleanup_finished.set() + + +class BlockingCleanupFailureServer(TaskBoundServer): + def __init__(self) -> None: + super().__init__() + self.cleanup_started = asyncio.Event() + self.allow_cleanup = asyncio.Event() + self.connect_calls = 0 + self.cleanup_calls = 0 + + async def connect(self) -> None: + await super().connect() + self.connect_calls += 1 + if self.connect_calls == 1: + raise RuntimeError("connect failed") + + async def cleanup(self) -> None: + self.cleanup_calls += 1 + self.cleanup_started.set() + await self.allow_cleanup.wait() + raise RuntimeError("cleanup failed") + + class FlakyServer(MCPServer): def __init__(self, failures: int) -> None: super().__init__() @@ -482,6 +530,231 @@ async def test_manager_connects_in_worker_tasks_when_parallel() -> None: assert server.cleaned is True +@pytest.mark.asyncio +async def test_manager_serializes_overlapping_parallel_cleanup_calls() -> None: + server = BlockingCleanupServer() + manager = MCPServerManager([server], connect_in_parallel=True) + await manager.connect_all() + + first_cleanup = asyncio.create_task(manager.cleanup_all()) + await server.cleanup_started.wait() + second_cleanup = asyncio.create_task(manager.cleanup_all()) + + server.allow_cleanup.set() + await asyncio.wait_for(asyncio.gather(first_cleanup, second_cleanup), timeout=1) + + assert server.cleanup_calls == 1 + assert manager._workers == {} + assert manager._connected_servers == set() + + +@pytest.mark.asyncio +async def test_manager_serializes_parallel_cleanup_and_full_reconnect() -> None: + server = BlockingCleanupServer() + manager = MCPServerManager([server], connect_in_parallel=True) + await manager.connect_all() + + cleanup_task = asyncio.create_task(manager.cleanup_all()) + reconnect_task: asyncio.Task[list[MCPServer]] | None = None + try: + await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + reconnect_task = asyncio.create_task(manager.reconnect(failed_only=False)) + await asyncio.sleep(0) + + assert not reconnect_task.done() + assert server.connect_calls == 1 + + server.allow_cleanup.set() + await asyncio.wait_for(asyncio.gather(cleanup_task, reconnect_task), timeout=1) + + assert server.connect_calls == 2 + assert server.cleanup_calls == 1 + assert server.active_generation == 2 + assert manager.active_servers == [server] + assert manager._connected_servers == {server} + finally: + server.allow_cleanup.set() + tasks: list[asyncio.Task[Any]] = [cleanup_task] + if reconnect_task is not None: + tasks.append(reconnect_task) + await asyncio.gather(*tasks, return_exceptions=True) + await manager.cleanup_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["connect_all", "reconnect", "cleanup_all"]) +@pytest.mark.parametrize("suppress_cancelled_error", [True, False]) +async def test_manager_applies_cancellation_policy_while_waiting_for_lifecycle_lock( + operation: str, + suppress_cancelled_error: bool, +) -> None: + server = BlockingCleanupServer() + manager = MCPServerManager( + [server], + connect_in_parallel=True, + suppress_cancelled_error=suppress_cancelled_error, + ) + await manager.connect_all() + + lock_owner = asyncio.create_task(manager.cleanup_all()) + waiter: asyncio.Task[Any] | None = None + try: + await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + if operation == "connect_all": + waiter = asyncio.create_task(manager.connect_all()) + elif operation == "reconnect": + waiter = asyncio.create_task(manager.reconnect(failed_only=False)) + else: + waiter = asyncio.create_task(manager.cleanup_all()) + await asyncio.sleep(0) + + assert not waiter.done() + waiter.cancel() + result = await asyncio.wait_for(asyncio.gather(waiter, return_exceptions=True), timeout=1) + + if suppress_cancelled_error: + if operation == "cleanup_all": + assert result[0] is None + else: + assert result[0] == [server] + else: + assert isinstance(result[0], asyncio.CancelledError) + finally: + server.allow_cleanup.set() + tasks: list[asyncio.Task[Any]] = [lock_owner] + if waiter is not None: + tasks.append(waiter) + await asyncio.gather(*tasks, return_exceptions=True) + await manager.cleanup_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("suppress_cancelled_error", [True, False]) +async def test_manager_retains_parallel_cleanup_worker_after_caller_cancellation( + suppress_cancelled_error: bool, +) -> None: + server = BlockingCleanupServer() + manager = MCPServerManager( + [server], + connect_in_parallel=True, + suppress_cancelled_error=suppress_cancelled_error, + ) + await manager.connect_all() + + original_worker = manager._workers[server] + cleanup_task = asyncio.create_task(manager.cleanup_all()) + connect_task: asyncio.Task[list[MCPServer]] | None = None + try: + await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + cleanup_task.cancel() + cleanup_result = await asyncio.wait_for( + asyncio.gather(cleanup_task, return_exceptions=True), timeout=1 + ) + + if suppress_cancelled_error: + assert cleanup_result[0] is None + else: + assert isinstance(cleanup_result[0], asyncio.CancelledError) + assert manager._workers[server] is original_worker + assert not original_worker.is_done + + connect_task = asyncio.create_task(manager.connect_all()) + await asyncio.sleep(0) + + assert not connect_task.done() + assert manager._workers[server] is original_worker + assert server.connect_calls == 1 + + server.allow_cleanup.set() + await asyncio.wait_for(connect_task, timeout=1) + + assert original_worker.is_done + assert manager._workers[server] is not original_worker + assert manager._connected_servers == {server} + assert manager.active_servers == [server] + assert manager.failed_servers == [] + assert manager.errors == {} + assert server.connect_calls == 2 + assert server.cleanup_calls == 1 + assert server.active_generation == 2 + finally: + server.allow_cleanup.set() + tasks: list[asyncio.Task[Any]] = [cleanup_task] + if connect_task is not None: + tasks.append(connect_task) + await asyncio.gather(*tasks, return_exceptions=True) + await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_manager_discards_parallel_cleanup_worker_after_cancelled_caller() -> None: + server = BlockingCleanupServer() + manager = MCPServerManager([server], connect_in_parallel=True) + await manager.connect_all() + + original_worker = manager._workers[server] + cleanup_task = asyncio.create_task(manager.cleanup_all()) + try: + await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + cleanup_task.cancel() + cleanup_result = await asyncio.gather(cleanup_task, return_exceptions=True) + assert cleanup_result[0] is None + + assert manager._workers[server] is original_worker + assert not original_worker.is_done + + server.allow_cleanup.set() + await asyncio.wait_for(original_worker.wait_until_stopped(), timeout=1) + await asyncio.sleep(0) + + assert manager._workers == {} + assert manager._connected_servers == set() + finally: + server.allow_cleanup.set() + await asyncio.gather(cleanup_task, return_exceptions=True) + await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_manager_preserves_cleanup_failure_after_cancelled_retry() -> None: + server = BlockingCleanupFailureServer() + manager = MCPServerManager([server], connect_in_parallel=True) + await manager.connect_all() + + first_retry = asyncio.create_task(manager.reconnect()) + second_retry: asyncio.Task[list[MCPServer]] | None = None + try: + await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + first_retry.cancel() + assert await first_retry == [] + + second_retry = asyncio.create_task(manager.reconnect()) + await asyncio.sleep(0) + assert not second_retry.done() + + server.allow_cleanup.set() + assert await asyncio.wait_for(second_retry, timeout=1) == [] + + assert server.connect_calls == 1 + assert server.cleanup_calls == 1 + assert manager.active_servers == [] + assert manager.failed_servers == [server] + assert str(manager.errors[server]) == "cleanup failed" + worker = manager._workers[server] + assert worker.is_done + assert str(worker.cleanup_error) == "cleanup failed" + + assert await manager.connect_all() == [] + assert server.connect_calls == 1 + finally: + server.allow_cleanup.set() + tasks: list[asyncio.Task[Any]] = [first_retry] + if second_retry is not None: + tasks.append(second_retry) + await asyncio.gather(*tasks, return_exceptions=True) + await manager.cleanup_all() + + @pytest.mark.asyncio async def test_cross_task_cleanup_raises_without_manager() -> None: server = TaskBoundServer() @@ -553,7 +826,12 @@ async def test_manager_reconnect_does_not_retry_after_cleanup_failure( assert server.cleanup_calls == 1 assert server.resource_open is True assert str(manager.errors[server]) == "cleanup failed" - assert manager._workers == {} + if connect_in_parallel: + worker = manager._workers[server] + assert worker.is_done + assert str(worker.cleanup_error) == "cleanup failed" + else: + assert manager._workers == {} @pytest.mark.asyncio @@ -700,29 +978,36 @@ async def test_manager_strict_connect_parallel_cleans_up_workers() -> None: @pytest.mark.asyncio -async def test_manager_parallel_cleanup_clears_worker_on_failure() -> None: +async def test_manager_parallel_cleanup_retains_worker_outcome_on_failure() -> None: server = CleanupFailingServer() manager = MCPServerManager([server], connect_in_parallel=True) await manager.connect_all() await manager.cleanup_all() - assert server not in manager._workers + worker = manager._workers[server] + assert worker.is_done + assert str(worker.cleanup_error) == "cleanup failed" assert server not in manager._connected_servers @pytest.mark.asyncio -async def test_manager_parallel_cleanup_drops_worker_after_error() -> None: +async def test_manager_parallel_cleanup_retains_worker_after_error() -> None: class HangingCleanupWorker: def __init__(self) -> None: self.cleanup_calls = 0 + self.error = RuntimeError("cleanup failed") @property def is_done(self) -> bool: - return False + return self.cleanup_calls > 0 - async def cleanup(self) -> None: + @property + def cleanup_error(self) -> BaseException | None: + return self.error if self.is_done else None + + async def cleanup(self, timeout_seconds: float | None) -> None: self.cleanup_calls += 1 - raise RuntimeError("cleanup failed") + raise self.error server = FlakyServer(failures=0) manager = MCPServerManager([server], connect_in_parallel=True) @@ -730,7 +1015,7 @@ async def cleanup(self) -> None: await manager.cleanup_all() - assert manager._workers == {} + assert manager._workers[server].cleanup_error is not None @pytest.mark.asyncio From 54cc7d938f37dc6681e4d50171df336a47100e85 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 09:00:29 +0900 Subject: [PATCH 262/473] fix(mcp): bound lifecycle and CI waits (#4342) --- .github/workflows/tests.yml | 6 ++ src/agents/mcp/manager.py | 10 +- tests/mcp/test_mcp_server_manager.py | 144 +++++++++++++++++++++------ 3 files changed, 124 insertions(+), 36 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ec9d27ee7f..1cd6c79126 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,7 @@ env: jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -44,6 +45,7 @@ jobs: typecheck: runs-on: ubuntu-latest + timeout-minutes: 12 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -77,6 +79,7 @@ jobs: tests: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -120,6 +123,7 @@ jobs: mcp-v1-compat: runs-on: ubuntu-latest + timeout-minutes: 5 env: OPENAI_API_KEY: fake-for-tests steps: @@ -145,6 +149,7 @@ jobs: tests-windows: runs-on: windows-latest + timeout-minutes: 10 env: OPENAI_API_KEY: fake-for-tests steps: @@ -174,6 +179,7 @@ jobs: build-docs: runs-on: ubuntu-latest + timeout-minutes: 10 env: OPENAI_API_KEY: fake-for-tests steps: diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index 0a62123f86..d732993867 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -76,10 +76,11 @@ async def cleanup(self, timeout_seconds: float | None) -> None: future=self._cleanup_future, ) ) - await asyncio.shield(self._cleanup_future) - - async def wait_until_stopped(self) -> None: - await asyncio.shield(self._task) + cleanup_waiter = asyncio.shield(self._cleanup_future) + if timeout_seconds is None: + await cleanup_waiter + else: + await asyncio.wait_for(cleanup_waiter, timeout=timeout_seconds) async def _submit(self, action: str, timeout_seconds: float | None) -> None: loop = asyncio.get_running_loop() @@ -513,7 +514,6 @@ async def _connect_all_parallel(self, servers: list[MCPServer]) -> None: async def _get_worker(self, server: MCPServer) -> _ServerWorker: worker = self._workers.get(server) if worker is not None and worker.is_stopping: - await worker.wait_until_stopped() await worker.cleanup(self.cleanup_timeout_seconds) self._discard_worker(server, worker) worker = self._workers.get(server) diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index fb0877b1df..fbb56bd66e 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -20,6 +20,8 @@ from .model_compat import ListResourceTemplatesResult +TEST_TIMEOUT_SECONDS = 1 + class TaskBoundServer(MCPServer): def __init__(self) -> None: @@ -475,6 +477,13 @@ def test_manager_validates_lifecycle_timeout_assignment() -> None: assert manager.connect_timeout_seconds is None +def test_manager_defaults_to_finite_lifecycle_timeouts() -> None: + manager = MCPServerManager([]) + + assert manager.connect_timeout_seconds == 10.0 + assert manager.cleanup_timeout_seconds == 10.0 + + @pytest.mark.asyncio @pytest.mark.parametrize("connect_in_parallel", [False, True]) async def test_manager_uses_current_lifecycle_timeouts( @@ -537,15 +546,28 @@ async def test_manager_serializes_overlapping_parallel_cleanup_calls() -> None: await manager.connect_all() first_cleanup = asyncio.create_task(manager.cleanup_all()) - await server.cleanup_started.wait() - second_cleanup = asyncio.create_task(manager.cleanup_all()) + second_cleanup: asyncio.Task[None] | None = None + try: + await asyncio.wait_for(server.cleanup_started.wait(), timeout=TEST_TIMEOUT_SECONDS) + second_cleanup = asyncio.create_task(manager.cleanup_all()) - server.allow_cleanup.set() - await asyncio.wait_for(asyncio.gather(first_cleanup, second_cleanup), timeout=1) + server.allow_cleanup.set() + await asyncio.wait_for( + asyncio.gather(first_cleanup, second_cleanup), timeout=TEST_TIMEOUT_SECONDS + ) - assert server.cleanup_calls == 1 - assert manager._workers == {} - assert manager._connected_servers == set() + assert server.cleanup_calls == 1 + assert manager._workers == {} + assert manager._connected_servers == set() + finally: + server.allow_cleanup.set() + tasks = [first_cleanup] + if second_cleanup is not None: + tasks.append(second_cleanup) + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) @pytest.mark.asyncio @@ -557,7 +579,7 @@ async def test_manager_serializes_parallel_cleanup_and_full_reconnect() -> None: cleanup_task = asyncio.create_task(manager.cleanup_all()) reconnect_task: asyncio.Task[list[MCPServer]] | None = None try: - await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + await asyncio.wait_for(server.cleanup_started.wait(), timeout=TEST_TIMEOUT_SECONDS) reconnect_task = asyncio.create_task(manager.reconnect(failed_only=False)) await asyncio.sleep(0) @@ -565,7 +587,9 @@ async def test_manager_serializes_parallel_cleanup_and_full_reconnect() -> None: assert server.connect_calls == 1 server.allow_cleanup.set() - await asyncio.wait_for(asyncio.gather(cleanup_task, reconnect_task), timeout=1) + await asyncio.wait_for( + asyncio.gather(cleanup_task, reconnect_task), timeout=TEST_TIMEOUT_SECONDS + ) assert server.connect_calls == 2 assert server.cleanup_calls == 1 @@ -577,8 +601,10 @@ async def test_manager_serializes_parallel_cleanup_and_full_reconnect() -> None: tasks: list[asyncio.Task[Any]] = [cleanup_task] if reconnect_task is not None: tasks.append(reconnect_task) - await asyncio.gather(*tasks, return_exceptions=True) - await manager.cleanup_all() + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) @pytest.mark.asyncio @@ -599,7 +625,7 @@ async def test_manager_applies_cancellation_policy_while_waiting_for_lifecycle_l lock_owner = asyncio.create_task(manager.cleanup_all()) waiter: asyncio.Task[Any] | None = None try: - await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + await asyncio.wait_for(server.cleanup_started.wait(), timeout=TEST_TIMEOUT_SECONDS) if operation == "connect_all": waiter = asyncio.create_task(manager.connect_all()) elif operation == "reconnect": @@ -610,7 +636,9 @@ async def test_manager_applies_cancellation_policy_while_waiting_for_lifecycle_l assert not waiter.done() waiter.cancel() - result = await asyncio.wait_for(asyncio.gather(waiter, return_exceptions=True), timeout=1) + result = await asyncio.wait_for( + asyncio.gather(waiter, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) if suppress_cancelled_error: if operation == "cleanup_all": @@ -624,8 +652,10 @@ async def test_manager_applies_cancellation_policy_while_waiting_for_lifecycle_l tasks: list[asyncio.Task[Any]] = [lock_owner] if waiter is not None: tasks.append(waiter) - await asyncio.gather(*tasks, return_exceptions=True) - await manager.cleanup_all() + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) @pytest.mark.asyncio @@ -645,10 +675,11 @@ async def test_manager_retains_parallel_cleanup_worker_after_caller_cancellation cleanup_task = asyncio.create_task(manager.cleanup_all()) connect_task: asyncio.Task[list[MCPServer]] | None = None try: - await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + await asyncio.wait_for(server.cleanup_started.wait(), timeout=TEST_TIMEOUT_SECONDS) cleanup_task.cancel() cleanup_result = await asyncio.wait_for( - asyncio.gather(cleanup_task, return_exceptions=True), timeout=1 + asyncio.gather(cleanup_task, return_exceptions=True), + timeout=TEST_TIMEOUT_SECONDS, ) if suppress_cancelled_error: @@ -666,7 +697,7 @@ async def test_manager_retains_parallel_cleanup_worker_after_caller_cancellation assert server.connect_calls == 1 server.allow_cleanup.set() - await asyncio.wait_for(connect_task, timeout=1) + await asyncio.wait_for(connect_task, timeout=TEST_TIMEOUT_SECONDS) assert original_worker.is_done assert manager._workers[server] is not original_worker @@ -682,8 +713,10 @@ async def test_manager_retains_parallel_cleanup_worker_after_caller_cancellation tasks: list[asyncio.Task[Any]] = [cleanup_task] if connect_task is not None: tasks.append(connect_task) - await asyncio.gather(*tasks, return_exceptions=True) - await manager.cleanup_all() + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) @pytest.mark.asyncio @@ -695,24 +728,28 @@ async def test_manager_discards_parallel_cleanup_worker_after_cancelled_caller() original_worker = manager._workers[server] cleanup_task = asyncio.create_task(manager.cleanup_all()) try: - await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + await asyncio.wait_for(server.cleanup_started.wait(), timeout=TEST_TIMEOUT_SECONDS) cleanup_task.cancel() - cleanup_result = await asyncio.gather(cleanup_task, return_exceptions=True) + cleanup_result = await asyncio.wait_for( + asyncio.gather(cleanup_task, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) assert cleanup_result[0] is None assert manager._workers[server] is original_worker assert not original_worker.is_done server.allow_cleanup.set() - await asyncio.wait_for(original_worker.wait_until_stopped(), timeout=1) + await asyncio.wait_for(asyncio.shield(original_worker._task), timeout=TEST_TIMEOUT_SECONDS) await asyncio.sleep(0) assert manager._workers == {} assert manager._connected_servers == set() finally: server.allow_cleanup.set() - await asyncio.gather(cleanup_task, return_exceptions=True) - await manager.cleanup_all() + await asyncio.wait_for( + asyncio.gather(cleanup_task, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) @pytest.mark.asyncio @@ -724,16 +761,16 @@ async def test_manager_preserves_cleanup_failure_after_cancelled_retry() -> None first_retry = asyncio.create_task(manager.reconnect()) second_retry: asyncio.Task[list[MCPServer]] | None = None try: - await asyncio.wait_for(server.cleanup_started.wait(), timeout=1) + await asyncio.wait_for(server.cleanup_started.wait(), timeout=TEST_TIMEOUT_SECONDS) first_retry.cancel() - assert await first_retry == [] + assert await asyncio.wait_for(first_retry, timeout=TEST_TIMEOUT_SECONDS) == [] second_retry = asyncio.create_task(manager.reconnect()) await asyncio.sleep(0) assert not second_retry.done() server.allow_cleanup.set() - assert await asyncio.wait_for(second_retry, timeout=1) == [] + assert await asyncio.wait_for(second_retry, timeout=TEST_TIMEOUT_SECONDS) == [] assert server.connect_calls == 1 assert server.cleanup_calls == 1 @@ -744,15 +781,60 @@ async def test_manager_preserves_cleanup_failure_after_cancelled_retry() -> None assert worker.is_done assert str(worker.cleanup_error) == "cleanup failed" - assert await manager.connect_all() == [] + assert await asyncio.wait_for(manager.connect_all(), timeout=TEST_TIMEOUT_SECONDS) == [] assert server.connect_calls == 1 finally: server.allow_cleanup.set() tasks: list[asyncio.Task[Any]] = [first_retry] if second_retry is not None: tasks.append(second_retry) - await asyncio.gather(*tasks, return_exceptions=True) - await manager.cleanup_all() + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), timeout=TEST_TIMEOUT_SECONDS + ) + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) + + +@pytest.mark.asyncio +async def test_manager_bounds_wait_for_stopping_parallel_worker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def run_without_internal_timeout( + func: Callable[[], Awaitable[Any]], timeout_seconds: float | None + ) -> None: + del timeout_seconds + await func() + + monkeypatch.setattr(manager_module, "_run_with_timeout_in_task", run_without_internal_timeout) + server = BlockingCleanupServer() + manager = MCPServerManager( + [server], + connect_in_parallel=True, + cleanup_timeout_seconds=0.05, + ) + await manager.connect_all() + + original_worker = manager._workers[server] + try: + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) + + assert isinstance(manager.errors[server], asyncio.TimeoutError) + assert manager._workers[server] is original_worker + assert not original_worker.is_done + + assert await asyncio.wait_for(manager.connect_all(), timeout=TEST_TIMEOUT_SECONDS) == [] + + assert isinstance(manager.errors[server], asyncio.TimeoutError) + assert manager._workers[server] is original_worker + assert server.connect_calls == 1 + finally: + server.allow_cleanup.set() + await asyncio.wait_for(asyncio.shield(original_worker._task), timeout=TEST_TIMEOUT_SECONDS) + await asyncio.sleep(0) + await asyncio.wait_for(manager.cleanup_all(), timeout=TEST_TIMEOUT_SECONDS) + + assert manager._workers == {} + assert manager._connected_servers == set() + assert server.cleanup_calls == 1 @pytest.mark.asyncio From afd11195cf4275008e3fad578be099b842138f15 Mon Sep 17 00:00:00 2001 From: Lucca Boas <86315612+Luccacvb@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:20:49 -0300 Subject: [PATCH 263/473] fix(voice): report transcription session close failures to the consumer (#4343) --- src/agents/voice/pipeline.py | 15 ++++++++++++++- tests/voice/test_pipeline.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py index 7373308a8d..699543b4f8 100644 --- a/src/agents/voice/pipeline.py +++ b/src/agents/voice/pipeline.py @@ -134,6 +134,7 @@ async def process_turns(): disabled=self.config.tracing_disabled, ): transcription_session = None + reported_error = False try: try: emitted_intro = False @@ -170,10 +171,22 @@ async def process_turns(): # would see only the cleanup error. log_model_and_tool_action_error(logger, "Error processing voice turns", e) await output._add_error(e) + reported_error = True raise finally: if transcription_session is not None: - await transcription_session.close() + try: + await transcription_session.close() + except Exception as e: + log_model_and_tool_action_error( + logger, "Error closing voice transcription session", e + ) + # Report only if nothing else has, which keeps the turn error's + # precedence. Clean runs and cancelled producers both arrive here + # with no terminal event queued and no other way to be released. + if not reported_error: + await output._add_error(e) + raise # Only a clean run reaches here. The error path above has already queued its # terminal event, and a cancelled producer has no consumer left to serve, so diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 98275e5e07..8fc2a4e634 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1098,6 +1098,36 @@ async def run(self, _: str) -> AsyncIterator[str]: assert exc_info.value is turn_error +@pytest.mark.asyncio +async def test_voicepipeline_failing_close_after_a_clean_run_reaches_the_consumer() -> None: + # A clean run has no error to report, so a failing close is the only thing left that can + # release the consumer. Unreported, the result stream waits on a terminal event forever. + + close_error = RuntimeError("close blew up") + + class FailingCloseSession(FakeSession): + async def close(self) -> None: + raise close_error + + class FailingCloseSTT(FakeSTT): + async def create_session(self, *args: Any, **kwargs: Any) -> FailingCloseSession: + session = FailingCloseSession() + session.outputs = self.outputs + return session + + pipeline = VoicePipeline( + workflow=FakeWorkflow([["hello"]]), + stt_model=FailingCloseSTT(["hello"]), + tts_model=FakeTTS(), + ) + result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + + with pytest.raises(RuntimeError) as exc_info: + await asyncio.wait_for(extract_events(result), timeout=5) + + assert exc_info.value is close_error + + @pytest.mark.asyncio async def test_voicepipeline_cancelled_consumer_closes_the_session_without_further_tts() -> None: # Cancelling the consumer tears down the producer. The transcription session still has to be From 9a8ecd257d2f16c978d9da59da024e9d7957e48b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 14:20:58 +0900 Subject: [PATCH 264/473] fix: preserve sandbox error contracts during mount redaction (#4344) --- src/agents/exceptions.py | 282 ++++- src/agents/sandbox/_mount_security.py | 569 +++++++--- tests/extensions/sandbox/test_cloudflare.py | 16 +- tests/extensions/sandbox/test_vercel.py | 45 +- tests/sandbox/test_docker.py | 7 +- tests/sandbox/test_mount_security.py | 1038 ++++++++++++++++++- 6 files changed, 1776 insertions(+), 181 deletions(-) diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index ed7af430dc..9577a6be34 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -1,8 +1,17 @@ from __future__ import annotations +import asyncio +import builtins +import sys import traceback +import types from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, NoReturn +from typing import TYPE_CHECKING, Any, NoReturn, cast + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup +else: + BaseExceptionGroup = builtins.BaseExceptionGroup if TYPE_CHECKING: from .agent import Agent @@ -22,6 +31,8 @@ _DRAIN_STREAM_EVENTS_ATTR = "_agents_drain_queued_stream_events" _DATA_REDACTED_ATTR = "_agents_data_redacted" _DATA_REDACTED_ERROR_MESSAGE = "Error details are redacted." +_TYPE_NAMESPACE_DESCRIPTOR = cast(Any, type).__dict__["__dict__"] +_TYPE_MRO_DESCRIPTOR = cast(Any, type).__dict__["__mro__"] def _mark_error_to_drain_stream_events(error: BaseException) -> None: @@ -36,20 +47,277 @@ def _mark_error_data_redacted(error: BaseException) -> None: setattr(error, _DATA_REDACTED_ATTR, True) +def _base_exception_instance_dict(error: BaseException) -> dict[object, object] | None: + """Return built-in exception state without invoking subclass attribute descriptors.""" + try: + reduced = BaseException.__reduce__(error) + except BaseException: + return None + if type(reduced) is not tuple or len(reduced) < 3: + return None + state = reduced[2] + return state if type(state) is dict else None + + +def _static_type_metadata( + value: type, +) -> tuple[types.MappingProxyType[str, object], tuple[type, ...]] | None: + """Return built-in type metadata without invoking metaclass descriptors.""" + try: + namespace = _TYPE_NAMESPACE_DESCRIPTOR.__get__(value, type(value)) + mro = _TYPE_MRO_DESCRIPTOR.__get__(value, type(value)) + except BaseException: + return None + if type(namespace) is not types.MappingProxyType or type(mro) is not tuple: + return None + return cast(types.MappingProxyType[str, object], namespace), cast(tuple[type, ...], mro) + + +def _object_instance_dict( + value: object, + *, + trusted_base: type, +) -> dict[object, object] | None: + """Return instance state through a descriptor owned by a trusted base type.""" + value_metadata = _static_type_metadata(type(value)) + trusted_metadata = _static_type_metadata(trusted_base) + if value_metadata is None or trusted_metadata is None: + return None + _, value_mro = value_metadata + if not any(base is trusted_base for base in value_mro): + return None + + _, trusted_mro = trusted_metadata + for base in trusted_mro: + base_metadata = _static_type_metadata(base) + if base_metadata is None: + continue + namespace, _ = base_metadata + for name, descriptor in namespace.items(): + if ( + type(name) is str + and str.__eq__(name, "__dict__") is True + and type(descriptor) is types.GetSetDescriptorType + ): + try: + state = descriptor.__get__(value, type(value)) + except BaseException: + return None + return state if type(state) is dict else None + return None + + +def _exact_string_state_entry( + state: dict[object, object], + name: str, +) -> tuple[bool, object | None]: + """Read an exact string key without hashing or comparing provider keys.""" + for candidate, value in dict.items(state): + if type(candidate) is str and str.__eq__(candidate, name) is True: + return True, value + return False, None + + +def _exact_string_state_value( + state: dict[object, object], + name: str, +) -> object | None: + """Read an exact string key, returning `None` for a missing key.""" + return _exact_string_state_entry(state, name)[1] + + def _is_error_data_redacted(error: BaseException) -> bool: - return bool(getattr(error, _DATA_REDACTED_ATTR, False)) + state = _base_exception_instance_dict(error) + return state is not None and _exact_string_state_value(state, _DATA_REDACTED_ATTR) is True def _clear_data_redacted_error_traceback(error: BaseException) -> None: - if _is_error_data_redacted(error) and error.__traceback__ is not None: - traceback.clear_frames(error.__traceback__) + if not _is_error_data_redacted(error): + return + descriptor = cast(Any, BaseException.__traceback__) + source_traceback = descriptor.__get__(error, type(error)) + if source_traceback is not None: + traceback.clear_frames(source_traceback) def _detach_data_redacted_error_traceback(error: BaseException) -> None: if _is_error_data_redacted(error): - error.__traceback__ = None - error.__cause__ = None - error.__context__ = None + for descriptor in ( + cast(Any, BaseException.__traceback__), + cast(Any, BaseException.__cause__), + cast(Any, BaseException.__context__), + ): + descriptor.__set__(error, None) + + +def _replace_data_redacted_process_control_error( + error: BaseException, +) -> BaseException | None: + """Discard a process-control source and return a fresh value-free replacement.""" + error_type = type(error) + if issubclass(error_type, asyncio.CancelledError): + safe_error: BaseException | None = asyncio.CancelledError() + elif issubclass(error_type, GeneratorExit): + safe_error = GeneratorExit() + elif issubclass(error_type, KeyboardInterrupt): + safe_error = KeyboardInterrupt() + elif not issubclass(error_type, SystemExit): + return None + else: + try: + code_descriptor = type.__getattribute__(SystemExit, "__dict__")["code"] + code = cast(Any, code_descriptor).__get__(error, error_type) + except BaseException: + safe_error = SystemExit(1) + else: + if code is None: + safe_error = SystemExit() + elif type(code) is int: + safe_error = SystemExit(code) + else: + safe_error = SystemExit(1) + + assert safe_error is not None + _discard_exception_graph(error) + _mark_error_data_redacted(safe_error) + return safe_error + + +def _collect_nested_exceptions(value: object, linked: list[BaseException]) -> None: + """Collect exceptions reachable through exact built-in containers without callbacks.""" + pending = [value] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if issubclass(type(current), BaseException): + linked.append(cast(BaseException, current)) + elif type(current) is dict: + for key, item in dict.items(current): + pending.extend((key, item)) + elif ( + type(current) is list + or type(current) is tuple + or type(current) is set + or type(current) is frozenset + ): + pending.extend(current) + + +def _discard_exception_graph(error: BaseException) -> None: + """Best-effort clear discoverable state from an exception and its linked graph. + + Exact built-in descriptors avoid provider-controlled attribute, descriptor, and + metaclass callbacks. Read-only storage such as an exception group's message + cannot be changed in place, so public boundaries must return a fresh error that + does not retain the source exception. + """ + pending = [error] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + + linked: list[BaseException] = [] + group_exceptions: tuple[BaseException, ...] | None = None + current_type = type(current) + if issubclass(current_type, BaseExceptionGroup): + try: + if sys.version_info < (3, 11): + group_state = _base_exception_instance_dict(current) + raw_group_exceptions = ( + _exact_string_state_value(group_state, "_exceptions") + if group_state is not None + else None + ) + else: + group_descriptor = type.__getattribute__(BaseExceptionGroup, "__dict__")[ + "exceptions" + ] + raw_group_exceptions = group_descriptor.__get__(current, current_type) + if type(raw_group_exceptions) is tuple: + group_exceptions = tuple( + cast(BaseException, candidate) + for candidate in raw_group_exceptions + if issubclass(type(candidate), BaseException) + ) + else: + group_exceptions = () + linked.extend(group_exceptions) + except BaseException: + pass + for descriptor in ( + cast(Any, BaseException.__cause__), + cast(Any, BaseException.__context__), + ): + try: + candidate = descriptor.__get__(current, current_type) + except BaseException: + continue + if issubclass(type(candidate), BaseException): + linked.append(cast(BaseException, candidate)) + + try: + args = cast(Any, BaseException.args).__get__(current, current_type) + _collect_nested_exceptions(args, linked) + except BaseException: + pass + + try: + source_traceback = cast(Any, BaseException.__traceback__).__get__(current, current_type) + except BaseException: + source_traceback = None + if source_traceback is not None: + try: + traceback.clear_frames(source_traceback) + except BaseException: + pass + + state = _base_exception_instance_dict(current) + if state is not None: + _collect_nested_exceptions(state, linked) + state.clear() + + current_metadata = _static_type_metadata(current_type) + if current_metadata is not None: + _, current_mro = current_metadata + for base in current_mro: + base_metadata = _static_type_metadata(base) + if base_metadata is None: + continue + namespace, _ = base_metadata + for descriptor in namespace.values(): + if type(descriptor) is not types.MemberDescriptorType: + continue + try: + value = descriptor.__get__(current, current_type) + _collect_nested_exceptions(value, linked) + except BaseException: + pass + try: + descriptor.__delete__(current) + except BaseException: + pass + + if group_exceptions is None: + safe_args: tuple[object, ...] = () + else: + safe_args = (_DATA_REDACTED_ERROR_MESSAGE, group_exceptions) + for descriptor, value in ( + (cast(Any, BaseException.args), safe_args), + (cast(Any, BaseException.__traceback__), None), + (cast(Any, BaseException.__cause__), None), + (cast(Any, BaseException.__context__), None), + ): + try: + descriptor.__set__(current, value) + except BaseException: + pass + pending.extend(linked) def _raise_data_redacted_error(error: BaseException) -> NoReturn: diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py index 2794e102cb..09478a772a 100644 --- a/src/agents/sandbox/_mount_security.py +++ b/src/agents/sandbox/_mount_security.py @@ -1,24 +1,30 @@ from __future__ import annotations -import asyncio import copy import dataclasses import importlib import re -import traceback +import sys +import types from collections.abc import Callable, Collection, Coroutine, Iterable, Mapping from functools import wraps from pathlib import PurePath, PurePosixPath -from typing import TYPE_CHECKING, Any, NoReturn, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Any, NoReturn, ParamSpec, TypeVar, cast, get_args from urllib.parse import urlsplit from ..exceptions import ( - _clear_data_redacted_error_traceback, - _detach_data_redacted_error_traceback, + _base_exception_instance_dict, + _discard_exception_graph, + _exact_string_state_entry, + _exact_string_state_value, _is_error_data_redacted, _mark_error_data_redacted, + _object_instance_dict, _raise_data_redacted_error, + _replace_data_redacted_process_control_error, + _static_type_metadata, ) +from . import errors as _sandbox_errors from .entries import ( AzureBlobMount, BaseEntry, @@ -40,7 +46,7 @@ RcloneMountPattern, S3FilesMountPattern, ) -from .errors import MountConfigError +from .errors import ErrorCode, MountConfigError, OpName, SandboxError if TYPE_CHECKING: from .manifest import Manifest @@ -404,6 +410,71 @@ class _InContainerMountCredentialCapability: _RCLONE_SAFE_FLAG_ARGS = frozenset({"allow-other"}) _RCLONE_SAFE_VALUE_ARGS = frozenset({"buffer-size", "gid", "uid"}) _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR = "_agents_safe_mount_validation_message" +_SAFE_MOUNT_VALIDATION_MESSAGE_MARKER = object() +_SANDBOX_ERROR_OPS = frozenset(get_args(OpName)) +_STRUCTURED_SANDBOX_ERROR_SAFE_SUBTYPE_STATE: tuple[ + tuple[type[SandboxError], tuple[tuple[str, object], ...]], ... +] = ( + (_sandbox_errors.SandboxError, ()), + (_sandbox_errors.ConfigurationError, ()), + (_sandbox_errors.SandboxRuntimeError, ()), + (_sandbox_errors.ArtifactError, ()), + (_sandbox_errors.SnapshotError, ()), + (_sandbox_errors.ApplyPatchError, ()), + (_sandbox_errors.InvalidManifestPathError, ()), + (_sandbox_errors.InvalidCompressionSchemeError, ()), + (_sandbox_errors.ExposedPortUnavailableError, ()), + (_sandbox_errors.ExecFailureError, (("command", ()),)), + ( + _sandbox_errors.ExecNonZeroError, + (("command", ()), ("exit_code", 1), ("stdout", b""), ("stderr", b"")), + ), + (_sandbox_errors.ExecTimeoutError, (("command", ()), ("timeout_s", None))), + (_sandbox_errors.ExecTransportError, (("command", ()),)), + (_sandbox_errors.PtySessionNotFoundError, (("session_id", -1),)), + (_sandbox_errors.WorkspaceIOError, ()), + (_sandbox_errors.ApplyPatchPathError, ()), + (_sandbox_errors.ApplyPatchDiffError, ()), + (_sandbox_errors.ApplyPatchFileNotFoundError, ()), + (_sandbox_errors.ApplyPatchDecodeError, ()), + (_sandbox_errors.WorkspaceReadNotFoundError, ()), + (_sandbox_errors.WorkspaceArchiveReadError, ()), + (_sandbox_errors.WorkspaceArchiveWriteError, ()), + (_sandbox_errors.WorkspaceWriteTypeError, ()), + (_sandbox_errors.WorkspaceStopError, ()), + (_sandbox_errors.WorkspaceStartError, ()), + (_sandbox_errors.WorkspaceRootNotFoundError, ()), + (_sandbox_errors.LocalArtifactError, ()), + (_sandbox_errors.LocalFileReadError, ()), + (_sandbox_errors.LocalDirReadError, ()), + (_sandbox_errors.LocalChecksumError, ()), + (_sandbox_errors.GitArtifactError, ()), + (_sandbox_errors.GitMissingInImageError, ()), + (_sandbox_errors.GitCloneError, ()), + (_sandbox_errors.GitSubpathError, ()), + (_sandbox_errors.GitCopyError, ()), + (_sandbox_errors.MountArtifactError, ()), + (_sandbox_errors.MountToolMissingError, ()), + (_sandbox_errors.MountCommandError, ()), + (_sandbox_errors.SkillsConfigError, ()), + (_sandbox_errors.SnapshotPersistError, ()), + (_sandbox_errors.SnapshotRestoreError, ()), + (_sandbox_errors.SnapshotNotRestorableError, ()), +) +_CALL_AUTHORITY_REQUIRED_STATE_KEYS = ( + "state", + "manifest", + "default_manifest", + "_sandbox_config", + "session_state", + "_trusted_manifest", +) +_CALL_AUTHORITY_LINK_STATE_KEYS = ( + "session", + "_session", + "_client", + "_inner", +) _P = ParamSpec("_P") _T = TypeVar("_T") @@ -416,37 +487,26 @@ class _InvalidRawMountManifestError(ValueError): def redact_mount_error_data( function: Callable[_P, Coroutine[Any, Any, _T]], ) -> Callable[_P, Coroutine[Any, Any, _T]]: - """Replace marked validation failures after clearing payload-bearing async frames.""" + """Replace failures after clearing async frames that handled mount authority.""" @wraps(function) async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: - call_has_authority = _call_has_configured_mount_authority(args, kwargs) - safe_error: Exception | None = None - safe_cancel: asyncio.CancelledError | None = None + call_has_authority = _call_has_configured_mount_authority( + args, + kwargs, + function=function, + ) + safe_error: BaseException | None = None try: return await function(*args, **kwargs) - except asyncio.CancelledError as error: - if not call_has_authority: - raise - discard_mount_source_exception(error) - safe_cancel = asyncio.CancelledError() - except Exception as error: - if isinstance(error, MountConfigError) and _is_error_data_redacted(error): - safe_error = _replace_mount_error(error) - elif _is_error_data_redacted(error): - _clear_data_redacted_error_traceback(error) - _detach_data_redacted_error_traceback(error) - error.__cause__ = None - error.__context__ = None - safe_error = error - elif call_has_authority: - safe_error = _replace_mount_operation_error(error) + except BaseException as error: + error_is_redacted = _is_error_data_redacted(error) + if call_has_authority or error_is_redacted: + safe_error = _replace_protected_mount_error(error) else: raise - del args, kwargs, call_has_authority - if safe_cancel is not None: - raise safe_cancel from None + del args, kwargs, call_has_authority, error_is_redacted assert safe_error is not None _raise_data_redacted_error(safe_error) @@ -458,33 +518,30 @@ def _redact_mount_error_data_sync( *, preserve_value_error_type: bool, ) -> Callable[_P, _T]: - """Replace validation failures after clearing payload-bearing sync frames.""" + """Replace failures after clearing sync frames that handled mount authority.""" @wraps(function) def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: - call_has_authority = _call_has_configured_mount_authority(args, kwargs) - safe_error: Exception | None = None + call_has_authority = _call_has_configured_mount_authority( + args, + kwargs, + function=function, + ) + safe_error: BaseException | None = None try: return function(*args, **kwargs) - except Exception as error: - if isinstance(error, MountConfigError) and _is_error_data_redacted(error): - safe_error = _replace_mount_error(error) - elif _is_error_data_redacted(error): - _clear_data_redacted_error_traceback(error) - _detach_data_redacted_error_traceback(error) - error.__cause__ = None - error.__context__ = None - safe_error = error - elif preserve_value_error_type and call_has_authority and isinstance(error, ValueError): + except BaseException as error: + error_is_redacted = _is_error_data_redacted(error) + if preserve_value_error_type and call_has_authority and isinstance(error, ValueError): discard_mount_source_exception(error) safe_error = ValueError("sandbox mount validation failed") _mark_error_data_redacted(safe_error) - elif call_has_authority: - safe_error = _replace_mount_operation_error(error) + elif call_has_authority or error_is_redacted: + safe_error = _replace_protected_mount_error(error) else: raise - del args, kwargs, call_has_authority + del args, kwargs, call_has_authority, error_is_redacted assert safe_error is not None _raise_data_redacted_error(safe_error) @@ -492,7 +549,7 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: def redact_mount_error_data_sync(function: Callable[_P, _T]) -> Callable[_P, _T]: - """Replace marked validation failures after clearing payload-bearing sync frames.""" + """Replace failures after clearing sync frames that handled mount authority.""" return _redact_mount_error_data_sync(function, preserve_value_error_type=False) @@ -505,24 +562,95 @@ def redact_mount_validation_error_data_sync( return _redact_mount_error_data_sync(function, preserve_value_error_type=True) -def _replace_mount_error(error: MountConfigError) -> MountConfigError: - message = ( - error.message - if getattr(error, _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR, False) - else "sandbox mount configuration is invalid" - ) +def _replace_mount_error( + error: MountConfigError, + *, + state: dict[object, object], + error_code: ErrorCode, + op: OpName, + retryable: bool | None, +) -> MountConfigError: + message = "sandbox mount configuration is invalid" + if ( + state is not None + and _exact_string_state_value(state, _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR) + is _SAFE_MOUNT_VALIDATION_MESSAGE_MARKER + and type(_exact_string_state_value(state, "message")) is str + ): + message = cast(str, _exact_string_state_value(state, "message")) + discard_mount_source_exception(error) safe_error = MountConfigError(message=message) + safe_error.error_code = error_code + safe_error.op = op + safe_error.retryable = retryable _mark_error_data_redacted(safe_error) - _clear_data_redacted_error_traceback(error) - _detach_data_redacted_error_traceback(error) - error.__cause__ = None - error.__context__ = None - error.args = ("Error details are redacted.",) - error.context = {} return safe_error -def _replace_mount_operation_error(error: Exception) -> RuntimeError: +def _replace_protected_mount_error(error: BaseException) -> BaseException: + process_control_error = _replace_data_redacted_process_control_error(error) + if process_control_error is not None: + return process_control_error + structured_error = _replace_structured_sandbox_error(error) + if structured_error is not None: + return structured_error + return _replace_mount_operation_error(error) + + +def _replace_structured_sandbox_error(error: BaseException) -> SandboxError | None: + error_type = type(error) + state = _base_exception_instance_dict(error) + if state is None: + return None + error_code = _exact_string_state_value(state, "error_code") + op = _exact_string_state_value(state, "op") + retryable_found, retryable = _exact_string_state_entry(state, "retryable") + if ( + type(error_code) is not ErrorCode + or type(op) is not str + or op not in _SANDBOX_ERROR_OPS + or not retryable_found + or (retryable is not None and type(retryable) is not bool) + ): + return None + + if error_type is MountConfigError: + return _replace_mount_error( + cast(MountConfigError, error), + state=state, + error_code=error_code, + op=cast(OpName, op), + retryable=retryable, + ) + + safe_subtype_state = next( + ( + fields + for candidate, fields in _STRUCTURED_SANDBOX_ERROR_SAFE_SUBTYPE_STATE + if error_type is candidate + ), + None, + ) + if safe_subtype_state is None: + return None + + discard_mount_source_exception(error) + safe_error = cast(SandboxError, BaseException.__new__(error_type)) + message = "sandbox operation failed while using a protected mount configuration" + object.__setattr__(safe_error, "message", message) + object.__setattr__(safe_error, "error_code", error_code) + object.__setattr__(safe_error, "op", cast(OpName, op)) + object.__setattr__(safe_error, "context", {}) + object.__setattr__(safe_error, "cause", None) + object.__setattr__(safe_error, "retryable", retryable) + for field_name, field_value in safe_subtype_state: + object.__setattr__(safe_error, field_name, field_value) + BaseException.__init__(safe_error, message) + _mark_error_data_redacted(safe_error) + return safe_error + + +def _replace_mount_operation_error(error: BaseException) -> RuntimeError: discard_mount_source_exception(error) safe_error = RuntimeError( "sandbox operation failed while using a protected mount configuration" @@ -533,53 +661,7 @@ def _replace_mount_operation_error(error: Exception) -> RuntimeError: def discard_mount_source_exception(error: BaseException) -> None: """Clear source frames without consulting provider-defined exception attributes.""" - - pending = [error] - seen: set[int] = set() - while pending: - current = pending.pop() - if id(current) in seen: - continue - seen.add(id(current)) - - linked: list[BaseException] = [] - for descriptor in ( - cast(Any, BaseException.__cause__), - cast(Any, BaseException.__context__), - ): - try: - candidate = descriptor.__get__(current, type(current)) - except BaseException: - continue - if isinstance(candidate, BaseException): - linked.append(candidate) - - try: - source_traceback = cast(Any, BaseException.__traceback__).__get__( - current, type(current) - ) - except BaseException: - source_traceback = None - if source_traceback is not None: - try: - traceback.clear_frames(source_traceback) - except BaseException: - pass - try: - BaseException.__init__(current) - except BaseException: - pass - for descriptor, value in ( - (cast(Any, BaseException.args), ()), - (cast(Any, BaseException.__traceback__), None), - (cast(Any, BaseException.__cause__), None), - (cast(Any, BaseException.__context__), None), - ): - try: - descriptor.__set__(current, value) - except BaseException: - pass - pending.extend(linked) + _discard_exception_graph(error) def _url_contains_inline_authority(value: object) -> bool: @@ -921,10 +1003,16 @@ def _manifest_has_configured_mount_authority(manifest: Manifest) -> bool: pending = list(manifest.entries.values()) while pending: entry = pending.pop() - if isinstance(entry, Mount) and _mount_has_or_may_hide_configured_authority(entry): + entry_metadata = _static_type_metadata(type(entry)) + entry_mro = () if entry_metadata is None else entry_metadata[1] + if any(base is Mount for base in entry_mro) and _mount_has_or_may_hide_configured_authority( + cast(Mount, entry) + ): return True - if isinstance(entry, Dir): + if type(entry) is Dir: pending.extend(entry.children.values()) + elif any(base is Dir for base in entry_mro): + return True return False @@ -946,65 +1034,224 @@ def _mount_has_or_may_hide_configured_authority(mount: Mount) -> bool: return bool(_configured_mount_authority_fields(mount)) +def _decorated_owner_type(function: Callable[..., object]) -> type | None: + """Resolve the SDK class that owns a decorated function without importing modules.""" + + if type(function) is not types.FunctionType: + return None + module_name = function.__module__ + qualname = function.__qualname__ + if ( + type(module_name) is not str + or not module_name.startswith("agents.") + or type(qualname) is not str + or qualname.count(".") != 1 + ): + return None + owner_name, _ = qualname.split(".", 1) + module = sys.modules.get(module_name) + if type(module) is not types.ModuleType: + return None + module_dict_descriptor = cast(Any, types.ModuleType).__dict__["__dict__"] + module_state = module_dict_descriptor.__get__(module, types.ModuleType) + if type(module_state) is not dict: + return None + candidate = _exact_string_state_value(module_state, owner_name) + if not isinstance(candidate, type) or _static_type_metadata(candidate) is None: + return None + return candidate + + +def _trusted_authority_owner_base( + value: object, + *, + decorated_owner_type: type | None, +) -> type | None: + """Return a canonical SDK base whose instance-state descriptor is trusted.""" + + from ..run_config import SandboxRunConfig + from .sandbox_agent import SandboxAgent + from .session.base_sandbox_session import BaseSandboxSession + from .session.sandbox_client import BaseSandboxClient + from .session.sandbox_session_state import SandboxSessionState + + metadata = _static_type_metadata(type(value)) + if metadata is None: + return None + _, value_mro = metadata + candidates = ( + decorated_owner_type, + BaseSandboxSession, + BaseSandboxClient, + SandboxSessionState, + SandboxRunConfig, + SandboxAgent, + MountStrategyBase, + ) + for candidate in candidates: + if candidate is not None and any(base is candidate for base in value_mro): + return candidate + return None + + +def _exact_slot_state_entry( + value: object, + *, + trusted_base: type, + name: str, +) -> tuple[bool, bool, object | None]: + """Read one exact Python slot without invoking provider attribute hooks. + + The first boolean reports whether the named state is declared by the + inspected hierarchy. The second reports whether that declaration is an + exact Python slot. A non-slot declaration is returned as the third value + for identity comparison only. A declared but unreadable slot is unresolved + and returns ``(True, True, None)`` so callers can fail closed. + """ + + value_metadata = _static_type_metadata(type(value)) + if value_metadata is None: + return False, False, None + _, value_mro = value_metadata + if not any(base is trusted_base for base in value_mro): + return False, False, None + + for base in value_mro: + base_metadata = _static_type_metadata(base) + if base_metadata is None: + return False, False, None + namespace, _ = base_metadata + for candidate, descriptor in namespace.items(): + if type(candidate) is not str or str.__eq__(candidate, name) is not True: + continue + if type(descriptor) is not types.MemberDescriptorType: + return True, False, descriptor + try: + slot_value = descriptor.__get__(value, type(value)) + except BaseException: + return True, True, None + return True, True, slot_value + if base is trusted_base: + break + return False, False, None + + def _call_has_configured_mount_authority( - args: tuple[object, ...], kwargs: Mapping[str, object] + args: tuple[object, ...], + kwargs: Mapping[str, object], + *, + function: Callable[..., object], ) -> bool: - """Inspect only SDK call-boundary manifest owners.""" + """Inspect SDK-owned call-boundary state for protected authority.""" from .manifest import Manifest + from .session.base_sandbox_session import BaseSandboxSession + from .session.sandbox_session import SandboxSession try: - for value in (*args, *kwargs.values()): - candidates = [value] - state = getattr(value, "state", None) - if state is not None: - candidates.append(state) - default_manifest = getattr(value, "default_manifest", None) - if default_manifest is not None: - candidates.append(default_manifest) - sandbox_config = getattr(value, "_sandbox_config", None) - if sandbox_config is not None: - candidates.append(sandbox_config) - configured_state = getattr(sandbox_config, "session_state", None) - if configured_state is not None: - candidates.append(configured_state) - configured_session = getattr(sandbox_config, "session", None) - if configured_session is not None: - candidates.append(configured_session) - configured_session_state = getattr(configured_session, "state", None) - if configured_session_state is not None: - candidates.append(configured_session_state) - for candidate in candidates: - has_runtime_authority = getattr( - candidate, - "_runtime_has_protected_mount_authority", - None, - ) - if ( - not isinstance(candidate, type) - and callable(has_runtime_authority) - and has_runtime_authority() - ): + sandbox_session_metadata = _static_type_metadata(SandboxSession) + if sandbox_session_metadata is None: + return True + sandbox_session_state_descriptor = sandbox_session_metadata[0]["state"] + values = (*args, *kwargs.values()) + for value in values: + if type(value) is Manifest and _manifest_has_configured_mount_authority(value): + return True + + decorated_owner_type = _decorated_owner_type(function) + pending = [(value, False) for value in values] + seen_optional: set[int] = set() + seen_required: set[int] = set() + while pending: + value, must_resolve_owner = pending.pop() + value_id = id(value) + if value_id in seen_required or (not must_resolve_owner and value_id in seen_optional): + continue + if must_resolve_owner: + seen_required.add(value_id) + else: + seen_optional.add(value_id) + + value_metadata = _static_type_metadata(type(value)) + value_mro = () if value_metadata is None else value_metadata[1] + if type(value) is Manifest: + if _manifest_has_configured_mount_authority(value): return True - if isinstance(candidate, Mount): - if _mount_has_or_may_hide_configured_authority(candidate): - return True - continue - if isinstance(candidate, Mapping) and any( - isinstance(item, Mount) and _mount_has_or_may_hide_configured_authority(item) - for item in candidate.values() - ): + continue + if any(base is Manifest for base in value_mro): + return True + if any(base is Mount for base in value_mro): + if _mount_has_or_may_hide_configured_authority(cast(Mount, value)): return True - manifest = ( - candidate - if isinstance(candidate, Manifest) - else getattr(candidate, "manifest", None) - ) - if isinstance(manifest, Manifest) and _manifest_has_configured_mount_authority( - manifest - ): + continue + if type(value) is dict: + pending.extend((item, must_resolve_owner) for item in dict.values(value)) + continue + if type(value) is list or type(value) is tuple: + pending.extend((item, must_resolve_owner) for item in value) + continue + if value is None or type(value) in (str, bytes, int, float, bool, complex): + continue + if any(base is PurePath for base in value_mro): + continue + + trusted_base = _trusted_authority_owner_base( + value, + decorated_owner_type=decorated_owner_type, + ) + if trusted_base is None: + if must_resolve_owner: return True - except Exception: + continue + state = _object_instance_dict(value, trusted_base=trusted_base) + if state is None: + return True + is_session_owner = any(base is BaseSandboxSession for base in value_mro) + for name in _CALL_AUTHORITY_REQUIRED_STATE_KEYS: + found, candidate = _exact_string_state_entry(state, name) + if name == "state" and is_session_owner: + if not found: + state_declared, is_exact_slot, candidate = _exact_slot_state_entry( + value, + trusted_base=trusted_base, + name=name, + ) + if state_declared and is_exact_slot: + if candidate is None: + return True + found = True + elif state_declared and candidate is sandbox_session_state_descriptor: + inner_found, inner = _exact_string_state_entry(state, "_inner") + if ( + not inner_found + or inner is None + or _trusted_authority_owner_base( + inner, + decorated_owner_type=decorated_owner_type, + ) + is None + ): + return True + elif state_declared: + return True + if found and candidate is not None: + pending.append((candidate, True)) + for name in _CALL_AUTHORITY_LINK_STATE_KEYS: + found, candidate = _exact_string_state_entry(state, name) + if found and candidate is not None: + pending.append((candidate, False)) + + credentials_found, credentials = _exact_string_state_entry( + state, + "_trusted_s3_mount_credentials", + ) + if type(credentials) is dict: + for configured in dict.values(credentials): + if type(configured) is tuple and any(item is not None for item in configured): + return True + elif credentials_found and credentials is not None: + return True + except BaseException: return True return False @@ -1043,7 +1290,11 @@ def _mark_mount_error_for_manifest(error: MountConfigError, manifest: Manifest) def _mark_mount_error_data_safe(error: MountConfigError) -> None: """Mark an SDK-created mount error whose message contains no credential-derived data.""" _mark_error_data_redacted(error) - setattr(error, _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR, True) + setattr( + error, + _SAFE_MOUNT_VALIDATION_MESSAGE_ATTR, + _SAFE_MOUNT_VALIDATION_MESSAGE_MARKER, + ) def _mark_mount_validation_error(error: MountConfigError) -> None: diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index f9ba9f62b7..0b912b1b57 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -589,7 +589,7 @@ async def test_cloudflare_protected_mount_state_drops_identity_before_resume() - provider_backend_id="cloudflare", ) - with pytest.raises(RuntimeError, match="protected mount configuration"): + with pytest.raises(MountConfigError, match="sandbox mount configuration is invalid"): await client.resume(rebound) @@ -617,7 +617,7 @@ async def running(self: CloudflareSandboxSession) -> bool: monkeypatch.setattr(CloudflareSandboxSession, "running", running) - with pytest.raises(RuntimeError, match="protected mount configuration"): + with pytest.raises(MountConfigError, match="sandbox mount configuration is invalid"): await CloudflareSandboxClient().resume(_make_state(manifest=manifest)) assert provider_calls == 0 @@ -1567,9 +1567,16 @@ async def _exec_internal(*command: str | Path, timeout: float | None = None) -> @pytest.mark.asyncio -@pytest.mark.parametrize("operation", ["persist", "hydrate"]) +@pytest.mark.parametrize( + ("operation", "expected_type"), + [ + ("persist", WorkspaceArchiveReadError), + ("hydrate", WorkspaceArchiveWriteError), + ], +) async def test_cloudflare_direct_persistence_redacts_protected_remount_failure( operation: str, + expected_type: type[WorkspaceArchiveReadError | WorkspaceArchiveWriteError], ) -> None: sentinel = "cloudflare-direct-persistence-secret" fake_http = _FakeHttp( @@ -1595,7 +1602,7 @@ async def test_cloudflare_direct_persistence_redacts_protected_remount_failure( ) sess = _make_session(state=_make_state(manifest=manifest), fake_http=fake_http) - with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + with pytest.raises(expected_type, match="protected mount configuration") as exc_info: if operation == "persist": await sess.persist_workspace() else: @@ -1603,6 +1610,7 @@ async def test_cloudflare_direct_persistence_redacts_protected_remount_failure( assert sentinel not in str(exc_info.value) assert sentinel not in repr(exc_info.value) + assert exc_info.value.context == {} assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None traceback = exc_info.value.__traceback__ diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 054f40a858..82f207de0a 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -30,9 +30,14 @@ from agents.sandbox.entries.mounts.base import InContainerMountAdapter from agents.sandbox.errors import ( ConfigurationError, + ErrorCode, + ExecTransportError, + ExposedPortUnavailableError, InvalidManifestPathError, MountCommandError, MountConfigError, + WorkspaceArchiveReadError, + WorkspaceArchiveWriteError, ) from agents.sandbox.manifest import EnvEntry, Environment, StrEnvValue from agents.sandbox.materialization import MaterializedFile @@ -832,7 +837,9 @@ def test_vercel_from_state_redacts_trusted_mount_credentials_from_failure_traceb sandbox_id="sandbox-existing", ) - with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + with pytest.raises( + MountConfigError, match="sandbox mount configuration is invalid" + ) as exc_info: vercel_module.VercelSandboxSession.from_state( state, allow_s3_credential_exposure=allow_s3_credential_exposure, @@ -875,7 +882,9 @@ def test_vercel_constructor_redacts_trusted_mount_credentials_from_failure_trace sandbox_id="sandbox-existing", ) - with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + with pytest.raises( + MountConfigError, match="sandbox mount configuration is invalid" + ) as exc_info: vercel_module.VercelSandboxSession( state=state, allow_s3_credential_exposure=allow_s3_credential_exposure, @@ -896,10 +905,24 @@ def test_vercel_constructor_redacts_trusted_mount_credentials_from_failure_trace @pytest.mark.asyncio -@pytest.mark.parametrize("operation", ["exec", "read", "write", "resolve_exposed_port"]) +@pytest.mark.parametrize( + ("operation", "expected_type"), + [ + ("exec", ExecTransportError), + ("read", WorkspaceArchiveReadError), + ("write", WorkspaceArchiveWriteError), + ("resolve_exposed_port", ExposedPortUnavailableError), + ], +) async def test_vercel_protected_session_public_operations_redact_provider_failures( monkeypatch: pytest.MonkeyPatch, operation: str, + expected_type: type[ + ExecTransportError + | WorkspaceArchiveReadError + | WorkspaceArchiveWriteError + | ExposedPortUnavailableError + ], ) -> None: vercel_module = _load_vercel_module(monkeypatch) package_module = importlib.import_module("agents.extensions.sandbox.vercel") @@ -950,10 +973,11 @@ def fail_domain(port: int) -> str: async def invoke() -> object: return await session.resolve_exposed_port(3000) - with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + with pytest.raises(expected_type, match="protected mount configuration") as exc_info: await invoke() assert sentinel not in str(exc_info.value) + assert exc_info.value.context == {} assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None _assert_base_exception_slots_cleared(source_error) @@ -2692,7 +2716,9 @@ async def fail_command( with pytest.raises(MountCommandError) as exc_info: await session.start() - assert exc_info.value.context["stderr"] == "sandbox provider command failed" + assert exc_info.value.error_code is ErrorCode.MOUNT_FAILED + assert exc_info.value.op == "materialize" + assert exc_info.value.context == {} assert transformed_secret not in str(exc_info.value) assert transformed_secret not in repr(exc_info.value.context) assert exc_info.value.retryable is True @@ -2732,8 +2758,9 @@ async def test_vercel_s3_mount_failure_discards_transformed_stderr( with pytest.raises(MountCommandError) as exc_info: await session.start() - assert exc_info.value.context["stderr"] == "sandbox provider command failed" - assert exc_info.value.context["exit_code"] == 1 + assert exc_info.value.error_code is ErrorCode.MOUNT_FAILED + assert exc_info.value.op == "materialize" + assert exc_info.value.context == {} assert transformed_secret not in str(exc_info.value) assert transformed_secret not in repr(exc_info.value.context) assert sandbox.stop_calls == 1 @@ -2784,7 +2811,9 @@ async def fail_command( await session.start() assert type(exc_info.value) is MountCommandError - assert exc_info.value.context["stderr"] == "sandbox provider command failed" + assert exc_info.value.error_code is ErrorCode.MOUNT_FAILED + assert exc_info.value.op == "materialize" + assert exc_info.value.context == {} assert exc_info.value.retryable is True assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index b599677757..366000d113 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -42,6 +42,7 @@ ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter from agents.sandbox.errors import ( + ErrorCode, ExecTimeoutError, ExecTransportError, InvalidManifestPathError, @@ -2401,9 +2402,13 @@ async def fail_stage_workspace_copy(**_kwargs: object) -> tuple[Path, Path]: monkeypatch.setattr(session, "_stage_workspace_copy", fail_stage_workspace_copy) - with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + with pytest.raises( + WorkspaceArchiveReadError, match="protected mount configuration" + ) as exc_info: await session.persist_workspace() + assert exc_info.value.error_code is ErrorCode.WORKSPACE_ARCHIVE_READ_ERROR + assert exc_info.value.context == {} assert sentinel not in str(exc_info.value) assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None diff --git a/tests/sandbox/test_mount_security.py b/tests/sandbox/test_mount_security.py index 001189ca70..d576b9e467 100644 --- a/tests/sandbox/test_mount_security.py +++ b/tests/sandbox/test_mount_security.py @@ -3,6 +3,8 @@ import asyncio import builtins import importlib +import inspect +import sys from pathlib import Path, PureWindowsPath from typing import Any, ClassVar, Literal, cast @@ -14,6 +16,7 @@ from agents.extensions.sandbox.e2b.mounts import E2BCloudBucketMountStrategy from agents.extensions.sandbox.modal.mounts import ModalCloudBucketMountStrategy from agents.extensions.sandbox.runloop.mounts import RunloopCloudBucketMountStrategy +from agents.run_config import SandboxRunConfig from agents.sandbox import Manifest from agents.sandbox._mount_security import ( CREDENTIALLESS_MOUNT_AUTHORITY_KEY, @@ -53,14 +56,32 @@ MountPatternConfig, RcloneMountConfig, ) -from agents.sandbox.errors import MountConfigError +from agents.sandbox.errors import ( + ErrorCode, + ExecNonZeroError, + ExecTimeoutError, + ExecTransportError, + InvalidManifestPathError, + MountCommandError, + MountConfigError, + MountToolMissingError, + PtySessionNotFoundError, + SandboxError, +) from agents.sandbox.manifest import Environment +from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.sandbox_client import BaseSandboxClient from agents.sandbox.session.sandbox_session import SandboxSession from agents.sandbox.session.sandbox_session_state import SandboxSessionState from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase, SnapshotSpec +from agents.sandbox.types import ExecResult from tests.utils.factories import TestSessionState +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup +else: + BaseExceptionGroup = builtins.BaseExceptionGroup + class _SecurityTestClient(BaseSandboxClient[None]): backend_id = "test" @@ -1085,9 +1106,11 @@ async def test_authority_detection_keeps_invalid_manifest_paths_inside_redaction async def validate(*, manifest: Manifest) -> None: validate_manifest_mount_credential_boundaries(manifest) - with pytest.raises(RuntimeError, match="protected mount configuration") as exc: + with pytest.raises(InvalidManifestPathError, match="protected mount configuration") as exc: await validate(manifest=manifest) + assert exc.value.error_code is ErrorCode.INVALID_MANIFEST_PATH + assert exc.value.context == {} assert sentinel not in str(exc.value) traceback_cursor = exc.value.__traceback__ while traceback_cursor is not None: @@ -2843,6 +2866,1017 @@ def test_raw_state_rejects_malformed_credential_file_locator() -> None: assert "credential-file-secret" not in str(exc.value) +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.parametrize("error_kind", ["command", "tool_missing"]) +@pytest.mark.asyncio +async def test_protected_structured_mount_error_preserves_safe_contract( + boundary: str, + error_kind: str, +) -> None: + sentinel = f"{boundary}-{error_kind}-structured-mount-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + child_error = RuntimeError(sentinel) + expected_type: type[SandboxError] + if error_kind == "command": + source_error: SandboxError = MountCommandError( + command=sentinel, + stderr=sentinel, + context={"credential": sentinel}, + cause=child_error, + retryable=True, + ) + expected_type = MountCommandError + expected_code = ErrorCode.MOUNT_FAILED + expected_retryable = True + else: + source_error = MountToolMissingError( + tool=sentinel, + context={"credential": sentinel}, + cause=child_error, + ) + expected_type = MountToolMissingError + expected_code = ErrorCode.MOUNT_MISSING_TOOL + expected_retryable = False + if sys.version_info >= (3, 11): + source_error.add_note(sentinel) + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(expected_type) as exc_info: + await fail(manifest=manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(expected_type) as exc_info: + fail_sync(manifest=manifest) + + safe_error = exc_info.value + assert type(safe_error) is expected_type + assert safe_error is not source_error + assert safe_error.error_code is expected_code + assert safe_error.op == "materialize" + assert safe_error.retryable is expected_retryable + assert safe_error.context == {} + assert safe_error.cause is None + assert safe_error.__cause__ is None + assert safe_error.__context__ is None + assert sentinel not in repr(safe_error) + assert cast(Any, BaseException.args).__get__(source_error, type(source_error)) == () + assert cast(Any, BaseException.__traceback__).__get__(source_error, type(source_error)) is None + assert child_error.args == () + assert child_error.__traceback__ is None + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.parametrize("invalid_state", ["missing_retryable", "invalid_op"]) +@pytest.mark.asyncio +async def test_protected_malformed_mount_config_error_falls_back( + boundary: str, + invalid_state: str, +) -> None: + sentinel = f"{boundary}-{invalid_state}-mount-config-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + source_error = MountConfigError(message=sentinel) + if invalid_state == "missing_retryable": + del source_error.retryable + else: + cast(Any, source_error).op = "invalid" + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(manifest=manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail_sync(manifest=manifest) + + assert type(exc_info.value) is RuntimeError + assert sentinel not in repr(exc_info.value) + assert cast(Any, BaseException.args).__get__(source_error, type(source_error)) == () + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.asyncio +async def test_protected_mount_config_error_preserves_valid_structured_fields( + boundary: str, +) -> None: + sentinel = f"{boundary}-mount-config-structured-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + source_error = MountConfigError(message=sentinel) + source_error.error_code = ErrorCode.EXEC_TIMEOUT + source_error.op = "exec" + source_error.retryable = True + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(MountConfigError) as exc_info: + await fail(manifest=manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(MountConfigError) as exc_info: + fail_sync(manifest=manifest) + + safe_error = exc_info.value + assert safe_error is not source_error + assert safe_error.error_code is ErrorCode.EXEC_TIMEOUT + assert safe_error.op == "exec" + assert safe_error.retryable is True + assert sentinel not in repr(safe_error) + assert source_error.args == () + assert source_error.__traceback__ is None + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.asyncio +async def test_untrusted_nested_authority_owner_fails_closed_without_descriptor_access( + boundary: str, +) -> None: + sentinel = f"{boundary}-untrusted-owner-descriptor-secret" + + class OpaqueOwner: + descriptor_accessed = False + + @property + def __dict__(self) -> dict[str, object]: # type: ignore[override] + type(self).descriptor_accessed = True + raise AssertionError("untrusted owner descriptor was accessed") + + client = _SecurityTestClient() + cast(Any, client).state = OpaqueOwner() + source_error = RuntimeError(sentinel) + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, client: _SecurityTestClient) -> None: + _ = client + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(client=client) + else: + + @redact_mount_error_data_sync + def fail_sync(*, client: _SecurityTestClient) -> None: + _ = client + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail_sync(client=client) + + assert exc_info.value is not source_error + assert OpaqueOwner.descriptor_accessed is False + assert sentinel not in repr(exc_info.value) + assert source_error.args == () + + +@pytest.mark.parametrize( + ("error_kind", "expected_state"), + [ + ("transport", {"command": ()}), + ( + "nonzero", + {"command": (), "exit_code": 1, "stdout": b"", "stderr": b""}, + ), + ("timeout", {"command": (), "timeout_s": None}), + ("pty", {"session_id": -1}), + ], +) +def test_protected_structured_sandbox_error_preserves_safe_subtype_state( + error_kind: str, + expected_state: dict[str, object], +) -> None: + sentinel = f"{error_kind}-structured-sandbox-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + if error_kind == "transport": + source_error: SandboxError = ExecTransportError( + command=(sentinel,), + message=sentinel, + retryable=True, + ) + elif error_kind == "nonzero": + source_error = ExecNonZeroError( + ExecResult(stdout=sentinel.encode(), stderr=sentinel.encode(), exit_code=42), + command=(sentinel,), + ) + elif error_kind == "timeout": + source_error = ExecTimeoutError(command=(sentinel,), timeout_s=42.0) + else: + source_error = PtySessionNotFoundError(session_id=42, context={"secret": sentinel}) + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(type(source_error)) as exc_info: + fail(manifest=manifest) + + safe_error = exc_info.value + assert type(safe_error) is type(source_error) + assert safe_error is not source_error + for field_name, field_value in expected_state.items(): + assert getattr(safe_error, field_name) == field_value + assert sentinel not in repr(safe_error) + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.parametrize("retryable_present", [False, True]) +@pytest.mark.asyncio +async def test_protected_structured_sandbox_error_requires_retryable_field( + boundary: str, + retryable_present: bool, +) -> None: + sentinel = f"{boundary}-{retryable_present}-missing-retryable-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + source_error = SandboxError( + message=sentinel, + error_code=ErrorCode.EXEC_TRANSPORT_ERROR, + op="exec", + context={"secret": sentinel}, + retryable=None, + ) + if not retryable_present: + del source_error.retryable + expected_type: type[BaseException] = SandboxError if retryable_present else RuntimeError + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(expected_type) as exc_info: + await fail(manifest=manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(expected_type) as exc_info: + fail_sync(manifest=manifest) + + safe_error = exc_info.value + if retryable_present: + assert type(safe_error) is SandboxError + assert safe_error.retryable is None + else: + assert type(safe_error) is RuntimeError + assert sentinel not in repr(safe_error) + assert cast(Any, BaseException.args).__get__(source_error, type(source_error)) == () + + +@pytest.mark.asyncio +async def test_protected_custom_sandbox_error_falls_back_without_source_state() -> None: + sentinel = "custom-sandbox-error-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class CustomSandboxError(SandboxError): + pass + + source_error = CustomSandboxError( + message=sentinel, + error_code=ErrorCode.MOUNT_FAILED, + op="materialize", + context={"credential": sentinel}, + retryable=True, + ) + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(manifest=manifest) + + assert type(exc_info.value) is RuntimeError + assert sentinel not in repr(exc_info.value) + assert cast(Any, BaseException.args).__get__(source_error, type(source_error)) == () + assert cast(Any, BaseException.__traceback__).__get__(source_error, type(source_error)) is None + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.asyncio +async def test_protected_hostile_exception_type_cannot_escape_redaction(boundary: str) -> None: + sentinel = f"{boundary}-hostile-exception-type-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class HostileMeta(type): + def __hash__(cls) -> int: + raise RuntimeError(sentinel) + + class ProviderError(Exception, metaclass=HostileMeta): + pass + + source_error = ProviderError(sentinel) + child_error = RuntimeError(sentinel) + source_error.__cause__ = child_error + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(manifest=manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail_sync(manifest=manifest) + + assert sentinel not in repr(exc_info.value) + assert cast(Any, BaseException.args).__get__(source_error, type(source_error)) == () + assert cast(Any, BaseException.__traceback__).__get__(source_error, type(source_error)) is None + assert child_error.args == () + assert child_error.__traceback__ is None + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.asyncio +async def test_protected_hostile_exception_state_key_cannot_escape_redaction( + boundary: str, +) -> None: + sentinel = f"{boundary}-hostile-exception-state-key-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class HostileKey: + def __hash__(self) -> int: + return hash("_agents_data_redacted") + + def __eq__(self, other: object) -> bool: + _ = other + raise RuntimeError(sentinel) + + source_error = RuntimeError(sentinel) + cast(dict[object, object], source_error.__dict__)[HostileKey()] = sentinel + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(manifest=manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail_sync(manifest=manifest) + + assert sentinel not in repr(exc_info.value) + assert source_error.args == () + assert source_error.__dict__ == {} + assert source_error.__traceback__ is None + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.asyncio +async def test_protected_direct_manifest_precedes_opaque_owner_descriptors( + boundary: str, +) -> None: + sentinel = f"{boundary}-opaque-owner-descriptor-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class OpaqueOwner: + @property + def state(self) -> object: + raise KeyboardInterrupt(sentinel) + + @property + def default_manifest(self) -> object: + raise KeyboardInterrupt(sentinel) + + @property + def _sandbox_config(self) -> object: + raise KeyboardInterrupt(sentinel) + + source_error = RuntimeError(sentinel) + + if boundary == "async": + + @redact_mount_error_data + async def fail(owner: object, manifest: Manifest) -> None: + _ = (owner, manifest) + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(OpaqueOwner(), manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(owner: object, manifest: Manifest) -> None: + _ = (owner, manifest) + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail_sync(OpaqueOwner(), manifest) + + assert sentinel not in repr(exc_info.value) + assert source_error.args == () + assert source_error.__traceback__ is None + + +def test_protected_mount_config_error_cannot_forge_safe_message_marker() -> None: + sentinel = "forged-safe-mount-message-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + source_error = MountConfigError(message=sentinel) + cast(Any, source_error)._agents_data_redacted = True + cast(Any, source_error)._agents_safe_mount_validation_message = True + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(MountConfigError, match="sandbox mount configuration is invalid") as exc: + fail(manifest=manifest) + + assert sentinel not in repr(exc.value) + assert source_error.args == () + assert source_error.__traceback__ is None + + +@pytest.mark.asyncio +async def test_protected_exception_group_is_not_retained_by_safe_error() -> None: + sentinel = "protected-exception-group-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + child_error = RuntimeError(sentinel) + source_error = BaseExceptionGroup(sentinel, [child_error]) + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(manifest=manifest) + + safe_error = exc_info.value + assert safe_error.__cause__ is None + assert safe_error.__context__ is None + assert sentinel not in repr(safe_error) + source_args = cast(Any, BaseException.args).__get__(source_error, type(source_error)) + assert source_args[0] == "Error details are redacted." + assert child_error.args == () + assert child_error.__traceback__ is None + traceback_cursor = safe_error.__traceback__ + while traceback_cursor is not None: + frame_path = Path(traceback_cursor.tb_frame.f_code.co_filename).as_posix() + if "/src/agents/" in frame_path: + assert sentinel not in repr(traceback_cursor.tb_frame.f_locals) + assert source_error not in traceback_cursor.tb_frame.f_locals.values() + traceback_cursor = traceback_cursor.tb_next + + +def test_protected_exception_group_children_are_collected_without_subclass_callbacks() -> None: + sentinel = "protected-exception-group-callback-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class HostileGroup(BaseExceptionGroup): + callbacks = 0 + + def __getattribute__(self, name: str) -> Any: + if name == "_exceptions": + type(self).callbacks += 1 + raise AssertionError("provider group state was accessed") + return super().__getattribute__(name) + + child_error = RuntimeError(sentinel) + source_error = HostileGroup(sentinel, [child_error]) + HostileGroup.callbacks = 0 + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail(manifest=manifest) + + assert HostileGroup.callbacks == 0 + assert child_error.args == () + assert child_error.__traceback__ is None + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + +def test_protected_nested_exception_is_scrubbed_beside_hostile_object() -> None: + sentinel = "protected-nested-exception-secret" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + class HostileMeta(type): + def __hash__(cls) -> int: + raise RuntimeError(sentinel) + + class Opaque(metaclass=HostileMeta): + pass + + child_error = RuntimeError(sentinel) + source_error = RuntimeError([child_error, Opaque()]) + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail(manifest=manifest) + + assert sentinel not in repr(exc_info.value) + assert source_error.args == () + assert source_error.__traceback__ is None + assert child_error.args == () + assert child_error.__traceback__ is None + + +@pytest.mark.parametrize( + ("source_error", "expected_type", "expected_args"), + [ + (SystemExit("protected-system-exit-secret"), SystemExit, (1,)), + (GeneratorExit("protected-generator-exit-secret"), GeneratorExit, ()), + (KeyboardInterrupt("protected-keyboard-interrupt-secret"), KeyboardInterrupt, ()), + ], +) +def test_protected_process_control_is_replaced_without_payload( + source_error: BaseException, + expected_type: type[BaseException], + expected_args: tuple[object, ...], +) -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="protected-process-control-authority", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(expected_type) as exc_info: + fail(manifest=manifest) + + assert type(exc_info.value) is expected_type + assert exc_info.value is not source_error + assert exc_info.value.args == expected_args + assert source_error.args == () + assert source_error.__traceback__ is None + + +def test_closing_protected_coroutine_preserves_generator_exit() -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="protected-generator-exit-authority", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + @redact_mount_error_data + async def suspend(*, manifest: Manifest) -> None: + _ = manifest + await asyncio.sleep(0) + + coroutine = suspend(manifest=manifest) + assert coroutine.send(None) is None + coroutine.close() + assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED + + +@pytest.mark.parametrize("protected", [False, True]) +@pytest.mark.asyncio +async def test_slot_backed_session_state_preserves_mount_authority_classification( + protected: bool, +) -> None: + sentinel = f"slot-backed-session-secret-{protected}" + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + if protected + else {} + ) + source_error = RuntimeError(sentinel) + + class SlotBackedSession(BaseSandboxSession): + __slots__ = ("state",) + + async def _ensure_backend_started(self) -> None: + raise source_error + + class SlotBackedSessionState(SandboxSessionState): + type: Literal["docker"] = "docker" + + SlotBackedSession.__abstractmethods__ = frozenset() + session = cast(Any, SlotBackedSession)() + session.state = SlotBackedSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="slot-backed-session"), + ) + assert vars(session) == {} + + if protected: + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await session.start() + + assert exc_info.value is not source_error + assert source_error.args == () + assert source_error.__traceback__ is None + else: + with pytest.raises(RuntimeError) as exc_info: + await session.start() + + assert exc_info.value is source_error + assert source_error.args == (sentinel,) + + +@pytest.mark.asyncio +async def test_property_backed_session_state_fails_closed_without_descriptor_access() -> None: + sentinel = "property-backed-session-secret" + source_error = RuntimeError(sentinel) + + class PropertyBackedSession(BaseSandboxSession): + state_accessed = False + + @property + def state(self) -> SandboxSessionState: + type(self).state_accessed = True + raise AssertionError("state property was accessed during classification") + + @state.setter + def state(self, value: SandboxSessionState) -> None: + _ = value + type(self).state_accessed = True + raise AssertionError("state property was accessed during classification") + + async def _ensure_backend_started(self) -> None: + raise source_error + + PropertyBackedSession.__abstractmethods__ = frozenset() + session = cast(Any, PropertyBackedSession)() + + @redact_mount_error_data + async def fail(session: BaseSandboxSession) -> None: + _ = session + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(session) + + assert exc_info.value is not source_error + assert PropertyBackedSession.state_accessed is False + assert source_error.args == () + assert source_error.__traceback__ is None + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.parametrize( + ("construction_code", "active_code", "expected_args"), + [ + ("protected-stale-system-exit-secret", 0, (0,)), + (0, "protected-active-system-exit-secret", (1,)), + ], +) +@pytest.mark.asyncio +async def test_protected_system_exit_uses_active_safe_status( + boundary: str, + construction_code: object, + active_code: str | int | None, + expected_args: tuple[object, ...], +) -> None: + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key="protected-system-exit-authority", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + source_error = SystemExit(construction_code) + source_error.code = active_code + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(SystemExit) as exc_info: + await fail(manifest=manifest) + else: + + @redact_mount_error_data_sync + def fail_sync(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(SystemExit) as exc_info: + fail_sync(manifest=manifest) + + assert type(exc_info.value) is SystemExit + assert exc_info.value is not source_error + assert exc_info.value.args == expected_args + assert source_error.args == () + assert source_error.__traceback__ is None + + +def test_credentialless_structured_mount_error_is_unchanged() -> None: + source_error = MountCommandError( + command="credentialless-command", + stderr="credentialless-stderr", + retryable=True, + ) + + @redact_mount_error_data_sync + def fail(*, manifest: Manifest) -> None: + _ = manifest + raise source_error + + with pytest.raises(MountCommandError) as exc_info: + fail(manifest=Manifest()) + + assert exc_info.value is source_error + assert source_error.retryable is True + assert source_error.context == { + "command": "credentialless-command", + "stderr": "credentialless-stderr", + } + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.parametrize("link_name", ["_client", "_inner", "session", "_session"]) +@pytest.mark.asyncio +async def test_credentialless_external_client_ignores_opaque_provider_link( + boundary: str, + link_name: str, +) -> None: + class ExternalClient(_SecurityTestClient): + pass + + client = ExternalClient() + setattr(client, link_name, object()) + source_error = MountCommandError( + command="credentialless-command", + stderr="credentialless-stderr", + retryable=True, + ) + + if boundary == "async": + + @redact_mount_error_data + async def fail(*, client: BaseSandboxClient[Any]) -> None: + _ = client + raise source_error + + with pytest.raises(MountCommandError) as exc_info: + await fail(client=client) + else: + + @redact_mount_error_data_sync + def fail_sync(*, client: BaseSandboxClient[Any]) -> None: + _ = client + raise source_error + + with pytest.raises(MountCommandError) as exc_info: + fail_sync(client=client) + + assert exc_info.value is source_error + assert source_error.retryable is True + assert source_error.context == { + "command": "credentialless-command", + "stderr": "credentialless-stderr", + } + + +@pytest.mark.parametrize("boundary", ["async", "sync"]) +@pytest.mark.parametrize("owner_kind", ["run_config", "external_client"]) +@pytest.mark.asyncio +async def test_required_authority_carrier_dominates_aliased_optional_link( + boundary: str, + owner_kind: str, +) -> None: + opaque = object() + if owner_kind == "run_config": + owner: object = SandboxRunConfig( + session=cast(Any, opaque), + session_state=cast(Any, opaque), + ) + else: + + class ExternalClient(_SecurityTestClient): + pass + + client = ExternalClient() + cast(Any, client).state = opaque + cast(Any, client)._inner = opaque + owner = client + + source_error = RuntimeError("protected-aliased-authority-secret") + + if boundary == "async": + + @redact_mount_error_data + async def fail(owner: object) -> None: + _ = owner + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + await fail(owner) + else: + + @redact_mount_error_data_sync + def fail_sync(owner: object) -> None: + _ = owner + raise source_error + + with pytest.raises(RuntimeError, match="protected mount configuration") as exc_info: + fail_sync(owner) + + assert exc_info.value is not source_error + assert source_error.args == () + assert source_error.__traceback__ is None + + @pytest.mark.asyncio async def test_operation_error_with_mount_authority_is_replaced() -> None: sentinel = "provider-operation-secret" From 8cb02cb8cf7ced83668e8ee9147cf5dfb63a2789 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 14:32:23 +0900 Subject: [PATCH 265/473] perf: defer broad verification until review is clean --- .../skills/code-change-verification/SKILL.md | 9 +- .../implementation-final-review/SKILL.md | 8 +- .../references/reviewer-brief.md | 6 +- .../scripts/review_protocol.py | 9 ++ .../scripts/test_review_protocol.py | 21 ++++- .../scripts/test_skill_contract.py | 88 +++++++++++-------- AGENTS.md | 10 ++- 7 files changed, 104 insertions(+), 47 deletions(-) diff --git a/.agents/skills/code-change-verification/SKILL.md b/.agents/skills/code-change-verification/SKILL.md index 6f6684a2db..29fc41466a 100644 --- a/.agents/skills/code-change-verification/SKILL.md +++ b/.agents/skills/code-change-verification/SKILL.md @@ -7,7 +7,7 @@ description: Run the mandatory verification stack when changes affect runtime co ## Overview -Ensure work is only marked complete after formatting, linting, type checking, and tests pass. Use this skill when changes affect runtime code, tests, or build/test configuration. You can skip it for docs-only or repository metadata unless a user asks for the full stack. +Ensure work is only marked complete after formatting, linting, type checking, and tests pass. Use this skill when changes affect runtime code, tests, or build/test configuration. You can skip it for docs-only or repository metadata unless a user asks for the full stack. This is a post-review final gate: when `$implementation-final-review` applies, do not invoke the broad stack until its clean-review condition applies to the stable task diff. ## Quick start @@ -19,6 +19,13 @@ Ensure work is only marked complete after formatting, linting, type checking, an 6. If any command fails, fix the issue, rerun the script, and report the failing output. 7. Confirm completion only when all commands succeed with no remaining issues. +## Start condition and host capacity + +- During iterative review, use only focused tests and a narrowly targeted static check when the changed typing boundary requires one. Defer repository-wide `make typecheck` and the rest of this complete stack until review is clean. +- Immediately before starting the complete stack, use available read-only task or process evidence to check whether another repository-wide test, typecheck, build, examples runner, or integration command is already active on the same host. +- When concrete contention is visible, continue useful non-heavy work such as review, remediation, evidence preparation, or focused checks, then check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file. +- Start automatically once review is clean, the diff is stable, and observable host capacity is available. Do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. + ## Codex execution policy The full test suite exercises `UnixLocalSandboxSession`, which starts its own macOS sandbox. A diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index 9d398c48f7..c915cb9d60 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -1,6 +1,6 @@ --- name: implementation-final-review -description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use compact self-contained reviewer packets and two concurrent no-history independent reviewers per round, overlap non-mutating final repository verification with long event-driven reviewer waits on the same frozen fingerprint, preserve clean evidence for unchanged semantic components, close repeated root-cause groups instead of accumulating local patches, and enforce bounded review cycles in one task-global ledger. +description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use compact self-contained reviewer packets and two concurrent no-history independent reviewers per round, defer broad final repository verification until review is clean and observable host capacity is available, preserve clean evidence for unchanged semantic components, close repeated root-cause groups instead of accumulating local patches, and enforce bounded review cycles in one task-global ledger. --- # Implementation Final Review @@ -39,7 +39,7 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor - Compare patching the current diff with replacing task-owned branch-local machinery by a narrow change from the base implementation. - Treat unreleased implementation and tests as disposable. Preserve unrelated or user-owned changes. - Choose the narrower design unless concrete contract evidence requires the current machinery. -6. Select the relevant review dimensions below from the affected runtime boundaries and repository architecture references. Complete every selected dimension even after finding a blocker; the goal is a complete final review, not the first valid comment. Classify review risk before dispatch: normal when the change does not affect concurrency, cancellation, security, trust, persistence, durable state, released compatibility, package/runtime exports, protocol ownership, or cross-provider lifecycle; elevated when any of those boundaries changes or an earlier round produced P0/P1. Run the cheapest affected-boundary preflight broad enough to catch likely late fallout from a dependency, package surface, generated artifact, or cross-cutting runtime change. For normal risk, prefer focused tests plus the affected subsystem's build, type, import, or generated-surface check. For elevated or cross-cutting risk, run the affected subsystem's complete unit suite plus its build, type, import, or distribution checks when available. Do not run the complete repository verification merely to enter the review gate. Run this preflight once for a semantic state and rerun only the affected checks after fixes. +6. Select the relevant review dimensions below from the affected runtime boundaries and repository architecture references. Complete every selected dimension even after finding a blocker; the goal is a complete final review, not the first valid comment. Classify review risk before dispatch: normal when the change does not affect concurrency, cancellation, security, trust, persistence, durable state, released compatibility, package/runtime exports, protocol ownership, or cross-provider lifecycle; elevated when any of those boundaries changes or an earlier round produced P0/P1. Run the cheapest affected-boundary preflight broad enough to catch likely late fallout from a dependency, package surface, generated artifact, or cross-cutting runtime change. Prefer focused tests plus a narrowly targeted import, generated-surface, or static check. Run a targeted type check only when the change directly affects a typing boundary and the command is materially narrower than repository-wide `make typecheck`. Do not run repository-wide lint, typecheck, builds, integration suites, `make tests-review`, or `make tests` merely to enter or iterate through the review gate. Run the focused preflight once for a semantic state and rerun only affected checks after fixes. 7. Build the pre-dispatch evidence required by the changed boundary: - For every changed public symbol, configuration field, event, serialized field, wire value, or documented caller-visible behavior, create a contract-surface inventory: producers and constructors; every consumer, forwarding branch, and adapter; default, missing, and invalid-value behavior; package exports and generated public surfaces when applicable; adjacent docs and examples; and caller-visible tests. Search adjacent contract surfaces even when they are absent from the diff. A required docs, example, export, adapter, or generated-surface update is a missing task deliverable, not out of scope merely because it is not yet in the manifest. - For concurrency, cancellation, reentrancy, shared lifecycle state, or a check followed by an await before a side effect, create an await-boundary matrix. For each relevant operation, record the state snapshot, blocking or await point, events and operations that may run while suspended, durable or monotonic evidence retained, revalidation before each side effect, and resulting cancel, feedback, persistence, or cleanup action. Include source completion, a newer operation active with known and unknown identity, a newer operation that starts and completes while suspended, and failure or cancellation of the awaited action when those states are supported. If correctness depends on whether something ever happened, current active state is insufficient unless serialization proves it cannot be lost; require monotonic identity, generation, tombstone, or equivalent durable evidence. @@ -54,7 +54,7 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. 10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. 11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the complete raw diff as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires the complete-diff digest to match its `tracked_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix and canonical root ownership, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. -12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. Do not leave the implementer idle while reviewers run: complete mutating formatting before fingerprinting, then immediately start every eligible non-mutating final repository gate on the exact frozen content while reviewers work. Before dispatch, record each planned command and establish that it does not edit, format, regenerate, stage, or create any task-owned deliverable. If a required gate cannot be shown non-mutating, defer it until review is clean. Preserve the repository's required order; in `openai-agents-python`, this means `make format` before fingerprinting, then `make lint`, `make typecheck`, and `make tests` during review. During an iterative review round, use the narrowest evidence-based affected-boundary check: for changes unrelated to every `review_optional` owner, run `make tests-review`; for a leaf subsystem change, run `make tests-review` plus that subsystem's complete test file or directory without a marker filter; for cross-cutting core or shared test-infrastructure changes, run `make tests`. Inspect the current marker owners before choosing. Prefer an already successful same-fingerprint check over rerunning it, and never replay cumulative historical verification. Represent reusable success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The reduced check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete `make tests` gate. If the affected boundary is uncertain, run `make tests`. Record combined, component, and repository fingerprints immediately before each gate starts and after it exits, along with commands, environment, and result. Concurrent verification earns final-gate credit only when all fingerprints match the reviewed repository state exactly. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. If a reviewer reports an actionable finding while verification is still running, cancel or stop the obsolete verification when practical, then wait for the complete reviewer batch before editing. If reviewer findings cause an edit, discard verification credit only for changed or dependency-invalidated components and for any final gate whose fingerprint no longer matches. +12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. 13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged. Require two concurrent independent delta reviews of every changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. 14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, and reopening a closed canonical root without evidence IDs that are new to that root. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. 15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. @@ -69,7 +69,7 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor - normal-risk change: two independent clean reviews of the same fingerprint, launched concurrently; - elevated-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint with complementary high-risk specialties, launched concurrently. - component-only post-review edit: clean credit for every unchanged component plus two concurrent clean independent delta reviews covering all changed components and their runtime boundary. -20. After the clean-review condition is met, complete the repository's code-change verification or accept the overlapped result from step 12 only when every mandatory command succeeded in the repository-required order against the exact clean-reviewed fingerprint, execution did not mutate reviewed content or create an ambiguous repository-state change, and the final combined and component fingerprints still match. If verification was still running, wait for it; do not rerun successful exact-fingerprint work merely because review completed later. If reviewer findings caused an edit, run the required verification again for the new fingerprint. Classify any final-gate edit before invalidating review evidence: +20. After the clean-review condition is met, confirm that the diff and component fingerprints remain stable, then check observable host capacity before starting the repository's code-change verification. Use available read-only task or process evidence; treat another repository-wide test, typecheck, build, examples runner, or integration command already active on the same host as concrete contention. When contention is visible, continue useful non-heavy work or an event-driven wait and check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file, and do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. Once capacity is available, run every mandatory command in the repository-required order against the exact clean-reviewed fingerprint. Record combined, component, and repository fingerprints immediately before and after the final stack. Accept final verification only when every command succeeds, execution does not mutate reviewed content or create an ambiguous repository-state change, and all fingerprints still match. Classify any final-gate edit before invalidating review evidence: - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index fbfbb322cf..54ccdcc88f 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -28,8 +28,8 @@ Use this template to prepare one self-contained, factual snapshot packet per fin - Focused preflight commands and results: - Same-fingerprint verification already credited, or `none`: - Verification receipt path and SHA-256 descriptors for credited checks, or `none`: -- Eligible concurrent final-gate commands and non-mutation basis: -- Gates deferred because they may mutate task-owned content, or `none`: +- Eligible concurrent final-gate commands: `none` (required because broad final gates start only after clean review): +- Broad final gates deferred until clean review: - Selected architecture references or exact relevant excerpts: ## Machine-readable preflight @@ -40,7 +40,7 @@ Store the shared packet index as one JSON object and validate it before dispatch The active implementation control plane is trusted to record real reviewer dispatches, waits, outputs, and verification executions. The local helper validates completeness, digests, identity, state transitions, and reuse against those records; it does not provide cryptographic attestation against a malicious control plane that fabricates every input. Platform-issued signed execution provenance is intentionally unsupported here and requires a separate trusted service. -The packet object uses integer `schema_version: 1` and contains these required top-level fields: `packet_overage_reason`, `task`, `scope_contract`, `repository`, `ledger`, `manifests`, `review_state`, `verification`, `architecture_references`, `evidence_artifacts`, `inventory`, `selected_high_risk_dimensions`, and `reviewer_assignments`. Mirror the factual fields above rather than adding conclusions. Encode `verification.preflight_results` as an array of exact `command` and `result` objects; use an empty array when no focused preflight ran. Store exactly one evidence artifact with `role: "review-state"` containing the unmodified `review_state.py` JSON, exactly one with `role: "complete-diff"`, and exactly one with `role: "repository-status"` containing unfiltered porcelain-v1 `-z` status. The `review_state` packet object contains exactly `evidence_id`, which names the review-state artifact, and the exact `revalidation_command`; extra copied fingerprint or state fields are invalid. The repository object names the status artifact with `status_evidence_id` and lists every changed path outside the task manifest in `exclusions` with a concrete reason. Use two reviewer assignments whose combined IDs cover every inventory row and selected high-risk dimension. Every reviewer assignment must include every component boundary and all three control artifacts; supporting evidence may remain specialty-specific. The validator derives fingerprints from the digested review-state artifact, requires repository base and head to match it, requires the task and component manifests to match its pathspecs exactly, requires the complete-diff artifact digest to equal its `tracked_diff_sha256`, requires the status digest to equal its unfiltered status fingerprint, and requires exclusions to account exactly for every unfiltered changed path outside the task workspace. It reports the packet's actual path, byte size, SHA-256 digest, review-state path, fingerprint, components, inventory IDs, and reviewer IDs; copy that output into the dispatch record. If the packet exceeds 12 KiB, replace `packet_overage_reason: "none"` with the decision-relevant reason it could not be split further. +The packet object uses integer `schema_version: 1` and contains these required top-level fields: `packet_overage_reason`, `task`, `scope_contract`, `repository`, `ledger`, `manifests`, `review_state`, `verification`, `architecture_references`, `evidence_artifacts`, `inventory`, `selected_high_risk_dimensions`, and `reviewer_assignments`. Mirror the factual fields above rather than adding conclusions. Encode `verification.preflight_results` as an array of exact `command` and `result` objects; use an empty array when no focused preflight ran. Set `verification.eligible_concurrent_gates` to the exact string `none`, and list the repository-wide lint, typecheck, test, build, examples, and integration gates that remain applicable in `verification.deferred_gates`; packet preflight rejects any attempt to overlap a broad final gate with review. Store exactly one evidence artifact with `role: "review-state"` containing the unmodified `review_state.py` JSON, exactly one with `role: "complete-diff"`, and exactly one with `role: "repository-status"` containing unfiltered porcelain-v1 `-z` status. The `review_state` packet object contains exactly `evidence_id`, which names the review-state artifact, and the exact `revalidation_command`; extra copied fingerprint or state fields are invalid. The repository object names the status artifact with `status_evidence_id` and lists every changed path outside the task manifest in `exclusions` with a concrete reason. Use two reviewer assignments whose combined IDs cover every inventory row and selected high-risk dimension. Every reviewer assignment must include every component boundary and all three control artifacts; supporting evidence may remain specialty-specific. The validator derives fingerprints from the digested review-state artifact, requires repository base and head to match it, requires the task and component manifests to match its pathspecs exactly, requires the complete-diff artifact digest to equal its `tracked_diff_sha256`, requires the status digest to equal its unfiltered status fingerprint, and requires exclusions to account exactly for every unfiltered changed path outside the task workspace. It reports the packet's actual path, byte size, SHA-256 digest, review-state path, fingerprint, components, inventory IDs, and reviewer IDs; copy that output into the dispatch record. If the packet exceeds 12 KiB, replace `packet_overage_reason: "none"` with the decision-relevant reason it could not be split further. The ledger contains `task_id`, `authorized_round_budgets`, `current_round`, `remaining_budget`, and `root_causes`. Supply the task ID and absolute task-global ledger path independently on every validator command. For every round after round 1, also supply the immediately preceding round's immutable ledger snapshot and its SHA-256 digest from the control plane; never derive either argument from the packet under validation. The immutable snapshot must be a distinct file, not the mutable current ledger under another argument. The validator requires the packet, current ledger, and prior ledger identity to match those control-plane arguments. It requires `current_round` plus `remaining_budget` to equal the sum of the positive integer budget history, the current budget history to preserve the prior prefix, the current round to equal the prior round for a same-round retry or advance by exactly one, every prior canonical root and its ownership to remain present, and the current ledger file's JSON object to match the packet ledger exactly. Each `ledger.root_causes` entry contains `id`, `status`, `inventory_ids`, and `contract_evidence_ids`. Every root must own at least one inventory ID, and each inventory ID has exactly one canonical root owner. Every contract evidence ID must resolve to an `evidence_artifacts[].id`; the ledger cannot establish evidence authority with an unindexed string. The implementer owns canonical IDs. Reviewers must reuse one supplied ID or propose `NEW:` with evidence or inventory not already owned by any canonical root; reviewers must not mint a renamed bare ID. Only the implementer promotes a proposal into the ledger. diff --git a/.agents/skills/implementation-final-review/scripts/review_protocol.py b/.agents/skills/implementation-final-review/scripts/review_protocol.py index d32fbe314f..57b4c5b309 100644 --- a/.agents/skills/implementation-final-review/scripts/review_protocol.py +++ b/.agents/skills/implementation-final-review/scripts/review_protocol.py @@ -425,6 +425,15 @@ def validate_packet( raise ProtocolError("Packet schema_version must be integer 1.") for dotted_path in REQUIRED_PACKET_TEXT: _text(_at(packet, dotted_path), dotted_path) + if _at(packet, "verification.eligible_concurrent_gates") != "none": + raise ProtocolError( + "verification.eligible_concurrent_gates must be 'none'; broad final gates start " + "only after clean review." + ) + if _at(packet, "verification.deferred_gates").strip().lower() in SENTINELS: + raise ProtocolError( + "verification.deferred_gates must list the applicable broad final gates." + ) if _at(packet, "task.risk_tier") not in {"normal", "elevated"}: raise ProtocolError("task.risk_tier must be 'normal' or 'elevated'.") expected_task_id = _text(expected_task_id, "expected task ID", concrete=True) diff --git a/.agents/skills/implementation-final-review/scripts/test_review_protocol.py b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py index 09d0f51473..36e4c7f422 100644 --- a/.agents/skills/implementation-final-review/scripts/test_review_protocol.py +++ b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py @@ -188,7 +188,7 @@ def _packet(self) -> dict[str, object]: } ], "eligible_concurrent_gates": "none", - "deferred_gates": "make format", + "deferred_gates": "make lint; make typecheck; make tests", "credited_receipts": [], }, "architecture_references": [], @@ -337,6 +337,25 @@ def test_valid_packet_reports_dispatch_digest_and_size(self) -> None: summary["packet_sha256"], hashlib.sha256(self.packet_path.read_bytes()).hexdigest() ) + def test_packet_defers_broad_final_gates_until_clean_review(self) -> None: + packet = copy.deepcopy(self.packet) + packet["verification"]["eligible_concurrent_gates"] = "make tests" + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex( + ProtocolError, + "verification.eligible_concurrent_gates must be 'none'", + ): + self._validate_packet() + + packet["verification"]["eligible_concurrent_gates"] = "none" + packet["verification"]["deferred_gates"] = "none" + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex( + ProtocolError, + "verification.deferred_gates must list the applicable broad final gates", + ): + self._validate_packet() + def test_packet_fails_closed_on_missing_field_or_incomplete_assignment(self) -> None: cases = [] missing = copy.deepcopy(self.packet) diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index f55bf80357..d4f877dec3 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -16,6 +16,9 @@ def setUpClass(cls) -> None: cls.reviewer_brief = (cls.skill_root / "references" / "reviewer-brief.md").read_text() cls.review_protocol = (cls.skill_root / "scripts" / "review_protocol.py").read_text() cls.repo_instructions = (cls.skill_root.parents[2] / "AGENTS.md").read_text() + cls.code_change_verification = ( + cls.skill_root.parent / "code-change-verification" / "SKILL.md" + ).read_text() def test_repo_local_metadata_matches_skill(self) -> None: self.assertEqual(self.skill.splitlines()[1], "name: implementation-final-review") @@ -82,53 +85,62 @@ def test_incomplete_reviewer_packets_fail_closed(self) -> None: with self.subTest(text=text): self.assertIn(text, self.reviewer_brief) - def test_full_verification_can_overlap_review_without_weakening_freeze(self) -> None: + def test_full_verification_waits_for_clean_review(self) -> None: required_text = ( - "Do not leave the implementer idle while reviewers run", - "complete mutating formatting before fingerprinting", - "every eligible non-mutating final repository gate", - "exact frozen content", - "`make lint`, `make typecheck`, and `make tests` during review", - "discard verification credit only for changed or dependency-invalidated components", - "accept the overlapped result from step 12", - "exact clean-reviewed fingerprint", - "do not rerun successful exact-fingerprint work", + "Do not start any broad final repository gate while review is incomplete or " + "finding-bearing", + "defer `make lint`, `make typecheck`, `make tests`, repository-wide builds, " + "examples runners, and integration suites until step 19 establishes clean review", + "Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` " + "during an iterative review round", + "Set `verification.eligible_concurrent_gates` to `none`", + "the exact clean-reviewed fingerprint must still pass the complete " + "repository-required verification stack", + "After the clean-review condition is met", ) for text in required_text: with self.subTest(text=text): self.assertIn(text, self.skill) - self.assertIn("Eligible concurrent final-gate commands", self.reviewer_brief) + self.assertIn("Eligible concurrent final-gate commands: `none`", self.reviewer_brief) + self.assertIn("Broad final gates deferred until clean review", self.reviewer_brief) self.assertIn( - "Gates deferred because they may mutate task-owned content", self.reviewer_brief + "packet preflight rejects any attempt to overlap a broad final gate with review", + self.reviewer_brief, ) - def test_overlapped_final_gates_preserve_fingerprint_integrity(self) -> None: + def test_host_capacity_check_avoids_locks_and_finalize_prompts(self) -> None: + for source in (self.skill, self.code_change_verification, self.repo_instructions): + with self.subTest(source=source[:40]): + self.assertIn("available read-only task or process evidence", source) + self.assertIn("repository lock", source) + self.assertIn("host-wide mutex", source) + self.assertIn("user-triggered `finalize`", source) + + self.assertIn("If host telemetry is unavailable", self.skill) + self.assertIn("Lack of host telemetry alone is not a blocker", self.repo_instructions) + + def test_work_status_reporting_distinguishes_running_and_final_states(self) -> None: required_text = ( - "establish that it does not edit, format, regenerate, stage, or create any " - "task-owned deliverable", - "Record combined, component, and repository fingerprints immediately before each " - "gate starts and after it exits", - "all fingerprints match the reviewed repository state exactly", - "cancel or stop the obsolete verification when practical", - "Keep `$pr-draft-summary` deferred", - "Invoke `$pr-draft-summary` last", + "Use `RUNNING` only in commentary", + "Use `COMPLETE` in the final response only when", + "Use `NEEDS_DECISION` in the final response only when", + 'instead of asking the user to say "continue"', ) - for text in required_text: with self.subTest(text=text): - self.assertIn(text, self.skill) + self.assertIn(text, self.repo_instructions) - def test_iterative_review_can_skip_unaffected_slow_subsystems(self) -> None: + def test_iterative_review_uses_focused_checks_only(self) -> None: required_text = ( - "for changes unrelated to every `review_optional` owner, run `make tests-review`", - "for a leaf subsystem change, run `make tests-review` plus that subsystem's " - "complete test file or directory", - "for cross-cutting core or shared test-infrastructure changes, run `make tests`", - "The reduced check earns no final-gate credit", - "the exact clean-reviewed fingerprint must still pass the complete `make tests` gate", - "If the affected boundary is uncertain, run `make tests`", + "Prefer focused tests plus a narrowly targeted import, generated-surface, or static " + "check", + "Run a targeted type check only when the change directly affects a typing boundary", + "Do not run repository-wide lint, typecheck, builds, integration suites, " + "`make tests-review`, or `make tests`", + "run only focused checks that target the changed boundary", + "The focused check earns no final-gate credit", ) for text in required_text: @@ -152,8 +164,8 @@ def test_shared_typescript_improvements_keep_python_boundaries(self) -> None: required_text = ( "package exports and generated public surfaces when applicable", "protocol capability ownership, pagination termination, cache ownership", - "in `openai-agents-python`, this means `make format` before fingerprinting", - "`make lint`, `make typecheck`, and `make tests` during review", + "defer `make lint`, `make typecheck`, `make tests`, repository-wide builds", + "the implementer runs the complete stack once after the clean-review gate", ) for text in required_text: @@ -292,11 +304,13 @@ def test_semantic_clean_credit_fails_closed_on_dependency_changes(self) -> None: def test_intermediate_verification_is_cost_aware_but_final_gate_is_complete(self) -> None: required_text = ( - "Prefer an already successful same-fingerprint check over rerunning it", + "Prefer an already successful same-fingerprint focused check over rerunning it", "never replay cumulative historical verification", - "The reduced check earns no final-gate credit", - "the exact clean-reviewed fingerprint must still pass the complete `make tests` gate", - "complete the repository's code-change verification", + "The focused check earns no final-gate credit", + "the exact clean-reviewed fingerprint must still pass the complete " + "repository-required verification stack", + "check observable host capacity before starting the repository's " + "code-change verification", ) for text in required_text: with self.subTest(text=text): diff --git a/AGENTS.md b/AGENTS.md index 54b47c9cac..97e888b836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ Run it when you change: You can skip `$code-change-verification` for docs-only or repo-meta changes (for example, `docs/`, `.agents/`, `README.md`, `AGENTS.md`, `.github/`), unless a user explicitly asks to run the full verification stack. +Treat `$code-change-verification` as the post-review final gate, not as an iterative review check. When `$implementation-final-review` applies, satisfy its clean-review condition before starting the repository-wide format, lint, typecheck, and test stack. Immediately before starting that stack, use available read-only task or process evidence to check for another broad test, typecheck, build, examples, or integration command already running on the same host. When concrete contention is visible, keep making progress on review, remediation, evidence preparation, or focused checks and defer the broad stack until capacity is available. Do not add a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. Lack of host telemetry alone is not a blocker. + #### `$openai-knowledge` When working on OpenAI API or OpenAI platform integrations in this repo (Responses API, tools, streaming, Realtime API, auth, models, rate limits, MCP, Agents SDK or ChatGPT Apps SDK), use `$openai-knowledge` to pull authoritative docs via the OpenAI Developer Docs MCP server (and guide setup if it is not configured). @@ -43,7 +45,7 @@ Independent reviewers dispatched by `$implementation-final-review` inherit the i #### `$implementation-final-review` -After implementing runtime code, tests, examples, build/test behavior, or behavior-impacting docs and completing focused tests, run `$implementation-final-review` before final `$code-change-verification` and `$pr-draft-summary` work and before declaring the task complete. This repository instruction authorizes automatic invocation without a separate user mention. Do not invoke it for planning, investigation, review, or report-only tasks, repo-meta changes, or docs without behavior impact. The skill's clean-review gate does not replace any other mandatory repository skill or verification gate. +After implementing runtime code, tests, examples, build/test behavior, or behavior-impacting docs and completing focused tests, run `$implementation-final-review` before final `$code-change-verification` and `$pr-draft-summary` work and before declaring the task complete. Do not start repository-wide lint, typecheck, tests, builds, examples, or integration suites while the independent review is incomplete or finding-bearing. This repository instruction authorizes automatic invocation without a separate user mention. Do not invoke it for planning, investigation, review, or report-only tasks, repo-meta changes, or docs without behavior impact. The skill's clean-review gate does not replace any other mandatory repository skill or verification gate. #### `$pr-draft-summary` @@ -53,6 +55,12 @@ Skip `$pr-draft-summary` only for trivial or conversation-only tasks, repo-meta/ Producing the PR draft block is part of the local final handoff. It is required for eligible local-only or uncommitted changes and does not authorize creating a branch, committing, pushing, or opening a pull request. +### Work Status Reporting + +- Use `RUNNING` only in commentary while autonomous work remains and no user action is required. Do not end a turn with a final response that says the task is still running or asks the user to send a generic continuation prompt. +- Use `COMPLETE` in the final response only when the requested work and every applicable review, verification, and local handoff step are complete. +- Use `NEEDS_DECISION` in the final response only when progress requires a concrete user choice, expanded authority, or an unresolved external condition. State the exact decision or condition instead of asking the user to say "continue". + ### Git Worktree and Branch Safety Work in the user's current checkout and on the current branch by default. If the Codex task is already running in a selected Git worktree, use that worktree without requesting additional permission. Do not create or switch to another Git worktree, and do not create or switch branches, unless the user explicitly asks for or approves that exact action in the current conversation. A request to implement, investigate, review, test, or verify changes does not by itself authorize changing the active worktree or branch. From 3b62591fc308c95f8bdd01f1aa6a5e68ca216f7c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 15:16:10 +0900 Subject: [PATCH 266/473] test: harden release compatibility contracts (#4297) --- .agents/skills/integration-tests/SKILL.md | 6 +- .github/scripts/detect-changes.sh | 2 +- .github/scripts/run_integration_tests.py | 244 +- .../scripts/update_released_api_contract.py | 110 + .github/workflows/tests.yml | 32 + Makefile | 14 + integration_tests/README.md | 10 +- integration_tests/__init__.py | 1 + integration_tests/_contract_support.py | 1167 + integration_tests/_fake_model.py | 120 + integration_tests/conftest.py | 26 + .../openai/test_approval_resume.py | 15 +- integration_tests/openai/test_responses.py | 3 +- .../packaging/test_optional_extras.py | 3 +- .../packaging/test_provider_selection.py | 3 +- .../packaging/test_released_api_contract.py | 30 + .../packaging/test_run_state_compatibility.py | 140 + integration_tests/pytest.ini | 5 + .../security/test_local_sandbox_isolation.py | 385 + .../security/test_packaged_mount_redaction.py | 176 + src/agents/exceptions.py | 56 +- .../extensions/sandbox/blaxel/mounts.py | 6 + .../extensions/sandbox/cloudflare/mounts.py | 5 + .../extensions/sandbox/daytona/mounts.py | 2 + src/agents/extensions/sandbox/e2b/mounts.py | 2 + src/agents/extensions/sandbox/modal/mounts.py | 5 + .../extensions/sandbox/runloop/mounts.py | 2 + .../extensions/sandbox/vercel/mounts.py | 13 +- .../extensions/sandbox/vercel/sandbox.py | 4 +- src/agents/run_context.py | 21 +- src/agents/run_state.py | 413 +- src/agents/sandbox/_mount_security.py | 46 +- .../sandbox/entries/mounts/_redaction.py | 65 + src/agents/sandbox/entries/mounts/base.py | 43 +- src/agents/sandbox/entries/mounts/patterns.py | 46 +- src/agents/sandbox/runtime_session_manager.py | 8 +- src/agents/sandbox/session/sandbox_client.py | 38 +- .../sandbox/session/sandbox_session_state.py | 108 +- tests/README.md | 4 + tests/extensions/sandbox/test_blaxel.py | 9 - tests/fake_model.py | 79 +- tests/fixtures/released_api_contract.json | 31386 ++++++++++++++++ tests/fixtures/run_state/README.md | 15 + .../features/v1_10_unlimited_max_turns.json | 55 + .../v1_11_tool_output_custom_data.json | 77 + .../v1_12_input_cache_write_usage.json | 56 + .../v1_13_nested_history_ownership.json | 120 + .../v1_13_programmatic_tool_calling.json | 206 + .../v1_14_hosted_mcp_approval_scope.json | 84 + .../v1_15_canonical_invocation_identity.json | 74 + .../v1_2_reasoning_item_id_policy.json | 66 + .../features/v1_3_resumed_trace_state.json | 59 + .../run_state/features/v1_4_request_id.json | 98 + ...v1_5_tool_search_and_display_metadata.json | 96 + .../v1_6_approval_rejection_message.json | 64 + ..._duplicate_agent_identity_and_sandbox.json | 63 + .../features/v1_8_prompt_cache_key.json | 55 + ...v1_9_custom_tool_call_and_tool_origin.json | 87 + tests/fixtures/run_state/generate_corpus.py | 601 + tests/fixtures/run_state/minimal/v1_0.json | 53 + tests/fixtures/run_state/minimal/v1_1.json | 53 + tests/fixtures/run_state/minimal/v1_10.json | 55 + tests/fixtures/run_state/minimal/v1_11.json | 55 + tests/fixtures/run_state/minimal/v1_12.json | 56 + tests/fixtures/run_state/minimal/v1_13.json | 56 + tests/fixtures/run_state/minimal/v1_14.json | 58 + tests/fixtures/run_state/minimal/v1_15.json | 59 + tests/fixtures/run_state/minimal/v1_2.json | 54 + tests/fixtures/run_state/minimal/v1_3.json | 54 + tests/fixtures/run_state/minimal/v1_4.json | 54 + tests/fixtures/run_state/minimal/v1_5.json | 54 + tests/fixtures/run_state/minimal/v1_6.json | 54 + tests/fixtures/run_state/minimal/v1_7.json | 54 + tests/fixtures/run_state/minimal/v1_8.json | 54 + tests/fixtures/run_state/minimal/v1_9.json | 55 + .../run_state/negative/future_version.json | 4 + .../negative/malformed_current_agent.json | 4 + .../run_state/negative/missing_version.json | 3 + .../resume/v1_13_pending_tool_approval.json | 325 + .../v1_13_legacy_mount_credentials.json | 212 + tests/fixtures/run_state/sources.json | 199 + tests/sandbox/test_compatibility_guards.py | 4 +- tests/sandbox/test_mounts.py | 19 +- tests/sandbox/test_runtime.py | 54 + tests/test_integration_runner.py | 473 + tests/test_local_shell_tool.py | 6 +- tests/test_released_api_contract.py | 1428 + tests/test_run_state.py | 14 +- tests/test_run_state_compatibility_corpus.py | 1385 + tests/test_runtime_symmetry_contract.py | 214 + 90 files changed, 41669 insertions(+), 282 deletions(-) create mode 100644 .github/scripts/update_released_api_contract.py create mode 100644 integration_tests/__init__.py create mode 100644 integration_tests/_contract_support.py create mode 100644 integration_tests/_fake_model.py create mode 100644 integration_tests/packaging/test_released_api_contract.py create mode 100644 integration_tests/packaging/test_run_state_compatibility.py create mode 100644 integration_tests/security/test_local_sandbox_isolation.py create mode 100644 integration_tests/security/test_packaged_mount_redaction.py create mode 100644 src/agents/sandbox/entries/mounts/_redaction.py create mode 100644 tests/fixtures/released_api_contract.json create mode 100644 tests/fixtures/run_state/README.md create mode 100644 tests/fixtures/run_state/features/v1_10_unlimited_max_turns.json create mode 100644 tests/fixtures/run_state/features/v1_11_tool_output_custom_data.json create mode 100644 tests/fixtures/run_state/features/v1_12_input_cache_write_usage.json create mode 100644 tests/fixtures/run_state/features/v1_13_nested_history_ownership.json create mode 100644 tests/fixtures/run_state/features/v1_13_programmatic_tool_calling.json create mode 100644 tests/fixtures/run_state/features/v1_14_hosted_mcp_approval_scope.json create mode 100644 tests/fixtures/run_state/features/v1_15_canonical_invocation_identity.json create mode 100644 tests/fixtures/run_state/features/v1_2_reasoning_item_id_policy.json create mode 100644 tests/fixtures/run_state/features/v1_3_resumed_trace_state.json create mode 100644 tests/fixtures/run_state/features/v1_4_request_id.json create mode 100644 tests/fixtures/run_state/features/v1_5_tool_search_and_display_metadata.json create mode 100644 tests/fixtures/run_state/features/v1_6_approval_rejection_message.json create mode 100644 tests/fixtures/run_state/features/v1_7_duplicate_agent_identity_and_sandbox.json create mode 100644 tests/fixtures/run_state/features/v1_8_prompt_cache_key.json create mode 100644 tests/fixtures/run_state/features/v1_9_custom_tool_call_and_tool_origin.json create mode 100644 tests/fixtures/run_state/generate_corpus.py create mode 100644 tests/fixtures/run_state/minimal/v1_0.json create mode 100644 tests/fixtures/run_state/minimal/v1_1.json create mode 100644 tests/fixtures/run_state/minimal/v1_10.json create mode 100644 tests/fixtures/run_state/minimal/v1_11.json create mode 100644 tests/fixtures/run_state/minimal/v1_12.json create mode 100644 tests/fixtures/run_state/minimal/v1_13.json create mode 100644 tests/fixtures/run_state/minimal/v1_14.json create mode 100644 tests/fixtures/run_state/minimal/v1_15.json create mode 100644 tests/fixtures/run_state/minimal/v1_2.json create mode 100644 tests/fixtures/run_state/minimal/v1_3.json create mode 100644 tests/fixtures/run_state/minimal/v1_4.json create mode 100644 tests/fixtures/run_state/minimal/v1_5.json create mode 100644 tests/fixtures/run_state/minimal/v1_6.json create mode 100644 tests/fixtures/run_state/minimal/v1_7.json create mode 100644 tests/fixtures/run_state/minimal/v1_8.json create mode 100644 tests/fixtures/run_state/minimal/v1_9.json create mode 100644 tests/fixtures/run_state/negative/future_version.json create mode 100644 tests/fixtures/run_state/negative/malformed_current_agent.json create mode 100644 tests/fixtures/run_state/negative/missing_version.json create mode 100644 tests/fixtures/run_state/resume/v1_13_pending_tool_approval.json create mode 100644 tests/fixtures/run_state/security/v1_13_legacy_mount_credentials.json create mode 100644 tests/fixtures/run_state/sources.json create mode 100644 tests/test_integration_runner.py create mode 100644 tests/test_released_api_contract.py create mode 100644 tests/test_run_state_compatibility_corpus.py create mode 100644 tests/test_runtime_symmetry_contract.py diff --git a/.agents/skills/integration-tests/SKILL.md b/.agents/skills/integration-tests/SKILL.md index 1866d674cb..10992d4ea7 100644 --- a/.agents/skills/integration-tests/SKILL.md +++ b/.agents/skills/integration-tests/SKILL.md @@ -20,6 +20,7 @@ Run this command from the repository root: ```bash env UV_DEFAULT_INDEX=https://pypi.org/simple \ + OPENAI_AGENTS_INTEGRATION_STRICT=1 \ OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS=1 \ OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS=0 \ make integration-tests-release @@ -27,8 +28,8 @@ env UV_DEFAULT_INDEX=https://pypi.org/simple \ - Use the release profile as the default whenever `$integration-tests` is invoked without a narrower request. - Use OpenRouter as the standard multi-provider gateway. Add provider-specific direct connections only when the user explicitly requests that additional credential matrix. -- Use existing `OPENAI_API_KEY` and `OPENROUTER_API_KEY` values without printing them. Missing optional service configuration may skip capability-specific tests unless strict mode was explicitly requested. -- The command rebuilds the wheel and source distribution, creates isolated virtual environments, checks public imports and optional dependencies, and runs the release-oriented live suites. +- Use existing `OPENAI_API_KEY` and `OPENROUTER_API_KEY` values without printing them. The release target enforces strict mode, so missing required service configuration fails instead of skipping. +- The command rebuilds the wheel and source distribution, creates isolated virtual environments, checks public imports and optional dependencies, runs the release-oriented live suites, and executes the local Docker security contract against both artifacts. - Do not run watch mode, modify source files, create a branch, commit, push, or open a pull request as part of this skill. ## Paired release validation @@ -41,6 +42,7 @@ Use a focused target only when the user specifically asks to narrow the run: ```bash env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-packaging +env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-security env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-core env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-providers env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-hosted diff --git a/.github/scripts/detect-changes.sh b/.github/scripts/detect-changes.sh index e898d2538f..93423ded9e 100755 --- a/.github/scripts/detect-changes.sh +++ b/.github/scripts/detect-changes.sh @@ -46,7 +46,7 @@ changed_files=$(git diff --name-only "$base_sha" "$head_sha" || true) case "$mode" in code) - pattern='^(src/|tests/|examples/|pyproject.toml$|uv.lock$|Makefile$)' + pattern='^(src/|tests/|integration_tests/|examples/|\.github/scripts/(detect-changes\.sh|run_integration_tests\.py|update_released_api_contract\.py)$|\.github/workflows/tests\.yml$|pyproject.toml$|uv.lock$|Makefile$)' ;; docs) pattern='^(docs/|mkdocs.yml$)' diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 28dbf65bb8..91df91d031 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -2,13 +2,17 @@ import argparse import os +import re +import shutil import subprocess import sys +import xml.etree.ElementTree as ET from pathlib import Path ROOT = Path(__file__).resolve().parents[2] WORKSPACE = ROOT / ".tmp" / "integration-tests" DIST = WORKSPACE / "dist" +RESULTS = WORKSPACE / "results" TESTS = ROOT / "integration_tests" EXTRAS = "any-llm,litellm,realtime,voice" OPTIONAL_EXTRAS = ( @@ -22,8 +26,10 @@ "viz", "s3", ) +STRICT_PROFILES = frozenset({"release", "security"}) PROFILES = ( "packaging", + "security", "mcp-v1", "core", "providers", @@ -43,7 +49,26 @@ def run(command: list[str], *, env: dict[str, str] | None = None) -> None: subprocess.run(command, cwd=ROOT, env=env, check=True) +def run_pytest(command: list[str], *, env: dict[str, str]) -> tuple[int, str]: + print(f"[integration] {' '.join(command)}", flush=True) + process = subprocess.Popen( + command, + cwd=ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + output: list[str] = [] + assert process.stdout is not None + for line in process.stdout: + print(line, end="", flush=True) + output.append(line) + return process.wait(), "".join(output) + + def build_distributions() -> tuple[Path, Path]: + shutil.rmtree(DIST, ignore_errors=True) DIST.mkdir(parents=True, exist_ok=True) run(["uv", "build", "--out-dir", str(DIST)]) wheels = sorted(DIST.glob("openai_agents-*.whl"), key=lambda path: path.stat().st_mtime) @@ -139,6 +164,8 @@ def run_suite( selection: str, environment_kind: str, additional_env: dict[str, str] | None = None, + profile: str, + require_no_skips: bool = False, ) -> None: child_env = dict(os.environ) child_env.pop("PYTHONPATH", None) @@ -179,7 +206,141 @@ def run_suite( "-m", selection, ] - run(command, env=child_env) + result_path = RESULTS / profile / f"{environment_kind}.xml" + result_path.parent.mkdir(parents=True, exist_ok=True) + command.append(f"--junitxml={result_path}") + return_code = 1 + output = "" + try: + return_code, output = run_pytest(command, env=child_env) + finally: + deselected_matches = re.findall(r"(\d+) deselected", output) + deselected = int(deselected_matches[-1]) if deselected_matches else 0 + junit_totals = _print_junit_summary( + profile, + environment_kind, + result_path, + deselected=deselected, + ) + if return_code: + raise subprocess.CalledProcessError(return_code, command) + if junit_totals is None: + raise RuntimeError( + f"Integration profile {profile}/{environment_kind} did not produce " + "a valid JUnit report." + ) + if (profile in STRICT_PROFILES or require_no_skips) and junit_totals["skipped"]: + raise RuntimeError( + f"Required integration suite {profile}/{environment_kind} skipped " + f"{junit_totals['skipped']} required test(s)." + ) + + +def _print_junit_summary( + profile: str, + environment_kind: str, + result_path: Path, + *, + deselected: int, +) -> dict[str, int] | None: + if not result_path.exists(): + print( + f"[integration] summary profile={profile} environment={environment_kind} " + "result=missing", + flush=True, + ) + return None + root = _sanitize_and_load_junit(result_path) + if root is None: + print( + f"[integration] summary profile={profile} environment={environment_kind} " + "result=invalid", + flush=True, + ) + return None + suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) + totals = { + key: sum(int(suite.attrib.get(key, "0")) for suite in suites) + for key in ("tests", "failures", "errors", "skipped") + } + passed = totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"] + print( + f"[integration] summary profile={profile} environment={environment_kind} " + f"passed={passed} failed={totals['failures']} errors={totals['errors']} " + f"skipped={totals['skipped']} deselected={deselected}", + flush=True, + ) + return totals + + +def _sanitize_and_load_junit(result_path: Path) -> ET.Element | None: + try: + tree = ET.parse(result_path) + source_root = tree.getroot() + if source_root.tag == "testsuite": + suites = [source_root] + elif source_root.tag == "testsuites": + suites = list(source_root.findall("testsuite")) + else: + suites = [] + if not suites: + raise ValueError("JUnit report does not contain a test suite.") + + safe_suites: list[ET.Element] = [] + for suite_index, suite in enumerate(suites): + counts: dict[str, int] = {} + for key in ("tests", "failures", "errors", "skipped"): + value = int(suite.attrib.get(key, "0")) + if value < 0: + raise ValueError(f"JUnit {key} count must be non-negative.") + counts[key] = value + testcases = list(suite.findall("testcase")) + actual_counts = { + "tests": len(testcases), + "failures": sum(len(case.findall("failure")) for case in testcases), + "errors": sum(len(case.findall("error")) for case in testcases), + "skipped": sum(len(case.findall("skipped")) for case in testcases), + } + if counts != actual_counts: + raise ValueError("JUnit declared counts do not match testcase outcomes.") + if any( + sum(len(case.findall(outcome)) for outcome in ("failure", "error", "skipped")) > 1 + for case in testcases + ): + raise ValueError("JUnit testcase has multiple terminal outcomes.") + + safe_suite = ET.Element( + "testsuite", + { + "name": f"suite-{suite_index}", + **{key: str(value) for key, value in counts.items()}, + }, + ) + safe_suites.append(safe_suite) + for case_index, case in enumerate(testcases): + safe_case = ET.SubElement( + safe_suite, + "testcase", + {"name": f"case-{case_index}"}, + ) + for outcome in ("failure", "error", "skipped"): + if case.find(outcome) is not None: + ET.SubElement(safe_case, outcome) + break + + if source_root.tag == "testsuite": + safe_root = safe_suites[0] + else: + safe_root = ET.Element("testsuites") + safe_root.extend(safe_suites) + ET.ElementTree(safe_root).write(result_path, encoding="utf-8", xml_declaration=True) + except (ET.ParseError, OSError, ValueError): + try: + result_path.unlink(missing_ok=True) + except OSError: + pass + return None + return safe_root def main() -> None: @@ -191,6 +352,9 @@ def main() -> None: help="Include configured direct Anthropic and Gemini providers alongside OpenRouter.", ) args = parser.parse_args() + if args.profile in STRICT_PROFILES: + os.environ["OPENAI_AGENTS_INTEGRATION_STRICT"] = "1" + shutil.rmtree(RESULTS / args.profile, ignore_errors=True) if args.all: os.environ["OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS"] = "1" os.environ["OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS"] = "1" @@ -212,16 +376,33 @@ def main() -> None: selection="mcp_compat", environment_kind=environment_kind, additional_env={"OPENAI_AGENTS_INTEGRATION_MCP_VERSION": mcp_version}, + profile=args.profile, ) - if args.profile in {"packaging", "core", "hosted", "full", "release", "nightly", "manual"}: - python = create_environment("core", wheel) + if args.profile in { + "packaging", + "security", + "core", + "hosted", + "full", + "release", + "nightly", + "manual", + }: + python = create_environment( + "core", + wheel, + optional_extra="docker" if args.profile in STRICT_PROFILES else None, + ) selections = { "packaging": "packaging", + "security": "security", "core": "packaging or core", "hosted": "packaging or hosted", "full": "packaging or ((core or hosted) and not nightly and not manual)", - "release": "packaging or ((core or hosted) and not nightly and not manual)", + "release": ( + "packaging or security or ((core or hosted) and not nightly and not manual)" + ), "nightly": "packaging or ((core or hosted) and not manual)", "manual": "packaging or core or hosted", } @@ -231,6 +412,7 @@ def main() -> None: sdist, selection=selections[args.profile], environment_kind="core", + profile=args.profile, ) if args.profile in {"providers", "realtime", "voice", "full", "release", "nightly", "manual"}: @@ -249,17 +431,63 @@ def main() -> None: sdist, selection=selection, environment_kind="extended", + profile=args.profile, ) - if args.profile in {"packaging", "full", "release", "nightly", "manual"}: - python = create_environment("sdist", sdist) - run_suite(python, wheel, sdist, selection="packaging", environment_kind="sdist") + if args.profile in {"packaging", "security", "full", "release", "nightly", "manual"}: + python = create_environment( + "sdist", + sdist, + optional_extra="docker" if args.profile in STRICT_PROFILES else None, + ) + if args.profile == "security": + selection = "security" + elif args.profile == "release": + selection = "packaging or distribution_smoke or security" + elif args.profile in {"nightly", "manual"}: + selection = "packaging or distribution_smoke" + else: + selection = "packaging" + run_suite( + python, + wheel, + sdist, + selection=selection, + environment_kind="sdist", + profile=args.profile, + ) + + if args.profile in {"packaging", "release"}: + for artifact_kind, distribution in (("wheel", wheel), ("sdist", sdist)): + environment_kind = f"{artifact_kind}-cloudflare" + python = create_environment( + environment_kind, + distribution, + optional_extra="cloudflare", + ) + run_suite( + python, + wheel, + sdist, + selection="packaging_dependency", + environment_kind=environment_kind, + additional_env={"OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS": "1"}, + profile=args.profile, + require_no_skips=True, + ) if args.profile in {"extras", "full", "release", "nightly", "manual"}: for optional_extra in OPTIONAL_EXTRAS: environment_kind = f"extra-{optional_extra}" python = create_environment(environment_kind, wheel, optional_extra=optional_extra) - run_suite(python, wheel, sdist, selection="extras", environment_kind=environment_kind) + run_suite( + python, + wheel, + sdist, + selection="extras", + environment_kind=environment_kind, + profile=args.profile, + ) if __name__ == "__main__": diff --git a/.github/scripts/update_released_api_contract.py b/.github/scripts/update_released_api_contract.py new file mode 100644 index 0000000000..35e6bea821 --- /dev/null +++ b/.github/scripts/update_released_api_contract.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +ROOT = Path(__file__).resolve().parents[2] +CONTRACT = ROOT / "tests" / "fixtures" / "released_api_contract.json" + +sys.path.insert(0, str(ROOT)) + +from integration_tests._contract_support import ( # noqa: E402 + build_released_api_contract, + load_api_contract, +) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Update the rolling released public API contract from the local checkout." + ) + parser.add_argument("--version", required=True, help="Release version without a leading v.") + parser.add_argument( + "--check", + action="store_true", + help="Fail instead of writing when the committed contract is out of date.", + ) + return parser.parse_args() + + +def _project_version() -> str: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + if not isinstance(version, str): + raise RuntimeError("pyproject.toml is missing project.version") + return version + + +def _head_commit() -> str: + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + text=True, + ).strip() + + +def _render(contract: dict[str, object]) -> str: + return json.dumps(contract, indent=2, sort_keys=True) + "\n" + + +def main() -> int: + args = _parse_args() + version = args.version + if ( + version.startswith("v") + or re.fullmatch(r"\d+\.\d+(?:\.\d+)*(?:[A-Za-z0-9.-]+)?", version) is None + ): + raise SystemExit("--version must be a semver-like value without a leading v") + + project_version = _project_version() + if project_version != version: + raise SystemExit( + f"--version {version!r} does not match pyproject.toml version {project_version!r}" + ) + + current = load_api_contract(CONTRACT) + try: + updated = build_released_api_contract( + current, + baseline=f"v{version}", + baseline_commit=_head_commit(), + ) + except ValueError as error: + raise SystemExit(str(error)) from None + rendered = _render(updated) + existing = CONTRACT.read_text(encoding="utf-8") + if rendered == existing: + print(f"Released API contract is current for v{version}.") + return 0 + if args.check: + print( + f"Released API contract is out of date for v{version}; " + f"run `make update-released-api-contract VERSION={version}`.", + file=sys.stderr, + ) + return 1 + + previous_exports = set(current["required_top_level_exports"]) + current_exports = set(updated["required_top_level_exports"]) + CONTRACT.write_text(rendered, encoding="utf-8") + print(f"Updated released API contract for v{version}.") + print(f"Added exports: {sorted(current_exports - previous_exports)!r}") + print(f"Removed exports: {sorted(previous_exports - current_exports)!r}") + print( + "Review shipped example imports and update canonical_imports or public_modules " + "when the release adds an intended submodule path." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cd6c79126..f250d3d413 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -147,6 +147,38 @@ jobs: if: steps.changes.outputs.run != 'true' run: echo "Skipping MCP v1 compatibility tests for non-code changes." + packaged-contract: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.14" + env: + OPENAI_AGENTS_INTEGRATION_PYTHON: ${{ matrix.python-version }} + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Detect code changes + id: changes + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: true + prune-cache: true + python-version: ${{ matrix.python-version }} + - name: Run packaged compatibility contracts + if: steps.changes.outputs.run == 'true' + run: make integration-tests-packaging + - name: Skip packaged compatibility contracts + if: steps.changes.outputs.run != 'true' + run: echo "Skipping packaged compatibility contracts for non-code changes." + tests-windows: runs-on: windows-latest timeout-minutes: 10 diff --git a/Makefile b/Makefile index 769556baaa..e14bc5a973 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,16 @@ sync: update-rclone-pin: uv run python .github/scripts/update_rclone_pin.py --cooldown-days $(or $(RCLONE_COOLDOWN_DAYS),7) $(if $(RCLONE_VERSION),--version $(RCLONE_VERSION)) +.PHONY: update-released-api-contract +update-released-api-contract: + @test -n "$(VERSION)" || (echo "VERSION is required, for example VERSION=0.20.0" >&2; exit 2) + uv run python .github/scripts/update_released_api_contract.py --version "$(VERSION)" + +.PHONY: check-released-api-contract +check-released-api-contract: + @test -n "$(VERSION)" || (echo "VERSION is required, for example VERSION=0.20.0" >&2; exit 2) + uv run python .github/scripts/update_released_api_contract.py --version "$(VERSION)" --check + .PHONY: format format: uv run ruff format @@ -93,6 +103,10 @@ integration-tests-manual: integration-tests-packaging: uv run python .github/scripts/run_integration_tests.py --profile packaging +.PHONY: integration-tests-security +integration-tests-security: + uv run python .github/scripts/run_integration_tests.py --profile security + .PHONY: integration-tests-mcp-v1 integration-tests-mcp-v1: uv run python .github/scripts/run_integration_tests.py --profile mcp-v1 diff --git a/integration_tests/README.md b/integration_tests/README.md index 150307c4ca..c72aa3035d 100644 --- a/integration_tests/README.md +++ b/integration_tests/README.md @@ -7,7 +7,9 @@ Run the complete release-oriented matrix with: export UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests -`make integration-tests-release` runs the same release-safe matrix explicitly. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. +`make integration-tests-release` runs the release-safe live matrix and the local Docker security contract in strict mode, so an unavailable daemon, image, credential, or required capability fails the release gate instead of becoming a skip. The focused `make integration-tests-security` target runs the same wheel and sdist security contract in strict mode without the live provider matrix; the security profile remains separate from the credential-free PR packaging job. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-security`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The packaging profile validates the released public API manifest and historical `RunState` corpus from base wheel and sdist environments, then validates the public API again from wheel and sdist environments with the Cloudflare extra installed so dependency-conditional exports are required. The security profile installs the Docker extra for both distribution formats, checks packaged credential redaction, and runs model-controlled environment, filesystem, and process inspection inside a local Docker sandbox through the public `Runner` lifecycle. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. + +Release PR preparation updates the rolling API manifest locally rather than in a credentialed GitHub workflow. After the release branch version bump, run `make update-released-api-contract VERSION=`, review and commit the manifest diff, then run `make check-released-api-contract VERSION=` after subsequent rebases. Promotion fails before writing if the candidate breaks the committed released contract. Inspectable top-level classes and functions are promoted automatically; documented properties, intended submodule paths, and canonical aliases remain explicit review decisions recorded in the manifest. The packaged profile remains the artifact-level verification that the committed contract holds for both wheel and sdist. Invoke the repository-local `$integration-tests` skill to run the release profile with configured OpenRouter-backed provider checks. OpenRouter provides a single configured gateway for the standard multi-provider matrix; provider-specific direct connections are optional extensions selected explicitly. When a release review also requires runnable examples, run `$examples-auto-run` first and then `$integration-tests`. @@ -19,10 +21,14 @@ The default general model is `gpt-5.6`, while LiteLLM function-tool cases use th When the host requires a SOCKS proxy, the runner installs `httpx[socks]` as a test-harness dependency without changing the SDK's published requirements. Set `OPENAI_AGENTS_INTEGRATION_DISABLE_PROXY=1` when the selected environment should connect without inherited proxy settings. -Set `OPENAI_AGENTS_INTEGRATION_STRICT=1` to fail rather than skip when a requested live feature is not configured. Integration tests never run as part of ordinary `make tests`. +Set `OPENAI_AGENTS_INTEGRATION_STRICT=1` to fail rather than skip when a requested live feature is not configured. The release and security profiles enable strict mode unconditionally. Integration tests never run as part of ordinary `make tests`. + +The security profile requires a reachable local Docker daemon and pulls `busybox:1.36.1` by default. Set `OPENAI_AGENTS_INTEGRATION_SECURITY_IMAGE` to use a pre-approved replacement image. An unavailable daemon or image is a failure for both focused security and release-candidate runs. Each live test has a 75-second timeout so a stalled provider connection cannot block a release review indefinitely. +Each isolated environment writes a JUnit report to `.tmp/integration-tests/results//`. The runner also prints pass, failure, error, skip, and deselection counts for every profile/environment pair. Pytest output and raw provider payloads are not attached to passing JUnit cases. + Set `OPENAI_AGENTS_INTEGRATION_PYTHON` to choose the Python interpreter used for isolated environments. For example, `OPENAI_AGENTS_INTEGRATION_PYTHON=3.10 make integration-tests-packaging` verifies the minimum supported Python package and import boundary; use Python 3.11 or newer for the full adapter matrix because the AnyLLM extra requires Python 3.11. The release suite also covers canonical and supported legacy public-import identity, client-side handoffs, nested agents as tools, custom and shell tools, namespaced tool search, approval/rejection plus serialized `RunState` resume, durable SQLite sessions, explicit and server-managed conversation continuation, controlled retries, input/output and tool guardrails, explicit prompt caching, structured streaming output, provider token logprobs, hosted web search/MCP approval, hosted multi-agent streaming, programmatic-tool streaming/handoffs, multi-turn Realtime history, usage, handoffs, agent updates, voice failure propagation, and independent installation of each selected optional dependency group. The nightly profile adds extended approval matrices, parallel tool concurrency, stateless reasoning replay, reusable Responses WebSocket sessions, collected trace trees, streamed provider tool calls, Realtime audio/guardrails, and streamed-input voice pipelines. diff --git a/integration_tests/__init__.py b/integration_tests/__init__.py new file mode 100644 index 0000000000..bb635daa35 --- /dev/null +++ b/integration_tests/__init__.py @@ -0,0 +1 @@ +"""Packaged integration test support.""" diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py new file mode 100644 index 0000000000..0efe59828a --- /dev/null +++ b/integration_tests/_contract_support.py @@ -0,0 +1,1167 @@ +from __future__ import annotations + +import dataclasses +import enum +import importlib +import inspect +import json +import logging +import sys +import traceback +from collections.abc import Callable, Iterable, Mapping +from copy import deepcopy +from importlib.util import find_spec +from pathlib import Path +from types import FunctionType, TracebackType +from typing import Any, cast + + +def load_api_contract(path: Path) -> dict[str, Any]: + contract = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) + _add_legacy_literal_types(contract) + return contract + + +def _add_legacy_literal_types(value: object) -> None: + if isinstance(value, dict): + if value.get("kind") == "literal" and "value" in value and "type" not in value: + literal = value["value"] + value["type"] = f"{type(literal).__module__}.{type(literal).__qualname__}" + for child in value.values(): + _add_legacy_literal_types(child) + elif isinstance(value, list): + for child in value: + _add_legacy_literal_types(child) + + +def _redaction_observables( + error: BaseException | None, + records: Iterable[logging.LogRecord], +) -> str: + values: list[str] = [] + seen: set[int] = set() + + def visit_exception_state(value: object) -> None: + value_id = id(value) + if value_id in seen: + return + seen.add(value_id) + + if isinstance(value, BaseException): + state = vars(value) + values.append(repr(state)) + visit_exception_state(value.args) + visit_exception_state(value.__cause__) + visit_exception_state(value.__context__) + visit_exception_state(value.__traceback__) + visit_exception_state(state) + elif isinstance(value, TracebackType): + module_name = value.tb_frame.f_globals.get("__name__", "") + if module_name == "agents" or module_name.startswith("agents."): + visit_exception_state(value.tb_frame.f_locals) + visit_exception_state(value.tb_next) + elif isinstance(value, Mapping): + for key, item in value.items(): + visit_exception_state(key) + visit_exception_state(item) + elif ( + dataclasses.is_dataclass(value) + and not isinstance(value, type) + and (type(value).__module__ == "agents" or type(value).__module__.startswith("agents.")) + ): + for field in dataclasses.fields(value): + visit_exception_state(getattr(value, field.name)) + elif isinstance(value, list | tuple | set | frozenset): + for item in value: + visit_exception_state(item) + elif isinstance(value, str | bytes | int | float | bool | None): + values.append(repr(value)) + + if error is not None: + values.extend( + ( + str(error), + repr(error), + repr(error.__cause__), + repr(error.__context__), + "".join(traceback.format_exception(error)), + ) + ) + visit_exception_state(error) + for record in records: + values.extend((record.getMessage(), repr(record.args), repr(record.__dict__))) + visit_exception_state(record.__dict__) + if record.exc_info is not None: + values.append("".join(traceback.format_exception(*record.exc_info))) + visit_exception_state(record.exc_info) + return "\n".join(values) + + +def _deserialize_common_sandbox_session_state(payload: dict[str, object]) -> Any: + from agents.sandbox.session import SandboxSessionState + + persisted_payload = deepcopy(payload) + state = SandboxSessionState.model_validate(persisted_payload) + return SandboxSessionState._mark_persisted_path_grants(state, payload=persisted_payload) + + +def _default_contract(value: object) -> dict[str, object]: + if value is inspect.Parameter.empty or value is dataclasses.MISSING: + return {"kind": "required"} + if value.__class__.__name__ == "_HAS_DEFAULT_FACTORY_CLASS": + return {"kind": "factory"} + if value is None or isinstance(value, bool | int | float | str): + return { + "kind": "literal", + "type": f"{type(value).__module__}.{type(value).__qualname__}", + "value": value, + } + from agents.mcp.server import _UNSET as mcp_failure_error_unset + from agents.retry import _UNSET as retry_unset + from agents.tool import _UNSET_FAILURE_ERROR_FUNCTION as failure_error_function_unset + from agents.tool_context import _MISSING as tool_context_missing + + sentinel_identities = ( + (retry_unset, "agents.retry._UNSET"), + (mcp_failure_error_unset, "agents.mcp.server._UNSET"), + (failure_error_function_unset, "agents.tool._UNSET_FAILURE_ERROR_FUNCTION"), + (tool_context_missing, "agents.tool_context._MISSING"), + ) + for sentinel, identity in sentinel_identities: + if value is sentinel: + return {"kind": "sentinel", "identity": identity} + value_type = f"{type(value).__module__}.{type(value).__qualname__}" + if value_type == "pydantic.fields.FieldInfo": + return {"kind": "repr", "type": value_type, "value": repr(value)} + if isinstance(value, enum.Enum): + return { + "kind": "enum", + "type": value_type, + "name": value.name, + "value": _default_contract(value.value), + } + if isinstance(value, tuple | list): + return { + "kind": "sequence", + "type": value_type, + "items": [_default_contract(item) for item in value], + } + if isinstance(value, dict): + return { + "kind": "mapping", + "type": value_type, + "items": [ + [_default_contract(key), _default_contract(item)] for key, item in value.items() + ], + } + if value_type.startswith("agents.") and callable(getattr(value, "model_dump", None)): + dumped = value.model_dump(mode="python") # type: ignore[attr-defined] + return { + "kind": "model", + "type": value_type, + "value": _default_contract(dumped), + } + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return { + "kind": "dataclass", + "type": value_type, + "fields": [ + {"name": field.name, "value": _default_contract(getattr(value, field.name))} + for field in dataclasses.fields(value) + ], + } + if type(value) is FunctionType and value.__module__.startswith("agents."): + return { + "kind": "callable", + "identity": f"{value.__module__}.{value.__qualname__}", + } + raise TypeError(f"Unsupported public API default value: {value_type}") + + +def _parameter_records( + parameters: Iterable[inspect.Parameter], +) -> list[dict[str, object]]: + return [ + { + "name": parameter.name, + "kind": parameter.kind.name, + "default": _default_contract(parameter.default), + } + for parameter in parameters + ] + + +def _signature(value: Callable[..., Any]) -> inspect.Signature: + return inspect.signature(value) + + +def _parameter_contract(value: Callable[..., Any]) -> list[dict[str, object]]: + parameters = list(_signature(value).parameters.values()) + if issubclass(type(value), type) and issubclass(cast(type, value), enum.Enum): + parameters = list(_signature(value.__new__).parameters.values())[1:] + return _parameter_records(parameters) + + +def _dataclass_field_contract(value: object) -> list[dict[str, object]]: + if not dataclasses.is_dataclass(value): + return [] + result: list[dict[str, object]] = [] + for field in dataclasses.fields(value): + if field.name.startswith("_"): + continue + if field.default_factory is not dataclasses.MISSING: + factory = cast(Callable[..., Any], field.default_factory) + default_contract: dict[str, object] = { + "kind": "factory", + "factory": f"{factory.__module__}.{factory.__qualname__}", + } + else: + default_contract = _default_contract(field.default) + result.append( + { + "name": field.name, + "init": field.init, + "default": default_contract, + } + ) + return result + + +def _callable_kind(value: Callable[..., Any]) -> str | None: + if issubclass(type(value), type): + return "class" + if type(value) is FunctionType: + return "function" + return None + + +def _enum_member_contract(value: object) -> list[dict[str, object]] | None: + if not (issubclass(type(value), type) and issubclass(cast(type, value), enum.Enum)): + return None + enum_type = cast(type[enum.Enum], value) + members: list[dict[str, object]] = [] + for name, member in enum_type.__members__.items(): + member_value = member.value + if member_value is None or isinstance(member_value, bool | int | float | str): + value_contract: dict[str, object] = { + "kind": "literal", + "type": f"{type(member_value).__module__}.{type(member_value).__qualname__}", + "value": member_value, + } + else: + raise TypeError( + f"Unsupported public enum value for " + f"{enum_type.__module__}.{enum_type.__qualname__}." + f"{name}: {type(member_value).__module__}.{type(member_value).__qualname__}" + ) + members.append({"name": name, "value": value_contract}) + return members + + +def _class_member_contract(descriptor: object) -> dict[str, object] | None: + descriptor_type = type(descriptor) + if descriptor_type is staticmethod: + binding = "static" + function = object.__getattribute__(descriptor, "__func__") + skip_first = False + elif descriptor_type is classmethod: + binding = "class" + function = object.__getattribute__(descriptor, "__func__") + skip_first = True + elif type(descriptor) is FunctionType: + binding = "instance" + function = descriptor + skip_first = True + else: + return None + if type(function) is not FunctionType: + return None + try: + parameters = list(_signature(function).parameters.values()) + except (TypeError, ValueError): + return None + if skip_first: + if not parameters: + return None + parameters = parameters[1:] + return { + "binding": binding, + "execution_kind": _function_execution_kind(function), + "parameters": _parameter_records(parameters), + } + + +def _function_execution_kind(value: object) -> str: + if inspect.isasyncgenfunction(value): + return "async_generator" + if inspect.iscoroutinefunction(value): + return "coroutine" + if inspect.isgeneratorfunction(value): + return "generator" + return "sync" + + +def _sdk_public_class_descriptor(value: type, name: str) -> object | None: + for owner in value.__mro__: + namespace = vars(owner) + if name not in namespace: + continue + owner_module = owner.__module__ + if owner is value or ( + isinstance(owner_module, str) + and (owner_module == "agents" or owner_module.startswith("agents.")) + ): + return cast(object, inspect.getattr_static(value, name)) + return None + return None + + +def _public_class_member_contract(value: object) -> dict[str, dict[str, object]]: + if not issubclass(type(value), type): + return {} + class_value = cast(type, value) + value_identity = f"{class_value.__module__}.{class_value.__qualname__}" + candidate_names: list[str] = [] + seen_names: set[str] = set() + + def add_candidate_names(namespace: Mapping[str, object]) -> None: + for name in namespace: + if name in seen_names: + continue + seen_names.add(name) + candidate_names.append(name) + + add_candidate_names(vars(class_value)) + for base in class_value.__mro__[1:]: + base_module = base.__module__ + if isinstance(base_module, str) and ( + base_module == "agents" or base_module.startswith("agents.") + ): + add_candidate_names(vars(base)) + members: dict[str, dict[str, object]] = {} + for name in candidate_names: + if name.startswith("_"): + continue + descriptor = _sdk_public_class_descriptor(class_value, name) + if descriptor is None: + continue + try: + member = _class_member_contract(descriptor) + except TypeError as error: + raise TypeError( + f"Unable to contract public method {value_identity}.{name}: {error}" + ) from None + if member is not None: + members[name] = member + return members + + +def _callable_contract(value: Callable[..., Any]) -> dict[str, Any]: + kind = _callable_kind(value) + if kind is None: + raise TypeError(f"Unsupported public callable type: {type(value)!r}") + contract: dict[str, Any] = { + "kind": kind, + "parameters": _parameter_contract(value), + "dataclass_fields": _dataclass_field_contract(value), + } + if kind == "function": + contract["execution_kind"] = _function_execution_kind(value) + enum_members = _enum_member_contract(value) + if enum_members is not None: + contract["enum_members"] = enum_members + if kind == "class": + contract["members"] = _public_class_member_contract(value) + return contract + + +def build_released_api_contract( + contract: dict[str, Any], + *, + baseline: str, + baseline_commit: str, + agents_module: Any | None = None, +) -> dict[str, Any]: + """Build the next rolling release contract from the current public surface.""" + agents = agents_module or importlib.import_module("agents") + compatibility_errors = validate_released_api_contract(contract, agents_module=agents) + if compatibility_errors: + details = "\n".join(f"- {error}" for error in compatibility_errors) + raise ValueError(f"Cannot promote an incompatible released API contract:\n{details}") + + current_exports = list(agents.__all__) + if not all(type(name) is str for name in current_exports): + raise ValueError("agents.__all__ must contain only strings") + if len(current_exports) != len(set(current_exports)): + raise ValueError("agents.__all__ must not contain duplicate exports") + + missing_bindings = [name for name in current_exports if not hasattr(agents, name)] + if missing_bindings: + raise ValueError(f"agents.__all__ contains missing bindings: {missing_bindings!r}") + + released_export_order = list(contract["required_top_level_exports"]) + released_exports = set(released_export_order) + current_export_names = set(current_exports) + ordered_exports = [name for name in released_export_order if name in current_export_names] + ordered_exports.extend(name for name in current_exports if name not in released_exports) + tracked_callables = set(contract["callables"]) + callables: dict[str, Any] = {} + for name in ordered_exports: + value = getattr(agents, name) + kind = _callable_kind(value) + should_track = name in tracked_callables + if not should_track and kind is not None: + try: + _signature(value) + except (TypeError, ValueError): + continue + should_track = True + if should_track: + callables[name] = _callable_contract(value) + + top_level_callable_ids = { + id(getattr(agents, name)) for name in callables if not name.startswith("agents.") + } + for entry in contract["canonical_imports"]: + module_name = entry["module"] + if module_name == "agents": + continue + qualified_name = f"{module_name}.{entry['name']}" + module = _import_contract_module(module_name, agents_module) + value = getattr(module, entry["name"]) + if id(value) in top_level_callable_ids: + continue + kind = _callable_kind(value) + if kind is None: + continue + try: + _signature(value) + except (TypeError, ValueError): + continue + callables[qualified_name] = _callable_contract(value) + + updated = deepcopy(contract) + updated["baseline"] = baseline + updated["required_top_level_exports"] = ordered_exports + updated["callables"] = callables + excluded_submodule_exports = set(contract.get("submodule_export_exclusions", [])) + required_submodule_exports: dict[str, dict[str, Any]] = {} + for module_name in contract["public_modules"]: + if module_name == "agents" or module_name in excluded_submodule_exports: + continue + try: + module = _import_contract_module(module_name, agents_module) + except Exception as error: + if _matches_platform_import_error(contract, module_name, error): + continue + raise + previous_module_contract = contract.get("required_submodule_exports", {}).get( + module_name, {} + ) + module_contract = _submodule_export_contract( + module, + optional_bindings=previous_module_contract.get("optional_bindings", {}), + optional_exports=previous_module_contract.get("optional_exports", {}), + ) + if module_contract is not None: + required_submodule_exports[module_name] = module_contract + updated["required_submodule_exports"] = required_submodule_exports + + updated_errors = validate_released_api_contract(updated, agents_module=agents) + if updated_errors: + details = "\n".join(f"- {error}" for error in updated_errors) + raise ValueError(f"Cannot promote an invalid released API contract:\n{details}") + + surface_keys = ( + "canonical_imports", + "callables", + "platform_import_errors", + "public_properties", + "public_modules", + "required_submodule_exports", + "required_top_level_exports", + "submodule_export_exclusions", + ) + surface_changed = any(updated.get(key) != contract.get(key) for key in surface_keys) + if baseline != contract["baseline"] or surface_changed: + updated["baseline_commit"] = baseline_commit + return updated + + +def _validate_parameter_contract( + name: str, + released: list[dict[str, object]], + current: list[dict[str, object]], +) -> list[str]: + errors: list[str] = [] + positional_kinds = {"POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"} + released_positional = [entry for entry in released if entry["kind"] in positional_kinds] + current_positional = [entry for entry in current if entry["kind"] in positional_kinds] + if current_positional[: len(released_positional)] != released_positional: + errors.append( + f"{name} changed its released positional parameter prefix: " + f"expected {released_positional!r}, got {current_positional!r}" + ) + elif any(entry["kind"] == "VAR_POSITIONAL" for entry in released) and len( + current_positional + ) != len(released_positional): + added = current_positional[len(released_positional) :] + errors.append( + f"{name} added positional parameters before its released variadic parameter: {added!r}" + ) + + current_by_name = {entry["name"]: entry for entry in current} + for entry in released: + if entry["kind"] in positional_kinds: + continue + current_entry = current_by_name.get(entry["name"]) + if current_entry != entry: + errors.append( + f"{name}.{entry['name']} changed its released parameter contract: " + f"expected {entry!r}, got {current_entry!r}" + ) + released_names = {entry["name"] for entry in released} + for entry in current: + if entry["name"] in released_names: + continue + if entry["kind"] in {"VAR_POSITIONAL", "VAR_KEYWORD"}: + continue + default = entry["default"] + if isinstance(default, dict) and default.get("kind") == "required": + errors.append(f"{name}.{entry['name']} added a required parameter") + return errors + + +def _import_contract_module(module_name: str, agents_module: Any | None) -> Any: + if module_name == "agents" and agents_module is not None: + return agents_module + return importlib.import_module(module_name) + + +def _validate_public_property_contract( + contract: dict[str, Any], + agents_module: Any | None, +) -> list[str]: + errors: list[str] = [] + for entry in contract.get("public_properties", []): + module_name = entry["module"] + class_name = entry["class_name"] + try: + module = _import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + class_value = getattr(module, class_name, None) + if not isinstance(class_value, type): + errors.append(f"Missing released public class {module_name}.{class_name}") + continue + for property_name in entry["names"]: + descriptor = inspect.getattr_static(class_value, property_name, None) + if not isinstance(descriptor, property): + errors.append( + f"{module_name}.{class_name}.{property_name} " + "removed or changed a released public property" + ) + return errors + + +def _submodule_export_contract( + module: object, + *, + optional_bindings: Mapping[str, str] | None = None, + optional_exports: Mapping[str, str] | None = None, +) -> dict[str, Any] | None: + exports = getattr(module, "__all__", None) + if exports is None: + return None + if not isinstance(exports, list | tuple) or not all(type(name) is str for name in exports): + raise ValueError("public module __all__ must contain only strings") + names = list(exports) + if len(names) != len(set(names)): + raise ValueError("public module __all__ must not contain duplicate exports") + optional_binding_modules = _optional_dependency_modules( + dict(optional_bindings or {}), field_name="optional_bindings" + ) + optional_export_modules = _optional_dependency_modules( + dict(optional_exports or {}), field_name="optional_exports" + ) + optional_binding_names = set(optional_binding_modules) + optional_export_names = set(optional_export_modules) + unknown_optional_names = sorted((optional_binding_names | optional_export_names) - set(names)) + if unknown_optional_names: + raise ValueError( + f"optional submodule bindings are not exported: {unknown_optional_names!r}" + ) + return { + "names": names, + "optional_bindings": { + name: optional_binding_modules[name] for name in names if name in optional_binding_names + }, + "optional_exports": { + name: optional_export_modules[name] for name in names if name in optional_export_names + }, + } + + +def _optional_dependency_modules(value: object, *, field_name: str) -> dict[str, str]: + if not isinstance(value, dict): + raise ValueError( + f"{field_name} must be an object mapping export names to dependency modules" + ) + modules: dict[str, str] = {} + for name, module_name in value.items(): + if type(name) is not str or not name: + raise ValueError(f"{field_name} export names must be non-empty strings") + if type(module_name) is not str or not module_name.strip(): + raise ValueError(f"{field_name} dependency for {name!r} must be a non-empty string") + modules[name] = module_name + return modules + + +def _optional_dependency_is_available(module_name: str) -> bool: + if module_name in sys.modules: + return sys.modules[module_name] is not None + return find_spec(module_name) is not None + + +def _matches_platform_import_error( + contract: dict[str, Any], module_name: str, error: Exception +) -> bool: + allowed_error_types = {"ImportError": ImportError} + for entry in contract.get("platform_import_errors", []): + if entry["module"] != module_name or sys.platform not in entry["platforms"]: + continue + expected_error_type = allowed_error_types.get(entry["error_type"]) + return ( + expected_error_type is not None + and type(error) is expected_error_type + and entry["message_contains"] in str(error) + ) + return False + + +def validate_released_api_contract( + contract: dict[str, Any], + *, + agents_module: Any | None = None, + require_all_optional_exports: bool = False, +) -> list[str]: + agents = agents_module or importlib.import_module("agents") + errors: list[str] = [] + + errors.extend(_validate_public_property_contract(contract, agents_module)) + + missing_exports = sorted(set(contract["required_top_level_exports"]) - set(agents.__all__)) + if missing_exports: + errors.append(f"Missing released top-level exports: {missing_exports!r}") + missing_bindings = sorted( + name for name in contract["required_top_level_exports"] if not hasattr(agents, name) + ) + if missing_bindings: + errors.append(f"Missing released top-level bindings: {missing_bindings!r}") + + imported_modules: dict[str, object] = {"agents": agents} + for module_name in contract["public_modules"]: + try: + imported_modules[module_name] = _import_contract_module(module_name, agents_module) + except Exception as error: + if _matches_platform_import_error(contract, module_name, error): + continue + errors.append(f"Failed to import released module {module_name}: {error!r}") + + for module_name, released in contract.get("required_submodule_exports", {}).items(): + module = imported_modules.get(module_name) + if module is None: + continue + try: + current = _submodule_export_contract(module) + except ValueError as error: + errors.append(f"Invalid released module exports for {module_name}: {error}") + continue + if current is None: + errors.append(f"Released module {module_name} no longer defines __all__") + continue + try: + optional_exports = _optional_dependency_modules( + released.get("optional_exports", {}), field_name="optional_exports" + ) + optional_bindings = _optional_dependency_modules( + released.get("optional_bindings", {}), field_name="optional_bindings" + ) + except ValueError as error: + errors.append( + f"Invalid released {module_name} optional dependency declarations: {error}" + ) + continue + unknown_optional_names = sorted( + (set(optional_bindings) | set(optional_exports)) - set(released["names"]) + ) + if unknown_optional_names: + errors.append( + f"Invalid released {module_name} optional dependency declarations: " + f"names are not exported: {unknown_optional_names!r}" + ) + continue + try: + unavailable_optional_exports = { + name + for name, dependency_module in optional_exports.items() + if not _optional_dependency_is_available(dependency_module) + } + unavailable_optional_bindings = { + name + for name, dependency_module in (optional_bindings | optional_exports).items() + if not _optional_dependency_is_available(dependency_module) + } + except (AttributeError, ImportError, ValueError) as error: + errors.append( + f"Unable to inspect released {module_name} optional dependencies: {error!r}" + ) + continue + if require_all_optional_exports and unavailable_optional_exports: + unavailable = sorted( + f"{name} -> {optional_exports[name]}" for name in unavailable_optional_exports + ) + errors.append( + f"Required optional dependencies for released {module_name} " + f"are unavailable: {unavailable!r}" + ) + continue + missing_names = sorted( + set(released["names"]) - unavailable_optional_exports - set(current["names"]) + ) + if missing_names: + errors.append(f"Missing released {module_name} exports: {missing_names!r}") + missing_required_bindings = [] + for name in released["names"]: + if name in unavailable_optional_bindings: + continue + try: + getattr(module, name) + except (AttributeError, ImportError): + missing_required_bindings.append(name) + if missing_required_bindings: + errors.append( + f"Missing released {module_name} bindings: {sorted(missing_required_bindings)!r}" + ) + + for entry in contract["canonical_imports"]: + try: + module = _import_contract_module(entry["module"], agents_module) + except Exception as error: + if _matches_platform_import_error(contract, entry["module"], error): + continue + errors.append(f"Failed to import released module {entry['module']}: {error!r}") + continue + try: + canonical = _import_contract_module(entry["canonical_module"], agents_module) + except Exception as error: + if _matches_platform_import_error(contract, entry["canonical_module"], error): + continue + errors.append( + f"Failed to import released module {entry['canonical_module']}: {error!r}" + ) + continue + missing = object() + actual = getattr(module, entry["name"], missing) + expected = getattr(canonical, entry["canonical_name"], missing) + if actual is missing or expected is missing or actual is not expected: + errors.append( + f"{entry['module']}.{entry['name']} no longer resolves to " + f"{entry['canonical_module']}.{entry['canonical_name']}" + ) + + for name, released in contract["callables"].items(): + if name.startswith("agents."): + module_name, _, binding_name = name.rpartition(".") + try: + module = _import_contract_module(module_name, agents_module) + except Exception as error: + if _matches_platform_import_error(contract, module_name, error): + continue + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + value = getattr(module, binding_name, None) + if value is None: + canonical_entry = next( + ( + entry + for entry in contract["canonical_imports"] + if entry["module"] == module_name and entry["name"] == binding_name + ), + None, + ) + if canonical_entry is not None: + try: + _import_contract_module(canonical_entry["canonical_module"], agents_module) + except Exception as error: + if _matches_platform_import_error( + contract, canonical_entry["canonical_module"], error + ): + continue + else: + module_name = "agents" + binding_name = name + value = getattr(agents, binding_name, None) + if value is None: + errors.append(f"Missing released callable {module_name}.{binding_name}") + continue + current_kind = _callable_kind(value) + if current_kind != released["kind"]: + errors.append( + f"Released callable {module_name}.{binding_name} changed kind from " + f"{released['kind']} to {current_kind or type(value).__name__}" + ) + continue + released_execution_kind = released.get("execution_kind") + if released_execution_kind is not None: + current_execution_kind = _function_execution_kind(value) + if current_execution_kind != released_execution_kind: + errors.append( + f"{name} changed execution from " + f"{released_execution_kind} to {current_execution_kind}" + ) + current_parameters = _parameter_contract(value) + errors.extend( + _validate_parameter_contract(name, released["parameters"], current_parameters) + ) + current_fields = _dataclass_field_contract(value) + released_fields = released["dataclass_fields"] + if current_fields[: len(released_fields)] != released_fields: + errors.append( + f"{name} changed its released dataclass field prefix: " + f"expected {released_fields!r}, got {current_fields!r}" + ) + for field in current_fields[len(released_fields) :]: + default = field["default"] + if field["init"] and isinstance(default, dict) and default.get("kind") == "required": + errors.append(f"{name}.{field['name']} added a required dataclass field") + for member_name, released_member in released.get("members", {}).items(): + descriptor = _sdk_public_class_descriptor(value, member_name) + current_member = _class_member_contract(descriptor) + if current_member is None: + errors.append(f"{name}.{member_name} removed a released public method") + continue + if current_member["binding"] != released_member["binding"]: + errors.append( + f"{name}.{member_name} changed binding from " + f"{released_member['binding']} to {current_member['binding']}" + ) + continue + released_execution_kind = released_member.get("execution_kind") + if ( + released_execution_kind is not None + and current_member["execution_kind"] != released_execution_kind + ): + errors.append( + f"{name}.{member_name} changed execution from " + f"{released_execution_kind} to {current_member['execution_kind']}" + ) + errors.extend( + _validate_parameter_contract( + f"{name}.{member_name}", + released_member["parameters"], + cast(list[dict[str, object]], current_member["parameters"]), + ) + ) + released_enum_members = released.get("enum_members") + if released_enum_members is not None: + current_enum_members = _enum_member_contract(value) + if current_enum_members is None: + errors.append(f"{name} is no longer an enum") + continue + current_enum_members_by_name = { + member["name"]: member["value"] for member in current_enum_members + } + for member in released_enum_members: + member_name = member["name"] + if member_name not in current_enum_members_by_name: + errors.append(f"{name}.{member_name} removed or renamed a released enum member") + continue + current_value = current_enum_members_by_name[member_name] + if current_value != member["value"]: + errors.append( + f"{name}.{member_name} changed its released enum value: " + f"expected {member['value']!r}, got {current_value!r}" + ) + + return errors + + +def _normalized_durable_state(payload: dict[str, Any]) -> dict[str, Any]: + normalized = deepcopy(payload) + normalized.pop("$schemaVersion", None) + return normalized + + +def _normalize_legacy_mount_credentials(payload: dict[str, Any]) -> dict[str, Any]: + from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY + + normalized = deepcopy(payload) + sandbox = cast(dict[str, Any], normalized["sandbox"]) + session_states = [cast(dict[str, Any], sandbox["session_state"])] + sessions_by_agent = cast(dict[str, dict[str, Any]], sandbox["sessions_by_agent"]) + session_states.extend( + cast(dict[str, Any], entry["session_state"]) for entry in sessions_by_agent.values() + ) + for session_state in session_states: + manifest = cast(dict[str, Any], session_state["manifest"]) + entries = cast(dict[str, dict[str, Any]], manifest["entries"]) + mount = entries["remote"] + mount["access_key_id"] = None + mount["secret_access_key"] = None + mount["session_token"] = None + strategy = cast(dict[str, Any], mount["mount_strategy"]) + strategy["driver_options"] = {} + session_state[REDACTED_MOUNT_AUTHORITY_KEY] = True + return normalized + + +def _legacy_driver_option_errors(payload: dict[str, Any]) -> list[str]: + sandbox = cast(dict[str, Any], payload["sandbox"]) + session_states = [("sandbox.session_state", cast(dict[str, Any], sandbox["session_state"]))] + sessions_by_agent = cast(dict[str, dict[str, Any]], sandbox["sessions_by_agent"]) + session_states.extend( + ( + f"sandbox.sessions_by_agent.{agent_id}.session_state", + cast(dict[str, Any], entry["session_state"]), + ) + for agent_id, entry in sessions_by_agent.items() + ) + errors: list[str] = [] + for path, session_state in session_states: + manifest = cast(dict[str, Any], session_state["manifest"]) + entries = cast(dict[str, dict[str, Any]], manifest["entries"]) + mount = entries["remote"] + strategy = cast(dict[str, Any], mount["mount_strategy"]) + if strategy.get("driver_options") != {}: + errors.append(f"{path}.manifest.entries.remote.mount_strategy.driver_options remained") + return errors + + +def _find_subset_errors(expected: object, actual: object, path: str = "state") -> list[str]: + if isinstance(expected, dict): + if not isinstance(actual, dict): + return [f"{path} changed type from mapping to {type(actual).__name__}"] + errors: list[str] = [] + for key, value in expected.items(): + if key not in actual: + errors.append(f"{path}.{key} was dropped") + continue + errors.extend(_find_subset_errors(value, actual[key], f"{path}.{key}")) + return errors + if isinstance(expected, list): + if not isinstance(actual, list): + return [f"{path} changed type from list to {type(actual).__name__}"] + if len(expected) != len(actual): + return [f"{path} changed length from {len(expected)} to {len(actual)}"] + errors = [] + for index, (expected_item, actual_item) in enumerate(zip(expected, actual, strict=True)): + errors.extend(_find_subset_errors(expected_item, actual_item, f"{path}[{index}]")) + return errors + if type(expected) is not type(actual): + return [f"{path} changed type from {type(expected).__name__} to {type(actual).__name__}"] + if expected != actual: + return [f"{path} changed from {expected!r} to {actual!r}"] + return [] + + +def _restore_agent(payload: dict[str, Any]) -> Any: + from agents import Agent, handoff + + current_agent = payload.get("current_agent") + name = ( + current_agent.get("name", "compat-agent") + if isinstance(current_agent, dict) + else "compat-agent" + ) + identity = current_agent.get("identity") if isinstance(current_agent, dict) else None + if identity == f"{name}#2": + duplicate = Agent(name=name) + return Agent(name=name, handoffs=[handoff(duplicate)]) + return Agent(name=name) + + +async def validate_historical_run_state_fixture(path: Path) -> list[str]: + from agents import RunState + from agents.run_state import CURRENT_SCHEMA_VERSION + + errors: list[str] = [] + payload = json.loads(path.read_text(encoding="utf-8")) + historical = deepcopy(payload) + original_version = historical.get("$schemaVersion") + agent = _restore_agent(historical) + restored = await RunState.from_json(agent, payload) + canonical = restored.to_json() + + if canonical.get("$schemaVersion") != CURRENT_SCHEMA_VERSION: + errors.append( + f"{path.name} rewrote as {canonical.get('$schemaVersion')!r}, " + f"expected {CURRENT_SCHEMA_VERSION!r}" + ) + semantic_errors = _find_subset_errors( + _normalized_durable_state(historical), + _normalized_durable_state(canonical), + ) + errors.extend(f"{path.name}: {error}" for error in semantic_errors) + + expected_canonical = deepcopy(canonical) + rerestored = await RunState.from_json(agent, deepcopy(canonical)) + recanonical = rerestored.to_json() + if recanonical != expected_canonical: + errors.append( + f"{path.name} was not idempotent after rewriting schema {original_version!r} " + f"to {CURRENT_SCHEMA_VERSION!r}" + ) + return errors + + +async def validate_historical_resume_behavior( + path: Path, + *, + feature: str, + decision: str | None = None, +) -> list[str]: + from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputText, + ) + + from agents import Agent, Runner, RunState, function_tool + from agents.items import ToolCallOutputItem, TResponseOutputItem + from integration_tests._fake_model import QueuedFakeModel + + invocation_count = 0 + if feature == "canonical_invocation_identity": + + def lookup_account(account_id: str) -> str: + nonlocal invocation_count + invocation_count += 1 + return f"approved:{account_id}" + + tool = function_tool(lookup_account, needs_approval=True) + model_turns: list[list[TResponseOutputItem]] = [ + [ + ResponseFunctionToolCall( + type="function_call", + name="lookup_account", + call_id="function-request-1", + status="completed", + arguments='{"account_id":"account-1"}', + ) + ] + ] + expected_invocations = 1 + expected_tool_output = "approved:account-1" + elif feature == "pending_tool_approval": + + def historical_approval(account_id: str) -> str: + nonlocal invocation_count + invocation_count += 1 + return f"approved:{account_id}" + + tool = function_tool(historical_approval, needs_approval=True) + model_turns = [] + if decision == "approve": + expected_invocations = 1 + expected_tool_output = "approved:account-1" + elif decision == "reject": + expected_invocations = 0 + expected_tool_output = "Candidate rejected historical approval" + else: + raise ValueError("pending_tool_approval requires an approve or reject decision") + else: + raise ValueError(f"Unsupported historical resume feature: {feature}") + + final_message = ResponseOutputMessage( + id="historical-resume-final", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText( + type="output_text", + text="resume complete", + annotations=[], + logprobs=[], + ) + ], + ) + model_turns.append([final_message]) + model = QueuedFakeModel(model_turns) + agent = Agent(name="compat-agent", model=model, tools=[tool]) + payload = json.loads(path.read_text(encoding="utf-8")) + restored = await RunState.from_json(agent, payload) + if feature == "pending_tool_approval": + interruptions = restored.get_interruptions() + if len(interruptions) != 1: + return [f"{path.name} did not restore its historical pending approval"] + if decision == "approve": + restored.approve(interruptions[0]) + else: + restored.reject( + interruptions[0], + rejection_message="Candidate rejected historical approval", + ) + result = await Runner.run(agent, restored) + + errors: list[str] = [] + if result.interruptions: + errors.append(f"{path.name} interrupted instead of applying its historical decision") + if invocation_count != expected_invocations: + errors.append( + f"{path.name} invoked its approval-controlled tool {invocation_count} times, " + f"expected {expected_invocations}" + ) + tool_outputs = [ + item.output for item in result.new_items if isinstance(item, ToolCallOutputItem) + ] + if expected_tool_output not in tool_outputs: + errors.append( + f"{path.name} did not preserve the historical tool decision output " + f"{expected_tool_output!r}" + ) + if result.final_output != "resume complete": + errors.append(f"{path.name} did not complete its resumed run") + return errors + + +async def validate_legacy_credential_run_state_fixture( + path: Path, + *, + sentinels: Iterable[str], +) -> list[str]: + from agents import RunState + from agents.run_state import CURRENT_SCHEMA_VERSION + + errors: list[str] = [] + payload = json.loads(path.read_text(encoding="utf-8")) + historical = deepcopy(payload) + agent = _restore_agent(payload) + restored = await RunState.from_json(agent, payload) + canonical = restored.to_json() + + if canonical.get("$schemaVersion") != CURRENT_SCHEMA_VERSION: + errors.append( + f"{path.name} rewrote as {canonical.get('$schemaVersion')!r}, " + f"expected {CURRENT_SCHEMA_VERSION!r}" + ) + semantic_errors = _find_subset_errors( + _normalized_durable_state(_normalize_legacy_mount_credentials(historical)), + _normalized_durable_state(canonical), + ) + errors.extend(f"{path.name}: {error}" for error in semantic_errors) + if not semantic_errors: + errors.extend(f"{path.name}: {error}" for error in _legacy_driver_option_errors(canonical)) + + serialized_observables = json.dumps(canonical, sort_keys=True) + repr(restored._sandbox) + for sentinel in sentinels: + if sentinel in serialized_observables: + errors.append(f"{path.name} retained credential sentinel {sentinel!r}") + + expected_canonical = deepcopy(canonical) + rerestored = await RunState.from_json(agent, deepcopy(canonical)) + if rerestored.to_json() != expected_canonical: + errors.append(f"{path.name} was not idempotent after credential sanitization") + return errors diff --git a/integration_tests/_fake_model.py b/integration_tests/_fake_model.py new file mode 100644 index 0000000000..6b5a9099a3 --- /dev/null +++ b/integration_tests/_fake_model.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence +from copy import deepcopy +from typing import Any, cast + +from openai.types.responses.response_prompt_param import ResponsePromptParam + +from agents.agent_output import AgentOutputSchemaBase +from agents.handoffs import Handoff +from agents.items import ( + ModelResponse, + TResponseInputItem, + TResponseOutputItem, + TResponseStreamEvent, +) +from agents.model_settings import ModelSettings +from agents.models.interface import Model, ModelTracing +from agents.tool import Tool +from agents.usage import Usage + + +class QueuedFakeModel(Model): + """Deterministic non-streaming model for installed-distribution contracts.""" + + def __init__(self, turns: Sequence[Sequence[TResponseOutputItem]]) -> None: + self._turns = [list(turn) for turn in turns] + self.requests: list[dict[str, Any]] = [] + + def _record_request( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> None: + self.requests.append( + { + "system_instructions": system_instructions, + "input": deepcopy(input), + "model_settings": model_settings, + "tools": list(tools), + "output_schema": output_schema, + "handoffs": list(handoffs), + "tracing": tracing, + "previous_response_id": previous_response_id, + "conversation_id": conversation_id, + "prompt": deepcopy(prompt), + } + ) + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> ModelResponse: + self._record_request( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id, + conversation_id, + prompt, + ) + if not self._turns: + raise AssertionError("QueuedFakeModel received an unexpected model request") + return ModelResponse( + output=self._turns.pop(0), + usage=Usage(requests=1), + response_id="queued-fake-response", + ) + + async def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> AsyncIterator[TResponseStreamEvent]: + self._record_request( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id, + conversation_id, + prompt, + ) + if False: + yield cast(TResponseStreamEvent, None) + raise AssertionError("QueuedFakeModel does not support streaming") diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py index 9eeeb2d7d6..54b3955c19 100644 --- a/integration_tests/conftest.py +++ b/integration_tests/conftest.py @@ -98,6 +98,32 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: metafunc.parametrize("external_provider", [None], ids=["unconfigured"]) +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + requested_extra = os.environ.get("OPENAI_AGENTS_INTEGRATION_EXTRA") + if requested_extra is None: + return + + selected: list[pytest.Item] = [] + deselected: list[pytest.Item] = [] + for item in items: + if ( + getattr(item, "originalname", None) + != "test_memory_extra_lazy_exports_resolve_to_the_installed_backend" + ): + selected.append(item) + continue + callspec = getattr(item, "callspec", None) + optional_extra = getattr(callspec, "params", {}).get("optional_extra") + if optional_extra == requested_extra: + selected.append(item) + else: + deselected.append(item) + + if deselected: + config.hook.pytest_deselected(items=deselected) + items[:] = selected + + def _strict() -> bool: return os.environ.get("OPENAI_AGENTS_INTEGRATION_STRICT", "").lower() in { "1", diff --git a/integration_tests/openai/test_approval_resume.py b/integration_tests/openai/test_approval_resume.py index 850fb76d18..6a9ca0327a 100644 --- a/integration_tests/openai/test_approval_resume.py +++ b/integration_tests/openai/test_approval_resume.py @@ -20,9 +20,16 @@ @pytest.mark.parametrize("approved", [False, True], ids=["rejected", "approved"]) -@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"]) +@pytest.mark.parametrize( + ("initial_streaming", "resume_streaming"), + [(False, True), (True, False)], + ids=["nonstreaming-to-streaming", "streaming-to-nonstreaming"], +) async def test_tool_approval_survives_serialized_state_and_resume( - integration_model: str, approved: bool, streaming: bool + integration_model: str, + approved: bool, + initial_streaming: bool, + resume_streaming: bool, ) -> None: calls: list[str] = [] @@ -46,7 +53,7 @@ def perform_action(action: str) -> str: first: RunResult | RunResultStreaming resumed: RunResult | RunResultStreaming - if streaming: + if initial_streaming: first_stream = Runner.run_streamed(agent, "Perform the deployment.", run_config=config) async for _event in first_stream.stream_events(): pass @@ -66,7 +73,7 @@ def perform_action(action: str) -> str: else: restored.reject(restored_interruption, rejection_message="The operator rejected deploy.") - if streaming: + if resume_streaming: resumed_stream = Runner.run_streamed(agent, restored, run_config=config) async for _event in resumed_stream.stream_events(): pass diff --git a/integration_tests/openai/test_responses.py b/integration_tests/openai/test_responses.py index c67effea7b..505038c876 100644 --- a/integration_tests/openai/test_responses.py +++ b/integration_tests/openai/test_responses.py @@ -66,7 +66,8 @@ def double_number(value: int) -> int: assert result.context_wrapper.usage.total_tokens > 0 -async def test_responses_structured_output_is_deserialized_from_the_installed_wheel( +@pytest.mark.distribution_smoke +async def test_responses_structured_output_is_deserialized_from_the_installed_distribution( integration_model: str, ) -> None: agent = Agent( diff --git a/integration_tests/packaging/test_optional_extras.py b/integration_tests/packaging/test_optional_extras.py index 45e586a5d3..56d42354d5 100644 --- a/integration_tests/packaging/test_optional_extras.py +++ b/integration_tests/packaging/test_optional_extras.py @@ -52,8 +52,7 @@ def test_memory_extra_lazy_exports_resolve_to_the_installed_backend( module_name: str, module_symbol: str, ) -> None: - if os.environ["OPENAI_AGENTS_INTEGRATION_EXTRA"] != optional_extra: - pytest.skip(f"This environment does not include the {optional_extra} extra.") + assert os.environ["OPENAI_AGENTS_INTEGRATION_EXTRA"] == optional_extra memory = importlib.import_module("agents.extensions.memory") module = importlib.import_module(module_name) diff --git a/integration_tests/packaging/test_provider_selection.py b/integration_tests/packaging/test_provider_selection.py index 496487414a..0a1c14e4d4 100644 --- a/integration_tests/packaging/test_provider_selection.py +++ b/integration_tests/packaging/test_provider_selection.py @@ -6,7 +6,8 @@ from typing import cast import pytest -from conftest import _external_providers, pytest_runtest_setup + +from integration_tests.conftest import _external_providers, pytest_runtest_setup pytestmark = pytest.mark.packaging diff --git a/integration_tests/packaging/test_released_api_contract.py b/integration_tests/packaging/test_released_api_contract.py new file mode 100644 index 0000000000..283ce016da --- /dev/null +++ b/integration_tests/packaging/test_released_api_contract.py @@ -0,0 +1,30 @@ +import os +from importlib.metadata import version +from pathlib import Path + +import pytest + +from integration_tests._contract_support import ( + load_api_contract, + validate_released_api_contract, +) + +pytestmark = pytest.mark.packaging + +CONTRACT = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "released_api_contract.json" + + +@pytest.mark.packaging_dependency +def test_installed_distribution_preserves_released_public_api_contract() -> None: + contract = load_api_contract(CONTRACT) + assert contract["baseline"] == f"v{version('openai-agents')}" + assert len(contract["baseline_commit"]) == 40 + + errors = validate_released_api_contract( + contract, + require_all_optional_exports=( + os.environ.get("OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS") == "1" + ), + ) + + assert errors == [] diff --git a/integration_tests/packaging/test_run_state_compatibility.py b/integration_tests/packaging/test_run_state_compatibility.py new file mode 100644 index 0000000000..7e6f86db8b --- /dev/null +++ b/integration_tests/packaging/test_run_state_compatibility.py @@ -0,0 +1,140 @@ +import json +import logging +from pathlib import Path + +import pytest + +from agents import Agent, RunState +from agents.run_state import SUPPORTED_SCHEMA_VERSIONS +from integration_tests._contract_support import ( + _deserialize_common_sandbox_session_state, + _redaction_observables, + validate_historical_resume_behavior, + validate_historical_run_state_fixture, + validate_legacy_credential_run_state_fixture, +) + +pytestmark = pytest.mark.packaging + +FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "run_state" +SOURCES = json.loads((FIXTURE_ROOT / "sources.json").read_text(encoding="utf-8")) + + +def test_installed_distribution_supports_the_historical_fixture_corpus() -> None: + assert frozenset(SOURCES["versions"]) == SUPPORTED_SCHEMA_VERSIONS + + +@pytest.mark.parametrize( + ("schema_version", "entry"), + sorted(SOURCES["versions"].items()), +) +async def test_installed_distribution_rewrites_historical_run_state( + schema_version: str, entry: dict[str, str] +) -> None: + fixture = FIXTURE_ROOT / entry["fixture"] + payload = json.loads(fixture.read_text(encoding="utf-8")) + + assert payload["$schemaVersion"] == schema_version + assert await validate_historical_run_state_fixture(fixture) == [] + + +@pytest.mark.parametrize("entry", SOURCES["features"], ids=lambda entry: entry["feature"]) +async def test_installed_distribution_rewrites_historical_features_semantically( + entry: dict[str, str], +) -> None: + fixture = FIXTURE_ROOT / entry["fixture"] + payload = json.loads(fixture.read_text(encoding="utf-8")) + + assert payload["$schemaVersion"] == entry["version"] + assert await validate_historical_run_state_fixture(fixture) == [] + + +@pytest.mark.parametrize( + ("feature", "decision"), + [ + ("pending_tool_approval", "approve"), + ("pending_tool_approval", "reject"), + ("canonical_invocation_identity", None), + ], +) +async def test_installed_distribution_resumes_historical_approval_decisions( + feature: str, + decision: str | None, +) -> None: + entry = ( + SOURCES["resume"] + if feature == "pending_tool_approval" + else next(entry for entry in SOURCES["features"] if entry["feature"] == feature) + ) + fixture = FIXTURE_ROOT / entry["fixture"] + + assert ( + await validate_historical_resume_behavior( + fixture, + feature=feature, + decision=decision, + ) + == [] + ) + + +async def test_installed_distribution_sanitizes_v0194_mount_credentials( + caplog: pytest.LogCaptureFixture, +) -> None: + entry = SOURCES["security"] + fixture = FIXTURE_ROOT / entry["fixture"] + sentinels = entry["sentinels"] + fixture_text = fixture.read_text(encoding="utf-8") + + assert entry["provenance"] == "historical_writer" + assert all(sentinel in fixture_text for sentinel in sentinels) + with caplog.at_level(logging.DEBUG): + assert ( + await validate_legacy_credential_run_state_fixture( + fixture, + sentinels=sentinels, + ) + == [] + ) + + payload = json.loads(fixture_text) + restored = await RunState.from_json(Agent(name="compat-agent"), payload) + canonical = restored.to_json() + session_payload = canonical["sandbox"]["session_state"] + session_state = _deserialize_common_sandbox_session_state(session_payload) + assert session_state.mount_authority_redacted is True + with pytest.raises(ValueError, match="requires a current trusted manifest") as exc_info: + session_state.rebind_persisted_mount_authority( + None, + provider_backend_id="unix_local", + ) + + observables = json.dumps(canonical, sort_keys=True) + repr(restored._sandbox) + observables += _redaction_observables(exc_info.value, caplog.records) + assert all(sentinel not in observables for sentinel in sentinels) + + +@pytest.mark.parametrize( + ("fixture_name", "message"), + [ + ("missing_version.json", "missing schema version"), + ("future_version.json", "schema version is not supported"), + ("malformed_current_agent.json", "Run state agent not found in agent map"), + ], +) +async def test_installed_distribution_rejects_invalid_run_state_without_disclosure( + fixture_name: str, + message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + fixture = FIXTURE_ROOT / "negative" / fixture_name + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_SECRET_SENTINEL_42" + assert sentinel in json.dumps(payload) + + with caplog.at_level(logging.DEBUG): + with pytest.raises(Exception, match=message) as exc_info: + await RunState.from_json(Agent(name="compat-agent"), payload) + + observables = _redaction_observables(exc_info.value, caplog.records) + assert sentinel not in observables diff --git a/integration_tests/pytest.ini b/integration_tests/pytest.ini index ed81acb658..18d7353157 100644 --- a/integration_tests/pytest.ini +++ b/integration_tests/pytest.ini @@ -3,9 +3,14 @@ asyncio_mode = auto asyncio_default_fixture_loop_scope = session asyncio_default_test_loop_scope = session timeout = 75 +junit_logging = no +junit_log_passing_tests = false testpaths = . markers = packaging: Distribution contents and installed-package boundaries. + packaging_dependency: Released API contracts with a declared optional dependency installed. + distribution_smoke: Small live core smoke tests that also run from an installed sdist. + security: Packaged redaction and local adversarial sandbox contracts. mcp_compat: Packaged MCP client compatibility across supported dependency versions. extras: Independently installed optional dependency groups. core: Live OpenAI Responses and Chat Completions coverage. diff --git a/integration_tests/security/test_local_sandbox_isolation.py b/integration_tests/security/test_local_sandbox_isolation.py new file mode 100644 index 0000000000..7f91f6cb6e --- /dev/null +++ b/integration_tests/security/test_local_sandbox_isolation.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import json +import logging +import os +import uuid +from pathlib import Path +from typing import Any, cast + +import pytest +from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputText, +) + +from agents import RunConfig, Runner +from agents.items import ToolCallOutputItem, TResponseOutputItem +from agents.sandbox import Manifest, SandboxAgent, SandboxPathGrant, SandboxRunConfig +from agents.sandbox.capabilities import Shell +from agents.sandbox.entries import InContainerMountStrategy, S3Mount +from agents.sandbox.entries.mounts.patterns import RcloneMountPattern +from agents.sandbox.errors import ErrorCode, MountConfigError +from agents.sandbox.session import ( + CallbackSink, + Instrumentation, + SandboxSession, + SandboxSessionEvent, +) +from agents.sandbox.snapshot import NoopSnapshotSpec +from integration_tests._contract_support import _redaction_observables +from integration_tests._fake_model import QueuedFakeModel +from integration_tests.conftest import skip_or_fail + +pytestmark = pytest.mark.security + +_INSPECTION_SCRIPT = r""" +set -eu +printf '%s\n' '--- environment ---' +env +printf '%s\n' '--- filesystem ---' +mkdir -p /workspace /run/agents-security-probe +dd if=/dev/zero bs=4096 count=2 2>/dev/null \ + | tr '\000' x > /workspace/.agents-inspection-tail-probe +printf '%s\n' 'FILESYSTEM_TAIL_PROBE_AFTER_4096' \ + >> /workspace/.agents-inspection-tail-probe +printf '%s\n' 'FILESYSTEM_OUTSIDE_WORKSPACE_PROBE' \ + > /run/agents-security-probe/readable-file + +inspection_list=/.agents-readable-files +: > "$inspection_list" +for root in /workspace /tmp /run /var /etc /root /home /app /opt; do + if [ -d "$root" ]; then + find "$root" -type f -print0 2>/dev/null >> "$inspection_list" || true + fi +done + +file_count=0 +total_bytes=0 +while IFS= read -r -d '' path; do + if [ -r "$path" ]; then + size=$(wc -c < "$path") || exit 70 + file_count=$((file_count + 1)) + total_bytes=$((total_bytes + size)) + if [ "$file_count" -gt 1024 ] || [ "$total_bytes" -gt 16777216 ]; then + printf '%s\n' 'filesystem inspection bound exceeded' + exit 70 + fi + fi +done < "$inspection_list" + +while IFS= read -r -d '' path; do + if [ -r "$path" ]; then + printf '%s\n' "$path" + cat "$path" || exit 70 + printf '\n' + fi +done < "$inspection_list" +rm -f "$inspection_list" +printf '%s\n' '--- filesystem complete ---' +printf '%s\n' '--- processes ---' +for process in /proc/[0-9]*; do + if [ -r "$process/cmdline" ]; then + tr '\000' '\n' < "$process/cmdline" || true + fi + if [ -r "$process/environ" ]; then + tr '\000' '\n' < "$process/environ" || true + fi +done +""" + +_TAIL_PROBE_EVIDENCE = ( + "/workspace/.agents-inspection-tail-probe\n" + "x" * 8192 + "FILESYSTEM_TAIL_PROBE_AFTER_4096\n" +) +_OUTSIDE_WORKSPACE_PROBE_EVIDENCE = ( + "/run/agents-security-probe/readable-file\nFILESYSTEM_OUTSIDE_WORKSPACE_PROBE\n" +) +_NESTED_MOUNT_PROBE_EVIDENCE = ( + "/run/agents-security-mounted-probe/readable-file\nFILESYSTEM_NESTED_MOUNT_PROBE\n" +) + + +def _assert_complete_filesystem_inspection(output: str) -> None: + assert "Process exited with code 0" in output + assert "--- environment ---" in output + assert "--- filesystem ---" in output + assert _TAIL_PROBE_EVIDENCE in output + assert _OUTSIDE_WORKSPACE_PROBE_EVIDENCE in output + assert _NESTED_MOUNT_PROBE_EVIDENCE in output + assert "--- filesystem complete ---\n--- processes ---" in output + + +@pytest.mark.parametrize("fail_after_inspection", [False, True], ids=["success", "model-failure"]) +async def test_runner_owned_local_sandbox_cannot_inspect_trusted_client_credential( + caplog: pytest.LogCaptureFixture, + fail_after_inspection: bool, + tmp_path: Path, +) -> None: + import docker # type: ignore[import-untyped] + + from agents.sandbox.sandboxes import DockerSandboxClient, DockerSandboxClientOptions + + sentinel = "LOCAL_SANDBOX_CREDENTIAL_SENTINEL_42" + image = os.environ.get("OPENAI_AGENTS_INTEGRATION_SECURITY_IMAGE", "busybox:1.36.1") + events: list[SandboxSessionEvent] = [] + created_container_ids: list[str] = [] + deleted_container_ids: list[str] = [] + try: + docker_client = docker.from_env() + except docker.errors.DockerException: + skip_or_fail("Local sandbox security requires a reachable Docker daemon.") + return + try: + try: + docker_client.ping() + try: + docker_client.images.get(image) + except docker.errors.ImageNotFound: + docker_client.images.pull(image) + except docker.errors.DockerException: + skip_or_fail("Local sandbox security requires the configured Docker image.") + + class _CredentialOwningDockerClient(DockerSandboxClient): + def __init__(self, *, trusted_credential: str) -> None: + super().__init__( + docker_client=docker_client, + instrumentation=Instrumentation( + sinks=[CallbackSink(lambda event, _session: events.append(event))] + ), + ) + self.trusted_credential = trusted_credential + + async def _create_container( + self, + image: str, + *, + manifest: Manifest | None = None, + exposed_ports: tuple[int, ...] = (), + session_id: uuid.UUID | None = None, + ) -> Any: + container = await super()._create_container( + image, + manifest=manifest, + exposed_ports=exposed_ports, + session_id=session_id, + ) + container_id = container.id + assert container_id is not None + created_container_ids.append(container_id) + return container + + async def delete(self, session: SandboxSession) -> SandboxSession: + deleted_container_ids.append(cast(Any, session.state).container_id) + return await super().delete(session) + + tool_call = ResponseFunctionToolCall( + type="function_call", + name="exec_command", + call_id="inspect-sandbox-boundary", + status="completed", + arguments=json.dumps( + { + "cmd": _INSPECTION_SCRIPT, + "shell": "sh", + "login": False, + "yield_time_ms": 10_000, + } + ), + ) + final_message = ResponseOutputMessage( + id="security-complete", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText( + type="output_text", + text="inspection complete", + annotations=[], + logprobs=[], + ) + ], + ) + turns: list[list[TResponseOutputItem]] = [[tool_call]] + if not fail_after_inspection: + turns.append([final_message]) + model = QueuedFakeModel(turns) + client = _CredentialOwningDockerClient(trusted_credential=sentinel) + nested_mount_source = tmp_path / "nested-mount-probe" + nested_mount_source.mkdir() + (nested_mount_source / "readable-file").write_text( + "FILESYSTEM_NESTED_MOUNT_PROBE\n", encoding="utf-8" + ) + manifest = Manifest( + root="/workspace", + extra_path_grants=( + SandboxPathGrant( + path="/run/agents-security-mounted-probe", + host_path=str(nested_mount_source), + read_only=True, + ), + ), + ) + agent = SandboxAgent( + name="security-inspector", + model=model, + default_manifest=manifest, + capabilities=[Shell()], + ) + assert client.trusted_credential == sentinel + assert sentinel not in manifest.model_dump_json() + result = None + run_error: BaseException | None = None + with caplog.at_level(logging.DEBUG): + if fail_after_inspection: + with pytest.raises( + AssertionError, + match="QueuedFakeModel received an unexpected model request", + ) as exc_info: + await Runner.run( + agent, + "Inspect every model-visible credential surface.", + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options=DockerSandboxClientOptions(image=image), + snapshot=NoopSnapshotSpec(), + ) + ), + ) + run_error = exc_info.value + else: + result = await Runner.run( + agent, + "Inspect every model-visible credential surface.", + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options=DockerSandboxClientOptions(image=image), + snapshot=NoopSnapshotSpec(), + ) + ), + ) + + assert len(created_container_ids) == 1 + assert deleted_container_ids == created_container_ids + for container_id in created_container_ids: + with pytest.raises(docker.errors.NotFound): + docker_client.containers.get(container_id) + finally: + for container_id in created_container_ids: + try: + docker_client.containers.get(container_id).remove(force=True) + except docker.errors.NotFound: + pass + except docker.errors.DockerException: + pass + docker_client.close() + + expected_model_request_fields = { + "system_instructions", + "input", + "model_settings", + "tools", + "output_schema", + "handoffs", + "tracing", + "previous_response_id", + "conversation_id", + "prompt", + } + assert model.requests + assert all(set(request) == expected_model_request_fields for request in model.requests) + model_requests = repr(model.requests) + model_visible_tool_outputs: list[object] = [] + for request in model.requests: + model_input = request["input"] + if not isinstance(model_input, list): + continue + model_visible_tool_outputs.extend( + item.get("output") + for item in model_input + if isinstance(item, dict) and item.get("type") == "function_call_output" + ) + assert model_visible_tool_outputs + model_visible_tool_output = "\n".join(str(output) for output in model_visible_tool_outputs) + _assert_complete_filesystem_inspection(model_visible_tool_output) + assert not any( + record.getMessage() == "Failed to clean up sandbox resources after run" + for record in caplog.records + ) + tool_outputs: list[Any] = [] + serialized_state: dict[str, Any] = {} + if result is not None: + tool_outputs = [ + item.output for item in result.new_items if isinstance(item, ToolCallOutputItem) + ] + assert len(tool_outputs) == 1 + _assert_complete_filesystem_inspection(str(tool_outputs[0])) + serialized_state = result.to_state().to_json() + observables = "\n".join( + ( + str(result.final_output if result is not None else None), + model_requests, + model_visible_tool_output, + repr(tool_outputs), + json.dumps(serialized_state, sort_keys=True), + *(event.model_dump_json() for event in events), + _redaction_observables(run_error, caplog.records), + ) + ) + assert sentinel not in observables + + +async def test_credential_bearing_in_container_mount_is_rejected_before_side_effects( + caplog: pytest.LogCaptureFixture, +) -> None: + from agents.sandbox.sandboxes import DockerSandboxClient, DockerSandboxClientOptions + + class _SideEffectTrackingDockerClient(DockerSandboxClient): + create_container_called = False + + async def _create_container(self, *args: object, **kwargs: object) -> Any: + _ = (args, kwargs) + self.create_container_called = True + raise AssertionError( + "container creation must not run for an unsafe credential boundary" + ) + + sentinels = ( + "LOCAL_ACCESS_SENTINEL_42", + "LOCAL_SECRET_SENTINEL_42", + "LOCAL_TOKEN_SENTINEL_42", + ) + client = _SideEffectTrackingDockerClient(docker_client=cast(Any, object())) + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="compat-bucket", + access_key_id=sentinels[0], + secret_access_key=sentinels[1], + session_token=sentinels[2], + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with caplog.at_level(logging.DEBUG): + with pytest.raises( + MountConfigError, + match="mount-scoped credentials cannot be exposed to a helper", + ) as exc_info: + await client.create( + manifest=manifest, + options=DockerSandboxClientOptions(image="unused"), + ) + + assert client.create_container_called is False + error = exc_info.value + assert error.error_code is ErrorCode.MOUNT_CONFIG_INVALID + assert error.op == "materialize" + assert error.retryable is False + assert error.context == {} + observables = _redaction_observables(error, caplog.records) + assert all(sentinel not in observables for sentinel in sentinels) diff --git a/integration_tests/security/test_packaged_mount_redaction.py b/integration_tests/security/test_packaged_mount_redaction.py new file mode 100644 index 0000000000..6d0ff66fdf --- /dev/null +++ b/integration_tests/security/test_packaged_mount_redaction.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import io +import logging +import uuid +from pathlib import Path +from typing import Literal + +import pytest + +from agents.sandbox import Manifest +from agents.sandbox.entries import FuseMountPattern, MountpointMountPattern +from agents.sandbox.entries.mounts.patterns import FuseMountConfig, MountpointMountConfig +from agents.sandbox.errors import ErrorCode, MountCommandError +from agents.sandbox.session import ( + BaseSandboxSession, + CallbackSink, + Instrumentation, + SandboxSession, + SandboxSessionEvent, + SandboxSessionState, +) +from agents.sandbox.snapshot import NoopSnapshot +from agents.sandbox.types import ExecResult, User +from integration_tests._contract_support import _redaction_observables + +pytestmark = pytest.mark.security + + +class _SecuritySessionState(SandboxSessionState): + type: Literal["integration_security"] = "integration_security" + + +class _FailingMountSession(BaseSandboxSession): + def __init__(self, *, mount_stderr: bytes) -> None: + self.state = _SecuritySessionState( + session_id=uuid.uuid4(), + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id=str(uuid.uuid4())), + ) + self._mount_stderr = mount_stderr + self.exec_calls: list[list[str]] = [] + + async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO: + _ = (path, user) + raise AssertionError("read() should not be called") + + async def write( + self, + path: Path, + data: io.IOBase, + *, + user: str | User | None = None, + ) -> None: + _ = (path, data, user) + + async def running(self) -> bool: + return True + + async def shutdown(self) -> None: + return None + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + _ = timeout + command_strings = [str(part) for part in command] + self.exec_calls.append(command_strings) + if ( + len(command_strings) >= 3 + and command_strings[:2] == ["sh", "-lc"] + and "mount-s3 " in command_strings[2] + and "command -v " not in command_strings[2] + ) or command_strings[:2] == ["blobfuse2", "mount"]: + return ExecResult(exit_code=1, stdout=b"", stderr=self._mount_stderr) + return ExecResult(exit_code=0, stdout=b"", stderr=b"") + + async def persist_workspace(self) -> io.IOBase: + raise AssertionError("persist_workspace() should not be called") + + async def hydrate_workspace(self, data: io.IOBase) -> None: + _ = data + raise AssertionError("hydrate_workspace() should not be called") + + +async def test_installed_distribution_redacts_mount_credentials_from_failures( + caplog: pytest.LogCaptureFixture, +) -> None: + sentinels = ( + "oaicred_access_42", + "oaicred_secret_42", + "oaicred_token_42", + "oaicred_endpoint_42", + ) + events: list[SandboxSessionEvent] = [] + inner = _FailingMountSession( + mount_stderr=("mount failed: " + " ".join(sentinels)).encode(), + ) + session = SandboxSession( + inner, + instrumentation=Instrumentation( + sinks=[CallbackSink(lambda event, _session: events.append(event))] + ), + ) + + with caplog.at_level(logging.DEBUG): + with pytest.raises( + MountCommandError, + match="sandbox operation failed while using a protected mount configuration", + ) as exc_info: + await MountpointMountPattern().apply( + session, + Path("/workspace/remote"), + MountpointMountConfig( + bucket="bucket", + access_key_id=sentinels[0], + secret_access_key=sentinels[1], + session_token=sentinels[2], + prefix=None, + region="us-east-1", + endpoint_url=f"https://user:{sentinels[3]}@example.test", + mount_type="s3_mount", + read_only=True, + ), + ) + + error = exc_info.value + serialized_observables = _redaction_observables(error, caplog.records) + serialized_observables += "\n" + "\n".join( + ( + *(event.model_dump_json() for event in events), + *(" ".join(command) for command in inner.exec_calls), + ) + ) + assert error.error_code is ErrorCode.MOUNT_FAILED + assert error.op == "materialize" + assert error.retryable is False + assert error.context == {} + for sentinel in sentinels: + assert sentinel not in serialized_observables + + +async def test_installed_distribution_redacts_fuse_inline_authority_from_failures( + caplog: pytest.LogCaptureFixture, +) -> None: + sentinel = "oaicred_fuse_endpoint_42" + inner = _FailingMountSession(mount_stderr=b"mount failed") + session = SandboxSession(inner) + + with caplog.at_level(logging.DEBUG): + with pytest.raises( + MountCommandError, + match="sandbox operation failed while using a protected mount configuration", + ) as exc_info: + await FuseMountPattern().apply( + session, + Path("/workspace/remote"), + FuseMountConfig( + account="account", + container="container", + endpoint=f"https://user:{sentinel}@example.test", + identity_client_id=None, + account_key=None, + mount_type="azure_blob_mount", + read_only=True, + ), + ) + + error = exc_info.value + assert error.error_code is ErrorCode.MOUNT_FAILED + assert error.op == "materialize" + assert error.retryable is False + assert error.context == {} + assert sentinel not in _redaction_observables(error, caplog.records) diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index 9577a6be34..4d30763b51 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -33,6 +33,7 @@ _DATA_REDACTED_ERROR_MESSAGE = "Error details are redacted." _TYPE_NAMESPACE_DESCRIPTOR = cast(Any, type).__dict__["__dict__"] _TYPE_MRO_DESCRIPTOR = cast(Any, type).__dict__["__mro__"] +_SYSTEM_EXIT_CODE_DESCRIPTOR = cast(Any, SystemExit).__dict__["code"] def _mark_error_to_drain_stream_events(error: BaseException) -> None: @@ -150,6 +151,43 @@ def _detach_data_redacted_error_traceback(error: BaseException) -> None: descriptor.__set__(error, None) +def _prepare_data_redacted_error( + error: BaseException, + *, + trusted_error_message: str | None = None, +) -> BaseException: + """Detach payload-owned state and return a safe error for a public boundary.""" + error_type = type(error) + process_control_error = _replace_data_redacted_process_control_error(error) + if process_control_error is not None: + return process_control_error + safe_message: str | None = None + if error_type is UserError or error_type is ValueError: + try: + args = cast(Any, BaseException.args).__get__(error, error_type) + if isinstance(args, tuple) and len(args) == 1 and isinstance(args[0], str): + message = args[0] + if message == trusted_error_message: + safe_message = message + except BaseException: + pass + + _discard_exception_graph(error) + + safe_error: BaseException = RuntimeError(_DATA_REDACTED_ERROR_MESSAGE) + if error_type is ModelBehaviorError: + safe_error = ModelBehaviorError(_DATA_REDACTED_ERROR_MESSAGE) + elif error_type is UserError and safe_message is not None: + safe_error = UserError(safe_message) + elif error_type is ValueError and safe_message is not None: + safe_error = ValueError(safe_message) + try: + _mark_error_data_redacted(safe_error) + except BaseException: + pass + return safe_error + + def _replace_data_redacted_process_control_error( error: BaseException, ) -> BaseException | None: @@ -163,23 +201,27 @@ def _replace_data_redacted_process_control_error( safe_error = KeyboardInterrupt() elif not issubclass(error_type, SystemExit): return None + elif error_type is not SystemExit: + safe_error = SystemExit(1) else: try: - code_descriptor = type.__getattribute__(SystemExit, "__dict__")["code"] - code = cast(Any, code_descriptor).__get__(error, error_type) + effective_code = _SYSTEM_EXIT_CODE_DESCRIPTOR.__get__(error, SystemExit) except BaseException: safe_error = SystemExit(1) else: - if code is None: + if type(effective_code) not in {type(None), bool, int}: + safe_error = SystemExit(1) + elif effective_code is None: safe_error = SystemExit() - elif type(code) is int: - safe_error = SystemExit(code) else: - safe_error = SystemExit(1) + safe_error = SystemExit(effective_code) assert safe_error is not None _discard_exception_graph(error) - _mark_error_data_redacted(safe_error) + try: + _mark_error_data_redacted(safe_error) + except BaseException: + pass return safe_error diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index c74a883a5d..9a04c965b6 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -101,6 +101,7 @@ async def activate( await _mount_bucket(session, config) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -113,6 +114,7 @@ async def deactivate( mount_path = mount._resolve_mount_path(session, dest) await _unmount_bucket(session, mount_path.as_posix()) + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, @@ -590,6 +592,7 @@ def validate_mount(self, mount: Mount) -> None: context={"mount_type": mount.type}, ) + @redact_mount_error_data async def activate( self, mount: Mount, @@ -609,6 +612,7 @@ async def activate( await _attach_drive(sandbox, config) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -623,6 +627,7 @@ async def deactivate( if sandbox is not None: await _detach_drive(sandbox, config.mount_path) + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, @@ -635,6 +640,7 @@ async def teardown_for_snapshot( if sandbox is not None: await _detach_drive(sandbox, effective_path) + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, diff --git a/src/agents/extensions/sandbox/cloudflare/mounts.py b/src/agents/extensions/sandbox/cloudflare/mounts.py index b6dcee22f6..fea4aeea61 100644 --- a/src/agents/extensions/sandbox/cloudflare/mounts.py +++ b/src/agents/extensions/sandbox/cloudflare/mounts.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Literal +from ....sandbox._mount_security import redact_mount_error_data from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountConfigError @@ -45,6 +46,7 @@ class CloudflareBucketMountStrategy(MountStrategyBase): def validate_mount(self, mount: Mount) -> None: _ = self._build_cloudflare_bucket_mount_config(mount) + @redact_mount_error_data async def activate( self, mount: Mount, @@ -67,6 +69,7 @@ async def activate( ) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -82,6 +85,7 @@ async def deactivate( _ = base_dir await session.unmount_bucket(mount._resolve_mount_path(session, dest)) # type: ignore[attr-defined] + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, @@ -96,6 +100,7 @@ async def teardown_for_snapshot( _ = mount await session.unmount_bucket(path) # type: ignore[attr-defined] + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 262035e03e..05549a9fe5 100644 --- a/src/agents/extensions/sandbox/daytona/mounts.py +++ b/src/agents/extensions/sandbox/daytona/mounts.py @@ -217,6 +217,7 @@ async def activate( await _ensure_rclone(session) return await self._delegate().activate(mount, session, dest, base_dir) + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -227,6 +228,7 @@ async def deactivate( _assert_daytona_session(session) await self._delegate().deactivate(mount, session, dest, base_dir) + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, diff --git a/src/agents/extensions/sandbox/e2b/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py index e08f0129a4..1d71ea2ba2 100644 --- a/src/agents/extensions/sandbox/e2b/mounts.py +++ b/src/agents/extensions/sandbox/e2b/mounts.py @@ -103,6 +103,7 @@ async def activate( delegate = await self._delegate_for_session(session) return await delegate.activate(mount, session, dest, base_dir) + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -113,6 +114,7 @@ async def deactivate( _assert_e2b_session(session) await self._delegate().deactivate(mount, session, dest, base_dir) + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, diff --git a/src/agents/extensions/sandbox/modal/mounts.py b/src/agents/extensions/sandbox/modal/mounts.py index a7dcb74a99..953bbc3679 100644 --- a/src/agents/extensions/sandbox/modal/mounts.py +++ b/src/agents/extensions/sandbox/modal/mounts.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Literal +from ....sandbox._mount_security import redact_mount_error_data from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountConfigError @@ -36,6 +37,7 @@ def supports_native_snapshot_detach(self, mount: Mount) -> bool: _ = mount return False + @redact_mount_error_data async def activate( self, mount: Mount, @@ -51,6 +53,7 @@ async def activate( _ = (mount, session, dest, base_dir) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -66,6 +69,7 @@ async def deactivate( _ = (mount, session, dest, base_dir) return None + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, @@ -75,6 +79,7 @@ async def teardown_for_snapshot( _ = (mount, session, path) return None + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, diff --git a/src/agents/extensions/sandbox/runloop/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py index 2d4bd1a026..3fde66171d 100644 --- a/src/agents/extensions/sandbox/runloop/mounts.py +++ b/src/agents/extensions/sandbox/runloop/mounts.py @@ -149,6 +149,7 @@ async def activate( delegate = await self._delegate_for_session(session) return await delegate.activate(mount, session, dest, base_dir) + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -159,6 +160,7 @@ async def deactivate( _assert_runloop_session(session) await self._delegate().deactivate(mount, session, dest, base_dir) + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, diff --git a/src/agents/extensions/sandbox/vercel/mounts.py b/src/agents/extensions/sandbox/vercel/mounts.py index 876adb1203..ed3c1b38f4 100644 --- a/src/agents/extensions/sandbox/vercel/mounts.py +++ b/src/agents/extensions/sandbox/vercel/mounts.py @@ -8,7 +8,10 @@ from typing import Literal, NoReturn from ....exceptions import _mark_error_data_redacted -from ....sandbox._mount_security import discard_mount_source_exception, redact_mount_error_data +from ....sandbox._mount_security import ( + discard_mount_source_exception, + redact_mount_error_data, +) from ....sandbox.entries import Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountCommandError, MountConfigError @@ -525,7 +528,7 @@ async def activate( s3_mount = vercel_session._runtime_trusted_s3_mount(mount_path) try: await _mount_s3(s3_mount, vercel_session, mount_path) - except (Exception, asyncio.CancelledError) as exc: + except BaseException as exc: await vercel_session._runtime_fail_s3_mount_transition(exc) raise vercel_session._runtime_record_s3_mount_active(mount_path) @@ -547,7 +550,7 @@ async def deactivate( return try: await _unmount_s3(vercel_session, mount_path) - except (Exception, asyncio.CancelledError) as exc: + except BaseException as exc: await vercel_session._runtime_fail_s3_mount_transition(exc) raise vercel_session._runtime_record_s3_mount_inactive(mount_path) @@ -565,7 +568,7 @@ async def teardown_for_snapshot( return try: await _unmount_s3(vercel_session, path) - except (Exception, asyncio.CancelledError) as exc: + except BaseException as exc: await vercel_session._runtime_fail_s3_mount_transition(exc) raise vercel_session._runtime_record_s3_mount_detached(path) @@ -584,7 +587,7 @@ async def restore_after_snapshot( s3_mount = vercel_session._runtime_trusted_s3_mount(path) try: await _mount_s3(s3_mount, vercel_session, path) - except (Exception, asyncio.CancelledError) as exc: + except BaseException as exc: await vercel_session._runtime_fail_s3_mount_transition(exc) raise vercel_session._runtime_record_s3_mount_restored(path) diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 9ba0a3be2e..cf2d91afe6 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -89,6 +89,7 @@ "vercel_s3_mount_start_session", default=None, ) +_REDACTED_MOUNT_FAILURE_CAUSE_TYPE = "redacted" DEFAULT_VERCEL_WORKSPACE_ROOT = "/vercel/sandbox" _DEFAULT_MANIFEST_ROOT = cast(str, Manifest.model_fields["root"].default) DEFAULT_VERCEL_SANDBOX_TIMEOUT_MS = 270_000 @@ -765,7 +766,8 @@ def _runtime_provider_retryability(self, error: BaseException) -> bool | None: return _vercel_provider_retryability(error) async def _runtime_fail_s3_mount_transition(self, error: BaseException) -> None: - self._s3_mount_failure = type(error).__name__ + _ = error + self._s3_mount_failure = _REDACTED_MOUNT_FAILURE_CAUSE_TYPE stop_task = asyncio.create_task(self._stop_attached_sandbox()) while not stop_task.done(): try: diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 136e327030..e6fcd4141e 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic @@ -1236,16 +1236,23 @@ def _restore_approval_record(cls, record_dict: Mapping[str, Any]) -> _ApprovalRe record.sticky_scope = sticky_scope return record - def _rebuild_tool_invocations(self, invocations: Any) -> None: + def _rebuild_tool_invocations( + self, + invocations: Any, + *, + validation_error_factory: Callable[[str], UserError] = UserError, + ) -> None: """Restore the current-schema canonical tool invocation ledger.""" self._tool_invocations = {} if not isinstance(invocations, Mapping): - raise UserError("RunState tool_invocations must be a mapping.") + raise validation_error_factory("RunState tool_invocations must be a mapping.") for call_id, serialized_invocation in invocations.items(): if not isinstance(call_id, str) or not call_id: - raise UserError("RunState tool_invocations contains an invalid call ID.") + raise validation_error_factory( + "RunState tool_invocations contains an invalid call ID." + ) if not isinstance(serialized_invocation, Mapping): - raise UserError(f"RunState tool invocation {call_id!r} must be a mapping.") + raise validation_error_factory("RunState tool invocation must be a mapping.") invocation_type = serialized_invocation.get("type") approval_scope = serialized_invocation.get("approval_scope") fingerprint = serialized_invocation.get("fingerprint") @@ -1259,8 +1266,8 @@ def _rebuild_tool_invocations(self, invocations: Any) -> None: or not isinstance(completed, bool) or (completed and not executed) ): - raise UserError( - f"RunState tool invocation {call_id!r} contains invalid lifecycle data." + raise validation_error_factory( + "RunState tool invocation contains invalid lifecycle data." ) self._tool_invocations[call_id] = _ToolInvocationRecord( invocation_type=invocation_type, diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 87a75a7ecb..adc44c3228 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -60,10 +60,8 @@ from .exceptions import ( ModelBehaviorError, UserError, - _clear_data_redacted_error_traceback, - _detach_data_redacted_error_traceback, - _is_error_data_redacted, _mark_error_data_redacted, + _prepare_data_redacted_error, _raise_data_redacted_error, ) from .guardrail import ( @@ -158,6 +156,18 @@ ContextOverride = Mapping[str, Any] | RunContextWrapper[Any] ContextSerializer = Callable[[Any], Mapping[str, Any]] ContextDeserializer = Callable[[Mapping[str, Any]], Any] +RunStateValidationError = UserError | ValueError +RunStateValidationErrorType = type[UserError] | type[ValueError] +RunStateValidationErrorFactory = Callable[ + [str, RunStateValidationErrorType], RunStateValidationError +] + + +def _default_run_state_validation_error( + message: str, + error_type: RunStateValidationErrorType, +) -> RunStateValidationError: + return error_type(message) # RunState schema policy. @@ -1478,25 +1488,30 @@ async def from_string( Raises: UserError: If the string is invalid JSON or has incompatible schema version. """ - parse_error: UserError | None = None + parse_error: BaseException | None = None try: state_json = json.loads(state_string) - except json.JSONDecodeError as e: - message = ( - "Failed to parse run state JSON at " - f"line {e.lineno}, column {e.colno}, character {e.pos}" - ) - e.doc = "" - e.__traceback__ = None + except json.JSONDecodeError as error: + state_string = "" + _prepare_data_redacted_error(error) + parse_error = UserError("Failed to parse run state JSON") + except BaseException as error: state_string = "" - parse_error = UserError(message) + prepared_error = _prepare_data_redacted_error(error) + if type(prepared_error) in {asyncio.CancelledError, KeyboardInterrupt, SystemExit}: + parse_error = prepared_error + else: + parse_error = UserError("Failed to parse run state JSON") state_string = "" if parse_error is not None: _mark_error_data_redacted(parse_error) + initial_agent = cast(Any, None) + context_override = None + context_deserializer = None _raise_data_redacted_error(parse_error) - safe_error: Exception | None = None + safe_error: BaseException | None = None try: return await RunState.from_json( initial_agent=initial_agent, @@ -1505,14 +1520,17 @@ async def from_string( context_deserializer=context_deserializer, strict_context=strict_context, ) - except Exception as error: - if not _is_error_data_redacted(error): - raise - _clear_data_redacted_error_traceback(error) - _detach_data_redacted_error_traceback(error) - safe_error = error + except BaseException as error: + trusted_error_message = _known_run_state_error_message(error) + safe_error = _prepare_data_redacted_error( + error, + trusted_error_message=trusted_error_message, + ) state_json = cast(Any, None) + initial_agent = cast(Any, None) + context_override = None + context_deserializer = None assert safe_error is not None _raise_data_redacted_error(safe_error) @@ -1544,45 +1562,72 @@ async def from_json( Raises: UserError: If the dict has incompatible schema version. """ - if not isinstance(state_json, dict): - state_json = cast(Any, None) - error = UserError("Run state JSON must be an object") - _mark_error_data_redacted(error) - _raise_data_redacted_error(error) + restore_error: BaseException | None = None + trusted_validation_errors: list[tuple[BaseException, str]] = [] + + def validation_error_factory( + message: str, + error_type: RunStateValidationErrorType, + ) -> RunStateValidationError: + error = error_type(message) + trusted_validation_errors.append((error, message)) + return error - schema_error: UserError | None = None try: - _validate_run_state_schema_version(state_json) - except UserError as error: - _mark_error_data_redacted(error) - _clear_data_redacted_error_traceback(error) - _detach_data_redacted_error_traceback(error) - schema_error = error - - if schema_error is not None: - state_json = cast(Any, None) - _raise_data_redacted_error(schema_error) - - from .sandbox._mount_security import ( - _raise_invalid_run_state_sandbox_envelope, - sanitize_run_state_sandbox_mount_authority, - ) + if not isinstance(state_json, dict): + state_json = cast(Any, None) + raise validation_error_factory("Run state JSON must be an object", UserError) - if "sandbox" in state_json: - if not isinstance(state_json["sandbox"], Mapping): - state_json["sandbox"] = {} - _raise_invalid_run_state_sandbox_envelope() - sanitized_sandbox, _redacted = sanitize_run_state_sandbox_mount_authority( - state_json["sandbox"] + _validate_run_state_json_value(state_json) + + _validate_run_state_schema_version( + state_json, + validation_error_factory=validation_error_factory, ) - state_json["sandbox"] = sanitized_sandbox - return await _build_run_state_from_json( - initial_agent=initial_agent, - state_json=state_json, - context_override=context_override, - context_deserializer=context_deserializer, - strict_context=strict_context, - ) + + from .sandbox._mount_security import sanitize_run_state_sandbox_mount_authority + + if "sandbox" in state_json: + if not isinstance(state_json["sandbox"], Mapping): + state_json["sandbox"] = {} + raise validation_error_factory( + "RunState sandbox resume state has an invalid envelope", + ValueError, + ) + sanitized_sandbox, _redacted = sanitize_run_state_sandbox_mount_authority( + state_json["sandbox"], + validation_error_factory=lambda message: cast( + ValueError, + validation_error_factory(message, ValueError), + ), + ) + state_json["sandbox"] = sanitized_sandbox + + return await _build_run_state_from_json( + initial_agent=initial_agent, + state_json=state_json, + context_override=context_override, + context_deserializer=context_deserializer, + strict_context=strict_context, + validation_error_factory=validation_error_factory, + ) + except BaseException as error: + trusted_error_message = _trusted_run_state_validation_message( + error, + trusted_validation_errors, + ) + restore_error = _prepare_data_redacted_error( + error, + trusted_error_message=trusted_error_message, + ) + trusted_validation_errors.clear() + + state_json = cast(Any, None) + initial_agent = cast(Any, None) + context_override = None + context_deserializer = None + assert restore_error is not None + _raise_data_redacted_error(restore_error) # -------------------------- @@ -1590,6 +1635,23 @@ async def from_json( # -------------------------- +def _validate_run_state_json_value(value: object) -> None: + """Validate the exact built-in JSON tree without invoking caller-defined protocols.""" + if type(value) is dict: + for key, item in dict.items(cast(dict[object, object], value)): + if type(key) is not str: + raise TypeError("Run state JSON contains an unsupported value") + _validate_run_state_json_value(item) + return + if type(value) is list: + for item in list.__iter__(cast(list[object], value)): + _validate_run_state_json_value(item) + return + if type(value) in {str, int, float, bool, type(None)}: + return + raise TypeError("Run state JSON contains an unsupported value") + + def _get_attr(obj: Any, attr: str, default: Any = None) -> Any: """Return attribute value if present, otherwise the provided default.""" return getattr(obj, attr, default) @@ -1658,17 +1720,14 @@ def _context_meta_warning_message(context_meta: Mapping[str, Any] | None) -> str "RunState context was serialized from a custom type; provide context_deserializer " "or context_override to restore it." ) - original_type = context_meta.get("original_type") or "custom" - class_path = context_meta.get("class_path") - type_label = f"{original_type} ({class_path})" if class_path else str(original_type) if context_meta.get("omitted"): return ( - "RunState context was omitted during serialization for " - f"{type_label}; provide context_override to supply it." + "RunState context was omitted during serialization; provide context_override " + "to supply it." ) return ( - "RunState context was serialized from " - f"{type_label}; provide context_deserializer or context_override to restore it." + "RunState context requires explicit restoration; provide context_deserializer or " + "context_override to restore it." ) @@ -2192,6 +2251,7 @@ async def _restore_pending_nested_agent_tool_runs( scope_id: str | None = None, context_deserializer: ContextDeserializer | None = None, strict_context: bool = False, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> None: """Rehydrate nested agent-as-tool run state into the ephemeral tool-call cache.""" if not function_actions: @@ -2214,6 +2274,7 @@ async def _restore_pending_nested_agent_tool_runs( state_json=dict(nested_state_data), context_deserializer=context_deserializer, strict_context=strict_context, + validation_error_factory=validation_error_factory, ) except Exception: if strict_context: @@ -2246,6 +2307,7 @@ async def _deserialize_processed_response( strict_context: bool = False, program_call_ids: Collection[str] = (), completed_program_call_ids: Collection[str] = (), + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> ProcessedResponse: """Deserialize a ProcessedResponse from JSON data. @@ -2262,6 +2324,7 @@ async def _deserialize_processed_response( processed_response_data.get("new_items", []), agent_map, agent_identity_map=agent_identity_map, + validation_error_factory=validation_error_factory, ) if hasattr(current_agent, "get_all_tools"): @@ -2563,6 +2626,7 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]: scope_id=scope_id, context_deserializer=context_deserializer, strict_context=strict_context, + validation_error_factory=validation_error_factory, ) mcp_approval_requests: list[ToolRunMCPApprovalRequest] = [] @@ -2600,6 +2664,7 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]: agent_map=agent_map, agent_identity_map=agent_identity_map, fallback_agent=current_agent, + validation_error_factory=validation_error_factory, ) if approval_item is not None: interruptions.append(approval_item) @@ -2713,6 +2778,7 @@ def _resolve_agent_from_data( agent_map: Mapping[str, Agent[Any]], agent_identity_map: Mapping[str, Agent[Any]] | None = None, fallback_agent: Agent[Any] | None = None, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> Agent[Any] | None: """Resolve an agent from serialized data with an optional fallback.""" agent_name = None @@ -2727,9 +2793,9 @@ def _resolve_agent_from_data( resolved = agent_identity_map.get(agent_identity) if resolved is not None: return resolved - raise UserError( - "Run state references an agent identity that is not present in the restored graph: " - f"{agent_identity}" + raise validation_error_factory( + "Run state references an agent identity that is not present in the restored graph", + UserError, ) if agent_name: @@ -2757,6 +2823,7 @@ def _deserialize_tool_approval_item( agent_identity_map: Mapping[str, Agent[Any]] | None = None, fallback_agent: Agent[Any] | None = None, pre_normalized_raw_item: Any | None = None, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> ToolApprovalItem | None: """Deserialize a ToolApprovalItem from serialized data.""" agent = _resolve_agent_from_data( @@ -2764,6 +2831,7 @@ def _deserialize_tool_approval_item( agent_map, agent_identity_map, fallback_agent, + validation_error_factory=validation_error_factory, ) if agent is None: return None @@ -3043,6 +3111,7 @@ def _deserialize_output_guardrail_results( agent_map: dict[str, Agent[Any]], agent_identity_map: Mapping[str, Agent[Any]] | None = None, fallback_agent: Agent[Any], + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> list[OutputGuardrailResult]: """Rehydrate output guardrail results from serialized data.""" deserialized: list[OutputGuardrailResult] = [] @@ -3058,6 +3127,7 @@ def _deserialize_output_guardrail_results( agent_map, agent_identity_map, fallback_agent, + validation_error_factory=validation_error_factory, ) if resolved_agent is None: resolved_agent = fallback_agent @@ -3133,18 +3203,23 @@ def _tool_output_guardrail_fn( return deserialized -def _validate_run_state_schema_version(state_json: Mapping[str, Any]) -> str: +def _validate_run_state_schema_version( + state_json: Mapping[str, Any], + *, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, +) -> str: schema_version = state_json.get("$schemaVersion") if not schema_version: - raise UserError("Run state is missing schema version") + raise validation_error_factory("Run state is missing schema version", UserError) if not isinstance(schema_version, str): - raise UserError("Run state schema version has an invalid type") + raise validation_error_factory("Run state schema version has an invalid type", UserError) if schema_version not in SUPPORTED_SCHEMA_VERSIONS: supported_versions = ", ".join(sorted(SUPPORTED_SCHEMA_VERSIONS)) - raise UserError( + raise validation_error_factory( "Run state schema version is not supported. " f"Supported versions are: {supported_versions}. " - f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}." + f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}.", + UserError, ) return schema_version @@ -3155,6 +3230,7 @@ async def _build_run_state_from_json( context_override: ContextOverride | None = None, context_deserializer: ContextDeserializer | None = None, strict_context: bool = False, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> RunState[Any, Agent[Any]]: """Shared helper to rebuild RunState from JSON payload. @@ -3168,7 +3244,10 @@ async def _build_run_state_from_json( safely, this function warns or raises (in ``strict_context`` mode) rather than silently claiming that the rebuilt mapping is equivalent to the original object. """ - schema_version = _validate_run_state_schema_version(state_json) + schema_version = _validate_run_state_schema_version( + state_json, + validation_error_factory=validation_error_factory, + ) schema_major, schema_minor = (int(part) for part in schema_version.split(".", maxsplit=1)) programmatic_major, programmatic_minor = ( int(part) for part in _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION.split(".", maxsplit=1) @@ -3177,24 +3256,25 @@ async def _build_run_state_from_json( programmatic_major, programmatic_minor, ) and _run_state_uses_programmatic_tool_calling(state_json): - raise UserError( + raise validation_error_factory( "Run state contains Programmatic Tool Calling data but uses schema version " f"{schema_version}. Programmatic Tool Calling requires schema version " - f"{_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION} or later." + f"{_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION} or later.", + UserError, ) agent_identity_map = _build_agent_identity_map(initial_agent) agent_map = _build_agent_map(initial_agent) current_agent_data = state_json["current_agent"] - current_agent_name = current_agent_data["name"] current_agent = _resolve_agent_from_data( current_agent_data, agent_map, agent_identity_map=agent_identity_map, + validation_error_factory=validation_error_factory, ) if current_agent is None: - raise UserError(f"Agent {current_agent_name} not found in agent map") + raise validation_error_factory("Run state agent not found in agent map", UserError) context_data = state_json["context"] usage = deserialize_usage(context_data.get("usage", {})) @@ -3214,10 +3294,16 @@ async def _build_run_state_from_json( ): warning_message = _context_meta_warning_message(context_meta) if strict_context: - raise UserError(warning_message) + raise validation_error_factory(warning_message, UserError) logger.warning(warning_message) if isinstance(context_override, RunContextWrapper): + if type(context_override) is not RunContextWrapper: + raise validation_error_factory( + "RunState restoration does not support RunContextWrapper subclasses; " + "provide the custom context value directly or wrap it in RunContextWrapper.", + UserError, + ) context = context_override elif context_override is not None: context = RunContextWrapper(context=context_override) @@ -3225,29 +3311,46 @@ async def _build_run_state_from_json( context = RunContextWrapper(context=None) elif context_deserializer is not None: if not isinstance(serialized_context, Mapping): - raise UserError( - "Serialized run state context must be a mapping to use context_deserializer." + raise validation_error_factory( + "Serialized run state context must be a mapping to use context_deserializer.", + UserError, ) try: rebuilt_context = context_deserializer(dict(serialized_context)) except Exception as exc: - raise UserError( - "Context deserializer failed while rebuilding RunState context." + raise validation_error_factory( + "Context deserializer failed while rebuilding RunState context.", + UserError, ) from exc if isinstance(rebuilt_context, RunContextWrapper): + if type(rebuilt_context) is not RunContextWrapper: + raise validation_error_factory( + "RunState restoration does not support RunContextWrapper subclasses; " + "provide the custom context value directly or wrap it in RunContextWrapper.", + UserError, + ) context = rebuilt_context else: context = RunContextWrapper(context=rebuilt_context) elif isinstance(serialized_context, Mapping): context = RunContextWrapper(context=serialized_context) else: - raise UserError("Serialized run state context must be a mapping. Please provide one.") + raise validation_error_factory( + "Serialized run state context must be a mapping. Please provide one.", + UserError, + ) context.usage = usage context._restored_unbound_approval_call_ids = set() context._allow_legacy_approval_binding_reconstruction = (schema_major, schema_minor) < (1, 15) context._rebuild_approvals(context_data.get("approvals", {})) if (schema_major, schema_minor) >= (1, 15): - context._rebuild_tool_invocations(context_data.get("tool_invocations", {})) + context._rebuild_tool_invocations( + context_data.get("tool_invocations", {}), + validation_error_factory=lambda message: cast( + UserError, + validation_error_factory(message, UserError), + ), + ) else: context._tool_invocations = {} hosted_mcp_major, hosted_mcp_minor = ( @@ -3261,7 +3364,7 @@ async def _build_run_state_from_json( if ( context_override is None and serialized_tool_input is not None - and getattr(context, "tool_input", None) is None + and context.tool_input is None ): context.tool_input = serialized_tool_input @@ -3296,7 +3399,7 @@ async def _build_run_state_from_json( state._current_turn = state_json["current_turn"] pending_input_raw = state_json.get("pending_input", []) if not isinstance(pending_input_raw, list): - raise UserError("Run state pending_input must be a list") + raise validation_error_factory("Run state pending_input must be a list", UserError) state._pending_input = cast( list[TResponseInputItem], [dict(item) if isinstance(item, Mapping) else item for item in pending_input_raw], @@ -3307,6 +3410,7 @@ async def _build_run_state_from_json( serialized_generated_items, agent_map, agent_identity_map=agent_identity_map, + validation_error_factory=validation_error_factory, ) last_processed_response_data = state_json.get("last_processed_response") @@ -3323,6 +3427,7 @@ async def _build_run_state_from_json( strict_context=strict_context, program_call_ids=program_call_ids, completed_program_call_ids=completed_program_call_ids, + validation_error_factory=validation_error_factory, ) else: state._last_processed_response = None @@ -3333,6 +3438,7 @@ async def _build_run_state_from_json( serialized_session_items, agent_map, agent_identity_map=agent_identity_map, + validation_error_factory=validation_error_factory, ) else: serialized_session_items = [] @@ -3392,8 +3498,9 @@ async def _build_run_state_from_json( nested_history_refs_json = state_json.get("nested_history_owned_session_item_refs", []) if not isinstance(nested_history_refs_json, list): - raise UserError( - "Run state nested_history_owned_session_item_refs must be a list of objects" + raise validation_error_factory( + "Run state nested_history_owned_session_item_refs must be a list of objects", + UserError, ) nested_history_refs: list[NestedHistoryOwnedItemRef] = [] for item_ref in nested_history_refs_json: @@ -3406,15 +3513,19 @@ async def _build_run_state_from_json( or type(item_ref.get("input_index")) is not int or cast(int, item_ref["input_index"]) < 0 ): - raise UserError( + raise validation_error_factory( "Run state nested_history_owned_session_item_refs entries must contain a " - "non-negative integer index and input_index, and 64-character digest" + "non-negative integer index and input_index, and 64-character digest", + UserError, ) session_source_index = cast(int, item_ref["index"]) input_index = cast(int, item_ref["input_index"]) digest = cast(str, item_ref["digest"]) if "session_items" in state_json and session_source_index >= len(serialized_session_items): - raise UserError("Run state nested history ownership references a missing session item") + raise validation_error_factory( + "Run state nested history ownership references a missing session item", + UserError, + ) session_index = restored_session_indexes.get(session_source_index) if session_index is None: logger.warning( @@ -3423,16 +3534,25 @@ async def _build_run_state_from_json( ) continue if not isinstance(state._original_input, list) or input_index >= len(state._original_input): - raise UserError("Run state nested history ownership references a missing input item") + raise validation_error_factory( + "Run state nested history ownership references a missing input item", + UserError, + ) run_item = state._session_items[session_index] run_input_item = run_item_to_input_item(run_item) if run_input_item is None or digest_input_item(run_input_item) != digest: - raise UserError("Run state nested history ownership session digest does not match") + raise validation_error_factory( + "Run state nested history ownership session digest does not match", + UserError, + ) ensure_nested_history_run_item_occurrence_key(run_item) input_item = cast(TResponseInputItem, state._original_input[input_index]) if digest_input_item(input_item) != digest: - raise UserError("Run state nested history ownership input digest does not match") + raise validation_error_factory( + "Run state nested history ownership input digest does not match", + UserError, + ) nested_history_refs.append( NestedHistoryOwnedItemRef( session_index=session_index, @@ -3454,6 +3574,7 @@ async def _build_run_state_from_json( agent_map=agent_map, agent_identity_map=agent_identity_map, fallback_agent=current_agent, + validation_error_factory=validation_error_factory, ) state._tool_input_guardrail_results = _deserialize_tool_input_guardrail_results( state_json.get("tool_input_guardrail_results", []) @@ -3477,6 +3598,7 @@ async def _build_run_state_from_json( item_data, agent_map=agent_map, agent_identity_map=agent_identity_map, + validation_error_factory=validation_error_factory, ) if approval_item is not None: interruptions.append(approval_item) @@ -3521,6 +3643,7 @@ async def _build_run_state_from_json( _validate_completed_tool_invocations( state, reconstruct_legacy=(schema_major, schema_minor) < (1, 15), + validation_error_factory=validation_error_factory, ) return state @@ -3530,6 +3653,7 @@ def _validate_completed_tool_invocations( state: RunState[Any, Agent[Any]], *, reconstruct_legacy: bool = False, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> None: """Reconcile invocation bindings with restored calls and outputs.""" if state._context is None: @@ -3804,9 +3928,10 @@ def record_run_item(run_item: RunItem) -> None: or any(expected_call not in occurrence for occurrence in occurrences) or expected_output not in restored_outputs ): - raise UserError( - f"RunState completed tool invocation {call_id!r} does not match a restored " - "tool call and output." + raise validation_error_factory( + "RunState completed tool invocation does not match a restored tool call " + "and output.", + UserError, ) @@ -4343,6 +4468,7 @@ def _deserialize_items( agent_map: dict[str, Agent[Any]], *, agent_identity_map: Mapping[str, Agent[Any]] | None = None, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> list[RunItem]: """Deserialize run items from JSON data. @@ -4388,6 +4514,7 @@ def _resolve_agent_info( raw_agent, agent_map, agent_identity_map, + validation_error_factory=validation_error_factory, ) if agent_candidate is not None: return agent_candidate, agent_candidate.name @@ -4513,11 +4640,13 @@ def _resolve_agent_info( item_data.get("source_agent"), agent_map, agent_identity_map, + validation_error_factory=validation_error_factory, ) target_agent = _resolve_agent_from_data( item_data.get("target_agent"), agent_map, agent_identity_map, + validation_error_factory=validation_error_factory, ) # If we cannot resolve both agents, skip this item gracefully @@ -4590,6 +4719,7 @@ def _resolve_agent_info( agent_identity_map=agent_identity_map, fallback_agent=agent, pre_normalized_raw_item=normalized_raw_item, + validation_error_factory=validation_error_factory, ) if approval_item is not None: result.append(approval_item) @@ -4613,6 +4743,7 @@ def _deserialize_items_with_source_indexes( agent_map: dict[str, Agent[Any]], *, agent_identity_map: Mapping[str, Agent[Any]] | None = None, + validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error, ) -> tuple[list[RunItem], list[int]]: """Deserialize items while retaining indexes of source entries that survived.""" items: list[RunItem] = [] @@ -4622,6 +4753,7 @@ def _deserialize_items_with_source_indexes( [item_data], agent_map, agent_identity_map=agent_identity_map, + validation_error_factory=validation_error_factory, ) items.extend(deserialized) source_indexes.extend([source_index] * len(deserialized)) @@ -4633,3 +4765,96 @@ def _clone_original_input(original_input: str | list[Any]) -> str | list[Any]: if isinstance(original_input, str): return original_input return copy.deepcopy(original_input) + + +_TRUSTED_RUN_STATE_ERROR_MESSAGES = frozenset( + { + "Run state JSON must be an object", + "Run state is missing schema version", + "Run state schema version has an invalid type", + ( + "Run state schema version is not supported. " + f"Supported versions are: {', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}. " + f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}." + ), + "Run state agent not found in agent map", + "Run state pending_input must be a list", + "Run state references an agent identity that is not present in the restored graph", + ( + "RunState context was serialized from a custom type; provide context_deserializer " + "or context_override to restore it." + ), + ( + "RunState context was omitted during serialization; provide context_override " + "to supply it." + ), + ( + "RunState context requires explicit restoration; provide context_deserializer or " + "context_override to restore it." + ), + "Serialized run state context must be a mapping to use context_deserializer.", + ( + "RunState restoration does not support RunContextWrapper subclasses; " + "provide the custom context value directly or wrap it in RunContextWrapper." + ), + "Context deserializer failed while rebuilding RunState context.", + "Serialized run state context must be a mapping. Please provide one.", + "Run state nested_history_owned_session_item_refs must be a list of objects", + ( + "Run state nested_history_owned_session_item_refs entries must contain a " + "non-negative integer index and input_index, and 64-character digest" + ), + "Run state nested history ownership references a missing session item", + "Run state nested history ownership references a missing input item", + "Run state nested history ownership session digest does not match", + "Run state nested history ownership input digest does not match", + "RunState tool_invocations must be a mapping.", + "RunState tool_invocations contains an invalid call ID.", + "RunState tool invocation must be a mapping.", + "RunState tool invocation contains invalid lifecycle data.", + "Hosted MCP approval decisions require a non-empty request id.", + ( + "Persistent hosted MCP approval decisions require a non-empty server_label " + "and tool name." + ), + "RunState completed tool invocation does not match a restored tool call and output.", + "RunState sandbox resume state contains an invalid manifest", + "RunState sandbox resume state has an invalid envelope", + *( + "Run state contains Programmatic Tool Calling data but uses schema version " + f"{schema_version}. Programmatic Tool Calling requires schema version " + f"{_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION} or later." + for schema_version in SUPPORTED_SCHEMA_VERSIONS + ), + } +) + + +def _known_run_state_error_message(error: BaseException) -> str | None: + if type(error) not in {UserError, ValueError}: + return None + try: + args = cast(Any, BaseException.args).__get__(error, type(error)) + except BaseException: + return None + if type(args) is not tuple or len(args) != 1 or type(args[0]) is not str: + return None + message = args[0] + return message if message in _TRUSTED_RUN_STATE_ERROR_MESSAGES else None + + +def _trusted_run_state_validation_message( + error: BaseException, + trusted_validation_errors: Sequence[tuple[BaseException, str]], +) -> str | None: + message = _known_run_state_error_message(error) + if message is None: + return None + return next( + ( + trusted_message + for trusted_error, trusted_message in trusted_validation_errors + if trusted_error is error and trusted_message == message + ), + None, + ) diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py index 09478a772a..95d08055d1 100644 --- a/src/agents/sandbox/_mount_security.py +++ b/src/agents/sandbox/_mount_security.py @@ -1266,7 +1266,7 @@ def _strategy_classification(strategy: MountStrategyBase) -> tuple[str, str | No return "unknown", None -def _redact_mount_serialization_error(error: Exception) -> MountConfigError: +def _redact_mount_serialization_error(error: BaseException) -> MountConfigError: discard_mount_source_exception(error) safe_error = MountConfigError( message="sandbox session state containing mount authority could not be serialized" @@ -1275,7 +1275,7 @@ def _redact_mount_serialization_error(error: Exception) -> MountConfigError: return safe_error -def _redact_mount_state_validation_error(error: Exception, *, message: str) -> ValueError: +def _redact_mount_state_validation_error(error: BaseException, *, message: str) -> ValueError: discard_mount_source_exception(error) safe_error = ValueError(message) _mark_error_data_redacted(safe_error) @@ -2213,13 +2213,17 @@ def _run_state_sandbox_envelope_is_valid(payload: object) -> bool: ) -def _sanitize_run_state_sandbox_mount_authority(payload: object) -> tuple[object, bool]: +def _sanitize_run_state_sandbox_mount_authority( + payload: object, + *, + validation_error_factory: Callable[[str], ValueError] | None = None, +) -> tuple[object, bool]: """Sanitize only the documented sandbox resume-state envelope.""" if not _run_state_sandbox_envelope_is_valid(payload): if isinstance(payload, dict | list): payload.clear() - _raise_invalid_run_state_sandbox_envelope() + _raise_invalid_run_state_sandbox_envelope(validation_error_factory=validation_error_factory) assert isinstance(payload, Mapping) sandbox = copy.deepcopy(dict(payload)) redacted = False @@ -2249,15 +2253,24 @@ def _sanitize_run_state_sandbox_mount_authority(payload: object) -> tuple[object return sandbox, redacted -def sanitize_run_state_sandbox_mount_authority(payload: object) -> tuple[object, bool]: +def sanitize_run_state_sandbox_mount_authority( + payload: object, + *, + validation_error_factory: Callable[[str], ValueError] | None = None, +) -> tuple[object, bool]: safe_error: ValueError | None = None try: - return _sanitize_run_state_sandbox_mount_authority(payload) - except _InvalidRawMountManifestError as error: - safe_error = _redact_mount_state_validation_error( - error, - message="RunState sandbox resume state contains an invalid manifest", + return _sanitize_run_state_sandbox_mount_authority( + payload, + validation_error_factory=validation_error_factory, ) + except _InvalidRawMountManifestError as error: + message = "RunState sandbox resume state contains an invalid manifest" + if validation_error_factory is None: + safe_error = _redact_mount_state_validation_error(error, message=message) + else: + discard_mount_source_exception(error) + safe_error = validation_error_factory(message) if isinstance(payload, dict | list): payload.clear() @@ -2266,7 +2279,14 @@ def sanitize_run_state_sandbox_mount_authority(payload: object) -> tuple[object, _raise_data_redacted_error(safe_error) -def _raise_invalid_run_state_sandbox_envelope() -> NoReturn: - error = ValueError("RunState sandbox resume state has an invalid envelope") - _mark_error_data_redacted(error) +def _raise_invalid_run_state_sandbox_envelope( + *, + validation_error_factory: Callable[[str], ValueError] | None = None, +) -> NoReturn: + message = "RunState sandbox resume state has an invalid envelope" + if validation_error_factory is None: + error = ValueError(message) + _mark_error_data_redacted(error) + else: + error = validation_error_factory(message) _raise_data_redacted_error(error) diff --git a/src/agents/sandbox/entries/mounts/_redaction.py b/src/agents/sandbox/entries/mounts/_redaction.py new file mode 100644 index 0000000000..b845922918 --- /dev/null +++ b/src/agents/sandbox/entries/mounts/_redaction.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import Any, ParamSpec, TypeVar +from urllib.parse import parse_qsl, unquote, urlsplit + +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +def _url_contains_inline_authority(value: object) -> bool: + if value is None: + return False + if not isinstance(value, str): + return True + if "@" in value: + return True + try: + parsed = urlsplit(value) + except ValueError: + return True + return parsed.username is not None or parsed.password is not None or bool(parsed.query) + + +def _inline_url_authority_values(value: object) -> tuple[str, ...]: + if not isinstance(value, str) or not _url_contains_inline_authority(value): + return () + + values = [value] + try: + parsed = urlsplit(value) + except ValueError: + return tuple(values) + + for authority_value in (parsed.username, parsed.password, parsed.query): + if authority_value: + values.extend((authority_value, unquote(authority_value))) + for _key, query_value in parse_qsl(parsed.query, keep_blank_values=True): + if query_value: + values.extend((query_value, unquote(query_value))) + return tuple(dict.fromkeys(values)) + + +def _redact_mount_lifecycle_error( + function: Callable[_P, Coroutine[Any, Any, _T]], +) -> Callable[_P, Coroutine[Any, Any, _T]]: + """Load the mount error boundary lazily to avoid an entries/security import cycle.""" + + protected: Callable[_P, Coroutine[Any, Any, _T]] | None = None + + @wraps(function) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + nonlocal protected + if protected is None: + from ..._mount_security import redact_mount_error_data + + protected = redact_mount_error_data(function) + try: + return await protected(*args, **kwargs) + except BaseException: + del args, kwargs + raise + + return wrapper diff --git a/src/agents/sandbox/entries/mounts/base.py b/src/agents/sandbox/entries/mounts/base.py index 3f2e7bf049..07cedd52d6 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -4,10 +4,9 @@ import builtins import inspect import warnings -from collections.abc import Callable, Coroutine, Mapping -from functools import wraps +from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Literal, ParamSpec, TypeVar +from typing import TYPE_CHECKING, ClassVar, Literal from pydantic import BaseModel, Field, SerializeAsAny, field_validator @@ -16,37 +15,12 @@ from ...types import FileMode, Permissions from ...workspace_paths import coerce_posix_path, posix_path_as_path, windows_absolute_path from ..base import BaseEntry +from ._redaction import _redact_mount_lifecycle_error from .patterns import MountPattern, MountPatternBase, MountPatternConfig if TYPE_CHECKING: from ...session.base_sandbox_session import BaseSandboxSession -_P = ParamSpec("_P") -_T = TypeVar("_T") - - -def _redact_mount_lifecycle_error( - function: Callable[_P, Coroutine[Any, Any, _T]], -) -> Callable[_P, Coroutine[Any, Any, _T]]: - """Load the mount error boundary lazily to avoid an entries/security import cycle.""" - - protected: Callable[_P, Coroutine[Any, Any, _T]] | None = None - - @wraps(function) - async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: - nonlocal protected - if protected is None: - from ..._mount_security import redact_mount_error_data - - protected = redact_mount_error_data(function) - try: - return await protected(*args, **kwargs) - except BaseException: - del args, kwargs - raise - - return wrapper - class InContainerMountAdapter: """Default adapter for mounts materialized by commands inside the sandbox. @@ -84,6 +58,7 @@ async def _build_config( ) return config + @_redact_mount_lifecycle_error async def activate( self, strategy: InContainerMountStrategy, @@ -97,6 +72,7 @@ async def activate( await strategy.pattern.apply(session, mount_path, config) return [] + @_redact_mount_lifecycle_error async def deactivate( self, strategy: InContainerMountStrategy, @@ -109,6 +85,7 @@ async def deactivate( config = await self._build_config(strategy, session, include_config_text=False) await strategy.pattern.unapply(session, mount_path, config) + @_redact_mount_lifecycle_error async def teardown_for_snapshot( self, strategy: InContainerMountStrategy, @@ -118,6 +95,7 @@ async def teardown_for_snapshot( config = await self._build_config(strategy, session, include_config_text=False) await strategy.pattern.unapply(session, path, config) + @_redact_mount_lifecycle_error async def restore_after_snapshot( self, strategy: InContainerMountStrategy, @@ -277,6 +255,7 @@ async def activate( ) return await mount.in_container_adapter().activate(self, session, dest, base_dir) + @_redact_mount_lifecycle_error async def deactivate( self, mount: Mount, @@ -286,6 +265,7 @@ async def deactivate( ) -> None: await mount.in_container_adapter().deactivate(self, session, dest, base_dir) + @_redact_mount_lifecycle_error async def teardown_for_snapshot( self, mount: Mount, @@ -328,6 +308,7 @@ class DockerVolumeMountStrategy(MountStrategyBase): def validate_mount(self, mount: Mount) -> None: mount.docker_volume_adapter().validate(self) + @_redact_mount_lifecycle_error async def activate( self, mount: Mount, @@ -343,6 +324,7 @@ async def activate( _ = (mount, session, dest, base_dir) return [] + @_redact_mount_lifecycle_error async def deactivate( self, mount: Mount, @@ -358,6 +340,7 @@ async def deactivate( _ = (mount, session, dest, base_dir) return None + @_redact_mount_lifecycle_error async def teardown_for_snapshot( self, mount: Mount, @@ -367,6 +350,7 @@ async def teardown_for_snapshot( _ = (mount, session, path) return None + @_redact_mount_lifecycle_error async def restore_after_snapshot( self, mount: Mount, @@ -488,6 +472,7 @@ async def apply( ) return await self.mount_strategy.activate(self, session, dest, base_dir) + @_redact_mount_lifecycle_error async def unmount( self, session: BaseSandboxSession, diff --git a/src/agents/sandbox/entries/mounts/patterns.py b/src/agents/sandbox/entries/mounts/patterns.py index 276ea23f2c..fe464667bf 100644 --- a/src/agents/sandbox/entries/mounts/patterns.py +++ b/src/agents/sandbox/entries/mounts/patterns.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field +from ....exceptions import _mark_error_data_redacted from ...errors import ( MountCommandError, MountConfigError, @@ -24,6 +25,11 @@ sandbox_path_str, windows_absolute_path, ) +from ._redaction import ( + _inline_url_authority_values, + _redact_mount_lifecycle_error, + _url_contains_inline_authority, +) if TYPE_CHECKING: from ...session.base_sandbox_session import BaseSandboxSession @@ -306,6 +312,7 @@ def to_text(self) -> str: lines.append("") return "\n".join(lines) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -402,12 +409,15 @@ async def apply( result = await session.exec(*cmd, shell=False) if not result.ok(): - raise MountCommandError( + error = MountCommandError( command=" ".join(cmd), stderr=result.stderr.decode("utf-8", errors="replace"), context={"account": account, "container": container}, ) + _mark_error_data_redacted(error) + raise error + @_redact_mount_lifecycle_error async def unapply( self, session: BaseSandboxSession, @@ -436,6 +446,7 @@ class MountpointOptions: options: MountpointOptions = Field(default_factory=MountpointOptions) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -444,6 +455,12 @@ async def apply( ) -> None: mountpoint_config = _require_mount_config(config, MountpointMountConfig) bucket = mountpoint_config.bucket + protected_endpoint_url = ( + mountpoint_config.endpoint_url + if _url_contains_inline_authority(mountpoint_config.endpoint_url) + else None + ) + endpoint_env_reference = "${OPENAI_AGENTS_MOUNT_ENDPOINT_URL}" tool_check = await session.exec("command -v mount-s3 >/dev/null 2>&1") if not tool_check.ok(): @@ -465,7 +482,14 @@ async def apply( if mountpoint_config.region: cmd.extend(["--region", mountpoint_config.region]) if mountpoint_config.endpoint_url: - cmd.extend(["--endpoint-url", mountpoint_config.endpoint_url]) + cmd.extend( + [ + "--endpoint-url", + endpoint_env_reference + if protected_endpoint_url is not None + else mountpoint_config.endpoint_url, + ] + ) if mountpoint_config.mount_type == "gcs_mount": # GCS XML API rejects the default upload checksum flow used by mount-s3. cmd.extend(["--upload-checksums", "off"]) @@ -482,10 +506,15 @@ async def apply( env_vars.append(("AWS_SECRET_ACCESS_KEY", secret_access_key)) if session_token: env_vars.append(("AWS_SESSION_TOKEN", session_token)) + if protected_endpoint_url is not None: + env_vars.append(("OPENAI_AGENTS_MOUNT_ENDPOINT_URL", protected_endpoint_url)) - joined_cmd = " ".join(shlex.quote(part) for part in cmd) + joined_cmd = " ".join( + f'"{part}"' if part == endpoint_env_reference else shlex.quote(part) for part in cmd + ) stderr_path: Path | None = None sensitive_values = [value for _name, value in env_vars] + sensitive_values.extend(_inline_url_authority_values(protected_endpoint_url)) if env_vars: session_id = getattr(session.state, "session_id", None) if session_id is None: @@ -521,12 +550,15 @@ async def apply( if stderr_path is not None: stderr += await _read_text_if_present(session, stderr_path) stderr = _redact_sensitive_values(stderr, sensitive_values) - raise MountCommandError( - command=joined_cmd, + error = MountCommandError( + command=_redact_sensitive_values(joined_cmd, sensitive_values), stderr=stderr, context={"bucket": bucket}, ) + _mark_error_data_redacted(error) + raise error + @_redact_mount_lifecycle_error async def unapply( self, session: BaseSandboxSession, @@ -555,6 +587,7 @@ class S3FilesOptions: options: S3FilesOptions = Field(default_factory=S3FilesOptions) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -602,6 +635,7 @@ async def apply( context={"file_system_id": s3files_config.file_system_id}, ) + @_redact_mount_lifecycle_error async def unapply( self, session: BaseSandboxSession, @@ -880,6 +914,7 @@ async def _start_rclone_client( context={"type": config.mount_type}, ) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -954,6 +989,7 @@ async def apply( config_path=command_config_path, ) + @_redact_mount_lifecycle_error async def unapply( self, session: BaseSandboxSession, diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index c670c00fa7..bc1a5379e9 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -20,7 +20,7 @@ from ..tracing import custom_span, get_current_trace from ._mount_security import ( _manifest_has_configured_mount_authority, - _replace_mount_operation_error, + _replace_protected_mount_error, _validate_manifest_mount_provenance, redact_mount_error_data, validate_manifest_mount_credential_boundaries, @@ -559,7 +559,7 @@ def _process_manifest( ) mount_credential_exposure_policy = processed_manifest._mount_credential_exposure_policy for capability in capabilities: - safe_error: RuntimeError | None = None + safe_error: BaseException | None = None try: processed_manifest = capability.process_manifest(processed_manifest) mount_credential_exposure_policy = ( @@ -567,10 +567,10 @@ def _process_manifest( mount_credential_exposure_policy ) ) - except Exception as error: + except BaseException as error: if not _manifest_has_configured_mount_authority(processed_manifest): raise - safe_error = _replace_mount_operation_error(error) + safe_error = _replace_protected_mount_error(error) if safe_error is not None: capabilities = [] diff --git a/src/agents/sandbox/session/sandbox_client.py b/src/agents/sandbox/session/sandbox_client.py index 2d936a6f95..f887cf3827 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -1,12 +1,15 @@ from __future__ import annotations import abc +from collections.abc import Mapping from typing import Any, ClassVar, Generic, TypeVar, cast from pydantic import BaseModel, ConfigDict, model_serializer from ...exceptions import _raise_data_redacted_error -from .._mount_security import redact_mount_error_data_sync +from .._mount_security import ( + redact_mount_error_data_sync, +) from ..errors import MountConfigError from ..manifest import Manifest from ..snapshot import SnapshotBase, SnapshotSpec @@ -234,33 +237,44 @@ def _serialize_session_state(self, state: SandboxSessionState) -> dict[str, obje @staticmethod def _deserialize_session_state_payload( - payload: dict[str, object], + payload: Mapping[str, object], state_class: type[SandboxSessionState], ) -> SandboxSessionState: + from ...exceptions import _replace_data_redacted_process_control_error from .._mount_security import ( _redact_mount_state_validation_error, sanitize_raw_session_state_mount_authority, ) - safe_error: ValueError | None = None + safe_error: BaseException | None = None + persisted_payload: dict[str, object] | None = None try: sanitized, _redacted = sanitize_raw_session_state_mount_authority(payload) - if isinstance(sanitized, dict): + if not isinstance(sanitized, dict): + raise TypeError("sandbox session state payload must be a mapping") + persisted_payload = sanitized + if isinstance(payload, dict): payload.clear() payload.update(sanitized) - state = state_class.model_validate(payload) - except Exception as error: - safe_error = _redact_mount_state_validation_error( - error, - message="sandbox session state payload is invalid", - ) + persisted_payload = payload + state = state_class.model_validate(persisted_payload) + except BaseException as error: + safe_error = _replace_data_redacted_process_control_error(error) + if safe_error is None: + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state payload is invalid", + ) if safe_error is not None: - payload.clear() + if isinstance(payload, dict): + payload.clear() sanitized = cast(Any, None) + persisted_payload = None state_class = cast(Any, None) _raise_data_redacted_error(safe_error) - return SandboxSessionState._mark_persisted_path_grants(state, payload=payload) + assert persisted_payload is not None + return SandboxSessionState._mark_persisted_path_grants(state, payload=persisted_payload) @abc.abstractmethod def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: diff --git a/src/agents/sandbox/session/sandbox_session_state.py b/src/agents/sandbox/session/sandbox_session_state.py index 678fb64df6..43dea6cb2e 100644 --- a/src/agents/sandbox/session/sandbox_session_state.py +++ b/src/agents/sandbox/session/sandbox_session_state.py @@ -17,7 +17,9 @@ ) from typing_extensions import Self -from .._mount_security import redact_mount_error_data_sync +from .._mount_security import ( + redact_mount_error_data_sync, +) from ..manifest import Manifest from ..snapshot import SnapshotBase @@ -102,13 +104,16 @@ def parse(cls, payload: object) -> SandboxSessionState: payload = payload.model_dump() if isinstance(payload, dict): - from ...exceptions import _raise_data_redacted_error + from ...exceptions import ( + _raise_data_redacted_error, + _replace_data_redacted_process_control_error, + ) from .._mount_security import ( _redact_mount_state_validation_error, sanitize_raw_session_state_mount_authority, ) - safe_error: ValueError | None = None + safe_error: BaseException | None = None sanitized: object = None state_type: object = None subclass: SessionStateClass | None = None @@ -129,14 +134,16 @@ def parse(cls, payload: object) -> SandboxSessionState: subclass.model_validate(payload), payload=payload, ) - except Exception as error: + except BaseException as error: payload.clear() if isinstance(sanitized, dict): sanitized.clear() - safe_error = _redact_mount_state_validation_error( - error, - message="sandbox session state payload is invalid", - ) + safe_error = _replace_data_redacted_process_control_error(error) + if safe_error is None: + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state payload is invalid", + ) payload = cast(Any, None) sanitized = None @@ -161,18 +168,23 @@ def model_validate_json( ) -> Self: """Validate JSON without retaining malformed input in a public error.""" - from ...exceptions import _raise_data_redacted_error + from ...exceptions import ( + _raise_data_redacted_error, + _replace_data_redacted_process_control_error, + ) from .._mount_security import _redact_mount_state_validation_error decoded: object = None - safe_error: ValueError | None = None + safe_error: BaseException | None = None try: decoded = json.loads(json_data) - except Exception as error: - safe_error = _redact_mount_state_validation_error( - error, - message="sandbox session state JSON is invalid", - ) + except BaseException as error: + safe_error = _replace_data_redacted_process_control_error(error) + if safe_error is None: + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state JSON is invalid", + ) if safe_error is not None: if isinstance(decoded, dict | list): @@ -191,9 +203,18 @@ def model_validate_json( by_alias=by_alias, by_name=by_name, ) - except Exception: + except BaseException as error: + safe_error = _replace_data_redacted_process_control_error(error) json_data = cast(Any, None) - raise + if safe_error is not None: + _raise_data_redacted_error(safe_error) + if isinstance(error, Exception): + raise + safe_error = _redact_mount_state_validation_error( + error, + message="sandbox session state JSON is invalid", + ) + _raise_data_redacted_error(safe_error) @classmethod def _mark_persisted_path_grants( @@ -338,7 +359,7 @@ def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: ) data: dict[str, Any] | None = None - safe_error: Exception | None = None + safe_error: BaseException | None = None provenance_error = _manifest_mount_provenance_error(self.manifest) if provenance_error is not None: _mark_mount_validation_error(provenance_error) @@ -351,7 +372,7 @@ def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: cast(dict[str, Any], data).get("manifest") ) ) - except Exception as error: + except BaseException as error: if not _manifest_has_configured_mount_authority(self.manifest): raise safe_error = _redact_mount_serialization_error(error) @@ -380,37 +401,54 @@ def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: @model_validator(mode="wrap") @classmethod def _restore_mount_authority_marker(cls, value: Any, handler: Any) -> SandboxSessionState: - from ...exceptions import _raise_data_redacted_error + from ...exceptions import ( + _raise_data_redacted_error, + _replace_data_redacted_process_control_error, + ) from .._mount_security import ( REDACTED_MOUNT_AUTHORITY_KEY, + _manifest_has_configured_mount_authority, _redact_mount_state_validation_error, sanitize_raw_session_state_mount_authority, ) - marker = isinstance(value, Mapping) and value.get(REDACTED_MOUNT_AUTHORITY_KEY) is True + marker = False state: SandboxSessionState | None = None sanitized: object = None - safe_error: ValueError | None = None - if ( - isinstance(value, Mapping) - and "manifest" in value - and not isinstance(value.get("manifest"), Manifest) - ): - try: + safe_error: BaseException | None = None + redact_failure = True + try: + if isinstance(value, Mapping): + marker = value.get(REDACTED_MOUNT_AUTHORITY_KEY) is True + if ( + isinstance(value, Mapping) + and "manifest" in value + and not isinstance(value.get("manifest"), Manifest) + ): sanitized, redacted = sanitize_raw_session_state_mount_authority(value) marker = marker or redacted + redact_failure = marker state = handler(sanitized) - except Exception as error: - if isinstance(value, dict): - value.clear() - if isinstance(sanitized, dict): - sanitized.clear() + else: + manifest = value.get("manifest") if isinstance(value, Mapping) else None + redact_failure = marker or ( + isinstance(manifest, Manifest) + and _manifest_has_configured_mount_authority(manifest) + ) + state = handler(value) + except BaseException as error: + safe_error = _replace_data_redacted_process_control_error(error) + if safe_error is None and not redact_failure: + raise + if isinstance(value, dict): + value.clear() + if isinstance(sanitized, dict): + sanitized.clear() + if safe_error is None: safe_error = _redact_mount_state_validation_error( error, message="sandbox session state payload is invalid", ) - else: - state = handler(value) if safe_error is not None: value = cast(Any, None) diff --git a/tests/README.md b/tests/README.md index 59ef96afbf..f1f6175203 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,6 +43,10 @@ make tests-parallel Compare test counts, skips, warnings, assertions, and lifecycle coverage as well as elapsed time. Full-suite wall-clock results depend on host load and worker scheduling, so treat repeated focused measurements as the stronger evidence for an individual optimization. Run the repository's required verification stack after the final test changes. +Release compatibility contracts that inspect the current checkout belong in `tests/` when they are deterministic and in-process. Keep their combined serial focused runtime below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. + +The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable exported class or function signature and execution kind, and the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Review the resulting JSON diff and add newly documented properties to `public_properties`, newly intended submodule import paths to `canonical_imports`, and optional-dependency-free modules to `public_modules`; those policy decisions are deliberately not inferred from implementation modules. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. + ## Snapshots We use [inline-snapshots](https://15r10nk.github.io/inline-snapshot/latest/) for some tests. If your code adds new snapshot tests or breaks existing ones, you can fix/create them. After fixing/creating snapshots, run `make tests` again to verify the tests pass. diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 1ab368bee4..3fe1d0d93a 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -2997,7 +2997,6 @@ def test_build_mount_config_gcs_hmac(self) -> None: def test_build_mount_config_unsupported(self) -> None: from agents.extensions.sandbox.blaxel.mounts import _build_mount_config - from agents.sandbox.errors import MountConfigError # Use a MagicMock with a type attribute to simulate an unsupported mount. mount = MagicMock() @@ -3007,7 +3006,6 @@ def test_build_mount_config_unsupported(self) -> None: def test_assert_blaxel_session_wrong_type(self) -> None: from agents.extensions.sandbox.blaxel.mounts import _assert_blaxel_session - from agents.sandbox.errors import MountConfigError class _WrongSession: pass @@ -3206,7 +3204,6 @@ async def test_mount_s3_r2_sigv4(self) -> None: @pytest.mark.asyncio async def test_mount_s3_fails(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3 - from agents.sandbox.errors import MountConfigError session = _FakeMountSession() session._next_results = [ @@ -3371,7 +3368,6 @@ async def test_mount_gcs_anonymous(self) -> None: @pytest.mark.asyncio async def test_mount_gcs_fails(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs - from agents.sandbox.errors import MountConfigError session = _FakeMountSession() session._next_results = [ @@ -3481,7 +3477,6 @@ async def test_install_tool_with_apt(self) -> None: @pytest.mark.asyncio async def test_install_tool_fails_after_retries(self) -> None: from agents.extensions.sandbox.blaxel.mounts import _install_tool - from agents.sandbox.errors import MountConfigError session = _FakeMountSession() session._next_results = [ @@ -3913,7 +3908,6 @@ async def test_attach_drive_success(self) -> None: @pytest.mark.asyncio async def test_attach_drive_error(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountConfig, _attach_drive - from agents.sandbox.errors import MountConfigError sandbox = _FakeSandboxInstance() sandbox.drives.mount_error = RuntimeError("mount api error") @@ -3926,7 +3920,6 @@ async def test_attach_drive_error(self) -> None: @pytest.mark.asyncio async def test_attach_drive_no_drives_api(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountConfig, _attach_drive - from agents.sandbox.errors import MountConfigError class _NoDrives: pass @@ -3999,7 +3992,6 @@ class _NoDrives: @pytest.mark.asyncio async def test_drive_strategy_validate_wrong_mount_type(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountStrategy - from agents.sandbox.errors import MountConfigError strategy = BlaxelDriveMountStrategy() mount = MagicMock() @@ -4010,7 +4002,6 @@ async def test_drive_strategy_validate_wrong_mount_type(self) -> None: @pytest.mark.asyncio async def test_drive_strategy_validate_non_drive_mount(self) -> None: from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountStrategy - from agents.sandbox.errors import MountConfigError strategy = BlaxelDriveMountStrategy() mount = MagicMock() diff --git a/tests/fake_model.py b/tests/fake_model.py index f2fd23f143..ddbfadc9dc 100644 --- a/tests/fake_model.py +++ b/tests/fake_model.py @@ -78,8 +78,9 @@ def get_next_output(self) -> list[TResponseOutputItem] | Exception: return [] return self.turn_outputs.pop(0) - async def get_response( + def _record_turn_args( self, + *, system_instructions: str | None, input: str | list[TResponseInputItem], model_settings: ModelSettings, @@ -87,26 +88,53 @@ async def get_response( output_schema: AgentOutputSchemaBase | None, handoffs: list[Handoff], tracing: ModelTracing, - *, previous_response_id: str | None, conversation_id: str | None, prompt: Any | None, - ) -> ModelResponse: + ) -> None: turn_args = { "system_instructions": system_instructions, "input": input, "model_settings": model_settings, "tools": tools, "output_schema": output_schema, + "handoffs": handoffs, + "tracing": tracing, "previous_response_id": previous_response_id, "conversation_id": conversation_id, + "prompt": prompt, } - if self.first_turn_args is None: self.first_turn_args = turn_args.copy() - self.last_turn_args = turn_args + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: Any | None, + ) -> ModelResponse: + self._record_turn_args( + system_instructions=system_instructions, + input=input, + model_settings=model_settings, + tools=tools, + output_schema=output_schema, + handoffs=handoffs, + tracing=tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + with generation_span(disabled=not self.tracing_enabled) as span: output = self.get_next_output() @@ -158,20 +186,18 @@ async def stream_response( conversation_id: str | None = None, prompt: Any | None = None, ) -> AsyncIterator[TResponseStreamEvent]: - turn_args = { - "system_instructions": system_instructions, - "input": input, - "model_settings": model_settings, - "tools": tools, - "output_schema": output_schema, - "previous_response_id": previous_response_id, - "conversation_id": conversation_id, - } - - if self.first_turn_args is None: - self.first_turn_args = turn_args.copy() - - self.last_turn_args = turn_args + self._record_turn_args( + system_instructions=system_instructions, + input=input, + model_settings=model_settings, + tools=tools, + output_schema=output_schema, + handoffs=handoffs, + tracing=tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) with generation_span(disabled=not self.tracing_enabled) as span: output = self.get_next_output() if isinstance(output, Exception): @@ -360,8 +386,19 @@ def get_response_obj( output_tokens=usage.output_tokens if usage else 0, total_tokens=usage.total_tokens if usage else 0, input_tokens_details=InputTokensDetails.model_validate( - {"cache_write_tokens": 0, "cached_tokens": 0} + { + "cache_write_tokens": ( + getattr(usage.input_tokens_details, "cache_write_tokens", 0) if usage else 0 + ), + "cached_tokens": ( + getattr(usage.input_tokens_details, "cached_tokens", 0) if usage else 0 + ), + } + ), + output_tokens_details=OutputTokensDetails( + reasoning_tokens=( + getattr(usage.output_tokens_details, "reasoning_tokens", 0) if usage else 0 + ) ), - output_tokens_details=OutputTokensDetails(reasoning_tokens=0), ), ) diff --git a/tests/fixtures/released_api_contract.json b/tests/fixtures/released_api_contract.json new file mode 100644 index 0000000000..924ef767c5 --- /dev/null +++ b/tests/fixtures/released_api_contract.json @@ -0,0 +1,31386 @@ +{ + "baseline": "v0.19.4", + "baseline_commit": "9bfad15ab8297fbb2afe389c983a5cb573eeef56", + "callables": { + "Agent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "handoff_description" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "tools" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "mcp_servers" + }, + { + "default": { + "factory": "agents.agent.AgentBase.", + "kind": "factory" + }, + "init": true, + "name": "mcp_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "instructions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "prompt" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "handoffs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "model" + }, + { + "default": { + "factory": "agents.models.default_models.get_default_model_settings", + "kind": "factory" + }, + "init": true, + "name": "model_settings" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "input_guardrails" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "run_llm_again" + }, + "init": true, + "name": "tool_use_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "reset_tool_choice" + } + ], + "kind": "class", + "members": { + "as_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "custom_output_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_enabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_stream" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_turns" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "identity": "agents.tool.default_tool_error_function", + "kind": "callable" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_builder" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "include_input_schema" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "get_all_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_mcp_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_system_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoff_description" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_servers" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "instructions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_guardrails" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "run_llm_again" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_use_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reset_tool_choice" + } + ] + }, + "AgentBase": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "handoff_description" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "tools" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "mcp_servers" + }, + { + "default": { + "factory": "agents.agent.AgentBase.", + "kind": "factory" + }, + "init": true, + "name": "mcp_config" + } + ], + "kind": "class", + "members": { + "get_all_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_mcp_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoff_description" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_servers" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_config" + } + ] + }, + "AgentHookContext": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "factory": "agents.usage.Usage", + "kind": "factory" + }, + "init": true, + "name": "usage" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "turn_input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_input" + } + ], + "kind": "class", + "members": { + "approve_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_approve" + } + ] + }, + "get_approval_status": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "get_rejection_message": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "is_tool_approved": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + } + ] + }, + "reject_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_reject" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "rejection_message" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "turn_input" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_approvals" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_input" + } + ] + }, + "AgentOutputSchema": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_type" + } + ], + "kind": "class", + "members": { + "is_plain_text": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "is_strict_json_schema": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "json_schema": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "name": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "validate_json": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_str" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strict_json_schema" + } + ] + }, + "AgentOutputSchemaBase": { + "dataclass_fields": [], + "kind": "class", + "members": { + "is_plain_text": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "is_strict_json_schema": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "json_schema": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "name": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "validate_json": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_str" + } + ] + } + }, + "parameters": [] + }, + "AgentSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "metadata" + } + ] + }, + "AgentToolInvocation": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_call_id" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_arguments" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_arguments" + } + ] + }, + "AgentUpdatedStreamEvent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "new_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "agent_updated_stream_event" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "new_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "agent_updated_stream_event" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "AgentsException": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "args" + } + ] + }, + "ApplyPatchEditor": { + "dataclass_fields": [], + "kind": "class", + "members": { + "create_file": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operation" + } + ] + }, + "delete_file": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operation" + } + ] + }, + "update_file": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operation" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "args" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "ApplyPatchOperation": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "diff" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "move_to" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "diff" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "move_to" + } + ] + }, + "ApplyPatchResult": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "status" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "output" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "status" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "ApplyPatchTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "editor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "apply_patch" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "on_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "allowed_callers" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "editor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "apply_patch" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "allowed_callers" + } + ] + }, + "ApplyPatchToolCustomDataContext": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "run_context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "operations" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "status" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "status" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + } + ] + }, + "AsyncComputer": { + "dataclass_fields": [], + "kind": "class", + "members": { + "click": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "button" + } + ] + }, + "double_click": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + } + ] + }, + "drag": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "keypress": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "keys" + } + ] + }, + "move": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + } + ] + }, + "screenshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "scroll": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scroll_x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scroll_y" + } + ] + }, + "type": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "text" + } + ] + }, + "wait": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [] + }, + "CodeInterpreterTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_config" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_config" + } + ] + }, + "CompactionItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "compaction_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "compaction_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "Computer": { + "dataclass_fields": [], + "kind": "class", + "members": { + "click": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "button" + } + ] + }, + "double_click": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + } + ] + }, + "drag": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "keypress": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "keys" + } + ] + }, + "move": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + } + ] + }, + "screenshot": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "scroll": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "y" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scroll_x" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scroll_y" + } + ] + }, + "type": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "text" + } + ] + }, + "wait": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [] + }, + "ComputerProvider": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "create" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "dispose" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "create" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dispose" + } + ] + }, + "ComputerTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "computer" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "on_safety_check" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "custom_data_extractor" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "computer" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_safety_check" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "custom_data_extractor" + } + ] + }, + "ComputerToolCustomDataContext": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "run_context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_call" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + } + ] + }, + "CustomSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "CustomTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "description" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "on_invoke_tool" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "format" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "on_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "defer_loading" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "allowed_callers" + }, + { + "default": { + "kind": "required" + }, + "init": false, + "name": "tool_config" + } + ], + "kind": "class", + "members": { + "runtime_needs_approval": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "runtime_on_approval": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "description" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_invoke_tool" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "format" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "defer_loading" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "allowed_callers" + } + ] + }, + "CustomToolCustomDataContext": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + } + ] + }, + "FileSearchTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "vector_store_ids" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "max_num_results" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "include_search_results" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "ranking_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "filters" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "vector_store_ids" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_num_results" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "include_search_results" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ranking_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "filters" + } + ] + }, + "FunctionSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_data" + } + ] + }, + "FunctionTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "description" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "params_json_schema" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "on_invoke_tool" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "strict_json_schema" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "is_enabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_input_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "timeout_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "error_as_result" + }, + "init": true, + "name": "timeout_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "timeout_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "defer_loading" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "allowed_callers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "output_json_schema" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "description" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "params_json_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_invoke_tool" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strict_json_schema" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_enabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_input_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "error_as_result" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "defer_loading" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "allowed_callers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "output_json_schema" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_output_type_adapter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "_use_default_failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "_is_agent_tool" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_agent_tool_default_identity" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "_is_codex_tool" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_agent_instance" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_tool_namespace_description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_mcp_title" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_tool_origin" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "_emit_tool_origin" + } + ] + }, + "FunctionToolCustomDataContext": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + } + ] + }, + "FunctionToolResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "run_item" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "interruptions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "agent_run_result" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_item" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "interruptions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_run_result" + } + ] + }, + "GenerateDynamicPromptData": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "GenerationSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + } + ] + }, + "GuardrailFunctionOutput": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_info" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tripwire_triggered" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_info" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tripwire_triggered" + } + ] + }, + "GuardrailSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "triggered" + } + ] + }, + "Handoff": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_description" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input_json_schema" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "on_invoke_handoff" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "input_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "nest_handoff_history" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "strict_json_schema" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "is_enabled" + } + ], + "kind": "class", + "members": { + "default_tool_description": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "default_tool_name": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "get_transfer_message": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_description" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_json_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_invoke_handoff" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "nest_handoff_history" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strict_json_schema" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_enabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_default_tool_identity" + } + ] + }, + "HandoffCallItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "handoff_call_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "handoff_call_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "HandoffInputData": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input_history" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "pre_handoff_items" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "new_items" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "run_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "input_items" + } + ], + "kind": "class", + "members": { + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_history" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pre_handoff_items" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "new_items" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_items" + } + ] + }, + "HandoffOutputItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "source_agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "target_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "handoff_output_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "source_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "target_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "handoff_output_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "HandoffSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "from_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "to_agent" + } + ] + }, + "HostedMCPTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "on_approval_request" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_approval_request" + } + ] + }, + "ImageGenerationTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_config" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_config" + } + ] + }, + "InputGuardrail": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "run_in_parallel" + } + ], + "kind": "class", + "members": { + "get_name": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "run": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_in_parallel" + } + ] + }, + "InputGuardrailResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "InputGuardrailTripwireTriggered": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail_result" + } + ] + }, + "ItemHelpers": { + "dataclass_fields": [], + "kind": "class", + "members": { + "copy_tool_call_caller": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_item" + } + ] + }, + "extract_last_content": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "extract_last_text": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "extract_refusal": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "extract_text": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "input_to_new_input_list": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + } + ] + }, + "text_message_output": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "text_message_outputs": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "tool_call_output_item": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "output_json_schema" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "output_type_adapter" + } + ] + } + }, + "parameters": [] + }, + "LocalShellCommandRequest": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "LocalShellTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "executor" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "executor" + } + ] + }, + "MCPApprovalRequestItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_approval_request_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_approval_request_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "MCPApprovalResponseItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_approval_response_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_approval_response_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "MCPListToolsItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_list_tools_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_list_tools_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "MCPListToolsSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "server" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "result" + } + ] + }, + "MCPToolApprovalRequest": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "MCPToolCancellationError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "MaxTurnsExceeded": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "MessageOutputItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "message_output_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "message_output_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "Model": { + "dataclass_fields": [], + "kind": "class", + "members": { + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_response": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "prompt" + } + ] + }, + "get_retry_advice": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request" + } + ] + }, + "stream_response": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "prompt" + } + ] + } + }, + "parameters": [] + }, + "ModelBehaviorError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "ModelProvider": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_model": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_name" + } + ] + } + }, + "parameters": [] + }, + "ModelRefusalError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "refusal" + } + ] + }, + "ModelResponse": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "usage" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "request_id" + } + ], + "kind": "class", + "members": { + "to_input_items": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request_id" + } + ] + }, + "ModelRetryAdvice": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "suggested" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retry_after" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "replay_safety" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "reason" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "normalized" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "suggested" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_after" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "replay_safety" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reason" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "normalized" + } + ] + }, + "ModelRetryAdviceRequest": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "attempt" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "stream" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "conversation_id" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "attempt" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "stream" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + } + ] + }, + "ModelRetryBackoffSettings": { + "dataclass_fields": [ + { + "default": { + "kind": "repr", + "type": "pydantic.fields.FieldInfo", + "value": "FieldInfo(annotation=NoneType, required=False, default=None, metadata=[Ge(ge=0)])" + }, + "init": true, + "name": "initial_delay" + }, + { + "default": { + "kind": "repr", + "type": "pydantic.fields.FieldInfo", + "value": "FieldInfo(annotation=NoneType, required=False, default=None, metadata=[Ge(ge=0)])" + }, + "init": true, + "name": "max_delay" + }, + { + "default": { + "kind": "repr", + "type": "pydantic.fields.FieldInfo", + "value": "FieldInfo(annotation=NoneType, required=False, default=None, metadata=[Ge(ge=0)])" + }, + "init": true, + "name": "multiplier" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "jitter" + } + ], + "kind": "class", + "members": { + "to_json_dict": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "initial_delay" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_delay" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "multiplier" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "jitter" + } + ] + }, + "ModelRetryNormalizedError": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "status_code" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "error_code" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "message" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "request_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retry_after" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "is_abort" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "is_network_error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "is_timeout" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "status_code" + }, + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error_code" + }, + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request_id" + }, + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_after" + }, + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_abort" + }, + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_network_error" + }, + { + "default": { + "identity": "agents.retry._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_timeout" + } + ] + }, + "ModelRetrySettings": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "max_retries" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "backoff" + }, + { + "default": { + "kind": "repr", + "type": "pydantic.fields.FieldInfo", + "value": "FieldInfo(annotation=NoneType, required=False, default=None, exclude=True, repr=False)" + }, + "init": true, + "name": "policy" + } + ], + "kind": "class", + "members": { + "to_json_dict": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_retries" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "backoff" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "policy" + } + ] + }, + "ModelSettings": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "temperature" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "top_p" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "frequency_penalty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "presence_penalty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_choice" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "parallel_tool_calls" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "truncation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "max_tokens" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "reasoning" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "verbosity" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "store" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "prompt_cache_retention" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "include_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "response_include" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "top_logprobs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "extra_query" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "extra_body" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "extra_headers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "extra_args" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retry" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "context_management" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "prompt_cache_options" + } + ], + "kind": "class", + "members": { + "resolve": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "override" + } + ] + }, + "to_json_dict": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_traceable_dict": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "temperature" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "top_p" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "frequency_penalty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "presence_penalty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_choice" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parallel_tool_calls" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "truncation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_tokens" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reasoning" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "verbosity" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "store" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt_cache_retention" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "include_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response_include" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "top_logprobs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "extra_query" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "extra_body" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "extra_headers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "extra_args" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context_management" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt_cache_options" + } + ] + }, + "ModelTracing": { + "dataclass_fields": [], + "enum_members": [ + { + "name": "DISABLED", + "value": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + } + }, + { + "name": "ENABLED", + "value": { + "kind": "literal", + "type": "builtins.int", + "value": 1 + } + }, + { + "name": "ENABLED_WITHOUT_DATA", + "value": { + "kind": "literal", + "type": "builtins.int", + "value": 2 + } + } + ], + "kind": "class", + "members": { + "include_data": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "is_disabled": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "value" + } + ] + }, + "MultiProvider": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_model": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_name" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "provider_map" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_api_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_base_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_organization" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_project" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_use_responses" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_use_responses_websocket" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "openai_strict_feature_validation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_websocket_base_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "alias" + }, + "kind": "KEYWORD_ONLY", + "name": "openai_prefix_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "error" + }, + "kind": "KEYWORD_ONLY", + "name": "unknown_prefix_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_agent_registration" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_responses_websocket_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "openai_buffer_streamed_tool_calls" + } + ] + }, + "OpenAIAgentRegistrationConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "harness_id" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "harness_id" + } + ] + }, + "OpenAIChatCompletionsModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_response": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + } + ] + }, + "get_retry_advice": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request" + } + ] + }, + "stream_response": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "openai_client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "should_replay_reasoning_content" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strict_feature_validation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "buffer_streamed_tool_calls" + } + ] + }, + "OpenAIConversationsSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session_settings" + } + ] + }, + "OpenAIProvider": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_model": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_name" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "api_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "base_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "websocket_base_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "openai_client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "organization" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "project" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "use_responses" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "use_responses_websocket" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "strict_feature_validation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "agent_registration" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "responses_websocket_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "buffer_streamed_tool_calls" + } + ] + }, + "OpenAIResponsesCompactionAwareSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "run_compaction": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "args" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "args" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "OpenAIResponsesCompactionSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "run_compaction": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "args" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "underlying_session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-4.1" + }, + "kind": "KEYWORD_ONLY", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "auto" + }, + "kind": "KEYWORD_ONLY", + "name": "compaction_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "should_trigger_compaction" + } + ] + }, + "OpenAIResponsesModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_response": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + } + ] + }, + "get_retry_advice": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request" + } + ] + }, + "stream_response": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "openai_client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "model_is_explicit" + } + ] + }, + "OpenAIResponsesWSModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_response": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + } + ] + }, + "get_retry_advice": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request" + } + ] + }, + "stream_response": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "openai_client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "model_is_explicit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "websocket_options" + } + ] + }, + "OutputGuardrail": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "name" + } + ], + "kind": "class", + "members": { + "get_name": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "run": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_output" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + } + ] + }, + "OutputGuardrailResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent_output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "OutputGuardrailTripwireTriggered": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail_result" + } + ] + }, + "ProgrammaticToolCallingTool": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [] + }, + "RawResponsesStreamEvent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "raw_response_event" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "raw_response_event" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "ReasoningItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "reasoning_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "reasoning_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "ResponseSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + } + ] + }, + "ResponsesWebSocketSession": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "provider" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "run_config" + } + ], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "run": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "run_streamed": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "provider" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_config" + } + ] + }, + "RetryDecision": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "retry" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "delay" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "reason" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "delay" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reason" + } + ] + }, + "RetryPolicyContext": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "attempt" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "max_retries" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "stream" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "normalized" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "provider_advice" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "attempt" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_retries" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "stream" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "normalized" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "provider_advice" + } + ] + }, + "RunConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "model" + }, + { + "default": { + "factory": "agents.models.multi_provider.MultiProvider", + "kind": "factory" + }, + "init": true, + "name": "model_provider" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "handoff_input_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "nest_handoff_history" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "handoff_history_mapper" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "input_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "tracing_disabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tracing" + }, + { + "default": { + "factory": "agents.run_config._default_trace_include_sensitive_data", + "kind": "factory" + }, + "init": true, + "name": "trace_include_sensitive_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "Agent workflow" + }, + "init": true, + "name": "workflow_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "trace_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "group_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "trace_metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "session_input_callback" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "call_model_input_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_error_formatter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "session_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "reasoning_item_id_policy" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "sandbox" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_execution" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "raise_error" + }, + "init": true, + "name": "tool_not_found_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "warn" + }, + "init": true, + "name": "tool_name_collision_policy" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_provider" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoff_input_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "nest_handoff_history" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoff_history_mapper" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing_disabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "Agent workflow" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workflow_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "group_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session_input_callback" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_model_input_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_error_formatter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reasoning_item_id_policy" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sandbox" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_execution" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "raise_error" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_not_found_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "warn" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name_collision_policy" + } + ] + }, + "RunContextWrapper": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "factory": "agents.usage.Usage", + "kind": "factory" + }, + "init": true, + "name": "usage" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "turn_input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_input" + } + ], + "kind": "class", + "members": { + "approve_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_approve" + } + ] + }, + "get_approval_status": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "get_rejection_message": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "is_tool_approved": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + } + ] + }, + "reject_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_reject" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "rejection_message" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "turn_input" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_approvals" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_input" + } + ] + }, + "RunErrorData": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "history" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "last_agent" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "history" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "last_agent" + } + ] + }, + "RunErrorDetails": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "last_agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context_wrapper" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_guardrail_results" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "tool_input_guardrail_results" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "tool_output_guardrail_results" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "last_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context_wrapper" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_guardrail_results" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_input_guardrail_results" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_output_guardrail_results" + } + ] + }, + "RunErrorHandlerInput": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "run_data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_data" + } + ] + }, + "RunErrorHandlerResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "final_output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "include_in_history" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "final_output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "include_in_history" + } + ] + }, + "RunItemStreamEvent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "run_item_stream_event" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "run_item_stream_event" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "RunResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "final_output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context_wrapper" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 10 + }, + "init": true, + "name": "max_turns" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "interruptions" + } + ], + "kind": "class", + "members": { + "final_output_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cls" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raise_if_incorrect_type" + } + ] + }, + "release_agents": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "release_new_items" + } + ] + }, + "to_input_list": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "preserve_all" + }, + "kind": "KEYWORD_ONLY", + "name": "mode" + } + ] + }, + "to_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "final_output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context_wrapper" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_last_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_last_processed_response" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_tool_use_tracker_snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_current_turn_persisted_item_count" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_current_turn" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_model_input_items" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_original_input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_auto_previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 10 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_turns" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "interruptions" + } + ] + }, + "RunResultStreaming": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "final_output" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context_wrapper" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "current_agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "current_turn" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "max_turns" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "trace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "is_complete" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "run_loop_task" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "interruptions" + } + ], + "kind": "class", + "members": { + "cancel": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "immediate" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mode" + } + ] + }, + "ensure_sandbox_cleanup_on_completion": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "final_output_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cls" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raise_if_incorrect_type" + } + ] + }, + "release_agents": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "release_new_items" + } + ] + }, + "stream_events": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [] + }, + "to_input_list": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "preserve_all" + }, + "kind": "KEYWORD_ONLY", + "name": "mode" + } + ] + }, + "to_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "new_items" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_responses" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "final_output" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_input_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_output_guardrail_results" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context_wrapper" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "current_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "current_turn" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_turns" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_current_agent_output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_complete" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_model_input_items" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_event_queue" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_input_guardrail_queue" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_loop_task" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_input_guardrails_task" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_triggered_input_guardrail_result" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_output_guardrails_task" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_stored_exception" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "none" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_cancel_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_last_processed_response" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "interruptions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_waiting_on_event_queue" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_current_turn_persisted_item_count" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_stream_input_persisted" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_original_input_for_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_max_turns_handled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_original_input" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_tool_use_tracker_snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_state" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_auto_previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_run_impl_task" + } + ] + }, + "RunState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "approve": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_approve" + } + ] + }, + "from_json": { + "binding": "static", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "initial_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state_json" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context_deserializer" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "strict_context" + } + ] + }, + "from_string": { + "binding": "static", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "initial_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state_string" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context_deserializer" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "strict_context" + } + ] + }, + "get_interruptions": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "get_tool_use_tracker_snapshot": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "reject": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_reject" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "rejection_message" + } + ] + }, + "set_reasoning_item_id_policy": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "policy" + } + ] + }, + "set_tool_use_tracker_snapshot": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot" + } + ] + }, + "set_trace": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace" + } + ] + }, + "to_json": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context_serializer" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "strict_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "include_tracing_api_key" + } + ] + }, + "to_string": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context_serializer" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "strict_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "include_tracing_api_key" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "original_input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 10 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_turns" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "auto_previous_response_id" + } + ] + }, + "Runner": { + "dataclass_fields": [], + "kind": "class", + "members": { + "run": { + "binding": "class", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 10 + }, + "kind": "KEYWORD_ONLY", + "name": "max_turns" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "error_handlers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "auto_previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + } + ] + }, + "run_streamed": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 10 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_turns" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "auto_previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "error_handlers" + } + ] + }, + "run_sync": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 10 + }, + "kind": "KEYWORD_ONLY", + "name": "max_turns" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "error_handlers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "auto_previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + } + ] + } + }, + "parameters": [] + }, + "SQLiteSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "close": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": ":memory:" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "db_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "agent_sessions" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sessions_table" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "agent_messages" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "messages_table" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session_settings" + } + ] + }, + "Session": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "args" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "SessionABC": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [] + }, + "SessionSettings": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "limit" + } + ], + "kind": "class", + "members": { + "resolve": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "override" + } + ] + }, + "to_dict": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "ShellActionRequest": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "commands" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "timeout_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "max_output_length" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "commands" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_output_length" + } + ] + }, + "ShellCallData": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "call_id" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "action" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "status" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "raw" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "action" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "status" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw" + } + ] + }, + "ShellCallOutcome": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "exit_code" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exit_code" + } + ] + }, + "ShellCommandOutput": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "" + }, + "init": true, + "name": "stdout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "" + }, + "init": true, + "name": "stderr" + }, + { + "default": { + "factory": "agents.tool.ShellCommandOutput.", + "kind": "factory" + }, + "init": true, + "name": "outcome" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "provider_data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "stdout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "stderr" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "outcome" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "provider_data" + } + ] + }, + "ShellCommandRequest": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ShellResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "max_output_length" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "provider_data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_output_length" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "provider_data" + } + ] + }, + "ShellTool": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "executor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "shell" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "on_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "environment" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "allowed_callers" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "executor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "shell" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "environment" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "allowed_callers" + } + ] + }, + "Span": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "finish": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reset_current" + } + ] + }, + "set_error": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + } + ] + }, + "start": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mark_as_current" + } + ] + } + }, + "parameters": [] + }, + "SpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [] + }, + "SpeechGroupSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + } + ] + }, + "SpeechSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pcm" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_format" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "first_content_at" + } + ] + }, + "TaskSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "metadata" + } + ] + }, + "ToolApprovalItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_approval_item" + }, + "init": true, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_origin" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_lookup_key" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_approval_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_origin" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "_allow_bare_name_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "ToolCallItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_call_item" + }, + "init": true, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "title" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_origin" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_call_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "title" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_origin" + } + ] + }, + "ToolCallOutputItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_call_output_item" + }, + "init": true, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_origin" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "custom_data" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_call_output_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_origin" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "custom_data" + } + ] + }, + "ToolErrorFormatterArgs": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "kind" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_type" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "call_id" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "default_message" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "run_context" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "kind" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_type" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "default_message" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "ToolExecutionConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "max_function_tool_concurrency" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "pre_approval_tool_input_guardrails" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_function_tool_concurrency" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pre_approval_tool_input_guardrails" + } + ] + }, + "ToolGuardrailFunctionOutput": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_info" + }, + { + "default": { + "factory": "agents.tool_guardrails.ToolGuardrailFunctionOutput.", + "kind": "factory" + }, + "init": true, + "name": "behavior" + } + ], + "kind": "class", + "members": { + "allow": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_info" + } + ] + }, + "raise_exception": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_info" + } + ] + }, + "reject_content": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_info" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_info" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "behavior" + } + ] + }, + "ToolInputGuardrail": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "name" + } + ], + "kind": "class", + "members": { + "get_name": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "run": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + } + ] + }, + "ToolInputGuardrailData": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "ToolInputGuardrailResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "ToolInputGuardrailTripwireTriggered": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "ToolOrigin": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "mcp_server_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "agent_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "agent_tool_name" + } + ], + "kind": "class", + "members": { + "from_json_dict": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "to_json_dict": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_server_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_tool_name" + } + ] + }, + "ToolOriginType": { + "dataclass_fields": [], + "enum_members": [ + { + "name": "FUNCTION", + "value": { + "kind": "literal", + "type": "builtins.str", + "value": "function" + } + }, + { + "name": "MCP", + "value": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp" + } + }, + { + "name": "AGENT_AS_TOOL", + "value": { + "kind": "literal", + "type": "builtins.str", + "value": "agent_as_tool" + } + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "value" + } + ] + }, + "ToolOutputFileContent": { + "dataclass_fields": [], + "kind": "class", + "members": { + "check_at_least_one_required_field": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "file" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "file_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "file_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "file_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "filename" + } + ] + }, + "ToolOutputGuardrail": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "name" + } + ], + "kind": "class", + "members": { + "get_name": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "run": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + } + ] + }, + "ToolOutputGuardrailData": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "ToolOutputGuardrailResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "ToolOutputGuardrailTripwireTriggered": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "guardrail" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "ToolOutputImage": { + "dataclass_fields": [], + "kind": "class", + "members": { + "check_at_least_one_required_field": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "image" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "image_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "file_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "detail" + } + ] + }, + "ToolOutputText": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "text" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "text" + } + ] + }, + "ToolSearchCallItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_search_call_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_search_call_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "ToolSearchOutputItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_search_output_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tool_search_output_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "ToolSearchTool": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "execution" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "parameters" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "execution" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parameters" + } + ] + }, + "ToolTimeoutError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout_seconds" + } + ] + }, + "ToolsToFinalOutputResult": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "is_final_output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "final_output" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_final_output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "final_output" + } + ] + }, + "Trace": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "finish": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reset_current" + } + ] + }, + "start": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mark_as_current" + } + ] + }, + "to_json": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "include_tracing_api_key" + } + ] + } + }, + "parameters": [] + }, + "TracingProcessor": { + "dataclass_fields": [], + "kind": "class", + "members": { + "force_flush": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "on_span_end": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span" + } + ] + }, + "on_span_start": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span" + } + ] + }, + "on_trace_end": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace" + } + ] + }, + "on_trace_start": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace" + } + ] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [] + }, + "TranscriptionSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pcm" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_format" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_config" + } + ] + }, + "TurnSpanData": { + "dataclass_fields": [], + "kind": "class", + "members": { + "export": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "turn" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "metadata" + } + ] + }, + "Usage": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "init": true, + "name": "requests" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "init": true, + "name": "input_tokens" + }, + { + "default": { + "factory": "agents.usage._make_input_tokens_details", + "kind": "factory" + }, + "init": true, + "name": "input_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "init": true, + "name": "output_tokens" + }, + { + "default": { + "factory": "agents.usage.Usage.", + "kind": "factory" + }, + "init": true, + "name": "output_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "init": true, + "name": "total_tokens" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "request_usage_entries" + } + ], + "kind": "class", + "members": { + "add": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "other" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "requests" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_tokens" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_tokens" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "total_tokens" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request_usage_entries" + } + ] + }, + "UserError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "WebSearchTool": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "user_location" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "filters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "medium" + }, + "init": true, + "name": "search_context_size" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "external_web_access" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user_location" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "filters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "medium" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "search_context_size" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "external_web_access" + } + ] + }, + "add_trace_processor": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_processor" + } + ] + }, + "agent_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "agents.extensions.handoff_prompt.prompt_with_handoff_instructions": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + } + ] + }, + "agents.extensions.memory.AdvancedSQLiteSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "close": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "create_branch_from_content": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "search_term" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_name" + } + ] + }, + "create_branch_from_turn": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "turn_number" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_name" + } + ] + }, + "delete_branch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "force" + } + ] + }, + "find_turns_by_content": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "search_term" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + }, + "get_conversation_by_turns": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + }, + "get_conversation_turns": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + }, + "get_session_usage": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + }, + "get_tool_usage": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + }, + "get_turn_usage": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user_turn_number" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + }, + "list_branches": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "store_run_usage": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "result" + } + ] + }, + "switch_to_branch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "branch_id" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": ":memory:" + }, + "kind": "KEYWORD_ONLY", + "name": "db_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "create_tables" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "logger" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "agents.extensions.memory.EncryptedSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_items": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "limit" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "underlying_session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "encryption_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 600 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ttl" + } + ] + }, + "agents.extensions.sandbox.BlaxelDriveMountStrategy": { + "dataclass_fields": [], + "kind": "class", + "members": { + "activate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "deactivate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "restore_after_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "supports_native_snapshot_detach": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "teardown_for_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "validate_mount": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "blaxel_drive" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.extensions.sandbox.BlaxelSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "token" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.extensions.sandbox.BlaxelSandboxClientOptions": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "image" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "memory" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "region" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "labels" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "ttl" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "exposed_port_public" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "init": true, + "name": "exposed_port_url_ttl_s" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "image" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "memory" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "region" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "labels" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ttl" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_port_public" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_port_url_ttl_s" + } + ] + }, + "agents.extensions.sandbox.DaytonaCloudBucketMountStrategy": { + "dataclass_fields": [], + "kind": "class", + "members": { + "activate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "deactivate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "restore_after_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "supports_native_snapshot_detach": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "teardown_for_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "validate_mount": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "daytona_cloud_bucket" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "model", + "type": "agents.sandbox.entries.mounts.patterns.RcloneMountPattern", + "value": { + "items": [ + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "type" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "rclone" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "mode" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "remote_name" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "extra_args" + }, + { + "items": [], + "kind": "sequence", + "type": "builtins.list" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_addr" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_mount_options" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "config_file_path" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ] + ], + "kind": "mapping", + "type": "builtins.dict" + } + }, + "kind": "KEYWORD_ONLY", + "name": "pattern" + } + ] + }, + "agents.extensions.sandbox.DaytonaSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "api_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "api_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.extensions.sandbox.DaytonaSandboxClientOptions": { + "dataclass_fields": [], + "kind": "class", + "members": { + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sandbox_snapshot_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "image" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "create_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "start_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "auto_stop_interval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeouts" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_port_url_ttl_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "daytona" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.extensions.sandbox.DaytonaSandboxSessionState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_path_grants_rebound": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "rebind_persisted_path_grants": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "daytona" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_snapshot_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "image" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "base_env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "kind": "KEYWORD_ONLY", + "name": "create_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "kind": "KEYWORD_ONLY", + "name": "start_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "KEYWORD_ONLY", + "name": "auto_stop_interval" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_port_url_ttl_s" + } + ] + }, + "agents.extensions.sandbox.E2BSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.extensions.sandbox.E2BSandboxClientOptions": { + "dataclass_fields": [], + "kind": "class", + "members": { + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sandbox_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "template" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "envs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "secure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "allow_internet_access" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pause_on_exit" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pause" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "auto_resume" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.extensions.sandbox.E2BSandboxSessionState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_path_grants_rebound": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "rebind_persisted_path_grants": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "agents.extensions.sandbox.e2b.sandbox.E2BSandboxType", + "value": "e2b" + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "template" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "metadata" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "base_envs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "secure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "allow_internet_access" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pause" + }, + "kind": "KEYWORD_ONLY", + "name": "on_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "auto_resume" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mcp" + } + ] + }, + "agents.extensions.sandbox.E2BSandboxType": { + "dataclass_fields": [], + "enum_members": [ + { + "name": "CODE_INTERPRETER", + "value": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b_code_interpreter" + } + }, + { + "name": "E2B", + "value": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b" + } + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "value" + } + ] + }, + "agents.lifecycle.RunHooksBase": { + "dataclass_fields": [], + "kind": "class", + "members": { + "on_agent_end": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + } + ] + }, + "on_agent_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "on_handoff": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "from_agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "to_agent" + } + ] + }, + "on_llm_end": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response" + } + ] + }, + "on_llm_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_prompt" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_items" + } + ] + }, + "on_tool_end": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "result" + } + ] + }, + "on_tool_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + } + ] + } + }, + "parameters": [] + }, + "agents.mcp.MCPServer": { + "dataclass_fields": [], + "kind": "class", + "members": { + "call_tool": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "meta" + } + ] + }, + "cleanup": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "connect": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "get_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + } + ] + }, + "list_prompts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "list_resource_templates": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_resources": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "read_resource": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "uri" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_structured_content" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "require_approval" + }, + { + "default": { + "identity": "agents.mcp.server._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_meta_resolver" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "custom_data_extractor" + } + ] + }, + "agents.mcp.MCPServerManager": { + "dataclass_fields": [], + "kind": "class", + "members": { + "cleanup_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "connect_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "reconnect": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "failed_only" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "servers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 10.0 + }, + "kind": "KEYWORD_ONLY", + "name": "connect_timeout_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 10.0 + }, + "kind": "KEYWORD_ONLY", + "name": "cleanup_timeout_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "drop_failed_servers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "suppress_cancelled_error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "connect_in_parallel" + } + ] + }, + "agents.mcp.MCPServerSse": { + "dataclass_fields": [], + "kind": "class", + "members": { + "call_tool": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "meta" + } + ] + }, + "cleanup": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "connect": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "create_streams": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "get_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + } + ] + }, + "invalidate_tools_cache": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "list_prompts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "list_resource_templates": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_resources": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "read_resource": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "uri" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "params" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cache_tools_list" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 5 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "client_session_timeout_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_structured_content" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_retry_attempts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 1.0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_base" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message_handler" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "require_approval" + }, + { + "default": { + "identity": "agents.mcp.server._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_meta_resolver" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "custom_data_extractor" + } + ] + }, + "agents.mcp.MCPServerStdio": { + "dataclass_fields": [], + "kind": "class", + "members": { + "call_tool": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "meta" + } + ] + }, + "cleanup": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "connect": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "create_streams": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "get_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + } + ] + }, + "invalidate_tools_cache": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "list_prompts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "list_resource_templates": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_resources": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "read_resource": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "uri" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "params" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cache_tools_list" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 5 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "client_session_timeout_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_structured_content" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_retry_attempts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 1.0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_base" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message_handler" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "require_approval" + }, + { + "default": { + "identity": "agents.mcp.server._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_meta_resolver" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "custom_data_extractor" + } + ] + }, + "agents.mcp.MCPServerStreamableHttp": { + "dataclass_fields": [], + "kind": "class", + "members": { + "call_tool": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "meta" + } + ] + }, + "cleanup": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "connect": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "create_streams": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "get_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + } + ] + }, + "invalidate_tools_cache": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "list_prompts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "list_resource_templates": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_resources": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cursor" + } + ] + }, + "list_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + }, + "read_resource": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "uri" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "params" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cache_tools_list" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 5 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "client_session_timeout_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_structured_content" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_retry_attempts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 1.0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_base" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message_handler" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "require_approval" + }, + { + "default": { + "identity": "agents.mcp.server._UNSET", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_meta_resolver" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "custom_data_extractor" + } + ] + }, + "agents.mcp.util.MCPUtil": { + "dataclass_fields": [], + "kind": "class", + "members": { + "get_all_function_tools": { + "binding": "class", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "servers" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "convert_schemas_to_strict" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "identity": "agents.tool.default_tool_error_function", + "kind": "callable" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "include_server_in_tool_names" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reserved_tool_names" + } + ] + }, + "get_function_tools": { + "binding": "class", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "server" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "convert_schemas_to_strict" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "identity": "agents.tool.default_tool_error_function", + "kind": "callable" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "include_server_in_tool_names" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reserved_tool_names" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "server_index" + } + ] + }, + "invoke_mcp_tool": { + "binding": "class", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "server" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_json" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "meta" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_display_name" + } + ] + }, + "to_function_tool": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "server" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "convert_schemas_to_strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "identity": "agents.tool.default_tool_error_function", + "kind": "callable" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name_override" + } + ] + } + }, + "parameters": [] + }, + "agents.mcp.util.create_static_tool_filter": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "allowed_tool_names" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "blocked_tool_names" + } + ] + }, + "agents.model_settings.MCPToolChoice": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "server_label" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "server_label" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + } + ] + }, + "agents.models.is_gpt_5_default": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "agents.realtime.RealtimeAgent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "handoff_description" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "tools" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "mcp_servers" + }, + { + "default": { + "factory": "agents.agent.AgentBase.", + "kind": "factory" + }, + "init": true, + "name": "mcp_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "instructions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "prompt" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "handoffs" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "hooks" + } + ], + "kind": "class", + "members": { + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "get_all_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_mcp_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_system_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoff_description" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_servers" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "instructions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hooks" + } + ] + }, + "agents.realtime.RealtimePlaybackTracker": { + "dataclass_fields": [], + "kind": "class", + "members": { + "get_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "on_interrupted": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "on_play_bytes": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "item_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "item_content_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "bytes" + } + ] + }, + "on_play_ms": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "item_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "item_content_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ms" + } + ] + }, + "set_audio_format": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "format" + } + ] + } + }, + "parameters": [] + }, + "agents.realtime.RealtimeRunner": { + "dataclass_fields": [], + "kind": "class", + "members": { + "run": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "model_config" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "config" + } + ] + }, + "agents.realtime.RealtimeSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "approve_tool_call": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "always" + } + ] + }, + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "enter": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "interrupt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "on_event": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "event" + } + ] + }, + "reject_tool_call": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "always" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "rejection_message" + } + ] + }, + "send_audio": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "audio" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "commit" + } + ] + }, + "send_message": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "update_agent": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_config" + } + ] + }, + "agents.realtime.items.AssistantAudio": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "audio" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "audio" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "transcript" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "extra_data" + } + ] + }, + "agents.realtime.items.AssistantMessageItem": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "previous_item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "message" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "assistant" + }, + "kind": "KEYWORD_ONLY", + "name": "role" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "status" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "content" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "extra_data" + } + ] + }, + "agents.realtime.items.AssistantText": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "text" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "text" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "extra_data" + } + ] + }, + "agents.realtime.items.InputText": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "input_text" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "text" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "extra_data" + } + ] + }, + "agents.realtime.items.UserMessageItem": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "previous_item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "message" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "user" + }, + "kind": "KEYWORD_ONLY", + "name": "role" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "content" + }, + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "extra_data" + } + ] + }, + "agents.realtime.model_events.RealtimeModelItemUpdatedEvent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "item_updated" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "item_updated" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "agents.realtime.model_events.RealtimeModelRawServerEvent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "raw_server_event" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "raw_server_event" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "agents.realtime.model_events.RealtimeModelUsageEvent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "input_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "output_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "usage" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_tokens_details" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "usage" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "agents.realtime.model_inputs.RealtimeModelSendRawMessage": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "message" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "agents.realtime.openai_realtime.OpenAIRealtimeSIPModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_listener": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "listener" + } + ] + }, + "build_initial_session_payload": { + "binding": "static", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "model_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "overrides" + } + ] + }, + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "connect": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "options" + } + ] + }, + "remove_listener": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "listener" + } + ] + }, + "send_event": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "event" + } + ] + }, + "send_event_if": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "event" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "send_if" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "transport_config" + } + ] + }, + "agents.realtime.realtime_handoff": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_description_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_handoff" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_enabled" + } + ] + }, + "agents.realtime.runner.RealtimeRunner": { + "dataclass_fields": [], + "kind": "class", + "members": { + "run": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "model_config" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "starting_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "config" + } + ] + }, + "agents.sandbox.Capability": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + } + ] + }, + "agents.sandbox.ExecTimeoutError": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error_code" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "op" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cause" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retryable" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "command" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cause" + } + ] + }, + "agents.sandbox.LocalFile": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local_file" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "src" + } + ] + }, + "agents.sandbox.LocalSnapshotSpec": { + "dataclass_fields": [], + "kind": "class", + "members": { + "build": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot_id" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "base_path" + } + ] + }, + "agents.sandbox.Manifest": { + "dataclass_fields": [], + "kind": "class", + "members": { + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 1 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "depth" + } + ] + }, + "ephemeral_entry_paths": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 1 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "depth" + } + ] + }, + "ephemeral_mount_targets": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "ephemeral_persistence_paths": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 1 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "depth" + } + ] + }, + "iter_entries": { + "binding": "instance", + "execution_kind": "generator", + "parameters": [] + }, + "mount_targets": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "validated_entries": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 1 + }, + "kind": "KEYWORD_ONLY", + "name": "version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "/workspace" + }, + "kind": "KEYWORD_ONLY", + "name": "root" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "entries" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "environment" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "users" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "groups" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "extra_path_grants" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "remote_mount_command_allowlist" + } + ] + }, + "agents.sandbox.MemoryGenerateConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 256 + }, + "init": true, + "name": "max_raw_memories_for_consolidation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.4-mini" + }, + "init": true, + "name": "phase_one_model" + }, + { + "default": { + "factory": "agents.sandbox.config._default_memory_phase_one_model_settings", + "kind": "factory" + }, + "init": true, + "name": "phase_one_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.5" + }, + "init": true, + "name": "phase_two_model" + }, + { + "default": { + "factory": "agents.sandbox.config._default_memory_phase_two_model_settings", + "kind": "factory" + }, + "init": true, + "name": "phase_two_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "extra_prompt" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 256 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_raw_memories_for_consolidation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.4-mini" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_one_model" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_one_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.5" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_two_model" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_two_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "extra_prompt" + } + ] + }, + "agents.sandbox.MemoryLayoutConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "memories" + }, + "init": true, + "name": "memories_dir" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "sessions" + }, + "init": true, + "name": "sessions_dir" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "memories" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "memories_dir" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "sessions" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sessions_dir" + } + ] + }, + "agents.sandbox.RemoteSnapshotSpec": { + "dataclass_fields": [], + "kind": "class", + "members": { + "build": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot_id" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "remote" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "client_dependency_key" + } + ] + }, + "agents.sandbox.SandboxAgent": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "handoff_description" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "tools" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "mcp_servers" + }, + { + "default": { + "factory": "agents.agent.AgentBase.", + "kind": "factory" + }, + "init": true, + "name": "mcp_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "instructions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "prompt" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "handoffs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "model" + }, + { + "default": { + "factory": "agents.models.default_models.get_default_model_settings", + "kind": "factory" + }, + "init": true, + "name": "model_settings" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "input_guardrails" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "run_llm_again" + }, + "init": true, + "name": "tool_use_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "reset_tool_choice" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "default_manifest" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "base_instructions" + }, + { + "default": { + "factory": "agents.sandbox.capabilities.capabilities.Capabilities.default", + "kind": "factory" + }, + "init": true, + "name": "capabilities" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "run_as" + } + ], + "kind": "class", + "members": { + "as_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "custom_output_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_enabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_stream" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_turns" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "identity": "agents.tool.default_tool_error_function", + "kind": "callable" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_builder" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "include_input_schema" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ] + }, + "get_all_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_mcp_tools": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + }, + "get_system_prompt": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_context" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoff_description" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_servers" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "instructions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_guardrails" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hooks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "run_llm_again" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_use_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "reset_tool_choice" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "default_manifest" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_instructions" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "capabilities" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "run_as" + } + ] + }, + "agents.sandbox.SandboxPathGrant": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "read_only" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "host_path" + } + ] + }, + "agents.sandbox.SandboxRunConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "session_state" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "manifest" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "snapshot" + }, + { + "default": { + "factory": "agents.run_config.SandboxConcurrencyLimits", + "kind": "factory" + }, + "init": true, + "name": "concurrency_limits" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "archive_limits" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session_state" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "concurrency_limits" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "archive_limits" + } + ] + }, + "agents.sandbox.WorkspaceReadNotFoundError": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error_code" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "op" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cause" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retryable" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cause" + } + ] + }, + "agents.sandbox.capabilities.Capabilities": { + "dataclass_fields": [], + "kind": "class", + "members": { + "default": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [] + }, + "agents.sandbox.capabilities.Filesystem": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "filesystem" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "configure_tools" + } + ] + }, + "agents.sandbox.capabilities.FilesystemToolSet": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "view_image" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "apply_patch" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "view_image" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "apply_patch" + } + ] + }, + "agents.sandbox.capabilities.LocalDirLazySkillSource": { + "dataclass_fields": [], + "kind": "class", + "members": { + "list_skill_metadata": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "skills_path" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "KEYWORD_ONLY", + "name": "source_grants" + } + ] + }, + "load_skill": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "skill_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "skills_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "source" + } + ] + }, + "agents.sandbox.capabilities.Memory": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "memory" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "layout" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "read" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "generate" + } + ] + }, + "agents.sandbox.capabilities.Shell": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "shell" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "configure_tools" + } + ] + }, + "agents.sandbox.capabilities.Skills": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "load_skill": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "skill_name" + } + ] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "skills" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "skills" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "from_" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "lazy_from" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": ".agents" + }, + "kind": "KEYWORD_ONLY", + "name": "skills_path" + } + ] + }, + "agents.sandbox.capabilities.capabilities.Capabilities": { + "dataclass_fields": [], + "kind": "class", + "members": { + "default": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [] + }, + "agents.sandbox.capabilities.compaction.Compaction": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "compaction" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "policy" + } + ] + }, + "agents.sandbox.capabilities.memory.Memory": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "memory" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "layout" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "read" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "generate" + } + ] + }, + "agents.sandbox.capabilities.shell.Shell": { + "dataclass_fields": [], + "kind": "class", + "members": { + "bind": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "bind_run_as": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "instructions": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "process_context": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + } + ] + }, + "process_manifest": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "manifest" + } + ] + }, + "required_capability_types": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "sampling_params": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sampling_params" + } + ] + }, + "tools": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "shell" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "configure_tools" + } + ] + }, + "agents.sandbox.config.MemoryGenerateConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 256 + }, + "init": true, + "name": "max_raw_memories_for_consolidation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.4-mini" + }, + "init": true, + "name": "phase_one_model" + }, + { + "default": { + "factory": "agents.sandbox.config._default_memory_phase_one_model_settings", + "kind": "factory" + }, + "init": true, + "name": "phase_one_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.5" + }, + "init": true, + "name": "phase_two_model" + }, + { + "default": { + "factory": "agents.sandbox.config._default_memory_phase_two_model_settings", + "kind": "factory" + }, + "init": true, + "name": "phase_two_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "extra_prompt" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 256 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_raw_memories_for_consolidation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.4-mini" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_one_model" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_one_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gpt-5.5" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_two_model" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "phase_two_model_settings" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "extra_prompt" + } + ] + }, + "agents.sandbox.config.MemoryReadConfig": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "init": true, + "name": "live_update" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "live_update" + } + ] + }, + "agents.sandbox.entries.AzureBlobMount": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strategy" + } + ] + }, + "build_in_container_mount_config": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pattern" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "include_config_text" + } + ] + }, + "docker_volume_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "in_container_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + }, + "supported_docker_volume_drivers": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supported_in_container_patterns": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "unmount": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "azure_blob_mount" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "account" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "container" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "endpoint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "identity_client_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "account_key" + } + ] + }, + "agents.sandbox.entries.Dir": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "dir" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "children" + } + ] + }, + "agents.sandbox.entries.DockerVolumeMountStrategy": { + "dataclass_fields": [], + "kind": "class", + "members": { + "activate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "deactivate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "restore_after_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "supports_native_snapshot_detach": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "teardown_for_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "validate_mount": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "docker_volume" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "driver" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "driver_options" + } + ] + }, + "agents.sandbox.entries.File": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "file" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "content" + } + ] + }, + "agents.sandbox.entries.FuseMountPattern": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "_FuseMountPattern__context" + } + ] + }, + "unapply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "allow_other" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "syslog" + }, + "kind": "KEYWORD_ONLY", + "name": "log_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "log_debug" + }, + "kind": "KEYWORD_ONLY", + "name": "log_level" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "block_cache" + }, + "kind": "KEYWORD_ONLY", + "name": "cache_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cache_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cache_size_mb" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 16 + }, + "kind": "KEYWORD_ONLY", + "name": "block_cache_block_size_mb" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "kind": "KEYWORD_ONLY", + "name": "block_cache_disk_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 120 + }, + "kind": "KEYWORD_ONLY", + "name": "file_cache_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "file_cache_max_size_mb" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "attr_cache_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "entry_cache_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "negative_entry_cache_timeout_sec" + } + ] + }, + "agents.sandbox.entries.GCSMount": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strategy" + } + ] + }, + "build_in_container_mount_config": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pattern" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "include_config_text" + } + ] + }, + "docker_volume_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "in_container_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + }, + "supported_docker_volume_drivers": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supported_in_container_patterns": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "unmount": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gcs_mount" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "bucket" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "access_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "secret_access_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "prefix" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "region" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "endpoint_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "service_account_file" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "service_account_credentials" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "access_token" + } + ] + }, + "agents.sandbox.entries.GitRepo": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "git_repo" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "github.com" + }, + "kind": "KEYWORD_ONLY", + "name": "host" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "repo" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "ref" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "subpath" + } + ] + }, + "agents.sandbox.entries.InContainerMountStrategy": { + "dataclass_fields": [], + "kind": "class", + "members": { + "activate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "deactivate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "restore_after_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "supports_native_snapshot_detach": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "teardown_for_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "validate_mount": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "in_container" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "pattern" + } + ] + }, + "agents.sandbox.entries.LocalDir": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local_dir" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "src" + } + ] + }, + "agents.sandbox.entries.LocalFile": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local_file" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "src" + } + ] + }, + "agents.sandbox.entries.Mount": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strategy" + } + ] + }, + "build_in_container_mount_config": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pattern" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "include_config_text" + } + ] + }, + "docker_volume_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "in_container_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + }, + "supported_docker_volume_drivers": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supported_in_container_patterns": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "unmount": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mount_strategy" + } + ] + }, + "agents.sandbox.entries.MountpointMountPattern": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + }, + "unapply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mountpoint" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "agents.sandbox.entries.R2Mount": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strategy" + } + ] + }, + "build_in_container_mount_config": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pattern" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "include_config_text" + } + ] + }, + "docker_volume_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "in_container_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + }, + "supported_docker_volume_drivers": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supported_in_container_patterns": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "unmount": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "r2_mount" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "bucket" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "account_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "access_key_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "secret_access_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "custom_domain" + } + ] + }, + "agents.sandbox.entries.RcloneMountPattern": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + }, + "read_config_text": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "remote_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mount_type" + } + ] + }, + "resolve_remote_name": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remote_kind" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_type" + } + ] + }, + "unapply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "rclone" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + }, + "kind": "KEYWORD_ONLY", + "name": "mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "remote_name" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "extra_args" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "nfs_addr" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "nfs_mount_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "config_file_path" + } + ] + }, + "agents.sandbox.entries.S3FilesMount": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strategy" + } + ] + }, + "build_in_container_mount_config": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pattern" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "include_config_text" + } + ] + }, + "docker_volume_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "in_container_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + }, + "supported_docker_volume_drivers": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supported_in_container_patterns": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "unmount": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "s3_files_mount" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "file_system_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "subpath" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_target_ip" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "access_point" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "region" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "extra_options" + } + ] + }, + "agents.sandbox.entries.S3FilesMountPattern": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + }, + "unapply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "s3files" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "agents.sandbox.entries.S3Mount": { + "dataclass_fields": [], + "kind": "class", + "members": { + "apply": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "strategy" + } + ] + }, + "build_in_container_mount_config": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pattern" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "include_config_text" + } + ] + }, + "docker_volume_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "in_container_adapter": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "registered_types": { + "binding": "class", + "execution_kind": "sync", + "parameters": [] + }, + "supported_docker_volume_drivers": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supported_in_container_patterns": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "unmount": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "s3_mount" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_dir" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "bucket" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "access_key_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "secret_access_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "session_token" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "prefix" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "region" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "endpoint_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "AWS" + }, + "kind": "KEYWORD_ONLY", + "name": "s3_provider" + } + ] + }, + "agents.sandbox.errors.MountCommandError": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error_code" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "op" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cause" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retryable" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "command" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "stderr" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cause" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "retryable" + } + ] + }, + "agents.sandbox.errors.WorkspaceArchiveWriteError": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error_code" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "op" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cause" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retryable" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cause" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "retryable" + } + ] + }, + "agents.sandbox.errors.WorkspaceReadNotFoundError": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "error_code" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "op" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cause" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retryable" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cause" + } + ] + }, + "agents.sandbox.manifest.Environment": { + "dataclass_fields": [], + "kind": "class", + "members": { + "normalized": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "resolve": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "value" + } + ] + }, + "agents.sandbox.sandboxes.UnixLocalSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.sandbox.sandboxes.UnixLocalSandboxClientOptions": { + "dataclass_fields": [], + "kind": "class", + "members": { + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "unix_local" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.sandbox.sandboxes.UnixLocalSandboxSessionState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_path_grants_rebound": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "rebind_persisted_path_grants": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "unix_local" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_owned" + } + ] + }, + "agents.sandbox.sandboxes.unix_local.UnixLocalSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.sandbox.session.BaseSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [] + }, + "agents.sandbox.session.BaseSandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [] + }, + "agents.sandbox.session.Dependencies": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "bind_factory": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "key" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "factory" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "cache" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "overwrite" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "owns_result" + } + ] + }, + "bind_value": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "key" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "value" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "overwrite" + } + ] + }, + "clone": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "get": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "key" + } + ] + }, + "require": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "consumer" + } + ] + }, + "with_values": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "values" + } + ] + } + }, + "parameters": [] + }, + "agents.sandbox.session.EventPayloadPolicy": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "include_exec_output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 8000 + }, + "kind": "KEYWORD_ONLY", + "name": "max_stdout_chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 8000 + }, + "kind": "KEYWORD_ONLY", + "name": "max_stderr_chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "include_write_len" + } + ] + }, + "agents.sandbox.session.Instrumentation": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_sink": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sink" + } + ] + }, + "emit": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "event" + } + ] + }, + "flush": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sinks" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "payload_policy" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "payload_policy_by_op" + } + ] + }, + "agents.sandbox.session.JsonlOutboxSink": { + "dataclass_fields": [], + "kind": "class", + "members": { + "handle": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "event" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "best_effort" + }, + "kind": "KEYWORD_ONLY", + "name": "mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "log" + }, + "kind": "KEYWORD_ONLY", + "name": "on_error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "payload_policy" + } + ] + }, + "agents.sandbox.session.SandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "inner" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.sandbox.session.base_sandbox_session.BaseSandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [] + }, + "agents.sandbox.session.sandbox_session.SandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "inner" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.sandbox.session.sandbox_session_state.SandboxSessionState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_path_grants_rebound": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "rebind_persisted_path_grants": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_ready" + } + ] + }, + "agents.sandbox.snapshot.NoopSnapshotSpec": { + "dataclass_fields": [], + "kind": "class", + "members": { + "build": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot_id" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "noop" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.sandbox.snapshot.SnapshotBase": { + "dataclass_fields": [], + "kind": "class", + "members": { + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "persist": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "restorable": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "restore": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "id" + } + ] + }, + "agents.tool_context.ToolContext": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "context" + }, + { + "default": { + "factory": "agents.usage.Usage", + "kind": "factory" + }, + "init": true, + "name": "usage" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "init": true, + "name": "turn_input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_input" + }, + { + "default": { + "factory": "agents.tool_context._assert_must_pass_tool_name", + "kind": "factory" + }, + "init": true, + "name": "tool_name" + }, + { + "default": { + "factory": "agents.tool_context._assert_must_pass_tool_call_id", + "kind": "factory" + }, + "init": true, + "name": "tool_call_id" + }, + { + "default": { + "factory": "agents.tool_context._assert_must_pass_tool_arguments", + "kind": "factory" + }, + "init": true, + "name": "tool_arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_call" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "run_config" + } + ], + "kind": "class", + "members": { + "approve_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_approve" + } + ] + }, + "from_agent_context": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_config" + } + ] + }, + "get_approval_status": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "get_rejection_message": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "is_tool_approved": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + } + ] + }, + "reject_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_reject" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "rejection_message" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call_id" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "turn_input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_approvals" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_input" + } + ] + }, + "apply_diff": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "diff" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "default" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mode" + } + ] + }, + "custom_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "default_handoff_history_mapper": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "transcript" + } + ] + }, + "default_tool_error_function": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ctx" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + } + ] + }, + "dispose_resolved_computers": { + "dataclass_fields": [], + "execution_kind": "coroutine", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "run_context" + } + ] + }, + "enable_verbose_stdout_logging": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "flush_traces": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "function_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "function_tool": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "func" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "name_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "description_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "docstring_style" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "use_docstring_info" + }, + { + "default": { + "identity": "agents.tool._UNSET_FAILURE_ERROR_FUNCTION", + "kind": "sentinel" + }, + "kind": "KEYWORD_ONLY", + "name": "failure_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "strict_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "is_enabled" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "needs_approval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_input_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_output_guardrails" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "error_as_result" + }, + "kind": "KEYWORD_ONLY", + "name": "timeout_behavior" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout_error_function" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "defer_loading" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "allowed_callers" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "output_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "output_json_schema" + } + ] + }, + "gen_span_id": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "gen_trace_id": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "generation_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "get_conversation_history_wrappers": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "get_current_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "get_current_trace": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "guardrail_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "triggered" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "handoff": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_description_override" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "on_handoff" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_filter" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "nest_handoff_history" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "is_enabled" + } + ] + }, + "handoff_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "from_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "to_agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "input_guardrail": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "func" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "run_in_parallel" + } + ] + }, + "is_openai_responses_compaction_aware_session": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "mcp_tools_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "server" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "result" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "nest_handoff_history": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoff_input_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "history_mapper" + } + ] + }, + "output_guardrail": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "func" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "name" + } + ] + }, + "reset_conversation_history_wrappers": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [] + }, + "resolve_computer": { + "dataclass_fields": [], + "execution_kind": "coroutine", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "tool" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "run_context" + } + ] + }, + "response_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "responses_websocket_session": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "api_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "base_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "websocket_base_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "organization" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "project" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "alias" + }, + "kind": "KEYWORD_ONLY", + "name": "openai_prefix_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "error" + }, + "kind": "KEYWORD_ONLY", + "name": "unknown_prefix_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "responses_websocket_options" + } + ] + }, + "run_demo_loop": { + "dataclass_fields": [], + "execution_kind": "coroutine", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "stream" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 10 + }, + "kind": "KEYWORD_ONLY", + "name": "max_turns" + } + ] + }, + "set_conversation_history_wrappers": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "start" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "end" + } + ] + }, + "set_default_openai_agent_registration": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "config" + } + ] + }, + "set_default_openai_api": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "api" + } + ] + }, + "set_default_openai_client": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "client" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_for_tracing" + } + ] + }, + "set_default_openai_harness": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "harness_id" + } + ] + }, + "set_default_openai_key": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_for_tracing" + } + ] + }, + "set_default_openai_responses_transport": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "transport" + } + ] + }, + "set_trace_processors": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "processors" + } + ] + }, + "set_trace_provider": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "provider" + } + ] + }, + "set_tracing_disabled": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "set_tracing_export_api_key": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "api_key" + } + ] + }, + "speech_group_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "speech_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pcm" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_format" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "first_content_at" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "task_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "tool_input_guardrail": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "func" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "name" + } + ] + }, + "tool_namespace": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "description" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "tools" + } + ] + }, + "tool_output_guardrail": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "func" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "name" + } + ] + }, + "trace": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workflow_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "group_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "transcription_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pcm" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_format" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + }, + "turn_span": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "turn" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "span_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "parent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "disabled" + } + ] + } + }, + "canonical_imports": [ + { + "canonical_module": "agents", + "canonical_name": "Agent", + "module": "agents.agent", + "name": "Agent" + }, + { + "canonical_module": "agents", + "canonical_name": "function_tool", + "module": "agents.decorators", + "name": "function_tool" + }, + { + "canonical_module": "agents", + "canonical_name": "input_guardrail", + "module": "agents.decorators", + "name": "input_guardrail" + }, + { + "canonical_module": "agents", + "canonical_name": "output_guardrail", + "module": "agents.decorators", + "name": "output_guardrail" + }, + { + "canonical_module": "agents", + "canonical_name": "function_tool", + "module": "agents.decorators", + "name": "tool" + }, + { + "canonical_module": "agents", + "canonical_name": "tool_input_guardrail", + "module": "agents.decorators", + "name": "tool_input_guardrail" + }, + { + "canonical_module": "agents", + "canonical_name": "tool_output_guardrail", + "module": "agents.decorators", + "name": "tool_output_guardrail" + }, + { + "canonical_module": "agents", + "canonical_name": "ApplyPatchOperation", + "module": "agents.editor", + "name": "ApplyPatchOperation" + }, + { + "canonical_module": "agents", + "canonical_name": "ApplyPatchResult", + "module": "agents.editor", + "name": "ApplyPatchResult" + }, + { + "canonical_module": "agents.extensions", + "canonical_name": "handoff_filters", + "module": "agents.extensions", + "name": "handoff_filters" + }, + { + "canonical_module": "agents.extensions.handoff_prompt", + "canonical_name": "RECOMMENDED_PROMPT_PREFIX", + "module": "agents.extensions.handoff_prompt", + "name": "RECOMMENDED_PROMPT_PREFIX" + }, + { + "canonical_module": "agents.extensions.handoff_prompt", + "canonical_name": "prompt_with_handoff_instructions", + "module": "agents.extensions.handoff_prompt", + "name": "prompt_with_handoff_instructions" + }, + { + "canonical_module": "agents.extensions.memory", + "canonical_name": "AdvancedSQLiteSession", + "module": "agents.extensions.memory", + "name": "AdvancedSQLiteSession" + }, + { + "canonical_module": "agents.extensions.memory", + "canonical_name": "EncryptedSession", + "module": "agents.extensions.memory", + "name": "EncryptedSession" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "BlaxelDriveMountStrategy", + "module": "agents.extensions.sandbox", + "name": "BlaxelDriveMountStrategy" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "BlaxelSandboxClient", + "module": "agents.extensions.sandbox", + "name": "BlaxelSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "BlaxelSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "BlaxelSandboxClientOptions" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "DEFAULT_BLAXEL_WORKSPACE_ROOT", + "module": "agents.extensions.sandbox", + "name": "DEFAULT_BLAXEL_WORKSPACE_ROOT" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "DEFAULT_DAYTONA_WORKSPACE_ROOT", + "module": "agents.extensions.sandbox", + "name": "DEFAULT_DAYTONA_WORKSPACE_ROOT" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "DaytonaCloudBucketMountStrategy", + "module": "agents.extensions.sandbox", + "name": "DaytonaCloudBucketMountStrategy" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "DaytonaSandboxClient", + "module": "agents.extensions.sandbox", + "name": "DaytonaSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "DaytonaSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "DaytonaSandboxClientOptions" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "DaytonaSandboxSessionState", + "module": "agents.extensions.sandbox", + "name": "DaytonaSandboxSessionState" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "E2BSandboxClient", + "module": "agents.extensions.sandbox", + "name": "E2BSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "E2BSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "E2BSandboxClientOptions" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "E2BSandboxSessionState", + "module": "agents.extensions.sandbox", + "name": "E2BSandboxSessionState" + }, + { + "canonical_module": "agents.extensions.sandbox", + "canonical_name": "E2BSandboxType", + "module": "agents.extensions.sandbox", + "name": "E2BSandboxType" + }, + { + "canonical_module": "agents", + "canonical_name": "input_guardrail", + "module": "agents.guardrail", + "name": "input_guardrail" + }, + { + "canonical_module": "agents", + "canonical_name": "handoff", + "module": "agents.handoffs", + "name": "handoff" + }, + { + "canonical_module": "agents", + "canonical_name": "CompactionItem", + "module": "agents.items", + "name": "CompactionItem" + }, + { + "canonical_module": "agents", + "canonical_name": "HandoffCallItem", + "module": "agents.items", + "name": "HandoffCallItem" + }, + { + "canonical_module": "agents", + "canonical_name": "HandoffOutputItem", + "module": "agents.items", + "name": "HandoffOutputItem" + }, + { + "canonical_module": "agents", + "canonical_name": "MCPApprovalRequestItem", + "module": "agents.items", + "name": "MCPApprovalRequestItem" + }, + { + "canonical_module": "agents", + "canonical_name": "MCPApprovalResponseItem", + "module": "agents.items", + "name": "MCPApprovalResponseItem" + }, + { + "canonical_module": "agents", + "canonical_name": "MCPListToolsItem", + "module": "agents.items", + "name": "MCPListToolsItem" + }, + { + "canonical_module": "agents", + "canonical_name": "MessageOutputItem", + "module": "agents.items", + "name": "MessageOutputItem" + }, + { + "canonical_module": "agents", + "canonical_name": "ModelResponse", + "module": "agents.items", + "name": "ModelResponse" + }, + { + "canonical_module": "agents", + "canonical_name": "ReasoningItem", + "module": "agents.items", + "name": "ReasoningItem" + }, + { + "canonical_module": "agents", + "canonical_name": "RunItem", + "module": "agents.items", + "name": "RunItem" + }, + { + "canonical_module": "agents", + "canonical_name": "TResponseInputItem", + "module": "agents.items", + "name": "TResponseInputItem" + }, + { + "canonical_module": "agents.items", + "canonical_name": "TResponseStreamEvent", + "module": "agents.items", + "name": "TResponseStreamEvent" + }, + { + "canonical_module": "agents", + "canonical_name": "ToolApprovalItem", + "module": "agents.items", + "name": "ToolApprovalItem" + }, + { + "canonical_module": "agents", + "canonical_name": "ToolCallItem", + "module": "agents.items", + "name": "ToolCallItem" + }, + { + "canonical_module": "agents", + "canonical_name": "ToolCallOutputItem", + "module": "agents.items", + "name": "ToolCallOutputItem" + }, + { + "canonical_module": "agents", + "canonical_name": "ToolSearchCallItem", + "module": "agents.items", + "name": "ToolSearchCallItem" + }, + { + "canonical_module": "agents", + "canonical_name": "ToolSearchOutputItem", + "module": "agents.items", + "name": "ToolSearchOutputItem" + }, + { + "canonical_module": "agents.lifecycle", + "canonical_name": "RunHooksBase", + "module": "agents.lifecycle", + "name": "RunHooksBase" + }, + { + "canonical_module": "agents.mcp", + "canonical_name": "MCPServer", + "module": "agents.mcp", + "name": "MCPServer" + }, + { + "canonical_module": "agents.mcp", + "canonical_name": "MCPServerManager", + "module": "agents.mcp", + "name": "MCPServerManager" + }, + { + "canonical_module": "agents.mcp", + "canonical_name": "MCPServerSse", + "module": "agents.mcp", + "name": "MCPServerSse" + }, + { + "canonical_module": "agents.mcp", + "canonical_name": "MCPServerStdio", + "module": "agents.mcp", + "name": "MCPServerStdio" + }, + { + "canonical_module": "agents.mcp", + "canonical_name": "MCPServerStreamableHttp", + "module": "agents.mcp", + "name": "MCPServerStreamableHttp" + }, + { + "canonical_module": "agents.mcp.util", + "canonical_name": "MCPUtil", + "module": "agents.mcp.util", + "name": "MCPUtil" + }, + { + "canonical_module": "agents.mcp.util", + "canonical_name": "create_static_tool_filter", + "module": "agents.mcp.util", + "name": "create_static_tool_filter" + }, + { + "canonical_module": "agents", + "canonical_name": "SQLiteSession", + "module": "agents.memory", + "name": "SQLiteSession" + }, + { + "canonical_module": "agents", + "canonical_name": "Session", + "module": "agents.memory.session", + "name": "Session" + }, + { + "canonical_module": "agents", + "canonical_name": "SessionSettings", + "module": "agents.memory.session_settings", + "name": "SessionSettings" + }, + { + "canonical_module": "agents", + "canonical_name": "SQLiteSession", + "module": "agents.memory.sqlite_session", + "name": "SQLiteSession" + }, + { + "canonical_module": "agents.model_settings", + "canonical_name": "MCPToolChoice", + "module": "agents.model_settings", + "name": "MCPToolChoice" + }, + { + "canonical_module": "agents", + "canonical_name": "ModelSettings", + "module": "agents.model_settings", + "name": "ModelSettings" + }, + { + "canonical_module": "agents.models", + "canonical_name": "is_gpt_5_default", + "module": "agents.models", + "name": "is_gpt_5_default" + }, + { + "canonical_module": "agents", + "canonical_name": "ModelTracing", + "module": "agents.models.interface", + "name": "ModelTracing" + }, + { + "canonical_module": "agents", + "canonical_name": "OpenAIProvider", + "module": "agents.models.openai_provider", + "name": "OpenAIProvider" + }, + { + "canonical_module": "agents.realtime", + "canonical_name": "RealtimeAgent", + "module": "agents.realtime", + "name": "RealtimeAgent" + }, + { + "canonical_module": "agents.realtime", + "canonical_name": "RealtimePlaybackTracker", + "module": "agents.realtime", + "name": "RealtimePlaybackTracker" + }, + { + "canonical_module": "agents.realtime", + "canonical_name": "RealtimeRunner", + "module": "agents.realtime", + "name": "RealtimeRunner" + }, + { + "canonical_module": "agents.realtime", + "canonical_name": "RealtimeSession", + "module": "agents.realtime", + "name": "RealtimeSession" + }, + { + "canonical_module": "agents.realtime", + "canonical_name": "RealtimeSessionEvent", + "module": "agents.realtime", + "name": "RealtimeSessionEvent" + }, + { + "canonical_module": "agents.realtime", + "canonical_name": "realtime_handoff", + "module": "agents.realtime", + "name": "realtime_handoff" + }, + { + "canonical_module": "agents.realtime.config", + "canonical_name": "RealtimeSessionModelSettings", + "module": "agents.realtime.config", + "name": "RealtimeSessionModelSettings" + }, + { + "canonical_module": "agents.realtime.config", + "canonical_name": "RealtimeUserInputMessage", + "module": "agents.realtime.config", + "name": "RealtimeUserInputMessage" + }, + { + "canonical_module": "agents.realtime.items", + "canonical_name": "AssistantAudio", + "module": "agents.realtime.items", + "name": "AssistantAudio" + }, + { + "canonical_module": "agents.realtime.items", + "canonical_name": "AssistantMessageItem", + "module": "agents.realtime.items", + "name": "AssistantMessageItem" + }, + { + "canonical_module": "agents.realtime.items", + "canonical_name": "AssistantText", + "module": "agents.realtime.items", + "name": "AssistantText" + }, + { + "canonical_module": "agents.realtime.items", + "canonical_name": "InputText", + "module": "agents.realtime.items", + "name": "InputText" + }, + { + "canonical_module": "agents.realtime.items", + "canonical_name": "RealtimeItem", + "module": "agents.realtime.items", + "name": "RealtimeItem" + }, + { + "canonical_module": "agents.realtime.items", + "canonical_name": "UserMessageItem", + "module": "agents.realtime.items", + "name": "UserMessageItem" + }, + { + "canonical_module": "agents.realtime.model", + "canonical_name": "RealtimeModelConfig", + "module": "agents.realtime.model", + "name": "RealtimeModelConfig" + }, + { + "canonical_module": "agents.realtime.model_events", + "canonical_name": "RealtimeModelItemUpdatedEvent", + "module": "agents.realtime.model_events", + "name": "RealtimeModelItemUpdatedEvent" + }, + { + "canonical_module": "agents.realtime.model_events", + "canonical_name": "RealtimeModelRawServerEvent", + "module": "agents.realtime.model_events", + "name": "RealtimeModelRawServerEvent" + }, + { + "canonical_module": "agents.realtime.model_events", + "canonical_name": "RealtimeModelUsageEvent", + "module": "agents.realtime.model_events", + "name": "RealtimeModelUsageEvent" + }, + { + "canonical_module": "agents.realtime.model_inputs", + "canonical_name": "RealtimeModelSendRawMessage", + "module": "agents.realtime.model_inputs", + "name": "RealtimeModelSendRawMessage" + }, + { + "canonical_module": "agents.realtime.openai_realtime", + "canonical_name": "OpenAIRealtimeSIPModel", + "module": "agents.realtime.openai_realtime", + "name": "OpenAIRealtimeSIPModel" + }, + { + "canonical_module": "agents.realtime.runner", + "canonical_name": "RealtimeRunner", + "module": "agents.realtime.runner", + "name": "RealtimeRunner" + }, + { + "canonical_module": "agents", + "canonical_name": "ResponsesWebSocketSession", + "module": "agents.responses_websocket_session", + "name": "ResponsesWebSocketSession" + }, + { + "canonical_module": "agents", + "canonical_name": "RetryDecision", + "module": "agents.retry", + "name": "RetryDecision" + }, + { + "canonical_module": "agents", + "canonical_name": "RetryPolicyContext", + "module": "agents.retry", + "name": "RetryPolicyContext" + }, + { + "canonical_module": "agents", + "canonical_name": "RunConfig", + "module": "agents.run", + "name": "RunConfig" + }, + { + "canonical_module": "agents", + "canonical_name": "Runner", + "module": "agents.run", + "name": "Runner" + }, + { + "canonical_module": "agents", + "canonical_name": "RunConfig", + "module": "agents.run_config", + "name": "RunConfig" + }, + { + "canonical_module": "agents", + "canonical_name": "RunContextWrapper", + "module": "agents.run_context", + "name": "RunContextWrapper" + }, + { + "canonical_module": "agents", + "canonical_name": "RunState", + "module": "agents.run_state", + "name": "RunState" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "Capability", + "module": "agents.sandbox", + "name": "Capability" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "ExecTimeoutError", + "module": "agents.sandbox", + "name": "ExecTimeoutError" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "LocalFile", + "module": "agents.sandbox", + "name": "LocalFile" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "LocalSnapshotSpec", + "module": "agents.sandbox", + "name": "LocalSnapshotSpec" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "Manifest", + "module": "agents.sandbox", + "name": "Manifest" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "MemoryGenerateConfig", + "module": "agents.sandbox", + "name": "MemoryGenerateConfig" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "MemoryLayoutConfig", + "module": "agents.sandbox", + "name": "MemoryLayoutConfig" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "RemoteSnapshotSpec", + "module": "agents.sandbox", + "name": "RemoteSnapshotSpec" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "SandboxAgent", + "module": "agents.sandbox", + "name": "SandboxAgent" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "SandboxPathGrant", + "module": "agents.sandbox", + "name": "SandboxPathGrant" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "SandboxRunConfig", + "module": "agents.sandbox", + "name": "SandboxRunConfig" + }, + { + "canonical_module": "agents.sandbox", + "canonical_name": "WorkspaceReadNotFoundError", + "module": "agents.sandbox", + "name": "WorkspaceReadNotFoundError" + }, + { + "canonical_module": "agents.sandbox.capabilities", + "canonical_name": "Capabilities", + "module": "agents.sandbox.capabilities", + "name": "Capabilities" + }, + { + "canonical_module": "agents.sandbox.capabilities", + "canonical_name": "Filesystem", + "module": "agents.sandbox.capabilities", + "name": "Filesystem" + }, + { + "canonical_module": "agents.sandbox.capabilities", + "canonical_name": "FilesystemToolSet", + "module": "agents.sandbox.capabilities", + "name": "FilesystemToolSet" + }, + { + "canonical_module": "agents.sandbox.capabilities", + "canonical_name": "LocalDirLazySkillSource", + "module": "agents.sandbox.capabilities", + "name": "LocalDirLazySkillSource" + }, + { + "canonical_module": "agents.sandbox.capabilities", + "canonical_name": "Memory", + "module": "agents.sandbox.capabilities", + "name": "Memory" + }, + { + "canonical_module": "agents.sandbox.capabilities", + "canonical_name": "Shell", + "module": "agents.sandbox.capabilities", + "name": "Shell" + }, + { + "canonical_module": "agents.sandbox.capabilities", + "canonical_name": "Skills", + "module": "agents.sandbox.capabilities", + "name": "Skills" + }, + { + "canonical_module": "agents.sandbox.capabilities.capabilities", + "canonical_name": "Capabilities", + "module": "agents.sandbox.capabilities.capabilities", + "name": "Capabilities" + }, + { + "canonical_module": "agents.sandbox.capabilities.compaction", + "canonical_name": "Compaction", + "module": "agents.sandbox.capabilities.compaction", + "name": "Compaction" + }, + { + "canonical_module": "agents.sandbox.capabilities.memory", + "canonical_name": "Memory", + "module": "agents.sandbox.capabilities.memory", + "name": "Memory" + }, + { + "canonical_module": "agents.sandbox.capabilities.shell", + "canonical_name": "Shell", + "module": "agents.sandbox.capabilities.shell", + "name": "Shell" + }, + { + "canonical_module": "agents.sandbox.config", + "canonical_name": "DEFAULT_PYTHON_SANDBOX_IMAGE", + "module": "agents.sandbox.config", + "name": "DEFAULT_PYTHON_SANDBOX_IMAGE" + }, + { + "canonical_module": "agents.sandbox.config", + "canonical_name": "MemoryGenerateConfig", + "module": "agents.sandbox.config", + "name": "MemoryGenerateConfig" + }, + { + "canonical_module": "agents.sandbox.config", + "canonical_name": "MemoryReadConfig", + "module": "agents.sandbox.config", + "name": "MemoryReadConfig" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "AzureBlobMount", + "module": "agents.sandbox.entries", + "name": "AzureBlobMount" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "Dir", + "module": "agents.sandbox.entries", + "name": "Dir" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "DockerVolumeMountStrategy", + "module": "agents.sandbox.entries", + "name": "DockerVolumeMountStrategy" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "File", + "module": "agents.sandbox.entries", + "name": "File" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "FuseMountPattern", + "module": "agents.sandbox.entries", + "name": "FuseMountPattern" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "GCSMount", + "module": "agents.sandbox.entries", + "name": "GCSMount" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "GitRepo", + "module": "agents.sandbox.entries", + "name": "GitRepo" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "InContainerMountStrategy", + "module": "agents.sandbox.entries", + "name": "InContainerMountStrategy" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "LocalDir", + "module": "agents.sandbox.entries", + "name": "LocalDir" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "LocalFile", + "module": "agents.sandbox.entries", + "name": "LocalFile" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "Mount", + "module": "agents.sandbox.entries", + "name": "Mount" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "MountpointMountPattern", + "module": "agents.sandbox.entries", + "name": "MountpointMountPattern" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "R2Mount", + "module": "agents.sandbox.entries", + "name": "R2Mount" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "RcloneMountPattern", + "module": "agents.sandbox.entries", + "name": "RcloneMountPattern" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "S3FilesMount", + "module": "agents.sandbox.entries", + "name": "S3FilesMount" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "S3FilesMountPattern", + "module": "agents.sandbox.entries", + "name": "S3FilesMountPattern" + }, + { + "canonical_module": "agents.sandbox.entries", + "canonical_name": "S3Mount", + "module": "agents.sandbox.entries", + "name": "S3Mount" + }, + { + "canonical_module": "agents.sandbox.errors", + "canonical_name": "MountCommandError", + "module": "agents.sandbox.errors", + "name": "MountCommandError" + }, + { + "canonical_module": "agents.sandbox.errors", + "canonical_name": "WorkspaceArchiveWriteError", + "module": "agents.sandbox.errors", + "name": "WorkspaceArchiveWriteError" + }, + { + "canonical_module": "agents.sandbox.errors", + "canonical_name": "WorkspaceReadNotFoundError", + "module": "agents.sandbox.errors", + "name": "WorkspaceReadNotFoundError" + }, + { + "canonical_module": "agents.sandbox.manifest", + "canonical_name": "Environment", + "module": "agents.sandbox.manifest", + "name": "Environment" + }, + { + "canonical_module": "agents.sandbox.sandboxes.unix_local", + "canonical_name": "UnixLocalSandboxClient", + "module": "agents.sandbox.sandboxes", + "name": "UnixLocalSandboxClient" + }, + { + "canonical_module": "agents.sandbox.sandboxes.unix_local", + "canonical_name": "UnixLocalSandboxClientOptions", + "module": "agents.sandbox.sandboxes", + "name": "UnixLocalSandboxClientOptions" + }, + { + "canonical_module": "agents.sandbox.sandboxes.unix_local", + "canonical_name": "UnixLocalSandboxSessionState", + "module": "agents.sandbox.sandboxes", + "name": "UnixLocalSandboxSessionState" + }, + { + "canonical_module": "agents.sandbox.sandboxes.unix_local", + "canonical_name": "UnixLocalSandboxClient", + "module": "agents.sandbox.sandboxes.unix_local", + "name": "UnixLocalSandboxClient" + }, + { + "canonical_module": "agents.sandbox.session", + "canonical_name": "BaseSandboxClient", + "module": "agents.sandbox.session", + "name": "BaseSandboxClient" + }, + { + "canonical_module": "agents.sandbox.session", + "canonical_name": "BaseSandboxSession", + "module": "agents.sandbox.session", + "name": "BaseSandboxSession" + }, + { + "canonical_module": "agents.sandbox.session", + "canonical_name": "Dependencies", + "module": "agents.sandbox.session", + "name": "Dependencies" + }, + { + "canonical_module": "agents.sandbox.session", + "canonical_name": "EventPayloadPolicy", + "module": "agents.sandbox.session", + "name": "EventPayloadPolicy" + }, + { + "canonical_module": "agents.sandbox.session", + "canonical_name": "Instrumentation", + "module": "agents.sandbox.session", + "name": "Instrumentation" + }, + { + "canonical_module": "agents.sandbox.session", + "canonical_name": "JsonlOutboxSink", + "module": "agents.sandbox.session", + "name": "JsonlOutboxSink" + }, + { + "canonical_module": "agents.sandbox.session", + "canonical_name": "SandboxSession", + "module": "agents.sandbox.session", + "name": "SandboxSession" + }, + { + "canonical_module": "agents.sandbox.session.base_sandbox_session", + "canonical_name": "BaseSandboxSession", + "module": "agents.sandbox.session.base_sandbox_session", + "name": "BaseSandboxSession" + }, + { + "canonical_module": "agents.sandbox.session.sandbox_session", + "canonical_name": "SandboxSession", + "module": "agents.sandbox.session.sandbox_session", + "name": "SandboxSession" + }, + { + "canonical_module": "agents.sandbox.session.sandbox_session_state", + "canonical_name": "SandboxSessionState", + "module": "agents.sandbox.session.sandbox_session_state", + "name": "SandboxSessionState" + }, + { + "canonical_module": "agents.sandbox.snapshot", + "canonical_name": "NoopSnapshotSpec", + "module": "agents.sandbox.snapshot", + "name": "NoopSnapshotSpec" + }, + { + "canonical_module": "agents.sandbox.snapshot", + "canonical_name": "SnapshotBase", + "module": "agents.sandbox.snapshot", + "name": "SnapshotBase" + }, + { + "canonical_module": "agents", + "canonical_name": "AgentUpdatedStreamEvent", + "module": "agents.stream_events", + "name": "AgentUpdatedStreamEvent" + }, + { + "canonical_module": "agents", + "canonical_name": "RawResponsesStreamEvent", + "module": "agents.stream_events", + "name": "RawResponsesStreamEvent" + }, + { + "canonical_module": "agents", + "canonical_name": "StreamEvent", + "module": "agents.stream_events", + "name": "StreamEvent" + }, + { + "canonical_module": "agents", + "canonical_name": "FunctionTool", + "module": "agents.tool", + "name": "FunctionTool" + }, + { + "canonical_module": "agents", + "canonical_name": "ShellCallOutcome", + "module": "agents.tool", + "name": "ShellCallOutcome" + }, + { + "canonical_module": "agents", + "canonical_name": "ShellCommandOutput", + "module": "agents.tool", + "name": "ShellCommandOutput" + }, + { + "canonical_module": "agents", + "canonical_name": "ShellCommandRequest", + "module": "agents.tool", + "name": "ShellCommandRequest" + }, + { + "canonical_module": "agents.tool", + "canonical_name": "ShellOnApprovalFunctionResult", + "module": "agents.tool", + "name": "ShellOnApprovalFunctionResult" + }, + { + "canonical_module": "agents", + "canonical_name": "ShellResult", + "module": "agents.tool", + "name": "ShellResult" + }, + { + "canonical_module": "agents", + "canonical_name": "ShellTool", + "module": "agents.tool", + "name": "ShellTool" + }, + { + "canonical_module": "agents", + "canonical_name": "Tool", + "module": "agents.tool", + "name": "Tool" + }, + { + "canonical_module": "agents", + "canonical_name": "function_tool", + "module": "agents.tool", + "name": "function_tool" + }, + { + "canonical_module": "agents.tool_context", + "canonical_name": "ToolContext", + "module": "agents.tool_context", + "name": "ToolContext" + }, + { + "canonical_module": "agents", + "canonical_name": "tool_input_guardrail", + "module": "agents.tool_guardrails", + "name": "tool_input_guardrail" + }, + { + "canonical_module": "agents", + "canonical_name": "TracingProcessor", + "module": "agents.tracing", + "name": "TracingProcessor" + } + ], + "platform_import_errors": [ + { + "error_type": "ImportError", + "message_contains": "UnixLocalSandbox is not supported on Windows.", + "module": "agents.sandbox.sandboxes.unix_local", + "platforms": [ + "win32" + ] + } + ], + "public_properties": [ + { + "class_name": "RunResultBase", + "module": "agents.result", + "names": [ + "agent_tool_invocation", + "last_agent", + "last_response_id" + ] + }, + { + "class_name": "RunResult", + "module": "agents.result", + "names": [ + "agent_tool_invocation", + "last_agent", + "last_response_id" + ] + }, + { + "class_name": "RunResultStreaming", + "module": "agents.result", + "names": [ + "agent_tool_invocation", + "last_agent", + "last_response_id", + "run_loop_exception" + ] + } + ], + "public_modules": [ + "agents", + "agents.agent", + "agents.decorators", + "agents.editor", + "agents.extensions", + "agents.extensions.handoff_filters", + "agents.extensions.handoff_prompt", + "agents.extensions.memory", + "agents.extensions.sandbox", + "agents.guardrail", + "agents.handoffs", + "agents.items", + "agents.lifecycle", + "agents.mcp", + "agents.mcp.util", + "agents.memory", + "agents.memory.session", + "agents.memory.session_settings", + "agents.model_settings", + "agents.models", + "agents.models.interface", + "agents.models.openai_chatcompletions", + "agents.models.openai_provider", + "agents.models.openai_responses", + "agents.realtime", + "agents.realtime.config", + "agents.realtime.items", + "agents.realtime.model", + "agents.realtime.model_events", + "agents.realtime.model_inputs", + "agents.realtime.openai_realtime", + "agents.realtime.runner", + "agents.responses_websocket_session", + "agents.retry", + "agents.run", + "agents.run_config", + "agents.run_context", + "agents.run_state", + "agents.sandbox", + "agents.sandbox.capabilities", + "agents.sandbox.capabilities.capabilities", + "agents.sandbox.capabilities.compaction", + "agents.sandbox.capabilities.memory", + "agents.sandbox.capabilities.shell", + "agents.sandbox.config", + "agents.sandbox.entries", + "agents.sandbox.errors", + "agents.sandbox.manifest", + "agents.sandbox.sandboxes", + "agents.sandbox.sandboxes.unix_local", + "agents.sandbox.session", + "agents.sandbox.session.base_sandbox_session", + "agents.sandbox.session.sandbox_session", + "agents.sandbox.session.sandbox_session_state", + "agents.sandbox.snapshot", + "agents.stream_events", + "agents.tool", + "agents.tool_context", + "agents.tool_guardrails", + "agents.tracing" + ], + "required_submodule_exports": { + "agents.decorators": { + "names": [ + "function_tool", + "input_guardrail", + "output_guardrail", + "tool", + "tool_input_guardrail", + "tool_output_guardrail" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.extensions": { + "names": [ + "ToolOutputTrimmer" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.extensions.handoff_filters": { + "names": [ + "remove_all_tools", + "nest_handoff_history", + "default_handoff_history_mapper" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.extensions.memory": { + "names": [ + "AdvancedSQLiteSession", + "AsyncSQLiteSession", + "DAPR_CONSISTENCY_EVENTUAL", + "DAPR_CONSISTENCY_STRONG", + "DaprSession", + "EncryptedSession", + "MongoDBSession", + "RedisSession", + "SQLAlchemySession" + ], + "optional_bindings": { + "AsyncSQLiteSession": "aiosqlite", + "DAPR_CONSISTENCY_EVENTUAL": "dapr", + "DAPR_CONSISTENCY_STRONG": "dapr", + "DaprSession": "dapr", + "EncryptedSession": "cryptography", + "MongoDBSession": "pymongo", + "RedisSession": "redis", + "SQLAlchemySession": "sqlalchemy" + }, + "optional_exports": {} + }, + "agents.extensions.sandbox": { + "names": [ + "E2BCloudBucketMountStrategy", + "E2BSandboxClient", + "E2BSandboxClientOptions", + "E2BSandboxSession", + "E2BSandboxSessionState", + "E2BSandboxTimeouts", + "E2BSandboxType", + "DEFAULT_DAYTONA_WORKSPACE_ROOT", + "DaytonaCloudBucketMountStrategy", + "DaytonaSandboxResources", + "DaytonaSandboxClient", + "DaytonaSandboxClientOptions", + "DaytonaSandboxSession", + "DaytonaSandboxSessionState", + "DaytonaSandboxTimeouts", + "DEFAULT_BLAXEL_WORKSPACE_ROOT", + "BlaxelCloudBucketMountConfig", + "BlaxelCloudBucketMountStrategy", + "BlaxelDriveMountConfig", + "BlaxelDriveMountStrategy", + "BlaxelSandboxClient", + "BlaxelSandboxClientOptions", + "BlaxelSandboxSession", + "BlaxelSandboxSessionState", + "BlaxelTimeouts", + "CloudflareBucketMountConfig", + "CloudflareBucketMountStrategy", + "CloudflareSandboxClient", + "CloudflareSandboxClientOptions", + "CloudflareSandboxSession", + "CloudflareSandboxSessionState" + ], + "optional_bindings": {}, + "optional_exports": { + "CloudflareBucketMountConfig": "aiohttp", + "CloudflareBucketMountStrategy": "aiohttp", + "CloudflareSandboxClient": "aiohttp", + "CloudflareSandboxClientOptions": "aiohttp", + "CloudflareSandboxSession": "aiohttp", + "CloudflareSandboxSessionState": "aiohttp" + } + }, + "agents.handoffs": { + "names": [ + "Handoff", + "HandoffHistoryMapper", + "HandoffInputData", + "HandoffInputFilter", + "default_handoff_history_mapper", + "get_conversation_history_wrappers", + "handoff", + "nest_handoff_history", + "reset_conversation_history_wrappers", + "set_conversation_history_wrappers" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.mcp": { + "names": [ + "MCPServer", + "MCPServerSse", + "MCPServerSseParams", + "MCPServerStdio", + "MCPServerStdioParams", + "MCPServerStreamableHttp", + "MCPServerStreamableHttpParams", + "MCPServerManager", + "LocalMCPApprovalCallable", + "MCPUtil", + "MCPToolCustomDataContext", + "MCPToolCustomDataExtractor", + "MCPToolMetaContext", + "MCPToolMetaResolver", + "ToolFilter", + "ToolFilterCallable", + "ToolFilterContext", + "ToolFilterStatic", + "create_static_tool_filter" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.memory": { + "names": [ + "Session", + "SessionABC", + "SessionInputCallback", + "SessionSettings", + "SQLiteSession", + "OpenAIConversationsSession", + "OpenAIResponsesCompactionSession", + "OpenAIResponsesCompactionArgs", + "OpenAIResponsesCompactionAwareSession", + "is_openai_responses_compaction_aware_session" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.models": { + "names": [ + "get_default_model", + "get_default_model_settings", + "gpt_5_reasoning_settings_required", + "is_gpt_5_default", + "OpenAIAgentRegistrationConfig" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.realtime": { + "names": [ + "RealtimeAgent", + "RealtimeAgentHooks", + "RealtimeRunHooks", + "RealtimeRunner", + "realtime_handoff", + "RealtimeAudioFormat", + "RealtimeClientMessage", + "RealtimeGuardrailsSettings", + "RealtimeInputAudioNoiseReductionConfig", + "RealtimeInputAudioTranscriptionConfig", + "RealtimeModelName", + "RealtimeModelTracingConfig", + "RealtimeReasoningConfig", + "RealtimeReasoningEffort", + "RealtimeRunConfig", + "RealtimeSessionModelSettings", + "RealtimeToolExecutionConfig", + "RealtimeTurnDetectionConfig", + "RealtimeUserInput", + "RealtimeUserInputMessage", + "RealtimeUserInputText", + "RealtimeAgentEndEvent", + "RealtimeAgentStartEvent", + "RealtimeAudio", + "RealtimeAudioEnd", + "RealtimeAudioInterrupted", + "RealtimeError", + "RealtimeEventInfo", + "RealtimeGuardrailTripped", + "RealtimeHandoffEvent", + "RealtimeHistoryAdded", + "RealtimeHistoryUpdated", + "RealtimeRawModelEvent", + "RealtimeSessionEvent", + "RealtimeToolApprovalRequired", + "RealtimeToolEnd", + "RealtimeToolStart", + "AssistantMessageItem", + "AssistantText", + "InputAudio", + "InputText", + "RealtimeItem", + "RealtimeMessageItem", + "RealtimeResponse", + "RealtimeToolCallItem", + "SystemMessageItem", + "UserMessageItem", + "RealtimeModel", + "RealtimeModelConfig", + "RealtimeModelListener", + "RealtimePlaybackTracker", + "RealtimePlaybackState", + "RealtimeConnectionStatus", + "RealtimeModelAudioDoneEvent", + "RealtimeModelAudioEvent", + "RealtimeModelAudioInterruptedEvent", + "RealtimeModelCachedTokensDetails", + "RealtimeModelConnectionStatusEvent", + "RealtimeModelErrorEvent", + "RealtimeModelEvent", + "RealtimeModelExceptionEvent", + "RealtimeModelInputAudioTranscriptionCompletedEvent", + "RealtimeModelInputTokensDetails", + "RealtimeModelItemDeletedEvent", + "RealtimeModelItemUpdatedEvent", + "RealtimeModelOtherEvent", + "RealtimeModelOutputTextDeltaEvent", + "RealtimeModelOutputTokensDetails", + "RealtimeModelToolCallEvent", + "RealtimeModelTranscriptDeltaEvent", + "RealtimeModelTurnEndedEvent", + "RealtimeModelTurnStartedEvent", + "RealtimeModelUsageEvent", + "RealtimeModelInputTextContent", + "RealtimeModelRawClientMessage", + "RealtimeModelSendAudio", + "RealtimeModelSendEvent", + "RealtimeModelSendInterrupt", + "RealtimeModelSendRawMessage", + "RealtimeModelSendSessionUpdate", + "RealtimeModelSendToolOutput", + "RealtimeModelSendUserInput", + "RealtimeModelUserInput", + "RealtimeModelUserInputMessage", + "DEFAULT_MODEL_SETTINGS", + "OpenAIRealtimeSIPModel", + "OpenAIRealtimeWebSocketModel", + "get_api_key", + "RealtimeSession" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.responses_websocket_session": { + "names": [ + "ResponsesWebSocketSession", + "responses_websocket_session" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.run": { + "names": [ + "AgentRunner", + "Runner", + "RunConfig", + "RunOptions", + "RunState", + "RunContextWrapper", + "ModelInputData", + "CallModelData", + "CallModelInputFilter", + "ToolNameCollisionPolicy", + "ReasoningItemIdPolicy", + "ToolExecutionConfig", + "ToolErrorFormatter", + "ToolErrorFormatterArgs", + "ToolNotFoundBehavior", + "DEFAULT_MAX_TURNS", + "set_default_agent_runner", + "get_default_agent_runner" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.run_config": { + "names": [ + "DEFAULT_MAX_TURNS", + "ToolNameCollisionPolicy", + "CallModelData", + "CallModelInputFilter", + "ModelInputData", + "ReasoningItemIdPolicy", + "RunConfig", + "RunOptions", + "SandboxArchiveLimits", + "SandboxConcurrencyLimits", + "SandboxRunConfig", + "ToolExecutionConfig", + "ToolErrorFormatter", + "ToolErrorFormatterArgs", + "_default_trace_include_sensitive_data" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.sandbox": { + "names": [ + "Capability", + "Dir", + "ErrorCode", + "ExecResult", + "ExposedPortEndpoint", + "ExposedPortUnavailableError", + "ExecTimeoutError", + "ExecTransportError", + "FileMode", + "Group", + "LocalFile", + "LocalSnapshot", + "LocalSnapshotSpec", + "Manifest", + "MemoryLayoutConfig", + "MemoryReadConfig", + "MemoryGenerateConfig", + "RemoteSnapshot", + "RemoteSnapshotSpec", + "Permissions", + "SandboxAgent", + "SandboxArchiveLimits", + "SandboxPathGrant", + "SandboxConcurrencyLimits", + "SandboxError", + "SandboxRunConfig", + "SnapshotSpec", + "WorkspaceArchiveReadError", + "WorkspaceArchiveWriteError", + "WorkspaceReadNotFoundError", + "WorkspaceWriteTypeError", + "User", + "resolve_snapshot" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.sandbox.capabilities": { + "names": [ + "Capability", + "Capabilities", + "Compaction", + "CompactionModelInfo", + "CompactionPolicy", + "DynamicCompactionPolicy", + "FilesystemToolSet", + "LazySkillSource", + "LocalDirLazySkillSource", + "Memory", + "Shell", + "ShellToolSet", + "Skill", + "SkillMetadata", + "Skills", + "StaticCompactionPolicy", + "Filesystem" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.sandbox.entries": { + "names": [ + "AzureBlobMount", + "BaseEntry", + "BoxMount", + "Dir", + "File", + "DockerVolumeMountStrategy", + "FuseMountPattern", + "GCSMount", + "GitRepo", + "InContainerMountStrategy", + "LocalDir", + "LocalFile", + "Mount", + "MountPattern", + "MountPatternBase", + "MountStrategy", + "MountStrategyBase", + "MountpointMountPattern", + "R2Mount", + "RcloneMountPattern", + "S3Mount", + "S3FilesMount", + "S3FilesMountPattern", + "resolve_workspace_path" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.sandbox.session": { + "names": [ + "BaseSandboxClient", + "BaseSandboxClientOptions", + "BaseSandboxSession", + "CallbackSink", + "ChainedSink", + "ClientOptionsT", + "Dependencies", + "DependenciesBindingError", + "DependenciesError", + "DependenciesMissingDependencyError", + "DependencyKey", + "ExposedPortEndpoint", + "EventPayloadPolicy", + "EventSink", + "HttpProxySink", + "Instrumentation", + "JsonlOutboxSink", + "SandboxSession", + "SandboxSessionEvent", + "SandboxSessionFinishEvent", + "SandboxSessionStartEvent", + "SandboxSessionState", + "WorkspaceJsonlSink", + "event_to_json_line", + "validate_sandbox_session_event" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.tracing": { + "names": [ + "add_trace_processor", + "agent_span", + "custom_span", + "flush_traces", + "function_span", + "generation_span", + "get_current_span", + "get_current_trace", + "get_trace_provider", + "guardrail_span", + "handoff_span", + "response_span", + "set_trace_processors", + "set_trace_provider", + "set_tracing_disabled", + "TracingConfig", + "TraceCtxManager", + "trace", + "task_span", + "turn_span", + "Trace", + "SpanError", + "Span", + "SpanData", + "AgentSpanData", + "CustomSpanData", + "FunctionSpanData", + "GenerationSpanData", + "GuardrailSpanData", + "HandoffSpanData", + "MCPListToolsSpanData", + "ResponseSpanData", + "SpeechGroupSpanData", + "SpeechSpanData", + "TaskSpanData", + "TranscriptionSpanData", + "TurnSpanData", + "TracingProcessor", + "TraceProvider", + "gen_trace_id", + "gen_span_id", + "speech_group_span", + "speech_span", + "transcription_span", + "mcp_tools_span" + ], + "optional_bindings": {}, + "optional_exports": {} + } + }, + "required_top_level_exports": [ + "Agent", + "AgentBase", + "AgentToolStreamEvent", + "StopAtTools", + "ToolsToFinalOutputFunction", + "ToolsToFinalOutputResult", + "default_handoff_history_mapper", + "get_conversation_history_wrappers", + "nest_handoff_history", + "reset_conversation_history_wrappers", + "set_conversation_history_wrappers", + "Runner", + "apply_diff", + "run_demo_loop", + "Model", + "ModelProvider", + "ModelTracing", + "ModelSettings", + "ModelRetryAdvice", + "ModelRetryAdviceRequest", + "ModelRetryBackoffSettings", + "ModelRetryNormalizedError", + "ModelRetrySettings", + "RetryDecision", + "RetryPolicy", + "RetryPolicyContext", + "retry_policies", + "OpenAIChatCompletionsModel", + "MultiProvider", + "OpenAIProvider", + "OpenAIAgentRegistrationConfig", + "OpenAIResponsesModel", + "OpenAIResponsesWSModel", + "AgentOutputSchema", + "AgentOutputSchemaBase", + "Computer", + "AsyncComputer", + "Environment", + "Button", + "AgentsException", + "InputGuardrailTripwireTriggered", + "OutputGuardrailTripwireTriggered", + "ToolInputGuardrailTripwireTriggered", + "ToolOutputGuardrailTripwireTriggered", + "DynamicPromptFunction", + "GenerateDynamicPromptData", + "Prompt", + "MaxTurnsExceeded", + "MCPToolCancellationError", + "ModelBehaviorError", + "ModelRefusalError", + "ToolTimeoutError", + "UserError", + "InputGuardrail", + "InputGuardrailResult", + "OutputGuardrail", + "OutputGuardrailResult", + "GuardrailFunctionOutput", + "input_guardrail", + "output_guardrail", + "ToolInputGuardrail", + "ToolOutputGuardrail", + "ToolGuardrailFunctionOutput", + "ToolInputGuardrailData", + "ToolInputGuardrailResult", + "ToolOutputGuardrailData", + "ToolOutputGuardrailResult", + "tool_input_guardrail", + "tool_output_guardrail", + "handoff", + "Handoff", + "HandoffInputData", + "HandoffInputFilter", + "TResponseInputItem", + "MessageOutputItem", + "ModelResponse", + "RunItem", + "HandoffCallItem", + "HandoffOutputItem", + "ToolApprovalItem", + "MCPApprovalRequestItem", + "MCPApprovalResponseItem", + "MCPListToolsItem", + "ToolCallItem", + "ToolCallOutputItem", + "ToolSearchCallItem", + "ToolSearchOutputItem", + "ToolOrigin", + "ToolOriginType", + "ReasoningItem", + "ItemHelpers", + "RunHooks", + "AgentHooks", + "Session", + "SessionABC", + "SessionSettings", + "SQLiteSession", + "OpenAIConversationsSession", + "OpenAIResponsesCompactionSession", + "OpenAIResponsesCompactionArgs", + "OpenAIResponsesCompactionAwareSession", + "is_openai_responses_compaction_aware_session", + "CompactionItem", + "AgentHookContext", + "RunContextWrapper", + "TContext", + "RunErrorDetails", + "RunErrorData", + "RunErrorHandler", + "RunErrorHandlerInput", + "RunErrorHandlerResult", + "RunErrorHandlers", + "AgentToolInvocation", + "RunResult", + "RunResultStreaming", + "ResponsesWebSocketSession", + "RunConfig", + "ToolNameCollisionPolicy", + "ReasoningItemIdPolicy", + "ToolExecutionConfig", + "ToolErrorFormatter", + "ToolErrorFormatterArgs", + "ToolNotFoundBehavior", + "RunState", + "RawResponsesStreamEvent", + "RunItemStreamEvent", + "AgentUpdatedStreamEvent", + "StreamEvent", + "FunctionTool", + "FunctionToolCustomDataContext", + "FunctionToolCustomDataExtractor", + "FunctionToolResult", + "ComputerTool", + "ComputerToolCustomDataContext", + "ComputerToolCustomDataExtractor", + "ComputerProvider", + "CustomTool", + "CustomToolCustomDataContext", + "CustomToolCustomDataExtractor", + "FileSearchTool", + "CodeInterpreterTool", + "ImageGenerationTool", + "LocalShellCommandRequest", + "LocalShellExecutor", + "LocalShellTool", + "ShellActionRequest", + "ShellCallData", + "ShellCallOutcome", + "ShellCommandOutput", + "ShellCommandRequest", + "ShellToolLocalSkill", + "ShellToolSkillReference", + "ShellToolInlineSkillSource", + "ShellToolInlineSkill", + "ShellToolContainerSkill", + "ShellToolContainerNetworkPolicyDomainSecret", + "ShellToolContainerNetworkPolicyAllowlist", + "ShellToolContainerNetworkPolicyDisabled", + "ShellToolContainerNetworkPolicy", + "ShellToolLocalEnvironment", + "ShellToolContainerAutoEnvironment", + "ShellToolContainerReferenceEnvironment", + "ShellToolHostedEnvironment", + "ShellToolEnvironment", + "ShellExecutor", + "ShellResult", + "ShellTool", + "ApplyPatchEditor", + "ApplyPatchOperation", + "ApplyPatchResult", + "ApplyPatchTool", + "ApplyPatchToolCustomDataContext", + "ApplyPatchToolCustomDataExtractor", + "ProgrammaticToolCallingTool", + "Tool", + "ToolCaller", + "WebSearchTool", + "HostedMCPTool", + "MCPToolApprovalFunction", + "MCPToolApprovalRequest", + "MCPToolApprovalFunctionResult", + "ToolOutputText", + "ToolOutputTextDict", + "ToolOutputImage", + "ToolOutputImageDict", + "ToolOutputFileContent", + "ToolOutputFileContentDict", + "ToolSearchTool", + "function_tool", + "tool_namespace", + "resolve_computer", + "dispose_resolved_computers", + "Usage", + "add_trace_processor", + "agent_span", + "custom_span", + "flush_traces", + "function_span", + "generation_span", + "get_current_span", + "get_current_trace", + "guardrail_span", + "handoff_span", + "response_span", + "set_trace_processors", + "set_trace_provider", + "set_tracing_disabled", + "speech_group_span", + "transcription_span", + "speech_span", + "mcp_tools_span", + "task_span", + "trace", + "turn_span", + "Trace", + "TracingProcessor", + "SpanError", + "Span", + "SpanData", + "AgentSpanData", + "CustomSpanData", + "FunctionSpanData", + "GenerationSpanData", + "GuardrailSpanData", + "HandoffSpanData", + "SpeechGroupSpanData", + "SpeechSpanData", + "MCPListToolsSpanData", + "ResponseSpanData", + "TaskSpanData", + "TranscriptionSpanData", + "TurnSpanData", + "set_default_openai_key", + "set_default_openai_client", + "set_default_openai_api", + "set_default_openai_responses_transport", + "OpenAIResponsesWebSocketOptions", + "set_default_openai_harness", + "set_default_openai_agent_registration", + "responses_websocket_session", + "set_tracing_export_api_key", + "enable_verbose_stdout_logging", + "gen_trace_id", + "gen_span_id", + "default_tool_error_function", + "sandbox", + "__version__" + ], + "submodule_export_exclusions": [ + "agents.sandbox.sandboxes" + ] +} diff --git a/tests/fixtures/run_state/README.md b/tests/fixtures/run_state/README.md new file mode 100644 index 0000000000..f111591b0d --- /dev/null +++ b/tests/fixtures/run_state/README.md @@ -0,0 +1,15 @@ +# RunState compatibility corpus + +The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.15. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture. + +Regenerate the feature corpus from the recorded historical source trees with: + +```bash +UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/generate_corpus.py +``` + +The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout. + +Versions 1.7 and 1.8 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. Their fixtures are therefore marked `canonical_compatibility`: the recorded 1.9 writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. + +Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison. diff --git a/tests/fixtures/run_state/features/v1_10_unlimited_max_turns.json b/tests/fixtures/run_state/features/v1_10_unlimited_max_turns.json new file mode 100644 index 0000000000..7aff920d4e --- /dev/null +++ b/tests/fixtures/run_state/features/v1_10_unlimited_max_turns.json @@ -0,0 +1,55 @@ +{ + "$schemaVersion": "1.10", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": null, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_11_tool_output_custom_data.json b/tests/fixtures/run_state/features/v1_11_tool_output_custom_data.json new file mode 100644 index 0000000000..e7e2446ad7 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_11_tool_output_custom_data.json @@ -0,0 +1,77 @@ +{ + "$schemaVersion": "1.11", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [ + { + "agent": { + "name": "compat-agent" + }, + "custom_data": { + "ids": [ + "a", + "b" + ], + "ui": { + "kind": "chart" + } + }, + "output": "result", + "raw_item": { + "call_id": "custom-data-1", + "output": "result", + "type": "function_call_output" + }, + "type": "tool_call_output_item" + } + ], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_12_input_cache_write_usage.json b/tests/fixtures/run_state/features/v1_12_input_cache_write_usage.json new file mode 100644 index 0000000000..78398bc5e6 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_12_input_cache_write_usage.json @@ -0,0 +1,56 @@ +{ + "$schemaVersion": "1.12", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 10, + "input_tokens_details": [ + { + "cache_write_tokens": 7, + "cached_tokens": 3 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 1, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_13_nested_history_ownership.json b/tests/fixtures/run_state/features/v1_13_nested_history_ownership.json new file mode 100644 index 0000000000..21d92b6e7d --- /dev/null +++ b/tests/fixtures/run_state/features/v1_13_nested_history_ownership.json @@ -0,0 +1,120 @@ +{ + "$schemaVersion": "1.13", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "content": [ + { + "annotations": [], + "text": "owned history", + "type": "output_text" + } + ], + "id": "owned-message", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "type": "message_output_item" + } + ], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [ + 0 + ], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [ + { + "digest": "64239013ae7c72683ac8cc61af7c8fe421c6f24d12b77beeef4a37b72141194d", + "index": 0, + "input_index": 0 + } + ], + "no_active_agent_run": true, + "original_input": [ + { + "content": [ + { + "annotations": [], + "text": "owned history", + "type": "output_text" + } + ], + "id": "owned-message", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "content": [ + { + "annotations": [], + "text": "owned history", + "type": "output_text" + } + ], + "id": "owned-message", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "type": "message_output_item" + } + ], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_13_programmatic_tool_calling.json b/tests/fixtures/run_state/features/v1_13_programmatic_tool_calling.json new file mode 100644 index 0000000000..55b4024264 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_13_programmatic_tool_calling.json @@ -0,0 +1,206 @@ +{ + "$schemaVersion": "1.13", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "call_id": "program-call", + "code": "lookup()", + "fingerprint": "fingerprint", + "id": "program-item", + "type": "program" + }, + "type": "tool_call_item" + }, + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "arguments": "{}", + "call_id": "function-call", + "caller": { + "caller_id": "program-call", + "type": "program" + }, + "id": "function-item", + "name": "lookup", + "type": "function_call" + }, + "tool_name": "lookup", + "type": "tool_call_item" + }, + { + "agent": { + "name": "compat-agent" + }, + "output": "done", + "raw_item": { + "call_id": "program-call", + "id": "program-output-item", + "result": "done", + "status": "completed", + "type": "program_output" + }, + "type": "tool_call_output_item" + } + ], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [ + null, + null, + null + ], + "input_guardrail_results": [], + "last_model_response": { + "output": [ + { + "call_id": "program-call", + "code": "lookup()", + "fingerprint": "fingerprint", + "id": "program-item", + "type": "program" + }, + { + "arguments": "{}", + "call_id": "function-call", + "caller": { + "caller_id": "program-call", + "type": "program" + }, + "id": "function-item", + "name": "lookup", + "type": "function_call" + }, + { + "call_id": "program-call", + "id": "program-output-item", + "result": "done", + "status": "completed", + "type": "program_output" + } + ], + "request_id": null, + "response_id": "response-program", + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [ + { + "output": [ + { + "call_id": "program-call", + "code": "lookup()", + "fingerprint": "fingerprint", + "id": "program-item", + "type": "program" + }, + { + "arguments": "{}", + "call_id": "function-call", + "caller": { + "caller_id": "program-call", + "type": "program" + }, + "id": "function-item", + "name": "lookup", + "type": "function_call" + }, + { + "call_id": "program-call", + "id": "program-output-item", + "result": "done", + "status": "completed", + "type": "program_output" + } + ], + "request_id": null, + "response_id": "response-program", + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + } + ], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_14_hosted_mcp_approval_scope.json b/tests/fixtures/run_state/features/v1_14_hosted_mcp_approval_scope.json new file mode 100644 index 0000000000..6bd4a5fc8a --- /dev/null +++ b/tests/fixtures/run_state/features/v1_14_hosted_mcp_approval_scope.json @@ -0,0 +1,84 @@ +{ + "$schemaVersion": "1.14", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "hosted_mcp_approvals": [ + { + "decision": { + "approved": true, + "rejected": [] + }, + "identity": { + "server_label": "accounts-server", + "tool_name": "lookup_account", + "type": "server_tool" + } + }, + { + "decision": { + "approved": [ + "mcp-request-1" + ], + "rejected": [] + }, + "identity": { + "request_id": "mcp-request-1", + "tool_name": "lookup_account", + "type": "query" + } + } + ], + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_15_canonical_invocation_identity.json b/tests/fixtures/run_state/features/v1_15_canonical_invocation_identity.json new file mode 100644 index 0000000000..b5e94f99b0 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_15_canonical_invocation_identity.json @@ -0,0 +1,74 @@ +{ + "$schemaVersion": "1.15", + "auto_previous_response_id": false, + "context": { + "approvals": { + "lookup_account": { + "approved": [ + "function-request-1" + ], + "rejected": [] + } + }, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": { + "function-request-1": { + "approval_scope": "73dc418185c1335b2f8ae49c817524e389e0f47c666a4578a8fb9d921756bbd4", + "completed": false, + "executed": false, + "fingerprint": "1f6f2dba51c70f18ab8f13f23bc34068e31ce849b6d7323575a0a99b7303a356", + "type": "function_call" + } + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_2_reasoning_item_id_policy.json b/tests/fixtures/run_state/features/v1_2_reasoning_item_id_policy.json new file mode 100644 index 0000000000..554e8c8e7f --- /dev/null +++ b/tests/fixtures/run_state/features/v1_2_reasoning_item_id_policy.json @@ -0,0 +1,66 @@ +{ + "$schemaVersion": "1.2", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "id": "reasoning-1", + "summary": [], + "type": "reasoning" + }, + "type": "reasoning_item" + } + ], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": "omit", + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_3_resumed_trace_state.json b/tests/fixtures/run_state/features/v1_3_resumed_trace_state.json new file mode 100644 index 0000000000..24c48d0390 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_3_resumed_trace_state.json @@ -0,0 +1,59 @@ +{ + "$schemaVersion": "1.3", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": { + "id": "trace_d2a749aa8313480eba2c32d382a6a62d", + "object": "trace", + "tracing_api_key_hash": "367cb46be96fa11da87030dbc0cf83bd0294053ac150e835b916990d2f5e6d0e", + "workflow_name": "compatibility trace" + } +} diff --git a/tests/fixtures/run_state/features/v1_4_request_id.json b/tests/fixtures/run_state/features/v1_4_request_id.json new file mode 100644 index 0000000000..88d814022b --- /dev/null +++ b/tests/fixtures/run_state/features/v1_4_request_id.json @@ -0,0 +1,98 @@ +{ + "$schemaVersion": "1.4", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": { + "output": [], + "request_id": "request-1", + "response_id": "response-1", + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [ + { + "output": [], + "request_id": "request-1", + "response_id": "response-1", + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + } + ], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_5_tool_search_and_display_metadata.json b/tests/fixtures/run_state/features/v1_5_tool_search_and_display_metadata.json new file mode 100644 index 0000000000..a6df941e2d --- /dev/null +++ b/tests/fixtures/run_state/features/v1_5_tool_search_and_display_metadata.json @@ -0,0 +1,96 @@ +{ + "$schemaVersion": "1.5", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "arguments": { + "query": "account balance" + }, + "execution": "server", + "status": "completed", + "type": "tool_search_call" + }, + "type": "tool_search_call_item" + }, + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "execution": "server", + "status": "completed", + "tools": [], + "type": "tool_search_output" + }, + "type": "tool_search_output_item" + }, + { + "agent": { + "name": "compat-agent" + }, + "description": "Reads the account balance.", + "raw_item": { + "arguments": "{}", + "call_id": "call-display", + "name": "lookup", + "status": "completed", + "type": "function_call" + }, + "title": "Lookup account", + "type": "tool_call_item" + } + ], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_6_approval_rejection_message.json b/tests/fixtures/run_state/features/v1_6_approval_rejection_message.json new file mode 100644 index 0000000000..05aa5b08c8 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_6_approval_rejection_message.json @@ -0,0 +1,64 @@ +{ + "$schemaVersion": "1.6", + "auto_previous_response_id": false, + "context": { + "approvals": { + "sensitive_tool": { + "approved": [], + "rejected": [ + "approval-1" + ], + "rejection_messages": { + "approval-1": "Denied by release reviewer" + } + } + }, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_7_duplicate_agent_identity_and_sandbox.json b/tests/fixtures/run_state/features/v1_7_duplicate_agent_identity_and_sandbox.json new file mode 100644 index 0000000000..ac69aa9c74 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_7_duplicate_agent_identity_and_sandbox.json @@ -0,0 +1,63 @@ +{ + "$schemaVersion": "1.7", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "identity": "compat-agent#2", + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "sandbox": { + "provider": "compat-provider", + "requires_rebind": true, + "session_state": { + "session_id": "sandbox-session-1" + } + }, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_8_prompt_cache_key.json b/tests/fixtures/run_state/features/v1_8_prompt_cache_key.json new file mode 100644 index 0000000000..cd748646ec --- /dev/null +++ b/tests/fixtures/run_state/features/v1_8_prompt_cache_key.json @@ -0,0 +1,55 @@ +{ + "$schemaVersion": "1.8", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": "prompt-cache-key-1", + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/features/v1_9_custom_tool_call_and_tool_origin.json b/tests/fixtures/run_state/features/v1_9_custom_tool_call_and_tool_origin.json new file mode 100644 index 0000000000..a30cc81325 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_9_custom_tool_call_and_tool_origin.json @@ -0,0 +1,87 @@ +{ + "$schemaVersion": "1.9", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "call_id": "custom-call-1", + "input": "account-1", + "name": "custom_lookup", + "type": "custom_tool_call" + }, + "tool_name": "custom_lookup", + "tool_origin": { + "type": "function" + }, + "type": "tool_call_item" + }, + { + "agent": { + "name": "compat-agent" + }, + "output": "custom result", + "raw_item": { + "call_id": "custom-call-1", + "output": "custom result", + "type": "custom_tool_call_output" + }, + "tool_origin": { + "type": "function" + }, + "type": "tool_call_output_item" + } + ], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/generate_corpus.py b/tests/fixtures/run_state/generate_corpus.py new file mode 100644 index 0000000000..994b7dd4f3 --- /dev/null +++ b/tests/fixtures/run_state/generate_corpus.py @@ -0,0 +1,601 @@ +from __future__ import annotations + +import json +import os +import subprocess +import tarfile +import tempfile +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path +from typing import cast + +ROOT = Path(__file__).resolve().parents[3] +OUTPUT = Path(__file__).resolve().parent / "features" +SECURITY_OUTPUT = Path(__file__).resolve().parent / "security" +RESUME_OUTPUT = Path(__file__).resolve().parent / "resume" + +BASE = """ +import json + +from agents import Agent, RunContextWrapper, RunState + +agent = Agent(name="compat-agent") +state = RunState( + context=RunContextWrapper(context={}), + original_input="historical input", + starting_agent=agent, + max_turns=10, +) +""" + + +@dataclass(frozen=True) +class Scenario: + version: str + commit: str + name: str + code: str + provenance: str = "historical_writer" + emitted_version: str | None = None + + +SCENARIOS = ( + Scenario( + "1.2", + "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c", + "reasoning_item_id_policy", + """ +from agents.items import ReasoningItem +from openai.types.responses import ResponseReasoningItem + +state.set_reasoning_item_id_policy("omit") +state._generated_items = [ + ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem(type="reasoning", id="reasoning-1", summary=[]), + ) +] +""", + ), + Scenario( + "1.3", + "6814a54711f591712c893f0a8be1cf56c512ae63", + "resumed_trace_state", + """ +from agents import trace + +with trace( + workflow_name="compatibility trace", + tracing={"api_key": "fixed-trace-key"}, +) as run_trace: + state.set_trace(run_trace) +""", + ), + Scenario( + "1.4", + "159beb56130f7d85192acfd593c9168757984dc0", + "request_id", + """ +from agents import ModelResponse, Usage + +state._model_responses = [ + ModelResponse(output=[], usage=Usage(), response_id="response-1", request_id="request-1") +] +""", + ), + Scenario( + "1.5", + "e0f6a28c20887b83dd4e1532cdfe0b78a01d4961", + "tool_search_and_display_metadata", + """ +from agents.items import ToolCallItem, ToolSearchCallItem, ToolSearchOutputItem +from openai.types.responses import ResponseFunctionToolCall + +state._generated_items = [ + ToolSearchCallItem( + agent=agent, + raw_item={ + "type": "tool_search_call", + "arguments": {"query": "account balance"}, + "execution": "server", + "status": "completed", + }, + ), + ToolSearchOutputItem( + agent=agent, + raw_item={ + "type": "tool_search_output", + "execution": "server", + "status": "completed", + "tools": [], + }, + ), + ToolCallItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="lookup", + call_id="call-display", + status="completed", + arguments="{}", + ), + title="Lookup account", + description="Reads the account balance.", + ), +] +""", + ), + Scenario( + "1.6", + "86739b1a0f94d73f9a35e68f6f25ddc0beaa2078", + "approval_rejection_message", + """ +from agents.items import ToolApprovalItem +from openai.types.responses import ResponseFunctionToolCall + +approval = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="sensitive_tool", + call_id="approval-1", + status="completed", + arguments="{}", + ), +) +state.reject(approval, rejection_message="Denied by release reviewer") +""", + ), + Scenario( + "1.7", + "2d665c9a67fdf3198a0daa0f9978b8239d78e78b", + "duplicate_agent_identity_and_sandbox", + """ +from agents import handoff + +duplicate = Agent(name="compat-agent") +agent.handoffs = [handoff(duplicate)] +state._current_agent = duplicate +state._sandbox = { + "provider": "compat-provider", + "session_state": {"session_id": "sandbox-session-1"}, + "requires_rebind": True, +} +""", + provenance="canonical_compatibility", + emitted_version="1.9", + ), + Scenario( + "1.8", + "2d665c9a67fdf3198a0daa0f9978b8239d78e78b", + "prompt_cache_key", + """ +state._generated_prompt_cache_key = "prompt-cache-key-1" +""", + provenance="canonical_compatibility", + emitted_version="1.9", + ), + Scenario( + "1.9", + "bed924b45d97ea0080655329129075e457d46c6d", + "custom_tool_call_and_tool_origin", + """ +from agents import ToolOrigin, ToolOriginType +from agents.items import ToolCallItem, ToolCallOutputItem + +origin = ToolOrigin(type=ToolOriginType.FUNCTION) +state._generated_items = [ + ToolCallItem( + agent=agent, + raw_item={ + "type": "custom_tool_call", + "call_id": "custom-call-1", + "name": "custom_lookup", + "input": "account-1", + }, + tool_origin=origin, + ), + ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "custom_tool_call_output", + "call_id": "custom-call-1", + "output": "custom result", + }, + output="custom result", + tool_origin=origin, + ), +] +""", + ), + Scenario( + "1.10", + "a4ba63f7045d27998a0b1bc1ee64a313574ee139", + "unlimited_max_turns", + """ +state._max_turns = None +""", + ), + Scenario( + "1.11", + "70c447e14ffabdf29bfaeb4bb3df33bb6dfaaab7", + "tool_output_custom_data", + """ +from agents.items import ToolCallOutputItem + +state._generated_items = [ + ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "custom-data-1", + "output": "result", + }, + output="result", + custom_data={"ui": {"kind": "chart"}, "ids": ["a", "b"]}, + ) +] +""", + ), + Scenario( + "1.12", + "95df2c99a745655ba71c763b8ac036283e9df87e", + "input_cache_write_usage", + """ +from agents.usage import InputTokensDetails + +state._context.usage.requests = 1 +state._context.usage.input_tokens = 10 +state._context.usage.input_tokens_details = InputTokensDetails.model_validate( + {"cache_write_tokens": 7, "cached_tokens": 3} +) +""", + ), + Scenario( + "1.13", + "ece7b0e5861d6c839041d5f860a2a2cf08bba81e", + "programmatic_tool_calling", + """ +from agents.items import ModelResponse, ToolCallItem, ToolCallOutputItem +from agents.usage import Usage +from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call import CallerProgram +from openai.types.responses.response_output_item import Program, ProgramOutput + +program = Program( + id="program-item", + call_id="program-call", + code="lookup()", + fingerprint="fingerprint", + type="program", +) +function_call = ResponseFunctionToolCall( + id="function-item", + call_id="function-call", + name="lookup", + arguments="{}", + caller=CallerProgram(type="program", caller_id="program-call"), + type="function_call", +) +program_output = ProgramOutput( + id="program-output-item", + call_id="program-call", + result="done", + status="completed", + type="program_output", +) +state._model_responses = [ + ModelResponse( + output=[program, function_call, program_output], + usage=Usage(), + response_id="response-program", + ) +] +state._generated_items = [ + ToolCallItem(agent=agent, raw_item=program), + ToolCallItem(agent=agent, raw_item=function_call), + ToolCallOutputItem(agent=agent, raw_item=program_output, output="done"), +] +""", + ), + Scenario( + "1.13", + "ece7b0e5861d6c839041d5f860a2a2cf08bba81e", + "nested_history_ownership", + """ +from agents.items import MessageOutputItem +from agents.run_internal.items import ( + NestedHistoryOwnedItemRef, + digest_input_item, + run_item_to_input_item, +) +from openai.types.responses import ResponseOutputMessage, ResponseOutputText + +message_item = MessageOutputItem( + agent=agent, + raw_item=ResponseOutputMessage( + id="owned-message", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText( + type="output_text", + text="owned history", + annotations=[], + ) + ], + ), +) +input_item = run_item_to_input_item(message_item) +digest = digest_input_item(input_item) +assert input_item is not None and digest is not None +state._original_input = [input_item] +state._session_items = [message_item] +state._generated_items = [message_item] +state._nested_history_owned_session_item_refs = [ + NestedHistoryOwnedItemRef( + session_index=0, + digest=digest, + input_index=0, + run_item=message_item, + input_item=input_item, + ) +] +""", + ), + Scenario( + "1.14", + "0c60a196af1236044a829e39b10f22a9cedaa326", + "hosted_mcp_approval_scope", + """ +from agents.items import ToolApprovalItem +from openai.types.responses.response_output_item import McpApprovalRequest + +approval = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="mcp-request-1", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="accounts-server", + ), +) +state.approve(approval, always_approve=True) +""", + ), + Scenario( + "1.15", + "9c6cadf8201f4908ced206d49ed9f1489dc9db67", + "canonical_invocation_identity", + """ +from agents.items import ToolApprovalItem +from openai.types.responses import ResponseFunctionToolCall + +approval = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="lookup_account", + call_id="function-request-1", + status="completed", + arguments='{"account_id":"account-1"}', + ), +) +state.approve(approval) +""", + ), +) + + +LEGACY_MOUNT_CREDENTIALS = Scenario( + "1.13", + "92aa1b905306d7f5a130d911061c44cddeaa6e20", + "legacy_mount_credentials", + """ +from agents.sandbox import Manifest +from agents.sandbox.entries import DockerVolumeMountStrategy, S3Mount +from agents.sandbox.snapshot import NoopSnapshot + +manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="compat-bucket", + access_key_id="RUNSTATE_ACCESS_SENTINEL_42", + secret_access_key="RUNSTATE_SECRET_SENTINEL_42", + session_token="RUNSTATE_TOKEN_SENTINEL_42", + region="us-east-1", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"vfs-cache-mode": "off"}, + ), + ) + } +) +session_state = { + "type": "unix_local", + "session_id": "00000000-0000-0000-0000-000000000042", + "snapshot": NoopSnapshot(id="legacy-snapshot").model_dump(mode="json"), + "manifest": manifest.model_dump(mode="json"), + "exposed_ports": [], + "workspace_root_owned": False, +} +state._sandbox = { + "backend_id": "unix_local", + "current_agent_key": agent.name, + "current_agent_name": agent.name, + "session_state": session_state, + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": session_state, + } + }, +} +""", +) + + +PENDING_TOOL_APPROVAL = Scenario( + "1.13", + "92aa1b905306d7f5a130d911061c44cddeaa6e20", + "pending_tool_approval", + r""" +import asyncio + +from agents import Runner, function_tool +from tests.fake_model import FakeModel +from tests.test_responses import get_function_tool_call + +@function_tool(needs_approval=True) +def historical_approval(account_id: str) -> str: + return f"approved:{account_id}" + +async def produce_pending_state(): + model = FakeModel() + model.add_multiple_turn_outputs( + [[get_function_tool_call( + "historical_approval", + '{"account_id":"account-1"}', + call_id="historical-approval-1", + )]] + ) + run_agent = Agent(name="compat-agent", model=model, tools=[historical_approval]) + result = await Runner.run(run_agent, "historical input") + assert len(result.interruptions) == 1 + return result.to_state() + +state = asyncio.run(produce_pending_state()) +""", +) + + +def _extract(commit: str, destination: Path) -> None: + archive = subprocess.check_output(["git", "archive", commit], cwd=ROOT) + with tarfile.open(fileobj=BytesIO(archive)) as bundle: + bundle.extractall(destination, filter="data") + + +def _generate(scenario: Scenario) -> dict[str, object]: + with tempfile.TemporaryDirectory(prefix=f"run-state-{scenario.version}-") as temp: + tree = Path(temp) + _extract(scenario.commit, tree) + env = dict(os.environ) + env["UV_DEFAULT_INDEX"] = "https://pypi.org/simple" + for variable in ( + "ALL_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "http_proxy", + "https_proxy", + ): + env.pop(variable, None) + completed = subprocess.run( + [ + "uv", + "run", + "--project", + str(tree), + "--frozen", + "--no-dev", + "python", + "-c", + BASE + scenario.code + "\nprint(json.dumps(state.to_json(), sort_keys=True))\n", + ], + cwd=tree, + env=env, + capture_output=True, + text=True, + ) + if completed.returncode: + raise RuntimeError( + f"Historical writer {scenario.commit} failed:\n" + f"{completed.stdout}\n{completed.stderr}" + ) + payload = json.loads(completed.stdout) + emitted_version = scenario.emitted_version or scenario.version + if payload["$schemaVersion"] != emitted_version: + raise RuntimeError( + f"Historical writer {scenario.commit} emitted " + f"{payload['$schemaVersion']}, expected {emitted_version}." + ) + if scenario.provenance == "canonical_compatibility": + payload["$schemaVersion"] = scenario.version + return cast(dict[str, object], payload) + + +def main() -> None: + OUTPUT.mkdir(parents=True, exist_ok=True) + feature_sources: list[dict[str, str]] = [] + for scenario in SCENARIOS: + payload = _generate(scenario) + filename = f"v{scenario.version.replace('.', '_')}_{scenario.name}.json" + (OUTPUT / filename).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + source = { + "version": scenario.version, + "feature": scenario.name, + "commit": scenario.commit, + "fixture": f"features/{filename}", + "provenance": scenario.provenance, + } + if scenario.emitted_version is not None: + source["emitted_version"] = scenario.emitted_version + source["note"] = ( + "The release-boundary schema renumbering introduced this reader version " + "without a writer that emitted it. The recorded writer emitted 1.9; only " + "the schema label is changed to exercise the canonical compatibility branch." + ) + feature_sources.append(source) + + sources_path = OUTPUT.parent / "sources.json" + sources = json.loads(sources_path.read_text(encoding="utf-8")) + sources["features"] = feature_sources + + SECURITY_OUTPUT.mkdir(parents=True, exist_ok=True) + security_payload = _generate(LEGACY_MOUNT_CREDENTIALS) + security_filename = "v1_13_legacy_mount_credentials.json" + (SECURITY_OUTPUT / security_filename).write_text( + json.dumps(security_payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + sources["security"] = { + "version": LEGACY_MOUNT_CREDENTIALS.version, + "feature": LEGACY_MOUNT_CREDENTIALS.name, + "commit": LEGACY_MOUNT_CREDENTIALS.commit, + "fixture": f"security/{security_filename}", + "provenance": LEGACY_MOUNT_CREDENTIALS.provenance, + "sentinels": [ + "RUNSTATE_ACCESS_SENTINEL_42", + "RUNSTATE_SECRET_SENTINEL_42", + "RUNSTATE_TOKEN_SENTINEL_42", + ], + } + + RESUME_OUTPUT.mkdir(parents=True, exist_ok=True) + resume_payload = _generate(PENDING_TOOL_APPROVAL) + resume_filename = "v1_13_pending_tool_approval.json" + (RESUME_OUTPUT / resume_filename).write_text( + json.dumps(resume_payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + sources["resume"] = { + "version": PENDING_TOOL_APPROVAL.version, + "feature": PENDING_TOOL_APPROVAL.name, + "commit": PENDING_TOOL_APPROVAL.commit, + "fixture": f"resume/{resume_filename}", + "provenance": PENDING_TOOL_APPROVAL.provenance, + } + sources_path.write_text( + json.dumps(sources, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/run_state/minimal/v1_0.json b/tests/fixtures/run_state/minimal/v1_0.json new file mode 100644 index 0000000000..6dac4975fc --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_0.json @@ -0,0 +1,53 @@ +{ + "$schemaVersion": "1.0", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_1.json b/tests/fixtures/run_state/minimal/v1_1.json new file mode 100644 index 0000000000..439ca66fa8 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_1.json @@ -0,0 +1,53 @@ +{ + "$schemaVersion": "1.1", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_10.json b/tests/fixtures/run_state/minimal/v1_10.json new file mode 100644 index 0000000000..afb1084e3f --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_10.json @@ -0,0 +1,55 @@ +{ + "$schemaVersion": "1.10", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_11.json b/tests/fixtures/run_state/minimal/v1_11.json new file mode 100644 index 0000000000..cfcd36e1b7 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_11.json @@ -0,0 +1,55 @@ +{ + "$schemaVersion": "1.11", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_12.json b/tests/fixtures/run_state/minimal/v1_12.json new file mode 100644 index 0000000000..d2aa6b23ca --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_12.json @@ -0,0 +1,56 @@ +{ + "$schemaVersion": "1.12", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_13.json b/tests/fixtures/run_state/minimal/v1_13.json new file mode 100644 index 0000000000..9a11c41887 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_13.json @@ -0,0 +1,56 @@ +{ + "$schemaVersion": "1.13", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_14.json b/tests/fixtures/run_state/minimal/v1_14.json new file mode 100644 index 0000000000..15583fcd78 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_14.json @@ -0,0 +1,58 @@ +{ + "$schemaVersion": "1.14", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_15.json b/tests/fixtures/run_state/minimal/v1_15.json new file mode 100644 index 0000000000..85f2a24af2 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_15.json @@ -0,0 +1,59 @@ +{ + "$schemaVersion": "1.15", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": {}, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_2.json b/tests/fixtures/run_state/minimal/v1_2.json new file mode 100644 index 0000000000..5137d689f9 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_2.json @@ -0,0 +1,54 @@ +{ + "$schemaVersion": "1.2", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_3.json b/tests/fixtures/run_state/minimal/v1_3.json new file mode 100644 index 0000000000..31f51ca60d --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_3.json @@ -0,0 +1,54 @@ +{ + "$schemaVersion": "1.3", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_4.json b/tests/fixtures/run_state/minimal/v1_4.json new file mode 100644 index 0000000000..01c7bd0465 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_4.json @@ -0,0 +1,54 @@ +{ + "$schemaVersion": "1.4", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_5.json b/tests/fixtures/run_state/minimal/v1_5.json new file mode 100644 index 0000000000..dc041abfed --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_5.json @@ -0,0 +1,54 @@ +{ + "$schemaVersion": "1.5", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_6.json b/tests/fixtures/run_state/minimal/v1_6.json new file mode 100644 index 0000000000..0061cdfb6d --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_6.json @@ -0,0 +1,54 @@ +{ + "$schemaVersion": "1.6", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_7.json b/tests/fixtures/run_state/minimal/v1_7.json new file mode 100644 index 0000000000..0a9027d9b8 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_7.json @@ -0,0 +1,54 @@ +{ + "$schemaVersion": "1.7", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_8.json b/tests/fixtures/run_state/minimal/v1_8.json new file mode 100644 index 0000000000..f066ff5174 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_8.json @@ -0,0 +1,54 @@ +{ + "$schemaVersion": "1.8", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_9.json b/tests/fixtures/run_state/minimal/v1_9.json new file mode 100644 index 0000000000..06658770fe --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_9.json @@ -0,0 +1,55 @@ +{ + "$schemaVersion": "1.9", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/negative/future_version.json b/tests/fixtures/run_state/negative/future_version.json new file mode 100644 index 0000000000..95c744365b --- /dev/null +++ b/tests/fixtures/run_state/negative/future_version.json @@ -0,0 +1,4 @@ +{ + "$schemaVersion": "999", + "untrusted": "RUNSTATE_SECRET_SENTINEL_42" +} diff --git a/tests/fixtures/run_state/negative/malformed_current_agent.json b/tests/fixtures/run_state/negative/malformed_current_agent.json new file mode 100644 index 0000000000..bc0b406922 --- /dev/null +++ b/tests/fixtures/run_state/negative/malformed_current_agent.json @@ -0,0 +1,4 @@ +{ + "$schemaVersion": "1.15", + "current_agent": "RUNSTATE_SECRET_SENTINEL_42" +} diff --git a/tests/fixtures/run_state/negative/missing_version.json b/tests/fixtures/run_state/negative/missing_version.json new file mode 100644 index 0000000000..0ef2a4aadd --- /dev/null +++ b/tests/fixtures/run_state/negative/missing_version.json @@ -0,0 +1,3 @@ +{ + "untrusted": "RUNSTATE_SECRET_SENTINEL_42" +} diff --git a/tests/fixtures/run_state/resume/v1_13_pending_tool_approval.json b/tests/fixtures/run_state/resume/v1_13_pending_tool_approval.json new file mode 100644 index 0000000000..6cb8b2ce07 --- /dev/null +++ b/tests/fixtures/run_state/resume/v1_13_pending_tool_approval.json @@ -0,0 +1,325 @@ +{ + "$schemaVersion": "1.13", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": null, + "context_meta": { + "omitted": false, + "original_type": "none", + "requires_deserializer": false, + "serialized_via": "none" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": { + "data": { + "interruptions": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + }, + "tool_lookup_key": { + "kind": "bare", + "name": "historical_approval" + }, + "tool_name": "historical_approval", + "tool_origin": { + "type": "function" + }, + "type": "tool_approval_item" + } + ] + }, + "type": "next_step_interruption" + }, + "current_turn": 1, + "current_turn_persisted_item_count": 0, + "generated_items": [ + { + "agent": { + "name": "compat-agent" + }, + "description": "", + "raw_item": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + }, + "tool_name": "historical_approval", + "tool_origin": { + "type": "function" + }, + "type": "tool_call_item" + }, + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + }, + "tool_lookup_key": { + "kind": "bare", + "name": "historical_approval" + }, + "tool_name": "historical_approval", + "tool_origin": { + "type": "function" + }, + "type": "tool_approval_item" + } + ], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [ + 0, + 1 + ], + "input_guardrail_results": [], + "last_model_response": { + "output": [ + { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + } + ], + "request_id": null, + "response_id": "resp-789", + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "last_processed_response": { + "apply_patch_actions": [], + "computer_actions": [], + "custom_tool_actions": [], + "functions": [ + { + "tool": { + "description": "", + "lookupKey": { + "kind": "bare", + "name": "historical_approval" + }, + "name": "historical_approval", + "paramsJsonSchema": { + "additionalProperties": false, + "properties": { + "account_id": { + "title": "Account Id", + "type": "string" + } + }, + "required": [ + "account_id" + ], + "title": "historical_approval_args", + "type": "object" + } + }, + "tool_call": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + } + } + ], + "handoffs": [], + "interruptions": [ + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + }, + "tool_lookup_key": { + "kind": "bare", + "name": "historical_approval" + }, + "tool_name": "historical_approval", + "tool_origin": { + "type": "function" + }, + "type": "tool_approval_item" + } + ], + "local_shell_actions": [], + "mcp_approval_requests": [], + "new_items": [ + { + "agent": { + "name": "compat-agent" + }, + "description": "", + "raw_item": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + }, + "tool_name": "historical_approval", + "tool_origin": { + "type": "function" + }, + "type": "tool_call_item" + } + ], + "shell_actions": [], + "tools_used": [ + "historical_approval" + ] + }, + "max_turns": 10, + "model_responses": [ + { + "output": [ + { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + } + ], + "request_id": null, + "response_id": "resp-789", + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + } + ], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [ + { + "agent": { + "name": "compat-agent" + }, + "description": "", + "raw_item": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + }, + "tool_name": "historical_approval", + "tool_origin": { + "type": "function" + }, + "type": "tool_call_item" + }, + { + "agent": { + "name": "compat-agent" + }, + "raw_item": { + "arguments": "{\"account_id\":\"account-1\"}", + "call_id": "historical-approval-1", + "id": "1", + "name": "historical_approval", + "type": "function_call" + }, + "tool_lookup_key": { + "kind": "bare", + "name": "historical_approval" + }, + "tool_name": "historical_approval", + "tool_origin": { + "type": "function" + }, + "type": "tool_approval_item" + } + ], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": { + "compat-agent": [ + "historical_approval" + ] + }, + "trace": { + "id": "trace_179a5e809d614cf89ac5a1f24b415704", + "object": "trace", + "workflow_name": "Agent workflow" + } +} diff --git a/tests/fixtures/run_state/security/v1_13_legacy_mount_credentials.json b/tests/fixtures/run_state/security/v1_13_legacy_mount_credentials.json new file mode 100644 index 0000000000..0a26590fb2 --- /dev/null +++ b/tests/fixtures/run_state/security/v1_13_legacy_mount_credentials.json @@ -0,0 +1,212 @@ +{ + "$schemaVersion": "1.13", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "sandbox": { + "backend_id": "unix_local", + "current_agent_key": "compat-agent", + "current_agent_name": "compat-agent", + "session_state": { + "exposed_ports": [], + "manifest": { + "entries": { + "remote": { + "access_key_id": "RUNSTATE_ACCESS_SENTINEL_42", + "bucket": "compat-bucket", + "description": null, + "endpoint_url": null, + "ephemeral": true, + "group": null, + "is_dir": true, + "mount_path": null, + "mount_strategy": { + "driver": "rclone", + "driver_options": { + "vfs-cache-mode": "off" + }, + "type": "docker_volume" + }, + "permissions": { + "directory": true, + "group": 5, + "other": 5, + "owner": 7 + }, + "prefix": null, + "read_only": true, + "region": "us-east-1", + "s3_provider": "AWS", + "secret_access_key": "RUNSTATE_SECRET_SENTINEL_42", + "session_token": "RUNSTATE_TOKEN_SENTINEL_42", + "type": "s3_mount" + } + }, + "environment": { + "value": {} + }, + "extra_path_grants": [], + "groups": [], + "remote_mount_command_allowlist": [ + "ls", + "find", + "stat", + "cat", + "less", + "head", + "tail", + "du", + "grep", + "rg", + "wc", + "sort", + "cut", + "cp", + "tee", + "echo", + "mkdir", + "rm" + ], + "root": "/workspace", + "users": [], + "version": 1 + }, + "session_id": "00000000-0000-0000-0000-000000000042", + "snapshot": { + "id": "legacy-snapshot", + "type": "noop" + }, + "type": "unix_local", + "workspace_root_owned": false + }, + "sessions_by_agent": { + "compat-agent": { + "agent_name": "compat-agent", + "session_state": { + "exposed_ports": [], + "manifest": { + "entries": { + "remote": { + "access_key_id": "RUNSTATE_ACCESS_SENTINEL_42", + "bucket": "compat-bucket", + "description": null, + "endpoint_url": null, + "ephemeral": true, + "group": null, + "is_dir": true, + "mount_path": null, + "mount_strategy": { + "driver": "rclone", + "driver_options": { + "vfs-cache-mode": "off" + }, + "type": "docker_volume" + }, + "permissions": { + "directory": true, + "group": 5, + "other": 5, + "owner": 7 + }, + "prefix": null, + "read_only": true, + "region": "us-east-1", + "s3_provider": "AWS", + "secret_access_key": "RUNSTATE_SECRET_SENTINEL_42", + "session_token": "RUNSTATE_TOKEN_SENTINEL_42", + "type": "s3_mount" + } + }, + "environment": { + "value": {} + }, + "extra_path_grants": [], + "groups": [], + "remote_mount_command_allowlist": [ + "ls", + "find", + "stat", + "cat", + "less", + "head", + "tail", + "du", + "grep", + "rg", + "wc", + "sort", + "cut", + "cp", + "tee", + "echo", + "mkdir", + "rm" + ], + "root": "/workspace", + "users": [], + "version": 1 + }, + "session_id": "00000000-0000-0000-0000-000000000042", + "snapshot": { + "id": "legacy-snapshot", + "type": "noop" + }, + "type": "unix_local", + "workspace_root_owned": false + } + } + } + }, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/sources.json b/tests/fixtures/run_state/sources.json new file mode 100644 index 0000000000..cdae672998 --- /dev/null +++ b/tests/fixtures/run_state/sources.json @@ -0,0 +1,199 @@ +{ + "baseline": "v0.19.4", + "features": [ + { + "commit": "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c", + "feature": "reasoning_item_id_policy", + "fixture": "features/v1_2_reasoning_item_id_policy.json", + "provenance": "historical_writer", + "version": "1.2" + }, + { + "commit": "6814a54711f591712c893f0a8be1cf56c512ae63", + "feature": "resumed_trace_state", + "fixture": "features/v1_3_resumed_trace_state.json", + "provenance": "historical_writer", + "version": "1.3" + }, + { + "commit": "159beb56130f7d85192acfd593c9168757984dc0", + "feature": "request_id", + "fixture": "features/v1_4_request_id.json", + "provenance": "historical_writer", + "version": "1.4" + }, + { + "commit": "e0f6a28c20887b83dd4e1532cdfe0b78a01d4961", + "feature": "tool_search_and_display_metadata", + "fixture": "features/v1_5_tool_search_and_display_metadata.json", + "provenance": "historical_writer", + "version": "1.5" + }, + { + "commit": "86739b1a0f94d73f9a35e68f6f25ddc0beaa2078", + "feature": "approval_rejection_message", + "fixture": "features/v1_6_approval_rejection_message.json", + "provenance": "historical_writer", + "version": "1.6" + }, + { + "commit": "2d665c9a67fdf3198a0daa0f9978b8239d78e78b", + "emitted_version": "1.9", + "feature": "duplicate_agent_identity_and_sandbox", + "fixture": "features/v1_7_duplicate_agent_identity_and_sandbox.json", + "note": "The release-boundary schema renumbering introduced this reader version without a writer that emitted it. The recorded writer emitted 1.9; only the schema label is changed to exercise the canonical compatibility branch.", + "provenance": "canonical_compatibility", + "version": "1.7" + }, + { + "commit": "2d665c9a67fdf3198a0daa0f9978b8239d78e78b", + "emitted_version": "1.9", + "feature": "prompt_cache_key", + "fixture": "features/v1_8_prompt_cache_key.json", + "note": "The release-boundary schema renumbering introduced this reader version without a writer that emitted it. The recorded writer emitted 1.9; only the schema label is changed to exercise the canonical compatibility branch.", + "provenance": "canonical_compatibility", + "version": "1.8" + }, + { + "commit": "bed924b45d97ea0080655329129075e457d46c6d", + "feature": "custom_tool_call_and_tool_origin", + "fixture": "features/v1_9_custom_tool_call_and_tool_origin.json", + "provenance": "historical_writer", + "version": "1.9" + }, + { + "commit": "a4ba63f7045d27998a0b1bc1ee64a313574ee139", + "feature": "unlimited_max_turns", + "fixture": "features/v1_10_unlimited_max_turns.json", + "provenance": "historical_writer", + "version": "1.10" + }, + { + "commit": "70c447e14ffabdf29bfaeb4bb3df33bb6dfaaab7", + "feature": "tool_output_custom_data", + "fixture": "features/v1_11_tool_output_custom_data.json", + "provenance": "historical_writer", + "version": "1.11" + }, + { + "commit": "95df2c99a745655ba71c763b8ac036283e9df87e", + "feature": "input_cache_write_usage", + "fixture": "features/v1_12_input_cache_write_usage.json", + "provenance": "historical_writer", + "version": "1.12" + }, + { + "commit": "ece7b0e5861d6c839041d5f860a2a2cf08bba81e", + "feature": "programmatic_tool_calling", + "fixture": "features/v1_13_programmatic_tool_calling.json", + "provenance": "historical_writer", + "version": "1.13" + }, + { + "commit": "ece7b0e5861d6c839041d5f860a2a2cf08bba81e", + "feature": "nested_history_ownership", + "fixture": "features/v1_13_nested_history_ownership.json", + "provenance": "historical_writer", + "version": "1.13" + }, + { + "commit": "0c60a196af1236044a829e39b10f22a9cedaa326", + "feature": "hosted_mcp_approval_scope", + "fixture": "features/v1_14_hosted_mcp_approval_scope.json", + "provenance": "historical_writer", + "version": "1.14" + }, + { + "commit": "9c6cadf8201f4908ced206d49ed9f1489dc9db67", + "feature": "canonical_invocation_identity", + "fixture": "features/v1_15_canonical_invocation_identity.json", + "provenance": "historical_writer", + "version": "1.15" + } + ], + "resume": { + "commit": "92aa1b905306d7f5a130d911061c44cddeaa6e20", + "feature": "pending_tool_approval", + "fixture": "resume/v1_13_pending_tool_approval.json", + "provenance": "historical_writer", + "version": "1.13" + }, + "security": { + "commit": "92aa1b905306d7f5a130d911061c44cddeaa6e20", + "feature": "legacy_mount_credentials", + "fixture": "security/v1_13_legacy_mount_credentials.json", + "provenance": "historical_writer", + "sentinels": [ + "RUNSTATE_ACCESS_SENTINEL_42", + "RUNSTATE_SECRET_SENTINEL_42", + "RUNSTATE_TOKEN_SENTINEL_42" + ], + "version": "1.13" + }, + "versions": { + "1.0": { + "commit": "3ce7c24d349b77bb750062b7e0e856d9ff48a5d5", + "fixture": "minimal/v1_0.json" + }, + "1.1": { + "commit": "db4a462accb87f75b27feba8dafe0b27e1707bec", + "fixture": "minimal/v1_1.json" + }, + "1.10": { + "commit": "b9cbab149f2b94d5597c2f987db6f96c54d86395", + "fixture": "minimal/v1_10.json" + }, + "1.11": { + "commit": "7fc489eb0d2d09b38f81d852c22f9193a89044c0", + "fixture": "minimal/v1_11.json" + }, + "1.12": { + "commit": "8724b1b9baf8495f35efc4c2a767a5d1ca6f84cc", + "fixture": "minimal/v1_12.json" + }, + "1.13": { + "commit": "965335aba6f6c71500e0b8cdb4e9e495f5801d4d", + "fixture": "minimal/v1_13.json" + }, + "1.14": { + "commit": "8b810bc4bd1acaafeab3fcfe65ad93187a561be0", + "fixture": "minimal/v1_14.json" + }, + "1.15": { + "commit": "4720150fde047baa4e88b16082b282bee3a5e87d", + "fixture": "minimal/v1_15.json" + }, + "1.2": { + "commit": "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c", + "fixture": "minimal/v1_2.json" + }, + "1.3": { + "commit": "6814a54711f591712c893f0a8be1cf56c512ae63", + "fixture": "minimal/v1_3.json" + }, + "1.4": { + "commit": "159beb56130f7d85192acfd593c9168757984dc0", + "fixture": "minimal/v1_4.json" + }, + "1.5": { + "commit": "e96186113bb72b13d16b07d0305d6f7a4130c965", + "fixture": "minimal/v1_5.json" + }, + "1.6": { + "commit": "9ac31ab49ff655478f344f8dfdc44550683ff475", + "fixture": "minimal/v1_6.json" + }, + "1.7": { + "commit": "e0124244f4fad45f2489b715aad32fde0959724b", + "fixture": "minimal/v1_7.json" + }, + "1.8": { + "commit": "22f072a923b7ebd44eb218117b8cf1abaca4b2db", + "fixture": "minimal/v1_8.json" + }, + "1.9": { + "commit": "2d665c9a67fdf3198a0daa0f9978b8239d78e78b", + "fixture": "minimal/v1_9.json" + } + } +} diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index 7ab0bf74ff..a5279e2672 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -64,7 +64,7 @@ def _make_session_state(cls: type[StateT], **overrides: object) -> StateT: def _import_optional_class(module_name: str, class_name: str) -> type[Any]: - module = pytest.importorskip(module_name) + module = pytest.importorskip(module_name, exc_type=ImportError) value = getattr(module, class_name) assert isinstance(value, type) return cast(type[Any], value) @@ -341,7 +341,7 @@ def test_extension_sandbox_package_export_surfaces_are_stable( module_name: str, expected_exports: set[str], ) -> None: - module = pytest.importorskip(module_name) + module = pytest.importorskip(module_name, exc_type=ImportError) assert set(module.__all__) == expected_exports for name in expected_exports: diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index 276c829b80..3aba1d0ba0 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -29,7 +29,7 @@ RcloneMountConfig, S3FilesMountConfig, ) -from agents.sandbox.errors import MountCommandError, MountConfigError +from agents.sandbox.errors import ErrorCode, MountCommandError, MountConfigError from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.events import SandboxSessionEvent from agents.sandbox.session.manager import Instrumentation @@ -632,25 +632,26 @@ async def test_s3_mountpoint_failure_redacts_credentials_from_errors_and_events( session_token="token", prefix=None, region="us-east-1", - endpoint_url=None, + endpoint_url="https://user:inline-endpoint-secret@example.test", mount_type="s3_mount", read_only=False, ), ) - context = exc_info.value.context - command = str(context["command"]) - stderr = str(context["stderr"]) - assert "REDACTED" in stderr + assert exc_info.value.error_code is ErrorCode.MOUNT_FAILED + assert exc_info.value.op == "materialize" + assert exc_info.value.retryable is False + assert exc_info.value.context == {} + command = " ".join(str(part) for part in inner.exec_calls[-1]) assert ".sandbox-mountpoint-env" in command assert any( path.as_posix().startswith(".sandbox-mountpoint-env/") for path in inner.persist_workspace_skip_paths() ) serialized_events = "\n".join(event.model_dump_json() for event in events) - for sensitive_value in ("access", "secret", "token"): + for sensitive_value in ("access", "secret", "token", "inline-endpoint-secret"): assert sensitive_value not in command - assert sensitive_value not in stderr + assert sensitive_value not in repr(exc_info.value) assert sensitive_value not in serialized_events @@ -1473,7 +1474,7 @@ async def test_blobfuse_cache_path_must_be_outside_mount_path() -> None: container="container", endpoint=None, identity_client_id=None, - account_key="secret", + account_key=None, mount_type="azure_blob_mount", read_only=True, ), diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 161fd5ecbd..8440abe599 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -4317,6 +4317,60 @@ async def test_session_manager_redacts_authority_added_before_capability_failure traceback = traceback.tb_next +@pytest.mark.parametrize( + ("authority_timing", "error_kind", "expected_type", "expected_args"), + [ + ("existing", "system_exit", SystemExit, (1,)), + ("added", "keyboard_interrupt", KeyboardInterrupt, ()), + ], +) +def test_process_manifest_preserves_value_free_process_control_with_authority( + authority_timing: str, + error_kind: str, + expected_type: type[BaseException], + expected_args: tuple[object, ...], +) -> None: + sentinel = f"process-manifest-{authority_timing}-{error_kind}-secret" + source_error: BaseException = ( + SystemExit(sentinel) if error_kind == "system_exit" else KeyboardInterrupt(sentinel) + ) + + class ProcessControlCapability(Capability): + type: str = "process-control" + + def process_manifest(self, manifest: Manifest) -> Manifest: + if authority_timing == "added": + manifest.entries["data"] = S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + raise source_error + + manifest = Manifest() + if authority_timing == "existing": + manifest.entries["data"] = S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + + with pytest.raises(expected_type) as exc_info: + SandboxRuntimeSessionManager._process_manifest( + [ProcessControlCapability()], + manifest, + ) + + assert type(exc_info.value) is expected_type + assert exc_info.value.args == expected_args + assert exc_info.value is not source_error + assert source_error.args == () + assert source_error.__traceback__ is None + assert sentinel not in repr(exc_info.value) + + @pytest.mark.asyncio @pytest.mark.parametrize( "processed_grants", diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py new file mode 100644 index 0000000000..472dc9119c --- /dev/null +++ b/tests/test_integration_runner.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +import os +import runpy +import sys +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +RUNNER = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "run_integration_tests.py" +CHANGE_DETECTOR = RUNNER.with_name("detect-changes.sh") +INTEGRATION_CONFTEST = RUNNER.parents[2] / "integration_tests" / "conftest.py" + + +def _sanitizer() -> Callable[[Path], Any]: + return cast(Callable[[Path], Any], runpy.run_path(str(RUNNER))["_sanitize_and_load_junit"]) + + +def _run_suite() -> Callable[..., None]: + return cast(Callable[..., None], runpy.run_path(str(RUNNER))["run_suite"]) + + +def test_junit_sanitizer_removes_failure_details_and_captured_output(tmp_path: Path) -> None: + sentinel = "JUNIT_SECRET_SENTINEL_42" + report = tmp_path / "results.xml" + report.write_text( + f""" + + + + + +traceback {sentinel} +stdout {sentinel}stderr {sentinel} + + +exception {sentinel} + + +reason {sentinel} + +""", + encoding="utf-8", + ) + + root = _sanitizer()(report) + + assert root is not None + serialized = report.read_text(encoding="utf-8") + assert sentinel not in serialized + assert 'name="suite-0"' in serialized + assert [case.attrib for case in root.findall("testsuite/testcase")] == [ + {"name": "case-0"}, + {"name": "case-1"}, + {"name": "case-2"}, + {"name": "case-3"}, + ] + assert "properties" not in serialized + + +def test_junit_sanitizer_rebuilds_only_safe_count_and_outcome_structure(tmp_path: Path) -> None: + sentinel = "JUNIT_UNKNOWN_CARRIER_SECRET_42" + report = tmp_path / "results.xml" + report.write_text( + f'{sentinel}' + f'{sentinel}' + f'{sentinel}' + f'' + f"{sentinel}{sentinel}" + f'' + f'{sentinel}' + f"{sentinel}{sentinel}", + encoding="utf-8", + ) + + root = _sanitizer()(report) + + assert root is not None + assert report.read_text(encoding="utf-8") == ( + "\n" + '' + "" + ) + + +def test_junit_sanitizer_discards_malformed_reports(tmp_path: Path) -> None: + report = tmp_path / "results.xml" + report.write_text("secret", encoding="utf-8") + + assert _sanitizer()(report) is None + assert not report.exists() + + +def test_junit_sanitizer_preserves_single_suite_counts(tmp_path: Path) -> None: + report = tmp_path / "results.xml" + report.write_text( + '' + '', + encoding="utf-8", + ) + + root = _sanitizer()(report) + + assert root is not None + assert root.tag == "testsuite" + assert root.attrib == { + "name": "suite-0", + "tests": "1", + "failures": "0", + "errors": "0", + "skipped": "0", + } + assert [case.attrib for case in root.findall("testcase")] == [{"name": "case-0"}] + + +def test_junit_sanitizer_discards_reports_with_invalid_counts(tmp_path: Path) -> None: + report = tmp_path / "results.xml" + report.write_text( + '', + encoding="utf-8", + ) + + assert _sanitizer()(report) is None + assert not report.exists() + + +def test_junit_sanitizer_discards_reports_with_impossible_totals(tmp_path: Path) -> None: + report = tmp_path / "results.xml" + report.write_text( + '', + encoding="utf-8", + ) + + assert _sanitizer()(report) is None + assert not report.exists() + + +@pytest.mark.parametrize( + "suite", + [ + '' + '', + '' + '', + ], +) +def test_junit_sanitizer_discards_declared_actual_count_mismatches( + tmp_path: Path, + suite: str, +) -> None: + report = tmp_path / "results.xml" + report.write_text(suite, encoding="utf-8") + + assert _sanitizer()(report) is None + assert not report.exists() + + +def test_junit_sanitizer_discards_duplicate_terminal_outcomes(tmp_path: Path) -> None: + report = tmp_path / "results.xml" + report.write_text( + '' + '', + encoding="utf-8", + ) + + assert _sanitizer()(report) is None + assert not report.exists() + + +@pytest.mark.parametrize("invalid_report", [False, True], ids=["missing", "invalid"]) +def test_successful_integration_run_requires_valid_junit_evidence( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + invalid_report: bool, +) -> None: + run_suite = _run_suite() + + def fake_run_pytest(command: list[str], *, env: dict[str, str]) -> tuple[int, str]: + _ = env + if invalid_report: + result_path = Path(command[-1].removeprefix("--junitxml=")) + result_path.write_text("", encoding="utf-8") + return 0, "1 passed" + + monkeypatch.setitem(run_suite.__globals__, "RESULTS", tmp_path) + monkeypatch.setitem(run_suite.__globals__, "run_pytest", fake_run_pytest) + + with pytest.raises(RuntimeError, match="did not produce a valid JUnit report"): + run_suite( + tmp_path / "python", + tmp_path / "candidate.whl", + tmp_path / "candidate.tar.gz", + selection="packaging", + environment_kind="core", + profile="packaging", + ) + + +@pytest.mark.parametrize( + ("profile", "strict"), + [("release", True), ("security", True), ("packaging", False)], +) +def test_strict_integration_profiles_reject_valid_junit_with_skips( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + profile: str, + strict: bool, +) -> None: + run_suite = _run_suite() + + def fake_run_pytest(command: list[str], *, env: dict[str, str]) -> tuple[int, str]: + _ = env + result_path = Path(command[-1].removeprefix("--junitxml=")) + result_path.write_text( + '' + '', + encoding="utf-8", + ) + return 0, "1 skipped" + + monkeypatch.setitem(run_suite.__globals__, "RESULTS", tmp_path) + monkeypatch.setitem(run_suite.__globals__, "run_pytest", fake_run_pytest) + + if strict: + with pytest.raises(RuntimeError, match="skipped 1 required test"): + run_suite( + tmp_path / "python", + tmp_path / "candidate.whl", + tmp_path / "candidate.tar.gz", + selection=profile, + environment_kind="core", + profile=profile, + ) + else: + run_suite( + tmp_path / "python", + tmp_path / "candidate.whl", + tmp_path / "candidate.tar.gz", + selection=profile, + environment_kind="core", + profile=profile, + ) + + +def test_strict_integration_profile_rejects_skipped_requested_optional_extra( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + run_suite = _run_suite() + + def fake_run_pytest(command: list[str], *, env: dict[str, str]) -> tuple[int, str]: + _ = env + result_path = Path(command[-1].removeprefix("--junitxml=")) + result_path.write_text( + '' + '', + encoding="utf-8", + ) + return 0, "1 skipped" + + monkeypatch.setitem(run_suite.__globals__, "RESULTS", tmp_path) + monkeypatch.setitem(run_suite.__globals__, "run_pytest", fake_run_pytest) + + with pytest.raises(RuntimeError, match="skipped 1 required test"): + run_suite( + tmp_path / "python", + tmp_path / "candidate.whl", + tmp_path / "candidate.tar.gz", + selection="extras", + environment_kind="extra-any-llm", + profile="release", + ) + + +def test_required_packaging_dependency_suite_rejects_valid_junit_with_skips( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + run_suite = _run_suite() + + def fake_run_pytest(command: list[str], *, env: dict[str, str]) -> tuple[int, str]: + _ = env + result_path = Path(command[-1].removeprefix("--junitxml=")) + result_path.write_text( + '' + '', + encoding="utf-8", + ) + return 0, "1 skipped" + + monkeypatch.setitem(run_suite.__globals__, "RESULTS", tmp_path) + monkeypatch.setitem(run_suite.__globals__, "run_pytest", fake_run_pytest) + + with pytest.raises(RuntimeError, match="skipped 1 required test"): + run_suite( + tmp_path / "python", + tmp_path / "candidate.whl", + tmp_path / "candidate.tar.gz", + selection="packaging_dependency", + environment_kind="wheel-cloudflare", + profile="packaging", + require_no_skips=True, + ) + + +def test_extra_collection_deselects_only_non_applicable_memory_backends( + monkeypatch: pytest.MonkeyPatch, +) -> None: + hook = cast( + Callable[[Any, list[Any]], None], + runpy.run_path(str(INTEGRATION_CONFTEST))["pytest_collection_modifyitems"], + ) + requested = SimpleNamespace(originalname="test_requested_optional_extra_imports") + matching = SimpleNamespace( + originalname="test_memory_extra_lazy_exports_resolve_to_the_installed_backend", + callspec=SimpleNamespace(params={"optional_extra": "redis"}), + ) + non_applicable = SimpleNamespace( + originalname="test_memory_extra_lazy_exports_resolve_to_the_installed_backend", + callspec=SimpleNamespace(params={"optional_extra": "encrypt"}), + ) + deselected: list[Any] = [] + config = SimpleNamespace( + hook=SimpleNamespace( + pytest_deselected=lambda *, items: deselected.extend(items), + ) + ) + items = [requested, matching, non_applicable] + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_EXTRA", "redis") + + hook(config, items) + + assert items == [requested, matching] + assert deselected == [non_applicable] + + +def test_code_change_detection_includes_packaged_contract_inputs() -> None: + detector = CHANGE_DETECTOR.read_text(encoding="utf-8") + + assert "integration_tests/" in detector + assert "detect-changes\\.sh" in detector + assert "run_integration_tests\\.py" in detector + assert "update_released_api_contract\\.py" in detector + assert "\\.github/workflows/tests\\.yml" in detector + + +def test_packaging_profile_checks_dependency_present_contract_for_wheel_and_sdist( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + main = cast(Callable[[], None], namespace["main"]) + wheel = tmp_path / "candidate.whl" + sdist = tmp_path / "candidate.tar.gz" + created: list[tuple[str, Path, str | None]] = [] + suites: list[dict[str, Any]] = [] + + def fake_build_distributions() -> tuple[Path, Path]: + return wheel, sdist + + def fake_create_environment( + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), + ) -> Path: + _ = (extras, additional_requirements) + created.append((name, distribution, optional_extra)) + return tmp_path / name / "python" + + def fake_run_suite(*args: object, **kwargs: Any) -> None: + _ = args + suites.append(kwargs) + + monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "packaging"]) + monkeypatch.setitem(main.__globals__, "build_distributions", fake_build_distributions) + monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) + monkeypatch.setitem(main.__globals__, "run_suite", fake_run_suite) + monkeypatch.setattr(main.__globals__["shutil"], "rmtree", lambda *args, **kwargs: None) + + main() + + assert ("wheel-cloudflare", wheel, "cloudflare") in created + assert ("sdist-cloudflare", sdist, "cloudflare") in created + dependency_suites = [ + suite + for suite in suites + if suite["environment_kind"] in {"wheel-cloudflare", "sdist-cloudflare"} + ] + assert [suite["environment_kind"] for suite in dependency_suites] == [ + "wheel-cloudflare", + "sdist-cloudflare", + ] + assert all(suite["selection"] == "packaging_dependency" for suite in dependency_suites) + assert all( + suite["additional_env"] == {"OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS": "1"} + for suite in dependency_suites + ) + assert all(suite["require_no_skips"] is True for suite in dependency_suites) + + +def test_release_profile_enforces_strict_security_for_wheel_and_sdist( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + main = cast(Callable[[], None], namespace["main"]) + created: list[tuple[str, str | None]] = [] + suites: list[dict[str, Any]] = [] + + def fake_build_distributions() -> tuple[Path, Path]: + return tmp_path / "candidate.whl", tmp_path / "candidate.tar.gz" + + def fake_create_environment( + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), + ) -> Path: + _ = (distribution, extras, additional_requirements) + created.append((name, optional_extra)) + return tmp_path / name / "python" + + def fake_run_suite(*args: object, **kwargs: Any) -> None: + _ = args + suites.append(kwargs) + + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "0") + monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "release"]) + monkeypatch.setitem(main.__globals__, "build_distributions", fake_build_distributions) + monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) + monkeypatch.setitem(main.__globals__, "run_suite", fake_run_suite) + monkeypatch.setattr(main.__globals__["shutil"], "rmtree", lambda *args, **kwargs: None) + + main() + + assert os.environ["OPENAI_AGENTS_INTEGRATION_STRICT"] == "1" + assert ("core", "docker") in created + assert ("sdist", "docker") in created + assert ("wheel-cloudflare", "cloudflare") in created + assert ("sdist-cloudflare", "cloudflare") in created + assert any( + suite["environment_kind"] == "core" and "security" in suite["selection"] for suite in suites + ) + assert any( + suite["environment_kind"] == "sdist" and "security" in suite["selection"] + for suite in suites + ) + extra_suites = [suite for suite in suites if suite["environment_kind"].startswith("extra-")] + assert extra_suites + assert all("allow_skips" not in suite for suite in extra_suites) + dependency_suites = [ + suite + for suite in suites + if suite["environment_kind"] in {"wheel-cloudflare", "sdist-cloudflare"} + ] + assert len(dependency_suites) == 2 + assert all(suite["selection"] == "packaging_dependency" for suite in dependency_suites) + assert all( + suite["additional_env"] == {"OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS": "1"} + for suite in dependency_suites + ) + assert all(suite["require_no_skips"] is True for suite in dependency_suites) + assert namespace["STRICT_PROFILES"] == frozenset({"release", "security"}) diff --git a/tests/test_local_shell_tool.py b/tests/test_local_shell_tool.py index 872beaf96a..ae89252c69 100644 --- a/tests/test_local_shell_tool.py +++ b/tests/test_local_shell_tool.py @@ -295,8 +295,12 @@ async def test_run_state_rejects_id_only_local_shell_output(schema_version: str resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool]) try: if schema_version is None: - with pytest.raises(UserError, match="completed tool invocation 'call_local_shell'"): + with pytest.raises( + UserError, + match="completed tool invocation does not match a restored tool call and output", + ) as exc_info: await RunState.from_json(resumed_agent, serialized) + assert "call_local_shell" not in str(exc_info.value) else: resumed_state = await RunState.from_json(resumed_agent, serialized) await Runner.run(resumed_agent, resumed_state) diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py new file mode 100644 index 0000000000..aab01e7f36 --- /dev/null +++ b/tests/test_released_api_contract.py @@ -0,0 +1,1428 @@ +import sys +from collections.abc import AsyncIterator, Callable, Iterator +from dataclasses import dataclass +from enum import Enum +from importlib.metadata import version +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic import Field + +import integration_tests._contract_support as contract_support +from integration_tests._contract_support import ( + _callable_contract, + _default_contract, + _parameter_contract, + _public_class_member_contract, + _validate_parameter_contract, + _validate_public_property_contract, + build_released_api_contract, + load_api_contract, + validate_released_api_contract, +) + +CONTRACT = Path(__file__).parent / "fixtures" / "released_api_contract.json" + + +@pytest.mark.parametrize( + ("released", "changed"), + [(False, 0), (1, 1.0)], +) +def test_literal_default_contract_preserves_exact_builtin_type( + released: object, + changed: object, +) -> None: + assert _default_contract(released) != _default_contract(changed) + + def released_callable(value: object = released) -> None: + _ = value + + def changed_callable(value: object = changed) -> None: + _ = value + + errors = _validate_parameter_contract( + "Example", + _parameter_contract(released_callable), + _parameter_contract(changed_callable), + ) + + assert len(errors) == 1 + assert "changed its released positional parameter prefix" in errors[0] + + +@pytest.mark.allow_call_model_methods +def test_current_source_preserves_released_public_api_contract() -> None: + contract = load_api_contract(CONTRACT) + assert contract["baseline"] == f"v{version('openai-agents')}" + assert len(contract["baseline_commit"]) == 40 + if contract["baseline"] == "v0.19.4": + assert contract["baseline_commit"] == "9bfad15ab8297fbb2afe389c983a5cb573eeef56" + assert all( + field["name"] != "preserve_raw_usage" + for field in contract["callables"]["ModelSettings"]["dataclass_fields"] + ) + assert set(contract["callables"]["Runner"]["members"]) == { + "run", + "run_streamed", + "run_sync", + } + assert {"final_output_as", "release_agents", "to_input_list", "to_state"}.issubset( + contract["callables"]["RunResult"]["members"] + ) + assert {"from_json", "to_json"}.issubset(contract["callables"]["RunState"]["members"]) + assert contract["public_properties"] == [ + { + "module": "agents.result", + "class_name": "RunResultBase", + "names": ["agent_tool_invocation", "last_agent", "last_response_id"], + }, + { + "module": "agents.result", + "class_name": "RunResult", + "names": ["agent_tool_invocation", "last_agent", "last_response_id"], + }, + { + "module": "agents.result", + "class_name": "RunResultStreaming", + "names": [ + "agent_tool_invocation", + "last_agent", + "last_response_id", + "run_loop_exception", + ], + }, + ] + + errors = validate_released_api_contract(contract) + + assert errors == [] + + +def test_callable_contract_ignores_typing_aliases() -> None: + alias = Callable[[str], None] + agents_module = SimpleNamespace(__all__=["Callback"], Callback=alias) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": [], + "canonical_imports": [], + "callables": {}, + } + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert updated["required_top_level_exports"] == ["Callback"] + assert updated["callables"] == {} + + +def test_constructor_contract_allows_optional_suffixes_only() -> None: + def released(value: str) -> None: + _ = value + + def compatible(value: str, optional: int = 1, *, named: bool = False) -> None: + _ = (value, optional, named) + + def compatible_variadic(value: str, *args: object, **kwargs: object) -> None: + _ = (value, args, kwargs) + + def incompatible(value: str, required: int) -> None: + _ = (value, required) + + released_contract = _parameter_contract(released) + + assert ( + _validate_parameter_contract("Example", released_contract, _parameter_contract(compatible)) + == [] + ) + assert ( + _validate_parameter_contract( + "Example", released_contract, _parameter_contract(compatible_variadic) + ) + == [] + ) + assert _validate_parameter_contract( + "Example", released_contract, _parameter_contract(incompatible) + ) == ["Example.required added a required parameter"] + + def released_variadic(*args: object) -> None: + _ = args + + def incompatible_before_variadic(optional: int = 1, *args: object) -> None: + _ = (optional, args) + + assert _validate_parameter_contract( + "VariadicExample", + _parameter_contract(released_variadic), + _parameter_contract(incompatible_before_variadic), + ) == [ + "VariadicExample added positional parameters before its released variadic parameter: " + "[{'name': 'optional', 'kind': 'POSITIONAL_OR_KEYWORD', " + "'default': {'kind': 'literal', 'type': 'builtins.int', 'value': 1}}]" + ] + + +def test_public_class_member_contract_tracks_direct_callable_bindings() -> None: + class Released: + def instance(self, value: str, optional: int = 1) -> None: + _ = (value, optional) + + @classmethod + def class_method(cls, value: str) -> None: + _ = (cls, value) + + @staticmethod + def static_method(value: str) -> None: + _ = value + + @property + def property_value(self) -> str: + return "value" + + assert _public_class_member_contract(Released) == { + "instance": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + }, + { + "name": "optional", + "kind": "POSITIONAL_OR_KEYWORD", + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 1, + }, + }, + ], + }, + "class_method": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + } + ], + }, + "static_method": { + "binding": "static", + "execution_kind": "sync", + "parameters": [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + } + ], + }, + } + + +def test_curated_public_property_contract_detects_removed_or_changed_properties() -> None: + class ReleasedBase: + @property + def retained(self) -> str: + return "value" + + class Released(ReleasedBase): + @property + def retained(self) -> str: + return "value" + + @property + def concrete_only(self) -> str: + return "value" + + contract: dict[str, Any] = { + "public_properties": [ + { + "module": "agents", + "class_name": "ReleasedBase", + "names": ["retained", "removed"], + }, + { + "module": "agents", + "class_name": "Released", + "names": ["retained", "concrete_only"], + }, + ] + } + agents_module = SimpleNamespace( + __all__=[], + ReleasedBase=ReleasedBase, + Released=Released, + ) + + assert _validate_public_property_contract(contract, agents_module) == [ + "agents.ReleasedBase.removed removed or changed a released public property" + ] + + Changed = type( + "Changed", + (ReleasedBase,), + {"retained": lambda self: "value"}, + ) + + agents_module.Released = Changed + + assert _validate_public_property_contract(contract, agents_module) == [ + "agents.ReleasedBase.removed removed or changed a released public property", + "agents.Released.retained removed or changed a released public property", + "agents.Released.concrete_only removed or changed a released public property", + ] + + +def test_public_class_member_contract_tracks_only_sdk_owned_inherited_methods() -> None: + class ExternalBase: + def external_method(self) -> None: + return None + + class SDKBase(ExternalBase): + __module__ = "agents.contract_test" + + def instance_method(self, value: str) -> None: + _ = value + + @classmethod + def class_method(cls, value: str) -> None: + _ = (cls, value) + + @staticmethod + def shadowed_method(value: str) -> None: + _ = value + + def shadowed_method(_self: object) -> str: + return "value" + + Released = type( + "Released", + (SDKBase,), + {"shadowed_method": property(shadowed_method)}, + ) + + assert _public_class_member_contract(Released) == { + "class_method": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + } + ], + }, + "instance_method": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + } + ], + }, + } + + +def test_callable_contract_preserves_wrapped_function_signature() -> None: + def released(value: str, optional: int = 1) -> str: + return value * optional + + def middle(*args: object, **kwargs: object) -> object: + _ = (args, kwargs) + return None + + def outer(*args: object, **kwargs: object) -> object: + _ = (args, kwargs) + return None + + middle.__wrapped__ = released # type: ignore[attr-defined] + outer.__wrapped__ = middle # type: ignore[attr-defined] + + assert _callable_contract(outer)["parameters"] == [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + }, + { + "name": "optional", + "kind": "POSITIONAL_OR_KEYWORD", + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 1, + }, + }, + ] + + +def test_released_public_class_member_contract_rejects_breaking_changes() -> None: + class Released: + def inherited(self, value: str) -> None: + _ = value + + @classmethod + def changed_binding(cls, value: str) -> None: + _ = (cls, value) + + @staticmethod + def removed(value: str) -> None: + _ = value + + def changed_signature(self, value: str, optional: int = 1) -> None: + _ = (value, optional) + + class CompatibleBase: + __module__ = "agents.contract_test" + + def inherited(self, value: str, optional: int = 1) -> None: + _ = (value, optional) + + class Incompatible(CompatibleBase): + @staticmethod + def changed_binding(value: str) -> None: + _ = value + + def changed_signature(self, renamed: str, optional: int = 1) -> None: + _ = (renamed, optional) + + agents_module = SimpleNamespace(__all__=["Released"], Released=Incompatible) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["Released"], + "public_modules": [], + "canonical_imports": [], + "callables": {"Released": _callable_contract(Released)}, + } + + errors = validate_released_api_contract(contract, agents_module=agents_module) + + assert errors == [ + "Released.changed_binding changed binding from class to static", + "Released.removed removed a released public method", + "Released.changed_signature changed its released positional parameter prefix: " + "expected [{'name': 'value', 'kind': 'POSITIONAL_OR_KEYWORD', " + "'default': {'kind': 'required'}}, {'name': 'optional', " + "'kind': 'POSITIONAL_OR_KEYWORD', 'default': {'kind': 'literal', " + "'type': 'builtins.int', 'value': 1}}], got [{'name': 'renamed', " + "'kind': 'POSITIONAL_OR_KEYWORD', 'default': {'kind': 'required'}}, " + "{'name': 'optional', 'kind': 'POSITIONAL_OR_KEYWORD', " + "'default': {'kind': 'literal', 'type': 'builtins.int', 'value': 1}}]", + "Released.changed_signature.renamed added a required parameter", + ] + + +def test_released_callable_contract_rejects_execution_kind_changes() -> None: + async def released_async(value: str) -> str: + return value + + def released_sync(value: str) -> str: + return value + + def changed_to_sync(value: str) -> str: + return value + + async def changed_to_async(value: str) -> str: + return value + + def released_generator(value: str) -> Iterator[str]: + yield value + + def changed_generator_to_sync(value: str) -> str: + return value + + class ReleasedBase: + __module__ = "agents.contract_test" + + @classmethod + async def inherited_async(cls, value: str) -> str: + _ = cls + return value + + class Released(ReleasedBase): + async def direct_async(self, value: str) -> str: + return value + + @staticmethod + def direct_sync(value: str) -> str: + return value + + async def direct_async_generator(self, value: str) -> AsyncIterator[str]: + yield value + + class ChangedBase: + __module__ = "agents.contract_test" + + @classmethod + def inherited_async(cls, value: str) -> str: + _ = cls + return value + + class Changed(ChangedBase): + def direct_async(self, value: str) -> str: + return value + + @staticmethod + async def direct_sync(value: str) -> str: + return value + + async def direct_async_generator(self, value: str) -> str: + return value + + agents_module = SimpleNamespace( + __all__=["released_async", "released_sync", "released_generator", "Released"], + released_async=changed_to_sync, + released_sync=changed_to_async, + released_generator=changed_generator_to_sync, + Released=Changed, + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [ + "released_async", + "released_sync", + "released_generator", + "Released", + ], + "public_modules": [], + "canonical_imports": [], + "callables": { + "released_async": _callable_contract(released_async), + "released_sync": _callable_contract(released_sync), + "released_generator": _callable_contract(released_generator), + "Released": _callable_contract(Released), + }, + } + + errors = validate_released_api_contract(contract, agents_module=agents_module) + + assert set(errors) == { + "released_async changed execution from coroutine to sync", + "released_sync changed execution from sync to coroutine", + "released_generator changed execution from generator to sync", + "Released.direct_async changed execution from coroutine to sync", + "Released.direct_sync changed execution from sync to coroutine", + "Released.direct_async_generator changed execution from async_generator to coroutine", + "Released.inherited_async changed execution from coroutine to sync", + } + + +def test_released_opaque_sentinel_default_rejects_unrepresentable_replacement() -> None: + from agents.tool import _UNSET_FAILURE_ERROR_FUNCTION + + def released(value: object = _UNSET_FAILURE_ERROR_FUNCTION) -> None: + _ = value + + def incompatible(value: object = object()) -> None: + _ = value + + released_contract = _parameter_contract(released) + + assert released_contract[0]["default"] == { + "kind": "sentinel", + "identity": "agents.tool._UNSET_FAILURE_ERROR_FUNCTION", + } + with pytest.raises(TypeError, match="Unsupported public API default value: builtins.object"): + _parameter_contract(incompatible) + + +def test_field_info_default_contract_preserves_the_complete_default() -> None: + assert _default_contract(Field(default=1)) != _default_contract(Field(default=2)) + + +def test_qualified_submodule_callable_contract_detects_signature_change( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def released(value: str, optional: int = 1) -> None: + _ = (value, optional) + + def incompatible(renamed: str, optional: int = 1) -> None: + _ = (renamed, optional) + + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(released=incompatible) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": [], + "canonical_imports": [], + "callables": {"agents.submodule.released": _callable_contract(released)}, + } + + errors = validate_released_api_contract(contract, agents_module=agents_module) + + assert any("changed its released positional parameter prefix" in error for error in errors) + + +def test_release_contract_update_freezes_submodule_only_callable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def helper(value: str = "default") -> None: + _ = value + + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(helper=helper) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "canonical_imports": [ + { + "module": "agents.submodule", + "name": "helper", + "canonical_module": "agents.submodule", + "canonical_name": "helper", + } + ], + "callables": {}, + } + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert updated["callables"]["agents.submodule.helper"] == _callable_contract(helper) + + +def test_enum_constructor_contract_uses_member_lookup_signature() -> None: + class ReleasedEnum(Enum): + VALUE = "value" + + assert _parameter_contract(ReleasedEnum) == [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + } + ] + + +def test_released_enum_contract_freezes_members_and_values() -> None: + class ReleasedEnum(Enum): + OLD = "old" + + class CompatibleEnum(Enum): + OLD = "old" + NEW = "new" + + class RenamedEnum(Enum): + RENAMED = "old" + + class ChangedValueEnum(Enum): + OLD = "changed" + + contract: dict[str, Any] = { + "required_top_level_exports": ["ReleasedEnum"], + "public_modules": [], + "canonical_imports": [], + "callables": {"ReleasedEnum": _callable_contract(ReleasedEnum)}, + } + + assert ( + validate_released_api_contract( + contract, + agents_module=SimpleNamespace(__all__=["ReleasedEnum"], ReleasedEnum=CompatibleEnum), + ) + == [] + ) + assert validate_released_api_contract( + contract, + agents_module=SimpleNamespace(__all__=["ReleasedEnum"], ReleasedEnum=RenamedEnum), + ) == ["ReleasedEnum.OLD removed or renamed a released enum member"] + assert validate_released_api_contract( + contract, + agents_module=SimpleNamespace(__all__=["ReleasedEnum"], ReleasedEnum=ChangedValueEnum), + ) == [ + "ReleasedEnum.OLD changed its released enum value: expected " + "{'kind': 'literal', 'type': 'builtins.str', 'value': 'old'}, got " + "{'kind': 'literal', 'type': 'builtins.str', 'value': 'changed'}" + ] + + +def test_public_api_contract_requires_real_export_bindings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import agents + + contract: dict[str, Any] = { + "required_top_level_exports": ["AgentsException"], + "public_modules": [], + "canonical_imports": [], + "callables": {}, + } + monkeypatch.delattr(agents, "AgentsException") + + assert validate_released_api_contract(contract) == [ + "Missing released top-level bindings: ['AgentsException']" + ] + + +@pytest.mark.parametrize("failure", ["membership", "binding"]) +def test_public_api_contract_requires_released_submodule_exports( + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + sandbox_error = type("SandboxError", (Exception,), {}) + submodule = SimpleNamespace( + __all__=[] if failure == "membership" else ["SandboxError"], + SandboxError=sandbox_error, + ) + if failure == "binding": + del submodule.SandboxError + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["SandboxError"], + "optional_bindings": {}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + errors = validate_released_api_contract(contract, agents_module=agents_module) + + expected_kind = "exports" if failure == "membership" else "bindings" + assert errors == [f"Missing released agents.submodule {expected_kind}: ['SandboxError']"] + + +def test_public_api_contract_rejects_missing_self_canonical_binding() -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [ + { + "module": "agents", + "name": "Missing", + "canonical_module": "agents", + "canonical_name": "Missing", + } + ], + "callables": {}, + } + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "agents.Missing no longer resolves to agents.Missing" + ] + + +def test_public_api_contract_allows_declared_platform_import_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.platform_specific"], + "platform_import_errors": [ + { + "module": "agents.platform_specific", + "platforms": ["win32"], + "error_type": "ImportError", + "message_contains": "not supported on Windows", + } + ], + "canonical_imports": [ + { + "module": "agents.platform_specific", + "name": "PlatformBinding", + "canonical_module": "agents.platform_specific", + "canonical_name": "PlatformBinding", + } + ], + "callables": {}, + } + + def raise_platform_error(module_name: str, _: Any) -> Any: + assert module_name == "agents.platform_specific" + raise ImportError("Backend is not supported on Windows. Use another backend.") + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_import_contract_module", raise_platform_error) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [] + + +def test_public_api_contract_allows_binding_with_unavailable_canonical_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + parent_module = SimpleNamespace() + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.platform_parent", "agents.platform_child"], + "platform_import_errors": [ + { + "module": "agents.platform_child", + "platforms": ["win32"], + "error_type": "ImportError", + "message_contains": "not supported on Windows", + } + ], + "canonical_imports": [ + { + "module": "agents.platform_parent", + "name": "PlatformBinding", + "canonical_module": "agents.platform_child", + "canonical_name": "PlatformBinding", + } + ], + "callables": {}, + } + + def import_platform_module(module_name: str, _: Any) -> Any: + if module_name == "agents.platform_parent": + return parent_module + assert module_name == "agents.platform_child" + raise ImportError("Backend is not supported on Windows. Use another backend.") + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_import_contract_module", import_platform_module) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [] + + +def test_public_api_contract_rejects_unexpected_platform_import_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.platform_specific"], + "platform_import_errors": [ + { + "module": "agents.platform_specific", + "platforms": ["win32"], + "error_type": "ImportError", + "message_contains": "not supported on Windows", + } + ], + "canonical_imports": [], + "callables": {}, + } + + def raise_unexpected_error(module_name: str, _: Any) -> Any: + assert module_name == "agents.platform_specific" + raise ImportError("Unexpected dependency failure") + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_import_contract_module", raise_unexpected_error) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Failed to import released module agents.platform_specific: " + "ImportError('Unexpected dependency failure')" + ] + + +def test_public_api_contract_rejects_same_named_foreign_platform_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.platform_specific"], + "platform_import_errors": [ + { + "module": "agents.platform_specific", + "platforms": ["win32"], + "error_type": "ImportError", + "message_contains": "not supported on Windows", + } + ], + "canonical_imports": [], + "callables": {}, + } + foreign_import_error = type("ImportError", (Exception,), {}) + + def raise_foreign_error(module_name: str, _: Any) -> Any: + assert module_name == "agents.platform_specific" + raise foreign_import_error("Backend is not supported on Windows.") + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_import_contract_module", raise_foreign_error) + + errors = validate_released_api_contract(contract, agents_module=agents_module) + assert len(errors) == 1 + assert errors[0].startswith("Failed to import released module agents.platform_specific:") + + +def test_public_api_contract_rejects_required_dataclass_suffix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import agents + + @dataclass + class Incompatible: + value: str + required_suffix: int + + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": [], + "canonical_imports": [], + "callables": { + "ContractExample": { + "kind": "class", + "parameters": [ + { + "name": "value", + "kind": "POSITIONAL_OR_KEYWORD", + "default": {"kind": "required"}, + } + ], + "dataclass_fields": [ + {"name": "value", "init": True, "default": {"kind": "required"}} + ], + } + }, + } + monkeypatch.setattr(agents, "ContractExample", Incompatible, raising=False) + + assert validate_released_api_contract(contract) == [ + "ContractExample.required_suffix added a required parameter", + "ContractExample.required_suffix added a required dataclass field", + ] + + +def test_release_contract_update_freezes_new_exports_and_callables() -> None: + @dataclass + class Existing: + value: str + optional: int = 1 + + @dataclass + class NewPublic: + name: str + enabled: bool = True + + def new_helper() -> None: + return None + + class Uninspectable: + __signature__ = "invalid" + + class NewEnum(Enum): + VALUE = "value" + + agents_module = SimpleNamespace( + __all__=["new_helper", "Existing", "NewPublic", "NewEnum", "Uninspectable"], + Existing=Existing, + new_helper=new_helper, + NewPublic=NewPublic, + NewEnum=NewEnum, + Uninspectable=Uninspectable, + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["Existing"], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {}, + } + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert updated["baseline"] == "v0.20.0" + assert updated["baseline_commit"] == "b" * 40 + assert updated["required_top_level_exports"] == [ + "Existing", + "new_helper", + "NewPublic", + "NewEnum", + "Uninspectable", + ] + assert set(updated["callables"]) == {"Existing", "NewEnum", "NewPublic", "new_helper"} + assert updated["callables"]["Existing"]["kind"] == "class" + assert updated["callables"]["new_helper"]["kind"] == "function" + assert [field["name"] for field in updated["callables"]["Existing"]["dataclass_fields"]] == [ + "value", + "optional", + ] + assert [field["name"] for field in updated["callables"]["NewPublic"]["dataclass_fields"]] == [ + "name", + "enabled", + ] + assert updated["callables"]["NewEnum"]["enum_members"] == [ + { + "name": "VALUE", + "value": {"kind": "literal", "type": "builtins.str", "value": "value"}, + } + ] + assert updated["public_modules"] == ["agents"] + assert updated["canonical_imports"] == [] + assert updated["required_submodule_exports"] == {} + + unchanged = build_released_api_contract( + updated, + baseline="v0.20.0", + baseline_commit="c" * 40, + agents_module=agents_module, + ) + assert unchanged["baseline_commit"] == "b" * 40 + + +def test_release_contract_update_promotes_selected_submodule_exports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = object() + added = object() + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["Existing", "Added"], Existing=existing, Added=added) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["Existing"], + "optional_bindings": {}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert updated["required_submodule_exports"] == { + "agents.submodule": { + "names": ["Existing", "Added"], + "optional_bindings": {}, + "optional_exports": {}, + } + } + + +def test_public_api_contract_allows_declared_optional_submodule_binding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["OptionalBackend"]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {"OptionalBackend": "missing_optional_backend_dependency"}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [] + + +def test_public_api_contract_allows_declared_optional_submodule_export( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "missing_optional_backend_dependency"}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [] + + +def test_public_api_contract_requires_available_optional_submodule_export( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "json"}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Missing released agents.submodule exports: ['OptionalBackend']", + "Missing released agents.submodule bindings: ['OptionalBackend']", + ] + + +def test_public_api_contract_requires_declared_dependencies_in_strict_optional_profile( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "mistyped_dependency_name"}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract( + contract, + agents_module=agents_module, + require_all_optional_exports=True, + ) == [ + "Required optional dependencies for released agents.submodule are unavailable: " + "['OptionalBackend -> mistyped_dependency_name']" + ] + + +def test_public_api_contract_treats_loaded_dependency_without_spec_as_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + dependency_name = "loaded_dependency_without_spec" + monkeypatch.setitem(sys.modules, dependency_name, SimpleNamespace(__spec__=None)) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": dependency_name}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Missing released agents.submodule exports: ['OptionalBackend']", + "Missing released agents.submodule bindings: ['OptionalBackend']", + ] + + +@pytest.mark.parametrize( + ("optional_exports", "expected_error"), + [ + ( + {"OptionalBackend": None}, + "optional_exports dependency for 'OptionalBackend' must be a non-empty string", + ), + ( + {"OptionalBackend": ""}, + "optional_exports dependency for 'OptionalBackend' must be a non-empty string", + ), + ( + [], + "optional_exports must be an object mapping export names to dependency modules", + ), + ], +) +def test_public_api_contract_rejects_malformed_optional_dependency_declarations( + monkeypatch: pytest.MonkeyPatch, + optional_exports: object, + expected_error: str, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": optional_exports, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Invalid released agents.submodule optional dependency declarations: " + expected_error + ] + + +def test_release_contract_update_rejects_new_submodule_export_without_binding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = object() + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["Existing", "Added"], Existing=existing) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["Existing"], + "optional_bindings": {}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + with pytest.raises( + ValueError, + match="Cannot promote an invalid released API contract", + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + +def test_release_contract_update_rejects_incompatible_current_surface() -> None: + class Released: + def __init__(self, value: str) -> None: + self.value = value + + class Incompatible: + def __init__(self, renamed: str) -> None: + self.renamed = renamed + + agents_module = SimpleNamespace(__all__=["Released"], Released=Incompatible) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["Released"], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {"Released": _callable_contract(Released)}, + } + + with pytest.raises( + ValueError, + match="Cannot promote an incompatible released API contract", + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + +def test_release_contract_update_rejects_function_signature_change() -> None: + def released(value: str, optional: int = 1) -> None: + _ = (value, optional) + + def incompatible(renamed: str, optional: int = 1) -> None: + _ = (renamed, optional) + + agents_module = SimpleNamespace(__all__=["released"], released=incompatible) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["released"], + "public_modules": [], + "canonical_imports": [], + "callables": {"released": _callable_contract(released)}, + } + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "released changed its released positional parameter prefix: expected " + "[{'name': 'value', 'kind': 'POSITIONAL_OR_KEYWORD', " + "'default': {'kind': 'required'}}, {'name': 'optional', " + "'kind': 'POSITIONAL_OR_KEYWORD', " + "'default': {'kind': 'literal', 'type': 'builtins.int', 'value': 1}}], got " + "[{'name': 'renamed', 'kind': 'POSITIONAL_OR_KEYWORD', " + "'default': {'kind': 'required'}}, {'name': 'optional', " + "'kind': 'POSITIONAL_OR_KEYWORD', " + "'default': {'kind': 'literal', 'type': 'builtins.int', 'value': 1}}]", + "released.renamed added a required parameter", + ] + + +def test_release_contract_update_rejects_class_replaced_by_function() -> None: + class Released: + def __init__(self, value: str) -> None: + self.value = value + + def replacement(value: str) -> None: + _ = value + + agents_module = SimpleNamespace(__all__=["Released"], Released=replacement) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["Released"], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {"Released": _callable_contract(Released)}, + } + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Released callable agents.Released changed kind from class to function" + ] + with pytest.raises( + ValueError, + match="Released callable agents.Released changed kind from class to function", + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + +def test_release_contract_update_rejects_duplicate_exports() -> None: + agents_module = SimpleNamespace(__all__=["Duplicate", "Duplicate"], Duplicate=object()) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": [], + "canonical_imports": [], + "callables": {}, + } + + with pytest.raises(ValueError, match="must not contain duplicate exports"): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 601a36d7cf..a69edbb242 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -4657,7 +4657,7 @@ async def test_missing_agent_in_map_error(self): # Try to deserialize with a different agent that doesn't have AgentA in handoffs agent_b = Agent(name="AgentB") - with pytest.raises(Exception, match="Agent AgentA not found in agent map"): + with pytest.raises(Exception, match="Run state agent not found in agent map"): await RunState.from_string(agent_b, json_str) @@ -6896,7 +6896,7 @@ def rebound_lookup() -> str: name="TestAgent", tools=[ProgrammaticToolCallingTool(), rebound_lookup], ) - with pytest.raises(ModelBehaviorError, match="caller programmatic"): + with pytest.raises(ModelBehaviorError, match="Error details are redacted"): await RunState.from_json( rebound_agent, state.to_json(), @@ -6945,7 +6945,7 @@ def rebound_lookup() -> str: return "rebound" rebound_agent = Agent(name="TestAgent", tools=[rebound_lookup]) - with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"): + with pytest.raises(ModelBehaviorError, match="Error details are redacted"): await RunState.from_json(rebound_agent, state.to_json(), context_override={}) @pytest.mark.asyncio @@ -6975,7 +6975,7 @@ def saved_lookup() -> str: functions=[ToolRunFunction(tool_call=function_call, function_tool=saved_lookup)] ) - with pytest.raises(ModelBehaviorError, match="parent program item"): + with pytest.raises(ModelBehaviorError, match="Error details are redacted"): await RunState.from_json(agent, state.to_json(), context_override={}) @pytest.mark.asyncio @@ -7026,7 +7026,7 @@ def saved_lookup() -> str: functions=[ToolRunFunction(tool_call=function_call, function_tool=saved_lookup)] ) - with pytest.raises(ModelBehaviorError, match="already completed"): + with pytest.raises(ModelBehaviorError, match="Error details are redacted"): await RunState.from_json(agent, state.to_json(), context_override={}) @pytest.mark.asyncio @@ -7093,7 +7093,7 @@ async def test_programmatic_mcp_approval_rechecks_allowed_callers_on_resume(self name="TestAgent", tools=[ProgrammaticToolCallingTool(), rebound_mcp_tool], ) - with pytest.raises(ModelBehaviorError, match="caller programmatic"): + with pytest.raises(ModelBehaviorError, match="Error details are redacted"): await RunState.from_json( rebound_agent, state.to_json(), @@ -8271,7 +8271,7 @@ async def test_from_json_agent_not_found(self): "generated_items": [], } - with pytest.raises(UserError, match="Agent NonExistentAgent not found in agent map"): + with pytest.raises(UserError, match="Run state agent not found in agent map"): await RunState.from_json(agent, state_json) @pytest.mark.asyncio diff --git a/tests/test_run_state_compatibility_corpus.py b/tests/test_run_state_compatibility_corpus.py new file mode 100644 index 0000000000..19232245cb --- /dev/null +++ b/tests/test_run_state_compatibility_corpus.py @@ -0,0 +1,1385 @@ +import asyncio +import builtins +import json +import logging +import sys +import types +from collections.abc import Iterator, Mapping +from pathlib import Path +from typing import Any, cast + +import pytest + +import agents.run_state as run_state_module + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup +else: + BaseExceptionGroup = builtins.BaseExceptionGroup + +from agents import Agent, RunState, UserError +from agents.run_context import RunContextWrapper +from agents.run_state import SUPPORTED_SCHEMA_VERSIONS +from agents.sandbox.entries.mounts.patterns import FuseMountConfig +from integration_tests._contract_support import ( + _deserialize_common_sandbox_session_state, + _find_subset_errors, + _normalized_durable_state, + _redaction_observables, + validate_historical_resume_behavior, + validate_historical_run_state_fixture, + validate_legacy_credential_run_state_fixture, +) + +FIXTURE_ROOT = Path(__file__).parent / "fixtures" / "run_state" +SOURCES = json.loads((FIXTURE_ROOT / "sources.json").read_text(encoding="utf-8")) + + +def test_redaction_observables_include_nested_exception_owned_state() -> None: + root_sentinel = "sentinel-root-exception-secret" + nested_sentinel = "sentinel-nested-exception-secret" + args_sentinel = "sentinel-exception-args-secret" + group_sentinel = "sentinel-exception-group-secret" + record_sentinel = "sentinel-log-record-secret" + exc_info_sentinel = "sentinel-exc-info-secret" + traceback_sentinel = "sentinel-traceback-local-secret" + dataclass_sentinel = "sentinel-traceback-dataclass-secret" + + synthetic_globals: dict[str, Any] = {"__name__": "agents.synthetic_redaction_test"} + exec( + "def raise_with_sensitive_local(secret):\n" + " sensitive_payload = {'credential': secret}\n" + " if not sensitive_payload:\n" + " return\n" + " raise RuntimeError('sanitized traceback error')\n", + synthetic_globals, + ) + raise_with_sensitive_local = synthetic_globals["raise_with_sensitive_local"] + synthetic_globals["FuseMountConfig"] = FuseMountConfig + exec( + "def raise_with_sensitive_config(config):\n" + " raise RuntimeError('sanitized config traceback error')\n", + synthetic_globals, + ) + raise_with_sensitive_config = synthetic_globals["raise_with_sensitive_config"] + + try: + raise_with_sensitive_local(traceback_sentinel) + except RuntimeError as caught: + traceback_error = caught + try: + raise_with_sensitive_config( + FuseMountConfig( + account="account", + container="container", + endpoint=f"https://user:{dataclass_sentinel}@example.test", + identity_client_id=None, + account_key=None, + mount_type="azure_blob_mount", + ) + ) + except RuntimeError as caught: + dataclass_traceback_error = caught + nested = RuntimeError("sanitized nested error") + nested.payload = {"credential": nested_sentinel} # type: ignore[attr-defined] + args_error = RuntimeError("sanitized args error") + args_error.payload = {"credential": args_sentinel} # type: ignore[attr-defined] + group_error = RuntimeError("sanitized group error") + group_error.payload = {"credential": group_sentinel} # type: ignore[attr-defined] + exception_group = BaseExceptionGroup("sanitized group", [group_error]) + error = ValueError("sanitized outer error") + error.payload = { # type: ignore[attr-defined] + "credential": root_sentinel, + "nested": [nested], + } + error.payload["cycle"] = error # type: ignore[attr-defined] + error.args = ( + "sanitized outer error", + args_error, + exception_group, + traceback_error, + dataclass_traceback_error, + ) + record_error = RuntimeError("sanitized record error") + record_error.payload = {"credential": record_sentinel} # type: ignore[attr-defined] + exc_info_error = RuntimeError("sanitized exc_info error") + exc_info_error.payload = {"credential": exc_info_sentinel} # type: ignore[attr-defined] + record = logging.LogRecord( + name="redaction-test", + level=logging.ERROR, + pathname=__file__, + lineno=0, + msg="sanitized log", + args=(), + exc_info=(RuntimeError, exc_info_error, None), + ) + record.nested_error = record_error + + observables = _redaction_observables(error, [record]) + + assert root_sentinel in observables + assert nested_sentinel in observables + assert args_sentinel in observables + assert group_sentinel in observables + assert record_sentinel in observables + assert exc_info_sentinel in observables + assert traceback_sentinel in observables + assert dataclass_sentinel in observables + + +@pytest.mark.parametrize( + "field_name", + [ + "no_active_agent_run", + "last_model_response", + "generated_session_item_indexes", + "conversation_id", + "input_guardrail_results", + "tool_use_tracker", + ], +) +def test_historical_state_comparison_covers_every_durable_field(field_name: str) -> None: + historical = {"$schemaVersion": "1.0", field_name: {"value": "preserve-me"}} + canonical = {"$schemaVersion": "1.15"} + + errors = _find_subset_errors( + _normalized_durable_state(historical), + _normalized_durable_state(canonical), + ) + + assert errors == [f"state.{field_name} was dropped"] + + +def test_historical_state_comparison_preserves_json_scalar_types() -> None: + errors = _find_subset_errors( + {"no_active_agent_run": True, "current_turn": 1}, + {"no_active_agent_run": 1, "current_turn": 1.0}, + ) + + assert errors == [ + "state.no_active_agent_run changed type from bool to int", + "state.current_turn changed type from int to float", + ] + + +def test_historical_fixture_corpus_matches_supported_schema_versions() -> None: + assert SOURCES["baseline"] == "v0.19.4" + assert frozenset(SOURCES["versions"]) == SUPPORTED_SCHEMA_VERSIONS + assert all(entry["commit"] for entry in SOURCES["versions"].values()) + assert {entry["version"] for entry in SOURCES["features"]} == { + version for version in SUPPORTED_SCHEMA_VERSIONS if version not in {"1.0", "1.1"} + } + assert {entry["provenance"] for entry in SOURCES["features"]} == { + "historical_writer", + "canonical_compatibility", + } + + +@pytest.mark.parametrize( + ("schema_version", "entry"), + sorted(SOURCES["versions"].items()), +) +async def test_historical_minimal_run_state_rewrites_idempotently( + schema_version: str, entry: dict[str, str] +) -> None: + fixture = FIXTURE_ROOT / entry["fixture"] + payload = json.loads(fixture.read_text(encoding="utf-8")) + + assert payload["$schemaVersion"] == schema_version + assert await validate_historical_run_state_fixture(fixture) == [] + + +@pytest.mark.parametrize("entry", SOURCES["features"], ids=lambda entry: entry["feature"]) +async def test_historical_feature_run_state_rewrites_semantically(entry: dict[str, str]) -> None: + fixture = FIXTURE_ROOT / entry["fixture"] + payload = json.loads(fixture.read_text(encoding="utf-8")) + + assert payload["$schemaVersion"] == entry["version"] + assert await validate_historical_run_state_fixture(fixture) == [] + + +@pytest.mark.parametrize( + ("feature", "decision"), + [ + ("pending_tool_approval", "approve"), + ("pending_tool_approval", "reject"), + ("canonical_invocation_identity", None), + ], +) +async def test_historical_approval_decisions_control_resumed_runs( + feature: str, + decision: str | None, +) -> None: + entry = ( + SOURCES["resume"] + if feature == "pending_tool_approval" + else next(entry for entry in SOURCES["features"] if entry["feature"] == feature) + ) + fixture = FIXTURE_ROOT / entry["fixture"] + + assert ( + await validate_historical_resume_behavior( + fixture, + feature=feature, + decision=decision, + ) + == [] + ) + + +async def test_historical_fixture_comparison_uses_immutable_expected_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture = FIXTURE_ROOT / "features" / "v1_8_prompt_cache_key.json" + original_from_json = RunState.from_json + + async def mutating_from_json( + initial_agent: Agent[Any], + state_json: dict[str, Any], + ) -> Any: + state_json.pop("generated_prompt_cache_key", None) + return await original_from_json(initial_agent, state_json) + + monkeypatch.setattr(RunState, "from_json", staticmethod(mutating_from_json)) + + errors = await validate_historical_run_state_fixture(fixture) + + assert any("state.generated_prompt_cache_key" in error for error in errors) + + +async def test_historical_fixture_idempotence_uses_immutable_expected_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture = FIXTURE_ROOT / "features" / "v1_8_prompt_cache_key.json" + original_from_json = RunState.from_json + call_count = 0 + + async def mutating_second_read( + initial_agent: Agent[Any], + state_json: dict[str, Any], + ) -> Any: + nonlocal call_count + call_count += 1 + if call_count == 2: + state_json["current_turn"] = 42 + return await original_from_json(initial_agent, state_json) + + monkeypatch.setattr(RunState, "from_json", staticmethod(mutating_second_read)) + + errors = await validate_historical_run_state_fixture(fixture) + + assert any("was not idempotent" in error for error in errors) + + +async def test_credential_fixture_idempotence_uses_immutable_expected_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entry = SOURCES["security"] + fixture = FIXTURE_ROOT / entry["fixture"] + original_from_json = RunState.from_json + call_count = 0 + + async def mutating_second_read( + initial_agent: Agent[Any], + state_json: dict[str, Any], + ) -> Any: + nonlocal call_count + call_count += 1 + if call_count == 2: + state_json["current_turn"] = 42 + return await original_from_json(initial_agent, state_json) + + monkeypatch.setattr(RunState, "from_json", staticmethod(mutating_second_read)) + + errors = await validate_legacy_credential_run_state_fixture( + fixture, + sentinels=entry["sentinels"], + ) + + assert any("was not idempotent" in error for error in errors) + + +async def test_credential_fixture_comparison_uses_immutable_expected_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entry = SOURCES["security"] + fixture = FIXTURE_ROOT / entry["fixture"] + original_from_json = RunState.from_json + call_count = 0 + + async def mutating_first_read( + initial_agent: Agent[Any], + state_json: dict[str, Any], + ) -> Any: + nonlocal call_count + call_count += 1 + if call_count == 1: + state_json["current_turn"] = 42 + return await original_from_json(initial_agent, state_json) + + monkeypatch.setattr(RunState, "from_json", staticmethod(mutating_first_read)) + + errors = await validate_legacy_credential_run_state_fixture( + fixture, + sentinels=entry["sentinels"], + ) + + assert any("state.current_turn changed from 0 to 42" in error for error in errors) + + +@pytest.mark.parametrize( + ("agent_id", "expected_path"), + [ + (None, "sandbox.session_state"), + ("compat-agent", "sandbox.sessions_by_agent.compat-agent.session_state"), + ], +) +async def test_credential_fixture_requires_opaque_driver_options_to_be_removed( + monkeypatch: pytest.MonkeyPatch, + agent_id: str | None, + expected_path: str, +) -> None: + entry = SOURCES["security"] + fixture = FIXTURE_ROOT / entry["fixture"] + original_to_json = RunState.to_json + + def retaining_to_json(state: RunState[Any, Any]) -> dict[str, Any]: + payload = original_to_json(state) + sandbox = payload["sandbox"] + session_state = ( + sandbox["session_state"] + if agent_id is None + else sandbox["sessions_by_agent"][agent_id]["session_state"] + ) + strategy = session_state["manifest"]["entries"]["remote"]["mount_strategy"] + strategy["driver_options"] = {"vfs-cache-mode": "off"} + return payload + + monkeypatch.setattr(RunState, "to_json", retaining_to_json) + + errors = await validate_legacy_credential_run_state_fixture( + fixture, + sentinels=entry["sentinels"], + ) + + assert errors == [ + f"{fixture.name}: {expected_path}.manifest.entries.remote." + "mount_strategy.driver_options remained" + ] + + +async def test_v0194_mount_credentials_are_sanitized_and_require_trusted_rebind( + caplog: pytest.LogCaptureFixture, +) -> None: + entry = SOURCES["security"] + fixture = FIXTURE_ROOT / entry["fixture"] + sentinels = entry["sentinels"] + fixture_text = fixture.read_text(encoding="utf-8") + + assert entry["provenance"] == "historical_writer" + assert entry["version"] == "1.13" + assert all(sentinel in fixture_text for sentinel in sentinels) + with caplog.at_level(logging.DEBUG): + assert ( + await validate_legacy_credential_run_state_fixture( + fixture, + sentinels=sentinels, + ) + == [] + ) + + payload = json.loads(fixture_text) + restored = await RunState.from_json(Agent(name="compat-agent"), payload) + canonical = restored.to_json() + sandbox = canonical["sandbox"] + session_payload = sandbox["session_state"] + session_state = _deserialize_common_sandbox_session_state(session_payload) + assert session_state.mount_authority_redacted is True + with pytest.raises(ValueError, match="requires a current trusted manifest") as exc_info: + session_state.rebind_persisted_mount_authority( + None, + provider_backend_id="unix_local", + ) + + observables = json.dumps(canonical, sort_keys=True) + repr(restored._sandbox) + observables += _redaction_observables(exc_info.value, caplog.records) + assert all(sentinel not in observables for sentinel in sentinels) + + +@pytest.mark.parametrize( + ("fixture_name", "message"), + [ + ("missing_version.json", "missing schema version"), + ("future_version.json", "schema version is not supported"), + ("malformed_current_agent.json", "Run state agent not found in agent map"), + ], +) +async def test_invalid_run_state_fixtures_fail_without_disclosing_values( + fixture_name: str, + message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + fixture = FIXTURE_ROOT / "negative" / fixture_name + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_SECRET_SENTINEL_42" + assert sentinel in json.dumps(payload) + + with caplog.at_level(logging.DEBUG): + with pytest.raises(Exception, match=message) as exc_info: + await RunState.from_json(Agent(name="compat-agent"), payload) + + observables = _redaction_observables(exc_info.value, caplog.records) + assert sentinel not in observables + + +async def test_run_state_cancellation_releases_payload_from_sdk_tracebacks() -> None: + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_CANCEL_SENTINEL_42" + payload["original_input"] = sentinel + started = asyncio.Event() + gate = asyncio.Event() + + async def wait_for_cancellation(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + started.set() + await gate.wait() + return [] + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(wait_for_cancellation, agent) # type: ignore[method-assign] + task = asyncio.create_task(RunState.from_json(agent, payload)) + await started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await task + + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_cleanup_survives_exception_rejecting_redaction_marker() -> None: + class MarkerRejectingError(Exception): + def __setattr__(self, name: str, value: object) -> None: + if name == "_agents_data_redacted": + raise RuntimeError("marker rejected") + super().__setattr__(name, value) + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_MARKER_SENTINEL_42" + payload["original_input"] = sentinel + + async def raise_hostile_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise MarkerRejectingError("safe restoration failure") + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_hostile_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_rejects_foreign_json_mappings_without_invoking_them() -> None: + sentinel = "RUNSTATE_FOREIGN_JSON_MAPPING_SENTINEL_42" + calls: list[str] = [] + + class ForeignMapping(Mapping[str, object]): + def __getitem__(self, key: str) -> object: + calls.append(f"getitem:{key}") + raise ValueError("RunState sandbox resume state has an invalid envelope") + + def __iter__(self) -> Iterator[str]: + calls.append("iter") + raise ValueError(sentinel) + + def __len__(self) -> int: + calls.append("len") + raise ValueError(sentinel) + + payload = json.loads((FIXTURE_ROOT / "minimal" / "v1_15.json").read_text()) + payload["sandbox"] = ForeignMapping() + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(Agent(name="compat-agent"), payload) + + assert calls == [] + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize("entry_point", ["context_override", "context_deserializer"]) +async def test_run_state_rejects_context_wrapper_subclasses_before_restoration( + entry_point: str, +) -> None: + callbacks: list[str] = [] + + class ForeignContextWrapper(RunContextWrapper[object]): + def _rebuild_approvals(self, approvals: Any) -> None: + _ = approvals + callbacks.append("rebuild_approvals") + raise AssertionError("Foreign restoration override executed") + + class ToolInputDescriptor: + def __get__(self, instance: object, owner: type[object]) -> object: + _ = (instance, owner) + callbacks.append("get_tool_input") + raise AssertionError("Foreign tool_input descriptor executed") + + def __set__(self, instance: object, value: object) -> None: + _ = (instance, value) + callbacks.append("set_tool_input") + raise AssertionError("Foreign tool_input descriptor executed") + + context = ForeignContextWrapper(context={"custom": True}) + type.__setattr__(ForeignContextWrapper, "tool_input", ToolInputDescriptor()) + payload = json.loads( + (FIXTURE_ROOT / "features" / "v1_15_canonical_invocation_identity.json").read_text() + ) + payload["context"]["tool_input"] = {"durable": "expected"} + kwargs: dict[str, object] + if entry_point == "context_override": + kwargs = {"context_override": context} + else: + kwargs = {"context_deserializer": lambda _payload: context} + + with pytest.raises( + UserError, + match="RunState restoration does not support RunContextWrapper subclasses", + ) as exc_info: + await RunState.from_json(Agent(name="compat-agent"), payload, **kwargs) # type: ignore[arg-type] + + assert str(exc_info.value).endswith( + "provide the custom context value directly or wrap it in RunContextWrapper." + ) + assert callbacks == [] + assert context.usage.requests == 0 + assert context._approvals == {} + + +async def test_run_state_restores_an_exact_context_wrapper_without_replacing_it() -> None: + context = RunContextWrapper(context={"custom": True}) + payload = json.loads( + (FIXTURE_ROOT / "features" / "v1_15_canonical_invocation_identity.json").read_text() + ) + payload["context"]["tool_input"] = {"durable": "expected"} + + restored = await RunState.from_json( + Agent(name="compat-agent"), + payload, + context_deserializer=lambda _payload: context, + ) + + assert restored._context is context + assert context.context == {"custom": True} + assert context.tool_input == {"durable": "expected"} + assert context._tool_invocations + + +@pytest.mark.parametrize("operation", ["from_json", "from_string"]) +async def test_run_state_direct_base_exception_is_value_free(operation: str) -> None: + class ProviderAbort(BaseException): + pass + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = f"RUNSTATE_{operation.upper()}_BASE_EXCEPTION_SENTINEL_42" + payload["original_input"] = sentinel + source_error = ProviderAbort(sentinel) + + async def raise_source_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise source_error + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_source_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + if operation == "from_json": + await RunState.from_json(agent, payload) + else: + await RunState.from_string(agent, json.dumps(payload)) + + assert source_error.args == () + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +def _system_exit_with_effective_code( + argument_code: bool, + effective_code: bool, +) -> SystemExit: + error = SystemExit(argument_code) + error.code = effective_code + return error + + +@pytest.mark.parametrize( + ("source_error", "expected_type", "expected_args"), + [ + (SystemExit(7), SystemExit, (7,)), + (SystemExit("sensitive exit detail"), SystemExit, (1,)), + (SystemExit(False), SystemExit, (False,)), + (SystemExit(True), SystemExit, (True,)), + (_system_exit_with_effective_code(False, True), SystemExit, (True,)), + (_system_exit_with_effective_code(True, False), SystemExit, (False,)), + (SystemExit(), SystemExit, ()), + (KeyboardInterrupt("sensitive interrupt detail"), KeyboardInterrupt, ()), + (type("ProviderSystemExit", (SystemExit,), {})("sensitive exit detail"), SystemExit, (1,)), + (type("ProviderFalseSystemExit", (SystemExit,), {})(False), SystemExit, (1,)), + (type("ProviderTrueSystemExit", (SystemExit,), {})(True), SystemExit, (1,)), + ( + type("ProviderKeyboardInterrupt", (KeyboardInterrupt,), {})( + "sensitive interrupt detail" + ), + KeyboardInterrupt, + (), + ), + ], + ids=[ + "system-exit-integer", + "system-exit-string", + "system-exit-false", + "system-exit-true", + "system-exit-effective-true", + "system-exit-effective-false", + "system-exit-none", + "keyboard-interrupt", + "system-exit-subclass", + "system-exit-false-subclass", + "system-exit-true-subclass", + "keyboard-interrupt-subclass", + ], +) +async def test_run_state_preserves_value_free_process_control_exceptions( + source_error: BaseException, + expected_type: type[BaseException], + expected_args: tuple[object, ...], +) -> None: + payload = json.loads((FIXTURE_ROOT / "minimal" / "v1_15.json").read_text()) + payload["context"]["context_meta"] = { + "omitted": False, + "original_type": "custom.Context", + "requires_deserializer": True, + "serialized_via": "custom", + } + + def deserialize_context(_payload: Mapping[str, Any]) -> object: + raise source_error + + with pytest.raises(expected_type) as exc_info: + await RunState.from_json( + Agent(name="compat-agent"), + payload, + context_deserializer=deserialize_context, + ) + + assert type(exc_info.value) is expected_type + assert exc_info.value.args == expected_args + if expected_args: + assert type(exc_info.value.args[0]) is type(expected_args[0]) + assert exc_info.value is not source_error + assert source_error.args == () + assert source_error.__traceback__ is None + if type(source_error) is SystemExit: + assert source_error.code is None + + +async def test_run_state_rejects_a_foreign_error_with_a_copied_trusted_traceback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sentinel = "RUNSTATE_COPIED_TRUSTED_TRACEBACK_SECRET_42" + payload = json.loads((FIXTURE_ROOT / "minimal" / "v1_15.json").read_text()) + invalid_payload = dict(payload, pending_input=sentinel) + with pytest.raises(UserError) as trusted_exc_info: + await run_state_module._build_run_state_from_json( + Agent(name="compat-agent"), invalid_payload + ) + + source_error = UserError("Run state pending_input must be a list").with_traceback( + trusted_exc_info.value.__traceback__ + ) + source_error.payload = sentinel # type: ignore[attr-defined] + + def raise_foreign_error(_agent: Agent[Any]) -> dict[str, Agent[Any]]: + raise source_error + + monkeypatch.setattr(run_state_module, "_build_agent_map", raise_foreign_error) + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(Agent(name="compat-agent"), payload) + + assert source_error.args == () + assert source_error.__dict__ == {} + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_rejects_a_foreign_error_with_a_forged_validation_marker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sentinel = "RUNSTATE_FORGED_VALIDATION_MARKER_SECRET_42" + message = "Run state pending_input must be a list" + payload = json.loads((FIXTURE_ROOT / "minimal" / "v1_15.json").read_text()) + source_error = UserError(message) + source_error.payload = sentinel # type: ignore[attr-defined] + source_error._agents_run_state_validation = (object(), message) # type: ignore[attr-defined] + + def raise_foreign_error(_agent: Agent[Any]) -> dict[str, Agent[Any]]: + raise source_error + + monkeypatch.setattr(run_state_module, "_build_agent_map", raise_foreign_error) + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(Agent(name="compat-agent"), payload) + + assert source_error.args == () + assert source_error.__dict__ == {} + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_rejects_a_mutated_trusted_diagnostic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sentinel = "RUNSTATE_MUTATED_TRUSTED_DIAGNOSTIC_SECRET_42" + payload = json.loads((FIXTURE_ROOT / "minimal" / "v1_15.json").read_text()) + source_error: UserError | None = None + + def mutate_trusted_error(_agent: Agent[Any]) -> dict[str, Agent[Any]]: + nonlocal source_error + try: + run_state_module._validate_run_state_schema_version({}) + except UserError as error: + source_error = error + error.args = (sentinel,) + raise + raise AssertionError("trusted error producer unexpectedly returned") + + agent = Agent(name="compat-agent") + monkeypatch.setattr(run_state_module, "_build_agent_map", mutate_trusted_error) + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert source_error is not None + assert source_error.args == () + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize("operation", ["from_json", "from_string"]) +async def test_run_state_rejects_a_diagnostic_from_the_wrong_trusted_producer( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + sentinel = "RUNSTATE_WRONG_TRUSTED_PRODUCER_SECRET_42" + payload = json.loads((FIXTURE_ROOT / "minimal" / "v1_15.json").read_text()) + payload["original_input"] = sentinel + source_error: UserError | None = None + + def substitute_trusted_diagnostic(_agent: Agent[Any]) -> dict[str, Agent[Any]]: + nonlocal source_error + try: + run_state_module._validate_run_state_schema_version({}) + except UserError as error: + source_error = error + error.args = ("Run state pending_input must be a list",) + raise + raise AssertionError("trusted error producer unexpectedly returned") + + agent = Agent(name="compat-agent") + monkeypatch.setattr(run_state_module, "_build_agent_map", substitute_trusted_diagnostic) + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + if operation == "from_json": + await RunState.from_json(agent, payload) + else: + await RunState.from_string(agent, json.dumps(payload)) + + assert source_error is not None + assert source_error.args == () + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize("operation", ["from_json", "from_string"]) +@pytest.mark.parametrize( + "carrier", + ["agent_name", "agent_identity", "context_type", "completed_call_id"], +) +async def test_run_state_payload_derived_restoration_errors_are_value_free( + operation: str, + carrier: str, +) -> None: + sentinel = f"RUNSTATE_{carrier.upper()}_SECRET_42" + fixture_name = ( + "features/v1_15_canonical_invocation_identity.json" + if carrier == "completed_call_id" + else "minimal/v1_15.json" + ) + payload = json.loads((FIXTURE_ROOT / fixture_name).read_text(encoding="utf-8")) + strict_context = False + if carrier == "agent_name": + payload["current_agent"] = {"name": sentinel} + elif carrier == "agent_identity": + payload["current_agent"] = {"name": "compat-agent", "identity": sentinel} + elif carrier == "context_type": + payload["context"]["context_meta"] = { + "original_type": sentinel, + "requires_deserializer": True, + } + strict_context = True + else: + invocation = next(iter(payload["context"]["tool_invocations"].values())) + invocation["completed"] = True + payload["context"]["tool_invocations"] = {sentinel: invocation} + + agent = Agent(name="compat-agent") + expected_message = { + "agent_name": "Run state agent not found in agent map", + "agent_identity": "agent identity", + "context_type": "requires explicit restoration", + "completed_call_id": "invalid lifecycle data", + }[carrier] + with pytest.raises(UserError, match=expected_message) as exc_info: + if operation == "from_json": + await RunState.from_json(agent, payload, strict_context=strict_context) + else: + await RunState.from_string( + agent, + json.dumps(payload), + strict_context=strict_context, + ) + + assert type(exc_info.value) is UserError + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize("operation", ["from_json", "from_string"]) +async def test_sdk_owned_run_state_validation_preserves_sanitized_user_error( + operation: str, +) -> None: + sentinel = "RUNSTATE_INVALID_PENDING_INPUT_SECRET_42" + payload = json.loads((FIXTURE_ROOT / "minimal" / "v1_15.json").read_text()) + payload["pending_input"] = sentinel + agent = Agent(name="compat-agent") + + with pytest.raises(UserError, match="Run state pending_input must be a list") as exc_info: + if operation == "from_json": + await RunState.from_json(agent, payload) + else: + await RunState.from_string(agent, json.dumps(payload)) + + assert type(exc_info.value) is UserError + assert str(exc_info.value) == "Run state pending_input must be a list" + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize("carrier", ["message", "attribute", "args", "notes", "group"]) +async def test_run_state_failure_discards_exception_owned_payload(carrier: str) -> None: + class PayloadError(Exception): + pass + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = f"RUNSTATE_{carrier.upper()}_SENTINEL_42" + payload["original_input"] = sentinel + + async def raise_payload_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + if carrier == "message": + raise RuntimeError(sentinel) + if carrier == "attribute": + error = PayloadError("safe restoration failure") + error.payload = payload # type: ignore[attr-defined] + raise error + if carrier == "args": + raise ValueError("safe restoration failure", payload) + if carrier == "notes": + note_error = RuntimeError("safe restoration failure") + note_error.__notes__ = [sentinel] + raise note_error + nested = RuntimeError("safe nested failure") + nested.payload = payload # type: ignore[attr-defined] + raise BaseExceptionGroup("safe restoration group", [nested]) + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_payload_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError) as exc_info: + await RunState.from_json(agent, payload) + + assert str(exc_info.value) == "Error details are redacted." + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_failure_discards_retained_source_exception() -> None: + class PayloadError(Exception): + pass + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_RETAINED_SOURCE_SENTINEL_42" + payload["original_input"] = sentinel + source_error = PayloadError("safe restoration failure") + source_error.payload = payload # type: ignore[attr-defined] + + async def raise_source_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise source_error + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_source_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted"): + await RunState.from_json(agent, payload) + + assert source_error.args == () + assert source_error.__dict__ == {} + assert source_error.__traceback__ is None + + +async def test_run_state_failure_discards_retained_nested_exception() -> None: + class PayloadError(Exception): + pass + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_RETAINED_NESTED_EXCEPTION_SENTINEL_42" + child = PayloadError(sentinel) + child.payload = {"credential": sentinel} # type: ignore[attr-defined] + source_error = RuntimeError("safe restoration failure", {"children": [child]}) + source_error.payload = (child,) # type: ignore[attr-defined] + + async def raise_source_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise source_error + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_source_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted"): + await RunState.from_json(agent, payload) + + assert source_error.args == () + assert source_error.__dict__ == {} + assert source_error.__traceback__ is None + assert child.args == () + assert child.__dict__ == {} + assert child.__traceback__ is None + + +async def test_run_state_failure_discards_retained_exception_group_children() -> None: + class SlottedPayloadError(Exception): + payload: object + __slots__ = ("payload",) + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_RETAINED_GROUP_SENTINEL_42" + payload["original_input"] = sentinel + child = SlottedPayloadError("safe child") + child.payload = payload + source_group = BaseExceptionGroup(sentinel, [child]) + + async def raise_group(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise source_group + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_group, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert child.args == () + with pytest.raises(AttributeError): + _ = child.payload + assert source_group.args[0] == "Error details are redacted." + assert source_group.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_failure_discards_slots_without_metaclass_callbacks() -> None: + descriptor_calls: list[str] = [] + + class HostileMroDescriptor: + def __get__(self, obj: object, owner: type | None = None) -> object: + _ = (obj, owner) + descriptor_calls.append("get") + raise AssertionError("Metaclass descriptor executed") + + def __set__(self, obj: object, value: object) -> None: + _ = (obj, value) + descriptor_calls.append("set") + raise AssertionError("Metaclass descriptor executed") + + class HostileExceptionMeta(type): + __mro__ = cast(Any, HostileMroDescriptor()) + + class SlottedPayloadError(Exception, metaclass=HostileExceptionMeta): + payload: object + __slots__ = ("payload",) + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_HOSTILE_METACLASS_SLOT_SENTINEL_42" + payload["original_input"] = sentinel + source_error = SlottedPayloadError("safe restoration failure") + source_error.payload = payload + + async def raise_source_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise source_error + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_source_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert descriptor_calls == [] + assert source_error.args == () + with pytest.raises(AttributeError): + _ = source_error.payload + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_hidden_overwritten_slot_remains_caller_owned_without_callbacks() -> None: + descriptor_calls: list[str] = [] + + class HostileDescriptor: + def __get__(self, obj: object, owner: type | None = None) -> object: + _ = (obj, owner) + descriptor_calls.append("get") + raise AssertionError("Provider descriptor executed") + + def __set__(self, obj: object, value: object) -> None: + _ = (obj, value) + descriptor_calls.append("set") + raise AssertionError("Provider descriptor executed") + + def __delete__(self, obj: object) -> None: + _ = obj + descriptor_calls.append("delete") + raise AssertionError("Provider descriptor executed") + + class SlottedPayloadError(Exception): + payload: object + __slots__ = ("payload",) + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_HIDDEN_PROVIDER_SLOT_SENTINEL_42" + payload["original_input"] = sentinel + source_error = SlottedPayloadError("safe restoration failure") + source_error.payload = payload + original_descriptor = type.__getattribute__(SlottedPayloadError, "__dict__")["payload"] + type.__setattr__(SlottedPayloadError, "payload", HostileDescriptor()) + + async def raise_source_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise source_error + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_source_error, agent) # type: ignore[method-assign] + + try: + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert descriptor_calls == [] + assert source_error.args == () + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + # Python exposes no callback-free way to reach the hidden slot after its + # defining descriptor is replaced. The provider-owned source shell remains + # outside the SDK public error boundary and can recover its own storage. + type.__setattr__(SlottedPayloadError, "payload", original_descriptor) + assert source_error.payload is payload + finally: + type.__setattr__(SlottedPayloadError, "payload", original_descriptor) + + +async def test_run_state_from_string_discards_exception_owned_payload() -> None: + class PayloadError(Exception): + pass + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_FROM_STRING_SENTINEL_42" + payload["original_input"] = sentinel + + async def raise_payload_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + error = PayloadError("safe restoration failure") + error.payload = payload # type: ignore[attr-defined] + raise error + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_payload_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_string(agent, json.dumps(payload)) + + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_from_string_malformed_json_discards_caller_owned_state() -> None: + sentinel = "RUNSTATE_MALFORMED_JSON_CALLER_SENTINEL_42" + agent = Agent(name=sentinel) + + def deserialize_context(value: object) -> object: + _ = value + return {"credential": sentinel} + + with pytest.raises(UserError, match="Failed to parse run state JSON") as exc_info: + await RunState.from_string( + agent, + "{", + context_override={"credential": sentinel}, + context_deserializer=deserialize_context, + ) + + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_from_string_deep_json_failure_discards_parser_state() -> None: + sentinel = "RUNSTATE_DEEP_JSON_PARSER_SENTINEL_42" + state_string = "[" * 100_000 + json.dumps(sentinel) + "]" * 100_000 + + with pytest.raises( + UserError, + match="Failed to parse run state JSON|Run state JSON must be an object", + ) as exc_info: + await RunState.from_string(Agent(name="compat-agent"), state_string) + + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_from_string_non_decode_parser_failure_discards_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sentinel = "RUNSTATE_NON_DECODE_PARSER_SENTINEL_42" + source_error = RecursionError("parser nesting limit") + + def fail_to_parse(state_string: str) -> object: + retained_input = state_string + if retained_input: + raise source_error + return {} + + monkeypatch.setattr(json, "loads", fail_to_parse) + + with pytest.raises(UserError, match="Failed to parse run state JSON") as exc_info: + await RunState.from_string(Agent(name="compat-agent"), sentinel) + + assert source_error.args == () + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_from_string_discards_hostile_json_decode_error_descriptors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sentinel = "RUNSTATE_HOSTILE_JSON_DECODE_DESCRIPTOR_SECRET_42" + + class HostileJSONDecodeError(json.JSONDecodeError): + def __getattribute__(self, name: str) -> object: + if name in {"lineno", "colno", "pos"}: + raise RuntimeError(sentinel) + return super().__getattribute__(name) + + source_error = HostileJSONDecodeError("invalid JSON", sentinel, 0) + + def fail_to_parse(_state_string: str) -> object: + raise source_error + + monkeypatch.setattr(json, "loads", fail_to_parse) + + with pytest.raises(UserError, match="Failed to parse run state JSON") as exc_info: + await RunState.from_string(Agent(name="compat-agent"), sentinel) + + assert source_error.args == () + assert source_error.__dict__ == {} + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize( + ("source_error", "expected_type", "expected_args"), + [ + (SystemExit("sensitive parser exit"), SystemExit, (1,)), + (KeyboardInterrupt("sensitive parser interrupt"), KeyboardInterrupt, ()), + ], +) +async def test_run_state_from_string_parser_preserves_value_free_process_control( + monkeypatch: pytest.MonkeyPatch, + source_error: BaseException, + expected_type: type[BaseException], + expected_args: tuple[object, ...], +) -> None: + def fail_to_parse(_state_string: str) -> object: + raise source_error + + monkeypatch.setattr(json, "loads", fail_to_parse) + + with pytest.raises(expected_type) as exc_info: + await RunState.from_string(Agent(name="compat-agent"), "{}") + + assert type(exc_info.value) is expected_type + assert exc_info.value.args == expected_args + assert exc_info.value is not source_error + assert source_error.args == () + assert source_error.__traceback__ is None + + +@pytest.mark.parametrize("operation", ["from_json", "from_string"]) +async def test_run_state_discards_base_exception_group_with_cancellation( + operation: str, +) -> None: + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_BASE_GROUP_SENTINEL_42" + payload["original_input"] = sentinel + + async def raise_group(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + child = RuntimeError("safe child") + child.payload = payload # type: ignore[attr-defined] + raise BaseExceptionGroup( + "safe restoration group", + [asyncio.CancelledError(), child], + ) + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_group, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + if operation == "from_json": + await RunState.from_json(agent, payload) + else: + await RunState.from_string(agent, json.dumps(payload)) + + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize("hostile_field", ["args", "traceback"]) +async def test_run_state_cleanup_handles_hostile_exception_descriptors( + hostile_field: str, +) -> None: + class HostileError(Exception): + if hostile_field == "args": + + @property + def args(self) -> tuple[object, ...]: # type: ignore[override] + raise RuntimeError("hostile descriptor failure") + + if hostile_field == "traceback": + + @property + def __traceback__(self) -> object: # type: ignore[override] + raise RuntimeError("hostile descriptor failure") + + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = f"RUNSTATE_HOSTILE_{hostile_field.upper()}_SENTINEL_42" + payload["original_input"] = sentinel + + async def raise_hostile(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise HostileError(sentinel) + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_hostile, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert exc_info.value.__context__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +@pytest.mark.parametrize("module_name", ["agents.spoofed_external_hook", object()]) +async def test_run_state_does_not_trust_mutable_frame_module_metadata( + module_name: object, +) -> None: + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_SPOOFED_MODULE_SENTINEL_42" + payload["original_input"] = sentinel + scope = { + "__name__": module_name, + "RuntimeError": RuntimeError, + "sentinel": sentinel, + } + exec("async def fail(self, context):\n raise RuntimeError(sentinel)", scope) + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(cast(Any, scope["fail"]), agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert exc_info.value.__context__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_does_not_trust_foreign_code_with_sdk_globals_and_filename() -> None: + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_FOREIGN_CODE_SENTINEL_42" + try: + exec( + compile( + f"raise UserError({sentinel!r})", + run_state_module.__file__, + "exec", + ), + run_state_module.__dict__, + ) + except UserError as error: + source_error = error + + async def raise_source_error(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise source_error + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_source_error, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert source_error.args == () + assert source_error.__traceback__ is None + assert sentinel not in _redaction_observables(exc_info.value, []) + + +async def test_run_state_does_not_trust_cancellation_name_or_module() -> None: + fake_cancel_type = type( + "CancelledError", + (Exception,), + {"__module__": "asyncio.exceptions"}, + ) + fixture = FIXTURE_ROOT / "resume" / "v1_13_pending_tool_approval.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + sentinel = "RUNSTATE_FAKE_CANCEL_SENTINEL_42" + payload["original_input"] = sentinel + + async def raise_fake_cancel(self: Agent[Any], context: object) -> list[object]: + _ = (self, context) + raise fake_cancel_type(sentinel) + + agent = Agent(name="compat-agent") + agent.get_all_tools = types.MethodType(raise_fake_cancel, agent) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Error details are redacted") as exc_info: + await RunState.from_json(agent, payload) + + assert type(exc_info.value) is RuntimeError + assert sentinel not in _redaction_observables(exc_info.value, []) diff --git a/tests/test_runtime_symmetry_contract.py b/tests/test_runtime_symmetry_contract.py new file mode 100644 index 0000000000..1dd7c66229 --- /dev/null +++ b/tests/test_runtime_symmetry_contract.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails + +from agents import Agent, Runner, Tool, Usage +from agents.items import ToolApprovalItem +from agents.result import RunResult, RunResultStreaming +from agents.usage import serialize_usage + +from .fake_model import FakeModel +from .test_responses import get_function_tool, get_function_tool_call, get_text_message +from .testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans +from .utils.simple_session import SimpleListSession + + +def _item_projection(item: Any) -> dict[str, Any]: + if isinstance(item, ToolApprovalItem): + return { + "type": type(item).__name__, + "name": item.name, + "call_id": item.call_id, + } + payload = item.to_input_item() + return { + key: payload.get(key) + for key in ("type", "name", "call_id", "output") + if payload.get(key) is not None + } + + +def _result_projection(result: RunResult | RunResultStreaming) -> dict[str, Any]: + return { + "final_output": result.final_output, + "last_agent": result.last_agent.name, + "new_items": [_item_projection(item) for item in result.new_items], + "interruptions": [ + { + "name": item.name, + "call_id": item.call_id, + } + for item in result.interruptions + ], + "usage": serialize_usage(result.context_wrapper.usage), + } + + +def _detailed_usage() -> Usage: + return Usage( + requests=1, + input_tokens=11, + output_tokens=7, + total_tokens=18, + input_tokens_details=InputTokensDetails.model_validate( + {"cached_tokens": 3, "cache_write_tokens": 2} + ), + output_tokens_details=OutputTokensDetails(reasoning_tokens=4), + ) + + +def _assert_detailed_usage(usage: dict[str, Any]) -> None: + assert usage["input_tokens"] > 0 + assert usage["output_tokens"] > 0 + assert usage["total_tokens"] > 0 + assert usage["input_tokens_details"][0]["cached_tokens"] > 0 + assert usage["input_tokens_details"][0]["cache_write_tokens"] > 0 + assert usage["output_tokens_details"][0]["reasoning_tokens"] > 0 + assert usage["request_usage_entries"] + + +def _trace_projection() -> list[dict[str, Any]]: + def project_node(node: dict[str, Any]) -> dict[str, Any]: + projected = {key: node[key] for key in ("workflow_name", "type") if key in node} + projected["has_error"] = node.get("error") is not None + children = node.get("children") + if isinstance(children, list): + projected["children"] = [project_node(child) for child in children] + return projected + + return [project_node(trace) for trace in fetch_normalized_spans()] + + +async def _run( + agent: Agent[Any], + *, + streamed: bool, + session: SimpleListSession | None = None, +) -> RunResult | RunResultStreaming: + if not streamed: + return await Runner.run(agent, "run the contract", session=session) + result = Runner.run_streamed(agent, "run the contract", session=session) + async for _event in result.stream_events(): + pass + return result + + +@pytest.mark.parametrize("streamed", [False, True]) +async def test_fake_model_records_every_model_visible_request_field(streamed: bool) -> None: + model = FakeModel() + model.set_next_output([get_text_message("READY")]) + await _run(Agent(name="request-contract-agent", model=model), streamed=streamed) + + assert set(model.last_turn_args) == { + "system_instructions", + "input", + "model_settings", + "tools", + "output_schema", + "handoffs", + "tracing", + "previous_response_id", + "conversation_id", + "prompt", + } + + +@pytest.mark.parametrize("scenario", ["basic", "function-tool"]) +async def test_streamed_and_nonstreamed_runs_have_matching_semantics(scenario: str) -> None: + projections: list[dict[str, Any]] = [] + for streamed in (False, True): + SPAN_PROCESSOR_TESTING.clear() + model = FakeModel(tracing_enabled=True) + model.set_hardcoded_usage(_detailed_usage()) + tools: list[Tool] = [] + if scenario == "function-tool": + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("release_check", "{}", call_id="call-release")], + [get_text_message("READY")], + ] + ) + tools = [get_function_tool("release_check", "checked")] + else: + model.set_next_output([get_text_message("READY")]) + agent = Agent(name="symmetry-agent", model=model, tools=tools) + session = SimpleListSession(session_id=f"{scenario}-{streamed}") + result = await _run(agent, streamed=streamed, session=session) + projections.append( + { + "result": _result_projection(result), + "session_items": await session.get_items(), + "traces": _trace_projection(), + } + ) + + assert projections[0] == projections[1] + for projection in projections: + _assert_detailed_usage(projection["result"]["usage"]) + assert projection["session_items"] + assert projection["traces"] + + +async def test_streamed_and_nonstreamed_runs_raise_the_same_exception_class() -> None: + exception_classes: list[type[BaseException]] = [] + for streamed in (False, True): + model = FakeModel() + model.set_next_output(RuntimeError("release contract failure")) + agent = Agent(name="symmetry-agent", model=model) + + with pytest.raises(RuntimeError) as exc_info: + await _run(agent, streamed=streamed) + exception_classes.append(type(exc_info.value)) + + assert exception_classes == [RuntimeError, RuntimeError] + + +async def test_approval_resume_cross_modes_have_matching_semantics() -> None: + projections: list[dict[str, Any]] = [] + for start_streamed, resume_streamed in ((True, False), (False, True)): + SPAN_PROCESSOR_TESTING.clear() + model = FakeModel(tracing_enabled=True) + model.set_hardcoded_usage(_detailed_usage()) + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("release_check", "{}", call_id="call-release")], + [get_text_message("READY")], + ] + ) + tool = get_function_tool("release_check", "checked") + tool.needs_approval = True + agent = Agent(name="symmetry-agent", model=model, tools=[tool]) + session = SimpleListSession(session_id=f"approval-{start_streamed}-{resume_streamed}") + + first = await _run(agent, streamed=start_streamed, session=session) + assert len(first.interruptions) == 1 + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed: RunResult | RunResultStreaming + if resume_streamed: + streaming_result = Runner.run_streamed(agent, state, session=session) + async for _event in streaming_result.stream_events(): + pass + resumed = streaming_result + else: + resumed = await Runner.run(agent, state, session=session) + + projections.append( + { + "first": _result_projection(first), + "resumed": _result_projection(resumed), + "session_items": await session.get_items(), + "traces": _trace_projection(), + } + ) + + assert projections[0] == projections[1] + for projection in projections: + _assert_detailed_usage(projection["first"]["usage"]) + _assert_detailed_usage(projection["resumed"]["usage"]) + assert projection["session_items"] + assert projection["traces"] From 92de6cf547ca34e98a89041b50513652a97bfbca Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 16:03:23 +0900 Subject: [PATCH 267/473] feat: add local release candidate preparation --- .agents/skills/pr-draft-summary/SKILL.md | 4 +- .../skills/release-candidate-prep/SKILL.md | 111 ++++++ .../release-candidate-prep/scripts/prepare.py | 337 ++++++++++++++++++ .../scripts/test_prepare.py | 263 ++++++++++++++ .github/workflows/release-pr.yml | 144 -------- AGENTS.md | 8 +- 6 files changed, 720 insertions(+), 147 deletions(-) create mode 100644 .agents/skills/release-candidate-prep/SKILL.md create mode 100755 .agents/skills/release-candidate-prep/scripts/prepare.py create mode 100755 .agents/skills/release-candidate-prep/scripts/test_prepare.py delete mode 100644 .github/workflows/release-pr.yml diff --git a/.agents/skills/pr-draft-summary/SKILL.md b/.agents/skills/pr-draft-summary/SKILL.md index 3190d97658..230aa851b8 100644 --- a/.agents/skills/pr-draft-summary/SKILL.md +++ b/.agents/skills/pr-draft-summary/SKILL.md @@ -1,6 +1,6 @@ --- name: pr-draft-summary -description: Create the required PR-ready summary block, branch suggestion, title, and draft description for openai-agents-python. Use before the final response whenever the current task changed runtime code, tests, examples, build/test configuration, or docs with behavior impact, regardless of perceived change size and including local-only or uncommitted work. Skip only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, or when the user explicitly says not to include the PR draft block. +description: Create the required PR-ready summary block, branch suggestion, title, and draft description for openai-agents-python. Use before the final response whenever the current task changed runtime code, tests, examples, build/test configuration, or docs with behavior impact, regardless of perceived change size and including local-only or uncommitted work. Skip only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, an explicitly invoked $release-candidate-prep handoff, or when the user explicitly says not to include the PR draft block. --- # PR Draft Summary @@ -12,7 +12,7 @@ Produce the PR-ready summary required in this repository after eligible code wor - Before every final response, check whether the current task changed runtime code (`src/agents/`), tests (`tests/`), examples (`examples/`), build/test configuration, or docs with behavior impact. - If it did, run this skill after required verification and before sending the final response. Do not use perceived change size to decide whether to run it. - Run it for eligible local-only and uncommitted work even when the user did not ask to create a pull request. Producing this text does not authorize creating a branch, committing, pushing, or opening a pull request. -- Skip only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, or when the user explicitly says not to include the PR draft block. +- Skip only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, an explicitly invoked `$release-candidate-prep` handoff that uses the complete `$final-release-review` report as its release-specific PR description, or when the user explicitly says not to include the PR draft block. This exception applies to preparing the release candidate itself, not to implementing or changing the release-preparation skill. ## Inputs to Collect Automatically (do not ask the user) - Current branch: `git rev-parse --abbrev-ref HEAD`. diff --git a/.agents/skills/release-candidate-prep/SKILL.md b/.agents/skills/release-candidate-prep/SKILL.md new file mode 100644 index 0000000000..01b836931e --- /dev/null +++ b/.agents/skills/release-candidate-prep/SKILL.md @@ -0,0 +1,111 @@ +--- +name: release-candidate-prep +description: Prepare an OpenAI Agents Python release candidate locally from exact origin/main, freeze the released API contract, create one local release commit, run final release review, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. +--- + +# Release Candidate Preparation + +Use this skill only when the user explicitly invokes `$release-candidate-prep` and supplies a release version without a leading `v`, for example `VERSION=0.20.1`. This skill replaces the removed GitHub Actions release-PR creator with a reviewed local workflow. + +## Non-negotiable boundaries + +- Treat explicit invocation as authorization to fast-forward a clean local `main`, create `release/v`, update the three release-owned files, and create one local commit. +- Never push, open or edit a pull request, add labels or milestones, create a release, or otherwise mutate GitHub. Never run `gh`. +- Own exactly `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`. Runtime, documentation, workflow, or other repository changes must land on `main` before release preparation. +- Do not stash, reset, delete, overwrite, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty, is not on `main`, has diverged from refreshed `origin/main`, or collides with a local or remote release branch. +- Remove inherited `OPENAI_API_KEY` from every child command. Release preparation does not require a live OpenAI API request. +- Stop after the local commit, final release review, and copy-ready handoff. The user owns the push and pull-request creation. + +## 1. Establish the release input + +Require one semver-like version without a leading `v`. Do not infer a version from milestones, branch names, or local modifications. Announce that the skill will update the local checkout and create one commit but will not write to GitHub. + +Read `$final-release-review` completely before starting. Its final-candidate report is the release pull request description. Do not use `$pr-draft-summary` for the release candidate itself; this skill owns the fixed release branch, commit subject, title, and description. Continue to use `$pr-draft-summary` normally when implementing changes to this skill or other repository behavior. + +## 2. Prepare the uncommitted candidate + +From the repository root, run: + +```bash +env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py --version +``` + +The helper must complete all of these operations or fail with an actionable error: + +1. Verify the repository root, `main` branch, and clean working tree. +2. Verify that `release/v` does not exist locally or remotely. +3. Fetch `main` into `origin/main`, fast-forward local `main` with `git merge --ff-only origin/main`, and require local `HEAD` to equal refreshed `origin/main`. +4. Create `release/v`. +5. Update the single project version declaration in `pyproject.toml`. +6. Run `make sync` with `UV_DEFAULT_INDEX=https://pypi.org/simple`. +7. Run `make update-released-api-contract VERSION=` and then `make check-released-api-contract VERSION=`. +8. Require exactly the three release-owned paths to be modified and leave them unstaged and uncommitted. + +If the helper fails after branch creation, preserve its local branch and working-tree evidence. Report the failing command and state rather than guessing whether a partial run is safe to resume. + +## 3. Review and commit the exact release diff + +Inspect all release-owned files before staging: + +```bash +git status --short +git diff --check +git diff -- pyproject.toml uv.lock tests/fixtures/released_api_contract.json +``` + +Confirm all of the following: + +- `pyproject.toml` and the editable `openai-agents` entry in `uv.lock` declare the requested version. +- The API contract baseline is `v` and its `baseline_commit` is the exact `origin/main` source commit on which the release branch is based. +- The generated contract preserves the previous release and freezes intended new exports and signatures. +- Any intended `public_properties`, `canonical_imports`, or `public_modules` policy additions have been reviewed explicitly; the updater deliberately does not infer them. +- No path outside the three-file release manifest is changed, staged, or untracked. + +Stage only the manifest and create exactly one local commit: + +```bash +git add pyproject.toml uv.lock tests/fixtures/released_api_contract.json +git commit -m "release: " +``` + +Do not amend unrelated content into the commit. + +## 4. Run the final-candidate release review + +Invoke `$final-release-review` in final-candidate mode with the release commit as `TARGET=HEAD`. The branch, `pyproject.toml`, `uv.lock`, and API contract must agree on the intended version. + +If the review is blocked, stop. Return its unblock checklist, retain the local branch and commit for follow-up, and do not present the candidate as PR-ready. After any fix, regenerate the API contract when the public surface may have changed, restore a single release commit, and rerun the complete final-candidate review. + +## 5. Recheck main freshness + +After a green review, fetch `origin main` again without credentials and compare it with the release commit's parent. If they differ, the candidate is stale. First verify that the branch is clean, has exactly one local commit, and that the commit changes only the three-file release manifest. Rebase that commit onto the new `origin/main` so Git detects any conflicting release metadata. After a clean rebase, move the local release branch back to `origin/main` with a mixed reset, which preserves the rebased release tree as unstaged task-owned changes. Restore only `tests/fixtures/released_api_contract.json` from `origin/main`, rerun `make sync`, update and check the API contract while `HEAD` is the new base, review the exact manifest again, and recreate the single `release: ` commit. Then rerun `$final-release-review`. Repeat until the reviewed commit is exactly one commit ahead of current `origin/main`. + +If replay conflicts or another path changes, stop with recoverable evidence. Do not force a resolution that expands the release commit beyond its manifest. + +## 6. Produce the release handoff + +For a green, current candidate, return the `$final-release-review` report plus this release-specific block in English: + +```markdown +# Release Pull Request + +## Branch + +release/v + +## Commit + +release: + +## Title + +Release + +## Description + + +``` + +Apply the repository's GitHub paste-readiness rules to the report. Use native `#123` references for this repository and `owner/repo#123` for another repository. Keep the required compare URL. Do not include local paths, Codex citations, operational diagnostics, or app directives inside the copy-ready description. + +Also report the local branch, commit SHA, parent `origin/main` commit, and the exact three-file manifest outside the copy-ready block. State explicitly that nothing was pushed and no pull request was created. diff --git a/.agents/skills/release-candidate-prep/scripts/prepare.py b/.agents/skills/release-candidate-prep/scripts/prepare.py new file mode 100755 index 0000000000..f494ba5144 --- /dev/null +++ b/.agents/skills/release-candidate-prep/scripts/prepare.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Prepare an uncommitted local release candidate from exact origin/main.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from collections.abc import Sequence + +ROOT = Path(__file__).resolve().parents[4] +VERSION_PATTERN = re.compile(r"\d+\.\d+(?:\.\d+)*(?:[A-Za-z0-9.-]+)?\Z") +PROJECT_VERSION_PATTERN = re.compile(r'(?m)^version\s*=\s*"[^"]+"') +RELEASE_PATHS = frozenset( + { + "pyproject.toml", + "tests/fixtures/released_api_contract.json", + "uv.lock", + } +) + + +class ReleasePreparationError(RuntimeError): + """Report a safe, actionable release preparation failure.""" + + +@dataclass(frozen=True) +class PreparedCandidate: + """Describe the successfully prepared local candidate.""" + + base_commit: str + branch: str + changed_paths: tuple[str, ...] + version: str + + +def _release_environment() -> dict[str, str]: + env = os.environ.copy() + for name in ("GH_TOKEN", "GITHUB_TOKEN", "OPENAI_API_KEY"): + env.pop(name, None) + env["UV_DEFAULT_INDEX"] = "https://pypi.org/simple" + return env + + +def _command_text(args: Sequence[str]) -> str: + return shlex.join(str(arg) for arg in args) + + +def run_command( + repo: Path, + args: Sequence[str], + *, + env: dict[str, str] | None = None, + announce: bool = False, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + """Run one command and preserve useful output for failures.""" + + if announce: + print(f"+ {_command_text(args)}", flush=True) + effective_env = _release_environment() if env is None else env.copy() + for name in ("GH_TOKEN", "GITHUB_TOKEN", "OPENAI_API_KEY"): + effective_env.pop(name, None) + effective_env["UV_DEFAULT_INDEX"] = "https://pypi.org/simple" + result = subprocess.run( + [str(arg) for arg in args], + cwd=repo, + env=effective_env, + check=False, + capture_output=True, + text=True, + ) + if announce: + if result.stdout: + print(result.stdout, end="") + if result.stderr: + print(result.stderr, end="", file=sys.stderr) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown command failure" + raise ReleasePreparationError(f"{_command_text(args)} failed: {detail}") + return result + + +def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + """Run a Git inspection command.""" + + return run_command(repo, ["git", *args], check=check) + + +def validate_version(version: str) -> str: + """Validate the release version accepted by the existing contract updater.""" + + if version.startswith("v") or ".." in version or VERSION_PATTERN.fullmatch(version) is None: + raise ReleasePreparationError( + "Version must be semver-like without a leading v, for example 0.20.1 or 0.21.0-rc1." + ) + return version + + +def project_version(repo: Path) -> str: + """Read the project version from pyproject.toml.""" + + data = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + if not isinstance(version, str): + raise ReleasePreparationError("pyproject.toml is missing project.version.") + return version + + +def replace_project_version_text(text: str, version: str) -> str: + """Replace the repository's single project version declaration.""" + + updated, count = PROJECT_VERSION_PATTERN.subn(f'version = "{version}"', text) + if count != 1: + raise ReleasePreparationError( + f"Expected exactly one version declaration in pyproject.toml, found {count}." + ) + if updated == text: + raise ReleasePreparationError(f"pyproject.toml already declares version {version}.") + return updated + + +def replace_project_version(repo: Path, version: str) -> None: + """Update pyproject.toml while preserving all unrelated text.""" + + path = repo / "pyproject.toml" + text = path.read_text(encoding="utf-8") + path.write_text(replace_project_version_text(text, version), encoding="utf-8") + + +def _current_branch(repo: Path) -> str: + result = git(repo, "symbolic-ref", "--quiet", "--short", "HEAD", check=False) + if result.returncode != 0: + raise ReleasePreparationError("Release preparation requires a named main branch.") + return result.stdout.strip() + + +def _status(repo: Path) -> str: + return git(repo, "status", "--porcelain=v1", "--untracked-files=all").stdout + + +def _require_repository_root(repo: Path) -> None: + top_level = Path(git(repo, "rev-parse", "--show-toplevel").stdout.strip()).resolve() + if top_level != repo.resolve(): + raise ReleasePreparationError( + f"Run release preparation from the repository root {top_level}, not {repo.resolve()}." + ) + + +def _require_clean_main(repo: Path) -> None: + branch = _current_branch(repo) + if branch != "main": + raise ReleasePreparationError( + f"Release preparation requires branch 'main', found {branch!r}." + ) + if _status(repo): + raise ReleasePreparationError("Release preparation requires a clean working tree.") + + +def _require_branch_absent(repo: Path, branch: str) -> None: + local = git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False) + if local.returncode == 0: + raise ReleasePreparationError(f"Local branch {branch!r} already exists.") + if local.returncode not in (0, 1): + raise ReleasePreparationError(f"Unable to inspect local branch {branch!r}.") + + remote = git(repo, "ls-remote", "--exit-code", "--heads", "origin", branch, check=False) + if remote.returncode == 0: + raise ReleasePreparationError(f"Remote branch {branch!r} already exists.") + if remote.returncode != 2: + detail = remote.stderr.strip() or remote.stdout.strip() or "unknown remote error" + raise ReleasePreparationError(f"Unable to inspect remote branch {branch!r}: {detail}") + + +def _changed_paths(repo: Path) -> set[str]: + changed: set[str] = set() + for args in ( + ("diff", "--name-only"), + ("diff", "--name-only", "--cached"), + ("ls-files", "--others", "--exclude-standard"), + ): + changed.update(line for line in git(repo, *args).stdout.splitlines() if line) + return changed + + +def _locked_project_version(repo: Path) -> str: + data = tomllib.loads((repo / "uv.lock").read_text(encoding="utf-8")) + packages = data.get("package", []) + matches = [ + package + for package in packages + if package.get("name") == "openai-agents" and package.get("source") == {"editable": "."} + ] + if len(matches) != 1 or not isinstance(matches[0].get("version"), str): + raise ReleasePreparationError( + "uv.lock must contain exactly one editable openai-agents package with a version." + ) + return matches[0]["version"] + + +def _validate_prepared_files(repo: Path, version: str, base_commit: str) -> tuple[str, ...]: + changed = _changed_paths(repo) + if changed != RELEASE_PATHS: + missing = sorted(RELEASE_PATHS - changed) + unexpected = sorted(changed - RELEASE_PATHS) + raise ReleasePreparationError( + "Prepared release paths do not match the required manifest; " + f"missing={missing!r}, unexpected={unexpected!r}." + ) + if project_version(repo) != version: + raise ReleasePreparationError("pyproject.toml does not contain the requested version.") + if _locked_project_version(repo) != version: + raise ReleasePreparationError("uv.lock does not contain the requested project version.") + + contract = json.loads( + (repo / "tests/fixtures/released_api_contract.json").read_text(encoding="utf-8") + ) + if contract.get("baseline") != f"v{version}": + raise ReleasePreparationError( + "The released API contract baseline does not match the version." + ) + if contract.get("baseline_commit") != base_commit: + raise ReleasePreparationError( + "The released API contract baseline_commit does not match " + "the origin/main source commit." + ) + if git(repo, "diff", "--cached", "--quiet", check=False).returncode != 0: + raise ReleasePreparationError("The helper must leave all release changes unstaged.") + return tuple(sorted(changed)) + + +def prepare(repo: Path, version: str) -> PreparedCandidate: + """Prepare the three-file release candidate and leave it uncommitted.""" + + repo = repo.resolve() + version = validate_version(version) + _require_repository_root(repo) + _require_clean_main(repo) + if project_version(repo) == version: + raise ReleasePreparationError(f"Project version is already {version}.") + + branch = f"release/v{version}" + _require_branch_absent(repo, branch) + env = _release_environment() + run_command( + repo, + [ + "git", + "fetch", + "origin", + "refs/heads/main:refs/remotes/origin/main", + "--prune", + ], + env=env, + announce=True, + ) + run_command(repo, ["git", "merge", "--ff-only", "origin/main"], env=env, announce=True) + base_commit = git(repo, "rev-parse", "origin/main").stdout.strip() + head_commit = git(repo, "rev-parse", "HEAD").stdout.strip() + if head_commit != base_commit: + raise ReleasePreparationError( + f"Local main is {head_commit}, but refreshed origin/main is {base_commit}; " + "refusing to release." + ) + if project_version(repo) == version: + raise ReleasePreparationError(f"Refreshed origin/main already declares version {version}.") + + _require_branch_absent(repo, branch) + run_command(repo, ["git", "switch", "-c", branch], env=env, announce=True) + replace_project_version(repo, version) + run_command(repo, ["make", "sync"], env=env, announce=True) + run_command( + repo, + ["make", "update-released-api-contract", f"VERSION={version}"], + env=env, + announce=True, + ) + run_command( + repo, + ["make", "check-released-api-contract", f"VERSION={version}"], + env=env, + announce=True, + ) + changed_paths = _validate_prepared_files(repo, version, base_commit) + return PreparedCandidate( + base_commit=base_commit, + branch=branch, + changed_paths=changed_paths, + version=version, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Prepare an uncommitted local release candidate from exact origin/main." + ) + parser.add_argument( + "--version", + required=True, + help="Release version without a leading v, for example 0.20.1.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + candidate = prepare(ROOT, args.version) + except (OSError, ReleasePreparationError, tomllib.TOMLDecodeError, json.JSONDecodeError) as exc: + print(f"Release preparation failed: {exc}", file=sys.stderr) + return 1 + + print("Release candidate prepared locally and left uncommitted.") + print(f"Base commit: {candidate.base_commit}") + print(f"Branch: {candidate.branch}") + print(f"Version: {candidate.version}") + print("Changed paths:") + for path in candidate.changed_paths: + print(f"- {path}") + print("Review the diff before staging the three release-owned files.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/release-candidate-prep/scripts/test_prepare.py b/.agents/skills/release-candidate-prep/scripts/test_prepare.py new file mode 100755 index 0000000000..c033061b18 --- /dev/null +++ b/.agents/skills/release-candidate-prep/scripts/test_prepare.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Focused tests for local release candidate preparation.""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import prepare + + +def run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + list(args), + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + + +class ReleaseRepository: + """Create a disposable release repository with a local bare origin.""" + + def __init__(self, root: Path) -> None: + self.root = root + self.repo = root / "repo" + self.origin = root / "origin.git" + self.repo.mkdir() + run(self.repo, "git", "init", "--initial-branch=main") + run(self.repo, "git", "config", "user.name", "Release Test") + run(self.repo, "git", "config", "user.email", "release-test@example.com") + self._write_fixture_files() + run(self.repo, "git", "add", ".") + run(self.repo, "git", "commit", "-m", "Initial release source") + run(root, "git", "init", "--bare", str(self.origin)) + run(self.repo, "git", "remote", "add", "origin", str(self.origin)) + run(self.repo, "git", "push", "--set-upstream", "origin", "main") + self.base_commit = run(self.repo, "git", "rev-parse", "HEAD").stdout.strip() + + def advance_origin(self) -> str: + updater = self.root / "updater" + run(self.root, "git", "clone", "--branch", "main", str(self.origin), str(updater)) + run(updater, "git", "config", "user.name", "Release Updater") + run(updater, "git", "config", "user.email", "release-updater@example.com") + (updater / "new-source.txt").write_text("new source\n", encoding="utf-8") + run(updater, "git", "add", "new-source.txt") + run(updater, "git", "commit", "-m", "Advance main") + run(updater, "git", "push", "origin", "main") + return run(updater, "git", "rev-parse", "HEAD").stdout.strip() + + def _write_fixture_files(self) -> None: + (self.repo / "tests/fixtures").mkdir(parents=True) + (self.repo / "pyproject.toml").write_text( + '[project]\nname = "openai-agents"\nversion = "0.19.4"\n', + encoding="utf-8", + ) + (self.repo / "uv.lock").write_text( + 'version = 1\n\n[[package]]\nname = "openai-agents"\nversion = "0.19.4"\n' + 'source = { editable = "." }\n', + encoding="utf-8", + ) + (self.repo / "tests/fixtures/released_api_contract.json").write_text( + json.dumps( + { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "callables": {}, + "required_top_level_exports": [], + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + (self.repo / "fake_release.py").write_text( + """from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +root = Path(__file__).parent +action = sys.argv[1] +if {'GH_TOKEN', 'GITHUB_TOKEN', 'OPENAI_API_KEY'} & os.environ.keys(): + raise SystemExit('credentials must not reach release subprocesses') +if os.environ.get('UV_DEFAULT_INDEX') != 'https://pypi.org/simple': + raise SystemExit('UV_DEFAULT_INDEX must use the public package index') +version = re.search( + r'^version = \"([^\"]+)\"$', + (root / 'pyproject.toml').read_text(), + re.MULTILINE, +).group(1) +if action == 'sync': + path = root / 'uv.lock' + text = path.read_text() + text = re.sub( + r'(name = \"openai-agents\"\\nversion = \")[^\"]+(\")', + rf'\\g<1>{version}\\g<2>', + text, + ) + path.write_text(text) +elif action == 'update': + expected = sys.argv[2] + if expected != version: + raise SystemExit('version mismatch') + path = root / 'tests/fixtures/released_api_contract.json' + contract = json.loads(path.read_text()) + contract['baseline'] = f'v{version}' + contract['baseline_commit'] = subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], cwd=root, text=True + ).strip() + path.write_text(json.dumps(contract, indent=2, sort_keys=True) + '\\n') +elif action == 'check': + expected = sys.argv[2] + contract = json.loads( + (root / 'tests/fixtures/released_api_contract.json').read_text() + ) + if expected != version or contract['baseline'] != f'v{version}': + raise SystemExit('contract mismatch') +else: + raise SystemExit(f'unknown action: {action}') +""", + encoding="utf-8", + ) + (self.repo / "Makefile").write_text( + "sync:\n\tpython fake_release.py sync\n\n" + "update-released-api-contract:\n\tpython fake_release.py update $(VERSION)\n\n" + "check-released-api-contract:\n\tpython fake_release.py check $(VERSION)\n", + encoding="utf-8", + ) + + +class VersionTests(unittest.TestCase): + def test_validate_version_accepts_release_and_prerelease(self) -> None: + self.assertEqual(prepare.validate_version("0.20.1"), "0.20.1") + self.assertEqual(prepare.validate_version("0.21.0-rc1"), "0.21.0-rc1") + + def test_validate_version_rejects_ambiguous_values(self) -> None: + for value in ("v0.20.1", "0..20.1", "next", "0.20.1/other"): + with self.subTest(value=value), self.assertRaises(prepare.ReleasePreparationError): + prepare.validate_version(value) + + def test_replace_project_version_requires_exactly_one_change(self) -> None: + text = '[project]\nversion = "0.19.4"\n' + self.assertEqual( + prepare.replace_project_version_text(text, "0.20.0"), + '[project]\nversion = "0.20.0"\n', + ) + with self.assertRaises(prepare.ReleasePreparationError): + prepare.replace_project_version_text(text, "0.19.4") + with self.assertRaises(prepare.ReleasePreparationError): + prepare.replace_project_version_text( + text + '[tool.example]\nversion = "1.0.0"\n', + "0.20.0", + ) + + +class PreparationTests(unittest.TestCase): + def test_prepare_creates_branch_and_exact_uncommitted_manifest(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + + with mock.patch.dict( + os.environ, + { + "GH_TOKEN": "untrusted", + "GITHUB_TOKEN": "untrusted", + "OPENAI_API_KEY": "untrusted", + }, + ): + candidate = prepare.prepare(fixture.repo, "0.20.0") + + self.assertEqual(candidate.base_commit, fixture.base_commit) + self.assertEqual(candidate.branch, "release/v0.20.0") + self.assertEqual(set(candidate.changed_paths), prepare.RELEASE_PATHS) + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "release/v0.20.0", + ) + self.assertEqual( + run(fixture.repo, "git", "rev-list", "--count", "origin/main..HEAD").stdout.strip(), + "0", + ) + remote_heads = run( + fixture.repo, + "git", + "ls-remote", + "--heads", + "origin", + "release/v0.20.0", + ).stdout + self.assertEqual(remote_heads, "") + contract = json.loads( + (fixture.repo / "tests/fixtures/released_api_contract.json").read_text() + ) + self.assertEqual(contract["baseline"], "v0.20.0") + self.assertEqual(contract["baseline_commit"], fixture.base_commit) + + def test_prepare_rejects_dirty_main_without_creating_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + (fixture.repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") + + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + "clean working tree", + ): + prepare.prepare(fixture.repo, "0.20.0") + + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "main", + ) + + def test_prepare_fast_forwards_to_refreshed_origin_main(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + refreshed_base = fixture.advance_origin() + + candidate = prepare.prepare(fixture.repo, "0.20.0") + + self.assertEqual(candidate.base_commit, refreshed_base) + self.assertEqual( + run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(), + refreshed_base, + ) + self.assertTrue((fixture.repo / "new-source.txt").is_file()) + + def test_prepare_rejects_existing_remote_release_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + run( + fixture.repo, + "git", + "push", + "origin", + "HEAD:refs/heads/release/v0.20.0", + ) + + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + "Remote branch 'release/v0.20.0' already exists", + ): + prepare.prepare(fixture.repo, "0.20.0") + + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "main", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml deleted file mode 100644 index 23eacb77e5..0000000000 --- a/.github/workflows/release-pr.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Create release PR - -on: - workflow_dispatch: - inputs: - version: - description: "Version to release (e.g., 0.6.6)" - required: true - -permissions: - contents: write - pull-requests: write - -jobs: - release-pr: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 0 - ref: main - - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 - with: - version: "0.11.14" - enable-cache: true - prune-cache: true - - name: Fetch tags - run: git fetch origin --tags --prune - - name: Ensure release branch does not exist - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - branch="release/v${RELEASE_VERSION}" - if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then - echo "Branch $branch already exists on origin." >&2 - exit 1 - fi - - name: Update version - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - python - <<'PY' - import os - import pathlib - import re - import sys - - version = os.environ["RELEASE_VERSION"] - if version.startswith("v"): - print("Version must not start with 'v' (use x.y.z...).", file=sys.stderr) - sys.exit(1) - if ".." in version: - print("Version contains consecutive dots (use x.y.z...).", file=sys.stderr) - sys.exit(1) - if not re.match(r"^\d+\.\d+(\.\d+)*([a-zA-Z0-9\.-]+)?$", version): - print( - "Version must be semver-like (e.g., 0.6.6, 0.6.6-rc1, 0.6.6.dev1).", - file=sys.stderr, - ) - sys.exit(1) - path = pathlib.Path("pyproject.toml") - text = path.read_text() - updated, count = re.subn( - r'(?m)^version\s*=\s*"[^\"]+"', - f'version = "{version}"', - text, - ) - if count != 1: - print("Expected to update exactly one version line.", file=sys.stderr) - sys.exit(1) - if updated == text: - print("Version already set; no changes made.", file=sys.stderr) - sys.exit(1) - path.write_text(updated) - PY - - name: Sync dependencies - run: make sync - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Create release branch and commit - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - branch="release/v${RELEASE_VERSION}" - git checkout -b "$branch" - git add pyproject.toml uv.lock - if git diff --cached --quiet; then - echo "No changes to commit." >&2 - exit 1 - fi - git commit -m "Bump version to ${RELEASE_VERSION}" - git push --set-upstream origin "$branch" - - name: Build PR body - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - printf 'Release PR for %s.\n\nThe release readiness report will be prepared manually.\n' "$RELEASE_VERSION" > pr-body.md - - name: Create or update PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - head_branch="release/v${RELEASE_VERSION}" - milestone_name="$(python .github/scripts/select-release-milestone.py --version "$RELEASE_VERSION")" - pr_number="$(gh pr list --head "$head_branch" --base "main" --json number --jq '.[0].number // empty')" - if [ -z "$pr_number" ]; then - create_args=( - --title "Release ${RELEASE_VERSION}" - --body-file pr-body.md - --base "main" - --head "$head_branch" - --label "project" - ) - if [ -n "$milestone_name" ]; then - create_args+=(--milestone "$milestone_name") - fi - if ! gh pr create "${create_args[@]}"; then - echo "PR create with label/milestone failed; retrying without them." >&2 - gh pr create \ - --title "Release ${RELEASE_VERSION}" \ - --body-file pr-body.md \ - --base "main" \ - --head "$head_branch" - fi - else - edit_args=( - --title "Release ${RELEASE_VERSION}" - --body-file pr-body.md - --add-label "project" - ) - if [ -n "$milestone_name" ]; then - edit_args+=(--milestone "$milestone_name") - fi - if ! gh pr edit "$pr_number" "${edit_args[@]}"; then - echo "PR edit with label/milestone failed; retrying without them." >&2 - gh pr edit "$pr_number" --title "Release ${RELEASE_VERSION}" --body-file pr-body.md - fi - fi diff --git a/AGENTS.md b/AGENTS.md index 97e888b836..52a63714d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,10 +51,16 @@ After implementing runtime code, tests, examples, build/test behavior, or behavi Before every final response for a task that changed runtime code, tests, examples, build/test configuration, or docs with behavior impact, invoke `$pr-draft-summary` to generate the required PR summary block, branch suggestion, title, and draft description. Determine whether to invoke it from the changed files, not from a subjective assessment of change size. -Skip `$pr-draft-summary` only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, or when the user explicitly says not to include the PR draft block. +Skip `$pr-draft-summary` only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, an explicitly invoked `$release-candidate-prep` handoff that uses the complete `$final-release-review` report as its release-specific PR description, or when the user explicitly says not to include the PR draft block. The release exception applies to preparing the candidate itself, not to implementing or changing the release-preparation skill. Producing the PR draft block is part of the local final handoff. It is required for eligible local-only or uncommitted changes and does not authorize creating a branch, committing, pushing, or opening a pull request. +#### `$release-candidate-prep` + +Use `$release-candidate-prep` only when the user explicitly invokes it with a release version. It fast-forwards a clean local `main`, creates `release/v`, updates `pyproject.toml` and `uv.lock`, freezes and checks `tests/fixtures/released_api_contract.json`, creates one local release commit, invokes `$final-release-review` against that commit, and returns the fixed release PR title plus the complete final-candidate report as the PR description. + +The skill replaces the former GitHub Actions release-PR creator. It must never push, open or edit a pull request, create a release, or mutate any other GitHub state. Release tag creation and PyPI publication remain owned by their post-merge workflows. The release commit may contain only `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`; all runtime and documentation changes must land on `main` before preparation. + ### Work Status Reporting - Use `RUNNING` only in commentary while autonomous work remains and no user action is required. Do not end a turn with a final response that says the task is still running or asks the user to send a generic continuation prompt. From 2e3aa55fee2b590625d82ebdf89f39b3343b2518 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 19:15:34 +0900 Subject: [PATCH 268/473] ci: check prospective release contracts before merge (#4347) --- .github/scripts/run_integration_tests.py | 163 ++++++- .../scripts/update_released_api_contract.py | 25 ++ .github/workflows/tests.yml | 89 +++- Makefile | 18 + integration_tests/_contract_support.py | 231 +++++++++- .../packaging/test_released_api_contract.py | 206 ++++++++- .../released_api_contract_policy.json | 88 ++++ tests/test_integration_runner.py | 252 ++++++++++- tests/test_released_api_contract.py | 416 +++++++++++++++++- 9 files changed, 1442 insertions(+), 46 deletions(-) create mode 100644 tests/fixtures/released_api_contract_policy.json diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 91df91d031..86d0b382d1 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -10,10 +10,19 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from integration_tests._contract_support import ( # noqa: E402 + SubmoduleExportPolicy, + load_submodule_export_policy, +) + WORKSPACE = ROOT / ".tmp" / "integration-tests" DIST = WORKSPACE / "dist" RESULTS = WORKSPACE / "results" TESTS = ROOT / "integration_tests" +CONTRACT_POLICY = ROOT / "tests" / "fixtures" / "released_api_contract_policy.json" +PROSPECTIVE_CONTRACT_ENV = "OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT" EXTRAS = "any-llm,litellm,realtime,voice" OPTIONAL_EXTRAS = ( "any-llm", @@ -29,6 +38,8 @@ STRICT_PROFILES = frozenset({"release", "security"}) PROFILES = ( "packaging", + "prospective-contract", + "prospective-platform", "security", "mcp-v1", "core", @@ -352,6 +363,15 @@ def main() -> None: help="Include configured direct Anthropic and Gemini providers alongside OpenRouter.", ) args = parser.parse_args() + prospective_policy: SubmoduleExportPolicy | None = None + if args.profile in {"prospective-contract", "prospective-platform"}: + prospective_contract = os.environ.get(PROSPECTIVE_CONTRACT_ENV) + if not prospective_contract or not Path(prospective_contract).is_file(): + raise RuntimeError( + "The prospective-contract profile requires " + f"{PROSPECTIVE_CONTRACT_ENV} to name an existing contract file." + ) + prospective_policy = load_submodule_export_policy(CONTRACT_POLICY) if args.profile in STRICT_PROFILES: os.environ["OPENAI_AGENTS_INTEGRATION_STRICT"] = "1" shutil.rmtree(RESULTS / args.profile, ignore_errors=True) @@ -381,6 +401,7 @@ def main() -> None: if args.profile in { "packaging", + "prospective-contract", "security", "core", "hosted", @@ -396,6 +417,7 @@ def main() -> None: ) selections = { "packaging": "packaging", + "prospective-contract": "packaging", "security": "security", "core": "packaging or core", "hosted": "packaging or hosted", @@ -434,7 +456,15 @@ def main() -> None: profile=args.profile, ) - if args.profile in {"packaging", "security", "full", "release", "nightly", "manual"}: + if args.profile in { + "packaging", + "prospective-contract", + "security", + "full", + "release", + "nightly", + "manual", + }: python = create_environment( "sdist", sdist, @@ -457,6 +487,129 @@ def main() -> None: profile=args.profile, ) + if args.profile == "prospective-contract": + assert prospective_policy is not None + for artifact_kind, distribution in (("wheel", wheel), ("sdist", sdist)): + for installation in prospective_policy.dependency_installations: + if not installation.is_supported_on_current_platform(): + print( + "[integration] skipping optional dependency " + f"{installation.dependency_module} on unsupported platform " + f"{sys.platform}", + flush=True, + ) + continue + dependency_slug = re.sub(r"[^a-z0-9]+", "-", installation.dependency_module.lower()) + environment_kind = f"{artifact_kind}-prospective-{dependency_slug}" + additional_requirements = ( + (installation.requirement,) if installation.requirement is not None else () + ) + python = create_environment( + environment_kind, + distribution, + optional_extra=installation.extra, + additional_requirements=additional_requirements, + ) + installation_description = ( + f"extra {installation.extra}" + if installation.extra is not None + else f"requirement {installation.requirement}" + ) + additional_env = { + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES": ( + installation.dependency_module + ), + "OPENAI_AGENTS_INTEGRATION_OPTIONAL_DEPENDENCY_INSTALLATION": ( + installation_description + ), + } + if installation.extra is not None: + additional_env["OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_EXTRA"] = ( + installation.extra + ) + run_suite( + python, + wheel, + sdist, + selection="packaging_dependency", + environment_kind=environment_kind, + additional_env=additional_env, + profile=args.profile, + require_no_skips=True, + ) + + if args.profile == "prospective-platform": + assert prospective_policy is not None + core_environment_kind = "wheel-prospective-platform-core" + core_python = create_environment(core_environment_kind, wheel) + run_suite( + core_python, + wheel, + sdist, + selection="packaging_dependency", + environment_kind=core_environment_kind, + profile=args.profile, + require_no_skips=True, + ) + + unsupported_installations = tuple( + installation + for installation in prospective_policy.dependency_installations + if not installation.is_supported_on_current_platform() + ) + for installation in unsupported_installations: + print( + "[integration] skipping optional dependency " + f"{installation.dependency_module} on unsupported platform {sys.platform}", + flush=True, + ) + supported_installations = tuple( + installation + for installation in prospective_policy.dependency_installations + if installation.is_supported_on_current_platform() + ) + dependency_extras = sorted( + { + installation.extra + for installation in supported_installations + if installation.extra is not None + } + ) + dependency_requirements = tuple( + sorted( + { + installation.requirement + for installation in supported_installations + if installation.requirement is not None + } + ) + ) + dependency_modules = ",".join( + installation.dependency_module for installation in supported_installations + ) + environment_kind = "wheel-prospective-platform" + python = create_environment( + environment_kind, + wheel, + optional_extra=",".join(dependency_extras) or None, + additional_requirements=dependency_requirements, + ) + run_suite( + python, + wheel, + sdist, + selection="packaging_dependency", + environment_kind=environment_kind, + additional_env={ + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES": dependency_modules, + "OPENAI_AGENTS_INTEGRATION_OPTIONAL_DEPENDENCY_INSTALLATION": ( + "policy optional dependencies" + ), + }, + profile=args.profile, + require_no_skips=True, + ) + if args.profile in {"packaging", "release"}: for artifact_kind, distribution in (("wheel", wheel), ("sdist", sdist)): environment_kind = f"{artifact_kind}-cloudflare" @@ -471,7 +624,13 @@ def main() -> None: sdist, selection="packaging_dependency", environment_kind=environment_kind, - additional_env={"OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS": "1"}, + additional_env={ + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES": "aiohttp", + "OPENAI_AGENTS_INTEGRATION_OPTIONAL_DEPENDENCY_INSTALLATION": ( + "extra cloudflare" + ), + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_EXTRA": "cloudflare", + }, profile=args.profile, require_no_skips=True, ) diff --git a/.github/scripts/update_released_api_contract.py b/.github/scripts/update_released_api_contract.py index 35e6bea821..b59ecb90fc 100644 --- a/.github/scripts/update_released_api_contract.py +++ b/.github/scripts/update_released_api_contract.py @@ -14,12 +14,14 @@ ROOT = Path(__file__).resolve().parents[2] CONTRACT = ROOT / "tests" / "fixtures" / "released_api_contract.json" +POLICY = ROOT / "tests" / "fixtures" / "released_api_contract_policy.json" sys.path.insert(0, str(ROOT)) from integration_tests._contract_support import ( # noqa: E402 build_released_api_contract, load_api_contract, + load_submodule_export_policy, ) @@ -33,6 +35,11 @@ def _parse_args() -> argparse.Namespace: action="store_true", help="Fail instead of writing when the committed contract is out of date.", ) + parser.add_argument( + "--output", + type=Path, + help="Write a prospective contract to this path instead of changing the released fixture.", + ) return parser.parse_args() @@ -58,6 +65,8 @@ def _render(contract: dict[str, object]) -> str: def main() -> int: args = _parse_args() + if args.check and args.output is not None: + raise SystemExit("--check and --output cannot be used together") version = args.version if ( version.startswith("v") @@ -70,17 +79,33 @@ def main() -> int: raise SystemExit( f"--version {version!r} does not match pyproject.toml version {project_version!r}" ) + output = args.output.resolve() if args.output is not None else None + protected_outputs = {CONTRACT.resolve(), POLICY.resolve()} + if output in protected_outputs: + raise SystemExit( + "--output must not overwrite released API contract inputs: " + "tests/fixtures/released_api_contract.json or " + "tests/fixtures/released_api_contract_policy.json" + ) current = load_api_contract(CONTRACT) + policy = load_submodule_export_policy(POLICY) try: updated = build_released_api_contract( current, baseline=f"v{version}", baseline_commit=_head_commit(), + submodule_export_policy=policy.modules, ) except ValueError as error: raise SystemExit(str(error)) from None rendered = _render(updated) + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + print(f"Wrote prospective released API contract to {output}.") + return 0 + existing = CONTRACT.read_text(encoding="utf-8") if rendered == existing: print(f"Released API contract is current for v{version}.") diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f250d3d413..1bcb93019b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -148,6 +148,7 @@ jobs: run: echo "Skipping MCP v1 compatibility tests for non-code changes." packaged-contract: + needs: prospective-release-contract runs-on: ubuntu-latest strategy: fail-fast: false @@ -172,13 +173,97 @@ jobs: enable-cache: true prune-cache: true python-version: ${{ matrix.python-version }} - - name: Run packaged compatibility contracts + - name: Download prospective release contract if: steps.changes.outputs.run == 'true' - run: make integration-tests-packaging + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: prospective-release-contract + path: .tmp + - name: Run packaged and prospective compatibility contracts + if: steps.changes.outputs.run == 'true' + env: + OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT: ${{ github.workspace }}/.tmp/prospective_released_api_contract.json + run: make integration-tests-prospective-contract - name: Skip packaged compatibility contracts if: steps.changes.outputs.run != 'true' run: echo "Skipping packaged compatibility contracts for non-code changes." + packaged-contract-windows: + needs: prospective-release-contract + runs-on: windows-latest + timeout-minutes: 15 + env: + OPENAI_AGENTS_INTEGRATION_PYTHON: "3.13" + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Detect code changes + id: changes + shell: bash + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: true + prune-cache: true + python-version: "3.13" + - name: Download prospective release contract + if: steps.changes.outputs.run == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: prospective-release-contract + path: .tmp + - name: Run Windows prospective contract smoke test + if: steps.changes.outputs.run == 'true' + env: + OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT: ${{ github.workspace }}/.tmp/prospective_released_api_contract.json + run: uv run python .github/scripts/run_integration_tests.py --profile prospective-platform + - name: Skip Windows prospective contract smoke test + if: steps.changes.outputs.run != 'true' + run: echo "Skipping Windows prospective contract smoke test for non-code changes." + + prospective-release-contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + OPENAI_AGENTS_INTEGRATION_PYTHON: "3.12" + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Detect code changes + id: changes + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: true + prune-cache: true + python-version: "3.12" + - name: Install all optional dependencies + if: steps.changes.outputs.run == 'true' + run: make sync + - name: Generate prospective release contract + if: steps.changes.outputs.run == 'true' + run: make prepare-prospective-released-api-contract + - name: Upload prospective release contract + if: steps.changes.outputs.run == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: prospective-release-contract + path: .tmp/prospective_released_api_contract.json + if-no-files-found: error + include-hidden-files: true + retention-days: 1 + - name: Skip prospective release contract + if: steps.changes.outputs.run != 'true' + run: echo "Skipping prospective release contract for non-code changes." + tests-windows: runs-on: windows-latest timeout-minutes: 10 diff --git a/Makefile b/Makefile index e14bc5a973..8a8704d2bd 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,20 @@ check-released-api-contract: @test -n "$(VERSION)" || (echo "VERSION is required, for example VERSION=0.20.0" >&2; exit 2) uv run python .github/scripts/update_released_api_contract.py --version "$(VERSION)" --check +PROSPECTIVE_RELEASED_API_CONTRACT ?= .tmp/prospective_released_api_contract.json + +.PHONY: prepare-prospective-released-api-contract +prepare-prospective-released-api-contract: + @version="$$(uv run python -c 'from importlib.metadata import version; print(version("openai-agents"))')"; \ + uv run python .github/scripts/update_released_api_contract.py \ + --version "$$version" \ + --output "$(PROSPECTIVE_RELEASED_API_CONTRACT)" + +.PHONY: check-prospective-released-api-contract +check-prospective-released-api-contract: prepare-prospective-released-api-contract + OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT="$(abspath $(PROSPECTIVE_RELEASED_API_CONTRACT))" \ + $(MAKE) integration-tests-prospective-contract + .PHONY: format format: uv run ruff format @@ -103,6 +117,10 @@ integration-tests-manual: integration-tests-packaging: uv run python .github/scripts/run_integration_tests.py --profile packaging +.PHONY: integration-tests-prospective-contract +integration-tests-prospective-contract: + uv run python .github/scripts/run_integration_tests.py --profile prospective-contract + .PHONY: integration-tests-security integration-tests-security: uv run python .github/scripts/run_integration_tests.py --profile security diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 0efe59828a..87cd27fe9f 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -16,12 +16,140 @@ from typing import Any, cast +@dataclasses.dataclass(frozen=True) +class OptionalDependencyInstallation: + dependency_module: str + extra: str | None = None + requirement: str | None = None + unsupported_platforms: tuple[str, ...] = () + + def is_supported_on_current_platform(self) -> bool: + return sys.platform not in self.unsupported_platforms + + +@dataclasses.dataclass(frozen=True) +class SubmoduleExportPolicy: + modules: dict[str, dict[str, dict[str, str]]] + dependency_installations: tuple[OptionalDependencyInstallation, ...] + + def load_api_contract(path: Path) -> dict[str, Any]: contract = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) _add_legacy_literal_types(contract) return contract +def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("submodule export policy must be an object") + unknown_top_level_fields = sorted(set(value) - {"modules", "optional_dependencies"}) + if unknown_top_level_fields: + raise ValueError( + f"submodule export policy has unknown fields: {unknown_top_level_fields!r}" + ) + modules = value.get("modules") + if not isinstance(modules, dict): + raise ValueError("submodule export policy modules must be an object keyed by module name") + policy: dict[str, dict[str, dict[str, str]]] = {} + for module_name, declarations in modules.items(): + if type(module_name) is not str or not module_name: + raise ValueError("submodule export policy module names must be non-empty strings") + if not isinstance(declarations, dict): + raise ValueError(f"submodule export policy for {module_name} must be an object") + unknown_fields = sorted(set(declarations) - {"optional_bindings", "optional_exports"}) + if unknown_fields: + raise ValueError( + f"submodule export policy for {module_name} has unknown fields: {unknown_fields!r}" + ) + policy[module_name] = { + "optional_bindings": _optional_dependency_modules( + declarations.get("optional_bindings", {}), field_name="optional_bindings" + ), + "optional_exports": _optional_dependency_modules( + declarations.get("optional_exports", {}), field_name="optional_exports" + ), + } + + dependencies = value.get("optional_dependencies") + if not isinstance(dependencies, dict): + raise ValueError("submodule export policy optional_dependencies must be an object") + dependency_installations: list[OptionalDependencyInstallation] = [] + for module_name, installation in dependencies.items(): + if type(module_name) is not str or not module_name: + raise ValueError("optional dependency module names must be non-empty strings") + if not isinstance(installation, dict): + raise ValueError( + f"optional dependency installation for {module_name} must be an object" + ) + unknown_fields = sorted( + set(installation) - {"extra", "requirement", "unsupported_platforms"} + ) + if unknown_fields: + raise ValueError( + f"optional dependency installation for {module_name} has unknown fields: " + f"{unknown_fields!r}" + ) + configured = [field for field in ("extra", "requirement") if field in installation] + if len(configured) != 1: + raise ValueError( + f"optional dependency installation for {module_name} must declare exactly one " + "of extra or requirement" + ) + field_name = configured[0] + install_value = installation[field_name] + if type(install_value) is not str or not install_value: + raise ValueError( + f"optional dependency installation {field_name} for {module_name} must be a " + "non-empty string" + ) + unsupported_platforms = installation.get("unsupported_platforms", []) + if ( + not isinstance(unsupported_platforms, list) + or not all(type(platform) is str and platform for platform in unsupported_platforms) + or len(unsupported_platforms) != len(set(unsupported_platforms)) + ): + raise ValueError( + f"optional dependency installation unsupported_platforms for {module_name} " + "must be a list of unique non-empty strings" + ) + dependency_installations.append( + OptionalDependencyInstallation( + dependency_module=module_name, + extra=install_value if field_name == "extra" else None, + requirement=install_value if field_name == "requirement" else None, + unsupported_platforms=tuple(unsupported_platforms), + ) + ) + + referenced_dependencies = { + dependency + for module_policy in policy.values() + for declarations in module_policy.values() + for dependency in declarations.values() + } + missing_installations = sorted(referenced_dependencies - set(dependencies)) + unused_installations = sorted(set(dependencies) - referenced_dependencies) + if missing_installations: + raise ValueError( + "submodule export policy dependencies are missing installation declarations: " + f"{missing_installations!r}" + ) + if unused_installations: + raise ValueError( + "submodule export policy has unused dependency installation declarations: " + f"{unused_installations!r}" + ) + return SubmoduleExportPolicy( + modules=policy, + dependency_installations=tuple( + sorted( + dependency_installations, key=lambda installation: installation.dependency_module + ) + ), + ) + + def _add_legacy_literal_types(value: object) -> None: if isinstance(value, dict): if value.get("kind") == "literal" and "value" in value and "type" not in value: @@ -39,13 +167,15 @@ def _redaction_observables( records: Iterable[logging.LogRecord], ) -> str: values: list[str] = [] - seen: set[int] = set() + seen: dict[int, object] = {} def visit_exception_state(value: object) -> None: value_id = id(value) if value_id in seen: return - seen.add(value_id) + # Keep visited objects alive so a later temporary object cannot reuse an id and be + # mistaken for a cycle. Traceback frame locals are materialized as temporary dicts. + seen[value_id] = value if isinstance(value, BaseException): state = vars(value) @@ -381,6 +511,7 @@ def build_released_api_contract( baseline: str, baseline_commit: str, agents_module: Any | None = None, + submodule_export_policy: Mapping[str, Mapping[str, Mapping[str, str]]] | None = None, ) -> dict[str, Any]: """Build the next rolling release contract from the current public surface.""" agents = agents_module or importlib.import_module("agents") @@ -445,8 +576,40 @@ def build_released_api_contract( updated["required_top_level_exports"] = ordered_exports updated["callables"] = callables excluded_submodule_exports = set(contract.get("submodule_export_exclusions", [])) + public_modules = list(contract["public_modules"]) + if submodule_export_policy is not None: + invalid_policy_modules = sorted( + module_name + for module_name in submodule_export_policy + if not module_name.startswith("agents.") + ) + if invalid_policy_modules: + raise ValueError( + "new submodule export policy modules must be under the agents package: " + f"{invalid_policy_modules!r}" + ) + released_public_modules = set(public_modules) + public_modules.extend(sorted(set(submodule_export_policy) - released_public_modules)) + unavailable_policy_dependencies = sorted( + { + dependency_module + for module_policy in submodule_export_policy.values() + for field_name in ("optional_bindings", "optional_exports") + for dependency_module in _optional_dependency_modules( + dict(module_policy.get(field_name, {})), field_name=field_name + ).values() + if not _optional_dependency_is_available(dependency_module) + } + ) + if unavailable_policy_dependencies: + raise ValueError( + "submodule export policy dependency modules are unavailable: " + f"{unavailable_policy_dependencies!r}. Run `make sync` to install all " + "optional dependencies, or correct the dependency module names." + ) + updated["public_modules"] = public_modules required_submodule_exports: dict[str, dict[str, Any]] = {} - for module_name in contract["public_modules"]: + for module_name in public_modules: if module_name == "agents" or module_name in excluded_submodule_exports: continue try: @@ -454,14 +617,19 @@ def build_released_api_contract( except Exception as error: if _matches_platform_import_error(contract, module_name, error): continue + if submodule_export_policy is not None and module_name in submodule_export_policy: + raise ValueError( + f"Cannot import submodule export policy module {module_name}: {error!r}" + ) from None raise - previous_module_contract = contract.get("required_submodule_exports", {}).get( - module_name, {} - ) + if submodule_export_policy is None: + module_policy = contract.get("required_submodule_exports", {}).get(module_name, {}) + else: + module_policy = submodule_export_policy.get(module_name, {}) module_contract = _submodule_export_contract( module, - optional_bindings=previous_module_contract.get("optional_bindings", {}), - optional_exports=previous_module_contract.get("optional_exports", {}), + optional_bindings=module_policy.get("optional_bindings", {}), + optional_exports=module_policy.get("optional_exports", {}), ) if module_contract is not None: required_submodule_exports[module_name] = module_contract @@ -644,7 +812,6 @@ def validate_released_api_contract( contract: dict[str, Any], *, agents_module: Any | None = None, - require_all_optional_exports: bool = False, ) -> list[str]: agents = agents_module or importlib.import_module("agents") errors: list[str] = [] @@ -718,17 +885,43 @@ def validate_released_api_contract( f"Unable to inspect released {module_name} optional dependencies: {error!r}" ) continue - if require_all_optional_exports and unavailable_optional_exports: - unavailable = sorted( - f"{name} -> {optional_exports[name]}" for name in unavailable_optional_exports - ) - errors.append( - f"Required optional dependencies for released {module_name} " - f"are unavailable: {unavailable!r}" - ) - continue + current_names = set(current["names"]) + for name in sorted(unavailable_optional_exports & current_names): + try: + getattr(module, name) + except (AttributeError, ImportError): + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ but its binding is unavailable; " + "declare it in optional_bindings instead of optional_exports" + ) + else: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ and its binding resolves; remove its " + "optional declaration or correct its dependency module" + ) + binding_only_names = set(optional_bindings) - set(optional_exports) + for name in sorted(unavailable_optional_bindings & binding_only_names): + if name not in current_names: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} is absent from __all__; declare it in optional_exports " + "instead of optional_bindings" + ) + continue + try: + getattr(module, name) + except (AttributeError, ImportError): + pass + else: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ and its binding resolves; remove its " + "optional declaration or correct its dependency module" + ) missing_names = sorted( - set(released["names"]) - unavailable_optional_exports - set(current["names"]) + set(released["names"]) - unavailable_optional_exports - current_names ) if missing_names: errors.append(f"Missing released {module_name} exports: {missing_names!r}") diff --git a/integration_tests/packaging/test_released_api_contract.py b/integration_tests/packaging/test_released_api_contract.py index 283ce016da..ec23a1a4e5 100644 --- a/integration_tests/packaging/test_released_api_contract.py +++ b/integration_tests/packaging/test_released_api_contract.py @@ -1,8 +1,11 @@ import os -from importlib.metadata import version +from importlib.metadata import metadata, requires, version +from importlib.util import find_spec from pathlib import Path import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name from integration_tests._contract_support import ( load_api_contract, @@ -12,6 +15,164 @@ pytestmark = pytest.mark.packaging CONTRACT = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "released_api_contract.json" +PROSPECTIVE_CONTRACT_ENV = "OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT" +REQUIRED_OPTIONAL_DEPENDENCIES_ENV = "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES" +OPTIONAL_DEPENDENCY_INSTALLATION_ENV = "OPENAI_AGENTS_INTEGRATION_OPTIONAL_DEPENDENCY_INSTALLATION" +REQUIRED_OPTIONAL_EXTRA_ENV = "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_EXTRA" + + +def _distributions_declared_by_extra(requirement_strings: list[str], extra: str) -> set[str]: + no_extra = "__openai_agents_no_extra__" + declared: set[str] = set() + for requirement_string in requirement_strings: + requirement = Requirement(requirement_string) + marker = requirement.marker + if ( + marker is not None + and marker.evaluate({"extra": extra}) + and not marker.evaluate({"extra": no_extra}) + ): + declared.add(canonicalize_name(requirement.name)) + return declared + + +def _extra_metadata_error( + *, + extra: str, + dependency_module: str, + provided_extras: list[str], + requirement_strings: list[str], +) -> str | None: + canonical_extra = canonicalize_name(extra) + if canonical_extra not in { + canonicalize_name(provided_extra) for provided_extra in provided_extras + }: + return ( + f"The installed openai-agents artifact does not provide policy extra {extra!r}. " + "Correct its entry in tests/fixtures/released_api_contract_policy.json or add " + "the extra under [project.optional-dependencies]." + ) + + distribution_name = canonicalize_name(dependency_module) + declared_distributions = _distributions_declared_by_extra(requirement_strings, extra) + if distribution_name not in declared_distributions: + return ( + f"The installed openai-agents artifact extra {extra!r} does not declare " + f"distribution {distribution_name!r} for policy dependency module " + f"{dependency_module!r}. Add it to [project.optional-dependencies].{extra}; " + "transitive or base-environment availability does not satisfy this check." + ) + return None + + +def _prospective_contract_failure_message(errors: list[str]) -> str: + details = "\n".join(f"- {error}" for error in errors) + return ( + "Prospective release API contract check failed before release preparation.\n" + "The installed distribution does not match the generated public API contract:\n" + f"{details}\n\n" + "If a missing name is absent from the clean module's `__all__` because it depends " + "on an optional extra, add it to `tests/fixtures/released_api_contract_policy.json` " + "under the module's `optional_exports` mapping. If the name remains in `__all__` " + "but resolving its binding requires the optional dependency, add it under " + "`optional_bindings` instead. If the export is required, make its defining module " + "importable without the optional package. If the optional dependency package itself " + "does not support this runner platform, verify that upstream limitation and add the " + "platform to its `unsupported_platforms` list under `optional_dependencies`; do not " + "exclude SDK-owned platform failures. Then run `make sync` and " + "`make check-prospective-released-api-contract` again." + ) + + +@pytest.mark.packaging_dependency +def test_artifact_installation_provides_its_declared_optional_dependencies() -> None: + configured_dependencies = os.environ.get(REQUIRED_OPTIONAL_DEPENDENCIES_ENV) + if not configured_dependencies: + return + + installation = os.environ.get(OPTIONAL_DEPENDENCY_INSTALLATION_ENV, "configured installation") + dependency_modules = [ + module_name.strip() + for module_name in configured_dependencies.split(",") + if module_name.strip() + ] + missing_dependencies = [ + module_name for module_name in dependency_modules if find_spec(module_name) is None + ] + if missing_dependencies: + pytest.fail( + f"The artifact environment for {installation} does not provide declared " + f"optional dependency modules {missing_dependencies!r}. Update the corresponding " + "openai-agents extra or policy requirement." + ) + + +@pytest.mark.packaging_dependency +def test_artifact_extra_declares_its_policy_dependency() -> None: + extra = os.environ.get(REQUIRED_OPTIONAL_EXTRA_ENV) + if not extra: + return + + configured_dependencies = os.environ.get(REQUIRED_OPTIONAL_DEPENDENCIES_ENV, "") + dependency_modules = [ + module_name.strip() + for module_name in configured_dependencies.split(",") + if module_name.strip() + ] + if len(dependency_modules) != 1: + pytest.fail( + f"Extra provenance validation requires exactly one dependency module, got " + f"{dependency_modules!r}. Fix the prospective-contract runner configuration." + ) + dependency_module = dependency_modules[0] + error = _extra_metadata_error( + extra=extra, + dependency_module=dependency_module, + provided_extras=metadata("openai-agents").get_all("Provides-Extra") or [], + requirement_strings=requires("openai-agents") or [], + ) + if error is not None: + pytest.fail(error) + + +def test_extra_metadata_provenance_ignores_base_and_transitive_requirements() -> None: + assert _distributions_declared_by_extra( + [ + "cryptography>=45", + "pyjwt[crypto]>=2; python_version >= '3.10'", + "redis>=7; extra == 'redis'", + "cryptography>=45; extra == 'encrypt'", + ], + "encrypt", + ) == {"cryptography"} + + assert _extra_metadata_error( + extra="encrypt", + dependency_module="cryptography", + provided_extras=["encrypt"], + requirement_strings=[ + "cryptography>=45", + "pyjwt[crypto]>=2; python_version >= '3.10'", + ], + ) == ( + "The installed openai-agents artifact extra 'encrypt' does not declare distribution " + "'cryptography' for policy dependency module 'cryptography'. Add it to " + "[project.optional-dependencies].encrypt; transitive or base-environment availability " + "does not satisfy this check." + ) + + +def test_extra_metadata_provenance_rejects_unknown_extra() -> None: + assert _extra_metadata_error( + extra="missing", + dependency_module="cryptography", + provided_extras=["encrypt"], + requirement_strings=["cryptography>=45; extra == 'encrypt'"], + ) == ( + "The installed openai-agents artifact does not provide policy extra 'missing'. Correct " + "its entry in tests/fixtures/released_api_contract_policy.json or add the extra under " + "[project.optional-dependencies]." + ) @pytest.mark.packaging_dependency @@ -20,11 +181,42 @@ def test_installed_distribution_preserves_released_public_api_contract() -> None assert contract["baseline"] == f"v{version('openai-agents')}" assert len(contract["baseline_commit"]) == 40 - errors = validate_released_api_contract( - contract, - require_all_optional_exports=( - os.environ.get("OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS") == "1" - ), - ) + errors = validate_released_api_contract(contract) assert errors == [] + + +@pytest.mark.packaging_dependency +def test_installed_distribution_is_ready_for_prospective_release_contract() -> None: + configured_path = os.environ.get(PROSPECTIVE_CONTRACT_ENV) + if not configured_path: + return + + path = Path(configured_path) + if not path.is_file(): + pytest.fail( + f"Prospective release API contract does not exist: {path}. " + "Run `make check-prospective-released-api-contract` from the repository root." + ) + + contract = load_api_contract(path) + errors = validate_released_api_contract(contract) + if errors: + pytest.fail(_prospective_contract_failure_message(errors)) + + +def test_prospective_contract_failure_guidance_distinguishes_optional_shapes() -> None: + message = _prospective_contract_failure_message( + [ + "Missing released agents.example exports: ['ConditionalExport']", + "Missing released agents.example bindings: ['LazyBinding']", + ] + ) + + assert "absent from the clean module's `__all__`" in message + assert "`optional_exports`" in message + assert "remains in `__all__`" in message + assert "`optional_bindings` instead" in message + assert "`unsupported_platforms`" in message + assert "do not exclude SDK-owned platform failures" in message + assert "make check-prospective-released-api-contract" in message diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json new file mode 100644 index 0000000000..f1f38705a3 --- /dev/null +++ b/tests/fixtures/released_api_contract_policy.json @@ -0,0 +1,88 @@ +{ + "optional_dependencies": { + "aiohttp": { + "extra": "cloudflare" + }, + "aiosqlite": { + "requirement": "aiosqlite>=0.21.0" + }, + "cryptography": { + "extra": "encrypt" + }, + "dapr": { + "extra": "dapr" + }, + "modal": { + "extra": "modal" + }, + "pymongo": { + "extra": "mongodb" + }, + "redis": { + "extra": "redis" + }, + "runloop_api_client": { + "extra": "runloop" + }, + "sqlalchemy": { + "extra": "sqlalchemy" + }, + "vercel": { + "extra": "vercel", + "unsupported_platforms": [ + "win32" + ] + } + }, + "modules": { + "agents.extensions.memory": { + "optional_bindings": { + "AsyncSQLiteSession": "aiosqlite", + "DAPR_CONSISTENCY_EVENTUAL": "dapr", + "DAPR_CONSISTENCY_STRONG": "dapr", + "DaprSession": "dapr", + "EncryptedSession": "cryptography", + "MongoDBSession": "pymongo", + "RedisSession": "redis", + "SQLAlchemySession": "sqlalchemy" + }, + "optional_exports": {} + }, + "agents.extensions.sandbox": { + "optional_bindings": {}, + "optional_exports": { + "CloudflareBucketMountConfig": "aiohttp", + "CloudflareBucketMountStrategy": "aiohttp", + "CloudflareSandboxClient": "aiohttp", + "CloudflareSandboxClientOptions": "aiohttp", + "CloudflareSandboxSession": "aiohttp", + "CloudflareSandboxSessionState": "aiohttp", + "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT": "runloop_api_client", + "DEFAULT_RUNLOOP_WORKSPACE_ROOT": "runloop_api_client", + "ModalCloudBucketMountStrategy": "modal", + "ModalSandboxClient": "modal", + "ModalSandboxClientOptions": "modal", + "ModalSandboxSession": "modal", + "ModalSandboxSessionState": "modal", + "RunloopAfterIdle": "runloop_api_client", + "RunloopCloudBucketMountStrategy": "runloop_api_client", + "RunloopGatewaySpec": "runloop_api_client", + "RunloopLaunchParameters": "runloop_api_client", + "RunloopMcpSpec": "runloop_api_client", + "RunloopPlatformClient": "runloop_api_client", + "RunloopSandboxClient": "runloop_api_client", + "RunloopSandboxClientOptions": "runloop_api_client", + "RunloopSandboxSession": "runloop_api_client", + "RunloopSandboxSessionState": "runloop_api_client", + "RunloopTimeouts": "runloop_api_client", + "RunloopTunnelConfig": "runloop_api_client", + "RunloopUserParameters": "runloop_api_client", + "VercelCloudBucketMountStrategy": "vercel", + "VercelSandboxClient": "vercel", + "VercelSandboxClientOptions": "vercel", + "VercelSandboxSession": "vercel", + "VercelSandboxSessionState": "vercel" + } + } + } +} diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 472dc9119c..20595e4a38 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -400,7 +400,12 @@ def fake_run_suite(*args: object, **kwargs: Any) -> None: ] assert all(suite["selection"] == "packaging_dependency" for suite in dependency_suites) assert all( - suite["additional_env"] == {"OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS": "1"} + suite["additional_env"] + == { + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES": "aiohttp", + "OPENAI_AGENTS_INTEGRATION_OPTIONAL_DEPENDENCY_INSTALLATION": "extra cloudflare", + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_EXTRA": "cloudflare", + } for suite in dependency_suites ) assert all(suite["require_no_skips"] is True for suite in dependency_suites) @@ -466,8 +471,251 @@ def fake_run_suite(*args: object, **kwargs: Any) -> None: assert len(dependency_suites) == 2 assert all(suite["selection"] == "packaging_dependency" for suite in dependency_suites) assert all( - suite["additional_env"] == {"OPENAI_AGENTS_INTEGRATION_REQUIRE_OPTIONAL_EXPORTS": "1"} + suite["additional_env"] + == { + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES": "aiohttp", + "OPENAI_AGENTS_INTEGRATION_OPTIONAL_DEPENDENCY_INSTALLATION": "extra cloudflare", + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_EXTRA": "cloudflare", + } for suite in dependency_suites ) assert all(suite["require_no_skips"] is True for suite in dependency_suites) assert namespace["STRICT_PROFILES"] == frozenset({"release", "security"}) + + +@pytest.mark.parametrize("platform", ["linux", "win32"]) +def test_prospective_contract_profile_isolates_each_policy_installation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + platform: str, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + main = cast(Callable[[], None], namespace["main"]) + wheel = tmp_path / "candidate.whl" + sdist = tmp_path / "candidate.tar.gz" + prospective_contract = tmp_path / "prospective-contract.json" + prospective_contract.write_text("{}", encoding="utf-8") + created: list[tuple[str, Path, str | None, tuple[str, ...]]] = [] + suites: list[dict[str, Any]] = [] + + def fake_build_distributions() -> tuple[Path, Path]: + return wheel, sdist + + def fake_create_environment( + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), + ) -> Path: + _ = extras + created.append((name, distribution, optional_extra, additional_requirements)) + return tmp_path / name / "python" + + def fake_run_suite(*args: object, **kwargs: Any) -> None: + _ = args + suites.append(kwargs) + + monkeypatch.setenv("OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT", str(prospective_contract)) + monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "prospective-contract"]) + monkeypatch.setattr(main.__globals__["sys"], "platform", platform) + monkeypatch.setitem(main.__globals__, "build_distributions", fake_build_distributions) + monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) + monkeypatch.setitem(main.__globals__, "run_suite", fake_run_suite) + monkeypatch.setattr(main.__globals__["shutil"], "rmtree", lambda *args, **kwargs: None) + + main() + + policy = namespace["load_submodule_export_policy"](namespace["CONTRACT_POLICY"]) + supported_installations = tuple( + installation + for installation in policy.dependency_installations + if installation.is_supported_on_current_platform() + ) + isolated = [entry for entry in created if "-prospective-" in entry[0]] + assert len(isolated) == 2 * len(supported_installations) + assert all(bool(extra) != bool(requirements) for _, _, extra, requirements in isolated) + assert ("wheel-prospective-aiohttp", wheel, "cloudflare", ()) in isolated + assert ( + "wheel-prospective-aiosqlite", + wheel, + None, + ("aiosqlite>=0.21.0",), + ) in isolated + assert ("sdist-prospective-modal", sdist, "modal", ()) in isolated + + isolated_suites = [suite for suite in suites if "-prospective-" in suite["environment_kind"]] + assert len(isolated_suites) == len(isolated) + assert all(suite["selection"] == "packaging_dependency" for suite in isolated_suites) + assert all(suite["require_no_skips"] is True for suite in isolated_suites) + assert { + suite["additional_env"]["OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES"] + for suite in isolated_suites + } == {installation.dependency_module for installation in supported_installations} + assert { + ( + suite["additional_env"]["OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES"], + suite["additional_env"].get("OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_EXTRA"), + ) + for suite in isolated_suites + } == { + (installation.dependency_module, installation.extra) + for installation in supported_installations + } + + +@pytest.mark.parametrize("platform", ["linux", "win32"]) +def test_prospective_platform_profile_checks_core_before_combined_optional_dependencies( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + platform: str, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + main = cast(Callable[[], None], namespace["main"]) + wheel = tmp_path / "candidate.whl" + sdist = tmp_path / "candidate.tar.gz" + prospective_contract = tmp_path / "prospective-contract.json" + prospective_contract.write_text("{}", encoding="utf-8") + created: list[tuple[str, Path, str | None, tuple[str, ...]]] = [] + suites: list[dict[str, Any]] = [] + + def fake_build_distributions() -> tuple[Path, Path]: + return wheel, sdist + + def fake_create_environment( + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), + ) -> Path: + _ = extras + created.append((name, distribution, optional_extra, additional_requirements)) + return tmp_path / name / "python" + + def fake_run_suite(*args: object, **kwargs: Any) -> None: + _ = args + suites.append(kwargs) + + monkeypatch.setenv("OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT", str(prospective_contract)) + monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "prospective-platform"]) + monkeypatch.setattr(main.__globals__["sys"], "platform", platform) + monkeypatch.setitem(main.__globals__, "build_distributions", fake_build_distributions) + monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) + monkeypatch.setitem(main.__globals__, "run_suite", fake_run_suite) + monkeypatch.setattr(main.__globals__["shutil"], "rmtree", lambda *args, **kwargs: None) + + main() + + policy = namespace["load_submodule_export_policy"](namespace["CONTRACT_POLICY"]) + supported_installations = tuple( + installation + for installation in policy.dependency_installations + if installation.is_supported_on_current_platform() + ) + expected_extras = ",".join( + sorted( + { + installation.extra + for installation in supported_installations + if installation.extra is not None + } + ) + ) + expected_requirements = tuple( + sorted( + { + installation.requirement + for installation in supported_installations + if installation.requirement is not None + } + ) + ) + assert created == [ + ( + "wheel-prospective-platform-core", + wheel, + None, + (), + ), + ( + "wheel-prospective-platform", + wheel, + expected_extras, + expected_requirements, + ), + ] + assert len(suites) == 2 + assert suites[0]["environment_kind"] == "wheel-prospective-platform-core" + assert suites[0]["selection"] == "packaging_dependency" + assert suites[0]["require_no_skips"] is True + assert "additional_env" not in suites[0] + assert suites[1]["environment_kind"] == "wheel-prospective-platform" + assert suites[1]["selection"] == "packaging_dependency" + assert suites[1]["require_no_skips"] is True + assert suites[1]["additional_env"] == { + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES": ",".join( + installation.dependency_module for installation in supported_installations + ), + "OPENAI_AGENTS_INTEGRATION_OPTIONAL_DEPENDENCY_INSTALLATION": ( + "policy optional dependencies" + ), + } + + +def test_prospective_platform_profile_excludes_unsupported_optional_dependencies( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + namespace = runpy.run_path(str(RUNNER)) + main = cast(Callable[[], None], namespace["main"]) + wheel = tmp_path / "candidate.whl" + sdist = tmp_path / "candidate.tar.gz" + prospective_contract = tmp_path / "prospective-contract.json" + prospective_contract.write_text("{}", encoding="utf-8") + created: list[tuple[str, Path, str | None, tuple[str, ...]]] = [] + suites: list[dict[str, Any]] = [] + + def fake_create_environment( + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), + ) -> Path: + _ = extras + created.append((name, distribution, optional_extra, additional_requirements)) + return tmp_path / name / "python" + + monkeypatch.setenv("OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT", str(prospective_contract)) + monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "prospective-platform"]) + monkeypatch.setattr(main.__globals__["sys"], "platform", "win32") + monkeypatch.setitem(main.__globals__, "build_distributions", lambda: (wheel, sdist)) + monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) + monkeypatch.setitem( + main.__globals__, "run_suite", lambda *args, **kwargs: suites.append(kwargs) + ) + monkeypatch.setattr(main.__globals__["shutil"], "rmtree", lambda *args, **kwargs: None) + + main() + + combined_environment = next( + environment for environment in created if environment[0] == "wheel-prospective-platform" + ) + assert "vercel" not in (combined_environment[2] or "").split(",") + combined_suite = next( + suite for suite in suites if suite["environment_kind"] == "wheel-prospective-platform" + ) + required_dependencies = combined_suite["additional_env"][ + "OPENAI_AGENTS_INTEGRATION_REQUIRED_OPTIONAL_DEPENDENCIES" + ].split(",") + assert "vercel" not in required_dependencies + assert "aiohttp" in required_dependencies + assert ( + "[integration] skipping optional dependency vercel on unsupported platform win32" + in capsys.readouterr().out + ) diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index aab01e7f36..d45419d853 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -1,6 +1,8 @@ +import json +import subprocess import sys from collections.abc import AsyncIterator, Callable, Iterator -from dataclasses import dataclass +from dataclasses import asdict, dataclass from enum import Enum from importlib.metadata import version from pathlib import Path @@ -20,6 +22,7 @@ _validate_public_property_contract, build_released_api_contract, load_api_contract, + load_submodule_export_policy, validate_released_api_contract, ) @@ -1062,6 +1065,360 @@ def test_release_contract_update_promotes_selected_submodule_exports( } +def test_release_contract_policy_preserves_new_optional_export_in_core_install( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = object() + optional = object() + agents_module = SimpleNamespace(__all__=[]) + full_submodule = SimpleNamespace( + __all__=["Existing", "OptionalBackend"], + Existing=existing, + OptionalBackend=optional, + ) + core_submodule = SimpleNamespace(__all__=["Existing"], Existing=existing) + imported_submodule = full_submodule + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["Existing"], + "optional_bindings": {}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + + def import_module(module_name: str, _agents_module: object) -> object: + return agents_module if module_name == "agents" else imported_submodule + + monkeypatch.setattr(contract_support, "_import_contract_module", import_module) + dependency_available = True + monkeypatch.setattr( + contract_support, + "_optional_dependency_is_available", + lambda _module_name: dependency_available, + ) + policy = { + "agents.submodule": { + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "missing_optional_backend_dependency"}, + } + } + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + submodule_export_policy=policy, + ) + dependency_available = False + imported_submodule = core_submodule + + assert updated["required_submodule_exports"]["agents.submodule"] == { + "names": ["Existing", "OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "missing_optional_backend_dependency"}, + } + assert validate_released_api_contract(updated, agents_module=agents_module) == [] + + +def test_release_contract_policy_rejects_unavailable_dependency_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_optional_dependency_is_available", + lambda _module_name: False, + ) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + with pytest.raises( + ValueError, + match="submodule export policy dependency modules are unavailable: " + r"\['mistyped_dependency'\]", + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + submodule_export_policy={ + "agents.submodule": { + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "mistyped_dependency"}, + } + }, + ) + + +def test_release_contract_policy_adds_new_public_optional_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + optional = object() + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["OptionalBackend"], OptionalBackend=optional) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_optional_dependency_is_available", + lambda _module_name: True, + ) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + submodule_export_policy={ + "agents.new_submodule": { + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "optional_backend"}, + } + }, + ) + + assert updated["public_modules"] == ["agents", "agents.new_submodule"] + assert updated["required_submodule_exports"]["agents.new_submodule"] == { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "optional_backend"}, + } + + +def test_release_contract_policy_rejects_unimportable_new_public_module() -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {}, + } + + with pytest.raises( + ValueError, + match=r"Cannot import submodule export policy module agents\.typo: ModuleNotFoundError", + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + submodule_export_policy={ + "agents.typo": {"optional_bindings": {}, "optional_exports": {}} + }, + ) + + +def test_release_contract_policy_rejects_new_module_outside_agents_package() -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {}, + } + + with pytest.raises( + ValueError, + match="new submodule export policy modules must be under the agents package: " + r"\['external_package'\]", + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + submodule_export_policy={ + "external_package": {"optional_bindings": {}, "optional_exports": {}} + }, + ) + + +def test_load_submodule_export_policy_rejects_unknown_fields(tmp_path: Path) -> None: + policy_path = tmp_path / "policy.json" + policy_path.write_text( + '{"modules": {"agents.submodule": {"optional_export": {}}}, "optional_dependencies": {}}', + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match=r"submodule export policy for agents.submodule has unknown fields: " + r"\['optional_export'\]", + ): + load_submodule_export_policy(policy_path) + + +def test_load_submodule_export_policy_requires_dependency_installations(tmp_path: Path) -> None: + policy_path = tmp_path / "policy.json" + policy_path.write_text( + '{"modules": {"agents.submodule": {"optional_exports": ' + '{"OptionalBackend": "optional_backend"}}}, "optional_dependencies": {}}', + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="submodule export policy dependencies are missing installation declarations: " + r"\['optional_backend'\]", + ): + load_submodule_export_policy(policy_path) + + +def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: Path) -> None: + policy_path = tmp_path / "policy.json" + policy_path.write_text( + '{"modules": {"agents.submodule": {"optional_bindings": ' + '{"LazyBinding": "binding_dependency"}, "optional_exports": ' + '{"ConditionalExport": "export_dependency"}}}, "optional_dependencies": ' + '{"binding_dependency": {"requirement": "binding-package>=1"}, ' + '"export_dependency": {"extra": "export-extra"}}}', + encoding="utf-8", + ) + + policy = load_submodule_export_policy(policy_path) + + assert policy.modules == { + "agents.submodule": { + "optional_bindings": {"LazyBinding": "binding_dependency"}, + "optional_exports": {"ConditionalExport": "export_dependency"}, + } + } + assert [asdict(installation) for installation in policy.dependency_installations] == [ + { + "dependency_module": "binding_dependency", + "extra": None, + "requirement": "binding-package>=1", + "unsupported_platforms": (), + }, + { + "dependency_module": "export_dependency", + "extra": "export-extra", + "requirement": None, + "unsupported_platforms": (), + }, + ] + + +def test_load_submodule_export_policy_collects_unsupported_platforms(tmp_path: Path) -> None: + policy_path = tmp_path / "policy.json" + policy_path.write_text( + '{"modules": {"agents.submodule": {"optional_exports": ' + '{"ConditionalExport": "export_dependency"}}}, "optional_dependencies": ' + '{"export_dependency": {"extra": "export-extra", ' + '"unsupported_platforms": ["win32"]}}}', + encoding="utf-8", + ) + + policy = load_submodule_export_policy(policy_path) + + assert policy.dependency_installations[0].unsupported_platforms == ("win32",) + + +@pytest.mark.parametrize( + "unsupported_platforms", + ["win32", [""], ["win32", "win32"]], +) +def test_load_submodule_export_policy_rejects_invalid_unsupported_platforms( + tmp_path: Path, unsupported_platforms: object +) -> None: + policy_path = tmp_path / "policy.json" + policy_path.write_text( + json.dumps( + { + "modules": { + "agents.submodule": { + "optional_exports": {"ConditionalExport": "export_dependency"} + } + }, + "optional_dependencies": { + "export_dependency": { + "extra": "export-extra", + "unsupported_platforms": unsupported_platforms, + } + }, + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="must be a list of unique non-empty strings"): + load_submodule_export_policy(policy_path) + + +@pytest.mark.parametrize( + "protected_path", + [ + CONTRACT, + CONTRACT.with_name("released_api_contract_policy.json"), + ], +) +def test_prospective_output_rejects_contract_input_path(protected_path: Path) -> None: + root = CONTRACT.parents[2] + result = subprocess.run( + [ + sys.executable, + str(root / ".github" / "scripts" / "update_released_api_contract.py"), + "--version", + version("openai-agents"), + "--output", + str(protected_path), + ], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 1 + assert result.stderr.strip() == ( + "--output must not overwrite released API contract inputs: " + "tests/fixtures/released_api_contract.json or " + "tests/fixtures/released_api_contract_policy.json" + ) + + def test_public_api_contract_allows_declared_optional_submodule_binding( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1120,11 +1477,11 @@ def test_public_api_contract_allows_declared_optional_submodule_export( assert validate_released_api_contract(contract, agents_module=agents_module) == [] -def test_public_api_contract_requires_available_optional_submodule_export( +def test_public_api_contract_rejects_optional_export_that_remains_in_all( monkeypatch: pytest.MonkeyPatch, ) -> None: agents_module = SimpleNamespace(__all__=[]) - submodule = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["OptionalBackend"]) contract: dict[str, Any] = { "required_top_level_exports": [], "public_modules": ["agents.submodule"], @@ -1132,7 +1489,7 @@ def test_public_api_contract_requires_available_optional_submodule_export( "agents.submodule": { "names": ["OptionalBackend"], "optional_bindings": {}, - "optional_exports": {"OptionalBackend": "json"}, + "optional_exports": {"OptionalBackend": "missing_optional_backend_dependency"}, } }, "canonical_imports": [], @@ -1147,12 +1504,47 @@ def test_public_api_contract_requires_available_optional_submodule_export( ) assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Invalid released agents.submodule optional dependency declaration: " + "'OptionalBackend' remains in __all__ but its binding is unavailable; " + "declare it in optional_bindings instead of optional_exports" + ] + + +def test_public_api_contract_rejects_optional_binding_absent_from_all( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {"OptionalBackend": "missing_optional_backend_dependency"}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Invalid released agents.submodule optional dependency declaration: " + "'OptionalBackend' is absent from __all__; declare it in optional_exports " + "instead of optional_bindings", "Missing released agents.submodule exports: ['OptionalBackend']", - "Missing released agents.submodule bindings: ['OptionalBackend']", ] -def test_public_api_contract_requires_declared_dependencies_in_strict_optional_profile( +def test_public_api_contract_requires_available_optional_submodule_export( monkeypatch: pytest.MonkeyPatch, ) -> None: agents_module = SimpleNamespace(__all__=[]) @@ -1164,7 +1556,7 @@ def test_public_api_contract_requires_declared_dependencies_in_strict_optional_p "agents.submodule": { "names": ["OptionalBackend"], "optional_bindings": {}, - "optional_exports": {"OptionalBackend": "mistyped_dependency_name"}, + "optional_exports": {"OptionalBackend": "json"}, } }, "canonical_imports": [], @@ -1178,13 +1570,9 @@ def test_public_api_contract_requires_declared_dependencies_in_strict_optional_p ), ) - assert validate_released_api_contract( - contract, - agents_module=agents_module, - require_all_optional_exports=True, - ) == [ - "Required optional dependencies for released agents.submodule are unavailable: " - "['OptionalBackend -> mistyped_dependency_name']" + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Missing released agents.submodule exports: ['OptionalBackend']", + "Missing released agents.submodule bindings: ['OptionalBackend']", ] From 8979f88873c8032286b679d50bd34ec8cc34c898 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 19:24:41 +0900 Subject: [PATCH 269/473] refactor: gate release readiness before branch creation --- .../skills/release-candidate-prep/SKILL.md | 66 ++++++++++--- .../release-candidate-prep/scripts/prepare.py | 97 +++++++++++++++++-- .../scripts/test_prepare.py | 75 ++++++++++++-- 3 files changed, 209 insertions(+), 29 deletions(-) diff --git a/.agents/skills/release-candidate-prep/SKILL.md b/.agents/skills/release-candidate-prep/SKILL.md index 01b836931e..8d7aedba7e 100644 --- a/.agents/skills/release-candidate-prep/SKILL.md +++ b/.agents/skills/release-candidate-prep/SKILL.md @@ -1,6 +1,6 @@ --- name: release-candidate-prep -description: Prepare an OpenAI Agents Python release candidate locally from exact origin/main, freeze the released API contract, create one local release commit, run final release review, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. +description: Preflight and prepare an OpenAI Agents Python release candidate locally from exact origin/main, gate readiness before branch creation, freeze the released API contract, create one local release commit, run final release review, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. --- # Release Candidate Preparation @@ -9,10 +9,10 @@ Use this skill only when the user explicitly invokes `$release-candidate-prep` a ## Non-negotiable boundaries -- Treat explicit invocation as authorization to fast-forward a clean local `main`, create `release/v`, update the three release-owned files, and create one local commit. +- Treat explicit invocation as authorization to fast-forward a clean local `main`, run branch-free release-readiness gates, create `release/v` only after those gates pass, update the three release-owned files, and create one local commit. - Never push, open or edit a pull request, add labels or milestones, create a release, or otherwise mutate GitHub. Never run `gh`. - Own exactly `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`. Runtime, documentation, workflow, or other repository changes must land on `main` before release preparation. -- Do not stash, reset, delete, overwrite, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty, is not on `main`, has diverged from refreshed `origin/main`, or collides with a local or remote release branch. +- Do not stash, reset, delete, overwrite, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty, is not on `main`, has diverged from refreshed `origin/main`, collides with a local or remote release branch, fails the prospective packaged-contract gate, receives a blocked planning review, or has advanced since those gates ran. - Remove inherited `OPENAI_API_KEY` from every child command. Release preparation does not require a live OpenAI API request. - Stop after the local commit, final release review, and copy-ready handoff. The user owns the push and pull-request creation. @@ -22,20 +22,60 @@ Require one semver-like version without a leading `v`. Do not infer a version fr Read `$final-release-review` completely before starting. Its final-candidate report is the release pull request description. Do not use `$pr-draft-summary` for the release candidate itself; this skill owns the fixed release branch, commit subject, title, and description. Continue to use `$pr-draft-summary` normally when implementing changes to this skill or other repository behavior. -## 2. Prepare the uncommitted candidate +## 2. Freeze a branch-free preflight input From the repository root, run: ```bash -env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py --version +env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py preflight --version ``` -The helper must complete all of these operations or fail with an actionable error: +The helper must complete all of these operations or fail with an actionable error while remaining on `main`: 1. Verify the repository root, `main` branch, and clean working tree. 2. Verify that `release/v` does not exist locally or remotely. 3. Fetch `main` into `origin/main`, fast-forward local `main` with `git merge --ff-only origin/main`, and require local `HEAD` to equal refreshed `origin/main`. -4. Create `release/v`. +4. Recheck the release-branch collision and require the refreshed working tree to remain clean. +5. Print the exact 40-character base commit to use for both readiness gates and later materialization. + +Record that base commit as ``. Do not create or switch branches yet. + +## 3. Run the branch-free readiness gates + +Run both gates against exact `` before materializing any candidate: + +1. Start the prospective packaged-contract gate from the clean local checkout: + + ```bash + env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple make check-prospective-released-api-contract + ``` + +2. Invoke `$final-release-review` in **pre-release planning** mode with `TARGET=` and the requested version as the release intent. Require a green release call. Keep the target pinned to the commit rather than allowing a later `origin/main` refresh to change the reviewed source. + +These gates are independent consumers of the same clean source commit. Start the prospective command as a long-running session and perform the read-only planning review while it runs when the execution environment supports overlap. Wait for both results before continuing. If concurrency is unavailable, run them sequentially with the prospective gate first; correctness must not depend on overlap. + +If either gate fails or blocks, stop on clean `main`, report the prospective command failure or the planning review's unblock checklist, and do not create `release/v`. A failed prospective gate should direct maintainers to fix the public surface or `tests/fixtures/released_api_contract_policy.json` on `main`. A blocked planning review should direct runtime or documentation-timing follow-up to `main` as applicable. + +After both gates pass, require all of the following before materialization: + +- The current branch is still `main`. +- The working tree is clean; ignored `.tmp` output is allowed. +- `HEAD` still equals ``. + +## 4. Materialize the uncommitted candidate + +Run: + +```bash +env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py materialize --version --expected-base +``` + +The helper must complete all of these operations or fail with an actionable error: + +1. Repeat the root, clean `main`, version, and local/remote release-branch checks. +2. Refresh and fast-forward `origin/main` again. +3. Require refreshed `origin/main` to equal ``. If it advanced, stop on clean `main` and rerun preflight plus both readiness gates against the new commit. +4. Create `release/v` only after the exact-base check passes. 5. Update the single project version declaration in `pyproject.toml`. 6. Run `make sync` with `UV_DEFAULT_INDEX=https://pypi.org/simple`. 7. Run `make update-released-api-contract VERSION=` and then `make check-released-api-contract VERSION=`. @@ -43,7 +83,7 @@ The helper must complete all of these operations or fail with an actionable erro If the helper fails after branch creation, preserve its local branch and working-tree evidence. Report the failing command and state rather than guessing whether a partial run is safe to resume. -## 3. Review and commit the exact release diff +## 5. Review and commit the exact release diff Inspect all release-owned files before staging: @@ -70,19 +110,21 @@ git commit -m "release: " Do not amend unrelated content into the commit. -## 4. Run the final-candidate release review +## 6. Run the final-candidate release review Invoke `$final-release-review` in final-candidate mode with the release commit as `TARGET=HEAD`. The branch, `pyproject.toml`, `uv.lock`, and API contract must agree on the intended version. If the review is blocked, stop. Return its unblock checklist, retain the local branch and commit for follow-up, and do not present the candidate as PR-ready. After any fix, regenerate the API contract when the public surface may have changed, restore a single release commit, and rerun the complete final-candidate review. -## 5. Recheck main freshness +The earlier planning review proves that the source commit was ready before branch creation. This final-candidate review remains required because it verifies the materialized branch, version metadata, lockfile, and frozen contract together. Use its complete report as the release pull request description; do not substitute the planning report. + +## 7. Recheck main freshness -After a green review, fetch `origin main` again without credentials and compare it with the release commit's parent. If they differ, the candidate is stale. First verify that the branch is clean, has exactly one local commit, and that the commit changes only the three-file release manifest. Rebase that commit onto the new `origin/main` so Git detects any conflicting release metadata. After a clean rebase, move the local release branch back to `origin/main` with a mixed reset, which preserves the rebased release tree as unstaged task-owned changes. Restore only `tests/fixtures/released_api_contract.json` from `origin/main`, rerun `make sync`, update and check the API contract while `HEAD` is the new base, review the exact manifest again, and recreate the single `release: ` commit. Then rerun `$final-release-review`. Repeat until the reviewed commit is exactly one commit ahead of current `origin/main`. +After a green review, fetch `origin main` again without credentials and compare it with the release commit's parent. If they differ, the candidate is stale. First verify that the branch is clean, has exactly one local commit, and that the commit changes only the three-file release manifest. Rebase that commit onto the new `origin/main` so Git detects any conflicting release metadata. After a clean rebase, move the local release branch back to `origin/main` with a mixed reset, which preserves the rebased release tree as unstaged task-owned changes. Restore only `tests/fixtures/released_api_contract.json` from `origin/main`, rerun `make sync`, run `make check-prospective-released-api-contract`, update and check the released API contract while `HEAD` is the new base, review the exact manifest again, and recreate the single `release: ` commit. Then rerun `$final-release-review`. Repeat until the reviewed commit is exactly one commit ahead of current `origin/main`. If replay conflicts or another path changes, stop with recoverable evidence. Do not force a resolution that expands the release commit beyond its manifest. -## 6. Produce the release handoff +## 8. Produce the release handoff For a green, current candidate, return the `$final-release-review` report plus this release-specific block in English: diff --git a/.agents/skills/release-candidate-prep/scripts/prepare.py b/.agents/skills/release-candidate-prep/scripts/prepare.py index f494ba5144..11bb24aa26 100755 --- a/.agents/skills/release-candidate-prep/scripts/prepare.py +++ b/.agents/skills/release-candidate-prep/scripts/prepare.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prepare an uncommitted local release candidate from exact origin/main.""" +"""Preflight and materialize a local release candidate from exact origin/main.""" from __future__ import annotations @@ -22,6 +22,7 @@ ROOT = Path(__file__).resolve().parents[4] VERSION_PATTERN = re.compile(r"\d+\.\d+(?:\.\d+)*(?:[A-Za-z0-9.-]+)?\Z") +COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z") PROJECT_VERSION_PATTERN = re.compile(r'(?m)^version\s*=\s*"[^"]+"') RELEASE_PATHS = frozenset( { @@ -36,6 +37,15 @@ class ReleasePreparationError(RuntimeError): """Report a safe, actionable release preparation failure.""" +@dataclass(frozen=True) +class ReleasePreflight: + """Describe a branch-free release readiness input.""" + + base_commit: str + branch: str + version: str + + @dataclass(frozen=True) class PreparedCandidate: """Describe the successfully prepared local candidate.""" @@ -109,6 +119,16 @@ def validate_version(version: str) -> str: return version +def validate_commit(commit: str) -> str: + """Validate an exact lowercase Git commit identifier.""" + + if COMMIT_PATTERN.fullmatch(commit) is None: + raise ReleasePreparationError( + "Expected base must be a full 40-character lowercase Git commit identifier." + ) + return commit + + def project_version(repo: Path) -> str: """Read the project version from pyproject.toml.""" @@ -203,11 +223,16 @@ def _locked_project_version(repo: Path) -> str: for package in packages if package.get("name") == "openai-agents" and package.get("source") == {"editable": "."} ] - if len(matches) != 1 or not isinstance(matches[0].get("version"), str): + if len(matches) != 1: + raise ReleasePreparationError( + "uv.lock must contain exactly one editable openai-agents package with a version." + ) + locked_version = matches[0].get("version") + if not isinstance(locked_version, str): raise ReleasePreparationError( "uv.lock must contain exactly one editable openai-agents package with a version." ) - return matches[0]["version"] + return locked_version def _validate_prepared_files(repo: Path, version: str, base_commit: str) -> tuple[str, ...]: @@ -241,8 +266,8 @@ def _validate_prepared_files(repo: Path, version: str, base_commit: str) -> tupl return tuple(sorted(changed)) -def prepare(repo: Path, version: str) -> PreparedCandidate: - """Prepare the three-file release candidate and leave it uncommitted.""" +def preflight(repo: Path, version: str) -> ReleasePreflight: + """Refresh exact main and validate release inputs without creating a branch.""" repo = repo.resolve() version = validate_version(version) @@ -278,6 +303,32 @@ def prepare(repo: Path, version: str) -> PreparedCandidate: raise ReleasePreparationError(f"Refreshed origin/main already declares version {version}.") _require_branch_absent(repo, branch) + if _status(repo): + raise ReleasePreparationError( + "Release preflight must leave the refreshed main working tree clean." + ) + return ReleasePreflight( + base_commit=base_commit, + branch=branch, + version=version, + ) + + +def materialize(repo: Path, version: str, expected_base: str) -> PreparedCandidate: + """Create the three-file candidate only from the reviewed preflight commit.""" + + expected_base = validate_commit(expected_base) + release_input = preflight(repo, version) + if release_input.base_commit != expected_base: + raise ReleasePreparationError( + f"Preflight reviewed {expected_base}, but refreshed origin/main is " + f"{release_input.base_commit}; rerun release preflight and planning review." + ) + + repo = repo.resolve() + env = _release_environment() + branch = release_input.branch + base_commit = release_input.base_commit run_command(repo, ["git", "switch", "-c", branch], env=env, announce=True) replace_project_version(repo, version) run_command(repo, ["make", "sync"], env=env, announce=True) @@ -304,24 +355,54 @@ def prepare(repo: Path, version: str) -> PreparedCandidate: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Prepare an uncommitted local release candidate from exact origin/main." + description="Preflight or materialize a local release candidate from exact origin/main." ) - parser.add_argument( + subparsers = parser.add_subparsers(dest="phase", required=True) + preflight_parser = subparsers.add_parser( + "preflight", + help="Refresh exact main and validate inputs without creating a release branch.", + ) + preflight_parser.add_argument( "--version", required=True, help="Release version without a leading v, for example 0.20.1.", ) + materialize_parser = subparsers.add_parser( + "materialize", + help="Create an uncommitted candidate from the reviewed preflight commit.", + ) + materialize_parser.add_argument( + "--version", + required=True, + help="Release version without a leading v, for example 0.20.1.", + ) + materialize_parser.add_argument( + "--expected-base", + required=True, + help="Exact 40-character origin/main commit approved by release preflight.", + ) return parser.parse_args() def main() -> int: args = parse_args() try: - candidate = prepare(ROOT, args.version) + if args.phase == "preflight": + release_input = preflight(ROOT, args.version) + else: + candidate = materialize(ROOT, args.version, args.expected_base) except (OSError, ReleasePreparationError, tomllib.TOMLDecodeError, json.JSONDecodeError) as exc: print(f"Release preparation failed: {exc}", file=sys.stderr) return 1 + if args.phase == "preflight": + print("Release preflight passed on clean main without creating a branch.") + print(f"Base commit: {release_input.base_commit}") + print(f"Planned branch: {release_input.branch}") + print(f"Version: {release_input.version}") + print("Run the prospective contract and planning-review gates against this commit.") + return 0 + print("Release candidate prepared locally and left uncommitted.") print(f"Base commit: {candidate.base_commit}") print(f"Branch: {candidate.branch}") diff --git a/.agents/skills/release-candidate-prep/scripts/test_prepare.py b/.agents/skills/release-candidate-prep/scripts/test_prepare.py index c033061b18..bca5645208 100755 --- a/.agents/skills/release-candidate-prep/scripts/test_prepare.py +++ b/.agents/skills/release-candidate-prep/scripts/test_prepare.py @@ -164,11 +164,37 @@ def test_replace_project_version_requires_exactly_one_change(self) -> None: "0.20.0", ) + def test_validate_commit_requires_full_lowercase_identifier(self) -> None: + commit = "a" * 40 + self.assertEqual(prepare.validate_commit(commit), commit) + for value in ("a" * 39, "A" * 40, "main", "a" * 41): + with self.subTest(value=value), self.assertRaises(prepare.ReleasePreparationError): + prepare.validate_commit(value) + class PreparationTests(unittest.TestCase): - def test_prepare_creates_branch_and_exact_uncommitted_manifest(self) -> None: + def test_preflight_leaves_clean_main_without_creating_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + + release_input = prepare.preflight(fixture.repo, "0.20.0") + + self.assertEqual(release_input.base_commit, fixture.base_commit) + self.assertEqual(release_input.branch, "release/v0.20.0") + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "main", + ) + self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") + self.assertNotIn( + "release/v0.20.0", + run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(), + ) + + def test_materialize_creates_branch_and_exact_uncommitted_manifest(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) + release_input = prepare.preflight(fixture.repo, "0.20.0") with mock.patch.dict( os.environ, @@ -178,7 +204,11 @@ def test_prepare_creates_branch_and_exact_uncommitted_manifest(self) -> None: "OPENAI_API_KEY": "untrusted", }, ): - candidate = prepare.prepare(fixture.repo, "0.20.0") + candidate = prepare.materialize( + fixture.repo, + "0.20.0", + release_input.base_commit, + ) self.assertEqual(candidate.base_commit, fixture.base_commit) self.assertEqual(candidate.branch, "release/v0.20.0") @@ -206,7 +236,7 @@ def test_prepare_creates_branch_and_exact_uncommitted_manifest(self) -> None: self.assertEqual(contract["baseline"], "v0.20.0") self.assertEqual(contract["baseline_commit"], fixture.base_commit) - def test_prepare_rejects_dirty_main_without_creating_branch(self) -> None: + def test_preflight_rejects_dirty_main_without_creating_branch(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) (fixture.repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") @@ -215,28 +245,28 @@ def test_prepare_rejects_dirty_main_without_creating_branch(self) -> None: prepare.ReleasePreparationError, "clean working tree", ): - prepare.prepare(fixture.repo, "0.20.0") + prepare.preflight(fixture.repo, "0.20.0") self.assertEqual( run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), "main", ) - def test_prepare_fast_forwards_to_refreshed_origin_main(self) -> None: + def test_preflight_fast_forwards_to_refreshed_origin_main(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) refreshed_base = fixture.advance_origin() - candidate = prepare.prepare(fixture.repo, "0.20.0") + release_input = prepare.preflight(fixture.repo, "0.20.0") - self.assertEqual(candidate.base_commit, refreshed_base) + self.assertEqual(release_input.base_commit, refreshed_base) self.assertEqual( run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(), refreshed_base, ) self.assertTrue((fixture.repo / "new-source.txt").is_file()) - def test_prepare_rejects_existing_remote_release_branch(self) -> None: + def test_preflight_rejects_existing_remote_release_branch(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) run( @@ -251,12 +281,39 @@ def test_prepare_rejects_existing_remote_release_branch(self) -> None: prepare.ReleasePreparationError, "Remote branch 'release/v0.20.0' already exists", ): - prepare.prepare(fixture.repo, "0.20.0") + prepare.preflight(fixture.repo, "0.20.0") + + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "main", + ) + + def test_materialize_rejects_stale_preflight_before_creating_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + release_input = prepare.preflight(fixture.repo, "0.20.0") + refreshed_base = fixture.advance_origin() + + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + f"Preflight reviewed {release_input.base_commit}, but refreshed origin/main is " + f"{refreshed_base}", + ): + prepare.materialize(fixture.repo, "0.20.0", release_input.base_commit) self.assertEqual( run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), "main", ) + self.assertEqual( + run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(), + refreshed_base, + ) + self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") + self.assertNotIn( + "release/v0.20.0", + run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(), + ) if __name__ == "__main__": From 92ca19441a5c36f4080a63d2df665ea1368dc2c8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 19:40:46 +0900 Subject: [PATCH 270/473] refactor: adjust the final release review when cutting a new version --- .agents/skills/final-release-review/SKILL.md | 20 +- .../references/review-checklist.md | 4 + .../skills/release-candidate-prep/SKILL.md | 72 ++--- .../release-candidate-prep/scripts/prepare.py | 233 ++++++++++++++--- .../scripts/test_prepare.py | 246 ++++++++++++++++-- AGENTS.md | 4 +- 6 files changed, 491 insertions(+), 88 deletions(-) diff --git a/.agents/skills/final-release-review/SKILL.md b/.agents/skills/final-release-review/SKILL.md index d32048ecbe..28512babe3 100644 --- a/.agents/skills/final-release-review/SKILL.md +++ b/.agents/skills/final-release-review/SKILL.md @@ -12,11 +12,11 @@ Audit `BASE_TAG...TARGET` in one of two modes: - **Pre-release planning:** use when the user asks to plan the next release or when the target, normally `origin/main`, does not yet declare a release candidate. The user may still supply a tentative `patch` or `minor` intent. Recommend the compatible type; do not treat unchanged package metadata as a blocker. - **Final candidate:** use when the user asks for a final candidate decision, the target is a release branch, or target package metadata has already been bumped beyond BASE for the next release. Compare the candidate intent with the minimum release type required by the diff. -In both modes, find concrete regressions and release risks, independently determine version compatibility, review the latest open documentation PRs before claiming coverage is missing, and produce an actionable release handoff. Keep documentation readiness separate from the release gate. +In both modes, find concrete regressions and release risks, independently determine version compatibility, review the latest open documentation PRs before claiming coverage is missing, and produce an actionable release handoff. Keep documentation readiness separate from the release gate. The release call is a controlling checker result: callers must stop on **BLOCKED** and may continue only on **GREEN LIGHT TO SHIP**. Producing the report text is not itself a passing result. ## Quick start -1. Ensure the repository root is `openai-agents-python`. +1. Ensure the repository root is `openai-agents-python`. When a caller supplies a dedicated candidate worktree, run every local inspection from that worktree rather than another checkout of the repository. 2. Sync remote tags and choose the previous release: ```bash BASE_TAG="$(.agents/skills/final-release-review/scripts/find_latest_release_tag.sh origin 'v*')" @@ -46,6 +46,8 @@ In both modes, find concrete regressions and release risks, independently determ 8. Discover and review relevant open documentation PRs using current read-only GitHub state. Do not infer coverage from local branches, titles, or historical context. 9. Report the release intent, ship/block gate, risk assessment, documentation coverage, and conditional minor-release Key Changes draft. +For a final candidate reviewed as `TARGET=HEAD`, also require `HEAD` to be the exact target in the candidate checkout, inspect the checked-out branch and release-owned files directly, and keep working-tree changes outside the commit from being mistaken for reviewed candidate content. + ## Release intent and versioning policy - Treat routine compatible releases as `patch`. @@ -85,6 +87,8 @@ In both modes, find concrete regressions and release risks, independently determ - Unchanged package version metadata in pre-release planning mode. - A documentation review may reveal an underlying runtime or compatibility defect. Block only for that defect, not for the documentation state. - A green gate must still explain important user-visible release surfaces. +- A caller must treat any target, base, candidate-content, version-metadata, lockfile, or contract change after review as invalidating the gate. The changed candidate requires a complete new review and a new release call. +- Never issue a green release call merely because the report template is complete. The target diff and applicable checked-out candidate contents must have been inspected first. ## Workflow @@ -95,6 +99,18 @@ In both modes, find concrete regressions and release risks, independently determ - Assume the target passed repository CI unless told otherwise. Do not rerun routine unit, lint, formatting, type, or coverage checks by default. - Use diff stats, directory distribution, commit order, and name status to identify high-risk areas. Read changed tests as behavioral evidence, not as proof by themselves. +### Inspect a materialized candidate checkout + +In final-candidate mode, when the caller provides a dedicated checkout or worktree: + +- Resolve and record the checkout root, current branch, `HEAD`, and clean status before auditing. Do not switch to a different checkout that happens to share the same Git object database. +- Require `TARGET=HEAD` to resolve to the checked-out commit. Treat detached HEAD, a mismatched release branch, uncommitted release-owned files, or unrelated changed paths as candidate inconsistency. +- Read `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json` from that checkout. Verify the intended version, editable `openai-agents` lock entry, contract baseline, and contract `baseline_commit` against the release branch and commit parent. +- Inspect the exact commit diff and confirm that the materialized release commit owns only its expected release manifest when the invoking workflow defines one. +- Keep the checkout path as local evidence for the caller, but do not put local paths into copy-ready release text. + +These checks make the final-candidate review a release gate. The report remains the human-readable evidence and PR-description source for a green result; it does not replace the checks. + ### Audit contracts and prove findings - Compare BASE and TARGET rather than reviewing TARGET in isolation. diff --git a/.agents/skills/final-release-review/references/review-checklist.md b/.agents/skills/final-release-review/references/review-checklist.md index 2a0e553000..6946b297ad 100644 --- a/.agents/skills/final-release-review/references/review-checklist.md +++ b/.agents/skills/final-release-review/references/review-checklist.md @@ -6,6 +6,7 @@ Use the release-mode, versioning, gate, documentation, and output policies in `. - Sync remote tags and resolve the latest matching release tag with `../scripts/find_latest_release_tag.sh origin 'v*'`. - Refresh the requested target, defaulting to `origin/main`, and record its exact commit. +- When the caller provides a dedicated checkout or worktree, run every local inspection there and record its root, current branch, `HEAD`, and clean status as gate evidence. Do not substitute another checkout that shares the same Git objects. - Resolve review mode first, then release intent. Record the evidence for each decision separately. - Generate `git diff --stat BASE...TARGET`, `git diff --dirstat=files,0 BASE...TARGET`, `git log --oneline --reverse BASE..TARGET`, and `git diff --name-status BASE...TARGET`. - Inspect suspicious paths with `git diff --word-diff BASE...TARGET -- `. @@ -27,6 +28,8 @@ Capture: - minimum required release type and the contracts that establish it; - planning recommendation or final-candidate compatibility verdict. +For a materialized final candidate, read the checked-out package metadata, lockfile, and released API contract before deciding compatibility. Require the candidate branch, `HEAD`, intended version, contract baseline, and contract base commit to agree. Treat uncommitted release-owned files or unrelated changed paths as an inconsistent candidate rather than reviewing only the commit object. + ## Audit runtime and package contracts ### Stage 1: broad discovery @@ -130,3 +133,4 @@ When `../SKILL.md` requires the minor-release draft: - Documentation-obligation inventory, current docs PR source and head SHA or search limitation, aggregate coverage, and exact post-release suggestions. - Conditional copy-ready Key Changes draft for minor releases. - Explicit ship/block call and an unblock checklist only when blocked. +- For a dedicated final-candidate checkout, confirmation that the exact checked-out `HEAD` and release-owned file contents were inspected and were clean. Keep the local checkout path out of copy-ready report text. diff --git a/.agents/skills/release-candidate-prep/SKILL.md b/.agents/skills/release-candidate-prep/SKILL.md index 8d7aedba7e..83c29c030c 100644 --- a/.agents/skills/release-candidate-prep/SKILL.md +++ b/.agents/skills/release-candidate-prep/SKILL.md @@ -1,6 +1,6 @@ --- name: release-candidate-prep -description: Preflight and prepare an OpenAI Agents Python release candidate locally from exact origin/main, gate readiness before branch creation, freeze the released API contract, create one local release commit, run final release review, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. +description: Preflight and prepare an OpenAI Agents Python release candidate in a dedicated worktree from exact origin/main, gate readiness before branch creation, freeze the released API contract, create one local release commit, enforce final release review as a checker, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. --- # Release Candidate Preparation @@ -9,83 +9,87 @@ Use this skill only when the user explicitly invokes `$release-candidate-prep` a ## Non-negotiable boundaries -- Treat explicit invocation as authorization to fast-forward a clean local `main`, run branch-free release-readiness gates, create `release/v` only after those gates pass, update the three release-owned files, and create one local commit. +- Treat explicit invocation as authorization to fetch `origin/main`, create one dedicated detached release worktree, run branch-free release-readiness gates there, create `release/v` in that worktree only after those gates pass, update the three release-owned files, and create one local commit. +- Keep the user's source checkout on its existing clean `main` commit. Do not fast-forward it, switch its branch, or materialize release files there. Leave the dedicated release worktree in place for green handoff, blocked review, or recoverable failure. - Never push, open or edit a pull request, add labels or milestones, create a release, or otherwise mutate GitHub. Never run `gh`. - Own exactly `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`. Runtime, documentation, workflow, or other repository changes must land on `main` before release preparation. -- Do not stash, reset, delete, overwrite, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty, is not on `main`, has diverged from refreshed `origin/main`, collides with a local or remote release branch, fails the prospective packaged-contract gate, receives a blocked planning review, or has advanced since those gates ran. +- Do not stash, reset, delete, overwrite, remove an existing worktree, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty or is not on `main`, the dedicated worktree is not clean and detached at refreshed `origin/main`, the release branch collides locally or remotely, the prospective packaged-contract gate fails, the planning review blocks, or `origin/main` advances after those gates run. +- Treat `$final-release-review` as the controlling release checker, not only as a report generator. Its planning gate must be green before branch creation, and its final-candidate gate must inspect the materialized worktree and be green before PR-ready handoff. Any candidate content, commit, or base change invalidates the previous green result. - Remove inherited `OPENAI_API_KEY` from every child command. Release preparation does not require a live OpenAI API request. - Stop after the local commit, final release review, and copy-ready handoff. The user owns the push and pull-request creation. ## 1. Establish the release input -Require one semver-like version without a leading `v`. Do not infer a version from milestones, branch names, or local modifications. Announce that the skill will update the local checkout and create one commit but will not write to GitHub. +Require one semver-like version without a leading `v`. Do not infer a version from milestones, branch names, or local modifications. Announce that the skill will create and retain a dedicated release worktree with one local commit, keep the source checkout unchanged, and not write to GitHub. Read `$final-release-review` completely before starting. Its final-candidate report is the release pull request description. Do not use `$pr-draft-summary` for the release candidate itself; this skill owns the fixed release branch, commit subject, title, and description. Continue to use `$pr-draft-summary` normally when implementing changes to this skill or other repository behavior. -## 2. Freeze a branch-free preflight input +## 2. Create an isolated branch-free preflight input From the repository root, run: ```bash -env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py preflight --version +env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py preflight --version --worktree-root ``` -The helper must complete all of these operations or fail with an actionable error while remaining on `main`: +The helper must complete all of these operations or fail with an actionable error while leaving the source checkout on its original `main` commit: 1. Verify the repository root, `main` branch, and clean working tree. 2. Verify that `release/v` does not exist locally or remotely. -3. Fetch `main` into `origin/main`, fast-forward local `main` with `git merge --ff-only origin/main`, and require local `HEAD` to equal refreshed `origin/main`. -4. Recheck the release-branch collision and require the refreshed working tree to remain clean. -5. Print the exact 40-character base commit to use for both readiness gates and later materialization. +3. Fetch `main` into `origin/main` without merging or switching the source checkout. +4. Choose a unique task-oriented path under the configured Codex worktree root. Check both the filesystem and `git worktree list`; never reuse or delete a collision. +5. Create a detached worktree at exact refreshed `origin/main`, then require that worktree to be clean, detached, and at the exact 40-character base commit. +6. Recheck the release-branch collision and require the source checkout to remain clean on `main` at its original commit. +7. Print the exact base commit, unchanged source-checkout commit, planned branch, and dedicated worktree path for both readiness gates and later materialization. -Record that base commit as ``. Do not create or switch branches yet. +Record the base commit as ``, the source-checkout commit as ``, and the path as ``. Do not create or switch branches yet. Keep the detached worktree if a later gate blocks so its exact reviewed source remains inspectable. ## 3. Run the branch-free readiness gates Run both gates against exact `` before materializing any candidate: -1. Start the prospective packaged-contract gate from the clean local checkout: +1. Start the prospective packaged-contract gate from ``: ```bash env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple make check-prospective-released-api-contract ``` -2. Invoke `$final-release-review` in **pre-release planning** mode with `TARGET=` and the requested version as the release intent. Require a green release call. Keep the target pinned to the commit rather than allowing a later `origin/main` refresh to change the reviewed source. +2. Invoke `$final-release-review` from `` in **pre-release planning** mode with `TARGET=` and the requested version as the release intent. Require its release-checker result to be **GREEN LIGHT TO SHIP**. Keep the target pinned to the commit rather than allowing a later `origin/main` refresh to change the reviewed source, and require all local source, contract, and package inspection to use the dedicated worktree. These gates are independent consumers of the same clean source commit. Start the prospective command as a long-running session and perform the read-only planning review while it runs when the execution environment supports overlap. Wait for both results before continuing. If concurrency is unavailable, run them sequentially with the prospective gate first; correctness must not depend on overlap. -If either gate fails or blocks, stop on clean `main`, report the prospective command failure or the planning review's unblock checklist, and do not create `release/v`. A failed prospective gate should direct maintainers to fix the public surface or `tests/fixtures/released_api_contract_policy.json` on `main`. A blocked planning review should direct runtime or documentation-timing follow-up to `main` as applicable. +If either gate fails or blocks, stop without creating `release/v`, leave the source checkout unchanged, retain the detached worktree, and report its path plus the prospective command failure or the planning review's unblock checklist. A failed prospective gate should direct maintainers to fix the public surface or `tests/fixtures/released_api_contract_policy.json` on `main`. A blocked planning review should direct runtime or documentation-timing follow-up to `main` as applicable. Do not continue merely because the review produced a well-formed report. After both gates pass, require all of the following before materialization: -- The current branch is still `main`. -- The working tree is clean; ignored `.tmp` output is allowed. -- `HEAD` still equals ``. +- The source checkout is still clean on `main` at the same commit it had before preflight. +- `` is clean except for ignored `.tmp` output, remains detached, and has `HEAD == `. +- The planning review's green gate applies to `` and the requested release intent. ## 4. Materialize the uncommitted candidate Run: ```bash -env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py materialize --version --expected-base +env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple uv run --frozen python .agents/skills/release-candidate-prep/scripts/prepare.py materialize --version --expected-base --expected-source-head --worktree ``` The helper must complete all of these operations or fail with an actionable error: -1. Repeat the root, clean `main`, version, and local/remote release-branch checks. -2. Refresh and fast-forward `origin/main` again. -3. Require refreshed `origin/main` to equal ``. If it advanced, stop on clean `main` and rerun preflight plus both readiness gates against the new commit. -4. Create `release/v` only after the exact-base check passes. +1. Repeat the source-root, clean `main`, version, registered-worktree, detached-HEAD, and local/remote release-branch checks. +2. Refresh `origin/main` again without moving the source checkout. +3. Require refreshed `origin/main` and `` HEAD to equal ``. If `origin/main` advanced, retain the old detached worktree and rerun preflight plus both readiness gates in a new exact-base worktree. +4. Create `release/v` inside `` only after the exact-base check passes. 5. Update the single project version declaration in `pyproject.toml`. 6. Run `make sync` with `UV_DEFAULT_INDEX=https://pypi.org/simple`. 7. Run `make update-released-api-contract VERSION=` and then `make check-released-api-contract VERSION=`. -8. Require exactly the three release-owned paths to be modified and leave them unstaged and uncommitted. +8. Require exactly the three release-owned paths to be modified in ``, leave them unstaged and uncommitted, and confirm that the source checkout remains unchanged. -If the helper fails after branch creation, preserve its local branch and working-tree evidence. Report the failing command and state rather than guessing whether a partial run is safe to resume. +If the helper fails after branch creation, preserve its local branch, dedicated worktree, and working-tree evidence. Report the failing command and state rather than guessing whether a partial run is safe to resume. Never remove the worktree as automatic cleanup. ## 5. Review and commit the exact release diff -Inspect all release-owned files before staging: +Run the remaining commands from ``. Inspect all release-owned files before staging: ```bash git status --short @@ -112,15 +116,15 @@ Do not amend unrelated content into the commit. ## 6. Run the final-candidate release review -Invoke `$final-release-review` in final-candidate mode with the release commit as `TARGET=HEAD`. The branch, `pyproject.toml`, `uv.lock`, and API contract must agree on the intended version. +Invoke `$final-release-review` from `` in final-candidate mode with the release commit as `TARGET=HEAD`. This invocation is a release checker: it must inspect the complete candidate diff and the actual checked-out `release/v` contents, including `pyproject.toml`, the editable `openai-agents` entry in `uv.lock`, and `tests/fixtures/released_api_contract.json`. The branch, package metadata, lockfile, contract baseline, contract `baseline_commit`, and intended version must agree. -If the review is blocked, stop. Return its unblock checklist, retain the local branch and commit for follow-up, and do not present the candidate as PR-ready. After any fix, regenerate the API contract when the public surface may have changed, restore a single release commit, and rerun the complete final-candidate review. +If the review is blocked, stop. Return its unblock checklist, retain the local branch, commit, and worktree for follow-up, and do not present the candidate as PR-ready. A report body does not authorize continuation when the release call is blocked. After any fix, regenerate the API contract when the public surface may have changed, restore a single release commit, and rerun the complete final-candidate review. -The earlier planning review proves that the source commit was ready before branch creation. This final-candidate review remains required because it verifies the materialized branch, version metadata, lockfile, and frozen contract together. Use its complete report as the release pull request description; do not substitute the planning report. +The earlier planning review proves that the source commit was ready before branch creation. This final-candidate review remains required because it verifies the materialized branch, version metadata, lockfile, and frozen contract together. Treat its green release call as the handoff gate, then reuse its complete report as the release pull request description; do not substitute the planning report. ## 7. Recheck main freshness -After a green review, fetch `origin main` again without credentials and compare it with the release commit's parent. If they differ, the candidate is stale. First verify that the branch is clean, has exactly one local commit, and that the commit changes only the three-file release manifest. Rebase that commit onto the new `origin/main` so Git detects any conflicting release metadata. After a clean rebase, move the local release branch back to `origin/main` with a mixed reset, which preserves the rebased release tree as unstaged task-owned changes. Restore only `tests/fixtures/released_api_contract.json` from `origin/main`, rerun `make sync`, run `make check-prospective-released-api-contract`, update and check the released API contract while `HEAD` is the new base, review the exact manifest again, and recreate the single `release: ` commit. Then rerun `$final-release-review`. Repeat until the reviewed commit is exactly one commit ahead of current `origin/main`. +After a green review, fetch `origin main` again without credentials from `` and compare it with the release commit's parent. If they differ, the candidate is stale. First verify that the branch is clean, has exactly one local commit, and that the commit changes only the three-file release manifest. Rebase that commit onto the new `origin/main` so Git detects any conflicting release metadata. After a clean rebase, move the local release branch back to `origin/main` with a mixed reset, which preserves the rebased release tree as unstaged task-owned changes. Restore only `tests/fixtures/released_api_contract.json` from `origin/main`, rerun `make sync`, run `make check-prospective-released-api-contract`, update and check the released API contract while `HEAD` is the new base, review the exact manifest again, and recreate the single `release: ` commit. The base and candidate content changed, so the previous green check is invalid: rerun `$final-release-review` from the worktree and require a new green release call. Repeat until the reviewed commit is exactly one commit ahead of current `origin/main`. If replay conflicts or another path changes, stop with recoverable evidence. Do not force a resolution that expands the release commit beyond its manifest. @@ -150,4 +154,12 @@ Release Apply the repository's GitHub paste-readiness rules to the report. Use native `#123` references for this repository and `owner/repo#123` for another repository. Keep the required compare URL. Do not include local paths, Codex citations, operational diagnostics, or app directives inside the copy-ready description. -Also report the local branch, commit SHA, parent `origin/main` commit, and the exact three-file manifest outside the copy-ready block. State explicitly that nothing was pushed and no pull request was created. +Also report the dedicated worktree path, local branch, commit SHA, parent `origin/main` commit, and the exact three-file manifest outside the copy-ready block. State explicitly that the source checkout was left unchanged, nothing was pushed, and no pull request was created. Leave the worktree in place for the user's handoff. + +## Failure behavior + +- Preflight or worktree creation failure: leave the source checkout unchanged and do not delete or reuse any colliding worktree. +- Prospective-contract failure or blocked planning review: retain the detached worktree, do not create the release branch, and return the exact failure or unblock checklist. +- Materialization failure: retain the worktree and any branch or uncommitted evidence exactly as left by the failing command. +- Blocked final-candidate review: retain the single release commit and worktree, do not call the candidate PR-ready, and return the checker-derived unblock checklist. +- Freshness conflict or unexpected changed path: stop with recoverable worktree evidence rather than forcing a resolution or expanding the release manifest. diff --git a/.agents/skills/release-candidate-prep/scripts/prepare.py b/.agents/skills/release-candidate-prep/scripts/prepare.py index 11bb24aa26..6c34fb8d4a 100755 --- a/.agents/skills/release-candidate-prep/scripts/prepare.py +++ b/.agents/skills/release-candidate-prep/scripts/prepare.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Preflight and materialize a local release candidate from exact origin/main.""" +"""Preflight and materialize an isolated release candidate from exact origin/main.""" from __future__ import annotations @@ -39,21 +39,25 @@ class ReleasePreparationError(RuntimeError): @dataclass(frozen=True) class ReleasePreflight: - """Describe a branch-free release readiness input.""" + """Describe an isolated branch-free release readiness input.""" base_commit: str branch: str + source_commit: str version: str + worktree: Path @dataclass(frozen=True) class PreparedCandidate: - """Describe the successfully prepared local candidate.""" + """Describe the successfully prepared isolated candidate.""" base_commit: str branch: str changed_paths: tuple[str, ...] + source_commit: str version: str + worktree: Path def _release_environment() -> dict[str, str]: @@ -139,6 +143,17 @@ def project_version(repo: Path) -> str: return version +def project_version_at(repo: Path, commit: str) -> str: + """Read the project version from one exact commit without changing a checkout.""" + + text = git(repo, "show", f"{commit}:pyproject.toml").stdout + data = tomllib.loads(text) + version = data.get("project", {}).get("version") + if not isinstance(version, str): + raise ReleasePreparationError("pyproject.toml is missing project.version.") + return version + + def replace_project_version_text(text: str, version: str) -> str: """Replace the repository's single project version declaration.""" @@ -189,6 +204,79 @@ def _require_clean_main(repo: Path) -> None: raise ReleasePreparationError("Release preparation requires a clean working tree.") +def _require_source_head(repo: Path, expected_source_head: str) -> None: + """Require the user's source checkout to remain at its preflight commit.""" + + source_head = git(repo, "rev-parse", "HEAD").stdout.strip() + if source_head != expected_source_head: + raise ReleasePreparationError( + f"Source checkout HEAD changed from {expected_source_head} to {source_head}; " + "leave both checkouts intact and restart release preparation." + ) + + +def _registered_worktrees(repo: Path) -> set[Path]: + """Return canonical paths registered in the repository worktree inventory.""" + + paths: set[Path] = set() + for line in git(repo, "worktree", "list", "--porcelain").stdout.splitlines(): + if line.startswith("worktree "): + paths.add(Path(line.removeprefix("worktree ")).resolve()) + return paths + + +def _choose_worktree_path(repo: Path, worktree_root: Path, version: str) -> Path: + """Choose a unique release worktree path without reusing or deleting collisions.""" + + worktree_root = worktree_root.expanduser().resolve() + if worktree_root == repo or worktree_root.is_relative_to(repo): + raise ReleasePreparationError( + "The release worktree root must be outside the source checkout." + ) + + registered = _registered_worktrees(repo) + stem = f"{repo.name}-release-v{version}" + suffix = 1 + while True: + name = stem if suffix == 1 else f"{stem}-{suffix}" + candidate = worktree_root / name + if not candidate.exists() and candidate.resolve() not in registered: + return candidate + suffix += 1 + + +def _require_registered_detached_worktree( + source_repo: Path, + worktree: Path, + expected_base: str, +) -> None: + """Require a clean registered detached worktree at the reviewed base.""" + + worktree = worktree.expanduser().resolve() + if worktree not in _registered_worktrees(source_repo): + raise ReleasePreparationError( + f"Release worktree {worktree} is not registered for this repository." + ) + if not worktree.is_dir(): + raise ReleasePreparationError(f"Release worktree path does not exist: {worktree}.") + _require_repository_root(worktree) + branch = git(worktree, "symbolic-ref", "--quiet", "--short", "HEAD", check=False) + if branch.returncode == 0: + raise ReleasePreparationError( + f"Release worktree must remain detached before materialization, found " + f"{branch.stdout.strip()!r}." + ) + if branch.returncode != 1: + raise ReleasePreparationError("Unable to inspect the release worktree branch state.") + head = git(worktree, "rev-parse", "HEAD").stdout.strip() + if head != expected_base: + raise ReleasePreparationError( + f"Release worktree HEAD is {head}, expected reviewed base {expected_base}." + ) + if _status(worktree): + raise ReleasePreparationError("Release worktree must be clean before materialization.") + + def _require_branch_absent(repo: Path, branch: str) -> None: local = git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False) if local.returncode == 0: @@ -266,16 +354,14 @@ def _validate_prepared_files(repo: Path, version: str, base_commit: str) -> tupl return tuple(sorted(changed)) -def preflight(repo: Path, version: str) -> ReleasePreflight: - """Refresh exact main and validate release inputs without creating a branch.""" +def preflight(repo: Path, version: str, worktree_root: Path) -> ReleasePreflight: + """Refresh exact main and create an isolated branch-free readiness checkout.""" repo = repo.resolve() version = validate_version(version) _require_repository_root(repo) _require_clean_main(repo) - if project_version(repo) == version: - raise ReleasePreparationError(f"Project version is already {version}.") - + source_commit = git(repo, "rev-parse", "HEAD").stdout.strip() branch = f"release/v{version}" _require_branch_absent(repo, branch) env = _release_environment() @@ -291,71 +377,113 @@ def preflight(repo: Path, version: str) -> ReleasePreflight: env=env, announce=True, ) - run_command(repo, ["git", "merge", "--ff-only", "origin/main"], env=env, announce=True) base_commit = git(repo, "rev-parse", "origin/main").stdout.strip() - head_commit = git(repo, "rev-parse", "HEAD").stdout.strip() - if head_commit != base_commit: - raise ReleasePreparationError( - f"Local main is {head_commit}, but refreshed origin/main is {base_commit}; " - "refusing to release." - ) - if project_version(repo) == version: + if project_version_at(repo, base_commit) == version: raise ReleasePreparationError(f"Refreshed origin/main already declares version {version}.") _require_branch_absent(repo, branch) if _status(repo): raise ReleasePreparationError( - "Release preflight must leave the refreshed main working tree clean." + "Release preflight must leave the source main working tree clean." ) + worktree = _choose_worktree_path(repo, worktree_root, version) + worktree.parent.mkdir(parents=True, exist_ok=True) + run_command( + repo, + ["git", "worktree", "add", "--detach", str(worktree), base_commit], + env=env, + announce=True, + ) + _require_registered_detached_worktree(repo, worktree, base_commit) + _require_clean_main(repo) + _require_source_head(repo, source_commit) return ReleasePreflight( base_commit=base_commit, branch=branch, + source_commit=source_commit, version=version, + worktree=worktree, ) -def materialize(repo: Path, version: str, expected_base: str) -> PreparedCandidate: - """Create the three-file candidate only from the reviewed preflight commit.""" +def materialize( + repo: Path, + version: str, + expected_base: str, + expected_source_head: str, + worktree: Path, +) -> PreparedCandidate: + """Create the three-file candidate in the reviewed isolated worktree.""" expected_base = validate_commit(expected_base) - release_input = preflight(repo, version) - if release_input.base_commit != expected_base: - raise ReleasePreparationError( - f"Preflight reviewed {expected_base}, but refreshed origin/main is " - f"{release_input.base_commit}; rerun release preflight and planning review." - ) - + expected_source_head = validate_commit(expected_source_head) repo = repo.resolve() + version = validate_version(version) + worktree = worktree.expanduser().resolve() + _require_repository_root(repo) + _require_clean_main(repo) + _require_source_head(repo, expected_source_head) + _require_registered_detached_worktree(repo, worktree, expected_base) + env = _release_environment() - branch = release_input.branch - base_commit = release_input.base_commit - run_command(repo, ["git", "switch", "-c", branch], env=env, announce=True) - replace_project_version(repo, version) - run_command(repo, ["make", "sync"], env=env, announce=True) + branch = f"release/v{version}" + _require_branch_absent(repo, branch) run_command( repo, + [ + "git", + "fetch", + "origin", + "refs/heads/main:refs/remotes/origin/main", + "--prune", + ], + env=env, + announce=True, + ) + base_commit = git(repo, "rev-parse", "origin/main").stdout.strip() + if base_commit != expected_base: + raise ReleasePreparationError( + f"Preflight reviewed {expected_base}, but refreshed origin/main is {base_commit}; " + "leave the detached worktree intact and rerun preflight plus both readiness gates." + ) + _require_clean_main(repo) + _require_source_head(repo, expected_source_head) + _require_branch_absent(repo, branch) + _require_registered_detached_worktree(repo, worktree, expected_base) + if project_version(worktree) == version: + raise ReleasePreparationError(f"Project version is already {version}.") + + run_command(worktree, ["git", "switch", "-c", branch], env=env, announce=True) + replace_project_version(worktree, version) + run_command(worktree, ["make", "sync"], env=env, announce=True) + run_command( + worktree, ["make", "update-released-api-contract", f"VERSION={version}"], env=env, announce=True, ) run_command( - repo, + worktree, ["make", "check-released-api-contract", f"VERSION={version}"], env=env, announce=True, ) - changed_paths = _validate_prepared_files(repo, version, base_commit) + changed_paths = _validate_prepared_files(worktree, version, base_commit) + _require_clean_main(repo) + _require_source_head(repo, expected_source_head) return PreparedCandidate( base_commit=base_commit, branch=branch, changed_paths=changed_paths, + source_commit=expected_source_head, version=version, + worktree=worktree, ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Preflight or materialize a local release candidate from exact origin/main." + description="Preflight or materialize an isolated release candidate from exact origin/main." ) subparsers = parser.add_subparsers(dest="phase", required=True) preflight_parser = subparsers.add_parser( @@ -367,6 +495,12 @@ def parse_args() -> argparse.Namespace: required=True, help="Release version without a leading v, for example 0.20.1.", ) + preflight_parser.add_argument( + "--worktree-root", + type=Path, + default=Path(os.environ.get("CODEX_WORKTREE_ROOT", Path.home() / ".codex/worktrees")), + help="Directory under which to create a unique detached release worktree.", + ) materialize_parser = subparsers.add_parser( "materialize", help="Create an uncommitted candidate from the reviewed preflight commit.", @@ -381,6 +515,17 @@ def parse_args() -> argparse.Namespace: required=True, help="Exact 40-character origin/main commit approved by release preflight.", ) + materialize_parser.add_argument( + "--expected-source-head", + required=True, + help="Exact source-checkout HEAD recorded by release preflight.", + ) + materialize_parser.add_argument( + "--worktree", + type=Path, + required=True, + help="Detached worktree created by the matching preflight.", + ) return parser.parse_args() @@ -388,25 +533,35 @@ def main() -> int: args = parse_args() try: if args.phase == "preflight": - release_input = preflight(ROOT, args.version) + release_input = preflight(ROOT, args.version, args.worktree_root) else: - candidate = materialize(ROOT, args.version, args.expected_base) + candidate = materialize( + ROOT, + args.version, + args.expected_base, + args.expected_source_head, + args.worktree, + ) except (OSError, ReleasePreparationError, tomllib.TOMLDecodeError, json.JSONDecodeError) as exc: print(f"Release preparation failed: {exc}", file=sys.stderr) return 1 if args.phase == "preflight": - print("Release preflight passed on clean main without creating a branch.") + print("Release preflight passed without changing the source main checkout.") print(f"Base commit: {release_input.base_commit}") + print(f"Source commit: {release_input.source_commit}") print(f"Planned branch: {release_input.branch}") print(f"Version: {release_input.version}") - print("Run the prospective contract and planning-review gates against this commit.") + print(f"Worktree: {release_input.worktree}") + print("Run both readiness gates from this detached worktree against the base commit.") return 0 - print("Release candidate prepared locally and left uncommitted.") + print("Release candidate prepared in its dedicated worktree and left uncommitted.") print(f"Base commit: {candidate.base_commit}") + print(f"Source commit: {candidate.source_commit}") print(f"Branch: {candidate.branch}") print(f"Version: {candidate.version}") + print(f"Worktree: {candidate.worktree}") print("Changed paths:") for path in candidate.changed_paths: print(f"- {path}") diff --git a/.agents/skills/release-candidate-prep/scripts/test_prepare.py b/.agents/skills/release-candidate-prep/scripts/test_prepare.py index bca5645208..1fa8591d18 100755 --- a/.agents/skills/release-candidate-prep/scripts/test_prepare.py +++ b/.agents/skills/release-candidate-prep/scripts/test_prepare.py @@ -14,11 +14,11 @@ import prepare -def run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: +def run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run( list(args), cwd=repo, - check=True, + check=check, capture_output=True, text=True, ) @@ -31,6 +31,7 @@ def __init__(self, root: Path) -> None: self.root = root self.repo = root / "repo" self.origin = root / "origin.git" + self.worktree_root = root / "worktrees" self.repo.mkdir() run(self.repo, "git", "init", "--initial-branch=main") run(self.repo, "git", "config", "user.name", "Release Test") @@ -177,7 +178,12 @@ def test_preflight_leaves_clean_main_without_creating_branch(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) - release_input = prepare.preflight(fixture.repo, "0.20.0") + source_head = fixture.base_commit + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) self.assertEqual(release_input.base_commit, fixture.base_commit) self.assertEqual(release_input.branch, "release/v0.20.0") @@ -186,6 +192,30 @@ def test_preflight_leaves_clean_main_without_creating_branch(self) -> None: "main", ) self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") + self.assertEqual( + run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(), + source_head, + ) + self.assertEqual( + run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(), + fixture.base_commit, + ) + self.assertEqual( + run( + release_input.worktree, + "git", + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + check=False, + ).returncode, + 1, + ) + self.assertEqual( + run(release_input.worktree, "git", "status", "--porcelain").stdout, + "", + ) self.assertNotIn( "release/v0.20.0", run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(), @@ -194,7 +224,11 @@ def test_preflight_leaves_clean_main_without_creating_branch(self) -> None: def test_materialize_creates_branch_and_exact_uncommitted_manifest(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) - release_input = prepare.preflight(fixture.repo, "0.20.0") + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) with mock.patch.dict( os.environ, @@ -208,19 +242,32 @@ def test_materialize_creates_branch_and_exact_uncommitted_manifest(self) -> None fixture.repo, "0.20.0", release_input.base_commit, + release_input.source_commit, + release_input.worktree, ) self.assertEqual(candidate.base_commit, fixture.base_commit) self.assertEqual(candidate.branch, "release/v0.20.0") self.assertEqual(set(candidate.changed_paths), prepare.RELEASE_PATHS) self.assertEqual( - run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + run(release_input.worktree, "git", "branch", "--show-current").stdout.strip(), "release/v0.20.0", ) self.assertEqual( - run(fixture.repo, "git", "rev-list", "--count", "origin/main..HEAD").stdout.strip(), + run( + release_input.worktree, + "git", + "rev-list", + "--count", + "origin/main..HEAD", + ).stdout.strip(), "0", ) + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "main", + ) + self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") remote_heads = run( fixture.repo, "git", @@ -231,7 +278,7 @@ def test_materialize_creates_branch_and_exact_uncommitted_manifest(self) -> None ).stdout self.assertEqual(remote_heads, "") contract = json.loads( - (fixture.repo / "tests/fixtures/released_api_contract.json").read_text() + (release_input.worktree / "tests/fixtures/released_api_contract.json").read_text() ) self.assertEqual(contract["baseline"], "v0.20.0") self.assertEqual(contract["baseline_commit"], fixture.base_commit) @@ -245,26 +292,36 @@ def test_preflight_rejects_dirty_main_without_creating_branch(self) -> None: prepare.ReleasePreparationError, "clean working tree", ): - prepare.preflight(fixture.repo, "0.20.0") + prepare.preflight(fixture.repo, "0.20.0", fixture.worktree_root) self.assertEqual( run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), "main", ) - def test_preflight_fast_forwards_to_refreshed_origin_main(self) -> None: + def test_preflight_keeps_source_main_and_checks_out_refreshed_origin_in_worktree(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) + source_head = fixture.base_commit refreshed_base = fixture.advance_origin() - release_input = prepare.preflight(fixture.repo, "0.20.0") + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) self.assertEqual(release_input.base_commit, refreshed_base) self.assertEqual( run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(), + source_head, + ) + self.assertFalse((fixture.repo / "new-source.txt").exists()) + self.assertEqual( + run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(), refreshed_base, ) - self.assertTrue((fixture.repo / "new-source.txt").is_file()) + self.assertTrue((release_input.worktree / "new-source.txt").is_file()) def test_preflight_rejects_existing_remote_release_branch(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -281,7 +338,7 @@ def test_preflight_rejects_existing_remote_release_branch(self) -> None: prepare.ReleasePreparationError, "Remote branch 'release/v0.20.0' already exists", ): - prepare.preflight(fixture.repo, "0.20.0") + prepare.preflight(fixture.repo, "0.20.0", fixture.worktree_root) self.assertEqual( run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), @@ -291,7 +348,11 @@ def test_preflight_rejects_existing_remote_release_branch(self) -> None: def test_materialize_rejects_stale_preflight_before_creating_branch(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) - release_input = prepare.preflight(fixture.repo, "0.20.0") + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) refreshed_base = fixture.advance_origin() with self.assertRaisesRegex( @@ -299,7 +360,13 @@ def test_materialize_rejects_stale_preflight_before_creating_branch(self) -> Non f"Preflight reviewed {release_input.base_commit}, but refreshed origin/main is " f"{refreshed_base}", ): - prepare.materialize(fixture.repo, "0.20.0", release_input.base_commit) + prepare.materialize( + fixture.repo, + "0.20.0", + release_input.base_commit, + release_input.source_commit, + release_input.worktree, + ) self.assertEqual( run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), @@ -307,9 +374,158 @@ def test_materialize_rejects_stale_preflight_before_creating_branch(self) -> Non ) self.assertEqual( run(fixture.repo, "git", "rev-parse", "HEAD").stdout.strip(), - refreshed_base, + fixture.base_commit, + ) + self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") + self.assertEqual( + run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(), + fixture.base_commit, + ) + self.assertEqual( + run( + release_input.worktree, + "git", + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + check=False, + ).returncode, + 1, + ) + self.assertNotIn( + "release/v0.20.0", + run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(), + ) + + def test_preflight_chooses_a_new_path_without_reusing_a_worktree_collision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + collision = fixture.worktree_root / f"{fixture.repo.name}-release-v0.20.0" + collision.parent.mkdir(parents=True) + run( + fixture.repo, + "git", + "worktree", + "add", + "--detach", + str(collision), + fixture.base_commit, + ) + + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) + + self.assertEqual( + release_input.worktree, + (fixture.worktree_root / f"{fixture.repo.name}-release-v0.20.0-2").resolve(), + ) + self.assertTrue(collision.is_dir()) + self.assertEqual( + run(collision, "git", "rev-parse", "HEAD").stdout.strip(), + fixture.base_commit, + ) + + def test_preflight_rejects_a_worktree_root_inside_the_source_checkout(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + nested_root = fixture.repo / ".release-worktrees" + + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + "must be outside the source checkout", + ): + prepare.preflight(fixture.repo, "0.20.0", nested_root) + + self.assertFalse(nested_root.exists()) + self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") + + def test_materialize_failure_preserves_worktree_evidence_and_source_checkout(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) + real_run_command = prepare.run_command + + def fail_sync( + repo: Path, + args: list[str] | tuple[str, ...], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + if list(args) == ["make", "sync"]: + raise prepare.ReleasePreparationError("simulated make sync failure") + return real_run_command(repo, args, **kwargs) + + with mock.patch.object(prepare, "run_command", side_effect=fail_sync): + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + "simulated make sync failure", + ): + prepare.materialize( + fixture.repo, + "0.20.0", + release_input.base_commit, + release_input.source_commit, + release_input.worktree, + ) + + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "main", ) self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") + self.assertEqual( + run(release_input.worktree, "git", "branch", "--show-current").stdout.strip(), + "release/v0.20.0", + ) + self.assertIn( + "pyproject.toml", + run(release_input.worktree, "git", "status", "--porcelain").stdout, + ) + self.assertEqual(prepare.project_version(release_input.worktree), "0.20.0") + + def test_materialize_rejects_a_source_checkout_head_change(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) + (fixture.repo / "local-only.txt").write_text("local\n", encoding="utf-8") + run(fixture.repo, "git", "add", "local-only.txt") + run(fixture.repo, "git", "commit", "-m", "Move source checkout") + + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + "Source checkout HEAD changed", + ): + prepare.materialize( + fixture.repo, + "0.20.0", + release_input.base_commit, + release_input.source_commit, + release_input.worktree, + ) + + self.assertEqual( + run( + release_input.worktree, + "git", + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + check=False, + ).returncode, + 1, + ) self.assertNotIn( "release/v0.20.0", run(fixture.repo, "git", "branch", "--format=%(refname:short)").stdout.splitlines(), diff --git a/AGENTS.md b/AGENTS.md index 52a63714d4..eb0383d5ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,9 +57,9 @@ Producing the PR draft block is part of the local final handoff. It is required #### `$release-candidate-prep` -Use `$release-candidate-prep` only when the user explicitly invokes it with a release version. It fast-forwards a clean local `main`, creates `release/v`, updates `pyproject.toml` and `uv.lock`, freezes and checks `tests/fixtures/released_api_contract.json`, creates one local release commit, invokes `$final-release-review` against that commit, and returns the fixed release PR title plus the complete final-candidate report as the PR description. +Use `$release-candidate-prep` only when the user explicitly invokes it with a release version. It keeps the user's clean `main` checkout unchanged, creates a dedicated detached worktree at refreshed `origin/main`, runs the readiness gates there, creates `release/v` in that worktree, updates `pyproject.toml` and `uv.lock`, freezes and checks `tests/fixtures/released_api_contract.json`, and creates one local release commit. It invokes `$final-release-review` as the controlling checker against both the pre-release source and the materialized candidate; a blocked release call stops the workflow, while a green final-candidate report becomes the release-specific PR description. -The skill replaces the former GitHub Actions release-PR creator. It must never push, open or edit a pull request, create a release, or mutate any other GitHub state. Release tag creation and PyPI publication remain owned by their post-merge workflows. The release commit may contain only `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`; all runtime and documentation changes must land on `main` before preparation. +The skill replaces the former GitHub Actions release-PR creator. It must never push, open or edit a pull request, create a release, or mutate any other GitHub state. It leaves the dedicated worktree in place for green handoff, blocked review, or recoverable failure. Release tag creation and PyPI publication remain owned by their post-merge workflows. The release commit may contain only `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`; all runtime and documentation changes must land on `main` before preparation. ### Work Status Reporting From b3427dcf0dcaf20de455af6f13bc5dd0fe0e704d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 19:46:08 +0900 Subject: [PATCH 271/473] fix: bootstrap release prep dependencies --- .agents/skills/release-candidate-prep/SKILL.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.agents/skills/release-candidate-prep/SKILL.md b/.agents/skills/release-candidate-prep/SKILL.md index 83c29c030c..a9d6d88747 100644 --- a/.agents/skills/release-candidate-prep/SKILL.md +++ b/.agents/skills/release-candidate-prep/SKILL.md @@ -13,7 +13,7 @@ Use this skill only when the user explicitly invokes `$release-candidate-prep` a - Keep the user's source checkout on its existing clean `main` commit. Do not fast-forward it, switch its branch, or materialize release files there. Leave the dedicated release worktree in place for green handoff, blocked review, or recoverable failure. - Never push, open or edit a pull request, add labels or milestones, create a release, or otherwise mutate GitHub. Never run `gh`. - Own exactly `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`. Runtime, documentation, workflow, or other repository changes must land on `main` before release preparation. -- Do not stash, reset, delete, overwrite, remove an existing worktree, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty or is not on `main`, the dedicated worktree is not clean and detached at refreshed `origin/main`, the release branch collides locally or remotely, the prospective packaged-contract gate fails, the planning review blocks, or `origin/main` advances after those gates run. +- Do not stash, reset, delete, overwrite, remove an existing worktree, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty or is not on `main`, the dedicated worktree is not clean and detached at refreshed `origin/main`, the release branch collides locally or remotely, the prospective packaged-contract gate fails after the allowed dependency-bootstrap recovery, the planning review blocks, or `origin/main` advances after those gates run. - Treat `$final-release-review` as the controlling release checker, not only as a report generator. Its planning gate must be green before branch creation, and its final-candidate gate must inspect the materialized worktree and be green before PR-ready handoff. Any candidate content, commit, or base change invalidates the previous green result. - Remove inherited `OPENAI_API_KEY` from every child command. Release preparation does not require a live OpenAI API request. - Stop after the local commit, final release review, and copy-ready handoff. The user owns the push and pull-request creation. @@ -46,6 +46,14 @@ Record the base commit as ``, the source-checkout commit as `` to remain clean except for ignored environment or `.tmp` output. If synchronization changes a tracked or untracked repository path, stop with that evidence instead of treating the changed checkout as the reviewed source. + Run both gates against exact `` before materializing any candidate: 1. Start the prospective packaged-contract gate from ``: @@ -58,7 +66,9 @@ Run both gates against exact `` before materializing any candida These gates are independent consumers of the same clean source commit. Start the prospective command as a long-running session and perform the read-only planning review while it runs when the execution environment supports overlap. Wait for both results before continuing. If concurrency is unavailable, run them sequentially with the prospective gate first; correctness must not depend on overlap. -If either gate fails or blocks, stop without creating `release/v`, leave the source checkout unchanged, retain the detached worktree, and report its path plus the prospective command failure or the planning review's unblock checklist. A failed prospective gate should direct maintainers to fix the public surface or `tests/fixtures/released_api_contract_policy.json` on `main`. A blocked planning review should direct runtime or documentation-timing follow-up to `main` as applicable. Do not continue merely because the review produced a well-formed report. +If the prospective command reports only that optional dependency modules are unavailable, treat the result as a recoverable environment-bootstrap failure rather than a contract-gate decision. Do not ask the user to choose between synchronization and fixing `main`. Rerun the credential-free `make sync` command, require the worktree to remain clean, and retry the prospective command exactly once. Do not use this recovery for a contract mismatch, packaging or runtime compatibility failure, changed repository path, or any other substantive gate failure. + +If dependency synchronization still fails, the prospective command still reports unavailable dependency modules after the single retry, or either gate otherwise fails or blocks, stop without creating `release/v`, leave the source checkout unchanged, retain the detached worktree, and report its path plus the exact failure or the planning review's unblock checklist. Classify a dependency installation failure as environment or dependency setup, a contract-generation mismatch as public-surface or `tests/fixtures/released_api_contract_policy.json` work on `main`, and a packaged compatibility failure by its actual failing source, packaging, platform, or runtime path. A blocked planning review should direct runtime or documentation-timing follow-up to `main` as applicable. Do not continue merely because the review produced a well-formed report. After both gates pass, require all of the following before materialization: @@ -159,7 +169,8 @@ Also report the dedicated worktree path, local branch, commit SHA, parent `origi ## Failure behavior - Preflight or worktree creation failure: leave the source checkout unchanged and do not delete or reuse any colliding worktree. -- Prospective-contract failure or blocked planning review: retain the detached worktree, do not create the release branch, and return the exact failure or unblock checklist. +- Dependency-bootstrap failure: retry unavailable optional dependency setup only as described in the readiness-gate procedure, then retain the detached worktree and return the exact failure if recovery does not succeed. +- Prospective-contract failure after the allowed dependency-bootstrap recovery or blocked planning review: retain the detached worktree, do not create the release branch, and return the exact failure or unblock checklist. - Materialization failure: retain the worktree and any branch or uncommitted evidence exactly as left by the failing command. - Blocked final-candidate review: retain the single release commit and worktree, do not call the candidate PR-ready, and return the checker-derived unblock checklist. - Freshness conflict or unexpected changed path: stop with recoverable worktree evidence rather than forcing a resolution or expanding the release manifest. From ea5653a4167b9dce3c94876a53b69a70b2601177 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 21:35:41 +0900 Subject: [PATCH 272/473] fix: make release API contract promotion policy-driven (#4349) --- .../scripts/update_released_api_contract.py | 6 +- integration_tests/_contract_support.py | 416 +++++++++++- tests/README.md | 2 +- .../released_api_contract_policy.json | 72 +- tests/test_released_api_contract.py | 640 +++++++++++++++++- 5 files changed, 1086 insertions(+), 50 deletions(-) diff --git a/.github/scripts/update_released_api_contract.py b/.github/scripts/update_released_api_contract.py index b59ecb90fc..a1b2f4c5db 100644 --- a/.github/scripts/update_released_api_contract.py +++ b/.github/scripts/update_released_api_contract.py @@ -95,7 +95,7 @@ def main() -> int: current, baseline=f"v{version}", baseline_commit=_head_commit(), - submodule_export_policy=policy.modules, + release_policy=policy, ) except ValueError as error: raise SystemExit(str(error)) from None @@ -125,8 +125,8 @@ def main() -> int: print(f"Added exports: {sorted(current_exports - previous_exports)!r}") print(f"Removed exports: {sorted(previous_exports - current_exports)!r}") print( - "Review shipped example imports and update canonical_imports or public_modules " - "when the release adds an intended submodule path." + "Review shipped example imports and update released_api_contract_policy.json when " + "the release adds canonical imports, public properties, or public modules." ) return 0 diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 87cd27fe9f..6bf9331faa 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -31,6 +31,8 @@ def is_supported_on_current_platform(self) -> bool: class SubmoduleExportPolicy: modules: dict[str, dict[str, dict[str, str]]] dependency_installations: tuple[OptionalDependencyInstallation, ...] + canonical_imports: tuple[dict[str, str], ...] = () + public_properties: tuple[dict[str, Any], ...] = () def load_api_contract(path: Path) -> dict[str, Any]: @@ -43,7 +45,9 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: value = json.loads(path.read_text(encoding="utf-8")) if not isinstance(value, dict): raise ValueError("submodule export policy must be an object") - unknown_top_level_fields = sorted(set(value) - {"modules", "optional_dependencies"}) + unknown_top_level_fields = sorted( + set(value) - {"canonical_imports", "modules", "optional_dependencies", "public_properties"} + ) if unknown_top_level_fields: raise ValueError( f"submodule export policy has unknown fields: {unknown_top_level_fields!r}" @@ -147,9 +151,82 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: dependency_installations, key=lambda installation: installation.dependency_module ) ), + canonical_imports=_canonical_import_policy(value.get("canonical_imports", [])), + public_properties=_public_property_policy(value.get("public_properties", [])), ) +def _canonical_import_policy(value: object) -> tuple[dict[str, str], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy canonical_imports must be a list") + required_fields = {"canonical_module", "canonical_name", "module", "name"} + entries: list[dict[str, str]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if not isinstance(entry, dict) or set(entry) != required_fields: + raise ValueError( + "submodule export policy canonical_imports entries must contain exactly " + "canonical_module, canonical_name, module, and name" + ) + if not all(type(entry[field]) is str and entry[field] for field in required_fields): + raise ValueError( + "submodule export policy canonical_imports values must be non-empty strings" + ) + identity = (entry["module"], entry["name"]) + if identity in identities: + raise ValueError( + "submodule export policy canonical_imports must not repeat " + f"{entry['module']}.{entry['name']}" + ) + identities.add(identity) + entries.append({field: entry[field] for field in sorted(required_fields)}) + return tuple(entries) + + +def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_properties must be a list") + required_fields = {"class_name", "module", "names"} + entries: list[dict[str, Any]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if not isinstance(entry, dict) or set(entry) != required_fields: + raise ValueError( + "submodule export policy public_properties entries must contain exactly " + "class_name, module, and names" + ) + module_name = entry["module"] + class_name = entry["class_name"] + names = entry["names"] + if type(module_name) is not str or not module_name: + raise ValueError( + "submodule export policy public_properties module must be a non-empty string" + ) + if type(class_name) is not str or not class_name: + raise ValueError( + "submodule export policy public_properties class_name must be a non-empty string" + ) + if ( + not isinstance(names, list) + or not names + or not all(type(name) is str and name for name in names) + or len(names) != len(set(names)) + ): + raise ValueError( + "submodule export policy public_properties names must be a non-empty list of " + "unique non-empty strings" + ) + identity = (module_name, class_name) + if identity in identities: + raise ValueError( + "submodule export policy public_properties must not repeat " + f"{module_name}.{class_name}" + ) + identities.add(identity) + entries.append({"class_name": class_name, "module": module_name, "names": list(names)}) + return tuple(entries) + + def _add_legacy_literal_types(value: object) -> None: if isinstance(value, dict): if value.get("kind") == "literal" and "value" in value and "type" not in value: @@ -505,13 +582,134 @@ def _callable_contract(value: Callable[..., Any]) -> dict[str, Any]: return contract +def _merge_canonical_imports( + existing: Iterable[Mapping[str, str]], promoted: Iterable[Mapping[str, str]] +) -> list[dict[str, str]]: + result = [dict(entry) for entry in existing] + by_identity = {(entry["module"], entry["name"]): entry for entry in result} + for entry_value in promoted: + entry = dict(entry_value) + identity = (entry["module"], entry["name"]) + previous = by_identity.get(identity) + if previous is not None: + if previous != entry: + raise ValueError( + "release policy canonical import conflicts with the released contract for " + f"{entry['module']}.{entry['name']}" + ) + continue + result.append(entry) + by_identity[identity] = entry + return result + + +def _merge_public_properties( + existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] +) -> list[dict[str, Any]]: + result = [deepcopy(dict(entry)) for entry in existing] + by_identity = {(entry["module"], entry["class_name"]): entry for entry in result} + for entry_value in promoted: + entry = deepcopy(dict(entry_value)) + identity = (entry["module"], entry["class_name"]) + previous = by_identity.get(identity) + if previous is None: + result.append(entry) + by_identity[identity] = entry + continue + previous_names = previous["names"] + for name in entry["names"]: + if name not in previous_names: + previous_names.append(name) + return result + + +def _optional_dependency_unsupported_platforms( + contract: Mapping[str, Any], +) -> dict[str, tuple[str, ...]]: + value = contract.get("optional_dependency_unsupported_platforms", {}) + if not isinstance(value, dict): + raise ValueError("optional_dependency_unsupported_platforms must be an object") + result: dict[str, tuple[str, ...]] = {} + for dependency_module, platforms in value.items(): + if type(dependency_module) is not str or not dependency_module: + raise ValueError( + "optional_dependency_unsupported_platforms keys must be non-empty strings" + ) + if ( + not isinstance(platforms, list) + or not all(type(platform) is str and platform for platform in platforms) + or len(platforms) != len(set(platforms)) + ): + raise ValueError( + "optional_dependency_unsupported_platforms values must be lists of unique " + "non-empty strings" + ) + result[dependency_module] = tuple(platforms) + return result + + +def _optional_dependency_is_available_for_contract( + dependency_module: str, + unsupported_platforms: Mapping[str, tuple[str, ...]], +) -> bool: + return not _optional_dependency_is_unsupported_for_contract( + dependency_module, unsupported_platforms + ) and _optional_dependency_is_available(dependency_module) + + +def _optional_dependency_is_unsupported_for_contract( + dependency_module: str, + unsupported_platforms: Mapping[str, tuple[str, ...]], +) -> bool: + return sys.platform in unsupported_platforms.get(dependency_module, ()) + + +def _optional_dependency_for_binding( + contract: Mapping[str, Any], module_name: str, binding_name: str +) -> str | None: + return _optional_dependency_for_binding_in_modules( + contract.get("required_submodule_exports", {}), module_name, binding_name + ) + + +def _optional_dependency_for_binding_in_modules( + modules: Mapping[str, Any], module_name: str, binding_name: str +) -> str | None: + module_contract = modules.get(module_name, {}) + for field_name in ("optional_bindings", "optional_exports"): + dependency_module = module_contract.get(field_name, {}).get(binding_name) + if dependency_module is not None: + return cast(str, dependency_module) + return None + + +def _preserve_released_callable_for_promotion( + contract: Mapping[str, Any], + callables: dict[str, Any], + qualified_name: str, + *, + fail_if_missing: bool, + unavailable_reason: str, +) -> None: + released_callable = contract["callables"].get(qualified_name) + if released_callable is None: + if not fail_if_missing: + return + raise ValueError( + f"Cannot promote new canonical callable {qualified_name} because " + f"{unavailable_reason}. Ensure the binding is available and exposes an inspectable " + "signature on the release preparation host." + ) + callables[qualified_name] = deepcopy(released_callable) + + def build_released_api_contract( contract: dict[str, Any], *, baseline: str, baseline_commit: str, agents_module: Any | None = None, - submodule_export_policy: Mapping[str, Mapping[str, Mapping[str, str]]] | None = None, + release_policy: SubmoduleExportPolicy | None = None, ) -> dict[str, Any]: """Build the next rolling release contract from the current public surface.""" agents = agents_module or importlib.import_module("agents") @@ -550,16 +748,86 @@ def build_released_api_contract( if should_track: callables[name] = _callable_contract(value) + canonical_imports = _merge_canonical_imports( + contract["canonical_imports"], + release_policy.canonical_imports if release_policy is not None else (), + ) + policy_unsupported_platforms = ( + { + installation.dependency_module: installation.unsupported_platforms + for installation in release_policy.dependency_installations + if installation.unsupported_platforms + } + if release_policy is not None + else {} + ) top_level_callable_ids = { id(getattr(agents, name)) for name in callables if not name.startswith("agents.") } - for entry in contract["canonical_imports"]: + for entry in canonical_imports: module_name = entry["module"] if module_name == "agents": continue qualified_name = f"{module_name}.{entry['name']}" - module = _import_contract_module(module_name, agents_module) - value = getattr(module, entry["name"]) + is_new_canonical_import = entry not in contract["canonical_imports"] + optional_dependency = ( + _optional_dependency_for_binding_in_modules( + release_policy.modules, module_name, entry["name"] + ) + if release_policy is not None + else None + ) + if optional_dependency is not None and not _optional_dependency_is_available_for_contract( + optional_dependency, policy_unsupported_platforms + ): + if _optional_dependency_is_unsupported_for_contract( + optional_dependency, policy_unsupported_platforms + ): + _preserve_released_callable_for_promotion( + contract, + callables, + qualified_name, + fail_if_missing=is_new_canonical_import, + unavailable_reason=( + f"optional dependency {optional_dependency!r} is unsupported on " + f"{sys.platform!r}" + ), + ) + continue + try: + module = _import_contract_module(module_name, agents_module) + except Exception as error: + if _matches_platform_import_error(contract, module_name, error): + _preserve_released_callable_for_promotion( + contract, + callables, + qualified_name, + fail_if_missing=is_new_canonical_import, + unavailable_reason=( + f"module {module_name!r} has a declared import error on {sys.platform!r}" + ), + ) + continue + raise + value = getattr(module, entry["name"], None) + if value is None: + try: + _import_contract_module(entry["canonical_module"], agents_module) + except Exception as error: + if _matches_platform_import_error(contract, entry["canonical_module"], error): + _preserve_released_callable_for_promotion( + contract, + callables, + qualified_name, + fail_if_missing=is_new_canonical_import, + unavailable_reason=( + f"canonical module {entry['canonical_module']!r} has a declared " + f"import error on {sys.platform!r}" + ), + ) + continue + raise + continue if id(value) in top_level_callable_ids: continue kind = _callable_kind(value) @@ -567,7 +835,14 @@ def build_released_api_contract( continue try: _signature(value) - except (TypeError, ValueError): + except (TypeError, ValueError) as error: + _preserve_released_callable_for_promotion( + contract, + callables, + qualified_name, + fail_if_missing=is_new_canonical_import, + unavailable_reason=f"its signature cannot be inspected: {error!r}", + ) continue callables[qualified_name] = _callable_contract(value) @@ -575,8 +850,19 @@ def build_released_api_contract( updated["baseline"] = baseline updated["required_top_level_exports"] = ordered_exports updated["callables"] = callables + updated["canonical_imports"] = canonical_imports + updated["public_properties"] = _merge_public_properties( + contract.get("public_properties", []), + release_policy.public_properties if release_policy is not None else (), + ) + if release_policy is not None: + updated["optional_dependency_unsupported_platforms"] = { + dependency_module: list(platforms) + for dependency_module, platforms in policy_unsupported_platforms.items() + } excluded_submodule_exports = set(contract.get("submodule_export_exclusions", [])) public_modules = list(contract["public_modules"]) + submodule_export_policy = release_policy.modules if release_policy is not None else None if submodule_export_policy is not None: invalid_policy_modules = sorted( module_name @@ -598,7 +884,10 @@ def build_released_api_contract( for dependency_module in _optional_dependency_modules( dict(module_policy.get(field_name, {})), field_name=field_name ).values() - if not _optional_dependency_is_available(dependency_module) + if not _optional_dependency_is_unsupported_for_contract( + dependency_module, policy_unsupported_platforms + ) + and not _optional_dependency_is_available(dependency_module) } ) if unavailable_policy_dependencies: @@ -626,10 +915,21 @@ def build_released_api_contract( module_policy = contract.get("required_submodule_exports", {}).get(module_name, {}) else: module_policy = submodule_export_policy.get(module_name, {}) + allowed_missing_optional_exports = { + name + for name, dependency_module in _optional_dependency_modules( + dict(module_policy.get("optional_exports", {})), + field_name="optional_exports", + ).items() + if _optional_dependency_is_unsupported_for_contract( + dependency_module, policy_unsupported_platforms + ) + } module_contract = _submodule_export_contract( module, optional_bindings=module_policy.get("optional_bindings", {}), optional_exports=module_policy.get("optional_exports", {}), + allowed_missing_optional_exports=allowed_missing_optional_exports, ) if module_contract is not None: required_submodule_exports[module_name] = module_contract @@ -643,6 +943,7 @@ def build_released_api_contract( surface_keys = ( "canonical_imports", "callables", + "optional_dependency_unsupported_platforms", "platform_import_errors", "public_properties", "public_modules", @@ -738,6 +1039,7 @@ def _submodule_export_contract( *, optional_bindings: Mapping[str, str] | None = None, optional_exports: Mapping[str, str] | None = None, + allowed_missing_optional_exports: Iterable[str] = (), ) -> dict[str, Any] | None: exports = getattr(module, "__all__", None) if exports is None: @@ -755,11 +1057,19 @@ def _submodule_export_contract( ) optional_binding_names = set(optional_binding_modules) optional_export_names = set(optional_export_modules) - unknown_optional_names = sorted((optional_binding_names | optional_export_names) - set(names)) + allowed_missing_names = set(allowed_missing_optional_exports) + unknown_optional_names = sorted( + (optional_binding_names | optional_export_names) - set(names) - allowed_missing_names + ) if unknown_optional_names: raise ValueError( f"optional submodule bindings are not exported: {unknown_optional_names!r}" ) + names.extend( + name + for name in optional_export_modules + if name in allowed_missing_names and name not in names + ) return { "names": names, "optional_bindings": { @@ -816,6 +1126,12 @@ def validate_released_api_contract( agents = agents_module or importlib.import_module("agents") errors: list[str] = [] + try: + unsupported_platforms = _optional_dependency_unsupported_platforms(contract) + except ValueError as error: + errors.append(f"Invalid released optional dependency platform declarations: {error}") + unsupported_platforms = {} + errors.extend(_validate_public_property_contract(contract, agents_module)) missing_exports = sorted(set(contract["required_top_level_exports"]) - set(agents.__all__)) @@ -870,15 +1186,33 @@ def validate_released_api_contract( ) continue try: + unsupported_optional_exports = { + name + for name, dependency_module in optional_exports.items() + if _optional_dependency_is_unsupported_for_contract( + dependency_module, unsupported_platforms + ) + } + unsupported_optional_bindings = { + name + for name, dependency_module in (optional_bindings | optional_exports).items() + if _optional_dependency_is_unsupported_for_contract( + dependency_module, unsupported_platforms + ) + } unavailable_optional_exports = { name for name, dependency_module in optional_exports.items() - if not _optional_dependency_is_available(dependency_module) + if not _optional_dependency_is_available_for_contract( + dependency_module, unsupported_platforms + ) } unavailable_optional_bindings = { name for name, dependency_module in (optional_bindings | optional_exports).items() - if not _optional_dependency_is_available(dependency_module) + if not _optional_dependency_is_available_for_contract( + dependency_module, unsupported_platforms + ) } except (AttributeError, ImportError, ValueError) as error: errors.append( @@ -890,17 +1224,25 @@ def validate_released_api_contract( try: getattr(module, name) except (AttributeError, ImportError): - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ but its binding is unavailable; " - "declare it in optional_bindings instead of optional_exports" - ) + if name in unsupported_optional_exports: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ on an unsupported platform but its " + "binding is unavailable" + ) + else: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ but its binding is unavailable; " + "declare it in optional_bindings instead of optional_exports" + ) else: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ and its binding resolves; remove its " - "optional declaration or correct its dependency module" - ) + if name not in unsupported_optional_exports: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ and its binding resolves; remove its " + "optional declaration or correct its dependency module" + ) binding_only_names = set(optional_bindings) - set(optional_exports) for name in sorted(unavailable_optional_bindings & binding_only_names): if name not in current_names: @@ -913,13 +1255,19 @@ def validate_released_api_contract( try: getattr(module, name) except (AttributeError, ImportError): - pass + if name in unsupported_optional_bindings: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ on an unsupported platform but its " + "binding is unavailable" + ) else: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ and its binding resolves; remove its " - "optional declaration or correct its dependency module" - ) + if name not in unsupported_optional_bindings: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ and its binding resolves; remove its " + "optional declaration or correct its dependency module" + ) missing_names = sorted( set(released["names"]) - unavailable_optional_exports - current_names ) @@ -939,6 +1287,13 @@ def validate_released_api_contract( ) for entry in contract["canonical_imports"]: + optional_dependency = _optional_dependency_for_binding( + contract, entry["module"], entry["name"] + ) + if optional_dependency is not None and not _optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ): + continue try: module = _import_contract_module(entry["module"], agents_module) except Exception as error: @@ -967,6 +1322,15 @@ def validate_released_api_contract( for name, released in contract["callables"].items(): if name.startswith("agents."): module_name, _, binding_name = name.rpartition(".") + optional_dependency = _optional_dependency_for_binding( + contract, module_name, binding_name + ) + if optional_dependency is not None and not ( + _optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue try: module = _import_contract_module(module_name, agents_module) except Exception as error: diff --git a/tests/README.md b/tests/README.md index f1f6175203..dc4bfd6dd2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -45,7 +45,7 @@ Compare test counts, skips, warnings, assertions, and lifecycle coverage as well Release compatibility contracts that inspect the current checkout belong in `tests/` when they are deterministic and in-process. Keep their combined serial focused runtime below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. -The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable exported class or function signature and execution kind, and the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Review the resulting JSON diff and add newly documented properties to `public_properties`, newly intended submodule import paths to `canonical_imports`, and optional-dependency-free modules to `public_modules`; those policy decisions are deliberately not inferred from implementation modules. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. +The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable exported class or function signature and execution kind, and the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented properties to `public_properties`, newly intended submodule import paths to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. ## Snapshots diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index f1f38705a3..b67a9e51af 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -1,4 +1,48 @@ { + "canonical_imports": [ + { + "canonical_module": "agents", + "canonical_name": "InputItem", + "module": "agents.items", + "name": "InputItem" + }, + { + "canonical_module": "agents.extensions.sandbox.modal", + "canonical_name": "ModalSandboxClient", + "module": "agents.extensions.sandbox", + "name": "ModalSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox.modal", + "canonical_name": "ModalSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "ModalSandboxClientOptions" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopSandboxClient", + "module": "agents.extensions.sandbox", + "name": "RunloopSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "RunloopSandboxClientOptions" + }, + { + "canonical_module": "agents.extensions.sandbox.vercel", + "canonical_name": "VercelSandboxClient", + "module": "agents.extensions.sandbox", + "name": "VercelSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox.vercel", + "canonical_name": "VercelSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "VercelSandboxClientOptions" + } + ], "optional_dependencies": { "aiohttp": { "extra": "cloudflare" @@ -84,5 +128,31 @@ "VercelSandboxSessionState": "vercel" } } - } + }, + "public_properties": [ + { + "class_name": "RunState", + "module": "agents.run_state", + "names": [ + "pending_input" + ] + }, + { + "class_name": "RetryPolicyContext", + "module": "agents.retry", + "names": [ + "response_started", + "replay_safety", + "stateful_request" + ] + }, + { + "class_name": "SandboxSessionState", + "module": "agents.sandbox.session.sandbox_session_state", + "names": [ + "mount_authority_redacted", + "mount_authority_rebound" + ] + } + ] } diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index d45419d853..47d87e00bd 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -5,6 +5,7 @@ from dataclasses import asdict, dataclass from enum import Enum from importlib.metadata import version +from inspect import Signature from pathlib import Path from types import SimpleNamespace from typing import Any @@ -14,6 +15,8 @@ import integration_tests._contract_support as contract_support from integration_tests._contract_support import ( + OptionalDependencyInstallation, + SubmoduleExportPolicy, _callable_contract, _default_contract, _parameter_contract, @@ -29,6 +32,21 @@ CONTRACT = Path(__file__).parent / "fixtures" / "released_api_contract.json" +def _release_policy( + modules: dict[str, dict[str, dict[str, str]]], + *, + dependency_installations: tuple[OptionalDependencyInstallation, ...] = (), + canonical_imports: tuple[dict[str, str], ...] = (), + public_properties: tuple[dict[str, Any], ...] = (), +) -> SubmoduleExportPolicy: + return SubmoduleExportPolicy( + modules=modules, + dependency_installations=dependency_installations, + canonical_imports=canonical_imports, + public_properties=public_properties, + ) + + @pytest.mark.parametrize( ("released", "changed"), [(False, 0), (1, 1.0)], @@ -1116,7 +1134,7 @@ def import_module(module_name: str, _agents_module: object) -> object: baseline="v0.20.0", baseline_commit="b" * 40, agents_module=agents_module, - submodule_export_policy=policy, + release_policy=_release_policy(policy), ) dependency_available = False imported_submodule = core_submodule @@ -1129,6 +1147,531 @@ def import_module(module_name: str, _agents_module: object) -> object: assert validate_released_api_contract(updated, agents_module=agents_module) == [] +def test_release_contract_policy_promotes_canonical_imports_and_public_properties( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class NewPublic: + def __init__(self, value: str, optional: int = 1) -> None: + self.value = value + self.optional = optional + + @property + def status(self) -> str: + return "ready" + + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["NewPublic"], NewPublic=NewPublic) + modules = { + "agents": agents_module, + "agents.submodule": submodule, + "agents.submodule.impl": SimpleNamespace(NewPublic=NewPublic), + } + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_properties": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: modules[module_name], + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {"agents.submodule": {"optional_bindings": {}, "optional_exports": {}}}, + canonical_imports=( + { + "canonical_module": "agents.submodule.impl", + "canonical_name": "NewPublic", + "module": "agents.submodule", + "name": "NewPublic", + }, + ), + public_properties=( + { + "class_name": "NewPublic", + "module": "agents.submodule", + "names": ["status"], + }, + ), + ), + ) + + assert updated["canonical_imports"] == [ + { + "canonical_module": "agents.submodule.impl", + "canonical_name": "NewPublic", + "module": "agents.submodule", + "name": "NewPublic", + } + ] + assert updated["public_properties"] == [ + { + "class_name": "NewPublic", + "module": "agents.submodule", + "names": ["status"], + } + ] + assert updated["callables"]["agents.submodule.NewPublic"] == _callable_contract(NewPublic) + + +def test_release_contract_policy_honors_unsupported_platform_during_promotion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class PlatformBinding: + def __init__(self, value: str) -> None: + self.value = value + + agents_module = SimpleNamespace(__all__=[]) + platform_parent = SimpleNamespace(__all__=[]) + optional_parent = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": [ + "agents", + "agents.platform_parent", + "agents.platform_child", + ], + "platform_import_errors": [ + { + "module": "agents.platform_child", + "platforms": ["win32"], + "error_type": "ImportError", + "message_contains": "not supported on Windows", + } + ], + "canonical_imports": [ + { + "canonical_module": "agents.platform_child", + "canonical_name": "PlatformBinding", + "module": "agents.platform_parent", + "name": "PlatformBinding", + } + ], + "callables": { + "agents.platform_parent.PlatformBinding": _callable_contract(PlatformBinding) + }, + } + + def import_module(module_name: str, _agents_module: object) -> object: + if module_name == "agents": + return agents_module + if module_name == "agents.platform_parent": + return platform_parent + if module_name == "agents.platform_child": + raise ImportError("Platform binding is not supported on Windows") + if module_name == "agents.optional_parent": + return optional_parent + raise AssertionError(f"Unexpected import: {module_name}") + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr(contract_support, "_import_contract_module", import_module) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + { + "agents.optional_parent": { + "optional_bindings": {}, + "optional_exports": {"OptionalProvider": "optional_backend"}, + } + }, + dependency_installations=( + OptionalDependencyInstallation( + dependency_module="optional_backend", + extra="optional-provider", + unsupported_platforms=("win32",), + ), + ), + ), + ) + + assert updated["optional_dependency_unsupported_platforms"] == {"optional_backend": ["win32"]} + assert updated["canonical_imports"] == contract["canonical_imports"] + assert updated["required_submodule_exports"]["agents.optional_parent"] == { + "names": ["OptionalProvider"], + "optional_bindings": {}, + "optional_exports": {"OptionalProvider": "optional_backend"}, + } + assert updated["callables"]["agents.platform_parent.PlatformBinding"] == _callable_contract( + PlatformBinding + ) + assert "agents.optional_parent.OptionalProvider" not in updated["callables"] + + +def test_release_contract_policy_rejects_new_callable_on_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {}, + } + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) + + with pytest.raises( + ValueError, + match=( + r"Cannot promote new canonical callable agents\.optional_parent\.OptionalProvider " + r"because optional dependency 'optional_backend' is unsupported on 'win32'.*" + r"release preparation host" + ), + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + { + "agents.optional_parent": { + "optional_bindings": {}, + "optional_exports": {"OptionalProvider": "optional_backend"}, + } + }, + dependency_installations=( + OptionalDependencyInstallation( + dependency_module="optional_backend", + extra="optional-provider", + unsupported_platforms=("win32",), + ), + ), + canonical_imports=( + { + "canonical_module": "agents.optional_impl", + "canonical_name": "OptionalProvider", + "module": "agents.optional_parent", + "name": "OptionalProvider", + }, + ), + ), + ) + + +def test_release_contract_policy_rejects_new_callable_with_uninspectable_signature( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class UninspectableMeta(type): + @property + def __signature__(cls) -> Signature: + raise ValueError("signature unavailable") + + class Uninspectable(metaclass=UninspectableMeta): + pass + + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["Uninspectable"], Uninspectable=Uninspectable) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "callables": {}, + } + modules = { + "agents": agents_module, + "agents.submodule": submodule, + "agents.submodule.impl": submodule, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: modules[module_name], + ) + + with pytest.raises( + ValueError, + match=( + r"Cannot promote new canonical callable agents\.submodule\.Uninspectable because " + r"its signature cannot be inspected.*release preparation host" + ), + ): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + { + "agents.submodule": { + "optional_bindings": {}, + "optional_exports": {}, + } + }, + canonical_imports=( + { + "canonical_module": "agents.submodule.impl", + "canonical_name": "Uninspectable", + "module": "agents.submodule", + "name": "Uninspectable", + }, + ), + ), + ) + + +def test_release_contract_policy_keeps_existing_uninspectable_canonical_surface( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class UninspectableMeta(type): + @property + def __signature__(cls) -> Signature: + raise ValueError("signature unavailable") + + class Uninspectable(metaclass=UninspectableMeta): + pass + + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["Uninspectable"], Uninspectable=Uninspectable) + canonical_entry = { + "canonical_module": "agents.submodule.impl", + "canonical_name": "Uninspectable", + "module": "agents.submodule", + "name": "Uninspectable", + } + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents", "agents.submodule"], + "canonical_imports": [canonical_entry], + "callables": {}, + } + modules = { + "agents": agents_module, + "agents.submodule": submodule, + "agents.submodule.impl": submodule, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: modules[module_name], + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert updated["canonical_imports"] == [canonical_entry] + assert "agents.submodule.Uninspectable" not in updated["callables"] + + +def test_public_api_contract_skips_optional_surface_on_frozen_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class OptionalBackend: + def __init__(self, value: str) -> None: + self.value = value + + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "optional_backend"}, + } + }, + "optional_dependency_unsupported_platforms": {"optional_backend": ["win32"]}, + "canonical_imports": [ + { + "canonical_module": "agents.submodule.impl", + "canonical_name": "OptionalBackend", + "module": "agents.submodule", + "name": "OptionalBackend", + } + ], + "callables": {"agents.submodule.OptionalBackend": _callable_contract(OptionalBackend)}, + } + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [] + + +def test_public_api_contract_allows_present_optional_surface_on_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + optional_export = object() + optional_binding = object() + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace( + __all__=["OptionalExport", "OptionalBinding"], + OptionalExport=optional_export, + OptionalBinding=optional_binding, + ) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalExport", "OptionalBinding"], + "optional_bindings": {"OptionalBinding": "optional_binding_dependency"}, + "optional_exports": {"OptionalExport": "optional_export_dependency"}, + } + }, + "optional_dependency_unsupported_platforms": { + "optional_binding_dependency": ["win32"], + "optional_export_dependency": ["win32"], + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [] + + +def test_public_api_contract_rejects_dangling_optional_export_on_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["OptionalExport"]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalExport"], + "optional_bindings": {}, + "optional_exports": {"OptionalExport": "optional_export_dependency"}, + } + }, + "optional_dependency_unsupported_platforms": {"optional_export_dependency": ["win32"]}, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Invalid released agents.submodule optional dependency declaration: " + "'OptionalExport' remains in __all__ on an unsupported platform but its " + "binding is unavailable" + ] + + +def test_public_api_contract_rejects_dangling_optional_binding_on_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["OptionalBinding"]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBinding"], + "optional_bindings": {"OptionalBinding": "optional_binding_dependency"}, + "optional_exports": {}, + } + }, + "optional_dependency_unsupported_platforms": {"optional_binding_dependency": ["win32"]}, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Invalid released agents.submodule optional dependency declaration: " + "'OptionalBinding' remains in __all__ on an unsupported platform but its " + "binding is unavailable" + ] + + +def test_public_api_contract_requires_optional_surface_on_supported_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["OptionalBackend"], + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "optional_backend"}, + } + }, + "optional_dependency_unsupported_platforms": {"optional_backend": ["win32"]}, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "Missing released agents.submodule exports: ['OptionalBackend']", + "Missing released agents.submodule bindings: ['OptionalBackend']", + ] + + def test_release_contract_policy_rejects_unavailable_dependency_module( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1165,12 +1708,14 @@ def test_release_contract_policy_rejects_unavailable_dependency_module( baseline="v0.20.0", baseline_commit="b" * 40, agents_module=agents_module, - submodule_export_policy={ - "agents.submodule": { - "optional_bindings": {}, - "optional_exports": {"OptionalBackend": "mistyped_dependency"}, + release_policy=_release_policy( + { + "agents.submodule": { + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "mistyped_dependency"}, + } } - }, + ), ) @@ -1206,12 +1751,14 @@ def test_release_contract_policy_adds_new_public_optional_module( baseline="v0.20.0", baseline_commit="b" * 40, agents_module=agents_module, - submodule_export_policy={ - "agents.new_submodule": { - "optional_bindings": {}, - "optional_exports": {"OptionalBackend": "optional_backend"}, + release_policy=_release_policy( + { + "agents.new_submodule": { + "optional_bindings": {}, + "optional_exports": {"OptionalBackend": "optional_backend"}, + } } - }, + ), ) assert updated["public_modules"] == ["agents", "agents.new_submodule"] @@ -1242,9 +1789,9 @@ def test_release_contract_policy_rejects_unimportable_new_public_module() -> Non baseline="v0.20.0", baseline_commit="b" * 40, agents_module=agents_module, - submodule_export_policy={ - "agents.typo": {"optional_bindings": {}, "optional_exports": {}} - }, + release_policy=_release_policy( + {"agents.typo": {"optional_bindings": {}, "optional_exports": {}}} + ), ) @@ -1269,9 +1816,9 @@ def test_release_contract_policy_rejects_new_module_outside_agents_package() -> baseline="v0.20.0", baseline_commit="b" * 40, agents_module=agents_module, - submodule_export_policy={ - "external_package": {"optional_bindings": {}, "optional_exports": {}} - }, + release_policy=_release_policy( + {"external_package": {"optional_bindings": {}, "optional_exports": {}}} + ), ) @@ -1309,11 +1856,15 @@ def test_load_submodule_export_policy_requires_dependency_installations(tmp_path def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: Path) -> None: policy_path = tmp_path / "policy.json" policy_path.write_text( - '{"modules": {"agents.submodule": {"optional_bindings": ' + '{"canonical_imports": [{"canonical_module": "agents.submodule.impl", ' + '"canonical_name": "ConditionalExport", "module": "agents.submodule", ' + '"name": "ConditionalExport"}], "modules": {"agents.submodule": {"optional_bindings": ' '{"LazyBinding": "binding_dependency"}, "optional_exports": ' '{"ConditionalExport": "export_dependency"}}}, "optional_dependencies": ' '{"binding_dependency": {"requirement": "binding-package>=1"}, ' - '"export_dependency": {"extra": "export-extra"}}}', + '"export_dependency": {"extra": "export-extra"}}, "public_properties": ' + '[{"class_name": "ConditionalExport", "module": "agents.submodule", ' + '"names": ["status"]}]}', encoding="utf-8", ) @@ -1339,6 +1890,21 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: "unsupported_platforms": (), }, ] + assert policy.canonical_imports == ( + { + "canonical_module": "agents.submodule.impl", + "canonical_name": "ConditionalExport", + "module": "agents.submodule", + "name": "ConditionalExport", + }, + ) + assert policy.public_properties == ( + { + "class_name": "ConditionalExport", + "module": "agents.submodule", + "names": ["status"], + }, + ) def test_load_submodule_export_policy_collects_unsupported_platforms(tmp_path: Path) -> None: @@ -1356,6 +1922,42 @@ def test_load_submodule_export_policy_collects_unsupported_platforms(tmp_path: P assert policy.dependency_installations[0].unsupported_platforms == ("win32",) +def test_repository_release_policy_declares_v020_contract_surfaces() -> None: + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + + assert next( + installation + for installation in policy.dependency_installations + if installation.dependency_module == "vercel" + ).unsupported_platforms == ("win32",) + assert {(entry["module"], entry["name"]) for entry in policy.canonical_imports} == { + ("agents.items", "InputItem"), + ("agents.extensions.sandbox", "ModalSandboxClient"), + ("agents.extensions.sandbox", "ModalSandboxClientOptions"), + ("agents.extensions.sandbox", "RunloopSandboxClient"), + ("agents.extensions.sandbox", "RunloopSandboxClientOptions"), + ("agents.extensions.sandbox", "VercelSandboxClient"), + ("agents.extensions.sandbox", "VercelSandboxClientOptions"), + } + assert policy.public_properties == ( + { + "class_name": "RunState", + "module": "agents.run_state", + "names": ["pending_input"], + }, + { + "class_name": "RetryPolicyContext", + "module": "agents.retry", + "names": ["response_started", "replay_safety", "stateful_request"], + }, + { + "class_name": "SandboxSessionState", + "module": "agents.sandbox.session.sandbox_session_state", + "names": ["mount_authority_redacted", "mount_authority_rebound"], + }, + ) + + @pytest.mark.parametrize( "unsupported_platforms", ["win32", [""], ["win32", "win32"]], From ee395e2490bbcc5d1fdc6fdb8e42dabae3aea4c6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 10 Aug 2026 23:38:48 +0900 Subject: [PATCH 273/473] test: isolate released API source validation (#4350) --- .github/workflows/tests.yml | 2 +- integration_tests/README.md | 2 +- integration_tests/_contract_support.py | 59 ++++++++ tests/README.md | 4 +- .../released_api_contract_policy.json | 42 ++++++ tests/test_released_api_contract.py | 135 +++++++++++++++--- 6 files changed, 218 insertions(+), 26 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1bcb93019b..f56ce64498 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -248,7 +248,7 @@ jobs: - name: Install all optional dependencies if: steps.changes.outputs.run == 'true' run: make sync - - name: Generate prospective release contract + - name: Validate source and generate prospective release contract if: steps.changes.outputs.run == 'true' run: make prepare-prospective-released-api-contract - name: Upload prospective release contract diff --git a/integration_tests/README.md b/integration_tests/README.md index c72aa3035d..fad18b4a73 100644 --- a/integration_tests/README.md +++ b/integration_tests/README.md @@ -9,7 +9,7 @@ Run the complete release-oriented matrix with: `make integration-tests-release` runs the release-safe live matrix and the local Docker security contract in strict mode, so an unavailable daemon, image, credential, or required capability fails the release gate instead of becoming a skip. The focused `make integration-tests-security` target runs the same wheel and sdist security contract in strict mode without the live provider matrix; the security profile remains separate from the credential-free PR packaging job. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-security`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The packaging profile validates the released public API manifest and historical `RunState` corpus from base wheel and sdist environments, then validates the public API again from wheel and sdist environments with the Cloudflare extra installed so dependency-conditional exports are required. The security profile installs the Docker extra for both distribution formats, checks packaged credential redaction, and runs model-controlled environment, filesystem, and process inspection inside a local Docker sandbox through the public `Runner` lifecycle. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. -Release PR preparation updates the rolling API manifest locally rather than in a credentialed GitHub workflow. After the release branch version bump, run `make update-released-api-contract VERSION=`, review and commit the manifest diff, then run `make check-released-api-contract VERSION=` after subsequent rebases. Promotion fails before writing if the candidate breaks the committed released contract. Inspectable top-level classes and functions are promoted automatically; documented properties, intended submodule paths, and canonical aliases remain explicit review decisions recorded in the manifest. The packaged profile remains the artifact-level verification that the committed contract holds for both wheel and sdist. +Release PR preparation updates the rolling API manifest locally rather than in a credentialed GitHub workflow. After the release branch version bump, run `make update-released-api-contract VERSION=`, review and commit the manifest diff, then run `make check-released-api-contract VERSION=` after subsequent rebases. Promotion fails before writing if the candidate breaks the committed released contract. The prospective release-contract job performs this source validation in one dedicated Python process so provider behavior tests cannot change its import graph. Inspectable top-level classes and functions are promoted automatically; documented properties, intended submodule paths, and canonical aliases remain explicit review decisions recorded in the manifest. The packaged profiles remain the artifact-level verification that the committed contract holds for core and policy-declared optional surfaces across wheel, sdist, and supported platforms. Invoke the repository-local `$integration-tests` skill to run the release profile with configured OpenRouter-backed provider checks. OpenRouter provides a single configured gateway for the standard multi-provider matrix; provider-specific direct connections are optional extensions selected explicitly. When a release review also requires runnable examples, run `$examples-auto-run` first and then `$integration-tests`. diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 6bf9331faa..5fb725cc6e 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -15,6 +15,8 @@ from types import FunctionType, TracebackType from typing import Any, cast +from pydantic import BaseModel + @dataclasses.dataclass(frozen=True) class OptionalDependencyInstallation: @@ -434,6 +436,27 @@ def _dataclass_field_contract(value: object) -> list[dict[str, object]]: return result +def _pydantic_model_field_contract(value: object) -> list[dict[str, object]] | None: + if not (isinstance(value, type) and issubclass(value, BaseModel)): + return None + result: list[dict[str, object]] = [] + for name, field in value.model_fields.items(): + if name.startswith("_"): + continue + if field.is_required(): + default_contract: dict[str, object] = {"kind": "required"} + elif field.default_factory is not None: + factory = field.default_factory + default_contract = { + "kind": "factory", + "factory": f"{factory.__module__}.{factory.__qualname__}", + } + else: + default_contract = _default_contract(field.default) + result.append({"name": name, "default": default_contract}) + return result + + def _callable_kind(value: Callable[..., Any]) -> str | None: if issubclass(type(value), type): return "class" @@ -574,6 +597,9 @@ def _callable_contract(value: Callable[..., Any]) -> dict[str, Any]: } if kind == "function": contract["execution_kind"] = _function_execution_kind(value) + model_fields = _pydantic_model_field_contract(value) + if model_fields is not None: + contract["model_fields"] = model_fields enum_members = _enum_member_contract(value) if enum_members is not None: contract["enum_members"] = enum_members @@ -1001,6 +1027,30 @@ def _validate_parameter_contract( return errors +def _validate_pydantic_model_field_contract( + name: str, + released: list[dict[str, object]], + current: list[dict[str, object]] | None, +) -> list[str]: + errors: list[str] = [] + current_by_name = {cast(str, entry["name"]): entry for entry in current or []} + for entry in released: + current_entry = current_by_name.get(cast(str, entry["name"])) + if current_entry != entry: + errors.append( + f"{name}.{entry['name']} changed its released Pydantic model field contract: " + f"expected {entry!r}, got {current_entry!r}" + ) + released_names = {entry["name"] for entry in released} + for entry in current or []: + if entry["name"] in released_names: + continue + default = entry["default"] + if isinstance(default, dict) and default.get("kind") == "required": + errors.append(f"{name}.{entry['name']} added a required Pydantic model field") + return errors + + def _import_contract_module(module_name: str, agents_module: Any | None) -> Any: if module_name == "agents" and agents_module is not None: return agents_module @@ -1393,6 +1443,15 @@ def validate_released_api_contract( default = field["default"] if field["init"] and isinstance(default, dict) and default.get("kind") == "required": errors.append(f"{name}.{field['name']} added a required dataclass field") + released_model_fields = released.get("model_fields") + if released_model_fields is not None: + errors.extend( + _validate_pydantic_model_field_contract( + name, + cast(list[dict[str, object]], released_model_fields), + _pydantic_model_field_contract(value), + ) + ) for member_name, released_member in released.get("members", {}).items(): descriptor = _sdk_public_class_descriptor(value, member_name) current_member = _class_member_contract(descriptor) diff --git a/tests/README.md b/tests/README.md index dc4bfd6dd2..3276319e84 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,9 +43,9 @@ make tests-parallel Compare test counts, skips, warnings, assertions, and lifecycle coverage as well as elapsed time. Full-suite wall-clock results depend on host load and worker scheduling, so treat repeated focused measurements as the stronger evidence for an individual optimization. Run the repository's required verification stack after the final test changes. -Release compatibility contracts that inspect the current checkout belong in `tests/` when they are deterministic and in-process. Keep their combined serial focused runtime below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. +Release compatibility unit tests must exercise policy and validation logic with explicit constructed modules instead of inspecting the current checkout's shared import state. The prospective release-contract job validates the current source checkout once in a dedicated Python process, and the packaged integration profiles validate real wheel, sdist, optional-extra, and platform surfaces in isolated environments. Keep the combined serial focused runtime of release compatibility unit tests below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. -The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable exported class or function signature and execution kind, and the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented properties to `public_properties`, newly intended submodule import paths to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. +The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable exported class or function signature and execution kind, Pydantic model field names and defaults, and the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented properties to `public_properties`, newly intended submodule import paths to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. ## Snapshots diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index b67a9e51af..de436ec022 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -6,6 +6,12 @@ "module": "agents.items", "name": "InputItem" }, + { + "canonical_module": "agents.extensions.sandbox.modal", + "canonical_name": "ModalCloudBucketMountStrategy", + "module": "agents.extensions.sandbox", + "name": "ModalCloudBucketMountStrategy" + }, { "canonical_module": "agents.extensions.sandbox.modal", "canonical_name": "ModalSandboxClient", @@ -18,6 +24,30 @@ "module": "agents.extensions.sandbox", "name": "ModalSandboxClientOptions" }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopAfterIdle", + "module": "agents.extensions.sandbox", + "name": "RunloopAfterIdle" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopGatewaySpec", + "module": "agents.extensions.sandbox", + "name": "RunloopGatewaySpec" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopLaunchParameters", + "module": "agents.extensions.sandbox", + "name": "RunloopLaunchParameters" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopMcpSpec", + "module": "agents.extensions.sandbox", + "name": "RunloopMcpSpec" + }, { "canonical_module": "agents.extensions.sandbox.runloop", "canonical_name": "RunloopSandboxClient", @@ -30,6 +60,18 @@ "module": "agents.extensions.sandbox", "name": "RunloopSandboxClientOptions" }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopTunnelConfig", + "module": "agents.extensions.sandbox", + "name": "RunloopTunnelConfig" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopUserParameters", + "module": "agents.extensions.sandbox", + "name": "RunloopUserParameters" + }, { "canonical_module": "agents.extensions.sandbox.vercel", "canonical_name": "VercelSandboxClient", diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 47d87e00bd..f45d91b5e8 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -5,13 +5,13 @@ from dataclasses import asdict, dataclass from enum import Enum from importlib.metadata import version -from inspect import Signature +from inspect import Parameter, Signature from pathlib import Path from types import SimpleNamespace from typing import Any import pytest -from pydantic import Field +from pydantic import BaseModel, Field import integration_tests._contract_support as contract_support from integration_tests._contract_support import ( @@ -73,8 +73,7 @@ def changed_callable(value: object = changed) -> None: assert "changed its released positional parameter prefix" in errors[0] -@pytest.mark.allow_call_model_methods -def test_current_source_preserves_released_public_api_contract() -> None: +def test_released_api_contract_fixture_matches_installed_version() -> None: contract = load_api_contract(CONTRACT) assert contract["baseline"] == f"v{version('openai-agents')}" assert len(contract["baseline_commit"]) == 40 @@ -116,10 +115,6 @@ def test_current_source_preserves_released_public_api_contract() -> None: }, ] - errors = validate_released_api_contract(contract) - - assert errors == [] - def test_callable_contract_ignores_typing_aliases() -> None: alias = Callable[[str], None] @@ -698,20 +693,16 @@ class ChangedValueEnum(Enum): ] -def test_public_api_contract_requires_real_export_bindings( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import agents - +def test_public_api_contract_requires_real_export_bindings() -> None: contract: dict[str, Any] = { "required_top_level_exports": ["AgentsException"], "public_modules": [], "canonical_imports": [], "callables": {}, } - monkeypatch.delattr(agents, "AgentsException") + agents_module = SimpleNamespace(__all__=["AgentsException"]) - assert validate_released_api_contract(contract) == [ + assert validate_released_api_contract(contract, agents_module=agents_module) == [ "Missing released top-level bindings: ['AgentsException']" ] @@ -916,11 +907,7 @@ def raise_foreign_error(module_name: str, _: Any) -> Any: assert errors[0].startswith("Failed to import released module agents.platform_specific:") -def test_public_api_contract_rejects_required_dataclass_suffix( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import agents - +def test_public_api_contract_rejects_required_dataclass_suffix() -> None: @dataclass class Incompatible: value: str @@ -946,14 +933,111 @@ class Incompatible: } }, } - monkeypatch.setattr(agents, "ContractExample", Incompatible, raising=False) + agents_module = SimpleNamespace(__all__=[], ContractExample=Incompatible) - assert validate_released_api_contract(contract) == [ + assert validate_released_api_contract(contract, agents_module=agents_module) == [ "ContractExample.required_suffix added a required parameter", "ContractExample.required_suffix added a required dataclass field", ] +def test_callable_contract_tracks_pydantic_model_fields() -> None: + class Model(BaseModel): + required: str + optional: int = 1 + generated: list[str] = Field(default_factory=list) + + Model.__signature__ = Signature([Parameter("data", kind=Parameter.VAR_KEYWORD)]) + callable_contract = _callable_contract(Model) + + assert callable_contract["parameters"] == [ + {"name": "data", "kind": "VAR_KEYWORD", "default": {"kind": "required"}} + ] + assert callable_contract["model_fields"] == [ + {"name": "required", "default": {"kind": "required"}}, + { + "name": "optional", + "default": {"kind": "literal", "type": "builtins.int", "value": 1}, + }, + { + "name": "generated", + "default": {"kind": "factory", "factory": "builtins.list"}, + }, + ] + + +def test_public_api_contract_validates_pydantic_model_fields() -> None: + class Released(BaseModel): + required: str + optional: int = 1 + + class Compatible(BaseModel): + optional: int = 1 + required: str + added_optional: bool = False + + class Renamed(BaseModel): + renamed: str + optional: int = 1 + + class ChangedDefault(BaseModel): + required: str + optional: int = 2 + + class AddedRequired(BaseModel): + required: str + optional: int = 1 + added_required: bool + + opaque_signature = Signature([Parameter("data", kind=Parameter.VAR_KEYWORD)]) + for model in (Released, Compatible, Renamed, ChangedDefault, AddedRequired): + model.__signature__ = opaque_signature + + released_callable = _callable_contract(Released) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": [], + "canonical_imports": [], + "callables": {"Model": released_callable}, + } + + def validate(model: type[BaseModel]) -> list[str]: + return validate_released_api_contract( + contract, + agents_module=SimpleNamespace(__all__=[], Model=model), + ) + + assert validate(Compatible) == [] + assert validate(Renamed) == [ + "Model.required changed its released Pydantic model field contract: " + "expected {'name': 'required', 'default': {'kind': 'required'}}, got None", + "Model.renamed added a required Pydantic model field", + ] + assert validate(ChangedDefault) == [ + "Model.optional changed its released Pydantic model field contract: " + "expected {'name': 'optional', 'default': {'kind': 'literal', " + "'type': 'builtins.int', 'value': 1}}, got {'name': 'optional', " + "'default': {'kind': 'literal', 'type': 'builtins.int', 'value': 2}}" + ] + assert validate(AddedRequired) == ["Model.added_required added a required Pydantic model field"] + + legacy_contract = { + **contract, + "callables": { + "Model": { + key: value for key, value in released_callable.items() if key != "model_fields" + } + }, + } + assert ( + validate_released_api_contract( + legacy_contract, + agents_module=SimpleNamespace(__all__=[], Model=Renamed), + ) + == [] + ) + + def test_release_contract_update_freezes_new_exports_and_callables() -> None: @dataclass class Existing: @@ -1932,10 +2016,17 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: ).unsupported_platforms == ("win32",) assert {(entry["module"], entry["name"]) for entry in policy.canonical_imports} == { ("agents.items", "InputItem"), + ("agents.extensions.sandbox", "ModalCloudBucketMountStrategy"), ("agents.extensions.sandbox", "ModalSandboxClient"), ("agents.extensions.sandbox", "ModalSandboxClientOptions"), + ("agents.extensions.sandbox", "RunloopAfterIdle"), + ("agents.extensions.sandbox", "RunloopGatewaySpec"), + ("agents.extensions.sandbox", "RunloopLaunchParameters"), + ("agents.extensions.sandbox", "RunloopMcpSpec"), ("agents.extensions.sandbox", "RunloopSandboxClient"), ("agents.extensions.sandbox", "RunloopSandboxClientOptions"), + ("agents.extensions.sandbox", "RunloopTunnelConfig"), + ("agents.extensions.sandbox", "RunloopUserParameters"), ("agents.extensions.sandbox", "VercelSandboxClient"), ("agents.extensions.sandbox", "VercelSandboxClientOptions"), } From 684976659a6e5d07bfd9ee2a0d1defbf510bb9a9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 07:01:15 +0900 Subject: [PATCH 274/473] fix: freeze Runloop platform properties (#4355) --- integration_tests/_contract_support.py | 16 +++++- .../released_api_contract_policy.json | 18 +++++++ tests/test_released_api_contract.py | 52 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 5fb725cc6e..6aebd9af38 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -1060,11 +1060,19 @@ def _import_contract_module(module_name: str, agents_module: Any | None) -> Any: def _validate_public_property_contract( contract: dict[str, Any], agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, ) -> list[str]: errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} for entry in contract.get("public_properties", []): module_name = entry["module"] class_name = entry["class_name"] + optional_dependency = _optional_dependency_for_binding(contract, module_name, class_name) + if optional_dependency is not None and not _optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ): + continue try: module = _import_contract_module(module_name, agents_module) except Exception as error: @@ -1182,7 +1190,13 @@ def validate_released_api_contract( errors.append(f"Invalid released optional dependency platform declarations: {error}") unsupported_platforms = {} - errors.extend(_validate_public_property_contract(contract, agents_module)) + errors.extend( + _validate_public_property_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) missing_exports = sorted(set(contract["required_top_level_exports"]) - set(agents.__all__)) if missing_exports: diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index de436ec022..4bf43b6116 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -188,6 +188,24 @@ "stateful_request" ] }, + { + "class_name": "RunloopPlatformClient", + "module": "agents.extensions.sandbox", + "names": [ + "axons", + "benchmarks", + "blueprints", + "network_policies", + "secrets" + ] + }, + { + "class_name": "RunloopSandboxClient", + "module": "agents.extensions.sandbox", + "names": [ + "platform" + ] + }, { "class_name": "SandboxSessionState", "module": "agents.sandbox.session.sandbox_session_state", diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index f45d91b5e8..3ad76d63cc 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -302,6 +302,48 @@ def concrete_only(self) -> str: ] +def test_curated_public_property_contract_honors_optional_dependency_availability( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class OptionalClient: + pass + + contract: dict[str, Any] = { + "required_submodule_exports": { + "agents.optional": { + "names": ["OptionalClient"], + "optional_bindings": {}, + "optional_exports": {"OptionalClient": "optional_backend"}, + } + }, + "public_properties": [ + { + "module": "agents.optional", + "class_name": "OptionalClient", + "names": ["status"], + } + ], + } + agents_module = SimpleNamespace(__all__=[]) + optional_module = SimpleNamespace(OptionalClient=OptionalClient) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else optional_module + ), + ) + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) + + assert _validate_public_property_contract(contract, agents_module) == [] + + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + + assert _validate_public_property_contract(contract, agents_module) == [ + "agents.optional.OptionalClient.status removed or changed a released public property" + ] + + def test_public_class_member_contract_tracks_only_sdk_owned_inherited_methods() -> None: class ExternalBase: def external_method(self) -> None: @@ -2041,6 +2083,16 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: "module": "agents.retry", "names": ["response_started", "replay_safety", "stateful_request"], }, + { + "class_name": "RunloopPlatformClient", + "module": "agents.extensions.sandbox", + "names": ["axons", "benchmarks", "blueprints", "network_policies", "secrets"], + }, + { + "class_name": "RunloopSandboxClient", + "module": "agents.extensions.sandbox", + "names": ["platform"], + }, { "class_name": "SandboxSessionState", "module": "agents.sandbox.session.sandbox_session_state", From a5def04fbf539c4e3d883260ec567fa6993d587c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 07:05:09 +0900 Subject: [PATCH 275/473] feat(realtime): support GA transcription options (#4351) --- src/agents/realtime/config.py | 30 +++++++++++-- tests/realtime/test_openai_realtime.py | 62 +++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/agents/realtime/config.py b/src/agents/realtime/config.py index f13575d535..6dbb209a20 100644 --- a/src/agents/realtime/config.py +++ b/src/agents/realtime/config.py @@ -79,12 +79,33 @@ class RealtimeInputAudioTranscriptionConfig(TypedDict): language: NotRequired[str] """The language code for transcription.""" - model: NotRequired[Literal["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"] | str] + model: NotRequired[ + Literal[ + "gpt-transcribe", + "gpt-live-transcribe", + "gpt-4o-transcribe", + "gpt-4o-mini-transcribe", + "gpt-4o-mini-transcribe-2025-12-15", + "gpt-4o-transcribe-diarize", + "gpt-realtime-whisper", + "whisper-1", + ] + | str + ] """The transcription model to use.""" prompt: NotRequired[str] """An optional prompt to guide transcription.""" + keywords: NotRequired[list[str]] + """Literal terms that may appear in the audio.""" + + languages: NotRequired[list[str]] + """Expected input languages for transcription.""" + + delay: NotRequired[Literal["minimal", "low", "medium", "high", "xhigh"]] + """The latency and accuracy tradeoff for streaming transcription.""" + class RealtimeInputAudioNoiseReductionConfig(TypedDict): """Noise reduction configuration for input audio.""" @@ -130,7 +151,8 @@ class RealtimeAudioInputConfig(TypedDict, total=False): format: RealtimeAudioFormat | OpenAIRealtimeAudioFormats noise_reduction: RealtimeInputAudioNoiseReductionConfig | None transcription: RealtimeInputAudioTranscriptionConfig - turn_detection: RealtimeTurnDetectionConfig + turn_detection: RealtimeTurnDetectionConfig | None + """Configuration for detecting conversation turns, or ``None`` to disable detection.""" class RealtimeAudioOutputConfig(TypedDict, total=False): @@ -201,8 +223,8 @@ class RealtimeSessionModelSettings(TypedDict): input_audio_noise_reduction: NotRequired[RealtimeInputAudioNoiseReductionConfig | None] """Noise reduction configuration for input audio.""" - turn_detection: NotRequired[RealtimeTurnDetectionConfig] - """Configuration for detecting conversation turns.""" + turn_detection: NotRequired[RealtimeTurnDetectionConfig | None] + """Configuration for detecting conversation turns, or ``None`` to disable detection.""" tool_choice: NotRequired[ToolChoice] """How the model should choose which tools to call.""" diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index c6c2b5d9cf..aec471e9d4 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -14,6 +14,7 @@ from agents import Agent, WebSearchTool, function_tool from agents.exceptions import UserError from agents.handoffs import handoff +from agents.realtime import RealtimeSessionModelSettings from agents.realtime.model import RealtimeModelConfig, RealtimePlaybackTracker from agents.realtime.model_events import ( RealtimeModelAudioEvent, @@ -476,7 +477,7 @@ async def test_connect_already_connected_assertion(self, model, mock_websocket): @pytest.mark.asyncio async def test_session_update_disable_turn_detection(self, model, mock_websocket): """Session.update should allow users to disable turn-detection.""" - config = { + config: RealtimeModelConfig = { "api_key": "test-api-key-123", "initial_model_settings": { "model_name": "gpt-4o-realtime-preview", @@ -2808,6 +2809,65 @@ def test_session_config_includes_reasoning_capable_settings(self, model): assert payload["parallel_tool_calls"] is False assert payload["reasoning"] == {"effort": "low"} + def test_session_config_forwards_ga_input_audio_transcription_options(self, model): + contextual_settings: RealtimeSessionModelSettings = { + "audio": { + "input": { + "transcription": { + "model": "gpt-transcribe", + "keywords": ["LegalOn", "TomoniAI"], + "languages": ["ja", "en"], + "prompt": "A Japanese conversation about LegalOn and TomoniAI.", + } + } + } + } + low_latency_settings: RealtimeSessionModelSettings = { + "audio": { + "input": { + "transcription": { + "model": "gpt-live-transcribe", + }, + "turn_detection": None, + } + } + } + whisper_settings: RealtimeSessionModelSettings = { + "audio": { + "input": { + "transcription": { + "model": "gpt-realtime-whisper", + "delay": "low", + }, + "turn_detection": None, + } + } + } + + contextual_payload = model._get_session_config(contextual_settings).model_dump( + exclude_unset=True + ) + low_latency_payload = model._get_session_config(low_latency_settings).model_dump( + exclude_unset=True + ) + whisper_payload = model._get_session_config(whisper_settings).model_dump(exclude_unset=True) + + assert contextual_payload["audio"]["input"]["transcription"] == { + "model": "gpt-transcribe", + "keywords": ["LegalOn", "TomoniAI"], + "languages": ["ja", "en"], + "prompt": "A Japanese conversation about LegalOn and TomoniAI.", + } + assert low_latency_payload["audio"]["input"]["transcription"] == { + "model": "gpt-live-transcribe", + } + assert low_latency_payload["audio"]["input"]["turn_detection"] is None + assert whisper_payload["audio"]["input"]["transcription"] == { + "model": "gpt-realtime-whisper", + "delay": "low", + } + assert whisper_payload["audio"]["input"]["turn_detection"] is None + def test_session_config_passes_max_output_tokens(self, model): # Integer cap is forwarded verbatim to the server payload. cfg = model._get_session_config({"max_output_tokens": 256}) From 67e6d377b8c9b0fc569ecbbbbde335425ef695ba Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 07:22:30 +0900 Subject: [PATCH 276/473] fix: refresh release candidates consistently --- .../skills/release-candidate-prep/SKILL.md | 28 +-- .../release-candidate-prep/scripts/prepare.py | 49 ++++-- .../scripts/test_prepare.py | 163 +++++++++++++++++- 3 files changed, 209 insertions(+), 31 deletions(-) diff --git a/.agents/skills/release-candidate-prep/SKILL.md b/.agents/skills/release-candidate-prep/SKILL.md index a9d6d88747..63c2054ea5 100644 --- a/.agents/skills/release-candidate-prep/SKILL.md +++ b/.agents/skills/release-candidate-prep/SKILL.md @@ -1,6 +1,6 @@ --- name: release-candidate-prep -description: Preflight and prepare an OpenAI Agents Python release candidate in a dedicated worktree from exact origin/main, gate readiness before branch creation, freeze the released API contract, create one local release commit, enforce final release review as a checker, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. +description: Preflight and prepare an OpenAI Agents Python release candidate in a dedicated worktree from exact origin/main, gate readiness before branch creation, freeze the released API contract, create or replace the local release branch with one release commit, enforce final release review as a checker, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. --- # Release Candidate Preparation @@ -9,11 +9,11 @@ Use this skill only when the user explicitly invokes `$release-candidate-prep` a ## Non-negotiable boundaries -- Treat explicit invocation as authorization to fetch `origin/main`, create one dedicated detached release worktree, run branch-free release-readiness gates there, create `release/v` in that worktree only after those gates pass, update the three release-owned files, and create one local commit. +- Treat explicit invocation as authorization to fetch `origin/main`, create one dedicated detached release worktree, run branch-free release-readiness gates there, create or replace the local `release/v` in that worktree only after those gates pass, update the three release-owned files, and create one local commit. If the branch already exists locally or remotely, the required final local state is still exact current `origin/main` plus only the new release commit; an existing local branch may be replaced only when it is not checked out in another worktree. - Keep the user's source checkout on its existing clean `main` commit. Do not fast-forward it, switch its branch, or materialize release files there. Leave the dedicated release worktree in place for green handoff, blocked review, or recoverable failure. - Never push, open or edit a pull request, add labels or milestones, create a release, or otherwise mutate GitHub. Never run `gh`. - Own exactly `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`. Runtime, documentation, workflow, or other repository changes must land on `main` before release preparation. -- Do not stash, reset, delete, overwrite, remove an existing worktree, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty or is not on `main`, the dedicated worktree is not clean and detached at refreshed `origin/main`, the release branch collides locally or remotely, the prospective packaged-contract gate fails after the allowed dependency-bootstrap recovery, the planning review blocks, or `origin/main` advances after those gates run. +- Do not stash, delete, overwrite or remove an existing worktree, or work around unrelated local changes. Fail before branch creation when the initial checkout is dirty or is not on `main`, the dedicated worktree is not clean and detached at refreshed `origin/main`, an existing local release branch is checked out in another worktree, the prospective packaged-contract gate fails after the allowed dependency-bootstrap recovery, the planning review blocks, or `origin/main` advances after those gates run. - Treat `$final-release-review` as the controlling release checker, not only as a report generator. Its planning gate must be green before branch creation, and its final-candidate gate must inspect the materialized worktree and be green before PR-ready handoff. Any candidate content, commit, or base change invalidates the previous green result. - Remove inherited `OPENAI_API_KEY` from every child command. Release preparation does not require a live OpenAI API request. - Stop after the local commit, final release review, and copy-ready handoff. The user owns the push and pull-request creation. @@ -35,11 +35,11 @@ env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi. The helper must complete all of these operations or fail with an actionable error while leaving the source checkout on its original `main` commit: 1. Verify the repository root, `main` branch, and clean working tree. -2. Verify that `release/v` does not exist locally or remotely. +2. Inspect whether `release/v` exists locally or remotely. Permit replacement, but fail if the local branch is checked out in another worktree. 3. Fetch `main` into `origin/main` without merging or switching the source checkout. 4. Choose a unique task-oriented path under the configured Codex worktree root. Check both the filesystem and `git worktree list`; never reuse or delete a collision. 5. Create a detached worktree at exact refreshed `origin/main`, then require that worktree to be clean, detached, and at the exact 40-character base commit. -6. Recheck the release-branch collision and require the source checkout to remain clean on `main` at its original commit. +6. Recheck that an existing local release branch remains replaceable and require the source checkout to remain clean on `main` at its original commit. 7. Print the exact base commit, unchanged source-checkout commit, planned branch, and dedicated worktree path for both readiness gates and later materialization. Record the base commit as ``, the source-checkout commit as ``, and the path as ``. Do not create or switch branches yet. Keep the detached worktree if a later gate blocks so its exact reviewed source remains inspectable. @@ -86,14 +86,14 @@ env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi. The helper must complete all of these operations or fail with an actionable error: -1. Repeat the source-root, clean `main`, version, registered-worktree, detached-HEAD, and local/remote release-branch checks. +1. Repeat the source-root, clean `main`, version, registered-worktree, detached-HEAD, and release-branch replaceability checks. 2. Refresh `origin/main` again without moving the source checkout. 3. Require refreshed `origin/main` and `` HEAD to equal ``. If `origin/main` advanced, retain the old detached worktree and rerun preflight plus both readiness gates in a new exact-base worktree. -4. Create `release/v` inside `` only after the exact-base check passes. -5. Update the single project version declaration in `pyproject.toml`. -6. Run `make sync` with `UV_DEFAULT_INDEX=https://pypi.org/simple`. -7. Run `make update-released-api-contract VERSION=` and then `make check-released-api-contract VERSION=`. -8. Require exactly the three release-owned paths to be modified in ``, leave them unstaged and uncommitted, and confirm that the source checkout remains unchanged. +4. Keep the worktree detached while updating the single project version declaration in `pyproject.toml`. +5. Run `make sync` with `UV_DEFAULT_INDEX=https://pypi.org/simple`. +6. Run `make update-released-api-contract VERSION=` and then `make check-released-api-contract VERSION=`. +7. Require exactly the three release-owned paths to be modified in ``, leave them unstaged and uncommitted, and confirm that the source checkout remains unchanged. +8. Only after those candidate checks pass, create or reset the local `release/v` inside `` to exact `` while preserving the validated unstaged manifest. Do not retain commits or content from an older local or remote candidate. This delayed replacement must leave an existing local branch unchanged when candidate generation fails. If the helper fails after branch creation, preserve its local branch, dedicated worktree, and working-tree evidence. Report the failing command and state rather than guessing whether a partial run is safe to resume. Never remove the worktree as automatic cleanup. @@ -134,7 +134,7 @@ The earlier planning review proves that the source commit was ready before branc ## 7. Recheck main freshness -After a green review, fetch `origin main` again without credentials from `` and compare it with the release commit's parent. If they differ, the candidate is stale. First verify that the branch is clean, has exactly one local commit, and that the commit changes only the three-file release manifest. Rebase that commit onto the new `origin/main` so Git detects any conflicting release metadata. After a clean rebase, move the local release branch back to `origin/main` with a mixed reset, which preserves the rebased release tree as unstaged task-owned changes. Restore only `tests/fixtures/released_api_contract.json` from `origin/main`, rerun `make sync`, run `make check-prospective-released-api-contract`, update and check the released API contract while `HEAD` is the new base, review the exact manifest again, and recreate the single `release: ` commit. The base and candidate content changed, so the previous green check is invalid: rerun `$final-release-review` from the worktree and require a new green release call. Repeat until the reviewed commit is exactly one commit ahead of current `origin/main`. +After a green review, fetch `origin main` again without credentials from `` and compare it with the release commit's parent. If they differ, the candidate is stale. First verify that the branch is clean, has exactly one local commit, and that the commit changes only the three-file release manifest. Rebase that commit onto the new `origin/main` so Git detects any conflicting release metadata. After a clean rebase, move the local release branch back to `origin/main` with a mixed reset, which preserves the rebased release tree as unstaged task-owned changes. Restore all three release-owned files (`pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`) from `origin/main`, run `make sync`, and require the worktree to be clean at the new base. Run `make check-prospective-released-api-contract` only in that internally consistent base state, where the installed project version and frozen contract baseline agree. Then update `pyproject.toml` to ``, run `make sync`, run `make update-released-api-contract VERSION=` and `make check-released-api-contract VERSION=`, review the exact manifest again, and recreate the single `release: ` commit. The base and candidate content changed, so the previous green check is invalid: rerun `$final-release-review` from the worktree and require a new green release call. Repeat until the reviewed local branch is exactly one commit ahead of current `origin/main` and that commit changes only the three-file release manifest. If replay conflicts or another path changes, stop with recoverable evidence. Do not force a resolution that expands the release commit beyond its manifest. @@ -166,11 +166,13 @@ Apply the repository's GitHub paste-readiness rules to the report. Use native `# Also report the dedicated worktree path, local branch, commit SHA, parent `origin/main` commit, and the exact three-file manifest outside the copy-ready block. State explicitly that the source checkout was left unchanged, nothing was pushed, and no pull request was created. Leave the worktree in place for the user's handoff. +If `release/v` already exists on `origin`, inspect its exact current commit with credential-free `git ls-remote --heads origin release/v` immediately before handoff and record it as ``. State explicitly that the local branch has replaced the old candidate and now contains exact current `origin/main` plus only the new `release: ` commit. Because this skill never mutates GitHub, provide the user with the exact `git push --force-with-lease=refs/heads/release/v: origin release/v` command to replace the remote branch themselves; never run it. A normal push or an unspecified lease is insufficient for this replacement case. If the remote branch changes after inspection, the explicit lease must reject the push instead of overwriting unseen work. + ## Failure behavior - Preflight or worktree creation failure: leave the source checkout unchanged and do not delete or reuse any colliding worktree. - Dependency-bootstrap failure: retry unavailable optional dependency setup only as described in the readiness-gate procedure, then retain the detached worktree and return the exact failure if recovery does not succeed. - Prospective-contract failure after the allowed dependency-bootstrap recovery or blocked planning review: retain the detached worktree, do not create the release branch, and return the exact failure or unblock checklist. -- Materialization failure: retain the worktree and any branch or uncommitted evidence exactly as left by the failing command. +- Materialization failure before successful branch replacement: retain the detached worktree and its uncommitted evidence, and leave any existing local release branch unchanged. Failure after successful branch replacement must retain the worktree, branch, and evidence exactly as left by the failing command. - Blocked final-candidate review: retain the single release commit and worktree, do not call the candidate PR-ready, and return the checker-derived unblock checklist. - Freshness conflict or unexpected changed path: stop with recoverable worktree evidence rather than forcing a resolution or expanding the release manifest. diff --git a/.agents/skills/release-candidate-prep/scripts/prepare.py b/.agents/skills/release-candidate-prep/scripts/prepare.py index 6c34fb8d4a..29ef65eeea 100755 --- a/.agents/skills/release-candidate-prep/scripts/prepare.py +++ b/.agents/skills/release-candidate-prep/scripts/prepare.py @@ -277,17 +277,38 @@ def _require_registered_detached_worktree( raise ReleasePreparationError("Release worktree must be clean before materialization.") -def _require_branch_absent(repo: Path, branch: str) -> None: +def _worktrees_using_branch(repo: Path, branch: str) -> tuple[Path, ...]: + """Return registered worktrees that currently check out one local branch.""" + + matches: list[Path] = [] + worktree: Path | None = None + for line in [*git(repo, "worktree", "list", "--porcelain").stdout.splitlines(), ""]: + if line.startswith("worktree "): + worktree = Path(line.removeprefix("worktree ")).resolve() + elif line == f"branch refs/heads/{branch}" and worktree is not None: + matches.append(worktree) + elif not line: + worktree = None + return tuple(matches) + + +def _require_branch_replaceable(repo: Path, branch: str) -> None: + """Require an existing release branch to be safe to replace locally.""" + local = git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False) - if local.returncode == 0: - raise ReleasePreparationError(f"Local branch {branch!r} already exists.") if local.returncode not in (0, 1): raise ReleasePreparationError(f"Unable to inspect local branch {branch!r}.") + if local.returncode == 0: + worktrees = _worktrees_using_branch(repo, branch) + if worktrees: + locations = ", ".join(str(path) for path in worktrees) + raise ReleasePreparationError( + f"Local branch {branch!r} is checked out in {locations}; switch that worktree " + "away from the branch before replacing the release candidate." + ) remote = git(repo, "ls-remote", "--exit-code", "--heads", "origin", branch, check=False) - if remote.returncode == 0: - raise ReleasePreparationError(f"Remote branch {branch!r} already exists.") - if remote.returncode != 2: + if remote.returncode not in (0, 2): detail = remote.stderr.strip() or remote.stdout.strip() or "unknown remote error" raise ReleasePreparationError(f"Unable to inspect remote branch {branch!r}: {detail}") @@ -363,7 +384,7 @@ def preflight(repo: Path, version: str, worktree_root: Path) -> ReleasePreflight _require_clean_main(repo) source_commit = git(repo, "rev-parse", "HEAD").stdout.strip() branch = f"release/v{version}" - _require_branch_absent(repo, branch) + _require_branch_replaceable(repo, branch) env = _release_environment() run_command( repo, @@ -381,7 +402,7 @@ def preflight(repo: Path, version: str, worktree_root: Path) -> ReleasePreflight if project_version_at(repo, base_commit) == version: raise ReleasePreparationError(f"Refreshed origin/main already declares version {version}.") - _require_branch_absent(repo, branch) + _require_branch_replaceable(repo, branch) if _status(repo): raise ReleasePreparationError( "Release preflight must leave the source main working tree clean." @@ -427,7 +448,7 @@ def materialize( env = _release_environment() branch = f"release/v{version}" - _require_branch_absent(repo, branch) + _require_branch_replaceable(repo, branch) run_command( repo, [ @@ -448,12 +469,11 @@ def materialize( ) _require_clean_main(repo) _require_source_head(repo, expected_source_head) - _require_branch_absent(repo, branch) + _require_branch_replaceable(repo, branch) _require_registered_detached_worktree(repo, worktree, expected_base) if project_version(worktree) == version: raise ReleasePreparationError(f"Project version is already {version}.") - run_command(worktree, ["git", "switch", "-c", branch], env=env, announce=True) replace_project_version(worktree, version) run_command(worktree, ["make", "sync"], env=env, announce=True) run_command( @@ -471,6 +491,13 @@ def materialize( changed_paths = _validate_prepared_files(worktree, version, base_commit) _require_clean_main(repo) _require_source_head(repo, expected_source_head) + _require_branch_replaceable(repo, branch) + run_command( + worktree, + ["git", "switch", "--no-track", "-C", branch, expected_base], + env=env, + announce=True, + ) return PreparedCandidate( base_commit=base_commit, branch=branch, diff --git a/.agents/skills/release-candidate-prep/scripts/test_prepare.py b/.agents/skills/release-candidate-prep/scripts/test_prepare.py index 1fa8591d18..192aa7dd39 100755 --- a/.agents/skills/release-candidate-prep/scripts/test_prepare.py +++ b/.agents/skills/release-candidate-prep/scripts/test_prepare.py @@ -323,7 +323,7 @@ def test_preflight_keeps_source_main_and_checks_out_refreshed_origin_in_worktree ) self.assertTrue((release_input.worktree / "new-source.txt").is_file()) - def test_preflight_rejects_existing_remote_release_branch(self) -> None: + def test_existing_remote_release_branch_is_replaced_locally(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) run( @@ -333,16 +333,100 @@ def test_preflight_rejects_existing_remote_release_branch(self) -> None: "origin", "HEAD:refs/heads/release/v0.20.0", ) + old_remote_candidate = fixture.base_commit + refreshed_base = fixture.advance_origin() + + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) + candidate = prepare.materialize( + fixture.repo, + "0.20.0", + release_input.base_commit, + release_input.source_commit, + release_input.worktree, + ) + + self.assertEqual(candidate.branch, "release/v0.20.0") + self.assertEqual(release_input.base_commit, refreshed_base) + self.assertEqual( + run(release_input.worktree, "git", "rev-parse", "HEAD").stdout.strip(), + refreshed_base, + ) + self.assertEqual( + run( + fixture.repo, + "git", + "ls-remote", + "--heads", + "origin", + "release/v0.20.0", + ).stdout.split()[0], + old_remote_candidate, + ) + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "main", + ) + + def test_existing_local_release_branch_is_replaced_at_reviewed_base(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + run(fixture.repo, "git", "branch", "release/v0.20.0") + stale_commit = fixture.base_commit + refreshed_base = fixture.advance_origin() + + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) + prepare.materialize( + fixture.repo, + "0.20.0", + release_input.base_commit, + release_input.source_commit, + release_input.worktree, + ) + + self.assertNotEqual(stale_commit, refreshed_base) + self.assertEqual(release_input.base_commit, refreshed_base) + self.assertEqual( + run( + fixture.repo, + "git", + "rev-parse", + "refs/heads/release/v0.20.0", + ).stdout.strip(), + refreshed_base, + ) + + def test_preflight_rejects_release_branch_checked_out_in_another_worktree(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + colliding_worktree = fixture.root / "existing-release" + run( + fixture.repo, + "git", + "worktree", + "add", + "-b", + "release/v0.20.0", + str(colliding_worktree), + fixture.base_commit, + ) with self.assertRaisesRegex( prepare.ReleasePreparationError, - "Remote branch 'release/v0.20.0' already exists", + "is checked out in", ): prepare.preflight(fixture.repo, "0.20.0", fixture.worktree_root) self.assertEqual( - run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), - "main", + run(colliding_worktree, "git", "branch", "--show-current").stdout.strip(), + "release/v0.20.0", ) def test_materialize_rejects_stale_preflight_before_creating_branch(self) -> None: @@ -443,7 +527,7 @@ def test_preflight_rejects_a_worktree_root_inside_the_source_checkout(self) -> N self.assertFalse(nested_root.exists()) self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") - def test_materialize_failure_preserves_worktree_evidence_and_source_checkout(self) -> None: + def test_materialize_failure_preserves_detached_evidence_and_source_checkout(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) release_input = prepare.preflight( @@ -481,8 +565,16 @@ def fail_sync( ) self.assertEqual(run(fixture.repo, "git", "status", "--porcelain").stdout, "") self.assertEqual( - run(release_input.worktree, "git", "branch", "--show-current").stdout.strip(), - "release/v0.20.0", + run( + release_input.worktree, + "git", + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + check=False, + ).returncode, + 1, ) self.assertIn( "pyproject.toml", @@ -490,6 +582,63 @@ def fail_sync( ) self.assertEqual(prepare.project_version(release_input.worktree), "0.20.0") + def test_materialize_failure_does_not_replace_existing_local_release_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + run(fixture.repo, "git", "branch", "release/v0.20.0") + existing_candidate = fixture.base_commit + fixture.advance_origin() + release_input = prepare.preflight( + fixture.repo, + "0.20.0", + fixture.worktree_root, + ) + real_run_command = prepare.run_command + + def fail_sync( + repo: Path, + args: list[str] | tuple[str, ...], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + if list(args) == ["make", "sync"]: + raise prepare.ReleasePreparationError("simulated make sync failure") + return real_run_command(repo, args, **kwargs) + + with mock.patch.object(prepare, "run_command", side_effect=fail_sync): + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + "simulated make sync failure", + ): + prepare.materialize( + fixture.repo, + "0.20.0", + release_input.base_commit, + release_input.source_commit, + release_input.worktree, + ) + + self.assertEqual( + run( + fixture.repo, + "git", + "rev-parse", + "refs/heads/release/v0.20.0", + ).stdout.strip(), + existing_candidate, + ) + self.assertEqual( + run( + release_input.worktree, + "git", + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + check=False, + ).returncode, + 1, + ) + def test_materialize_rejects_a_source_checkout_head_change(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = ReleaseRepository(Path(directory)) From cda89c8ae4b924435aa89075ab8312f809ddf97c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 08:03:48 +0900 Subject: [PATCH 277/473] fix: freeze new public submodule callables --- integration_tests/_contract_support.py | 52 ++++++ tests/README.md | 2 +- tests/test_released_api_contract.py | 229 +++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 6aebd9af38..dcc437a3c2 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -465,6 +465,15 @@ def _callable_kind(value: Callable[..., Any]) -> str | None: return None +def _is_sdk_owned_callable(value: object) -> bool: + module_name = getattr(value, "__module__", None) + return ( + _callable_kind(cast(Callable[..., Any], value)) is not None + and isinstance(module_name, str) + and (module_name == "agents" or module_name.startswith("agents.")) + ) + + def _enum_member_contract(value: object) -> list[dict[str, object]] | None: if not (issubclass(type(value), type) and issubclass(cast(type, value), enum.Enum)): return None @@ -729,6 +738,15 @@ def _preserve_released_callable_for_promotion( callables[qualified_name] = deepcopy(released_callable) +def _preserve_released_submodule_callables( + contract: Mapping[str, Any], callables: dict[str, Any], module_name: str +) -> None: + for qualified_name, released_callable in contract["callables"].items(): + callable_module, _, _ = qualified_name.rpartition(".") + if callable_module == module_name: + callables.setdefault(qualified_name, deepcopy(released_callable)) + + def build_released_api_contract( contract: dict[str, Any], *, @@ -924,6 +942,7 @@ def build_released_api_contract( ) updated["public_modules"] = public_modules required_submodule_exports: dict[str, dict[str, Any]] = {} + released_submodule_exports = contract.get("required_submodule_exports", {}) for module_name in public_modules: if module_name == "agents" or module_name in excluded_submodule_exports: continue @@ -931,6 +950,7 @@ def build_released_api_contract( module = _import_contract_module(module_name, agents_module) except Exception as error: if _matches_platform_import_error(contract, module_name, error): + _preserve_released_submodule_callables(contract, callables, module_name) continue if submodule_export_policy is not None and module_name in submodule_export_policy: raise ValueError( @@ -959,6 +979,38 @@ def build_released_api_contract( ) if module_contract is not None: required_submodule_exports[module_name] = module_contract + released_names = set(released_submodule_exports.get(module_name, {}).get("names", [])) + for name in module_contract["names"]: + qualified_name = f"{module_name}.{name}" + was_tracked = qualified_name in tracked_callables + if name in released_names and not was_tracked: + continue + optional_dependency = _optional_dependency_for_binding_in_modules( + {module_name: module_contract}, module_name, name + ) + if optional_dependency is not None and not ( + _optional_dependency_is_available_for_contract( + optional_dependency, policy_unsupported_platforms + ) + ): + if was_tracked: + callables[qualified_name] = deepcopy(contract["callables"][qualified_name]) + continue + value = getattr(module, name, None) + if value is None: + continue + if not was_tracked and not _is_sdk_owned_callable(value): + continue + kind = _callable_kind(value) + if kind is None: + continue + try: + _signature(value) + except (TypeError, ValueError): + if was_tracked: + callables[qualified_name] = deepcopy(contract["callables"][qualified_name]) + continue + callables[qualified_name] = _callable_contract(value) updated["required_submodule_exports"] = required_submodule_exports updated_errors = validate_released_api_contract(updated, agents_module=agents) diff --git a/tests/README.md b/tests/README.md index 3276319e84..edbd836622 100644 --- a/tests/README.md +++ b/tests/README.md @@ -45,7 +45,7 @@ Compare test counts, skips, warnings, assertions, and lifecycle coverage as well Release compatibility unit tests must exercise policy and validation logic with explicit constructed modules instead of inspecting the current checkout's shared import state. The prospective release-contract job validates the current source checkout once in a dedicated Python process, and the packaged integration profiles validate real wheel, sdist, optional-extra, and platform surfaces in isolated environments. Keep the combined serial focused runtime of release compatibility unit tests below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. -The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable exported class or function signature and execution kind, Pydantic model field names and defaults, and the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented properties to `public_properties`, newly intended submodule import paths to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. +The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable top-level class or function signature and execution kind, and every newly exported SDK-owned inspectable class or function in a tracked public submodule. Qualified submodule callables remain tracked in later releases. Callable contracts include Pydantic model field names and defaults plus the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented properties to `public_properties`, newly intended cross-module import identities to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. ## Snapshots diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 3ad76d63cc..cc9efa8d36 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -1209,6 +1209,235 @@ def test_release_contract_update_promotes_selected_submodule_exports( } +def test_release_contract_update_freezes_new_sdk_submodule_callable_without_canonical_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Existing: + pass + + class NewPublic: + def __init__(self, value: str, optional: int = 1) -> None: + self.value = value + self.optional = optional + + Existing.__module__ = "agents.submodule" + NewPublic.__module__ = "agents.submodule" + agents_module = SimpleNamespace(__all__=["NewPublic"], NewPublic=NewPublic) + submodule = SimpleNamespace( + __all__=["Existing", "NewPublic"], Existing=Existing, NewPublic=NewPublic + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": ["Existing"], + "optional_bindings": {}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert "agents.submodule.Existing" not in updated["callables"] + assert updated["callables"]["NewPublic"] == _callable_contract(NewPublic) + assert updated["callables"]["agents.submodule.NewPublic"] == _callable_contract(NewPublic) + assert updated["canonical_imports"] == [] + + unchanged = build_released_api_contract( + updated, + baseline="v0.20.0", + baseline_commit="c" * 40, + agents_module=agents_module, + ) + assert unchanged["callables"]["agents.submodule.NewPublic"] == _callable_contract(NewPublic) + + class ChangedPublic: + def __init__(self, value: str, optional: int = 1, *, required: int) -> None: + self.value = value + self.optional = optional + self.required = required + + ChangedPublic.__module__ = "agents.submodule" + submodule.NewPublic = ChangedPublic + + assert validate_released_api_contract(updated, agents_module=agents_module) == [ + "agents.submodule.NewPublic.required added a required parameter" + ] + + +def test_release_contract_update_skips_new_third_party_submodule_callable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ExternalPublic: + pass + + ExternalPublic.__module__ = "external_package" + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=["ExternalPublic"], ExternalPublic=ExternalPublic) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.submodule"], + "required_submodule_exports": { + "agents.submodule": { + "names": [], + "optional_bindings": {}, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert "agents.submodule.ExternalPublic" not in updated["callables"] + + +def test_release_contract_update_preserves_tracked_submodule_callable_on_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class OptionalPublic: + def __init__(self, value: str) -> None: + self.value = value + + OptionalPublic.__module__ = "agents.optional_parent" + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(__all__=[]) + callable_contract = _callable_contract(OptionalPublic) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.optional_parent"], + "required_submodule_exports": { + "agents.optional_parent": { + "names": ["OptionalPublic"], + "optional_bindings": {}, + "optional_exports": {"OptionalPublic": "optional_backend"}, + } + }, + "optional_dependency_unsupported_platforms": {"optional_backend": ["win32"]}, + "canonical_imports": [], + "callables": {"agents.optional_parent.OptionalPublic": callable_contract}, + } + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else submodule + ), + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + { + "agents.optional_parent": { + "optional_bindings": {}, + "optional_exports": {"OptionalPublic": "optional_backend"}, + } + }, + dependency_installations=( + OptionalDependencyInstallation( + dependency_module="optional_backend", + extra="optional-provider", + unsupported_platforms=("win32",), + ), + ), + ), + ) + + assert updated["callables"]["agents.optional_parent.OptionalPublic"] == callable_contract + + +def test_release_contract_update_preserves_tracked_submodule_callable_on_platform_import_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class PlatformPublic: + def __init__(self, value: str) -> None: + self.value = value + + PlatformPublic.__module__ = "agents.platform_specific" + agents_module = SimpleNamespace(__all__=[]) + callable_contract = _callable_contract(PlatformPublic) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents.platform_specific"], + "required_submodule_exports": { + "agents.platform_specific": { + "names": ["PlatformPublic"], + "optional_bindings": {}, + "optional_exports": {}, + } + }, + "platform_import_errors": [ + { + "module": "agents.platform_specific", + "platforms": ["win32"], + "error_type": "ImportError", + "message_contains": "not supported on Windows", + } + ], + "canonical_imports": [], + "callables": {"agents.platform_specific.PlatformPublic": callable_contract}, + } + + def import_platform_module(module_name: str, _: Any) -> Any: + assert module_name == "agents.platform_specific" + raise ImportError("Backend is not supported on Windows.") + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(contract_support, "_import_contract_module", import_platform_module) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + ) + + assert updated["callables"]["agents.platform_specific.PlatformPublic"] == callable_contract + + def test_release_contract_policy_preserves_new_optional_export_in_core_install( monkeypatch: pytest.MonkeyPatch, ) -> None: From 2231eb5d40cd4a9d6b86f79492e984eeb3301263 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 10:18:01 +0900 Subject: [PATCH 278/473] fix: reject schemas that exceed safe recursion depth (#4358) --- src/agents/mcp/util.py | 6 +-- src/agents/strict_schema.py | 83 +++++++++++++++++++++++++++++++++---- src/agents/tool.py | 11 ++--- tests/mcp/test_mcp_util.py | 24 +++++++++++ tests/test_function_tool.py | 71 +++++++++++++++++++++++++++++++ tests/test_strict_schema.py | 40 ++++++++++++++++++ 6 files changed, 218 insertions(+), 17 deletions(-) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 3e7ade6d67..27d11149d5 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -19,7 +19,7 @@ from ..exceptions import AgentsException, MCPToolCancellationError, ModelBehaviorError, UserError from ..logger import log_tool_action_error, logger from ..run_context import RunContextWrapper -from ..strict_schema import ensure_strict_json_schema +from ..strict_schema import _copy_json_schema, ensure_strict_json_schema from ..tool import ( FunctionTool, Tool, @@ -535,7 +535,7 @@ def to_function_tool( effective_failure_error_function = server._get_failure_error_function( failure_error_function ) - schema, is_strict = copy.deepcopy(tool_input_schema(tool)), False + schema, is_strict = _copy_json_schema(tool_input_schema(tool)), False input_schema_is_empty = schema == {} # MCP spec doesn't require the inputSchema to have `properties`, but OpenAI spec does. @@ -550,7 +550,7 @@ def to_function_tool( # the original schema intact. try: schema = ensure_strict_json_schema( - copy.deepcopy(schema), + _copy_json_schema(schema), _reject_open_objects=not input_schema_is_empty, ) is_strict = True diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 6c9ce4bfb1..b1ec3866ad 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -20,6 +20,10 @@ # example, tool schemas advertised by a third-party MCP server). _MAX_SCHEMA_NODES = 100_000 +# Keep recursive copying and normalization comfortably below Python's recursion limit. This +# counts every nested dictionary or list, including schema maps such as `properties` and `$defs`. +_MAX_SCHEMA_DEPTH = 100 + _ADDITIONAL_PROPERTIES_ERROR = ( "additionalProperties should not be set for object types. This could be because " "you're using an older version of Pydantic, or because you configured additional " @@ -36,6 +40,32 @@ "JSON schema contains a reference whose target was not validated for strict mode." ) +_SCHEMA_DEPTH_ERROR = ( + "JSON schema is too deeply nested to process safely. Simplify or flatten the schema." +) + + +def _validate_json_schema_depth(schema: object) -> None: + """Reject container nesting that is unsafe for recursive schema processing.""" + stack: list[tuple[object, int]] = [(schema, 1)] + while stack: + value, depth = stack.pop() + if depth > _MAX_SCHEMA_DEPTH: + raise UserError(_SCHEMA_DEPTH_ERROR) + + if isinstance(value, dict): + stack.extend( + (child, depth + 1) for child in value.values() if isinstance(child, dict | list) + ) + elif isinstance(value, list): + stack.extend((child, depth + 1) for child in value if isinstance(child, dict | list)) + + +def _copy_json_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Copy a JSON schema only after verifying recursive copying is safe.""" + _validate_json_schema_depth(schema) + return copy.deepcopy(schema) + class _NodeBudget: """Tracks conversion state across the recursion.""" @@ -62,6 +92,7 @@ def ensure_strict_json_schema( """Mutates the given JSON schema to ensure it conforms to the `strict` standard that the OpenAI API expects. """ + _validate_json_schema_depth(schema) if schema == {}: return copy.deepcopy(_EMPTY_SCHEMA) budget = _NodeBudget(_MAX_SCHEMA_NODES, reject_open_objects=_reject_open_objects) @@ -99,7 +130,11 @@ def _ensure_strict_json_schema( path: tuple[str, ...], root: dict[str, object], budget: _NodeBudget | None = None, + depth: int = 1, ) -> dict[str, Any]: + if depth > _MAX_SCHEMA_DEPTH: + raise UserError(_SCHEMA_DEPTH_ERROR) + if not is_dict(json_schema): raise TypeError(f"Expected {json_schema} to be a dictionary; path={path}") @@ -108,12 +143,17 @@ def _ensure_strict_json_schema( if budget is None: budget = _NodeBudget(_MAX_SCHEMA_NODES) budget.spend() + next_depth = depth + 1 defs = json_schema.get("$defs") if is_dict(defs): for def_name, def_schema in defs.items(): _ensure_strict_json_schema( - def_schema, path=(*path, "$defs", def_name), root=root, budget=budget + def_schema, + path=(*path, "$defs", def_name), + root=root, + budget=budget, + depth=next_depth, ) definitions = json_schema.get("definitions") @@ -124,6 +164,7 @@ def _ensure_strict_json_schema( path=(*path, "definitions", definition_name), root=root, budget=budget, + depth=next_depth, ) typ = json_schema.get("type") @@ -159,7 +200,11 @@ def _ensure_strict_json_schema( json_schema["required"] = list(properties.keys()) json_schema["properties"] = { key: _ensure_strict_json_schema( - prop_schema, path=(*path, "properties", key), root=root, budget=budget + prop_schema, + path=(*path, "properties", key), + root=root, + budget=budget, + depth=next_depth, ) for key, prop_schema in properties.items() } @@ -169,7 +214,7 @@ def _ensure_strict_json_schema( items = json_schema.get("items") if is_dict(items): json_schema["items"] = _ensure_strict_json_schema( - items, path=(*path, "items"), root=root, budget=budget + items, path=(*path, "items"), root=root, budget=budget, depth=next_depth ) # unions @@ -177,7 +222,11 @@ def _ensure_strict_json_schema( if is_list(any_of): json_schema["anyOf"] = [ _ensure_strict_json_schema( - variant, path=(*path, "anyOf", str(i)), root=root, budget=budget + variant, + path=(*path, "anyOf", str(i)), + root=root, + budget=budget, + depth=next_depth, ) for i, variant in enumerate(any_of) ] @@ -192,7 +241,11 @@ def _ensure_strict_json_schema( existing_any_of = [] json_schema["anyOf"] = existing_any_of + [ _ensure_strict_json_schema( - variant, path=(*path, "oneOf", str(i)), root=root, budget=budget + variant, + path=(*path, "oneOf", str(i)), + root=root, + budget=budget, + depth=next_depth, ) for i, variant in enumerate(one_of) ] @@ -204,15 +257,25 @@ def _ensure_strict_json_schema( if len(all_of) == 1: json_schema.update( _ensure_strict_json_schema( - all_of[0], path=(*path, "allOf", "0"), root=root, budget=budget + all_of[0], + path=(*path, "allOf", "0"), + root=root, + budget=budget, + depth=next_depth, ) ) json_schema.pop("allOf") - return _ensure_strict_json_schema(json_schema, path=path, root=root, budget=budget) + return _ensure_strict_json_schema( + json_schema, path=path, root=root, budget=budget, depth=next_depth + ) else: json_schema["allOf"] = [ _ensure_strict_json_schema( - entry, path=(*path, "allOf", str(i)), root=root, budget=budget + entry, + path=(*path, "allOf", str(i)), + root=root, + budget=budget, + depth=next_depth, ) for i, entry in enumerate(all_of) ] @@ -246,7 +309,9 @@ def _ensure_strict_json_schema( json_schema.update({**resolved, **json_schema}) # Since the schema expanded from `$ref` might not have `additionalProperties: false` applied # we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid - return _ensure_strict_json_schema(json_schema, path=path, root=root, budget=budget) + return _ensure_strict_json_schema( + json_schema, path=path, root=root, budget=budget, depth=next_depth + ) if budget.reject_open_objects and "$ref" in json_schema: raise UserError(_UNVALIDATED_REF_ERROR) diff --git a/src/agents/tool.py b/src/agents/tool.py index 768a8e32bf..625348dadf 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -66,7 +66,7 @@ from .function_schema import DocstringStyle, function_schema, generate_func_documentation from .logger import log_tool_action_warning, logger from .run_context import RunContextWrapper -from .strict_schema import ensure_strict_json_schema +from .strict_schema import _copy_json_schema, ensure_strict_json_schema from .tool_context import ToolContext from .tool_guardrails import ToolInputGuardrail, ToolOutputGuardrail from .tracing import SpanError @@ -594,7 +594,7 @@ def __post_init__(self): self.on_invoke_tool = bind_to_function_tool(self) if self.strict_json_schema: self.params_json_schema = ensure_strict_json_schema( - copy.deepcopy(self.params_json_schema) + _copy_json_schema(self.params_json_schema) ) _validate_function_tool_timeout_config(self) @@ -2199,7 +2199,7 @@ def _build_function_tool_output_type( output_json_schema = output_type_adapter.json_schema(mode="serialization") if not _json_schema_is_object(output_json_schema): raise UserError("the generated JSON Schema is not an object schema") - output_json_schema = ensure_strict_json_schema(copy.deepcopy(output_json_schema)) + output_json_schema = ensure_strict_json_schema(_copy_json_schema(output_json_schema)) except Exception as error: raise UserError( "Function tool output_type must define a strict JSON object schema. " @@ -2234,7 +2234,7 @@ def _resolve_function_tool_output( return _build_function_tool_output_type(output_type) if output_json_schema is not None: - return copy.deepcopy(output_json_schema), None + return _copy_json_schema(output_json_schema), None if allowed_callers is None or "programmatic" not in allowed_callers: return None, None @@ -2737,8 +2737,9 @@ def _normalize_function_tool_output_json_schema( """Copy and normalize a declared function output schema as a strict object schema.""" if not isinstance(output_json_schema, dict) or not _json_schema_is_object(output_json_schema): raise UserError("Function tool output_json_schema must define a JSON object schema.") + copied_schema = _copy_json_schema(output_json_schema) try: - return ensure_strict_json_schema(copy.deepcopy(output_json_schema)) + return ensure_strict_json_schema(copied_schema) except Exception as error: raise UserError( "Function tool output_json_schema must define a strict JSON object schema." diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index fed250ea83..530ec679cd 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -60,6 +60,16 @@ def _convertible_schema() -> dict[str, Any]: return schema +def _nested_object_schema(depth: int) -> dict[str, Any]: + root: dict[str, Any] = {"type": "object", "properties": {}} + current = root + for _ in range(depth): + child: dict[str, Any] = {"type": "object", "properties": {}} + current["properties"]["child"] = child + current = child + return root + + @pytest.mark.asyncio async def test_get_all_function_tools(): """Test that the get_all_function_tools function returns all function tools from a list of MCP @@ -1875,6 +1885,20 @@ def test_to_function_tool_does_not_mutate_mcp_input_schema(): assert tool_input_schema(tool) == {"type": "object", "description": "Test tool"} +@pytest.mark.parametrize("convert_schemas_to_strict", [False, True]) +def test_to_function_tool_rejects_deep_schema_before_copying( + convert_schemas_to_strict: bool, +): + tool = MCPTool(name="deep_tool", inputSchema=_nested_object_schema(1_000)) + + with pytest.raises(UserError, match="too deeply nested"): + MCPUtil.to_function_tool( + tool, + FakeMCPServer(), + convert_schemas_to_strict=convert_schemas_to_strict, + ) + + def test_to_function_tool_failed_strict_conversion_keeps_original_schema(): # ``ensure_strict_json_schema`` mutates the schema in place. Until this is # isolated, a partially-mutated schema would be served as non-strict, leaking diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index f3b5aea683..ecaba2a761 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -40,6 +40,26 @@ def argless_function() -> str: return "ok" +def _nested_object_schema(depth: int) -> dict[str, Any]: + root: dict[str, Any] = {"type": "object", "properties": {}} + current = root + for _ in range(depth): + child: dict[str, Any] = {"type": "object", "properties": {}} + current["properties"]["child"] = child + current = child + return root + + +def _chained_ref_schema(depth: int) -> dict[str, Any]: + definitions: dict[str, Any] = {f"L{i}": {"$ref": f"#/$defs/L{i + 1}"} for i in range(depth)} + definitions[f"L{depth}"] = {"type": "string"} + return { + "$defs": definitions, + "type": "object", + "properties": {"value": {"$ref": "#/$defs/L0", "description": "value"}}, + } + + def test_tool_namespace_copies_tools_with_metadata() -> None: tool = function_tool(argless_function) @@ -1007,6 +1027,57 @@ async def noop(ctx: ToolContext[Any], input: str) -> str: assert tool.params_json_schema["required"] == ["x"] +def test_function_tool_rejects_deep_schema_before_copying() -> None: + async def noop(ctx: ToolContext[Any], input: str) -> str: + return "" + + schema = _nested_object_schema(1_000) + + with pytest.raises(UserError, match="too deeply nested"): + FunctionTool( + name="strict_tool", + description="Uses a strict schema", + params_json_schema=schema, + on_invoke_tool=noop, + ) + + non_strict_tool = FunctionTool( + name="non_strict_tool", + description="Uses the original schema", + params_json_schema=schema, + on_invoke_tool=noop, + strict_json_schema=False, + ) + assert non_strict_tool.params_json_schema is schema + + +def test_function_tool_rejects_deeply_chained_refs_before_conversion() -> None: + async def noop(ctx: ToolContext[Any], input: str) -> str: + return "" + + with pytest.raises(UserError, match="too deeply nested"): + FunctionTool( + name="strict_tool", + description="Uses a strict schema", + params_json_schema=_chained_ref_schema(1_000), + on_invoke_tool=noop, + ) + + +def test_function_tool_rejects_deep_output_schema_before_copying() -> None: + async def noop(ctx: ToolContext[Any], input: str) -> str: + return "" + + with pytest.raises(UserError, match="too deeply nested"): + FunctionTool( + name="output_tool", + description="Uses a structured output schema", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=noop, + output_json_schema=_nested_object_schema(1_000), + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("input_json", ["[]", '"value"', "123", "null", "true"]) async def test_function_tool_rejects_non_object_json_input(input_json: str) -> None: diff --git a/tests/test_strict_schema.py b/tests/test_strict_schema.py index 04093ea02e..56a6abd310 100644 --- a/tests/test_strict_schema.py +++ b/tests/test_strict_schema.py @@ -6,6 +6,28 @@ from agents.strict_schema import ensure_strict_json_schema +def _nested_object_schema(depth: int) -> dict[str, object]: + root: dict[str, object] = {"type": "object", "properties": {}} + current = root + for _ in range(depth): + child: dict[str, object] = {"type": "object", "properties": {}} + properties = current["properties"] + assert isinstance(properties, dict) + properties["child"] = child + current = child + return root + + +def _chained_ref_schema(depth: int) -> dict[str, object]: + definitions: dict[str, object] = {f"L{i}": {"$ref": f"#/$defs/L{i + 1}"} for i in range(depth)} + definitions[f"L{depth}"] = {"type": "string"} + return { + "$defs": definitions, + "type": "object", + "properties": {"value": {"$ref": "#/$defs/L0", "description": "value"}}, + } + + def test_empty_schema_has_additional_properties_false(): strict_schema = ensure_strict_json_schema({}) assert strict_schema["additionalProperties"] is False @@ -35,6 +57,24 @@ def test_non_dict_schema_errors(): ensure_strict_json_schema([]) # type: ignore +def test_deeply_nested_schema_is_rejected_before_recursive_conversion(): + with pytest.raises(UserError, match="too deeply nested"): + ensure_strict_json_schema(_nested_object_schema(1_000)) + + +def test_reasonably_nested_schema_remains_supported(): + schema = _nested_object_schema(10) + + result = ensure_strict_json_schema(schema) + + assert result["additionalProperties"] is False + + +def test_deeply_chained_refs_are_rejected_before_recursive_conversion(): + with pytest.raises(UserError, match="too deeply nested"): + ensure_strict_json_schema(_chained_ref_schema(1_000)) + + def test_object_without_additional_properties(): # When an object type schema has properties but no additionalProperties, # it should be added and the "required" list set from the property keys. From b2a460d42947c1393135b2f3ced13d494331c8e7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 10:49:00 +0900 Subject: [PATCH 279/473] chore: preserve review credit for verified type-erasure edits --- .../skills/implementation-final-review/SKILL.md | 15 +++++++++++---- .../references/reviewer-brief.md | 2 ++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index c915cb9d60..f82ac2c68c 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -9,7 +9,7 @@ Treat implementation and final review as separate phases. Reconstruct the change ## Non-negotiable guarantees -- Review the exact final task content, including committed, staged, unstaged, and task-owned untracked deliverables. +- Review the exact final task content, including committed, staged, unstaged, and task-owned untracked deliverables. The only exception is the narrowly verified final-gate type-erasure closure in step 20, which preserves clean credit through explicit identity evidence and still requires the complete final verification stack on the resulting fingerprint. - Use the merge-base three-dot diff for patch ownership and the latest release tag separately for released compatibility. - Require independent review. A same-context self-review cannot satisfy the clean-review gate. - Freeze task-owned content while reviewers inspect a fingerprint. @@ -55,7 +55,7 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor 10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. 11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the complete raw diff as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires the complete-diff digest to match its `tracked_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix and canonical root ownership, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. 12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. -13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged. Require two concurrent independent delta reviews of every changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. +13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence unless the change later qualifies for the final-gate type-erasure closure in step 20. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged, except for that narrowly recorded type-erasure closure. Require two concurrent independent delta reviews of every other changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. 14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, and reopening a closed canonical root without evidence IDs that are new to that root. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. 15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. 16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the canonical root-cause ID closed. Do not reopen it for another local patch without new contract evidence or a newly uncovered inventory ID; reject aliases, renamed IDs, and bare unknown IDs instead of treating them as new roots. If it cannot be closed coherently, escalate instead of consuming more rounds. @@ -69,11 +69,18 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor - normal-risk change: two independent clean reviews of the same fingerprint, launched concurrently; - elevated-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint with complementary high-risk specialties, launched concurrently. - component-only post-review edit: clean credit for every unchanged component plus two concurrent clean independent delta reviews covering all changed components and their runtime boundary. -20. After the clean-review condition is met, confirm that the diff and component fingerprints remain stable, then check observable host capacity before starting the repository's code-change verification. Use available read-only task or process evidence; treat another repository-wide test, typecheck, build, examples runner, or integration command already active on the same host as concrete contention. When contention is visible, continue useful non-heavy work or an event-driven wait and check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file, and do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. Once capacity is available, run every mandatory command in the repository-required order against the exact clean-reviewed fingerprint. Record combined, component, and repository fingerprints immediately before and after the final stack. Accept final verification only when every command succeeds, execution does not mutate reviewed content or create an ambiguous repository-state change, and all fingerprints still match. Classify any final-gate edit before invalidating review evidence: + - verified final-gate type-erasure closure satisfying every condition in step 20: preserve the prior clean set without a new fingerprint round or reviewer dispatch, then run the complete final verification stack on the resulting fingerprint. +20. After the clean-review condition is met, confirm that the diff and component fingerprints remain stable, then check observable host capacity before starting the repository's code-change verification. Use available read-only task or process evidence; treat another repository-wide test, typecheck, build, examples runner, or integration command already active on the same host as concrete contention. When contention is visible, continue useful non-heavy work or an event-driven wait and check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file, and do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. Once capacity is available, run every mandatory command in the repository-required order against the exact clean-reviewed fingerprint, or against the recorded resulting fingerprint of the verified type-erasure closure below. Record combined, component, and repository fingerprints immediately before and after the final stack. Accept final verification only when every command succeeds, execution does not mutate that final content or create an ambiguous repository-state change, and all fingerprints still match. Classify any final-gate edit before invalidating review evidence: + - Verified type-erasure-only edit: preserve the existing clean set without an independent delta review only when every condition below holds. This exception consumes no fingerprint round and requires no reviewer packet, but it does not grant final-gate credit; restart every mandatory final gate on the resulting fingerprint. + - The edit is made only after the final stack reports a formatter, linter, or static-type-checker failure, and the failure does not reveal unresolved runtime or contract uncertainty. + - The exact delta is limited to importing `cast` directly from the standard-library `typing` module, wrapping one unchanged private implementation expression as `cast(, )`, and formatter-only whitespace. The imported name is not rebound or used elsewhere. + - The edit does not change expression evaluation order or count, exception propagation, a public or exported annotation or signature, a decorator, runtime branch, constant, test assertion, generated surface, documentation, scope contract, inventory row, component dependency, or risk tier. + - The implementer records the before and after fingerprints, the exact delta, the original final-gate failure, and the runtime-identity basis that `typing.cast` returns its value unchanged. Targeted formatting, lint, type checking, and affected focused tests must pass before restarting the full stack. + - Any additional token change, behavioral-equivalence argument beyond this exact `typing.cast` shape, or uncertainty about the conditions above falls through to the normal runtime-edit rule and requires the applicable independent delta review. - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. - - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean or delta-review evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. + - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean, delta-review, or verified type-erasure-closure evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. 21. Stop the autonomous loop when the active cycle reaches its current budget: six fingerprint rounds for the initial implementation cycle or two for a post-completion feedback cycle. This is an absolute cap for the active cycle, not a target, and it does not reset when execution pauses or context changes. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or explicitly authorize another bounded budget. When concrete actionable feedback arrives after a successfully completed and sealed cycle, append the feedback cycle's default two-round budget to the same ledger without another authorization prompt. In every other case, append a user-authorized budget to the same ledger rather than replacing its history. Maintain one compact round ledger throughout all review cycles and persist it as a durable, task-global artifact: diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index 54ccdcc88f..ae60e571d7 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -2,6 +2,8 @@ Use this template to prepare one self-contained, factual snapshot packet per fingerprint round. Fill every field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Fill it once, reuse the shared body byte-for-byte for every reviewer, and vary only the final specialty assignment. Keep this control-plane brief near 12 KB when practical. Store larger evidence in indexed files and reference each file by exact path and SHA-256 digest. Do not omit decision-relevant evidence merely to meet the soft size target. Do not include implementer conclusions, suspected bugs, prior findings, or intended fixes. +The verified final-gate type-erasure closure defined in `SKILL.md` step 20 does not create a fingerprint round, reviewer packet, or reviewer assignment. Record its exact delta, before and after fingerprints, final-gate failure, runtime-identity basis, and focused verification in the task-global ledger and final verification evidence. If any condition for that exception is not mechanically established, prepare the normal delta-review packet instead. + ## Shared evidence - Original requirement: From 23da2b6254ce285762cdef4452c9bb8d9f5e36ef Mon Sep 17 00:00:00 2001 From: Lucca Boas <86315612+Luccacvb@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:52:33 -0300 Subject: [PATCH 280/473] fix(chat-completions): omit parallel_tool_calls without tools on the Chat Completions path (#4359) --- src/agents/models/openai_chatcompletions.py | 11 ++-- tests/models/test_kwargs_functionality.py | 61 ++++++++++++++++++- tests/models/test_openai_chatcompletions.py | 15 ++++- .../test_openai_chatcompletions_stream.py | 4 +- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 229cd65513..c5e4509126 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -631,12 +631,6 @@ async def _fetch_response( if tracing.include_data(): span.span_data.input = converted_messages - if model_settings.parallel_tool_calls and tools: - parallel_tool_calls: bool | Omit = True - elif model_settings.parallel_tool_calls is False: - parallel_tool_calls = False - else: - parallel_tool_calls = omit tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) response_format = Converter.convert_response_format(output_schema) @@ -647,6 +641,11 @@ async def _fetch_response( converted_tools = _to_dump_compatible(converted_tools) tools_param = converted_tools if converted_tools else omit + # Chat Completions rejects parallel_tool_calls unless tools are present, so derive it + # from the converted list, which also covers handoff-only turns. + parallel_tool_calls: bool | Omit = ( + self._non_null_or_omit(model_settings.parallel_tool_calls) if converted_tools else omit + ) if _debug.DONT_LOG_MODEL_DATA: logger.debug("Calling LLM") diff --git a/tests/models/test_kwargs_functionality.py b/tests/models/test_kwargs_functionality.py index d87a063cbe..7c5438adf4 100644 --- a/tests/models/test_kwargs_functionality.py +++ b/tests/models/test_kwargs_functionality.py @@ -6,7 +6,7 @@ from httpx import Headers, Response from litellm.exceptions import RateLimitError from litellm.types.utils import Choices, Message, ModelResponse, Usage -from openai import APIConnectionError +from openai import APIConnectionError, omit from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.completion_usage import CompletionUsage @@ -110,6 +110,65 @@ async def fake_acompletion(model, messages=None, **kwargs): assert (captured["tools"] is not None) is (tool_source != "none") +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("parallel_tool_calls", [True, False]) +@pytest.mark.parametrize("tool_source", ["none", "function", "handoff"]) +async def test_openai_only_forwards_parallel_tool_calls_with_converted_tools( + parallel_tool_calls: bool, tool_source: str +): + captured: dict[str, object] = {} + + class MockChatCompletions: + async def create(self, **kwargs): + captured.update(kwargs) + msg = ChatCompletionMessage(role="assistant", content="test response") + return ChatCompletion( + id="test-id", + created=0, + model="gpt-4", + object="chat.completion", + choices=[Choice(index=0, message=msg, finish_reason="stop")], + usage=CompletionUsage(completion_tokens=5, prompt_tokens=10, total_tokens=15), + ) + + class MockChat: + def __init__(self): + self.completions = MockChatCompletions() + + class MockClient: + def __init__(self): + self.chat = MockChat() + self.base_url = "https://api.openai.com/v1" + + tools: list[Tool] = ( + [function_tool(lambda: "ok", name_override="test_tool")] + if tool_source == "function" + else [] + ) + handoffs = [handoff(Agent(name="handoff"))] if tool_source == "handoff" else [] + + model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=MockClient()) # type: ignore + await model.get_response( + system_instructions=None, + input="test input", + model_settings=ModelSettings(parallel_tool_calls=parallel_tool_calls), + tools=tools, + output_schema=None, + handoffs=handoffs, + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + ) + + if tool_source == "none": + assert captured["parallel_tool_calls"] is omit + assert captured["tools"] is omit + else: + assert captured["parallel_tool_calls"] is parallel_tool_calls + assert captured["tools"] is not omit + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("use_dictionary", [False, True], ids=["model-settings", "dictionary"]) diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 1c764f388d..6120aa9cfa 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -46,6 +46,7 @@ OpenAIProvider, Runner, __version__, + function_tool, generation_span, trace, ) @@ -53,6 +54,7 @@ from agents.models._retry_runtime import provider_managed_retries_disabled from agents.models.chatcmpl_helpers import HEADERS_OVERRIDE, ChatCmplHelpers from agents.models.fake_id import FAKE_RESPONSES_ID +from agents.tool import Tool from tests.testing_processor import fetch_ordered_spans @@ -74,6 +76,7 @@ def _minimal_chat_completion(content: str = "ok") -> ChatCompletion: async def _run_chat_completions_model_with_custom_base_url( model_settings: ModelSettings | dict[str, Any] | None = None, + tools: list[Tool] | None = None, ) -> dict[str, Any]: class DummyCompletions: def __init__(self) -> None: @@ -105,7 +108,12 @@ def __init__(self, completions: DummyCompletions) -> None: model="gpt-4", openai_client=DummyClient(completions), # type: ignore[arg-type] ) - agent = Agent(name="test", model=model, model_settings=model_settings or ModelSettings()) + agent = Agent( + name="test", + model=model, + model_settings=model_settings or ModelSettings(), + tools=tools or [], + ) await Runner.run(agent, "hi") @@ -1076,7 +1084,10 @@ async def test_chat_completions_requests_normalize_dictionary_agent_settings( }, } kwargs = await _run_chat_completions_model_with_custom_base_url( - model_settings=settings if use_dictionary else ModelSettings(**settings) + model_settings=settings if use_dictionary else ModelSettings(**settings), + # parallel_tool_calls is only forwarded alongside tools, so this parity check + # needs a tool for that setting to reach the request. + tools=[function_tool(lambda: "ok", name_override="test_tool")], ) assert kwargs["reasoning_effort"] == "high" diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index dc929c412d..93c08717af 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -210,7 +210,9 @@ def __init__(self, completions: DummyCompletions) -> None: system_instructions=None, input="hi", model_settings=agent.model_settings, - tools=[], + # parallel_tool_calls is only forwarded alongside tools, so this parity check + # needs a tool for that setting to reach the request. + tools=[function_tool(lambda: "ok", name_override="test_tool")], output_schema=None, handoffs=[], tracing=ModelTracing.DISABLED, From 27c1060185b2fcb1cad756adab201cd2d4e9769c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 11:25:19 +0900 Subject: [PATCH 281/473] fix: reject unsafe strict-schema ref siblings (#4356) Co-authored-by: snowingfox <1503401882@qq.com> --- src/agents/strict_schema.py | 197 ++++++++++++- tests/mcp/test_mcp_util.py | 321 +++++++++++++++++++++ tests/test_strict_schema.py | 554 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1063 insertions(+), 9 deletions(-) diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index b1ec3866ad..15b4c652fd 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -1,7 +1,7 @@ from __future__ import annotations import copy -from typing import Any, TypeGuard +from typing import Any, TypeGuard, cast from openai import NOT_GIVEN @@ -39,6 +39,10 @@ _UNVALIDATED_REF_ERROR = ( "JSON schema contains a reference whose target was not validated for strict mode." ) +_NESTED_RESOURCE_REF_ERROR = ( + "JSON schema contains a reference owned by or crossing a nested `$id` resource that cannot " + "be resolved against the document root." +) _SCHEMA_DEPTH_ERROR = ( "JSON schema is too deeply nested to process safely. Simplify or flatten the schema." @@ -67,6 +71,30 @@ def _copy_json_schema(schema: dict[str, Any]) -> dict[str, Any]: return copy.deepcopy(schema) +# Keywords that may legally sit alongside a `$ref` without adding validation constraints. +# Definition maps are also allowed because they do not directly constrain an instance. +_REF_NON_CONSTRAINING_SIBLINGS = frozenset( + { + "$anchor", + "$comment", + "$defs", + "$schema", + "contentEncoding", + "contentMediaType", + "contentSchema", + "default", + "definitions", + "deprecated", + "description", + "examples", + "readOnly", + "title", + "writeOnly", + } +) +_ROOT_REF_NON_CONSTRAINING_SIBLINGS = _REF_NON_CONSTRAINING_SIBLINGS | {"$id"} + + class _NodeBudget: """Tracks conversion state across the recursion.""" @@ -131,6 +159,7 @@ def _ensure_strict_json_schema( root: dict[str, object], budget: _NodeBudget | None = None, depth: int = 1, + inside_nested_resource: bool = False, ) -> dict[str, Any]: if depth > _MAX_SCHEMA_DEPTH: raise UserError(_SCHEMA_DEPTH_ERROR) @@ -144,6 +173,25 @@ def _ensure_strict_json_schema( budget = _NodeBudget(_MAX_SCHEMA_NODES) budget.spend() next_depth = depth + 1 + inside_nested_resource = inside_nested_resource or ( + json_schema is not root and _declares_schema_resource(json_schema) + ) + + if "$ref" in json_schema: + if inside_nested_resource: + raise UserError(_NESTED_RESOURCE_REF_ERROR) + allowed_siblings = ( + _ROOT_REF_NON_CONSTRAINING_SIBLINGS + if json_schema is root + else _REF_NON_CONSTRAINING_SIBLINGS + ) + incompatible_siblings = set(json_schema) - {"$ref"} - allowed_siblings + if incompatible_siblings: + raise UserError( + "JSON schema contains a `$ref` with incompatible sibling keyword(s) " + f"({', '.join(sorted(incompatible_siblings))}) that cannot be merged " + "without changing its accepted values." + ) defs = json_schema.get("$defs") if is_dict(defs): @@ -154,6 +202,7 @@ def _ensure_strict_json_schema( root=root, budget=budget, depth=next_depth, + inside_nested_resource=inside_nested_resource, ) definitions = json_schema.get("definitions") @@ -165,6 +214,7 @@ def _ensure_strict_json_schema( root=root, budget=budget, depth=next_depth, + inside_nested_resource=inside_nested_resource, ) typ = json_schema.get("type") @@ -205,6 +255,7 @@ def _ensure_strict_json_schema( root=root, budget=budget, depth=next_depth, + inside_nested_resource=inside_nested_resource, ) for key, prop_schema in properties.items() } @@ -214,7 +265,12 @@ def _ensure_strict_json_schema( items = json_schema.get("items") if is_dict(items): json_schema["items"] = _ensure_strict_json_schema( - items, path=(*path, "items"), root=root, budget=budget, depth=next_depth + items, + path=(*path, "items"), + root=root, + budget=budget, + depth=next_depth, + inside_nested_resource=inside_nested_resource, ) # unions @@ -227,6 +283,7 @@ def _ensure_strict_json_schema( root=root, budget=budget, depth=next_depth, + inside_nested_resource=inside_nested_resource, ) for i, variant in enumerate(any_of) ] @@ -246,6 +303,7 @@ def _ensure_strict_json_schema( root=root, budget=budget, depth=next_depth, + inside_nested_resource=inside_nested_resource, ) for i, variant in enumerate(one_of) ] @@ -255,18 +313,40 @@ def _ensure_strict_json_schema( all_of = json_schema.get("allOf") if is_list(all_of): if len(all_of) == 1: - json_schema.update( - _ensure_strict_json_schema( - all_of[0], + entry = all_of[0] + if ( + is_dict(entry) + and "$ref" in entry + and not (set(entry) - {"$ref"} - _REF_NON_CONSTRAINING_SIBLINGS) + ): + if inside_nested_resource: + raise UserError(_NESTED_RESOURCE_REF_ERROR) + budget.spend() + strict_entry = _resolve_non_constraining_ref_chain( + root=root, + schema=entry, + budget=budget, + ) + else: + strict_entry = _ensure_strict_json_schema( + entry, path=(*path, "allOf", "0"), root=root, budget=budget, depth=next_depth, + inside_nested_resource=inside_nested_resource, ) - ) json_schema.pop("allOf") + merged = _merge_single_all_of(entry=strict_entry, parent=json_schema) + json_schema.clear() + json_schema.update(merged) return _ensure_strict_json_schema( - json_schema, path=path, root=root, budget=budget, depth=next_depth + json_schema, + path=path, + root=root, + budget=budget, + depth=next_depth, + inside_nested_resource=inside_nested_resource, ) else: json_schema["allOf"] = [ @@ -276,6 +356,7 @@ def _ensure_strict_json_schema( root=root, budget=budget, depth=next_depth, + inside_nested_resource=inside_nested_resource, ) for i, entry in enumerate(all_of) ] @@ -310,7 +391,12 @@ def _ensure_strict_json_schema( # Since the schema expanded from `$ref` might not have `additionalProperties: false` applied # we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid return _ensure_strict_json_schema( - json_schema, path=path, root=root, budget=budget, depth=next_depth + json_schema, + path=path, + root=root, + budget=budget, + depth=next_depth, + inside_nested_resource=inside_nested_resource, ) if budget.reject_open_objects and "$ref" in json_schema: @@ -325,16 +411,109 @@ def resolve_ref(*, root: dict[str, object], ref: str) -> object: path = ref[2:].split("/") resolved = root - for key in path: + for raw_key in path: + key = raw_key.replace("~1", "/").replace("~0", "~") value = resolved[key] assert is_dict(value), ( f"encountered non-dictionary entry while resolving {ref} - {resolved}" ) resolved = value + if _declares_schema_resource(resolved): + raise UserError(_NESTED_RESOURCE_REF_ERROR) return resolved +def _declares_schema_resource(schema: dict[str, object]) -> bool: + return isinstance(schema.get("$id"), str) + + +def _resolve_non_constraining_ref_chain( + *, + root: dict[str, object], + schema: dict[str, Any], + budget: _NodeBudget, +) -> dict[str, Any]: + resolved = schema + seen_refs: set[str] = set() + carried_siblings: dict[str, Any] = {} + while True: + if "$ref" not in resolved: + break + if set(resolved) - {"$ref"} - _REF_NON_CONSTRAINING_SIBLINGS: + break + + ref = resolved["$ref"] + assert isinstance(ref, str), f"Received non-string $ref - {ref}" + if ref in seen_refs: + raise UserError("JSON schema contains a circular `$ref` chain.") + seen_refs.add(ref) + + carried_siblings = { + **{key: value for key, value in resolved.items() if key != "$ref"}, + **carried_siblings, + } + budget.spend() + target = resolve_ref(root=root, ref=ref) + if not is_dict(target): + raise ValueError(f"Expected `$ref: {ref}` to resolved to a dictionary but got {target}") + resolved = target + return {**resolved, **carried_siblings} + + +def _merge_single_all_of( + *, + entry: dict[str, Any], + parent: dict[str, Any], +) -> dict[str, Any]: + merged = dict(entry) + incompatible_overlaps: list[str] = [] + for key, parent_value in parent.items(): + if key not in merged: + merged[key] = parent_value + elif key in _REF_NON_CONSTRAINING_SIBLINGS: + merged[key] = parent_value + elif _json_values_equal(parent_value, merged[key]): + continue + elif (key == "properties" and parent_value == {}) or ( + key == "required" and parent_value == [] + ): + continue + else: + incompatible_overlaps.append(key) + + if incompatible_overlaps: + raise UserError( + "JSON schema contains a singleton `allOf` entry with incompatible parent " + f"keyword(s) ({', '.join(sorted(incompatible_overlaps))}) that cannot be merged " + "without changing its accepted values." + ) + return merged + + +def _json_values_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + if isinstance(left, int | float) and isinstance(right, int | float): + return left == right + if isinstance(left, list) or isinstance(right, list): + if not isinstance(left, list) or not isinstance(right, list): + return False + return len(left) == len(right) and all( + _json_values_equal(left_item, right_item) + for left_item, right_item in zip(left, right, strict=False) + ) + if isinstance(left, dict) or isinstance(right, dict): + if not isinstance(left, dict) or not isinstance(right, dict): + return False + return left.keys() == right.keys() and all( + _json_values_equal(left[key], right[key]) for key in left + ) + if type(left) is not type(right): + return False + return cast(bool, left == right) + + def is_dict(obj: object) -> TypeGuard[dict[str, object]]: # just pretend that we know there are only `str` keys # as that check is not worth the performance cost diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 530ec679cd..f63560a7cd 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -1923,6 +1923,327 @@ def test_to_function_tool_failed_strict_conversion_keeps_original_schema(): } +@pytest.mark.parametrize( + "node_schema", + [ + { + "properties": {"a": {"type": "string"}}, + "required": ["a"], + "$ref": "#/$defs/T", + }, + { + "$ref": "#/$defs/T", + "allOf": [{"$ref": "#/$defs/U"}], + }, + {"$ref": "#/$defs/T", "additionalProperties": False}, + ], + ids=["overlapping-properties", "singleton-all-of", "interacting-object-constraints"], +) +def test_to_function_tool_ref_sibling_falls_back_to_original_schema(node_schema): + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"b": {"type": "string"}}, + "required": ["b"], + }, + "U": {"type": "integer"}, + }, + "type": "object", + "properties": {"node": node_schema}, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_ref_with_schema_metadata_remains_strict(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "value": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/$defs/T", + } + }, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is True + assert function_tool.params_json_schema["properties"]["value"] == { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + } + assert function_tool.params_json_schema["additionalProperties"] is False + + +def test_to_function_tool_ref_with_nested_id_falls_back_to_original_schema(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "value": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "$ref": "#/$defs/T", + } + }, + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_ref_with_anchor_remains_strict(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "value": { + "$anchor": "value", + "$ref": "#/$defs/T", + } + }, + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is True + assert function_tool.params_json_schema["properties"]["value"] == { + "$anchor": "value", + "type": "string", + } + assert function_tool.params_json_schema["additionalProperties"] is False + + +def test_to_function_tool_single_all_of_annotated_alias_remains_strict(): + schema = { + "components": { + "schemas": { + "Inner": { + "type": "object", + "description": "inner", + "properties": {"value": {"type": "string"}}, + "additionalProperties": False, + }, + "Outer": { + "$ref": "#/components/schemas/Inner", + "description": "outer", + }, + } + }, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Outer", + "title": "entry", + } + ], + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is True + assert function_tool.params_json_schema["description"] == "outer" + assert function_tool.params_json_schema["title"] == "entry" + assert function_tool.params_json_schema["properties"] == {"value": {"type": "string"}} + assert function_tool.params_json_schema["required"] == ["value"] + assert function_tool.params_json_schema["additionalProperties"] is False + assert "$ref" not in function_tool.params_json_schema + + +def test_to_function_tool_single_all_of_annotated_entry_conflict_falls_back(): + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"inner": {"type": "string"}}, + "additionalProperties": False, + } + }, + "type": "object", + "properties": {"outer": {"type": "string"}}, + "allOf": [{"$ref": "#/$defs/T", "description": "alias"}], + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_nested_single_all_of_conflict_falls_back(): + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"inner": {"type": "string"}}, + "additionalProperties": False, + } + }, + "type": "object", + "properties": {"outer": {"type": "string"}}, + "allOf": [{"allOf": [{"$ref": "#/$defs/T"}]}], + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_single_all_of_json_distinct_overlap_falls_back(): + schema = { + "$defs": {"T": {"const": 1}}, + "type": "object", + "const": True, + "properties": {}, + "allOf": [{"$ref": "#/$defs/T"}], + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_single_all_of_nested_id_falls_back(): + schema = { + "$defs": {"T": {"type": "string"}}, + "contentSchema": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "type": "object", + "properties": {"value": {"$ref": "#/$defs/T"}}, + }, + "type": "object", + "properties": {}, + "allOf": [{"$ref": "#/contentSchema"}], + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_single_all_of_nested_id_owner_falls_back(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "node": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "allOf": [{"$ref": "#/$defs/T"}], + } + }, + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_single_all_of_descendant_nested_id_owner_falls_back(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "node": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "type": "object", + "properties": { + "child": { + "allOf": [{"$ref": "#/$defs/T"}], + } + }, + "additionalProperties": False, + } + }, + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_single_all_of_target_below_nested_id_falls_back(): + schema = { + "$defs": {"T": {"type": "string"}}, + "contentSchema": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "target": {"$ref": "#/$defs/T"}, + }, + "type": "object", + "properties": {}, + "allOf": [{"$ref": "#/contentSchema/target"}], + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is False + assert function_tool.params_json_schema == schema + + +def test_to_function_tool_ref_allows_unrelated_id_definition_name(): + schema = { + "$defs": { + "$id": {"type": "integer"}, + "T": {"type": "string"}, + }, + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/T", + "description": "value", + } + }, + "additionalProperties": False, + } + tool = MCPTool(name="test_tool", inputSchema=schema) + + function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True) + + assert function_tool.strict_json_schema is True + assert function_tool.params_json_schema["properties"]["value"] == { + "type": "string", + "description": "value", + } + + @pytest.mark.parametrize( "free_form_schema", [ diff --git a/tests/test_strict_schema.py b/tests/test_strict_schema.py index 56a6abd310..bc17e7eefb 100644 --- a/tests/test_strict_schema.py +++ b/tests/test_strict_schema.py @@ -1,4 +1,5 @@ import copy +from collections import OrderedDict import pytest @@ -344,6 +345,351 @@ def test_allOf_single_entry_merging(): assert result["properties"]["a"]["type"] == "boolean" +def test_allOf_single_ref_entry_merging(): + schema = { + "$defs": { + "Inner": { + "type": "object", + "properties": {"b": {"type": "string"}}, + "required": ["b"], + }, + "Outer": {"$ref": "#/$defs/Inner"}, + }, + "type": "object", + "allOf": [{"$ref": "#/$defs/Outer"}], + } + + result = ensure_strict_json_schema(schema) + + assert "allOf" not in result + assert "$ref" not in result + assert result["type"] == "object" + assert result["properties"] == {"b": {"type": "string"}} + assert result["required"] == ["b"] + assert result["additionalProperties"] is False + + +def test_allOf_single_ref_entry_preserves_annotated_aliases(): + schema = { + "components": { + "schemas": { + "Inner": { + "type": "object", + "description": "inner", + "properties": {"value": {"type": "string"}}, + }, + "Outer": { + "$ref": "#/components/schemas/Inner", + "description": "outer", + }, + } + }, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Outer", + "title": "entry", + } + ], + } + + result = ensure_strict_json_schema(schema) + + assert "$ref" not in result + assert result["description"] == "outer" + assert result["title"] == "entry" + assert result["properties"] == {"value": {"type": "string"}} + assert result["required"] == ["value"] + assert result["additionalProperties"] is False + + +def test_allOf_single_ref_entry_rejects_overlapping_parent_constraints(): + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"inner": {"type": "string"}}, + } + }, + "type": "object", + "properties": {"outer": {"type": "string"}}, + "allOf": [{"$ref": "#/$defs/T", "description": "alias"}], + } + + with pytest.raises(UserError, match="singleton `allOf`"): + ensure_strict_json_schema(schema) + + +def test_nested_single_allOf_rejects_overlapping_parent_constraints(): + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"inner": {"type": "string"}}, + } + }, + "type": "object", + "properties": {"outer": {"type": "string"}}, + "allOf": [{"allOf": [{"$ref": "#/$defs/T"}]}], + } + + with pytest.raises(UserError, match="singleton `allOf`"): + ensure_strict_json_schema(schema) + + +@pytest.mark.parametrize( + ("referenced_value", "parent_value"), + [ + (1, True), + ({"nested": [1]}, {"nested": [True]}), + ], + ids=["top-level", "nested"], +) +def test_allOf_single_ref_entry_rejects_json_distinct_equal_python_values( + referenced_value, parent_value +): + schema = { + "$defs": {"T": {"const": referenced_value}}, + "const": parent_value, + "allOf": [{"$ref": "#/$defs/T"}], + } + + with pytest.raises(UserError, match="singleton `allOf`"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_ref_entry_accepts_equal_json_numbers(): + schema = { + "$defs": {"T": {"const": 1}}, + "const": 1.0, + "allOf": [{"$ref": "#/$defs/T"}], + } + + result = ensure_strict_json_schema(schema) + + assert result["const"] == 1 + assert isinstance(result["const"], int) + + +def test_allOf_single_ref_entry_accepts_equal_mapping_subclasses(): + schema = { + "$defs": {"T": {"const": OrderedDict([("nested", [1])])}}, + "const": {"nested": [1]}, + "allOf": [{"$ref": "#/$defs/T"}], + } + + result = ensure_strict_json_schema(schema) + + assert result["const"] == {"nested": [1]} + + +def test_allOf_single_circular_ref_is_rejected(): + schema = { + "$defs": { + "A": {"$ref": "#/$defs/B"}, + "B": {"$ref": "#/$defs/A"}, + }, + "type": "object", + "allOf": [{"$ref": "#/$defs/A"}], + } + + with pytest.raises(UserError, match="circular"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_annotated_circular_ref_is_rejected(): + schema = { + "components": { + "schemas": { + "A": {"$ref": "#/components/schemas/B", "description": "a"}, + "B": {"$ref": "#/components/schemas/A", "description": "b"}, + } + }, + "type": "object", + "allOf": [{"$ref": "#/components/schemas/A"}], + } + + with pytest.raises(UserError, match="circular"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_ref_chain_spends_node_budget(monkeypatch): + monkeypatch.setattr("agents.strict_schema._MAX_SCHEMA_NODES", 4) + schema = { + "components": { + "schemas": { + "A": {"$ref": "#/components/schemas/B"}, + "B": {"$ref": "#/components/schemas/C"}, + "C": {"type": "object", "properties": {}}, + } + }, + "type": "object", + "allOf": [{"$ref": "#/components/schemas/A"}], + } + + with pytest.raises(UserError, match="too large"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_ref_rejects_nested_id_before_promoting_target(): + schema = { + "$defs": {"T": {"type": "string"}}, + "contentSchema": { + "$id": "https://example.test/nested", + "$ref": "#/$defs/T", + }, + "type": "object", + "allOf": [{"$ref": "#/contentSchema"}], + } + + with pytest.raises(UserError, match=r"nested `\$id`"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_ref_rejects_nested_id_owner_before_resolution(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "node": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "allOf": [{"$ref": "#/$defs/T"}], + } + }, + } + + with pytest.raises(UserError, match=r"nested `\$id` resource"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_ref_rejects_descendant_nested_id_owner_before_resolution(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "node": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "type": "object", + "properties": { + "child": { + "allOf": [{"$ref": "#/$defs/T"}], + } + }, + } + }, + } + + with pytest.raises(UserError, match=r"nested `\$id` resource"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_ref_rejects_promoted_nested_id_with_descendant_ref(): + schema = { + "$defs": {"T": {"type": "string"}}, + "contentSchema": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "type": "object", + "properties": {"value": {"$ref": "#/$defs/T"}}, + }, + "type": "object", + "allOf": [{"$ref": "#/contentSchema"}], + } + + with pytest.raises(UserError, match=r"nested `\$id` resource"): + ensure_strict_json_schema(schema) + + +def test_allOf_single_ref_rejects_target_below_nested_id_resource(): + schema = { + "$defs": {"T": {"type": "string"}}, + "contentSchema": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "target": {"$ref": "#/$defs/T"}, + }, + "type": "object", + "allOf": [{"$ref": "#/contentSchema/target"}], + } + + with pytest.raises(UserError, match=r"nested `\$id` resource"): + ensure_strict_json_schema(schema) + + +@pytest.mark.parametrize( + ("container", "ref"), + [ + ({"$defs": {"$id": {"type": "object", "properties": {}}}}, "#/$defs/$id"), + ( + {"components": {"schemas": {"$id": {"type": "object", "properties": {}}}}}, + "#/components/schemas/$id", + ), + ], + ids=["defs", "components-schemas"], +) +def test_allOf_single_ref_allows_id_as_schema_map_member_name(container, ref): + schema = { + **container, + "type": "object", + "allOf": [{"$ref": ref}], + } + + result = ensure_strict_json_schema(schema) + + assert result["type"] == "object" + assert result["properties"] == {} + assert result["additionalProperties"] is False + + +def test_ref_allows_unrelated_id_definition_name(): + schema = { + "$defs": { + "$id": {"type": "integer"}, + "T": {"type": "string"}, + }, + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/T", + "description": "value", + } + }, + } + + result = ensure_strict_json_schema(schema) + + assert result["properties"]["value"] == { + "type": "string", + "description": "value", + } + + +@pytest.mark.parametrize( + ("definition_name", "ref_token"), + [("a/b", "a~1b"), ("a~b", "a~0b"), ("a~1b", "a~01b")], + ids=["slash", "tilde", "replacement-order"], +) +def test_allOf_single_ref_entry_decodes_json_pointer_tokens(definition_name, ref_token): + schema = { + "$defs": { + definition_name: { + "type": "object", + "properties": {"value": {"type": "string"}}, + } + }, + "type": "object", + "allOf": [{"$ref": f"#/$defs/{ref_token}"}], + } + + result = ensure_strict_json_schema(schema) + + assert result["properties"] == {"value": {"type": "string"}} + assert result["required"] == ["value"] + assert result["additionalProperties"] is False + + @pytest.mark.parametrize("additional_properties", [True, {}], ids=["true", "schema"]) def test_allOf_single_entry_cannot_overwrite_strict_object(additional_properties): schema = { @@ -436,3 +782,211 @@ def test_ref_expansion_bomb_is_rejected(): } with pytest.raises(UserError): ensure_strict_json_schema(schema) + + +def test_ref_with_incompatible_sibling_is_rejected(): + # Parent-wins merging would silently discard the referenced constraints on `b`. + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"b": {"type": "string"}}, + "required": ["b"], + } + }, + "type": "object", + "properties": { + "node": { + "properties": {"a": {"type": "string"}}, + "required": ["a"], + "$ref": "#/$defs/T", + } + }, + } + + with pytest.raises(UserError, match="incompatible sibling"): + ensure_strict_json_schema(schema) + + +def test_ref_with_incompatible_type_sibling_is_rejected(): + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"b": {"type": "string"}}, + "required": ["b"], + } + }, + "type": "object", + "properties": {"node": {"type": "string", "$ref": "#/$defs/T"}}, + } + + with pytest.raises(UserError, match="incompatible sibling"): + ensure_strict_json_schema(schema) + + +def test_ref_with_single_all_of_sibling_is_rejected(): + schema = { + "$defs": { + "A": {"type": "string"}, + "B": {"type": "integer"}, + }, + "type": "object", + "properties": { + "node": { + "$ref": "#/$defs/A", + "allOf": [{"$ref": "#/$defs/B"}], + } + }, + } + + with pytest.raises(UserError, match="incompatible sibling"): + ensure_strict_json_schema(schema) + + +def test_ref_with_validation_sibling_is_rejected(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": {"node": {"$ref": "#/$defs/T", "minLength": 1}}, + } + + with pytest.raises(UserError, match="incompatible sibling"): + ensure_strict_json_schema(schema) + + +def test_ref_with_interacting_object_sibling_is_rejected(): + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"b": {"type": "string"}}, + "required": ["b"], + "additionalProperties": False, + } + }, + "type": "object", + "properties": { + "node": { + "$ref": "#/$defs/T", + "additionalProperties": False, + } + }, + } + + with pytest.raises(UserError, match="incompatible sibling"): + ensure_strict_json_schema(schema) + + +def test_ref_with_annotation_sibling_is_still_expanded(): + # Annotation-only siblings (description/title/... ) do not constrain the accepted + # values, so a `$ref` carrying them must keep expanding into the referent. + schema = { + "$defs": { + "T": { + "type": "object", + "properties": {"b": {"type": "string"}}, + "required": ["b"], + } + }, + "type": "object", + "properties": { + "node": { + "contentMediaType": "application/json", + "description": "a node", + "title": "Node", + "$ref": "#/$defs/T", + } + }, + } + + result = ensure_strict_json_schema(schema) + node = result["properties"]["node"] + assert node["type"] == "object" + assert node["properties"] == {"b": {"type": "string"}} + assert node["required"] == ["b"] + assert node["description"] == "a node" + assert node["title"] == "Node" + assert node["contentMediaType"] == "application/json" + assert "$ref" not in node + + +def test_ref_with_schema_metadata_is_still_expanded(): + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "T": { + "type": "object", + "properties": {"value": {"type": "string"}}, + } + }, + "$ref": "#/$defs/T", + } + + result = ensure_strict_json_schema(schema) + + assert result["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert result["type"] == "object" + assert result["properties"] == {"value": {"type": "string"}} + assert result["required"] == ["value"] + assert result["additionalProperties"] is False + assert "$ref" not in result + + +def test_root_ref_with_id_is_still_expanded(): + schema = { + "$id": "https://example.test/root", + "$defs": { + "T": { + "type": "object", + "properties": {"value": {"type": "string"}}, + } + }, + "$ref": "#/$defs/T", + } + + result = ensure_strict_json_schema(schema) + + assert result["$id"] == "https://example.test/root" + assert result["type"] == "object" + assert result["properties"] == {"value": {"type": "string"}} + assert result["required"] == ["value"] + assert result["additionalProperties"] is False + assert "$ref" not in result + + +def test_nested_ref_with_id_is_rejected_before_resolution(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "value": { + "$id": "https://example.test/nested", + "$defs": {"T": {"type": "integer"}}, + "$ref": "#/$defs/T", + } + }, + } + + with pytest.raises(UserError, match=r"nested `\$id` resource"): + ensure_strict_json_schema(schema) + + +def test_ref_with_anchor_is_still_expanded(): + schema = { + "$defs": {"T": {"type": "string"}}, + "type": "object", + "properties": { + "value": { + "$anchor": "value", + "$ref": "#/$defs/T", + } + }, + } + + result = ensure_strict_json_schema(schema) + + assert result["properties"]["value"] == { + "$anchor": "value", + "type": "string", + } From c0b876379e82095b164c78cec65fb659a699a98d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 11:22:31 +0900 Subject: [PATCH 282/473] perf: limit mypy to runtime source --- Makefile | 9 ++------- tests/README.md | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 8a8704d2bd..76eedc5b8b 100644 --- a/Makefile +++ b/Makefile @@ -46,11 +46,11 @@ lint: .PHONY: mypy mypy: - uv run mypy $(if $(TYPECHECK_SRC_ONLY),src,.) --exclude site + uv run mypy src .PHONY: pyright pyright: - uv run pyright --project pyrightconfig.json --threads "$${PYRIGHT_THREADS:-4}" $(if $(TYPECHECK_SRC_ONLY),src,) + uv run pyright --project pyrightconfig.json --threads "$${PYRIGHT_THREADS:-4}" .PHONY: typecheck typecheck: @@ -64,11 +64,6 @@ typecheck: wait $$mypy_pid; \ wait $$pyright_pid; \ trap - EXIT - -.PHONY: typecheck-src -typecheck-src: - @$(MAKE) typecheck TYPECHECK_SRC_ONLY=1 - .PHONY: tests tests: tests-parallel $(MAKE) tests-serial diff --git a/tests/README.md b/tests/README.md index edbd836622..6e486263bf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,7 +16,7 @@ The `serial` marker means that a test needs exclusive execution after every xdis Choose review-round coverage by impact. For a leaf subsystem change, run `make tests-review` plus the owning subsystem's complete test file or directory without a marker filter, so its `review_optional` cases are restored. For cross-cutting runtime changes such as runner orchestration, agent or item flow, shared persistence, or test infrastructure, run `make tests` during review. Prefer the full suite whenever the affected boundary is ambiguous. This selection changes only iterative feedback; the final verification always runs `make tests`. -`make typecheck` runs mypy and pyright concurrently. Pyright uses four analysis threads by default; set `PYRIGHT_THREADS` to a positive integer to override the local thread count. The speedup does not remove either analyzer or narrow its selected project or source scope. +`make typecheck` runs mypy and pyright concurrently. Mypy checks `src`, while Pyright checks the `src` and `tests` paths configured in `pyrightconfig.json`. Pyright uses four analysis threads by default; set `PYRIGHT_THREADS` to a positive integer to override the local thread count. ## Performance and determinism From d2bda3f3110415bf02e526a3983b0d0fa903e0d7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 11:52:30 +0900 Subject: [PATCH 283/473] release: 0.20.0 (#4348) --- pyproject.toml | 2 +- tests/fixtures/released_api_contract.json | 10026 +++++++++++++++++++- uv.lock | 2 +- 3 files changed, 9646 insertions(+), 384 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2fdf3194c1..3faa0fd0af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.19.4" +version = "0.20.0" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/fixtures/released_api_contract.json b/tests/fixtures/released_api_contract.json index 924ef767c5..67f75de166 100644 --- a/tests/fixtures/released_api_contract.json +++ b/tests/fixtures/released_api_contract.json @@ -1,6 +1,6 @@ { - "baseline": "v0.19.4", - "baseline_commit": "9bfad15ab8297fbb2afe389c983a5cb573eeef56", + "baseline": "v0.20.0", + "baseline_commit": "c0b876379e82095b164c78cec65fb659a699a98d", "callables": { "Agent": { "dataclass_fields": [ @@ -699,6 +699,15 @@ }, "kind": "KEYWORD_ONLY", "name": "tool_lookup_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "current_invocation" } ] }, @@ -3881,221 +3890,7 @@ } ] }, - "ItemHelpers": { - "dataclass_fields": [], - "kind": "class", - "members": { - "copy_tool_call_caller": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_call" - }, - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "output_item" - } - ] - }, - "extract_last_content": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "message" - } - ] - }, - "extract_last_text": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "message" - } - ] - }, - "extract_refusal": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "message" - } - ] - }, - "extract_text": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "message" - } - ] - }, - "input_to_new_input_list": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "input" - } - ] - }, - "text_message_output": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "message" - } - ] - }, - "text_message_outputs": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "items" - } - ] - }, - "tool_call_output_item": { - "binding": "class", - "execution_kind": "sync", - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_call" - }, - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "output" - }, - { - "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null - }, - "kind": "KEYWORD_ONLY", - "name": "output_json_schema" - }, - { - "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null - }, - "kind": "KEYWORD_ONLY", - "name": "output_type_adapter" - } - ] - } - }, - "parameters": [] - }, - "LocalShellCommandRequest": { - "dataclass_fields": [ - { - "default": { - "kind": "required" - }, - "init": true, - "name": "ctx_wrapper" - }, - { - "default": { - "kind": "required" - }, - "init": true, - "name": "data" - } - ], - "kind": "class", - "members": {}, - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "ctx_wrapper" - }, - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "data" - } - ] - }, - "LocalShellTool": { - "dataclass_fields": [ - { - "default": { - "kind": "required" - }, - "init": true, - "name": "executor" - } - ], - "kind": "class", - "members": {}, - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "executor" - } - ] - }, - "MCPApprovalRequestItem": { + "InputItem": { "dataclass_fields": [ { "default": { @@ -4115,75 +3910,369 @@ "default": { "kind": "literal", "type": "builtins.str", - "value": "mcp_approval_request_item" + "value": "input_item" }, "init": true, "name": "type" - } - ], - "kind": "class", - "members": { - "release_agent": { - "binding": "instance", - "execution_kind": "sync", - "parameters": [] - }, - "to_input_item": { - "binding": "instance", - "execution_kind": "sync", - "parameters": [] - } - }, - "parameters": [ - { - "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "agent" }, { "default": { - "kind": "required" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "raw_item" - }, - { - "default": { - "kind": "literal", - "type": "builtins.str", - "value": "mcp_approval_request_item" - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "type" - } - ] - }, - "MCPApprovalResponseItem": { - "dataclass_fields": [ - { - "default": { - "kind": "required" - }, - "init": true, - "name": "agent" - }, - { - "default": { - "kind": "required" - }, - "init": true, - "name": "raw_item" - }, - { - "default": { - "kind": "literal", - "type": "builtins.str", - "value": "mcp_approval_response_item" + "factory": "agents.items.InputItem.", + "kind": "factory" }, "init": true, - "name": "type" + "name": "input_id" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "input_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input_id" + } + ] + }, + "ItemHelpers": { + "dataclass_fields": [], + "kind": "class", + "members": { + "copy_tool_call_caller": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_item" + } + ] + }, + "extract_last_content": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "extract_last_text": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "extract_refusal": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "extract_text": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "input_to_new_input_list": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + } + ] + }, + "text_message_output": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + } + ] + }, + "text_message_outputs": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "items" + } + ] + }, + "tool_call_output_item": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "output_json_schema" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "output_type_adapter" + } + ] + } + }, + "parameters": [] + }, + "LocalShellCommandRequest": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ctx_wrapper" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "LocalShellTool": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "executor" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "executor" + } + ] + }, + "MCPApprovalRequestItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_approval_request_item" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": { + "release_agent": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "to_input_item": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_approval_request_item" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, + "MCPApprovalResponseItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mcp_approval_response_item" + }, + "init": true, + "name": "type" } ], "kind": "class", @@ -4711,6 +4800,15 @@ }, "init": true, "name": "request_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "raw_usage" } ], "kind": "class", @@ -4751,6 +4849,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "request_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_usage" } ] }, @@ -4800,6 +4907,15 @@ }, "init": true, "name": "normalized" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "response_started" } ], "kind": "class", @@ -4849,6 +4965,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "normalized" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response_started" } ] }, @@ -5444,6 +5569,15 @@ }, "init": true, "name": "prompt_cache_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "preserve_raw_usage" } ], "kind": "class", @@ -5679,6 +5813,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "prompt_cache_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "preserve_raw_usage" } ] }, @@ -7399,6 +7542,15 @@ }, "init": true, "name": "reason" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "approve_unsafe_replay" } ], "kind": "class", @@ -7428,6 +7580,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "reason" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approve_unsafe_replay" } ] }, @@ -7476,6 +7637,24 @@ }, "init": true, "name": "provider_advice" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "conversation_id" } ], "kind": "class", @@ -7524,6 +7703,24 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "provider_advice" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" } ] }, @@ -8064,6 +8261,15 @@ }, "kind": "KEYWORD_ONLY", "name": "tool_lookup_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "current_invocation" } ] }, @@ -9403,6 +9609,19 @@ "dataclass_fields": [], "kind": "class", "members": { + "add_input": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + } + ] + }, "approve": { "binding": "instance", "execution_kind": "sync", @@ -9425,6 +9644,11 @@ } ] }, + "clear_pending_input": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, "from_json": { "binding": "static", "execution_kind": "coroutine", @@ -11211,6 +11435,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "tool_origin" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_resolved_tool_name" } ] }, @@ -11852,6 +12085,48 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "file" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "file_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "file_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "file_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "filename" + } + ], "parameters": [ { "default": { @@ -12076,6 +12351,40 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "image" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "image_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "file_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "detail" + } + ], "parameters": [ { "default": { @@ -12119,6 +12428,22 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "text" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "text" + } + ], "parameters": [ { "default": { @@ -13276,13 +13601,32 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "items" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" } ] }, "clear_session": { "binding": "instance", "execution_kind": "coroutine", - "parameters": [] + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" + } + ] }, "get_items": { "binding": "instance", @@ -13296,13 +13640,32 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "limit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" } ] }, "pop_item": { "binding": "instance", "execution_kind": "coroutine", - "parameters": [] + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" + } + ] } }, "parameters": [ @@ -13517,6 +13880,16 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "blaxel_drive" + }, + "name": "type" + } + ], "parameters": [ { "default": { @@ -14055,6 +14428,113 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "daytona_cloud_bucket" + }, + "name": "type" + }, + { + "default": { + "kind": "model", + "type": "agents.sandbox.entries.mounts.patterns.RcloneMountPattern", + "value": { + "items": [ + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "type" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "rclone" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "mode" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "remote_name" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "extra_args" + }, + { + "items": [], + "kind": "sequence", + "type": "builtins.list" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_addr" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_mount_options" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "config_file_path" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ] + ], + "kind": "mapping", + "type": "builtins.dict" + } + }, + "name": "pattern" + } + ], "parameters": [ { "default": { @@ -14315,6 +14795,112 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "daytona" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "sandbox_snapshot_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "image" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "name": "create_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "name": "start_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "name": "auto_stop_interval" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "timeouts" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "name": "exposed_port_url_ttl_s" + } + ], "parameters": [ { "default": { @@ -14457,6 +15043,64 @@ } ] }, + "model_validate_json": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "extra" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_name" + } + ] + }, "parse": { "binding": "class", "execution_kind": "sync", @@ -14470,6 +15114,26 @@ } ] }, + "rebind_persisted_mount_authority": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "provider_backend_id" + } + ] + }, "rebind_persisted_path_grants": { "binding": "instance", "execution_kind": "sync", @@ -14484,6 +15148,158 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "daytona" + }, + "name": "type" + }, + { + "default": { + "factory": "uuid.uuid4", + "kind": "factory" + }, + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "name": "manifest" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "sandbox_snapshot_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "image" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "base_env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "name": "create_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 60 + }, + "name": "start_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 0 + }, + "name": "auto_stop_interval" + }, + { + "default": { + "factory": "agents.extensions.sandbox.daytona.sandbox.DaytonaSandboxTimeouts", + "kind": "factory" + }, + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "name": "exposed_port_url_ttl_s" + } + ], "parameters": [ { "default": { @@ -14780,6 +15596,126 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "sandbox_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "template" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "envs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "secure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "allow_internet_access" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "pause_on_exit" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pause" + }, + "name": "on_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "auto_resume" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mcp" + } + ], "parameters": [ { "default": { @@ -14938,6 +15874,64 @@ } ] }, + "model_validate_json": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "extra" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_name" + } + ] + }, "parse": { "binding": "class", "execution_kind": "sync", @@ -14951,6 +15945,26 @@ } ] }, + "rebind_persisted_mount_authority": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "provider_backend_id" + } + ] + }, "rebind_persisted_path_grants": { "binding": "instance", "execution_kind": "sync", @@ -14965,13 +15979,5849 @@ ] } }, - "parameters": [ + "model_fields": [ { "default": { "kind": "literal", "type": "builtins.str", "value": "e2b" }, + "name": "type" + }, + { + "default": { + "factory": "uuid.uuid4", + "kind": "factory" + }, + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "name": "manifest" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "agents.extensions.sandbox.e2b.sandbox.E2BSandboxType", + "value": "e2b" + }, + "name": "sandbox_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "template" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "sandbox_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "metadata" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "base_envs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "secure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "allow_internet_access" + }, + { + "default": { + "factory": "agents.extensions.sandbox.e2b.sandbox.E2BSandboxTimeouts", + "kind": "factory" + }, + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pause" + }, + "name": "on_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "auto_resume" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mcp" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "agents.extensions.sandbox.e2b.sandbox.E2BSandboxType", + "value": "e2b" + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "template" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "metadata" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "base_envs" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "secure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "allow_internet_access" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "pause" + }, + "kind": "KEYWORD_ONLY", + "name": "on_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "auto_resume" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "mcp" + } + ] + }, + "agents.extensions.sandbox.E2BSandboxType": { + "dataclass_fields": [], + "enum_members": [ + { + "name": "CODE_INTERPRETER", + "value": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b_code_interpreter" + } + }, + { + "name": "E2B", + "value": { + "kind": "literal", + "type": "builtins.str", + "value": "e2b" + } + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "value" + } + ] + }, + "agents.extensions.sandbox.ModalCloudBucketMountStrategy": { + "dataclass_fields": [], + "kind": "class", + "members": { + "activate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "deactivate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "restore_after_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "supports_native_snapshot_detach": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "teardown_for_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "validate_mount": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "modal_cloud_bucket" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "secret_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "secret_environment_name" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "modal_cloud_bucket" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "secret_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "secret_environment_name" + } + ] + }, + "agents.extensions.sandbox.ModalSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "image" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.extensions.sandbox.ModalSandboxClientOptions": { + "dataclass_fields": [], + "kind": "class", + "members": { + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "modal" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "app_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "sandbox_create_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_filesystem_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_filesystem_restore_timeout_s" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "gpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 300 + }, + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "use_sleep_cmd" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "2025.06" + }, + "name": "image_builder_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "idle_timeout" + } + ], + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "app_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "sandbox_create_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot_filesystem_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot_filesystem_restore_timeout_s" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "gpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 300 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_sleep_cmd" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "2025.06" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "image_builder_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "idle_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "modal" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.extensions.sandbox.ModalSandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "from_state": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "image" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "snapshot_filesystem": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "state" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "image" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox" + } + ] + }, + "agents.extensions.sandbox.ModalSandboxSessionState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_path_grants_rebound": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "model_validate_json": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "extra" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_name" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "rebind_persisted_mount_authority": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "provider_backend_id" + } + ] + }, + "rebind_persisted_path_grants": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "modal" + }, + "name": "type" + }, + { + "default": { + "factory": "uuid.uuid4", + "kind": "factory" + }, + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "name": "manifest" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "name": "app_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "image_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "image_tag" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 30.0 + }, + "name": "sandbox_create_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 60.0 + }, + "name": "snapshot_filesystem_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 60.0 + }, + "name": "snapshot_filesystem_restore_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "gpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 300 + }, + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "use_sleep_cmd" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "2025.06" + }, + "name": "image_builder_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "idle_timeout" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "modal" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "app_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "image_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "image_tag" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 30.0 + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_create_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 60.0 + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_filesystem_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 60.0 + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_filesystem_restore_timeout_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "gpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 300 + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "use_sleep_cmd" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "2025.06" + }, + "kind": "KEYWORD_ONLY", + "name": "image_builder_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "idle_timeout" + } + ] + }, + "agents.extensions.sandbox.RunloopAfterIdle": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "idle_time_seconds" + }, + { + "default": { + "kind": "required" + }, + "name": "on_idle" + } + ], + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "data" + } + ] + }, + "agents.extensions.sandbox.RunloopCloudBucketMountStrategy": { + "dataclass_fields": [], + "kind": "class", + "members": { + "activate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "deactivate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "restore_after_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "supports_native_snapshot_detach": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "teardown_for_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "validate_mount": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "runloop_cloud_bucket" + }, + "name": "type" + }, + { + "default": { + "kind": "model", + "type": "agents.sandbox.entries.mounts.patterns.RcloneMountPattern", + "value": { + "items": [ + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "type" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "rclone" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "mode" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "remote_name" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "extra_args" + }, + { + "items": [], + "kind": "sequence", + "type": "builtins.list" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_addr" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_mount_options" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "config_file_path" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ] + ], + "kind": "mapping", + "type": "builtins.dict" + } + }, + "name": "pattern" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "runloop_cloud_bucket" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "model", + "type": "agents.sandbox.entries.mounts.patterns.RcloneMountPattern", + "value": { + "items": [ + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "type" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "rclone" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "mode" + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "remote_name" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "extra_args" + }, + { + "items": [], + "kind": "sequence", + "type": "builtins.list" + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_addr" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "nfs_mount_options" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ], + [ + { + "kind": "literal", + "type": "builtins.str", + "value": "config_file_path" + }, + { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + } + ] + ], + "kind": "mapping", + "type": "builtins.dict" + } + }, + "kind": "KEYWORD_ONLY", + "name": "pattern" + } + ] + }, + "agents.extensions.sandbox.RunloopGatewaySpec": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "gateway" + }, + { + "default": { + "kind": "required" + }, + "name": "secret" + } + ], + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "gateway" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "secret" + } + ] + }, + "agents.extensions.sandbox.RunloopLaunchParameters": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "after_idle" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "architecture" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "available_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "custom_cpu_cores" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "custom_disk_size" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "custom_gb_memory" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "keep_alive_time_seconds" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "launch_commands" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "network_policy_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "required_services" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "resource_size_request" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "user_parameters" + } + ], + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "data" + } + ] + }, + "agents.extensions.sandbox.RunloopMcpSpec": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "mcp_config" + }, + { + "default": { + "kind": "required" + }, + "name": "secret" + } + ], + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "mcp_config" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "secret" + } + ] + }, + "agents.extensions.sandbox.RunloopPlatformClient": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "_sdk" + } + ] + }, + "agents.extensions.sandbox.RunloopSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "bearer_token" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "base_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.extensions.sandbox.RunloopSandboxClientOptions": { + "dataclass_fields": [], + "kind": "class", + "members": { + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "runloop" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "blueprint_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "blueprint_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "timeouts" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "user_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "launch_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "tunnel" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "gateways" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mcp" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "managed_secrets" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "blueprint_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "blueprint_name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeouts" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "launch_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tunnel" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "gateways" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mcp" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "metadata" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "managed_secrets" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "runloop" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.extensions.sandbox.RunloopSandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "from_state": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "sdk" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "devbox" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "state" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "sdk" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "devbox" + } + ] + }, + "agents.extensions.sandbox.RunloopSandboxSessionState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_path_grants_rebound": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "model_validate_json": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "extra" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_name" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "rebind_persisted_mount_authority": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "provider_backend_id" + } + ] + }, + "rebind_persisted_path_grants": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "runloop" + }, + "name": "type" + }, + { + "default": { + "factory": "uuid.uuid4", + "kind": "factory" + }, + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "name": "manifest" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "name": "devbox_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "blueprint_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "blueprint_name" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "base_env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "name" + }, + { + "default": { + "factory": "agents.extensions.sandbox.runloop.sandbox.RunloopTimeouts", + "kind": "factory" + }, + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "user_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "launch_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "tunnel" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "gateways" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "mcp" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "metadata" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "secret_refs" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "runloop" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "devbox_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "blueprint_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "blueprint_name" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "base_env_vars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "pause_on_exit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "name" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "timeouts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "launch_parameters" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tunnel" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "gateways" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "mcp" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "metadata" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "secret_refs" + } + ] + }, + "agents.extensions.sandbox.RunloopTimeouts": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 86400 + }, + "name": "exec_timeout_unbounded_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 300.0 + }, + "name": "create_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 10.0 + }, + "name": "keepalive_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 30.0 + }, + "name": "cleanup_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 30.0 + }, + "name": "fast_op_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 1800.0 + }, + "name": "file_upload_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 1800.0 + }, + "name": "file_download_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 300.0 + }, + "name": "snapshot_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 120.0 + }, + "name": "suspend_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 300.0 + }, + "name": "resume_s" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 86400 + }, + "kind": "KEYWORD_ONLY", + "name": "exec_timeout_unbounded_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 300.0 + }, + "kind": "KEYWORD_ONLY", + "name": "create_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 10.0 + }, + "kind": "KEYWORD_ONLY", + "name": "keepalive_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 30.0 + }, + "kind": "KEYWORD_ONLY", + "name": "cleanup_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 30.0 + }, + "kind": "KEYWORD_ONLY", + "name": "fast_op_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 1800.0 + }, + "kind": "KEYWORD_ONLY", + "name": "file_upload_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 1800.0 + }, + "kind": "KEYWORD_ONLY", + "name": "file_download_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 300.0 + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 120.0 + }, + "kind": "KEYWORD_ONLY", + "name": "suspend_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.float", + "value": 300.0 + }, + "kind": "KEYWORD_ONLY", + "name": "resume_s" + } + ] + }, + "agents.extensions.sandbox.RunloopTunnelConfig": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "auth_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "http_keep_alive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "wake_on_http" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "auth_mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "http_keep_alive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wake_on_http" + } + ] + }, + "agents.extensions.sandbox.RunloopUserParameters": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "uid" + }, + { + "default": { + "kind": "required" + }, + "name": "username" + } + ], + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_KEYWORD", + "name": "data" + } + ] + }, + "agents.extensions.sandbox.VercelCloudBucketMountStrategy": { + "dataclass_fields": [], + "kind": "class", + "members": { + "activate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "build_docker_volume_driver_config": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "deactivate": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dest" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "base_dir" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "restore_after_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "supports_native_snapshot_detach": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + }, + "teardown_for_snapshot": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "validate_mount": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "mount" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "vercel_cloud_bucket" + }, + "name": "type" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "vercel_cloud_bucket" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.extensions.sandbox.VercelSandboxClient": { + "dataclass_fields": [], + "kind": "class", + "members": { + "create": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "options" + } + ] + }, + "delete": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "session" + } + ] + }, + "deserialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "resume": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + }, + "serialize_session_state": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "token" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "project_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "team_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "instrumentation" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "dependencies" + } + ] + }, + "agents.extensions.sandbox.VercelSandboxClientOptions": { + "dataclass_fields": [], + "kind": "class", + "members": { + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "vercel" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "project_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "team_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 270000 + }, + "name": "timeout_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "runtime" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "env" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "interactive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_expiration_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "network_policy" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "allow_s3_credential_exposure" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "project_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "team_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 270000 + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "runtime" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "env" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "interactive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "snapshot_expiration_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "network_policy" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "allow_s3_credential_exposure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "vercel" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + } + ] + }, + "agents.extensions.sandbox.VercelSandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "from_state": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "state" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "token" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "allow_s3_credential_exposure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "trusted_s3_mounts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "trusted_manifest" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "state" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "sandbox" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "token" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "allow_s3_credential_exposure" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "trusted_s3_mounts" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "trusted_manifest" + } + ] + }, + "agents.extensions.sandbox.VercelSandboxSessionState": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_path_grants_rebound": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "model_validate_json": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "extra" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_name" + } + ] + }, + "parse": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "payload" + } + ] + }, + "rebind_persisted_mount_authority": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "provider_backend_id" + } + ] + }, + "rebind_persisted_path_grants": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "vercel" + }, + "name": "type" + }, + { + "default": { + "factory": "uuid.uuid4", + "kind": "factory" + }, + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "name": "manifest" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "required" + }, + "name": "sandbox_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "project_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "team_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "timeout_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "runtime" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "env" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "interactive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_expiration_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "network_policy" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "s3_mounts_non_resumable" + } + ], + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "vercel" + }, "kind": "KEYWORD_ONLY", "name": "type" }, @@ -15040,11 +21890,11 @@ { "default": { "kind": "literal", - "type": "agents.extensions.sandbox.e2b.sandbox.E2BSandboxType", - "value": "e2b" + "type": "builtins.NoneType", + "value": null }, "kind": "KEYWORD_ONLY", - "name": "sandbox_type" + "name": "project_id" }, { "default": { @@ -15053,7 +21903,7 @@ "value": null }, "kind": "KEYWORD_ONLY", - "name": "template" + "name": "team_id" }, { "default": { @@ -15062,7 +21912,7 @@ "value": null }, "kind": "KEYWORD_ONLY", - "name": "sandbox_timeout" + "name": "timeout_ms" }, { "default": { @@ -15071,39 +21921,25 @@ "value": null }, "kind": "KEYWORD_ONLY", - "name": "metadata" - }, - { - "default": { - "kind": "factory" - }, - "kind": "KEYWORD_ONLY", - "name": "base_envs" + "name": "runtime" }, { "default": { "kind": "literal", - "type": "builtins.bool", - "value": true + "type": "builtins.NoneType", + "value": null }, "kind": "KEYWORD_ONLY", - "name": "secure" + "name": "resources" }, { "default": { "kind": "literal", - "type": "builtins.bool", - "value": true - }, - "kind": "KEYWORD_ONLY", - "name": "allow_internet_access" - }, - { - "default": { - "kind": "factory" + "type": "builtins.NoneType", + "value": null }, "kind": "KEYWORD_ONLY", - "name": "timeouts" + "name": "env" }, { "default": { @@ -15112,7 +21948,7 @@ "value": false }, "kind": "KEYWORD_ONLY", - "name": "pause_on_exit" + "name": "interactive" }, { "default": { @@ -15126,20 +21962,11 @@ { "default": { "kind": "literal", - "type": "builtins.str", - "value": "pause" - }, - "kind": "KEYWORD_ONLY", - "name": "on_timeout" - }, - { - "default": { - "kind": "literal", - "type": "builtins.bool", - "value": true + "type": "builtins.NoneType", + "value": null }, "kind": "KEYWORD_ONLY", - "name": "auto_resume" + "name": "snapshot_expiration_ms" }, { "default": { @@ -15148,39 +21975,16 @@ "value": null }, "kind": "KEYWORD_ONLY", - "name": "mcp" - } - ] - }, - "agents.extensions.sandbox.E2BSandboxType": { - "dataclass_fields": [], - "enum_members": [ - { - "name": "CODE_INTERPRETER", - "value": { - "kind": "literal", - "type": "builtins.str", - "value": "e2b_code_interpreter" - } + "name": "network_policy" }, - { - "name": "E2B", - "value": { - "kind": "literal", - "type": "builtins.str", - "value": "e2b" - } - } - ], - "kind": "class", - "members": {}, - "parameters": [ { "default": { - "kind": "required" + "kind": "literal", + "type": "builtins.bool", + "value": false }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "value" + "kind": "KEYWORD_ONLY", + "name": "s3_mounts_non_resumable" } ] }, @@ -17321,6 +24125,32 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "audio" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "audio" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "transcript" + } + ], "parameters": [ { "default": { @@ -17362,6 +24192,52 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "previous_item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "message" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "assistant" + }, + "name": "role" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "status" + }, + { + "default": { + "kind": "required" + }, + "name": "content" + } + ], "parameters": [ { "default": { @@ -17426,6 +24302,24 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "text" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "text" + } + ], "parameters": [ { "default": { @@ -17458,6 +24352,24 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "input_text" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "text" + } + ], "parameters": [ { "default": { @@ -17490,6 +24402,44 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "previous_item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "message" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "user" + }, + "name": "role" + }, + { + "default": { + "kind": "required" + }, + "name": "content" + } + ], "parameters": [ { "default": { @@ -18076,6 +25026,30 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + } + ], "parameters": [ { "default": { @@ -18240,6 +25214,61 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local_file" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "required" + }, + "name": "src" + } + ], "parameters": [ { "default": { @@ -18320,6 +25349,22 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "base_path" + } + ], "parameters": [ { "default": { @@ -18398,6 +25443,19 @@ "execution_kind": "generator", "parameters": [] }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, "mount_targets": { "binding": "instance", "execution_kind": "sync", @@ -18407,8 +25465,94 @@ "binding": "instance", "execution_kind": "sync", "parameters": [] + }, + "with_in_container_mount_broad_credential_exposure_acknowledged": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "mount_paths" + } + ] + }, + "with_in_container_mount_credential_exposure_acknowledged": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "mount_paths" + } + ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 1 + }, + "name": "version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "/workspace" + }, + "name": "root" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "entries" + }, + { + "default": { + "factory": "agents.sandbox.manifest.Environment", + "kind": "factory" + }, + "name": "environment" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "name": "users" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "name": "groups" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "extra_path_grants" + }, + { + "default": { + "factory": "agents.sandbox.manifest.Manifest.", + "kind": "factory" + }, + "name": "remote_mount_command_allowlist" + } + ], "parameters": [ { "default": { @@ -18644,6 +25788,22 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "remote" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "client_dependency_key" + } + ], "parameters": [ { "default": { @@ -19218,6 +26378,38 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "read_only" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "host_path" + } + ], "parameters": [ { "default": { @@ -19593,6 +26785,40 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "filesystem" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "configure_tools" + } + ], "parameters": [ { "default": { @@ -19731,6 +26957,14 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "source" + } + ], "parameters": [ { "default": { @@ -19852,6 +27086,53 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "memory" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + }, + { + "default": { + "factory": "agents.sandbox.config.MemoryLayoutConfig", + "kind": "factory" + }, + "name": "layout" + }, + { + "default": { + "factory": "agents.sandbox.config.MemoryReadConfig", + "kind": "factory" + }, + "name": "read" + }, + { + "default": { + "factory": "agents.sandbox.config.MemoryGenerateConfig", + "kind": "factory" + }, + "name": "generate" + } + ], "parameters": [ { "default": { @@ -20001,6 +27282,40 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "shell" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "configure_tools" + } + ], "parameters": [ { "default": { @@ -20164,6 +27479,63 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "skills" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "name": "skills" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "from_" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "lazy_from" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": ".agents" + }, + "name": "skills_path" + } + ], "parameters": [ { "default": { @@ -20338,6 +27710,40 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "compaction" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "policy" + } + ], "parameters": [ { "default": { @@ -20488,6 +27894,53 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "memory" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + }, + { + "default": { + "factory": "agents.sandbox.config.MemoryLayoutConfig", + "kind": "factory" + }, + "name": "layout" + }, + { + "default": { + "factory": "agents.sandbox.config.MemoryReadConfig", + "kind": "factory" + }, + "name": "read" + }, + { + "default": { + "factory": "agents.sandbox.config.MemoryGenerateConfig", + "kind": "factory" + }, + "name": "generate" + } + ], "parameters": [ { "default": { @@ -20637,6 +28090,40 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "shell" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "run_as" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "configure_tools" + } + ], "parameters": [ { "default": { @@ -20962,6 +28449,113 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "azure_blob_mount" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "name": "account" + }, + { + "default": { + "kind": "required" + }, + "name": "container" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "endpoint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "identity_client_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "account_key" + } + ], "parameters": [ { "default": { @@ -21146,6 +28740,62 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "dir" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "children" + } + ], "parameters": [ { "default": { @@ -21387,6 +29037,29 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "docker_volume" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "driver" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "driver_options" + } + ], "parameters": [ { "default": { @@ -21463,6 +29136,61 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "file" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "required" + }, + "name": "content" + } + ], "parameters": [ { "default": { @@ -21597,6 +29325,120 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "allow_other" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "syslog" + }, + "name": "log_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "log_debug" + }, + "name": "log_level" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "block_cache" + }, + "name": "cache_type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "cache_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "cache_size_mb" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 16 + }, + "name": "block_cache_block_size_mb" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 3600 + }, + "name": "block_cache_disk_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 120 + }, + "name": "file_cache_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "file_cache_max_size_mb" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "attr_cache_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "entry_cache_timeout_sec" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "negative_entry_cache_timeout_sec" + } + ], "parameters": [ { "default": { @@ -21876,6 +29718,147 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "gcs_mount" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "name": "bucket" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "access_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "secret_access_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "prefix" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "region" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "endpoint_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "service_account_file" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "service_account_credentials" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "access_token" + } + ], "parameters": [ { "default": { @@ -22098,6 +30081,83 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "git_repo" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "github.com" + }, + "name": "host" + }, + { + "default": { + "kind": "required" + }, + "name": "repo" + }, + { + "default": { + "kind": "required" + }, + "name": "ref" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "subpath" + } + ], "parameters": [ { "default": { @@ -22364,6 +30424,22 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "in_container" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "pattern" + } + ], "parameters": [ { "default": { @@ -22455,6 +30531,63 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local_dir" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "src" + } + ], "parameters": [ { "default": { @@ -22569,6 +30702,61 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local_file" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "required" + }, + "name": "src" + } + ], "parameters": [ { "default": { @@ -22781,6 +30969,75 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "name": "mount_strategy" + } + ], "parameters": [ { "default": { @@ -22918,6 +31175,23 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "mountpoint" + }, + "name": "type" + }, + { + "default": { + "factory": "agents.sandbox.entries.mounts.patterns.MountpointMountPattern.MountpointOptions", + "kind": "factory" + }, + "name": "options" + } + ], "parameters": [ { "default": { @@ -23087,6 +31361,113 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "r2_mount" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "name": "bucket" + }, + { + "default": { + "kind": "required" + }, + "name": "account_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "access_key_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "secret_access_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "custom_domain" + } + ], "parameters": [ { "default": { @@ -23323,6 +31704,63 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "rclone" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "fuse" + }, + "name": "mode" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "remote_name" + }, + { + "default": { + "factory": "builtins.list", + "kind": "factory" + }, + "name": "extra_args" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "nfs_addr" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "nfs_mount_options" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "config_file_path" + } + ], "parameters": [ { "default": { @@ -23537,6 +31975,122 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "s3_files_mount" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "name": "file_system_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "subpath" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mount_target_ip" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "access_point" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "region" + }, + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "extra_options" + } + ], "parameters": [ { "default": { @@ -23726,6 +32280,23 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "s3files" + }, + "name": "type" + }, + { + "default": { + "factory": "agents.sandbox.entries.mounts.patterns.S3FilesMountPattern.S3FilesOptions", + "kind": "factory" + }, + "name": "options" + } + ], "parameters": [ { "default": { @@ -23895,6 +32466,139 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "s3_mount" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "description" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "ephemeral" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "group" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "is_dir" + }, + { + "default": { + "factory": "agents.sandbox.entries.base.BaseEntry.", + "kind": "factory" + }, + "name": "permissions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "mount_path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "read_only" + }, + { + "default": { + "kind": "required" + }, + "name": "mount_strategy" + }, + { + "default": { + "kind": "required" + }, + "name": "bucket" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "access_key_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "secret_access_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "session_token" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "prefix" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "region" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "endpoint_url" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "AWS" + }, + "name": "s3_provider" + } + ], "parameters": [ { "default": { @@ -24322,6 +33026,15 @@ "parameters": [] } }, + "model_fields": [ + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "value" + } + ], "parameters": [ { "default": { @@ -24461,6 +33174,24 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "unix_local" + }, + "name": "type" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "name": "exposed_ports" + } + ], "parameters": [ { "default": { @@ -24504,6 +33235,64 @@ } ] }, + "model_validate_json": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "extra" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_name" + } + ] + }, "parse": { "binding": "class", "execution_kind": "sync", @@ -24517,6 +33306,26 @@ } ] }, + "rebind_persisted_mount_authority": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "provider_backend_id" + } + ] + }, "rebind_persisted_path_grants": { "binding": "instance", "execution_kind": "sync", @@ -24531,6 +33340,74 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "unix_local" + }, + "name": "type" + }, + { + "default": { + "factory": "uuid.uuid4", + "kind": "factory" + }, + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "name": "manifest" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_ready" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_owned" + } + ], "parameters": [ { "default": { @@ -25467,6 +34344,40 @@ "dataclass_fields": [], "kind": "class", "members": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "include_exec_output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 8000 + }, + "name": "max_stdout_chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.int", + "value": 8000 + }, + "name": "max_stderr_chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "name": "include_write_len" + } + ], "parameters": [ { "default": { @@ -27243,6 +36154,64 @@ } ] }, + "model_validate_json": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "json_data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "extra" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "context" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_alias" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "by_name" + } + ] + }, "parse": { "binding": "class", "execution_kind": "sync", @@ -27256,6 +36225,26 @@ } ] }, + "rebind_persisted_mount_authority": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trusted_manifest" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "provider_backend_id" + } + ] + }, "rebind_persisted_path_grants": { "binding": "instance", "execution_kind": "sync", @@ -27270,6 +36259,64 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "type" + }, + { + "default": { + "factory": "uuid.uuid4", + "kind": "factory" + }, + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "name": "snapshot" + }, + { + "default": { + "kind": "required" + }, + "name": "manifest" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "name": "exposed_ports" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "snapshot_fingerprint_version" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "name": "workspace_root_ready" + } + ], "parameters": [ { "default": { @@ -27353,6 +36400,16 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "noop" + }, + "name": "type" + } + ], "parameters": [ { "default": { @@ -27435,6 +36492,20 @@ ] } }, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "id" + } + ], "parameters": [ { "default": { @@ -27689,6 +36760,15 @@ }, "kind": "KEYWORD_ONLY", "name": "tool_lookup_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "current_invocation" } ] }, @@ -30539,8 +39619,97 @@ "canonical_name": "TracingProcessor", "module": "agents.tracing", "name": "TracingProcessor" + }, + { + "canonical_module": "agents", + "canonical_name": "InputItem", + "module": "agents.items", + "name": "InputItem" + }, + { + "canonical_module": "agents.extensions.sandbox.modal", + "canonical_name": "ModalCloudBucketMountStrategy", + "module": "agents.extensions.sandbox", + "name": "ModalCloudBucketMountStrategy" + }, + { + "canonical_module": "agents.extensions.sandbox.modal", + "canonical_name": "ModalSandboxClient", + "module": "agents.extensions.sandbox", + "name": "ModalSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox.modal", + "canonical_name": "ModalSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "ModalSandboxClientOptions" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopAfterIdle", + "module": "agents.extensions.sandbox", + "name": "RunloopAfterIdle" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopGatewaySpec", + "module": "agents.extensions.sandbox", + "name": "RunloopGatewaySpec" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopLaunchParameters", + "module": "agents.extensions.sandbox", + "name": "RunloopLaunchParameters" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopMcpSpec", + "module": "agents.extensions.sandbox", + "name": "RunloopMcpSpec" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopSandboxClient", + "module": "agents.extensions.sandbox", + "name": "RunloopSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "RunloopSandboxClientOptions" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopTunnelConfig", + "module": "agents.extensions.sandbox", + "name": "RunloopTunnelConfig" + }, + { + "canonical_module": "agents.extensions.sandbox.runloop", + "canonical_name": "RunloopUserParameters", + "module": "agents.extensions.sandbox", + "name": "RunloopUserParameters" + }, + { + "canonical_module": "agents.extensions.sandbox.vercel", + "canonical_name": "VercelSandboxClient", + "module": "agents.extensions.sandbox", + "name": "VercelSandboxClient" + }, + { + "canonical_module": "agents.extensions.sandbox.vercel", + "canonical_name": "VercelSandboxClientOptions", + "module": "agents.extensions.sandbox", + "name": "VercelSandboxClientOptions" } ], + "optional_dependency_unsupported_platforms": { + "vercel": [ + "win32" + ] + }, "platform_import_errors": [ { "error_type": "ImportError", @@ -30551,36 +39720,6 @@ ] } ], - "public_properties": [ - { - "class_name": "RunResultBase", - "module": "agents.result", - "names": [ - "agent_tool_invocation", - "last_agent", - "last_response_id" - ] - }, - { - "class_name": "RunResult", - "module": "agents.result", - "names": [ - "agent_tool_invocation", - "last_agent", - "last_response_id" - ] - }, - { - "class_name": "RunResultStreaming", - "module": "agents.result", - "names": [ - "agent_tool_invocation", - "last_agent", - "last_response_id", - "run_loop_exception" - ] - } - ], "public_modules": [ "agents", "agents.agent", @@ -30643,6 +39782,78 @@ "agents.tool_guardrails", "agents.tracing" ], + "public_properties": [ + { + "class_name": "RunResultBase", + "module": "agents.result", + "names": [ + "agent_tool_invocation", + "last_agent", + "last_response_id" + ] + }, + { + "class_name": "RunResult", + "module": "agents.result", + "names": [ + "agent_tool_invocation", + "last_agent", + "last_response_id" + ] + }, + { + "class_name": "RunResultStreaming", + "module": "agents.result", + "names": [ + "agent_tool_invocation", + "last_agent", + "last_response_id", + "run_loop_exception" + ] + }, + { + "class_name": "RunState", + "module": "agents.run_state", + "names": [ + "pending_input" + ] + }, + { + "class_name": "RetryPolicyContext", + "module": "agents.retry", + "names": [ + "response_started", + "replay_safety", + "stateful_request" + ] + }, + { + "class_name": "RunloopPlatformClient", + "module": "agents.extensions.sandbox", + "names": [ + "axons", + "benchmarks", + "blueprints", + "network_policies", + "secrets" + ] + }, + { + "class_name": "RunloopSandboxClient", + "module": "agents.extensions.sandbox", + "names": [ + "platform" + ] + }, + { + "class_name": "SandboxSessionState", + "module": "agents.sandbox.session.sandbox_session_state", + "names": [ + "mount_authority_redacted", + "mount_authority_rebound" + ] + } + ], "required_submodule_exports": { "agents.decorators": { "names": [ @@ -30705,6 +39916,11 @@ "E2BSandboxSessionState", "E2BSandboxTimeouts", "E2BSandboxType", + "ModalCloudBucketMountStrategy", + "ModalSandboxClient", + "ModalSandboxClientOptions", + "ModalSandboxSession", + "ModalSandboxSessionState", "DEFAULT_DAYTONA_WORKSPACE_ROOT", "DaytonaCloudBucketMountStrategy", "DaytonaSandboxResources", @@ -30728,7 +39944,27 @@ "CloudflareSandboxClient", "CloudflareSandboxClientOptions", "CloudflareSandboxSession", - "CloudflareSandboxSessionState" + "CloudflareSandboxSessionState", + "VercelCloudBucketMountStrategy", + "VercelSandboxClient", + "VercelSandboxClientOptions", + "VercelSandboxSession", + "VercelSandboxSessionState", + "DEFAULT_RUNLOOP_WORKSPACE_ROOT", + "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT", + "RunloopAfterIdle", + "RunloopGatewaySpec", + "RunloopLaunchParameters", + "RunloopMcpSpec", + "RunloopPlatformClient", + "RunloopCloudBucketMountStrategy", + "RunloopSandboxClient", + "RunloopSandboxClientOptions", + "RunloopSandboxSession", + "RunloopSandboxSessionState", + "RunloopTimeouts", + "RunloopTunnelConfig", + "RunloopUserParameters" ], "optional_bindings": {}, "optional_exports": { @@ -30737,7 +39973,32 @@ "CloudflareSandboxClient": "aiohttp", "CloudflareSandboxClientOptions": "aiohttp", "CloudflareSandboxSession": "aiohttp", - "CloudflareSandboxSessionState": "aiohttp" + "CloudflareSandboxSessionState": "aiohttp", + "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT": "runloop_api_client", + "DEFAULT_RUNLOOP_WORKSPACE_ROOT": "runloop_api_client", + "ModalCloudBucketMountStrategy": "modal", + "ModalSandboxClient": "modal", + "ModalSandboxClientOptions": "modal", + "ModalSandboxSession": "modal", + "ModalSandboxSessionState": "modal", + "RunloopAfterIdle": "runloop_api_client", + "RunloopCloudBucketMountStrategy": "runloop_api_client", + "RunloopGatewaySpec": "runloop_api_client", + "RunloopLaunchParameters": "runloop_api_client", + "RunloopMcpSpec": "runloop_api_client", + "RunloopPlatformClient": "runloop_api_client", + "RunloopSandboxClient": "runloop_api_client", + "RunloopSandboxClientOptions": "runloop_api_client", + "RunloopSandboxSession": "runloop_api_client", + "RunloopSandboxSessionState": "runloop_api_client", + "RunloopTimeouts": "runloop_api_client", + "RunloopTunnelConfig": "runloop_api_client", + "RunloopUserParameters": "runloop_api_client", + "VercelCloudBucketMountStrategy": "vercel", + "VercelSandboxClient": "vercel", + "VercelSandboxClientOptions": "vercel", + "VercelSandboxSession": "vercel", + "VercelSandboxSessionState": "vercel" } }, "agents.handoffs": { @@ -31378,7 +40639,8 @@ "gen_span_id", "default_tool_error_function", "sandbox", - "__version__" + "__version__", + "InputItem" ], "submodule_export_exclusions": [ "agents.sandbox.sandboxes" diff --git a/uv.lock b/uv.lock index 37b5f2c63a..c092e351b5 100644 --- a/uv.lock +++ b/uv.lock @@ -2472,7 +2472,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.19.4" +version = "0.20.0" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 80e1baaefdfff291b3d7e55987219107c9736d80 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 12:13:38 +0900 Subject: [PATCH 284/473] docs: synchronize v0.20.0 features (#4280) --- docs/guardrails.md | 2 + docs/human_in_the_loop.md | 4 +- docs/mcp.md | 35 +++++++++++++++-- docs/models/index.md | 15 ++++--- docs/realtime/guide.md | 54 ++++++++++++++++++++++++++ docs/release.md | 15 +++++++ docs/results.md | 28 +++++++++++++ docs/running_agents.md | 2 +- docs/sandbox/clients.md | 18 +++++++++ docs/sandbox/guide.md | 2 + docs/sessions/index.md | 82 +++++++++++++++++++++++++++++---------- docs/streaming.md | 2 + docs/usage.md | 24 ++++++++++++ docs/voice/pipeline.md | 2 + 14 files changed, 255 insertions(+), 30 deletions(-) diff --git a/docs/guardrails.md b/docs/guardrails.md index 70bb0d7e3b..9b258e824d 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -51,6 +51,8 @@ Output guardrails run in 3 steps: Output guardrails always run after the agent completes, so they don't support the `run_in_parallel` parameter. +An output tripwire and an exception raised by the guardrail function have different session behavior. A tripwire rejects the candidate final output. When a tripwire fires, the runner asks the configured session to persist already-completed tool call and tool output items, together with any reasoning context required to replay those calls, while excluding the rejected candidate final output. The runner applies this tripwire rule to both streaming and non-streaming runs. When the guardrail function raises an exception instead of returning a tripwire result, the runner treats the verdict as unknown and asks the configured session to persist the completed final-turn items before surfacing the guardrail exception. If that session write also fails, the session write error takes precedence. Streaming runs use the same persistence ordering as non-streaming runs and raise the terminal exception from `stream_events()`. An immediate [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel] call while the output guardrail is running cancels the in-flight guardrail and does not start a final-turn session write. + ## Tool guardrails Tool guardrails wrap **`FunctionTool` instances** and let you validate or block calls to those tools before and after execution. They are configured on the tool itself and run every time that tool is invoked. diff --git a/docs/human_in_the_loop.md b/docs/human_in_the_loop.md index 17b4e89200..c153fe4b11 100644 --- a/docs/human_in_the_loop.md +++ b/docs/human_in_the_loop.md @@ -45,13 +45,15 @@ agent = Agent( ## How the approval flow works 1. When the model emits a tool call, the runner evaluates its approval rule (`needs_approval`, `require_approval`, or the hosted MCP equivalent). -2. If an approval decision for that tool call is already stored in the [`RunContextWrapper`][agents.run_context.RunContextWrapper], the runner proceeds without prompting. Per-call approvals are scoped to the specific call ID; pass `always_approve=True` or `always_reject=True` to persist the same decision for future calls to that tool during the rest of the run. +2. If an approval decision for that tool call is already stored in the [`RunContextWrapper`][agents.run_context.RunContextWrapper], the runner proceeds without prompting. Per-call approvals are scoped to the specific call ID; pass `always_approve=True` or `always_reject=True` to persist the same decision for future calls to the same tool identity during the rest of the run. 3. If the approval rule requires approval and no decision for that tool call is stored, execution pauses, and `RunResult.interruptions` (or `RunResultStreaming.interruptions`) contains [`ToolApprovalItem`][agents.items.ToolApprovalItem] entries with details such as `agent.name`, `tool_name`, and `arguments`. This includes approvals raised after a handoff or inside nested `Agent.as_tool()` executions. 4. Convert the result to a `RunState` with `result.to_state()`, call `state.approve(...)` or `state.reject(...)`, and then resume with `Runner.run(agent, state)` or `Runner.run_streamed(agent, state)`, where `agent` is the original top-level agent for the run. 5. The resumed run continues where it left off and will re-enter this flow if new approvals are needed. Sticky decisions created with `always_approve=True` or `always_reject=True` are stored in the run state, so they survive `state.to_string()` / `RunState.from_string(...)` and `state.to_json()` / `RunState.from_json(...)` when you resume the same paused run later. +For approval requests from [`HostedMCPTool`][agents.tool.HostedMCPTool], the Agents SDK identifies a sticky tool decision by the combination of `server_label` and tool name. An always-approve decision for `lookup_account` on one hosted MCP server does not approve a tool with the same name on another server. The Agents SDK persists an always-approve or always-reject decision only when the hosted MCP approval request includes both non-empty identity fields. + You do not need to resolve every pending approval in the same pass. `interruptions` can contain a mix of regular function tools, hosted MCP approvals, and nested `Agent.as_tool()` approvals. If you rerun after approving or rejecting only some items, those resolved calls can continue while unresolved ones remain in `interruptions` and pause the run again. ## Custom rejection messages diff --git a/docs/mcp.md b/docs/mcp.md index 38255d3d8f..3104f023eb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -26,6 +26,34 @@ Before wiring an MCP server into an agent decide where the tool calls should exe The sections below walk through each option, how to configure it, and when to prefer one transport over another. +## MCP Python SDK v1 and v2 + +The Agents SDK supports both major versions of the `mcp` Python package through the dependency range `mcp>=1.19.0,<3`. The installed `mcp` package version is separate from the MCP protocol version negotiated with a server. The Agents SDK detects the installed package major version and adapts stdio, SSE, and Streamable HTTP connections automatically, so ordinary server configuration does not need a version switch. + +When MCP Python SDK v2 is installed, the Agents SDK creates the v2 `mcp.Client` with `mode="auto"` around the configured local transport. The client first sends a `server/discover` probe at the newest protocol version supported by the installed MCP SDK. A modern server answers the probe, and the client adopts the result. If an older server does not support `server/discover`, the client falls back to the legacy `initialize` handshake and uses the protocol version negotiated there. Installing MCP Python SDK v2 therefore does not force every connection to use the newest MCP protocol version. See the MCP Python SDK's [protocol version negotiation guide](https://py.sdk.modelcontextprotocol.io/protocol-versions/). + +Most applications should let their dependency resolver select a compatible version. If your application must stay on one major version, add an explicit constraint alongside `openai-agents`: + +```bash +# MCP Python SDK v1 +pip install "mcp>=1.19.0,<2" + +# MCP Python SDK v2 +pip install "mcp>=2,<3" +``` + +HTTP transport customization must use the HTTP stack owned by the installed MCP package: + +| Customization | MCP Python SDK v1 | MCP Python SDK v2 | +| --- | --- | --- | +| `params["auth"]` | `httpx.Auth` | `httpx2.Auth` | +| `params["httpx_client_factory"]` return value | `httpx.AsyncClient` | `httpx2.AsyncClient` | +| `MCPServerStreamableHttp` `params["ignore_initialized_notification_failure"] = True` | Supported | Not supported; rejected before connecting | + +Use an `Authorization` header when possible, as shown in the Streamable HTTP example below; an `Authorization` header works unchanged with both package versions. When an application supplies `params["auth"]` or `params["httpx_client_factory"]`, those values must use the HTTP types for the installed `mcp` package major version. When an application sets `MCPServerStreamableHttp`'s `params["ignore_initialized_notification_failure"] = True`, the application must keep `mcp<2` or disable the option before upgrading. + +These local `mcp` dependency requirements do not apply to [`HostedMCPTool`][agents.tool.HostedMCPTool] because the OpenAI Responses API owns the remote MCP connection. + ## Agent-level MCP configuration In addition to choosing a transport, you can tune how MCP tools are prepared by setting `Agent.mcp_config`. @@ -269,9 +297,9 @@ server = MCPServerStreamableHttp( If your run context is a Pydantic model, dataclass, or custom class, read the tenant ID with attribute access instead. -### MCP tool outputs: text and images +### MCP tool outputs: text, images, and other content -When an MCP tool returns image content, the SDK automatically maps it to image-type entries in the tool output. Mixed text/image responses are forwarded as a list of output items, so agents can consume MCP image results the same way they consume image output from regular function tools. +When an MCP result uses its content blocks, the SDK forwards text content as text output and maps image content to image-type entries in the tool output. For other MCP content block types, including audio and resource blocks, the SDK forwards a text output whose value is the block's valid JSON serialization. Responses that contain multiple content blocks are forwarded as a list of output items. If `use_structured_content=True` selects a non-empty, non-error `structuredContent` payload, that structured payload takes precedence over these content blocks. Missing or empty structured content falls back to the content blocks. ## 3. HTTP with SSE MCP servers @@ -363,7 +391,8 @@ Key behaviors: - Failures are tracked in `failed_servers` and `errors`. - Set `strict=True` to raise on the first connection failure. - Call `reconnect(failed_only=True)` to retry failed servers, or `reconnect(failed_only=False)` to restart all servers. -- Set `connect_timeout_seconds`, `cleanup_timeout_seconds`, and `connect_in_parallel` to tune lifecycle behavior. Lifecycle timeouts accept positive finite seconds, or `None` to disable them, and are validated both during construction and assignment; zero is rejected because it would create an immediate deadline. +- Calls to `connect_all()`, `reconnect()`, and `cleanup_all()` are serialized. If one lifecycle operation is already running, another lifecycle operation waits for it to finish instead of connecting or cleaning up the same servers concurrently. +- Set `connect_timeout_seconds`, `cleanup_timeout_seconds`, and `connect_in_parallel` to tune lifecycle behavior. Both lifecycle timeouts default to 10 seconds. They accept positive finite seconds, or `None` to disable them, and are validated both during construction and assignment; zero is rejected because it would create an immediate deadline. ## Common server capabilities diff --git a/docs/models/index.md b/docs/models/index.md index 447e206150..d547d04c7e 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -23,7 +23,7 @@ Start with the simplest path that fits your setup: For most OpenAI-only apps, the recommended path is to use string model names with the default OpenAI provider and stay on the Responses model path. -When you don't specify a model when initializing an `Agent`, the default model will be used. The default is currently [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) with `reasoning.effort="none"` and `verbosity="low"` for low-latency agent workflows. If you have access, we recommend setting your agents to `gpt-5.6-sol` for higher quality while keeping explicit `model_settings`. +When an [`Agent`][agents.agent.Agent] does not specify a model, the Agents SDK uses [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna) with `reasoning.effort="none"` and `verbosity="low"` by default for cost-sensitive, high-volume agent workflows. Applications that need frontier capability can explicitly set `model="gpt-5.6-sol"` and choose `model_settings` that are appropriate for the workload. If you want to switch to other models like `gpt-5.6-sol`, there are two ways to configure your agents. @@ -545,11 +545,12 @@ A retry policy receives a [`RetryPolicyContext`][agents.retry.RetryPolicyContext - `error` for raw inspection. - `normalized` facts such as `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, and `is_abort`. - `provider_advice` when the underlying model adapter can supply retry guidance. +- `response_started`, `replay_safety`, and `stateful_request` as stable replay-safety facts captured before the policy runs. `replay_safety` is `"safe"`, `"unsafe"`, or `"unknown"`; `stateful_request` is true when the request uses `previous_response_id` or `conversation_id`. The policy can return either: - `True` / `False` for a simple retry decision. -- A [`RetryDecision`][agents.retry.RetryDecision] when you want to override the delay or attach a diagnostic reason. +- A [`RetryDecision`][agents.retry.RetryDecision] when you want to override the delay, attach a diagnostic reason, or explicitly approve a narrowly scoped unsafe replay. The SDK exports ready-made helpers on `retry_policies`: @@ -567,13 +568,15 @@ When you compose policies, `provider_suggested()` is the safest first building b ##### Safety boundaries -Some failures are never retried automatically: +Some failures are never retried: - Abort errors. -- Requests where provider advice marks replay as unsafe. - Streamed runs after output has already started in a way that would make replay unsafe. +- Requests with a separate local-side-effect replay veto, including Programmatic Tool Calling requests, unless the provider has independently marked the replay safe. -Stateful follow-up requests using `previous_response_id` or `conversation_id` are also treated more conservatively. For those requests, non-provider predicates such as `network_error()` or `http_status([500])` are not enough by themselves. The retry policy should include a replay-safe approval from the provider, typically via `retry_policies.provider_suggested()`. +Provider-marked unsafe failures are also blocked by default. For a non-streaming request without a separate local-side-effect veto, an application can accept the provider-side replay risk by returning `RetryDecision(retry=True, approve_unsafe_replay=True)`. Check `context.response_started`, `context.replay_safety`, and `context.stateful_request` before granting this approval, and grant it only when repeating provider-side work is acceptable. An ordinary `RetryDecision(retry=True)` never bypasses replay protection, and `approve_unsafe_replay=True` cannot authorize streamed retries or local side effects. + +Stateful follow-up requests using `previous_response_id` or `conversation_id` fail closed when replay safety is unknown. For those requests, non-provider predicates such as `network_error()` or `http_status([500])` are not enough by themselves. Include a replay-safe approval from the provider, typically via `retry_policies.provider_suggested()`, or explicitly approve a non-streaming failure that the provider marked unsafe as described above. ##### Runner and agent merge behavior @@ -624,6 +627,8 @@ result = await Runner.run( If you use [`MultiProvider`][agents.MultiProvider], pass `openai_strict_feature_validation=True` instead. +The OpenAI Chat Completions API can return audio output, but [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] does not currently convert audio output into Agents SDK run items. If a non-streaming message or streaming delta contains audio output, the adapter raises `AgentsException("Audio is not currently supported")` instead of returning a partial or empty result. Use [Realtime agents](../realtime/guide.md) or [Voice agents](../voice/quickstart.md) for SDK-managed audio workflows. + Some OpenAI-compatible Chat Completions providers stream tool-call deltas in chunks that are not reliable enough for incremental SDK processing. In that case, enable streamed tool-call buffering so the SDK emits tool calls only after the provider stream finishes: ```python diff --git a/docs/realtime/guide.md b/docs/realtime/guide.md index 5b275cf3e7..433d836d73 100644 --- a/docs/realtime/guide.md +++ b/docs/realtime/guide.md @@ -85,6 +85,60 @@ Useful run-level settings on `RealtimeRunner(config=...)` include: See [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] and [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] for the full typed surface. +### Input transcription settings + +Configure input transcription under `audio.input.transcription`. Use `gpt-live-transcribe` for low-latency incremental transcripts, or use `gpt-transcribe` over WebSocket when transcription should begin after an audio turn is committed or when your application needs detected-language output. The Agents SDK forwards the model-specific GA transcription settings in the nested session configuration: + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-live-transcribe", + "prompt": "A support call about the OpenAI Agents SDK.", + "keywords": ["RunState", "MCPServerManager"], + "languages": ["en", "ja"], + }, + "turn_detection": None, + } + } + } + }, +) +``` + +For `gpt-live-transcribe`, `prompt` provides free-form recording context, `keywords` lists literal terms that may occur in the audio, and `languages` lists expected input languages. This model uses plural `languages` instead of singular `language`; do not send both fields. + +The OpenAI client version pinned by this SDK supports `delay` only with `gpt-realtime-whisper`. Configure that model's latency and accuracy tradeoff as follows: + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-realtime-whisper", + "delay": "low", + }, + "turn_detection": None, + } + } + } + }, +) +``` + +The `delay` setting accepts `minimal`, `low`, `medium`, `high`, or `xhigh`. Lower values can produce earlier partial text, while higher values give the transcription model more audio context and can improve recognition accuracy. Benchmark representative audio instead of assuming fixed timing for any level. + +Use `gpt-transcribe` in a Realtime session over WebSocket only when transcription should begin after a committed audio turn or the application needs detected-language output. The model automatically uses earlier transcribed turns as context. The `gpt-transcribe` completion event reports detected languages in its `languages` output field. This output field is different from the `gpt-live-transcribe` expected-language input shown above. + +Setting `audio.input.turn_detection` to `None` disables automatic turn detection. The application must then commit audio turns and control response creation as described in [Manual response control](#manual-response-control). See the OpenAI API [Realtime transcription guide](https://developers.openai.com/api/docs/guides/realtime-transcription) for model behavior, validation rules, and latency guidance. + ## Inputs and outputs ### Text and structured user messages diff --git a/docs/release.md b/docs/release.md index 369d8f9219..39453dd706 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,6 +19,21 @@ We will increment `Z` for non-breaking changes: ## Breaking change changelog +### 0.20.0 + +Version 0.20.0 includes a potentially breaking MCP dependency migration for applications that customize local MCP HTTP transports. It also updates the SDK default model used when an agent or run does not explicitly select one. + +Highlights: + +- The SDK default model is now `gpt-5.6-luna` instead of `gpt-5.4-mini`. The default `reasoning.effort="none"` and `verbosity="low"` settings are unchanged. +- Explicit agent models, run-level model overrides, and the `OPENAI_DEFAULT_MODEL` environment variable continue to take precedence over the SDK default. +- Realtime input transcription settings now recognize `gpt-transcribe`, `gpt-live-transcribe`, and `gpt-realtime-whisper`. For low-latency `gpt-live-transcribe` sessions, nested `audio.input.transcription` settings can supply `prompt`, `keywords`, and multiple expected `languages`. The OpenAI client version pinned by this SDK supports the `delay` latency/accuracy level only with `gpt-realtime-whisper`. Use `gpt-transcribe` over WebSocket for transcription after a committed audio turn or for detected-language output. Setting `audio.input.turn_detection=None` explicitly disables automatic turn detection. See [Input transcription settings](realtime/guide.md#input-transcription-settings). +- Local MCP connections created by the Agents SDK now support MCP Python SDK v2 while retaining v1 compatibility through `mcp>=1.19.0,<3`. The Agents SDK adapts ordinary stdio, SSE, and Streamable HTTP connections automatically. With MCP v2 installed, these connections use `mcp.Client(mode="auto")` to probe the newest supported protocol and fall back to the legacy `initialize` handshake for older servers. If dependency resolution selects MCP v2, applications that supply custom `httpx.Auth` objects or `httpx.AsyncClient` factories must migrate those values to `httpx2`, or pin `mcp<2` to retain the v1 HTTP stack. `MCPServerStreamableHttp`'s `params["ignore_initialized_notification_failure"] = True` option also remains v1-only. See [MCP Python SDK v1 and v2](mcp.md#mcp-python-sdk-v1-and-v2) for migration details. +- Sandbox mount validation now rejects unsafe credential placement before sandbox or mount-helper side effects. Trusted applications can acknowledge mount-scoped or broad credential exposure for an exact in-container mount path without changing the storage capability tables. These acknowledgements are runtime-only and serialized sandbox state never grants credential authority by itself. At protected mount boundaries, the SDK returns a fresh redacted exception. If the source exception is an exact recognized SDK sandbox error and its approved structured fields validate, the replacement preserves that subtype and the validated safe fields. A recognized `MountConfigError` can also retain an SDK-generated safe validation message. Otherwise, the SDK returns a fresh generic redacted error. Provider-controlled or otherwise unapproved messages, command data, notes, context, causes, and source traceback state are not retained. See [Mounts and remote storage](sandbox/clients.md#mounts-and-remote-storage) and [Resume from session state](sandbox/guide.md#resume-from-session-state). +- Retry policies can inspect stable replay-safety facts and explicitly set `RetryDecision(approve_unsafe_replay=True)` for a non-streaming request that the provider marked unsafe. This approval does not bypass aborts, emitted streamed output, or separate local-side-effect vetoes such as Programmatic Tool Calling. See [Runner-managed retries](models/index.md#runner-managed-retries). +- Resumable `RunState` objects can now stage durable user input with `add_input()` before the next model call. Staged input survives serialization, runs through input guardrails, and produces one durable SDK input occurrence across local sessions and server-managed conversations. An explicitly approved unsafe replay can still resend the input to the provider and repeat provider-side work. See [Add input before resuming](results.md#add-input-before-resuming). +- Runtime reliability fixes align streamed and non-streamed [output-guardrail session persistence](guardrails.md#output-guardrails), preserve `FunctionTool` subclasses during copying and namespacing, and raise an explicit error for [unsupported Chat Completions audio output](models/index.md#chat-completions-compatibility-options) instead of silently completing an empty stream. The `OpenAIResponsesCompactionSession` wrapper attempts and awaits [pre-compaction history recovery](sessions/index.md#auto-compaction-can-block-streaming) before cancellation reaches the caller. A [`VoicePipeline`](voice/pipeline.md#results) consumer now receives transcription-session close failures after a clean run, while an earlier turn failure retains precedence over a later close failure. `RunState` round trips now preserve local shell output, acknowledged computer safety checks, default-valued tool output fields, and Pydantic model or dataclass outputs encountered while traversing dictionaries, lists, or tuples. MCP conversion preserves free-form object schemas and image output, and serializes other raw content blocks such as audio and resource blocks as valid JSON text. `MCPServerManager` serializes overlapping lifecycle operations and applies finite default timeouts to connection and cleanup. Model replay removes server-owned `created_by` metadata from output items before using them as input. + ### 0.19.0 This minor release does **not** introduce a breaking change. The minor version bump reflects a significant new OpenAI Responses feature area: Programmatic Tool Calling. diff --git a/docs/results.md b/docs/results.md index d6c6985a31..16631c8948 100644 --- a/docs/results.md +++ b/docs/results.md @@ -67,6 +67,7 @@ Resubmitting computer-tool items as conversation input uses the raw Responses pa [`new_items`][agents.result.RunResultBase.new_items] gives you the richest view of what happened during the run. Common item types are: +- [`InputItem`][agents.items.InputItem] for input admitted from `RunState.pending_input` immediately before a resumed model call - [`MessageOutputItem`][agents.items.MessageOutputItem] for assistant messages - [`ReasoningItem`][agents.items.ReasoningItem] for reasoning items - [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] and [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] for Responses tool search requests and loaded tool-search results @@ -119,6 +120,8 @@ If a tool needs approval, pending approvals are exposed in [`RunResult.interrupt Call [`to_state()`][agents.result.RunResult.to_state] to capture a resumable [`RunState`][agents.run_state.RunState], approve or reject the pending items, and then resume with `Runner.run(...)` or `Runner.run_streamed(...)`. +When a [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] output is a Pydantic model or dataclass, `RunState` serializes that output as structured data. `RunState` also traverses dictionaries, lists, and tuples and converts Pydantic models or dataclasses that it encounters in those containers; tuples are restored as lists after a JSON round trip. Other non-JSON-compatible values can fall back to their string representation, so return explicitly JSON-compatible data when an exact custom type must survive serialization. + ```python from agents import Agent, Runner @@ -132,6 +135,24 @@ if result.interruptions: result = await Runner.run(agent, state) ``` +#### Add input before resuming + +Use [`RunState.add_input()`][agents.run_state.RunState.add_input] when new user input arrives after a run pauses or stops after a completed turn, but before the unfinished run reaches its next model call. A string becomes a user message, and multiple calls preserve insertion order. The staged input is part of serialized `RunState`, so it survives `to_json()` / `from_json()` and `to_string()` / `from_string()` round trips. + +```python +state = result.to_state() +state.add_input("Also keep the generated report in the project folder.") + +for interruption in state.get_interruptions(): + state.approve(interruption) + +result = await Runner.run(agent, state) +``` + +On resume, the runner applies both the current agent's input guardrails and the input guardrails from [`RunConfig`][agents.run.RunConfig] only to the staged input. When a client-managed [`Session`][agents.memory.session.Session] is configured, the runner converts the accepted staged input into a durable [`InputItem`][agents.items.InputItem] and awaits the session write before issuing the model request. Without a client-managed session or server-managed conversation, the runner converts the accepted staged input into an `InputItem` before issuing the model request. For a server-managed conversation, the input remains pending until the server request accepts it. Across serialization, resume, and replay-safe retries, the SDK preserves one durable `InputItem` occurrence. This SDK occurrence guarantee is not a provider-delivery guarantee: if a retry policy returns `RetryDecision(approve_unsafe_replay=True)` after a request may have reached the provider, the runner can resend the staged input and provider-side work can repeat. Successfully admitted input appears in `new_items` as an `InputItem`. Read [`RunState.pending_input`][agents.run_state.RunState.pending_input] for a detached copy, or call [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input] to discard all staged input before resuming. + +`RunState.add_input()` rejects a terminal state, a state with no remaining model turns, a state in which an accepted model response is awaiting local processing, and an interrupted state whose pending tool result may end the run before another model call. In those cases, finish the current run and start a new user turn instead. + For streaming runs, finish consuming [`stream_events()`][agents.result.RunResultStreaming.stream_events] first, then inspect `result.interruptions` and resume from `result.to_state()`. For the full approval flow, see [Human-in-the-loop](human_in_the_loop.md). ### Server-managed continuation @@ -175,6 +196,13 @@ Python does not expose a separate streamed `completed` promise or `error` proper [`last_response_id`][agents.result.RunResultBase.last_response_id] is just the ID from the last entry in `raw_responses`. +Each [`ModelResponse`][agents.items.ModelResponse] also exposes two diagnostics that apply to that individual model call: + +- [`request_id`][agents.items.ModelResponse.request_id] is the transport request ID when the model adapter and transport propagate one. The built-in `OpenAIResponsesModel` and `OpenAIChatCompletionsModel` propagate an available server-generated `x-request-id` on their HTTP and SSE transport paths. When the configured endpoint is the OpenAI API, log a non-`None` value in production so you can correlate failures with OpenAI support; for an OpenAI-compatible provider or proxy, use that service's support channel instead. `OpenAIResponsesWSModel` currently leaves `request_id` as `None`. Third-party adapters do not guarantee request ID propagation. The AnyLLM Chat Completions adapter and `LitellmModel` currently leave `request_id` as `None`. The Agents SDK AnyLLM Responses adapter may also leave `request_id` as `None` when it normalizes a provider response without preserving the transport request ID. +- [`raw_usage`][agents.items.ModelResponse.raw_usage] is an opt-in, JSON-compatible snapshot of the provider's usage payload before the Agents SDK normalizes the payload. Enable `raw_usage` with `ModelSettings(preserve_raw_usage=True)`; see [Preserving provider usage payloads](usage.md#preserving-provider-usage-payloads). + +`ModelResponse.request_id` and `ModelResponse.raw_usage` can each be `None`, so handle these values as optional diagnostics rather than conversation state. + ### Guardrail results Agent-level guardrails are exposed as [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] and [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results]. diff --git a/docs/running_agents.md b/docs/running_agents.md index 32bd335219..9dcf4d1f4a 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -29,7 +29,7 @@ When you call any of the three `Runner` methods above, you pass in a starting ag - a string (treated as a user message), - a list of input items in the OpenAI Responses API format, or -- a [`RunState`][agents.run_state.RunState] when resuming an interrupted run. +- a [`RunState`][agents.run_state.RunState] when resuming a paused run or a run stopped with `cancel(mode="after_turn")`. The state can also carry [input staged for the next resumed model call](results.md#add-input-before-resuming). The runner then runs a loop: diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index 7102eb917c..001ff33e0f 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -119,6 +119,24 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac +The mount tables describe which storage types each backend can execute. A check mark does not bypass the credential boundary for a mount helper that runs inside a model-controlled sandbox, and it does not mean that every strategy can operate without credentials. The Agents SDK accepts an in-container mount without an acknowledgement only when the selected helper can operate without protected authority. It rejects a mount that requires protected authority before starting the sandbox or mount helper unless trusted application code explicitly acknowledges the exposure for the exact mount path. + +Credentialless `rclone` mounts are limited to S3, GCS, R2, and Azure Blob. An in-container Box mount requires a non-interactive authentication source and the acknowledgement that matches that source. `FuseMountPattern` requires broad acknowledgement because `blobfuse2` discovers ambient Azure authority, even when no inline credential is configured. `S3FilesMountPattern` likewise requires broad acknowledgement because `mount.s3files` uses ambient IAM authority. These requirements also apply when Docker is the backend; the check marks below indicate that Docker can execute the mount after the applicable authority boundary is satisfied. + +For a mount entry named `"data"`, retain the copied `Manifest` returned by the acknowledgement that matches the configured authority: + +```python +# Mount-scoped values such as inline access keys. +manifest = manifest.with_in_container_mount_credential_exposure_acknowledged("data") + +# Broader authority such as managed or workload identity and external credential files. +manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") +``` + +Pass every exact mount path that needs the acknowledgement. A mount that uses both authority classes requires both acknowledgements. The acknowledgements are runtime-only, are not serialized, and permit the helper to receive credentials without confining credential use to the mounted path. Prefer an external or provider-native strategy when available, and otherwise use sandbox-scoped, short-lived, least-privilege credentials. + +`VercelSandboxClientOptions(allow_s3_credential_exposure=True)` remains a compatibility option for create-time Vercel S3 mounts with inline mount-scoped credentials. It does not authorize broad credential authority. + The table below summarizes which remote storage entries each backend can mount directly.
diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md index 42733d630d..90abb88c35 100644 --- a/docs/sandbox/guide.md +++ b/docs/sandbox/guide.md @@ -649,6 +649,8 @@ Use this when sandbox state lives in your own storage or job system and you want Session-state serialization omits native `host_path` values. To resume host-backed grants, provide the current trusted manifest through `SandboxRunConfig.manifest` or `agent.default_manifest`; otherwise resume fails before the sandbox starts. Never derive host paths from serialized or other untrusted input. +Session-state and `RunState` serialization also remove cloud mount credentials, credential-bearing helper configuration, and in-container credential-exposure acknowledgements. For a backend that supports resuming mounted sessions, provide the current trusted manifest through `SandboxRunConfig.manifest` or `agent.default_manifest` when the state contains redacted mount authority. When the mount entry named `"data"` needs mount-scoped acknowledgement, retain the copied manifest with `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` before resuming. Use `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")` for broad authority, and call both methods when the mount uses both authority classes. Pass every exact mount path that needs an acknowledgement. The Agents SDK restores credentials only when the current trusted manifest has exactly the same credential-free mount topology as the persisted state. Missing or mismatched trusted configuration causes resume to fail before the sandbox starts; serialized state never grants authority by itself. `VercelSandboxClient` cannot resume a mounted session, so start a new sandbox with the trusted manifest instead. + ### Start from a snapshot Seed a new sandbox from saved files and artifacts: diff --git a/docs/sessions/index.md b/docs/sessions/index.md index b9acdaf9cd..dee68240b1 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -284,6 +284,8 @@ If your agent runs with `ModelSettings(store=False)`, the Responses API does not Compaction clears and rewrites the session history, so the SDK waits for compaction to finish before considering the run complete. In streaming mode, this means `run.stream_events()` can stay open for a few seconds after the last output token if compaction is heavy. +`OpenAIResponsesCompactionSession.run_compaction()` treats the clear-and-rewrite operation as a recoverable replacement at the wrapper boundary. If replacement fails or is cancelled after the underlying history changes, the wrapper attempts to restore the previous history and waits for that recovery attempt to settle before the original exception or cancellation reaches the caller. If the underlying backend also fails during recovery, the previous history can remain unrestored and the SDK logs the recovery failure. The wrapper serializes calls to `add_items()`, `pop_item()`, and `clear_session()` with the locked replacement and recovery phase, but a mutation can complete while the remote compaction request is still in flight and then be overwritten by successful replacement. Run manual compaction between turns without concurrent wrapper mutations, and do not mutate the underlying session directly while compaction is running. + If you want low-latency streaming or fast turn-taking, disable auto-compaction and call `run_compaction()` yourself between turns (or during idle time). You can decide when to force compaction based on your own criteria. ```python @@ -641,39 +643,38 @@ if __name__ == "__main__": ## Custom session implementations -You can implement your own session memory by creating a class that follows the [`Session`][agents.memory.session.Session] protocol: +You can implement your own session memory by creating a class that structurally follows the [`Session`][agents.memory.session.Session] protocol. You do not need to inherit from `SessionABC`; define `session_id` and `session_settings`, and implement the four history methods directly: ```python -from agents.memory.session import SessionABC +from agents import Agent, Runner, SessionSettings from agents.items import TResponseInputItem -from typing import List -class MyCustomSession(SessionABC): + +class MyCustomSession: """Custom session implementation following the Session protocol.""" - def __init__(self, session_id: str): + session_settings: SessionSettings | None = None + + def __init__(self, session_id: str) -> None: self.session_id = session_id - # Your initialization here + self.items: list[TResponseInputItem] = [] - async def get_items(self, limit: int | None = None) -> List[TResponseInputItem]: - """Retrieve conversation history for this session.""" - # Your implementation here - pass + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + if limit is None: + return list(self.items) + if limit <= 0: + return [] + return list(self.items[-limit:]) - async def add_items(self, items: List[TResponseInputItem]) -> None: - """Store new items for this session.""" - # Your implementation here - pass + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.items.extend(items) async def pop_item(self) -> TResponseInputItem | None: - """Remove and return the most recent item from this session.""" - # Your implementation here - pass + return self.items.pop() if self.items else None async def clear_session(self) -> None: - """Clear all items for this session.""" - # Your implementation here - pass + self.items.clear() + # Use your custom session agent = Agent(name="Assistant") @@ -684,6 +685,47 @@ result = await Runner.run( ) ``` +### Accessing run context from a custom session + +The Agents SDK can pass the active [`RunContextWrapper`][agents.run_context.RunContextWrapper] to a custom session for tenant routing, authorization, or other app-specific storage decisions. For the Agents SDK to pass the wrapper, add an explicitly named, keyword-compatible `wrapper` parameter to all four history methods: + +```python +from typing import Any + +from agents import RunContextWrapper +from agents.items import TResponseInputItem + + +class ContextAwareSession: + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: ... + + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... + + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: ... + + async def clear_session( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... +``` + +The Agents SDK enables this integration only when `get_items`, `add_items`, `pop_item`, and `clear_session` all declare `wrapper`. A generic `**kwargs` parameter does not satisfy this signature check. Existing session implementations that omit `wrapper` keep their released call shape and continue to work without changes. + ## Community session implementations The community has developed additional session implementations: diff --git a/docs/streaming.md b/docs/streaming.md index f4abec97cc..57632d6cdc 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -62,6 +62,8 @@ If you need to stop a streaming run in the middle, call [`result.cancel()`][agen A streamed run is not complete until `result.stream_events()` finishes. The SDK may still be persisting session items, finalizing approval state, or compacting history after the last visible token. If you are manually continuing from [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list], and `cancel(mode="after_turn")` stops after a tool turn, rerun `result.last_agent` with that normalized input to continue the unfinished existing user turn instead of appending a fresh user turn right away. + +- If new user input arrives before that unfinished run resumes, convert the drained result with `result.to_state()`, call [`state.add_input(...)`][agents.run_state.RunState.add_input], and resume from the state. The runner admits the staged input immediately before the next model call; see [Add input before resuming](results.md#add-input-before-resuming). - If a streamed run stopped for tool approval, do not treat that as a new turn. Finish draining the stream, inspect `result.interruptions`, and resume from `result.to_state()` instead. - Use [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] to customize how retrieved session history and the new user input are merged before the next model call. If you rewrite new-turn items there, the rewritten version is what gets persisted for that turn. diff --git a/docs/usage.md b/docs/usage.md index fbeb68e2b7..2d4f1d1987 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,6 +11,7 @@ The Agents SDK automatically tracks token usage for every run. You can access it - **request_usage_entries**: list of per-request usage breakdowns - **details**: - `input_tokens_details.cached_tokens` + - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` ## Accessing usage from a run @@ -49,6 +50,29 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` +## Preserving provider usage payloads + +The Agents SDK normalizes provider usage into [`Usage`][agents.usage.Usage] fields that provide consistent totals across model providers. Set [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] to `True` when an application must retain provider-specific usage fields or distinguish an omitted field from a provider-reported zero: + +```python +from agents import Agent, ModelSettings, Runner + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(preserve_raw_usage=True), +) +result = await Runner.run(agent, "What's the weather in Tokyo?") + +for response in result.raw_responses: + print(response.raw_usage) +``` + +The Agents SDK stores each [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] value as a detached, JSON-compatible snapshot of the provider payload for that model call. The Agents SDK does not aggregate `raw_usage` across the run. The value remains `None` when preservation is disabled, the provider returns no usage payload, or an upstream adapter has already discarded the original field-presence information. + +`preserve_raw_usage` preserves only a usage payload that reaches the model adapter; the setting does not request usage from the provider. When a streaming Chat Completions provider requires an explicit usage request, also set `ModelSettings(include_usage=True)`. + +`LitellmModel` does not currently populate `ModelResponse.raw_usage` in either streaming or non-streaming runs, so `preserve_raw_usage=True` has no effect with that adapter. Continue to use the normalized [`Usage`][agents.usage.Usage] fields when using `LitellmModel`, or choose an adapter that supports raw usage preservation when provider-specific field presence is required. + ## Accessing usage with sessions When you use a `Session` (e.g., `SQLiteSession`), each call to `Runner.run(...)` returns usage for that specific run. Sessions maintain conversation history for context, but each run's usage is independent. diff --git a/docs/voice/pipeline.md b/docs/voice/pipeline.md index 2a003293bf..33658e2afe 100644 --- a/docs/voice/pipeline.md +++ b/docs/voice/pipeline.md @@ -54,6 +54,8 @@ The result of a voice pipeline run is a [`StreamedAudioResult`][agents.voice.res 2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle], which informs you of lifecycle events like a turn starting or ending. 3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError], which is an error event. +Terminal pipeline errors are raised while the application consumes [`StreamedAudioResult.stream()`][agents.voice.result.StreamedAudioResult.stream]. If the speech-to-text transcription session fails to close after an otherwise clean run, the stream raises that close error instead of waiting indefinitely. If the turn has already failed and closing the transcription session also fails, the stream preserves the original turn error as the primary error. + ```python result = await pipeline.run(input) From 7518f367e32e6becb4015c000990249501587304 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 13:25:04 +0900 Subject: [PATCH 285/473] docs: update translated pages --- docs/ja/guardrails.md | 62 ++++--- docs/ja/human_in_the_loop.md | 90 ++++----- docs/ja/mcp.md | 159 +++++++++------- docs/ja/models/index.md | 316 ++++++++++++++++---------------- docs/ja/realtime/guide.md | 164 +++++++++++------ docs/ja/release.md | 133 ++++++++------ docs/ja/results.md | 156 +++++++++------- docs/ja/running_agents.md | 222 +++++++++++----------- docs/ja/sandbox/clients.md | 90 +++++---- docs/ja/sandbox/guide.md | 326 +++++++++++++++++---------------- docs/ja/sessions/index.md | 274 +++++++++++++++------------ docs/ja/streaming.md | 44 ++--- docs/ja/usage.md | 56 ++++-- docs/ja/voice/pipeline.md | 24 +-- docs/ko/guardrails.md | 68 +++---- docs/ko/human_in_the_loop.md | 88 ++++----- docs/ko/mcp.md | 177 ++++++++++-------- docs/ko/models/index.md | 276 ++++++++++++++-------------- docs/ko/realtime/guide.md | 156 ++++++++++------ docs/ko/release.md | 133 ++++++++------ docs/ko/results.md | 138 ++++++++------ docs/ko/running_agents.md | 227 ++++++++++++----------- docs/ko/sandbox/clients.md | 86 +++++---- docs/ko/sandbox/guide.md | 306 ++++++++++++++++--------------- docs/ko/sessions/index.md | 234 +++++++++++++---------- docs/ko/streaming.md | 64 +++---- docs/ko/usage.md | 54 ++++-- docs/ko/voice/pipeline.md | 24 +-- docs/zh/guardrails.md | 54 +++--- docs/zh/human_in_the_loop.md | 98 +++++----- docs/zh/mcp.md | 220 ++++++++++++---------- docs/zh/models/index.md | 346 ++++++++++++++++++----------------- docs/zh/realtime/guide.md | 186 ++++++++++++------- docs/zh/release.md | 137 ++++++++------ docs/zh/results.md | 164 ++++++++++------- docs/zh/running_agents.md | 236 ++++++++++++------------ docs/zh/sandbox/clients.md | 84 +++++---- docs/zh/sandbox/guide.md | 326 +++++++++++++++++---------------- docs/zh/sessions/index.md | 274 +++++++++++++++------------ docs/zh/streaming.md | 62 ++++--- docs/zh/usage.md | 60 ++++-- docs/zh/voice/pipeline.md | 34 ++-- 42 files changed, 3552 insertions(+), 2876 deletions(-) diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index bc65e8947e..3a56a59192 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -4,79 +4,81 @@ search: --- # ガードレール -ガードレールを使用すると、ユーザー入力とエージェント出力のチェックや検証を実行できます。たとえば、非常に高性能な(したがって低速でコストも高い)モデルを使用して顧客のリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストのモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、即座にエラーを送出できるため、時間とコストを節約できます。ブロッキング実行では高コストのモデルが起動しないことが保証されますが、並列実行ではガードレールが完了する前に高コストのモデルがすでに起動している可能性があります。詳しくは、以下の「実行モード」を参照してください。 +ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、顧客からのリクエストに対応するため、非常に高性能である一方、低速かつ高コストなモデルを使用するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストなモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、直ちにエラーを発生させ、時間とコストを節約できます。ブロッキング実行では、高コストなモデルが起動しないことが保証されます。一方、並列実行では、ガードレールが完了する前に高コストなモデルがすでに起動している可能性があります。詳細については、以下の「実行モード」を参照してください。 -ガードレールには次の 2 種類があります。 +ガードレールには、次の 2 種類があります。 1. 入力ガードレールは、最初のユーザー入力に対して実行されます -2. 出力ガードレールは、最終的なエージェント出力に対して実行されます +2. 出力ガードレールは、エージェントの最終出力に対して実行されます ## ワークフローの境界 -ガードレールはエージェントとツールに設定されますが、ワークフロー内のすべての同じ時点で実行されるわけではありません。 +ガードレールはエージェントとツールに関連付けられますが、ワークフロー内ですべてが同じタイミングに実行されるわけではありません。 - **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 - **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 - **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前、出力ガードレールは実行後に実行されます。 -マネージャー、ハンドオフ、または処理を委任されたスペシャリストを含むワークフローで、カスタム関数ツールの各呼び出し前後にチェックが必要な場合は、エージェントレベルの入出力ガードレールだけに依存せず、ツールガードレールを使用してください。 +マネージャー、ハンドオフ、または委任先の専門エージェントを含むワークフローで、各カスタム関数ツールの呼び出し前後にチェックが必要な場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 ## 入力ガードレール 入力ガードレールは、次の 3 ステップで実行されます。 -1. まず、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 -2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合は [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が送出されるため、ユーザーに適切に応答したり、例外を処理したりできます。 +1. 最初に、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 +2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] が生成されます。その後、これは [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 !!! Note - 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初* のエージェントである場合にのみ実行されます。なぜ `guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェントに設定されるのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 入力ガードレールはユーザー入力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最初* のエージェントである場合にのみ実行されます。なぜ `guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェント上にあるのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 ### 実行モード 入力ガードレールは、次の 2 つの実行モードをサポートしています。 -- **並列実行** (デフォルト、`run_in_parallel=True`):ガードレールは、エージェントの実行と同時に実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールのトリップワイヤーが作動した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 +- **並列実行** (デフォルト、 `run_in_parallel=True` ):ガードレールはエージェントの実行と同時に実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールのトリップワイヤーが作動した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 -- **ブロッキング実行** (`run_in_parallel=False`):ガードレールは、エージェントが起動する *前* に実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防止できます。これは、コストを最適化する場合や、ツール呼び出しによる潜在的な副作用を回避したい場合に最適です。 +- **ブロッキング実行** ( `run_in_parallel=False` ):ガードレールは、エージェントが起動する *前* に実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは一切実行されないため、トークンの消費とツールの実行を防止できます。これは、コストを最適化したい場合や、ツール呼び出しによる潜在的な副作用を回避したい場合に最適です。 ## 出力ガードレール 出力ガードレールは、次の 3 ステップで実行されます。 -1. まず、ガードレールはエージェントが生成した出力を受け取ります。 -2. 次に、ガードレール関数が実行されて [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成し、それが [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合は [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が送出されるため、ユーザーに適切に応答したり、例外を処理したりできます。 +1. 最初に、ガードレールはエージェントが生成した出力を受け取ります。 +2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] が生成されます。その後、これは [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 !!! Note - 出力ガードレールは最終的なエージェント出力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最後* のエージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 出力ガードレールはエージェントの最終出力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最後* のエージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 - 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターはサポートしていません。 + 出力ガードレールは常にエージェントの完了後に実行されるため、 `run_in_parallel` パラメーターをサポートしていません。 + +出力トリップワイヤーと、ガードレール関数によって発生した例外では、セッションの動作が異なります。トリップワイヤーは、最終出力の候補を拒否します。トリップワイヤーが作動すると、ランナーは設定済みのセッションに対して、すでに完了したツール呼び出しとツール出力の項目を、それらの呼び出しの再実行に必要な推論コンテキストとともに永続化するよう要求します。このとき、拒否された最終出力の候補は除外されます。ランナーは、このトリップワイヤーのルールをストリーミング実行と非ストリーミング実行の両方に適用します。ガードレール関数がトリップワイヤーの結果を返す代わりに例外を発生させた場合、ランナーは判定を不明として扱い、ガードレール例外を通知する前に、完了した最終ターンの項目を永続化するよう設定済みのセッションに要求します。そのセッションへの書き込みも失敗した場合は、セッション書き込みエラーが優先されます。ストリーミング実行では、非ストリーミング実行と同じ永続化順序が使用され、 `stream_events()` から終端例外が発生します。出力ガードレールの実行中に [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel] を直ちに呼び出すと、実行中のガードレールがキャンセルされ、最終ターンのセッション書き込みは開始されません。 ## ツールガードレール -ツールガードレールは **`FunctionTool` のインスタンス** をラップし、ツールの実行前後にその呼び出しを検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 +ツールガードレールは **`FunctionTool` のインスタンス** をラップし、それらのツールの呼び出しを実行前後に検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 -- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、出力のメッセージへの置き換え、またはトリップワイヤーの送出が可能です。 -- 出力ツールガードレールはツールの実行後に実行され、出力の置き換えまたはトリップワイヤーの送出が可能です。 -- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に実行されます。承認保留による中断が発生する前にこれらの入力チェックを実行するには、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定します。この承認前チェックを通過した呼び出しは、ツールの実行前に、承認後にも再度チェックされます。 -- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)と組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)もこのガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 +- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、メッセージによる出力の置き換え、またはトリップワイヤーの作動が可能です。 +- 出力ツールガードレールはツールの実行後に実行され、出力の置き換えまたはトリップワイヤーの作動が可能です。 +- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に実行されます。保留中の承認による中断が発生する前にこれらの入力チェックを実行するには、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定してください。この事前承認チェックに合格した呼び出しも、ツールの実行前に承認後のチェックを再度受けます。 +- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。OpenAI がホストするツール( `WebSearchTool` 、 `FileSearchTool` 、 `HostedMCPTool` 、 `CodeInterpreterTool` 、 `ImageGenerationTool` )および組み込み実行ツール( `ComputerTool` 、 `ShellTool` 、 `ApplyPatchTool` 、 `LocalShellTool` )も、このガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 -詳しくは、以下のコードスニペットを参照してください。 +詳細については、以下のコードスニペットを参照してください。 ## トリップワイヤー -エージェントの入力または出力がガードレールを通過しなかった場合、ガードレールはトリップワイヤーを使用して通知できます。ランナーは即座に `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を送出し、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外が使用されます。 +エージェントの入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーによってそれを通知できます。ランナーは直ちに `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を発生させ、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外が使用されます。 -エージェントレベルのトリップワイヤーでは、例外の `guardrail_result` によって、トリップワイヤーを作動させたガードレールを特定できます。ランナーが入力トリップワイヤーを送出した場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレール結果が含まれます。これには、トリップワイヤーを作動させた結果も含まれます。出力トリップワイヤーでは、`exception.run_data.output_guardrail_results` を通じて、これに相当する累積結果が提供されます。 +エージェントレベルのトリップワイヤーでは、例外の `guardrail_result` によって、トリップワイヤーを作動させたガードレールを特定できます。ランナーによって入力トリップワイヤーが発生した場合、 `exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの結果が含まれます。これには、トリップワイヤーを作動させた結果も含まれます。出力トリップワイヤーでは、同等の累積結果が `exception.run_data.output_guardrail_results` を通じて提供されます。 -一方、ツールトリップワイヤーの例外は、トリガーとなった `guardrail` と `output` を直接公開します。その `run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` リストには、失敗前に完了したターンから蓄積された結果が保持されます。トリガーとなった結果は、例外の `output` から取得できます。`MaxTurnsExceeded` など、ランナーが管理するその他の失敗でも、完了したツールガードレールの結果がこれらのリストに保持されます。`stream_events()` が例外を送出した後も、ストリーミング結果では、同じように蓄積されたエージェントおよびツールガードレールの結果リストを取得できます。ランナーが管理する実行パスの外部で例外が送出された場合、`run_data` は `None` になる可能性があります。 +一方、ツールトリップワイヤー例外では、作動の原因となった `guardrail` と `output` が直接公開されます。その `run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` のリストには、失敗前に完了したターンから累積された結果が保持されます。作動の原因となった結果は、例外の `output` を通じて取得できます。 `MaxTurnsExceeded` など、ランナーが管理するその他の失敗でも、完了したツールガードレールの結果がこれらのリストに保持されます。 `stream_events()` が例外を発生させた後、ストリーミング実行結果には、同じく累積されたエージェントおよびツールガードレールの結果リストが公開されます。ランナーが管理する実行パスの外部で例外が発生した場合、 `run_data` は `None` になることがあります。 ## ガードレールの実装 -入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行してこれを実現します。 +入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することによって、これを実現します。 ```python from pydantic import BaseModel @@ -129,9 +131,9 @@ async def main(): print("Math homework guardrail tripped") ``` -1. ガードレール関数でこのエージェントを使用します。 -2. これは、エージェントの入力とコンテキストを受け取り、結果を返すガードレール関数です。 -3. ガードレールの結果には追加情報を含めることができます。 +1. このエージェントをガードレール関数で使用します。 +2. これは、エージェントの入力/コンテキストを受け取り、結果を返すガードレール関数です。 +3. ガードレールの結果には、追加情報を含めることができます。 4. これは、ワークフローを定義する実際のエージェントです。 出力ガードレールも同様です。 @@ -192,7 +194,7 @@ async def main(): 3. これは、エージェントの出力を受け取り、結果を返すガードレール関数です。 4. これは、ワークフローを定義する実際のエージェントです。 -最後に、ツールガードレールのコード例を示します。 +最後に、ツールガードレールの例を示します。 ```python import json diff --git a/docs/ja/human_in_the_loop.md b/docs/ja/human_in_the_loop.md index 521379759c..c97f3f6279 100644 --- a/docs/ja/human_in_the_loop.md +++ b/docs/ja/human_in_the_loop.md @@ -2,21 +2,21 @@ search: exclude: true --- -# ヒューマン・イン・ザ・ループ +# ヒューマンインザループ -ヒューマン・イン・ザ・ループ (HITL) フローを使用すると、機密性の高いツール呼び出しを人が承認または拒否するまで、エージェントの実行を一時停止できます。ツールは承認が必要なタイミングを宣言し、実行結果では保留中の承認が中断として提示されます。また、`RunState` を使用すると、一時停止した実行をシリアル化し、判断後に再開できます。 +ヒューマンインザループ (HITL) フローを使用すると、機密性の高いツール呼び出しを人が承認または拒否するまで、エージェントの実行を一時停止できます。ツールは承認が必要となる条件を宣言し、実行結果では保留中の承認が割り込みとして提示されます。また、`RunState` を使用すると、一時停止した実行をシリアライズし、判断後に再開できます。 -この承認機構は実行全体に適用され、現在の最上位エージェントだけに限定されません。ツールが現在のエージェントに属する場合、ハンドオフで到達したエージェントに属する場合、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行に属する場合にも、同じパターンが適用されます。ネストされた `Agent.as_tool()` の場合も、中断は外側の実行に提示されるため、外側の `RunState` で承認または拒否し、元の最上位の実行を再開します。 +この承認フローは実行全体に適用され、現在の最上位エージェントだけに限定されません。ツールが現在のエージェント、ハンドオフ先のエージェント、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行のいずれに属する場合でも、同じパターンが適用されます。ネストされた `Agent.as_tool()` の場合も、割り込みは外側の実行に提示されるため、外側の `RunState` で承認または拒否し、元の最上位の実行を再開します。 -`Agent.as_tool()` では、承認が 2 つの異なるレイヤーで発生する場合があります。エージェントツール自体が `Agent.as_tool(..., needs_approval=...)` を介して承認を要求できるほか、ネストされた実行の開始後に、その内部のツールが独自の承認を要求することもできます。どちらも、外側の実行における同じ中断フローで処理されます。 +`Agent.as_tool()` では、承認が 2 つの異なる層で発生する可能性があります。エージェントツール自体が `Agent.as_tool(..., needs_approval=...)` を介して承認を要求できるほか、ネストされた実行の開始後に、その内部のツールが独自の承認を要求することもできます。どちらも、同じ外側の実行の割り込みフローで処理されます。 このページでは、`interruptions` を介した手動承認フローを中心に説明します。アプリがコード内で判断できる場合、一部のツールタイプではプログラムによる承認コールバックもサポートされているため、実行を一時停止せずに続行できます。 ## 承認が必要なツールの指定 -常に承認を要求するには `needs_approval` を `True` に設定します。または、呼び出しごとに判断する非同期関数を指定します。この callable は、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 +常に承認を要求するには `needs_approval` を `True` に設定し、呼び出しごとに判断するには非同期関数を指定します。この callable は、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 -SDK が引数を安全に検査できない場合、callable の承認ルールは安全側に倒れ、承認が必須になります。引数が不正な JSON、正しい JSON でもオブジェクトではないもの(たとえば `null` やリスト)、または `NaN`、`Infinity`、`-Infinity` などの非標準定数を含む場合、callable は呼び出されず、その呼び出しには手動承認が必要です。この動作は、Runner と Realtime のツール呼び出しで共通です。 +SDK が引数を安全に検査できない場合、callable の承認ルールは安全側に倒れます。引数が不正な JSON、有効な JSON ではあるもののオブジェクトではないもの(たとえば、`null` やリスト)、または `NaN`、`Infinity`、`-Infinity` などの非標準定数を含む場合、callable は呼び出されず、その呼び出しには手動承認が必要となります。この動作は、Runner と Realtime のツール呼び出しで共通です。 ```python from agents import Agent @@ -44,26 +44,28 @@ agent = Agent( ) ``` -`needs_approval` は、[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]、[`ApplyPatchTool`][agents.tool.ApplyPatchTool] で利用できます。ローカル MCP サーバーも、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] の `require_approval` を介した承認をサポートします。ホスト型 MCP サーバーでは、`tool_config={"require_approval": "always"}` とオプションの `on_approval_request` コールバックを指定した [`HostedMCPTool`][agents.tool.HostedMCPTool] を介して承認をサポートします。中断を提示せずに自動承認または自動拒否する場合、シェルツールおよび apply_patch ツールでは `on_approval` コールバックを使用できます。 +`needs_approval` は、[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]、[`ApplyPatchTool`][agents.tool.ApplyPatchTool] で利用できます。ローカル MCP サーバーでも、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] の `require_approval` を介して承認をサポートしています。ホスト型 MCP サーバーでは、`tool_config={"require_approval": "always"}` と任意の `on_approval_request` コールバックを指定した [`HostedMCPTool`][agents.tool.HostedMCPTool] を介して承認をサポートしています。Shell ツールと apply_patch ツールでは、割り込みを提示せずに自動承認または自動拒否する場合、`on_approval` コールバックを利用できます。 ## 承認フローの仕組み -1. モデルがツール呼び出しを生成すると、ランナーはその承認ルール(`needs_approval`、`require_approval`、またはホスト型 MCP に相当するもの)を評価します。 -2. そのツール呼び出しに対する承認判断がすでに [`RunContextWrapper`][agents.run_context.RunContextWrapper] に保存されている場合、ランナーは確認を求めずに続行します。呼び出し単位の承認は特定の呼び出し ID に限定されます。実行の残りの期間、そのツールに対する今後の呼び出しにも同じ判断を保持するには、`always_approve=True` または `always_reject=True` を渡します。 -3. 承認ルールで承認が必要とされ、そのツール呼び出しに対する判断が保存されていない場合、実行は一時停止します。`RunResult.interruptions`(または `RunResultStreaming.interruptions`)には、`agent.name`、`tool_name`、`arguments` などの詳細を含む [`ToolApprovalItem`][agents.items.ToolApprovalItem] エントリが格納されます。これには、ハンドオフ後またはネストされた `Agent.as_tool()` の実行内で発生した承認も含まれます。 -4. `result.to_state()` を使用して実行結果を `RunState` に変換し、`state.approve(...)` または `state.reject(...)` を呼び出します。その後、`Runner.run(agent, state)` または `Runner.run_streamed(agent, state)` を使用して再開します。ここで、`agent` はその実行の元の最上位エージェントです。 -5. 再開した実行は中断箇所から続行し、新たな承認が必要になった場合はこのフローに再度入ります。 +1. モデルがツール呼び出しを出力すると、ランナーはその承認ルール(`needs_approval`、`require_approval`、またはホスト型 MCP に相当するもの)を評価します。 +2. そのツール呼び出しに対する承認判断がすでに [`RunContextWrapper`][agents.run_context.RunContextWrapper] に保存されている場合、ランナーは確認せずに処理を続行します。呼び出し単位の承認は、特定の呼び出し ID に限定されます。実行の残りの期間中、同じツール識別情報に対する今後の呼び出しにも同じ判断を保持するには、`always_approve=True` または `always_reject=True` を渡します。 +3. 承認ルールで承認が必要と判断され、そのツール呼び出しに対する判断が保存されていない場合、実行は一時停止します。`RunResult.interruptions`(または `RunResultStreaming.interruptions`)には、`agent.name`、`tool_name`、`arguments` などの詳細を含む [`ToolApprovalItem`][agents.items.ToolApprovalItem] エントリが格納されます。これには、ハンドオフ後またはネストされた `Agent.as_tool()` の実行内で発生した承認も含まれます。 +4. `result.to_state()` を使用して実行結果を `RunState` に変換し、`state.approve(...)` または `state.reject(...)` を呼び出した後、`Runner.run(agent, state)` または `Runner.run_streamed(agent, state)` で再開します。ここで、`agent` はその実行の元の最上位エージェントです。 +5. 再開された実行は中断箇所から続行され、新しい承認が必要になると、このフローに再度入ります。 -`always_approve=True` または `always_reject=True` で作成された固定判断は実行状態に保存されるため、同じ一時停止中の実行を後で再開するときに、`state.to_string()` / `RunState.from_string(...)` および `state.to_json()` / `RunState.from_json(...)` を経ても保持されます。 +`always_approve=True` または `always_reject=True` で作成された継続的な判断は実行状態に保存されるため、後から同じ一時停止済みの実行を再開する際に、`state.to_string()` / `RunState.from_string(...)` および `state.to_json()` / `RunState.from_json(...)` を経ても保持されます。 -同じ処理回ですべての保留中の承認を解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認を混在させることができます。一部の項目だけを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決のものは `interruptions` に残り、実行は再び一時停止します。 +[`HostedMCPTool`][agents.tool.HostedMCPTool] からの承認リクエストについて、Agents SDK は `server_label` とツール名の組み合わせによって、継続的なツール判断を識別します。あるホスト型 MCP サーバー上の `lookup_account` に対する常時承認の判断によって、別のサーバー上にある同名のツールが承認されることはありません。Agents SDK が常時承認または常時拒否の判断を保持するのは、ホスト型 MCP の承認リクエストに空ではない両方の識別フィールドが含まれている場合のみです。 + +保留中の承認をすべて同じ処理内で解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認を混在させることができます。一部の項目だけを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決のものは `interruptions` に残り、実行は再び一時停止します。 ## カスタム拒否メッセージ -デフォルトでは、拒否されたツール呼び出しについて、SDK の標準的な拒否テキストが実行内に返されます。このメッセージは 2 つのレイヤーでカスタマイズできます。 +デフォルトでは、拒否されたツール呼び出しについて、SDK の標準的な拒否テキストが実行に返されます。このメッセージは、次の 2 つの層でカスタマイズできます。 -- 実行全体のフォールバック: [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter] を設定すると、実行全体にわたる承認拒否について、モデルに表示されるデフォルトメッセージを制御できます。 -- 呼び出し単位のオーバーライド: 特定の拒否されたツール呼び出しだけに異なるメッセージを提示する場合は、`state.reject(...)` に `rejection_message=...` を渡します。 +- 実行全体のフォールバック: [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter] を設定すると、実行全体にわたって、承認拒否時にモデルへ提示されるデフォルトメッセージを制御できます。 +- 呼び出し単位のオーバーライド: 特定の 1 つの拒否されたツール呼び出しに異なるメッセージを提示するには、`state.reject(...)` に `rejection_message=...` を渡します。 両方が指定されている場合、呼び出し単位の `rejection_message` が実行全体のフォーマッターより優先されます。 @@ -86,27 +88,27 @@ state.reject( ) ``` -両方のレイヤーを組み合わせた完全なコード例については、[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py) を参照してください。 +両方の層を組み合わせた完全な例については、[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py) を参照してください。 -## 承認判断の自動化 +## 自動承認判断 手動の `interruptions` は最も汎用的なパターンですが、唯一の方法ではありません。 -- ローカルの [`ShellTool`][agents.tool.ShellTool] および [`ApplyPatchTool`][agents.tool.ApplyPatchTool] では、`on_approval` を使用してコード内で即座に承認または拒否できます。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] では、`tool_config={"require_approval": "always"}` と `on_approval_request` を組み合わせて、同様にプログラムによる判断を行えます。 -- 通常の [`function_tool`][agents.tool.function_tool] ツールおよび [`Agent.as_tool()`][agents.agent.Agent.as_tool] では、このページで説明する手動中断フローを使用します。 +- ローカルの [`ShellTool`][agents.tool.ShellTool] と [`ApplyPatchTool`][agents.tool.ApplyPatchTool] では、`on_approval` を使用して、コード内ですぐに承認または拒否できます。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] では、`tool_config={"require_approval": "always"}` と `on_approval_request` を組み合わせて、同様にプログラムで判断できます。 +- 通常の [`function_tool`][agents.tool.function_tool] ツールと [`Agent.as_tool()`][agents.agent.Agent.as_tool] では、このページで説明する手動割り込みフローを使用します。 -これらのコールバックが判断を返すと、人の応答を待つために一時停止することなく実行が続行されます。Realtime および音声セッション API については、[Realtime ガイド](realtime/guide.md)の承認フローを参照してください。 +これらのコールバックが判断を返すと、人の応答を待つために一時停止することなく実行が続行されます。Realtime API と音声セッション API については、[Realtime ガイド](realtime/guide.md)の承認フローを参照してください。 ## ストリーミングとセッション -同じ中断フローは、ストリーミング実行でも機能します。ストリーミング実行が一時停止したら、イテレーターが終了するまで [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を続け、[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] を確認して解決します。再開後の出力でもストリーミングを継続する場合は、[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] を使用して再開します。このパターンのストリーミング版については、[ストリーミング](streaming.md)を参照してください。 +同じ割り込みフローをストリーミング実行でも利用できます。ストリーミング実行が一時停止した後も、イテレーターが終了するまで [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] を消費し続け、[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] を確認して解決します。再開後の出力でもストリーミングを継続する場合は、[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] で再開します。このパターンのストリーミング版については、[ストリーミング](streaming.md)を参照してください。 -セッションも使用している場合は、`RunState` から再開するときに同じセッションインスタンスを渡し続けるか、同じセッション ID とバッキングストアを使用するように構成された別のセッションオブジェクトを渡します。再開されたターンは、同じ保存済み会話履歴に追加されます。セッションのライフサイクルの詳細については、[セッション](sessions/index.md)を参照してください。 +セッションも使用している場合は、`RunState` から再開するときに同じセッションインスタンスを引き続き渡すか、同じセッション ID とバッキングストア向けに構成された別のセッションオブジェクトを渡します。再開されたターンは、同じ保存済み会話履歴に追加されます。セッションのライフサイクルの詳細については、[セッション](sessions/index.md)を参照してください。 ## 一時停止、承認、再開の例 -以下のスニペットは JavaScript の HITL ガイドと同じ流れです。ツールに承認が必要な場合に一時停止し、状態をディスクに保存して再読み込みし、判断を取得した後に再開します。 +以下のスニペットは JavaScript の HITL ガイドと同様に、ツールに承認が必要な場合に一時停止し、状態をディスクに保持して再読み込みし、判断を取得した後に再開します。 ```python import asyncio @@ -171,35 +173,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -このコード例では、`prompt_approval` は `input()` を使用し、`run_in_executor(...)` で実行されるため同期関数です。承認元がすでに非同期の場合(たとえば、HTTP リクエストや非同期データベースクエリ)は、`async def` 関数を使用し、`await` で直接待機できます。 +この例では、`prompt_approval` は `input()` を使用し、`run_in_executor(...)` で実行されるため、同期関数になっています。承認元がすでに非同期である場合(たとえば、HTTP リクエストや非同期データベースクエリ)、`async def` 関数を使用し、それを直接 `await` できます。 -承認のために一時停止する可能性がある実行でストリーミングを使用するには、`Runner.run_streamed` を呼び出し、完了するまで `result.stream_events()` を消費した後、上記と同じ `result.to_state()` および再開手順に従います。 +承認のために一時停止する可能性がある実行でストリーミングを使用するには、`Runner.run_streamed` を呼び出し、完了するまで `result.stream_events()` を消費した後、上記と同じ `result.to_state()` および再開の手順に従います。 ## リポジトリのパターンとコード例 - **ストリーミング承認**: `examples/agent_patterns/human_in_the_loop_stream.py` は、`stream_events()` を最後まで消費し、保留中のツール呼び出しを承認してから `Runner.run_streamed(agent, state)` で再開する方法を示します。 -- **カスタム拒否テキスト**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` は、承認が拒否された場合に、実行レベルの `tool_error_formatter` と呼び出し単位の `rejection_message` オーバーライドを組み合わせる方法を示します。 -- **エージェントをツールとして使用する場合の承認**: `Agent.as_tool(..., needs_approval=...)` は、委任されたエージェントタスクにレビューが必要な場合に、同じ中断フローを適用します。ネストされた中断も外側の実行に提示されるため、ネストされたエージェントではなく、元の最上位エージェントを再開します。 -- **ローカルのシェルツールと apply_patch ツール**: `ShellTool` と `ApplyPatchTool` も `needs_approval` をサポートします。実行の残りの期間、そのツールに対する今後の呼び出しに判断をキャッシュするには、`state.approve(interruption, always_approve=True)` または `state.reject(..., always_reject=True)` を使用します。自動判断には `on_approval` を指定します(`examples/tools/shell.py` を参照)。手動判断では中断を処理します(`examples/tools/shell_human_in_the_loop.py` を参照)。ホスト型シェル環境は `needs_approval` または `on_approval` をサポートしていません。[ツールガイド](tools.md)を参照してください。 -- **ローカル MCP サーバー**: MCP ツール呼び出しを制御するには、`MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` で `require_approval` を使用します(`examples/mcp/get_all_mcp_tools_example/main.py` および `examples/mcp/tool_filter_example/main.py` を参照)。 -- **ホスト型 MCP サーバー**: HITL を強制するには、`HostedMCPTool` で `tool_config={"require_approval": "always"}` を設定し、必要に応じて自動承認または自動拒否を行う `on_approval_request` を指定します(`examples/hosted_mcp/human_in_the_loop.py` および `examples/hosted_mcp/on_approval.py` を参照)。信頼できるサーバーには `"never"` を使用します(`examples/hosted_mcp/simple.py`)。 -- **セッションとメモリ**: `Runner.run` にセッションを渡すと、承認と会話履歴が複数のターンにわたって保持されます。SQLite および OpenAI Conversations のセッションバリアントは、`examples/memory/memory_session_hitl_example.py` と `examples/memory/openai_session_hitl_example.py` にあります。 -- **Realtime エージェント**: Realtime デモでは、`RealtimeSession` 上の `approve_tool_call` / `reject_tool_call` を介してツール呼び出しを承認または拒否する WebSocket メッセージを公開しています(サーバー側のハンドラーについては `examples/realtime/app/server.py`、API のインターフェースについては [Realtime ガイド](realtime/guide.md#tool-approvals)を参照)。 +- **カスタム拒否テキスト**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` は、承認が拒否されたときに、実行レベルの `tool_error_formatter` と呼び出し単位の `rejection_message` オーバーライドを組み合わせる方法を示します。 +- **ツールとしてのエージェントの承認**: `Agent.as_tool(..., needs_approval=...)` は、委任されたエージェントのタスクにレビューが必要な場合にも同じ割り込みフローを適用します。ネストされた割り込みも外側の実行に提示されるため、ネストされたエージェントではなく、元の最上位エージェントを再開します。 +- **ローカルの Shell ツールと apply_patch ツール**: `ShellTool` と `ApplyPatchTool` も `needs_approval` をサポートしています。実行の残りの期間中、そのツールに対する今後の呼び出しにも判断をキャッシュするには、`state.approve(interruption, always_approve=True)` または `state.reject(..., always_reject=True)` を使用します。自動判断には `on_approval` を指定し(`examples/tools/shell.py` を参照)、手動判断では割り込みを処理します(`examples/tools/shell_human_in_the_loop.py` を参照)。ホスト型 Shell 環境では、`needs_approval` または `on_approval` はサポートされていません。[ツールガイド](tools.md)を参照してください。 +- **ローカル MCP サーバー**: MCP ツール呼び出しを制御するには、`MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` で `require_approval` を使用します(`examples/mcp/get_all_mcp_tools_example/main.py` と `examples/mcp/tool_filter_example/main.py` を参照)。 +- **ホスト型 MCP サーバー**: HITL を強制するには `HostedMCPTool` に `tool_config={"require_approval": "always"}` を設定します。必要に応じて、自動承認または自動拒否するための `on_approval_request` を指定できます(`examples/hosted_mcp/human_in_the_loop.py` と `examples/hosted_mcp/on_approval.py` を参照)。信頼済みのサーバーには `"never"` を使用します(`examples/hosted_mcp/simple.py`)。 +- **セッションとメモリ**: 承認と会話履歴を複数のターンにわたって保持するには、`Runner.run` にセッションを渡します。SQLite および OpenAI Conversations のセッションバリアントは、`examples/memory/memory_session_hitl_example.py` と `examples/memory/openai_session_hitl_example.py` にあります。 +- **Realtime エージェント**: Realtime デモでは、`RealtimeSession` の `approve_tool_call` / `reject_tool_call` を介してツール呼び出しを承認または拒否する WebSocket メッセージを公開しています(サーバー側のハンドラーについては `examples/realtime/app/server.py`、API サーフェスについては [Realtime ガイド](realtime/guide.md#tool-approvals)を参照)。 ## 長時間にわたる承認 -`RunState` は永続性を考慮して設計されています。`state.to_json()` または `state.to_string()` を使用して保留中の作業をデータベースやキューに保存し、後で `RunState.from_json(...)` または `RunState.from_string(...)` を使用して再作成します。 +`RunState` は永続性を考慮して設計されています。保留中の処理をデータベースやキューに保存するには `state.to_json()` または `state.to_string()` を使用し、後から再作成するには `RunState.from_json(...)` または `RunState.from_string(...)` を使用します。 -便利なシリアル化オプションは次のとおりです。 +便利なシリアライズオプションは次のとおりです。 -- `context_serializer`: マッピングではないコンテキストオブジェクトのシリアル化方法をカスタマイズします。 +- `context_serializer`: マッピングではないコンテキストオブジェクトをシリアライズする方法をカスタマイズします。 - `context_deserializer`: `RunState.from_json(...)` または `RunState.from_string(...)` で状態を読み込む際に、マッピングではないコンテキストオブジェクトを再構築します。 -- `strict_context=True`: コンテキストがすでにマッピングであるか、`context_serializer` を指定していない限り、シリアル化を失敗させます。また、コンテキストがすでにマッピングであるか、`context_deserializer` を指定していない限り、デシリアル化を失敗させます。 -- `context_override`: 状態の読み込み時に、シリアル化されたコンテキストを置き換えます。元のコンテキストオブジェクトを復元したくない場合に便利ですが、すでにシリアル化されたペイロードからそのコンテキストが削除されるわけではありません。 -- `include_tracing_api_key=True`: 再開された作業で同じ認証情報を使用してトレースをエクスポートし続ける必要がある場合、シリアル化されたトレースペイロードにトレーシング API キーを含めます。 +- `strict_context=True`: コンテキストがすでにマッピングであるか、`context_serializer` が指定されていない限り、シリアライズを失敗させます。また、コンテキストがすでにマッピングであるか、`context_deserializer` が指定されていない限り、デシリアライズを失敗させます。 +- `context_override`: 状態の読み込み時に、シリアライズ済みのコンテキストを置き換えます。元のコンテキストオブジェクトを復元したくない場合に便利ですが、すでにシリアライズ済みのペイロードからそのコンテキストが削除されるわけではありません。 +- `include_tracing_api_key=True`: 再開した処理でも同じ認証情報でトレースをエクスポートし続ける必要がある場合、シリアライズ済みのトレースペイロードにトレーシング API キーを含めます。 -シリアル化された実行状態には、アプリのコンテキストに加え、承認、使用量、シリアル化された `tool_input`、ネストされたエージェントをツールとして使用する実行の再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK が管理するランタイムメタデータが含まれます。シリアル化された状態を保存または送信する場合、`RunContextWrapper.context` を永続化データとして扱い、意図的に状態とともに移動させる場合を除き、そこにシークレットを格納しないでください。 +シリアライズ済みの実行状態には、アプリのコンテキストに加えて、承認、使用量、シリアライズ済みの `tool_input`、ネストされたツールとしてのエージェントの再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK が管理するランタイムメタデータが含まれます。シリアライズ済みの状態を保存または送信する場合は、`RunContextWrapper.context` を永続化データとして扱い、意図的に状態とともに移動させる場合を除き、そこにシークレットを格納しないでください。 -## 保留タスクのバージョニング +## 保留中タスクのバージョニング -承認が長期間保留される可能性がある場合は、エージェント定義または SDK のバージョンマーカーをシリアル化された状態とともに保存します。これにより、デシリアル化を対応するコードパスに振り分け、モデル、プロンプト、ツール定義が変更された際の非互換性を回避できます。 \ No newline at end of file +承認が長期間保留される可能性がある場合は、シリアライズ済みの状態とともに、エージェント定義または SDK のバージョンマーカーを保存します。これにより、モデル、プロンプト、またはツール定義が変更された場合でも、対応するコードパスにデシリアライズを振り分け、非互換性を回避できます。 \ No newline at end of file diff --git a/docs/ja/mcp.md b/docs/ja/mcp.md index d8a228a669..e844fd485e 100644 --- a/docs/ja/mcp.md +++ b/docs/ja/mcp.md @@ -4,31 +4,59 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)は、アプリケーションがツールとコンテキストを言語モデルに公開する方法を標準化します。公式ドキュメントからの引用です。 +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)は、アプリケーションがツールやコンテキストを言語モデルに公開する方法を標準化します。公式ドキュメントでは、次のように説明されています。 > MCP は、アプリケーションが LLM にコンテキストを提供する方法を標準化するオープンプロトコルです。MCP は、AI -> アプリケーションにおける USB-C ポートのようなものだと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続するための標準化された方法を提供するのと同様に、MCP -> は AI モデルをさまざまなデータソースやツールに接続するための標準化された方法を提供します。 +> アプリケーション向けの USB-C ポートのようなものだと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続する標準化された方法を提供するのと同様に、MCP +> は AI モデルをさまざまなデータソースやツールに接続する標準化された方法を提供します。 -Agents Python SDK は複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開する独自のサーバーを構築したりできます。 +Agents Python SDK は、複数の MCP トランスポートを認識します。これにより、既存の MCP サーバーを再利用したり、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開する独自の MCP サーバーを構築したりできます。 !!! warning "接続前の MCP サーバーの信頼性確認" - MCP ツールはモデルコンテキストのデータを公開し、指定された認証情報を使用して操作を実行できます。信頼できるサーバーにのみ接続し、最小権限の認証情報を使用してください。また、アクセストークンは URL ではなく認可フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)を参照してください。 + MCP ツールは、モデルコンテキストのデータを公開し、提供された認証情報を使用して操作を実行できます。信頼できるサーバーのみに接続し、最小権限の認証情報を使用してください。また、アクセストークンは URL ではなく認証フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)も参照してください。 ## MCP 統合の選択 -MCP サーバーをエージェントに接続する前に、ツール呼び出しを実行する場所と、利用可能なトランスポートを決定します。以下の表は、Python SDK がサポートする選択肢をまとめたものです。 +MCP サーバーをエージェントに接続する前に、ツール呼び出しをどこで実行するか、またどのトランスポートにアクセスできるかを決めます。以下の表は、Python SDK がサポートするオプションをまとめたものです。 | 必要なこと | 推奨オプション | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI の Responses API がモデルに代わって、パブリックにアクセス可能な MCP サーバーを呼び出す| [`HostedMCPTool`][agents.tool.HostedMCPTool] を使用する **ホステッド MCP サーバーツール** | -| ローカルまたはリモートで実行する Streamable HTTP サーバーに接続する | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用する **Streamable HTTP MCP サーバー** | -| Server-Sent Events を使用する HTTP を実装したサーバーと通信する | [`MCPServerSse`][agents.mcp.server.MCPServerSse] を使用する **SSE 対応 HTTP MCP サーバー** | -| ローカルプロセスを起動し、stdin/stdout を介して通信する | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用する **stdio MCP サーバー** | +| OpenAI の Responses API が、モデルに代わって一般公開された MCP サーバーを呼び出す | [`HostedMCPTool`][agents.tool.HostedMCPTool] による **ホスト型 MCP サーバーツール** | +| ローカルまたはリモートで実行する Streamable HTTP サーバーに接続する | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] による **Streamable HTTP MCP サーバー** | +| Server-Sent Events を使用する HTTP を実装したサーバーと通信する | [`MCPServerSse`][agents.mcp.server.MCPServerSse] による **SSE 対応 HTTP MCP サーバー** | +| ローカルプロセスを起動し、stdin/stdout 経由で通信する | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] による **stdio MCP サーバー** | 以下のセクションでは、各オプション、その設定方法、および各トランスポートを選択すべき状況について説明します。 +## MCP Python SDK v1 と v2 + +Agents SDK は、依存関係の範囲 `mcp>=1.19.0,<3` を通じて、`mcp` Python パッケージの両方のメジャーバージョンをサポートします。インストールされている `mcp` パッケージのバージョンは、サーバーとの間でネゴシエートされる MCP プロトコルバージョンとは別です。Agents SDK は、インストールされているパッケージのメジャーバージョンを検出し、stdio、SSE、および Streamable HTTP 接続を自動的に調整するため、通常のサーバー設定ではバージョンを切り替える必要はありません。 + +MCP Python SDK v2 がインストールされている場合、Agents SDK は設定されたローカルトランスポートを `mode="auto"` でラップして、v2 の `mcp.Client` を作成します。クライアントはまず、インストールされている MCP SDK がサポートする最新のプロトコルバージョンで `server/discover` プローブを送信します。最新のサーバーはこのプローブに応答し、クライアントはその結果を採用します。古いサーバーが `server/discover` をサポートしていない場合、クライアントは従来の `initialize` ハンドシェイクにフォールバックし、そこでネゴシエートされたプロトコルバージョンを使用します。したがって、MCP Python SDK v2 をインストールしても、すべての接続で最新の MCP プロトコルバージョンが強制的に使用されるわけではありません。MCP Python SDK の[プロトコルバージョンネゴシエーションガイド](https://py.sdk.modelcontextprotocol.io/protocol-versions/)を参照してください。 + +ほとんどのアプリケーションでは、依存関係リゾルバーに互換性のあるバージョンを選択させることを推奨します。アプリケーションを特定のメジャーバージョンに固定する必要がある場合は、`openai-agents` とともに明示的な制約を追加します。 + +```bash +# MCP Python SDK v1 +pip install "mcp>=1.19.0,<2" + +# MCP Python SDK v2 +pip install "mcp>=2,<3" +``` + +HTTP トランスポートのカスタマイズでは、インストールされている MCP パッケージが所有する HTTP スタックを使用する必要があります。 + +| カスタマイズ | MCP Python SDK v1 | MCP Python SDK v2 | +| --- | --- | --- | +| `params["auth"]` | `httpx.Auth` | `httpx2.Auth` | +| `params["httpx_client_factory"]` の戻り値 | `httpx.AsyncClient` | `httpx2.AsyncClient` | +| `MCPServerStreamableHttp` `params["ignore_initialized_notification_failure"] = True` | サポート対象 | サポート対象外。接続前に拒否されます | + +可能な場合は、以下の Streamable HTTP の例に示すように、`Authorization` ヘッダーを使用してください。`Authorization` ヘッダーは、どちらのパッケージバージョンでも変更せずに使用できます。アプリケーションが `params["auth"]` または `params["httpx_client_factory"]` を指定する場合、それらの値には、インストールされている `mcp` パッケージのメジャーバージョンに対応する HTTP 型を使用する必要があります。アプリケーションが `MCPServerStreamableHttp` の `params["ignore_initialized_notification_failure"] = True` を設定する場合、アップグレード前に `mcp<2` を維持するか、そのオプションを無効にする必要があります。 + +これらのローカルな `mcp` の依存関係要件は、リモート MCP 接続を OpenAI Responses API が管理するため、[`HostedMCPTool`][agents.tool.HostedMCPTool] には適用されません。 + ## エージェントレベルの MCP 設定 トランスポートの選択に加えて、`Agent.mcp_config` を設定することで、MCP ツールの準備方法を調整できます。 @@ -51,33 +79,33 @@ agent = Agent( ) ``` -注記: +注記: - `convert_schemas_to_strict` はベストエフォートです。スキーマを変換できない場合は、元のスキーマが使用されます。 - `failure_error_function` は、MCP ツール呼び出しの失敗をモデルにどのように提示するかを制御します。 -- `failure_error_function` が未設定の場合、SDK はデフォルトのツールエラーフォーマッターを使用します。 +- `failure_error_function` が設定されていない場合、SDK はデフォルトのツールエラーフォーマッターを使用します。 - サーバーレベルの `failure_error_function` は、そのサーバーについて `Agent.mcp_config["failure_error_function"]` を上書きします。 -- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバープレフィックス付きの名前でモデルに公開されます。これにより、複数の MCP サーバーが同じ名前のツールを公開する場合の衝突を回避しやすくなります。生成される名前は ASCII で安全に使用でき、`FunctionTool` インスタンスの名前の長さ制限内に収まり、ローカルの `FunctionTool` インスタンスに設定された名前や、同じエージェントで有効になっているハンドオフとは衝突しません。SDK は引き続き、元のサーバー上で元の MCP ツール名を呼び出します。 +- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバープレフィックス付きの名前でモデルに公開されます。これは、複数の MCP サーバーが同名のツールを公開する場合の衝突回避に役立ちます。生成される名前は ASCII セーフで、`FunctionTool` インスタンスの名前の長さ制限内に収まり、同じエージェントに設定されたローカル `FunctionTool` インスタンスの名前や、有効なハンドオフの名前とは衝突しません。SDK は引き続き、元のサーバー上で元の MCP ツール名を使用して呼び出します。 -## トランスポート間で共通するパターン +## トランスポート間の共通パターン -トランスポートを選択した後、ほとんどの統合では次の事項も決定する必要があります。 +トランスポートを選択した後、ほとんどの統合では、次の事項について判断する必要があります。 - ツールの一部のみを公開する方法([ツールフィルタリング](#tool-filtering))。 - サーバーが再利用可能なプロンプトも提供するかどうか([プロンプト](#prompts))。 - `list_tools()` をキャッシュするかどうか([キャッシュ](#caching))。 -- MCP のアクティビティをトレースにどのように表示するか([トレーシング](#tracing))。 +- MCP アクティビティがトレースにどのように表示されるか([トレーシング](#tracing))。 -ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通する概念です。Streamable HTTP のセクションでは最も完全な例を示しています。同じパターンは、ほかのローカルトランスポートにも適用できます。 +ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP のセクションでは最も完全なコード例を示しており、同じパターンを他のローカルトランスポートにも適用できます。 -## 1. ホステッド MCP サーバーツール +## 1. ホスト型 MCP サーバーツール -ホステッドツールでは、ツールとの一連のやり取り全体が OpenAI のインフラストラクチャ内で実行されます。コードでツールを一覧取得して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベルとオプションのコネクターメタデータを Responses API に転送します。モデルはリモートサーバーのツールを一覧取得し、Python プロセスへの追加のコールバックなしで呼び出します。現在、ホステッドツールは、Responses API のホステッド MCP 統合をサポートする OpenAI モデルで使用できます。 +ホスト型ツールでは、ツールのラウンドトリップ全体が OpenAI のインフラストラクチャ内で実行されます。コード側でツールを一覧表示して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベルと、必要に応じてコネクターのメタデータを Responses API に転送します。モデルは、Python プロセスへの追加のコールバックを行わずに、リモートサーバーのツールを一覧表示して呼び出します。現在、ホスト型ツールは、Responses API のホスト型 MCP 統合をサポートする OpenAI モデルで動作します。 -### 基本的なホステッド MCP ツール +### 基本的なホスト型 MCP ツール -エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加して、ホステッドツールを作成します。`tool_config` -辞書は、REST API に送信する JSON と同じ構造です。 +エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加して、ホスト型ツールを作成します。`tool_config` +の辞書は、REST API に送信する JSON と同じ構造です。 ```python import asyncio @@ -109,13 +137,14 @@ async def main() -> None: asyncio.run(main()) ``` -ホステッドサーバーはツールを自動的に公開するため、`mcp_servers` に追加する必要はありません。 +ホスト型サーバーは、そのツールを自動的に公開します。`mcp_servers` に追加する必要はありません。 -ホステッドツール検索でホステッド MCP サーバーを遅延読み込みする場合は、`tool_config["defer_loading"] = True` を設定し、[`ToolSearchTool`][agents.tool.ToolSearchTool] をエージェントに追加します。これは OpenAI Responses モデルでのみサポートされます。ツール検索の完全な設定と制約については、[ツール](tools.md#hosted-tool-search)を参照してください。 +ホスト型ツール検索によってホスト型 MCP サーバーを遅延読み込みする場合は、`tool_config["defer_loading"] = True` を設定し、[`ToolSearchTool`][agents.tool.ToolSearchTool] をエージェントに追加します。これは OpenAI Responses モデルでのみサポートされます。ツール検索の完全な設定と制約については、[ツール](tools.md#hosted-tool-search)を参照してください。 -### ホステッド MCP 結果のストリーミング +### ホスト型 MCP の実行結果のストリーミング -ホステッドツールは、関数ツールとまったく同じ方法で実行結果のストリーミングをサポートします。モデルの処理中に増分 MCP 出力を受け取るには、`Runner.run_streamed` を使用します。 +ホスト型ツールでは、関数ツールとまったく同じ方法で実行結果のストリーミングがサポートされます。モデルが処理中の間に、`Runner.run_streamed` を使用して +MCP の増分出力を受け取ります。 ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -127,7 +156,7 @@ print(result.final_output) ### オプションの承認フロー -サーバーが機密性の高い操作を実行できる場合は、各ツールの実行前に人間またはプログラムによる承認を必須にできます。`tool_config` 内の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名をポリシーにマッピングする辞書を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 +サーバーが機密性の高い操作を実行できる場合、各ツールの実行前に人間またはプログラムによる承認を必須にできます。`tool_config` 内の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名をポリシーにマッピングする辞書を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -157,9 +186,9 @@ agent = Agent( コールバックは同期または非同期にでき、モデルが実行を継続するために承認データを必要とするたびに呼び出されます。 -### コネクターを基盤とするホステッドサーバー +### コネクターを基盤とするホスト型サーバー -ホステッド MCP は OpenAI コネクターにも対応しています。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホステッドサーバーがコネクターのツールを公開します。 +ホスト型 MCP は OpenAI コネクターもサポートします。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 ```python import os @@ -175,11 +204,11 @@ HostedMCPTool( ) ``` -ストリーミング、承認、コネクターを含む完全に動作するホステッドツールのサンプルは、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)にあります。 +ストリーミング、承認、コネクターを含む、完全に動作するホスト型ツールのサンプルは、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)にあります。 ## 2. Streamable HTTP MCP サーバー -ネットワーク接続を自分で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。トランスポートを管理する場合や、低レイテンシーを維持しながら独自のインフラストラクチャ内でサーバーを実行する場合には、Streamable HTTP サーバーが最適です。 +ネットワーク接続を自身で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを制御する場合や、低レイテンシーを維持しながら自身のインフラストラクチャ内でサーバーを実行する場合に最適です。 ```python import asyncio @@ -214,26 +243,26 @@ async def main() -> None: asyncio.run(main()) ``` -コンストラクターでは、次の追加オプションを使用できます。 +コンストラクターは、次の追加オプションを受け入れます。 -- `client_session_timeout_seconds` は MCP ClientSession の読み取りタイムアウトを制御します。`datetime.timedelta` で表現できる 1 マイクロ秒以上の正の有限値を指定すると、有限のタイムアウトが設定されます。`None` と `0` を指定すると無効になります。それ以外の値は、サーバーの構築時に拒否されます。 +- `client_session_timeout_seconds` は、MCP ClientSession の読み取りタイムアウトを制御します。`datetime.timedelta` で表現できる正の有限値で、かつ 1 マイクロ秒以上の値を指定すると、有限のタイムアウトが設定されます。`None` と `0` を指定すると無効になります。それ以外の値は、サーバーの構築時に拒否されます。 - `use_structured_content` は、テキスト出力より `tool_result.structured_content` を優先するかどうかを切り替えます。 -- `max_retry_attempts` と `retry_backoff_seconds_base` は、`list_tools()` と `call_tool()` に自動再試行を追加します。 +- `max_retry_attempts` と `retry_backoff_seconds_base` は、`list_tools()` と `call_tool()` に対する自動再試行を追加します。 - `tool_filter` を使用すると、ツールの一部のみを公開できます([ツールフィルタリング](#tool-filtering)を参照)。 -- `require_approval` は、ローカル MCP ツールに対する人間参加型の承認ポリシーを有効にします。 +- `require_approval` は、ローカル MCP ツールで Human-in-the-loop の承認ポリシーを有効にします。 - `failure_error_function` は、モデルに表示される MCP ツールの失敗メッセージをカスタマイズします。代わりにエラーを発生させるには、`None` に設定します。 - `tool_meta_resolver` は、`call_tool()` の前に、呼び出しごとの MCP `_meta` ペイロードを挿入します。 ### ローカル MCP サーバーの承認ポリシー -`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` は、いずれも `require_approval` を受け取ります。 +`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` は、いずれも `require_approval` を受け入れます。 -サポートされる形式: +サポートされる形式: - すべてのツールに対する `"always"` または `"never"`。 -- `True` はすべてのツールに承認を必須とし、`False` はどのツールにも承認を必須としません(それぞれ `"always"` および `"never"` と同等です)。 -- ツールごとのマップ。例: `{"delete_file": "always", "read_file": "never"}`。 -- グループ化されたオブジェクト: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 +- `True` ではすべてのツールに承認が必要で、`False` ではどのツールにも承認は不要です(それぞれ `"always"` および `"never"` と同等です)。 +- ツールごとのマップ。例:`{"delete_file": "always", "read_file": "never"}`。 +- グループ化されたオブジェクト:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 ```python async with MCPServerStreamableHttp( @@ -244,7 +273,7 @@ async with MCPServerStreamableHttp( ... ``` -完全な一時停止と再開のフローについては、[人間参加型処理](human_in_the_loop.md)と `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 +一時停止と再開を含む完全なフローについては、[Human-in-the-loop](human_in_the_loop.md)および `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 ### `tool_meta_resolver` による呼び出しごとのメタデータ @@ -271,17 +300,17 @@ server = MCPServerStreamableHttp( 実行コンテキストが Pydantic モデル、dataclass、またはカスタムクラスの場合は、属性アクセスを使用してテナント ID を読み取ります。 -### MCP ツールの出力: テキストと画像 +### MCP ツールの出力:テキスト、画像、その他のコンテンツ -MCP ツールが画像コンテンツを返すと、SDK は自動的にツール出力内の画像タイプのエントリーへマッピングします。テキストと画像が混在するレスポンスは出力項目のリストとして転送されるため、エージェントは通常の関数ツールからの画像出力と同じ方法で MCP の画像結果を利用できます。 +MCP の実行結果でコンテンツブロックが使用されている場合、SDK はテキストコンテンツをテキスト出力として転送し、画像コンテンツをツール出力内の画像型エントリーにマッピングします。音声ブロックやリソースブロックを含むその他の MCP コンテンツブロック型については、SDK は、そのブロックを有効な JSON としてシリアライズした値を持つテキスト出力を転送します。複数のコンテンツブロックを含むレスポンスは、出力項目のリストとして転送されます。`use_structured_content=True` が、空でなくエラーでもない `structuredContent` ペイロードを選択した場合、その構造化ペイロードがこれらのコンテンツブロックより優先されます。構造化コンテンツが存在しないか空の場合は、コンテンツブロックにフォールバックします。 ## 3. SSE 対応 HTTP MCP サーバー !!! warning - MCP プロジェクトでは Server-Sent Events トランスポートが非推奨になっています。新しい統合には Streamable HTTP または stdio を使用し、SSE はレガシーサーバーでのみ使用してください。 + MCP プロジェクトでは、Server-Sent Events トランスポートは非推奨になっています。新しい統合では Streamable HTTP または stdio を優先し、SSE は従来のサーバーにのみ使用してください。 -MCP サーバーが SSE 対応 HTTP トランスポートを実装している場合は、[`MCPServerSse`][agents.mcp.server.MCPServerSse] をインスタンス化します。トランスポートを除けば、API は Streamable HTTP サーバーと同一です。 +MCP サーバーが SSE 対応 HTTP トランスポートを実装している場合は、[`MCPServerSse`][agents.mcp.server.MCPServerSse] をインスタンス化します。トランスポートを除き、API は Streamable HTTP サーバーと同一です。 ```python @@ -310,7 +339,7 @@ async with MCPServerSse( ## 4. stdio MCP サーバー -ローカルのサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを生成し、パイプを開いたまま維持し、コンテキストマネージャーの終了時に自動的に閉じます。このオプションは、簡単な概念実証を行う場合や、サーバーがコマンドラインのエントリーポイントのみを公開する場合に便利です。 +ローカルサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを生成し、パイプを開いたまま維持し、コンテキストマネージャーの終了時に自動的に閉じます。このオプションは、簡単な概念実証や、サーバーがコマンドラインのエントリーポイントのみを公開する場合に役立ちます。 ```python from pathlib import Path @@ -338,7 +367,7 @@ async with MCPServerStdio( ## 5. MCP サーバーマネージャー -複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、正常に接続できたサーバーのみをエージェントに公開します。コンストラクターのオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 +複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、正常に接続されたサーバーのみをエージェントに公開します。コンストラクターのオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 ```python from agents import Agent, Runner @@ -359,21 +388,22 @@ async with MCPServerManager(servers) as manager: print(result.final_output) ``` -主な動作: +主な動作: - `drop_failed_servers=True` の場合(デフォルト)、`active_servers` には正常に接続されたサーバーのみが含まれます。 -- 失敗は `failed_servers` と `errors` に記録されます。 +- 失敗は `failed_servers` と `errors` で追跡されます。 - 最初の接続失敗時に例外を発生させるには、`strict=True` を設定します。 - 失敗したサーバーを再試行するには `reconnect(failed_only=True)` を、すべてのサーバーを再起動するには `reconnect(failed_only=False)` を呼び出します。 -- ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。ライフサイクルのタイムアウトには、正の有限秒数を指定できます。無効にするには `None` を指定します。値は構築時と代入時の両方で検証されます。ゼロは即時の期限を設定することになるため、拒否されます。 +- `connect_all()`、`reconnect()`、`cleanup_all()` の呼び出しは直列化されます。あるライフサイクル操作がすでに実行中の場合、別のライフサイクル操作は、同じサーバーへの接続やクリーンアップを同時に行わず、その操作が完了するまで待機します。 +- ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。どちらのライフサイクルタイムアウトもデフォルトは 10 秒です。正の有限秒、または無効にするための `None` を受け入れ、構築時と代入時の両方で検証されます。即時の期限が設定されてしまうため、0 は拒否されます。 -## 共通のサーバー機能 +## サーバーに共通する機能 -以下のセクションは、MCP サーバーの各トランスポートに共通して適用されますが、具体的な API はサーバークラスによって異なります。 +以下のセクションは、MCP サーバーの各トランスポートに共通して適用されます(具体的な API サーフェスはサーバークラスによって異なります)。 ## ツールフィルタリング -各 MCP サーバーはツールフィルターをサポートしているため、エージェントが必要とする関数のみを公開できます。フィルタリングは、構築時または実行ごとに動的に行えます。 +各 MCP サーバーはツールフィルターをサポートしているため、エージェントが必要とする関数のみを公開できます。フィルタリングは、構築時に静的に行うことも、実行ごとに動的に行うこともできます。 ### 静的ツールフィルタリング @@ -395,11 +425,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK は最初に許可リストを適用し、その後、残った集合からブロック対象のツールを削除します。 +`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK は最初に許可リストを適用し、その後、残ったツールからブロック対象のツールを削除します。 ### 動的ツールフィルタリング -より複雑なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る callable を渡します。callable は同期または非同期にでき、ツールを公開する場合は `True` を返します。 +より複雑なロジックでは、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る callable を渡します。callable は同期または非同期にでき、ツールを公開する場合は `True` を返します。 ```python from pathlib import Path @@ -427,7 +457,8 @@ async with MCPServerStdio( ## プロンプト -MCP サーバーは、エージェントの instructions を動的に生成するプロンプトも提供できます。プロンプトをサポートするサーバーは、次の 2 つのメソッドを公開します。 +MCP サーバーは、エージェントへの指示を動的に生成するプロンプトも提供できます。プロンプトをサポートするサーバーは、次の 2 つの +メソッドを公開します。 - `list_prompts()` は、利用可能なプロンプトテンプレートを列挙します。 - `get_prompt(name, arguments)` は、必要に応じてパラメーターを指定して、具体的なプロンプトを取得します。 @@ -450,25 +481,25 @@ agent = Agent( ## ページネーション -組み込みのローカル MCP サーバークラスは、ツールとプロンプトを一覧取得する際に `nextCursor` を自動的にたどります。`list_tools()` は、フィルターの適用またはキャッシュへの格納前にツールの完全なリストを収集し、`list_prompts()` は `nextCursor=None` を含む 1 つの統合された実行結果を返します。後続ページの取得に失敗した場合やサーバーがカーソルを繰り返した場合は、部分的な実行結果を公開またはキャッシュせず、エラーを発生させます。 +組み込みのローカル MCP サーバークラスは、ツールとプロンプトを一覧表示する際に `nextCursor` を自動的にたどります。`list_tools()` は、フィルターの適用またはキャッシュへの格納前に完全なツール一覧を収集し、`list_prompts()` は `nextCursor=None` を含む 1 つの統合された実行結果を返します。後続のページが失敗した場合や、サーバーが同じカーソルを繰り返した場合、部分的な実行結果を公開またはキャッシュする代わりに、操作はエラーを発生させます。 -リソースは引き続き明示的にページ分割されます。次のページを取得するには、`list_resources()` または `list_resource_templates()` の `nextCursor` を、`cursor` 引数として渡します。 +リソースは引き続き明示的にページ分割されます。次のページを取得するには、`list_resources()` または `list_resource_templates()` から取得した `nextCursor` を、`cursor` 引数として再度渡します。 ## キャッシュ -各エージェント実行では、それぞれの MCP サーバーで `list_tools()` が呼び出されます。リモートサーバーでは無視できないレイテンシーが発生する可能性があるため、すべての MCP サーバークラスで `cache_tools_list` オプションが公開されています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新のリストを強制的に取得するには、サーバーインスタンスで `invalidate_tools_cache()` を呼び出します。 +エージェントを実行するたびに、各 MCP サーバー上で `list_tools()` が呼び出されます。リモートサーバーでは顕著なレイテンシーが生じる可能性があるため、すべての MCP サーバークラスは `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新の一覧を強制的に取得するには、サーバーインスタンス上で `invalidate_tools_cache()` を呼び出します。 ## トレーシング -[トレーシング](./tracing.md)では、以下を含む MCP のアクティビティが自動的に記録されます。 +[トレーシング](./tracing.md)では、次の項目を含む MCP アクティビティが自動的に記録されます。 -1. ツールを一覧取得するための MCP サーバーへの呼び出し。 +1. ツールを一覧表示するための MCP サーバーへの呼び出し。 2. ツール呼び出しに関する MCP 関連情報。 ![MCP トレーシングのスクリーンショット](../assets/images/mcp-tracing.jpg) -## 関連情報 +## 関連資料 -- [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様と設計ガイド。 +- [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様および設計ガイド。 - [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 実行可能な stdio、SSE、Streamable HTTP のサンプル。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認とコネクターを含む、ホステッド MCP の完全なデモ。 \ No newline at end of file +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 承認やコネクターを含む、ホスト型 MCP の完全なデモ。 \ No newline at end of file diff --git a/docs/ja/models/index.md b/docs/ja/models/index.md index e3ad1325c0..96c3892ab0 100644 --- a/docs/ja/models/index.md +++ b/docs/ja/models/index.md @@ -4,43 +4,43 @@ search: --- # モデル -Agents SDKには、すぐに使用できるOpenAIモデルのサポートが、次の 2 種類用意されています。 +Agents SDK は、すぐに利用できる OpenAI モデルを次の 2 種類の形式でサポートしています。 -- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用してOpenAI APIを呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 -- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用してOpenAI APIを呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 +- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 +- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 ## モデル設定の選択 -設定に適した最もシンプルな方法から始めてください。 +まず、設定に合う最もシンプルな方法を選択してください。 | 目的 | 推奨される方法 | 詳細 | | --- | --- | --- | -| OpenAIモデルのみを使用する | デフォルトのOpenAIプロバイダーと Responses モデルパスを使用する | [OpenAIモデル](#openai-models) | -| WebSocket トランスポート経由でOpenAI Responses APIを使用する | Responses モデルパスを維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | -| OpenAIがホストするサブエージェントを使用する | 実験的なホステッドマルチエージェントモデルを使用する | [ホステッドマルチエージェント](#hosted-multi-agent-experimental) | -| OpenAI以外のプロバイダーを 1 つ使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI以外のモデル](#non-openai-models) | -| エージェント間でモデルやプロバイダーを組み合わせる | 実行ごと、またはエージェントごとにプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[プロバイダーをまたいだモデルの組み合わせ](#mixing-models-across-providers) | -| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses パスで `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | -| OpenAI以外のプロバイダーまたは複数プロバイダーのルーティングにサードパーティアダプターを使用する | サポートされているベータ版アダプターを比較し、リリース予定のプロバイダーパスを検証する | [サードパーティアダプター](#third-party-adapters) | +| OpenAI モデルのみを使用する | デフォルトの OpenAI プロバイダーで Responses モデルのパスを使用する | [OpenAI モデル](#openai-models) | +| WebSocket トランスポート経由で OpenAI Responses API を使用する | Responses モデルのパスを維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | +| OpenAI がホストするサブエージェントを使用する | 実験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | +| OpenAI 以外の単一プロバイダーを使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI 以外のモデル](#non-openai-models) | +| エージェント間でモデルまたはプロバイダーを組み合わせる | 実行ごとまたはエージェントごとにプロバイダーを選択し、機能の違いを確認する | [単一ワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[複数プロバイダー間でのモデルの組み合わせ](#mixing-models-across-providers) | +| OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses のパスで `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | +| OpenAI 以外または複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポートされているベータ版アダプターを比較し、リリース予定のプロバイダーパスを検証する | [サードパーティ製アダプター](#third-party-adapters) | -## OpenAIモデル +## OpenAI モデル -OpenAIのみを使用するほとんどのアプリでは、デフォルトのOpenAIプロバイダーで文字列のモデル名を使用し、Responses モデルパスを維持する方法を推奨します。 +OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルのパスを維持することを推奨します。 -`Agent` の初期化時にモデルを指定しない場合は、デフォルトモデルが使用されます。現在のデフォルトは、低レイテンシーのエージェントワークフロー向けに `reasoning.effort="none"` と `verbosity="low"` を指定した [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini) です。アクセス権がある場合は、明示的な `model_settings` を維持しつつ、品質を高めるためにエージェントを `gpt-5.6-sol` に設定することを推奨します。 +[`Agent`][agents.agent.Agent] でモデルを指定しない場合、Agents SDK はコスト重視で大量処理を行うエージェントワークフロー向けに、デフォルトで `reasoning.effort="none"` および `verbosity="low"` とともに [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna) を使用します。最先端の能力が必要なアプリケーションでは、`model="gpt-5.6-sol"` を明示的に設定し、ワークロードに適した `model_settings` を選択できます。 -`gpt-5.6-sol` などの別のモデルへ切り替える場合、エージェントを設定する方法は 2 つあります。 +`gpt-5.6-sol` などの別のモデルに切り替える場合、エージェントを設定する方法は 2 つあります。 ### デフォルトモデル -まず、カスタムモデルが設定されていないすべてのエージェントで特定のモデルを一貫して使用するには、エージェントを実行する前に `OPENAI_DEFAULT_MODEL` 環境変数を設定します。 +まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用する場合は、エージェントを実行する前に `OPENAI_DEFAULT_MODEL` 環境変数を設定します。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -次に、`RunConfig` を使用して実行のデフォルトモデルを設定できます。エージェントにモデルを設定しない場合、この実行のモデルが使用されます。 +次に、`RunConfig` を使用して、実行のデフォルトモデルを設定できます。エージェントにモデルを設定しなかった場合、この実行のモデルが使用されます。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 モデル -この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。これは、ほとんどのユースケースで最適に機能する設定です。デフォルトモデルの推論労力を調整するには、独自の `ModelSettings` を渡します。 +この方法で `gpt-5.6-sol` などの任意の GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。これには、ほとんどのユースケースに最適な設定が指定されています。デフォルトモデルの推論量を調整するには、独自の `ModelSettings` を渡します。 ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -レイテンシーを低くするには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 +レイテンシーを低減するには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 -GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、会話ターン間で引き継がれる推論コンテキスト、および `"max"` の労力レベルもサポートします。これらの制御は Responses API パスで利用できます。 +GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、会話ターン間で引き継がれる推論コンテキスト、および `"max"` の effort レベルもサポートします。これらの制御は Responses API のパスで使用できます。 ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode` と `reasoning.context` は、Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされる労力レベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` 労力には Responses APIを使用してください。Chat Completions アダプターは、警告を表示してモードとコンテキストを無視します。その警告をエラーに変えるには、OpenAIプロバイダーで `strict_feature_validation=True` を設定してください。 +`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions は `reasoning.effort` のみを使用し、サポートされる effort レベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` effort には Responses API を使用してください。Chat Completions アダプターは警告を表示して mode と context を無視します。この警告をエラーに変更するには、OpenAI プロバイダーで `strict_feature_validation=True` を設定します。 -`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の Responses API 会話、または前回の推論項目を次のリクエストに含めることで会話を維持してください。ステートレスな `store=False` 呼び出しでは、レスポンス内の `reasoning.encrypted_content` をリクエストし、それらの推論項目を次のリクエストの入力に含めてください。 +`context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の Responses API 会話、または次のリクエストに以前の推論項目を含めることで、会話を維持してください。ステートレスな `store=False` 呼び出しでは、レスポンスで `reasoning.encrypted_content` をリクエストし、その推論項目を次のリクエストの入力に含めます。 #### ComputerTool のモデル選択 -エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれている場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは、GA の組み込み `computer` ツールが使用されます。一方、明示的な `computer-use-preview` リクエストでは、従来の `computer_use_preview` ペイロードが維持されます。 +エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストにおける有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは、GA の組み込み `computer` ツールが使用されます。一方、明示的な `computer-use-preview` リクエストでは、従来の `computer_use_preview` ペイロードが維持されます。 -主な例外は、プロンプトによって管理される呼び出しです。プロンプトテンプレートでモデルを指定し、SDK がリクエストから `model` を省略する場合、プロンプトに固定されているモデルを推測しないように、SDK はプレビュー互換のコンピューターペイロードをデフォルトで使用します。このフローで GA パスを維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制してください。 +主な例外は、プロンプトで管理される呼び出しです。プロンプトテンプレートでモデルを指定し、SDK がリクエストから `model` を省略する場合、プロンプトでどのモデルが固定されているかを推測しないよう、SDK はプレビュー互換のコンピューターペイロードをデフォルトで使用します。このフローで GA のパスを維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制します。 -[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルと一致する組み込みセレクターに正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターに正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 -プレビュー互換のリクエストでは、`environment` とディスプレイ寸法を事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエストを送信する前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 +プレビュー互換のリクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用する、プロンプトで管理されたフローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエストを送信する前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 #### GPT-5 以外のモデル -カスタム `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はどのモデルとも互換性のある汎用の `ModelSettings` に戻します。 +カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK は任意のモデルと互換性のある汎用の `ModelSettings` に戻ります。 ### Responses 専用のツール機能 -次のツール機能は、OpenAI Responses モデルでのみサポートされています。 +以下のツール機能は、OpenAI Responses モデルでのみサポートされます。 - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` および、遅延読み込みに対応するその他の Responses ツールサーフェス +- `@function_tool(defer_loading=True)` およびその他の遅延読み込み対応の Responses ツールサーフェス - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`、および `tool_choice="programmatic_tool_calling"` -これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、ネームスペース名のみ、または遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませてください。設定の詳細と現在の制約については、[ホステッドツール検索](../tools.md#hosted-tool-search)および[プログラマティックツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 +これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、単独の名前空間名や遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 ### Responses WebSocket トランスポート -デフォルトでは、OpenAI Responses APIリクエストは HTTP トランスポートを使用します。OpenAI Responses プロバイダーパスを使用する場合、WebSocket トランスポートをオプトインで有効にできます。 +デフォルトでは、OpenAI Responses API のリクエストは HTTP トランスポートを使用します。OpenAI Responses プロバイダーのパスを使用する場合は、WebSocket トランスポートを明示的に有効にできます。 #### 基本設定 @@ -137,11 +137,11 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトのOpenAIプロバイダーがモデル名を解決した結果として得られるOpenAI Responses モデルに影響します。これには、`"gpt-5.6-sol"` などの文字列モデル名も含まれます。 +これは、デフォルトの OpenAI プロバイダーがモデル名を解決した結果となる OpenAI Responses モデルに影響します。これには、`"gpt-5.6-sol"` などの文字列のモデル名も含まれます。 -トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡す場合、グローバルデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 +トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡した場合は、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 -#### プロバイダー単位または実行単位の設定 +#### プロバイダーまたは実行レベルの設定 プロバイダーごと、または実行ごとに WebSocket トランスポートを設定することもできます。 @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -SDK のOpenAI統合を経由するプロバイダーは、オプションのエージェント登録設定も受け入れます。これは、OpenAI設定でハーネス ID などのプロバイダーレベルの登録メタデータが必要となる場合の高度なオプションです。 +SDK の OpenAI 統合を経由してルーティングするプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAI の設定でハーネス ID などのプロバイダーレベルの登録メタデータが必要な場合の高度なオプションです。 ```python from agents import ( @@ -188,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider` による高度なルーティング +#### `MultiProvider` を使用した高度なルーティング -プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定してください。 +プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 -`MultiProvider` は、過去から引き継がれた次の 2 つのデフォルト動作を維持します。 +`MultiProvider` には、従来からのデフォルト動作が 2 つあります。 -- `openai/...` はOpenAIプロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 +- `openai/...` は OpenAI プロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 - 不明なプレフィックスは、そのまま渡されるのではなく `UserError` を発生させます。 -OpenAIプロバイダーを、リテラルなネームスペース付きモデル ID を要求するOpenAI互換エンドポイントへ接続する場合は、パススルー動作を明示的にオプトインしてください。WebSocket を有効にした設定では、`MultiProvider` でも `openai_use_responses_websocket=True` を維持してください。 +OpenAI プロバイダーを、名前空間付きのモデル ID をそのまま要求する OpenAI 互換エンドポイントに接続する場合は、パススルー動作を明示的に有効にします。WebSocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を維持してください。 ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -バックエンドがリテラルな `openai/...` 文字列を要求する場合は、`openai_prefix_mode="model_id"` を使用してください。バックエンドが `openrouter/openai/gpt-4.1-mini` などの他のネームスペース付きモデル ID を要求する場合は、`unknown_prefix_mode="model_id"` を使用してください。これらのオプションは、WebSocket トランスポート外の `MultiProvider` でも機能します。この例で WebSocket を有効なままにしているのは、このセクションで説明しているトランスポート設定の一部であるためです。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 +バックエンドが文字列 `openai/...` をそのまま要求する場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を要求する場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも機能します。この例で WebSocket を有効にしているのは、このセクションで説明しているトランスポート設定の一部だからです。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも使用できます。 -`MultiProvider` を通じてルーティングしながら、同じプロバイダーレベルの登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡してください。これは基盤となるOpenAIプロバイダーへ転送されます。 +`MultiProvider` を介してルーティングする際に、同じプロバイダーレベルの登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡します。これは基盤となる OpenAI プロバイダーへ転送されます。 -カスタムのOpenAI互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 +カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket の `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 #### 注意事項 -- これは WebSocket トランスポート経由の Responses APIであり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions には適用されません。OpenAI以外のプロバイダーには、そのプロバイダーが Responses WebSocket の `/responses` エンドポイントをサポートしている場合にのみ適用されます。 +- これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions には適用されません。OpenAI 以外のプロバイダーには、Responses WebSocket の `/responses` エンドポイントをサポートしている場合にのみ適用されます。 - 環境にまだ存在しない場合は、`websockets` パッケージをインストールしてください。 -- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンにわたり同じ WebSocket 接続を再利用するワークフローでは、ネストされた Agents-as-tools 呼び出しも含め、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 -- 長時間の推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズしてください。遅延した pong フレームを許容するには `ping_timeout` を増やします。ping を有効にしたままハートビートのタイムアウトを無効にするには、`ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性を重視する場合は、HTTP/SSE トランスポートを使用してください。 -- SDK はデフォルトで、受信メッセージサイズの上限を無効にします(`max_size=None`)。プロキシの背後にある長時間稼働するエージェントプロセスや、メモリーが制限されたコンテナでは、メッセージごとのメモリー使用量を制限するために `responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定してください。 -- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。その上限を超えたら新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用してください。 -- サービスは、接続ローカルのメモリーに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、`previous_response_id` が参照するレスポンスがそのメモリーから削除されます。再接続後も、保存済みのレスポンスが利用可能であれば処理を継続できますが、`store=False` と ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` で新しいチェーンを開始して入力コンテキスト全体を送信するか、ローカルで管理するセッション状態からそのコンテキストを再構築してください。 +- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数のターン間で同じ WebSocket 接続を再利用したいマルチターンワークフローでは、ネストされた Agents-as-tools 呼び出しも含め、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 +- 長時間の推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズします。遅延した pong フレームを許容するには `ping_timeout` を増やし、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを優先してください。 +- デフォルトでは、SDK は受信メッセージのサイズ上限を無効にします(`max_size=None`)。プロキシの背後にある長時間稼働のエージェントプロセスや、メモリに制約があるコンテナでは、メッセージごとのメモリ使用量を制限するために `responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定します。 +- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。この上限に達したら、新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用します。 +- サービスは、接続ローカルのメモリに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、`previous_response_id` が参照するレスポンスがそのメモリから削除されます。再接続後も、保存済みのレスポンスが利用可能であれば続行できますが、`store=False` および ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` で新しいチェーンを開始して完全な入力コンテキストを送信するか、ローカルで管理しているセッション状態からそのコンテキストを再構築してください。 -### ホステッドマルチエージェント(実験的) +### ホスト型マルチエージェント(実験的) -OpenAI Responses APIのホステッドマルチエージェントベータでは、GPT-5.6 のルートモデルが、サーバーでホストされるサブエージェントを作成して連携させることができます。Agents SDKは通常の `Runner` を引き続き使用できます。ホステッドオーケストレーションはサービス上に留まり、開発者が定義した関数ツールはアプリケーション内で実行されます。 +OpenAI Responses API のホスト型マルチエージェントベータでは、GPT-5.6 のルートモデルがサーバーでホストされるサブエージェントを作成して連携できます。Agents SDK は通常どおり `Runner` を使用し続けられます。ホスト型オーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 -この統合は実験的であり、ローカル関数の出力を `response.inject` によりアクティブなホステッドエージェントへ返せるように、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開する、バージョン 2.45.0 以降の `openai[realtime]` ビルドが必要です。インターフェースとベータ項目のスキーマは、一般提供までに変更される可能性があります。 +この統合は実験的であり、ローカル関数の出力を `response.inject` を使用してアクティブなホスト型エージェントへ返せるよう、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開する、バージョン 2.45.0 以降の `openai[realtime]` ビルドが必要です。インターフェースとベータ版の項目スキーマは、一般提供までに変更される可能性があります。 #### モデルの設定 @@ -260,13 +260,13 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` を指定しない場合、モデルはデフォルトのOpenAIクライアントを使用します。`max_concurrent_subagents` を省略すると、サービスのデフォルトが使用されます。 +`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` を指定しない限り、このモデルはデフォルトの OpenAI クライアントを使用します。`max_concurrent_subagents` を省略すると、サービスのデフォルトが使用されます。 #### ローカル関数ツール -すべてのホステッドエージェントは、リクエストに設定されたモデルとツールを共有します。Responses APIは、どのホステッドエージェントが関数を呼び出すかを決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスへ挿入します。これにより、サービスは元のホステッド呼び出し元を再開できます。関数の実行には、Runner の通常のガードレール、フック、および失敗時の変換が引き続き適用されます。SDK のツール承認による中断はサポートされていません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 +すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは、Responses API が決定します。通常の SDK Runner が関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスに挿入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、引き続き Runner の通常のガードレール、フック、および失敗時の変換が適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 -呼び出し元を考慮したログ記録や認可がツールに必要な場合は、`get_hosted_agent_metadata()` を使用してください。 +ツールで呼び出し元を考慮したログ記録または認可が必要な場合は、`get_hosted_agent_metadata()` を使用します。 ```python from typing import Any @@ -283,48 +283,48 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -ホステッドエージェント名は観測用のメタデータであり、ローカルルーティングの仕組みではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用のあるツールでは、その呼び出し ID を冪等性キーとして使用し、必要な認可をツール実行前または実行中にアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses APIの境界を越えます。 +ホスト型エージェントの名前は観測用のメタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、ツールの実行前または実行中に、必要な認可をアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 #### 出力とストリーミングの動作 -フェーズが `final_answer` で、`/root` に帰属するメッセージのみが、通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホステッドオーケストレーションの記録を高レベルの `RunResult` から除外します。SDK がそれらの記録をローカル関数として実行することはありません。 +`/root` に帰属し、フェーズが `final_answer` のメッセージだけが、通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がこれらのレコードをローカル関数として実行することはありません。 -raw ストリーミングでは、ホステッド出力項目や `response.inject.created` の確認応答を含む、ベータ版 Responses イベントが引き続き公開されます。アダプターは、関数呼び出しの準備が整うと、アクティブな 1 つのプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。raw のホステッド項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用すると、項目またはツール呼び出しが帰属するホステッドエージェントを識別できます。 +raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含む、ベータ版 Responses イベントが引き続き公開されます。アダプターは、関数呼び出しの準備ができた時点で、1 つのアクティブなプロバイダーレスポンスを SDK から見える論理モデルターンに分割します。その後、Runner が出力を生成すると、同じプロバイダーレスポンスを再開します。項目またはツール呼び出しが帰属するホスト型エージェントを識別するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用します。 #### SDK オーケストレーションとの関係 -ホステッドマルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 +ホスト型マルチエージェントは、SDK のハンドオフや Agents-as-tools とは別の機能です。 -- ホステッドマルチエージェントは、OpenAIサービス上にサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 -- SDK のハンドオフは、アクティブなローカル SDK の `Agent` を変更します。すべてのホステッドエージェントが同じハンドオフツールを受け取り、所有権の競合が生じるため、この実験的モデルを使用している場合は拒否されます。 -- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 +- ホスト型マルチエージェントは、OpenAI サービス上でサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 +- SDK のハンドオフは、アクティブなローカル SDK の `Agent` を変更します。この実験的モデルを使用する場合、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が発生するため、ハンドオフは拒否されます。 +- Agents-as-tools は引き続き使用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 #### 現在の制限事項 -実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされていません。ただし、サービスが各ホステッドエージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 +実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版でサポートされていませんが、サービスが各ホスト型エージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 -1 つの `OpenAIHostedMultiAgentModel` インスタンスが所有できるアクティブなホステッドレスポンスは、一度に最大 1 つです。ローカル関数の出力を待機中に実行が放棄された場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホステッドレスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 +1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に所有できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待っている間に実行を中止した場合は、`await model.close()` を呼び出して WebSocket を解放してください。実行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 -基盤となる Responses APIベータの動作については、[OpenAIマルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 +基盤となる Responses API ベータ版の動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 -## OpenAI以外のモデル +## OpenAI 以外のモデル -OpenAI以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティアダプターを追加しなくてもこれで十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 +OpenAI 以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくても、これで十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 -### OpenAI以外のプロバイダーの統合方法 +### OpenAI 以外のプロバイダーの統合方法 -| 方法 | 使用する状況 | 適用範囲 | +| 方法 | 使用する状況 | スコープ | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つのOpenAI互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行単位 | -| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェント単位 | -| サードパーティアダプター | 組み込みの方法では提供されないプロバイダー対応やルーティングが必要な場合 | [サードパーティアダプター](#third-party-adapters)を参照 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI 互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルなデフォルト | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行ごと | +| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェントごと | +| サードパーティ製アダプター | 組み込みのパスでは提供されないプロバイダー対応範囲またはルーティングが、アダプターによって必要な場合 | [サードパーティ製アダプター](#third-party-adapters)を参照 | -次の組み込み方法で、他の LLM プロバイダーを統合できます。 +次の組み込みの方法で、他の LLM プロバイダーを統合できます。 -1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーにOpenAI互換 API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで使用します。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 -3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスにモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせられます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 +1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーに OpenAI 互換の API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで指定します。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 +3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスでモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせられます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 `platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` を使用してトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - これらのコード例では、多くの LLM プロバイダーがまだ Responses APIをサポートしていないため、Chat Completions APIおよびモデルを使用しています。LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 + これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses API をサポートしている場合は、Responses の使用を推奨します。 -## 1 つのワークフローでのモデルの組み合わせ +## 単一ワークフローでのモデルの組み合わせ -単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小さく高速なモデルを使用し、複雑なタスクには大きく高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定するときは、次のいずれかの方法で特定のモデルを選択できます。 +単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小さく高速なモデルを使用し、複雑なタスクにはより大規模で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定するときは、次のいずれかの方法で特定のモデルを選択できます。 -1. モデル名を渡します。 -2. 任意のモデル名と、その名前を Model インスタンスにマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡します。 -3. [`Model`][agents.models.interface.Model] の実装を直接指定します。 +1. モデル名を渡す。 +2. 任意のモデル名と、その名前を Model インスタンスにマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡す。 +3. [`Model`][agents.models.interface.Model] の実装を直接指定する。 !!! note - SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、両者ではサポートする機能とツールの組み合わせが異なるため、ワークフローごとに単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 + SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、この 2 つの形式でサポートされる機能とツールが異なるため、ワークフローごとに単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用可能であることを確認してください。 ```python import asyncio @@ -391,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAIモデルの名前を直接設定します。 +1. OpenAI モデルの名前を直接設定します。 2. [`Model`][agents.models.interface.Model] の実装を指定します。 -エージェントが使用するモデルをさらに設定する場合は、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。 +エージェントで使用するモデルをさらに設定する場合は、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。 ```python from agents import Agent, ModelSettings @@ -409,22 +409,22 @@ english_agent = Agent( ## OpenAI Responses の高度な設定 -OpenAI Responses パスを使用しており、より細かな制御が必要な場合は、`ModelSettings` から始めてください。 +OpenAI Responses のパスを使用していて、より詳細な制御が必要な場合は、まず `ModelSettings` を使用します。 ### 一般的な高度な `ModelSettings` オプション -OpenAI Responses APIを使用している場合、いくつかのリクエストフィールドには対応する `ModelSettings` フィールドがすでに用意されているため、それらに `extra_args` を使用する必要はありません。 +OpenAI Responses API を使用する場合、いくつかのリクエストフィールドには対応する `ModelSettings` フィールドがすでに直接用意されているため、それらに `extra_args` を使用する必要はありません。 - `parallel_tool_calls`: 同じターンで複数のツール呼び出しを許可または禁止します。 -- `truncation`: コンテキストが上限を超える場合に失敗する代わりに、Responses APIが最も古い会話項目を削除できるよう、`"auto"` を設定します。 -- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに関係します。 -- `context_management`: `compact_threshold` による Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 -- `prompt_cache_retention`: 以前のモデルファミリー向けの延長保持を設定します。たとえば、 +- `truncation`: コンテキストが上限を超える場合に失敗させるのではなく、Responses API が最も古い会話項目を削除できるよう、`"auto"` を設定します。 +- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに影響します。 +- `context_management`: `compact_threshold` を使用する Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 +- `prompt_cache_retention`: 以前のモデルファミリー向けの保持期間延長を設定します。たとえば、 `"24h"` を使用します。 - `prompt_cache_options`: 暗黙的または明示的なプロンプトキャッシュを選択し、GPT-5.6 では `"30m"` のキャッシュ TTL を設定します。 - `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードをリクエストします。 -- `top_logprobs`: 出力テキストについて上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 -- `retry`: モデル呼び出しについて Runner が管理する再試行設定をオプトインで有効にします。[Runner が管理する再試行](#runner-managed-retries)を参照してください。 +- `top_logprobs`: 出力テキストについて、上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 +- `retry`: モデル呼び出しに対して Runner が管理する再試行設定を有効にします。[Runner が管理する再試行](#runner-managed-retries)を参照してください。 ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストにそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、およびファイルのコンテンツ部分にあるブレークポイントを維持します。 +明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストにそのまま渡され、Chat Completions コンバーターは、テキスト、画像、音声、およびファイルのコンテンツ部分にあるブレークポイントを維持します。 ```python from agents import Runner @@ -470,18 +470,19 @@ result = await Runner.run( ) ``` -従来の保持制御を使用する以前のモデルファミリーでは、`prompt_cache_retention` を引き続き利用できます。 -直接指定する `ModelSettings` フィールドと、`extra_args` 内の同じキーを組み合わせないでください。 +`prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーでも引き続き利用できます。 +直接指定する `ModelSettings` フィールドと、`extra_args` 内の同じキーを +組み合わせないでください。 -`store=False` を設定すると、Responses APIはそのレスポンスを後でサーバー側から取得できる状態で保持しません。これは、ステートレスまたはゼロデータ保持形式のフローに便利ですが、通常ならレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、最後のレスポンスが保存されていない場合、デフォルトの `"auto"` 圧縮パスを入力ベースの圧縮に切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 +`store=False` を設定すると、Responses API はそのレスポンスを後からサーバー側で取得できる状態に維持しません。これはステートレスまたはゼロデータ保持形式のフローに便利ですが、通常であればレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、最後のレスポンスが保存されなかった場合、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、デフォルトの `"auto"` 圧縮パスを入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses APIリクエストとともに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間でスタンドアロンの `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 +サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストで送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間でスタンドアロンの `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 ### `extra_args` の受け渡し -SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用してください。 +SDK がトップレベルでまだ直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 -OpenAIモデルを使用する場合、`extra_args` は Responses APIと Chat Completions APIの両方にオプションのパラメーターを渡せます。たとえば、`user` や `service_tier` です。サポートされているモデルで [Fast モード](https://developers.openai.com/api/docs/guides/fast-mode)を使用するには、`extra_args={"service_tier": "fast"}` を設定してください。`"priority"` も同等です。同じリクエストフィールドを、直接指定する `ModelSettings` フィールドでも設定しないでください。 +OpenAI モデルを使用する場合、`extra_args` は Responses API と Chat Completions API の両方にオプションのパラメーターを渡せます。たとえば、`user` や `service_tier` です。対応モデルで [Fast mode](https://developers.openai.com/api/docs/guides/fast-mode) を使用するには、`extra_args={"service_tier": "fast"}` を設定します。`"priority"` も同等です。同じリクエストフィールドを、直接指定する `ModelSettings` フィールドでも設定しないでください。 ```python from agents import Agent, ModelSettings @@ -499,9 +500,9 @@ english_agent = Agent( ## Runner が管理する再試行 -再試行はランタイム専用であり、オプトイン方式です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 +再試行はランタイム専用であり、明示的に有効化する必要があります。`ModelSettings(retry=...)` を設定し、再試行ポリシーで再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 -Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` はレスポンス前の過負荷フレームと、コードのない `server_error` フレームを再試行の提案として認識します。これだけでは再試行は有効になりません。引き続き `ModelRetrySettings` が必要であり、通常のリプレイ安全性チェックも適用されます。レスポンスイベントが 1 つでも到着済みの場合、SDK はリクエストをリプレイしません。 +Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` はレスポンス前の過負荷フレームと、コードのない `server_error` フレームを再試行の提案として認識します。これだけで再試行が有効になるわけではありません。引き続き `ModelRetrySettings` が必要であり、通常のリプレイ安全性チェックも適用されます。レスポンスイベントが 1 つでもすでに到着している場合、SDK はリクエストをリプレイしません。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -529,85 +530,88 @@ agent = Agent( ) ``` -`ModelRetrySettings` には、次の 3 つのフィールドがあります。 +`ModelRetrySettings` には 3 つのフィールドがあります。
-| フィールド | 型 | 注記 | +| フィールド | 型 | 備考 | | --- | --- | --- | | `max_retries` | `int | None` | 最初のリクエスト後に許可される再試行回数です。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合のデフォルト遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみを制限します。ポリシーから返された明示的な遅延や retry-after ヒントは制限しません。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合の、デフォルトの遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみを制限します。ポリシーまたは retry-after ヒントが返す明示的な遅延は制限しません。 | | `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドはランタイム専用であり、シリアライズされません。 |
-再試行ポリシーは、次の情報を持つ [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 +再試行ポリシーは、次の情報を含む [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 -- `attempt` と `max_retries`。これにより、試行回数を考慮した判断ができます。 -- `stream`。これにより、ストリーミング動作と非ストリーミング動作を分岐できます。 -- raw の検査に使用する `error`。 +- 試行回数を考慮した判断を行うための `attempt` と `max_retries`。 +- ストリーミングと非ストリーミングの動作を分岐するための `stream`。 +- raw データを調査するための `error`。 - `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの `normalized` 情報。 - 基盤となるモデルアダプターが再試行の指針を提供できる場合の `provider_advice`。 +- ポリシーの実行前に取得される、安定したリプレイ安全性情報としての `response_started`、`replay_safety`、および `stateful_request`。`replay_safety` は `"safe"`、`"unsafe"`、または `"unknown"` です。リクエストが `previous_response_id` または `conversation_id` を使用する場合、`stateful_request` は true です。 ポリシーは、次のいずれかを返せます。 - 単純な再試行判断を示す `True` / `False`。 -- 遅延をオーバーライドするか、診断理由を付加する場合の [`RetryDecision`][agents.retry.RetryDecision]。 +- 遅延を上書きする場合、診断理由を付加する場合、または限定された範囲の安全でないリプレイを明示的に承認する場合の [`RetryDecision`][agents.retry.RetryDecision]。 -SDK は、`retry_policies` で既製のヘルパーをエクスポートします。 +SDK は `retry_policies` で、すぐに使用できるヘルパーをエクスポートします。 | ヘルパー | 動作 | | --- | --- | -| `retry_policies.never()` | 常にオプトアウトします。 | -| `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行アドバイスに従います。 | -| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウト障害に一致します。 | -| `retry_policies.http_status([...])` | 選択された HTTP ステータスコードに一致します。 | +| `retry_policies.never()` | 常に再試行しません。 | +| `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行に関する助言に従います。 | +| `retry_policies.network_error()` | 一時的なトランスポート障害とタイムアウトに一致します。 | +| `retry_policies.http_status([...])` | 選択した HTTP ステータスコードに一致します。 | | `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合にのみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` はその値を制限しません。 | -| `retry_policies.any(...)` | ネストされたポリシーのいずれかがオプトインした場合に再試行します。 | -| `retry_policies.all(...)` | ネストされたすべてのポリシーがオプトインした場合にのみ再試行します。 | +| `retry_policies.any(...)` | ネストされたポリシーのいずれかが再試行を選択した場合に再試行します。 | +| `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合にのみ再試行します。 | -ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の構成要素です。プロバイダーがそれらを区別できる場合に、プロバイダーによる拒否とリプレイ安全性の承認を維持するためです。 +ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の構成要素です。プロバイダーが拒否とリプレイ安全性の承認を区別できる場合に、それらを維持するためです。 ##### 安全性の境界 -一部の障害は自動的に再試行されません。 +一部の失敗は再試行されません。 - 中止エラー。 -- プロバイダーのアドバイスでリプレイが安全でないと示されたリクエスト。 - 出力がすでに開始され、リプレイが安全でなくなるストリーミング実行。 +- Programmatic Tool Calling リクエストを含め、ローカルの副作用に対する独立したリプレイ拒否があるリクエスト。ただし、プロバイダーがリプレイを安全と個別に判断した場合を除きます。 -`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストも、より慎重に扱われます。これらのリクエストでは、`network_error()` や `http_status([500])` など、プロバイダー由来ではない述語だけでは不十分です。再試行ポリシーには、通常は `retry_policies.provider_suggested()` を通じて、プロバイダーからリプレイが安全であるという承認を含める必要があります。 +プロバイダーによって安全でないと判断された失敗も、デフォルトではブロックされます。独立したローカル副作用の拒否がない非ストリーミングリクエストでは、アプリケーションが `RetryDecision(retry=True, approve_unsafe_replay=True)` を返すことで、プロバイダー側のリプレイリスクを受け入れられます。この承認を与える前に、`context.response_started`、`context.replay_safety`、および `context.stateful_request` を確認し、プロバイダー側の処理を繰り返しても問題ない場合にのみ承認してください。通常の `RetryDecision(retry=True)` がリプレイ保護を回避することはありません。また、`approve_unsafe_replay=True` はストリーミングの再試行やローカルの副作用を承認できません。 + +`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストは、リプレイの安全性が不明な場合に安全側に倒して失敗します。このようなリクエストでは、`network_error()` や `http_status([500])` など、プロバイダー由来ではない述語だけでは不十分です。通常は `retry_policies.provider_suggested()` を介して、プロバイダーからリプレイ安全性の承認を取得するか、前述の方法でプロバイダーが安全でないと判断した非ストリーミングの失敗を明示的に承認してください。 ##### Runner とエージェントのマージ動作 `retry` は、Runner レベルとエージェントレベルの `ModelSettings` の間でディープマージされます。 -- エージェントは `retry.max_retries` のみをオーバーライドし、Runner の `policy` を引き継げます。 -- エージェントは `retry.backoff` の一部のみをオーバーライドし、Runner の他のバックオフフィールドを維持できます。 -- `policy` はランタイム専用であるため、シリアライズされた `ModelSettings` は `max_retries` と `backoff` を維持しますが、コールバック自体は省略します。 +- エージェントは `retry.max_retries` のみを上書きし、Runner の `policy` を継承できます。 +- エージェントは `retry.backoff` の一部のみを上書きし、Runner の他のバックオフフィールドを維持できます。 +- `policy` はランタイム専用であるため、シリアライズされた `ModelSettings` は `max_retries` と `backoff` を保持しますが、コールバック自体は省略します。 -さらに詳しいコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを利用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 +より詳しいコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを利用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 -## OpenAI以外のプロバイダーのトラブルシューティング +## OpenAI 以外のプロバイダーのトラブルシューティング -### トレーシングクライアントエラー 401 +### トレーシングクライアントのエラー 401 -トレーシングに関連するエラーが発生する場合、トレースがOpenAIサーバーへアップロードされる一方で、OpenAI API キーがないことが原因です。これを解決する方法は 3 つあります。 +トレーシングに関するエラーが発生するのは、トレースが OpenAI のサーバーにアップロードされる一方で、OpenAI API キーがないためです。これを解決するには、次の 3 つの方法があります。 -1. トレーシングを完全に無効にします: [`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. トレーシング用のOpenAIキーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 -3. OpenAI以外のトレースプロセッサーを使用します。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 +1. トレーシングを完全に無効にする: [`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 +2. トレーシング用の OpenAI キーを設定する: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 +3. OpenAI 以外のトレースプロセッサーを使用する。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 -### Responses APIのサポート +### Responses API のサポート -SDK はデフォルトで Responses APIを使用しますが、他の多くの LLM プロバイダーはまだこれをサポートしていません。その結果、404 や同様の問題が発生する場合があります。解決方法は 2 つあります。 +SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだサポートしていません。その結果、404 エラーまたは同様の問題が発生する場合があります。解決するには、次の 2 つの方法があります。 1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数を通じて `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] を使用します。コード例は[こちら](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)にあります。 ### Chat Completions の互換性オプション -Chat Completions を通じてルーティングする場合、SDK は Chat Completions では送信できない Responses 専用フィールドを警告なしに削除することで互換性を維持します。たとえば、`previous_response_id`、`conversation_id`、Responses APIの `prompt` フィールド、またはテキストのみではないツール出力などです。開発中にこれらの不一致を即座に失敗させる場合は、OpenAIプロバイダーで厳密な機能検証を有効にしてください。 +Chat Completions を介してルーティングする場合、SDK は、Chat Completions では送信できない Responses 専用フィールドを通知なく削除することで互換性を維持します。これには、`previous_response_id`、`conversation_id`、Responses API の `prompt` フィールド、またはテキストのみではないツール出力などが含まれます。開発中にこのような不一致を即座に失敗させる場合は、OpenAI プロバイダーで厳格な機能検証を有効にします。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -625,9 +629,11 @@ result = await Runner.run( ) ``` -[`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡してください。 +[`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡します。 + +OpenAI Chat Completions API は音声出力を返せますが、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は現在、音声出力を Agents SDK の実行項目へ変換しません。非ストリーミングメッセージまたはストリーミングの差分に音声出力が含まれる場合、アダプターは部分的または空の実行結果を返す代わりに `AgentsException("Audio is not currently supported")` を発生させます。SDK が管理する音声ワークフローには、[Realtime エージェント](../realtime/guide.md)または[音声エージェント](../voice/quickstart.md)を使用してください。 -一部のOpenAI互換 Chat Completions プロバイダーは、SDK による増分処理には信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされるツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが終了した後でのみ SDK がツール呼び出しを生成するようにしてください。 +一部の OpenAI 互換 Chat Completions プロバイダーは、SDK がインクリメンタルに処理するには信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされるツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが終了した後にのみ SDK がツール呼び出しを生成するようにします。 ```python from agents import OpenAIProvider @@ -638,11 +644,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider] では、`openai_buffer_streamed_tool_calls=True` を使用してください。 +[`MultiProvider`][agents.MultiProvider] では、`openai_buffer_streamed_tool_calls=True` を使用します。 ### structured outputs のサポート -一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)をサポートしていません。その結果、次のようなエラーが発生する場合があります。 +一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。その場合、次のようなエラーが発生することがあります。 ``` @@ -650,42 +656,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていますが、出力に使用する `json_schema` を指定できません。現在この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを利用することを推奨します。そうでない場合、不正な形式の JSON によりアプリケーションが頻繁に動作しなくなるためです。 +これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていても、出力に使用する `json_schema` を指定できません。現在、この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを使用することを推奨します。そうしないと、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 -## プロバイダーをまたいだモデルの組み合わせ +## 複数プロバイダー間でのモデルの組み合わせ -モデルプロバイダー間の機能差を認識しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAIは structured outputs、マルチモーダル入力、ホステッドファイル検索、および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 +モデルプロバイダー間の機能差を把握しておく必要があります。把握していないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホスト型のファイル検索および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限事項に注意してください。 -- 未対応の `tools` を、それを理解しないプロバイダーへ送信しないでください -- テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください -- 構造化 JSON 出力をサポートしないプロバイダーは、無効な JSON を生成する場合があることに注意してください。 +- 理解できないプロバイダーに、サポートされていない `tools` を送信しないでください +- テキストのみを扱うモデルを呼び出す前に、マルチモーダル入力を除外してください +- 構造化された JSON 出力をサポートしないプロバイダーでは、不正な JSON が生成される場合があることに注意してください。 -## サードパーティアダプター +## サードパーティ製アダプター -SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティアダプターを使用してください。この SDK でOpenAIモデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] パスを使用してください。サードパーティアダプターは、OpenAIモデルとOpenAI以外のプロバイダーを組み合わせる必要がある場合や、アダプターでのみ提供されるプロバイダー対応またはルーティングが必要な場合に使用します。アダプターは SDK と上流のモデルプロバイダーの間に互換性レイヤーを追加するため、機能のサポートとリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 +SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティ製アダプターを使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] のパスを優先してください。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる必要がある場合や、アダプターだけが提供するプロバイダー対応範囲またはルーティングが必要な場合に使用します。アダプターにより SDK と上流のモデルプロバイダーの間に互換性レイヤーが追加されるため、機能のサポートとリクエストのセマンティクスはプロバイダーによって異なる可能性があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 ### Any-LLM -Any-LLM のサポートは、Any-LLM が管理するプロバイダー対応またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として含まれています。 +Any-LLM のサポートは、Any-LLM が管理するプロバイダー対応範囲またはルーティングが必要な場合向けに、ベストエフォートのベータ版として提供されています。 上流のプロバイダーパスに応じて、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 -Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` モデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡してください。 +Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` のモデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡します。 -Any-LLM は引き続きサードパーティアダプターレイヤーであるため、プロバイダーの依存関係と機能上の不足は SDK ではなく、上流の Any-LLM によって定義されます。上流プロバイダーが使用量メトリクスを返す場合、それらは自動的に伝播されます。ただし、ストリーミングされる Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要な場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +Any-LLM は引き続きサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能差は SDK ではなく、上流の Any-LLM によって定義されます。上流のプロバイダーが使用量メトリクスを返す場合、それらは自動的に伝播されます。ただし、ストリーミングされる Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要な場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 ### LiteLLM -LiteLLM のサポートは、LiteLLM 固有のプロバイダー対応またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として含まれています。 +LiteLLM のサポートは、LiteLLM 固有のプロバイダー対応範囲またはルーティングが必要な場合向けに、ベストエフォートのベータ版として提供されています。 -LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールし、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` モデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 +LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールし、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` のモデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 -LiteLLM アダプターを通じてアクセスする一部のプロバイダーは、デフォルトでは SDK の使用量メトリクスを設定しません。使用量レポートが必要な場合は `ModelSettings(include_usage=True)` を渡し、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 +LiteLLM アダプターを通じて利用する一部のプロバイダーでは、デフォルトで SDK の使用量メトリクスが設定されません。使用量レポートが必要な場合は `ModelSettings(include_usage=True)` を渡し、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 -LiteLLM がレスポンスオブジェクトについて Pydantic シリアライザーの警告を出力する場合、LiteLLM アダプターをインポートする前に、SDK の互換性パッチをオプトインで有効にできます。 +LiteLLM がレスポンスオブジェクトに対する Pydantic シリアライザーの警告を出力する場合は、LiteLLM アダプターをインポートする前に、SDK の互換性パッチを有効にできます。 ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -このパッチはデフォルトでは無効で、`1` または `true` の値に対してのみ有効になります。非公開の LiteLLM ロギングヘルパーをラップすることで、LiteLLM のレスポンスシリアライズに関する特定の種類の警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。非公開の LiteLLM APIに依存しているため、LiteLLM をアップグレードするときは再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file +このパッチはデフォルトでは無効であり、`1` または `true` の値でのみ有効になります。LiteLLM の非公開ログヘルパーをラップすることで、LiteLLM のレスポンスシリアライズに関する特定の種類の警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。LiteLLM の非公開 API に依存しているため、LiteLLM をアップグレードするときに再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file diff --git a/docs/ja/realtime/guide.md b/docs/ja/realtime/guide.md index e19d09aff2..bd960660bd 100644 --- a/docs/ja/realtime/guide.md +++ b/docs/ja/realtime/guide.md @@ -4,19 +4,19 @@ search: --- # リアルタイムエージェントガイド -このガイドでは、OpenAI Agents SDK のリアルタイムレイヤーと OpenAI Realtime API の対応関係、および Python SDK が追加する動作について説明します。 +このガイドでは、OpenAI Agents SDK のリアルタイムレイヤーが OpenAI Realtime API にどのように対応するか、および Python SDK が追加する動作について説明します。 -!!! note "最初にお読みください" +!!! note "はじめに" - デフォルトの Python の手順を使用する場合は、最初に[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用するか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれません。 + デフォルトの Python 利用手順については、まず[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用すべきか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれていません。 ## 概要 -リアルタイムエージェントは Realtime API への長時間接続を維持するため、モデルはターンごとに新しいリクエストを開始し直すことなく、テキストと音声を段階的に処理し、音声出力をストリーミングし、ツールを呼び出し、中断を処理できます。 +リアルタイムエージェントは Realtime API への長時間接続を維持するため、モデルはテキストと音声を逐次処理し、音声出力をストリーミングし、ツールを呼び出し、ターンごとに新しいリクエストを開始し直すことなく中断を処理できます。 SDK の主なコンポーネントは次のとおりです。 -- **RealtimeAgent**: 1 つのリアルタイムスペシャリストに対する指示、ツール、出力ガードレール、ハンドオフ +- **RealtimeAgent**: 1 つのリアルタイム専門エージェントに対する指示、ツール、出力ガードレール、ハンドオフ - **RealtimeRunner**: 開始エージェントをリアルタイムトランスポートに接続するセッションファクトリー - **RealtimeSession**: 入力の送信、イベントの受信、履歴の追跡、ツールの実行を行うライブセッション - **RealtimeModel**: トランスポートの抽象化。デフォルトは OpenAI のサーバー側 WebSocket 実装です。 @@ -27,25 +27,25 @@ SDK の主なコンポーネントは次のとおりです。 1. 1 つ以上の `RealtimeAgent` を作成します。 2. 開始エージェントを指定して `RealtimeRunner` を作成します。 -3. `RealtimeSession` を取得するために `await runner.run()` を呼び出します。 +3. `await runner.run()` を呼び出し、`RealtimeSession` を取得します。 4. `async with session:` または `await session.enter()` を使用してセッションに入ります。 5. `send_message()` または `send_audio()` を使用してユーザー入力を送信します。 6. 会話が終了するまでセッションイベントを反復処理します。 テキストのみの実行とは異なり、`runner.run()` は最終的な実行結果をすぐには生成しません。代わりに、ローカル履歴、バックグラウンドでのツール実行、ガードレールの状態、アクティブなエージェント設定をトランスポートレイヤーと同期し続けるライブセッションオブジェクトを返します。 -デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、デフォルトの Python の手順では Realtime API へのサーバー側 WebSocket 接続が使用されます。別の `RealtimeModel` を渡した場合も、接続方法は変更できますが、同じセッションライフサイクルとエージェント機能が適用されます。 +デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、デフォルトの Python 利用手順では Realtime API へのサーバー側 WebSocket 接続が使用されます。別の `RealtimeModel` を渡した場合も、接続メカニズムは変更できますが、同じセッションライフサイクルとエージェント機能が適用されます。 ## エージェントとセッションの設定 `RealtimeAgent` は、通常の `Agent` 型よりも意図的に対象範囲が限定されています。 -- モデルの選択は、エージェント単位ではなくセッションレベルで設定します。 +- モデルはエージェントごとではなく、セッションレベルで選択します。 - structured outputs はサポートされていません。 - 音声は設定できますが、セッションが音声を一度生成した後は変更できません。 -- 指示、関数ツール、ハンドオフ、フック、出力ガードレールは引き続きすべて機能します。 +- 指示、関数ツール、ハンドオフ、フック、出力ガードレールはすべて引き続き使用できます。 -`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と、従来のフラットなエイリアスの両方をサポートします。新しいコードではネスト形式を推奨します。また、新しいリアルタイムエージェントでは `gpt-realtime-2.1` から始めてください。 +`RealtimeSessionModelSettings` は、新しいネストされた `audio` 設定と、従来のフラットなエイリアスの両方をサポートします。新しいコードではネスト形式を推奨します。また、新しいリアルタイムエージェントには `gpt-realtime-2.1` を使用してください。 ```python runner = RealtimeRunner( @@ -67,7 +67,7 @@ runner = RealtimeRunner( ) ``` -便利なセッションレベルの設定には次のものがあります。 +便利なセッションレベルの設定には、次のものがあります。 - `audio.input.format`、`audio.output.format` - `audio.input.transcription` @@ -79,7 +79,7 @@ runner = RealtimeRunner( - `prompt` - `tracing` -`RealtimeRunner(config=...)` で利用できる便利な実行レベルの設定には次のものがあります。 +`RealtimeRunner(config=...)` の便利な実行レベル設定には、次のものがあります。 - `async_tool_calls` - `output_guardrails` @@ -87,13 +87,67 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -型付けされた API 全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]を参照してください。 +型付きインターフェースの全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 + +### 入力文字起こし設定 + +入力文字起こしは `audio.input.transcription` で設定します。低レイテンシーの逐次文字起こしには `gpt-live-transcribe` を使用します。音声ターンの確定後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合は、WebSocket 経由で `gpt-transcribe` を使用します。Agents SDK は、モデル固有の GA 文字起こし設定をネストされたセッション設定で転送します。 + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-live-transcribe", + "prompt": "A support call about the OpenAI Agents SDK.", + "keywords": ["RunState", "MCPServerManager"], + "languages": ["en", "ja"], + }, + "turn_detection": None, + } + } + } + }, +) +``` + +`gpt-live-transcribe` では、`prompt` に自由形式の録音コンテキストを指定し、`keywords` に音声内に出現する可能性がある用語をリテラルで列挙し、`languages` に想定される入力言語を列挙します。このモデルでは、単数形の `language` ではなく複数形の `languages` を使用します。両方のフィールドを送信しないでください。 + +この SDK が固定しているバージョンの OpenAI クライアントでは、`delay` は `gpt-realtime-whisper` との組み合わせでのみサポートされます。このモデルのレイテンシーと精度のトレードオフは、次のように設定します。 + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-realtime-whisper", + "delay": "low", + }, + "turn_detection": None, + } + } + } + }, +) +``` + +`delay` 設定には、`minimal`、`low`、`medium`、`high`、または `xhigh` を指定できます。値が低いほど部分テキストが早く生成される可能性があり、値が高いほど文字起こしモデルに多くの音声コンテキストが提供され、認識精度が向上する可能性があります。各レベルの処理時間が一定であると想定せず、実際のユースケースを代表する音声でベンチマークしてください。 + +WebSocket 経由の Realtime セッションで `gpt-transcribe` を使用するのは、確定済みの音声ターンの後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合に限ります。モデルは、以前に文字起こしされたターンをコンテキストとして自動的に使用します。`gpt-transcribe` 完了イベントは、`languages` 出力フィールドで検出言語を報告します。この出力フィールドは、上記の想定言語入力である `gpt-live-transcribe` とは異なります。 + +`audio.input.turn_detection` を `None` に設定すると、自動ターン検出が無効になります。その場合、アプリケーションは音声ターンを確定し、[手動レスポンス制御](#manual-response-control)の説明に従ってレスポンスの作成を制御する必要があります。モデルの動作、検証ルール、レイテンシーに関するガイダンスについては、OpenAI API の[リアルタイム文字起こしガイド](https://developers.openai.com/api/docs/guides/realtime-transcription)を参照してください。 ## 入出力 ### テキストと構造化ユーザーメッセージ -プレーンテキストまたは構造化されたリアルタイムメッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]を使用します。 +プレーンテキストまたは構造化されたリアルタイムメッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] を使用します。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +165,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -構造化メッセージは、リアルタイムの会話に画像入力を含めるための主な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)の Web デモのコード例では、この方法で `input_image` メッセージを転送します。 +構造化メッセージは、リアルタイム会話に画像入力を含めるための主な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) の Web デモ例では、この方法で `input_image` メッセージを転送します。 -### 音声入力 +### オーディオ入力 -raw 音声バイトをストリーミングするには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]を使用します。 +raw オーディオバイトをストリーミングするには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用します。 ```python await session.send_audio(audio_bytes) ``` -サーバー側のターン検出が無効な場合は、ターンの境界を自分で指定する必要があります。高レベルの便利な方法は次のとおりです。 +サーバー側のターン検出が無効になっている場合は、ターンの境界を指定する必要があります。高レベルの便利な方法は次のとおりです。 ```python await session.send_audio(audio_bytes, commit=True) ``` -より低レベルの制御が必要な場合は、基盤となるモデルトランスポートを通じて、`input_audio_buffer.commit` などの Realtime API クライアントイベントを直接送信することもできます。 +より低レベルの制御が必要な場合は、基盤となるモデルトランスポートを介して `input_audio_buffer.commit` などの Realtime API クライアントイベントを直接送信することもできます。 ### 手動レスポンス制御 -`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを自動的に開始します。一部の設定では、raw 音声のバッファリングだけでは同じ動作が**自動的には**行われません。 +`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを開始します。一部の設定では、raw オーディオのバッファリングによって同じ処理が自動的に行われるとは**限りません**。 -Realtime API レベルでターンを手動制御するには、`turn_detection` を `null` に設定する `session.update` イベントを送信し、その後に `input_audio_buffer.commit` と `response.create` を自分で送信します。 +Realtime API レベルでの手動ターン制御では、`turn_detection` を `null` に設定する `session.update` イベントを送信してから、`input_audio_buffer.commit` と `response.create` を自身で送信します。 -ターンを手動で管理する場合は、モデルトランスポートを通じて raw クライアントイベントを送信できます。 +ターンを手動で管理する場合は、モデルトランスポートを介して raw クライアントイベントを送信できます。 ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -152,16 +206,16 @@ await session.model.send_event( このパターンは、次の場合に役立ちます。 - `turn_detection` が無効で、モデルが応答するタイミングを決定したい場合 -- レスポンスをトリガーする前に、ユーザー入力を検査または制御したい場合 +- レスポンスを開始する前にユーザー入力を検査または制限したい場合 - 帯域外レスポンスにカスタムプロンプトが必要な場合 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)の SIP のコード例では、最初の挨拶を強制するために raw `response.create` を使用しています。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) の SIP コード例では、raw `response.create` を使用して最初の挨拶を強制しています。 ## イベント、履歴、中断 -`RealtimeSession` は高レベルの SDK イベントを生成しつつ、必要に応じて raw モデルイベントも転送します。 +`RealtimeSession` は高レベルの SDK イベントを発行しつつ、必要に応じて raw モデルイベントも転送します。 -特に重要なセッションイベントには次のものがあります。 +重要なセッションイベントには、次のものがあります。 - `audio`、`audio_end`、`audio_interrupted` - `agent_start`、`agent_end` @@ -173,13 +227,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI の状態管理に最も役立つイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しを含むセッションのローカル履歴を、`RealtimeItem` オブジェクトとして公開します。 +UI の状態に最も役立つイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しなど、セッションのローカル履歴を `RealtimeItem` オブジェクトとして公開します。 ### 使用量の集計 -完了したモデルレスポンスに使用量が含まれている場合、SDK の OpenAI `RealtimeModel` トランスポートは、`raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]を生成します。その `usage` フィールドには、そのレスポンスのトークン数が含まれ、`input_tokens_details` と `output_tokens_details` には任意のモダリティ別内訳が含まれます。 +完了したモデルレスポンスに使用量が含まれる場合、SDK の OpenAI `RealtimeModel` トランスポートは、`raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] を発行します。その `usage` フィールドには、そのレスポンスのトークン数が含まれます。また、`input_tokens_details` と `output_tokens_details` には、モダリティ別の内訳が任意で含まれます。 -また、セッションは各レスポンスの使用量を共有の [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]に追加します。ライブセッションの累積使用量を確認するには、`agent_end` など、その後に発生する高レベルイベントの `event.info.context.usage` から読み取ります。 +セッションは各レスポンスの使用量を、共有される [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] にも追加します。ライブセッションの累積使用量を確認するには、`agent_end` など、その後の高レベルイベントの `event.info.context.usage` から読み取ります。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,15 +251,15 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めた場合にのみ報告されます。累積値は、その `RealtimeSession` が受信したレスポンスを対象とし、複数のセッションをまたぐ合計ではありません。 +使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めた場合にのみ報告されます。累積値の対象は、その `RealtimeSession` が受信したレスポンスです。複数のセッションを横断した合計ではありません。 -### 中断と再生位置の追跡 +### 中断と再生トラッキング -ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を生成し、ユーザーが実際に聞いた内容とサーバー側の会話が一致するように履歴を更新します。 +ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を発行し、ユーザーが実際に聞いた内容とサーバー側の会話が一致するように履歴を更新します。 -低遅延のローカル再生では、多くの場合、デフォルトの再生トラッカーで十分です。リモート再生や遅延再生のシナリオ、特に電話通信では、生成済みの音声がすべて再生されたと仮定するのではなく、実際の再生位置で中断されたレスポンスを切り詰めるために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]を使用します。 +低レイテンシーのローカル再生では、通常はデフォルトの再生トラッカーで十分です。リモート再生や遅延再生、特に電話通信では、生成された音声がすべてすでに聞かれたと見なすのではなく、実際の再生位置で中断されたレスポンスを切り詰めるために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] を使用します。 -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)の Twilio のコード例で、このパターンを確認できます。 +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) の Twilio コード例で、このパターンを確認できます。 ## ツール、承認、ハンドオフ、ガードレール @@ -230,11 +284,11 @@ agent = RealtimeAgent( ) ``` -### ツールの承認 +### ツール承認 -関数ツールでは、実行前に人間による承認を必須にできます。その場合、セッションは `tool_approval_required` を生成し、`approve_tool_call()` または `reject_tool_call()` を呼び出すまでツールの実行を一時停止します。 +関数ツールでは、実行前に人間による承認を必須にできます。この場合、セッションは `tool_approval_required` を発行し、`approve_tool_call()` または `reject_tool_call()` を呼び出すまでツールの実行を一時停止します。 -ツールに入力ガードレールも設定されている場合、それらのガードレールは承認後、実行直前に動作します。承認イベントが生成される前に実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を指定してランナーを作成します。この承認前チェックに合格した呼び出しも、承認後かつ実行前に再度チェックされます。 +ツールに入力ガードレールもある場合、承認後の実行直前にそれらのガードレールが実行されます。承認イベントが発行される前に実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を指定してランナーを作成します。この承認前チェックを通過した呼び出しも、承認後の実行前に再度チェックされます。 ```python async for event in session: @@ -242,11 +296,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)を参照してください。Human-in-the-loop のドキュメントでも、[Human-in-the-loop](../human_in_the_loop.md)でこのフローを参照しています。 +具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) を参照してください。Human-in-the-loop のドキュメントでも、[Human in the loop](../human_in_the_loop.md) でこのフローを参照しています。 ### ハンドオフ -リアルタイムハンドオフでは、あるエージェントから別のスペシャリストへライブ会話を引き継ぐことができます。 +リアルタイムハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を引き継げます。 ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +322,11 @@ main_agent = RealtimeAgent( ) ``` -ハンドオフとして直接使用される `RealtimeAgent` オブジェクトは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。リアルタイムハンドオフは、通常のハンドオフの `input_filter` をサポートして**いません**。 +ハンドオフとして直接使用される `RealtimeAgent` オブジェクトは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。リアルタイムハンドオフでは、通常のハンドオフの `input_filter` はサポートされていません。 ### ガードレール -リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートします。出力ガードレールのチェックにはデバウンスが適用されます。各チェックは部分的な差分ごとではなく、蓄積された出力テキストと音声文字起こしの差分に対して実行され、例外を送出する代わりに `guardrail_tripped` を生成します。 +リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートします。出力ガードレールのチェックはデバウンスされます。各チェックは、部分的な差分ごとではなく、蓄積された出力テキストと音声文字起こしの差分に対して実行され、例外を発生させる代わりに `guardrail_tripped` を発行します。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,15 +346,15 @@ agent = RealtimeAgent( ) ``` -リアルタイム出力ガードレールが音声文字起こしに対して作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制し、`guardrail_tripped` を生成して、作動したガードレールの名前を含むフォローアップのユーザーメッセージを送信します。これにより、モデルは代替レスポンスを生成できます。トリップワイヤーが作動した時点ですでに一部の音声がバッファリングされている可能性があるため、音声プレーヤーは引き続き `audio_interrupted` を監視し、ローカル再生を直ちに停止する必要があります。組み込みの OpenAI Realtime トランスポートでは、チェック対象のレスポンスが終了した後にガードレールチェックが完了した場合、セッションはそのレスポンスのバッファリング済み再生のみを中断し、後から開始されたレスポンスはキャンセルしません。テキストのみの出力では、代わりにレスポンス単位の `response.cancel` が送信されます。停止すべき音声再生がないため、`audio_interrupted` は生成されません。組み込みの OpenAI Realtime モデルを使用している場合、テキストのみの経路でも、同じ `guardrail_tripped` イベントとフォローアップのユーザーメッセージが生成されます。 +音声文字起こしに対してリアルタイム出力ガードレールが作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制し、`guardrail_tripped` を発行します。さらに、作動したガードレールの名前を含むフォローアップのユーザーメッセージを送信し、モデルが代替レスポンスを生成できるようにします。トリップワイヤーが作動した時点で音声の一部がすでにバッファリングされている可能性があるため、音声プレイヤーでは引き続き `audio_interrupted` を監視し、ローカル再生を直ちに停止する必要があります。組み込みの OpenAI Realtime トランスポートでは、チェック対象のレスポンスが終了した後にガードレールチェックが完了した場合、セッションはそのレスポンスのバッファリング済み再生だけを中断し、後から開始されたレスポンスはキャンセルしません。テキストのみの出力では、代わりにレスポンス単位の `response.cancel` を送信します。停止すべき音声再生がないため、`audio_interrupted` は発行されません。組み込みの OpenAI Realtime モデルを使用する場合、テキストのみの経路でも同じ `guardrail_tripped` イベントとフォローアップのユーザーメッセージが発行されます。 -カスタム `RealtimeModel` トランスポートで同じ発生元レスポンス単位の音声中断動作を実現するには、`RealtimeModelSendInterrupt.response_id` と `playback_only` に従う必要があります。また、テキストのみの出力経路で復旧メッセージをサポートするには、`RealtimeModel.send_event_if()` をオーバーライドする必要があります。実装では、トランスポートが実際にイベントをコミットする境界で指定された条件を再確認するか、条件チェックとイベントのコミットをまとめて直列化する必要があります。デフォルト実装は、復旧メッセージを安全にスキップします。条件を一度チェックしてからイベントを別途送信すると、条件チェックとイベントのコミットの間に別のレスポンスが開始される可能性があるためです。ただし、レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 +カスタム `RealtimeModel` トランスポートでは、同じ発生元レスポンス単位の音声中断動作を実現するため、`RealtimeModelSendInterrupt.response_id` と `playback_only` に従う必要があります。また、テキストのみの出力経路で復旧メッセージをサポートするには、`RealtimeModel.send_event_if()` をオーバーライドする必要があります。実装では、トランスポートで実際にイベントを確定する境界において、指定された条件を再チェックするか、条件チェックとイベントの確定をまとめて直列化する必要があります。デフォルト実装は復旧メッセージを安全にスキップします。条件を一度チェックしてからイベントを別途送信すると、そのチェックからイベントの確定までの間に別のレスポンスが開始される可能性があるためです。レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 ## SIP と電話通信 -Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]を通じた正式サポートの SIP アタッチフローが含まれています。 +Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] を介した正式サポートの SIP アタッチフローが含まれています。 -Realtime Calls API を通じて着信があり、その結果生成された `call_id` にエージェントセッションをアタッチする場合に使用します。 +Realtime Calls API 経由で着信した通話に対し、生成された `call_id` にエージェントセッションをアタッチする場合に使用します。 ```python from agents.realtime import RealtimeRunner @@ -317,20 +371,20 @@ async with await runner.run( ... ``` -先に通話を受け付け、受付時のペイロードをエージェントから導出されたセッション設定と一致させる必要がある場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)で確認できます。 +最初に通話を受け入れる必要があり、受け入れペイロードをエージェントから生成されたセッション設定と一致させたい場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) にあります。 ## 低レベルアクセスとカスタムエンドポイント -`session.model` を通じて、基盤となるトランスポートオブジェクトにアクセスできます。 +基盤となるトランスポートオブジェクトには、`session.model` を介してアクセスできます。 -これは、次のものが必要な場合に使用します。 +次のものが必要な場合に使用します。 - `session.model.add_listener(...)` を使用したカスタムリスナー - `response.create` や `session.update` などの raw クライアントイベント -- `model_config` を通じたカスタムの `url`、`headers`、`api_key` の処理 -- 既存のリアルタイム通話への `call_id` のアタッチ +- `model_config` を介したカスタムの `url`、`headers`、または `api_key` の処理 +- 既存のリアルタイム通話への `call_id` によるアタッチ -`RealtimeModelConfig` は次のものをサポートします。 +`RealtimeModelConfig` は次をサポートします。 - `api_key` - `url` @@ -339,9 +393,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -このリポジトリに同梱されている `call_id` のコード例は SIP です。より広範な Realtime API でも一部のサーバー側制御フローに `call_id` が使用されますが、ここでは Python のコード例としてパッケージ化されていません。 +このリポジトリに同梱されている `call_id` コード例は SIP です。より広範な Realtime API では、一部のサーバー側制御フローに `call_id` も使用しますが、ここでは Python コード例としてパッケージ化されていません。 -Azure OpenAI に接続する場合は、GA の Realtime エンドポイント URL と明示的なヘッダーを渡します。次に例を示します。 +Azure OpenAI に接続する場合は、GA Realtime エンドポイント URL と明示的なヘッダーを渡します。次に例を示します。 ```python session = await runner.run( @@ -352,7 +406,7 @@ session = await runner.run( ) ``` -トークンベースの認証では、`headers` に Bearer トークンを指定します。 +トークンベース認証では、`headers` に Bearer トークンを使用します。 ```python session = await runner.run( @@ -363,7 +417,7 @@ session = await runner.run( ) ``` -`headers` を渡した場合、SDK は `Authorization` を自動的には追加しません。リアルタイムエージェントでは、従来のベータ版のパス(`/openai/realtime?api-version=...`)を使用しないでください。 +`headers` を渡した場合、SDK は `Authorization` を自動的に追加しません。リアルタイムエージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を使用しないでください。 ## 関連資料 diff --git a/docs/ja/release.md b/docs/ja/release.md index d48bf2f1b0..44036c0cd6 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -4,51 +4,66 @@ search: --- # リリースプロセス/変更履歴 -このプロジェクトでは、`0.Y.Z` 形式を使用する、セマンティックバージョニングを若干変更した方式に従います。先頭の `0` は、SDK がまだ急速に進化していることを示します。各構成要素は次のように更新します。 +このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングを一部変更した方式に従います。先頭の `0` は、SDK がまだ急速に進化していることを示します。各構成要素は次のように更新します。 ## マイナー(`Y`)バージョン -ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を増やします。たとえば、`0.0.x` から `0.1.x` への移行には、破壊的変更が含まれる可能性があります。 +ベータと明記されていない公開インターフェースに **破壊的変更** を加える場合、マイナーバージョン `Y` を増やします。たとえば、`0.0.x` から `0.1.x` への更新には、破壊的変更が含まれる可能性があります。 -破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することを推奨します。 +破壊的変更を避けるには、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 ## パッチ(`Z`)バージョン -破壊的でない変更については、`Z` を増やします。 +破壊的変更ではない変更の場合、`Z` を増やします。 -- バグ修正 -- 新機能 -- 非公開インターフェースへの変更 -- ベータ機能の更新 +- バグ修正 +- 新機能 +- 非公開インターフェースの変更 +- ベータ機能の更新 ## 破壊的変更の変更履歴 +### 0.20.0 + +バージョン 0.20.0 には、ローカル MCP HTTP トランスポートをカスタマイズするアプリケーションに影響する可能性がある、破壊的な MCP 依存関係の移行が含まれます。また、エージェントまたは実行でモデルが明示的に選択されていない場合に使用される SDK のデフォルトモデルも更新されます。 + +主な変更点: + +- SDK のデフォルトモデルは、`gpt-5.4-mini` ではなく `gpt-5.6-luna` になりました。デフォルトの `reasoning.effort="none"` および `verbosity="low"` の設定に変更はありません。 +- エージェントに明示的に指定されたモデル、実行レベルのモデルオーバーライド、および `OPENAI_DEFAULT_MODEL` 環境変数は、引き続き SDK のデフォルトより優先されます。 +- Realtime 入力文字起こし設定で、`gpt-transcribe`、`gpt-live-transcribe`、`gpt-realtime-whisper` が認識されるようになりました。低レイテンシーの `gpt-live-transcribe` セッションでは、ネストされた `audio.input.transcription` 設定から `prompt`、`keywords`、および期待される複数の `languages` を指定できます。この SDK が固定している OpenAI クライアントのバージョンでは、`delay` のレイテンシー/精度レベルは `gpt-realtime-whisper` でのみサポートされます。確定済みの音声ターン後に文字起こしを行う場合、または検出された言語を出力する場合は、WebSocket 経由で `gpt-transcribe` を使用してください。`audio.input.turn_detection=None` を明示的に設定すると、ターンの自動検出が無効になります。[入力文字起こし設定](realtime/guide.md#input-transcription-settings)を参照してください。 +- Agents SDK によって作成されるローカル MCP 接続は、`mcp>=1.19.0,<3` を通じて v1 との互換性を維持しながら、MCP Python SDK v2 をサポートするようになりました。Agents SDK は、通常の stdio、SSE、Streamable HTTP 接続を自動的に適応させます。MCP v2 がインストールされている場合、これらの接続では `mcp.Client(mode="auto")` を使用してサポートされている最新のプロトコルを確認し、古いサーバーでは従来の `initialize` ハンドシェイクにフォールバックします。依存関係の解決で MCP v2 が選択された場合、カスタムの `httpx.Auth` オブジェクトまたは `httpx.AsyncClient` ファクトリーを指定するアプリケーションでは、それらの値を `httpx2` に移行するか、v1 HTTP スタックを維持するために `mcp<2` を固定する必要があります。`MCPServerStreamableHttp` の `params["ignore_initialized_notification_failure"] = True` オプションも、引き続き v1 専用です。移行の詳細については、[MCP Python SDK v1 と v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 +- サンドボックスのマウント検証では、サンドボックスまたはマウントヘルパーによる副作用が発生する前に、安全でない認証情報の配置を拒否するようになりました。信頼できるアプリケーションは、ストレージ機能テーブルを変更することなく、コンテナー内の正確なマウントパスに対するマウントスコープまたは広範な認証情報の露出を承認できます。これらの承認は実行時にのみ有効であり、シリアライズされたサンドボックス状態だけで認証情報への権限が付与されることはありません。保護されたマウント境界では、SDK は新たに生成された秘匿化済みの例外を返します。発生元の例外が、正確に認識された SDK のサンドボックスエラーであり、承認済みの構造化フィールドが検証に合格した場合、置換後の例外ではそのサブタイプと検証済みの安全なフィールドが保持されます。認識された `MountConfigError` では、SDK が生成した安全な検証メッセージも保持できます。それ以外の場合、SDK は新たに生成された汎用の秘匿化済みエラーを返します。プロバイダーが制御する、またはその他の理由で承認されていないメッセージ、コマンドデータ、注記、コンテキスト、原因、および発生元のトレースバック状態は保持されません。[マウントとリモートストレージ](sandbox/clients.md#mounts-and-remote-storage)および[セッション状態からの再開](sandbox/guide.md#resume-from-session-state)を参照してください。 +- 再試行ポリシーでは、安定したリプレイ安全性情報を確認し、プロバイダーが安全でないと判断した非ストリーミングリクエストに対して `RetryDecision(approve_unsafe_replay=True)` を明示的に設定できます。この承認によって、中止、すでに出力されたストリーミング結果、または Programmatic Tool Calling などのローカル側の副作用に対する個別の拒否が回避されることはありません。[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 +- 再開可能な `RunState` オブジェクトでは、次回のモデル呼び出し前に、`add_input()` を使用して永続的なユーザー入力をステージングできるようになりました。ステージングされた入力はシリアライズ後も維持され、入力ガードレールを通過し、ローカルセッションとサーバー管理の会話にわたって、永続的な SDK 入力を 1 回だけ生成します。安全でないリプレイが明示的に承認されている場合でも、入力がプロバイダーに再送信され、プロバイダー側の処理が繰り返される可能性があります。[再開前の入力追加](results.md#add-input-before-resuming)を参照してください。 +- 実行時の信頼性修正により、ストリーミング実行と非ストリーミング実行で[出力ガードレールのセッション永続化](guardrails.md#output-guardrails)の動作が統一され、コピーおよび名前空間の適用中も `FunctionTool` のサブクラスが保持されるようになりました。また、[サポートされていない Chat Completions の音声出力](models/index.md#chat-completions-compatibility-options)では、空のストリームを暗黙的に完了する代わりに、明示的なエラーが発生するようになりました。`OpenAIResponsesCompactionSession` ラッパーは、キャンセルが呼び出し元に伝わる前に、[コンパクション前の履歴復旧](sessions/index.md#auto-compaction-can-block-streaming)を試行して完了を待ちます。[`VoicePipeline`](voice/pipeline.md#results) のコンシューマーは、正常な実行後に文字起こしセッションのクローズに失敗した場合、その失敗を受け取るようになりました。一方、先行するターンの失敗は、後から発生したクローズの失敗より優先されます。`RunState` の往復変換では、ローカルシェルの出力、承認済みのコンピューター安全性チェック、デフォルト値が設定されたツール出力フィールド、および辞書、リスト、タプルの走査中に検出された Pydantic モデルまたは dataclass の出力が保持されるようになりました。MCP 変換では、自由形式のオブジェクトスキーマと画像出力が保持され、音声ブロックやリソースブロックなど、その他の raw コンテンツブロックは有効な JSON テキストとしてシリアライズされます。`MCPServerManager` は重複するライフサイクル操作を順番に実行し、接続とクリーンアップに有限のデフォルトタイムアウトを適用します。モデルのリプレイでは、出力項目を入力として使用する前に、サーバー所有の `created_by` メタデータが削除されます。 + ### 0.19.0 -このマイナーリリースには、破壊的変更は **ありません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域であるプログラマティックツール呼び出しを反映したものです。 +このマイナーリリースでは、破壊的変更は **導入されません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域である Programmatic Tool Calling を反映したものです。 主な変更点: -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。これにより、対応する OpenAI Responses モデルは JavaScript を生成し、プログラマティックツール呼び出しの対象となるツールを連携させることができます。ツールごとの `allowed_callers`、`FunctionTool` インスタンスからの structured outputs、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。セットアップと制約については、[プログラマティックツール呼び出し](tools.md#programmatic-tool-calling)を参照してください。 -- 公開 `agents.decorators` モジュールと、既存の `@function_tool` デコレーターの短いエイリアスである `@tool` を、既存のガードレールデコレーターと併せて追加しました。`FunctionTool` インスタンスは、非同期 callable オブジェクトもサポートするようになりました。 -- SDK 設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け付けるようになり、不明な設定も検証されます。 -- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体のエラーおよび診断ログを強化し、有用なデバッグコンテキストを維持しながら、raw な機密ペイロードが公開されないようにしました。 -- AnyLLM、LiteLLM、Chat Completions との互換性を向上し、モデルの再試行間でセッション履歴を保持するようにしました。また、レスポンス開始前に発生する WebSocket の過負荷に関するプロバイダー再試行ガイダンスを追加し、許可されている場合には、オプトインの Runner 再試行ポリシーで失敗した試行を再実行できるようにしました。 -- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントされたセッションでは、バケットの内容がワークスペースの永続化対象から除外され、動的なマウント変更やセッションの再開は意図的にサポートされません。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。これにより、対応する OpenAI Responses モデルは、Programmatic Tool Calling の対象となるツールを連携させる JavaScript を生成できます。ツール単位の `allowed_callers`、`FunctionTool` インスタンスからの structured outputs、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。設定方法と制約については、[Programmatic Tool Calling](tools.md#programmatic-tool-calling)を参照してください。 +- 公開 `agents.decorators` モジュールと、既存のガードレールデコレーターに加えて、既存の `@function_tool` デコレーターの短いエイリアスである `@tool` を追加しました。`FunctionTool` インスタンスは、非同期の呼び出し可能オブジェクトもサポートするようになりました。 +- SDK の設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け入れるようになり、不明な設定も検証されます。 +- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体でエラーおよび診断ログを強化し、有用なデバッグコンテキストを維持しながら、raw の機密ペイロードが露出しないようにしました。 +- AnyLLM、LiteLLM、Chat Completions との互換性を改善し、モデルの再試行をまたいでセッション履歴が保持されるようにしました。また、レスポンス開始前に発生した WebSocket の過負荷に対するプロバイダー再試行ガイダンスを追加し、オプトインの Runner 再試行ポリシーで、許可されている場合に失敗した試行をリプレイできるようにしました。 +- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外され、動的なマウント変更やセッションの再開は意図的にサポートされません。 ### 0.18.0 -このマイナーリリースには、破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを反映したものです。 +このマイナーリリースでは、破壊的変更は **導入されません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを反映したものです。 主な変更点: -- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime セットアップでは追加設定なしで最新の推奨モデルが使用されるようになりました。 +- Realtime エージェントはデフォルトモデルとして `gpt-realtime-2.1` を使用するようになったため、新しい Realtime 設定では追加の構成なしで最新の推奨モデルが使用されます。 ### 0.17.0 -このバージョンでは、サンドボックスのローカルソースの実体化において、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` が実体化の `base_dir` 内に維持されます。`base_dir` は、マニフェストが適用される時点での SDK プロセスの現在の作業ディレクトリです。相対ローカルソースはそのディレクトリを基準に解決されますが、絶対ローカルソースは、あらかじめそのディレクトリ内または明示的な許可対象内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼できるホストのファイルやディレクトリを、そのベースディレクトリ外からサンドボックスワークスペースへ意図的にコピーするアプリケーションに影響する可能性があります。 +このバージョンでは、サンドボックスのローカルソースの実体化において、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` は実体化の `base_dir` 内に保持されます。`base_dir` は、マニフェストの適用時における SDK プロセスの現在の作業ディレクトリです。相対的なローカルソースはそのディレクトリを基準に解決されます。一方、絶対パスのローカルソースは、すでにそのディレクトリ内に存在するか、明示的な許可の対象である必要があります。これにより、ローカルアーティファクトの境界に関する問題が解消されますが、そのベースディレクトリ外にある信頼済みのホストファイルやディレクトリを、意図的にサンドボックスワークスペースへコピーしているアプリケーションに影響する可能性があります。 -移行するには、マニフェストレベルで `SandboxPathGrant` を使用して、信頼できるホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 +移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みのホストルートを許可してください。サンドボックスでそれらのファイルを読み取るだけの場合は、読み取り専用にすることをお勧めします。 ```python from pathlib import Path @@ -75,13 +90,13 @@ manifest = Manifest( ) ``` -`extra_path_grants` は、信頼できるアプリケーション設定として扱ってください。アプリケーションが対象のホストパスをすでに承認している場合を除き、モデル出力やその他の信頼できないマニフェスト入力から許可設定を作成しないでください。 +`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスをすでに承認している場合を除き、モデルの出力やその他の信頼できないマニフェスト入力から許可を設定しないでください。 ### 0.16.0 -このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には `reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルト値が含まれるようになりました。 +このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルトが含まれるようになりました。 -以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に設定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 +以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に指定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 ```python agent = Agent(name="Assistant", model="gpt-4.1") @@ -89,14 +104,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 主な変更点: -- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の上限を無効にできるようになりました。 -- ローカル、Docker、プロバイダーを利用する各サンドボックス実装において、サンドボックスワークスペースのハイドレーションで、絶対パスのシンボリックリンク先を含め、アーカイブルート外を指すシンボリックリンクを含む tar アーカイブが拒否されるようになりました。 +- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の上限を無効にできるようになりました。 +- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、プロバイダー提供のすべてのサンドボックス実装において、絶対パスのシンボリックリンク先を含め、アーカイブルート外を指すシンボリックリンクを含む tar アーカイブを拒否するようになりました。 ### 0.15.0 -このバージョンでは、モデルによる拒否が、空のテキスト出力として扱われたり、structured outputs の場合に実行ループが `MaxTurnsExceeded` まで再試行されたりするのではなく、`ModelRefusalError` として明示的に公開されるようになりました。 +このバージョンでは、モデルによる拒否は、空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` まで実行ループが再試行されたりするのではなく、`ModelRefusalError` として明示的に通知されるようになりました。 -これは、拒否のみを含むモデルレスポンスが `final_output == ""` で完了することを想定していたコードに影響します。例外を送出せずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 +これは以前、拒否のみのモデルレスポンスが `final_output == ""` で完了することを想定していたコードに影響します。例外を発生させずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 ```python result = Runner.run_sync( @@ -106,94 +121,94 @@ result = Runner.run_sync( ) ``` -structured outputs を使用するエージェントの場合、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 +structured outputs を使用するエージェントの場合、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様に検証します。 ### 0.14.0 -このマイナーリリースには破壊的変更は **ありません** が、主要な新しいベータ機能領域としてサンドボックスエージェントが追加され、ローカル、コンテナ化、ホスト環境で利用するために必要なランタイム、バックエンド、ドキュメントのサポートも追加されました。 +このマイナーリリースでは、破壊的変更は **導入されません**が、主要な新しいベータ機能領域であるサンドボックスエージェントと、ローカル環境、コンテナー環境、ホスト環境でそれらを使用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されます。 主な変更点: -- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムサーフェスを追加しました。これにより、エージェントはファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開サポートを備えた、永続的で隔離されたワークスペース内で作業できます。 -- `UnixLocalSandboxClient` と `DockerSandboxClient` により、ローカル開発およびコンテナ化された開発向けのサンドボックス実行バックエンドを追加しました。また、Python パッケージのオプション依存関係 extras を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合も追加しました。 -- サンドボックスメモリのサポートを追加し、今後の実行で以前の実行から得た知見を再利用できるようになりました。段階的開示、複数ターンのグループ化、設定可能な隔離境界、および S3 を利用するワークフローを含む永続メモリのコード例を備えています。 -- ローカルおよび synthetic ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files 向けのリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、保存済みスナップショットを使用する再開フローを含む、より包括的なワークスペースおよび再開モデルを追加しました。 -- `examples/sandbox/` 配下に多数のサンドボックスコード例とチュートリアルを追加しました。スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有のセットアップ、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドワークフローを扱っています。 -- サンドボックス対応のセッション準備、機能のバインド、状態のシリアル化、統合トレーシング、プロンプトキャッシュキーのデフォルト値、機密性の高い MCP 出力をより安全に秘匿する処理により、コアランタイムとトレーシングスタックを拡張しました。 +- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースを追加しました。これにより、エージェントはファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開機能を備えた永続的で分離されたワークスペース内で作業できます。 +- `UnixLocalSandboxClient` と `DockerSandboxClient` によるローカルおよびコンテナー化された開発向けのサンドボックス実行バックエンドに加えて、Python パッケージのオプション依存関係 extras を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合を追加しました。 +- サンドボックスメモリのサポートを追加し、段階的開示、複数ターンのグループ化、構成可能な分離境界、S3 ベースのワークフローを含む永続化メモリのコード例により、今後の実行で以前の実行から得た知見を再利用できるようになりました。 +- ローカルおよび合成ワークスペースエントリー、S3/R2/GCS/Azure Blob Storage/S3 Files のリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットによる再開フローを含む、より包括的なワークスペースおよび再開モデルを追加しました。 +- `examples/sandbox/` 配下に、スキル、ハンドオフ、メモリを利用したコーディングタスク、プロバイダー固有の設定、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドのワークフローを扱う、多数のサンドボックスのコード例とチュートリアルを追加しました。 +- サンドボックス対応のセッション準備、機能のバインド、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーのデフォルト、機密性の高い MCP 出力のより安全な秘匿化により、コアランタイムとトレーシングスタックを拡張しました。 ### 0.13.0 -このマイナーリリースには破壊的変更は **ありません** が、注目すべき Realtime のデフォルト更新に加え、新しい MCP 機能とランタイムの安定性向上が含まれています。 +このマイナーリリースでは、破壊的変更は **導入されません**が、注目すべき Realtime のデフォルト更新に加え、新しい MCP 機能と実行時の安定性修正が含まれます。 主な変更点: -- デフォルトの WebSocket Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェントのセットアップでは追加設定なしで新しいモデルが使用されるようになりました。 -- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開され、`MCPServerStreamableHttp` で `session_id` が公開されるようになりました。これにより、MCP Streamable HTTP トランスポートを使用するセッションを、再接続やステートレスワーカーをまたいで再開できます。 -- Chat Completions 統合では、`should_replay_reasoning_content` を通じて既存の推論内容の再送信をオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論およびツール呼び出しの継続性が向上しました。 -- `SQLAlchemySession` での同時初回書き込み、推論除去後に孤立した assistant メッセージ ID を含む圧縮リクエスト、MCP/推論項目を残していた `remove_all_tools()`、`FunctionTool` インスタンスのバッチ実行機構における競合など、複数のランタイムおよびセッションのエッジケースを修正しました。 +- デフォルトの WebSocket Realtime モデルは `gpt-realtime-1.5` になったため、新しい Realtime エージェント設定では追加の構成なしで新しいモデルが使用されます。 +- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開され、`MCPServerStreamableHttp` で `session_id` が公開されるようになりました。これにより、MCP Streamable HTTP トランスポートを使用するセッションを、再接続やステートレスワーカーをまたいで再開できます。 +- Chat Completions 統合では、`should_replay_reasoning_content` を通じて既存の推論内容の再送信をオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論やツール呼び出しの継続性が向上しました。 +- `SQLAlchemySession` での同時初回書き込み、推論の除去後に孤立したアシスタントメッセージ ID を含むコンパクションリクエスト、MCP/推論項目を残していた `remove_all_tools()`、`FunctionTool` インスタンスのバッチ実行機構における競合状態など、複数のランタイムおよびセッションのエッジケースを修正しました。 ### 0.12.0 -このマイナーリリースには、破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 +このマイナーリリースでは、破壊的変更は **導入されません**。主な新機能については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を参照してください。 ### 0.11.0 -このマイナーリリースには、破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 +このマイナーリリースでは、破壊的変更は **導入されません**。主な新機能については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を参照してください。 ### 0.10.0 -このマイナーリリースには破壊的変更は **ありません** が、OpenAI Responses ユーザー向けの重要な新機能領域として、Responses API の WebSocket トランスポートサポートが含まれています。 +このマイナーリリースでは、破壊的変更は **導入されません**が、OpenAI Responses ユーザー向けの重要な新機能領域である Responses API の WebSocket トランスポートサポートが含まれます。 主な変更点: -- OpenAI Responses モデル向けの WebSocket トランスポートサポートを追加しました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 -- 複数ターンの実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 -- ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングコード例(`examples/basic/stream_ws.py`)を追加しました。 +- OpenAI Responses モデルに WebSocket トランスポートのサポートを追加しました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 +- 複数ターンの実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 +- ストリーミング、ツール、承認、フォローアップターンを扱う、新しい WebSocket ストリーミングのコード例(`examples/basic/stream_ws.py`)を追加しました。 ### 0.9.0 -このバージョンでは、Python 3.9 がサポート対象外になりました。このメジャーバージョンが 3 か月前に EOL に達したためです。より新しいランタイムバージョンにアップグレードしてください。 +このバージョンでは、このメジャーバージョンが 3 か月前に EOL を迎えたため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンにアップグレードしてください。 -さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に絞り込まれました。通常、この変更によって破壊的な問題が発生することはありませんが、コードがより広い union 型に依存している場合は、コード側で調整が必要になることがあります。 +さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に限定されました。この変更によって通常は破壊的な問題が発生することはありませんが、コードがより広範な共用体型に依存している場合は、アプリケーション側で調整が必要になる可能性があります。 ### 0.8.0 このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる可能性があります。 -- **同期** Python callable をラップする `FunctionTool` インスタンスは、イベントループスレッド上で実行されるのではなく、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールロジックがスレッドローカル状態またはスレッドアフィニティのあるリソースに依存する場合は、非同期ツール実装に移行するか、ツールコードでスレッドアフィニティを明示してください。 -- ローカル MCP ツールの失敗処理が設定可能になり、デフォルトの動作では、実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返せるようになりました。即時失敗のセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 +- `FunctionTool` インスタンスでラップされた **同期** Python 呼び出し可能オブジェクトは、イベントループのスレッドで実行されるのではなく、`asyncio.to_thread(...)` を通じてワーカースレッドで実行されるようになりました。ツールのロジックがスレッドローカルな状態やスレッドに依存するリソースに依存している場合は、非同期ツール実装へ移行するか、ツールコード内でスレッドアフィニティを明示してください。 +- ローカル MCP ツールの失敗処理が構成可能になり、デフォルトの動作では実行全体を失敗させる代わりに、モデルから参照できるエラー出力を返せるようになりました。即時失敗の動作に依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定をオーバーライドするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 ### 0.7.0 このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかあります。 -- ネストされたハンドオフ履歴が **オプトイン** になりました(デフォルトでは無効です)。v0.6.x のデフォルトだったネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 -- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルト値として設定されていた従来の `"low"` から `"none"` に変更されました。プロンプトまたは品質/コスト特性が `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 +- ネストされたハンドオフ履歴は **オプトイン** になりました(デフォルトでは無効です)。v0.6.x のデフォルトであったネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 +- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルトによって設定されていた従来の `"low"` から `"none"` に変更されました。プロンプトまたは品質/コスト特性が `"low"` に依存している場合は、`model_settings` で明示的に設定してください。 ### 0.6.0 -このバージョンでは、デフォルトのハンドオフ履歴は、ユーザーと assistant のターンを個別のメッセージとして渡すのではなく、単一の assistant メッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約を提供します -- 既存の単一メッセージ形式のハンドオフトランスクリプトでは、デフォルトで `` ブロックの前に、正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` が置かれるようになり、後続のエージェントは明確なラベル付きの要約を受け取れます +このバージョンでは、デフォルトのハンドオフ履歴は、ユーザーとアシスタントの各ターンを別々のメッセージとして渡すのではなく、単一のアシスタントメッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます。 +- 既存の単一メッセージ形式のハンドオフ記録は、デフォルトで `` ブロックの前に、正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` を置いて開始するようになり、後続のエージェントは明確なラベル付きの要約を受け取ります。 ### 0.5.0 -このバージョンでは、外部から確認できる破壊的変更は導入されていませんが、新機能と内部実装に関する重要な更新がいくつか含まれています。 +このバージョンでは、目に見える破壊的変更は導入されませんが、新機能と内部の重要な更新がいくつか含まれます。 - `RealtimeRunner` に、[SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートを追加しました。 -- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックを大幅に改訂しました +- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックを大幅に改訂しました。 ### 0.4.0 -このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x バージョンはサポート対象外になりました。この SDK では openai v2.x を使用してください。 +このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x バージョンはサポートされなくなりました。この SDK とともに openai v2.x を使用してください。 ### 0.3.0 -このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA 版)に移行します。 +このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA バージョン)に移行されます。 ### 0.2.0 -このバージョンでは、以前 `Agent` を引数として受け取っていた箇所の一部が、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、これは MCP サーバーの `list_tools()` メソッドシグネチャに適用されます。これは純粋に型付け上の変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 +このバージョンでは、以前は引数として `Agent` を受け取っていた箇所の一部が、代わりに `AgentBase` を受け取るようになりました。たとえば、MCP サーバーの `list_tools()` メソッドシグネチャがこれに該当します。これは型指定のみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 ### 0.1.0 -このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされているすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file +このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされたすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file diff --git a/docs/ja/results.md b/docs/ja/results.md index f1af84c249..5f60bd1d2c 100644 --- a/docs/ja/results.md +++ b/docs/ja/results.md @@ -6,86 +6,87 @@ search: `Runner.run` メソッドを呼び出すと、次の 2 種類の実行結果のいずれかを受け取ります。 -- `Runner.run(...)` または `Runner.run_sync(...)` から返される [`RunResult`][agents.result.RunResult] -- `Runner.run_streamed(...)` から返される [`RunResultStreaming`][agents.result.RunResultStreaming] +- `Runner.run(...)` または `Runner.run_sync(...)` からの [`RunResult`][agents.result.RunResult] +- `Runner.run_streamed(...)` からの [`RunResultStreaming`][agents.result.RunResultStreaming] -どちらも [`RunResultBase`][agents.result.RunResultBase] を継承しており、`final_output`、`new_items`、`last_agent`、`raw_responses`、`to_state()` などの共通の実行結果インターフェースを公開します。 +どちらも [`RunResultBase`][agents.result.RunResultBase] を継承し、`final_output`、`new_items`、`last_agent`、`raw_responses`、`to_state()` など、共通の結果インターフェースを公開します。 -`RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御が追加されています。 +`RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御機能も追加されています。 -## 適切な実行結果インターフェースの選択 +## 適切な結果インターフェースの選択 -ほとんどのアプリケーションで必要となる実行結果のプロパティやヘルパーは、ごくわずかです。 +ほとんどのアプリケーションで必要なのは、少数の結果プロパティまたはヘルパーのみです。 | 必要なもの | 使用するもの | | --- | --- | | ユーザーに表示する最終回答 | `final_output` | -| ローカルの完全なトランスクリプトを含む、再実行可能な次ターンの入力リスト | `to_input_list()` | +| ローカルの完全なトランスクリプトを含む、再実行可能な次ターン入力リスト | `to_input_list()` | | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な実行項目 | `new_items` | | 通常、次のユーザーターンを処理するエージェント | `last_agent` | -| `previous_response_id` を使用した OpenAI Responses API の連鎖 | `last_response_id` | -| 保留中の承認と再開可能なスナップショット | `interruptions` と `to_state()` | +| `previous_response_id` を使用した OpenAI Responses API のチェーン | `last_response_id` | +| 保留中の承認と再開可能なスナップショット | `interruptions` および `to_state()` | | 現在のネストされた `Agent.as_tool()` 呼び出しに関するメタデータ | `agent_tool_invocation` | -| raw のモデル呼び出しまたはガードレールの診断情報 | `raw_responses` とガードレールの実行結果配列 | +| raw モデル呼び出しまたはガードレールの診断情報 | `raw_responses` およびガードレール結果の配列 | ## 最終出力 -[`final_output`][agents.result.RunResultBase.final_output] プロパティには、最後に実行されたエージェントの最終出力が格納されます。次のいずれかになります。 +[`final_output`][agents.result.RunResultBase.final_output] プロパティには、最後に実行されたエージェントの最終出力が含まれます。これは次のいずれかです。 -- 最後のエージェントに `output_type` が定義されていない場合は `str` -- 最後のエージェントに出力型が定義されている場合は、`last_agent.output_type` 型のオブジェクト -- 承認による中断で一時停止した場合など、最終出力が生成される前に実行が停止した場合は `None` +- 最後のエージェントに `output_type` が定義されていなかった場合は、`str` +- 最後のエージェントに出力型が定義されていた場合は、`last_agent.output_type` 型のオブジェクト +- 承認による中断で一時停止した場合など、最終出力が生成される前に実行が停止した場合は、`None` !!! note - `final_output` の型は `Any` です。ハンドオフによって実行を完了するエージェントが変わる可能性があるため、SDK は可能性のある出力型の完全な集合を静的に把握できません。 + `final_output` の型は `Any` です。ハンドオフによって実行を完了するエージェントが変わる可能性があるため、SDK は考えられるすべての出力型を静的に把握できません。 ストリーミングモードでは、ストリームの処理が完了するまで `final_output` は `None` のままです。イベントごとのフローについては、[ストリーミング](streaming.md)を参照してください。 -## 入力、次ターンの履歴、新しい項目 +## 入力、次ターンの履歴、新規項目 これらのインターフェースは、それぞれ異なる目的に対応します。 -| プロパティまたはヘルパー | 内容 | 最適な用途 | +| プロパティまたはヘルパー | 含まれる内容 | 最適な用途 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基礎入力です。ハンドオフ入力フィルターによって履歴が書き換えられた場合は、実行の続行に使用されたフィルター済み入力が反映されます。 | この実行で実際に使用された入力の監査 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として表現したものです。デフォルトの `mode="preserve_all"` では、`new_items` から変換された履歴が保持されます。ただし、SDK のデフォルトのネストされたハンドオフ履歴へすでに移動されたセッション項目と完全に同一の出現箇所が、再度追加されることはありません。ハンドオフのフィルタリングによってモデル履歴が書き換えられる場合、`mode="normalized"` は正規の継続入力を優先します。 | 手動のチャットループ、クライアント管理の会話状態、プレーン項目の履歴確認 | +| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基本入力です。ハンドオフ入力フィルターによって履歴が書き換えられた場合は、実行の継続に使用されたフィルター済み入力が反映されます。 | この実行で実際に使用された入力の監査 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として表したビューです。デフォルトの `mode="preserve_all"` は、`new_items` から変換された履歴を維持します。ただし、SDK のデフォルトのネストされたハンドオフ履歴へすでに移動された、セッション項目の同一の出現箇所を再度追加することはありません。`mode="normalized"` は、ハンドオフフィルタリングによってモデル履歴が書き換えられた場合に、正規の継続入力を優先します。 | 手動チャットループ、クライアント管理の会話状態、プレーンな項目としての履歴確認 | | [`new_items`][agents.result.RunResultBase.new_items] | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な [`RunItem`][agents.items.RunItem] ラッパーです。 | ログ、UI、監査、デバッグ | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行内の各モデル呼び出しから得られた raw の [`ModelResponse`][agents.items.ModelResponse] オブジェクトです。 | プロバイダーレベルの診断または raw レスポンスの確認 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行内の各モデル呼び出しから取得された raw [`ModelResponse`][agents.items.ModelResponse] オブジェクトです。 | プロバイダーレベルの診断または raw レスポンスの確認 | 実際には、次のように使い分けます。 - 実行をプレーンな入力項目として確認する場合は、`to_input_list()` を使用します。 -- ハンドオフのフィルタリングまたはネストされたハンドオフ履歴の書き換え後に、次の `Runner.run(..., input=...)` 呼び出しで使用する正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 +- ハンドオフフィルタリングまたはネストされたハンドオフ履歴の書き換え後に、次の `Runner.run(..., input=...)` 呼び出しで使用する正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 - SDK に履歴の読み込みと保存を任せる場合は、[`session=...`](sessions/index.md) を使用します。 -- `conversation_id` または `previous_response_id` を使用して OpenAI のサーバー管理状態を利用している場合は、通常、`to_input_list()` を再送信するのではなく、新しいユーザー入力のみを渡して保存済みの ID を再利用します。 -- ログ、UI、監査のために変換済みの完全な履歴が必要な場合は、デフォルトの `to_input_list()` モードまたは `new_items` を使用します。 +- `conversation_id` または `previous_response_id` を使用して OpenAI のサーバー管理状態を利用している場合、通常は `to_input_list()` を再送信せず、新しいユーザー入力のみを渡して保存済み ID を再利用します。 +- ログ、UI、または監査用に変換済みの完全な履歴が必要な場合は、デフォルトの `to_input_list()` モードまたは `new_items` を使用します。 -SDK のデフォルトのネストされたハンドオフ履歴でメッセージ項目がそのまま保持される場合、Sessions、`RunState`、`to_input_list()` は、内容で重複排除するのではなく、所有対象となる個々の出現箇所を正確に追跡します。同じメッセージが別々に出現した場合、それぞれが別のものとして保持されます。すでに所有されている出現箇所だけが、再度追加されないようになります。 +SDK のデフォルトのネストされたハンドオフ履歴でメッセージ項目がそのまま保持される場合、Sessions、`RunState`、`to_input_list()` は、内容によって重複排除するのではなく、所有する正確な出現箇所を追跡します。別々に発生した同一メッセージは別々のまま保持され、すでに所有されている出現箇所だけが 2 回目の追加を回避されます。 -JavaScript SDK とは異なり、Python には、実行中に新しく生成されたモデル形式の項目だけを含む独立した `output` プロパティはありません。SDK のメタデータが必要な場合は `new_items` を使用し、raw のモデルペイロードが必要な場合は `raw_responses` を確認してください。 +JavaScript SDK とは異なり、Python には実行中に新たに生成されたモデル形式の項目のみを含む独立した `output` プロパティはありません。SDK のメタデータが必要な場合は `new_items` を使用し、raw モデルペイロードが必要な場合は `raw_responses` を確認してください。 -コンピューターツールの項目を会話入力として再送信する場合は、raw の Responses ペイロード形式が使用されます。プレビューモデルの `computer_call` 項目では単一の `action` が保持されますが、`gpt-5.5` のコンピューター呼び出しでは、バッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] はモデルが生成した形式をそのまま保持するため、それらの項目を会話入力として手動で再送信する場合、一時停止と再開のフロー、および保存されたトランスクリプトは、プレビュー版と GA 版の両方のコンピューターツール呼び出しで引き続き動作します。ローカルの実行結果は、引き続き `new_items` 内の `computer_call_output` 項目として表示されます。 +コンピュータツールの項目を会話入力として再送信する場合は、raw Responses ペイロード形式が使用されます。プレビューモデルの `computer_call` 項目では単一の `action` が保持される一方、`gpt-5.5` コンピュータ呼び出しでは、バッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] はモデルが生成した形式を保持するため、これらの項目を会話入力として手動で再送信する場合、一時停止と再開のフロー、保存済みトランスクリプトは、プレビュー版と GA 版の両方のコンピュータツール呼び出しで引き続き動作します。ローカルの実行結果は、引き続き `new_items` 内に `computer_call_output` 項目として表示されます。 -### 新しい項目 +### 新規項目 -[`new_items`][agents.result.RunResultBase.new_items] では、実行中に発生した内容を最も詳細に確認できます。一般的な項目の型は次のとおりです。 +[`new_items`][agents.result.RunResultBase.new_items] では、実行中に起きたことを最も詳細に確認できます。一般的な項目型は次のとおりです。 +- 再開されたモデル呼び出しの直前に `RunState.pending_input` から受け入れられた入力を表す [`InputItem`][agents.items.InputItem] - アシスタントメッセージを表す [`MessageOutputItem`][agents.items.MessageOutputItem] - 推論項目を表す [`ReasoningItem`][agents.items.ReasoningItem] -- Responses のツール検索リクエストと読み込まれたツール検索結果を表す [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] と [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- ツール呼び出しとその実行結果を表す [`ToolCallItem`][agents.items.ToolCallItem] と [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 承認待ちで一時停止したツール呼び出しを表す [`ToolApprovalItem`][agents.items.ToolApprovalItem] +- Responses のツール検索リクエストと読み込まれたツール検索結果を表す [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] および [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- ツール呼び出しとその実行結果を表す [`ToolCallItem`][agents.items.ToolCallItem] および [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- 承認のために一時停止したツール呼び出しを表す [`ToolApprovalItem`][agents.items.ToolApprovalItem] - ホスト型 MCP の承認とツールカタログを表す [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem]、[`MCPListToolsItem`][agents.items.MCPListToolsItem] -- ハンドオフリクエストと完了した転送を表す [`HandoffCallItem`][agents.items.HandoffCallItem] と [`HandoffOutputItem`][agents.items.HandoffOutputItem] +- ハンドオフリクエストと完了した移管を表す [`HandoffCallItem`][agents.items.HandoffCallItem] および [`HandoffOutputItem`][agents.items.HandoffOutputItem] -エージェントとの関連付け、ツールの出力、ハンドオフの境界、承認の境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 +エージェントとの関連付け、ツール出力、ハンドオフの境界、または承認の境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 -ホスト型ツール検索を使用する場合は、モデルが発行した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を、どの名前空間、関数、ホスト型 MCP サーバーがそのターン用に読み込まれたかを確認するには `ToolSearchOutputItem.raw_item` を参照してください。 +ホスト型ツール検索を使用する場合は、モデルが発行した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を、該当ターンで読み込まれた名前空間、関数、またはホスト型 MCP サーバーを確認するには `ToolSearchOutputItem.raw_item` を調べてください。 -プログラムによるツール呼び出しでは、生成された `program` は `ToolCallItem` となり、そのプログラムが所有する通常の子ツール呼び出しも `ToolCallItem` エントリとなり、対応する `program_output` は `ToolCallOutputItem` となります。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外で、それぞれ `MCPApprovalRequestItem` エントリと `MCPListToolsItem` エントリになります。 +プログラムによるツール呼び出しでは、生成された `program` は `ToolCallItem` です。そのプログラムが所有する通常の子ツール呼び出しも `ToolCallItem` エントリであり、対応する `program_output` は `ToolCallOutputItem` です。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外であり、それぞれ `MCPApprovalRequestItem` エントリと `MCPListToolsItem` エントリになります。 -raw 項目は、型付きの Responses オブジェクトまたはマッピングの場合があります。特に、プログラムが所有するシェル呼び出しとパッチ適用呼び出しではマッピングが使用されます。マッピングでも安全に確認できるパターンを使用してください。 +raw 項目は、型付きの Responses オブジェクトまたはマッピングである場合があります。特に、プログラムが所有する shell 呼び出しと apply-patch 呼び出しではマッピングが使用されます。マッピングでも安全な次の検査パターンを使用してください。 ```python from collections.abc import Mapping @@ -107,21 +108,23 @@ caller_id = ( ) ``` -プログラムが所有する子呼び出しでは、`caller` の `type` フィールドは `program` となり、`caller_id` は親プログラム呼び出しを識別します。 +プログラムが所有する子呼び出しでは、`caller` の `type` フィールドは `program` であり、`caller_id` は親プログラム呼び出しを識別します。 -## 会話の続行または再開 +## 会話の継続または再開 ### 次ターンのエージェント -[`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが格納されます。多くの場合、ハンドオフ後の次のユーザーターンで再利用するエージェントとして最適です。 +[`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが含まれます。多くの場合、ハンドオフ後の次のユーザーターンで再利用するのに最適なエージェントです。 -ストリーミングモードでは、実行の進行に応じて [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が更新されるため、ストリームが完了する前にハンドオフを確認できます。 +ストリーミングモードでは、実行の進行に伴って [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が更新されるため、ストリームが完了する前にハンドオフを確認できます。 ### 中断と実行状態 -ツールで承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接使用されたツール、ハンドオフ後に到達したツール、ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって発生した承認が含まれる場合があります。 +ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接呼び出されたツール、ハンドオフ後に到達したツール、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって要求された承認が含まれる場合があります。 -再開可能な [`RunState`][agents.run_state.RunState] を取得するには [`to_state()`][agents.result.RunResult.to_state] を呼び出し、保留中の項目を承認または拒否してから、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 +[`to_state()`][agents.result.RunResult.to_state] を呼び出して、再開可能な [`RunState`][agents.run_state.RunState] を取得します。保留中の項目を承認または却下し、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 + +[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] の出力が Pydantic モデルまたはデータクラスの場合、`RunState` はその出力を構造化データとしてシリアライズします。`RunState` は辞書、リスト、タプルも再帰的に処理し、それらのコンテナ内で検出した Pydantic モデルまたはデータクラスを変換します。タプルは JSON のラウンドトリップ後にリストとして復元されます。JSON と互換性のないその他の値は文字列表現にフォールバックする場合があるため、正確なカスタム型をシリアライズ後も保持する必要がある場合は、明示的に JSON 互換のデータを返してください。 ```python from agents import Agent, Runner @@ -136,17 +139,35 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了してから `result.interruptions` を確認し、`result.to_state()` から再開します。承認フローの全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 +#### 再開前の入力追加 + +実行が一時停止した後、または完了したターンの後で停止したものの、未完了の実行が次のモデル呼び出しに到達する前に新しいユーザー入力を受け取った場合は、[`RunState.add_input()`][agents.run_state.RunState.add_input] を使用します。文字列はユーザーメッセージになり、複数回の呼び出しでは挿入順序が維持されます。ステージ済み入力はシリアライズされた `RunState` の一部であるため、`to_json()` / `from_json()` および `to_string()` / `from_string()` のラウンドトリップ後も保持されます。 + +```python +state = result.to_state() +state.add_input("Also keep the generated report in the project folder.") + +for interruption in state.get_interruptions(): + state.approve(interruption) + +result = await Runner.run(agent, state) +``` -### サーバー管理による継続 +再開時、Runner は現在のエージェントの入力ガードレールと [`RunConfig`][agents.run.RunConfig] の入力ガードレールの両方を、ステージ済み入力のみに適用します。クライアント管理の [`Session`][agents.memory.session.Session] が構成されている場合、Runner は受け入れられたステージ済み入力を永続的な [`InputItem`][agents.items.InputItem] に変換し、モデルリクエストを発行する前にセッションへの書き込み完了を待ちます。クライアント管理セッションもサーバー管理の会話もない場合、Runner はモデルリクエストを発行する前に、受け入れられたステージ済み入力を `InputItem` に変換します。サーバー管理の会話では、サーバーリクエストが受け入れるまで入力は保留状態のままです。シリアライズ、再開、再実行しても安全な再試行を通じて、SDK は永続的な `InputItem` の出現を 1 つだけ保持します。この SDK による出現回数の保証は、プロバイダーへの配信保証ではありません。リクエストがプロバイダーに到達した可能性がある後で再試行ポリシーが `RetryDecision(approve_unsafe_replay=True)` を返した場合、Runner はステージ済み入力を再送信する可能性があり、プロバイダー側の処理が繰り返されることがあります。正常に受け入れられた入力は、`new_items` に `InputItem` として表示されます。分離されたコピーを取得するには [`RunState.pending_input`][agents.run_state.RunState.pending_input] を読み取り、再開前にすべてのステージ済み入力を破棄するには [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input] を呼び出します。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API の連鎖を継続する場合は、次のターンで `previous_response_id` として渡します。 +`RunState.add_input()` は、終端状態、モデルの残りターンがない状態、受け入れられたモデルレスポンスがローカル処理を待っている状態、および保留中のツール実行結果によって次のモデル呼び出し前に実行が終了する可能性がある中断状態を拒否します。このような場合は、現在の実行を完了してから、新しいユーザーターンを開始してください。 -すでに `to_input_list()`、`session`、`conversation_id` を使用して会話を継続している場合は、通常 `last_response_id` は必要ありません。複数ステップの実行に含まれるすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 +ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了し、その後 `result.interruptions` を確認して `result.to_state()` から再開します。承認フロー全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 -## エージェントをツールとして使用する際のメタデータ +### サーバー管理の継続 -ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から実行結果が返された場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は、それを囲む `Agent.as_tool()` 呼び出しに関する次の不変メタデータを公開します。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API のチェーンを継続する場合は、次のターンで `previous_response_id` として再度渡します。 + +すでに `to_input_list()`、`session`、または `conversation_id` を使用して会話を継続している場合、通常は `last_response_id` は必要ありません。複数ステップの実行からすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 + +## ツールとしてのエージェントのメタデータ + +ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から結果が返された場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は、それを囲む `Agent.as_tool()` 呼び出しに関する変更不可能なメタデータを公開します。 - `tool_name` - `tool_call_id` @@ -154,41 +175,48 @@ if result.interruptions: 通常のトップレベル実行では、`agent_tool_invocation` は `None` です。 -これは特に `custom_output_extractor` 内で便利です。ネストされた実行結果を後処理する際に、それを囲む `Agent.as_tool()` 呼び出しのツール名、呼び出し ID、raw 引数が必要になる場合があります。関連する `Agent.as_tool()` のパターンについては、[ツール](tools.md)を参照してください。 +これは特に `custom_output_extractor` 内で、ネストされた実行結果を後処理する際に、それを囲む `Agent.as_tool()` 呼び出しのツール名、呼び出し ID、または raw 引数が必要な場合に役立ちます。関連する `Agent.as_tool()` のパターンについては、[ツール](tools.md)を参照してください。 -そのネストされた実行で解析済みの構造化入力も必要な場合は、`context_wrapper.tool_input` を参照してください。これは、[`RunState`][agents.run_state.RunState] がネストされたツール入力用に汎用的にシリアル化するフィールドです。一方、`agent_tool_invocation` は、現在のネストされた呼び出しのメタデータを実行結果上で直接公開します。 +そのネストされた実行に対するパース済みの構造化入力も必要な場合は、`context_wrapper.tool_input` を読み取ります。これは、[`RunState`][agents.run_state.RunState] がネストされたツール入力として汎用的にシリアライズするフィールドです。一方、`agent_tool_invocation` は、現在のネストされた呼び出しのメタデータを結果上で直接公開します。 ## ストリーミングのライフサイクルと診断 -[`RunResultStreaming`][agents.result.RunResultStreaming] は上記と同じ実行結果インターフェースを継承しますが、次のストリーミング固有の制御が追加されています。 +[`RunResultStreaming`][agents.result.RunResultStreaming] は前述と同じ結果インターフェースを継承しますが、ストリーミング固有の制御機能も追加されています。 - セマンティックなストリームイベントを消費するための [`stream_events()`][agents.result.RunResultStreaming.stream_events] - 実行中のアクティブなエージェントを追跡するための [`current_agent`][agents.result.RunResultStreaming.current_agent] - ストリーミング実行が完全に終了したかどうかを確認するための [`is_complete`][agents.result.RunResultStreaming.is_complete] -- 実行を直ちに、または現在のターンの後に停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] +- 実行を即座に、または現在のターンの後で停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] + +非同期イテレーターが終了するまで `stream_events()` を消費し続けてください。このイテレーターが終了するまでストリーミング実行は完了していません。また、最後の可視トークンが到着した後も、`final_output`、`interruptions`、`raw_responses` などの概要プロパティや、セッション永続化の副作用が処理中である可能性があります。 + +`cancel()` を呼び出した場合は、キャンセルとクリーンアップを正しく完了できるように、`stream_events()` の消費を続けてください。 + +Python には、ストリーミングされた独立の `completed` Promise や `error` プロパティはありません。実行を終了させるストリーミングエラーは `stream_events()` によって送出され、`is_complete` は実行が終端状態に到達したかどうかを示します。 -非同期イテレーターが完了するまで `stream_events()` を消費し続けてください。このイテレーターが終了するまで、ストリーミング実行は完了していません。最後に表示されるトークンが到着した後も、`final_output`、`interruptions`、`raw_responses` などの概要プロパティや、セッション永続化の副作用が確定処理中の場合があります。 +### Raw レスポンス -`cancel()` を呼び出した場合は、キャンセルとクリーンアップが正しく完了するように、`stream_events()` を引き続き消費してください。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが含まれます。複数ステップの実行では、ハンドオフやモデル、ツール、モデルというサイクルの繰り返しなどにより、複数のレスポンスが生成される場合があります。 -Python には、ストリーミング用の独立した `completed` Promise や `error` プロパティはありません。実行を終了させるストリーミングエラーは `stream_events()` によって送出され、`is_complete` は実行が終端状態に達したかどうかを示します。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリから取得した ID にすぎません。 -### raw レスポンス +各 [`ModelResponse`][agents.items.ModelResponse] では、個々のモデル呼び出しに適用される次の 2 つの診断情報も公開されます。 -[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw のモデルレスポンスが格納されます。複数ステップの実行では、ハンドオフや、モデル、ツール、モデルというサイクルの繰り返しなどによって、複数のレスポンスが生成される場合があります。 +- [`request_id`][agents.items.ModelResponse.request_id] は、モデルアダプターとトランスポートが ID を伝播する場合のトランスポートリクエスト ID です。組み込みの `OpenAIResponsesModel` と `OpenAIChatCompletionsModel` は、HTTP および SSE のトランスポート経路で、利用可能なサーバー生成の `x-request-id` を伝播します。構成されたエンドポイントが OpenAI API の場合は、本番環境で `None` ではない値をログに記録すると、障害を OpenAI サポートに問い合わせる際に関連付けられます。OpenAI 互換プロバイダーまたはプロキシの場合は、代わりにそのサービスのサポート窓口を使用してください。現在、`OpenAIResponsesWSModel` では `request_id` は `None` のままです。サードパーティー製アダプターでは、リクエスト ID の伝播は保証されません。AnyLLM Chat Completions アダプターと `LitellmModel` では、現在 `request_id` は `None` のままです。Agents SDK の AnyLLM Responses アダプターでも、トランスポートリクエスト ID を保持せずにプロバイダーレスポンスを正規化した場合、`request_id` が `None` のままになることがあります。 +- [`raw_usage`][agents.items.ModelResponse.raw_usage] は、Agents SDK がペイロードを正規化する前の、プロバイダーの使用量ペイロードに関するオプトインの JSON 互換スナップショットです。`ModelSettings(preserve_raw_usage=True)` を指定して `raw_usage` を有効にします。[プロバイダーの使用量ペイロードの保持](usage.md#preserving-provider-usage-payloads)を参照してください。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリの ID にすぎません。 +`ModelResponse.request_id` と `ModelResponse.raw_usage` はそれぞれ `None` になる可能性があるため、これらの値は会話状態ではなく、オプションの診断情報として扱ってください。 -### ガードレールの実行結果 +### ガードレール結果 -エージェントレベルのガードレールは、[`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] と [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] として公開されます。 +エージェントレベルのガードレールは、[`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] および [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] として公開されます。 -ツールのガードレールは、[`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] と [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] として個別に公開されます。 +ツールのガードレールは、[`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] および [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] として個別に公開されます。 -これらの配列は実行全体を通じて蓄積されるため、判断のログ記録、追加のガードレールメタデータの保存、実行がブロックされた理由のデバッグに役立ちます。 +これらの配列は実行全体を通じて蓄積されるため、判断のログ記録、追加のガードレールメタデータの保存、または実行がブロックされた理由のデバッグに役立ちます。 ### コンテキストと使用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータとともに、アプリのコンテキストを公開します。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータとともにアプリケーションコンテキストを公開します。 -使用量は `context_wrapper.usage` で追跡されます。ストリーミング実行では、ストリームの最後のチャンクが処理されるまで、使用量の合計値の反映が遅れる場合があります。ラッパーの完全な形式と永続化に関する注意事項については、[コンテキスト管理](context.md)を参照してください。 \ No newline at end of file +使用量は `context_wrapper.usage` で追跡されます。ストリーミング実行では、ストリームの最後のチャンクが処理されるまで、使用量の合計への反映が遅れる場合があります。ラッパーの完全な形式と永続化に関する注意事項については、[コンテキスト管理](context.md)を参照してください。 \ No newline at end of file diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index 5b1721fddf..cda0cc7feb 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -4,11 +4,11 @@ search: --- # エージェントの実行 -[`Runner`][agents.run.Runner] クラスを使用してエージェントを実行できます。次の 3 つの方法があります。 +エージェントは [`Runner`][agents.run.Runner] クラスを介して実行できます。次の 3 つの方法があります。 -1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行し、[`RunResult`][agents.result.RunResult] を返します。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドで、内部では単に `.run()` を実行します。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントをそのままストリーミングします。 +1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行され、[`RunResult`][agents.result.RunResult] を返します。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドであり、内部では単に `.run()` を実行します。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行され、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、イベントを受信するたびにストリーミングします。 ```python from agents import Agent, Runner @@ -23,34 +23,34 @@ async def main(): # Infinite loop's dance ``` -詳細については、[実行結果ガイド](results.md)を参照してください。 +詳細については、[実行結果ガイド](results.md)をご覧ください。 -## ランナーのライフサイクルと設定 +## Runner のライフサイクルと設定 ### エージェントループ -上記 3 つの `Runner` メソッドのいずれかを呼び出す際は、開始エージェントと入力を渡します。入力には次のものを使用できます。 +上記 3 つの `Runner` メソッドのいずれかを呼び出すときは、開始エージェントと入力を渡します。入力には次のものを指定できます。 - 文字列(ユーザーメッセージとして扱われます) - OpenAI Responses API 形式の入力項目のリスト -- 中断された実行を再開する場合は、[`RunState`][agents.run_state.RunState] +- 一時停止した実行、または `cancel(mode="after_turn")` で停止した実行を再開する場合は、[`RunState`][agents.run_state.RunState]。状態には、[次回のモデル呼び出し再開時に使用するために準備された入力](results.md#add-input-before-resuming)を含めることもできます。 -その後、ランナーは次のループを実行します。 +その後、Runner はループを実行します。 -1. 現在のエージェントに対し、現在の入力を使用して LLM を呼び出します。 +1. 現在の入力を使用して、現在のエージェント向けに LLM を呼び出します。 2. LLM が出力を生成します。 - 1. ランナーが LLM の出力を最終出力と判定した場合、ループを終了して実行結果を返します。 + 1. Runner が LLM の出力を最終出力と判定した場合、ループを終了して実行結果を返します。 2. LLM がハンドオフを要求した場合、現在のエージェントと入力を更新し、ループを再実行します。 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行し、実行結果を追加して、ループを再実行します。 3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note - LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力を生成し、ツール呼び出しが存在しないことです。 + LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが存在しないことです。 ### ストリーミング -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] に、新たに生成されたすべての出力を含む実行の完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)をご覧ください。 #### Responses WebSocket トランスポート(オプションのヘルパー) @@ -58,11 +58,11 @@ OpenAI Responses の WebSocket トランスポートを有効にしても、通 これは WebSocket トランスポート経由の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 -トランスポートの選択ルール、および具象モデルオブジェクトやカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)を参照してください。 +トランスポートの選択規則と、具体的なモデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 -##### パターン 1:セッションヘルパーなし(利用可能) +##### パターン 1:セッションヘルパーなし(動作可能) -WebSocket トランスポートのみを使用し、共有プロバイダーやセッションを SDK で管理する必要がない場合は、この方法を使用します。 +WebSocket トランスポートのみを使用し、SDK に共有プロバイダーやセッションを管理させる必要がない場合に使用します。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -このパターンは単発の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 +このパターンは単発の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続されることがあります。 ##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) -複数の実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、ネストされたエージェントツール呼び出しも対象です。 +共有の WebSocket 対応プロバイダーと `RunConfig` を複数の実行で使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 ```python import asyncio @@ -119,59 +119,59 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果を最後まで取得してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 +コンテキストを終了する前に、ストリーミングされた実行結果を最後まで消費してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる場合があります。 -サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約を取り除くものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。復元動作の詳細については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)を参照してください。 +サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約を取り除くものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。復元動作の詳細については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)をご覧ください。 -長時間の推論ターンで WebSocket のキープアライブがタイムアウトする場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket のレイテンシよりも信頼性を重視する実行には、HTTP/SSE トランスポートを使用してください。 +長時間の推論ターンで WebSocket のキープアライブがタイムアウトする場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket の低レイテンシーより信頼性を重視する実行では、HTTP/SSE トランスポートを使用してください。 ### 実行設定 -`run_config` パラメーターを使用すると、エージェント実行の一部のグローバル設定を構成できます。 +`run_config` パラメーターを使用すると、エージェントの実行に関する一部のグローバル設定を構成できます。 #### 一般的な実行設定のカテゴリー -各エージェントの定義を変更せずに単一の実行の動作をオーバーライドするには、`RunConfig` を使用します。 +各エージェント定義を変更せずに単一の実行の動作を上書きするには、`RunConfig` を使用します。 -##### モデル、プロバイダー、セッションのデフォルト設定 +##### モデル、プロバイダー、セッションのデフォルト -- [`model`][agents.run.RunConfig.model]:各エージェントが持つ `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 +- [`model`][agents.run.RunConfig.model]:各 Agent が持つ `model` にかかわらず、使用するグローバル LLM モデルを設定できます。 - [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 -- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定をオーバーライドします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 -- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト設定(たとえば、`SessionSettings(limit=...)`)をオーバーライドします。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions を使用する各 `Runner` 実行の前に、新しいユーザー入力をセッション履歴と統合する方法をカスタマイズします。コールバックは同期または非同期にできます。 +- [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 +- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(`SessionSettings(limit=...)` など)を上書きします。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions を使用する場合に、`Runner` の各実行前に新しいユーザー入力をセッション履歴とマージする方法をカスタマイズします。コールバックは同期または非同期にできます。 -##### ガードレール、ハンドオフ、モデル入力の調整 +##### ガードレール、ハンドオフ、モデル入力の整形 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだ設定されていない場合に、すべてのハンドオフへ適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信する入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントを参照してください。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、ロスレスなメッセージ項目を元の位置に保持しながら、要約可能な履歴を順序付けられたアシスタント要約セグメントへ圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、raw なトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、および `RunResult.to_input_list()` では、SDK のデフォルトのネスト履歴に同一のメッセージ出現箇所がすでに含まれている場合、そのメッセージを重複して追加しません。一方で、内容が同一でも別個のメッセージは保持されます。すべての [Runner メソッド][agents.run.Runner]は、明示的に渡さなかった場合に `RunConfig` を自動的に作成するため、クイックスタートとコード例ではデフォルトが無効のまま維持されます。また、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは、引き続きこの設定をオーバーライドします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を通じてこの設定をオーバーライドできます。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインした場合に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを記述することなく、組み込みの順序付けられた要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の短縮やシステムプロンプトの挿入に使用できます。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:ランナーが以前の出力を次のターンのモデル入力へ変換する際に、推論項目 ID を保持するか省略するかを制御します。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信される入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、ロスレスなメッセージ項目を元の位置に保持しながら、要約可能な履歴を順序付きのアシスタント要約セグメントに圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、生のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、および `RunResult.to_input_list()` は、SDK のデフォルトのネストされた履歴にすでに含まれている同一のメッセージ出現を二重に追加することを避ける一方で、内容が同一でも別々のメッセージは保持します。すべての [Runner メソッド][agents.run.Runner]は、指定されていない場合に `RunConfig` を自動的に作成します。そのため、クイックスタートとコード例ではデフォルトが無効のままとなり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個別のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を介してこの設定を上書きできます。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインするたびに、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの呼び出し可能オブジェクトです。完全なハンドオフフィルターを記述せずに組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の切り詰めやシステムプロンプトの挿入に使用できます。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力に変換するときに、推論項目 ID を保持するか省略するかを制御します。 ##### トレーシングと可観測性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 -- [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定をオーバーライドするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入力と出力など、機密である可能性のあるデータをトレースに含めるかどうかを設定します。 +- [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密である可能性のあるデータをトレースに含めるかどうかを設定します。 - [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 -##### ツールの実行、承認、エラー動作 +##### ツール実行、承認、ツールエラーの動作 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出しの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合に、ランナーが処理する方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。オプトインすると、代わりにモデルから見えるエラー出力を返します。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、ランナーが処理する方法を設定します。デフォルトの `"warn"` では、対処方法を示す警告をログに記録し、現在のディスパッチ先として選択されたものだけを公開します。`"error"` では、モデルを呼び出す前に `UserError` が発生します。名前空間付きツールと遅延読み込みツールに対する厳密な検証は変更されません。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから見えるツールエラーメッセージをカスタマイズします。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出し数の制限など、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合に、Runner がどう処理するかを設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから参照可能なエラー出力を返すようオプトインできます。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、Runner がどう処理するかを設定します。デフォルトの `"warn"` では、対処方法を示す警告をログに記録し、現在のディスパッチで優先されるものだけを公開します。`"error"` では、モデルが呼び出される前に `UserError` が発生します。名前空間付きツールと遅延読み込みツールの厳格な検証は変更されません。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから参照可能なツールエラーメッセージをカスタマイズします。 -ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付けられたトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡すか、特定のハンドオフに対して有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントをロスレスなメッセージ項目の前後に配置します。raw なトランスクリプトを保持する場合(デフォルト)は、フラグを未設定のままにするか、必要な形式で会話をそのまま転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成される要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 +ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付きトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡すか、特定のハンドオフに対して有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントをロスレスなメッセージ項目の前後に配置します。生のトランスクリプトを保持する場合(デフォルト)は、フラグを未設定のままにするか、必要なとおりに会話を転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成される要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 #### 実行設定の詳細 ##### `tool_execution` -実行時のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 +実行時にローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成した場合、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツール呼び出し数の上限を設定するには、整数値を指定します。 +`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成すると、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツール呼び出し数の上限を設定するには、整数値を指定します。 これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別のものです。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがツール呼び出しを生成した後に、SDK がローカル関数ツール呼び出しを実行する方法を制御します。 -`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、承認後、実行直前にのみツール入力ガードレールが実行されます。保留中の承認による中断が生成される前に関数ツール入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間依存のチェックは実行前に再検証されます。 +`pre_approval_tool_input_guardrails=False` は、デフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、承認後の実行直前にのみツール入力ガードレールが実行されます。保留中の承認による中断が生成される前に、関数ツールの入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックに合格した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間に依存するチェックは実行前に再検証されます。 ##### `tool_not_found_behavior` -デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合、ランナーは `ModelBehaviorError` を発生させます。 +デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 -実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は解決できなかったツール呼び出しに対して `function_call_output` を追加し、モデルを再度実行します。これにより、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 +実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は解決できなかったツール呼び出しに `function_call_output` を追加してモデルを再実行するため、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 ```python from agents import Agent, RunConfig, Runner @@ -214,22 +214,22 @@ result = await Runner.run( ) ``` -現在、このオプションはツール名の検索に失敗した関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードには、引き続き既存のエラー動作が適用されます。 +このオプションは現在、ツール名の検索に失敗した関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、従来のエラー動作が引き続き使用されます。 ##### `tool_error_formatter` -SDK がモデルから見えるツールエラー出力を作成したときにモデルへ返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 +SDK がモデルから参照可能なツールエラー出力を作成するときにモデルへ返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 フォーマッターは、次の内容を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 -- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリーです。 -- `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)です。 -- `tool_name`:ツール名です。 -- `call_id`:ツール呼び出し ID です。 -- `default_message`:SDK のデフォルトの、モデルから見えるメッセージです。 -- `run_context`:有効な実行コンテキストラッパーです。 +- `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリー。 +- `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)。 +- `tool_name`:ツール名。 +- `call_id`:ツール呼び出し ID。 +- `default_message`:SDK のデフォルトの、モデルから参照可能なメッセージ。 +- `run_context`:アクティブな実行コンテキストのラッパー。 -メッセージを置き換える文字列を返すか、SDK のデフォルトを使用する場合は `None` を返します。 +メッセージを置き換える文字列を返すか、SDK のデフォルトを使用するには `None` を返します。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,56 +256,56 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` は、ランナーが履歴を次へ引き継ぐ際(たとえば、`RunResult.to_input_list()` やセッションを利用した実行を使用する場合)に、推論項目を次のターンのモデル入力へ変換する方法を制御します。 +`reasoning_item_id_policy` は、Runner が履歴を次のターンへ引き継ぐ際に、推論項目を次のターンのモデル入力へ変換する方法を制御します(たとえば、`RunResult.to_input_list()` またはセッションを基盤とする実行を使用する場合)。 - `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 - `"omit"`:生成される次のターンの入力から推論項目 ID を削除します。 -`"omit"` は主に、推論項目が `id` とともに送信されているものの、後続に必要な項目(たとえば、`Item 'rs_...' of type 'reasoning' was provided without its required following item.`)がない場合に発生する、Responses API の 400 エラーの一種に対するオプトインの緩和策として使用します。 +`"omit"` は主に、推論項目が `id` とともに送信されたものの、後続に必要な項目(たとえば `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)がない場合に発生する、一連の Responses API 400 エラーへのオプトインの緩和策として使用します。 -これは、SDK が以前の出力から後続の入力を構築する複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。推論項目 ID が保持されている一方で、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合に発生します。 +これは、SDK が以前の出力から後続入力を構築する複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。推論項目 ID が保持されている一方、プロバイダーがその ID と対応する後続項目を常にペアにすることを求める場合に発生します。 -`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` は削除されます。これにより、SDK が生成する後続入力で、その API の不変条件に抵触することを回避できます。 +`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` は削除されます。これにより、SDK が生成した後続入力でその API の不変条件に抵触することを回避できます。 適用範囲に関する注意事項: -- これは、SDK が後続入力を構築する際に生成または転送する推論項目のみを変更します。 +- これは、SDK が後続入力を構築するときに生成または転送する推論項目のみを変更します。 - ユーザーが指定した初期入力項目は書き換えません。 -- このポリシーの適用後でも、`call_model_input_filter` によって意図的に推論 ID を再導入できます。 +- `call_model_input_filter` では、このポリシーが適用された後でも、意図的に推論 ID を再導入できます。 ## 状態と会話の管理 ### メモリ戦略の選択 -状態を次のターンへ引き継ぐ一般的な方法は 4 つあります。 +次のターンへ状態を引き継ぐ一般的な方法は 4 つあります。 | 戦略 | 状態の保存場所 | 最適な用途 | 次のターンで渡すもの | | --- | --- | --- | --- | -| `result.to_input_list()` | アプリケーションのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | +| `result.to_input_list()` | アプリのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | | `session` | ストレージと SDK | 永続的なチャット状態、再開可能な実行、カスタムストア | 同じ `session` インスタンス、または同じストアを参照する別のインスタンス | -| `conversation_id` | OpenAI Conversations API | ワーカーまたはサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | -| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに行う、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | +| `conversation_id` | OpenAI Conversations API | ワーカーまたはサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と、新しいユーザーターンのみ | +| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに行う、軽量なサーバー管理の継続 | `result.last_response_id` と、新しいユーザーターンのみ | -`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI によって管理され、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両方のレイヤーを意図的に調整している場合を除き、コンテキストが重複する可能性があります。 +`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両方のレイヤーを意図的に調整しない限り、コンテキストが重複する可能性があります。 !!! note - 同じ実行内で、セッションの永続化とサーバー管理の会話設定 + 同じ実行で、セッションの永続化とサーバー管理の会話設定 (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)を - 組み合わせることはできません。呼び出しごとにいずれか 1 つの方法を選択してください。 + 組み合わせることはできません。呼び出しごとに 1 つの方法を選択してください。 ### 会話とチャットスレッド -いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があり、その結果、LLM が 1 回以上呼び出されることがあります。ただし、これはチャット会話における論理的な 1 ターンを表します。例: +いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される場合があります(したがって、1 回以上の LLM 呼び出しが行われます)が、チャット会話における論理的な 1 ターンを表します。次に例を示します。 1. ユーザーターン:ユーザーがテキストを入力します。 -2. ランナー実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 +2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントにハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力のみを表示することもできます。いずれの場合も、その後ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ何を表示するかを選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力のみを表示することもできます。いずれの場合も、その後ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 #### 手動による会話管理 -[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] メソッドを使用して次のターンの入力を取得し、会話履歴を手動で管理できます。 +次のターンの入力を取得するには、[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] メソッドを使用して会話履歴を手動で管理できます。 ```python from agents import Agent, Runner, trace @@ -329,7 +329,7 @@ async def main(): #### Sessions による自動会話管理 -より簡単な方法として、`.to_input_list()` を手動で呼び出すことなく、[Sessions](sessions/index.md) を使用して会話履歴を自動的に処理できます。 +より簡単な方法として、`.to_input_list()` を手動で呼び出すことなく会話履歴を自動的に処理するために、[Sessions](sessions/index.md) を使用できます。 ```python from agents import Agent, Runner, SQLiteSession, trace @@ -359,18 +359,18 @@ Sessions は次の処理を自動的に行います。 - 各実行後に新しいメッセージを保存します - セッション ID ごとに個別の会話を維持します -詳細については、[Sessions のドキュメント](sessions/index.md)を参照してください。 +詳細については、[Sessions のドキュメント](sessions/index.md)をご覧ください。 #### サーバー管理の会話 -`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能にサーバー側の会話状態を管理させることもできます。これにより、過去のすべてのメッセージを手動で再送信することなく、会話履歴を保持できます。以下のいずれのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)を参照してください。 +`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能にサーバー側で会話状態を管理させることもできます。これにより、過去のすべてのメッセージを手動で再送信せずに会話履歴を保持できます。以下のいずれのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 -OpenAI では、ターンをまたいで状態を追跡する方法を 2 つ提供しています。 +OpenAI では、ターン間で状態を追跡する方法を 2 つ提供しています。 ##### 1. `conversation_id` の使用 -最初に OpenAI Conversations API を使用して会話を作成し、その後のすべての呼び出しでその ID を再利用します。 +最初に OpenAI Conversations API を使用して会話を作成し、その後の各呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -393,7 +393,7 @@ async def main(): ##### 2. `previous_response_id` の使用 -もう 1 つの選択肢は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的に関連付けます。 +もう 1 つの選択肢は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的にリンクします。 ```python from agents import Agent, Runner @@ -418,29 +418,29 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を維持するため、再開したターンは同じサーバー管理の会話で継続されます。 +実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` 設定を保持するため、再開したターンは同じサーバー管理の会話内で続行されます。 -`conversation_id` と `previous_response_id` は相互排他的です。システム間で共有できる名前付きの会話リソースが必要な場合は、`conversation_id` を使用します。ターン間を継続するための最も軽量な Responses API の基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 +`conversation_id` と `previous_response_id` は相互に排他的です。システム間で共有できる名前付き会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 !!! note - SDK は、`conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話を使用した実行では、再試行前に内部の会話トラッカー入力を巻き戻すため、 - 同じ準備済み項目を問題なく再送信できます。 + SDK は `conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の + 会話の実行では、再試行前に内部の会話追跡用入力を巻き戻し、 + 同じ準備済み項目を問題なく再送信できるようにします。 ローカルのセッションベースの実行(`conversation_id`、 - `previous_response_id`、または `auto_previous_response_id` とは組み合わせられません)では、 - SDK は最近永続化された入力項目をベストエフォートでロールバックし、 - 再試行後の履歴エントリの重複を減らします。 + `previous_response_id`、または `auto_previous_response_id` とは組み合わせられません)では、SDK は + 再試行後に履歴項目が重複することを減らすため、直近で永続化された入力項目の + ベストエフォートなロールバックも行います。 この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも行われます。モデルリクエストに対する - より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 + より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)をご覧ください。 ## フックとカスタマイズ -### モデル呼び出しの入力フィルター +### モデル呼び出し入力フィルター -モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。フックは、現在のエージェント、コンテキスト、および統合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 +モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは現在のエージェント、コンテキスト、結合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。その他の形式を返すと `UserError` が発生します。 @@ -461,19 +461,19 @@ result = Runner.run_sync( ) ``` -ランナーは準備済み入力リストのコピーをフックへ渡すため、呼び出し元の元のリストをその場で変更することなく、項目を短縮、置換、または並べ替えできます。 +Runner は準備済み入力リストのコピーをフックに渡すため、呼び出し元の元のリストをその場で変更することなく、切り詰め、置換、並べ替えを行えます。 -セッションを使用している場合、`call_model_input_filter` はセッション履歴がすでに読み込まれ、現在のターンと統合された後に実行されます。それより前の統合ステップ自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 +セッションを使用している場合、`call_model_input_filter` は、セッション履歴が読み込まれ、現在のターンとマージされた後に実行されます。それより前のマージ手順自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 -`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。このペイロードは、過去の履歴の完全な再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続処理で送信済みとして記録されます。 +`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次回の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続処理で送信済みとしてマークされます。 -機密データの削除、長い履歴の短縮、追加のシステムガイダンスの挿入を行うには、`run_config` を使用して実行ごとにフックを設定します。 +機密データの編集、長い履歴の切り詰め、追加のシステムガイダンスの挿入を行うには、`run_config` を介して実行ごとにフックを設定します。 ## エラーと復旧 ### エラーハンドラー -すべての `Runner` エントリポイントは、エラー種別をキーとする dict である `error_handlers` を受け取ります。サポートされるキーは、`"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 +すべての `Runner` エントリーポイントは、エラー種別をキーとする dict である `error_handlers` を受け取ります。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 ```python from agents import ( @@ -502,7 +502,7 @@ result = Runner.run_sync( print(result.final_output) ``` -モデルメッセージがエージェントの structured `output_type` に対して検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を行いません。フォールバックがない場合、空でないレスポンスの検証失敗では引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 +モデルメッセージがエージェントの structured な `output_type` に対して検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を辞退します。フォールバックがない場合、空でない値の検証失敗では引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 ```python from pydantic import BaseModel @@ -534,9 +534,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数のハンドラーでは、合成されたフォールバック出力を会話履歴に追加し、設定されたセッションへ永続化します。実行結果の履歴やセッションストレージに追加せず、フォールバックを呼び出し元へ返す場合は、`include_in_history=False` を設定します。 +`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数のハンドラーでは、合成されたフォールバック出力が会話履歴に追加され、設定済みのセッションに永続化されます。実行結果の履歴やセッションストレージに追加せず、フォールバックを呼び出し元に返す場合は、`include_in_history=False` を設定します。 -モデルによる拒否が発生した際に、`ModelRefusalError` で実行を終了する代わりにアプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 +モデルの拒否が `ModelRefusalError` で実行を終了する代わりにアプリケーション固有のフォールバックを生成する必要がある場合は、`"model_refusal"` を使用します。 ```python from pydantic import BaseModel @@ -570,33 +570,33 @@ print(result.final_output) ## 永続的な実行の統合とヒューマンインザループ -ツール承認の一時停止と再開のパターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長時間の待機、再試行、またはプロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 +ツール承認の一時停止と再開のパターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長時間の待機、再試行、またはプロセスの再起動にまたがる場合の永続的なオーケストレーションを目的としています。 ### Dapr -Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、ヒューマンインザループのワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの使用を[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 +Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、ヒューマンインザループのワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立な [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの利用は、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 ### Temporal -Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモを[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認し、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)から参照できます。 +Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間のタスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)でご覧いただけます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)から確認できます。 ### Restate -Agents SDK の [Restate](https://restate.dev/) 統合は、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントに使用できます。この統合では、依存関係として Restate の単一バイナリランタイムが必要です。また、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)を参照してください。 +Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人間による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実行できます。この統合には、依存関係として Restate の単一バイナリランタイムが必要です。また、エージェントをプロセスやコンテナ、またはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 ### DBOS -Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進捗を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは、SQLite または Postgres データベースのみです。詳細については、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)を参照してください。 +Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動をまたいで進捗を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは SQLite または Postgres データベースのみです。詳細については、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 ## 例外 SDK は特定の状況で例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 -- [`AgentsException`][agents.exceptions.AgentsException]:SDK が発生させるすべての例外の基底クラスです。他のすべての具体的な例外の派生元となる汎用型です。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドへ渡された `max_turns` 制限を超えた場合に発生します。指定されたエージェントループのターン数(LLM 呼び出し回数)以内に、エージェントがタスクを完了できなかったことを示します。この制限を無効にするには、`max_turns=None` を設定します。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。これには次のものが含まれます。 - - 不正な JSON:モデルがツール呼び出しまたは直接の出力で、不正な JSON 構造を提供した場合です。特に、特定の `output_type` が定義されている場合に該当します。 - - 予期しないツール関連の障害:モデルが想定された方法でツールを使用できなかった場合です -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定されたタイムアウトを超え、そのツールが `timeout_behavior="raise_exception"` を使用している場合に発生します。 -- [`UserError`][agents.exceptions.UserError]:SDK を使用してコードを記述する人が、SDK の使用時に誤りを犯した場合に発生します。通常は、不適切なコード実装、無効な設定、または SDK API の誤用が原因です。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:入力ガードレールの条件を満たすと `InputGuardrailTripwireTriggered` が発生し、出力ガードレールの条件を満たすと `OutputGuardrailTripwireTriggered` が発生します。入力ガードレールは処理前に受信メッセージを確認し、出力ガードレールは配信前にエージェントの最終レスポンスを確認します。 \ No newline at end of file +- [`AgentsException`][agents.exceptions.AgentsException]:SDK が発生させるすべての例外の基底クラスです。その他すべての固有の例外が派生する汎用型として機能します。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドに渡された `max_turns` の制限を超えた場合に発生します。指定されたエージェントループのターン数(LLM 呼び出し数)以内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。次のような場合が含まれます。 + - 不正な形式の JSON:モデルがツール呼び出しまたは直接出力で不正な形式の JSON 構造を提供した場合。特に、特定の `output_type` が定義されている場合。 + - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定済みのタイムアウトを超え、ツールが `timeout_behavior="raise_exception"` を使用している場合に発生します。 +- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードを記述している方が、SDK の使用時に誤りを犯した場合に発生します。通常、不正なコード実装、無効な設定、または SDK API の誤用が原因です。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:入力ガードレールの条件が満たされると `InputGuardrailTripwireTriggered` が発生し、出力ガードレールの条件が満たされると `OutputGuardrailTripwireTriggered` が発生します。入力ガードレールは処理前に受信メッセージを確認し、出力ガードレールは提供前にエージェントの最終レスポンスを確認します。 \ No newline at end of file diff --git a/docs/ja/sandbox/clients.md b/docs/ja/sandbox/clients.md index 44d52ffe75..ce9e77ac12 100644 --- a/docs/ja/sandbox/clients.md +++ b/docs/ja/sandbox/clients.md @@ -4,11 +4,11 @@ search: --- # サンドボックスクライアント -このページでは、サンドボックスでの処理を実行する場所を選択できます。ほとんどの場合、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントとクライアント固有のオプションのみを変更し、`SandboxAgent` の定義はそのまま使用します。 +このページでは、サンドボックスでの作業を実行する場所を選択します。ほとんどの場合、`SandboxAgent` の定義はそのままで、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 内のサンドボックスクライアントとクライアント固有のオプションのみを変更します。 !!! warning "ベータ機能" - サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 + サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポート対象の機能が変更される可能性があり、今後さらに高度な機能が追加される予定です。 ## 選択ガイド @@ -16,30 +16,30 @@ search: | 目的 | 最初の選択肢 | 理由 | | --- | --- | --- | -| macOS または Linux での最速のローカル反復 | `UnixLocalSandboxClient` | 追加インストールが不要で、ローカルファイルシステムを使用した開発が容易です。 | -| 基本的なコンテナ分離 | `DockerSandboxClient` | 指定したイメージを使用し、Docker 内で処理を実行します。 | -| ホスト実行または本番環境相当の分離 | ホスト型サンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境へ移します。 | +| macOS または Linux で最速のローカル反復開発 | `UnixLocalSandboxClient` | 追加インストールが不要で、ローカルファイルシステムを使った開発が簡単です。 | +| 基本的なコンテナ分離 | `DockerSandboxClient` | 特定のイメージを使用して Docker 内で作業を実行します。 | +| ホステッド実行または本番環境相当の分離 | ホステッドサンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境に移します。 |
## ローカルクライアント -ほとんどのユーザーには、次の 2 つのサンドボックスクライアントのいずれかを最初に使用することをお勧めします。 +ほとんどのユーザーは、次の 2 つのサンドボックスクライアントのいずれかから始めることをおすすめします。
-| クライアント | インストール | 選択する場合 | コード例 | +| クライアント | インストール | 適している場合 | 例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカル反復を行う場合。ローカル開発の優れたデフォルトです。 | [Unix ローカルのスターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離が必要な場合や、対象環境をローカルで再現するために特定のイメージを使用する場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカル反復開発を行う場合。ローカル開発の優れたデフォルトです。 | [Unix ローカルのスターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離が必要な場合、または特定のイメージを使用して対象環境をローカルで再現する場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix ローカルは、ローカルファイルシステムを対象とした開発を始める最も簡単な方法です。より強力な環境分離や本番環境相当の一貫性が必要になった場合は、Docker またはホスト型プロバイダーへ移行してください。 +Unix-local は、ローカルファイルシステムを対象に開発を始める最も簡単な方法です。より強力な環境分離や本番環境相当の一貫性が必要になったら、Docker またはホステッドプロバイダーに移行してください。 -`SandboxPathGrant.host_path` は Docker 専用で、ホスト上のパスをコンテナ内の別の POSIX パスにマッピングします。Unix ローカルでは、同一パスの許可のみがサポートされます。詳細については、[マニフェストのパス許可](guide.md#manifest)を参照してください。 +`SandboxPathGrant.host_path` は Docker 専用で、ホストのパスをコンテナ内の別の POSIX パスにマッピングします。Unix-local では、同一パスへの許可のみがサポートされます。詳細については、[マニフェストのパス許可](guide.md#manifest)を参照してください。 -Unix ローカルから Docker に切り替えるには、エージェント定義をそのまま維持し、実行設定のみを変更します。 +Unix-local から Docker に切り替えるには、エージェント定義はそのままにして、実行設定のみを変更します。 ```python from docker import from_env as docker_from_env @@ -56,45 +56,45 @@ run_config = RunConfig( ) ``` -コンテナ分離が必要な場合や、サンドボックスイメージを別の環境で使用されるイメージと一致させる場合に使用します。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 +コンテナ分離が必要な場合や、サンドボックスイメージを別の環境で使用されているイメージと一致させる場合に使用します。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 ## マウントとリモートストレージ -マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホスト型プロバイダーの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 +マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホステッドプロバイダー向けの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 一般的なマウントオプションは次のとおりです。 - `mount_path`: サンドボックス内でストレージが表示される場所です。相対パスはマニフェストルートを基準に解決され、絶対パスはそのまま使用されます。 -- `read_only`: デフォルトは `True` です。サンドボックスからマウント済みストレージへ書き戻す必要がある場合のみ、`False` を設定します。 +- `read_only`: デフォルトは `True` です。サンドボックスからマウントされたストレージへ書き戻す必要がある場合にのみ、`False` を設定します。 - `mount_strategy`: 必須です。マウントエントリとサンドボックスバックエンドの両方に適合する戦略を使用してください。 -マウントは一時的なワークスペースエントリとして扱われます。スナップショットと永続化のフローでは、マウント済みのリモートストレージを保存対象のワークスペースへコピーするのではなく、マウント済みパスを切り離すかスキップします。 +マウントは、一時的なワークスペースエントリとして扱われます。スナップショットおよび永続化のフローでは、マウントされたリモートストレージを保存済みワークスペースへコピーする代わりに、マウントされたパスを切り離すかスキップします。 -汎用的なローカル/コンテナ戦略は次のとおりです。 +汎用のローカル/コンテナ戦略は次のとおりです。
-| 戦略またはパターン | 使用する場合 | 注記 | +| 戦略またはパターン | 適している場合 | 注記 | | --- | --- | --- | | `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | サンドボックスイメージで `rclone` を実行できる場合。 | S3、GCS、R2、Azure Blob、Box をサポートします。`RcloneMountPattern` は `fuse` モードまたは `nfs` モードで実行できます。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` があり、Mountpoint 形式で S3 または S3 互換ストレージへアクセスする場合。 | `S3Mount` と `GCSMount` をサポートします。 | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | イメージに `blobfuse2` があり、FUSE をサポートしている場合。 | `AzureBlobMount` をサポートします。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` があり、Mountpoint 形式で S3 または S3 互換ストレージにアクセスする場合。 | `S3Mount` と `GCSMount` をサポートします。 | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | イメージに `blobfuse2` と FUSE サポートがある場合。 | `AzureBlobMount` をサポートします。 | | `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | イメージに `mount.s3files` があり、既存の S3 Files マウントターゲットへ接続できる場合。 | `S3FilesMount` をサポートします。 | -| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを利用するマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` を使用してマウントできます。S3 と GCS は `mountpoint` を使用してマウントすることもできます。 | +| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを利用したマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` を介してマウントできます。また、S3 と GCS は `mountpoint` を介してマウントすることもできます。 |
-## サポート対象のホスト型プラットフォーム +## サポート対象のホステッドプラットフォーム -ホスト型環境が必要な場合、通常は同じ `SandboxAgent` の定義をそのまま使用し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントのみを変更します。 +ホステッド環境が必要な場合、通常は同じ `SandboxAgent` 定義をそのまま使用し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 内のサンドボックスクライアントのみを変更します。 -このリポジトリをチェックアウトしたものではなく、公開版 SDK を使用している場合は、対応するパッケージの extra を使用してサンドボックスクライアントの依存関係をインストールしてください。 +このリポジトリのチェックアウトではなく公開版 SDK を使用している場合は、対応するパッケージの extras を使用してサンドボックスクライアントの依存関係をインストールしてください。 -プロバイダー固有のセットアップに関する注記と、リポジトリに含まれる拡張機能のコード例へのリンクについては、[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md) を参照してください。 +プロバイダー固有の設定に関する注記と、リポジトリに含まれる拡張機能のコード例へのリンクについては、[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md) を参照してください。
-| クライアント | インストール | コード例 | +| クライアント | インストール | 例 | | --- | --- | --- | | `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel ランナー](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | | `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare ランナー](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | @@ -106,23 +106,41 @@ run_config = RunConfig(
-ホスト型サンドボックスクライアントは、プロバイダー固有のマウント戦略を公開します。ストレージプロバイダーに最適なバックエンドとマウント戦略を選択してください。 +ホステッドサンドボックスクライアントは、プロバイダー固有のマウント戦略を公開します。使用するストレージプロバイダーに最適なバックエンドとマウント戦略を選択してください。
| バックエンド | マウントに関する注記 | | --- | --- | -| Docker | `InContainerMountStrategy` や `DockerVolumeMountStrategy` などのローカル戦略で、`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` をサポートします。 | -| `ModalSandboxClient` | `ModalCloudBucketMountStrategy` を `S3Mount`、`R2Mount`、HMAC 認証の `GCSMount` とともに使用することで、クラウドバケットのマウントをサポートします。インライン認証情報または名前付き Modal Secret を使用できます。 | -| `CloudflareSandboxClient` | `CloudflareBucketMountStrategy` を `S3Mount`、`R2Mount`、HMAC 認証の `GCSMount` とともに使用することで、バケットのマウントをサポートします。 | -| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy` と `S3Mount`、`R2Mount`、または `GCSMount` のエントリを組み合わせることで、クラウドバケットのマウントをサポートします。また、`BlaxelDriveMount` と `BlaxelDriveMountStrategy` による永続的な Blaxel Drives もサポートします。どちらも `agents.extensions.sandbox.blaxel` から利用できます。 | -| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` を使用して `rclone` 経由でクラウドストレージをマウントできます。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` とともに使用してください。 | -| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` を使用して `rclone` 経由でクラウドストレージをマウントできます。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` とともに使用してください。 | -| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` を使用して `rclone` 経由でクラウドストレージをマウントできます。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` とともに使用してください。 | -| `VercelSandboxClient` | `VercelCloudBucketMountStrategy` と `S3Mount` のエントリを組み合わせることで、作成時に限り S3 および S3 互換バケットのマウントをサポートします。マウント済みセッションは再開できません。また、インライン認証情報には `allow_s3_credential_exposure=True` が必要です。 | +| Docker | `InContainerMountStrategy` や `DockerVolumeMountStrategy` などのローカル戦略により、`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` をサポートします。 | +| `ModalSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証を使用する `GCSMount` とともに `ModalCloudBucketMountStrategy` を使用することで、クラウドバケットのマウントをサポートします。インライン認証情報または名前付きの Modal Secret を使用できます。 | +| `CloudflareSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証を使用する `GCSMount` とともに `CloudflareBucketMountStrategy` を使用することで、バケットのマウントをサポートします。 | +| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy` と `S3Mount`、`R2Mount`、または `GCSMount` のエントリを組み合わせることで、クラウドバケットのマウントをサポートします。また、`agents.extensions.sandbox.blaxel` から利用できる `BlaxelDriveMount` と `BlaxelDriveMountStrategy` により、永続的な Blaxel Drives もサポートします。 | +| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` を使用し、`rclone` を介したクラウドストレージのマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用します。 | +| `E2BSandboxClient` | `E2BCloudBucketMountStrategy` を使用し、`rclone` を介したクラウドストレージのマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用します。 | +| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` を使用し、`rclone` を介したクラウドストレージのマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用します。 | +| `VercelSandboxClient` | `VercelCloudBucketMountStrategy` と `S3Mount` のエントリを組み合わせることで、作成時に限り S3 および S3 互換バケットのマウントをサポートします。マウントされたセッションは再開できず、インライン認証情報には `allow_s3_credential_exposure=True` が必要です。 |
+マウント表は、各バックエンドが実行できるストレージタイプを示しています。チェックマークが付いていても、モデルが制御するサンドボックス内で実行されるマウントヘルパーの認証情報境界を回避できるわけではなく、すべての戦略が認証情報なしで動作できることを意味するものでもありません。Agents SDK が承認なしでコンテナ内マウントを受け入れるのは、選択したヘルパーが保護対象の権限なしで動作できる場合のみです。保護対象の権限を必要とするマウントについては、信頼できるアプリケーションコードが対象のマウントパスに対する権限の公開を明示的に承認しない限り、サンドボックスまたはマウントヘルパーを起動する前に拒否されます。 + +認証情報を必要としない `rclone` のマウントは、S3、GCS、R2、Azure Blob に限定されます。コンテナ内の Box マウントには、非対話型の認証ソースと、そのソースに対応する承認が必要です。`FuseMountPattern` では、インライン認証情報が設定されていない場合でも `blobfuse2` が環境に存在する Azure 権限を検出するため、広範な承認が必要です。同様に、`S3FilesMountPattern` でも `mount.s3files` が環境に存在する IAM 権限を使用するため、広範な承認が必要です。これらの要件は、Docker がバックエンドの場合にも適用されます。以下のチェックマークは、該当する権限境界の要件を満たした後に、Docker がマウントを実行できることを示しています。 + +`"data"` という名前のマウントエントリでは、設定された権限に対応する承認によって返される、コピー済みの `Manifest` を保持してください。 + +```python +# Mount-scoped values such as inline access keys. +manifest = manifest.with_in_container_mount_credential_exposure_acknowledged("data") + +# Broader authority such as managed or workload identity and external credential files. +manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") +``` + +承認が必要なすべてのマウントについて、正確なマウントパスをそれぞれ渡してください。両方の権限クラスを使用するマウントには、両方の承認が必要です。承認は実行時にのみ使用され、シリアライズされません。また、認証情報の使用範囲をマウント先のパスに限定することなく、ヘルパーが認証情報を受け取ることを許可します。利用可能な場合は外部戦略またはプロバイダーネイティブの戦略を優先し、それ以外の場合はサンドボックス単位で、短期間のみ有効な最小権限の認証情報を使用してください。 + +`VercelSandboxClientOptions(allow_s3_credential_exposure=True)` は、マウント単位のインライン認証情報を使用して作成時に Vercel S3 をマウントするための互換性オプションとして引き続き利用できます。広範な認証情報へのアクセス権限を付与するものではありません。 + 次の表は、各バックエンドが直接マウントできるリモートストレージエントリをまとめたものです。
@@ -140,4 +158,4 @@ run_config = RunConfig(
-その他の実行可能なコード例については、ローカル、コーディング、メモリ、ハンドオフ、エージェント構成のパターンを扱う [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) と、ホスト型サンドボックスクライアントを扱う [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) を参照してください。 \ No newline at end of file +実行可能なコード例については、ローカル、コーディング、メモリ、ハンドオフ、エージェント構成のパターンを扱う [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) と、ホステッドサンドボックスクライアントを扱う [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) を参照してください。 \ No newline at end of file diff --git a/docs/ja/sandbox/guide.md b/docs/ja/sandbox/guide.md index 7de2178362..295ff09df1 100644 --- a/docs/ja/sandbox/guide.md +++ b/docs/ja/sandbox/guide.md @@ -8,9 +8,9 @@ search: サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果を発揮します。**サンドボックスエージェント**は、専用ツールとシェルコマンドを使用して、大規模なドキュメント群の検索や操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために使用できる永続的なワークスペースをモデルに提供します。Agents SDK のサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。また、適切なファイルをファイルシステムに配置し、サンドボックスをオーケストレーションすることで、大規模なタスクの開始、停止、再開を容易に行えます。 +最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果を発揮します。 **サンドボックスエージェント** は、専用ツールやシェルコマンドを使用して、大規模なドキュメントセットの検索や操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために使用できる永続的なワークスペースをモデルに提供します。Agents SDKのサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。これにより、適切なファイルをファイルシステムに配置し、サンドボックスをオーケストレーションして、大規模なタスクを容易に開始、停止、再開できます。 -エージェントが必要とするデータに基づいてワークスペースを定義します。GitHub リポジトリ、ローカルのファイルやディレクトリ、合成されたタスクファイル、S3 や Azure Blob Storage などのリモートファイルシステム、および指定したその他のサンドボックス入力から開始できます。 +エージェントが必要とするデータを中心にワークスペースを定義します。GitHub リポジトリ、ローカルのファイルやディレクトリ、合成されたタスクファイル、S3 や Azure Blob Storage などのリモートファイルシステム、およびその他の指定したサンドボックス入力から開始できます。
@@ -18,23 +18,23 @@ search:
-`SandboxAgent` は引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントインターフェースを維持し、通常の `Runner` API を介して実行されます。変更されるのは実行境界です。 +`SandboxAgent` は引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントインターフェースを維持し、引き続き通常の `Runner` API を介して実行されます。変わるのは実行境界です。 -- `SandboxAgent` は、エージェント自体を定義します。通常のエージェント設定に加え、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト、およびファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能を定義します。 -- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースの初期コンテンツとレイアウトを宣言します。 -- サンドボックスセッションは、コマンドが実行され、ファイルが変更される稼働中の分離環境です。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、実行がサンドボックスセッションを取得する方法を決定します。たとえば、サンドボックスセッションを直接注入する、シリアライズされたサンドボックスセッション状態から再接続する、サンドボックスクライアントを介して新しいサンドボックスセッションを作成する、といった方法があります。 -- 保存済みのサンドボックス状態とスナップショットを使用すると、後続の実行で以前の作業に再接続したり、保存済みコンテンツから新しいサンドボックスセッションを初期化したりできます。 +- `SandboxAgent` はエージェント自体を定義します。これには、通常のエージェント設定に加えて、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト、およびファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能が含まれます。 +- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースに必要な初期コンテンツとレイアウトを宣言します。 +- サンドボックスセッションは、コマンドが実行され、ファイルが変更される、稼働中の分離された環境です。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションを直接注入する、シリアライズ済みのサンドボックスセッション状態から再接続する、サンドボックスクライアントを介して新しいサンドボックスセッションを作成するなど、実行がそのサンドボックスセッションを取得する方法を決定します。 +- 保存済みのサンドボックス状態とスナップショットにより、後続の実行で以前の作業に再接続したり、保存済みコンテンツから新しいサンドボックスセッションを初期化したりできます。 -`Manifest` は新規セッションのワークスペース契約であり、稼働中の各サンドボックスに関する完全な信頼できる情報源ではありません。実行における有効なワークスペースは、再利用されたサンドボックスセッション、シリアライズされたサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合があります。 +`Manifest` は、新規セッションのワークスペース契約であり、稼働中のすべてのサンドボックスに対する完全な信頼できる唯一の情報源ではありません。実行に有効なワークスペースは、再利用されたサンドボックスセッション、シリアライズ済みのサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合があります。 -このページでは、「サンドボックスセッション」とは、サンドボックスクライアントによって管理される稼働中の実行環境を指します。これは、[セッション](../sessions/index.md)で説明されている SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 +このページ全体で「サンドボックスセッション」とは、サンドボックスクライアントによって管理される稼働中の実行環境を指します。これは、[セッション](../sessions/index.md)で説明されている SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 -外側のランタイムは引き続き、承認、トレーシング、ハンドオフ、および実行の再開に必要な状態の追跡を担当します。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担当します。この分離は、モデルの中核をなす要素です。 +外側のランタイムは、引き続き承認、トレーシング、ハンドオフ、および実行の再開に必要な状態の追跡を担います。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担います。この役割分担は、このモデルの中核を成します。 ### 各要素の関係 -サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備し、稼働中のサンドボックスセッションにバインドし、後続の実行に備えて状態を保存できます。 +サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備して稼働中のサンドボックスセッションにバインドし、後続の実行用に状態を保存できます。 ```mermaid flowchart LR @@ -54,39 +54,39 @@ flowchart LR ライフサイクルは、次の 3 つのフェーズに分けて考えます。 -1. `SandboxAgent`、`Manifest`、および各種機能を使用して、エージェントと新規ワークスペース契約を定義します。 -2. サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を `Runner` に指定して、実行を開始します。 -3. ランナーが管理する `RunState`、明示的なサンドボックス `session_state`、または保存済みのワークスペーススナップショットから、後で処理を続行します。 +1. `SandboxAgent`、`Manifest`、および各種機能を使用して、エージェントと新規ワークスペースの契約を定義します。 +2. サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を `Runner` に渡して実行します。 +3. ランナーが管理する `RunState`、明示的なサンドボックス `session_state`、または保存済みワークスペーススナップショットから、後で処理を継続します。 -シェルアクセスがときどき使用するツールの 1 つにすぎない場合は、[ツールガイド](../tools.md)のホステッドシェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 +シェルアクセスをときどき使用する単なる 1 つのツールとして必要とする場合は、[ツールガイド](../tools.md)のホスト型シェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 ## 使用場面 サンドボックスエージェントは、次のようなワークスペース中心のワークフローに適しています。 -- コーディングとデバッグ。たとえば、GitHub リポジトリ内の課題報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 -- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォームの下書きを作成する場合 +- コーディングとデバッグ。たとえば、GitHub リポジトリ内の Issue 報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 +- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォームのドラフトを作成する場合 - ファイルに基づくレビューや分析。たとえば、回答前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 -- 分離されたマルチエージェントパターン。たとえば、各レビュアーやコーディング用サブエージェントに個別のワークスペースを与える場合 -- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正し、後で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 +- 分離されたマルチエージェントパターン。たとえば、各レビュー担当エージェントやコーディングサブエージェントに専用のワークスペースを割り当てる場合 +- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正し、後続の実行で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 -ファイルや、状態を保持して変更可能なファイルシステムへのアクセスが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスがときどき使用する機能の 1 つにすぎない場合は、ホステッドシェルを追加します。ワークスペース境界自体が機能の一部である場合は、サンドボックスエージェントを使用します。 +ファイルへのアクセスや、状態を持つ変更可能なファイルシステムが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスがときどき必要となる機能の 1 つにすぎない場合は、ホスト型シェルを追加します。ワークスペース境界自体が機能の一部である場合は、サンドボックスエージェントを使用します。 ## サンドボックスクライアントの選択 -macOS または Linux でのローカル開発には、`UnixLocalSandboxClient` から始めてください。Windows では、`DockerSandboxClient` またはホステッドプロバイダーを使用してください。サポートされている任意のプラットフォームで、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に移行し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーに移行してください。 +macOS または Linux でのローカル開発では、`UnixLocalSandboxClient` から始めてください。Windows では、`DockerSandboxClient` またはホスト型プロバイダーを使用します。サポート対象のどのプラットフォームでも、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に、プロバイダー管理の実行が必要な場合はホスト型プロバイダーに移行してください。 -ほとんどの場合、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントとそのオプションを変更しても、`SandboxAgent` の定義は同じままです。ローカル、Docker、ホステッド、リモートマウントのオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +ほとんどの場合、`SandboxAgent` の定義は変えずに、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 内のサンドボックスクライアントとそのオプションのみを変更します。ローカル、Docker、ホスト型、およびリモートマウントの各オプションについては、[サンドボックスクライアント](clients.md)を参照してください。 ## 中核要素
-| レイヤー | SDK の主要要素 | 回答する内容 | +| レイヤー | SDK の主要要素 | 回答する問い | | --- | --- | --- | -| エージェント定義 | `SandboxAgent`、`Manifest`、各種機能 | どのエージェントを実行し、どの新規セッション用ワークスペース契約から開始しますか? | -| サンドボックス実行 | `SandboxRunConfig`、サンドボックスクライアント、稼働中のサンドボックスセッション | この実行は稼働中のサンドボックスセッションをどのように取得し、作業はどこで実行されますか? | -| 保存済みサンドボックス状態 | `RunState` サンドボックスペイロード、`session_state`、スナップショット | このワークフローは以前のサンドボックス作業にどのように再接続し、保存済みコンテンツから新しいサンドボックスセッションをどのように初期化しますか? | +| エージェント定義 | `SandboxAgent`、`Manifest`、各種機能 | どのエージェントを実行し、どの新規セッション用ワークスペース契約から開始するか? | +| サンドボックス実行 | `SandboxRunConfig`、サンドボックスクライアント、稼働中のサンドボックスセッション | この実行はどのように稼働中のサンドボックスセッションを取得し、どこで処理を実行するか? | +| 保存済みサンドボックス状態 | `RunState` のサンドボックスペイロード、`session_state`、スナップショット | このワークフローは、以前のサンドボックス作業にどのように再接続し、保存済みコンテンツから新しいサンドボックスセッションをどのように初期化するか? |
@@ -94,38 +94,38 @@ SDK の主要要素は、次のように各レイヤーに対応します。
-| 要素 | 管理対象 | 確認する内容 | +| 要素 | 担当範囲 | 確認すべき問い | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を行い、どのデフォルト設定を引き継ぐ必要がありますか? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッション用ワークスペースのファイルとフォルダー | 実行開始時に、どのファイルとフォルダーがファイルシステム上に存在する必要がありますか? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、指示フラグメント、ランタイム動作をこのエージェントに関連付けますか? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションの取得元 | この実行では、サンドボックスセッションを注入、再開、作成のいずれで取得しますか? | -| [`RunState`][agents.run_state.RunState] | ランナーが管理する保存済みサンドボックス状態 | 以前のランナー管理ワークフローを再開し、そのサンドボックス状態を自動的に引き継ぎますか? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部ですでにシリアライズしたサンドボックス状態から再開しますか? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション向けに保存されたワークスペースコンテンツ | 新しいサンドボックスセッションを、保存済みのファイルや成果物から開始しますか? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を実行し、どのデフォルト設定を保持すべきか? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッションのワークスペースファイルとフォルダー | 実行開始時に、ファイルシステム上にどのファイルとフォルダーが存在すべきか? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、instructions の断片、またはランタイム動作をこのエージェントに関連付けるべきか? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションのソース | この実行ではサンドボックスセッションを注入、再開、または作成すべきか? | +| [`RunState`][agents.run_state.RunState] | ランナー管理の保存済みサンドボックス状態 | 以前のランナー管理ワークフローを再開し、そのサンドボックス状態を自動的に引き継いでいるか? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部ですでにシリアライズしたサンドボックス状態から再開するか? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション用に保存されたワークスペースコンテンツ | 新しいサンドボックスセッションを保存済みのファイルや成果物から開始するか? |
実用的な設計順序は次のとおりです。 -1. `Manifest` で新規セッション用ワークスペース契約を定義します。 -2. `SandboxAgent` でエージェントを定義します。 -3. 組み込み機能またはカスタム機能を追加します。 +1. `Manifest` を使用して、新規セッションのワークスペース契約を定義します。 +2. `SandboxAgent` を使用して、エージェントを定義します。 +3. 組み込みまたはカスタムの機能を追加します。 4. `RunConfig(sandbox=SandboxRunConfig(...))` で、各実行がサンドボックスセッションを取得する方法を決定します。 ## サンドボックス実行の準備 -実行時に、ランナーはその定義を具体的なサンドボックスベースの実行に変換します。 +実行時に、ランナーは定義を具体的なサンドボックスベースの実行に変換します。 -1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡した場合は、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 -2. 実行に対して有効なワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは 1 回限りのマニフェストオーバーライドまたは `agent.default_manifest` から開始します。このため、`Manifest` だけでは、すべての実行における最終的な稼働中ワークスペースは定義されません。 -3. 各機能が、結果として得られたマニフェストを処理できるようにします。これにより、最終的なエージェントが準備される前に、各機能がファイル、マウント、その他のワークスペーススコープの動作を追加できます。 -4. 最終的な指示を固定順序で構築します。最初に SDK のデフォルトサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、機能の指示フラグメント、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 -5. 機能のツールを稼働中のサンドボックスセッションにバインドし、通常の `Runner` API を介して準備済みのエージェントを実行します。 +1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡すと、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 +2. 実行に有効なワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、その既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは 1 回限りのマニフェストオーバーライドまたは `agent.default_manifest` から開始します。そのため、`Manifest` だけでは、すべての実行における最終的な稼働中ワークスペースは定義されません。 +3. 各機能に、生成されたマニフェストを処理させます。これにより、最終的なエージェントの準備前に、機能がファイル、マウント、またはその他のワークスペーススコープの動作を追加できます。 +4. 最終的な instructions を固定順序で構築します。まず SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドする場合は `base_instructions`、次に `instructions`、機能の instructions 断片、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 +5. 機能のツールを稼働中のサンドボックスセッションにバインドし、通常の `Runner` API を介して準備済みエージェントを実行します。 -サンドボックス化によってターンの意味が変わることはありません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックス操作ではありません。サンドボックス側の操作とターンの間に固定された 1 対 1 の対応関係はありません。一部の作業はサンドボックス実行レイヤー内にとどまる場合があり、その他のアクションではツールの実行結果、承認、別の種類の状態など、次のモデルステップを必要とする情報が返されます。実用上は、サンドボックスでの作業後にエージェントランタイムが別のモデル応答を必要とする場合にのみ、もう 1 ターン消費されます。 +サンドボックス化によって、ターンの意味は変わりません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックスアクションではありません。サンドボックス側の操作とターンの間に固定された 1:1 の対応関係はありません。一部の処理はサンドボックス実行レイヤー内で完結する場合がありますが、別のアクションでは、ツールの実行結果、承認、その他の状態など、追加のモデルステップを必要とする情報が返されます。実用上は、サンドボックスで処理が行われた後、エージェントランタイムが別のモデル応答を必要とする場合にのみ、追加のターンが消費されます。 -これらの準備ステップがあるため、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` は、`SandboxAgent` を設計する際に検討すべき主要なサンドボックス固有オプションです。 +これらの準備ステップがあるため、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` は、`SandboxAgent` を設計する際に考慮すべき主要なサンドボックス固有オプションです。 ## `SandboxAgent` のオプション @@ -136,52 +136,52 @@ SDK の主要要素は、次のように各レイヤーに対応します。 | オプション | 最適な用途 | | --- | --- | | `default_manifest` | ランナーが作成する新しいサンドボックスセッションのデフォルトワークスペース。 | -| `instructions` | SDK のサンドボックスプロンプトの後に追加される、役割、ワークフロー、成功基準。 | +| `instructions` | SDK のサンドボックスプロンプトの後に追加される、役割、ワークフロー、成功条件。 | | `base_instructions` | SDK のサンドボックスプロンプトを置き換える高度なエスケープハッチ。 | -| `capabilities` | このエージェントとともに引き継ぐ必要があるサンドボックスネイティブのツールと動作。 | -| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けサンドボックスツールのユーザー ID。 | +| `capabilities` | このエージェントに付随させるサンドボックスネイティブのツールと動作。 | +| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けサンドボックスツール用のユーザー ID。 | -サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェント上ではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に指定します。 +サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェントではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に設定します。 ### `default_manifest` -`default_manifest` は、ランナーがこのエージェント用に新しいサンドボックスセッションを作成するときに使用される、デフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。エージェントが通常開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 +`default_manifest` は、ランナーがこのエージェント用に新しいサンドボックスセッションを作成するときに使用するデフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。通常、エージェントが開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 -これはデフォルトにすぎません。実行では `SandboxRunConfig(manifest=...)` を使用してオーバーライドでき、再利用または再開されたサンドボックスセッションは既存のワークスペース状態を維持します。 +これはデフォルトにすぎません。実行時に `SandboxRunConfig(manifest=...)` でオーバーライドでき、再利用または再開されたサンドボックスセッションは既存のワークスペース状態を維持します。 ### `instructions` と `base_instructions` -異なるプロンプトでも維持する必要がある短いルールには、`instructions` を使用します。`SandboxAgent` では、これらの指示が SDK のサンドボックス基本プロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しながら、独自の役割、ワークフロー、成功基準を追加できます。 +異なるプロンプト間でも維持すべき短いルールには、`instructions` を使用します。`SandboxAgent` では、これらの instructions が SDK のサンドボックス基本プロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しつつ、独自の役割、ワークフロー、成功条件を追加できます。 -SDK のサンドボックス基本プロンプトを置き換える場合にのみ、`base_instructions` を使用します。ほとんどのエージェントでは設定しないでください。 +SDK のサンドボックス基本プロンプトを置き換える場合にのみ、`base_instructions` を使用してください。ほとんどのエージェントでは設定しないでください。
-| 配置先 | 用途 | 例 | +| 設定先 | 用途 | 例 | | --- | --- | --- | -| `instructions` | エージェントの安定した役割、ワークフロールール、成功基準。 | 「オンボーディング書類を確認してから、ハンドオフしてください。」、「最終ファイルを `output/` に書き込んでください。」 | +| `instructions` | エージェントの安定した役割、ワークフロールール、成功条件。 | 「オンボーディング書類を調査してから、ハンドオフする。」「最終ファイルを `output/` に書き込む。」 | | `base_instructions` | SDK のサンドボックス基本プロンプトの完全な置き換え。 | カスタムの低レベルサンドボックスラッパープロンプト。 | -| ユーザープロンプト | この実行に対する 1 回限りのリクエスト。 | 「このワークスペースを要約してください。」 | -| マニフェスト内のワークスペースファイル | より長いタスク仕様、リポジトリローカルの指示、範囲を限定した参照資料。 | `repo/task.md`、ドキュメントバンドル、サンプルパケット。 | +| ユーザープロンプト | この実行固有のリクエスト。 | 「このワークスペースを要約してください。」 | +| マニフェスト内のワークスペースファイル | 長いタスク仕様、リポジトリローカルの instructions、または範囲を限定した参考資料。 | `repo/task.md`、ドキュメントバンドル、サンプル資料。 |
`instructions` の適切な使用例は次のとおりです。 -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY 状態が重要な場合にエージェントを単一の対話型プロセス内に維持します。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスレビュアーが確認後にユーザーへ直接回答することを禁止します。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的に記入されたファイルが実際に `output/` に配置されることを求めます。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY の状態が重要な場合に、エージェントを単一の対話型プロセス内に維持します。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスレビュー担当エージェントが調査後にユーザーへ直接回答することを禁止します。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的な記入済みファイルが実際に `output/` に配置されることを必須とします。 - [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) では、正確な検証コマンドを固定し、ワークスペースルート相対のパッチパスを明確にします。 -ユーザーの 1 回限りのタスクを `instructions` にコピーすること、マニフェストに含めるべき長い参照資料を埋め込むこと、組み込み機能がすでに注入しているツールドキュメントを繰り返すこと、実行時にモデルが必要としないローカルインストール情報を混在させることは避けてください。 +ユーザーの 1 回限りのタスクを `instructions` にコピーすること、マニフェストに含めるべき長い参考資料を埋め込むこと、組み込み機能がすでに注入するツールドキュメントを繰り返すこと、モデルが実行時に必要としないローカルインストールの注意事項を混在させることは避けてください。 -`instructions` を省略しても、SDK にはデフォルトのサンドボックスプロンプトが含まれます。低レベルのラッパーにはそれで十分ですが、ほとんどのユーザー向けエージェントでは、引き続き明示的な `instructions` を指定する必要があります。 +`instructions` を省略しても、SDK にはデフォルトのサンドボックスプロンプトが含まれます。低レベルのラッパーにはそれで十分ですが、ユーザー向けエージェントの大半では、引き続き明示的な `instructions` を指定する必要があります。 ### `capabilities` -各機能は、サンドボックスネイティブの動作を `SandboxAgent` に関連付けます。実行開始前にワークスペースを構成し、サンドボックス固有の指示を追加し、稼働中のサンドボックスセッションにバインドされるツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 +機能は、サンドボックスネイティブの動作を `SandboxAgent` に関連付けます。実行開始前にワークスペースを構成し、サンドボックス固有の instructions を追加し、稼働中のサンドボックスセッションにバインドされるツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 組み込み機能には次のものがあります。 @@ -189,59 +189,59 @@ SDK のサンドボックス基本プロンプトを置き換える場合にの | 機能 | 追加する場合 | 注記 | | --- | --- | --- | -| `Shell` | エージェントがシェルアクセスを必要とする場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 操作をサポートする場合は `write_stdin` も追加します。 | -| `Filesystem` | エージェントがファイルを編集したり、ローカル画像を確認したりする必要がある場合。 | `apply_patch` と `view_image` を追加します。パッチパスはワークスペースルート相対です。 | -| `Skills` | サンドボックス内でスキルを検出し、実体化する場合。 | `.agents` や `.agents/skills` を手動でマウントするよりも、こちらを推奨します。`Skills` がスキルをインデックス化し、サンドボックス内に実体化します。 | -| `Memory` | 後続の実行でメモリ成果物を読み取るか生成する場合。 | `Shell` が必要です。実行中にメモリ成果物を更新するには、`Filesystem` も必要です。 | +| `Shell` | エージェントがシェルアクセスを必要とする場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 対話をサポートする場合は `write_stdin` も追加します。 | +| `Filesystem` | エージェントがファイルの編集やローカル画像の調査を必要とする場合。 | `apply_patch` と `view_image` を追加します。パッチパスはワークスペースルート相対です。 | +| `Skills` | サンドボックス内でスキルを検出し、実体化する場合。 | `.agents` や `.agents/skills` を手動でマウントするよりも、こちらを推奨します。`Skills` がスキルのインデックスを作成し、サンドボックス内に実体化します。 | +| `Memory` | 後続の実行でメモリ成果物を読み取る、または生成する場合。 | `Shell` が必要です。実行中にメモリ成果物を更新する場合は、`Filesystem` も必要です。 | | `Compaction` | 長時間実行されるフローで、コンパクション項目の後にコンテキストを削減する必要がある場合。 | モデルのサンプリングと入力処理を調整します。 | デフォルトでは、`SandboxAgent.capabilities` は `Capabilities.default()` を使用し、これには `Filesystem()`、`Shell()`、`Compaction()` が含まれます。`capabilities=[...]` を渡すと、そのリストがデフォルトを置き換えるため、引き続き必要なデフォルト機能を含めてください。 -スキルについては、実体化する方法に応じて取得元を選択します。 +スキルについては、実体化する方法に応じてソースを選択します。 -- `Skills(lazy_from=LocalDirLazySkillSource(...))` は、モデルが最初にインデックスを検出し、必要なものだけを読み込めるため、規模の大きなローカルスキルディレクトリに適したデフォルトです。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージまたはワークスペース内にのみ存在するパスではなく、ホスト側の元のスキルディレクトリを渡してください。 +- `Skills(lazy_from=LocalDirLazySkillSource(...))` は、モデルが最初にインデックスを検出し、必要なものだけを読み込めるため、大規模なローカルスキルディレクトリに適したデフォルトです。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージまたはワークスペース内にしか存在しないパスではなく、元のホスト側スキルディレクトリを渡してください。 - `Skills(from_=LocalDir(src=...))` は、事前にステージングする小規模なローカルバンドルに適しています。 - `Skills(from_=GitRepo(repo=..., ref=...))` は、スキル自体をリポジトリから取得する場合に適しています。 `LocalDir.src` は SDK ホスト上のソースパスです。`skills_path` は、`load_skill` が呼び出されたときにスキルがステージングされる、サンドボックスワークスペース内の相対的な宛先パスです。 -スキルがすでに `.agents/skills//SKILL.md` のような場所のディスク上に存在する場合は、`LocalDir(...)` でそのソースルートを指定し、公開には引き続き `Skills(...)` を使用します。サンドボックス内の異なるレイアウトに依存する既存のワークスペース契約がない限り、デフォルトの `skills_path=".agents"` を維持してください。 +スキルがすでに `.agents/skills//SKILL.md` のような場所に保存されている場合は、`LocalDir(...)` にそのソースルートを指定し、引き続き `Skills(...)` を使用して公開します。別のサンドボックス内レイアウトに依存する既存のワークスペース契約がない限り、デフォルトの `skills_path=".agents"` を維持してください。 -適合する場合は、組み込み機能を優先してください。組み込み機能で対応できないサンドボックス固有のツールや指示インターフェースが必要な場合にのみ、カスタム機能を作成してください。 +適合する場合は、組み込み機能を優先してください。組み込み機能では対応できないサンドボックス固有のツールまたは instructions インターフェースが必要な場合にのみ、カスタム機能を作成します。 ## 概念 ### マニフェスト -[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` を設定し、ファイルやディレクトリを宣言し、ローカルファイルをコピーし、Git リポジトリをクローンし、リモートストレージのマウントを接続し、環境変数を設定し、ユーザーやグループを定義し、ワークスペース外の特定の絶対パスへのアクセスを許可できます。 +[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーまたはグループの定義、ワークスペース外の特定の絶対パスへのアクセス許可を行えます。 -マニフェストエントリのパスは、ワークスペース相対です。絶対パスにすることや、`..` を使用してワークスペース外へ移動することはできません。これにより、ローカル、Docker、ホステッドクライアント間でワークスペース契約の移植性が維持されます。 +マニフェストエントリのパスは、ワークスペース相対です。絶対パスにすることも、`..` を使用してワークスペース外へ移動することもできません。これにより、ローカル、Docker、ホスト型クライアント間でワークスペース契約の移植性が維持されます。 -作業開始前にエージェントが必要とする素材には、マニフェストエントリを使用します。 +作業開始前にエージェントが必要とする資料には、マニフェストエントリを使用します。
| マニフェストエントリ | 用途 | | --- | --- | -| `File`、`Dir` | 小規模な合成入力、補助ファイル、出力ディレクトリ。 | -| `LocalFile`、`LocalDir` | サンドボックス内に実体化する必要があるホストのファイルやディレクトリ。 | -| `GitRepo` | ワークスペースに取得する必要があるリポジトリ。 | -| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` などのマウント | サンドボックス内に表示する必要がある外部ストレージ。 | +| `File`、`Dir` | 小規模な合成入力、補助ファイル、または出力ディレクトリ。 | +| `LocalFile`、`LocalDir` | サンドボックス内に実体化するホストのファイルまたはディレクトリ。 | +| `GitRepo` | ワークスペースに取得するリポジトリ。 | +| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` などのマウント | サンドボックス内に表示する外部ストレージ。 |
-`Dir` は、合成された子要素から、または出力先としてサンドボックスワークスペース内にディレクトリを作成します。ホストのファイルシステムから読み取るものではありません。既存のホストディレクトリをサンドボックスワークスペースにコピーする場合は、`LocalDir` を使用します。 +`Dir` は、合成された子要素から、または出力先として、サンドボックスワークスペース内にディレクトリを作成します。ホストファイルシステムからは読み取りません。既存のホストディレクトリをサンドボックスワークスペースにコピーする場合は、`LocalDir` を使用してください。 -デフォルトでは、`LocalFile.src` と `LocalDir.src` は SDK プロセスの作業ディレクトリを基準に解決されます。ソースは、`extra_path_grants` の対象でない限り、そのベースディレクトリ内に収める必要があります。これにより、ローカルソースの実体化が、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に維持されます。 +デフォルトでは、`LocalFile.src` と `LocalDir.src` は SDK プロセスの作業ディレクトリを基準に解決されます。`extra_path_grants` で許可されていない限り、ソースはそのベースディレクトリ内にある必要があります。これにより、ローカルソースの実体化が、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に維持されます。 マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。マウントオプションとプロバイダーのサポートについては、[サンドボックスクライアント](clients.md#mounts-and-remote-storage)を参照してください。 -適切なマニフェスト設計では通常、ワークスペース契約を限定的に保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、指示内で `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールでファイルを編集する場合、パッチパスはシェルの `workdir` ではなく、サンドボックスワークスペースのルートを基準とすることに注意してください。 +適切なマニフェスト設計では通常、ワークスペース契約を限定的に保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、instructions 内で `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールを使用してファイルを編集する場合、パッチパスはシェルの `workdir` ではなく、サンドボックスワークスペースルートからの相対パスであることに注意してください。 -エージェントがワークスペース外の具体的な絶対パスを必要とする場合、またはマニフェストが SDK プロセスの作業ディレクトリ外にある信頼済みのローカルソースをコピーする必要がある場合にのみ、`extra_path_grants` を使用します。たとえば、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内に実体化する生成済みスキルディレクトリなどがあります。許可は、ローカルソースの実体化と SDK のファイル API に適用されます。また、バックエンドがファイルシステムポリシーを適用できる場合は、シェル実行にも適用されます。 +エージェントがワークスペース外の具体的な絶対パスを必要とする場合、または SDK プロセスの作業ディレクトリ外にある信頼済みローカルソースをマニフェストでコピーする必要がある場合にのみ、`extra_path_grants` を使用します。たとえば、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内に実体化する生成済みスキルディレクトリなどです。許可は、ローカルソースの実体化と SDK ファイル API に適用されます。また、バックエンドがファイルシステムポリシーを適用できる場合は、シェル実行にも適用されます。 ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +254,17 @@ manifest = Manifest( ) ``` -Docker で別の絶対ホストパスをコンテナ内の絶対 POSIX `path` にバインドマウントする場合は、`host_path` を設定します。`UnixLocalSandboxClient` は両方のパスが同じであるパスのみの許可だけをサポートし、`host_path` を拒否します。サンドボックスで変更させないホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用します。 +Docker が別の絶対ホストパスを、コンテナ内の絶対 POSIX `path` にバインドマウントする必要がある場合は、`host_path` を設定します。`UnixLocalSandboxClient` は両方のパスが同じであるパスのみの許可をサポートし、`host_path` は拒否します。サンドボックスで変更してはならないホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用します。 -`extra_path_grants` を含むマニフェストは、信頼済みの設定として扱ってください。アプリケーションが該当するホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 +`extra_path_grants` を含むマニフェストは、信頼済みの設定として扱ってください。アプリケーションがそれらのホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 -スナップショットと `persist_workspace()` には、引き続きワークスペースルートだけが含まれます。追加で許可されたパスはランタイムアクセスであり、永続的なワークスペース状態ではありません。 +スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスはランタイムアクセスであり、永続的なワークスペース状態ではありません。 ### 権限 -`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これはサンドボックスが実体化するファイルに関する設定であり、モデルの権限、承認ポリシー、API 認証情報に関する設定ではありません。 +`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これは、サンドボックスが実体化するファイルに関するものであり、モデルの権限、承認ポリシー、API 認証情報に関するものではありません。 -デフォルトでは、マニフェストエントリについて、所有者には読み取り、書き込み、実行が許可され、グループとその他のユーザーには読み取りと実行が許可されます。ステージングされたファイルを非公開、読み取り専用、または実行可能にする必要がある場合は、これをオーバーライドします。 +デフォルトでは、マニフェストエントリは所有者が読み取り、書き込み、実行でき、グループとその他のユーザーが読み取り、実行できます。ステージングされたファイルを非公開、読み取り専用、または実行可能にする必要がある場合は、これをオーバーライドします。 ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +280,9 @@ private_notes = File( ) ``` -`Permissions` は、所有者、グループ、その他のユーザーそれぞれのビットと、エントリがディレクトリかどうかを格納します。直接構築するか、`Permissions.from_str(...)` を使用してモード文字列から解析するか、`Permissions.from_mode(...)` を使用して OS モードから取得できます。 +`Permissions` は、所有者、グループ、その他のユーザーの各ビットと、そのエントリがディレクトリであるかどうかを個別に保存します。直接構築するか、`Permissions.from_str(...)` でモード文字列から解析するか、`Permissions.from_mode(...)` で OS モードから取得できます。 -ユーザーは、作業を実行できるサンドボックス ID です。その ID をサンドボックス内に存在させる場合は、マニフェストに `User` を追加します。次に、シェルコマンド、ファイル読み取り、パッチなどのモデル向けサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指している場合、ランナーがそのユーザーを有効なマニフェストに追加します。 +ユーザーは、作業を実行できるサンドボックス ID です。その ID をサンドボックス内に存在させる場合は、マニフェストに `User` を追加します。シェルコマンド、ファイル読み取り、パッチなどのモデル向けサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指す場合、ランナーがそのユーザーを有効なマニフェストに追加します。 ```python from agents import Runner @@ -334,13 +334,13 @@ result = await Runner.run( ) ``` -ファイルレベルの共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` ユーザーは、サンドボックスネイティブのアクションを実行するユーザーを制御します。`Permissions` は、サンドボックスがワークスペースを実体化した後、そのユーザーがどのファイルを読み取り、書き込み、実行できるかを制御します。 +ファイル単位の共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` のユーザーはサンドボックスネイティブのアクションを実行するユーザーを制御し、`Permissions` は、サンドボックスがワークスペースを実体化した後、そのユーザーが読み取り、書き込み、実行できるファイルを制御します。 ### SnapshotSpec -`SnapshotSpec` は、新しいサンドボックスセッションに対して、保存済みのワークスペースコンテンツをどこから復元し、どこへ永続化するかを指定します。これはサンドボックスワークスペースのスナップショットポリシーです。一方、`session_state` は、特定のサンドボックスバックエンドを再開するためのシリアライズされた接続状態です。 +`SnapshotSpec` は、新しいサンドボックスセッションに対して、保存済みワークスペースコンテンツの復元元と保存先を指定します。これはサンドボックスワークスペースのスナップショットポリシーであり、`session_state` は特定のサンドボックスバックエンドを再開するためのシリアライズ済み接続状態です。 -ローカルで永続化されるスナップショットには `LocalSnapshotSpec` を使用し、アプリがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。ワークスペーススナップショットの永続化を必要としない高度な呼び出し元は、これを明示的に使用することもできます。 +ローカルの永続的なスナップショットには `LocalSnapshotSpec` を使用し、アプリケーションがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。高度な呼び出し元は、ワークスペーススナップショットの永続化が不要な場合に、これを明示的に使用できます。 ```python from pathlib import Path @@ -357,13 +357,13 @@ run_config = RunConfig( ) ``` -ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントがそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、サンドボックスは実行を続行する前に保存済みのワークスペースコンテンツを復元します。クリーンアップ時には、ランナーが所有するサンドボックスセッションがワークスペースをアーカイブし、スナップショットを介して再び永続化します。 +ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントはそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、サンドボックスは実行を続行する前に保存済みワークスペースコンテンツを復元します。クリーンアップ時には、ランナー所有のサンドボックスセッションがワークスペースをアーカイブし、スナップショットを介して再度永続化します。 -`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存場所を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時的なパスは、永続的なワークスペースコンテンツとしてスナップショットにコピーされません。 +`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット場所を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時的なパスは、永続的なワークスペースコンテンツとしてスナップショットにコピーされません。 ### サンドボックスのライフサイクル -ライフサイクルには、**SDK 所有**と**開発者所有**の 2 つのモードがあります。 +ライフサイクルには、 **SDK 所有** と **開発者所有** の 2 つのモードがあります。
@@ -391,7 +391,7 @@ sequenceDiagram
-サンドボックスを 1 回の実行中だけ存続させる必要がある場合は、SDK 所有のライフサイクルを使用します。`client`、必要に応じて `manifest` と `snapshot`、および必要なクライアントの `options` を渡します。ランナーはサンドボックスを作成または再開し、起動してエージェントを実行し、スナップショットベースのワークスペース状態を永続化し、サンドボックスセッションを終了して、ランナーが所有するリソースをクライアントにクリーンアップさせます。 +サンドボックスが 1 回の実行中のみ存在すればよい場合は、SDK 所有のライフサイクルを使用します。`client`、必要に応じて `manifest` と `snapshot`、および必要なクライアントの `options` を渡します。ランナーはサンドボックスを作成または再開して開始し、エージェントを実行し、スナップショットベースのワークスペース状態を永続化し、サンドボックスセッションを終了して、ランナー所有のリソースをクライアントにクリーンアップさせます。 ```python result = await Runner.run( @@ -403,7 +403,7 @@ result = await Runner.run( ) ``` -サンドボックスを事前に作成する場合、稼働中の 1 つのサンドボックスを複数の実行で再利用する場合、実行後にファイルを確認する場合、自分で作成したサンドボックス上でストリーミングする場合、またはクリーンアップのタイミングを正確に決める場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中のサンドボックスを使用しますが、代わりに閉じることはありません。 +サンドボックスを事前に作成する、1 つの稼働中サンドボックスを複数の実行で再利用する、実行後にファイルを調査する、自分で作成したサンドボックス上でストリーミングする、クリーンアップの正確なタイミングを決定する場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中サンドボックスを使用しますが、代わりに閉じることはありません。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 +通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリケーションでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 ```python sandbox = await client.create( @@ -435,36 +435,36 @@ finally: await sandbox.aclose() ``` -`stop()` は、スナップショットベースのワークスペースコンテンツだけを永続化し、サンドボックスを終了しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースを停止し、セッションスコープの依存関係を閉じます。 +`stop()` は、スナップショットベースのワークスペースコンテンツを永続化するだけで、サンドボックスを終了しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースをシャットダウンし、セッションスコープの依存関係を閉じます。 ## `SandboxRunConfig` のオプション [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションの取得元と、新しいセッションの初期化方法を決定する実行ごとのオプションを保持します。 -### サンドボックスの取得元 +### サンドボックスのソース -次のオプションは、ランナーがサンドボックスセッションを再利用、再開、作成のいずれで取得するかを決定します。 +次のオプションは、ランナーがサンドボックスセッションを再利用、再開、または作成するかどうかを決定します。
| オプション | 使用する場合 | 注記 | | --- | --- | --- | | `client` | ランナーにサンドボックスセッションの作成、再開、クリーンアップを任せる場合。 | 稼働中のサンドボックス `session` を指定しない限り必須です。 | -| `session` | 稼働中のサンドボックスセッションを自分ですでに作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中のサンドボックスセッションを再利用します。 | -| `session_state` | シリアライズされたサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。ランナーはその明示的な状態から再開し、再開されたセッションのライフサイクルを所有します。 | +| `session` | 稼働中のサンドボックスセッションをすでに自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中サンドボックスセッションを再利用します。 | +| `session_state` | シリアライズ済みのサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。ランナーはその明示的な状態から再開し、再開されたセッションのライフサイクルを所有します。 |
実際には、ランナーは次の順序でサンドボックスセッションを解決します。 -1. `run_config.sandbox.session` を注入した場合、その稼働中のサンドボックスセッションを直接再利用します。 -2. それ以外で、`RunState` から実行を再開する場合は、保存されているサンドボックスセッション状態を再開します。 -3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的にシリアライズされたサンドボックスセッション状態から再開します。 -4. それ以外の場合、ランナーは新しいサンドボックスセッションを作成します。その新しいセッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 +1. `run_config.sandbox.session` を注入すると、その稼働中のサンドボックスセッションを直接再利用します。 +2. それ以外で、実行が `RunState` から再開される場合は、保存されているサンドボックスセッション状態を再開します。 +3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的なシリアライズ済みサンドボックスセッション状態から再開します。 +4. それ以外の場合、ランナーは新しいサンドボックスセッションを作成します。その新規セッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 ### 新規セッションの入力 -次のオプションは、ランナーが新しいサンドボックスセッションを作成する場合にのみ関係します。 +次のオプションは、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。
@@ -472,27 +472,27 @@ finally: | --- | --- | --- | | `manifest` | 新規セッションのワークスペースを 1 回限りでオーバーライドする場合。 | 省略すると `agent.default_manifest` にフォールバックします。 | | `snapshot` | 新しいサンドボックスセッションをスナップショットから初期化する場合。 | 再開に似たフローやリモートスナップショットクライアントに便利です。 | -| `options` | サンドボックスクライアントが作成時のオプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウトなど、クライアント固有の設定で一般的です。 | +| `options` | サンドボックスクライアントが作成時のオプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウト、および同様のクライアント固有設定で一般的です。 |
### 実体化の制御 -`concurrency_limits` は、並列実行できるサンドボックス実体化処理の量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。いずれかの値を `None` に設定すると、その特定の制限を無効にできます。 +`concurrency_limits` は、並列で実行できるサンドボックス実体化処理の量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。いずれかの値を `None` に設定すると、その制限のみが無効になります。 -`archive_limits` は、アーカイブ展開に対する SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定し、アーカイブに対してより厳密なリソース制御が必要な場合は `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` などの明示的な値を渡します。SDK のアーカイブリソース制限がないデフォルト動作を維持するには `archive_limits=None` のままにし、特定の制限だけを無効にするには個々のフィールドを `None` に設定します。 +`archive_limits` は、アーカイブ抽出に対する SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定し、アーカイブにより厳密なリソース制御が必要な場合は `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` などの明示的な値を渡します。SDK のアーカイブリソース制限がないデフォルト動作を維持するには `archive_limits=None` のままにし、個別の制限のみを無効にするには、そのフィールドを `None` に設定します。 次の点に注意してください。 -- 新規セッション:`manifest=` と `snapshot=` は、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 -- 再開とスナップショット:`session_state=` は以前にシリアライズされたサンドボックス状態に再接続します。一方、`snapshot=` は保存済みのワークスペースコンテンツから新しいサンドボックスセッションを初期化します。 -- クライアント固有のオプション:`options=` はサンドボックスクライアントによって異なります。Docker と多くのホステッドクライアントでは必須です。 -- 注入された稼働中セッション:実行中のサンドボックス `session` を渡した場合、機能によるマニフェスト更新で、互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリ型の置き換え、マウントエントリの追加や変更はできません。 -- ランナー API:`SandboxAgent` の実行では、引き続き通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 +- 新規セッション: `manifest=` と `snapshot=` は、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 +- 再開とスナップショット: `session_state=` は以前にシリアライズされたサンドボックス状態に再接続しますが、`snapshot=` は保存済みワークスペースコンテンツから新しいサンドボックスセッションを初期化します。 +- クライアント固有オプション: `options=` はサンドボックスクライアントに依存します。Docker と多くのホスト型クライアントでは必須です。 +- 注入された稼働中セッション: 稼働中のサンドボックス `session` を渡した場合、機能によるマニフェスト更新で、互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリタイプの置き換え、マウントエントリの追加または変更はできません。 +- ランナー API: `SandboxAgent` の実行でも、通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 -## 完全な例:コーディングタスク +## 完全なコード例:コーディングタスク -このコーディング形式の例は、適切なデフォルトの開始点です。 +次のコーディング形式のコード例は、デフォルトの出発点として適しています。 ```python import asyncio @@ -571,19 +571,19 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。この例では、Unix ローカル実行間で決定論的に検証できるよう、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリには、もちろん Python、JavaScript、その他の任意のものを使用できます。 +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。このコード例では、Unix ローカル実行間で決定論的に検証できるように、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリには、もちろん Python、JavaScript、その他任意のものを使用できます。 ## 一般的なパターン -上記の完全な例から始めてください。多くの場合、サンドボックスクライアント、サンドボックスセッションの取得元、またはワークスペースの取得元だけを変更し、同じ `SandboxAgent` をそのまま維持できます。 +上記の完全なコード例から始めてください。多くの場合、同じ `SandboxAgent` を維持したまま、サンドボックスクライアント、サンドボックスセッションのソース、またはワークスペースのソースだけを変更できます。 ### サンドボックスクライアントの切り替え -エージェント定義はそのまま維持し、実行設定だけを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +エージェント定義を変えずに、実行設定のみを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を、プロバイダー管理の実行が必要な場合はホスト型プロバイダーを使用します。コード例とプロバイダーのオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 ### ワークスペースのオーバーライド -エージェント定義はそのまま維持し、新規セッションのマニフェストだけを置き換えます。 +エージェント定義を変えずに、新規セッションのマニフェストのみを入れ替えます。 ```python from agents.run import RunConfig @@ -603,11 +603,11 @@ run_config = RunConfig( ) ``` -エージェントを再構築せず、同じエージェントの役割を異なるリポジトリ、パケット、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディング例では、1 回限りのオーバーライドの代わりに `default_manifest` を使用して同じパターンを示しています。 +エージェントを再構築せずに、同じエージェントの役割を異なるリポジトリ、資料、またはタスクバンドルに対して実行する場合に使用します。上記の検証済みコーディングコード例では、1 回限りのオーバーライドではなく `default_manifest` を使用して、同じパターンを示しています。 ### サンドボックスセッションの注入 -ライフサイクルの明示的な制御、実行後の確認、または出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 +ライフサイクルの明示的な制御、実行後の調査、または出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 ```python from agents import Runner @@ -628,11 +628,11 @@ async with sandbox: ) ``` -実行後にワークスペースを確認する場合や、すでに起動しているサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) および [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 +実行後にワークスペースを調査する場合や、すでに開始済みのサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) と [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 ### セッション状態からの再開 -`RunState` の外部ですでにサンドボックス状態をシリアライズしている場合は、その状態からランナーを再接続させます。 +`RunState` の外部ですでにサンドボックス状態をシリアライズしている場合は、その状態からランナーを再接続します。 ```python from agents.run import RunConfig @@ -649,13 +649,15 @@ run_config = RunConfig( ) ``` -サンドボックス状態を独自のストレージやジョブシステムに保存し、`Runner` でその状態から直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) を参照してください。 +サンドボックス状態を独自のストレージやジョブシステムに保存し、`Runner` でそこから直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) を参照してください。 -セッション状態のシリアライズでは、ネイティブの `host_path` 値が省略されます。ホストベースの許可を再開するには、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` で指定してください。指定しない場合、サンドボックスが起動する前に再開が失敗します。シリアライズされた入力やその他の信頼できない入力から、ホストパスを決して生成しないでください。 +セッション状態のシリアライズでは、ネイティブの `host_path` 値が省略されます。ホストベースの許可を再開するには、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` から指定してください。指定しない場合、サンドボックスの開始前に再開が失敗します。シリアライズ済み入力やその他の信頼できない入力からホストパスを生成しないでください。 + +セッション状態と `RunState` のシリアライズでは、クラウドマウントの認証情報、認証情報を含む補助設定、コンテナ内での認証情報公開に対する確認も削除されます。マウント済みセッションの再開をサポートするバックエンドでは、状態に秘匿化されたマウント権限が含まれる場合、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` から指定してください。`"data"` という名前のマウントエントリにマウントスコープの確認が必要な場合は、再開前に `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` を使用して、コピーされたマニフェストを保持します。広範な権限には `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")` を使用し、マウントで両方の権限クラスを使用する場合は両方のメソッドを呼び出します。確認が必要な正確なマウントパスをすべて渡してください。Agents SDKは、現在の信頼済みマニフェストの認証情報を除いたマウントトポロジーが、永続化された状態と完全に一致する場合にのみ認証情報を復元します。信頼済み設定が不足している、または一致しない場合、サンドボックスの開始前に再開が失敗します。シリアライズ済み状態だけで権限が付与されることはありません。`VercelSandboxClient` はマウント済みセッションを再開できないため、代わりに信頼済みマニフェストを使用して新しいサンドボックスを開始してください。 ### スナップショットからの開始 -保存済みのファイルや成果物から新しいサンドボックスを初期化します。 +保存済みのファイルと成果物から新しいサンドボックスを初期化します。 ```python from pathlib import Path @@ -672,11 +674,11 @@ run_config = RunConfig( ) ``` -新しいサンドボックスセッションを作成する実行で、`agent.default_manifest` だけではなく、保存済みのワークスペースコンテンツから開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 +新しいサンドボックスセッションを作成する実行で、`agent.default_manifest` だけでなく、保存済みワークスペースコンテンツから開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 -### Git からのスキルの読み込み +### Git からのスキル読み込み -ローカルのスキル取得元を、リポジトリベースの取得元に置き換えます。 +ローカルのスキルソースを、リポジトリベースのソースに置き換えます。 ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -687,11 +689,11 @@ capabilities = Capabilities.default() + [ ] ``` -スキルバンドルに独自のリリースサイクルがある場合や、複数のサンドボックス間で共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 +スキルバンドルに独自のリリースサイクルがある場合や、複数のサンドボックスで共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 ### ツールとしての公開 -ツールエージェントには、独自のサンドボックス境界を与えることも、親の実行から稼働中のサンドボックスを再利用させることもできます。再利用は、高速な読み取り専用の探索エージェントに便利です。別のサンドボックスを作成、ハイドレーション、スナップショットするコストをかけずに、親の実行が使用しているものとまったく同じワークスペースを確認できます。 +ツールエージェントには、独自のサンドボックス境界を割り当てることも、親実行の稼働中サンドボックスを再利用させることもできます。再利用は、高速な読み取り専用エクスプローラーエージェントに便利です。別のサンドボックスの作成、ハイドレーション、スナップショット作成のコストをかけずに、親実行が使用しているワークスペースそのものを調査できます。 ```python from agents import Runner @@ -773,9 +775,9 @@ async with sandbox: ) ``` -ここでは、親エージェントは `coordinator` として実行され、探索ツールエージェントは同じ稼働中のサンドボックスセッション内で `explorer` として実行されます。`pricing_packet/` のエントリは `other` ユーザーが読み取れるため、探索エージェントはすばやく確認できますが、書き込みビットはありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループだけが利用できるため、探索エージェントを読み取り専用に保ちながら、親は最終成果物を書き込めます。 +ここでは、親エージェントが同じ稼働中サンドボックスセッション内で `coordinator` として実行され、エクスプローラーツールエージェントが `explorer` として実行されます。`pricing_packet/` エントリは `other` ユーザーが読み取り可能なため、エクスプローラーはすばやく調査できますが、書き込みビットはありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループのみが使用できるため、エクスプローラーを読み取り専用に維持したまま、親が最終成果物を書き込めます。 -ツールエージェントに実際の分離が必要な場合は、独自のサンドボックス `RunConfig` を与えます。 +ツールエージェントに実際の分離が必要な場合は、独自のサンドボックス `RunConfig` を割り当てます。 ```python from docker import from_env as docker_from_env @@ -801,11 +803,11 @@ rollout_agent.as_tool( ) ``` -ツールエージェントが自由に変更を行う場合、信頼できないコマンドを実行する場合、または異なるバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 +ツールエージェントが自由に変更を加える、信頼できないコマンドを実行する、または別のバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 ### ローカルツールおよび MCP との組み合わせ -サンドボックスワークスペースを維持しながら、同じエージェントで通常のツールも使用します。 +サンドボックスワークスペースを維持したまま、同じエージェントで通常のツールも使用します。 ```python from agents.sandbox import SandboxAgent @@ -820,46 +822,46 @@ agent = SandboxAgent( ) ``` -ワークスペースの確認がエージェントの仕事の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 +ワークスペースの調査がエージェントの作業の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 ## メモリ -今後のサンドボックスエージェント実行で以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは、SDK の会話用 `Session` メモリとは別のものです。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 +将来のサンドボックスエージェント実行で、以前の実行から学習させる場合は、`Memory` 機能を使用します。メモリは、SDK の会話用 `Session` メモリとは別のものです。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそれらのファイルを読み取れるようにします。 -設定、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 +セットアップ、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 ## 構成パターン -単一エージェントのパターンを理解した後は、より大規模なシステム内のどこにサンドボックス境界を配置するかを検討します。 +単一エージェントのパターンを理解したら、次に検討すべき設計上の問いは、より大きなシステムのどこにサンドボックス境界を配置するかです。 -サンドボックスエージェントは、引き続き SDK の他の要素と組み合わせられます。 +サンドボックスエージェントは、引き続き SDK の他の機能と組み合わせられます。 -- [ハンドオフ](../handoffs.md):ドキュメント量の多い作業を、サンドボックスを使用しない受付エージェントからサンドボックスレビュアーへハンドオフします。 -- [Agents as tools](../tools.md#agents-as-tools):複数のサンドボックスエージェントをツールとして公開します。通常は、各ツールに独自のサンドボックス境界を与えるため、`Agent.as_tool(...)` の各呼び出しで `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡します。 -- [MCP](../mcp.md) と通常の関数ツール:サンドボックス機能は、`mcp_servers` および通常の Python ツールと共存できます。 -- [エージェントの実行](../running_agents.md):サンドボックス実行でも通常の `Runner` API を使用します。 +- [ハンドオフ](../handoffs.md): ドキュメント量の多い作業を、サンドボックスを使用しない受付エージェントからサンドボックスレビュー担当エージェントへハンドオフします。 +- [Agents as tools](../tools.md#agents-as-tools): 複数のサンドボックスエージェントをツールとして公開します。通常は各 `Agent.as_tool(...)` 呼び出しで `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡し、各ツールに独自のサンドボックス境界を割り当てます。 +- [MCP](../mcp.md) と通常の関数ツール: サンドボックス機能は、`mcp_servers` および通常の Python ツールと共存できます。 +- [エージェントの実行](../running_agents.md): サンドボックス実行でも、通常の `Runner` API を使用します。 -特に一般的なパターンは次の 2 つです。 +特に一般的なのは、次の 2 つのパターンです。 -- サンドボックスを使用しないエージェントから、ワークスペースの分離が必要なワークフロー部分だけをサンドボックスエージェントにハンドオフするパターン -- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は `Agent.as_tool(...)` の呼び出しごとに別のサンドボックス `RunConfig` を使用して、各ツールに独自の分離されたワークスペースを与えるパターン +- ワークスペースの分離が必要なワークフロー部分に限り、サンドボックスを使用しないエージェントからサンドボックスエージェントへハンドオフする +- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は `Agent.as_tool(...)` 呼び出しごとに個別のサンドボックス `RunConfig` を割り当て、各ツールに独自の分離されたワークスペースを提供する ### ターンとサンドボックス実行 -ハンドオフとエージェントをツールとして呼び出す場合は、分けて説明すると理解しやすくなります。 +ハンドオフと Agents-as-tools の呼び出しは、分けて説明すると理解しやすくなります。 -ハンドオフでは、トップレベルの実行とトップレベルのターンループはそれぞれ 1 つのままです。アクティブなエージェントは変わりますが、実行はネストされません。サンドボックスを使用しない受付エージェントがサンドボックスレビュアーにハンドオフすると、同じ実行内の次のモデル呼び出しがサンドボックスエージェント向けに準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフは、同じ実行の次のターンを担当するエージェントを変更します。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) を参照してください。 +ハンドオフでは、トップレベルの実行とトップレベルのターンループは 1 つのままです。アクティブなエージェントは変わりますが、実行がネストされることはありません。サンドボックスを使用しない受付エージェントがサンドボックスレビュー担当エージェントにハンドオフすると、同じ実行内の次のモデル呼び出しがサンドボックスエージェント向けに準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフは、同じ実行の次のターンを担当するエージェントを変更します。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) を参照してください。 -`Agent.as_tool(...)` では関係が異なります。外側のオーケストレーターは、ツールを呼び出すことを決定するために外側の 1 ターンを使用し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認があり、通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合もあれば、複数ターンかかる場合もあります。外側のオーケストレーターから見ると、これらの作業はすべて 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増えることはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 +`Agent.as_tool(...)` では、関係が異なります。外側のオーケストレーターは、ツールを呼び出すことを決定するために外側のターンを 1 つ使用し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認、通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合もあれば、複数ターンを要する場合もあります。外側のオーケストレーターから見ると、そのすべての処理は 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増えることはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 -承認の動作も同じように分かれます。 +承認の動作も同じ区分に従います。 -- ハンドオフでは、サンドボックスエージェントがその実行のアクティブなエージェントになるため、承認は同じトップレベルの実行上に維持されます。 -- `Agent.as_tool(...)` では、サンドボックスツールエージェント内で発生した承認も外側の実行に表示されますが、保存されたネスト済み実行状態から取得され、外側の実行が再開されるとネストされたサンドボックス実行も再開されます。 +- ハンドオフでは、サンドボックスエージェントがその実行のアクティブなエージェントになるため、承認は同じトップレベルの実行に維持されます +- `Agent.as_tool(...)` では、サンドボックスツールエージェント内で発生した承認も外側の実行に提示されますが、保存されたネスト済み実行状態から取得され、外側の実行が再開されるとネストされたサンドボックス実行も再開されます ## 関連資料 -- [クイックスタート](../sandbox_agents.md):サンドボックスエージェントを 1 つ実行します。 -- [サンドボックスクライアント](clients.md):ローカル、Docker、ホステッド、マウントのオプションを選択します。 -- [エージェントメモリ](memory.md):以前のサンドボックス実行から得た学習内容を保持して再利用します。 -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成の各パターンです。 \ No newline at end of file +- [クイックスタート](../sandbox_agents.md): サンドボックスエージェントを 1 つ実行します。 +- [サンドボックスクライアント](clients.md): ローカル、Docker、ホスト型、マウントの各オプションを選択します。 +- [エージェントメモリ](memory.md): 以前のサンドボックス実行で得られた学習内容を保持し、再利用します。 +- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成の各パターンです。 \ No newline at end of file diff --git a/docs/ja/sessions/index.md b/docs/ja/sessions/index.md index 2d8a3c62ed..e1a822215d 100644 --- a/docs/ja/sessions/index.md +++ b/docs/ja/sessions/index.md @@ -4,11 +4,11 @@ search: --- # セッション -Agents SDKには、複数回のエージェント実行にわたって会話履歴を自動的に維持する組み込みのセッションメモリが用意されており、ターン間で `.to_input_list()` を手動で処理する必要がありません。 +Agents SDK には、複数回のエージェント実行にわたって会話履歴を自動的に維持する組み込みのセッションメモリが用意されており、ターン間で `.to_input_list()` を手動管理する必要がなくなります。 -セッションは特定のセッションの会話履歴を保存するため、明示的にメモリを手動管理しなくても、エージェントはコンテキストを維持できます。これは、エージェントに以前のやり取りを記憶させたいチャットアプリケーションや、複数ターンの会話を構築する場合に特に便利です。 +セッションは特定のセッションの会話履歴を保存するため、明示的な手動のメモリ管理を必要とせずに、エージェントがコンテキストを維持できます。これは、エージェントに過去のやり取りを記憶させたいチャットアプリケーションや複数ターンの会話を構築する場合に特に便利です。 -SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内では、セッションを実行レベルの継続オプション `conversation_id`、`previous_response_id`、`auto_previous_response_id` と組み合わせることはできません。代わりに OpenAIサーバー管理の継続を使用したい場合は、セッションと重ねて使用せず、これらのメカニズムのいずれかを選択してください。 +SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内では、セッションを実行レベルの継続オプション `conversation_id`、`previous_response_id`、`auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI のサーバー管理による継続を使用する場合は、セッションと重ねて使用せず、これらのメカニズムのいずれかを選択してください。 ## クイックスタート @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 同じセッションによる中断された実行の再開 -承認待ちで実行が一時停止した場合は、同じセッションインスタンス、または同じセッション ID と同じ基盤ストレージバックエンドで構成された別のインスタンスを使用して再開します。これにより、再開されたターンで同じ保存済み会話履歴が引き継がれます。 +実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または、同じセッション ID と同じ基盤ストレージバックエンドを使用するよう設定された別のインスタンス)で再開し、再開後のターンが同じ保存済み会話履歴を引き継ぐようにしてください。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -65,29 +65,29 @@ if result.interruptions: ## セッションの基本動作 -セッションメモリが有効な場合: +セッションメモリが有効な場合は、次のように動作します。 -1. **各実行の前**: Runner はセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 +1. **各実行の前**: ランナーはセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 2. **各実行の後**: 実行中に生成されたすべての新しい項目(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 -3. **コンテキストの保持**: 同じセッションを使用する後続の各実行には会話履歴全体が含まれるため、エージェントはコンテキストを維持できます。 +3. **コンテキストの保持**: 同じセッションを使用する後続の各実行には完全な会話履歴が含まれるため、エージェントはコンテキストを維持できます。 -これにより、`.to_input_list()` を手動で呼び出して実行間の会話状態を管理する必要がなくなります。 +これにより、`.to_input_list()` を手動で呼び出したり、実行間で会話の状態を管理したりする必要がなくなります。 ## 履歴と新しい入力のマージ方法の制御 -セッションを渡すと、Runner は通常、次の順序でモデル入力を準備します。 +セッションを渡すと、通常、ランナーはモデル入力を次の順序で準備します。 1. セッション履歴(`session.get_items(...)` から取得) 2. 新しいターンの入力 -モデル呼び出し前のマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 +モデル呼び出し前にこのマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 -- `history`: 取得されたセッション履歴(入力項目形式に正規化済み) -- `new_input`: 現在のターンの新しい入力項目 +- `history`: 取得されたセッション履歴(入力項目形式に正規化済み) +- `new_input`: 現在のターンの新しい入力項目 -モデルに送信する入力項目の最終リストを返します。 +モデルに送信する最終的な入力項目のリストを返します。 -コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストはそのターンのモデル入力を制御しますが、SDK が永続化するのは新しいターンに属する項目だけです。そのため、古い履歴を並べ替えたりフィルタリングしたりしても、古いセッション項目が新しい入力として再度保存されることはありません。 +コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストはそのターンのモデル入力を制御しますが、SDK が永続化するのは、新しいターンに属する項目のみです。したがって、古い履歴の並べ替えやフィルタリングによって、古いセッション項目が新規入力として再度保存されることはありません。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -セッションによる項目の保存方法を変更せずに、履歴のカスタム整理、並べ替え、または選択的な追加が必要な場合に使用します。モデル呼び出しの直前に最終的な処理を追加する必要がある場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 +セッションによる項目の保存方法を変更せずに、履歴を独自に削減、並べ替え、または選択的に追加する必要がある場合に使用します。モデル呼び出しの直前に最終処理を行う必要がある場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 ## 取得する履歴の制限 -各実行前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 +各実行の前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 -- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッション項目を取得 -- `SessionSettings(limit=N)`: 最新の `N` 個の項目のみを取得 +- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッション項目を取得します +- `SessionSettings(limit=N)`: 最新の `N` 項目のみを取得します -[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] を使用して、実行ごとに適用できます。 +これは、[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] を使用して実行ごとに適用できます。 ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` 内の `None` 以外の各値が、その実行に対応するデフォルト値を上書きします。これは、セッションのデフォルト動作を変更せずに取得件数を制限したい長い会話で便利です。 +セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` 内の `None` 以外の各値は、その実行に対応するデフォルト値を上書きします。これは、セッションのデフォルト動作を変更せずに、長い会話で取得件数を制限したい場合に便利です。 ## メモリ操作 ### 基本操作 -セッションは、会話履歴を管理するための複数の操作をサポートしています。 +セッションでは、会話履歴を管理するための複数の操作を利用できます。 ```python from agents import SQLiteSession @@ -165,9 +165,9 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 修正での pop_item の使用 +### 修正のための pop_item の使用 -`pop_item` メソッドは、会話の最後の項目を取り消したり変更したりする場合に特に便利です。 +`pop_item` メソッドは、会話内の最後の項目を取り消したり変更したりする場合に特に便利です。 ```python from agents import Agent, Runner, SQLiteSession @@ -198,32 +198,32 @@ print(f"Agent: {result.final_output}") ## 組み込みのセッション実装 -SDK は、さまざまなユースケース向けに複数のセッション実装を提供しています。 +SDK には、さまざまなユースケース向けの複数のセッション実装が用意されています。 ### 組み込みセッション実装の選択 -以下の詳細な例を読む前に、開始点を選ぶためにこの表を使用してください。 +以下の詳細なコード例を読む前に、この表を使用して出発点を選択してください。 -| セッションタイプ | 最適な用途 | 備考 | +| セッションの種類 | 最適な用途 | 備考 | | --- | --- | --- | | `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量、ファイルベースまたはインメモリ | | `AsyncSQLiteSession` | `aiosqlite` を使用する非同期 SQLite | 非同期ドライバーをサポートする拡張バックエンド | -| `RedisSession` | ワーカーやサービス間での共有メモリ | 低レイテンシーの分散デプロイに適しています | -| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作します | -| `MongoDBSession` | MongoDB をすでに使用しているアプリ、またはマルチプロセスストレージが必要なアプリ | 非同期 pymongo、順序付け用のアトミックなシーケンスカウンター | -| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数のステートストア、TTL、整合性制御をサポートします | -| `OpenAIConversationsSession` | OpenAI内のサーバー管理ストレージ | OpenAI Conversations API を基盤とする履歴 | -| `OpenAIResponsesCompactionSession` | 自動圧縮を使用する長い会話 | 別のセッションバックエンドをラップします | -| `AdvancedSQLiteSession` | SQLite と分岐/分析 | より多機能です。専用ページを参照してください | -| `EncryptedSession` | 別のセッションに追加する暗号化と TTL | ラッパーです。最初に基盤となるバックエンドを選択してください | +| `RedisSession` | ワーカーやサービス間での共有メモリ | 低レイテンシーの分散デプロイに最適 | +| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作 | +| `MongoDBSession` | MongoDB をすでに使用している、またはマルチプロセスストレージを必要とするアプリ | 非同期 pymongo。順序付け用のアトミックなシーケンスカウンター | +| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数のステートストアに加え、TTL と整合性の制御をサポート | +| `OpenAIConversationsSession` | OpenAI 内のサーバー管理ストレージ | OpenAI Conversations API を基盤とする履歴 | +| `OpenAIResponsesCompactionSession` | 自動コンパクションを必要とする長い会話 | 別のセッションバックエンドをラップ | +| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析が必要な場合 | より多機能。専用ページを参照 | +| `EncryptedSession` | 別のセッションに暗号化と TTL を追加する場合 | ラッパー。最初に基盤となるバックエンドを選択 | -一部の実装には詳細を記載した専用ページがあり、各サブセクション内にリンクがあります。 +一部の実装には、追加の詳細を記載した専用ページがあります。各サブセクション内にリンクを掲載しています。 -ChatKit 用の Pythonサーバーを実装する場合は、ChatKit のスレッドと項目を永続化するために `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDKセッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[`chatkit-python` による ChatKit データストアの実装ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 +ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドと項目の永続化に `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[`chatkit-python` による ChatKit データストアの実装ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 ### OpenAI Conversations API セッション -`OpenAIConversationsSession` を通じて [OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations)を使用します。 +`OpenAIConversationsSession` を通じて [OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations) を使用します。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -257,11 +257,11 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses 圧縮セッション +### OpenAI Responses コンパクションセッション -Responses API(`responses.compact`)を使用して保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動的に圧縮できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は、異なる方法で履歴を管理します。 +Responses API(`responses.compact`)を使用して、保存された会話履歴をコンパクションするには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動的にコンパクションできます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 -#### 一般的な使用方法(自動圧縮) +#### 一般的な使用方法(自動コンパクション) ```python from agents import Agent, Runner, SQLiteSession @@ -278,17 +278,19 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -デフォルトでは、各ターン後に SDK が圧縮候補がしきい値を満たしているかを確認し、満たしている場合にのみ圧縮します。 +デフォルトでは、SDK は各ターン後にコンパクション候補がしきい値を満たしているか確認し、満たしている場合にのみコンパクションします。 -`compaction_mode="previous_response_id"` は圧縮セッションが保持する Responses API のレスポンス ID を使用し、そのレスポンスチェーンが利用可能な間に最も効果的に動作します。一方、`compaction_mode="input"` は現在のセッション項目から圧縮リクエストを再構築します。これは、レスポンスチェーンが利用できない場合や、セッションの内容を信頼できる唯一の情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 +`compaction_mode="previous_response_id"` は、コンパクションセッションによって保持されている Responses API のレスポンス ID を使用し、そのレスポンスチェーンが利用可能な間に最適に動作します。代わりに `compaction_mode="input"` は、現在のセッション項目からコンパクションリクエストを再構築します。これは、レスポンスチェーンが利用できない場合や、セッションの内容を信頼できる唯一の情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 -エージェントを `ModelSettings(store=False)` で実行すると、Responses API は後で参照できるように最後のレスポンスを保持しません。このステートレス構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースの圧縮にフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 +エージェントを `ModelSettings(store=False)` で実行すると、Responses API は後から参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースのコンパクションにフォールバックします。完全なコード例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py) を参照してください。 -#### 自動圧縮によるストリーミングのブロック +#### 自動コンパクションによるストリーミングのブロック -圧縮ではセッション履歴を消去して再書き込みするため、SDK は圧縮が完了するまで実行を完了と見なしません。ストリーミングモードでは、圧縮処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 +コンパクションではセッション履歴を消去して書き直すため、SDK はコンパクションが完了するまで実行を完了したものと見なしません。ストリーミングモードでは、コンパクションの負荷が高い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 -低レイテンシーのストリーミングや迅速なターン切り替えが必要な場合は、自動圧縮を無効にし、ターン間(またはアイドル時)に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、圧縮を強制するタイミングを決定できます。 +`OpenAIResponsesCompactionSession.run_compaction()` は、消去と再書き込みの操作を、ラッパー境界で復旧可能な置換として扱います。基盤となる履歴が変更された後に置換が失敗またはキャンセルされた場合、ラッパーは以前の履歴の復元を試み、元の例外またはキャンセルが呼び出し元に伝わる前に、その復旧処理が完了するまで待機します。復旧中に基盤バックエンドでも障害が発生した場合、以前の履歴が復元されないままになる可能性があり、SDK は復旧の失敗をログに記録します。ラッパーは `add_items()`、`pop_item()`、`clear_session()` の呼び出しを、ロックされた置換および復旧フェーズと直列化します。ただし、リモートのコンパクションリクエストがまだ進行中の間に変更が完了し、その後、正常な置換によって上書きされる可能性があります。手動コンパクションは、ラッパーへの変更が並行して行われていないターン間に実行し、コンパクションの実行中に基盤セッションを直接変更しないでください。 + +低レイテンシーのストリーミングや迅速なターン切り替えが必要な場合は、自動コンパクションを無効にし、ターン間(またはアイドル時)に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、コンパクションを強制するタイミングを決定できます。 ```python from agents import Agent, Runner, SQLiteSession @@ -311,7 +313,7 @@ await session.run_compaction({"force": True}) ### SQLite セッション -SQLite を使用する、デフォルトの軽量なセッション実装です。 +SQLite を使用するデフォルトの軽量なセッション実装です。 ```python from agents import SQLiteSession @@ -349,7 +351,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis セッション -複数のワーカーまたはサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 +複数のワーカーやサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 ```bash pip install openai-agents[redis] @@ -368,11 +370,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)` は Redis クライアントを作成し、その所有権を持ちます。`close()` の後、セッションは終了状態となり、以降のセッション操作では `RuntimeError` が発生します。`close()` の呼び出しは、反復または並行して行っても安全です。アプリケーションが Redis クライアントをすでに管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築します。その場合、`close()` は何もせず、呼び出し元がクライアントの所有権を保持し、セッションも引き続き使用できます。 +`from_url(...)` は Redis クライアントを作成し、その所有権を持ちます。`close()` の後、セッションは終了状態となり、それ以降のセッション操作では `RuntimeError` が発生します。`close()` は、繰り返しまたは並行して呼び出しても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を使用して `RedisSession(...)` を直接構築します。その場合、`close()` は何も行わず、クライアントの所有権とセッションの使用可能性はどちらも呼び出し元に保持されます。 ### SQLAlchemy セッション -SQLAlchemy がサポートする任意のデータベースを使用する、本番環境向けの Agents SDKセッション永続化です。 +SQLAlchemy がサポートする任意のデータベースを使用した、本番環境対応の Agents SDK セッション永続化です。 ```python from agents.extensions.memory import SQLAlchemySession @@ -415,19 +417,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -注意事項: +注意事項: -- `from_address(...)` は Dapr クライアントを作成し、その所有権を持ちます。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を指定して `DaprSession(...)` を直接構築します。 -- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを持つセッションは終了状態になります。以降のセッション操作では `RuntimeError` が発生しますが、`close()` の呼び出しは、反復または並行して行っても安全です。注入されたクライアントを使用する場合、`close()` は何もせず、セッションは引き続き使用できます。 -- 基盤となるステートストアが TTL をサポートしている場合は、`ttl=...` を渡すことで、セッションデータに TTL による有効期限が自動的に適用されます。 -- 書き込み後の読み取りについて、より強い保証が必要な場合は `consistency=DAPR_CONSISTENCY_STRONG` を渡します。 -- Dapr Python SDK は HTTP サイドカーエンドポイントも確認します。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` を指定して Dapr を起動してください。 -- ローカルコンポーネントやトラブルシューティングを含む設定手順の全体については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)を参照してください。 +- `from_address(...)` は Dapr クライアントを作成し、その所有権を持ちます。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を使用して `DaprSession(...)` を直接構築します。 +- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを使用するセッションは終了状態となり、それ以降のセッション操作では `RuntimeError` が発生します。一方、`close()` は、繰り返しまたは並行して呼び出しても安全です。注入されたクライアントを使用する場合、`close()` は何も行わず、セッションは引き続き使用できます。 +- 基盤のステートストアが TTL をサポートしている場合は、セッションデータに TTL の有効期限が自動適用されるよう、`ttl=...` を渡します。 +- 書き込み後の読み取りについて、より強い保証が必要な場合は、`consistency=DAPR_CONSISTENCY_STRONG` を渡します。 +- Dapr Python SDK は HTTP サイドカーエンドポイントも確認します。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` でも Dapr を起動してください。 +- ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py) を参照してください。 ### MongoDB セッション -MongoDB をすでに使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要な場合は、`MongoDBSession` を使用します。 +MongoDB をすでに使用している、または水平方向にスケール可能なマルチプロセスのセッションストレージを必要とするアプリケーションでは、`MongoDBSession` を使用します。 ```bash pip install openai-agents[mongodb] @@ -450,12 +452,12 @@ print(result.final_output) await session.close() ``` -注意事項: +注意事項: -- `from_uri(...)` は `AsyncMongoClient` を作成してその所有権を持ち、`session.close()` の際に閉じます。所有クライアントを持つセッションは `close()` の後に終了状態となり、以降のセッション操作では `RuntimeError` が発生します。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築します。その場合、`session.close()` は何もせず、呼び出し元がクライアントのライフサイクルに対する責任を保持し、セッションは引き続き使用できます。 -- その他の変更を行わずに、`mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すことで、[MongoDB Atlas](https://www.mongodb.com/products/platform)に接続できます。 -- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。空でない `add_items()` の各呼び出しでは、単調増加する `seq` によって最後の項目を基準にバッチが順序付けられた、1 つの論理バッチドキュメントが書き込まれます。従来の項目単位のメッセージドキュメントも引き続き読み取れます。論理バッチは MongoDB の単一ドキュメントのサイズ制限内に収まる必要があります。サイズを超過したバッチは、部分的なバッチを保存することなくアトミックに失敗します。 -- 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 +- `from_uri(...)` は `AsyncMongoClient` を作成して所有し、`session.close()` で閉じます。所有クライアントを使用するセッションは、`close()` の後に終了状態となり、それ以降のセッション操作では `RuntimeError` が発生します。アプリケーションがすでにクライアントを管理している場合は、`client=...` を使用して `MongoDBSession(...)` を直接構築します。その場合、`session.close()` は何も行わず、クライアントのライフサイクルに対する責任は呼び出し元に保持され、セッションは引き続き使用できます。 +- `mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すだけで、ほかに変更を加えることなく [MongoDB Atlas](https://www.mongodb.com/products/platform) に接続できます。 +- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。空でない `add_items()` の各呼び出しでは、単調増加する `seq` によって最終項目を基準にバッチが順序付けられた、1 つの論理バッチドキュメントが書き込まれます。従来の項目ごとのメッセージドキュメントも引き続き読み取り可能です。論理バッチは MongoDB の単一ドキュメントのサイズ上限内に収まる必要があります。サイズ超過のバッチは、部分的なバッチを保存することなくアトミックに失敗します。 +- 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 ### 高度な SQLite セッション @@ -483,7 +485,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 ### 暗号化セッション -任意のセッション実装向けの透過的な暗号化ラッパーです。 +任意のセッション実装に対応する透過的な暗号化ラッパーです。 ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -508,32 +510,32 @@ result = await Runner.run(agent, "Hello", session=session) 詳細なドキュメントについては、[暗号化セッション](encrypted_session.md)を参照してください。 -### その他のセッションタイプ +### その他のセッション形式 -ほかにもいくつかの組み込みオプションがあります。`examples/memory/` および `extensions/memory/` 配下のソースコードを参照してください。 +ほかにもいくつかの組み込みオプションがあります。`examples/memory/` と `extensions/memory/` 配下のソースコードを参照してください。 ## 運用パターン ### セッション ID の命名 -会話を整理しやすい、意味のあるセッション ID を使用します。 +会話を整理しやすい、意味のあるセッション ID を使用してください。 -- ユーザーベース: `"user_12345"` -- スレッドベース: `"thread_abc123"` -- コンテキストベース: `"support_ticket_456"` +- ユーザーベース: `"user_12345"` +- スレッドベース: `"thread_abc123"` +- コンテキストベース: `"support_ticket_456"` ### メモリの永続化 -- 一時的な会話には、インメモリ SQLite(`SQLiteSession("session_id")`)を使用します -- 永続的な会話には、ファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します -- `aiosqlite` ベースの実装が必要な場合は、非同期 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)を使用します -- 共有された低レイテンシーのセッションメモリには、Redis ベースのセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します -- SQLAlchemy がサポートする既存のデータベースを使用する本番システムには、SQLAlchemy ベースのセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)を使用します -- MongoDB をすでに使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要な場合は、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します -- 組み込みのテレメトリ、トレーシング、データ分離、30 種類を超えるデータベースバックエンドのサポートが必要な本番環境のクラウドネイティブデプロイには、Dapr ステートストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します -- OpenAI Conversations API に履歴を保存したい場合は、OpenAIがホストするストレージ(`OpenAIConversationsSession()`)を使用します -- 任意のセッションに透過的な暗号化と TTL ベースの有効期限を追加するには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します -- より高度なユースケースでは、他の本番システム(Django など)向けのカスタムセッションバックエンドの実装を検討してください +- 一時的な会話にはインメモリ SQLite(`SQLiteSession("session_id")`)を使用します +- 永続的な会話にはファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します +- `aiosqlite` ベースの実装が必要な場合は、非同期 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)を使用します +- 共有された低レイテンシーのセッションメモリには、Redis ベースのセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します +- SQLAlchemy がサポートする既存のデータベースを使用する本番システムには、SQLAlchemy ベースのセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)を使用します +- MongoDB をすでに使用している、または水平方向にスケール可能なマルチプロセスのセッションストレージを必要とするアプリケーションには、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します +- 組み込みのテレメトリ、トレーシング、データ分離、および 30 以上のデータベースバックエンドのサポートを必要とする本番環境のクラウドネイティブなデプロイには、Dapr ステートストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します +- OpenAI Conversations API に履歴を保存したい場合は、OpenAI がホストするストレージ(`OpenAIConversationsSession()`)を使用します +- 任意のセッションを透過的な暗号化と TTL ベースの有効期限でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します +- より高度なユースケースでは、ほかの本番システム(Django など)向けのカスタムセッションバックエンドの実装を検討してください ### 複数のセッション @@ -579,9 +581,9 @@ result2 = await Runner.run( ) ``` -## 完全な例 +## 完全なコード例 -セッションメモリの動作を示す完全な例を以下に示します。 +セッションメモリの動作を示す完全なコード例を以下に示します。 ```python import asyncio @@ -645,39 +647,38 @@ if __name__ == "__main__": ## カスタムセッション実装 -[`Session`][agents.memory.session.Session] プロトコルに従うクラスを作成することで、独自のセッションメモリを実装できます。 +[`Session`][agents.memory.session.Session] プロトコルに構造的に準拠するクラスを作成することで、独自のセッションメモリを実装できます。`SessionABC` を継承する必要はありません。`session_id` と `session_settings` を定義し、4 つの履歴メソッドを直接実装してください。 ```python -from agents.memory.session import SessionABC +from agents import Agent, Runner, SessionSettings from agents.items import TResponseInputItem -from typing import List -class MyCustomSession(SessionABC): + +class MyCustomSession: """Custom session implementation following the Session protocol.""" - def __init__(self, session_id: str): + session_settings: SessionSettings | None = None + + def __init__(self, session_id: str) -> None: self.session_id = session_id - # Your initialization here + self.items: list[TResponseInputItem] = [] - async def get_items(self, limit: int | None = None) -> List[TResponseInputItem]: - """Retrieve conversation history for this session.""" - # Your implementation here - pass + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + if limit is None: + return list(self.items) + if limit <= 0: + return [] + return list(self.items[-limit:]) - async def add_items(self, items: List[TResponseInputItem]) -> None: - """Store new items for this session.""" - # Your implementation here - pass + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.items.extend(items) async def pop_item(self) -> TResponseInputItem | None: - """Remove and return the most recent item from this session.""" - # Your implementation here - pass + return self.items.pop() if self.items else None async def clear_session(self) -> None: - """Clear all items for this session.""" - # Your implementation here - pass + self.items.clear() + # Use your custom session agent = Agent(name="Assistant") @@ -688,28 +689,69 @@ result = await Runner.run( ) ``` +### カスタムセッションからの実行コンテキストへのアクセス + +Agents SDK は、テナントルーティング、認可、またはアプリ固有のその他のストレージ判断のために、アクティブな [`RunContextWrapper`][agents.run_context.RunContextWrapper] をカスタムセッションに渡すことができます。Agents SDK がラッパーを渡せるようにするには、4 つの履歴メソッドすべてに、明示的に命名され、キーワード引数として使用可能な `wrapper` パラメーターを追加します。 + +```python +from typing import Any + +from agents import RunContextWrapper +from agents.items import TResponseInputItem + + +class ContextAwareSession: + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: ... + + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... + + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: ... + + async def clear_session( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... +``` + +Agents SDK がこの統合を有効にするのは、`get_items`、`add_items`、`pop_item`、`clear_session` のすべてで `wrapper` が宣言されている場合のみです。汎用の `**kwargs` パラメーターでは、このシグネチャチェックを満たしません。`wrapper` を省略している既存のセッション実装では、公開済みの呼び出し形式が維持され、変更せずに引き続き動作します。 + ## コミュニティによるセッション実装 -コミュニティによって、追加のセッション実装が開発されています。 +コミュニティは、追加のセッション実装を開発しています。 | パッケージ | 説明 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)向けの Django ORM ベースのセッション | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)向けの、Django ORM ベースのセッション | -セッション実装を構築した場合は、ここに追加するためのドキュメント PR をぜひ提出してください。 +セッション実装を構築した場合は、ここに追加するためのドキュメント PR をぜひ送信してください。 ## API リファレンス 詳細な API ドキュメントについては、以下を参照してください。 -- [`Session`][agents.memory.session.Session] - プロトコルインターフェース -- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 実装 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 圧縮ラッパー -- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基本的な SQLite 実装 -- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` ベースの非同期 SQLite 実装 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis ベースのセッション実装 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy ベースの実装 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB ベースのセッション実装 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr ステートストア実装 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析を備えた拡張 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッション向けの暗号化ラッパー \ No newline at end of file +- [`Session`][agents.memory.session.Session] - プロトコルインターフェース +- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API の実装 +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API コンパクションラッパー +- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基本的な SQLite 実装 +- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` ベースの非同期 SQLite 実装 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis ベースのセッション実装 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy ベースの実装 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB ベースのセッション実装 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr ステートストア実装 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析を備えた拡張 SQLite +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッション向けの暗号化ラッパー \ No newline at end of file diff --git a/docs/ja/streaming.md b/docs/ja/streaming.md index 17ebe63b46..b2f18f7a90 100644 --- a/docs/ja/streaming.md +++ b/docs/ja/streaming.md @@ -4,19 +4,19 @@ search: --- # ストリーミング -ストリーミングを使用すると、進行中のエージェント実行の更新を購読できます。これは、エンドユーザーに進捗状況の更新や部分的なレスポンスを表示する場合に便利です。 +ストリーミングを使用すると、エージェントの実行中に更新を受け取れます。これは、エンドユーザーに進捗状況や部分的な応答を表示する場合に役立ちます。 -ストリーミングするには、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を呼び出します。これにより、[`RunResultStreaming`][agents.result.RunResultStreaming] が得られます。`result.stream_events()` を呼び出すと、以下で説明する [`StreamEvent`][agents.stream_events.StreamEvent] オブジェクトの非同期ストリームが得られます。 +ストリーミングするには、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を呼び出します。これにより、[`RunResultStreaming`][agents.result.RunResultStreaming] が返されます。`result.stream_events()` を呼び出すと、以下で説明する [`StreamEvent`][agents.stream_events.StreamEvent] オブジェクトの非同期ストリームが得られます。 -非同期イテレーターが終了するまで、`result.stream_events()` を消費し続けてください。ストリーミング実行は、イテレーターが終了するまで完了しません。また、セッションの永続化、承認情報の記録、履歴の圧縮などの後処理は、最後に表示されるトークンが到着した後に完了する場合があります。ループを抜けると、`result.is_complete` は最終的な実行状態を反映します。 +非同期イテレーターが終了するまで、`result.stream_events()` を受け取り続けてください。ストリーミング実行はイテレーターが終了するまで完了しません。また、セッションの永続化、承認の記録管理、履歴の圧縮などの後処理は、最後に表示されるトークンが到着した後に完了する場合があります。ループが終了すると、`result.is_complete` に最終的な実行状態が反映されます。 ## Raw レスポンスイベント -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] オブジェクトは、LLM から直接渡される raw イベントをラップします。各オブジェクトの `data` フィールドには、`response.created` や `response.output_text.delta` などの型を持つ OpenAI Responses API イベントが格納されます。これらのイベントは、レスポンスメッセージを生成され次第ユーザーにストリーミングする場合に便利です。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] オブジェクトは、LLM から直接渡される raw イベントをラップします。各オブジェクトの `data` フィールドには、`response.created` や `response.output_text.delta` などの型を持つ OpenAI Responses API イベントが含まれます。これらのイベントは、応答メッセージが生成され次第、ユーザーにストリーミングする場合に役立ちます。 -コンピュータツールの raw イベントでは、保存済みの結果と同様に、プレビュー版と GA 版の区別が維持されます。プレビューのフローでは、1 つの `action` を持つ `computer_call` アイテムをストリーミングします。一方、`gpt-5.5` では、バッチ化された `actions[]` を持つ `computer_call` アイテムをストリーミングできます。上位レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] インターフェースでは、このためにコンピュータ専用の特別なイベント名は追加されません。どちらの形式も引き続き `tool_called` として公開され、スクリーンショットの結果は `computer_call_output` アイテムをラップする `tool_output` として返されます。 +コンピュータツールの raw イベントでは、保存された結果と同じく、プレビュー版と GA 版の区別が維持されます。プレビューフローでは、1 つの `action` を含む `computer_call` 項目がストリーミングされます。一方、`gpt-5.5` では、バッチ化された `actions[]` を含む `computer_call` 項目をストリーミングできます。上位レベルの [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] インターフェースでは、これに対してコンピュータ専用の特別なイベント名は追加されません。どちらの形式も引き続き `tool_called` として公開され、スクリーンショットの結果は `computer_call_output` 項目をラップする `tool_output` として返されます。 -たとえば、次の例では LLM が生成したテキストをトークン単位で出力します。 +たとえば、次のコードは LLM が生成したテキストをトークン単位で出力します。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## ストリーミングと承認 -ストリーミングは、ツールの承認のために一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` が終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] に公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 +ストリーミングは、ツールの承認待ちで一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` が終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] に公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,25 +57,27 @@ if result.interruptions: pass ``` -一時停止と再開の完全な手順については、[人間参加型のガイド](human_in_the_loop.md)を参照してください。 +一時停止と再開の手順全体については、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 -## 現在のターン完了後のストリーミング停止 +## 現在のターン後のストリーミング停止 -ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行はすぐに停止します。現在のターンを正常に完了させてから停止するには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 +ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行は直ちに停止します。停止する前に現在のターンを正常に完了させるには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 -ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後に表示されるトークンの後も、SDK ではセッションアイテムの永続化、承認状態の確定、履歴の圧縮が続いている可能性があります。 +ストリーミング実行は、`result.stream_events()` が終了するまで完了しません。最後に表示されるトークンの後も、SDK がセッション項目の永続化、承認状態の確定、または履歴の圧縮を行っている場合があります。 -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で続行している場合に、`cancel(mode="after_turn")` がツールのターン後に停止したときは、新しいユーザーターンをすぐに追加するのではなく、その正規化済み入力を指定して `result.last_agent` を再実行し、未完了の既存ユーザーターンを続行してください。 -- ストリーミング実行がツールの承認のために停止した場合、それを新しいターンとして扱わないでください。ストリームを最後まで消費し、`result.interruptions` を確認して、`result.to_state()` から再開してください。 -- [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用すると、取得したセッション履歴と新しいユーザー入力を、次回のモデル呼び出し前にどのように統合するかをカスタマイズできます。そこで新しいターンのアイテムを書き換えると、そのターンでは書き換え後のバージョンが永続化されます。 +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] から手動で続行していて、ツールターンの後に `cancel(mode="after_turn")` が停止した場合は、すぐに新しいユーザーターンを追加するのではなく、正規化された入力を使用して `result.last_agent` を再実行し、未完了の既存ユーザーターンを続行します。 -## 実行アイテムイベントとエージェントイベント +- 未完了の実行を再開する前に新しいユーザー入力が届いた場合は、受け取りを完了した実行結果を `result.to_state()` で変換し、[`state.add_input(...)`][agents.run_state.RunState.add_input] を呼び出して、その状態から再開します。Runner は次のモデル呼び出しの直前に、準備済みの入力を取り込みます。[再開前の入力追加](results.md#add-input-before-resuming)を参照してください。 +- ストリーミング実行がツールの承認待ちで停止した場合、それを新しいターンとして扱わないでください。ストリームを最後まで受け取り、`result.interruptions` を確認して、代わりに `result.to_state()` から再開します。 +- 次のモデル呼び出しの前に、取得したセッション履歴と新しいユーザー入力をどのように統合するかをカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。そこで新しいターンの項目を書き換えた場合、その書き換え後のバージョンがそのターンについて永続化されます。 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より上位レベルのイベントです。アイテムの生成が完全に完了したときに通知されます。これにより、各トークン単位ではなく、「メッセージが生成された」「ツールが実行された」などの単位で進捗状況の更新を送信できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(たとえば、ハンドオフの結果として)に更新を提供します。 +## 実行項目イベントとエージェントイベント -### 実行アイテムイベント名 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より上位レベルのイベントです。項目の生成が完全に完了した時点で通知されます。これにより、トークンごとではなく、「メッセージが生成された」「ツールが実行された」などの単位で進捗状況を通知できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(たとえば、ハンドオフの結果として)に更新を提供します。 -`RunItemStreamEvent.name` は、固定のセマンティックイベント名を使用します。 +### 実行項目イベント名 + +`RunItemStreamEvent.name` では、次の固定されたセマンティックイベント名を使用します。 - `message_output_created` - `handoff_requested` @@ -89,15 +91,15 @@ if result.interruptions: - `mcp_approval_response` - `mcp_list_tools` -`handoff_occured` は、後方互換性のために意図的にスペルが誤っています。 +`handoff_occured` は、後方互換性のため意図的にスペルが誤っています。 ハンドオフ呼び出しは `handoff_requested` としてのみ発行され、`tool_called` として重複して発行されることはありません。同じターン内の通常の関数ツール呼び出しでは、引き続き `tool_called` が発行されます。 ホスト型ツール検索を使用する場合、モデルがツール検索リクエストを発行すると `tool_search_called` が発行され、Responses API が読み込まれたサブセットを返すと `tool_search_output_created` が発行されます。 -プログラムによるツール呼び出しでは、生成された `program` と、通常のプログラム所有の子ツール呼び出しに対して `tool_called` が発行されます。子ツールの出力と、生成された `program` に対応する `program_output` に対しては、`tool_output` が発行されます。プログラム所有のホスト型 MCP の `mcp_approval_request` アイテムと `mcp_list_tools` アイテムは例外です。それぞれ、[`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] をラップする `mcp_approval_requested` と、[`MCPListToolsItem`][agents.items.MCPListToolsItem] をラップする `mcp_list_tools` として発行されます。残りのアイテムを区別するには、raw アイテムの `type` を確認してください。プログラム所有の子呼び出しには `caller` も含まれ、その型は `program` で、呼び出し元 ID によって親プログラムが識別されます。 +Programmatic Tool Calling では、生成された `program` と、プログラムが所有する通常の子ツール呼び出しに対して `tool_called` が発行されます。子ツールの出力と、生成された `program` に対応する `program_output` に対しては、`tool_output` が発行されます。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外です。これらはそれぞれ、[`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] と [`MCPListToolsItem`][agents.items.MCPListToolsItem] をラップする `mcp_approval_requested` および `mcp_list_tools` として発行されます。残りの項目を区別するには、raw 項目の `type` を確認してください。プログラムが所有する子呼び出しには、型が `program` で、呼び出し元 ID が親プログラムを識別する `caller` も含まれます。 -たとえば、次の例では raw イベントを無視し、更新をユーザーにストリーミングします。 +たとえば、次のコードは raw イベントを無視し、更新をユーザーにストリーミングします。 ```python import asyncio diff --git a/docs/ja/usage.md b/docs/ja/usage.md index 987cfe0108..75f748714d 100644 --- a/docs/ja/usage.md +++ b/docs/ja/usage.md @@ -4,22 +4,23 @@ search: --- # 使用量 -Agents SDK は、実行ごとのトークン使用量を自動的に追跡します。実行コンテキストから使用量にアクセスし、コストの監視、上限の適用、分析データの記録に利用できます。 +Agents SDK は、実行ごとのトークン使用量を自動的に追跡します。実行コンテキストから使用量にアクセスし、コストの監視、上限の適用、分析データの記録に使用できます。 ## 追跡対象 -- **requests**: LLM API の呼び出し回数 +- **requests**: 実行された LLM API 呼び出しの数 - **input_tokens**: 送信された入力トークンの合計 - **output_tokens**: 受信した出力トークンの合計 -- **total_tokens**: 入力 + 出力 +- **total_tokens**: 入力と出力の合計 - **request_usage_entries**: リクエストごとの使用量内訳のリスト - **details**: - `input_tokens_details.cached_tokens` + - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` ## 実行からの使用量へのアクセス -`Runner.run(...)` の実行後、`result.context_wrapper.usage` から使用量にアクセスできます。 +`Runner.run(...)` の実行後、`result.context_wrapper.usage` から使用量にアクセスします。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -31,16 +32,16 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -使用量は、ツール呼び出しやハンドオフを生成するモデル呼び出しを含め、実行中のすべてのモデル呼び出しを通じて集計されます。 +使用量は、ツール呼び出しやハンドオフを生成するモデル呼び出しを含め、実行中のすべてのモデル呼び出しにわたって集計されます。 -### サードパーティーアダプターでの使用量の有効化 +### サードパーティー製アダプターでの使用量の有効化 -使用量レポートは、サードパーティーアダプターやプロバイダーのバックエンドによって異なります。サードパーティーアダプターを介してモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、以下を確認してください。 +使用量レポートは、サードパーティー製アダプターやプロバイダーのバックエンドによって異なります。サードパーティー製アダプター経由でモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、次の点に注意してください。 -- `AnyLLMModel` では、上流のプロバイダーが使用量を返すと、その情報が自動的に伝播されます。Chat Completions バックエンドからのレスポンスをストリーミングする場合、使用量チャンクを出力するために `ModelSettings(include_usage=True)` が必要になることがあります。 -- `LitellmModel` では、一部のプロバイダーのバックエンドがデフォルトで使用量を報告しないため、多くの場合 `ModelSettings(include_usage=True)` が必要です。 +- `AnyLLMModel` では、上流プロバイダーが使用量を返すと、自動的に伝播されます。Chat Completions バックエンドからレスポンスをストリーミングする場合、使用量チャンクを出力するには `ModelSettings(include_usage=True)` が必要になることがあります。 +- `LitellmModel` では、一部のプロバイダーのバックエンドはデフォルトで使用量を報告しないため、多くの場合 `ModelSettings(include_usage=True)` が必要です。 -モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注記を確認し、デプロイ予定のプロバイダーのバックエンドで使用量レポートを検証してください。 +Models ガイドの[サードパーティー製アダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注意事項を確認し、デプロイ予定のプロバイダーのバックエンドで使用量レポートを検証してください。 ## リクエストごとの使用量追跡 @@ -53,9 +54,32 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` +## プロバイダーの使用量ペイロードの保持 + +Agents SDK は、プロバイダーの使用量を [`Usage`][agents.usage.Usage] フィールドに正規化し、モデルプロバイダー間で一貫した合計値を提供します。アプリケーションでプロバイダー固有の使用量フィールドを保持する必要がある場合や、省略されたフィールドとプロバイダーが報告したゼロを区別する必要がある場合は、[`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] を `True` に設定します。 + +```python +from agents import Agent, ModelSettings, Runner + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(preserve_raw_usage=True), +) +result = await Runner.run(agent, "What's the weather in Tokyo?") + +for response in result.raw_responses: + print(response.raw_usage) +``` + +Agents SDK は、各 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 値を、そのモデル呼び出しに対するプロバイダーペイロードの独立した JSON 互換スナップショットとして保存します。Agents SDK は、実行全体で `raw_usage` を集計しません。保持が無効な場合、プロバイダーが使用量ペイロードを返さない場合、または上流アダプターが元のフィールド有無の情報をすでに破棄している場合、この値は `None` のままです。 + +`preserve_raw_usage` は、モデルアダプターに到達した使用量ペイロードのみを保持します。この設定によって、プロバイダーへ使用量が要求されることはありません。ストリーミングの Chat Completions プロバイダーで使用量の明示的な要求が必要な場合は、`ModelSettings(include_usage=True)` も設定してください。 + +`LitellmModel` は現在、ストリーミング実行でも非ストリーミング実行でも `ModelResponse.raw_usage` を設定しないため、`preserve_raw_usage=True` はこのアダプターでは効果がありません。`LitellmModel` を使用する場合は、引き続き正規化された [`Usage`][agents.usage.Usage] フィールドを使用してください。プロバイダー固有のフィールドの有無を確認する必要がある場合は、raw 使用量の保持をサポートするアダプターを選択してください。 + ## セッションでの使用量へのアクセス -`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その特定の実行の使用量が返されます。セッションはコンテキストとして会話履歴を保持しますが、各実行の使用量は独立しています。 +`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その実行固有の使用量が返されます。セッションはコンテキストとして会話履歴を保持しますが、各実行の使用量は独立しています。 ```python session = SQLiteSession("my_conversation") @@ -67,11 +91,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用量メトリクスは、その実行のみを表します。セッションでは、以前のメッセージが各実行への入力として再度渡される場合があり、それによって後続ターンの入力トークン数が増加します。 +セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用量メトリクスは、その実行のみを表すことに注意してください。セッションでは、以前のメッセージが各実行の入力として再度渡される場合があり、その後のターンの入力トークン数に影響します。 ## フックでの使用量の利用 -`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの主要な時点で使用量をログに記録できます。 +`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの主要な時点で使用量を記録できます。 ```python class MyHooks(RunHooks): @@ -82,9 +106,9 @@ class MyHooks(RunHooks): ## API リファレンス -詳細な API ドキュメントについては、以下を参照してください。 +API の詳細なドキュメントについては、以下を参照してください。 - [`Usage`][agents.usage.Usage] - 使用量追跡のデータ構造 - [`RequestUsage`][agents.usage.RequestUsage] - リクエストごとの使用量の詳細 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストから使用量にアクセス -- [`RunHooks`][agents.run.RunHooks] - 使用量追跡のライフサイクルへのフック \ No newline at end of file +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストからの使用量へのアクセス +- [`RunHooks`][agents.run.RunHooks] - 使用量追跡ライフサイクルへのフックの追加 \ No newline at end of file diff --git a/docs/ja/voice/pipeline.md b/docs/ja/voice/pipeline.md index f3c079e39a..91b8a57967 100644 --- a/docs/ja/voice/pipeline.md +++ b/docs/ja/voice/pipeline.md @@ -34,30 +34,32 @@ graph LR ## パイプラインの設定 -パイプラインを作成するときは、次の項目を設定できます。 +パイプラインを作成するとき、次の項目を設定できます。 1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase]。新しい音声が文字起こしされるたびに実行されるコードです。 -2. 使用する [`speech-to-text`][agents.voice.model.STTModel] および [`text-to-speech`][agents.voice.model.TTSModel] モデル +2. 使用する [`speech-to-text`][agents.voice.model.STTModel] モデルと [`text-to-speech`][agents.voice.model.TTSModel] モデル 3. [`config`][agents.voice.pipeline_config.VoicePipelineConfig]。次のような項目を設定できます。 - - モデル名をモデルに対応付けることができるモデルプロバイダー - - トレーシングを無効にするかどうか、音声ファイルをアップロードするかどうか、ワークフロー名、トレース ID などを含むトレーシング設定 - - プロンプト、言語、使用するデータ型など、TTS および STT モデルの設定 + - モデル名をモデルにマッピングできるモデルプロバイダー + - トレーシングを無効にするかどうか、音声ファイルをアップロードするかどうか、ワークフロー名、トレース ID などのトレーシング設定 + - プロンプト、言語、使用するデータ型など、TTS モデルと STT モデルの設定 ## パイプラインの実行 -[`run()`][agents.voice.pipeline.VoicePipeline.run] メソッドを使用してパイプラインを実行できます。このメソッドでは、次の 2 つの形式で音声入力を渡せます。 +[`run()`][agents.voice.pipeline.VoicePipeline.run] メソッドを使用してパイプラインを実行できます。このメソッドには、次の 2 つの形式で音声入力を渡せます。 -1. [`AudioInput`][agents.voice.input.AudioInput] は、完全な音声入力があり、その入力に対する結果だけを生成したい場合に使用します。これは、話者が話し終えたタイミングを検出する必要がない場合に便利です。たとえば、事前に録音された音声がある場合や、ユーザーが話し終えたタイミングが明確なプッシュトゥトークアプリの場合です。 -2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] は、ユーザーが話し終えたタイミングを検出する必要がある場合に使用します。検出された音声チャンクを順次送信でき、音声パイプラインは「アクティビティ検出」と呼ばれる処理を通じて、適切なタイミングでエージェントのワークフローを自動的に実行します。 +1. [`AudioInput`][agents.voice.input.AudioInput] は、完全な音声入力があり、その結果を生成するだけの場合に使用します。これは、話者が話し終えたタイミングを検出する必要がない場合に便利です。たとえば、事前に録音された音声がある場合や、ユーザーが話し終えたタイミングが明確なプッシュ・トゥ・トークアプリの場合です。 +2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] は、ユーザーが話し終えたタイミングを検出する必要がある場合に使用します。検出された音声チャンクを順次プッシュでき、音声パイプラインは「アクティビティ検出」と呼ばれる処理を通じて、適切なタイミングでエージェントのワークフローを自動的に実行します。 -## 実行結果 +## 結果 -音声パイプラインの実行結果は [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult] です。これは、イベントの発生時にそのイベントをストリーミングできるオブジェクトです。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] には、次のようないくつかの種類があります。 +音声パイプラインの実行結果は [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult] です。これは、イベントの発生に応じてストリーミングできるオブジェクトです。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] には、次のようないくつかの種類があります。 1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio]。音声チャンクを含みます。 2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle]。ターンの開始や終了などのライフサイクルイベントを通知します。 3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError]。エラーイベントです。 +アプリケーションが [`StreamedAudioResult.stream()`][agents.voice.result.StreamedAudioResult.stream] を処理している間に、パイプラインの終端エラーが送出されます。それ以外は正常に実行されたにもかかわらず、音声テキスト変換の文字起こしセッションを閉じられなかった場合、ストリームは無期限に待機する代わりに、そのクローズエラーを送出します。ターンがすでに失敗しており、文字起こしセッションのクローズも失敗した場合、ストリームは元のターンエラーを主要なエラーとして保持します。 + ```python result = await pipeline.run(input) @@ -78,4 +80,4 @@ async for event in result.stream(): ### 割り込み -Agents SDKには現在、[`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 用の組み込みの割り込み処理はありません。代わりに、検出された各ターンによってワークフローが個別に実行されます。アプリケーション内で割り込みを処理する場合は、[`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] イベントをリッスンできます。`turn_started` は、新しいターンが文字起こしされ、処理が開始されたことを示します。`turn_ended` は、該当するターンのすべての音声が送信された後にトリガーされます。これらのイベントを使用して、モデルがターンを開始したときに話者のマイクをミュートし、アプリケーションがそのターンに関連するすべての音声の再生を完了した後にミュートを解除できます。 \ No newline at end of file +現在、Agents SDK は [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] に対する組み込みの割り込み処理を提供していません。代わりに、検出されたターンごとにワークフローが個別に実行されます。アプリケーション内で割り込みを処理する場合は、[`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] イベントをリッスンできます。`turn_started` は、新しいターンが文字起こしされ、処理が開始されたことを示します。`turn_ended` は、該当するターンのすべての音声が送信された後にトリガーされます。これらのイベントを使用して、モデルがターンを開始したときに話者のマイクをミュートし、アプリケーションがそのターンに関連するすべての音声の再生を終えた後にミュートを解除できます。 \ No newline at end of file diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index 0d68dc9988..e8be0d0adc 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -4,75 +4,77 @@ search: --- # 가드레일 -가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 것은 원하지 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 시간과 비용을 절약할 수 있습니다. 차단 실행은 비용이 많이 드는 모델이 시작되지 않도록 보장하지만, 병렬 실행에서는 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 시작되었을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요. +가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와 달라고 요청하게 두고 싶지는 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 시간과 비용을 절약할 수 있습니다. 차단 실행은 비용이 많이 드는 모델이 시작되지 않도록 보장합니다. 반면 병렬 실행에서는 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 시작되었을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참고하세요. -가드레일에는 두 가지 종류가 있습니다. +가드레일에는 두 종류가 있습니다. 1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다. 2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다. ## 워크플로 경계 -가드레일은 에이전트와 도구에 연결되지만, 워크플로에서 모두 같은 시점에 실행되는 것은 아닙니다. +가드레일은 에이전트와 도구에 연결되지만, 워크플로의 모든 지점에서 실행되는 것은 아닙니다. -- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. -- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. -- **도구 가드레일**은 사용자 지정 함수 도구를 호출할 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. +- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. +- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. +- **도구 가드레일**은 사용자 정의 함수 도구가 호출될 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. -관리자, 핸드오프 또는 위임된 전문가가 포함된 워크플로에서 각 사용자 지정 함수 도구 호출 전후에 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. +관리자, 핸드오프 또는 작업을 위임받은 전문가가 포함된 워크플로에서 각 사용자 정의 함수 도구 호출 전후에 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. ## 입력 가드레일 입력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트에 전달된 것과 동일한 입력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 결과는 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다. -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이는 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다. +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! 참고 +!!! Note - 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로, 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 왜 `guardrails` 속성이 `Runner.run`에 전달되지 않고 에이전트에 있는지 궁금할 수 있습니다. 이는 가드레일이 대개 실제 에이전트와 관련되어 있기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로, 코드를 함께 배치하면 가독성에 유용합니다. + 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트의 가드레일은 해당 에이전트가 *첫 번째* 에이전트인 경우에만 실행됩니다. `guardrails` 속성을 `Runner.run`에 전달하지 않고 에이전트에 두는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 한곳에 배치하면 가독성에 도움이 됩니다. ### 실행 모드 입력 가드레일은 두 가지 실행 모드를 지원합니다. -- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘이 동시에 시작되므로 지연 시간이 가장 짧습니다. 하지만 가드레일의 트립와이어가 트리거되면 취소되기 전에 에이전트가 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. +- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘이 동시에 시작되므로 지연 시간이 가장 짧습니다. 하지만 가드레일의 트립와이어가 트리거되면 에이전트가 취소되기 전에 이미 토큰을 사용하고 도구를 실행했을 수 있습니다. -- **차단 실행**(`run_in_parallel=False`): 가드레일이 에이전트가 시작되기 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 전혀 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하고 도구 호출로 인한 잠재적인 부작용을 방지하려는 경우에 적합합니다. +- **차단 실행**(`run_in_parallel=False`): 에이전트가 시작되기 *전에* 가드레일이 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출로 인해 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. ## 출력 가드레일 출력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트가 생성한 출력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 결과는 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다. -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]이 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로, 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이는 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다. +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]이 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! 참고 +!!! Note - 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로, 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로 가드레일은 대개 실제 에이전트와 관련되어 있기 때문에 이렇게 동작합니다. 에이전트마다 서로 다른 가드레일을 실행하므로, 코드를 함께 배치하면 가독성에 유용합니다. + 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트의 가드레일은 해당 에이전트가 *마지막* 에이전트인 경우에만 실행됩니다. 입력 가드레일과 마찬가지로 이렇게 하는 이유는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 한곳에 배치하면 가독성에 도움이 됩니다. - 출력 가드레일은 항상 에이전트가 완료된 후 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. + 출력 가드레일은 항상 에이전트 실행이 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. + +출력 트립와이어와 가드레일 함수가 발생시킨 예외는 세션에서 서로 다르게 동작합니다. 트립와이어는 최종 출력 후보를 거부합니다. 트립와이어가 작동하면 러너는 거부된 최종 출력 후보를 제외하고, 이미 완료된 도구 호출 및 도구 출력 항목과 해당 호출을 재실행하는 데 필요한 추론 컨텍스트를 구성된 세션에 저장하도록 요청합니다. 러너는 이 트립와이어 규칙을 스트리밍 실행과 비스트리밍 실행 모두에 적용합니다. 가드레일 함수가 트립와이어 결과를 반환하는 대신 예외를 발생시키면 러너는 판정을 알 수 없는 것으로 간주하고, 가드레일 예외를 표면화하기 전에 완료된 최종 턴 항목을 저장하도록 구성된 세션에 요청합니다. 이 세션 쓰기도 실패하면 세션 쓰기 오류가 우선합니다. 스트리밍 실행은 비스트리밍 실행과 동일한 저장 순서를 사용하며 `stream_events()`에서 최종 예외를 발생시킵니다. 출력 가드레일이 실행 중일 때 [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel]을 즉시 호출하면 진행 중인 가드레일이 취소되고 최종 턴 세션 쓰기는 시작되지 않습니다. ## 도구 가드레일 -도구 가드레일은 **`FunctionTool` 인스턴스**를 래핑하며, 해당 도구 호출을 실행 전후에 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. +도구 가드레일은 **`FunctionTool` 인스턴스**를 래핑하며, 해당 도구의 실행 전후에 호출을 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. -- 입력 도구 가드레일은 도구가 실행되기 전에 실행되며, 호출을 건너뛰거나 출력을 메시지로 대체하거나 트립와이어를 발생시킬 수 있습니다. -- 출력 도구 가드레일은 도구가 실행된 후 실행되며, 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. -- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후, 실행 직전에 실행됩니다. 승인 대기 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 도구 실행 전 승인 후에 다시 검사됩니다. -- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 거치므로, 도구 가드레일은 핸드오프 호출 자체에 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 현재 도구 가드레일 옵션을 직접 노출하지 않습니다. +- 입력 도구 가드레일은 도구 실행 전에 실행되며, 호출을 건너뛰거나 출력을 메시지로 대체하거나 트립와이어를 발생시킬 수 있습니다. +- 출력 도구 가드레일은 도구 실행 후에 실행되며, 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. +- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후 실행 직전에 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 도구가 실행되기 전에 다시 검사됩니다. +- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성한 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 통해 실행되므로, 도구 가드레일은 핸드오프 호출 자체에 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 내장 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 현재 도구 가드레일 옵션을 직접 노출하지 않습니다. -자세한 내용은 아래 코드 스니펫을 참조하세요. +자세한 내용은 아래 코드 스니펫을 참고하세요. ## 트립와이어 -에이전트 입력 또는 출력이 가드레일을 통과하지 못하면 가드레일은 트립와이어로 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 이에 대응하는 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. +에이전트 입력이나 출력이 가드레일을 통과하지 못하면 가드레일은 트립와이어로 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 각각 해당하는 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. -에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`은 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 입력 트립와이어를 발생시킨 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어는 `exception.run_data.output_guardrail_results`을 통해 이에 상응하는 누적 결과를 제공합니다. +에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`은 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 발생시킨 입력 트립와이어의 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어는 `exception.run_data.output_guardrail_results`를 통해 이에 해당하는 누적 결과를 제공합니다. -반면 도구 트립와이어 예외는 트리거한 `guardrail` 및 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록에는 실패 전에 완료된 턴에서 누적된 결과가 보존되며, 트리거한 결과는 예외의 `output`을 통해 확인할 수 있습니다. `MaxTurnsExceeded`과 같은 러너 관리형 실패도 완료된 도구 가드레일 결과를 이러한 목록에 보존합니다. `stream_events()`이 예외를 발생시킨 후 스트리밍된 결과는 누적된 동일한 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생한 경우 `run_data`은 `None`일 수 있습니다. +반면 도구 트립와이어 예외는 트리거한 `guardrail`과 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록은 실패 전에 완료된 턴에서 누적된 결과를 보존하며, 트리거한 결과는 예외의 `output`을 통해 확인할 수 있습니다. `MaxTurnsExceeded`과 같이 러너가 관리하는 다른 실패도 완료된 도구 가드레일 결과를 이러한 목록에 보존합니다. `stream_events()`에서 예외가 발생한 후 스트리밍 결과는 동일하게 누적된 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생한 경우 `run_data`은 `None`일 수 있습니다. ## 가드레일 구현 @@ -129,12 +131,12 @@ async def main(): print("Math homework guardrail tripped") ``` -1. 이 에이전트를 가드레일 함수에서 사용합니다. -2. 에이전트의 입력/컨텍스트를 받고 결과를 반환하는 가드레일 함수입니다. +1. 가드레일 함수에서 이 에이전트를 사용합니다. +2. 에이전트의 입력과 컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. 3. 가드레일 결과에 추가 정보를 포함할 수 있습니다. 4. 워크플로를 정의하는 실제 에이전트입니다. -출력 가드레일도 이와 유사합니다. +출력 가드레일도 유사합니다. ```python from pydantic import BaseModel @@ -187,9 +189,9 @@ async def main(): print("Math output guardrail tripped") ``` -1. 실제 에이전트의 출력 유형입니다. -2. 가드레일의 출력 유형입니다. -3. 에이전트의 출력을 받고 결과를 반환하는 가드레일 함수입니다. +1. 실제 에이전트의 출력 타입입니다. +2. 가드레일의 출력 타입입니다. +3. 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. 4. 워크플로를 정의하는 실제 에이전트입니다. 마지막으로 도구 가드레일의 예제는 다음과 같습니다. diff --git a/docs/ko/human_in_the_loop.md b/docs/ko/human_in_the_loop.md index 253e92d394..9eb6a65692 100644 --- a/docs/ko/human_in_the_loop.md +++ b/docs/ko/human_in_the_loop.md @@ -4,19 +4,19 @@ search: --- # 휴먼인더루프 (HITL) -휴먼인더루프 (HITL) 흐름을 사용하면 사람이 민감한 도구 호출을 승인하거나 거부할 때까지 에이전트 실행을 일시 중지할 수 있습니다. 도구는 승인이 필요한 시점을 선언하고, 실행 결과는 보류 중인 승인을 인터럽션(중단 처리)으로 표시하며, `RunState`을 사용하면 일시 중지된 실행을 직렬화하고 결정이 내려진 후 재개할 수 있습니다. +휴먼인더루프 (HITL) 흐름을 사용하면 사람이 민감한 도구 호출을 승인하거나 거부할 때까지 에이전트 실행을 일시 중지할 수 있습니다. 도구는 승인이 필요한 시점을 선언하고, 실행 결과는 대기 중인 승인을 인터럽션(중단 처리)으로 노출하며, `RunState`를 사용하면 일시 중지된 실행을 직렬화하고 결정이 내려진 후 재개할 수 있습니다. -이 승인 메커니즘의 범위는 현재 최상위 에이전트에 국한되지 않고 실행 전체에 적용됩니다. 도구가 현재 에이전트, 핸드오프를 통해 도달한 에이전트 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에 속하는 경우에도 동일한 패턴이 적용됩니다. 중첩된 `Agent.as_tool()`의 경우에도 인터럽션(중단 처리)은 외부 실행에 표시되므로, 외부 `RunState`에서 이를 승인하거나 거부한 다음 원래의 최상위 실행을 재개합니다. +이 승인 인터페이스는 현재 최상위 에이전트에 국한되지 않고 실행 전체에 적용됩니다. 도구가 현재 에이전트에 속하는 경우, 핸드오프를 통해 도달한 에이전트에 속하는 경우, 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에 속하는 경우 모두 같은 패턴이 적용됩니다. 중첩된 `Agent.as_tool()`의 경우에도 인터럽션은 외부 실행에 노출되므로, 외부 `RunState`에서 승인하거나 거부한 후 원래의 최상위 실행을 재개합니다. -`Agent.as_tool()`를 사용하면 두 계층에서 승인이 발생할 수 있습니다. 에이전트 도구 자체가 `Agent.as_tool(..., needs_approval=...)`를 통해 승인을 요구할 수 있으며, 중첩된 실행이 시작된 후 중첩된 에이전트 내부의 도구가 자체 승인을 요청할 수도 있습니다. 두 경우 모두 동일한 외부 실행 인터럽션(중단 처리) 흐름을 통해 처리됩니다. +`Agent.as_tool()`를 사용하면 두 계층에서 승인이 발생할 수 있습니다. 에이전트 도구 자체가 `Agent.as_tool(..., needs_approval=...)`를 통해 승인을 요구할 수 있고, 중첩 실행이 시작된 후 중첩된 에이전트 내부의 도구가 자체 승인을 요청할 수도 있습니다. 두 경우 모두 동일한 외부 실행의 인터럽션(중단 처리) 흐름을 통해 처리됩니다. -이 페이지에서는 `interruptions`을 통한 수동 승인 흐름을 중점적으로 설명합니다. 애플리케이션이 코드에서 결정할 수 있다면, 일부 도구 유형은 프로그래밍 방식의 승인 콜백도 지원하므로 실행을 일시 중지하지 않고 계속할 수 있습니다. +이 페이지에서는 `interruptions`를 통한 수동 승인 흐름을 중점적으로 설명합니다. 애플리케이션이 코드에서 결정을 내릴 수 있다면 일부 도구 유형은 프로그래밍 방식의 승인 콜백도 지원하므로 실행을 일시 중지하지 않고 계속할 수 있습니다. ## 승인이 필요한 도구 표시 -항상 승인을 요구하려면 `needs_approval`을 `True`로 설정하거나, 호출별로 결정하는 비동기 함수를 제공합니다. 호출 가능 객체는 실행 컨텍스트, 파싱된 도구 매개변수, 도구 호출 ID를 전달받습니다. +항상 승인을 요구하려면 `needs_approval`을 `True`로 설정하고, 호출별로 결정하려면 비동기 함수를 제공합니다. 이 호출 가능 객체는 실행 컨텍스트, 파싱된 도구 매개변수, 도구 호출 ID를 받습니다. -SDK가 인수를 안전하게 검사할 수 없는 경우 호출 가능 승인 규칙은 안전을 위해 승인을 요구합니다. 인수가 잘못된 JSON이거나, 유효한 JSON이지만 객체가 아니거나(예: `null` 또는 목록), `NaN`, `Infinity`, `-Infinity` 같은 비표준 상수를 포함하면 호출 가능 객체가 호출되지 않으며 해당 호출에는 수동 승인이 필요합니다. 이 동작은 Runner와 Realtime 도구 호출에서 동일합니다. +SDK가 인수를 안전하게 검사할 수 없는 경우 호출 가능 승인 규칙은 기본적으로 승인을 요구합니다. 인수가 잘못된 JSON이거나, 유효한 JSON이지만 객체가 아니거나(예: `null` 또는 목록), `NaN`, `Infinity`, `-Infinity` 같은 비표준 상수를 포함하면 호출 가능 객체는 호출되지 않으며 해당 호출에는 수동 승인이 필요합니다. 이 동작은 Runner와 Realtime 도구 호출에서 동일합니다. ```python from agents import Agent @@ -44,26 +44,28 @@ agent = Agent( ) ``` -`needs_approval`은 [`function_tool`][agents.tool.function_tool], [`Agent.as_tool`][agents.agent.Agent.as_tool], [`ShellTool`][agents.tool.ShellTool], [`ApplyPatchTool`][agents.tool.ApplyPatchTool]에서 사용할 수 있습니다. 로컬 MCP 서버도 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]의 `require_approval`을 통해 승인을 지원합니다. 호스티드 MCP 서버는 [`HostedMCPTool`][agents.tool.HostedMCPTool]에서 `tool_config={"require_approval": "always"}`과 선택적인 `on_approval_request` 콜백을 통해 승인을 지원합니다. 인터럽션(중단 처리)을 표시하지 않고 자동 승인하거나 자동 거부하려는 경우 셸 및 apply_patch 도구에서 `on_approval` 콜백을 사용할 수 있습니다. +`needs_approval`은 [`function_tool`][agents.tool.function_tool], [`Agent.as_tool`][agents.agent.Agent.as_tool], [`ShellTool`][agents.tool.ShellTool], [`ApplyPatchTool`][agents.tool.ApplyPatchTool]에서 사용할 수 있습니다. 로컬 MCP 서버도 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]의 `require_approval`을 통해 승인을 지원합니다. 호스티드 MCP 서버는 [`HostedMCPTool`][agents.tool.HostedMCPTool]에서 `tool_config={"require_approval": "always"}` 및 선택적인 `on_approval_request` 콜백을 통해 승인을 지원합니다. 셸 및 apply_patch 도구에서는 인터럽션(중단 처리)을 노출하지 않고 자동으로 승인하거나 거부하려는 경우 `on_approval` 콜백을 사용할 수 있습니다. ## 승인 흐름의 작동 방식 -1. 모델이 도구 호출을 내보내면 Runner가 해당 승인 규칙(`needs_approval`, `require_approval` 또는 이에 대응하는 호스티드 MCP 규칙)을 평가합니다. -2. 해당 도구 호출에 대한 승인 결정이 이미 [`RunContextWrapper`][agents.run_context.RunContextWrapper]에 저장되어 있으면 Runner는 승인 요청 없이 진행합니다. 호출별 승인은 특정 호출 ID로 범위가 제한됩니다. 남은 실행 동안 해당 도구의 향후 호출에도 동일한 결정을 유지하려면 `always_approve=True` 또는 `always_reject=True`을 전달합니다. -3. 승인 규칙상 승인이 필요하지만 해당 도구 호출에 대한 결정이 저장되어 있지 않으면 실행이 일시 중지되고, `RunResult.interruptions`(또는 `RunResultStreaming.interruptions`)에 `agent.name`, `tool_name`, `arguments` 등의 세부 정보가 포함된 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 항목이 담깁니다. 여기에는 핸드오프 이후 또는 중첩된 `Agent.as_tool()` 실행 내부에서 발생한 승인도 포함됩니다. -4. `result.to_state()`를 사용하여 결과를 `RunState`로 변환하고, `state.approve(...)` 또는 `state.reject(...)`을 호출한 다음, `Runner.run(agent, state)` 또는 `Runner.run_streamed(agent, state)`으로 재개합니다. 여기서 `agent`는 해당 실행의 원래 최상위 에이전트입니다. -5. 재개된 실행은 중단된 지점부터 계속되며, 새 승인이 필요하면 이 흐름에 다시 진입합니다. +1. 모델이 도구 호출을 생성하면 Runner가 해당 승인 규칙(`needs_approval`, `require_approval` 또는 이에 해당하는 호스티드 MCP 규칙)을 평가합니다. +2. 해당 도구 호출에 관한 승인 결정이 이미 [`RunContextWrapper`][agents.run_context.RunContextWrapper]에 저장되어 있으면 Runner는 확인을 요청하지 않고 진행합니다. 호출별 승인은 특정 호출 ID에만 적용됩니다. 실행의 나머지 기간에 동일한 도구 ID를 사용하는 향후 호출에도 같은 결정을 유지하려면 `always_approve=True` 또는 `always_reject=True`을 전달합니다. +3. 승인 규칙상 승인이 필요하지만 해당 도구 호출에 관한 결정이 저장되어 있지 않으면 실행이 일시 중지되고, `RunResult.interruptions`(또는 `RunResultStreaming.interruptions`)에 `agent.name`, `tool_name`, `arguments` 등의 세부 정보가 포함된 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 항목이 들어갑니다. 여기에는 핸드오프 후 또는 중첩된 `Agent.as_tool()` 실행 내부에서 발생한 승인도 포함됩니다. +4. `result.to_state()`를 사용해 결과를 `RunState`로 변환하고 `state.approve(...)` 또는 `state.reject(...)`을 호출한 다음, `Runner.run(agent, state)` 또는 `Runner.run_streamed(agent, state)`으로 재개합니다. 여기서 `agent`는 해당 실행의 원래 최상위 에이전트입니다. +5. 재개된 실행은 중단된 지점부터 계속되며, 새로운 승인이 필요하면 이 흐름에 다시 진입합니다. -`always_approve=True` 또는 `always_reject=True`으로 생성된 고정 결정은 실행 상태에 저장되므로, 나중에 동일한 일시 중지 실행을 재개할 때 `state.to_string()` / `RunState.from_string(...)` 및 `state.to_json()` / `RunState.from_json(...)`을 거쳐도 유지됩니다. +`always_approve=True` 또는 `always_reject=True`으로 생성한 지속 결정은 실행 상태에 저장되므로, 나중에 동일한 일시 중지 실행을 재개할 때 `state.to_string()` / `RunState.from_string(...)` 및 `state.to_json()` / `RunState.from_json(...)`을 거쳐도 유지됩니다. -보류 중인 모든 승인을 한 번에 처리할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 함께 포함될 수 있습니다. 일부 항목만 승인하거나 거부한 후 다시 실행하면 처리된 호출은 계속 진행되고, 처리되지 않은 호출은 `interruptions`에 남아 실행을 다시 일시 중지할 수 있습니다. +[`HostedMCPTool`][agents.tool.HostedMCPTool]에서 발생한 승인 요청의 경우 Agents SDK는 `server_label`와 도구 이름의 조합으로 지속 도구 결정을 식별합니다. 한 호스티드 MCP 서버의 `lookup_account`에 대한 항상 승인 결정은 다른 서버에서 이름이 같은 도구를 승인하지 않습니다. Agents SDK는 호스티드 MCP 승인 요청에 비어 있지 않은 두 ID 필드가 모두 포함된 경우에만 항상 승인 또는 항상 거부 결정을 유지합니다. + +대기 중인 모든 승인을 한 번에 처리할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 함께 포함될 수 있습니다. 일부 항목만 승인하거나 거부한 후 다시 실행하면 처리된 호출은 계속 진행되고, 미처리된 호출은 `interruptions`에 남아 실행을 다시 일시 중지합니다. ## 사용자 지정 거부 메시지 -기본적으로 거부된 도구 호출은 SDK의 표준 거부 텍스트를 실행에 반환합니다. 다음 두 계층에서 이 메시지를 사용자 지정할 수 있습니다. +기본적으로 거부된 도구 호출은 SDK의 표준 거부 텍스트를 실행에 반환합니다. 이 메시지는 두 계층에서 사용자 지정할 수 있습니다. -- 실행 전체의 대체 동작: [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]를 설정하여 전체 실행에서 승인 거부 시 모델에 표시되는 기본 메시지를 제어합니다. -- 호출별 재정의: 특정 거부된 도구 호출 하나에 다른 메시지를 표시하려면 `state.reject(...)`에 `rejection_message=...`를 전달합니다. +- 실행 전체의 대체 메시지: [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]을 설정하여 실행 전체에서 승인 거부 시 모델에 표시되는 기본 메시지를 제어합니다. +- 호출별 재정의: 특정 거부 도구 호출 하나에 다른 메시지를 표시하려면 `state.reject(...)`에 `rejection_message=...`을 전달합니다. 둘 다 제공하면 호출별 `rejection_message`이 실행 전체 포매터보다 우선합니다. @@ -86,27 +88,27 @@ state.reject( ) ``` -두 계층을 함께 사용하는 전체 예제는 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)에서 확인할 수 있습니다. +두 계층을 함께 사용하는 전체 예제는 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)을 참조하세요. ## 자동 승인 결정 -수동 `interruptions`은 가장 일반적인 패턴이지만 유일한 방식은 아닙니다. +수동 `interruptions`이 가장 일반적인 패턴이지만 유일한 방식은 아닙니다. - 로컬 [`ShellTool`][agents.tool.ShellTool] 및 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]은 `on_approval`를 사용하여 코드에서 즉시 승인하거나 거부할 수 있습니다. -- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 `tool_config={"require_approval": "always"}`와 `on_approval_request`를 함께 사용하여 동일한 방식으로 프로그래밍 방식의 결정을 내릴 수 있습니다. +- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 `tool_config={"require_approval": "always"}`과 `on_approval_request`을 함께 사용하여 같은 방식의 프로그래밍 방식 결정을 내릴 수 있습니다. - 일반 [`function_tool`][agents.tool.function_tool] 도구와 [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 이 페이지의 수동 인터럽션(중단 처리) 흐름을 사용합니다. -이러한 콜백이 결정을 반환하면 사람의 응답을 기다리기 위해 일시 중지하지 않고 실행이 계속됩니다. Realtime 및 음성 세션 API의 경우 [Realtime 가이드](realtime/guide.md)의 승인 흐름을 참조하세요. +이러한 콜백이 결정을 반환하면 사람의 응답을 기다리기 위해 일시 중지하지 않고 실행이 계속됩니다. Realtime 및 음성 세션 API는 [Realtime 가이드](realtime/guide.md)의 승인 흐름을 참조하세요. ## 스트리밍 및 세션 -동일한 인터럽션(중단 처리) 흐름이 스트리밍 실행에서도 작동합니다. 스트리밍된 실행이 일시 중지되면 반복자가 끝날 때까지 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events]을 계속 소비하고, [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]을 검사하여 처리한 다음, 재개된 출력도 계속 스트리밍하려면 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]으로 재개합니다. 이 패턴의 스트리밍 버전은 [스트리밍](streaming.md)을 참조하세요. +동일한 인터럽션(중단 처리) 흐름이 스트리밍 실행에서도 작동합니다. 스트리밍된 실행이 일시 중지된 후 반복자가 끝날 때까지 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events]을 계속 소비하고, [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]을 검사하여 처리한 다음, 재개된 출력에서도 스트리밍을 유지하려면 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]으로 재개합니다. 이 패턴의 스트리밍 버전은 [스트리밍](streaming.md)을 참조하세요. -세션도 사용 중이라면 `RunState`에서 재개할 때 동일한 세션 인스턴스를 계속 전달하거나, 동일한 세션 ID와 백업 스토어를 사용하도록 구성된 다른 세션 객체를 전달합니다. 그러면 재개된 턴이 저장된 동일한 대화 기록에 추가됩니다. 세션 수명 주기에 대한 자세한 내용은 [세션](sessions/index.md)을 참조하세요. +세션도 사용 중이라면 `RunState`에서 재개할 때 동일한 세션 인스턴스를 계속 전달하거나, 동일한 세션 ID 및 백업 스토어를 사용하도록 구성된 다른 세션 객체를 전달합니다. 그러면 재개된 턴이 동일하게 저장된 대화 기록에 추가됩니다. 세션 수명 주기에 관한 자세한 내용은 [세션](sessions/index.md)을 참조하세요. -## 예제: 일시 중지, 승인 및 재개 +## 예제: 일시 중지, 승인, 재개 -아래 스니펫은 JavaScript HITL 가이드의 흐름을 재현합니다. 도구에 승인이 필요하면 실행을 일시 중지하고, 상태를 디스크에 저장한 후 다시 불러오며, 결정을 수집한 다음 실행을 재개합니다. +아래 코드 조각은 JavaScript HITL 가이드와 동일한 흐름을 보여 줍니다. 도구에 승인이 필요하면 일시 중지하고, 상태를 디스크에 저장하고, 다시 로드한 후 결정을 수집하여 재개합니다. ```python import asyncio @@ -171,35 +173,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -이 예제에서 `prompt_approval`은 `input()`을 사용하고 `run_in_executor(...)`로 실행되므로 동기식입니다. 승인 소스가 이미 비동기 방식이라면(예: HTTP 요청 또는 비동기 데이터베이스 쿼리) `async def` 함수를 사용하고 이를 직접 `await`할 수 있습니다. +이 예제에서 `prompt_approval`는 `input()`을 사용하고 `run_in_executor(...)`으로 실행되므로 동기식입니다. 승인 소스가 이미 비동기식이라면(예: HTTP 요청 또는 비동기 데이터베이스 쿼리) `async def` 함수를 사용하고 직접 `await`할 수 있습니다. -승인을 위해 일시 중지될 수 있는 실행에서 스트리밍을 사용하려면 `Runner.run_streamed`을 호출하고 완료될 때까지 `result.stream_events()`을 소비한 다음, 위에 표시된 것과 동일하게 `result.to_state()` 및 재개 단계를 수행합니다. +승인을 위해 일시 중지될 수 있는 실행에서 스트리밍을 사용하려면 `Runner.run_streamed`을 호출하고 완료될 때까지 `result.stream_events()`을 소비한 다음, 위에 나온 것과 동일한 `result.to_state()` 및 재개 단계를 따릅니다. ## 저장소 패턴 및 코드 예제 -- **스트리밍 승인**: `examples/agent_patterns/human_in_the_loop_stream.py`은 `stream_events()`을 끝까지 소비한 다음, `Runner.run_streamed(agent, state)`로 재개하기 전에 보류 중인 도구 호출을 승인하는 방법을 보여줍니다. -- **사용자 지정 거부 텍스트**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py`은 승인이 거부될 때 실행 수준의 `tool_error_formatter`와 호출별 `rejection_message` 재정의를 결합하는 방법을 보여줍니다. -- **에이전트 도구 승인**: `Agent.as_tool(..., needs_approval=...)`은 위임된 에이전트 작업에 검토가 필요할 때 동일한 인터럽션(중단 처리) 흐름을 적용합니다. 중첩된 인터럽션(중단 처리)도 외부 실행에 표시되므로 중첩된 에이전트가 아니라 원래의 최상위 에이전트를 재개합니다. -- **로컬 셸 및 apply_patch 도구**: `ShellTool` 및 `ApplyPatchTool`도 `needs_approval`을 지원합니다. 남은 실행 동안 해당 도구의 향후 호출을 위해 결정을 캐시하려면 `state.approve(interruption, always_approve=True)` 또는 `state.reject(..., always_reject=True)`을 사용합니다. 자동 결정의 경우 `on_approval`을 제공합니다(`examples/tools/shell.py` 참조). 수동 결정의 경우 인터럽션(중단 처리)을 처리합니다(`examples/tools/shell_human_in_the_loop.py` 참조). 호스티드 셸 환경은 `needs_approval` 또는 `on_approval`을 지원하지 않습니다. [도구 가이드](tools.md)를 참조하세요. +- **스트리밍 승인**: `examples/agent_patterns/human_in_the_loop_stream.py`은 `stream_events()`을 모두 소비한 다음, `Runner.run_streamed(agent, state)`으로 재개하기 전에 대기 중인 도구 호출을 승인하는 방법을 보여 줍니다. +- **사용자 지정 거부 텍스트**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py`은 승인이 거부될 때 실행 수준 `tool_error_formatter`과 호출별 `rejection_message` 재정의를 결합하는 방법을 보여 줍니다. +- **도구로 사용하는 에이전트 승인**: `Agent.as_tool(..., needs_approval=...)`은 위임된 에이전트 작업을 검토해야 할 때 동일한 인터럽션(중단 처리) 흐름을 적용합니다. 중첩된 인터럽션(중단 처리)도 외부 실행에 노출되므로 중첩된 에이전트가 아니라 원래의 최상위 에이전트를 재개합니다. +- **로컬 셸 및 apply_patch 도구**: `ShellTool` 및 `ApplyPatchTool`도 `needs_approval`을 지원합니다. 실행의 나머지 기간에 해당 도구를 향후 호출할 때 사용할 결정을 캐시하려면 `state.approve(interruption, always_approve=True)` 또는 `state.reject(..., always_reject=True)`을 사용합니다. 자동 결정에는 `on_approval`을 제공합니다(`examples/tools/shell.py` 참조). 수동 결정에는 인터럽션(중단 처리)을 처리합니다(`examples/tools/shell_human_in_the_loop.py` 참조). 호스티드 셸 환경은 `needs_approval` 또는 `on_approval`을 지원하지 않습니다. [도구 가이드](tools.md)를 참조하세요. - **로컬 MCP 서버**: MCP 도구 호출을 제한하려면 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp`에서 `require_approval`을 사용합니다(`examples/mcp/get_all_mcp_tools_example/main.py` 및 `examples/mcp/tool_filter_example/main.py` 참조). -- **호스티드 MCP 서버**: HITL을 강제하려면 `HostedMCPTool`에서 `tool_config={"require_approval": "always"}`을 설정하고, 선택적으로 `on_approval_request`을 제공하여 자동 승인하거나 거부합니다(`examples/hosted_mcp/human_in_the_loop.py` 및 `examples/hosted_mcp/on_approval.py` 참조). 신뢰할 수 있는 서버에는 `"never"`을 사용합니다(`examples/hosted_mcp/simple.py` 참조). -- **세션 및 메모리**: 승인과 대화 기록이 여러 턴에 걸쳐 유지되도록 `Runner.run`에 세션을 전달합니다. SQLite 및 OpenAI Conversations 세션 변형은 `examples/memory/memory_session_hitl_example.py`과 `examples/memory/openai_session_hitl_example.py`에 있습니다. -- **실시간 에이전트**: Realtime 데모는 `RealtimeSession`의 `approve_tool_call` / `reject_tool_call`을 통해 도구 호출을 승인하거나 거부하는 WebSocket 메시지를 제공합니다. 서버 측 핸들러는 `examples/realtime/app/server.py`을, API 인터페이스는 [Realtime 가이드](realtime/guide.md#tool-approvals)를 참조하세요. +- **호스티드 MCP 서버**: HITL을 강제하려면 `HostedMCPTool`에서 `tool_config={"require_approval": "always"}`을 설정하고, 선택적으로 자동 승인 또는 거부를 위한 `on_approval_request`을 제공합니다(`examples/hosted_mcp/human_in_the_loop.py` 및 `examples/hosted_mcp/on_approval.py` 참조). 신뢰할 수 있는 서버에는 `"never"`을 사용합니다(`examples/hosted_mcp/simple.py` 참조). +- **세션 및 메모리**: 승인과 대화 기록이 여러 턴에 걸쳐 유지되도록 `Runner.run`에 세션을 전달합니다. SQLite 및 OpenAI Conversations 세션 변형은 `examples/memory/memory_session_hitl_example.py` 및 `examples/memory/openai_session_hitl_example.py`에 있습니다. +- **실시간 에이전트**: 실시간 데모는 `RealtimeSession`에서 `approve_tool_call` / `reject_tool_call`을 통해 도구 호출을 승인하거나 거부하는 WebSocket 메시지를 제공합니다. 서버 측 핸들러는 `examples/realtime/app/server.py`을, API 인터페이스는 [Realtime 가이드](realtime/guide.md#tool-approvals)를 참조하세요. ## 장기 실행 승인 -`RunState`은 지속성을 고려하여 설계되었습니다. `state.to_json()` 또는 `state.to_string()`을 사용하여 보류 중인 작업을 데이터베이스나 큐에 저장하고, 나중에 `RunState.from_json(...)` 또는 `RunState.from_string(...)`로 다시 생성합니다. +`RunState`은 지속 가능하도록 설계되었습니다. `state.to_json()` 또는 `state.to_string()`을 사용하여 대기 중인 작업을 데이터베이스나 큐에 저장하고, 나중에 `RunState.from_json(...)` 또는 `RunState.from_string(...)`으로 다시 생성합니다. 유용한 직렬화 옵션은 다음과 같습니다. -- `context_serializer`: 매핑이 아닌 컨텍스트 객체가 직렬화되는 방식을 사용자 지정합니다. -- `context_deserializer`: `RunState.from_json(...)` 또는 `RunState.from_string(...)`로 상태를 불러올 때 매핑이 아닌 컨텍스트 객체를 다시 구성합니다. -- `strict_context=True`: 컨텍스트가 이미 매핑이거나 `context_serializer`을 제공한 경우가 아니면 직렬화에 실패합니다. 컨텍스트가 이미 매핑이거나 `context_deserializer`을 제공한 경우가 아니면 역직렬화에 실패합니다. -- `context_override`: 상태를 불러올 때 직렬화된 컨텍스트를 대체합니다. 원래 컨텍스트 객체를 복원하지 않으려는 경우 유용하지만, 이미 직렬화된 페이로드에서 해당 컨텍스트를 제거하지는 않습니다. -- `include_tracing_api_key=True`: 재개된 작업이 동일한 자격 증명으로 트레이스를 계속 내보내야 할 때 직렬화된 트레이스 페이로드에 트레이싱 API 키를 포함합니다. +- `context_serializer`: 매핑이 아닌 컨텍스트 객체의 직렬화 방식을 사용자 지정합니다. +- `context_deserializer`: `RunState.from_json(...)` 또는 `RunState.from_string(...)`을 사용하여 상태를 로드할 때 매핑이 아닌 컨텍스트 객체를 다시 구성합니다. +- `strict_context=True`: 컨텍스트가 이미 매핑이거나 `context_serializer`을 제공한 경우가 아니면 직렬화에 실패하고, 컨텍스트가 이미 매핑이거나 `context_deserializer`을 제공한 경우가 아니면 역직렬화에 실패합니다. +- `context_override`: 상태를 로드할 때 직렬화된 컨텍스트를 대체합니다. 원래 컨텍스트 객체를 복원하지 않으려는 경우 유용하지만, 이미 직렬화된 페이로드에서 해당 컨텍스트를 제거하지는 않습니다. +- `include_tracing_api_key=True`: 재개된 작업이 동일한 자격 증명으로 트레이스를 계속 내보내야 하는 경우 직렬화된 트레이스 페이로드에 트레이싱 API 키를 포함합니다. -직렬화된 실행 상태에는 애플리케이션 컨텍스트뿐 아니라 승인, 사용량, 직렬화된 `tool_input`, 중첩된 에이전트 도구 실행의 재개 정보, 트레이스 메타데이터, 서버 관리형 대화 설정 등 SDK가 관리하는 런타임 메타데이터도 포함됩니다. 직렬화된 상태를 저장하거나 전송할 계획이라면 `RunContextWrapper.context`을 영구 저장 데이터로 취급하고, 의도적으로 상태와 함께 전달하려는 경우가 아니라면 그 안에 비밀 정보를 넣지 마세요. +직렬화된 실행 상태에는 애플리케이션 컨텍스트와 함께 승인, 사용량, 직렬화된 `tool_input`, 중첩된 도구로서의 에이전트 실행 재개, 트레이스 메타데이터, 서버 관리형 대화 설정 등 SDK가 관리하는 런타임 메타데이터가 포함됩니다. 직렬화된 상태를 저장하거나 전송하려는 경우 `RunContextWrapper.context`를 영구 데이터로 취급하고, 상태와 함께 이동하도록 의도한 경우가 아니라면 여기에 비밀 정보를 넣지 마세요. -## 보류 중인 작업의 버전 관리 +## 대기 중인 작업의 버전 관리 -승인이 한동안 보류될 수 있다면 직렬화된 상태와 함께 에이전트 정의 또는 SDK의 버전 표시자를 저장합니다. 그러면 모델, 프롬프트 또는 도구 정의가 변경될 때 비호환성을 방지하도록 역직렬화 과정을 일치하는 코드 경로로 라우팅할 수 있습니다. \ No newline at end of file +승인이 장시간 대기할 수 있다면 직렬화된 상태와 함께 에이전트 정의 또는 SDK의 버전 표시를 저장합니다. 그러면 역직렬화 시 일치하는 코드 경로로 라우팅하여 모델, 프롬프트 또는 도구 정의가 변경될 때 발생하는 비호환성을 방지할 수 있습니다. \ No newline at end of file diff --git a/docs/ko/mcp.md b/docs/ko/mcp.md index 52d21834d6..cba5938c4c 100644 --- a/docs/ko/mcp.md +++ b/docs/ko/mcp.md @@ -8,27 +8,55 @@ search: 컨텍스트를 노출하는 방식을 표준화합니다. 공식 문서에서는 다음과 같이 설명합니다. > MCP는 애플리케이션이 LLM에 컨텍스트를 제공하는 방식을 표준화하는 개방형 프로토콜입니다. MCP를 AI -> 애플리케이션용 USB-C 포트라고 생각하면 됩니다. USB-C가 기기를 다양한 주변 장치 및 액세서리에 연결하는 표준화된 방식을 제공하듯이, MCP는 -> AI 모델을 다양한 데이터 소스와 도구에 연결하는 표준화된 방식을 제공합니다. +> 애플리케이션용 USB-C 포트라고 생각해 보세요. USB-C가 기기를 다양한 주변 장치와 액세서리에 연결하는 표준화된 방식을 제공하듯이, MCP는 +> AI 모델을 다양한 데이터 소스 및 도구에 연결하는 표준화된 방식을 제공합니다. -Python용 Agents SDK는 여러 MCP 전송 방식을 지원합니다. 따라서 기존 MCP 서버를 재사용하거나 자체 서버를 구축하여 파일 시스템, HTTP 또는 커넥터 기반 도구를 에이전트에 노출할 수 있습니다. +Agents Python SDK는 여러 MCP 전송 방식을 지원합니다. 따라서 기존 MCP 서버를 재사용하거나 자체 서버를 구축하여 파일 시스템, HTTP 또는 커넥터 기반 도구를 에이전트에 노출할 수 있습니다. !!! warning "연결 전 MCP 서버 신뢰성 확인" - MCP 도구는 모델 컨텍스트의 데이터를 노출하고 제공된 인증 정보로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 인증 정보를 사용하며, 액세스 토큰은 URL이 아닌 authorization 필드나 헤더에 보관하고, 민감한 작업에는 승인을 요구해야 합니다. [OpenAI MCP 보안 가이드](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)를 참고하세요. + MCP 도구는 모델 컨텍스트의 데이터를 노출하고 제공된 자격 증명으로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 자격 증명을 사용하며, 액세스 토큰을 URL이 아닌 인증 필드나 헤더에 보관하고, 민감한 작업에는 승인을 요구해야 합니다. [OpenAI MCP 보안 지침](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)을 참고하세요. ## MCP 통합 선택 -MCP 서버를 에이전트에 연결하기 전에 도구 호출을 어디에서 실행할지와 어떤 전송 방식에 접근할 수 있는지 결정해야 합니다. 아래 표에는 Python SDK가 지원하는 옵션이 요약되어 있습니다. +MCP 서버를 에이전트에 연결하기 전에 도구 호출을 실행할 위치와 접근 가능한 전송 방식을 결정해야 합니다. 아래 표는 Python SDK가 지원하는 옵션을 요약합니다. -| 필요한 작업 | 권장 옵션 | +| 필요한 사항 | 권장 옵션 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| OpenAI Responses API가 모델을 대신하여 공개적으로 접근 가능한 MCP 서버를 호출하도록 함| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | -| 로컬 또는 원격에서 실행하는 Streamable HTTP 서버에 연결 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 통한 **Streamable HTTP MCP 서버** | -| Server-Sent Events 방식의 HTTP를 구현한 서버와 통신 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **SSE 기반 HTTP MCP 서버** | -| 로컬 프로세스를 실행하고 stdin/stdout을 통해 통신 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 통한 **stdio MCP 서버** | +| OpenAI의 Responses API가 모델을 대신하여 공개적으로 접근 가능한 MCP 서버를 호출하도록 구성| [`HostedMCPTool`][agents.tool.HostedMCPTool]을 통한 **호스티드 MCP 서버 도구** | +| 로컬 또는 원격에서 실행하는 Streamable HTTP 서버에 연결 | [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 통한 **Streamable HTTP MCP 서버** | +| Server-Sent Events를 사용하는 HTTP를 구현한 서버와 통신 | [`MCPServerSse`][agents.mcp.server.MCPServerSse]를 통한 **SSE 기반 HTTP MCP 서버** | +| 로컬 프로세스를 실행하고 stdin/stdout을 통해 통신 | [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]을 통한 **stdio MCP 서버** | -아래 섹션에서는 각 옵션과 구성 방법, 각 전송 방식을 선택해야 하는 경우를 설명합니다. +아래 섹션에서는 각 옵션의 구성 방법과 특정 전송 방식을 다른 방식보다 우선해야 하는 경우를 설명합니다. + +## MCP Python SDK v1 및 v2 + +Agents SDK는 `mcp>=1.19.0,<3` 종속성 범위를 통해 `mcp` Python 패키지의 두 주요 버전을 모두 지원합니다. 설치된 `mcp` 패키지 버전은 서버와 협상하는 MCP 프로토콜 버전과 별개입니다. Agents SDK는 설치된 패키지의 메이저 버전을 감지하고 stdio, SSE, Streamable HTTP 연결을 자동으로 조정하므로 일반적인 서버 구성에는 버전 전환 설정이 필요하지 않습니다. + +MCP Python SDK v2가 설치되어 있으면 Agents SDK는 구성된 로컬 전송 방식에 `mode="auto"`을 적용하여 v2 `mcp.Client`을 생성합니다. 클라이언트는 먼저 설치된 MCP SDK가 지원하는 최신 프로토콜 버전으로 `server/discover` 프로브를 전송합니다. 최신 서버는 프로브에 응답하고 클라이언트는 그 결과를 채택합니다. 이전 서버가 `server/discover`을 지원하지 않으면 클라이언트는 레거시 `initialize` 핸드셰이크로 폴백하고 여기에서 협상된 프로토콜 버전을 사용합니다. 따라서 MCP Python SDK v2를 설치해도 모든 연결에서 최신 MCP 프로토콜 버전을 사용하도록 강제되지는 않습니다. MCP Python SDK의 [프로토콜 버전 협상 가이드](https://py.sdk.modelcontextprotocol.io/protocol-versions/)를 참고하세요. + +대부분의 애플리케이션에서는 종속성 리졸버가 호환되는 버전을 선택하도록 해야 합니다. 애플리케이션이 특정 메이저 버전을 유지해야 한다면 `openai-agents`과 함께 명시적 제약 조건을 추가하세요. + +```bash +# MCP Python SDK v1 +pip install "mcp>=1.19.0,<2" + +# MCP Python SDK v2 +pip install "mcp>=2,<3" +``` + +HTTP 전송 방식의 사용자 정의에는 설치된 MCP 패키지가 소유한 HTTP 스택을 사용해야 합니다. + +| 사용자 정의 | MCP Python SDK v1 | MCP Python SDK v2 | +| --- | --- | --- | +| `params["auth"]` | `httpx.Auth` | `httpx2.Auth` | +| `params["httpx_client_factory"]` 반환 값 | `httpx.AsyncClient` | `httpx2.AsyncClient` | +| `MCPServerStreamableHttp` `params["ignore_initialized_notification_failure"] = True` | 지원됨 | 지원되지 않음. 연결 전에 거부됨 | + +가능하면 아래 Streamable HTTP 예제와 같이 `Authorization` 헤더를 사용하세요. `Authorization` 헤더는 두 패키지 버전 모두에서 변경 없이 작동합니다. 애플리케이션이 `params["auth"]` 또는 `params["httpx_client_factory"]`을 제공하는 경우 해당 값은 설치된 `mcp` 패키지의 메이저 버전에 맞는 HTTP 타입을 사용해야 합니다. 애플리케이션이 `MCPServerStreamableHttp`의 `params["ignore_initialized_notification_failure"] = True`을 설정하는 경우 업그레이드하기 전에 `mcp<2`을 유지하거나 해당 옵션을 비활성화해야 합니다. + +이러한 로컬 `mcp` 종속성 요구 사항은 원격 MCP 연결을 OpenAI Responses API가 관리하는 [`HostedMCPTool`][agents.tool.HostedMCPTool]에는 적용되지 않습니다. ## 에이전트 수준 MCP 구성 @@ -54,31 +82,31 @@ agent = Agent( 참고: -- `convert_schemas_to_strict`은 최선형 방식으로 동작합니다. 스키마를 변환할 수 없으면 원래 스키마를 사용합니다. +- `convert_schemas_to_strict`은 최선형 방식으로 작동합니다. 스키마를 변환할 수 없으면 원래 스키마를 사용합니다. - `failure_error_function`은 MCP 도구 호출 실패가 모델에 표시되는 방식을 제어합니다. -- `failure_error_function`을 설정하지 않으면 SDK는 기본 도구 오류 포매터를 사용합니다. -- 서버 수준의 `failure_error_function`은 해당 서버의 `Agent.mcp_config["failure_error_function"]`보다 우선합니다. -- `include_server_in_tool_names`은 옵트인 방식입니다. 활성화하면 각 로컬 MCP 도구가 결정론적인 서버 접두사 이름으로 모델에 노출되므로 여러 MCP 서버가 동일한 이름의 도구를 게시할 때 충돌을 방지하는 데 도움이 됩니다. 생성된 이름은 ASCII에 안전하고 `FunctionTool` 인스턴스의 이름 길이 제한을 준수하며, 로컬 `FunctionTool` 인스턴스에 구성된 이름이나 동일한 에이전트에서 활성화된 핸드오프와 충돌하지 않습니다. SDK는 계속해서 원래 서버에서 원래 MCP 도구 이름을 호출합니다. +- `failure_error_function`이 설정되지 않으면 SDK는 기본 도구 오류 포매터를 사용합니다. +- 서버 수준의 `failure_error_function`은 해당 서버에 대해 `Agent.mcp_config["failure_error_function"]`을 재정의합니다. +- `include_server_in_tool_names`은 선택적으로 활성화해야 합니다. 활성화하면 각 로컬 MCP 도구가 결정론적으로 생성된 서버 접두사 이름으로 모델에 노출되므로 여러 MCP 서버가 같은 이름의 도구를 게시할 때 충돌을 방지하는 데 도움이 됩니다. 생성된 이름은 ASCII에 안전하고 `FunctionTool` 인스턴스의 이름 길이 제한을 준수하며, 같은 에이전트에 구성된 로컬 `FunctionTool` 인스턴스의 이름이나 활성화된 핸드오프와 충돌하지 않습니다. SDK는 계속해서 원래 서버에서 원래 MCP 도구 이름을 호출합니다. -## 전송 방식 전반의 공통 패턴 +## 전송 방식 공통 패턴 -전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 후속 사항을 결정해야 합니다. +전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 결정을 내려야 합니다. -- 도구의 일부만 노출하는 방법([도구 필터링](#tool-filtering)) -- 서버에서 재사용 가능한 프롬프트도 제공할지 여부([프롬프트](#prompts)) -- `list_tools()`을 캐시할지 여부([캐싱](#caching)) -- MCP 활동이 트레이스에 표시되는 방식([트레이싱](#tracing)) +- 도구 일부만 노출하는 방법([도구 필터링](#tool-filtering)) +- 서버가 재사용 가능한 프롬프트도 제공하는지 여부([프롬프트](#prompts)) +- `list_tools()`의 캐싱 여부([캐싱](#caching)) +- 트레이스에서 MCP 활동이 표시되는 방식([트레이싱](#tracing)) -로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. Streamable HTTP 섹션에서 가장 완전한 코드 예제를 제공하며, 다른 로컬 전송 방식에도 동일한 패턴이 적용됩니다. +로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. Streamable HTTP 섹션에서 가장 완전한 예제를 제공하며, 동일한 패턴이 다른 로컬 전송 방식에도 적용됩니다. ## 1. 호스티드 MCP 서버 도구 -호스티드 툴은 도구의 전체 왕복 과정을 OpenAI 인프라 내부에서 처리합니다. 코드에서 도구 목록을 조회하고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API에 전달합니다. 모델은 Python 프로세스에 추가 콜백하지 않고 원격 서버의 도구 목록을 조회하고 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. +호스티드 툴은 전체 도구 왕복 과정을 OpenAI 인프라에서 처리합니다. 코드에서 도구 목록을 조회하고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API에 전달합니다. 모델은 Python 프로세스에 추가 콜백을 보내지 않고 원격 서버의 도구 목록을 조회하고 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. ### 기본 호스티드 MCP 도구 -에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 만듭니다. `tool_config` -딕셔너리는 REST API에 전송하는 JSON과 동일한 구조입니다. +에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 생성합니다. `tool_config` +딕셔너리는 REST API로 전송할 JSON과 동일한 구조를 사용합니다. ```python import asyncio @@ -110,14 +138,14 @@ async def main() -> None: asyncio.run(main()) ``` -호스티드 서버는 도구를 자동으로 노출하므로 `mcp_servers`에 추가할 필요가 없습니다. +호스티드 서버는 도구를 자동으로 노출하므로 `mcp_servers`에 추가하지 않습니다. -호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하도록 하려면 `tool_config["defer_loading"] = True`을 설정하고 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 에이전트에 추가합니다. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 설정과 제한 사항은 [도구](tools.md#hosted-tool-search)를 참고하세요. +호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하려면 `tool_config["defer_loading"] = True`을 설정하고 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 에이전트에 추가하세요. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 구성과 제약 조건은 [도구](tools.md#hosted-tool-search)를 참고하세요. ### 호스티드 MCP 결과 스트리밍 -호스티드 툴은 함수 도구와 완전히 동일한 방식으로 결과 스트리밍을 지원합니다. 모델이 계속 작업하는 동안 증분 MCP 출력을 -사용하려면 `Runner.run_streamed`을 사용합니다. +호스티드 툴은 함수 도구와 정확히 같은 방식으로 결과 스트리밍을 지원합니다. 모델이 계속 작업하는 동안 +증분 MCP 출력을 사용하려면 `Runner.run_streamed`을 사용하세요. ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -129,7 +157,7 @@ print(result.final_output) ### 선택적 승인 흐름 -서버에서 민감한 작업을 수행할 수 있는 경우 각 도구 실행 전에 사람의 승인 또는 프로그래밍 방식의 승인을 요구할 수 있습니다. `tool_config`의 `require_approval`에 단일 정책(`"always"`, `"never"`) 또는 도구 이름을 정책에 매핑하는 딕셔너리를 구성합니다. Python 내부에서 결정하려면 `on_approval_request` 콜백을 제공합니다. +서버가 민감한 작업을 수행할 수 있다면 각 도구를 실행하기 전에 사람 또는 프로그램의 승인을 요구할 수 있습니다. `tool_config`의 `require_approval`을 단일 정책(`"always"`, `"never"`) 또는 도구 이름을 정책에 매핑하는 딕셔너리로 구성하세요. Python에서 결정을 내리려면 `on_approval_request` 콜백을 제공하세요. ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -157,11 +185,11 @@ agent = Agent( ) ``` -콜백은 동기식 또는 비동기식일 수 있으며 모델이 실행을 계속하기 위해 승인 데이터가 필요할 때마다 호출됩니다. +콜백은 동기식 또는 비동기식일 수 있으며, 모델이 실행을 계속하기 위해 승인 데이터가 필요할 때마다 호출됩니다. ### 커넥터 기반 호스티드 서버 -호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`과 액세스 토큰을 제공합니다. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 노출합니다. +호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`와 액세스 토큰을 제공하세요. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 노출합니다. ```python import os @@ -177,11 +205,11 @@ HostedMCPTool( ) ``` -스트리밍, 승인, 커넥터를 포함하여 완전히 실행 가능한 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에 있습니다. +스트리밍, 승인, 커넥터를 포함하여 완전히 작동하는 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에서 확인할 수 있습니다. ## 2. Streamable HTTP MCP 서버 -네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 사용합니다. Streamable HTTP 서버는 전송 방식을 직접 제어하거나 짧은 지연 시간을 유지하면서 자체 인프라 내부에서 서버를 실행하려는 경우에 적합합니다. +네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 사용하세요. Streamable HTTP 서버는 전송 방식을 직접 제어하거나, 짧은 지연 시간을 유지하면서 자체 인프라 내에서 서버를 실행하려는 경우에 적합합니다. ```python import asyncio @@ -216,26 +244,26 @@ async def main() -> None: asyncio.run(main()) ``` -생성자는 다음과 같은 추가 옵션을 받습니다. +생성자는 다음과 같은 추가 옵션을 지원합니다. -- `client_session_timeout_seconds`은 MCP ClientSession 읽기 타임아웃을 제어합니다. `datetime.timedelta`으로 표현할 수 있으며 1마이크로초 이상인 양의 유한 값은 유한 타임아웃을 설정하고, `None`과 `0`은 이를 비활성화합니다. 그 외의 값은 서버 생성 시 거부됩니다. +- `client_session_timeout_seconds`은 MCP ClientSession 읽기 타임아웃을 제어합니다. `datetime.timedelta`으로 표현할 수 있고 최소 1마이크로초인 양의 유한 값은 유한 타임아웃을 설정하며, `None`과 `0`은 타임아웃을 비활성화합니다. 그 밖의 값은 서버 생성 시 거부됩니다. - `use_structured_content`은 텍스트 출력보다 `tool_result.structured_content`을 우선할지 여부를 전환합니다. - `max_retry_attempts`과 `retry_backoff_seconds_base`은 `list_tools()` 및 `call_tool()`에 자동 재시도를 추가합니다. -- `tool_filter`을 사용하면 도구의 일부만 노출할 수 있습니다([도구 필터링](#tool-filtering) 참고). +- `tool_filter`을 사용하면 도구 일부만 노출할 수 있습니다([도구 필터링](#tool-filtering) 참고). - `require_approval`은 로컬 MCP 도구에 휴먼인더루프 (HITL) 승인 정책을 활성화합니다. -- `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 지정합니다. 대신 오류를 발생시키려면 `None`로 설정합니다. +- `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 정의합니다. 대신 오류를 발생시키려면 `None`로 설정하세요. - `tool_meta_resolver`은 `call_tool()` 전에 호출별 MCP `_meta` 페이로드를 삽입합니다. -### 로컬 MCP 서버 승인 정책 +### 로컬 MCP 서버의 승인 정책 -`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`은 모두 `require_approval`을 받습니다. +`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`은 모두 `require_approval`을 지원합니다. -지원되는 형식은 다음과 같습니다. +지원되는 형식: -- 모든 도구에 대해 `"always"` 또는 `"never"`을 지정할 수 있습니다. -- `True`은 모든 도구에 승인을 요구하고, `False`은 어떤 도구에도 승인을 요구하지 않습니다. 각각 `"always"` 및 `"never"`과 동일합니다. -- 도구별 맵을 사용할 수 있습니다. 예: `{"delete_file": "always", "read_file": "never"}` -- 그룹화된 객체를 사용할 수 있습니다. 예: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}` +- 모든 도구에 적용되는 `"always"` 또는 `"never"` +- `True`은 모든 도구에 승인을 요구하고, `False`은 어떤 도구에도 승인을 요구하지 않음(각각 `"always"` 및 `"never"`과 동일) +- 도구별 맵(예: `{"delete_file": "always", "read_file": "never"}`) +- 그룹화된 객체: `{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}` ```python async with MCPServerStreamableHttp( @@ -246,11 +274,11 @@ async with MCPServerStreamableHttp( ... ``` -전체 일시 중지/재개 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md) 및 `examples/mcp/get_all_mcp_tools_example/main.py`을 참고하세요. +전체 일시 중지/재개 흐름은 [휴먼인더루프](human_in_the_loop.md)와 `examples/mcp/get_all_mcp_tools_example/main.py`을 참고하세요. ### `tool_meta_resolver`을 사용한 호출별 메타데이터 -MCP 서버가 `_meta`에서 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 기대하는 경우 `tool_meta_resolver`을 사용합니다. 아래 코드 예제에서는 `dict`을 `Runner.run(...)`의 `context`로 전달한다고 가정합니다. +MCP 서버가 `_meta`에서 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 기대하는 경우 `tool_meta_resolver`을 사용하세요. 아래 예제에서는 `dict`을 `Runner.run(...)`의 `context`으로 전달한다고 가정합니다. ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -271,19 +299,19 @@ server = MCPServerStreamableHttp( ) ``` -실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 지정 클래스라면 속성 접근 방식으로 테넌트 ID를 읽습니다. +실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 정의 클래스라면 속성 접근 방식으로 테넌트 ID를 읽으세요. -### MCP 도구 출력: 텍스트와 이미지 +### MCP 도구 출력: 텍스트, 이미지 및 기타 콘텐츠 -MCP 도구가 이미지 콘텐츠를 반환하면 SDK가 이를 도구 출력의 이미지 유형 항목에 자동으로 매핑합니다. 텍스트와 이미지가 혼합된 응답은 출력 항목 목록으로 전달되므로 에이전트는 일반 함수 도구의 이미지 출력을 사용하는 것과 같은 방식으로 MCP 이미지 결과를 사용할 수 있습니다. +MCP 결과가 콘텐츠 블록을 사용하면 SDK는 텍스트 콘텐츠를 텍스트 출력으로 전달하고 이미지 콘텐츠를 도구 출력의 이미지 타입 항목으로 매핑합니다. 오디오 및 리소스 블록을 비롯한 다른 MCP 콘텐츠 블록 타입의 경우 SDK는 해당 블록을 유효한 JSON으로 직렬화한 값을 텍스트 출력으로 전달합니다. 여러 콘텐츠 블록이 포함된 응답은 출력 항목 목록으로 전달됩니다. `use_structured_content=True`이 비어 있지 않고 오류가 없는 `structuredContent` 페이로드를 선택하면 해당 structured payload가 이러한 콘텐츠 블록보다 우선합니다. structured content가 누락되었거나 비어 있으면 콘텐츠 블록으로 폴백합니다. ## 3. SSE 기반 HTTP MCP 서버 !!! warning - MCP 프로젝트는 Server-Sent Events 전송 방식을 지원 중단으로 지정했습니다. 신규 통합에는 Streamable HTTP 또는 stdio를 사용하고, SSE는 레거시 서버에만 유지하는 것이 좋습니다. + MCP 프로젝트는 Server-Sent Events 전송 방식을 더 이상 권장하지 않습니다. 새로운 통합에는 Streamable HTTP 또는 stdio를 우선하고, SSE는 레거시 서버에만 사용하세요. -MCP 서버가 SSE 기반 HTTP 전송 방식을 구현한다면 [`MCPServerSse`][agents.mcp.server.MCPServerSse]을 인스턴스화합니다. 전송 방식을 제외하면 API는 Streamable HTTP 서버와 동일합니다. +MCP 서버가 SSE 기반 HTTP 전송 방식을 구현하는 경우 [`MCPServerSse`][agents.mcp.server.MCPServerSse]을 인스턴스화하세요. 전송 방식을 제외하면 API는 Streamable HTTP 서버와 동일합니다. ```python @@ -312,7 +340,7 @@ async with MCPServerSse( ## 4. stdio MCP 서버 -로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]을 사용합니다. SDK가 프로세스를 생성하고 파이프를 열린 상태로 유지하며 컨텍스트 관리자가 종료되면 자동으로 닫습니다. 이 옵션은 빠른 개념 증명이나 서버가 명령줄 엔트리 포인트만 노출하는 경우에 유용합니다. +로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]을 사용하세요. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며 컨텍스트 관리자가 종료될 때 자동으로 닫습니다. 이 옵션은 빠르게 개념 증명을 만들거나 서버가 명령줄 진입점만 노출하는 경우에 유용합니다. ```python from pathlib import Path @@ -340,7 +368,7 @@ async with MCPServerStdio( ## 5. MCP 서버 관리자 -MCP 서버가 여러 개라면 `MCPServerManager`을 사용하여 서버를 미리 연결하고, 성공적으로 연결된 서버만 에이전트에 노출합니다. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참고하세요. +MCP 서버가 여러 개라면 `MCPServerManager`을 사용하여 미리 연결하고, 연결에 성공한 서버만 에이전트에 노출하세요. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참고하세요. ```python from agents import Agent, Runner @@ -361,25 +389,26 @@ async with MCPServerManager(servers) as manager: print(result.final_output) ``` -주요 동작은 다음과 같습니다. +주요 동작: -- `drop_failed_servers=True`인 경우(기본값) `active_servers`에는 성공적으로 연결된 서버만 포함됩니다. -- 실패는 `failed_servers`과 `errors`에서 추적됩니다. -- 첫 번째 연결 실패 시 오류를 발생시키려면 `strict=True`을 설정합니다. -- 실패한 서버를 다시 시도하려면 `reconnect(failed_only=True)`을 호출하고, 모든 서버를 다시 시작하려면 `reconnect(failed_only=False)`을 호출합니다. -- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정합니다. 수명 주기 타임아웃에는 양의 유한 초 또는 타임아웃을 비활성화하는 `None`을 사용할 수 있으며, 생성 시점과 할당 시점 모두에서 유효성을 검사합니다. 0은 즉시 기한이 만료되므로 거부됩니다. +- `drop_failed_servers=True`일 때(기본값) `active_servers`에는 연결에 성공한 서버만 포함됩니다. +- 실패는 `failed_servers` 및 `errors`에서 추적됩니다. +- 첫 번째 연결 실패 시 오류를 발생시키려면 `strict=True`을 설정하세요. +- 실패한 서버를 다시 시도하려면 `reconnect(failed_only=True)`을 호출하고, 모든 서버를 재시작하려면 `reconnect(failed_only=False)`을 호출하세요. +- `connect_all()`, `reconnect()`, `cleanup_all()` 호출은 직렬화됩니다. 수명 주기 작업이 이미 실행 중이라면 다른 수명 주기 작업은 같은 서버에 동시에 연결하거나 정리하지 않고 기존 작업이 끝날 때까지 기다립니다. +- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정하세요. 두 수명 주기 타임아웃의 기본값은 10초입니다. 양의 유한한 초 단위 값 또는 비활성화를 위한 `None`을 지원하며, 생성 시와 할당 시 모두 검증됩니다. 0은 즉시 기한 만료를 발생시키므로 거부됩니다. ## 공통 서버 기능 -아래 섹션은 모든 MCP 서버 전송 방식에 적용됩니다. 단, 정확한 API 범위는 서버 클래스에 따라 달라집니다. +아래 섹션은 MCP 서버 전송 방식 전반에 적용됩니다. 정확한 API 인터페이스는 서버 클래스에 따라 달라집니다. ## 도구 필터링 -각 MCP 서버는 에이전트에 필요한 기능만 노출할 수 있도록 도구 필터를 지원합니다. 필터링은 생성 시점에 수행하거나 실행마다 동적으로 수행할 수 있습니다. +각 MCP 서버는 에이전트에 필요한 함수만 노출할 수 있도록 도구 필터를 지원합니다. 필터링은 생성 시점에 수행하거나 실행별로 동적으로 수행할 수 있습니다. ### 정적 도구 필터링 -간단한 허용/차단 목록을 구성하려면 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter]을 사용합니다. +간단한 허용/차단 목록을 구성하려면 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter]을 사용하세요. ```python from pathlib import Path @@ -397,11 +426,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names`과 `blocked_tool_names`을 모두 제공하면 SDK는 먼저 허용 목록을 적용한 다음 남은 집합에서 차단된 도구를 제거합니다. +`allowed_tool_names`과 `blocked_tool_names`이 모두 제공되면 SDK는 먼저 허용 목록을 적용한 후 남은 집합에서 차단된 도구를 제거합니다. ### 동적 도구 필터링 -더 정교한 로직이 필요하면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]을 받는 호출 가능 객체를 전달합니다. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며 도구를 노출해야 할 때 `True`을 반환합니다. +더 정교한 로직을 구현하려면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]을 받는 호출 가능 객체를 전달하세요. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며, 도구를 노출해야 하는 경우 `True`을 반환합니다. ```python from pathlib import Path @@ -429,11 +458,11 @@ async with MCPServerStdio( ## 프롬프트 -MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 다음 두 가지 +MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 다음 두 메서드를 노출합니다. - `list_prompts()`은 사용 가능한 프롬프트 템플릿을 열거합니다. -- `get_prompt(name, arguments)`은 구체적인 프롬프트를 가져오며, 선택적으로 매개변수를 받을 수 있습니다. +- `get_prompt(name, arguments)`은 선택적으로 매개변수와 함께 구체적인 프롬프트를 가져옵니다. ```python from agents import Agent @@ -453,25 +482,25 @@ agent = Agent( ## 페이지네이션 -기본 제공 로컬 MCP 서버 클래스는 도구와 프롬프트 목록을 조회할 때 `nextCursor`을 자동으로 따라갑니다. `list_tools()`은 필터를 적용하거나 캐시를 채우기 전에 전체 도구 목록을 수집하고, `list_prompts()`은 `nextCursor=None`을 포함하는 하나의 결합된 결과를 반환합니다. 이후 페이지에서 실패하거나 서버가 커서를 반복하면 부분 결과를 노출하거나 캐시하는 대신 오류가 발생합니다. +기본 제공 로컬 MCP 서버 클래스는 도구와 프롬프트 목록을 조회할 때 `nextCursor`을 자동으로 따릅니다. `list_tools()`은 필터를 적용하거나 캐시를 채우기 전에 전체 도구 목록을 수집하고, `list_prompts()`은 `nextCursor=None`과 함께 하나로 결합된 결과를 반환합니다. 이후 페이지에서 오류가 발생하거나 서버가 커서를 반복하면 일부 결과를 노출하거나 캐싱하는 대신 작업에서 오류가 발생합니다. -리소스에는 계속 명시적 페이지네이션이 적용됩니다. 다음 페이지를 가져오려면 `list_resources()` 또는 `list_resource_templates()`에서 반환된 `nextCursor`을 `cursor` 인수로 다시 전달합니다. +리소스는 명시적 페이지네이션을 계속 사용합니다. 다음 페이지를 가져오려면 `list_resources()` 또는 `list_resource_templates()`에서 반환된 `nextCursor`을 `cursor` 인수로 다시 전달하세요. ## 캐싱 -모든 에이전트 실행은 각 MCP 서버에서 `list_tools()`을 호출합니다. 원격 서버는 상당한 지연 시간을 유발할 수 있으므로 모든 MCP 서버 클래스가 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신할 때만 `True`로 설정합니다. 나중에 최신 목록을 강제로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`을 호출합니다. +각 에이전트 실행은 모든 MCP 서버에서 `list_tools()`을 호출합니다. 원격 서버는 상당한 지연 시간을 유발할 수 있으므로 모든 MCP 서버 클래스는 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신하는 경우에만 `True`로 설정하세요. 나중에 목록을 새로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`을 호출하세요. ## 트레이싱 [트레이싱](./tracing.md)은 다음을 포함한 MCP 활동을 자동으로 캡처합니다. -1. 도구 목록을 조회하기 위한 MCP 서버 호출입니다. -2. 도구 호출의 MCP 관련 정보입니다. +1. 도구 목록을 조회하기 위한 MCP 서버 호출 +2. 도구 호출의 MCP 관련 정보 ![MCP 트레이싱 스크린샷](../assets/images/mcp-tracing.jpg) ## 추가 자료 - [Model Context Protocol](https://modelcontextprotocol.io/) – 사양 및 설계 가이드 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE, Streamable HTTP 샘플 코드 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인과 커넥터를 포함한 완전한 호스티드 MCP 데모 \ No newline at end of file +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE, Streamable HTTP 샘플 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 승인과 커넥터를 포함한 전체 호스티드 MCP 데모 \ No newline at end of file diff --git a/docs/ko/models/index.md b/docs/ko/models/index.md index fef3d52b13..0617239f04 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -4,32 +4,32 @@ search: --- # 모델 -Agents SDK는 기본적으로 다음 두 가지 유형의 OpenAI 모델을 지원합니다. +Agents SDK는 다음 두 가지 방식으로 OpenAI 모델을 즉시 사용할 수 있도록 지원합니다. - **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] - [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] ## 모델 설정 선택 -설정에 맞는 가장 간단한 경로부터 시작하세요. +설정에 맞는 가장 간단한 경로부터 시작합니다. -| 목표 | 권장 경로 | 자세히 알아보기 | +| 수행하려는 작업 | 권장 경로 | 자세히 알아보기 | | --- | --- | --- | -| OpenAI 모델만 사용 | 기본 OpenAI 공급자를 Responses 모델 경로와 함께 사용 | [OpenAI 모델](#openai-models) | -| WebSocket 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 WebSocket 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | -| OpenAI 호스트 서브에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | -| OpenAI 이외의 공급자 하나 사용 | 기본 제공 공급자 통합 지점부터 시작 | [OpenAI 이외의 모델](#non-openai-models) | -| 에이전트 전반에서 모델 또는 공급자 혼합 | 실행별 또는 에이전트별로 공급자를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [공급자 간 모델 혼합](#mixing-models-across-providers) | +| OpenAI 모델만 사용 | Responses 모델 경로와 함께 기본 OpenAI 프로바이더 사용 | [OpenAI 모델](#openai-models) | +| 웹소켓 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 웹소켓 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | +| OpenAI에서 호스팅하는 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | +| OpenAI 이외의 프로바이더 하나 사용 | 기본 제공 프로바이더 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | +| 에이전트 간 모델 또는 프로바이더 혼합 | 실행별 또는 에이전트별로 프로바이더를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [프로바이더 간 모델 혼합](#mixing-models-across-providers) | | 고급 OpenAI Responses 요청 설정 조정 | OpenAI Responses 경로에서 `ModelSettings` 사용 | [고급 OpenAI Responses 설정](#advanced-openai-responses-settings) | -| OpenAI 이외의 공급자 또는 혼합 공급자 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 출시하려는 공급자 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | +| OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 출시하려는 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | ## OpenAI 모델 -OpenAI만 사용하는 대부분의 앱에는 기본 OpenAI 공급자와 함께 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 방식을 권장합니다. +OpenAI만 사용하는 대부분의 앱에는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 방식을 권장합니다. -`Agent`을 초기화할 때 모델을 지정하지 않으면 기본 모델이 사용됩니다. 현재 기본값은 지연 시간이 짧은 에이전트 워크플로를 위한 `reasoning.effort="none"` 및 `verbosity="low"`이 적용된 [`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini)입니다. 사용할 수 있다면 명시적인 `model_settings`을 유지하면서 더 높은 품질을 위해 에이전트를 `gpt-5.6-sol`로 설정하는 것을 권장합니다. +[`Agent`][agents.agent.Agent]가 모델을 지정하지 않으면 Agents SDK는 비용에 민감한 대규모 에이전트 워크플로를 위해 기본적으로 `reasoning.effort="none"` 및 `verbosity="low"`과 함께 [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna)를 사용합니다. 최첨단 성능이 필요한 애플리케이션은 `model="gpt-5.6-sol"`을 명시적으로 설정하고 워크로드에 적합한 `model_settings`을 선택할 수 있습니다. -`gpt-5.6-sol` 같은 다른 모델로 전환하려면 두 가지 방법으로 에이전트를 구성할 수 있습니다. +`gpt-5.6-sol` 같은 다른 모델로 전환하려면 에이전트를 구성하는 두 가지 방법이 있습니다. ### 기본 모델 @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 모델 -이 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`을 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 적용됩니다. 기본 모델의 추론 노력을 조정하려면 자체 `ModelSettings`을 전달합니다. +이 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`을 적용합니다. 대부분의 사용 사례에 가장 적합한 값이 설정됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`을 전달합니다. ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -지연 시간을 줄이려면 GPT-5 모델과 함께 `reasoning.effort="none"`을 사용하는 것을 권장합니다. +지연 시간을 줄이려면 GPT-5 모델에서 `reasoning.effort="none"`을 사용하는 것이 좋습니다. -GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 대화 턴 간에 이어지는 추론 컨텍스트, `"max"` 노력 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. +GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 대화 턴 간에 유지되는 추론 컨텍스트, `"max"` 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. ```python from openai.types.shared import Reasoning @@ -96,23 +96,23 @@ agent = Agent( ) ``` -`reasoning.mode`과 `reasoning.context`은 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 노력 수준은 모델과 API 표면에 따라 달라집니다. GPT-5.6의 `"max"` 노력 수준에는 Responses API를 사용하세요. Chat Completions 어댑터는 경고를 표시하며 모드와 컨텍스트를 무시합니다. 해당 경고를 오류로 전환하려면 OpenAI 공급자에서 `strict_feature_validation=True`을 설정하세요. +`reasoning.mode` 및 `reasoning.context`은 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 인터페이스에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용합니다. Chat Completions 어댑터는 경고와 함께 모드 및 컨텍스트를 무시합니다. 해당 경고를 오류로 전환하려면 OpenAI 프로바이더에서 `strict_feature_validation=True`을 설정합니다. -`context="all_turns"`을 사용할 때는 `previous_response_id`, 서버 측 Responses API 대화를 통해 대화를 유지하거나 이전 추론 항목을 다음 요청에 포함하세요. 상태 비저장 `store=False` 호출에서는 응답에 `reasoning.encrypted_content`을 요청한 다음, 해당 추론 항목을 다음 요청의 입력에 포함하세요. +`context="all_turns"`을 사용할 때는 `previous_response_id`, 서버 측 Responses API 대화 또는 다음 요청에 이전 추론 항목을 포함하는 방식으로 대화를 유지합니다. 상태 비저장 `store=False` 호출의 경우 응답에서 `reasoning.encrypted_content`을 요청한 다음, 해당 추론 항목을 다음 요청의 입력으로 포함합니다. #### ComputerTool 모델 선택 -에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청에서 유효한 모델에 따라 SDK가 전송할 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 GA 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. +에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청에 적용되는 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 GA 기본 제공 `computer` 도구를 사용하는 반면, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. -프롬프트로 관리되는 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 지정하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 고정한 모델을 추측하지 않도록 미리보기 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 GA 경로를 유지하려면 요청에 `model="gpt-5.5"`을 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`을 사용하여 GA 선택기를 강제하세요. +프롬프트 관리형 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 지정하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 고정한 모델을 추측하지 않도록 미리보기 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 GA 경로를 유지하려면 요청에 `model="gpt-5.5"`을 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택기를 강제 적용합니다. -[`ComputerTool`][agents.tool.ComputerTool]이 등록된 경우 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`은 유효한 요청 모델에 맞는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 해당 문자열은 계속 일반 함수 이름처럼 동작합니다. +등록된 [`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`은 실제 요청 모델에 맞는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. -미리보기 호환 요청은 `environment`과 디스플레이 크기를 미리 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 GA 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. +미리보기 호환 요청은 `environment`과 디스플레이 크기를 미리 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리형 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 GA 선택기를 강제 적용해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. #### GPT-5 이외의 모델 -사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`으로 되돌아갑니다. +사용자 지정 `model_settings` 없이 GPT-5가 아닌 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`으로 되돌아갑니다. ### Responses 전용 도구 기능 @@ -120,14 +120,14 @@ agent = Agent( - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` 및 그 밖의 지연 로딩 Responses 도구 표면 +- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 인터페이스 - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers`, `tool_choice="programmatic_tool_calling"` -이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 단순 네임스페이스 이름이나 지연 전용 함수 이름을 강제하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 하세요. 설정 세부 정보와 현재 제약 사항은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. +이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용하는 경우 에이전트에 `ToolSearchTool()`을 추가하고, 단순 네임스페이스 이름이나 지연 로딩 전용 함수 이름을 강제 적용하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 합니다. 설정 세부 정보와 현재 제약 조건은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. ### Responses WebSocket 전송 -기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI Responses 공급자 경로를 사용할 때 WebSocket 전송을 선택적으로 활성화할 수 있습니다. +기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI Responses 프로바이더 경로를 사용할 때 웹소켓 전송을 선택할 수 있습니다. #### 기본 설정 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 공급자가 모델 이름을 해석할 때 생성되는 OpenAI Responses 모델에 적용됩니다(`"gpt-5.6-sol"` 같은 문자열 모델 이름 포함). +이는 기본 OpenAI 프로바이더가 모델 이름을 해석할 때 생성되는 OpenAI Responses 모델에 영향을 줍니다. 여기에는 `"gpt-5.6-sol"` 같은 문자열 모델 이름도 포함됩니다. -전송 방식은 SDK가 모델 이름을 모델 인스턴스로 해석할 때 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 계속 사용합니다. `RunConfig(model_provider=...)`을 전달하면 전역 기본값 대신 해당 공급자가 전송 방식 선택을 제어합니다. +SDK가 모델 이름을 모델 인스턴스로 해석할 때 전송 방식이 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 웹소켓을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 유지합니다. `RunConfig(model_provider=...)`을 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식 선택을 제어합니다. -#### 공급자 또는 실행 수준 설정 +#### 프로바이더 또는 실행 수준 설정 -공급자별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. +프로바이더별 또는 실행별로 웹소켓 전송을 구성할 수도 있습니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -SDK의 OpenAI 통합을 통해 라우팅하는 공급자는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID 같은 공급자 수준의 등록 메타데이터가 필요한 경우를 위한 고급 옵션입니다. +SDK의 OpenAI 통합을 통해 라우팅하는 프로바이더는 선택적인 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하니스 ID 같은 프로바이더 수준의 등록 메타데이터를 요구하는 경우를 위한 고급 옵션입니다. ```python from agents import ( @@ -190,14 +190,14 @@ result = await Runner.run( #### `MultiProvider`을 사용한 고급 라우팅 -접두사 기반 모델 라우팅이 필요한 경우(예: 한 번의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]을 사용하고 거기에서 `openai_use_responses_websocket=True`을 설정하세요. +접두사 기반 모델 라우팅이 필요한 경우(예: 한 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]을 사용하고 여기에서 `openai_use_responses_websocket=True`을 설정합니다. `MultiProvider`은 다음 두 가지 기존 기본 동작을 유지합니다. -- `openai/...`은 OpenAI 공급자의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`으로 라우팅됩니다. +- `openai/...`은 OpenAI 프로바이더의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`으로 라우팅됩니다. - 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`을 발생시킵니다. -리터럴 네임스페이스 모델 ID가 필요한 OpenAI 호환 엔드포인트를 OpenAI 공급자에 지정할 때는 통과 동작을 명시적으로 활성화하세요. WebSocket이 활성화된 설정에서는 `MultiProvider`에서도 `openai_use_responses_websocket=True`을 유지하세요. +OpenAI 프로바이더가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키도록 설정할 때는 통과 동작을 명시적으로 선택합니다. 웹소켓이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`을 유지합니다. ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -백엔드에 리터럴 `openai/...` 문자열이 필요한 경우 `openai_prefix_mode="model_id"`을 사용하세요. 백엔드에 `openrouter/openai/gpt-4.1-mini` 같은 다른 네임스페이스 모델 ID가 필요한 경우 `unknown_prefix_mode="model_id"`을 사용하세요. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화한 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. +백엔드가 리터럴 `openai/...` 문자열을 요구할 때 `openai_prefix_mode="model_id"`을 사용합니다. 백엔드가 `openrouter/openai/gpt-4.1-mini` 같은 다른 네임스페이스 모델 ID를 요구할 때 `unknown_prefix_mode="model_id"`을 사용합니다. 이러한 옵션은 웹소켓 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 웹소켓을 활성화한 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. -`MultiProvider`을 통해 라우팅하면서 동일한 공급자 수준의 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`을 전달하면 내부 OpenAI 공급자에 전달됩니다. +`MultiProvider`을 통해 라우팅하면서 동일한 프로바이더 수준 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`을 전달하면 기본 OpenAI 프로바이더로 전달됩니다. -사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 WebSocket 전송에는 호환되는 WebSocket `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. +사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 웹소켓 전송에도 호환되는 웹소켓 `/responses` 엔드포인트가 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. #### 참고 사항 -- 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않습니다. OpenAI 이외의 공급자가 Responses WebSocket `/responses` 엔드포인트를 지원하는 경우에만 해당 공급자에 적용됩니다. -- 환경에서 아직 사용할 수 없다면 `websockets` 패키지를 설치하세요. -- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 직접 사용할 수 있습니다. 여러 턴에 걸쳐 동일한 WebSocket 연결을 재사용하려는 멀티턴 워크플로에는(중첩된 에이전트 도구 호출 포함) [`responses_websocket_session()`][agents.responses_websocket_session] 헬퍼를 권장합니다. [에이전트 실행](../running_agents.md) 가이드와 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)을 참조하세요. -- 추론 턴이 길거나 지연 시간이 급증하는 네트워크에서는 `responses_websocket_options`을 사용하여 WebSocket 연결 유지 동작을 사용자 지정하세요. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화한 상태에서 하트비트 시간 초과를 비활성화하려면 `ping_timeout=None`을 설정하세요. WebSocket 지연 시간보다 안정성이 더 중요하면 HTTP/SSE 전송을 권장합니다. -- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 실행되거나 메모리가 제한된 컨테이너에서 장시간 실행되는 에이전트 프로세스의 경우 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`을 설정하세요. -- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 연결당 시간을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 여세요. 병렬 실행이 필요하면 여러 연결을 사용하세요. -- 서비스는 연결 로컬 메모리에 가장 최근 응답만 보관합니다. 실패한 `4xx` 또는 `5xx` 턴은 `previous_response_id`이 참조한 응답을 해당 메모리에서 제거합니다. 재연결 후에도 저장된 응답을 사용할 수 있으면 계속 진행할 수 있지만, `store=False` 및 ZDR 흐름에는 영구 저장된 대체 경로가 없습니다. `previous_response_id=None`로 새 체인을 시작하고 전체 입력 컨텍스트를 보내거나 로컬에서 관리하는 세션 상태로 해당 컨텍스트를 다시 구성하세요. +- 이는 [Realtime API](../realtime/guide.md)가 아니라 웹소켓 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않습니다. OpenAI 이외의 프로바이더에는 해당 프로바이더가 Responses 웹소켓 `/responses` 엔드포인트를 지원하는 경우에만 적용됩니다. +- 환경에 `websockets` 패키지가 아직 없다면 설치합니다. +- 웹소켓 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 직접 사용할 수 있습니다. 여러 턴과 중첩된 에이전트 도구 호출에서 동일한 웹소켓 연결을 재사용하려는 멀티턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 헬퍼를 권장합니다. [에이전트 실행](../running_agents.md) 가이드 및 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. +- 긴 추론 턴이나 지연 시간이 급증하는 네트워크에서는 `responses_websocket_options`으로 웹소켓 연결 유지 동작을 사용자 지정합니다. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping을 활성화한 상태로 하트비트 제한 시간을 비활성화하려면 `ping_timeout=None`을 설정합니다. 웹소켓 지연 시간보다 안정성이 더 중요하면 HTTP/SSE 전송을 사용하는 것이 좋습니다. +- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 실행되거나 메모리가 제한된 컨테이너에 있는 장기 실행 에이전트 프로세스에서는 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`을 설정합니다. +- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 각 연결을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 여세요. 병렬 실행이 필요하면 여러 연결을 사용합니다. +- 서비스는 연결 로컬 메모리에 가장 최근 응답만 유지합니다. 실패한 `4xx` 또는 `5xx` 턴은 `previous_response_id`이 참조하는 응답을 해당 메모리에서 제거합니다. 다시 연결한 후에도 저장된 응답이 있으면 계속 이어갈 수 있지만, `store=False` 및 ZDR 흐름에는 지속 저장된 대체 항목이 없습니다. `previous_response_id=None`으로 새 체인을 시작하고 전체 입력 컨텍스트를 전송하거나 로컬에서 관리하는 세션 상태를 바탕으로 해당 컨텍스트를 다시 구성합니다. ### 호스티드 멀티 에이전트(실험적) -OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 서브에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`을 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. +OpenAI Responses API의 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스팅되는 하위 에이전트를 생성하고 조율할 수 있습니다. Agents SDK는 일반적인 `Runner`을 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되며 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. -이 통합은 실험적이며, 로컬 함수 출력을 `response.inject`을 통해 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`을 노출하는 `openai[realtime]` 버전 2.45.0 이상의 빌드가 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. +이 통합은 실험적이며 로컬 함수 출력을 `response.inject`을 사용해 활성 상태인 호스티드 에이전트로 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`을 노출하는 `openai[realtime]` 버전 2.45.0 이상의 빌드가 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. #### 모델 구성 @@ -260,13 +260,13 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`이 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`이 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`을 생략하면 서비스 기본값이 사용됩니다. +`OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`이 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`이 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`이 생략되면 서비스 기본값이 사용됩니다. #### 로컬 함수 도구 -모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. 어떤 호스티드 에이전트가 함수를 호출할지는 Responses API가 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 포함된 `function_call_output`을 활성 WebSocket 응답에 삽입하여 서비스가 원래 호스티드 호출자를 재개할 수 있도록 합니다. 함수 실행에는 여전히 Runner의 일반 가드레일, 훅, 실패 변환이 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`이 아닌 함수 도구는 요청을 보내기 전에 거부됩니다. +모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. Responses API는 어느 호스티드 에이전트가 함수를 호출할지 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 있는 `function_call_output`을 활성 WebSocket 응답에 삽입하여 서비스가 원래 호스티드 호출자를 재개할 수 있도록 합니다. 함수 실행은 계속 Runner의 일반 가드레일, 훅, 실패 변환을 통과합니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`이 아닌 함수 도구는 요청을 보내기 전에 거부됩니다. -도구에 호출자 인식 로깅 또는 권한 부여가 필요하면 `get_hosted_agent_metadata()`을 사용하세요. +도구에 호출자 인식 로깅 또는 권한 부여가 필요한 경우 `get_hosted_agent_metadata()`을 사용합니다. ```python from typing import Any @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -호스티드 에이전트 이름은 관찰용 메타데이터이지 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공한 호출 ID를 사용하여 출력을 라우팅하세요. 부작용이 있는 도구에서는 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용하세요. 이 모델에는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. +호스티드 에이전트 이름은 로컬 라우팅 메커니즘이 아니라 관찰용 메타데이터입니다. SDK가 제공하는 호출 ID를 사용해 출력을 라우팅합니다. 부작용이 있는 도구의 경우 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용합니다. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. #### 출력 및 스트리밍 동작 -단계가 `final_answer`인 `/root`의 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 고수준 `RunResult`에서 서브에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 이러한 레코드를 로컬 함수로 실행하지 않습니다. +단계가 `final_answer`인 `/root`의 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 해당 레코드를 로컬 함수로 실행하지 않습니다. -raw 스트리밍은 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트를 계속 노출합니다. 어댑터는 함수 호출이 준비되면 하나의 활성 공급자 응답을 SDK에 표시되는 논리적 모델 턴으로 나눈 다음, Runner가 출력을 생성하면 동일한 공급자 응답을 재개합니다. 항목 또는 도구 호출이 어떤 호스티드 에이전트에 귀속되는지 식별하려면 raw 호스티드 항목이나 `ToolContext`과 함께 `get_hosted_agent_metadata()`을 사용하세요. +raw 스트리밍에서는 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트를 계속 노출합니다. 어댑터는 함수 호출이 준비되면 활성 프로바이더 응답 하나를 SDK에 표시되는 논리적 모델 턴으로 나눈 다음, Runner가 출력을 생성한 후 동일한 프로바이더 응답을 재개합니다. 항목이나 도구 호출이 어느 호스티드 에이전트에 귀속되는지 식별하려면 raw 호스티드 항목 또는 `ToolContext`과 함께 `get_hosted_agent_metadata()`을 사용합니다. #### SDK 오케스트레이션과의 관계 호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. -- 호스티드 멀티 에이전트는 OpenAI 서비스에서 서브에이전트를 생성합니다. 애플리케이션은 해당 서브에이전트를 생성하거나 예약하지 않습니다. -- SDK 핸드오프는 활성 로컬 SDK `Agent`을 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 핸드오프가 거부됩니다. -- Agents-as-tools는 계속 사용할 수 있지만, 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. +- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 해당 하위 에이전트를 생성하거나 예약하지 않습니다. +- SDK 핸드오프는 활성 로컬 SDK `Agent`을 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 거부됩니다. +- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. #### 현재 제한 사항 -실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공하는 `multi_agent` 또는 `betas` 재정의를 거부합니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`은 사용할 수 있지만, Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. +실험적 모델은 `reasoning.summary`, `max_tool_calls` 및 호출자가 제공한 `multi_agent` 또는 `betas` 재정의를 거부합니다. 명시적인 `context_management.compact_threshold`은 사용할 수 있지만, Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하기 때문입니다. -하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답만 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단되면 `await model.close()`을 호출하여 WebSocket을 해제하세요. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. +하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단되면 `await model.close()`을 호출해 WebSocket을 해제합니다. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. -기반이 되는 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)을 참조하세요. +기반 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)를 참조하세요. ## OpenAI 이외의 모델 -OpenAI 이외의 공급자가 필요한 경우 SDK에 기본 제공되는 공급자 통합 지점부터 시작하세요. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. +OpenAI 이외의 프로바이더가 필요한 경우 SDK의 기본 제공 프로바이더 통합 지점부터 시작합니다. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 이것만으로 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. -### OpenAI 이외의 공급자 통합 방식 +### OpenAI 이외의 프로바이더 통합 방식 | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | | [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트를 대부분 또는 모든 에이전트의 기본값으로 사용해야 할 때 | 전역 기본값 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 공급자를 단일 실행에 적용해야 할 때 | 실행별 | -| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 서로 다른 공급자 또는 구체적인 모델 객체가 필요할 때 | 에이전트별 | -| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 공급자 지원 범위 또는 라우팅이 필요할 때 | [서드 파티 어댑터](#third-party-adapters) 참조 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 프로바이더를 단일 실행에 적용해야 할 때 | 실행별 | +| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 다른 프로바이더 또는 구체적인 모델 객체가 필요할 때 | 에이전트별 | +| 서드 파티 어댑터 | 기본 제공 경로가 제공하지 않는 프로바이더 지원 범위 또는 라우팅이 필요할 때 | [서드 파티 어댑터](#third-party-adapters) 참조 | -다음 기본 제공 경로를 사용하여 다른 LLM 공급자를 통합할 수 있습니다. +다음과 같은 기본 제공 경로를 사용해 다른 LLM 프로바이더를 통합할 수 있습니다. -1. [`set_default_openai_client`][agents.set_default_openai_client]은 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우에 유용합니다. 이는 LLM 공급자에 OpenAI 호환 API 엔드포인트가 있고 `base_url` 및 `api_key`을 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. -2. [`ModelProvider`][agents.models.interface.ModelProvider]은 `Runner.run` 수준에 있습니다. 이를 사용하면 "이 실행의 모든 에이전트에 사용자 지정 모델 공급자를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. -3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에서 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 공급자를 조합할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. +1. [`set_default_openai_client`][agents.set_default_openai_client]은 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우 유용합니다. LLM 프로바이더에 OpenAI 호환 API 엔드포인트가 있어 `base_url` 및 `api_key`을 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. +2. [`ModelProvider`][agents.models.interface.ModelProvider]은 `Runner.run` 수준에 있습니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 프로바이더 사용"을 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. +3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 프로바이더를 조합할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. -`platform.openai.com`의 API 키가 없는 경우 `set_tracing_disabled()`을 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것을 권장합니다. +`platform.openai.com`의 API 키가 없는 경우 `set_tracing_disabled()`을 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것이 좋습니다. ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 이 예제에서는 많은 LLM 공급자가 아직 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 공급자가 Responses를 지원한다면 Responses를 사용하는 것을 권장합니다. + 이 예제에서는 많은 LLM 프로바이더가 아직 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 프로바이더가 Responses API를 지원한다면 Responses를 사용하는 것이 좋습니다. ## 하나의 워크플로에서 모델 혼합 -단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]을 구성할 때 다음 중 한 가지 방식으로 특정 모델을 선택할 수 있습니다. +단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. -1. 모델 이름을 전달합니다. -2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider]을 전달합니다. -3. [`Model`][agents.models.interface.Model] 구현을 직접 제공합니다. +1. 모델 이름 전달 +2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider] 전달 +3. [`Model`][agents.models.interface.Model] 구현을 직접 제공 !!! note - SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]과 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식은 서로 다른 기능과 도구 집합을 지원하므로 각 워크플로에서 하나의 모델 형식을 사용하는 것을 권장합니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용 중인 모든 기능을 양쪽 모두에서 사용할 수 있는지 확인하세요. + SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 및 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식이 서로 다른 기능 및 도구 집합을 지원하므로 각 워크플로에서는 단일 모델 형식을 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용하는 모든 기능이 양쪽 모두에서 제공되는지 확인하세요. ```python import asyncio @@ -391,7 +391,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAI 모델 이름을 직접 설정합니다. +1. OpenAI 모델의 이름을 직접 설정합니다. 2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. 에이전트에 사용되는 모델을 추가로 구성하려면 temperature 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.model_settings.ModelSettings]을 전달할 수 있습니다. @@ -409,21 +409,21 @@ english_agent = Agent( ## 고급 OpenAI Responses 설정 -OpenAI Responses 경로에서 더 세밀한 제어가 필요하면 `ModelSettings`부터 시작하세요. +OpenAI Responses 경로에서 더 세밀한 제어가 필요하면 `ModelSettings`부터 사용합니다. ### 일반적인 고급 `ModelSettings` 옵션 -OpenAI Responses API를 사용할 때는 여러 요청 필드에 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 해당 필드에 `extra_args`이 필요하지 않습니다. +OpenAI Responses API를 사용하는 경우 여러 요청 필드에 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 해당 필드에는 `extra_args`이 필요하지 않습니다. - `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출을 허용하거나 금지합니다. -- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 제거하도록 `"auto"`을 설정합니다. -- `store`: 생성된 응답을 나중에 검색할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로와 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. -- `context_management`: `compact_threshold`을 사용한 Responses 압축 같은 서버 측 컨텍스트 처리를 구성합니다. -- `prompt_cache_retention`: 예를 들어 `"24h"`을 사용하여 이전 모델 제품군의 확장 보존을 구성합니다. +- `truncation`: 컨텍스트가 한도를 초과할 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 제거하도록 `"auto"`을 설정합니다. +- `store`: 생성된 응답을 나중에 조회할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로 및 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. +- `context_management`: `compact_threshold`을 사용하는 Responses 압축 같은 서버 측 컨텍스트 처리를 구성합니다. +- `prompt_cache_retention`: 예를 들어 `"24h"`을 사용해 이전 모델 계열의 연장된 보존 기간을 구성합니다. - `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고, GPT-5.6의 경우 `"30m"` 캐시 TTL을 구성합니다. - `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 같은 더 풍부한 응답 페이로드를 요청합니다. - `top_logprobs`: 출력 텍스트의 상위 토큰 logprobs를 요청합니다. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. -- `retry`: 모델 호출에 Runner가 관리하는 재시도 설정을 사용하도록 선택합니다. [Runner 관리 재시도](#runner-managed-retries)를 참조하세요. +- `retry`: 모델 호출에 대해 Runner가 관리하는 재시도 설정을 활성화합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. ```python from agents import Agent, ModelSettings @@ -443,7 +443,7 @@ research_agent = Agent( ) ``` -명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가하세요. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 유지합니다. +명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가합니다. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 유지합니다. ```python from agents import Runner @@ -469,18 +469,17 @@ result = await Runner.run( ) ``` -`prompt_cache_retention`은 레거시 보존 제어를 사용하는 이전 모델 제품군에서 계속 사용할 수 있습니다. -직접 지정한 `ModelSettings` 필드를 `extra_args`의 동일한 키와 함께 사용하지 마세요. +`prompt_cache_retention`은 기존 보존 제어를 사용하는 이전 모델 계열에서 계속 사용할 수 있습니다. 직접 지정한 `ModelSettings` 필드와 `extra_args`의 동일한 키를 함께 사용하지 마세요. -`store=False`을 설정하면 Responses API는 나중에 서버 측에서 검색할 수 있도록 해당 응답을 보관하지 않습니다. 이는 상태 비저장 또는 데이터 무보존 방식의 흐름에 유용하지만, 그렇지 않으면 응답 ID를 재사용하는 기능이 로컬에서 관리하는 상태에 의존해야 한다는 의미이기도 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않은 경우 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. +`store=False`을 설정하면 Responses API는 나중에 서버 측에서 조회할 수 있도록 해당 응답을 보관하지 않습니다. 이는 상태 비저장 또는 데이터 미보존 방식의 흐름에 유용하지만, 응답 ID를 재사용하는 기능이 대신 로컬에서 관리하는 상태에 의존해야 함을 의미하기도 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않은 경우 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. 서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`은 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. ### `extra_args` 전달 -SDK가 아직 최상위 수준에서 직접 노출하지 않는 공급자별 요청 필드나 최신 요청 필드가 필요하면 `extra_args`을 사용하세요. +SDK가 아직 최상위 수준에서 직접 노출하지 않는 프로바이더별 또는 최신 요청 필드가 필요할 때 `extra_args`을 사용합니다. -OpenAI 모델을 사용할 때 `extra_args`은 Responses API와 Chat Completions API 모두에 선택적 매개변수를 전달할 수 있습니다(예: `user` 및 `service_tier`). 지원되는 모델에서 [Fast mode](https://developers.openai.com/api/docs/guides/fast-mode)를 사용하려면 `extra_args={"service_tier": "fast"}`을 설정하세요. `"priority"`도 동일하게 동작합니다. 직접 지정하는 `ModelSettings` 필드를 통해 동일한 요청 필드를 함께 설정하지 마세요. +OpenAI 모델을 사용할 때 `extra_args`은 선택적 매개변수를 Responses API와 Chat Completions API 모두에 전달할 수 있습니다(예: `user` 및 `service_tier`). 지원되는 모델에서는 [Fast 모드](https://developers.openai.com/api/docs/guides/fast-mode)를 사용하도록 `extra_args={"service_tier": "fast"}`을 설정할 수 있으며, `"priority"`도 동일하게 동작합니다. 직접 지정한 `ModelSettings` 필드를 통해 동일한 요청 필드를 함께 설정하지 마세요. ```python from agents import Agent, ModelSettings @@ -496,11 +495,11 @@ english_agent = Agent( ) ``` -## Runner 관리 재시도 +## Runner 관리형 재시도 재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`을 설정하고 재시도 정책에서 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. -Responses WebSocket 전송에서 `retry_policies.provider_suggested()`은 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`이 필요하며 일반적인 재실행 안전성 검사도 그대로 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. +Responses 웹소켓 전송에서 `retry_policies.provider_suggested()`은 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`이 필요하며 일반적인 재실행 안전성 검사도 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -528,85 +527,88 @@ agent = Agent( ) ``` -`ModelRetrySettings`에는 세 가지 필드가 있습니다. +`ModelRetrySettings`에는 세 개의 필드가 있습니다.
| 필드 | 유형 | 참고 | | --- | --- | --- | -| `max_retries` | `int | None` | 최초 요청 이후 허용되는 재시도 횟수 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`은 이렇게 계산된 백오프 지연만 제한합니다. 정책에서 반환한 명시적 지연이나 retry-after 힌트는 제한하지 않습니다. | +| `max_retries` | `int | None` | 최초 요청 후 허용되는 재시도 횟수 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적인 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`은 계산된 이 백오프 지연 시간만 제한합니다. 정책에서 반환한 명시적 지연 시간이나 retry-after 힌트는 제한하지 않습니다. | | `policy` | `RetryPolicy | None` | 재시도 여부를 결정하는 콜백입니다. 이 필드는 런타임 전용이며 직렬화되지 않습니다. |
-재시도 정책은 다음이 포함된 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. +재시도 정책은 다음 정보를 포함하는 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. - `attempt` 및 `max_retries`: 시도 횟수를 고려한 결정을 내리는 데 사용합니다. -- `stream`: 스트리밍 동작과 비스트리밍 동작을 분기하는 데 사용합니다. +- `stream`: 스트리밍 및 비스트리밍 동작을 분기하는 데 사용합니다. - `error`: raw 검사에 사용합니다. -- `normalized`: `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort` 같은 사실을 제공합니다. -- `provider_advice`: 내부 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용합니다. +- `normalized`: `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort` 같은 정보입니다. +- `provider_advice`: 기반 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용합니다. +- `response_started`, `replay_safety`, `stateful_request`: 정책 실행 전에 캡처되는 안정적인 재실행 안전성 정보입니다. `replay_safety`은 `"safe"`, `"unsafe"`, `"unknown"` 중 하나이며, 요청이 `previous_response_id` 또는 `conversation_id`을 사용하면 `stateful_request`은 true입니다. 정책은 다음 중 하나를 반환할 수 있습니다. - 간단한 재시도 결정을 위한 `True` / `False` -- 지연 시간을 재정의하거나 진단 사유를 첨부하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] +- 지연 시간을 재정의하거나, 진단 사유를 첨부하거나, 범위가 제한된 안전하지 않은 재실행을 명시적으로 승인하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] SDK는 `retry_policies`에서 바로 사용할 수 있는 헬퍼를 내보냅니다. | 헬퍼 | 동작 | | --- | --- | | `retry_policies.never()` | 항상 재시도하지 않습니다. | -| `retry_policies.provider_suggested()` | 공급자의 재시도 권고가 있으면 이를 따릅니다. | -| `retry_policies.network_error()` | 일시적인 전송 및 시간 초과 실패와 일치합니다. | -| `retry_policies.http_status([...])` | 선택한 HTTP 상태 코드와 일치합니다. | -| `retry_policies.retry_after()` | retry-after 힌트를 사용할 수 있을 때만 해당 지연 시간을 사용하여 재시도합니다. 이 헬퍼는 retry-after 값을 명시적 정책 지연으로 처리하므로 `backoff.max_delay`이 이를 제한하지 않습니다. | +| `retry_policies.provider_suggested()` | 가능한 경우 프로바이더의 재시도 권고를 따릅니다. | +| `retry_policies.network_error()` | 일시적인 전송 및 제한 시간 실패에 일치합니다. | +| `retry_policies.http_status([...])` | 선택된 HTTP 상태 코드에 일치합니다. | +| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연 시간을 사용해 재시도합니다. 이 헬퍼는 retry-after 값을 명시적 정책 지연 시간으로 처리하므로 `backoff.max_delay`이 이를 제한하지 않습니다. | | `retry_policies.any(...)` | 중첩된 정책 중 하나라도 재시도를 선택하면 재시도합니다. | -| `retry_policies.all(...)` | 모든 중첩 정책이 재시도를 선택할 때만 재시도합니다. | +| `retry_policies.all(...)` | 중첩된 모든 정책이 재시도를 선택할 때만 재시도합니다. | -정책을 조합할 때는 `provider_suggested()`이 가장 안전한 첫 번째 구성 요소입니다. 공급자가 재실행 거부와 재실행 안전 승인을 구분할 수 있을 때 이를 유지하기 때문입니다. +정책을 조합할 때는 `provider_suggested()`이 가장 안전한 첫 번째 기본 구성 요소입니다. 프로바이더가 거부와 재실행 안전성 승인을 구분할 수 있는 경우 이를 유지하기 때문입니다. ##### 안전 경계 -일부 실패는 자동으로 재시도되지 않습니다. +일부 실패는 재시도되지 않습니다. - 중단 오류 -- 공급자 권고에서 재실행이 안전하지 않다고 표시한 요청 -- 재실행이 안전하지 않을 정도로 출력이 이미 시작된 스트리밍 실행 +- 재실행이 안전하지 않게 되는 방식으로 출력이 이미 시작된 스트리밍 실행 +- 프로바이더가 독립적으로 재실행이 안전하다고 표시하지 않은 경우, Programmatic Tool Calling 요청을 포함해 별도의 로컬 부작용 재실행 거부가 있는 요청 -`previous_response_id` 또는 `conversation_id`을 사용하는 상태 저장 후속 요청도 더 보수적으로 처리됩니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 공급자 외부 조건자만으로 충분하지 않습니다. 재시도 정책에는 일반적으로 `retry_policies.provider_suggested()`을 통해 공급자가 제공한 재실행 안전 승인이 포함되어야 합니다. +프로바이더가 안전하지 않다고 표시한 실패도 기본적으로 차단됩니다. 별도의 로컬 부작용 거부가 없는 비스트리밍 요청의 경우 애플리케이션은 `RetryDecision(retry=True, approve_unsafe_replay=True)`을 반환하여 프로바이더 측 재실행 위험을 수용할 수 있습니다. 이 승인을 제공하기 전에 `context.response_started`, `context.replay_safety`, `context.stateful_request`을 확인하고, 프로바이더 측 작업 반복을 허용할 수 있을 때만 승인하세요. 일반적인 `RetryDecision(retry=True)`은 재실행 보호를 우회하지 않으며, `approve_unsafe_replay=True`은 스트리밍 재시도나 로컬 부작용을 승인할 수 없습니다. + +`previous_response_id` 또는 `conversation_id`을 사용하는 상태 유지형 후속 요청은 재실행 안전성을 알 수 없으면 안전을 위해 실패합니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 프로바이더 이외의 조건만으로는 충분하지 않습니다. 일반적으로 `retry_policies.provider_suggested()`을 통해 프로바이더의 재실행 안전 승인을 포함하거나, 위에서 설명한 대로 프로바이더가 안전하지 않다고 표시한 비스트리밍 실패를 명시적으로 승인합니다. ##### Runner 및 에이전트 병합 동작 `retry`은 Runner 수준과 에이전트 수준의 `ModelSettings` 간에 심층 병합됩니다. - 에이전트는 `retry.max_retries`만 재정의하면서 Runner의 `policy`을 계속 상속할 수 있습니다. -- 에이전트는 `retry.backoff`의 일부만 재정의하고 Runner의 형제 백오프 필드를 유지할 수 있습니다. -- `policy`은 런타임 전용이므로 직렬화된 `ModelSettings`은 `max_retries`과 `backoff`을 유지하지만 콜백 자체는 생략합니다. +- 에이전트는 `retry.backoff`의 일부만 재정의하면서 Runner의 다른 백오프 필드를 유지할 수 있습니다. +- `policy`은 런타임 전용이므로 직렬화된 `ModelSettings`은 `max_retries` 및 `backoff`을 유지하지만 콜백 자체는 생략합니다. 더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. -## OpenAI 이외의 공급자 문제 해결 +## OpenAI 이외의 프로바이더 문제 해결 ### 트레이싱 클라이언트 오류 401 -트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. +트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버로 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. -1. 트레이싱을 완전히 비활성화합니다: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] -2. 트레이싱용 OpenAI 키를 설정합니다: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급한 키여야 합니다. -3. OpenAI 이외의 트레이스 프로세서를 사용합니다. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. +1. 트레이싱 완전히 비활성화: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] +2. 트레이싱용 OpenAI 키 설정: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급받아야 합니다. +3. OpenAI 이외의 트레이스 프로세서 사용. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. ### Responses API 지원 -SDK는 기본적으로 Responses API를 사용하지만 다른 많은 LLM 공급자는 아직 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. +SDK는 기본적으로 Responses API를 사용하지만 다른 많은 LLM 프로바이더는 아직 이를 지원하지 않습니다. 그 결과 404 또는 이와 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. -1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]를 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. +1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]을 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. ### Chat Completions 호환성 옵션 -Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, Responses API의 `prompt` 필드, 텍스트 전용이 아닌 도구 출력처럼 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 별도 알림 없이 삭제하여 호환성을 유지합니다. 개발 중에 이러한 불일치가 즉시 실패하도록 하려면 OpenAI 공급자에서 엄격한 기능 검증을 활성화하세요. +Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, Responses API의 `prompt` 필드 또는 텍스트 전용이 아닌 도구 출력처럼 Chat Completions가 전송할 수 없는 Responses 전용 필드를 경고 없이 제거하여 호환성을 유지합니다. 개발 중에 이러한 불일치를 빠르게 실패로 처리하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화합니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -624,9 +626,11 @@ result = await Runner.run( ) ``` -[`MultiProvider`][agents.MultiProvider]을 사용하는 경우 대신 `openai_strict_feature_validation=True`을 전달하세요. +[`MultiProvider`][agents.MultiProvider]을 사용하는 경우 대신 `openai_strict_feature_validation=True`을 전달합니다. + +OpenAI Chat Completions API는 오디오 출력을 반환할 수 있지만 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 현재 오디오 출력을 Agents SDK 실행 항목으로 변환하지 않습니다. 비스트리밍 메시지나 스트리밍 델타에 오디오 출력이 포함된 경우 어댑터는 부분적이거나 빈 결과를 반환하는 대신 `AgentsException("Audio is not currently supported")`을 발생시킵니다. SDK에서 관리하는 오디오 워크플로에는 [Realtime agents](../realtime/guide.md) 또는 [음성 에이전트](../voice/quickstart.md)를 사용하세요. -일부 OpenAI 호환 Chat Completions 공급자는 증분 SDK 처리에 충분히 안정적이지 않은 청크로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 공급자 스트림이 끝난 후에만 SDK가 도구 호출을 내보내도록 하세요. +일부 OpenAI 호환 Chat Completions 프로바이더는 증분 SDK 처리에 충분히 신뢰할 수 없는 청크로 도구 호출 델타를 스트리밍합니다. 이 경우 SDK가 프로바이더 스트림이 완료된 후에만 도구 호출을 내보내도록 스트리밍 도구 호출 버퍼링을 활성화합니다. ```python from agents import OpenAIProvider @@ -637,11 +641,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider]에서는 `openai_buffer_streamed_tool_calls=True`을 사용하세요. +[`MultiProvider`][agents.MultiProvider]에는 `openai_buffer_streamed_tool_calls=True`을 사용합니다. ### structured outputs 지원 -일부 모델 공급자는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)을 지원하지 않습니다. 이로 인해 때때로 다음과 유사한 오류가 발생합니다. +일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이 경우 다음과 같은 오류가 발생할 수 있습니다. ``` @@ -649,42 +653,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -이는 일부 모델 공급자의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`을 지정할 수 없습니다. 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 공급자를 사용하는 것을 권장합니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. +이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`을 지정하도록 허용하지 않습니다. 이 문제를 해결하기 위해 노력하고 있지만, JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. -## 공급자 간 모델 혼합 +## 프로바이더 간 모델 혼합 -모델 공급자 간의 기능 차이를 알고 있어야 하며, 그렇지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 공급자는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. +모델 프로바이더 간 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. -- 이해할 수 없는 공급자에 지원되지 않는 `tools`을 보내지 마세요. +- 이해하지 못하는 프로바이더에 지원되지 않는 `tools`을 전송하지 마세요. - 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. -- 구조화된 JSON 출력을 지원하지 않는 공급자는 때때로 유효하지 않은 JSON을 생성한다는 점에 유의하세요. +- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 유효하지 않은 JSON을 생성할 수 있다는 점에 유의하세요. ## 서드 파티 어댑터 -SDK에 기본 제공되는 공급자 통합 지점만으로 충분하지 않을 때만 서드 파티 어댑터를 사용하세요. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 권장합니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 공급자와 결합해야 하거나 어댑터에서만 제공하는 공급자 지원 범위 또는 라우팅이 필요한 경우에 사용합니다. 어댑터는 SDK와 업스트림 모델 공급자 사이에 또 다른 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 공급자마다 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선 지원 방식의 베타 어댑터 통합으로 포함되어 있습니다. +SDK의 기본 제공 프로바이더 통합 지점으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용합니다. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 프로바이더와 결합해야 하거나 어댑터만 제공하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우에 사용합니다. 어댑터는 SDK와 업스트림 모델 프로바이더 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 프로바이더에 따라 달라질 수 있습니다. 현재 SDK에는 Any-LLM 및 LiteLLM이 최선형 베타 어댑터 통합으로 포함되어 있습니다. ### Any-LLM -Any-LLM 지원은 Any-LLM이 관리하는 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 지원 방식의 베타로 포함됩니다. +Any-LLM 지원은 Any-LLM에서 관리하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. -업스트림 공급자 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 공급자별 호환성 계층을 사용할 수 있습니다. +업스트림 프로바이더 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 프로바이더별 호환성 계층을 사용할 수 있습니다. -Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)부터 시작하세요. [`MultiProvider`][agents.MultiProvider]에서 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`을 사용할 수 있습니다. 모델 표면을 명시적으로 고정해야 한다면 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`을 전달하세요. +Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)에서 시작합니다. [`MultiProvider`][agents.MultiProvider]과 함께 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`을 사용할 수 있습니다. 모델 인터페이스를 명시적으로 고정해야 한다면 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`을 전달합니다. -Any-LLM은 서드 파티 어댑터 계층이므로 공급자 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의됩니다. 업스트림 공급자가 사용량 지표를 반환하면 자동으로 전달되지만, 스트리밍 Chat Completions 백엔드는 사용량 청크를 내보내기 전에 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses별 동작에 의존한다면 배포하려는 정확한 공급자 백엔드를 검증하세요. +Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의합니다. 업스트림 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드가 사용량 청크를 내보내기 전에 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 관련 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. ### LiteLLM -LiteLLM 지원은 LiteLLM별 공급자 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 지원 방식의 베타로 포함됩니다. +LiteLLM 지원은 LiteLLM 전용 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. -LiteLLM이 필요하면 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)부터 시작하세요. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. +LiteLLM이 필요하면 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)에서 시작합니다. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. -LiteLLM 어댑터를 통해 접근하는 일부 공급자는 기본적으로 SDK 사용량 지표를 채우지 않습니다. 사용량 보고가 필요한 경우 `ModelSettings(include_usage=True)`을 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 공급자 백엔드를 검증하세요. +LiteLLM 어댑터를 통해 액세스하는 일부 프로바이더는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요하면 `ModelSettings(include_usage=True)`을 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. -LiteLLM이 응답 객체에 대해 Pydantic 직렬 변환기 경고를 내보내는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. +LiteLLM이 응답 객체에 대해 Pydantic 직렬화 경고를 발생시키는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -이 패치는 기본적으로 비활성화되어 있으며 `1` 또는 `true` 값에 대해서만 활성화됩니다. 비공개 LiteLLM 로깅 헬퍼를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로 일반적인 직렬화 설정이 아닌 목적이 제한된 우회책으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file +이 패치는 기본적으로 비활성화되어 있으며 `1` 또는 `true` 값에 대해서만 활성화됩니다. 비공개 LiteLLM 로깅 헬퍼를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로 일반 직렬화 설정이 아니라 특정 문제를 위한 우회책으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고, 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file diff --git a/docs/ko/realtime/guide.md b/docs/ko/realtime/guide.md index 2c6fced902..b16cb5920d 100644 --- a/docs/ko/realtime/guide.md +++ b/docs/ko/realtime/guide.md @@ -2,50 +2,50 @@ search: exclude: true --- -# 실시간 에이전트 가이드 +# Realtime agents 가이드 -이 가이드에서는 OpenAI Agents SDK의 실시간 계층이 OpenAI Realtime API에 어떻게 매핑되는지와 파이썬 SDK가 추가로 제공하는 동작을 설명합니다. +이 가이드에서는 OpenAI Agents SDK의 실시간 계층이 OpenAI Realtime API에 어떻게 매핑되는지와 파이썬 SDK가 여기에 어떤 추가 동작을 제공하는지 설명합니다. !!! note "여기서 시작" - 기본 파이썬 방식을 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어보세요. 앱에서 서버 측 WebSocket과 SIP 중 무엇을 사용해야 할지 결정하려면 [실시간 전송](transport.md)을 읽어보세요. 브라우저 WebRTC 전송은 파이썬 SDK에 포함되지 않습니다. + 기본 파이썬 경로를 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어보세요. 애플리케이션에서 서버 측 WebSocket과 SIP 중 무엇을 사용할지 결정하는 중이라면 [실시간 전송](transport.md)을 읽어보세요. 브라우저 WebRTC 전송은 파이썬 SDK에 포함되지 않습니다. ## 개요 -실시간 에이전트는 Realtime API와 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하며, 도구를 호출하고, 매 턴마다 새 요청을 다시 시작하지 않고 인터럽션(중단 처리)을 처리할 수 있습니다. +Realtime agents는 Realtime API와의 장기 연결을 열린 상태로 유지하므로, 모델이 텍스트와 오디오를 점진적으로 처리하고 오디오 출력을 스트리밍하며 도구를 호출하고 매 턴마다 새 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. 주요 SDK 구성 요소는 다음과 같습니다. -- **RealtimeAgent**: 하나의 실시간 전문가를 위한 instructions, 도구, 출력 가드레일, 핸드오프 +- **RealtimeAgent**: 하나의 실시간 전문 에이전트를 위한 instructions, 도구, 출력 가드레일 및 핸드오프 - **RealtimeRunner**: 시작 에이전트를 실시간 전송에 연결하는 세션 팩토리 -- **RealtimeSession**: 입력을 보내고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 라이브 세션 +- **RealtimeSession**: 입력을 전송하고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 활성 세션 - **RealtimeModel**: 전송 추상화입니다. 기본값은 OpenAI의 서버 측 WebSocket 구현입니다. ## 세션 수명 주기 -일반적인 실시간 세션은 다음과 같이 진행됩니다. +일반적인 실시간 세션은 다음과 같습니다. 1. 하나 이상의 `RealtimeAgent`을 생성합니다. 2. 시작 에이전트로 `RealtimeRunner`을 생성합니다. 3. `await runner.run()`을 호출하여 `RealtimeSession`을 가져옵니다. -4. `async with session:` 또는 `await session.enter()`로 세션에 진입합니다. -5. `send_message()` 또는 `send_audio()`로 사용자 입력을 보냅니다. -6. 대화가 끝날 때까지 세션 이벤트를 순회합니다. +4. `async with session:` 또는 `await session.enter()`을 사용해 세션에 진입합니다. +5. `send_message()` 또는 `send_audio()`을 사용해 사용자 입력을 전송합니다. +6. 대화가 종료될 때까지 세션 이벤트를 순회합니다. -텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태, 활성 에이전트 구성을 전송 계층과 동기화하는 라이브 세션 객체를 반환합니다. +텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태 및 활성 에이전트 구성을 전송 계층과 동기화된 상태로 유지하는 활성 세션 객체를 반환합니다. -기본적으로 `RealtimeRunner`은 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 파이썬 방식은 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달하더라도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 방식만 달라질 수 있습니다. +기본적으로 `RealtimeRunner`은 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 파이썬 경로는 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달해도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 방식만 달라질 수 있습니다. ## 에이전트 및 세션 구성 `RealtimeAgent`은 의도적으로 일반 `Agent` 타입보다 범위가 좁습니다. - 모델 선택은 에이전트별이 아니라 세션 수준에서 구성합니다. -- structured outputs은 지원되지 않습니다. -- 음성을 구성할 수 있지만 세션에서 음성 오디오가 생성된 후에는 변경할 수 없습니다. -- Instructions, 함수 도구, 핸드오프, 훅, 출력 가드레일은 모두 계속 작동합니다. +- Structured outputs는 지원되지 않습니다. +- 음성을 구성할 수 있지만 세션에서 음성 오디오가 이미 생성된 후에는 변경할 수 없습니다. +- Instructions, 함수 도구, 핸드오프, 훅 및 출력 가드레일은 모두 계속 작동합니다. -`RealtimeSessionModelSettings`은 최신 중첩 `audio` 구성과 이전의 플랫 별칭을 모두 지원합니다. 새 코드에는 중첩 형태를 사용하는 것이 좋으며, 새로운 실시간 에이전트에는 `gpt-realtime-2.1`으로 시작하세요. +`RealtimeSessionModelSettings`은 새로운 중첩 `audio` 구성과 이전의 평면 별칭을 모두 지원합니다. 새 코드에는 중첩 구조를 권장하며, 새로운 Realtime agents에는 `gpt-realtime-2.1`부터 사용하세요. ```python runner = RealtimeRunner( @@ -87,13 +87,67 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -전체 타입 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]를 참조하세요. +전체 타입 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]을 참조하세요. + +### 입력 전사 설정 + +입력 전사는 `audio.input.transcription`에서 구성합니다. 지연 시간이 짧은 증분 전사에는 `gpt-live-transcribe`을 사용하고, 오디오 턴이 커밋된 후 전사를 시작해야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우에는 WebSocket을 통해 `gpt-transcribe`을 사용하세요. Agents SDK는 모델별 GA 전사 설정을 중첩 세션 구성에 전달합니다. + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-live-transcribe", + "prompt": "A support call about the OpenAI Agents SDK.", + "keywords": ["RunState", "MCPServerManager"], + "languages": ["en", "ja"], + }, + "turn_detection": None, + } + } + } + }, +) +``` + +`gpt-live-transcribe`의 경우 `prompt`은 자유 형식의 녹음 컨텍스트를 제공하고, `keywords`은 오디오에 포함될 수 있는 리터럴 용어를 나열하며, `languages`는 예상 입력 언어를 나열합니다. 이 모델은 단수형 `language` 대신 복수형 `languages`을 사용합니다. 두 필드를 모두 전송하지 마세요. + +이 SDK에 고정된 OpenAI 클라이언트 버전은 `delay`을 `gpt-realtime-whisper`과 함께 사용하는 경우에만 지원합니다. 다음과 같이 이 모델의 지연 시간과 정확도 간 절충점을 구성하세요. + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-realtime-whisper", + "delay": "low", + }, + "turn_detection": None, + } + } + } + }, +) +``` + +`delay` 설정에는 `minimal`, `low`, `medium`, `high` 또는 `xhigh`를 사용할 수 있습니다. 값이 낮으면 부분 텍스트가 더 일찍 생성될 수 있으며, 값이 높으면 전사 모델에 더 많은 오디오 컨텍스트가 제공되어 인식 정확도가 향상될 수 있습니다. 각 수준에 고정된 타이밍이 있다고 가정하지 말고 대표적인 오디오를 벤치마킹하세요. + +전사가 커밋된 오디오 턴 이후에 시작되어야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우에만 WebSocket 기반 Realtime 세션에서 `gpt-transcribe`을 사용하세요. 모델은 이전에 전사된 턴을 자동으로 컨텍스트로 사용합니다. `gpt-transcribe` 완료 이벤트는 `languages` 출력 필드에 감지된 언어를 보고합니다. 이 출력 필드는 위에 표시된 예상 언어 입력 `gpt-live-transcribe`과 다릅니다. + +`audio.input.turn_detection`을 `None`로 설정하면 자동 턴 감지가 비활성화됩니다. 그러면 애플리케이션이 [수동 응답 제어](#manual-response-control)에 설명된 대로 오디오 턴을 커밋하고 응답 생성을 제어해야 합니다. 모델 동작, 검증 규칙 및 지연 시간 지침은 OpenAI API의 [실시간 전사 가이드](https://developers.openai.com/api/docs/guides/realtime-transcription)를 참조하세요. ## 입력 및 출력 ### 텍스트 및 구조화된 사용자 메시지 -일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]을 사용합니다. +일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]을 사용하세요. ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +165,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -구조화된 메시지는 실시간 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 `input_image` 메시지를 이 방식으로 전달합니다. +구조화된 메시지는 실시간 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 이러한 방식으로 `input_image` 메시지를 전달합니다. ### 오디오 입력 -원시 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용합니다. +raw 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용하세요. ```python await session.send_audio(audio_bytes) ``` -서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 상위 수준의 편의 기능은 다음과 같습니다. +서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 다음과 같은 고수준 편의 기능을 사용할 수 있습니다. ```python await session.send_audio(audio_bytes, commit=True) ``` -더 낮은 수준의 제어가 필요한 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 Realtime API 클라이언트 이벤트를 직접 보낼 수도 있습니다. +더 저수준의 제어가 필요한 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 Realtime API 클라이언트 이벤트를 직접 전송할 수도 있습니다. ### 수동 응답 제어 -`session.send_message()`은 상위 수준 방식을 사용하여 사용자 입력을 보내고 응답을 시작합니다. 일부 구성에서는 원시 오디오 버퍼링이 동일한 동작을 **자동으로 수행하지 않습니다**. +`session.send_message()`은 고수준 경로를 사용하여 사용자 입력을 전송하고 응답을 시작합니다. 일부 구성에서는 raw 오디오 버퍼링이 동일한 작업을 자동으로 수행하지 **않습니다**. -Realtime API 수준에서 수동 턴 제어란 `turn_detection`을 `null`로 설정하는 `session.update` 이벤트를 보낸 다음, `input_audio_buffer.commit`와 `response.create`을 직접 보내는 것을 의미합니다. +Realtime API 수준에서 수동 턴 제어란 `turn_detection`을 `null`로 설정하는 `session.update` 이벤트를 전송한 다음, `input_audio_buffer.commit`과 `response.create`을 직접 전송하는 것을 의미합니다. -턴을 수동으로 관리하는 경우 모델 전송을 통해 원시 클라이언트 이벤트를 보낼 수 있습니다. +턴을 수동으로 관리하는 경우 모델 전송을 통해 raw 클라이언트 이벤트를 전송할 수 있습니다. ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -152,14 +206,14 @@ await session.model.send_event( 이 패턴은 다음과 같은 경우에 유용합니다. - `turn_detection`이 비활성화되어 있고 모델의 응답 시점을 직접 결정하려는 경우 -- 응답을 트리거하기 전에 사용자 입력을 검사하거나 차단하려는 경우 +- 응답을 트리거하기 전에 사용자 입력을 검사하거나 제한하려는 경우 - 대역 외 응답을 위한 사용자 지정 프롬프트가 필요한 경우 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제에서는 원시 `response.create`을 사용하여 첫 인사말을 강제로 생성합니다. +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제에서는 시작 인사말을 강제로 생성하기 위해 raw `response.create`을 사용합니다. ## 이벤트, 기록 및 인터럽션(중단 처리) -`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내는 동시에, 필요할 때 원시 모델 이벤트도 계속 전달합니다. +`RealtimeSession`은 필요할 때 raw 모델 이벤트도 계속 전달하면서 고수준 SDK 이벤트를 내보냅니다. 중요한 세션 이벤트는 다음과 같습니다. @@ -173,13 +227,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`과 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지, 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. +UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`과 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지 및 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 노출합니다. ### 사용량 집계 -완료된 모델 응답에 사용량이 포함된 경우 SDK의 OpenAI `RealtimeModel` 전송은 `raw_model_event` 내부에서 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]를 내보냅니다. `usage` 필드에는 해당 응답의 토큰 수가 포함되며, `input_tokens_details`과 `output_tokens_details`는 선택적인 모달리티별 분석을 제공합니다. +완료된 모델 응답에 사용량이 포함된 경우 SDK의 OpenAI `RealtimeModel` 전송은 `raw_model_event` 내부에서 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]을 내보냅니다. 해당 `usage` 필드에는 그 응답의 토큰 수가 포함되며, `input_tokens_details`과 `output_tokens_details`은 선택적인 모달리티별 세부 내역을 제공합니다. -또한 세션은 각 응답의 사용량을 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. 라이브 세션의 누적 사용량을 확인하려면 `agent_end`과 같은 후속 상위 수준 이벤트의 `event.info.context.usage`에서 이를 읽으세요. +또한 세션은 각 응답의 사용량을 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. `agent_end`과 같은 후속 고수준 이벤트의 `event.info.context.usage`에서 이를 읽어 활성 세션의 누적 사용량을 확인할 수 있습니다. ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,13 +251,13 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -사용량은 모델 제공자가 완료된 응답에 포함한 경우에만 보고됩니다. 누적 값은 해당 `RealtimeSession`에서 수신한 응답에 적용되며, 여러 세션에 걸친 합계가 아닙니다. +사용량은 모델 제공자가 완료된 응답에 사용량을 포함하는 경우에만 보고됩니다. 누적 값은 해당 `RealtimeSession`이 수신한 응답을 포함하며, 여러 세션을 아우르는 합계는 아닙니다. ### 인터럽션(중단 처리) 및 재생 추적 -사용자가 어시스턴트의 응답을 중단하면 세션은 `audio_interrupted`을 내보내고 기록을 업데이트하여 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 합니다. +사용자가 어시스턴트를 중단하면 세션은 `audio_interrupted`을 내보내고, 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 기록을 업데이트합니다. -지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기로 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 모든 오디오를 이미 들었다고 가정하는 대신 실제 재생 위치에서 중단된 응답을 잘라내도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]을 사용하세요. +지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기로 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 모든 오디오를 이미 들었다고 가정하지 않고 실제 재생 위치에서 중단된 응답을 잘라내도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]을 사용하세요. [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)의 Twilio 예제에서 이 패턴을 확인할 수 있습니다. @@ -211,7 +265,7 @@ async for event in session: ### 함수 도구 -실시간 에이전트는 라이브 대화 중 함수 도구를 지원합니다. +Realtime agents는 실시간 대화 중 함수 도구를 지원합니다. ```python from agents.decorators import tool @@ -234,7 +288,7 @@ agent = RealtimeAgent( 함수 도구는 실행 전에 사람의 승인을 요구할 수 있습니다. 이 경우 세션은 `tool_approval_required`을 내보내고 `approve_tool_call()` 또는 `reject_tool_call()`을 호출할 때까지 도구 실행을 일시 중지합니다. -도구에 입력 가드레일도 있는 경우, 이러한 가드레일은 승인 후 실행 직전에 실행됩니다. 승인 이벤트가 발생하기 전에 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성하세요. 이 사전 승인 검사를 통과한 호출도 실행 전 승인 후 다시 검사됩니다. +도구에 입력 가드레일도 있는 경우 해당 가드레일은 승인 후 실행 직전에 수행됩니다. 승인 이벤트가 발생하기 전에 가드레일을 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성하세요. 이 사전 승인 검사를 통과한 호출도 실행 전 승인 이후에 다시 검사됩니다. ```python async for event in session: @@ -242,11 +296,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)을 참조하세요. 휴먼인더루프 문서에서도 [휴먼인더루프 (HITL)](../human_in_the_loop.md)에 이 흐름을 안내합니다. +구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)을 참조하세요. 휴먼인더루프 문서의 [휴먼인더루프 (HITL)](../human_in_the_loop.md)에서도 이 흐름을 안내합니다. ### 핸드오프 -실시간 핸드오프를 사용하면 한 에이전트가 라이브 대화를 다른 전문가에게 전달할 수 있습니다. +실시간 핸드오프를 사용하면 한 에이전트가 활성 대화를 다른 전문 에이전트에게 전달할 수 있습니다. ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +322,11 @@ main_agent = RealtimeAgent( ) ``` -핸드오프로 직접 사용되는 `RealtimeAgent` 객체는 자동으로 래핑되며, `realtime_handoff(...)`을 사용하면 이름, 설명, 검증, 콜백, 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프 `input_filter`을 지원하지 **않습니다**. +핸드오프로 직접 사용되는 `RealtimeAgent` 객체는 자동으로 래핑되며, `realtime_handoff(...)`을 사용해 이름, 설명, 검증, 콜백 및 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프 `input_filter`을 지원하지 **않습니다**. ### 가드레일 -실시간 에이전트는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일 검사는 디바운스됩니다. 각 검사는 모든 부분 델타가 아니라 누적된 출력 텍스트 및 오디오 트랜스크립트 델타에서 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`을 내보냅니다. +Realtime agents는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일 검사는 디바운스됩니다. 각 검사는 모든 부분 델타가 아니라 누적된 출력 텍스트 및 오디오 전사 델타에 대해 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`을 내보냅니다. ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,15 +346,15 @@ agent = RealtimeAgent( ) ``` -실시간 출력 가드레일이 오디오 트랜스크립트에서 트리거되면 세션은 활성 응답을 중단하고 `response.cancel`을 강제로 실행하며, `guardrail_tripped`을 내보내고, 트리거된 가드레일의 이름이 포함된 후속 사용자 메시지를 보내 모델이 대체 응답을 생성할 수 있게 합니다. 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되었을 수 있으므로, 오디오 플레이어는 계속 `audio_interrupted`을 수신하고 로컬 재생을 즉시 중지해야 합니다. 기본 제공 OpenAI Realtime 전송을 사용하는 경우, 검사 대상 응답이 종료된 후 가드레일 검사가 완료되면 세션은 해당 응답의 버퍼링된 재생만 중단하고 이후에 시작된 응답은 취소하지 않습니다. 텍스트 전용 출력에서는 대신 응답 범위의 `response.cancel`을 보냅니다. 중지할 오디오 재생이 없으므로 `audio_interrupted`은 내보내지 않습니다. 기본 제공 OpenAI Realtime 모델을 사용할 때 텍스트 전용 경로에서도 동일한 `guardrail_tripped` 이벤트와 후속 사용자 메시지가 발생합니다. +실시간 출력 가드레일이 오디오 전사에서 트리거되면 세션은 활성 응답을 중단하고, `response.cancel`을 강제하고, `guardrail_tripped`을 내보낸 다음, 모델이 대체 응답을 생성할 수 있도록 트리거된 가드레일의 이름을 포함하는 후속 사용자 메시지를 전송합니다. 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되어 있을 수 있으므로 오디오 플레이어는 계속 `audio_interrupted`을 수신하고 로컬 재생을 즉시 중지해야 합니다. 기본 제공 OpenAI Realtime 전송을 사용할 때 가드레일 검사가 검사 대상 응답이 종료된 후 완료되면 세션은 해당 응답의 버퍼링된 재생만 중단하며, 이후에 시작된 응답은 취소하지 않습니다. 텍스트 전용 출력에서는 대신 세션이 응답 범위의 `response.cancel`을 전송합니다. 중지할 오디오 재생이 없으므로 `audio_interrupted`은 내보내지 않습니다. 기본 제공 OpenAI Realtime 모델을 사용할 때 텍스트 전용 경로에서도 동일한 `guardrail_tripped` 이벤트와 후속 사용자 메시지가 내보내집니다. -사용자 지정 `RealtimeModel` 전송은 동일한 소스 범위 오디오 인터럽션(중단 처리) 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`과 `playback_only`을 준수해야 합니다. 텍스트 전용 출력 경로의 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`도 재정의해야 합니다. 구현에서는 제공된 조건을 전송의 실제 이벤트 커밋 경계에서 다시 검사하거나, 조건 검사를 이벤트 커밋과 함께 직렬화해야 합니다. 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 조건을 한 번 검사한 다음 이벤트를 별도로 보내면 해당 검사와 이벤트 커밋 사이에 다른 응답이 시작될 수 있기 때문입니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. +사용자 지정 `RealtimeModel` 전송은 동일한 소스 범위 오디오 인터럽션(중단 처리) 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`과 `playback_only`을 준수해야 합니다. 또한 텍스트 전용 출력 경로의 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`을 재정의해야 합니다. 구현은 전송의 실제 이벤트 커밋 경계에서 제공된 조건을 다시 검사하거나, 조건 검사와 이벤트 커밋을 함께 직렬화해야 합니다. 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 조건을 한 번 검사한 후 이벤트를 별도로 전송하면 해당 검사와 이벤트 커밋 사이에 다른 응답이 시작될 수 있기 때문입니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. ## SIP 및 전화 통신 -파이썬 SDK에는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통한 일급 SIP 연결 흐름이 포함되어 있습니다. +파이썬 SDK는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통한 일급 SIP 연결 흐름을 포함합니다. -Realtime Calls API를 통해 전화가 수신되고 생성된 `call_id`에 에이전트 세션을 연결하려는 경우 사용하세요. +Realtime Calls API를 통해 통화가 수신되고 그 결과 생성된 `call_id`에 에이전트 세션을 연결하려는 경우 이를 사용하세요. ```python from agents.realtime import RealtimeRunner @@ -317,7 +371,7 @@ async with await runner.run( ... ``` -먼저 전화를 수락해야 하고 수락 페이로드를 에이전트에서 파생된 세션 구성과 일치시키려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`을 사용하세요. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. +먼저 통화를 수락해야 하며 수락 페이로드를 에이전트에서 파생된 세션 구성과 일치시키려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`을 사용하세요. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. ## 저수준 접근 및 사용자 지정 엔드포인트 @@ -326,9 +380,9 @@ async with await runner.run( 다음이 필요한 경우 사용하세요. - `session.model.add_listener(...)`을 통한 사용자 지정 리스너 -- `response.create` 또는 `session.update`과 같은 원시 클라이언트 이벤트 +- `response.create` 또는 `session.update`와 같은 raw 클라이언트 이벤트 - `model_config`을 통한 사용자 지정 `url`, `headers` 또는 `api_key` 처리 -- 기존 실시간 호출에 대한 `call_id` 연결 +- 기존 실시간 통화에 `call_id` 연결 `RealtimeModelConfig`은 다음을 지원합니다. @@ -339,9 +393,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -이 저장소에서 제공하는 `call_id` 예제는 SIP입니다. 더 광범위한 Realtime API에서도 일부 서버 측 제어 흐름에 `call_id`을 사용하지만, 여기에서는 파이썬 예제로 패키징되어 있지 않습니다. +이 저장소에 포함된 `call_id` 예제는 SIP입니다. 더 광범위한 Realtime API에서도 일부 서버 측 제어 흐름에 `call_id`을 사용하지만, 여기서는 이러한 흐름을 파이썬 예제로 제공하지 않습니다. -Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적 헤더를 전달하세요. 예를 들면 다음과 같습니다. +Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적인 헤더를 전달하세요. 예를 들면 다음과 같습니다. ```python session = await runner.run( @@ -352,7 +406,7 @@ session = await runner.run( ) ``` -토큰 기반 인증의 경우 `headers`에 전달자 토큰을 사용하세요. +토큰 기반 인증에는 `headers`에서 bearer 토큰을 사용하세요. ```python session = await runner.run( @@ -363,7 +417,7 @@ session = await runner.run( ) ``` -`headers`을 전달하면 SDK는 `Authorization`을 자동으로 추가하지 않습니다. 실시간 에이전트에서 레거시 베타 경로(`/openai/realtime?api-version=...`)는 사용하지 마세요. +`headers`을 전달하면 SDK는 `Authorization`을 자동으로 추가하지 않습니다. Realtime agents에서는 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. ## 추가 자료 diff --git a/docs/ko/release.md b/docs/ko/release.md index 5e352c4a94..cf1882194f 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -4,51 +4,66 @@ search: --- # 릴리스 프로세스/변경 로그 -이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 유의적 버전 관리를 따릅니다. 맨 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. +이 프로젝트는 `0.Y.Z` 형식으로 의미론적 버전 관리(semantic versioning)를 약간 수정한 방식을 따릅니다. 앞의 `0`은 SDK가 아직 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. ## 마이너(`Y`) 버전 -베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 발생하면 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. +베타로 표시되지 않은 공개 인터페이스의 **호환성을 깨는 변경 사항**이 있을 때 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. 호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. ## 패치(`Z`) 버전 -호환성을 깨지 않는 다음 변경 사항에는 `Z`을 증가시킵니다. +호환성을 깨지 않는 다음 변경 사항이 있을 때 `Z`을 증가시킵니다. -- 버그 수정 -- 새로운 기능 -- 비공개 인터페이스 변경 -- 베타 기능 업데이트 +- 버그 수정 +- 새로운 기능 +- 비공개 인터페이스 변경 +- 베타 기능 업데이트 -## 호환성 변경 로그 +## 호환성을 깨는 변경 사항 변경 로그 + +### 0.20.0 + +버전 0.20.0에는 로컬 MCP HTTP 전송을 사용자 지정하는 애플리케이션에 호환성을 깨는 변경이 될 수 있는 MCP 종속성 마이그레이션이 포함됩니다. 또한 에이전트나 실행에서 모델을 명시적으로 선택하지 않을 때 사용하는 SDK 기본 모델도 업데이트됩니다. + +주요 내용: + +- 이제 SDK 기본 모델은 `gpt-5.4-mini`이 아니라 `gpt-5.6-luna`입니다. 기본 `reasoning.effort="none"` 및 `verbosity="low"` 설정은 변경되지 않았습니다. +- 명시적인 에이전트 모델, 실행 수준 모델 재정의 및 `OPENAI_DEFAULT_MODEL` 환경 변수는 계속해서 SDK 기본값보다 우선합니다. +- 이제 실시간 입력 전사 설정에서 `gpt-transcribe`, `gpt-live-transcribe`, `gpt-realtime-whisper`을 인식합니다. 지연 시간이 짧은 `gpt-live-transcribe` 세션에서는 중첩된 `audio.input.transcription` 설정으로 `prompt`, `keywords` 및 예상되는 여러 `languages`을 제공할 수 있습니다. 이 SDK가 고정하여 사용하는 OpenAI 클라이언트 버전은 `delay` 지연 시간/정확도 수준을 `gpt-realtime-whisper`에서만 지원합니다. 커밋된 오디오 턴 이후의 전사 또는 감지된 언어 출력을 위해서는 WebSocket에서 `gpt-transcribe`을 사용합니다. `audio.input.turn_detection=None`을 명시적으로 설정하면 자동 턴 감지가 비활성화됩니다. [입력 전사 설정](realtime/guide.md#input-transcription-settings)을 참조하세요. +- 이제 Agents SDK에서 생성한 로컬 MCP 연결은 `mcp>=1.19.0,<3`을 통해 v1 호환성을 유지하면서 MCP Python SDK v2를 지원합니다. Agents SDK는 일반적인 stdio, SSE 및 Streamable HTTP 연결을 자동으로 조정합니다. MCP v2가 설치되어 있으면 이러한 연결은 `mcp.Client(mode="auto")`을 사용해 지원되는 최신 프로토콜을 탐색하고, 이전 서버에서는 레거시 `initialize` 핸드셰이크로 대체합니다. 종속성 확인 결과 MCP v2가 선택되는 경우, 사용자 지정 `httpx.Auth` 객체 또는 `httpx.AsyncClient` 팩토리를 제공하는 애플리케이션은 해당 값을 `httpx2`로 마이그레이션하거나, v1 HTTP 스택을 유지하도록 `mcp<2`을 고정해야 합니다. `MCPServerStreamableHttp`의 `params["ignore_initialized_notification_failure"] = True` 옵션도 계속 v1에서만 사용할 수 있습니다. 마이그레이션 세부 정보는 [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참조하세요. +- 이제 샌드박스 마운트 검증은 샌드박스나 마운트 헬퍼의 부작용이 발생하기 전에 안전하지 않은 자격 증명 배치를 거부합니다. 신뢰할 수 있는 애플리케이션은 스토리지 기능 테이블을 변경하지 않고도 컨테이너 내부의 정확한 마운트 경로에 대한 마운트 범위 또는 광범위한 자격 증명 노출을 확인할 수 있습니다. 이러한 확인은 런타임에서만 유효하며, 직렬화된 샌드박스 상태만으로는 자격 증명 권한이 부여되지 않습니다. 보호된 마운트 경계에서 SDK는 새로 생성한 수정된 예외를 반환합니다. 소스 예외가 정확히 인식되는 SDK 샌드박스 오류이고 승인된 구조화 필드가 검증을 통과하면, 대체 예외는 해당 하위 유형과 검증된 안전 필드를 유지합니다. 인식된 `MountConfigError`도 SDK에서 생성한 안전한 검증 메시지를 유지할 수 있습니다. 그 외의 경우 SDK는 새로 생성한 일반적인 수정된 오류를 반환합니다. 제공자가 제어하거나 승인되지 않은 메시지, 명령 데이터, 메모, 컨텍스트, 원인 및 소스 트레이스백 상태는 유지되지 않습니다. [마운트 및 원격 스토리지](sandbox/clients.md#mounts-and-remote-storage)와 [세션 상태에서 재개](sandbox/guide.md#resume-from-session-state)를 참조하세요. +- 재시도 정책은 안정적인 재실행 안전성 정보를 검사하고, 제공자가 안전하지 않다고 표시한 비스트리밍 요청에 대해 `RetryDecision(approve_unsafe_replay=True)`을 명시적으로 설정할 수 있습니다. 이 승인은 중단, 이미 방출된 스트리밍 출력 또는 프로그래밍 방식 도구 호출과 같은 별도의 로컬 부작용 거부를 우회하지 않습니다. [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. +- 이제 재개 가능한 `RunState` 객체는 다음 모델 호출 전에 `add_input()`을 사용해 지속 가능한 사용자 입력을 스테이징할 수 있습니다. 스테이징된 입력은 직렬화 후에도 유지되고 입력 가드레일을 거치며, 로컬 세션 및 서버 관리형 대화 전반에서 지속 가능한 SDK 입력 1건을 생성합니다. 안전하지 않은 재실행을 명시적으로 승인하더라도 입력이 제공자에게 다시 전송되어 제공자 측 작업이 반복될 수 있습니다. [재개 전 입력 추가](results.md#add-input-before-resuming)를 참조하세요. +- 런타임 안정성 수정으로 스트리밍 및 비스트리밍 [출력 가드레일 세션 지속성](guardrails.md#output-guardrails)을 일치시키고, 복사 및 네임스페이스 적용 중에 `FunctionTool` 하위 클래스를 유지하며, 지원되지 않는 [Chat Completions 오디오 출력](models/index.md#chat-completions-compatibility-options)에 대해 빈 스트림을 조용히 완료하는 대신 명시적인 오류를 발생시킵니다. `OpenAIResponsesCompactionSession` 래퍼는 취소가 호출자에게 전달되기 전에 [압축 전 기록 복구](sessions/index.md#auto-compaction-can-block-streaming)를 시도하고 완료될 때까지 기다립니다. 이제 [`VoicePipeline`](voice/pipeline.md#results) 소비자는 실행이 정상적으로 완료된 후 전사 세션 종료 실패를 전달받으며, 이전 턴의 실패가 이후 종료 실패보다 우선합니다. 이제 `RunState` 왕복 변환은 로컬 셸 출력, 확인된 컴퓨터 안전 검사, 기본값이 설정된 도구 출력 필드, 그리고 딕셔너리, 목록 또는 튜플을 순회하는 동안 발견한 Pydantic 모델이나 데이터 클래스 출력을 유지합니다. MCP 변환은 자유 형식 객체 스키마와 이미지 출력을 유지하며, 오디오 및 리소스 블록과 같은 기타 raw 콘텐츠 블록을 유효한 JSON 텍스트로 직렬화합니다. `MCPServerManager`은 겹치는 수명 주기 작업을 직렬화하고 연결 및 정리에 유한한 기본 제한 시간을 적용합니다. 모델 재실행은 출력 항목을 입력으로 사용하기 전에 서버 소유의 `created_by` 메타데이터를 제거합니다. ### 0.19.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새 기능 영역인 프로그래매틱 도구 호출을 반영합니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새로운 기능 영역인 프로그래밍 방식 도구 호출을 반영합니다. 주요 내용: -- 지원되는 OpenAI Responses 모델이 프로그래매틱 도구 호출을 사용할 수 있는 도구를 조정하는 JavaScript를 생성할 수 있게 해 주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]이 추가되었습니다. 도구별 `allowed_callers`, `FunctionTool` 인스턴스의 structured outputs, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState`와의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래매틱 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. -- 공개 `agents.decorators` 모듈과 기존 `@function_tool` 데코레이터의 짧은 별칭인 `@tool`가 기존 가드레일 데코레이터와 함께 추가되었습니다. 이제 `FunctionTool` 인스턴스는 비동기 호출 가능 객체도 지원합니다. -- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인 전반에서 타입이 지정된 설정 객체나 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. -- 유용한 디버깅 컨텍스트를 유지하면서 가공되지 않은 민감한 페이로드가 노출되지 않도록 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅이 강화되었습니다. -- AnyLLM, LiteLLM 및 Chat Completions 호환성이 개선되었고, 모델 재시도 간에 세션 기록이 유지되며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 제공업체 재시도 지침이 추가되었습니다. 따라서 명시적으로 활성화한 Runner 재시도 정책은 허용되는 경우 실패한 시도를 다시 실행할 수 있습니다. -- `VercelCloudBucketMountStrategy`을 통해 [Vercel 샌드박스 생성 시에만 구성할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)가 추가되었습니다. 마운트가 적용된 세션에서는 버킷 콘텐츠가 워크스페이스 영속화 대상에서 제외되며, 동적 마운트 변경이나 세션 재개는 의도적으로 지원되지 않습니다. +- 지원되는 OpenAI Responses 모델이 프로그래밍 방식 도구 호출에 적합한 도구를 조정할 JavaScript를 생성할 수 있도록 하는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 추가했습니다. 도구별 `allowed_callers`, `FunctionTool` 인스턴스의 structured outputs, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState`과의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래밍 방식 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. +- 공개 `agents.decorators` 모듈과 기존 `@function_tool` 데코레이터의 짧은 별칭인 `@tool`을 기존 가드레일 데코레이터와 함께 추가했습니다. 이제 `FunctionTool` 인스턴스는 비동기 호출 가능 객체도 지원합니다. +- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인 전반에서 형식이 지정된 설정 객체나 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. +- 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅을 강화하여, 유용한 디버깅 컨텍스트를 유지하면서도 가공되지 않은 민감한 페이로드가 노출되지 않도록 했습니다. +- AnyLLM, LiteLLM 및 Chat Completions 호환성을 개선하고, 모델 재시도 전반에서 세션 기록을 유지하며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 제공자 재시도 지침을 추가했습니다. 따라서 허용되는 경우 옵트인 Runner 재시도 정책이 실패한 시도를 다시 실행할 수 있습니다. +- `VercelCloudBucketMountStrategy`을 통해 [Vercel 샌드박스를 생성할 때만 구성할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)를 추가했습니다. 마운트된 세션은 작업 공간 지속성에서 버킷 콘텐츠를 제외하며, 의도적으로 동적 마운트 변경이나 세션 재개를 지원하지 않습니다. ### 0.18.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만 반영합니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만을 위한 것입니다. 주요 내용: -- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새 Realtime 설정에서는 추가 구성 없이 최신 권장 모델을 사용합니다. +- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서 추가 구성 없이 권장되는 최신 모델을 사용합니다. ### 0.17.0 -이 버전에서 샌드박스의 로컬 소스 구체화는 소스 경로가 `Manifest.extra_path_grants`에 포함되지 않는 한 `LocalFile.src`와 `LocalDir.src`을 구체화 `base_dir` 내부에 유지합니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 경로 로컬 소스는 이미 그 내부에 있거나 명시적인 허용 범위 아래에 있어야 합니다. 이 변경으로 로컬 아티팩트 경계 문제가 해결되지만, 신뢰할 수 있는 호스트 파일이나 디렉터리를 해당 기본 디렉터리 외부에서 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에는 영향을 줄 수 있습니다. +이 버전에서는 소스 경로가 `Manifest.extra_path_grants`에 포함되지 않는 한, 샌드박스 로컬 소스 구체화 과정에서 `LocalFile.src`와 `LocalDir.src`이 구체화 `base_dir` 내부에 유지됩니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 확인되며, 절대 로컬 소스는 이미 그 안에 있거나 명시적으로 허용된 경로 아래에 있어야 합니다. 이는 로컬 아티팩트 경계 문제를 해결하지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 의도적으로 샌드박스 작업 공간에 복사하는 애플리케이션에 영향을 줄 수 있습니다. -마이그레이션하려면 `SandboxPathGrant`를 사용하여 매니페스트 수준에서 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우 읽기 전용으로 설정하는 것이 좋습니다. +마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`을 사용해 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우 읽기 전용으로 설정하는 것이 좋습니다. ```python from pathlib import Path @@ -75,13 +90,13 @@ manifest = Manifest( ) ``` -`extra_path_grants`를 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 신뢰할 수 없는 다른 매니페스트 입력으로 허용 범위를 채우지 마세요. +`extra_path_grants`을 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 신뢰할 수 없는 기타 매니페스트 입력으로 허용 목록을 채우지 마세요. ### 0.16.0 -이 버전에서 SDK 기본 모델은 이제 `gpt-4.1` 대신 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본값이 GPT-5 모델이므로, 명시하지 않은 기본 모델 설정에는 이제 `reasoning.effort="none"` 및 `verbosity="low"` 같은 GPT-5 기본값이 포함됩니다. +이 버전에서는 이제 SDK 기본 모델이 `gpt-4.1`이 아니라 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본값이 GPT-5 모델이므로 암시적인 기본 모델 설정에 이제 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. -이전 기본 모델 동작을 유지해야 한다면 에이전트 또는 실행 구성에서 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. +이전 기본 모델 동작을 유지해야 한다면 에이전트나 실행 구성에 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. ```python agent = Agent(name="Assistant", model="gpt-4.1") @@ -89,14 +104,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 주요 내용: -- 이제 `Runner.run`, `Runner.run_sync` 및 `Runner.run_streamed`에서 `max_turns=None`을 사용하여 턴 제한을 비활성화할 수 있습니다. -- 이제 로컬, Docker 및 제공업체 기반 샌드박스 구현 전반에서 샌드박스 워크스페이스를 채울 때 절대 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. +- 이제 `Runner.run`, `Runner.run_sync`, `Runner.run_streamed`은 턴 제한을 비활성화하기 위한 `max_turns=None`을 허용합니다. +- 이제 샌드박스 작업 공간 하이드레이션은 로컬, Docker 및 제공자 지원 샌드박스 구현 전반에서 절대 심볼릭 링크 대상을 포함해 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. ### 0.15.0 -이 버전에서는 모델의 거부 응답을 빈 텍스트 출력으로 처리하거나, structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하도록 하는 대신 이제 `ModelRefusalError`로 명시적으로 노출합니다. +이 버전에서는 모델 거부가 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하도록 하는 대신, 이제 `ModelRefusalError`로 명시적으로 노출됩니다. -이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. +이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. ```python result = Runner.run_sync( @@ -106,81 +121,81 @@ result = Runner.run_sync( ) ``` -structured outputs 에이전트의 경우 핸들러는 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. +structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. ### 0.14.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 샌드박스 에이전트라는 중요한 새 베타 기능 영역과 로컬, 컨테이너화 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 샌드박스 에이전트라는 주요 새 베타 기능 영역과 함께 로컬, 컨테이너화 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가됩니다. 주요 내용: -- `SandboxAgent`, `Manifest` 및 `SandboxRunConfig`을 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스가 추가되어 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 기능을 갖춘 영속적이고 격리된 워크스페이스 내에서 작업할 수 있습니다. -- `UnixLocalSandboxClient` 및 `DockerSandboxClient`을 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드가 추가되었으며, Python 패키지의 선택적 의존성 extras를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel용 호스팅 제공업체 통합도 추가되었습니다. -- 이후 실행에서 이전 실행의 교훈을 재사용할 수 있도록 샌드박스 메모리 지원이 추가되었으며, 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계 및 S3 기반 워크플로를 포함한 영속 메모리 코드 예제가 제공됩니다. -- 로컬 및 합성 워크스페이스 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하는 확장된 워크스페이스 및 재개 모델이 추가되었습니다. -- `examples/sandbox/` 아래에 기술을 활용한 코딩 작업, 핸드오프, 메모리, 제공업체별 설정과 코드 검토, 데이터룸 QA 및 웹사이트 복제 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제와 튜토리얼이 추가되었습니다. -- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더 안전한 민감한 MCP 출력 마스킹 기능으로 핵심 런타임 및 트레이싱 스택이 확장되었습니다. +- `SandboxAgent`, `Manifest`, `SandboxRunConfig`을 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스를 추가하여 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 기능을 갖춘 지속적이고 격리된 작업 공간에서 작업할 수 있도록 했습니다. +- `UnixLocalSandboxClient` 및 `DockerSandboxClient`을 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드를 추가했으며, Python 패키지의 선택적 종속성 extras를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel용 호스팅 제공자 통합도 추가했습니다. +- 이후 실행에서 이전 실행으로부터 얻은 교훈을 재사용할 수 있도록 샌드박스 메모리 지원을 추가했습니다. 여기에는 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계 및 S3 기반 워크플로를 포함한 지속형 메모리 예제가 포함됩니다. +- 로컬 및 합성 작업 공간 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, 그리고 `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하는 더 광범위한 작업 공간 및 재개 모델을 추가했습니다. +- `examples/sandbox/` 아래에 기술을 활용한 코딩 작업, 핸드오프, 메모리, 제공자별 설정 및 코드 검토, 데이터룸 QA, 웹사이트 복제와 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 예제와 튜토리얼을 추가했습니다. +- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더 안전한 민감한 MCP 출력 수정을 통해 핵심 런타임과 트레이싱 스택을 확장했습니다. ### 0.13.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함되었습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함됩니다. 주요 내용: -- 기본 WebSocket Realtime 모델은 이제 `gpt-realtime-1.5`이므로, 새 Realtime 에이전트 설정에서는 추가 구성 없이 최신 모델을 사용합니다. -- 이제 `MCPServer`은 `list_resources()`, `list_resource_templates()` 및 `read_resource()`을 노출하고, `MCPServerStreamableHttp`는 `session_id`을 노출합니다. 따라서 MCP Streamable HTTP 전송을 사용하는 세션을 재연결이나 상태 비저장 워커 간에 재개할 수 있습니다. -- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠를 다시 전송하도록 선택할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 제공업체별 추론 및 도구 호출 연속성이 개선됩니다. -- `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 연결 대상이 없는 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경합 상태를 포함한 여러 런타임 및 세션 경계 사례가 수정되었습니다. +- 이제 기본 WebSocket Realtime 모델은 `gpt-realtime-1.5`이므로 새로운 실시간 에이전트 설정에서 추가 구성 없이 더 최신 모델을 사용합니다. +- 이제 `MCPServer`에서 `list_resources()`, `list_resource_templates()`, `read_resource()`을 노출하고, `MCPServerStreamableHttp`에서 `session_id`을 노출하므로 MCP Streamable HTTP 전송을 사용하는 세션을 재연결 또는 상태 비저장 워커 전반에서 재개할 수 있습니다. +- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠 재전송을 옵트인할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 제공자별 추론/도구 호출 연속성이 향상됩니다. +- `SQLAlchemySession`의 동시 첫 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경합 상태를 비롯한 여러 런타임 및 세션의 극단적 사례를 수정했습니다. ### 0.12.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. ### 0.11.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. ### 0.10.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함되었습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함됩니다. 주요 내용: -- OpenAI Responses 모델을 위한 WebSocket 전송 지원이 추가되었습니다. 명시적으로 활성화해야 하며 HTTP는 계속 기본 전송 방식입니다. -- 여러 턴에 걸친 실행에서 공유 WebSocket 지원 제공업체와 `RunConfig`을 재사용하기 위한 `responses_websocket_session()` 헬퍼/`ResponsesWebSocketSession`이 추가되었습니다. -- 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 코드 예제(`examples/basic/stream_ws.py`)가 추가되었습니다. +- OpenAI Responses 모델에 대한 WebSocket 전송 지원을 추가했습니다(옵트인이며 HTTP가 계속 기본 전송 방식입니다). +- 여러 턴의 실행에서 공유 WebSocket 지원 제공자와 `RunConfig`을 재사용하기 위한 `responses_websocket_session()` 헬퍼 / `ResponsesWebSocketSession`을 추가했습니다. +- 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 예제(`examples/basic/stream_ws.py`)를 추가했습니다. ### 0.9.0 -이 버전에서는 해당 메이저 버전이 3개월 전에 지원 종료(EOL)에 도달했으므로 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. +이 버전에서는 주요 버전이 3개월 전에 지원 종료(EOL)에 도달함에 따라 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. -또한 `Agent#as_tool()` 메서드에서 반환되는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁혀졌습니다. 일반적으로 이 변경으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니온 타입에 의존한다면 일부 조정이 필요할 수 있습니다. +또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`으로 좁혀졌습니다. 이 변경으로 일반적으로 호환성이 깨지는 문제가 발생하지는 않지만, 코드가 더 넓은 유니언 타입에 의존한다면 일부 조정이 필요할 수 있습니다. ### 0.8.0 -이 버전에서는 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. +이 버전에서는 런타임 동작 변경 사항 두 가지로 인해 마이그레이션 작업이 필요할 수 있습니다. -- **동기식** Python 호출 가능 객체를 래핑하는 `FunctionTool` 인스턴스는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태나 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시적으로 지정하세요. -- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존하는 경우 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로 명시적인 핸들러가 있는 각 로컬 MCP 서버에서 `failure_error_function=None`을 설정하세요. +- `FunctionTool` 인스턴스가 래핑하는 **동기식** Python 호출 가능 객체는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태 또는 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시적으로 지정하세요. +- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로, 명시적 핸들러가 있는 각 로컬 MCP 서버에 `failure_error_function=None`을 설정하세요. ### 0.7.0 이 버전에는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경 사항이 있습니다. -- 이제 중첩된 핸드오프 기록은 **명시적으로 활성화**해야 하며 기본적으로 비활성화되어 있습니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. -- `gpt-5.1`/`gpt-5.2`의 기본 `reasoning.effort`이 SDK 기본값으로 구성되던 이전 기본값 `"low"`에서 `"none"`로 변경되었습니다. 프롬프트나 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. +- 이제 중첩된 핸드오프 기록은 **옵트인** 방식입니다(기본적으로 비활성화됨). v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. +- `gpt-5.1` / `gpt-5.2`의 기본 `reasoning.effort`이 SDK 기본값으로 구성되었던 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트나 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. ### 0.6.0 -이 버전에서는 사용자와 어시스턴트 턴을 별도의 메시지로 전달하는 대신 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여 이후 에이전트에 간결하고 예측 가능한 요약을 제공합니다 -- 이제 기존의 단일 메시지 핸드오프 트랜스크립트는 기본적으로 `` 블록 앞에 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`로 시작하므로 이후 에이전트에 명확히 표시된 요약이 제공됩니다 +이 버전에서는 사용자와 어시스턴트 턴을 별도의 메시지로 전달하는 대신, 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여 후속 에이전트에 간결하고 예측 가능한 요약을 제공합니다 +- 이제 기존의 단일 메시지 핸드오프 대화 기록은 기본적으로 `` 블록 앞에서 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`으로 시작하므로 후속 에이전트가 명확한 레이블이 있는 요약을 받습니다 ### 0.5.0 -이 버전은 눈에 띄는 호환성 변경 사항을 도입하지 않지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되었습니다. +이 버전은 눈에 보이는 호환성을 깨는 변경 사항을 도입하지 않지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되어 있습니다. -- [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리하기 위한 지원이 `RealtimeRunner`에 추가되었습니다. -- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직이 크게 개정되었습니다 +- `RealtimeRunner`에 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip) 처리 지원을 추가했습니다. +- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직을 대폭 수정했습니다. ### 0.4.0 @@ -192,8 +207,8 @@ structured outputs 에이전트의 경우 핸들러는 에이전트의 출력 ### 0.2.0 -이 버전에서는 이전에 `Agent`을 인수로 받던 일부 위치에서 이제 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처에 적용됩니다. 이는 타입만 변경된 것이며, 계속 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`을 `AgentBase`로 교체하여 타입 오류를 수정하면 됩니다. +이 버전에서는 이전에 `Agent`을 인수로 받던 몇몇 위치가 이제 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처에 이 변경이 적용됩니다. 이는 순수한 타입 변경이며, 계속해서 `Agent` 객체를 받습니다. 업데이트하려면 `Agent`을 `AgentBase`으로 바꿔 타입 오류를 수정하면 됩니다. ### 0.1.0 -이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context` 및 `agent`이라는 두 개의 새 매개변수가 있습니다. `MCPServer`의 하위 클래스에서 재정의된 모든 `MCPServer.list_tools()` 메서드에 이 매개변수를 추가해야 합니다. \ No newline at end of file +이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context`와 `agent`이라는 두 개의 새로운 매개변수가 추가되었습니다. `MCPServer` 하위 클래스에서 재정의한 모든 `MCPServer.list_tools()` 메서드에 이러한 매개변수를 추가해야 합니다. \ No newline at end of file diff --git a/docs/ko/results.md b/docs/ko/results.md index f646741215..c84db2cf58 100644 --- a/docs/ko/results.md +++ b/docs/ko/results.md @@ -4,76 +4,77 @@ search: --- # 결과 -`Runner.run` 메서드를 호출하면 다음 두 가지 결과 유형 중 하나를 받습니다. +`Runner.run` 메서드를 호출하면 다음 두 결과 유형 중 하나를 받습니다. -- `Runner.run(...)` 또는 `Runner.run_sync(...)`에서 [`RunResult`][agents.result.RunResult] -- `Runner.run_streamed(...)`에서 [`RunResultStreaming`][agents.result.RunResultStreaming] +- `Runner.run(...)` 또는 `Runner.run_sync(...)`에서 반환되는 [`RunResult`][agents.result.RunResult] +- `Runner.run_streamed(...)`에서 반환되는 [`RunResultStreaming`][agents.result.RunResultStreaming] -둘 다 [`RunResultBase`][agents.result.RunResultBase]를 상속하며, `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()` 같은 공통 결과 인터페이스를 제공합니다. +두 유형 모두 [`RunResultBase`][agents.result.RunResultBase]을 상속하며, 이 기본 클래스는 `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()` 같은 공통 결과 인터페이스를 제공합니다. `RunResultStreaming`에는 [`stream_events()`][agents.result.RunResultStreaming.stream_events], [`current_agent`][agents.result.RunResultStreaming.current_agent], [`is_complete`][agents.result.RunResultStreaming.is_complete], [`cancel(...)`][agents.result.RunResultStreaming.cancel] 같은 스트리밍 전용 제어 기능이 추가됩니다. -## 적합한 결과 인터페이스 선택 +## 적절한 결과 인터페이스 선택 대부분의 애플리케이션에는 몇 가지 결과 속성이나 헬퍼만 필요합니다. -| 필요한 항목 | 사용 항목 | +| 필요한 항목 | 사용 대상 | | --- | --- | | 사용자에게 표시할 최종 답변 | `final_output` | -| 전체 로컬 대화 기록이 포함된, 재생 가능한 다음 턴 입력 목록 | `to_input_list()` | +| 전체 로컬 대화 기록이 포함된 재실행 가능한 다음 턴 입력 목록 | `to_input_list()` | | 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 실행 항목 | `new_items` | | 일반적으로 다음 사용자 턴을 처리해야 하는 에이전트 | `last_agent` | -| `previous_response_id`을 사용한 OpenAI Responses API 체이닝 | `last_response_id` | +| `previous_response_id`을 사용하는 OpenAI Responses API 체이닝 | `last_response_id` | | 대기 중인 승인과 재개 가능한 스냅샷 | `interruptions` 및 `to_state()` | | 현재 중첩된 `Agent.as_tool()` 호출에 관한 메타데이터 | `agent_tool_invocation` | | 가공되지 않은 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | ## 최종 출력 -[`final_output`][agents.result.RunResultBase.final_output] 속성에는 마지막으로 실행된 에이전트의 최종 출력이 들어 있습니다. 다음 중 하나입니다. +[`final_output`][agents.result.RunResultBase.final_output] 속성에는 마지막으로 실행된 에이전트의 최종 출력이 포함됩니다. 다음 중 하나입니다. - 마지막 에이전트에 `output_type`이 정의되지 않은 경우 `str` - 마지막 에이전트에 출력 유형이 정의된 경우 `last_agent.output_type` 유형의 객체 -- 예를 들어 승인 인터럽션(중단 처리)으로 일시 중지되어 최종 출력이 생성되기 전에 실행이 중단된 경우 `None` +- 승인 인터럽션(중단 처리)에서 일시 중지되는 등 최종 출력이 생성되기 전에 실행이 중단된 경우 `None` !!! note - `final_output`의 유형은 `Any`입니다. 핸드오프로 인해 실행을 완료하는 에이전트가 바뀔 수 있으므로 SDK는 가능한 출력 유형 전체를 정적으로 알 수 없습니다. + `final_output`의 유형은 `Any`입니다. 핸드오프로 인해 실행을 완료하는 에이전트가 변경될 수 있으므로 SDK는 가능한 출력 유형 전체를 정적으로 알 수 없습니다. -스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`가 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참조하세요. +스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`가 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참고하세요. ## 입력, 다음 턴 기록 및 새 항목 -다음 인터페이스는 각각 서로 다른 질문에 답합니다. +다음 인터페이스는 서로 다른 질문에 답합니다. | 속성 또는 헬퍼 | 포함 내용 | 적합한 용도 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성한 경우 실행을 계속할 때 사용된 필터링된 입력이 반영됩니다. | 이 실행에서 실제로 입력으로 사용한 항목 감사 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행을 입력 항목 형태로 보여줍니다. 기본 `mode="preserve_all"`는 `new_items`에서 변환된 기록을 유지하지만, SDK 기본 중첩 핸드오프 기록으로 이미 이동된 정확히 동일한 세션 항목 인스턴스를 두 번째로 추가하지는 않습니다. 핸드오프 필터링으로 모델 기록을 다시 작성하는 경우 `mode="normalized"`은 정규 연속 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리형 대화 상태 및 일반 항목 기록 검사 | -| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사 및 디버깅 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 가져온 가공되지 않은 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 제공자 수준 진단 또는 가공되지 않은 응답 검사 | +| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성했다면 실행이 계속될 때 사용한 필터링된 입력을 반영합니다. | 이 실행에서 실제로 입력으로 사용한 내용 감사 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행을 입력 항목 형태로 보여 줍니다. 기본 `mode="preserve_all"`은 `new_items`에서 변환된 기록을 유지하지만, SDK 기본 중첩 핸드오프 기록으로 이미 이동된 정확히 동일한 세션 항목 인스턴스는 다시 추가하지 않습니다. 핸드오프 필터링이 모델 기록을 다시 작성하는 경우 `mode="normalized"`은 표준 연속 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리 대화 상태, 일반 항목 기록 검사 | +| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사, 디버깅 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 반환된 가공되지 않은 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 제공자 수준의 진단 또는 가공되지 않은 응답 검사 | 실제로는 다음과 같이 사용합니다. - 실행을 일반 입력 항목 형태로 확인하려면 `to_input_list()`을 사용합니다. -- 핸드오프 필터링이나 중첩 핸드오프 기록 재작성 후 다음 `Runner.run(..., input=...)` 호출에 사용할 정규 로컬 입력이 필요하면 `to_input_list(mode="normalized")`을 사용합니다. -- SDK에서 기록을 로드하고 저장하도록 하려면 [`session=...`](sessions/index.md)를 사용합니다. -- `conversation_id` 또는 `previous_response_id`을 사용하여 OpenAI 서버 관리형 상태를 이용하는 경우에는 일반적으로 `to_input_list()`을 다시 보내는 대신 새 사용자 입력만 전달하고 저장된 ID를 재사용합니다. -- 로그, UI 또는 감사를 위해 변환된 전체 기록이 필요하면 기본 `to_input_list()` 모드 또는 `new_items`를 사용합니다. +- 핸드오프 필터링 또는 중첩 핸드오프 기록 재작성 후 다음 `Runner.run(..., input=...)` 호출을 위한 표준 로컬 입력이 필요하면 `to_input_list(mode="normalized")`을 사용합니다. +- SDK가 기록을 로드하고 저장하도록 하려면 [`session=...`](sessions/index.md)을 사용합니다. +- `conversation_id` 또는 `previous_response_id`을 사용하여 OpenAI 서버 관리 상태를 이용하는 경우, 일반적으로 `to_input_list()`을 다시 전송하는 대신 새 사용자 입력만 전달하고 저장된 ID를 재사용합니다. +- 로그, UI 또는 감사에 사용할 전체 변환 기록이 필요하면 기본 `to_input_list()` 모드 또는 `new_items`을 사용합니다. -SDK 기본 중첩 핸드오프 기록이 메시지 항목을 그대로 보존할 때 Sessions, `RunState`, `to_input_list()`은 콘텐츠를 기준으로 중복 제거하지 않고 소유된 정확한 인스턴스를 추적합니다. 별도로 발생한 동일한 메시지는 별도로 유지되며, 이미 소유된 인스턴스만 두 번째로 추가되지 않습니다. +SDK 기본 중첩 핸드오프 기록이 메시지 항목을 그대로 보존하는 경우 Sessions, `RunState`, `to_input_list()`은 콘텐츠를 기준으로 중복을 제거하지 않고 정확히 소유된 인스턴스를 추적합니다. 서로 별도로 발생한 동일한 메시지는 별도로 유지되며, 이미 소유된 인스턴스만 다시 추가되지 않습니다. JavaScript SDK와 달리 Python은 실행 중 새로 생성된 모델 형식 항목만 포함하는 별도의 `output` 속성을 제공하지 않습니다. SDK 메타데이터가 필요하면 `new_items`을 사용하고, 가공되지 않은 모델 페이로드가 필요하면 `raw_responses`을 검사합니다. -컴퓨터 도구 항목을 대화 입력으로 다시 제출할 때는 가공되지 않은 Responses 페이로드 형식을 사용합니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 보존하는 반면, `gpt-5.5` 컴퓨터 호출은 일괄 처리된 `actions[]`을 보존할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list]와 [`RunState`][agents.run_state.RunState]는 모델이 생성한 형식을 그대로 유지하므로, 해당 항목을 대화 입력으로 수동 재제출하는 작업, 일시 중지/재개 흐름, 저장된 대화 기록이 프리뷰 및 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 계속해서 `new_items`에 `computer_call_output` 항목으로 표시됩니다. +컴퓨터 도구 항목을 대화 입력으로 다시 제출할 때는 가공되지 않은 Responses 페이로드 형식을 사용합니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 보존하는 반면, `gpt-5.5` 컴퓨터 호출은 일괄 처리된 `actions[]`을 보존할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list] 및 [`RunState`][agents.run_state.RunState]은 모델이 생성한 형식을 그대로 유지하므로, 이러한 항목을 대화 입력으로 수동 재제출하는 작업, 일시 중지/재개 흐름, 저장된 대화 기록이 프리뷰 및 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 여전히 `new_items`에서 `computer_call_output` 항목으로 나타납니다. ### 새 항목 -[`new_items`][agents.result.RunResultBase.new_items]은 실행 중 발생한 작업을 가장 풍부한 형태로 보여줍니다. 일반적인 항목 유형은 다음과 같습니다. +[`new_items`][agents.result.RunResultBase.new_items]은 실행 중 발생한 작업을 가장 풍부한 형태로 보여 줍니다. 일반적인 항목 유형은 다음과 같습니다. +- 재개된 모델 호출 직전에 `RunState.pending_input`에서 수용된 입력을 나타내는 [`InputItem`][agents.items.InputItem] - 어시스턴트 메시지를 나타내는 [`MessageOutputItem`][agents.items.MessageOutputItem] - 추론 항목을 나타내는 [`ReasoningItem`][agents.items.ReasoningItem] -- Responses 도구 검색 요청과 로드된 도구 검색 결과를 나타내는 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 및 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- Responses 도구 검색 요청 및 로드된 도구 검색 결과를 나타내는 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 및 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] - 도구 호출과 그 결과를 나타내는 [`ToolCallItem`][agents.items.ToolCallItem] 및 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] - 승인을 위해 일시 중지된 도구 호출을 나타내는 [`ToolApprovalItem`][agents.items.ToolApprovalItem] - 호스티드 MCP 승인 및 도구 카탈로그를 나타내는 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem], [`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem], [`MCPListToolsItem`][agents.items.MCPListToolsItem] @@ -81,11 +82,11 @@ JavaScript SDK와 달리 Python은 실행 중 새로 생성된 모델 형식 항 에이전트 연결 관계, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 `to_input_list()`보다 `new_items`을 선택합니다. -호스티드 도구 검색을 사용할 때는 `ToolSearchCallItem.raw_item`을 검사하여 모델이 생성한 검색 요청을 확인하고, `ToolSearchOutputItem.raw_item`를 검사하여 해당 턴에 어떤 네임스페이스, 함수 또는 호스티드 MCP 서버가 로드되었는지 확인합니다. +호스티드 도구 검색을 사용할 때는 `ToolSearchCallItem.raw_item`을 검사하여 모델이 생성한 검색 요청을 확인하고, `ToolSearchOutputItem.raw_item`을 검사하여 해당 턴에 로드된 네임스페이스, 함수 또는 호스티드 MCP 서버를 확인합니다. -프로그래밍 방식 도구 호출을 사용할 때 생성된 `program`는 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출 역시 `ToolCallItem` 항목이며, 이에 대응하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램이 소유한 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외로, 각각 `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. +Programmatic Tool Calling을 사용할 때 생성된 `program`은 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출 역시 `ToolCallItem` 항목이며, 이에 대응하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외로, `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. -가공되지 않은 항목은 유형이 지정된 Responses 객체 또는 매핑일 수 있습니다. 특히 프로그램이 소유한 셸 및 패치 적용 호출은 매핑을 사용합니다. 다음과 같이 매핑을 안전하게 검사하는 패턴을 사용합니다. +가공되지 않은 항목은 유형이 지정된 Responses 객체 또는 매핑일 수 있습니다. 특히 프로그램 소유의 셸 및 패치 적용 호출은 매핑을 사용합니다. 매핑에 안전한 다음 검사 패턴을 사용합니다. ```python from collections.abc import Mapping @@ -107,21 +108,23 @@ caller_id = ( ) ``` -프로그램이 소유한 하위 호출의 경우 `caller`에서 `type` 필드는 `program`이고, `caller_id`은 상위 프로그램 호출을 식별합니다. +프로그램 소유 하위 호출의 경우 `caller`의 `type` 필드는 `program`이고, `caller_id`은 상위 프로그램 호출을 식별합니다. ## 대화 계속 또는 재개 ### 다음 턴 에이전트 -[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 들어 있습니다. 핸드오프 후 다음 사용자 턴에 재사용할 에이전트로 가장 적합한 경우가 많습니다. +[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 핸드오프 후 다음 사용자 턴에서 재사용할 에이전트로 적합한 경우가 많습니다. -스트리밍 모드에서는 실행 진행에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로 스트림이 완료되기 전에 핸드오프를 확인할 수 있습니다. +스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로 스트림이 완료되기 전에 핸드오프를 관찰할 수 있습니다. ### 인터럽션(중단 처리) 및 실행 상태 -도구에 승인이 필요한 경우 승인 대기 항목은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 호출된 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. +도구에 승인이 필요한 경우 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. -[`to_state()`][agents.result.RunResult.to_state]을 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]를 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`으로 재개합니다. +[`to_state()`][agents.result.RunResult.to_state]를 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]을 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`을 사용하여 재개합니다. + +[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] 출력이 Pydantic 모델 또는 데이터 클래스인 경우 `RunState`은 해당 출력을 structured outputs로 직렬화합니다. `RunState`은 딕셔너리, 목록, 튜플도 순회하며 해당 컨테이너에서 발견한 Pydantic 모델 또는 데이터 클래스를 변환합니다. 튜플은 JSON 왕복 변환 후 목록으로 복원됩니다. JSON과 호환되지 않는 다른 값은 문자열 표현으로 대체될 수 있으므로, 정확한 사용자 지정 유형이 직렬화 후에도 유지되어야 한다면 명시적으로 JSON과 호환되는 데이터를 반환합니다. ```python from agents import Agent, Runner @@ -136,59 +139,84 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -스트리밍 실행의 경우 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 사용을 완료한 다음 `result.interruptions`을 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프(HITL)](human_in_the_loop.md)를 참조하세요. +#### 재개 전 입력 추가 + +실행이 일시 중지되거나 완료된 턴 이후 중단되었지만 완료되지 않은 실행이 다음 모델 호출에 도달하기 전에 새 사용자 입력이 도착한 경우 [`RunState.add_input()`][agents.run_state.RunState.add_input]을 사용합니다. 문자열은 사용자 메시지가 되며 여러 번 호출하면 삽입 순서가 유지됩니다. 준비된 입력은 직렬화된 `RunState`의 일부이므로 `to_json()` / `from_json()` 및 `to_string()` / `from_string()` 왕복 변환 후에도 유지됩니다. + +```python +state = result.to_state() +state.add_input("Also keep the generated report in the project folder.") + +for interruption in state.get_interruptions(): + state.approve(interruption) + +result = await Runner.run(agent, state) +``` + +재개 시 러너는 현재 에이전트의 입력 가드레일과 [`RunConfig`][agents.run.RunConfig]의 입력 가드레일을 준비된 입력에만 적용합니다. 클라이언트 관리형 [`Session`][agents.memory.session.Session]이 구성된 경우 러너는 수용된 준비 입력을 영구적인 [`InputItem`][agents.items.InputItem]으로 변환하고, 모델 요청을 보내기 전에 세션 쓰기가 완료되기를 기다립니다. 클라이언트 관리형 세션이나 서버 관리형 대화가 없으면 러너는 모델 요청을 보내기 전에 수용된 준비 입력을 `InputItem`으로 변환합니다. 서버 관리형 대화에서는 서버 요청이 입력을 수락할 때까지 입력이 대기 상태로 유지됩니다. 직렬화, 재개 및 재실행에 안전한 재시도 전반에서 SDK는 하나의 영구적인 `InputItem` 인스턴스를 보존합니다. 이 SDK 인스턴스 보장은 제공자 전달 보장이 아닙니다. 요청이 제공자에게 도달했을 가능성이 있는 상태에서 재시도 정책이 `RetryDecision(approve_unsafe_replay=True)`을 반환하면 러너가 준비된 입력을 다시 전송할 수 있고 제공자 측 작업이 반복될 수 있습니다. 성공적으로 수용된 입력은 `new_items`에 `InputItem`으로 나타납니다. 분리된 복사본을 가져오려면 [`RunState.pending_input`][agents.run_state.RunState.pending_input]을 읽고, 재개하기 전에 준비된 입력을 모두 삭제하려면 [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input]을 호출합니다. + +`RunState.add_input()`은 종료 상태, 남은 모델 턴이 없는 상태, 수락된 모델 응답이 로컬 처리를 기다리는 상태, 대기 중인 도구 결과가 다른 모델 호출 전에 실행을 종료할 수 있는 인터럽션(중단 처리) 상태를 거부합니다. 이러한 경우에는 현재 실행을 완료하고 새 사용자 턴을 시작합니다. + +스트리밍 실행에서는 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 소비를 완료한 다음 `result.interruptions`을 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)를 참고하세요. ### 서버 관리형 연속 실행 -[`last_response_id`][agents.result.RunResultBase.last_response_id]은 실행에서 가장 최근 모델 응답의 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 `previous_response_id`로 다시 전달합니다. +[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 가장 최근 모델 응답의 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이를 `previous_response_id`으로 다시 전달합니다. -이미 `to_input_list()`, `session` 또는 `conversation_id`로 대화를 계속하고 있다면 일반적으로 `last_response_id`은 필요하지 않습니다. 여러 단계로 구성된 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`을 검사합니다. +이미 `to_input_list()`, `session` 또는 `conversation_id`을 사용하여 대화를 계속하고 있다면 일반적으로 `last_response_id`은 필요하지 않습니다. 여러 단계로 이루어진 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`을 검사합니다. -## 도구로서의 에이전트 메타데이터 +## 도구로 사용하는 에이전트 메타데이터 -중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 생성된 경우 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 해당 결과를 둘러싼 `Agent.as_tool()` 호출에 관한 불변 메타데이터를 제공합니다. +중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 반환되면 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 이를 둘러싼 `Agent.as_tool()` 호출에 관한 변경 불가능한 메타데이터를 제공합니다. - `tool_name` - `tool_call_id` - `tool_arguments` -일반적인 최상위 실행에서는 `agent_tool_invocation`이 `None`입니다. +일반적인 최상위 실행에서 `agent_tool_invocation`는 `None`입니다. -이는 `custom_output_extractor` 내에서 특히 유용합니다. 중첩된 결과를 후처리할 때 이를 둘러싼 `Agent.as_tool()` 호출의 도구 이름, 호출 ID 또는 가공되지 않은 인수가 필요할 수 있기 때문입니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참조하세요. +이는 중첩된 결과를 후처리하면서 이를 둘러싼 `Agent.as_tool()` 호출의 도구 이름, 호출 ID 또는 가공되지 않은 인수가 필요할 수 있는 `custom_output_extractor` 내부에서 특히 유용합니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참고하세요. -해당 중첩 실행에 대해 파싱된 구조화 입력도 필요한 경우 `context_wrapper.tool_input`을 읽습니다. 이는 [`RunState`][agents.run_state.RunState]가 중첩 도구 입력에 대해 일반적으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출의 메타데이터를 결과에 직접 노출합니다. +해당 중첩 실행에서 파싱된 구조화 입력도 필요하면 `context_wrapper.tool_input`을 읽습니다. 이는 [`RunState`][agents.run_state.RunState]이 중첩 도구 입력을 위해 일반적으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출의 메타데이터를 결과에 직접 노출합니다. ## 스트리밍 수명 주기 및 진단 [`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 인터페이스를 상속하지만 다음과 같은 스트리밍 전용 제어 기능을 추가합니다. -- 의미론적 스트림 이벤트를 사용하기 위한 [`stream_events()`][agents.result.RunResultStreaming.stream_events] -- 실행 도중 활성 에이전트를 추적하기 위한 [`current_agent`][agents.result.RunResultStreaming.current_agent] -- 스트리밍 실행이 완전히 종료되었는지 확인하기 위한 [`is_complete`][agents.result.RunResultStreaming.is_complete] -- 실행을 즉시 또는 현재 턴 이후 중단하기 위한 [`cancel(...)`][agents.result.RunResultStreaming.cancel] +- 의미론적 스트림 이벤트를 소비하는 [`stream_events()`][agents.result.RunResultStreaming.stream_events] +- 실행 중 활성 에이전트를 추적하는 [`current_agent`][agents.result.RunResultStreaming.current_agent] +- 스트리밍된 실행이 완전히 완료되었는지 확인하는 [`is_complete`][agents.result.RunResultStreaming.is_complete] +- 실행을 즉시 또는 현재 턴 이후 중지하는 [`cancel(...)`][agents.result.RunResultStreaming.cancel] -비동기 이터레이터가 끝날 때까지 `stream_events()`을 계속 사용합니다. 해당 이터레이터가 끝날 때까지 스트리밍 실행은 완료된 것이 아니며, 마지막으로 표시되는 토큰이 도착한 후에도 `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 영속화 부수 효과가 아직 처리 중일 수 있습니다. +비동기 이터레이터가 완료될 때까지 `stream_events()`을 계속 소비합니다. 이 이터레이터가 끝날 때까지 스트리밍 실행은 완료되지 않으며, 마지막으로 표시되는 토큰이 도착한 뒤에도 `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 영속화 부수 효과가 아직 처리 중일 수 있습니다. -`cancel()`을 호출하는 경우 취소 및 정리가 올바르게 완료될 수 있도록 `stream_events()`을 계속 사용합니다. +`cancel()`을 호출한 경우 취소 및 정리가 올바르게 완료될 수 있도록 `stream_events()`을 계속 소비합니다. -Python은 별도의 스트리밍된 `completed` 프로미스나 `error` 속성을 제공하지 않습니다. 실행을 종료시키는 스트리밍 오류는 `stream_events()`에서 발생하며, `is_complete`은 실행이 종료 상태에 도달했는지를 나타냅니다. +Python은 별도의 스트리밍된 `completed` 프로미스 또는 `error` 속성을 제공하지 않습니다. 실행을 종료시키는 스트리밍 실패는 `stream_events()`에서 예외로 발생하며, `is_complete`은 실행이 종료 상태에 도달했는지를 나타냅니다. ### 가공되지 않은 응답 -[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 가공되지 않은 모델 응답이 들어 있습니다. 여러 단계로 구성된 실행에서는 핸드오프나 반복되는 모델/도구/모델 주기 등으로 인해 둘 이상의 응답이 생성될 수 있습니다. +[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 가공되지 않은 모델 응답이 포함됩니다. 여러 단계로 이루어진 실행은 핸드오프 또는 반복되는 모델/도구/모델 주기 등으로 인해 둘 이상의 응답을 생성할 수 있습니다. + +[`last_response_id`][agents.result.RunResultBase.last_response_id]는 `raw_responses`의 마지막 항목에 있는 ID일 뿐입니다. + +각 [`ModelResponse`][agents.items.ModelResponse]은 해당 개별 모델 호출에 적용되는 두 가지 진단 정보도 제공합니다. + +- [`request_id`][agents.items.ModelResponse.request_id]는 모델 어댑터와 전송 계층이 요청 ID를 전파하는 경우의 전송 요청 ID입니다. 기본 제공되는 `OpenAIResponsesModel` 및 `OpenAIChatCompletionsModel`은 HTTP 및 SSE 전송 경로에서 사용 가능한 서버 생성 `x-request-id`을 전파합니다. 구성된 엔드포인트가 OpenAI API인 경우 프로덕션에서 `None`이 아닌 값을 기록하여 장애를 OpenAI 지원팀과 연관 지을 수 있도록 합니다. OpenAI 호환 제공자 또는 프록시의 경우에는 해당 서비스의 지원 채널을 사용합니다. 현재 `OpenAIResponsesWSModel`은 `request_id`을 `None`으로 둡니다. 서드 파티 어댑터는 요청 ID 전파를 보장하지 않습니다. AnyLLM Chat Completions 어댑터와 `LitellmModel`은 현재 `request_id`을 `None`으로 둡니다. Agents SDK AnyLLM Responses 어댑터도 전송 요청 ID를 보존하지 않고 제공자 응답을 정규화하는 경우 `request_id`을 `None`으로 둘 수 있습니다. +- [`raw_usage`][agents.items.ModelResponse.raw_usage]는 Agents SDK가 페이로드를 정규화하기 전 제공자의 사용량 페이로드를 JSON 호환 형식으로 캡처한 옵트인 스냅샷입니다. `ModelSettings(preserve_raw_usage=True)`을 사용하여 `raw_usage`을 활성화합니다. [제공자 사용량 페이로드 보존](usage.md#preserving-provider-usage-payloads)을 참고하세요. -[`last_response_id`][agents.result.RunResultBase.last_response_id]은 `raw_responses`의 마지막 항목에서 가져온 ID일 뿐입니다. +`ModelResponse.request_id`과 `ModelResponse.raw_usage`은 각각 `None`일 수 있으므로 이러한 값은 대화 상태가 아닌 선택적 진단 정보로 처리합니다. ### 가드레일 결과 -에이전트 수준 가드레일은 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 및 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results]로 노출됩니다. +에이전트 수준 가드레일은 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 및 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results]로 제공됩니다. -도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도로 노출됩니다. +도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도로 제공됩니다. -이 배열은 실행 전체에 걸쳐 누적되므로 의사 결정을 로깅하거나, 추가 가드레일 메타데이터를 저장하거나, 실행이 차단된 이유를 디버깅하는 데 유용합니다. +이러한 배열은 실행 전반에 걸쳐 누적되므로 결정 사항 기록, 추가 가드레일 메타데이터 저장 또는 실행이 차단된 이유 디버깅에 유용합니다. ### 컨텍스트 및 사용량 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper]은 승인, 사용량, 중첩된 `tool_input` 같은 SDK 관리형 런타임 메타데이터와 함께 애플리케이션 컨텍스트를 제공합니다. +[`context_wrapper`][agents.result.RunResultBase.context_wrapper]은 승인, 사용량, 중첩된 `tool_input` 같은 SDK 관리 런타임 메타데이터와 함께 애플리케이션 컨텍스트를 제공합니다. -사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행에서는 스트림의 마지막 청크가 처리될 때까지 사용량 합계 반영이 지연될 수 있습니다. 전체 래퍼 구조와 영속화 관련 주의 사항은 [컨텍스트 관리](context.md)를 참조하세요. \ No newline at end of file +사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행에서는 스트림의 마지막 청크가 처리될 때까지 사용량 합계 반영이 지연될 수 있습니다. 전체 래퍼 구조와 영속성 관련 주의 사항은 [컨텍스트 관리](context.md)를 참고하세요. \ No newline at end of file diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index b61ecca626..178b56b1da 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -6,9 +6,9 @@ search: [`Runner`][agents.run.Runner] 클래스를 통해 에이전트를 실행할 수 있습니다. 다음 3가지 옵션이 있습니다. -1. [`Runner.run()`][agents.run.Runner.run]: 비동기로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 메서드이며 내부적으로 `.run()`를 실행합니다. -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고 수신되는 이벤트를 스트리밍합니다. +1. [`Runner.run()`][agents.run.Runner.run]은 비동기 방식으로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]은 동기 메서드이며 내부적으로 `.run()`을 실행합니다. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]는 비동기 방식으로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고, 수신되는 이벤트를 사용자에게 스트리밍합니다. ```python from agents import Agent, Runner @@ -25,44 +25,44 @@ async def main(): 자세한 내용은 [결과 가이드](results.md)를 참조하세요. -## Runner 수명 주기 및 구성 +## 러너 수명 주기 및 구성 ### 에이전트 루프 -위의 세 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. +위 세 가지 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. - 문자열(사용자 메시지로 처리) - OpenAI Responses API 형식의 입력 항목 목록 -- 인터럽션된 실행을 재개하는 경우 [`RunState`][agents.run_state.RunState] +- 일시 중지된 실행 또는 `cancel(mode="after_turn")`으로 중단된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState]. 상태에는 [다음 재개 모델 호출을 위해 준비된 입력](results.md#add-input-before-resuming)도 포함될 수 있습니다. -그런 다음 Runner가 루프를 실행합니다. +그런 다음 러너는 다음 루프를 실행합니다. -1. 현재 입력으로 현재 에이전트의 LLM을 호출합니다. +1. 현재 입력을 사용해 현재 에이전트에 대해 LLM을 호출합니다. 2. LLM이 출력을 생성합니다. - 1. Runner가 LLM 출력을 최종 출력으로 분류하면 루프가 종료되고 결과를 반환합니다. + 1. 러너가 LLM의 출력을 최종 출력으로 분류하면 루프를 종료하고 결과를 반환합니다. 2. LLM이 핸드오프를 요청하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. - 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 뒤 루프를 다시 실행합니다. -3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외를 발생시킵니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. + 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. +3. 전달된 `max_turns`을 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 타입의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. + LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. ### 스트리밍 -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 생성된 모든 새 출력을 포함하여 실행에 관한 전체 정보가 들어 있습니다. 스트리밍 이벤트에는 `.stream_events()`을 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 비롯한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`을 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송(선택적 헬퍼) +#### Responses WebSocket 전송 방식(선택적 헬퍼) -OpenAI Responses websocket 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용에는 websocket 세션 헬퍼를 권장하지만 필수는 아닙니다. +OpenAI Responses websocket 전송 방식을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 websocket 세션 헬퍼 사용을 권장하지만 필수는 아닙니다. -이는 websocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. +이는 websocket 전송 방식을 사용하는 Responses API이며, [Realtime API](realtime/guide.md)가 아닙니다. -전송 선택 규칙과 구체적인 모델 객체 또는 커스텀 제공자 관련 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. +전송 방식 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 공급자와 관련된 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 헬퍼 없음(작동 가능) +##### 패턴 1: 세션 헬퍼 미사용(작동함) -websocket 전송만 필요하고 SDK가 공유 제공자/세션을 관리할 필요가 없을 때 사용합니다. +websocket 전송 방식만 필요하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 같은 `RunConfig` / 제공자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. +이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 직접 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. -##### 패턴 2: `responses_websocket_session()` 사용(멀티턴 재사용에 권장) +##### 패턴 2: `responses_websocket_session()` 사용(다중 턴 재사용에 권장) -여러 실행에서 공유할 수 있는 websocket 지원 제공자와 `RunConfig`이 필요할 때 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`을 상속하는 중첩된 Agents-as-tools 호출도 포함됩니다. +여러 실행에서 websocket을 지원하는 공급자와 `RunConfig`을 공유하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`을 상속하는 중첩된 에이전트 도구 호출도 포함됩니다. ```python import asyncio @@ -119,11 +119,11 @@ async def main(): asyncio.run(main()) ``` -컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 소비하세요. websocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. +컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 사용해야 합니다. websocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. -서비스는 각 websocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`을 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용해 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. +서비스는 각 websocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`을 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용해 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 방식 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. -긴 추론 턴에서 websocket 연결 유지 타임아웃이 발생하면 `ping_timeout`를 늘리거나 `ping_timeout=None`로 설정하여 하트비트 타임아웃을 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. +긴 추론 턴에서 websocket keepalive 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정해 하트비트 시간 초과를 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송 방식을 사용하세요. ### 실행 구성 @@ -133,45 +133,45 @@ asyncio.run(main()) 각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`을 사용하세요. -##### 모델, 제공자 및 세션 기본값 +##### 모델, 공급자 및 세션 기본값 -- [`model`][agents.run.RunConfig.model]: 각 에이전트에 설정된 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. -- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 제공자이며 기본값은 OpenAI입니다. +- [`model`][agents.run.RunConfig.model]: 각 에이전트가 가진 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. +- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며 기본값은 OpenAI입니다. - [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`을 설정할 수 있습니다. -- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. +- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 검색할 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. - [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions 사용 시 각 `Runner` 실행 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. ##### 가드레일, 핸드오프 및 모델 입력 구성 - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트에 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 무손실 메시지 항목의 원래 위치를 보존하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩 핸드오프를 안정화하는 동안 기본적으로 비활성화되어 있습니다. 활성화하려면 `True`으로 설정하고, 가공되지 않은 트랜스크립트를 그대로 전달하려면 `False`로 두세요. Sessions, `RunState` 및 `RunResult.to_input_list()`은 SDK 기본 중첩 기록에 이미 포함된 정확히 동일한 메시지 인스턴스를 두 번 추가하지 않으면서 별도의 동일 메시지는 보존합니다. 모든 [Runner 메서드][agents.run.Runner]는 전달된 값이 없을 때 자동으로 `RunConfig`을 생성하므로 빠른 시작과 코드 예제에서는 기본값이 비활성화된 상태로 유지되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`을 옵트인할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순서형 요약 세그먼트를 대체하여 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 입력 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 손실 없이 보존되는 메시지 항목을 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화됩니다. 활성화하려면 `True`으로 설정하고, 가공되지 않은 대화 기록을 그대로 전달하려면 `False`으로 두세요. Sessions, `RunState` 및 `RunResult.to_input_list()`은 SDK 기본 중첩 기록에 이미 포함된 동일한 메시지 발생 건을 두 번 추가하지 않으면서 별개의 동일 메시지는 보존합니다. 모든 [Runner 메서드][agents.run.Runner]는 사용자가 전달하지 않으면 자동으로 `RunConfig`을 생성하므로 빠른 시작과 코드 예제에서는 기본적으로 이 기능이 비활성화되어 있으며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백이 있으면 계속해서 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`을 옵트인할 때마다 정규화된 대화 기록(기록 + 핸드오프 항목)을 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순차 요약 세그먼트를 대체하도록 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. - [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: 러너가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 유지할지 생략할지 제어합니다. ##### 트레이싱 및 관측 가능성 -- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에 대해 [트레이싱](tracing.md)을 비활성화할 수 있습니다. +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에 대한 [트레이싱](tracing.md)을 비활성화할 수 있습니다. - [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]을 전달합니다. -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출 입출력과 같이 민감할 수 있는 데이터를 포함할지 구성합니다. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출의 입력/출력 등 잠재적으로 민감한 데이터를 포함할지 구성합니다. - [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`은 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. ##### 도구 실행, 승인 및 도구 오류 동작 -- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행할 로컬 함수 도구 호출 수 제한 등 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않을 때 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`를 발생시킵니다. 대신 모델에 표시되는 오류 출력을 반환하려면 옵트인하세요. -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구 이름과 핸드오프 이름이 충돌할 때 Runner가 처리하는 방식을 구성합니다. 기본값 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치 대상으로 선택된 항목만 노출합니다. `"error"`은 모델 호출 전에 `UserError`를 발생시킵니다. 네임스페이스가 있는 도구와 지연 로딩 도구에 대한 엄격한 검증은 변경되지 않습니다. -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인한 도구 없음 출력 등 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: 동시에 실행되는 로컬 함수 도구 호출 수 제한과 같은 로컬 도구 호출의 SDK 측 실행 동작을 구성합니다. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않을 때 러너가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`이 발생합니다. 대신 모델에 표시되는 오류 출력을 반환하도록 옵트인할 수 있습니다. +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구와 핸드오프 이름이 충돌할 때 러너가 처리하는 방식을 구성합니다. 기본값인 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치에서 선택된 항목만 노출합니다. `"error"`은 모델이 호출되기 전에 `UserError`을 발생시킵니다. 네임스페이스가 지정된 도구와 지연 로딩 도구에 대한 엄격한 검증은 변경되지 않습니다. +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인한 도구 미발견 출력 등 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. -중첩 핸드오프는 옵트인 베타로 제공됩니다. `RunConfig(nest_handoff_history=True)`을 전달하여 순서형 트랜스크립트 압축을 활성화하거나, 특정 핸드오프에서 사용하려면 `handoff(..., nest_handoff_history=True)`를 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축약하는 대신 무손실 메시지 항목 주위에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 기본값인 가공되지 않은 트랜스크립트를 유지하려면 플래그를 설정하지 않거나 대화를 필요한 형태 그대로 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)을 제공하세요. 커스텀 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출하세요. +중첩된 핸드오프는 옵트인 베타로 제공됩니다. 순차 대화 기록 압축을 활성화하려면 `RunConfig(nest_handoff_history=True)`을 전달하거나 특정 핸드오프에 대해 `handoff(..., nest_handoff_history=True)`을 설정하세요. 기본 제공 매퍼는 전체 대화 기록을 하나의 메시지로 축약하는 대신, 손실 없이 보존되는 메시지 항목 주변에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 기본값인 가공되지 않은 대화 기록을 유지하려면 플래그를 설정하지 않거나, 필요한 방식 그대로 대화를 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)을 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출합니다. #### 실행 구성 세부 정보 ##### `tool_execution` -실행의 로컬 함수 도구 동시 실행 수 제한 등 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. +실행에서 로컬 함수 도구의 동시 실행 수를 제한하는 등 로컬 함수 도구의 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에 여러 함수 도구 호출을 생성하면 SDK가 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행할 로컬 함수 도구 호출 수를 제한하려면 정숫값을 설정하세요. +`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에서 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 호출 수를 제한하려면 정수 값을 설정하세요. -이는 제공자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]과 별개입니다. `parallel_tool_calls`은 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`는 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. +이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`은 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`은 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. -`pre_approval_tool_input_guardrails=False`은 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 먼저 실행이 일시 중지되고, 승인 후 실행 직전에 도구 입력 가드레일이 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 생성되기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 동일한 입력 가드레일을 다시 실행하므로, 실행 전에 시간에 민감한 검사를 다시 검증합니다. +`pre_approval_tool_input_guardrails=False`은 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요한 경우 실행이 먼저 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 실행됩니다. 보류 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`으로 설정하세요. 이 승인 전 검사를 통과한 호출에도 승인 후 동일한 입력 가드레일이 다시 실행되므로, 시간에 민감한 검사는 실행 전에 다시 검증됩니다. ##### `tool_not_found_behavior` -기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 Runner가 `ModelBehaviorError`을 발생시킵니다. +기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 러너는 `ModelBehaviorError`을 발생시킵니다. -실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서 SDK는 해결되지 않은 도구 호출에 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. +실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서는 SDK가 해결되지 않은 도구 호출에 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 답변할 수 있습니다. ```python from agents import Agent, RunConfig, Runner @@ -214,7 +214,7 @@ result = await Runner.run( ) ``` -현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 밖의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 외의 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. ##### `tool_error_formatter` @@ -222,14 +222,14 @@ SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. -- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`와 같은 오류 카테고리입니다. -- `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`)입니다. -- `tool_name`: 도구 이름입니다. -- `call_id`: 도구 호출 ID입니다. -- `default_message`: SDK의 기본 모델 표시 메시지입니다. -- `run_context`: 활성 실행 컨텍스트 래퍼입니다. +- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`과 같은 오류 카테고리 +- `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`) +- `tool_name`: 도구 이름 +- `call_id`: 도구 호출 ID +- `default_message`: 모델에 표시되는 SDK의 기본 메시지 +- `run_context`: 활성 실행 컨텍스트 래퍼 -메시지를 대체하려면 문자열을 반환하고, SDK 기본값을 사용하려면 `None`를 반환하세요. +메시지를 대체할 문자열을 반환하거나, SDK 기본값을 사용하려면 `None`을 반환하세요. ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,22 +256,22 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`은 Runner가 기록을 다음 턴으로 전달할 때 추론 항목을 다음 턴 모델 입력으로 변환하는 방식을 제어합니다. 예를 들어 `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때 적용됩니다. +`reasoning_item_id_policy`은 러너가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행 사용 시) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. -- `None` 또는 `"preserve"`(기본값): 추론 항목 ID를 유지합니다. -- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID를 제거합니다. +- `None` 또는 `"preserve"`(기본값): 추론 항목 ID 유지 +- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID 제거 -추론 항목이 `id`과 함께 전송되지만 필수 후속 항목(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)은 없는 경우 발생하는 Responses API 400 오류 유형을 완화하려면 주로 옵트인 방식으로 `"omit"`를 사용하세요. +주로 추론 항목이 `id`과 함께 전송되지만 필수 후속 항목(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)은 없는 경우 발생하는 Responses API 400 오류 유형을 완화하는 옵트인 방식으로 `"omit"`을 사용하세요. -이 오류는 SDK가 이전 출력으로 후속 입력을 구성하는 멀티턴 에이전트 실행에서 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID는 보존되지만 제공자는 해당 ID가 대응하는 후속 항목과 계속 쌍을 이루도록 요구할 수 있습니다. +이 문제는 다중 턴 에이전트 실행에서 SDK가 이전 출력으로 후속 입력을 구성할 때 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID가 보존되지만 공급자가 해당 ID를 그에 대응하는 후속 항목과 계속 쌍으로 유지하도록 요구할 수 있습니다. -`reasoning_item_id_policy="omit"`을 설정하면 추론 콘텐츠는 유지하면서 추론 항목의 `id`을 제거하므로, SDK가 생성한 후속 입력에서 해당 API 불변 조건이 위반되는 것을 방지할 수 있습니다. +`reasoning_item_id_policy="omit"`을 설정하면 추론 콘텐츠는 유지하되 추론 항목의 `id`은 제거하므로, SDK가 생성한 후속 입력에서 해당 API 불변 조건이 트리거되는 것을 방지할 수 있습니다. 적용 범위 참고 사항: - SDK가 후속 입력을 구성할 때 생성하거나 전달하는 추론 항목만 변경합니다. - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. -- 이 정책을 적용한 후에도 `call_model_input_filter`에서 의도적으로 추론 ID를 다시 추가할 수 있습니다. +- 이 정책이 적용된 후에도 `call_model_input_filter`이 의도적으로 추론 ID를 다시 추가할 수 있습니다. ## 상태 및 대화 관리 @@ -279,29 +279,29 @@ result = Runner.run_sync( 다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달할 항목 | +| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | | --- | --- | --- | --- | -| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 제공자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | -| `session` | 자체 스토리지와 SDK | 지속적인 채팅 상태, 재개 가능한 실행, 커스텀 저장소 | 동일한 `session` 인스턴스 또는 같은 저장소를 가리키는 다른 인스턴스 | -| `conversation_id` | OpenAI Conversations API | 여러 워커나 서비스에서 공유하려는 이름 있는 서버 측 대화 | 동일한 `conversation_id`과 새 사용자 턴만 전달 | -| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 실행 | `result.last_response_id`와 새 사용자 턴만 전달 | +| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | +| `session` | 사용자 스토리지 및 SDK | 영구 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 동일한 저장소를 가리키는 다른 인스턴스 | +| `conversation_id` | OpenAI Conversations API | 여러 워커 또는 서비스에서 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`과 새 사용자 턴만 전달 | +| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리 연속 실행 | `result.last_response_id`과 새 사용자 턴만 전달 | -`result.to_input_list()`과 `session`은 클라이언트에서 관리합니다. `conversation_id`과 `previous_response_id`는 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화별로 하나의 지속성 전략을 선택하세요. 클라이언트 관리형 기록과 OpenAI 관리형 상태를 함께 사용하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. +`result.to_input_list()`과 `session`은 클라이언트에서 관리합니다. `conversation_id`과 `previous_response_id`은 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 두 계층을 의도적으로 조정하는 경우가 아니라면 클라이언트 관리 기록과 OpenAI 관리 상태를 혼합할 때 컨텍스트가 중복될 수 있습니다. !!! note - 같은 실행에서 세션 지속성과 서버 관리 대화 설정 - (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)을 + 같은 실행에서는 세션 지속성을 서버 관리 대화 설정 + (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 함께 사용할 수 없습니다. 호출마다 한 가지 방식을 선택하세요. ### 대화/채팅 스레드 -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며, 이에 따라 하나 이상의 LLM 호출이 발생할 수 있습니다. 하지만 이는 채팅 대화에서 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며, 따라서 하나 이상의 LLM 호출이 이루어질 수 있습니다. 하지만 이는 채팅 대화에서 논리적으로 하나의 턴을 나타냅니다. 예를 들면 다음과 같습니다. -1. 사용자 턴: 사용자가 텍스트를 입력합니다. -2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 뒤 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트가 추가 도구를 실행한 후 출력을 생성합니다. +1. 사용자 턴: 사용자가 텍스트 입력 +2. 러너 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프하며, 두 번째 에이전트가 추가 도구를 실행한 다음 출력을 생성 -에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 경우든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. +에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 표시하거나 최종 출력만 표시할 수 있습니다. 어떤 방식을 사용하든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. #### 수동 대화 관리 @@ -327,9 +327,9 @@ async def main(): # California ``` -#### 세션을 통한 자동 대화 관리 +#### Sessions를 통한 자동 대화 관리 -더 간단한 방식으로는 `.to_input_list()`를 수동 호출하지 않고도 [Sessions](sessions/index.md)를 사용하여 대화 기록을 자동으로 처리할 수 있습니다. +더 간단한 방법으로는 `.to_input_list()`을 수동으로 호출하지 않고도 대화 기록을 자동으로 처리하는 [Sessions](sessions/index.md)를 사용할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession, trace @@ -353,18 +353,18 @@ async def main(): # California ``` -Sessions는 자동으로 다음 작업을 수행합니다. +Sessions는 다음 작업을 자동으로 수행합니다. -- 각 실행 전에 대화 기록 조회 +- 각 실행 전에 대화 기록 검색 - 각 실행 후 새 메시지 저장 -- 서로 다른 세션 ID에 대해 별도 대화 유지 +- 서로 다른 세션 ID별로 별도의 대화 유지 자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. #### 서버 관리 대화 -`to_input_list()` 또는 `Sessions`을 사용하여 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 매번 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래 서버 관리 방식 중 하나를 사용할 때는 각 요청에서 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. +`to_input_list()` 또는 `Sessions`을 사용해 로컬에서 처리하는 대신, OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전 메시지를 모두 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래의 서버 관리 방식 중 하나를 사용할 때는 요청마다 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. OpenAI는 턴 간 상태를 추적하는 두 가지 방법을 제공합니다. @@ -418,31 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개되는 경우 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 같은 서버 관리 대화에서 계속됩니다. +승인을 위해 실행이 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하는 경우, SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리 대화에서 계속 진행됩니다. -`conversation_id`과 `previous_response_id`는 함께 사용할 수 없습니다. 여러 시스템에서 공유할 수 있는 이름 있는 대화 리소스가 필요하면 `conversation_id`를 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`을 사용하세요. +`conversation_id`과 `previous_response_id`은 상호 배타적입니다. 여러 시스템에서 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`을 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`을 사용하세요. !!! note - SDK는 `conversation_locked` 오류를 백오프 방식으로 자동 재시도합니다. 서버 관리 - 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌려 준비된 동일 항목을 - 문제없이 다시 전송할 수 있도록 합니다. + SDK는 백오프를 적용해 `conversation_locked` 오류를 자동으로 재시도합니다. 서버 관리 + 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌려 동일하게 준비된 + 항목을 문제없이 다시 전송할 수 있도록 합니다. - 로컬 세션 기반 실행(`conversation_id`, - `previous_response_id` 또는 `auto_previous_response_id`과 함께 사용할 수 없음)에서도 SDK는 - 재시도 후 기록 항목이 중복되는 것을 줄이기 위해 최근에 저장된 입력 항목을 최선의 방식으로 - 롤백합니다. + 로컬 세션 기반 실행(`conversation_id`, `previous_response_id` 또는 + `auto_previous_response_id`과 함께 사용할 수 없음)에서도 SDK는 재시도 후 기록 항목의 + 중복을 줄이기 위해 최근에 저장된 입력 항목을 최선의 방식으로 롤백합니다. 이 호환성 재시도는 `ModelSettings.retry`을 구성하지 않아도 수행됩니다. 모델 요청에 - 대한 더 광범위한 옵트인 재시도 동작은 [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. + 대한 더 광범위한 옵트인 재시도 동작은 [러너 관리 재시도](models/index.md#runner-managed-retries)를 참조하세요. ## 훅 및 사용자 지정 ### 모델 호출 입력 필터 -모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`를 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 받아 새로운 `ModelInputData`을 반환합니다. +모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`을 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 받고 새로운 `ModelInputData`을 반환합니다. -반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`이 발생합니다. +반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형식을 반환하면 `UserError`이 발생합니다. ```python from agents import Agent, Runner, RunConfig @@ -461,19 +460,19 @@ result = Runner.run_sync( ) ``` -Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원래 목록을 제자리에서 변경하지 않고도 항목을 줄이거나 대체하거나 재정렬할 수 있습니다. +러너는 준비된 입력 목록의 사본을 훅에 전달하므로 호출자의 원래 목록을 인플레이스 방식으로 변경하지 않고도 항목을 줄이거나 대체하거나 재정렬할 수 있습니다. -세션을 사용하는 경우 `call_model_input_filter`은 세션 기록이 이미 로드되어 현재 턴과 병합된 후 실행됩니다. 이전 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. +세션을 사용하는 경우 `call_model_input_filter`은 세션 기록을 이미 로드하여 현재 턴과 병합한 후 실행됩니다. 이보다 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. -`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`과 함께 OpenAI 서버 관리 대화 상태를 사용하는 경우 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 이 페이로드는 이전 기록 전체를 다시 재생하는 대신 새 턴의 델타만 나타낼 수도 있습니다. 반환한 항목만 해당 서버 관리 연속 실행에 전송된 것으로 표시됩니다. +`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`을 통해 OpenAI 서버 관리 대화 상태를 사용하는 경우, 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록 전체를 재현하는 대신 새 턴의 델타만 나타낼 수도 있습니다. 사용자가 반환한 항목만 해당 서버 관리 연속 실행에서 전송된 것으로 표시됩니다. -민감한 데이터를 편집하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 삽입하려면 `run_config`를 통해 실행별로 훅을 설정하세요. +민감한 데이터를 삭제하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 삽입하려면 `run_config`을 통해 실행별로 훅을 설정하세요. ## 오류 및 복구 ### 오류 핸들러 -모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`를 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. +모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`을 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. ```python from agents import ( @@ -502,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -모델 메시지가 에이전트의 structured `output_type`에 대해 유효성 검사를 통과하지 못하거나 모델이 structured 최종 메시지를 반환하지 않는 경우 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 값을 반환할 수 있으며 SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지는 않습니다. `None`을 반환하면 복구를 거부합니다. 대체 값이 없으면 비어 있지 않은 유효성 검사 실패는 계속 `ModelBehaviorError`를 발생시키며, 비어 있는 structured 응답에는 기존 다음 턴 동작이 유지됩니다. +모델 메시지가 에이전트의 구조화된 `output_type`에 대해 검증되지 않거나 모델이 구조화된 최종 메시지를 반환하지 않을 때 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 값을 반환할 수 있으며, SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지는 않습니다. `None`을 반환하면 복구를 거부합니다. 대체 값이 없으면 비어 있지 않은 검증 실패는 계속해서 `ModelBehaviorError`을 발생시키고, 비어 있는 구조화된 응답은 기존의 다음 턴 동작을 유지합니다. ```python from pydantic import BaseModel @@ -534,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 핸들러에서는 합성된 대체 출력을 대화 기록에 추가하고 구성된 세션에 저장합니다. 대체 값을 결과 기록이나 세션 스토리지에 추가하지 않고 호출자에게 반환하려면 `include_in_history=False`를 설정하세요. +`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 수 핸들러에서는 합성된 대체 출력을 대화 기록에 추가하고 구성된 세션에 저장합니다. 결과 기록이나 세션 스토리지에 추가하지 않고 대체 출력을 호출자에게 반환하려면 `include_in_history=False`을 설정하세요. -모델 거부 시 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 출력을 생성하려면 `"model_refusal"`을 사용하세요. +모델의 거부로 실행을 `ModelRefusalError`과 함께 종료하는 대신 애플리케이션별 대체 출력을 생성하려면 `"model_refusal"`을 사용하세요. ```python from pydantic import BaseModel @@ -568,35 +567,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 내구성 있는 실행 통합 및 휴먼인더루프 (HITL) +## 내구성 실행 통합 및 휴먼인더루프 (HITL) -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)에서 시작하세요. 아래 통합은 실행에 긴 대기, 재시도 또는 프로세스 재시작이 포함될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 실행이 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 오케스트레이션을 위한 것입니다. ### Dapr -Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 실패에서 자동으로 복구되고 휴먼인더루프 (HITL) 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 벤더 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. +Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 실패 시 자동으로 복구되고 휴먼인더루프 (HITL) 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급자 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. ### Temporal -Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함하여 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. +Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함해 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 볼 수 있습니다. ### Restate -Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)를 읽거나 [문서](https://docs.restate.dev/ai)를 참조하세요. +Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. ### DBOS -Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 실패와 재시작 중에도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지터리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. +Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 실패 및 재시작 후에도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. ## 예외 -SDK는 특정 경우에 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. +SDK는 특정 경우에 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에 있습니다. 개요는 다음과 같습니다. -- [`AgentsException`][agents.exceptions.AgentsException]: SDK가 발생시키는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 타입입니다. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과할 때 발생하는 예외입니다. 에이전트가 지정된 에이전트 루프 턴(LLM 호출) 수 안에 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`를 설정하세요. -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 유효하지 않은 출력을 생성할 때 발생하는 예외입니다. 다음과 같은 경우가 포함될 수 있습니다. - - 잘못된 형식의 JSON: 특히 특정 `output_type`이 정의된 경우 모델이 도구 호출이나 직접 출력에 잘못된 형식의 JSON 구조를 제공하는 경우 - - 예상하지 못한 도구 관련 실패: 모델이 예상한 방식으로 도구를 사용하지 못한 경우 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 타임아웃을 초과하고 해당 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생하는 예외입니다. -- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성하는 사람인 사용자가 SDK 사용 중 오류를 범했을 때 발생하는 예외입니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API 오용으로 인해 발생합니다. -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 입력 가드레일의 조건이 충족되면 `InputGuardrailTripwireTriggered`이 발생하고, 출력 가드레일의 조건이 충족되면 `OutputGuardrailTripwireTriggered`가 발생합니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. \ No newline at end of file +- [`AgentsException`][agents.exceptions.AgentsException]: SDK가 발생시키는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 유형입니다. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과하면 발생합니다. 지정된 에이전트 루프 턴 수(LLM 호출 횟수) 내에 에이전트가 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`을 설정하세요. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 유효하지 않은 출력을 생성할 때 발생합니다. 여기에는 다음이 포함될 수 있습니다. + - 잘못된 형식의 JSON: 모델이 도구 호출이나 직접 출력에서 잘못된 형식의 JSON 구조를 제공하는 경우. 특히 특정 `output_type`이 정의된 경우 + - 예상하지 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못한 경우 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생합니다. +- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성하는 사람인 사용자가 SDK 사용 중 오류를 범하면 발생합니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API 오용으로 인해 발생합니다. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 입력 가드레일 조건이 충족되면 `InputGuardrailTripwireTriggered`이 발생하고, 출력 가드레일 조건이 충족되면 `OutputGuardrailTripwireTriggered`이 발생합니다. 입력 가드레일은 처리 전에 들어오는 메시지를 확인하며, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 확인합니다. \ No newline at end of file diff --git a/docs/ko/sandbox/clients.md b/docs/ko/sandbox/clients.md index 51237a0639..ccd68d0839 100644 --- a/docs/ko/sandbox/clients.md +++ b/docs/ko/sandbox/clients.md @@ -4,21 +4,21 @@ search: --- # 샌드박스 클라이언트 -이 페이지에서 샌드박스 작업을 실행할 위치를 선택합니다. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. +이 페이지를 사용하여 샌드박스 작업을 실행할 위치를 선택합니다. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 사항, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지나면서 더 많은 고급 기능이 추가될 예정입니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 수 있습니다. -## 결정 가이드 +## 선택 가이드
-| 목표 | 시작할 항목 | 이유 | +| 목표 | 시작 항목 | 이유 | | --- | --- | --- | -| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치 없이 간단한 로컬 파일 시스템에서 개발할 수 있습니다. | +| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치 없이 간단하게 로컬 파일 시스템에서 개발할 수 있습니다. | | 기본적인 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지를 사용하는 Docker 내부에서 작업을 실행합니다. | -| 호스티드 실행 또는 프로덕션 수준의 격리 | 호스티드 샌드박스 클라이언트 | 작업 공간 경계를 공급자가 관리하는 환경으로 이동합니다. | +| 호스티드 실행 또는 프로덕션 환경 수준의 격리 | 호스티드 샌드박스 클라이언트 | 작업 공간 경계를 공급자가 관리하는 환경으로 이동합니다. |
@@ -28,16 +28,16 @@ search:
-| 클라이언트 | 설치 | 선택할 상황 | 예제 | +| 클라이언트 | 설치 | 선택이 적합한 경우 | 예제 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠르게 로컬 반복 개발을 진행하려는 경우. 로컬 개발에 적합한 기본 선택지입니다. | [Unix-local 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 컨테이너 격리가 필요하거나 대상 환경을 로컬에서 재현하기 위해 특정 이미지를 사용하려는 경우. | [Docker 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠르게 로컬 반복 개발을 수행하려는 경우입니다. 로컬 개발에 적합한 기본 선택입니다. | [Unix-local 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 컨테이너 격리가 필요하거나 대상 환경을 로컬에서 재현하기 위해 특정 이미지를 사용하려는 경우입니다. | [Docker 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리 또는 프로덕션 수준의 환경 일치가 필요하면 Docker나 호스티드 공급자로 전환합니다. +Unix-local은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리나 프로덕션 환경과의 동등성이 필요하면 Docker 또는 호스티드 공급자로 전환합니다. -`SandboxPathGrant.host_path`은 Docker 전용이며 호스트 경로를 컨테이너 내부의 다른 POSIX 경로에 매핑합니다. Unix-local은 동일 경로 권한 부여만 지원합니다. 자세한 내용은 [매니페스트 경로 권한 부여](guide.md#manifest)를 참조하세요. +`SandboxPathGrant.host_path`은 Docker 전용이며 호스트 경로를 컨테이너 내부의 다른 POSIX 경로에 매핑합니다. Unix-local은 동일 경로 허용만 지원합니다. 자세한 내용은 [매니페스트 경로 허용](guide.md#manifest)을 참조하세요. Unix-local에서 Docker로 전환하려면 에이전트 정의는 그대로 유지하고 실행 구성만 변경합니다. @@ -56,39 +56,39 @@ run_config = RunConfig( ) ``` -컨테이너 격리가 필요하거나 샌드박스 이미지가 다른 환경에서 사용하는 이미지와 일치해야 할 때 이 방법을 사용합니다. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. +컨테이너 격리가 필요하거나 샌드박스 이미지를 다른 환경에서 사용하는 이미지와 일치시키려면 이 방식을 사용합니다. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. ## 마운트 및 원격 스토리지 -마운트 항목은 노출할 스토리지를 나타내고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 나타냅니다. 기본 제공 마운트 항목과 범용 전략은 `agents.sandbox.entries`에서 가져옵니다. 호스티드 공급자용 전략은 `agents.extensions.sandbox` 또는 공급자별 확장 패키지에서 사용할 수 있습니다. +마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 설명합니다. 기본 제공 마운트 항목과 범용 전략은 `agents.sandbox.entries`에서 가져옵니다. 호스티드 공급자 전략은 `agents.extensions.sandbox` 또는 공급자별 확장 패키지에서 사용할 수 있습니다. 일반적인 마운트 옵션은 다음과 같습니다. -- `mount_path`: 샌드박스에서 스토리지가 나타나는 위치입니다. 상대 경로는 매니페스트 루트를 기준으로 해석되며, 절대 경로는 그대로 사용됩니다. -- `read_only`: 기본값은 `True`입니다. 샌드박스가 마운트된 스토리지에 변경 사항을 다시 기록해야 하는 경우에만 `False`을 설정합니다. -- `mount_strategy`: 필수입니다. 마운트 항목과 샌드박스 백엔드 모두에 맞는 전략을 사용합니다. +- `mount_path`: 샌드박스에서 스토리지가 표시되는 위치입니다. 상대 경로는 매니페스트 루트를 기준으로 해석되며, 절대 경로는 그대로 사용됩니다. +- `read_only`: 기본값은 `True`입니다. 샌드박스에서 마운트된 스토리지에 변경 사항을 다시 기록해야 하는 경우에만 `False`으로 설정합니다. +- `mount_strategy`: 필수 항목입니다. 마운트 항목과 샌드박스 백엔드 모두에 맞는 전략을 사용합니다. -마운트는 임시 작업 공간 항목으로 처리됩니다. 스냅샷 및 영속성 처리 과정에서는 마운트된 원격 스토리지를 저장된 작업 공간에 복사하지 않고 마운트된 경로를 분리하거나 건너뜁니다. +마운트는 임시 작업 공간 항목으로 취급됩니다. 스냅샷 및 영속성 흐름에서는 마운트된 원격 스토리지를 저장된 작업 공간에 복사하는 대신 마운트된 경로를 분리하거나 건너뜁니다. 범용 로컬/컨테이너 전략은 다음과 같습니다.
-| 전략 또는 패턴 | 사용할 상황 | 참고 | +| 전략 또는 패턴 | 사용이 적합한 경우 | 참고 사항 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone`을 실행할 수 있는 경우. | S3, GCS, R2, Azure Blob 및 Box를 지원합니다. `RcloneMountPattern`은 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3`이 있고 Mountpoint 방식의 S3 또는 S3 호환 액세스가 필요한 경우. | `S3Mount` 및 `GCSMount`을 지원합니다. | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2` 및 FUSE 지원이 있는 경우. | `AzureBlobMount`을 지원합니다. | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 이미지에 `mount.s3files`이 있고 기존 S3 Files 마운트 대상에 연결할 수 있는 경우. | `S3FilesMount`를 지원합니다. | -| `DockerVolumeMountStrategy(driver=...)` | 컨테이너가 시작되기 전에 Docker가 볼륨 드라이버 기반 마운트를 연결해야 하는 경우. | Docker 전용입니다. S3, GCS, R2, Azure Blob 및 Box는 `rclone`을 통해 마운트할 수 있으며, S3와 GCS는 `mountpoint`를 통해서도 마운트할 수 있습니다. | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone`을 실행할 수 있는 경우입니다. | S3, GCS, R2, Azure Blob, Box를 지원합니다. `RcloneMountPattern`은 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3`이 있으며 Mountpoint 방식의 S3 또는 S3 호환 액세스를 사용하려는 경우입니다. | `S3Mount`와 `GCSMount`을 지원합니다. | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2`와 FUSE 지원이 있는 경우입니다. | `AzureBlobMount`을 지원합니다. | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 이미지에 `mount.s3files`이 있으며 기존 S3 Files 마운트 대상에 연결할 수 있는 경우입니다. | `S3FilesMount`를 지원합니다. | +| `DockerVolumeMountStrategy(driver=...)` | 컨테이너가 시작되기 전에 Docker가 볼륨 드라이버 기반 마운트를 연결해야 하는 경우입니다. | Docker 전용입니다. S3, GCS, R2, Azure Blob, Box는 `rclone`을 통해 마운트할 수 있으며, S3와 GCS는 `mountpoint`를 통해서도 마운트할 수 있습니다. |
## 지원되는 호스티드 플랫폼 -호스티드 환경이 필요한 경우 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트만 변경합니다. +호스티드 환경이 필요한 경우에는 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트만 변경합니다. -이 저장소의 체크아웃 대신 배포된 SDK를 사용하는 경우 일치하는 패키지 extra를 통해 샌드박스 클라이언트 종속성을 설치합니다. +이 저장소의 체크아웃 대신 배포된 SDK를 사용하는 경우 해당 패키지 extra를 통해 샌드박스 클라이언트 종속성을 설치합니다. 저장소에 포함된 확장 코드 예제의 공급자별 설정 참고 사항과 링크는 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)를 참조하세요. @@ -106,23 +106,41 @@ run_config = RunConfig( -호스티드 샌드박스 클라이언트는 공급자별 마운트 전략을 제공합니다. 스토리지 공급자에 가장 적합한 백엔드와 마운트 전략을 선택합니다. +호스티드 샌드박스 클라이언트는 공급자별 마운트 전략을 제공합니다. 스토리지 공급자에 가장 적합한 백엔드와 마운트 전략을 선택하세요.
| 백엔드 | 마운트 참고 사항 | | --- | --- | -| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy` 같은 로컬 전략을 사용하여 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount`를 지원합니다. | -| `ModalSandboxClient` | `S3Mount`, `R2Mount` 및 HMAC 인증 방식의 `GCSMount`과 함께 `ModalCloudBucketMountStrategy`을 사용하여 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | -| `CloudflareSandboxClient` | `S3Mount`, `R2Mount` 및 HMAC 인증 방식의 `GCSMount`과 함께 `CloudflareBucketMountStrategy`을 사용하여 버킷 마운트를 지원합니다. | -| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy`을 `S3Mount`, `R2Mount` 또는 `GCSMount` 항목과 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 또한 `BlaxelDriveMount` 및 `BlaxelDriveMountStrategy`을 사용하여 영구 Blaxel Drives를 지원하며, 둘 다 `agents.extensions.sandbox.blaxel`에서 사용할 수 있습니다. | -| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy`을 사용해 `rclone`을 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용합니다. | -| `E2BSandboxClient` | `E2BCloudBucketMountStrategy`를 사용해 `rclone`를 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | -| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy`을 사용해 `rclone`를 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | +| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy`과 같은 로컬 전략을 사용하여 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount`를 지원합니다. | +| `ModalSandboxClient` | `ModalCloudBucketMountStrategy`을 `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount`과 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | +| `CloudflareSandboxClient` | `CloudflareBucketMountStrategy`을 `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount`과 함께 사용하여 버킷 마운트를 지원합니다. | +| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy`을 `S3Mount`, `R2Mount`, `GCSMount` 항목 중 하나와 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 또한 `BlaxelDriveMount`와 `BlaxelDriveMountStrategy`을 통해 영속적인 Blaxel Drives를 지원하며, 둘 다 `agents.extensions.sandbox.blaxel`에서 사용할 수 있습니다. | +| `DaytonaSandboxClient` | `rclone`을 통해 `DaytonaCloudBucketMountStrategy`을 사용하여 클라우드 스토리지 마운트를 지원합니다. 이를 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용합니다. | +| `E2BSandboxClient` | `rclone`를 통해 `E2BCloudBucketMountStrategy`를 사용하여 클라우드 스토리지 마운트를 지원합니다. 이를 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | +| `RunloopSandboxClient` | `rclone`를 통해 `RunloopCloudBucketMountStrategy`을 사용하여 클라우드 스토리지 마운트를 지원합니다. 이를 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | | `VercelSandboxClient` | `VercelCloudBucketMountStrategy`을 `S3Mount` 항목과 함께 사용하여 생성 시점에만 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True`가 필요합니다. |
+마운트 표에는 각 백엔드에서 실행할 수 있는 스토리지 유형이 설명되어 있습니다. 체크 표시는 모델이 제어하는 샌드박스 내부에서 실행되는 마운트 헬퍼의 자격 증명 경계를 우회하지 않으며, 모든 전략이 자격 증명 없이 작동할 수 있다는 의미도 아닙니다. 선택한 헬퍼가 보호된 권한 없이 작동할 수 있는 경우에만 Agents SDK는 승인 없이 컨테이너 내부 마운트를 허용합니다. 보호된 권한이 필요한 마운트는 신뢰할 수 있는 애플리케이션 코드가 해당 마운트 경로의 노출을 명시적으로 승인하지 않는 한 샌드박스 또는 마운트 헬퍼를 시작하기 전에 거부됩니다. + +자격 증명이 없는 `rclone` 마운트는 S3, GCS, R2, Azure Blob으로 제한됩니다. 컨테이너 내부 Box 마운트에는 비대화형 인증 소스와 해당 소스에 맞는 승인이 필요합니다. 인라인 자격 증명을 구성하지 않은 경우에도 `blobfuse2`가 주변 환경의 Azure 권한을 검색하므로 `FuseMountPattern`에는 광범위한 승인이 필요합니다. 마찬가지로 `mount.s3files`이 주변 환경의 IAM 권한을 사용하므로 `S3FilesMountPattern`에도 광범위한 승인이 필요합니다. 이러한 요구 사항은 Docker가 백엔드인 경우에도 적용됩니다. 아래 체크 표시는 해당 권한 경계가 충족된 후 Docker가 마운트를 실행할 수 있음을 나타냅니다. + +이름이 `"data"`인 마운트 항목의 경우 구성된 권한과 일치하는 승인에서 반환된 복사본 `Manifest`를 유지합니다. + +```python +# Mount-scoped values such as inline access keys. +manifest = manifest.with_in_container_mount_credential_exposure_acknowledged("data") + +# Broader authority such as managed or workload identity and external credential files. +manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") +``` + +승인이 필요한 모든 정확한 마운트 경로를 전달합니다. 두 권한 클래스를 모두 사용하는 마운트에는 두 가지 승인이 모두 필요합니다. 승인은 런타임 전용이고 직렬화되지 않으며, 자격 증명의 사용을 마운트된 경로로 제한하지 않은 채 헬퍼가 자격 증명을 받을 수 있도록 허용합니다. 가능한 경우 외부 전략 또는 공급자 네이티브 전략을 사용하고, 그렇지 않으면 샌드박스 범위로 제한된 수명이 짧은 최소 권한 자격 증명을 사용하세요. + +`VercelSandboxClientOptions(allow_s3_credential_exposure=True)`은 인라인 마운트 범위 자격 증명을 사용하는 생성 시점의 Vercel S3 마운트를 위한 호환성 옵션으로 계속 제공됩니다. 이 옵션은 광범위한 자격 증명 권한을 허용하지 않습니다. + 아래 표에는 각 백엔드가 직접 마운트할 수 있는 원격 스토리지 항목이 요약되어 있습니다.
@@ -140,4 +158,4 @@ run_config = RunConfig(
-실행 가능한 코드 예제를 더 살펴보려면 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴은 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)에서, 호스티드 샌드박스 클라이언트는 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)에서 확인하세요. \ No newline at end of file +실행 가능한 코드 예제를 더 보려면 로컬, 코딩, 메모리, 핸드오프, 에이전트 구성 패턴은 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)에서, 호스티드 샌드박스 클라이언트는 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)에서 살펴보세요. \ No newline at end of file diff --git a/docs/ko/sandbox/guide.md b/docs/ko/sandbox/guide.md index 36fc497b0f..7dc141e1a8 100644 --- a/docs/ko/sandbox/guide.md +++ b/docs/ko/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API의 세부 사항, 기본값 및 지원 기능이 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지나면서 더 고급 기능이 추가될 수 있습니다. -최신 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특수 도구와 셸 명령을 사용하여 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 결과물을 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 모델에 지속성 있는 워크스페이스를 제공하며, 에이전트는 이를 사용해 사용자를 대신하여 작업할 수 있습니다. Agents SDK의 샌드박스 에이전트를 사용하면 샌드박스 환경과 결합된 에이전트를 쉽게 실행할 수 있으며, 적절한 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모로 작업을 쉽게 시작, 중지 및 재개할 수 있습니다. +최신 에이전트는 파일 시스템의 실제 파일을 직접 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특화된 도구와 셸 명령을 사용해 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 결과물을 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 에이전트가 사용자를 대신해 작업할 수 있는 영구 워크스페이스를 모델에 제공합니다. Agents SDK의 샌드박스 에이전트를 사용하면 샌드박스 환경과 결합된 에이전트를 쉽게 실행할 수 있으며, 적절한 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모 작업을 쉽게 시작, 중지, 재개할 수 있습니다. 에이전트에 필요한 데이터를 중심으로 워크스페이스를 정의합니다. GitHub 저장소, 로컬 파일 및 디렉터리, 합성 작업 파일, S3나 Azure Blob Storage 같은 원격 파일 시스템 및 사용자가 제공하는 기타 샌드박스 입력으로 시작할 수 있습니다.
-![컴퓨팅 환경이 포함된 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png) +![컴퓨팅이 포함된 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png)
-`SandboxAgent`은 여전히 `Agent`입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일 및 훅과 같은 일반적인 에이전트 인터페이스를 유지하며, 일반적인 `Runner` API를 통해 계속 실행됩니다. 달라지는 부분은 실행 경계입니다. +`SandboxAgent`도 여전히 `Agent`입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일, 훅과 같은 일반적인 에이전트 인터페이스를 그대로 유지하며, 일반 `Runner` API를 통해 계속 실행됩니다. 달라지는 부분은 실행 경계입니다. -- `SandboxAgent`은 에이전트 자체를 정의합니다. 여기에는 일반적인 에이전트 구성뿐 아니라 `default_manifest`, `base_instructions`, `run_as`과 같은 샌드박스 전용 기본값, 파일 시스템 도구, 셸 액세스, 스킬, 메모리 또는 압축 같은 기능이 포함됩니다. -- `Manifest`는 파일, 저장소, 마운트 및 환경을 포함해 새 샌드박스 워크스페이스에 필요한 초기 콘텐츠와 레이아웃을 선언합니다. -- 샌드박스 세션은 명령이 실행되고 파일이 변경되는 활성 격리 환경입니다. -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 실행에서 샌드박스 세션을 가져오는 방법을 결정합니다. 예를 들어 세션을 직접 주입하거나, 직렬화된 샌드박스 세션 상태에서 다시 연결하거나, 샌드박스 클라이언트를 통해 새 샌드박스 세션을 생성할 수 있습니다. -- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행에서 이전 작업에 다시 연결하거나 저장된 콘텐츠로 새 샌드박스 세션을 초기화할 수 있습니다. +- `SandboxAgent`은 에이전트 자체를 정의합니다. 여기에는 일반적인 에이전트 구성과 더불어 `default_manifest`, `base_instructions`, `run_as` 같은 샌드박스 전용 기본값 및 파일 시스템 도구, 셸 접근, 스킬, 메모리, 압축 같은 기능이 포함됩니다. +- `Manifest`는 파일, 저장소, 마운트, 환경을 포함해 새 샌드박스 워크스페이스의 원하는 초기 내용과 레이아웃을 선언합니다. +- 샌드박스 세션은 명령이 실행되고 파일이 변경되는 실제 격리 환경입니다. +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 라이브 샌드박스 세션을 직접 주입하거나, 직렬화된 샌드박스 세션 상태에서 다시 연결하거나, 샌드박스 클라이언트를 통해 새 샌드박스 세션을 만드는 등의 방식으로 실행이 샌드박스 세션을 얻는 방법을 결정합니다. +- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행이 이전 작업에 다시 연결되거나 저장된 내용으로 새 샌드박스 세션을 초기화할 수 있습니다. -`Manifest`은 새 세션의 워크스페이스 계약이며, 모든 활성 샌드박스에 대한 완전한 정보 소스는 아닙니다. 실행의 실질적인 워크스페이스는 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택된 스냅샷에서 가져올 수도 있습니다. +`Manifest`은 새 세션의 워크스페이스 계약이며, 모든 라이브 샌드박스에 대한 완전한 단일 진실 공급원은 아닙니다. 실행의 유효 워크스페이스는 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택한 스냅샷에서 가져올 수도 있습니다. -이 페이지에서 "샌드박스 세션"은 샌드박스 클라이언트가 관리하는 활성 실행 환경을 의미합니다. 이는 [세션](../sessions/index.md)에서 설명하는 SDK의 대화형 [`Session`][agents.memory.session.Session] 인터페이스와 다릅니다. +이 페이지에서 "샌드박스 세션"은 샌드박스 클라이언트가 관리하는 라이브 실행 환경을 의미합니다. 이는 [세션](../sessions/index.md)에서 설명하는 SDK의 대화형 [`Session`][agents.memory.session.Session] 인터페이스와 다릅니다. -외부 런타임은 여전히 승인, 트레이싱, 핸드오프 및 실행 재개에 필요한 상태 추적을 담당합니다. 샌드박스 세션은 명령, 파일 변경 및 환경 격리를 담당합니다. 이러한 역할 분리는 모델의 핵심 요소입니다. +외부 런타임은 계속해서 승인, 트레이싱, 핸드오프 및 실행 재개에 필요한 상태 추적을 담당합니다. 샌드박스 세션은 명령, 파일 변경 및 환경 격리를 담당합니다. 이러한 분리는 모델의 핵심 요소입니다. -### 구성 요소의 결합 방식 +### 구성 요소 간의 관계 -샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 활성 샌드박스 세션에 연결하며, 이후 실행을 위해 상태를 저장할 수 있습니다. +샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 라이브 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. ```mermaid flowchart LR @@ -56,27 +56,27 @@ flowchart LR 1. `SandboxAgent`, `Manifest` 및 기능을 사용해 에이전트와 새 워크스페이스 계약을 정의합니다. 2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig`을 `Runner`에 제공하여 실행합니다. -3. 러너가 관리하는 `RunState`, 명시적인 샌드박스 `session_state` 또는 저장된 워크스페이스 스냅샷에서 나중에 작업을 계속합니다. +3. 러너가 관리하는 `RunState`, 명시적 샌드박스 `session_state` 또는 저장된 워크스페이스 스냅샷에서 나중에 작업을 계속합니다. -셸 액세스를 가끔 사용하는 하나의 도구로만 활용한다면 [도구 가이드](../tools.md)의 호스티드 셸부터 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. +셸 접근이 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](../tools.md)의 호스티드 셸부터 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. ## 사용 시점 샌드박스 에이전트는 다음과 같은 워크스페이스 중심 워크플로에 적합합니다. -- 코딩 및 디버깅. 예를 들어 GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 대상 테스트 실행 -- 문서 처리 및 편집. 예를 들어 사용자의 금융 문서에서 정보를 추출하고 작성이 완료된 세금 양식 초안 생성 -- 파일 기반 검토 또는 분석. 예를 들어 답변 전에 온보딩 자료, 생성된 보고서 또는 결과물 번들 확인 -- 격리된 다중 에이전트 패턴. 예를 들어 각 검토자 또는 코딩 하위 에이전트에 자체 워크스페이스 제공 -- 다단계 워크스페이스 작업. 예를 들어 한 번의 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개 +- 코딩 및 디버깅. 예를 들어 GitHub 저장소의 이슈 보고서에 대한 자동 수정을 오케스트레이션하고 대상 테스트 실행 +- 문서 처리 및 편집. 예를 들어 사용자의 재무 문서에서 정보를 추출하고 작성된 세금 양식 초안 생성 +- 파일 기반 검토 또는 분석. 예를 들어 답변하기 전에 온보딩 패킷, 생성된 보고서 또는 결과물 번들 확인 +- 격리된 다중 에이전트 패턴. 예를 들어 각 검토자 또는 코딩 하위 에이전트에 별도 워크스페이스 제공 +- 다단계 워크스페이스 작업. 예를 들어 한 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개 -파일이나 상태를 유지하며 변경 가능한 파일 시스템에 액세스할 필요가 없다면 계속 `Agent`을 사용하세요. 셸 액세스가 가끔 필요한 기능 중 하나일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. +파일이나 상태를 유지하며 변경 가능한 파일 시스템에 접근할 필요가 없다면 `Agent`을 계속 사용하세요. 셸 접근이 가끔 필요한 기능일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. ## 샌드박스 클라이언트 선택 -macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 공급자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리나 이미지 동등성이 필요하면 `DockerSandboxClient`로 전환하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자로 전환하세요. +macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient`부터 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 제공자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리나 이미지 일관성이 필요하면 `DockerSandboxClient`로 전환하고, 제공자가 관리하는 실행이 필요하면 호스티드 제공자로 전환하세요. -대부분의 경우 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 해당 옵션만 변경하고 `SandboxAgent` 정의는 동일하게 유지할 수 있습니다. 로컬, Docker, 호스티드 및 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. +대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 해당 옵션만 변경합니다. 로컬, Docker, 호스티드 및 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. ## 핵심 구성 요소 @@ -84,52 +84,52 @@ macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 | 계층 | 주요 SDK 구성 요소 | 답하는 질문 | | --- | --- | --- | -| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트가 실행되며, 어떤 새 세션 워크스페이스 계약으로 시작해야 합니까? | -| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트 및 활성 샌드박스 세션 | 이 실행은 어떻게 활성 샌드박스 세션을 가져오며, 작업은 어디에서 실행됩니까? | -| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state` 및 스냅샷 | 이 워크플로는 어떻게 이전 샌드박스 작업에 다시 연결하거나 저장된 콘텐츠로 새 샌드박스 세션을 초기화합니까? | +| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트가 실행되며, 어떤 새 세션 워크스페이스 계약에서 시작해야 하는가? | +| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트 및 라이브 샌드박스 세션 | 이 실행은 어떻게 라이브 샌드박스 세션을 얻으며, 작업은 어디에서 실행되는가? | +| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state` 및 스냅샷 | 이 워크플로는 어떻게 이전 샌드박스 작업에 다시 연결되거나 저장된 내용으로 새 샌드박스 세션을 초기화하는가? | -주요 SDK 구성 요소는 다음과 같이 해당 계층에 대응합니다. +주요 SDK 구성 요소는 다음과 같이 이러한 계층에 대응합니다.
-| 구성 요소 | 담당 영역 | 확인할 질문 | +| 구성 요소 | 담당 범위 | 확인할 질문 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트는 무엇을 해야 하며, 어떤 기본값을 함께 유지해야 합니까? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션 워크스페이스의 파일 및 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 합니까? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 어떤 도구, 지침 조각 또는 런타임 동작을 이 에이전트에 연결해야 합니까? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트 및 샌드박스 세션 소스 | 이 실행은 샌드박스 세션을 주입, 재개 또는 생성해야 합니까? | -| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 이전에 러너가 관리하던 워크플로를 재개하고 해당 샌드박스 상태를 자동으로 전달하고 있습니까? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태를 재개하려고 합니까? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위해 저장된 워크스페이스 콘텐츠 | 새 샌드박스 세션이 저장된 파일과 결과물에서 시작해야 합니까? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트가 무엇을 해야 하며, 어떤 기본값이 에이전트와 함께 전달되어야 하는가? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션의 워크스페이스 파일 및 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 하는가? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 이 에이전트에 어떤 도구, instructions 조각 또는 런타임 동작을 연결해야 하는가? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트 및 샌드박스 세션 소스 | 이 실행에서 샌드박스 세션을 주입, 재개 또는 생성해야 하는가? | +| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 이전에 러너가 관리하던 워크플로를 재개하고 해당 샌드박스 상태를 자동으로 이어가는가? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태로부터 재개하려는가? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위한 저장된 워크스페이스 내용 | 새 샌드박스 세션을 저장된 파일과 결과물에서 시작해야 하는가? |
실용적인 설계 순서는 다음과 같습니다. -1. `Manifest`로 새 세션 워크스페이스 계약을 정의합니다. +1. `Manifest`를 사용해 새 세션의 워크스페이스 계약을 정의합니다. 2. `SandboxAgent`으로 에이전트를 정의합니다. 3. 기본 제공 또는 사용자 지정 기능을 추가합니다. -4. `RunConfig(sandbox=SandboxRunConfig(...))`에서 각 실행이 샌드박스 세션을 가져올 방법을 결정합니다. +4. `RunConfig(sandbox=SandboxRunConfig(...))`에서 각 실행이 샌드박스 세션을 얻는 방법을 결정합니다. -## 샌드박스 실행 준비 방식 +## 샌드박스 실행 준비 실행 시 러너는 해당 정의를 구체적인 샌드박스 기반 실행으로 변환합니다. -1. `SandboxRunConfig`에서 샌드박스 세션을 확인합니다. `session=...`를 전달하면 해당 활성 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...`을 사용해 세션을 생성하거나 재개합니다. -2. 실행에 실질적으로 적용할 워크스페이스 입력을 결정합니다. 실행에서 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest`에서 시작합니다. 이 때문에 모든 실행의 최종 활성 워크스페이스가 `Manifest`만으로 정의되지는 않습니다. -3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트를 준비하기 전에 기능에서 파일, 마운트 또는 기타 워크스페이스 범위 동작을 추가할 수 있습니다. -4. 다음과 같은 고정된 순서로 최종 지침을 구성합니다. SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`, 그다음 `instructions`, 기능 지침 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리 순입니다. -5. 기능 도구를 활성 샌드박스 세션에 연결하고 일반적인 `Runner` API를 통해 준비된 에이전트를 실행합니다. +1. `SandboxRunConfig`에서 샌드박스 세션을 결정합니다. `session=...`를 전달하면 해당 라이브 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...`을 사용해 샌드박스 세션을 생성하거나 재개합니다. +2. 실행에 사용할 유효 워크스페이스 입력을 결정합니다. 실행에서 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest`에서 시작합니다. 이 때문에 `Manifest`만으로는 모든 실행의 최종 라이브 워크스페이스를 정의할 수 없습니다. +3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트가 준비되기 전에 기능이 파일, 마운트 또는 기타 워크스페이스 범위 동작을 추가할 수 있습니다. +4. 고정된 순서로 최종 instructions를 구성합니다. 먼저 SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`, 그다음 `instructions`, 기능의 instructions 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리 순입니다. +5. 기능 도구를 라이브 샌드박스 세션에 바인딩하고 일반 `Runner` API를 통해 준비된 에이전트를 실행합니다. -샌드박스를 사용해도 턴의 의미는 달라지지 않습니다. 턴은 여전히 단일 셸 명령이나 샌드박스 작업이 아니라 모델의 한 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내에서 처리될 수 있지만, 도구 결과, 승인 또는 다른 종류의 상태처럼 추가 모델 단계가 필요한 정보를 반환하는 작업도 있습니다. 실용적인 원칙으로는 샌드박스 작업이 발생한 후 에이전트 런타임에 또 다른 모델 응답이 필요한 경우에만 추가 턴이 소비됩니다. +샌드박스 사용 여부는 턴의 의미를 바꾸지 않습니다. 턴은 여전히 단일 셸 명령이나 샌드박스 작업이 아니라 모델 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내부에서 계속될 수 있지만, 도구 결과, 승인 또는 다른 종류의 상태처럼 또 다른 모델 단계가 필요한 정보를 반환하는 작업도 있습니다. 실용적인 기준으로는 샌드박스 작업이 발생한 후 에이전트 런타임에 또 다른 모델 응답이 필요할 때만 추가 턴이 소비됩니다. 이러한 준비 단계 때문에 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as`은 `SandboxAgent`을 설계할 때 고려해야 할 주요 샌드박스 전용 옵션입니다. ## `SandboxAgent` 옵션 -일반적인 `Agent` 필드에 더해 사용할 수 있는 샌드박스 전용 옵션은 다음과 같습니다. +일반적인 `Agent` 필드에 추가되는 샌드박스 전용 옵션은 다음과 같습니다.
@@ -138,8 +138,8 @@ macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 | `default_manifest` | 러너가 생성하는 새 샌드박스 세션의 기본 워크스페이스 | | `instructions` | SDK 샌드박스 프롬프트 뒤에 추가되는 역할, 워크플로 및 성공 기준 | | `base_instructions` | SDK 샌드박스 프롬프트를 대체하는 고급 탈출구 | -| `capabilities` | 이 에이전트와 함께 유지해야 하는 샌드박스 네이티브 도구 및 동작 | -| `run_as` | 셸 명령, 파일 읽기 및 패치와 같이 모델에 노출되는 샌드박스 도구의 사용자 ID | +| `capabilities` | 이 에이전트와 함께 전달되어야 하는 샌드박스 네이티브 도구 및 동작 | +| `run_as` | 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구의 사용자 ID |
@@ -147,15 +147,15 @@ macOS 또는 Linux에서 로컬로 개발할 때는 `UnixLocalSandboxClient`로 ### `default_manifest` -`default_manifest`은 러너가 이 에이전트에 대한 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest]입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리 및 마운트에 사용하세요. +`default_manifest`은 러너가 이 에이전트용 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest]입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리 및 마운트를 지정하는 데 사용합니다. -이는 기본값일 뿐입니다. 실행 시 `SandboxRunConfig(manifest=...)`으로 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 워크스페이스 상태를 유지합니다. +이는 기본값일 뿐입니다. 실행에서 `SandboxRunConfig(manifest=...)`으로 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 워크스페이스 상태를 유지합니다. ### `instructions` 및 `base_instructions` -여러 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`을 사용하세요. `SandboxAgent`에서 이러한 지침은 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로 및 성공 기준을 추가할 수 있습니다. +다양한 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`을 사용하세요. `SandboxAgent`에서 이러한 instructions는 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로 및 성공 기준을 추가할 수 있습니다. -SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions`을 사용하세요. 대부분의 에이전트에서는 설정하지 않는 것이 좋습니다. +SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions`을 사용하세요. 대부분의 에이전트는 이를 설정하지 않아야 합니다.
@@ -163,25 +163,25 @@ SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instr | --- | --- | --- | | `instructions` | 에이전트의 안정적인 역할, 워크플로 규칙 및 성공 기준 | "온보딩 문서를 검사한 다음 핸드오프하세요.", "최종 파일을 `output/`에 작성하세요." | | `base_instructions` | SDK 샌드박스 기본 프롬프트의 완전한 대체 | 사용자 지정 저수준 샌드박스 래퍼 프롬프트 | -| 사용자 프롬프트 | 이번 실행의 일회성 요청 | "이 워크스페이스를 요약하세요." | -| 매니페스트의 워크스페이스 파일 | 긴 작업 명세, 저장소 로컬 지침 또는 범위가 제한된 참고 자료 | `repo/task.md`, 문서 번들, 샘플 자료 | +| 사용자 프롬프트 | 이 실행을 위한 일회성 요청 | "이 워크스페이스를 요약하세요." | +| 매니페스트의 워크스페이스 파일 | 더 긴 작업 명세, 저장소 로컬 instructions 또는 범위가 제한된 참고 자료 | `repo/task.md`, 문서 번들, 샘플 패킷 |
-`instructions`의 적절한 사용 예는 다음과 같습니다. +`instructions`의 적절한 사용 예시는 다음과 같습니다. - [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py)는 PTY 상태가 중요할 때 에이전트를 하나의 대화형 프로세스에 유지합니다. - [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)는 샌드박스 검토자가 검사 후 사용자에게 직접 답변하지 못하도록 합니다. - [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)는 최종 작성 파일이 실제로 `output/`에 저장되도록 요구합니다. - [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)는 정확한 검증 명령을 고정하고 워크스페이스 루트 기준 패치 경로를 명확히 합니다. -사용자의 일회성 작업을 `instructions`에 복사하거나, 매니페스트에 포함해야 할 긴 참고 자료를 삽입하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 반복하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. +사용자의 일회성 작업을 `instructions`에 복사하거나, 매니페스트에 속하는 긴 참고 자료를 포함하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 다시 작성하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. -`instructions`을 생략해도 SDK에는 기본 샌드박스 프롬프트가 포함됩니다. 저수준 래퍼에는 이것만으로 충분하지만, 대부분의 사용자 대상 에이전트에서는 여전히 명시적인 `instructions`을 제공해야 합니다. +`instructions`을 생략해도 SDK에는 기본 샌드박스 프롬프트가 포함됩니다. 저수준 래퍼에는 이것만으로 충분하지만, 대부분의 사용자 대상 에이전트는 여전히 명시적인 `instructions`을 제공해야 합니다. ### `capabilities` -기능은 샌드박스 네이티브 동작을 `SandboxAgent`에 연결합니다. 실행 시작 전에 워크스페이스를 구성하고, 샌드박스 전용 지침을 추가하고, 활성 샌드박스 세션에 연결되는 도구를 노출하고, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. +기능은 `SandboxAgent`에 샌드박스 네이티브 동작을 연결합니다. 실행이 시작되기 전에 워크스페이스를 구성하고, 샌드박스 전용 instructions를 추가하고, 라이브 샌드박스 세션에 바인딩되는 도구를 노출하며, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. 기본 제공 기능은 다음과 같습니다. @@ -189,59 +189,59 @@ SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instr | 기능 | 추가 시점 | 참고 사항 | | --- | --- | --- | -| `Shell` | 에이전트에 셸 액세스가 필요할 때 | `exec_command`을 추가하며, 샌드박스 클라이언트가 PTY 상호작용을 지원하면 `write_stdin`도 추가합니다. | -| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 할 때 | `apply_patch` 및 `view_image`를 추가합니다. 패치 경로는 워크스페이스 루트를 기준으로 합니다. | -| `Skills` | 샌드박스에서 스킬 검색 및 구체화를 사용하려 할 때 | `.agents` 또는 `.agents/skills`을 수동으로 마운트하는 대신 이를 사용하는 것이 좋습니다. `Skills`은 스킬의 인덱스를 생성하고 샌드박스에 구체화합니다. | +| `Shell` | 에이전트에 셸 접근이 필요할 때 | `exec_command`을 추가하며, 샌드박스 클라이언트가 PTY 상호작용을 지원하면 `write_stdin`도 추가합니다. | +| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 할 때 | `apply_patch`와 `view_image`를 추가합니다. 패치 경로는 워크스페이스 루트 기준입니다. | +| `Skills` | 샌드박스에서 스킬 검색 및 구체화를 사용하려 할 때 | `.agents` 또는 `.agents/skills`을 수동으로 마운트하는 것보다 이 기능을 권장합니다. `Skills`이 스킬을 인덱싱하고 샌드박스에 구체화합니다. | | `Memory` | 후속 실행에서 메모리 결과물을 읽거나 생성해야 할 때 | `Shell`이 필요합니다. 실행 중 메모리 결과물을 업데이트하려면 `Filesystem`도 필요합니다. | -| `Compaction` | 장기 실행 흐름에서 압축 항목 이후 컨텍스트를 축소해야 할 때 | 모델 샘플링 및 입력 처리를 조정합니다. | +| `Compaction` | 장기 실행 흐름에서 압축 항목 이후 컨텍스트를 정리해야 할 때 | 모델 샘플링 및 입력 처리를 조정합니다. | -기본적으로 `SandboxAgent.capabilities`는 `Capabilities.default()`를 사용하며, 여기에는 `Filesystem()`, `Shell()`, `Compaction()`이 포함됩니다. `capabilities=[...]`을 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용할 기본 기능도 포함해야 합니다. +기본적으로 `SandboxAgent.capabilities`는 `Capabilities.default()`를 사용하며, 여기에는 `Filesystem()`, `Shell()`, `Compaction()`이 포함됩니다. `capabilities=[...]`을 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용하려는 기본 기능을 모두 포함하세요. -스킬의 경우 구체화하려는 방식에 따라 소스를 선택하세요. +스킬의 경우 원하는 구체화 방식에 따라 소스를 선택하세요. -- `Skills(lazy_from=LocalDirLazySkillSource(...))`은 모델이 먼저 인덱스를 검색하고 필요한 항목만 로드할 수 있으므로 규모가 큰 로컬 스킬 디렉터리에 적합한 기본값입니다. -- `LocalDirLazySkillSource(source=LocalDir(src=...))`은 SDK 프로세스가 실행 중인 파일 시스템에서 읽습니다. 샌드박스 이미지나 워크스페이스 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. -- `Skills(from_=LocalDir(src=...))`는 미리 스테이징하려는 소규모 로컬 번들에 더 적합합니다. -- `Skills(from_=GitRepo(repo=..., ref=...))`은 스킬 자체를 저장소에서 가져와야 할 때 적합합니다. +- 모델이 먼저 인덱스를 탐색하고 필요한 항목만 불러올 수 있으므로, 규모가 큰 로컬 스킬 디렉터리에는 `Skills(lazy_from=LocalDirLazySkillSource(...))`이 적절한 기본값입니다. +- `LocalDirLazySkillSource(source=LocalDir(src=...))`은 SDK 프로세스가 실행되는 파일 시스템에서 읽습니다. 샌드박스 이미지나 워크스페이스 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. +- 미리 스테이징하려는 작은 로컬 번들에는 `Skills(from_=LocalDir(src=...))`가 더 적합합니다. +- 스킬 자체를 저장소에서 가져와야 한다면 `Skills(from_=GitRepo(repo=..., ref=...))`이 적합합니다. -`LocalDir.src`은 SDK 호스트의 소스 경로입니다. `skills_path`는 `load_skill` 호출 시 스킬이 스테이징되는 샌드박스 워크스페이스 내부의 상대 대상 경로입니다. +`LocalDir.src`은 SDK 호스트의 소스 경로입니다. `skills_path`는 `load_skill`이 호출될 때 스킬이 스테이징되는 샌드박스 워크스페이스 내부의 상대 대상 경로입니다. -스킬이 이미 `.agents/skills//SKILL.md`과 같은 디스크 경로에 있다면 `LocalDir(...)`이 해당 소스 루트를 가리키도록 하고, 계속 `Skills(...)`을 사용해 스킬을 노출하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 워크스페이스 계약이 없다면 기본 `skills_path=".agents"`을 유지하세요. +스킬이 이미 `.agents/skills//SKILL.md` 같은 디스크 경로에 있다면 `LocalDir(...)`이 해당 소스 루트를 가리키도록 하고, 이를 노출할 때는 계속 `Skills(...)`을 사용하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 워크스페이스 계약이 없다면 기본 `skills_path=".agents"`을 유지하세요. -기본 제공 기능이 요구 사항에 맞는다면 우선 사용하세요. 기본 제공 기능에서 다루지 않는 샌드박스 전용 도구 또는 지침 인터페이스가 필요한 경우에만 사용자 지정 기능을 작성하세요. +적합한 기본 제공 기능이 있다면 이를 우선 사용하세요. 기본 제공 기능으로 처리할 수 없는 샌드박스 전용 도구 또는 instructions 인터페이스가 필요한 경우에만 사용자 지정 기능을 작성하세요. ## 개념 ### 매니페스트 -[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root`을 설정하고, 파일 및 디렉터리를 선언하고, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자 또는 그룹을 정의하고, 워크스페이스 외부의 특정 절대 경로에 대한 액세스 권한을 부여할 수 있습니다. +[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root`을 설정하고, 파일과 디렉터리를 선언하고, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자 또는 그룹을 정의하며, 워크스페이스 외부의 특정 절대 경로에 대한 접근 권한을 부여할 수 있습니다. -매니페스트 항목 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..`을 통해 워크스페이스를 벗어날 수 없습니다. 이를 통해 로컬, Docker 및 호스티드 클라이언트 간에 워크스페이스 계약의 이식성을 유지할 수 있습니다. +매니페스트 항목의 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..`을 통해 워크스페이스를 벗어날 수 없으므로, 로컬, Docker 및 호스티드 클라이언트 간에 워크스페이스 계약을 이식할 수 있습니다. -작업 시작 전에 에이전트에 필요한 자료에는 매니페스트 항목을 사용하세요. +작업을 시작하기 전에 에이전트에 필요한 자료에는 매니페스트 항목을 사용하세요.
| 매니페스트 항목 | 용도 | | --- | --- | -| `File`, `Dir` | 소규모 합성 입력, 보조 파일 또는 출력 디렉터리 | +| `File`, `Dir` | 작은 합성 입력, 보조 파일 또는 출력 디렉터리 | | `LocalFile`, `LocalDir` | 샌드박스에 구체화해야 하는 호스트 파일 또는 디렉터리 | | `GitRepo` | 워크스페이스로 가져와야 하는 저장소 | | `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 같은 마운트 | 샌드박스 내부에 표시해야 하는 외부 스토리지 |
-`Dir`는 합성 하위 항목으로 샌드박스 워크스페이스 내부에 디렉터리를 만들거나 출력 위치를 생성합니다. 호스트 파일 시스템에서는 읽지 않습니다. 기존 호스트 디렉터리를 샌드박스 워크스페이스로 복사해야 할 때는 `LocalDir`을 사용하세요. +`Dir`는 합성 하위 항목 또는 출력 위치로부터 샌드박스 워크스페이스 내부에 디렉터리를 생성하며, 호스트 파일 시스템에서 읽지는 않습니다. 기존 호스트 디렉터리를 샌드박스 워크스페이스로 복사해야 할 때는 `LocalDir`을 사용하세요. -기본적으로 `LocalFile.src` 및 `LocalDir.src`은 SDK 프로세스 작업 디렉터리를 기준으로 확인됩니다. 소스는 `extra_path_grants`의 적용을 받지 않는 한 해당 기본 디렉터리 아래에 있어야 합니다. 이를 통해 로컬 소스 구체화가 나머지 샌드박스 매니페스트와 동일한 호스트 경로 신뢰 경계 내에 유지됩니다. +`LocalFile.src`과 `LocalDir.src`은 기본적으로 SDK 프로세스 작업 디렉터리를 기준으로 해석됩니다. 소스는 `extra_path_grants`에 포함되지 않는 한 해당 기본 디렉터리 아래에 있어야 합니다. 이렇게 하면 로컬 소스 구체화가 나머지 샌드박스 매니페스트와 동일한 호스트 경로 신뢰 경계 내에 유지됩니다. -마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방법을 설명합니다. 마운트 옵션과 공급자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참고하세요. +마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 설명합니다. 마운트 옵션과 제공자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참고하세요. -일반적으로 적절한 매니페스트 설계란 워크스페이스 계약의 범위를 좁게 유지하고, 긴 작업 절차는 `repo/task.md`과 같은 워크스페이스 파일에 배치하며, 지침에서는 `repo/task.md` 또는 `output/report.md`와 같은 상대 워크스페이스 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집하는 경우, 패치 경로는 셸의 `workdir`가 아니라 샌드박스 워크스페이스 루트를 기준으로 한다는 점에 유의하세요. +적절한 매니페스트 설계는 일반적으로 워크스페이스 계약의 범위를 좁게 유지하고, 긴 작업 절차는 `repo/task.md` 같은 워크스페이스 파일에 넣으며, instructions에서 `repo/task.md` 또는 `output/report.md` 같은 상대 워크스페이스 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집한다면 패치 경로가 셸 `workdir`이 아니라 샌드박스 워크스페이스 루트를 기준으로 한다는 점에 유의하세요. -에이전트가 워크스페이스 외부의 구체적인 절대 경로에 액세스해야 하거나 매니페스트에서 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 할 때만 `extra_path_grants`을 사용하세요. 예를 들면 임시 도구 출력용 `/tmp`, 읽기 전용 런타임용 `/opt/toolchain` 또는 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리가 있습니다. 권한 부여는 로컬 소스 구체화와 SDK 파일 API에 적용됩니다. 백엔드에서 파일 시스템 정책을 적용할 수 있는 경우 셸 실행에도 적용됩니다. +에이전트에 워크스페이스 외부의 구체적인 절대 경로가 필요하거나 매니페스트가 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 할 때만 `extra_path_grants`을 사용하세요. 예를 들어 임시 도구 출력용 `/tmp`, 읽기 전용 런타임용 `/opt/toolchain`, 또는 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리가 있습니다. 권한 부여는 로컬 소스 구체화 및 SDK 파일 API에 적용됩니다. 백엔드가 파일 시스템 정책을 적용할 수 있는 경우 셸 실행에도 적용됩니다. ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +254,17 @@ manifest = Manifest( ) ``` -Docker가 컨테이너 내부의 절대 POSIX `path`에 다른 절대 호스트 경로를 바인드 마운트해야 할 때 `host_path`을 설정하세요. `UnixLocalSandboxClient`은 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path`는 거부합니다. 샌드박스에서 수정하면 안 되는 호스트 데이터에는 `read_only=True`을 사용하고, 복사만으로 충분하다면 `LocalFile` 또는 `LocalDir`를 사용하세요. +Docker가 컨테이너 내부의 절대 POSIX `path`에 다른 절대 호스트 경로를 바인드 마운트해야 할 때는 `host_path`을 설정하세요. `UnixLocalSandboxClient`은 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path`을 거부합니다. 샌드박스가 수정해서는 안 되는 호스트 데이터에는 `read_only=True`을 사용하고, 복사만으로 충분하면 `LocalFile` 또는 `LocalDir`를 사용하세요. -`extra_path_grants`이 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. +`extra_path_grants`이 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션이 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력 또는 기타 신뢰할 수 없는 페이로드에서 권한 부여를 불러오지 마세요. -스냅샷과 `persist_workspace()`에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스용이며 지속성 있는 워크스페이스 상태가 아닙니다. +스냅샷과 `persist_workspace()`에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 접근용이며 영구 워크스페이스 상태가 아닙니다. ### 권한 -`Permissions`은 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스에서 구체화하는 파일에 관한 것이며, 모델 권한, 승인 정책 또는 API 자격 증명에 관한 것이 아닙니다. +`Permissions`은 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스가 구체화하는 파일에 관한 것이며 모델 권한, 승인 정책 또는 API 자격 증명에 관한 것이 아닙니다. -기본적으로 매니페스트 항목은 소유자가 읽고 쓰고 실행할 수 있으며, 그룹과 기타 사용자는 읽고 실행할 수 있습니다. 스테이징된 파일을 비공개, 읽기 전용 또는 실행 가능하게 만들어야 할 때 이를 재정의하세요. +기본적으로 매니페스트 항목은 소유자가 읽기, 쓰기 및 실행할 수 있고 그룹과 기타 사용자가 읽고 실행할 수 있습니다. 스테이징된 파일을 비공개, 읽기 전용 또는 실행 가능 상태로 만들어야 할 때 이를 재정의하세요. ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +280,9 @@ private_notes = File( ) ``` -`Permissions`은 소유자, 그룹 및 기타 사용자의 비트를 각각 저장하며, 항목이 디렉터리인지 여부도 저장합니다. 직접 구성하거나, `Permissions.from_str(...)`으로 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)`으로 OS 모드에서 파생할 수 있습니다. +`Permissions`은 소유자, 그룹 및 기타 사용자에 대한 비트를 별도로 저장하며, 항목이 디렉터리인지 여부도 저장합니다. 직접 구성하거나, `Permissions.from_str(...)`을 사용해 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)`을 사용해 OS 모드에서 파생할 수 있습니다. -사용자는 샌드박스에서 작업을 실행할 수 있는 ID입니다. 해당 ID가 샌드박스에 존재하도록 하려면 매니페스트에 `User`를 추가한 다음, 셸 명령, 파일 읽기 및 패치와 같이 모델에 노출되는 샌드박스 도구를 해당 사용자로 실행해야 할 때 `SandboxAgent.run_as`을 설정하세요. `run_as`이 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 실질적인 매니페스트에 자동으로 추가합니다. +사용자는 샌드박스에서 작업을 실행할 수 있는 ID입니다. 해당 ID가 샌드박스에 존재하도록 하려면 매니페스트에 `User`을 추가한 다음, 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구가 해당 사용자로 실행되어야 할 때 `SandboxAgent.run_as`을 설정하세요. `run_as`이 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 유효 매니페스트에 자동으로 추가합니다. ```python from agents import Runner @@ -334,13 +334,13 @@ result = await Runner.run( ) ``` -파일 수준 공유 규칙도 필요하다면 사용자를 매니페스트 그룹 및 항목 `group` 메타데이터와 결합하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하고, `Permissions`은 샌드박스에서 워크스페이스를 구체화한 후 해당 사용자가 읽고 쓰고 실행할 수 있는 파일을 제어합니다. +파일 수준 공유 규칙도 필요하다면 사용자와 매니페스트 그룹 및 항목 `group` 메타데이터를 함께 사용하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하고, `Permissions`은 샌드박스가 워크스페이스를 구체화한 후 해당 사용자가 읽고 쓰고 실행할 수 있는 파일을 제어합니다. ### SnapshotSpec -`SnapshotSpec`은 새 샌드박스 세션에서 저장된 워크스페이스 콘텐츠를 복원할 위치와 다시 저장할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state`은 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. +`SnapshotSpec`은 새 샌드박스 세션에 저장된 워크스페이스 내용을 복원할 위치와 다시 영속화할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state`은 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. -로컬 지속성 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 앱에서 원격 스냅샷 클라이언트를 제공하는 경우 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷을 설정할 수 없으면 아무 작업도 하지 않는 스냅샷이 대체 수단으로 사용되며, 워크스페이스 스냅샷의 지속성이 필요하지 않은 고급 호출자는 이를 명시적으로 사용할 수 있습니다. +로컬 영구 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 애플리케이션이 원격 스냅샷 클라이언트를 제공할 때는 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷 설정을 사용할 수 없으면 no-op 스냅샷이 대체 수단으로 사용되며, 고급 호출자는 워크스페이스 스냅샷 영속화를 원하지 않을 때 이를 명시적으로 사용할 수 있습니다. ```python from pathlib import Path @@ -357,13 +357,13 @@ run_config = RunConfig( ) ``` -러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트는 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작할 때 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 저장된 워크스페이스 콘텐츠를 복원합니다. 정리할 때 러너가 소유한 샌드박스 세션은 워크스페이스를 보관하고 스냅샷을 통해 다시 저장합니다. +러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 생성합니다. 시작 시 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 저장된 워크스페이스 내용을 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 워크스페이스를 보관하고 스냅샷을 통해 다시 영속화합니다. -`snapshot`를 생략하면 런타임은 가능할 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 이를 설정할 수 없으면 아무 작업도 하지 않는 스냅샷으로 대체됩니다. 마운트된 경로와 임시 경로는 지속성 있는 워크스페이스 콘텐츠로 스냅샷에 복사되지 않습니다. +`snapshot`을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 이를 설정할 수 없으면 no-op 스냅샷으로 대체합니다. 마운트된 경로와 임시 경로는 영구 워크스페이스 내용으로 스냅샷에 복사되지 않습니다. ### 샌드박스 수명 주기 -수명 주기에는 **SDK 소유**와 **개발자 소유**라는 두 가지 모드가 있습니다. +수명 주기 모드는 **SDK 소유**와 **개발자 소유** 두 가지입니다.
@@ -391,7 +391,7 @@ sequenceDiagram
-샌드박스를 한 번의 실행 동안만 유지하면 되는 경우 SDK 소유 수명 주기를 사용하세요. `client`, 선택적으로 `manifest` 및 `snapshot`, 그리고 필요한 클라이언트 `options`을 전달합니다. 러너는 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 저장하고, 샌드박스 세션을 종료하고, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. +샌드박스를 한 번의 실행 동안만 유지해야 할 때는 SDK 소유 수명 주기를 사용하세요. `client`, 선택적으로 `manifest`와 `snapshot`, 그리고 필요한 클라이언트 `options`을 전달합니다. 러너는 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 영속화하고, 샌드박스 세션을 종료한 다음, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. ```python result = await Runner.run( @@ -403,7 +403,7 @@ result = await Runner.run( ) ``` -샌드박스를 미리 생성하거나, 여러 실행에서 하나의 활성 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려는 경우 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너는 해당 활성 샌드박스를 사용하지만 대신 닫지는 않습니다. +샌드박스를 미리 생성하거나, 여러 실행에서 하나의 라이브 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너는 해당 라이브 샌드박스를 사용하지만 사용자를 대신해 닫지는 않습니다. ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -일반적으로 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 앱에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. +일반적으로는 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 애플리케이션에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. ```python sandbox = await client.create( @@ -435,11 +435,11 @@ finally: await sandbox.aclose() ``` -`stop()`은 스냅샷 기반 워크스페이스 콘텐츠만 저장하며 샌드박스를 종료하지 않습니다. `aclose()`은 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. +`stop()`은 스냅샷 기반 워크스페이스 내용만 영속화하며 샌드박스를 종료하지 않습니다. `aclose()`은 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하며, 세션 범위 종속성을 닫습니다. ## `SandboxRunConfig` 옵션 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 샌드박스 세션의 출처와 새 세션의 초기화 방식을 결정하는 실행별 옵션을 포함합니다. +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에는 샌드박스 세션의 출처와 새 세션 초기화 방법을 결정하는 실행별 옵션이 포함됩니다. ### 샌드박스 소스 @@ -449,18 +449,18 @@ finally: | 옵션 | 사용 시점 | 참고 사항 | | --- | --- | --- | -| `client` | 러너가 샌드박스 세션을 생성, 재개 및 정리하도록 하려는 경우 | 활성 샌드박스 `session`를 제공하지 않는 한 필수입니다. | -| `session` | 이미 활성 샌드박스 세션을 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 활성 샌드박스 세션을 재사용합니다. | -| `session_state` | 직렬화된 샌드박스 세션 상태는 있지만 활성 샌드박스 세션 객체는 없는 경우 | `client`이 필요합니다. 러너는 해당 명시적 상태에서 재개하고 재개된 세션의 수명 주기를 소유합니다. | +| `client` | 러너가 샌드박스 세션을 생성, 재개 및 정리하도록 하려는 경우 | 라이브 샌드박스 `session`을 제공하지 않는 한 필수입니다. | +| `session` | 라이브 샌드박스 세션을 이미 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 라이브 샌드박스 세션을 재사용합니다. | +| `session_state` | 직렬화된 샌드박스 세션 상태는 있지만 라이브 샌드박스 세션 객체는 없는 경우 | `client`이 필요합니다. 러너는 해당 명시적 상태에서 재개하고 재개된 세션의 수명 주기를 소유합니다. | -실제로 러너는 다음 순서로 샌드박스 세션을 확인합니다. +실제로 러너는 다음 순서로 샌드박스 세션을 결정합니다. -1. `run_config.sandbox.session`을 주입하면 해당 활성 샌드박스 세션을 직접 재사용합니다. -2. 그렇지 않고 실행이 `RunState`에서 재개되는 경우, 저장된 샌드박스 세션 상태를 재개합니다. -3. 그렇지 않고 `run_config.sandbox.session_state`을 전달한 경우, 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. -4. 그렇지 않으면 러너가 새 샌드박스 세션을 생성합니다. 해당 새 세션에는 `run_config.sandbox.manifest`이 제공되면 이를 사용하고, 그렇지 않으면 `agent.default_manifest`를 사용합니다. +1. `run_config.sandbox.session`을 주입하면 해당 라이브 샌드박스 세션을 직접 재사용합니다. +2. 그렇지 않고 `RunState`에서 실행을 재개한다면 저장된 샌드박스 세션 상태를 재개합니다. +3. 그렇지 않고 `run_config.sandbox.session_state`을 전달하면 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. +4. 그렇지 않으면 새 샌드박스 세션을 생성합니다. 새 세션에는 제공된 경우 `run_config.sandbox.manifest`을 사용하고, 제공되지 않았다면 `agent.default_manifest`을 사용합니다. ### 새 세션 입력 @@ -470,25 +470,25 @@ finally: | 옵션 | 사용 시점 | 참고 사항 | | --- | --- | --- | -| `manifest` | 일회성 새 세션 워크스페이스 재정의가 필요한 경우 | 생략하면 `agent.default_manifest`로 대체됩니다. | +| `manifest` | 일회성 새 세션 워크스페이스 재정의가 필요한 경우 | 생략하면 `agent.default_manifest`으로 대체됩니다. | | `snapshot` | 새 샌드박스 세션을 스냅샷에서 초기화해야 하는 경우 | 재개와 유사한 흐름이나 원격 스냅샷 클라이언트에 유용합니다. | -| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 유사한 클라이언트별 설정에서 일반적으로 사용됩니다. | +| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 이와 유사한 클라이언트별 설정에 흔히 사용됩니다. | ### 구체화 제어 -`concurrency_limits`은 병렬로 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요할 때 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`을 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None`로 설정하세요. +`concurrency_limits`은 동시에 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요하면 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`을 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None`으로 설정하세요. -`archive_limits`은 아카이브 추출을 위한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임계값을 활성화하려면 `archive_limits=SandboxArchiveLimits()`로 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요한 경우 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)`와 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한이 없는 기본 동작을 유지하려면 `archive_limits=None`으로 두고, 해당 제한만 비활성화하려면 개별 필드를 `None`로 설정하세요. +`archive_limits`은 아카이브 추출에 대한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임계값을 활성화하려면 `archive_limits=SandboxArchiveLimits()`로 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요하면 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한 없이 기본 동작을 유지하려면 `archive_limits=None`으로 두고, 특정 제한만 비활성화하려면 개별 필드를 `None`으로 설정하세요. -유의해야 할 몇 가지 사항은 다음과 같습니다. +다음과 같은 몇 가지 사항을 기억해 두는 것이 좋습니다. -- 새 세션: `manifest=` 및 `snapshot=`은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. -- 재개와 스냅샷의 차이: `session_state=`은 이전에 직렬화된 샌드박스 상태에 다시 연결하지만, `snapshot=`은 저장된 워크스페이스 콘텐츠로 새 샌드박스 세션을 초기화합니다. -- 클라이언트별 옵션: `options=`은 샌드박스 클라이언트에 따라 달라지며, Docker와 다수의 호스티드 클라이언트에서 필요합니다. -- 주입된 활성 세션: 실행 중인 샌드박스 `session`을 전달하면 기능 기반 매니페스트 업데이트에서 호환되는 비마운트 항목을 추가할 수 있습니다. `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups`을 변경하거나, 기존 항목을 제거하거나, 항목 유형을 대체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. -- 러너 API: `SandboxAgent` 실행에는 여전히 일반적인 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API가 사용됩니다. +- 새 세션: `manifest=`와 `snapshot=`은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. +- 재개와 스냅샷: `session_state=`은 이전에 직렬화된 샌드박스 상태에 다시 연결하지만, `snapshot=`은 저장된 워크스페이스 내용으로 새 샌드박스 세션을 초기화합니다. +- 클라이언트별 옵션: `options=`은 샌드박스 클라이언트에 따라 달라집니다. Docker와 많은 호스티드 클라이언트에는 이 옵션이 필요합니다. +- 주입된 라이브 세션: 실행 중인 샌드박스 `session`을 전달하면 기능 기반 매니페스트 업데이트에서 호환되는 비마운트 항목을 추가할 수 있습니다. 그러나 `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups`을 변경하거나, 기존 항목을 제거하거나, 항목 유형을 대체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. +- 러너 API: `SandboxAgent` 실행은 계속해서 일반 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API를 사용합니다. ## 전체 예제: 코딩 작업 @@ -571,7 +571,7 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제에서는 Unix 로컬 실행 전반에서 결정론적으로 검증할 수 있도록 작은 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 무엇이든 사용할 수 있습니다. +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제는 Unix 로컬 실행에서 결정론적으로 검증할 수 있도록 작은 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 어떤 언어로도 구성할 수 있습니다. ## 일반적인 패턴 @@ -579,11 +579,11 @@ if __name__ == "__main__": ### 샌드박스 클라이언트 전환 -에이전트 정의는 동일하게 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 동등성이 필요하면 Docker를 사용하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자를 사용하세요. 예제와 공급자 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. +에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 일관성이 필요하면 Docker를 사용하고, 제공자가 관리하는 실행을 원하면 호스티드 제공자를 사용하세요. 예제와 제공자 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. ### 워크스페이스 재정의 -에이전트 정의는 동일하게 유지하고 새 세션 매니페스트만 교체하세요. +에이전트 정의는 그대로 유지하고 새 세션의 매니페스트만 교체합니다. ```python from agents.run import RunConfig @@ -603,11 +603,11 @@ run_config = RunConfig( ) ``` -에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 여러 저장소, 자료 또는 작업 번들에서 실행해야 할 때 사용하세요. 위의 검증된 코딩 예제에서는 일회성 재정의 대신 `default_manifest`을 사용해 동일한 패턴을 보여 줍니다. +에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 서로 다른 저장소, 패킷 또는 작업 번들에 적용해야 할 때 사용하세요. 위에서 검증된 코딩 예제는 일회성 재정의 대신 `default_manifest`을 사용해 동일한 패턴을 보여 줍니다. ### 샌드박스 세션 주입 -명시적인 수명 주기 제어, 실행 후 검사 또는 출력 복사가 필요한 경우 활성 샌드박스 세션을 주입하세요. +명시적인 수명 주기 제어, 실행 후 검사 또는 출력 복사가 필요할 때 라이브 샌드박스 세션을 주입합니다. ```python from agents import Runner @@ -628,11 +628,11 @@ async with sandbox: ) ``` -실행 후 워크스페이스를 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려는 경우 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 및 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참고하세요. +실행 후 워크스페이스를 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려 할 때 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)와 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참고하세요. ### 세션 상태에서 재개 -`RunState` 외부에서 샌드박스 상태를 이미 직렬화했다면 러너가 해당 상태에서 다시 연결하도록 하세요. +이미 `RunState` 외부에서 샌드박스 상태를 직렬화했다면 러너가 해당 상태에 다시 연결하도록 합니다. ```python from agents.run import RunConfig @@ -649,13 +649,15 @@ run_config = RunConfig( ) ``` -샌드박스 상태가 자체 스토리지나 작업 시스템에 있고 `Runner`에서 직접 재개하도록 하려는 경우 사용하세요. 직렬화 및 역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참고하세요. +샌드박스 상태가 자체 스토리지나 작업 시스템에 있고 `Runner`이 해당 상태에서 직접 재개하도록 하려는 경우 사용하세요. 직렬화 및 역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참고하세요. -세션 상태 직렬화에서는 네이티브 `host_path` 값이 생략됩니다. 호스트 기반 권한 부여를 재개하려면 현재 신뢰할 수 있는 매니페스트를 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`를 통해 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개에 실패합니다. 직렬화된 입력이나 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생하지 마세요. +세션 상태 직렬화에서는 네이티브 `host_path` 값이 생략됩니다. 호스트 기반 권한 부여를 재개하려면 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`을 통해 현재의 신뢰할 수 있는 매니페스트를 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 입력이나 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생하지 마세요. + +세션 상태와 `RunState` 직렬화에서는 클라우드 마운트 자격 증명, 자격 증명이 포함된 보조 구성 및 컨테이너 내부 자격 증명 노출 승인도 제거됩니다. 마운트된 세션 재개를 지원하는 백엔드에서 상태에 삭제된 마운트 권한 정보가 포함된 경우 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`을 통해 현재의 신뢰할 수 있는 매니페스트를 제공하세요. `"data"`이라는 마운트 항목에 마운트 범위 승인이 필요한 경우 재개하기 전에 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")`을 사용해 복사된 매니페스트를 유지하세요. 광범위한 권한에는 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")`을 사용하고, 마운트가 두 권한 클래스를 모두 사용하는 경우 두 메서드를 모두 호출하세요. 승인이 필요한 정확한 마운트 경로를 모두 전달하세요. Agents SDK는 현재 신뢰할 수 있는 매니페스트가 영속화된 상태와 자격 증명을 제외한 마운트 토폴로지가 정확히 동일한 경우에만 자격 증명을 복원합니다. 신뢰할 수 있는 구성이 없거나 일치하지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 상태 자체로는 권한이 부여되지 않습니다. `VercelSandboxClient`은 마운트된 세션을 재개할 수 없으므로, 대신 신뢰할 수 있는 매니페스트를 사용해 새 샌드박스를 시작하세요. ### 스냅샷에서 시작 -저장된 파일과 결과물로 새 샌드박스를 초기화하세요. +저장된 파일과 결과물로 새 샌드박스를 초기화합니다. ```python from pathlib import Path @@ -672,11 +674,11 @@ run_config = RunConfig( ) ``` -새 샌드박스 세션을 생성하는 실행에서 `agent.default_manifest`만 사용하는 대신 저장된 워크스페이스 콘텐츠로 시작해야 할 때 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참고하세요. +새 샌드박스 세션을 생성하는 실행이 `agent.default_manifest`만 사용하는 대신 저장된 워크스페이스 내용에서 시작해야 할 때 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참고하세요. -### Git에서 스킬 로드 +### Git에서 스킬 불러오기 -로컬 스킬 소스를 저장소 기반 소스로 교체하세요. +로컬 스킬 소스를 저장소 기반 소스로 교체합니다. ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -687,11 +689,11 @@ capabilities = Capabilities.default() + [ ] ``` -스킬 번들에 자체 릴리스 주기가 있거나 여러 샌드박스에서 공유해야 할 때 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참고하세요. +스킬 번들의 릴리스 주기가 별도로 관리되거나 여러 샌드박스에서 공유해야 할 때 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참고하세요. ### 도구로 노출 -도구 에이전트에는 자체 샌드박스 경계를 부여하거나 상위 실행의 활성 샌드박스를 재사용하도록 할 수 있습니다. 재사용은 빠른 읽기 전용 탐색기 에이전트에 유용합니다. 다른 샌드박스를 생성, 초기화 또는 스냅샷으로 저장하는 비용 없이 상위 실행에서 사용하는 정확한 워크스페이스를 검사할 수 있습니다. +도구 에이전트에는 자체 샌드박스 경계를 제공하거나 상위 실행의 라이브 샌드박스를 재사용하도록 할 수 있습니다. 빠른 읽기 전용 탐색기 에이전트에는 재사용이 유용합니다. 별도의 샌드박스를 생성하고, 채우고, 스냅샷으로 만드는 비용 없이 상위 실행이 사용하는 정확한 워크스페이스를 검사할 수 있습니다. ```python from agents import Runner @@ -773,9 +775,9 @@ async with sandbox: ) ``` -여기서 상위 에이전트는 동일한 활성 샌드박스 세션 내에서 `coordinator`로 실행되고, 탐색기 도구 에이전트는 `explorer`로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색기가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자/그룹만 사용할 수 있으므로, 탐색기는 읽기 전용으로 유지되는 동안 상위 에이전트가 최종 결과물을 작성할 수 있습니다. +여기서 상위 에이전트는 `coordinator`으로 실행되고, 탐색기 도구 에이전트는 동일한 라이브 샌드박스 세션 내부에서 `explorer`으로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색기가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자 및 그룹에만 제공되므로, 상위 에이전트는 최종 결과물을 작성할 수 있지만 탐색기는 읽기 전용으로 유지됩니다. -도구 에이전트에 실제 격리가 필요하다면 자체 샌드박스 `RunConfig`를 제공하세요. +도구 에이전트에 실제 격리가 필요하다면 자체 샌드박스 `RunConfig`을 제공하세요. ```python from docker import from_env as docker_from_env @@ -801,11 +803,11 @@ rollout_agent.as_tool( ) ``` -도구 에이전트가 자유롭게 변경하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드/이미지를 사용해야 할 때 별도의 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. +도구 에이전트가 자유롭게 변경하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드 또는 이미지를 사용해야 할 때는 별도 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. -### 로컬 도구 및 MCP와의 결합 +### 로컬 도구 및 MCP와 결합 -샌드박스 워크스페이스를 유지하면서 동일한 에이전트에서 일반 도구도 계속 사용하세요. +샌드박스 워크스페이스를 유지하면서 동일한 에이전트에서 일반 도구도 사용합니다. ```python from agents.sandbox import SandboxAgent @@ -824,42 +826,42 @@ agent = SandboxAgent( ## 메모리 -향후 샌드박스 에이전트 실행에서 이전 실행의 내용을 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제한 다음 이후 실행에서 해당 파일을 읽을 수 있습니다. +향후 샌드박스 에이전트 실행이 이전 실행으로부터 학습해야 할 때 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제한 다음, 이후 실행에서 해당 파일을 읽을 수 있습니다. -설정, 읽기/생성 동작, 다중 턴 대화 및 레이아웃 격리에 대해서는 [에이전트 메모리](memory.md)를 참고하세요. +설정, 읽기 및 생성 동작, 다중 턴 대화, 레이아웃 격리에 관한 내용은 [에이전트 메모리](memory.md)를 참고하세요. ## 구성 패턴 -단일 에이전트 패턴을 이해했다면 다음 설계 질문은 더 큰 시스템에서 샌드박스 경계를 어디에 배치할 것인지입니다. +단일 에이전트 패턴을 이해한 다음에는 더 큰 시스템에서 샌드박스 경계를 어디에 둘지 결정해야 합니다. -샌드박스 에이전트는 여전히 SDK의 나머지 요소와 함께 구성할 수 있습니다. +샌드박스 에이전트도 SDK의 나머지 부분과 함께 구성할 수 있습니다. - [핸드오프](../handoffs.md): 샌드박스를 사용하지 않는 접수 에이전트에서 문서 중심 작업을 샌드박스 검토자에게 핸드오프합니다. -- [Agents as tools](../tools.md#agents-as-tools): 여러 샌드박스 에이전트를 도구로 노출합니다. 일반적으로 각 도구가 자체 샌드박스 경계를 갖도록 각 `Agent.as_tool(...)` 호출에 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`를 전달합니다. +- [Agents as tools](../tools.md#agents-as-tools): 여러 샌드박스 에이전트를 도구로 노출합니다. 일반적으로 각 `Agent.as_tool(...)` 호출에서 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`을 전달하여 각 도구에 자체 샌드박스 경계를 제공합니다. - [MCP](../mcp.md) 및 일반 함수 도구: 샌드박스 기능은 `mcp_servers` 및 일반 Python 도구와 함께 사용할 수 있습니다. -- [에이전트 실행](../running_agents.md): 샌드박스 실행에서도 일반적인 `Runner` API를 사용합니다. +- [에이전트 실행](../running_agents.md): 샌드박스 실행도 일반 `Runner` API를 사용합니다. -특히 일반적인 두 가지 패턴은 다음과 같습니다. +특히 다음 두 패턴이 흔히 사용됩니다. -- 워크스페이스 격리가 필요한 워크플로 부분에서만 샌드박스를 사용하지 않는 에이전트가 샌드박스 에이전트로 핸드오프 -- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하며, 일반적으로 각 도구가 자체적으로 격리된 워크스페이스를 갖도록 각 `Agent.as_tool(...)` 호출마다 별도의 샌드박스 `RunConfig` 사용 +- 샌드박스를 사용하지 않는 에이전트가 워크스페이스 격리가 필요한 워크플로 부분만 샌드박스 에이전트로 핸드오프 +- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하며, 일반적으로 각 `Agent.as_tool(...)` 호출마다 별도의 샌드박스 `RunConfig`을 사용하여 각 도구에 자체 격리 워크스페이스 제공 ### 턴과 샌드박스 실행 -핸드오프와 에이전트 도구 호출을 별도로 설명하면 이해하기 쉽습니다. +핸드오프와 Agents as tools 호출을 별도로 설명하면 이해하는 데 도움이 됩니다. -핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프만 존재합니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면 동일한 실행의 다음 모델 호출이 샌드박스 에이전트에 맞게 준비되고, 해당 샌드박스 에이전트가 다음 턴을 수행합니다. 즉, 핸드오프는 동일한 실행에서 다음 턴을 담당할 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참고하세요. +핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프가 있습니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면 동일한 실행의 다음 모델 호출이 샌드박스 에이전트용으로 준비되며, 해당 샌드박스 에이전트가 다음 턴을 맡습니다. 즉, 핸드오프는 동일한 실행의 다음 턴을 소유하는 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참고하세요. -`Agent.as_tool(...)`에서는 관계가 다릅니다. 외부 오케스트레이터는 하나의 외부 턴을 사용하여 도구 호출을 결정하고, 해당 도구 호출은 샌드박스 에이전트에 대한 중첩 실행을 시작합니다. 중첩 실행은 자체 턴 루프, `max_turns`, 승인 및 일반적으로 자체 샌드박스 `RunConfig`을 갖습니다. 중첩된 한 번의 턴에서 완료될 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터 관점에서는 이 모든 작업이 여전히 하나의 도구 호출 뒤에서 이루어지므로, 중첩된 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. +`Agent.as_tool(...)`에서는 관계가 다릅니다. 외부 오케스트레이터는 하나의 외부 턴을 사용해 도구 호출을 결정하고, 해당 도구 호출은 샌드박스 에이전트의 중첩 실행을 시작합니다. 중첩 실행에는 자체 턴 루프, `max_turns`, 승인 및 일반적으로 자체 샌드박스 `RunConfig`이 있습니다. 중첩 턴 하나로 완료될 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터의 관점에서는 이 모든 작업이 하나의 도구 호출 뒤에서 이루어지므로, 중첩 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. 승인 동작도 동일한 구분을 따릅니다. -- 핸드오프의 경우 샌드박스 에이전트가 해당 실행의 활성 에이전트가 되므로 승인은 동일한 최상위 실행에 유지됩니다. -- `Agent.as_tool(...)`의 경우 샌드박스 도구 에이전트 내부에서 발생한 승인도 외부 실행에 표시되지만, 저장된 중첩 실행 상태에서 발생하며 외부 실행이 재개될 때 중첩된 샌드박스 실행을 재개합니다. +- 핸드오프에서는 샌드박스 에이전트가 해당 실행의 활성 에이전트가 되므로 승인이 동일한 최상위 실행에 유지됩니다. +- `Agent.as_tool(...)`에서는 샌드박스 도구 에이전트 내부에서 발생한 승인도 외부 실행에 표시되지만, 저장된 중첩 실행 상태에서 가져오며 외부 실행이 재개될 때 중첩 샌드박스 실행을 재개합니다. ## 추가 자료 -- [빠른 시작](../sandbox_agents.md): 하나의 샌드박스 에이전트를 실행합니다. +- [빠른 시작](../sandbox_agents.md): 샌드박스 에이전트 하나를 실행합니다. - [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드 및 마운트 옵션을 선택합니다. -- [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 얻은 내용을 보존하고 재사용합니다. +- [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 얻은 학습 내용을 보존하고 재사용합니다. - [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 실행 가능한 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴입니다. \ No newline at end of file diff --git a/docs/ko/sessions/index.md b/docs/ko/sessions/index.md index ea8b1636f2..55c80c90c0 100644 --- a/docs/ko/sessions/index.md +++ b/docs/ko/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 세션 -Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 기본 제공 세션 메모리를 지원하므로, 턴 사이에서 `.to_input_list()`을 수동으로 처리할 필요가 없습니다. +Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 기본 제공 세션 메모리를 제공하므로, 턴 사이에서 `.to_input_list()`을 수동으로 처리할 필요가 없습니다. -세션은 특정 세션의 대화 기록을 저장하여, 명시적으로 메모리를 수동 관리하지 않아도 에이전트가 컨텍스트를 유지할 수 있게 합니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. +세션은 특정 세션의 대화 기록을 저장하므로, 명시적인 수동 메모리 관리 없이도 에이전트가 컨텍스트를 유지할 수 있습니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. -SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용하세요. 동일한 실행에서 세션은 실행 수준 연속 실행 옵션인 `conversation_id`, `previous_response_id`, `auto_previous_response_id`과 함께 사용할 수 없습니다. 대신 OpenAI 서버 관리형 연속 실행을 사용하려면 세션을 추가로 적용하지 말고 이러한 메커니즘 중 하나를 선택하세요. +SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용합니다. 동일한 실행에서는 세션을 실행 수준의 연속 실행 옵션인 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`과 함께 사용할 수 없습니다. 대신 OpenAI 서버에서 관리하는 연속 실행을 사용하려면 세션을 추가로 적용하지 말고 해당 메커니즘 중 하나를 선택합니다. ## 빠른 시작 @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 동일한 세션을 사용한 인터럽션(중단 처리)된 실행 재개 +## 동일한 세션을 사용한 인터럽션된 실행 재개 -승인을 위해 실행이 일시 중지되면, 재개된 턴이 저장된 동일한 대화 기록을 이어서 사용하도록 동일한 세션 인스턴스 또는 동일한 세션 ID와 동일한 기본 스토리지 백엔드로 구성된 다른 인스턴스를 사용하여 재개하세요. +실행이 승인을 위해 일시 중지된 경우 동일한 세션 인스턴스 또는 동일한 세션 ID 및 동일한 기본 스토리지 백엔드로 구성된 다른 인스턴스를 사용하여 재개해야 합니다. 그래야 재개된 턴이 저장된 동일한 대화 기록을 이어서 사용합니다. ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -65,29 +65,29 @@ if result.interruptions: ## 핵심 세션 동작 -세션 메모리가 활성화된 경우: +세션 메모리가 활성화되면 다음과 같이 동작합니다. -1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 가져와 입력 항목 앞에 추가합니다. +1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 조회하여 입력 항목 앞에 추가합니다. 2. **각 실행 후**: 실행 중 생성된 모든 새 항목(사용자 입력, 어시스턴트 응답, 도구 호출 등)이 세션에 자동으로 저장됩니다. -3. **컨텍스트 보존**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. +3. **컨텍스트 보존**: 동일한 세션을 사용하는 각 후속 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. 따라서 `.to_input_list()`을 수동으로 호출하고 실행 사이의 대화 상태를 관리할 필요가 없습니다. ## 기록과 새 입력의 병합 방식 제어 -세션을 전달하면 러너는 일반적으로 다음과 같이 모델 입력을 준비합니다. +세션을 전달하면 러너는 일반적으로 다음 순서로 모델 입력을 준비합니다. -1. 세션 기록(`session.get_items(...)`에서 가져옴) +1. 세션 기록(`session.get_items(...)`에서 조회) 2. 새 턴 입력 -모델 호출 전에 이 병합 단계를 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 콜백은 다음 두 목록을 받습니다. +모델 호출 전에 이 병합 단계를 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용합니다. 콜백은 다음 두 목록을 받습니다. -- `history`: 가져온 세션 기록(이미 입력 항목 형식으로 정규화됨) +- `history`: 조회된 세션 기록(이미 입력 항목 형식으로 정규화됨) - `new_input`: 현재 턴의 새 입력 항목 -모델에 전송할 최종 입력 항목 목록을 반환하세요. +모델에 전송할 최종 입력 항목 목록을 반환합니다. -콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속하는 항목만 저장합니다. 따라서 이전 기록을 재정렬하거나 필터링해도 이전 세션 항목이 새 입력으로 다시 저장되지 않습니다. +콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속하는 항목만 저장합니다. 따라서 이전 기록의 순서를 변경하거나 필터링하더라도 기존 세션 항목이 새 입력으로 다시 저장되지 않습니다. ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,14 +109,14 @@ result = await Runner.run( ) ``` -세션의 항목 저장 방식을 변경하지 않고 기록을 사용자 지정하여 정리, 재정렬 또는 선택적으로 포함해야 할 때 사용하세요. 모델 호출 직전에 추가적인 최종 처리 단계가 필요하다면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. +세션이 항목을 저장하는 방식을 변경하지 않고 기록을 사용자 지정하여 정리하거나, 순서를 변경하거나, 선별적으로 포함해야 할 때 이 기능을 사용합니다. 모델 호출 직전에 추가적인 최종 처리 단계가 필요하면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]을 사용합니다. -## 가져올 기록의 제한 +## 조회 기록 제한 -각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]을 사용하세요. +각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]을 사용합니다. -- `SessionSettings(limit=None)` (기본값): 사용 가능한 모든 세션 항목을 가져옴 -- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 가져옴 +- `SessionSettings(limit=None)`(기본값): 사용 가능한 모든 세션 항목 조회 +- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 조회 [`RunConfig.session_settings`][agents.run.RunConfig.session_settings]을 통해 실행별로 적용할 수 있습니다. @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -세션 구현이 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`에서 `None`이 아닌 각 값은 해당 실행의 대응하는 기본값을 재정의합니다. 세션의 기본 동작을 변경하지 않고 가져올 기록의 크기를 제한하려는 긴 대화에 유용합니다. +세션 구현에서 기본 세션 설정을 제공하는 경우 `RunConfig.session_settings`의 `None`이 아닌 각 값은 해당 실행에서 대응하는 기본값을 재정의합니다. 세션의 기본 동작을 변경하지 않고 조회 크기를 제한하려는 긴 대화에 유용합니다. ## 메모리 작업 ### 기본 작업 -세션은 대화 기록을 관리하기 위한 여러 작업을 지원합니다. +세션은 대화 기록 관리를 위한 여러 작업을 지원합니다. ```python from agents import SQLiteSession @@ -165,9 +165,9 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 수정을 위한 pop_item 사용 +### 수정 시 pop_item 사용 -`pop_item` 메서드는 대화의 마지막 항목을 실행 취소하거나 수정하려는 경우 특히 유용합니다. +대화의 마지막 항목을 실행 취소하거나 수정하려는 경우 `pop_item` 메서드가 특히 유용합니다. ```python from agents import Agent, Runner, SQLiteSession @@ -202,28 +202,28 @@ SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다 ### 기본 제공 세션 구현 선택 -아래의 자세한 예제를 읽기 전에 이 표를 사용하여 시작점을 선택하세요. +아래의 상세 예제를 읽기 전에 이 표를 참고하여 시작점을 선택합니다. -| 세션 유형 | 적합한 용도 | 참고 | +| 세션 유형 | 적합한 용도 | 참고 사항 | | --- | --- | --- | | `SQLiteSession` | 로컬 개발 및 간단한 앱 | 기본 제공되며 가볍고, 파일 기반 또는 인메모리 방식 | | `AsyncSQLiteSession` | `aiosqlite`을 사용하는 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | -| `RedisSession` | 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | -| `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 호환 | -| `MongoDBSession` | 이미 MongoDB를 사용하거나 멀티프로세스 스토리지가 필요한 앱 | 비동기 pymongo, 순서 지정을 위한 원자적 시퀀스 카운터 | +| `RedisSession` | 여러 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | +| `SQLAlchemySession` | 기존 데이터베이스가 있는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 호환 | +| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 스토리지가 필요한 앱 | 비동기 pymongo 사용, 순서 지정을 위한 원자적 시퀀스 카운터 제공 | | `DaprSession` | Dapr 사이드카를 사용하는 클라우드 네이티브 배포 | 여러 상태 저장소와 TTL 및 일관성 제어 지원 | | `OpenAIConversationsSession` | OpenAI의 서버 관리형 스토리지 | OpenAI Conversations API 기반 기록 | | `OpenAIResponsesCompactionSession` | 자동 압축이 필요한 긴 대화 | 다른 세션 백엔드를 감싸는 래퍼 | -| `AdvancedSQLiteSession` | SQLite와 분기/분석 | 더 많은 기능을 제공하며 전용 페이지 참고 | -| `EncryptedSession` | 다른 세션에 암호화와 TTL 추가 | 래퍼이므로 먼저 기본 백엔드 선택 필요 | +| `AdvancedSQLiteSession` | SQLite와 분기/분석 기능 | 더 많은 기능을 제공하며 전용 페이지 참조 | +| `EncryptedSession` | 다른 세션에 암호화 및 TTL 추가 | 래퍼이며 먼저 기본 백엔드 선택 필요 | -일부 구현에는 추가 세부 정보를 제공하는 전용 페이지가 있으며, 해당 하위 섹션에 링크되어 있습니다. +일부 구현에는 추가 세부 정보를 제공하는 전용 페이지가 있으며, 해당 하위 섹션에 링크가 포함되어 있습니다. -ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속화를 위해 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만, ChatKit 스토어를 그대로 대체할 수는 없습니다. [ChatKit 데이터 스토어 구현에 관한 `chatkit-python` 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참고하세요. +ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성을 위해 `chatkit.store.Store` 구현을 사용합니다. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만 ChatKit 저장소를 바로 대체할 수는 없습니다. [`chatkit-python` ChatKit 데이터 저장소 구현 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참조하세요. ### OpenAI Conversations API 세션 -`OpenAIConversationsSession`을 통해 [OpenAI의 Conversations API](https://platform.openai.com/docs/api-reference/conversations)를 사용하세요. +`OpenAIConversationsSession`을 통해 [OpenAI Conversations API](https://platform.openai.com/docs/api-reference/conversations)를 사용합니다. ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -259,7 +259,7 @@ print(result.final_output) # "California" ### OpenAI Responses 압축 세션 -Responses API(`responses.compact`)를 사용하여 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 세션은 기본 세션을 감싸며 `should_trigger_compaction`을 기준으로 각 턴 이후 자동 압축할 수 있습니다. `OpenAIConversationsSession`을 이 세션으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. +Responses API(`responses.compact`)를 사용하여 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용합니다. 이 구현은 기본 세션을 감싸며 `should_trigger_compaction`에 따라 각 턴 후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 구현으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. #### 일반적인 사용법(자동 압축) @@ -278,17 +278,19 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -기본적으로 SDK는 각 턴 이후 압축 대상이 임계값을 충족하는지 확인하고, 충족할 때만 압축합니다. +기본적으로 SDK는 각 턴 후 압축 후보가 임곗값을 충족하는지 확인하고, 충족할 때만 압축합니다. -`compaction_mode="previous_response_id"`은 압축 세션이 보관한 Responses API 응답 ID를 사용하며, 해당 응답 체인을 계속 사용할 수 있을 때 가장 효과적입니다. 반면 `compaction_mode="input"`은 현재 세션 항목을 바탕으로 압축 요청을 다시 구성하므로, 응답 체인을 사용할 수 없거나 세션 콘텐츠를 단일 진실 공급원으로 사용하려는 경우 유용합니다. 기본값인 `"auto"`은 사용 가능한 가장 안전한 옵션을 선택합니다. +`compaction_mode="previous_response_id"`은 압축 세션에서 유지하는 Responses API 응답 ID를 사용하며 해당 응답 체인을 계속 사용할 수 있을 때 가장 효과적입니다. 반면 `compaction_mode="input"`은 현재 세션 항목을 바탕으로 압축 요청을 다시 구성합니다. 이는 응답 체인을 사용할 수 없거나 세션 콘텐츠를 기준 데이터로 사용하려는 경우에 유용합니다. 기본값인 `"auto"`은 사용 가능한 가장 안전한 옵션을 선택합니다. -에이전트가 `ModelSettings(store=False)`으로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보관하지 않습니다. 이러한 무상태 설정에서 기본 `"auto"` 모드는 `previous_response_id`에 의존하지 않고 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)을 참고하세요. +에이전트가 `ModelSettings(store=False)`으로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보존하지 않습니다. 이러한 무상태 설정에서는 기본 `"auto"` 모드가 `previous_response_id`에 의존하는 대신 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)을 참조하세요. -#### 자동 압축에 의한 스트리밍 차단 +#### 스트리밍을 차단할 수 있는 자동 압축 -압축은 세션 기록을 지우고 다시 작성하므로 SDK는 실행이 완료된 것으로 간주하기 전에 압축이 끝날 때까지 기다립니다. 스트리밍 모드에서는 압축 작업이 무거운 경우 마지막 출력 토큰 이후에도 `run.stream_events()`이 몇 초간 열린 상태로 유지될 수 있습니다. +압축은 세션 기록을 지우고 다시 작성하므로 SDK는 압축이 완료될 때까지 기다린 후 실행이 완료된 것으로 간주합니다. 스트리밍 모드에서는 압축 작업이 무거울 경우 마지막 출력 토큰 이후에도 `run.stream_events()`이 몇 초 동안 열린 상태로 유지될 수 있습니다. -지연 시간이 짧은 스트리밍이나 빠른 턴 전환을 원한다면 자동 압축을 비활성화하고 턴 사이 또는 유휴 시간에 `run_compaction()`을 직접 호출하세요. 자체 기준에 따라 압축을 강제할 시점을 결정할 수 있습니다. +`OpenAIResponsesCompactionSession.run_compaction()`은 지우기 및 다시 쓰기 작업을 래퍼 경계에서 복구 가능한 교체 작업으로 처리합니다. 기본 기록이 변경된 후 교체가 실패하거나 취소되면 래퍼는 이전 기록 복원을 시도하고, 해당 복구 시도가 완료될 때까지 기다린 후 원래 예외나 취소를 호출자에게 전달합니다. 복구 중 기본 백엔드에서도 오류가 발생하면 이전 기록이 복원되지 않은 상태로 남을 수 있으며 SDK는 복구 실패를 로그에 기록합니다. 래퍼는 `add_items()`, `pop_item()`, `clear_session()` 호출을 잠금이 적용된 교체 및 복구 단계와 직렬화합니다. 그러나 원격 압축 요청이 진행 중인 동안 변경 작업이 완료된 후 성공적인 교체로 덮어써질 수 있습니다. 동시 래퍼 변경 작업이 없는 턴 사이에 수동 압축을 실행하고, 압축이 실행되는 동안 기본 세션을 직접 변경하지 마세요. + +지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요하면 자동 압축을 비활성화하고 턴 사이 또는 유휴 시간에 `run_compaction()`을 직접 호출합니다. 자체 기준에 따라 압축을 강제로 수행할 시점을 결정할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession @@ -332,7 +334,7 @@ result = await Runner.run( ### 비동기 SQLite 세션 -`aiosqlite` 기반의 SQLite 영속화를 원한다면 `AsyncSQLiteSession`을 사용하세요. +`aiosqlite` 기반의 SQLite 영속성이 필요한 경우 `AsyncSQLiteSession`을 사용합니다. ```bash pip install aiosqlite @@ -349,7 +351,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis 세션 -여러 워커 또는 서비스 간에 세션 메모리를 공유하려면 `RedisSession`을 사용하세요. +여러 워커 또는 서비스 간에 세션 메모리를 공유하려면 `RedisSession`을 사용합니다. ```bash pip install openai-agents[redis] @@ -368,11 +370,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후 세션은 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. `close()`을 반복하거나 동시에 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리한다면 `redis_client=...`을 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`은 아무 작업도 하지 않으며, 호출자가 클라이언트 소유권과 세션 사용 가능 상태를 모두 유지합니다. +`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후에는 세션이 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. `close()`을 반복하거나 동시에 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하는 경우 `redis_client=...`을 사용하여 `RedisSession(...)`을 직접 생성합니다. 이 경우 `close()`은 아무 작업도 하지 않으며 호출자가 클라이언트 소유권을 유지하고 세션도 계속 사용할 수 있습니다. ### SQLAlchemy 세션 -SQLAlchemy가 지원하는 모든 데이터베이스를 활용하는 프로덕션용 Agents SDK 세션 영속화입니다. +SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션용 Agents SDK 세션 영속성 구현입니다. ```python from agents.extensions.memory import SQLAlchemySession @@ -390,11 +392,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참고하세요. +자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참조하세요. ### Dapr 세션 -이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 구성된 상태 저장소 백엔드를 전환하려면 `DaprSession`을 사용하세요. +이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 구성된 상태 저장소 백엔드를 전환하려는 경우 `DaprSession`을 사용합니다. ```bash pip install openai-agents[dapr] @@ -415,19 +417,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -참고: +참고 사항: -- `from_address(...)`은 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리한다면 `dapr_client=...`을 사용하여 `DaprSession(...)`을 직접 생성하세요. -- 컨텍스트를 종료하거나 `close()`을 호출하면 소유 클라이언트를 사용하는 세션이 종료 상태가 됩니다. 이후 세션 작업은 `RuntimeError`을 발생시키지만, `close()`을 반복하거나 동시에 호출해도 안전합니다. 주입된 클라이언트를 사용하면 `close()`은 아무 작업도 하지 않으며 세션은 계속 사용할 수 있습니다. +- `from_address(...)`은 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리하는 경우 `dapr_client=...`을 사용하여 `DaprSession(...)`을 직접 생성합니다. +- 컨텍스트를 종료하거나 `close()`을 호출하면 소유 클라이언트를 사용하는 세션이 종료 상태가 됩니다. 이후 세션 작업은 `RuntimeError`을 발생시키지만 `close()`을 반복하거나 동시에 호출해도 안전합니다. 주입된 클라이언트를 사용하면 `close()`은 아무 작업도 하지 않으며 세션을 계속 사용할 수 있습니다. - 기본 상태 저장소가 TTL을 지원하는 경우 `ttl=...`을 전달하면 세션 데이터에 TTL 만료가 자동으로 적용됩니다. -- 쓰기 후 읽기에 대한 더 강력한 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG`을 전달하세요. -- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발에서는 `dapr_address`에서 사용하는 gRPC 포트와 함께 `--dapr-http-port 3500`으로 Dapr를 시작하세요. -- 로컬 컴포넌트와 문제 해결을 포함한 전체 설정 절차는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)을 참고하세요. +- 쓰기 후 읽기에 대해 더 강력한 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG`을 전달합니다. +- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발에서는 `dapr_address`에서 사용하는 gRPC 포트뿐만 아니라 `--dapr-http-port 3500`도 사용하여 Dapr을 시작합니다. +- 로컬 구성 요소와 문제 해결을 포함한 전체 설정 안내는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참조하세요. ### MongoDB 세션 -이미 MongoDB를 사용하거나 수평 확장이 가능한 멀티프로세스 세션 스토리지가 필요한 애플리케이션에는 `MongoDBSession`을 사용하세요. +이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에서는 `MongoDBSession`을 사용합니다. ```bash pip install openai-agents[mongodb] @@ -450,12 +452,12 @@ print(result.final_output) await session.close() ``` -참고: +참고 사항: -- `from_uri(...)`은 `AsyncMongoClient`을 생성하고 소유하며 `session.close()`에서 닫습니다. 소유 클라이언트를 사용하는 세션은 `close()` 이후 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. 애플리케이션에서 이미 클라이언트를 관리한다면 `client=...`을 사용하여 `MongoDBSession(...)`을 직접 생성하세요. 이 경우 `session.close()`은 아무 작업도 하지 않고, 호출자가 클라이언트 수명 주기를 관리할 책임을 유지하며, 세션은 계속 사용할 수 있습니다. -- 다른 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결하세요. -- 두 개의 컬렉션이 사용되며 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`)과 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 비어 있지 않은 각 `add_items()` 호출은 단조 증가하는 `seq`이 최종 항목을 기준으로 배치 순서를 정하는 하나의 논리적 배치 문서를 작성합니다. 기존의 항목별 메시지 문서도 계속 읽을 수 있습니다. 논리적 배치는 MongoDB의 단일 문서 크기 제한 내에 있어야 하며, 크기를 초과한 배치는 일부만 저장되지 않고 원자적으로 실패합니다. -- 첫 실행 전에 연결 상태를 확인하려면 `await session.ping()`을 사용하세요. +- `from_uri(...)`은 `AsyncMongoClient`을 생성하고 소유하며 `session.close()`에서 이를 닫습니다. 소유 클라이언트를 사용하는 세션은 `close()` 이후 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. 애플리케이션에서 이미 클라이언트를 관리하는 경우 `client=...`을 사용하여 `MongoDBSession(...)`을 직접 생성합니다. 이 경우 `session.close()`은 아무 작업도 하지 않고 호출자가 클라이언트 수명 주기를 관리할 책임을 유지하며 세션도 계속 사용할 수 있습니다. +- 별도의 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결합니다. +- 두 개의 컬렉션이 사용되며, 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`) 및 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 비어 있지 않은 각 `add_items()` 호출은 논리적 배치 문서 하나를 작성하며, 단조 증가하는 `seq`이 해당 배치의 마지막 항목을 기준으로 순서를 지정합니다. 기존의 항목별 메시지 문서도 계속 읽을 수 있습니다. 논리적 배치는 MongoDB의 단일 문서 크기 제한 이내여야 하며, 제한을 초과하는 배치는 일부만 저장되지 않고 원자적으로 실패합니다. +- 첫 실행 전에 연결을 확인하려면 `await session.ping()`을 사용합니다. ### 고급 SQLite 세션 @@ -479,11 +481,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참고하세요. +자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참조하세요. ### 암호화된 세션 -모든 세션 구현에 사용할 수 있는 투명한 암호화 래퍼입니다. +모든 세션 구현에 적용할 수 있는 투명한 암호화 래퍼입니다. ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -506,34 +508,34 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -자세한 문서는 [암호화된 세션](encrypted_session.md)을 참고하세요. +자세한 문서는 [암호화된 세션](encrypted_session.md)을 참조하세요. ### 기타 세션 유형 -몇 가지 기본 제공 옵션이 더 있습니다. `examples/memory/`과 `extensions/memory/` 아래의 소스 코드를 참고하세요. +몇 가지 기본 제공 옵션이 더 있습니다. `examples/memory/` 및 `extensions/memory/` 아래의 소스 코드를 참조하세요. ## 운영 패턴 -### 세션 ID 명명법 +### 세션 ID 명명 규칙 -대화를 체계적으로 관리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. +대화를 체계적으로 관리하는 데 도움이 되는 의미 있는 세션 ID를 사용합니다. - 사용자 기반: `"user_12345"` - 스레드 기반: `"thread_abc123"` - 컨텍스트 기반: `"support_ticket_456"` -### 메모리 영속화 +### 메모리 영속성 - 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`) 사용 - 영구 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) 사용 - `aiosqlite` 기반 구현이 필요하면 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) 사용 -- 지연 시간이 짧은 공유 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`) 사용 +- 공유되는 저지연 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`) 사용 - SQLAlchemy가 지원하는 기존 데이터베이스를 사용하는 프로덕션 시스템에는 SQLAlchemy 기반 세션(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) 사용 -- 이미 MongoDB를 사용하거나 수평 확장이 가능한 멀티프로세스 세션 스토리지가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) 사용 -- 기본 제공 텔레메트리, 트레이싱, 데이터 격리 및 30개 이상의 데이터베이스 백엔드 지원이 필요한 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 -- OpenAI Conversations API에 기록을 저장하려면 OpenAI 호스트 스토리지(`OpenAIConversationsSession()`) 사용 -- 투명한 암호화와 TTL 기반 만료를 모든 세션에 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 -- 더 고급 사용 사례를 위해 다른 프로덕션 시스템(예: Django)용 사용자 지정 세션 백엔드 구현 고려 +- 이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) 사용 +- 기본 제공 텔레메트리, 트레이싱, 데이터 격리와 30개 이상의 데이터베이스 백엔드 지원이 필요한 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 +- OpenAI Conversations API에 기록을 저장하려면 OpenAI 호스팅 스토리지(`OpenAIConversationsSession()`) 사용 +- 투명한 암호화 및 TTL 기반 만료를 모든 세션에 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 +- 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 사용자 정의 세션 백엔드 구현 고려 ### 여러 세션 @@ -581,7 +583,7 @@ result2 = await Runner.run( ## 전체 예제 -다음은 세션 메모리의 동작을 보여주는 전체 예제입니다. +다음은 세션 메모리의 실제 동작을 보여 주는 전체 예제입니다. ```python import asyncio @@ -643,41 +645,40 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 사용자 지정 세션 구현 +## 사용자 정의 세션 구현 -[`Session`][agents.memory.session.Session] 프로토콜을 따르는 클래스를 생성하여 자체 세션 메모리를 구현할 수 있습니다. +[`Session`][agents.memory.session.Session] 프로토콜의 구조를 따르는 클래스를 생성하여 자체 세션 메모리를 구현할 수 있습니다. `SessionABC`을 상속할 필요는 없습니다. `session_id` 및 `session_settings`을 정의하고 네 가지 기록 메서드를 직접 구현합니다. ```python -from agents.memory.session import SessionABC +from agents import Agent, Runner, SessionSettings from agents.items import TResponseInputItem -from typing import List -class MyCustomSession(SessionABC): + +class MyCustomSession: """Custom session implementation following the Session protocol.""" - def __init__(self, session_id: str): + session_settings: SessionSettings | None = None + + def __init__(self, session_id: str) -> None: self.session_id = session_id - # Your initialization here + self.items: list[TResponseInputItem] = [] - async def get_items(self, limit: int | None = None) -> List[TResponseInputItem]: - """Retrieve conversation history for this session.""" - # Your implementation here - pass + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + if limit is None: + return list(self.items) + if limit <= 0: + return [] + return list(self.items[-limit:]) - async def add_items(self, items: List[TResponseInputItem]) -> None: - """Store new items for this session.""" - # Your implementation here - pass + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.items.extend(items) async def pop_item(self) -> TResponseInputItem | None: - """Remove and return the most recent item from this session.""" - # Your implementation here - pass + return self.items.pop() if self.items else None async def clear_session(self) -> None: - """Clear all items for this session.""" - # Your implementation here - pass + self.items.clear() + # Use your custom session agent = Agent(name="Assistant") @@ -688,6 +689,47 @@ result = await Runner.run( ) ``` +### 사용자 정의 세션의 실행 컨텍스트 접근 + +Agents SDK는 테넌트 라우팅, 권한 부여 또는 기타 앱별 스토리지 결정을 위해 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper]을 사용자 정의 세션에 전달할 수 있습니다. Agents SDK가 래퍼를 전달하도록 하려면 네 가지 기록 메서드 모두에 명시적인 이름을 가지며 키워드와 호환되는 `wrapper` 매개변수를 추가합니다. + +```python +from typing import Any + +from agents import RunContextWrapper +from agents.items import TResponseInputItem + + +class ContextAwareSession: + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: ... + + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... + + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: ... + + async def clear_session( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... +``` + +Agents SDK는 `get_items`, `add_items`, `pop_item`, `clear_session`이 모두 `wrapper`을 선언하는 경우에만 이 통합을 활성화합니다. 일반적인 `**kwargs` 매개변수는 이 시그니처 검사를 충족하지 않습니다. `wrapper`을 생략한 기존 세션 구현은 릴리스된 호출 형식을 유지하며 변경 없이 계속 작동합니다. + ## 커뮤니티 세션 구현 커뮤니티에서 추가 세션 구현을 개발했습니다. @@ -696,11 +738,11 @@ result = await Runner.run( |---------|-------------| | [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 위한 Django ORM 기반 세션 | -세션 구현을 만들었다면 여기에 추가할 수 있도록 문서 PR을 자유롭게 제출해 주세요! +세션 구현을 개발했다면 여기에 추가할 수 있도록 문서 PR을 자유롭게 제출해 주세요! ## API 레퍼런스 -자세한 API 문서는 다음을 참고하세요. +자세한 API 문서는 다음을 참조하세요. - [`Session`][agents.memory.session.Session] - 프로토콜 인터페이스 - [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 구현 @@ -711,5 +753,5 @@ result = await Runner.run( - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy 기반 구현 - [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 기반 세션 구현 - [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 상태 저장소 구현 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기와 분석 기능을 갖춘 향상된 SQLite +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기 및 분석 기능을 갖춘 향상된 SQLite - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 모든 세션을 위한 암호화 래퍼 \ No newline at end of file diff --git a/docs/ko/streaming.md b/docs/ko/streaming.md index 2fbcdbb360..ca41b97924 100644 --- a/docs/ko/streaming.md +++ b/docs/ko/streaming.md @@ -6,15 +6,15 @@ search: 스트리밍을 사용하면 에이전트 실행이 진행되는 동안 업데이트를 구독할 수 있습니다. 최종 사용자에게 진행 상황 업데이트와 부분 응답을 표시할 때 유용합니다. -스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 호출합니다. 그러면 [`RunResultStreaming`][agents.result.RunResultStreaming]이 반환됩니다. `result.stream_events()`를 호출하면 아래에서 설명하는 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림을 얻을 수 있습니다. +스트리밍하려면 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 호출하여 [`RunResultStreaming`][agents.result.RunResultStreaming]을 받을 수 있습니다. `result.stream_events()`를 호출하면 아래에서 설명하는 [`StreamEvent`][agents.stream_events.StreamEvent] 객체의 비동기 스트림을 얻습니다. -비동기 반복자가 완료될 때까지 `result.stream_events()`를 계속 소비하세요. 반복자가 끝나기 전까지 스트리밍 실행은 완료된 것이 아니며, 세션 지속성, 승인 기록 관리 또는 기록 압축과 같은 후처리는 마지막으로 표시되는 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. +비동기 이터레이터가 완료될 때까지 `result.stream_events()`를 계속 소비해야 합니다. 스트리밍 실행은 이터레이터가 종료될 때까지 완료된 것이 아니며, 세션 영속화, 승인 기록 관리, 기록 압축과 같은 후처리는 마지막으로 표시되는 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. -## 원시 응답 이벤트 +## 가공되지 않은 응답 이벤트 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 객체는 LLM에서 직접 전달된 원시 이벤트를 래핑합니다. 각 객체의 `data` 필드에는 `response.created` 또는 `response.output_text.delta` 같은 유형의 OpenAI Responses API 이벤트가 포함됩니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 객체는 LLM에서 직접 전달된 가공되지 않은 이벤트를 래핑합니다. 각 객체의 `data` 필드에는 `response.created` 또는 `response.output_text.delta` 같은 유형의 OpenAI Responses API 이벤트가 포함됩니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. -컴퓨터 도구의 원시 이벤트는 저장된 결과와 동일하게 프리뷰와 GA를 구분합니다. 프리뷰 흐름은 하나의 `action`이 포함된 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 일괄 처리된 `actions[]`가 포함된 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 인터페이스는 이를 위해 컴퓨터 전용 이벤트 이름을 별도로 추가하지 않습니다. 두 형태 모두 여전히 `tool_called`으로 노출되며, 스크린샷 결과는 `computer_call_output` 항목을 래핑하는 `tool_output`로 반환됩니다. +컴퓨터 도구의 가공되지 않은 이벤트는 저장된 결과와 동일하게 프리뷰와 GA를 구분합니다. 프리뷰 흐름은 하나의 `action`이 있는 `computer_call` 항목을 스트리밍하는 반면, `gpt-5.5`는 일괄 처리된 `actions[]`가 있는 `computer_call` 항목을 스트리밍할 수 있습니다. 상위 수준의 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 인터페이스에는 이를 위한 컴퓨터 전용 이벤트 이름이 별도로 추가되지 않습니다. 두 형식 모두 계속 `tool_called`으로 노출되며, 스크린샷 결과는 `computer_call_output` 항목을 래핑하는 `tool_output`로 반환됩니다. 예를 들어 다음 코드는 LLM이 생성한 텍스트를 토큰 단위로 출력합니다. @@ -39,9 +39,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 스트리밍과 승인 +## 스트리밍 및 승인 -스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요한 경우 `result.stream_events()`가 완료되고, 보류 중인 승인은 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. `result.to_state()`를 사용해 결과를 [`RunState`][agents.run_state.RunState]으로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`으로 재개하세요. +스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요하면 `result.stream_events()`가 완료되고, 보류 중인 승인은 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. `result.to_state()`를 사용하여 결과를 [`RunState`][agents.run_state.RunState]로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`으로 재개합니다. ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,47 +57,49 @@ if result.interruptions: pass ``` -전체 일시 중지 및 재개 과정은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참조하세요. +전체 일시 중지 및 재개 과정은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참고하세요. ## 현재 턴 이후 스트리밍 취소 -스트리밍 실행을 도중에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출하세요. 기본적으로 실행은 즉시 중지됩니다. 중지하기 전에 현재 턴이 정상적으로 완료되도록 하려면 대신 `result.cancel(mode="after_turn")`를 호출하세요. +진행 중인 스트리밍 실행을 중간에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출합니다. 기본적으로 실행이 즉시 중지됩니다. 중지하기 전에 현재 턴이 정상적으로 완료되도록 하려면 대신 `result.cancel(mode="after_turn")`를 호출합니다. -`result.stream_events()`가 완료되기 전까지 스트리밍 실행은 완료된 것이 아닙니다. 마지막으로 표시되는 토큰 이후에도 SDK에서 세션 항목을 저장하거나, 승인 상태를 확정하거나, 기록을 압축하고 있을 수 있습니다. +스트리밍 실행은 `result.stream_events()`가 완료될 때까지 완료된 것이 아닙니다. 마지막으로 표시되는 토큰 이후에도 SDK에서 세션 항목을 영속화하거나, 승인 상태를 확정하거나, 기록을 압축하고 있을 수 있습니다. -[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하는 중이고 `cancel(mode="after_turn")`가 도구 턴 이후 중지되는 경우, 즉시 새로운 사용자 턴을 추가하는 대신 정규화된 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 기존 사용자 턴을 계속 진행하세요. -- 도구 승인을 위해 스트리밍 실행이 중지된 경우 이를 새로운 턴으로 처리하지 마세요. 스트림 소비를 끝까지 완료하고 `result.interruptions`을 확인한 다음 `result.to_state()`에서 재개하세요. -- 다음 모델 호출 전에 가져온 세션 기록과 새로운 사용자 입력을 병합하는 방식을 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]를 사용하세요. 여기에서 새로운 턴의 항목을 다시 작성하면 다시 작성된 버전이 해당 턴에 저장됩니다. +[`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list]에서 수동으로 계속 진행하는 중에 도구 턴 이후 `cancel(mode="after_turn")`이 중지된 경우, 곧바로 새 사용자 턴을 추가하지 말고 정규화된 해당 입력으로 `result.last_agent`를 다시 실행하여 완료되지 않은 기존 사용자 턴을 계속합니다. -## 실행 항목 이벤트와 에이전트 이벤트 +- 완료되지 않은 실행이 재개되기 전에 새 사용자 입력이 도착하면, 끝까지 소비한 결과를 `result.to_state()`으로 변환하고 [`state.add_input(...)`][agents.run_state.RunState.add_input]을 호출한 후 해당 상태에서 재개합니다. 러너는 다음 모델 호출 직전에 준비된 입력을 반영합니다. [재개 전 입력 추가](results.md#add-input-before-resuming)를 참고하세요. +- 스트리밍 실행이 도구 승인을 위해 중지된 경우 이를 새 턴으로 취급하지 마세요. 스트림을 끝까지 소비하고 `result.interruptions`를 검사한 다음 `result.to_state()`에서 재개합니다. +- 다음 모델 호출 전에 조회된 세션 기록과 새 사용자 입력을 병합하는 방식을 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용합니다. 여기에서 새 턴 항목을 다시 작성하면 다시 작성된 버전이 해당 턴에 영속화됩니다. -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]은 상위 수준의 이벤트입니다. 항목 생성이 완전히 완료되면 이를 알려줍니다. 따라서 각 토큰 대신 "메시지 생성 완료", "도구 실행 완료" 등의 수준에서 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과로 변경될 때) 업데이트를 제공합니다. +## 실행 항목 이벤트 및 에이전트 이벤트 + +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 상위 수준의 이벤트입니다. 항목이 완전히 생성되었을 때 이를 알려 줍니다. 따라서 각 토큰 대신 "메시지 생성됨", "도구 실행됨" 등의 수준으로 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때 업데이트를 제공합니다(예: 핸드오프의 결과). ### 실행 항목 이벤트 이름 -`RunItemStreamEvent.name`는 고정된 의미론적 이벤트 이름 집합을 사용합니다. +`RunItemStreamEvent.name`는 정해진 의미론적 이벤트 이름 집합을 사용합니다. -- `message_output_created` -- `handoff_requested` -- `handoff_occured` -- `tool_called` -- `tool_search_called` -- `tool_search_output_created` -- `tool_output` -- `reasoning_item_created` -- `mcp_approval_requested` -- `mcp_approval_response` -- `mcp_list_tools` +- `message_output_created` +- `handoff_requested` +- `handoff_occured` +- `tool_called` +- `tool_search_called` +- `tool_search_output_created` +- `tool_output` +- `reasoning_item_created` +- `mcp_approval_requested` +- `mcp_approval_response` +- `mcp_list_tools` `handoff_occured`는 이전 버전과의 호환성을 위해 의도적으로 철자가 잘못 표기되어 있습니다. -핸드오프 호출은 `handoff_requested`로만 발생하며, `tool_called`로도 함께 발생하지는 않습니다. 동일한 턴의 일반 함수 도구 호출은 계속 `tool_called`을 발생시킵니다. +핸드오프 호출은 `handoff_requested`으로만 내보내지며, `tool_called`로도 내보내지는 것은 아닙니다. 동일한 턴의 일반 함수 도구 호출은 계속 `tool_called`를 내보냅니다. -호스티드 툴 검색을 사용하는 경우 모델이 도구 검색 요청을 실행할 때 `tool_search_called`이 발생하고, Responses API가 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 발생합니다. +호스티드 툴 검색을 사용하면 모델에서 도구 검색 요청을 실행할 때 `tool_search_called`이 내보내지고, Responses API에서 로드된 하위 집합을 반환할 때 `tool_search_output_created`가 내보내집니다. -프로그래밍 방식 도구 호출에서는 생성된 `program`와 프로그램 소유의 일반 하위 도구 호출에 대해 `tool_called`이 발생합니다. 하위 도구 출력과 생성된 `program`에 대응하는 `program_output`에 대해서는 `tool_output`가 발생합니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외입니다. 이 항목들은 각각 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]와 [`MCPListToolsItem`][agents.items.MCPListToolsItem]를 래핑하는 `mcp_approval_requested` 및 `mcp_list_tools`로 발생합니다. 나머지 항목을 구분하려면 원시 항목의 `type`를 확인하세요. 프로그램 소유의 하위 호출에는 유형이 `program`이고 호출자 ID가 상위 프로그램을 식별하는 `caller`도 포함됩니다. +프로그래밍 방식 도구 호출을 사용하면 생성된 `program`과 프로그램이 소유한 일반 하위 도구 호출에 대해 `tool_called`가 내보내집니다. 하위 도구 출력과 생성된 `program`와 일치하는 `program_output`에 대해서는 `tool_output`이 내보내집니다. 프로그램이 소유한 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외입니다. 이들은 각각 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 및 [`MCPListToolsItem`][agents.items.MCPListToolsItem]을 래핑하는 `mcp_approval_requested` 및 `mcp_list_tools`으로 내보내집니다. 나머지 항목을 구분하려면 가공되지 않은 항목의 `type`을 검사하세요. 프로그램이 소유한 하위 호출에는 유형이 `program`이고 호출자 ID가 상위 프로그램을 식별하는 `caller`도 포함됩니다. -예를 들어 다음 코드는 원시 이벤트를 무시하고 업데이트를 사용자에게 스트리밍합니다. +예를 들어 다음 코드는 가공되지 않은 이벤트를 무시하고 사용자에게 업데이트를 스트리밍합니다. ```python import asyncio diff --git a/docs/ko/usage.md b/docs/ko/usage.md index 83f8a80a63..0aba0bf46f 100644 --- a/docs/ko/usage.md +++ b/docs/ko/usage.md @@ -4,7 +4,7 @@ search: --- # 사용량 -Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 액세스하여 비용을 모니터링하거나, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. +Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 접근하여 비용을 모니터링하고, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. ## 추적 항목 @@ -12,14 +12,15 @@ Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. - **input_tokens**: 전송된 총 입력 토큰 수 - **output_tokens**: 수신된 총 출력 토큰 수 - **total_tokens**: 입력 + 출력 -- **request_usage_entries**: 요청별 사용량 분석 목록 +- **request_usage_entries**: 요청별 사용량 상세 내역 목록 - **details**: - `input_tokens_details.cached_tokens` + - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 실행에서 사용량 액세스 +## 실행에서 사용량 접근 -`Runner.run(...)` 실행 후 `result.context_wrapper.usage`을 통해 사용량에 액세스합니다. +`Runner.run(...)` 이후에는 `result.context_wrapper.usage`를 통해 사용량에 접근합니다. ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -33,14 +34,14 @@ print("Total tokens:", usage.total_tokens) 사용량은 도구 호출이나 핸드오프를 생성하는 모델 호출을 포함하여 실행 중 발생한 모든 모델 호출에 걸쳐 집계됩니다. -### 서드 파티 어댑터에서 사용량 활성화 +### 서드파티 어댑터의 사용량 활성화 -사용량 보고 방식은 서드 파티 어댑터와 공급자 백엔드에 따라 다릅니다. 서드 파티 어댑터를 통해 모델에 액세스하고 정확한 `result.context_wrapper.usage` 값이 필요한 경우 다음을 참고하세요. +사용량 보고 방식은 서드파티 어댑터와 제공자 백엔드에 따라 다릅니다. 서드파티 어댑터를 통해 모델에 접근하면서 정확한 `result.context_wrapper.usage` 값이 필요한 경우: -- `AnyLLMModel` 사용 시 업스트림 공급자가 사용량을 반환하면 자동으로 전파됩니다. Chat Completions 백엔드에서 응답을 스트리밍할 때 사용량 청크가 생성되도록 하려면 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. -- `LitellmModel` 사용 시 일부 공급자 백엔드는 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`이 필요한 경우가 많습니다. +- `AnyLLMModel`에서는 업스트림 제공자가 사용량을 반환할 경우 자동으로 전파됩니다. Chat Completions 백엔드에서 응답을 스트리밍할 때 사용량 청크가 출력되도록 하려면 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. +- `LitellmModel`에서는 일부 제공자 백엔드가 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`가 필요한 경우가 많습니다. -모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 검토하고, 배포하려는 정확한 공급자 백엔드에서 사용량 보고를 검증하세요. +Models 가이드의 [서드파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 검토하고, 배포하려는 정확한 제공자 백엔드에서 사용량 보고를 검증하세요. ## 요청별 사용량 추적 @@ -53,7 +54,30 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 세션에서 사용량 액세스 +## 제공자 사용량 페이로드 보존 + +Agents SDK는 제공자 사용량을 여러 모델 제공자에 걸쳐 일관된 합계를 제공하는 [`Usage`][agents.usage.Usage] 필드로 정규화합니다. 애플리케이션에서 제공자별 사용량 필드를 유지하거나, 생략된 필드와 제공자가 보고한 0을 구분해야 하는 경우 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage]를 `True`으로 설정합니다. + +```python +from agents import Agent, ModelSettings, Runner + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(preserve_raw_usage=True), +) +result = await Runner.run(agent, "What's the weather in Tokyo?") + +for response in result.raw_responses: + print(response.raw_usage) +``` + +Agents SDK는 각 모델 호출의 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 값을 제공자 페이로드에서 분리된 JSON 호환 스냅샷으로 저장합니다. Agents SDK는 실행 전체에 걸쳐 `raw_usage`를 집계하지 않습니다. 보존이 비활성화되어 있거나, 제공자가 사용량 페이로드를 반환하지 않거나, 업스트림 어댑터가 원래 필드의 존재 여부 정보를 이미 폐기한 경우 값은 `None`으로 유지됩니다. + +`preserve_raw_usage`은 모델 어댑터에 도달한 사용량 페이로드만 보존하며, 이 설정은 제공자에게 사용량을 요청하지 않습니다. 스트리밍 Chat Completions 제공자가 명시적인 사용량 요청을 요구하는 경우 `ModelSettings(include_usage=True)`도 설정합니다. + +현재 `LitellmModel`는 스트리밍 또는 비스트리밍 실행 모두에서 `ModelResponse.raw_usage`을 채우지 않으므로 해당 어댑터에서는 `preserve_raw_usage=True`이 효과가 없습니다. `LitellmModel`을 사용할 때는 정규화된 [`Usage`][agents.usage.Usage] 필드를 계속 사용하거나, 제공자별 필드의 존재 여부가 필요한 경우 raw 사용량 보존을 지원하는 어댑터를 선택하세요. + +## 세션에서 사용량 접근 `Session`(예: `SQLiteSession`)을 사용하면 `Runner.run(...)`에 대한 각 호출이 해당 실행의 사용량을 반환합니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만, 각 실행의 사용량은 독립적입니다. @@ -67,11 +91,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -세션은 실행 간 대화 컨텍스트를 유지하지만 각 `Runner.run()` 호출에서 반환되는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행의 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. +세션은 실행 사이에 대화 컨텍스트를 보존하지만, 각 `Runner.run()` 호출이 반환하는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행의 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. ## 훅에서 사용량 활용 -`RunHooks`을 사용하는 경우 각 훅에 전달되는 `context` 객체에는 `usage`이 포함됩니다. 이를 통해 주요 수명 주기 시점의 사용량을 기록할 수 있습니다. +`RunHooks`을 사용하는 경우 각 훅에 전달되는 `context` 객체에는 `usage`이 포함됩니다. 이를 통해 주요 수명 주기 시점에 사용량을 기록할 수 있습니다. ```python class MyHooks(RunHooks): @@ -82,9 +106,9 @@ class MyHooks(RunHooks): ## API 레퍼런스 -자세한 API 문서는 다음을 참고하세요. +자세한 API 문서는 다음을 참조하세요. - [`Usage`][agents.usage.Usage] - 사용량 추적 데이터 구조 -- [`RequestUsage`][agents.usage.RequestUsage] - 요청별 사용량 세부 정보 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 실행 컨텍스트에서 사용량 액세스 +- [`RequestUsage`][agents.usage.RequestUsage] - 요청별 사용량 상세 정보 +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 실행 컨텍스트에서 사용량 접근 - [`RunHooks`][agents.run.RunHooks] - 사용량 추적 수명 주기에 훅 연결 \ No newline at end of file diff --git a/docs/ko/voice/pipeline.md b/docs/ko/voice/pipeline.md index 4f8e9925b6..414c02da77 100644 --- a/docs/ko/voice/pipeline.md +++ b/docs/ko/voice/pipeline.md @@ -4,7 +4,7 @@ search: --- # 파이프라인 및 워크플로 -[`VoicePipeline`][agents.voice.pipeline.VoicePipeline]은 에이전트 워크플로를 음성 앱으로 쉽게 전환할 수 있게 해주는 클래스입니다. 실행할 워크플로를 전달하면 파이프라인이 입력 오디오 전사, 오디오 종료 감지, 적절한 시점의 워크플로 호출, 워크플로 출력을 다시 오디오로 변환하는 작업을 처리합니다. +[`VoicePipeline`][agents.voice.pipeline.VoicePipeline]는 에이전트 워크플로를 음성 앱으로 쉽게 전환할 수 있게 해 주는 클래스입니다. 실행할 워크플로를 전달하면 파이프라인이 입력 오디오 변환, 오디오 종료 감지, 적절한 시점의 워크플로 호출, 워크플로 출력의 오디오 변환을 처리합니다. ```mermaid graph LR @@ -34,30 +34,32 @@ graph LR ## 파이프라인 구성 -파이프라인을 생성할 때 다음과 같은 항목을 설정할 수 있습니다. +파이프라인을 생성할 때 다음과 같은 몇 가지 항목을 설정할 수 있습니다. -1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase]은 새 오디오가 전사될 때마다 실행되는 코드입니다. +1. 새 오디오가 텍스트로 변환될 때마다 실행되는 코드인 [`workflow`][agents.voice.workflow.VoiceWorkflowBase] 2. 사용할 [`speech-to-text`][agents.voice.model.STTModel] 및 [`text-to-speech`][agents.voice.model.TTSModel] 모델 3. 다음과 같은 항목을 구성할 수 있는 [`config`][agents.voice.pipeline_config.VoicePipelineConfig] - - 모델 이름을 모델에 매핑할 수 있는 모델 제공자 - - 트레이싱 비활성화 여부, 오디오 파일 업로드 여부, 워크플로 이름, 트레이스 ID 등을 포함한 트레이싱 설정 + - 모델 이름을 모델에 매핑할 수 있는 모델 공급자 + - 트레이싱 비활성화 여부, 오디오 파일 업로드 여부, 워크플로 이름, trace ID 등을 포함한 트레이싱 설정 - 프롬프트, 언어, 사용되는 데이터 유형과 같은 TTS 및 STT 모델 설정 ## 파이프라인 실행 -[`run()`][agents.voice.pipeline.VoicePipeline.run] 메서드를 통해 파이프라인을 실행할 수 있으며, 다음 두 가지 형식으로 오디오 입력을 전달할 수 있습니다. +[`run()`][agents.voice.pipeline.VoicePipeline.run] 메서드를 통해 파이프라인을 실행할 수 있으며, 다음 두 가지 형태로 오디오 입력을 전달할 수 있습니다. -1. [`AudioInput`][agents.voice.input.AudioInput]은 완전한 오디오 입력이 있고 해당 입력에 대한 결과만 생성하려는 경우에 사용합니다. 화자가 말하기를 마친 시점을 감지할 필요가 없는 경우에 유용합니다. 예를 들어 사전 녹음된 오디오가 있거나 사용자가 말하기를 마친 시점이 명확한 푸시투토크 앱에서 사용할 수 있습니다. -2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]은 사용자가 말하기를 마친 시점을 감지해야 할 수 있는 경우에 사용합니다. 오디오 청크가 감지되는 대로 전달할 수 있으며, 음성 파이프라인은 "활동 감지"라는 프로세스를 통해 적절한 시점에 에이전트 워크플로를 자동으로 실행합니다. +1. [`AudioInput`][agents.voice.input.AudioInput]은 완전한 오디오 입력이 있고 이에 대한 결과만 생성하려는 경우에 사용합니다. 화자가 말을 마쳤는지 감지할 필요가 없는 경우에 유용합니다. 예를 들어 사전 녹음된 오디오가 있거나 사용자가 말을 마친 시점을 명확히 알 수 있는 눌러서 말하기(push-to-talk) 앱에서 사용할 수 있습니다. +2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]은 사용자가 말을 마쳤는지 감지해야 할 수 있는 경우에 사용합니다. 오디오 청크가 감지되는 대로 전달할 수 있으며, 음성 파이프라인은 "활동 감지(activity detection)"라는 프로세스를 통해 적절한 시점에 에이전트 워크플로를 자동으로 실행합니다. ## 결과 -음성 파이프라인 실행 결과는 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]입니다. 이는 이벤트가 발생하는 대로 스트리밍할 수 있는 객체입니다. [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent]에는 다음과 같은 몇 가지 유형이 있습니다. +음성 파이프라인 실행의 결과는 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]입니다. 이 객체를 사용하면 이벤트가 발생하는 대로 스트리밍할 수 있습니다. [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent]에는 다음과 같은 몇 가지 유형이 있습니다. 1. 오디오 청크를 포함하는 [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio] -2. 턴 시작이나 종료와 같은 수명 주기 이벤트를 알려주는 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] +2. 턴 시작 또는 종료와 같은 수명 주기 이벤트를 알려 주는 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 3. 오류 이벤트인 [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError] +애플리케이션이 [`StreamedAudioResult.stream()`][agents.voice.result.StreamedAudioResult.stream]을 사용하는 동안 치명적인 파이프라인 오류가 발생합니다. 그 외에는 정상적으로 실행되었지만 음성-텍스트 변환 세션을 종료하지 못한 경우, 스트림은 무기한 기다리지 않고 해당 종료 오류를 발생시킵니다. 턴이 이미 실패한 상태에서 음성 변환 세션 종료까지 실패한 경우, 스트림은 원래 턴 오류를 기본 오류로 유지합니다. + ```python result = await pipeline.run(input) @@ -78,4 +80,4 @@ async for event in result.stream(): ### 인터럽션(중단 처리) -현재 Agents SDK는 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]에 대한 내장 인터럽션(중단 처리) 기능을 제공하지 않습니다. 대신 감지된 각 턴마다 워크플로가 별도로 실행됩니다. 애플리케이션 내에서 인터럽션(중단 처리)을 처리하려면 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 이벤트를 수신할 수 있습니다. `turn_started`은 새 턴이 전사되어 처리가 시작됨을 나타냅니다. `turn_ended`은 해당 턴의 모든 오디오가 전송된 후 트리거됩니다. 이러한 이벤트를 사용하여 모델이 턴을 시작할 때 화자의 마이크를 음소거하고, 애플리케이션이 해당 턴과 관련된 모든 오디오 재생을 마친 후 음소거를 해제할 수 있습니다. \ No newline at end of file +현재 Agents SDK는 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]에 내장된 인터럽션(중단 처리) 기능을 제공하지 않습니다. 대신 감지된 각 턴이 워크플로의 개별 실행을 트리거합니다. 애플리케이션 내에서 인터럽션(중단 처리)을 처리하려면 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 이벤트를 수신할 수 있습니다. `turn_started`은 새 턴이 텍스트로 변환되어 처리가 시작되고 있음을 나타냅니다. `turn_ended`은 해당 턴의 모든 오디오가 전송된 후 트리거됩니다. 이러한 이벤트를 사용하여 모델이 턴을 시작할 때 화자의 마이크를 음소거하고, 애플리케이션이 해당 턴과 관련된 모든 오디오 재생을 마친 후 음소거를 해제할 수 있습니다. \ No newline at end of file diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index 4626f82596..5a92c207e8 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -4,79 +4,81 @@ search: --- # 安全防护措施 -安全防护措施可用于检查和验证用户输入及智能体输出。例如,假设您有一个智能体,它使用非常智能(因而速度较慢且成本较高)的模型来协助处理客户请求。您不会希望恶意用户要求该模型帮助他们完成数学作业。因此,您可以使用一个快速且成本较低的模型运行安全防护措施。如果安全防护措施检测到恶意使用行为,它可以立即引发错误,从而节省时间和费用。阻塞执行可保证高成本模型不会启动;采用并行执行时,高成本模型可能在安全防护措施完成前就已启动。有关详细信息,请参阅下文的“执行模式”。 +安全防护措施使你能够检查和验证用户输入与智能体输出。例如,假设你有一个使用非常智能(因而速度慢、成本高)的模型来协助处理客户请求的智能体。你不会希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用一个速度快、成本低的模型运行安全防护措施。如果安全防护措施检测到恶意使用,就可以立即引发错误,从而节省时间和成本。阻塞执行可保证高成本模型不会启动;采用并行执行时,高成本模型可能在安全防护措施完成之前就已经启动。有关详细信息,请参阅下文的“执行模式”。 -安全防护措施分为两种: +安全防护措施分为两类: 1. 输入安全防护措施针对初始用户输入运行 -2. 输出安全防护措施针对智能体的最终输出运行 +2. 输出安全防护措施针对最终智能体输出运行 ## 工作流边界 -安全防护措施会附加到智能体和工具,但它们并非都在工作流中的相同节点运行: +安全防护措施附加到智能体和工具,但并非都会在工作流中的相同节点运行: -- **输入安全防护措施**仅针对链中的第一个智能体运行。 -- **输出安全防护措施**仅针对生成最终输出的智能体运行。 -- **工具安全防护措施**会在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 +- **输入安全防护措施**仅针对链中的第一个智能体运行。 +- **输出安全防护措施**仅针对生成最终输出的智能体运行。 +- **工具安全防护措施**会在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 -如果工作流包含管理器、任务转移或受委派的专家,并且您需要在每次自定义函数工具调用之前和/或之后执行检查,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 +如果需要在包含管理者、任务转移或受委派专家的工作流中,于每次自定义函数工具调用之前和/或之后执行检查,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 ## 输入安全防护措施 输入安全防护措施分 3 个步骤运行: 1. 首先,安全防护措施接收传递给智能体的同一输入。 -2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便您适当地回应用户或处理该异常。 +2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装在 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 !!! Note - 输入安全防护措施旨在针对用户输入运行,因此只有当某个智能体是*第一个*智能体时,其安全防护措施才会运行。您可能会想,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施通常与具体的智能体相关——您会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输入安全防护措施旨在针对用户输入运行,因此,仅当某个智能体是*第一个*智能体时,才会运行该智能体的安全防护措施。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施往往与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 ### 执行模式 输入安全防护措施支持两种执行模式: -- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体并发执行。由于两者同时启动,因此这种模式可实现最低延迟。但是,如果安全防护措施的触发器被触发,智能体在被取消前可能已经消耗了 token 并执行了工具。 +- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体执行并发运行。由于二者同时启动,因此这种模式可以实现最低延迟。但是,如果安全防护措施的触发器被触发,智能体可能在取消之前已经消耗了 token 并执行了工具。 -- **阻塞执行**(`run_in_parallel=False`):安全防护措施在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将永远不会执行,从而避免消耗 token 和执行工具。这非常适合优化成本,以及希望避免工具调用产生潜在副作用的场景。 +- **阻塞执行**(`run_in_parallel=False`):安全防护措施在智能体启动*之前*运行并完成。如果安全防护措施触发器被触发,智能体将永远不会执行,从而避免消耗 token 和执行工具。这非常适合成本优化,以及希望避免工具调用可能产生副作用的场景。 ## 输出安全防护措施 输出安全防护措施分 3 个步骤运行: 1. 首先,安全防护措施接收智能体生成的输出。 -2. 接下来,运行安全防护措施函数以生成 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装到 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便您适当地回应用户或处理该异常。 +2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装在 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 !!! Note - 输出安全防护措施旨在针对智能体的最终输出运行,因此只有当某个智能体是*最后一个*智能体时,其安全防护措施才会运行。与输入安全防护措施类似,我们这样做是因为安全防护措施通常与具体的智能体相关——您会为不同的智能体运行不同的安全防护措施,因此将相关代码放在一起有助于提高可读性。 + 输出安全防护措施旨在针对最终智能体输出运行,因此,仅当某个智能体是*最后一个*智能体时,才会运行该智能体的安全防护措施。与输入安全防护措施类似,我们这样做是因为安全防护措施往往与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 输出安全防护措施始终在智能体完成后运行,因此不支持 `run_in_parallel` 参数。 +输出触发器与安全防护措施函数引发的异常具有不同的会话行为。触发器会拒绝候选最终输出。当触发器触发时,运行器会请求已配置的会话持久化已完成的工具调用和工具输出项目,以及重放这些调用所需的任何推理上下文,同时排除被拒绝的候选最终输出。运行器会对流式传输和非流式传输运行应用这项触发器规则。当安全防护措施函数引发异常而不是返回触发器结果时,运行器会将判定视为未知,并请求已配置的会话持久化已完成的最终轮次项目,然后再抛出安全防护措施异常。如果该会话写入也失败,则会话写入错误优先。流式传输运行采用与非流式传输运行相同的持久化顺序,并从 `stream_events()` 引发终止异常。如果在输出安全防护措施运行期间立即调用 [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel],则会取消正在进行的安全防护措施,并且不会启动最终轮次的会话写入。 + ## 工具安全防护措施 -工具安全防护措施会包装 **`FunctionTool` 实例**,让您可以在执行前后验证或阻止对这些工具的调用。它们在工具本身上配置,并在每次调用该工具时运行。 +工具安全防护措施封装**`FunctionTool` 实例**,使你能够在执行前后验证或阻止对这些工具的调用。它们在工具本身上配置,并在每次调用该工具时运行。 -- 输入工具安全防护措施在工具执行前运行,可以跳过调用、使用一条消息替换输出,或触发触发器。 -- 输出工具安全防护措施在工具执行后运行,可以替换输出或触发触发器。 -- 如果函数工具需要审批,输入工具安全防护措施通常会在审批后、执行前立即运行。如果您希望在发出待审批中断之前运行这些输入检查,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此次审批前检查的调用仍会在审批后、工具执行前再次接受检查。 -- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的函数工具。任务转移通过 SDK 的任务转移管道运行,而不是通过常规的函数工具管道运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管道,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前也不直接提供工具安全防护措施选项。 +- 输入工具安全防护措施在工具执行前运行,可以跳过调用、用消息替换输出或引发触发器。 +- 输出工具安全防护措施在工具执行后运行,可以替换输出或引发触发器。 +- 如果函数工具需要审批,输入工具安全防护措施通常会在审批后、执行前立即运行。如果希望这些输入检查在发出待审批中断之前运行,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此次审批前检查的调用仍会在获得审批后、工具执行前再次接受检查。 +- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的函数工具。任务转移通过 SDK 的任务转移管线运行,而不是通过常规函数工具管线运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管线,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 当前不直接提供工具安全防护措施选项。 -有关详细信息,请参阅下面的代码片段。 +有关详细信息,请参阅下方的代码片段。 ## 触发器 -如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即引发 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止智能体执行。工具安全防护措施使用对应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 +如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即引发 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止智能体执行。工具安全防护措施使用相应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 -对于智能体级别的触发器,异常的 `guardrail_result` 会标识触发该触发器的安全防护措施。对于由运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,其中包括触发该触发器的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供等效的累积结果。 +对于智能体级别的触发器,异常的 `guardrail_result` 用于标识触发该触发器的安全防护措施。对于运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,包括触发该触发器的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供等效的累积结果。 -工具触发器异常则直接公开触发异常的 `guardrail` 和 `output`。其 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留故障发生前已完成轮次中累积的结果;触发异常的结果可通过异常的 `output` 获取。其他由运行器管理的故障(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。`stream_events()` 引发异常后,流式结果会公开同样的智能体和工具安全防护措施累积结果列表。如果异常是在由运行器管理的执行路径之外引发的,`run_data` 可以是 `None`。 +工具触发器异常则会直接公开触发该异常的 `guardrail` 和 `output`。它们的 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留失败前已完成轮次中累积的结果;触发结果可通过异常的 `output` 获取。其他由运行器管理的失败(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。`stream_events()` 引发异常后,流式传输结果会公开相同的累积智能体和工具安全防护措施结果列表。当异常在运行器管理的执行路径之外引发时,`run_data` 可以是 `None`。 ## 安全防护措施的实现 -您需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在内部运行一个智能体来实现这一点。 +你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将通过在底层运行一个智能体来实现。 ```python from pydantic import BaseModel diff --git a/docs/zh/human_in_the_loop.md b/docs/zh/human_in_the_loop.md index 918e5daeb2..d46cbe3ed1 100644 --- a/docs/zh/human_in_the_loop.md +++ b/docs/zh/human_in_the_loop.md @@ -4,19 +4,19 @@ search: --- # 人工介入 -使用人工介入(HITL)流程暂停智能体执行,直到有人批准或拒绝敏感工具调用。工具会声明其何时需要审批,运行结果会以中断项的形式显示待处理的审批,而 `RunState` 可让你序列化已暂停的运行,并在作出决定后恢复运行。 +使用人工介入(HITL)流程暂停智能体执行,直到人员批准或拒绝敏感的工具调用。工具会声明其何时需要审批,运行结果会以中断项的形式呈现待处理的审批,而 `RunState` 允许你序列化已暂停的运行,并在做出决策后恢复运行。 -该审批机制适用于整个运行,并不限于当前的顶层智能体。无论工具属于当前智能体、通过任务转移到达的智能体,还是嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 执行,都采用相同的模式。在嵌套的 `Agent.as_tool()` 情况下,中断仍会显示在外层运行中,因此你需要在外层 `RunState` 上批准或拒绝它,然后恢复原始顶层运行。 +该审批机制覆盖整个运行,并不限于当前的顶层智能体。无论工具属于当前智能体、通过任务转移到达的智能体,还是嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 执行,都适用相同的模式。在嵌套 `Agent.as_tool()` 的情况下,中断仍会呈现在外层运行中,因此你需要在外层 `RunState` 上批准或拒绝它,然后恢复原始的顶层运行。 -使用 `Agent.as_tool()` 时,审批可能发生在两个不同层级:智能体工具本身可以通过 `Agent.as_tool(..., needs_approval=...)` 要求审批,而嵌套智能体中的工具可以在嵌套运行开始后提出各自的审批请求。二者都通过相同的外层运行中断流程处理。 +使用 `Agent.as_tool()` 时,审批可能发生在两个不同层级:智能体工具本身可以通过 `Agent.as_tool(..., needs_approval=...)` 要求审批,而嵌套智能体中的工具也可能在嵌套运行开始后发起自己的审批请求。这两种情况都通过相同的外层运行中断流程处理。 -本页重点介绍通过 `interruptions` 进行的人工审批流程。如果你的应用可以通过代码作出决定,某些工具类型也支持程序化审批回调,使运行无需暂停即可继续。 +本页重点介绍通过 `interruptions` 实现的手动审批流程。如果你的应用可以通过代码做出决策,某些工具类型还支持程序化审批回调,使运行无需暂停即可继续。 -## 需要审批的工具标记 +## 需审批工具的标记 -将 `needs_approval` 设置为 `True`,可始终要求审批;也可以提供一个异步函数,按每次调用作出决定。该可调用对象会接收运行上下文、已解析的工具参数和工具调用 ID。 +将 `needs_approval` 设置为 `True` 可始终要求审批,也可以提供一个异步函数,针对每次调用分别做出决策。该可调用对象会接收运行上下文、解析后的工具参数和工具调用 ID。 -当 SDK 无法安全检查参数时,可调用的审批规则会采取默认拒绝策略。如果参数是格式错误的 JSON、是有效 JSON 但并非对象(例如 `null` 或列表),或者包含 `NaN`、`Infinity` 或 `-Infinity` 等非标准常量,则不会调用该可调用对象,并且该调用需要人工审批。Runner 和 Realtime 工具调用的行为相同。 +当 SDK 无法安全检查参数时,可调用审批规则会采用失败关闭策略。如果参数是格式错误的 JSON、是有效的 JSON 但不是对象(例如 `null` 或列表),或包含 `NaN`、`Infinity` 或 `-Infinity` 等非标准常量,则不会调用该可调用对象,并且该调用需要手动审批。Runner 和 Realtime 工具调用的行为相同。 ```python from agents import Agent @@ -44,28 +44,30 @@ agent = Agent( ) ``` -`needs_approval` 可用于 [`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool]。本地 MCP服务器也通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse] 和 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 上的 `require_approval` 支持审批。托管式 MCP服务器通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 支持审批,该工具使用 `tool_config={"require_approval": "always"}` 和可选的 `on_approval_request` 回调。如果你希望自动批准或自动拒绝,而不触发中断,Shell 和 apply_patch 工具可接受 `on_approval` 回调。 +[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool] 均提供 `needs_approval`。本地 MCP 服务器也支持通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse] 和 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 上的 `require_approval` 进行审批。托管的 MCP 服务器通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 支持审批,其中使用 `tool_config={"require_approval": "always"}`,并可选择提供 `on_approval_request` 回调。如果你希望自动批准或自动拒绝,而不呈现中断项,Shell 和 apply_patch 工具可接受 `on_approval` 回调。 -## 审批流程的工作原理 +## 审批流程 -1. 当模型发出工具调用时,运行器会评估其审批规则(`needs_approval`、`require_approval` 或托管式 MCP 的对应规则)。 -2. 如果该工具调用的审批决定已存储在 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 中,运行器将直接继续执行,不再提示。每次调用的审批仅适用于特定调用 ID;传入 `always_approve=True` 或 `always_reject=True`,可在本次运行剩余期间,为以后对该工具的调用保留相同决定。 -3. 如果审批规则要求审批,但尚未存储该工具调用的决定,执行会暂停,并且 `RunResult.interruptions`(或 `RunResultStreaming.interruptions`)会包含 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 条目,其中具有 `agent.name`、`tool_name` 和 `arguments` 等详细信息。这包括任务转移后或嵌套 `Agent.as_tool()` 执行中提出的审批请求。 -4. 使用 `result.to_state()` 将结果转换为 `RunState`,调用 `state.approve(...)` 或 `state.reject(...)`,然后使用 `Runner.run(agent, state)` 或 `Runner.run_streamed(agent, state)` 恢复运行,其中 `agent` 是该运行的原始顶层智能体。 -5. 恢复后的运行会从暂停处继续;如果需要新的审批,则会再次进入此流程。 +1. 当模型发出工具调用时,运行器会评估其审批规则(`needs_approval`、`require_approval` 或托管 MCP 的对应规则)。 +2. 如果该工具调用的审批决策已存储在 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 中,运行器将继续执行而不再提示。单次调用审批的作用域限定于特定调用 ID;传入 `always_approve=True` 或 `always_reject=True`,可在本次运行的剩余期间,为后续对同一工具标识的调用保留相同决策。 +3. 如果审批规则要求审批,并且尚未存储该工具调用的决策,执行将暂停,`RunResult.interruptions`(或 `RunResultStreaming.interruptions`)会包含 [`ToolApprovalItem`][agents.items.ToolApprovalItem] 条目,其中包含 `agent.name`、`tool_name` 和 `arguments` 等详细信息。这也包括任务转移之后或嵌套 `Agent.as_tool()` 执行内部发起的审批。 +4. 使用 `result.to_state()` 将结果转换为 `RunState`,调用 `state.approve(...)` 或 `state.reject(...)`,然后使用 `Runner.run(agent, state)` 或 `Runner.run_streamed(agent, state)` 恢复运行,其中 `agent` 是该次运行的原始顶层智能体。 +5. 恢复后的运行会从暂停处继续,并在需要新的审批时重新进入此流程。 -使用 `always_approve=True` 或 `always_reject=True` 创建的持久决定会存储在运行状态中,因此之后恢复同一已暂停的运行时,它们可以在 `state.to_string()` / `RunState.from_string(...)` 和 `state.to_json()` / `RunState.from_json(...)` 过程中继续保留。 +使用 `always_approve=True` 或 `always_reject=True` 创建的持久决策会存储在运行状态中,因此当你之后恢复同一个已暂停的运行时,这些决策在经过 `state.to_string()` / `RunState.from_string(...)` 和 `state.to_json()` / `RunState.from_json(...)` 后仍然有效。 -你不必在同一次处理中解决所有待审批项。`interruptions` 可以同时包含常规函数工具、托管式 MCP 审批和嵌套的 `Agent.as_tool()` 审批。如果你仅批准或拒绝部分条目后重新运行,已解决的调用可以继续执行,而未解决的调用会保留在 `interruptions` 中,并再次暂停运行。 +对于来自 [`HostedMCPTool`][agents.tool.HostedMCPTool] 的审批请求,Agents SDK 使用 `server_label` 与工具名称的组合来标识持久工具决策。在一个托管 MCP 服务器上对 `lookup_account` 做出的始终批准决策,不会批准另一个服务器上同名的工具。只有当托管 MCP 审批请求包含两个非空标识字段时,Agents SDK 才会持久保存始终批准或始终拒绝的决策。 + +你不必在同一轮处理中解决所有待处理审批。`interruptions` 可以同时包含常规函数工具、托管 MCP 审批以及嵌套的 `Agent.as_tool()` 审批。如果你仅批准或拒绝部分项目后重新运行,已解决的调用可以继续,而未解决的调用仍会保留在 `interruptions` 中,并再次暂停运行。 ## 自定义拒绝消息 默认情况下,被拒绝的工具调用会将 SDK 的标准拒绝文本返回到运行中。你可以在两个层级自定义该消息: -- 整个运行的回退设置:设置 [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter],以控制整个运行中审批被拒绝时模型可见的默认消息。 -- 单次调用覆盖:如果希望某个特定的被拒绝工具调用显示不同的消息,请将 `rejection_message=...` 传给 `state.reject(...)`。 +- 全运行范围的后备设置:设置 [`RunConfig.tool_error_formatter`][agents.run.RunConfig.tool_error_formatter],以控制整个运行中审批遭拒时默认向模型显示的消息。 +- 单次调用覆盖:如果你希望某个特定的被拒绝工具调用呈现不同消息,请向 `state.reject(...)` 传入 `rejection_message=...`。 -如果二者都已提供,则单次调用的 `rejection_message` 优先于整个运行的格式化程序。 +如果两者都已提供,则单次调用的 `rejection_message` 优先于全运行范围的格式化器。 ```python from agents import RunConfig, ToolErrorFormatterArgs @@ -88,25 +90,25 @@ state.reject( 有关同时展示这两个层级的完整代码示例,请参阅 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)。 -## 自动审批决定 +## 自动审批决策 -手动处理 `interruptions` 是最通用的模式,但并非唯一模式: +手动 `interruptions` 是最通用的模式,但并非唯一方式: -- 本地 [`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool] 可以使用 `on_approval`,立即在代码中批准或拒绝。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] 可以结合使用 `tool_config={"require_approval": "always"}` 和 `on_approval_request`,作出同类程序化决定。 +- 本地 [`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool] 可以使用 `on_approval`,在代码中立即批准或拒绝。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] 可以结合使用 `tool_config={"require_approval": "always"}` 与 `on_approval_request`,做出同类程序化决策。 - 普通 [`function_tool`][agents.tool.function_tool] 工具和 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 使用本页介绍的手动中断流程。 -当这些回调返回决定时,运行会继续,而无需暂停以等待人工响应。对于 Realtime 和语音会话 API,请参阅 [Realtime 指南](realtime/guide.md)中的审批流程。 +当这些回调返回决策时,运行会继续,而无需暂停等待人工响应。对于 Realtime 和语音会话 API,请参阅 [Realtime 指南](realtime/guide.md)中的审批流程。 ## 流式传输与会话 -相同的中断流程也适用于流式运行。流式运行暂停后,继续使用 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events],直到迭代器结束;然后检查 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]、处理中断项,并使用 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] 恢复运行,以使恢复后的输出继续进行流式传输。有关该模式的流式版本,请参阅[流式传输](streaming.md)。 +同一中断流程也适用于流式运行。流式运行暂停后,应持续消费 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events],直到迭代器结束;然后检查 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]、解决其中的中断项,并在希望恢复后的输出继续进行流式传输时,使用 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] 恢复。有关此模式的流式版本,请参阅[流式传输](streaming.md)。 -如果你还在使用会话,从 `RunState` 恢复时,请继续传入同一个会话实例,或传入另一个为相同会话 ID 和后端存储配置的会话对象。恢复后的轮次随后会追加到同一份已存储的对话历史中。有关会话生命周期的详细信息,请参阅[会话](sessions/index.md)。 +如果你还使用了会话,请在从 `RunState` 恢复时继续传入同一个会话实例,或者传入针对相同会话 ID 和后端存储配置的另一个会话对象。恢复后的轮次随后会追加到同一份已存储的对话历史中。有关会话生命周期的详细信息,请参阅[会话](sessions/index.md)。 -## 示例:暂停、批准与恢复 +## 暂停、批准与恢复示例 -下面的代码片段与 JavaScript HITL 指南中的流程一致:当工具需要审批时暂停,将状态持久化到磁盘,重新加载状态,并在收集到决定后恢复运行。 +下面的代码片段与 JavaScript HITL 指南采用相同流程:它会在工具需要审批时暂停,将状态持久化到磁盘,重新加载状态,并在收集决策后恢复运行。 ```python import asyncio @@ -171,35 +173,35 @@ if __name__ == "__main__": asyncio.run(main()) ``` -在此示例中,`prompt_approval` 是同步函数,因为它使用 `input()`,并通过 `run_in_executor(...)` 执行。如果你的审批来源本身已经是异步的(例如 HTTP 请求或异步数据库查询),则可以使用 `async def` 函数,并直接对其执行 `await`。 +在此示例中,`prompt_approval` 是同步的,因为它使用 `input()`,并通过 `run_in_executor(...)` 执行。如果你的审批来源已经是异步的(例如 HTTP 请求或异步数据库查询),则可以改用 `async def` 函数,并直接对其使用 `await`。 -要在可能因审批而暂停的运行中使用流式传输,请调用 `Runner.run_streamed`,持续使用 `result.stream_events()` 直至完成,然后执行上文所示的相同 `result.to_state()` 和恢复步骤。 +若要在可能因审批而暂停的运行中使用流式传输,请调用 `Runner.run_streamed`,消费 `result.stream_events()` 直至其完成,然后执行与上述相同的 `result.to_state()` 和恢复步骤。 ## 仓库模式与代码示例 -- **流式审批**:`examples/agent_patterns/human_in_the_loop_stream.py` 展示如何完整消费 `stream_events()`,然后批准待处理的工具调用,再使用 `Runner.run_streamed(agent, state)` 恢复运行。 -- **自定义拒绝文本**:`examples/agent_patterns/human_in_the_loop_custom_rejection.py` 展示审批被拒绝时,如何将运行级 `tool_error_formatter` 与单次调用的 `rejection_message` 覆盖结合使用。 -- **智能体作为工具的审批**:当委派给智能体的任务需要审核时,`Agent.as_tool(..., needs_approval=...)` 会应用相同的中断流程。嵌套中断仍会显示在外层运行中,因此应恢复原始顶层智能体,而非嵌套智能体。 -- **本地 Shell 和 apply_patch 工具**:`ShellTool` 和 `ApplyPatchTool` 也支持 `needs_approval`。使用 `state.approve(interruption, always_approve=True)` 或 `state.reject(..., always_reject=True)`,可在本次运行剩余期间缓存决定,供以后对该工具的调用使用。对于自动决定,请提供 `on_approval`(参阅 `examples/tools/shell.py`);对于手动决定,请处理中断项(参阅 `examples/tools/shell_human_in_the_loop.py`)。托管式 Shell 环境不支持 `needs_approval` 或 `on_approval`;请参阅[工具指南](tools.md)。 -- **本地 MCP服务器**:使用 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` 上的 `require_approval`,对 MCP 工具调用设置审批门控(参阅 `examples/mcp/get_all_mcp_tools_example/main.py` 和 `examples/mcp/tool_filter_example/main.py`)。 -- **托管式 MCP服务器**:在 `HostedMCPTool` 上设置 `tool_config={"require_approval": "always"}` 以强制执行 HITL,也可以选择提供 `on_approval_request` 以自动批准或拒绝(参阅 `examples/hosted_mcp/human_in_the_loop.py` 和 `examples/hosted_mcp/on_approval.py`)。对于可信服务器,请使用 `"never"`(`examples/hosted_mcp/simple.py`)。 -- **会话与记忆**:将会话传给 `Runner.run`,使审批和对话历史能够跨多个轮次保留。SQLite 和 OpenAI Conversations 会话变体位于 `examples/memory/memory_session_hitl_example.py` 和 `examples/memory/openai_session_hitl_example.py` 中。 +- **流式审批**:`examples/agent_patterns/human_in_the_loop_stream.py` 展示了如何完整消费 `stream_events()`,然后批准待处理的工具调用,最后使用 `Runner.run_streamed(agent, state)` 恢复运行。 +- **自定义拒绝文本**:`examples/agent_patterns/human_in_the_loop_custom_rejection.py` 展示了在审批被拒绝时,如何将运行级 `tool_error_formatter` 与单次调用的 `rejection_message` 覆盖设置结合使用。 +- **智能体工具审批**:当委托给智能体的任务需要审核时,`Agent.as_tool(..., needs_approval=...)` 会应用相同的中断流程。嵌套中断仍会呈现在外层运行中,因此应恢复原始顶层智能体,而不是嵌套智能体。 +- **本地 Shell 和 apply_patch 工具**:`ShellTool` 和 `ApplyPatchTool` 也支持 `needs_approval`。使用 `state.approve(interruption, always_approve=True)` 或 `state.reject(..., always_reject=True)`,可在本次运行的剩余期间为该工具的后续调用缓存决策。对于自动决策,请提供 `on_approval`(参阅 `examples/tools/shell.py`);对于手动决策,请处理中断项(参阅 `examples/tools/shell_human_in_the_loop.py`)。托管的 Shell 环境不支持 `needs_approval` 或 `on_approval`;请参阅[工具指南](tools.md)。 +- **本地 MCP 服务器**:在 `MCPServerStdio` / `MCPServerSse` / `MCPServerStreamableHttp` 上使用 `require_approval` 控制 MCP 工具调用(参阅 `examples/mcp/get_all_mcp_tools_example/main.py` 和 `examples/mcp/tool_filter_example/main.py`)。 +- **托管 MCP 服务器**:在 `HostedMCPTool` 上设置 `tool_config={"require_approval": "always"}` 以强制执行 HITL,并可选择提供 `on_approval_request` 来自动批准或拒绝(参阅 `examples/hosted_mcp/human_in_the_loop.py` 和 `examples/hosted_mcp/on_approval.py`)。对于受信任的服务器,请使用 `"never"`(`examples/hosted_mcp/simple.py`)。 +- **会话与记忆**:向 `Runner.run` 传入会话,使审批和对话历史能够跨多个轮次保留。SQLite 和 OpenAI Conversations 会话变体位于 `examples/memory/memory_session_hitl_example.py` 和 `examples/memory/openai_session_hitl_example.py` 中。 - **实时智能体**:实时演示提供了 WebSocket 消息,可通过 `RealtimeSession` 上的 `approve_tool_call` / `reject_tool_call` 批准或拒绝工具调用(有关服务器端处理程序,请参阅 `examples/realtime/app/server.py`;有关 API 接口,请参阅 [Realtime 指南](realtime/guide.md#tool-approvals))。 -## 长时间运行的审批 +## 长期审批 -`RunState` 采用持久化设计。使用 `state.to_json()` 或 `state.to_string()` 将待处理工作存储在数据库或队列中,之后再使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 重新创建它。 +`RunState` 专为持久化而设计。使用 `state.to_json()` 或 `state.to_string()` 将待处理工作存储在数据库或队列中,之后再使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 重新创建它。 -可用的序列化选项: +实用的序列化选项: -- `context_serializer`:自定义非映射上下文对象的序列化方式。 -- `context_deserializer`:使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 加载状态时,重新构建非映射上下文对象。 -- `strict_context=True`:除非上下文本身已是映射或你提供了 `context_serializer`,否则序列化失败;除非上下文本身已是映射或你提供了 `context_deserializer`,否则反序列化失败。 -- `context_override`:加载状态时替换已序列化的上下文。如果你不想恢复原始上下文对象,此选项会很有用,但它不会从已序列化的载荷中移除该上下文。 -- `include_tracing_api_key=True`:当恢复的工作需要继续使用相同凭据导出追踪数据时,在已序列化的追踪载荷中包含追踪 API 密钥。 +- `context_serializer`:自定义非映射类型上下文对象的序列化方式。 +- `context_deserializer`:使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 加载状态时,重新构建非映射类型的上下文对象。 +- `strict_context=True`:除非上下文本身已是映射类型,或你提供了 `context_serializer`,否则序列化将失败;除非上下文本身已是映射类型,或你提供了 `context_deserializer`,否则反序列化将失败。 +- `context_override`:加载状态时替换已序列化的上下文。当你不希望恢复原始上下文对象时,此选项很有用,但它不会从已序列化的 payload 中移除该上下文。 +- `include_tracing_api_key=True`:在需要恢复后的工作继续使用相同凭据导出追踪数据时,将追踪 API 密钥包含在已序列化的追踪 payload 中。 -已序列化的运行状态包括你的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量、已序列化的 `tool_input`、嵌套的智能体作为工具的恢复信息、追踪元数据和服务器管理的对话设置。如果你计划存储或传输已序列化的状态,请将 `RunContextWrapper.context` 视为持久化数据,并避免在其中放置机密信息,除非你确实希望这些机密随状态一起传输。 +已序列化的运行状态包含应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量、已序列化的 `tool_input`、嵌套的智能体工具恢复信息、追踪元数据和服务器管理的对话设置。如果你计划存储或传输已序列化的状态,请将 `RunContextWrapper.context` 视为持久化数据;除非你明确希望密钥随状态一起传递,否则请避免将密钥放入其中。 -## 待处理任务的版本控制 +## 待处理任务的版本管理 -如果审批可能搁置一段时间,请将智能体定义或 SDK 的版本标记与已序列化状态一同存储。随后,你可以将反序列化路由到匹配的代码路径,以避免模型、提示词或工具定义发生变化时出现不兼容问题。 \ No newline at end of file +如果审批可能长时间处于待处理状态,请将智能体定义或 SDK 的版本标记与已序列化状态一同存储。这样,你就可以将反序列化操作路由到匹配的代码路径,避免模型、提示词或工具定义发生变化时出现不兼容问题。 \ No newline at end of file diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md index e6e857487b..4e8d8b72c8 100644 --- a/docs/zh/mcp.md +++ b/docs/zh/mcp.md @@ -4,35 +4,62 @@ search: --- # Model context protocol (MCP) -[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)规范了应用程序向语言模型公开工具和 -上下文的方式。根据官方文档: +[Model context protocol](https://modelcontextprotocol.io/introduction)(MCP)规定了应用程序向语言模型公开工具和上下文的标准方式。官方文档对此说明如下: -> MCP是一种开放协议,用于规范应用程序向LLM提供上下文的方式。可以将MCP视为AI -> 应用程序的USB-C端口。正如USB-C提供了一种将设备连接到各种外围设备和配件的标准化方式,MCP -> 也提供了一种将AI模型连接到不同数据源和工具的标准化方式。 +> MCP 是一种开放协议,用于标准化应用程序向LLM提供上下文的方式。可以将 MCP 想象成 AI +> 应用程序的 USB-C 端口。正如 USB-C 提供了一种将设备连接到各种外围设备和配件的标准化方式,MCP +> 也提供了一种将 AI 模型连接到不同数据源和工具的标准化方式。 -Agents Python SDK支持多种MCP传输方式。这样,你既可以复用现有MCP服务器,也可以构建自己的服务器,向智能体公开由文件系统、HTTP或连接器支持的工具。 +Agents Python SDK 支持多种 MCP 传输方式。因此,你可以复用现有的 MCP 服务器,也可以自行构建服务器,向智能体公开由文件系统、HTTP 或连接器支持的工具。 -!!! warning "连接前请确认MCP服务器可信" +!!! warning "连接前信任验证" - MCP工具可能会公开模型上下文中的数据,并使用你提供的凭证执行操作。请仅连接到你信任的服务器,使用最小权限凭证,将访问令牌放在授权字段或标头中而不是URL中,并要求对敏感操作进行审批。请参阅[OpenAI MCP安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 + MCP 工具可以公开模型上下文中的数据,并使用你提供的凭据执行操作。请仅连接你信任的服务器,使用最小权限凭据,将访问令牌放在授权字段或标头中而不是 URL 中,并要求对敏感操作进行审批。请参阅 [OpenAI MCP 安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 -## MCP集成选项 +## MCP 集成方式的选择 -在将MCP服务器接入智能体之前,请确定工具调用应在何处执行,以及你可以访问哪些传输方式。下表汇总了Python SDK支持的选项。 +将 MCP 服务器接入智能体之前,请确定应在何处执行工具调用,以及你可以访问哪些传输方式。下表汇总了 Python SDK 支持的选项。 -| 你的需求 | 推荐选项 | +| 需求 | 推荐选项 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| 让OpenAI的Responses API代表模型调用可公开访问的MCP服务器| 通过[`HostedMCPTool`][agents.tool.HostedMCPTool]使用**托管式MCP服务器工具** | -| 连接到你在本地或远程运行的Streamable HTTP服务器 | 通过[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]使用**Streamable HTTP MCP服务器** | -| 与实现了使用Server-Sent Events的HTTP协议的服务器通信 | 通过[`MCPServerSse`][agents.mcp.server.MCPServerSse]使用**使用SSE的HTTP MCP服务器** | -| 启动本地进程并通过stdin/stdout通信 | 通过[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]使用**stdio MCP服务器** | +| 让OpenAI的 Responses API 代表模型调用可公开访问的 MCP 服务器| 通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 使用**托管式 MCP 服务器工具** | +| 连接到你在本地或远程运行的 Streamable HTTP 服务器 | 通过 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 使用 **Streamable HTTP MCP 服务器** | +| 与实现了基于 Server-Sent Events 的 HTTP 的服务器通信 | 通过 [`MCPServerSse`][agents.mcp.server.MCPServerSse] 使用**基于 SSE 的 HTTP MCP 服务器** | +| 启动本地进程并通过 stdin/stdout 通信 | 通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] 使用 **stdio MCP 服务器** | 以下各节将逐一介绍每个选项、配置方式,以及何时应优先选择某种传输方式。 -## 智能体级MCP配置 +## MCP Python SDK v1 与 v2 -除了选择传输方式之外,你还可以通过设置`Agent.mcp_config`来调整MCP工具的准备方式。 +Agents SDK 通过依赖版本范围 `mcp>=1.19.0,<3` 支持 `mcp` Python 软件包的两个主要版本。已安装的 `mcp` 软件包版本与同服务器协商的 MCP 协议版本相互独立。Agents SDK 会检测已安装软件包的主版本,并自动适配 stdio、SSE 和 Streamable HTTP 连接,因此普通服务器配置不需要提供版本切换选项。 + +安装 MCP Python SDK v2 后,Agents SDK 会围绕配置的本地传输方式创建带有 `mode="auto"` 的 v2 `mcp.Client`。客户端首先使用已安装 MCP SDK 所支持的最新协议版本发送 `server/discover` 探测请求。现代服务器会响应此探测请求,客户端随后采用响应结果。如果较旧的服务器不支持 `server/discover`,客户端会回退到旧版 `initialize` 握手,并使用在该过程中协商的协议版本。因此,安装 MCP Python SDK v2 并不会强制所有连接都使用最新的 MCP 协议版本。请参阅 MCP Python SDK 的[协议版本协商指南](https://py.sdk.modelcontextprotocol.io/protocol-versions/)。 + +大多数应用程序应让依赖解析器选择兼容版本。如果你的应用程序必须固定使用某个主版本,请在 `openai-agents` 旁添加显式约束: + +```bash +# MCP Python SDK v1 +pip install "mcp>=1.19.0,<2" + +# MCP Python SDK v2 +pip install "mcp>=2,<3" +``` + +HTTP 传输自定义必须使用已安装 MCP 软件包所拥有的 HTTP 栈: + +| 自定义项 | MCP Python SDK v1 | MCP Python SDK v2 | +| --- | --- | --- | +| `params["auth"]` | `httpx.Auth` | `httpx2.Auth` | +| `params["httpx_client_factory"]` 返回值 | `httpx.AsyncClient` | `httpx2.AsyncClient` | +| `MCPServerStreamableHttp` `params["ignore_initialized_notification_failure"] = True` | 支持 | 不支持;连接前会被拒绝 | + +应尽可能使用 `Authorization` 标头,如下方的 Streamable HTTP 代码示例所示;`Authorization` 标头在两个软件包版本中均可保持不变。应用程序提供 `params["auth"]` 或 `params["httpx_client_factory"]` 时,这些值必须使用已安装 `mcp` 软件包主版本对应的 HTTP 类型。应用程序设置 `MCPServerStreamableHttp` 的 `params["ignore_initialized_notification_failure"] = True` 时,必须保留 `mcp<2`,或在升级前禁用该选项。 + +这些本地 `mcp` 依赖要求不适用于 [`HostedMCPTool`][agents.tool.HostedMCPTool],因为远程 MCP 连接由OpenAI Responses API 管理。 + +## 智能体级 MCP 配置 + +除了选择传输方式外,还可以通过设置 `Agent.mcp_config` 调整 MCP 工具的准备方式。 ```python from agents import Agent @@ -52,33 +79,33 @@ agent = Agent( ) ``` -注意: +注意事项: -- `convert_schemas_to_strict`采用尽力而为的方式。如果某个模式无法转换,则使用原始模式。 -- `failure_error_function`控制如何向模型呈现MCP工具调用失败。 -- 未设置`failure_error_function`时,SDK使用默认的工具错误格式化器。 -- 服务器级的`failure_error_function`会覆盖该服务器的`Agent.mcp_config["failure_error_function"]`。 -- `include_server_in_tool_names`需要主动启用。启用后,每个本地MCP工具都会以带有确定性服务器前缀的名称向模型公开,这有助于避免多个MCP服务器发布同名工具时发生冲突。生成的名称兼容ASCII,并且不超过`FunctionTool`实例的名称长度限制,也不会与同一智能体上本地`FunctionTool`实例的已配置名称或已启用的任务转移发生冲突。SDK仍会在原始服务器上调用具有原始名称的MCP工具。 +- `convert_schemas_to_strict` 采用尽力而为的方式。如果无法转换某个架构,则使用原始架构。 +- `failure_error_function` 控制如何向模型呈现 MCP 工具调用失败。 +- 未设置 `failure_error_function` 时,SDK 使用默认的工具错误格式化程序。 +- 服务器级 `failure_error_function` 会覆盖该服务器的 `Agent.mcp_config["failure_error_function"]`。 +- `include_server_in_tool_names` 需要主动启用。启用后,每个本地 MCP 工具都会使用确定性的服务器前缀名称向模型公开,有助于避免多个 MCP 服务器发布同名工具时发生冲突。生成的名称兼容 ASCII,不会超过 `FunctionTool` 实例的名称长度限制,也不会与同一智能体上本地 `FunctionTool` 实例的已配置名称或已启用任务转移发生冲突。SDK 仍会在原始服务器上调用具有原始名称的 MCP 工具。 ## 各传输方式的通用模式 -选择传输方式后,大多数集成都需要做出相同的后续决策: +选择传输方式后,大多数集成还需要作出相同的后续决策: -- 如何仅公开部分工具([工具筛选](#tool-filtering))。 +- 如何仅公开一部分工具([工具筛选](#tool-filtering))。 - 服务器是否还提供可复用的提示词([提示词](#prompts))。 -- 是否应缓存`list_tools()`([缓存](#caching))。 -- MCP活动如何显示在追踪中([追踪](#tracing))。 +- 是否应缓存 `list_tools()`([缓存](#caching))。 +- MCP 活动如何显示在追踪中([追踪](#tracing))。 -对于本地MCP服务器(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的`_meta`载荷也是通用概念。Streamable HTTP一节提供了最完整的代码示例,同样的模式也适用于其他本地传输方式。 +对于本地 MCP 服务器(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的 `_meta` 载荷也是通用概念。Streamable HTTP 一节给出了最完整的代码示例,同样的模式也适用于其他本地传输方式。 -## 1. 托管式MCP服务器工具 +## 1. 托管式 MCP 服务器工具 -托管式工具将整个工具往返过程交由OpenAI的基础设施处理。你的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool]会将服务器标签(以及可选的连接器元数据)转发给Responses API。模型会列出远程服务器的工具并调用它们,而无需额外回调你的Python进程。托管式工具目前适用于支持Responses API托管式MCP集成的OpenAI模型。 +托管工具会将整个工具调用往返流程交由OpenAI基础设施处理。你的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool] 会将服务器标签(以及可选的连接器元数据)转发给 Responses API。模型会列出远程服务器的工具并调用它们,而无需额外回调你的 Python 进程。目前,托管工具适用于支持 Responses API 托管式 MCP 集成的OpenAI模型。 -### 基础托管式MCP工具 +### 基础托管式 MCP 工具 -将[`HostedMCPTool`][agents.tool.HostedMCPTool]添加到智能体的`tools`列表中,即可创建托管式工具。`tool_config` -字典与发送到REST API的JSON一致: +将 [`HostedMCPTool`][agents.tool.HostedMCPTool] 添加到智能体的 `tools` 列表,即可创建托管工具。`tool_config` +字典与发送给 REST API 的 JSON 相对应: ```python import asyncio @@ -110,14 +137,14 @@ async def main() -> None: asyncio.run(main()) ``` -托管式服务器会自动公开其工具;你无需将其添加到`mcp_servers`。 +托管服务器会自动公开其工具;无需将其添加到 `mcp_servers`。 -如果希望托管式工具搜索以延迟方式加载托管式MCP服务器,请设置`tool_config["defer_loading"] = True`,并将[`ToolSearchTool`][agents.tool.ToolSearchTool]添加到智能体。只有OpenAI Responses模型支持此功能。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 +如果希望托管工具搜索以延迟加载方式加载托管式 MCP 服务器,请设置 `tool_config["defer_loading"] = True`,并将 [`ToolSearchTool`][agents.tool.ToolSearchTool] 添加到智能体。仅OpenAI Responses 模型支持此功能。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 -### 托管式MCP结果的流式传输 +### 托管式 MCP 结果的流式传输 -托管式工具支持流式传输结果,其方式与函数工具完全相同。使用`Runner.run_streamed` -可在模型仍在工作时接收增量MCP输出: +托管工具支持流式传输结果,其方式与函数工具完全相同。使用 `Runner.run_streamed` +可在模型仍在工作时接收增量 MCP 输出: ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -129,7 +156,7 @@ print(result.final_output) ### 可选审批流程 -如果服务器可以执行敏感操作,你可以要求每次执行工具前都进行人工或程序化审批。在`tool_config`中配置`require_approval`,可使用单一策略(`"always"`、`"never"`),也可以使用将工具名称映射到策略的字典。若要在Python中做出决策,请提供`on_approval_request`回调。 +如果服务器能够执行敏感操作,可以要求在每次执行工具前进行人工或程序化审批。在 `tool_config` 中配置 `require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。若要在 Python 中作出决定,请提供 `on_approval_request` 回调。 ```python from agents import MCPToolApprovalFunctionResult, MCPToolApprovalRequest @@ -157,11 +184,11 @@ agent = Agent( ) ``` -该回调可以是同步或异步的;每当模型需要审批数据才能继续运行时,就会调用它。 +该回调可以是同步或异步的,并且每当模型需要审批数据才能继续运行时都会调用它。 -### 连接器支持的托管式服务器 +### 由连接器支持的托管服务器 -托管式MCP还支持OpenAI连接器。无需指定`server_url`,只需提供`connector_id`和访问令牌。Responses API会处理身份验证,托管式服务器则会公开连接器的工具。 +托管式 MCP 还支持OpenAI连接器。无需指定 `server_url`,只需提供 `connector_id` 和访问令牌。Responses API 会处理身份验证,托管服务器则会公开连接器的工具。 ```python import os @@ -177,11 +204,11 @@ HostedMCPTool( ) ``` -完整可运行的托管式工具示例(包括流式传输、审批和连接器)位于[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 +完整可运行的托管工具代码示例(包括流式传输、审批和连接器)位于 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 -## 2. Streamable HTTP MCP服务器 +## 2. Streamable HTTP MCP 服务器 -如果希望自行管理网络连接,请使用[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你需要控制传输方式,或希望在自己的基础设施中运行服务器并保持较低延迟时,Streamable HTTP服务器是理想选择。 +如果希望自行管理网络连接,请使用 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。如果你需要控制传输方式,或者希望在自己的基础设施中运行服务器并保持较低延迟,Streamable HTTP 服务器是理想选择。 ```python import asyncio @@ -218,23 +245,23 @@ asyncio.run(main()) 构造函数还接受以下选项: -- `client_session_timeout_seconds`控制MCP ClientSession的读取超时。可由`datetime.timedelta`表示且至少为一微秒的正有限值会设置有限超时;`None`和`0`会禁用超时。构造服务器时会拒绝其他值。 -- `use_structured_content`控制是否优先使用`tool_result.structured_content`而不是文本输出。 -- `max_retry_attempts`和`retry_backoff_seconds_base`为`list_tools()`和`call_tool()`添加自动重试。 -- `tool_filter`允许你仅公开部分工具(请参阅[工具筛选](#tool-filtering))。 -- `require_approval`为本地MCP工具启用人工参与的审批策略。 -- `failure_error_function`用于自定义模型可见的MCP工具失败消息;将其设置为`None`则改为抛出错误。 -- `tool_meta_resolver`会在`call_tool()`之前注入每次调用的MCP `_meta`载荷。 +- `client_session_timeout_seconds` 控制 MCP ClientSession 的读取超时。可由 `datetime.timedelta` 表示且至少为一微秒的有限正值会设置有限超时;`None` 和 `0` 会禁用超时。构造服务器时会拒绝其他值。 +- `use_structured_content` 控制是否优先使用 `tool_result.structured_content` 而不是文本输出。 +- `max_retry_attempts` 和 `retry_backoff_seconds_base` 为 `list_tools()` 和 `call_tool()` 添加自动重试。 +- `tool_filter` 允许你仅公开一部分工具(请参阅[工具筛选](#tool-filtering))。 +- `require_approval` 为本地 MCP 工具启用人机协同审批策略。 +- `failure_error_function` 用于自定义模型可见的 MCP 工具失败消息;将其设置为 `None` 可改为抛出错误。 +- `tool_meta_resolver` 会在 `call_tool()` 之前注入每次调用的 MCP `_meta` 载荷。 -### 本地MCP服务器的审批策略 +### 本地 MCP 服务器的审批策略 -`MCPServerStdio`、`MCPServerSse`和`MCPServerStreamableHttp`都接受`require_approval`。 +`MCPServerStdio`、`MCPServerSse` 和 `MCPServerStreamableHttp` 均接受 `require_approval`。 支持以下形式: -- 对所有工具使用`"always"`或`"never"`。 -- `True`要求审批所有工具,而`False`不要求审批任何工具(分别等同于`"always"`和`"never"`)。 -- 按工具配置的映射,例如`{"delete_file": "always", "read_file": "never"}`。 +- 对所有工具使用 `"always"` 或 `"never"`。 +- `True` 要求审批所有工具,`False` 不要求审批任何工具(分别等同于 `"always"` 和 `"never"`)。 +- 按工具配置的映射,例如 `{"delete_file": "always", "read_file": "never"}`。 - 分组对象:`{"always": {"tool_names": [...]}, "never": {"tool_names": [...]}}`。 ```python @@ -246,11 +273,11 @@ async with MCPServerStreamableHttp( ... ``` -有关完整的暂停/恢复流程,请参阅[人工参与](human_in_the_loop.md)和`examples/mcp/get_all_mcp_tools_example/main.py`。 +有关完整的暂停/恢复流程,请参阅[人机协同](human_in_the_loop.md)和 `examples/mcp/get_all_mcp_tools_example/main.py`。 -### 使用`tool_meta_resolver`传递每次调用的元数据 +### 使用 `tool_meta_resolver` 的每次调用元数据 -当MCP服务器要求在`_meta`中提供请求元数据(例如租户ID或追踪上下文)时,请使用`tool_meta_resolver`。以下代码示例假定你将`dict`作为`context`传递给`Runner.run(...)`。 +当 MCP 服务器要求在 `_meta` 中提供请求元数据(例如租户 ID 或追踪上下文)时,请使用 `tool_meta_resolver`。以下代码示例假设你将 `dict` 作为 `context` 传递给 `Runner.run(...)`。 ```python from agents.mcp import MCPServerStreamableHttp, MCPToolMetaContext @@ -271,19 +298,19 @@ server = MCPServerStreamableHttp( ) ``` -如果运行上下文是Pydantic模型、数据类或自定义类,请改用属性访问读取租户ID。 +如果运行上下文是 Pydantic 模型、dataclass 或自定义类,请改用属性访问方式读取租户 ID。 -### MCP工具输出:文本和图像 +### MCP 工具输出:文本、图像及其他内容 -当MCP工具返回图像内容时,SDK会自动将其映射为工具输出中的图像类型条目。混合文本/图像响应会作为输出项列表转发,因此智能体可以像使用常规函数工具的图像输出一样使用MCP图像结果。 +当 MCP 结果使用内容块时,SDK 会将文本内容作为文本输出转发,并将图像内容映射为工具输出中的图像类型条目。对于其他 MCP 内容块类型(包括音频和资源块),SDK 会转发文本输出,其值为该内容块的有效 JSON 序列化结果。包含多个内容块的响应会作为输出项列表转发。如果 `use_structured_content=True` 选择了非空且无错误的 `structuredContent` 载荷,则该结构化载荷优先于这些内容块。结构化内容缺失或为空时,会回退到内容块。 -## 3. 使用SSE的HTTP MCP服务器 +## 3. 基于 SSE 的 HTTP MCP 服务器 !!! warning - MCP项目已弃用Server-Sent Events传输方式。对于新集成,请优先使用Streamable HTTP或stdio,并仅为旧版服务器保留SSE。 + MCP 项目已弃用 Server-Sent Events 传输方式。对于新集成,请优先使用 Streamable HTTP 或 stdio,仅为旧版服务器保留 SSE。 -如果MCP服务器实现了使用SSE的HTTP传输方式,请实例化[`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其API与Streamable HTTP服务器完全相同。 +如果 MCP 服务器实现了基于 SSE 的 HTTP 传输方式,请实例化 [`MCPServerSse`][agents.mcp.server.MCPServerSse]。除传输方式外,其 API 与 Streamable HTTP 服务器完全相同。 ```python @@ -310,9 +337,9 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP服务器 +## 4. stdio MCP 服务器 -对于作为本地子进程运行的MCP服务器,请使用[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会生成进程、保持管道打开,并在退出上下文管理器时自动关闭管道。此选项适用于快速进行概念验证,或服务器仅公开命令行入口点的情况。 +对于以本地子进程方式运行的 MCP 服务器,请使用 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK 会启动该进程、保持管道打开,并在退出上下文管理器时自动关闭管道。此选项适合快速构建概念验证,或服务器仅公开命令行入口点的情况。 ```python from pathlib import Path @@ -338,9 +365,9 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP服务器管理器 +## 5. MCP 服务器管理器 -如果有多个MCP服务器,请使用`MCPServerManager`预先连接它们,并向智能体公开其中成功连接的服务器子集。有关构造函数选项和重新连接行为,请参阅[MCPServerManager API参考](ref/mcp/manager.md)。 +如果有多个 MCP 服务器,请使用 `MCPServerManager` 预先连接它们,并向智能体公开其中成功连接的服务器子集。有关构造函数选项和重新连接行为,请参阅 [MCPServerManager API 参考](ref/mcp/manager.md)。 ```python from agents import Agent, Runner @@ -361,25 +388,26 @@ async with MCPServerManager(servers) as manager: print(result.final_output) ``` -关键行为: +主要行为: -- 当`drop_failed_servers=True`为默认值时,`active_servers`仅包含成功连接的服务器。 -- 连接失败会记录在`failed_servers`和`errors`中。 -- 设置`strict=True`可在首次连接失败时抛出异常。 -- 调用`reconnect(failed_only=True)`可重试连接失败的服务器,调用`reconnect(failed_only=False)`可重启所有服务器。 -- 设置`connect_timeout_seconds`、`cleanup_timeout_seconds`和`connect_in_parallel`可调整生命周期行为。生命周期超时接受有限的正秒数,也可以使用`None`禁用超时;这些值会在构造和赋值时进行验证。零值会被拒绝,因为它会导致立即到达截止时间。 +- 当 `drop_failed_servers=True`(默认值)时,`active_servers` 仅包含成功连接的服务器。 +- 失败信息记录在 `failed_servers` 和 `errors` 中。 +- 设置 `strict=True` 可在首次连接失败时抛出异常。 +- 调用 `reconnect(failed_only=True)` 可重试失败的服务器,调用 `reconnect(failed_only=False)` 可重启所有服务器。 +- 对 `connect_all()`、`reconnect()` 和 `cleanup_all()` 的调用会串行执行。如果某个生命周期操作已在运行,另一个生命周期操作会等待其完成,而不会并发连接或清理相同的服务器。 +- 设置 `connect_timeout_seconds`、`cleanup_timeout_seconds` 和 `connect_in_parallel` 可调整生命周期行为。两个生命周期超时的默认值均为 10 秒。它们接受有限正秒数,或使用 `None` 将其禁用,并且在构造和赋值时都会进行验证;零会被拒绝,因为它会产生立即到期的截止时间。 -## 通用服务器功能 +## 通用服务器能力 -以下各节适用于不同的MCP服务器传输方式(具体API接口取决于服务器类)。 +以下各节适用于所有 MCP 服务器传输方式(具体 API 范围取决于服务器类)。 ## 工具筛选 -每个MCP服务器都支持工具筛选器,因此你可以仅公开智能体所需的函数。筛选既可以在构造时进行,也可以在每次运行时动态进行。 +每个 MCP 服务器都支持工具筛选器,因此你可以仅公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 ### 静态工具筛选 -使用[`create_static_tool_filter`][agents.mcp.create_static_tool_filter]配置简单的允许列表/阻止列表: +使用 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter] 配置简单的允许列表和阻止列表: ```python from pathlib import Path @@ -397,11 +425,11 @@ filesystem_server = MCPServerStdio( ) ``` -同时提供`allowed_tool_names`和`blocked_tool_names`时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 +同时提供 `allowed_tool_names` 和 `blocked_tool_names` 时,SDK 会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 ### 动态工具筛选 -对于更复杂的逻辑,请传入一个接收[`ToolFilterContext`][agents.mcp.ToolFilterContext]的可调用对象。该可调用对象可以是同步或异步的,并在应公开工具时返回`True`。 +对于更复杂的逻辑,请传入一个可调用对象,该对象接收 [`ToolFilterContext`][agents.mcp.ToolFilterContext]。该可调用对象可以是同步或异步的,并在应公开工具时返回 `True`。 ```python from pathlib import Path @@ -425,15 +453,15 @@ async with MCPServerStdio( ... ``` -筛选器上下文会公开当前的`run_context`、请求这些工具的`agent`以及`server_name`。 +筛选器上下文会公开活动的 `run_context`、请求工具的 `agent`,以及 `server_name`。 ## 提示词 -MCP服务器还可以提供动态生成智能体指令的提示词。支持提示词的服务器会公开两种 +MCP 服务器还可以提供动态生成智能体指令的提示词。支持提示词的服务器会公开两种 方法: -- `list_prompts()`列举可用的提示词模板。 -- `get_prompt(name, arguments)`获取具体的提示词,并可选择传入参数。 +- `list_prompts()` 枚举可用的提示词模板。 +- `get_prompt(name, arguments)` 获取具体提示词,并可选择提供参数。 ```python from agents import Agent @@ -453,25 +481,25 @@ agent = Agent( ## 分页 -内置的本地MCP服务器类会在列出工具和提示词时自动跟随`nextCursor`。`list_tools()`会先收集完整的工具列表,再应用筛选器或填充缓存;`list_prompts()`则返回一个`nextCursor=None`的合并结果。如果后续页面失败或服务器重复游标,该操作会抛出错误,而不是公开或缓存部分结果。 +内置的本地 MCP 服务器类在列出工具和提示词时,会自动跟随 `nextCursor`。`list_tools()` 会先收集完整的工具列表,再应用筛选器或填充缓存;`list_prompts()` 则返回一个合并结果,其中包含 `nextCursor=None`。如果后续页面失败或服务器重复使用游标,该操作会抛出错误,而不会公开或缓存部分结果。 -资源仍需显式分页。将`list_resources()`或`list_resource_templates()`中的`nextCursor`作为`cursor`参数传回,即可获取下一页。 +资源仍需显式分页。将 `list_resources()` 或 `list_resource_templates()` 返回的 `nextCursor` 作为 `cursor` 参数传回,以获取下一页。 ## 缓存 -每次智能体运行都会在每个MCP服务器上调用`list_tools()`。远程服务器可能引入明显延迟,因此所有MCP服务器类都提供`cache_tools_list`选项。只有在确信工具定义不会频繁变化时,才应将其设置为`True`。若之后需要强制获取新列表,请在服务器实例上调用`invalidate_tools_cache()`。 +每次智能体运行都会在每个 MCP 服务器上调用 `list_tools()`。远程服务器可能带来明显的延迟,因此所有 MCP 服务器类都公开了 `cache_tools_list` 选项。仅当你确信工具定义不会频繁变化时,才应将其设置为 `True`。如需稍后强制获取最新列表,请在服务器实例上调用 `invalidate_tools_cache()`。 ## 追踪 -[追踪](./tracing.md)会自动捕获MCP活动,包括: +[追踪](./tracing.md)会自动捕获 MCP 活动,包括: -1. 为列出工具而向MCP服务器发出的调用。 -2. 工具调用中与MCP相关的信息。 +1. 为列出工具而对 MCP 服务器发起的调用。 +2. 工具调用中的 MCP 相关信息。 -![MCP追踪截图](../assets/images/mcp-tracing.jpg) +![MCP 追踪截图](../assets/images/mcp-tracing.jpg) ## 延伸阅读 -- [Model Context Protocol](https://modelcontextprotocol.io/)——规范和设计指南。 -- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp)——可运行的stdio、SSE和Streamable HTTP示例。 -- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)——完整的托管式MCP演示,包括审批和连接器。 \ No newline at end of file +- [Model Context Protocol](https://modelcontextprotocol.io/) – 规范和设计指南。 +- [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的 stdio、SSE 和 Streamable HTTP 代码示例。 +- [examples/hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp) – 完整的托管式 MCP 演示,包括审批和连接器。 \ No newline at end of file diff --git a/docs/zh/models/index.md b/docs/zh/models/index.md index 9b818ab69e..989a31ef11 100644 --- a/docs/zh/models/index.md +++ b/docs/zh/models/index.md @@ -4,10 +4,10 @@ search: --- # 模型 -Agents SDK原生支持两种形式的OpenAI模型: +Agents SDK 原生支持两种 OpenAI 模型: -- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],通过新的[Responses API](https://platform.openai.com/docs/api-reference/responses)调用OpenAI API。 -- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],通过[Chat Completions API](https://platform.openai.com/docs/api-reference/chat)调用OpenAI API。 +- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],它使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API。 +- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],它使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API。 ## 模型配置选择 @@ -15,32 +15,32 @@ Agents SDK原生支持两种形式的OpenAI模型: | 如果你希望…… | 推荐路径 | 更多信息 | | --- | --- | --- | -| 仅使用OpenAI模型 | 使用默认OpenAI提供商和Responses模型路径 | [OpenAI模型](#openai-models) | -| 通过 websocket 传输使用OpenAI Responses API | 保持使用Responses模型路径并启用 websocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | -| 使用由OpenAI托管的子智能体 | 使用实验性托管式多智能体模型 | [托管式多智能体](#hosted-multi-agent-experimental) | -| 使用一个非OpenAI提供商 | 从内置提供商集成点开始 | [非OpenAI模型](#non-openai-models) | -| 在多个智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | -| 调整高级OpenAI Responses请求设置 | 在OpenAI Responses路径上使用`ModelSettings` | [高级OpenAI Responses设置](#advanced-openai-responses-settings) | -| 使用第三方适配器进行非OpenAI或混合提供商路由 | 比较受支持的 beta 适配器,并验证你计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | +| 仅使用 OpenAI 模型 | 使用默认 OpenAI 提供商和 Responses 模型路径 | [OpenAI 模型](#openai-models) | +| 通过 WebSocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径并启用 WebSocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | +| 使用由 OpenAI 托管的子智能体 | 使用实验性的托管多智能体模型 | [托管多智能体](#hosted-multi-agent-experimental) | +| 使用一个非 OpenAI 提供商 | 从内置的提供商集成点开始 | [非 OpenAI 模型](#non-openai-models) | +| 在不同智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并查看功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | +| 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径上使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | +| 使用第三方适配器进行非 OpenAI 或混合提供商路由 | 比较受支持的 Beta 适配器,并验证计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | -## OpenAI模型 +## OpenAI 模型 -对于大多数仅使用OpenAI的应用,推荐路径是将字符串模型名称与默认OpenAI提供商搭配使用,并继续采用Responses模型路径。 +对于大多数仅使用 OpenAI 的应用,推荐使用字符串模型名称和默认 OpenAI 提供商,并保持使用 Responses 模型路径。 -初始化`Agent`时,如果未指定模型,则会使用默认模型。当前默认模型为[`gpt-5.4-mini`](https://developers.openai.com/api/docs/models/gpt-5.4-mini),并搭配`reasoning.effort="none"`和`verbosity="low"`,适用于低延迟智能体工作流。如果你有权访问,我们建议将智能体设置为`gpt-5.6-sol`,以便在显式保留`model_settings`的同时获得更高质量。 +当 [`Agent`][agents.agent.Agent] 未指定模型时,为满足成本敏感型、高吞吐量智能体工作流的需求,Agents SDK 默认使用带有 `reasoning.effort="none"` 和 `verbosity="low"` 的 [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna)。需要前沿能力的应用可以显式设置 `model="gpt-5.6-sol"`,并选择适合相应工作负载的 `model_settings`。 -如果要切换到`gpt-5.6-sol`等其他模型,可通过两种方式配置智能体。 +如果要切换到 `gpt-5.6-sol` 等其他模型,可通过两种方式配置智能体。 ### 默认模型 -首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置`OPENAI_DEFAULT_MODEL`环境变量。 +首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -其次,可以通过`RunConfig`为一次运行设置默认模型。如果没有为智能体设置模型,则会使用本次运行的模型。 +其次,可以通过 `RunConfig` 为一次运行设置默认模型。如果未给智能体设置模型,则会使用此次运行的模型。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 模型 -以这种方式使用任何 GPT-5 模型(如`gpt-5.6-sol`)时,SDK 会应用默认的`ModelSettings`。它会设置最适合大多数用例的值。要调整默认模型的推理强度,请传入你自己的`ModelSettings`: +以这种方式使用任何 GPT-5 模型(例如 `gpt-5.6-sol`)时,SDK 会应用默认的 `ModelSettings`。它会设置最适合大多数用例的值。若要调整默认模型的推理强度,请传入你自己的 `ModelSettings`: ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -为了降低延迟,建议将`reasoning.effort="none"`与 GPT-5 模型搭配使用。 +若要降低延迟,建议为 GPT-5 模型使用 `reasoning.effort="none"`。 -GPT-5.6 还通过现有的`reasoning`设置支持推理模式、跨对话轮次保留的推理上下文,以及`"max"`强度级别。这些控制项可在Responses API路径上使用: +GPT-5.6 还支持推理模式、跨对话轮次保留的推理上下文,以及通过现有 `reasoning` 设置指定的 `"max"` 强度级别。这些控制项可用于 Responses API 路径: ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode`和`reasoning.context`是仅限Responses的设置。Chat Completions仅使用`reasoning.effort`,且支持的强度级别取决于模型和 API 接口。请使用Responses API来设置 GPT-5.6 的`"max"`强度。Chat Completions适配器会忽略模式和上下文并发出警告;在OpenAI提供商上设置`strict_feature_validation=True`可将该警告转为错误。 +`reasoning.mode` 和 `reasoning.context` 是仅限 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,支持的强度级别取决于模型和 API 接口。请使用 Responses API 设置 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;在 OpenAI 提供商上设置 `strict_feature_validation=True` 可将该警告转为错误。 -使用`context="all_turns"`时,请通过`previous_response_id`、服务端Responses API对话,或在下一个请求中包含先前的推理项来保留对话。对于无状态的`store=False`调用,请在响应中请求`reasoning.encrypted_content`,然后在下一个请求中将这些推理项作为输入。 +使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端 Responses API 对话,或在下一次请求中包含之前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中请求 `reasoning.encrypted_content`,然后在下一次请求中将这些推理项作为输入包含在内。 #### ComputerTool 模型选择 -如果智能体包含[`ComputerTool`][agents.tool.ComputerTool],则实际Responses请求上的有效模型决定 SDK 发送哪种计算机工具载荷。显式的`gpt-5.5`请求使用正式版内置`computer`工具,而显式的`computer-use-preview`请求继续使用较旧的`computer_use_preview`载荷。 +如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],则实际 Responses 请求中生效的模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求则继续使用旧版 `computer_use_preview` 载荷。 -由提示词管理的调用是主要例外。如果提示词模板指定了模型,而 SDK 在请求中省略了`model`,SDK 会默认使用与预览版兼容的计算机载荷,以避免猜测提示词固定的是哪个模型。要在此流程中继续使用正式版路径,请在请求中显式指定`model="gpt-5.5"`,或使用`ModelSettings(tool_choice="computer")`或`ModelSettings(tool_choice="computer_use")`强制选择正式版。 +由提示词管理的调用是主要例外。如果提示词模板指定了模型,并且 SDK 在请求中省略了 `model`,SDK 会默认使用与预览版兼容的计算机载荷,以避免猜测提示词固定的是哪个模型。若要在此流程中继续使用正式发布路径,可以在请求中显式指定 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版本。 -注册[`ComputerTool`][agents.tool.ComputerTool]后,`tool_choice="computer"`、`"computer_use"`和`"computer_use_preview"`会被规范化为与有效请求模型匹配的内置选择器。如果未注册`ComputerTool`,这些字符串会继续像普通函数名称一样运作。 +注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与实际请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串将继续像普通函数名称一样工作。 -与预览版兼容的请求必须预先序列化`environment`和显示尺寸,因此,使用[`ComputerProvider`][agents.tool.ComputerProvider]工厂的提示词管理流程应传入具体的`Computer`或`AsyncComputer`实例,或在发送请求前强制使用正式版选择器。完整迁移详情请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 +与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此,由提示词管理且使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂的流程,应传入具体的 `Computer` 或 `AsyncComputer` 实例,或在发送请求前强制使用正式发布选择器。有关完整迁移详情,请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 #### 非 GPT-5 模型 -如果传入非 GPT-5 模型名称且未提供自定义`model_settings`,SDK 会恢复为与任何模型兼容的通用`ModelSettings`。 +如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复使用与任何模型兼容的通用 `ModelSettings`。 -### 仅限Responses的工具功能 +### 仅限 Responses 的工具功能 -以下工具功能仅受OpenAI Responses模型支持: +以下工具功能仅受 OpenAI Responses 模型支持: - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)`及其他延迟加载的Responses工具接口 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`和`tool_choice="programmatic_tool_calling"` +- `@function_tool(defer_loading=True)` 及其他延迟加载的 Responses 工具接口 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers` 和 `tool_choice="programmatic_tool_calling"` -Chat Completions模型和非Responses后端会拒绝这些功能。使用延迟加载工具时,请将`ToolSearchTool()`添加到智能体,并让模型通过`auto`或`required`工具选择来加载工具,而不是强制使用单独的命名空间名称或仅限延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管式工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 +Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请将 `ToolSearchTool()` 添加到智能体,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制使用单独的命名空间名称或仅限延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 ### Responses WebSocket 传输 -默认情况下,OpenAI Responses API请求使用 HTTP 传输。使用OpenAI Responses提供商路径时,你可以选择启用 websocket 传输。 +默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用 OpenAI Responses 提供商路径时,可以选择启用 WebSocket 传输。 #### 基本配置 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响默认OpenAI提供商解析模型名称时得到的OpenAI Responses模型,包括`"gpt-5.6-sol"`等字符串模型名称。 +这会影响默认 OpenAI 提供商解析模型名称时生成的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 -SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的[`Model`][agents.models.interface.Model]对象,其传输方式已固定:[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]使用 websocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]使用 HTTP,而[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]继续使用Chat Completions。如果传入`RunConfig(model_provider=...)`,则由该提供商控制传输方式的选择,而不是使用全局默认设置。 +SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已经固定:[​​`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 继续使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商而非全局默认配置控制传输方式的选择。 -#### 提供商级或运行级配置 +#### 提供商或运行级配置 -你也可以按提供商或按运行配置 websocket 传输: +也可以按提供商或按运行配置 WebSocket 传输: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -通过 SDK 的OpenAI集成进行路由的提供商也接受可选的智能体注册配置。这是一个高级选项,适用于OpenAI配置需要提供商级注册元数据(如测试框架 ID)的情况。 +通过 SDK 的 OpenAI 集成进行路由的提供商也接受可选的智能体注册配置。这是一项高级选项,适用于 OpenAI 配置需要提供商级注册元数据(例如测试框架 ID)的情况。 ```python from agents import ( @@ -188,16 +188,16 @@ result = await Runner.run( ) ``` -#### 使用`MultiProvider`的高级路由 +#### 使用 `MultiProvider` 的高级路由 -如果需要基于前缀的模型路由,例如在一次运行中混用`openai/...`和`any-llm/...`模型名称,请使用[`MultiProvider`][agents.MultiProvider]并在其中设置`openai_use_responses_websocket=True`。 +如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 -`MultiProvider`保留了两个历史默认设置: +`MultiProvider` 保留了两个历史默认行为: -- `openai/...`被视为OpenAI提供商的别名,因此`openai/gpt-4.1`会作为模型`gpt-4.1`进行路由。 -- 未知前缀会引发`UserError`,而不是按原样传递。 +- `openai/...` 被视为 OpenAI 提供商的别名,因此 `openai/gpt-4.1` 会以模型 `gpt-4.1` 进行路由。 +- 未知前缀会引发 `UserError`,而不是直接传递。 -将OpenAI提供商指向需要字面命名空间模型 ID 的OpenAI兼容端点时,请显式启用按原样传递行为。在启用 websocket 的配置中,也要在`MultiProvider`上保留`openai_use_responses_websocket=True`: +将 OpenAI 提供商指向需要字面量命名空间模型 ID 的 OpenAI 兼容端点时,请显式启用直通行为。在启用 WebSocket 的配置中,也要在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -后端需要字面量`openai/...`字符串时,请使用`openai_prefix_mode="model_id"`。后端需要`openrouter/openai/gpt-4.1-mini`等其他命名空间模型 ID 时,请使用`unknown_prefix_mode="model_id"`。这些选项也可在 websocket 传输之外的`MultiProvider`上使用;此代码示例继续启用 websocket,是因为它属于本节所述的传输配置。相同选项也可用于[`responses_websocket_session()`][agents.responses_websocket_session]。 +当后端需要字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端需要 `openrouter/openai/gpt-4.1-mini` 等其他命名空间模型 ID 时,请使用 `unknown_prefix_mode="model_id"`。这些选项同样适用于 WebSocket 传输之外的 `MultiProvider`;此示例继续启用 WebSocket,是因为它属于本节所述的传输配置。同样的选项也适用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 -如果通过`MultiProvider`进行路由时需要相同的提供商级注册元数据,请传入`openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发到底层OpenAI提供商。 +如果通过 `MultiProvider` 进行路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发给底层 OpenAI 提供商。 -如果使用自定义OpenAI兼容端点或代理,websocket 传输还需要兼容的 websocket `/responses`端点。在这些配置中,你可能需要显式设置`websocket_base_url`。 +如果使用自定义 OpenAI 兼容端点或代理,WebSocket 传输还需要兼容的 WebSocket `/responses` 端点。在这些配置中,可能需要显式设置 `websocket_base_url`。 #### 注意事项 -- 这是通过 websocket 传输的Responses API,而不是[Realtime API](../realtime/guide.md)。它不适用于Chat Completions。它仅适用于支持Responses websocket `/responses`端点的非OpenAI提供商。 -- 如果环境中尚未提供`websockets`包,请安装该包。 -- 启用 websocket 传输后,可以直接使用[`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次复用同一 websocket 连接的多轮工作流,包括嵌套的智能体工具调用,建议使用[`responses_websocket_session()`][agents.responses_websocket_session]辅助工具。请参阅[运行智能体](../running_agents.md)指南和[`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 -- 对于较长的推理轮次或延迟偶发激增的网络,请使用`responses_websocket_options`自定义 websocket 保活行为。增大`ping_timeout`可容忍延迟的 pong 帧,或将`ping_timeout=None`设置为禁用心跳超时,同时继续启用 ping。当可靠性比 websocket 延迟更重要时,优先使用 HTTP/SSE 传输。 -- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于位于代理之后或在内存受限容器中运行的长生命周期智能体进程,请设置`responses_websocket_options={"max_size": 8 * 1024 * 1024}`以限制每条消息的内存用量。 -- [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每个连接上一次处理一个响应,并将每个连接限制为 60 分钟。达到该限制后请打开新连接;需要并行运行时,请使用多个连接。 -- 该服务仅在连接本地内存中保留最近的响应。失败的`4xx`或`5xx`轮次会从该内存中逐出`previous_response_id`所引用的响应。重新连接后,存储的响应若仍可用,依然可以继续,但`store=False`和 ZDR 流程没有持久化回退方案。请使用`previous_response_id=None`启动新链并发送完整输入上下文,或从本地管理的会话状态重建该上下文。 +- 这是通过 WebSocket 传输的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions。只有非 OpenAI 提供商支持 Responses WebSocket `/responses` 端点时,它才适用于这些提供商。 +- 如果环境中尚未提供 `websockets` 软件包,请安装它。 +- 启用 WebSocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次以及嵌套的“智能体作为工具”调用复用同一 WebSocket 连接的多轮工作流,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助工具。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 +- 对于长时间推理轮次或存在延迟峰值的网络,请使用 `responses_websocket_options` 自定义 WebSocket 保活行为。增大 `ping_timeout` 以容忍延迟的 pong 帧,或将 `ping_timeout=None` 设置为禁用心跳超时,同时继续启用 ping。当可靠性比 WebSocket 延迟更重要时,请优先使用 HTTP/SSE 传输。 +- 默认情况下,SDK 会禁用传入消息大小限制(`max_size=None`)。对于位于代理之后或内存受限容器中的长期运行智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}` 以限制每条消息的内存用量。 +- [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每个连接上一次处理一个响应,并将每个连接限制为 60 分钟。达到此限制后请打开新连接;需要并行运行时,请使用多个连接。 +- 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会从该内存中逐出 `previous_response_id` 引用的响应。重新连接后,只要已存储的响应仍可用,便仍可继续该响应;但 `store=False` 和 ZDR 流程没有持久化回退方案。请使用 `previous_response_id=None` 启动新链并发送完整输入上下文,或根据本地管理的会话状态重建该上下文。 -### 托管式多智能体(实验性) +### 托管多智能体(实验性) -OpenAI Responses API托管式多智能体 beta 允许 GPT-5.6 根模型创建和协调由服务端托管的子智能体。Agents SDK可以继续使用常规的`Runner`:托管式编排在服务端进行,而开发者定义的函数工具在你的应用程序中执行。 +OpenAI Responses API 托管多智能体 Beta 版允许 GPT-5.6 根模型创建并协调服务端托管的子智能体。Agents SDK 可以继续使用其常规 `Runner`:托管编排在服务端进行,而开发者定义的函数工具则在应用中执行。 -此集成为实验性功能,并使用Responses WebSocket传输,以便通过`response.inject`将本地函数输出返回给活跃的托管式智能体。它要求`openai[realtime]`版本为 2.45.0 或更高版本,且该构建需公开`client.beta.responses.connect`。接口和 beta 项目架构可能会在正式发布前发生变化。 +此集成为实验性功能,使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活跃的托管智能体。它要求使用 `openai[realtime]` 2.45.0 或更高版本的构建,该构建需公开 `client.beta.responses.connect`。接口和 Beta 项架构可能会在正式发布前发生变化。 #### 模型配置 @@ -260,13 +260,13 @@ agent = Agent( ) ``` -构造`OpenAIHostedMultiAgentModel`会启用`multi_agent.enabled`并发送`OpenAI-Beta: responses_multi_agent=v1`WebSocket 标头。除非提供`openai_client`,否则模型会使用默认OpenAI客户端。如果省略`max_concurrent_subagents`,则使用服务默认值。 +构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled` 并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则模型使用默认 OpenAI 客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 #### 本地函数工具 -所有托管式智能体共享为请求配置的模型和工具。Responses API决定由哪个托管式智能体调用函数。常规 SDK Runner 会在本地执行函数,并将具有相同调用 ID 的`function_call_output`注入活跃的 WebSocket 响应,从而让服务恢复原始托管式调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。SDK 工具审批中断不受支持:任何`needs_approval`设置不为`False`的函数工具都会在发送请求前被拒绝。 +所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 会在本地执行函数,并将具有相同调用 ID 的 `function_call_output` 注入活跃的 WebSocket 响应,使服务能够恢复最初的托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的函数工具都会在发送请求前被拒绝。 -当工具需要感知调用方的日志记录或授权时,请使用`get_hosted_agent_metadata()`: +当工具需要感知调用方的日志记录或授权时,请使用 `get_hosted_agent_metadata()`: ```python from typing import Any @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -托管式智能体名称是观测元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于具有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间通过应用程序代码实施所需的授权;不要将`needs_approval`与此模型搭配使用。工具参数和输出会跨越Responses API边界。 +托管智能体名称是观测元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于具有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间通过应用代码实施所有必要的授权;请勿在此模型中使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 -#### 输出与流式传输行为 +#### 输出和流式传输行为 -只有归属于`/root`且阶段为`final_answer`的消息才会成为普通最终消息。实验性适配器会从高级`RunResult`中过滤掉子智能体消息和托管式编排记录;SDK 绝不会将这些记录作为本地函数执行。 +只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为常规最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管编排记录;SDK 绝不会将这些记录作为本地函数执行。 -原始流式传输仍会公开 beta Responses事件,包括托管式输出项和`response.inject.created`确认。函数调用准备就绪时,适配器会将一个活跃提供商响应划分为 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。使用`get_hosted_agent_metadata()`与原始托管项或`ToolContext`可识别该项或工具调用所归属的托管式智能体。 +原始流式传输会继续公开 Beta Responses 事件,包括托管输出项和 `response.inject.created` 确认。当函数调用就绪时,适配器会将一个活跃的提供商响应划分为 SDK 可见的逻辑模型轮次;Runner 生成输出后,再恢复同一个提供商响应。请将 `get_hosted_agent_metadata()` 与原始托管项或 `ToolContext` 一起使用,以识别该项或工具调用归属的托管智能体。 #### 与 SDK 编排的关系 -托管式多智能体不同于 SDK 任务转移和Agents-as-tools: +托管多智能体与 SDK 任务转移和 Agents-as-tools 相互独立: -- 托管式多智能体在OpenAI服务上创建子智能体。你的应用程序不会创建或调度这些子智能体。 -- SDK 任务转移会更改活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管式智能体都会收到相同的任务转移工具,从而导致所有权冲突。 -- Agents-as-tools仍然可用,但使用它们会创建嵌套的客户端编排和服务端编排。请审慎评估额外的延迟、成本和工具暴露。 +- 托管多智能体在 OpenAI 服务上创建子智能体。你的应用不会创建或调度这些子智能体。 +- SDK 任务转移会更改活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,从而造成所有权冲突。 +- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估由此增加的延迟、成本和工具暴露范围。 #### 当前限制 -实验性模型会拒绝`reasoning.summary`、`max_tool_calls`,以及调用方提供的`multi_agent`或`betas`覆盖值。beta 不支持Responses `/compact`端点,但可以使用显式的`context_management.compact_threshold`,因为服务会自动独立压缩每个托管式智能体的上下文。 +实验性模型会拒绝 `reasoning.summary`、`max_tool_calls`,以及调用方提供的 `multi_agent` 或 `betas` 覆盖。Beta 版不支持 Responses `/compact` 端点,不过可以使用显式的 `context_management.compact_threshold`,因为服务会自动分别压缩每个托管智能体的上下文。 -一个`OpenAIHostedMultiAgentModel`实例同一时间最多拥有一个活跃的托管式响应。如果运行在等待本地函数输出时被放弃,请调用`await model.close()`释放其 WebSocket。目前不支持在其他进程或事件循环中恢复进行中的托管式响应。 +一个 `OpenAIHostedMultiAgentModel` 实例一次最多拥有一个活跃的托管响应。如果在等待本地函数输出时放弃某次运行,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复进行中的托管响应。 -有关底层Responses API beta 行为,请参阅[OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 +有关底层 Responses API Beta 行为,请参阅 [OpenAI 多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 -## 非OpenAI模型 +## 非 OpenAI 模型 -如果需要非OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多配置中,无需添加第三方适配器即可满足需求。每种模式的代码示例都位于[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)中。 +如果需要非 OpenAI 提供商,请从 SDK 的内置提供商集成点开始。对于许多配置,这已足够,无需添加第三方适配器。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 -### 非OpenAI提供商集成方式 +### 非 OpenAI 提供商集成方式 | 方式 | 适用场景 | 作用域 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 一个OpenAI兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 一个 OpenAI 兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | | [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应应用于单次运行 | 每次运行 | | [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同提供商或具体模型对象 | 每个智能体 | | 第三方适配器 | 由于内置路径无法提供所需能力,因此需要适配器提供的提供商覆盖范围或路由 | 请参阅[第三方适配器](#third-party-adapters) | -你可以通过以下内置路径集成其他 LLM 提供商: +可以通过以下内置路径集成其他 LLM 提供商: -1. [`set_default_openai_client`][agents.set_default_openai_client]适用于希望在全局范围内使用`AsyncOpenAI`实例作为 LLM 客户端的情况。这适用于 LLM 提供商具有OpenAI兼容 API 端点,并且你可以设置`base_url`和`api_key`的场景。可配置的代码示例请参阅[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 -2. [`ModelProvider`][agents.models.interface.ModelProvider]位于`Runner.run`级别。这样你可以指定“本次运行中的所有智能体都使用自定义模型提供商”。可配置的代码示例请参阅[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 -3. [`Agent.model`][agents.agent.Agent.model]允许你在特定 Agent 实例上指定模型。这样可以为不同智能体灵活搭配不同提供商。可配置的代码示例请参阅[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 +1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望全局使用 `AsyncOpenAI` 实例作为 LLM 客户端的情况。这适用于 LLM 提供商具有 OpenAI 兼容 API 端点,并且可以设置 `base_url` 和 `api_key` 的情况。可配置示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 级别。这样可以指定“为此次运行中的所有智能体使用自定义模型提供商”。可配置示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 +3. [`Agent.model`][agents.agent.Agent.model] 允许在特定 Agent 实例上指定模型。这样可以为不同智能体灵活搭配不同提供商。可配置示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 -如果你没有`platform.openai.com`的 API 密钥,建议通过`set_tracing_disabled()`禁用追踪,或配置[其他追踪处理器](../tracing.md)。 +如果没有 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或配置[其他追踪处理器](../tracing.md)。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 在这些代码示例中,我们使用Chat Completions API/模型,因为许多 LLM 提供商仍不支持Responses API。如果你的 LLM 提供商支持它,我们建议使用Responses。 + 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果你的 LLM 提供商支持 Responses API,建议使用 Responses。 ## 在一个工作流中混用模型 -在单个工作流中,你可能希望为每个智能体使用不同模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置[`Agent`][agents.Agent]时,可以通过以下任一方式选择特定模型: +在单个工作流中,可能希望每个智能体使用不同的模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: 1. 传入模型名称。 -2. 传入任意模型名称和一个可将该名称映射到 Model 实例的[`ModelProvider`][agents.models.interface.ModelProvider]。 -3. 直接提供[`Model`][agents.models.interface.Model]实现。 +2. 传入任意模型名称以及能够将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 +3. 直接提供 [`Model`][agents.models.interface.Model] 实现。 !!! note - 虽然我们的 SDK 同时支持[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]和[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]形式,但我们建议每个工作流仅使用一种模型形式,因为这两种形式支持的功能和工具集合不同。如果工作流需要混合搭配不同的模型形式,请确保使用的所有功能在两者上均可用。 + 虽然 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 两种形式,但建议每个工作流只使用一种模型形式,因为两者支持的功能和工具集合不同。如果工作流需要混用模型形式,请确保正在使用的所有功能都同时受两者支持。 ```python import asyncio @@ -391,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 直接设置OpenAI模型的名称。 -2. 提供[`Model`][agents.models.interface.Model]实现。 +1. 直接设置 OpenAI 模型的名称。 +2. 提供 [`Model`][agents.models.interface.Model] 实现。 -如果希望进一步配置智能体使用的模型,可以传入[`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 +如果要进一步配置智能体使用的模型,可以传入 [`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 ```python from agents import Agent, ModelSettings @@ -407,24 +407,24 @@ english_agent = Agent( ) ``` -## 高级OpenAI Responses设置 +## 高级 OpenAI Responses 设置 -当使用OpenAI Responses路径并需要更多控制时,请从`ModelSettings`开始。 +使用 OpenAI Responses 路径并需要更多控制时,请从 `ModelSettings` 开始。 -### 常用高级`ModelSettings`选项 +### 常用高级 `ModelSettings` 选项 -使用OpenAI Responses API时,多个请求字段已具有对应的直接`ModelSettings`字段,因此无需为它们使用`extra_args`。 +使用 OpenAI Responses API 时,多个请求字段已具有对应的 `ModelSettings` 直接字段,因此无需为它们使用 `extra_args`。 -- `parallel_tool_calls`:允许或禁止在同一轮中进行多个工具调用。 -- `truncation`:设置`"auto"`,让Responses API在上下文即将溢出时丢弃最旧的对话项,而不是失败。 -- `store`:控制生成的响应是否存储在服务端以供日后检索。这对于依赖响应 ID 的后续工作流,以及在`store=False`时可能需要回退到本地输入的会话压缩流程非常重要。 -- `context_management`:配置服务端上下文处理,例如使用`compact_threshold`进行Responses压缩。 +- `parallel_tool_calls`:允许或禁止在同一轮中进行多次工具调用。 +- `truncation`:设置 `"auto"`,使 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是请求失败。 +- `store`:控制生成的响应是否存储在服务端以供以后检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 +- `context_management`:配置服务端上下文处理,例如使用 `compact_threshold` 进行 Responses 压缩。 - `prompt_cache_retention`:为较早的模型系列配置延长保留时间,例如 - 使用`"24h"`。 -- `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置`"30m"`缓存 TTL。 -- `response_include`:请求更丰富的响应载荷,例如`web_search_call.action.sources`、`file_search_call.results`或`reasoning.encrypted_content`。 -- `top_logprobs`:请求输出文本的 top-token logprobs。SDK 还会自动添加`message.output_text.logprobs`。 -- `retry`:选择启用由 runner 管理的模型调用重试设置。请参阅[Runner 管理的重试](#runner-managed-retries)。 + 使用 `"24h"`。 +- `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置 `"30m"` 缓存 TTL。 +- `response_include`:请求更丰富的响应载荷,例如 `web_search_call.action.sources`、`file_search_call.results` 或 `reasoning.encrypted_content`。 +- `top_logprobs`:请求输出文本的最高概率 token logprobs。SDK 还会自动添加 `message.output_text.logprobs`。 +- `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅[由 Runner 管理的重试](#runner-managed-retries)。 ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。同一`ModelSettings.prompt_cache_options`字段会原样传递到Responses和Chat Completions请求中,而Chat Completions转换器会保留文本、图像、音频和文件内容部分上的断点。 +使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。相同的 `ModelSettings.prompt_cache_options` 字段会透传到 Responses 和 Chat Completions 请求,Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 ```python from agents import Runner @@ -470,18 +470,19 @@ result = await Runner.run( ) ``` -对于使用旧版保留控制的较早模型系列,`prompt_cache_retention`仍然可用。不要将直接的`ModelSettings`字段与 -`extra_args`中的相同键组合使用。 +`prompt_cache_retention` 仍可用于采用旧版 +保留控制的较早模型系列。请勿同时使用直接 `ModelSettings` 字段和 +`extra_args` 中的同名键。 -设置`store=False`后,Responses API不会保留该响应以供后续服务端检索。这对于无状态或零数据保留风格的流程很有用,但也意味着原本会复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当最后一个响应未存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]会将其默认`"auto"`压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 +设置 `store=False` 后,Responses API 不会保留该响应供以后在服务端检索。这适用于无状态或零数据保留类型的流程,但也意味着原本会复用响应 ID 的功能必须改为依赖本地管理的状态。例如,当上一个响应未存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将其默认 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 -服务端压缩不同于[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]`会随每个Responses API请求发送,当渲染后的上下文超过阈值时,API 可以在响应中生成压缩项。`OpenAIResponsesCompactionSession`会在轮次之间调用独立的`responses.compact`端点,并重写本地会话历史记录。 +服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 随每次 Responses API 请求发送,当渲染后的上下文超过阈值时,API 可以在响应中发出压缩项。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 -### `extra_args`的传递 +### `extra_args` 的传递 -当你需要 SDK 尚未直接在顶层公开的提供商特定字段或较新的请求字段时,请使用`extra_args`。 +如果需要 SDK 尚未在顶层直接公开的提供商特定字段或较新的请求字段,请使用 `extra_args`。 -使用OpenAI模型时,`extra_args`可以向Responses API和Chat Completions API传递可选参数,例如`user`和`service_tier`。对于受支持的模型,请设置`extra_args={"service_tier": "fast"}`以使用[快速模式](https://developers.openai.com/api/docs/guides/fast-mode);`"priority"`仍与其等效。不要同时通过直接的`ModelSettings`字段设置同一个请求字段。 +使用 OpenAI 模型时,`extra_args` 可以向 Responses API 和 Chat Completions API 传递可选参数,例如 `user` 和 `service_tier`。对于受支持的模型,设置 `extra_args={"service_tier": "fast"}` 可使用[快速模式](https://developers.openai.com/api/docs/guides/fast-mode);`"priority"` 仍与其等效。请勿同时通过直接 `ModelSettings` 字段设置同一请求字段。 ```python from agents import Agent, ModelSettings @@ -497,11 +498,11 @@ english_agent = Agent( ) ``` -## Runner 管理的重试 +## 由 Runner 管理的重试 -重试仅在运行时生效,并且需要主动启用。除非设置`ModelSettings(retry=...)`且重试策略选择重试,否则 SDK 不会重试一般模型请求。 +重试仅在运行时生效,并且需要选择启用。除非设置 `ModelSettings(retry=...)` 且重试策略决定重试,否则 SDK 不会重试常规模型请求。 -在Responses websocket传输中,`retry_policies.provider_suggested()`会将响应前的过载帧和无代码的`server_error`帧识别为重试建议。这本身不会启用重试:你仍需设置`ModelRetrySettings`,且常规重放安全检查仍然适用。如果已经收到任何响应事件,SDK 不会重放请求。 +在 Responses WebSocket 传输中,`retry_policies.provider_suggested()` 会将响应前的过载帧和无代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:仍需设置 `ModelRetrySettings`,并且常规重放安全检查仍然适用。如果已经收到任何响应事件,SDK 不会重放请求。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -529,85 +530,88 @@ agent = Agent( ) ``` -`ModelRetrySettings`包含三个字段: +`ModelRetrySettings` 包含三个字段:
| 字段 | 类型 | 说明 | | --- | --- | --- | -| `max_retries` | `int | None` | 初始请求后允许的重试次数。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 策略决定重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay`仅限制计算所得的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | -| `policy` | `RetryPolicy | None` | 决定是否重试的回调。此字段仅在运行时使用,不会被序列化。 | +| `max_retries` | `int | None` | 初始请求之后允许的重试次数。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略进行重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制计算得出的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | +| `policy` | `RetryPolicy | None` | 决定是否重试的回调。此字段仅在运行时生效,不会被序列化。 |
-重试策略会接收一个[`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: +重试策略会接收一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: -- `attempt`和`max_retries`,供你根据尝试次数作出决策。 -- `stream`,供你区分流式与非流式行为。 +- `attempt` 和 `max_retries`,以便根据尝试次数作出决策。 +- `stream`,以便区分流式与非流式行为。 - `error`,用于原始数据检查。 -- `normalized`信息,例如`status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`和`is_abort`。 -- `provider_advice`,在底层模型适配器能够提供重试指导时使用。 +- `normalized` 事实,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 +- `provider_advice`,当底层模型适配器可以提供重试指导时使用。 +- `response_started`、`replay_safety` 和 `stateful_request`,它们是在策略运行前捕获的稳定重放安全事实。`replay_safety` 是 `"safe"`、`"unsafe"` 或 `"unknown"`;当请求使用 `previous_response_id` 或 `conversation_id` 时,`stateful_request` 为 true。 策略可以返回以下任一内容: -- `True`/`False`,用于简单的重试决策。 -- [`RetryDecision`][agents.retry.RetryDecision],用于覆盖延迟或附加诊断原因。 +- `True` / `False`,用于简单的重试决策。 +- 当需要覆盖延迟、附加诊断原因或显式批准范围有限的不安全重放时,返回 [`RetryDecision`][agents.retry.RetryDecision]。 -SDK 在`retry_policies`上导出了现成的辅助工具: +SDK 在 `retry_policies` 上导出了现成的辅助工具: | 辅助工具 | 行为 | | --- | --- | -| `retry_policies.never()` | 始终不启用重试。 | -| `retry_policies.provider_suggested()` | 在可用时遵循提供商的重试建议。 | -| `retry_policies.network_error()` | 匹配暂时性传输故障和超时故障。 | +| `retry_policies.never()` | 始终不启用。 | +| `retry_policies.provider_suggested()` | 在提供商提供重试建议时遵循该建议。 | +| `retry_policies.network_error()` | 匹配临时传输和超时故障。 | | `retry_policies.http_status([...])` | 匹配选定的 HTTP 状态码。 | -| `retry_policies.retry_after()` | 仅在存在 retry-after 提示时重试,并使用该延迟。此辅助工具将 retry-after 值视为显式策略延迟,因此`backoff.max_delay`不会限制它。 | -| `retry_policies.any(...)` | 任意嵌套策略选择启用时即重试。 | -| `retry_policies.all(...)` | 仅在所有嵌套策略都选择启用时重试。 | +| `retry_policies.retry_after()` | 仅在提供 retry-after 提示时重试,并使用该延迟。此辅助工具将 retry-after 值视为显式策略延迟,因此 `backoff.max_delay` 不会限制它。 | +| `retry_policies.any(...)` | 当任一嵌套策略选择启用时重试。 | +| `retry_policies.all(...)` | 仅当所有嵌套策略都选择启用时重试。 | -组合策略时,`provider_suggested()`是最安全的首选基础组件,因为当提供商可以区分否决意见和重放安全批准时,它会保留这些信息。 +组合策略时,`provider_suggested()` 是最安全的首个基础组件,因为当提供商能够区分否决和重放安全批准时,它会保留这些信息。 ##### 安全边界 -某些失败绝不会自动重试: +以下某些故障绝不会重试: - 中止错误。 -- 提供商建议将重放标记为不安全的请求。 -- 已开始输出且重放会不安全的流式运行。 +- 已经开始输出且重放会不安全的流式运行。 +- 存在单独本地副作用重放否决的请求,包括程序化工具调用请求,除非提供商已独立将重放标记为安全。 -使用`previous_response_id`或`conversation_id`的有状态后续请求也会以更保守的方式处理。对于这些请求,`network_error()`或`http_status([500])`等非提供商谓词本身并不足够。重试策略应包含提供商给出的重放安全批准,通常通过`retry_policies.provider_suggested()`实现。 +默认情况下,提供商标记为不安全的故障也会被阻止。对于不存在单独本地副作用否决的非流式请求,应用可以通过返回 `RetryDecision(retry=True, approve_unsafe_replay=True)` 接受提供商侧的重放风险。授予此批准前,请检查 `context.response_started`、`context.replay_safety` 和 `context.stateful_request`,并且仅在可以接受重复执行提供商侧工作时授予批准。普通的 `RetryDecision(retry=True)` 绝不会绕过重放保护,`approve_unsafe_replay=True` 也无法授权流式重试或本地副作用。 -##### Runner 与智能体的合并行为 +使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求在重放安全性未知时会以失败关闭。对于这些请求,仅使用 `network_error()` 或 `http_status([500])` 等非提供商谓词还不够。请包含提供商的重放安全批准,通常通过 `retry_policies.provider_suggested()` 实现;或者按照上述方式,显式批准提供商标记为不安全的非流式故障。 -Runner 级和智能体级`ModelSettings`之间会深度合并`retry`: +##### Runner 与智能体合并行为 -- 智能体可以仅覆盖`retry.max_retries`,同时继承 Runner 的`policy`。 -- 智能体可以仅覆盖`retry.backoff`的一部分,并保留 Runner 中同级的其他退避字段。 -- `policy`仅在运行时使用,因此序列化的`ModelSettings`会保留`max_retries`和`backoff`,但省略回调本身。 +`retry` 会在 Runner 级和智能体级 `ModelSettings` 之间进行深度合并: -更多代码示例请参阅[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py)和[基于适配器的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 +- 智能体可以仅覆盖 `retry.max_retries`,并继续继承 Runner 的 `policy`。 +- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留 Runner 的同级退避字段。 +- `policy` 仅在运行时生效,因此序列化的 `ModelSettings` 会保留 `max_retries` 和 `backoff`,但省略回调本身。 -## 非OpenAI提供商故障排除 +更完整的代码示例请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[基于适配器的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 + +## 非 OpenAI 提供商故障排除 ### 追踪客户端错误 401 -如果遇到与追踪相关的错误,这是因为追踪数据会上传到OpenAI服务器,而你没有OpenAI API 密钥。可通过以下三种方式解决: +如果遇到与追踪相关的错误,这是因为追踪数据会上传到 OpenAI 服务器,而你没有 OpenAI API 密钥。可通过以下三种方式解决: 1. 完全禁用追踪:[`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. 为追踪设置OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,且必须来自[platform.openai.com](https://platform.openai.com/)。 -3. 使用非OpenAI追踪处理器。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 +2. 为追踪设置 OpenAI 密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 +3. 使用非 OpenAI 追踪处理器。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 -### Responses API支持 +### Responses API 支持 -SDK 默认使用Responses API,但许多其他 LLM 提供商仍不支持它。因此,你可能会看到 404 或类似问题。可通过以下两种方式解决: +SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,可能会看到 404 或类似问题。可通过以下两种方式解决: -1. 调用[`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果你通过环境变量设置`OPENAI_API_KEY`和`OPENAI_BASE_URL`,此方法适用。 -2. 使用[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)提供了代码示例。 +1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方式适用。 +2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。相关代码示例见[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 -### Chat Completions兼容性选项 +### Chat Completions 兼容性选项 -通过Chat Completions进行路由时,SDK 会静默丢弃Chat Completions无法发送的仅限Responses字段,例如`previous_response_id`、`conversation_id`、Responses API的`prompt`字段,或并非纯文本的工具输出,以保持兼容性。如果希望这些不匹配问题在开发期间快速失败,请在OpenAI提供商上启用严格功能验证: +通过 Chat Completions 路由时,SDK 会静默丢弃 Chat Completions 无法发送的仅限 Responses 字段,以保持兼容性,例如 `previous_response_id`、`conversation_id`、Responses API `prompt` 字段,或并非纯文本的工具输出。如果希望这些不匹配问题在开发期间快速失败,请在 OpenAI 提供商上启用严格功能验证: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -625,9 +629,11 @@ result = await Runner.run( ) ``` -如果使用[`MultiProvider`][agents.MultiProvider],请改为传入`openai_strict_feature_validation=True`。 +如果使用 [`MultiProvider`][agents.MultiProvider],请改为传入 `openai_strict_feature_validation=True`。 + +OpenAI Chat Completions API 可以返回音频输出,但 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 当前不会将音频输出转换为 Agents SDK 运行项。如果非流式消息或流式增量包含音频输出,适配器会引发 `AgentsException("Audio is not currently supported")`,而不是返回部分结果或空结果。对于由 SDK 管理的音频工作流,请使用[实时智能体](../realtime/guide.md)或[语音智能体](../voice/quickstart.md)。 -一些OpenAI兼容的Chat Completions提供商会分块传输工具调用增量,但这些分块不够可靠,无法供 SDK 进行增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后生成工具调用: +一些 OpenAI 兼容的 Chat Completions 提供商会以分块形式流式传输工具调用增量,其可靠性不足以支持 SDK 增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后发出工具调用: ```python from agents import OpenAIProvider @@ -638,11 +644,11 @@ provider = OpenAIProvider( ) ``` -对于[`MultiProvider`][agents.MultiProvider],请使用`openai_buffer_streamed_tool_calls=True`。 +对于 [`MultiProvider`][agents.MultiProvider],请使用 `openai_buffer_streamed_tool_calls=True`。 -### structured outputs支持 +### structured outputs 支持 -某些模型提供商不支持[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: +一些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: ``` @@ -650,42 +656,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -这是某些模型提供商的局限:它们支持 JSON 输出,但不允许你指定输出所使用的`json_schema`。我们正在修复此问题,但建议依赖支持 JSON schema 输出的提供商,否则应用程序通常会因格式错误的 JSON 而中断。 +这是某些模型提供商的不足之处——它们支持 JSON 输出,但不允许指定用于输出的 `json_schema`。我们正在解决此问题,但建议依赖支持 JSON schema 输出的提供商,否则应用经常会因格式错误的 JSON 而中断。 ## 跨提供商混用模型 -你需要了解模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI支持structured outputs、多模态输入,以及托管式文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: +你需要了解模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI 支持 structured outputs、多模态输入、托管文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: -- 不要向无法理解相应`tools`的提供商发送它们 -- 在调用纯文本模型之前过滤掉多模态输入 -- 请注意,不支持结构化 JSON 输出的提供商有时会生成无效 JSON。 +- 不要向无法理解的提供商发送不受支持的 `tools` +- 调用纯文本模型前过滤掉多模态输入 +- 请注意,不支持结构化 JSON 输出的提供商偶尔会生成无效 JSON。 ## 第三方适配器 -仅当 SDK 的内置提供商集成点不足以满足需求时,才使用第三方适配器。如果此 SDK 仅使用OpenAI模型,请优先使用内置[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将OpenAI模型与非OpenAI提供商结合使用,或需要仅由适配器提供的提供商覆盖范围或路由的情况。适配器在 SDK 与上游模型提供商之间增加了一个兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的 beta 适配器集成形式提供 Any-LLM 和 LiteLLM。 +仅当 SDK 的内置提供商集成点不足以满足需求时,才使用第三方适配器。如果只通过此 SDK 使用 OpenAI 模型,请优先选择内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI 模型与非 OpenAI 提供商结合使用,或需要只有适配器才能提供的提供商覆盖范围或路由的情况。适配器会在 SDK 与上游模型提供商之间增加一层兼容层,因此功能支持和请求语义可能因提供商而异。SDK 当前以尽力支持的 Beta 适配器集成形式包含 Any-LLM 和 LiteLLM。 ### Any-LLM -Any-LLM 支持以尽力支持的 beta 形式提供,适用于需要由 Any-LLM 管理提供商覆盖范围或路由的情况。 +对于需要由 Any-LLM 管理提供商覆盖范围或路由的情况,Any-LLM 支持以尽力支持的 Beta 形式提供。 -根据上游提供商路径,Any-LLM 可能会使用Responses API、Chat Completions兼容 API 或提供商特定的兼容层。 +根据上游提供商路径,Any-LLM 可能会使用 Responses API、与 Chat Completions 兼容的 API,或提供商特定的兼容层。 -如果需要 Any-LLM,请安装`openai-agents[any-llm]`,然后从[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py)或[`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)开始。你可以将`any-llm/...`模型名称与[`MultiProvider`][agents.MultiProvider]搭配使用,直接实例化`AnyLLMModel`,或在运行作用域使用`AnyLLMProvider`。如果需要显式固定模型接口,请在构造`AnyLLMModel`时传入`api="responses"`或`api="chat_completions"`。 +如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 配合使用,直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 -Any-LLM 仍是第三方适配器层,因此提供商依赖项和功能缺口由上游 Any-LLM 定义,而非由 SDK 定义。当上游提供商返回使用量指标时,系统会自动传播这些指标,但流式Chat Completions后端可能需要`ModelSettings(include_usage=True)`才会生成使用量数据块。如果你依赖structured outputs、工具调用、使用量报告或Responses特定行为,请验证计划部署的具体提供商后端。 +Any-LLM 仍是第三方适配器层,因此提供商依赖项和能力缺口由上游 Any-LLM 而非 SDK 定义。当上游提供商返回用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要 `ModelSettings(include_usage=True)` 才会发出用量数据块。如果依赖 structured outputs、工具调用、用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 ### LiteLLM -LiteLLM 支持以尽力支持的 beta 形式提供,适用于需要 LiteLLM 特定提供商覆盖范围或路由的情况。 +对于需要 LiteLLM 特定提供商覆盖范围或路由的情况,LiteLLM 支持以尽力支持的 Beta 形式提供。 -如果需要 LiteLLM,请安装`openai-agents[litellm]`,然后从[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py)或[`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)开始。你可以使用`litellm/...`模型名称,也可以直接实例化[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 +如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。可以使用 `litellm/...` 模型名称,或直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 -通过 LiteLLM 适配器访问的某些提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入`ModelSettings(include_usage=True)`;如果你依赖structured outputs、工具调用、使用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 +通过 LiteLLM 适配器访问的部分提供商默认不会填充 SDK 用量指标。如果需要用量报告,请传入 `ModelSettings(include_usage=True)`;如果依赖 structured outputs、工具调用、用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 -如果 LiteLLM 为响应对象生成 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器之前选择启用 SDK 的兼容性补丁: +如果 LiteLLM 为响应对象发出 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器之前选择启用 SDK 的兼容性补丁: ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -该补丁默认禁用,仅在值为`1`或`true`时启用。它通过包装一个私有 LiteLLM 日志辅助工具来抑制特定类型的 LiteLLM 响应序列化警告,因此应将其视为针对性解决方案,而不是通用序列化设置。由于它依赖私有 LiteLLM API,升级 LiteLLM 时请重新验证该补丁,并在上游警告不再出现后移除该环境变量。 \ No newline at end of file +该补丁默认禁用,并且仅对 `1` 或 `true` 值启用。它通过封装一个私有 LiteLLM 日志辅助工具来抑制特定类别的 LiteLLM 响应序列化警告,因此应将其视为针对性解决方法,而不是通用序列化设置。由于它依赖私有 LiteLLM API,升级 LiteLLM 时请重新验证;当上游警告不再出现时,请移除该环境变量。 \ No newline at end of file diff --git a/docs/zh/realtime/guide.md b/docs/zh/realtime/guide.md index 0ebeede866..e2ba2410ee 100644 --- a/docs/zh/realtime/guide.md +++ b/docs/zh/realtime/guide.md @@ -4,48 +4,48 @@ search: --- # 实时智能体指南 -本指南介绍 OpenAI Agents SDK 的实时层如何映射到 OpenAI Realtime API,以及 Python SDK 在此基础上增加的额外行为。 +本指南说明OpenAI Agents SDK的实时层如何映射到OpenAI Realtime API,以及Python SDK在此基础上增加了哪些额外行为。 !!! note "从这里开始" - 如果你希望使用默认的 Python 路径,请先阅读[快速入门](quickstart.md)。如果你正在决定应用应使用服务端 WebSocket 还是 SIP,请阅读[实时传输](transport.md)。浏览器 WebRTC 传输不属于 Python SDK。 + 如果希望使用默认的Python路径,请先阅读[快速入门](quickstart.md)。如果正在决定应用应使用服务器端WebSocket还是SIP,请阅读[实时传输](transport.md)。浏览器WebRTC传输不属于Python SDK的一部分。 ## 概述 -实时智能体会与 Realtime API 保持长期连接,以便模型增量处理文本和音频、以流式方式输出音频、调用工具并处理中断,而无需每轮都重新发起请求。 +实时智能体与Realtime API保持长连接,使模型能够以增量方式处理文本和音频、流式传输音频输出、调用工具并处理中断,而无需在每轮对话时重新发起新请求。 -主要 SDK 组件包括: +主要SDK组件包括: -- **RealtimeAgent**:一名实时专用智能体的指令、工具、输出安全防护措施和任务转移 -- **RealtimeRunner**:将起始智能体连接到实时传输的会话工厂 -- **RealtimeSession**:发送输入、接收事件、追踪历史记录并执行工具的实时会话 -- **RealtimeModel**:传输抽象。默认实现是 OpenAI 的服务端 WebSocket。 +- **RealtimeAgent**:一个实时专家的指令、工具、输出安全防护措施和任务转移 +- **RealtimeRunner**:将起始智能体连接到实时传输层的会话工厂 +- **RealtimeSession**:用于发送输入、接收事件、追踪历史记录和执行工具的实时会话 +- **RealtimeModel**:传输抽象。默认实现是OpenAI的服务器端WebSocket。 ## 会话生命周期 典型的实时会话如下: -1. 创建一个或多个 `RealtimeAgent`。 -2. 使用起始智能体创建 `RealtimeRunner`。 -3. 调用 `await runner.run()` 获取 `RealtimeSession`。 -4. 使用 `async with session:` 或 `await session.enter()` 进入会话。 -5. 使用 `send_message()` 或 `send_audio()` 发送用户输入。 +1. 创建一个或多个`RealtimeAgent`。 +2. 使用起始智能体创建`RealtimeRunner`。 +3. 调用`await runner.run()`以获取`RealtimeSession`。 +4. 使用`async with session:`或`await session.enter()`进入会话。 +5. 使用`send_message()`或`send_audio()`发送用户输入。 6. 迭代处理会话事件,直到对话结束。 -与纯文本运行不同,`runner.run()` 不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和当前智能体配置与传输层保持同步。 +与纯文本运行不同,`runner.run()`不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和活动智能体配置与传输层保持同步。 -默认情况下,`RealtimeRunner` 使用 `OpenAIRealtimeWebSocketModel`,因此默认 Python 路径是连接到 Realtime API 的服务端 WebSocket。如果传入不同的 `RealtimeModel`,仍可使用相同的会话生命周期和智能体功能,但连接机制可以有所不同。 +默认情况下,`RealtimeRunner`使用`OpenAIRealtimeWebSocketModel`,因此默认的Python路径是与Realtime API建立服务器端WebSocket连接。如果传入其他`RealtimeModel`,仍可使用相同的会话生命周期和智能体功能,但连接机制可以改变。 ## 智能体与会话配置 -`RealtimeAgent` 的范围有意设计得比常规 `Agent` 类型更窄: +`RealtimeAgent`的适用范围有意设计得比常规`Agent`类型更窄: - 模型选择在会话级别配置,而不是按智能体配置。 -- 不支持 Structured outputs。 -- 可以配置语音,但在会话已经生成语音后无法更改。 -- 指令、函数工具、任务转移、钩子和输出安全防护措施仍然可用。 +- 不支持structured outputs。 +- 可以配置语音,但会话生成语音音频后便无法更改。 +- 指令、函数工具、任务转移、钩子和输出安全防护措施仍然全部可用。 -`RealtimeSessionModelSettings` 同时支持较新的嵌套 `audio` 配置和旧版扁平别名。新代码应优先使用嵌套形式,并为新的实时智能体从 `gpt-realtime-2.1` 开始: +`RealtimeSessionModelSettings`既支持较新的嵌套`audio`配置,也支持旧版扁平别名。对于新代码,建议使用嵌套结构;对于新的实时智能体,请从`gpt-realtime-2.1`开始: ```python runner = RealtimeRunner( @@ -67,7 +67,7 @@ runner = RealtimeRunner( ) ``` -常用的会话级设置包括: +实用的会话级设置包括: - `audio.input.format`、`audio.output.format` - `audio.input.transcription` @@ -79,7 +79,7 @@ runner = RealtimeRunner( - `prompt` - `tracing` -`RealtimeRunner(config=...)` 上常用的运行级设置包括: +`RealtimeRunner(config=...)`上的实用运行级设置包括: - `async_tool_calls` - `output_guardrails` @@ -87,13 +87,67 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -有关完整的类型化接口,请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 +有关完整的类型化接口,请参阅[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]和[`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 + +### 输入转录设置 + +在`audio.input.transcription`下配置输入转录。使用`gpt-live-transcribe`可获得低延迟增量转录;通过WebSocket使用`gpt-transcribe`,则可在提交一个音频轮次后开始转录,或在应用需要输出检测到的语言时进行转录。Agents SDK会在嵌套会话配置中转发特定于模型的GA转录设置: + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-live-transcribe", + "prompt": "A support call about the OpenAI Agents SDK.", + "keywords": ["RunState", "MCPServerManager"], + "languages": ["en", "ja"], + }, + "turn_detection": None, + } + } + } + }, +) +``` + +对于`gpt-live-transcribe`,`prompt`提供自由形式的录音上下文,`keywords`列出音频中可能出现的字面术语,`languages`列出预期的输入语言。此模型使用复数形式的`languages`,而不是单数形式的`language`;请勿同时发送这两个字段。 + +此SDK固定使用的OpenAI客户端版本仅支持将`delay`与`gpt-realtime-whisper`配合使用。请按以下方式配置该模型的延迟与准确度权衡: + +```python +runner = RealtimeRunner( + starting_agent=agent, + config={ + "model_settings": { + "audio": { + "input": { + "transcription": { + "model": "gpt-realtime-whisper", + "delay": "low", + }, + "turn_detection": None, + } + } + } + }, +) +``` + +`delay`设置接受`minimal`、`low`、`medium`、`high`或`xhigh`。较低的值可以更早生成部分文本,而较高的值可为转录模型提供更多音频上下文,并可能提高识别准确度。请使用有代表性的音频进行基准测试,不要假定任何级别具有固定的时间表现。 + +仅当应在提交音频轮次后开始转录,或应用需要输出检测到的语言时,才应在通过WebSocket建立的实时会话中使用`gpt-transcribe`。该模型会自动将之前已转录的轮次用作上下文。`gpt-transcribe`完成事件会在其`languages`输出字段中报告检测到的语言。此输出字段不同于上文所示的`gpt-live-transcribe`预期语言输入。 + +将`audio.input.turn_detection`设为`None`会禁用自动轮次检测。随后,应用必须按照[手动响应控制](#manual-response-control)中的说明提交音频轮次并控制响应创建。有关模型行为、验证规则和延迟指导,请参阅OpenAI API的[实时转录指南](https://developers.openai.com/api/docs/guides/realtime-transcription)。 ## 输入与输出 ### 文本与结构化用户消息 -使用 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] 发送纯文本或结构化实时消息。 +使用[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]发送纯文本或结构化实时消息。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +165,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -结构化消息是在实时对话中加入图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) 中的 Web 演示代码示例会以这种方式转发 `input_image` 消息。 +结构化消息是在实时对话中包含图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)中的示例Web演示会以这种方式转发`input_image`消息。 ### 音频输入 -使用 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] 流式传输原始音频字节: +使用[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]流式传输原始音频字节: ```python await session.send_audio(audio_bytes) ``` -如果禁用了服务端轮次检测,你需要负责标记轮次边界。高层便捷方法如下: +如果禁用了服务器端轮次检测,则需要自行标记轮次边界。高级便捷方式如下: ```python await session.send_audio(audio_bytes, commit=True) ``` -如果需要更底层的控制,也可以通过底层模型传输直接发送 Realtime API 客户端事件,例如 `input_audio_buffer.commit`。 +如果需要更低层级的控制,也可以直接通过底层模型传输层发送Realtime API客户端事件,例如`input_audio_buffer.commit`。 ### 手动响应控制 -`session.send_message()` 会通过高层路径发送用户输入,并为你启动响应。在某些配置中,原始音频缓冲**不会**自动执行相同操作。 +`session.send_message()`使用高级路径发送用户输入,并为你启动响应。在某些配置中,原始音频缓冲**不会**自动执行相同操作。 -在 Realtime API 层面,手动轮次控制意味着发送一个 `session.update` 事件,将 `turn_detection` 设置为 `null`,然后自行发送 `input_audio_buffer.commit` 和 `response.create`。 +在Realtime API层面,手动轮次控制意味着发送一个将`turn_detection`设为`null`的`session.update`事件,然后自行发送`input_audio_buffer.commit`和`response.create`。 -如果你正在手动管理轮次,可以通过模型传输发送原始客户端事件: +如果正在手动管理轮次,可以通过模型传输层发送原始客户端事件: ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -151,15 +205,15 @@ await session.model.send_event( 此模式适用于以下情况: -- 禁用了 `turn_detection`,且你希望自行决定模型何时响应 -- 希望在触发响应之前检查或管控用户输入 +- 已禁用`turn_detection`,并且希望自行决定模型何时响应 +- 希望在触发响应之前检查用户输入或设置门控 - 需要为带外响应使用自定义提示词 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) 中的 SIP 代码示例使用原始 `response.create` 强制生成开场问候。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)中的SIP代码示例使用原始`response.create`强制生成开场问候语。 ## 事件、历史记录与中断 -`RealtimeSession` 会发出更高层的 SDK 事件,同时仍会在需要时转发原始模型事件。 +`RealtimeSession`会发出更高级别的SDK事件,同时在需要时仍会转发原始模型事件。 重要的会话事件包括: @@ -173,13 +227,13 @@ await session.model.send_event( - `error` - `raw_model_event` -对 UI 状态最有用的事件通常是 `history_added` 和 `history_updated`。它们以 `RealtimeItem` 对象的形式公开会话的本地历史记录,包括用户消息、助手消息和工具调用。 +对于UI状态,最实用的事件通常是`history_added`和`history_updated`。它们会以`RealtimeItem`对象的形式公开会话的本地历史记录,其中包括用户消息、助手消息和工具调用。 ### 用量统计 -当已完成的模型响应包含用量信息时,SDK 的 OpenAI `RealtimeModel` 传输会在 `raw_model_event` 内发出 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其 `usage` 字段包含该响应的 token 数量,而 `input_tokens_details` 和 `output_tokens_details` 则提供可选的模态细分。 +当已完成的模型响应包含用量信息时,SDK的OpenAI `RealtimeModel`传输层会在`raw_model_event`中发出一个[`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其`usage`字段包含该响应的token计数,而`input_tokens_details`和`output_tokens_details`提供可选的模态明细。 -会话还会将每个响应的用量添加到共享的 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] 中。若要查看实时会话的累计用量,可在后续的高层事件(例如 `agent_end`)中从 `event.info.context.usage` 读取。 +会话还会将每个响应的用量添加到共享的[`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]中。在后续高级事件(例如`agent_end`)中从`event.info.context.usage`读取它,即可检查实时会话的累计用量。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -197,15 +251,15 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -只有当模型提供商在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该 `RealtimeSession` 收到的响应,并非跨会话总量。 +只有当模型提供商在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该`RealtimeSession`收到的响应;它不是跨会话总计。 ### 中断与播放追踪 -当用户打断助手时,会话会发出 `audio_interrupted` 并更新历史记录,使服务端对话与用户实际听到的内容保持一致。 +当用户打断助手时,会话会发出`audio_interrupted`并更新历史记录,使服务器端对话与用户实际听到的内容保持一致。 -对于低延迟本地播放,默认播放追踪器通常已足够。对于远程或延迟播放场景,尤其是电话场景,请使用 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],这样被中断的响应会在实际播放位置处截断,而不是假定所有已生成的音频都已播放给用户。 +对于低延迟本地播放,默认的播放追踪器通常已经足够。在远程或延迟播放场景中,尤其是电话场景,请使用[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],使被中断的响应在实际播放位置截断,而不是假定所有已生成的音频都已被用户听到。 -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) 中的 Twilio 代码示例展示了此模式。 +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)中的Twilio代码示例展示了此模式。 ## 工具、审批、任务转移与安全防护措施 @@ -232,9 +286,9 @@ agent = RealtimeAgent( ### 工具审批 -函数工具可以要求在执行前获得人工审批。出现这种情况时,会话会发出 `tool_approval_required`,并暂停工具运行,直到你调用 `approve_tool_call()` 或 `reject_tool_call()`。 +函数工具可以要求在执行前进行人工审批。发生这种情况时,会话会发出`tool_approval_required`并暂停工具运行,直到调用`approve_tool_call()`或`reject_tool_call()`。 -如果工具还配置了输入安全防护措施,这些安全防护措施会在获得审批后、执行前立即运行。若要在发出审批事件前运行它们,请使用 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` 创建运行器。通过此次审批前检查的调用,在获得审批后、执行前仍会再次接受检查。 +如果工具还具有输入安全防护措施,则这些安全防护措施会在审批后、执行前立即运行。若要在发出审批事件之前运行它们,请使用`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`创建运行器。通过此审批前检查的调用仍会在审批后、执行前再次接受检查。 ```python async for event in session: @@ -242,11 +296,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -有关具体的服务端审批循环,请参阅 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。人工介入文档中的[人工介入](../human_in_the_loop.md)也会引用此流程。 +有关具体的服务器端审批循环,请参阅[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。[人工介入](../human_in_the_loop.md)文档也会引导你返回此流程。 ### 任务转移 -实时任务转移允许一个智能体将实时对话移交给另一个专用智能体: +实时任务转移允许一个智能体将实时对话转交给另一个专家: ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +322,11 @@ main_agent = RealtimeAgent( ) ``` -直接用作任务转移的 `RealtimeAgent` 对象会被自动封装,而 `realtime_handoff(...)` 可用于自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的 `input_filter`。 +直接用作任务转移的`RealtimeAgent`对象会被自动包装,而`realtime_handoff(...)`可用于自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的`input_filter`。 ### 安全防护措施 -实时智能体支持对智能体响应实施输出安全防护措施,以及对函数工具调用实施输入安全防护措施。输出安全防护措施检查采用防抖机制:每次检查都会基于累积的输出文本和音频转录增量运行,而不是针对每个局部增量运行,并且会发出 `guardrail_tripped`,而不是抛出异常。 +实时智能体支持针对智能体响应的输出安全防护措施,以及针对函数工具调用的输入安全防护措施。输出安全防护措施检查会进行防抖:每次检查都基于累积的输出文本和音频转录增量运行,而不是针对每个部分增量运行,并且会发出`guardrail_tripped`而不是引发异常。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,15 +346,15 @@ agent = RealtimeAgent( ) ``` -当实时输出安全防护措施因音频转录而触发时,会话会中断当前响应、强制执行 `response.cancel`、发出 `guardrail_tripped`,并发送一条后续用户消息,其中指明被触发的安全防护措施,以便模型生成替代响应。音频播放器仍应监听 `audio_interrupted` 并立即停止本地播放,因为触发机制启动时可能已有部分音频进入缓冲区。使用内置的 OpenAI Realtime 传输时,如果安全防护措施检查在其所检查的响应结束后才完成,会话只会中断该响应的缓冲播放,而不会取消之后启动的任何响应。对于纯文本输出,会话会改为发送一个响应范围内的 `response.cancel`;由于没有音频播放需要停止,因此不会发出 `audio_interrupted`。使用内置 OpenAI Realtime 模型时,纯文本路径也会发出相同的 `guardrail_tripped` 事件和后续用户消息。 +当实时输出安全防护措施因音频转录而触发时,会话会中断活动响应、强制执行`response.cancel`、发出`guardrail_tripped`,并发送一条指出已触发安全防护措施的后续用户消息,使模型能够生成替代响应。音频播放器仍应监听`audio_interrupted`并立即停止本地播放,因为触发器触发时可能已有部分音频进入缓冲区。使用内置OpenAI Realtime传输层时,如果安全防护措施检查在其检查的响应结束后才完成,会话只会中断该响应的缓冲播放,而不会取消稍后启动的任何响应。对于纯文本输出,会话则会发送一个限定于响应的`response.cancel`;由于没有需要停止的音频播放,因此不会发出`audio_interrupted`。使用内置OpenAI Realtime模型时,纯文本路径也会发出相同的`guardrail_tripped`事件和后续用户消息。 -自定义 `RealtimeModel` 传输必须遵循 `RealtimeModelSendInterrupt.response_id` 和 `playback_only`,以提供相同的、限定来源范围的音频中断行为。它们还必须重写 `RealtimeModel.send_event_if()`,以支持纯文本输出路径的恢复消息。实现必须在传输的实际事件提交边界重新检查所提供的条件,或者将条件检查与事件提交串行化。默认实现会安全地跳过恢复消息,因为如果它只检查一次条件,随后再单独发送事件,那么在条件检查与事件提交之间可能会启动另一个响应;响应取消和 `guardrail_tripped` 事件仍会发生。 +自定义`RealtimeModel`传输层必须遵循`RealtimeModelSendInterrupt.response_id`和`playback_only`,以提供相同的源范围音频中断行为。它们还必须重写`RealtimeModel.send_event_if()`,以支持纯文本输出路径的恢复消息。实现必须在传输层实际提交事件的边界重新检查所提供的条件,或者将条件检查与事件提交串行化。默认实现会安全地跳过恢复消息,因为如果它只检查一次条件,然后单独发送事件,则在检查与事件提交之间可能会启动另一个响应;响应取消和`guardrail_tripped`事件仍会发生。 -## SIP 与电话 +## SIP与电话通信 -Python SDK 通过 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] 提供原生 SIP 挂接流程。 +Python SDK通过[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]提供一流的SIP附加流程。 -当呼叫通过 Realtime Calls API 到达,并且你希望将智能体会话挂接到生成的 `call_id` 时,请使用此流程: +当呼叫通过Realtime Calls API到达,并且希望将智能体会话附加到生成的`call_id`时,请使用该流程: ```python from agents.realtime import RealtimeRunner @@ -317,20 +371,20 @@ async with await runner.run( ... ``` -如果需要先接听呼叫,并希望接听请求载荷与从智能体派生的会话配置一致,请使用 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程可参阅 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 +如果需要先接受呼叫,并希望接受载荷与从智能体派生的会话配置匹配,请使用`OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程请参阅[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 ## 底层访问与自定义端点 -可以通过 `session.model` 访问底层传输对象。 +可以通过`session.model`访问底层传输对象。 -以下情况可使用此对象: +以下情况可使用此功能: -- 通过 `session.model.add_listener(...)` 添加自定义监听器 -- 发送原始客户端事件,例如 `response.create` 或 `session.update` -- 通过 `model_config` 自定义 `url`、`headers` 或 `api_key` 的处理方式 -- 使用 `call_id` 挂接到现有实时呼叫 +- 通过`session.model.add_listener(...)`添加自定义监听器 +- 发送原始客户端事件,例如`response.create`或`session.update` +- 通过`model_config`自定义处理`url`、`headers`或`api_key` +- 使用`call_id`附加到现有实时呼叫 -`RealtimeModelConfig` 支持: +`RealtimeModelConfig`支持: - `api_key` - `url` @@ -339,9 +393,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -此代码仓库提供的 `call_id` 代码示例使用 SIP。更广泛的 Realtime API 还会在某些服务端控制流程中使用 `call_id`,但这里并未将其作为 Python 代码示例提供。 +此仓库随附的`call_id`代码示例使用SIP。更广泛的Realtime API也会在某些服务器端控制流程中使用`call_id`,但此处未将这些流程打包为Python代码示例。 -连接 Azure OpenAI时,请传入正式发布版 Realtime 端点 URL 和显式请求头。例如: +连接到Azure OpenAI时,请传入GA Realtime端点URL和显式请求头。例如: ```python session = await runner.run( @@ -352,7 +406,7 @@ session = await runner.run( ) ``` -对于基于 token 的身份验证,请在 `headers` 中使用 Bearer token: +对于基于token的身份验证,请在`headers`中使用Bearer token: ```python session = await runner.run( @@ -363,12 +417,12 @@ session = await runner.run( ) ``` -如果传入 `headers`,SDK 不会自动添加 `Authorization`。实时智能体应避免使用旧版 beta 路径(`/openai/realtime?api-version=...`)。 +如果传入`headers`,SDK不会自动添加`Authorization`。请避免将旧版Beta路径(`/openai/realtime?api-version=...`)用于实时智能体。 ## 延伸阅读 - [实时传输](transport.md) - [快速入门](quickstart.md) - [OpenAI Realtime对话](https://developers.openai.com/api/docs/guides/realtime-conversations/) -- [OpenAI Realtime服务端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/) +- [OpenAI Realtime服务器端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/) - [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) \ No newline at end of file diff --git a/docs/zh/release.md b/docs/zh/release.md index 7a056152a9..c66395ff4c 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -10,45 +10,60 @@ search: 对于任何未标记为 beta 的公共接口发生的**破坏性变更**,我们将递增次版本号 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 -如果您不希望遇到破坏性变更,建议在项目中固定使用 `0.0.x` 版本。 +如果不希望引入破坏性变更,建议在项目中固定使用 `0.0.x` 版本。 ## 补丁版本(`Z`) 对于非破坏性变更,我们将递增 `Z`: -- bug 修复 +- Bug 修复 - 新功能 - 私有接口变更 - beta 功能更新 ## 破坏性变更日志 +### 0.20.0 + +0.20.0 版本包含一项可能具有破坏性的 MCP 依赖迁移,会影响自定义本地 MCP HTTP 传输的应用程序。它还更新了智能体或运行未显式选择模型时使用的 SDK 默认模型。 + +重点: + +- SDK 默认模型现已从 `gpt-5.4-mini` 改为 `gpt-5.6-luna`。默认的 `reasoning.effort="none"` 和 `verbosity="low"` 设置保持不变。 +- 显式指定的智能体模型、运行级模型覆盖项以及 `OPENAI_DEFAULT_MODEL` 环境变量仍优先于 SDK 默认值。 +- Realtime 输入转录设置现在可识别 `gpt-transcribe`、`gpt-live-transcribe` 和 `gpt-realtime-whisper`。对于低延迟 `gpt-live-transcribe` 会话,嵌套的 `audio.input.transcription` 设置可以提供 `prompt`、`keywords` 和多个预期的 `languages`。此 SDK 固定使用的 OpenAI 客户端版本仅在搭配 `gpt-realtime-whisper` 时支持 `delay` 延迟/准确度级别。通过 WebSocket 使用 `gpt-transcribe`,可在已提交音频轮次后进行转录或输出检测到的语言。显式设置 `audio.input.turn_detection=None` 会禁用自动轮次检测。请参阅[输入转录设置](realtime/guide.md#input-transcription-settings)。 +- Agents SDK 创建的本地 MCP 连接现在支持 MCP Python SDK v2,同时通过 `mcp>=1.19.0,<3` 保持对 v1 的兼容性。Agents SDK 会自动适配普通的 stdio、SSE 和 Streamable HTTP 连接。安装 MCP v2 后,这些连接会使用 `mcp.Client(mode="auto")` 探测最新的受支持协议,并针对旧版服务器回退到传统的 `initialize` 握手。如果依赖解析选择了 MCP v2,提供自定义 `httpx.Auth` 对象或 `httpx.AsyncClient` 工厂的应用程序必须将这些值迁移至 `httpx2`,或者固定使用 `mcp<2` 以保留 v1 HTTP 栈。`MCPServerStreamableHttp` 的 `params["ignore_initialized_notification_failure"] = True` 选项也仍然仅支持 v1。有关迁移详情,请参阅[MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 +- 沙盒挂载验证现在会在产生沙盒或挂载辅助程序的副作用之前,拒绝不安全的凭据放置。可信应用程序可以针对准确的容器内挂载路径,确认挂载范围内或更广泛的凭据暴露,而无需更改存储能力表。这些确认仅在运行时有效,序列化后的沙盒状态本身绝不会授予凭据权限。在受保护的挂载边界处,SDK 会返回一个全新的、经过脱敏的异常。如果源异常是完全匹配的、可识别的 SDK 沙盒错误,且其获准的结构化字段通过验证,则替代异常会保留该子类型和已验证的安全字段。可识别的 `MountConfigError` 还可以保留由 SDK 生成的安全验证消息。否则,SDK 会返回一个全新的通用脱敏错误。由提供商控制或未经批准的消息、命令数据、注释、上下文、原因及源回溯状态均不会保留。请参阅[挂载与远程存储](sandbox/clients.md#mounts-and-remote-storage)和[从会话状态恢复](sandbox/guide.md#resume-from-session-state)。 +- 重试策略可以检查稳定的重放安全事实,并针对提供商标记为不安全的非流式请求显式设置 `RetryDecision(approve_unsafe_replay=True)`。此批准不会绕过中止、已发出的流式输出或单独的本地副作用否决机制,例如程序化工具调用。请参阅[由 Runner 管理的重试](models/index.md#runner-managed-retries)。 +- 可恢复的 `RunState` 对象现在可以在下一次模型调用前使用 `add_input()` 暂存持久用户输入。暂存的输入会在序列化后保留、经过输入安全防护措施,并在本地会话和服务器管理的对话中生成一次持久的 SDK 输入记录。经过显式批准的不安全重放仍可能向提供商重新发送输入,并重复提供商侧的工作。请参阅[恢复前添加输入](results.md#add-input-before-resuming)。 +- 运行时可靠性修复统一了流式与非流式的[输出安全防护措施会话持久化](guardrails.md#output-guardrails),在复制和命名空间处理期间保留 `FunctionTool` 子类,并针对[不受支持的 Chat Completions 音频输出](models/index.md#chat-completions-compatibility-options)抛出明确错误,而不是静默完成空流。`OpenAIResponsesCompactionSession` 包装器会在取消传递至调用方前,尝试并等待[压缩前的历史记录恢复](sessions/index.md#auto-compaction-can-block-streaming)。[`VoicePipeline`](voice/pipeline.md#results) 使用方现在会在正常运行结束后收到转录会话关闭失败,而较早发生的轮次失败仍优先于之后发生的关闭失败。`RunState` 往返转换现在会保留本地 shell 输出、已确认的计算机安全检查、采用默认值的工具输出字段,以及遍历字典、列表或元组时遇到的 Pydantic 模型或 dataclass 输出。MCP 转换会保留自由格式对象 schema 和图像输出,并将音频块、资源块等其他原始内容块序列化为有效的 JSON 文本。`MCPServerManager` 会对重叠的生命周期操作进行串行化,并为连接和清理应用有限的默认超时时间。模型重放会先从输出项中移除服务器所有的 `created_by` 元数据,再将其用作输入。 + ### 0.19.0 -此此次版本发布**不**包含破坏性变更。次版本号的提升是为了体现一个重要的OpenAI Responses新功能领域:程序化工具调用。 +此次次版本发布**未**引入破坏性变更。次版本号递增反映了一项重要的 OpenAI Responses 新功能领域:程序化工具调用。 -亮点: +重点: -- 新增了 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],它允许受支持的OpenAI Responses模型生成 JavaScript,以协调符合程序化工具调用条件的工具。它支持每个工具的 `allowed_callers`、来自 `FunctionTool` 实例的 structured outputs,以及与 Runner 流式传输、安全防护措施、审批、会话和 `RunState` 的集成。有关设置和约束,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 -- 新增了公共 `agents.decorators` 模块和 `@tool`,后者是现有 `@function_tool` 装饰器的较短别名,与现有安全防护措施装饰器并列提供。`FunctionTool` 实例现在也支持异步可调用对象。 -- SDK 配置现在可在智能体、运行、模型、会话、沙箱和语音管线中一致地接受类型化设置对象或字典,并会验证未知设置。 -- 强化了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误及诊断日志记录,可在保留有用调试上下文的同时避免暴露原始敏感载荷。 -- 改进了 AnyLLM、LiteLLM 和 Chat Completions兼容性,在模型重试期间保留会话历史,并针对响应开始前发生的 WebSocket 过载添加了提供商重试指南,因此在允许的情况下,选择启用的 Runner 重试策略可以重新执行失败的尝试。 -- 通过 `VercelCloudBucketMountStrategy` 新增了[只能在创建 Vercel 沙箱时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。使用挂载的会话不会将存储桶内容纳入工作区持久化,并且特意不支持动态更改挂载或恢复会话。 +- 新增 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],使受支持的 OpenAI Responses 模型能够生成 JavaScript,以协调符合程序化工具调用条件的工具。它支持每个工具的 `allowed_callers`、来自 `FunctionTool` 实例的 structured outputs,以及与 Runner 流式传输、安全防护措施、批准、会话和 `RunState` 的集成。有关设置和限制,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 +- 新增公共 `agents.decorators` 模块和 `@tool`,后者是现有 `@function_tool` 装饰器的较短别名,与现有安全防护措施装饰器并列提供。`FunctionTool` 实例现在也支持异步可调用对象。 +- SDK 配置现在可在智能体、运行、模型、会话、沙盒和语音管线中统一接受类型化设置对象或字典,并会验证未知设置。 +- 加强了模型、工具、MCP、Realtime、会话、沙盒和追踪中的错误与诊断日志记录,在保留有用调试上下文的同时,避免暴露原始敏感载荷。 +- 改进了 AnyLLM、LiteLLM 和 Chat Completions 兼容性,在模型重试期间保留会话历史记录,并针对响应开始前发生的 WebSocket 过载添加了提供商重试指引,使选择启用的 Runner 重试策略能够在获准时重放失败的尝试。 +- 通过 `VercelCloudBucketMountStrategy` 新增[只能在创建 Vercel 沙盒时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。具有挂载的会话不会将存储桶内容纳入工作区持久化,并且有意不支持动态挂载变更或会话恢复。 ### 0.18.0 -此此次版本发布**不**包含破坏性变更。次版本号的提升仅用于 Realtime 智能体默认模型更新。 +此次次版本发布**未**引入破坏性变更。次版本号递增仅用于 Realtime 智能体默认模型更新。 -亮点: +重点: -- Realtime 智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 配置无需额外设置即可使用最新推荐模型。 +- Realtime 智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 设置无需额外配置即可使用最新的推荐模型。 ### 0.17.0 -在此版本中,沙箱本地源实例化会将 `LocalFile.src` 和 `LocalDir.src` 限制在实例化 `base_dir` 内,除非源路径包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源从该目录解析,而绝对本地源必须已位于该目录内,或位于明确授权的目录下。此变更修复了本地产物边界问题,但可能影响有意将该基础目录之外的可信主机文件或目录复制到沙箱工作区中的应用程序。 +在此版本中,沙盒本地源具体化会将 `LocalFile.src` 和 `LocalDir.src` 限制在具体化 `base_dir` 内,除非源路径由 `Manifest.extra_path_grants` 覆盖。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源会从该目录解析,而绝对本地源必须已经位于该目录内或处于显式授权范围内。此项变更修复了本地工件边界问题,但可能影响有意将该基础目录之外的可信主机文件或目录复制到沙盒工作区的应用程序。 -迁移时,请在清单级别使用 `SandboxPathGrant` 授予对可信主机根目录的访问权限;如果沙箱只需读取这些文件,最好授予只读权限: +若要迁移,请使用 `SandboxPathGrant` 在清单级别授权可信主机根目录;如果沙盒只需读取这些文件,最好将其设为只读: ```python from pathlib import Path @@ -75,28 +90,28 @@ manifest = Manifest( ) ``` -请将 `extra_path_grants` 视为可信应用程序配置。除非您的应用程序已经批准了这些主机路径,否则不要使用模型输出或其他不可信的清单输入来填充授权。 +应将 `extra_path_grants` 视为可信应用程序配置。除非应用程序已经批准相关主机路径,否则不要根据模型输出或其他不可信的清单输入填充授权项。 ### 0.16.0 -在此版本中,SDK 默认模型现已从 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包括 `reasoning.effort="none"` 和 `verbosity="low"` 等 GPT-5 默认值。 +在此版本中,SDK 默认模型现已从 `gpt-4.1` 改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含 `reasoning.effort="none"` 和 `verbosity="low"` 等 GPT-5 默认值。 -如果需要保留之前的默认模型行为,请在智能体或运行配置中显式设置模型,或者设置 `OPENAI_DEFAULT_MODEL` 环境变量: +如果需要保留此前的默认模型行为,请在智能体或运行配置中显式设置模型,或设置 `OPENAI_DEFAULT_MODEL` 环境变量: ```python agent = Agent(name="Assistant", model="gpt-4.1") ``` -亮点: +重点: - `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 -- 现在,本地、Docker 和提供商支持的沙箱实现中的沙箱工作区内容填充都会拒绝包含指向归档根目录之外的符号链接的 tar 归档,其中包括使用绝对路径作为目标的符号链接。 +- 在本地、Docker 和提供商支持的各种沙盒实现中,沙盒工作区水合现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 ### 0.15.0 -在此版本中,模型拒绝现在会显式作为 `ModelRefusalError` 抛出,而不再被视为空文本输出;对于 structured outputs,也不会再导致运行循环不断重试直至 `MaxTurnsExceeded`。 +在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于 structured outputs,也不再导致运行循环持续重试直至 `MaxTurnsExceeded`。 -这会影响之前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不抛出异常,请提供 `model_refusal` 运行错误处理程序: +这会影响此前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不抛出异常,请提供 `model_refusal` 运行错误处理程序: ```python result = Runner.run_sync( @@ -106,94 +121,94 @@ result = Runner.run_sync( ) ``` -对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出模式匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样对其进行验证。 +对于使用 structured outputs 的智能体,该处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样对其进行验证。 ### 0.14.0 -此此次版本发布**不**包含破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 +此次次版本发布**未**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙盒智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 -亮点: +重点: -- 新增了以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化的隔离工作区中处理文件、目录、Git 仓库、挂载和快照,并支持恢复。 -- 新增了通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 支持本地与容器化开发的沙箱执行后端,并通过 Python 软件包中的可选依赖 extras,为 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 提供托管提供商集成。 -- 新增了沙箱记忆支持,使未来的运行能够复用之前运行中的经验,并支持渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆示例。 -- 新增了更全面的工作区和恢复模型,包括本地与合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 -- 在 `examples/sandbox/` 下新增了大量沙箱代码示例和教程,涵盖使用技能、任务转移和记忆的编码任务、特定提供商的设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 -- 扩展了核心运行时和追踪技术栈,新增了感知沙箱的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出遮盖。 +- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙盒运行时接口,使智能体能够在支持文件、目录、Git 仓库、挂载、快照和恢复的持久隔离工作区中工作。 +- 通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 新增用于本地和容器化开发的沙盒执行后端,并通过 Python 包中的可选依赖 extras,为 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 提供托管提供商集成。 +- 新增沙盒记忆支持,使未来运行能够复用此前运行中的经验,并支持渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆代码示例。 +- 新增更广泛的工作区和恢复模型,包括本地与合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 +- 在 `examples/sandbox/` 下新增大量沙盒代码示例和教程,涵盖使用技能、任务转移和记忆的编码任务,特定于提供商的设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 +- 扩展核心运行时和追踪栈,增加可感知沙盒的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出脱敏。 ### 0.13.0 -此此次版本发布**不**包含破坏性变更,但包括一项重要的 Realtime 默认值更新,以及新的 MCP 能力和运行时稳定性修复。 +此次次版本发布**未**引入破坏性变更,但包含一项重要的 Realtime 默认值更新,以及新的 MCP 功能和运行时稳定性修复。 -亮点: +重点: -- 默认 WebSocket Realtime 模型现为 `gpt-realtime-1.5`,因此新的 Realtime 智能体配置无需额外设置即可使用较新的模型。 -- `MCPServer` 现在会公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在会公开 `session_id`,因此使用 MCP Streamable HTTP 传输的会话可以在重新连接或无状态工作进程之间恢复。 -- Chat Completions集成现在可以通过 `should_replay_reasoning_content` 选择重新发送现有推理内容,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理和工具调用连续性。 -- 修复了多个运行时和会话边缘情况,包括 `SQLAlchemySession` 中的并发首次写入、移除推理内容后存在孤立助手消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项目,以及 `FunctionTool` 实例的批处理执行器中的竞态条件。 +- 默认 WebSocket Realtime 模型现为 `gpt-realtime-1.5`,因此新的 Realtime 智能体设置无需额外配置即可使用较新的模型。 +- `MCPServer` 现在会公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在会公开 `session_id`,从而使使用 MCP Streamable HTTP 传输的会话能够在重新连接后或无状态工作进程之间恢复。 +- Chat Completions 集成现在可以通过 `should_replay_reasoning_content` 选择重新发送现有推理内容,从而改进 LiteLLM/DeepSeek 等适配器中特定于提供商的推理/工具调用连续性。 +- 修复了若干运行时和会话边界情况,包括 `SQLAlchemySession` 中并发的首次写入、移除推理内容后存在孤立 assistant 消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项,以及 `FunctionTool` 实例批量执行器中的竞争条件。 ### 0.12.0 -此此次版本发布**不**包含破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 +此次次版本发布**未**引入破坏性变更。有关重要功能新增内容,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 ### 0.11.0 -此此次版本发布**不**包含破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 +此次次版本发布**未**引入破坏性变更。有关重要功能新增内容,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 ### 0.10.0 -此此次版本发布**不**包含破坏性变更,但为OpenAI Responses用户引入了一个重要的新功能领域:Responses API 的 WebSocket 传输支持。 +此次次版本发布**未**引入破坏性变更,但为 OpenAI Responses 用户新增了一个重要功能领域:Responses API 的 WebSocket 传输支持。 -亮点: +重点: -- 为OpenAI Responses模型新增了 WebSocket 传输支持(需选择启用;HTTP 仍是默认传输方式)。 -- 新增了 `responses_websocket_session()` 辅助程序/`ResponsesWebSocketSession`,用于在多轮运行中复用共享的支持 WebSocket 的提供商和 `RunConfig`。 -- 新增了一个 WebSocket 流式传输示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 +- 为 OpenAI Responses 模型新增 WebSocket 传输支持(需选择启用;HTTP 仍为默认传输方式)。 +- 新增 `responses_websocket_session()` 辅助程序 / `ResponsesWebSocketSession`,用于在多轮运行中复用支持 WebSocket 的共享提供商和 `RunConfig`。 +- 新增 WebSocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、批准和后续轮次。 ### 0.9.0 -在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前终止支持。请升级到较新的运行时版本。 +在此版本中,不再支持 Python 3.9,因为此主要版本已于三个月前终止生命周期。请升级到较新的运行时版本。 -此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会引发破坏性问题,但如果您的代码依赖较宽泛的联合类型,可能需要进行一些调整。 +此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会引发破坏性问题,但如果代码依赖范围更广的联合类型,可能需要进行一些相应调整。 ### 0.8.0 -在此版本中,有两项运行时行为变更可能需要迁移: +在此版本中,两项运行时行为变更可能需要迁移: -- 包装**同步** Python 可调用对象的 `FunctionTool` 实例现在会通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果您的工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中明确处理线程亲和性。 -- 本地 MCP 工具失败处理现在可以配置,默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果您依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务器级别的 `failure_error_function` 值会覆盖智能体级别的设置,因此请在每个具有显式处理程序的本地 MCP 服务器上设置 `failure_error_function=None`。 +- `FunctionTool` 实例包装的**同步** Python 可调用对象现在会通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中明确处理线程亲和性。 +- 本地 MCP 工具失败处理现在可配置,默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务器级 `failure_error_function` 值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地 MCP 服务器上设置 `failure_error_function=None`。 ### 0.7.0 -在此版本中,有几项可能影响现有应用程序的行为变更: +在此版本中,有几项行为变更可能影响现有应用程序: -- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果您依赖 v0.6.x 中默认的嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 -- `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(之前的默认值为 SDK 默认设置所配置的 `"low"`)。如果您的提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置它。 +- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果依赖 v0.6.x 中默认启用的嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 +- `gpt-5.1` / `gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(此前默认值为 SDK 默认配置的 `"low"`)。如果提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置它。 ### 0.6.0 -在此版本中,默认任务转移历史记录现在会打包为一条助手消息,而不再将用户和助手轮次作为单独消息传递,从而为下游智能体提供简洁且可预测的回顾 -- 现有的单消息任务转移记录现在默认以确切的字面文本 `For context, here is the conversation so far between the user and the previous agent:` 开头,后面紧接 `` 块,从而为下游智能体提供带有清晰标签的回顾 +在此版本中,默认任务转移历史记录现在会打包为一条 assistant 消息,而不再将用户和 assistant 轮次作为单独消息传递,从而为下游智能体提供简洁且可预测的回顾 +- 现有的单消息任务转移记录现在默认在 `` 块之前以确切的字面文本 `For context, here is the conversation so far between the user and the previous agent:` 开头,从而为下游智能体提供带有明确标签的回顾 ### 0.5.0 -此版本未引入任何可见的破坏性变更,但包含新功能以及一些重要的底层更新: +此版本未引入任何可见的破坏性变更,但包含新功能和一些重要的底层更新: -- `RealtimeRunner` 新增了处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 -- 大幅修改了 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 +- 在 `RealtimeRunner` 中新增对处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 +- 大幅修订 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 ### 0.4.0 -在此版本中,不再支持 [openai](https://pypi.org/project/openai/) 软件包的 v1.x 版本。请将 openai v2.x 与此 SDK 配合使用。 +在此版本中,不再支持 [openai](https://pypi.org/project/openai/) 包的 v1.x 版本。请将 openai v2.x 与此 SDK 配合使用。 ### 0.3.0 -在此版本中,Realtime API支持迁移到 gpt-realtime 模型及其 API 接口(正式发布版本)。 +在此版本中,Realtime API 支持迁移至 gpt-realtime 模型及其 API 接口(GA 版本)。 ### 0.2.0 -在此版本中,少数之前接受 `Agent` 作为参数的位置现在改为接受 `AgentBase`。例如,这适用于 MCP 服务器中的 `list_tools()` 方法签名。这只是类型层面的变更,您仍将收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase` 来修复类型错误。 +在此版本中,少数原本接受 `Agent` 作为参数的位置,现改为接受 `AgentBase`。例如,这适用于 MCP 服务器中的 `list_tools()` 方法签名。这只是类型层面的变更,仍会收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase`,以修复类型错误。 ### 0.1.0 -在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。您需要将这些参数添加到 `MCPServer` 子类中每个被重写的 `MCPServer.list_tools()` 方法。 \ No newline at end of file +在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增两个参数:`run_context` 和 `agent`。需要将这些参数添加到 `MCPServer` 子类中所有被覆盖的 `MCPServer.list_tools()` 方法。 \ No newline at end of file diff --git a/docs/zh/results.md b/docs/zh/results.md index f7a8ac739e..0024caea1f 100644 --- a/docs/zh/results.md +++ b/docs/zh/results.md @@ -6,86 +6,87 @@ search: 调用 `Runner.run` 方法时,你会收到以下两种结果类型之一: -- 从 `Runner.run(...)` 或 `Runner.run_sync(...)` 获得的 [`RunResult`][agents.result.RunResult] -- 从 `Runner.run_streamed(...)` 获得的 [`RunResultStreaming`][agents.result.RunResultStreaming] +- 从 `Runner.run(...)` 或 `Runner.run_sync(...)` 返回的 [`RunResult`][agents.result.RunResult] +- 从 `Runner.run_streamed(...)` 返回的 [`RunResultStreaming`][agents.result.RunResultStreaming] -两者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者公开了共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 +二者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者提供共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 -`RunResultStreaming` 增加了流式传输专用控制功能,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 +`RunResultStreaming` 还提供流式传输专用的控制项,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 -## 合适结果接口的选择 +## 适当结果接口的选择 -大多数应用只需要少量结果属性或辅助方法: +大多数应用只需要少数几个结果属性或辅助方法: -| 需求 | 使用 | +| 如果你需要…… | 使用 | | --- | --- | -| 向用户显示最终答案 | `final_output` | +| 向用户显示的最终答案 | `final_output` | | 包含完整本地对话记录、可直接用于重放的下一轮输入列表 | `to_input_list()` | -| 包含智能体、工具、任务转移和审批元数据的丰富运行条目 | `new_items` | -| 通常应处理下一轮用户交互的智能体 | `last_agent` | -| 使用 `previous_response_id` 进行 OpenAI Responses API 链式调用 | `last_response_id` | +| 包含智能体、工具、任务转移和审批元数据的丰富运行项 | `new_items` | +| 通常应处理下一轮用户输入的智能体 | `last_agent` | +| 使用 `previous_response_id` 进行OpenAI的 Responses API 链式调用 | `last_response_id` | | 待处理的审批和可恢复快照 | `interruptions` 和 `to_state()` | | 当前嵌套 `Agent.as_tool()` 调用的元数据 | `agent_tool_invocation` | | 原始模型调用或安全防护措施诊断信息 | `raw_responses` 和安全防护措施结果数组 | ## 最终输出 -[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后一个运行的智能体所生成的最终输出。其类型可能是: +[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后运行的智能体所产生的最终输出。它可能是: - 如果最后一个智能体未定义 `output_type`,则为 `str` - 如果最后一个智能体定义了输出类型,则为 `last_agent.output_type` 类型的对象 -- 如果运行在生成最终输出前停止,则为 `None`,例如运行因审批中断而暂停 +- 如果运行在生成最终输出之前停止,则为 `None`,例如因审批中断而暂停 !!! note - `final_output` 的类型为 `Any`。任务转移可能会改变最终完成运行的智能体,因此 SDK 无法静态确定所有可能的输出类型。 + `final_output` 的类型标注为 `Any`。任务转移可能会改变最终结束运行的智能体,因此 SDK 无法静态获知所有可能的输出类型。 -在流式传输模式下,`final_output` 会保持为 `None`,直到流处理完毕。有关逐事件处理流程,请参阅[流式传输](streaming.md)。 +在流式传输模式下,`final_output` 会一直保持为 `None`,直到流处理完成。有关逐事件的处理流程,请参阅[流式传输](streaming.md)。 -## 输入、下一轮历史记录与新条目 +## 输入、下一轮历史记录和新项目 -这些接口分别用于回答不同的问题: +以下接口分别回答不同的问题: -| 属性或辅助方法 | 包含的内容 | 最适合的用途 | +| 属性或辅助方法 | 包含的内容 | 最适合的场景 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | 此运行片段的基础输入。如果任务转移输入过滤器重写了历史记录,此属性会反映运行继续执行时所使用的过滤后输入。 | 审计此运行实际使用的输入 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 运行的输入条目视图。默认的 `mode="preserve_all"` 会保留来自 `new_items` 的转换后历史记录,但不会再次追加已经移入 SDK 默认嵌套任务转移历史记录中的同一个会话条目实例;当任务转移过滤重写模型历史记录时,`mode="normalized"` 优先使用规范的续接输入。 | 手动聊天循环、由客户端管理的对话状态以及纯条目历史记录检查 | -| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 封装对象。 | 日志、UI、审计和调试 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供商级诊断或原始响应检查 | +| [`input`][agents.result.RunResultBase.input] | 此运行片段的基础输入。如果任务转移输入过滤器重写了历史记录,这里会反映运行继续执行时所使用的过滤后输入。 | 审计此次运行实际使用的输入 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 此次运行的输入项视图。默认的 `mode="preserve_all"` 会保留来自 `new_items` 的转换后历史记录,但不会再次追加已移入 SDK 默认嵌套任务转移历史记录的同一会话项实例;当任务转移过滤重写模型历史记录时,`mode="normalized"` 会优先使用标准续接输入。 | 手动聊天循环、由客户端管理的对话状态,以及普通项目历史记录检查 | +| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 包装器。 | 日志、UI、审计和调试 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 此次运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供商级诊断或原始响应检查 | 在实践中: -- 当你需要运行的纯输入条目视图时,使用 `to_input_list()`。 -- 在任务转移过滤或嵌套任务转移历史记录重写后,当你需要用于下一次 `Runner.run(..., input=...)` 调用的规范本地输入时,使用 `to_input_list(mode="normalized")`。 -- 当你希望 SDK 为你加载和保存历史记录时,使用 [`session=...`](sessions/index.md)。 -- 如果你正在通过 `conversation_id` 或 `previous_response_id` 使用由 OpenAI 服务器管理的状态,通常只需传入新的用户输入并复用存储的 ID,而无需重新发送 `to_input_list()`。 -- 当你需要用于日志、UI 或审计的完整转换后历史记录时,使用默认的 `to_input_list()` 模式或 `new_items`。 +- 如果需要此次运行的普通输入项视图,请使用 `to_input_list()`。 +- 如果在任务转移过滤或嵌套任务转移历史记录重写后,需要用于下一次 `Runner.run(..., input=...)` 调用的标准本地输入,请使用 `to_input_list(mode="normalized")`。 +- 如果希望 SDK 为你加载和保存历史记录,请使用 [`session=...`](sessions/index.md)。 +- 如果使用由OpenAI管理且带有 `conversation_id` 或 `previous_response_id` 的服务端状态,通常只需传入新的用户输入并复用已存储的 ID,而不必重新发送 `to_input_list()`。 +- 如果需要用于日志、UI 或审计的完整转换后历史记录,请使用默认的 `to_input_list()` 模式或 `new_items`。 -当 SDK 默认的嵌套任务转移历史记录逐字保留消息条目时,会话、`RunState` 和 `to_input_list()` 会追踪实际归属的条目实例,而不是按内容去重。分别出现的相同消息仍会保持独立;系统只会避免再次追加已归属的条目实例。 +当 SDK 默认的嵌套任务转移历史记录逐字保留消息项时,会话、`RunState` 和 `to_input_list()` 会追踪归其所有的确切实例,而不是按内容去重。分别出现的相同消息仍会保持独立;只有已归其所有的实例不会被再次追加。 -与 JavaScript SDK 不同,Python 不会公开单独的 `output` 属性来仅包含运行期间新生成的模型格式条目。需要 SDK 元数据时,请使用 `new_items`;需要原始模型载荷时,请检查 `raw_responses`。 +与 JavaScript SDK 不同,Python 不提供单独的 `output` 属性来仅包含运行期间新生成的模型格式项目。需要 SDK 元数据时,请使用 `new_items`;需要原始模型载荷时,请检查 `raw_responses`。 -将计算机工具条目作为对话输入重新提交时,会使用原始 Responses 载荷格式。预览模型的 `computer_call` 条目会保留单个 `action`,而 `gpt-5.5` 计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的格式,因此,无论是预览版还是正式发布版的计算机工具调用,手动将这些条目重新提交为对话输入、执行暂停/恢复流程以及使用已存储的对话记录都可以继续正常工作。本地执行结果仍会在 `new_items` 中显示为 `computer_call_output` 条目。 +将计算机工具项目作为对话输入重新提交时,会使用原始 Responses 载荷结构。预览模型的 `computer_call` 项目会保留单个 `action`,而 `gpt-5.5` 计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的结构,因此,无论是手动将这些项目作为对话输入重新提交、执行暂停/恢复流程,还是使用已存储的对话记录,都能同时兼容预览版和正式版计算机工具调用。本地执行结果仍会作为 `computer_call_output` 项目出现在 `new_items` 中。 -### 新条目 +### 新项目 -[`new_items`][agents.result.RunResultBase.new_items] 提供运行期间所发生事件的最丰富视图。常见条目类型包括: +[`new_items`][agents.result.RunResultBase.new_items] 提供此次运行期间所发生事件的最丰富视图。常见项目类型包括: -- 用于助手消息的 [`MessageOutputItem`][agents.items.MessageOutputItem] -- 用于推理条目的 [`ReasoningItem`][agents.items.ReasoningItem] -- 用于 Responses 工具搜索请求和已加载工具搜索结果的 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 和 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- 用于工具调用及其结果的 [`ToolCallItem`][agents.items.ToolCallItem] 和 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 用于因等待审批而暂停的工具调用的 [`ToolApprovalItem`][agents.items.ToolApprovalItem] -- 用于托管 MCP 审批和工具目录的 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem] -- 用于任务转移请求和已完成转移的 [`HandoffCallItem`][agents.items.HandoffCallItem] 和 [`HandoffOutputItem`][agents.items.HandoffOutputItem] +- [`InputItem`][agents.items.InputItem],表示在恢复的模型调用之前立即从 `RunState.pending_input` 接纳的输入 +- [`MessageOutputItem`][agents.items.MessageOutputItem],表示助手消息 +- [`ReasoningItem`][agents.items.ReasoningItem],表示推理项目 +- [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 和 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem],表示 Responses 工具搜索请求和已加载的工具搜索结果 +- [`ToolCallItem`][agents.items.ToolCallItem] 和 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem],表示工具调用及其结果 +- [`ToolApprovalItem`][agents.items.ToolApprovalItem],表示因等待审批而暂停的工具调用 +- [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem],表示托管式 MCP 审批和工具目录 +- [`HandoffCallItem`][agents.items.HandoffCallItem] 和 [`HandoffOutputItem`][agents.items.HandoffOutputItem],表示任务转移请求和已完成的转移 -当你需要智能体关联信息、工具输出、任务转移边界或审批边界时,应选择 `new_items`,而不是 `to_input_list()`。 +每当需要智能体关联信息、工具输出、任务转移边界或审批边界时,应选择 `new_items`,而不是 `to_input_list()`。 -使用托管工具搜索时,检查 `ToolSearchCallItem.raw_item` 可查看模型发出的搜索请求,检查 `ToolSearchOutputItem.raw_item` 可查看该轮加载了哪些命名空间、函数或托管 MCP 服务器。 +使用托管式工具搜索时,请检查 `ToolSearchCallItem.raw_item` 以查看模型发出的搜索请求,并检查 `ToolSearchOutputItem.raw_item` 以查看本轮加载了哪些命名空间、函数或托管式 MCP 服务器。 -使用程序化工具调用时,生成的 `program` 是 `ToolCallItem`,归属于该程序的普通子工具调用也是 `ToolCallItem` 条目,而对应的 `program_output` 是 `ToolCallOutputItem`。归属于程序的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 条目属于例外:它们会成为 `MCPApprovalRequestItem` 和 `MCPListToolsItem` 条目。 +使用程序化工具调用时,生成的 `program` 是 `ToolCallItem`,归该程序所有的普通子工具调用也是 `ToolCallItem` 条目,与之匹配的 `program_output` 是 `ToolCallOutputItem`。归程序所有的托管式 MCP `mcp_approval_request` 和 `mcp_list_tools` 项目属于例外:它们会成为 `MCPApprovalRequestItem` 和 `MCPListToolsItem` 条目。 -原始条目可以是带类型的 Responses 对象或映射。特别是,归属于程序的 shell 和补丁应用调用会使用映射。请使用可安全处理映射的检查模式: +原始项目可以是有类型的 Responses 对象或映射。特别是,归程序所有的 shell 和 apply-patch 调用使用映射。请使用兼容映射的检查模式: ```python from collections.abc import Mapping @@ -107,21 +108,23 @@ caller_id = ( ) ``` -对于归属于程序的子调用,`caller` 的 `type` 字段为 `program`,而 `caller_id` 用于标识父程序调用。 +对于归程序所有的子调用,`caller` 的 `type` 字段为 `program`,而 `caller_id` 用于标识父程序调用。 ## 对话的继续或恢复 ### 下一轮智能体 -[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后一个运行的智能体。在任务转移后,它通常是下一轮用户交互中最适合复用的智能体。 +[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。在发生任务转移后,它通常是下一轮用户输入最适合复用的智能体。 在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行推进而更新,因此你可以在流结束前观察任务转移。 -### 中断与运行状态 +### 中断和运行状态 -如果工具需要审批,待处理的审批会公开在 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中。其中可能包括由直接调用的工具、任务转移后调用的工具或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行触发的审批。 +如果工具需要审批,待处理的审批会在 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中公开。其中可能包括由直接工具、任务转移后触达的工具或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行触发的审批。 -调用 [`to_state()`][agents.result.RunResult.to_state] 以捕获可恢复的 [`RunState`][agents.run_state.RunState],批准或拒绝待处理条目,然后使用 `Runner.run(...)` 或 `Runner.run_streamed(...)` 恢复运行。 +调用 [`to_state()`][agents.result.RunResult.to_state] 以捕获可恢复的 [`RunState`][agents.run_state.RunState],批准或拒绝待处理项目,然后使用 `Runner.run(...)` 或 `Runner.run_streamed(...)` 恢复运行。 + +当 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] 的输出是 Pydantic 模型或数据类时,`RunState` 会将该输出序列化为结构化数据。`RunState` 还会遍历字典、列表和元组,并转换在这些容器中遇到的 Pydantic 模型或数据类;经过 JSON 往返转换后,元组会恢复为列表。其他与 JSON 不兼容的值可能会回退为其字符串表示形式,因此,如果必须让某个确切的自定义类型在序列化后保持不变,请返回明确兼容 JSON 的数据。 ```python from agents import Agent, Runner @@ -136,15 +139,33 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -对于流式传输运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions` 并从 `result.to_state()` 恢复。有关完整审批流程,请参阅[人工介入](human_in_the_loop.md)。 +#### 恢复前的输入添加 + +当运行在完成一轮后暂停或停止,但尚未完成的运行还未到达下一次模型调用时,如果有新的用户输入到达,请使用 [`RunState.add_input()`][agents.run_state.RunState.add_input]。字符串会转换为用户消息,多次调用则会保留插入顺序。暂存输入是序列化 `RunState` 的一部分,因此在 `to_json()` / `from_json()` 和 `to_string()` / `from_string()` 往返转换后仍会保留。 + +```python +state = result.to_state() +state.add_input("Also keep the generated report in the project folder.") + +for interruption in state.get_interruptions(): + state.approve(interruption) + +result = await Runner.run(agent, state) +``` -### 服务器管理的续接 +恢复运行时,运行器仅对暂存输入应用当前智能体的输入安全防护措施,以及来自 [`RunConfig`][agents.run.RunConfig] 的输入安全防护措施。如果配置了由客户端管理的 [`Session`][agents.memory.session.Session],运行器会将已接纳的暂存输入转换为持久化的 [`InputItem`][agents.items.InputItem],等待会话写入完成后再发出模型请求。如果既没有由客户端管理的会话,也没有服务端管理的对话,运行器会在发出模型请求前将已接纳的暂存输入转换为 `InputItem`。对于服务端管理的对话,输入会一直处于待处理状态,直到服务端请求接纳它。在序列化、恢复和可安全重放的重试过程中,SDK 会保留一个持久化的 `InputItem` 实例。此 SDK 实例保证并不等同于提供商交付保证:如果请求可能已到达提供商后,重试策略返回 `RetryDecision(approve_unsafe_replay=True)`,运行器可能会重新发送暂存输入,并导致提供商侧的工作重复执行。成功接纳的输入会作为 `InputItem` 出现在 `new_items` 中。读取 [`RunState.pending_input`][agents.run_state.RunState.pending_input] 可获得独立副本,也可以调用 [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input] 在恢复前丢弃所有暂存输入。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] 是此次运行中最新的模型响应 ID。若要继续 OpenAI Responses API 链,请在下一轮将其作为 `previous_response_id` 传回。 +在以下情况下,`RunState.add_input()` 会拒绝操作:状态已终止、状态中没有剩余的模型轮次、已接受的模型响应正在等待本地处理,或中断状态中的待处理工具结果可能会在下一次模型调用前结束运行。遇到这些情况时,应完成当前运行,然后开始新一轮用户交互。 -如果你已经使用 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 +对于流式运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions`,并从 `result.to_state()` 恢复。有关完整的审批流程,请参阅[人在回路](human_in_the_loop.md)。 -## 智能体工具元数据 +### 服务端管理的续接 + +[`last_response_id`][agents.result.RunResultBase.last_response_id] 是此次运行中最新的模型响应 ID。如果希望继续OpenAI的 Responses API 调用链,请在下一轮将它作为 `previous_response_id` 传回。 + +如果已经使用 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 + +## 智能体作为工具时的元数据 当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会公开有关外层 `Agent.as_tool()` 调用的不可变元数据: @@ -154,41 +175,48 @@ if result.interruptions: 对于普通的顶层运行,`agent_tool_invocation` 为 `None`。 -这在 `custom_output_extractor` 内尤其有用,因为在对嵌套结果进行后处理时,你可能需要外层 `Agent.as_tool()` 调用的工具名称、调用 ID 或原始参数。有关相关的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 +这在 `custom_output_extractor` 内尤其有用,因为对嵌套结果进行后处理时,你可能需要外层 `Agent.as_tool()` 调用的工具名称、调用 ID 或原始参数。有关相关的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 -如果还需要该嵌套运行解析后的结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 用于通用序列化嵌套工具输入的字段,而 `agent_tool_invocation` 会直接在结果上公开当前嵌套调用的元数据。 +如果还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 为嵌套工具输入进行通用序列化的字段,而 `agent_tool_invocation` 则直接在结果上公开当前嵌套调用的元数据。 -## 流式传输生命周期与诊断 +## 流式传输生命周期和诊断信息 -[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同的结果接口,同时增加了流式传输专用控制功能: +[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同的结果接口,但增加了流式传输专用的控制项: -- 使用 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 消费语义流事件 -- 使用 [`current_agent`][agents.result.RunResultStreaming.current_agent] 追踪运行期间的活动智能体 -- 使用 [`is_complete`][agents.result.RunResultStreaming.is_complete] 查看流式传输运行是否已完全结束 -- 使用 [`cancel(...)`][agents.result.RunResultStreaming.cancel] 立即停止运行或在当前轮结束后停止运行 +- [`stream_events()`][agents.result.RunResultStreaming.stream_events],用于消费语义流事件 +- [`current_agent`][agents.result.RunResultStreaming.current_agent],用于在运行过程中追踪当前活跃的智能体 +- [`is_complete`][agents.result.RunResultStreaming.is_complete],用于查看流式运行是否已完全结束 +- [`cancel(...)`][agents.result.RunResultStreaming.cancel],用于立即停止运行或在当前轮次结束后停止运行 -持续消费 `stream_events()`,直到异步迭代器结束。该迭代器结束前,流式传输运行不算完成;在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在处理。 +持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式运行才算完成;在最后一个可见 token 到达后,`final_output`、`interruptions` 和 `raw_responses` 等汇总属性以及会话持久化副作用可能仍在完成处理。 如果调用 `cancel()`,请继续消费 `stream_events()`,以便正确完成取消和清理。 -Python 不会公开单独的流式 `completed` Promise 或 `error` 属性。终止运行的流式传输故障会由 `stream_events()` 抛出,而 `is_complete` 会反映运行是否已到达终止状态。 +Python 不提供单独的流式 `completed` promise 或 `error` 属性。导致运行终止的流式传输故障会由 `stream_events()` 抛出,而 `is_complete` 则反映运行是否已达到终止状态。 ### 原始响应 -[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能产生多个响应,例如在任务转移或重复的模型/工具/模型循环中。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能会产生多个响应,例如跨任务转移或重复的模型/工具/模型循环。 [`last_response_id`][agents.result.RunResultBase.last_response_id] 只是 `raw_responses` 中最后一个条目的 ID。 +每个 [`ModelResponse`][agents.items.ModelResponse] 还会公开两个适用于该次模型调用的诊断信息: + +- [`request_id`][agents.items.ModelResponse.request_id] 是模型适配器和传输层进行传递时的传输请求 ID。内置的 `OpenAIResponsesModel` 和 `OpenAIChatCompletionsModel` 会在其 HTTP 和 SSE 传输路径上传递可用的服务端生成 `x-request-id`。当配置的端点是OpenAI的 API 时,请在生产环境中记录非 `None` 值,以便将故障与OpenAI支持团队关联;对于兼容OpenAI的提供商或代理,请改用相应服务的支持渠道。`OpenAIResponsesWSModel` 目前会让 `request_id` 保持为 `None`。第三方适配器不保证传递请求 ID。AnyLLM Chat Completions 适配器和 `LitellmModel` 目前会让 `request_id` 保持为 `None`。当 Agents SDK 的 AnyLLM Responses 适配器在规范化提供商响应时未保留传输请求 ID,也可能会让 `request_id` 保持为 `None`。 +- [`raw_usage`][agents.items.ModelResponse.raw_usage] 是一个需要显式启用且兼容 JSON 的快照,它保存提供商的用量载荷在被 Agents SDK 规范化之前的状态。使用 `ModelSettings(preserve_raw_usage=True)` 启用 `raw_usage`;请参阅[保留提供商用量载荷](usage.md#preserving-provider-usage-payloads)。 + +`ModelResponse.request_id` 和 `ModelResponse.raw_usage` 都可能是 `None`,因此应将这些值视为可选诊断信息,而不是对话状态。 + ### 安全防护措施结果 智能体级安全防护措施通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 公开。 工具安全防护措施则通过 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 和 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] 单独公开。 -这些数组会在整个运行期间持续累积,因此适合用于记录决策、存储额外的安全防护措施元数据,或调试运行被阻止的原因。 +这些数组会在整个运行过程中持续累积,因此可用于记录决策、存储额外的安全防护措施元数据,或调试运行被阻止的原因。 -### 上下文与用量 +### 上下文和用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 会公开你的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 会公开应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 -用量记录在 `context_wrapper.usage` 中。对于流式传输运行,用量总计可能要等到流的最终数据块处理完毕后才会更新。有关完整的封装结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file +用量在 `context_wrapper.usage` 上追踪。对于流式运行,在处理完流的最终数据块之前,用量总计可能会有所延迟。有关完整的包装器结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index 802f699ea2..54538bb416 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -7,8 +7,8 @@ search: 你可以通过 [`Runner`][agents.run.Runner] 类运行智能体。你有 3 种选择: 1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回 [`RunResult`][agents.result.RunResult]。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其底层仅运行 `.run()`。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,底层仅运行 `.run()`。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它会以流式传输模式调用 LLM,并在收到事件时将其传输给你。 ```python from agents import Agent, Runner @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -更多信息请参阅[结果指南](results.md)。 +有关更多信息,请参阅[结果指南](results.md)。 ## Runner 生命周期与配置 ### 智能体循环 -调用上述三个 `Runner` 方法中的任何一个时,需要传入起始智能体和输入。输入可以是: +调用上述三个 `Runner` 方法中的任意一个时,你需要传入一个起始智能体和输入。输入可以是: -- 字符串(视为用户消息), -- OpenAI Responses API格式的输入项列表,或 -- 恢复中断的运行时使用的 [`RunState`][agents.run_state.RunState]。 +- 字符串(视为用户消息); +- OpenAI Responses API 格式的输入项列表;或 +- 在恢复暂停的运行或因 `cancel(mode="after_turn")` 而停止的运行时,使用 [`RunState`][agents.run_state.RunState]。该状态还可以携带[为下一次恢复后的模型调用暂存的输入](results.md#add-input-before-resuming)。 -然后,runner 会运行一个循环: +随后,Runner 会运行一个循环: -1. 我们使用当前输入为当前智能体调用 LLM。 +1. 使用当前输入调用当前智能体的 LLM。 2. LLM 生成输出。 - 1. 如果 runner 将 LLM 的输出归类为最终输出,则循环结束,并返回结果。 - 2. 如果 LLM 请求任务转移,我们会更新当前智能体和输入,并重新运行循环。 - 3. 如果 LLM 生成工具调用,我们会运行这些工具调用、追加结果,然后重新运行循环。 -3. 如果超过传入的 `max_turns`,我们会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 + 1. 如果 Runner 将 LLM 的输出归类为最终输出,则循环结束并返回结果。 + 2. 如果 LLM 请求任务转移,则更新当前智能体和输入,并重新运行循环。 + 3. 如果 LLM 生成工具调用,则运行这些工具调用,追加结果,并重新运行循环。 +3. 如果超过所传入的 `max_turns`,则会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 !!! note - 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了所需类型的文本输出,并且没有工具调用。 + 判断 LLM 输出是否属于“最终输出”的规则是:它生成了所需类型的文本输出,并且不存在工具调用。 ### 流式传输 -流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含本次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。更多信息请参阅[流式传输指南](streaming.md)。 +流式传输还允许你在 LLM 运行时接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含该次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。有关更多信息,请参阅[流式传输指南](streaming.md)。 #### Responses WebSocket 传输(可选辅助工具) -如果启用 OpenAI Responses websocket 传输,你仍可继续使用常规的 `Runner` API。建议使用 websocket 会话辅助工具来复用连接,但这不是必需的。 +如果启用 OpenAI Responses WebSocket 传输,你仍可继续使用常规的 `Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 -这是通过 websocket 传输使用的 Responses API,而不是 [Realtime API](realtime/guide.md)。 +这是基于 WebSocket 传输的 Responses API,而不是 [Realtime API](realtime/guide.md)。 有关传输选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 -##### 模式 1:无会话辅助工具(可用) +##### 模式 1:不使用会话辅助工具(可用) -如果你只需要 websocket 传输,而不需要 SDK 为你管理共享提供商或会话,请使用此模式。 +如果你只需要 WebSocket 传输,而不需要 SDK 为你管理共享提供商或会话,请使用此模式。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -此模式适合单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个 `RunConfig` / 提供商实例,否则每次运行都可能重新连接。 +此模式适合单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,每次运行都可能重新连接,除非你手动复用同一个 `RunConfig` / 提供商实例。 ##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) -如果希望在多次运行中共享支持 websocket 的提供商和 `RunConfig`,请使用 [`responses_websocket_session()`][agents.responses_websocket_session];这也包括继承同一个 `run_config` 的嵌套“智能体作为工具”调用。 +如果你希望在多次运行中共享支持 WebSocket 的提供商和 `RunConfig`,请使用 [`responses_websocket_session()`][agents.responses_websocket_session](包括继承同一个 `run_config` 的嵌套“智能体即工具”调用)。 ```python import asyncio @@ -119,59 +119,59 @@ async def main(): asyncio.run(main()) ``` -请在上下文退出前完成流式结果的消费。如果在 websocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 +请在上下文退出前完成对流式结果的消费。如果在 WebSocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 -服务在每个 websocket 连接上一次处理一个响应,并将连接时长限制为 60 分钟。该辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文启动新链,或根据本地管理的会话状态重建该链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 +服务会在每个 WebSocket 连接上逐个处理响应,并将单个连接限制为 60 分钟。该辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文开始新的链,或根据本地管理的会话状态重新构建该链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 -如果长时间推理轮次触发 websocket keepalive 超时,请增大 `ping_timeout`,或将 `ping_timeout=None` 设为禁用心跳超时。对于可靠性比 websocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 +如果长时间推理轮次触发 WebSocket 保活超时,请增大 `ping_timeout`,或将 `ping_timeout=None` 设置为禁用心跳超时。对于可靠性比 WebSocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 ### 运行配置 -`run_config` 参数可用于配置智能体运行的一些全局设置: +通过 `run_config` 参数,你可以配置智能体运行的一些全局设置: #### 常见运行配置类别 使用 `RunConfig` 可覆盖单次运行的行为,而无需更改每个智能体的定义。 -##### 模型、提供商与会话默认设置 +##### 模型、提供商与会话默认值 -- [`model`][agents.run.RunConfig.model]:可设置全局使用的 LLM 模型,而不受各个智能体所设 `model` 的影响。 +- [`model`][agents.run.RunConfig.model]:用于设置全局使用的 LLM 模型,而不受每个智能体所设 `model` 的影响。 - [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 -- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,可以设置全局 `temperature` 或 `top_p`。 -- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认设置(例如 `SessionSettings(limit=...)`)。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每次 `Runner` 运行前如何将新的用户输入与会话历史记录合并。该回调可以是同步或异步的。 +- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定设置。例如,你可以设置全局 `temperature` 或 `top_p`。 +- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认值(例如 `SessionSettings(limit=...)`)。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用会话时,自定义每次 `Runner` 运行前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 ##### 安全防护措施、任务转移与模型输入调整 -- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要在所有运行中包含的输入或输出安全防护措施列表。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:应用于所有任务转移的全局输入过滤器,前提是该任务转移尚未设置过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详细信息,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 中的文档。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选择启用的 Beta 功能。在调用下一个智能体之前,它会将可摘要的历史记录压缩为按顺序排列的助手摘要片段,同时将无损消息项保留在原始位置。在我们完善嵌套任务转移期间,此功能默认禁用;将其设为 `True` 可启用,保持 `False` 则会原样传递原始记录。当 SDK 默认的嵌套历史记录已包含某条消息的确切实例时,Sessions、`RunState` 和 `RunResult.to_input_list()` 会避免将其重复追加两次,同时仍保留彼此独立但内容相同的消息。如果未传入 [Runner 方法][agents.run.Runner]所需的 `RunConfig`,所有这些方法都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。各个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:一个可选的可调用对象;每当你选择启用 `nest_handoff_history` 时,它都会接收规范化的对话记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的准确输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如用于裁剪历史记录或注入系统提示词。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 +- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要纳入所有运行的输入或输出安全防护措施列表。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未设置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详细信息,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 的文档。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项选择启用的测试版功能,在调用下一个智能体之前,将可汇总的历史记录压缩为有序的助手摘要片段,同时在原始位置保留无损消息项。在我们稳定嵌套任务转移功能期间,此功能默认禁用;将其设置为 `True` 可启用,或保留为 `False` 以直接传递原始记录。当 SDK 默认的嵌套历史记录中已包含某条消息时,会话、`RunState` 和 `RunResult.to_input_list()` 会避免重复追加该消息的同一次出现,同时仍保留彼此独立但内容相同的消息。如果你未传入 `RunConfig`,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,而任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。单个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:选择启用 `nest_handoff_history` 时,用于接收规范化记录(历史记录和任务转移项)的可选可调用对象。它必须返回要转发给下一个智能体的确切输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑完整准备好的模型输入(instructions 和输入项)的钩子,例如修剪历史记录或注入系统提示词。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 Runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 ##### 追踪与可观测性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许你为整个运行禁用[追踪](tracing.md)。 - [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],以覆盖追踪导出设置,例如每次运行的追踪 API 密钥。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在的敏感数据,例如 LLM 和工具调用的输入/输出。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:为运行设置追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行的追踪。 -- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含可能的敏感数据,例如 LLM 和工具调用的输入/输出。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是可选字段,可用于关联多次运行之间的追踪。 +- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要纳入所有追踪的元数据。 ##### 工具执行、审批与工具错误行为 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置 SDK 端执行本地工具调用的行为,例如限制同时运行的本地函数工具调用数量。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置当模型生成的函数工具调用名称与当前智能体可用的任何函数工具都不匹配时,runner 如何处理。默认行为是引发 `ModelBehaviorError`;可以选择改为返回模型可见的错误输出。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置当未设置命名空间的函数工具名称与任务转移名称发生冲突时,runner 如何处理。默认值 `"warn"` 会记录一条可操作的警告,并且仅公开当前的分派胜出项;`"error"` 会在调用模型前引发 `UserError`。对具有命名空间和延迟加载工具的严格验证保持不变。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝和选择启用后的工具未找到输出。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 侧的执行行为,例如限制同时运行的本地函数工具调用数量。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置 Runner 如何处理模型发出的函数工具调用,其工具名称与当前智能体可用的任何函数工具均不匹配的情况。默认行为会引发 `ModelBehaviorError`;你可以选择改为返回模型可见的错误输出。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置 Runner 如何处理未命名空间化且发生冲突的函数工具名称和任务转移名称。默认值 `"warn"` 会记录一条可操作的警告,并仅公开当前的分派胜出项;`"error"` 会在调用模型前引发 `UserError`。针对已命名空间化和延迟加载工具的严格验证保持不变。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒和选择启用的“找不到工具”输出。 -嵌套任务转移以可选择启用的 Beta 功能提供。传入 `RunConfig(nest_handoff_history=True)` 可启用有序对话记录压缩,也可以设置 `handoff(..., nest_handoff_history=True)`,为特定任务转移启用该功能。内置映射器会将生成的助手摘要片段放在无损消息项周围,而不是将整个对话记录折叠成一条消息。如果希望保留原始对话记录(默认行为),请勿设置该标志,或提供按需原样转发对话的 `handoff_input_filter`(或 `handoff_history_mapper`)。如需更改生成摘要片段中使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 可恢复默认设置)。 +嵌套任务转移是一项选择启用的测试版功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序记录压缩,或设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会在无损消息项前后放置生成的助手摘要片段,而不是将整个记录折叠成一条消息。如果你希望保留原始记录(默认行为),请不要设置该标志,或提供一个 `handoff_input_filter`(或 `handoff_history_mapper`),按你所需的确切方式转发对话。如果只想更改生成的摘要片段中使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 可恢复默认值)。 #### 运行配置详情 ##### `tool_execution` -如果希望配置 SDK 端对本地函数工具的行为,例如限制一次运行中的本地函数工具并发数,请使用 `tool_execution`。 +如果要配置本地函数工具在 SDK 侧的行为,例如限制一次运行中的本地函数工具并发数,请使用 `tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中生成多个函数工具调用时,SDK 会启动所有已生成的本地函数工具调用。设置整数值可限制这些本地函数工具调用同时运行的数量。 +`max_function_tool_concurrency=None` 会保留默认行为:当模型在一个轮次中发出多个函数工具调用时,SDK 会启动所有已发出的本地函数工具调用。设置整数值可限制同时运行的本地函数工具调用数量。 -这与提供商端的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中生成多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型生成本地函数工具调用后,SDK 如何执行这些调用。 +这与提供商侧的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地函数工具调用后,SDK 如何执行这些调用。 -`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果函数工具需要审批,运行会先暂停,并且工具输入安全防护措施仅在审批通过后、执行前立即运行。如果希望函数工具输入安全防护措施在发出待审批中断前运行,请将其设为 `True`。通过此审批前检查的调用在审批后仍会再次运行相同的输入安全防护措施,因此会在执行前重新验证时效性检查。 +`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果某个函数工具需要审批,运行会先暂停,工具输入安全防护措施仅在审批通过后、执行前立即运行。如果希望在发出待审批中断之前运行函数工具输入安全防护措施,请将其设置为 `True`。通过此审批前检查的调用在审批后仍会再次运行相同的输入安全防护措施,以便在执行前重新验证时效性检查。 ##### `tool_not_found_behavior` -默认情况下,如果模型生成的函数工具调用与当前智能体可用的任何函数工具都不匹配,runner 会引发 `ModelBehaviorError`。 +默认情况下,如果模型发出的函数工具调用与当前智能体可用的任何函数工具都不匹配,Runner 会引发 `ModelBehaviorError`。 -如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,并再次运行模型,使模型能够选择可用工具,或在不使用该工具的情况下作答。 +如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在该模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,使模型可以选择可用工具,或不使用该工具直接作答。 ```python from agents import Agent, RunConfig, Runner @@ -214,13 +214,13 @@ result = await Runner.run( ) ``` -此选项目前仅适用于工具名称查找失败的函数工具调用。其他无效的工具载荷会继续使用其现有的错误处理行为。 +目前,此选项仅适用于工具名称查找失败的函数工具调用。其他无效工具载荷仍会使用其现有错误处理行为。 ##### `tool_error_formatter` -使用 `tool_error_formatter` 可自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 +当 SDK 创建模型可见的工具错误输出时,可使用 `tool_error_formatter` 自定义返回给模型的消息。 -格式化器会接收包含以下内容的 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]: +格式化程序会接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: - `kind`:错误类别,例如 `"approval_rejected"` 或 `"tool_not_found"`。 - `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"` 或 `"custom"`)。 @@ -229,7 +229,7 @@ result = await Runner.run( - `default_message`:SDK 默认的模型可见消息。 - `run_context`:当前运行上下文包装器。 -返回字符串可替换该消息,返回 `None` 则使用 SDK 默认值。 +返回字符串以替换该消息,或返回 `None` 以使用 SDK 默认值。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,56 +256,56 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -当 runner 向前传递历史记录时(例如使用 `RunResult.to_input_list()` 或由会话支持的运行时),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 +当 Runner 将历史记录向后传递时(例如使用 `RunResult.to_input_list()` 或由会话支持的运行),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 -- `None` 或 `"preserve"`(默认):保留推理项 ID。 +- `None` 或 `"preserve"`(默认值):保留推理项 ID。 - `"omit"`:从生成的下一轮输入中移除推理项 ID。 -`"omit"` 主要用作一类 Responses API 400 错误的可选择启用缓解措施:推理项携带 `id` 发送,但后面缺少必需的项(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` 主要用于选择性缓解一类 Responses API 400 错误:推理项带有 `id`,但缺少其后所需的项(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -在多轮智能体运行中,如果 SDK 根据先前输出构建后续输入(包括会话持久化、服务器管理的会话增量、流式/非流式后续轮次以及恢复路径),并且保留了推理项 ID,但提供商要求该 ID 必须继续与其对应的后续项配对,就可能发生这种情况。 +在多轮智能体运行中,如果 SDK 根据先前输出构建后续输入(包括会话持久化、服务器管理的对话增量、流式/非流式后续轮次和恢复路径),并且保留了推理项 ID,但提供商要求该 ID 必须与其对应的后续项配对,就可能发生这种情况。 设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量约束。 -适用范围说明: +范围说明: - 这只会更改 SDK 构建后续输入时生成或转发的推理项。 -- 它不会重写用户提供的初始输入项。 +- 它不会改写用户提供的初始输入项。 - 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 -## 状态与会话管理 +## 状态与对话管理 -### 内存策略选择 +### 记忆策略选择 将状态带入下一轮通常有四种方式: -| 策略 | 状态存储位置 | 最适合 | 下一轮传入的内容 | +| 策略 | 状态所在位置 | 最适合 | 下一轮传入的内容 | | --- | --- | --- | --- | -| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()` 返回的列表加上下一条用户消息 | -| `session` | 你的存储加 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | -| `conversation_id` | OpenAI Conversations API | 希望跨工作进程或服务共享的具名服务器端会话 | 同一个 `conversation_id`,且仅传入新的用户轮次 | -| `previous_response_id` | OpenAI Responses API | 无需创建会话资源的轻量级服务器管理延续 | `result.last_response_id`,且仅传入新的用户轮次 | +| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()` 中的列表加上下一条用户消息 | +| `session` | 你的存储和 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | +| `conversation_id` | OpenAI Conversations API | 希望在工作进程或服务之间共享的命名服务器端对话 | 同一个 `conversation_id`,外加仅包含新用户轮次的内容 | +| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务器管理延续 | `result.last_response_id`,外加仅包含新用户轮次的内容 | -`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI管理,并且仅在使用 OpenAI Responses API时适用。在大多数应用中,每个会话应选择一种持久化策略。混用客户端管理的历史记录与 OpenAI管理的状态可能导致上下文重复,除非你有意协调这两个层级。 +`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅在使用 OpenAI Responses API 时适用。在大多数应用中,每个对话应选择一种持久化策略。除非你有意协调这两个层级,否则混用客户端管理的历史记录与 OpenAI 管理的状态可能会导致上下文重复。 !!! note - 同一次运行中,会话持久化不能与服务器管理的会话设置 + 同一次运行中,会话持久化不能与服务器管理的对话设置 (`conversation_id`、`previous_response_id` 或 `auto_previous_response_id`) 结合使用。每次调用请选择一种方式。 -### 会话与聊天线程 +### 对话/聊天线程 -调用任何运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但它表示聊天会话中的单个逻辑轮次。例如: +调用任何运行方法都可能导致一个或多个智能体运行(因而进行一次或多次 LLM 调用),但在聊天对话中,它表示一个逻辑轮次。例如: 1. 用户轮次:用户输入文本 -2. Runner 运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 +2. Runner 运行:第一个智能体调用 LLM、运行工具并将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 -智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的所有新项目,也可以只显示最终输出。无论采用哪种方式,用户随后都可能提出后续问题,此时可以再次调用运行方法。 +智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论采用哪种方式,用户之后都可能提出后续问题,此时你可以再次调用运行方法。 -#### 手动会话管理 +#### 手动对话管理 -你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法获取下一轮的输入,从而手动管理会话历史记录: +你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法获取下一轮输入,从而手动管理对话历史记录: ```python from agents import Agent, Runner, trace @@ -327,9 +327,9 @@ async def main(): # California ``` -#### 使用会话的自动会话管理 +#### 使用会话的自动对话管理 -若要采用更简单的方法,可以使用 [Sessions](sessions/index.md) 自动处理会话历史记录,而无需手动调用 `.to_input_list()`: +要采用更简单的方法,可以使用[会话](sessions/index.md)自动处理对话历史记录,而无需手动调用 `.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -353,24 +353,24 @@ async def main(): # California ``` -Sessions 会自动: +会话会自动: -- 在每次运行前检索会话历史记录 +- 在每次运行前检索对话历史记录 - 在每次运行后存储新消息 -- 为不同的会话 ID 维护独立会话 +- 为不同的会话 ID 维护独立对话 -更多详细信息请参阅 [Sessions 文档](sessions/index.md)。 +有关更多详细信息,请参阅[会话文档](sessions/index.md)。 -#### 服务器管理的会话 +#### 服务器管理的对话 -你也可以让 OpenAI会话状态功能在服务器端管理会话状态,而不是使用 `to_input_list()` 或 `Sessions` 在本地处理。这样无需手动重新发送所有历史消息,即可保留会话历史记录。对于下述任一服务器管理方式,每次请求仅传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅 [OpenAI会话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 +你也可以让 OpenAI 对话状态功能在服务器端管理对话状态,而不是使用 `to_input_list()` 或 `Sessions` 在本地处理。这样,无需每次手动重新发送所有历史消息即可保留对话历史记录。使用下述任一服务器管理方式时,每个请求只需传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 -OpenAI提供两种跨轮次追踪状态的方式: +OpenAI 提供两种跨轮次追踪状态的方式: ##### 1. 使用 `conversation_id` -首先使用 OpenAI Conversations API创建会话,然后在之后的每次调用中复用其 ID: +首先使用 OpenAI Conversations API 创建对话,然后在后续每次调用中复用其 ID: ```python from agents import Agent, Runner @@ -393,7 +393,7 @@ async def main(): ##### 2. 使用 `previous_response_id` -另一种方式是**响应链式关联**,其中每一轮都会显式链接到上一轮的响应 ID。 +另一个选项是**响应链式关联**,其中每个轮次都会显式关联上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -418,30 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,使恢复后的轮次继续在同一个服务器管理的会话中运行。 +如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,以便恢复后的轮次继续使用同一服务器管理的对话。 -`conversation_id` 和 `previous_response_id` 互斥。如果需要可跨系统共享的具名会话资源,请使用 `conversation_id`。如果需要从一轮延续到下一轮的最轻量 Responses API基本组件,请使用 `previous_response_id`。 +`conversation_id` 与 `previous_response_id` 互斥。如果希望使用可跨系统共享的命名对话资源,请使用 `conversation_id`。如果希望使用最轻量的 Responses API 基本组件在轮次之间延续,请使用 `previous_response_id`。 !!! note - SDK 会自动通过退避机制重试 `conversation_locked` 错误。在服务器管理的 - 会话运行中,它会在重试前回退内部会话追踪器的输入,以便可以完整地重新发送 - 相同的已准备项目。 + SDK 会自动采用退避策略重试 `conversation_locked` 错误。在服务器管理的 + 对话运行中,SDK 会在重试前回退内部对话追踪器的输入,以便完整地重新发送 + 相同的已准备项。 - 在基于本地会话的运行中(不能与 `conversation_id`、 + 在基于本地会话的运行中(无法与 `conversation_id`、 `previous_response_id` 或 `auto_previous_response_id` 结合使用),SDK 还会尽力 - 回滚最近持久化的输入项,以减少重试后重复的历史记录条目。 + 回滚最近持久化的输入项,以减少重试后出现重复历史记录条目的情况。 - 即使未配置 `ModelSettings.retry`,也会执行此兼容性重试。有关更广泛、可选择启用的 - 模型请求重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 + 即使你未配置 `ModelSettings.retry`,也会执行此兼容性重试。有关针对模型请求 + 更广泛的选择启用式重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 ## 钩子与自定义 ### 模型调用输入过滤器 -使用 `call_model_input_filter` 可在调用模型前立即编辑模型输入。该钩子会接收当前智能体、上下文和合并后的输入项(包括存在时的会话历史记录),并返回新的 `ModelInputData`。 +使用 `call_model_input_filter` 可在调用模型前编辑模型输入。该钩子接收当前智能体、上下文和合并后的输入项(包括存在的会话历史记录),并返回新的 `ModelInputData`。 -返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段是必需的,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 +返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填项,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 ```python from agents import Agent, Runner, RunConfig @@ -460,19 +460,19 @@ result = Runner.run_sync( ) ``` -Runner 会将已准备输入列表的副本传给该钩子,因此你可以裁剪、替换或重新排序该列表,而不会原地修改调用方的原始列表。 +Runner 会将已准备输入列表的副本传给该钩子,因此你可以修剪、替换或重新排序,而无需就地修改调用方的原始列表。 -如果使用会话,`call_model_input_filter` 会在会话历史记录已加载并与当前轮次合并后运行。如果希望自定义更早的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 +如果使用会话,`call_model_input_filter` 会在会话历史记录已加载并与当前轮次合并后运行。如果希望自定义此前的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 -如果使用由 OpenAI服务器管理的会话状态,并设置了 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id`,该钩子会针对下一次 Responses API调用已准备的载荷运行。该载荷可能已仅表示新轮次的增量,而不是完整重放早期历史记录。只有你返回的项目才会被标记为已发送至该服务器管理的延续流程。 +如果使用 OpenAI 服务器管理的对话状态以及 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id`,该钩子会对下一次 Responses API 调用的已准备载荷运行。该载荷可能已经只表示新轮次的增量,而不是对先前完整历史记录的重放。只有你返回的项才会被标记为已发送,用于该服务器管理的延续。 -通过 `run_config` 为每次运行设置该钩子,以遮盖敏感数据、裁剪过长的历史记录或注入额外的系统指导。 +通过 `run_config` 为每次运行设置该钩子,以隐去敏感数据、修剪过长的历史记录或注入额外的系统指引。 ## 错误与恢复 ### 错误处理程序 -所有 `Runner` 入口点都接受 `error_handlers`,这是一个以错误类型为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些键。 +所有 `Runner` 入口点都接受 `error_handlers`,它是一个以错误类型为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是让运行因相应错误而结束,请使用这些键。 ```python from agents import ( @@ -501,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -当模型消息无法通过智能体的结构化 `output_type` 验证,或模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。处理程序可以返回应用特定的回退值,SDK 会根据同一个 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空验证失败仍会引发 `ModelBehaviorError`,而空结构化响应会保留现有的下一轮行为。 +当模型消息无法通过智能体的结构化 `output_type` 验证,或模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。该处理程序可以返回应用特定的回退值,SDK 会根据同一个 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空验证失败仍会引发 `ModelBehaviorError`,而空结构化响应则保留现有的下一轮行为。 ```python from pydantic import BaseModel @@ -533,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理程序,这会将合成的回退输出追加到会话历史记录中,并将其持久化至已配置的会话。如果希望向调用方返回回退值,而不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 +`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理程序,这会将合成的回退输出追加到对话历史记录中,并将其持久化到已配置的会话。如果希望将回退值返回给调用方,但不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 -如果希望模型拒绝时生成应用特定的回退值,而不是以 `ModelRefusalError` 结束运行,请使用 `"model_refusal"`。 +当模型拒绝响应时,如果希望生成应用特定的回退值,而不是让运行以 `ModelRefusalError` 结束,请使用 `"model_refusal"`。 ```python from pydantic import BaseModel @@ -567,35 +567,35 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人工介入 +## 持久执行集成与人在回路 -有关工具审批的暂停/恢复模式,请先参阅专门的[人工介入指南](human_in_the_loop.md)。以下集成适用于运行可能跨越长时间等待、重试或进程重启的持久编排。 +对于工具审批的暂停/恢复模式,请先参阅专门的[人在回路指南](human_in_the_loop.md)。下述集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 ### Dapr -你可以使用 Agents SDK [Dapr](https://dapr.io) Diagrid 集成来运行持久的长时间运行智能体,这些智能体可自动从故障中恢复并支持人工介入工作流。Dapr 是一个供应商中立的 [CNCF](https://cncf.io) 工作流编排器。[在此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI智能体。 +你可以使用 Agents SDK的 [Dapr](https://dapr.io) Diagrid 集成,运行持久的长时间运行智能体,使其自动从故障中恢复并支持人在回路工作流。Dapr 是一个供应商中立的 [CNCF](https://cncf.io) 工作流编排器。可从[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI智能体。 ### Temporal -你可以使用 Agents SDK [Temporal](https://temporal.io/) 集成来运行持久的长时间运行工作流,包括人工介入任务。你可以[在此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并[在此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 +你可以使用 Agents SDK的 [Temporal](https://temporal.io/) 集成来运行持久的长时间运行工作流,包括人在回路任务。你可以在[此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK实际协作完成长时间运行任务的演示,并在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 ### Restate -你可以使用 Agents SDK [Restate](https://restate.dev/) 集成来运行轻量级持久智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单一二进制运行时,并支持将智能体作为进程/容器或无服务器函数运行。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 +你可以使用 Agents SDK的 [Restate](https://restate.dev/) 集成来构建轻量且持久的智能体,包括人工审批、任务转移和会话管理。该集成需要将 Restate 的单二进制运行时作为依赖项,并支持以进程/容器或无服务器函数的形式运行智能体。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 ### DBOS -你可以使用 Agents SDK [DBOS](https://dbos.dev/) 集成来运行可靠的智能体,在发生故障和重启时仍可保留进度。它支持长时间运行的智能体、人工介入工作流和任务转移,并同时支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 +你可以使用 Agents SDK的 [DBOS](https://dbos.dev/) 集成来运行可靠的智能体,并在发生故障和重启时保留进度。它支持长时间运行的智能体、人在回路工作流和任务转移,也同时支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 ## 异常 -SDK 会在特定情况下引发异常。完整列表请参阅 [`agents.exceptions`][]。概述如下: +SDK 会在特定情况下引发异常。完整列表位于 [`agents.exceptions`][]。概述如下: -- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 引发的所有异常的基类。它是一种通用类型,其他所有具体异常都派生自该类型。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。它表示智能体无法在指定的智能体循环轮次数(LLM 调用次数)内完成任务。设置 `max_turns=None` 可禁用该限制。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。这可能包括: +- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 所引发全部异常的基类。它是一个通用类型,其他所有特定异常都派生自此类。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。它表示智能体无法在指定数量的智能体循环轮次(LLM 调用)内完成任务。设置 `max_turns=None` 可禁用此限制。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。具体情况可能包括: - 格式错误的 JSON:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 时。 - - 意外的工具相关故障:模型未按预期方式使用工具 + - 意外的工具相关失败:模型未按预期方式使用工具时 - [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当函数工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会引发此异常。 -- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,会引发此异常。这通常是由不正确的代码实现、无效配置或误用 SDK API 导致的。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:当满足输入安全防护措施的条件时,会引发 `InputGuardrailTripwireTriggered`;当满足输出安全防护措施的条件时,会引发 `OutputGuardrailTripwireTriggered`。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file +- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,就会引发此异常。通常是由于代码实现错误、配置无效或误用 SDK API 所致。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:当输入安全防护措施的条件满足时,会引发 `InputGuardrailTripwireTriggered`;当输出安全防护措施的条件满足时,会引发 `OutputGuardrailTripwireTriggered`。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file diff --git a/docs/zh/sandbox/clients.md b/docs/zh/sandbox/clients.md index 1f86ffb167..f2c945ece4 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -4,42 +4,42 @@ search: --- # 沙箱客户端 -使用本页选择沙箱工作应在哪里运行。在大多数情况下,`SandboxAgent` 定义保持不变,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端和客户端特定选项。 +使用本页选择沙箱工作应在何处运行。在大多数情况下,`SandboxAgent` 定义保持不变,仅需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端和客户端特定选项。 !!! warning "Beta 功能" - 沙箱智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认值和支持的功能可能会发生变化,并且预计未来会提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且未来将提供更多高级功能。 ## 决策指南
-| 目标 | 首选 | 原因 | +| 目标 | 首选方案 | 原因 | | --- | --- | --- | -| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于使用本地文件系统进行开发。 | -| 基本的容器隔离 | `DockerSandboxClient` | 使用特定镜像在 Docker 内运行工作。 | -| 托管执行或生产环境级隔离 | 托管式沙箱客户端 | 将工作区边界移至由提供商管理的环境。 | +| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,适合简单的本地文件系统开发。 | +| 基础容器隔离 | `DockerSandboxClient` | 使用指定镜像在 Docker 中运行工作。 | +| 托管执行或生产级隔离 | 托管沙箱客户端 | 将工作区边界迁移到由提供商管理的环境。 |
## 本地客户端 -对于大多数用户,建议从以下两种沙箱客户端之一开始: +对于大多数用户,建议从以下两个沙箱客户端之一开始:
| 客户端 | 安装 | 适用场景 | 示例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上实现最快的本地迭代。适合作为本地开发的默认选择。 | [Unix 本地入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 需要容器隔离,或需要使用特定镜像在本地复现目标环境。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上实现最快的本地迭代。是本地开发的良好默认选择。 | [Unix 本地入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 希望使用容器隔离,或使用指定镜像在本地复现目标环境。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local 是基于本地文件系统进行开发的最简便方式。当需要更强的环境隔离或与生产环境保持一致时,请改用 Docker 或托管提供商。 +Unix 本地客户端是基于本地文件系统开始开发的最简便方式。当需要更强的环境隔离或与生产环境保持一致时,可迁移到 Docker 或托管提供商。 -`SandboxPathGrant.host_path` 仅适用于 Docker,用于将主机路径映射到容器内的另一个 POSIX 路径。Unix-local 仅支持同路径授权。详情请参阅[清单路径授权](guide.md#manifest)。 +`SandboxPathGrant.host_path` 仅适用于 Docker,它会将主机路径映射到容器内不同的 POSIX 路径。Unix 本地客户端仅支持同路径授权。有关详细信息,请参阅[清单路径授权](guide.md#manifest)。 -要从 Unix-local 切换到 Docker,请保持智能体定义不变,仅更改运行配置: +要从 Unix 本地客户端切换到 Docker,请保持智能体定义不变,仅更改运行配置: ```python from docker import from_env as docker_from_env @@ -60,13 +60,13 @@ run_config = RunConfig( ## 挂载与远程存储 -挂载条目描述要公开的存储;挂载策略描述沙箱后端如何连接该存储。可从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管提供商策略可从 `agents.extensions.sandbox` 或特定于提供商的扩展包中获取。 +挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管提供商策略可从 `agents.extensions.sandbox` 或提供商专用扩展包中获取。 常用挂载选项: -- `mount_path`:存储在沙箱中显示的位置。相对路径基于清单根目录解析;绝对路径则按原样使用。 -- `read_only`:默认为 `True`。仅当沙箱应将更改写回已挂载存储时,才设置为 `False`。 -- `mount_strategy`:必填。所用策略必须同时匹配挂载条目和沙箱后端。 +- `mount_path`:存储在沙箱中的显示位置。相对路径基于清单根目录解析;绝对路径按原样使用。 +- `read_only`:默认为 `True`。仅当沙箱应将更改写回已挂载存储时,才设置 `False`。 +- `mount_strategy`:必填。请使用同时匹配挂载条目和沙箱后端的策略。 挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不会将已挂载的远程存储复制到保存的工作区中。 @@ -77,20 +77,20 @@ run_config = RunConfig( | 策略或模式 | 适用场景 | 说明 | | --- | --- | --- | | `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙箱镜像可以运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可在 `fuse` 模式或 `nfs` 模式下运行。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像中包含 `mount-s3`,并且需要 Mountpoint 风格的 S3 或 S3 兼容访问。 | 支持 `S3Mount` 和 `GCSMount`。 | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像中包含 `blobfuse2` 并支持 FUSE。 | 支持 `AzureBlobMount`。 | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像中包含 `mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | -| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前连接由卷驱动程序支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 可通过 `rclone` 挂载;S3 和 GCS 也可通过 `mountpoint` 挂载。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像包含 `mount-s3`,并且需要 Mountpoint 风格的 S3 或兼容 S3 的访问方式。 | 支持 `S3Mount` 和 `GCSMount`。 | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像包含 `blobfuse2` 并支持 FUSE。 | 支持 `AzureBlobMount`。 | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像包含 `mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | +| `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前附加由卷驱动程序支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 可通过 `rclone` 挂载;S3 和 GCS 也可通过 `mountpoint` 挂载。 | ## 支持的托管平台 -当需要托管环境时,通常可以沿用同一份 `SandboxAgent` 定义,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端。 +需要托管环境时,通常可以沿用同一个 `SandboxAgent` 定义,仅更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端。 -如果使用的是已发布的 SDK,而非此仓库的检出版本,请通过匹配的软件包 extra 安装沙箱客户端依赖项。 +如果使用的是已发布的 SDK,而不是此代码仓库的检出版本,请通过对应的软件包 extra 安装沙箱客户端依赖项。 -有关特定于提供商的设置说明以及仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。 +有关提供商特定的设置说明,以及代码仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。
@@ -106,24 +106,42 @@ run_config = RunConfig(
-托管式沙箱客户端会公开特定于提供商的挂载策略。请选择最适合所用存储提供商的后端和挂载策略: +托管沙箱客户端会提供特定于提供商的挂载策略。请选择最适合所用存储提供商的后端和挂载策略:
| 后端 | 挂载说明 | | --- | --- | | Docker | 支持将 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` 和 `S3FilesMount` 与 `InContainerMountStrategy`、`DockerVolumeMountStrategy` 等本地策略配合使用。 | -| `ModalSandboxClient` | 支持使用 `ModalCloudBucketMountStrategy` 以及 `S3Mount`、`R2Mount` 和通过 HMAC 身份验证的 `GCSMount` 挂载云存储桶。可以使用内联凭据或已命名的 Modal Secret。 | -| `CloudflareSandboxClient` | 支持使用 `CloudflareBucketMountStrategy` 以及 `S3Mount`、`R2Mount` 和通过 HMAC 身份验证的 `GCSMount` 挂载存储桶。 | -| `BlaxelSandboxClient` | 支持将 `BlaxelCloudBucketMountStrategy` 与 `S3Mount`、`R2Mount` 或 `GCSMount` 条目配对,以挂载云存储桶。还支持通过 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 使用持久化 Blaxel Drives,二者均可从 `agents.extensions.sandbox.blaxel` 获取。 | -| `DaytonaSandboxClient` | 支持使用 `DaytonaCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `E2BSandboxClient` | 支持使用 `E2BCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `RunloopSandboxClient` | 支持使用 `RunloopCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `VercelSandboxClient` | 支持将 `VercelCloudBucketMountStrategy` 与 `S3Mount` 条目配对,以挂载仅能在创建时配置的 S3 和 S3 兼容存储桶;已挂载的会话无法恢复,并且内联凭据需要 `allow_s3_credential_exposure=True`。 | +| `ModalSandboxClient` | 支持通过 `ModalCloudBucketMountStrategy` 使用 `S3Mount`、`R2Mount` 和经 HMAC 身份验证的 `GCSMount` 来挂载云存储桶。可以使用内联凭证或具名 Modal Secret。 | +| `CloudflareSandboxClient` | 支持通过 `CloudflareBucketMountStrategy` 使用 `S3Mount`、`R2Mount` 和经 HMAC 身份验证的 `GCSMount` 来挂载存储桶。 | +| `BlaxelSandboxClient` | 支持将 `BlaxelCloudBucketMountStrategy` 与 `S3Mount`、`R2Mount` 或 `GCSMount` 条目配对来挂载云存储桶。还支持使用 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 挂载持久化 Blaxel Drives,两者均可从 `agents.extensions.sandbox.blaxel` 获取。 | +| `DaytonaSandboxClient` | 支持通过 `rclone` 使用 `DaytonaCloudBucketMountStrategy` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `E2BSandboxClient` | 支持通过 `rclone` 使用 `E2BCloudBucketMountStrategy` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `RunloopSandboxClient` | 支持通过 `rclone` 使用 `RunloopCloudBucketMountStrategy` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `VercelSandboxClient` | 支持将 `VercelCloudBucketMountStrategy` 与 `S3Mount` 条目配对,以挂载仅能在创建时配置的 S3 和兼容 S3 的存储桶;已挂载的会话无法恢复,并且内联凭证需要 `allow_s3_credential_exposure=True`。 |
-下表总结了每种后端可直接挂载哪些远程存储条目。 +挂载表描述了每个后端能够执行哪些存储类型。对于在由模型控制的沙箱内运行的挂载辅助程序,勾选标记并不会绕过凭证边界,也不表示每种策略都可以在没有凭证的情况下运行。仅当所选辅助程序可以在不使用受保护权限的情况下运行时,Agents SDK 才会接受未经确认的容器内挂载。如果挂载需要受保护权限,Agents SDK 会在启动沙箱或挂载辅助程序之前拒绝该挂载,除非可信的应用程序代码针对确切的挂载路径明确确认允许暴露该权限。 + +无需凭证的 `rclone` 挂载仅限于 S3、GCS、R2 和 Azure Blob。容器内的 Box 挂载需要非交互式身份验证来源,并且需要与该来源匹配的确认。`FuseMountPattern` 需要广泛权限确认,因为即使未配置内联凭证,`blobfuse2` 也会发现环境中的 Azure 权限。类似地,`S3FilesMountPattern` 也需要广泛权限确认,因为 `mount.s3files` 会使用环境中的 IAM 权限。当 Docker 作为后端时,这些要求同样适用;下表中的勾选标记表示在满足适用的权限边界后,Docker 可以执行该挂载。 + +对于名为 `"data"` 的挂载条目,请保留由与已配置权限匹配的确认操作所返回的 `Manifest` 副本: + +```python +# Mount-scoped values such as inline access keys. +manifest = manifest.with_in_container_mount_credential_exposure_acknowledged("data") + +# Broader authority such as managed or workload identity and external credential files. +manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") +``` + +请传入需要确认的每个确切挂载路径。同时使用两种权限类别的挂载需要两项确认。这些确认仅在运行时有效,不会被序列化,并且会允许辅助程序接收凭证,而不会将凭证的使用范围限制在已挂载路径内。应优先使用外部策略或提供商原生策略;否则,请使用作用域限定于沙箱、有效期短且遵循最小权限原则的凭证。 + +`VercelSandboxClientOptions(allow_s3_credential_exposure=True)` 仍可作为兼容性选项,用于在创建 Vercel S3 挂载时使用作用域限定于挂载的内联凭证。它不会授予广泛的凭证权限。 + +下表汇总了每个后端可以直接挂载的远程存储条目。
@@ -140,4 +158,4 @@ run_config = RunConfig(
-如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),其中包含本地运行、编码、记忆、任务转移和智能体组合模式;有关托管式沙箱客户端,请浏览 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)。 \ No newline at end of file +如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),其中包含本地运行、编码、内存、任务转移和智能体组合模式;有关托管沙箱客户端,请浏览 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)。 \ No newline at end of file diff --git a/docs/zh/sandbox/guide.md b/docs/zh/sandbox/guide.md index 538d2d543a..f3cff7526a 100644 --- a/docs/zh/sandbox/guide.md +++ b/docs/zh/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "Beta 功能" - 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且未来还会逐步提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认值和支持的能力可能会发生变化,并且后续会逐步提供更多高级功能。 -现代智能体若能在文件系统中操作真实文件,通常可以发挥最佳效果。**沙箱智能体**可以使用专用工具和 shell 命令搜索和处理大型文档集、编辑文件、生成产物以及运行命令。沙箱为模型提供持久化工作区,智能体可以在其中代您执行工作。Agents SDK 中的沙箱智能体可帮助您轻松运行与沙箱环境配对的智能体,便于将正确的文件放入文件系统,并编排沙箱,从而大规模启动、停止和恢复任务。 +现代智能体在能够操作文件系统中的真实文件时表现最佳。**沙箱智能体**可以使用专用工具和 shell 命令搜索及操作大型文档集、编辑文件、生成产物并运行命令。沙箱为模型提供持久化工作区,智能体可使用该工作区代您完成工作。Agents SDK 中的沙箱智能体可帮助您轻松运行与沙箱环境配对的智能体,方便您将所需文件放入文件系统,并对沙箱进行编排,从而轻松地大规模启动、停止和恢复任务。 您可以围绕智能体所需的数据定义工作区。工作区可以从 GitHub 仓库、本地文件和目录、合成任务文件、S3 或 Azure Blob Storage 等远程文件系统,以及您提供的其他沙箱输入开始构建。
-![带计算环境的沙箱智能体运行框架](../assets/images/harness_with_compute.png) +![带计算能力的沙箱智能体运行框架](../assets/images/harness_with_compute.png)
-`SandboxAgent` 仍然是 `Agent`。它保留常规的智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规 `Runner` API 运行。变化的是执行边界: +`SandboxAgent` 仍然是 `Agent`。它保留常规智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规 `Runner` API 运行。变化的是执行边界: -- `SandboxAgent` 定义智能体本身:常规智能体配置,以及 `default_manifest`、`base_instructions`、`run_as` 等沙箱专用默认值和文件系统工具、shell 访问、技能、记忆或压缩等能力。 -- `Manifest` 声明新沙箱工作区预期的初始内容和布局,包括文件、仓库、挂载和环境。 -- 沙箱会话是运行命令和修改文件的实时隔离环境。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定本次运行如何获取该沙箱会话,例如直接注入会话、从序列化的沙箱会话状态重新连接,或通过沙箱客户端创建新的沙箱会话。 -- 已保存的沙箱状态和快照可让后续运行重新连接到先前的工作,或使用已保存的内容初始化新的沙箱会话。 +- `SandboxAgent` 定义智能体本身:常规智能体配置,加上 `default_manifest`、`base_instructions`、`run_as` 等沙箱专属默认值,以及文件系统工具、shell 访问、技能、记忆或压缩等能力。 +- `Manifest` 声明新沙箱工作区所需的初始内容和布局,包括文件、仓库、挂载和环境。 +- 沙箱会话是运行命令和更改文件的实时隔离环境。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定本次运行如何获得该沙箱会话,例如直接注入、从序列化的沙箱会话状态重新连接,或通过沙箱客户端创建新的沙箱会话。 +- 保存的沙箱状态和快照使后续运行可以重新连接到先前的工作,或从保存的内容为新的沙箱会话设定初始状态。 -`Manifest` 是新会话工作区的约定,并非每个实时沙箱的完整事实来源。一次运行的实际工作区也可以来自复用的沙箱会话、序列化的沙箱会话状态,或运行时选择的快照。 +`Manifest` 是新会话的工作区契约,而不是每个实时沙箱的完整事实来源。某次运行的有效工作区也可以来自复用的沙箱会话、序列化的沙箱会话状态,或运行时选择的快照。 -在本页中,“沙箱会话”是指由沙箱客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中介绍的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 +在本页中,“沙箱会话”指由沙箱客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中所述的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 -外层运行时仍负责审批、追踪、任务转移,以及跟踪恢复运行所需的状态。沙箱会话负责命令、文件更改和环境隔离。这种职责划分是该模型的核心组成部分。 +外层运行时仍负责审批、追踪、任务转移,以及跟踪恢复运行所需的状态。沙箱会话负责命令、文件更改和环境隔离。这种职责划分是该模型的核心部分。 -### 组件之间的关系 +### 各组件的协作方式 -沙箱运行将智能体定义与每次运行的沙箱配置结合起来。运行器会准备智能体、将其绑定到实时沙箱会话,并可保存状态供后续运行使用。 +沙箱运行将智能体定义与每次运行的沙箱配置结合起来。运行器会准备智能体,将其绑定到实时沙箱会话,并可保存状态供后续运行使用。 ```mermaid flowchart LR @@ -50,95 +50,95 @@ flowchart LR sandbox --> saved ``` -沙箱专用默认值保留在 `SandboxAgent` 上。每次运行的沙箱会话选项保留在 `SandboxRunConfig` 中。 +沙箱专属默认值保留在 `SandboxAgent` 中。每次运行的沙箱会话选项保留在 `SandboxRunConfig` 中。 -可以将生命周期理解为三个阶段: +可以从三个阶段理解其生命周期: -1. 使用 `SandboxAgent`、`Manifest` 和各项能力定义智能体与新工作区约定。 -2. 向 `Runner` 提供一个 `SandboxRunConfig`,由其注入、恢复或创建沙箱会话,从而执行一次运行。 -3. 后续从运行器管理的 `RunState`、显式沙箱 `session_state` 或已保存的工作区快照继续运行。 +1. 使用 `SandboxAgent`、`Manifest` 和能力定义智能体及新工作区契约。 +2. 向 `Runner` 提供 `SandboxRunConfig` 来执行运行,由其注入、恢复或创建沙箱会话。 +3. 后续从运行器管理的 `RunState`、显式沙箱 `session_state` 或保存的工作区快照继续运行。 -如果 shell 访问只是偶尔使用的一项工具,请先使用[工具指南](../tools.md)中的托管 shell。如果工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分,则应使用沙箱智能体。 +如果 shell 访问只是偶尔使用的一项工具,请先使用[工具指南](../tools.md)中的托管 shell。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,请使用沙箱智能体。 ## 适用场景 沙箱智能体非常适合以工作区为中心的工作流,例如: -- 编码和调试,例如针对 GitHub 仓库中的问题报告编排自动修复并运行针对性测试 -- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完毕的税表草稿 -- 基于文件的审查或分析,例如在回答前检查入职资料包、生成的报告或产物包 +- 编码和调试,例如编排对 GitHub 仓库中问题报告的自动修复,并运行针对性测试 +- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完成的税务表单草稿 +- 基于文件的审查或分析,例如在回答前检查入职材料包、生成的报告或产物包 - 隔离的多智能体模式,例如为每个审查智能体或编码子智能体提供各自的工作区 -- 多步骤工作区任务,例如在一次运行中修复错误,之后再添加回归测试,或从快照或沙箱会话状态恢复 +- 多步骤工作区任务,例如在一次运行中修复错误,之后添加回归测试,或从快照或沙箱会话状态恢复 -如果不需要访问文件或使用有状态、可变的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔使用的一项能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 +如果您不需要访问文件或有状态、可变的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔需要的一项能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 -## 沙箱客户端的选择 +## 沙箱客户端选择 -在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,如果需要容器隔离或镜像一致性,请改用 `DockerSandboxClient`;如果需要由提供商管理执行,则改用托管提供商。 +在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,当您需要容器隔离或镜像一致性时,请迁移到 `DockerSandboxClient`;当您需要由提供商管理的执行环境时,请使用托管提供商。 -大多数情况下,`SandboxAgent` 定义保持不变,只需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙箱客户端](clients.md)。 +在大多数情况下,`SandboxAgent` 定义保持不变,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙箱客户端](clients.md)。 ## 核心组件
-| 层级 | 主要 SDK 组件 | 回答的问题 | +| 层 | 主要 SDK 组件 | 解答的问题 | | --- | --- | --- | -| 智能体定义 | `SandboxAgent`、`Manifest`、能力 | 将运行哪个智能体,以及它应从什么新会话工作区约定开始? | -| 沙箱执行 | `SandboxRunConfig`、沙箱客户端和实时沙箱会话 | 本次运行如何获得实时沙箱会话,工作在哪里执行? | -| 已保存的沙箱状态 | `RunState` 沙箱载荷、`session_state` 和快照 | 此工作流如何重新连接到先前的沙箱工作,或使用已保存的内容初始化新的沙箱会话? | +| 智能体定义 | `SandboxAgent`、`Manifest`、能力 | 将运行哪个智能体,以及它应从什么样的新会话工作区契约开始? | +| 沙箱执行 | `SandboxRunConfig`、沙箱客户端和实时沙箱会话 | 本次运行如何获得实时沙箱会话,以及工作在哪里执行? | +| 保存的沙箱状态 | `RunState` 沙箱载荷、`session_state` 和快照 | 此工作流如何重新连接到先前的沙箱工作,或根据保存的内容为新沙箱会话设定初始状态? |
-主要 SDK 组件与这些层级的对应关系如下: +主要 SDK 组件与这些层的对应关系如下:
-| 组件 | 负责的内容 | 应考虑的问题 | +| 组件 | 负责的内容 | 应询问的问题 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应该做什么,哪些默认值应随其一同使用? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 新会话工作区的文件和文件夹 | 运行开始时,文件系统中应该有哪些文件和文件夹? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应该做什么,哪些默认值应随它一起使用? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新会话工作区中的文件和文件夹 | 运行开始时,文件系统中应存在哪些文件和文件夹? | | [`Capability`][agents.sandbox.capabilities.capability.Capability] | 沙箱原生行为 | 应为此智能体附加哪些工具、指令片段或运行时行为? | | [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 每次运行的沙箱客户端和沙箱会话来源 | 本次运行应注入、恢复还是创建沙箱会话? | -| [`RunState`][agents.run_state.RunState] | 由运行器管理的已保存沙箱状态 | 我是否正在恢复由运行器管理的先前工作流,并自动沿用其沙箱状态? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙箱会话状态 | 我是否要从已在 `RunState` 外部序列化的沙箱状态恢复? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于新沙箱会话的已保存工作区内容 | 新沙箱会话是否应从已保存的文件和产物开始? | +| [`RunState`][agents.run_state.RunState] | 由运行器管理的已保存沙箱状态 | 我是否正在恢复先前由运行器管理的工作流,并自动将其沙箱状态延续下去? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙箱会话状态 | 我是否希望从已在 `RunState` 外部序列化的沙箱状态恢复? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于新沙箱会话的已保存工作区内容 | 新沙箱会话是否应从保存的文件和产物开始? |
实用的设计顺序如下: -1. 使用 `Manifest` 定义新会话工作区约定。 +1. 使用 `Manifest` 定义新会话工作区契约。 2. 使用 `SandboxAgent` 定义智能体。 3. 添加内置或自定义能力。 4. 在 `RunConfig(sandbox=SandboxRunConfig(...))` 中决定每次运行应如何获取沙箱会话。 -## 沙箱运行的准备流程 +## 沙箱运行的准备过程 -在运行时,运行器会将该定义转换为由沙箱支持的具体运行: +运行时,运行器会将该定义转换为由沙箱支持的具体运行: -1. 它从 `SandboxRunConfig` 解析沙箱会话。如果传入 `session=...`,则复用该实时沙箱会话。否则,它使用 `client=...` 创建或恢复会话。 -2. 它确定本次运行的实际工作区输入。如果运行注入或恢复了沙箱会话,则以该现有沙箱状态为准。否则,运行器从一次性清单覆盖项或 `agent.default_manifest` 开始。因此,仅靠 `Manifest` 无法定义每次运行的最终实时工作区。 -3. 它让各项能力处理生成的清单。这样,能力便可在准备最终智能体之前添加文件、挂载或其他工作区范围内的行为。 -4. 它按固定顺序构建最终指令:SDK 的默认沙箱提示词;如果显式覆盖,则使用 `base_instructions`;随后是 `instructions`、能力指令片段、所有远程挂载策略文本,最后是渲染后的文件系统树。 +1. 它从 `SandboxRunConfig` 解析沙箱会话。如果您传入 `session=...`,它会复用该实时沙箱会话。否则,它会使用 `client=...` 创建或恢复沙箱会话。 +2. 它确定本次运行的有效工作区输入。如果运行注入或恢复了沙箱会话,则以该现有沙箱状态为准。否则,运行器会从一次性清单覆盖项或 `agent.default_manifest` 开始。这就是为什么仅靠 `Manifest` 无法定义每次运行最终的实时工作区。 +3. 它允许能力处理生成的清单。这样,能力便可在最终智能体准备完成前添加文件、挂载或其他工作区范围的行为。 +4. 它按固定顺序构建最终指令:SDK 的默认沙箱提示词;如果您显式覆盖,则使用 `base_instructions`;之后是 `instructions`、能力指令片段、任何远程挂载策略文本,最后是渲染后的文件系统树。 5. 它将能力工具绑定到实时沙箱会话,并通过常规 `Runner` API 运行准备好的智能体。 -沙箱不会改变轮次的含义。一个轮次仍然是一次模型步骤,而不是一条 shell 命令或一次沙箱操作。沙箱侧操作与轮次之间不存在固定的 1:1 映射:有些工作可能完全在沙箱执行层内完成,而其他操作则会返回需要另一次模型步骤的信息,例如工具结果、审批或其他类型的状态。实际判断原则是:只有在沙箱工作完成后,智能体运行时需要模型再次响应时,才会消耗另一个轮次。 +沙箱不会改变轮次的含义。一个轮次仍是一次模型步骤,而不是一条 shell 命令或一次沙箱操作。沙箱侧操作与轮次之间没有固定的 1:1 映射:有些工作可能始终留在沙箱执行层中,而其他操作会返回需要另一次模型步骤的信息,例如工具结果、审批或其他类型的状态。实际而言,只有在沙箱工作完成后,智能体运行时还需要另一次模型响应时,才会消耗另一个轮次。 -正因为存在这些准备步骤,在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 才是需要重点考虑的主要沙箱专用选项。 +这些准备步骤说明了为什么在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 是需要重点考虑的主要沙箱专属选项。 ## `SandboxAgent` 选项 -除常规 `Agent` 字段外,还提供以下沙箱专用选项: +除了常规 `Agent` 字段外,还提供以下沙箱专属选项:
| 选项 | 最佳用途 | | --- | --- | -| `default_manifest` | 由运行器创建的新沙箱会话的默认工作区。 | -| `instructions` | 附加在 SDK 沙箱提示词之后的其他角色、工作流和成功标准。 | -| `base_instructions` | 用于替换 SDK 沙箱提示词的高级逃生舱选项。 | -| `capabilities` | 应随此智能体一同使用的沙箱原生工具和行为。 | +| `default_manifest` | 运行器创建的新沙箱会话所使用的默认工作区。 | +| `instructions` | 附加在 SDK 沙箱提示词之后的额外角色、工作流和成功标准。 | +| `base_instructions` | 替换 SDK 沙箱提示词的高级逃生舱口。 | +| `capabilities` | 应随此智能体一起使用的沙箱原生工具和行为。 | | `run_as` | 用于 shell 命令、文件读取和补丁等面向模型的沙箱工具的用户身份。 |
@@ -147,41 +147,41 @@ flowchart LR ### `default_manifest` -`default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。使用它定义智能体通常应从哪些文件、仓库、辅助材料、输出目录和挂载开始。 +`default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。请使用它指定智能体通常应具备的初始文件、仓库、辅助材料、输出目录和挂载。 -这只是默认值。运行可以通过 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 +这只是默认值。运行可以使用 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 ### `instructions` 和 `base_instructions` -对于应在不同提示词之间保留的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会附加到 SDK 沙箱基础提示词之后,因此您可以保留内置沙箱指导,同时添加自己的角色、工作流和成功标准。 +对于应在不同提示词之间保持不变的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会附加在 SDK 的沙箱基础提示词之后,因此您可以保留内置沙箱指南,同时添加自己的角色、工作流和成功标准。 -仅当您希望替换 SDK 沙箱基础提示词时,才使用 `base_instructions`。大多数智能体不应设置该选项。 +仅当您希望替换 SDK 沙箱基础提示词时,才使用 `base_instructions`。大多数智能体不应设置它。
| 放置位置 | 用途 | 示例 | | --- | --- | --- | -| `instructions` | 智能体的稳定角色、工作流规则和成功标准。 | “检查入职文档,然后进行任务转移。”、“将最终文件写入 `output/`。” | -| `base_instructions` | 完整替换 SDK 沙箱基础提示词。 | 自定义底层沙箱封装提示词。 | +| `instructions` | 智能体的稳定角色、工作流规则和成功标准。 | “检查入职文档,然后进行任务转移。”“将最终文件写入 `output/`。” | +| `base_instructions` | 完整替换 SDK 沙箱基础提示词。 | 自定义底层沙箱包装器提示词。 | | 用户提示词 | 本次运行的一次性请求。 | “总结此工作区。” | -| 清单中的工作区文件 | 较长的任务规范、仓库本地指令或范围受限的参考材料。 | `repo/task.md`、文档包、示例资料包。 | +| 清单中的工作区文件 | 较长的任务规范、仓库本地指令或范围明确的参考材料。 | `repo/task.md`、文档包、样本材料包。 |
`instructions` 的良好用法包括: -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 在 PTY 状态很重要时,让智能体保持在同一个交互式进程中。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙箱审查智能体在检查后直接回复用户。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写完成的文件实际写入 `output/`。 -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定确切的验证命令,并明确相对于工作区根目录的补丁路径。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 在 PTY 状态很重要时,让智能体始终停留在同一个交互式进程中。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙箱审查智能体在检查后直接回答用户。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写好的文件实际写入 `output/`。 +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定确切的验证命令,并明确补丁路径相对于工作区根目录。 -请避免将用户的一次性任务复制到 `instructions`、嵌入本应放入清单的长篇参考材料、重复内置能力已注入的工具文档,或混入模型在运行时不需要的本地安装说明。 +请避免将用户的一次性任务复制到 `instructions`、嵌入应放入清单的长篇参考材料、重复说明内置能力已经注入的工具文档,或混入模型在运行时不需要的本地安装说明。 -如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。对于底层封装,这已经足够;但大多数面向用户的智能体仍应提供显式的 `instructions`。 +如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。这对于底层包装器已经足够,但大多数面向用户的智能体仍应提供显式的 `instructions`。 ### `capabilities` -能力会将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、附加沙箱专用指令、公开绑定到实时沙箱会话的工具,并调整该智能体的模型行为或输入处理方式。 +能力可将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前塑造工作区、附加沙箱专属指令、公开绑定到实时沙箱会话的工具,以及调整该智能体的模型行为或输入处理。 内置能力包括: @@ -191,57 +191,57 @@ flowchart LR | --- | --- | --- | | `Shell` | 智能体需要 shell 访问。 | 添加 `exec_command`;当沙箱客户端支持 PTY 交互时,还会添加 `write_stdin`。 | | `Filesystem` | 智能体需要编辑文件或检查本地图像。 | 添加 `apply_patch` 和 `view_image`;补丁路径相对于工作区根目录。 | -| `Skills` | 您希望在沙箱中发现并物化技能。 | 优先使用它,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您将技能编入索引并物化到沙箱中。 | +| `Skills` | 您希望在沙箱中发现并具体化技能。 | 应优先使用此能力,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您建立技能索引并将其具体化到沙箱中。 | | `Memory` | 后续运行应读取或生成记忆产物。 | 需要 `Shell`;在运行期间更新记忆产物还需要 `Filesystem`。 | | `Compaction` | 长时间运行的流程需要在压缩项之后裁剪上下文。 | 调整模型采样和输入处理。 | -默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包含 `Filesystem()`、`Shell()` 和 `Compaction()`。如果传入 `capabilities=[...]`,该列表将替换默认列表,因此请包含您仍需要的所有默认能力。 +默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包括 `Filesystem()`、`Shell()` 和 `Compaction()`。如果您传入 `capabilities=[...]`,该列表会替换默认列表,因此请包含仍要使用的所有默认能力。 -对于技能,请根据期望的物化方式选择来源: +对于技能,请根据您希望其具体化的方式选择来源: -- `Skills(lazy_from=LocalDirLazySkillSource(...))` 非常适合作为大型本地技能目录的默认选项,因为模型可以先发现索引,然后仅加载所需内容。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从 SDK 进程运行所在的文件系统读取。请传入原始主机侧技能目录,而不是仅存在于沙箱镜像或工作区内的路径。 -- `Skills(from_=LocalDir(src=...))` 更适合希望预先暂存的小型本地包。 -- 当技能本身应来自某个仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 最为合适。 +- `Skills(lazy_from=LocalDirLazySkillSource(...))` 是较大本地技能目录的良好默认选项,因为模型可以先发现索引,然后只加载所需内容。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从运行 SDK 进程的文件系统中读取。请传入原始宿主机侧技能目录,而不是仅存在于沙箱镜像或工作区内的路径。 +- `Skills(from_=LocalDir(src=...))` 更适合您希望预先暂存的小型本地包。 +- 当技能本身应来自仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 是合适的选择。 -`LocalDir.src` 是 SDK 主机上的源路径。`skills_path` 是沙箱工作区内的相对目标路径,调用 `load_skill` 时,技能会暂存到该路径。 +`LocalDir.src` 是 SDK 宿主机上的源路径。`skills_path` 是沙箱工作区内的相对目标路径;调用 `load_skill` 时,技能会暂存于此。 -如果您的技能已位于类似 `.agents/skills//SKILL.md` 的磁盘路径下,请让 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 将其公开。除非现有工作区约定依赖不同的沙箱内布局,否则请保留默认的 `skills_path=".agents"`。 +如果您的技能已存储在类似 `.agents/skills//SKILL.md` 的磁盘路径中,请将 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 将其公开。除非现有工作区契约依赖不同的沙箱内布局,否则请保留默认的 `skills_path=".agents"`。 -如果内置能力满足需求,请优先使用。只有在需要内置能力未涵盖的沙箱专用工具或指令接口时,才编写自定义能力。 +如果内置能力可以满足需求,请优先使用它们。只有当您需要内置能力未覆盖的沙箱专属工具或指令接口时,才应编写自定义能力。 ## 概念 ### 清单 -[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,并授予对工作区外特定绝对路径的访问权限。 +[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 -清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 逃逸工作区,这可以让工作区约定在本地、Docker 和托管客户端之间保持可移植性。 +清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 跳出工作区,从而使工作区契约可以在本地、Docker 和托管客户端之间移植。 -使用清单条目定义智能体开始工作前所需的材料: +请使用清单条目指定智能体开始工作前所需的材料:
| 清单条目 | 用途 | | --- | --- | | `File`、`Dir` | 小型合成输入、辅助文件或输出目录。 | -| `LocalFile`、`LocalDir` | 应物化到沙箱中的主机文件或目录。 | -| `GitRepo` | 应提取到工作区的仓库。 | +| `LocalFile`、`LocalDir` | 应具体化到沙箱中的宿主机文件或目录。 | +| `GitRepo` | 应提取到工作区中的仓库。 | | `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` 等挂载 | 应显示在沙箱内的外部存储。 |
-`Dir` 根据合成子项在沙箱工作区内创建目录,或将其用作输出位置;它不会从主机文件系统读取内容。如果应将现有主机目录复制到沙箱工作区,请使用 `LocalDir`。 +`Dir` 根据合成子项在沙箱工作区内创建目录,或创建用作输出位置的目录;它不会从宿主机文件系统读取内容。如果需要将现有宿主机目录复制到沙箱工作区,请使用 `LocalDir`。 -默认情况下,`LocalFile.src` 和 `LocalDir.src` 相对于 SDK 进程工作目录进行解析。除非源路径由 `extra_path_grants` 覆盖,否则它必须位于该基础目录下。这样,本地源物化就会与沙箱清单的其余部分保持在同一主机路径信任边界内。 +默认情况下,`LocalFile.src` 和 `LocalDir.src` 相对于 SDK 进程工作目录进行解析。源必须位于该基础目录下,除非它包含在 `extra_path_grants` 中。这样可以让本地源的具体化与沙箱清单的其他部分保持在相同的宿主机路径信任边界内。 挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。有关挂载选项和提供商支持,请参阅[沙箱客户端](clients.md#mounts-and-remote-storage)。 -良好的清单设计通常意味着保持工作区约定精简,将较长的任务步骤放入 `repo/task.md` 等工作区文件,并在指令中使用相对工作区路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 能力的 `apply_patch` 工具编辑文件,请记住补丁路径相对于沙箱工作区根目录,而不是 shell 的 `workdir`。 +良好的清单设计通常意味着保持工作区契约精简,将较长的任务说明放在 `repo/task.md` 等工作区文件中,并在指令中使用相对工作区路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 能力的 `apply_patch` 工具编辑文件,请记住补丁路径相对于沙箱工作区根目录,而不是 shell 的 `workdir`。 -仅当智能体需要工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录外的可信本地源时,才使用 `extra_path_grants`。例如用于临时工具输出的 `/tmp`、用于只读运行时的 `/opt/toolchain`,或应物化到沙箱中的已生成技能目录。授权适用于本地源物化和 SDK 文件 API。当后端可以实施文件系统策略时,它也适用于 shell 执行: +仅当智能体需要工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录之外受信任的本地源时,才使用 `extra_path_grants`。示例包括用于临时工具输出的 `/tmp`、用于只读运行时的 `/opt/toolchain`,或应具体化到沙箱中的已生成技能目录。授权适用于本地源具体化和 SDK 文件 API。当后端能够强制实施文件系统策略时,它也适用于 shell 执行: ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +254,17 @@ manifest = Manifest( ) ``` -如果 Docker 应将不同的绝对主机路径绑定挂载到容器内的绝对 POSIX `path`,请设置 `host_path`。`UnixLocalSandboxClient` 仅支持两个路径相同的纯路径授权,并拒绝 `host_path`。对于沙箱不应修改的主机数据,请使用 `read_only=True`;如果复制即可满足需求,请使用 `LocalFile` 或 `LocalDir`。 +当 Docker 应将不同的宿主机绝对路径绑定挂载到容器内的 POSIX 绝对路径 `path` 时,请设置 `host_path`。`UnixLocalSandboxClient` 仅支持两个路径相同的纯路径授权,并拒绝 `host_path`。对于沙箱不应修改的宿主机数据,请使用 `read_only=True`;如果复制即可满足需求,则使用 `LocalFile` 或 `LocalDir`。 -请将包含 `extra_path_grants` 的清单视为可信配置。除非您的应用已批准相应主机路径,否则不要从模型输出或其他不可信载荷加载授权。 +应将包含 `extra_path_grants` 的清单视为受信任配置。除非应用已经批准这些宿主机路径,否则请勿从模型输出或其他不受信任的载荷中加载授权。 -快照和 `persist_workspace()` 仍只包含工作区根目录。额外授权路径属于运行时访问,而不是持久工作区状态。 +快照和 `persist_workspace()` 仍然只包含工作区根目录。额外授权的路径是运行时访问权限,而不是持久化工作区状态。 ### 权限 -`Permissions` 控制清单条目的文件系统权限。它针对沙箱物化的文件,而不是模型权限、审批策略或 API 凭据。 +`Permissions` 控制清单条目的文件系统权限。它针对沙箱具体化的文件,而不是模型权限、审批策略或 API 凭据。 -默认情况下,清单条目的所有者可读、可写、可执行,组和其他用户可读、可执行。当暂存文件应设为私有、只读或可执行时,请覆盖该默认值: +默认情况下,清单条目对所有者可读、可写、可执行,对组和其他用户可读、可执行。当暂存文件应为私有、只读或可执行文件时,请覆盖此设置: ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +280,9 @@ private_notes = File( ) ``` -`Permissions` 分别存储所有者、组和其他用户的权限位,以及该条目是否为目录。您可以直接构建它,使用 `Permissions.from_str(...)` 从模式字符串解析,或使用 `Permissions.from_mode(...)` 从操作系统模式派生。 +`Permissions` 分别存储所有者、组和其他用户的权限位,以及该条目是否为目录。您可以直接构建它、使用 `Permissions.from_str(...)` 从模式字符串解析,或使用 `Permissions.from_mode(...)` 从操作系统模式派生。 -用户是可以执行工作的沙箱身份。如果希望某个身份存在于沙箱中,请向清单添加 `User`,然后在 shell 命令、文件读取和补丁等面向模型的沙箱工具应以该用户身份运行时设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到实际清单中。 +用户是可以在沙箱中执行工作的身份。如果您希望该身份存在于沙箱中,请向清单添加 `User`;随后,当 shell 命令、文件读取和补丁等面向模型的沙箱工具应以该用户身份运行时,请设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到有效清单。 ```python from agents import Runner @@ -334,13 +334,13 @@ result = await Runner.run( ) ``` -如果还需要文件级共享规则,请将用户与清单组及条目 `group` 元数据结合使用。`run_as` 用户控制由谁执行沙箱原生操作;`Permissions` 控制沙箱物化工作区后,该用户可以读取、写入或执行哪些文件。 +如果还需要文件级共享规则,请将用户与清单组及条目的 `group` 元数据结合使用。`run_as` 用户控制谁执行沙箱原生操作;沙箱具体化工作区后,`Permissions` 控制该用户可以读取、写入或执行哪些文件。 ### SnapshotSpec -`SnapshotSpec` 指定新沙箱会话应从何处恢复已保存的工作区内容,以及将内容持久化回何处。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 +`SnapshotSpec` 指示新沙箱会话应从何处恢复保存的工作区内容,以及将内容持久化回何处。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 -使用 `LocalSnapshotSpec` 创建本地持久快照;当应用提供远程快照客户端时,使用 `RemoteSnapshotSpec`。当本地快照设置不可用时,会使用空操作快照作为后备;当不希望持久化工作区快照时,高级调用方也可以显式使用空操作快照。 +对于本地持久快照,请使用 `LocalSnapshotSpec`;当您的应用提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。本地快照设置不可用时,会使用空操作快照作为回退;不希望持久化工作区快照的高级调用方也可以显式使用它。 ```python from pathlib import Path @@ -357,13 +357,13 @@ run_config = RunConfig( ) ``` -当运行器创建新沙箱会话时,沙箱客户端会为该会话构建快照实例。启动时,如果快照可以恢复,沙箱会先恢复已保存的工作区内容,然后继续运行。清理时,运行器拥有的沙箱会话会归档工作区,并通过快照将其持久化。 +当运行器创建新沙箱会话时,沙箱客户端会为该会话构建快照实例。启动时,如果快照可恢复,沙箱会先恢复保存的工作区内容,然后再继续运行。清理时,由运行器拥有的沙箱会话会归档工作区,并通过快照将其持久化。 -如果省略 `snapshot`,运行时会在可行时尝试使用默认本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 +如果省略 `snapshot`,运行时会在可行时尝试使用默认本地快照位置。如果无法完成设置,则回退为空操作快照。挂载路径和临时路径不会作为持久化工作区内容复制到快照中。 ### 沙箱生命周期 -生命周期有两种模式:**SDK 所有**和**开发者所有**。 +生命周期分为两种模式:**SDK 所有**和**开发者所有**。
@@ -391,7 +391,7 @@ sequenceDiagram
-如果沙箱只需在一次运行期间存在,请使用 SDK 所有的生命周期。传入 `client`,以及可选的 `manifest` 和 `snapshot`,再加上所需的任何客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、结束沙箱会话,并让客户端清理运行器拥有的资源。 +当沙箱只需在一次运行期间存活时,请使用 SDK 所有的生命周期。传入 `client`,以及可选的 `manifest` 和 `snapshot`,再加上所需的任何客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、结束沙箱会话,并让客户端清理由运行器拥有的资源。 ```python result = await Runner.run( @@ -403,7 +403,7 @@ result = await Runner.run( ) ``` -如果希望提前创建沙箱、跨多次运行复用同一个实时沙箱、在运行后检查文件、通过自行创建的沙箱进行流式传输,或精确决定清理时机,请使用开发者所有的生命周期。传入 `session=...` 会指示运行器使用该实时沙箱,但运行器不会替您关闭它。 +当您希望提前创建沙箱、在多次运行中复用同一个实时沙箱、在运行后检查文件、通过自行创建的沙箱进行流式传输,或精确决定清理时机时,请使用开发者所有的生命周期。传入 `session=...` 会指示运行器使用该实时沙箱,但不会代您关闭它。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +414,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -通常应使用上下文管理器:它会在进入时启动沙箱,并在退出时执行会话清理生命周期。如果应用无法使用上下文管理器,请直接调用生命周期方法: +上下文管理器是常用形式:进入时启动沙箱,退出时运行会话清理生命周期。如果您的应用无法使用上下文管理器,请直接调用生命周期方法: ```python sandbox = await client.create( @@ -435,11 +435,11 @@ finally: await sandbox.aclose() ``` -`stop()` 只会持久化由快照支持的工作区内容;它不会关闭沙箱。`aclose()` 是完整的会话清理路径:它运行停止前钩子、调用 `stop()`、关闭沙箱资源,并关闭会话范围内的依赖项。 +`stop()` 只会持久化由快照支持的工作区内容;它不会销毁沙箱。`aclose()` 是完整的会话清理路径:它运行停止前钩子、调用 `stop()`、关闭沙箱资源并关闭会话范围的依赖项。 ## `SandboxRunConfig` 选项 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 包含每次运行的选项,用于决定沙箱会话的来源,以及应如何初始化新会话。 +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 保存每次运行的选项,用于决定沙箱会话的来源,以及应如何初始化新会话。 ### 沙箱来源 @@ -449,18 +449,18 @@ finally: | 选项 | 使用时机 | 说明 | | --- | --- | --- | -| `client` | 您希望运行器为您创建、恢复和清理沙箱会话。 | 除非提供实时沙箱 `session`,否则为必填项。 | -| `session` | 您已自行创建实时沙箱会话。 | 调用方拥有生命周期;运行器复用该实时沙箱会话。 | -| `session_state` | 您拥有序列化的沙箱会话状态,但没有实时沙箱会话对象。 | 需要 `client`;运行器从该显式状态恢复,并拥有恢复后会话的生命周期。 | +| `client` | 您希望运行器代您创建、恢复和清理沙箱会话。 | 除非您提供实时沙箱 `session`,否则此项为必需。 | +| `session` | 您已经自行创建了实时沙箱会话。 | 生命周期由调用方负责;运行器复用该实时沙箱会话。 | +| `session_state` | 您有序列化的沙箱会话状态,但没有实时沙箱会话对象。 | 需要 `client`;运行器从该显式状态恢复,并负责恢复后会话的生命周期。 | 实际使用中,运行器按以下顺序解析沙箱会话: 1. 如果注入 `run_config.sandbox.session`,则直接复用该实时沙箱会话。 -2. 否则,如果运行从 `RunState` 恢复,则恢复其中存储的沙箱会话状态。 -3. 否则,如果传入 `run_config.sandbox.session_state`,运行器会从该显式序列化的沙箱会话状态恢复。 -4. 否则,运行器会创建新的沙箱会话。对于该新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 +2. 否则,如果运行正从 `RunState` 恢复,则恢复其中存储的沙箱会话状态。 +3. 否则,如果传入 `run_config.sandbox.session_state`,运行器会从该显式序列化沙箱会话状态恢复。 +4. 否则,运行器会创建新沙箱会话。对于该新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 ### 新会话输入 @@ -470,29 +470,29 @@ finally: | 选项 | 使用时机 | 说明 | | --- | --- | --- | -| `manifest` | 您希望为新会话提供一次性工作区覆盖。 | 省略时回退到 `agent.default_manifest`。 | -| `snapshot` | 新沙箱会话应从快照初始化。 | 适用于类似恢复的流程或远程快照客户端。 | -| `options` | 沙箱客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时及类似的客户端专用设置。 | +| `manifest` | 您希望一次性覆盖新会话工作区。 | 省略时回退到 `agent.default_manifest`。 | +| `snapshot` | 新沙箱会话应从快照设定初始状态。 | 适用于类似恢复的流程或远程快照客户端。 | +| `options` | 沙箱客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时和类似的客户端专属设置。 | -### 物化控制 +### 具体化控制 -`concurrency_limits` 控制可以并行运行多少项沙箱物化工作。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用该特定限制。 +`concurrency_limits` 控制可并行运行的沙箱具体化工作量。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用对应的特定限制。 -`archive_limits` 控制 SDK 侧针对归档提取的资源检查。将其设置为 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可以传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持不应用 SDK 归档资源限制的默认行为;也可以将单个字段设置为 `None`,仅禁用该项限制。 +`archive_limits` 控制 SDK 侧针对归档提取的资源检查。将其设置为 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持不设 SDK 归档资源限制的默认行为;也可以将单个字段设置为 `None`,仅禁用对应限制。 -需要注意以下几点: +请注意以下几点: - 新会话:`manifest=` 和 `snapshot=` 仅在运行器创建新沙箱会话时适用。 -- 恢复与快照:`session_state=` 会重新连接到先前序列化的沙箱状态,而 `snapshot=` 会使用已保存的工作区内容初始化新的沙箱会话。 -- 客户端专用选项:`options=` 取决于沙箱客户端;Docker 和许多托管客户端都需要该选项。 -- 注入的实时会话:如果传入正在运行的沙箱 `session`,由能力驱动的清单更新可以添加兼容的非挂载条目。它们不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;不能移除现有条目;不能替换条目类型;也不能添加或更改挂载条目。 +- 恢复与快照:`session_state=` 重新连接到先前序列化的沙箱状态,而 `snapshot=` 根据保存的工作区内容为新沙箱会话设定初始状态。 +- 客户端专属选项:`options=` 取决于沙箱客户端;Docker 和许多托管客户端都需要它。 +- 注入的实时会话:如果传入正在运行的沙箱 `session`,由能力驱动的清单更新可以添加兼容的非挂载条目。它们不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;不能删除现有条目;不能替换条目类型;也不能添加或更改挂载条目。 - 运行器 API:`SandboxAgent` 执行仍使用常规 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API。 ## 完整示例:编码任务 -以下编码风格示例是一个很好的默认起点: +以下编码类示例是一个良好的默认起点: ```python import asyncio @@ -571,19 +571,19 @@ if __name__ == "__main__": ) ``` -请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 shell 的小型仓库,因此可以在 Unix 本地运行中以确定性方式验证该示例。实际任务仓库当然可以使用 Python、JavaScript 或任何其他语言。 +请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用基于 shell 的小型仓库,因此可以在 Unix 本地运行中以确定性的方式验证示例。您的实际任务仓库当然可以使用 Python、JavaScript 或其他任何语言。 ## 常见模式 -请从上面的完整示例开始。很多情况下,可以保持同一个 `SandboxAgent` 不变,只更改沙箱客户端、沙箱会话来源或工作区来源。 +请从上面的完整示例开始。在许多情况下,可以保持同一个 `SandboxAgent` 不变,只更改沙箱客户端、沙箱会话来源或工作区来源。 -### 沙箱客户端的切换 +### 沙箱客户端切换 -保持智能体定义不变,只更改运行配置。如果需要容器隔离或镜像一致性,请使用 Docker;如果需要由提供商管理执行,请使用托管提供商。有关示例和提供商选项,请参阅[沙箱客户端](clients.md)。 +保持智能体定义不变,仅更改运行配置。当您需要容器隔离或镜像一致性时使用 Docker;当您需要由提供商管理的执行环境时使用托管提供商。有关代码示例和提供商选项,请参阅[沙箱客户端](clients.md)。 -### 工作区的覆盖 +### 工作区覆盖 -保持智能体定义不变,只替换新会话清单: +保持智能体定义不变,仅替换新会话清单: ```python from agents.run import RunConfig @@ -603,11 +603,11 @@ run_config = RunConfig( ) ``` -当同一智能体角色应针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了相同模式,但使用的是 `default_manifest`,而不是一次性覆盖。 +当同一个智能体角色应针对不同仓库、材料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了相同模式,但使用 `default_manifest`,而不是一次性覆盖项。 -### 沙箱会话的注入 +### 沙箱会话注入 -当需要显式控制生命周期、在运行后检查或复制输出时,请注入实时沙箱会话: +当您需要显式控制生命周期、运行后检查或复制输出时,请注入实时沙箱会话: ```python from agents import Runner @@ -628,11 +628,11 @@ async with sandbox: ) ``` -如果希望在运行后检查工作区,或通过已启动的沙箱会话进行流式传输,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 +当您希望在运行后检查工作区,或通过已启动的沙箱会话进行流式传输时,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 -### 会话状态的恢复 +### 会话状态恢复 -如果已在 `RunState` 外部序列化沙箱状态,可以让运行器从该状态重新连接: +如果您已在 `RunState` 外部序列化沙箱状态,可让运行器从该状态重新连接: ```python from agents.run import RunConfig @@ -649,13 +649,15 @@ run_config = RunConfig( ) ``` -如果沙箱状态位于您自己的存储或作业系统中,并且希望 `Runner` 直接从中恢复,请使用此模式。有关序列化/反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 +当沙箱状态存储在您自己的存储系统或作业系统中,并且希望 `Runner` 直接从中恢复时,请使用此模式。有关序列化和反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 -会话状态序列化会省略原生 `host_path` 值。若要恢复由主机支持的授权,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前可信清单;否则会在沙箱启动前恢复失败。切勿从序列化输入或其他不可信输入派生主机路径。 +会话状态序列化会省略原生 `host_path` 值。要恢复由宿主机支持的授权,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单;否则,恢复会在沙箱启动前失败。切勿从序列化输入或其他不受信任的输入中派生宿主机路径。 -### 快照的使用 +会话状态和 `RunState` 序列化还会移除云挂载凭据、含凭据的辅助配置,以及容器内凭据公开确认。对于支持恢复已挂载会话的后端,当状态中包含经过编辑的挂载权限时,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单。当名为 `"data"` 的挂载条目需要挂载范围确认时,请在恢复前通过 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` 保留复制的清单。对于广泛权限,请使用 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")`;当挂载同时使用这两类权限时,请同时调用这两个方法。请传入需要确认的每一个确切挂载路径。仅当当前受信任清单与持久化状态具有完全相同的不含凭据的挂载拓扑时,Agents SDK 才会恢复凭据。缺失或不匹配的受信任配置会导致恢复在沙箱启动前失败;序列化状态本身绝不会授予权限。`VercelSandboxClient` 无法恢复已挂载会话,因此应改为使用受信任清单启动新沙箱。 -使用已保存的文件和产物初始化新沙箱: +### 快照启动 + +根据保存的文件和产物为新沙箱设定初始状态: ```python from pathlib import Path @@ -672,7 +674,7 @@ run_config = RunConfig( ) ``` -当创建新沙箱会话的运行应从已保存的工作区内容开始,而不仅仅使用 `agent.default_manifest` 时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 +当创建新沙箱会话的运行应从保存的工作区内容开始,而不是仅从 `agent.default_manifest` 开始时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 ### 从 Git 加载技能 @@ -687,11 +689,11 @@ capabilities = Capabilities.default() + [ ] ``` -如果技能包有自己的发布节奏,或应在多个沙箱之间共享,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 +当技能包有自己的发布周期,或应在多个沙箱之间共享时,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 -### 工具形式的公开 +### 工具公开 -工具智能体既可以拥有自己的沙箱边界,也可以复用父运行中的实时沙箱。复用适用于快速的只读探索智能体:它可以检查父运行正在使用的确切工作区,而无需为创建、填充或快照另一个沙箱付出成本。 +工具智能体既可以使用自己的沙箱边界,也可以复用父运行中的实时沙箱。复用适合快速、只读的探索智能体:它可以检查父运行正在使用的确切工作区,而无需承担创建、填充或快照另一个沙箱的成本。 ```python from agents import Runner @@ -773,9 +775,9 @@ async with sandbox: ) ``` -这里,父智能体以 `coordinator` 身份运行,探索工具智能体以 `explorer` 身份在同一个实时沙箱会话内运行。`pricing_packet/` 条目可由 `other` 用户读取,因此探索智能体可以快速检查它们,但没有写入权限位。`work/` 目录仅对协调器的用户/组可用,因此父智能体可以写入最终产物,而探索智能体保持只读。 +此处,父智能体以 `coordinator` 身份运行,探索工具智能体则在同一个实时沙箱会话内以 `explorer` 身份运行。`pricing_packet/` 条目可由 `other` 用户读取,因此探索智能体可以快速检查这些条目,但没有写入权限位。`work/` 目录仅对协调器的用户或组可用,因此父智能体可以写入最终产物,而探索智能体保持只读。 -当工具智能体需要真正的隔离时,请为其提供自己的沙箱 `RunConfig`: +如果工具智能体需要真正的隔离,请为其提供自己的沙箱 `RunConfig`: ```python from docker import from_env as docker_from_env @@ -801,11 +803,11 @@ rollout_agent.as_tool( ) ``` -当工具智能体应自由修改内容、运行不可信命令或使用不同后端/镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +当工具智能体应自由修改内容、运行不受信任的命令或使用不同后端或镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -### 与本地工具和 MCP 的组合 +### 与本地工具和 MCP 组合 -保留沙箱工作区,同时在同一智能体上继续使用常规工具: +保留沙箱工作区,同时在同一智能体上使用常规工具: ```python from agents.sandbox import SandboxAgent @@ -820,42 +822,42 @@ agent = SandboxAgent( ) ``` -如果工作区检查只是智能体工作的一部分,请使用此模式。请参阅 [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)。 +当工作区检查只是智能体工作的一部分时,请使用此模式。请参阅 [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)。 ## 记忆 -如果未来的沙箱智能体运行应从先前运行中学习,请使用 `Memory` 能力。记忆不同于 SDK 的对话式 `Session` 记忆:它会将经验提炼为沙箱工作区内的文件,供后续运行读取。 +当未来的沙箱智能体运行应从先前运行中学习时,请使用 `Memory` 能力。记忆与 SDK 的对话式 `Session` 记忆不同:它会将经验提炼为沙箱工作区中的文件,后续运行可以读取这些文件。 -有关设置、读取/生成行为、多轮对话和布局隔离,请参阅[智能体记忆](memory.md)。 +有关设置、读取和生成行为、多轮对话及布局隔离,请参阅[智能体记忆](memory.md)。 ## 组合模式 -明确单智能体模式后,下一个设计问题就是沙箱边界在更大系统中的位置。 +明确单智能体模式后,下一个设计问题是沙箱边界应位于较大系统中的何处。 -沙箱智能体仍可与 SDK 的其余部分组合: +沙箱智能体仍可与 SDK 的其他部分组合: - [任务转移](../handoffs.md):将文档密集型工作从非沙箱接收智能体转移给沙箱审查智能体。 -- [Agents as tools](../tools.md#agents-as-tools):将多个沙箱智能体公开为工具,通常是在每次 `Agent.as_tool(...)` 调用中传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,以便每个工具拥有自己的沙箱边界。 -- [MCP](../mcp.md) 和常规函数工具:沙箱能力可以与 `mcp_servers` 和普通 Python 工具共存。 -- [运行智能体](../running_agents.md):沙箱运行仍使用常规 `Runner` API。 +- [Agents as tools](../tools.md#agents-as-tools):将多个沙箱智能体公开为工具,通常在每次 `Agent.as_tool(...)` 调用中传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,使每个工具拥有自己的沙箱边界。 +- [MCP](../mcp.md) 和常规函数工具:沙箱能力可与 `mcp_servers` 和普通 Python 工具共存。 +- [智能体运行](../running_agents.md):沙箱运行仍使用常规 `Runner` API。 以下两种模式尤其常见: -- 非沙箱智能体仅针对工作流中需要工作区隔离的部分,将任务转移给沙箱智能体 -- 编排器将多个沙箱智能体公开为工具,通常为每次 `Agent.as_tool(...)` 调用分别提供一个沙箱 `RunConfig`,使每个工具都有自己的隔离工作区 +- 非沙箱智能体仅在工作流中需要工作区隔离的部分将任务转移给沙箱智能体 +- 编排器将多个沙箱智能体公开为工具,通常每次 `Agent.as_tool(...)` 调用都使用独立的沙箱 `RunConfig`,使每个工具获得自己的隔离工作区 ### 轮次与沙箱运行 -分别解释任务转移和智能体工具调用会更清晰。 +分别说明任务转移和智能体工具调用有助于理解两者。 -使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活跃智能体会发生变化,但运行不会变成嵌套运行。如果非沙箱接收智能体将任务转移给沙箱审查智能体,则同一次运行中的下一次模型调用会为沙箱智能体做准备,并由该沙箱智能体执行下一个轮次。换言之,任务转移会改变由哪个智能体负责同一次运行的下一个轮次。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 +使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活跃智能体会发生变化,但运行不会变成嵌套运行。如果非沙箱接收智能体将任务转移给沙箱审查智能体,则同一次运行中的下一次模型调用会针对沙箱智能体进行准备,该沙箱智能体将成为执行下一轮次的智能体。换言之,任务转移会改变同一次运行中由哪个智能体负责下一轮次。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 -使用 `Agent.as_tool(...)` 时,两者的关系有所不同。外层编排器使用一个外层轮次来决定调用工具,而该工具调用会为沙箱智能体启动一个嵌套运行。嵌套运行拥有自己的轮次循环、`max_turns`、审批,通常还有自己的沙箱 `RunConfig`。它可能在一个嵌套轮次中完成,也可能需要多个轮次。从外层编排器的角度看,所有这些工作仍位于一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +使用 `Agent.as_tool(...)` 时,关系则不同。外层编排器使用一个外层轮次来决定调用工具,该工具调用会为沙箱智能体启动嵌套运行。嵌套运行有自己的轮次循环、`max_turns`、审批,并且通常有自己的沙箱 `RunConfig`。它可能在一个嵌套轮次中完成,也可能需要多个轮次。从外层编排器的角度看,所有这些工作仍封装在一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -审批行为也遵循相同的划分: +审批行为也遵循相同的职责划分: -- 使用任务转移时,审批仍位于同一个顶层运行中,因为沙箱智能体此时已成为该运行中的活跃智能体 -- 使用 `Agent.as_tool(...)` 时,沙箱工具智能体内部触发的审批仍会显示在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 +- 使用任务转移时,审批仍位于同一个顶层运行中,因为沙箱智能体现在是该运行中的活跃智能体 +- 使用 `Agent.as_tool(...)` 时,沙箱工具智能体内部发起的审批仍会显示在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 ## 延伸阅读 diff --git a/docs/zh/sessions/index.md b/docs/zh/sessions/index.md index b67c17fbd1..38f1a2a0dc 100644 --- a/docs/zh/sessions/index.md +++ b/docs/zh/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 会话 -Agents SDK提供内置会话记忆,可在多次智能体运行之间自动维护对话历史记录,无需在各轮之间手动处理`.to_input_list()`。 +Agents SDK 提供内置会话记忆功能,可在多次智能体运行之间自动维护对话历史记录,无需在轮次之间手动处理`.to_input_list()`。 -会话存储特定会话的对话历史记录,使智能体无需显式的手动记忆管理即可保持上下文。这对于构建希望智能体记住先前交互的聊天应用或多轮对话尤其有用。 +会话存储特定会话的对话历史记录,使智能体无需显式的手动记忆管理即可保持上下文。这对于构建聊天应用或多轮对话尤其有用,因为在这些场景中,您希望智能体能够记住之前的交互。 -如果希望由SDK为你管理客户端侧记忆,请使用会话。在同一次运行中,会话不能与运行级续接选项`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果你希望改用由OpenAI服务器管理的续接,请选择其中一种机制,而不要在其上叠加会话。 +如果您希望由 SDK 管理客户端侧记忆,请使用会话。在同一次运行中,会话不能与运行级续接选项`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果您希望改用由OpenAI服务器管理的续接机制,请选择其中一种机制,而不要在其上叠加会话。 ## 快速入门 @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 使用同一会话恢复中断的运行 -如果运行因等待审批而暂停,请使用同一会话实例恢复运行(或使用配置了相同会话ID和相同底层存储后端的另一个实例),以便恢复后的轮次继续沿用同一份已存储的对话历史记录。 +如果运行因等待审批而暂停,请使用同一会话实例恢复运行(或使用配置了相同会话 ID 和相同底层存储后端的另一实例),以便恢复后的轮次继续沿用同一份已存储的对话历史记录。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -63,22 +63,22 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## 会话的核心行为 +## 核心会话行为 启用会话记忆后: 1. **每次运行前**:运行器会自动检索该会话的对话历史记录,并将其添加到输入项之前。 -2. **每次运行后**:运行期间生成的所有新项目(用户输入、助手响应、工具调用等)都会自动存储在会话中。 +2. **每次运行后**:运行期间生成的所有新项目(用户输入、助手响应、工具调用等)都会自动存储到会话中。 3. **上下文保留**:使用同一会话的每次后续运行都会包含完整的对话历史记录,使智能体能够保持上下文。 这样便无需手动调用`.to_input_list()`以及在运行之间管理对话状态。 ## 历史记录与新输入的合并控制 -传入会话时,运行器通常按以下顺序准备模型输入: +传入会话时,运行器通常会按以下顺序准备模型输入: -1. 会话历史记录(从`session.get_items(...)`检索) -2. 新一轮输入 +1. 会话历史记录(从`session.get_items(...)`中检索) +2. 新轮次输入 使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可在调用模型之前自定义该合并步骤。回调接收两个列表: @@ -87,7 +87,7 @@ if result.interruptions: 返回应发送给模型的最终输入项列表。 -回调接收的是两个列表的副本,因此你可以安全地修改它们。返回的列表控制该轮次的模型输入,但SDK仍只会持久化属于新轮次的项目。因此,对旧历史记录重新排序或进行筛选不会导致旧会话项目再次作为新输入保存。 +回调接收的是这两个列表的副本,因此您可以安全地修改它们。返回的列表控制该轮次的模型输入,但 SDK 仍只会持久化属于新轮次的项目。因此,对旧历史记录重新排序或进行筛选,不会导致旧会话项目再次作为新输入保存。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -当你需要自定义历史记录的删减、重新排序或选择性包含方式,但不想更改会话存储项目的方式时,请使用此功能。如果需要在模型调用前立即进行后续的最终处理,请使用[智能体运行指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 +当您需要自定义裁剪、重新排序或选择性地纳入历史记录,同时又不改变会话存储项目的方式时,请使用此功能。如果您需要在调用模型前立即执行后续的最终处理,请使用[运行智能体指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 -## 检索历史记录限制 +## 检索历史记录的限制 使用[`SessionSettings`][agents.memory.SessionSettings]控制每次运行前获取的历史记录数量。 - `SessionSettings(limit=None)`(默认):检索所有可用的会话项目 - `SessionSettings(limit=N)`:仅检索最近的`N`个项目 -你可以通过[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]将其应用于每次运行: +您可以通过[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]按每次运行应用此设置: ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,7 +134,7 @@ result = await Runner.run( ) ``` -如果你的会话实现提供默认会话设置,则`RunConfig.session_settings`中每个非`None`值都会覆盖该次运行对应的默认值。对于长对话,如果希望限制检索数量而不改变会话的默认行为,此功能非常有用。 +如果您的会话实现提供默认会话设置,则`RunConfig.session_settings`中每个非`None`值都会覆盖该次运行对应的默认值。这适用于长对话,可在不改变会话默认行为的情况下限制检索数量。 ## 记忆操作 @@ -165,9 +165,9 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 基于pop_item的更正 +### 使用 pop_item 进行更正 -当你希望撤销或修改对话中的最后一个项目时,`pop_item`方法尤其有用: +当您想撤销或修改对话中的最后一个项目时,`pop_item`方法尤其有用: ```python from agents import Agent, Runner, SQLiteSession @@ -198,32 +198,32 @@ print(f"Agent: {result.final_output}") ## 内置会话实现 -SDK针对不同用例提供了多种会话实现: +SDK 针对不同用例提供了多种会话实现: ### 内置会话实现的选择 -在阅读下方的详细示例之前,可使用此表选择起点。 +在阅读下方详细示例之前,可使用此表选择起点。 -| 会话类型 | 适用场景 | 说明 | +| 会话类型 | 最适用场景 | 备注 | | --- | --- | --- | -| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可基于文件或内存 | -| `AsyncSQLiteSession` | 使用`aiosqlite`的异步SQLite | 支持异步驱动程序的扩展后端 | -| `RedisSession` | 跨工作进程或服务共享记忆 | 适合低延迟分布式部署 | -| `SQLAlchemySession` | 使用现有数据库的生产应用 | 适用于SQLAlchemy支持的数据库 | -| `MongoDBSession` | 已使用MongoDB或需要多进程存储的应用 | 异步pymongo;使用原子序列计数器排序 | -| `DaprSession` | 使用Dapr边车的云原生部署 | 支持多种状态存储以及TTL和一致性控制 | -| `OpenAIConversationsSession` | OpenAI中的服务器托管存储 | 由OpenAI Conversations API支持的历史记录 | -| `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 对另一会话后端的封装 | -| `AdvancedSQLiteSession` | SQLite以及分支和分析 | 功能集较为丰富;请参阅专门页面 | -| `EncryptedSession` | 在另一会话上添加加密和TTL | 封装实现;请先选择底层后端 | +| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可使用文件或内存作为后端 | +| `AsyncSQLiteSession` | 搭配`aiosqlite`使用异步 SQLite | 支持异步驱动程序的扩展后端 | +| `RedisSession` | 在工作进程或服务之间共享记忆 | 适合低延迟分布式部署 | +| `SQLAlchemySession` | 使用现有数据库的生产应用 | 适用于 SQLAlchemy 支持的数据库 | +| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;使用原子序列计数器排序 | +| `DaprSession` | 使用 Dapr sidecar 的云原生部署 | 支持多种状态存储以及 TTL 和一致性控制 | +| `OpenAIConversationsSession` | OpenAI中的服务器托管存储 | 由 OpenAI Conversations API 支持的历史记录 | +| `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 对另一会话后端的包装器 | +| `AdvancedSQLiteSession` | SQLite 以及分支和分析功能 | 功能集更丰富;请参阅专门页面 | +| `EncryptedSession` | 在另一会话上添加加密和 TTL | 包装器;请先选择底层后端 | -部分实现有提供更多详细信息的专门页面,这些页面已在相应小节中以内联方式链接。 +部分实现有专门页面提供更多详细信息,其链接位于对应的小节中。 -如果你正在为ChatKit实现Python服务器,请使用`chatkit.store.Store`实现来持久化ChatKit的线程和项目。`SQLAlchemySession`等Agents SDK会话管理SDK侧的对话历史记录,但不能直接替代ChatKit的存储。请参阅[`chatkit-python` ChatKit数据存储实现指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 +如果您正在为 ChatKit 实现 Python 服务器,请使用`chatkit.store.Store`实现来持久化 ChatKit 的线程和项目。`SQLAlchemySession`等 Agents SDK 会话用于管理 SDK 侧的对话历史记录,但不能直接替代 ChatKit 的存储。请参阅[`chatkit-python` ChatKit 数据存储实现指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 -### OpenAI Conversations API会话 +### OpenAI Conversations API 会话 -通过`OpenAIConversationsSession`使用[OpenAI的Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 +通过`OpenAIConversationsSession`使用[OpenAI的 Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -257,9 +257,9 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses压缩会话 +### OpenAI Responses 压缩会话 -使用`OpenAIResponsesCompactionSession`通过Responses API(`responses.compact`)压缩已存储的对话历史记录。它封装了底层会话,并可在每轮结束后根据`should_trigger_compaction`自动进行压缩。不要使用它封装`OpenAIConversationsSession`;这两项功能以不同方式管理历史记录。 +使用`OpenAIResponsesCompactionSession`通过 Responses API(`responses.compact`)压缩已存储的对话历史记录。它包装一个底层会话,并可在每轮结束后根据`should_trigger_compaction`自动执行压缩。不要用它包装`OpenAIConversationsSession`;这两项功能采用不同方式管理历史记录。 #### 典型用法(自动压缩) @@ -278,17 +278,19 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -默认情况下,SDK会在每轮结束后检查待压缩内容是否达到阈值,并仅在达到阈值时进行压缩。 +默认情况下,每轮结束后,SDK 会检查压缩候选内容是否达到阈值,且仅在达到阈值时执行压缩。 -`compaction_mode="previous_response_id"`使用压缩会话保留的Responses API响应ID,并且在该响应链仍可用时效果最佳。`compaction_mode="input"`则根据当前会话项目重新构建压缩请求,适用于响应链不可用或希望以会话内容作为权威数据源的情况。默认的`"auto"`会选择最安全的可用选项。 +`compaction_mode="previous_response_id"`使用压缩会话保留的 Responses API 响应 ID,在该响应链仍然可用时效果最佳。`compaction_mode="input"`则根据当前会话项目重新构建压缩请求;当响应链不可用,或您希望以会话内容作为事实来源时,这种方式很有用。默认的`"auto"`会选择最安全的可用选项。 -如果智能体使用`ModelSettings(store=False)`运行,Responses API不会保留最后一个响应以供后续查询。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不依赖`previous_response_id`。完整示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 +如果您的智能体使用`ModelSettings(store=False)`运行,Responses API 不会保留最后一个响应供后续查询。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不是依赖`previous_response_id`。完整示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 #### 自动压缩对流式传输的阻塞 -压缩会清除并重写会话历史记录,因此SDK会等待压缩完成后才将运行视为已完成。在流式传输模式下,如果压缩任务较重,这意味着在最后一个输出词元产生后,`run.stream_events()`可能仍会保持打开数秒。 +压缩会清除并重写会话历史记录,因此 SDK 会等待压缩完成后才将运行视为完成。在流式传输模式下,如果压缩任务较重,这意味着最后一个输出 token 产生后,`run.stream_events()`可能还会保持打开数秒。 -如果你需要低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲时间)自行调用`run_compaction()`。你可以根据自己的条件决定何时强制压缩。 +`OpenAIResponsesCompactionSession.run_compaction()`在包装器边界将清除并重写操作视为可恢复的替换。如果底层历史记录发生更改后,替换失败或被取消,包装器会尝试恢复之前的历史记录,并等待该恢复尝试完成,然后再将原始异常或取消传递给调用方。如果底层后端在恢复期间也发生故障,之前的历史记录可能无法恢复,SDK 会记录此次恢复失败。包装器会将对`add_items()`、`pop_item()`和`clear_session()`的调用与加锁的替换及恢复阶段串行执行;但远程压缩请求仍在进行时,修改操作可能已经完成,随后又被成功的替换操作覆盖。请在轮次之间且包装器没有并发修改操作时执行手动压缩,并且不要在压缩运行期间直接修改底层会话。 + +如果您需要低延迟流式传输或快速轮次交互,请禁用自动压缩,并在轮次之间(或空闲期间)自行调用`run_compaction()`。您可以根据自己的标准决定何时强制执行压缩。 ```python from agents import Agent, Runner, SQLiteSession @@ -309,9 +311,9 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite会话 +### SQLite 会话 -使用SQLite的默认轻量级会话实现: +使用 SQLite 的默认轻量级会话实现: ```python from agents import SQLiteSession @@ -330,9 +332,9 @@ result = await Runner.run( ) ``` -### 异步SQLite会话 +### 异步 SQLite 会话 -如果希望使用由`aiosqlite`支持的SQLite持久化,请使用`AsyncSQLiteSession`。 +如果您希望 SQLite 持久化由`aiosqlite`提供支持,请使用`AsyncSQLiteSession`。 ```bash pip install aiosqlite @@ -347,9 +349,9 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis会话 +### Redis 会话 -使用`RedisSession`可在多个工作进程或服务之间共享会话记忆。 +使用`RedisSession`在多个工作进程或服务之间共享会话记忆。 ```bash pip install openai-agents[redis] @@ -368,11 +370,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)`会创建并拥有Redis客户端。调用`close()`后,会话进入终止状态,后续会话操作将引发`RuntimeError`;重复或并发调用`close()`是安全的。如果应用已经管理Redis客户端,请直接使用`redis_client=...`构造`RedisSession(...)`。在这种情况下,`close()`不会执行任何操作,调用方仍拥有客户端,并且会话仍可使用。 +`from_url(...)`会创建并拥有 Redis 客户端。执行`close()`后,会话将进入终止状态,后续会话操作会引发`RuntimeError`;重复或并发调用`close()`是安全的。如果您的应用已经管理 Redis 客户端,请通过`redis_client=...`直接构造`RedisSession(...)`。在这种情况下,`close()`不执行任何操作,调用方仍拥有客户端所有权,并且会话可继续使用。 -### SQLAlchemy会话 +### SQLAlchemy 会话 -使用SQLAlchemy支持的任意数据库,为Agents SDK提供可用于生产环境的会话持久化: +使用任何 SQLAlchemy 支持的数据库,实现可用于生产环境的 Agents SDK 会话持久化: ```python from agents.extensions.memory import SQLAlchemySession @@ -390,11 +392,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -详细文档请参阅[SQLAlchemy会话](sqlalchemy_session.md)。 +详细文档请参阅[SQLAlchemy 会话](sqlalchemy_session.md)。 -### Dapr会话 +### Dapr 会话 -如果你已运行Dapr边车,或希望在不更改智能体代码的情况下切换配置的状态存储后端,请使用`DaprSession`。 +如果您已经运行 Dapr sidecar,或希望在不更改智能体代码的情况下切换已配置的状态存储后端,请使用`DaprSession`。 ```bash pip install openai-agents[dapr] @@ -417,17 +419,17 @@ async with DaprSession.from_address( 注意事项: -- `from_address(...)`会为你创建并拥有Dapr客户端。如果应用已经管理客户端,请直接使用`dapr_client=...`构造`DaprSession(...)`。 -- 退出上下文或调用`close()`后,拥有客户端的会话会进入终止状态;后续会话操作将引发`RuntimeError`,而重复或并发调用`close()`是安全的。使用注入的客户端时,`close()`不会执行任何操作,会话仍可使用。 -- 如果底层状态存储支持TTL,请传入`ttl=...`,以自动对会话数据应用TTL过期机制。 -- 如果需要更强的写后读保证,请传入`consistency=DAPR_CONSISTENCY_STRONG`。 -- Dapr Python SDK还会检查HTTP边车端点。在本地开发中,启动Dapr时,除`dapr_address`中使用的gRPC端口外,还应使用`--dapr-http-port 3500`。 -- 包含本地组件和故障排除的完整设置演练,请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 +- `from_address(...)`会为您创建并拥有 Dapr 客户端。如果您的应用已管理客户端,请通过`dapr_client=...`直接构造`DaprSession(...)`。 +- 退出上下文或调用`close()`会使拥有客户端的会话进入终止状态;后续会话操作会引发`RuntimeError`,而重复或并发调用`close()`是安全的。使用注入的客户端时,`close()`不执行任何操作,会话仍可继续使用。 +- 如果底层状态存储支持 TTL,请传入`ttl=...`,以自动对会话数据应用 TTL 过期机制。 +- 需要更强的写后读保证时,请传入`consistency=DAPR_CONSISTENCY_STRONG`。 +- Dapr Python SDK 还会检查 HTTP sidecar 端点。在本地开发中,启动 Dapr 时,除了`dapr_address`中使用的 gRPC 端口外,还应指定`--dapr-http-port 3500`。 +- 有关完整的设置演练(包括本地组件和故障排除),请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 -### MongoDB会话 +### MongoDB 会话 -对于已使用MongoDB或需要可横向扩展的多进程会话存储的应用,请使用`MongoDBSession`。 +对于已经使用 MongoDB,或需要可水平扩展的多进程会话存储的应用,请使用`MongoDBSession`。 ```bash pip install openai-agents[mongodb] @@ -452,14 +454,14 @@ await session.close() 注意事项: -- `from_uri(...)`会创建并拥有`AsyncMongoClient`,并在`session.close()`时将其关闭。拥有客户端的会话在`close()`后会进入终止状态,后续会话操作将引发`RuntimeError`。如果应用已经管理客户端,请直接使用`client=...`构造`MongoDBSession(...)`;在这种情况下,`session.close()`不会执行任何操作,调用方仍负责客户端生命周期,并且会话仍可使用。 -- 将`mongodb+srv://user:password@cluster.example.mongodb.net` URI传递给`from_uri(...)`即可连接到[MongoDB Atlas](https://www.mongodb.com/products/platform),无需进行其他更改。 -- 系统会使用两个集合,二者的名称均可通过`sessions_collection=`(默认为`agent_sessions`)和`messages_collection=`(默认为`agent_messages`)进行配置。首次使用时会自动创建索引。每次非空的`add_items()`调用都会写入一个逻辑批次文档,其单调递增的`seq`会按该批次的最后一个项目对批次进行排序;旧版的逐项目消息文档仍可读取。逻辑批次必须符合MongoDB的单文档大小限制;过大的批次会以原子方式失败,不会存储部分批次。 +- `from_uri(...)`会创建并拥有`AsyncMongoClient`,并在`session.close()`时将其关闭。执行`close()`后,拥有客户端的会话将进入终止状态,后续会话操作会引发`RuntimeError`。如果您的应用已管理客户端,请通过`client=...`直接构造`MongoDBSession(...)`;在这种情况下,`session.close()`不执行任何操作,调用方仍负责管理客户端生命周期,并且会话可继续使用。 +- 将`mongodb+srv://user:password@cluster.example.mongodb.net`URI 传入`from_uri(...)`,无需进行其他更改,即可连接到[MongoDB Atlas](https://www.mongodb.com/products/platform)。 +- 此实现使用两个集合,二者的名称均可配置:`sessions_collection=`(默认值为`agent_sessions`)和`messages_collection=`(默认值为`agent_messages`)。首次使用时会自动创建索引。每次非空的`add_items()`调用都会写入一个逻辑批次文档,其中单调递增的`seq`会根据该批次的最后一个项目对其排序;旧版的逐项目消息文档仍然可读。一个逻辑批次必须在 MongoDB 的单文档大小限制之内;过大的批次会以原子方式失败,不会存储部分批次。 - 在首次运行前,使用`await session.ping()`验证连接。 -### 高级SQLite会话 +### 高级 SQLite 会话 -增强型SQLite会话,支持对话分支、用量分析和结构化查询: +增强型 SQLite 会话,支持对话分支、用量分析和结构化查询: ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -479,11 +481,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -详细文档请参阅[高级SQLite会话](advanced_sqlite_session.md)。 +详细文档请参阅[高级 SQLite 会话](advanced_sqlite_session.md)。 ### 加密会话 -适用于任意会话实现的透明加密封装: +适用于任何会话实现的透明加密包装器: ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -512,11 +514,11 @@ result = await Runner.run(agent, "Hello", session=session) 还有一些其他内置选项。请参阅`examples/memory/`以及`extensions/memory/`下的源代码。 -## 运维模式 +## 操作模式 -### 会话ID命名 +### 会话 ID 命名 -使用有意义的会话ID来帮助组织对话: +使用有意义的会话 ID 来帮助组织对话: - 基于用户:`"user_12345"` - 基于线程:`"thread_abc123"` @@ -524,16 +526,16 @@ result = await Runner.run(agent, "Hello", session=session) ### 记忆持久化 -- 对于临时对话,使用内存SQLite(`SQLiteSession("session_id")`) -- 对于持久对话,使用基于文件的SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) -- 如果需要基于`aiosqlite`的实现,请使用异步SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) -- 对于共享的低延迟会话记忆,使用Redis支持的会话(`RedisSession.from_url("session_id", url="redis://...")`) -- 对于使用SQLAlchemy所支持现有数据库的生产系统,使用由SQLAlchemy提供支持的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) -- 对于已使用MongoDB或需要多进程、可横向扩展会话存储的应用,使用MongoDB会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) -- 对于生产级云原生部署,使用Dapr状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),它提供内置遥测、追踪和数据隔离,并支持30多种数据库后端 -- 如果希望将历史记录存储在OpenAI Conversations API中,请使用由OpenAI托管的存储(`OpenAIConversationsSession()`) -- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)封装任意会话,以提供透明加密和基于TTL的过期机制 -- 对于更高级的用例,可考虑为其他生产系统(例如Django)实现自定义会话后端 +- 对临时对话使用内存 SQLite(`SQLiteSession("session_id")`) +- 对持久化对话使用基于文件的 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) +- 需要基于`aiosqlite`的实现时,使用异步 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) +- 对共享的低延迟会话记忆使用 Redis 后端会话(`RedisSession.from_url("session_id", url="redis://...")`) +- 对使用 SQLAlchemy 所支持现有数据库的生产系统,使用由 SQLAlchemy 驱动的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) +- 对已使用 MongoDB 或需要可水平扩展的多进程会话存储的应用,使用 MongoDB 会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) +- 对生产环境的云原生部署,使用 Dapr 状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),它内置遥测、追踪和数据隔离功能,并支持 30 多种数据库后端 +- 如果您希望将历史记录存储在 OpenAI Conversations API 中,请使用由OpenAI托管的存储(`OpenAIConversationsSession()`) +- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)为任意会话添加透明加密和基于 TTL 的过期机制 +- 对于更高级的用例,可考虑为其他生产系统(例如 Django)实现自定义会话后端 ### 多个会话 @@ -581,7 +583,7 @@ result2 = await Runner.run( ## 完整示例 -以下完整示例展示了会话记忆的实际运作方式: +以下完整示例展示了会话记忆的实际使用方式: ```python import asyncio @@ -645,39 +647,38 @@ if __name__ == "__main__": ## 自定义会话实现 -你可以创建遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话记忆: +您可以创建一个在结构上遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话记忆。您无需继承`SessionABC`;只需定义`session_id`和`session_settings`,并直接实现四个历史记录方法: ```python -from agents.memory.session import SessionABC +from agents import Agent, Runner, SessionSettings from agents.items import TResponseInputItem -from typing import List -class MyCustomSession(SessionABC): + +class MyCustomSession: """Custom session implementation following the Session protocol.""" - def __init__(self, session_id: str): + session_settings: SessionSettings | None = None + + def __init__(self, session_id: str) -> None: self.session_id = session_id - # Your initialization here + self.items: list[TResponseInputItem] = [] - async def get_items(self, limit: int | None = None) -> List[TResponseInputItem]: - """Retrieve conversation history for this session.""" - # Your implementation here - pass + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + if limit is None: + return list(self.items) + if limit <= 0: + return [] + return list(self.items[-limit:]) - async def add_items(self, items: List[TResponseInputItem]) -> None: - """Store new items for this session.""" - # Your implementation here - pass + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.items.extend(items) async def pop_item(self) -> TResponseInputItem | None: - """Remove and return the most recent item from this session.""" - # Your implementation here - pass + return self.items.pop() if self.items else None async def clear_session(self) -> None: - """Clear all items for this session.""" - # Your implementation here - pass + self.items.clear() + # Use your custom session agent = Agent(name="Assistant") @@ -688,28 +689,69 @@ result = await Runner.run( ) ``` +### 自定义会话对运行上下文的访问 + +Agents SDK 可将活动的[`RunContextWrapper`][agents.run_context.RunContextWrapper]传递给自定义会话,以用于租户路由、授权或其他应用特定的存储决策。要让 Agents SDK 传递该包装器,请为全部四个历史记录方法添加一个具有显式名称且与关键字调用兼容的`wrapper`参数: + +```python +from typing import Any + +from agents import RunContextWrapper +from agents.items import TResponseInputItem + + +class ContextAwareSession: + async def get_items( + self, + limit: int | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> list[TResponseInputItem]: ... + + async def add_items( + self, + items: list[TResponseInputItem], + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... + + async def pop_item( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> TResponseInputItem | None: ... + + async def clear_session( + self, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: ... +``` + +仅当`get_items`、`add_items`、`pop_item`和`clear_session`都声明了`wrapper`时,Agents SDK 才会启用此集成。通用的`**kwargs`参数不满足此签名检查。省略`wrapper`的现有会话实现会保持其已发布的调用形式,并且无需更改即可继续工作。 + ## 社区会话实现 -社区已开发其他会话实现: +社区已开发出更多会话实现: | 软件包 | 描述 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 基于Django ORM的会话,适用于Django支持的任意数据库(PostgreSQL、MySQL、SQLite等) | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 适用于任何 Django 所支持数据库(PostgreSQL、MySQL、SQLite 等)的 Django ORM 会话 | -如果你已构建会话实现,欢迎提交文档PR,将其添加到此处! +如果您构建了会话实现,欢迎提交文档 PR 将其添加到此处! -## API参考 +## API 参考 -有关详细的API文档,请参阅: +有关详细的 API 文档,请参阅: - [`Session`][agents.memory.session.Session] - 协议接口 -- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API实现 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API压缩封装 -- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础SQLite实现 -- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于`aiosqlite`的异步SQLite实现 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis支持的会话实现 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 由SQLAlchemy提供支持的实现 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB支持的会话实现 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr状态存储实现 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析的增强型SQLite实现 -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任意会话的加密封装 \ No newline at end of file +- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 实现 +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 压缩包装器 +- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础 SQLite 实现 +- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于`aiosqlite`的异步 SQLite 实现 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 后端会话实现 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 由 SQLAlchemy 驱动的实现 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 后端会话实现 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状态存储实现 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析功能的增强型 SQLite +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任意会话的加密包装器 \ No newline at end of file diff --git a/docs/zh/streaming.md b/docs/zh/streaming.md index a9880ab6d8..8d39844eec 100644 --- a/docs/zh/streaming.md +++ b/docs/zh/streaming.md @@ -6,17 +6,17 @@ search: 流式传输允许你订阅智能体运行过程中的更新。这对于向最终用户展示进度更新和部分响应非常有用。 -要进行流式传输,可以调用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed],它会返回一个 [`RunResultStreaming`][agents.result.RunResultStreaming]。调用 `result.stream_events()` 会得到由 [`StreamEvent`][agents.stream_events.StreamEvent] 对象组成的异步流,下文将对其进行说明。 +要进行流式传输,你可以调用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed],它会返回一个 [`RunResultStreaming`][agents.result.RunResultStreaming]。调用 `result.stream_events()` 可获得由 [`StreamEvent`][agents.stream_events.StreamEvent] 对象组成的异步流,具体说明如下。 -持续消费 `result.stream_events()`,直到异步迭代器结束。只有当迭代器结束时,流式运行才算完成;会话持久化、审批记录或历史压缩等后处理可能会在最后一个可见 token 到达后才完成。循环退出时,`result.is_complete` 会反映最终的运行状态。 +持续使用 `result.stream_events()` 进行消费,直到异步迭代器结束。只有迭代器结束后,流式运行才算完成;会话持久化、审批记录维护或历史压缩等后处理可能会在最后一个可见 token 到达后才完成。当循环退出时,`result.is_complete` 会反映最终的运行状态。 ## 原始响应事件 -[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 对象封装了直接从 LLM 传递的原始事件。每个对象的 `data` 字段都包含一个 OpenAI Responses API 事件,其类型可能是 `response.created` 或 `response.output_text.delta`。如果你希望在响应消息生成后立即将其以流式方式传输给用户,这些事件会很有用。 +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 对象封装了直接从 LLM 传递的原始事件。每个对象的 `data` 字段都包含一个 OpenAI Responses API 事件,其类型可能是 `response.created` 或 `response.output_text.delta`。如果你希望响应消息一经生成就立即以流式方式发送给用户,这些事件会非常有用。 -计算机工具的原始事件与存储结果保持相同的预览版与 GA 版差异。预览版流程会传输包含一个 `action` 的 `computer_call` 项,而 `gpt-5.5` 可以传输包含批量 `actions[]` 的 `computer_call` 项。更高层级的 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 接口不会为此添加仅供计算机工具使用的特殊事件名称:这两种形式仍会以 `tool_called` 的形式呈现,而截图结果会以封装 `computer_call_output` 项的 `tool_output` 形式返回。 +计算机工具的原始事件与已存储结果保持相同的预览版与正式发布版之分。预览版流程会流式传输带有一个 `action` 的 `computer_call` 条目,而 `gpt-5.5` 可以流式传输带有批量 `actions[]` 的 `computer_call` 条目。更高层级的 [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 接口不会为此添加计算机工具专用的特殊事件名称:这两种结构仍然都以 `tool_called` 的形式呈现,而截图结果会以封装 `computer_call_output` 条目的 `tool_output` 形式返回。 -例如,以下代码会逐个 token 输出 LLM 生成的文本。 +例如,以下代码会逐 token 输出 LLM 生成的文本。 ```python import asyncio @@ -41,7 +41,7 @@ if __name__ == "__main__": ## 流式传输与审批 -流式传输兼容因工具审批而暂停的运行。如果某个工具需要审批,`result.stream_events()` 会结束,并且待处理的审批会通过 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 暴露。使用 `result.to_state()` 将结果转换为 [`RunState`][agents.run_state.RunState],批准或拒绝该中断,然后使用 `Runner.run_streamed(...)` 恢复运行。 +流式传输与因工具审批而暂停的运行兼容。如果工具需要审批,`result.stream_events()` 会结束,待处理的审批则会在 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中公开。使用 `result.to_state()` 将结果转换为 [`RunState`][agents.run_state.RunState],批准或拒绝中断,然后使用 `Runner.run_streamed(...)` 恢复运行。 ```python result = Runner.run_streamed(agent, "Delete temporary files if they are no longer needed.") @@ -57,47 +57,49 @@ if result.interruptions: pass ``` -如需查看完整的暂停与恢复演示,请参阅[人在回路指南](human_in_the_loop.md)。 +有关完整的暂停和恢复操作流程,请参阅[人在回路指南](human_in_the_loop.md)。 ## 当前轮次结束后的流式传输取消 -如果需要中途停止流式运行,请调用 [`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。若要让当前轮次在停止前正常完成,请改为调用 `result.cancel(mode="after_turn")`。 +如果需要中途停止流式运行,请调用 [`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。要让当前轮次正常完成后再停止,请改为调用 `result.cancel(mode="after_turn")`。 -只有当 `result.stream_events()` 结束时,流式运行才算完成。在最后一个可见 token 到达后,SDK 可能仍在持久化会话项目、最终确定审批状态或压缩历史记录。 +只有 `result.stream_events()` 结束后,流式运行才算完成。在最后一个可见 token 到达后,SDK 可能仍在持久化会话条目、确定最终审批状态或压缩历史记录。 -如果你要从 [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] 手动继续运行,并且 `cancel(mode="after_turn")` 在工具轮次结束后停止,请使用该规范化输入重新运行 `result.last_agent`,以继续尚未完成的现有用户轮次,而不是立即追加一个新的用户轮次。 -- 如果流式运行因工具审批而停止,请勿将其视为新轮次。先完成流的消费,检查 `result.interruptions`,然后从 `result.to_state()` 恢复运行。 -- 使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 自定义在下一次模型调用之前,如何合并检索到的会话历史记录与新的用户输入。如果你在其中重写新轮次的项目,该轮次将持久化重写后的版本。 +如果你正从 [`result.to_input_list(mode="normalized")`][agents.result.RunResultBase.to_input_list] 手动继续,并且 `cancel(mode="after_turn")` 在某个工具轮次后停止,请使用该规范化输入重新运行 `result.last_agent`,以继续尚未完成的现有用户轮次,而不是立即追加一个新的用户轮次。 -## 运行项目事件与智能体事件 +- 如果在该未完成的运行恢复前收到了新的用户输入,请使用 `result.to_state()` 转换已消费完毕的结果,调用 [`state.add_input(...)`][agents.run_state.RunState.add_input],然后从该状态恢复运行。运行器会在下一次模型调用前立即接纳暂存的输入;请参阅[恢复前添加输入](results.md#add-input-before-resuming)。 +- 如果流式运行因工具审批而停止,请勿将其视为新轮次。应先将流消费完毕,检查 `result.interruptions`,然后改为从 `result.to_state()` 恢复运行。 +- 使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 自定义如何在下一次模型调用前合并检索到的会话历史与新的用户输入。如果你在此处重写新轮次条目,则重写后的版本会作为该轮次的持久化内容。 -[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在某个项目完全生成后通知你。借助这些事件,你可以按“消息已生成”“工具已运行”等粒度推送进度更新,而不必逐个 token 推送。同样,当当前智能体发生变化时(例如任务转移导致的变化),[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会向你提供更新。 +## 运行条目事件与智能体事件 -### 运行项目事件名称 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在条目完全生成后通知你。这样,你便可以按“消息已生成”“工具已运行”等粒度推送进度更新,而不是按每个 token 推送。同样,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会在当前智能体发生变化时向你提供更新(例如,因任务转移而发生变化)。 + +### 运行条目事件名称 `RunItemStreamEvent.name` 使用一组固定的语义事件名称: -- `message_output_created` -- `handoff_requested` -- `handoff_occured` -- `tool_called` -- `tool_search_called` -- `tool_search_output_created` -- `tool_output` -- `reasoning_item_created` -- `mcp_approval_requested` -- `mcp_approval_response` -- `mcp_list_tools` +- `message_output_created` +- `handoff_requested` +- `handoff_occured` +- `tool_called` +- `tool_search_called` +- `tool_search_output_created` +- `tool_output` +- `reasoning_item_created` +- `mcp_approval_requested` +- `mcp_approval_response` +- `mcp_list_tools` -为了向后兼容,`handoff_occured` 被有意拼错。 +为保持向后兼容,`handoff_occured` 特意保留了拼写错误。 任务转移调用只会以 `handoff_requested` 的形式发出,不会同时以 `tool_called` 的形式发出。同一轮次中的普通函数工具调用仍会发出 `tool_called`。 -使用托管工具搜索时,模型发出工具搜索请求时会发出 `tool_search_called`,而 Responses API 返回已加载的子集时会发出 `tool_search_output_created`。 +使用托管工具检索时,当模型发出工具检索请求,会发出 `tool_search_called`;当 Responses API 返回已加载的子集时,会发出 `tool_search_output_created`。 -使用程序化工具调用时,生成的 `program` 和普通的程序所属子工具调用会发出 `tool_called`。子工具输出以及与生成的 `program` 相匹配的 `program_output` 会发出 `tool_output`。程序所属的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 项属于例外:它们会分别以 `mcp_approval_requested` 和 `mcp_list_tools` 的形式发出,并分别封装 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem]。检查原始项目的 `type` 以区分其余项目;程序所属的子调用还会携带一个 `caller`,其类型为 `program`,其调用方 ID 用于标识父程序。 +使用程序化工具调用时,系统会为生成的 `program` 以及由程序拥有的普通子工具调用发出 `tool_called`。系统会为子工具输出以及与生成的 `program` 相匹配的 `program_output` 发出 `tool_output`。由程序拥有的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 条目属于例外:它们会分别以 `mcp_approval_requested` 和 `mcp_list_tools` 的形式发出,并分别封装 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem]。检查原始条目的 `type` 以区分其余条目;由程序拥有的子调用还带有一个类型为 `program` 的 `caller`,其调用方 ID 用于标识父程序。 -例如,以下代码会忽略原始事件,并以流式方式向用户传输更新。 +例如,以下代码会忽略原始事件,并以流式方式向用户发送更新。 ```python import asyncio diff --git a/docs/zh/usage.md b/docs/zh/usage.md index ef88f129c4..4984df52da 100644 --- a/docs/zh/usage.md +++ b/docs/zh/usage.md @@ -4,22 +4,23 @@ search: --- # 用量 -Agents SDK会自动追踪每次运行的 token 用量。你可以从运行上下文中访问这些信息,用于监控成本、强制执行限制或记录分析数据。 +Agents SDK 会自动追踪每次运行的 token 用量。你可以从运行上下文中访问这些信息,并用其监控成本、实施限制或记录分析数据。 ## 追踪内容 -- **requests**:发出的 LLM API 调用次数 +- **requests**:发起的 LLM API 调用次数 - **input_tokens**:发送的输入 token 总数 - **output_tokens**:接收的输出 token 总数 - **total_tokens**:输入 + 输出 - **request_usage_entries**:每个请求的用量明细列表 - **details**: - `input_tokens_details.cached_tokens` + - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 运行中的用量访问 +## 从运行中访问用量 -在`Runner.run(...)`完成后,通过`result.context_wrapper.usage`访问用量。 +执行 `Runner.run(...)` 后,通过 `result.context_wrapper.usage` 访问用量。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -31,20 +32,20 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -用量会汇总运行期间的所有模型调用,包括产生工具调用或任务转移的模型调用。 +用量会汇总运行期间的所有模型调用,包括生成工具调用或任务转移的模型调用。 -### 第三方适配器的用量启用 +### 为第三方适配器启用用量统计 -不同第三方适配器和提供商后端的用量报告方式各不相同。如果你通过第三方适配器访问模型,并且需要准确的`result.context_wrapper.usage`值: +不同第三方适配器和提供商后端的用量报告方式有所不同。如果你通过第三方适配器访问模型,并且需要准确的 `result.context_wrapper.usage` 值: -- 使用`AnyLLMModel`时,如果上游提供商返回用量数据,系统会自动传递该数据。从 Chat Completions后端流式传输响应时,可能需要设置`ModelSettings(include_usage=True)`,才能发出用量数据块。 -- 使用`LitellmModel`时,某些提供商后端默认不报告用量,因此通常需要`ModelSettings(include_usage=True)`。 +- 使用 `AnyLLMModel` 时,如果上游提供商返回用量信息,该信息会自动传递。通过 Chat Completions 后端进行流式响应时,可能需要设置 `ModelSettings(include_usage=True)`,以发送用量数据块。 +- 使用 `LitellmModel` 时,某些提供商后端默认不报告用量,因此通常需要设置 `ModelSettings(include_usage=True)`。 -请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)一节的适配器特定说明,并在你计划部署的确切提供商后端上验证用量报告。 +请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)部分的适配器特定说明,并在计划部署的具体提供商后端上验证用量报告。 -## 逐请求用量追踪 +## 按请求追踪用量 -SDK 会自动在`request_usage_entries`中追踪每个 API 请求的用量,这有助于详细计算成本和监控上下文窗口占用情况。 +SDK 会自动在 `request_usage_entries` 中追踪每个 API 请求的用量,这有助于详细计算成本和监控上下文窗口消耗。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -53,9 +54,32 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 会话中的用量访问 +## 提供商用量有效载荷的保留 -使用`Session`(例如`SQLiteSession`)时,每次调用`Runner.run(...)`都会返回该次特定运行的用量。会话会保留对话历史记录以提供上下文,但每次运行的用量彼此独立。 +Agents SDK 会将提供商用量标准化为 [`Usage`][agents.usage.Usage] 字段,从而在不同模型提供商之间提供一致的总量。当应用必须保留提供商特定的用量字段,或需要区分字段被省略与提供商报告值为零时,请将 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] 设置为 `True`: + +```python +from agents import Agent, ModelSettings, Runner + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(preserve_raw_usage=True), +) +result = await Runner.run(agent, "What's the weather in Tokyo?") + +for response in result.raw_responses: + print(response.raw_usage) +``` + +Agents SDK 会将每个 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 值存储为该模型调用的提供商有效载荷的独立、兼容 JSON 的快照。Agents SDK 不会在整个运行期间汇总 `raw_usage`。当禁用保留功能、提供商未返回用量有效载荷,或上游适配器已经丢弃原始字段存在性信息时,该值会保持为 `None`。 + +`preserve_raw_usage` 仅保留已传递至模型适配器的用量有效载荷;该设置不会向提供商请求用量信息。当流式 Chat Completions 提供商要求显式请求用量信息时,还需设置 `ModelSettings(include_usage=True)`。 + +目前,无论是流式还是非流式运行,`LitellmModel` 都不会填充 `ModelResponse.raw_usage`,因此 `preserve_raw_usage=True` 对该适配器不起作用。使用 `LitellmModel` 时,请继续使用标准化的 [`Usage`][agents.usage.Usage] 字段;如果需要保留提供商特定字段的存在性信息,请选择支持保留原始用量的适配器。 + +## 通过会话访问用量 + +使用 `Session`(例如 `SQLiteSession`)时,每次调用 `Runner.run(...)` 都会返回该次特定运行的用量。会话会保留对话历史以提供上下文,但每次运行的用量彼此独立。 ```python session = SQLiteSession("my_conversation") @@ -67,11 +91,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -请注意,虽然会话会在不同运行之间保留对话上下文,但每次调用`Runner.run()`返回的用量指标仅代表该次执行。在会话中,之前的消息可能会作为输入重新送入每次运行,这会影响后续轮次的输入 token 数量。 +请注意,虽然会话会在不同运行之间保留对话上下文,但每次调用 `Runner.run()` 返回的用量指标仅代表该次执行。在会话中,先前的消息可能会在每次运行时再次作为输入提供,这会影响后续轮次的输入 token 数量。 -## 钩子中的用量信息 +## 在钩子中使用用量 -如果你使用`RunHooks`,传递给每个钩子的`context`对象都包含`usage`。这使你可以在生命周期的关键时刻记录用量。 +如果你使用 `RunHooks`,传递给每个钩子的 `context` 对象都包含 `usage`。借助该对象,你可以在关键生命周期节点记录用量。 ```python class MyHooks(RunHooks): @@ -85,6 +109,6 @@ class MyHooks(RunHooks): 有关详细的 API 文档,请参阅: - [`Usage`][agents.usage.Usage] - 用量追踪数据结构 -- [`RequestUsage`][agents.usage.RequestUsage] - 每个请求的用量详情 +- [`RequestUsage`][agents.usage.RequestUsage] - 按请求统计的用量详情 - [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文中访问用量 - [`RunHooks`][agents.run.RunHooks] - 接入用量追踪生命周期 \ No newline at end of file diff --git a/docs/zh/voice/pipeline.md b/docs/zh/voice/pipeline.md index 868ad94066..0822be2859 100644 --- a/docs/zh/voice/pipeline.md +++ b/docs/zh/voice/pipeline.md @@ -2,9 +2,9 @@ search: exclude: true --- -# 流水线与工作流 +# 管线与工作流 -[`VoicePipeline`][agents.voice.pipeline.VoicePipeline] 是一个类,可让您轻松地将智能体工作流转变为语音应用。您传入要运行的工作流,流水线则负责转录输入音频、检测音频何时结束、在适当的时间调用工作流,以及将工作流输出转换回音频。 +[`VoicePipeline`][agents.voice.pipeline.VoicePipeline] 是一个可轻松将智能体工作流转化为语音应用的类。你只需传入要运行的工作流,管线便会负责转录输入音频、检测音频何时结束、在适当的时机调用工作流,并将工作流输出重新转换为音频。 ```mermaid graph LR @@ -32,31 +32,33 @@ graph LR ``` -## 流水线配置 +## 管线配置 -创建流水线时,您可以设置以下几项: +创建管线时,你可以设置以下几项: 1. [`workflow`][agents.voice.workflow.VoiceWorkflowBase],即每次转录新音频时运行的代码。 -2. 所使用的 [`speech-to-text`][agents.voice.model.STTModel] 和 [`text-to-speech`][agents.voice.model.TTSModel] 模型。 +2. 使用的 [`speech-to-text`][agents.voice.model.STTModel] 和 [`text-to-speech`][agents.voice.model.TTSModel] 模型。 3. [`config`][agents.voice.pipeline_config.VoicePipelineConfig],可用于配置以下内容: - - 模型提供商,可将模型名称映射到模型 + - 模型提供方,可将模型名称映射到模型 - 追踪,包括是否禁用追踪、是否上传音频文件、工作流名称、追踪 ID 等 - - TTS 和 STT 模型的设置,例如提示词、语言和所使用的数据类型。 + - TTS 和 STT 模型的设置,例如提示词、语言和使用的数据类型。 -## 流水线运行 +## 管线运行 -您可以通过 [`run()`][agents.voice.pipeline.VoicePipeline.run] 方法运行流水线。该方法允许您传入以下两种形式的音频输入: +你可以通过 [`run()`][agents.voice.pipeline.VoicePipeline.run] 方法运行管线。该方法允许你传入以下两种形式的音频输入: -1. 当您已有完整的音频输入,只想为其生成结果时,请使用 [`AudioInput`][agents.voice.input.AudioInput]。它适用于无需检测说话者何时说完的情况,例如已有预录音频,或在按键通话应用中,可以明确知道用户何时说完。 -2. 当您可能需要检测用户何时说完时,请使用 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]。它允许您在检测到音频块时将其推送进来,语音流水线会通过名为“活动检测”的过程,在适当的时间自动运行智能体工作流。 +1. 当你拥有完整的音频输入,并且只想为其生成结果时,可使用 [`AudioInput`][agents.voice.input.AudioInput]。这适用于不需要检测说话者何时结束发言的场景;例如,使用预录音频,或在一键通话应用中能够明确判断用户何时结束发言。 +2. 当你可能需要检测用户何时结束发言时,可使用 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]。它允许你在检测到音频分块时将其推送,而语音管线会通过名为“活动检测”的过程,在适当的时机自动运行智能体工作流。 ## 结果 -语音流水线的运行结果是 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]。借助此对象,您可以在事件发生时以流式方式获取事件。它包含以下几种 [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent]: +语音管线运行的结果是 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]。你可以通过此对象在事件发生时对其进行流式传输。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] 有以下几种类型: -1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio],其中包含一个音频块。 -2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle],用于通知您轮次开始或结束等生命周期事件。 -3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError],表示错误事件。 +1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio],其中包含一个音频分块。 +2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle],用于通知轮次开始或结束等生命周期事件。 +3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError],即错误事件。 + +应用程序使用 [`StreamedAudioResult.stream()`][agents.voice.result.StreamedAudioResult.stream] 时,会抛出导致管线终止的错误。如果一次原本正常的运行结束后,语音转文本的转录会话未能关闭,则流会抛出该关闭错误,而不会无限期等待。如果该轮次已经失败,并且关闭转录会话时也发生失败,则流会保留原始轮次错误作为主要错误。 ```python @@ -78,4 +80,4 @@ async for event in result.stream(): ### 中断 -Agents SDK 目前未针对 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 提供任何内置的中断处理机制。相反,检测到的每个轮次都会触发工作流的一次独立运行。如果您希望在应用程序内处理中断,可以监听 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 事件。`turn_started` 表示新轮次已转录完毕并开始处理。`turn_ended` 会在相应轮次的所有音频分发完毕后触发。您可以利用这些事件,在模型开始一个轮次时将说话者的麦克风静音,并在应用程序播放完与该轮次相关的所有音频后取消静音。 \ No newline at end of file +Agents SDK 目前不为 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 提供任何内置的中断处理机制。相反,每个检测到的轮次都会触发工作流的一次独立运行。如果你想在应用程序中处理中断,可以监听 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 事件。`turn_started` 表示新轮次已完成转录,处理即将开始。相应轮次的所有音频分发完毕后,会触发 `turn_ended`。你可以利用这些事件,在模型开始一个轮次时将说话者的麦克风静音,并在应用程序播放完与该轮次相关的所有音频后取消静音。 \ No newline at end of file From 8ecdac5947b0ed9f7c08e2b4d67a038840f5d5e8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 Aug 2026 14:15:30 +0900 Subject: [PATCH 286/473] perf: streamline final review evidence reuse --- .../implementation-final-review/SKILL.md | 28 ++-- .../references/reviewer-brief.md | 12 +- .../scripts/review_protocol.py | 13 +- .../scripts/review_state.py | 137 ++++++++++++++++-- .../scripts/test_review_protocol.py | 14 +- .../scripts/test_review_state.py | 86 +++++++++++ .../scripts/test_skill_contract.py | 107 +++++++++++++- .../skills/implementation-kickoff/SKILL.md | 14 +- .../scripts/test_validate_handoff.py | 107 ++++++++++++++ .../scripts/validate_handoff.py | 74 +++++++++- 10 files changed, 548 insertions(+), 44 deletions(-) create mode 100644 .agents/skills/implementation-kickoff/scripts/test_validate_handoff.py diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index f82ac2c68c..f7736dec21 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -9,10 +9,12 @@ Treat implementation and final review as separate phases. Reconstruct the change ## Non-negotiable guarantees -- Review the exact final task content, including committed, staged, unstaged, and task-owned untracked deliverables. The only exception is the narrowly verified final-gate type-erasure closure in step 20, which preserves clean credit through explicit identity evidence and still requires the complete final verification stack on the resulting fingerprint. +- Review the exact final task content, including committed, staged, unstaged, and task-owned untracked deliverables. The only exceptions are the narrowly verified final-gate type-erasure and base-advance closures in step 20, which preserve clean credit through explicit identity evidence and still require the complete final verification stack on the resulting fingerprint. - Use the merge-base three-dot diff for patch ownership and the latest release tag separately for released compatibility. - Require independent review. A same-context self-review cannot satisfy the clean-review gate. - Freeze task-owned content while reviewers inspect a fingerprint. +- Treat an exact normalized file path in the task and component manifests as authoritative even when ignore rules match that file. An existing exact file takes literal precedence over Git pathspec metacharacters; use explicit `:(glob)` magic when pattern semantics are intended. A directory or glob pathspec never promotes ignored operational files into the review. +- Repeat commit-hook inspection, every safe rewriting step, second-pass idempotence, and generated-provenance validation before every fingerprint freeze, including post-fix and delta-review rounds. Record the exact executable inspection and rewriting commands plus their results in packet preflight evidence; a prose label is not an executable command. - Start independent reviewers without inherited conversation history. Fresh judgment does not require repeatedly replaying the implementer's context. - Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. - Never weaken final repository verification. Component-aware review invalidation reduces repeated review, not required build or test gates. @@ -29,7 +31,7 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor ## Workflow -1. Finish the initial implementation and focused tests. Apply formatting before review when formatting can rewrite the diff. +1. Finish the initial implementation and focused tests. Apply formatting before review when formatting can rewrite the diff. Inspect the actual final commit-hook configuration and run the exact safe, non-committing equivalent of every hook step that can rewrite task-owned content before freezing the first review fingerprint. Run each rewriting step until a second execution is content-idempotent. Normalize generated files before computing embedded hashes or provenance so the hook cannot invalidate them later. Record any hook step that cannot safely run before review; if that step later changes task content, apply the normal invalidation rules without exception. 2. Re-read the original user request and the current implementation scope contract. If no contract exists, record the required behavior, compatibility requirements, intentionally unsupported cases and failure behavior, and supported alternative or `none`. 3. Resolve the intended target and merge base. If a supplied target or base is not an ancestor of `HEAD`, compute their common merge base and treat `merge-base...HEAD` as the task-owned diff. Use the latest release tag separately when released compatibility is the relevant boundary. Include committed, staged, unstaged, and untracked changes that belong to the task. 4. Read the complete task-owned three-dot diff from the resolved merge base. Never treat target-only commits between the merge base and an advanced or divergent target as deletions or regressions introduced by the patch. Check integration with the current target separately when relevant; report an actual conflict or semantic incompatibility, not mere absence of target-side changes. Do not limit review to the latest fix or files named in prior feedback. Record a complexity delta: runtime lines changed, new state fields, new synchronization or ownership mechanisms, affected subsystems, and test permutations. @@ -52,25 +54,33 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor - unsupported neighboring case that should fail earlier; - unnecessary machinery or duplicated source of truth; - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. -10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Exclude operational artifacts such as plans, review ledgers, traces, and temporary reports unless they are deliverables. Keep the manifest stable and update it only when task-owned shipped paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ...`; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. -11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the complete raw diff as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires the complete-diff digest to match its `tracked_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix and canonical root ownership, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. -12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. -13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence unless the change later qualifies for the final-gate type-erasure closure in step 20. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged, except for that narrowly recorded type-erasure closure. Require two concurrent independent delta reviews of every other changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. +10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Plans, review ledgers, packets, traces, temporary reports, and other workflow artifacts are operational-only by default even when repository policy requires creating them; include one only when the original requirement or repository policy explicitly makes that exact path a committed deliverable. Keep operational files outside the shipped manifest and account for them as repository exclusions. Keep the shipped manifest stable and update it only when task-owned deliverable paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. For each component, record an exact semantic dependency-input pathspec set plus the reason each input can affect the component; do not use a coarse directory or prose-only `none` claim when build configuration, generated-surface owners, or shared runtime code are dependencies. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ... --complete-diff-output `; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Always generate the complete-diff artifact through `--complete-diff-output`; a standalone `git diff` omits ordinary untracked deliverables. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. +11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the raw output produced by that command's `--complete-diff-output` as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires `complete_diff_paths` to equal the task workspace exactly, requires the complete-diff digest to match its `complete_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix and canonical root ownership, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. +12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. A reviewer process that fails before producing a protocol-valid output because of startup, service, content-filter, context, or tool infrastructure has produced neither a finding nor clean credit and does not advance `ledger.current_round` or consume another fingerprint round. Replace only that reviewer on the same frozen packet and assignment; a protocol-valid output already accepted from the other reviewer remains usable while the task fingerprint, packet, and assignment are unchanged. The original two-reviewer concurrent dispatch satisfies the round's concurrency requirement; the accepted peer output plus one independently launched replacement output on the identical packet and assignment form the required pair. If an independent replacement remains unavailable, report the gate as unavailable instead of counting an infrastructure failure as review evidence. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. +13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence unless the change later qualifies for one of the narrow final-gate closures in step 20. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged, except for a narrowly recorded step 20 closure. Require two concurrent independent delta reviews of every other changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. 14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, and reopening a closed canonical root without evidence IDs that are new to that root. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. 15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. 16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the canonical root-cause ID closed. Do not reopen it for another local patch without new contract evidence or a newly uncovered inventory ID; reject aliases, renamed IDs, and bare unknown IDs instead of treating them as new roots. If it cannot be closed coherently, escalate instead of consuming more rounds. -17. Increment the fingerprint round and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> review without waiting for another user prompt. +17. Increment the fingerprint round, repeat the full commit-hook parity gate from step 1, and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> hook parity -> review without waiting for another user prompt. 18. Apply the non-convergence guard before another local fix: - If the same root-cause group produces another P0/P1 after a complexity reset, return to the merge base and replace task-owned branch-local machinery with the narrowest coherent implementation. - If runtime diff size, state fields, ownership modes, or test permutations grow materially for two consecutive rounds, do not call that convergence merely because each finding is local. Re-run the baseline-reset gate. - If the same root-cause group produces actionable findings in three finding-bearing rounds, or the narrower reimplementation still produces the same root-cause P0/P1, escalate early rather than consuming the round budget. - If four rounds complete without a shrinking or stable diff and falling finding severity, escalate early. 19. Stop successfully only after the required clean-review condition is met on the exact reviewed content and every required reviewer output has passed the acceptance gate: + - same-round infrastructure replacement: the original two-reviewer dispatch was concurrent, and the accepted pair consists of the unchanged protocol-valid peer output plus one independent replacement output accepted under step 12; - normal-risk change: two independent clean reviews of the same fingerprint, launched concurrently; - elevated-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint with complementary high-risk specialties, launched concurrently. - component-only post-review edit: clean credit for every unchanged component plus two concurrent clean independent delta reviews covering all changed components and their runtime boundary. - verified final-gate type-erasure closure satisfying every condition in step 20: preserve the prior clean set without a new fingerprint round or reviewer dispatch, then run the complete final verification stack on the resulting fingerprint. -20. After the clean-review condition is met, confirm that the diff and component fingerprints remain stable, then check observable host capacity before starting the repository's code-change verification. Use available read-only task or process evidence; treat another repository-wide test, typecheck, build, examples runner, or integration command already active on the same host as concrete contention. When contention is visible, continue useful non-heavy work or an event-driven wait and check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file, and do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. Once capacity is available, run every mandatory command in the repository-required order against the exact clean-reviewed fingerprint, or against the recorded resulting fingerprint of the verified type-erasure closure below. Record combined, component, and repository fingerprints immediately before and after the final stack. Accept final verification only when every command succeeds, execution does not mutate that final content or create an ambiguous repository-state change, and all fingerprints still match. Classify any final-gate edit before invalidating review evidence: + - verified base-advance closure satisfying every condition in step 20: preserve the prior clean set without a new fingerprint round or reviewer dispatch, then run the complete final verification stack on the replayed fingerprint. +20. After the clean-review condition is met, confirm that the diff and component fingerprints remain stable, then check observable host capacity before starting the repository's code-change verification. Use available read-only task or process evidence; treat another repository-wide test, typecheck, build, examples runner, or integration command already active on the same host as concrete contention. When contention is visible, continue useful non-heavy work or an event-driven wait and check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file, and do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. Once capacity is available, run every mandatory command in the repository-required order against the exact clean-reviewed fingerprint, or against the recorded resulting fingerprint of a verified closure below. Record combined, component, and repository fingerprints immediately before and after the final stack. Accept final verification only when every command succeeds, execution does not mutate that final content or create an ambiguous repository-state change, and all fingerprints still match. Classify any final-gate replay or edit before invalidating review evidence: + - Verified base-advance closure: when the intended target advances after clean review, preserve the existing clean set without a new fingerprint round or reviewer dispatch only when every condition below holds. This exception grants no final-verification credit; rerun every mandatory final gate on the replayed fingerprint and regenerate the complete PR handoff. + - The replay or rebase is conflict-free and requires no manual task-content edit. The old and new `review_state.py` artifacts have byte-identical task and component `workspace` arrays, and their `tracked_diff_sha256` values are identical; compare these fields directly because content fingerprints intentionally include the resolved base. + - Before the original review, each component recorded exact semantic dependency-input pathspecs and reasons. The complete upstream delta from old base to new base changes no task-manifest path, dependency-input path, selected architecture reference, generated-surface owner, or applicable build, test, lint, format, hook, lockfile, or package configuration input. + - The original requirement, scope contract, inventory rows, assertions about runtime behavior, selected review dimensions, risk tier, and released compatibility boundary are unchanged. Focused checks affected by integration with the new base pass. + - Record the old and new base, head, combined/component/repository fingerprints, exact upstream changed-path list and diff digest, dependency-input pathspecs, comparison commands, and results in the task-global ledger. This closure consumes no fingerprint round and creates no reviewer packet. + - Any path overlap, changed configuration or dependency input, conflict, manual resolution, changed diff digest, missing prior dependency map, or uncertainty falls through to a fresh review round on the new base. A coarse directory-disjointness claim is insufficient. - Verified type-erasure-only edit: preserve the existing clean set without an independent delta review only when every condition below holds. This exception consumes no fingerprint round and requires no reviewer packet, but it does not grant final-gate credit; restart every mandatory final gate on the resulting fingerprint. - The edit is made only after the final stack reports a formatter, linter, or static-type-checker failure, and the failure does not reveal unresolved runtime or contract uncertainty. - The exact delta is limited to importing `cast` directly from the standard-library `typing` module, wrapping one unchanged private implementation expression as `cast(, )`, and formatter-only whitespace. The imported name is not rebound or used elsewhere. @@ -80,7 +90,7 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. - - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean, delta-review, or verified type-erasure-closure evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. + - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean, delta-review, verified base-advance-closure, or verified type-erasure-closure evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. 21. Stop the autonomous loop when the active cycle reaches its current budget: six fingerprint rounds for the initial implementation cycle or two for a post-completion feedback cycle. This is an absolute cap for the active cycle, not a target, and it does not reset when execution pauses or context changes. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or explicitly authorize another bounded budget. When concrete actionable feedback arrives after a successfully completed and sealed cycle, append the feedback cycle's default two-round budget to the same ledger without another authorization prompt. In every other case, append a user-authorized budget to the same ledger rather than replacing its history. Maintain one compact round ledger throughout all review cycles and persist it as a durable, task-global artifact: diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index ae60e571d7..2a719667c9 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -2,7 +2,7 @@ Use this template to prepare one self-contained, factual snapshot packet per fingerprint round. Fill every field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Fill it once, reuse the shared body byte-for-byte for every reviewer, and vary only the final specialty assignment. Keep this control-plane brief near 12 KB when practical. Store larger evidence in indexed files and reference each file by exact path and SHA-256 digest. Do not omit decision-relevant evidence merely to meet the soft size target. Do not include implementer conclusions, suspected bugs, prior findings, or intended fixes. -The verified final-gate type-erasure closure defined in `SKILL.md` step 20 does not create a fingerprint round, reviewer packet, or reviewer assignment. Record its exact delta, before and after fingerprints, final-gate failure, runtime-identity basis, and focused verification in the task-global ledger and final verification evidence. If any condition for that exception is not mechanically established, prepare the normal delta-review packet instead. +The verified final-gate type-erasure and base-advance closures defined in `SKILL.md` step 20 do not create a fingerprint round, reviewer packet, or reviewer assignment. For a type-erasure closure, record its exact delta, before and after fingerprints, final-gate failure, runtime-identity basis, and focused verification in the task-global ledger and final verification evidence. For a base-advance closure, record the old and new base, head, fingerprints, byte-identical task and component workspace evidence, identical tracked-diff digest, complete upstream changed-path list and diff digest, exact dependency-input pathspecs, and focused integration checks. If every condition for the applicable exception is not mechanically established, prepare the normal delta-review packet instead. ## Shared evidence @@ -19,15 +19,15 @@ The verified final-gate type-erasure closure defined in `SKILL.md` step 20 does - Risk tier and reason: - Task-global ledger path, task identity, current round, and remaining authorized budget: - Canonical root-cause ledger (`ID | open/closed | inventory IDs | contract evidence IDs`): -- Canonical task manifest: +- Canonical task manifest (an exact normalized file entry remains authoritative when ignored; directory and glob entries do not promote ignored files): - Component manifests: -- Semantic component dependency map and invalidation reasons: +- Semantic component dependency map (`component | exact base pathspecs | invalidation reason`): - Combined, component, and repository fingerprints: - Exact fingerprint revalidation command: - Unfiltered repository-status artifact and explicit exclusions outside the task manifest: -- Complete three-dot diff command: +- Complete three-dot diff command using `review_state.py --complete-diff-output` so task-owned untracked files are included: - Indexed evidence manifest (`ID | role | exact path | SHA-256 | purpose`): -- Focused preflight commands and results: +- Focused preflight commands and results, including idempotent commit-hook parity with the exact executable hook-inspection commands plus second-pass results for every content-rewriting step before this fingerprint freeze: - Same-fingerprint verification already credited, or `none`: - Verification receipt path and SHA-256 descriptors for credited checks, or `none`: - Eligible concurrent final-gate commands: `none` (required because broad final gates start only after clean review): @@ -42,7 +42,7 @@ Store the shared packet index as one JSON object and validate it before dispatch The active implementation control plane is trusted to record real reviewer dispatches, waits, outputs, and verification executions. The local helper validates completeness, digests, identity, state transitions, and reuse against those records; it does not provide cryptographic attestation against a malicious control plane that fabricates every input. Platform-issued signed execution provenance is intentionally unsupported here and requires a separate trusted service. -The packet object uses integer `schema_version: 1` and contains these required top-level fields: `packet_overage_reason`, `task`, `scope_contract`, `repository`, `ledger`, `manifests`, `review_state`, `verification`, `architecture_references`, `evidence_artifacts`, `inventory`, `selected_high_risk_dimensions`, and `reviewer_assignments`. Mirror the factual fields above rather than adding conclusions. Encode `verification.preflight_results` as an array of exact `command` and `result` objects; use an empty array when no focused preflight ran. Set `verification.eligible_concurrent_gates` to the exact string `none`, and list the repository-wide lint, typecheck, test, build, examples, and integration gates that remain applicable in `verification.deferred_gates`; packet preflight rejects any attempt to overlap a broad final gate with review. Store exactly one evidence artifact with `role: "review-state"` containing the unmodified `review_state.py` JSON, exactly one with `role: "complete-diff"`, and exactly one with `role: "repository-status"` containing unfiltered porcelain-v1 `-z` status. The `review_state` packet object contains exactly `evidence_id`, which names the review-state artifact, and the exact `revalidation_command`; extra copied fingerprint or state fields are invalid. The repository object names the status artifact with `status_evidence_id` and lists every changed path outside the task manifest in `exclusions` with a concrete reason. Use two reviewer assignments whose combined IDs cover every inventory row and selected high-risk dimension. Every reviewer assignment must include every component boundary and all three control artifacts; supporting evidence may remain specialty-specific. The validator derives fingerprints from the digested review-state artifact, requires repository base and head to match it, requires the task and component manifests to match its pathspecs exactly, requires the complete-diff artifact digest to equal its `tracked_diff_sha256`, requires the status digest to equal its unfiltered status fingerprint, and requires exclusions to account exactly for every unfiltered changed path outside the task workspace. It reports the packet's actual path, byte size, SHA-256 digest, review-state path, fingerprint, components, inventory IDs, and reviewer IDs; copy that output into the dispatch record. If the packet exceeds 12 KiB, replace `packet_overage_reason: "none"` with the decision-relevant reason it could not be split further. +The packet object uses integer `schema_version: 1` and contains these required top-level fields: `packet_overage_reason`, `task`, `scope_contract`, `repository`, `ledger`, `manifests`, `review_state`, `verification`, `architecture_references`, `evidence_artifacts`, `inventory`, `selected_high_risk_dimensions`, and `reviewer_assignments`. Mirror the factual fields above rather than adding conclusions. Encode `verification.preflight_results` as an array of exact `command` and `result` objects; use an empty array when no focused preflight ran. Set `verification.eligible_concurrent_gates` to the exact string `none`, and list the repository-wide lint, typecheck, test, build, examples, and integration gates that remain applicable in `verification.deferred_gates`; packet preflight rejects any attempt to overlap a broad final gate with review. Store exactly one evidence artifact with `role: "review-state"` containing the unmodified `review_state.py` JSON, exactly one with `role: "complete-diff"` generated by the same command's `--complete-diff-output`, and exactly one with `role: "repository-status"` containing unfiltered porcelain-v1 `-z` status. The `review_state` packet object contains exactly `evidence_id`, which names the review-state artifact, and the exact revalidation command; extra copied fingerprint or state fields are invalid. The repository object names the status artifact with `status_evidence_id` and lists every changed path outside the task manifest in `exclusions` with a concrete reason. `manifests.dependency_map` must name each component, list exact base pathspecs for every semantic, generated-surface, hook, and build/test configuration input that can invalidate it, and state why; a prose-only claim that a component has no dependencies cannot support a later base-advance closure. Use two reviewer assignments whose combined IDs cover every inventory row and selected high-risk dimension. Every reviewer assignment must include every component boundary and all three control artifacts; supporting evidence may remain specialty-specific. The validator derives fingerprints from the digested review-state artifact, requires repository base and head to match it, requires the task and component manifests to match its pathspecs exactly, requires `complete_diff_paths` to match the task workspace exactly, requires the complete-diff artifact digest to equal its `complete_diff_sha256`, requires the status digest to equal its unfiltered status fingerprint, and requires exclusions to account exactly for every unfiltered changed path outside the task workspace. It reports the packet's actual path, byte size, SHA-256 digest, review-state path, fingerprint, components, inventory IDs, and reviewer IDs; copy that output into the dispatch record. If the packet exceeds 12 KiB, replace `packet_overage_reason: "none"` with the decision-relevant reason it could not be split further. The ledger contains `task_id`, `authorized_round_budgets`, `current_round`, `remaining_budget`, and `root_causes`. Supply the task ID and absolute task-global ledger path independently on every validator command. For every round after round 1, also supply the immediately preceding round's immutable ledger snapshot and its SHA-256 digest from the control plane; never derive either argument from the packet under validation. The immutable snapshot must be a distinct file, not the mutable current ledger under another argument. The validator requires the packet, current ledger, and prior ledger identity to match those control-plane arguments. It requires `current_round` plus `remaining_budget` to equal the sum of the positive integer budget history, the current budget history to preserve the prior prefix, the current round to equal the prior round for a same-round retry or advance by exactly one, every prior canonical root and its ownership to remain present, and the current ledger file's JSON object to match the packet ledger exactly. Each `ledger.root_causes` entry contains `id`, `status`, `inventory_ids`, and `contract_evidence_ids`. Every root must own at least one inventory ID, and each inventory ID has exactly one canonical root owner. Every contract evidence ID must resolve to an `evidence_artifacts[].id`; the ledger cannot establish evidence authority with an unindexed string. The implementer owns canonical IDs. Reviewers must reuse one supplied ID or propose `NEW:` with evidence or inventory not already owned by any canonical root; reviewers must not mint a renamed bare ID. Only the implementer promotes a proposal into the ledger. diff --git a/.agents/skills/implementation-final-review/scripts/review_protocol.py b/.agents/skills/implementation-final-review/scripts/review_protocol.py index 57b4c5b309..61591fbabb 100644 --- a/.agents/skills/implementation-final-review/scripts/review_protocol.py +++ b/.agents/skills/implementation-final-review/scripts/review_protocol.py @@ -308,7 +308,15 @@ def _review_state( repository = _sha256(state.get("repository_fingerprint"), "review_state.repository_fingerprint") status = _sha256(state.get("status_sha256"), "review_state.status_sha256") tracked_diff = _sha256(state.get("tracked_diff_sha256"), "review_state.tracked_diff_sha256") + complete_diff = _sha256(state.get("complete_diff_sha256"), "review_state.complete_diff_sha256") workspace = _workspace_entries(state.get("workspace"), "review_state.workspace") + complete_diff_paths = _strings( + state.get("complete_diff_paths"), "review_state.complete_diff_paths" + ) + if complete_diff_paths != sorted(workspace): + raise ProtocolError( + "review_state.complete_diff_paths must exactly match the task workspace." + ) actual_combined = _content_fingerprint(base, list(workspace.values())) if combined != actual_combined: raise ProtocolError("review_state.content_fingerprint does not match its workspace.") @@ -361,6 +369,7 @@ def _review_state( head=head, status_sha256=status, tracked_diff_sha256=tracked_diff, + complete_diff_sha256=complete_diff, unfiltered_status_sha256=unfiltered_status, unfiltered_content_fingerprint=unfiltered_content, ) @@ -529,10 +538,10 @@ def validate_packet( if artifact["role"] == "complete-diff" } complete_diff_id = next(iter(complete_diff_ids)) - if artifacts[complete_diff_id]["digest"] != state["tracked_diff_sha256"]: + if artifacts[complete_diff_id]["digest"] != state["complete_diff_sha256"]: raise ProtocolError( f"Complete-diff artifact {complete_diff_id} must match " - "review_state.tracked_diff_sha256." + "review_state.complete_diff_sha256." ) ledger = _object(packet.get("ledger"), "ledger") diff --git a/.agents/skills/implementation-final-review/scripts/review_state.py b/.agents/skills/implementation-final-review/scripts/review_state.py index e54aeafa89..6ae9858897 100644 --- a/.agents/skills/implementation-final-review/scripts/review_state.py +++ b/.agents/skills/implementation-final-review/scripts/review_state.py @@ -9,13 +9,28 @@ import os import re import subprocess -from pathlib import Path +from pathlib import Path, PurePosixPath def _git(repo: Path, *args: str) -> bytes: return subprocess.check_output(("git", "-C", os.fspath(repo), *args), stderr=subprocess.PIPE) +def _git_diff(repo: Path, *args: str) -> bytes: + completed = subprocess.run( + ("git", "-C", os.fspath(repo), *args), + capture_output=True, + ) + if completed.returncode not in {0, 1}: + raise subprocess.CalledProcessError( + completed.returncode, + completed.args, + output=completed.stdout, + stderr=completed.stderr, + ) + return completed.stdout + + def _digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @@ -77,6 +92,7 @@ def _workspace_entry(repo: Path, relative_path: str) -> dict[str, object]: def _workspace_entries( repo: Path, base: str, pathspecs: tuple[str, ...] ) -> list[dict[str, object]]: + git_pathspecs = _git_pathspecs(repo, pathspecs) tracked_paths = _git( repo, "diff", @@ -85,23 +101,95 @@ def _workspace_entries( "-z", base, "--", - *pathspecs, + *git_pathspecs, ) - untracked_paths = _git( + untracked_paths = _untracked_paths(repo, pathspecs) + paths = { + os.fsdecode(raw_path) + for raw_path in (*tracked_paths.split(b"\0"), *untracked_paths) + if raw_path + } + return [_workspace_entry(repo, relative_path) for relative_path in sorted(paths)] + + +def _untracked_paths(repo: Path, pathspecs: tuple[str, ...]) -> tuple[bytes, ...]: + literal_pathspecs = _literal_pathspecs(repo, pathspecs) + raw_paths = _git( repo, "ls-files", "--others", "--exclude-standard", "-z", "--", - *pathspecs, + *_git_pathspecs(repo, pathspecs, literal_pathspecs), ) - paths = { - os.fsdecode(raw_path) - for raw_path in (*tracked_paths.split(b"\0"), *untracked_paths.split(b"\0")) - if raw_path - } - return [_workspace_entry(repo, relative_path) for relative_path in sorted(paths)] + paths = {raw_path for raw_path in raw_paths.split(b"\0") if raw_path} + for pathspec in literal_pathspecs: + raw_path = os.fsencode(pathspec) + tracked_paths = _git(repo, "ls-files", "-z", "--", f":(literal){pathspec}") + if raw_path not in tracked_paths.split(b"\0"): + paths.add(raw_path) + return tuple(sorted(paths)) + + +def _literal_pathspecs(repo: Path, pathspecs: tuple[str, ...]) -> frozenset[str]: + literal_pathspecs: set[str] = set() + for pathspec in pathspecs: + if pathspec.startswith(":("): + continue + relative_path = PurePosixPath(pathspec) + if ( + relative_path.is_absolute() + or pathspec != relative_path.as_posix() + or any(part in {".", ".."} for part in relative_path.parts) + ): + continue + candidate = repo.joinpath(*relative_path.parts) + raw_path = os.fsencode(pathspec) + tracked_paths = _git(repo, "ls-files", "-z", "--", f":(literal){pathspec}") + if candidate.is_file() or candidate.is_symlink() or raw_path in tracked_paths.split(b"\0"): + literal_pathspecs.add(pathspec) + return frozenset(literal_pathspecs) + + +def _git_pathspecs( + repo: Path, + pathspecs: tuple[str, ...], + literal_pathspecs: frozenset[str] | None = None, +) -> tuple[str, ...]: + literal_pathspecs = literal_pathspecs or _literal_pathspecs(repo, pathspecs) + return tuple( + f":(literal){pathspec}" if pathspec in literal_pathspecs else pathspec + for pathspec in pathspecs + ) + + +def _complete_diff(repo: Path, base: str, pathspecs: tuple[str, ...]) -> bytes: + chunks = [ + _git( + repo, + "diff", + "--binary", + "--full-index", + base, + "--", + *_git_pathspecs(repo, pathspecs), + ) + ] + for raw_path in _untracked_paths(repo, pathspecs): + chunks.append( + _git_diff( + repo, + "diff", + "--no-index", + "--binary", + "--full-index", + "--", + "/dev/null", + os.fsdecode(raw_path), + ) + ) + return b"".join(chunks) def _content_fingerprint(base: str, workspace: list[dict[str, object]]) -> str: @@ -120,6 +208,7 @@ def _repository_fingerprint( head: str, status_sha256: str, tracked_diff_sha256: str, + complete_diff_sha256: str, unfiltered_status_sha256: str, unfiltered_content_fingerprint: str, ) -> str: @@ -129,6 +218,7 @@ def _repository_fingerprint( "head": head, "status_sha256": status_sha256, "tracked_diff_sha256": tracked_diff_sha256, + "complete_diff_sha256": complete_diff_sha256, "unfiltered_status_sha256": unfiltered_status_sha256, "unfiltered_content_fingerprint": unfiltered_content_fingerprint, }, @@ -144,6 +234,7 @@ def review_state( base: str, pathspecs: tuple[str, ...] = (), components: dict[str, tuple[str, ...]] | None = None, + complete_diff_output: Path | None = None, ) -> dict[str, object]: repo = repo.resolve() pathspecs = _canonical_pathspecs(pathspecs) @@ -168,8 +259,9 @@ def review_state( "--full-index", resolved_base, "--", - *pathspecs, + *_git_pathspecs(repo, pathspecs), ) + complete_diff = _complete_diff(repo, resolved_base, pathspecs) status = _git( repo, "status", @@ -177,7 +269,7 @@ def review_state( "-z", "--untracked-files=all", "--", - *pathspecs, + *_git_pathspecs(repo, pathspecs), ) workspace = _workspace_entries(repo, resolved_base, pathspecs) unfiltered_status = _git( @@ -188,6 +280,10 @@ def review_state( "--untracked-files=all", ) unfiltered_workspace = _workspace_entries(repo, resolved_base, ()) + unfiltered_by_path = {str(entry["path"]): entry for entry in unfiltered_workspace} + for entry in workspace: + unfiltered_by_path.setdefault(str(entry["path"]), entry) + unfiltered_workspace = [unfiltered_by_path[path] for path in sorted(unfiltered_by_path)] content_fingerprint = _content_fingerprint(resolved_base, workspace) component_states: dict[str, dict[str, object]] = {} @@ -224,12 +320,15 @@ def review_state( "head": head, "status_sha256": _digest(status), "tracked_diff_sha256": _digest(tracked_diff), + "complete_diff_sha256": _digest(complete_diff), } repository_fingerprint = _repository_fingerprint( **repository_state, unfiltered_status_sha256=_digest(unfiltered_status), unfiltered_content_fingerprint=_content_fingerprint(resolved_base, unfiltered_workspace), ) + if complete_diff_output is not None: + complete_diff_output.write_bytes(complete_diff) return { "fingerprint": content_fingerprint, "content_fingerprint": content_fingerprint, @@ -237,6 +336,7 @@ def review_state( "base": resolved_base, "pathspecs": list(pathspecs), "workspace": workspace, + "complete_diff_paths": [str(entry["path"]) for entry in workspace], "components": component_states, "unfiltered": { "status_sha256": _digest(unfiltered_status), @@ -301,6 +401,11 @@ def main() -> None: help="Named component pathspec. Repeat a name to group paths into one fingerprint.", ) parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Repository worktree path.") + parser.add_argument( + "--complete-diff-output", + type=Path, + help="Write the complete binary diff, including task-owned untracked files, to this path.", + ) parser.add_argument("--pretty", action="store_true", help="Pretty-print the JSON output.") args = parser.parse_args() try: @@ -321,7 +426,13 @@ def main() -> None: name: _canonical_pathspecs(tuple(component_pathspecs)) for name, component_pathspecs in component_values.items() } - state = review_state(args.repo, args.base, pathspecs, components) + state = review_state( + args.repo, + args.base, + pathspecs, + components, + complete_diff_output=args.complete_diff_output, + ) except ValueError as error: parser.error(str(error)) except subprocess.CalledProcessError as error: diff --git a/.agents/skills/implementation-final-review/scripts/test_review_protocol.py b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py index 36e4c7f422..4f409cb393 100644 --- a/.agents/skills/implementation-final-review/scripts/test_review_protocol.py +++ b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py @@ -62,6 +62,7 @@ def setUp(self) -> None: head=head, status_sha256=status_sha256, tracked_diff_sha256=tracked_diff_sha256, + complete_diff_sha256=tracked_diff_sha256, unfiltered_status_sha256=status_sha256, unfiltered_content_fingerprint=_content_fingerprint(base, workspace), ) @@ -74,6 +75,8 @@ def setUp(self) -> None: "repository_fingerprint": self.repository, "status_sha256": status_sha256, "tracked_diff_sha256": tracked_diff_sha256, + "complete_diff_sha256": tracked_diff_sha256, + "complete_diff_paths": ["src/example.py"], "pathspecs": ["src/example.py"], "components": { "api-contract": { @@ -597,6 +600,7 @@ def test_unfiltered_changed_paths_require_explicit_exclusions(self) -> None: head=state["head"], status_sha256=state["status_sha256"], tracked_diff_sha256=state["tracked_diff_sha256"], + complete_diff_sha256=state["complete_diff_sha256"], unfiltered_status_sha256=state["unfiltered"]["status_sha256"], unfiltered_content_fingerprint=_content_fingerprint( state["base"], state["unfiltered"]["workspace"] @@ -681,7 +685,15 @@ def test_complete_diff_must_match_review_state(self) -> None: ).hexdigest() self._write_packet(self.packet_path, packet) - with self.assertRaisesRegex(ProtocolError, "must match review_state.tracked_diff_sha256"): + with self.assertRaisesRegex(ProtocolError, "must match review_state.complete_diff_sha256"): + self._validate_packet() + + def test_complete_diff_paths_must_match_task_workspace(self) -> None: + state = copy.deepcopy(self.review_state) + state["complete_diff_paths"] = [] + self._write_review_state(state) + + with self.assertRaisesRegex(ProtocolError, "must exactly match the task workspace"): self._validate_packet() def test_review_state_artifact_is_digest_bound(self) -> None: diff --git a/.agents/skills/implementation-final-review/scripts/test_review_state.py b/.agents/skills/implementation-final-review/scripts/test_review_state.py index 593dc8b67f..048c00868b 100644 --- a/.agents/skills/implementation-final-review/scripts/test_review_state.py +++ b/.agents/skills/implementation-final-review/scripts/test_review_state.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import subprocess import sys @@ -99,6 +100,91 @@ def test_unfiltered_workspace_accounts_for_changes_outside_manifest(self) -> Non ) self.assertRegex(state["unfiltered"]["status_sha256"], r"^[0-9a-f]{64}$") + def test_complete_diff_includes_task_owned_untracked_files(self) -> None: + new_test = self.repo / "tests" / "test_new.py" + new_test.write_text("assert 2 == 2\n") + complete_diff = self.repo / "complete.diff" + + state = review_state( + self.repo, + self.base, + ("tests",), + complete_diff_output=complete_diff, + ) + + diff = complete_diff.read_bytes() + self.assertIn(b"diff --git a/tests/test_new.py b/tests/test_new.py", diff) + self.assertIn(b"+assert 2 == 2", diff) + self.assertEqual(state["complete_diff_sha256"], hashlib.sha256(diff).hexdigest()) + self.assertEqual( + state["complete_diff_paths"], + ["tests/test_new.py"], + ) + self.assertNotEqual(state["complete_diff_sha256"], state["tracked_diff_sha256"]) + + def test_exact_manifest_path_includes_ignored_untracked_file(self) -> None: + ignored = self.repo / "plans" / "private.md" + ignored.write_text("shipped fixture\n") + complete_diff = self.repo / "complete.diff" + + state = review_state( + self.repo, + self.base, + ("plans/private.md",), + {"release-metadata": ("plans/private.md",)}, + complete_diff_output=complete_diff, + ) + + self.assertEqual(state["complete_diff_paths"], ["plans/private.md"]) + self.assertEqual(state["unfiltered"]["workspace"], state["workspace"]) + self.assertEqual( + state["components"]["release-metadata"]["workspace"], + state["workspace"], + ) + self.assertIn(b"+shipped fixture", complete_diff.read_bytes()) + + def test_directory_pathspec_does_not_promote_ignored_operational_files(self) -> None: + (self.repo / "plans" / "private.md").write_text("operational plan\n") + + state = review_state(self.repo, self.base, ("plans",)) + + self.assertEqual(state["workspace"], []) + self.assertEqual(state["complete_diff_paths"], []) + + def test_literal_filename_with_pathspec_metacharacters_is_exact(self) -> None: + (self.repo / "plans" / "[a].md").write_text("literal\n") + (self.repo / "plans" / "a.md").write_text("glob match\n") + + state = review_state(self.repo, self.base, ("plans/[a].md",)) + + self.assertEqual(state["complete_diff_paths"], ["plans/[a].md"]) + + def test_explicit_glob_magic_preserves_pattern_semantics(self) -> None: + (self.repo / "plans" / "[a].md").write_text("literal\n") + (self.repo / "plans" / "a.md").write_text("glob match\n") + + state = review_state(self.repo, self.base, (":(glob)plans/[a].md",)) + + self.assertEqual(state["complete_diff_paths"], ["plans/[a].md", "plans/a.md"]) + + def test_cli_writes_complete_diff_output(self) -> None: + (self.repo / "tests" / "test_new.py").write_text("assert True\n") + complete_diff = self.repo / "complete.diff" + + completed = self._run_cli( + "--pathspec", + "tests", + "--complete-diff-output", + str(complete_diff), + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + state = json.loads(completed.stdout) + self.assertEqual( + state["complete_diff_sha256"], + hashlib.sha256(complete_diff.read_bytes()).hexdigest(), + ) + def test_repository_fingerprint_includes_outside_manifest_state_and_content(self) -> None: (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") before = review_state(self.repo, self.base, ("src",)) diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index d4f877dec3..9ca387cbbd 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -19,6 +19,12 @@ def setUpClass(cls) -> None: cls.code_change_verification = ( cls.skill_root.parent / "code-change-verification" / "SKILL.md" ).read_text() + cls.implementation_kickoff = ( + cls.skill_root.parent / "implementation-kickoff" / "SKILL.md" + ).read_text() + cls.handoff_validator = ( + cls.skill_root.parent / "implementation-kickoff" / "scripts" / "validate_handoff.py" + ).read_text() def test_repo_local_metadata_matches_skill(self) -> None: self.assertEqual(self.skill.splitlines()[1], "name: implementation-final-review") @@ -121,6 +127,100 @@ def test_host_capacity_check_avoids_locks_and_finalize_prompts(self) -> None: self.assertIn("If host telemetry is unavailable", self.skill) self.assertIn("Lack of host telemetry alone is not a blocker", self.repo_instructions) + def test_commit_hook_parity_runs_before_review(self) -> None: + required_skill_text = ( + "Inspect the actual final commit-hook configuration", + "exact safe, non-committing equivalent", + "until a second execution is content-idempotent", + "Normalize generated files before computing embedded hashes or provenance", + "before every fingerprint freeze, including post-fix and delta-review rounds", + "exact executable inspection and rewriting commands plus their results", + ) + required_kickoff_text = ( + "Before freezing the first review fingerprint", + "Repeat rewriting steps until content-idempotent", + "verify generated-file hashes or provenance after normalization", + ) + for text in required_skill_text: + with self.subTest(source="skill", text=text): + self.assertIn(text, self.skill) + for text in required_kickoff_text: + with self.subTest(source="kickoff", text=text): + self.assertIn(text, self.implementation_kickoff) + self.assertIn("idempotent commit-hook parity", self.reviewer_brief) + + def test_reviewer_infrastructure_failure_stays_in_the_same_round(self) -> None: + required_text = ( + "fails before producing a protocol-valid output", + "has produced neither a finding nor clean credit", + "does not advance `ledger.current_round` or consume another fingerprint round", + "Replace only that reviewer on the same frozen packet and assignment", + "The original two-reviewer concurrent dispatch satisfies the round's concurrency " + "requirement", + "the accepted peer output plus one independently launched replacement output", + "report the gate as unavailable instead of counting an infrastructure failure as " + "review evidence", + ) + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + + def test_complete_diff_includes_untracked_task_deliverables(self) -> None: + required_text = ( + "--complete-diff-output ", + "a standalone `git diff` omits ordinary untracked deliverables", + "`complete_diff_paths` to equal the task workspace exactly", + "`complete_diff_sha256`", + ) + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + self.assertIn("task-owned untracked files are included", self.reviewer_brief) + self.assertIn( + "ordinary task-owned untracked files are present", self.implementation_kickoff + ) + self.assertIn("authoritative even when ignore rules match that file", self.skill) + self.assertIn("literal precedence over Git pathspec metacharacters", self.skill) + self.assertIn("use explicit `:(glob)` magic", self.skill) + self.assertIn( + "directory or glob pathspec never promotes ignored operational files", self.skill + ) + + def test_verified_base_advance_closure_is_strict_and_keeps_final_verification(self) -> None: + required_text = ( + "Verified base-advance closure", + "byte-identical task and component `workspace` arrays", + "their `tracked_diff_sha256` values are identical", + "complete upstream delta from old base to new base", + "dependency-input path", + "applicable build, test, lint, format, hook, lockfile, or package configuration input", + "This closure consumes no fingerprint round and creates no reviewer packet", + "falls through to a fresh review round on the new base", + "rerun every mandatory final gate on the replayed fingerprint", + ) + for text in required_text: + with self.subTest(text=text): + self.assertIn(text, self.skill) + self.assertIn("verified base-advance closure", self.implementation_kickoff) + self.assertIn("rerun every mandatory final verification gate", self.implementation_kickoff) + self.assertIn("exact base pathspecs", self.reviewer_brief) + self.assertIn("prose-only claim", self.reviewer_brief) + + def test_operational_artifacts_are_excluded_from_the_handoff_manifest(self) -> None: + required_kickoff_text = ( + "operational-only by default", + "Compare the staged changed-path set byte-for-byte with the canonical shipped manifest", + "do not stage an ignored ExecPlan or review artifact", + "--shipped-path-manifest ", + ) + for text in required_kickoff_text: + with self.subTest(text=text): + self.assertIn(text, self.implementation_kickoff) + self.assertIn('"--shipped-path-manifest"', self.handoff_validator) + self.assertIn( + "Committed paths do not match the shipped-path manifest", self.handoff_validator + ) + def test_work_status_reporting_distinguishes_running_and_final_states(self) -> None: required_text = ( "Use `RUNNING` only in commentary", @@ -267,7 +367,8 @@ def test_snapshot_packet_and_structured_output_bound_repeated_work(self) -> None ) required_brief_text = ( "Indexed evidence manifest (`ID | role | exact path | SHA-256 | purpose`)", - "Semantic component dependency map and invalidation reasons", + "Semantic component dependency map (`component | exact base pathspecs | " + "invalidation reason`)", '"checked_inventory_ids"', '"unchecked_inventory_ids"', '"sibling_scenario_scan"', @@ -333,7 +434,7 @@ def test_machine_readable_protocol_closes_observed_convergence_gaps(self) -> Non "review_state.evidence_id", "repository.status_evidence_id", "Assign every component and all three control artifacts to both reviewers", - "requires the complete-diff digest to match its `tracked_diff_sha256`", + "requires the complete-diff digest to match its `complete_diff_sha256`", "requires `repository.exclusions` to account exactly", "summary-only inventory row is incomplete", "active control plane outside the packet", @@ -371,7 +472,7 @@ def test_machine_readable_protocol_closes_observed_convergence_gaps(self) -> Non 'role: "repository-status"', "The `review_state` packet object contains exactly `evidence_id`", "extra copied fingerprint or state fields are invalid", - "requires the complete-diff artifact digest to equal its `tracked_diff_sha256`", + "requires the complete-diff artifact digest to equal its `complete_diff_sha256`", "Supply the task ID and absolute task-global ledger path independently", "requires `current_round` plus `remaining_budget` to equal the sum", "immediately preceding round's immutable ledger snapshot and its SHA-256 digest", diff --git a/.agents/skills/implementation-kickoff/SKILL.md b/.agents/skills/implementation-kickoff/SKILL.md index 0ce93b345a..fc8a7e7e23 100644 --- a/.agents/skills/implementation-kickoff/SKILL.md +++ b/.agents/skills/implementation-kickoff/SKILL.md @@ -16,7 +16,7 @@ Use this skill as the explicit transition from an agreed implementation scope to ## 1. Establish the task boundary -Record the original requirement, success criteria, intended target (`origin/main` unless the user states otherwise), task-owned paths, compatibility boundary, intentionally unsupported cases, and required repository skills. For a multi-step task, create and maintain the repository's required ExecPlan, but keep operational artifacts out of the shipped-path manifest unless they are intended deliverables. +Record the original requirement, success criteria, intended target (`origin/main` unless the user states otherwise), task-owned paths, compatibility boundary, intentionally unsupported cases, and required repository skills. For a multi-step task, create and maintain the repository's required ExecPlan. An ExecPlan, review packet, ledger, trace, or temporary report is operational-only by default even when repository policy requires creating it; do not add it to the shipped-path manifest unless the original requirement or repository policy explicitly makes that exact path a committed deliverable. If the current directory is a worktree previously created for this same task in the current conversation, resume it. Otherwise, continue from the user's current checkout only long enough to create a new worktree. @@ -33,7 +33,7 @@ Do not create the final branch yet. A detached worktree makes the eventual `$pr- ## 3. Implement without task commits -Keep the task diff uncommitted through implementation, focused tests, formatting, and review fixes. Track new files explicitly because ordinary diff statistics omit untracked files. Use the applicable repository skills and references, including `$implementation-strategy` before user-facing or runtime changes. +Keep the task diff uncommitted through implementation, focused tests, formatting, and review fixes. Track new files explicitly because ordinary diff statistics omit untracked files. Maintain one canonical shipped-path manifest separately from operational artifacts and require a concrete deliverable reason for every path in it. Use the applicable repository skills and references, including `$implementation-strategy` before user-facing or runtime changes. Do not create checkpoint commits. If an external interruption requires extra protection, leave the dedicated worktree intact or use a clearly named temporary stash; restore the changes before continuing and do not treat the stash as a deliverable. @@ -62,7 +62,7 @@ Record this observed `origin/main` commit as the final-base candidate. Do not ca ## 5. Complete final review and verification -Run the repository's applicable completion gates against the complete task-owned diff on the final-base candidate. For runtime code, tests, examples, build or test behavior, or behavior-impacting docs, run `$implementation-final-review` and the required `$code-change-verification` sequence in their mandated order. Honor their fingerprint and invalidation rules. +Run the repository's applicable completion gates against the complete task-owned diff on the final-base candidate. Before freezing the first review fingerprint, inspect the actual final commit-hook configuration and run the exact safe, non-committing equivalent of every hook step that can rewrite a shipped path. Repeat rewriting steps until content-idempotent, and verify generated-file hashes or provenance after normalization. Generate final-review evidence with `review_state.py --complete-diff-output ` so ordinary task-owned untracked files are present in the reviewed diff without staging them. For runtime code, tests, examples, build or test behavior, or behavior-impacting docs, run `$implementation-final-review` and the required `$code-change-verification` sequence in their mandated order. Honor their fingerprint and invalidation rules. Skip those skills only when their own repository rules say the task is ineligible, such as a repo-meta-only change. Do not weaken an eligible gate merely because the diff is small. @@ -80,11 +80,11 @@ If the diff, scope, base, behavior claim, issue relationship, or provenance chan ## 7. Recheck main and create one commit -Fetch `origin main` once more immediately before creating the branch. If it differs from the final-base candidate, return to section 4 and repeat replay, affected checks, final review, verification, and PR handoff. Once stable: +Fetch `origin main` once more immediately before creating the branch. If it differs from the final-base candidate, return to section 4 and replay onto the new base. Then apply `$implementation-final-review` step 20: preserve clean-review credit only when the verified base-advance closure proves an identical task diff and component workspace plus a complete, non-overlapping upstream dependency/tooling audit. Even when that closure applies, rerun every mandatory final verification gate on the new base and regenerate the PR handoff. If any closure condition is missing or ambiguous, repeat affected checks, fresh independent review, verification, and PR handoff. Once stable: 1. Check whether the suggested branch exists locally, remotely, or in another worktree. Ask `$pr-draft-summary` for the next available numeric suffix and regenerate the handoff before creating a colliding branch. 2. Create the exact suggested branch in the task worktree. -3. Stage only the task-owned shipped-path manifest, including intended new files. Inspect the staged diff before committing. +3. Stage only the task-owned shipped-path manifest, including intended new files. Compare the staged changed-path set byte-for-byte with the canonical shipped manifest before committing; any missing or unexpected path is a hard stop. In particular, do not stage an ignored ExecPlan or review artifact merely because it was required during implementation. 4. Use the PR draft title as the commit subject. 5. For a takeover, add the verified original PR author as `Co-authored-by: Name `, retain distinct valid co-author trailers from the imported commits, and deduplicate identities. 6. Create exactly one commit. Let repository hooks run normally. @@ -93,7 +93,7 @@ Branch creation and committing identical content are repository bookkeeping and ## 8. Validate and hand off -Run `python .agents/skills/implementation-kickoff/scripts/validate_handoff.py --repo --base --expected-branch `. For a takeover, also pass `--required-trailer-email ` for each identity that must be credited. +Run `python .agents/skills/implementation-kickoff/scripts/validate_handoff.py --repo --base --expected-branch --shipped-path-manifest `. For a takeover, also pass `--required-trailer-email ` for each identity that must be credited. The manifest contains one exact repository-relative shipped path per line and excludes operational artifacts. Independently confirm that the committed diff has the reviewed content fingerprint when final review supplied one. The validator checks Git topology and repository cleanliness; it does not replace semantic review or fingerprint verification. @@ -105,5 +105,5 @@ Leave the worktree in place. Report the worktree path, final observed base commi - Worktree or branch collision: preserve the existing target and choose a new unused path or regenerated branch suggestion. - Replay conflict: retain recoverable task changes and ask for direction when the correct resolution is ambiguous. - Review or verification failure: leave the detached task worktree for continuation; do not package a commit as ready. -- Commit-hook mutation: invalidate affected evidence and repeat the required gates. +- Commit-hook mutation: invalidate affected evidence, fix the missing hook-parity preflight or generated-file normalization, and repeat the required gates. - Non-clean or multi-commit final state: do not hand off as complete until corrected without discarding user-owned work. diff --git a/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py b/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py new file mode 100644 index 0000000000..4431f0eeb6 --- /dev/null +++ b/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import subprocess +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path + +from validate_handoff import load_shipped_paths, validate + + +class ValidateHandoffTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.root = Path(self.temporary_directory.name) + self.repo = self.root / "repo" + self.repo.mkdir() + self._git("init", "-q", "-b", "main") + self._git("config", "user.name", "Test User") + self._git("config", "user.email", "test@example.com") + (self.repo / "README.md").write_text("base\n") + self._git("add", "README.md") + self._git("commit", "-qm", "base") + self.base = self._git("rev-parse", "HEAD").stdout.strip() + self._git("checkout", "-qb", "feat/review-workflow") + + def _git(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ("git", *args), + cwd=self.repo, + check=True, + capture_output=True, + text=True, + ) + + def _commit(self, paths: dict[str, str]) -> None: + for relative_path, content in paths.items(): + path = self.repo / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + self._git("add", *paths) + self._git("commit", "-qm", "change workflow") + + def _args(self, manifest: Path) -> Namespace: + return Namespace( + repo=self.repo, + base=self.base, + expected_branch="feat/review-workflow", + required_trailer_email=[], + shipped_path_manifest=manifest, + ) + + def test_exact_shipped_manifest_is_valid(self) -> None: + self._commit({"src/change.py": "value = 1\n"}) + manifest = self.root / "shipped.paths" + manifest.write_text("src/change.py\n") + + report, failures = validate(self._args(manifest)) + + self.assertEqual(failures, []) + self.assertTrue(report["valid"]) + self.assertEqual(report["shipped_paths"], ["src/change.py"]) + + def test_operational_file_not_in_manifest_fails(self) -> None: + self._commit( + { + "src/change.py": "value = 1\n", + "plans/task.md": "operational plan\n", + } + ) + manifest = self.root / "shipped.paths" + manifest.write_text("src/change.py\n") + + report, failures = validate(self._args(manifest)) + + self.assertFalse(report["valid"]) + self.assertIn("unexpected=['plans/task.md']", failures[0]) + + def test_explicit_ignored_deliverable_is_valid_after_force_staging(self) -> None: + (self.repo / ".git" / "info" / "exclude").write_text("fixture.generated\n") + fixture = self.repo / "fixture.generated" + fixture.write_text("shipped fixture\n") + self._git("add", "-f", "fixture.generated") + self._git("commit", "-qm", "add ignored fixture") + manifest = self.root / "shipped.paths" + manifest.write_text("fixture.generated\n") + + report, failures = validate(self._args(manifest)) + + self.assertEqual(failures, []) + self.assertTrue(report["valid"]) + self.assertEqual(report["shipped_paths"], ["fixture.generated"]) + + def test_manifest_paths_must_be_normalized_and_unique(self) -> None: + manifest = self.root / "shipped.paths" + manifest.write_text("src/change.py\nsrc/change.py\n") + with self.assertRaisesRegex(ValueError, "Duplicate shipped-path manifest entry"): + load_shipped_paths(manifest) + + manifest.write_text("../outside.txt\n") + with self.assertRaisesRegex(ValueError, "normalized repository-relative paths"): + load_shipped_paths(manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/implementation-kickoff/scripts/validate_handoff.py b/.agents/skills/implementation-kickoff/scripts/validate_handoff.py index a19b4bed15..accc867b45 100755 --- a/.agents/skills/implementation-kickoff/scripts/validate_handoff.py +++ b/.agents/skills/implementation-kickoff/scripts/validate_handoff.py @@ -8,7 +8,7 @@ import re import subprocess import sys -from pathlib import Path +from pathlib import Path, PurePosixPath class GitCommandError(RuntimeError): @@ -51,10 +51,39 @@ def parse_args() -> argparse.Namespace: default=[], help="Email that must appear in a Co-authored-by trailer. Repeat as needed.", ) + parser.add_argument( + "--shipped-path-manifest", + type=Path, + help=( + "File containing the exact repository-relative paths expected in the handoff commit, " + "one per line." + ), + ) parser.add_argument("--json", action="store_true", help="Emit the result as JSON.") return parser.parse_args() +def load_shipped_paths(path: Path) -> set[str]: + lines = path.read_text().splitlines() + if not lines: + raise ValueError(f"Shipped-path manifest is empty: {path}") + + shipped_paths: set[str] = set() + for line_number, raw_path in enumerate(lines, start=1): + if not raw_path: + raise ValueError(f"Shipped-path manifest contains a blank line at {line_number}.") + path_value = PurePosixPath(raw_path) + if path_value.is_absolute() or ".." in path_value.parts or str(path_value) != raw_path: + raise ValueError( + "Shipped-path manifest entries must be normalized repository-relative paths: " + f"{raw_path!r}." + ) + if raw_path in shipped_paths: + raise ValueError(f"Duplicate shipped-path manifest entry: {raw_path}") + shipped_paths.add(raw_path) + return shipped_paths + + def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]: repo = args.repo.expanduser().resolve() failures: list[str] = [] @@ -91,6 +120,33 @@ def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]: if ahead != 1: failures.append(f"HEAD must be exactly one commit ahead of base, found {ahead} commits.") + shipped_manifest: str | None = None + shipped_paths: list[str] | None = None + if args.shipped_path_manifest is not None: + manifest_path = args.shipped_path_manifest.expanduser().resolve() + expected_paths = load_shipped_paths(manifest_path) + actual_paths = { + path + for path in run_git( + repo, + "diff", + "--name-only", + "--no-renames", + "-z", + f"{base}..{head}", + ).stdout.split("\0") + if path + } + missing_paths = sorted(expected_paths - actual_paths) + unexpected_paths = sorted(actual_paths - expected_paths) + if missing_paths or unexpected_paths: + failures.append( + "Committed paths do not match the shipped-path manifest: " + f"missing={missing_paths}, unexpected={unexpected_paths}." + ) + shipped_manifest = str(manifest_path) + shipped_paths = sorted(expected_paths) + subject = run_git(repo, "show", "-s", "--format=%s", "HEAD").stdout.strip() if not subject: failures.append("HEAD commit subject is empty.") @@ -115,6 +171,8 @@ def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]: "ahead": ahead, "clean": not status, "coauthor_trailer_emails": sorted(trailer_emails), + "shipped_path_manifest": shipped_manifest, + "shipped_paths": shipped_paths, "valid": not failures, } return report, failures @@ -124,7 +182,7 @@ def main() -> int: args = parse_args() try: report, failures = validate(args) - except (GitCommandError, ValueError) as exc: + except (GitCommandError, OSError, UnicodeError, ValueError) as exc: report = {"repo": str(args.repo.expanduser().resolve()), "valid": False} failures = [str(exc)] @@ -133,7 +191,17 @@ def main() -> int: else: status = "valid" if not failures else "invalid" print(f"Implementation handoff: {status}") - for key in ("repo", "base", "head", "branch", "subject", "ahead", "clean"): + for key in ( + "repo", + "base", + "head", + "branch", + "subject", + "ahead", + "clean", + "shipped_path_manifest", + "shipped_paths", + ): if key in report: print(f"{key}: {report[key]}") for failure in failures: From 863b96cfe99b5388910ff5b8cd85329003330132 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Tue, 11 Aug 2026 14:24:08 +0800 Subject: [PATCH 287/473] fix(voice): honor WAV sample width (#4361) --- src/agents/voice/input.py | 37 ++++++++++++++++++++++++++++---- tests/voice/test_input.py | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/agents/voice/input.py b/src/agents/voice/input.py index c39172c79a..a88722a1b3 100644 --- a/src/agents/voice/input.py +++ b/src/agents/voice/input.py @@ -19,19 +19,48 @@ def _buffer_to_audio_file( sample_width: int = 2, channels: int = 1, ) -> tuple[str, io.BytesIO, str]: + if sample_width not in {1, 2, 3, 4}: + raise UserError("Sample width must be between 1 and 4 bytes") + if buffer.dtype == np.float32: - # convert to int16 - buffer = np.clip(buffer, -1.0, 1.0) - buffer = (buffer * 32767).astype(np.int16) + clipped_buffer = np.clip(buffer, -1.0, 1.0) + if sample_width == 1: + audio_bytes = ( + np.rint((clipped_buffer.astype(np.float64) + 1.0) * 127.5) + .astype(np.uint8) + .tobytes() + ) + elif sample_width == 2: + # Keep the established float32-to-PCM16 quantization unchanged. + audio_bytes = (clipped_buffer * 32767).astype("> 8) + 128).astype(np.uint8).tobytes() + else: + pcm_buffer = buffer.astype(np.int32) << (8 * (sample_width - 2)) + if sample_width == 2: + audio_bytes = pcm_buffer.astype(" Date: Wed, 12 Aug 2026 07:07:53 +0800 Subject: [PATCH 288/473] fix(extensions): gate AnyLLM parallel tool calls on converted tools (#4363) --- src/agents/extensions/models/any_llm_model.py | 8 +--- tests/models/test_any_llm_model.py | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 33610bc676..81cf898b63 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -881,19 +881,13 @@ async def _fetch_chat_response( if tracing.include_data(): span.span_data.input = converted_messages - parallel_tool_calls = ( - True - if model_settings.parallel_tool_calls and tools - else False - if model_settings.parallel_tool_calls is False - else None - ) tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) response_format = Converter.convert_response_format(output_schema) converted_tools = [Converter.tool_to_openai(tool) for tool in tools] if tools else [] for handoff in handoffs: converted_tools.append(Converter.convert_handoff_tool(handoff)) converted_tools = _to_dump_compatible(converted_tools) + parallel_tool_calls = model_settings.parallel_tool_calls if converted_tools else None if _debug.DONT_LOG_MODEL_DATA: logger.debug("Calling LLM") diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 9a37cb17c1..734f44a735 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -45,6 +45,8 @@ Tool, TResponseInputItem, __version__, + function_tool, + handoff, trace, ) from agents.exceptions import UserError @@ -281,6 +283,48 @@ async def test_user_agent_header_any_llm_chat(override_ua: str | None, monkeypat assert provider.chat_calls[0]["extra_headers"]["User-Agent"] == expected_ua +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("parallel_tool_calls", [True, False]) +@pytest.mark.parametrize("tool_source", ["none", "function", "handoff"]) +async def test_any_llm_chat_only_forwards_parallel_tool_calls_with_converted_tools( + monkeypatch: pytest.MonkeyPatch, + parallel_tool_calls: bool, + tool_source: str, +) -> None: + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel( + model="openrouter/openai/gpt-5.4-mini", + api="chat_completions", + ) + + tools: list[Tool] = ( + [function_tool(lambda: "ok", name_override="test_tool")] + if tool_source == "function" + else [] + ) + handoffs = [handoff(Agent(name="handoff"))] if tool_source == "handoff" else [] + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(parallel_tool_calls=parallel_tool_calls), + tools=tools, + output_schema=None, + handoffs=handoffs, + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + expected_parallel_tool_calls = parallel_tool_calls if tool_source != "none" else None + call = provider.chat_calls[0] + assert call["parallel_tool_calls"] is expected_parallel_tool_calls + assert (call["tools"] is not None) is (tool_source != "none") + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_any_llm_chat_preserves_falsy_reasoning(monkeypatch: pytest.MonkeyPatch) -> None: From e9998afa5bbd2c9c0631cafe92b80b200af60339 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 11 Aug 2026 18:16:13 -0500 Subject: [PATCH 289/473] fix(mcp): isolate manager lifecycle results (#4368) --- src/agents/mcp/manager.py | 4 ++-- tests/mcp/test_mcp_server_manager.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index d732993867..716b673e1d 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -300,7 +300,7 @@ async def _connect_all(self) -> list[MCPServer]: self._refresh_active_servers() - return self._active_servers + return self.active_servers async def reconnect(self, *, failed_only: bool = True) -> list[MCPServer]: """Reconnect servers and return the active list. @@ -336,7 +336,7 @@ async def _reconnect(self, *, failed_only: bool) -> list[MCPServer]: await self._attempt_connect(server) finally: self._refresh_active_servers() - return self._active_servers + return self.active_servers async def cleanup_all(self) -> None: """Cleanup all servers in reverse order.""" diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index fbb56bd66e..7b4e0c2d05 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -1212,6 +1212,21 @@ def test_manager_accepts_one_shot_iterables() -> None: assert manager.active_servers == [server_a, server_b] +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["connect_all", "reconnect"]) +async def test_manager_lifecycle_results_do_not_mutate_active_servers(operation: str) -> None: + server = FlakyServer(failures=0) + manager = MCPServerManager([server]) + + if operation == "reconnect": + await manager.connect_all() + + active_servers = await getattr(manager, operation)() + active_servers.clear() + + assert manager.active_servers == [server] + + @pytest.mark.asyncio async def test_manager_connects_servers_from_a_one_shot_iterable() -> None: server_a = CleanupAwareServer() From 3c6622d5b93ffeb8f201fc454105a6bce348f6d3 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 11 Aug 2026 18:22:12 -0500 Subject: [PATCH 290/473] fix(sessions): handle zero conversation history limits (#4365) --- src/agents/memory/openai_conversations_session.py | 2 ++ tests/memory/test_openai_conversations_session.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/agents/memory/openai_conversations_session.py b/src/agents/memory/openai_conversations_session.py index 2718e7e0aa..8e0641067c 100644 --- a/src/agents/memory/openai_conversations_session.py +++ b/src/agents/memory/openai_conversations_session.py @@ -86,6 +86,8 @@ async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: session_id = await self._get_session_id() session_limit = resolve_session_limit(limit, self.session_settings) + if session_limit == 0: + return [] all_items = [] if session_limit is None: diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index db803260c8..9117c64f21 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -218,6 +218,20 @@ async def test_clear_session_id(self, mock_openai_client): class TestOpenAIConversationsSessionBasicOperations: """Test basic CRUD operations with simple mocking.""" + @pytest.mark.asyncio + async def test_get_items_zero_limit_returns_empty_without_api_call(self, mock_openai_client): + """A zero history limit must not be forwarded to the Conversations API.""" + mock_openai_client.conversations.items.list = MagicMock( + side_effect=AssertionError("items.list must not receive limit=0") + ) + session = OpenAIConversationsSession(openai_client=mock_openai_client) + + assert await session.get_items(limit=0) == [] + + mock_openai_client.conversations.create.assert_awaited_once_with(items=[]) + assert session.session_id == "test_conversation_id" + mock_openai_client.conversations.items.list.assert_not_called() + @pytest.mark.asyncio async def test_add_items_simple(self, mock_openai_client): """Test adding items to the conversation.""" From d5b27bb3cd495416539379278f00b55d7f746c34 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 12 Aug 2026 08:41:52 +0900 Subject: [PATCH 291/473] fix(apply-diff): apply stacked anchors sequentially (#4369) Co-authored-by: Anton Dzyatkovsky (Mac16) --- src/agents/apply_diff.py | 63 ++++++++++++++--- tests/sandbox/test_apply_patch.py | 39 +++++++++++ tests/test_apply_diff.py | 110 ++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 11 deletions(-) diff --git a/src/agents/apply_diff.py b/src/agents/apply_diff.py index 4d35f6d7d4..9c3b4547eb 100644 --- a/src/agents/apply_diff.py +++ b/src/agents/apply_diff.py @@ -130,19 +130,20 @@ def _parse_update_diff(lines: list[str], input: str) -> ParsedUpdateDiff: cursor = 0 while not _is_done(parser, END_SECTION_MARKERS): - anchor = _read_str(parser, "@@ ") - has_bare_anchor = ( - anchor == "" and parser.index < len(parser.lines) and parser.lines[parser.index] == "@@" - ) - if has_bare_anchor: - parser.index += 1 + anchors, has_anchor = _read_anchors(parser) - if not (anchor or has_bare_anchor or cursor == 0): + if not (has_anchor or cursor == 0): current_line = parser.lines[parser.index] if parser.index < len(parser.lines) else "" raise ValueError(f"Invalid Line:\n{current_line}") - if anchor.strip(): - cursor = _advance_cursor_to_anchor(anchor, input_lines, cursor, parser) + for index, anchor in enumerate(anchors): + cursor = _advance_cursor_to_anchor( + anchor, + input_lines, + cursor, + parser, + force_forward_search=index > 0, + ) section = _read_section(parser.lines, parser.index) find_result = _find_context(input_lines, section.next_context, cursor, section.eof) @@ -168,22 +169,62 @@ def _parse_update_diff(lines: list[str], input: str) -> ParsedUpdateDiff: return ParsedUpdateDiff(chunks=chunks, fuzz=parser.fuzz) +def _read_anchors(parser: ParserState) -> tuple[list[str], bool]: + """Consume the ``@@`` header lines that introduce one hunk. + + The patch format lets a hunk carry several stacked headers, so nested code can be + located when a single header plus context is still ambiguous:: + + @@ class BaseClass + @@ def method(): + + Returns the non-empty headers in the order they should narrow the search, plus + whether a usable header was seen at all. + """ + anchors: list[str] = [] + has_anchor = False + + while True: + start_index = parser.index + anchor = _read_str(parser, "@@ ") + consumed = parser.index != start_index + bare = False + + if not consumed and parser.index < len(parser.lines) and parser.lines[parser.index] == "@@": + parser.index += 1 + consumed = bare = True + + if not consumed: + break + if anchor or bare: + has_anchor = True + if anchor.strip(): + anchors.append(anchor) + + return anchors, has_anchor + + def _advance_cursor_to_anchor( anchor: str, input_lines: list[str], cursor: int, parser: ParserState, + *, + force_forward_search: bool = False, ) -> int: found = False - if not any(line == anchor for line in input_lines[:cursor]): + if force_forward_search or not any(line == anchor for line in input_lines[:cursor]): for i in range(cursor, len(input_lines)): if input_lines[i] == anchor: cursor = i + 1 found = True break - if not found and not any(line.strip() == anchor.strip() for line in input_lines[:cursor]): + if not found and ( + force_forward_search + or not any(line.strip() == anchor.strip() for line in input_lines[:cursor]) + ): for i in range(cursor, len(input_lines)): if input_lines[i].strip() == anchor.strip(): cursor = i + 1 diff --git a/tests/sandbox/test_apply_patch.py b/tests/sandbox/test_apply_patch.py index c62d3c07de..fb74700918 100644 --- a/tests/sandbox/test_apply_patch.py +++ b/tests/sandbox/test_apply_patch.py @@ -49,6 +49,45 @@ async def test_apply_patch_update_uses_anchor_jump() -> None: assert session.files[Path("/workspace/anchor.txt")] == b"a\nb\nmarker\nc\ne\n" +@pytest.mark.asyncio +async def test_apply_patch_update_uses_stacked_anchor_jump() -> None: + """The tool description tells the model to stack ``@@`` headers when one is ambiguous.""" + session = ApplyPatchSession() + session.files[Path("/workspace/stacked.py")] = ( + b"class First\n" + b" def target():\n" + b" return 0\n" + b"\n" + b"class Second\n" + b" def helper():\n" + b" pass\n" + b"\n" + b" def target():\n" + b" pass\n" + ) + + await session.apply_patch( + ApplyPatchOperation( + type="update_file", + path="stacked.py", + diff="@@ class Second\n@@ def target():\n- pass\n+ return 1\n", + ) + ) + + assert session.files[Path("/workspace/stacked.py")] == ( + b"class First\n" + b" def target():\n" + b" return 0\n" + b"\n" + b"class Second\n" + b" def helper():\n" + b" pass\n" + b"\n" + b" def target():\n" + b" return 1\n" + ) + + @pytest.mark.asyncio async def test_apply_patch_update_matches_end_of_file_context() -> None: session = ApplyPatchSession() diff --git a/tests/test_apply_diff.py b/tests/test_apply_diff.py index 299bac82e4..8fd49ea93c 100644 --- a/tests/test_apply_diff.py +++ b/tests/test_apply_diff.py @@ -34,6 +34,116 @@ def test_apply_diff_applies_contextual_replacement() -> None: assert apply_diff(input_text, diff) == "line1\nupdated\nline3\n" +def test_apply_diff_applies_stacked_anchors_from_the_tool_description() -> None: + """The worked example the apply_patch tool description gives the model.""" + input_text = ( + "\n".join( + [ + "class BaseClass", + " def search():", + " pass", + "", + "class Subclass", + " def search():", + " pass", + ] + ) + + "\n" + ) + diff = "\n".join( + [ + "@@ class BaseClass", + "@@ def search():", + "- pass", + "+ raise NotImplementedError()", + "", + "@@ class Subclass", + "@@ def search():", + "- pass", + "+ raise NotImplementedError()", + ] + ) + + assert ( + apply_diff(input_text, diff) + == "\n".join( + [ + "class BaseClass", + " def search():", + " raise NotImplementedError()", + "", + "class Subclass", + " def search():", + " raise NotImplementedError()", + ] + ) + + "\n" + ) + + +def test_apply_diff_stacked_anchors_narrow_to_the_named_block() -> None: + """The second anchor skips an earlier matching body inside the selected class.""" + input_text = ( + "\n".join( + [ + "class First", + " def target():", + " return 0", + "", + "class Second", + " def helper():", + " pass", + "", + " def target():", + " pass", + ] + ) + + "\n" + ) + diff = "\n".join( + [ + "@@ class Second", + "@@ def target():", + "- pass", + "+ return 1", + ] + ) + + assert ( + apply_diff(input_text, diff) + == "\n".join( + [ + "class First", + " def target():", + " return 0", + "", + "class Second", + " def helper():", + " pass", + "", + " def target():", + " return 1", + ] + ) + + "\n" + ) + + +def test_apply_diff_stacked_anchors_stay_advisory_when_unmatched() -> None: + """Same rule a single unmatched anchor already follows: locate by context, don't fail.""" + input_text = "a\nb\n" + diff = "\n".join(["@@ nope", "@@ also-nope", "-b", "+B"]) + + assert apply_diff(input_text, diff) == "a\nB\n" + + +def test_apply_diff_stacked_anchors_accept_a_trailing_bare_anchor() -> None: + input_text = "class Only\n def run():\n pass\n" + diff = "\n".join(["@@ class Only", "@@", "- pass", "+ return 1"]) + + assert apply_diff(input_text, diff) == "class Only\n def run():\n return 1\n" + + def test_apply_diff_raises_on_context_mismatch() -> None: input_text = "one\ntwo\n" diff = "\n".join(["@@ -1,2 +1,2 @@", " x", "-two", "+2"]) From 6beab353ff7a7f5d087e793dc2284e222f0a48e8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 12 Aug 2026 08:46:47 +0900 Subject: [PATCH 292/473] fix(voice): reject incomplete multichannel audio frames (#4370) Co-authored-by: Henry Su --- src/agents/voice/input.py | 8 ++++++-- tests/voice/test_input.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/input.py b/src/agents/voice/input.py index a88722a1b3..1c5e1f99bf 100644 --- a/src/agents/voice/input.py +++ b/src/agents/voice/input.py @@ -22,6 +22,12 @@ def _buffer_to_audio_file( if sample_width not in {1, 2, 3, 4}: raise UserError("Sample width must be between 1 and 4 bytes") + if buffer.dtype not in (np.int16, np.float32): + raise UserError("Buffer must be a numpy array of int16 or float32") + + if channels > 0 and buffer.size % channels != 0: + raise UserError("Buffer must contain complete channel frames") + if buffer.dtype == np.float32: clipped_buffer = np.clip(buffer, -1.0, 1.0) if sample_width == 1: @@ -41,8 +47,6 @@ def _buffer_to_audio_file( audio_bytes = pcm_32.view(np.uint8).reshape(-1, 4)[:, :3].tobytes() else: audio_bytes = pcm_buffer.astype("> 8) + 128).astype(np.uint8).tobytes() else: diff --git a/tests/voice/test_input.py b/tests/voice/test_input.py index 84738ce8a5..1d300f6560 100644 --- a/tests/voice/test_input.py +++ b/tests/voice/test_input.py @@ -95,6 +95,19 @@ def test_buffer_to_audio_file_rejects_unsupported_sample_width(): _buffer_to_audio_file(buffer, sample_width=5) +def test_audio_input_validates_multichannel_frame_alignment(): + complete_audio = AudioInput(buffer=np.array([1, 2, 3, 4], dtype=np.int16), channels=2) + _, audio_file, _ = complete_audio.to_audio_file() + with wave.open(audio_file, "rb") as wav_file: + assert wav_file.getnchannels() == 2 + assert wav_file.getnframes() == 2 + + audio_input = AudioInput(buffer=np.array([1, 2, 3], dtype=np.int16), channels=2) + + with pytest.raises(UserError, match="complete channel frames"): + audio_input.to_audio_file() + + def test_buffer_to_audio_file_invalid_dtype(): # Create a buffer with invalid dtype (float64) buffer = np.array([1.0, 2.0, 3.0], dtype=np.float64) @@ -102,6 +115,9 @@ def test_buffer_to_audio_file_invalid_dtype(): with pytest.raises(UserError, match="Buffer must be a numpy array of int16 or float32"): _buffer_to_audio_file(buffer=buffer) + with pytest.raises(UserError, match="Buffer must be a numpy array of int16 or float32"): + _buffer_to_audio_file(buffer=buffer, channels=2) + class TestAudioInput: def test_audio_input_default_params(self): From dd34097826cb8f8304b5fcc4e67aa038c2ff5841 Mon Sep 17 00:00:00 2001 From: Lucca Boas Date: Tue, 11 Aug 2026 21:43:49 -0300 Subject: [PATCH 293/473] fix(runner): close the model stream when a streamed turn ends in a terminal failure (#4366) --- src/agents/models/_run_context.py | 15 +- src/agents/run_internal/model_retry.py | 4 +- src/agents/run_internal/run_loop.py | 106 ++++++------- tests/test_agent_runner_streamed.py | 203 +++++++++++++++++++++++-- 4 files changed, 254 insertions(+), 74 deletions(-) diff --git a/src/agents/models/_run_context.py b/src/agents/models/_run_context.py index fbe0e7265c..7a1159905c 100644 --- a/src/agents/models/_run_context.py +++ b/src/agents/models/_run_context.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Iterator -from contextlib import contextmanager +from collections.abc import AsyncGenerator, Iterator +from contextlib import aclosing, contextmanager from contextvars import ContextVar from typing import TypeVar @@ -24,9 +24,12 @@ def get_model_run_owner() -> object | None: async def model_run_context_stream( - stream: AsyncIterator[T], + stream: AsyncGenerator[T, None], owner: object, -) -> AsyncIterator[T]: +) -> AsyncGenerator[T, None]: + # `aclosing` forwards an early `aclose()` on this generator to the delegate, so the + # delegate's cleanup runs deterministically instead of waiting for garbage collection. with model_run_context(owner): - async for item in stream: - yield item + async with aclosing(stream): + async for item in stream: + yield item diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index 168060e648..d01b327d59 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -2,7 +2,7 @@ import asyncio import random -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping from inspect import isawaitable from typing import Any @@ -569,7 +569,7 @@ async def stream_response_with_retry( conversation_id: str | None, failed_retry_attempts_out: list[int] | None = None, replay_unsafe_request: bool = False, -) -> AsyncIterator[TResponseStreamEvent]: +) -> AsyncGenerator[TResponseStreamEvent, None]: request_attempt = 1 policy_attempt = 1 failed_policy_attempts = 0 diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 701e669535..4a2f9141d3 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -8,6 +8,7 @@ import asyncio import dataclasses as _dc from collections.abc import Awaitable, Callable +from contextlib import aclosing from functools import partial from typing import Any, TypeVar, cast from uuid import uuid4 @@ -1871,58 +1872,61 @@ async def rewind_model_request() -> None: ), ) - async for event in model_run_context_stream(retry_stream, tool_use_tracker): - streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event)) - - terminal_response: Response | None = None - is_completed_event = False - if isinstance(event, ResponseCompletedEvent): - is_completed_event = True - terminal_response = event.response - elif getattr(event, "type", None) in {"response.incomplete", "response.failed"}: - event_type = cast(str, event.type) - maybe_response = getattr(event, "response", None) - raise response_terminal_failure_error( - event_type, - maybe_response if isinstance(maybe_response, Response) else None, - ) - elif getattr(event, "type", None) in {"error", "response.error"}: - raise response_error_event_failure_error(cast(str, event.type), event) - - if terminal_response is not None: - if is_completed_event and not terminal_response.output and streamed_response_output: - # Some streaming backends emit output items during item.done events while leaving - # the terminal response output empty. Preserve those items so the runner can - # resolve the completed step correctly. - terminal_response.output = list(streamed_response_output) - # Always fold retry attempts into usage, even when the terminal response omits - # provider usage (common for some Chat Completions / LiteLLM streams). Skipping - # apply_retry_attempt_usage here would drop failed-attempt accounting and diverge - # from the non-streaming get_response_with_retry path. - usage = apply_retry_attempt_usage( - ( - _response_usage_to_usage(terminal_response.usage) - if terminal_response.usage - # Defaults to zero requests, so adapters that fold several provider - # responses into one and report counts separately are not double-counted. - else Usage(requests=_requests_for_response_without_usage(terminal_response)) - ), - stream_failed_retry_attempts[0], - ) - final_response = ModelResponse( - output=terminal_response.output, - usage=usage, - response_id=terminal_response.id, - request_id=getattr(terminal_response, "_request_id", None), - raw_usage=( - _extract_raw_usage_snapshot(terminal_response) - if model_settings.preserve_raw_usage is True - else None - ), - ) + # Raising out of this loop leaves the model stream suspended at its yield, so close it + # explicitly instead of waiting for garbage collection to finalize the generator chain. + async with aclosing(model_run_context_stream(retry_stream, tool_use_tracker)) as model_events: + async for event in model_events: + streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event)) + + terminal_response: Response | None = None + is_completed_event = False + if isinstance(event, ResponseCompletedEvent): + is_completed_event = True + terminal_response = event.response + elif getattr(event, "type", None) in {"response.incomplete", "response.failed"}: + event_type = cast(str, event.type) + maybe_response = getattr(event, "response", None) + raise response_terminal_failure_error( + event_type, + maybe_response if isinstance(maybe_response, Response) else None, + ) + elif getattr(event, "type", None) in {"error", "response.error"}: + raise response_error_event_failure_error(cast(str, event.type), event) + + if terminal_response is not None: + if is_completed_event and not terminal_response.output and streamed_response_output: + # Some streaming backends emit output items during item.done events while + # leaving the terminal response output empty. Preserve those items so the + # runner can resolve the completed step correctly. + terminal_response.output = list(streamed_response_output) + # Always fold retry attempts into usage, even when the terminal response omits + # provider usage (common for some Chat Completions / LiteLLM streams). Skipping + # apply_retry_attempt_usage here would drop failed-attempt accounting and diverge + # from the non-streaming get_response_with_retry path. + usage = apply_retry_attempt_usage( + ( + _response_usage_to_usage(terminal_response.usage) + if terminal_response.usage + # Defaults to zero requests, so adapters that fold several provider + # responses into one and report counts separately are not double-counted. + else Usage(requests=_requests_for_response_without_usage(terminal_response)) + ), + stream_failed_retry_attempts[0], + ) + final_response = ModelResponse( + output=terminal_response.output, + usage=usage, + response_id=terminal_response.id, + request_id=getattr(terminal_response, "_request_id", None), + raw_usage=( + _extract_raw_usage_snapshot(terminal_response) + if model_settings.preserve_raw_usage is True + else None + ), + ) - if isinstance(event, ResponseOutputItemDoneEvent): - streamed_response_output.append(event.item) + if isinstance(event, ResponseOutputItemDoneEvent): + streamed_response_output.append(event.item) if final_response is None: raise ModelBehaviorError("Model did not produce a final response!") diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index f7e32bf850..8bbf4ec2f6 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -108,6 +108,23 @@ def _find_reasoning_input_item( return None +class _DummyWSClient: + """Stand-in for `AsyncOpenAI` that the websocket model only reads connection settings from.""" + + def __init__(self) -> None: + self.base_url = httpx.URL("https://api.openai.com/v1/") + self.websocket_base_url = None + self.default_query: dict[str, Any] = {} + self.default_headers = { + "Authorization": "Bearer test-key", + "User-Agent": "AsyncOpenAI/Python test", + } + self.timeout: Any = None + + async def _refresh_api_key(self) -> None: + return None + + def _ws_terminal_response_frame(event_type: str, response_id: str, sequence_number: int) -> str: response = get_response_obj([get_text_message("partial final")], response_id=response_id) return json.dumps( @@ -612,22 +629,8 @@ async def close(self) -> None: if self.close_code is None: self.close_code = 1000 - class DummyWSClient: - def __init__(self) -> None: - self.base_url = httpx.URL("https://api.openai.com/v1/") - self.websocket_base_url = None - self.default_query: dict[str, Any] = {} - self.default_headers = { - "Authorization": "Bearer test-key", - "User-Agent": "AsyncOpenAI/Python test", - } - self.timeout: Any = None - - async def _refresh_api_key(self) -> None: - return None - ws = DummyWSConnection([_ws_terminal_response_frame(terminal_event_type, "resp-ws", 1)]) - model = OpenAIResponsesWSModel(model="gpt-4", openai_client=DummyWSClient()) # type: ignore[arg-type] + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=_DummyWSClient()) # type: ignore[arg-type] async def fake_open( _ws_url: str, @@ -654,6 +657,176 @@ async def fake_open( assert result.raw_responses == [] +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_ws_terminal_failure_frees_the_request_lock_for_a_waiting_run(monkeypatch) -> None: + """A terminal failure must hand the shared websocket back to a run already waiting for it. + + Run A holds `_ws_request_lock` while run B blocks on it. A then fails terminally, and B has + to complete on the surviving connection, which only happens if A's cleanup actually ran. + """ + release_run_a = asyncio.Event() + run_a_holds_lock = asyncio.Event() + run_b_waiting_on_lock = asyncio.Event() + + class SharedWSConnection: + def __init__(self) -> None: + self.close_code: int | None = None + self.send_calls = 0 + self.recv_calls = 0 + + async def send(self, payload: str) -> None: + self.send_calls += 1 + if self.send_calls == 1: + run_a_holds_lock.set() + return None + + async def recv(self) -> str: + self.recv_calls += 1 + if self.recv_calls == 1: + # Keep run A in-flight until run B is queued behind the request lock. + await release_run_a.wait() + return _ws_terminal_response_frame("response.incomplete", "resp-a", 1) + return _ws_terminal_response_frame("response.completed", "resp-b", 1) + + async def close(self) -> None: + if self.close_code is None: + self.close_code = 1000 + + ws = SharedWSConnection() + open_calls = 0 + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=_DummyWSClient()) # type: ignore[arg-type] + request_lock = model._get_ws_request_lock() + + async def fake_open( + _ws_url: str, + _headers: dict[str, str], + *, + connect_timeout: float | None = None, + ) -> SharedWSConnection: + nonlocal open_calls + open_calls += 1 + return ws + + monkeypatch.setattr(model, "_open_websocket_connection", fake_open) + original_await_websocket_with_timeout = model._await_websocket_with_timeout + + async def observed_await_websocket_with_timeout( + awaitable: Any, + timeout_seconds: float | None, + phase: str, + ) -> Any: + if phase == "request lock wait" and request_lock.locked(): + run_b_waiting_on_lock.set() + return await original_await_websocket_with_timeout(awaitable, timeout_seconds, phase) + + monkeypatch.setattr( + model, "_await_websocket_with_timeout", observed_await_websocket_with_timeout + ) + agent = Agent(name="test", model=model) + + async def run_a() -> None: + result = Runner.run_streamed(agent, input="a") + with pytest.raises(ModelBehaviorError, match="response.incomplete"): + async for _ in result.stream_events(): + pass + + async def run_b() -> tuple[Any, list[str | None]]: + result = Runner.run_streamed(agent, input="b") + async for _ in result.stream_events(): + pass + return result.final_output, [response.response_id for response in result.raw_responses] + + task_a = asyncio.create_task(run_a()) + try: + await asyncio.wait_for(run_a_holds_lock.wait(), timeout=5) + assert request_lock.locked(), "run A released the request lock before run B started" + + task_b = asyncio.create_task(run_b()) + await asyncio.wait_for(run_b_waiting_on_lock.wait(), timeout=5) + assert not task_b.done(), "run B was expected to be waiting on the request lock" + finally: + release_run_a.set() + + await task_a + try: + # Only a hang guard: run B needs event loop turns, not wall-clock time. + output, response_ids = await asyncio.wait_for(task_b, timeout=5) + except TimeoutError: + pytest.fail("run A's terminal failure left `_ws_request_lock` held, so run B never woke") + + assert output == "partial final", "run B did not complete on the surviving connection" + assert response_ids == ["resp-b"], "run B did not receive its own response" + assert not request_lock.locked(), "the request lock was left held after both runs finished" + assert ws.close_code is None, "the shared connection should survive a terminal failure" + assert open_calls == 1, "run B should reuse the websocket connection opened by run A" + assert ws.send_calls == 2, "run B should complete on the existing websocket connection" + + +def _terminal_failure_event(event_type: str) -> Any: + """Build the terminal event the streamed run loop rejects for `event_type`.""" + if event_type == "response.incomplete": + return ResponseIncompleteEvent( + response=get_response_obj([], response_id="resp-terminal"), + sequence_number=0, + type="response.incomplete", + ) + if event_type == "response.failed": + return ResponseFailedEvent( + response=get_response_obj([], response_id="resp-terminal"), + sequence_number=0, + type="response.failed", + ) + if event_type == "error": + return ResponseErrorEvent( + code=None, message="boom", param=None, sequence_number=0, type="error" + ) + # `response.error` is not a literal the SDK models, but the run loop rejects it too. + return ResponseErrorEvent.model_construct( + code=None, message="boom", param=None, sequence_number=0, type="response.error" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "terminal_event_type", + ["response.incomplete", "response.failed", "error", "response.error"], +) +async def test_streamed_terminal_failure_closes_the_model_stream(terminal_event_type: str) -> None: + """The run loop must close the model stream itself before a terminal failure leaves the loop. + + Comparing the closing task with the iterating one keeps this honest: an abandoned generator + is still finalized eventually, but by asyncio's async-generator hook, in another task and + context, which is what leaves the response span unended. + """ + iterating_task: asyncio.Task[Any] | None = None + closing_task: asyncio.Task[Any] | None = None + + class TerminalFailureModel(Model): + async def get_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + async def stream_response(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]: + nonlocal iterating_task, closing_task + iterating_task = asyncio.current_task() + try: + yield _terminal_failure_event(terminal_event_type) + finally: + closing_task = asyncio.current_task() + + agent = Agent(name="test", model=TerminalFailureModel()) + result = Runner.run_streamed(agent, input="test") + + with pytest.raises(ModelBehaviorError, match=terminal_event_type): + async for _ in result.stream_events(): + pass + + assert closing_task is not None and closing_task is iterating_task, ( + "the run loop must close the model stream in its own task; it was left open or finalized " + "later by asyncio's async-generator hook" + ) + + @pytest.mark.asyncio async def test_subsequent_runs(): model = FakeModel() From 39c3a3bb1b8a43919110763492e09ee59d309140 Mon Sep 17 00:00:00 2001 From: Jaideep Pyne Date: Wed, 12 Aug 2026 08:25:17 +0530 Subject: [PATCH 294/473] docs: clarify PGP key location (#4371) --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index f3ecd61a17..6d6f549314 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,4 +2,4 @@ For a more in-depth look at our security policy, please check out our [Coordinated Vulnerability Disclosure Policy](https://openai.com/security/disclosure/#:~:text=Disclosure%20Policy,-Security%20is%20essential&text=OpenAI%27s%20coordinated%20vulnerability%20disclosure%20policy,expect%20from%20us%20in%20return.). -Our PGP key can located [at this address.](https://cdn.openai.com/security.txt) +Our PGP key can be found [at this address](https://cdn.openai.com/security.txt). From 5250cb86053f50abea9d30e7d06b8fc4b5b6adb1 Mon Sep 17 00:00:00 2001 From: hansu650 <2788086371@qq.com> Date: Wed, 12 Aug 2026 13:18:50 +0800 Subject: [PATCH 295/473] fix(voice): reject non-positive audio channels (#4372) --- src/agents/voice/input.py | 5 ++++- tests/voice/test_input.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/agents/voice/input.py b/src/agents/voice/input.py index 1c5e1f99bf..63f2313d1b 100644 --- a/src/agents/voice/input.py +++ b/src/agents/voice/input.py @@ -25,7 +25,10 @@ def _buffer_to_audio_file( if buffer.dtype not in (np.int16, np.float32): raise UserError("Buffer must be a numpy array of int16 or float32") - if channels > 0 and buffer.size % channels != 0: + if channels <= 0: + raise UserError("Channels must be greater than zero") + + if buffer.size % channels != 0: raise UserError("Buffer must contain complete channel frames") if buffer.dtype == np.float32: diff --git a/tests/voice/test_input.py b/tests/voice/test_input.py index 1d300f6560..06acf2b4bf 100644 --- a/tests/voice/test_input.py +++ b/tests/voice/test_input.py @@ -108,6 +108,14 @@ def test_audio_input_validates_multichannel_frame_alignment(): audio_input.to_audio_file() +@pytest.mark.parametrize("channels", [0, -1]) +def test_audio_input_rejects_non_positive_channels(channels): + audio_input = AudioInput(buffer=np.zeros(4, dtype=np.int16), channels=channels) + + with pytest.raises(UserError, match="Channels must be greater than zero"): + audio_input.to_audio_file() + + def test_buffer_to_audio_file_invalid_dtype(): # Create a buffer with invalid dtype (float64) buffer = np.array([1.0, 2.0, 3.0], dtype=np.float64) From c12f14d1e98a37774c449107b21f7bbe82d0aa31 Mon Sep 17 00:00:00 2001 From: xumaple <45406854+xumaple@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:14:52 -0400 Subject: [PATCH 296/473] feat(sandbox): let managed_secrets reference existing Runloop secrets (#4378) --- examples/sandbox/extensions/README.md | 2 +- src/agents/extensions/sandbox/__init__.py | 2 + .../extensions/sandbox/runloop/__init__.py | 2 + .../extensions/sandbox/runloop/sandbox.py | 35 +++++--- tests/extensions/sandbox/test_runloop.py | 84 +++++++++++++++++++ .../released_api_contract_policy.json | 1 + tests/sandbox/test_compatibility_guards.py | 1 + 7 files changed, 114 insertions(+), 13 deletions(-) diff --git a/examples/sandbox/extensions/README.md b/examples/sandbox/extensions/README.md index 7b5c3c0615..3037543df4 100644 --- a/examples/sandbox/extensions/README.md +++ b/examples/sandbox/extensions/README.md @@ -303,7 +303,7 @@ public_blueprints = await client.platform.blueprints.list_public() public_benchmarks = await client.platform.benchmarks.list_public() ``` -`managed_secrets` are stored as Runloop account secrets and only secret references are persisted in session state. The platform facade also exposes Runloop-native helpers for blueprints, benchmarks, secrets, network policies, and axons. +`managed_secrets` are stored as Runloop account secrets and only secret references are persisted in session state. An entry's value may instead be `RunloopExistingSecret()`, which attaches a secret that is already stored on the account by name, uploading nothing and leaving the stored value untouched. The platform facade also exposes Runloop-native helpers for blueprints, benchmarks, secrets, network policies, and axons. If you enable `--root`, Runloop launches the devbox with `launch_parameters.user_parameters={"username":"root","uid":0}`. In that mode, the default home and working directory become `/root`, so the example also uses `/root` as its manifest workspace root. If you configure root launch in your own code, either rely on that root-mode default or explicitly choose a `manifest.root` under `/root`. ## Blaxel diff --git a/src/agents/extensions/sandbox/__init__.py b/src/agents/extensions/sandbox/__init__.py index ebf9e9aeeb..9ea2eb8dc3 100644 --- a/src/agents/extensions/sandbox/__init__.py +++ b/src/agents/extensions/sandbox/__init__.py @@ -80,6 +80,7 @@ DEFAULT_RUNLOOP_WORKSPACE_ROOT as DEFAULT_RUNLOOP_WORKSPACE_ROOT, RunloopAfterIdle as RunloopAfterIdle, RunloopCloudBucketMountStrategy as RunloopCloudBucketMountStrategy, + RunloopExistingSecret as RunloopExistingSecret, RunloopGatewaySpec as RunloopGatewaySpec, RunloopLaunchParameters as RunloopLaunchParameters, RunloopMcpSpec as RunloopMcpSpec, @@ -195,6 +196,7 @@ "DEFAULT_RUNLOOP_WORKSPACE_ROOT", "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT", "RunloopAfterIdle", + "RunloopExistingSecret", "RunloopGatewaySpec", "RunloopLaunchParameters", "RunloopMcpSpec", diff --git a/src/agents/extensions/sandbox/runloop/__init__.py b/src/agents/extensions/sandbox/runloop/__init__.py index afc228d4f5..9070307bd0 100644 --- a/src/agents/extensions/sandbox/runloop/__init__.py +++ b/src/agents/extensions/sandbox/runloop/__init__.py @@ -5,6 +5,7 @@ DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT, DEFAULT_RUNLOOP_WORKSPACE_ROOT, RunloopAfterIdle, + RunloopExistingSecret, RunloopGatewaySpec, RunloopLaunchParameters, RunloopMcpSpec, @@ -29,6 +30,7 @@ "DEFAULT_RUNLOOP_WORKSPACE_ROOT", "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT", "RunloopAfterIdle", + "RunloopExistingSecret", "RunloopGatewaySpec", "RunloopLaunchParameters", "RunloopMcpSpec", diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py index dae79d1e2f..2662a55db4 100644 --- a/src/agents/extensions/sandbox/runloop/sandbox.py +++ b/src/agents/extensions/sandbox/runloop/sandbox.py @@ -422,6 +422,16 @@ class RunloopMcpSpec(BaseModel): secret: str = Field(min_length=1) +class RunloopExistingSecret(BaseModel): + """A `managed_secrets` entry that is already stored on the Runloop account. + + Used in place of a secret value to attach an account secret to the devbox by name, + leaving the stored value untouched. + """ + + model_config = {"frozen": True, "extra": "forbid"} + + def _normalize_runloop_user_parameters( user_parameters: RunloopUserParameters | dict[str, object] | None, ) -> RunloopUserParameters | None: @@ -475,7 +485,7 @@ class RunloopSandboxClientOptions(BaseSandboxClientOptions): gateways: dict[str, RunloopGatewaySpec] | None = None mcp: dict[str, RunloopMcpSpec] | None = None metadata: dict[str, str] | None = None - managed_secrets: dict[str, str] | None = None + managed_secrets: dict[str, str | RunloopExistingSecret] | None = None def __init__( self, @@ -492,7 +502,7 @@ def __init__( gateways: dict[str, RunloopGatewaySpec] | None = None, mcp: dict[str, RunloopMcpSpec] | None = None, metadata: dict[str, str] | None = None, - managed_secrets: dict[str, str] | None = None, + managed_secrets: dict[str, str | RunloopExistingSecret] | None = None, *, type: Literal["runloop"] = "runloop", ) -> None: @@ -1482,10 +1492,10 @@ def _runloop_launch_parameters_payload( return payload or None -async def _upsert_runloop_managed_secrets( +async def _resolve_runloop_managed_secret_refs( sdk: Any, *, - managed_secrets: dict[str, str] | None, + managed_secrets: dict[str, str | RunloopExistingSecret] | None, timeout_s: float, ) -> dict[str, str]: if not managed_secrets: @@ -1493,13 +1503,14 @@ async def _upsert_runloop_managed_secrets( secret_refs: dict[str, str] = {} for env_var, secret_value in sorted(managed_secrets.items()): - try: - await sdk.secret.create(name=env_var, value=secret_value, timeout=timeout_s) - except Exception as e: - if _is_runloop_conflict(e): - await sdk.secret.update(env_var, value=secret_value, timeout=timeout_s) - else: - raise + if not isinstance(secret_value, RunloopExistingSecret): + try: + await sdk.secret.create(name=env_var, value=secret_value, timeout=timeout_s) + except Exception as e: + if _is_runloop_conflict(e): + await sdk.secret.update(env_var, value=secret_value, timeout=timeout_s) + else: + raise secret_refs[env_var] = env_var return secret_refs @@ -1599,7 +1610,7 @@ async def create( else: timeouts = RunloopTimeouts.model_validate(timeouts_in) - secret_refs = await _upsert_runloop_managed_secrets( + secret_refs = await _resolve_runloop_managed_secret_refs( self._sdk, managed_secrets=resolved_options.managed_secrets, timeout_s=timeouts.fast_op_s, diff --git a/tests/extensions/sandbox/test_runloop.py b/tests/extensions/sandbox/test_runloop.py index 516e8d8daf..80711bf3b2 100644 --- a/tests/extensions/sandbox/test_runloop.py +++ b/tests/extensions/sandbox/test_runloop.py @@ -1873,6 +1873,90 @@ async def test_create_upserts_managed_secret_when_runloop_returns_bad_request_ex assert sdk.secret.update_calls == [("API_KEY", "new-value", {"timeout": 30.0})] assert session.state.secret_refs == {"API_KEY": "API_KEY"} + @pytest.mark.asyncio + async def test_create_references_existing_managed_secrets_without_uploading_values( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runloop_module = _load_runloop_module(monkeypatch) + + async with runloop_module.RunloopSandboxClient() as client: + session = await client.create( + options=runloop_module.RunloopSandboxClientOptions( + managed_secrets={ + "SHARED_TOKEN": runloop_module.RunloopExistingSecret(), + "API_KEY": runloop_module.RunloopExistingSecret(), + }, + ) + ) + sdk = _FakeAsyncRunloopSDK.created_instances[-1] + + assert sdk.secret.create_calls == [] + assert sdk.secret.update_calls == [] + create_params = sdk.devbox.create_calls[0] + assert create_params["secrets"] == { + "API_KEY": "API_KEY", + "SHARED_TOKEN": "SHARED_TOKEN", + } + assert session.state.secret_refs == { + "API_KEY": "API_KEY", + "SHARED_TOKEN": "SHARED_TOKEN", + } + + @pytest.mark.asyncio + async def test_create_mixes_existing_and_uploaded_managed_secrets( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runloop_module = _load_runloop_module(monkeypatch) + + async with runloop_module.RunloopSandboxClient() as client: + session = await client.create( + options=runloop_module.RunloopSandboxClientOptions( + managed_secrets={ + "API_KEY": "super-secret", + "SHARED_TOKEN": runloop_module.RunloopExistingSecret(), + }, + ) + ) + sdk = _FakeAsyncRunloopSDK.created_instances[-1] + + assert sdk.secret.create_calls == [("API_KEY", "super-secret", {"timeout": 30.0})] + assert sdk.secret.update_calls == [] + assert "SHARED_TOKEN" not in sdk.secret.secrets + create_params = sdk.devbox.create_calls[0] + assert create_params["secrets"] == { + "API_KEY": "API_KEY", + "SHARED_TOKEN": "SHARED_TOKEN", + } + assert session.state.secret_refs == { + "API_KEY": "API_KEY", + "SHARED_TOKEN": "SHARED_TOKEN", + } + assert "super-secret" not in json.dumps(session.state.model_dump(mode="json")) + + def test_runloop_client_options_round_trip_existing_managed_secrets( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runloop_module = _load_runloop_module(monkeypatch) + + options = runloop_module.RunloopSandboxClientOptions( + managed_secrets={ + "API_KEY": "super-secret", + "SHARED_TOKEN": runloop_module.RunloopExistingSecret(), + }, + ) + + restored = runloop_module.RunloopSandboxClientOptions.model_validate( + json.loads(json.dumps(options.model_dump(mode="json"))) + ) + + assert restored.managed_secrets == { + "API_KEY": "super-secret", + "SHARED_TOKEN": runloop_module.RunloopExistingSecret(), + } + @pytest.mark.asyncio async def test_resume_and_snapshot_restore_reuse_runloop_native_options( self, diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index 4bf43b6116..eb71176757 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -152,6 +152,7 @@ "ModalSandboxSessionState": "modal", "RunloopAfterIdle": "runloop_api_client", "RunloopCloudBucketMountStrategy": "runloop_api_client", + "RunloopExistingSecret": "runloop_api_client", "RunloopGatewaySpec": "runloop_api_client", "RunloopLaunchParameters": "runloop_api_client", "RunloopMcpSpec": "runloop_api_client", diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index a5279e2672..0e3a2bcea4 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -304,6 +304,7 @@ def test_core_sandbox_public_export_surface_is_stable() -> None: "DEFAULT_RUNLOOP_WORKSPACE_ROOT", "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT", "RunloopAfterIdle", + "RunloopExistingSecret", "RunloopGatewaySpec", "RunloopLaunchParameters", "RunloopMcpSpec", From 0fc268e3e25e857431ad18877ebf205359206e86 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 13 Aug 2026 10:29:33 +0900 Subject: [PATCH 297/473] fix(mcp): add configurable retry backoff ceiling (#4379) Co-authored-by: rxits <132228481+rxits@users.noreply.github.com> --- src/agents/mcp/server.py | 53 +++++++++- tests/mcp/test_client_session_retries.py | 127 ++++++++++++++++++++++- 2 files changed, 171 insertions(+), 9 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index e4b2fc6c5f..4ce05c9502 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -161,6 +161,24 @@ def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | f return timeout_seconds if MCP_V2 else timeout +def _validate_retry_backoff_seconds_max(value: float | None) -> None: + """Validate the optional maximum retry delay.""" + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int | float): + raise TypeError("retry_backoff_seconds_max must be a number of seconds or None.") + try: + is_finite = math.isfinite(value) + except OverflowError as error: + raise ValueError( + "retry_backoff_seconds_max must be a non-negative finite number of seconds or None." + ) from error + if not is_finite or value < 0: + raise ValueError( + "retry_backoff_seconds_max must be a non-negative finite number of seconds or None." + ) + + def _transport_error_urls_are_safe( http_error: Exception, ) -> bool: @@ -857,6 +875,7 @@ def __init__( failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, + retry_backoff_seconds_max: float | None = None, ): """ Args: @@ -894,6 +913,8 @@ def __init__( tool calls. It is invoked by the Agents SDK before calling `call_tool`. custom_data_extractor: Optional callable that produces SDK-only custom data for emitted MCP tool output items. + retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between + retries. Defaults to `None`, which leaves exponential backoff uncapped. """ super().__init__( use_structured_content=use_structured_content, @@ -912,9 +933,11 @@ def __init__( # Validate during construction, then convert again when connecting in case callers mutate # the public timeout attribute before a later connection attempt. _client_session_read_timeout(client_session_timeout_seconds) + _validate_retry_backoff_seconds_max(retry_backoff_seconds_max) self.client_session_timeout_seconds = client_session_timeout_seconds self.max_retry_attempts = max_retry_attempts self.retry_backoff_seconds_base = retry_backoff_seconds_base + self.retry_backoff_seconds_max = retry_backoff_seconds_max self.message_handler = message_handler # The cache is always dirty at startup, so that we fetch tools at least once @@ -1187,6 +1210,17 @@ async def _run_request_with_transport_error_redaction( assert transport_error is not None self._raise_mapped_transport_error(transport_error, None) + def _retry_backoff_seconds(self, backoffs_taken: int) -> float: + """Return the configured exponential delay after the given number of backoffs.""" + if self.retry_backoff_seconds_max is None: + return cast(float, self.retry_backoff_seconds_base * (2**backoffs_taken)) + + try: + backoff = math.ldexp(self.retry_backoff_seconds_base, backoffs_taken) + except OverflowError: + backoff = math.copysign(math.inf, self.retry_backoff_seconds_base) + return min(backoff, self.retry_backoff_seconds_max) + async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: attempts = 0 while True: @@ -1196,8 +1230,7 @@ async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: attempts += 1 if self.max_retry_attempts != -1 and attempts > self.max_retry_attempts: raise - backoff = self.retry_backoff_seconds_base * (2 ** (attempts - 1)) - await asyncio.sleep(backoff) + await asyncio.sleep(self._retry_backoff_seconds(attempts - 1)) @asynccontextmanager async def _client_session_context(self, read_timeout: timedelta | float | None): @@ -1845,6 +1878,7 @@ def __init__( failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, + retry_backoff_seconds_max: float | None = None, ): """Create a new MCP server based on the stdio transport. @@ -1887,6 +1921,8 @@ def __init__( tool calls. It is invoked by the Agents SDK before calling `call_tool`. custom_data_extractor: Optional callable that produces SDK-only custom data for emitted MCP tool output items. + retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between + retries. Defaults to `None`, which leaves exponential backoff uncapped. """ super().__init__( cache_tools_list=cache_tools_list, @@ -1900,6 +1936,7 @@ def __init__( failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, + retry_backoff_seconds_max=retry_backoff_seconds_max, ) self.params = StdioServerParameters( @@ -1974,6 +2011,7 @@ def __init__( failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, + retry_backoff_seconds_max: float | None = None, ): """Create a new MCP server based on the HTTP with SSE transport. @@ -2018,6 +2056,8 @@ def __init__( tool calls. It is invoked by the Agents SDK before calling `call_tool`. custom_data_extractor: Optional callable that produces SDK-only custom data for emitted MCP tool output items. + retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between + retries. Defaults to `None`, which leaves exponential backoff uncapped. """ super().__init__( cache_tools_list=cache_tools_list, @@ -2031,6 +2071,7 @@ def __init__( failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, + retry_backoff_seconds_max=retry_backoff_seconds_max, ) self.params = params @@ -2131,6 +2172,7 @@ def __init__( failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, + retry_backoff_seconds_max: float | None = None, ): """Create a new MCP server based on the Streamable HTTP transport. @@ -2176,6 +2218,8 @@ def __init__( tool calls. It is invoked by the Agents SDK before calling `call_tool`. custom_data_extractor: Optional callable that produces SDK-only custom data for emitted MCP tool output items. + retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between + retries. Defaults to `None`, which leaves exponential backoff uncapped. """ super().__init__( cache_tools_list=cache_tools_list, @@ -2189,6 +2233,7 @@ def __init__( failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, + retry_backoff_seconds_max=retry_backoff_seconds_max, ) self.params = params @@ -2391,13 +2436,13 @@ async def call_tool( if exc.__cause__ is not None: raise exc.__cause__ from exc raise - backoff = self.retry_backoff_seconds_base * (2**backoffs_taken) + backoff = self._retry_backoff_seconds(backoffs_taken) backoffs_taken += 1 await asyncio.sleep(backoff) except Exception: if self.max_retry_attempts != -1 and retries_used >= self.max_retry_attempts: raise - backoff = self.retry_backoff_seconds_base * (2**backoffs_taken) + backoff = self._retry_backoff_seconds(backoffs_taken) backoffs_taken += 1 await asyncio.sleep(backoff) first_attempt = False diff --git a/tests/mcp/test_client_session_retries.py b/tests/mcp/test_client_session_retries.py index b571a15c58..5d8df49241 100644 --- a/tests/mcp/test_client_session_retries.py +++ b/tests/mcp/test_client_session_retries.py @@ -1,7 +1,7 @@ import asyncio import sys from contextlib import asynccontextmanager -from typing import cast +from typing import Any, cast import httpx import pytest @@ -16,7 +16,12 @@ from agents.exceptions import UserError from agents.mcp._compat import mcp_request_timeout_code -from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession +from agents.mcp.server import ( + MCPServerSse, + MCPServerStdio, + MCPServerStreamableHttp, + _MCPServerWithClientSession, +) from .model_compat import ListPromptsResult, ListToolsResult, Tool as MCPTool, create_mcp_error @@ -45,12 +50,21 @@ async def list_tools(self, *, params: PaginatedRequestParams | None = None): class DummyServer(_MCPServerWithClientSession): - def __init__(self, session: DummySession, retries: int, *, serialize_requests: bool = False): + def __init__( + self, + session: DummySession, + retries: int, + *, + retry_backoff_seconds_base: float = 0, + retry_backoff_seconds_max: float | None = None, + serialize_requests: bool = False, + ): super().__init__( cache_tools_list=False, client_session_timeout_seconds=None, max_retry_attempts=retries, - retry_backoff_seconds_base=0, + retry_backoff_seconds_base=retry_backoff_seconds_base, + retry_backoff_seconds_max=retry_backoff_seconds_max, ) self.session = cast(ClientSession, session) self._serialize_session_requests = serialize_requests @@ -376,12 +390,19 @@ async def call_tool(self, tool_name, arguments, meta=None): class DummyStreamableHttpServer(MCPServerStreamableHttp): - def __init__(self, shared_session: object, isolated_session: object): + def __init__( + self, + shared_session: object, + isolated_session: object, + *, + retry_backoff_seconds_max: float | None = None, + ): super().__init__( params={"url": "https://example.test/mcp"}, client_session_timeout_seconds=None, max_retry_attempts=0, retry_backoff_seconds_base=0, + retry_backoff_seconds_max=retry_backoff_seconds_max, ) self.session = cast(ClientSession, shared_session) self._isolated_session = cast(ClientSession, isolated_session) @@ -747,3 +768,99 @@ async def record_sleep(delay: float) -> None: await server.call_tool("tool", None) assert delays == [1.0, 2.0, 4.0] + + +def test_retry_backoff_seconds_max_is_forwarded_by_public_servers(): + servers = [ + MCPServerStdio(params={"command": "test"}, retry_backoff_seconds_max=0.0), + MCPServerSse(params={"url": "https://example.test/sse"}, retry_backoff_seconds_max=0.0), + MCPServerStreamableHttp( + params={"url": "https://example.test/mcp"}, retry_backoff_seconds_max=0.0 + ), + ] + + assert [server.retry_backoff_seconds_max for server in servers] == [0.0, 0.0, 0.0] + + +@pytest.mark.parametrize( + "value", + [-1.0, float("-inf"), float("inf"), float("nan")], +) +def test_retry_backoff_seconds_max_rejects_negative_and_non_finite_values(value: float): + with pytest.raises(ValueError, match="non-negative finite"): + MCPServerStreamableHttp( + params={"url": "https://example.test/mcp"}, + retry_backoff_seconds_max=value, + ) + + +@pytest.mark.parametrize("value", [True, "1"]) +def test_retry_backoff_seconds_max_rejects_non_numeric_values(value: object): + with pytest.raises(TypeError, match="must be a number of seconds or None"): + MCPServerStreamableHttp( + params={"url": "https://example.test/mcp"}, + retry_backoff_seconds_max=cast(Any, value), + ) + + +@pytest.mark.parametrize("retries", [8, -1]) +@pytest.mark.asyncio +async def test_generic_backoff_remains_uncapped_by_default(monkeypatch, retries: int): + delays: list[float] = [] + + async def record_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(asyncio, "sleep", record_sleep) + + session = DummySession(fail_call_tool=8) + server = DummyServer(session, retries, retry_backoff_seconds_base=1.0) + + await server.call_tool("tool", None) + + assert delays == [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0] + + +@pytest.mark.asyncio +async def test_generic_backoff_respects_configured_maximum(monkeypatch): + delays: list[float] = [] + + async def record_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(asyncio, "sleep", record_sleep) + + session = DummySession(fail_call_tool=9) + server = DummyServer( + session, + -1, + retry_backoff_seconds_base=1.0, + retry_backoff_seconds_max=64.0, + ) + + await server.call_tool("tool", None) + + assert delays == [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 64.0, 64.0] + + +@pytest.mark.asyncio +async def test_streamable_http_backoff_respects_configured_maximum(monkeypatch): + delays: list[float] = [] + + async def record_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(asyncio, "sleep", record_sleep) + + shared_session = FlakyRuntimeErrorSession(failures=9) + server = DummyStreamableHttpServer( + shared_session, + TimeoutSession(), + retry_backoff_seconds_max=64.0, + ) + server.max_retry_attempts = -1 + server.retry_backoff_seconds_base = 1.0 + + await server.call_tool("tool", None) + + assert delays == [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 64.0, 64.0] From 05d6850da5da7ae5efe0e89e85660e5878547ba4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 13 Aug 2026 11:20:33 +0900 Subject: [PATCH 298/473] feat: add scripted model test utilities (#4362) --- AGENTS.md | 2 + integration_tests/_contract_support.py | 6 +- integration_tests/_fake_model.py | 120 - .../security/test_local_sandbox_isolation.py | 32 +- .../voice/test_voice_pipeline.py | 18 +- src/agents/realtime/testing.py | 478 ++++ src/agents/testing/__init__.py | 45 + src/agents/testing/model.py | 1198 +++++++++ src/agents/testing/sandbox.py | 570 +++++ src/agents/usage.py | 11 + src/agents/voice/testing.py | 421 ++++ tests/README.md | 2 + .../memory/test_advanced_sqlite_session.py | 34 +- .../memory/test_async_sqlite_session.py | 22 +- .../memory/test_dapr_redis_integration.py | 14 +- tests/extensions/memory/test_dapr_session.py | 32 +- .../extensions/memory/test_encrypt_session.py | 22 +- .../extensions/memory/test_mongodb_session.py | 26 +- tests/extensions/memory/test_redis_session.py | 32 +- .../memory/test_sqlalchemy_session.py | 28 +- .../extensions/sandbox/test_blaxel_mounts.py | 36 +- tests/extensions/sandbox/test_rclone.py | 44 +- tests/extensions/sandbox/test_vercel.py | 12 +- tests/fake_model.py | 404 --- tests/fastapi/test_streaming_context.py | 7 +- tests/fixtures/run_state/generate_corpus.py | 6 +- tests/mcp/test_mcp_approval.py | 14 +- tests/mcp/test_mcp_pagination_integration.py | 6 +- tests/mcp/test_mcp_tracing.py | 18 +- tests/mcp/test_prompt_server.py | 12 +- tests/mcp/test_runner_calls_mcp.py | 44 +- .../test_openai_conversations_session.py | 20 +- ...est_openai_responses_compaction_session.py | 16 +- tests/memory/test_session.py | 60 +- tests/memory/test_session_context_wrapper.py | 20 +- tests/memory/test_session_limit.py | 46 +- tests/model_test_helpers.py | 76 + tests/models/test_map.py | 6 +- tests/models/test_openai_responses.py | 2 +- tests/realtime/test_runner.py | 56 +- tests/realtime/test_session.py | 99 +- tests/realtime/test_session_exceptions.py | 155 +- tests/realtime/test_testing.py | 1369 ++++++++++ .../capabilities/test_apply_patch_tool.py | 13 +- .../capabilities/test_shell_capability.py | 477 ++-- .../capabilities/test_skills_capability.py | 25 +- .../capabilities/test_view_image_tool.py | 118 +- tests/sandbox/integration_tests/test_model.py | 6 +- tests/sandbox/test_memory.py | 267 +- tests/sandbox/test_runtime.py | 382 +-- .../sandbox/test_runtime_agent_preparation.py | 23 +- tests/test_agent_as_tool.py | 132 +- tests/test_agent_hooks.py | 60 +- tests/test_agent_llm_hooks.py | 14 +- tests/test_agent_memory_leak.py | 6 +- tests/test_agent_prompt.py | 15 +- tests/test_agent_runner.py | 659 +++-- tests/test_agent_runner_streamed.py | 373 ++- tests/test_agent_tracing.py | 126 +- tests/test_call_model_input_filter.py | 80 +- tests/test_call_model_input_filter_unit.py | 28 +- tests/test_cancel_streaming.py | 42 +- tests/test_computer_action.py | 6 +- tests/test_computer_tool_lifecycle.py | 44 +- tests/test_error_logging_redaction.py | 50 +- tests/test_example_workflows.py | 221 +- tests/test_global_hooks.py | 36 +- tests/test_guardrails.py | 238 +- tests/test_handoff_history_duplication.py | 419 ++-- tests/test_hitl_error_scenarios.py | 92 +- tests/test_hitl_session_scenario.py | 86 +- tests/test_invalid_final_output_handler.py | 42 +- tests/test_local_shell_tool.py | 21 +- tests/test_max_turns.py | 62 +- tests/test_pretty_print.py | 26 +- tests/test_process_model_response.py | 58 +- tests/test_programmatic_tool_calling.py | 171 +- tests/test_prompt_cache_key.py | 78 +- tests/test_repl.py | 22 +- tests/test_responses_tracing.py | 12 +- tests/test_run.py | 15 +- tests/test_run_config.py | 48 +- tests/test_run_error_details.py | 10 +- tests/test_run_hooks.py | 64 +- tests/test_run_impl_resume_paths.py | 4 +- tests/test_run_state.py | 296 +-- tests/test_run_state_pending_input.py | 154 +- tests/test_runner_guardrail_resume.py | 6 +- tests/test_runtime_symmetry_contract.py | 29 +- tests/test_scripted_model.py | 2205 +++++++++++++++++ tests/test_scripted_sandbox.py | 559 +++++ tests/test_server_conversation_tracker.py | 21 +- tests/test_shell_call_serialization.py | 6 +- tests/test_soft_cancel.py | 76 +- tests/test_stream_events.py | 77 +- tests/test_stream_input_guardrail_timing.py | 34 +- .../test_streamed_terminal_output_backfill.py | 138 +- tests/test_streaming_logging.py | 6 +- tests/test_streaming_tool_call_arguments.py | 212 +- tests/test_tool_approval_call_id_reuse.py | 360 +-- tests/test_tool_choice_reset.py | 48 +- tests/test_tool_custom_data.py | 25 +- tests/test_tool_guardrails.py | 6 +- tests/test_tool_name_collision_policy.py | 372 +-- tests/test_tool_origin.py | 51 +- tests/test_tracing_errors.py | 58 +- tests/test_tracing_errors_streamed.py | 42 +- tests/test_usage.py | 6 +- tests/utils/hitl.py | 29 +- tests/voice/fake_models.py | 115 - tests/voice/pipeline_test_models.py | 90 + tests/voice/test_openai_stt.py | 18 +- tests/voice/test_pipeline.py | 227 +- tests/voice/test_testing.py | 543 ++++ tests/voice/test_workflow.py | 91 +- 115 files changed, 11256 insertions(+), 4956 deletions(-) delete mode 100644 integration_tests/_fake_model.py create mode 100644 src/agents/realtime/testing.py create mode 100644 src/agents/testing/__init__.py create mode 100644 src/agents/testing/model.py create mode 100644 src/agents/testing/sandbox.py create mode 100644 src/agents/voice/testing.py delete mode 100644 tests/fake_model.py create mode 100644 tests/model_test_helpers.py create mode 100644 tests/realtime/test_testing.py create mode 100644 tests/test_scripted_model.py create mode 100644 tests/test_scripted_sandbox.py delete mode 100644 tests/voice/fake_models.py create mode 100644 tests/voice/pipeline_test_models.py create mode 100644 tests/voice/test_testing.py diff --git a/AGENTS.md b/AGENTS.md index eb0383d5ca..3cb7790981 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,8 @@ The OpenAI Agents Python repository provides the Python Agents SDK, examples, an Before submitting changes, ensure relevant checks pass and extend tests when you touch code. +For provider-neutral agent workflow tests, prefer `ScriptedModel` from `agents.testing` over adding a new mock or fake `Model`. Prefer `ScriptedRealtimeModel` from `agents.realtime.testing` for Realtime session tests, the scripted utilities from `agents.voice.testing` for Voice pipeline tests, and `scripted_sandbox_session()` from `agents.testing` for deterministic Sandbox session calls. Keep a specialized test double only when the test specifically requires provider-wire conversion, malformed streams, controlled suspension or concurrency, or an exact cancellation or lifecycle boundary that the scripted utilities cannot preserve; document that boundary in the test. + Before adding or changing async, retry, timeout, subprocess, PTY, warning, or xdist-sensitive tests, read [Performance and determinism](tests/README.md#performance-and-determinism) and preserve the applicable behavioral and lifecycle coverage while optimizing execution. When `$code-change-verification` applies, run it to execute the required verification stack from the repository root. Rerun the full stack after applying fixes. diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index dcc437a3c2..7f51c0236f 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -1712,7 +1712,7 @@ async def validate_historical_resume_behavior( from agents import Agent, Runner, RunState, function_tool from agents.items import ToolCallOutputItem, TResponseOutputItem - from integration_tests._fake_model import QueuedFakeModel + from agents.testing import ModelStep, ScriptedModel invocation_count = 0 if feature == "canonical_invocation_identity": @@ -1771,7 +1771,9 @@ def historical_approval(account_id: str) -> str: ], ) model_turns.append([final_message]) - model = QueuedFakeModel(model_turns) + model = ScriptedModel( + [ModelStep(output=turn, response_id="queued-fake-response") for turn in model_turns] + ) agent = Agent(name="compat-agent", model=model, tools=[tool]) payload = json.loads(path.read_text(encoding="utf-8")) restored = await RunState.from_json(agent, payload) diff --git a/integration_tests/_fake_model.py b/integration_tests/_fake_model.py deleted file mode 100644 index 6b5a9099a3..0000000000 --- a/integration_tests/_fake_model.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncIterator, Sequence -from copy import deepcopy -from typing import Any, cast - -from openai.types.responses.response_prompt_param import ResponsePromptParam - -from agents.agent_output import AgentOutputSchemaBase -from agents.handoffs import Handoff -from agents.items import ( - ModelResponse, - TResponseInputItem, - TResponseOutputItem, - TResponseStreamEvent, -) -from agents.model_settings import ModelSettings -from agents.models.interface import Model, ModelTracing -from agents.tool import Tool -from agents.usage import Usage - - -class QueuedFakeModel(Model): - """Deterministic non-streaming model for installed-distribution contracts.""" - - def __init__(self, turns: Sequence[Sequence[TResponseOutputItem]]) -> None: - self._turns = [list(turn) for turn in turns] - self.requests: list[dict[str, Any]] = [] - - def _record_request( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - previous_response_id: str | None, - conversation_id: str | None, - prompt: ResponsePromptParam | None, - ) -> None: - self.requests.append( - { - "system_instructions": system_instructions, - "input": deepcopy(input), - "model_settings": model_settings, - "tools": list(tools), - "output_schema": output_schema, - "handoffs": list(handoffs), - "tracing": tracing, - "previous_response_id": previous_response_id, - "conversation_id": conversation_id, - "prompt": deepcopy(prompt), - } - ) - - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: ResponsePromptParam | None, - ) -> ModelResponse: - self._record_request( - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id, - conversation_id, - prompt, - ) - if not self._turns: - raise AssertionError("QueuedFakeModel received an unexpected model request") - return ModelResponse( - output=self._turns.pop(0), - usage=Usage(requests=1), - response_id="queued-fake-response", - ) - - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: ResponsePromptParam | None, - ) -> AsyncIterator[TResponseStreamEvent]: - self._record_request( - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id, - conversation_id, - prompt, - ) - if False: - yield cast(TResponseStreamEvent, None) - raise AssertionError("QueuedFakeModel does not support streaming") diff --git a/integration_tests/security/test_local_sandbox_isolation.py b/integration_tests/security/test_local_sandbox_isolation.py index 7f91f6cb6e..d029850ded 100644 --- a/integration_tests/security/test_local_sandbox_isolation.py +++ b/integration_tests/security/test_local_sandbox_isolation.py @@ -28,8 +28,8 @@ SandboxSessionEvent, ) from agents.sandbox.snapshot import NoopSnapshotSpec +from agents.testing import ModelStep, ScriptedModel, UnexpectedModelCall from integration_tests._contract_support import _redaction_observables -from integration_tests._fake_model import QueuedFakeModel from integration_tests.conftest import skip_or_fail pytestmark = pytest.mark.security @@ -204,7 +204,9 @@ async def delete(self, session: SandboxSession) -> SandboxSession: turns: list[list[TResponseOutputItem]] = [[tool_call]] if not fail_after_inspection: turns.append([final_message]) - model = QueuedFakeModel(turns) + model = ScriptedModel( + [ModelStep(output=turn, response_id="queued-fake-response") for turn in turns] + ) client = _CredentialOwningDockerClient(trusted_credential=sentinel) nested_mount_source = tmp_path / "nested-mount-probe" nested_mount_source.mkdir() @@ -234,8 +236,8 @@ async def delete(self, session: SandboxSession) -> SandboxSession: with caplog.at_level(logging.DEBUG): if fail_after_inspection: with pytest.raises( - AssertionError, - match="QueuedFakeModel received an unexpected model request", + UnexpectedModelCall, + match="no scripted steps remain", ) as exc_info: await Runner.run( agent, @@ -277,24 +279,12 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass docker_client.close() - expected_model_request_fields = { - "system_instructions", - "input", - "model_settings", - "tools", - "output_schema", - "handoffs", - "tracing", - "previous_response_id", - "conversation_id", - "prompt", - } - assert model.requests - assert all(set(request) == expected_model_request_fields for request in model.requests) - model_requests = repr(model.requests) + assert model.calls + assert all(call.streamed is False for call in model.calls) + model_requests = repr(model.calls) model_visible_tool_outputs: list[object] = [] - for request in model.requests: - model_input = request["input"] + for call in model.calls: + model_input = call.input if not isinstance(model_input, list): continue model_visible_tool_outputs.extend( diff --git a/integration_tests/voice/test_voice_pipeline.py b/integration_tests/voice/test_voice_pipeline.py index 60b4af9e4d..adb2817c1d 100644 --- a/integration_tests/voice/test_voice_pipeline.py +++ b/integration_tests/voice/test_voice_pipeline.py @@ -217,22 +217,11 @@ async def test_voice_pipeline_surfaces_tts_failures_without_hanging( from agents.voice import ( AudioInput, SingleAgentVoiceWorkflow, - TTSModel, - TTSModelSettings, VoicePipeline, VoiceStreamEventLifecycle, ) from agents.voice.events import VoiceStreamEventError - - class FailingTTSModel(TTSModel): - @property - def model_name(self) -> str: - return "failing-packaged-tts" - - async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: - del text, settings - raise RuntimeError("Packaged TTS synthesis failed.") - yield b"" # pragma: no cover + from agents.voice.testing import ScriptedTTSModel agent: Agent[Any] = Agent( name="Packaged failing voice workflow agent", @@ -242,7 +231,10 @@ async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[byte ) pipeline = VoicePipeline( workflow=SingleAgentVoiceWorkflow(agent), - tts_model=FailingTTSModel(), + tts_model=ScriptedTTSModel( + [RuntimeError("Packaged TTS synthesis failed.")], + model_name="failing-packaged-tts", + ), config={"tracing_disabled": True}, ) audio = np.frombuffer(integration_pcm_audio, dtype=np.int16).copy() diff --git a/src/agents/realtime/testing.py b/src/agents/realtime/testing.py new file mode 100644 index 0000000000..94f586abff --- /dev/null +++ b/src/agents/realtime/testing.py @@ -0,0 +1,478 @@ +"""Deterministic Realtime model transport for session tests.""" + +from __future__ import annotations + +import asyncio +import copy +from collections import deque +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass, field +from typing import TypeAlias, cast + +from typing_extensions import Required, TypedDict + +from ..models._trace import sanitize_url_for_trace +from .config import RealtimeSessionModelSettings +from .model import ( + RealtimeModel, + RealtimeModelConfig, + RealtimeModelListener, + RealtimePlaybackTracker, +) +from .model_events import RealtimeModelErrorEvent, RealtimeModelEvent, RealtimeModelExceptionEvent +from .model_inputs import RealtimeModelSendEvent, RealtimeModelSendSessionUpdate + + +class RealtimeScriptError(Exception): + """Base exception for an invalid or incompletely consumed Realtime script.""" + + +class UnexpectedRealtimeSend(RealtimeScriptError): + """Raised when an outbound event does not match the next scripted step.""" + + def __init__( + self, + message: str, + *, + actual: RealtimeModelSendEvent, + expected: RealtimeSendMatcher | None, + ) -> None: + super().__init__(message) + self.actual = actual + self.expected = expected + + +class UnconsumedRealtimeSteps(RealtimeScriptError): + """Raised when a test finishes before consuming every configured send step.""" + + def __init__(self, message: str, *, remaining_steps: int) -> None: + super().__init__(message) + self.remaining_steps = remaining_steps + + +RealtimeSendMatcher: TypeAlias = ( + RealtimeModelSendEvent | type[RealtimeModelSendEvent] | Callable[[RealtimeModelSendEvent], bool] +) + + +@dataclass(frozen=True) +class RealtimeStep: + """One expected outbound event and the normalized inbound events it triggers.""" + + expect: RealtimeSendMatcher + emit: Sequence[RealtimeModelEvent] = field(default_factory=tuple) + error: Exception | None = None + + def __post_init__(self) -> None: + frozen_emit = tuple(self.emit) + if frozen_emit and self.error is not None: + raise ValueError("A RealtimeStep cannot define both emit events and an error.") + object.__setattr__(self, "emit", frozen_emit) + + +class RealtimeConnectCall(TypedDict, total=False): + """A credential-free snapshot of one Realtime connection call.""" + + api_key_provided: Required[bool] + headers_provided: Required[bool] + url: str + initial_model_settings: RealtimeSessionModelSettings + playback_tracker: RealtimePlaybackTracker + call_id: str + + +@dataclass +class _RealtimeDeliveryResult: + done: asyncio.Future[BaseException | None] + pending_deliveries: int = 1 + error: BaseException | None = None + + +@dataclass(frozen=True) +class _QueuedRealtimeDelivery: + events: Sequence[RealtimeModelEvent] + error: Exception | None + result: _RealtimeDeliveryResult + committed_close_calls: int + + +class ScriptedRealtimeModel(RealtimeModel): + """An in-memory, listener-based Realtime transport with deterministic send steps.""" + + def __init__( + self, + steps: Iterable[RealtimeStep] = (), + *, + connect_events: Iterable[RealtimeModelEvent] = (), + connect_error: Exception | None = None, + close_error: Exception | None = None, + strict: bool = True, + ) -> None: + connect_event_values = tuple(connect_events) + if connect_event_values and connect_error is not None: + raise ValueError( + "A ScriptedRealtimeModel cannot define both connect events and a connect error." + ) + self._steps = [_snapshot_realtime_step(step) for step in steps] + self._connect_events = tuple(_snapshot_model_event(event) for event in connect_event_values) + self._connect_error = connect_error + self._close_error = close_error + self._strict = strict + self._listeners: list[RealtimeModelListener] = [] + self._send_lock = asyncio.Lock() + self._delivery_queue: deque[_QueuedRealtimeDelivery] = deque() + self._delivery_worker: asyncio.Task[None] | None = None + self._active_delivery_result: _RealtimeDeliveryResult | None = None + self._connect_calls: list[RealtimeConnectCall] = [] + self._sent_events: list[RealtimeModelSendEvent] = [] + self.connected = False + self.closed = False + self.close_calls = 0 + + @property + def listeners(self) -> tuple[RealtimeModelListener, ...]: + """Return the currently registered listeners.""" + return tuple(self._listeners) + + @property + def connect_calls(self) -> tuple[RealtimeConnectCall, ...]: + """Return detached snapshots of recorded connection calls.""" + return tuple(_clone_connect_call(call) for call in self._connect_calls) + + @property + def sent_events(self) -> tuple[RealtimeModelSendEvent, ...]: + """Return detached snapshots of recorded outbound events.""" + return tuple(self._snapshot_send_event(event) for event in self._sent_events) + + @property + def remaining_steps(self) -> int: + """Return the number of expected outbound sends that remain.""" + return len(self._steps) + + async def connect(self, options: RealtimeModelConfig) -> None: + if self.connected or self._delivery_worker is not None: + raise AssertionError("Already connected") + self._connect_calls.append(_snapshot_connect_call(options)) + if self._connect_error is not None: + raise self._connect_error + self.connected = True + self.closed = False + try: + async with self._send_lock: + self._ensure_emit_allowed() + event_snapshots = tuple( + _snapshot_model_event(event) for event in self._connect_events + ) + result, reentrant = self._queue_delivery_locked(events=event_snapshots) + await self._finish_queued_delivery(result, reentrant) + except BaseException: + self.connected = False + self.closed = True + raise + + def add_listener(self, listener: RealtimeModelListener) -> None: + if listener not in self._listeners: + self._listeners.append(listener) + + def remove_listener(self, listener: RealtimeModelListener) -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + async def send_event(self, event: RealtimeModelSendEvent) -> None: + result, reentrant = await self._commit_send(event) + await self._finish_queued_delivery(result, reentrant) + + async def send_event_if( + self, + event: RealtimeModelSendEvent, + send_if: Callable[[], bool], + ) -> bool: + async with self._send_lock: + self._ensure_sendable() + if not send_if(): + return False + result, reentrant = self._commit_send_locked(event) + await self._finish_queued_delivery(result, reentrant) + return True + + async def emit(self, *events: RealtimeModelEvent) -> None: + """Deliver normalized model events to all current listeners in order.""" + async with self._send_lock: + self._ensure_emit_allowed() + event_snapshots = tuple(_snapshot_model_event(event) for event in events) + result, reentrant = self._queue_delivery_locked(events=event_snapshots) + await self._finish_queued_delivery(result, reentrant) + + async def _broadcast_events( + self, + events: Sequence[RealtimeModelEvent], + *, + committed_close_calls: int, + ) -> None: + for event in events: + listeners = tuple(self._listeners) + for listener in listeners: + if self.closed or self.close_calls != committed_close_calls: + return + await listener.on_event(event) + + async def close(self) -> None: + self.close_calls += 1 + if self.closed: + return + self.connected = False + self.closed = True + if self._close_error is not None: + raise self._close_error + + def assert_complete(self) -> None: + """Raise when expected outbound send steps remain unconsumed.""" + if self._steps: + raise UnconsumedRealtimeSteps( + f"{len(self._steps)} scripted Realtime step(s) were not consumed.", + remaining_steps=len(self._steps), + ) + + async def _commit_send( + self, event: RealtimeModelSendEvent + ) -> tuple[_RealtimeDeliveryResult, bool]: + async with self._send_lock: + self._ensure_sendable() + return self._commit_send_locked(event) + + def _commit_send_locked( + self, event: RealtimeModelSendEvent + ) -> tuple[_RealtimeDeliveryResult, bool]: + event_snapshot = self._snapshot_send_event(event) + step = self._pop_matching_step(event, actual_snapshot=event_snapshot) + self._sent_events.append(event_snapshot) + return self._queue_delivery_locked( + events=step.emit if step is not None else (), + error=step.error if step is not None else None, + ) + + def _queue_delivery_locked( + self, + *, + events: Sequence[RealtimeModelEvent], + error: Exception | None = None, + ) -> tuple[_RealtimeDeliveryResult, bool]: + current_task = asyncio.current_task() + if current_task is None: + raise RuntimeError("A scripted Realtime send requires an active asyncio task.") + active_result = self._active_delivery_result + if current_task is self._delivery_worker and active_result is not None: + reentrant = True + result = active_result + result.pending_deliveries += 1 + else: + reentrant = False + result = _RealtimeDeliveryResult(done=asyncio.get_running_loop().create_future()) + self._delivery_queue.append( + _QueuedRealtimeDelivery( + events=tuple(events), + error=error, + result=result, + committed_close_calls=self.close_calls, + ) + ) + self._ensure_delivery_worker_locked() + return result, reentrant + + async def _finish_queued_delivery( + self, + result: _RealtimeDeliveryResult, + reentrant: bool, + ) -> None: + if reentrant: + return + error = await asyncio.shield(result.done) + if error is not None: + raise error + + def _ensure_delivery_worker_locked(self) -> None: + if self._delivery_worker is None or self._delivery_worker.done(): + self._delivery_worker = asyncio.create_task(self._drain_committed_sends()) + + async def _drain_committed_sends(self) -> None: + while True: + async with self._send_lock: + if not self._delivery_queue: + self._active_delivery_result = None + self._delivery_worker = None + return + delivery = self._delivery_queue.popleft() + self._active_delivery_result = delivery.result + error = await self._deliver_queued_events(delivery) + result = delivery.result + if error is not None and result.error is None: + result.error = error + result.pending_deliveries -= 1 + if result.pending_deliveries == 0 and not result.done.done(): + result.done.set_result(result.error) + + async def _deliver_queued_events( + self, delivery: _QueuedRealtimeDelivery + ) -> BaseException | None: + try: + if delivery.error is not None: + raise delivery.error + self._ensure_emit_allowed(delivery.committed_close_calls) + await self._broadcast_events( + delivery.events, + committed_close_calls=delivery.committed_close_calls, + ) + except BaseException as error: + return error + return None + + def _ensure_sendable(self) -> None: + if not self.connected or self.closed: + raise RealtimeScriptError( + "Cannot send an event while the scripted model is disconnected." + ) + + def _ensure_emit_allowed( + self, + committed_close_calls: int | None = None, + ) -> None: + if ( + not self.connected + or self.closed + or (committed_close_calls is not None and self.close_calls != committed_close_calls) + ): + raise RealtimeScriptError( + "Cannot emit events while the scripted model is disconnected." + ) + + def _pop_matching_step( + self, + event: RealtimeModelSendEvent, + *, + actual_snapshot: RealtimeModelSendEvent, + ) -> RealtimeStep | None: + if not self._steps: + if not self._strict: + return None + raise UnexpectedRealtimeSend( + "Unexpected Realtime send: no scripted steps remain.", + actual=actual_snapshot, + expected=None, + ) + step = self._steps[0] + if not _matches(step.expect, event): + if not self._strict: + return None + raise UnexpectedRealtimeSend( + "Unexpected Realtime send: event did not match the next scripted expectation.", + actual=actual_snapshot, + expected=_snapshot_realtime_expectation(step.expect), + ) + return self._steps.pop(0) + + @staticmethod + def _snapshot_send_event(event: RealtimeModelSendEvent) -> RealtimeModelSendEvent: + return _snapshot_send_event(event) + + +def _snapshot_realtime_step(step: RealtimeStep) -> RealtimeStep: + return RealtimeStep( + expect=_snapshot_realtime_expectation(step.expect), + emit=tuple(_snapshot_model_event(event) for event in step.emit), + error=step.error, + ) + + +def _snapshot_realtime_expectation(expectation: RealtimeSendMatcher) -> RealtimeSendMatcher: + if isinstance(expectation, type) or callable(expectation): + return expectation + return _snapshot_send_event(expectation) + + +def _snapshot_send_event(event: RealtimeModelSendEvent) -> RealtimeModelSendEvent: + if isinstance(event, RealtimeModelSendSessionUpdate): + return RealtimeModelSendSessionUpdate( + session_settings=_snapshot_model_settings(event.session_settings) + ) + return copy.deepcopy(event) + + +def _snapshot_model_event(event: RealtimeModelEvent) -> RealtimeModelEvent: + if isinstance(event, RealtimeModelExceptionEvent): + return RealtimeModelExceptionEvent( + exception=event.exception, + context=copy.deepcopy(event.context), + ) + if isinstance(event, RealtimeModelErrorEvent) and isinstance(event.error, Exception): + return RealtimeModelErrorEvent(error=event.error) + return copy.deepcopy(event) + + +def _matches(expectation: RealtimeSendMatcher, event: RealtimeModelSendEvent) -> bool: + if isinstance(expectation, type): + return isinstance(event, expectation) + if callable(expectation): + return expectation(_snapshot_send_event(event)) + return event == expectation + + +def _snapshot_connect_call(options: RealtimeModelConfig) -> RealtimeConnectCall: + snapshot: RealtimeConnectCall = { + "api_key_provided": "api_key" in options, + "headers_provided": "headers" in options, + } + if "url" in options: + snapshot["url"] = sanitize_url_for_trace(options["url"]) + if "initial_model_settings" in options: + snapshot["initial_model_settings"] = _snapshot_model_settings( + options["initial_model_settings"] + ) + if "playback_tracker" in options: + snapshot["playback_tracker"] = options["playback_tracker"] + if "call_id" in options: + snapshot["call_id"] = options["call_id"] + return snapshot + + +def _clone_connect_call(call: RealtimeConnectCall) -> RealtimeConnectCall: + snapshot: RealtimeConnectCall = { + "api_key_provided": call["api_key_provided"], + "headers_provided": call["headers_provided"], + } + if "url" in call: + snapshot["url"] = call["url"] + if "initial_model_settings" in call: + snapshot["initial_model_settings"] = _snapshot_model_settings( + call["initial_model_settings"] + ) + if "playback_tracker" in call: + snapshot["playback_tracker"] = call["playback_tracker"] + if "call_id" in call: + snapshot["call_id"] = call["call_id"] + return snapshot + + +def _snapshot_model_settings( + settings: RealtimeSessionModelSettings, +) -> RealtimeSessionModelSettings: + snapshot = cast( + RealtimeSessionModelSettings, + copy.deepcopy( + {key: value for key, value in settings.items() if key not in {"tools", "handoffs"}} + ), + ) + if "tools" in settings: + snapshot["tools"] = list(settings["tools"]) + if "handoffs" in settings: + snapshot["handoffs"] = list(settings["handoffs"]) + return snapshot + + +__all__ = [ + "RealtimeConnectCall", + "RealtimeScriptError", + "RealtimeStep", + "ScriptedRealtimeModel", + "UnconsumedRealtimeSteps", + "UnexpectedRealtimeSend", +] diff --git a/src/agents/testing/__init__.py b/src/agents/testing/__init__.py new file mode 100644 index 0000000000..91fde0feba --- /dev/null +++ b/src/agents/testing/__init__.py @@ -0,0 +1,45 @@ +"""Deterministic test doubles for Agents SDK workflows.""" + +from .model import ( + InvalidModelStep, + ModelCall, + ModelScriptError, + ModelStep, + ModelStepSpec, + ScriptedModel, + UnconsumedModelSteps, + UnexpectedModelCall, + assistant_message, + function_call, +) +from .sandbox import ( + InvalidSandboxStep, + SandboxCall, + SandboxCallMatcherError, + SandboxScriptError, + SandboxStepSpec, + UnconsumedSandboxSteps, + UnexpectedSandboxCall, + scripted_sandbox_session, +) + +__all__ = [ + "InvalidModelStep", + "ModelCall", + "ModelScriptError", + "ModelStep", + "ModelStepSpec", + "InvalidSandboxStep", + "SandboxCall", + "SandboxCallMatcherError", + "SandboxScriptError", + "SandboxStepSpec", + "ScriptedModel", + "UnconsumedModelSteps", + "UnexpectedModelCall", + "UnconsumedSandboxSteps", + "UnexpectedSandboxCall", + "assistant_message", + "function_call", + "scripted_sandbox_session", +] diff --git a/src/agents/testing/model.py b/src/agents/testing/model.py new file mode 100644 index 0000000000..eafecb3b21 --- /dev/null +++ b/src/agents/testing/model.py @@ -0,0 +1,1198 @@ +from __future__ import annotations + +import copy +import inspect +import json +import sys +from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Iterable, + Iterator, + Mapping, + Sequence, +) +from contextlib import contextmanager +from dataclasses import dataclass, field, replace +from typing import Any, Literal, TypeAlias, cast + +from openai.types.responses import ( + Response, + ResponseApplyPatchToolCall, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseInProgressEvent, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, + ResponseOutputTextAnnotationAddedEvent, + ResponseReasoningSummaryPartAddedEvent, + ResponseReasoningSummaryPartDoneEvent, + ResponseReasoningSummaryTextDeltaEvent, + ResponseReasoningSummaryTextDoneEvent, + ResponseReasoningTextDeltaEvent, + ResponseReasoningTextDoneEvent, + ResponseRefusalDeltaEvent, + ResponseRefusalDoneEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, + ResponseUsage, +) +from openai.types.responses.response_prompt_param import ResponsePromptParam +from openai.types.responses.response_reasoning_item import ResponseReasoningItem +from openai.types.responses.response_reasoning_summary_part_added_event import ( + Part as AddedEventPart, +) +from openai.types.responses.response_reasoning_summary_part_done_event import Part as DoneEventPart +from openai.types.responses.response_text_delta_event import ( + Logprob as ResponseTextDeltaLogprob, + LogprobTopLogprob as ResponseTextDeltaTopLogprob, +) +from openai.types.responses.response_text_done_event import ( + Logprob as ResponseTextDoneLogprob, + LogprobTopLogprob as ResponseTextDoneTopLogprob, +) +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails +from typing_extensions import TypedDict + +from .._tool_invocation import tool_invocation_call_id +from ..agent_output import AgentOutputSchemaBase +from ..exceptions import ModelBehaviorError +from ..handoffs import Handoff +from ..items import ModelResponse, TResponseInputItem, TResponseOutputItem, TResponseStreamEvent +from ..model_settings import ModelSettings +from ..models.interface import Model, ModelTracing +from ..retry import ModelRetryAdvice, ModelRetryAdviceRequest +from ..tool import Tool +from ..tracing import SpanError, generation_span +from ..tracing.scope import Scope +from ..usage import ( + Usage, + _attach_normalized_usage, + _attach_raw_usage_snapshot, + _raw_usage_snapshot, +) +from ..util._error_tracing import REDACTED_TRACE_ERROR_MESSAGE + + +class ModelScriptError(Exception): + """Base exception for an invalid or incompletely consumed model script.""" + + +ModelStepReason: TypeAlias = Literal[ + "invalid_input", + "unsupported_field", + "invalid_error", + "invalid_responder", + "invalid_stream_events", + "conflicting_outcomes", + "invalid_retry_advice", +] + + +class InvalidModelStep(ModelScriptError): + """Raised when a model step is invalid before it enters the script queue.""" + + def __init__( + self, + message: str, + *, + reason: ModelStepReason, + input_index: int, + ) -> None: + super().__init__(message) + self.reason = reason + self.input_index = input_index + + +class UnexpectedModelCall(ModelScriptError): + """Raised when the model is called after all configured steps were consumed.""" + + def __init__(self, message: str, *, call: ModelCall, call_index: int) -> None: + super().__init__(message) + self.call = call + self.call_index = call_index + + +class UnconsumedModelSteps(ModelScriptError): + """Raised when a test finishes before consuming every configured step.""" + + def __init__(self, message: str, *, remaining_steps: int) -> None: + super().__init__(message) + self.remaining_steps = remaining_steps + + +@dataclass(frozen=True) +class ModelCall: + """A recorded call at the provider-neutral ``Model`` boundary.""" + + system_instructions: str | None + input: Any + model_settings: ModelSettings + tools: list[Tool] + output_schema: AgentOutputSchemaBase | None + handoffs: list[Handoff] + tracing: ModelTracing + previous_response_id: str | None + conversation_id: str | None + prompt: ResponsePromptParam | None + streamed: bool + + +def _snapshot_model_call(call: ModelCall) -> ModelCall: + return ModelCall( + system_instructions=call.system_instructions, + input=copy.deepcopy(call.input), + model_settings=copy.deepcopy(call.model_settings), + tools=list(call.tools), + output_schema=call.output_schema, + handoffs=list(call.handoffs), + tracing=call.tracing, + previous_response_id=call.previous_response_id, + conversation_id=call.conversation_id, + prompt=copy.deepcopy(call.prompt), + streamed=call.streamed, + ) + + +@dataclass +class ModelStep: + """One deterministic model call result. + + ``output`` uses the normalized SDK output-item boundary. Set ``error`` to raise from the model + call, ``responder`` to derive the result from the recorded call, or ``stream_events`` to supply + an exact normalized event stream for advanced streaming tests. ``ScriptedModel`` also accepts + the equivalent dictionary form described by ``ModelStepSpec``. + """ + + output: Sequence[TResponseOutputItem] = field(default_factory=tuple) + usage: Usage = field(default_factory=Usage) + response_id: str | None = "resp-789" + request_id: str | None = None + raw_usage: dict[str, Any] | None = None + error: Exception | None = None + responder: ModelResponder | None = None + stream_events: Sequence[TResponseStreamEvent] | ModelStreamFactory | None = None + retry_advice: ModelRetryAdvice | None = None + + @classmethod + def raise_error( + cls, + error: Exception, + *, + retry_advice: ModelRetryAdvice | None = None, + ) -> ModelStep: + """Create a step that raises ``error`` with optional provider retry guidance.""" + return cls(error=error, retry_advice=retry_advice) + + @classmethod + def respond(cls, responder: ModelResponder) -> ModelStep: + """Create a step whose result is derived from the recorded call.""" + return cls(responder=responder) + + @classmethod + def stream( + cls, + events: Sequence[TResponseStreamEvent] | ModelStreamFactory, + *, + output: Sequence[TResponseOutputItem] = (), + usage: Usage | None = None, + response_id: str | None = "resp-789", + ) -> ModelStep: + """Create a step with an exact normalized stream-event sequence or factory.""" + stream_events = events if callable(events) else tuple(events) + return cls( + output=output, + usage=usage or Usage(), + response_id=response_id, + stream_events=stream_events, + ) + + +class ModelStepSpec(TypedDict, total=False): + """Dictionary form of ``ModelStep`` accepted by ``ScriptedModel``.""" + + output: Sequence[TResponseOutputItem] + usage: Usage + response_id: str | None + request_id: str | None + raw_usage: dict[str, Any] | None + error: Exception | None + responder: ModelResponder | None + stream_events: Sequence[TResponseStreamEvent] | ModelStreamFactory | None + retry_advice: ModelRetryAdvice | None + + +_MODEL_STEP_FIELDS = frozenset(ModelStepSpec.__annotations__) + + +ModelStepResult: TypeAlias = ( + ModelStep | ModelStepSpec | ModelResponse | Sequence[TResponseOutputItem] | Exception +) +ModelResponder: TypeAlias = Callable[[ModelCall], ModelStepResult | Awaitable[ModelStepResult]] +ModelStreamFactory: TypeAlias = Callable[[ModelCall], AsyncIterator[TResponseStreamEvent]] +ModelScriptItem: TypeAlias = ModelStepResult + + +class ScriptedModel(Model): + """A deterministic provider-neutral model for testing agent workflows. + + Each step may be a ``ModelStep``, an equivalent ``ModelStepSpec`` dictionary, a + ``ModelResponse``, a normalized output-item sequence, or an exception. + """ + + def __init__( + self, + steps: Iterable[ModelScriptItem] = (), + *, + emit_traces: bool = False, + default_usage: Usage | None = None, + ) -> None: + self._steps = [ + self._coerce_step(step, input_index) for input_index, step in enumerate(steps) + ] + self._emit_traces = emit_traces + self._default_usage = copy.deepcopy(default_usage) + self._calls: list[ModelCall] = [] + self._retry_advice_by_error_id: dict[int, tuple[Exception, ModelRetryAdvice]] = {} + + @property + def calls(self) -> tuple[ModelCall, ...]: + """Return detached snapshots of recorded model calls.""" + return tuple(_snapshot_model_call(call) for call in self._calls) + + @property + def remaining_steps(self) -> int: + """Return the number of configured model calls that have not run yet.""" + return len(self._steps) + + @property + def first_call(self) -> ModelCall | None: + """Return the first recorded call, if any.""" + return _snapshot_model_call(self._calls[0]) if self._calls else None + + @property + def last_call(self) -> ModelCall | None: + """Return the most recent recorded call, if any.""" + return _snapshot_model_call(self._calls[-1]) if self._calls else None + + def enqueue(self, step: ModelScriptItem) -> None: + """Append one model step.""" + self._steps.append(self._coerce_step(step, 0)) + + def extend(self, steps: Iterable[ModelScriptItem]) -> None: + """Append multiple model steps.""" + normalized = [ + self._coerce_step(step, input_index) for input_index, step in enumerate(steps) + ] + self._steps.extend(normalized) + + def set_default_usage(self, usage: Usage | None) -> None: + """Set usage for scripted steps that do not provide their own usage.""" + self._default_usage = copy.deepcopy(usage) + + def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None: + """Return retry advice attached to the exact scripted error that was raised.""" + configured = self._retry_advice_by_error_id.get(id(request.error)) + if configured is None or configured[0] is not request.error: + return None + return copy.deepcopy(configured[1]) + + def assert_complete(self) -> None: + """Raise when configured steps remain unconsumed.""" + if self._steps: + raise UnconsumedModelSteps( + f"{len(self._steps)} scripted model step(s) were not consumed.", + remaining_steps=len(self._steps), + ) + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> ModelResponse: + call = self._record_call( + system_instructions=system_instructions, + input=input, + model_settings=model_settings, + tools=tools, + output_schema=output_schema, + handoffs=handoffs, + tracing=tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + streamed=False, + ) + with generation_span(disabled=not self._emit_traces) as span: + retry_advice_synced = False + try: + step = await self._next_resolved_step(call) + if step.error is not None: + self._remember_retry_advice(step) + retry_advice_synced = True + raise step.error + return self._model_response(step, call.model_settings) + except Exception as error: + if not retry_advice_synced: + self._forget_retry_advice(error) + self._set_span_error(span, error, call.tracing) + raise + + async def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> AsyncIterator[TResponseStreamEvent]: + call = self._record_call( + system_instructions=system_instructions, + input=input, + model_settings=model_settings, + tools=tools, + output_schema=output_schema, + handoffs=handoffs, + tracing=tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + streamed=True, + ) + span = generation_span(disabled=not self._emit_traces) + span.start(mark_as_current=False) + retry_advice_synced = False + try: + with _mark_span_current(span): + step = await self._next_resolved_step(call) + if step.error is not None: + self._remember_retry_advice(step) + retry_advice_synced = True + raise step.error + if callable(step.stream_events): + with _mark_span_current(span): + stream = step.stream_events(call) + try: + while True: + try: + with _mark_span_current(span): + event = await anext(stream) + except StopAsyncIteration: + break + yield event + finally: + aclose = getattr(stream, "aclose", None) + if callable(aclose): + active_error = sys.exc_info()[1] + try: + with _mark_span_current(span): + await aclose() + except BaseException: + if active_error is None: + raise + return + if step.stream_events is not None: + for event in step.stream_events: + yield event + return + with _mark_span_current(span): + events = _stream_events_for_step( + step, + preserve_raw_usage=call.model_settings.preserve_raw_usage is True, + ) + for event in events: + yield event + except Exception as error: + if not retry_advice_synced: + self._forget_retry_advice(error) + self._set_span_error(span, error, call.tracing) + raise + finally: + span.finish(reset_current=False) + + def _record_call( + self, + *, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + streamed: bool, + ) -> ModelCall: + recorded_input = copy.deepcopy(input) + call = ModelCall( + system_instructions=system_instructions, + input=recorded_input, + model_settings=copy.deepcopy(model_settings), + tools=list(tools), + output_schema=output_schema, + handoffs=list(handoffs), + tracing=tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=copy.deepcopy(prompt), + streamed=streamed, + ) + execution_call = _snapshot_model_call(call) + self._calls.append(call) + return execution_call + + async def _next_resolved_step(self, call: ModelCall) -> ModelStep: + if not self._steps: + mode = "streaming" if call.streamed else "non-streaming" + call_index = len(self._calls) - 1 + raise UnexpectedModelCall( + f"Unexpected {mode} model call #{call_index + 1}: no scripted steps remain.", + call=call, + call_index=call_index, + ) + step = self._steps.pop(0) + while step.responder is not None: + result = step.responder(call) + if inspect.isawaitable(result): + result = await result + step = self._coerce_step(result, 0) + if step.usage == Usage(): + usage = self._default_usage if self._default_usage is not None else Usage(requests=1) + else: + usage = step.usage + return replace(step, usage=copy.deepcopy(usage)) + + @staticmethod + def _coerce_step( + step: ModelScriptItem | ModelStepResult, + input_index: int, + ) -> ModelStep: + if isinstance(step, ModelStep): + normalized = step + elif isinstance(step, ModelResponse): + normalized = ModelStep( + output=step.output, + usage=step.usage, + response_id=step.response_id, + request_id=step.request_id, + raw_usage=step.raw_usage, + ) + elif isinstance(step, Exception): + normalized = ModelStep.raise_error(step) + elif isinstance(step, Mapping): + unsupported = [field for field in step if field not in _MODEL_STEP_FIELDS] + if unsupported: + raise _invalid_model_step( + reason="unsupported_field", + input_index=input_index, + detail="contains unsupported fields", + ) + normalized = ModelStep(**cast(ModelStepSpec, dict(step))) + else: + normalized = ModelStep(output=step) + _validate_model_step(normalized, input_index=input_index) + return _snapshot_model_step(normalized) + + def _remember_retry_advice(self, step: ModelStep) -> None: + if step.error is None: + return + if step.retry_advice is None: + self._forget_retry_advice(step.error) + return + self._retry_advice_by_error_id[id(step.error)] = ( + step.error, + copy.deepcopy(step.retry_advice), + ) + + def _forget_retry_advice(self, error: Exception) -> None: + self._retry_advice_by_error_id.pop(id(error), None) + + @staticmethod + def _model_response(step: ModelStep, model_settings: ModelSettings) -> ModelResponse: + return ModelResponse( + output=_convert_output_items(step.output), + usage=step.usage, + response_id=step.response_id, + request_id=step.request_id, + raw_usage=( + _raw_usage_snapshot(step.raw_usage) + if model_settings.preserve_raw_usage is True + else None + ), + ) + + @staticmethod + def _set_span_error(span: Any, error: Exception, tracing: ModelTracing) -> None: + try: + if tracing.include_data(): + try: + error_message = str(error) + except BaseException: + error_message = f"Unrenderable {type(error).__name__}" + else: + error_message = REDACTED_TRACE_ERROR_MESSAGE + span.set_error( + SpanError( + message="Error", + data={"name": error.__class__.__name__, "message": error_message}, + ) + ) + except BaseException: + pass + + +def assistant_message(text: str, *, item_id: str = "scripted-message") -> TResponseOutputItem: + """Build one normalized assistant text output item.""" + return ResponseOutputMessage( + id=item_id, + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText( + text=text, + type="output_text", + annotations=[], + logprobs=[], + ) + ], + ) + + +@contextmanager +def _mark_span_current(span: Any) -> Iterator[None]: + token = Scope.set_current_span(span) + try: + yield + finally: + Scope.reset_current_span(token) + + +def function_call( + name: str, + arguments: str | Mapping[str, Any], + *, + call_id: str, + item_id: str | None = None, + namespace: str | None = None, +) -> TResponseOutputItem: + """Build one normalized function-tool call output item.""" + serialized_arguments = ( + arguments + if isinstance(arguments, str) + else json.dumps(arguments, ensure_ascii=False, separators=(",", ":")) + ) + kwargs: dict[str, Any] = { + "id": call_id if item_id is None else item_id, + "call_id": call_id, + "type": "function_call", + "name": name, + "arguments": serialized_arguments, + } + if namespace is not None: + kwargs["namespace"] = namespace + return ResponseFunctionToolCall(**kwargs) + + +def _convert_output_items( + output: Sequence[TResponseOutputItem], +) -> list[TResponseOutputItem]: + converted: list[TResponseOutputItem] = [] + for item in output: + if isinstance(item, dict) and item.get("type") == "apply_patch_call": + call_identity = tool_invocation_call_id(item) + call_id = call_identity[1] if call_identity is not None else None + if call_id is None: + raise ModelBehaviorError( + "Tool invocations require a non-empty string call ID before execution." + ) + if "id" in item: + item_id = item["id"] + if not isinstance(item_id, str) or not item_id: + raise ModelBehaviorError( + "Apply-patch tool calls require a non-empty string item ID when provided." + ) + else: + item_id = call_id + converted.append( + cast( + TResponseOutputItem, + ResponseApplyPatchToolCall( + type="apply_patch_call", + id=item_id, + call_id=call_id, + status=item["status"] if "status" in item else "completed", + operation=item.get("operation"), + caller=item.get("caller"), + ), + ) + ) + else: + converted.append(item) + return converted + + +def _snapshot_model_step(step: ModelStep) -> ModelStep: + stream_events = step.stream_events + if stream_events is not None and not callable(stream_events): + stream_events = copy.deepcopy(stream_events) + return ModelStep( + output=copy.deepcopy(step.output), + usage=copy.deepcopy(step.usage), + response_id=step.response_id, + request_id=step.request_id, + raw_usage=copy.deepcopy(step.raw_usage), + error=step.error, + responder=step.responder, + stream_events=stream_events, + retry_advice=copy.deepcopy(step.retry_advice), + ) + + +def _invalid_model_step( + *, + reason: ModelStepReason, + input_index: int, + detail: str, +) -> InvalidModelStep: + return InvalidModelStep( + f"Scripted model step #{input_index + 1} {detail}.", + reason=reason, + input_index=input_index, + ) + + +def _validate_model_step(step: ModelStep, *, input_index: int) -> None: + if not isinstance(step.output, Sequence) or isinstance(step.output, str | bytes): + raise _invalid_model_step( + reason="invalid_input", + input_index=input_index, + detail="must use a sequence for output", + ) + if not isinstance(step.usage, Usage): + raise _invalid_model_step( + reason="invalid_input", + input_index=input_index, + detail="must use Usage for usage", + ) + if step.response_id is not None and not isinstance(step.response_id, str): + raise _invalid_model_step( + reason="invalid_input", + input_index=input_index, + detail="must use a string or None for response_id", + ) + if step.request_id is not None and not isinstance(step.request_id, str): + raise _invalid_model_step( + reason="invalid_input", + input_index=input_index, + detail="must use a string or None for request_id", + ) + if step.raw_usage is not None and not isinstance(step.raw_usage, dict): + raise _invalid_model_step( + reason="invalid_input", + input_index=input_index, + detail="must use a dictionary or None for raw_usage", + ) + if step.error is not None and not isinstance(step.error, Exception): + raise _invalid_model_step( + reason="invalid_error", + input_index=input_index, + detail="must use an Exception for error", + ) + if step.responder is not None and not callable(step.responder): + raise _invalid_model_step( + reason="invalid_responder", + input_index=input_index, + detail="must use a callable responder", + ) + if ( + step.stream_events is not None + and not callable(step.stream_events) + and ( + not isinstance(step.stream_events, Sequence) + or isinstance(step.stream_events, str | bytes) + ) + ): + raise _invalid_model_step( + reason="invalid_stream_events", + input_index=input_index, + detail="must use a sequence or callable stream_events value", + ) + selected = sum(value is not None for value in (step.error, step.responder, step.stream_events)) + if selected > 1: + raise _invalid_model_step( + reason="conflicting_outcomes", + input_index=input_index, + detail="cannot combine error, responder, and stream_events outcomes", + ) + if step.retry_advice is not None: + if not isinstance(step.retry_advice, ModelRetryAdvice) or step.error is None: + raise _invalid_model_step( + reason="invalid_retry_advice", + input_index=input_index, + detail="requires an error and a ModelRetryAdvice value", + ) + + +def _stream_events_for_step( + step: ModelStep, + *, + preserve_raw_usage: bool, +) -> list[TResponseStreamEvent]: + output = _convert_output_items(step.output) + unsupported_item = next( + ( + item + for item in output + if not isinstance( + item, + ResponseApplyPatchToolCall + | ResponseFunctionToolCall + | ResponseOutputMessage + | ResponseReasoningItem, + ) + ), + None, + ) + if unsupported_item is not None: + raise ModelBehaviorError( + f"Automatic streaming does not support {type(unsupported_item).__name__}. " + "Use ModelStep.stream(...) to provide exact normalized stream events." + ) + response = _response_for_step( + step, + output, + ) + in_progress_response = response.model_copy( + update={"output": [], "status": "in_progress", "usage": None} + ) + if preserve_raw_usage and step.raw_usage is not None: + _attach_raw_usage_snapshot(response, step.raw_usage) + events: list[TResponseStreamEvent] = [] + sequence_number = 0 + + events.append( + cast( + TResponseStreamEvent, + ResponseCreatedEvent( + type="response.created", + response=copy.deepcopy(in_progress_response), + sequence_number=sequence_number, + ), + ) + ) + sequence_number += 1 + events.append( + cast( + TResponseStreamEvent, + ResponseInProgressEvent( + type="response.in_progress", + response=copy.deepcopy(in_progress_response), + sequence_number=sequence_number, + ), + ) + ) + sequence_number += 1 + + for output_index, output_item in enumerate(output): + events.append( + cast( + TResponseStreamEvent, + ResponseOutputItemAddedEvent( + type="response.output_item.added", + item=copy.deepcopy(_in_progress_output_item(output_item)), + output_index=output_index, + sequence_number=sequence_number, + ), + ) + ) + sequence_number += 1 + + if isinstance(output_item, ResponseReasoningItem): + for summary_index, summary in enumerate(output_item.summary or []): + events.extend( + [ + cast( + TResponseStreamEvent, + ResponseReasoningSummaryPartAddedEvent( + type="response.reasoning_summary_part.added", + item_id=output_item.id, + output_index=output_index, + summary_index=summary_index, + part=AddedEventPart(text="", type=summary.type), + sequence_number=sequence_number, + ), + ), + cast( + TResponseStreamEvent, + ResponseReasoningSummaryTextDeltaEvent( + type="response.reasoning_summary_text.delta", + item_id=output_item.id, + output_index=output_index, + summary_index=summary_index, + delta=summary.text, + sequence_number=sequence_number + 1, + ), + ), + cast( + TResponseStreamEvent, + ResponseReasoningSummaryTextDoneEvent( + type="response.reasoning_summary_text.done", + item_id=output_item.id, + output_index=output_index, + summary_index=summary_index, + text=summary.text, + sequence_number=sequence_number + 2, + ), + ), + cast( + TResponseStreamEvent, + ResponseReasoningSummaryPartDoneEvent( + type="response.reasoning_summary_part.done", + item_id=output_item.id, + output_index=output_index, + summary_index=summary_index, + part=DoneEventPart(text=summary.text, type=summary.type), + sequence_number=sequence_number + 3, + ), + ), + ] + ) + sequence_number += 4 + for content_index, content in enumerate(output_item.content or []): + events.extend( + [ + cast( + TResponseStreamEvent, + ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + delta=content.text, + sequence_number=sequence_number, + ), + ), + cast( + TResponseStreamEvent, + ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + text=content.text, + sequence_number=sequence_number + 1, + ), + ), + ] + ) + sequence_number += 2 + elif isinstance(output_item, ResponseFunctionToolCall): + item_id = output_item.call_id if output_item.id is None else output_item.id + events.extend( + [ + cast( + TResponseStreamEvent, + ResponseFunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + item_id=item_id, + output_index=output_index, + delta=output_item.arguments, + sequence_number=sequence_number, + ), + ), + cast( + TResponseStreamEvent, + ResponseFunctionCallArgumentsDoneEvent( + type="response.function_call_arguments.done", + item_id=item_id, + output_index=output_index, + arguments=output_item.arguments, + name=output_item.name, + sequence_number=sequence_number + 1, + ), + ), + ] + ) + sequence_number += 2 + elif isinstance(output_item, ResponseOutputMessage): + for content_index, content_part in enumerate(output_item.content or []): + if isinstance(content_part, ResponseOutputText): + delta_logprobs = [ + ResponseTextDeltaLogprob( + token=logprob.token, + logprob=logprob.logprob, + top_logprobs=[ + ResponseTextDeltaTopLogprob( + token=top_logprob.token, + logprob=top_logprob.logprob, + ) + for top_logprob in logprob.top_logprobs + ], + ) + for logprob in content_part.logprobs or [] + ] + done_logprobs = [ + ResponseTextDoneLogprob( + token=logprob.token, + logprob=logprob.logprob, + top_logprobs=[ + ResponseTextDoneTopLogprob( + token=top_logprob.token, + logprob=top_logprob.logprob, + ) + for top_logprob in logprob.top_logprobs + ], + ) + for logprob in content_part.logprobs or [] + ] + events.extend( + [ + cast( + TResponseStreamEvent, + ResponseContentPartAddedEvent( + type="response.content_part.added", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + part=content_part.model_copy( + deep=True, + update={"annotations": [], "logprobs": [], "text": ""}, + ), + sequence_number=sequence_number, + ), + ), + cast( + TResponseStreamEvent, + ResponseTextDeltaEvent( + type="response.output_text.delta", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + delta=content_part.text, + logprobs=delta_logprobs, + sequence_number=sequence_number + 1, + ), + ), + ] + ) + sequence_number += 2 + for annotation_index, annotation in enumerate(content_part.annotations or []): + events.append( + cast( + TResponseStreamEvent, + ResponseOutputTextAnnotationAddedEvent( + type="response.output_text.annotation.added", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + annotation_index=annotation_index, + annotation=copy.deepcopy(annotation), + sequence_number=sequence_number, + ), + ) + ) + sequence_number += 1 + events.extend( + [ + cast( + TResponseStreamEvent, + ResponseTextDoneEvent( + type="response.output_text.done", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + text=content_part.text, + logprobs=done_logprobs, + sequence_number=sequence_number, + ), + ), + cast( + TResponseStreamEvent, + ResponseContentPartDoneEvent( + type="response.content_part.done", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + part=copy.deepcopy(content_part), + sequence_number=sequence_number + 1, + ), + ), + ] + ) + sequence_number += 2 + elif isinstance(content_part, ResponseOutputRefusal): + events.extend( + [ + cast( + TResponseStreamEvent, + ResponseContentPartAddedEvent( + type="response.content_part.added", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + part=content_part.model_copy( + deep=True, + update={"refusal": ""}, + ), + sequence_number=sequence_number, + ), + ), + cast( + TResponseStreamEvent, + ResponseRefusalDeltaEvent( + type="response.refusal.delta", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + delta=content_part.refusal, + sequence_number=sequence_number + 1, + ), + ), + cast( + TResponseStreamEvent, + ResponseRefusalDoneEvent( + type="response.refusal.done", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + refusal=content_part.refusal, + sequence_number=sequence_number + 2, + ), + ), + cast( + TResponseStreamEvent, + ResponseContentPartDoneEvent( + type="response.content_part.done", + item_id=output_item.id, + output_index=output_index, + content_index=content_index, + part=copy.deepcopy(content_part), + sequence_number=sequence_number + 3, + ), + ), + ] + ) + sequence_number += 4 + + events.append( + cast( + TResponseStreamEvent, + ResponseOutputItemDoneEvent( + type="response.output_item.done", + item=copy.deepcopy(output_item), + output_index=output_index, + sequence_number=sequence_number, + ), + ) + ) + sequence_number += 1 + + events.append( + cast( + TResponseStreamEvent, + ResponseCompletedEvent( + type="response.completed", + response=response, + sequence_number=sequence_number, + ), + ) + ) + return events + + +def _in_progress_output_item(output_item: TResponseOutputItem) -> TResponseOutputItem: + if isinstance(output_item, ResponseApplyPatchToolCall): + return output_item.model_copy(update={"status": "in_progress"}) + if isinstance(output_item, ResponseOutputMessage): + return output_item.model_copy(update={"content": [], "status": "in_progress"}) + if isinstance(output_item, ResponseReasoningItem): + return output_item.model_copy( + update={ + "content": [] if output_item.content is not None else None, + "encrypted_content": None, + "status": "in_progress", + "summary": [], + } + ) + if isinstance(output_item, ResponseFunctionToolCall): + return output_item.model_copy(update={"arguments": "", "status": "in_progress"}) + return output_item + + +def _response_for_step( + step: ModelStep, + output: list[TResponseOutputItem], +) -> Response: + usage = step.usage + response_usage = _response_usage_for_usage(usage) + _attach_normalized_usage(response_usage, usage) + object.__setattr__(response_usage, "_agents_sdk_request_count", usage.requests) + if usage.request_usage_entries: + object.__setattr__( + response_usage, + "_agents_sdk_request_usages", + [_response_usage_for_usage(entry) for entry in usage.request_usage_entries], + ) + response = Response( + id=step.response_id if step.response_id is not None else "scripted-response", + created_at=0, + model="scripted-model", + object="response", + output=output, + tool_choice="none", + tools=[], + top_p=None, + parallel_tool_calls=False, + status="completed", + usage=response_usage, + ) + if step.response_id is None: + # The normalized Model boundary permits an absent response ID, although Response does not. + object.__setattr__(response, "id", None) + if step.request_id is not None: + response._request_id = step.request_id + return response + + +def _response_usage_for_usage(usage: Any) -> ResponseUsage: + return ResponseUsage( + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + total_tokens=usage.total_tokens, + input_tokens_details=InputTokensDetails.model_validate( + { + "cache_write_tokens": getattr(usage.input_tokens_details, "cache_write_tokens", 0), + "cached_tokens": getattr(usage.input_tokens_details, "cached_tokens", 0), + } + ), + output_tokens_details=OutputTokensDetails( + reasoning_tokens=getattr(usage.output_tokens_details, "reasoning_tokens", 0) + ), + ) diff --git a/src/agents/testing/sandbox.py b/src/agents/testing/sandbox.py new file mode 100644 index 0000000000..1354459747 --- /dev/null +++ b/src/agents/testing/sandbox.py @@ -0,0 +1,570 @@ +from __future__ import annotations + +import copy +import inspect +import io +from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Literal, TypeAlias, cast, get_args + +from typing_extensions import TypedDict + +from ..editor import ApplyPatchOperation +from ..sandbox.apply_patch import PatchFormat +from ..sandbox.files import FileEntry +from ..sandbox.manifest import Manifest +from ..sandbox.session.base_sandbox_session import BaseSandboxSession +from ..sandbox.session.pty_types import PtyExecUpdate +from ..sandbox.session.sandbox_session_state import SandboxSessionState +from ..sandbox.snapshot import NoopSnapshot +from ..sandbox.types import ExecResult, User + +SandboxMethod = Literal[ + "apply_patch", + "exec", + "ls", + "mkdir", + "pty_exec_start", + "pty_write_stdin", + "read", + "rm", + "write", +] +SandboxStepReason = Literal["invalid_input", "unknown_method", "invalid_matcher", "invalid_outcome"] + +_SCRIPTABLE_METHODS: frozenset[str] = frozenset(get_args(SandboxMethod)) +_PTY_METHODS: frozenset[SandboxMethod] = frozenset({"pty_exec_start", "pty_write_stdin"}) +_UNSUPPORTED_OPTIONAL_METHODS: frozenset[str] = frozenset( + { + "extract", + "hydrate_workspace", + "persist_workspace", + "resolve_exposed_port", + } +) + +_HIDDEN_LIFECYCLE_METHODS: frozenset[str] = frozenset({"pty_terminate_all"}) + +for _method_name in _SCRIPTABLE_METHODS | _UNSUPPORTED_OPTIONAL_METHODS | _HIDDEN_LIFECYCLE_METHODS: + if not hasattr(BaseSandboxSession, _method_name): + raise RuntimeError(f"Unknown BaseSandboxSession method: {_method_name}") + + +class SandboxScriptError(Exception): + """Base exception for an invalid or incompletely consumed sandbox script.""" + + +class InvalidSandboxStep(SandboxScriptError): + """Raised when a sandbox step is invalid at factory construction time.""" + + def __init__( + self, + message: str, + *, + reason: SandboxStepReason, + input_index: int, + method: str | None, + ) -> None: + super().__init__(message) + self.reason = reason + self.input_index = input_index + self.method = method + + +class UnexpectedSandboxCall(SandboxScriptError): + """Raised when a call does not match the next configured sandbox step.""" + + def __init__( + self, + message: str, + *, + call: SandboxCall, + call_index: int, + expected_method: str | None, + remaining_steps: int, + ) -> None: + super().__init__(message) + self.call = call + self.call_index = call_index + self.actual_method = call.method + self.expected_method = expected_method + self.remaining_steps = remaining_steps + + +class SandboxCallMatcherError(SandboxScriptError): + """Raised when a sandbox step matcher rejects its call.""" + + def __init__(self, message: str, *, call: SandboxCall, call_index: int) -> None: + super().__init__(message) + self.call = call + self.call_index = call_index + self.method = call.method + + +class UnconsumedSandboxSteps(SandboxScriptError): + """Raised when configured sandbox steps remain unconsumed.""" + + def __init__( + self, + message: str, + *, + remaining_steps: int, + pending_methods: tuple[str, ...], + ) -> None: + super().__init__(message) + self.remaining_steps = remaining_steps + self.pending_methods = pending_methods + + +@dataclass(frozen=True) +class SandboxCall: + """A detached invocation-time sandbox call snapshot.""" + + call_index: int + method: str + args: tuple[Any, ...] + kwargs: Mapping[str, Any] + + +SandboxMatcher: TypeAlias = Callable[[SandboxCall], bool | None] +SandboxResponder: TypeAlias = Callable[[SandboxCall], Any | Awaitable[Any]] + + +class SandboxStepSpec(TypedDict, total=False): + """Dictionary form of one FIFO scripted sandbox call.""" + + method: SandboxMethod + match: SandboxMatcher + result: Any + responder: SandboxResponder + error: Exception + + +_STEP_FIELDS = frozenset(SandboxStepSpec.__annotations__) + + +@dataclass(frozen=True) +class _SandboxStep: + method: SandboxMethod + match: SandboxMatcher | None + outcome: Literal["result", "responder", "error"] + value: Any + + +def _snapshot_value(value: Any) -> Any: + if isinstance(value, io.BytesIO): + if value.closed: + raise TypeError("Cannot snapshot a closed sandbox byte stream.") + byte_snapshot = io.BytesIO(value.getvalue()) + byte_snapshot.seek(value.tell()) + return byte_snapshot + if isinstance(value, io.StringIO): + if value.closed: + raise TypeError("Cannot snapshot a closed sandbox text stream.") + text_snapshot = io.StringIO(value.getvalue()) + text_snapshot.seek(value.tell()) + return text_snapshot + if isinstance(value, io.IOBase): + raise TypeError("Sandbox stream snapshots support only io.BytesIO and io.StringIO.") + if callable(value): + return value + if isinstance(value, tuple): + return tuple(_snapshot_value(item) for item in value) + if isinstance(value, list): + return [_snapshot_value(item) for item in value] + if isinstance(value, dict): + return {_snapshot_value(key): _snapshot_value(item) for key, item in value.items()} + if isinstance(value, set): + return {_snapshot_value(item) for item in value} + return copy.deepcopy(value) + + +def _snapshot_call(call: SandboxCall) -> SandboxCall: + return SandboxCall( + call_index=call.call_index, + method=call.method, + args=tuple(_snapshot_value(call.args)), + kwargs=MappingProxyType( + {name: _snapshot_value(value) for name, value in call.kwargs.items()} + ), + ) + + +def _invalid_step( + *, + reason: SandboxStepReason, + input_index: int, + method: str | None, + detail: str, +) -> InvalidSandboxStep: + return InvalidSandboxStep( + f"Scripted sandbox step #{input_index + 1} {detail}.", + reason=reason, + input_index=input_index, + method=method, + ) + + +def _normalize_step(input: object, input_index: int) -> _SandboxStep: + if not isinstance(input, Mapping): + raise _invalid_step( + reason="invalid_input", + input_index=input_index, + method=None, + detail="must be a mapping", + ) + + method = input.get("method") + if any(field not in _STEP_FIELDS for field in input): + raise _invalid_step( + reason="invalid_input", + input_index=input_index, + method=method if isinstance(method, str) else None, + detail="contains unsupported fields", + ) + if not isinstance(method, str) or method not in _SCRIPTABLE_METHODS: + raise _invalid_step( + reason="unknown_method", + input_index=input_index, + method=method if isinstance(method, str) else None, + detail="has an unknown method", + ) + + matcher = input.get("match") + if isinstance(matcher, io.IOBase): + raise _invalid_step( + reason="invalid_matcher", + input_index=input_index, + method=method, + detail=f"for {method} must use a non-stream callable matcher", + ) + if matcher is not None and not callable(matcher): + raise _invalid_step( + reason="invalid_matcher", + input_index=input_index, + method=method, + detail=f"for {method} must use a callable matcher", + ) + + outcomes = [name for name in ("result", "responder", "error") if name in input] + if len(outcomes) != 1: + raise _invalid_step( + reason="invalid_outcome", + input_index=input_index, + method=method, + detail=f"for {method} must define exactly one outcome", + ) + outcome = cast(Literal["result", "responder", "error"], outcomes[0]) + value = input[outcome] + if outcome == "responder" and isinstance(value, io.IOBase): + raise _invalid_step( + reason="invalid_outcome", + input_index=input_index, + method=method, + detail=f"for {method} must use a non-stream callable responder", + ) + if outcome == "responder" and not callable(value): + raise _invalid_step( + reason="invalid_outcome", + input_index=input_index, + method=method, + detail=f"for {method} must use a callable responder", + ) + if outcome == "error" and not isinstance(value, Exception): + raise _invalid_step( + reason="invalid_outcome", + input_index=input_index, + method=method, + detail=f"for {method} must use an Exception", + ) + if outcome == "result": + try: + value = _snapshot_value(value) + except TypeError as error: + raise _invalid_step( + reason="invalid_outcome", + input_index=input_index, + method=method, + detail=f"for {method} contains a result that cannot be snapshotted", + ) from error + return _SandboxStep( + method=cast(SandboxMethod, method), + match=cast(SandboxMatcher | None, matcher), + outcome=outcome, + value=value, + ) + + +class _ScriptedSandboxSession(BaseSandboxSession): + def __init__(self, steps: Sequence[_SandboxStep], *, manifest: Manifest | None) -> None: + self.state = SandboxSessionState( + type="scripted", + snapshot=NoopSnapshot(id="scripted"), + manifest=copy.deepcopy(manifest) if manifest is not None else Manifest(), + ) + self._steps = list(steps) + configured_methods = {step.method for step in steps} + if configured_methods & _PTY_METHODS: + configured_methods.update(_PTY_METHODS) + self._configured_methods = frozenset(configured_methods) + self._calls: list[SandboxCall] = [] + self._running = False + + def __getattribute__(self, name: str) -> Any: + if name in _SCRIPTABLE_METHODS: + configured = object.__getattribute__(self, "_configured_methods") + if name not in configured: + raise AttributeError(name) + if name in _UNSUPPORTED_OPTIONAL_METHODS | _HIDDEN_LIFECYCLE_METHODS: + raise AttributeError(name) + return super().__getattribute__(name) + + def __dir__(self) -> list[str]: + configured = self._configured_methods + return sorted( + name + for name in super().__dir__() + if name not in _UNSUPPORTED_OPTIONAL_METHODS | _HIDDEN_LIFECYCLE_METHODS + and (name not in _SCRIPTABLE_METHODS or name in configured) + ) + + @property + def calls(self) -> tuple[SandboxCall, ...]: + """Return detached call-history snapshots in invocation order.""" + return tuple(_snapshot_call(call) for call in self._calls) + + @property + def remaining_steps(self) -> int: + """Return the number of configured calls that remain.""" + return len(self._steps) + + def assert_complete(self) -> None: + """Raise when configured sandbox calls remain unconsumed.""" + if self._steps: + pending_methods = tuple(step.method for step in self._steps) + raise UnconsumedSandboxSteps( + f"Scripted sandbox session has {len(self._steps)} unconsumed step(s).", + remaining_steps=len(self._steps), + pending_methods=pending_methods, + ) + + async def _invoke( + self, method: SandboxMethod, args: tuple[Any, ...], kwargs: dict[str, Any] + ) -> Any: + call = SandboxCall( + call_index=len(self._calls), + method=method, + args=tuple(_snapshot_value(args)), + kwargs=MappingProxyType( + {name: _snapshot_value(value) for name, value in kwargs.items()} + ), + ) + call_index = call.call_index + self._calls.append(call) + + if not self._steps: + raise UnexpectedSandboxCall( + f"Unexpected sandbox {method} call #{call_index + 1}: no scripted steps remain.", + call=_snapshot_call(call), + call_index=call_index, + expected_method=None, + remaining_steps=0, + ) + + step = self._steps[0] + if step.method != method: + raise UnexpectedSandboxCall( + f"Unexpected sandbox {method} call #{call_index + 1}; expected {step.method}.", + call=_snapshot_call(call), + call_index=call_index, + expected_method=step.method, + remaining_steps=len(self._steps), + ) + + matcher_call = _snapshot_call(call) + if step.match is not None and step.match(matcher_call) is False: + raise SandboxCallMatcherError( + f"Sandbox matcher rejected {method} call #{call_index + 1}.", + call=_snapshot_call(call), + call_index=call_index, + ) + self._steps.pop(0) + if step.outcome == "error": + raise step.value + if step.outcome == "responder": + result = step.value(_snapshot_call(call)) + if inspect.isawaitable(result): + result = await result + return result + return step.value + + async def start(self) -> None: + self._running = True + self.state.workspace_root_ready = True + + async def stop(self) -> None: + self._running = False + + async def _before_shutdown(self) -> None: + return + + async def running(self) -> bool: + return self._running + + def supports_pty(self) -> bool: + return _PTY_METHODS.issubset(self._configured_methods) + + async def exec( + self, + *command: str | Path, + timeout: float | None = None, + shell: bool | list[str] = True, + user: str | User | None = None, + ) -> ExecResult: + return cast( + ExecResult, + await self._invoke( + "exec", + command, + {"timeout": timeout, "shell": shell, "user": user}, + ), + ) + + async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase: + return cast(io.IOBase, await self._invoke("read", (path,), {"user": user})) + + async def write( + self, + path: Path, + data: io.IOBase, + *, + user: str | User | None = None, + ) -> None: + await self._invoke("write", (path, data), {"user": user}) + + async def ls( + self, + path: Path | str, + *, + user: str | User | None = None, + ) -> list[FileEntry]: + return cast(list[FileEntry], await self._invoke("ls", (path,), {"user": user})) + + async def rm( + self, + path: Path | str, + *, + recursive: bool = False, + user: str | User | None = None, + ) -> None: + await self._invoke("rm", (path,), {"recursive": recursive, "user": user}) + + async def mkdir( + self, + path: Path | str, + *, + parents: bool = False, + user: str | User | None = None, + ) -> None: + await self._invoke("mkdir", (path,), {"parents": parents, "user": user}) + + async def apply_patch( + self, + operations: ApplyPatchOperation + | dict[str, object] + | list[ApplyPatchOperation | dict[str, object]], + *, + patch_format: PatchFormat | Literal["v4a"] = "v4a", + ) -> str: + return cast( + str, + await self._invoke( + "apply_patch", + (operations,), + {"patch_format": patch_format}, + ), + ) + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + _ = (command, timeout) + raise NotImplementedError + + async def persist_workspace(self) -> io.IOBase: + raise NotImplementedError + + async def hydrate_workspace(self, data: io.IOBase) -> None: + _ = data + raise NotImplementedError + + async def pty_exec_start( + self, + *command: str | Path, + timeout: float | None = None, + shell: bool | list[str] = True, + user: str | User | None = None, + tty: bool = False, + yield_time_s: float | None = None, + max_output_tokens: int | None = None, + ) -> PtyExecUpdate: + return cast( + PtyExecUpdate, + await self._invoke( + "pty_exec_start", + command, + { + "timeout": timeout, + "shell": shell, + "user": user, + "tty": tty, + "yield_time_s": yield_time_s, + "max_output_tokens": max_output_tokens, + }, + ), + ) + + async def pty_write_stdin( + self, + *, + session_id: int, + chars: str, + yield_time_s: float | None = None, + max_output_tokens: int | None = None, + ) -> PtyExecUpdate: + return cast( + PtyExecUpdate, + await self._invoke( + "pty_write_stdin", + (), + { + "session_id": session_id, + "chars": chars, + "yield_time_s": yield_time_s, + "max_output_tokens": max_output_tokens, + }, + ), + ) + + +def scripted_sandbox_session( + steps: Iterable[SandboxStepSpec | Mapping[str, Any]] = (), + *, + manifest: Manifest | None = None, +) -> _ScriptedSandboxSession: + """Create a deterministic provider-free sandbox session for agent workflow tests. + + Each FIFO step defines ``method`` plus exactly one of ``result``, ``responder``, or ``error``. + An optional ``match`` callable receives a detached ``SandboxCall``. The returned object is the + session itself, so pass it directly to ``SandboxRunConfig(session=session)``. Only configured + model-facing methods are visible. The two PTY methods are exposed together when either one is + configured because they form one advertised session capability. Use a custom + ``BaseSandboxSession`` or a real provider for lifecycle, persistence, mount, or broader + filesystem behavior. + """ + normalized = [_normalize_step(step, index) for index, step in enumerate(steps)] + return _ScriptedSandboxSession(normalized, manifest=manifest) diff --git a/src/agents/usage.py b/src/agents/usage.py index 28e482e77a..a71f9155df 100644 --- a/src/agents/usage.py +++ b/src/agents/usage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import json from collections.abc import Mapping from dataclasses import field @@ -11,6 +12,7 @@ from pydantic.dataclasses import dataclass _RAW_USAGE_ATTRIBUTE = "_agents_sdk_raw_usage" +_NORMALIZED_USAGE_ATTRIBUTE = "_agents_sdk_normalized_usage" _RAW_USAGE_ADAPTER = TypeAdapter(dict[str, JsonValue]) _RAW_USAGE_MISSING = object() @@ -333,6 +335,10 @@ def _requests_for_response_without_usage(response: Any) -> int: def _response_usage_to_usage(response_usage: Any) -> Usage: """Convert Responses API usage, including adapter-supplied per-request details.""" + normalized_usage = getattr(response_usage, _NORMALIZED_USAGE_ATTRIBUTE, None) + if isinstance(normalized_usage, Usage): + return copy.deepcopy(normalized_usage) + request_usages = getattr(response_usage, "_agents_sdk_request_usages", None) request_count = getattr(response_usage, "_agents_sdk_request_count", 1) @@ -362,6 +368,11 @@ def _response_usage_to_usage(response_usage: Any) -> Usage: ) +def _attach_normalized_usage(target: Any, usage: Usage) -> None: + """Attach a detached normalized usage snapshot for lossless internal conversion.""" + object.__setattr__(target, _NORMALIZED_USAGE_ATTRIBUTE, copy.deepcopy(usage)) + + def _serialize_usage_details(details: Any, default: dict[str, int]) -> dict[str, Any]: """Serialize token details while applying the given default when empty.""" if hasattr(details, "model_dump"): diff --git a/src/agents/voice/testing.py b/src/agents/voice/testing.py new file mode 100644 index 0000000000..0d53e8cc55 --- /dev/null +++ b/src/agents/voice/testing.py @@ -0,0 +1,421 @@ +"""Deterministic speech and workflow components for Voice pipeline tests.""" + +from __future__ import annotations + +import copy +from collections.abc import AsyncIterator, Iterable, Sequence +from dataclasses import dataclass, field +from typing import Any + +from .imports import np +from .input import AudioInput, StreamedAudioInput +from .model import ( + StreamedTranscriptionSession, + STTModel, + STTModelSettings, + TTSModel, + TTSModelSettings, +) +from .workflow import VoiceWorkflowBase + + +class VoiceScriptError(Exception): + """Base exception for an invalid or incompletely consumed Voice script.""" + + +class UnexpectedVoiceCall(VoiceScriptError): + """Raised when a Voice component is called after its script is exhausted.""" + + def __init__(self, message: str, *, operation: str) -> None: + super().__init__(message) + self.operation = operation + + +class UnconsumedVoiceSteps(VoiceScriptError): + """Raised when a test finishes before consuming every configured Voice step.""" + + def __init__(self, message: str, *, remaining_steps: int) -> None: + super().__init__(message) + self.remaining_steps = remaining_steps + + +@dataclass(frozen=True) +class STTCall: + """A recorded static transcription call.""" + + input: AudioInput + settings: STTModelSettings + trace_include_sensitive_data: bool + trace_include_sensitive_audio_data: bool + + +@dataclass(frozen=True) +class STTSessionCall: + """A recorded streamed transcription-session creation call.""" + + input: StreamedAudioInput + settings: STTModelSettings + trace_include_sensitive_data: bool + trace_include_sensitive_audio_data: bool + + +@dataclass(frozen=True) +class TTSCall: + """A recorded text-to-speech call.""" + + text: str + settings: TTSModelSettings + + +@dataclass(frozen=True) +class TTSResult: + """The PCM byte chunks returned by one text-to-speech call.""" + + chunks: Sequence[bytes] = field(default_factory=tuple) + + +TranscriptionResult = str | Exception +TTSResultItem = TTSResult | Sequence[bytes] | Exception +WorkflowResult = str | Sequence[str] | Exception + +_START_NOT_CONFIGURED: Any = object() + + +def _snapshot_audio_input(input: AudioInput) -> AudioInput: + return AudioInput( + buffer=input.buffer.copy(), + frame_rate=input.frame_rate, + sample_width=input.sample_width, + channels=input.channels, + ) + + +def _snapshot_stt_call(call: STTCall) -> STTCall: + return STTCall( + input=_snapshot_audio_input(call.input), + settings=copy.deepcopy(call.settings), + trace_include_sensitive_data=call.trace_include_sensitive_data, + trace_include_sensitive_audio_data=call.trace_include_sensitive_audio_data, + ) + + +def _snapshot_stt_session_call(call: STTSessionCall) -> STTSessionCall: + return STTSessionCall( + input=call.input, + settings=copy.deepcopy(call.settings), + trace_include_sensitive_data=call.trace_include_sensitive_data, + trace_include_sensitive_audio_data=call.trace_include_sensitive_audio_data, + ) + + +def _snapshot_tts_call(call: TTSCall) -> TTSCall: + return TTSCall(text=call.text, settings=copy.deepcopy(call.settings)) + + +class ScriptedTranscriptionSession(StreamedTranscriptionSession): + """A closable stream of configured transcription turns.""" + + def __init__( + self, + turns: str | Iterable[TranscriptionResult] = (), + *, + close_error: Exception | None = None, + ) -> None: + self._turns: list[TranscriptionResult] = [turns] if isinstance(turns, str) else list(turns) + self._close_error = close_error + self.closed = False + self.close_calls = 0 + + async def transcribe_turns(self) -> AsyncIterator[str]: + while self._turns and not self.closed: + turn = self._turns.pop(0) + if isinstance(turn, Exception): + raise turn + yield turn + + async def close(self) -> None: + self.close_calls += 1 + if self.closed: + return + self.closed = True + if self._close_error is not None: + raise self._close_error + + def assert_complete(self) -> None: + """Raise when configured transcript turns remain unconsumed.""" + if self._turns: + raise UnconsumedVoiceSteps( + f"{len(self._turns)} scripted transcription turn(s) were not consumed.", + remaining_steps=len(self._turns), + ) + + +class ScriptedSTTModel(STTModel): + """A deterministic speech-to-text model for static and streamed audio tests.""" + + def __init__( + self, + transcriptions: str | Iterable[TranscriptionResult] = (), + *, + sessions: str + | Iterable[ScriptedTranscriptionSession | Iterable[TranscriptionResult] | Exception] = (), + model_name: str = "scripted-stt", + ) -> None: + self._transcriptions: list[TranscriptionResult] = ( + [transcriptions] if isinstance(transcriptions, str) else list(transcriptions) + ) + configured_sessions = [sessions] if isinstance(sessions, str) else sessions + self._sessions: list[ + ScriptedTranscriptionSession | tuple[TranscriptionResult, ...] | Exception + ] = [ + configured + if isinstance(configured, ScriptedTranscriptionSession | Exception) + else (configured,) + if isinstance(configured, str) + else tuple(configured) + for configured in configured_sessions + ] + self._model_name = model_name + self._calls: list[STTCall] = [] + self._session_calls: list[STTSessionCall] = [] + self._created_sessions: list[ScriptedTranscriptionSession] = [] + + @property + def model_name(self) -> str: + return self._model_name + + @property + def calls(self) -> tuple[STTCall, ...]: + """Return detached snapshots of recorded static transcription calls.""" + return tuple(_snapshot_stt_call(call) for call in self._calls) + + @property + def session_calls(self) -> tuple[STTSessionCall, ...]: + """Return detached snapshots of recorded streamed-session calls.""" + return tuple(_snapshot_stt_session_call(call) for call in self._session_calls) + + @property + def created_sessions(self) -> tuple[ScriptedTranscriptionSession, ...]: + """Return created sessions while preserving their live object identity.""" + return tuple(self._created_sessions) + + async def transcribe( + self, + input: AudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> str: + call = STTCall( + input=input, + settings=settings, + trace_include_sensitive_data=trace_include_sensitive_data, + trace_include_sensitive_audio_data=trace_include_sensitive_audio_data, + ) + call = _snapshot_stt_call(call) + self._calls.append(call) + if not self._transcriptions: + raise UnexpectedVoiceCall( + "Unexpected static transcription call: no scripted transcriptions remain.", + operation="static_transcription", + ) + result = self._transcriptions.pop(0) + if isinstance(result, Exception): + raise result + return result + + async def create_session( + self, + input: StreamedAudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> StreamedTranscriptionSession: + call = STTSessionCall( + input=input, + settings=settings, + trace_include_sensitive_data=trace_include_sensitive_data, + trace_include_sensitive_audio_data=trace_include_sensitive_audio_data, + ) + call = _snapshot_stt_session_call(call) + self._session_calls.append(call) + if not self._sessions: + raise UnexpectedVoiceCall( + "Unexpected streamed transcription session: no scripted sessions remain.", + operation="streamed_session", + ) + configured = self._sessions.pop(0) + if isinstance(configured, Exception): + raise configured + session = ( + configured + if isinstance(configured, ScriptedTranscriptionSession) + else ScriptedTranscriptionSession(configured) + ) + self._created_sessions.append(session) + return session + + def assert_complete(self) -> None: + """Raise when static transcriptions or sessions remain unconsumed.""" + remaining = len(self._transcriptions) + len(self._sessions) + if remaining: + raise UnconsumedVoiceSteps( + f"{remaining} scripted STT step(s) were not consumed.", + remaining_steps=remaining, + ) + for session in self._created_sessions: + session.assert_complete() + + +class ScriptedTTSModel(TTSModel): + """A deterministic text-to-speech model that yields configured PCM byte chunks.""" + + def __init__( + self, + results: Iterable[TTSResultItem] = (), + *, + model_name: str = "scripted-tts", + ) -> None: + self._results = [self._coerce_result(result) for result in results] + self._model_name = model_name + self._calls: list[TTSCall] = [] + + @property + def model_name(self) -> str: + return self._model_name + + @property + def calls(self) -> tuple[TTSCall, ...]: + """Return detached snapshots of recorded text-to-speech calls.""" + return tuple(_snapshot_tts_call(call) for call in self._calls) + + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + call = _snapshot_tts_call(TTSCall(text=text, settings=settings)) + self._calls.append(call) + if not self._results: + raise UnexpectedVoiceCall( + "Unexpected TTS call: no scripted results remain.", + operation="tts", + ) + result = self._results.pop(0) + if isinstance(result, Exception): + raise result + for chunk in result.chunks: + yield chunk + + def assert_complete(self) -> None: + """Raise when configured TTS results remain unconsumed.""" + if self._results: + raise UnconsumedVoiceSteps( + f"{len(self._results)} scripted TTS result(s) were not consumed.", + remaining_steps=len(self._results), + ) + + @staticmethod + def _coerce_result(result: TTSResultItem) -> TTSResult | Exception: + if isinstance(result, Exception): + return result + if isinstance(result, TTSResult): + return TTSResult(chunks=tuple(result.chunks)) + return TTSResult(chunks=tuple(result)) + + +class ScriptedVoiceWorkflow(VoiceWorkflowBase): + """A deterministic Voice workflow that yields configured text fragments per turn.""" + + def __init__( + self, + turns: str | Iterable[WorkflowResult] = (), + *, + start: str | Sequence[str] | Exception = _START_NOT_CONFIGURED, + ) -> None: + configured_turns = [turns] if isinstance(turns, str) else turns + self._turns = [ + turn if isinstance(turn, Exception) else _normalize_fragments(turn) + for turn in configured_turns + ] + self._start = ( + () + if start is _START_NOT_CONFIGURED + else start + if isinstance(start, Exception) + else _normalize_fragments(start) + ) + self._start_configured = start is not _START_NOT_CONFIGURED + self._start_pending = self._start_configured + self._transcriptions: list[str] = [] + + @property + def transcriptions(self) -> tuple[str, ...]: + """Return the recorded workflow transcriptions.""" + return tuple(self._transcriptions) + + async def on_start(self) -> AsyncIterator[str]: + if self._start_configured and not self._start_pending: + raise UnexpectedVoiceCall( + "Unexpected workflow startup call: no scripted startup step remains.", + operation="workflow_start", + ) + self._start_pending = False + if isinstance(self._start, Exception): + raise self._start + for fragment in self._start: + yield fragment + + async def run(self, transcription: str) -> AsyncIterator[str]: + self._transcriptions.append(transcription) + if not self._turns: + raise UnexpectedVoiceCall( + "Unexpected workflow turn: no scripted turns remain.", + operation="workflow_turn", + ) + result = self._turns.pop(0) + if isinstance(result, Exception): + raise result + for fragment in result: + yield fragment + + def assert_complete(self) -> None: + """Raise when the startup step or configured workflow turns remain unconsumed.""" + remaining = int(self._start_pending) + len(self._turns) + if not remaining: + return + if self._start_pending and not self._turns: + raise UnconsumedVoiceSteps( + "1 scripted workflow startup step was not consumed.", + remaining_steps=1, + ) + if not self._start_pending: + raise UnconsumedVoiceSteps( + f"{len(self._turns)} scripted workflow turn(s) were not consumed.", + remaining_steps=len(self._turns), + ) + raise UnconsumedVoiceSteps( + f"{remaining} scripted workflow step(s) were not consumed.", + remaining_steps=remaining, + ) + + +def _normalize_fragments(fragments: str | Sequence[str]) -> tuple[str, ...]: + return (fragments,) if isinstance(fragments, str) else tuple(fragments) + + +def pcm16_samples(samples: Iterable[int]) -> bytes: + """Encode integer samples as native little-endian PCM16 bytes.""" + return np.asarray(list(samples), dtype=" str: @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel(), tools=[test_tool]) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel(), tools=[test_tool]) @pytest.fixture @@ -74,7 +74,7 @@ def usage_data() -> Usage: def create_mock_run_result(usage: Usage | None = None, agent: Agent | None = None) -> RunResult: """Helper function to create a mock RunResult for testing.""" if agent is None: - agent = Agent(name="test", model=FakeModel()) + agent = Agent(name="test", model=ScriptedModel()) if usage is None: usage = Usage( @@ -2057,10 +2057,10 @@ async def store_session_usage(result: Any, session: AdvancedSQLiteSession): # Ignore errors in test helper pass - # Set up fake model responses - assert isinstance(agent.model, FakeModel) - fake_model = agent.model - fake_model.set_next_output([get_text_message("San Francisco")]) + # Set up scripted model responses. + assert isinstance(agent.model, ScriptedModel) + scripted_model = agent.model + scripted_model.enqueue([get_text_message("San Francisco")]) # First turn result1 = await Runner.run( @@ -2072,7 +2072,7 @@ async def store_session_usage(result: Any, session: AdvancedSQLiteSession): await store_session_usage(result1, session) # Second turn - fake_model.set_next_output([get_text_message("California")]) + scripted_model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" await store_session_usage(result2, session) @@ -2085,7 +2085,7 @@ async def store_session_usage(result: Any, session: AdvancedSQLiteSession): session_usage = await session.get_session_usage() assert session_usage is not None assert session_usage["total_turns"] == 2 - # FakeModel doesn't generate realistic usage data, so we just check structure exists + # ScriptedModel doesn't generate realistic usage data, so we just check structure exists assert "requests" in session_usage assert "total_tokens" in session_usage @@ -2480,9 +2480,9 @@ async def test_tool_execution_integration(agent: Agent): session_id = "tool_integration_test" session = AdvancedSQLiteSession(session_id=session_id, create_tables=True) - # Set up the fake model to trigger a tool call - fake_model = cast(FakeModel, agent.model) - fake_model.set_next_output( + # Set up the scripted model to trigger a tool call. + scripted_model = cast(ScriptedModel, agent.model) + scripted_model.enqueue( [ { # type: ignore "type": "function_call", @@ -2494,7 +2494,7 @@ async def test_tool_execution_integration(agent: Agent): ) # Then set the final response - fake_model.set_next_output([get_text_message("Tool executed successfully")]) + scripted_model.enqueue([get_text_message("Tool executed successfully")]) # Run the agent result = await Runner.run( @@ -2687,8 +2687,8 @@ async def test_runner_with_session_settings_override(agent: Agent): await session.add_items(items) # Use RunConfig to override limit to 2 - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("Got it")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("Got it")]) await Runner.run( agent, @@ -2700,7 +2700,7 @@ async def test_runner_with_session_settings_override(agent: Agent): ) # Verify the agent received only the last 2 history items + new question - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input # Filter out the new "New question" input history_items = [item for item in last_input if item.get("content") != "New question"] # Should have 2 history items (last two from the 10 we added) diff --git a/tests/extensions/memory/test_async_sqlite_session.py b/tests/extensions/memory/test_async_sqlite_session.py index 5ade0e2cc5..6b4d616c0f 100644 --- a/tests/extensions/memory/test_async_sqlite_session.py +++ b/tests/extensions/memory/test_async_sqlite_session.py @@ -19,7 +19,7 @@ from agents import Agent, Runner, TResponseInputItem from agents.extensions.memory import AsyncSQLiteSession from agents.memory import SessionSettings -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message pytestmark = pytest.mark.asyncio @@ -33,8 +33,8 @@ def _assert_cancel_message(exc: asyncio.CancelledError, expected: str) -> None: @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel()) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel()) def _item_ids(items: Sequence[TResponseInputItem]) -> list[str]: @@ -267,9 +267,9 @@ async def test_async_sqlite_session_runner_integration(agent: Agent): db_path = Path(temp_dir) / "async_runner_integration.db" session = AsyncSQLiteSession("runner_integration_test", db_path) - assert isinstance(agent.model, FakeModel) + assert isinstance(agent.model, ScriptedModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + agent.model.enqueue([get_text_message("San Francisco")]) result1 = await Runner.run( agent, "What city is the Golden Gate Bridge in?", @@ -277,11 +277,11 @@ async def test_async_sqlite_session_runner_integration(agent: Agent): ) assert result1.final_output == "San Francisco" - agent.model.set_next_output([get_text_message("California")]) + agent.model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert isinstance(last_input, list) assert len(last_input) > 1 assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) @@ -296,14 +296,14 @@ async def test_async_sqlite_session_session_isolation(agent: Agent): session1 = AsyncSQLiteSession("session_1", db_path) session2 = AsyncSQLiteSession("session_2", db_path) - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("I like cats.")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("I like cats.")]) await Runner.run(agent, "I like cats.", session=session1) - agent.model.set_next_output([get_text_message("I like dogs.")]) + agent.model.enqueue([get_text_message("I like dogs.")]) await Runner.run(agent, "I like dogs.", session=session2) - agent.model.set_next_output([get_text_message("You said you like cats.")]) + agent.model.enqueue([get_text_message("You said you like cats.")]) result = await Runner.run(agent, "What animal did I say I like?", session=session1) assert "cats" in result.final_output.lower() assert "dogs" not in result.final_output.lower() diff --git a/tests/extensions/memory/test_dapr_redis_integration.py b/tests/extensions/memory/test_dapr_redis_integration.py index 4f3d8f4453..68e04d4392 100644 --- a/tests/extensions/memory/test_dapr_redis_integration.py +++ b/tests/extensions/memory/test_dapr_redis_integration.py @@ -45,7 +45,7 @@ DAPR_CONSISTENCY_STRONG, DaprSession, ) -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message # Docker-backed integration tests should stay on the exclusive serial test path. @@ -234,8 +234,8 @@ def dapr_container(redis_container, docker_network): @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel()) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel()) async def test_dapr_redis_integration(dapr_container, monkeypatch): @@ -321,8 +321,8 @@ async def test_dapr_runner_integration(agent: Agent, dapr_container, monkeypatch await session.clear_session() # First turn - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("San Francisco")]) result1 = await Runner.run( agent, "What city is the Golden Gate Bridge in?", @@ -331,12 +331,12 @@ async def test_dapr_runner_integration(agent: Agent, dapr_container, monkeypatch assert result1.final_output == "San Francisco" # Second turn - should remember context - agent.model.set_next_output([get_text_message("California")]) + agent.model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" # Verify history - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert len(last_input) > 1 assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) diff --git a/tests/extensions/memory/test_dapr_session.py b/tests/extensions/memory/test_dapr_session.py index 5458d39128..61fda29508 100644 --- a/tests/extensions/memory/test_dapr_session.py +++ b/tests/extensions/memory/test_dapr_session.py @@ -14,7 +14,7 @@ DAPR_CONSISTENCY_STRONG, DaprSession, ) -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message # Mark all tests in this file as asyncio @@ -166,8 +166,8 @@ def conflict_dapr_client() -> ConflictFakeDaprClient: @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel()) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel()) async def _create_test_session( @@ -233,8 +233,8 @@ async def test_runner_integration(agent: Agent, fake_dapr_client: FakeDaprClient try: # First turn - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("San Francisco")]) result1 = await Runner.run( agent, "What city is the Golden Gate Bridge in?", @@ -243,12 +243,12 @@ async def test_runner_integration(agent: Agent, fake_dapr_client: FakeDaprClient assert result1.final_output == "San Francisco" # Second turn - agent.model.set_next_output([get_text_message("California")]) + agent.model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" # Verify history was passed to the model on the second turn - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert len(last_input) > 1 assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) @@ -270,23 +270,23 @@ async def test_session_isolation(fake_dapr_client: FakeDaprClient): ) try: - agent = Agent(name="test", model=FakeModel()) + agent = Agent(name="test", model=ScriptedModel()) # Clean up any existing data await session1.clear_session() await session2.clear_session() # Interact with session 1 - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("I like cats.")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("I like cats.")]) await Runner.run(agent, "I like cats.", session=session1) # Interact with session 2 - agent.model.set_next_output([get_text_message("I like dogs.")]) + agent.model.enqueue([get_text_message("I like dogs.")]) await Runner.run(agent, "I like dogs.", session=session2) # Go back to session 1 and check its memory - agent.model.set_next_output([get_text_message("You said you like cats.")]) + agent.model.enqueue([get_text_message("You said you like cats.")]) result = await Runner.run(agent, "What animal did I say I like?", session=session1) assert "cats" in result.final_output.lower() assert "dogs" not in result.final_output.lower() @@ -1033,7 +1033,7 @@ async def test_runner_with_session_settings_override(fake_dapr_client: FakeDaprC """Test that RunConfig can override session's default settings.""" from agents import Agent, RunConfig, Runner from agents.memory import SessionSettings - from tests.fake_model import FakeModel + from agents.testing import ScriptedModel from tests.test_responses import get_text_message session = DaprSession( @@ -1052,9 +1052,9 @@ async def test_runner_with_session_settings_override(fake_dapr_client: FakeDaprC ] await session.add_items(items) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("Got it")]) + model.enqueue([get_text_message("Got it")]) await Runner.run( agent, @@ -1066,7 +1066,7 @@ async def test_runner_with_session_settings_override(fake_dapr_client: FakeDaprC ) # Verify the agent received only the last 2 history items + new question - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # Filter out the new "New question" input history_items = [item for item in last_input if item.get("content") != "New question"] # Should have 2 history items (last two from the 10 we added) diff --git a/tests/extensions/memory/test_encrypt_session.py b/tests/extensions/memory/test_encrypt_session.py index fb6da900dd..5ccbf59f8f 100644 --- a/tests/extensions/memory/test_encrypt_session.py +++ b/tests/extensions/memory/test_encrypt_session.py @@ -19,7 +19,7 @@ TResponseInputItem, ) from agents.extensions.memory.encrypt_session import EncryptedSession -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message # Mark all tests in this file as asyncio @@ -35,8 +35,8 @@ def _invalid_encrypted_envelope() -> TResponseInputItem: @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel()) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel()) @pytest.fixture @@ -106,8 +106,8 @@ async def test_encrypted_session_with_runner( encryption_key=encryption_key, ) - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("San Francisco")]) result1 = await Runner.run( agent, "What city is the Golden Gate Bridge in?", @@ -115,11 +115,11 @@ async def test_encrypted_session_with_runner( ) assert result1.final_output == "San Francisco" - agent.model.set_next_output([get_text_message("California")]) + agent.model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert len(last_input) > 1 assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) @@ -603,7 +603,7 @@ async def test_runner_with_session_settings_override(encryption_key: str): """Test that RunConfig can override session's default settings.""" from agents import Agent, RunConfig, Runner from agents.memory import SessionSettings - from tests.fake_model import FakeModel + from agents.testing import ScriptedModel from tests.test_responses import get_text_message temp_dir = tempfile.mkdtemp() @@ -620,9 +620,9 @@ async def test_runner_with_session_settings_override(encryption_key: str): items: list[TResponseInputItem] = [{"role": "user", "content": f"Turn {i}"} for i in range(10)] await session.add_items(items) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("Got it")]) + model.enqueue([get_text_message("Got it")]) await Runner.run( agent, @@ -634,7 +634,7 @@ async def test_runner_with_session_settings_override(encryption_key: str): ) # Verify the agent received only the last 2 history items + new question - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # Filter out the new "New question" input history_items = [item for item in last_input if item.get("content") != "New question"] # Should have 2 history items (last two from the 10 we added) diff --git a/tests/extensions/memory/test_mongodb_session.py b/tests/extensions/memory/test_mongodb_session.py index da8b6214f4..20def54304 100644 --- a/tests/extensions/memory/test_mongodb_session.py +++ b/tests/extensions/memory/test_mongodb_session.py @@ -22,7 +22,7 @@ from agents import Agent, Runner, TResponseInputItem from agents.memory.session_settings import SessionSettings -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message pytestmark = pytest.mark.asyncio @@ -338,7 +338,7 @@ def session() -> MongoDBSession: @pytest.fixture def agent() -> Agent: - return Agent(name="test", model=FakeModel()) + return Agent(name="test", model=ScriptedModel()) # --------------------------------------------------------------------------- @@ -1048,16 +1048,16 @@ async def test_runner_integration(agent: Agent) -> None: """MongoDBSession must supply conversation history to the Runner.""" session = _make_session("runner-test") - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("San Francisco")]) result1 = await Runner.run(agent, "Where is the Golden Gate Bridge?", session=session) assert result1.final_output == "San Francisco" - agent.model.set_next_output([get_text_message("California")]) + agent.model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert len(last_input) > 1 assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) @@ -1069,14 +1069,14 @@ async def test_runner_session_isolation(agent: Agent) -> None: s1 = MongoDBSession("user-a", client=client, database="agents_test") # type: ignore[arg-type] s2 = MongoDBSession("user-b", client=client, database="agents_test") # type: ignore[arg-type] - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("I like cats.")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("I like cats.")]) await Runner.run(agent, "I like cats.", session=s1) - agent.model.set_next_output([get_text_message("I like dogs.")]) + agent.model.enqueue([get_text_message("I like dogs.")]) await Runner.run(agent, "I like dogs.", session=s2) - agent.model.set_next_output([get_text_message("You said you like cats.")]) + agent.model.enqueue([get_text_message("You said you like cats.")]) result = await Runner.run(agent, "What animal did I mention?", session=s1) assert "cats" in result.final_output.lower() assert "dogs" not in result.final_output.lower() @@ -1099,8 +1099,8 @@ async def test_runner_with_session_settings_limit(agent: Agent) -> None: ] await session.add_items(history) - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("Got it")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("Got it")]) await Runner.run( agent, "New question", @@ -1108,7 +1108,7 @@ async def test_runner_with_session_settings_limit(agent: Agent) -> None: run_config=RunConfig(session_settings=SessionSettings(limit=2)), ) - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input history_items = [i for i in last_input if i.get("content") != "New question"] assert len(history_items) == 2 diff --git a/tests/extensions/memory/test_redis_session.py b/tests/extensions/memory/test_redis_session.py index c7f2b292db..9758ae278a 100644 --- a/tests/extensions/memory/test_redis_session.py +++ b/tests/extensions/memory/test_redis_session.py @@ -13,7 +13,7 @@ from agents import Agent, Runner, TResponseInputItem from agents.extensions.memory.redis_session import RedisSession -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message # Keep the fallback-to-real-Redis path isolated from xdist workers. @@ -61,8 +61,8 @@ async def _safe_rpush(client: Redis, key: str, value: str) -> None: @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel()) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel()) async def _create_redis_session( @@ -205,8 +205,8 @@ async def test_runner_integration(agent: Agent): try: # First turn - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("San Francisco")]) result1 = await Runner.run( agent, "What city is the Golden Gate Bridge in?", @@ -215,12 +215,12 @@ async def test_runner_integration(agent: Agent): assert result1.final_output == "San Francisco" # Second turn - agent.model.set_next_output([get_text_message("California")]) + agent.model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" # Verify history was passed to the model on the second turn - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert len(last_input) > 1 assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) @@ -234,23 +234,23 @@ async def test_session_isolation(): session2 = await _create_redis_session("session_2") try: - agent = Agent(name="test", model=FakeModel()) + agent = Agent(name="test", model=ScriptedModel()) # Clean up any existing data await session1.clear_session() await session2.clear_session() # Interact with session 1 - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("I like cats.")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("I like cats.")]) await Runner.run(agent, "I like cats.", session=session1) # Interact with session 2 - agent.model.set_next_output([get_text_message("I like dogs.")]) + agent.model.enqueue([get_text_message("I like dogs.")]) await Runner.run(agent, "I like dogs.", session=session2) # Go back to session 1 and check its memory - agent.model.set_next_output([get_text_message("You said you like cats.")]) + agent.model.enqueue([get_text_message("You said you like cats.")]) result = await Runner.run(agent, "What animal did I say I like?", session=session1) assert "cats" in result.final_output.lower() assert "dogs" not in result.final_output.lower() @@ -1094,7 +1094,7 @@ async def test_runner_with_session_settings_override(): """Test that RunConfig can override session's default settings.""" from agents import Agent, RunConfig, Runner from agents.memory import SessionSettings - from tests.fake_model import FakeModel + from agents.testing import ScriptedModel from tests.test_responses import get_text_message if USE_FAKE_REDIS: @@ -1118,9 +1118,9 @@ async def test_runner_with_session_settings_override(): ] await session.add_items(items) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("Got it")]) + model.enqueue([get_text_message("Got it")]) await Runner.run( agent, @@ -1132,7 +1132,7 @@ async def test_runner_with_session_settings_override(): ) # Verify the agent received only the last 2 history items + new question - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # Filter out the new "New question" input history_items = [item for item in last_input if item.get("content") != "New question"] # Should have 2 history items (last two from the 10 we added) diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index f18d803328..b985d0a7e9 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -25,7 +25,7 @@ from agents import Agent, Runner, TResponseInputItem from agents.extensions.memory.sqlalchemy_session import SQLAlchemySession -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message # Mark all tests in this file as asyncio @@ -72,8 +72,8 @@ def _item_ids(items: Sequence[TResponseInputItem]) -> list[str]: @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel()) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel()) async def test_sqlalchemy_session_direct_ops(agent: Agent): @@ -159,8 +159,8 @@ async def test_runner_integration(agent: Agent): session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True) # First turn - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("San Francisco")]) result1 = await Runner.run( agent, "What city is the Golden Gate Bridge in?", @@ -169,12 +169,12 @@ async def test_runner_integration(agent: Agent): assert result1.final_output == "San Francisco" # Second turn - agent.model.set_next_output([get_text_message("California")]) + agent.model.enqueue([get_text_message("California")]) result2 = await Runner.run(agent, "What state is it in?", session=session) assert result2.final_output == "California" # Verify history was passed to the model on the second turn - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert len(last_input) > 1 assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) @@ -188,16 +188,16 @@ async def test_session_isolation(agent: Agent): session2 = SQLAlchemySession.from_url(session_id_2, url=DB_URL, create_tables=True) # Interact with session 1 - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("I like cats.")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("I like cats.")]) await Runner.run(agent, "I like cats.", session=session1) # Interact with session 2 - agent.model.set_next_output([get_text_message("I like dogs.")]) + agent.model.enqueue([get_text_message("I like dogs.")]) await Runner.run(agent, "I like dogs.", session=session2) # Go back to session 1 and check its memory - agent.model.set_next_output([get_text_message("You said you like cats.")]) + agent.model.enqueue([get_text_message("You said you like cats.")]) result = await Runner.run(agent, "What animal did I say I like?", session=session1) assert "cats" in result.final_output.lower() assert "dogs" not in result.final_output.lower() @@ -1259,8 +1259,8 @@ async def test_runner_with_session_settings_override(agent: Agent): await session.add_items(items) # Use RunConfig to override limit to 2 - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("Got it")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("Got it")]) await Runner.run( agent, @@ -1272,7 +1272,7 @@ async def test_runner_with_session_settings_override(agent: Agent): ) # Verify the agent received only the last 2 history items + new question - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input # Filter out the new "New question" input history_items = [item for item in last_input if item.get("content") != "New question"] # Should have 2 history items (last two from the 10 we added) diff --git a/tests/extensions/sandbox/test_blaxel_mounts.py b/tests/extensions/sandbox/test_blaxel_mounts.py index 11d8cd05c5..4fbab98e7e 100644 --- a/tests/extensions/sandbox/test_blaxel_mounts.py +++ b/tests/extensions/sandbox/test_blaxel_mounts.py @@ -1,34 +1,28 @@ from __future__ import annotations import shlex -from types import SimpleNamespace -from typing import Any from agents.extensions.sandbox.blaxel.mounts import ( BlaxelCloudBucketMountConfig, _mount_gcs, _mount_s3, ) +from agents.sandbox import ExecResult +from agents.testing import scripted_sandbox_session _INJECTION = "x; touch /tmp/pwned" -class _RecordingSession: - """Minimal sandbox session that records the `sh -c` commands it is asked to run.""" - - def __init__(self) -> None: - self.commands: list[str] = [] - - async def exec(self, *args: Any, **kwargs: Any) -> Any: - if len(args) >= 3 and args[0] == "sh" and args[1] == "-c": - self.commands.append(args[2]) - return SimpleNamespace(exit_code=0, stdout=b"", stderr=b"") +def _successful_exec(_call: object) -> ExecResult: + return ExecResult(exit_code=0, stdout=b"", stderr=b"") async def test_s3_mount_options_are_shell_quoted() -> None: - session = _RecordingSession() + session = scripted_sandbox_session( + [{"method": "exec", "responder": _successful_exec} for _ in range(3)] + ) await _mount_s3( - session, # type: ignore[arg-type] + session, BlaxelCloudBucketMountConfig( provider="s3", bucket="bucket", @@ -36,15 +30,19 @@ async def test_s3_mount_options_are_shell_quoted() -> None: endpoint_url=f"http://{_INJECTION}", ), ) - cmd = next(c for c in session.commands if c.startswith("s3fs")) + commands = [call.args[2] for call in session.calls if call.args[:2] == ("sh", "-c")] + cmd = next(command for command in commands if command.startswith("s3fs")) # The injected `; touch` must stay inside the -o option token, not become its own command. assert "touch" not in shlex.split(cmd) + session.assert_complete() async def test_gcs_mount_prefix_is_shell_quoted() -> None: - session = _RecordingSession() + session = scripted_sandbox_session( + [{"method": "exec", "responder": _successful_exec} for _ in range(3)] + ) await _mount_gcs( - session, # type: ignore[arg-type] + session, BlaxelCloudBucketMountConfig( provider="gcs", bucket="bucket", @@ -52,5 +50,7 @@ async def test_gcs_mount_prefix_is_shell_quoted() -> None: prefix=_INJECTION, ), ) - cmd = next(c for c in session.commands if c.startswith("gcsfuse")) + commands = [call.args[2] for call in session.calls if call.args[:2] == ("sh", "-c")] + cmd = next(command for command in commands if command.startswith("gcsfuse")) assert "touch" not in shlex.split(cmd) + session.assert_complete() diff --git a/tests/extensions/sandbox/test_rclone.py b/tests/extensions/sandbox/test_rclone.py index 5f8de7a3a4..3a7c249272 100644 --- a/tests/extensions/sandbox/test_rclone.py +++ b/tests/extensions/sandbox/test_rclone.py @@ -12,22 +12,13 @@ ) from agents.sandbox.errors import MountConfigError from agents.sandbox.types import ExecResult +from agents.testing import scripted_sandbox_session def _result(*, exit_code: int = 0, stdout: bytes = b"") -> ExecResult: return ExecResult(stdout=stdout, stderr=b"", exit_code=exit_code) -class _FakeSession: - def __init__(self, results: list[ExecResult]) -> None: - self.results = results - self.calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] - - async def exec(self, *command: str, **kwargs: object) -> ExecResult: - self.calls.append((command, kwargs)) - return self.results.pop(0) - - @pytest.mark.parametrize( ("machine", "expected"), [ @@ -73,25 +64,25 @@ def test_rclone_install_command_pins_and_verifies_archive() -> None: @pytest.mark.asyncio async def test_ensure_rclone_preserves_preinstalled_binary() -> None: - session = _FakeSession([_result()]) + session = scripted_sandbox_session([{"method": "exec", "result": _result()}]) - await ensure_rclone(session) # type: ignore[arg-type] + await ensure_rclone(session) assert len(session.calls) == 1 @pytest.mark.asyncio async def test_ensure_rclone_rejects_unsupported_architecture_before_install() -> None: - session = _FakeSession( + session = scripted_sandbox_session( [ - _result(exit_code=1), - _result(), - _result(stdout=b"mips64\n"), + {"method": "exec", "result": _result(exit_code=1)}, + {"method": "exec", "result": _result()}, + {"method": "exec", "result": _result(stdout=b"mips64\n")}, ] ) with pytest.raises(MountConfigError, match="architecture is unsupported") as exc_info: - await ensure_rclone(session) # type: ignore[arg-type] + await ensure_rclone(session) assert exc_info.value.context["architecture"] == "mips64" assert len(session.calls) == 3 @@ -99,19 +90,22 @@ async def test_ensure_rclone_rejects_unsupported_architecture_before_install() - @pytest.mark.asyncio async def test_ensure_rclone_reports_checksum_mismatch() -> None: - session = _FakeSession( + session = scripted_sandbox_session( [ - _result(exit_code=1), - _result(), - _result(stdout=b"x86_64\n"), - _result(), - _result(), - _result(exit_code=_RCLONE_CHECKSUM_MISMATCH_EXIT), + {"method": "exec", "result": _result(exit_code=1)}, + {"method": "exec", "result": _result()}, + {"method": "exec", "result": _result(stdout=b"x86_64\n")}, + {"method": "exec", "result": _result()}, + {"method": "exec", "result": _result()}, + { + "method": "exec", + "result": _result(exit_code=_RCLONE_CHECKSUM_MISMATCH_EXIT), + }, ] ) with pytest.raises(MountConfigError, match="checksum verification failed") as exc_info: - await ensure_rclone(session) # type: ignore[arg-type] + await ensure_rclone(session) assert exc_info.value.context == { "package": "rclone", diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 82f207de0a..118d64423f 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -48,8 +48,8 @@ from agents.sandbox.session.sinks import CallbackSink from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase from agents.sandbox.types import User +from agents.testing import ScriptedModel from tests._fake_workspace_paths import resolve_fake_workspace_path -from tests.fake_model import FakeModel class _FakeNetworkPolicyRule(BaseModel): @@ -1045,7 +1045,7 @@ async def test_vercel_injected_session_accepts_unchanged_s3_manifest( manifest=_vercel_s3_manifest(package_module), options=vercel_module.VercelSandboxClientOptions(), ) - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=session), @@ -1074,7 +1074,7 @@ async def test_vercel_injected_session_revalidates_preexisting_s3_topology_mutat ) mount = cast(S3Mount, session.state.manifest.entries["remote"]) mount.bucket = "tampered-bucket" - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=session), @@ -1105,7 +1105,7 @@ async def test_vercel_injected_session_applies_non_mount_delta_with_fixed_s3_top options=vercel_module.VercelSandboxClientOptions(), ) sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=session), @@ -1147,7 +1147,7 @@ async def running_once() -> bool: return True monkeypatch.setattr(session, "running", running_once) - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") update = await SandboxRuntimeSessionManager._process_live_session_manifest( agent=agent, @@ -1176,7 +1176,7 @@ async def test_vercel_stopped_injected_session_rejects_non_mount_delta_before_st ) sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) sandbox.status = "stopped" - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=session), diff --git a/tests/fake_model.py b/tests/fake_model.py deleted file mode 100644 index ddbfadc9dc..0000000000 --- a/tests/fake_model.py +++ /dev/null @@ -1,404 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncIterator -from typing import Any - -from openai.types.responses import ( - Response, - ResponseApplyPatchToolCall, - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseInProgressEvent, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputText, - ResponseReasoningSummaryPartAddedEvent, - ResponseReasoningSummaryPartDoneEvent, - ResponseReasoningSummaryTextDeltaEvent, - ResponseReasoningSummaryTextDoneEvent, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, - ResponseUsage, -) -from openai.types.responses.response_reasoning_item import ResponseReasoningItem -from openai.types.responses.response_reasoning_summary_part_added_event import ( - Part as AddedEventPart, -) -from openai.types.responses.response_reasoning_summary_part_done_event import Part as DoneEventPart -from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails - -from agents.agent_output import AgentOutputSchemaBase -from agents.handoffs import Handoff -from agents.items import ( - ModelResponse, - TResponseInputItem, - TResponseOutputItem, - TResponseStreamEvent, -) -from agents.model_settings import ModelSettings -from agents.models.interface import Model, ModelTracing -from agents.tool import Tool -from agents.tracing import SpanError, generation_span -from agents.usage import Usage - - -class FakeModel(Model): - def __init__( - self, - tracing_enabled: bool = False, - initial_output: list[TResponseOutputItem] | Exception | None = None, - ): - if initial_output is None: - initial_output = [] - self.turn_outputs: list[list[TResponseOutputItem] | Exception] = ( - [initial_output] if initial_output else [] - ) - self.tracing_enabled = tracing_enabled - self.last_turn_args: dict[str, Any] = {} - self.first_turn_args: dict[str, Any] | None = None - self.hardcoded_usage: Usage | None = None - - def set_hardcoded_usage(self, usage: Usage): - self.hardcoded_usage = usage - - def set_next_output(self, output: list[TResponseOutputItem] | Exception): - self.turn_outputs.append(output) - - def add_multiple_turn_outputs(self, outputs: list[list[TResponseOutputItem] | Exception]): - self.turn_outputs.extend(outputs) - - def get_next_output(self) -> list[TResponseOutputItem] | Exception: - if not self.turn_outputs: - return [] - return self.turn_outputs.pop(0) - - def _record_turn_args( - self, - *, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> None: - turn_args = { - "system_instructions": system_instructions, - "input": input, - "model_settings": model_settings, - "tools": tools, - "output_schema": output_schema, - "handoffs": handoffs, - "tracing": tracing, - "previous_response_id": previous_response_id, - "conversation_id": conversation_id, - "prompt": prompt, - } - if self.first_turn_args is None: - self.first_turn_args = turn_args.copy() - self.last_turn_args = turn_args - - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> ModelResponse: - self._record_turn_args( - system_instructions=system_instructions, - input=input, - model_settings=model_settings, - tools=tools, - output_schema=output_schema, - handoffs=handoffs, - tracing=tracing, - previous_response_id=previous_response_id, - conversation_id=conversation_id, - prompt=prompt, - ) - - with generation_span(disabled=not self.tracing_enabled) as span: - output = self.get_next_output() - - if isinstance(output, Exception): - span.set_error( - SpanError( - message="Error", - data={ - "name": output.__class__.__name__, - "message": str(output), - }, - ) - ) - raise output - - converted_output = [] - for item in output: - if isinstance(item, dict) and item.get("type") == "apply_patch_call": - call_id = str(item.get("call_id") or item.get("id") or "") - converted_output.append( - ResponseApplyPatchToolCall( - type="apply_patch_call", - id=str(item.get("id") or call_id), - call_id=call_id, - status=item.get("status") or "completed", - operation=item.get("operation"), - ) - ) - else: - converted_output.append(item) - - return ModelResponse( - output=converted_output, - usage=self.hardcoded_usage or Usage(), - response_id="resp-789", - ) - - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None = None, - conversation_id: str | None = None, - prompt: Any | None = None, - ) -> AsyncIterator[TResponseStreamEvent]: - self._record_turn_args( - system_instructions=system_instructions, - input=input, - model_settings=model_settings, - tools=tools, - output_schema=output_schema, - handoffs=handoffs, - tracing=tracing, - previous_response_id=previous_response_id, - conversation_id=conversation_id, - prompt=prompt, - ) - with generation_span(disabled=not self.tracing_enabled) as span: - output = self.get_next_output() - if isinstance(output, Exception): - span.set_error( - SpanError( - message="Error", - data={ - "name": output.__class__.__name__, - "message": str(output), - }, - ) - ) - raise output - - response = get_response_obj(output, usage=self.hardcoded_usage) - sequence_number = 0 - - yield ResponseCreatedEvent( - type="response.created", - response=response, - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseInProgressEvent( - type="response.in_progress", - response=response, - sequence_number=sequence_number, - ) - sequence_number += 1 - - for output_index, output_item in enumerate(output): - yield ResponseOutputItemAddedEvent( - type="response.output_item.added", - item=output_item, - output_index=output_index, - sequence_number=sequence_number, - ) - sequence_number += 1 - - if isinstance(output_item, ResponseReasoningItem): - if output_item.summary: - for summary_index, summary in enumerate(output_item.summary): - yield ResponseReasoningSummaryPartAddedEvent( - type="response.reasoning_summary_part.added", - item_id=output_item.id, - output_index=output_index, - summary_index=summary_index, - part=AddedEventPart(text=summary.text, type=summary.type), - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseReasoningSummaryTextDeltaEvent( - type="response.reasoning_summary_text.delta", - item_id=output_item.id, - output_index=output_index, - summary_index=summary_index, - delta=summary.text, - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseReasoningSummaryTextDoneEvent( - type="response.reasoning_summary_text.done", - item_id=output_item.id, - output_index=output_index, - summary_index=summary_index, - text=summary.text, - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseReasoningSummaryPartDoneEvent( - type="response.reasoning_summary_part.done", - item_id=output_item.id, - output_index=output_index, - summary_index=summary_index, - part=DoneEventPart(text=summary.text, type=summary.type), - sequence_number=sequence_number, - ) - sequence_number += 1 - - elif isinstance(output_item, ResponseFunctionToolCall): - yield ResponseFunctionCallArgumentsDeltaEvent( - type="response.function_call_arguments.delta", - item_id=output_item.call_id, - output_index=output_index, - delta=output_item.arguments, - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseFunctionCallArgumentsDoneEvent( - type="response.function_call_arguments.done", - item_id=output_item.call_id, - output_index=output_index, - arguments=output_item.arguments, - name=output_item.name, - sequence_number=sequence_number, - ) - sequence_number += 1 - - elif isinstance(output_item, ResponseOutputMessage): - for content_index, content_part in enumerate(output_item.content or []): - if isinstance(content_part, ResponseOutputText): - yield ResponseContentPartAddedEvent( - type="response.content_part.added", - item_id=output_item.id, - output_index=output_index, - content_index=content_index, - part=content_part, - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseTextDeltaEvent( - type="response.output_text.delta", - item_id=output_item.id, - output_index=output_index, - content_index=content_index, - delta=content_part.text, - logprobs=[], - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseTextDoneEvent( - type="response.output_text.done", - item_id=output_item.id, - output_index=output_index, - content_index=content_index, - text=content_part.text, - logprobs=[], - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseContentPartDoneEvent( - type="response.content_part.done", - item_id=output_item.id, - output_index=output_index, - content_index=content_index, - part=content_part, - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseOutputItemDoneEvent( - type="response.output_item.done", - item=output_item, - output_index=output_index, - sequence_number=sequence_number, - ) - sequence_number += 1 - - yield ResponseCompletedEvent( - type="response.completed", - response=response, - sequence_number=sequence_number, - ) - - -class PromptCacheFakeModel(FakeModel): - def _supports_default_prompt_cache_key(self) -> bool: - return True - - -def get_response_obj( - output: list[TResponseOutputItem], - response_id: str | None = None, - usage: Usage | None = None, -) -> Response: - return Response( - id=response_id or "resp-789", - created_at=123, - model="test_model", - object="response", - output=output, - tool_choice="none", - tools=[], - top_p=None, - parallel_tool_calls=False, - usage=ResponseUsage( - input_tokens=usage.input_tokens if usage else 0, - output_tokens=usage.output_tokens if usage else 0, - total_tokens=usage.total_tokens if usage else 0, - input_tokens_details=InputTokensDetails.model_validate( - { - "cache_write_tokens": ( - getattr(usage.input_tokens_details, "cache_write_tokens", 0) if usage else 0 - ), - "cached_tokens": ( - getattr(usage.input_tokens_details, "cached_tokens", 0) if usage else 0 - ), - } - ), - output_tokens_details=OutputTokensDetails( - reasoning_tokens=( - getattr(usage.output_tokens_details, "reasoning_tokens", 0) if usage else 0 - ) - ), - ), - ) diff --git a/tests/fastapi/test_streaming_context.py b/tests/fastapi/test_streaming_context.py index f2b8903947..e13a434857 100644 --- a/tests/fastapi/test_streaming_context.py +++ b/tests/fastapi/test_streaming_context.py @@ -2,7 +2,8 @@ from httpx import ASGITransport, AsyncClient from inline_snapshot import snapshot -from ..fake_model import FakeModel +from agents.testing import ScriptedModel + from ..test_responses import get_text_message from .streaming_app import agent, app @@ -14,9 +15,9 @@ async def test_streaming_context(): leading to a tracing error because the context was closed in the wrong context. This test ensures that this actually works. """ - model = FakeModel() + model = ScriptedModel() agent.model = model - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) transport = ASGITransport(app) async with AsyncClient(transport=transport, base_url="http://test") as ac: diff --git a/tests/fixtures/run_state/generate_corpus.py b/tests/fixtures/run_state/generate_corpus.py index 994b7dd4f3..5b56159348 100644 --- a/tests/fixtures/run_state/generate_corpus.py +++ b/tests/fixtures/run_state/generate_corpus.py @@ -446,7 +446,7 @@ class Scenario: import asyncio from agents import Runner, function_tool -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_function_tool_call @function_tool(needs_approval=True) @@ -454,8 +454,8 @@ def historical_approval(account_id: str) -> str: return f"approved:{account_id}" async def produce_pending_state(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [[get_function_tool_call( "historical_approval", '{"account_id":"account-1"}', diff --git a/tests/mcp/test_mcp_approval.py b/tests/mcp/test_mcp_approval.py index 873746f0fd..16a5b4c004 100644 --- a/tests/mcp/test_mcp_approval.py +++ b/tests/mcp/test_mcp_approval.py @@ -5,8 +5,8 @@ from agents import Agent, RunContextWrapper, Runner from agents.exceptions import UserError +from agents.testing import ScriptedModel -from ..fake_model import FakeModel from ..test_responses import get_function_tool_call, get_text_message from ..utils.hitl import queue_function_call_and_text, resume_after_first_approval from .helpers import FakeMCPServer @@ -19,7 +19,7 @@ async def test_mcp_require_approval_pauses_and_resumes(): server = FakeMCPServer(require_approval="always") server.add_tool("add", {"type": "object", "properties": {}}) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( @@ -51,7 +51,7 @@ async def test_mcp_require_approval_tool_lists(): server = FakeMCPServer(require_approval=require_approval) server.add_tool("add", {"type": "object", "properties": {}}) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( @@ -76,7 +76,7 @@ async def test_mcp_require_approval_tool_mapping(): server = FakeMCPServer(require_approval=require_approval) server.add_tool("add", {"type": "object", "properties": {}}) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( @@ -102,7 +102,7 @@ async def test_mcp_require_approval_mapping_allows_policy_keyword_tool_names(): server.add_tool("always", {"type": "object", "properties": {}}) server.add_tool("never", {"type": "object", "properties": {}}) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( @@ -167,7 +167,7 @@ def require_approval( server.add_tool("guarded", {"type": "object", "properties": {}}) server.add_tool("safe", {"type": "object", "properties": {}}) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( @@ -212,7 +212,7 @@ async def require_approval( server = FakeMCPServer(require_approval=require_approval) server.add_tool("conditional", {"type": "object", "properties": {}}) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( diff --git a/tests/mcp/test_mcp_pagination_integration.py b/tests/mcp/test_mcp_pagination_integration.py index fcef8449e0..0302675cc7 100644 --- a/tests/mcp/test_mcp_pagination_integration.py +++ b/tests/mcp/test_mcp_pagination_integration.py @@ -8,8 +8,8 @@ from agents import Agent, Runner from agents.mcp import MCPServerStdio from agents.mcp._compat import MCP_V2, result_next_cursor +from agents.testing import ScriptedModel -from ..fake_model import FakeModel from ..test_responses import get_function_tool_call, get_text_message PAGINATED_SERVER_PATH = Path(__file__).parent / "servers" / "paginated.py" @@ -50,8 +50,8 @@ async def test_stdio_server_auto_paginates_tools_and_prompts(): @pytest.mark.asyncio async def test_agent_calls_tool_from_second_stdio_page(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("second_page_tool", "{}")], [get_text_message("done")], diff --git a/tests/mcp/test_mcp_tracing.py b/tests/mcp/test_mcp_tracing.py index 2ebcf83cb1..074b054bb0 100644 --- a/tests/mcp/test_mcp_tracing.py +++ b/tests/mcp/test_mcp_tracing.py @@ -4,8 +4,8 @@ from inline_snapshot import snapshot from agents import Agent, RunConfig, Runner +from agents.testing import ScriptedModel -from ..fake_model import FakeModel from ..test_responses import get_function_tool, get_function_tool_call, get_text_message from ..testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans from .helpers import FakeMCPServer @@ -13,7 +13,7 @@ @pytest.mark.asyncio async def test_mcp_tracing(): - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer() server.add_tool("test_tool_1", {}) agent = Agent( @@ -23,7 +23,7 @@ async def test_mcp_tracing(): tools=[get_function_tool("non_mcp_tool", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -86,7 +86,7 @@ async def test_mcp_tracing(): SPAN_PROCESSOR_TESTING.clear() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -161,7 +161,7 @@ async def test_mcp_tracing(): # Add more tools to the server server.add_tool("test_tool_3", {}) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_tool_3", "")], @@ -223,12 +223,12 @@ async def test_mcp_tracing(): @pytest.mark.asyncio async def test_mcp_tracing_redacts_output_when_sensitive_data_disabled(): - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer() server.add_tool("test_tool_1", {}) agent = Agent(name="test", model=model, mcp_servers=[server]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("test_tool_1", "")], [get_text_message("done")], @@ -284,7 +284,7 @@ async def test_mcp_tracing_redacts_output_when_sensitive_data_disabled(): async def test_mcp_tracing_always_hides_url_credentials( trace_include_sensitive_data: bool, ): - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer( server_name=( "streamable_http: https://user:s3cr3t_pw@mcp.example.test:8443/mcp" @@ -293,7 +293,7 @@ async def test_mcp_tracing_always_hides_url_credentials( ) server.add_tool("search", {}) agent = Agent(name="test", model=model, mcp_servers=[server]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("search", "")], [get_text_message("done")], diff --git a/tests/mcp/test_prompt_server.py b/tests/mcp/test_prompt_server.py index 9df2048bcd..dc87c7f6bc 100644 --- a/tests/mcp/test_prompt_server.py +++ b/tests/mcp/test_prompt_server.py @@ -5,8 +5,8 @@ from agents import Agent, Runner from agents.mcp import MCPServer, MCPToolMetaResolver +from agents.testing import ScriptedModel -from ..fake_model import FakeModel from ..test_responses import get_text_message from .model_compat import ListResourceTemplatesResult @@ -173,13 +173,11 @@ async def test_agent_with_prompt_instructions(): instructions = prompt_result.messages[0].content.text # Create agent with prompt-generated instructions - model = FakeModel() + model = ScriptedModel() agent = Agent(name="prompt_agent", instructions=instructions, model=model, mcp_servers=[server]) # Mock model response - model.add_multiple_turn_outputs( - [[get_text_message("Code analysis complete. Found security vulnerability.")]] - ) + model.extend([[get_text_message("Code analysis complete. Found security vulnerability.")]]) # Run the agent result = await Runner.run(agent, input="Review this code: def unsafe_exec(cmd): os.system(cmd)") @@ -211,12 +209,12 @@ async def test_agent_with_prompt_instructions_streaming(streaming: bool): instructions = prompt_result.messages[0].content.text # Create agent - model = FakeModel() + model = ScriptedModel() agent = Agent( name="streaming_prompt_agent", instructions=instructions, model=model, mcp_servers=[server] ) - model.add_multiple_turn_outputs([[get_text_message("Security analysis complete.")]]) + model.extend([[get_text_message("Security analysis complete.")]]) if streaming: streaming_result = Runner.run_streamed(agent, input="Review code") diff --git a/tests/mcp/test_runner_calls_mcp.py b/tests/mcp/test_runner_calls_mcp.py index 9a97900d48..670f188554 100644 --- a/tests/mcp/test_runner_calls_mcp.py +++ b/tests/mcp/test_runner_calls_mcp.py @@ -15,8 +15,8 @@ handoff, ) from agents.exceptions import AgentsException +from agents.testing import ScriptedModel -from ..fake_model import FakeModel from ..test_responses import get_function_tool_call, get_text_message from .helpers import FakeMCPServer @@ -29,14 +29,14 @@ async def test_runner_calls_mcp_tool(streaming: bool): server.add_tool("test_tool_1", {}) server.add_tool("test_tool_2", {}) server.add_tool("test_tool_3", {}) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, mcp_servers=[server], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_tool_2", "")], @@ -63,14 +63,14 @@ async def test_runner_asserts_when_mcp_tool_not_found(streaming: bool): server.add_tool("test_tool_1", {}) server.add_tool("test_tool_2", {}) server.add_tool("test_tool_3", {}) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, mcp_servers=[server], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_tool_doesnt_exist", "")], @@ -99,14 +99,14 @@ async def test_runner_works_with_multiple_mcp_servers(streaming: bool): server2.add_tool("test_tool_2", {}) server2.add_tool("test_tool_3", {}) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, mcp_servers=[server1, server2], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_tool_2", "")], @@ -138,14 +138,14 @@ async def test_runner_errors_when_mcp_tools_clash(streaming: bool): server2.add_tool("test_tool_2", {}) server2.add_tool("test_tool_3", {}) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, mcp_servers=[server1, server2], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_tool_3", "")], @@ -172,7 +172,7 @@ async def test_runner_can_call_server_prefixed_mcp_tool_names(streaming: bool): server2 = FakeMCPServer(server_name="calendar") server2.add_tool("search", {}) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -180,7 +180,7 @@ async def test_runner_can_call_server_prefixed_mcp_tool_names(streaming: bool): mcp_config={"include_server_in_tool_names": True}, ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("a_message"), get_function_tool_call("mcp_calendar__search", "")], [get_text_message("done")], @@ -220,7 +220,7 @@ async def invoke_local_tool(context: Any, input_json: str) -> str: on_invoke_tool=invoke_local_tool, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -238,7 +238,7 @@ async def invoke_local_tool(context: Any, input_json: str) -> str: assert calendar_search_tool_name != "mcp_calendar__search" assert calendar_search_tool_name.startswith("mcp_calendar__search_") - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("a_message"), get_function_tool_call(calendar_search_tool_name, "")], [get_text_message("done")], @@ -263,11 +263,11 @@ async def test_runner_prefixed_mcp_tool_names_do_not_collide_with_handoffs(strea server = FakeMCPServer(server_name="calendar") server.add_tool("search", {}) - target_model = FakeModel() + target_model = ScriptedModel() target_agent = Agent(name="calendar_agent", model=target_model) - target_model.add_multiple_turn_outputs([[get_text_message("handoff target")]]) + target_model.extend([[get_text_message("handoff target")]]) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -282,7 +282,7 @@ async def test_runner_prefixed_mcp_tool_names_do_not_collide_with_handoffs(strea assert calendar_search_tool_name != "mcp_calendar__search" assert calendar_search_tool_name.startswith("mcp_calendar__search_") - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("a_message"), get_function_tool_call(calendar_search_tool_name, "")], [get_text_message("done")], @@ -297,7 +297,7 @@ async def test_runner_prefixed_mcp_tool_names_do_not_collide_with_handoffs(strea await Runner.run(agent, input="user_message") assert server.tool_calls == ["search"] - assert target_model.first_turn_args is None + assert not target_model.calls class Foo(BaseModel): @@ -314,7 +314,7 @@ async def test_runner_calls_mcp_tool_with_args(streaming: bool): server.add_tool("test_tool_1", {}) server.add_tool("test_tool_2", Foo.model_json_schema()) server.add_tool("test_tool_3", {}) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -323,7 +323,7 @@ async def test_runner_calls_mcp_tool_with_args(streaming: bool): json_args = json.dumps(Foo(bar="baz", baz=1).model_dump()) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_tool_2", json_args)], @@ -362,14 +362,14 @@ async def test_runner_emits_mcp_error_tool_call_output_item(streaming: bool): server = CrashingFakeMCPServer() server.add_tool("crashing_tool", {}) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, mcp_servers=[server], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("a_message"), get_function_tool_call("crashing_tool", "{}")], [get_text_message("done")], diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index 9117c64f21..d0075418d9 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -20,7 +20,7 @@ OpenAIConversationsSession, start_openai_conversations_session, ) -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message @@ -46,8 +46,8 @@ def mock_openai_client(): @pytest.fixture def agent() -> Agent: - """Fixture for a basic agent with a fake model.""" - return Agent(name="test", model=FakeModel()) + """Fixture for a basic agent with a scripted model.""" + return Agent(name="test", model=ScriptedModel()) class TestStartOpenAIConversationsSession: @@ -451,8 +451,8 @@ async def test_runner_integration_basic(self, agent: Agent, mock_openai_client): with patch.object(session, "get_items", return_value=[]): with patch.object(session, "add_items") as mock_add_items: # Run the agent - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("San Francisco")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("San Francisco")]) result = await Runner.run( agent, "What city is the Golden Gate Bridge in?", session=session @@ -477,15 +477,15 @@ async def test_runner_with_conversation_history(self, agent: Agent, mock_openai_ with patch.object(session, "get_items", return_value=conversation_history): with patch.object(session, "add_items"): # Second turn - should have access to previous conversation - assert isinstance(agent.model, FakeModel) - agent.model.set_next_output([get_text_message("California")]) + assert isinstance(agent.model, ScriptedModel) + agent.model.enqueue([get_text_message("California")]) result = await Runner.run(agent, "What state is it in?", session=session) assert result.final_output == "California" # Verify that the model received the conversation history - last_input = agent.model.last_turn_args["input"] + last_input = agent.model.calls[-1].input assert len(last_input) > 1 # Should include previous messages # Check that previous conversation is included @@ -495,8 +495,8 @@ async def test_runner_with_conversation_history(self, agent: Agent, mock_openai_ @pytest.mark.asyncio async def test_runner_persists_program_item_ids(self, mock_openai_client): """Program items keep the id the Conversations create-item schema requires.""" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ Program( diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index ae0c7e2028..7731c27778 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -29,7 +29,7 @@ TOOL_CALL_SESSION_DESCRIPTION_KEY, TOOL_CALL_SESSION_TITLE_KEY, ) -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_function_tool, get_function_tool_call, get_text_message from tests.utils.simple_session import SimpleListSession @@ -1463,7 +1463,7 @@ async def test_compaction_runs_during_runner_flow(self) -> None: should_trigger_compaction=lambda ctx: True, ) - model = FakeModel(initial_output=[get_text_message("ok")]) + model = ScriptedModel(steps=[[get_text_message("ok")]]) agent = Agent(name="assistant", model=model) await Runner.run(agent, "hello", session=session) @@ -1486,7 +1486,7 @@ async def test_compaction_skips_when_tool_outputs_present(self) -> None: ) tool = get_function_tool(name="do_thing", return_value="done") - model = FakeModel(initial_output=[get_function_tool_call("do_thing")]) + model = ScriptedModel(steps=[[get_function_tool_call("do_thing")]]) agent = Agent( name="assistant", model=model, @@ -1518,7 +1518,7 @@ def should_trigger_compaction(context: dict[str, Any]) -> bool: ) tool = get_function_tool(name="do_thing", return_value="done") - model = FakeModel(initial_output=[get_function_tool_call("do_thing")]) + model = ScriptedModel(steps=[[get_function_tool_call("do_thing")]]) agent = Agent( name="assistant", model=model, @@ -1554,8 +1554,8 @@ def should_trigger_compaction(context: dict[str, Any]) -> bool: ) tool = get_function_tool(name="do_thing", return_value="done") - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("do_thing")], [get_text_message("ok")], @@ -1596,8 +1596,8 @@ def should_trigger_compaction(context: dict[str, Any]) -> bool: ) tool = get_function_tool(name="do_thing", return_value="done") - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("do_thing")], [get_function_tool_call("do_thing")], diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index 8c8bf5ea0d..9667c5b23c 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -11,7 +11,7 @@ from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession, TResponseInputItem from agents.memory.sqlite_session import _await_mutation -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message @@ -95,11 +95,11 @@ async def test_session_memory_basic_functionality_parametrized(runner_method): session_id = "test_session_123" session = SQLiteSession(session_id, db_path) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # First turn - model.set_next_output([get_text_message("San Francisco")]) + model.enqueue([get_text_message("San Francisco")]) result1 = await run_agent_async( runner_method, agent, @@ -109,7 +109,7 @@ async def test_session_memory_basic_functionality_parametrized(runner_method): assert result1.final_output == "San Francisco" # Second turn - should have conversation history - model.set_next_output([get_text_message("California")]) + model.enqueue([get_text_message("California")]) result2 = await run_agent_async( runner_method, agent, @@ -120,7 +120,7 @@ async def test_session_memory_basic_functionality_parametrized(runner_method): # Verify that the input to the second turn includes the previous conversation # The model should have received the full conversation history - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert len(last_input) > 1 # Should have more than just the current message session.close() @@ -135,16 +135,16 @@ async def test_session_memory_with_explicit_instance_parametrized(runner_method) session_id = "test_session_456" session = SQLiteSession(session_id, db_path) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # First turn - model.set_next_output([get_text_message("Hello")]) + model.enqueue([get_text_message("Hello")]) result1 = await run_agent_async(runner_method, agent, "Hi there", session=session) assert result1.final_output == "Hello" # Second turn - model.set_next_output([get_text_message("I remember you said hi")]) + model.enqueue([get_text_message("I remember you said hi")]) result2 = await run_agent_async( runner_method, agent, @@ -160,21 +160,21 @@ async def test_session_memory_with_explicit_instance_parametrized(runner_method) @pytest.mark.asyncio async def test_session_memory_disabled_parametrized(runner_method): """Test that session memory is disabled when session=None across all runner methods.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # First turn (no session parameters = disabled) - model.set_next_output([get_text_message("Hello")]) + model.enqueue([get_text_message("Hello")]) result1 = await run_agent_async(runner_method, agent, "Hi there") assert result1.final_output == "Hello" # Second turn - should NOT have conversation history - model.set_next_output([get_text_message("I don't remember")]) + model.enqueue([get_text_message("I don't remember")]) result2 = await run_agent_async(runner_method, agent, "Do you remember what I said?") assert result2.final_output == "I don't remember" # Verify that the input to the second turn is just the current message - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert len(last_input) == 1 # Should only have the current message @@ -186,14 +186,14 @@ async def test_session_memory_different_sessions_parametrized(runner_method): with tempfile.TemporaryDirectory() as temp_dir: db_path = Path(temp_dir) / "test_memory.db" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Session 1 session_id_1 = "session_1" session_1 = SQLiteSession(session_id_1, db_path) - model.set_next_output([get_text_message("I like cats")]) + model.enqueue([get_text_message("I like cats")]) result1 = await run_agent_async(runner_method, agent, "I like cats", session=session_1) assert result1.final_output == "I like cats" @@ -201,12 +201,12 @@ async def test_session_memory_different_sessions_parametrized(runner_method): session_id_2 = "session_2" session_2 = SQLiteSession(session_id_2, db_path) - model.set_next_output([get_text_message("I like dogs")]) + model.enqueue([get_text_message("I like dogs")]) result2 = await run_agent_async(runner_method, agent, "I like dogs", session=session_2) assert result2.final_output == "I like dogs" # Back to Session 1 - should remember cats, not dogs - model.set_next_output([get_text_message("Yes, you mentioned cats")]) + model.enqueue([get_text_message("Yes, you mentioned cats")]) result3 = await run_agent_async( runner_method, agent, @@ -548,7 +548,7 @@ async def test_session_memory_appends_list_input_by_default(runner_method): session_id = "test_validation_parametrized" session = SQLiteSession(session_id, db_path) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) initial_history: list[TResponseInputItem] = [ @@ -559,10 +559,10 @@ async def test_session_memory_appends_list_input_by_default(runner_method): list_input = [{"role": "user", "content": "Test message"}] - model.set_next_output([get_text_message("This should run")]) + model.enqueue([get_text_message("This should run")]) await run_agent_async(runner_method, agent, list_input, session=session) - assert model.last_turn_args["input"] == initial_history + list_input + assert model.calls[-1].input == initial_history + list_input session.close() @@ -574,7 +574,7 @@ async def test_session_callback_prepared_input(runner_method): with tempfile.TemporaryDirectory() as temp_dir: db_path = Path(temp_dir) / "test_memory.db" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Session @@ -594,7 +594,7 @@ def filter_assistant_messages(history, new_input): return [item for item in history if item["role"] == "user"] + new_input new_turn_input = [{"role": "user", "content": "What your name?"}] - model.set_next_output([get_text_message("I'm gpt-4o")]) + model.enqueue([get_text_message("I'm gpt-4o")]) # Run the agent with the callable await run_agent_async( @@ -610,8 +610,8 @@ def filter_assistant_messages(history, new_input): new_turn_input[0], # New input ] - assert len(model.last_turn_args["input"]) == 2 - assert model.last_turn_args["input"] == expected_model_input + assert len(model.calls[-1].input) == 2 + assert model.calls[-1].input == expected_model_input finally: session.close() @@ -621,7 +621,7 @@ def filter_assistant_messages(history, new_input): async def test_session_callback_repeating_history_does_not_grow_session(runner_method): with tempfile.TemporaryDirectory() as temp_dir: db_path = Path(temp_dir) / "test_memory.db" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) session = SQLiteSession("session_repeat", db_path) @@ -632,7 +632,7 @@ def repeat_first(history, new_input): try: for turn in range(3): - model.set_next_output([get_text_message(f"assistant {turn}")]) + model.enqueue([get_text_message(f"assistant {turn}")]) await run_agent_async( runner_method, agent, @@ -825,9 +825,9 @@ async def _failing_add_items(_items): session.add_items = _failing_add_items # type: ignore[method-assign] - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("This should not be reached")]) + model.enqueue([get_text_message("This should not be reached")]) result = Runner.run_streamed(agent, "Hello", session=session) @@ -981,9 +981,9 @@ async def test_runner_with_session_settings_override(): ] await session.add_items(items) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("Got it")]) + model.enqueue([get_text_message("Got it")]) await Runner.run( agent, @@ -995,7 +995,7 @@ async def test_runner_with_session_settings_override(): ) # Verify the agent received only the last 2 history items + new question - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # Filter out the new "New question" input history_items = [item for item in last_input if item.get("content") != "New question"] # Should have 2 history items (last two from the 10 we added) diff --git a/tests/memory/test_session_context_wrapper.py b/tests/memory/test_session_context_wrapper.py index 0a7cf65f24..2cb2dba68e 100644 --- a/tests/memory/test_session_context_wrapper.py +++ b/tests/memory/test_session_context_wrapper.py @@ -11,8 +11,8 @@ from agents.memory import OpenAIResponsesCompactionSession from agents.memory.session import _session_accepts_wrapper, _session_method_accepts_wrapper from agents.run_internal.session_persistence import rewind_session_items +from agents.testing import ScriptedModel from agents.tool import function_tool -from tests.fake_model import FakeModel from tests.test_responses import get_function_tool_call, get_text_message @@ -136,7 +136,7 @@ async def __call__(self, *args: Any, **kwargs: Any) -> Any: @pytest.mark.asyncio async def test_runner_passes_same_wrapper_to_context_aware_session(streamed: bool) -> None: session = ContextAwareSession() - model = FakeModel(initial_output=[get_text_message("ok")]) + model = ScriptedModel(steps=[[get_text_message("ok")]]) agent = Agent(name="test", model=model) context = TenantContext(tenant_id="tenant-a") @@ -161,7 +161,7 @@ async def test_runner_passes_same_wrapper_to_context_aware_session(streamed: boo @pytest.mark.asyncio async def test_runner_preserves_legacy_session_call_shapes() -> None: session = LegacySession() - model = FakeModel(initial_output=[get_text_message("ok")]) + model = ScriptedModel(steps=[[get_text_message("ok")]]) agent = Agent(name="test", model=model) result = await Runner.run( @@ -179,7 +179,7 @@ async def test_runner_preserves_legacy_session_call_shapes() -> None: @pytest.mark.asyncio async def test_runner_does_not_treat_legacy_kwargs_as_wrapper_opt_in() -> None: session = LegacyKwargsSession() - model = FakeModel(initial_output=[get_text_message("ok")]) + model = ScriptedModel(steps=[[get_text_message("ok")]]) result = await Runner.run( Agent(name="test", model=model), @@ -197,7 +197,7 @@ async def test_runner_does_not_treat_legacy_kwargs_as_wrapper_opt_in() -> None: async def test_runner_preserves_legacy_calls_when_signature_inspection_fails() -> None: session = cast(Any, LegacySession()) session.get_items = UninspectableAsyncMethod(session.get_items) - model = FakeModel(initial_output=[get_text_message("ok")]) + model = ScriptedModel(steps=[[get_text_message("ok")]]) result = await Runner.run( Agent(name="test", model=model), @@ -245,7 +245,7 @@ async def clear_session(self) -> None: pass session = PartialSession() - model = FakeModel(initial_output=[get_text_message("ok")]) + model = ScriptedModel(steps=[[get_text_message("ok")]]) result = await Runner.run( Agent(name="test", model=model), @@ -338,7 +338,7 @@ def guardrail_function( context = TenantContext(tenant_id="tenant-a") agent = Agent( name="test", - model=FakeModel(initial_output=[get_text_message("not persisted")]), + model=ScriptedModel(steps=[[get_text_message("not persisted")]]), input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)], ) @@ -362,8 +362,8 @@ async def test_tool() -> str: return "tool result" tool = function_tool(test_tool, name_override="test_tool", needs_approval=True) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("test_tool", "{}", call_id="call-resume")], [get_text_message("done")], @@ -415,7 +415,7 @@ async def test_compaction_session_keeps_context_aware_underlying_on_legacy_scope ) result = await Runner.run( - Agent(name="test", model=FakeModel(initial_output=[get_text_message("done")])), + Agent(name="test", model=ScriptedModel(steps=[[get_text_message("done")]])), "hello", context=TenantContext(tenant_id="tenant-a"), session=session, diff --git a/tests/memory/test_session_limit.py b/tests/memory/test_session_limit.py index 3a2311d4be..f48611c7c8 100644 --- a/tests/memory/test_session_limit.py +++ b/tests/memory/test_session_limit.py @@ -9,7 +9,7 @@ from agents import Agent, RunConfig, SQLiteSession from agents.items import TResponseInputItem from agents.memory import SessionSettings -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.memory.test_session import run_agent_async from tests.test_responses import get_text_message @@ -24,17 +24,17 @@ async def test_session_limit_parameter(runner_method): session_id = "limit_test" session = SQLiteSession(session_id, db_path) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Build up a longer conversation history - model.set_next_output([get_text_message("Reply 1")]) + model.enqueue([get_text_message("Reply 1")]) await run_agent_async(runner_method, agent, "Message 1", session=session) - model.set_next_output([get_text_message("Reply 2")]) + model.enqueue([get_text_message("Reply 2")]) await run_agent_async(runner_method, agent, "Message 2", session=session) - model.set_next_output([get_text_message("Reply 3")]) + model.enqueue([get_text_message("Reply 3")]) await run_agent_async(runner_method, agent, "Message 3", session=session) # Verify we have 6 items in total (3 user + 3 assistant) @@ -42,7 +42,7 @@ async def test_session_limit_parameter(runner_method): assert len(all_items) == 6 # Test session_limit via RunConfig - should only get last 2 history items + new input - model.set_next_output([get_text_message("Reply 4")]) + model.enqueue([get_text_message("Reply 4")]) await run_agent_async( runner_method, agent, @@ -52,7 +52,7 @@ async def test_session_limit_parameter(runner_method): ) # Verify model received limited history - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # Should have: 2 history items + 1 new message = 3 total assert len(last_input) == 3 # First item should be "Message 3" (not Message 1 or 2) @@ -102,8 +102,8 @@ async def test_session_limit_drops_unmatched_history_function_call_output(runner assert await session.get_items(limit=2) == history[-2:] - model = FakeModel() - model.set_next_output([get_text_message("Tomorrow is sunny too.")]) + model = ScriptedModel() + model.enqueue([get_text_message("Tomorrow is sunny too.")]) agent = Agent(name="test", model=model) await run_agent_async( @@ -114,7 +114,7 @@ async def test_session_limit_drops_unmatched_history_function_call_output(runner run_config=RunConfig(session_settings=SessionSettings(limit=2)), ) - assert model.last_turn_args["input"] == [ + assert model.calls[-1].input == [ history[-1], {"role": "user", "content": "What about tomorrow?"}, ] @@ -130,18 +130,18 @@ async def test_session_limit_zero(runner_method): session_id = "limit_zero_test" session = SQLiteSession(session_id, db_path) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Build conversation history - model.set_next_output([get_text_message("Reply 1")]) + model.enqueue([get_text_message("Reply 1")]) await run_agent_async(runner_method, agent, "Message 1", session=session) - model.set_next_output([get_text_message("Reply 2")]) + model.enqueue([get_text_message("Reply 2")]) await run_agent_async(runner_method, agent, "Message 2", session=session) # Test with limit=0 - should get NO history, just new message - model.set_next_output([get_text_message("Reply 3")]) + model.enqueue([get_text_message("Reply 3")]) await run_agent_async( runner_method, agent, @@ -151,7 +151,7 @@ async def test_session_limit_zero(runner_method): ) # Verify model received only the new message - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert len(last_input) == 1 assert last_input[0].get("content") == "Message 3" @@ -167,12 +167,12 @@ async def test_session_limit_none_gets_all_history(runner_method): session_id = "limit_none_test" session = SQLiteSession(session_id, db_path) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Build longer conversation for i in range(1, 6): - model.set_next_output([get_text_message(f"Reply {i}")]) + model.enqueue([get_text_message(f"Reply {i}")]) await run_agent_async(runner_method, agent, f"Message {i}", session=session) # Verify 10 items in session (5 user + 5 assistant) @@ -180,7 +180,7 @@ async def test_session_limit_none_gets_all_history(runner_method): assert len(all_items) == 10 # Test with session_limit=None (default) - should get all history - model.set_next_output([get_text_message("Reply 6")]) + model.enqueue([get_text_message("Reply 6")]) await run_agent_async( runner_method, agent, @@ -190,7 +190,7 @@ async def test_session_limit_none_gets_all_history(runner_method): ) # Verify model received all history + new message - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert len(last_input) == 11 # 10 history + 1 new assert last_input[0].get("content") == "Message 1" assert last_input[-1].get("content") == "Message 6" @@ -207,15 +207,15 @@ async def test_session_limit_larger_than_history(runner_method): session_id = "limit_large_test" session = SQLiteSession(session_id, db_path) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Build small conversation - model.set_next_output([get_text_message("Reply 1")]) + model.enqueue([get_text_message("Reply 1")]) await run_agent_async(runner_method, agent, "Message 1", session=session) # Test with limit=100 (much larger than actual history) - model.set_next_output([get_text_message("Reply 2")]) + model.enqueue([get_text_message("Reply 2")]) await run_agent_async( runner_method, agent, @@ -225,7 +225,7 @@ async def test_session_limit_larger_than_history(runner_method): ) # Verify model received all available history + new message - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert len(last_input) == 3 # 2 history + 1 new assert last_input[0].get("content") == "Message 1" # Assistant message has content as a list diff --git a/tests/model_test_helpers.py b/tests/model_test_helpers.py new file mode 100644 index 0000000000..02b4ca941f --- /dev/null +++ b/tests/model_test_helpers.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import copy +from collections.abc import AsyncIterator + +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseOutputItemDoneEvent, + ResponseUsage, +) +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails + +from agents.items import TResponseOutputItem, TResponseStreamEvent +from agents.testing import ModelStep +from agents.usage import Usage + + +def get_response_obj( + output: list[TResponseOutputItem], + response_id: str | None = None, + usage: Usage | None = None, +) -> Response: + """Build an OpenAI response object for adapter-level tests.""" + return Response( + id=response_id or "resp-789", + created_at=123, + model="test_model", + object="response", + output=output, + tool_choice="none", + tools=[], + top_p=None, + parallel_tool_calls=False, + usage=ResponseUsage( + input_tokens=usage.input_tokens if usage else 0, + output_tokens=usage.output_tokens if usage else 0, + total_tokens=usage.total_tokens if usage else 0, + input_tokens_details=InputTokensDetails.model_validate( + { + "cache_write_tokens": ( + getattr(usage.input_tokens_details, "cache_write_tokens", 0) if usage else 0 + ), + "cached_tokens": ( + getattr(usage.input_tokens_details, "cached_tokens", 0) if usage else 0 + ), + } + ), + output_tokens_details=OutputTokensDetails( + reasoning_tokens=( + getattr(usage.output_tokens_details, "reasoning_tokens", 0) if usage else 0 + ) + ), + ), + ) + + +def get_exact_output_stream_step(output: list[TResponseOutputItem]) -> ModelStep: + """Build an exact normalized stream for tests whose subject is downstream processing.""" + stream_output = copy.deepcopy(output) + + async def events(_call: object) -> AsyncIterator[TResponseStreamEvent]: + for output_index, output_item in enumerate(stream_output): + yield ResponseOutputItemDoneEvent( + type="response.output_item.done", + item=output_item, + output_index=output_index, + sequence_number=output_index, + ) + yield ResponseCompletedEvent( + type="response.completed", + response=get_response_obj(stream_output), + sequence_number=len(stream_output), + ) + + return ModelStep.stream(events) diff --git a/tests/models/test_map.py b/tests/models/test_map.py index 20f1cf1469..1ac2daf09b 100644 --- a/tests/models/test_map.py +++ b/tests/models/test_map.py @@ -158,9 +158,9 @@ def __init__(self, **kwargs): def get_model(self, model_name): captured_model["value"] = model_name - fake_model = object() - captured_result["value"] = fake_model - return fake_model + mapped_model = object() + captured_result["value"] = mapped_model + return mapped_model monkeypatch.setattr("agents.models.multi_provider.OpenAIProvider", FakeOpenAIProvider) diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 627d65a301..0d17eb1746 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -41,7 +41,7 @@ ) from agents.retry import ModelRetryAdviceRequest from agents.usage import Usage -from tests.fake_model import get_response_obj +from tests.model_test_helpers import get_response_obj from tests.testing_processor import fetch_ordered_spans diff --git a/tests/realtime/test_runner.py b/tests/realtime/test_runner.py index 39b9d4031a..9b32fced88 100644 --- a/tests/realtime/test_runner.py +++ b/tests/realtime/test_runner.py @@ -4,42 +4,20 @@ from agents.realtime.agent import RealtimeAgent from agents.realtime.config import RealtimeRunConfig, RealtimeSessionModelSettings -from agents.realtime.model import RealtimeModel, RealtimeModelConfig +from agents.realtime.model import RealtimeModelConfig from agents.realtime.runner import RealtimeRunner from agents.realtime.session import RealtimeSession +from agents.realtime.testing import RealtimeConnectCall, ScriptedRealtimeModel from agents.tool import function_tool -class MockRealtimeModel(RealtimeModel): - def __init__(self): - self.connect_args = None +class RunnerRealtimeModel(ScriptedRealtimeModel): + def __init__(self) -> None: + super().__init__(strict=False) - async def connect(self, options=None): - self.connect_args = options - - def add_listener(self, listener): - pass - - def remove_listener(self, listener): - pass - - async def send_event(self, event): - pass - - async def send_message(self, message, other_event_data=None): - pass - - async def send_audio(self, audio, commit=False): - pass - - async def send_tool_output(self, tool_call, output, start_response=True): - pass - - async def interrupt(self): - pass - - async def close(self): - pass + @property + def connect_args(self) -> RealtimeConnectCall | None: + return self.connect_calls[-1] if self.connect_calls else None @pytest.fixture @@ -52,12 +30,12 @@ def mock_agent(): @pytest.fixture def mock_model(): - return MockRealtimeModel() + return RunnerRealtimeModel() @pytest.mark.asyncio async def test_run_preserves_falsey_custom_model(mock_agent: Mock): - class FalseyRealtimeModel(MockRealtimeModel): + class FalseyRealtimeModel(RunnerRealtimeModel): def __bool__(self) -> bool: return False @@ -70,7 +48,7 @@ def __bool__(self) -> bool: @pytest.mark.asyncio async def test_run_creates_session_with_no_settings( - mock_agent: Mock, mock_model: MockRealtimeModel + mock_agent: Mock, mock_model: RunnerRealtimeModel ): """Test that run() creates a session correctly if no settings are provided""" runner = RealtimeRunner(mock_agent, model=mock_model) @@ -98,7 +76,7 @@ async def test_run_creates_session_with_no_settings( @pytest.mark.asyncio async def test_run_creates_session_with_settings_only_in_init( - mock_agent: Mock, mock_model: MockRealtimeModel + mock_agent: Mock, mock_model: RunnerRealtimeModel ): """Test that it creates a session with the right settings if they are provided only in init""" config = RealtimeRunConfig( @@ -122,7 +100,7 @@ async def test_run_creates_session_with_settings_only_in_init( @pytest.mark.asyncio async def test_run_creates_session_with_settings_in_both_init_and_run_overrides( - mock_agent: Mock, mock_model: MockRealtimeModel + mock_agent: Mock, mock_model: RunnerRealtimeModel ): """Test settings provided in run() parameter are passed through""" init_config = RealtimeRunConfig( @@ -152,7 +130,7 @@ async def test_run_creates_session_with_settings_in_both_init_and_run_overrides( @pytest.mark.asyncio async def test_run_creates_session_with_settings_only_in_run( - mock_agent: Mock, mock_model: MockRealtimeModel + mock_agent: Mock, mock_model: RunnerRealtimeModel ): """Test settings provided only in run()""" runner = RealtimeRunner(mock_agent, model=mock_model) @@ -178,7 +156,7 @@ async def test_run_creates_session_with_settings_only_in_run( @pytest.mark.asyncio -async def test_run_with_context_parameter(mock_agent: Mock, mock_model: MockRealtimeModel): +async def test_run_with_context_parameter(mock_agent: Mock, mock_model: RunnerRealtimeModel): """Test that context parameter is passed through to session""" runner = RealtimeRunner(mock_agent, model=mock_model) test_context = {"user_id": "test123"} @@ -194,7 +172,7 @@ async def test_run_with_context_parameter(mock_agent: Mock, mock_model: MockReal @pytest.mark.asyncio -async def test_run_with_none_values_from_agent_does_not_crash(mock_model: MockRealtimeModel): +async def test_run_with_none_values_from_agent_does_not_crash(mock_model: RunnerRealtimeModel): """Test that runner handles agents with None values without crashing""" agent = Mock(spec=RealtimeAgent) agent.get_system_prompt = AsyncMock(return_value=None) @@ -216,7 +194,7 @@ async def test_run_with_none_values_from_agent_does_not_crash(mock_model: MockRe @pytest.mark.asyncio -async def test_tool_and_handoffs_are_correct(mock_model: MockRealtimeModel): +async def test_tool_and_handoffs_are_correct(mock_model: RunnerRealtimeModel): @function_tool def tool_one(): return "result_one" diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index a9951069ce..546b22a224 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -76,6 +76,7 @@ _PendingToolOutputSendError, _serialize_tool_output, ) +from agents.realtime.testing import RealtimeConnectCall, ScriptedRealtimeModel from agents.run_context import RunContextWrapper from agents.tool import FunctionTool, function_tool, tool_namespace from agents.tool_context import ToolContext @@ -87,39 +88,23 @@ from agents.usage import Usage -class _DummyModel(RealtimeModel): +class _DummyModel(ScriptedRealtimeModel): def __init__(self) -> None: - super().__init__() - self.events: list[Any] = [] - self.listeners: list[Any] = [] - self.connect_options: Any | None = None - - async def connect(self, options=None): - self.connect_options = options - - async def close(self): # pragma: no cover - not used here - pass + super().__init__(strict=False) - async def send_event(self, event): - self.events.append(event) - - def add_listener(self, listener): - self.listeners.append(listener) + @property + def events(self) -> tuple[Any, ...]: + return self.sent_events - def remove_listener(self, listener): - if listener in self.listeners: - self.listeners.remove(listener) + @property + def connect_options(self) -> RealtimeConnectCall | None: + return self.connect_calls[-1] if self.connect_calls else None class _FailingConnectModel(_DummyModel): def __init__(self, exc: BaseException) -> None: super().__init__() - self.exc = exc - self.connect_options: Any | None = None - - async def connect(self, options=None): - self.connect_options = options - raise self.exc + self._connect_error = exc def _agent_with_ambiguous_realtime_tools(name: str = "invalid_agent") -> RealtimeAgent: @@ -208,8 +193,11 @@ async def test_property_and_send_helpers_and_enter_alias(): # property assert session.model is model - # enter alias calls __aenter__ - async with await session.enter(): + # The enter alias calls __aenter__, so callers close it manually. + entered = await session.enter() + try: + assert entered is session + # send helpers await session.send_message("hi") await session.send_audio(b"abc", commit=True) @@ -219,6 +207,8 @@ async def test_property_and_send_helpers_and_enter_alias(): assert any(isinstance(e, RealtimeModelSendUserInput) for e in model.events) assert any(isinstance(e, RealtimeModelSendAudio) and e.commit for e in model.events) assert any(isinstance(e, RealtimeModelSendInterrupt) for e in model.events) + finally: + await session.close() @pytest.mark.asyncio @@ -1278,7 +1268,7 @@ async def test_aenter_validates_initial_model_settings_before_listener_registrat with pytest.raises(UserError, match="Duplicate Realtime tool"): await session.__aenter__() - assert model.listeners == [] + assert model.listeners == () @pytest.mark.parametrize( @@ -1296,16 +1286,12 @@ async def test_aenter_removes_listener_when_connect_fails(exc: BaseException): await session.__aenter__() assert model.connect_options is not None - assert model.listeners == [] + assert model.listeners == () -class MockRealtimeModel(RealtimeModel): +class RecordingRealtimeModel(ScriptedRealtimeModel): def __init__(self): - super().__init__() - self.listeners = [] - self.connect_called = False - self.close_called = False - self.sent_events = [] + super().__init__(strict=False) # Legacy tracking for tests that haven't been updated yet self.sent_messages = [] self.sent_audio = [] @@ -1313,16 +1299,6 @@ def __init__(self): self.interrupts_called = 0 self.retired_audio_response_ids = [] - async def connect(self, options=None): - self.connect_called = True - - def add_listener(self, listener): - self.listeners.append(listener) - - def remove_listener(self, listener): - if listener in self.listeners: - self.listeners.remove(listener) - async def send_event(self, event): from agents.realtime.model_inputs import ( RealtimeModelSendAudio, @@ -1331,7 +1307,7 @@ async def send_event(self, event): RealtimeModelSendUserInput, ) - self.sent_events.append(event) + self._sent_events.append(self._snapshot_send_event(event)) # Update legacy tracking for compatibility if isinstance(event, RealtimeModelSendUserInput): @@ -1349,9 +1325,6 @@ async def send_event_if(self, event, send_if): await self.send_event(event) return True - async def close(self): - self.close_called = True - def _retire_response_audio(self, response_id: str) -> None: self.retired_audio_response_ids.append(response_id) @@ -1368,7 +1341,7 @@ def mock_agent(): @pytest.fixture def mock_model(): - return MockRealtimeModel() + return RecordingRealtimeModel() def _set_default_timeout_fields(tool: Mock) -> Mock: @@ -1392,7 +1365,7 @@ def tool_func() -> str: return tool -def _sent_tool_output_strings(model: MockRealtimeModel) -> list[str]: +def _sent_tool_output_strings(model: RecordingRealtimeModel) -> list[str]: return [output for _call, output, _start_response in model.sent_tool_outputs] @@ -2598,7 +2571,7 @@ async def test_function_tool_send_failure_retries_cached_output_without_rerun( ): """An approved call should retry cached output only for the same invocation.""" - class FailingToolOutputModel(MockRealtimeModel): + class FailingToolOutputModel(RecordingRealtimeModel): def __init__(self): super().__init__() self.fail_next_tool_output = True @@ -2696,7 +2669,7 @@ async def test_async_function_tool_send_failure_retries_cached_output_without_re ): """The async approval path should bind retries to the original invocation.""" - class FailingToolOutputModel(MockRealtimeModel): + class FailingToolOutputModel(RecordingRealtimeModel): def __init__(self): super().__init__() self.fail_next_tool_output = True @@ -3012,7 +2985,7 @@ async def test_handoff_validation_failure_keeps_current_agent(self, mock_model): ) assert session._current_agent is first_agent - assert mock_model.sent_events == [] + assert mock_model.sent_events == () assert mock_model.sent_tool_outputs == [] assert "call_invalid" not in session._active_tool_invocations assert not session._context_wrapper._tool_invocations["call_invalid"].completed @@ -3807,7 +3780,7 @@ async def test_reject_pending_tool_call_reserves_call_id_before_sending( ): """A duplicate event during rejection output sending should not emit a second output.""" - class BlockingToolOutputModel(MockRealtimeModel): + class BlockingToolOutputModel(RecordingRealtimeModel): def __init__(self): super().__init__() self.started = asyncio.Event() @@ -4171,7 +4144,7 @@ async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: @pytest.mark.asyncio async def test_pending_function_output_rejects_handoff_role_reuse(self): - class FailingToolOutputModel(MockRealtimeModel): + class FailingToolOutputModel(RecordingRealtimeModel): async def send_event(self, event): if isinstance(event, RealtimeModelSendToolOutput): raise RuntimeError("send failed") @@ -4627,14 +4600,14 @@ class ToolResult(BaseModel): assert sent_output == json.dumps({"name": "demo", "score": 7}) def test_serialize_tool_output_ignores_non_pydantic_model_dump_objects(self) -> None: - class FakeModelDump: + class ModelDumpObject: def model_dump(self, *_args: Any, **_kwargs: Any) -> dict[str, Any]: raise AssertionError("non-pydantic objects should not use model_dump") def __str__(self) -> str: return "fake-model-dump-object" - assert _serialize_tool_output(FakeModelDump()) == "fake-model-dump-object" + assert _serialize_tool_output(ModelDumpObject()) == "fake-model-dump-object" def test_serialize_tool_output_falls_back_when_pydantic_json_dump_fails(self) -> None: class FallbackModel(BaseModel): @@ -5044,7 +5017,7 @@ async def test_response_audio_cleanup_waits_for_delayed_guardrail(self, mock_age release_guardrail = asyncio.Event() operations: list[str] = [] - class TrackingModel(MockRealtimeModel): + class TrackingModel(RecordingRealtimeModel): async def send_event(self, event): await super().send_event(event) if isinstance(event, RealtimeModelSendInterrupt): @@ -5158,7 +5131,7 @@ async def test_interrupted_response_audio_delta_is_not_forwarded(self, mock_mode @pytest.mark.asyncio async def test_response_audio_cleanup_error_releases_session_suppression(self, mock_agent): - class FailingRetirementModel(MockRealtimeModel): + class FailingRetirementModel(RecordingRealtimeModel): def _retire_response_audio(self, response_id: str) -> None: raise RuntimeError(f"failed to retire {response_id}") @@ -5251,7 +5224,7 @@ async def test_output_text_guardrail_rechecks_generation_at_feedback_send_bounda feedback_send_started = asyncio.Event() release_feedback_send = asyncio.Event() - class BoundaryCheckingModel(MockRealtimeModel): + class BoundaryCheckingModel(RecordingRealtimeModel): async def send_event_if(self, event, send_if): feedback_send_started.set() await release_feedback_send.wait() @@ -5288,7 +5261,7 @@ async def send_event_if(self, event, send_if): async def test_output_text_guardrail_skips_feedback_without_atomic_model_send( self, mock_agent, triggered_guardrail ): - class CustomModelWithoutAtomicSend(MockRealtimeModel): + class CustomModelWithoutAtomicSend(RecordingRealtimeModel): def __init__(self): super().__init__() self.feedback_send_started = False @@ -6128,7 +6101,7 @@ async def test_update_agent_validation_failure_keeps_current_agent(self, mock_mo await session.update_agent(invalid_agent) assert session._current_agent is first_agent - assert mock_model.sent_events == [] + assert mock_model.sent_events == () class TestTranscriptPreservation: diff --git a/tests/realtime/test_session_exceptions.py b/tests/realtime/test_session_exceptions.py index 7da5aa9b1c..53356bcaa0 100644 --- a/tests/realtime/test_session_exceptions.py +++ b/tests/realtime/test_session_exceptions.py @@ -2,87 +2,23 @@ import asyncio import json -from typing import Any from unittest.mock import AsyncMock, Mock import pytest import websockets.exceptions from agents.realtime.events import RealtimeError -from agents.realtime.model import RealtimeModel, RealtimeModelConfig, RealtimeModelListener from agents.realtime.model_events import ( RealtimeModelErrorEvent, RealtimeModelEvent, RealtimeModelExceptionEvent, ) from agents.realtime.session import RealtimeSession +from agents.realtime.testing import ScriptedRealtimeModel -class FakeRealtimeModel(RealtimeModel): - """Fake model for testing that forwards events to listeners.""" - - def __init__(self): - self._listeners: list[RealtimeModelListener] = [] - self._events_to_send: list[RealtimeModelEvent] = [] - self._is_connected = False - self._send_task: asyncio.Task[None] | None = None - - def set_next_events(self, events: list[RealtimeModelEvent]) -> None: - """Set events to be sent to listeners.""" - self._events_to_send = events.copy() - - async def connect(self, options: RealtimeModelConfig) -> None: - """Fake connection that starts sending events.""" - self._is_connected = True - self._send_task = asyncio.create_task(self._send_events()) - - async def _send_events(self) -> None: - """Send queued events to all listeners.""" - for event in self._events_to_send: - await asyncio.sleep(0.001) # Small delay to simulate async behavior - for listener in self._listeners: - await listener.on_event(event) - - def add_listener(self, listener: RealtimeModelListener) -> None: - """Add a listener.""" - self._listeners.append(listener) - - def remove_listener(self, listener: RealtimeModelListener) -> None: - """Remove a listener.""" - if listener in self._listeners: - self._listeners.remove(listener) - - async def close(self) -> None: - """Close the fake model.""" - self._is_connected = False - if self._send_task and not self._send_task.done(): - self._send_task.cancel() - try: - await self._send_task - except asyncio.CancelledError: - pass - - async def send_message( - self, message: Any, other_event_data: dict[str, Any] | None = None - ) -> None: - """Fake send message.""" - pass - - async def send_audio(self, audio: bytes, *, commit: bool = False) -> None: - """Fake send audio.""" - pass - - async def send_event(self, event: Any) -> None: - """Fake send event.""" - pass - - async def send_tool_output(self, tool_call: Any, output: str, start_response: bool) -> None: - """Fake send tool output.""" - pass - - async def interrupt(self) -> None: - """Fake interrupt.""" - pass +def model_with_events(*events: RealtimeModelEvent) -> ScriptedRealtimeModel: + return ScriptedRealtimeModel(connect_events=events, strict=False) @pytest.fixture @@ -95,19 +31,11 @@ def fake_agent(): return agent -@pytest.fixture -def fake_model(): - """Create a fake model for testing.""" - return FakeRealtimeModel() - - class TestSessionExceptions: """Test exception handling in RealtimeSession.""" @pytest.mark.asyncio - async def test_end_to_end_exception_propagation_and_cleanup( - self, fake_model: FakeRealtimeModel, fake_agent - ): + async def test_end_to_end_exception_propagation_and_cleanup(self, fake_agent): """Test that exceptions are stored, trigger cleanup, and are raised in __aiter__.""" # Create test exception test_exception = ValueError("Test error") @@ -116,10 +44,8 @@ async def test_end_to_end_exception_propagation_and_cleanup( ) # Set up session - session = RealtimeSession(fake_model, fake_agent, None) - - # Set events to send - fake_model.set_next_events([exception_event]) + model = model_with_events(exception_event) + session = RealtimeSession(model, fake_agent, None) # Start session async with session: @@ -131,13 +57,11 @@ async def test_end_to_end_exception_propagation_and_cleanup( # Verify cleanup occurred assert session._closed is True assert session._stored_exception == test_exception - assert fake_model._is_connected is False - assert len(fake_model._listeners) == 0 + assert model.connected is False + assert model.listeners == () @pytest.mark.asyncio - async def test_websocket_connection_closure_type_distinction( - self, fake_model: FakeRealtimeModel, fake_agent - ): + async def test_websocket_connection_closure_type_distinction(self, fake_agent): """Test different WebSocket closure types generate appropriate events.""" # Test ConnectionClosed (should create exception event) error_closure = websockets.exceptions.ConnectionClosed(None, None) @@ -145,8 +69,7 @@ async def test_websocket_connection_closure_type_distinction( exception=error_closure, context="WebSocket connection closed unexpectedly" ) - session = RealtimeSession(fake_model, fake_agent, None) - fake_model.set_next_events([error_event]) + session = RealtimeSession(model_with_events(error_event), fake_agent, None) with pytest.raises(websockets.exceptions.ConnectionClosed): async with session: @@ -158,7 +81,7 @@ async def test_websocket_connection_closure_type_distinction( assert isinstance(session._stored_exception, websockets.exceptions.ConnectionClosed) @pytest.mark.asyncio - async def test_json_parsing_error_handling(self, fake_model: FakeRealtimeModel, fake_agent): + async def test_json_parsing_error_handling(self, fake_agent): """Test JSON parsing errors are properly handled and contextualized.""" # Create JSON decode error json_error = json.JSONDecodeError("Invalid JSON", "bad json", 0) @@ -166,8 +89,7 @@ async def test_json_parsing_error_handling(self, fake_model: FakeRealtimeModel, exception=json_error, context="Failed to parse WebSocket message as JSON" ) - session = RealtimeSession(fake_model, fake_agent, None) - fake_model.set_next_events([json_exception_event]) + session = RealtimeSession(model_with_events(json_exception_event), fake_agent, None) with pytest.raises(json.JSONDecodeError): async with session: @@ -179,7 +101,7 @@ async def test_json_parsing_error_handling(self, fake_model: FakeRealtimeModel, assert session._closed is True @pytest.mark.asyncio - async def test_exception_context_preservation(self, fake_model: FakeRealtimeModel, fake_agent): + async def test_exception_context_preservation(self, fake_agent): """Test that exception context information is preserved through the handling process.""" test_contexts = [ ("Failed to send audio", RuntimeError("Audio encoding failed")), @@ -190,8 +112,8 @@ async def test_exception_context_preservation(self, fake_model: FakeRealtimeMode for context, exception in test_contexts: exception_event = RealtimeModelExceptionEvent(exception=exception, context=context) - session = RealtimeSession(fake_model, fake_agent, None) - fake_model.set_next_events([exception_event]) + model = model_with_events(exception_event) + session = RealtimeSession(model, fake_agent, None) with pytest.raises(type(exception)): async with session: @@ -202,14 +124,8 @@ async def test_exception_context_preservation(self, fake_model: FakeRealtimeMode assert session._stored_exception == exception assert session._closed is True - # Reset for next iteration - fake_model._is_connected = False - fake_model._listeners.clear() - @pytest.mark.asyncio - async def test_multiple_exception_handling_behavior( - self, fake_model: FakeRealtimeModel, fake_agent - ): + async def test_multiple_exception_handling_behavior(self, fake_agent): """Test behavior when multiple exceptions occur before consumption.""" # Create multiple exceptions first_exception = ValueError("First error") @@ -222,13 +138,11 @@ async def test_multiple_exception_handling_behavior( exception=second_exception, context="Second context" ) - session = RealtimeSession(fake_model, fake_agent, None) - fake_model.set_next_events([first_event, second_event]) + session = RealtimeSession(model_with_events(first_event, second_event), fake_agent, None) - # Start session and let events process + # Start the session after both events are configured for connection. async with session: - # Give time for events to be processed - await asyncio.sleep(0.05) + pass # The first exception should be stored (second should overwrite, but that's # the current behavior). In practice, once an exception occurs, cleanup @@ -237,9 +151,7 @@ async def test_multiple_exception_handling_behavior( assert session._closed is True @pytest.mark.asyncio - async def test_exception_during_guardrail_processing( - self, fake_model: FakeRealtimeModel, fake_agent - ): + async def test_exception_during_guardrail_processing(self, fake_agent): """Test that exceptions don't interfere with guardrail task cleanup.""" # Create exception event test_exception = RuntimeError("Processing error") @@ -247,7 +159,8 @@ async def test_exception_during_guardrail_processing( exception=test_exception, context="Processing failed" ) - session = RealtimeSession(fake_model, fake_agent, None) + model = model_with_events(exception_event) + session = RealtimeSession(model, fake_agent, None) async def running_task() -> None: await asyncio.Event().wait() @@ -261,8 +174,6 @@ async def completed_task() -> None: await completed session._guardrail_tasks = {pending, completed} - fake_model.set_next_events([exception_event]) - with pytest.raises(RuntimeError, match="Processing error"): async with session: async for _event in session: @@ -275,9 +186,7 @@ async def completed_task() -> None: assert len(session._guardrail_tasks) == 0 @pytest.mark.asyncio - async def test_normal_events_still_work_before_exception( - self, fake_model: FakeRealtimeModel, fake_agent - ): + async def test_normal_events_still_work_before_exception(self, fake_agent): """Test that normal events are processed before an exception occurs.""" # Create normal event followed by exception normal_event = RealtimeModelErrorEvent(error={"message": "Normal error"}) @@ -285,15 +194,25 @@ async def test_normal_events_still_work_before_exception( exception=ValueError("Fatal error"), context="Fatal context" ) - session = RealtimeSession(fake_model, fake_agent, None) - fake_model.set_next_events([normal_event, exception_event]) + model = ScriptedRealtimeModel(strict=False) + session = RealtimeSession(model, fake_agent, None) events_received = [] with pytest.raises(ValueError, match="Fatal error"): async with session: - async for event in session: - events_received.append(event) + + async def emit_events() -> None: + await model.emit(normal_event) + await asyncio.sleep(0) + await model.emit(exception_event) + + emitter = asyncio.create_task(emit_events()) + try: + async for event in session: + events_received.append(event) + finally: + await emitter # Should have received events before exception assert len(events_received) >= 1 diff --git a/tests/realtime/test_testing.py b/tests/realtime/test_testing.py new file mode 100644 index 0000000000..9e590c57b5 --- /dev/null +++ b/tests/realtime/test_testing.py @@ -0,0 +1,1369 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any, cast + +import pytest + +from agents import Agent, handoff +from agents.realtime import ( + RealtimeModelConfig, + RealtimeModelExceptionEvent, + RealtimeModelListener, + RealtimeModelOutputTextDeltaEvent, + RealtimeModelSendInterrupt, + RealtimeModelSendSessionUpdate, + RealtimeModelSendUserInput, + RealtimePlaybackTracker, + RealtimeSessionModelSettings, +) +from agents.realtime.model_events import RealtimeModelEvent +from agents.realtime.model_inputs import RealtimeModelSendEvent +from agents.realtime.testing import ( + RealtimeScriptError, + RealtimeStep, + ScriptedRealtimeModel, + UnconsumedRealtimeSteps, + UnexpectedRealtimeSend, +) + +from ..test_responses import get_function_tool + + +@dataclass +class RecordingListener(RealtimeModelListener): + events: list[RealtimeModelEvent] = field(default_factory=list) + + async def on_event(self, event: RealtimeModelEvent) -> None: + self.events.append(event) + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_records_sends_and_emits_events() -> None: + emitted = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + model = ScriptedRealtimeModel( + [ + RealtimeStep( + expect=RealtimeModelSendUserInput(user_input="hi"), + emit=[emitted], + ) + ] + ) + listener = RecordingListener() + model.add_listener(listener) + + await model.connect({}) + await model.send_event(RealtimeModelSendUserInput(user_input="hi")) + + assert listener.events == [emitted] + assert model.sent_events == (RealtimeModelSendUserInput(user_input="hi"),) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_records_sanitized_connect_snapshot() -> None: + settings: RealtimeSessionModelSettings = {"modalities": ["text"]} + headers = {"Authorization": "Bearer secret"} + options: RealtimeModelConfig = { + "api_key": "secret", + "headers": headers, + "url": "wss://user:password@example.test:8443/realtime?token=secret#fragment", + "initial_model_settings": settings, + "call_id": "call_1", + } + model = ScriptedRealtimeModel() + + await model.connect(options) + settings["modalities"].append("audio") + headers["Authorization"] = "changed" + + assert model.connect_calls == ( + { + "api_key_provided": True, + "headers_provided": True, + "url": "wss://example.test:8443/realtime", + "initial_model_settings": {"modalities": ["text"]}, + "call_id": "call_1", + }, + ) + assert "password" not in repr(model.connect_calls) + assert "secret" not in repr(model.connect_calls) + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_rejects_duplicate_connection_before_side_effects() -> None: + connected = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="connected", + response_id="response_1", + ) + model = ScriptedRealtimeModel(connect_events=[connected]) + listener = RecordingListener() + model.add_listener(listener) + + await model.connect({"call_id": "first"}) + + with pytest.raises(AssertionError, match="Already connected"): + await model.connect({"call_id": "second"}) + + assert model.connect_calls == ( + { + "api_key_provided": False, + "headers_provided": False, + "call_id": "first", + }, + ) + assert listener.events == [connected] + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_rejects_connection_during_startup_delivery() -> None: + connected = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="connected", + response_id="response_1", + ) + delivery_started = asyncio.Event() + release_delivery = asyncio.Event() + + class BlockingListener(RealtimeModelListener): + def __init__(self) -> None: + self.events: list[RealtimeModelEvent] = [] + + async def on_event(self, event: RealtimeModelEvent) -> None: + self.events.append(event) + delivery_started.set() + await release_delivery.wait() + + listener = BlockingListener() + model = ScriptedRealtimeModel(connect_events=[connected]) + model.add_listener(listener) + first_connect = asyncio.create_task(model.connect({"call_id": "first"})) + + try: + await asyncio.wait_for(delivery_started.wait(), timeout=1) + await model.close() + + with pytest.raises(AssertionError, match="Already connected"): + await model.connect({"call_id": "second"}) + + assert model.connect_calls == ( + { + "api_key_provided": False, + "headers_provided": False, + "call_id": "first", + }, + ) + assert listener.events == [connected] + finally: + release_delivery.set() + await asyncio.wait_for(first_connect, timeout=1) + + model.remove_listener(listener) + await model.connect({"call_id": "third"}) + assert [call.get("call_id") for call in model.connect_calls] == ["first", "third"] + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_does_not_revive_cancelled_startup_delivery() -> None: + connected = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="connected", + response_id="response_1", + ) + first_delivery_started = asyncio.Event() + release_first_delivery = asyncio.Event() + + class BlockingFirstDelivery(RealtimeModelListener): + def __init__(self) -> None: + self.calls = 0 + + async def on_event(self, event: RealtimeModelEvent) -> None: + self.calls += 1 + if self.calls == 1: + first_delivery_started.set() + await release_first_delivery.wait() + + blocking = BlockingFirstDelivery() + recording = RecordingListener() + model = ScriptedRealtimeModel(connect_events=[connected]) + model.add_listener(blocking) + model.add_listener(recording) + first_connect = asyncio.create_task(model.connect({"call_id": "first"})) + await asyncio.wait_for(first_delivery_started.wait(), timeout=1) + + first_connect.cancel() + with pytest.raises(asyncio.CancelledError): + await first_connect + + with pytest.raises(AssertionError, match="Already connected"): + await model.connect({"call_id": "second"}) + + delivery_worker = model._delivery_worker + assert delivery_worker is not None + release_first_delivery.set() + await asyncio.wait_for(asyncio.shield(delivery_worker), timeout=1) + + model.remove_listener(blocking) + await model.connect({"call_id": "third"}) + + assert recording.events == [connected] + assert blocking.calls == 1 + assert [call.get("call_id") for call in model.connect_calls] == ["first", "third"] + assert model.connected is True + assert model.closed is False + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_exposes_detached_read_only_histories() -> None: + tracker = RealtimePlaybackTracker() + tool = get_function_tool("lookup", "tool result") + handoff_value = handoff(Agent(name="delegate")) + settings: RealtimeSessionModelSettings = { + "modalities": ["text"], + "tools": [tool], + "handoffs": [handoff_value], + } + user_input = cast( + Any, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + ) + event = RealtimeModelSendUserInput(user_input=user_input) + session_settings: RealtimeSessionModelSettings = { + "modalities": ["text"], + "tools": [tool], + "handoffs": [handoff_value], + } + session_update = RealtimeModelSendSessionUpdate(session_settings=session_settings) + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=event), + RealtimeStep(expect=RealtimeModelSendSessionUpdate), + ] + ) + + await model.connect( + { + "initial_model_settings": settings, + "playback_tracker": tracker, + } + ) + await model.send_event(event) + await model.send_event(session_update) + settings["modalities"].append("audio") + session_settings["modalities"].append("audio") + user_input["content"][0]["text"] = "changed externally" + + connect_history = model.connect_calls + send_history = model.sent_events + assert isinstance(connect_history, tuple) + assert isinstance(send_history, tuple) + assert connect_history[0]["playback_tracker"] is tracker + connect_history[0]["initial_model_settings"]["modalities"].append("audio") + connect_history[0]["initial_model_settings"]["tools"].clear() + connect_history[0]["initial_model_settings"]["handoffs"].clear() + recorded_event = cast(RealtimeModelSendUserInput, send_history[0]) + recorded_input = cast(Any, recorded_event.user_input) + recorded_input["content"][0]["text"] = "changed through accessor" + recorded_update = cast(RealtimeModelSendSessionUpdate, send_history[1]) + recorded_update.session_settings["modalities"].append("audio") + recorded_update.session_settings["tools"].clear() + recorded_update.session_settings["handoffs"].clear() + + retained_settings = model.connect_calls[0]["initial_model_settings"] + assert retained_settings["modalities"] == ["text"] + assert retained_settings["tools"] == [tool] + assert retained_settings["handoffs"] == [handoff_value] + assert retained_settings["tools"][0] is tool + assert retained_settings["handoffs"][0] is handoff_value + retained_event = cast(RealtimeModelSendUserInput, model.sent_events[0]) + assert cast(Any, retained_event.user_input)["content"][0]["text"] == "hello" + retained_update = cast(RealtimeModelSendSessionUpdate, model.sent_events[1]) + assert retained_update.session_settings["modalities"] == ["text"] + assert retained_update.session_settings["tools"] == [tool] + assert retained_update.session_settings["handoffs"] == [handoff_value] + assert retained_update.session_settings["tools"][0] is tool + assert retained_update.session_settings["handoffs"][0] is handoff_value + + +def test_realtime_step_freezes_emit_and_rejects_emit_with_error() -> None: + event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + source = [event] + step = RealtimeStep(expect=RealtimeModelSendInterrupt, emit=source) + source.clear() + + assert step.emit == (event,) + with pytest.raises(ValueError, match="both emit events and an error"): + RealtimeStep( + expect=RealtimeModelSendInterrupt, + emit=[event], + error=RuntimeError("failed"), + ) + + +def test_scripted_realtime_model_rejects_connect_events_with_error_before_steps() -> None: + event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + + def steps() -> Iterator[RealtimeStep]: + raise AssertionError("steps should not be evaluated") + yield RealtimeStep(expect=RealtimeModelSendInterrupt) + + with pytest.raises(ValueError, match="both connect events and a connect error"): + ScriptedRealtimeModel( + steps=steps(), + connect_events=[event], + connect_error=RuntimeError("failed"), + ) + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_preserves_error_only_connection() -> None: + error = RuntimeError("failed") + model = ScriptedRealtimeModel(connect_error=error) + + with pytest.raises(RuntimeError) as exc_info: + await model.connect({"call_id": "call_1"}) + + assert exc_info.value is error + assert model.connect_calls == ( + { + "api_key_provided": False, + "headers_provided": False, + "call_id": "call_1", + }, + ) + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_snapshots_static_scripts_at_configuration() -> None: + expected_input = cast( + Any, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "before"}], + }, + ) + expected = RealtimeModelSendUserInput(user_input=expected_input) + emitted = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="before", + response_id="response_1", + ) + connected = RealtimeModelOutputTextDeltaEvent( + item_id="item_0", + delta="connected before", + response_id="response_0", + ) + model = ScriptedRealtimeModel( + [RealtimeStep(expect=expected, emit=[emitted])], + connect_events=[connected], + ) + + expected_input["content"][0]["text"] = "after" + emitted.delta = "after" + connected.delta = "connected after" + listener = RecordingListener() + model.add_listener(listener) + + await model.connect({}) + await model.send_event( + RealtimeModelSendUserInput( + user_input={ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "before"}], + } + ) + ) + + assert [cast(RealtimeModelOutputTextDeltaEvent, event).delta for event in listener.events] == [ + "connected before", + "before", + ] + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_preserves_matcher_and_error_identity() -> None: + error = RuntimeError("failed") + + def matcher(event: RealtimeModelSendEvent) -> bool: + return isinstance(event, RealtimeModelSendInterrupt) + + model = ScriptedRealtimeModel([RealtimeStep(expect=matcher, error=error)]) + await model.connect({}) + + with pytest.raises(RuntimeError, match="failed") as exc_info: + await model.send_event(RealtimeModelSendInterrupt()) + + assert exc_info.value is error + + def rejecting_matcher(_event: RealtimeModelSendEvent) -> bool: + return False + + mismatch_model = ScriptedRealtimeModel([RealtimeStep(expect=rejecting_matcher)]) + await mismatch_model.connect({}) + with pytest.raises(UnexpectedRealtimeSend) as mismatch_info: + await mismatch_model.send_event(RealtimeModelSendInterrupt()) + assert mismatch_info.value.expected is rejecting_matcher + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_isolates_accepted_matcher_mutations() -> None: + event = RealtimeModelSendUserInput(user_input="before") + matched_event: RealtimeModelSendEvent | None = None + + def matcher(candidate: RealtimeModelSendEvent) -> bool: + nonlocal matched_event + matched_event = candidate + assert isinstance(candidate, RealtimeModelSendUserInput) + candidate.user_input = "mutated by matcher" + return True + + model = ScriptedRealtimeModel([RealtimeStep(expect=matcher)]) + await model.connect({}) + + await model.send_event(event) + + assert matched_event is not event + assert event.user_input == "before" + assert model.sent_events == (RealtimeModelSendUserInput(user_input="before"),) + model.assert_complete() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("raises", [False, True]) +async def test_scripted_realtime_model_isolates_rejected_matcher_mutations( + raises: bool, +) -> None: + event = RealtimeModelSendUserInput(user_input="before") + + def matcher(candidate: RealtimeModelSendEvent) -> bool: + assert isinstance(candidate, RealtimeModelSendUserInput) + candidate.user_input = "mutated by matcher" + if raises: + raise RuntimeError("matcher failed") + return False + + model = ScriptedRealtimeModel([RealtimeStep(expect=matcher)]) + await model.connect({}) + + if raises: + with pytest.raises(RuntimeError, match="matcher failed"): + await model.send_event(event) + else: + with pytest.raises(UnexpectedRealtimeSend) as exc_info: + await model.send_event(event) + actual = exc_info.value.actual + assert isinstance(actual, RealtimeModelSendUserInput) + assert actual.user_input == "before" + + assert event.user_input == "before" + assert model.remaining_steps == 1 + assert model.sent_events == () + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_preserves_emitted_exception_identity() -> None: + error = RuntimeError("failed") + model = ScriptedRealtimeModel( + connect_events=[RealtimeModelExceptionEvent(exception=error, context="connect")] + ) + listener = RecordingListener() + model.add_listener(listener) + + await model.connect({}) + + event = cast(RealtimeModelExceptionEvent, listener.events[0]) + assert event.exception is error + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_serializes_connect_and_reentrant_send_events() -> None: + connect_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="connect", + response_id="response_1", + ) + reply_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="reply", + response_id="response_2", + ) + send = RealtimeModelSendUserInput(user_input="hello") + model = ScriptedRealtimeModel( + [RealtimeStep(expect=send, emit=[reply_event])], + connect_events=[connect_event], + ) + + class ReentrantListener(RecordingListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + await super().on_event(event) + if event == connect_event: + await model.send_event(send) + + reentrant = ReentrantListener() + recording = RecordingListener() + model.add_listener(reentrant) + model.add_listener(recording) + + await model.connect({}) + + assert reentrant.events == [connect_event, reply_event] + assert recording.events == [connect_event, reply_event] + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_refreshes_listener_snapshot_for_each_event() -> None: + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + second_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="second", + response_id="response_2", + ) + third_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_3", + delta="third", + response_id="response_3", + ) + model = ScriptedRealtimeModel() + + class RemovingListener(RecordingListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + await super().on_event(event) + if event == first_event: + model.remove_listener(self) + + removing = RemovingListener() + recording = RecordingListener() + model.add_listener(removing) + model.add_listener(recording) + await model.connect({}) + + await model.emit(first_event, second_event) + await model.emit(third_event) + + assert removing.events == [first_event] + assert recording.events == [first_event, second_event, third_event] + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_snapshots_ad_hoc_events_when_queued() -> None: + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + snapshot_taken = asyncio.Event() + + class SignalingEvent(RealtimeModelOutputTextDeltaEvent): + def __deepcopy__(self, memo: dict[int, Any]) -> SignalingEvent: + snapshot_taken.set() + return SignalingEvent( + item_id=self.item_id, + delta=self.delta, + response_id=self.response_id, + ) + + queued_event = SignalingEvent( + item_id="item_2", + delta="before", + response_id="response_2", + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class BlockingListener(RecordingListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + await super().on_event(event) + if event == first_event: + first_started.set() + await release_first.wait() + + model = ScriptedRealtimeModel() + listener = BlockingListener() + model.add_listener(listener) + await model.connect({}) + + first_task = asyncio.create_task(model.emit(first_event)) + await first_started.wait() + second_task = asyncio.create_task(model.emit(queued_event)) + await snapshot_taken.wait() + queued_event.delta = "after" + release_first.set() + await asyncio.gather(first_task, second_task) + + assert listener.events[0] == first_event + assert isinstance(listener.events[1], RealtimeModelOutputTextDeltaEvent) + assert listener.events[1].delta == "before" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancel_second_sender", [False, True]) +async def test_scripted_realtime_model_delivers_concurrent_sends_in_commit_order( + cancel_second_sender: bool, +) -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + second_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="second", + response_id="response_2", + ) + second_committed = asyncio.Event() + + def match_second(event: RealtimeModelSendEvent) -> bool: + matched = event == second_send + if matched: + second_committed.set() + return matched + + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=match_second, emit=[second_event]), + ] + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class BlockingListener(RealtimeModelListener): + def __init__(self) -> None: + self.events: list[RealtimeModelEvent] = [] + + async def on_event(self, event: RealtimeModelEvent) -> None: + self.events.append(event) + if event == first_event: + first_started.set() + await release_first.wait() + + blocking = BlockingListener() + recording = RecordingListener() + model.add_listener(blocking) + model.add_listener(recording) + await model.connect({}) + + first_task = asyncio.create_task(model.send_event(first_send)) + await first_started.wait() + second_task = asyncio.create_task(model.send_event(second_send)) + try: + await second_committed.wait() + if cancel_second_sender: + second_task.cancel() + with pytest.raises(asyncio.CancelledError): + await second_task + assert recording.events == [] + finally: + release_first.set() + await first_task + if not cancel_second_sender: + await second_task + + assert blocking.events == [first_event, second_event] + assert recording.events == [first_event, second_event] + assert model.sent_events == (first_send, second_send) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_propagates_reentrant_delivery_error() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + expected = RuntimeError("reentrant delivery failed") + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=second_send, error=expected), + ] + ) + + class ReentrantListener(RealtimeModelListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + if event == first_event: + await model.send_event(second_send) + + recording = RecordingListener() + model.add_listener(ReentrantListener()) + model.add_listener(recording) + await model.connect({}) + + with pytest.raises(RuntimeError) as exc_info: + await asyncio.wait_for(model.send_event(first_send), timeout=1) + + assert exc_info.value is expected + assert recording.events == [first_event] + assert model.sent_events == (first_send, second_send) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_stops_broadcast_after_listener_error() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + skipped_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_skipped", + delta="skipped", + response_id="response_skipped", + ) + second_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="second", + response_id="response_2", + ) + second_committed = asyncio.Event() + + def match_second(event: RealtimeModelSendEvent) -> bool: + matched = event == second_send + if matched: + second_committed.set() + return matched + + expected = RuntimeError("listener failed") + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event, skipped_event]), + RealtimeStep(expect=match_second, emit=[second_event]), + ] + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class FailingListener(RealtimeModelListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + if event == first_event: + first_started.set() + await release_first.wait() + raise expected + + recording = RecordingListener() + model.add_listener(FailingListener()) + model.add_listener(recording) + await model.connect({}) + + async def commit_second_send() -> None: + await first_started.wait() + second_task = asyncio.create_task(model.send_event(second_send)) + await second_committed.wait() + release_first.set() + await asyncio.wait_for(second_task, timeout=1) + + coordinator = asyncio.create_task(commit_second_send()) + with pytest.raises(RuntimeError) as exc_info: + await model.send_event(first_send) + await coordinator + + assert exc_info.value is expected + assert recording.events == [second_event] + assert model.sent_events == (first_send, second_send) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_preserves_callback_cancellation() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + second_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="second", + response_id="response_2", + ) + second_committed = asyncio.Event() + + def match_second(event: RealtimeModelSendEvent) -> bool: + matched = event == second_send + if matched: + second_committed.set() + return matched + + expected = asyncio.CancelledError("listener cancelled") + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=match_second, emit=[second_event]), + ] + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class CancellingListener(RealtimeModelListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + if event == first_event: + first_started.set() + await release_first.wait() + raise expected + + recording = RecordingListener() + model.add_listener(CancellingListener()) + model.add_listener(recording) + await model.connect({}) + + async def commit_second_send() -> None: + await first_started.wait() + second_task = asyncio.create_task(model.send_event(second_send)) + await second_committed.wait() + release_first.set() + await asyncio.wait_for(second_task, timeout=1) + + coordinator = asyncio.create_task(commit_second_send()) + with pytest.raises(asyncio.CancelledError) as exc_info: + await model.send_event(first_send) + await coordinator + + assert exc_info.value is expected + assert recording.events == [second_event] + assert model.sent_events == (first_send, second_send) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_sender_cancellation_does_not_cancel_delivery() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + second_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="second", + response_id="response_2", + ) + second_committed = asyncio.Event() + + def match_second(event: RealtimeModelSendEvent) -> bool: + matched = event == second_send + if matched: + second_committed.set() + return matched + + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=match_second, emit=[second_event]), + ] + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class BlockingListener(RealtimeModelListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + if event == first_event: + first_started.set() + await release_first.wait() + + recording = RecordingListener() + model.add_listener(BlockingListener()) + model.add_listener(recording) + await model.connect({}) + + first_task = asyncio.create_task(model.send_event(first_send)) + await first_started.wait() + second_task = asyncio.create_task(model.send_event(second_send)) + await second_committed.wait() + first_task.cancel("sender cancelled") + release_first.set() + first_result, second_result = await asyncio.gather( + first_task, + second_task, + return_exceptions=True, + ) + + assert isinstance(first_result, asyncio.CancelledError) + assert second_result is None + assert first_task.cancelled() + assert not second_task.cancelled() + assert recording.events == [first_event, second_event] + assert model.sent_events == (first_send, second_send) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_revalidates_close_before_queued_delivery() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + second_committed = asyncio.Event() + + def match_second(event: RealtimeModelSendEvent) -> bool: + matched = event == second_send + if matched: + second_committed.set() + return matched + + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=match_second), + ] + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class BlockingListener(RealtimeModelListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + if event == first_event: + first_started.set() + await release_first.wait() + + recording = RecordingListener() + model.add_listener(BlockingListener()) + model.add_listener(recording) + await model.connect({}) + + first_task = asyncio.create_task(model.send_event(first_send)) + await first_started.wait() + second_task = asyncio.create_task(model.send_event(second_send)) + await second_committed.wait() + await model.close() + release_first.set() + first_result, second_result = await asyncio.gather( + first_task, + second_task, + return_exceptions=True, + ) + + assert first_result is None + assert isinstance(second_result, RealtimeScriptError) + assert recording.events == [] + assert model.sent_events == (first_send, second_send) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_rejects_reconnect_until_old_broadcast_quiesces() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + second_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="second", + response_id="response_2", + ) + second_committed = asyncio.Event() + + def match_second(received: RealtimeModelSendEvent) -> bool: + matched = received == second_send + if matched: + second_committed.set() + return matched + + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=match_second, emit=[second_event]), + ] + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class BlockingListener(RealtimeModelListener): + async def on_event(self, received: RealtimeModelEvent) -> None: + if received == first_event: + first_started.set() + await release_first.wait() + + recording = RecordingListener() + model.add_listener(BlockingListener()) + model.add_listener(recording) + await model.connect({}) + + first_task = asyncio.create_task(model.send_event(first_send)) + await first_started.wait() + second_task = asyncio.create_task(model.send_event(second_send)) + await second_committed.wait() + await model.close() + + with pytest.raises(AssertionError, match="Already connected"): + await model.connect({"call_id": "early"}) + + delivery_worker = model._delivery_worker + assert delivery_worker is not None + release_first.set() + first_result, second_result = await asyncio.gather( + first_task, + second_task, + return_exceptions=True, + ) + await asyncio.wait_for(asyncio.shield(delivery_worker), timeout=1) + + await model.connect({"call_id": "replacement"}) + + assert first_result is None + assert isinstance(second_result, RealtimeScriptError) + assert recording.events == [] + assert [call.get("call_id") for call in model.connect_calls] == [None, "replacement"] + assert model.connected is True + assert model.closed is False + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_preserves_queued_error_after_close() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + expected = RuntimeError("configured failure") + second_committed = asyncio.Event() + + def match_second(received: RealtimeModelSendEvent) -> bool: + matched = received == second_send + if matched: + second_committed.set() + return matched + + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=match_second, error=expected), + ] + ) + first_started = asyncio.Event() + release_first = asyncio.Event() + + class BlockingListener(RealtimeModelListener): + async def on_event(self, received: RealtimeModelEvent) -> None: + if received == first_event: + first_started.set() + await release_first.wait() + + model.add_listener(BlockingListener()) + await model.connect({}) + + first_task = asyncio.create_task(model.send_event(first_send)) + await first_started.wait() + second_task = asyncio.create_task(model.send_event(second_send)) + await second_committed.wait() + await model.close() + release_first.set() + first_result, second_result = await asyncio.gather( + first_task, + second_task, + return_exceptions=True, + ) + + assert first_result is None + assert second_result is expected + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_allows_reentrant_close_during_broadcast() -> None: + send = RealtimeModelSendUserInput(user_input="first") + event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + model = ScriptedRealtimeModel([RealtimeStep(expect=send, emit=[event])]) + + class ClosingListener(RealtimeModelListener): + async def on_event(self, received: RealtimeModelEvent) -> None: + if received == event: + await model.close() + + recording = RecordingListener() + model.add_listener(ClosingListener()) + model.add_listener(recording) + await model.connect({}) + + await asyncio.wait_for(model.send_event(send), timeout=1) + + assert recording.events == [] + assert model.closed is True + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_defers_reentrant_delivery_until_broadcast_finishes() -> None: + first_send = RealtimeModelSendUserInput(user_input="first") + second_send = RealtimeModelSendUserInput(user_input="second") + first_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="first", + response_id="response_1", + ) + second_event = RealtimeModelOutputTextDeltaEvent( + item_id="item_2", + delta="second", + response_id="response_2", + ) + model = ScriptedRealtimeModel( + [ + RealtimeStep(expect=first_send, emit=[first_event]), + RealtimeStep(expect=second_send, emit=[second_event]), + ] + ) + + class ReentrantListener(RealtimeModelListener): + def __init__(self) -> None: + self.events: list[RealtimeModelEvent] = [] + + async def on_event(self, event: RealtimeModelEvent) -> None: + self.events.append(event) + if event == first_event: + await model.send_event(second_send) + + reentrant = ReentrantListener() + recording = RecordingListener() + model.add_listener(reentrant) + model.add_listener(recording) + await model.connect({}) + + await asyncio.wait_for(model.send_event(first_send), timeout=1) + + assert reentrant.events == [first_event, second_event] + assert recording.events == [first_event, second_event] + assert model.sent_events == (first_send, second_send) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_conditionally_commits_under_send_lock() -> None: + model = ScriptedRealtimeModel([RealtimeStep(expect=RealtimeModelSendInterrupt)]) + await model.connect({}) + + skipped = await model.send_event_if(RealtimeModelSendInterrupt(), lambda: False) + sent = await model.send_event_if(RealtimeModelSendInterrupt(), lambda: True) + + assert skipped is False + assert sent is True + assert model.sent_events == (RealtimeModelSendInterrupt(),) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_rejects_unexpected_send() -> None: + expected = RealtimeModelSendUserInput(user_input="expected secret") + actual = RealtimeModelSendUserInput(user_input="actual secret") + model = ScriptedRealtimeModel([RealtimeStep(expect=expected)]) + await model.connect({}) + + with pytest.raises(UnexpectedRealtimeSend, match="expectation") as exc_info: + await model.send_event(actual) + + assert exc_info.value.actual == actual + assert exc_info.value.actual is not actual + assert exc_info.value.expected == expected + assert exc_info.value.expected is not expected + assert "actual secret" not in str(exc_info.value) + assert "expected secret" not in str(exc_info.value) + assert model.remaining_steps == 1 + assert model.sent_events == () + await model.send_event(expected) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_reports_exhausted_send_attributes() -> None: + actual = RealtimeModelSendUserInput(user_input="exhausted secret") + model = ScriptedRealtimeModel() + await model.connect({}) + + with pytest.raises(UnexpectedRealtimeSend, match="no scripted steps") as exc_info: + await model.send_event(actual) + + assert exc_info.value.actual == actual + assert exc_info.value.actual is not actual + assert exc_info.value.expected is None + assert "exhausted secret" not in str(exc_info.value) + assert model.sent_events == () + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_snapshot_failure_has_no_side_effects() -> None: + expected_error = RuntimeError("event snapshot failed") + + class Uncopyable: + def __deepcopy__(self, _memo: dict[int, Any]) -> Any: + raise expected_error + + event = RealtimeModelSendUserInput(user_input=cast(Any, Uncopyable())) + model = ScriptedRealtimeModel([RealtimeStep(expect=RealtimeModelSendUserInput)]) + await model.connect({}) + + with pytest.raises(RuntimeError, match="event snapshot failed") as exc_info: + await model.send_event(event) + + assert exc_info.value is expected_error + assert model.remaining_steps == 1 + assert model.sent_events == () + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_conditional_mismatch_preserves_step() -> None: + model = ScriptedRealtimeModel([RealtimeStep(expect=RealtimeModelSendInterrupt)]) + await model.connect({}) + + with pytest.raises(UnexpectedRealtimeSend, match="expected"): + await model.send_event_if( + RealtimeModelSendUserInput(user_input="wrong"), + lambda: True, + ) + + assert model.remaining_steps == 1 + assert model.sent_events == () + + +@pytest.mark.asyncio +@pytest.mark.parametrize("strict", [False, True]) +async def test_scripted_realtime_model_raising_matcher_preserves_step(strict: bool) -> None: + def raise_from_matcher(_event) -> bool: + raise RuntimeError("matcher failed") + + model = ScriptedRealtimeModel([RealtimeStep(expect=raise_from_matcher)], strict=strict) + await model.connect({}) + + with pytest.raises(RuntimeError, match="matcher failed"): + await model.send_event(RealtimeModelSendInterrupt()) + + assert model.remaining_steps == 1 + assert model.sent_events == () + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_can_record_unscripted_sends_explicitly() -> None: + model = ScriptedRealtimeModel(strict=False) + await model.connect({}) + + await model.send_event(RealtimeModelSendInterrupt()) + + assert model.sent_events == (RealtimeModelSendInterrupt(),) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("conditional", [False, True]) +async def test_scripted_realtime_model_non_strict_mismatch_preserves_pending_step( + conditional: bool, +) -> None: + expected = RealtimeModelSendInterrupt() + unrelated = RealtimeModelSendUserInput(user_input="unrelated") + model = ScriptedRealtimeModel([RealtimeStep(expect=expected)], strict=False) + await model.connect({}) + + if conditional: + assert await model.send_event_if(unrelated, lambda: True) is True + else: + await model.send_event(unrelated) + + assert model.sent_events == (unrelated,) + assert model.remaining_steps == 1 + + await model.send_event(expected) + assert model.sent_events == (unrelated, expected) + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_closes_idempotently() -> None: + model = ScriptedRealtimeModel() + await model.connect({}) + + await model.close() + await model.close() + + assert model.closed is True + assert model.connected is False + assert model.close_calls == 2 + + +@pytest.mark.asyncio +async def test_scripted_realtime_model_disconnects_when_connect_listener_fails() -> None: + class RaisingListener(RealtimeModelListener): + async def on_event(self, event: RealtimeModelEvent) -> None: + raise RuntimeError("listener failed") + + model = ScriptedRealtimeModel( + connect_events=[ + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ] + ) + model.add_listener(RaisingListener()) + + with pytest.raises(RuntimeError, match="listener failed"): + await model.connect({}) + + assert model.connected is False + assert model.closed is True + with pytest.raises(RealtimeScriptError, match="disconnected"): + await model.send_event(RealtimeModelSendInterrupt()) + await model.close() + await model.close() + assert model.close_calls == 2 + with pytest.raises(RealtimeScriptError, match="disconnected"): + await model.emit( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="late", + response_id="response_1", + ) + ) + + +def test_scripted_realtime_model_reports_unconsumed_steps() -> None: + model = ScriptedRealtimeModel([RealtimeStep(expect=RealtimeModelSendInterrupt)]) + + with pytest.raises(UnconsumedRealtimeSteps, match="1 scripted Realtime step") as exc_info: + model.assert_complete() + + assert exc_info.value.remaining_steps == 1 diff --git a/tests/sandbox/capabilities/test_apply_patch_tool.py b/tests/sandbox/capabilities/test_apply_patch_tool.py index 0d4b847c00..82cb540c6d 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -17,6 +17,7 @@ from agents.run_internal.tool_actions import CustomToolAction from agents.sandbox.capabilities.tools import SandboxApplyPatchTool from agents.sandbox.types import User +from agents.testing import scripted_sandbox_session from tests.sandbox._apply_patch_test_session import ( ApplyPatchSession, UserRecordingApplyPatchSession, @@ -26,7 +27,7 @@ class TestSandboxApplyPatchTool: def test_exposes_custom_apply_patch_tool(self) -> None: - tool = SandboxApplyPatchTool(session=ApplyPatchSession()) + tool = SandboxApplyPatchTool(session=scripted_sandbox_session()) assert isinstance(tool, CustomTool) assert tool.name == "apply_patch" @@ -36,7 +37,7 @@ def test_exposes_custom_apply_patch_tool(self) -> None: assert tool.tool_config["format"]["syntax"] == "lark" def test_converter_uses_sandbox_custom_apply_patch_tool_config(self) -> None: - tool = SandboxApplyPatchTool(session=ApplyPatchSession()) + tool = SandboxApplyPatchTool(session=scripted_sandbox_session()) converted = Converter.convert_tools([tool], handoffs=[]) @@ -55,7 +56,9 @@ async def needs_approval( ) -> bool: return operation.type != "create_file" - tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=needs_approval) + tool = SandboxApplyPatchTool( + session=scripted_sandbox_session(), needs_approval=needs_approval + ) assert cast(object, tool.needs_approval) is needs_approval assert cast(object, tool.operation_needs_approval) is needs_approval @@ -67,7 +70,7 @@ async def needs_approval( ) -> bool: return operation.type == "delete_file" - tool = SandboxApplyPatchTool(session=ApplyPatchSession()) + tool = SandboxApplyPatchTool(session=scripted_sandbox_session()) tool.needs_approval = needs_approval result = await _execute_custom_tool_call( @@ -147,7 +150,7 @@ async def needs_approval( @pytest.mark.asyncio async def test_invalid_patch_input_surfaces_tool_error_after_approval_precheck(self) -> None: - tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=True) + tool = SandboxApplyPatchTool(session=scripted_sandbox_session(), needs_approval=True) result = await _execute_custom_tool_call( tool, diff --git a/tests/sandbox/capabilities/test_shell_capability.py b/tests/sandbox/capabilities/test_shell_capability.py index 9986365925..38ff54c2a9 100644 --- a/tests/sandbox/capabilities/test_shell_capability.py +++ b/tests/sandbox/capabilities/test_shell_capability.py @@ -1,8 +1,6 @@ from __future__ import annotations -import io import uuid -from pathlib import Path from typing import Any, cast import pytest @@ -17,243 +15,60 @@ ) from agents.sandbox.capabilities.tools.shell_tool import _resolve_shell from agents.sandbox.errors import ExecTimeoutError, ExecTransportError, PtySessionNotFoundError -from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.pty_types import PtyExecUpdate -from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult, User +from agents.testing import scripted_sandbox_session from agents.tool import FunctionTool from agents.tool_context import ToolContext -from tests.utils.factories import TestSessionState -class _ShellSession(BaseSandboxSession): - def __init__(self, manifest: Manifest) -> None: - self.state = TestSessionState( - manifest=manifest, - snapshot=NoopSnapshot(id=str(uuid.uuid4())), - ) - self.exec_calls: list[tuple[str, float | None, bool | list[str]]] = [] - self.exec_users: list[str | None] = [] - - async def start(self) -> None: - return None - - async def stop(self) -> None: - return None - - async def shutdown(self) -> None: - return None - - async def running(self) -> bool: - return True - - async def read(self, path: Path, *, user: object = None) -> io.BytesIO: - _ = (path, user) - raise AssertionError("read() should not be called") - - async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None: - _ = (path, data, user) - raise AssertionError("write() should not be called") - - async def _exec_internal( - self, - *command: str | Path, - timeout: float | None = None, - ) -> ExecResult: - _ = command - _ = timeout - raise AssertionError("_exec_internal() should not be called directly") - - async def exec( - self, - *command: str | Path, - timeout: float | None = None, - user: str | User | None = None, - shell: bool | list[str] = False, - ) -> ExecResult: - self.exec_users.append(user.name if isinstance(user, User) else user) - rendered_command = " ".join(str(part) for part in command) - self.exec_calls.append((rendered_command, timeout, shell)) - return ExecResult( - stdout=f"stdout: {rendered_command}".encode(), - stderr=f"stderr: {rendered_command}".encode(), - exit_code=7, - ) - - async def persist_workspace(self) -> io.IOBase: - return io.BytesIO() - - async def hydrate_workspace(self, data: io.IOBase) -> None: - _ = data - +def _default_exec_result(call: Any) -> ExecResult: + rendered_command = " ".join(str(part) for part in call.args) + return ExecResult( + stdout=f"stdout: {rendered_command}".encode(), + stderr=f"stderr: {rendered_command}".encode(), + exit_code=7, + ) -class _TimeoutShellSession(_ShellSession): - async def exec( - self, - *command: str | Path, - timeout: float | None = None, - user: str | User | None = None, - shell: bool | list[str] = False, - ) -> ExecResult: - _ = (command, user, shell) - raise ExecTimeoutError(command=("sleep 30",), timeout_s=timeout) +def _shell_session( + *, + manifest: Manifest | None = None, + result: ExecResult | None = None, + error: Exception | None = None, +) -> Any: + outcome: dict[str, object] + if error is not None: + outcome = {"error": error} + elif result is not None: + outcome = {"result": result} + else: + outcome = {"responder": _default_exec_result} + step: dict[str, object] = {"method": "exec"} + step.update(outcome) + return scripted_sandbox_session( + cast(Any, [step]), + manifest=manifest or Manifest(root="/workspace"), + ) -class _OutputShellSession(_ShellSession): - def __init__( - self, - manifest: Manifest, - *, - stdout: bytes, - stderr: bytes, - exit_code: int = 7, - ) -> None: - super().__init__(manifest) - self.stdout = stdout - self.stderr = stderr - self.exit_code = exit_code - async def exec( - self, - *command: str | Path, - timeout: float | None = None, - user: str | User | None = None, - shell: bool | list[str] = False, - ) -> ExecResult: - self.exec_users.append(user.name if isinstance(user, User) else user) - rendered_command = " ".join(str(part) for part in command) - self.exec_calls.append((rendered_command, timeout, shell)) - return ExecResult(stdout=self.stdout, stderr=self.stderr, exit_code=self.exit_code) - - -class _PtyShellSession(_ShellSession): - def __init__(self, manifest: Manifest) -> None: - super().__init__(manifest) - self._next_session_id = 1337 - self._live_sessions: set[int] = set() - self.last_exec_yield_time_s: float | None = None - self.last_exec_user: str | None = None - self.last_write_yield_time_s: float | None = None - - def supports_pty(self) -> bool: - return True - - async def pty_exec_start( - self, - *command: str | Path, - timeout: float | None = None, - shell: bool | list[str] = True, - user: str | User | None = None, - tty: bool = False, - yield_time_s: float | None = None, - max_output_tokens: int | None = None, - ) -> PtyExecUpdate: - _ = (command, timeout, shell, tty, max_output_tokens) - self.last_exec_user = user.name if isinstance(user, User) else user - self.last_exec_yield_time_s = yield_time_s - session_id = self._next_session_id - self._next_session_id += 1 - self._live_sessions.add(session_id) - return PtyExecUpdate( - process_id=session_id, - output=b"", - exit_code=None, - original_token_count=None, - ) - - async def pty_write_stdin( - self, - *, - session_id: int, - chars: str, - yield_time_s: float | None = None, - max_output_tokens: int | None = None, - ) -> PtyExecUpdate: - _ = max_output_tokens - self.last_write_yield_time_s = yield_time_s - if session_id not in self._live_sessions: - raise PtySessionNotFoundError(session_id=session_id) - - self._live_sessions.discard(session_id) - return PtyExecUpdate( - process_id=None, - output=chars.encode("utf-8", errors="replace"), - exit_code=0, - original_token_count=None, - ) - - -class _PtyNoStdinShellSession(_PtyShellSession): - async def pty_write_stdin( - self, - *, - session_id: int, - chars: str, - yield_time_s: float | None = None, - max_output_tokens: int | None = None, - ) -> PtyExecUpdate: - _ = (chars, yield_time_s, max_output_tokens) - if session_id not in self._live_sessions: - raise PtySessionNotFoundError(session_id=session_id) - raise RuntimeError("stdin is not available for this process") - - -class _PtyUnexpectedStdinErrorShellSession(_PtyShellSession): - async def pty_write_stdin( - self, - *, - session_id: int, - chars: str, - yield_time_s: float | None = None, - max_output_tokens: int | None = None, - ) -> PtyExecUpdate: - _ = (session_id, chars, yield_time_s, max_output_tokens) - raise RuntimeError("unexpected stdin failure") - - -class _PtyTransportFailingShellSession(_OutputShellSession): - def __init__( - self, - manifest: Manifest, - *, - stdout: bytes = b"", - stderr: bytes = b"", - exit_code: int = 0, - transport_context: dict[str, object] | None = None, - ) -> None: - super().__init__(manifest, stdout=stdout, stderr=stderr, exit_code=exit_code) - self.transport_context = transport_context or {} - self.exec_call_count = 0 +def _pty_session( + steps: list[dict[str, object]], + *, + manifest: Manifest | None = None, +) -> Any: + return scripted_sandbox_session( + cast(Any, steps), + manifest=manifest or Manifest(root="/workspace"), + ) - def supports_pty(self) -> bool: - return True - async def exec( - self, - *command: str | Path, - timeout: float | None = None, - user: str | User | None = None, - shell: bool | list[str] = False, - ) -> ExecResult: - self.exec_call_count += 1 - return await super().exec(*command, timeout=timeout, user=user, shell=shell) - - async def pty_exec_start( - self, - *command: str | Path, - timeout: float | None = None, - shell: bool | list[str] = True, - user: str | User | None = None, - tty: bool = False, - yield_time_s: float | None = None, - max_output_tokens: int | None = None, - ) -> PtyExecUpdate: - _ = (timeout, shell, user, tty, yield_time_s, max_output_tokens) - raise ExecTransportError( - command=command, - context=self.transport_context, - cause=RuntimeError("connection closed while reading HTTP status line"), - ) +def _transport_error(context: dict[str, object]) -> ExecTransportError: + return ExecTransportError( + command=("pwd",), + context=context, + cause=RuntimeError("connection closed while reading HTTP status line"), + ) def _patch_shell_tool_clock( @@ -286,7 +101,7 @@ def test_tools_requires_bound_session(self) -> None: def test_tools_exposes_exec_command_function_tool_after_bind(self) -> None: capability = Shell() - capability.bind(_ShellSession(Manifest(root="/workspace"))) + capability.bind(_shell_session()) tools = capability.tools() @@ -297,7 +112,7 @@ def test_tools_exposes_exec_command_function_tool_after_bind(self) -> None: def test_tools_exposes_write_stdin_for_pty_sessions(self) -> None: capability = Shell() - capability.bind(_PtyShellSession(Manifest(root="/workspace"))) + capability.bind(_pty_session([{"method": "pty_write_stdin", "result": None}])) tools = capability.tools() @@ -307,6 +122,17 @@ def test_tools_exposes_write_stdin_for_pty_sessions(self) -> None: assert tools[0].name == "exec_command" assert tools[1].name == "write_stdin" + def test_tools_keep_both_pty_session_methods_callable(self) -> None: + capability = Shell() + session = _pty_session([{"method": "pty_exec_start", "result": None}]) + capability.bind(session) + + tools = capability.tools() + + assert len(tools) == 2 + assert hasattr(session, "pty_exec_start") + assert hasattr(session, "pty_write_stdin") + def test_configure_tools_can_customize_shell_approvals_after_clone(self) -> None: async def exec_command_needs_approval( _ctx: Any, params: dict[str, Any], _call_id: str @@ -324,7 +150,7 @@ def configure_tools(toolset: ShellToolSet) -> None: toolset.write_stdin.needs_approval = write_stdin_needs_approval capability = Shell(configure_tools=configure_tools).clone() - capability.bind(_PtyShellSession(Manifest(root="/workspace"))) + capability.bind(_pty_session([{"method": "pty_write_stdin", "result": None}])) tools = capability.tools() exec_command_tool = cast(ExecCommandTool, tools[0]) @@ -341,7 +167,7 @@ def configure_tools(toolset: ShellToolSet) -> None: saw_missing_write_stdin = toolset.write_stdin is None capability = Shell(configure_tools=configure_tools) - capability.bind(_ShellSession(Manifest(root="/workspace"))) + capability.bind(_shell_session()) tools = capability.tools() @@ -361,7 +187,7 @@ def configure_tools(toolset: ShellToolSet) -> None: toolset.exec_command = replacement_exec_command capability = Shell(configure_tools=configure_tools) - capability.bind(_ShellSession(Manifest(root="/workspace"))) + capability.bind(_shell_session()) tools = capability.tools() exec_command_tool = cast(ExecCommandTool, tools[0]) @@ -392,7 +218,7 @@ async def test_exec_command_tool_runs_commands_with_source_output_format( monkeypatch: pytest.MonkeyPatch, ) -> None: capability = Shell() - session = _ShellSession(Manifest(root="/workspace")) + session = _shell_session() capability.bind(session) tool = cast(FunctionTool, capability.tools()[0]) @@ -412,7 +238,9 @@ async def test_exec_command_tool_runs_commands_with_source_output_format( ExecCommandArgs(cmd="pwd", yield_time_ms=1500).model_dump_json(), ) - assert session.exec_calls == [("pwd", 1.5, True)] + assert session.calls[0].args == ("pwd",) + assert session.calls[0].kwargs["timeout"] == 1.5 + assert session.calls[0].kwargs["shell"] is True assert ( output == "Chunk ID: 123456\n" "Wall time: 0.2500 seconds\n" @@ -425,7 +253,14 @@ async def test_exec_command_tool_runs_commands_with_source_output_format( @pytest.mark.asyncio async def test_exec_command_tool_runs_as_bound_user(self) -> None: capability = Shell() - session = _ShellSession(Manifest(root="/workspace")) + session = scripted_sandbox_session( + [ + { + "method": "exec", + "result": ExecResult(stdout=b"", stderr=b"", exit_code=0), + } + ] + ) capability.bind(session) capability.bind_run_as(User(name="sandbox-user")) tool = cast(FunctionTool, capability.tools()[0]) @@ -435,7 +270,8 @@ async def test_exec_command_tool_runs_as_bound_user(self) -> None: ExecCommandArgs(cmd="pwd").model_dump_json(), ) - assert session.exec_users == ["sandbox-user"] + assert session.calls[0].kwargs["user"] == User(name="sandbox-user") + session.assert_complete() @pytest.mark.asyncio async def test_exec_command_tool_includes_original_token_count_when_truncating( @@ -443,7 +279,7 @@ async def test_exec_command_tool_includes_original_token_count_when_truncating( monkeypatch: pytest.MonkeyPatch, ) -> None: capability = Shell() - session = _ShellSession(Manifest(root="/workspace")) + session = _shell_session() capability.bind(session) tool = cast(FunctionTool, capability.tools()[0]) @@ -478,7 +314,7 @@ async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell( monkeypatch: pytest.MonkeyPatch, ) -> None: capability = Shell() - session = _ShellSession(Manifest(root="/workspace")) + session = _shell_session() capability.bind(session) tool = cast(FunctionTool, capability.tools()[0]) _patch_shell_tool_clock( @@ -498,9 +334,9 @@ async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell( ).model_dump_json(), ) - assert session.exec_calls == [ - ("cd /workspace/src/project && pwd", 10.0, ["/bin/bash", "-c"]) - ] + assert session.calls[0].args == ("cd /workspace/src/project && pwd",) + assert session.calls[0].kwargs["timeout"] == 10.0 + assert session.calls[0].kwargs["shell"] == ["/bin/bash", "-c"] assert ( output == "Chunk ID: 876543\n" "Wall time: 0.1250 seconds\n" @@ -516,8 +352,8 @@ async def test_exec_command_tool_allows_split_path_grant_workdir( monkeypatch: pytest.MonkeyPatch, ) -> None: capability = Shell() - session = _ShellSession( - Manifest( + session = _shell_session( + manifest=Manifest( root="/workspace", extra_path_grants=( SandboxPathGrant( @@ -547,7 +383,9 @@ async def test_exec_command_tool_allows_split_path_grant_workdir( ).model_dump_json(), ) - assert session.exec_calls == [("cd /mnt/shared-data && pwd", 10.0, ["/bin/bash", "-c"])] + assert session.calls[0].args == ("cd /mnt/shared-data && pwd",) + assert session.calls[0].kwargs["timeout"] == 10.0 + assert session.calls[0].kwargs["shell"] == ["/bin/bash", "-c"] assert ( output == "Chunk ID: 111111\n" "Wall time: 0.2500 seconds\n" @@ -563,7 +401,19 @@ async def test_exec_command_tool_uses_pty_when_supported( monkeypatch: pytest.MonkeyPatch, ) -> None: capability = Shell() - session = _PtyShellSession(Manifest(root="/workspace")) + session = _pty_session( + [ + { + "method": "pty_exec_start", + "result": PtyExecUpdate( + process_id=1337, + output=b"", + exit_code=None, + original_token_count=None, + ), + } + ] + ) capability.bind(session) tool = cast(FunctionTool, capability.tools()[0]) _patch_shell_tool_clock( @@ -578,7 +428,7 @@ async def test_exec_command_tool_uses_pty_when_supported( ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(), ) - assert session.last_exec_yield_time_s == 0.0 + assert session.calls[0].kwargs["yield_time_s"] == 0.0 assert ( output == "Chunk ID: abcdef\n" "Wall time: 0.0500 seconds\n" @@ -590,7 +440,19 @@ async def test_exec_command_tool_uses_pty_when_supported( @pytest.mark.asyncio async def test_exec_command_tool_starts_pty_as_bound_user(self) -> None: capability = Shell() - session = _PtyShellSession(Manifest(root="/workspace")) + session = _pty_session( + [ + { + "method": "pty_exec_start", + "result": PtyExecUpdate( + process_id=1337, + output=b"", + exit_code=None, + original_token_count=None, + ), + } + ] + ) capability.bind(session) capability.bind_run_as(User(name="sandbox-user")) tool = cast(FunctionTool, capability.tools()[0]) @@ -600,7 +462,7 @@ async def test_exec_command_tool_starts_pty_as_bound_user(self) -> None: ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(), ) - assert session.last_exec_user == "sandbox-user" + assert session.calls[0].kwargs["user"] == User(name="sandbox-user") @pytest.mark.asyncio async def test_exec_command_tool_formats_timeout_without_exit_code( @@ -608,7 +470,7 @@ async def test_exec_command_tool_formats_timeout_without_exit_code( monkeypatch: pytest.MonkeyPatch, ) -> None: capability = Shell() - session = _TimeoutShellSession(Manifest(root="/workspace")) + session = _shell_session(error=ExecTimeoutError(command=("sleep 30",), timeout_s=0.005)) capability.bind(session) tool = cast(FunctionTool, capability.tools()[0]) _patch_shell_tool_clock( @@ -635,13 +497,19 @@ async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_trans self, monkeypatch: pytest.MonkeyPatch, ) -> None: - tool = ExecCommandTool( - session=_PtyTransportFailingShellSession( - Manifest(root="/workspace"), - stdout=b"fallback ok", - transport_context={"stage": "open_pipe", "retry_safe": True}, - ) - ) + session = _pty_session( + [ + { + "method": "pty_exec_start", + "error": _transport_error({"stage": "open_pipe", "retry_safe": True}), + }, + { + "method": "exec", + "result": ExecResult(stdout=b"fallback ok", stderr=b"", exit_code=0), + }, + ] + ) + tool = ExecCommandTool(session=session) _patch_shell_tool_clock( monkeypatch, chunk_id="44444444444444444444444444444444", @@ -661,12 +529,17 @@ async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_trans @pytest.mark.asyncio async def test_exec_command_tool_does_not_fall_back_for_tty_sessions(self) -> None: - tool = ExecCommandTool( - session=_PtyTransportFailingShellSession( - Manifest(root="/workspace"), - transport_context={"stage": "open_pipe", "retry_safe": True, "tty": True}, - ) + session = _pty_session( + [ + { + "method": "pty_exec_start", + "error": _transport_error( + {"stage": "open_pipe", "retry_safe": True, "tty": True} + ), + } + ] ) + tool = ExecCommandTool(session=session) with pytest.raises(ExecTransportError): await tool.on_invoke_tool( @@ -678,12 +551,15 @@ async def test_exec_command_tool_does_not_fall_back_for_tty_sessions(self) -> No async def test_exec_command_tool_does_not_fall_back_for_non_retry_safe_transport_errors( self, ) -> None: - tool = ExecCommandTool( - session=_PtyTransportFailingShellSession( - Manifest(root="/workspace"), - transport_context={"stage": "open_pipe"}, - ) + session = _pty_session( + [ + { + "method": "pty_exec_start", + "error": _transport_error({"stage": "open_pipe"}), + } + ] ) + tool = ExecCommandTool(session=session) with pytest.raises(ExecTransportError): await tool.on_invoke_tool( @@ -697,10 +573,8 @@ async def test_exec_command_tool_uses_stdout_only_when_stderr_is_empty( monkeypatch: pytest.MonkeyPatch, ) -> None: tool = ExecCommandTool( - session=_OutputShellSession( - Manifest(root="/workspace"), - stdout=b"stdout only\n", - stderr=b"", + session=_shell_session( + result=ExecResult(stdout=b"stdout only\n", stderr=b"", exit_code=7) ) ) _patch_shell_tool_clock( @@ -729,10 +603,8 @@ async def test_exec_command_tool_uses_stderr_only_when_stdout_is_empty( monkeypatch: pytest.MonkeyPatch, ) -> None: tool = ExecCommandTool( - session=_OutputShellSession( - Manifest(root="/workspace"), - stdout=b"", - stderr=b"stderr only\n", + session=_shell_session( + result=ExecResult(stdout=b"", stderr=b"stderr only\n", exit_code=7) ) ) _patch_shell_tool_clock( @@ -761,10 +633,12 @@ async def test_exec_command_tool_does_not_insert_extra_newline_when_stdout_alrea monkeypatch: pytest.MonkeyPatch, ) -> None: tool = ExecCommandTool( - session=_OutputShellSession( - Manifest(root="/workspace"), - stdout=b"stdout line\n", - stderr=b"stderr line\n", + session=_shell_session( + result=ExecResult( + stdout=b"stdout line\n", + stderr=b"stderr line\n", + exit_code=7, + ) ) ) _patch_shell_tool_clock( @@ -793,8 +667,19 @@ async def test_write_stdin_tool_writes_and_finishes_session( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - session = _PtyShellSession(Manifest(root="/workspace")) - session._live_sessions.add(1337) + session = _pty_session( + [ + { + "method": "pty_write_stdin", + "result": PtyExecUpdate( + process_id=None, + output=b"hello", + exit_code=0, + original_token_count=None, + ), + } + ] + ) tool = WriteStdinTool(session=session) _patch_shell_tool_clock( monkeypatch, @@ -818,7 +703,7 @@ async def test_write_stdin_tool_writes_and_finishes_session( @pytest.mark.asyncio async def test_write_stdin_tool_rejects_non_pty_sessions(self) -> None: - tool = WriteStdinTool(session=_ShellSession(Manifest(root="/workspace"))) + tool = WriteStdinTool(session=_shell_session()) with pytest.raises( RuntimeError, match="write_stdin is not available for non-PTY sandboxes" @@ -833,7 +718,15 @@ async def test_write_stdin_tool_formats_unknown_session_error( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - tool = WriteStdinTool(session=_PtyShellSession(Manifest(root="/workspace"))) + session = _pty_session( + [ + { + "method": "pty_write_stdin", + "error": PtySessionNotFoundError(session_id=9999), + } + ] + ) + tool = WriteStdinTool(session=session) _patch_shell_tool_clock( monkeypatch, chunk_id="66666666666666666666666666666666", @@ -859,8 +752,14 @@ async def test_write_stdin_tool_formats_missing_stdin_error( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - session = _PtyNoStdinShellSession(Manifest(root="/workspace")) - session._live_sessions.add(1337) + session = _pty_session( + [ + { + "method": "pty_write_stdin", + "error": RuntimeError("stdin is not available for this process"), + } + ] + ) tool = WriteStdinTool(session=session) _patch_shell_tool_clock( monkeypatch, @@ -885,9 +784,15 @@ async def test_write_stdin_tool_formats_missing_stdin_error( @pytest.mark.asyncio async def test_write_stdin_tool_reraises_unexpected_runtime_error(self) -> None: - tool = WriteStdinTool( - session=_PtyUnexpectedStdinErrorShellSession(Manifest(root="/workspace")) + session = _pty_session( + [ + { + "method": "pty_write_stdin", + "error": RuntimeError("unexpected stdin failure"), + } + ] ) + tool = WriteStdinTool(session=session) with pytest.raises(RuntimeError, match="unexpected stdin failure"): await tool.on_invoke_tool( diff --git a/tests/sandbox/capabilities/test_skills_capability.py b/tests/sandbox/capabilities/test_skills_capability.py index 6d220179ad..cb2961efe0 100644 --- a/tests/sandbox/capabilities/test_skills_capability.py +++ b/tests/sandbox/capabilities/test_skills_capability.py @@ -21,6 +21,7 @@ from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult, FileMode, Group, Permissions, User from agents.sandbox.workspace_paths import coerce_posix_path, sandbox_path_str +from agents.testing import scripted_sandbox_session from agents.tool import FunctionTool from agents.tool_context import ToolContext from agents.tracing import trace @@ -644,7 +645,11 @@ def test_lazy_tools_expose_load_skill_after_bind(self, tmp_path: Path) -> None: skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8") capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root))) - capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root))) + capability.bind( + scripted_sandbox_session( + manifest=_source_granted_manifest(workspace_root, source=src_root) + ) + ) tools = capability.tools() @@ -785,8 +790,10 @@ async def test_load_skill_rejects_missing_lazy_source_directory(self, tmp_path: lazy_from=LocalDirLazySkillSource(source=LocalDir(src=tmp_path / "missing-skills")) ) capability.bind( - _SkillsSession( - _source_granted_manifest(workspace_root, source=tmp_path / "missing-skills") + scripted_sandbox_session( + manifest=_source_granted_manifest( + workspace_root, source=tmp_path / "missing-skills" + ) ) ) @@ -811,7 +818,11 @@ async def test_load_skill_rejects_ambiguous_skill_name(self, tmp_path: Path) -> encoding="utf-8", ) capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root))) - capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root))) + capability.bind( + scripted_sandbox_session( + manifest=_source_granted_manifest(workspace_root, source=src_root) + ) + ) with pytest.raises(SkillsConfigError): await capability.load_skill("shared-skill") @@ -840,7 +851,11 @@ async def test_lazy_metadata_cache_is_reset_on_bind(self, tmp_path: Path) -> Non second_instructions = await capability.instructions( _source_granted_manifest(workspace_root, source=src_root) ) - capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root))) + capability.bind( + scripted_sandbox_session( + manifest=_source_granted_manifest(workspace_root, source=src_root) + ) + ) third_instructions = await capability.instructions( _source_granted_manifest(workspace_root, source=src_root) ) diff --git a/tests/sandbox/capabilities/test_view_image_tool.py b/tests/sandbox/capabilities/test_view_image_tool.py index 095cdf6201..936d619ae4 100644 --- a/tests/sandbox/capabilities/test_view_image_tool.py +++ b/tests/sandbox/capabilities/test_view_image_tool.py @@ -2,7 +2,6 @@ import base64 import io -import uuid from pathlib import Path from typing import cast @@ -11,12 +10,10 @@ from agents.sandbox import Manifest from agents.sandbox.capabilities.tools import ViewImageTool from agents.sandbox.errors import WorkspaceReadNotFoundError -from agents.sandbox.session.base_sandbox_session import BaseSandboxSession -from agents.sandbox.snapshot import NoopSnapshot -from agents.sandbox.types import ExecResult, User +from agents.sandbox.types import User +from agents.testing import scripted_sandbox_session from agents.tool import ToolOutputImage from agents.tool_context import ToolContext -from tests.utils.factories import TestSessionState _MAX_IMAGE_BYTES = 10 * 1024 * 1024 _PNG_BASE64 = ( @@ -25,76 +22,9 @@ _PNG_BYTES = base64.b64decode(_PNG_BASE64) -class _ImageSession(BaseSandboxSession): - def __init__(self, manifest: Manifest) -> None: - self.state = TestSessionState( - manifest=manifest, - snapshot=NoopSnapshot(id=str(uuid.uuid4())), - ) - self.files: dict[Path, bytes] = {} - self.read_users: list[str | None] = [] - - async def start(self) -> None: - return None - - async def stop(self) -> None: - return None - - async def shutdown(self) -> None: - return None - - async def running(self) -> bool: - return True - - async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO: - self.read_users.append(user.name if isinstance(user, User) else user) - normalized = self.normalize_path(path) - if normalized not in self.files: - raise FileNotFoundError(normalized) - return io.BytesIO(self.files[normalized]) - - async def write( - self, - path: Path, - data: io.IOBase, - *, - user: str | User | None = None, - ) -> None: - _ = user - normalized = self.normalize_path(path) - payload = data.read() - if isinstance(payload, str): - self.files[normalized] = payload.encode("utf-8") - else: - self.files[normalized] = bytes(payload) - - async def _exec_internal( - self, - *command: str | Path, - timeout: float | None = None, - ) -> ExecResult: - _ = (command, timeout) - raise AssertionError("_exec_internal() should not be called") - - async def persist_workspace(self) -> io.IOBase: - return io.BytesIO() - - async def hydrate_workspace(self, data: io.IOBase) -> None: - _ = data - - -class _ProviderNotFoundImageSession(_ImageSession): - async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO: - self.read_users.append(user.name if isinstance(user, User) else user) - normalized = self.normalize_path(path) - if normalized in self.files: - return io.BytesIO(self.files[normalized]) - raise WorkspaceReadNotFoundError(path=normalized) - - class TestViewImageTool: def test_view_image_accepts_needs_approval_setting(self) -> None: - session = _ImageSession(Manifest(root="/workspace")) + session = scripted_sandbox_session() async def needs_approval(_ctx: object, params: dict[str, object], _call_id: str) -> bool: return str(params["path"]).startswith("sensitive/") @@ -105,8 +35,7 @@ async def needs_approval(_ctx: object, params: dict[str, object], _call_id: str) @pytest.mark.asyncio async def test_view_image_returns_tool_output_image_for_png(self) -> None: - session = _ImageSession(Manifest(root="/workspace")) - session.files[Path("/workspace/images/dot.png")] = _PNG_BYTES + session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(_PNG_BYTES)}]) tool = ViewImageTool(session=session) output = await tool.on_invoke_tool( @@ -117,11 +46,11 @@ async def test_view_image_returns_tool_output_image_for_png(self) -> None: assert isinstance(output, ToolOutputImage) assert output.image_url == f"data:image/png;base64,{_PNG_BASE64}" assert output.detail is None + session.assert_complete() @pytest.mark.asyncio async def test_view_image_reads_as_bound_user(self) -> None: - session = _ImageSession(Manifest(root="/workspace")) - session.files[Path("/workspace/images/dot.png")] = _PNG_BYTES + session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(_PNG_BYTES)}]) tool = ViewImageTool(session=session, user=User(name="sandbox-user")) output = await tool.on_invoke_tool( @@ -130,12 +59,12 @@ async def test_view_image_reads_as_bound_user(self) -> None: ) assert isinstance(output, ToolOutputImage) - assert session.read_users == ["sandbox-user"] + assert session.calls[0].kwargs["user"] == User(name="sandbox-user") + session.assert_complete() @pytest.mark.asyncio async def test_view_image_rejects_non_image_files(self) -> None: - session = _ImageSession(Manifest(root="/workspace")) - session.files[Path("/workspace/notes.txt")] = b"hello\n" + session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(b"hello\n")}]) tool = ViewImageTool(session=session) output = await tool.on_invoke_tool( @@ -144,12 +73,17 @@ async def test_view_image_rejects_non_image_files(self) -> None: ) assert output == "image path `notes.txt` is not a supported image file" + session.assert_complete() @pytest.mark.asyncio async def test_view_image_rejects_images_larger_than_10mb(self) -> None: - session = _ImageSession(Manifest(root="/workspace")) - session.files[Path("/workspace/images/huge.png")] = b"\x89PNG\r\n\x1a\n" + ( - b"0" * (_MAX_IMAGE_BYTES + 1) + session = scripted_sandbox_session( + [ + { + "method": "read", + "result": io.BytesIO(b"\x89PNG\r\n\x1a\n" + (b"0" * (_MAX_IMAGE_BYTES + 1))), + } + ] ) tool = ViewImageTool(session=session) @@ -162,14 +96,24 @@ async def test_view_image_rejects_images_larger_than_10mb(self) -> None: "image path `images/huge.png` exceeded the allowed size of 10MB; " "resize or compress the image and try again" ) + session.assert_complete() @pytest.mark.asyncio async def test_view_image_rejection_text_does_not_expose_provider_path(self) -> None: provider_root = Path("/provider/private/root") - session = _ProviderNotFoundImageSession(Manifest(root=str(provider_root))) - session.files[provider_root / "notes.txt"] = b"hello\n" - session.files[provider_root / "images/huge.png"] = b"\x89PNG\r\n\x1a\n" + ( - b"0" * (_MAX_IMAGE_BYTES + 1) + session = scripted_sandbox_session( + [ + { + "method": "read", + "error": WorkspaceReadNotFoundError(path=provider_root / "images/missing.png"), + }, + {"method": "read", "result": io.BytesIO(b"hello\n")}, + { + "method": "read", + "result": io.BytesIO(b"\x89PNG\r\n\x1a\n" + (b"0" * (_MAX_IMAGE_BYTES + 1))), + }, + ], + manifest=Manifest(root=str(provider_root)), ) tool = ViewImageTool(session=session) diff --git a/tests/sandbox/integration_tests/test_model.py b/tests/sandbox/integration_tests/test_model.py index b784ff9f57..fad9f1d566 100644 --- a/tests/sandbox/integration_tests/test_model.py +++ b/tests/sandbox/integration_tests/test_model.py @@ -5,19 +5,19 @@ from typing import Any from agents.items import TResponseOutputItem -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_final_output_message, get_function_tool_call __test__ = False -class TestModel(FakeModel): +class TestModel(ScriptedModel): """Reusable queued model for sandbox integration tests.""" __test__ = False def queue_turn(self, *items: TResponseOutputItem) -> None: - self.set_next_output(list(items)) + self.enqueue(list(items)) def queue_function_call( self, diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index dc889704b1..75ad621124 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -75,7 +75,7 @@ ) from agents.sandbox.runtime import _stream_memory_input_override from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_final_output_message, get_text_message from tests.utils.hitl import make_shell_call @@ -148,8 +148,8 @@ def _memory_config( extra_prompt: str | None = None, layout: MemoryLayoutConfig | None = None, read: MemoryReadConfig | None = None, - phase_one_model: FakeModel | None = None, - phase_two_model: FakeModel | None = None, + phase_one_model: ScriptedModel | None = None, + phase_two_model: ScriptedModel | None = None, ) -> Memory: return Memory( layout=layout or MemoryLayoutConfig(), @@ -157,14 +157,16 @@ def _memory_config( generate=MemoryGenerateConfig( max_raw_memories_for_consolidation=max_raw_memories_for_consolidation, extra_prompt=extra_prompt, - phase_one_model=phase_one_model or FakeModel(initial_output=[_phase_one_message()]), + phase_one_model=phase_one_model or ScriptedModel(steps=[[_phase_one_message()]]), phase_two_model=phase_two_model - or FakeModel( - initial_output=[ - _patch_update_call("memory-md", "memories/MEMORY.md", "memory entry"), - _patch_update_call( - "memory-summary", "memories/memory_summary.md", "summary entry" - ), + or ScriptedModel( + steps=[ + [ + _patch_update_call("memory-md", "memories/MEMORY.md", "memory entry"), + _patch_update_call( + "memory-summary", "memories/memory_summary.md", "summary entry" + ), + ] ] ), ), @@ -175,13 +177,12 @@ def _run_config_for_session(session: Any) -> RunConfig: return RunConfig(sandbox=SandboxRunConfig(session=session)) -def _extract_user_text(fake_model: FakeModel) -> str: - assert fake_model.first_turn_args is not None - return _extract_user_text_from_turn_args(fake_model.first_turn_args) +def _extract_user_text(scripted_model: ScriptedModel) -> str: + assert bool(scripted_model.calls) + return _extract_user_text_from_model_input(scripted_model.calls[0].input) -def _extract_user_text_from_turn_args(turn_args: dict[str, Any]) -> str: - input_items = turn_args["input"] +def _extract_user_text_from_model_input(input_items: str | list[Any]) -> str: assert isinstance(input_items, list) first_item = cast(dict[str, Any], input_items[0]) content = first_item["content"] @@ -649,23 +650,25 @@ async def test_runner_memory_generation_sanitizes_and_truncates_phase_one_prompt monkeypatch.setattr(phase_one_module, "_PHASE_ONE_ROLLOUT_TOKEN_LIMIT", 1000) client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_one_model = FakeModel(initial_output=[_phase_one_message()]) + phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) memory = _memory_config(phase_one_model=phase_one_model) agent = SandboxAgent( name="worker", - model=FakeModel( - initial_output=[ - ResponseReasoningItem(id="rs_1", summary=[], type="reasoning"), - cast( - TResponseOutputItem, - { - "id": "compaction_1", - "type": "compaction", - "summary": "compacted-so-far", - "encrypted_content": "encrypted", - }, - ), - get_text_message("done"), + model=ScriptedModel( + steps=[ + [ + ResponseReasoningItem(id="rs_1", summary=[], type="reasoning"), + cast( + TResponseOutputItem, + { + "id": "compaction_1", + "type": "compaction", + "summary": "compacted-so-far", + "encrypted_content": "encrypted", + }, + ), + get_text_message("done"), + ] ] ), instructions="Worker.", @@ -694,7 +697,7 @@ async def test_runner_memory_generation_sanitizes_and_truncates_phase_one_prompt ) assert result.final_output == "done" - assert phase_one_model.first_turn_args is None + assert not phase_one_model.calls await session.aclose() closed = True @@ -721,7 +724,7 @@ async def test_sandbox_agent_without_memory_capability_skips_memory_generation() session = await client.create(manifest=Manifest()) agent = SandboxAgent( name="worker", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", ) @@ -990,14 +993,16 @@ async def test_memory_capability_live_update_instructions() -> None: async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_one_model = FakeModel(initial_output=[_phase_one_message()]) - phase_two_model = FakeModel( - initial_output=[ - _patch_update_call("memory-md", "memories/MEMORY.md", "memory entry"), - _patch_update_call("memory-summary", "memories/memory_summary.md", "summary entry"), + phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) + phase_two_model = ScriptedModel( + steps=[ + [ + _patch_update_call("memory-md", "memories/MEMORY.md", "memory entry"), + _patch_update_call("memory-summary", "memories/memory_summary.md", "summary entry"), + ] ] ) - phase_two_model.set_next_output([get_final_output_message("consolidated")]) + phase_two_model.enqueue([get_final_output_message("consolidated")]) memory = _memory_config( extra_prompt="Track durable user preferences.", phase_one_model=phase_one_model, @@ -1005,7 +1010,7 @@ async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: ) agent = SandboxAgent( name="worker", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", capabilities=[memory], ) @@ -1023,7 +1028,7 @@ async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: assert result.final_output == "done" assert len(rollouts) == 1 - assert phase_one_model.first_turn_args is None + assert not phase_one_model.calls await session.aclose() closed = True @@ -1048,16 +1053,12 @@ async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: assert "rollout_path: sessions/" in rollout_summaries[0].read_text() assert "terminal_state: completed" in rollout_summaries[0].read_text() assert '"terminal_state":"completed"' in _extract_user_text(phase_one_model) - assert phase_one_model.first_turn_args is not None - assert ( - "DEVELOPER-SPECIFIC EXTRA GUIDANCE" - in phase_one_model.first_turn_args["system_instructions"] - ) - assert ( - "Track durable user preferences." - in phase_one_model.first_turn_args["system_instructions"] - ) - assert phase_two_model.first_turn_args is not None + assert bool(phase_one_model.calls) + system_instructions = phase_one_model.calls[0].system_instructions + assert system_instructions is not None + assert "DEVELOPER-SPECIFIC EXTRA GUIDANCE" in system_instructions + assert "Track durable user preferences." in system_instructions + assert bool(phase_two_model.calls) assert "DEVELOPER-SPECIFIC EXTRA GUIDANCE" in _extract_user_text(phase_two_model) assert "Track durable user preferences." in _extract_user_text(phase_two_model) finally: @@ -1068,24 +1069,28 @@ async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: async def test_sandbox_memory_uses_custom_layout() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_two_model = FakeModel( - initial_output=[ - _patch_update_call("memory-md", "agent_memory/MEMORY.md", "memory entry"), - _patch_update_call("memory-summary", "agent_memory/memory_summary.md", "summary entry"), + phase_two_model = ScriptedModel( + steps=[ + [ + _patch_update_call("memory-md", "agent_memory/MEMORY.md", "memory entry"), + _patch_update_call( + "memory-summary", "agent_memory/memory_summary.md", "summary entry" + ), + ] ] ) - phase_two_model.set_next_output([get_final_output_message("consolidated")]) + phase_two_model.enqueue([get_final_output_message("consolidated")]) memory = Memory( layout=MemoryLayoutConfig(memories_dir="agent_memory", sessions_dir="agent_sessions"), read=None, generate=MemoryGenerateConfig( - phase_one_model=FakeModel(initial_output=[_phase_one_message()]), + phase_one_model=ScriptedModel(steps=[[_phase_one_message()]]), phase_two_model=phase_two_model, ), ) agent = SandboxAgent( name="worker", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", capabilities=[memory], ) @@ -1114,47 +1119,51 @@ async def test_sandbox_memory_uses_custom_layout() -> None: async def test_sandbox_memory_supports_multiple_generating_layouts_in_one_session() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_two_model_a = FakeModel( - initial_output=[ - _patch_update_call("a-memory", "agent_a_memory/MEMORY.md", "agent a entry"), - _patch_update_call( - "a-summary", - "agent_a_memory/memory_summary.md", - "agent a summary", - ), + phase_two_model_a = ScriptedModel( + steps=[ + [ + _patch_update_call("a-memory", "agent_a_memory/MEMORY.md", "agent a entry"), + _patch_update_call( + "a-summary", + "agent_a_memory/memory_summary.md", + "agent a summary", + ), + ] ] ) - phase_two_model_a.set_next_output([get_final_output_message("agent a consolidated")]) - phase_two_model_b = FakeModel( - initial_output=[ - _patch_update_call("b-memory", "agent_b_memory/MEMORY.md", "agent b entry"), - _patch_update_call( - "b-summary", - "agent_b_memory/memory_summary.md", - "agent b summary", - ), + phase_two_model_a.enqueue([get_final_output_message("agent a consolidated")]) + phase_two_model_b = ScriptedModel( + steps=[ + [ + _patch_update_call("b-memory", "agent_b_memory/MEMORY.md", "agent b entry"), + _patch_update_call( + "b-summary", + "agent_b_memory/memory_summary.md", + "agent b summary", + ), + ] ] ) - phase_two_model_b.set_next_output([get_final_output_message("agent b consolidated")]) + phase_two_model_b.enqueue([get_final_output_message("agent b consolidated")]) memory_a = _memory_config( layout=MemoryLayoutConfig(memories_dir="agent_a_memory", sessions_dir="agent_a_sessions"), - phase_one_model=FakeModel(initial_output=[_phase_one_message(raw_memory="agent a raw\n")]), + phase_one_model=ScriptedModel(steps=[[_phase_one_message(raw_memory="agent a raw\n")]]), phase_two_model=phase_two_model_a, ) memory_b = _memory_config( layout=MemoryLayoutConfig(memories_dir="agent_b_memory", sessions_dir="agent_b_sessions"), - phase_one_model=FakeModel(initial_output=[_phase_one_message(raw_memory="agent b raw\n")]), + phase_one_model=ScriptedModel(steps=[[_phase_one_message(raw_memory="agent b raw\n")]]), phase_two_model=phase_two_model_b, ) agent_a = SandboxAgent( name="agent-a", - model=FakeModel(initial_output=[get_final_output_message("a done")]), + model=ScriptedModel(steps=[[get_final_output_message("a done")]]), instructions="Agent A.", capabilities=[memory_a], ) agent_b = SandboxAgent( name="agent-b", - model=FakeModel(initial_output=[get_final_output_message("b done")]), + model=ScriptedModel(steps=[[get_final_output_message("b done")]]), instructions="Agent B.", capabilities=[memory_b], ) @@ -1183,7 +1192,7 @@ async def test_sandbox_memory_rejects_different_generate_configs_for_same_layout session = await client.create(manifest=Manifest()) memory = _memory_config() different_memory = _memory_config( - phase_one_model=FakeModel(initial_output=[_phase_one_message(raw_memory="different\n")]) + phase_one_model=ScriptedModel(steps=[[_phase_one_message(raw_memory="different\n")]]) ) try: @@ -1266,27 +1275,31 @@ async def test_sandbox_memory_rejects_shared_sessions_dir_for_different_memories async def test_sandbox_memory_groups_segments_by_sdk_session_until_close() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_one_model = FakeModel(initial_output=[_phase_one_message(raw_memory="joined raw\n")]) - phase_two_model = FakeModel( - initial_output=[ - _patch_update_call("memory-md", "memories/MEMORY.md", "joined entry"), - _patch_update_call("memory-summary", "memories/memory_summary.md", "joined summary"), + phase_one_model = ScriptedModel(steps=[[_phase_one_message(raw_memory="joined raw\n")]]) + phase_two_model = ScriptedModel( + steps=[ + [ + _patch_update_call("memory-md", "memories/MEMORY.md", "joined entry"), + _patch_update_call( + "memory-summary", "memories/memory_summary.md", "joined summary" + ), + ] ] ) - phase_two_model.set_next_output([get_final_output_message("joined")]) + phase_two_model.enqueue([get_final_output_message("joined")]) memory = _memory_config( phase_one_model=phase_one_model, phase_two_model=phase_two_model, ) first_agent = SandboxAgent( name="first-worker", - model=FakeModel(initial_output=[get_final_output_message("first done")]), + model=ScriptedModel(steps=[[get_final_output_message("first done")]]), instructions="Worker.", capabilities=[memory], ) second_agent = SandboxAgent( name="second-worker", - model=FakeModel(initial_output=[get_final_output_message("second done")]), + model=ScriptedModel(steps=[[get_final_output_message("second done")]]), instructions="Worker.", capabilities=[memory], ) @@ -1324,7 +1337,7 @@ async def test_sandbox_memory_groups_segments_by_sdk_session_until_close() -> No ] assert segments[0]["input"] == [{"content": "first", "role": "user"}] assert segments[1]["input"] == [{"content": "second", "role": "user"}] - assert phase_one_model.first_turn_args is None + assert not phase_one_model.calls await session.aclose() closed = True @@ -1345,8 +1358,8 @@ async def test_sandbox_memory_groups_segments_by_sdk_session_until_close() -> No async def test_sandbox_memory_fallback_does_not_mutate_run_config() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - agent_model = FakeModel() - agent_model.add_multiple_turn_outputs( + agent_model = ScriptedModel() + agent_model.extend( [ [get_final_output_message("first done")], [get_final_output_message("second done")], @@ -1387,7 +1400,7 @@ async def test_sandbox_memory_uses_conversation_id_when_sdk_session_is_absent() session = await client.create(manifest=Manifest()) agent = SandboxAgent( name="worker", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", capabilities=[_memory_config()], ) @@ -1413,8 +1426,8 @@ async def test_sandbox_memory_uses_conversation_id_when_sdk_session_is_absent() async def test_sandbox_memory_uses_group_id_when_sdk_session_is_absent() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - agent_model = FakeModel() - agent_model.add_multiple_turn_outputs( + agent_model = ScriptedModel() + agent_model.extend( [ [get_final_output_message("first done")], [get_final_output_message("second done")], @@ -1450,8 +1463,8 @@ async def test_sandbox_memory_uses_group_id_when_sdk_session_is_absent() -> None async def test_sandbox_memory_uses_per_run_conversation_when_no_conversation_id() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - agent_model = FakeModel() - agent_model.add_multiple_turn_outputs( + agent_model = ScriptedModel() + agent_model.extend( [ [get_final_output_message("first done")], [get_final_output_message("second done")], @@ -1483,27 +1496,29 @@ async def test_sandbox_memory_uses_per_run_conversation_when_no_conversation_id( async def test_sandbox_memory_caps_phase_two_selection_and_surfaces_removed_rollouts() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_one_model = FakeModel() - phase_one_model.add_multiple_turn_outputs( + phase_one_model = ScriptedModel() + phase_one_model.extend( [ [_phase_one_message(slug="first", raw_memory="first raw\n")], [_phase_one_message(slug="second", raw_memory="second raw\n")], ] ) - phase_two_model = FakeModel( - initial_output=[ - _patch_update_call("memory-md", "memories/MEMORY.md", "first entry"), - _patch_update_call("memory-summary", "memories/memory_summary.md", "first summary"), + phase_two_model = ScriptedModel( + steps=[ + [ + _patch_update_call("memory-md", "memories/MEMORY.md", "first entry"), + _patch_update_call("memory-summary", "memories/memory_summary.md", "first summary"), + ] ] ) - phase_two_model.set_next_output([get_final_output_message("consolidated")]) + phase_two_model.enqueue([get_final_output_message("consolidated")]) memory = _memory_config( max_raw_memories_for_consolidation=1, phase_one_model=phase_one_model, phase_two_model=phase_two_model, ) - agent_model = FakeModel() - agent_model.add_multiple_turn_outputs( + agent_model = ScriptedModel() + agent_model.extend( [ [get_final_output_message("first done")], [get_final_output_message("second done")], @@ -1551,8 +1566,8 @@ async def test_sandbox_memory_caps_phase_two_selection_and_surfaces_removed_roll assert "second raw" in merged_raw_memories assert "first raw" not in merged_raw_memories - assert phase_two_model.first_turn_args is not None - prompt = _extract_user_text_from_turn_args(phase_two_model.first_turn_args) + assert bool(phase_two_model.calls) + prompt = _extract_user_text_from_model_input(phase_two_model.calls[0].input) assert "newly added since the last successful Phase 2 run: 1" in prompt assert f"rollout_id={selected_rollout_ids[0]}" in prompt finally: @@ -1563,21 +1578,25 @@ async def test_sandbox_memory_caps_phase_two_selection_and_surfaces_removed_roll async def test_sandbox_memory_runs_phase_one_and_phase_two_on_session_close() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_one_model = FakeModel(initial_output=[_phase_one_message()]) - phase_two_model = FakeModel( - initial_output=[ - _patch_update_call("memory-md", "memories/MEMORY.md", "shutdown entry"), - _patch_update_call("memory-summary", "memories/memory_summary.md", "shutdown summary"), + phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) + phase_two_model = ScriptedModel( + steps=[ + [ + _patch_update_call("memory-md", "memories/MEMORY.md", "shutdown entry"), + _patch_update_call( + "memory-summary", "memories/memory_summary.md", "shutdown summary" + ), + ] ] ) - phase_two_model.set_next_output([get_final_output_message("shutdown")]) + phase_two_model.enqueue([get_final_output_message("shutdown")]) memory = _memory_config( phase_one_model=phase_one_model, phase_two_model=phase_two_model, ) agent = SandboxAgent( name="worker", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", capabilities=[memory], ) @@ -1752,7 +1771,7 @@ async def _raise_write_rollout(*args: Any, **kwargs: Any) -> Path: client = _DeleteTrackingUnixLocalSandboxClient() agent = SandboxAgent( name="worker", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", capabilities=[_memory_config()], ) @@ -1796,23 +1815,25 @@ async def _raise_write_rollout(*args: Any, **kwargs: Any) -> Path: async def test_sandbox_memory_marks_interrupted_runs_in_phase_one_prompt() -> None: client = UnixLocalSandboxClient() session = await client.create(manifest=Manifest()) - phase_one_model = FakeModel(initial_output=[_phase_one_message()]) - phase_two_model = FakeModel( - initial_output=[ - _patch_update_call("memory-md", "memories/MEMORY.md", "interrupted entry"), - _patch_update_call( - "memory-summary", "memories/memory_summary.md", "interrupted summary" - ), + phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) + phase_two_model = ScriptedModel( + steps=[ + [ + _patch_update_call("memory-md", "memories/MEMORY.md", "interrupted entry"), + _patch_update_call( + "memory-summary", "memories/memory_summary.md", "interrupted summary" + ), + ] ] ) - phase_two_model.set_next_output([get_final_output_message("done")]) + phase_two_model.enqueue([get_final_output_message("done")]) memory = _memory_config( phase_one_model=phase_one_model, phase_two_model=phase_two_model, ) agent = SandboxAgent( name="worker", - model=FakeModel(initial_output=[make_shell_call("approval-call")]), + model=ScriptedModel(steps=[[make_shell_call("approval-call")]]), instructions="Worker.", tools=[ShellTool(executor=lambda _request: "ok", needs_approval=True)], capabilities=[memory], diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 8440abe599..fcef40cb0f 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -97,10 +97,10 @@ from agents.sandbox.snapshot import LocalSnapshotSpec, NoopSnapshot, SnapshotBase from agents.sandbox.types import ExecResult from agents.stream_events import RunItemStreamEvent +from agents.testing import ScriptedModel, scripted_sandbox_session from agents.tool import FunctionTool, Tool from agents.tool_context import ToolContext from agents.tracing import trace -from tests.fake_model import FakeModel from tests.test_responses import ( get_final_output_message, get_function_tool, @@ -1516,7 +1516,7 @@ def _unix_local_run_config( @pytest.mark.asyncio async def test_runner_merges_sandbox_instructions_and_tools() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) capability_tool = get_function_tool("capability_tool", "ok") capability = _RecordingCapability( instruction_text="Capability instructions.", @@ -1564,8 +1564,8 @@ async def test_runner_merges_sandbox_instructions_and_tools() -> None: assert client.create_kwargs["options"] == {"image": "sandbox"} assert isinstance(client.create_kwargs["snapshot"], LocalSnapshotSpec) - assert model.first_turn_args is not None - assert model.first_turn_args["system_instructions"] == ( + assert bool(model.calls) + assert model.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Agent instructions\n\n" "Additional instructions.\n\n" @@ -1573,9 +1573,9 @@ async def test_runner_merges_sandbox_instructions_and_tools() -> None: "Capability instructions.\n\n" f"{runtime_agent_preparation_module._filesystem_instructions(manifest)}" ) - assert [tool.name for tool in model.first_turn_args["tools"]] == ["capability_tool"] + assert [tool.name for tool in model.calls[0].tools] == ["capability_tool"] - input_items = model.first_turn_args["input"] + input_items = model.calls[0].input assert isinstance(input_items, list) assert _extract_user_text(input_items[0]) == "hello" @@ -1603,7 +1603,7 @@ def test_filesystem_instructions_omit_extra_path_grants() -> None: @pytest.mark.asyncio async def test_runner_adds_run_as_user_to_created_manifest_without_default_manifest() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) session = _FakeSession(Manifest()) client = _FakeClient(session) run_as = User(name="sandbox-user") @@ -1629,7 +1629,7 @@ async def test_runner_adds_run_as_user_to_created_manifest_without_default_manif @pytest.mark.asyncio async def test_runner_uses_default_sandbox_prompt_when_instructions_missing() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) capability = _RecordingCapability(instruction_text="Capability instructions.") session = _FakeSession(Manifest()) client = _FakeClient(session) @@ -1646,21 +1646,21 @@ async def test_runner_uses_default_sandbox_prompt_when_instructions_missing() -> ) assert result.final_output == "done" - assert model.first_turn_args is not None + assert bool(model.calls) expected_instructions = ( f"{get_default_sandbox_instructions()}\n\n" "# Sandbox capability instructions\n\n" "Capability instructions.\n\n" f"{runtime_agent_preparation_module._filesystem_instructions(session.state.manifest)}" ) - assert model.first_turn_args["system_instructions"] == (expected_instructions) + assert model.calls[0].system_instructions == (expected_instructions) @pytest.mark.asyncio async def test_runner_handles_missing_default_sandbox_prompt_resource( monkeypatch: pytest.MonkeyPatch, ) -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) capability = _RecordingCapability(instruction_text="Capability instructions.") session = _FakeSession(Manifest()) client = _FakeClient(session) @@ -1686,8 +1686,8 @@ def _raise_file_not_found(_package: object) -> object: runtime_agent_preparation_module.get_default_sandbox_instructions.cache_clear() assert result.final_output == "done" - assert model.first_turn_args is not None - assert model.first_turn_args["system_instructions"] == ( + assert bool(model.calls) + assert model.calls[0].system_instructions == ( "# Agent instructions\n\n" "Additional instructions.\n\n" "# Sandbox capability instructions\n\n" @@ -1698,7 +1698,7 @@ def _raise_file_not_found(_package: object) -> object: @pytest.mark.asyncio async def test_runner_dynamic_instructions_do_not_override_default_sandbox_prompt() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) capability = _RecordingCapability(instruction_text="Capability instructions.") session = _FakeSession(Manifest()) client = _FakeClient(session) @@ -1723,8 +1723,8 @@ def dynamic_instructions( ) assert result.final_output == "done" - assert model.first_turn_args is not None - assert model.first_turn_args["system_instructions"] == ( + assert bool(model.calls) + assert model.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Sandbox capability instructions\n\n" "Capability instructions.\n\n" @@ -1734,7 +1734,7 @@ def dynamic_instructions( @pytest.mark.asyncio async def test_runner_base_instructions_override_default_sandbox_prompt() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) capability = _RecordingCapability(instruction_text="Capability instructions.") session = _FakeSession(Manifest()) client = _FakeClient(session) @@ -1753,8 +1753,8 @@ async def test_runner_base_instructions_override_default_sandbox_prompt() -> Non ) assert result.final_output == "done" - assert model.first_turn_args is not None - assert model.first_turn_args["system_instructions"] == ( + assert bool(model.calls) + assert model.calls[0].system_instructions == ( "Custom base instructions.\n\n" "# Agent instructions\n\n" "Additional instructions.\n\n" @@ -1766,7 +1766,7 @@ async def test_runner_base_instructions_override_default_sandbox_prompt() -> Non @pytest.mark.asyncio async def test_runner_adds_remote_mount_policy_instructions() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) manifest = Manifest( entries={ "remote": S3Mount( @@ -1791,8 +1791,8 @@ async def test_runner_adds_remote_mount_policy_instructions() -> None: ) assert result.final_output == "done" - assert model.first_turn_args is not None - system_instructions = model.first_turn_args["system_instructions"] + assert bool(model.calls) + system_instructions = model.calls[0].system_instructions assert isinstance(system_instructions, str) expected_policy_pattern = re.escape(REMOTE_MOUNT_POLICY) expected_policy_pattern = expected_policy_pattern.replace( @@ -1821,7 +1821,7 @@ async def test_runner_adds_remote_mount_policy_instructions() -> None: @pytest.mark.asyncio async def test_runner_adds_remote_mount_policy_for_non_ephemeral_mounts() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) manifest = Manifest( entries={ "remote": S3Mount( @@ -1847,15 +1847,15 @@ async def test_runner_adds_remote_mount_policy_for_non_ephemeral_mounts() -> Non ) assert result.final_output == "done" - assert model.first_turn_args is not None - system_instructions = model.first_turn_args["system_instructions"] + assert bool(model.calls) + system_instructions = model.calls[0].system_instructions assert isinstance(system_instructions, str) assert "- /workspace/remote (mounted in read-only mode)" in system_instructions @pytest.mark.asyncio async def test_runner_applies_compaction_capability_to_input_and_model_settings() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) session = _FakeSession(Manifest()) client = _FakeClient(session) agent = SandboxAgent( @@ -1878,9 +1878,9 @@ async def test_runner_applies_compaction_capability_to_input_and_model_settings( ) assert result.final_output == "done" - assert model.first_turn_args is not None - assert model.first_turn_args["input"] == input_items[1:] - model_settings = model.first_turn_args["model_settings"] + assert bool(model.calls) + assert model.calls[0].input == input_items[1:] + model_settings = model.calls[0].model_settings assert isinstance(model_settings, ModelSettings) assert model_settings.extra_args == { "context_management": [ @@ -1894,7 +1894,7 @@ async def test_runner_applies_compaction_capability_to_input_and_model_settings( @pytest.mark.asyncio async def test_runner_marks_writable_remote_mounts_in_policy() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) manifest = Manifest( entries={ "remote": S3Mount( @@ -1920,8 +1920,8 @@ async def test_runner_marks_writable_remote_mounts_in_policy() -> None: ) assert result.final_output == "done" - assert model.first_turn_args is not None - system_instructions = model.first_turn_args["system_instructions"] + assert bool(model.calls) + system_instructions = model.calls[0].system_instructions assert isinstance(system_instructions, str) assert "- /workspace/remote (mounted in read+write mode)" in system_instructions assert "Use `apply_patch` directly for text edits on read+write mounts." in system_instructions @@ -1933,7 +1933,7 @@ async def test_runner_marks_writable_remote_mounts_in_policy() -> None: @pytest.mark.asyncio async def test_runner_uses_manifest_remote_mount_command_allowlist_override() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) manifest = Manifest( entries={ "remote": S3Mount( @@ -1959,8 +1959,8 @@ async def test_runner_uses_manifest_remote_mount_command_allowlist_override() -> ) assert result.final_output == "done" - assert model.first_turn_args is not None - system_instructions = model.first_turn_args["system_instructions"] + assert bool(model.calls) + system_instructions = model.calls[0].system_instructions assert isinstance(system_instructions, str) assert "Only use these commands on remote mounts:" in system_instructions assert "`ls`, `cp`" in system_instructions @@ -1970,7 +1970,7 @@ async def test_runner_uses_manifest_remote_mount_command_allowlist_override() -> async def test_runner_requires_sandbox_config_for_sandbox_agent() -> None: agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -1980,7 +1980,7 @@ async def test_runner_requires_sandbox_config_for_sandbox_agent() -> None: @pytest.mark.asyncio async def test_runner_streamed_cleans_runner_owned_session() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) session = _FakeSession(Manifest()) client = _FakeClient(session) agent = SandboxAgent( @@ -2023,7 +2023,7 @@ async def test_runner_streamed_guardrail_trip_blocks_runner_owned_sandbox_creati client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", input_guardrails=[ InputGuardrail( @@ -2047,7 +2047,7 @@ async def test_runner_streamed_guardrail_trip_blocks_runner_owned_sandbox_creati @pytest.mark.asyncio async def test_runner_does_not_close_injected_sandbox_session() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) default_manifest = Manifest(entries={"default.txt": File(content=b"default")}) session_manifest = Manifest(entries={"session.txt": File(content=b"session")}) injected_session = _FakeSession(session_manifest) @@ -2075,15 +2075,15 @@ async def test_runner_does_not_close_injected_sandbox_session() -> None: assert injected_session.shutdown_calls == 0 assert injected_session.close_dependency_calls == 0 - assert model.first_turn_args is not None - input_items = model.first_turn_args["input"] + assert bool(model.calls) + input_items = model.calls[0].input assert isinstance(input_items, str) or isinstance(input_items, list) assert injected_session.state.manifest.entries == session_manifest.entries @pytest.mark.asyncio async def test_runner_does_not_restart_running_injected_sandbox_session() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) injected_session = _FakeSession(Manifest(entries={"session.txt": File(content=b"session")})) injected_session._running = True agent = SandboxAgent( @@ -2110,7 +2110,7 @@ async def test_runner_guardrail_trip_blocks_runner_owned_sandbox_creation() -> N client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", input_guardrails=[ InputGuardrail( @@ -2136,7 +2136,7 @@ async def test_runner_guardrail_trip_blocks_running_injected_session_mutation() live_session._running = True agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", capabilities=[_ManifestMutationCapability()], input_guardrails=[ @@ -2167,7 +2167,7 @@ async def test_runner_streamed_guardrail_trip_blocks_running_injected_session_mu live_session._running = True agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", capabilities=[_ManifestMutationCapability()], input_guardrails=[ @@ -2196,7 +2196,7 @@ async def test_runner_streamed_guardrail_trip_blocks_running_injected_session_mu @pytest.mark.asyncio async def test_runner_uses_public_sandbox_agent_for_dynamic_instructions() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) session = _FakeSession(Manifest()) client = _FakeClient(session) seen_agents: list[Agent[Any]] = [] @@ -2221,8 +2221,8 @@ def dynamic_instructions(_ctx: RunContextWrapper[Any], current_agent: Agent[Any] assert result.final_output == "done" assert seen_agents == [agent] - assert model.first_turn_args is not None - assert model.first_turn_args["system_instructions"] == ( + assert bool(model.calls) + assert model.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Agent instructions\n\n" "Saw public agent.\n\n" @@ -2242,7 +2242,7 @@ def dynamic_prompt(data: GenerateDynamicPromptData) -> Prompt: agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", prompt=dynamic_prompt, capabilities=[_RecordingCapability(instruction_text="Capability instructions.")], @@ -2257,7 +2257,7 @@ def dynamic_prompt(data: GenerateDynamicPromptData) -> Prompt: streamed_agent = SandboxAgent( name="streamed-sandbox", - model=FakeModel(initial_output=[get_final_output_message("streamed done")]), + model=ScriptedModel(steps=[[get_final_output_message("streamed done")]]), instructions="Base instructions.", prompt=dynamic_prompt, capabilities=[_RecordingCapability(instruction_text="Capability instructions.")], @@ -2284,7 +2284,7 @@ def capture_model_input(data: CallModelData[Any]) -> ModelInputData: agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", capabilities=[_RecordingCapability(instruction_text="Capability instructions.")], ) @@ -2315,7 +2315,7 @@ def capture_model_input(data: CallModelData[Any]) -> ModelInputData: agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", capabilities=[_RecordingCapability(instruction_text="Capability instructions.")], ) @@ -2340,9 +2340,9 @@ def capture_model_input(data: CallModelData[Any]) -> ModelInputData: @pytest.mark.asyncio async def test_runner_reuses_prepared_sandbox_agent_across_turns_for_tool_choice_reset() -> None: - model = FakeModel() + model = ScriptedModel() tool = get_function_tool("capability_tool", "ok") - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("capability_tool", json.dumps({}))], [get_final_output_message("done")], @@ -2361,15 +2361,15 @@ async def test_runner_reuses_prepared_sandbox_agent_across_turns_for_tool_choice result = await Runner.run(agent, "hello", run_config=_sandbox_run_config(client)) assert result.final_output == "done" - assert model.first_turn_args is not None - assert model.first_turn_args["model_settings"].tool_choice == "required" - assert model.last_turn_args["model_settings"].tool_choice is None + assert bool(model.calls) + assert model.calls[0].model_settings.tool_choice == "required" + assert model.calls[-1].model_settings.tool_choice is None @pytest.mark.asyncio async def test_runner_rebuilds_sandbox_resources_for_handoff_target_agent() -> None: - triage_model = FakeModel() - worker_model = FakeModel(initial_output=[get_final_output_message("done")]) + triage_model = ScriptedModel() + worker_model = ScriptedModel(steps=[[get_final_output_message("done")]]) client = _ManifestSessionClient() triage_manifest = Manifest(entries={"README.md": File(content=b"Triage workspace")}) worker_manifest = Manifest(entries={"README.md": File(content=b"Worker workspace")}) @@ -2388,7 +2388,7 @@ async def test_runner_rebuilds_sandbox_resources_for_handoff_target_agent() -> N capabilities=[_ManifestInstructionsCapability()], handoffs=[worker], ) - triage_model.turn_outputs = [[get_handoff_tool_call(worker)]] + triage_model.enqueue([get_handoff_tool_call(worker)]) result = await Runner.run( triage, @@ -2404,8 +2404,8 @@ async def test_runner_rebuilds_sandbox_resources_for_handoff_target_agent() -> N client.created_manifests[0].entries["README.md"] != client.created_manifests[1].entries["README.md"] ) - assert worker_model.first_turn_args is not None - assert worker_model.first_turn_args["system_instructions"] == ( + assert bool(worker_model.calls) + assert worker_model.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Agent instructions\n\n" "Worker instructions.\n\n" @@ -2418,8 +2418,8 @@ async def test_runner_rebuilds_sandbox_resources_for_handoff_target_agent() -> N @pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"]) @pytest.mark.asyncio async def test_context_rewrite_releases_removed_nested_history_ownership(streamed: bool) -> None: - triage_model = FakeModel() - worker_model = FakeModel(initial_output=[get_final_output_message("done")]) + triage_model = ScriptedModel() + worker_model = ScriptedModel(steps=[[get_final_output_message("done")]]) client = _ManifestSessionClient() worker = SandboxAgent( name="worker", @@ -2434,9 +2434,9 @@ async def test_context_rewrite_releases_removed_nested_history_ownership(streame capabilities=[], handoffs=[worker], ) - triage_model.turn_outputs = [ - [get_final_output_message("handoff message"), get_handoff_tool_call(worker)] - ] + triage_model.extend( + [[get_final_output_message("handoff message"), get_handoff_tool_call(worker)]] + ) run_config = RunConfig( sandbox=SandboxRunConfig(client=client), nest_handoff_history=True, @@ -2463,8 +2463,8 @@ async def test_context_rewrite_releases_removed_nested_history_ownership(streame async def test_context_rebuild_retains_unambiguous_nested_history_ownership( streamed: bool, ) -> None: - triage_model = FakeModel() - worker_model = FakeModel(initial_output=[get_final_output_message("done")]) + triage_model = ScriptedModel() + worker_model = ScriptedModel(steps=[[get_final_output_message("done")]]) client = _ManifestSessionClient() worker = SandboxAgent( name="worker", @@ -2479,9 +2479,9 @@ async def test_context_rebuild_retains_unambiguous_nested_history_ownership( capabilities=[], handoffs=[worker], ) - triage_model.turn_outputs = [ - [get_final_output_message("handoff message"), get_handoff_tool_call(worker)] - ] + triage_model.extend( + [[get_final_output_message("handoff message"), get_handoff_tool_call(worker)]] + ) run_config = RunConfig( sandbox=SandboxRunConfig(client=client), nest_handoff_history=True, @@ -2506,8 +2506,8 @@ async def test_context_rebuild_retains_unambiguous_nested_history_ownership( @pytest.mark.asyncio async def test_runner_resumed_handoff_materializes_manifest_for_new_sandbox_agent() -> None: - triage_model = FakeModel() - worker_model = FakeModel(initial_output=[get_final_output_message("done")]) + triage_model = ScriptedModel() + worker_model = ScriptedModel(steps=[[get_final_output_message("done")]]) client = _ManifestSessionClient() @function_tool(name_override="approval_tool", needs_approval=True) @@ -2532,7 +2532,7 @@ def approval_tool() -> str: capabilities=[_ManifestInstructionsCapability()], handoffs=[worker], ) - triage_model.add_multiple_turn_outputs( + triage_model.extend( [ [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_resume")], [get_handoff_tool_call(worker)], @@ -2558,8 +2558,8 @@ def approval_tool() -> str: assert resumed.final_output == "done" assert len(client.created_manifests) == 2 assert client.created_manifests[1] is not None - assert worker_model.first_turn_args is not None - assert worker_model.first_turn_args["system_instructions"] == ( + assert bool(worker_model.calls) + assert worker_model.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Agent instructions\n\n" "Worker instructions.\n\n" @@ -2779,7 +2779,7 @@ async def test_unix_local_persist_workspace_excludes_mounted_directory_contents( async def test_runner_allows_fresh_unix_local_sessions_without_options() -> None: agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -2818,7 +2818,7 @@ async def test_unix_local_runner_cleanup_preserves_resumed_caller_owned_workspac state = cast(UnixLocalSandboxSessionState, created.state) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -2918,7 +2918,7 @@ async def test_runner_streamed_ignores_sandbox_cleanup_failures_after_success() client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -2936,7 +2936,7 @@ async def test_runner_omits_sandbox_resume_state_when_cleanup_fails() -> None: client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -2955,7 +2955,7 @@ async def test_runner_clears_sandbox_session_from_non_streamed_results_after_cle client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -2971,7 +2971,7 @@ async def test_runner_streamed_cleans_sandbox_once_after_stream_completion() -> client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -3002,7 +3002,7 @@ async def output_guardrail( agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", capabilities=[_RecordingCapability(instruction_text="Capability instructions.")], output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], @@ -3023,7 +3023,7 @@ async def test_runner_streamed_immediate_cancel_skips_waiting_for_sandbox_cleanu client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -3048,8 +3048,8 @@ async def test_runner_streamed_run_loop_task_waits_for_sandbox_cleanup_and_persi stop_gate = asyncio.Event() session = _PersistingStopSession(Manifest(), stop_gate) client = _FakeClient(session) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_final_output_message("done")], [get_final_output_message("again")], @@ -3114,8 +3114,8 @@ async def test_runner_persists_workspace_and_tool_choice_state_across_sandbox_re def approval_tool() -> str: return "approved" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( @@ -3165,8 +3165,8 @@ def approval_tool() -> str: } state_json = state.to_json() - resumed_model = FakeModel() - resumed_model.add_multiple_turn_outputs( + resumed_model = ScriptedModel() + resumed_model.extend( [ [ get_function_tool_call( @@ -3196,7 +3196,7 @@ def approval_tool() -> str: ) assert resumed.final_output == "done" - assert resumed_model.last_turn_args["model_settings"].tool_choice is None + assert resumed_model.calls[-1].model_settings.tool_choice is None assert any( isinstance(item, ToolCallOutputItem) and item.output == "persist me" @@ -3214,8 +3214,8 @@ async def test_runner_restores_all_sandbox_agents_from_run_state_across_handoffs def approval_tool() -> str: return "approved" - triage_model = FakeModel() - worker_model = FakeModel() + triage_model = ScriptedModel() + worker_model = ScriptedModel() worker = SandboxAgent( name="worker", model=worker_model, @@ -3230,7 +3230,7 @@ def approval_tool() -> str: handoffs=[worker], ) worker.handoffs = [triage] - triage_model.add_multiple_turn_outputs( + triage_model.extend( [ [ get_function_tool_call( @@ -3242,7 +3242,7 @@ def approval_tool() -> str: [get_handoff_tool_call(worker)], ] ) - worker_model.add_multiple_turn_outputs( + worker_model.extend( [ [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")], ] @@ -3264,8 +3264,8 @@ def approval_tool() -> str: assert set(sessions_by_agent) == {triage.name, worker.name} state_json = state.to_json() - resumed_triage_model = FakeModel() - resumed_worker_model = FakeModel() + resumed_triage_model = ScriptedModel() + resumed_worker_model = ScriptedModel() resumed_worker = SandboxAgent( name="worker", model=resumed_worker_model, @@ -3280,8 +3280,8 @@ def approval_tool() -> str: handoffs=[resumed_worker], ) resumed_worker.handoffs = [resumed_triage] - resumed_worker_model.add_multiple_turn_outputs([[get_handoff_tool_call(resumed_triage)]]) - resumed_triage_model.add_multiple_turn_outputs( + resumed_worker_model.extend([[get_handoff_tool_call(resumed_triage)]]) + resumed_triage_model.extend( [ [ get_function_tool_call( @@ -3320,8 +3320,8 @@ async def test_runner_serializes_unique_sandbox_resume_keys_for_duplicate_agent_ def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() first = SandboxAgent( name="sandbox", model=first_model, @@ -3336,7 +3336,7 @@ def approval_tool() -> str: ) first.handoffs = [second] second.handoffs = [first] - first_model.add_multiple_turn_outputs( + first_model.extend( [ [ get_function_tool_call( @@ -3356,7 +3356,7 @@ def approval_tool() -> str: [get_final_output_message("done")], ] ) - second_model.add_multiple_turn_outputs( + second_model.extend( [ [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")], [get_handoff_tool_call(first, call_id="handoff_to_first")], @@ -3395,7 +3395,7 @@ def test_duplicate_name_sandbox_identity_map_uses_capability_and_manifest_config def _make_agent(readme: bytes, capability_text: str) -> SandboxAgent[None]: return SandboxAgent( name="sandbox", - model=FakeModel(), + model=ScriptedModel(), instructions="Base instructions.", default_manifest=Manifest(entries={"README.md": File(content=readme)}), capabilities=[_RecordingCapability(instruction_text=capability_text)], @@ -3431,8 +3431,8 @@ def _identity_for(identity_map: dict[str, Agent[Any]], target: Agent[Any]) -> st async def test_session_manager_reserves_current_duplicate_resume_key_for_current_agent() -> None: manifest = Manifest(entries={"README.md": File(content=b"duplicate resume")}) client = _FakeClient(_FakeSession(manifest)) - first = SandboxAgent(name="sandbox", model=FakeModel(), instructions="First.") - second = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Second.") + first = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="First.") + second = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="Second.") first.handoffs = [second] second.handoffs = [first] first_session_state = client.serialize_session_state( @@ -3478,9 +3478,9 @@ async def test_session_manager_reserves_current_duplicate_resume_key_for_current def test_session_manager_generates_collision_free_resume_keys_for_literal_suffix_names() -> None: client = _FakeClient(_FakeSession(Manifest())) - first = SandboxAgent(name="sandbox", model=FakeModel(), instructions="First.") - literal_suffix = SandboxAgent(name="sandbox#2", model=FakeModel(), instructions="Literal.") - second = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Second.") + first = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="First.") + literal_suffix = SandboxAgent(name="sandbox#2", model=ScriptedModel(), instructions="Literal.") + second = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="Second.") first.handoffs = [literal_suffix, second] literal_suffix.handoffs = [first, second] second.handoffs = [first, literal_suffix] @@ -3504,7 +3504,7 @@ def test_session_manager_generates_collision_free_resume_keys_for_literal_suffix async def test_session_manager_passes_concurrency_limits_from_run_config( source: str, ) -> None: - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") live_session = _FakeSession(Manifest()) client = _FakeClient(live_session) @@ -3558,7 +3558,7 @@ async def test_session_manager_passes_concurrency_limits_from_run_config( async def test_session_manager_passes_archive_limits_from_run_config( source: str, ) -> None: - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") live_session = _FakeSession(Manifest()) client = _FakeClient(live_session) archive_limits = SandboxArchiveLimits( @@ -3603,7 +3603,7 @@ async def test_session_manager_passes_archive_limits_from_run_config( @pytest.mark.asyncio async def test_session_manager_default_archive_limits_preserves_no_resource_limits() -> None: - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") live_session = _FakeSession(Manifest()) client = _FakeClient(live_session) manager = SandboxRuntimeSessionManager( @@ -3620,7 +3620,7 @@ async def test_session_manager_default_archive_limits_preserves_no_resource_limi @pytest.mark.asyncio async def test_session_manager_rejects_invalid_archive_limits() -> None: - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") client = _FakeClient(_FakeSession(Manifest())) limits = SandboxArchiveLimits(max_input_bytes=1) limits.max_input_bytes = 0 @@ -3660,7 +3660,7 @@ async def test_session_manager_rejects_invalid_concurrency_limits( limits: SandboxConcurrencyLimits, message: str, ) -> None: - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") client = _FakeClient(_FakeSession(Manifest())) manager = SandboxRuntimeSessionManager( starting_agent=agent, @@ -3684,8 +3684,8 @@ async def test_session_manager_rejects_invalid_concurrency_limits( async def test_session_manager_preserves_untouched_run_state_sessions_on_cleanup() -> None: manifest = Manifest(entries={"README.md": File(content=b"duplicate resume")}) client = _FakeClient(_FakeSession(manifest)) - triage = SandboxAgent(name="triage", model=FakeModel(), instructions="Triage.") - worker = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + triage = SandboxAgent(name="triage", model=ScriptedModel(), instructions="Triage.") + worker = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") triage.handoffs = [worker] worker.handoffs = [triage] triage_session_state = client.serialize_session_state( @@ -3745,7 +3745,7 @@ async def test_session_manager_reapplies_capability_manifest_mutations_on_resume capability = _ManifestMutationCapability() agent = SandboxAgent( name="worker", - model=FakeModel(), + model=ScriptedModel(), instructions="Worker.", default_manifest=Manifest(), ) @@ -3821,7 +3821,7 @@ async def test_session_manager_rebinds_persisted_path_grants_from_current_manife client = _FakeClient(_FakeSession(Manifest())) agent = SandboxAgent( name="worker", - model=FakeModel(), + model=ScriptedModel(), instructions="Worker.", default_manifest=trusted_manifest, ) @@ -3885,7 +3885,7 @@ async def test_session_manager_rebinds_redacted_external_mount_authority() -> No client.backend_id = "docker" agent = SandboxAgent( name="worker", - model=FakeModel(), + model=ScriptedModel(), instructions="Worker.", default_manifest=trusted_manifest, ) @@ -3955,7 +3955,7 @@ async def test_session_manager_rebinds_capability_host_path_grant_once( client = _FakeClient(_FakeSession(Manifest())) agent = SandboxAgent( name="worker", - model=FakeModel(), + model=ScriptedModel(), instructions="Worker.", default_manifest=Manifest(), ) @@ -4009,7 +4009,7 @@ async def test_session_manager_rejects_unmarked_serialized_host_path( tmp_path: Path, ) -> None: client = _FakeClient(_FakeSession(Manifest())) - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") serialized_state = TestSessionState( manifest=Manifest( extra_path_grants=( @@ -4065,7 +4065,7 @@ async def test_session_manager_adds_run_as_user_on_resume() -> None: run_as = User(name="sandbox-user") agent = SandboxAgent( name="worker", - model=FakeModel(), + model=ScriptedModel(), instructions="Worker.", run_as=run_as, ) @@ -4111,7 +4111,7 @@ async def test_session_manager_applies_capability_manifest_mutations_with_sessio source: str, ) -> None: capability = _ManifestMutationCapability() - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") run_state: RunState[Any, Agent[Any]] | None = None if source == "live_session": @@ -4163,7 +4163,7 @@ async def test_session_manager_applies_capability_manifest_mutations_with_sessio async def test_session_manager_starts_stopped_injected_session_with_manifest_mutation() -> None: live_session = _LiveSessionDeltaRecorder(Manifest()) capability = _ManifestMutationCapability() - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4207,7 +4207,7 @@ async def test_session_manager_rejects_unsafe_stopped_injected_session_manifest( [_CredentialedMountCapability()] if authority_source == "capability" else [] ) live_session = _LiveSessionDeltaRecorder(initial_manifest) - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4250,7 +4250,7 @@ async def test_session_manager_redacts_capability_failure_with_external_mount_au client = _FakeClient(_FakeSession(Manifest())) agent = SandboxAgent( name="worker", - model=FakeModel(), + model=ScriptedModel(), instructions="Worker.", default_manifest=manifest if manifest_source == "agent_default" else None, ) @@ -4286,7 +4286,7 @@ async def test_session_manager_redacts_capability_failure_with_external_mount_au async def test_session_manager_redacts_authority_added_before_capability_failure() -> None: sentinel = "capability-added-mount-secret" client = _FakeClient(_FakeSession(Manifest())) - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig( @@ -4407,7 +4407,7 @@ async def test_session_manager_rejects_stopped_injected_session_host_mount_chang Manifest(extra_path_grants=current_grants), ) capability = _ManifestPathGrantsCapability(processed_grants) - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4431,7 +4431,7 @@ async def test_session_manager_materializes_running_injected_session_manifest_mu live_session = _LiveSessionDeltaRecorder(Manifest()) live_session._running = True capability = _ManifestMutationCapability() - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4464,7 +4464,7 @@ async def test_session_manager_validates_running_manifest_update_before_material inner._running = True live_session = SandboxSession(inner) capability = _ManifestMutationCapability() - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4488,7 +4488,7 @@ async def test_session_manager_retries_running_injected_session_delta_apply_afte live_session = _LiveSessionDeltaRecorder(Manifest(), fail_entry_batch_times=1) live_session._running = True capability = _ManifestMutationCapability() - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4528,7 +4528,7 @@ async def test_session_manager_retries_running_injected_session_delta_apply_afte async def test_session_manager_skips_rematerialization_for_unchanged_running_session() -> None: live_session = _LiveSessionDeltaRecorder(Manifest()) live_session._running = True - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4557,7 +4557,7 @@ async def test_session_manager_skips_rematerialization_for_unchanged_running_ses async def test_session_manager_rejects_running_injected_session_account_mutation() -> None: live_session = _LiveSessionDeltaRecorder(Manifest()) live_session._running = True - agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + agent = SandboxAgent(name="worker", model=ScriptedModel(), instructions="Worker.") manager = SandboxRuntimeSessionManager( starting_agent=agent, sandbox_config=SandboxRunConfig(session=live_session), @@ -4580,7 +4580,7 @@ async def test_session_manager_rejects_running_injected_session_account_mutation @pytest.mark.asyncio async def test_session_manager_preserves_existing_payload_when_no_sandbox_session_is_used() -> None: client = _FakeClient(_FakeSession(Manifest())) - agent = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Base instructions.") + agent = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="Base instructions.") run_state: RunState[Any, Agent[Any]] = cast( RunState[Any, Agent[Any]], RunState( @@ -4616,7 +4616,7 @@ async def test_session_manager_preserves_existing_payload_when_no_sandbox_sessio @pytest.mark.asyncio async def test_session_manager_omits_existing_payload_for_injected_live_session() -> None: - agent = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Base instructions.") + agent = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="Base instructions.") live_session = _FakeSession(Manifest()) run_state: RunState[Any, Agent[Any]] = cast( RunState[Any, Agent[Any]], @@ -4657,9 +4657,9 @@ async def test_session_manager_omits_existing_payload_for_injected_live_session( async def test_session_manager_uses_run_state_starting_agent_for_duplicate_resume_keys() -> None: manifest = Manifest(entries={"README.md": File(content=b"duplicate resume")}) client = _FakeClient(_FakeSession(manifest)) - first = SandboxAgent(name="sandbox", model=FakeModel(), instructions="First.") - second = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Second.") - approver = Agent(name="approver", model=FakeModel(), instructions="Approve.", handoffs=[]) + first = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="First.") + second = SandboxAgent(name="sandbox", model=ScriptedModel(), instructions="Second.") + approver = Agent(name="approver", model=ScriptedModel(), instructions="Approve.", handoffs=[]) approver.handoffs = [second, first] first.handoffs = [second] second.handoffs = [approver] @@ -4712,7 +4712,7 @@ async def test_session_manager_restores_duplicate_name_sessions_when_only_sandbo def _make_agent(readme: bytes, capability_text: str) -> SandboxAgent[None]: return SandboxAgent( name="sandbox", - model=FakeModel(), + model=ScriptedModel(), instructions="Base instructions.", default_manifest=Manifest(entries={"README.md": File(content=readme)}), capabilities=[_RecordingCapability(instruction_text=capability_text)], @@ -4793,8 +4793,8 @@ async def test_runner_restores_duplicate_name_sandbox_sessions_after_json_roundt def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() first = SandboxAgent( name="sandbox", model=first_model, @@ -4809,7 +4809,7 @@ def approval_tool() -> str: ) first.handoffs = [second] second.handoffs = [first] - first_model.add_multiple_turn_outputs( + first_model.extend( [ [ get_function_tool_call( @@ -4821,7 +4821,7 @@ def approval_tool() -> str: [get_handoff_tool_call(second, call_id="handoff_to_second")], ] ) - second_model.add_multiple_turn_outputs( + second_model.extend( [[get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")]] ) @@ -4834,8 +4834,8 @@ def approval_tool() -> str: state = first_run.to_state() state_json = state.to_json() - resumed_first_model = FakeModel() - resumed_second_model = FakeModel() + resumed_first_model = ScriptedModel() + resumed_second_model = ScriptedModel() resumed_first = SandboxAgent( name="sandbox", model=resumed_first_model, @@ -4850,10 +4850,10 @@ def approval_tool() -> str: ) resumed_first.handoffs = [resumed_second] resumed_second.handoffs = [resumed_first] - resumed_second_model.add_multiple_turn_outputs( + resumed_second_model.extend( [[get_handoff_tool_call(resumed_first, call_id="handoff_to_first")]] ) - resumed_first_model.add_multiple_turn_outputs( + resumed_first_model.extend( [ [ get_function_tool_call( @@ -4891,8 +4891,8 @@ async def test_runner_restores_legacy_current_sandbox_payload_after_json_roundtr def approval_tool() -> str: return "approved" - initial_model = FakeModel() - initial_model.add_multiple_turn_outputs( + initial_model = ScriptedModel() + initial_model.extend( [ [ get_function_tool_call( @@ -4925,8 +4925,8 @@ def approval_tool() -> str: "sessions_by_agent": {str(id(agent)): session_state}, } - resumed_model = FakeModel() - resumed_model.add_multiple_turn_outputs( + resumed_model = ScriptedModel() + resumed_model.extend( [ [ get_function_tool_call( @@ -5365,7 +5365,7 @@ async def test_sandbox_run_persists_only_new_session_input_items() -> None: } ] ) - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) agent = SandboxAgent( name="sandbox", model=model, @@ -5393,8 +5393,8 @@ async def test_sandbox_run_persists_only_new_session_input_items() -> None: @pytest.mark.asyncio async def test_runner_streamed_emits_public_agent_for_tool_and_reasoning_events() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ _get_reasoning_item(), @@ -5449,7 +5449,7 @@ def test_capability_clone_deep_copies_nested_object_state() -> None: def test_capability_clone_preserves_session_field_identity() -> None: capability = Shell() - session = _FakeSession(Manifest()) + session = scripted_sandbox_session() capability.bind(session) cloned = capability.clone() @@ -5832,7 +5832,7 @@ async def test_prepare_agent_rechecks_session_liveness_before_reusing_cached_age client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) runtime = SandboxRuntime( @@ -5870,7 +5870,7 @@ async def test_prepare_agent_binds_run_as_to_cloned_capabilities() -> None: capability = _RecordingCapability() agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), capabilities=[capability], run_as="sandbox-user", ) @@ -5900,7 +5900,7 @@ async def test_prepare_agent_processes_context_with_bound_cached_capabilities() client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), capabilities=[_ProcessContextSessionCapability()], ) runtime = SandboxRuntime( @@ -5943,7 +5943,7 @@ async def test_prepare_agent_starts_new_live_session_even_when_backend_reports_r client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) runtime = SandboxRuntime( @@ -5968,7 +5968,7 @@ async def test_sandbox_runtime_emits_high_level_sdk_spans() -> None: client = _FakeClient(session) agent = SandboxAgent( name="sandbox", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) runtime = SandboxRuntime( @@ -6034,8 +6034,8 @@ async def test_runner_uses_public_agent_for_non_function_tool_outputs() -> None: type="local_shell_call", ) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [local_shell_call], [get_final_output_message("done")], @@ -6069,13 +6069,13 @@ async def test_runner_uses_public_agent_for_non_function_tool_outputs() -> None: @pytest.mark.asyncio async def test_sandbox_agent_as_tool_uses_runner_sandbox_prep() -> None: - child_model = FakeModel(initial_output=[get_final_output_message("child done")]) - parent_model = FakeModel( - initial_output=[ - get_function_tool_call("delegate_to_child", json.dumps({"input": "check sandbox"})) + child_model = ScriptedModel(steps=[[get_final_output_message("child done")]]) + parent_model = ScriptedModel( + steps=[ + [get_function_tool_call("delegate_to_child", json.dumps({"input": "check sandbox"}))] ] ) - parent_model.set_next_output([get_final_output_message("parent done")]) + parent_model.enqueue([get_final_output_message("parent done")]) capability = _RecordingCapability(instruction_text="Use the sandbox carefully.") manifest = Manifest(entries={"README.md": File(content=b"Use repo-safe commands only.")}) @@ -6104,16 +6104,16 @@ async def test_sandbox_agent_as_tool_uses_runner_sandbox_prep() -> None: assert result.final_output == "parent done" assert capability.bound_session is None - assert child_model.first_turn_args is not None - child_input = child_model.first_turn_args["input"] + assert bool(child_model.calls) + child_input = child_model.calls[0].input assert isinstance(child_input, list) assert _extract_user_text(child_input[0]) == "check sandbox" @pytest.mark.asyncio async def test_runner_reapplies_sandbox_prep_on_handoff() -> None: - triage_model = FakeModel() - worker_model = FakeModel(initial_output=[get_final_output_message("done")]) + triage_model = ScriptedModel() + worker_model = ScriptedModel(steps=[[get_final_output_message("done")]]) manifest = Manifest(entries={"README.md": File(content=b"Shared repo instructions.")}) session = _FakeSession(manifest) client = _FakeClient(session) @@ -6135,7 +6135,7 @@ async def test_runner_reapplies_sandbox_prep_on_handoff() -> None: capabilities=[capability_one], handoffs=[worker], ) - triage_model.turn_outputs = [[get_handoff_tool_call(worker)]] + triage_model.enqueue([get_handoff_tool_call(worker)]) result = await Runner.run( triage, @@ -6146,8 +6146,8 @@ async def test_runner_reapplies_sandbox_prep_on_handoff() -> None: assert result.final_output == "done" assert capability_one.bound_session is None assert capability_two.bound_session is None - assert worker_model.first_turn_args is not None - assert worker_model.first_turn_args["system_instructions"] == ( + assert bool(worker_model.calls) + assert worker_model.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Agent instructions\n\n" "Worker instructions.\n\n" @@ -6163,12 +6163,12 @@ async def test_prepare_agent_uses_active_sandbox_agent_memory_capability_for_han client = _FakeClient(session) triage = SandboxAgent( name="triage", - model=FakeModel(), + model=ScriptedModel(), capabilities=[Memory(), Filesystem(), Shell()], ) reviewer = SandboxAgent( name="reviewer", - model=FakeModel(), + model=ScriptedModel(), capabilities=[Memory(generate=None), Filesystem(), Shell()], ) runtime = SandboxRuntime( @@ -6201,11 +6201,11 @@ async def test_prepare_agent_enables_memory_when_handoff_target_adds_capability( client = _FakeClient(session) triage = SandboxAgent( name="triage", - model=FakeModel(), + model=ScriptedModel(), ) worker = SandboxAgent( name="worker", - model=FakeModel(), + model=ScriptedModel(), capabilities=[Memory(), Filesystem(), Shell()], ) runtime = SandboxRuntime( @@ -6234,7 +6234,7 @@ async def test_prepare_agent_enables_memory_when_handoff_target_adds_capability( @pytest.mark.asyncio async def test_runner_restores_sandbox_from_run_state() -> None: - model = FakeModel() + model = ScriptedModel() @function_tool(name_override="approval_tool", needs_approval=True) def approval_tool() -> str: @@ -6250,7 +6250,7 @@ def approval_tool() -> str: tools=[approval_tool], default_manifest=manifest, ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_resume")], [get_final_output_message("done")], @@ -6280,7 +6280,7 @@ def approval_tool() -> str: @pytest.mark.asyncio async def test_runner_rejects_concurrent_reuse_of_same_sandbox_agent() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) start_gate = asyncio.Event() session = _FakeSession(Manifest(), start_gate=start_gate) client = _FakeClient(session) @@ -6322,8 +6322,8 @@ async def test_runner_isolates_shared_capabilities_per_run() -> None: ) client_one = _FakeClient(session_one) client_two = _FakeClient(session_two) - model_one = FakeModel(initial_output=[get_final_output_message("done one")]) - model_two = FakeModel(initial_output=[get_final_output_message("done two")]) + model_one = ScriptedModel(steps=[[get_final_output_message("done one")]]) + model_two = ScriptedModel(steps=[[get_final_output_message("done two")]]) agent_one = SandboxAgent( name="sandbox-one", model=model_one, @@ -6352,9 +6352,9 @@ async def test_runner_isolates_shared_capabilities_per_run() -> None: assert first_result.final_output == "done one" assert second_result.final_output == "done two" - assert model_one.first_turn_args is not None - assert model_two.first_turn_args is not None - assert model_one.first_turn_args["system_instructions"] == ( + assert bool(model_one.calls) + assert bool(model_two.calls) + assert model_one.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Agent instructions\n\n" "Base instructions.\n\n" @@ -6362,7 +6362,7 @@ async def test_runner_isolates_shared_capabilities_per_run() -> None: "Session one instructions.\n\n" f"{runtime_agent_preparation_module._filesystem_instructions(session_one.state.manifest)}" ) - assert model_two.first_turn_args["system_instructions"] == ( + assert model_two.calls[0].system_instructions == ( f"{get_default_sandbox_instructions()}\n\n" "# Agent instructions\n\n" "Base instructions.\n\n" @@ -6375,7 +6375,7 @@ async def test_runner_isolates_shared_capabilities_per_run() -> None: @pytest.mark.asyncio async def test_runner_deep_clones_capability_runtime_state() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) session = _FakeSession(Manifest(entries={"README.md": File(content=b"hello")})) client = _FakeClient(session) @@ -6406,7 +6406,7 @@ def bind(self, session: BaseSandboxSession) -> None: @pytest.mark.asyncio async def test_runner_keeps_public_agent_identity_for_hooks_and_streaming() -> None: - model = FakeModel(initial_output=[get_final_output_message("done")]) + model = ScriptedModel(steps=[[get_final_output_message("done")]]) session = _FakeSession(Manifest()) client = _FakeClient(session) run_hooks = _RecordingRunHooks() @@ -6437,7 +6437,7 @@ async def test_runner_keeps_public_agent_identity_for_hooks_and_streaming() -> N assert agent_hooks.llm_ended_agents == [agent] assert all(item.agent is agent for item in result.new_items) - streamed_model = FakeModel(initial_output=[get_final_output_message("streamed done")]) + streamed_model = ScriptedModel(steps=[[get_final_output_message("streamed done")]]) streamed_session = _FakeSession(Manifest()) streamed_client = _FakeClient(streamed_session) streamed_run_hooks = _RecordingRunHooks() diff --git a/tests/sandbox/test_runtime_agent_preparation.py b/tests/sandbox/test_runtime_agent_preparation.py index c532f7e990..d5054a8b75 100644 --- a/tests/sandbox/test_runtime_agent_preparation.py +++ b/tests/sandbox/test_runtime_agent_preparation.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine from pathlib import Path -from types import SimpleNamespace from typing import Any, cast import pytest @@ -16,8 +15,8 @@ from agents.sandbox.entries import BaseEntry, File from agents.sandbox.manifest import Manifest from agents.sandbox.sandbox_agent import SandboxAgent -from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.types import User +from agents.testing import scripted_sandbox_session def test_sandbox_agent_normalizes_first_party_dictionary_configuration() -> None: @@ -84,8 +83,8 @@ async def instructions(self, manifest: Manifest) -> str | None: return self.fragment -def _session_with_manifest(manifest: Manifest | None) -> object: - return SimpleNamespace(state=SimpleNamespace(manifest=manifest)) +def _session_with_manifest(manifest: Manifest | None): + return scripted_sandbox_session(manifest=manifest) def test_prepare_sandbox_agent_passes_session_manifest_to_capability_instructions(): @@ -97,7 +96,7 @@ def test_prepare_sandbox_agent_passes_session_manifest_to_capability_instruction base_instructions="base instructions", instructions="additional instructions", ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=cast(list[Capability], [capability]), ) instructions = cast( @@ -134,7 +133,7 @@ def test_prepare_sandbox_agent_wraps_capabilities_without_agent_instructions(): name="sandbox", base_instructions="base instructions", ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=cast(list[Capability], [capability]), ) instructions = cast( @@ -170,7 +169,7 @@ def test_prepare_sandbox_agent_passes_default_model_to_capability_sampling_param name="sandbox", instructions="base instructions", ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=cast(list[Capability], [capability]), ) @@ -185,7 +184,7 @@ def test_prepare_sandbox_agent_prepares_default_compaction_policy() -> None: name="sandbox", instructions="base instructions", ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=[Compaction()], ) @@ -203,7 +202,7 @@ def test_prepare_sandbox_agent_uses_default_sandbox_instructions_when_base_missi name="sandbox", instructions="additional instructions", ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=cast(list[Capability], [capability]), ) instructions = cast( @@ -259,7 +258,7 @@ def test_prepare_sandbox_agent_validates_required_capabilities() -> None: instructions="base instructions", capabilities=[Memory()], ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=[Memory()], ) @@ -270,7 +269,7 @@ def test_prepare_sandbox_agent_validates_required_capabilities() -> None: instructions="base instructions", capabilities=[Memory(read=MemoryReadConfig(live_update=False), generate=None)], ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=[Memory(read=MemoryReadConfig(live_update=False), generate=None)], ) @@ -280,7 +279,7 @@ def test_prepare_sandbox_agent_validates_required_capabilities() -> None: instructions="base instructions", capabilities=[Memory()], ), - session=cast(BaseSandboxSession, _session_with_manifest(manifest)), + session=_session_with_manifest(manifest), capabilities=cast( list[Capability], [ diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index 3b97c5d9b7..777cbbc246 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -47,8 +47,8 @@ from agents.run_context import _ApprovalRecord from agents.run_state import _build_agent_map from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent +from agents.testing import ScriptedModel from agents.tool_context import ToolContext -from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer from tests.mcp.model_compat import create_mcp_error from tests.test_responses import get_function_tool_call, get_text_message @@ -1790,14 +1790,14 @@ async def test_agent_as_tool_resume_survives_cancellation_after_nested_output_co nested_model_waiting = asyncio.Event() keep_nested_model_waiting = asyncio.Event() - class BlockingSecondModel(FakeModel): + class BlockingSecondModel(ScriptedModel): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.calls = 0 + self.response_calls = 0 async def get_response(self, *args: Any, **kwargs: Any) -> ModelResponse: - self.calls += 1 - if self.calls == 2: + self.response_calls += 1 + if self.response_calls == 2: nested_model_waiting.set() await keep_nested_model_waiting.wait() return await super().get_response(*args, **kwargs) @@ -1808,24 +1808,26 @@ async def sensitive() -> str: return "inner value" inner_model = BlockingSecondModel( - initial_output=[get_function_tool_call("sensitive", "{}", call_id="inner_call")] + steps=[[get_function_tool_call("sensitive", "{}", call_id="inner_call")]] ) - inner_model.set_next_output([get_text_message("inner done")]) + inner_model.enqueue([get_text_message("inner done")]) inner_agent = Agent(name="inner", model=inner_model, tools=[sensitive]) nested_tool = inner_agent.as_tool( tool_name="delegate", tool_description="Delegate", ) - outer_model = FakeModel( - initial_output=[ - get_function_tool_call( - "delegate", - '{"input":"hi"}', - call_id="outer_call", - ) + outer_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "delegate", + '{"input":"hi"}', + call_id="outer_call", + ) + ] ] ) - outer_model.set_next_output([get_text_message("outer done")]) + outer_model.enqueue([get_text_message("outer done")]) outer_agent = Agent(name="outer", model=outer_model, tools=[nested_tool]) async def run_outer(input_value: Any) -> RunResult | RunResultStreaming: @@ -1843,7 +1845,7 @@ async def run_outer(input_value: Any) -> RunResult | RunResultStreaming: resume_task = asyncio.create_task(run_outer(state)) await nested_model_waiting.wait() assert tool_attempts == ["ran"] - assert inner_model.calls == 2 + assert inner_model.response_calls == 2 resume_task.cancel() with pytest.raises(asyncio.CancelledError): @@ -1853,7 +1855,7 @@ async def run_outer(input_value: Any) -> RunResult | RunResultStreaming: assert result.final_output == "outer done" assert tool_attempts == ["ran"] - assert inner_model.calls == 3 + assert inner_model.response_calls == 3 @pytest.mark.parametrize( @@ -2515,28 +2517,30 @@ async def on_stream(payload: AgentToolStreamEvent) -> None: async def test_agent_as_tool_streaming_settles_multi_segment_text_output() -> None: agent = Agent( name="streamer", - model=FakeModel( - initial_output=[ - ResponseOutputMessage( - id="msg_multi_segment", - role="assistant", - status="completed", - type="message", - content=[ - ResponseOutputText( - annotations=[], - text="first ", - type="output_text", - logprobs=[], - ), - ResponseOutputText( - annotations=[], - text="second", - type="output_text", - logprobs=[], - ), - ], - ) + model=ScriptedModel( + steps=[ + [ + ResponseOutputMessage( + id="msg_multi_segment", + role="assistant", + status="completed", + type="message", + content=[ + ResponseOutputText( + annotations=[], + text="first ", + type="output_text", + logprobs=[], + ), + ResponseOutputText( + annotations=[], + text="second", + type="output_text", + logprobs=[], + ), + ], + ) + ] ] ), ) @@ -2578,28 +2582,30 @@ class StructuredOutput(BaseModel): agent = Agent( name="streamer", - model=FakeModel( - initial_output=[ - ResponseOutputMessage( - id="msg_multi_segment_structured", - role="assistant", - status="completed", - type="message", - content=[ - ResponseOutputText( - annotations=[], - text='{"answer":"str', - type="output_text", - logprobs=[], - ), - ResponseOutputText( - annotations=[], - text='uctured"}', - type="output_text", - logprobs=[], - ), - ], - ) + model=ScriptedModel( + steps=[ + [ + ResponseOutputMessage( + id="msg_multi_segment_structured", + role="assistant", + status="completed", + type="message", + content=[ + ResponseOutputText( + annotations=[], + text='{"answer":"str', + type="output_text", + logprobs=[], + ), + ResponseOutputText( + annotations=[], + text='uctured"}', + type="output_text", + logprobs=[], + ), + ], + ) + ] ] ), output_type=StructuredOutput, @@ -2686,10 +2692,10 @@ async def call_tool( agent = Agent( name="streamer", - model=FakeModel(), + model=ScriptedModel(), mcp_servers=[nested_server], ) - cast(FakeModel, agent.model).add_multiple_turn_outputs( + cast(ScriptedModel, agent.model).extend( [ [get_function_tool_call(tool_name, "{}")], [ diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 4974ce980a..a71b527d4c 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -11,10 +11,10 @@ from agents.lifecycle import AgentHooks from agents.run import Runner from agents.run_context import AgentHookContext, RunContextWrapper, TContext +from agents.testing import ScriptedModel from agents.tool import Tool from agents.tool_context import ToolContext -from .fake_model import FakeModel from .test_responses import ( get_final_output_message, get_function_tool, @@ -82,14 +82,14 @@ def __bool__(self) -> bool: @pytest.mark.asyncio async def test_falsy_agent_hooks_are_invoked() -> None: hooks = FalsyAgentHooks() - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("some_function", "result")], hooks=hooks, ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("some_function", json.dumps({"a": "b"}))], [get_text_message("done")], @@ -109,7 +109,7 @@ async def test_falsy_agent_hooks_are_invoked() -> None: @pytest.mark.asyncio async def test_non_streamed_agent_hooks(): hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test_1", model=model, @@ -128,12 +128,12 @@ async def test_non_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_text_message("user_message")]) + model.enqueue([get_text_message("user_message")]) output = await Runner.run(agent_3, input="user_message") assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], [get_text_message("done")], @@ -144,7 +144,7 @@ async def test_non_streamed_agent_hooks(): assert len(set(hooks.tool_context_ids)) == 1 hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -165,7 +165,7 @@ async def test_non_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -196,7 +196,7 @@ async def test_non_streamed_agent_hooks(): @pytest.mark.asyncio async def test_streamed_agent_hooks(): hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -209,14 +209,14 @@ async def test_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_text_message("user_message")]) + model.enqueue([get_text_message("user_message")]) output = Runner.run_streamed(agent_3, input="user_message") async for _ in output.stream_events(): pass assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -239,7 +239,7 @@ async def test_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -276,7 +276,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_structured_output_non_streamed_agent_hooks(): hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -290,12 +290,12 @@ async def test_structured_output_non_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + model.enqueue([get_final_output_message(json.dumps({"a": "b"}))]) output = await Runner.run(agent_3, input="user_message") assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -316,7 +316,7 @@ async def test_structured_output_non_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -347,7 +347,7 @@ async def test_structured_output_non_streamed_agent_hooks(): @pytest.mark.asyncio async def test_structured_output_streamed_agent_hooks(): hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -361,14 +361,14 @@ async def test_structured_output_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + model.enqueue([get_final_output_message(json.dumps({"a": "b"}))]) output = Runner.run_streamed(agent_3, input="user_message") async for _ in output.stream_events(): pass assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -388,7 +388,7 @@ async def test_structured_output_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -425,7 +425,7 @@ class EmptyAgentHooks(AgentHooks): @pytest.mark.asyncio async def test_base_agent_hooks_dont_crash(): hooks = EmptyAgentHooks() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -438,12 +438,12 @@ async def test_base_agent_hooks_dont_crash(): ) agent_1.handoffs.append(agent_3) - model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + model.enqueue([get_final_output_message(json.dumps({"a": "b"}))]) output = Runner.run_streamed(agent_3, input="user_message") async for _ in output.stream_events(): pass - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -455,7 +455,7 @@ async def test_base_agent_hooks_dont_crash(): ) await Runner.run(agent_3, input="user_message") - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -490,10 +490,10 @@ async def on_start(self, context: AgentHookContext[TContext], agent: Agent[TCont async def test_agent_hooks_receives_turn_input_string(): """Test that on_start receives turn_input when input is a string.""" hooks = AgentHooksWithTurnInput() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, hooks=hooks) - model.set_next_output([get_text_message("response")]) + model.enqueue([get_text_message("response")]) await Runner.run(agent, input="hello world") assert len(hooks.captured_turn_inputs) == 1 @@ -507,7 +507,7 @@ async def test_agent_hooks_receives_turn_input_string(): async def test_agent_hooks_receives_turn_input_list(): """Test that on_start receives turn_input when input is a list.""" hooks = AgentHooksWithTurnInput() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, hooks=hooks) input_items: list[Any] = [ @@ -515,7 +515,7 @@ async def test_agent_hooks_receives_turn_input_list(): {"role": "user", "content": "second message"}, ] - model.set_next_output([get_text_message("response")]) + model.enqueue([get_text_message("response")]) await Runner.run(agent, input=input_items) assert len(hooks.captured_turn_inputs) == 1 @@ -529,10 +529,10 @@ async def test_agent_hooks_receives_turn_input_list(): async def test_agent_hooks_receives_turn_input_streamed(): """Test that on_start receives turn_input in streamed mode.""" hooks = AgentHooksWithTurnInput() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, hooks=hooks) - model.set_next_output([get_text_message("response")]) + model.enqueue([get_text_message("response")]) result = Runner.run_streamed(agent, input="streamed input") async for _ in result.stream_events(): pass diff --git a/tests/test_agent_llm_hooks.py b/tests/test_agent_llm_hooks.py index 31a88315e0..ac4febca73 100644 --- a/tests/test_agent_llm_hooks.py +++ b/tests/test_agent_llm_hooks.py @@ -8,9 +8,9 @@ from agents.lifecycle import AgentHooks from agents.run import Runner from agents.run_context import AgentHookContext, RunContextWrapper, TContext +from agents.testing import ScriptedModel from agents.tool import Tool -from .fake_model import FakeModel from .test_responses import ( get_function_tool, get_text_message, @@ -74,12 +74,12 @@ async def on_llm_end( @pytest.mark.asyncio async def test_async_agent_hooks_with_llm(): hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent( name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[], hooks=hooks ) # Simulate a single LLM call producing an output: - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) await Runner.run(agent, input="hello") # Expect one on_start, one on_llm_start, one on_llm_end, and one on_end assert hooks.events == {"on_start": 1, "on_llm_start": 1, "on_llm_end": 1, "on_end": 1} @@ -88,12 +88,12 @@ async def test_async_agent_hooks_with_llm(): # test_sync_agent_hook_with_llm() def test_sync_agent_hook_with_llm(): hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent( name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[], hooks=hooks ) # Simulate a single LLM call producing an output: - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) Runner.run_sync(agent, input="hello") # Expect one on_start, one on_llm_start, one on_llm_end, and one on_end assert hooks.events == {"on_start": 1, "on_llm_start": 1, "on_llm_end": 1, "on_end": 1} @@ -103,12 +103,12 @@ def test_sync_agent_hook_with_llm(): @pytest.mark.asyncio async def test_streamed_agent_hooks_with_llm(): hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent( name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[], hooks=hooks ) # Simulate a single LLM call producing an output: - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) stream = Runner.run_streamed(agent, input="hello") async for event in stream.stream_events(): diff --git a/tests/test_agent_memory_leak.py b/tests/test_agent_memory_leak.py index 424aa399dc..5a71511bd7 100644 --- a/tests/test_agent_memory_leak.py +++ b/tests/test_agent_memory_leak.py @@ -7,7 +7,7 @@ from openai.types.responses import ResponseOutputMessage, ResponseOutputText from agents import Agent, Runner -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel def _make_message(text: str) -> ResponseOutputMessage: @@ -22,8 +22,8 @@ def _make_message(text: str) -> ResponseOutputMessage: @pytest.mark.asyncio async def test_agent_is_released_after_run() -> None: - fake_model = FakeModel(initial_output=[_make_message("Paris")]) - agent = Agent(name="leak-test-agent", instructions="Answer questions.", model=fake_model) + scripted_model = ScriptedModel(steps=[[_make_message("Paris")]]) + agent = Agent(name="leak-test-agent", instructions="Answer questions.", model=scripted_model) agent_ref = weakref.ref(agent) # Running the agent should not leave behind strong references once the result goes out of scope. diff --git a/tests/test_agent_prompt.py b/tests/test_agent_prompt.py index b9a9865b03..53b0a7888c 100644 --- a/tests/test_agent_prompt.py +++ b/tests/test_agent_prompt.py @@ -10,13 +10,14 @@ from agents.models.interface import Model, ModelProvider from agents.models.openai_responses import OpenAIResponsesModel from agents.prompts import GenerateDynamicPromptData +from agents.testing import ScriptedModel +from tests.model_test_helpers import get_response_obj -from .fake_model import FakeModel, get_response_obj from .test_responses import get_text_message -class PromptCaptureFakeModel(FakeModel): - """Subclass of FakeModel that records the prompt passed to the model.""" +class PromptCaptureScriptedModel(ScriptedModel): + """Subclass of ScriptedModel that records the prompt passed to the model.""" def __init__(self): super().__init__() @@ -91,11 +92,11 @@ def dynamic_prompt_fn(_data): async def test_prompt_is_passed_to_model(): static_prompt: Prompt = {"id": "model_prompt"} - model = PromptCaptureFakeModel() + model = PromptCaptureScriptedModel() agent = Agent(name="test", model=model, prompt=static_prompt) # Ensure the model returns a simple message so the run completes in one turn. - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) await Runner.run(agent, input="hello") @@ -171,7 +172,7 @@ async def failing_prompt(_data: GenerateDynamicPromptData) -> Prompt: agent = Agent( name="prompt-agent", - model=FakeModel(), + model=ScriptedModel(), instructions=slow_instructions, prompt=failing_prompt, ) @@ -206,7 +207,7 @@ async def failing_prompt(_data: GenerateDynamicPromptData) -> Prompt: agent = Agent( name="prompt-agent", - model=FakeModel(), + model=ScriptedModel(), instructions=slow_instructions, prompt=failing_prompt, ) diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 1cce41035c..8f33883012 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -96,11 +96,12 @@ from agents.run_internal.tool_execution import execute_approved_tools from agents.run_internal.tool_use_tracker import AgentToolUseTracker from agents.run_state import RunState +from agents.testing import ModelStep, ScriptedModel from agents.tool import ComputerTool, FunctionToolResult, HostedMCPTool, ShellTool, function_tool from agents.tool_context import ToolContext from agents.usage import Usage -from .fake_model import FakeModel +from .model_test_helpers import get_exact_output_stream_step from .test_responses import ( get_final_output_message, get_function_tool, @@ -182,7 +183,7 @@ async def _run_agent_with_optional_streaming( @pytest.mark.parametrize("streamed", [False, True]) @pytest.mark.asyncio async def test_persistent_hosted_mcp_approval_does_not_cross_servers(streamed: bool) -> None: - model = FakeModel() + model = ScriptedModel() server_a = HostedMCPTool( tool_config=Mcp( type="mcp", @@ -197,27 +198,28 @@ async def test_persistent_hosted_mcp_approval_does_not_cross_servers(streamed: b server_url="https://server-b.example/mcp", ) ) - model.add_multiple_turn_outputs( + outputs = [ [ - [ - McpApprovalRequest( - id="request-a", - type="mcp_approval_request", - arguments="{}", - name="lookup_account", - server_label="server-a", - ) - ], - [ - McpApprovalRequest( - id="request-b", - type="mcp_approval_request", - arguments="{}", - name="lookup_account", - server_label="server-b", - ) - ], - ] + McpApprovalRequest( + id="request-a", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ) + ], + [ + McpApprovalRequest( + id="request-b", + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-b", + ) + ], + ] + model.extend( + [get_exact_output_stream_step(output) for output in outputs] if streamed else outputs ) agent = Agent(name="test", model=model, tools=[server_a, server_b]) @@ -251,7 +253,7 @@ async def test_run_reports_derived_agent_name_collisions_before_model_call( caplog: pytest.LogCaptureFixture, ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - model = FakeModel(initial_output=[get_text_message("done")]) + model = ScriptedModel(steps=[[get_text_message("done")]]) billing = Agent(name="Billing Agent") normalized_billing = Agent(name="billing agent") if surface == "agent_tool": @@ -298,8 +300,8 @@ async def test_run_reports_derived_agent_name_collisions_before_model_call( run_config=run_config, ) - assert model.first_turn_args is None - assert not model.last_turn_args + assert not model.calls + assert not model.calls else: with caplog.at_level("WARNING", logger="openai.agents"): await _run_agent_with_optional_streaming( @@ -309,7 +311,7 @@ async def test_run_reports_derived_agent_name_collisions_before_model_call( run_config=run_config, ) - assert model.first_turn_args is not None + assert bool(model.calls) collision_messages = [ message for message in caplog.messages if message.startswith("Ambiguous ") ] @@ -337,8 +339,8 @@ def second_lookup() -> str: calls.append("second") return "second" - model = FakeModel(initial_output=[get_function_tool_call("lookup", "{}")]) - model.set_next_output([get_text_message("done")]) + model = ScriptedModel(steps=[[get_function_tool_call("lookup", "{}")]]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[first_lookup, second_lookup]) with caplog.at_level("WARNING", logger="openai.agents"): @@ -349,8 +351,8 @@ def second_lookup() -> str: ) assert calls == ["second"] - assert model.first_turn_args is not None - assert model.first_turn_args["tools"] == [second_lookup] + assert bool(model.calls) + assert model.calls[0].tools == [second_lookup] collision_messages = [ message for message in caplog.messages if message.startswith("Ambiguous ") ] @@ -448,7 +450,7 @@ def first_lookup() -> str: def second_lookup() -> str: return "second" - model = FakeModel(initial_output=[get_text_message("done")]) + model = ScriptedModel(steps=[[get_text_message("done")]]) agent = Agent(name="agent", model=model, tools=[first_lookup, second_lookup]) with pytest.raises( @@ -462,7 +464,7 @@ def second_lookup() -> str: run_config=RunConfig(tool_name_collision_policy="error"), ) - assert model.first_turn_args is None + assert not model.calls @pytest.mark.asyncio @@ -471,7 +473,7 @@ async def test_run_warns_once_for_repeated_source_agent_name( caplog: pytest.LogCaptureFixture, ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - model = FakeModel(initial_output=[get_text_message("done")]) + model = ScriptedModel(steps=[[get_text_message("done")]]) agent = Agent( name="orchestrator", model=model, @@ -493,8 +495,8 @@ async def test_run_warns_once_for_repeated_source_agent_name( "tools. Assign a unique routed name to every colliding function tool with " "`name_override=`, `tool_name=`, or a namespace." ] - assert model.first_turn_args is not None - assert model.first_turn_args["tools"] == [agent.tools[-1]] + assert bool(model.calls) + assert model.calls[0].tools == [agent.tools[-1]] def test_multiway_mixed_collision_reports_every_owner_must_be_unique( @@ -532,9 +534,9 @@ def second_route() -> str: @pytest.mark.parametrize("streamed", [False, True]) @pytest.mark.asyncio async def test_handoff_enablement_uses_initialized_turn_context(streamed: bool) -> None: - model = FakeModel() + model = ScriptedModel() target = Agent(name="target", model=model) - model.add_multiple_turn_outputs( + model.extend( [ [get_handoff_tool_call(target)], [get_text_message("done")], @@ -1004,12 +1006,12 @@ def _find_reasoning_input_item( @pytest.mark.asyncio async def test_simple_first_run(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, ) - model.set_next_output([get_text_message("first")]) + model.enqueue([get_text_message("first")]) result = await Runner.run(agent, input="test") assert result.input == "test" @@ -1021,7 +1023,7 @@ async def test_simple_first_run(): assert len(result.to_input_list()) == 2, "should have original input and generated item" - model.set_next_output([get_text_message("second")]) + model.enqueue([get_text_message("second")]) result = await Runner.run( agent, input=[get_text_input_item("message"), get_text_input_item("another_message")] @@ -1034,19 +1036,19 @@ async def test_simple_first_run(): @pytest.mark.asyncio async def test_subsequent_runs(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, ) - model.set_next_output([get_text_message("third")]) + model.enqueue([get_text_message("third")]) result = await Runner.run(agent, input="test") assert result.input == "test" assert len(result.new_items) == 1, "exactly one item should be generated" assert len(result.to_input_list()) == 2, "should have original input and generated item" - model.set_next_output([get_text_message("fourth")]) + model.enqueue([get_text_message("fourth")]) result = await Runner.run(agent, input=result.to_input_list()) assert len(result.input) == 2, f"should have previous input but got {result.input}" @@ -1060,14 +1062,14 @@ async def test_subsequent_runs(): @pytest.mark.asyncio async def test_tool_call_runs(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], @@ -1098,7 +1100,7 @@ async def _ok_tool() -> str: async def _cancel_tool() -> str: raise asyncio.CancelledError("tool-cancelled") - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -1108,7 +1110,7 @@ async def _cancel_tool() -> str: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("ok_tool", "{}", call_id="call_ok"), @@ -1123,7 +1125,7 @@ async def _cancel_tool() -> str: assert result.final_output == "final answer" assert len(result.raw_responses) == 2 - second_turn_input = cast(list[dict[str, Any]], model.last_turn_args["input"]) + second_turn_input = cast(list[dict[str, Any]], model.calls[-1].input) tool_outputs = [ item for item in second_turn_input if item.get("type") == "function_call_output" ] @@ -1144,14 +1146,14 @@ async def test_single_tool_call_with_cancelled_tool_reaches_final_output() -> No async def _cancel_tool() -> str: raise asyncio.CancelledError("tool-cancelled") - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[function_tool(_cancel_tool, name_override="cancel_tool")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("cancel_tool", "{}", call_id="call_cancel")], [get_text_message("final answer")], @@ -1163,7 +1165,7 @@ async def _cancel_tool() -> str: assert result.final_output == "final answer" assert len(result.raw_responses) == 2 - second_turn_input = cast(list[dict[str, Any]], model.last_turn_args["input"]) + second_turn_input = cast(list[dict[str, Any]], model.calls[-1].input) tool_outputs = [ item for item in second_turn_input if item.get("type") == "function_call_output" ] @@ -1180,14 +1182,14 @@ async def _cancel_tool() -> str: @pytest.mark.asyncio async def test_reasoning_item_id_policy_omits_follow_up_reasoning_ids() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -1208,7 +1210,7 @@ async def test_reasoning_item_id_policy_omits_follow_up_reasoning_ids() -> None: ) assert result.final_output == "done" - second_request_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + second_request_reasoning = _find_reasoning_input_item(model.calls[-1].input) assert second_request_reasoning is not None assert "id" not in second_request_reasoning @@ -1219,14 +1221,14 @@ async def test_reasoning_item_id_policy_omits_follow_up_reasoning_ids() -> None: @pytest.mark.asyncio async def test_call_model_input_filter_can_reintroduce_reasoning_ids() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -1260,7 +1262,7 @@ def reintroduce_reasoning_id(data: Any) -> Any: ) assert result.final_output == "done" - second_request_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + second_request_reasoning = _find_reasoning_input_item(model.calls[-1].input) assert second_request_reasoning is not None assert second_request_reasoning.get("id") == "rs_reintroduced" @@ -1269,8 +1271,8 @@ def reintroduce_reasoning_id(data: Any) -> Any: assert "id" not in history_reasoning -class _RevokedReasoningIdModel(FakeModel): - """FakeModel that 404s like the Responses API when a revoked reasoning ID is replayed.""" +class _RevokedReasoningIdModel(ScriptedModel): + """ScriptedModel that 404s like the Responses API when a revoked reasoning ID is replayed.""" def __init__(self) -> None: super().__init__() @@ -1319,7 +1321,7 @@ async def test_omit_policy_strips_reasoning_ids_already_stored_in_the_session() session = SQLiteSession("issue-2020") # Turn 1 predates the mitigation, so the session records the reasoning ID. - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem(id="rs_triage", type="reasoning", summary=[]), @@ -1338,7 +1340,7 @@ async def test_omit_policy_strips_reasoning_ids_already_stored_in_the_session() model.revoked_reasoning_ids.add("rs_triage") # Turn 2 opts into the documented mitigation for this failure. - model.add_multiple_turn_outputs([[get_text_message("done")]]) + model.extend([[get_text_message("done")]]) second = await Runner.run( triage, input="anything else?", @@ -1347,14 +1349,14 @@ async def test_omit_policy_strips_reasoning_ids_already_stored_in_the_session() ) assert second.final_output == "done" - replayed_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + replayed_reasoning = _find_reasoning_input_item(model.calls[-1].input) assert replayed_reasoning is not None assert "id" not in replayed_reasoning @pytest.mark.asyncio async def test_resumed_run_uses_serialized_reasoning_item_id_policy() -> None: - model = FakeModel() + model = ScriptedModel() @function_tool(name_override="approval_tool", needs_approval=True) def approval_tool() -> str: @@ -1366,7 +1368,7 @@ def approval_tool() -> str: tools=[approval_tool], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -1398,14 +1400,14 @@ def approval_tool() -> str: resumed = await Runner.run(agent, restored_state) assert resumed.final_output == "done" - second_request_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + second_request_reasoning = _find_reasoning_input_item(model.calls[-1].input) assert second_request_reasoning is not None assert "id" not in second_request_reasoning @pytest.mark.asyncio async def test_pending_approval_skips_tool_input_guardrails_by_default() -> None: - model = FakeModel() + model = ScriptedModel() guardrail_runs = 0 @tool_input_guardrail @@ -1423,7 +1425,7 @@ def approval_tool() -> str: return "ok" agent = Agent(name="test", model=model, tools=[approval_tool]) - model.set_next_output([get_function_tool_call("approval_tool", "{}", call_id="call_default")]) + model.enqueue([get_function_tool_call("approval_tool", "{}", call_id="call_default")]) result = await Runner.run(agent, "hello") @@ -1434,7 +1436,7 @@ def approval_tool() -> str: @pytest.mark.asyncio async def test_pre_approval_tool_input_guardrails_can_reject_before_pending_approval() -> None: - model = FakeModel() + model = ScriptedModel() executed = False @tool_input_guardrail @@ -1452,7 +1454,7 @@ def approval_tool() -> str: return "ok" agent = Agent(name="test", model=model, tools=[approval_tool]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call_reject")], [get_text_message("done")], @@ -1479,7 +1481,7 @@ def approval_tool() -> str: @pytest.mark.asyncio async def test_pre_approval_tool_input_guardrails_rerun_after_resume() -> None: - model = FakeModel() + model = ScriptedModel() guardrail_runs = 0 executed = 0 @@ -1500,7 +1502,7 @@ def approval_tool() -> str: return "ok" agent = Agent(name="test", model=model, tools=[approval_tool]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call_resume")], [get_text_message("done")], @@ -1530,7 +1532,7 @@ def approval_tool() -> str: @pytest.mark.asyncio async def test_tool_call_context_includes_current_agent() -> None: - model = FakeModel() + model = ScriptedModel() captured_contexts: list[ToolContext[Any]] = [] @function_tool(name_override="foo") @@ -1544,7 +1546,7 @@ def foo(context: ToolContext[Any]) -> str: tools=[foo], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("foo", "{}")], [get_text_message("done")], @@ -1560,7 +1562,7 @@ def foo(context: ToolContext[Any]) -> str: @pytest.mark.asyncio async def test_handoffs(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1576,7 +1578,7 @@ async def test_handoffs(): tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -1600,7 +1602,7 @@ async def test_handoffs(): @pytest.mark.asyncio async def test_nested_handoff_filters_model_input_but_preserves_session_items(): - model = FakeModel() + model = ScriptedModel() delegate = Agent( name="delegate", model=model, @@ -1612,7 +1614,7 @@ async def test_nested_handoff_filters_model_input_but_preserves_session_items(): tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call. [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -1665,7 +1667,7 @@ def capture_model_input(data): @pytest.mark.asyncio async def test_nested_handoff_filters_reasoning_items_from_model_input(): - model = FakeModel() + model = ScriptedModel() delegate = Agent( name="delegate", model=model, @@ -1676,7 +1678,7 @@ async def test_nested_handoff_filters_reasoning_items_from_model_input(): handoffs=[delegate], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -1719,7 +1721,7 @@ def capture_model_input(data): @pytest.mark.asyncio async def test_resume_preserves_filtered_model_input_after_handoff(): - model = FakeModel() + model = ScriptedModel() @function_tool(name_override="approval_tool", needs_approval=True) def approval_tool() -> str: @@ -1737,7 +1739,7 @@ def approval_tool() -> str: tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call( @@ -1795,7 +1797,7 @@ def capture_model_input(data): @pytest.mark.asyncio async def test_resumed_state_updates_agent_after_handoff() -> None: - model = FakeModel() + model = ScriptedModel() @function_tool(name_override="triage_tool", needs_approval=True) def triage_tool() -> str: @@ -1817,7 +1819,7 @@ def delegate_tool() -> str: tools=[triage_tool], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("triage_tool", "{}", call_id="triage-1")], [get_text_message("handoff"), get_handoff_tool_call(delegate)], @@ -1845,7 +1847,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_structured_output(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1860,7 +1862,7 @@ async def test_structured_output(): handoffs=[agent_1], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [ @@ -1921,7 +1923,7 @@ def remove_new_items(handoff_input_data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_handoff_filters(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1937,7 +1939,7 @@ async def test_handoff_filters(): ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -1955,7 +1957,7 @@ async def test_handoff_filters(): @pytest.mark.asyncio async def test_opt_in_handoff_history_nested_and_filters_respected(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="delegate", model=model, @@ -1966,7 +1968,7 @@ async def test_opt_in_handoff_history_nested_and_filters_respected(): handoffs=[agent_1], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("triage summary"), get_handoff_tool_call(agent_1)], [get_text_message("resolution")], @@ -1991,12 +1993,12 @@ async def test_opt_in_handoff_history_nested_and_filters_respected(): assert _input_message_text(result.input[1]) == "triage summary" handoff_summary = _input_message_text(result.input[2]) assert "transfer_to_delegate" in handoff_summary - delegate_input = model.last_turn_args["input"] + delegate_input = model.calls[-1].input assert isinstance(delegate_input, list) assert len(delegate_input) == 3 assert _input_message_text(delegate_input[1]) == "triage summary" - passthrough_model = FakeModel() + passthrough_model = ScriptedModel() delegate = Agent(name="delegate", model=passthrough_model) def passthrough_filter(data: HandoffInputData) -> HandoffInputData: @@ -2008,7 +2010,7 @@ def passthrough_filter(data: HandoffInputData) -> HandoffInputData: handoffs=[handoff(delegate, input_filter=passthrough_filter)], ) - passthrough_model.add_multiple_turn_outputs( + passthrough_model.extend( [ [get_text_message("triage summary"), get_handoff_tool_call(delegate)], [get_text_message("resolution")], @@ -2028,8 +2030,8 @@ def passthrough_filter(data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"]) async def test_falsey_per_handoff_input_filter_takes_precedence(streamed: bool) -> None: - triage_model = FakeModel() - delegate_model = FakeModel() + triage_model = ScriptedModel() + delegate_model = ScriptedModel() delegate = Agent(name="delegate", model=delegate_model) class FalseyInputFilter: @@ -2053,8 +2055,8 @@ def global_filter(_data: HandoffInputData) -> HandoffInputData: model=triage_model, handoffs=[handoff(delegate, input_filter=per_handoff_filter)], ) - triage_model.add_multiple_turn_outputs([[get_handoff_tool_call(delegate)]]) - delegate_model.add_multiple_turn_outputs([[get_text_message("done")]]) + triage_model.extend([[get_handoff_tool_call(delegate)]]) + delegate_model.extend([[get_text_message("done")]]) result = await _run_agent_with_optional_streaming( triage, @@ -2073,21 +2075,17 @@ def global_filter(_data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_opt_in_handoff_history_accumulates_across_multiple_handoffs(): - triage_model = FakeModel() - delegate_model = FakeModel() - closer_model = FakeModel() + triage_model = ScriptedModel() + delegate_model = ScriptedModel() + closer_model = ScriptedModel() closer = Agent(name="closer", model=closer_model) delegate = Agent(name="delegate", model=delegate_model, handoffs=[closer]) triage = Agent(name="triage", model=triage_model, handoffs=[delegate]) - triage_model.add_multiple_turn_outputs( - [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]] - ) - delegate_model.add_multiple_turn_outputs( - [[get_text_message("delegate update"), get_handoff_tool_call(closer)]] - ) - closer_model.add_multiple_turn_outputs([[get_text_message("resolution")]]) + triage_model.extend([[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]) + delegate_model.extend([[get_text_message("delegate update"), get_handoff_tool_call(closer)]]) + closer_model.extend([[get_text_message("resolution")]]) result = await Runner.run( triage, @@ -2096,8 +2094,8 @@ async def test_opt_in_handoff_history_accumulates_across_multiple_handoffs(): ) assert result.final_output == "resolution" - assert closer_model.first_turn_args is not None - closer_input = closer_model.first_turn_args["input"] + assert bool(closer_model.calls) + closer_input = closer_model.calls[0].input assert isinstance(closer_input, list) summary = _as_message(closer_input[0]) assert summary["role"] == "assistant" @@ -2121,8 +2119,8 @@ async def test_server_managed_handoff_history_auto_disables_with_warning( nest_source: str, caplog: pytest.LogCaptureFixture, ) -> None: - triage_model = FakeModel() - delegate_model = FakeModel() + triage_model = ScriptedModel() + delegate_model = ScriptedModel() delegate = Agent(name="delegate", model=delegate_model) run_config = RunConfig() @@ -2134,10 +2132,8 @@ async def test_server_managed_handoff_history_auto_disables_with_warning( run_config = RunConfig(nest_handoff_history=True) triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs) - triage_model.add_multiple_turn_outputs( - [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]] - ) - delegate_model.add_multiple_turn_outputs([[get_text_message("done")]]) + triage_model.extend([[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]) + delegate_model.extend([[get_text_message("done")]]) with caplog.at_level("WARNING", logger="openai.agents"): result = await _run_agent_with_optional_streaming( @@ -2150,8 +2146,8 @@ async def test_server_managed_handoff_history_auto_disables_with_warning( assert result.final_output == "done" assert "do not support nest_handoff_history" in caplog.text - assert delegate_model.first_turn_args is not None - delegate_input = delegate_model.first_turn_args["input"] + assert bool(delegate_model.calls) + delegate_input = delegate_model.calls[0].input assert isinstance(delegate_input, list) assert len(delegate_input) == 1 handoff_output = delegate_input[0] @@ -2172,8 +2168,8 @@ async def test_server_managed_handoff_input_filters_still_raise( streamed: bool, filter_source: str, ) -> None: - triage_model = FakeModel() - delegate_model = FakeModel() + triage_model = ScriptedModel() + delegate_model = ScriptedModel() delegate = Agent(name="delegate", model=delegate_model) def passthrough_filter(data: HandoffInputData) -> HandoffInputData: @@ -2188,10 +2184,8 @@ def passthrough_filter(data: HandoffInputData) -> HandoffInputData: run_config = RunConfig(handoff_input_filter=passthrough_filter) triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs) - triage_model.add_multiple_turn_outputs( - [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]] - ) - delegate_model.add_multiple_turn_outputs([[get_text_message("done")]]) + triage_model.extend([[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]) + delegate_model.extend([[get_text_message("done")]]) with pytest.raises( UserError, @@ -2205,14 +2199,14 @@ def passthrough_filter(data: HandoffInputData) -> HandoffInputData: auto_previous_response_id=True, ) - assert delegate_model.first_turn_args is None + assert not delegate_model.calls @pytest.mark.asyncio async def test_async_input_filter_supported(): # DO NOT rename this without updating pyproject.toml - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -2239,7 +2233,7 @@ async def async_input_filter(data: HandoffInputData) -> HandoffInputData: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -2252,7 +2246,7 @@ async def async_input_filter(data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_invalid_input_filter_fails(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -2280,7 +2274,7 @@ def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -2293,7 +2287,7 @@ def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_non_callable_input_filter_causes_error(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -2318,7 +2312,7 @@ async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[ ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -2337,7 +2331,7 @@ def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: nonlocal call_output call_output = data["bar"] - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -2355,7 +2349,7 @@ def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -2381,7 +2375,7 @@ async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: nonlocal call_output call_output = data["bar"] - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -2399,7 +2393,7 @@ async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -2475,8 +2469,8 @@ def guardrail_function( agent = Agent( name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)] ) - model = FakeModel() - model.set_next_output([get_text_message("user_message")]) + model = ScriptedModel() + model.enqueue([get_text_message("user_message")]) with pytest.raises(InputGuardrailTripwireTriggered): await Runner.run(agent, input="user_message") @@ -2496,8 +2490,8 @@ async def guardrail_function( session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_text_message("should_not_be_saved")]) + model = ScriptedModel() + model.enqueue([get_text_message("should_not_be_saved")]) agent = Agent( name="test", @@ -3335,7 +3329,7 @@ def callback( @pytest.mark.asyncio async def test_persist_session_items_for_guardrail_trip_uses_original_input_when_missing() -> None: session = SimpleListSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) run_state: RunState[Any] = RunState( context=RunContextWrapper(context={}), original_input="input", @@ -3430,8 +3424,8 @@ async def test_conversation_lock_rewind_skips_when_no_snapshot() -> None: ) locked_error.code = "conversation_locked" - model = FakeModel() - model.add_multiple_turn_outputs([locked_error, [get_text_message("ok")]]) + model = ScriptedModel() + model.extend([locked_error, [get_text_message("ok")]]) agent = Agent(name="test", model=model) result = await get_new_response( @@ -3460,8 +3454,8 @@ async def test_conversation_lock_rewind_skips_when_no_snapshot() -> None: async def test_non_streamed_model_retry_does_not_rewind_committed_session_input( tmp_path: Path, session_backend: str ) -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ APIConnectionError( message="connection error", @@ -3503,9 +3497,9 @@ async def test_non_streamed_model_retry_does_not_rewind_committed_session_input( @pytest.mark.asyncio async def test_get_new_response_uses_agent_retry_settings() -> None: - model = FakeModel() - model.set_hardcoded_usage(Usage(requests=1)) - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.set_default_usage(Usage(requests=1)) + model.extend( [ APIConnectionError( message="connection error", @@ -3819,7 +3813,7 @@ def test_collect_retry_owned_tail_serializations_returns_empty_for_empty_session @pytest.mark.asyncio async def test_save_result_to_session_does_not_increment_counter_when_nothing_saved() -> None: session = SimpleListSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) approval_item = ToolApprovalItem( agent=agent, raw_item={"type": "function_call", "call_id": "call-1", "name": "tool"}, @@ -3846,7 +3840,7 @@ async def test_save_result_to_session_does_not_increment_counter_when_nothing_sa @pytest.mark.asyncio async def test_save_result_to_session_returns_count_and_updates_state() -> None: session = SimpleListSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) run_state: RunState[Any] = RunState( context=RunContextWrapper(context={}), original_input="input", @@ -3898,7 +3892,7 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) run_state: RunState[Any] = RunState( context=RunContextWrapper(context={}), original_input="input", @@ -3933,7 +3927,7 @@ async def clear_session(self) -> None: @pytest.mark.asyncio async def test_save_result_to_session_omits_reasoning_ids_when_policy_is_omit() -> None: session = SimpleListSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) run_state: RunState[Any] = RunState( context=RunContextWrapper(context={}), original_input="input", @@ -3985,7 +3979,7 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) run_state: RunState[Any] = RunState( context=RunContextWrapper(context={}), original_input="input", @@ -4040,7 +4034,7 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) run_state: RunState[Any] = RunState( context=RunContextWrapper(context={}), original_input="input", @@ -4131,7 +4125,7 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) # Chat Completions providers have no server-assigned reasoning ID, so the SDK stamps its # own placeholder. That placeholder is not a server identity, so the item is no more # persistable than one with no ID at all. @@ -4177,7 +4171,7 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) placeholder_reasoning = ReasoningItem( agent=agent, raw_item=ResponseReasoningItem( @@ -4205,7 +4199,7 @@ async def clear_session(self) -> None: @pytest.mark.asyncio async def test_save_result_to_session_keeps_tool_call_payload_api_safe() -> None: session = SimpleListSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) tool_call = ToolCallItem( agent=agent, raw_item=ResponseFunctionToolCall( @@ -4349,7 +4343,7 @@ async def test_session_persists_only_new_step_items(monkeypatch: pytest.MonkeyPa """Ensure only per-turn new_step_items are persisted to the session.""" session = SimpleListSession() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) pre_item = _DummyRunItem( {"type": "message", "role": "assistant", "content": "old"}, "message_output_item" @@ -4442,13 +4436,13 @@ def guardrail_function( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], model=model, ) - model.set_next_output([get_text_message("user_message")]) + model.enqueue([get_text_message("user_message")]) with pytest.raises(OutputGuardrailTripwireTriggered): await Runner.run(agent, input="user_message") @@ -4461,8 +4455,8 @@ def guardrail_function( return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_text_message("should_not_be_saved")]) + model = ScriptedModel() + model.enqueue([get_text_message("should_not_be_saved")]) agent = Agent( name="test", model=model, @@ -4487,8 +4481,8 @@ def guardrail_function( raise RuntimeError("guardrail failed") session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_text_message("preserved_on_guardrail_error")]) + model = ScriptedModel() + model.enqueue([get_text_message("preserved_on_guardrail_error")]) agent = Agent( name="test", model=model, @@ -4517,8 +4511,8 @@ async def guardrail_function( raise AssertionError("unreachable") session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_text_message("preserved_on_guardrail_cancellation")]) + model = ScriptedModel() + model.enqueue([get_text_message("preserved_on_guardrail_cancellation")]) agent = Agent( name="test", model=model, @@ -4551,8 +4545,8 @@ def foo(a: str) -> str: return f"result:{a}" session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_function_tool_call("foo", json.dumps({"a": "b"}))]) + model = ScriptedModel() + model.enqueue([get_function_tool_call("foo", json.dumps({"a": "b"}))]) agent = Agent( name="test", model=model, @@ -4569,7 +4563,7 @@ def foo(a: str) -> str: state = streamed.to_state() state._current_turn_persisted_item_count = 2 - model.set_next_output([get_text_message("accepted_final")]) + model.enqueue([get_text_message("accepted_final")]) resumed = await Runner.run(agent, state, session=session) assert resumed.final_output == "accepted_final" @@ -4605,8 +4599,8 @@ def commit_tool() -> str: return "committed-result" session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_function_tool_call("commit_tool", "{}", call_id="call-first")]) + model = ScriptedModel() + model.enqueue([get_function_tool_call("commit_tool", "{}", call_id="call-first")]) agent = Agent( name="test", model=model, @@ -4623,7 +4617,7 @@ def commit_tool() -> str: assert state._current_turn_persisted_item_count == 2 agent.tool_use_behavior = "stop_on_first_tool" - model.set_next_output([get_function_tool_call("commit_tool", "{}", call_id="call-second")]) + model.enqueue([get_function_tool_call("commit_tool", "{}", call_id="call-second")]) if tripwire_triggered: with pytest.raises(OutputGuardrailTripwireTriggered): @@ -4661,8 +4655,8 @@ def guardrail_function( tripwire_triggered=False, # Doesn't trigger tripwire ) - model = FakeModel() - model.set_next_output([get_text_message("response")]) + model = ScriptedModel() + model.enqueue([get_text_message("response")]) agent = Agent( name="test", @@ -4687,8 +4681,8 @@ def guardrail_function( tripwire_triggered=False, # Doesn't trigger tripwire ) - model = FakeModel() - model.set_next_output([get_text_message("response")]) + model = ScriptedModel() + model.enqueue([get_text_message("response")]) agent = Agent( name="test", @@ -4717,7 +4711,7 @@ class FalsyAgentOutputSchema(AgentOutputSchema): def __bool__(self) -> bool: return False - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -4726,7 +4720,7 @@ def __bool__(self) -> bool: output_type=FalsyAgentOutputSchema(Foo), ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -4755,7 +4749,7 @@ def custom_tool_use_behavior( @pytest.mark.asyncio async def test_tool_use_behavior_custom_function(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -4763,7 +4757,7 @@ async def test_tool_use_behavior_custom_function(): tool_use_behavior=custom_tool_use_behavior, ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -4787,12 +4781,12 @@ async def test_tool_use_behavior_custom_function(): @pytest.mark.asyncio async def test_model_settings_override(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, model_settings=ModelSettings(temperature=1.0, max_tokens=1000) ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("a_message"), @@ -4807,20 +4801,20 @@ async def test_model_settings_override(): ) # temperature is overridden by Runner.run, but max_tokens is not - assert model.last_turn_args["model_settings"].temperature == 0.5 - assert model.last_turn_args["model_settings"].max_tokens == 1000 + assert model.calls[-1].model_settings.temperature == 0.5 + assert model.calls[-1].model_settings.max_tokens == 1000 @pytest.mark.asyncio async def test_previous_response_id_passed_between_runs(): """Test that previous_response_id is passed to the model on subsequent runs.""" - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) - assert model.last_turn_args.get("previous_response_id") is None + assert not model.calls await Runner.run(agent, input="test", previous_response_id="resp-non-streamed-test") - assert model.last_turn_args.get("previous_response_id") == "resp-non-streamed-test" + assert model.calls[-1].previous_response_id == "resp-non-streamed-test" @pytest.mark.asyncio @@ -4833,8 +4827,8 @@ async def test_previous_response_id_passed_between_runs(): ], ) async def test_run_rejects_session_with_server_managed_conversation(run_kwargs: dict[str, Any]): - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) session = SimpleListSession() @@ -4844,7 +4838,7 @@ async def test_run_rejects_session_with_server_managed_conversation(run_kwargs: @pytest.mark.asyncio async def test_run_rejects_session_with_resumed_conversation_state(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) session = SimpleListSession() context_wrapper = RunContextWrapper(context=None) @@ -4871,8 +4865,8 @@ async def test_run_rejects_session_with_resumed_conversation_state(): async def test_run_streamed_rejects_session_with_server_managed_conversation( run_kwargs: dict[str, Any], ): - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) session = SimpleListSession() @@ -4882,7 +4876,7 @@ async def test_run_streamed_rejects_session_with_server_managed_conversation( @pytest.mark.asyncio async def test_run_streamed_rejects_session_with_resumed_conversation_state(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) session = SimpleListSession() context_wrapper = RunContextWrapper(context=None) @@ -4901,14 +4895,14 @@ async def test_run_streamed_rejects_session_with_resumed_conversation_state(): async def test_multi_turn_previous_response_id_passed_between_runs(): """Test that previous_response_id is passed to the model on subsequent runs.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], @@ -4917,41 +4911,41 @@ async def test_multi_turn_previous_response_id_passed_between_runs(): ] ) - assert model.last_turn_args.get("previous_response_id") is None + assert not model.calls await Runner.run(agent, input="test", previous_response_id="resp-test-123") - assert model.last_turn_args.get("previous_response_id") == "resp-789" + assert model.calls[-1].previous_response_id == "resp-789" @pytest.mark.asyncio async def test_previous_response_id_passed_between_runs_streamed(): """Test that previous_response_id is passed to the model on subsequent streamed runs.""" - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent( name="test", model=model, ) - assert model.last_turn_args.get("previous_response_id") is None + assert not model.calls result = Runner.run_streamed(agent, input="test", previous_response_id="resp-stream-test") async for _ in result.stream_events(): pass - assert model.last_turn_args.get("previous_response_id") == "resp-stream-test" + assert model.calls[-1].previous_response_id == "resp-stream-test" @pytest.mark.asyncio async def test_previous_response_id_passed_between_runs_streamed_multi_turn(): """Test that previous_response_id is passed to the model on subsequent streamed runs.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], @@ -4960,25 +4954,25 @@ async def test_previous_response_id_passed_between_runs_streamed_multi_turn(): ] ) - assert model.last_turn_args.get("previous_response_id") is None + assert not model.calls result = Runner.run_streamed(agent, input="test", previous_response_id="resp-stream-test") async for _ in result.stream_events(): pass - assert model.last_turn_args.get("previous_response_id") == "resp-789" + assert model.calls[-1].previous_response_id == "resp-789" @pytest.mark.asyncio async def test_conversation_id_only_sends_new_items_multi_turn(): """Test that conversation_id mode only sends new items on subsequent turns.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -5003,8 +4997,8 @@ async def test_conversation_id_only_sends_new_items_multi_turn(): assert result.final_output == "done" # Check the first call - it should include the original input since generated_items is empty - assert model.first_turn_args is not None - first_input = model.first_turn_args["input"] + assert bool(model.calls) + first_input = model.calls[0].input # First call should include the original user input assert isinstance(first_input, list) @@ -5016,7 +5010,7 @@ async def test_conversation_id_only_sends_new_items_multi_turn(): assert user_message.get("content") == "user_message" # Check the input from the last turn (third turn after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # In conversation_id mode, the third turn should only contain the tool output assert isinstance(last_input, list) @@ -5031,14 +5025,14 @@ async def test_conversation_id_only_sends_new_items_multi_turn(): @pytest.mark.asyncio async def test_conversation_id_only_sends_new_items_multi_turn_streamed(): """Test that conversation_id mode only sends new items on subsequent turns (streamed mode).""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -5066,8 +5060,8 @@ async def test_conversation_id_only_sends_new_items_multi_turn_streamed(): assert result.final_output == "done" # Check the first call - it should include the original input since generated_items is empty - assert model.first_turn_args is not None - first_input = model.first_turn_args["input"] + assert bool(model.calls) + first_input = model.calls[0].input # First call should include the original user input assert isinstance(first_input, list) @@ -5079,7 +5073,7 @@ async def test_conversation_id_only_sends_new_items_multi_turn_streamed(): assert user_message.get("content") == "user_message" # Check the input from the last turn (third turn after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # In conversation_id mode, the third turn should only contain the tool output assert isinstance(last_input, list) @@ -5095,14 +5089,14 @@ async def test_conversation_id_only_sends_new_items_multi_turn_streamed(): async def test_previous_response_id_only_sends_new_items_multi_turn(): """Test that previous_response_id mode only sends new items and updates previous_response_id between turns.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], @@ -5117,8 +5111,8 @@ async def test_previous_response_id_only_sends_new_items_multi_turn(): assert result.final_output == "done" # Check the first call - it should include the original input since generated_items is empty - assert model.first_turn_args is not None - first_input = model.first_turn_args["input"] + assert bool(model.calls) + first_input = model.calls[0].input # First call should include the original user input assert isinstance(first_input, list) @@ -5130,7 +5124,7 @@ async def test_previous_response_id_only_sends_new_items_multi_turn(): assert user_message.get("content") == "user_message" # Check the input from the last turn (second turn after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # In previous_response_id mode, the third turn should only contain the tool output assert isinstance(last_input, list) @@ -5141,19 +5135,13 @@ async def test_previous_response_id_only_sends_new_items_multi_turn(): assert tool_result_item.get("type") == "function_call_output" assert tool_result_item.get("call_id") is not None - # Verify that previous_response_id is modified according to fake_model behavior - assert model.last_turn_args.get("previous_response_id") == "resp-789" + # Verify that previous_response_id is modified according to the scripted model behavior. + assert model.calls[-1].previous_response_id == "resp-789" @pytest.mark.asyncio async def test_previous_response_id_retry_does_not_resend_initial_input_multi_turn(): - class StatefulRetrySafeFakeModel(FakeModel): - def get_retry_advice(self, request): - if request.previous_response_id or request.conversation_id: - return ModelRetryAdvice(suggested=True, replay_safety="safe") - return None - - model = StatefulRetrySafeFakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -5166,11 +5154,14 @@ def get_retry_advice(self, request): ), ) - model.add_multiple_turn_outputs( + model.extend( [ - APIConnectionError( - message="connection error", - request=httpx.Request("POST", "https://example.com"), + ModelStep.raise_error( + APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ), + retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"), ), [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], [get_text_message("done")], @@ -5182,7 +5173,7 @@ def get_retry_advice(self, request): ) assert result.final_output == "done" - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert len(last_input) == 1 assert last_input[0].get("type") == "function_call_output" @@ -5192,27 +5183,24 @@ def get_retry_advice(self, request): async def test_auto_previous_response_id_retries_when_policy_approves_unsafe_replay(): seen: list[RetryPolicyContext] = [] - class StatefulRetryUnsafeFakeModel(FakeModel): - def get_retry_advice(self, request): - if request.previous_response_id or request.conversation_id: - return ModelRetryAdvice( - suggested=False, - replay_safety="unsafe", - response_started=True, - ) - return None - def policy(context: RetryPolicyContext) -> RetryDecision: seen.append(context) return RetryDecision(retry=True, approve_unsafe_replay=True) - model = StatefulRetryUnsafeFakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("test_func", '{"arg": "foo"}')], - APIConnectionError( - message="connection closed after response processing started", - request=httpx.Request("POST", "https://example.com"), + ModelStep.raise_error( + APIConnectionError( + message="connection closed after response processing started", + request=httpx.Request("POST", "https://example.com"), + ), + retry_advice=ModelRetryAdvice( + suggested=False, + replay_safety="unsafe", + response_started=True, + ), ), [get_text_message("done")], ] @@ -5241,14 +5229,14 @@ def policy(context: RetryPolicyContext) -> RetryDecision: async def test_previous_response_id_only_sends_new_items_multi_turn_streamed(): """Test that previous_response_id mode only sends new items and updates previous_response_id between turns (streamed mode).""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], @@ -5266,8 +5254,8 @@ async def test_previous_response_id_only_sends_new_items_multi_turn_streamed(): assert result.final_output == "done" # Check the first call - it should include the original input since generated_items is empty - assert model.first_turn_args is not None - first_input = model.first_turn_args["input"] + assert bool(model.calls) + first_input = model.calls[0].input # First call should include the original user input assert isinstance(first_input, list) @@ -5279,7 +5267,7 @@ async def test_previous_response_id_only_sends_new_items_multi_turn_streamed(): assert user_message.get("content") == "user_message" # Check the input from the last turn (second turn after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # In previous_response_id mode, the third turn should only contain the tool output assert isinstance(last_input, list) @@ -5290,19 +5278,13 @@ async def test_previous_response_id_only_sends_new_items_multi_turn_streamed(): assert tool_result_item.get("type") == "function_call_output" assert tool_result_item.get("call_id") is not None - # Verify that previous_response_id is modified according to fake_model behavior - assert model.last_turn_args.get("previous_response_id") == "resp-789" + # Verify that previous_response_id is modified according to the scripted model behavior. + assert model.calls[-1].previous_response_id == "resp-789" @pytest.mark.asyncio async def test_previous_response_id_retry_does_not_resend_initial_input_multi_turn_streamed(): - class StatefulRetrySafeFakeModel(FakeModel): - def get_retry_advice(self, request): - if request.previous_response_id or request.conversation_id: - return ModelRetryAdvice(suggested=True, replay_safety="safe") - return None - - model = StatefulRetrySafeFakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -5315,11 +5297,14 @@ def get_retry_advice(self, request): ), ) - model.add_multiple_turn_outputs( + model.extend( [ - APIConnectionError( - message="connection error", - request=httpx.Request("POST", "https://example.com"), + ModelStep.raise_error( + APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ), + retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"), ), [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], [get_text_message("done")], @@ -5334,7 +5319,7 @@ def get_retry_advice(self, request): assert result.final_output == "done" - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert len(last_input) == 1 assert last_input[0].get("type") == "function_call_output" @@ -5343,14 +5328,14 @@ def get_retry_advice(self, request): @pytest.mark.asyncio async def test_default_send_all_items(): """Test that without conversation_id or previous_response_id, all items are sent.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], @@ -5365,7 +5350,7 @@ async def test_default_send_all_items(): assert result.final_output == "done" # Check the input from the last turn (second turn after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # In default, the second turn should contain ALL items: # 1. Original user message @@ -5403,14 +5388,14 @@ async def test_default_send_all_items(): async def test_default_send_all_items_streamed(): """Test that without conversation_id or previous_response_id, all items are sent (streamed mode).""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], @@ -5428,7 +5413,7 @@ async def test_default_send_all_items_streamed(): assert result.final_output == "done" # Check the input from the last turn (second turn after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # In default mode, the second turn should contain ALL items: # 1. Original user message @@ -5464,13 +5449,13 @@ async def test_default_send_all_items_streamed(): @pytest.mark.asyncio async def test_default_multi_turn_drops_orphan_hosted_shell_calls() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent( name="hosted-shell", model=model, tools=[ShellTool(environment={"type": "container_auto"})], ) - model.add_multiple_turn_outputs( + model.extend( [ [make_shell_call("call_shell_1", id_value="shell_1", commands=["echo hi"])], [get_text_message("done")], @@ -5481,7 +5466,7 @@ async def test_default_multi_turn_drops_orphan_hosted_shell_calls() -> None: assert result.final_output == "done" - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert len(last_input) == 1 assert not any( @@ -5493,7 +5478,7 @@ async def test_default_multi_turn_drops_orphan_hosted_shell_calls() -> None: @pytest.mark.asyncio async def test_manual_pending_shell_call_input_is_preserved_non_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent( name="manual-shell", model=model, @@ -5503,7 +5488,7 @@ async def test_manual_pending_shell_call_input_is_preserved_non_streamed() -> No TResponseInputItem, make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]), ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("test_func", '{"arg": "foo"}')], [get_text_message("done")], @@ -5513,15 +5498,15 @@ async def test_manual_pending_shell_call_input_is_preserved_non_streamed() -> No result = await Runner.run(agent, input=[pending_shell_call]) assert result.final_output == "done" - assert isinstance(model.first_turn_args, dict) + assert bool(model.calls) assert any( isinstance(item, dict) and item.get("type") == "shell_call" and item.get("call_id") == "manual_shell" - for item in model.first_turn_args["input"] + for item in model.calls[0].input ) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert any( isinstance(item, dict) @@ -5533,7 +5518,7 @@ async def test_manual_pending_shell_call_input_is_preserved_non_streamed() -> No @pytest.mark.asyncio async def test_manual_pending_shell_call_input_is_preserved_non_streamed_with_session() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent( name="manual-shell", model=model, @@ -5544,7 +5529,7 @@ async def test_manual_pending_shell_call_input_is_preserved_non_streamed_with_se TResponseInputItem, make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]), ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("test_func", '{"arg": "foo"}')], [get_text_message("done")], @@ -5554,15 +5539,15 @@ async def test_manual_pending_shell_call_input_is_preserved_non_streamed_with_se result = await Runner.run(agent, input=[pending_shell_call], session=session) assert result.final_output == "done" - assert isinstance(model.first_turn_args, dict) + assert bool(model.calls) assert any( isinstance(item, dict) and item.get("type") == "shell_call" and item.get("call_id") == "manual_shell" - for item in model.first_turn_args["input"] + for item in model.calls[0].input ) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert any( isinstance(item, dict) @@ -5574,15 +5559,17 @@ async def test_manual_pending_shell_call_input_is_preserved_non_streamed_with_se @pytest.mark.asyncio async def test_default_multi_turn_streamed_drops_orphan_hosted_shell_calls() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent( name="hosted-shell", model=model, tools=[ShellTool(environment={"type": "container_auto"})], ) - model.add_multiple_turn_outputs( + model.extend( [ - [make_shell_call("call_shell_1", id_value="shell_1", commands=["echo hi"])], + get_exact_output_stream_step( + [make_shell_call("call_shell_1", id_value="shell_1", commands=["echo hi"])] + ), [get_text_message("done")], ] ) @@ -5593,7 +5580,7 @@ async def test_default_multi_turn_streamed_drops_orphan_hosted_shell_calls() -> assert result.final_output == "done" - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert len(last_input) == 1 assert not any( @@ -5605,20 +5592,20 @@ async def test_default_multi_turn_streamed_drops_orphan_hosted_shell_calls() -> @pytest.mark.asyncio async def test_manual_pending_shell_call_input_is_preserved_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="manual-shell", model=model) pending_shell_call = cast( TResponseInputItem, make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]), ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) result = Runner.run_streamed(agent, input=[pending_shell_call]) async for _ in result.stream_events(): pass assert result.final_output == "done" - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert any( isinstance(item, dict) @@ -5630,21 +5617,21 @@ async def test_manual_pending_shell_call_input_is_preserved_streamed() -> None: @pytest.mark.asyncio async def test_manual_pending_shell_call_input_is_preserved_streamed_with_session() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="manual-shell", model=model) session = SimpleListSession() pending_shell_call = cast( TResponseInputItem, make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]), ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) result = Runner.run_streamed(agent, input=[pending_shell_call], session=session) async for _ in result.stream_events(): pass assert result.final_output == "done" - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input assert isinstance(last_input, list) assert any( isinstance(item, dict) @@ -5658,14 +5645,14 @@ async def test_manual_pending_shell_call_input_is_preserved_streamed_with_sessio async def test_auto_previous_response_id_multi_turn(): """Test that auto_previous_response_id=True enables chaining from the first internal turn.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], @@ -5678,8 +5665,8 @@ async def test_auto_previous_response_id_multi_turn(): assert result.final_output == "done" # Check the first call - assert model.first_turn_args is not None - first_input = model.first_turn_args["input"] + assert bool(model.calls) + first_input = model.calls[0].input # First call should include the original user input assert isinstance(first_input, list) @@ -5691,10 +5678,10 @@ async def test_auto_previous_response_id_multi_turn(): assert user_message.get("content") == "user_message" # With auto_previous_response_id=True, first call should NOT have previous_response_id - assert model.first_turn_args.get("previous_response_id") is None + assert model.calls[0].previous_response_id is None # Check the input from the second turn (after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # With auto_previous_response_id=True, the second turn should only contain the tool output assert isinstance(last_input, list) @@ -5707,21 +5694,21 @@ async def test_auto_previous_response_id_multi_turn(): # With auto_previous_response_id=True, second call should have # previous_response_id set to the first response - assert model.last_turn_args.get("previous_response_id") == "resp-789" + assert model.calls[-1].previous_response_id == "resp-789" @pytest.mark.asyncio async def test_auto_previous_response_id_multi_turn_streamed(): """Test that auto_previous_response_id=True enables chaining from the first internal turn (streamed mode).""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], @@ -5737,8 +5724,8 @@ async def test_auto_previous_response_id_multi_turn_streamed(): assert result.final_output == "done" # Check the first call - assert model.first_turn_args is not None - first_input = model.first_turn_args["input"] + assert bool(model.calls) + first_input = model.calls[0].input # First call should include the original user input assert isinstance(first_input, list) @@ -5750,10 +5737,10 @@ async def test_auto_previous_response_id_multi_turn_streamed(): assert user_message.get("content") == "user_message" # With auto_previous_response_id=True, first call should NOT have previous_response_id - assert model.first_turn_args.get("previous_response_id") is None + assert model.calls[0].previous_response_id is None # Check the input from the second turn (after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # With auto_previous_response_id=True, the second turn should only contain the tool output assert isinstance(last_input, list) @@ -5766,21 +5753,21 @@ async def test_auto_previous_response_id_multi_turn_streamed(): # With auto_previous_response_id=True, second call should have # previous_response_id set to the first response - assert model.last_turn_args.get("previous_response_id") == "resp-789" + assert model.calls[-1].previous_response_id == "resp-789" @pytest.mark.asyncio async def test_without_previous_response_id_and_auto_previous_response_id_no_chaining(): """Test that without previous_response_id and auto_previous_response_id, internal turns don't chain.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("test_func", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], @@ -5794,8 +5781,8 @@ async def test_without_previous_response_id_and_auto_previous_response_id_no_cha assert result.final_output == "done" # Check the first call - assert model.first_turn_args is not None - first_input = model.first_turn_args["input"] + assert bool(model.calls) + first_input = model.calls[0].input # First call should include the original user input assert isinstance(first_input, list) @@ -5807,10 +5794,10 @@ async def test_without_previous_response_id_and_auto_previous_response_id_no_cha assert user_message.get("content") == "user_message" # First call should NOT have previous_response_id - assert model.first_turn_args.get("previous_response_id") is None + assert model.calls[0].previous_response_id is None # Check the input from the second turn (after function execution) - last_input = model.last_turn_args["input"] + last_input = model.calls[-1].input # Without passing previous_response_id and auto_previous_response_id, # the second turn should contain all items (no chaining): @@ -5819,13 +5806,13 @@ async def test_without_previous_response_id_and_auto_previous_response_id_no_cha assert len(last_input) == 4 # User message, assistant message, function call, and tool result # Second call should also NOT have previous_response_id (no chaining) - assert model.last_turn_args.get("previous_response_id") is None + assert model.calls[-1].previous_response_id is None @pytest.mark.asyncio async def test_dynamic_tool_addition_run() -> None: """Test that tools can be added to an agent during a run.""" - model = FakeModel() + model = ScriptedModel() executed: dict[str, bool] = {"called": False} @@ -5843,7 +5830,7 @@ async def add_tool() -> str: agent.tools.append(add_tool) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("add_tool", json.dumps({}), call_id="call-add-tool")], [get_function_tool_call("tool2", json.dumps({}), call_id="call-tool-two")], @@ -5859,9 +5846,9 @@ async def add_tool() -> str: @pytest.mark.asyncio async def test_tool_not_found_behavior_returns_error_to_model() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, tool_use_behavior="run_llm_again") - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("missing_tool", "{}", call_id="call_missing")], [get_text_message("recovered")], @@ -5875,7 +5862,7 @@ async def test_tool_not_found_behavior_returns_error_to_model() -> None: ) assert result.final_output == "recovered" - second_turn_input = model.last_turn_args["input"] + second_turn_input = model.calls[-1].input assert isinstance(second_turn_input, list) tool_outputs = [ item @@ -5893,9 +5880,9 @@ async def test_tool_not_found_behavior_returns_error_to_model() -> None: @pytest.mark.asyncio async def test_tool_not_found_behavior_uses_tool_error_formatter() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, tool_use_behavior="run_llm_again") - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("missing_tool", "{}", call_id="call_missing")], [get_text_message("recovered")], @@ -5920,7 +5907,7 @@ async def formatter(args: Any) -> str | None: assert result.final_output == "recovered" assert seen_kinds == ["tool_not_found"] - second_turn_input = model.last_turn_args["input"] + second_turn_input = model.calls[-1].input assert isinstance(second_turn_input, list) tool_outputs = [ item @@ -5938,7 +5925,7 @@ async def formatter(args: Any) -> str | None: @pytest.mark.asyncio async def test_tool_not_found_behavior_handles_mixed_function_tool_calls() -> None: - model = FakeModel() + model = ScriptedModel() calls: list[str] = [] @function_tool(name_override="known_tool") @@ -5952,7 +5939,7 @@ async def known_tool() -> str: tools=[known_tool], tool_use_behavior="run_llm_again", ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("missing_tool", "{}", call_id="call_missing"), @@ -5970,7 +5957,7 @@ async def known_tool() -> str: assert calls == ["known_tool"] assert result.final_output == "done" - second_turn_input = model.last_turn_args["input"] + second_turn_input = model.calls[-1].input assert isinstance(second_turn_input, list) tool_outputs = { item.get("call_id"): item.get("output") @@ -6009,9 +5996,9 @@ async def echo_tool(text: str) -> str: ) # Patch the model to simulate two tool calls and a final message - model = FakeModel() + model = ScriptedModel() orchestrator_agent.model = model - model.add_multiple_turn_outputs( + model.extend( [ # First turn: tool call [get_function_tool_call("echo_tool", json.dumps({"text": "foo"}), call_id="1")], @@ -6080,7 +6067,7 @@ async def echo_tool(text: str) -> str: @pytest.mark.asyncio async def test_execute_approved_tools_with_non_function_tool(): """Test _execute_approved_tools handles non-FunctionTool.""" - model = FakeModel() + model = ScriptedModel() # Create a computer tool (not a FunctionTool) class MockComputer(Computer): @@ -6401,7 +6388,7 @@ async def billing_lookup() -> str: description="Billing tools", tools=[function_tool(billing_lookup, name_override="lookup_account")], )[0] - agent = Agent(name="TestAgent", model=FakeModel(), tools=[crm_tool, billing_tool]) + agent = Agent(name="TestAgent", model=ScriptedModel(), tools=[crm_tool, billing_tool]) tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-ambiguous") assert isinstance(tool_call, ResponseFunctionToolCall) @@ -6429,7 +6416,7 @@ async def bare_lookup() -> str: return "bare" bare_tool = function_tool(bare_lookup, name_override="lookup_account") - agent = Agent(name="TestAgent", model=FakeModel(), tools=[bare_tool]) + agent = Agent(name="TestAgent", model=ScriptedModel(), tools=[bare_tool]) tool_call = get_function_tool_call( "lookup_account", @@ -6473,7 +6460,7 @@ async def deferred_lookup() -> str: name_override="lookup_account", defer_loading=True, ) - agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool]) + agent = Agent(name="TestAgent", model=ScriptedModel(), tools=[visible_tool, deferred_tool]) tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-visible") assert isinstance(tool_call, ResponseFunctionToolCall) @@ -6516,7 +6503,7 @@ async def deferred_lookup() -> str: name_override="lookup_account", defer_loading=True, ) - agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool]) + agent = Agent(name="TestAgent", model=ScriptedModel(), tools=[visible_tool, deferred_tool]) tool_call = get_function_tool_call( "lookup_account", @@ -6557,7 +6544,7 @@ async def deferred_lookup() -> str: name_override="lookup_account", defer_loading=True, ) - agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool]) + agent = Agent(name="TestAgent", model=ScriptedModel(), tools=[visible_tool, deferred_tool]) tool_call = get_function_tool_call( "lookup_account", @@ -6606,7 +6593,7 @@ async def second_lookup() -> str: first_tool = function_tool(first_lookup, name_override="lookup_account") second_tool = function_tool(second_lookup, name_override="lookup_account") - agent = Agent(name="TestAgent", model=FakeModel(), tools=[first_tool, second_tool]) + agent = Agent(name="TestAgent", model=ScriptedModel(), tools=[first_tool, second_tool]) tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-shadow") assert isinstance(tool_call, ResponseFunctionToolCall) diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 8bbf4ec2f6..d962a1b393 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -46,24 +46,19 @@ handoff, retry_policies, ) -from agents.items import ( - ModelResponse, - RunItem, - ToolApprovalItem, - TResponseInputItem, - TResponseStreamEvent, -) +from agents.items import RunItem, ToolApprovalItem, TResponseInputItem, TResponseStreamEvent from agents.memory.openai_conversations_session import OpenAIConversationsSession -from agents.models.interface import Model, ModelTracing +from agents.models.interface import Model from agents.run import RunConfig from agents.run_internal import run_loop from agents.run_internal.run_loop import QueueCompleteSentinel from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, StreamEvent -from agents.tool import FunctionTool, Tool +from agents.testing import ModelStep, ScriptedModel +from agents.tool import FunctionTool from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail from agents.usage import Usage, _attach_raw_usage_snapshot +from tests.model_test_helpers import get_response_obj -from .fake_model import FakeModel, get_response_obj from .test_responses import ( get_final_output_message, get_function_tool, @@ -138,12 +133,12 @@ def _ws_terminal_response_frame(event_type: str, response_id: str, sequence_numb @pytest.mark.asyncio async def test_simple_first_run(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, ) - model.set_next_output([get_text_message("first")]) + model.enqueue([get_text_message("first")]) result = Runner.run_streamed(agent, input="test") async for _ in result.stream_events(): @@ -158,7 +153,7 @@ async def test_simple_first_run(): assert len(result.to_input_list()) == 2, "should have original input and generated item" - model.set_next_output([get_text_message("second")]) + model.enqueue([get_text_message("second")]) result = Runner.run_streamed( agent, input=[get_text_input_item("message"), get_text_input_item("another_message")] @@ -174,23 +169,23 @@ async def test_simple_first_run(): @pytest.mark.asyncio async def test_empty_list_input_reaches_model(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("first")]) + model.enqueue([get_text_message("first")]) result = Runner.run_streamed(agent, input=[]) async for _ in result.stream_events(): pass assert result.final_output == "first" - assert model.last_turn_args["input"] == [] + assert model.calls[-1].input == [] @pytest.mark.asyncio async def test_streamed_tool_not_found_behavior_returns_error_to_model() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("missing_tool", "{}", call_id="call_missing")], [get_text_message("recovered")], @@ -206,7 +201,7 @@ async def test_streamed_tool_not_found_behavior_returns_error_to_model() -> None pass assert result.final_output == "recovered" - second_turn_input = model.last_turn_args["input"] + second_turn_input = model.calls[-1].input assert isinstance(second_turn_input, list) assert { item.get("call_id"): item.get("output") @@ -226,7 +221,7 @@ async def test_streamed_tool_not_found_behavior_returns_error_to_model() -> None async def test_streamed_run_rejects_failed_terminal_response_payload_events( terminal_event_type: str, terminal_event_cls: type[Any] ) -> None: - class TerminalPayloadFakeModel(FakeModel): + class TerminalPayloadScriptedModel(ScriptedModel): async def stream_response( self, system_instructions, @@ -250,7 +245,7 @@ async def stream_response( "previous_response_id": previous_response_id, "conversation_id": conversation_id, } - if self.first_turn_args is None: + if not self.calls: self.first_turn_args = self.last_turn_args.copy() response = get_response_obj( @@ -262,7 +257,7 @@ async def stream_response( sequence_number=0, ) - model = TerminalPayloadFakeModel() + model = TerminalPayloadScriptedModel() agent = Agent(name="test", model=model) result = Runner.run_streamed(agent, input="test") @@ -281,7 +276,7 @@ async def stream_response( @pytest.mark.asyncio async def test_streamed_run_rejects_response_error_terminal_event() -> None: - class TerminalErrorFakeModel(FakeModel): + class TerminalErrorScriptedModel(ScriptedModel): async def stream_response( self, system_instructions, @@ -305,7 +300,7 @@ async def stream_response( "previous_response_id": previous_response_id, "conversation_id": conversation_id, } - if self.first_turn_args is None: + if not self.calls: self.first_turn_args = self.last_turn_args.copy() yield ResponseErrorEvent( @@ -316,7 +311,7 @@ async def stream_response( sequence_number=0, ) - model = TerminalErrorFakeModel() + model = TerminalErrorScriptedModel() agent = Agent(name="test", model=model) result = Runner.run_streamed(agent, input="test") @@ -340,7 +335,7 @@ async def stream_response( async def test_streamed_run_exposes_request_id_on_raw_responses( preserve_raw_usage: bool | None, ) -> None: - class RequestIdTerminalFakeModel(FakeModel): + class RequestIdTerminalScriptedModel(ScriptedModel): async def stream_response( self, system_instructions, @@ -369,7 +364,7 @@ async def stream_response( sequence_number=0, ) - model = RequestIdTerminalFakeModel() + model = RequestIdTerminalScriptedModel() agent = Agent( name="test", model=model, @@ -394,8 +389,8 @@ async def stream_response( @pytest.mark.asyncio async def test_streamed_run_preserves_request_usage_entries_after_retry() -> None: - model = FakeModel() - model.set_hardcoded_usage( + model = ScriptedModel() + model.set_default_usage( Usage( requests=1, input_tokens=10, @@ -403,7 +398,7 @@ async def test_streamed_run_preserves_request_usage_entries_after_retry() -> Non total_tokens=15, ) ) - model.add_multiple_turn_outputs( + model.extend( [ APIConnectionError( message="connection error", @@ -436,58 +431,16 @@ async def test_streamed_run_preserves_request_usage_entries_after_retry() -> Non assert usage.request_usage_entries[1].total_tokens == 15 -class _RetryThenMissingUsageModel(Model): - """Stream a successful retry whose terminal Response omits usage data.""" - - def __init__(self) -> None: - self.calls = 0 +@pytest.mark.asyncio +async def test_streamed_run_counts_retry_attempts_when_terminal_usage_missing() -> None: + """Retry accounting must survive successful streams that omit Response.usage. - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: Any, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> ModelResponse: - self.calls += 1 - if self.calls == 1: - raise APIConnectionError( - message="connection error", - request=httpx.Request("POST", "https://example.com"), - ) - return ModelResponse( - output=[get_text_message("done")], - usage=Usage(requests=1), - response_id="resp-missing-usage", - ) + Non-OpenAI chat-completions adapters (e.g. LiteLLM) can complete a stream without a usage + chunk, leaving ``Response.usage`` as ``None``. Failed retry attempts must still be counted, + matching the non-streaming ``apply_retry_attempt_usage`` path. + """ - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: Any, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> AsyncIterator[TResponseStreamEvent]: - self.calls += 1 - if self.calls == 1: - raise APIConnectionError( - message="connection error", - request=httpx.Request("POST", "https://example.com"), - ) + async def missing_usage_stream(_call) -> AsyncIterator[TResponseStreamEvent]: response = get_response_obj([get_text_message("done")]) response.usage = None yield ResponseCompletedEvent( @@ -496,17 +449,15 @@ async def stream_response( sequence_number=0, ) - -@pytest.mark.asyncio -async def test_streamed_run_counts_retry_attempts_when_terminal_usage_missing() -> None: - """Retry accounting must survive successful streams that omit Response.usage. - - Non-OpenAI chat-completions adapters (e.g. LiteLLM) can complete a stream without a usage - chunk, leaving ``Response.usage`` as ``None``. Failed retry attempts must still be counted, - matching the non-streaming ``apply_retry_attempt_usage`` path. - """ - - model = _RetryThenMissingUsageModel() + model = ScriptedModel( + [ + APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ), + ModelStep.stream(missing_usage_stream), + ] + ) agent = Agent( name="test", model=model, @@ -523,7 +474,7 @@ async def test_streamed_run_counts_retry_attempts_when_terminal_usage_missing() pass usage = result.context_wrapper.usage - assert model.calls == 2 + assert len(model.calls) == 2 assert usage.requests == 2 assert len(usage.request_usage_entries) == 2 assert usage.request_usage_entries[0].total_tokens == 0 @@ -532,8 +483,8 @@ async def test_streamed_run_counts_retry_attempts_when_terminal_usage_missing() @pytest.mark.asyncio async def test_streamed_model_retry_does_not_rewind_committed_session_input() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ APIConnectionError( message="connection error", @@ -567,8 +518,8 @@ async def test_streamed_model_retry_does_not_rewind_committed_session_input() -> async def test_streamed_run_preserves_request_usage_entries_after_conversation_locked_retry() -> ( None ): - model = FakeModel() - model.set_hardcoded_usage( + model = ScriptedModel() + model.set_default_usage( Usage( requests=1, input_tokens=10, @@ -576,7 +527,7 @@ async def test_streamed_run_preserves_request_usage_entries_after_conversation_l total_tokens=15, ) ) - model.add_multiple_turn_outputs( + model.extend( [ _conversation_locked_error(), [get_text_message("done")], @@ -829,12 +780,12 @@ async def stream_response(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any] @pytest.mark.asyncio async def test_subsequent_runs(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, ) - model.set_next_output([get_text_message("third")]) + model.enqueue([get_text_message("third")]) result = Runner.run_streamed(agent, input="test") async for _ in result.stream_events(): @@ -844,7 +795,7 @@ async def test_subsequent_runs(): assert len(result.new_items) == 1, "exactly one item should be generated" assert len(result.to_input_list()) == 2, "should have original input and generated item" - model.set_next_output([get_text_message("fourth")]) + model.enqueue([get_text_message("fourth")]) result = Runner.run_streamed(agent, input=result.to_input_list()) async for _ in result.stream_events(): @@ -861,14 +812,14 @@ async def test_subsequent_runs(): @pytest.mark.asyncio async def test_tool_call_runs(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], @@ -901,7 +852,7 @@ async def _ok_tool() -> str: async def _cancel_tool() -> str: raise asyncio.CancelledError("tool-cancelled") - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, @@ -911,7 +862,7 @@ async def _cancel_tool() -> str: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("ok_tool", "{}", call_id="call_ok"), @@ -927,7 +878,7 @@ async def _cancel_tool() -> str: assert result.final_output == "final answer" assert len(result.raw_responses) == 2 - second_turn_input = cast(list[dict[str, Any]], model.last_turn_args["input"]) + second_turn_input = cast(list[dict[str, Any]], model.calls[-1].input) tool_outputs = [ item for item in second_turn_input if item.get("type") == "function_call_output" ] @@ -948,14 +899,14 @@ async def test_streamed_single_tool_call_with_cancelled_tool_reaches_final_outpu async def _cancel_tool() -> str: raise asyncio.CancelledError("tool-cancelled") - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[function_tool(_cancel_tool, name_override="cancel_tool")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("cancel_tool", "{}", call_id="call_cancel")], [get_text_message("final answer")], @@ -968,7 +919,7 @@ async def _cancel_tool() -> str: assert result.final_output == "final answer" assert len(result.raw_responses) == 2 - second_turn_input = cast(list[dict[str, Any]], model.last_turn_args["input"]) + second_turn_input = cast(list[dict[str, Any]], model.calls[-1].input) tool_outputs = [ item for item in second_turn_input if item.get("type") == "function_call_output" ] @@ -985,14 +936,14 @@ async def _cancel_tool() -> str: @pytest.mark.asyncio async def test_streamed_reasoning_item_id_policy_omits_follow_up_reasoning_ids() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -1015,7 +966,7 @@ async def test_streamed_reasoning_item_id_policy_omits_follow_up_reasoning_ids() pass assert result.final_output == "done" - second_request_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + second_request_reasoning = _find_reasoning_input_item(model.calls[-1].input) assert second_request_reasoning is not None assert "id" not in second_request_reasoning @@ -1024,8 +975,8 @@ async def test_streamed_reasoning_item_id_policy_omits_follow_up_reasoning_ids() assert "id" not in history_reasoning -class _StreamedRevokedReasoningIdModel(FakeModel): - """FakeModel that 404s like the Responses API when a revoked reasoning ID is replayed.""" +class _StreamedRevokedReasoningIdModel(ScriptedModel): + """ScriptedModel that 404s like the Responses API when a revoked reasoning ID is replayed.""" def __init__(self) -> None: super().__init__() @@ -1071,7 +1022,7 @@ async def test_streamed_omit_policy_strips_reasoning_ids_already_stored_in_the_s session = SQLiteSession("issue-2020-streamed") # Turn 1 predates the mitigation, so the session records the reasoning ID. - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem(id="rs_triage", type="reasoning", summary=[]), @@ -1092,7 +1043,7 @@ async def test_streamed_omit_policy_strips_reasoning_ids_already_stored_in_the_s model.revoked_reasoning_ids.add("rs_triage") # Turn 2 opts into the documented mitigation for this failure. - model.add_multiple_turn_outputs([[get_text_message("done")]]) + model.extend([[get_text_message("done")]]) second = Runner.run_streamed( triage, input="anything else?", @@ -1103,14 +1054,14 @@ async def test_streamed_omit_policy_strips_reasoning_ids_already_stored_in_the_s pass assert second.final_output == "done" - replayed_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input")) + replayed_reasoning = _find_reasoning_input_item(model.calls[-1].input) assert replayed_reasoning is not None assert "id" not in replayed_reasoning @pytest.mark.asyncio async def test_streamed_run_again_persists_tool_items_to_session(): - model = FakeModel() + model = ScriptedModel() call_id = "call-session-run-again" agent = Agent( name="test", @@ -1119,7 +1070,7 @@ async def test_streamed_run_again_persists_tool_items_to_session(): ) session = SimpleListSession() - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("foo", json.dumps({"a": "b"}), call_id=call_id)], [get_text_message("done")], @@ -1146,7 +1097,7 @@ async def test_streamed_run_again_persists_tool_items_to_session(): @pytest.mark.asyncio async def test_handoffs(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1162,7 +1113,7 @@ async def test_handoffs(): tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -1192,7 +1143,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_structured_output(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1207,7 +1158,7 @@ async def test_structured_output(): handoffs=[agent_1], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [ @@ -1270,7 +1221,7 @@ def remove_new_items(handoff_input_data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_handoff_filters(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1286,7 +1237,7 @@ async def test_handoff_filters(): ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -1306,7 +1257,7 @@ async def test_handoff_filters(): @pytest.mark.asyncio async def test_streamed_nested_handoff_filters_reasoning_items_from_model_input(): - model = FakeModel() + model = ScriptedModel() delegate = Agent( name="delegate", model=model, @@ -1317,7 +1268,7 @@ async def test_streamed_nested_handoff_filters_reasoning_items_from_model_input( handoffs=[delegate], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -1363,7 +1314,7 @@ def capture_model_input(data): async def test_async_input_filter_supported(): # DO NOT rename this without updating pyproject.toml - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1390,7 +1341,7 @@ async def async_input_filter(data: HandoffInputData) -> HandoffInputData: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -1404,7 +1355,7 @@ async def async_input_filter(data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_invalid_input_filter_fails(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1432,7 +1383,7 @@ def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -1447,7 +1398,7 @@ def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_non_callable_input_filter_causes_error(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1472,7 +1423,7 @@ async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[ ], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], [get_text_message("last")], @@ -1493,7 +1444,7 @@ def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: nonlocal call_output call_output = data["bar"] - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1511,7 +1462,7 @@ def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -1539,7 +1490,7 @@ async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: nonlocal call_output call_output = data["bar"] - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1557,7 +1508,7 @@ async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -1590,7 +1541,7 @@ def guardrail_function( agent = Agent( name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)], - model=FakeModel(), + model=ScriptedModel([[]]), ) with pytest.raises(InputGuardrailTripwireTriggered): @@ -1633,7 +1584,7 @@ async def fail_finalizer(_result: Any) -> bool: agent = Agent( name=agent_name, input_guardrails=[InputGuardrail(guardrail_function=safe_guardrail)], - model=FakeModel(initial_output=[get_text_message("done")]), + model=ScriptedModel(steps=[[get_text_message("done")]]), ) with caplog.at_level(logging.DEBUG, logger="openai.agents"): @@ -1675,8 +1626,8 @@ async def guardrail_function( session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_text_message("should_not_be_saved")]) + model = ScriptedModel() + model.enqueue([get_text_message("should_not_be_saved")]) agent = Agent( name="test", @@ -1706,8 +1657,8 @@ def guardrail_function( session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_text_message("should_not_be_saved")]) + model = ScriptedModel() + model.enqueue([get_text_message("should_not_be_saved")]) agent = Agent( name="test", @@ -1740,8 +1691,8 @@ async def guardrail_function( session = SimpleListSession() - model = FakeModel() - model.set_next_output([get_text_message("should_not_be_saved")]) + model = ScriptedModel() + model.enqueue([get_text_message("should_not_be_saved")]) agent = Agent( name="test", @@ -1793,8 +1744,8 @@ async def clear_session(self) -> None: session = DummyOpenAIConversationsSession() - model = FakeModel() - model.set_next_output([get_text_message("ok")]) + model = ScriptedModel() + model.enqueue([get_text_message("ok")]) agent = Agent( name="test", @@ -1830,8 +1781,8 @@ async def clear_session(self) -> None: @pytest.mark.asyncio async def test_stream_input_persistence_saves_only_new_turn_input(monkeypatch: pytest.MonkeyPatch): session = SimpleListSession() - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first")], [get_text_message("second")], @@ -1896,9 +1847,9 @@ async def guardrail_function( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() # Ensure the model finishes streaming quickly. - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) agent = Agent( name="test", @@ -1923,7 +1874,7 @@ def guardrail_function( tripwire_triggered=True, ) - model = FakeModel(initial_output=[get_text_message("first_test")]) + model = ScriptedModel(steps=[[get_text_message("first_test")]]) agent = Agent( name="test", @@ -1947,7 +1898,7 @@ def guardrail_function( tripwire_triggered=True, ) - model = FakeModel(initial_output=[get_text_message("first_test")]) + model = ScriptedModel(steps=[[get_text_message("first_test")]]) agent = Agent( name="test", @@ -1972,7 +1923,7 @@ def guardrail_function( ) -> GuardrailFunctionOutput: raise RuntimeError("guardrail failed") - model = FakeModel(initial_output=[get_text_message("first_test")]) + model = ScriptedModel(steps=[[get_text_message("first_test")]]) agent = Agent( name="test", @@ -2002,7 +1953,7 @@ def guardrail_function( agent = Agent( name="test", - model=FakeModel(), + model=ScriptedModel([[]]), ) with pytest.raises(InputGuardrailTripwireTriggered): @@ -2027,7 +1978,7 @@ def guardrail_function( tripwire_triggered=True, ) - model = FakeModel(initial_output=[get_text_message("first_test")]) + model = ScriptedModel(steps=[[get_text_message("first_test")]]) agent = Agent( name="test", @@ -2048,7 +1999,7 @@ def guardrail_function( @pytest.mark.asyncio async def test_streaming_events(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -2063,7 +2014,7 @@ async def test_streaming_events(): handoffs=[agent_1], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [ @@ -2154,7 +2105,7 @@ async def test_streaming_events(): @pytest.mark.asyncio async def test_dynamic_tool_addition_run_streamed() -> None: - model = FakeModel() + model = ScriptedModel() executed: dict[str, bool] = {"called": False} @@ -2172,7 +2123,7 @@ async def add_tool() -> str: agent.tools.append(add_tool) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("add_tool", json.dumps({}), call_id="call-add-tool")], [get_function_tool_call("tool2", json.dumps({}), call_id="call-tool-two")], @@ -2308,8 +2259,8 @@ def output_guardrail( tripwire_triggered=guardrail_state["tripwire"], ) - model = FakeModel() - model.set_next_output([get_function_tool_call("approval_tool", "{}", call_id="call-approved")]) + model = ScriptedModel() + model.enqueue([get_function_tool_call("approval_tool", "{}", call_id="call-approved")]) agent = Agent( name="test", model=model, @@ -2356,11 +2307,11 @@ async def run_once(input_value: Any) -> Any: if tripwire: guardrail_state["tripwire"] = False - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) next_result = await run_once("Continue") assert next_result.final_output == "done" - model_input = model.last_turn_args["input"] + model_input = model.calls[-1].input assert isinstance(model_input, list) replayed_tool_items = [ item @@ -2396,8 +2347,8 @@ def output_guardrail( ) -> GuardrailFunctionOutput: return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) - model = FakeModel() - model.set_next_output([get_function_tool_call("commit_tool", "{}", call_id="call-committed")]) + model = ScriptedModel() + model.enqueue([get_function_tool_call("commit_tool", "{}", call_id="call-committed")]) agent = Agent( name="test", model=model, @@ -2430,7 +2381,7 @@ def output_guardrail( # The next run must see the completed call instead of re-issuing the same side effect. agent.output_guardrails = [] - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) if mode == "non_streamed": followup: Any = await Runner.run(agent, "Continue", session=session) else: @@ -2439,7 +2390,7 @@ def output_guardrail( assert followup.final_output == "done" assert calls == ["ran"] - model_input = model.last_turn_args["input"] + model_input = model.calls[-1].input assert isinstance(model_input, list) replayed = [ (item.get("type"), item.get("call_id")) @@ -2464,8 +2415,8 @@ def output_guardrail( ) -> GuardrailFunctionOutput: return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) - model = FakeModel() - model.set_next_output([get_text_message("should_not_be_saved")]) + model = ScriptedModel() + model.enqueue([get_text_message("should_not_be_saved")]) agent = Agent( name="test", model=model, @@ -2501,8 +2452,8 @@ def output_guardrail( ) -> GuardrailFunctionOutput: return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("commit_tool", "{}", call_id="call-mixed")], [get_text_message("should_not_be_saved")], @@ -2553,9 +2504,9 @@ def output_guardrail( ) -> GuardrailFunctionOutput: return GuardrailFunctionOutput(output_info=None, tripwire_triggered=tripwire) - model = FakeModel() + model = ScriptedModel() # The message precedes the tool call, so a split save would reorder the persisted turn. - model.set_next_output( + model.enqueue( [ get_text_message("assistant-preamble"), get_function_tool_call("commit_tool", "{}", call_id="call-mixed"), @@ -2616,8 +2567,8 @@ def output_guardrail( ) -> GuardrailFunctionOutput: raise RuntimeError("guardrail failed") - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_text_message("assistant-preamble"), get_function_tool_call("commit_tool", "{}", call_id="call-mixed"), @@ -2669,8 +2620,8 @@ def output_guardrail( guardrail_failed = True raise RuntimeError("guardrail failed") - model = FakeModel() - model.set_next_output([get_text_message("assistant-preamble")]) + model = ScriptedModel() + model.enqueue([get_text_message("assistant-preamble")]) agent = Agent( name="test", model=model, @@ -2708,7 +2659,7 @@ def output_guardrail( guardrail_failed = True raise RuntimeError("guardrail failed") - model = FakeModel(initial_output=[get_text_message("assistant-preamble")]) + model = ScriptedModel(steps=[[get_text_message("assistant-preamble")]]) agent = Agent( name="test", model=model, @@ -2751,7 +2702,7 @@ def output_guardrail( agent = Agent( name="test", - model=FakeModel(initial_output=[get_text_message("assistant-preamble")]), + model=ScriptedModel(steps=[[get_text_message("assistant-preamble")]]), output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], ) session = AbortingFinalTurnSession() @@ -2792,7 +2743,7 @@ def output_guardrail( guardrail_failed = True raise RuntimeError("guardrail failed") - model = FakeModel(initial_output=[get_text_message("assistant-preamble")]) + model = ScriptedModel(steps=[[get_text_message("assistant-preamble")]]) agent = Agent( name="test", model=model, @@ -2843,8 +2794,8 @@ def output_guardrail( ) -> GuardrailFunctionOutput: return GuardrailFunctionOutput(output_info=None, tripwire_triggered=tripwire) - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ ResponseReasoningItem( id="rs_committed", @@ -2882,11 +2833,11 @@ async def run_once(input_value: Any) -> Any: # The reasoning/call/output group has to reach the next request in that order. agent.output_guardrails = [] - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) followup = await run_once("Continue") assert followup.final_output == "done" - model_input = model.last_turn_args["input"] + model_input = model.calls[-1].input assert isinstance(model_input, list) replayed = [ item.get("type") @@ -2922,8 +2873,8 @@ def output_guardrail( ) -> GuardrailFunctionOutput: return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ ResponseReasoningItem( id="rs_rejected", @@ -2972,11 +2923,11 @@ async def run_once(input_value: Any) -> Any: # ...and the surviving group still replays in order, with no dangling reasoning item. agent.output_guardrails = [] - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) followup = await run_once("Continue") assert followup.final_output == "done" - model_input = model.last_turn_args["input"] + model_input = model.calls[-1].input assert isinstance(model_input, list) replayed = [ item.get("type") @@ -2989,7 +2940,7 @@ async def run_once(input_value: Any) -> Any: @pytest.mark.asyncio async def test_streaming_resume_preserves_filtered_model_input_after_handoff(): - model = FakeModel() + model = ScriptedModel() @function_tool(name_override="approval_tool", needs_approval=True) def approval_tool() -> str: @@ -3007,7 +2958,7 @@ def approval_tool() -> str: tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call( @@ -3221,7 +3172,7 @@ async def test_tool() -> str: @pytest.mark.asyncio async def test_streaming_non_max_turns_exception_does_not_emit_queued_events() -> None: model, agent = make_model_and_agent(name="test") - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) result = Runner.run_streamed(agent, input="hello") result.cancel() @@ -3248,7 +3199,7 @@ async def test_streaming_hitl_server_conversation_tracker_priming(): model, agent = make_model_and_agent(name="test") # First run with conversation_id - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = Runner.run_streamed( agent, input="test", conversation_id="conv123", previous_response_id="resp123" ) @@ -3258,7 +3209,7 @@ async def test_streaming_hitl_server_conversation_tracker_priming(): state = result1.to_state() # Resume with same conversation_id - should not duplicate messages - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = Runner.run_streamed( agent, state, conversation_id="conv123", previous_response_id="resp123" ) @@ -3295,7 +3246,7 @@ def guarded_tool() -> str: async def test_streamed_run_reports_tool_guardrail_results(): """Streamed runs must expose tool guardrail results like non-streamed runs do.""" model, agent = make_model_and_agent(tools=[_tool_with_guardrails()]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("guarded_tool", "{}", call_id="call_1")], [get_text_message("done")], @@ -3316,9 +3267,9 @@ async def test_streamed_run_reports_tool_guardrail_results(): async def test_streamed_tool_guardrail_results_match_non_streamed(): """The same run reports the same tool guardrail results in both execution modes.""" - def _build() -> tuple[FakeModel, Agent[Any]]: + def _build() -> tuple[ScriptedModel, Agent[Any]]: model, agent = make_model_and_agent(tools=[_tool_with_guardrails()]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("guarded_tool", "{}", call_id="call_1")], [get_function_tool_call("guarded_tool", "{}", call_id="call_2")], @@ -3347,7 +3298,7 @@ def _build() -> tuple[FakeModel, Agent[Any]]: @pytest.mark.asyncio async def test_streamed_tool_guardrail_results_survive_handoff(): """Tool guardrail results from a handoff turn reach the streamed result.""" - model = FakeModel() + model = ScriptedModel() target = Agent(name="target", model=model) agent = Agent( name="source", @@ -3355,7 +3306,7 @@ async def test_streamed_tool_guardrail_results_survive_handoff(): tools=[_tool_with_guardrails()], handoffs=[target], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("guarded_tool", "{}", call_id="call_1"), @@ -3390,7 +3341,7 @@ def approval_tool() -> str: return "approved-result" model, agent = make_model_and_agent(tools=[plain_tool, approval_tool]) - model.set_next_output( + model.enqueue( [ get_function_tool_call("plain_tool", "{}", call_id="call_plain"), get_function_tool_call("approval_tool", "{}", call_id="call_approval"), @@ -3409,7 +3360,7 @@ def approval_tool() -> str: async def test_streamed_tool_guardrail_results_persist_into_run_state(): """Tool guardrail results from a streamed run round-trip through RunState.""" model, agent = make_model_and_agent(tools=[_tool_with_guardrails()]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("guarded_tool", "{}", call_id="call_1")], [get_text_message("done")], @@ -3433,7 +3384,7 @@ async def test_streamed_resume_tool_guardrail_results_match_non_streamed(): to a specific count. """ - def _build() -> tuple[FakeModel, Agent[Any]]: + def _build() -> tuple[ScriptedModel, Agent[Any]]: @tool_input_guardrail def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: return ToolGuardrailFunctionOutput.allow(output_info="input-checked") @@ -3452,7 +3403,7 @@ def approval_tool() -> str: return "approved-result" model, agent = make_model_and_agent(tools=[approval_tool]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call_approval")], [get_text_message("done")], @@ -3492,7 +3443,7 @@ async def test_streamed_resume_terminal_turn_reports_tool_guardrail_results(): regular turn loop. """ - def _build() -> tuple[FakeModel, Agent[Any]]: + def _build() -> tuple[ScriptedModel, Agent[Any]]: @tool_input_guardrail def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: return ToolGuardrailFunctionOutput.allow(output_info="input-checked") @@ -3510,16 +3461,14 @@ def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput def approval_tool() -> str: return "approved-result" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="TestAgent", model=model, tools=[approval_tool], tool_use_behavior="stop_on_first_tool", ) - model.set_next_output( - [get_function_tool_call("approval_tool", "{}", call_id="call_approval")] - ) + model.enqueue([get_function_tool_call("approval_tool", "{}", call_id="call_approval")]) return model, agent _, streamed_agent = _build() @@ -3553,7 +3502,7 @@ def approval_tool() -> str: async def test_streamed_resume_handoff_turn_reports_tool_guardrail_results(): """A resumed streamed turn that hands off keeps the guardrail results it produced.""" - def _build() -> tuple[FakeModel, Agent[Any]]: + def _build() -> tuple[ScriptedModel, Agent[Any]]: @tool_input_guardrail def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: return ToolGuardrailFunctionOutput.allow(output_info="input-checked") @@ -3571,7 +3520,7 @@ def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput def approval_tool() -> str: return "approved-result" - model = FakeModel() + model = ScriptedModel() target = Agent(name="target", model=model) agent = Agent( name="TestAgent", @@ -3579,7 +3528,7 @@ def approval_tool() -> str: tools=[approval_tool], handoffs=[target], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("approval_tool", "{}", call_id="call_approval"), @@ -3656,8 +3605,8 @@ def commit_tool() -> str: tool_call_count += 1 return "committed-result" - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_text_message("assistant-preamble"), get_function_tool_call("commit_tool", "{}", call_id="call-cancel"), diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py index 2a629926a8..c094439947 100644 --- a/tests/test_agent_tracing.py +++ b/tests/test_agent_tracing.py @@ -9,9 +9,9 @@ from agents import Agent, RunConfig, Runner, RunState, custom_span, function_tool, trace from agents.sandbox.runtime import SandboxRuntime +from agents.testing import ScriptedModel from agents.usage import Usage -from .fake_model import FakeModel from .test_responses import get_function_tool_call, get_text_message from .testing_processor import ( assert_no_traces, @@ -22,7 +22,7 @@ ) -def _make_approval_agent(model: FakeModel) -> Agent[None]: +def _make_approval_agent(model: ScriptedModel) -> Agent[None]: @function_tool(name_override="approval_tool", needs_approval=True) def approval_tool() -> str: return "ok" @@ -43,8 +43,8 @@ def _usage_metadata(requests: int, input_tokens: int, output_tokens: int) -> dic async def test_single_run_is_single_trace(): agent = Agent( name="test_agent", - model=FakeModel( - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + steps=[[get_text_message("first_test")]], ), ) @@ -77,7 +77,7 @@ async def test_agent_span_uses_resolved_tool_name_collision_view( surface: str, streamed: bool, ) -> None: - model = FakeModel(initial_output=[get_text_message("done")]) + model = ScriptedModel(steps=[[get_text_message("done")]]) expected_tools: list[str] expected_handoffs: list[str] @@ -136,14 +136,14 @@ async def test_task_and_turn_spans_export_aggregate_usage(): def foo_tool() -> str: return "foo result" - model = FakeModel(tracing_enabled=True) - model.add_multiple_turn_outputs( + model = ScriptedModel(emit_traces=True) + model.extend( [ [get_function_tool_call("foo_tool", "{}", call_id="call-1")], [get_text_message("done")], ] ) - model.set_hardcoded_usage( + model.set_default_usage( Usage( requests=1, input_tokens=10, @@ -259,8 +259,8 @@ async def test_task_and_turn_spans_can_be_disabled(): def foo_tool() -> str: return "foo result" - model = FakeModel(tracing_enabled=True) - model.add_multiple_turn_outputs( + model = ScriptedModel(emit_traces=True) + model.extend( [ [get_function_tool_call("foo_tool", "{}", call_id="call-1")], [get_text_message("done")], @@ -296,9 +296,9 @@ def foo_tool() -> str: async def test_task_and_turn_spans_can_be_explicitly_enabled(): agent = Agent( name="test_agent", - model=FakeModel( - tracing_enabled=True, - initial_output=[get_text_message("done")], + model=ScriptedModel( + emit_traces=True, + steps=[[get_text_message("done")]], ), ) @@ -317,9 +317,9 @@ async def test_task_and_turn_spans_can_be_explicitly_enabled(): async def test_task_span_resets_current_span_if_run_setup_fails(monkeypatch: pytest.MonkeyPatch): agent = Agent( name="test_agent", - model=FakeModel( - tracing_enabled=True, - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + emit_traces=True, + steps=[[get_text_message("first_test")]], ), ) @@ -347,8 +347,8 @@ def raise_setup_error(self: SandboxRuntime[None], agent: Agent[None]) -> None: @pytest.mark.asyncio async def test_multiple_runs_are_multiple_traces(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first_test")], [get_text_message("second_test")], @@ -398,8 +398,8 @@ async def test_multiple_runs_are_multiple_traces(): @pytest.mark.asyncio async def test_resumed_run_reuses_original_trace_without_duplicate_trace_start(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call-1")], [get_text_message("done")], @@ -425,14 +425,14 @@ async def test_resumed_run_reuses_original_trace_without_duplicate_trace_start() @pytest.mark.asyncio async def test_resumed_run_task_span_usage_is_run_local_delta(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call-1")], [get_text_message("done")], ] ) - model.set_hardcoded_usage(Usage(requests=1, input_tokens=10, output_tokens=3, total_tokens=13)) + model.set_default_usage(Usage(requests=1, input_tokens=10, output_tokens=3, total_tokens=13)) agent = _make_approval_agent(model) first = await Runner.run(agent, input="first_test") @@ -461,8 +461,8 @@ async def test_resumed_run_task_span_usage_is_run_local_delta(): @pytest.mark.asyncio async def test_resumed_run_from_serialized_state_reuses_original_trace(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call-1")], [get_text_message("done")], @@ -490,8 +490,8 @@ async def test_resumed_run_from_serialized_state_reuses_original_trace(): @pytest.mark.asyncio async def test_resumed_run_from_serialized_state_preserves_explicit_trace_key(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call-1")], [get_text_message("done")], @@ -530,8 +530,8 @@ async def test_resumed_run_from_serialized_state_preserves_explicit_trace_key(): @pytest.mark.asyncio async def test_resumed_run_with_workflow_override_starts_new_trace() -> None: trace_id = f"trace_{uuid4().hex}" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call-1")], [get_text_message("done")], @@ -570,8 +570,8 @@ async def test_resumed_run_with_workflow_override_starts_new_trace() -> None: @pytest.mark.asyncio async def test_wrapped_trace_is_single_trace(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first_test")], [get_text_message("second_test")], @@ -631,8 +631,8 @@ async def test_parent_disabled_trace_disabled_agent_trace(): with trace(workflow_name="test_workflow", disabled=True): agent = Agent( name="test_agent", - model=FakeModel( - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + steps=[[get_text_message("first_test")]], ), ) @@ -645,8 +645,8 @@ async def test_parent_disabled_trace_disabled_agent_trace(): async def test_manual_disabling_works(): agent = Agent( name="test_agent", - model=FakeModel( - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + steps=[[get_text_message("first_test")]], ), ) @@ -659,8 +659,8 @@ async def test_manual_disabling_works(): async def test_trace_config_works(): agent = Agent( name="test_agent", - model=FakeModel( - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + steps=[[get_text_message("first_test")]], ), ) @@ -696,8 +696,8 @@ async def test_trace_config_works(): async def test_not_starting_streaming_creates_trace(): agent = Agent( name="test_agent", - model=FakeModel( - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + steps=[[get_text_message("first_test")]], ), ) @@ -737,8 +737,8 @@ async def test_not_starting_streaming_creates_trace(): async def test_streaming_single_run_is_single_trace(): agent = Agent( name="test_agent", - model=FakeModel( - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + steps=[[get_text_message("first_test")]], ), ) @@ -768,8 +768,8 @@ async def test_streaming_single_run_is_single_trace(): @pytest.mark.asyncio async def test_multiple_streamed_runs_are_multiple_traces(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first_test")], [get_text_message("second_test")], @@ -824,8 +824,8 @@ async def test_multiple_streamed_runs_are_multiple_traces(): @pytest.mark.asyncio async def test_resumed_streaming_run_reuses_original_trace_without_duplicate_trace_start(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call-1")], [get_text_message("done")], @@ -855,14 +855,14 @@ async def test_resumed_streaming_run_reuses_original_trace_without_duplicate_tra @pytest.mark.asyncio async def test_resumed_streaming_run_task_span_usage_is_run_local_delta(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="call-1")], [get_text_message("done")], ] ) - model.set_hardcoded_usage(Usage(requests=1, input_tokens=11, output_tokens=4, total_tokens=15)) + model.set_default_usage(Usage(requests=1, input_tokens=11, output_tokens=4, total_tokens=15)) agent = _make_approval_agent(model) first = Runner.run_streamed(agent, input="first_test") @@ -895,8 +895,8 @@ async def test_resumed_streaming_run_task_span_usage_is_run_local_delta(): @pytest.mark.asyncio async def test_wrapped_streaming_trace_is_single_trace(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first_test")], [get_text_message("second_test")], @@ -963,9 +963,9 @@ async def test_wrapped_streaming_trace_is_single_trace(): async def test_wrapped_streaming_run_creates_root_task_span(): agent = Agent( name="test_agent", - model=FakeModel( - tracing_enabled=True, - initial_output=[get_text_message("first_test")], + model=ScriptedModel( + emit_traces=True, + steps=[[get_text_message("first_test")]], ), ) @@ -997,7 +997,7 @@ async def test_wrapped_run_task_span_uses_run_workflow_name(): def _make_agent() -> Agent[None]: return Agent( name="test_agent", - model=FakeModel(initial_output=[get_text_message("first_test")]), + model=ScriptedModel(steps=[[get_text_message("first_test")]]), ) run_config = RunConfig(workflow_name="inner_workflow") @@ -1021,9 +1021,9 @@ def _make_agent() -> Agent[None]: async def test_wrapped_streaming_run_can_disable_task_and_turn_spans(): agent = Agent( name="test_agent", - model=FakeModel( - tracing_enabled=True, - initial_output=[get_text_message("done")], + model=ScriptedModel( + emit_traces=True, + steps=[[get_text_message("done")]], ), ) @@ -1050,8 +1050,8 @@ async def test_wrapped_streaming_run_can_disable_task_and_turn_spans(): @pytest.mark.asyncio async def test_wrapped_mixed_trace_is_single_trace(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first_test")], [get_text_message("second_test")], @@ -1114,8 +1114,8 @@ async def test_wrapped_mixed_trace_is_single_trace(): @pytest.mark.asyncio async def test_parent_disabled_trace_disables_streaming_agent_trace(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first_test")], [get_text_message("second_test")], @@ -1136,8 +1136,8 @@ async def test_parent_disabled_trace_disables_streaming_agent_trace(): @pytest.mark.asyncio async def test_manual_streaming_disabling_works(): - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("first_test")], [get_text_message("second_test")], diff --git a/tests/test_call_model_input_filter.py b/tests/test_call_model_input_filter.py index 3ae86206a5..beff1d2a98 100644 --- a/tests/test_call_model_input_filter.py +++ b/tests/test_call_model_input_filter.py @@ -6,8 +6,8 @@ from agents import Agent, RunConfig, Runner, TResponseInputItem, UserError from agents.run import CallModelData, ModelInputData +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_text_input_item, get_text_message from .testing_processor import fetch_span_errors @@ -16,11 +16,11 @@ @pytest.mark.asyncio async def test_call_model_input_filter_sync_non_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Prepare model output - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) def filter_fn(data: CallModelData[Any]) -> ModelInputData: mi = data.model_data @@ -33,19 +33,19 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: run_config=RunConfig(call_model_input_filter=filter_fn), ) - assert model.last_turn_args["system_instructions"] == "filtered-sync" - assert isinstance(model.last_turn_args["input"], list) - assert len(model.last_turn_args["input"]) == 2 - assert model.last_turn_args["input"][-1]["content"] == "added-sync" + assert model.calls[-1].system_instructions == "filtered-sync" + assert isinstance(model.calls[-1].input, list) + assert len(model.calls[-1].input) == 2 + assert model.calls[-1].input[-1]["content"] == "added-sync" @pytest.mark.asyncio async def test_call_model_input_filter_async_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) # Prepare model output - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) async def filter_fn(data: CallModelData[Any]) -> ModelInputData: mi = data.model_data @@ -60,15 +60,15 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: async for _ in result.stream_events(): pass - assert model.last_turn_args["system_instructions"] == "filtered-async" - assert isinstance(model.last_turn_args["input"], list) - assert len(model.last_turn_args["input"]) == 2 - assert model.last_turn_args["input"][-1]["content"] == "added-async" + assert model.calls[-1].system_instructions == "filtered-async" + assert isinstance(model.calls[-1].input, list) + assert len(model.calls[-1].input) == 2 + assert model.calls[-1].input[-1]["content"] == "added-async" @pytest.mark.asyncio async def test_call_model_input_filter_invalid_return_type_raises() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) def invalid_filter(_data: CallModelData[Any]): @@ -99,7 +99,7 @@ def filter_fn(_data: CallModelData[Any]) -> ModelInputData: with pytest.raises(ValueError, match=SENSITIVE_ERROR_MESSAGE): await Runner.run( - Agent(name="test", model=FakeModel(tracing_enabled=False)), + Agent(name="test", model=ScriptedModel(emit_traces=False)), input="start", run_config=RunConfig( call_model_input_filter=filter_fn, @@ -117,9 +117,9 @@ def filter_fn(_data: CallModelData[Any]) -> ModelInputData: @pytest.mark.asyncio async def test_call_model_input_filter_prefers_latest_duplicate_outputs_non_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) duplicate_old = cast( TResponseInputItem, @@ -152,7 +152,7 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: outputs = [ item - for item in model.last_turn_args["input"] + for item in model.calls[-1].input if item.get("type") == "function_call_output" and item.get("call_id") == "dup-call" ] assert len(outputs) == 1 @@ -161,9 +161,9 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: @pytest.mark.asyncio async def test_call_model_input_filter_prefers_latest_duplicate_outputs_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) duplicate_old = cast( TResponseInputItem, @@ -198,7 +198,7 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: outputs = [ item - for item in model.last_turn_args["input"] + for item in model.calls[-1].input if item.get("type") == "function_call_output" and item.get("call_id") == "dup-call-stream" ] assert len(outputs) == 1 @@ -294,9 +294,9 @@ def _sent_item_types(sent_input: Any) -> list[str | None]: @pytest.mark.asyncio async def test_call_model_input_filter_keeps_duplicate_item_order_non_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) def filter_fn(data: CallModelData[Any]) -> ModelInputData: return ModelInputData( @@ -312,7 +312,7 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: # Collapsing the repeated call must not move it behind its output; the Responses API # rejects a function_call_output whose function_call has not been sent yet. - assert _sent_item_types(model.last_turn_args["input"]) == [ + assert _sent_item_types(model.calls[-1].input) == [ "function_call", "function_call_output", ] @@ -320,9 +320,9 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: @pytest.mark.asyncio async def test_call_model_input_filter_keeps_duplicate_item_order_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) async def filter_fn(data: CallModelData[Any]) -> ModelInputData: return ModelInputData( @@ -338,7 +338,7 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: async for _ in result.stream_events(): pass - assert _sent_item_types(model.last_turn_args["input"]) == [ + assert _sent_item_types(model.calls[-1].input) == [ "function_call", "function_call_output", ] @@ -346,9 +346,9 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: @pytest.mark.asyncio async def test_call_model_input_filter_keeps_duplicate_output_order_non_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) def filter_fn(data: CallModelData[Any]) -> ModelInputData: return ModelInputData( @@ -362,18 +362,18 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: run_config=RunConfig(call_model_input_filter=filter_fn), ) - assert _sent_item_types(model.last_turn_args["input"]) == [ + assert _sent_item_types(model.calls[-1].input) == [ "function_call", "function_call_output", ] - assert model.last_turn_args["input"][-1]["output"] == "new" + assert model.calls[-1].input[-1]["output"] == "new" @pytest.mark.asyncio async def test_call_model_input_filter_keeps_duplicate_output_order_streamed() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) async def filter_fn(data: CallModelData[Any]) -> ModelInputData: return ModelInputData( @@ -389,18 +389,18 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: async for _ in result.stream_events(): pass - assert _sent_item_types(model.last_turn_args["input"]) == [ + assert _sent_item_types(model.calls[-1].input) == [ "function_call", "function_call_output", ] - assert model.last_turn_args["input"][-1]["output"] == "new" + assert model.calls[-1].input[-1]["output"] == "new" @pytest.mark.asyncio async def test_call_model_input_filter_keeps_reasoning_before_required_follower() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("ok")]) + model.enqueue([get_text_message("ok")]) def filter_fn(data: CallModelData[Any]) -> ModelInputData: return ModelInputData( @@ -414,12 +414,10 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: run_config=RunConfig(call_model_input_filter=filter_fn), ) - assert _sent_item_types(model.last_turn_args["input"]) == [ + assert _sent_item_types(model.calls[-1].input) == [ "reasoning", "function_call", ] - reasoning_items = [ - item for item in model.last_turn_args["input"] if item.get("type") == "reasoning" - ] + reasoning_items = [item for item in model.calls[-1].input if item.get("type") == "reasoning"] assert len(reasoning_items) == 1 assert reasoning_items[0]["summary"] == [{"type": "summary_text", "text": "new"}] diff --git a/tests/test_call_model_input_filter_unit.py b/tests/test_call_model_input_filter_unit.py index ba96b32332..110da33278 100644 --- a/tests/test_call_model_input_filter_unit.py +++ b/tests/test_call_model_input_filter_unit.py @@ -9,15 +9,15 @@ from agents.agent import Agent from agents.exceptions import UserError from agents.run import CallModelData, ModelInputData, RunConfig, Runner -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel @pytest.mark.asyncio async def test_call_model_input_filter_sync_non_streamed_unit() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output( + model.enqueue( [ ResponseOutputMessage( id="1", @@ -44,18 +44,18 @@ def filter_fn(data: CallModelData[Any]) -> ModelInputData: run_config=RunConfig(call_model_input_filter=filter_fn), ) - assert model.last_turn_args["system_instructions"] == "filtered-sync" - assert isinstance(model.last_turn_args["input"], list) - assert len(model.last_turn_args["input"]) == 2 - assert model.last_turn_args["input"][-1]["content"] == "added-sync" + assert model.calls[-1].system_instructions == "filtered-sync" + assert isinstance(model.calls[-1].input, list) + assert len(model.calls[-1].input) == 2 + assert model.calls[-1].input[-1]["content"] == "added-sync" @pytest.mark.asyncio async def test_call_model_input_filter_async_streamed_unit() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output( + model.enqueue( [ ResponseOutputMessage( id="1", @@ -84,15 +84,15 @@ async def filter_fn(data: CallModelData[Any]) -> ModelInputData: async for _ in result.stream_events(): pass - assert model.last_turn_args["system_instructions"] == "filtered-async" - assert isinstance(model.last_turn_args["input"], list) - assert len(model.last_turn_args["input"]) == 2 - assert model.last_turn_args["input"][-1]["content"] == "added-async" + assert model.calls[-1].system_instructions == "filtered-async" + assert isinstance(model.calls[-1].input, list) + assert len(model.calls[-1].input) == 2 + assert model.calls[-1].input[-1]["content"] == "added-async" @pytest.mark.asyncio async def test_call_model_input_filter_invalid_return_type_raises_unit() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) def invalid_filter(_data: CallModelData[Any]): diff --git a/tests/test_cancel_streaming.py b/tests/test_cancel_streaming.py index b912f05130..3fbf2571ac 100644 --- a/tests/test_cancel_streaming.py +++ b/tests/test_cancel_streaming.py @@ -8,13 +8,13 @@ from agents import Agent, Runner from agents.guardrail import input_guardrail from agents.stream_events import RawResponsesStreamEvent +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_function_tool, get_function_tool_call, get_text_message -class SlowCompleteFakeModel(FakeModel): - """A FakeModel that delays before emitting the completed event in streaming.""" +class SlowCompleteScriptedModel(ScriptedModel): + """A ScriptedModel that delays before emitting the completed event in streaming.""" def __init__(self, delay_seconds: float): super().__init__() @@ -29,7 +29,7 @@ async def stream_response(self, *args, **kwargs): @pytest.mark.asyncio async def test_simple_streaming_with_cancel(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") @@ -46,14 +46,14 @@ async def test_simple_streaming_with_cancel(): @pytest.mark.asyncio async def test_multiple_events_streaming_with_cancel(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Joker", model=model, tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -79,7 +79,7 @@ async def test_multiple_events_streaming_with_cancel(): @pytest.mark.asyncio async def test_cancel_prevents_further_events(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") events = [] @@ -95,7 +95,7 @@ async def test_cancel_prevents_further_events(): @pytest.mark.asyncio async def test_cancel_is_idempotent(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") events = [] @@ -110,7 +110,7 @@ async def test_cancel_is_idempotent(): @pytest.mark.asyncio async def test_cancel_before_streaming(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") result.cancel() # Cancel before streaming @@ -120,7 +120,7 @@ async def test_cancel_before_streaming(): @pytest.mark.asyncio async def test_cancel_cleans_up_resources(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") # Start streaming, then cancel @@ -138,7 +138,7 @@ async def test_cancel_cleans_up_resources(): @pytest.mark.asyncio async def test_cancel_immediate_mode_explicit(): """Test explicit immediate mode behaves same as default.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") @@ -154,8 +154,8 @@ async def test_cancel_immediate_mode_explicit(): @pytest.mark.asyncio async def test_stream_events_respects_asyncio_timeout_cancellation(): - model = SlowCompleteFakeModel(delay_seconds=0.5) - model.set_next_output([get_text_message("Final response")]) + model = SlowCompleteScriptedModel(delay_seconds=0.5) + model.enqueue([get_text_message("Final response")]) agent = Agent(name="TimeoutTester", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") @@ -183,7 +183,7 @@ async def test_stream_events_respects_asyncio_timeout_cancellation(): async def test_cancel_immediate_unblocks_waiting_stream_consumer(): block_event = asyncio.Event() - class BlockingFakeModel(FakeModel): + class BlockingScriptedModel(ScriptedModel): async def stream_response( self, system_instructions, @@ -213,7 +213,7 @@ async def stream_response( ): yield event - model = BlockingFakeModel() + model = BlockingScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") @@ -236,8 +236,8 @@ async def consume_events(): @pytest.mark.asyncio async def test_run_loop_exception_property_is_none_on_success(): """run_loop_exception is None when the stream completes without error.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) agent = Agent(name="A", model=model) result = Runner.run_streamed(agent, input="hi") @@ -251,7 +251,7 @@ async def test_run_loop_exception_property_is_none_on_success(): async def test_run_loop_exception_surfaced_after_stream(): """run_loop_exception is set when the run loop raises before yielding events.""" - class BoomModel(FakeModel): + class BoomModel(ScriptedModel): async def get_response(self, *args, **kwargs): raise RuntimeError("run loop boom") @@ -278,7 +278,7 @@ class FalsyRuntimeError(RuntimeError): def __bool__(self) -> bool: return False - class BoomModel(FakeModel): + class BoomModel(ScriptedModel): async def stream_response(self, *args, **kwargs): raise FalsyRuntimeError("falsy run loop boom") yield @@ -300,8 +300,8 @@ def __bool__(self) -> bool: async def raising_guardrail(context, agent, input): raise FalsyRuntimeError("falsy guardrail boom") - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) result = Runner.run_streamed( Agent(name="A", model=model, input_guardrails=[raising_guardrail]), input="hi", diff --git a/tests/test_computer_action.py b/tests/test_computer_action.py index 2caa52736f..950821aa12 100644 --- a/tests/test_computer_action.py +++ b/tests/test_computer_action.py @@ -46,9 +46,9 @@ from agents.items import ToolCallOutputItem from agents.run_internal import run_loop from agents.run_internal.run_loop import ComputerAction, ToolRunComputerAction +from agents.testing import ScriptedModel from agents.tool import ComputerToolSafetyCheckData -from .fake_model import FakeModel from .test_responses import get_text_message from .testing_processor import SPAN_PROCESSOR_TESTING @@ -639,8 +639,8 @@ async def test_runner_trace_lists_ga_computer_tool_name() -> None: pending_safety_checks=[], status="completed", ) - model = FakeModel(tracing_enabled=True) - model.add_multiple_turn_outputs( + model = ScriptedModel(emit_traces=True) + model.extend( [ [tool_call], [get_text_message("done")], diff --git a/tests/test_computer_tool_lifecycle.py b/tests/test_computer_tool_lifecycle.py index bbb0e04baf..de75cdeb71 100644 --- a/tests/test_computer_tool_lifecycle.py +++ b/tests/test_computer_tool_lifecycle.py @@ -26,7 +26,7 @@ ) from agents.computer import Button, Computer, Environment from agents.models.openai_responses import Converter -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel class FakeComputer(Computer): @@ -158,7 +158,7 @@ async def test_runner_disposes_computer_after_run() -> None: dispose = AsyncMock() tool = ComputerTool(computer=ComputerProvider[FakeComputer](create=create, dispose=dispose)) - model = FakeModel(initial_output=[_make_message("done")]) + model = ScriptedModel(steps=[[_make_message("done")]]) agent = Agent(name="ComputerAgent", model=model, tools=[tool]) result = await Runner.run(agent, "hello") @@ -167,7 +167,7 @@ async def test_runner_disposes_computer_after_run() -> None: create.assert_awaited_once() dispose.assert_awaited_once() dispose.assert_awaited_with(run_context=result.context_wrapper, computer=created) - resolved_tool = cast(ComputerTool[Any], model.last_turn_args["tools"][0]) + resolved_tool = cast(ComputerTool[Any], model.calls[-1].tools[0]) assert resolved_tool is not tool assert resolved_tool.computer is created @@ -194,27 +194,29 @@ async def on_tool_end( self.ended.append(tool) tool = ComputerTool(computer=FakeComputer("concrete")) - model = FakeModel( - initial_output=[ - ResponseComputerToolCall( - id="computer-call", - type="computer_call", - action=ActionScreenshot(type="screenshot"), - call_id="computer-call", - pending_safety_checks=[], - status="completed", - ) + model = ScriptedModel( + steps=[ + [ + ResponseComputerToolCall( + id="computer-call", + type="computer_call", + action=ActionScreenshot(type="screenshot"), + call_id="computer-call", + pending_safety_checks=[], + status="completed", + ) + ] ] ) - model.set_next_output([_make_message("done")]) + model.enqueue([_make_message("done")]) agent = Agent(name="ComputerAgent", model=model, tools=[tool]) hooks = IdentityHooks() result = await Runner.run(agent, "hello", hooks=hooks) assert result.final_output == "done" - assert model.first_turn_args is not None - assert model.first_turn_args["tools"][0] is tool + assert bool(model.calls) + assert model.calls[0].tools[0] is tool assert hooks.started == [tool] assert hooks.ended == [tool] @@ -239,10 +241,10 @@ async def dispose(*_: Any, computer: FakeComputer, **__: Any) -> None: release_model = [asyncio.Event(), asyncio.Event()] serialized_widths: list[int] = [] - class GatedSerializationModel(FakeModel): + class GatedSerializationModel(ScriptedModel): def __init__(self) -> None: - super().__init__(initial_output=[_make_message("done")]) - self.set_next_output([_make_message("done")]) + super().__init__(steps=[[_make_message("done")]]) + self.enqueue([_make_message("done")]) self.call_count = 0 async def get_response( @@ -331,7 +333,7 @@ async def test_streamed_run_disposes_computer_after_completion() -> None: dispose = AsyncMock() tool = ComputerTool(computer=ComputerProvider[FakeComputer](create=create, dispose=dispose)) - model = FakeModel(initial_output=[_make_message("done")]) + model = ScriptedModel(steps=[[_make_message("done")]]) agent = Agent(name="ComputerAgent", model=model, tools=[tool]) streamed_result = Runner.run_streamed(agent, "hello") @@ -342,6 +344,6 @@ async def test_streamed_run_disposes_computer_after_completion() -> None: create.assert_awaited_once() dispose.assert_awaited_once() dispose.assert_awaited_with(run_context=streamed_result.context_wrapper, computer=created) - resolved_tool = cast(ComputerTool[Any], model.last_turn_args["tools"][0]) + resolved_tool = cast(ComputerTool[Any], model.calls[-1].tools[0]) assert resolved_tool is not tool assert resolved_tool.computer is created diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index afbacf9bbe..78d403c3cd 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -65,13 +65,13 @@ resolve_approval_rejection_message, ) from agents.run_state import _deserialize_items +from agents.testing import ScriptedModel from agents.tool_context import ToolContext from agents.tracing.processor_interface import TracingProcessor from agents.tracing.provider import SynchronousMultiTracingProcessor from agents.tracing.spans import Span from agents.tracing.traces import Trace -from .fake_model import FakeModel from .test_responses import get_function_tool_call, get_text_message from .utils.simple_session import SimpleListSession @@ -1215,9 +1215,9 @@ async def test_run_surfaces_redacted_output_validation_error( ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model, output_type=_RequiredOutput) - model.set_next_output([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model.enqueue([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) session = SimpleListSession( session_id="redacted-run", history=[{"role": "user", "content": _MODEL_OUTPUT_SECRET}], @@ -1243,9 +1243,9 @@ def test_run_sync_surfaces_redacted_output_validation_error_without_runner_data( ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model, output_type=_RequiredOutput) - model.set_next_output([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model.enqueue([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) session = SimpleListSession( session_id="redacted-run-sync", history=[{"role": "user", "content": _MODEL_OUTPUT_SECRET}], @@ -1273,7 +1273,7 @@ async def test_run_preserves_diagnostic_wrapper_traceback_locals( monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) diagnostic_input = "DIAGNOSTIC_RUNNER_INPUT_SECRET" - model = FakeModel(initial_output=[get_text_message('{"answer": "missing count"}')]) + model = ScriptedModel(steps=[[get_text_message('{"answer": "missing count"}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) session = SimpleListSession(session_id="diagnostic-runner") @@ -1292,7 +1292,7 @@ def test_run_sync_preserves_diagnostic_wrapper_traceback_locals( monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) diagnostic_input = "DIAGNOSTIC_RUNNER_SYNC_INPUT_SECRET" - model = FakeModel(initial_output=[get_text_message('{"answer": "missing count"}')]) + model = ScriptedModel(steps=[[get_text_message('{"answer": "missing count"}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) session = SimpleListSession(session_id="diagnostic-runner-sync") @@ -1311,9 +1311,9 @@ async def test_streamed_run_surfaces_redacted_output_validation_error( ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model, output_type=_RequiredOutput) - model.set_next_output([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model.enqueue([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) result = Runner.run_streamed(agent, "go") with pytest.raises(ModelBehaviorError) as exc_info: @@ -1334,7 +1334,7 @@ async def test_streamed_run_loop_exception_follows_model_data_policy( redacted: bool, ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) - model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) result = Runner.run_streamed(agent, "go") @@ -1378,7 +1378,7 @@ def output_guardrail( AgentOutputSchema(_RequiredOutput).validate_json(payload) raise AssertionError("validation should fail") # pragma: no cover - model = FakeModel(initial_output=[get_text_message(_MODEL_OUTPUT_SECRET)]) + model = ScriptedModel(steps=[[get_text_message(_MODEL_OUTPUT_SECRET)]]) agent = Agent( name="A", model=model, @@ -1434,7 +1434,7 @@ def output_guardrail( raise AssertionError("validation should fail") # pragma: no cover caplog.set_level(logging.ERROR, logger="openai.agents") - model = FakeModel(initial_output=[get_text_message(_MODEL_OUTPUT_SECRET)]) + model = ScriptedModel(steps=[[get_text_message(_MODEL_OUTPUT_SECRET)]]) agent = Agent( name="A", model=model, @@ -1536,7 +1536,7 @@ def output_guardrail( agent = Agent( name="A", - model=FakeModel(initial_output=[get_text_message(_MODEL_OUTPUT_SECRET)]), + model=ScriptedModel(steps=[[get_text_message(_MODEL_OUTPUT_SECRET)]]), output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], ) result = Runner.run_streamed(agent, "go", session=FailingFinalTurnSession()) @@ -1569,7 +1569,7 @@ def input_guardrail( AgentOutputSchema(_RequiredOutput).validate_json(payload) raise AssertionError("validation should fail") # pragma: no cover - model = FakeModel(initial_output=[get_text_message("unused")]) + model = ScriptedModel(steps=[[get_text_message("unused")]]) agent = Agent( name="A", model=model, @@ -1596,7 +1596,7 @@ async def test_invalid_final_output_handler_receives_detached_redacted_error( streamed: bool, ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) - model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) retained_errors: list[ModelBehaviorError] = [] @@ -1636,7 +1636,7 @@ async def test_invalid_final_output_handler_invalid_fallback_preserves_redaction ) -> None: fallback_secret = "INVALID_HANDLER_FALLBACK_SECRET" monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) - model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) def invalid_fallback(_data: RunErrorHandlerInput[None]) -> dict[str, str]: @@ -1681,10 +1681,8 @@ async def test_invalid_final_output_handler_fallback_serialization_follows_redac ) -> None: fallback_secret = "PERMISSIVE_HANDLER_FALLBACK_SECRET" monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) - model = FakeModel( - initial_output=[ - get_text_message(f'{{"payload": "{_MODEL_OUTPUT_SECRET}", "count": "invalid"}}') - ] + model = ScriptedModel( + steps=[[get_text_message(f'{{"payload": "{_MODEL_OUTPUT_SECRET}", "count": "invalid"}}')]] ) agent = Agent(name="A", model=model, output_type=_PermissiveFallbackOutput) @@ -1743,7 +1741,7 @@ async def test_empty_final_output_handler_fallback_serialization_follows_redacti ) -> None: fallback_secret = "EMPTY_HANDLER_FALLBACK_SECRET" monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) - model = FakeModel(initial_output=[]) + model = ScriptedModel(steps=[[]]) agent = Agent(name="A", model=model, output_type=_PermissiveFallbackOutput) def permissive_fallback(_data: RunErrorHandlerInput[None]) -> RunErrorHandlerResult: @@ -1792,7 +1790,7 @@ async def test_invalid_final_output_handler_failure_preserves_redaction( streamed: bool, ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) - model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) def fail(data: RunErrorHandlerInput[None]) -> None: @@ -1831,7 +1829,7 @@ async def test_invalid_final_output_handler_hostile_failure_preserves_redaction( ) -> None: handler_secret = "HOSTILE_HANDLER_FAILURE_SECRET" monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) - model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) def fail(_data: RunErrorHandlerInput[None]) -> None: @@ -1870,7 +1868,7 @@ async def test_invalid_final_output_handler_failure_preserves_diagnostic_context streamed: bool, ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) - model = FakeModel(initial_output=[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]) + model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]]) agent = Agent(name="A", model=model, output_type=_RequiredOutput) def fail(_data: RunErrorHandlerInput[None]) -> None: @@ -1914,8 +1912,8 @@ async def test_multiturn_output_validation_error_run_data_follows_redaction_poli def record_value(value: str) -> str: return "recorded" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( diff --git a/tests/test_example_workflows.py b/tests/test_example_workflows.py index 3018ef8dc1..1a75ebe819 100644 --- a/tests/test_example_workflows.py +++ b/tests/test_example_workflows.py @@ -30,6 +30,7 @@ ) from agents.agent import ToolsToFinalOutputResult from agents.items import TResponseInputItem +from agents.testing import ScriptedModel from agents.tool import FunctionToolResult, function_tool from examples.financial_research_agent.agents.verifier_agent import ( VerificationIssue, @@ -55,7 +56,6 @@ from examples.tools.web_search_filters import _normalized_source_urls from examples.web_search_utils import extract_url_citations, extract_web_search_source_urls -from .fake_model import FakeModel from .test_responses import ( get_final_output_message, get_function_tool_call, @@ -341,16 +341,16 @@ class OutlineCheckerOutput: @pytest.mark.asyncio async def test_llm_as_judge_loop_handles_dataclass_feedback() -> None: """Mimics the llm_as_a_judge example: loop until the evaluator passes the outline.""" - outline_model = FakeModel() - outline_model.add_multiple_turn_outputs( + outline_model = ScriptedModel() + outline_model.extend( [ [get_text_message("Outline v1")], [get_text_message("Outline v2")], ] ) - judge_model = FakeModel() - judge_model.add_multiple_turn_outputs( + judge_model = ScriptedModel() + judge_model.extend( [ [ get_final_output_message( @@ -400,14 +400,14 @@ async def test_llm_as_judge_loop_handles_dataclass_feedback() -> None: assert latest_outline == "Outline v2" assert len(conversation) == 4 - assert judge_model.last_turn_args["input"] == conversation + assert judge_model.calls[-1].input == conversation @pytest.mark.asyncio async def test_parallel_translation_flow_reuses_runner_outputs() -> None: """Covers the parallelization example by feeding multiple translations into a picker agent.""" - translation_model = FakeModel() - translation_model.add_multiple_turn_outputs( + translation_model = ScriptedModel() + translation_model.extend( [ [get_text_message("Uno")], [get_text_message("Dos")], @@ -416,8 +416,8 @@ async def test_parallel_translation_flow_reuses_runner_outputs() -> None: ) spanish_agent = Agent(name="spanish_agent", model=translation_model) - picker_model = FakeModel() - picker_model.set_next_output([get_text_message("Pick: Dos")]) + picker_model = ScriptedModel() + picker_model.enqueue([get_text_message("Pick: Dos")]) picker_agent = Agent(name="picker", model=picker_model) translations: list[str] = [] @@ -433,7 +433,7 @@ async def test_parallel_translation_flow_reuses_runner_outputs() -> None: assert translations == ["Uno", "Dos", "Tres"] assert picker_result.final_output == "Pick: Dos" - assert picker_model.last_turn_args["input"] == [ + assert picker_model.calls[-1].input == [ {"content": f"Input: Hello\n\nTranslations:\n{combined}", "role": "user"} ] @@ -441,18 +441,18 @@ async def test_parallel_translation_flow_reuses_runner_outputs() -> None: @pytest.mark.asyncio async def test_deterministic_story_flow_stops_when_checker_blocks() -> None: """Mimics deterministic flow: stop early when quality gate fails.""" - outline_model = FakeModel() - outline_model.set_next_output([get_text_message("Outline v1")]) - checker_model = FakeModel() - checker_model.set_next_output( + outline_model = ScriptedModel() + outline_model.enqueue([get_text_message("Outline v1")]) + checker_model = ScriptedModel() + checker_model.enqueue( [ get_final_output_message( json.dumps({"response": {"good_quality": False, "is_scifi": True}}) ) ] ) - story_model = FakeModel() - story_model.set_next_output(RuntimeError("story should not run")) + story_model = ScriptedModel() + story_model.enqueue(RuntimeError("story should not run")) outline_agent = Agent(name="outline", model=outline_model) checker_agent = Agent( @@ -474,24 +474,24 @@ async def test_deterministic_story_flow_stops_when_checker_blocks() -> None: assert decision.is_scifi is True if decision.good_quality and decision.is_scifi: await Runner.run(story_agent, outline_result.final_output) - assert story_model.first_turn_args is None, "story agent should never be invoked when gated" + assert not story_model.calls, "story agent should never be invoked when gated" @pytest.mark.asyncio async def test_deterministic_story_flow_runs_story_on_pass() -> None: """Mimics deterministic flow: run full path when checker approves.""" - outline_model = FakeModel() - outline_model.set_next_output([get_text_message("Outline ready")]) - checker_model = FakeModel() - checker_model.set_next_output( + outline_model = ScriptedModel() + outline_model.enqueue([get_text_message("Outline ready")]) + checker_model = ScriptedModel() + checker_model.enqueue( [ get_final_output_message( json.dumps({"response": {"good_quality": True, "is_scifi": True}}) ) ] ) - story_model = FakeModel() - story_model.set_next_output([get_text_message("Final story")]) + story_model = ScriptedModel() + story_model.enqueue([get_text_message("Final story")]) outline_agent = Agent(name="outline", model=outline_model) checker_agent = Agent( @@ -513,14 +513,14 @@ async def test_deterministic_story_flow_runs_story_on_pass() -> None: story_result = await Runner.run(story_agent, outline_result.final_output) assert story_result.final_output == "Final story" - assert story_model.last_turn_args["input"] == [{"content": "Outline ready", "role": "user"}] + assert story_model.calls[-1].input == [{"content": "Outline ready", "role": "user"}] @pytest.mark.asyncio async def test_routing_stream_emits_text_and_updates_inputs() -> None: """Mimics routing example stream: text deltas flow through and input history updates.""" - model = FakeModel() - model.set_next_output([get_text_message("Bonjour")]) + model = ScriptedModel() + model.enqueue([get_text_message("Bonjour")]) triage_agent = Agent(name="triage_agent", model=model) streamed = Runner.run_streamed(triage_agent, input="Salut") @@ -557,8 +557,8 @@ class MathHomeworkOutput(BaseModel): @pytest.mark.asyncio async def test_input_guardrail_agent_trips_and_returns_info() -> None: """Mimics math guardrail example: guardrail agent runs and trips before main agent completes.""" - guardrail_model = FakeModel() - guardrail_model.set_next_output( + guardrail_model = ScriptedModel() + guardrail_model.enqueue( [ get_final_output_message( json.dumps({"reasoning": "math detected", "is_math_homework": True}) @@ -577,8 +577,8 @@ async def math_guardrail( output_info=output, tripwire_triggered=output.is_math_homework ) - main_model = FakeModel() - main_model.set_next_output([get_text_message("Should not run")]) + main_model = ScriptedModel() + main_model.enqueue([get_text_message("Should not run")]) main_agent = Agent(name="main", model=main_model, input_guardrails=[math_guardrail]) with pytest.raises(InputGuardrailTripwireTriggered) as excinfo: @@ -610,8 +610,8 @@ async def sensitive_data_check( tripwire_triggered=contains_phone, ) - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_final_output_message( json.dumps( @@ -642,8 +642,8 @@ async def sensitive_data_check( @pytest.mark.asyncio async def test_streaming_guardrail_style_cancel_after_threshold() -> None: """Mimics streaming guardrail example: stop streaming once threshold is reached.""" - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_text_message("Chunk1 "), get_text_message("Chunk2 "), @@ -673,8 +673,8 @@ async def test_streaming_guardrail_style_cancel_after_threshold() -> None: @pytest.mark.asyncio async def test_streaming_cancel_after_turn_allows_turn_completion() -> None: """Ensure cancel(after_turn) lets the current turn finish and final_output is populated.""" - model = FakeModel() - model.set_next_output([get_text_message("Hello"), get_text_message("World")]) + model = ScriptedModel() + model.enqueue([get_text_message("Hello"), get_text_message("World")]) agent = Agent(name="talkative", model=model) streamed = Runner.run_streamed(agent, input="Hi") @@ -696,12 +696,12 @@ async def test_streaming_cancel_after_turn_allows_turn_completion() -> None: @pytest.mark.asyncio async def test_streaming_handoff_emits_agent_updated_event() -> None: """Mimics routing handoff stream: emits AgentUpdatedStreamEvent and switches agent.""" - delegate_model = FakeModel() - delegate_model.set_next_output([get_text_message("delegate reply")]) + delegate_model = ScriptedModel() + delegate_model.enqueue([get_text_message("delegate reply")]) delegate_agent = Agent(name="delegate", model=delegate_model) - triage_model = FakeModel() - triage_model.set_next_output( + triage_model = ScriptedModel() + triage_model.enqueue( [ get_text_message("triage summary"), get_handoff_tool_call(delegate_agent), @@ -749,8 +749,8 @@ async def fake_invoke(ctx, input: str) -> str: billing_tool.on_invoke_tool = fake_invoke - main_model = FakeModel() - main_model.add_multiple_turn_outputs( + main_model = ScriptedModel() + main_model.extend( [ [get_function_tool_call("billing_agent", json.dumps({"input": "Need bill"}))], [get_text_message("Final answer")], @@ -776,8 +776,8 @@ async def fake_invoke(ctx, input: str) -> str: @pytest.mark.asyncio async def test_sandbox_agents_as_tools_example_serializes_structured_reviews() -> None: - pricing_model = FakeModel() - pricing_model.set_next_output( + pricing_model = ScriptedModel() + pricing_model.enqueue( [ get_final_output_message( json.dumps( @@ -793,8 +793,8 @@ async def test_sandbox_agents_as_tools_example_serializes_structured_reviews() - ) ] ) - rollout_model = FakeModel() - rollout_model.set_next_output( + rollout_model = ScriptedModel() + rollout_model.enqueue( [ get_final_output_message( json.dumps( @@ -812,8 +812,8 @@ async def test_sandbox_agents_as_tools_example_serializes_structured_reviews() - ) ] ) - orchestrator_model = FakeModel() - orchestrator_model.add_multiple_turn_outputs( + orchestrator_model = ScriptedModel() + orchestrator_model.extend( [ [ get_function_tool_call( @@ -878,7 +878,7 @@ def get_discount_approval_rule(discount_percent: int) -> str: assert result.final_output == "Recommendation complete" outer_second_turn_input = cast( list[dict[str, Any]], - orchestrator_model.last_turn_args["input"], + orchestrator_model.calls[-1].input, ) outer_tool_outputs = [ item for item in outer_second_turn_input if item.get("type") == "function_call_output" @@ -962,8 +962,8 @@ def get_weather(city: str) -> str: return f"{city}: Sunny" # default: run_llm_again -> model responds after tool call - default_model = FakeModel() - default_model.add_multiple_turn_outputs( + default_model = ScriptedModel() + default_model.extend( [ [ get_text_message("Tool call coming"), @@ -986,8 +986,8 @@ def get_weather(city: str) -> str: assert len(default_result.raw_responses) == 2 # first_tool: stop_on_first_tool -> final output from first tool result - first_model = FakeModel() - first_model.set_next_output( + first_model = ScriptedModel() + first_model.enqueue( [ get_text_message("Tool call coming"), get_function_tool_call("get_weather", json.dumps({"city": "Paris"})), @@ -1014,8 +1014,8 @@ async def custom_tool_use_behavior( is_final_output=True, final_output=f"Custom:{results[0].output}" ) - custom_model = FakeModel() - custom_model.set_next_output( + custom_model = ScriptedModel() + custom_model.enqueue( [ get_text_message("Tool call coming"), get_function_tool_call("get_weather", json.dumps({"city": "Berlin"})), @@ -1037,12 +1037,12 @@ async def custom_tool_use_behavior( @pytest.mark.asyncio async def test_routing_multi_turn_continues_with_handoff_agent() -> None: """Mimics routing example multi-turn: first handoff, then continue with delegated agent.""" - delegate_model = FakeModel() - delegate_model.set_next_output([get_text_message("Bonjour")]) + delegate_model = ScriptedModel() + delegate_model.enqueue([get_text_message("Bonjour")]) delegate_agent = Agent(name="delegate", model=delegate_model) - triage_model = FakeModel() - triage_model.add_multiple_turn_outputs( + triage_model = ScriptedModel() + triage_model.extend( [ [get_handoff_tool_call(delegate_agent)], [get_text_message("handoff completed")], @@ -1055,13 +1055,13 @@ async def test_routing_multi_turn_continues_with_handoff_agent() -> None: assert first_result.last_agent == delegate_agent # Next user turn continues with delegate. - delegate_model.set_next_output([get_text_message("Encore?")]) + delegate_model.enqueue([get_text_message("Encore?")]) follow_up_input = first_result.to_input_list() follow_up_input.append({"role": "user", "content": "Encore!"}) second_result = await Runner.run(delegate_agent, follow_up_input) assert second_result.final_output == "Encore?" - assert delegate_model.last_turn_args["input"] == follow_up_input + assert delegate_model.calls[-1].input == follow_up_input @pytest.mark.asyncio @@ -1084,19 +1084,19 @@ def european_enabled(ctx: RunContextWrapper[AppContext], _agent: AgentBase) -> b ] for preference, expected_tools in scenarios: - spanish_model = FakeModel() - spanish_model.set_next_output([get_text_message("ES hola")]) + spanish_model = ScriptedModel() + spanish_model.enqueue([get_text_message("ES hola")]) spanish_agent = Agent(name="spanish", model=spanish_model) - french_model = FakeModel() - french_model.set_next_output([get_text_message("FR bonjour")]) + french_model = ScriptedModel() + french_model.enqueue([get_text_message("FR bonjour")]) french_agent = Agent(name="french", model=french_model) - italian_model = FakeModel() - italian_model.set_next_output([get_text_message("IT ciao")]) + italian_model = ScriptedModel() + italian_model.enqueue([get_text_message("IT ciao")]) italian_agent = Agent(name="italian", model=italian_model) - orchestrator_model = FakeModel() + orchestrator_model = ScriptedModel() # Build tool calls only for expected tools to avoid missing-tool errors. tool_calls = [ get_function_tool_call( @@ -1106,7 +1106,7 @@ def european_enabled(ctx: RunContextWrapper[AppContext], _agent: AgentBase) -> b ) for tool_name in sorted(expected_tools) ] - orchestrator_model.add_multiple_turn_outputs([tool_calls, [get_text_message("Done")]]) + orchestrator_model.extend([tool_calls, [get_text_message("Done")]]) context = AppContext(language_preference=preference) @@ -1137,35 +1137,35 @@ def european_enabled(ctx: RunContextWrapper[AppContext], _agent: AgentBase) -> b assert result.final_output == "Done" assert ( - spanish_model.first_turn_args is not None + bool(spanish_model.calls) if "respond_spanish" in expected_tools - else spanish_model.first_turn_args is None + else not spanish_model.calls ) assert ( - french_model.first_turn_args is not None + bool(french_model.calls) if "respond_french" in expected_tools - else french_model.first_turn_args is None + else not french_model.calls ) assert ( - italian_model.first_turn_args is not None + bool(italian_model.calls) if "respond_italian" in expected_tools - else italian_model.first_turn_args is None + else not italian_model.calls ) @pytest.mark.asyncio async def test_agents_as_tools_orchestrator_runs_multiple_translations() -> None: """Orchestrator calls multiple translation agent tools then summarizes.""" - spanish_model = FakeModel() - spanish_model.set_next_output([get_text_message("ES hola")]) + spanish_model = ScriptedModel() + spanish_model.enqueue([get_text_message("ES hola")]) spanish_agent = Agent(name="spanish", model=spanish_model) - french_model = FakeModel() - french_model.set_next_output([get_text_message("FR bonjour")]) + french_model = ScriptedModel() + french_model.enqueue([get_text_message("FR bonjour")]) french_agent = Agent(name="french", model=french_model) - orchestrator_model = FakeModel() - orchestrator_model.add_multiple_turn_outputs( + orchestrator_model = ScriptedModel() + orchestrator_model.extend( [ [ get_function_tool_call( @@ -1197,8 +1197,8 @@ async def test_agents_as_tools_orchestrator_runs_multiple_translations() -> None result = await Runner.run(orchestrator, "Hi") assert result.final_output == "Summary complete" - assert spanish_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}] - assert french_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}] + assert spanish_model.calls[-1].input == [{"content": "Hi", "role": "user"}] + assert french_model.calls[-1].input == [{"content": "Hi", "role": "user"}] assert len(result.raw_responses) == 3 @@ -1209,14 +1209,15 @@ async def test_agents_as_tools_subagent_cancellation_preserves_parent_final_outp async def _cancel_tool() -> str: raise asyncio.CancelledError("tool-cancelled") - success_model = FakeModel() - success_model.set_next_output([get_text_message("Status: ok")]) + success_model = ScriptedModel() + success_model.enqueue([get_text_message("Status: ok")]) success_agent = Agent(name="status", model=success_model) - observability_model = FakeModel() - observability_model.set_next_output( + observability_model = ScriptedModel() + observability_model.enqueue( [get_function_tool_call("cancel_tool", "{}", call_id="inner_cancel")] ) + observability_model.enqueue([]) observability_agent = Agent( name="observability", model=observability_model, @@ -1224,8 +1225,8 @@ async def _cancel_tool() -> str: model_settings=ModelSettings(tool_choice="required"), ) - orchestrator_model = FakeModel() - orchestrator_model.add_multiple_turn_outputs( + orchestrator_model = ScriptedModel() + orchestrator_model.extend( [ [ get_function_tool_call( @@ -1257,11 +1258,11 @@ async def _cancel_tool() -> str: assert result.final_output == "Summary complete" assert len(result.raw_responses) == 2 - assert success_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}] - assert observability_model.first_turn_args is not None - assert observability_model.first_turn_args["input"] == [{"content": "Hi", "role": "user"}] + assert success_model.calls[-1].input == [{"content": "Hi", "role": "user"}] + assert bool(observability_model.calls) + assert observability_model.calls[0].input == [{"content": "Hi", "role": "user"}] - second_turn_input = cast(list[dict[str, Any]], orchestrator_model.last_turn_args["input"]) + second_turn_input = cast(list[dict[str, Any]], orchestrator_model.calls[-1].input) tool_outputs = [ item for item in second_turn_input if item.get("type") == "function_call_output" ] @@ -1294,12 +1295,12 @@ async def _cancel_tool() -> str: async def on_stream(event: AgentToolStreamEvent) -> None: received_events.append(event) - status_model = FakeModel() - status_model.set_next_output([get_text_message("Status: ok")]) + status_model = ScriptedModel() + status_model.enqueue([get_text_message("Status: ok")]) status_agent = Agent(name="status", model=status_model) - observability_model = FakeModel() - observability_model.add_multiple_turn_outputs( + observability_model = ScriptedModel() + observability_model.extend( [ [ get_function_tool_call("ok_tool", "{}", call_id="inner_ok"), @@ -1318,8 +1319,8 @@ async def on_stream(event: AgentToolStreamEvent) -> None: model_settings=ModelSettings(tool_choice="required"), ) - orchestrator_model = FakeModel() - orchestrator_model.add_multiple_turn_outputs( + orchestrator_model = ScriptedModel() + orchestrator_model.extend( [ [ get_function_tool_call( @@ -1356,12 +1357,12 @@ async def on_stream(event: AgentToolStreamEvent) -> None: assert result.final_output == "Summary complete" assert len(result.raw_responses) == 2 assert received_events, "on_stream should confirm the nested streaming path ran" - assert status_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}] - assert observability_model.last_turn_args is not None + assert status_model.calls[-1].input == [{"content": "Hi", "role": "user"}] + assert bool(observability_model.calls) nested_second_turn_input = cast( list[dict[str, Any]], - observability_model.last_turn_args["input"], + observability_model.calls[-1].input, ) nested_tool_outputs = [ item for item in nested_second_turn_input if item.get("type") == "function_call_output" @@ -1383,7 +1384,7 @@ async def on_stream(event: AgentToolStreamEvent) -> None: outer_second_turn_input = cast( list[dict[str, Any]], - orchestrator_model.last_turn_args["input"], + orchestrator_model.calls[-1].input, ) outer_tool_outputs = [ item for item in outer_second_turn_input if item.get("type") == "function_call_output" @@ -1409,12 +1410,12 @@ async def test_agents_as_tools_failure_error_function_none_reraises_cancelled_er async def _cancel_tool() -> str: raise asyncio.CancelledError("tool-cancelled") - status_model = FakeModel() - status_model.set_next_output([get_text_message("Status: ok")]) + status_model = ScriptedModel() + status_model.enqueue([get_text_message("Status: ok")]) status_agent = Agent(name="status", model=status_model) - observability_model = FakeModel() - observability_model.set_next_output( + observability_model = ScriptedModel() + observability_model.enqueue( [get_function_tool_call("cancel_tool", "{}", call_id="inner_cancel")] ) observability_agent = Agent( @@ -1426,8 +1427,8 @@ async def _cancel_tool() -> str: model_settings=ModelSettings(tool_choice="required"), ) - orchestrator_model = FakeModel() - orchestrator_model.set_next_output( + orchestrator_model = ScriptedModel() + orchestrator_model.enqueue( [ get_function_tool_call( "status_agent", diff --git a/tests/test_global_hooks.py b/tests/test_global_hooks.py index 0b4bada0c0..cb0c393899 100644 --- a/tests/test_global_hooks.py +++ b/tests/test_global_hooks.py @@ -8,9 +8,9 @@ from typing_extensions import TypedDict from agents import Agent, RunContextWrapper, RunHooks, Runner, TContext, Tool +from agents.testing import ScriptedModel from agents.tool_context import ToolContext -from .fake_model import FakeModel from .test_responses import ( get_final_output_message, get_function_tool, @@ -75,7 +75,7 @@ async def on_tool_end( @pytest.mark.asyncio async def test_non_streamed_agent_hooks(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -87,12 +87,12 @@ async def test_non_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_text_message("user_message")]) + model.enqueue([get_text_message("user_message")]) output = await Runner.run(agent_3, input="user_message", hooks=hooks) assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("some_function", json.dumps({"a": "b"}))], [get_text_message("done")], @@ -103,7 +103,7 @@ async def test_non_streamed_agent_hooks(): assert len(set(hooks.tool_context_ids)) == 1 hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -127,7 +127,7 @@ async def test_non_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -161,7 +161,7 @@ async def test_non_streamed_agent_hooks(): @pytest.mark.asyncio async def test_streamed_agent_hooks(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -173,14 +173,14 @@ async def test_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_text_message("user_message")]) + model.enqueue([get_text_message("user_message")]) output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) async for _ in output.stream_events(): pass assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -204,7 +204,7 @@ async def test_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -243,7 +243,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_structured_output_non_streamed_agent_hooks(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -256,12 +256,12 @@ async def test_structured_output_non_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + model.enqueue([get_final_output_message(json.dumps({"a": "b"}))]) output = await Runner.run(agent_3, input="user_message", hooks=hooks) assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -284,7 +284,7 @@ async def test_structured_output_non_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -316,7 +316,7 @@ async def test_structured_output_non_streamed_agent_hooks(): @pytest.mark.asyncio async def test_structured_output_streamed_agent_hooks(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent(name="test_2", model=model) agent_3 = Agent( @@ -329,14 +329,14 @@ async def test_structured_output_streamed_agent_hooks(): agent_1.handoffs.append(agent_3) - model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + model.enqueue([get_final_output_message(json.dumps({"a": "b"}))]) output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) async for _ in output.stream_events(): pass assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -360,7 +360,7 @@ async def test_structured_output_streamed_agent_hooks(): }, f"got unexpected event count: {hooks.events}" hooks.reset() - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index 9bd343dead..275623ba8b 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -24,8 +24,8 @@ from agents.guardrail import input_guardrail, output_guardrail from agents.result import RunResultStreaming from agents.run_internal.guardrails import run_input_guardrails, run_input_guardrails_with_queue +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_function_tool_call, get_text_message from .testing_processor import fetch_events @@ -365,14 +365,14 @@ async def parallel_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'hello'", input_guardrails=[parallel_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) result = await Runner.run(agent, "test input") @@ -380,7 +380,7 @@ async def parallel_check( assert result.final_output is not None assert len(result.input_guardrail_results) == 1 assert result.input_guardrail_results[0].output.output_info == "parallel_ok" - assert model.first_turn_args is not None, "Model should have been called in parallel mode" + assert bool(model.calls), "Model should have been called in parallel mode" @pytest.mark.asyncio @@ -399,14 +399,14 @@ async def parallel_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="streaming_agent", instructions="Reply with 'hello'", input_guardrails=[parallel_check], model=model, ) - model.set_next_output([get_text_message("hello from stream")]) + model.enqueue([get_text_message("hello from stream")]) result = Runner.run_streamed(agent, "test input") @@ -416,7 +416,7 @@ async def parallel_check( assert guardrail_executed is True assert received_events is True - assert model.first_turn_args is not None, "Model should have been called in parallel mode" + assert bool(model.calls), "Model should have been called in parallel mode" @pytest.mark.asyncio @@ -435,21 +435,21 @@ async def blocking_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'hello'", input_guardrails=[blocking_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) with pytest.raises(InputGuardrailTripwireTriggered) as exc_info: await Runner.run(agent, "test input") assert guardrail_executed is True assert exc_info.value.guardrail_result.output.output_info == "security_violation" - assert model.first_turn_args is None, "Model should not have been called" + assert not model.calls, "Model should not have been called" @pytest.mark.asyncio @@ -468,14 +468,14 @@ async def blocking_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="streaming_agent", instructions="Reply with a long message", input_guardrails=[blocking_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) result = Runner.run_streamed(agent, "test input") @@ -484,7 +484,7 @@ async def blocking_check( pass assert guardrail_executed is True - assert model.first_turn_args is None, "Model should not have been called" + assert not model.calls, "Model should not have been called" @pytest.mark.asyncio @@ -516,7 +516,7 @@ async def slow_parallel_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="agent_with_tools", instructions="Call the fast_tool immediately", @@ -524,8 +524,8 @@ async def slow_parallel_check( input_guardrails=[slow_parallel_check], model=model, ) - model.set_next_output([get_function_tool_call("fast_tool", arguments="{}")]) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_function_tool_call("fast_tool", arguments="{}")]) + model.enqueue([get_text_message("done")]) with pytest.raises(InputGuardrailTripwireTriggered): await Runner.run(agent, "trigger guardrail") @@ -534,7 +534,7 @@ async def slow_parallel_check( assert tool_was_executed is True, ( "Expected tool to execute before slow parallel guardrail triggered" ) - assert model.first_turn_args is not None, "Model should have been called in parallel mode" + assert bool(model.calls), "Model should have been called in parallel mode" @pytest.mark.asyncio @@ -553,7 +553,7 @@ async def tripwire_after_model_starts( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() original_get_response = model.get_response async def slow_get_response(*args, **kwargs): @@ -573,7 +573,7 @@ async def slow_get_response(*args, **kwargs): input_guardrails=[tripwire_after_model_starts], model=model, ) - model.set_next_output([get_text_message("should_not_finish")]) + model.enqueue([get_text_message("should_not_finish")]) with patch.object(model, "get_response", side_effect=slow_get_response): with pytest.raises(InputGuardrailTripwireTriggered): @@ -600,7 +600,7 @@ async def tripwire_after_model_starts( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() original_get_response = model.get_response async def slow_get_response(*args, **kwargs): @@ -620,7 +620,7 @@ async def slow_get_response(*args, **kwargs): input_guardrails=[tripwire_after_model_starts], model=model, ) - model.set_next_output([get_text_message("should_finish_without_cancel")]) + model.enqueue([get_text_message("should_finish_without_cancel")]) with patch.object(model, "get_response", side_effect=slow_get_response): with patch( @@ -663,7 +663,7 @@ async def slow_parallel_check( guardrail_cancelled.set() raise - model = FakeModel() + model = ScriptedModel() async def boom_get_response(*args, **kwargs): # Only blow up once the guardrail is genuinely mid-flight. @@ -704,7 +704,7 @@ async def raising_parallel_check( await asyncio.wait_for(model_started.wait(), timeout=1) raise ValueError("guardrail boom") - model = FakeModel() + model = ScriptedModel() original_get_response = model.get_response async def slow_get_response(*args, **kwargs): @@ -724,7 +724,7 @@ async def slow_get_response(*args, **kwargs): input_guardrails=[raising_parallel_check], model=model, ) - model.set_next_output([get_text_message("should_not_finish")]) + model.enqueue([get_text_message("should_not_finish")]) with patch.object(model, "get_response", side_effect=slow_get_response): with pytest.raises(ValueError, match="guardrail boom"): @@ -748,7 +748,7 @@ async def raising_parallel_check( await asyncio.wait_for(model_started.wait(), timeout=1) raise ValueError("guardrail boom") - model = FakeModel() + model = ScriptedModel() async def blocking_stream_response(*args, **kwargs): model_started.set() @@ -797,7 +797,7 @@ async def raising_parallel_check( await asyncio.wait_for(raise_guardrail_error.wait(), timeout=1) raise ValueError("guardrail boom") - class BlockingCleanupFakeModel(FakeModel): + class BlockingCleanupScriptedModel(ScriptedModel): async def _cleanup_on_run_end(self, owner: object) -> None: model_cleanup_started.set() try: @@ -807,8 +807,8 @@ async def _cleanup_on_run_end(self, owner: object) -> None: model_cleanup_cancelled.set() raise - model = BlockingCleanupFakeModel(tracing_enabled=True) - model.set_next_output(RuntimeError("model boom")) + model = BlockingCleanupScriptedModel(emit_traces=True) + model.enqueue(RuntimeError("model boom")) agent = Agent( name="streaming_model_error_agent", @@ -875,7 +875,7 @@ async def slow_parallel_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="agent_with_tools", instructions="Call the fast_tool immediately", @@ -883,8 +883,8 @@ async def slow_parallel_check( input_guardrails=[slow_parallel_check], model=model, ) - model.set_next_output([get_function_tool_call("fast_tool", arguments="{}")]) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_function_tool_call("fast_tool", arguments="{}")]) + model.enqueue([get_text_message("done")]) result = Runner.run_streamed(agent, "trigger guardrail") @@ -896,7 +896,7 @@ async def slow_parallel_check( assert tool_was_executed is True, ( "Expected tool to execute before slow parallel guardrail triggered" ) - assert model.first_turn_args is not None, "Model should have been called in parallel mode" + assert bool(model.calls), "Model should have been called in parallel mode" @pytest.mark.asyncio @@ -922,7 +922,7 @@ async def tripwire_before_tool_execution( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() original_stream_response = model.stream_response async def delayed_stream_response(*args, **kwargs): @@ -939,8 +939,8 @@ async def delayed_stream_response(*args, **kwargs): input_guardrails=[tripwire_before_tool_execution], model=model, ) - model.set_next_output([get_function_tool_call("dangerous_tool", arguments="{}")]) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_function_tool_call("dangerous_tool", arguments="{}")]) + model.enqueue([get_text_message("done")]) with patch.object(model, "stream_response", side_effect=delayed_stream_response): result = Runner.run_streamed(agent, "trigger guardrail") @@ -952,7 +952,7 @@ async def delayed_stream_response(*args, **kwargs): assert model_started.is_set() is True assert guardrail_tripped.is_set() is True assert tool_was_executed is False - assert model.first_turn_args is not None, "Model should have been called in parallel mode" + assert bool(model.calls), "Model should have been called in parallel mode" @pytest.mark.asyncio @@ -996,7 +996,7 @@ async def slow_to_cancel_guardrail( slow_cancel_finished.set() raise - model = FakeModel() + model = ScriptedModel() original_stream_response = model.stream_response async def delayed_stream_response(*args, **kwargs): @@ -1013,8 +1013,8 @@ async def delayed_stream_response(*args, **kwargs): input_guardrails=[tripwire_before_tool_execution, slow_to_cancel_guardrail], model=model, ) - model.set_next_output([get_function_tool_call("dangerous_tool", arguments="{}")]) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_function_tool_call("dangerous_tool", arguments="{}")]) + model.enqueue([get_text_message("done")]) with patch.object(model, "stream_response", side_effect=delayed_stream_response): result = Runner.run_streamed(agent, "trigger guardrail") @@ -1033,7 +1033,7 @@ async def delayed_stream_response(*args, **kwargs): assert slow_cancel_started.is_set() is True assert slow_cancel_finished.is_set() is True assert tool_was_executed is False - assert model.first_turn_args is not None, "Model should have been called in parallel mode" + assert bool(model.calls), "Model should have been called in parallel mode" @pytest.mark.asyncio @@ -1059,7 +1059,7 @@ async def security_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="agent_with_tools", instructions="Call the dangerous_tool immediately", @@ -1067,14 +1067,14 @@ async def security_check( input_guardrails=[security_check], model=model, ) - model.set_next_output([get_function_tool_call("dangerous_tool", arguments="{}")]) + model.enqueue([get_function_tool_call("dangerous_tool", arguments="{}")]) with pytest.raises(InputGuardrailTripwireTriggered): await Runner.run(agent, "trigger guardrail") assert guardrail_executed is True assert tool_was_executed is False - assert model.first_turn_args is None, "Model should not have been called" + assert not model.calls, "Model should not have been called" @pytest.mark.asyncio @@ -1100,7 +1100,7 @@ async def security_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="agent_with_tools", instructions="Call the dangerous_tool immediately", @@ -1108,7 +1108,7 @@ async def security_check( input_guardrails=[security_check], model=model, ) - model.set_next_output([get_function_tool_call("dangerous_tool", arguments="{}")]) + model.enqueue([get_function_tool_call("dangerous_tool", arguments="{}")]) result = Runner.run_streamed(agent, "trigger guardrail") @@ -1118,7 +1118,7 @@ async def security_check( assert guardrail_executed is True assert tool_was_executed is False - assert model.first_turn_args is None, "Model should not have been called" + assert not model.calls, "Model should not have been called" @pytest.mark.asyncio @@ -1137,20 +1137,20 @@ async def parallel_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'success'", input_guardrails=[parallel_check], model=model, ) - model.set_next_output([get_text_message("success")]) + model.enqueue([get_text_message("success")]) result = await Runner.run(agent, "test input") assert guardrail_executed is True assert result.final_output is not None - assert model.first_turn_args is not None, "Model should have been called" + assert bool(model.calls), "Model should have been called" @pytest.mark.asyncio @@ -1169,14 +1169,14 @@ async def parallel_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'success'", input_guardrails=[parallel_check], model=model, ) - model.set_next_output([get_text_message("success")]) + model.enqueue([get_text_message("success")]) result = Runner.run_streamed(agent, "test input") @@ -1186,7 +1186,7 @@ async def parallel_check( assert guardrail_executed is True assert received_events is True - assert model.first_turn_args is not None, "Model should have been called" + assert bool(model.calls), "Model should have been called" @pytest.mark.asyncio @@ -1205,20 +1205,20 @@ async def blocking_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'success'", input_guardrails=[blocking_check], model=model, ) - model.set_next_output([get_text_message("success")]) + model.enqueue([get_text_message("success")]) result = await Runner.run(agent, "test input") assert guardrail_executed is True assert result.final_output is not None - assert model.first_turn_args is not None, "Model should have been called after guardrail passed" + assert bool(model.calls), "Model should have been called after guardrail passed" @pytest.mark.asyncio @@ -1237,14 +1237,14 @@ async def blocking_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'success'", input_guardrails=[blocking_check], model=model, ) - model.set_next_output([get_text_message("success")]) + model.enqueue([get_text_message("success")]) result = Runner.run_streamed(agent, "test input") @@ -1254,7 +1254,7 @@ async def blocking_check( assert guardrail_executed is True assert received_events is True - assert model.first_turn_args is not None, "Model should have been called after guardrail passed" + assert bool(model.calls), "Model should have been called after guardrail passed" @pytest.mark.asyncio @@ -1289,7 +1289,7 @@ async def parallel_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() original_get_response = model.get_response @@ -1306,7 +1306,7 @@ async def tracked_get_response(*args, **kwargs): input_guardrails=[blocking_check, parallel_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) with patch.object(model, "get_response", side_effect=tracked_get_response): result = await Runner.run(agent, "test input") @@ -1324,9 +1324,7 @@ async def tracked_get_response(*args, **kwargs): "Model called while parallel guardrail still running" ) assert parallel_finished.is_set() is True, "Parallel guardrail should have completed" - assert model.first_turn_args is not None, ( - "Model should have been called after blocking guardrails passed" - ) + assert bool(model.calls), "Model should have been called after blocking guardrails passed" @pytest.mark.asyncio @@ -1361,7 +1359,7 @@ async def parallel_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() original_stream_response = model.stream_response @@ -1379,7 +1377,7 @@ async def tracked_stream_response(*args, **kwargs): input_guardrails=[blocking_check, parallel_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) with patch.object(model, "stream_response", side_effect=tracked_stream_response): result = Runner.run_streamed(agent, "test input") @@ -1399,9 +1397,7 @@ async def tracked_stream_response(*args, **kwargs): "Model called while parallel guardrail still running" ) assert parallel_finished.is_set() is True, "Parallel guardrail should have completed" - assert model.first_turn_args is not None, ( - "Model should have been called after blocking guardrails passed" - ) + assert bool(model.calls), "Model should have been called after blocking guardrails passed" @pytest.mark.asyncio @@ -1432,7 +1428,7 @@ async def second_blocking_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() original_get_response = model.get_response @@ -1446,7 +1442,7 @@ async def tracked_get_response(*args, **kwargs): input_guardrails=[first_blocking_check, second_blocking_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) with patch.object(model, "get_response", side_effect=tracked_get_response): result = await Runner.run(agent, "test input") @@ -1466,9 +1462,7 @@ async def tracked_get_response(*args, **kwargs): assert timestamps["second_blocking_end"] <= timestamps["model_called"], ( "Second blocking guardrail must complete before model is called" ) - assert model.first_turn_args is not None, ( - "Model should have been called after all blocking guardrails passed" - ) + assert bool(model.calls), "Model should have been called after all blocking guardrails passed" @pytest.mark.asyncio @@ -1499,7 +1493,7 @@ async def second_blocking_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() original_stream_response = model.stream_response @@ -1514,7 +1508,7 @@ async def tracked_stream_response(*args, **kwargs): input_guardrails=[first_blocking_check, second_blocking_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) with patch.object(model, "stream_response", side_effect=tracked_stream_response): result = Runner.run_streamed(agent, "test input") @@ -1536,9 +1530,7 @@ async def tracked_stream_response(*args, **kwargs): assert timestamps["second_blocking_end"] <= timestamps["model_called"], ( "Second blocking guardrail must complete before model is called" ) - assert model.first_turn_args is not None, ( - "Model should have been called after all blocking guardrails passed" - ) + assert bool(model.calls), "Model should have been called after all blocking guardrails passed" @pytest.mark.asyncio @@ -1575,14 +1567,14 @@ async def second_blocking_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="multi_blocking_agent", instructions="Reply with 'hello'", input_guardrails=[first_blocking_check, second_blocking_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) with pytest.raises(InputGuardrailTripwireTriggered): await Runner.run(agent, "test input") @@ -1593,9 +1585,7 @@ async def second_blocking_check( assert "first_blocking_end" in timestamps assert "second_blocking_start" in timestamps assert "second_blocking_end" in timestamps - assert model.first_turn_args is None, ( - "Model should not have been called when guardrail triggered" - ) + assert not model.calls, "Model should not have been called when guardrail triggered" @pytest.mark.asyncio @@ -1632,14 +1622,14 @@ async def second_blocking_check( tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="multi_blocking_agent", instructions="Reply with 'hello'", input_guardrails=[first_blocking_check, second_blocking_check], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) result = Runner.run_streamed(agent, "test input") @@ -1653,9 +1643,7 @@ async def second_blocking_check( assert "first_blocking_end" in timestamps assert "second_blocking_start" in timestamps assert "second_blocking_end" in timestamps - assert model.first_turn_args is None, ( - "Model should not have been called when guardrail triggered" - ) + assert not model.calls, "Model should not have been called when guardrail triggered" @pytest.mark.asyncio @@ -1685,22 +1673,22 @@ async def config_level_check( tripwire_triggered=False, ) - model1 = FakeModel() + model1 = ScriptedModel() agent_with_guardrail = Agent( name="test_agent", instructions="Reply with 'hello'", input_guardrails=[agent_level_check], model=model1, ) - model1.set_next_output([get_text_message("hello")]) + model1.enqueue([get_text_message("hello")]) - model2 = FakeModel() + model2 = ScriptedModel() agent_without_guardrail = Agent( name="test_agent", instructions="Reply with 'hello'", model=model2, ) - model2.set_next_output([get_text_message("hello")]) + model2.enqueue([get_text_message("hello")]) run_config = RunConfig(input_guardrails=[config_level_check]) result1 = await Runner.run(agent_with_guardrail, "test input") @@ -1714,8 +1702,8 @@ async def config_level_check( assert result2.input_guardrail_results[0].output.output_info == "config_level_passed" assert result1.final_output is not None assert result2.final_output is not None - assert model1.first_turn_args is not None - assert model2.first_turn_args is not None + assert bool(model1.calls) + assert bool(model2.calls) @pytest.mark.asyncio @@ -1760,14 +1748,14 @@ async def slow_guardrail_that_should_be_cancelled( slow_guardrail_cancelled = True raise - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'hello'", input_guardrails=[fast_guardrail_that_triggers, slow_guardrail_that_should_be_cancelled], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) with pytest.raises(InputGuardrailTripwireTriggered): await asyncio.wait_for(Runner.run(agent, "test input"), timeout=5) @@ -1780,9 +1768,7 @@ async def slow_guardrail_that_should_be_cancelled( assert slow_guardrail_executed is False, "Slow guardrail should NOT have completed execution" # Verify agent never started - assert model.first_turn_args is None, ( - "Model should not have been called when guardrail triggered" - ) + assert not model.calls, "Model should not have been called when guardrail triggered" @pytest.mark.asyncio @@ -1827,14 +1813,14 @@ async def slow_guardrail_that_should_be_cancelled( slow_guardrail_cancelled = True raise - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_agent", instructions="Reply with 'hello'", input_guardrails=[fast_guardrail_that_triggers, slow_guardrail_that_should_be_cancelled], model=model, ) - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) result = Runner.run_streamed(agent, "test input") @@ -1853,9 +1839,7 @@ async def consume_stream() -> None: assert slow_guardrail_executed is False, "Slow guardrail should NOT have completed execution" # Verify agent never started - assert model.first_turn_args is None, ( - "Model should not have been called when guardrail triggered" - ) + assert not model.calls, "Model should not have been called when guardrail triggered" @pytest.mark.asyncio @@ -1882,7 +1866,7 @@ async def raising_guardrail( await slow_started.wait() raise RuntimeError("guardrail failed") - agent = Agent(name="test_agent", model=FakeModel()) + agent = Agent(name="test_agent", model=ScriptedModel()) context = RunContextWrapper(context=None) streamed_result = RunResultStreaming( "test input", @@ -2021,7 +2005,7 @@ async def second_fn( ] -def _tripwire_agent(model: FakeModel, *, run_in_parallel: bool) -> Agent[Any]: +def _tripwire_agent(model: ScriptedModel, *, run_in_parallel: bool) -> Agent[Any]: return Agent( name="guardrail_results_agent", model=model, @@ -2039,8 +2023,8 @@ def _result_names(results: list[Any]) -> list[str]: @pytest.mark.parametrize("run_in_parallel", [False, True]) async def test_input_guardrail_tripwire_reports_results(run_in_parallel: bool): """Runner.run() reports every completed guardrail result on the raised tripwire.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) with pytest.raises(InputGuardrailTripwireTriggered) as exc_info: await Runner.run(_tripwire_agent(model, run_in_parallel=run_in_parallel), "test input") @@ -2055,8 +2039,8 @@ async def test_input_guardrail_tripwire_reports_results(run_in_parallel: bool): @pytest.mark.parametrize("run_in_parallel", [False, True]) async def test_input_guardrail_tripwire_reports_results_streamed(run_in_parallel: bool): """The streamed path reports the same results, including on the streamed result object.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) result = Runner.run_streamed( _tripwire_agent(model, run_in_parallel=run_in_parallel), "test input" @@ -2073,8 +2057,8 @@ async def test_input_guardrail_tripwire_reports_results_streamed(run_in_parallel def test_input_guardrail_tripwire_reports_results_sync(): """Runner.run_sync() matches the async entry points.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) with pytest.raises(InputGuardrailTripwireTriggered) as exc_info: Runner.run_sync(_tripwire_agent(model, run_in_parallel=False), "test input") @@ -2087,8 +2071,8 @@ def test_input_guardrail_tripwire_reports_results_sync(): @pytest.mark.asyncio async def test_input_guardrail_results_reported_on_success(): """Passing guardrails still land on the successful result exactly once.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) agent = Agent( name="guardrail_results_agent", model=model, @@ -2134,8 +2118,8 @@ async def test_input_guardrail_exception_reports_completed_results_streamed( run_in_parallel: bool, ): """A streamed guardrail raising a non-tripwire error still reports earlier results.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) agent = Agent( name="guardrail_results_agent", model=model, @@ -2180,7 +2164,7 @@ async def second_fn( ] -def _output_tripwire_agent(model: FakeModel) -> Agent[Any]: +def _output_tripwire_agent(model: ScriptedModel) -> Agent[Any]: return Agent( name="output_guardrail_results_agent", model=model, @@ -2191,8 +2175,8 @@ def _output_tripwire_agent(model: FakeModel) -> Agent[Any]: @pytest.mark.asyncio async def test_output_guardrail_tripwire_reports_results(): """Runner.run() reports every completed output guardrail result on the raised tripwire.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: await Runner.run(_output_tripwire_agent(model), "test input") @@ -2206,8 +2190,8 @@ async def test_output_guardrail_tripwire_reports_results(): @pytest.mark.asyncio async def test_output_guardrail_tripwire_reports_results_streamed(): """The streamed path reports the same results, including on the streamed result object.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) result = Runner.run_streamed(_output_tripwire_agent(model), "test input") with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: @@ -2222,8 +2206,8 @@ async def test_output_guardrail_tripwire_reports_results_streamed(): def test_output_guardrail_tripwire_reports_results_sync(): """Runner.run_sync() matches the async entry points.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: Runner.run_sync(_output_tripwire_agent(model), "test input") @@ -2236,8 +2220,8 @@ def test_output_guardrail_tripwire_reports_results_sync(): @pytest.mark.asyncio async def test_output_guardrail_results_reported_on_success(): """Passing output guardrails still land on the successful result exactly once.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) agent = Agent( name="output_guardrail_results_agent", model=model, @@ -2270,8 +2254,8 @@ async def test_output_guardrail_exception_reports_completed_results(): @pytest.mark.asyncio async def test_output_guardrail_exception_reports_completed_results_streamed(): """A streamed output guardrail raising a non-tripwire error still reports earlier results.""" - model = FakeModel() - model.set_next_output([get_text_message("hello")]) + model = ScriptedModel() + model.enqueue([get_text_message("hello")]) agent = Agent( name="output_guardrail_results_agent", model=model, diff --git a/tests/test_handoff_history_duplication.py b/tests/test_handoff_history_duplication.py index bdd03fe4e7..9c5c5e7bcd 100644 --- a/tests/test_handoff_history_duplication.py +++ b/tests/test_handoff_history_duplication.py @@ -63,8 +63,9 @@ from agents.run_internal.session_persistence import ( resolve_nested_history_owned_session_item_refs, ) +from agents.testing import ScriptedModel -from .fake_model import FakeModel +from .model_test_helpers import get_exact_output_stream_step from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message from .utils.simple_session import SimpleListSession @@ -528,16 +529,14 @@ def test_full_handoff_scenario_no_duplication(self): @pytest.mark.asyncio async def test_to_input_list_normalized_uses_filtered_continuation_after_nested_handoff() -> None: - triage_model = FakeModel() - delegate_model = FakeModel() + triage_model = ScriptedModel() + delegate_model = ScriptedModel() delegate = Agent(name="delegate", model=delegate_model) triage = Agent(name="triage", model=triage_model, handoffs=[delegate]) - triage_model.add_multiple_turn_outputs( - [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]] - ) - delegate_model.add_multiple_turn_outputs( + triage_model.extend([[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]) + delegate_model.extend( [ [get_text_message("resolution")], [get_text_message("followup answer")], @@ -567,13 +566,13 @@ async def test_to_input_list_normalized_uses_filtered_continuation_after_nested_ assert "function_call" not in normalized_types assert "function_call_output" not in normalized_types - replay_model = FakeModel() + replay_model = ScriptedModel() replay_agent = Agent(name="replay", model=replay_model) - replay_model.add_multiple_turn_outputs([[get_text_message("replayed")]]) + replay_model.extend([[get_text_message("replayed")]]) replay_result = await Runner.run(replay_agent, input=preserve_all_input) - assert replay_model.first_turn_args is not None - replay_input = replay_model.first_turn_args["input"] + assert bool(replay_model.calls) + replay_input = replay_model.calls[0].input assert isinstance(replay_input, list) assert sum(_input_item_text(item) == "triage summary" for item in replay_input) == 1 assert replay_result.final_output == "replayed" @@ -582,7 +581,7 @@ async def test_to_input_list_normalized_uses_filtered_continuation_after_nested_ follow_up_result = await Runner.run(delegate, input=follow_up_input) assert follow_up_result.final_output == "followup answer" - assert delegate_model.last_turn_args["input"] == follow_up_input + assert delegate_model.calls[-1].input == follow_up_input @pytest.mark.asyncio @@ -590,8 +589,8 @@ async def test_to_input_list_normalized_keeps_delegate_tool_items_after_nested_h async def lookup_weather(city: str) -> str: return f"weather:{city}" - triage_model = FakeModel() - delegate_model = FakeModel() + triage_model = ScriptedModel() + delegate_model = ScriptedModel() delegate = Agent( name="delegate", @@ -600,10 +599,8 @@ async def lookup_weather(city: str) -> str: ) triage = Agent(name="triage", model=triage_model, handoffs=[delegate]) - triage_model.add_multiple_turn_outputs( - [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]] - ) - delegate_model.add_multiple_turn_outputs( + triage_model.extend([[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]) + delegate_model.extend( [ [ get_text_message("delegate preamble"), @@ -659,8 +656,8 @@ def keep_messages_only(data: HandoffInputData) -> HandoffInputData: ) ) - triage_model = FakeModel() - delegate_model = FakeModel() + triage_model = ScriptedModel() + delegate_model = ScriptedModel() delegate = Agent(name="delegate", model=delegate_model) triage = Agent( @@ -669,10 +666,8 @@ def keep_messages_only(data: HandoffInputData) -> HandoffInputData: handoffs=[handoff(delegate, input_filter=keep_messages_only)], ) - triage_model.add_multiple_turn_outputs( - [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]] - ) - delegate_model.add_multiple_turn_outputs([[get_text_message("resolution")]]) + triage_model.extend([[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]) + delegate_model.extend([[get_text_message("resolution")]]) result = await Runner.run(triage, input="user_question") preserve_all_input = result.to_input_list() @@ -702,18 +697,16 @@ async def test_non_nested_filtered_handoff_does_not_add_occurrence_lineage( def identity_filter(data: HandoffInputData) -> HandoffInputData: return data - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=identity_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) if streamed: streamed_result = Runner.run_streamed(first_agent, input="start") @@ -745,18 +738,16 @@ def custom_filter(data: HandoffInputData) -> HandoffInputData: input_items=(), ) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=custom_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run(first_agent, input="start") @@ -766,14 +757,12 @@ def custom_filter(data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_wrapper_reset_does_not_change_nested_history_ownership() -> None: """Replay ownership must not depend on wrappers that are current after the run.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) set_conversation_history_wrappers(start="<>", end="<>") try: @@ -797,18 +786,16 @@ def chained_filter(data: HandoffInputData) -> HandoffInputData: return remove_all_tools(nest_handoff_history(data)) input_filter = chained_filter if chained else nest_handoff_history - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=input_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run(first_agent, input="start") @@ -824,18 +811,16 @@ def copied_filter(data: HandoffInputData) -> HandoffInputData: assert not isinstance(nested.input_history, str) return nested.clone(input_history=deepcopy(nested.input_history)) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=copied_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run(first_agent, input="start") @@ -856,18 +841,16 @@ def rebuilt_filter(data: HandoffInputData) -> HandoffInputData: ) ) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=rebuilt_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run(first_agent, input="start") @@ -891,18 +874,16 @@ def inserting_filter(data: HandoffInputData) -> HandoffInputData: ) return nested.clone(input_history=(inserted, *rebuilt_history)) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=inserting_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) if streamed: streamed_result = Runner.run_streamed(first_agent, input="start") @@ -930,18 +911,16 @@ def rebuilt_filter(data: HandoffInputData) -> HandoffInputData: input_items=nested.input_items, ) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=rebuilt_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run(first_agent, input="start") @@ -956,18 +935,16 @@ def copied_filter(data: HandoffInputData) -> HandoffInputData: nested = nest_handoff_history(data) return nested.clone(new_items=deepcopy(nested.new_items)) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=copied_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run(first_agent, input="start") @@ -987,18 +964,16 @@ def replacement_filter(data: HandoffInputData) -> HandoffInputData: ) return nested.clone(new_items=(replacement, *nested.new_items[1:])) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=replacement_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run(first_agent, input="start") @@ -1060,18 +1035,16 @@ def duplicate_message_filter(data: HandoffInputData) -> HandoffInputData: message = data.new_items[0] return nest_handoff_history(data.clone(new_items=(message, message, *data.new_items[1:]))) - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", model=first_model, handoffs=[handoff(second_agent, input_filter=duplicate_message_filter)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) if streamed: streamed_result = Runner.run_streamed(first_agent, input="start") @@ -1091,8 +1064,8 @@ async def test_nested_history_retains_forwarded_pre_handoff_item_provenance( streamed: bool, ) -> None: """Lossless items from earlier turns must retain one replay occurrence.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) tool_search_call = ResponseToolSearchCall( @@ -1111,13 +1084,14 @@ async def test_nested_history_retains_forwarded_pre_handoff_item_provenance( tools=[], type="tool_search_output", ) - first_model.add_multiple_turn_outputs( + first_output = [tool_search_call, tool_search_output] + first_model.extend( [ - [tool_search_call, tool_search_output], + get_exact_output_stream_step(first_output) if streamed else first_output, [get_handoff_tool_call(second_agent)], ] ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + second_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=True) run_result: RunResult | RunResultStreaming @@ -1156,14 +1130,12 @@ async def test_nested_history_retains_forwarded_pre_handoff_item_provenance( @pytest.mark.asyncio async def test_to_input_list_during_active_stream_does_not_mutate_input() -> None: """Inspecting an active stream must not mutate its eventual public input.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = Runner.run_streamed( first_agent, @@ -1186,14 +1158,12 @@ async def test_to_input_list_during_active_stream_does_not_mutate_input() -> Non @pytest.mark.asyncio async def test_nested_history_ownership_remaps_after_new_items_insertion() -> None: """A caller inserting a public new_items entry must not make ownership drop the new item.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run( first_agent, @@ -1210,14 +1180,12 @@ async def test_nested_history_ownership_remaps_after_new_items_insertion() -> No @pytest.mark.asyncio async def test_nested_history_input_removal_does_not_claim_an_unmarked_equal_occurrence() -> None: """An equal replacement input must not retain ownership of the removed occurrence.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run( first_agent, @@ -1237,14 +1205,12 @@ async def test_nested_history_input_removal_does_not_claim_an_unmarked_equal_occ @pytest.mark.asyncio async def test_nested_history_new_item_removal_does_not_claim_an_equal_item() -> None: """Removing the owned RunItem must not transfer ownership to an equal RunItem.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run( first_agent, @@ -1272,14 +1238,12 @@ async def test_nested_history_new_item_removal_does_not_claim_an_equal_item() -> @pytest.mark.asyncio async def test_nested_history_ownership_survives_result_new_items_copy() -> None: """Copying result run items must not replay a nested session occurrence twice.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run( first_agent, @@ -1299,14 +1263,12 @@ async def test_nested_history_input_copy_does_not_infer_occurrence_ownership( streamed: bool, ) -> None: """An unmarked public-input copy must remain distinct from its session occurrence.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result: RunResult | RunResultStreaming if streamed: @@ -1341,14 +1303,12 @@ async def test_nested_history_input_copy_and_reorder_does_not_infer_ownership( streamed: bool, ) -> None: """Payload equality must not transfer ownership after a copied input reorder.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("owned once"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("owned once"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=True) result: RunResult | RunResultStreaming @@ -1380,14 +1340,12 @@ async def test_result_input_mutation_does_not_change_state_snapshot_ownership( def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model, tools=[approval_tool]) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("owned once"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs( + first_model.extend([[get_text_message("owned once"), get_handoff_tool_call(second_agent)]]) + second_model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="approval")], [get_text_message("done")], @@ -1438,14 +1396,12 @@ def approval_tool() -> str: @pytest.mark.asyncio async def test_nested_history_ownership_revalidates_after_input_removal() -> None: """Removing an owned input occurrence must restore its session copy during replay.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) result = await Runner.run( first_agent, @@ -1566,9 +1522,9 @@ def test_nested_history_normalizes_forwarded_status_before_ownership() -> None: @pytest.mark.asyncio async def test_plain_handoff_preserves_prior_nested_history_ownership(streamed: bool) -> None: """A later non-nesting handoff must not clear ownership established by an earlier handoff.""" - first_model = FakeModel() - second_model = FakeModel() - final_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() + final_model = ScriptedModel() final_agent = Agent(name="final", model=final_model) second_agent = Agent( name="second", @@ -1576,13 +1532,9 @@ async def test_plain_handoff_preserves_prior_nested_history_ownership(streamed: handoffs=[handoff(final_agent, nest_handoff_history=False)], ) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("first message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs( - [[get_text_message("second message"), get_handoff_tool_call(final_agent)]] - ) - final_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("first message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("second message"), get_handoff_tool_call(final_agent)]]) + final_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=True) if streamed: @@ -1609,9 +1561,9 @@ def copy_history(data: HandoffInputData) -> HandoffInputData: return data return data.clone(input_history=deepcopy(data.input_history)) - first_model = FakeModel() - second_model = FakeModel() - final_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() + final_model = ScriptedModel() final_agent = Agent(name="final", model=final_model) second_agent = Agent( name="second", @@ -1625,13 +1577,9 @@ def copy_history(data: HandoffInputData) -> HandoffInputData: ], ) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("first message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs( - [[get_text_message("second message"), get_handoff_tool_call(final_agent)]] - ) - final_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("first message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("second message"), get_handoff_tool_call(final_agent)]]) + final_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=True) if streamed: @@ -1660,9 +1608,9 @@ async def test_copied_custom_input_items_keep_session_occurrence_for_later_nesti def copy_model_items(data: HandoffInputData) -> HandoffInputData: return data.clone(input_items=deepcopy(data.new_items)) - first_model = FakeModel() - second_model = FakeModel() - final_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() + final_model = ScriptedModel() final_agent = Agent(name="final", model=final_model) second_agent = Agent(name="second", model=second_model, handoffs=[final_agent]) first_agent = Agent( @@ -1670,11 +1618,9 @@ def copy_model_items(data: HandoffInputData) -> HandoffInputData: model=first_model, handoffs=[handoff(second_agent, input_filter=copy_model_items)], ) - first_model.add_multiple_turn_outputs( - [[get_text_message("copied once"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_handoff_tool_call(final_agent)]]) - final_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("copied once"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_handoff_tool_call(final_agent)]]) + final_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=True) if streamed: @@ -1697,9 +1643,9 @@ async def test_identity_filter_preserves_prior_nested_history_ownership(streamed def identity_filter(data: HandoffInputData) -> HandoffInputData: return data - first_model = FakeModel() - second_model = FakeModel() - final_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() + final_model = ScriptedModel() final_agent = Agent(name="final", model=final_model) second_agent = Agent( name="second", @@ -1707,13 +1653,9 @@ def identity_filter(data: HandoffInputData) -> HandoffInputData: handoffs=[handoff(final_agent, input_filter=identity_filter)], ) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("first message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs( - [[get_text_message("second message"), get_handoff_tool_call(final_agent)]] - ) - final_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("first message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("second message"), get_handoff_tool_call(final_agent)]]) + final_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=True) if streamed: @@ -1739,9 +1681,9 @@ async def test_nested_handoff_history_preserves_identical_messages_across_turns( def continue_work() -> str: return "continue" - first_model = FakeModel() - second_model = FakeModel() - final_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() + final_model = ScriptedModel() final_agent = Agent(name="final", model=final_model) second_agent = Agent( name="second", @@ -1751,16 +1693,14 @@ def continue_work() -> str: ) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("same"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs( + first_model.extend([[get_text_message("same"), get_handoff_tool_call(second_agent)]]) + second_model.extend( [ [get_text_message("same"), get_function_tool_call("continue_work", "{}")], [get_text_message("same"), get_handoff_tool_call(final_agent)], ] ) - final_model.add_multiple_turn_outputs([[get_text_message("same")]]) + final_model.extend([[get_text_message("same")]]) if streamed: streamed_result = Runner.run_streamed( @@ -1779,7 +1719,7 @@ def continue_work() -> str: ) replay_input = result.to_input_list() - final_input = final_model.last_turn_args["input"] + final_input = final_model.calls[-1].input summary = str(cast(dict[str, Any], final_input[0])["content"]) assert summary.count("same") == 2 assert sum(_input_item_text(item) == "same" for item in replay_input) == 4 @@ -1788,15 +1728,13 @@ def continue_work() -> str: @pytest.mark.asyncio async def test_explicit_default_handoff_history_mapper_is_honored() -> None: """An explicitly configured mapper should own the exact model input.""" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) - first_model.add_multiple_turn_outputs( - [[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + first_model.extend([[get_text_message("handoff message"), get_handoff_tool_call(second_agent)]]) + second_model.extend([[get_text_message("done")]]) await Runner.run( first_agent, @@ -1807,8 +1745,8 @@ async def test_explicit_default_handoff_history_mapper_is_honored() -> None: ), ) - assert second_model.first_turn_args is not None - second_input = second_model.first_turn_args["input"] + assert bool(second_model.calls) + second_input = second_model.calls[0].input assert isinstance(second_input, list) assert len(second_input) == 1 summary = str(cast(dict[str, Any], second_input[0])["content"]) @@ -1824,9 +1762,9 @@ async def test_nested_handoff_history_partition_survives_interruption_resume() - def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() - final_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() + final_model = ScriptedModel() final_agent = Agent(name="final", model=final_model) second_agent = Agent( name="second", @@ -1837,16 +1775,14 @@ def approval_tool() -> str: first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) run_config = RunConfig(nest_handoff_history=True) - first_model.add_multiple_turn_outputs( - [[get_text_message("once"), get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs( + first_model.extend([[get_text_message("once"), get_handoff_tool_call(second_agent)]]) + second_model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="approval")], [get_text_message("once"), get_handoff_tool_call(final_agent)], ] ) - final_model.add_multiple_turn_outputs([[get_text_message("done")]]) + final_model.extend([[get_text_message("done")]]) interrupted = await Runner.run(first_agent, input="start", run_config=run_config) assert len(interrupted.interruptions) == 1 @@ -1870,7 +1806,7 @@ def approval_tool() -> str: resumed = await Runner.run(first_agent, restored, run_config=run_config) assert resumed.final_output == "done" - final_input = final_model.last_turn_args["input"] + final_input = final_model.calls[-1].input summary = str(cast(dict[str, Any], final_input[0])["content"]) assert summary.count("once") == 1 assert sum(_input_item_text(item) == "once" for item in resumed.to_input_list()) == 2 @@ -1893,9 +1829,9 @@ async def test_pending_handoff_in_interrupted_turn_survives_run_state( def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() - final_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() + final_model = ScriptedModel() final_agent = Agent(name="final", model=final_model) second_agent = Agent( name="second", @@ -1910,8 +1846,8 @@ def approval_tool() -> str: first_handoff.call_id = "first-handoff" final_handoff = cast(ResponseFunctionToolCall, get_handoff_tool_call(final_agent)) final_handoff.call_id = "final-handoff" - first_model.add_multiple_turn_outputs([[get_text_message("first once"), first_handoff]]) - second_model.add_multiple_turn_outputs( + first_model.extend([[get_text_message("first once"), first_handoff]]) + second_model.extend( [ [ get_text_message("second once"), @@ -1920,7 +1856,7 @@ def approval_tool() -> str: ] ] ) - final_model.add_multiple_turn_outputs([[get_text_message("done")]]) + final_model.extend([[get_text_message("done")]]) interrupted: RunResult | RunResultStreaming if streamed: @@ -1964,7 +1900,7 @@ def approval_tool() -> str: == 1 ) if nest_handoff_history: - final_input = final_model.last_turn_args["input"] + final_input = final_model.calls[-1].input summary = str(cast(dict[str, Any], final_input[0])["content"]) assert summary.count("first once") == 1 assert summary.count("second once") == 1 @@ -2008,8 +1944,8 @@ async def on_handoff( nonlocal handoff_count handoff_count += 1 - source_model = FakeModel() - target_model = FakeModel() + source_model = ScriptedModel() + target_model = ScriptedModel() target_agent = Agent(name="target", model=target_model) source_agent = Agent( name="source", @@ -2030,8 +1966,8 @@ async def on_handoff( second_call.id = "item-second" handoff_call.id = "item-handoff" handoff_call.call_id = "handoff" - source_model.add_multiple_turn_outputs([[first_call, second_call, handoff_call]]) - target_model.add_multiple_turn_outputs([[get_text_message("done")]]) + source_model.extend([[first_call, second_call, handoff_call]]) + target_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=nest_handoff_history) hooks = RecordingHooks() @@ -2152,8 +2088,8 @@ async def test_nested_history_resume_to_final_preserves_status_less_ownership( def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model, tools=[approval_tool]) first_agent = Agent(name="first", model=first_model, handoffs=[second_agent]) run_config = RunConfig(nest_handoff_history=True) @@ -2164,10 +2100,8 @@ def approval_tool() -> str: status=None, type="message", ) - first_model.add_multiple_turn_outputs( - [[status_less_message, get_handoff_tool_call(second_agent)]] - ) - second_model.add_multiple_turn_outputs( + first_model.extend([[status_less_message, get_handoff_tool_call(second_agent)]]) + second_model.extend( [ [get_function_tool_call("approval_tool", "{}", call_id="approval")], [get_text_message("done")], @@ -2211,10 +2145,8 @@ def approval_tool() -> str: assert final_output == "done" assert sum(_input_item_text(item) == "once" for item in replay_input) == 1 assert all("_agents_nested_history_token" not in item for item in replay_input) - assert second_model.last_turn_args is not None - assert all( - "_agents_nested_history_token" not in item for item in second_model.last_turn_args["input"] - ) + assert bool(second_model.calls) + assert all("_agents_nested_history_token" not in item for item in second_model.calls[-1].input) @pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"]) @@ -2234,8 +2166,8 @@ async def test_first_nested_handoff_after_restore_uses_explicit_occurrence_linea def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() second_agent = Agent(name="second", model=second_model) first_agent = Agent( name="first", @@ -2259,17 +2191,18 @@ def approval_tool() -> str: tools=[], type="tool_search_output", ) - first_model.add_multiple_turn_outputs( + first_output = [ + tool_search_call, + tool_search_output, + get_function_tool_call("approval_tool", "{}", call_id="approval"), + ] + first_model.extend( [ - [ - tool_search_call, - tool_search_output, - get_function_tool_call("approval_tool", "{}", call_id="approval"), - ], + get_exact_output_stream_step(first_output) if streamed else first_output, [get_handoff_tool_call(second_agent)], ] ) - second_model.add_multiple_turn_outputs([[get_text_message("done")]]) + second_model.extend([[get_text_message("done")]]) run_config = RunConfig(nest_handoff_history=True) interrupted: RunResult | RunResultStreaming diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index 2d0e573c35..d8518088cf 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -73,6 +73,7 @@ execute_mcp_approval_requests, ) from agents.run_state import RunState as RunStateClass +from agents.testing import ScriptedModel from agents.tool import FunctionTool, HostedMCPTool from agents.tool_guardrails import ( ToolGuardrailFunctionOutput, @@ -83,7 +84,6 @@ ) from agents.usage import Usage -from .fake_model import FakeModel from .mcp.helpers import FakeMCPServer from .test_responses import get_text_message from .utils.hitl import ( @@ -321,12 +321,12 @@ async def test_nested_agent_tool_resumes_after_rejection() -> None: async def inner_hitl_tool() -> str: return "ok" - inner_model = FakeModel() + inner_model = ScriptedModel() inner_agent = Agent(name="Inner", model=inner_model, tools=[inner_hitl_tool]) inner_call_first = make_function_tool_call(inner_hitl_tool.name, call_id="inner-1") inner_call_retry = make_function_tool_call(inner_hitl_tool.name, call_id="inner-2") inner_final = get_text_message("done") - inner_model.add_multiple_turn_outputs( + inner_model.extend( [ [inner_call_first], [inner_call_retry], @@ -340,12 +340,12 @@ async def inner_hitl_tool() -> str: needs_approval=True, ) - outer_model = FakeModel() + outer_model = ScriptedModel() outer_agent = Agent(name="Outer", model=outer_model, tools=[agent_tool]) outer_call = make_function_tool_call( agent_tool.name, call_id="outer-1", arguments='{"input":"hi"}' ) - outer_model.add_multiple_turn_outputs([[outer_call]]) + outer_model.extend([[outer_call]]) first = await Runner.run(outer_agent, "start") assert first.interruptions, "agent tool should request approval first" @@ -391,23 +391,23 @@ async def inner_hitl_tool() -> str: async def observer() -> str: return "unused" - inner_model = FakeModel() - inner_model.add_multiple_turn_outputs( - [[make_function_tool_call(inner_hitl_tool.name, call_id="inner-1")]] - ) + inner_model = ScriptedModel() + inner_model.extend([[make_function_tool_call(inner_hitl_tool.name, call_id="inner-1")]]) inner_agent = Agent(name="Inner", model=inner_model, tools=[inner_hitl_tool]) agent_tool = inner_agent.as_tool( tool_name="inner_agent_tool", tool_description="Inner agent tool with HITL", needs_approval=True, ) - outer_model = FakeModel( - initial_output=[ - make_function_tool_call( - agent_tool.name, - call_id="outer-1", - arguments='{"input":"safe"}', - ) + outer_model = ScriptedModel( + steps=[ + [ + make_function_tool_call( + agent_tool.name, + call_id="outer-1", + arguments='{"input":"safe"}', + ) + ] ] ) outer_agent = Agent(name="Outer", model=outer_model, tools=[agent_tool, observer]) @@ -438,9 +438,9 @@ async def test_nested_agent_tool_interruptions_remain_distinct_across_outer_call async def inner_hitl_tool() -> str: return "ok" - inner_model = FakeModel() + inner_model = ScriptedModel() inner_agent = Agent(name="Inner", model=inner_model, tools=[inner_hitl_tool]) - inner_model.add_multiple_turn_outputs( + inner_model.extend( [ [make_function_tool_call(inner_hitl_tool.name, call_id="inner-1")], [make_function_tool_call(inner_hitl_tool.name, call_id="inner-2")], @@ -453,9 +453,9 @@ async def inner_hitl_tool() -> str: needs_approval=False, ) - outer_model = FakeModel() + outer_model = ScriptedModel() outer_agent = Agent(name="Outer", model=outer_model, tools=[agent_tool]) - outer_model.add_multiple_turn_outputs( + outer_model.extend( [ [ make_function_tool_call( @@ -488,11 +488,9 @@ async def outer_shared_tool() -> str: async def inner_shared_tool() -> str: return "inner" - inner_model = FakeModel() + inner_model = ScriptedModel() inner_agent = Agent(name="Inner", model=inner_model, tools=[inner_shared_tool]) - inner_model.add_multiple_turn_outputs( - [[make_function_tool_call(inner_shared_tool.name, call_id="dup")]] - ) + inner_model.extend([[make_function_tool_call(inner_shared_tool.name, call_id="dup")]]) agent_tool = inner_agent.as_tool( tool_name="inner_agent_tool", @@ -500,9 +498,9 @@ async def inner_shared_tool() -> str: needs_approval=False, ) - outer_model = FakeModel() + outer_model = ScriptedModel() outer_agent = Agent(name="Outer", model=outer_model, tools=[outer_shared_tool, agent_tool]) - outer_model.add_multiple_turn_outputs( + outer_model.extend( [ [make_function_tool_call(outer_shared_tool.name, call_id="dup")], [ @@ -568,7 +566,7 @@ async def test_resume_does_not_duplicate_pending_shell_approvals() -> None: call_id = extract_tool_call_id(raw_call) assert call_id, "shell call must have a call_id" - model.set_next_output([raw_call]) + model.enqueue([raw_call]) first = await Runner.run(agent, "run shell") assert first.interruptions, "shell tool should require approval" @@ -626,7 +624,8 @@ def local_executor(request: Any) -> str: action={"type": "exec", "command": ["echo", "test"], "env": {}}, # type: ignore[arg-type] status="in_progress", ) - model.set_next_output([local_shell_call]) + model.enqueue([local_shell_call]) + model.enqueue([]) await Runner.run(agent, "run local shell") @@ -654,7 +653,7 @@ async def test_tool() -> str: tool = function_tool(test_tool, needs_approval=require_approval) model, agent = make_model_and_agent(tools=[tool]) - model.add_multiple_turn_outputs([[make_function_tool_call("test_tool", call_id="call-1")]]) + model.extend([[make_function_tool_call("test_tool", call_id="call-1")]]) result1 = await Runner.run(agent, "call test_tool", max_turns=20) assert result1.interruptions, "should have an interruption" @@ -662,7 +661,7 @@ async def test_tool() -> str: state = approve_first_interruption(result1, always_approve=True) # Provide 10 more turns (turns 2-11) to ensure we exceed the default 10 but not 20. - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message(f"turn {i + 2}"), # Text message first (doesn't finish) @@ -671,6 +670,7 @@ async def test_tool() -> str: for i in range(10) ] ) + model.enqueue([]) result2 = await Runner.run(agent, state) assert result2 is not None, "Run should complete successfully with max_turns=20 from state" @@ -687,7 +687,7 @@ async def test_tool() -> str: model, agent = make_model_and_agent(tools=[tool]) # Model emits a tool call requiring approval - model.set_next_output([make_function_tool_call("test_tool", call_id="call-1")]) + model.enqueue([make_function_tool_call("test_tool", call_id="call-1")]) # First turn with interruption result1 = await Runner.run(agent, "call test_tool") @@ -859,7 +859,7 @@ async def test_preserve_persisted_item_counter_when_resuming_streamed_runs(): ] # Set up model to return final output immediately (so the run completes) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) result = Runner.run_streamed(agent, state) @@ -910,7 +910,7 @@ def bad_tool() -> str: return "ok" model, agent = make_model_and_agent(tools=[bad_tool]) - model.set_next_output([make_function_tool_call("bad_tool")]) + model.enqueue([make_function_tool_call("bad_tool")]) with pytest.raises(UserError, match="needs_approval"): await Runner.run(agent, "run invalid") @@ -951,9 +951,7 @@ async def invoke_tool(_ctx: Any, raw_arguments: str) -> str: needs_approval=needs_approval, ) model, agent = make_model_and_agent(tools=[tool]) - model.set_next_output( - [make_function_tool_call(tool.name, arguments=arguments, call_id="call-invalid")] - ) + model.enqueue([make_function_tool_call(tool.name, arguments=arguments, call_id="call-invalid")]) result = await Runner.run(agent, "send an email") @@ -986,7 +984,7 @@ async def invoke_tool(_ctx: Any, raw_arguments: str) -> str: ) arguments = '{"subject": "status update"}' model, agent = make_model_and_agent(tools=[tool]) - model.add_multiple_turn_outputs( + model.extend( [ [make_function_tool_call(tool.name, arguments=arguments, call_id="call-valid")], [get_text_message("done")], @@ -1058,7 +1056,7 @@ async def get_current_timestamp() -> str: spanish_agent.tools = [get_current_timestamp] # Spanish agent will first request timestamp, then return text. - nested_model.add_multiple_turn_outputs( + nested_model.extend( [ [make_function_tool_call("get_current_timestamp")], [get_text_message("hola")], @@ -1066,7 +1064,7 @@ async def get_current_timestamp() -> str: ) # Orchestrator model will call the spanish agent tool. - orchestrator_model = FakeModel() + orchestrator_model = ScriptedModel() orchestrator = Agent( name="orchestrator", tools=[ @@ -1079,7 +1077,7 @@ async def get_current_timestamp() -> str: model=orchestrator_model, ) - orchestrator_model.add_multiple_turn_outputs( + orchestrator_model.extend( [ [ make_function_tool_call( @@ -1132,7 +1130,7 @@ async def inner_tool() -> str: return "inner output" nested_agent.tools = [inner_tool] - nested_model.add_multiple_turn_outputs( + nested_model.extend( [ [ make_function_tool_call( @@ -1171,8 +1169,8 @@ async def extract_custom_output(result: Any) -> str: outer_tool.tool_output_guardrails = [track_output] outer_tool.custom_data_extractor = extract_custom_data - outer_model = FakeModel() - outer_model.add_multiple_turn_outputs( + outer_model = ScriptedModel() + outer_model.extend( [ [ make_function_tool_call( @@ -1789,9 +1787,9 @@ async def needs_approval(_ctx: Any, _args: dict[str, Any], call_id: str) -> bool async def sensitive(value: str) -> str: return f"ran:{value}" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="agent", model=model, tools=[sensitive]) - model.add_multiple_turn_outputs( + model.extend( [ [make_function_tool_call(sensitive.name, call_id="call-1", arguments='{"value":"x"}')], [get_text_message("done")], @@ -1827,14 +1825,14 @@ async def sensitive(value: str) -> str: def failing_behavior(_ctx: Any, _results: Any) -> Any: raise RuntimeError("tool use behavior failed") - model = FakeModel() + model = ScriptedModel() agent = Agent( name="agent", model=model, tools=[sensitive], tool_use_behavior=failing_behavior, ) - model.add_multiple_turn_outputs( + model.extend( [ [make_function_tool_call(sensitive.name, call_id="call-1", arguments='{"value":"x"}')], [get_text_message("done")], diff --git a/tests/test_hitl_session_scenario.py b/tests/test_hitl_session_scenario.py index c7b3ab579d..4d7013424d 100644 --- a/tests/test_hitl_session_scenario.py +++ b/tests/test_hitl_session_scenario.py @@ -1,24 +1,19 @@ from __future__ import annotations -import json -from collections.abc import AsyncIterator from dataclasses import dataclass from typing import Any, cast import pytest -from openai.types.responses import ResponseFunctionToolCall from agents import ( Agent, - Model, - ModelResponse, ModelSettings, OpenAIConversationsSession, Runner, - Usage, function_tool, ) -from agents.items import TResponseInputItem, TResponseStreamEvent +from agents.items import TResponseInputItem +from agents.testing import ModelCall, ModelStep, ScriptedModel, function_call from tests.test_responses import get_text_message from tests.utils.hitl import HITL_REJECTION_MSG from tests.utils.simple_session import SimpleListSession @@ -69,67 +64,32 @@ class ScenarioResult: items: list[TResponseInputItem] -class ScenarioModel(Model): - def __init__(self) -> None: - self._counter = 0 - - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Any], - output_schema: Any, - handoffs: list[Any], - tracing: Any, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> ModelResponse: - if input_has_rejection(input): - return ModelResponse( - output=[get_text_message(HITL_REJECTION_MSG)], - usage=Usage(), - response_id="resp-test", - ) - tool_choice = model_settings.tool_choice +def make_scenario_model() -> ScriptedModel: + call_counter = 0 + + def respond(call: ModelCall): + nonlocal call_counter + if input_has_rejection(call.input): + return [get_text_message(HITL_REJECTION_MSG)] + tool_choice = call.model_settings.tool_choice tool_name = tool_choice if isinstance(tool_choice, str) else TOOL_ECHO - self._counter += 1 - call_id = f"call_{self._counter}" - query = extract_user_message(input) - tool_call = ResponseFunctionToolCall( - type="function_call", - name=tool_name, - call_id=call_id, - arguments=json.dumps({"query": query}), - ) - return ModelResponse(output=[tool_call], usage=Usage(), response_id="resp-test") - - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Any], - output_schema: Any, - handoffs: list[Any], - tracing: Any, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> AsyncIterator[TResponseStreamEvent]: - if False: - yield cast(TResponseStreamEvent, {}) - raise RuntimeError("Streaming is not supported in this scenario.") + call_counter += 1 + return [ + function_call( + tool_name, + {"query": extract_user_message(call.input)}, + call_id=f"call_{call_counter}", + ) + ] + + return ScriptedModel([ModelStep.respond(respond) for _ in range(4)]) @pytest.mark.asyncio async def test_memory_session_hitl_scenario() -> None: execute_counts.clear() session = SimpleListSession(session_id="memory") - model = ScenarioModel() + model = make_scenario_model() steps = [ ScenarioStep( @@ -233,7 +193,7 @@ class Client: rehydrated_session = OpenAIConversationsSession( conversation_id="conv_test", openai_client=typed_client ) - model = ScenarioModel() + model = make_scenario_model() steps = [ ScenarioStep( @@ -280,7 +240,7 @@ class Client: async def run_scenario_step( session: Any, - model: ScenarioModel, + model: ScriptedModel, step: ScenarioStep, ) -> ScenarioResult: agent = Agent( diff --git a/tests/test_invalid_final_output_handler.py b/tests/test_invalid_final_output_handler.py index 667d9a746d..68ef8c2a1e 100644 --- a/tests/test_invalid_final_output_handler.py +++ b/tests/test_invalid_final_output_handler.py @@ -27,8 +27,8 @@ ) from agents.items import TResponseInputItem, TResponseOutputItem from agents.stream_events import RunItemStreamEvent +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_function_tool_call, get_text_message from .utils.simple_session import SimpleListSession @@ -62,7 +62,7 @@ def _message_texts(items: list[TResponseInputItem]) -> list[str]: @pytest.mark.asyncio async def test_invalid_final_output_raises_without_handler() -> None: - model = FakeModel(initial_output=[get_text_message("not valid json")]) + model = ScriptedModel(steps=[[get_text_message("not valid json")]]) agent = Agent(name="test", model=model, output_type=FinalOutput) with pytest.raises(ModelBehaviorError, match="Invalid JSON"): @@ -71,7 +71,7 @@ async def test_invalid_final_output_raises_without_handler() -> None: @pytest.mark.asyncio async def test_invalid_final_output_handler_returns_validated_fallback() -> None: - model = FakeModel(initial_output=[get_text_message("not valid json")]) + model = ScriptedModel(steps=[[get_text_message("not valid json")]]) agent = Agent(name="test", model=model, output_type=FinalOutput) def handler(data: RunErrorHandlerInput[None]) -> FinalOutput: @@ -96,7 +96,7 @@ def handler(data: RunErrorHandlerInput[None]) -> FinalOutput: @pytest.mark.asyncio async def test_invalid_final_output_handler_can_skip_fallback_history() -> None: - model = FakeModel(initial_output=[get_text_message("not valid json")]) + model = ScriptedModel(steps=[[get_text_message("not valid json")]]) agent = Agent(name="test", model=model, output_type=FinalOutput) result = await Runner.run( @@ -119,7 +119,7 @@ async def test_invalid_final_output_handler_rejects_invalid_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) - model = FakeModel(initial_output=[get_text_message("not valid json")]) + model = ScriptedModel(steps=[[get_text_message("not valid json")]]) agent = Agent(name="test", model=model, output_type=FinalOutput) with pytest.warns(UserWarning, match="Pydantic serializer warnings"): @@ -133,7 +133,7 @@ async def test_invalid_final_output_handler_rejects_invalid_fallback( @pytest.mark.asyncio async def test_invalid_final_output_handler_can_decline_recovery() -> None: - model = FakeModel(initial_output=[get_text_message("not valid json")]) + model = ScriptedModel(steps=[[get_text_message("not valid json")]]) agent = Agent(name="test", model=model, output_type=FinalOutput) with pytest.raises(ModelBehaviorError, match="Invalid JSON"): @@ -146,7 +146,7 @@ async def test_invalid_final_output_handler_can_decline_recovery() -> None: @pytest.mark.asyncio async def test_invalid_final_output_handler_does_not_catch_other_model_behavior_errors() -> None: - model = FakeModel(initial_output=[get_function_tool_call("missing_tool")]) + model = ScriptedModel(steps=[[get_function_tool_call("missing_tool")]]) agent = Agent(name="test", model=model, output_type=FinalOutput) handler_called = False @@ -170,8 +170,8 @@ def handler(_data: RunErrorHandlerInput[None]) -> FinalOutput: async def test_empty_structured_output_handler_avoids_another_model_turn( invalid_output: list[TResponseOutputItem], ) -> None: - model = FakeModel() - model.add_multiple_turn_outputs([invalid_output, [get_text_message('{"summary":"unused"}')]]) + model = ScriptedModel() + model.extend([invalid_output, [get_text_message('{"summary":"unused"}')]]) agent = Agent(name="test", model=model, output_type=FinalOutput) def handler(data: RunErrorHandlerInput[None]) -> FinalOutput: @@ -188,7 +188,7 @@ def handler(data: RunErrorHandlerInput[None]) -> FinalOutput: ) assert result.final_output == FinalOutput(summary="safe fallback") - assert len(model.turn_outputs) == 1 + assert model.remaining_steps == 1 @pytest.mark.asyncio @@ -199,19 +199,19 @@ def handler(data: RunErrorHandlerInput[None]) -> FinalOutput: async def test_empty_structured_output_without_fallback_keeps_existing_next_turn_behavior( error_handlers: RunErrorHandlers[None] | None, ) -> None: - model = FakeModel() - model.add_multiple_turn_outputs([[], [get_text_message('{"summary":"second turn"}')]]) + model = ScriptedModel() + model.extend([[], [get_text_message('{"summary":"second turn"}')]]) agent = Agent(name="test", model=model, output_type=FinalOutput) result = await Runner.run(agent, input="user_message", error_handlers=error_handlers) assert result.final_output == FinalOutput(summary="second turn") - assert not model.turn_outputs + assert model.remaining_steps == 0 @pytest.mark.asyncio async def test_streamed_invalid_final_output_emits_exact_fallback_item() -> None: - model = FakeModel(initial_output=[get_text_message("not valid json")]) + model = ScriptedModel(steps=[[get_text_message("not valid json")]]) agent = Agent(name="test", model=model, output_type=FinalOutput) session = SimpleListSession() @@ -246,8 +246,8 @@ async def test_streamed_invalid_final_output_emits_exact_fallback_item() -> None @pytest.mark.asyncio async def test_streamed_empty_structured_output_handler_avoids_another_model_turn() -> None: - model = FakeModel() - model.add_multiple_turn_outputs([[], [get_text_message('{"summary":"unused"}')]]) + model = ScriptedModel() + model.extend([[], [get_text_message('{"summary":"unused"}')]]) agent = Agent(name="test", model=model, output_type=FinalOutput) result = Runner.run_streamed( @@ -258,7 +258,7 @@ async def test_streamed_empty_structured_output_handler_avoids_another_model_tur events = [event async for event in result.stream_events()] assert result.final_output == FinalOutput(summary="safe fallback") - assert len(model.turn_outputs) == 1 + assert model.remaining_steps == 1 assert any( isinstance(event, RunItemStreamEvent) and event.name == "message_output_created" @@ -270,7 +270,7 @@ async def test_streamed_empty_structured_output_handler_avoids_another_model_tur @pytest.mark.asyncio async def test_invalid_final_output_fallback_runs_hooks_and_output_guardrails() -> None: - model = FakeModel(initial_output=[get_text_message("not valid json")]) + model = ScriptedModel(steps=[[get_text_message("not valid json")]]) hooks = RecordingRunHooks() guarded_outputs: list[Any] = [] @@ -315,8 +315,8 @@ async def record_side_effect(value: str) -> str: side_effects.append(value) return f"recorded:{value}" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( @@ -373,4 +373,4 @@ async def record_side_effect(value: str) -> str: assert final_output == FinalOutput(summary="safe fallback") assert side_effects == ["once"] - assert len(model.turn_outputs) == 2 + assert model.remaining_steps == 2 diff --git a/tests/test_local_shell_tool.py b/tests/test_local_shell_tool.py index ae89252c69..ca385ace31 100644 --- a/tests/test_local_shell_tool.py +++ b/tests/test_local_shell_tool.py @@ -28,8 +28,9 @@ from agents.items import ToolCallOutputItem from agents.run_internal.run_loop import LocalShellAction, ToolRunLocalShellCall from agents.run_state import RunState +from agents.testing import ScriptedModel +from tests.model_test_helpers import get_response_obj -from .fake_model import FakeModel, get_response_obj from .test_responses import get_text_message @@ -47,7 +48,7 @@ def __call__(self, request: LocalShellCommandRequest) -> str: async def _create_serialized_local_shell_state() -> tuple[LocalShellTool, dict[str, Any]]: tool = LocalShellTool(executor=RecordingLocalShellExecutor(output="shell result")) - initial_model = FakeModel() + initial_model = ScriptedModel() initial_agent = Agent(name="shell-agent", model=initial_model, tools=[tool]) local_shell_call = LocalShellCall( id="lsh_test", @@ -62,7 +63,7 @@ async def _create_serialized_local_shell_state() -> tuple[LocalShellTool, dict[s status="completed", type="local_shell_call", ) - initial_model.add_multiple_turn_outputs( + initial_model.extend( [ [get_text_message("running shell"), local_shell_call], [get_text_message("shell complete")], @@ -158,7 +159,7 @@ async def test_runner_executes_local_shell_calls() -> None: executor = RecordingLocalShellExecutor(output="shell result") tool = LocalShellTool(executor=executor) - model = FakeModel() + model = ScriptedModel() agent = Agent(name="shell-agent", model=model, tools=[tool]) action = LocalShellCallAction( @@ -176,7 +177,7 @@ async def test_runner_executes_local_shell_calls() -> None: type="local_shell_call", ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("running shell"), local_shell_call], [get_text_message("shell complete")], @@ -188,7 +189,8 @@ async def test_runner_executes_local_shell_calls() -> None: assert len(executor.calls) == 1 request = executor.calls[0] assert isinstance(request, LocalShellCommandRequest) - assert request.data is local_shell_call + assert request.data == local_shell_call + assert request.data is not local_shell_call items = result.new_items assert len(items) == 4 @@ -201,7 +203,8 @@ async def test_runner_executes_local_shell_calls() -> None: tool_call_item = items[1] assert tool_call_item.type == "tool_call_item" - assert tool_call_item.raw_item is local_shell_call + assert tool_call_item.raw_item == local_shell_call + assert tool_call_item.raw_item is not local_shell_call local_shell_output = items[2] assert isinstance(local_shell_output, ToolCallOutputItem) @@ -334,8 +337,8 @@ async def test_run_state_preserves_official_local_shell_original_input( "id": "lsh_output_123", "output": "shell result", } - model = FakeModel() - model.add_multiple_turn_outputs([[get_text_message("complete")]]) + model = ScriptedModel() + model.extend([[get_text_message("complete")]]) agent = Agent(name="shell-agent", model=model) result = await Runner.run(agent, input=[original_input]) serialized = json.loads(json.dumps(result.to_state().to_json())) diff --git a/tests/test_max_turns.py b/tests/test_max_turns.py index e192b14e83..f7d7c433c4 100644 --- a/tests/test_max_turns.py +++ b/tests/test_max_turns.py @@ -18,8 +18,8 @@ UserError, ) from agents.stream_events import RunItemStreamEvent +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import ( get_function_tool, get_function_tool_call, @@ -30,7 +30,7 @@ @pytest.mark.asyncio async def test_non_streamed_max_turns(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -39,7 +39,7 @@ async def test_non_streamed_max_turns(): func_output = json.dumps({"a": "b"}) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_function_tool_call("some_function", func_output, "1")], [get_text_message("2"), get_function_tool_call("some_function", func_output, "2")], @@ -54,7 +54,7 @@ async def test_non_streamed_max_turns(): @pytest.mark.asyncio async def test_non_streamed_max_turns_none_disables_limit(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -63,7 +63,7 @@ async def test_non_streamed_max_turns_none_disables_limit(): func_output = json.dumps({"a": "b"}) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_function_tool_call("some_function", func_output, "1")], [get_text_message("2"), get_function_tool_call("some_function", func_output, "2")], @@ -81,7 +81,7 @@ async def test_non_streamed_max_turns_none_disables_limit(): @pytest.mark.asyncio async def test_streamed_max_turns(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -89,7 +89,7 @@ async def test_streamed_max_turns(): ) func_output = json.dumps({"a": "b"}) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -121,7 +121,7 @@ async def test_streamed_max_turns(): @pytest.mark.asyncio async def test_streamed_max_turns_none_disables_limit(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -129,7 +129,7 @@ async def test_streamed_max_turns_none_disables_limit(): ) func_output = json.dumps({"a": "b"}) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_function_tool_call("some_function", func_output, "1")], [get_text_message("2"), get_function_tool_call("some_function", func_output, "2")], @@ -157,19 +157,19 @@ class FooModel(BaseModel): @pytest.mark.asyncio async def test_non_streamed_structured_output_refusal_raises_without_retry(): - model = FakeModel(initial_output=[get_refusal_message("I cannot help with that request.")]) + model = ScriptedModel(steps=[[get_refusal_message("I cannot help with that request.")]]) agent = Agent(name="test_1", model=model, output_type=FooModel) with pytest.raises(ModelRefusalError) as exc_info: await Runner.run(agent, input="user_message", max_turns=3) assert exc_info.value.refusal == "I cannot help with that request." - assert not model.turn_outputs + assert model.remaining_steps == 0 @pytest.mark.asyncio async def test_non_streamed_refusal_handler_returns_structured_output(): - model = FakeModel(initial_output=[get_refusal_message("I cannot help with that request.")]) + model = ScriptedModel(steps=[[get_refusal_message("I cannot help with that request.")]]) agent = Agent(name="test_1", model=model, output_type=FooModel) def handler(data): @@ -194,7 +194,7 @@ def handler(data): @pytest.mark.asyncio async def test_non_streamed_refusal_handler_can_skip_history(): - model = FakeModel(initial_output=[get_refusal_message("I cannot help with that request.")]) + model = ScriptedModel(steps=[[get_refusal_message("I cannot help with that request.")]]) agent = Agent(name="test_1", model=model) result = await Runner.run( @@ -214,7 +214,7 @@ async def test_non_streamed_refusal_handler_can_skip_history(): @pytest.mark.asyncio async def test_streamed_refusal_handler_returns_output(): - model = FakeModel(initial_output=[get_refusal_message("I cannot help with that request.")]) + model = ScriptedModel(steps=[[get_refusal_message("I cannot help with that request.")]]) agent = Agent(name="test_1", model=model) result = Runner.run_streamed( @@ -237,7 +237,7 @@ async def test_streamed_refusal_handler_returns_output(): @pytest.mark.asyncio async def test_structured_output_non_streamed_max_turns(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -245,7 +245,7 @@ async def test_structured_output_non_streamed_max_turns(): tools=[get_function_tool("tool_1", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("tool_1")], [get_function_tool_call("tool_1")], @@ -260,7 +260,7 @@ async def test_structured_output_non_streamed_max_turns(): @pytest.mark.asyncio async def test_structured_output_streamed_max_turns(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -268,7 +268,7 @@ async def test_structured_output_streamed_max_turns(): tools=[get_function_tool("tool_1", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("tool_1")], [get_function_tool_call("tool_1")], @@ -285,7 +285,7 @@ async def test_structured_output_streamed_max_turns(): @pytest.mark.asyncio async def test_structured_output_max_turns_handler_invalid_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -303,7 +303,7 @@ async def test_structured_output_max_turns_handler_invalid_output(): @pytest.mark.asyncio async def test_structured_output_max_turns_handler_pydantic_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -324,7 +324,7 @@ async def test_structured_output_max_turns_handler_pydantic_output(): @pytest.mark.asyncio async def test_structured_output_max_turns_handler_list_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -344,7 +344,7 @@ async def test_structured_output_max_turns_handler_list_output(): @pytest.mark.asyncio async def test_non_streamed_max_turns_handler_returns_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test_1", model=model) result = await Runner.run( @@ -364,7 +364,7 @@ async def test_non_streamed_max_turns_handler_returns_output(): @pytest.mark.asyncio async def test_non_streamed_max_turns_handler_skip_history(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test_1", model=model) result = await Runner.run( @@ -385,7 +385,7 @@ async def test_non_streamed_max_turns_handler_skip_history(): @pytest.mark.asyncio async def test_non_streamed_max_turns_handler_raw_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test_1", model=model) result = await Runner.run( @@ -401,7 +401,7 @@ async def test_non_streamed_max_turns_handler_raw_output(): @pytest.mark.asyncio async def test_non_streamed_max_turns_handler_raw_dict_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test_1", model=model) result = await Runner.run( @@ -416,7 +416,7 @@ async def test_non_streamed_max_turns_handler_raw_dict_output(): @pytest.mark.asyncio async def test_streamed_max_turns_handler_returns_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test_1", model=model) result = Runner.run_streamed( @@ -439,7 +439,7 @@ async def test_streamed_max_turns_handler_returns_output(): @pytest.mark.asyncio async def test_streamed_max_turns_handler_pydantic_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -466,7 +466,7 @@ async def test_streamed_max_turns_handler_pydantic_output(): @pytest.mark.asyncio async def test_streamed_max_turns_handler_list_output(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -492,15 +492,13 @@ async def test_streamed_max_turns_handler_list_output(): async def _run_max_turns_handler_with_session(streamed: bool) -> list[str]: """Run one tool turn, trip max turns, and return the session's persisted item types.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( - [[get_function_tool_call("some_function", json.dumps({"a": "b"}))]] - ) + model.extend([[get_function_tool_call("some_function", json.dumps({"a": "b"}))]]) session = SQLiteSession("max-turns-handler", ":memory:") try: if streamed: diff --git a/tests/test_pretty_print.py b/tests/test_pretty_print.py index 1bb6814bd5..cc2df86a98 100644 --- a/tests/test_pretty_print.py +++ b/tests/test_pretty_print.py @@ -6,20 +6,20 @@ from agents import Agent, RunContextWrapper, RunErrorDetails, Runner, RunResult from agents.agent_output import _WRAPPER_DICT_KEY +from agents.testing import ScriptedModel from agents.util._pretty_print import ( pretty_print_result, pretty_print_run_error_details, pretty_print_run_result_streaming, ) -from tests.fake_model import FakeModel from .test_responses import get_final_output_message, get_text_message @pytest.mark.asyncio async def test_pretty_result(): - model = FakeModel() - model.set_next_output([get_text_message("Hi there")]) + model = ScriptedModel() + model.enqueue([get_text_message("Hi there")]) agent = Agent(name="test_agent", model=model) result = await Runner.run(agent, input="Hello") @@ -92,8 +92,8 @@ def test_pretty_run_error_details(): @pytest.mark.asyncio async def test_pretty_run_result_streaming(): - model = FakeModel() - model.set_next_output([get_text_message("Hi there")]) + model = ScriptedModel() + model.enqueue([get_text_message("Hi there")]) agent = Agent(name="test_agent", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -122,8 +122,8 @@ class Foo(BaseModel): @pytest.mark.asyncio async def test_pretty_run_result_structured_output(): - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_text_message("Test"), get_final_output_message(Foo(bar="Hi there").model_dump_json()), @@ -150,8 +150,8 @@ async def test_pretty_run_result_structured_output(): @pytest.mark.asyncio async def test_pretty_run_result_streaming_structured_output(): - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_text_message("Test"), get_final_output_message(Foo(bar="Hi there").model_dump_json()), @@ -184,8 +184,8 @@ async def test_pretty_run_result_streaming_structured_output(): @pytest.mark.asyncio async def test_pretty_run_result_list_structured_output(): - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_text_message("Test"), get_final_output_message( @@ -219,8 +219,8 @@ async def test_pretty_run_result_list_structured_output(): @pytest.mark.asyncio async def test_pretty_run_result_streaming_list_structured_output(): - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_text_message("Test"), get_final_output_message( diff --git a/tests/test_process_model_response.py b/tests/test_process_model_response.py index 78049d2c5f..9bee6a72ad 100644 --- a/tests/test_process_model_response.py +++ b/tests/test_process_model_response.py @@ -41,8 +41,8 @@ ) from agents.mcp.util import MCPUtil from agents.run_internal import run_loop +from agents.testing import ScriptedModel from agents.usage import Usage -from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer from tests.mcp.model_compat import Tool as MCPTool from tests.test_responses import get_function_tool_call @@ -76,7 +76,7 @@ def _make_hosted_mcp_list_tools(server_label: str, tool_name: str) -> McpListToo def test_process_model_response_shell_call_without_tool_raises() -> None: - agent = Agent(name="no-shell", model=FakeModel()) + agent = Agent(name="no-shell", model=ScriptedModel()) shell_call = make_shell_call("shell-1") with pytest.raises(ModelBehaviorError, match="shell tool"): @@ -111,7 +111,7 @@ def __bool__(self) -> bool: def test_process_model_response_sets_title_for_local_mcp_function_tool() -> None: - agent = Agent(name="local-mcp", model=FakeModel()) + agent = Agent(name="local-mcp", model=ScriptedModel()) mcp_tool = MCPTool(name="search_docs", inputSchema={}, description=None, title="Search Docs") function_tool = MCPUtil.to_function_tool( mcp_tool, @@ -142,7 +142,7 @@ def test_process_model_response_sets_title_for_local_mcp_function_tool() -> None def test_process_model_response_uses_mcp_list_tools_metadata_for_hosted_mcp_calls() -> None: - agent = Agent(name="hosted-mcp", model=FakeModel()) + agent = Agent(name="hosted-mcp", model=ScriptedModel()) hosted_tool = HostedMCPTool( tool_config=cast( Any, @@ -186,7 +186,7 @@ def test_process_model_response_uses_mcp_list_tools_metadata_for_hosted_mcp_call def test_process_model_response_skips_local_shell_execution_for_hosted_environment() -> None: shell_tool = ShellTool(environment={"type": "container_auto"}) - agent = Agent(name="hosted-shell", model=FakeModel(), tools=[shell_tool]) + agent = Agent(name="hosted-shell", model=ScriptedModel(), tools=[shell_tool]) shell_call = make_shell_call("shell-hosted-1") processed = run_loop.process_model_response( @@ -213,7 +213,7 @@ def test_process_model_response_sanitizes_shell_call_model_object() -> None: action=cast(Any, {"commands": ["echo hi"], "timeout_ms": 1000}), ) shell_tool = ShellTool(environment={"type": "container_auto"}) - agent = Agent(name="hosted-shell-model", model=FakeModel(), tools=[shell_tool]) + agent = Agent(name="hosted-shell-model", model=ScriptedModel(), tools=[shell_tool]) processed = run_loop.process_model_response( agent=agent, @@ -252,7 +252,7 @@ def test_process_model_response_preserves_shell_call_output() -> None: } ], } - agent = Agent(name="shell-output", model=FakeModel()) + agent = Agent(name="shell-output", model=ScriptedModel()) processed = run_loop.process_model_response( agent=agent, @@ -288,7 +288,7 @@ def test_process_model_response_sanitizes_shell_call_output_model_object() -> No ], ), ) - agent = Agent(name="shell-output-model", model=FakeModel()) + agent = Agent(name="shell-output-model", model=ScriptedModel()) processed = run_loop.process_model_response( agent=agent, @@ -322,7 +322,7 @@ def test_process_model_response_sanitizes_shell_call_output_model_object() -> No def test_process_model_response_apply_patch_call_without_tool_raises() -> None: - agent = Agent(name="no-apply", model=FakeModel()) + agent = Agent(name="no-apply", model=ScriptedModel()) apply_patch_call = make_apply_patch_dict("apply-1", diff="-old\n+new\n") with pytest.raises(ModelBehaviorError, match="apply_patch tool"): @@ -338,7 +338,7 @@ def test_process_model_response_apply_patch_call_without_tool_raises() -> None: def test_process_model_response_sanitizes_apply_patch_call_model_object() -> None: editor = RecordingEditor() apply_patch_tool = ApplyPatchTool(editor=editor) - agent = Agent(name="apply-agent-model", model=FakeModel(), tools=[apply_patch_tool]) + agent = Agent(name="apply-agent-model", model=ScriptedModel(), tools=[apply_patch_tool]) apply_patch_call = ResponseApplyPatchToolCall( type="apply_patch_call", id="ap_call_1", @@ -380,7 +380,7 @@ def test_process_model_response_sanitizes_apply_patch_call_model_object() -> Non def test_process_model_response_queues_apply_patch_call() -> None: editor = RecordingEditor() apply_patch_tool = ApplyPatchTool(editor=editor) - agent = Agent(name="apply-agent", model=FakeModel(), tools=[apply_patch_tool]) + agent = Agent(name="apply-agent", model=ScriptedModel(), tools=[apply_patch_tool]) apply_patch_call = make_apply_patch_dict("apply-1") processed = run_loop.process_model_response( @@ -420,7 +420,7 @@ def __bool__(self) -> bool: def test_process_model_response_queues_hosted_apply_patch_from_custom_tool_call() -> None: editor = RecordingEditor() apply_patch_tool = ApplyPatchTool(editor=editor) - agent = Agent(name="apply-agent-custom", model=FakeModel(), tools=[apply_patch_tool]) + agent = Agent(name="apply-agent-custom", model=ScriptedModel(), tools=[apply_patch_tool]) custom_call = ResponseCustomToolCall( type="custom_tool_call", name="apply_patch", @@ -456,7 +456,7 @@ def test_process_model_response_queues_custom_tool_call_for_custom_tool() -> Non on_invoke_tool=lambda _ctx, raw_input: raw_input, format={"type": "text"}, ) - agent = Agent(name="custom-agent", model=FakeModel(), tools=[custom_tool]) + agent = Agent(name="custom-agent", model=ScriptedModel(), tools=[custom_tool]) custom_call = ResponseCustomToolCall( type="custom_tool_call", name="raw_editor", @@ -487,7 +487,7 @@ def test_process_model_response_prefers_namespaced_function_over_apply_patch_fal tools=[function_tool(lambda payload: payload, name_override="apply_patch_lookup")], )[0] all_tools: list[Tool] = [namespaced_tool] - agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="billing-agent", model=ScriptedModel(), tools=all_tools) processed = run_loop.process_model_response( agent=agent, @@ -511,7 +511,7 @@ def test_process_model_response_prefers_namespaced_function_over_apply_patch_fal def test_process_model_response_handles_compaction_item() -> None: - agent = Agent(name="compaction-agent", model=FakeModel()) + agent = Agent(name="compaction-agent", model=ScriptedModel()) compaction_item = ResponseCompactionItem( id="comp-1", encrypted_content="enc", @@ -537,7 +537,7 @@ def test_process_model_response_handles_compaction_item() -> None: def test_process_model_response_classifies_tool_search_items() -> None: - agent = Agent(name="tool-search-agent", model=FakeModel()) + agent = Agent(name="tool-search-agent", model=ScriptedModel()) tool_search_call = construct_type( type_=ResponseOutputItem, value={ @@ -604,7 +604,7 @@ def test_process_model_response_uses_namespace_for_duplicate_function_names() -> tools=[billing_tool], ) all_tools: list[Tool] = [*crm_namespace, *billing_namespace] - agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="billing-agent", model=ScriptedModel(), tools=all_tools) processed = run_loop.process_model_response( agent=agent, @@ -633,7 +633,7 @@ def test_process_model_response_collapses_synthetic_deferred_namespace_in_tools_ name_override="get_weather", defer_loading=True, ) - agent = Agent(name="weather-agent", model=FakeModel(), tools=[deferred_tool]) + agent = Agent(name="weather-agent", model=ScriptedModel(), tools=[deferred_tool]) processed = run_loop.process_model_response( agent=agent, @@ -670,7 +670,7 @@ def test_process_model_response_rejects_bare_name_for_duplicate_namespaced_funct tools=[billing_tool], ) all_tools: list[Tool] = [*crm_namespace, *billing_namespace] - agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="billing-agent", model=ScriptedModel(), tools=all_tools) with pytest.raises(ModelBehaviorError, match="Tool lookup_account not found"): run_loop.process_model_response( @@ -688,7 +688,7 @@ def test_process_model_response_uses_last_duplicate_top_level_function() -> None first_tool = function_tool(lambda customer_id: f"first:{customer_id}", name_override="lookup") second_tool = function_tool(lambda customer_id: f"second:{customer_id}", name_override="lookup") all_tools: list[Tool] = [first_tool, second_tool] - agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="lookup-agent", model=ScriptedModel(), tools=all_tools) processed = run_loop.process_model_response( agent=agent, @@ -707,7 +707,7 @@ def test_process_model_response_rejects_reserved_same_name_namespace_shape() -> invalid_tool._tool_namespace = "lookup_account" invalid_tool._tool_namespace_description = "Same-name namespace" all_tools: list[Tool] = [invalid_tool] - agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="lookup-agent", model=ScriptedModel(), tools=all_tools) with pytest.raises(UserError, match="synthetic namespace `lookup_account.lookup_account`"): run_loop.process_model_response( @@ -740,7 +740,7 @@ def test_process_model_response_rejects_qualified_name_collision_with_dotted_top tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")], )[0] all_tools: list[Tool] = [dotted_top_level_tool, namespaced_tool] - agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="lookup-agent", model=ScriptedModel(), tools=all_tools) with pytest.raises(UserError, match="qualified name `crm.lookup_account`"): run_loop.process_model_response( @@ -771,7 +771,7 @@ def test_process_model_response_prefers_visible_top_level_function_over_deferred defer_loading=True, ) all_tools: list[Tool] = [visible_tool, deferred_tool] - agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="lookup-agent", model=ScriptedModel(), tools=all_tools) processed = run_loop.process_model_response( agent=agent, @@ -801,7 +801,7 @@ def test_process_model_response_uses_internal_lookup_key_for_deferred_top_level_ defer_loading=True, ) all_tools: list[Tool] = [visible_tool, deferred_tool] - agent = Agent(name="lookup-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="lookup-agent", model=ScriptedModel(), tools=all_tools) processed = run_loop.process_model_response( agent=agent, @@ -832,7 +832,7 @@ def test_process_model_response_preserves_synthetic_namespace_for_deferred_top_l defer_loading=True, ) all_tools: list[Tool] = [deferred_tool] - agent = Agent(name="weather-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="weather-agent", model=ScriptedModel(), tools=all_tools) processed = run_loop.process_model_response( agent=agent, @@ -858,10 +858,10 @@ def test_process_model_response_prefers_namespaced_function_over_handoff_name_co description="Billing tools", tools=[billing_tool], ) - handoff_target = Agent(name="lookup-agent", model=FakeModel()) + handoff_target = Agent(name="lookup-agent", model=ScriptedModel()) lookup_handoff: Handoff = handoff(handoff_target, tool_name_override="lookup_account") all_tools: list[Tool] = [*billing_namespace] - agent = Agent(name="billing-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="billing-agent", model=ScriptedModel(), tools=all_tools) processed = run_loop.process_model_response( agent=agent, @@ -890,7 +890,7 @@ def test_process_model_response_prefers_namespaced_function_over_handoff_name_co def test_process_model_response_rejects_mismatched_function_namespace() -> None: bare_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account") all_tools: list[Tool] = [bare_tool] - agent = Agent(name="bare-agent", model=FakeModel(), tools=all_tools) + agent = Agent(name="bare-agent", model=ScriptedModel(), tools=all_tools) with pytest.raises(ModelBehaviorError, match="crm.lookup_account"): run_loop.process_model_response( @@ -911,7 +911,7 @@ def test_process_model_response_rejects_mismatched_function_namespace() -> None: def test_process_model_response_collects_missing_function_tool_when_opted_in() -> None: - agent = Agent(name="test", model=FakeModel(), tools=[function_tool(lambda: "ok")]) + agent = Agent(name="test", model=ScriptedModel(), tools=[function_tool(lambda: "ok")]) missing_call = get_function_tool_call("missing_tool", "{}", call_id="call_missing") processed = run_loop.process_model_response( diff --git a/tests/test_programmatic_tool_calling.py b/tests/test_programmatic_tool_calling.py index 79b32fa2c6..c3a9e98de7 100644 --- a/tests/test_programmatic_tool_calling.py +++ b/tests/test_programmatic_tool_calling.py @@ -70,9 +70,10 @@ from agents.models.chatcmpl_converter import Converter as ChatCompletionsConverter from agents.models.openai_responses import Converter as ResponsesConverter from agents.run_internal.turn_resolution import process_model_response +from agents.testing import ScriptedModel from agents.tool_context import ToolContext -from .fake_model import FakeModel +from .model_test_helpers import get_exact_output_stream_step from .test_responses import get_handoff_tool_call, get_text_message PROGRAM_CALL_ID = "call_program" @@ -493,7 +494,7 @@ def failing_tool(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_runner_preserves_direct_error_for_schema_backed_tool() -> None: - model = FakeModel() + model = ScriptedModel() direct_call = ResponseFunctionToolCall( id="function_item", call_id=FUNCTION_CALL_ID, @@ -501,7 +502,7 @@ async def test_runner_preserves_direct_error_for_schema_backed_tool() -> None: arguments='{"sku":"A-1"}', type="function_call", ) - model.add_multiple_turn_outputs([[direct_call], [get_text_message("inventory lookup failed")]]) + model.extend([[direct_call], [get_text_message("inventory lookup failed")]]) @function_tool(allowed_callers=["direct", "programmatic"]) def lookup_inventory(sku: str) -> InventoryOutput: @@ -526,7 +527,7 @@ def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_runner_preserves_direct_default_timeout_for_schema_backed_tool() -> None: - model = FakeModel() + model = ScriptedModel() direct_call = ResponseFunctionToolCall( id="function_item", call_id=FUNCTION_CALL_ID, @@ -534,7 +535,7 @@ async def test_runner_preserves_direct_default_timeout_for_schema_backed_tool() arguments='{"sku":"A-1"}', type="function_call", ) - model.add_multiple_turn_outputs([[direct_call], [get_text_message("timed out")]]) + model.extend([[direct_call], [get_text_message("timed out")]]) @function_tool(allowed_callers=["direct", "programmatic"], timeout=0.01) async def lookup_inventory(sku: str) -> InventoryOutput: @@ -588,8 +589,8 @@ def failing_tool() -> InventoryOutput: @pytest.mark.asyncio async def test_schema_backed_programmatic_tool_accepts_conforming_custom_timeout_output() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("timeout handled")], @@ -1190,8 +1191,8 @@ def test_process_model_response_rejects_program_items_without_programmatic_tool( @pytest.mark.asyncio async def test_runner_rejects_program_item_without_programmatic_tool() -> None: - model = FakeModel() - model.set_next_output([_program()]) + model = ScriptedModel() + model.enqueue([_program()]) agent = Agent(name="inventory", model=model) with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"): @@ -1252,8 +1253,8 @@ async def shell_executor(_request: Any) -> str: @pytest.mark.asyncio async def test_runner_does_not_execute_program_owned_call_without_programmatic_tool() -> None: - model = FakeModel() - model.set_next_output([_function_call()]) + model = ScriptedModel() + model.enqueue([_function_call()]) executed = False @function_tool(allowed_callers=["programmatic"]) @@ -1500,8 +1501,8 @@ def lookup_inventory(sku: str) -> str: @pytest.mark.asyncio async def test_runner_does_not_execute_program_owned_call_without_parent_program() -> None: - model = FakeModel() - model.set_next_output([_function_call()]) + model = ScriptedModel() + model.enqueue([_function_call()]) executed = False @function_tool(allowed_callers=["programmatic"]) @@ -1891,12 +1892,12 @@ def test_process_model_response_accepts_allowed_program_owned_shell_output( @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) async def test_runner_executes_and_replays_programmatic_function_calls(streamed: bool) -> None: - model = FakeModel() - model.add_multiple_turn_outputs( - [ - [_program(), _function_call()], - [_program_output(), get_text_message("42 units are available")], - ] + outputs = [ + [_program(), _function_call()], + [_program_output(), get_text_message("42 units are available")], + ] + model = ScriptedModel( + [get_exact_output_stream_step(output) for output in outputs] if streamed else outputs ) @function_tool(allowed_callers=["programmatic"]) @@ -1923,9 +1924,9 @@ def lookup_inventory(sku: str) -> InventoryOutput: result = await Runner.run(agent, "Check inventory") assert result.final_output == "42 units are available" - assert model.first_turn_args is not None - assert model.first_turn_args["model_settings"].tool_choice == "programmatic_tool_calling" - assert model.last_turn_args["model_settings"].tool_choice is None + assert bool(model.calls) + assert model.calls[0].model_settings.tool_choice == "programmatic_tool_calling" + assert model.calls[-1].model_settings.tool_choice is None function_outputs = [ item @@ -1941,18 +1942,21 @@ def lookup_inventory(sku: str) -> InventoryOutput: } assert _caller_dict(raw_output["caller"]) == PROGRAM_CALLER - replayed_output = next( - item - for item in model.last_turn_args["input"] - if isinstance(item, dict) and item.get("type") == "function_call_output" + replayed_output = cast( + dict[str, Any], + next( + item + for item in model.calls[-1].input + if isinstance(item, dict) and item.get("type") == "function_call_output" + ), ) assert _caller_dict(replayed_output["caller"]) == PROGRAM_CALLER @pytest.mark.asyncio async def test_typed_programmatic_tool_preserves_input_guardrail_rejection() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("request rejected")], @@ -1991,8 +1995,8 @@ def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_typed_programmatic_tool_preserves_default_timeout_result() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("request timed out")], @@ -2023,8 +2027,8 @@ async def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_typed_programmatic_tool_preserves_output_guardrail_rejection() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("request rejected")], @@ -2070,12 +2074,12 @@ async def test_typed_programmatic_tool_preserves_approval_rejection( serialize_state: bool, rejection_message: str | None, ) -> None: - model = FakeModel() - model.add_multiple_turn_outputs( - [ - [_program(), _function_call()], - [_program_output(), get_text_message("request rejected")], - ] + outputs = [ + [_program(), _function_call()], + [_program_output(), get_text_message("request rejected")], + ] + model = ScriptedModel( + [get_exact_output_stream_step(output) for output in outputs] if streaming else outputs ) @function_tool(allowed_callers=["programmatic"], needs_approval=True) @@ -2114,19 +2118,22 @@ def lookup_inventory(sku: str) -> InventoryOutput: expected_message = rejection_message or "Tool execution was not approved." assert json.loads(function_outputs[0]["output"]) == {"error": expected_message} assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER - assert model.last_turn_args is not None - replayed_output = next( - item - for item in model.last_turn_args["input"] - if isinstance(item, dict) and item.get("type") == "function_call_output" + assert bool(model.calls) + replayed_output = cast( + dict[str, Any], + next( + item + for item in model.calls[-1].input + if isinstance(item, dict) and item.get("type") == "function_call_output" + ), ) assert json.loads(replayed_output["output"]) == {"error": expected_message} @pytest.mark.asyncio async def test_rebuilt_mapping_programmatic_approval_preserves_caller() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("done")], @@ -2164,8 +2171,8 @@ def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_rebuilt_mapping_programmatic_approval_rechecks_caller_permissions() -> None: - model = FakeModel() - model.set_next_output([_program(), _function_call()]) + model = ScriptedModel() + model.enqueue([_program(), _function_call()]) executed = False @function_tool(allowed_callers=["programmatic"], needs_approval=True) @@ -2197,8 +2204,8 @@ def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio @pytest.mark.parametrize("parent_state", ["missing", "completed"]) async def test_rebuilt_programmatic_approval_requires_active_parent(parent_state: str) -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("done")], @@ -2250,8 +2257,8 @@ def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_typed_programmatic_tool_preserves_pre_approval_guardrail_rejection() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("request rejected")], @@ -2290,7 +2297,7 @@ def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_runner_handles_multiple_pauses_from_one_program() -> None: - model = FakeModel() + model = ScriptedModel() second_call = ResponseFunctionToolCall( id="function_item_2", call_id="call_lookup_2", @@ -2299,7 +2306,7 @@ async def test_runner_handles_multiple_pauses_from_one_program() -> None: caller=CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), type="function_call", ) - model.add_multiple_turn_outputs( + model.extend( [ [_program(), _function_call()], [_program_output("incomplete"), second_call], @@ -2334,13 +2341,13 @@ def lookup_inventory(sku: str) -> InventoryOutput: assert result.final_output == "84 units are available" assert calls == ["A-1", "B-2"] - assert model.last_turn_args["model_settings"].tool_choice is None + assert model.calls[-1].model_settings.tool_choice is None assert len([item for item in result.new_items if isinstance(item, ToolCallOutputItem)]) == 4 @pytest.mark.asyncio async def test_runner_executes_programmatic_batch_calls_concurrently() -> None: - model = FakeModel() + model = ScriptedModel() batch_calls = [ ResponseFunctionToolCall( id=f"function_item_{index}", @@ -2352,7 +2359,7 @@ async def test_runner_executes_programmatic_batch_calls_concurrently() -> None: ) for index in range(9) ] - model.add_multiple_turn_outputs( + model.extend( [ [_program(), *batch_calls], [_program_output(), get_text_message("batch complete")], @@ -2398,8 +2405,8 @@ async def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_previous_response_id_continuation_sends_only_program_function_output() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("done")], @@ -2419,8 +2426,8 @@ def lookup_inventory(sku: str) -> InventoryOutput: result = await Runner.run(agent, "Check inventory", auto_previous_response_id=True) assert result.final_output == "done" - assert model.last_turn_args["previous_response_id"] == "resp-789" - last_input = model.last_turn_args["input"] + assert model.calls[-1].previous_response_id == "resp-789" + last_input = model.calls[-1].input assert isinstance(last_input, list) assert len(last_input) == 1 function_output = cast(dict[str, Any], last_input[0]) @@ -2433,8 +2440,8 @@ def lookup_inventory(sku: str) -> InventoryOutput: async def test_previous_response_id_continuation_accepts_server_owned_program_output( streamed: bool, ) -> None: - model = FakeModel() - model.set_next_output([_program_output(), get_text_message("done")]) + output = [_program_output(), get_text_message("done")] + model = ScriptedModel([get_exact_output_stream_step(output) if streamed else output]) @function_tool(allowed_callers=["programmatic"]) def lookup_inventory(sku: str) -> InventoryOutput: @@ -2470,13 +2477,13 @@ def lookup_inventory(sku: str) -> InventoryOutput: ) assert result.final_output == "done" - assert model.last_turn_args["previous_response_id"] == "response_with_program_parent" + assert model.calls[-1].previous_response_id == "response_with_program_parent" @pytest.mark.asyncio async def test_previous_response_id_continuation_accepts_repeated_program_pause() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_function_call()], [_program_output(), get_text_message("done")], @@ -2520,8 +2527,8 @@ def lookup_inventory(sku: str) -> InventoryOutput: async def test_run_state_round_trip_preserves_server_owned_program_parent( parent_source: str, ) -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_function_call()], [_program_output(), get_text_message("done")], @@ -2566,13 +2573,13 @@ def lookup_inventory(sku: str) -> InventoryOutput: assert executed is True assert result.final_output == "done" - assert model.last_turn_args["previous_response_id"] == "resp-789" + assert model.calls[-1].previous_response_id == "resp-789" @pytest.mark.asyncio async def test_sqlite_session_round_trip_preserves_program_history_and_caller() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [_program(), _function_call()], [_program_output(), get_text_message("done")], @@ -2614,9 +2621,9 @@ def lookup_inventory(sku: str) -> InventoryOutput: @pytest.mark.asyncio async def test_nested_handoff_summarizes_complete_programmatic_transcript() -> None: - model = FakeModel() + model = ScriptedModel() delegate = Agent(name="delegate", model=model) - model.add_multiple_turn_outputs( + model.extend( [ [_program(), _function_call()], [_program_output(), get_handoff_tool_call(delegate)], @@ -2692,8 +2699,8 @@ async def test_non_function_programmatic_outputs_preserve_caller() -> None: caller = cast(Any, PROGRAM_CALLER) async def run_tool(tool: Any, tool_call: Any) -> dict[str, Any]: - model = FakeModel() - model.add_multiple_turn_outputs([[_program(), tool_call], [get_text_message("done")]]) + model = ScriptedModel() + model.extend([[_program(), tool_call], [get_text_message("done")]]) agent = Agent( name="tool agent", model=model, @@ -2752,13 +2759,13 @@ def delete_file(self, _operation: Any) -> str: apply_patch_output = await run_tool( ApplyPatchTool(editor=Editor(), allowed_callers=["programmatic"]), - ResponseApplyPatchToolCall( - id="apply_patch_item", - call_id="call_apply_patch", - operation=OperationCreateFile(type="create_file", path="example.txt", diff="hello"), - status="completed", - type="apply_patch_call", - caller=caller, - ), + { + "id": "apply_patch_item", + "call_id": "call_apply_patch", + "operation": {"type": "create_file", "path": "example.txt", "diff": "hello"}, + "status": "completed", + "type": "apply_patch_call", + "caller": caller, + }, ) assert _caller_dict(apply_patch_output["caller"]) == PROGRAM_CALLER diff --git a/tests/test_prompt_cache_key.py b/tests/test_prompt_cache_key.py index 45adfaa546..244d62eb86 100644 --- a/tests/test_prompt_cache_key.py +++ b/tests/test_prompt_cache_key.py @@ -4,13 +4,13 @@ from openai.types.responses.response_create_params import ContextManagement, PromptCacheOptions from agents import Agent, ModelSettings, RunConfig, Runner +from agents.testing import ScriptedModel -from .fake_model import FakeModel, PromptCacheFakeModel from .test_responses import get_function_tool, get_function_tool_call, get_text_message from .utils.simple_session import SimpleListSession -def _sent_prompt_cache_key(model: FakeModel, *, first_turn: bool = False) -> str | None: +def _sent_prompt_cache_key(model: ScriptedModel, *, first_turn: bool = False) -> str | None: model_settings = _sent_model_settings(model, first_turn=first_turn) extra_args = model_settings.extra_args or {} value = extra_args.get("prompt_cache_key") @@ -18,23 +18,27 @@ def _sent_prompt_cache_key(model: FakeModel, *, first_turn: bool = False) -> str return value -def _sent_model_settings(model: FakeModel, *, first_turn: bool = False) -> ModelSettings: - args = model.first_turn_args if first_turn else model.last_turn_args - assert args is not None - model_settings = args["model_settings"] +def _sent_model_settings(model: ScriptedModel, *, first_turn: bool = False) -> ModelSettings: + call = model.calls[0] if first_turn else model.calls[-1] + model_settings = call.model_settings assert isinstance(model_settings, ModelSettings) return model_settings -class DefaultPromptCacheDisabledFakeModel(FakeModel): +class PromptCacheScriptedModel(ScriptedModel): + def _supports_default_prompt_cache_key(self) -> bool: + return True + + +class DefaultPromptCacheDisabledScriptedModel(ScriptedModel): def _supports_default_prompt_cache_key(self) -> bool: return False @pytest.mark.asyncio async def test_runner_generates_prompt_cache_key_by_default() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) await Runner.run(agent, "hi") @@ -46,21 +50,21 @@ async def test_runner_generates_prompt_cache_key_by_default() -> None: @pytest.mark.asyncio async def test_runner_adds_prompt_cache_key_without_adding_model_call_keyword() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) await Runner.run(agent, "hi") - # PromptCacheFakeModel uses the public Model.get_response() signature. If the runner added + # PromptCacheScriptedModel uses the public Model.get_response() signature. If the runner added # prompt_cache_key as a direct model-call keyword, this run would fail before this assertion. assert _sent_prompt_cache_key(model) is not None @pytest.mark.asyncio async def test_runner_reuses_generated_prompt_cache_key_across_turns() -> None: - model = PromptCacheFakeModel() - model.add_multiple_turn_outputs( + model = PromptCacheScriptedModel() + model.extend( [ [get_function_tool_call("lookup", "{}")], [get_text_message("done")], @@ -78,8 +82,8 @@ async def test_runner_reuses_generated_prompt_cache_key_across_turns() -> None: @pytest.mark.asyncio async def test_runner_skips_generated_prompt_cache_key_when_model_disables_default() -> None: - model = DefaultPromptCacheDisabledFakeModel() - model.set_next_output([get_text_message("done")]) + model = DefaultPromptCacheDisabledScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) await Runner.run(agent, "hi") @@ -89,8 +93,8 @@ async def test_runner_skips_generated_prompt_cache_key_when_model_disables_defau @pytest.mark.asyncio async def test_runner_respects_existing_extra_args_prompt_cache_key() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent( name="test", model=model, @@ -106,8 +110,8 @@ async def test_runner_respects_existing_extra_args_prompt_cache_key() -> None: @pytest.mark.asyncio async def test_runner_respects_existing_extra_body_prompt_cache_key() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent( name="test", model=model, @@ -124,8 +128,8 @@ async def test_runner_respects_existing_extra_body_prompt_cache_key() -> None: @pytest.mark.asyncio async def test_runner_generates_prompt_cache_key_with_unrelated_extra_args() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) model_settings = ModelSettings(extra_args={"service_tier": "flex"}) agent = Agent( name="test", @@ -146,8 +150,8 @@ async def test_runner_generates_prompt_cache_key_with_unrelated_extra_args() -> @pytest.mark.asyncio async def test_runner_preserves_context_management_when_adding_prompt_cache_key() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) context_management: list[ContextManagement] = [ {"type": "compaction", "compact_threshold": 200000} ] @@ -170,8 +174,8 @@ async def test_runner_preserves_context_management_when_adding_prompt_cache_key( @pytest.mark.asyncio async def test_runner_preserves_prompt_cache_options_when_adding_prompt_cache_key() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) prompt_cache_options: PromptCacheOptions = {"mode": "explicit", "ttl": "30m"} model_settings = ModelSettings(prompt_cache_options=prompt_cache_options) agent = Agent(name="test", model=model, model_settings=model_settings) @@ -188,8 +192,8 @@ async def test_runner_preserves_prompt_cache_options_when_adding_prompt_cache_ke @pytest.mark.asyncio async def test_runner_skips_generated_key_when_model_settings_has_prompt_cache_keys() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent( name="test", model=model, @@ -206,8 +210,8 @@ async def test_runner_skips_generated_key_when_model_settings_has_prompt_cache_k @pytest.mark.asyncio async def test_runner_uses_group_id_as_stable_prompt_cache_key_boundary() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) await Runner.run(agent, "hi", run_config=RunConfig(group_id="thread-123")) @@ -219,8 +223,8 @@ async def test_runner_uses_group_id_as_stable_prompt_cache_key_boundary() -> Non @pytest.mark.asyncio async def test_runner_uses_session_id_as_stable_prompt_cache_key_boundary() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) session = SimpleListSession(session_id="session-123") @@ -233,8 +237,8 @@ async def test_runner_uses_session_id_as_stable_prompt_cache_key_boundary() -> N @pytest.mark.asyncio async def test_streamed_runner_generates_prompt_cache_key_by_default() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("done")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) result = Runner.run_streamed(agent, "hi") @@ -248,8 +252,8 @@ async def test_streamed_runner_generates_prompt_cache_key_by_default() -> None: @pytest.mark.asyncio async def test_run_state_preserves_generated_prompt_cache_key_on_resume() -> None: - model = PromptCacheFakeModel() - model.set_next_output([get_text_message("first")]) + model = PromptCacheScriptedModel() + model.enqueue([get_text_message("first")]) agent = Agent(name="test", model=model) first_result = await Runner.run(agent, "hi") @@ -257,7 +261,7 @@ async def test_run_state_preserves_generated_prompt_cache_key_on_resume() -> Non state = first_result.to_state() restored_state = await type(state).from_string(agent, state.to_string()) - model.set_next_output([get_text_message("second")]) + model.enqueue([get_text_message("second")]) await Runner.run(agent, restored_state) assert first_key is not None diff --git a/tests/test_repl.py b/tests/test_repl.py index 1b050d9462..556403c232 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -1,8 +1,8 @@ import pytest from agents import Agent, run_demo_loop +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import ( get_function_tool, get_function_tool_call, @@ -14,8 +14,8 @@ @pytest.mark.asyncio async def test_run_demo_loop_conversation(monkeypatch, capsys): - model = FakeModel() - model.add_multiple_turn_outputs([[get_text_message("hello")], [get_text_message("good")]]) + model = ScriptedModel() + model.extend([[get_text_message("hello")], [get_text_message("good")]]) agent = Agent(name="test", model=model) @@ -27,7 +27,7 @@ async def test_run_demo_loop_conversation(monkeypatch, capsys): output = capsys.readouterr().out assert "hello" in output assert "good" in output - assert model.last_turn_args["input"] == [ + assert model.calls[-1].input == [ get_text_input_item("Hi"), get_text_message("hello").model_dump(exclude_unset=True), get_text_input_item("How are you?"), @@ -36,7 +36,7 @@ async def test_run_demo_loop_conversation(monkeypatch, capsys): @pytest.mark.asyncio async def test_run_demo_loop_streaming(monkeypatch, capsys): - model = FakeModel() + model = ScriptedModel() target_agent = Agent(name="target", model=model) agent = Agent( name="test", @@ -47,7 +47,7 @@ async def test_run_demo_loop_streaming(monkeypatch, capsys): # A single user turn that exercises every streamed event branch: # a tool call, the tool output, a handoff (agent update), then a text answer. - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("foo", "{}")], [get_handoff_tool_call(target_agent)], @@ -69,7 +69,7 @@ async def test_run_demo_loop_streaming(monkeypatch, capsys): @pytest.mark.asyncio async def test_run_demo_loop_exits_on_eof(monkeypatch, capsys): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) def raise_eof(_=" > ") -> str: @@ -80,13 +80,13 @@ def raise_eof(_=" > ") -> str: await run_demo_loop(agent, stream=False) # The loop should terminate cleanly without ever invoking the model. - assert model.last_turn_args == {} + assert not model.calls @pytest.mark.asyncio async def test_run_demo_loop_skips_empty_input(monkeypatch, capsys): - model = FakeModel() - model.add_multiple_turn_outputs([[get_text_message("hello")]]) + model = ScriptedModel() + model.extend([[get_text_message("hello")]]) agent = Agent(name="test", model=model) # Empty lines are ignored; only the non-empty input reaches the runner. @@ -97,4 +97,4 @@ async def test_run_demo_loop_skips_empty_input(monkeypatch, capsys): output = capsys.readouterr().out assert "hello" in output - assert model.last_turn_args["input"] == [get_text_input_item("Hi")] + assert model.calls[-1].input == [get_text_input_item("Hi")] diff --git a/tests/test_responses_tracing.py b/tests/test_responses_tracing.py index 14dd654de8..71124d047d 100644 --- a/tests/test_responses_tracing.py +++ b/tests/test_responses_tracing.py @@ -6,7 +6,7 @@ from agents import ModelBehaviorError, ModelSettings, ModelTracing, OpenAIResponsesModel, trace from agents.tracing.span_data import ResponseSpanData -from tests import fake_model +from tests import model_test_helpers from .testing_processor import assert_no_spans, fetch_normalized_spans, fetch_ordered_spans @@ -49,7 +49,7 @@ def __init__(self): def __aiter__(self): yield ResponseCompletedEvent( type="response.completed", - response=fake_model.get_response_obj(self.output), + response=model_test_helpers.get_response_obj(self.output), sequence_number=0, ) @@ -249,7 +249,7 @@ class DummyStream: async def __aiter__(self): yield ResponseCompletedEvent( type="response.completed", - response=fake_model.get_response_obj([], "dummy-id-123"), + response=model_test_helpers.get_response_obj([], "dummy-id-123"), sequence_number=0, ) @@ -322,7 +322,7 @@ async def dummy_fetch_response( class DummyTerminalEvent: def __init__(self): self.type = terminal_event_type - self.response = fake_model.get_response_obj([], "dummy-id-terminal") + self.response = model_test_helpers.get_response_obj([], "dummy-id-terminal") self.sequence_number = 0 class DummyStream: @@ -391,7 +391,7 @@ class DummyStream: async def __aiter__(self): yield ResponseCompletedEvent( type="response.completed", - response=fake_model.get_response_obj([], "dummy-id-123"), + response=model_test_helpers.get_response_obj([], "dummy-id-123"), sequence_number=0, ) @@ -467,7 +467,7 @@ class DummyStream: async def __aiter__(self): yield ResponseCompletedEvent( type="response.completed", - response=fake_model.get_response_obj([], "dummy-id-123"), + response=model_test_helpers.get_response_obj([], "dummy-id-123"), sequence_number=0, ) diff --git a/tests/test_run.py b/tests/test_run.py index 3788cab625..0f09a24fcc 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,13 +1,14 @@ from __future__ import annotations +from typing import Any, cast from unittest import mock import pytest from agents import Agent, Runner from agents.run import AgentRunner, set_default_agent_runner +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_text_input_item, get_text_message @@ -16,7 +17,7 @@ async def test_static_run_methods_call_into_default_runner() -> None: runner = mock.Mock(spec=AgentRunner) set_default_agent_runner(runner) - agent = Agent(name="test", model=FakeModel()) + agent = Agent(name="test", model=ScriptedModel()) await Runner.run(agent, input="test") runner.run.assert_called_once() @@ -29,16 +30,16 @@ async def test_static_run_methods_call_into_default_runner() -> None: @pytest.mark.asyncio async def test_run_preserves_duplicate_user_messages() -> None: - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="test", model=model) input_items = [get_text_input_item("repeat"), get_text_input_item("repeat")] await Runner.run(agent, input=input_items) - sent_input = model.last_turn_args["input"] + sent_input = model.calls[-1].input assert isinstance(sent_input, list) assert len(sent_input) == 2 - assert sent_input[0]["content"] == "repeat" - assert sent_input[1]["content"] == "repeat" + assert cast(dict[str, Any], sent_input[0])["content"] == "repeat" + assert cast(dict[str, Any], sent_input[1])["content"] == "repeat" diff --git a/tests/test_run_config.py b/tests/test_run_config.py index 5c6fbeffa7..1e61f4e8d6 100644 --- a/tests/test_run_config.py +++ b/tests/test_run_config.py @@ -19,8 +19,8 @@ from agents.run_config import SandboxConcurrencyLimits, SandboxRunConfig from agents.sandbox.manifest import Manifest from agents.sandbox.snapshot import NoopSnapshotSpec +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_text_message @@ -30,7 +30,7 @@ class DummyProvider(ModelProvider): def __init__(self, model_to_return: Model | None = None) -> None: self.last_requested: str | None = None - self.model_to_return: Model = model_to_return or FakeModel() + self.model_to_return: Model = model_to_return or ScriptedModel() def get_model(self, model_name: str | None) -> Model: # record the requested model name and return our test model @@ -119,7 +119,7 @@ def test_run_config_rejects_unknown_first_party_dictionary_fields( @pytest.mark.asyncio async def test_runner_accepts_dictionary_run_configuration() -> None: - model = FakeModel(initial_output=[get_text_message("done")]) + model = ScriptedModel(steps=[[get_text_message("done")]]) agent = Agent(name="test", model=model) result = await Runner.run( @@ -138,8 +138,8 @@ async def test_model_provider_on_run_config_is_used_for_agent_model_name() -> No provided in the ``RunConfig``, the ``Runner`` should resolve the model using the ``model_provider`` on the ``RunConfig``. """ - fake_model = FakeModel(initial_output=[get_text_message("from-provider")]) - provider = DummyProvider(model_to_return=fake_model) + scripted_model = ScriptedModel(steps=[[get_text_message("from-provider")]]) + provider = DummyProvider(model_to_return=scripted_model) agent = Agent(name="test", model="test-model") run_config = RunConfig(model_provider=provider) result = await Runner.run(agent, input="any", run_config=run_config) @@ -154,8 +154,8 @@ async def test_run_config_model_name_override_takes_precedence() -> None: When a model name string is set on the RunConfig, then that name should be looked up using the RunConfig's model_provider, and should override any model on the agent. """ - fake_model = FakeModel(initial_output=[get_text_message("override-name")]) - provider = DummyProvider(model_to_return=fake_model) + scripted_model = ScriptedModel(steps=[[get_text_message("override-name")]]) + provider = DummyProvider(model_to_return=scripted_model) agent = Agent(name="test", model="agent-model") run_config = RunConfig(model="override-name", model_provider=provider) result = await Runner.run(agent, input="any", run_config=run_config) @@ -179,14 +179,15 @@ async def test_run_config_model_name_override_uses_model_specific_default_settin than the default fallback model. """ monkeypatch.setenv("OPENAI_DEFAULT_MODEL", "gpt-5.4-mini") - fake_model = FakeModel(initial_output=[get_text_message("override-name")]) - provider = DummyProvider(model_to_return=fake_model) + scripted_model = ScriptedModel(steps=[[get_text_message("override-name")]]) + provider = DummyProvider(model_to_return=scripted_model) agent = Agent(name="test") run_config = RunConfig(model=model_name, model_provider=provider) result = await Runner.run(agent, input="any", run_config=run_config) assert result.final_output == "override-name" - assert fake_model.first_turn_args is not None - model_settings = fake_model.first_turn_args["model_settings"] + assert bool(scripted_model.calls) + model_settings = scripted_model.calls[0].model_settings + assert model_settings.reasoning is not None assert model_settings.reasoning.effort == reasoning_effort assert model_settings.verbosity == "low" @@ -199,8 +200,8 @@ async def test_run_config_model_settings_override_implicit_model_specific_defaul RunConfig model settings should overlay the implicit defaults for the resolved model name. """ monkeypatch.setenv("OPENAI_DEFAULT_MODEL", "gpt-5.4-mini") - fake_model = FakeModel(initial_output=[get_text_message("override-name")]) - provider = DummyProvider(model_to_return=fake_model) + scripted_model = ScriptedModel(steps=[[get_text_message("override-name")]]) + provider = DummyProvider(model_to_return=scripted_model) agent = Agent(name="test") run_config = RunConfig( model="gpt-5", @@ -209,8 +210,9 @@ async def test_run_config_model_settings_override_implicit_model_specific_defaul ) result = await Runner.run(agent, input="any", run_config=run_config) assert result.final_output == "override-name" - assert fake_model.first_turn_args is not None - model_settings = fake_model.first_turn_args["model_settings"] + assert bool(scripted_model.calls) + model_settings = scripted_model.calls[0].model_settings + assert model_settings.reasoning is not None assert model_settings.reasoning.effort == "low" assert model_settings.verbosity == "low" assert model_settings.temperature == 0.3 @@ -222,11 +224,11 @@ async def test_run_config_model_override_object_takes_precedence() -> None: When a concrete Model instance is set on the RunConfig, then that instance should be returned by AgentRunner._get_model regardless of the agent's model. """ - fake_model = FakeModel(initial_output=[get_text_message("override-object")]) + scripted_model = ScriptedModel(steps=[[get_text_message("override-object")]]) agent = Agent(name="test", model="agent-model") - run_config = RunConfig(model=fake_model) + run_config = RunConfig(model=scripted_model) result = await Runner.run(agent, input="any", run_config=run_config) - # Our FakeModel on the RunConfig should have been used. + # The ScriptedModel on the RunConfig should have been used. assert result.final_output == "override-object" @@ -237,13 +239,13 @@ async def test_agent_model_object_is_used_when_present() -> None: not specify a model override, then that object should be used directly without consulting the RunConfig's model_provider. """ - fake_model = FakeModel(initial_output=[get_text_message("from-agent-object")]) + scripted_model = ScriptedModel(steps=[[get_text_message("from-agent-object")]]) provider = DummyProvider() - agent = Agent(name="test", model=fake_model) + agent = Agent(name="test", model=scripted_model) run_config = RunConfig(model_provider=provider) result = await Runner.run(agent, input="any", run_config=run_config) # The dummy provider should never have been called, and the output should come from - # the FakeModel on the agent. + # the ScriptedModel on the agent. assert provider.last_requested is None assert result.final_output == "from-agent-object" @@ -352,7 +354,7 @@ def test_tool_name_collision_policy_rejects_invalid_value() -> None: @pytest.mark.asyncio async def test_runner_dictionary_rejects_invalid_tool_name_collision_policy() -> None: - model = FakeModel(initial_output=[get_text_message("done")]) + model = ScriptedModel(steps=[[get_text_message("done")]]) agent = Agent(name="test", model=model) with pytest.raises( @@ -365,4 +367,4 @@ async def test_runner_dictionary_rejects_invalid_tool_name_collision_policy() -> run_config={"tool_name_collision_policy": cast(Any, "erorr")}, ) - assert model.first_turn_args is None + assert not model.calls diff --git a/tests/test_run_error_details.py b/tests/test_run_error_details.py index 104b248fc4..d97dd9aa99 100644 --- a/tests/test_run_error_details.py +++ b/tests/test_run_error_details.py @@ -3,16 +3,16 @@ import pytest from agents import Agent, MaxTurnsExceeded, RunErrorDetails, Runner +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_function_tool, get_function_tool_call, get_text_message @pytest.mark.asyncio async def test_run_error_includes_data(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, tools=[get_function_tool("foo", "res")]) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_function_tool_call("foo", json.dumps({"a": "b"}))], [get_text_message("done")], @@ -29,9 +29,9 @@ async def test_run_error_includes_data(): @pytest.mark.asyncio async def test_streamed_run_error_includes_data(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, tools=[get_function_tool("foo", "res")]) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("1"), get_function_tool_call("foo", json.dumps({"a": "b"}))], [get_text_message("done")], diff --git a/tests/test_run_hooks.py b/tests/test_run_hooks.py index e580651b8c..562ff3e358 100644 --- a/tests/test_run_hooks.py +++ b/tests/test_run_hooks.py @@ -7,15 +7,14 @@ from agents.agent import Agent from agents.items import ItemHelpers, ModelResponse, TResponseInputItem from agents.lifecycle import AgentHooks, RunHooks -from agents.models.interface import Model from agents.run import Runner from agents.run_context import AgentHookContext, RunContextWrapper, TContext from agents.run_internal.run_loop import validate_run_hooks +from agents.testing import ModelStep, ScriptedModel from agents.tool import Tool, function_tool from agents.tool_context import ToolContext from tests.test_agent_llm_hooks import AgentHooksForTests -from .fake_model import FakeModel from .test_responses import ( get_function_tool, get_function_tool_call, @@ -89,11 +88,11 @@ async def on_llm_end( @pytest.mark.asyncio async def test_async_run_hooks_with_llm(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[]) # Simulate a single LLM call producing an output: - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) await Runner.run(agent, input="hello", hooks=hooks) # Expect one on_agent_start, one on_llm_start, one on_llm_end, and one on_agent_end assert hooks.events == { @@ -107,10 +106,10 @@ async def test_async_run_hooks_with_llm(): # test_sync_run_hook_with_llm() def test_sync_run_hook_with_llm(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[]) # Simulate a single LLM call producing an output: - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) Runner.run_sync(agent, input="hello", hooks=hooks) # Expect one on_agent_start, one on_llm_start, one on_llm_end, and one on_agent_end assert hooks.events == { @@ -125,10 +124,10 @@ def test_sync_run_hook_with_llm(): @pytest.mark.asyncio async def test_streamed_run_hooks_with_llm(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[]) # Simulate a single LLM call producing an output: - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) stream = Runner.run_streamed(agent, input="hello", hooks=hooks) async for event in stream.stream_events(): @@ -160,13 +159,13 @@ async def test_streamed_run_hooks_with_llm(): async def test_async_run_hooks_with_agent_hooks_with_llm(): hooks = RunHooksForTests() agent_hooks = AgentHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent( name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[], hooks=agent_hooks ) # Simulate a single LLM call producing an output: - model.set_next_output([get_text_message("hello")]) + model.enqueue([get_text_message("hello")]) await Runner.run(agent, input="hello", hooks=hooks) # Expect one on_agent_start, one on_llm_start, one on_llm_end, and one on_agent_end assert hooks.events == { @@ -182,13 +181,13 @@ async def test_async_run_hooks_with_agent_hooks_with_llm(): @pytest.mark.asyncio async def test_run_hooks_llm_error_non_streaming(monkeypatch): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[]) async def boom(*args, **kwargs): raise RuntimeError("boom") - monkeypatch.setattr(FakeModel, "get_response", boom, raising=True) + monkeypatch.setattr(ScriptedModel, "get_response", boom, raising=True) with pytest.raises(RuntimeError, match="boom"): await Runner.run(agent, input="hello", hooks=hooks) @@ -206,7 +205,7 @@ class DummyAgentHooks(AgentHooks): @pytest.mark.asyncio async def test_runner_run_rejects_agent_hooks(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model) hooks = cast(RunHooks, DummyAgentHooks()) @@ -215,7 +214,7 @@ async def test_runner_run_rejects_agent_hooks(): def test_runner_run_streamed_rejects_agent_hooks(): - model = FakeModel() + model = ScriptedModel() agent = Agent(name="A", model=model) hooks = cast(RunHooks, DummyAgentHooks()) @@ -228,13 +227,9 @@ def test_validate_run_hooks_rejects_non_hook_objects() -> None: validate_run_hooks(object()) -class BoomModel(Model): - async def get_response(self, *a, **k): - raise AssertionError("get_response should not be called in streaming test") - - async def stream_response(self, *a, **k): - yield {"foo": "bar"} - raise RuntimeError("stream blew up") +async def _failing_stream(_call): + yield {"foo": "bar"} + raise RuntimeError("stream blew up") @pytest.mark.asyncio @@ -244,7 +239,8 @@ async def test_streamed_run_hooks_llm_error(monkeypatch): but do NOT emit on_llm_end (current behavior), and the exception propagates. """ hooks = RunHooksForTests() - agent = Agent(name="A", model=BoomModel(), tools=[get_function_tool("f", "res")], handoffs=[]) + model = ScriptedModel([ModelStep.stream(_failing_stream)]) + agent = Agent(name="A", model=model, tools=[get_function_tool("f", "res")], handoffs=[]) stream = Runner.run_streamed(agent, input="hello", hooks=hooks) @@ -276,10 +272,10 @@ async def on_agent_start( async def test_run_hooks_receives_turn_input_string(): """Test that on_agent_start receives turn_input when input is a string.""" hooks = RunHooksWithTurnInput() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("response")]) + model.enqueue([get_text_message("response")]) await Runner.run(agent, input="hello world", hooks=hooks) assert len(hooks.captured_turn_inputs) == 1 @@ -293,7 +289,7 @@ async def test_run_hooks_receives_turn_input_string(): async def test_run_hooks_receives_turn_input_list(): """Test that on_agent_start receives turn_input when input is a list.""" hooks = RunHooksWithTurnInput() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) input_items: list[Any] = [ @@ -301,7 +297,7 @@ async def test_run_hooks_receives_turn_input_list(): {"role": "user", "content": "second message"}, ] - model.set_next_output([get_text_message("response")]) + model.enqueue([get_text_message("response")]) await Runner.run(agent, input=input_items, hooks=hooks) assert len(hooks.captured_turn_inputs) == 1 @@ -315,10 +311,10 @@ async def test_run_hooks_receives_turn_input_list(): async def test_run_hooks_receives_turn_input_streamed(): """Test that on_agent_start receives turn_input in streamed mode.""" hooks = RunHooksWithTurnInput() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model) - model.set_next_output([get_text_message("response")]) + model.enqueue([get_text_message("response")]) result = Runner.run_streamed(agent, input="streamed input", hooks=hooks) async for _ in result.stream_events(): pass @@ -332,7 +328,7 @@ async def test_run_hooks_receives_turn_input_streamed(): @pytest.mark.asyncio async def test_run_hooks_count_tool_and_handoff_invocations(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent( @@ -342,7 +338,7 @@ async def test_run_hooks_count_tool_and_handoff_invocations(): tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("some_function", json.dumps({"a": "b"}))], [get_text_message("a_message"), get_handoff_tool_call(agent_1)], @@ -362,7 +358,7 @@ async def test_run_hooks_count_tool_and_handoff_invocations(): @pytest.mark.asyncio async def test_streamed_run_hooks_count_tool_and_handoff_invocations(): hooks = RunHooksForTests() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent(name="test_1", model=model) agent_2 = Agent( @@ -372,7 +368,7 @@ async def test_streamed_run_hooks_count_tool_and_handoff_invocations(): tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="call_1"), @@ -433,10 +429,10 @@ def get_metadata() -> dict[str, object]: run_hooks = RecordingRunHooks() agent_hooks = RecordingAgentHooks() - model = FakeModel() + model = ScriptedModel() agent = Agent(name="test", model=model, tools=[get_metadata], hooks=agent_hooks) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("get_metadata", "{}")], [get_text_message("done")], diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 1bfb118f20..c518c95ee0 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -30,8 +30,8 @@ SingleStepResult, ) from agents.run_state import RunState +from agents.testing import ScriptedModel from agents.usage import Usage -from tests.fake_model import FakeModel from tests.test_responses import get_function_tool_call, get_text_message from tests.utils.hitl import ( make_agent, @@ -44,7 +44,7 @@ @pytest.mark.asyncio async def test_resolve_interrupted_turn_final_output_short_circuit(monkeypatch) -> None: - agent: Agent[dict[str, str]] = make_agent(model=FakeModel()) + agent: Agent[dict[str, str]] = make_agent(model=ScriptedModel()) context_wrapper = make_context_wrapper() async def fake_execute_tool_plan(*_: object, **__: object): diff --git a/tests/test_run_state.py b/tests/test_run_state.py index a69edbb242..81c091f253 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -4,10 +4,9 @@ import gc import importlib -import io import json import logging -from collections.abc import AsyncIterator, Callable, Mapping +from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass from datetime import datetime @@ -39,7 +38,7 @@ from openai.types.responses.tool_param import Mcp from pydantic import BaseModel, ValidationError -from agents import Agent, Model, ModelSettings, RunConfig, RunHooks, Runner, handoff, trace +from agents import Agent, ModelSettings, RunConfig, RunHooks, Runner, handoff, trace from agents._tool_invocation import tool_invocation_identity_and_scope from agents.computer import Computer from agents.exceptions import ModelBehaviorError, UserError @@ -66,7 +65,6 @@ ToolSearchOutputItem, TResponseInputItem, TResponseOutputItem, - TResponseStreamEvent, ) from agents.run_context import RunContextWrapper from agents.run_error_handlers import RunErrorHandlerResult, RunErrorHandlers @@ -110,9 +108,8 @@ from agents.sandbox.capabilities.capability import Capability from agents.sandbox.entries import BaseEntry, Mount, MountStrategyBase from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxSessionState -from agents.sandbox.session.base_sandbox_session import BaseSandboxSession -from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot -from agents.sandbox.types import ExecResult +from agents.sandbox.snapshot import LocalSnapshot +from agents.testing import ModelCall, ModelStep, ScriptedModel, scripted_sandbox_session from agents.tool import ( ApplyPatchTool, ComputerTool, @@ -135,9 +132,7 @@ ) from agents.tracing.traces import TraceState from agents.usage import Usage -from tests.utils.factories import TestSessionState -from .fake_model import FakeModel from .test_responses import ( get_final_output_message, get_function_tool_call, @@ -166,49 +161,6 @@ TContext = TypeVar("TContext") -class _IdentitySandboxSession(BaseSandboxSession): - def __init__(self, root: str) -> None: - self.state = TestSessionState( - manifest=Manifest(root=root), - snapshot=NoopSnapshot(id=f"snapshot:{root}"), - ) - - async def start(self) -> None: - return None - - async def stop(self) -> None: - return None - - async def shutdown(self) -> None: - return None - - async def running(self) -> bool: - return True - - async def read(self, path: Path, *, user: object = None) -> Any: - _ = (path, user) - raise AssertionError("read() should not be called") - - async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None: - _ = (path, data, user) - raise AssertionError("write() should not be called") - - async def _exec_internal( - self, - *command: Any, - timeout: float | None = None, - ) -> ExecResult: - _ = (command, timeout) - raise AssertionError("_exec_internal() should not be called") - - async def persist_workspace(self) -> Any: - raise AssertionError("persist_workspace() should not be called") - - async def hydrate_workspace(self, data: Any) -> None: - _ = data - raise AssertionError("hydrate_workspace() should not be called") - - class _IdentityCapability(Capability): type: str = "identity" setting: str @@ -315,8 +267,8 @@ def __bool__(self) -> bool: trace_state = FalsyTraceState(trace_id="trace_falsy") - model = FakeModel() - model.set_next_output([get_final_output_message("done")]) + model = ScriptedModel() + model.enqueue([get_final_output_message("done")]) result = await Runner.run(Agent(name="test", model=model), "input") result._trace_state = trace_state @@ -324,8 +276,8 @@ def __bool__(self) -> bool: assert isinstance(restored, FalsyTraceState) assert restored.trace_id == "trace_falsy" - streaming_model = FakeModel() - streaming_model.set_next_output([get_final_output_message("done")]) + streaming_model = ScriptedModel() + streaming_model.enqueue([get_final_output_message("done")]) streaming_result = Runner.run_streamed( Agent(name="streaming-test", model=streaming_model), "input", @@ -625,13 +577,21 @@ def test_capability_identity_uses_config_but_not_bound_session(self) -> None: first_alpha_capability = _IdentityCapability(setting="alpha") first_beta_capability = _IdentityCapability(setting="beta") - first_alpha_capability.bind(_IdentitySandboxSession("/workspace/first-alpha")) - first_beta_capability.bind(_IdentitySandboxSession("/workspace/first-beta")) + first_alpha_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/first-alpha")) + ) + first_beta_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/first-beta")) + ) second_alpha_capability = _IdentityCapability(setting="alpha") second_beta_capability = _IdentityCapability(setting="beta") - second_alpha_capability.bind(_IdentitySandboxSession("/workspace/second-alpha")) - second_beta_capability.bind(_IdentitySandboxSession("/workspace/second-beta")) + second_alpha_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/second-alpha")) + ) + second_beta_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/second-beta")) + ) first_alpha_signature = _capability_identity_signature(first_alpha_capability) first_beta_signature = _capability_identity_signature(first_beta_capability) @@ -707,8 +667,8 @@ async def test_result_to_state_preserves_duplicate_name_root_and_owned_state(sel def approval_tool() -> str: return "approved" - first_model = FakeModel() - second_model = FakeModel() + first_model = ScriptedModel() + second_model = ScriptedModel() first = Agent(name="duplicate", model=first_model) second = Agent( name="duplicate", @@ -719,8 +679,8 @@ def approval_tool() -> str: first.handoffs = [second] second.handoffs = [first] - first_model.add_multiple_turn_outputs([[get_handoff_tool_call(second)]]) - second_model.add_multiple_turn_outputs( + first_model.extend([[get_handoff_tool_call(second)]]) + second_model.extend( [[get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")]] ) @@ -1930,7 +1890,7 @@ async def on_agent_start(self, context: Any, _agent: Agent[Any]) -> None: probe_agent = Agent( name="ApprovalProbeAgent", - model=FakeModel(initial_output=[get_text_message("done")]), + model=ScriptedModel(steps=[[get_text_message("done")]]), ) await Runner.run( probe_agent, @@ -4452,109 +4412,52 @@ def _has_function_call_output(input_data: str | list[TResponseInputItem]) -> boo return True return False - class ResumeAwareToolModel(Model): - def __init__( - self, - *, - tool_name: str, - tool_arguments: str, - final_text: str, - call_prefix: str, - preceding_tool_name: str | None = None, - ) -> None: - self.tool_name = tool_name - self.tool_arguments = tool_arguments - self.final_text = final_text - self.call_prefix = call_prefix - self.preceding_tool_name = preceding_tool_name - self.call_count = 0 - - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Any], - output_schema: Any, - handoffs: list[Any], - tracing: Any, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> ModelResponse: - del ( - system_instructions, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id, - conversation_id, - prompt, - ) - if _has_function_call_output(input): + def _make_resume_aware_tool_model( + *, + tool_name: str, + tool_arguments: str, + final_text: str, + call_prefix: str, + preceding_tool_name: str | None = None, + ) -> ScriptedModel: + tool_call_count = 0 + + def _respond(call: ModelCall) -> ModelResponse: + nonlocal tool_call_count + if _has_function_call_output(call.input): return ModelResponse( - output=[get_text_message(self.final_text)], + output=[get_text_message(final_text)], usage=Usage(), - response_id=f"{self.call_prefix}-done", + response_id=f"{call_prefix}-done", ) - self.call_count += 1 + tool_call_count += 1 output: list[TResponseOutputItem] = [] - if self.preceding_tool_name is not None: + if preceding_tool_name is not None: output.append( ResponseFunctionToolCall( type="function_call", - name=self.preceding_tool_name, - call_id=f"{self.call_prefix}-preceding-{self.call_count}", + name=preceding_tool_name, + call_id=f"{call_prefix}-preceding-{tool_call_count}", arguments="{}", ) ) output.append( ResponseFunctionToolCall( type="function_call", - name=self.tool_name, - call_id=f"{self.call_prefix}-{id(self)}-{self.call_count}", - arguments=self.tool_arguments, + name=tool_name, + call_id=f"{call_prefix}-{id(model)}-{tool_call_count}", + arguments=tool_arguments, ) ) return ModelResponse( output=output, usage=Usage(), - response_id=f"{self.call_prefix}-call-{self.call_count}", + response_id=f"{call_prefix}-call-{tool_call_count}", ) - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Any], - output_schema: Any, - handoffs: list[Any], - tracing: Any, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> AsyncIterator[TResponseStreamEvent]: - del ( - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id, - conversation_id, - prompt, - ) - if False: - yield cast(TResponseStreamEvent, {}) - raise RuntimeError("Streaming is not supported in this test.") + model = ScriptedModel(ModelStep.respond(_respond) for _ in range(3)) + return model tool_calls: list[str] = [] @@ -4563,7 +4466,7 @@ async def inner_sensitive_tool(text: str) -> str: tool_calls.append(text) return f"approved:{text}" - inner_model = ResumeAwareToolModel( + inner_model = _make_resume_aware_tool_model( tool_name="inner_sensitive_tool", tool_arguments=json.dumps({"text": "hello"}), final_text="inner-complete", @@ -4575,7 +4478,7 @@ async def inner_sensitive_tool(text: str) -> str: tool_name="inner_agent_tool", tool_description="Inner agent tool", ) - outer_model = ResumeAwareToolModel( + outer_model = _make_resume_aware_tool_model( tool_name="inner_agent_tool", tool_arguments=json.dumps({"input": "hello"}), final_text="outer-complete", @@ -4628,6 +4531,8 @@ async def inner_sensitive_tool(text: str) -> str: assert resumed_result_two.final_output == "outer-complete" assert resumed_result_two.interruptions == [] assert tool_calls == (["hello", "hello"] if approve_nested_tool else []) + inner_model.assert_complete() + outer_model.assert_complete() async def test_json_decode_error_handling(self): """Test that invalid JSON raises appropriate error.""" @@ -4667,18 +4572,18 @@ class TestRunStateResumption: @pytest.mark.asyncio async def test_resume_from_run_state(self): """Test resuming a run from a RunState.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) # First run - create a state - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input") # Create RunState from result state = result1.to_state() # Resume from state - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = await Runner.run(agent, state) assert result2.final_output == "Second response" @@ -4686,16 +4591,16 @@ async def test_resume_from_run_state(self): @pytest.mark.asyncio async def test_resume_from_run_state_does_not_mutate_source_result(self): """Resuming from a state must not append to the raw_responses already returned.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input") assert len(result1.raw_responses) == 1 state = result1.to_state() - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = await Runner.run(agent, state) # The second run accumulates on top of the first, but the RunResult that was @@ -4707,15 +4612,15 @@ async def test_resume_from_run_state_does_not_mutate_source_result(self): @pytest.mark.asyncio async def test_resume_does_not_append_to_the_state_it_resumed_from(self): """A resumed run must not accumulate its responses into the caller's checkpoint.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input") state = result1.to_state() serialized_before = state.to_json()["model_responses"] - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = await Runner.run(agent, state) assert len(result2.raw_responses) == 2 @@ -4725,22 +4630,22 @@ async def test_resume_does_not_append_to_the_state_it_resumed_from(self): assert state.to_json()["model_responses"] == serialized_before # Re-running the same checkpoint therefore replays only its own history. - model.set_next_output([get_text_message("Third response")]) + model.enqueue([get_text_message("Third response")]) result3 = await Runner.run(agent, state) assert len(result3.raw_responses) == 2 @pytest.mark.asyncio async def test_streamed_resume_does_not_append_to_the_state_it_resumed_from(self): """A streamed resume must not accumulate its items into the caller's checkpoint.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input") state = result1.to_state() serialized_before = state.to_json()["session_items"] - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = Runner.run_streamed(agent, state) async for _ in result2.stream_events(): pass @@ -4750,7 +4655,7 @@ async def test_streamed_resume_does_not_append_to_the_state_it_resumed_from(self assert state.to_json()["session_items"] == serialized_before # Without this, the abandoned attempt's message leaks into the replayed history. - model.set_next_output([get_text_message("Third response")]) + model.enqueue([get_text_message("Third response")]) result3 = Runner.run_streamed(agent, state) async for _ in result3.stream_events(): pass @@ -4760,10 +4665,10 @@ async def test_streamed_resume_does_not_append_to_the_state_it_resumed_from(self @pytest.mark.asyncio async def test_resumed_max_turns_handler_does_not_append_to_state_items(self): """A resumed run that trips max turns must not append to the state's items.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input", max_turns=1) state = result1.to_state() serialized_before = state.to_json()["generated_items"] @@ -4780,15 +4685,15 @@ async def test_resumed_max_turns_handler_does_not_append_to_state_items(self): @pytest.mark.asyncio async def test_fresh_runs_still_report_their_own_history(self): """Boundary: a run that starts without a state is unaffected by the copies.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input") assert len(result1.raw_responses) == 1 assert len(result1.new_items) == 1 - model.set_next_output([get_text_message("Streamed response")]) + model.enqueue([get_text_message("Streamed response")]) result2 = Runner.run_streamed(agent, "Second input") async for _ in result2.stream_events(): pass @@ -4798,12 +4703,12 @@ async def test_fresh_runs_still_report_their_own_history(self): @pytest.mark.asyncio async def test_resume_from_run_state_with_context(self): """Test resuming a run from a RunState with context override.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) # First run with context context1 = {"key": "value1"} - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input", context=context1) # Create RunState from result @@ -4811,7 +4716,7 @@ async def test_resume_from_run_state_with_context(self): # Resume from state with different context (should use new context) context2 = {"key": "value2"} - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = await Runner.run(agent, state, context=context2) # New context should be used. @@ -4823,18 +4728,18 @@ async def test_resume_from_run_state_with_context(self): @pytest.mark.asyncio async def test_resume_from_run_state_with_conversation_id(self): """Test resuming a run from a RunState with conversation_id.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) # First run - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input", conversation_id="conv123") # Create RunState from result state = result1.to_state() # Resume from state with conversation_id - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = await Runner.run(agent, state, conversation_id="conv123") assert result2.final_output == "Second response" @@ -4842,18 +4747,18 @@ async def test_resume_from_run_state_with_conversation_id(self): @pytest.mark.asyncio async def test_resume_from_run_state_with_previous_response_id(self): """Test resuming a run from a RunState with previous_response_id.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) # First run - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input", previous_response_id="resp123") # Create RunState from result state = result1.to_state() # Resume from state with previous_response_id - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = await Runner.run(agent, state, previous_response_id="resp123") assert result2.final_output == "Second response" @@ -4861,7 +4766,7 @@ async def test_resume_from_run_state_with_previous_response_id(self): @pytest.mark.asyncio async def test_resume_from_run_state_with_interruption(self): """Test resuming a run from a RunState with an interruption.""" - model = FakeModel() + model = ScriptedModel() async def tool_func() -> str: return "tool_result" @@ -4875,7 +4780,8 @@ async def tool_func() -> str: ) # First run - create an interruption - model.set_next_output([get_function_tool_call("test_tool", "{}")]) + model.enqueue([get_function_tool_call("test_tool", "{}")]) + model.enqueue([]) result1 = await Runner.run(agent, "First input") # Create RunState from result @@ -4886,7 +4792,7 @@ async def tool_func() -> str: state.approve(state.get_interruptions()[0]) # Resume from state - should execute approved tools - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = await Runner.run(agent, state) assert result2.final_output == "Second response" @@ -4894,18 +4800,18 @@ async def tool_func() -> str: @pytest.mark.asyncio async def test_resume_from_run_state_streamed(self): """Test resuming a run from a RunState using run_streamed.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) # First run - model.set_next_output([get_text_message("First response")]) + model.enqueue([get_text_message("First response")]) result1 = await Runner.run(agent, "First input") # Create RunState from result state = result1.to_state() # Resume from state using run_streamed - model.set_next_output([get_text_message("Second response")]) + model.enqueue([get_text_message("Second response")]) result2 = Runner.run_streamed(agent, state) events = [] @@ -4920,8 +4826,8 @@ async def test_resume_from_run_state_streamed(self): async def test_resume_from_run_state_streamed_uses_context_from_state(self): """Test that streaming with RunState uses context from state.""" - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="TestAgent", model=model) # Create a RunState with context @@ -4940,8 +4846,8 @@ async def test_resume_from_run_state_streamed_uses_context_from_state(self): async def test_resume_from_run_state_streamed_with_context_override(self): """Test that streaming uses provided context override when resuming.""" - model = FakeModel() - model.set_next_output([get_text_message("done")]) + model = ScriptedModel() + model.enqueue([get_text_message("done")]) agent = Agent(name="TestAgent", model=model) # Create a RunState with context @@ -4959,7 +4865,7 @@ async def test_resume_from_run_state_streamed_with_context_override(self): @pytest.mark.asyncio async def test_run_result_streaming_to_state_with_interruptions(self): """Test RunResultStreaming.to_state() sets _current_step with interruptions.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="TestAgent", model=model) async def test_tool() -> str: @@ -4969,7 +4875,7 @@ async def test_tool() -> str: agent.tools = [tool] # Create a run that will have interruptions - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("test_tool", json.dumps({}))], [get_text_message("done")], @@ -9549,7 +9455,7 @@ async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: return text model, agent = make_model_and_agent(tools=[needs_ok], name="agent") - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("needs_ok", json.dumps({"text": "one"}), call_id="1")], [get_final_output_message("done")], @@ -9647,17 +9553,17 @@ async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: output_tokens=3, total_tokens=20, ) - nested_model = FakeModel() - nested_model.set_hardcoded_usage(nested_turn_usage) + nested_model = ScriptedModel() + nested_model.set_default_usage(nested_turn_usage) nested_agent = Agent(name="nested", tools=[needs_ok], model=nested_model) - nested_model.add_multiple_turn_outputs( + nested_model.extend( [ [get_function_tool_call("needs_ok", json.dumps({"text": "one"}), call_id="inner-1")], [get_final_output_message("nested-done")], ] ) - outer_model = FakeModel() + outer_model = ScriptedModel() outer = Agent( name="outer", tools=[ @@ -9669,7 +9575,7 @@ async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: ], model=outer_model, ) - outer_model.add_multiple_turn_outputs( + outer_model.extend( [ [ get_function_tool_call( diff --git a/tests/test_run_state_pending_input.py b/tests/test_run_state_pending_input.py index 1c19590010..fdf1d2cd71 100644 --- a/tests/test_run_state_pending_input.py +++ b/tests/test_run_state_pending_input.py @@ -19,10 +19,11 @@ from agents.run_internal.oai_conversation import OpenAIServerConversationTracker from agents.run_internal.run_steps import NextStepInterruption, NextStepRunAgain from agents.run_state import CURRENT_SCHEMA_VERSION, RunState +from agents.testing import ScriptedModel from agents.tool import Tool from agents.usage import Usage -from .fake_model import FakeModel +from .model_test_helpers import get_exact_output_stream_step from .test_computer_tool_lifecycle import FakeComputer from .test_responses import get_function_tool_call, get_text_message from .utils.simple_session import SimpleListSession @@ -53,7 +54,7 @@ async def _make_after_turn_state( *, session: SimpleListSession | None = None, auto_previous_response_id: bool = False, -) -> tuple[FakeModel, Agent[Any], RunState[Any], list[str]]: +) -> tuple[ScriptedModel, Agent[Any], RunState[Any], list[str]]: calls: list[str] = [] @function_tool(name_override="record_destination") @@ -61,8 +62,8 @@ def record_destination(destination: str) -> str: calls.append(destination) return f"recorded:{destination}" - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [ get_function_tool_call( "record_destination", @@ -136,13 +137,13 @@ async def test_after_turn_resume_admits_input_after_tool_output_exactly_once() - session = SimpleListSession() model, agent, state, calls = await _make_after_turn_state(session=session) state.add_input("Change the destination to Tokyo") - model.set_next_output([get_text_message("Updated")]) + model.enqueue([get_text_message("Updated")]) result = await Runner.run(agent, state, session=session) assert result.final_output == "Updated" assert calls == ["Paris"] - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_item_type(item) for item in model_input] == [ "user", "function_call", @@ -171,7 +172,7 @@ async def test_after_turn_resume_admits_input_after_tool_output_exactly_once() - async def test_streamed_resume_matches_pending_input_ordering() -> None: model, agent, state, calls = await _make_after_turn_state() state.add_input("Change the destination to Tokyo") - model.set_next_output([get_text_message("Updated")]) + model.enqueue([get_text_message("Updated")]) result = Runner.run_streamed(agent, state) async for _ in result.stream_events(): @@ -179,7 +180,7 @@ async def test_streamed_resume_matches_pending_input_ordering() -> None: assert result.final_output == "Updated" assert calls == ["Paris"] - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_item_type(item) for item in model_input] == [ "user", "function_call", @@ -199,14 +200,14 @@ async def test_streamed_resume_matches_pending_input_ordering() -> None: async def test_server_managed_resume_sends_pending_input_as_unsent_delta_once() -> None: model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Change the destination to Tokyo") - model.set_next_output([get_text_message("Updated")]) + model.enqueue([get_text_message("Updated")]) result = await Runner.run(agent, state) assert result.final_output == "Updated" assert calls == ["Paris"] - assert model.last_turn_args["previous_response_id"] == "resp-789" - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + assert model.calls[-1].previous_response_id == "resp-789" + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_item_type(item) for item in model_input] == ["function_call_output", "user"] assert [_message_text(item) for item in model_input].count( "Change the destination to Tokyo" @@ -243,7 +244,7 @@ async def test_server_managed_resume_sends_identical_late_input_in_later_occurre ) -> None: model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Repeat") - model.set_next_output( + model.enqueue( [ get_function_tool_call( "record_destination", @@ -261,7 +262,7 @@ async def test_server_managed_resume_sends_identical_late_input_in_later_occurre state = await RunState.from_json(agent, first_resume.to_state().to_json()) admitted_before = next(item for item in state._generated_items if isinstance(item, InputItem)) state.add_input("Repeat") - model.set_next_output([get_text_message("Done")]) + model.enqueue([get_text_message("Done")]) if streamed_second_resume: streamed_result = Runner.run_streamed(agent, state) @@ -274,7 +275,7 @@ async def test_server_managed_resume_sends_identical_late_input_in_later_occurre assert final_output == "Done" assert calls == ["Paris", "Rome"] - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Repeat") == 1 admitted_after = [item for item in state._generated_items if isinstance(item, InputItem)] assert [item.input_id for item in admitted_after].count(admitted_before.input_id) == 1 @@ -290,8 +291,8 @@ def protected_tool(value: str) -> str: calls.append(value) return f"approved:{value}" - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [get_function_tool_call("protected_tool", '{"value":"one"}', call_id="call-protected")] ) agent = Agent(name="assistant", model=model, tools=[protected_tool]) @@ -305,12 +306,12 @@ def protected_tool(value: str) -> str: assert _message_text(state.pending_input[0]) == "Late input" state.approve(state.get_interruptions()[0]) - model.set_next_output([get_text_message("Done")]) + model.enqueue([get_text_message("Done")]) resumed = await Runner.run(agent, state) assert resumed.final_output == "Done" assert calls == ["one"] - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_item_type(item) for item in model_input][-2:] == ["function_call_output", "user"] assert _message_text(model_input[-1]) == "Late input" @@ -324,8 +325,8 @@ def protected_tool(value: str) -> str: calls.append(value) return f"approved:{value}" - model = FakeModel() - model.set_next_output( + model = ScriptedModel() + model.enqueue( [get_function_tool_call("protected_tool", '{"value":"one"}', call_id="call-protected")] ) agent = Agent(name="assistant", model=model, tools=[protected_tool]) @@ -342,10 +343,10 @@ def protected_tool(value: str) -> str: assert calls == ["one"] assert _message_text(state.pending_input[0]) == "Late input" - model.set_next_output([get_text_message("Done")]) + model.enqueue([get_text_message("Done")]) result = await Runner.run(agent, state) assert result.final_output == "Done" - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Late input") == 1 @@ -367,13 +368,15 @@ async def test_interruption_without_guaranteed_next_model_rejects_input( def protected_tool(value: str) -> str: return value - model = FakeModel( - initial_output=[ - get_function_tool_call( - "protected_tool", - '{"value":"one"}', - call_id="call-protected-terminal", - ) + model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "protected_tool", + '{"value":"one"}', + call_id="call-protected-terminal", + ) + ] ] ) agent = Agent( @@ -412,13 +415,13 @@ def trip_pending_input( agent.input_guardrails = [InputGuardrail(guardrail_function=trip_pending_input)] state.add_input("Unsafe late input") - model.set_next_output([get_text_message("Must not run")]) - queued_outputs = len(model.turn_outputs) + model.enqueue([get_text_message("Must not run")]) + queued_outputs = model.remaining_steps with pytest.raises(InputGuardrailTripwireTriggered): await Runner.run(agent, state) - assert len(model.turn_outputs) == queued_outputs + assert model.remaining_steps == queued_outputs assert [[_message_text(item) for item in batch] for batch in guarded_inputs] == [ ["Unsafe late input"] ] @@ -453,7 +456,7 @@ def inspect_config_input( input_guardrails=[InputGuardrail(guardrail_function=inspect_config_input)] ) state.add_input("Guard only this") - model.set_next_output([get_text_message("Done")]) + model.enqueue([get_text_message("Done")]) result = await Runner.run(agent, state, run_config=run_config) @@ -494,7 +497,7 @@ def inspect_pending_input( await Runner.run(agent, state, session=session) should_trip = False - model.set_next_output([get_text_message("Recovered")]) + model.enqueue([get_text_message("Recovered")]) if streamed_retry: streamed_result = Runner.run_streamed(agent, state, session=session) async for _event in streamed_result.stream_events(): @@ -519,7 +522,7 @@ def inspect_pending_input( async def test_failed_model_request_does_not_duplicate_admitted_input_on_resume() -> None: model, agent, state, _calls = await _make_after_turn_state() state.add_input("Late input") - model.set_next_output(RuntimeError("model failed")) + model.enqueue(RuntimeError("model failed")) with pytest.raises(RuntimeError, match="model failed"): await Runner.run(agent, state) @@ -534,10 +537,10 @@ async def test_failed_model_request_does_not_duplicate_admitted_input_on_resume( next(item.input_id for item in state._generated_items if isinstance(item, InputItem)) == admitted_input_id ) - model.set_next_output([get_text_message("Recovered")]) + model.enqueue([get_text_message("Recovered")]) result = await Runner.run(agent, state) assert result.final_output == "Recovered" - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Late input") == 1 @@ -546,7 +549,7 @@ async def test_failed_model_request_with_session_persists_admitted_input_once() session = SimpleListSession() model, agent, state, _calls = await _make_after_turn_state(session=session) state.add_input("Late input") - model.set_next_output(RuntimeError("model failed")) + model.enqueue(RuntimeError("model failed")) with pytest.raises(RuntimeError, match="model failed"): await Runner.run(agent, state, session=session) @@ -555,10 +558,10 @@ async def test_failed_model_request_with_session_persists_admitted_input_once() assert [_message_text(item) for item in await session.get_items()].count("Late input") == 1 state = await RunState.from_json(agent, state.to_json()) - model.set_next_output([get_text_message("Recovered")]) + model.enqueue([get_text_message("Recovered")]) result = await Runner.run(agent, state, session=session) assert result.final_output == "Recovered" - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Late input") == 1 assert [_message_text(item) for item in await session.get_items()].count("Late input") == 1 @@ -567,17 +570,17 @@ async def test_failed_model_request_with_session_persists_admitted_input_once() async def test_failed_server_managed_request_keeps_pending_input_for_retry() -> None: model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Late input") - model.set_next_output(RuntimeError("model failed")) + model.enqueue(RuntimeError("model failed")) with pytest.raises(RuntimeError, match="model failed"): await Runner.run(agent, state) assert _message_text(state.pending_input[0]) == "Late input" state = await RunState.from_json(agent, state.to_json()) - model.set_next_output([get_text_message("Recovered")]) + model.enqueue([get_text_message("Recovered")]) result = await Runner.run(agent, state) assert result.final_output == "Recovered" - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Late input") == 1 assert state.pending_input == [] @@ -586,7 +589,7 @@ async def test_failed_server_managed_request_keeps_pending_input_for_retry() -> async def test_server_filter_omission_remains_pending_for_later_nonstream_turn() -> None: model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Late input") - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call( @@ -616,7 +619,7 @@ def omit_first_request(data: CallModelData[Any]) -> ModelInputData: assert result.final_output == "Done" assert calls == ["Paris", "Rome"] - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Late input") == 1 assert state.pending_input == [] @@ -625,7 +628,7 @@ def omit_first_request(data: CallModelData[Any]) -> ModelInputData: async def test_server_filter_omission_survives_streamed_state_round_trip() -> None: model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Late input") - model.set_next_output( + model.enqueue( [ get_function_tool_call( "record_destination", @@ -651,11 +654,11 @@ def omit_pending(data: CallModelData[Any]) -> ModelInputData: assert [_message_text(item) for item in state.pending_input] == ["Late input"] assert not any(isinstance(item, InputItem) for item in state._generated_items) - model.set_next_output([get_text_message("Done")]) + model.enqueue([get_text_message("Done")]) result = await Runner.run(agent, state) assert result.final_output == "Done" assert calls == ["Paris", "Rome"] - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Late input") == 1 @@ -664,7 +667,7 @@ def omit_pending(data: CallModelData[Any]) -> ModelInputData: async def test_server_filter_reconstructed_pending_rewrite_is_rejected(streamed: bool) -> None: model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Late input") - model.set_next_output([get_text_message("Done")]) + model.enqueue([get_text_message("Done")]) def reconstruct_pending(data: CallModelData[Any]) -> ModelInputData: rewritten = [ @@ -678,7 +681,7 @@ def reconstruct_pending(data: CallModelData[Any]) -> ModelInputData: instructions=data.model_data.instructions, ) - queued_outputs = len(model.turn_outputs) + queued_outputs = model.remaining_steps run_config = RunConfig(call_model_input_filter=reconstruct_pending) if streamed: failed = Runner.run_streamed(agent, state, run_config=run_config) @@ -689,7 +692,7 @@ def reconstruct_pending(data: CallModelData[Any]) -> ModelInputData: with pytest.raises(UserError, match="cannot safely associate"): await Runner.run(agent, state, run_config=run_config) - assert len(model.turn_outputs) == queued_outputs + assert model.remaining_steps == queued_outputs assert [_message_text(item) for item in state.pending_input] == ["Late input"] @@ -697,7 +700,7 @@ def reconstruct_pending(data: CallModelData[Any]) -> ModelInputData: async def test_server_filter_in_place_pending_rewrite_preserves_occurrence() -> None: model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Late input") - model.set_next_output([get_text_message("Done")]) + model.enqueue([get_text_message("Done")]) def rewrite_pending_in_place(data: CallModelData[Any]) -> ModelInputData: for item in data.model_data.input: @@ -712,7 +715,7 @@ def rewrite_pending_in_place(data: CallModelData[Any]) -> ModelInputData: ) assert result.final_output == "Done" - model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in model_input].count("Filtered late input") == 1 assert state.pending_input == [] @@ -747,7 +750,7 @@ async def on_llm_end( agent_hooks = CountAgentResponseHook() agent.hooks = agent_hooks state.add_input("Late input") - model.set_next_output([get_text_message("Accepted")]) + model.enqueue([get_text_message("Accepted")]) if streamed_failure: failed = Runner.run_streamed(agent, state, hooks=FailAfterResponse()) @@ -758,7 +761,7 @@ async def on_llm_end( with pytest.raises(RuntimeError, match="after response"): await Runner.run(agent, state, hooks=FailAfterResponse()) - accepted_model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + accepted_model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in accepted_model_input].count("Late input") == 1 assert state.pending_input == [] assert isinstance(state._current_step, NextStepInterruption) @@ -766,12 +769,12 @@ async def on_llm_end( assert state._current_step.llm_end_hooks_started assert agent_hooks.call_count == 1 state = await RunState.from_json(agent, state.to_json()) - queued_outputs = len(model.turn_outputs) + queued_outputs = model.remaining_steps recovered = await Runner.run(agent, state) assert recovered.final_output == "Accepted" assert agent_hooks.call_count == 1 - assert len(model.turn_outputs) == queued_outputs + assert model.remaining_steps == queued_outputs @pytest.mark.asyncio @@ -781,7 +784,7 @@ async def test_server_acceptance_commits_before_invocation_validation_failure( ) -> None: model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Late input") - model.set_next_output( + model.enqueue( [ get_function_tool_call( "record_destination", @@ -800,7 +803,7 @@ async def test_server_acceptance_commits_before_invocation_validation_failure( with pytest.raises(ModelBehaviorError, match="completed tool call ID"): await Runner.run(agent, state) - accepted_model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + accepted_model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in accepted_model_input].count("Late input") == 1 assert state.pending_input == [] assert isinstance(state._current_step, NextStepInterruption) @@ -809,10 +812,10 @@ async def test_server_acceptance_commits_before_invocation_validation_failure( assert calls == ["Paris"] state = await RunState.from_json(agent, state.to_json()) - queued_outputs = len(model.turn_outputs) + queued_outputs = model.remaining_steps with pytest.raises(UserError, match="accepted model response could not be processed"): await Runner.run(agent, state) - assert len(model.turn_outputs) == queued_outputs + assert model.remaining_steps == queued_outputs assert calls == ["Paris"] @@ -845,18 +848,17 @@ async def on_tool_start( model, agent, state, _calls = await _make_after_turn_state(auto_previous_response_id=True) agent.tools = [ComputerTool(computer=RecordingComputer())] state.add_input("Late input") - model.set_next_output( - [ - ResponseComputerToolCall( - id="computer-item", - type="computer_call", - action=ActionScreenshot(type="screenshot"), - call_id="computer-call", - pending_safety_checks=[], - status="completed", - ) - ] - ) + output = [ + ResponseComputerToolCall( + id="computer-item", + type="computer_call", + action=ActionScreenshot(type="screenshot"), + call_id="computer-call", + pending_safety_checks=[], + status="completed", + ) + ] + model.enqueue(get_exact_output_stream_step(output) if streamed_failure else output) hooks = FailComputerStart() if streamed_failure: @@ -909,7 +911,7 @@ async def on_tool_end( model, agent, state, calls = await _make_after_turn_state(auto_previous_response_id=True) state.add_input("Late input") - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call( @@ -947,13 +949,13 @@ async def on_tool_end( recovered = await Runner.run(agent, state) assert recovered.final_output == "Recovered" assert calls == ["Paris", "Rome"] - retry_model_input = cast(list[TResponseInputItem], model.last_turn_args["input"]) + retry_model_input = cast(list[TResponseInputItem], model.calls[-1].input) assert [_message_text(item) for item in retry_model_input].count("Late input") == 0 @pytest.mark.asyncio async def test_terminal_state_rejects_pending_input_without_mutation() -> None: - model = FakeModel(initial_output=[get_text_message("Done")]) + model = ScriptedModel(steps=[[get_text_message("Done")]]) agent = Agent(name="assistant", model=model) result = await Runner.run(agent, "Initial request") state = result.to_state() diff --git a/tests/test_runner_guardrail_resume.py b/tests/test_runner_guardrail_resume.py index f2d928f717..d7f1b38369 100644 --- a/tests/test_runner_guardrail_resume.py +++ b/tests/test_runner_guardrail_resume.py @@ -15,6 +15,7 @@ SingleStepResult, ) from agents.run_state import RunState +from agents.testing import ScriptedModel from agents.tool_guardrails import ( AllowBehavior, ToolGuardrailFunctionOutput, @@ -24,12 +25,11 @@ ToolOutputGuardrailResult, ) from agents.usage import Usage -from tests.fake_model import FakeModel @pytest.mark.asyncio async def test_runner_resume_preserves_guardrail_results(monkeypatch: pytest.MonkeyPatch) -> None: - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={}) input_guardrail: InputGuardrail[Any] = InputGuardrail( @@ -163,7 +163,7 @@ async def test_runner_resume_preserves_guardrail_results_on_reinterruption( monkeypatch: pytest.MonkeyPatch, ) -> None: """A resumed run that interrupts again must keep the tool guardrail results it carried in.""" - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={}) tool_input_guardrail: ToolInputGuardrail[Any] = ToolInputGuardrail( diff --git a/tests/test_runtime_symmetry_contract.py b/tests/test_runtime_symmetry_contract.py index 1dd7c66229..15bd321321 100644 --- a/tests/test_runtime_symmetry_contract.py +++ b/tests/test_runtime_symmetry_contract.py @@ -8,9 +8,9 @@ from agents import Agent, Runner, Tool, Usage from agents.items import ToolApprovalItem from agents.result import RunResult, RunResultStreaming +from agents.testing import ScriptedModel from agents.usage import serialize_usage -from .fake_model import FakeModel from .test_responses import get_function_tool, get_function_tool_call, get_text_message from .testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans from .utils.simple_session import SimpleListSession @@ -97,12 +97,12 @@ async def _run( @pytest.mark.parametrize("streamed", [False, True]) -async def test_fake_model_records_every_model_visible_request_field(streamed: bool) -> None: - model = FakeModel() - model.set_next_output([get_text_message("READY")]) +async def test_scripted_model_records_every_model_visible_request_field(streamed: bool) -> None: + model = ScriptedModel() + model.enqueue([get_text_message("READY")]) await _run(Agent(name="request-contract-agent", model=model), streamed=streamed) - assert set(model.last_turn_args) == { + assert set(model.calls[-1].__dataclass_fields__) == { "system_instructions", "input", "model_settings", @@ -113,6 +113,7 @@ async def test_fake_model_records_every_model_visible_request_field(streamed: bo "previous_response_id", "conversation_id", "prompt", + "streamed", } @@ -121,11 +122,11 @@ async def test_streamed_and_nonstreamed_runs_have_matching_semantics(scenario: s projections: list[dict[str, Any]] = [] for streamed in (False, True): SPAN_PROCESSOR_TESTING.clear() - model = FakeModel(tracing_enabled=True) - model.set_hardcoded_usage(_detailed_usage()) + model = ScriptedModel(emit_traces=True) + model.set_default_usage(_detailed_usage()) tools: list[Tool] = [] if scenario == "function-tool": - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("release_check", "{}", call_id="call-release")], [get_text_message("READY")], @@ -133,7 +134,7 @@ async def test_streamed_and_nonstreamed_runs_have_matching_semantics(scenario: s ) tools = [get_function_tool("release_check", "checked")] else: - model.set_next_output([get_text_message("READY")]) + model.enqueue([get_text_message("READY")]) agent = Agent(name="symmetry-agent", model=model, tools=tools) session = SimpleListSession(session_id=f"{scenario}-{streamed}") result = await _run(agent, streamed=streamed, session=session) @@ -155,8 +156,8 @@ async def test_streamed_and_nonstreamed_runs_have_matching_semantics(scenario: s async def test_streamed_and_nonstreamed_runs_raise_the_same_exception_class() -> None: exception_classes: list[type[BaseException]] = [] for streamed in (False, True): - model = FakeModel() - model.set_next_output(RuntimeError("release contract failure")) + model = ScriptedModel() + model.enqueue(RuntimeError("release contract failure")) agent = Agent(name="symmetry-agent", model=model) with pytest.raises(RuntimeError) as exc_info: @@ -170,9 +171,9 @@ async def test_approval_resume_cross_modes_have_matching_semantics() -> None: projections: list[dict[str, Any]] = [] for start_streamed, resume_streamed in ((True, False), (False, True)): SPAN_PROCESSOR_TESTING.clear() - model = FakeModel(tracing_enabled=True) - model.set_hardcoded_usage(_detailed_usage()) - model.add_multiple_turn_outputs( + model = ScriptedModel(emit_traces=True) + model.set_default_usage(_detailed_usage()) + model.extend( [ [get_function_tool_call("release_check", "{}", call_id="call-release")], [get_text_message("READY")], diff --git a/tests/test_scripted_model.py b/tests/test_scripted_model.py new file mode 100644 index 0000000000..ebf3ab4c4e --- /dev/null +++ b/tests/test_scripted_model.py @@ -0,0 +1,2205 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any, cast + +import httpx +import pytest +from openai import APIConnectionError +from openai.types.responses import ( + ResponseApplyPatchToolCall, + ResponseCodeInterpreterToolCall, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseCustomToolCall, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseInProgressEvent, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, + ResponseOutputTextAnnotationAddedEvent, + ResponseReasoningSummaryPartAddedEvent, + ResponseReasoningTextDeltaEvent, + ResponseReasoningTextDoneEvent, + ResponseRefusalDeltaEvent, + ResponseRefusalDoneEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, +) +from openai.types.responses.response_output_item import ImageGenerationCall, McpCall +from openai.types.responses.response_output_text import ( + AnnotationFilePath, + AnnotationURLCitation, + Logprob, + LogprobTopLogprob, +) +from openai.types.responses.response_prompt_param import ResponsePromptParam, Variables +from openai.types.responses.response_reasoning_item import Content, ResponseReasoningItem, Summary +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails +from pydantic import ValidationError + +from agents import ( + Agent, + ModelBehaviorError, + ModelRetryAdvice, + ModelRetryAdviceRequest, + ModelRetrySettings, + RunConfig, + Runner, + handoff, + retry_policies, +) +from agents.agent_output import AgentOutputSchema +from agents.items import TResponseInputItem, TResponseOutputItem, TResponseStreamEvent +from agents.model_settings import ModelSettings +from agents.models.interface import ModelTracing +from agents.testing import ( + InvalidModelStep, + ModelCall, + ModelStep, + ModelStepSpec, + ScriptedModel, + UnconsumedModelSteps, + UnexpectedModelCall, + assistant_message, + function_call, +) +from agents.tracing import SpanError +from agents.tracing.scope import Scope +from agents.usage import RequestUsage, Usage, _extract_raw_usage_snapshot +from agents.util._error_tracing import REDACTED_TRACE_ERROR_MESSAGE + +from .model_test_helpers import get_response_obj +from .test_responses import get_function_tool +from .testing_processor import fetch_span_errors + + +@pytest.mark.asyncio +async def test_scripted_model_runs_tool_workflow_and_records_calls() -> None: + model = ScriptedModel( + [ + [function_call("lookup", {"city": "Tokyo"}, call_id="call_1")], + [assistant_message("sunny")], + ] + ) + agent = Agent( + name="weather", + model=model, + tools=[get_function_tool("lookup", "tool result")], + ) + + result = await Runner.run(agent, "weather?") + + assert result.final_output == "sunny" + assert len(model.calls) == 2 + assert model.first_call is not None + assert model.first_call.input == [{"content": "weather?", "role": "user"}] + assert model.last_call is not None + assert model.last_call.streamed is False + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_model_generates_stream_events() -> None: + model = ScriptedModel([[assistant_message("hello")]]) + result = Runner.run_streamed(Agent(name="test", model=model), "hi") + + events = [event async for event in result.stream_events()] + + assert result.final_output == "hello" + assert events + assert model.last_call is not None + assert model.last_call.streamed is True + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_model_stream_preserves_request_usage_details() -> None: + request_entries = [ + RequestUsage( + input_tokens=3, + output_tokens=2, + total_tokens=5, + input_tokens_details=InputTokensDetails.model_validate( + {"cached_tokens": 1, "cache_write_tokens": 0} + ), + output_tokens_details=OutputTokensDetails(reasoning_tokens=1), + ), + RequestUsage( + input_tokens=7, + output_tokens=4, + total_tokens=11, + input_tokens_details=InputTokensDetails.model_validate( + {"cached_tokens": 2, "cache_write_tokens": 3} + ), + output_tokens_details=OutputTokensDetails(reasoning_tokens=2), + ), + ] + usage = Usage( + requests=5, + input_tokens=10, + output_tokens=6, + total_tokens=16, + input_tokens_details=InputTokensDetails.model_validate( + {"cached_tokens": 3, "cache_write_tokens": 3} + ), + output_tokens_details=OutputTokensDetails(reasoning_tokens=3), + request_usage_entries=request_entries, + ) + streamed_model = ScriptedModel([ModelStep(output=[assistant_message("hello")], usage=usage)]) + non_streamed_model = ScriptedModel( + [ModelStep(output=[assistant_message("hello")], usage=usage)] + ) + + streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi") + async for _event in streamed_result.stream_events(): + pass + non_streamed_result = await Runner.run( + Agent(name="non-streamed", model=non_streamed_model), "hi" + ) + + propagated = streamed_result.context_wrapper.usage + assert propagated.requests == 5 + assert propagated.input_tokens == 10 + assert propagated.output_tokens == 6 + assert propagated.total_tokens == 16 + assert propagated.request_usage_entries == request_entries + assert propagated == non_streamed_result.context_wrapper.usage + + +@pytest.mark.asyncio +async def test_scripted_model_stream_counts_default_usage_as_one_request() -> None: + streamed_model = ScriptedModel([[assistant_message("streamed")]]) + non_streamed_model = ScriptedModel([[assistant_message("non-streamed")]]) + + streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi") + async for _event in streamed_result.stream_events(): + pass + non_streamed_result = await Runner.run( + Agent(name="non-streamed", model=non_streamed_model), "hi" + ) + + assert streamed_result.context_wrapper.usage.requests == 1 + assert non_streamed_result.context_wrapper.usage.requests == 1 + + +@pytest.mark.asyncio +async def test_scripted_model_preserves_explicit_zero_request_usage_across_run_modes() -> None: + usage = Usage( + input_tokens=2, + output_tokens=1, + total_tokens=3, + request_usage_entries=[ + RequestUsage( + input_tokens=1, + output_tokens=1, + total_tokens=2, + input_tokens_details=InputTokensDetails.model_validate( + {"cached_tokens": 0, "cache_write_tokens": 0} + ), + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + ) + ], + ) + streamed_model = ScriptedModel([ModelStep(output=[assistant_message("streamed")], usage=usage)]) + non_streamed_model = ScriptedModel( + [ModelStep(output=[assistant_message("non-streamed")], usage=usage)] + ) + + streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi") + async for _event in streamed_result.stream_events(): + pass + non_streamed_result = await Runner.run( + Agent(name="non-streamed", model=non_streamed_model), "hi" + ) + + assert streamed_result.context_wrapper.usage == usage + assert non_streamed_result.context_wrapper.usage == usage + + +@pytest.mark.asyncio +async def test_scripted_model_copies_default_usage_before_retry_accounting() -> None: + default_usage = Usage(requests=1, input_tokens=2, total_tokens=2) + model = ScriptedModel( + [ + APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ), + [assistant_message("first")], + [assistant_message("second")], + ], + default_usage=default_usage, + ) + agent = Agent( + name="test", + model=model, + model_settings=ModelSettings( + retry=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ) + ), + ) + + first_result = await Runner.run(agent, "first") + second_result = await Runner.run(agent, "second") + + assert first_result.context_wrapper.usage.requests == 2 + assert second_result.context_wrapper.usage.requests == 1 + assert default_usage == Usage(requests=1, input_tokens=2, total_tokens=2) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("response_id", [None, ""]) +async def test_scripted_model_stream_preserves_response_id(response_id: str | None) -> None: + streamed_model = ScriptedModel( + [ModelStep(output=[assistant_message("streamed")], response_id=response_id)] + ) + non_streamed_model = ScriptedModel( + [ModelStep(output=[assistant_message("non-streamed")], response_id=response_id)] + ) + + streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi") + async for _event in streamed_result.stream_events(): + pass + non_streamed_result = await Runner.run( + Agent(name="non-streamed", model=non_streamed_model), "hi" + ) + + assert streamed_result.last_response_id == response_id + assert non_streamed_result.last_response_id == response_id + + +@pytest.mark.asyncio +async def test_scripted_model_stream_does_not_chain_absent_response_id() -> None: + def respond(call: ModelCall): + assert call.previous_response_id is None + return [assistant_message("done")] + + model = ScriptedModel( + [ + ModelStep( + output=[function_call("lookup", {}, call_id="call_1")], + response_id=None, + ), + ModelStep.respond(respond), + ] + ) + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("lookup", "tool result")], + ) + + result = Runner.run_streamed(agent, "hi", auto_previous_response_id=True) + async for _event in result.stream_events(): + pass + + assert result.final_output == "done" + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_model_supports_dynamic_responder() -> None: + def respond(call): + assert isinstance(call.input, list) + return [assistant_message(str(call.input[0]["content"]))] + + model = ScriptedModel([ModelStep.respond(respond)]) + + result = await Runner.run(Agent(name="test", model=model), "hello") + + assert result.final_output == "hello" + + +@pytest.mark.asyncio +async def test_scripted_model_accepts_step_mapping() -> None: + usage = Usage(requests=2, input_tokens=3, output_tokens=4, total_tokens=7) + model = ScriptedModel( + [ + { + "output": [assistant_message("mapped")], + "usage": usage, + "response_id": "resp_mapped", + "request_id": "req_mapped", + "raw_usage": {"source": "mapping"}, + } + ] + ) + + response = await model.get_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert response.output == [assistant_message("mapped")] + assert response.usage == usage + assert response.response_id == "resp_mapped" + assert response.request_id == "req_mapped" + assert response.raw_usage is None + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_model_snapshots_model_settings_in_recorded_calls() -> None: + settings = ModelSettings( + tool_choice="auto", + extra_args={"provider": {"mode": "first"}}, + ) + model = ScriptedModel([[assistant_message("first")], [assistant_message("second")]]) + + async for _event in model.stream_response( + None, + [], + settings, + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + pass + settings.tool_choice = "none" + assert settings.extra_args is not None + settings.extra_args["provider"]["mode"] = "second" + await model.get_response( + None, + [], + settings, + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + settings.extra_args["provider"]["mode"] = "after" + + first_settings = model.calls[0].model_settings + second_settings = model.calls[1].model_settings + assert first_settings is not settings + assert first_settings.tool_choice == "auto" + assert first_settings.extra_args == {"provider": {"mode": "first"}} + assert second_settings.tool_choice == "none" + assert second_settings.extra_args == {"provider": {"mode": "second"}} + + +@pytest.mark.asyncio +async def test_scripted_model_snapshots_input_and_prompt_in_recorded_calls() -> None: + input_item: dict[str, Any] = { + "role": "user", + "content": [{"type": "input_text", "text": "first"}], + } + input_items = cast(list[TResponseInputItem], [input_item]) + prompt_variables: dict[str, Variables] = {"topic": "first"} + prompt: ResponsePromptParam = { + "id": "pmpt_1", + "variables": prompt_variables, + } + model = ScriptedModel([[assistant_message("done")]]) + + await model.get_response( + None, + input_items, + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=prompt, + ) + input_item["content"][0]["text"] = "second" + prompt_variables["topic"] = "second" + + assert model.last_call is not None + assert model.last_call.input == [ + { + "role": "user", + "content": [{"type": "input_text", "text": "first"}], + } + ] + assert model.last_call.prompt == { + "id": "pmpt_1", + "variables": {"topic": "first"}, + } + + +@pytest.mark.asyncio +async def test_scripted_model_exposes_detached_read_only_call_history() -> None: + input_item: dict[str, Any] = { + "role": "user", + "content": [{"type": "input_text", "text": "first"}], + } + settings = ModelSettings(extra_args={"provider": {"mode": "first"}}) + tool = get_function_tool("lookup", "tool result") + target = Agent(name="delegate") + handoff_value = handoff(target) + output_schema = AgentOutputSchema(str) + model = ScriptedModel([[assistant_message("done")]]) + + await model.get_response( + None, + cast(list[TResponseInputItem], [input_item]), + settings, + [tool], + output_schema, + [handoff_value], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + history = model.calls + assert isinstance(history, tuple) + with pytest.raises(AttributeError): + cast(Any, history).append(history[0]) + + returned = history[0] + returned.input[0]["content"][0]["text"] = "changed" + assert returned.model_settings.extra_args is not None + returned.model_settings.extra_args["provider"]["mode"] = "changed" + returned.tools.clear() + returned.handoffs.clear() + + retained = model.calls[0] + assert retained.input[0]["content"][0]["text"] == "first" + assert retained.model_settings.extra_args == {"provider": {"mode": "first"}} + assert retained.tools == [tool] + assert retained.handoffs == [handoff_value] + assert retained.tools[0] is tool + assert retained.handoffs[0] is handoff_value + assert retained.output_schema is output_schema + assert retained.tracing is ModelTracing.DISABLED + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_scripted_model_snapshot_failure_has_no_recording_side_effects( + streamed: bool, +) -> None: + expected = RuntimeError("settings snapshot failed") + + class Uncopyable: + def __deepcopy__(self, _memo: dict[int, Any]) -> Any: + raise expected + + responder_calls: list[ModelCall] = [] + + def respond(call: ModelCall) -> list[TResponseOutputItem]: + responder_calls.append(call) + return [assistant_message("unexpected")] + + model = ScriptedModel([ModelStep.respond(respond)]) + settings = ModelSettings(extra_args={"sentinel": Uncopyable()}) + + with pytest.raises(RuntimeError, match="settings snapshot failed") as exc_info: + if streamed: + async for _event in model.stream_response( + None, + [], + settings, + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + pass + else: + await model.get_response( + None, + [], + settings, + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert exc_info.value is expected + assert model.calls == () + assert model.remaining_steps == 1 + assert responder_calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("preserve_raw_usage", [None, False, True]) +async def test_scripted_model_gates_and_snapshots_raw_usage( + streamed: bool, + preserve_raw_usage: bool | None, +) -> None: + raw_usage = {"provider": {"input_tokens": 3}} + model = ScriptedModel([ModelStep(output=[assistant_message("done")], raw_usage=raw_usage)]) + agent = Agent( + name="test", + model=model, + model_settings=ModelSettings(preserve_raw_usage=preserve_raw_usage), + ) + + if streamed: + result = Runner.run_streamed(agent, "hi") + terminal_raw_usage = None + saw_terminal = False + async for event in result.stream_events(): + if event.type == "raw_response_event" and isinstance( + event.data, ResponseCompletedEvent + ): + saw_terminal = True + terminal_raw_usage = _extract_raw_usage_snapshot(event.data.response) + assert saw_terminal is True + else: + result = await Runner.run(agent, "hi") + terminal_raw_usage = None + + raw_usage["provider"]["input_tokens"] = 99 + expected_raw_usage = {"provider": {"input_tokens": 3}} if preserve_raw_usage is True else None + assert result.raw_responses[0].raw_usage == expected_raw_usage + if streamed: + assert terminal_raw_usage == expected_raw_usage + + +@pytest.mark.asyncio +async def test_scripted_model_withholds_raw_usage_until_stream_completion() -> None: + raw_usage = {"provider": {"input_tokens": 3}} + model = ScriptedModel([ModelStep(output=[assistant_message("done")], raw_usage=raw_usage)]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(preserve_raw_usage=True), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + response_events = [ + event + for event in events + if isinstance( + event, + ResponseCreatedEvent | ResponseInProgressEvent | ResponseCompletedEvent, + ) + ] + assert [_extract_raw_usage_snapshot(event.response) for event in response_events] == [ + None, + None, + raw_usage, + ] + + +@pytest.mark.asyncio +async def test_scripted_model_accepts_step_mappings_in_queue_methods() -> None: + model = ScriptedModel() + model.enqueue({"output": [assistant_message("first")]}) + model.extend([{"output": [assistant_message("second")]}]) + agent = Agent(name="test", model=model) + + first = await Runner.run(agent, "first") + second = await Runner.run(agent, "second") + + assert first.final_output == "first" + assert second.final_output == "second" + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_model_responder_can_return_step_mapping() -> None: + def respond(call: ModelCall) -> ModelStepSpec: + assert isinstance(call.input, list) + return {"output": [assistant_message(str(call.input[0]["content"]))]} + + model = ScriptedModel([ModelStep.respond(respond)]) + + result = await Runner.run(Agent(name="test", model=model), "mapped") + + assert result.final_output == "mapped" + model.assert_complete() + + +def test_scripted_model_rejects_unknown_step_mapping_keys() -> None: + invalid_step = cast(ModelStepSpec, {"unknown": True}) + + with pytest.raises(InvalidModelStep, match=r"step #1") as exc_info: + ScriptedModel([invalid_step]) + + assert exc_info.value.reason == "unsupported_field" + assert exc_info.value.input_index == 0 + + +@pytest.mark.parametrize( + ("step", "reason"), + [ + ({"error": "not an exception"}, "invalid_error"), + ({"responder": "not callable"}, "invalid_responder"), + ({"stream_events": 1}, "invalid_stream_events"), + ( + {"error": RuntimeError("failed"), "responder": lambda _call: []}, + "conflicting_outcomes", + ), + ({"output": "not an output sequence"}, "invalid_input"), + ({"usage": {"requests": 1}}, "invalid_input"), + ({"response_id": 1}, "invalid_input"), + ({"raw_usage": []}, "invalid_input"), + ({"retry_advice": ModelRetryAdvice(suggested=True)}, "invalid_retry_advice"), + ( + {"error": RuntimeError("failed"), "retry_advice": "not advice"}, + "invalid_retry_advice", + ), + ], +) +def test_scripted_model_validates_step_envelopes_before_queuing( + step: object, + reason: str, +) -> None: + model = ScriptedModel([[assistant_message("retained")]]) + + with pytest.raises(InvalidModelStep, match=r"step #1") as exc_info: + model.extend(cast(Any, [step])) + + assert exc_info.value.reason == reason + assert exc_info.value.input_index == 0 + assert model.remaining_steps == 1 + + +def test_scripted_model_reports_the_zero_origin_index_of_an_invalid_step() -> None: + with pytest.raises(InvalidModelStep, match=r"step #2") as exc_info: + ScriptedModel([[assistant_message("valid")], cast(Any, {"unknown": True})]) + + assert exc_info.value.reason == "unsupported_field" + assert exc_info.value.input_index == 1 + + +@pytest.mark.asyncio +async def test_scripted_model_supports_awaitable_recursive_responder() -> None: + async def outer_responder(call: ModelCall) -> ModelStep: + def inner_responder(inner_call: ModelCall) -> list[TResponseOutputItem]: + assert inner_call is call + return [assistant_message("nested")] + + return ModelStep.respond(inner_responder) + + model = ScriptedModel([ModelStep.respond(outer_responder)]) + + result = await Runner.run(Agent(name="test", model=model), "hello") + + assert result.final_output == "nested" + assert len(model.calls) == 1 + model.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_model_retry_advice_is_error_scoped_and_detached() -> None: + error = RuntimeError("failed") + advice = ModelRetryAdvice(suggested=True, replay_safety="safe") + model = ScriptedModel([ModelStep.raise_error(error, retry_advice=advice)]) + advice.suggested = False + + with pytest.raises(RuntimeError, match="failed"): + await model.get_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + request = ModelRetryAdviceRequest(error=error, attempt=1, stream=False) + first = model.get_retry_advice(request) + assert first is not None + assert first.suggested is True + first.suggested = False + second = model.get_retry_advice(request) + assert second is not None + assert second.suggested is True + other_request = ModelRetryAdviceRequest( + error=RuntimeError("other"), + attempt=1, + stream=False, + ) + assert model.get_retry_advice(other_request) is None + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_scripted_model_clears_stale_retry_advice_after_responder_failure( + streamed: bool, +) -> None: + error = RuntimeError("failed") + + def raise_same_error(_call: ModelCall) -> ModelStep: + raise error + + model = ScriptedModel( + [ + ModelStep.raise_error(error, retry_advice=ModelRetryAdvice(suggested=True)), + ModelStep.respond(raise_same_error), + [assistant_message("must remain unconsumed")], + ] + ) + + async def invoke() -> None: + if streamed: + async for _event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + pass + else: + await model.get_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + request = ModelRetryAdviceRequest(error=error, attempt=1, stream=streamed) + with pytest.raises(RuntimeError, match="failed"): + await invoke() + assert model.get_retry_advice(request) is not None + + with pytest.raises(RuntimeError, match="failed"): + await invoke() + assert model.get_retry_advice(request) is None + assert model.remaining_steps == 1 + + +@pytest.mark.asyncio +async def test_scripted_model_supports_exact_normalized_stream() -> None: + output = [assistant_message("exact")] + + async def stream(_call) -> AsyncIterator[TResponseStreamEvent]: + yield ResponseCompletedEvent( + type="response.completed", + response=get_response_obj(output), + sequence_number=0, + ) + + model = ScriptedModel([ModelStep.stream(stream, output=output)]) + result = Runner.run_streamed(Agent(name="test", model=model), "hi") + + async for _event in result.stream_events(): + pass + + assert result.final_output == "exact" + + +def test_model_step_freezes_exact_stream_event_sequence() -> None: + event = ResponseCompletedEvent( + type="response.completed", + response=get_response_obj([]), + sequence_number=0, + ) + events = [event] + + step = ModelStep.stream(events) + events.clear() + + assert step.stream_events == (event,) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("output_item", "item_kind"), + [ + ( + ResponseCustomToolCall( + call_id="call_1", + id="item_1", + input="payload", + name="custom", + type="custom_tool_call", + ), + "ResponseCustomToolCall", + ), + ( + ResponseCodeInterpreterToolCall( + id="item_1", + code="print('hello')", + container_id="container_1", + outputs=None, + status="completed", + type="code_interpreter_call", + ), + "ResponseCodeInterpreterToolCall", + ), + ( + McpCall( + id="item_1", + arguments='{"query":"hello"}', + name="search", + server_label="docs", + status="completed", + type="mcp_call", + ), + "McpCall", + ), + ( + ImageGenerationCall( + id="item_1", + result="base64-image", + status="completed", + type="image_generation_call", + ), + "ImageGenerationCall", + ), + ], +) +async def test_scripted_model_rejects_unsupported_automatic_tool_streams( + output_item: TResponseOutputItem, + item_kind: str, +) -> None: + model = ScriptedModel([[output_item]]) + yielded: list[TResponseStreamEvent] = [] + + with pytest.raises( + ModelBehaviorError, + match=rf"Automatic streaming does not support {item_kind}.*ModelStep\.stream", + ): + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + yielded.append(event) + + assert yielded == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("queue_method", ["constructor", "enqueue", "extend"]) +async def test_scripted_model_snapshots_static_steps_when_queued(queue_method: str) -> None: + output_item = assistant_message("before") + usage = Usage(requests=2, input_tokens=3, output_tokens=4, total_tokens=7) + raw_usage = {"provider": {"cached_tokens": 1}} + step = ModelStep( + output=[output_item], + usage=usage, + response_id="resp_before", + raw_usage=raw_usage, + ) + + if queue_method == "constructor": + model = ScriptedModel([step]) + else: + model = ScriptedModel() + if queue_method == "enqueue": + model.enqueue(step) + else: + model.extend([step]) + + message = cast(ResponseOutputMessage, output_item) + text = cast(ResponseOutputText, message.content[0]) + text.text = "after" + usage.requests = 99 + raw_usage["provider"]["cached_tokens"] = 99 + step.response_id = "resp_after" + + response = await model.get_response( + None, + [], + ModelSettings(preserve_raw_usage=True), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + queued_message = cast(ResponseOutputMessage, response.output[0]) + queued_text = cast(ResponseOutputText, queued_message.content[0]) + assert queued_text.text == "before" + assert response.usage == Usage(requests=2, input_tokens=3, output_tokens=4, total_tokens=7) + assert response.response_id == "resp_before" + assert response.raw_usage == {"provider": {"cached_tokens": 1}} + + +@pytest.mark.asyncio +async def test_scripted_model_snapshots_output_shorthand_when_queued() -> None: + output_item = assistant_message("before") + output = [output_item] + model = ScriptedModel([output]) + + message = cast(ResponseOutputMessage, output_item) + text = cast(ResponseOutputText, message.content[0]) + text.text = "after" + output.clear() + + response = await model.get_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + queued_message = cast(ResponseOutputMessage, response.output[0]) + queued_text = cast(ResponseOutputText, queued_message.content[0]) + assert queued_text.text == "before" + + +@pytest.mark.asyncio +async def test_scripted_model_records_stream_factory_errors_on_generation_span() -> None: + async def stream(_call) -> AsyncIterator[TResponseStreamEvent]: + yield ResponseCreatedEvent( + type="response.created", + response=get_response_obj([]), + sequence_number=0, + ) + raise RuntimeError("stream failed") + + model = ScriptedModel([ModelStep.stream(stream)], emit_traces=True) + + with pytest.raises(RuntimeError, match="stream failed"): + result = Runner.run_streamed(Agent(name="test", model=model), "hi") + async for _event in result.stream_events(): + pass + + assert fetch_span_errors("generation") == [ + { + "message": "Error", + "data": {"name": "RuntimeError", "message": "stream failed"}, + } + ] + + +@pytest.mark.asyncio +async def test_scripted_model_marks_span_current_only_while_advancing_stream_factory() -> None: + observed_spans: list[Any] = [] + + async def stream(_call) -> AsyncIterator[TResponseStreamEvent]: + observed_spans.append(Scope.get_current_span()) + yield ResponseCreatedEvent( + type="response.created", + response=get_response_obj([]), + sequence_number=0, + ) + observed_spans.append(Scope.get_current_span()) + + model = ScriptedModel([ModelStep.stream(stream)], emit_traces=True) + + async for _event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + assert Scope.get_current_span() is None + + assert len(observed_spans) == 2 + assert observed_spans[0] is observed_spans[1] + assert observed_spans[0] is not None + assert observed_spans[0].span_data.type == "generation" + + +@pytest.mark.asyncio +async def test_scripted_model_closes_exact_stream_when_outer_stream_is_closed() -> None: + closed = False + close_span: Any = None + + async def stream(_call) -> AsyncIterator[TResponseStreamEvent]: + nonlocal closed, close_span + try: + yield ResponseCreatedEvent( + type="response.created", + response=get_response_obj([]), + sequence_number=0, + ) + await asyncio.Event().wait() + finally: + closed = True + close_span = Scope.get_current_span() + + model = ScriptedModel([ModelStep.stream(stream)], emit_traces=True) + outer = model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + await anext(outer) + assert closed is False + await outer.aclose() + + assert closed is True + assert close_span is not None + assert close_span.span_data.type == "generation" + assert Scope.get_current_span() is None + + +@pytest.mark.asyncio +async def test_scripted_model_closes_exact_stream_when_consumer_is_cancelled() -> None: + blocked = asyncio.Event() + closed = asyncio.Event() + + async def stream(_call) -> AsyncIterator[TResponseStreamEvent]: + try: + yield ResponseCreatedEvent( + type="response.created", + response=get_response_obj([]), + sequence_number=0, + ) + blocked.set() + await asyncio.Event().wait() + finally: + closed.set() + + model = ScriptedModel([ModelStep.stream(stream)]) + outer = model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + await anext(outer) + pending = asyncio.create_task(anext(outer)) + await blocked.wait() + pending.cancel() + + with pytest.raises(asyncio.CancelledError): + await pending + assert closed.is_set() + + +@pytest.mark.asyncio +async def test_scripted_model_preserves_cancellation_when_exact_stream_close_fails() -> None: + class FailingCloseStream: + def __init__(self) -> None: + self.blocked = asyncio.Event() + self.close_calls = 0 + + def __aiter__(self) -> FailingCloseStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + self.blocked.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def aclose(self) -> None: + self.close_calls += 1 + raise RuntimeError("close failed") + + inner = FailingCloseStream() + model = ScriptedModel([ModelStep.stream(lambda _call: inner)]) + outer = model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + pending = asyncio.create_task(anext(outer)) + await inner.blocked.wait() + pending.cancel() + + with pytest.raises(asyncio.CancelledError): + await pending + assert inner.close_calls == 1 + + +@pytest.mark.asyncio +async def test_scripted_model_surfaces_exact_stream_close_failure_after_exhaustion() -> None: + close_error = RuntimeError("close failed") + + class FailingCloseStream: + def __aiter__(self) -> FailingCloseStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + raise StopAsyncIteration + + async def aclose(self) -> None: + raise close_error + + inner = FailingCloseStream() + model = ScriptedModel([ModelStep.stream(lambda _call: inner)], emit_traces=True) + agent = Agent(name="test", model=model) + + with pytest.raises(RuntimeError, match="close failed") as exc_info: + result = Runner.run_streamed(agent, "hi") + async for _event in result.stream_events(): + pass + + assert exc_info.value is close_error + assert fetch_span_errors("generation") == [ + { + "message": "Error", + "data": {"name": "RuntimeError", "message": "close failed"}, + } + ] + + +@pytest.mark.asyncio +async def test_scripted_model_preserves_exact_stream_error_when_close_fails() -> None: + stream_error = RuntimeError("stream failed") + + class FailingStream: + def __init__(self) -> None: + self.close_calls = 0 + + def __aiter__(self) -> FailingStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + raise stream_error + + async def aclose(self) -> None: + self.close_calls += 1 + raise RuntimeError("close failed") + + inner = FailingStream() + model = ScriptedModel([ModelStep.stream(lambda _call: inner)], emit_traces=True) + agent = Agent(name="test", model=model) + + with pytest.raises(RuntimeError, match="stream failed") as exc_info: + result = Runner.run_streamed(agent, "hi") + async for _event in result.stream_events(): + pass + + assert exc_info.value is stream_error + assert inner.close_calls == 1 + assert fetch_span_errors("generation") == [ + { + "message": "Error", + "data": {"name": "RuntimeError", "message": "stream failed"}, + } + ] + + +@pytest.mark.parametrize("emit_traces", [False, True]) +def test_scripted_model_early_stream_exit_has_task_safe_span_cleanup(emit_traces: bool) -> None: + loop = asyncio.new_event_loop() + errors: list[dict[str, Any]] = [] + loop.set_exception_handler(lambda _loop, context: errors.append(context)) + + async def consume_one_event() -> None: + model = ScriptedModel([[assistant_message("hello")]], emit_traces=emit_traces) + async for _event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + assert Scope.get_current_span() is None + break + + try: + loop.run_until_complete(consume_one_event()) + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + loop.close() + + assert errors == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_scripted_model_preserves_unformattable_responder_error(streamed: bool) -> None: + class UnformattableError(RuntimeError): + def __str__(self) -> str: + raise RuntimeError("format failed") + + expected = UnformattableError() + + def respond(_call: ModelCall) -> Any: + raise expected + + model = ScriptedModel([ModelStep.respond(respond)], emit_traces=True) + agent = Agent(name="test", model=model) + + with pytest.raises(UnformattableError) as exc_info: + if streamed: + result = Runner.run_streamed(agent, "hi") + async for _event in result.stream_events(): + pass + else: + await Runner.run(agent, "hi") + + assert exc_info.value is expected + assert fetch_span_errors("generation") == [ + { + "message": "Error", + "data": { + "name": "UnformattableError", + "message": "Unrenderable UnformattableError", + }, + } + ] + + +def test_scripted_model_ignores_span_attachment_failure() -> None: + class FailingSpan: + def set_error(self, _error: SpanError) -> None: + raise KeyboardInterrupt + + ScriptedModel._set_span_error(FailingSpan(), RuntimeError("model failed"), ModelTracing.ENABLED) + + +def test_scripted_model_contains_base_exception_from_error_formatting() -> None: + class UnformattableError(RuntimeError): + def __str__(self) -> str: + raise KeyboardInterrupt + + class RecordingSpan: + def __init__(self) -> None: + self.error: SpanError | None = None + + def set_error(self, error: SpanError) -> None: + self.error = error + + span = RecordingSpan() + ScriptedModel._set_span_error(span, UnformattableError(), ModelTracing.ENABLED) + + assert span.error == SpanError( + message="Error", + data={ + "name": "UnformattableError", + "message": "Unrenderable UnformattableError", + }, + ) + + +def test_scripted_model_redacts_span_error_without_rendering_exception() -> None: + class SensitiveError(RuntimeError): + def __init__(self) -> None: + self.str_calls = 0 + + def __str__(self) -> str: + self.str_calls += 1 + return "sensitive payload" + + class RecordingSpan: + def __init__(self) -> None: + self.error: SpanError | None = None + + def set_error(self, error: SpanError) -> None: + self.error = error + + error = SensitiveError() + span = RecordingSpan() + + ScriptedModel._set_span_error(span, error, ModelTracing.ENABLED_WITHOUT_DATA) + + assert error.str_calls == 0 + assert span.error == SpanError( + message="Error", + data={ + "name": "SensitiveError", + "message": REDACTED_TRACE_ERROR_MESSAGE, + }, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("awaitable", [False, True]) +async def test_scripted_model_records_responder_errors_on_generation_span( + streamed: bool, + awaitable: bool, +) -> None: + expected = RuntimeError("responder failed") + + def respond(_call: ModelCall) -> Any: + raise expected + + async def respond_async(_call: ModelCall) -> Any: + raise expected + + model = ScriptedModel( + [ModelStep.respond(respond_async if awaitable else respond)], + emit_traces=True, + ) + agent = Agent(name="test", model=model) + + with pytest.raises(RuntimeError, match="responder failed") as exc_info: + if streamed: + result = Runner.run_streamed(agent, "hi") + async for _event in result.stream_events(): + pass + else: + await Runner.run(agent, "hi") + + assert exc_info.value is expected + assert fetch_span_errors("generation") == [ + { + "message": "Error", + "data": {"name": "RuntimeError", "message": "responder failed"}, + } + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_scripted_model_redacts_generation_span_errors_from_run_config( + streamed: bool, +) -> None: + expected = RuntimeError("sensitive provider payload") + model = ScriptedModel([ModelStep.raise_error(expected)], emit_traces=True) + agent = Agent(name="test", model=model) + run_config = RunConfig(trace_include_sensitive_data=False) + + with pytest.raises(RuntimeError) as exc_info: + if streamed: + result = Runner.run_streamed(agent, "hi", run_config=run_config) + async for _event in result.stream_events(): + pass + else: + await Runner.run(agent, "hi", run_config=run_config) + + assert exc_info.value is expected + assert fetch_span_errors("generation") == [ + { + "message": "Error", + "data": { + "name": "RuntimeError", + "message": REDACTED_TRACE_ERROR_MESSAGE, + }, + } + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("call_id", [None, "", 123]) +async def test_scripted_model_rejects_invalid_apply_patch_call_id(call_id: Any) -> None: + output = cast( + TResponseOutputItem, + { + "type": "apply_patch_call", + "id": "patch_item", + "call_id": call_id, + "operation": {"type": "delete_file", "path": "example.txt"}, + }, + ) + model = ScriptedModel([[output]]) + + with pytest.raises( + ModelBehaviorError, + match="Tool invocations require a non-empty string call ID before execution", + ): + await Runner.run(Agent(name="test", model=model), "hi") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("item_id", [None, "", 123]) +async def test_scripted_model_rejects_invalid_apply_patch_item_id(item_id: Any) -> None: + output = cast( + TResponseOutputItem, + { + "type": "apply_patch_call", + "id": item_id, + "call_id": "call_1", + "operation": {"type": "delete_file", "path": "example.txt"}, + }, + ) + model = ScriptedModel([[output]]) + + with pytest.raises( + ModelBehaviorError, + match="Apply-patch tool calls require a non-empty string item ID when provided", + ): + await Runner.run(Agent(name="test", model=model), "hi") + + +@pytest.mark.asyncio +async def test_scripted_model_defaults_omitted_apply_patch_item_id_to_call_id() -> None: + output = cast( + TResponseOutputItem, + { + "type": "apply_patch_call", + "call_id": "call_1", + "operation": {"type": "delete_file", "path": "example.txt"}, + }, + ) + model = ScriptedModel([[output]]) + + response = await model.get_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + item = response.output[0] + assert isinstance(item, ResponseApplyPatchToolCall) + assert item.id == "call_1" + assert item.status == "completed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [None, "", "invalid"]) +async def test_scripted_model_rejects_invalid_explicit_apply_patch_status(status: Any) -> None: + output = cast( + TResponseOutputItem, + { + "type": "apply_patch_call", + "id": "patch_item", + "call_id": "call_1", + "status": status, + "operation": {"type": "delete_file", "path": "example.txt"}, + }, + ) + model = ScriptedModel([[output]]) + + with pytest.raises(ValidationError): + await model.get_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + +@pytest.mark.asyncio +async def test_scripted_model_function_argument_events_use_output_item_id() -> None: + model = ScriptedModel([[function_call("lookup", "{}", call_id="call_1", item_id="item_1")]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + argument_events = [ + event + for event in events + if isinstance( + event, + ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent, + ) + ] + assert [event.item_id for event in argument_events] == ["item_1", "item_1"] + + +@pytest.mark.asyncio +async def test_scripted_model_function_argument_events_preserve_empty_output_item_id() -> None: + output = function_call("lookup", "{}", call_id="call_1", item_id="") + model = ScriptedModel([[output]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + item_events = [ + event + for event in events + if isinstance(event, ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent) + ] + argument_events = [ + event + for event in events + if isinstance( + event, + ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent, + ) + ] + assert [event.item.id for event in item_events] == ["", ""] + assert [event.item_id for event in argument_events] == ["", ""] + + +@pytest.mark.asyncio +async def test_scripted_model_function_argument_events_fall_back_to_call_id() -> None: + output = ResponseFunctionToolCall( + type="function_call", + name="lookup", + arguments="{}", + call_id="call_1", + ) + model = ScriptedModel([[output]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + argument_events = [ + event + for event in events + if isinstance( + event, + ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent, + ) + ] + assert [event.item_id for event in argument_events] == ["call_1", "call_1"] + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_uses_in_progress_added_payloads() -> None: + reasoning = ResponseReasoningItem( + id="reasoning_1", + summary=[Summary(text="summary", type="summary_text")], + encrypted_content="encrypted-reasoning", + type="reasoning", + status="completed", + ) + message = assistant_message("hello", item_id="message_1") + function = function_call( + "lookup", + {"city": "Tokyo"}, + call_id="call_1", + item_id="function_1", + ) + completed_items = [reasoning, message, function] + usage = Usage(requests=3, input_tokens=4, output_tokens=5, total_tokens=9) + model = ScriptedModel([ModelStep(output=completed_items, usage=usage)]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + opening_responses = [ + event.response + for event in events + if isinstance(event, ResponseCreatedEvent | ResponseInProgressEvent) + ] + assert [response.output for response in opening_responses] == [[], []] + assert [response.status for response in opening_responses] == ["in_progress", "in_progress"] + assert [response.usage for response in opening_responses] == [None, None] + + added_items = [ + event.item for event in events if isinstance(event, ResponseOutputItemAddedEvent) + ] + assert isinstance(added_items[0], ResponseReasoningItem) + assert added_items[0].encrypted_content is None + assert added_items[0].summary == [] + assert added_items[0].status == "in_progress" + assert isinstance(added_items[1], ResponseOutputMessage) + assert added_items[1].content == [] + assert added_items[1].status == "in_progress" + assert isinstance(added_items[2], ResponseFunctionToolCall) + assert added_items[2].arguments == "" + assert added_items[2].status == "in_progress" + + added_summary_parts = [ + event.part for event in events if isinstance(event, ResponseReasoningSummaryPartAddedEvent) + ] + assert [part.text for part in added_summary_parts] == [""] + added_content_parts = [ + event.part for event in events if isinstance(event, ResponseContentPartAddedEvent) + ] + assert [part.text for part in added_content_parts] == [""] + + done_items = [event.item for event in events if isinstance(event, ResponseOutputItemDoneEvent)] + assert done_items == completed_items + assert cast(ResponseReasoningItem, done_items[0]).encrypted_content == "encrypted-reasoning" + completed_event = next(event for event in events if isinstance(event, ResponseCompletedEvent)) + assert completed_event.response.status == "completed" + assert completed_event.response.output == completed_items + assert ( + cast(ResponseReasoningItem, completed_event.response.output[0]).encrypted_content + == "encrypted-reasoning" + ) + assert completed_event.response.usage is not None + assert completed_event.response.usage.input_tokens == 4 + assert completed_event.response.usage.output_tokens == 5 + assert completed_event.response.usage.total_tokens == 9 + request_count_attribute = "_agents_sdk_request_count" + assert getattr(completed_event.response.usage, request_count_attribute) == 3 + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_detaches_done_item_from_terminal_response() -> None: + model = ScriptedModel([[assistant_message("original", item_id="message_1")]]) + completed_event: ResponseCompletedEvent | None = None + + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + if isinstance(event, ResponseOutputItemDoneEvent): + assert isinstance(event.item, ResponseOutputMessage) + assert isinstance(event.item.content[0], ResponseOutputText) + event.item.content[0].text = "mutated" + elif isinstance(event, ResponseCompletedEvent): + completed_event = event + + assert completed_event is not None + terminal_item = completed_event.response.output[0] + assert isinstance(terminal_item, ResponseOutputMessage) + assert isinstance(terminal_item.content[0], ResponseOutputText) + assert terminal_item.content[0].text == "original" + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_detaches_content_part_event_payloads() -> None: + text = ResponseOutputText.model_validate( + { + "type": "output_text", + "text": "original text", + "annotations": [], + "logprobs": [], + "provider_data": {"nested": ["original text"]}, + } + ) + refusal = ResponseOutputRefusal.model_validate( + { + "type": "refusal", + "refusal": "original refusal", + "provider_data": {"nested": ["original refusal"]}, + } + ) + message = ResponseOutputMessage( + id="message_1", + type="message", + role="assistant", + status="completed", + content=[text, refusal], + ) + model = ScriptedModel([[message]]) + done_parts: list[ResponseOutputText | ResponseOutputRefusal] = [] + done_item: ResponseOutputMessage | None = None + completed_item: ResponseOutputMessage | None = None + + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + if isinstance(event, ResponseContentPartAddedEvent): + cast(Any, event.part).provider_data["nested"][0] = "mutated" + elif isinstance(event, ResponseContentPartDoneEvent): + done_parts.append(event.part) + elif isinstance(event, ResponseOutputItemDoneEvent): + assert isinstance(event.item, ResponseOutputMessage) + done_item = event.item + elif isinstance(event, ResponseCompletedEvent): + assert isinstance(event.response.output[0], ResponseOutputMessage) + completed_item = event.response.output[0] + + expected_provider_data = [ + {"nested": ["original text"]}, + {"nested": ["original refusal"]}, + ] + assert [cast(Any, part).provider_data for part in done_parts] == expected_provider_data + assert done_item is not None + assert [cast(Any, part).provider_data for part in done_item.content] == expected_provider_data + assert completed_item is not None + assert [ + cast(Any, part).provider_data for part in completed_item.content + ] == expected_provider_data + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_preserves_text_logprobs() -> None: + output_logprobs = [ + Logprob( + token="hello", + bytes=[104, 101, 108, 108, 111], + logprob=-0.25, + top_logprobs=[ + LogprobTopLogprob( + token="hi", + bytes=[104, 105], + logprob=-1.5, + ) + ], + ), + Logprob( + token="!", + bytes=[33], + logprob=-0.1, + top_logprobs=[], + ), + ] + text = ResponseOutputText( + type="output_text", + text="hello", + annotations=[], + logprobs=output_logprobs, + ) + message = ResponseOutputMessage( + id="message_1", + type="message", + role="assistant", + status="completed", + content=[text], + ) + model = ScriptedModel([[message]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + added_event = next( + event for event in events if isinstance(event, ResponseContentPartAddedEvent) + ) + delta_event = next(event for event in events if isinstance(event, ResponseTextDeltaEvent)) + done_event = next(event for event in events if isinstance(event, ResponseTextDoneEvent)) + assert isinstance(added_event.part, ResponseOutputText) + assert added_event.part.logprobs == [] + assert [(logprob.token, logprob.logprob) for logprob in delta_event.logprobs] == [ + ("hello", -0.25), + ("!", -0.1), + ] + assert delta_event.logprobs[0].top_logprobs is not None + assert [logprob.token for logprob in delta_event.logprobs[0].top_logprobs] == ["hi"] + assert delta_event.logprobs[1].top_logprobs == [] + assert [(logprob.token, logprob.logprob) for logprob in done_event.logprobs] == [ + ("hello", -0.25), + ("!", -0.1), + ] + assert done_event.logprobs[0].top_logprobs is not None + assert [logprob.token for logprob in done_event.logprobs[0].top_logprobs] == ["hi"] + assert done_event.logprobs[1].top_logprobs == [] + + completed_event = next(event for event in events if isinstance(event, ResponseCompletedEvent)) + completed_message = completed_event.response.output[0] + assert isinstance(completed_message, ResponseOutputMessage) + completed_text = completed_message.content[0] + assert isinstance(completed_text, ResponseOutputText) + assert completed_text.logprobs == output_logprobs + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_emits_text_annotation_events() -> None: + annotations = [ + AnnotationURLCitation( + end_index=5, + start_index=0, + title="Example", + type="url_citation", + url="https://example.test", + ), + AnnotationFilePath( + file_id="file_1", + index=6, + type="file_path", + ), + ] + text = ResponseOutputText( + type="output_text", + text="hello file", + annotations=annotations, + logprobs=[], + ) + message = ResponseOutputMessage( + id="message_1", + type="message", + role="assistant", + status="completed", + content=[text], + ) + model = ScriptedModel([[message]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + added_part = next(event for event in events if isinstance(event, ResponseContentPartAddedEvent)) + annotation_events = [ + event for event in events if isinstance(event, ResponseOutputTextAnnotationAddedEvent) + ] + text_done = next(event for event in events if isinstance(event, ResponseTextDoneEvent)) + assert isinstance(added_part.part, ResponseOutputText) + assert added_part.part.annotations == [] + assert [event.annotation_index for event in annotation_events] == [0, 1] + assert [event.annotation for event in annotation_events] == annotations + assert all(event.item_id == "message_1" for event in annotation_events) + assert all(event.output_index == 0 for event in annotation_events) + assert all(event.content_index == 0 for event in annotation_events) + assert annotation_events[0].sequence_number == added_part.sequence_number + 2 + assert annotation_events[1].sequence_number == annotation_events[0].sequence_number + 1 + assert text_done.sequence_number == annotation_events[1].sequence_number + 1 + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_emits_reasoning_content_events() -> None: + reasoning = ResponseReasoningItem( + id="reasoning_1", + summary=[], + content=[Content(text="think carefully", type="reasoning_text")], + type="reasoning", + status="completed", + ) + model = ScriptedModel([[reasoning]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + added_event = next(event for event in events if isinstance(event, ResponseOutputItemAddedEvent)) + assert isinstance(added_event.item, ResponseReasoningItem) + assert added_event.item.content == [] + delta_event = next( + event for event in events if isinstance(event, ResponseReasoningTextDeltaEvent) + ) + done_event = next( + event for event in events if isinstance(event, ResponseReasoningTextDoneEvent) + ) + assert delta_event.item_id == "reasoning_1" + assert delta_event.output_index == 0 + assert delta_event.content_index == 0 + assert delta_event.delta == "think carefully" + assert done_event.item_id == "reasoning_1" + assert done_event.output_index == 0 + assert done_event.content_index == 0 + assert done_event.text == "think carefully" + assert done_event.sequence_number == delta_event.sequence_number + 1 + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_marks_added_apply_patch_call_in_progress() -> None: + apply_patch_call = ResponseApplyPatchToolCall( + type="apply_patch_call", + id="apply_patch_1", + call_id="call_1", + status="completed", + operation=cast( + Any, + {"type": "update_file", "path": "test.md", "diff": "-old\n+new\n"}, + ), + ) + model = ScriptedModel([[apply_patch_call]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + added_event = next(event for event in events if isinstance(event, ResponseOutputItemAddedEvent)) + done_event = next(event for event in events if isinstance(event, ResponseOutputItemDoneEvent)) + completed_event = next(event for event in events if isinstance(event, ResponseCompletedEvent)) + + assert isinstance(added_event.item, ResponseApplyPatchToolCall) + assert added_event.item.status == "in_progress" + assert isinstance(done_event.item, ResponseApplyPatchToolCall) + assert done_event.item.status == "completed" + assert isinstance(completed_event.response.output[0], ResponseApplyPatchToolCall) + assert completed_event.response.output[0].status == "completed" + + +@pytest.mark.asyncio +async def test_scripted_model_automatic_stream_emits_refusal_content_events() -> None: + refusal = ResponseOutputRefusal(type="refusal", refusal="I cannot help with that.") + message = ResponseOutputMessage( + id="message_1", + type="message", + role="assistant", + status="completed", + content=[refusal], + ) + model = ScriptedModel([[message]]) + + events = [ + event + async for event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + ] + + assert [event.type for event in events] == [ + "response.created", + "response.in_progress", + "response.output_item.added", + "response.content_part.added", + "response.refusal.delta", + "response.refusal.done", + "response.content_part.done", + "response.output_item.done", + "response.completed", + ] + assert [event.sequence_number for event in events] == list(range(len(events))) + content_events = [ + event + for event in events + if isinstance( + event, + ResponseContentPartAddedEvent + | ResponseRefusalDeltaEvent + | ResponseRefusalDoneEvent + | ResponseContentPartDoneEvent, + ) + ] + assert [event.type for event in content_events] == [ + "response.content_part.added", + "response.refusal.delta", + "response.refusal.done", + "response.content_part.done", + ] + assert all(event.item_id == "message_1" for event in content_events) + assert all(event.output_index == 0 for event in content_events) + assert all(event.content_index == 0 for event in content_events) + added, delta, refusal_done, content_done = content_events + assert isinstance(added, ResponseContentPartAddedEvent) + assert isinstance(added.part, ResponseOutputRefusal) + assert added.part.refusal == "" + assert isinstance(delta, ResponseRefusalDeltaEvent) + assert delta.delta == "I cannot help with that." + assert isinstance(refusal_done, ResponseRefusalDoneEvent) + assert refusal_done.refusal == "I cannot help with that." + assert isinstance(content_done, ResponseContentPartDoneEvent) + assert content_done.part == refusal + + +@pytest.mark.asyncio +async def test_scripted_model_rejects_unexpected_call() -> None: + model = ScriptedModel() + + with pytest.raises(UnexpectedModelCall, match="no scripted steps remain") as exc_info: + await Runner.run(Agent(name="test", model=model), "hi") + + assert exc_info.value.call_index == 0 + assert "call #1" in str(exc_info.value) + assert exc_info.value.call.streamed is False + assert exc_info.value.call.input == [{"content": "hi", "role": "user"}] + + exc_info.value.call.input[0]["content"] = "changed" + assert model.calls[0].input == [{"content": "hi", "role": "user"}] + + +@pytest.mark.asyncio +async def test_scripted_model_unexpected_streaming_call_records_streamed_attribute() -> None: + model = ScriptedModel() + + with pytest.raises(UnexpectedModelCall) as exc_info: + async for _event in model.stream_response( + None, + [], + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + pass + + assert exc_info.value.call_index == 0 + assert exc_info.value.call.streamed is True + + +def test_scripted_model_reports_unconsumed_steps() -> None: + model = ScriptedModel([[assistant_message("unused")]]) + + with pytest.raises(UnconsumedModelSteps, match="1 scripted model step") as exc_info: + model.assert_complete() + + assert exc_info.value.remaining_steps == 1 diff --git a/tests/test_scripted_sandbox.py b/tests/test_scripted_sandbox.py new file mode 100644 index 0000000000..722a14d6a9 --- /dev/null +++ b/tests/test_scripted_sandbox.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +import io +from pathlib import Path +from types import MappingProxyType +from typing import Any, cast + +import pytest + +from agents import RunConfig, Runner +from agents.sandbox import ExecResult, Manifest, SandboxAgent +from agents.sandbox.capabilities import Shell +from agents.sandbox.files import FileEntry +from agents.sandbox.session.base_sandbox_session import BaseSandboxSession +from agents.sandbox.session.pty_types import PtyExecUpdate +from agents.testing import ( + InvalidSandboxStep, + SandboxCall, + SandboxCallMatcherError, + ScriptedModel, + UnconsumedSandboxSteps, + UnexpectedSandboxCall, + assistant_message, + function_call, + scripted_sandbox_session, +) + + +class _CallableBytesIO(io.BytesIO): + def __call__(self) -> None: + pass + + +class _CallableBufferedReader(io.BufferedReader): + def __call__(self) -> None: + pass + + +def test_scripted_sandbox_exposes_only_configured_scriptable_methods() -> None: + session = scripted_sandbox_session( + [{"method": "exec", "result": ExecResult(stdout=b"", stderr=b"", exit_code=0)}] + ) + + assert isinstance(session, BaseSandboxSession) + assert hasattr(session, "exec") + assert not hasattr(session, "read") + assert not hasattr(session, "apply_patch") + assert not hasattr(session, "pty_exec_start") + assert "exec" in dir(session) + assert "read" not in dir(session) + assert "apply_patch" not in dir(session) + assert "pty_exec_start" not in dir(session) + + +@pytest.mark.asyncio +async def test_scripted_sandbox_cleanup_does_not_advertise_pty_termination() -> None: + session = scripted_sandbox_session() + + assert not hasattr(session, "pty_terminate_all") + assert "pty_terminate_all" not in dir(session) + + await session.aclose() + + async with scripted_sandbox_session() as context_session: + assert await context_session.running() is True + + assert await context_session.running() is False + + +def test_scripted_sandbox_snapshots_manifest_and_derives_pty_support() -> None: + manifest = Manifest(root="/configured") + session = scripted_sandbox_session( + [{"method": "pty_exec_start", "result": None}], + manifest=manifest, + ) + manifest.root = "/mutated" + + assert session.state.manifest.root == "/configured" + assert session.supports_pty() is True + + pty_session = scripted_sandbox_session( + [ + {"method": "pty_exec_start", "result": None}, + {"method": "pty_write_stdin", "result": None}, + ] + ) + assert pty_session.supports_pty() is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("configured_method", "missing_method"), + [ + ("pty_exec_start", "pty_write_stdin"), + ("pty_write_stdin", "pty_exec_start"), + ], +) +async def test_scripted_sandbox_exposes_pty_methods_as_one_capability( + configured_method: str, + missing_method: str, +) -> None: + session = scripted_sandbox_session([{"method": configured_method, "result": None}]) + + assert session.supports_pty() is True + assert hasattr(session, "pty_exec_start") + assert hasattr(session, "pty_write_stdin") + assert "pty_exec_start" in dir(session) + assert "pty_write_stdin" in dir(session) + + with pytest.raises(UnexpectedSandboxCall) as exc_info: + if missing_method == "pty_exec_start": + await session.pty_exec_start("pwd") + else: + await session.pty_write_stdin(session_id=1, chars="") + + assert exc_info.value.actual_method == missing_method + assert exc_info.value.expected_method == configured_method + + +@pytest.mark.asyncio +async def test_scripted_sandbox_supports_capability_method_inventory() -> None: + read_result = io.BytesIO(b"contents") + list_result: list[FileEntry] = [] + pty_start_result = PtyExecUpdate( + process_id=123, + output=b"started", + exit_code=None, + original_token_count=None, + ) + pty_write_result = PtyExecUpdate( + process_id=None, + output=b"done", + exit_code=0, + original_token_count=None, + ) + session = scripted_sandbox_session( + [ + {"method": "read", "result": read_result}, + {"method": "write", "result": None}, + {"method": "ls", "result": list_result}, + {"method": "mkdir", "result": None}, + {"method": "rm", "result": None}, + {"method": "apply_patch", "result": "Done!"}, + {"method": "pty_exec_start", "result": pty_start_result}, + {"method": "pty_write_stdin", "result": pty_write_result}, + ] + ) + stream = io.BytesIO(b"payload") + + returned_read_result = cast(io.BytesIO, await session.read(Path("in.txt"))) + assert returned_read_result is not read_result + assert returned_read_result.getvalue() == b"contents" + await session.write(Path("out.txt"), stream) + assert await session.ls(".") == [] + await session.mkdir("new", parents=True) + await session.rm("old", recursive=True) + assert await session.apply_patch({"type": "delete_file", "path": "old.txt"}) == "Done!" + assert (await session.pty_exec_start("sh", tty=True)).process_id == 123 + assert (await session.pty_write_stdin(session_id=123, chars="exit")).exit_code == 0 + + assert [call.method for call in session.calls] == [ + "read", + "write", + "ls", + "mkdir", + "rm", + "apply_patch", + "pty_exec_start", + "pty_write_stdin", + ] + recorded_stream = cast(io.BytesIO, session.calls[1].args[1]) + assert recorded_stream is not stream + assert recorded_stream.getvalue() == b"payload" + assert recorded_stream.tell() == 0 + session.assert_complete() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("configured", "original_content"), + [(io.BytesIO(b"before"), b"before"), (io.StringIO("before"), "before")], +) +async def test_scripted_sandbox_snapshots_supported_stream_results( + configured: io.BytesIO | io.StringIO, + original_content: bytes | str, +) -> None: + configured.seek(2) + session = scripted_sandbox_session([{"method": "read", "result": configured}]) + configured.seek(0) + configured.write(b"after" if isinstance(configured, io.BytesIO) else "after") + configured.close() + + result = cast(io.BytesIO | io.StringIO, await session.read(Path("input.txt"))) + + assert result is not configured + assert result.tell() == 2 + assert result.getvalue() == original_content + session.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_sandbox_snapshots_supported_stream_call_arguments() -> None: + session = scripted_sandbox_session([{"method": "write", "result": None}]) + source = io.BytesIO(b"before") + source.seek(3) + + await session.write(Path("output.bin"), source) + source.seek(0) + source.write(b"after") + source.close() + + recorded = cast(io.BytesIO, session.calls[0].args[1]) + assert recorded is not source + assert recorded.tell() == 3 + assert recorded.getvalue() == b"before" + recorded.write(b"changed") + retained = cast(io.BytesIO, session.calls[0].args[1]) + assert retained.tell() == 3 + assert retained.getvalue() == b"before" + session.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_sandbox_snapshots_callable_supported_streams() -> None: + result_source = _CallableBytesIO(b"result") + call_source = _CallableBytesIO(b"call") + session = scripted_sandbox_session( + [ + {"method": "read", "result": result_source}, + {"method": "write", "result": None}, + ] + ) + + result = cast(io.BytesIO, await session.read(Path("input.bin"))) + await session.write(Path("output.bin"), call_source) + + assert result is not result_source + assert result.getvalue() == b"result" + recorded = cast(io.BytesIO, session.calls[1].args[1]) + assert recorded is not call_source + assert recorded.getvalue() == b"call" + session.assert_complete() + + +def test_scripted_sandbox_rejects_unsupported_or_closed_stream_results() -> None: + with io.BufferedReader(io.BytesIO(b"payload")) as unsupported: + with pytest.raises(InvalidSandboxStep) as unsupported_info: + scripted_sandbox_session([{"method": "read", "result": unsupported}]) + assert unsupported_info.value.reason == "invalid_outcome" + + closed = io.BytesIO(b"payload") + closed.close() + with pytest.raises(InvalidSandboxStep) as closed_info: + scripted_sandbox_session([{"method": "read", "result": closed}]) + assert closed_info.value.reason == "invalid_outcome" + + +@pytest.mark.asyncio +async def test_scripted_sandbox_rejects_unsupported_stream_call_before_commit() -> None: + session = scripted_sandbox_session([{"method": "write", "result": None}]) + + with io.BufferedReader(io.BytesIO(b"payload")) as unsupported: + with pytest.raises(TypeError, match="support only io.BytesIO and io.StringIO"): + await session.write(Path("output.bin"), unsupported) + + assert session.calls == () + assert session.remaining_steps == 1 + + +def test_scripted_sandbox_rejects_callable_unsupported_stream_result() -> None: + with _CallableBufferedReader(io.BytesIO(b"payload")) as unsupported: + with pytest.raises(InvalidSandboxStep) as exc_info: + scripted_sandbox_session([{"method": "read", "result": unsupported}]) + + assert exc_info.value.reason == "invalid_outcome" + + +@pytest.mark.parametrize( + "stream_factory", + [ + lambda: _CallableBytesIO(b"payload"), + lambda: _CallableBufferedReader(io.BytesIO(b"payload")), + ], +) +@pytest.mark.parametrize( + ("field", "reason"), + [("match", "invalid_matcher"), ("responder", "invalid_outcome")], +) +def test_scripted_sandbox_rejects_callable_stream_matchers_and_responders( + stream_factory: Any, + field: str, + reason: str, +) -> None: + stream = cast(io.IOBase, stream_factory()) + step: dict[str, Any] = {"method": "exec", field: stream} + if field == "match": + step["result"] = ExecResult(stdout=b"", stderr=b"", exit_code=0) + + try: + with pytest.raises(InvalidSandboxStep) as exc_info: + scripted_sandbox_session([step]) + finally: + stream.close() + + assert exc_info.value.reason == reason + assert exc_info.value.input_index == 0 + + +@pytest.mark.asyncio +async def test_scripted_sandbox_rejects_callable_unsupported_stream_call_before_commit() -> None: + session = scripted_sandbox_session([{"method": "write", "result": None}]) + + with _CallableBufferedReader(io.BytesIO(b"payload")) as unsupported: + with pytest.raises(TypeError, match="support only io.BytesIO and io.StringIO"): + await session.write(Path("output.bin"), unsupported) + + assert session.calls == () + assert session.remaining_steps == 1 + + +@pytest.mark.asyncio +async def test_scripted_sandbox_snapshots_static_results_when_queued() -> None: + configured = ExecResult(stdout=b"before", stderr=b"", exit_code=0) + session = scripted_sandbox_session([{"method": "exec", "result": configured}]) + configured.stdout = b"after" + + result = await session.exec("pwd") + + assert result.stdout == b"before" + session.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_sandbox_records_detached_fifo_calls() -> None: + source_operations: list[dict[str, object]] = [{"type": "delete_file", "path": "before.txt"}] + session = scripted_sandbox_session( + [ + {"method": "apply_patch", "result": "Done!"}, + {"method": "exec", "result": ExecResult(stdout=b"ok", stderr=b"", exit_code=0)}, + ] + ) + + assert await session.apply_patch(cast(Any, source_operations)) == "Done!" + source_operations[0]["path"] = "after.txt" + result = await session.exec("pwd", shell=False) + + assert result.stdout == b"ok" + assert session.remaining_steps == 0 + session.assert_complete() + assert session.calls[0].method == "apply_patch" + assert session.calls[0].args[0] == [{"type": "delete_file", "path": "before.txt"}] + assert session.calls[1] == SandboxCall( + call_index=1, + method="exec", + args=("pwd",), + kwargs=MappingProxyType({"timeout": None, "shell": False, "user": None}), + ) + + returned_operations = cast(list[dict[str, object]], session.calls[0].args[0]) + returned_operations[0]["path"] = "mutated.txt" + assert session.calls[0].args[0] == [{"type": "delete_file", "path": "before.txt"}] + + +@pytest.mark.asyncio +async def test_scripted_sandbox_snapshot_failure_does_not_commit_call() -> None: + class Uncopyable: + def __deepcopy__(self, memo: dict[int, object]) -> object: + _ = memo + raise RuntimeError("cannot snapshot") + + session = scripted_sandbox_session( + [{"method": "exec", "result": ExecResult(stdout=b"", stderr=b"", exit_code=0)}] + ) + + with pytest.raises(RuntimeError, match="cannot snapshot"): + await session.exec(cast(Any, Uncopyable())) + + assert session.calls == () + assert session.remaining_steps == 1 + + +@pytest.mark.asyncio +async def test_scripted_sandbox_supports_matchers_responders_and_errors() -> None: + injected_error = RuntimeError("sandbox unavailable") + session = scripted_sandbox_session( + [ + { + "method": "exec", + "match": lambda call: call.args == ("pwd",), + "responder": lambda call: ExecResult( + stdout=f"call {len(call.args)}".encode(), stderr=b"", exit_code=0 + ), + }, + {"method": "exec", "error": injected_error}, + ] + ) + + assert (await session.exec("pwd")).stdout == b"call 1" + with pytest.raises(RuntimeError) as exc_info: + await session.exec("next") + assert exc_info.value is injected_error + session.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_sandbox_reports_structured_payload_free_failures() -> None: + mismatch = scripted_sandbox_session( + [ + {"method": "exec", "result": ExecResult(stdout=b"", stderr=b"", exit_code=0)}, + {"method": "read", "result": None}, + ] + ) + + with pytest.raises(UnexpectedSandboxCall) as mismatch_info: + await mismatch.read(Path("/secret/payload.txt")) + mismatch_error = mismatch_info.value + assert mismatch_error.call_index == 0 + assert "call #1" in str(mismatch_error) + assert mismatch_error.actual_method == "read" + assert mismatch_error.expected_method == "exec" + assert mismatch_error.remaining_steps == 2 + assert mismatch.remaining_steps == 2 + assert "/secret/payload.txt" not in str(mismatch_error) + await mismatch.exec("retry") + assert mismatch.remaining_steps == 1 + + rejected = scripted_sandbox_session( + [ + { + "method": "exec", + "match": lambda call: call.args == ("expected",), + "result": ExecResult(stdout=b"", stderr=b"", exit_code=0), + } + ] + ) + with pytest.raises(SandboxCallMatcherError) as rejected_info: + await rejected.exec("secret command") + assert rejected_info.value.call_index == 0 + assert "call #1" in str(rejected_info.value) + assert rejected_info.value.method == "exec" + assert "secret command" not in str(rejected_info.value) + assert rejected.remaining_steps == 1 + await rejected.exec("expected") + rejected.assert_complete() + + extra = scripted_sandbox_session( + [{"method": "exec", "result": ExecResult(stdout=b"", stderr=b"", exit_code=0)}] + ) + await extra.exec("first") + assert hasattr(extra, "exec") + with pytest.raises(UnexpectedSandboxCall) as extra_info: + await extra.exec("second secret") + assert extra_info.value.call_index == 1 + assert "call #2" in str(extra_info.value) + assert extra_info.value.expected_method is None + assert extra_info.value.remaining_steps == 0 + assert "second secret" not in str(extra_info.value) + + +@pytest.mark.asyncio +async def test_scripted_sandbox_retains_step_after_matcher_exception() -> None: + matcher_error = RuntimeError("matcher failed") + matcher_calls = 0 + + def match(_call: SandboxCall) -> bool: + nonlocal matcher_calls + matcher_calls += 1 + if matcher_calls == 1: + raise matcher_error + return True + + session = scripted_sandbox_session( + [ + { + "method": "exec", + "match": match, + "result": ExecResult(stdout=b"ok", stderr=b"", exit_code=0), + } + ] + ) + + with pytest.raises(RuntimeError) as exc_info: + await session.exec("first") + + assert exc_info.value is matcher_error + assert session.remaining_steps == 1 + assert (await session.exec("retry")).stdout == b"ok" + session.assert_complete() + + +def test_scripted_sandbox_reports_unconsumed_steps() -> None: + session = scripted_sandbox_session( + [ + {"method": "exec", "result": ExecResult(stdout=b"", stderr=b"", exit_code=0)}, + {"method": "read", "result": None}, + ] + ) + + with pytest.raises(UnconsumedSandboxSteps) as exc_info: + session.assert_complete() + assert exc_info.value.remaining_steps == 2 + assert exc_info.value.pending_methods == ("exec", "read") + + +@pytest.mark.parametrize( + ("step", "reason"), + [ + ("not a mapping", "invalid_input"), + ({"method": "exec", "matc": lambda _call: True, "result": None}, "invalid_input"), + ({"method": "unknown", "result": None}, "unknown_method"), + ({"method": "exec", "match": "no", "result": None}, "invalid_matcher"), + ({"method": "exec"}, "invalid_outcome"), + ({"method": "exec", "result": None, "error": RuntimeError()}, "invalid_outcome"), + ({"method": "exec", "responder": "no"}, "invalid_outcome"), + ({"method": "exec", "error": "no"}, "invalid_outcome"), + ], +) +def test_scripted_sandbox_validates_steps_before_use(step: object, reason: str) -> None: + with pytest.raises(InvalidSandboxStep) as exc_info: + scripted_sandbox_session(cast(Any, [step])) + assert exc_info.value.reason == reason + assert exc_info.value.input_index == 0 + assert "step #1" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_scripted_sandbox_drives_black_box_sandbox_agent_workflow() -> None: + session = scripted_sandbox_session( + [ + { + "method": "exec", + "match": lambda call: call.args == ("pwd",), + "result": ExecResult(stdout=b"/workspace\n", stderr=b"", exit_code=0), + } + ] + ) + model = ScriptedModel( + [ + [function_call("exec_command", {"cmd": "pwd"}, call_id="call_1")], + [assistant_message("The workspace is /workspace.")], + ] + ) + agent = SandboxAgent( + name="Test agent", + model=model, + capabilities=[Shell()], + ) + + result = await Runner.run( + agent, + "Where am I?", + run_config=RunConfig(sandbox={"session": session}), + ) + + assert result.final_output == "The workspace is /workspace." + assert len(session.calls) == 1 + assert len(model.calls) == 2 + session.assert_complete() + model.assert_complete() diff --git a/tests/test_server_conversation_tracker.py b/tests/test_server_conversation_tracker.py index 7f20fe46c6..3474a3d235 100644 --- a/tests/test_server_conversation_tracker.py +++ b/tests/test_server_conversation_tracker.py @@ -28,9 +28,10 @@ from agents.run_internal.run_steps import NextStepInterruption from agents.run_internal.tool_use_tracker import AgentToolUseTracker from agents.stream_events import RunItemStreamEvent +from agents.testing import ScriptedModel from agents.usage import Usage +from tests.model_test_helpers import get_exact_output_stream_step -from .fake_model import FakeModel from .test_responses import get_text_message @@ -815,8 +816,8 @@ def test_prepare_input_does_not_resend_reasoning_item_after_marking_omitted_id_a @pytest.mark.asyncio async def test_get_new_response_marks_filtered_input_as_sent() -> None: - model = FakeModel() - model.set_next_output([get_text_message("ok")]) + model = ScriptedModel() + model.enqueue([get_text_message("ok")]) agent = Agent(name="test", model=model) tracker = OpenAIServerConversationTracker(conversation_id="conv4", previous_response_id=None) context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={}) @@ -848,15 +849,15 @@ def _filter_input(payload: Any) -> ModelInputData: None, ) - assert model.last_turn_args["input"] == [item_1] + assert model.calls[-1].input == [item_1] assert any(item is item_1 for item in tracker.sent_items) assert all(item is not item_2 for item in tracker.sent_items) @pytest.mark.asyncio async def test_run_single_turn_streamed_marks_filtered_input_as_sent() -> None: - model = FakeModel() - model.set_next_output([get_text_message("ok")]) + model = ScriptedModel() + model.enqueue([get_text_message("ok")]) agent = Agent(name="test", model=model) tracker = OpenAIServerConversationTracker(conversation_id="conv6", previous_response_id=None) context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={}) @@ -903,13 +904,13 @@ def _filter_input(payload: Any) -> ModelInputData: server_conversation_tracker=tracker, ) - assert model.last_turn_args["input"] == [item_1] + assert model.calls[-1].input == [item_1] assert tracker.remaining_initial_input == [item_2] @pytest.mark.asyncio async def test_run_single_turn_streamed_seeds_hosted_mcp_metadata_from_pre_step_items() -> None: - model = FakeModel() + model = ScriptedModel() mcp_call = McpCall( id="mcp_call_1", arguments="{}", @@ -918,7 +919,7 @@ async def test_run_single_turn_streamed_seeds_hosted_mcp_metadata_from_pre_step_ type="mcp_call", status="completed", ) - model.set_next_output([mcp_call]) + model.enqueue(get_exact_output_stream_step([mcp_call])) agent = Agent(name="test", model=model) hosted_tool = HostedMCPTool( tool_config=cast( @@ -977,7 +978,7 @@ def _filter_input(payload: Any) -> ModelInputData: all_tools=[hosted_tool], ) - assert model.last_turn_args["input"] == [item_1] + assert model.calls[-1].input == [item_1] tool_call_events: list[ToolCallItem] = [] while not streamed_result._event_queue.empty(): diff --git a/tests/test_shell_call_serialization.py b/tests/test_shell_call_serialization.py index 2c0b4d9c35..6737c99eef 100644 --- a/tests/test_shell_call_serialization.py +++ b/tests/test_shell_call_serialization.py @@ -6,8 +6,8 @@ from agents.exceptions import ModelBehaviorError from agents.items import ToolCallOutputItem from agents.run_internal import run_loop +from agents.testing import ScriptedModel from agents.tool import ShellCallOutcome, ShellCommandOutput -from tests.fake_model import FakeModel def test_coerce_shell_call_reads_max_output_length() -> None: @@ -122,7 +122,7 @@ def test_serialize_shell_output_emits_canonical_outcome() -> None: def test_shell_rejection_payload_preserves_missing_exit_code() -> None: - agent = Agent(name="tester", model=FakeModel()) + agent = Agent(name="tester", model=ScriptedModel()) raw_item = { "type": "shell_call_output", "call_id": "call-1", @@ -148,7 +148,7 @@ def test_shell_rejection_payload_preserves_missing_exit_code() -> None: def test_shell_output_preserves_zero_exit_code() -> None: - agent = Agent(name="tester", model=FakeModel()) + agent = Agent(name="tester", model=ScriptedModel()) raw_item = { "type": "shell_call_output", "call_id": "call-2", diff --git a/tests/test_soft_cancel.py b/tests/test_soft_cancel.py index 3941c85523..1bcfcf121a 100644 --- a/tests/test_soft_cancel.py +++ b/tests/test_soft_cancel.py @@ -10,8 +10,8 @@ from agents import Agent, Runner, SQLiteSession from agents.agent_output import AgentOutputSchema from agents.stream_events import StreamEvent +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import ( get_function_tool, get_function_tool_call, @@ -23,7 +23,7 @@ @pytest.mark.asyncio async def test_soft_cancel_completes_turn(): """Verify soft cancel waits for turn to complete.""" - model = FakeModel() + model = ScriptedModel([[]]) agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -44,7 +44,7 @@ async def test_soft_cancel_completes_turn(): async def test_soft_cancel_vs_immediate(): """Compare soft cancel vs immediate cancel behavior.""" # Immediate cancel - model1 = FakeModel() + model1 = ScriptedModel([[]]) agent1 = Agent(name="A1", model=model1) result1 = Runner.run_streamed(agent1, input="Hello") immediate_events = [] @@ -54,7 +54,7 @@ async def test_soft_cancel_vs_immediate(): result1.cancel(mode="immediate") # Soft cancel - model2 = FakeModel() + model2 = ScriptedModel([[]]) agent2 = Agent(name="A2", model=model2) result2 = Runner.run_streamed(agent2, input="Hello") soft_events = [] @@ -72,14 +72,14 @@ async def test_soft_cancel_vs_immediate(): @pytest.mark.asyncio async def test_soft_cancel_with_tool_calls(): """Verify tool calls execute before soft cancel stops.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, tools=[get_function_tool("calc", "42")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("Let me calculate"), @@ -109,7 +109,7 @@ async def test_soft_cancel_with_tool_calls(): @pytest.mark.asyncio async def test_soft_cancel_saves_session(): """Verify session is saved properly with soft cancel.""" - model = FakeModel() + model = ScriptedModel([[], []]) agent = Agent(name="Assistant", model=model) session = SQLiteSession("test_soft_cancel_session") @@ -136,7 +136,7 @@ async def test_soft_cancel_saves_session(): @pytest.mark.asyncio async def test_soft_cancel_tracks_usage(): """Verify usage is tracked for completed turn.""" - model = FakeModel() + model = ScriptedModel([[]]) agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -145,7 +145,7 @@ async def test_soft_cancel_tracks_usage(): if event.type == "raw_response_event": result.cancel(mode="after_turn") - # Usage should be tracked (FakeModel tracks requests even if tokens are 0) + # Usage should be tracked (ScriptedModel tracks requests even if tokens are 0) assert result.context_wrapper.usage.requests > 0 @@ -153,7 +153,7 @@ async def test_soft_cancel_tracks_usage(): @pytest.mark.parametrize("consumer_suspensions", [0, 1, 3]) async def test_soft_cancel_stops_next_turn(consumer_suspensions: int): """Verify soft cancel prevents next turn from starting.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, @@ -161,7 +161,7 @@ async def test_soft_cancel_stops_next_turn(consumer_suspensions: int): ) # Set up multi-turn scenario - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Turn 2")], @@ -188,13 +188,13 @@ async def test_soft_cancel_stops_next_turn(consumer_suspensions: int): @pytest.mark.asyncio async def test_soft_cancel_stops_next_turn_with_short_lived_anext_tasks(): """Per-event tasks must not acknowledge a turn before the caller handles its event.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, tools=[get_function_tool("tool1", "result1")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Turn 2")], @@ -218,8 +218,8 @@ async def test_soft_cancel_stops_next_turn_with_short_lived_anext_tasks(): @pytest.mark.asyncio async def test_streamed_run_completes_without_an_event_consumer(): """Turn acknowledgement must not block a run whose events are not consumed.""" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Turn 2")], @@ -242,8 +242,8 @@ async def test_streamed_run_completes_without_an_event_consumer(): @pytest.mark.asyncio async def test_closing_stream_consumer_releases_turn_acknowledgement(): """Closing an iterator must not deadlock while a turn awaits its consumer.""" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Turn 2")], @@ -271,8 +271,8 @@ async def test_closing_stream_consumer_releases_turn_acknowledgement(): @pytest.mark.asyncio async def test_cancelled_stream_consumer_releases_turn_acknowledgement(): """Cancelling a consumer suspended after yield must release the completed turn.""" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Turn 2")], @@ -315,8 +315,8 @@ async def consume_events() -> None: @pytest.mark.asyncio async def test_immediate_cancel_releases_turn_acknowledgement(): """Immediate cancellation must cancel a run waiting for streamed event acknowledgement.""" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Turn 2")], @@ -342,7 +342,7 @@ async def test_immediate_cancel_releases_turn_acknowledgement(): @pytest.mark.asyncio async def test_cancel_mode_backward_compatibility(): """Verify default behavior unchanged.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -363,7 +363,7 @@ async def test_cancel_mode_backward_compatibility(): @pytest.mark.asyncio async def test_soft_cancel_idempotent(): """Verify calling cancel multiple times is safe.""" - model = FakeModel() + model = ScriptedModel([[]]) agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -382,7 +382,7 @@ async def test_soft_cancel_idempotent(): @pytest.mark.asyncio async def test_soft_cancel_before_streaming(): """Verify soft cancel before streaming starts.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -398,7 +398,7 @@ async def test_soft_cancel_before_streaming(): @pytest.mark.asyncio async def test_soft_cancel_mixed_modes(): """Verify changing cancel mode behaves correctly.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -418,7 +418,7 @@ async def test_soft_cancel_mixed_modes(): @pytest.mark.asyncio async def test_soft_cancel_explicit_immediate_mode(): """Test explicit immediate mode behaves same as default.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -439,7 +439,7 @@ async def test_soft_cancel_explicit_immediate_mode(): @pytest.mark.asyncio async def test_soft_cancel_with_multiple_tool_calls(): """Verify soft cancel works with multiple tool calls in one turn.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, @@ -450,7 +450,7 @@ async def test_soft_cancel_with_multiple_tool_calls(): ) # Turn with multiple tool calls - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("tool1", "{}", call_id="tool_1"), @@ -479,14 +479,14 @@ async def test_soft_cancel_with_multiple_tool_calls(): @pytest.mark.asyncio async def test_soft_cancel_preserves_state(): """Verify soft cancel preserves all result state correctly.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, tools=[get_function_tool("tool1", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Done")], @@ -509,7 +509,7 @@ async def test_soft_cancel_preserves_state(): @pytest.mark.asyncio async def test_immediate_cancel_clears_queues(): """Verify immediate cancel clears queues as expected.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -528,7 +528,7 @@ async def test_immediate_cancel_clears_queues(): @pytest.mark.asyncio async def test_soft_cancel_does_not_clear_queues_immediately(): """Verify soft cancel does NOT clear queues immediately.""" - model = FakeModel() + model = ScriptedModel() agent = Agent(name="Assistant", model=model) result = Runner.run_streamed(agent, input="Hello") @@ -551,7 +551,7 @@ async def test_soft_cancel_with_handoff(): """Verify soft cancel after handoff saves the handoff turn.""" from agents import Handoff - model = FakeModel() + model = ScriptedModel() # Create two agents with handoff agent2 = Agent(name="Agent2", model=model) @@ -574,7 +574,7 @@ async def on_invoke_handoff(context, data): ) # Setup: Agent1 does handoff, Agent2 responds - model.add_multiple_turn_outputs( + model.extend( [ # Agent1's turn - triggers handoff [get_function_tool_call(Handoff.default_tool_name(agent2), "{}")], @@ -610,7 +610,7 @@ async def test_soft_cancel_waits_for_handoff_event_consumption_before_next_turn( """A suspended handoff consumer can stop the run before the delegate model starts.""" second_request_started = asyncio.Event() - class HandoffModel(FakeModel): + class HandoffModel(ScriptedModel): def __init__(self) -> None: super().__init__() self.request_count = 0 @@ -625,7 +625,7 @@ async def stream_response(self, *args, **kwargs): model = HandoffModel() delegate = Agent(name="Delegate", model=model, output_type=int) triage = Agent(name="Triage", model=model, handoffs=[delegate]) - model.add_multiple_turn_outputs( + model.extend( [ [get_handoff_tool_call(delegate)], [get_text_message("Delegate response")], @@ -666,7 +666,7 @@ async def consume_events() -> None: @pytest.mark.asyncio async def test_soft_cancel_with_session_and_multiple_turns(): """Verify soft cancel with session across multiple turns.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, @@ -677,7 +677,7 @@ async def test_soft_cancel_with_session_and_multiple_turns(): await session.clear_session() # Setup 3 turns - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("tool1", "{}", call_id="tool_1")], [get_function_tool_call("tool1", "{}", call_id="tool_2")], diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index 27e482d55a..d6ad434545 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -51,10 +51,11 @@ ToolSearchOutputItem, ) from agents.run_internal.streaming import stream_step_items_to_queue, stream_step_result_to_queue +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .mcp.helpers import FakeMCPServer from .mcp.model_compat import Tool as MCPTool +from .model_test_helpers import get_exact_output_stream_step from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message @@ -88,14 +89,14 @@ async def foo() -> str: @pytest.mark.asyncio async def test_stream_events_main(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Joker", model=model, tools=[foo], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [ @@ -127,7 +128,7 @@ async def test_stream_events_main(): @pytest.mark.asyncio async def test_stream_events_tool_called_includes_local_mcp_title() -> None: - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer( tools=[ MCPTool( @@ -140,7 +141,7 @@ async def test_stream_events_tool_called_includes_local_mcp_title() -> None: ) agent = Agent(name="MCPAgent", model=model, mcp_servers=[server]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("search_docs", "{}")], [get_text_message("done")], @@ -287,11 +288,11 @@ async def foo(args: str) -> str: english_agent = Agent( name="EnglishAgent", instructions="You only speak English.", - model=FakeModel(), + model=ScriptedModel([[]]), ) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_text_message("Hello"), @@ -341,14 +342,14 @@ async def test_complete_streaming_events(): - Function call with arguments delta/done events - Message output with content_part and text delta/done events """ - model = FakeModel() + model = ScriptedModel() agent = Agent( name="TestAgent", model=model, tools=[foo], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_reasoning_item(), @@ -481,8 +482,8 @@ async def test_complete_streaming_events(): @pytest.mark.asyncio async def test_tool_call_event_preserves_order_before_later_reasoning_item() -> None: - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call("foo", '{"arg": "value"}'), @@ -511,12 +512,14 @@ async def test_tool_call_event_preserves_order_before_later_reasoning_item() -> async def test_handoff_event_preserves_order_before_later_reasoning_item() -> None: english_agent = Agent( name="EnglishAgent", - model=FakeModel(initial_output=[get_text_message("Done")]), + model=ScriptedModel(steps=[[get_text_message("Done")]]), ) - model = FakeModel( - initial_output=[ - get_handoff_tool_call(english_agent), - get_reasoning_item(), + model = ScriptedModel( + steps=[ + [ + get_handoff_tool_call(english_agent), + get_reasoning_item(), + ] ] ) triage_agent = Agent(name="TriageAgent", model=model, handoffs=[english_agent]) @@ -542,12 +545,14 @@ def copied_filter(data: HandoffInputData) -> HandoffInputData: english_agent = Agent( name="EnglishAgent", - model=FakeModel(initial_output=[get_text_message("Done")]), + model=ScriptedModel(steps=[[get_text_message("Done")]]), ) - model = FakeModel( - initial_output=[ - get_text_message("Transferring"), - get_handoff_tool_call(english_agent), + model = ScriptedModel( + steps=[ + [ + get_text_message("Transferring"), + get_handoff_tool_call(english_agent), + ] ] ) triage_agent = Agent( @@ -572,7 +577,7 @@ def copied_filter(data: HandoffInputData) -> HandoffInputData: @pytest.mark.asyncio async def test_stream_events_emit_tool_search_items() -> None: - model = FakeModel() + model = ScriptedModel() agent = Agent(name="ToolSearchAgent", model=model) tool_search_call = cast( ResponseOutputItem, @@ -616,8 +621,12 @@ async def test_stream_events_emit_tool_search_items() -> None: }, ), ) - model.add_multiple_turn_outputs( - [[tool_search_call, tool_search_output, get_text_message("Done")]] + model.extend( + [ + get_exact_output_stream_step( + [tool_search_call, tool_search_output, get_text_message("Done")] + ) + ] ) result = Runner.run_streamed(agent, input="Search for CRM order tools") @@ -641,10 +650,10 @@ async def test_stream_events_emit_tool_search_items() -> None: @pytest.mark.asyncio async def test_streamed_handoff_call_is_not_emitted_as_tool_called(): """A handoff call streams only as `handoff_requested`, never also as `tool_called`.""" - english_agent = Agent(name="EnglishAgent", model=FakeModel()) + english_agent = Agent(name="EnglishAgent", model=ScriptedModel([[]])) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_handoff_tool_call(english_agent)], [get_text_message("Done")], @@ -673,10 +682,10 @@ async def test_streamed_handoff_call_is_not_emitted_as_tool_called(): @pytest.mark.asyncio async def test_streamed_tool_call_alongside_handoff_still_emits_tool_called(): """A real tool call in the same turn as a handoff keeps its `tool_called` event.""" - english_agent = Agent(name="EnglishAgent", model=FakeModel()) + english_agent = Agent(name="EnglishAgent", model=ScriptedModel([[]])) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call("foo", '{"a": "b"}', call_id="tool_call"), @@ -712,10 +721,10 @@ async def test_streamed_tool_call_alongside_handoff_still_emits_tool_called(): @pytest.mark.asyncio async def test_streamed_handoff_item_events_match_new_items(): """Streamed run item events stay in sync with the items recorded on the result.""" - english_agent = Agent(name="EnglishAgent", model=FakeModel()) + english_agent = Agent(name="EnglishAgent", model=ScriptedModel([[]])) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("Transferring"), get_handoff_tool_call(english_agent)], [get_text_message("Done")], diff --git a/tests/test_stream_input_guardrail_timing.py b/tests/test_stream_input_guardrail_timing.py index 9309dee819..5d8bd676f7 100644 --- a/tests/test_stream_input_guardrail_timing.py +++ b/tests/test_stream_input_guardrail_timing.py @@ -10,7 +10,7 @@ from agents import Agent, GuardrailFunctionOutput, InputGuardrail, RunContextWrapper, Runner from agents.exceptions import InputGuardrailTripwireTriggered from agents.items import TResponseInputItem -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message from tests.testing_processor import fetch_events, fetch_ordered_spans @@ -49,8 +49,8 @@ async def slow_guardrail( output_info={"delay": FAST_GUARDRAIL_DELAY}, tripwire_triggered=False ) - model = FakeModel() - model.set_next_output([get_text_message("Final response")]) + model = ScriptedModel() + model.enqueue([get_text_message("Final response")]) agent = Agent( name="TimingAgentOrder", @@ -81,8 +81,8 @@ async def test_run_streamed_input_guardrail_timing_is_consistent(guardrail_delay """ # Arrange: Agent with a single text output and a delayed input guardrail - model = FakeModel() - model.set_next_output([get_text_message("Final response")]) + model = ScriptedModel() + model.enqueue([get_text_message("Final response")]) agent = Agent( name="TimingAgent", @@ -122,8 +122,8 @@ async def test_run_streamed_input_guardrail_sequences_match_between_fast_and_slo """Run twice with fast vs slow input guardrail and compare event sequences exactly.""" async def run_once(delay: float) -> list[str]: - model = FakeModel() - model.set_next_output([get_text_message("Final response")]) + model = ScriptedModel() + model.enqueue([get_text_message("Final response")]) agent = Agent( name="TimingAgent", model=model, @@ -148,8 +148,8 @@ async def run_once(delay: float) -> list[str]: async def test_run_streamed_input_guardrail_tripwire_raises(guardrail_delay: float): """Guardrail tripwire must raise from stream_events regardless of timing.""" - model = FakeModel() - model.set_next_output([get_text_message("Final response")]) + model = ScriptedModel() + model.enqueue([get_text_message("Final response")]) agent = Agent( name="TimingAgentTrip", @@ -173,11 +173,11 @@ async def test_run_streamed_input_guardrail_tripwire_raises(guardrail_delay: flo ) -class SlowCompleteFakeModel(FakeModel): - """A FakeModel that delays just before emitting ResponseCompletedEvent in streaming.""" +class SlowCompleteScriptedModel(ScriptedModel): + """A ScriptedModel that delays just before emitting ResponseCompletedEvent in streaming.""" - def __init__(self, delay_seconds: float, tracing_enabled: bool = True): - super().__init__(tracing_enabled=tracing_enabled) + def __init__(self, delay_seconds: float, emit_traces: bool = True): + super().__init__(emit_traces=emit_traces) self._delay_seconds = delay_seconds async def stream_response(self, *args, **kwargs): @@ -206,8 +206,8 @@ def _iso(s: str | None) -> datetime: async def test_parent_span_and_trace_finish_after_slow_input_guardrail(): """Agent span and trace finish after guardrail when guardrail completes last.""" - model = FakeModel(tracing_enabled=True) - model.set_next_output([get_text_message("Final response")]) + model = ScriptedModel(emit_traces=True) + model.enqueue([get_text_message("Final response")]) agent = Agent( name="TimingAgentTrace", model=model, @@ -240,8 +240,8 @@ async def test_parent_span_and_trace_finish_after_slow_input_guardrail(): async def test_parent_span_and_trace_finish_after_slow_model(): """Agent span and trace finish after model when model completes last.""" - model = SlowCompleteFakeModel(delay_seconds=SLOW_GUARDRAIL_DELAY, tracing_enabled=True) - model.set_next_output([get_text_message("Final response")]) + model = SlowCompleteScriptedModel(delay_seconds=SLOW_GUARDRAIL_DELAY, emit_traces=True) + model.enqueue([get_text_message("Final response")]) agent = Agent( name="TimingAgentTrace", model=model, diff --git a/tests/test_streamed_terminal_output_backfill.py b/tests/test_streamed_terminal_output_backfill.py index d4ca79b2b5..c35c447f41 100644 --- a/tests/test_streamed_terminal_output_backfill.py +++ b/tests/test_streamed_terminal_output_backfill.py @@ -2,7 +2,6 @@ import json from collections.abc import AsyncIterator -from typing import Any import pytest from openai.types.responses import ( @@ -13,75 +12,24 @@ ) from agents import Agent, Runner -from agents.agent_output import AgentOutputSchemaBase -from agents.handoffs import Handoff -from agents.items import TResponseInputItem, TResponseOutputItem, TResponseStreamEvent -from agents.model_settings import ModelSettings -from agents.models.interface import ModelTracing -from agents.tool import Tool, function_tool - -from .fake_model import FakeModel, get_response_obj +from agents.items import TResponseOutputItem, TResponseStreamEvent +from agents.testing import ModelStep, ScriptedModel +from agents.tool import function_tool + +from .model_test_helpers import get_response_obj from .test_responses import get_final_output_message, get_function_tool_call -class TerminalOutputStreamModel(FakeModel): - def __init__(self) -> None: - super().__init__() - self.terminal_turn_outputs: list[list[TResponseOutputItem]] = [] - - def add_terminal_turn_outputs( - self, - outputs: list[list[TResponseOutputItem]], - ) -> None: - self.terminal_turn_outputs.extend(outputs) - - def get_next_terminal_output(self) -> list[TResponseOutputItem]: - if not self.terminal_turn_outputs: - return [] - return self.terminal_turn_outputs.pop(0) - - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None = None, - conversation_id: str | None = None, - prompt: Any | None = None, - ) -> AsyncIterator[TResponseStreamEvent]: - turn_args = { - "system_instructions": system_instructions, - "input": input, - "model_settings": model_settings, - "tools": tools, - "output_schema": output_schema, - "previous_response_id": previous_response_id, - "conversation_id": conversation_id, - } - - if self.first_turn_args is None: - self.first_turn_args = turn_args.copy() - - self.last_turn_args = turn_args - streamed_output = self.get_next_output() - if isinstance(streamed_output, Exception): - raise streamed_output - - terminal_response = get_response_obj( - self.get_next_terminal_output(), - usage=self.hardcoded_usage, - ) +def _stream_step( + streamed_output: list[TResponseOutputItem], + terminal_output: list[TResponseOutputItem], +) -> ModelStep: + async def events(_call) -> AsyncIterator[TResponseStreamEvent]: + terminal_response = get_response_obj(terminal_output) sequence_number = 0 yield ResponseCreatedEvent( - type="response.created", - response=terminal_response, - sequence_number=sequence_number, + type="response.created", response=terminal_response, sequence_number=sequence_number ) sequence_number += 1 @@ -107,6 +55,8 @@ async def stream_response( sequence_number=sequence_number, ) + return ModelStep.stream(events) + @pytest.mark.asyncio async def test_streamed_runner_backfills_empty_terminal_output_before_step_resolution() -> None: @@ -117,21 +67,16 @@ async def test_tool(a: str) -> str: return "tool_result" tool = function_tool(test_tool, name_override="foo") - model = TerminalOutputStreamModel() - agent = Agent(name="test", model=model, tools=[tool]) - - model.add_multiple_turn_outputs( - [ - [get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="call-1")], - [get_final_output_message("done")], - ] - ) - model.add_terminal_turn_outputs( + model = ScriptedModel( [ - [], - [get_final_output_message("done")], + _stream_step( + [get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="call-1")], + [], + ), + _stream_step([get_final_output_message("done")], [get_final_output_message("done")]), ] ) + agent = Agent(name="test", model=model, tools=[tool]) result = Runner.run_streamed(agent, input="test") async for _ in result.stream_events(): @@ -151,19 +96,15 @@ async def test_tool(a: str) -> str: return "tool_result" tool = function_tool(test_tool, name_override="foo") - model = TerminalOutputStreamModel() - agent = Agent(name="test", model=model, tools=[tool]) - - model.add_multiple_turn_outputs( - [ - [get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="call-1")], - ] - ) - model.add_terminal_turn_outputs( + model = ScriptedModel( [ - [get_final_output_message("done")], + _stream_step( + [get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="call-1")], + [get_final_output_message("done")], + ) ] ) + agent = Agent(name="test", model=model, tools=[tool]) result = Runner.run_streamed(agent, input="test") async for _ in result.stream_events(): @@ -188,24 +129,19 @@ async def bar_tool(b: str) -> str: foo = function_tool(foo_tool, name_override="foo") bar = function_tool(bar_tool, name_override="bar") - model = TerminalOutputStreamModel() - agent = Agent(name="test", model=model, tools=[foo, bar]) - - model.add_multiple_turn_outputs( - [ - [ - get_function_tool_call("foo", json.dumps({"a": "first"}), call_id="call-1"), - get_function_tool_call("bar", json.dumps({"b": "second"}), call_id="call-2"), - ], - [get_final_output_message("done")], - ] - ) - model.add_terminal_turn_outputs( + model = ScriptedModel( [ - [], - [get_final_output_message("done")], + _stream_step( + [ + get_function_tool_call("foo", json.dumps({"a": "first"}), call_id="call-1"), + get_function_tool_call("bar", json.dumps({"b": "second"}), call_id="call-2"), + ], + [], + ), + _stream_step([get_final_output_message("done")], [get_final_output_message("done")]), ] ) + agent = Agent(name="test", model=model, tools=[foo, bar]) result = Runner.run_streamed(agent, input="test") async for _ in result.stream_events(): diff --git a/tests/test_streaming_logging.py b/tests/test_streaming_logging.py index 380853e556..09f8583aae 100644 --- a/tests/test_streaming_logging.py +++ b/tests/test_streaming_logging.py @@ -10,7 +10,7 @@ from agents.run import AgentRunner from agents.run_context import RunContextWrapper from agents.run_state import RunState -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message @@ -20,8 +20,8 @@ async def test_run_streamed_resume_omits_tool_output_in_log_when_dont_log( ) -> None: monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) - model = FakeModel() - model.set_next_output([get_text_message("ok")]) + model = ScriptedModel() + model.enqueue([get_text_message("ok")]) agent = Agent(name="log-agent", model=model) context_wrapper: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) state = RunState( diff --git a/tests/test_streaming_tool_call_arguments.py b/tests/test_streaming_tool_call_arguments.py index 6a49bcf494..f5d88a291e 100644 --- a/tests/test_streaming_tool_call_arguments.py +++ b/tests/test_streaming_tool_call_arguments.py @@ -7,7 +7,7 @@ import json from collections.abc import AsyncIterator -from typing import Any, cast +from typing import cast import pytest from openai.types.responses import ( @@ -18,116 +18,54 @@ ) from agents import Agent, Runner, function_tool -from agents.agent_output import AgentOutputSchemaBase -from agents.handoffs import Handoff -from agents.items import TResponseInputItem, TResponseOutputItem, TResponseStreamEvent -from agents.model_settings import ModelSettings -from agents.models.interface import Model, ModelTracing +from agents.items import TResponseOutputItem, TResponseStreamEvent from agents.stream_events import RunItemStreamEvent -from agents.tool import Tool -from agents.tracing import generation_span +from agents.testing import ModelStep, ScriptedModel +from tests.model_test_helpers import get_response_obj -from .fake_model import get_response_obj from .test_responses import get_function_tool_call -class StreamingFakeModel(Model): - """A fake model that actually emits streaming events to test our streaming fix.""" - - def __init__(self): - self.turn_outputs: list[list[TResponseOutputItem]] = [] - self.last_turn_args: dict[str, Any] = {} - - def set_next_output(self, output: list[TResponseOutputItem]): - self.turn_outputs.append(output) - - def get_next_output(self) -> list[TResponseOutputItem]: - if not self.turn_outputs: - return [] - return self.turn_outputs.pop(0) - - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ): - raise NotImplementedError("Use stream_response instead") - - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None = None, - conversation_id: str | None = None, - prompt: Any | None = None, - ) -> AsyncIterator[TResponseStreamEvent]: - """Stream events that simulate real OpenAI streaming behavior for tool calls.""" - self.last_turn_args = { - "system_instructions": system_instructions, - "input": input, - "model_settings": model_settings, - "tools": tools, - "output_schema": output_schema, - "previous_response_id": previous_response_id, - "conversation_id": conversation_id, - } - - with generation_span(disabled=True) as _: - output = self.get_next_output() - - sequence_number = 0 - - # Emit each output item with proper streaming events - for item in output: - if isinstance(item, ResponseFunctionToolCall): - # First: emit ResponseOutputItemAddedEvent with EMPTY arguments - # (this simulates the real streaming behavior that was causing the bug) - empty_args_item = ResponseFunctionToolCall( - id=item.id, - call_id=item.call_id, - type=item.type, - name=item.name, - arguments="", # EMPTY - this is the bug condition! - ) - - yield ResponseOutputItemAddedEvent( - item=empty_args_item, - output_index=0, - type="response.output_item.added", - sequence_number=sequence_number, - ) - sequence_number += 1 - - # Then: emit ResponseOutputItemDoneEvent with COMPLETE arguments - yield ResponseOutputItemDoneEvent( - item=item, # This has the complete arguments - output_index=0, - type="response.output_item.done", - sequence_number=sequence_number, - ) - sequence_number += 1 - - # Finally: emit completion - yield ResponseCompletedEvent( - type="response.completed", - response=get_response_obj(output), - sequence_number=sequence_number, - ) +def _split_argument_step(output: list[TResponseOutputItem]) -> ModelStep: + async def events(_call) -> AsyncIterator[TResponseStreamEvent]: + sequence_number = 0 + + # Emit each output item with proper streaming events. + for item in output: + if isinstance(item, ResponseFunctionToolCall): + # First emit an added event with empty arguments, as the API does before deltas. + empty_args_item = ResponseFunctionToolCall( + id=item.id, + call_id=item.call_id, + type=item.type, + name=item.name, + arguments="", + ) + + yield ResponseOutputItemAddedEvent( + item=empty_args_item, + output_index=0, + type="response.output_item.added", + sequence_number=sequence_number, + ) + sequence_number += 1 + + # Then emit the completed item with its final arguments. + yield ResponseOutputItemDoneEvent( + item=item, + output_index=0, + type="response.output_item.done", + sequence_number=sequence_number, + ) + sequence_number += 1 + + yield ResponseCompletedEvent( + type="response.completed", + response=get_response_obj(output), + sequence_number=sequence_number, + ) + + return ModelStep.stream(events) @function_tool @@ -146,7 +84,7 @@ def format_message(name: str, message: str, urgent: bool = False) -> str: @pytest.mark.asyncio async def test_streaming_tool_call_arguments_not_empty(): """Test that tool_called events contain non-empty arguments during streaming.""" - model = StreamingFakeModel() + model = ScriptedModel() agent = Agent( name="TestAgent", model=model, @@ -155,12 +93,14 @@ async def test_streaming_tool_call_arguments_not_empty(): # Set up a tool call with arguments expected_arguments = '{"a": 5, "b": 3}' - model.set_next_output( - [ - get_function_tool_call("calculate_sum", expected_arguments, "call_123"), - ] + model.enqueue( + _split_argument_step( + [ + get_function_tool_call("calculate_sum", expected_arguments, "call_123"), + ] + ) ) - + model.enqueue([]) result = Runner.run_streamed(agent, input="Add 5 and 3") tool_called_events = [] @@ -212,7 +152,7 @@ async def test_streaming_tool_call_arguments_not_empty(): @pytest.mark.asyncio async def test_streaming_tool_call_arguments_complex(): """Test streaming tool calls with complex arguments including strings and booleans.""" - model = StreamingFakeModel() + model = ScriptedModel() agent = Agent( name="TestAgent", model=model, @@ -223,12 +163,14 @@ async def test_streaming_tool_call_arguments_complex(): expected_arguments = ( '{"name": "Alice", "message": "Your meeting is starting soon", "urgent": true}' ) - model.set_next_output( - [ - get_function_tool_call("format_message", expected_arguments, "call_456"), - ] + model.enqueue( + _split_argument_step( + [ + get_function_tool_call("format_message", expected_arguments, "call_456"), + ] + ) ) - + model.enqueue([]) result = Runner.run_streamed(agent, input="Format a message for Alice") tool_called_events = [] @@ -265,7 +207,7 @@ async def test_streaming_tool_call_arguments_complex(): @pytest.mark.asyncio async def test_streaming_multiple_tool_calls_arguments(): """Test that multiple tool calls in streaming all have proper arguments.""" - model = StreamingFakeModel() + model = ScriptedModel() agent = Agent( name="TestAgent", model=model, @@ -273,15 +215,17 @@ async def test_streaming_multiple_tool_calls_arguments(): ) # Set up multiple tool calls - model.set_next_output( - [ - get_function_tool_call("calculate_sum", '{"a": 10, "b": 20}', "call_1"), - get_function_tool_call( - "format_message", '{"name": "Bob", "message": "Test"}', "call_2" - ), - ] + model.enqueue( + _split_argument_step( + [ + get_function_tool_call("calculate_sum", '{"a": 10, "b": 20}', "call_1"), + get_function_tool_call( + "format_message", '{"name": "Bob", "message": "Test"}', "call_2" + ), + ] + ) ) - + model.enqueue([]) result = Runner.run_streamed(agent, input="Do some calculations") tool_called_events = [] @@ -324,7 +268,7 @@ async def test_streaming_multiple_tool_calls_arguments(): @pytest.mark.asyncio async def test_streaming_tool_call_with_empty_arguments(): """Test that tool calls with legitimately empty arguments still work correctly.""" - model = StreamingFakeModel() + model = ScriptedModel() @function_tool def get_current_time() -> str: @@ -338,12 +282,14 @@ def get_current_time() -> str: ) # Tool call with empty arguments (legitimate case) - model.set_next_output( - [ - get_function_tool_call("get_current_time", "{}", "call_time"), - ] + model.enqueue( + _split_argument_step( + [ + get_function_tool_call("get_current_time", "{}", "call_time"), + ] + ) ) - + model.enqueue([]) result = Runner.run_streamed(agent, input="What time is it?") tool_called_events = [] diff --git a/tests/test_tool_approval_call_id_reuse.py b/tests/test_tool_approval_call_id_reuse.py index 5b6a43172b..6cff1d3444 100644 --- a/tests/test_tool_approval_call_id_reuse.py +++ b/tests/test_tool_approval_call_id_reuse.py @@ -46,7 +46,7 @@ ) from agents.editor import ApplyPatchOperation, ApplyPatchResult from agents.exceptions import ModelBehaviorError, UserError -from agents.items import ModelResponse, ToolApprovalItem +from agents.items import ModelResponse, ToolApprovalItem, TResponseOutputItem from agents.lifecycle import RunHooks from agents.models.interface import Model, ModelProvider from agents.run_context import RunContextWrapper @@ -59,9 +59,10 @@ from agents.run_internal.tool_planning import _collect_runs_by_approval from agents.run_state import RunState from agents.stream_events import RunItemStreamEvent +from agents.testing import ScriptedModel from agents.tool import Tool from agents.tool_context import ToolContext -from tests.fake_model import FakeModel +from tests.model_test_helpers import get_exact_output_stream_step from tests.test_computer_tool_lifecycle import FakeComputer from tests.test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message from tests.utils.hitl import make_apply_patch_dict, make_shell_call, make_state_with_interruptions @@ -205,8 +206,8 @@ def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: call_id="patch_0", arguments=json.dumps(operation), ) - model = FakeModel(initial_output=[call]) - model.set_next_output([get_text_message("done")]) + model = ScriptedModel(steps=[[call]]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[tool]) result = await Runner.run(agent, "update the file") @@ -236,10 +237,8 @@ def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: call_id="patch_0", arguments=json.dumps(operation), ) - model = FakeModel() - model.add_multiple_turn_outputs( - [[call], [call.model_copy(deep=True)], [get_text_message("done")]] - ) + model = ScriptedModel() + model.extend([[call], [call.model_copy(deep=True)], [get_text_message("done")]]) agent = Agent( name="agent", model=model, @@ -305,8 +304,8 @@ def execute(request: Any) -> str: first_call = make_shell_call("call_0", commands=["echo first"]) second_call = make_shell_call("call_1", commands=["echo second"]) - model = FakeModel() - model.add_multiple_turn_outputs([[first_call], [second_call], [get_text_message("done")]]) + model = ScriptedModel() + model.extend([[first_call], [second_call], [get_text_message("done")]]) tool = ShellTool( executor=execute, name="safe_shell", @@ -337,7 +336,7 @@ def run_replacement(_request: Any) -> str: return "ok" call = make_shell_call("call_0", commands=["echo safe"]) - model = FakeModel(initial_output=[call]) + model = ScriptedModel(steps=[[call]]) original_tool = ShellTool( executor=run_first, name="safe_shell", @@ -348,7 +347,7 @@ def run_replacement(_request: Any) -> str: first = await Runner.run(agent, "run command") state = first.to_state() state.approve(first.interruptions[0]) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) completed = await Runner.run(agent, state) agent.tools = [ @@ -358,7 +357,7 @@ def run_replacement(_request: Any) -> str: needs_approval=False, ) ] - model.set_next_output([cast(Any, dict(cast(dict[str, Any], call)))]) + model.enqueue([cast(Any, dict(cast(dict[str, Any], call)))]) with pytest.raises(ModelBehaviorError, match="unique call ID"): await Runner.run(agent, completed.to_state()) @@ -376,8 +375,8 @@ def execute(request: Any) -> str: return "ok" call = make_shell_call("legacy-shell", commands=["echo safe"]) - model = FakeModel(initial_output=[call]) - model.set_next_output([get_text_message("done")]) + model = ScriptedModel(steps=[[call]]) + model.enqueue([get_text_message("done")]) agent = Agent( name="agent", model=model, @@ -401,7 +400,7 @@ def execute(request: Any) -> str: restored_record = restored._context._tool_invocations["legacy-shell"] assert restored_record.approval_scope == expected_identity[2] assert restored_record.fingerprint == expected_identity[3] - model.add_multiple_turn_outputs( + model.extend( [ [call], [get_text_message("done again")], @@ -499,8 +498,8 @@ def record_value(value: str) -> str: executed.append(value) return value - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( @@ -549,14 +548,16 @@ async def invoke(_context: Any, raw_input: str) -> str: needs_approval=True, on_approval=approve, ) - model = FakeModel( - initial_output=[ - ResponseCustomToolCall( - type="custom_tool_call", - name=tool.name, - call_id="", - input="changed", - ) + model = ScriptedModel( + steps=[ + [ + ResponseCustomToolCall( + type="custom_tool_call", + name=tool.name, + call_id="", + input="changed", + ) + ] ] ) agent = Agent(name="agent", model=model, tools=[tool]) @@ -576,15 +577,17 @@ def format_tool_error(args: Any) -> str: formatter_calls.append(args.tool_name) return "error" - model = FakeModel( - initial_output=[ - ResponseFunctionToolCall( - id="item_0", - type="function_call", - name="missing", - arguments="{}", - call_id="", - ) + model = ScriptedModel( + steps=[ + [ + ResponseFunctionToolCall( + id="item_0", + type="function_call", + name="missing", + arguments="{}", + call_id="", + ) + ] ] ) agent = Agent(name="agent", model=model) @@ -627,8 +630,8 @@ def format_tool_error(args: Any) -> str: name="missing", call_id="shared", ) - model = FakeModel() - model.add_multiple_turn_outputs([[valid_call], [malformed_replacement]]) + model = ScriptedModel() + model.extend([[valid_call], [malformed_replacement]]) agent = Agent(name="agent", model=model, tools=[record_value]) with pytest.raises(ModelBehaviorError, match="unique call ID"): @@ -654,15 +657,17 @@ async def test_empty_handoff_call_id_fails_before_handoff_callback() -> None: tool_name_override="route", on_handoff=lambda _context: handoff_calls.append("route"), ) - model = FakeModel( - initial_output=[ - ResponseFunctionToolCall( - id="item_0", - type="function_call", - name="route", - arguments="{}", - call_id="", - ) + model = ScriptedModel( + steps=[ + [ + ResponseFunctionToolCall( + id="item_0", + type="function_call", + name="route", + arguments="{}", + call_id="", + ) + ] ] ) agent = Agent(name="agent", model=model, handoffs=[route]) @@ -689,8 +694,8 @@ async def on_handoff( hook_calls.append(to_agent.name) raise RuntimeError("handoff hook failed") - model = FakeModel(initial_output=[call]) - model.set_next_output([call.model_copy(deep=True)]) + model = ScriptedModel(steps=[[call]]) + model.enqueue([call.model_copy(deep=True)]) agent = Agent(name="source", model=model, handoffs=[target]) context = RunContextWrapper(context=None) hooks = FailingHooks() @@ -763,8 +768,8 @@ async def record_value(value: str) -> str: '{"value":"safe"}', call_id="call_0", ) - model = FakeModel(initial_output=[duplicate, duplicate.model_copy(deep=True)]) - model.set_next_output([get_text_message("done")]) + model = ScriptedModel(steps=[[duplicate, duplicate.model_copy(deep=True)]]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[record_value]) result = await _run( @@ -795,8 +800,8 @@ async def record_value(value: str) -> str: executed.append(value) return value - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], [get_function_tool_call("record_value", '{ "value" : "safe" }', call_id="call_0")], @@ -838,8 +843,8 @@ async def record_value(value: str) -> str: executed.append(value) return value - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], [get_function_tool_call("record_value", '{"value":"changed"}', call_id="call_0")], @@ -877,28 +882,32 @@ async def inner_sensitive_tool(value: str) -> str: executed.append(value) return value - inner_model = FakeModel( - initial_output=[ - get_function_tool_call( - "inner_sensitive_tool", - '{"value":"safe"}', - call_id="shared", - ) + inner_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "inner_sensitive_tool", + '{"value":"safe"}', + call_id="shared", + ) + ] ] ) - inner_model.set_next_output([get_text_message("inner done")]) + inner_model.enqueue([get_text_message("inner done")]) inner_agent = Agent(name="inner", model=inner_model, tools=[inner_sensitive_tool]) - outer_model = FakeModel( - initial_output=[ - get_function_tool_call( - "nested_agent", - '{"input":"hello"}', - call_id="shared", - ) + outer_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "nested_agent", + '{"input":"hello"}', + call_id="shared", + ) + ] ] ) - outer_model.set_next_output([get_text_message("outer done")]) + outer_model.enqueue([get_text_message("outer done")]) outer_agent = Agent( name="outer", model=outer_model, @@ -936,8 +945,8 @@ async def sensitive(value: str) -> str: executed.append(value) return value - inner_model = FakeModel() - inner_model.add_multiple_turn_outputs( + inner_model = ScriptedModel() + inner_model.extend( [ [get_function_tool_call("sensitive", '{"value":"same"}', call_id="shared")], [get_text_message("inner done")], @@ -945,8 +954,8 @@ async def sensitive(value: str) -> str: ) inner_agent = Agent(name="inner", model=inner_model, tools=[sensitive]) - outer_model = FakeModel() - outer_model.add_multiple_turn_outputs( + outer_model = ScriptedModel() + outer_model.extend( [ [get_function_tool_call("sensitive", '{"value":"same"}', call_id="shared")], [ @@ -1016,13 +1025,15 @@ async def record_value(value: str) -> str: executed.append(value) return value - model = FakeModel( - initial_output=[ - get_function_tool_call( - "record_value", - '{"value":"safe"}', - call_id="call_0", - ) + model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "record_value", + '{"value":"safe"}', + call_id="call_0", + ) + ] ] ) agent = Agent(name="agent", model=model, tools=[record_value]) @@ -1048,8 +1059,8 @@ async def perform_side_effect() -> str: raise RuntimeError("failed after side effect") call = get_function_tool_call("perform_side_effect", "{}", call_id="call_0") - model = FakeModel() - model.add_multiple_turn_outputs([[call], [call.model_copy(deep=True)]]) + model = ScriptedModel() + model.extend([[call], [call.model_copy(deep=True)]]) agent = Agent(name="agent", model=model, tools=[perform_side_effect]) context = RunContextWrapper(context=None) @@ -1082,8 +1093,8 @@ async def invoke(_context: Any, value: str) -> str: call_id="call_0", input="safe", ) - model = FakeModel(initial_output=[duplicate, duplicate.model_copy(deep=True)]) - model.set_next_output([get_text_message("done")]) + model = ScriptedModel(steps=[[duplicate, duplicate.model_copy(deep=True)]]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[tool]) result = await Runner.run(agent, "edit text") @@ -1107,10 +1118,12 @@ async def needs_approval(_context: Any, arguments: dict[str, Any], _call_id: str async def record_value(value: str) -> str: return value - model = FakeModel( - initial_output=[ - get_function_tool_call("record_value", '{"value":"one"}', call_id="call_0"), - get_function_tool_call("record_value", '{"value":"two"}', call_id="call_0"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("record_value", '{"value":"one"}', call_id="call_0"), + get_function_tool_call("record_value", '{"value":"two"}', call_id="call_0"), + ] ] ) agent = Agent(name="agent", model=model, tools=[record_value]) @@ -1140,7 +1153,9 @@ async def on_llm_end( self.llm_end_calls += 1 hooks = CountingHooks() - model = FakeModel(initial_output=[make_shell_call("call_0", commands=["echo safe"])]) + output: list[TResponseOutputItem] = [make_shell_call("call_0", commands=["echo safe"])] + step = get_exact_output_stream_step(output) if mode == "streamed" else output + model = ScriptedModel(steps=[step]) agent = Agent(name="agent", model=model) with pytest.raises(ModelBehaviorError, match="without a shell tool"): @@ -1171,19 +1186,21 @@ async def on_llm_end( async def record_value(value: str) -> str: return value - model = FakeModel() - model.add_multiple_turn_outputs( + outputs: list[list[TResponseOutputItem]] = [ + [get_function_tool_call("record_value", '{"value":"safe"}', call_id="shared")], [ - [get_function_tool_call("record_value", '{"value":"safe"}', call_id="shared")], - [ - get_function_tool_call( - "record_value", - '{"value":"changed"}', - call_id="shared", - ), - make_shell_call("shell_0", commands=["echo safe"]), - ], - ] + get_function_tool_call( + "record_value", + '{"value":"changed"}', + call_id="shared", + ), + make_shell_call("shell_0", commands=["echo safe"]), + ], + ] + model = ScriptedModel( + [get_exact_output_stream_step(output) for output in outputs] + if mode == "streamed" + else outputs ) hooks = CountingHooks() agent = Agent(name="agent", model=model, tools=[record_value]) @@ -1223,7 +1240,9 @@ async def on_llm_end( call_id="shared", ) hooks = CountingHooks() - model = FakeModel(initial_output=[first_call, second_call]) + output: list[TResponseOutputItem] = [first_call, second_call] + step = get_exact_output_stream_step(output) if mode == "streamed" else output + model = ScriptedModel(steps=[step]) agent = Agent(name="agent", model=model) with pytest.raises(ModelBehaviorError, match="one response"): @@ -1244,10 +1263,12 @@ async def record_value(value: str) -> str: executed.append(value) return value - model = FakeModel( - initial_output=[ - get_function_tool_call("record_value", '{"value":"one"}', call_id="call_0"), - get_function_tool_call("record_value", '{"value":"two"}', call_id="call_0"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("record_value", '{"value":"one"}', call_id="call_0"), + get_function_tool_call("record_value", '{"value":"two"}', call_id="call_0"), + ] ] ) agent = Agent(name="agent", model=model, tools=[record_value]) @@ -1295,9 +1316,11 @@ def acknowledge_safety_check(data: Any) -> bool: ], } ) + output: list[TResponseOutputItem] = [first_call, changed_call] + step = get_exact_output_stream_step(output) if mode == "streamed" else output agent = Agent( name="computer-agent", - model=FakeModel(initial_output=[first_call, changed_call]), + model=ScriptedModel(steps=[step]), tools=[tool], ) @@ -1345,8 +1368,12 @@ def acknowledge_safety_check(data: Any) -> bool: ], } ) - model = FakeModel() - model.add_multiple_turn_outputs([[first_call], [changed_call]]) + outputs: list[list[TResponseOutputItem]] = [[first_call], [changed_call]] + model = ScriptedModel( + [get_exact_output_stream_step(output) for output in outputs] + if mode == "streamed" + else outputs + ) agent = Agent(name="computer-agent", model=model, tools=[tool]) with pytest.raises(ModelBehaviorError, match="completed tool call ID"): @@ -1389,8 +1416,8 @@ async def on_tool_end( pending_safety_checks=[], status="completed", ) - model = FakeModel() - model.add_multiple_turn_outputs([[call], [call.model_copy(deep=True)]]) + model = ScriptedModel() + model.extend([[call], [call.model_copy(deep=True)]]) agent = Agent(name="computer-agent", model=model, tools=[tool]) context = RunContextWrapper(context=None) hooks = FailOnceHooks() @@ -1413,8 +1440,8 @@ async def record_value(value: str) -> str: executed.append(value) return value - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], [ @@ -1437,7 +1464,7 @@ async def record_value(value: str) -> str: assert resumed.final_output == "done" assert executed == ["safe"] - model_input = model.last_turn_args["input"] + model_input = model.calls[-1].input assert isinstance(model_input, list) assert not any(item.get("id") == "rs_replay" for item in model_input) assert ( @@ -1458,8 +1485,8 @@ async def record_value(value: str) -> str: executed.append(value) return value - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("record_value", '{"value":"safe"}', call_id="call_0")], [ @@ -1539,9 +1566,7 @@ async def record_value() -> str: executed.append("ran") return "sensitive" - model = FakeModel( - initial_output=[get_function_tool_call("record_value", "{}", call_id="call_0")] - ) + model = ScriptedModel(steps=[[get_function_tool_call("record_value", "{}", call_id="call_0")]]) agent = Agent(name="agent", model=model, tools=[record_value]) first = await Runner.run(agent, "record a value") state = first.to_state() @@ -1566,8 +1591,8 @@ async def perform_side_effect() -> str: attempts.append("ran") raise RuntimeError("failed after side effect") - model = FakeModel( - initial_output=[get_function_tool_call("perform_side_effect", "{}", call_id="call_0")] + model = ScriptedModel( + steps=[[get_function_tool_call("perform_side_effect", "{}", call_id="call_0")]] ) agent = Agent(name="agent", model=model, tools=[perform_side_effect]) first = await Runner.run(agent, "run it") @@ -1596,8 +1621,8 @@ async def perform_side_effect() -> str: await keep_running.wait() return "done" - model = FakeModel( - initial_output=[get_function_tool_call("perform_side_effect", "{}", call_id="call_0")] + model = ScriptedModel( + steps=[[get_function_tool_call("perform_side_effect", "{}", call_id="call_0")]] ) agent = Agent(name="agent", model=model, tools=[perform_side_effect]) first = await Runner.run(agent, "run it") @@ -1621,7 +1646,7 @@ async def test_failed_approved_agent_tool_start_does_not_reexecute() -> None: hook_calls: list[str] = [] inner_agent = Agent( name="inner", - model=FakeModel(initial_output=[get_text_message("inner done")]), + model=ScriptedModel(steps=[[get_text_message("inner done")]]), ) agent_tool = inner_agent.as_tool( tool_name="delegate", @@ -1640,8 +1665,8 @@ async def on_tool_start( hook_calls.append("ran") raise RuntimeError("failed after side effect") - outer_model = FakeModel( - initial_output=[get_function_tool_call("delegate", '{"input":"hi"}', call_id="call_0")] + outer_model = ScriptedModel( + steps=[[get_function_tool_call("delegate", '{"input":"hi"}', call_id="call_0")]] ) outer_agent = Agent(name="outer", model=outer_model, tools=[agent_tool]) first = await Runner.run(outer_agent, "delegate") @@ -1690,13 +1715,15 @@ async def on_tool_end( self.failed = True raise RuntimeError("second end hook failed") - model = FakeModel( - initial_output=[ - get_function_tool_call("first_tool", "{}", call_id="call_first"), - get_function_tool_call("second_tool", "{}", call_id="call_second"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("first_tool", "{}", call_id="call_first"), + get_function_tool_call("second_tool", "{}", call_id="call_second"), + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[first_tool, second_tool]) hooks = FailSecondHookOnce() @@ -1712,7 +1739,7 @@ async def on_tool_end( assert resumed.final_output == "done" assert sorted(executed) == ["first", "second"] - model_input = model.last_turn_args["input"] + model_input = model.calls[-1].input assert isinstance(model_input, list) output_call_ids = [ item["call_id"] @@ -1739,8 +1766,8 @@ def record_value(value: str) -> str: json.dumps({"value": "safe"}), call_id="call-duplicate", ) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [[duplicated_call, duplicated_call.model_copy(deep=True)], [get_text_message("done")]] ) run_config = RunConfig(model_provider=_ScriptedProvider(model)) @@ -1829,13 +1856,15 @@ async def record_value(value: int) -> str: '{"value":1}', call_id="call_0", ) - model = FakeModel( - initial_output=[ - duplicate, - duplicate.model_copy(deep=True), + model = ScriptedModel( + steps=[ + [ + duplicate, + duplicate.model_copy(deep=True), + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[record_value]) first = await Runner.run(agent, "record a value") @@ -2061,8 +2090,8 @@ def second_tool(value: str) -> str: executed.append(f"second:{value}") return value - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( @@ -2116,8 +2145,8 @@ async def invoke_custom(_ctx: Any, raw_input: str) -> str: on_invoke_tool=invoke_custom, format={"type": "text"}, ) - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( @@ -2172,8 +2201,8 @@ def sibling_tool() -> str: executed.append("sibling") return "sibling" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( @@ -2224,8 +2253,8 @@ def approval_gate(value: str) -> str: executed.append(f"gate:{value}") return value - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [ get_function_tool_call( @@ -2316,8 +2345,8 @@ async def test_serialized_completed_approval_skips_exact_replay( provider = run_config.model_provider assert isinstance(provider, _ScriptedProvider) model = provider.model - assert isinstance(model, FakeModel) - model_input = model.last_turn_args["input"] + assert isinstance(model, ScriptedModel) + model_input = model.calls[-1].input assert isinstance(model_input, list) for call_id in ("call_0", "call_1"): calls = [ @@ -2368,16 +2397,18 @@ async def record_value(value: str) -> str: call_id="call_0", ), ) - model = FakeModel( - initial_output=[ - get_function_tool_call( - "record_value", - json.dumps({"value": replay_value}), - call_id="call_0", - ) + model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "record_value", + json.dumps({"value": replay_value}), + call_id="call_0", + ) + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[record_value]) context: RunContextWrapper[Any] = RunContextWrapper(context=None) context.approve_tool( @@ -2738,7 +2769,7 @@ def approve_request(_request: MCPToolApprovalRequest) -> MCPToolApprovalFunction ) agent = Agent( name="mcp-approval-agent", - model=FakeModel(initial_output=[request]), + model=ScriptedModel(steps=[[request]]), tools=[mcp_tool], ) @@ -2746,7 +2777,8 @@ def approve_request(_request: MCPToolApprovalRequest) -> MCPToolApprovalFunction assert callback_calls == 0 assert len(result.interruptions) == 1 - assert result.interruptions[0].raw_item is request + assert result.interruptions[0].raw_item == request + assert result.interruptions[0].raw_item is not request @pytest.mark.parametrize("always_approve", [False, True], ids=["per-call", "sticky"]) @@ -2828,10 +2860,8 @@ def approve_request(_request: MCPToolApprovalRequest) -> MCPToolApprovalFunction server_label="test_server", arguments='{"limit":1,"query":"safe"}', ) - model = FakeModel() - model.add_multiple_turn_outputs( - [[first_request], [replayed_request], [get_text_message("done")]] - ) + model = ScriptedModel() + model.extend([[first_request], [replayed_request], [get_text_message("done")]]) agent = Agent(name="mcp-approval-agent", model=model, tools=[mcp_tool]) first = await Runner.run(agent, "lookup") @@ -2845,7 +2875,7 @@ def approve_request(_request: MCPToolApprovalRequest) -> MCPToolApprovalFunction assert result.final_output == "done" assert callback_calls == int(with_callback) - model_input = model.last_turn_args["input"] + model_input = model.calls[-1].input assert isinstance(model_input, list) replay_items = [ item diff --git a/tests/test_tool_choice_reset.py b/tests/test_tool_choice_reset.py index ea3113e59a..07b067b859 100644 --- a/tests/test_tool_choice_reset.py +++ b/tests/test_tool_choice_reset.py @@ -2,8 +2,8 @@ from agents import Agent, ModelSettings, Runner from agents.run_internal.run_loop import AgentToolUseTracker, maybe_reset_tool_choice +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_function_tool, get_function_tool_call, get_text_message @@ -75,9 +75,9 @@ async def test_required_tool_choice_with_multiple_runs(self): run works correctly and doesn't get stuck in an infinite loop. Also verify that tool_choice remains "required" between runs. """ - # Set up our fake model with responses for two runs - fake_model = FakeModel() - fake_model.add_multiple_turn_outputs( + # Set up the scripted model with responses for two runs. + scripted_model = ScriptedModel() + scripted_model.extend( [[get_text_message("First run response")], [get_text_message("Second run response")]] ) @@ -85,7 +85,7 @@ async def test_required_tool_choice_with_multiple_runs(self): custom_tool = get_function_tool("custom_tool") agent = Agent( name="test_agent", - model=fake_model, + model=scripted_model, tools=[custom_tool], model_settings=ModelSettings(tool_choice="required"), ) @@ -93,14 +93,14 @@ async def test_required_tool_choice_with_multiple_runs(self): # First run should work correctly and preserve tool_choice result1 = await Runner.run(agent, "first run") assert result1.final_output == "First run response" - assert fake_model.last_turn_args["model_settings"].tool_choice == "required", ( + assert scripted_model.calls[-1].model_settings.tool_choice == "required", ( "tool_choice should stay required" ) # Second run should also work correctly with tool_choice still required result2 = await Runner.run(agent, "second run") assert result2.final_output == "Second run response" - assert fake_model.last_turn_args["model_settings"].tool_choice == "required", ( + assert scripted_model.calls[-1].model_settings.tool_choice == "required", ( "tool_choice should stay required" ) @@ -110,9 +110,9 @@ async def test_required_with_stop_at_tool_name(self): Test scenario 2: When using required tool_choice with stop_at_tool_names behavior, ensure it correctly stops at the specified tool """ - # Set up fake model to return a tool call for second_tool - fake_model = FakeModel() - fake_model.set_next_output([get_function_tool_call("second_tool", "{}")]) + # Set up the scripted model to return a tool call for second_tool. + scripted_model = ScriptedModel() + scripted_model.enqueue([get_function_tool_call("second_tool", "{}")]) # Create agent with two tools and tool_choice="required" and stop_at_tool behavior first_tool = get_function_tool("first_tool", return_value="first tool result") @@ -120,7 +120,7 @@ async def test_required_with_stop_at_tool_name(self): agent = Agent( name="test_agent", - model=fake_model, + model=scripted_model, tools=[first_tool, second_tool], model_settings=ModelSettings(tool_choice="required"), tool_use_behavior={"stop_at_tool_names": ["second_tool"]}, @@ -136,9 +136,9 @@ async def test_specific_tool_choice(self): Test scenario 3: When using a specific tool choice name, ensure it doesn't cause infinite loops. """ - # Set up fake model to return a text message - fake_model = FakeModel() - fake_model.set_next_output([get_text_message("Test message")]) + # Set up the scripted model to return a text message. + scripted_model = ScriptedModel() + scripted_model.enqueue([get_text_message("Test message")]) # Create agent with specific tool_choice tool1 = get_function_tool("tool1") @@ -147,7 +147,7 @@ async def test_specific_tool_choice(self): agent = Agent( name="test_agent", - model=fake_model, + model=scripted_model, tools=[tool1, tool2, tool3], model_settings=ModelSettings(tool_choice="tool1"), # Specific tool ) @@ -162,9 +162,9 @@ async def test_required_with_single_tool(self): Test scenario 4: When using required tool_choice with only one tool, ensure it doesn't cause infinite loops. """ - # Set up fake model to return a tool call followed by a text message - fake_model = FakeModel() - fake_model.add_multiple_turn_outputs( + # Set up the scripted model to return a tool call followed by a text message. + scripted_model = ScriptedModel() + scripted_model.extend( [ # First call returns a tool call [get_function_tool_call("custom_tool", "{}")], @@ -177,7 +177,7 @@ async def test_required_with_single_tool(self): custom_tool = get_function_tool("custom_tool", return_value="tool result") agent = Agent( name="test_agent", - model=fake_model, + model=scripted_model, tools=[custom_tool], model_settings=ModelSettings(tool_choice="required"), ) @@ -191,9 +191,9 @@ async def test_dont_reset_tool_choice_if_not_required(self): """ Test scenario 5: When agent.reset_tool_choice is False, ensure tool_choice is not reset. """ - # Set up fake model to return a tool call followed by a text message - fake_model = FakeModel() - fake_model.add_multiple_turn_outputs( + # Set up the scripted model to return a tool call followed by a text message. + scripted_model = ScriptedModel() + scripted_model.extend( [ # First call returns a tool call [get_function_tool_call("custom_tool", "{}")], @@ -206,7 +206,7 @@ async def test_dont_reset_tool_choice_if_not_required(self): custom_tool = get_function_tool("custom_tool", return_value="tool result") agent = Agent( name="test_agent", - model=fake_model, + model=scripted_model, tools=[custom_tool], model_settings=ModelSettings(tool_choice="required"), reset_tool_choice=False, @@ -214,4 +214,4 @@ async def test_dont_reset_tool_choice_if_not_required(self): await Runner.run(agent, "test") - assert fake_model.last_turn_args["model_settings"].tool_choice == "required" + assert scripted_model.calls[-1].model_settings.tool_choice == "required" diff --git a/tests/test_tool_custom_data.py b/tests/test_tool_custom_data.py index 14939cf47a..584e5daa7e 100644 --- a/tests/test_tool_custom_data.py +++ b/tests/test_tool_custom_data.py @@ -34,9 +34,9 @@ ComputerAction, CustomToolAction, ) +from agents.testing import ScriptedModel from agents.tool_context import ToolContext -from .fake_model import FakeModel from .mcp.helpers import FakeMCPServer from .test_apply_patch_tool import DummyApplyPatchCall from .test_responses import get_function_tool_call, get_text_message @@ -56,8 +56,8 @@ def extract_custom_data(ctx: Any) -> dict[str, Any]: def get_data() -> str: return "tool_result" - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("call tool"), get_function_tool_call("get_data", "{}")], [get_text_message("done")], @@ -75,8 +75,7 @@ def get_data() -> str: assert "renderer" not in replay_payload assert "renderer" not in cast(dict[str, Any], tool_output.raw_item) assert all( - not (isinstance(item, dict) and "custom_data" in item) - for item in model.last_turn_args["input"] + not (isinstance(item, dict) and "custom_data" in item) for item in model.calls[-1].input ) @@ -86,10 +85,8 @@ async def test_function_tool_custom_data_rejects_non_json_compatible_data() -> N def get_data() -> str: return "tool_result" - model = FakeModel() - model.add_multiple_turn_outputs( - [[get_text_message("call tool"), get_function_tool_call("get_data", "{}")]] - ) + model = ScriptedModel() + model.extend([[get_text_message("call tool"), get_function_tool_call("get_data", "{}")]]) agent = Agent(name="test", model=model, tools=[get_data]) with pytest.raises(UserError, match="custom_data_extractor must return JSON-compatible data"): @@ -105,10 +102,8 @@ async def test_function_tool_custom_data_rejects_non_finite_floats( def get_data() -> str: return "tool_result" - model = FakeModel() - model.add_multiple_turn_outputs( - [[get_text_message("call tool"), get_function_tool_call("get_data", "{}")]] - ) + model = ScriptedModel() + model.extend([[get_text_message("call tool"), get_function_tool_call("get_data", "{}")]]) agent = Agent(name="test", model=model, tools=[get_data]) with pytest.raises(UserError, match="custom_data_extractor must return JSON-compatible data"): @@ -124,8 +119,8 @@ def extract_custom_data(ctx: Any) -> dict[str, Any]: server.add_tool("meta_tool", {}) server._response_meta = {"chart": {"type": "line"}} - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_text_message("call tool"), get_function_tool_call("meta_tool", "{}")], [get_text_message("done")], diff --git a/tests/test_tool_guardrails.py b/tests/test_tool_guardrails.py index 6819ba885d..09bd86736b 100644 --- a/tests/test_tool_guardrails.py +++ b/tests/test_tool_guardrails.py @@ -19,10 +19,10 @@ UserError, function_tool, ) +from agents.testing import ScriptedModel from agents.tool_context import ToolContext from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail -from .fake_model import FakeModel from .test_responses import get_function_tool_call @@ -538,8 +538,8 @@ def guarded(query: str) -> str: guarded.tool_input_guardrails = input_guardrails or [] guarded.tool_output_guardrails = output_guardrails or [] - model = FakeModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ [get_function_tool_call("guarded", '{"query": "secret"}', call_id="guarded_1")], [get_function_tool_call("guarded", '{"query": "secret"}', call_id="guarded_2")], diff --git a/tests/test_tool_name_collision_policy.py b/tests/test_tool_name_collision_policy.py index 3eae9ba5f8..2d01423034 100644 --- a/tests/test_tool_name_collision_policy.py +++ b/tests/test_tool_name_collision_policy.py @@ -25,9 +25,9 @@ ) from agents.items import ToolCallOutputItem from agents.lifecycle import RunHooks +from agents.testing import ScriptedModel from agents.tool import Tool, function_tool -from .fake_model import FakeModel from .mcp.helpers import FakeMCPServer from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message @@ -48,7 +48,7 @@ def local_lookup() -> str: return "local" local_tool = function_tool(local_lookup, name_override="lookup") - model = FakeModel(initial_output=[get_function_tool_call("lookup", "{}")]) + model = ScriptedModel(steps=[[get_function_tool_call("lookup", "{}")]]) agent = Agent(name="agent", model=model, mcp_servers=[server]) initial_result = await Runner.run(agent, "Look this up") @@ -56,7 +56,7 @@ def local_lookup() -> str: interruption = state.get_interruptions()[0] state.approve(interruption) agent.tools = [local_tool] - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) resumed_result = await Runner.run(agent, state) @@ -77,9 +77,7 @@ async def test_resume_error_mode_rejects_current_collision_before_side_effects() lambda: _record(calls, "colliding"), name_override="lookup", ) - model = FakeModel( - initial_output=[get_function_tool_call("lookup", "{}", call_id="lookup_call")] - ) + model = ScriptedModel(steps=[[get_function_tool_call("lookup", "{}", call_id="lookup_call")]]) agent = Agent(name="agent", model=model, tools=[queued_tool]) initial_result = await Runner.run(agent, "Look this up") @@ -124,7 +122,7 @@ def route_function() -> str: ) target = Agent( name="target", - model=FakeModel(initial_output=[get_text_message("target done")]), + model=ScriptedModel(steps=[[get_text_message("target done")]]), ) route_handoff = handoff( target, @@ -132,7 +130,7 @@ def route_function() -> str: on_handoff=lambda _: calls.append("handoff"), input_filter=FalsyFilter(), ) - model = FakeModel(initial_output=[get_function_tool_call("route", "{}", call_id="route_call")]) + model = ScriptedModel(steps=[[get_function_tool_call("route", "{}", call_id="route_call")]]) agent = Agent(name="agent", model=model, tools=[route_tool]) initial_result = await Runner.run(agent, "Route this request") @@ -163,14 +161,14 @@ async def test_reclassified_handoff_is_rejected_before_run_hook() -> None: ) target = Agent( name="target", - model=FakeModel(initial_output=[get_text_message("target done")]), + model=ScriptedModel(steps=[[get_text_message("target done")]]), ) route_handoff = handoff( target, tool_name_override="route", on_handoff=lambda _: calls.append("handoff"), ) - model = FakeModel(initial_output=[get_function_tool_call("route", "{}", call_id="route")]) + model = ScriptedModel(steps=[[get_function_tool_call("route", "{}", call_id="route")]]) agent = Agent(name="agent", model=model, tools=[route_tool]) first = await Runner.run(agent, "Route this request") @@ -218,13 +216,15 @@ def route_function() -> str: route_tool = function_tool(route_function, name_override="route") target = Agent(name="target") route_handoff = handoff(target, tool_name_override="route") - model = FakeModel( - initial_output=[ - get_function_tool_call("approval_tool", "{}", call_id="approval_call"), - get_handoff_tool_call(target, override_name="route", args="{}"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(target, override_name="route", args="{}"), + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent( name="agent", model=model, @@ -256,11 +256,11 @@ async def test_resume_rebinds_queued_handoff_to_current_warn_winner() -> None: ) first_target = Agent( name="first", - model=FakeModel(initial_output=[get_text_message("first done")]), + model=ScriptedModel(steps=[[get_text_message("first done")]]), ) second_target = Agent( name="second", - model=FakeModel(initial_output=[get_text_message("second done")]), + model=ScriptedModel(steps=[[get_text_message("second done")]]), ) first_handoff = handoff( first_target, @@ -272,10 +272,12 @@ async def test_resume_rebinds_queued_handoff_to_current_warn_winner() -> None: tool_name_override="route", on_handoff=lambda _: calls.append("second"), ) - model = FakeModel( - initial_output=[ - get_function_tool_call("approval_tool", "{}", call_id="approval_call"), - get_handoff_tool_call(second_target, override_name="route", args="{}"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(second_target, override_name="route", args="{}"), + ] ] ) agent = Agent( @@ -310,10 +312,12 @@ async def test_resume_rejects_missing_queued_handoff_before_side_effects() -> No tool_name_override="route", on_handoff=lambda _: calls.append("handoff"), ) - model = FakeModel( - initial_output=[ - get_function_tool_call("approval_tool", "{}", call_id="approval_call"), - get_handoff_tool_call(target, override_name="route", args="{}"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(target, override_name="route", args="{}"), + ] ] ) agent = Agent( @@ -346,13 +350,15 @@ async def test_missing_interrupted_agent_tool_preserves_nested_state_for_retry() name_override="sensitive", needs_approval=True, ) - inner_model = FakeModel( - initial_output=[ - get_function_tool_call("before_pause", "{}", call_id="before_call"), - get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + inner_model = ScriptedModel( + steps=[ + [ + get_function_tool_call("before_pause", "{}", call_id="before_call"), + get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + ] ] ) - inner_model.set_next_output([get_text_message("inner done")]) + inner_model.enqueue([get_text_message("inner done")]) inner_agent = Agent( name="inner", model=inner_model, @@ -362,16 +368,18 @@ async def test_missing_interrupted_agent_tool_preserves_nested_state_for_retry() tool_name="lookup", tool_description="Look up a value with the inner agent.", ) - outer_model = FakeModel( - initial_output=[ - get_function_tool_call( - "lookup", - '{"input":"hi"}', - call_id="outer_call", - ) + outer_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "lookup", + '{"input":"hi"}', + call_id="outer_call", + ) + ] ] ) - outer_model.set_next_output([get_text_message("outer done")]) + outer_model.enqueue([get_text_message("outer done")]) outer_agent = Agent(name="outer", model=outer_model, tools=[nested_tool]) initial_result = await Runner.run(outer_agent, "Look this up") @@ -408,21 +416,23 @@ async def test_missing_formatter_cancellation_keeps_nested_state_serializable() name_override="serial_sensitive", needs_approval=True, ) - inner_model = FakeModel( - initial_output=[ - get_function_tool_call( - "serial_before_pause", - "{}", - call_id="serial_before_call", - ), - get_function_tool_call( - "serial_sensitive", - "{}", - call_id="serial_sensitive_call", - ), + inner_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "serial_before_pause", + "{}", + call_id="serial_before_call", + ), + get_function_tool_call( + "serial_sensitive", + "{}", + call_id="serial_sensitive_call", + ), + ] ] ) - inner_model.set_next_output([get_text_message("inner done")]) + inner_model.enqueue([get_text_message("inner done")]) inner_agent = Agent( name="inner", model=inner_model, @@ -432,16 +442,18 @@ async def test_missing_formatter_cancellation_keeps_nested_state_serializable() tool_name="serial_lookup", tool_description="Look up a value with the inner agent.", ) - outer_model = FakeModel( - initial_output=[ - get_function_tool_call( - "serial_lookup", - '{"input":"hi"}', - call_id="serial_outer_call", - ) + outer_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "serial_lookup", + '{"input":"hi"}', + call_id="serial_outer_call", + ) + ] ] ) - outer_model.set_next_output([get_text_message("outer done")]) + outer_model.enqueue([get_text_message("outer done")]) outer_agent = Agent(name="outer", model=outer_model, tools=[nested_tool]) initial_result = await Runner.run(outer_agent, "Look this up") @@ -488,8 +500,8 @@ async def test_replacing_interrupted_agent_tool_fails_before_side_effects() -> N ) inner_agent = Agent( name="inner", - model=FakeModel( - initial_output=[get_function_tool_call("sensitive", "{}", call_id="call_sensitive")] + model=ScriptedModel( + steps=[[get_function_tool_call("sensitive", "{}", call_id="call_sensitive")]] ), tools=[sensitive_tool], ) @@ -499,13 +511,15 @@ async def test_replacing_interrupted_agent_tool_fails_before_side_effects() -> N ) outer_agent = Agent( name="outer", - model=FakeModel( - initial_output=[ - get_function_tool_call( - "lookup", - '{"input":"hi"}', - call_id="call_lookup", - ) + model=ScriptedModel( + steps=[ + [ + get_function_tool_call( + "lookup", + '{"input":"hi"}', + call_id="call_lookup", + ) + ] ] ), tools=[nested_tool], @@ -548,11 +562,13 @@ async def test_resume_preserves_model_order_for_function_outcomes() -> None: name_override="available_lookup", needs_approval=True, ) - model = FakeModel( - initial_output=[ - get_function_tool_call("missing_lookup", "{}", call_id="missing_call"), - get_function_tool_call("rejected_lookup", "{}", call_id="rejected_call"), - get_function_tool_call("available_lookup", "{}", call_id="available_call"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("missing_lookup", "{}", call_id="missing_call"), + get_function_tool_call("rejected_lookup", "{}", call_id="rejected_call"), + get_function_tool_call("available_lookup", "{}", call_id="available_call"), + ] ] ) agent = Agent( @@ -569,7 +585,7 @@ async def test_resume_preserves_model_order_for_function_outcomes() -> None: else: state.approve(interruption) agent.tools = [rejected_tool, available_tool] - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) resumed_result = await Runner.run( agent, @@ -598,8 +614,8 @@ async def inner_hitl_tool() -> str: inner_calls.append("inner") return "ok" - inner_model = FakeModel() - inner_model.add_multiple_turn_outputs( + inner_model = ScriptedModel() + inner_model.extend( [ [get_function_tool_call(inner_hitl_tool.name, "{}", call_id="inner-1")], [get_function_tool_call(inner_hitl_tool.name, "{}", call_id="inner-2")], @@ -613,18 +629,20 @@ async def inner_hitl_tool() -> str: tool_description="Run the inner agent.", needs_approval=False, ) - outer_model = FakeModel( - initial_output=[ - get_function_tool_call( - agent_tool.name, - '{"input":"a"}', - call_id="outer-a", - ), - get_function_tool_call( - agent_tool.name, - '{"input":"b"}', - call_id="outer-b", - ), + outer_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + agent_tool.name, + '{"input":"a"}', + call_id="outer-a", + ), + get_function_tool_call( + agent_tool.name, + '{"input":"b"}', + call_id="outer-b", + ), + ] ] ) outer_agent = Agent(name="outer", model=outer_model, tools=[agent_tool]) @@ -632,7 +650,7 @@ async def inner_hitl_tool() -> str: state = initial_result.to_state() for interruption in state.get_interruptions(): state.approve(interruption) - outer_model.set_next_output([get_text_message("done")]) + outer_model.enqueue([get_text_message("done")]) resumed_result = await Runner.run(outer_agent, state) @@ -677,7 +695,7 @@ def handoff_enabled( ) target = Agent( name="target", - model=FakeModel(initial_output=[get_text_message("done")]), + model=ScriptedModel(steps=[[get_text_message("done")]]), ) route_handoff = handoff( target, @@ -700,10 +718,12 @@ async def get_all_tools( ) -> list[Tool]: return await super().get_all_tools(run_context) - model = FakeModel( - initial_output=[ - get_function_tool_call("approval_tool", "{}", call_id="approval_call"), - get_handoff_tool_call(target, override_name="route", args="{}"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(target, override_name="route", args="{}"), + ] ] ) agent_class = DelegatingAllToolsAgent if override_all_tools else DelegatingMCPAgent @@ -741,10 +761,12 @@ async def test_resume_rejects_conflicting_persisted_identity_before_sibling_effe name_override="sibling", needs_approval=True, ) - model = FakeModel( - initial_output=[ - get_function_tool_call("lookup", "{}", call_id="conflicting_call"), - get_function_tool_call("sibling", "{}", call_id="sibling_call"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("lookup", "{}", call_id="conflicting_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] ] ) agent = Agent(name="agent", model=model, tools=[conflicting_tool, sibling_tool]) @@ -781,8 +803,8 @@ async def test_resume_rejects_legacy_approval_name_change_before_side_effects() lambda: _record(calls, "new"), name_override="new_lookup", ) - model = FakeModel( - initial_output=[get_function_tool_call("old_lookup", "{}", call_id="lookup_call")] + model = ScriptedModel( + steps=[[get_function_tool_call("old_lookup", "{}", call_id="lookup_call")]] ) agent = Agent(name="agent", model=model, tools=[old_tool]) @@ -823,17 +845,19 @@ async def test_resume_treats_apply_patch_prefixed_queued_function_as_function( tools.extend(tool_namespace(name=namespace, description="Lookup tools", tools=[base_tool])) else: tools.append(base_tool) - model = FakeModel( - initial_output=[ - get_function_tool_call( - "apply_patch_lookup", - "{}", - call_id="lookup_call", - namespace=namespace, - ) + model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "apply_patch_lookup", + "{}", + call_id="lookup_call", + namespace=namespace, + ) + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=tools) initial_result = await Runner.run(agent, "Look this up") @@ -885,9 +909,7 @@ async def test_approved_malformed_approval_only_stays_pending_without_side_effec name_override="lookup", needs_approval=True, ) - model = FakeModel( - initial_output=[get_function_tool_call("lookup", "{}", call_id="lookup_call")] - ) + model = ScriptedModel(steps=[[get_function_tool_call("lookup", "{}", call_id="lookup_call")]]) agent = Agent(name="agent", model=model, tools=[tool]) initial_result = await Runner.run(agent, "Look this up") @@ -936,10 +958,8 @@ async def get_all_tools( approval.raw_item.name = "other" return await super().get_all_tools(run_context) - model = FakeModel( - initial_output=[get_function_tool_call("lookup", "{}", call_id="lookup_call")] - ) - model.set_next_output([get_text_message("done")]) + model = ScriptedModel(steps=[[get_function_tool_call("lookup", "{}", call_id="lookup_call")]]) + model.enqueue([get_text_message("done")]) agent = MutatingAgent(name="agent", model=model, tools=[lookup_tool, other_tool]) initial_result = await Runner.run(agent, "Look this up") @@ -967,16 +987,18 @@ def lookup(amount: int) -> str: name_override="lookup", needs_approval=True, ) - model = FakeModel( - initial_output=[ - get_function_tool_call( - "lookup", - '{"amount":10}', - call_id="lookup_call", - ) + model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "lookup", + '{"amount":10}', + call_id="lookup_call", + ) + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[lookup_tool]) initial_result = await Runner.run(agent, "Look this up") @@ -1025,8 +1047,8 @@ async def get_all_tools( cast(Any, approval.raw_item).caller.caller_id = "mutated_program" return await super().get_all_tools(run_context) - model = FakeModel(initial_output=[program, function_call]) - model.set_next_output([get_text_message("done")]) + model = ScriptedModel(steps=[[program, function_call]]) + model.enqueue([get_text_message("done")]) agent = MutatingCallerAgent( name="agent", model=model, @@ -1083,7 +1105,7 @@ async def get_all_tools( program.call_id = "forged_program" return await super().get_all_tools(run_context) - model = FakeModel(initial_output=[program, function_call]) + model = ScriptedModel(steps=[[program, function_call]]) agent = MutatingParentAgent( name="agent", model=model, @@ -1122,10 +1144,12 @@ async def test_resume_rejects_response_backed_approval_lookup_mismatch_before_ef name_override="sibling", needs_approval=True, ) - model = FakeModel( - initial_output=[ - get_function_tool_call("lookup", "{}", call_id="lookup_call"), - get_function_tool_call("sibling", "{}", call_id="sibling_call"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] ] ) agent = Agent(name="agent", model=model, tools=[lookup_tool, sibling_tool]) @@ -1170,16 +1194,18 @@ def delete_file(self, operation: Any) -> dict[str, str]: return {"output": "deleted", "status": "completed"} patch_tool = ApplyPatchTool(editor=cast(Any, Editor()), needs_approval=True) - model = FakeModel( - initial_output=[ - get_function_tool_call( - "apply_patch", - '{"type":"update_file","path":"test.md","diff":"-a\\n+b\\n"}', - call_id="patch_call", - ) + model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "apply_patch", + '{"type":"update_file","path":"test.md","diff":"-a\\n+b\\n"}', + call_id="patch_call", + ) + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[patch_tool]) initial_result = await Runner.run(agent, "Update the file") @@ -1204,13 +1230,15 @@ async def test_nested_rebind_is_not_committed_before_later_strict_missing_error( name_override="sensitive", needs_approval=True, ) - inner_model = FakeModel( - initial_output=[ - get_function_tool_call("before", "{}", call_id="before_call"), - get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + inner_model = ScriptedModel( + steps=[ + [ + get_function_tool_call("before", "{}", call_id="before_call"), + get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + ] ] ) - inner_model.set_next_output([get_text_message("inner done")]) + inner_model.enqueue([get_text_message("inner done")]) inner_agent = Agent( name="inner", model=inner_model, @@ -1225,13 +1253,15 @@ async def test_nested_rebind_is_not_committed_before_later_strict_missing_error( name_override="missing", needs_approval=True, ) - outer_model = FakeModel( - initial_output=[ - get_function_tool_call("nested", '{"input":"go"}', call_id="nested_call"), - get_function_tool_call("missing", "{}", call_id="missing_call"), + outer_model = ScriptedModel( + steps=[ + [ + get_function_tool_call("nested", '{"input":"go"}', call_id="nested_call"), + get_function_tool_call("missing", "{}", call_id="missing_call"), + ] ] ) - outer_model.set_next_output([get_text_message("outer done")]) + outer_model.enqueue([get_text_message("outer done")]) outer_agent = Agent( name="outer", model=outer_model, @@ -1287,11 +1317,13 @@ async def test_cross_kind_duplicate_call_id_fails_before_execution() -> None: }, }, ) - model = FakeModel( - initial_output=[ - get_function_tool_call("missing", "{}", call_id="missing_call"), - get_function_tool_call("lookup", "{}", call_id="shared_call"), - shell_call, + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("missing", "{}", call_id="missing_call"), + get_function_tool_call("lookup", "{}", call_id="shared_call"), + shell_call, + ] ] ) agent = Agent( @@ -1333,10 +1365,12 @@ async def test_approved_malformed_queued_approval_stays_pending_without_side_eff name_override="sibling", needs_approval=True, ) - model = FakeModel( - initial_output=[ - get_function_tool_call("lookup", "{}", call_id="lookup_call"), - get_function_tool_call("sibling", "{}", call_id="sibling_call"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] ] ) agent = Agent(name="agent", model=model, tools=[lookup_tool, sibling_tool]) @@ -1368,10 +1402,12 @@ async def test_resume_rejects_cross_kind_approval_identity_before_sibling_effect name_override="sibling", needs_approval=True, ) - model = FakeModel( - initial_output=[ - get_function_tool_call("lookup", "{}", call_id="lookup_call"), - get_function_tool_call("sibling", "{}", call_id="sibling_call"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("lookup", "{}", call_id="lookup_call"), + get_function_tool_call("sibling", "{}", call_id="sibling_call"), + ] ] ) agent = Agent(name="agent", model=model, tools=[lookup_tool, sibling_tool]) @@ -1413,13 +1449,15 @@ async def test_missing_formatter_cancellation_precedes_sibling_side_effects( name_override="available", needs_approval=True, ) - model = FakeModel( - initial_output=[ - get_function_tool_call("missing", "{}", call_id="missing_call"), - get_function_tool_call("available", "{}", call_id="available_call"), + model = ScriptedModel( + steps=[ + [ + get_function_tool_call("missing", "{}", call_id="missing_call"), + get_function_tool_call("available", "{}", call_id="available_call"), + ] ] ) - model.set_next_output([get_text_message("done")]) + model.enqueue([get_text_message("done")]) agent = Agent(name="agent", model=model, tools=[missing_tool, available_tool]) initial_result = await Runner.run(agent, "Look these up") diff --git a/tests/test_tool_origin.py b/tests/test_tool_origin.py index 6343427987..bf9251c710 100644 --- a/tests/test_tool_origin.py +++ b/tests/test_tool_origin.py @@ -32,9 +32,10 @@ from agents.run_internal.agent_bindings import bind_public_agent from agents.run_internal.run_loop import get_output_schema from agents.run_internal.tool_execution import execute_function_tool_calls -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.mcp.helpers import FakeMCPServer from tests.mcp.model_compat import Tool as MCPTool +from tests.model_test_helpers import get_exact_output_stream_step from tests.test_responses import get_function_tool_call, get_text_message from tests.utils.factories import make_run_state, make_tool_call, roundtrip_state @@ -70,14 +71,14 @@ def _make_hosted_mcp_list_tools(server_label: str, tool_name: str) -> McpListToo @pytest.mark.asyncio async def test_runner_attaches_function_tool_origin_to_call_and_output_items() -> None: - model = FakeModel() + model = ScriptedModel() @function_tool(name_override="lookup_account") def lookup_account() -> str: return "account" agent = Agent(name="tool-origin-agent", model=model, tools=[lookup_account]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("lookup_account", json.dumps({}), call_id="call_lookup")], [get_text_message("done")], @@ -93,14 +94,14 @@ def lookup_account() -> str: @pytest.mark.asyncio async def test_rejected_function_tool_output_preserves_tool_origin() -> None: - model = FakeModel() + model = ScriptedModel() @function_tool(name_override="approval_tool", needs_approval=True) def approval_tool() -> str: raise AssertionError("The tool should not run when rejected.") agent = Agent(name="approval-agent", model=model, tools=[approval_tool]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")], [get_text_message("done")], @@ -138,7 +139,7 @@ def test_tool_call_output_item_preserves_positional_type_argument() -> None: @pytest.mark.asyncio async def test_runner_attaches_local_mcp_tool_origin_to_call_and_output_items() -> None: - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer( server_name="docs_server", tools=[ @@ -151,7 +152,7 @@ async def test_runner_attaches_local_mcp_tool_origin_to_call_and_output_items() ], ) agent = Agent(name="mcp-agent", model=model, mcp_servers=[server]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("search_docs", json.dumps({}), call_id="call_search_docs")], [get_text_message("done")], @@ -172,13 +173,13 @@ async def test_local_mcp_tool_origin_hides_url_credentials_in_run_state() -> Non "?api_key=SECRET_QS_KEY#SECRET_FRAGMENT" ) safe_server_name = "streamable_http: https://mcp.example.test:8443/mcp" - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer( server_name=raw_server_name, tools=[MCPTool(name="search_docs", inputSchema={})], ) agent = Agent(name="mcp-agent", model=model, mcp_servers=[server]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("search_docs", json.dumps({}), call_id="call_search_docs")], [get_text_message("done")], @@ -199,7 +200,7 @@ async def test_local_mcp_tool_origin_hides_url_credentials_in_run_state() -> Non @pytest.mark.asyncio async def test_streamed_tool_call_item_includes_local_mcp_origin() -> None: - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer( server_name="docs_server", tools=[ @@ -212,7 +213,7 @@ async def test_streamed_tool_call_item_includes_local_mcp_origin() -> None: ], ) agent = Agent(name="stream-mcp-agent", model=model, mcp_servers=[server]) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("search_docs", json.dumps({}), call_id="call_stream_search")], [get_text_message("done")], @@ -287,7 +288,7 @@ def test_process_model_response_attaches_hosted_mcp_tool_origin() -> None: @pytest.mark.asyncio async def test_streamed_tool_call_item_includes_hosted_mcp_origin() -> None: - model = FakeModel() + model = ScriptedModel() hosted_tool = HostedMCPTool( tool_config=cast( Any, @@ -299,19 +300,21 @@ async def test_streamed_tool_call_item_includes_hosted_mcp_origin() -> None: ) ) agent = Agent(name="stream-hosted-mcp", model=model, tools=[hosted_tool]) - model.add_multiple_turn_outputs( + model.extend( [ - [ - _make_hosted_mcp_list_tools("docs_server", "search_docs"), - McpCall( - id="mcp_call_stream_1", - arguments="{}", - name="search_docs", - server_label="docs_server", - type="mcp_call", - status="completed", - ), - ], + get_exact_output_stream_step( + [ + _make_hosted_mcp_list_tools("docs_server", "search_docs"), + McpCall( + id="mcp_call_stream_1", + arguments="{}", + name="search_docs", + server_label="docs_server", + type="mcp_call", + status="completed", + ), + ] + ), [get_text_message("done")], ] ) diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index b37622ef90..2e7a985bff 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -22,8 +22,8 @@ _debug, ) from agents.run_internal.error_handlers import attach_generic_agent_error +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import ( get_final_output_message, get_function_tool, @@ -36,8 +36,8 @@ @pytest.mark.asyncio async def test_single_turn_model_error(): - model = FakeModel(tracing_enabled=True) - model.set_next_output(ValueError("test error")) + model = ScriptedModel(emit_traces=True) + model.enqueue(ValueError("test error")) agent = Agent( name="test_agent", @@ -78,7 +78,7 @@ async def test_single_turn_model_error(): @pytest.mark.asyncio async def test_multi_turn_no_handoffs(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent = Agent( name="test_agent", @@ -86,7 +86,7 @@ async def test_multi_turn_no_handoffs(): tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], @@ -145,7 +145,7 @@ async def test_tool_call_error(monkeypatch: pytest.MonkeyPatch): # which depends on inspecting the chained JSONDecodeError, is preserved. monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent = Agent( name="test_agent", @@ -153,7 +153,7 @@ async def test_tool_call_error(monkeypatch: pytest.MonkeyPatch): tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("a_message"), get_function_tool_call("foo", "bad_json")], [get_text_message("done")], @@ -212,7 +212,7 @@ async def test_tool_call_error(monkeypatch: pytest.MonkeyPatch): @pytest.mark.asyncio async def test_multiple_handoff_doesnt_error(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent_1 = Agent( name="test", @@ -229,7 +229,7 @@ async def test_multiple_handoff_doesnt_error(): tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -302,7 +302,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_multiple_final_output_doesnt_error(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent_1 = Agent( name="test", @@ -310,7 +310,7 @@ async def test_multiple_final_output_doesnt_error(): output_type=Foo, ) - model.set_next_output( + model.enqueue( [ get_final_output_message(json.dumps(Foo(bar="baz"))), get_final_output_message(json.dumps(Foo(bar="abc"))), @@ -338,7 +338,7 @@ async def test_multiple_final_output_doesnt_error(): @pytest.mark.asyncio async def test_handoffs_lead_to_correct_agent_spans(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent_1 = Agent( name="test_agent_1", @@ -360,7 +360,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): agent_1.handoffs.append(agent_3) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -466,7 +466,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): @pytest.mark.asyncio async def test_max_turns_exceeded(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent = Agent( name="test", @@ -475,7 +475,7 @@ async def test_max_turns_exceeded(): tools=[get_function_tool("foo", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("foo", call_id="tool_1")], [get_function_tool_call("foo", call_id="tool_2")], @@ -535,8 +535,8 @@ async def test_guardrail_error(): agent = Agent( name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)] ) - model = FakeModel() - model.set_next_output([get_text_message("some_message")]) + model = ScriptedModel() + model.enqueue([get_text_message("some_message")]) with pytest.raises(InputGuardrailTripwireTriggered): await Runner.run(agent, input="user_message") @@ -570,8 +570,8 @@ async def test_guardrail_error(): def test_run_sync_marks_agent_span_with_generic_error(): - model = FakeModel(tracing_enabled=True) - model.set_next_output(ValueError("test error")) + model = ScriptedModel(emit_traces=True) + model.enqueue(ValueError("test error")) with pytest.raises(ValueError, match="test error"): Runner.run_sync(Agent(name="test_agent", model=model), input="first_test") @@ -584,16 +584,16 @@ def test_run_sync_marks_agent_span_with_generic_error(): @pytest.mark.asyncio async def test_run_agent_span_error_matches_streamed_path(): """The non-streamed and streamed paths record the same agent span error.""" - non_streamed_model = FakeModel(tracing_enabled=True) - non_streamed_model.set_next_output(ValueError("test error")) + non_streamed_model = ScriptedModel(emit_traces=True) + non_streamed_model.enqueue(ValueError("test error")) with pytest.raises(ValueError): await Runner.run(Agent(name="test_agent", model=non_streamed_model), input="first_test") non_streamed_errors = fetch_span_errors("agent") SPAN_PROCESSOR_TESTING.clear() - streamed_model = FakeModel(tracing_enabled=True) - streamed_model.set_next_output(ValueError("test error")) + streamed_model = ScriptedModel(emit_traces=True) + streamed_model.enqueue(ValueError("test error")) result = Runner.run_streamed(Agent(name="test_agent", model=streamed_model), input="first_test") with pytest.raises(ValueError): async for _ in result.stream_events(): @@ -604,8 +604,8 @@ async def test_run_agent_span_error_matches_streamed_path(): @pytest.mark.asyncio async def test_run_agent_span_error_redacts_sensitive_data(): - model = FakeModel(tracing_enabled=False) - model.set_next_output(ValueError(SENSITIVE_ERROR_MESSAGE)) + model = ScriptedModel(emit_traces=False) + model.enqueue(ValueError(SENSITIVE_ERROR_MESSAGE)) with pytest.raises(ValueError): await Runner.run( @@ -625,8 +625,8 @@ async def test_run_agent_span_error_redacts_sensitive_data(): @pytest.mark.asyncio async def test_run_does_not_mark_agent_span_for_model_behavior_error(): """ModelBehaviorError is reported by the generation span, so the agent span stays clean.""" - model = FakeModel(tracing_enabled=True) - model.set_next_output(ModelBehaviorError("bad model output")) + model = ScriptedModel(emit_traces=True) + model.enqueue(ModelBehaviorError("bad model output")) with pytest.raises(ModelBehaviorError): await Runner.run(Agent(name="test_agent", model=model), input="first_test") @@ -671,7 +671,7 @@ async def test_run_propagates_exception_whose_str_raises(): with pytest.raises(UnformattableError) as exc_info: await Runner.run( - Agent(name="test_agent", model=FakeModel(tracing_enabled=True)), + Agent(name="test_agent", model=ScriptedModel(emit_traces=True)), input="first_test", hooks=RaisingHooks(error), ) @@ -688,7 +688,7 @@ async def test_streamed_run_propagates_exception_whose_str_raises(): error = UnformattableError() result = Runner.run_streamed( - Agent(name="test_agent", model=FakeModel(tracing_enabled=True)), + Agent(name="test_agent", model=ScriptedModel(emit_traces=True)), input="first_test", hooks=RaisingHooks(error), ) diff --git a/tests/test_tracing_errors_streamed.py b/tests/test_tracing_errors_streamed.py index 52b6b50a58..00b73af373 100644 --- a/tests/test_tracing_errors_streamed.py +++ b/tests/test_tracing_errors_streamed.py @@ -23,8 +23,8 @@ TResponseInputItem, _debug, ) +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import ( get_final_output_message, get_function_tool, @@ -57,8 +57,8 @@ async def wait_for_normalized_spans(timeout: float = 0.2): @pytest.mark.asyncio async def test_single_turn_model_error(): - model = FakeModel(tracing_enabled=True) - model.set_next_output(ValueError("test error")) + model = ScriptedModel(emit_traces=True) + model.enqueue(ValueError("test error")) agent = Agent( name="test_agent", @@ -108,8 +108,8 @@ async def test_single_turn_model_error(): ], ) async def test_streamed_agent_error_redacts_sensitive_data(error: Exception) -> None: - model = FakeModel(tracing_enabled=False) - model.set_next_output(error) + model = ScriptedModel(emit_traces=False) + model.enqueue(error) with pytest.raises(type(error)): result = Runner.run_streamed( @@ -130,7 +130,7 @@ async def test_streamed_agent_error_redacts_sensitive_data(error: Exception) -> @pytest.mark.asyncio async def test_multi_turn_no_handoffs(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent = Agent( name="test_agent", @@ -138,7 +138,7 @@ async def test_multi_turn_no_handoffs(): tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a message and tool call [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], @@ -199,7 +199,7 @@ async def test_tool_call_error(monkeypatch: pytest.MonkeyPatch): # which depends on inspecting the chained JSONDecodeError, is preserved. monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent = Agent( name="test_agent", @@ -207,7 +207,7 @@ async def test_tool_call_error(monkeypatch: pytest.MonkeyPatch): tools=[get_function_tool("foo", "tool_result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_text_message("a_message"), get_function_tool_call("foo", "bad_json")], [get_text_message("done")], @@ -268,7 +268,7 @@ async def test_tool_call_error(monkeypatch: pytest.MonkeyPatch): @pytest.mark.asyncio async def test_multiple_handoff_doesnt_error(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent_1 = Agent( name="test", @@ -285,7 +285,7 @@ async def test_multiple_handoff_doesnt_error(): tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}))], @@ -356,7 +356,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_multiple_final_output_no_error(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent_1 = Agent( name="test", @@ -364,7 +364,7 @@ async def test_multiple_final_output_no_error(): output_type=Foo, ) - model.set_next_output( + model.enqueue( [ get_final_output_message(json.dumps(Foo(bar="baz"))), get_final_output_message(json.dumps(Foo(bar="abc"))), @@ -396,7 +396,7 @@ async def test_multiple_final_output_no_error(): @pytest.mark.asyncio async def test_handoffs_lead_to_correct_agent_spans(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent_1 = Agent( name="test_agent_1", @@ -418,7 +418,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): agent_1.handoffs.append(agent_3) - model.add_multiple_turn_outputs( + model.extend( [ # First turn: a tool call [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], @@ -521,7 +521,7 @@ async def test_handoffs_lead_to_correct_agent_spans(): @pytest.mark.asyncio async def test_max_turns_exceeded(): - model = FakeModel(tracing_enabled=True) + model = ScriptedModel(emit_traces=True) agent = Agent( name="test", @@ -530,7 +530,7 @@ async def test_max_turns_exceeded(): tools=[get_function_tool("foo", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("foo", call_id="tool_1")], [get_function_tool_call("foo", call_id="tool_2")], @@ -589,14 +589,14 @@ def input_guardrail_function( @pytest.mark.asyncio async def test_input_guardrail_error(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, input_guardrails=[InputGuardrail(guardrail_function=input_guardrail_function)], ) - model.set_next_output([get_text_message("some_message")]) + model.enqueue([get_text_message("some_message")]) with pytest.raises(InputGuardrailTripwireTriggered): result = Runner.run_streamed(agent, input="user_message") @@ -642,14 +642,14 @@ def output_guardrail_function( @pytest.mark.asyncio async def test_output_guardrail_error(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test", model=model, output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail_function)], ) - model.set_next_output([get_text_message("some_message")]) + model.enqueue([get_text_message("some_message")]) with pytest.raises(OutputGuardrailTripwireTriggered): result = Runner.run_streamed(agent, input="user_message") diff --git a/tests/test_usage.py b/tests/test_usage.py index c6c8444ef0..58e5031438 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -12,6 +12,7 @@ from agents import Agent, Runner from agents.run_internal.agent_runner_helpers import snapshot_usage, usage_delta +from agents.testing import ScriptedModel from agents.usage import ( RequestUsage, Usage, @@ -20,7 +21,6 @@ model_usage_to_span_usage, serialize_usage, ) -from tests.fake_model import FakeModel from tests.test_responses import get_text_message @@ -90,8 +90,8 @@ async def test_runner_run_carries_request_usage_entries() -> None: ) ], ) - model = FakeModel(initial_output=[get_text_message("done")]) - model.set_hardcoded_usage(usage) + model = ScriptedModel(steps=[[get_text_message("done")]]) + model.set_default_usage(usage) agent = Agent(name="usage-agent", model=model) result = await Runner.run(agent, input="hi") diff --git a/tests/utils/hitl.py b/tests/utils/hitl.py index 018159d334..269d7e0e6a 100644 --- a/tests/utils/hitl.py +++ b/tests/utils/hitl.py @@ -11,8 +11,7 @@ from agents.run_context import RunContextWrapper from agents.run_internal.run_loop import NextStepInterruption, SingleStepResult from agents.run_state import RunState as RunStateClass - -from ..fake_model import FakeModel +from agents.testing import ScriptedModel HITL_REJECTION_MSG = "Tool execution was not approved." @@ -38,13 +37,13 @@ class PendingScenario: async def roundtrip_interruptions_via_run( agent: Agent[Any], - model: FakeModel, + model: ScriptedModel, raw_call: Any, *, user_input: str = "test", ) -> list[ToolApprovalItem]: """Run once with a tool call, serialize state, and deserialize it.""" - model.set_next_output([raw_call]) + model.enqueue([raw_call]) result = await Runner.run(agent, user_input) assert result.interruptions, "expected an interruption" state = result.to_state() @@ -54,7 +53,7 @@ async def roundtrip_interruptions_via_run( async def assert_roundtrip_tool_name( agent: Agent[Any], - model: FakeModel, + model: ScriptedModel, raw_call: TResponseOutputItem, expected_tool_name: str, *, @@ -143,7 +142,7 @@ async def run_and_resume( user_input: str, ) -> RunResult: """Run once, then resume from the produced state.""" - model.set_next_output([raw_call]) + model.enqueue([raw_call]) first = await Runner.run(agent, user_input) return await Runner.run(agent, first.to_state()) @@ -193,10 +192,10 @@ async def run_and_resume_after_approval( user_input: str, ) -> RunResult: """Run, approve the first interruption, and resume.""" - model.set_next_output([raw_call]) + model.enqueue([raw_call]) first = await Runner.run(agent, user_input) state = approve_first_interruption(first, always_approve=True) - model.set_next_output([final_output]) + model.enqueue([final_output]) return await Runner.run(agent, state) @@ -319,20 +318,20 @@ def make_function_tool_call( def queue_function_call_and_text( - model: FakeModel, + model: ScriptedModel, function_call: TResponseOutputItem, *, first_turn_extra: Sequence[TResponseOutputItem] | None = None, followup: Sequence[TResponseOutputItem] | None = None, ) -> None: - """Queue a function call turn followed by a follow-up turn on the fake model.""" + """Queue a function call turn followed by a follow-up turn on the scripted model.""" raw_type = ( function_call.get("type") if isinstance(function_call, dict) else getattr(function_call, "type", None) ) assert raw_type == "function_call", "queue_function_call_and_text expects a function call item" - model.add_multiple_turn_outputs( + model.extend( [ [function_call, *(first_turn_extra or [])], list(followup or []), @@ -349,7 +348,7 @@ async def run_and_resume_with_mutation( mutate_state: Callable[[RunStateClass[Any, Agent[Any]], ToolApprovalItem], None] | None = None, ) -> tuple[RunResult, RunResult]: """Run until interruption, optionally mutate state, then resume.""" - model.add_multiple_turn_outputs(turn_outputs) + model.extend(turn_outputs) first = await Runner.run(agent, input=user_input) assert first.interruptions, "expected an approval interruption" state = first.to_state() @@ -454,9 +453,9 @@ def make_model_and_agent( *, tools: Sequence[Any] | None = None, name: str = "TestAgent", -) -> tuple[FakeModel, Agent[Any]]: - """Build a FakeModel with a paired Agent for HITL tests.""" - model = FakeModel() +) -> tuple[ScriptedModel, Agent[Any]]: + """Build a ScriptedModel with a paired Agent for HITL tests.""" + model = ScriptedModel() agent = make_agent(model=model, tools=tools, name=name) return model, agent diff --git a/tests/voice/fake_models.py b/tests/voice/fake_models.py deleted file mode 100644 index 109ee4cb18..0000000000 --- a/tests/voice/fake_models.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncIterator -from typing import Literal - -import numpy as np -import numpy.typing as npt - -try: - from agents.voice import ( - AudioInput, - StreamedAudioInput, - StreamedTranscriptionSession, - STTModel, - STTModelSettings, - TTSModel, - TTSModelSettings, - VoiceWorkflowBase, - ) -except ImportError: - pass - - -class FakeTTS(TTSModel): - """Fakes TTS by just returning string bytes.""" - - def __init__(self, strategy: Literal["default", "split_words"] = "default"): - self.strategy = strategy - - @property - def model_name(self) -> str: - return "fake_tts" - - async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: - if self.strategy == "default": - yield np.zeros(2, dtype=np.int16).tobytes() - elif self.strategy == "split_words": - for _ in text.split(): - yield np.zeros(2, dtype=np.int16).tobytes() - - async def verify_audio(self, text: str, audio: bytes, dtype: npt.DTypeLike = np.int16) -> None: - assert audio == np.zeros(2, dtype=dtype).tobytes() - - async def verify_audio_chunks( - self, text: str, audio_chunks: list[bytes], dtype: npt.DTypeLike = np.int16 - ) -> None: - assert audio_chunks == [np.zeros(2, dtype=dtype).tobytes() for _word in text.split()] - - -class FakeSession(StreamedTranscriptionSession): - """A fake streamed transcription session that yields preconfigured transcripts.""" - - def __init__(self): - self.outputs: list[str] = [] - - async def transcribe_turns(self) -> AsyncIterator[str]: - for t in self.outputs: - yield t - - async def close(self) -> None: - return None - - -class FakeSTT(STTModel): - """A fake STT model that either returns a single transcript or yields multiple.""" - - def __init__(self, outputs: list[str] | None = None): - self.outputs = outputs or [] - - @property - def model_name(self) -> str: - return "fake_stt" - - async def transcribe(self, _: AudioInput, __: STTModelSettings, ___: bool, ____: bool) -> str: - return self.outputs.pop(0) - - async def create_session( - self, - _: StreamedAudioInput, - __: STTModelSettings, - ___: bool, - ____: bool, - ) -> StreamedTranscriptionSession: - session = FakeSession() - session.outputs = self.outputs - return session - - -class FakeWorkflow(VoiceWorkflowBase): - """A fake workflow that yields preconfigured outputs.""" - - def __init__(self, outputs: list[list[str]] | None = None): - self.outputs = outputs or [] - - def add_output(self, output: list[str]) -> None: - self.outputs.append(output) - - def add_multiple_outputs(self, outputs: list[list[str]]) -> None: - self.outputs.extend(outputs) - - async def run(self, _: str) -> AsyncIterator[str]: - if not self.outputs: - raise ValueError("No output configured") - output = self.outputs.pop(0) - for t in output: - yield t - - -class FakeStreamedAudioInput: - @classmethod - async def get(cls, count: int) -> StreamedAudioInput: - input = StreamedAudioInput() - for _ in range(count): - await input.add_audio(np.zeros(2, dtype=np.int16)) - return input diff --git a/tests/voice/pipeline_test_models.py b/tests/voice/pipeline_test_models.py new file mode 100644 index 0000000000..b6946dd01b --- /dev/null +++ b/tests/voice/pipeline_test_models.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Literal + +import numpy as np +import numpy.typing as npt + +from agents.voice import ( + StreamedAudioInput, + STTModelSettings, + TTSModelSettings, +) +from agents.voice.testing import ( + ScriptedSTTModel, + ScriptedTranscriptionSession, + ScriptedTTSModel, + ScriptedVoiceWorkflow, +) + + +class ZeroPcmTTSModel(ScriptedTTSModel): + """Generate deterministic zero-valued PCM for pipeline lifecycle tests.""" + + def __init__(self, strategy: Literal["default", "split_words"] = "default") -> None: + super().__init__(model_name="zero-pcm-tts") + self.strategy = strategy + + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + if self.strategy == "default": + yield np.zeros(2, dtype=np.int16).tobytes() + elif self.strategy == "split_words": + for _ in text.split(): + yield np.zeros(2, dtype=np.int16).tobytes() + + async def verify_audio(self, text: str, audio: bytes, dtype: npt.DTypeLike = np.int16) -> None: + assert audio == np.zeros(2, dtype=dtype).tobytes() + + async def verify_audio_chunks( + self, text: str, audio_chunks: list[bytes], dtype: npt.DTypeLike = np.int16 + ) -> None: + assert audio_chunks == [np.zeros(2, dtype=dtype).tobytes() for _word in text.split()] + + +class QueuedTranscriptionSession(ScriptedTranscriptionSession): + """Yield mutable queued transcripts for lifecycle-specific pipeline tests.""" + + def __init__(self) -> None: + super().__init__() + self.outputs: list[str] = [] + + async def transcribe_turns(self) -> AsyncIterator[str]: + for transcript in self.outputs: + yield transcript + + async def close(self) -> None: + return None + + +class QueuedSTTModel(ScriptedSTTModel): + """Share one transcript queue across static and lifecycle-specific streamed tests.""" + + def __init__(self, outputs: list[str] | None = None) -> None: + super().__init__(outputs or [], model_name="queued-stt") + self.outputs = self._transcriptions + + async def create_session( + self, + input: StreamedAudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> QueuedTranscriptionSession: + del input, settings, trace_include_sensitive_data, trace_include_sensitive_audio_data + session = QueuedTranscriptionSession() + session.outputs = self.outputs + return session + + +class QueuedVoiceWorkflow(ScriptedVoiceWorkflow): + """A named scripted workflow base for pipeline lifecycle subclasses.""" + + +class StreamedAudioInputFactory: + @classmethod + async def get(cls, count: int) -> StreamedAudioInput: + input = StreamedAudioInput() + for _ in range(count): + await input.add_audio(np.zeros(2, dtype=np.int16)) + return input diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index e75e27c37a..de9e7b0631 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -34,7 +34,7 @@ _audio_buffer_to_base64, ) - from .fake_models import FakeStreamedAudioInput + from .pipeline_test_models import StreamedAudioInputFactory except ImportError: pass @@ -367,7 +367,7 @@ async def test_non_json_messages_should_crash(): mock_ws = create_mock_websocket(["not a json message"]) with patch("websockets.connect", return_value=mock_ws): # Instantiate the session - input_audio = await FakeStreamedAudioInput.get(count=2) + input_audio = await StreamedAudioInputFactory.get(count=2) stt_settings = STTModelSettings() session = OpenAISTTTranscriptionSession( @@ -407,7 +407,7 @@ async def test_session_connects_and_configures_successfully(): ) with patch("websockets.connect", return_value=mock_ws) as mock_connect: # Instantiate the session - input_audio = await FakeStreamedAudioInput.get(count=2) + input_audio = await StreamedAudioInputFactory.get(count=2) stt_settings = STTModelSettings() session = OpenAISTTTranscriptionSession( @@ -543,7 +543,7 @@ async def test_transcription_event_puts_output_in_queue(created, updated, comple with patch("websockets.connect", return_value=mock_ws): # Prepare - audio_input = await FakeStreamedAudioInput.get(count=2) + audio_input = await StreamedAudioInputFactory.get(count=2) stt_settings = STTModelSettings() session = OpenAISTTTranscriptionSession( @@ -589,7 +589,7 @@ def fake_time_func(): ) # add a fake event to the mock websocket to make sure it doesn't raise a different exception with patch("websockets.connect", return_value=mock_ws): - audio_input = await FakeStreamedAudioInput.get(count=2) + audio_input = await StreamedAudioInputFactory.get(count=2) stt_settings = STTModelSettings() session = OpenAISTTTranscriptionSession( @@ -638,7 +638,7 @@ async def test_session_error_event(monkeypatch: pytest.MonkeyPatch): ) with patch("websockets.connect", return_value=mock_ws): - audio_input = await FakeStreamedAudioInput.get(count=2) + audio_input = await StreamedAudioInputFactory.get(count=2) stt_settings = STTModelSettings() session = OpenAISTTTranscriptionSession( @@ -676,7 +676,7 @@ async def test_session_error_event_before_session_created(): ) with patch("websockets.connect", return_value=mock_ws): - audio_input = await FakeStreamedAudioInput.get(count=2) + audio_input = await StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, client=AsyncMock(api_key="FAKE_KEY"), @@ -719,7 +719,7 @@ async def messages_then_timeout() -> AsyncGenerator[str, None]: mock_ws.__aiter__.side_effect = messages_then_timeout with patch("websockets.connect", return_value=mock_ws): - audio_input = await FakeStreamedAudioInput.get(count=2) + audio_input = await StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, client=AsyncMock(api_key="FAKE_KEY"), @@ -773,7 +773,7 @@ async def test_inactivity_timeout(): ], ), ): - audio_input = await FakeStreamedAudioInput.get(count=2) + audio_input = await StreamedAudioInputFactory.get(count=2) stt_settings = STTModelSettings() session = OpenAISTTTranscriptionSession( diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 8fc2a4e634..43f207f525 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -17,6 +17,7 @@ try: from agents.voice import ( AudioInput, + StreamedAudioInput, StreamedAudioResult, STTModelSettings, TTSModelSettings, @@ -27,14 +28,14 @@ VoiceStreamEventLifecycle, ) - from .fake_models import ( - FakeSession, - FakeStreamedAudioInput, - FakeSTT, - FakeTTS, - FakeWorkflow, - ) from .helpers import extract_events + from .pipeline_test_models import ( + QueuedSTTModel, + QueuedTranscriptionSession, + QueuedVoiceWorkflow, + StreamedAudioInputFactory, + ZeroPcmTTSModel, + ) except ImportError: pass @@ -57,7 +58,7 @@ class _ProviderVoicePipelineConfig(VoicePipelineConfig): def test_streamed_audio_result_odd_length_buffer_int16() -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(dtype=np.int16), VoicePipelineConfig(), ) @@ -75,7 +76,7 @@ def __bool__(self) -> bool: return False result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -99,7 +100,7 @@ async def fail() -> None: @pytest.mark.asyncio async def test_streamed_audio_result_propagates_consumer_cancellation(monkeypatch) -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -139,7 +140,7 @@ async def test_streamed_audio_result_preserves_cancellation_when_cleanup_fails( caplog: pytest.LogCaptureFixture, ) -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -182,7 +183,7 @@ async def fail_cleanup() -> None: @pytest.mark.asyncio async def test_streamed_audio_result_closes_owned_tasks_after_yield() -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -228,7 +229,7 @@ async def hold_open(index: int) -> None: @pytest.mark.asyncio async def test_streamed_audio_result_closes_gracefully_after_session_end_yield() -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -281,7 +282,7 @@ async def test_streamed_audio_result_propagates_cancellation_when_terminal_clean monkeypatch, ) -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -330,7 +331,7 @@ async def test_streamed_audio_result_aclose_surfaces_terminal_producer_error( monkeypatch, ) -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -386,7 +387,7 @@ async def test_streamed_audio_result_surfaces_completed_terminal_producer_error( close_early: bool, ) -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(), VoicePipelineConfig(), ) @@ -458,10 +459,10 @@ def test_voice_pipeline_config_rejects_unknown_dictionary_settings( @pytest.mark.asyncio async def test_voicepipeline_normalizes_nested_dictionary_config() -> None: - fake_stt = FakeSTT(["first"]) - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel(["first"]) + fake_tts = ZeroPcmTTSModel() pipeline = VoicePipeline( - workflow=FakeWorkflow([["out_1"]]), + workflow=QueuedVoiceWorkflow([["out_1"]]), stt_model=fake_stt, tts_model=fake_tts, config={ @@ -478,9 +479,31 @@ async def test_voicepipeline_normalizes_nested_dictionary_config() -> None: await fake_tts.verify_audio("out_1", audio_chunks[0]) +@pytest.mark.asyncio +async def test_queued_stt_model_shares_static_and_streamed_transcription_queue() -> None: + stt = QueuedSTTModel(["static", "streamed"]) + + transcription = await stt.transcribe( + AudioInput(buffer=np.zeros(2, dtype=np.int16)), + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + session = await stt.create_session( + StreamedAudioInput(), + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + streamed_transcriptions = [turn async for turn in session.transcribe_turns()] + + assert transcription == "static" + assert streamed_transcriptions == ["streamed"] + + def test_streamed_audio_result_odd_length_buffer_float32() -> None: result = StreamedAudioResult( - FakeTTS(), + ZeroPcmTTSModel(), TTSModelSettings(dtype=np.float32), VoicePipelineConfig(), ) @@ -494,7 +517,7 @@ def test_streamed_audio_result_odd_length_buffer_float32() -> None: @pytest.mark.asyncio async def test_streamed_audio_result_preserves_cross_chunk_sample_boundaries() -> None: - class SplitSampleTTS(FakeTTS): + class SplitSampleTTS(ZeroPcmTTSModel): async def run(self, text: str, settings: TTSModelSettings): del text, settings yield b"\x01" @@ -533,7 +556,7 @@ async def test_streamed_audio_error_respects_sensitive_data_setting( trace_include_sensitive_data: bool, expected_error: str, ) -> None: - class FailingTTS(FakeTTS): + class FailingTTS(ZeroPcmTTSModel): async def run(self, text: str, settings: TTSModelSettings): del text, settings raise RuntimeError("sensitive-tts-error") @@ -564,7 +587,7 @@ async def run(self, text: str, settings: TTSModelSettings): async def test_streamed_audio_dispatcher_handles_stream_failure() -> None: """A failed _stream_audio task must not leave _dispatch_audio blocked forever.""" - class FailingTTS(FakeTTS): + class FailingTTS(ZeroPcmTTSModel): async def run(self, text: str, settings: TTSModelSettings): del text, settings raise RuntimeError("tts-failure") @@ -613,7 +636,7 @@ async def test_voice_pipeline_awaits_task_cleanup_after_tts_failure() -> None: second_segment_started = asyncio.Event() second_segment_stopped = asyncio.Event() - class FailingTTS(FakeTTS): + class FailingTTS(ZeroPcmTTSModel): async def run(self, text: str, settings: TTSModelSettings): del settings if text == "first": @@ -631,8 +654,8 @@ def split_immediately(text: str) -> tuple[str, str]: return text, "" pipeline = VoicePipeline( - workflow=FakeWorkflow([["first", "second"]]), - stt_model=FakeSTT(["user input"]), + workflow=QueuedVoiceWorkflow([["first", "second"]]), + stt_model=QueuedSTTModel(["user input"]), tts_model=FailingTTS(), config=VoicePipelineConfig(tts_settings=TTSModelSettings(text_splitter=split_immediately)), ) @@ -675,7 +698,7 @@ async def wait(self) -> Literal[True]: def split_immediately(text: str) -> tuple[str, str]: return text, "" - fake_tts = FakeTTS() + fake_tts = ZeroPcmTTSModel() result = StreamedAudioResult( fake_tts, TTSModelSettings(buffer_size=1, text_splitter=split_immediately), @@ -716,7 +739,7 @@ def split_immediately(text: str) -> tuple[str, str]: async def test_streamed_audio_result_synthesizes_short_custom_splitter_chunk() -> None: texts: list[str] = [] - class RecordingTTS(FakeTTS): + class RecordingTTS(ZeroPcmTTSModel): async def run(self, text: str, settings: TTSModelSettings): texts.append(text) yield np.zeros(2, dtype=np.int16).tobytes() @@ -745,7 +768,7 @@ def split_immediately(text: str) -> tuple[str, str]: async def test_streamed_audio_result_ignores_empty_custom_splitter_chunk() -> None: texts: list[str] = [] - class RecordingTTS(FakeTTS): + class RecordingTTS(ZeroPcmTTSModel): async def run(self, text: str, settings: TTSModelSettings): texts.append(text) yield np.zeros(2, dtype=np.int16).tobytes() @@ -774,9 +797,9 @@ def discard_text(_text: str) -> tuple[str, str]: async def test_voicepipeline_run_single_turn() -> None: # Single turn. Should produce a single audio output, which is the TTS output for "out_1". - fake_stt = FakeSTT(["first"]) - workflow = FakeWorkflow([["out_1"]]) - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["out_1"]]) + fake_tts = ZeroPcmTTSModel() config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1)) pipeline = VoicePipeline( workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config @@ -797,12 +820,12 @@ async def test_voicepipeline_run_single_turn() -> None: async def test_voicepipeline_streamed_audio_input() -> None: # Multi turn. Should produce 2 audio outputs, which are the TTS outputs of "out_1" and "out_2" - fake_stt = FakeSTT(["first", "second"]) - workflow = FakeWorkflow([["out_1"], ["out_2"]]) - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel(["first", "second"]) + workflow = QueuedVoiceWorkflow([["out_1"], ["out_2"]]) + fake_tts = ZeroPcmTTSModel() pipeline = VoicePipeline(workflow=workflow, stt_model=fake_stt, tts_model=fake_tts) - streamed_audio_input = await FakeStreamedAudioInput.get(count=2) + streamed_audio_input = await StreamedAudioInputFactory.get(count=2) result = await pipeline.run(streamed_audio_input) events, audio_chunks = await extract_events(result) @@ -825,7 +848,7 @@ def _never_complete(text: str) -> tuple[str, str]: return "", text -class _RecordingTTS(FakeTTS): +class _RecordingTTS(ZeroPcmTTSModel): """Records every text handed to TTS so a test can assert no work was started.""" def __init__(self) -> None: @@ -841,12 +864,12 @@ async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[byte async def test_voicepipeline_streamed_audio_input_without_turns() -> None: # Zero turns. The session still has to end, otherwise `stream()` waits on the queue forever. - fake_stt = FakeSTT([]) - workflow = FakeWorkflow() - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel([]) + workflow = QueuedVoiceWorkflow() + fake_tts = ZeroPcmTTSModel() pipeline = VoicePipeline(workflow=workflow, stt_model=fake_stt, tts_model=fake_tts) - streamed_audio_input = await FakeStreamedAudioInput.get(count=0) + streamed_audio_input = await StreamedAudioInputFactory.get(count=0) result = await pipeline.run(streamed_audio_input) # The timeout bounds the failure mode under test, which is a stream that never terminates. @@ -862,7 +885,7 @@ async def test_voicepipeline_delivers_on_start_output_during_startup() -> None: intro_delivered = asyncio.Event() - class GatedSession(FakeSession): + class GatedSession(QueuedTranscriptionSession): async def transcribe_turns(self) -> AsyncIterator[str]: # Released only once the greeting has been fully delivered. If the intro turn were # left open until session end, this would never be released and the test times out. @@ -870,13 +893,13 @@ async def transcribe_turns(self) -> AsyncIterator[str]: for t in self.outputs: yield t - class GatedSTT(FakeSTT): + class GatedSTT(QueuedSTTModel): async def create_session(self, *args: Any, **kwargs: Any) -> GatedSession: session = GatedSession() session.outputs = self.outputs return session - class GreetingWorkflow(FakeWorkflow): + class GreetingWorkflow(QueuedVoiceWorkflow): async def on_start(self) -> AsyncIterator[str]: yield "Hello there" @@ -889,7 +912,7 @@ async def on_start(self) -> AsyncIterator[str]: tts_model=_RecordingTTS(), config=config, ) - result = await pipeline.run(await FakeStreamedAudioInput.get(count=0)) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=0)) events: list[str] = [] @@ -915,15 +938,15 @@ async def test_voicepipeline_on_start_output_is_its_own_turn() -> None: # so this pins that the greeting is finalized as its own turn and the first user response still # gets its own turn_started rather than being folded into an intro that is still open. - class ImmediateSession(FakeSession): + class ImmediateSession(QueuedTranscriptionSession): async def transcribe_turns(self) -> AsyncIterator[str]: yield "hello" - class ImmediateSTT(FakeSTT): + class ImmediateSTT(QueuedSTTModel): async def create_session(self, *args: Any, **kwargs: Any) -> ImmediateSession: return ImmediateSession() - class GreetingWorkflow(FakeWorkflow): + class GreetingWorkflow(QueuedVoiceWorkflow): async def on_start(self) -> AsyncIterator[str]: yield "Hello there" @@ -940,7 +963,7 @@ async def run(self, _: str) -> AsyncIterator[str]: tts_model=recording_tts, config=config, ) - result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) events, _ = await asyncio.wait_for(extract_events(result), timeout=5) @@ -963,11 +986,11 @@ async def test_voicepipeline_failed_turn_closes_the_session_without_further_tts( closed = asyncio.Event() - class ClosingSession(FakeSession): + class ClosingSession(QueuedTranscriptionSession): async def close(self) -> None: closed.set() - class ClosingSTT(FakeSTT): + class ClosingSTT(QueuedSTTModel): async def create_session(self, *args: Any, **kwargs: Any) -> ClosingSession: session = ClosingSession() session.outputs = self.outputs @@ -975,7 +998,7 @@ async def create_session(self, *args: Any, **kwargs: Any) -> ClosingSession: error = RuntimeError("workflow blew up") - class FailingWorkflow(FakeWorkflow): + class FailingWorkflow(QueuedVoiceWorkflow): async def run(self, _: str) -> AsyncIterator[str]: yield "partial" raise error @@ -990,7 +1013,7 @@ async def run(self, _: str) -> AsyncIterator[str]: tts_model=recording_tts, config=config, ) - result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) with pytest.raises(RuntimeError) as exc_info: await asyncio.wait_for(extract_events(result), timeout=5) @@ -1011,19 +1034,19 @@ async def test_voicepipeline_error_waits_for_the_session_close_before_cleanup() release_close = asyncio.Event() close_finished = asyncio.Event() - class BlockingCloseSession(FakeSession): + class BlockingCloseSession(QueuedTranscriptionSession): async def close(self) -> None: close_started.set() await release_close.wait() close_finished.set() - class BlockingCloseSTT(FakeSTT): + class BlockingCloseSTT(QueuedSTTModel): async def create_session(self, *args: Any, **kwargs: Any) -> BlockingCloseSession: session = BlockingCloseSession() session.outputs = self.outputs return session - class FailingWorkflow(FakeWorkflow): + class FailingWorkflow(QueuedVoiceWorkflow): async def run(self, _: str) -> AsyncIterator[str]: yield "partial" raise turn_error @@ -1038,7 +1061,7 @@ async def run(self, _: str) -> AsyncIterator[str]: tts_model=recording_tts, config=config, ) - result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) consumer = asyncio.create_task(extract_events(result)) await asyncio.wait_for(close_started.wait(), timeout=5) @@ -1070,17 +1093,17 @@ async def test_voicepipeline_failing_close_does_not_replace_the_turn_error() -> turn_error = RuntimeError("workflow blew up") close_error = RuntimeError("close blew up") - class FailingCloseSession(FakeSession): + class FailingCloseSession(QueuedTranscriptionSession): async def close(self) -> None: raise close_error - class FailingCloseSTT(FakeSTT): + class FailingCloseSTT(QueuedSTTModel): async def create_session(self, *args: Any, **kwargs: Any) -> FailingCloseSession: session = FailingCloseSession() session.outputs = self.outputs return session - class FailingWorkflow(FakeWorkflow): + class FailingWorkflow(QueuedVoiceWorkflow): async def run(self, _: str) -> AsyncIterator[str]: raise turn_error yield "" @@ -1088,9 +1111,9 @@ async def run(self, _: str) -> AsyncIterator[str]: pipeline = VoicePipeline( workflow=FailingWorkflow(), stt_model=FailingCloseSTT(["hello"]), - tts_model=FakeTTS(), + tts_model=ZeroPcmTTSModel(), ) - result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) with pytest.raises(RuntimeError) as exc_info: await asyncio.wait_for(extract_events(result), timeout=5) @@ -1105,22 +1128,22 @@ async def test_voicepipeline_failing_close_after_a_clean_run_reaches_the_consume close_error = RuntimeError("close blew up") - class FailingCloseSession(FakeSession): + class FailingCloseSession(QueuedTranscriptionSession): async def close(self) -> None: raise close_error - class FailingCloseSTT(FakeSTT): + class FailingCloseSTT(QueuedSTTModel): async def create_session(self, *args: Any, **kwargs: Any) -> FailingCloseSession: session = FailingCloseSession() session.outputs = self.outputs return session pipeline = VoicePipeline( - workflow=FakeWorkflow([["hello"]]), + workflow=QueuedVoiceWorkflow([["hello"]]), stt_model=FailingCloseSTT(["hello"]), - tts_model=FakeTTS(), + tts_model=ZeroPcmTTSModel(), ) - result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) with pytest.raises(RuntimeError) as exc_info: await asyncio.wait_for(extract_events(result), timeout=5) @@ -1136,7 +1159,7 @@ async def test_voicepipeline_cancelled_consumer_closes_the_session_without_furth closed = asyncio.Event() buffered = asyncio.Event() - class ClosingSession(FakeSession): + class ClosingSession(QueuedTranscriptionSession): async def transcribe_turns(self) -> AsyncIterator[str]: yield "hello" await asyncio.Event().wait() @@ -1144,11 +1167,11 @@ async def transcribe_turns(self) -> AsyncIterator[str]: async def close(self) -> None: closed.set() - class ClosingSTT(FakeSTT): + class ClosingSTT(QueuedSTTModel): async def create_session(self, *args: Any, **kwargs: Any) -> ClosingSession: return ClosingSession() - class BufferingWorkflow(FakeWorkflow): + class BufferingWorkflow(QueuedVoiceWorkflow): async def run(self, _: str) -> AsyncIterator[str]: yield "partial" buffered.set() @@ -1164,7 +1187,7 @@ async def run(self, _: str) -> AsyncIterator[str]: tts_model=recording_tts, config=config, ) - result = await pipeline.run(await FakeStreamedAudioInput.get(count=1)) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) consumer = asyncio.create_task(extract_events(result)) await asyncio.wait_for(buffered.wait(), timeout=5) @@ -1181,9 +1204,9 @@ async def test_voicepipeline_run_single_turn_split_words() -> None: # Single turn. Should produce multiple audio outputs, which are the TTS outputs of "foo bar baz" # split into words and then "foo2 bar2 baz2" split into words. - fake_stt = FakeSTT(["first"]) - workflow = FakeWorkflow([["foo bar baz"]]) - fake_tts = FakeTTS(strategy="split_words") + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["foo bar baz"]]) + fake_tts = ZeroPcmTTSModel(strategy="split_words") config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1)) pipeline = VoicePipeline( workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config @@ -1207,14 +1230,14 @@ async def test_voicepipeline_run_multi_turn_split_words() -> None: # Multi turn. Should produce multiple audio outputs, which are the TTS outputs of "foo bar baz" # split into words. - fake_stt = FakeSTT(["first", "second"]) - workflow = FakeWorkflow([["foo bar baz"], ["foo2 bar2 baz2"]]) - fake_tts = FakeTTS(strategy="split_words") + fake_stt = QueuedSTTModel(["first", "second"]) + workflow = QueuedVoiceWorkflow([["foo bar baz"], ["foo2 bar2 baz2"]]) + fake_tts = ZeroPcmTTSModel(strategy="split_words") config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1)) pipeline = VoicePipeline( workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config ) - streamed_audio_input = await FakeStreamedAudioInput.get(count=6) + streamed_audio_input = await StreamedAudioInputFactory.get(count=6) result = await pipeline.run(streamed_audio_input) events, audio_chunks = await extract_events(result) assert events == [ @@ -1239,9 +1262,9 @@ async def test_voicepipeline_run_multi_turn_split_words() -> None: async def test_voicepipeline_float32() -> None: # Single turn. Should produce a single audio output, which is the TTS output for "out_1". - fake_stt = FakeSTT(["first"]) - workflow = FakeWorkflow([["out_1"]]) - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["out_1"]]) + fake_tts = ZeroPcmTTSModel() config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1, dtype=np.float32)) pipeline = VoicePipeline( workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config @@ -1267,9 +1290,9 @@ def _transform_data( ) -> npt.NDArray[np.int16]: return data_chunk.astype(np.int16) - fake_stt = FakeSTT(["first"]) - workflow = FakeWorkflow([["out_1"]]) - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["out_1"]]) + fake_tts = ZeroPcmTTSModel() config = VoicePipelineConfig( tts_settings=TTSModelSettings( buffer_size=1, @@ -1292,7 +1315,7 @@ def _transform_data( await fake_tts.verify_audio("out_1", audio_chunks[0], dtype=np.int16) -class _BlockingWorkflow(FakeWorkflow): +class _BlockingWorkflow(QueuedVoiceWorkflow): def __init__(self, gate: asyncio.Event): super().__init__() self._gate = gate @@ -1302,7 +1325,7 @@ async def run(self, _: str): yield "out_1" -class _FailingWorkflow(FakeWorkflow): +class _FailingWorkflow(QueuedVoiceWorkflow): def __init__(self, error: BaseException): super().__init__() self.error = error @@ -1312,7 +1335,7 @@ async def run(self, _: str): yield "" # pragma: no cover -class _OnStartYieldThenFailWorkflow(FakeWorkflow): +class _OnStartYieldThenFailWorkflow(QueuedVoiceWorkflow): def __init__(self, outputs: list[list[str]], error: BaseException | None = None): super().__init__(outputs) self.error = error or RuntimeError("boom") @@ -1324,8 +1347,8 @@ async def on_start(self): @pytest.mark.asyncio async def test_voicepipeline_trace_not_finished_before_single_turn_completes() -> None: - fake_stt = FakeSTT(["first"]) - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel(["first"]) + fake_tts = ZeroPcmTTSModel() gate = asyncio.Event() workflow = _BlockingWorkflow(gate) config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1)) @@ -1348,12 +1371,12 @@ async def test_voicepipeline_trace_not_finished_before_single_turn_completes() - @pytest.mark.asyncio async def test_voicepipeline_trace_finishes_after_multi_turn_processing() -> None: - fake_stt = FakeSTT(["first", "second"]) - workflow = FakeWorkflow([["out_1"], ["out_2"]]) - fake_tts = FakeTTS() + fake_stt = QueuedSTTModel(["first", "second"]) + workflow = QueuedVoiceWorkflow([["out_1"], ["out_2"]]) + fake_tts = ZeroPcmTTSModel() pipeline = VoicePipeline(workflow=workflow, stt_model=fake_stt, tts_model=fake_tts) - streamed_audio_input = await FakeStreamedAudioInput.get(count=2) + streamed_audio_input = await StreamedAudioInputFactory.get(count=2) result = await pipeline.run(streamed_audio_input) await extract_events(result) assert fetch_events()[-1] == "trace_end" @@ -1361,12 +1384,12 @@ async def test_voicepipeline_trace_finishes_after_multi_turn_processing() -> Non @pytest.mark.asyncio async def test_voicepipeline_multi_turn_on_start_exception_does_not_abort() -> None: - fake_stt = FakeSTT(["first"]) + fake_stt = QueuedSTTModel(["first"]) workflow = _OnStartYieldThenFailWorkflow([["out_1"]]) - fake_tts = FakeTTS() + fake_tts = ZeroPcmTTSModel() pipeline = VoicePipeline(workflow=workflow, stt_model=fake_stt, tts_model=fake_tts) - streamed_audio_input = await FakeStreamedAudioInput.get(count=1) + streamed_audio_input = await StreamedAudioInputFactory.get(count=1) result = await pipeline.run(streamed_audio_input) events, _ = await extract_events(result) @@ -1397,10 +1420,10 @@ async def test_voice_on_start_errors_apply_model_and_tool_logging_policies( error.__cause__ = cause pipeline = VoicePipeline( workflow=_OnStartYieldThenFailWorkflow([["out_1"]], error), - stt_model=FakeSTT(["first"]), - tts_model=FakeTTS(), + stt_model=QueuedSTTModel(["first"]), + tts_model=ZeroPcmTTSModel(), ) - streamed_audio_input = await FakeStreamedAudioInput.get(count=1) + streamed_audio_input = await StreamedAudioInputFactory.get(count=1) caplog.set_level(logging.WARNING, logger="openai.agents") result = await pipeline.run(streamed_audio_input) @@ -1466,11 +1489,11 @@ async def test_voice_workflow_errors_apply_model_and_tool_logging_policies( error = RuntimeError("SECRET_VOICE_TOOL_PAYLOAD") pipeline = VoicePipeline( workflow=_FailingWorkflow(error), - stt_model=FakeSTT(["first"]), - tts_model=FakeTTS(), + stt_model=QueuedSTTModel(["first"]), + tts_model=ZeroPcmTTSModel(), ) audio_input = ( - await FakeStreamedAudioInput.get(count=1) + await StreamedAudioInputFactory.get(count=1) if streamed else AudioInput(buffer=np.zeros(2, dtype=np.int16)) ) diff --git a/tests/voice/test_testing.py b/tests/voice/test_testing.py new file mode 100644 index 0000000000..3275fe473b --- /dev/null +++ b/tests/voice/test_testing.py @@ -0,0 +1,543 @@ +from __future__ import annotations + +from typing import Any, cast + +import numpy as np +import pytest + +from agents.voice import ( + AudioInput, + StreamedAudioInput, + STTModelSettings, + TTSModelSettings, + VoicePipeline, +) +from agents.voice.events import VoiceStreamEventAudio, VoiceStreamEventLifecycle +from agents.voice.testing import ( + ScriptedSTTModel, + ScriptedTranscriptionSession, + ScriptedTTSModel, + ScriptedVoiceWorkflow, + TTSResult, + UnconsumedVoiceSteps, + UnexpectedVoiceCall, + pcm16_samples, +) + + +@pytest.mark.asyncio +async def test_scripted_voice_components_run_static_pipeline() -> None: + audio = pcm16_samples([0, 100, -100, 0]) + stt = ScriptedSTTModel(["hello"]) + workflow = ScriptedVoiceWorkflow([["hi."]]) + tts = ScriptedTTSModel([TTSResult([audio])]) + pipeline = VoicePipeline( + workflow=workflow, + stt_model=stt, + tts_model=tts, + config={"tracing_disabled": True, "tts_settings": {"buffer_size": 1}}, + ) + + result = await pipeline.run(AudioInput(np.zeros(2, dtype=np.int16))) + lifecycle: list[str] = [] + chunks: list[bytes] = [] + async for event in result.stream(): + if isinstance(event, VoiceStreamEventLifecycle): + lifecycle.append(event.event) + elif isinstance(event, VoiceStreamEventAudio): + assert event.data is not None + chunks.append(event.data.tobytes()) + + assert lifecycle == ["turn_started", "turn_ended", "session_ended"] + assert chunks == [audio] + assert workflow.transcriptions == ("hello",) + assert [call.text for call in tts.calls] == ["hi."] + stt.assert_complete() + workflow.assert_complete() + tts.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_tts_freezes_wrapped_result_chunks_when_queued() -> None: + chunks = [b"first"] + tts = ScriptedTTSModel([TTSResult(chunks)]) + + chunks.append(b"later") + result = [chunk async for chunk in tts.run("hello", tts_settings())] + + assert result == [b"first"] + tts.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_stt_treats_bare_transcription_string_as_one_result() -> None: + stt = ScriptedSTTModel("hello") + + transcription = await stt.transcribe( + AudioInput(np.zeros(2, dtype=np.int16)), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + assert transcription == "hello" + stt.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_stt_creates_closable_streamed_session() -> None: + session = ScriptedTranscriptionSession(["first", "second"]) + stt = ScriptedSTTModel(sessions=[session]) + + created = await stt.create_session( + StreamedAudioInput(), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + turns = [turn async for turn in created.transcribe_turns()] + await created.close() + await created.close() + + assert turns == ["first", "second"] + assert session.closed is True + assert session.close_calls == 2 + stt.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_stt_treats_bare_session_string_as_one_turn() -> None: + stt = ScriptedSTTModel(sessions=["hello"]) + + created = await stt.create_session( + StreamedAudioInput(), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + turns = [turn async for turn in created.transcribe_turns()] + + assert turns == ["hello"] + stt.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_stt_treats_direct_sessions_string_as_one_session() -> None: + stt = ScriptedSTTModel(sessions="hello") + + created = await stt.create_session( + StreamedAudioInput(), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + turns = [turn async for turn in created.transcribe_turns()] + + assert turns == ["hello"] + stt.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_stt_snapshots_nested_session_turns_when_queued() -> None: + turns = ["before"] + stt = ScriptedSTTModel(sessions=[turns]) + turns[0] = "after" + turns.append("later") + + created = await stt.create_session( + StreamedAudioInput(), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + transcriptions = [turn async for turn in created.transcribe_turns()] + + assert transcriptions == ["before"] + stt.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_stt_preserves_streamed_session_exception_identity() -> None: + expected = RuntimeError("session failed") + stt = ScriptedSTTModel(sessions=[expected]) + + with pytest.raises(RuntimeError) as exc_info: + await stt.create_session( + StreamedAudioInput(), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + assert exc_info.value is expected + stt.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_transcription_session_treats_bare_string_as_one_turn() -> None: + session = ScriptedTranscriptionSession("hello") + + turns = [turn async for turn in session.transcribe_turns()] + + assert turns == ["hello"] + session.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_transcription_session_stops_after_close() -> None: + session = ScriptedTranscriptionSession(["first", "second"]) + turns = session.transcribe_turns() + + assert await anext(turns) == "first" + await session.close() + + with pytest.raises(StopAsyncIteration): + await anext(turns) + with pytest.raises(UnconsumedVoiceSteps, match="1 scripted transcription turn"): + session.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_transcription_session_does_not_start_after_close() -> None: + session = ScriptedTranscriptionSession(["unconsumed"]) + await session.close() + + assert [turn async for turn in session.transcribe_turns()] == [] + with pytest.raises(UnconsumedVoiceSteps, match="1 scripted transcription turn"): + session.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_voice_components_surface_configured_errors() -> None: + stt = ScriptedSTTModel([RuntimeError("stt failed")]) + + with pytest.raises(RuntimeError, match="stt failed"): + await stt.transcribe( + AudioInput(np.zeros(2, dtype=np.int16)), + settings=stt_settings(), + trace_include_sensitive_data=True, + trace_include_sensitive_audio_data=True, + ) + + +@pytest.mark.asyncio +async def test_scripted_voice_components_snapshot_recorded_settings() -> None: + stt_settings_value = STTModelSettings( + language="en", + turn_detection={"type": "server_vad", "threshold": 0.5}, + ) + session_settings_value = STTModelSettings( + language="ja", + turn_detection={"type": "semantic_vad", "eagerness": "auto"}, + ) + tts_settings_value = TTSModelSettings(voice="alloy", buffer_size=20) + stt = ScriptedSTTModel(["hello"], sessions=["こんにちは"]) + tts = ScriptedTTSModel([TTSResult([])]) + + await stt.transcribe( + AudioInput(np.zeros(2, dtype=np.int16)), + settings=stt_settings_value, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + await stt.create_session( + StreamedAudioInput(), + settings=session_settings_value, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + async for _chunk in tts.run("hello", tts_settings_value): + pass + + stt_settings_value.language = "fr" + assert stt_settings_value.turn_detection is not None + stt_settings_value.turn_detection["threshold"] = 0.9 + session_settings_value.language = "ko" + assert session_settings_value.turn_detection is not None + session_settings_value.turn_detection["eagerness"] = "high" + tts_settings_value.voice = "nova" + tts_settings_value.buffer_size = 80 + + assert stt.calls[0].settings.language == "en" + assert stt.calls[0].settings.turn_detection == { + "type": "server_vad", + "threshold": 0.5, + } + assert stt.session_calls[0].settings.language == "ja" + assert stt.session_calls[0].settings.turn_detection == { + "type": "semantic_vad", + "eagerness": "auto", + } + assert tts.calls[0].settings.voice == "alloy" + assert tts.calls[0].settings.buffer_size == 20 + + +@pytest.mark.asyncio +async def test_scripted_voice_components_expose_detached_read_only_histories() -> None: + audio_input = AudioInput(np.array([1, 2], dtype=np.int16)) + streamed_input = StreamedAudioInput() + stt_settings_value = STTModelSettings( + language="en", + turn_detection={"type": "server_vad", "threshold": 0.5}, + ) + session_settings_value = STTModelSettings(language="ja") + tts_settings_value = TTSModelSettings(voice="alloy", buffer_size=20) + session = ScriptedTranscriptionSession() + stt = ScriptedSTTModel(["hello"], sessions=[session]) + tts = ScriptedTTSModel([TTSResult([])]) + workflow = ScriptedVoiceWorkflow([[]]) + + await stt.transcribe( + audio_input, + settings=stt_settings_value, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + created = await stt.create_session( + streamed_input, + settings=session_settings_value, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + async for _chunk in tts.run("hello", tts_settings_value): + pass + async for _fragment in workflow.run("transcript"): + pass + + audio_input.buffer[0] = 9 + stt_calls = stt.calls + stt_calls[0].input.buffer[1] = 9 + stt_calls[0].settings.language = "changed" + session_calls = stt.session_calls + session_calls[0].settings.language = "changed" + tts_calls = tts.calls + tts_calls[0].settings.voice = "nova" + + assert isinstance(stt_calls, tuple) + assert isinstance(session_calls, tuple) + assert isinstance(stt.created_sessions, tuple) + assert isinstance(tts_calls, tuple) + assert isinstance(workflow.transcriptions, tuple) + assert stt.calls[0].input.buffer.tolist() == [1, 2] + assert stt.calls[0].settings.language == "en" + assert stt.session_calls[0].input is streamed_input + assert stt.session_calls[0].settings.language == "ja" + assert stt.created_sessions[0] is session + assert created is session + assert tts.calls[0].settings.voice == "alloy" + assert workflow.transcriptions == ("transcript",) + + +@pytest.mark.asyncio +async def test_scripted_stt_snapshot_failure_has_no_side_effects() -> None: + expected = RuntimeError("audio snapshot failed") + + class UncopyableBuffer: + def copy(self) -> Any: + raise expected + + stt = ScriptedSTTModel(["unused"]) + audio_input = AudioInput(cast(Any, UncopyableBuffer())) + + with pytest.raises(RuntimeError, match="audio snapshot failed") as exc_info: + await stt.transcribe( + audio_input, + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + assert exc_info.value is expected + assert stt.calls == () + with pytest.raises(UnconsumedVoiceSteps) as unconsumed: + stt.assert_complete() + assert unconsumed.value.remaining_steps == 1 + + +@pytest.mark.asyncio +async def test_scripted_voice_settings_snapshot_failure_has_no_side_effects() -> None: + expected = RuntimeError("settings snapshot failed") + + class Uncopyable: + def __deepcopy__(self, _memo: dict[int, Any]) -> Any: + raise expected + + stt = ScriptedSTTModel(["unused"], sessions=["unused"]) + tts = ScriptedTTSModel([TTSResult([])]) + stt_settings_value = STTModelSettings(turn_detection={"sentinel": Uncopyable()}) + tts_settings_value = TTSModelSettings(transform_data=cast(Any, Uncopyable())) + + with pytest.raises(RuntimeError, match="settings snapshot failed"): + await stt.transcribe( + AudioInput(np.zeros(1, dtype=np.int16)), + settings=stt_settings_value, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + with pytest.raises(RuntimeError, match="settings snapshot failed"): + await stt.create_session( + StreamedAudioInput(), + settings=stt_settings_value, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + with pytest.raises(RuntimeError, match="settings snapshot failed"): + async for _chunk in tts.run("hello", tts_settings_value): + pass + + assert stt.calls == () + assert stt.session_calls == () + assert tts.calls == () + with pytest.raises(UnconsumedVoiceSteps) as stt_unconsumed: + stt.assert_complete() + with pytest.raises(UnconsumedVoiceSteps) as tts_unconsumed: + tts.assert_complete() + assert stt_unconsumed.value.remaining_steps == 2 + assert tts_unconsumed.value.remaining_steps == 1 + + +@pytest.mark.asyncio +async def test_scripted_voice_errors_identify_exhausted_operations() -> None: + stt = ScriptedSTTModel() + workflow = ScriptedVoiceWorkflow() + + with pytest.raises(UnexpectedVoiceCall) as static_error: + await stt.transcribe( + AudioInput(np.zeros(1, dtype=np.int16)), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + with pytest.raises(UnexpectedVoiceCall) as session_error: + await stt.create_session( + StreamedAudioInput(), + settings=stt_settings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + with pytest.raises(UnexpectedVoiceCall) as workflow_error: + async for _fragment in workflow.run("hello"): + pass + + assert static_error.value.operation == "static_transcription" + assert session_error.value.operation == "streamed_session" + assert workflow_error.value.operation == "workflow_turn" + + +@pytest.mark.asyncio +async def test_scripted_tts_rejects_unexpected_call() -> None: + tts = ScriptedTTSModel() + + with pytest.raises(UnexpectedVoiceCall, match="no scripted results remain") as exc_info: + async for _chunk in tts.run("hello", tts_settings()): + pass + + assert exc_info.value.operation == "tts" + + +@pytest.mark.asyncio +async def test_scripted_workflow_consumes_start_fragments_once() -> None: + workflow = ScriptedVoiceWorkflow(start=["hello", " world"]) + + assert [fragment async for fragment in workflow.on_start()] == ["hello", " world"] + workflow.assert_complete() + + with pytest.raises(UnexpectedVoiceCall, match="no scripted startup step remains") as exc_info: + async for _fragment in workflow.on_start(): + pass + + assert exc_info.value.operation == "workflow_start" + + +@pytest.mark.asyncio +async def test_scripted_workflow_treats_bare_start_string_as_one_fragment() -> None: + workflow = ScriptedVoiceWorkflow(start="hello") + + assert [fragment async for fragment in workflow.on_start()] == ["hello"] + workflow.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_workflow_tracks_explicit_empty_start_step() -> None: + workflow = ScriptedVoiceWorkflow(start=[]) + + with pytest.raises(UnconsumedVoiceSteps) as exc_info: + workflow.assert_complete() + assert exc_info.value.remaining_steps == 1 + + assert [fragment async for fragment in workflow.on_start()] == [] + workflow.assert_complete() + + with pytest.raises(UnexpectedVoiceCall) as repeated: + async for _fragment in workflow.on_start(): + pass + assert repeated.value.operation == "workflow_start" + + +@pytest.mark.asyncio +async def test_scripted_workflow_treats_bare_turn_string_as_one_fragment() -> None: + workflow = ScriptedVoiceWorkflow(["hello"]) + + assert [fragment async for fragment in workflow.run("transcript")] == ["hello"] + workflow.assert_complete() + + +@pytest.mark.asyncio +async def test_scripted_workflow_treats_direct_string_as_one_turn() -> None: + workflow = ScriptedVoiceWorkflow("hello") + + assert [fragment async for fragment in workflow.run("transcript")] == ["hello"] + workflow.assert_complete() + + +def test_scripted_workflow_reports_unconsumed_start_fragments() -> None: + workflow = ScriptedVoiceWorkflow(start=["unused"]) + + with pytest.raises(UnconsumedVoiceSteps, match="1 scripted workflow startup step") as exc_info: + workflow.assert_complete() + + assert exc_info.value.remaining_steps == 1 + + +def test_scripted_workflow_reports_all_unconsumed_steps() -> None: + workflow = ScriptedVoiceWorkflow([["unused"]], start=["unused"]) + + with pytest.raises(UnconsumedVoiceSteps, match="2 scripted workflow step") as exc_info: + workflow.assert_complete() + + assert exc_info.value.remaining_steps == 2 + + +@pytest.mark.asyncio +async def test_static_pipeline_leaves_configured_workflow_start_unconsumed() -> None: + workflow = ScriptedVoiceWorkflow([[]], start=["streamed-only greeting"]) + pipeline = VoicePipeline( + workflow=workflow, + stt_model=ScriptedSTTModel(["hello"]), + tts_model=ScriptedTTSModel(), + config={"tracing_disabled": True}, + ) + + result = await pipeline.run(AudioInput(np.zeros(2, dtype=np.int16))) + async for _event in result.stream(): + pass + + with pytest.raises(UnconsumedVoiceSteps, match="1 scripted workflow startup step"): + workflow.assert_complete() + + +def test_scripted_voice_components_report_unconsumed_steps() -> None: + workflow = ScriptedVoiceWorkflow([["unused"]]) + + with pytest.raises(UnconsumedVoiceSteps, match="1 scripted workflow turn") as exc_info: + workflow.assert_complete() + + assert exc_info.value.remaining_steps == 1 + + +def stt_settings() -> STTModelSettings: + return STTModelSettings() + + +def tts_settings() -> TTSModelSettings: + return TTSModelSettings() diff --git a/tests/voice/test_workflow.py b/tests/voice/test_workflow.py index 402c521280..af27007962 100644 --- a/tests/voice/test_workflow.py +++ b/tests/voice/test_workflow.py @@ -1,25 +1,13 @@ from __future__ import annotations import json -from collections.abc import AsyncIterator -from typing import Any import pytest from inline_snapshot import snapshot -from openai.types.responses import ResponseCompletedEvent -from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent -from agents import Agent, Model, ModelSettings, ModelTracing, Tool -from agents.agent_output import AgentOutputSchemaBase -from agents.handoffs import Handoff -from agents.items import ( - ModelResponse, - TResponseInputItem, - TResponseOutputItem, - TResponseStreamEvent, -) +from agents import Agent +from agents.testing import ScriptedModel -from ..fake_model import get_response_obj from ..test_responses import get_function_tool, get_function_tool_call, get_text_message try: @@ -29,79 +17,10 @@ pass -class FakeStreamingModel(Model): - def __init__(self): - self.turn_outputs: list[list[TResponseOutputItem]] = [] - - def set_next_output(self, output: list[TResponseOutputItem]): - self.turn_outputs.append(output) - - def add_multiple_turn_outputs(self, outputs: list[list[TResponseOutputItem]]): - self.turn_outputs.extend(outputs) - - def get_next_output(self) -> list[TResponseOutputItem]: - if not self.turn_outputs: - return [] - return self.turn_outputs.pop(0) - - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> ModelResponse: - raise NotImplementedError("Not implemented") - - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: Any | None, - ) -> AsyncIterator[TResponseStreamEvent]: - output = self.get_next_output() - for item in output: - if ( - item.type == "message" - and len(item.content) == 1 - and item.content[0].type == "output_text" - ): - yield ResponseTextDeltaEvent( - content_index=0, - delta=item.content[0].text, - type="response.output_text.delta", - output_index=0, - item_id=item.id, - sequence_number=0, - logprobs=[], - ) - - yield ResponseCompletedEvent( - type="response.completed", - response=get_response_obj(output), - sequence_number=1, - ) - - @pytest.mark.asyncio async def test_single_agent_workflow(monkeypatch) -> None: - model = FakeStreamingModel() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ # First turn: a message and a tool call [ @@ -164,7 +83,7 @@ async def test_single_agent_workflow(monkeypatch) -> None: ) assert workflow._current_agent == agent - model.set_next_output([get_text_message("done_2")]) + model.enqueue([get_text_message("done_2")]) # Run it again with a new transcription to make sure the input history is updated output = [] From fc461eebdfc5cddcc226b0ddd17d654201fcfb66 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 13 Aug 2026 12:09:43 +0900 Subject: [PATCH 299/473] feat: support OpenAI Python 3 and HTTPX2 (#4380) --- examples/realtime/twilio_sip/requirements.txt | 2 +- integration_tests/openai/test_retry.py | 4 +- .../packaging/test_mcp_compat.py | 3 +- pyproject.toml | 2 +- src/agents/_httpx_compat.py | 39 +++++++++ src/agents/mcp/_compat.py | 60 +++++++++----- src/agents/mcp/server.py | 45 +++++------ src/agents/models/_retry_runtime.py | 12 +-- src/agents/models/openai_provider.py | 12 +-- src/agents/models/openai_responses.py | 10 ++- src/agents/run_internal/model_retry.py | 30 ++++--- src/agents/tracing/processors.py | 10 +-- .../voice/models/openai_model_provider.py | 12 +-- tests/models/test_kwargs_functionality.py | 4 +- tests/models/test_model_retry.py | 20 ++--- tests/models/test_openai_chatcompletions.py | 26 +++--- .../test_openai_chatcompletions_stream.py | 8 +- tests/models/test_openai_responses.py | 81 +++++++++++++------ tests/models/test_openai_retry_helpers.py | 15 ++++ tests/test_config.py | 7 +- tests/test_trace_processor.py | 40 ++++----- tests/tracing/test_import_side_effects.py | 55 ++++++++++++- tests/voice/test_openai_model_provider.py | 7 +- uv.lock | 10 +-- 24 files changed, 345 insertions(+), 169 deletions(-) create mode 100644 src/agents/_httpx_compat.py diff --git a/examples/realtime/twilio_sip/requirements.txt b/examples/realtime/twilio_sip/requirements.txt index 943a72eb6c..913a327259 100644 --- a/examples/realtime/twilio_sip/requirements.txt +++ b/examples/realtime/twilio_sip/requirements.txt @@ -1,3 +1,3 @@ fastapi>=0.120.0 -openai>=2.2,<3 +openai>=3.0.0,<4 uvicorn[standard]>=0.38.0 diff --git a/integration_tests/openai/test_retry.py b/integration_tests/openai/test_retry.py index 85a0c1e480..5cb52424ed 100644 --- a/integration_tests/openai/test_retry.py +++ b/integration_tests/openai/test_retry.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any -import httpx +import httpx2 import pytest from openai import APIConnectionError, AsyncOpenAI @@ -37,7 +37,7 @@ async def fail_once(*args: Any, **kwargs: Any) -> Any: if attempts == 1: raise APIConnectionError( message="Controlled integration-test transport failure.", - request=httpx.Request("POST", "https://api.openai.com/v1/responses"), + request=httpx2.Request("POST", "https://api.openai.com/v1/responses"), ) return await original_fetch(*args, **kwargs) diff --git a/integration_tests/packaging/test_mcp_compat.py b/integration_tests/packaging/test_mcp_compat.py index cdf7dc2446..50ca59b358 100644 --- a/integration_tests/packaging/test_mcp_compat.py +++ b/integration_tests/packaging/test_mcp_compat.py @@ -6,7 +6,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import httpx import pytest from agents.mcp import MCPServerSse, MCPServerStdio, MCPServerStreamableHttp @@ -57,6 +56,8 @@ def test_packaged_client_uses_mcp_v1_sse_transport() -> None: def test_packaged_client_uses_mcp_v1_streamable_http_auth_and_factory() -> None: + import httpx + auth = httpx.BasicAuth("user", "pass") def factory(headers=None, timeout=None, auth=None): diff --git a/pyproject.toml b/pyproject.toml index 3faa0fd0af..d887efbdcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" license = "MIT" authors = [{ name = "OpenAI", email = "support@openai.com" }] dependencies = [ - "openai>=2.45.0,<3", + "openai>=3.0.0,<4", "pydantic>=2.12.2, <3", "griffelib>=2, <3", "typing-extensions>=4.12.2, <5", diff --git a/src/agents/_httpx_compat.py b/src/agents/_httpx_compat.py new file mode 100644 index 0000000000..00a4aba40f --- /dev/null +++ b/src/agents/_httpx_compat.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import sys +from functools import cache +from importlib import import_module +from types import ModuleType +from typing import Any, cast + + +@cache +def _load_legacy_httpx() -> ModuleType | None: + try: + return import_module("httpx") + except ModuleNotFoundError as exc: + if exc.name != "httpx": + raise + return None + + +def is_legacy_httpx_instance(value: Any, *type_names: str) -> bool: + legacy_httpx = sys.modules.get("httpx") + if not isinstance(legacy_httpx, ModuleType): + return False + types = tuple(cast(type[Any], getattr(legacy_httpx, name)) for name in type_names) + return isinstance(value, types) + + +def legacy_httpx_types(*type_names: str) -> tuple[type[Any], ...]: + legacy_httpx = _load_legacy_httpx() + if legacy_httpx is None: + return () + return tuple(cast(type[Any], getattr(legacy_httpx, name)) for name in type_names) + + +def require_legacy_httpx() -> ModuleType: + legacy_httpx = _load_legacy_httpx() + if legacy_httpx is None: # pragma: no cover - MCP v1 declares the dependency + raise ImportError("The installed integration requires the legacy httpx package.") + return legacy_httpx diff --git a/src/agents/mcp/_compat.py b/src/agents/mcp/_compat.py index 859d71f3aa..e264f816dc 100644 --- a/src/agents/mcp/_compat.py +++ b/src/agents/mcp/_compat.py @@ -1,13 +1,14 @@ from __future__ import annotations +from functools import cache from importlib import import_module from importlib.metadata import version -from types import ModuleType from typing import Any, cast -import httpx from pydantic import AnyUrl +from .._httpx_compat import legacy_httpx_types, require_legacy_httpx + def _major_version(distribution: str) -> int: raw_version = version(distribution) @@ -26,26 +27,41 @@ def _major_version(distribution: str) -> int: vars(_mcp_exceptions).get("MCPError") or vars(_mcp_exceptions)["McpError"], ) -MCP_HTTPX: ModuleType = import_module("httpx2") if MCP_V2 else httpx -HTTP_STATUS_ERROR_TYPES: tuple[type[Exception], ...] = tuple( - dict.fromkeys((httpx.HTTPStatusError, cast(type[Exception], MCP_HTTPX.HTTPStatusError))) -) -HTTP_REQUEST_ERROR_TYPES: tuple[type[Exception], ...] = tuple( - dict.fromkeys((httpx.RequestError, cast(type[Exception], MCP_HTTPX.RequestError))) -) -HTTP_CONNECT_ERROR_TYPES: tuple[type[Exception], ...] = tuple( - dict.fromkeys((httpx.ConnectError, cast(type[Exception], MCP_HTTPX.ConnectError))) -) -HTTP_TIMEOUT_ERROR_TYPES: tuple[type[Exception], ...] = tuple( - dict.fromkeys((httpx.TimeoutException, cast(type[Exception], MCP_HTTPX.TimeoutException))) -) -HTTP_ERROR_TYPES: tuple[type[Exception], ...] = tuple( - dict.fromkeys((httpx.HTTPError, cast(type[Exception], MCP_HTTPX.HTTPError))) -) -HTTP_INVALID_URL_TYPES: tuple[type[Exception], ...] = tuple( - dict.fromkeys((httpx.InvalidURL, cast(type[Exception], MCP_HTTPX.InvalidURL))) -) +MCP_HTTPX = import_module("httpx2") if MCP_V2 else require_legacy_httpx() + + +def _http_error_types(name: str) -> tuple[type[Exception], ...]: + return (cast(type[Exception], getattr(MCP_HTTPX, name)),) + + +HTTP_STATUS_ERROR_TYPES = _http_error_types("HTTPStatusError") +HTTP_REQUEST_ERROR_TYPES = _http_error_types("RequestError") +HTTP_CONNECT_ERROR_TYPES = _http_error_types("ConnectError") +HTTP_TIMEOUT_ERROR_TYPES = _http_error_types("TimeoutException") +HTTP_ERROR_TYPES = _http_error_types("HTTPError") +HTTP_INVALID_URL_TYPES = _http_error_types("InvalidURL") + + +@cache +def enable_legacy_httpx_compat() -> None: + global HTTP_STATUS_ERROR_TYPES + global HTTP_REQUEST_ERROR_TYPES + global HTTP_CONNECT_ERROR_TYPES + global HTTP_TIMEOUT_ERROR_TYPES + global HTTP_ERROR_TYPES + global HTTP_INVALID_URL_TYPES + + def with_legacy(current: tuple[type[Exception], ...], name: str) -> tuple[type[Exception], ...]: + legacy = cast(tuple[type[Exception], ...], legacy_httpx_types(name)) + return tuple(dict.fromkeys((*current, *legacy))) + + HTTP_STATUS_ERROR_TYPES = with_legacy(HTTP_STATUS_ERROR_TYPES, "HTTPStatusError") + HTTP_REQUEST_ERROR_TYPES = with_legacy(HTTP_REQUEST_ERROR_TYPES, "RequestError") + HTTP_CONNECT_ERROR_TYPES = with_legacy(HTTP_CONNECT_ERROR_TYPES, "ConnectError") + HTTP_TIMEOUT_ERROR_TYPES = with_legacy(HTTP_TIMEOUT_ERROR_TYPES, "TimeoutException") + HTTP_ERROR_TYPES = with_legacy(HTTP_ERROR_TYPES, "HTTPError") + HTTP_INVALID_URL_TYPES = with_legacy(HTTP_INVALID_URL_TYPES, "InvalidURL") def create_v2_client( @@ -133,7 +149,7 @@ def mcp_error_message(error: BaseException) -> str: def mcp_request_timeout_code() -> int: - return -32001 if MCP_V2 else int(httpx.codes.REQUEST_TIMEOUT) + return -32001 if MCP_V2 else int(MCP_HTTPX.codes.REQUEST_TIMEOUT) def is_mcp_timeout_error(error: BaseException) -> bool: diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 4ce05c9502..053159c470 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeVar, Union, cast import anyio -import httpx if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup # pyright: ignore[reportMissingImports] @@ -46,13 +45,8 @@ from ..run_context import RunContextWrapper from ..tool import ToolErrorFunction from ..util._types import MaybeAwaitable +from . import _compat as mcp_compat from ._compat import ( - HTTP_CONNECT_ERROR_TYPES, - HTTP_ERROR_TYPES, - HTTP_INVALID_URL_TYPES, - HTTP_REQUEST_ERROR_TYPES, - HTTP_STATUS_ERROR_TYPES, - HTTP_TIMEOUT_ERROR_TYPES, MCP_HTTPX, MCP_V2, MCPError, @@ -202,7 +196,7 @@ def _transport_error_urls_are_safe( if redirect_location is not None: try: request_urls.append(str(response_url.join(redirect_location))) - except HTTP_INVALID_URL_TYPES + (ValueError,): + except mcp_compat.HTTP_INVALID_URL_TYPES + (ValueError,): return False return all(get_mcp_server_log_name(url) == url for url in request_urls) @@ -325,7 +319,7 @@ def _create_default_streamable_http_client( kwargs["headers"] = headers if auth is not None: kwargs["auth"] = auth - return httpx.AsyncClient(**kwargs) + return MCP_HTTPX.AsyncClient(**kwargs) def _validate_v2_http_auth(auth: Any) -> None: @@ -435,7 +429,7 @@ async def _handle_post_request(self, ctx: Any) -> None: try: await super()._handle_post_request(ctx) - except HTTP_ERROR_TYPES as exc: + except mcp_compat.HTTP_ERROR_TYPES as exc: _log_transport_warning( "Ignoring initialized notification HTTP failure", exc, @@ -453,7 +447,7 @@ async def _streamablehttp_client_with_transport( sse_read_timeout: float | timedelta = 60 * 5, terminate_on_close: bool = True, httpx_client_factory: HttpClientFactory = _create_default_streamable_http_client, - auth: httpx.Auth | None = None, + auth: Any = None, transport_factory: Callable[[str], Any] = StreamableHTTPTransport, ) -> AsyncGenerator[MCPStreamTransport, None]: timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout @@ -465,7 +459,7 @@ async def _streamablehttp_client_with_transport( client = httpx_client_factory( headers=headers, - timeout=httpx.Timeout(timeout_seconds, read=sse_read_timeout_seconds), + timeout=MCP_HTTPX.Timeout(timeout_seconds, read=sse_read_timeout_seconds), auth=auth, ) transport = transport_factory(url) @@ -916,6 +910,7 @@ def __init__( retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between retries. Defaults to `None`, which leaves exponential backoff uncapped. """ + mcp_compat.enable_legacy_httpx_compat() super().__init__( use_structured_content=use_structured_content, require_approval=require_approval, @@ -1104,9 +1099,9 @@ def _select_cleanup_transport_error(self, error: BaseException) -> Exception | N candidates = error.exceptions if isinstance(error, BaseExceptionGroup) else (error,) for error_types in ( - HTTP_STATUS_ERROR_TYPES, - HTTP_CONNECT_ERROR_TYPES, - HTTP_TIMEOUT_ERROR_TYPES, + mcp_compat.HTTP_STATUS_ERROR_TYPES, + mcp_compat.HTTP_CONNECT_ERROR_TYPES, + mcp_compat.HTTP_TIMEOUT_ERROR_TYPES, ): selected_http_error = next( ( @@ -1179,7 +1174,9 @@ async def _run_request_with_transport_error_redaction( base_error_group: BaseExceptionGroup | None = None try: return await func() - except HTTP_STATUS_ERROR_TYPES + HTTP_REQUEST_ERROR_TYPES as http_error: + except ( + mcp_compat.HTTP_STATUS_ERROR_TYPES + mcp_compat.HTTP_REQUEST_ERROR_TYPES + ) as http_error: transport_error = self._user_error_for_request_operation(operation, http_error) except BaseExceptionGroup as error_group: http_errors = self._extract_http_errors_from_exception(error_group) @@ -1478,14 +1475,14 @@ async def fetch_pages() -> bool: if self.tool_filter is not None: filtered_tools = await self._apply_tool_filter(filtered_tools, run_context, agent) return filtered_tools - except HTTP_STATUS_ERROR_TYPES as e: + except mcp_compat.HTTP_STATUS_ERROR_TYPES as e: status_code = http_status_code(e) transport_error = UserError( f"Failed to list tools from MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except HTTP_REQUEST_ERROR_TYPES as e: + except mcp_compat.HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) if transport_cause is not None and not is_http_connect_error(e): raise @@ -1534,14 +1531,14 @@ async def call_tool( lambda: cast(Any, session).call_tool(tool_name, arguments, meta=meta) ) ) - except HTTP_STATUS_ERROR_TYPES as e: + except mcp_compat.HTTP_STATUS_ERROR_TYPES as e: status_code = http_status_code(e) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except HTTP_REQUEST_ERROR_TYPES as e: + except mcp_compat.HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) if transport_cause is not None and not is_http_connect_error(e): raise @@ -1746,8 +1743,8 @@ async def cleanup(self): raise except ( # type: ignore[misc] BaseExceptionGroup, - *HTTP_STATUS_ERROR_TYPES, - *HTTP_REQUEST_ERROR_TYPES, + *mcp_compat.HTTP_STATUS_ERROR_TYPES, + *mcp_compat.HTTP_REQUEST_ERROR_TYPES, ) as e: selected_http_error = self._select_cleanup_transport_error(e) if selected_http_error is not None: @@ -2446,14 +2443,14 @@ async def call_tool( backoffs_taken += 1 await asyncio.sleep(backoff) first_attempt = False - except HTTP_STATUS_ERROR_TYPES as e: + except mcp_compat.HTTP_STATUS_ERROR_TYPES as e: status_code = http_status_code(e) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except HTTP_REQUEST_ERROR_TYPES as e: + except mcp_compat.HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) if transport_cause is not None and not is_http_connect_error(e): raise diff --git a/src/agents/models/_retry_runtime.py b/src/agents/models/_retry_runtime.py index 2661c06e6d..718931f403 100644 --- a/src/agents/models/_retry_runtime.py +++ b/src/agents/models/_retry_runtime.py @@ -5,11 +5,13 @@ from contextlib import contextmanager from contextvars import ContextVar from email.utils import parsedate_to_datetime -from typing import Any +from typing import Any, cast -import httpx +import httpx2 from openai import APIStatusError +from .._httpx_compat import is_legacy_httpx_instance + def iter_error_chain(error: Exception) -> Iterator[Exception]: current: Exception | None = error @@ -23,7 +25,7 @@ def iter_error_chain(error: Exception) -> Iterator[Exception]: def header_lookup(headers: Any, key: str) -> str | None: normalized_key = key.lower() - if isinstance(headers, httpx.Headers): + if isinstance(headers, httpx2.Headers): value = headers.get(key) return value if isinstance(value, str) else None if isinstance(headers, Mapping): @@ -35,8 +37,8 @@ def header_lookup(headers: Any, key: str) -> str | None: def _get_candidate_header(candidate: Exception, key: str) -> str | None: response = getattr(candidate, "response", None) - if isinstance(response, httpx.Response): - header_value = header_lookup(response.headers, key) + if isinstance(response, httpx2.Response) or is_legacy_httpx_instance(response, "Response"): + header_value = header_lookup(cast(Any, response).headers, key) if header_value is not None: return header_value diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index 703be44ee2..acb944b566 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -5,8 +5,8 @@ import weakref from typing import Any -import httpx -from openai import AsyncOpenAI, DefaultAsyncHttpxClient +import httpx2 +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client from ..exceptions import UserError from . import _openai_shared @@ -28,17 +28,17 @@ DEFAULT_MODEL: str = "gpt-4o" -_http_client: httpx.AsyncClient | None = None +_http_client: httpx2.AsyncClient | None = None _WSModelCacheKey = tuple[str, bool] _WSLoopModelCache = dict[_WSModelCacheKey, Model] -# If we create a new httpx client for each request, that would mean no sharing of connection pools, +# If we create a new HTTP client for each request, that would mean no sharing of connection pools, # which would mean worse latency and resource usage. So, we share the client across requests. -def shared_http_client() -> httpx.AsyncClient: +def shared_http_client() -> httpx2.AsyncClient: global _http_client if _http_client is None: - _http_client = DefaultAsyncHttpxClient() + _http_client = DefaultAsyncHttpx2Client() return _http_client diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 1f5e41e9ab..4289d032e8 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -19,7 +19,7 @@ overload, ) -import httpx +import httpx2 from openai import AsyncOpenAI, NotGiven, Omit, omit from openai.types import ChatModel from openai.types.responses import ( @@ -41,6 +41,7 @@ from typing_extensions import NotRequired from .. import _debug +from .._httpx_compat import is_legacy_httpx_instance from .._tool_identity import ( get_explicit_function_tool_namespace, get_function_tool_namespace_description, @@ -1356,7 +1357,7 @@ def _get_websocket_request_timeouts(self, timeout: Any) -> _WebsocketRequestTime if timeout is None or _is_openai_omitted_value(timeout): return _WebsocketRequestTimeouts(lock=None, connect=None, send=None, recv=None) - if isinstance(timeout, httpx.Timeout): + if isinstance(timeout, httpx2.Timeout) or is_legacy_httpx_instance(timeout, "Timeout"): return _WebsocketRequestTimeouts( lock=None if timeout.pool is None else float(timeout.pool), connect=None if timeout.connect is None else float(timeout.connect), @@ -1478,7 +1479,10 @@ def _merge_websocket_headers(self, extra_headers: Mapping[str, Any]) -> dict[str def _prepare_websocket_url(self, extra_query: Any) -> str: if self._client.websocket_base_url is not None: - base_url = httpx.URL(self._client.websocket_base_url) + websocket_base_url = self._client.websocket_base_url + if is_legacy_httpx_instance(websocket_base_url, "URL"): + websocket_base_url = str(websocket_base_url) + base_url = httpx2.URL(websocket_base_url) ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) base_url = base_url.copy_with(scheme=ws_scheme) else: diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index d01b327d59..5452cdada2 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -6,9 +6,10 @@ from inspect import isawaitable from typing import Any -import httpx +import httpx2 from openai import APIConnectionError, APITimeoutError, BadRequestError +from .._httpx_compat import is_legacy_httpx_instance from ..items import ModelResponse, TResponseStreamEvent from ..logger import log_model_action_debug, logger from ..models._retry_runtime import ( @@ -45,6 +46,20 @@ DEFAULT_BACKOFF_JITTER = True COMPATIBILITY_CONVERSATION_LOCKED_RETRIES = 3 _RETRY_SAFE_STREAM_EVENT_TYPES = frozenset({"response.created", "response.in_progress"}) +_NETWORK_ERROR_TYPES = ( + httpx2.ConnectError, + httpx2.ReadError, + httpx2.RemoteProtocolError, + httpx2.TimeoutException, + httpx2.WriteError, +) +_LEGACY_NETWORK_ERROR_TYPE_NAMES = ( + "ConnectError", + "ReadError", + "RemoteProtocolError", + "TimeoutException", + "WriteError", +) def _is_conversation_locked_error(error: Exception) -> bool: @@ -70,18 +85,13 @@ def _is_network_like_error(error: Exception) -> bool: if isinstance(error, APIConnectionError | APITimeoutError | TimeoutError): return True - network_error_types = ( - httpx.ConnectError, - httpx.ReadError, - httpx.RemoteProtocolError, - httpx.TimeoutException, - httpx.WriteError, - ) - if isinstance(error, network_error_types): + if isinstance(error, _NETWORK_ERROR_TYPES): return True for candidate in _iter_error_chain(error): - if isinstance(candidate, network_error_types): + if isinstance(candidate, _NETWORK_ERROR_TYPES): + return True + if is_legacy_httpx_instance(candidate, *_LEGACY_NETWORK_ERROR_TYPE_NAMES): return True if candidate.__class__.__module__.startswith( "websockets" diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 4545d2e176..b61f3e7976 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -11,7 +11,7 @@ from functools import cached_property from typing import Any, cast -import httpx +import httpx2 from .. import _debug from ..logger import ( @@ -87,7 +87,7 @@ def __init__( self._shutdown_event = threading.Event() # Keep a client open for connection pooling across multiple export calls - self._client = httpx.Client(timeout=httpx.Timeout(timeout=60, connect=5.0)) + self._client = httpx2.Client(timeout=httpx2.Timeout(timeout=60, connect=5.0)) def set_api_key(self, api_key: str): """Set the OpenAI API key for the exporter. @@ -201,7 +201,7 @@ def _export_with_deadline(self, items: list[Trace | Span[Any]], deadline: float logger.warning( "[non-fatal] Tracing: server error %s, retrying.", response.status_code ) - except httpx.RequestError as exc: + except httpx2.RequestError as exc: # Network or other I/O error, we'll retry log_model_and_tool_action_warning( logger, "[non-fatal] Tracing request failed", exc @@ -220,7 +220,7 @@ def _export_with_deadline(self, items: list[Trace | Span[Any]], deadline: float break delay = min(delay * 2, self.max_delay) - def _timeout_for_deadline(self, deadline: float | None) -> httpx.Timeout | None: + def _timeout_for_deadline(self, deadline: float | None) -> httpx2.Timeout | None: if deadline is None: return None @@ -229,7 +229,7 @@ def _timeout_for_deadline(self, deadline: float | None) -> httpx.Timeout | None: return None connect_timeout = min(5.0, remaining) - return httpx.Timeout(remaining, connect=connect_timeout) + return httpx2.Timeout(remaining, connect=connect_timeout) def _sleep_before_retry(self, sleep_time: float, deadline: float | None) -> bool: if deadline is None: diff --git a/src/agents/voice/models/openai_model_provider.py b/src/agents/voice/models/openai_model_provider.py index e58ebe9d66..17cd1dc129 100644 --- a/src/agents/voice/models/openai_model_provider.py +++ b/src/agents/voice/models/openai_model_provider.py @@ -2,8 +2,8 @@ from typing import Any -import httpx -from openai import AsyncOpenAI, DefaultAsyncHttpxClient +import httpx2 +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client from ...exceptions import UserError from ...models import _openai_shared @@ -16,15 +16,15 @@ from .openai_stt import OpenAISTTModel from .openai_tts import OpenAITTSModel -_http_client: httpx.AsyncClient | None = None +_http_client: httpx2.AsyncClient | None = None -# If we create a new httpx client for each request, that would mean no sharing of connection pools, +# If we create a new HTTP client for each request, that would mean no sharing of connection pools, # which would mean worse latency and resource usage. So, we share the client across requests. -def shared_http_client() -> httpx.AsyncClient: +def shared_http_client() -> httpx2.AsyncClient: global _http_client if _http_client is None: - _http_client = DefaultAsyncHttpxClient() + _http_client = DefaultAsyncHttpx2Client() return _http_client diff --git a/tests/models/test_kwargs_functionality.py b/tests/models/test_kwargs_functionality.py index 7c5438adf4..5a0981d2c3 100644 --- a/tests/models/test_kwargs_functionality.py +++ b/tests/models/test_kwargs_functionality.py @@ -1,6 +1,6 @@ from typing import Any -import httpx +import httpx2 import litellm import pytest from httpx import Headers, Response @@ -441,7 +441,7 @@ def test_litellm_get_retry_advice_keeps_stateful_transport_failures_ambiguous() model = LitellmModel(model="test-model") error = APIConnectionError( message="connection error", - request=httpx.Request("POST", "https://api.openai.com/v1/responses"), + request=httpx2.Request("POST", "https://api.openai.com/v1/responses"), ) advice = model.get_retry_advice( diff --git a/tests/models/test_model_retry.py b/tests/models/test_model_retry.py index a17e98c968..30efa2bb04 100644 --- a/tests/models/test_model_retry.py +++ b/tests/models/test_model_retry.py @@ -4,7 +4,7 @@ from collections.abc import AsyncIterator from typing import Any, cast -import httpx +import httpx2 import pytest from openai import APIConnectionError, APIStatusError, BadRequestError from pydantic import ValidationError @@ -80,13 +80,13 @@ def __call__(self, _context: RetryPolicyContext) -> bool: def _connection_error(message: str = "connection error") -> APIConnectionError: return APIConnectionError( message=message, - request=httpx.Request("POST", "https://example.com"), + request=httpx2.Request("POST", "https://example.com"), ) def _conversation_locked_error() -> BadRequestError: - request = httpx.Request("POST", "https://example.com") - response = httpx.Response( + request = httpx2.Request("POST", "https://example.com") + response = httpx2.Response( 400, request=request, json={"error": {"code": "conversation_locked", "message": "locked"}}, @@ -101,8 +101,8 @@ def _conversation_locked_error() -> BadRequestError: def _status_error(status_code: int, code: str = "server_error") -> APIStatusError: - request = httpx.Request("POST", "https://example.com") - response = httpx.Response( + request = httpx2.Request("POST", "https://example.com") + response = httpx2.Response( status_code, request=request, json={"error": {"code": code, "message": code}}, @@ -117,8 +117,8 @@ def _status_error(status_code: int, code: str = "server_error") -> APIStatusErro def _status_error_without_code(status_code: int, body_code: str = "server_error") -> APIStatusError: - request = httpx.Request("POST", "https://example.com") - response = httpx.Response( + request = httpx2.Request("POST", "https://example.com") + response = httpx2.Response( status_code, request=request, json={"error": {"code": body_code, "message": body_code}}, @@ -778,8 +778,8 @@ async def rewind() -> None: async def get_response() -> ModelResponse: nonlocal calls calls += 1 - request = httpx.Request("POST", "https://example.com") - response = httpx.Response( + request = httpx2.Request("POST", "https://example.com") + response = httpx2.Response( 429, request=request, headers={"retry-after-ms": "1250"}, diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 6120aa9cfa..b8198e6ec5 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -4,7 +4,7 @@ from collections.abc import AsyncIterator from typing import Any, cast -import httpx +import httpx2 import pytest from openai import APIConnectionError, APIStatusError, AsyncOpenAI, omit from openai._models import add_request_id @@ -101,7 +101,7 @@ async def create(self, **kwargs: Any) -> Any: class DummyClient: def __init__(self, completions: DummyCompletions) -> None: self.chat = type("_Chat", (), {"completions": completions})() - self.base_url = httpx.URL("https://custom.example.test/v1/") + self.base_url = httpx2.URL("https://custom.example.test/v1/") completions = DummyCompletions() model = OpenAIChatCompletionsModel( @@ -588,7 +588,7 @@ async def create(self, **kwargs: Any) -> Any: class DummyClient: def __init__(self) -> None: self.chat = type("_Chat", (), {"completions": DummyCompletions()})() - self.base_url = httpx.URL("http://fake") + self.base_url = httpx2.URL("http://fake") model = OpenAIChatCompletionsModel( model="gpt-4", @@ -639,7 +639,7 @@ class DummyClient: def __init__(self) -> None: self.completions = DummyCompletions() self.chat = type("_Chat", (), {"completions": self.completions})() - self.base_url = httpx.URL("http://fake") + self.base_url = httpx2.URL("http://fake") client = DummyClient() model = OpenAIChatCompletionsModel( @@ -899,7 +899,7 @@ async def patched_fetch_response(self, *args, **kwargs): def test_get_client_disables_provider_managed_retries_on_runner_retry() -> None: class DummyChatCompletionsClient: def __init__(self) -> None: - self.base_url = httpx.URL("https://api.openai.com/v1/") + self.base_url = httpx2.URL("https://api.openai.com/v1/") self.chat = type("ChatNamespace", (), {"completions": object()})() self.with_options_calls: list[dict[str, Any]] = [] @@ -974,7 +974,7 @@ async def create(self, **kwargs: Any) -> Any: class DummyClient: def __init__(self, completions: DummyCompletions) -> None: self.chat = type("_Chat", (), {"completions": completions})() - self.base_url = httpx.URL("http://fake") + self.base_url = httpx2.URL("http://fake") msg = ChatCompletionMessage(role="assistant", content="ignored") choice = Choice(index=0, finish_reason="stop", message=msg) @@ -1163,7 +1163,7 @@ async def create(self, **kwargs: Any) -> Any: class DummyClient: def __init__(self, completions: DummyCompletions) -> None: self.chat = type("_Chat", (), {"completions": completions})() - self.base_url = httpx.URL("https://api.openai.com/v1/") + self.base_url = httpx2.URL("https://api.openai.com/v1/") msg = ChatCompletionMessage(role="assistant", content="ok") choice = Choice(index=0, finish_reason="stop", message=msg) @@ -1251,7 +1251,7 @@ async def create(self, **kwargs: Any) -> Any: class DummyClient: def __init__(self, completions: DummyCompletions) -> None: self.chat = type("_Chat", (), {"completions": completions})() - self.base_url = httpx.URL("http://fake") + self.base_url = httpx2.URL("http://fake") completions = DummyCompletions() dummy_client = DummyClient(completions) @@ -1313,8 +1313,8 @@ def test_clean_gemini_tool_call_id_removes_thought_suffix() -> None: def test_get_retry_advice_uses_openai_headers() -> None: - request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions") - response = httpx.Response( + request = httpx2.Request("POST", "https://api.openai.com/v1/chat/completions") + response = httpx2.Response( 429, request=request, headers={ @@ -1351,7 +1351,7 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None: model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=cast(Any, object())) error = APIConnectionError( message="connection error", - request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"), + request=httpx2.Request("POST", "https://api.openai.com/v1/chat/completions"), ) advice = model.get_retry_advice( @@ -1371,8 +1371,8 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None: def test_get_retry_advice_marks_stateful_http_failures_replay_safe() -> None: - request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions") - response = httpx.Response( + request = httpx2.Request("POST", "https://api.openai.com/v1/chat/completions") + response = httpx2.Response( 429, request=request, json={"error": {"code": "rate_limit"}}, diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 93c08717af..af95339905 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -3,7 +3,7 @@ from collections.abc import AsyncIterator from typing import Any, cast -import httpx +import httpx2 import pytest from openai.types.chat.chat_completion import ChatCompletion, Choice as ChatCompletionChoice from openai.types.chat.chat_completion_chunk import ( @@ -178,7 +178,7 @@ async def create(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: class DummyClient: def __init__(self, completions: DummyCompletions) -> None: self.chat = type("_Chat", (), {"completions": completions})() - self.base_url = httpx.URL("https://api.openai.com/v1/") + self.base_url = httpx2.URL("https://api.openai.com/v1/") completions = DummyCompletions() model = OpenAIChatCompletionsModel( @@ -3713,10 +3713,10 @@ class FakeStream: """Mimics `openai.AsyncStream`, which exposes the raw HTTP response.""" def __init__(self) -> None: - self.response = httpx.Response( + self.response = httpx2.Response( 200, headers={"x-request-id": "req_streamed_456"}, - request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"), + request=httpx2.Request("POST", "https://api.openai.com/v1/chat/completions"), ) def __aiter__(self) -> AsyncIterator[ChatCompletionChunk]: diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 0d17eb1746..8ce1c40a0a 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from typing import Any, cast -import httpx +import httpx2 import pytest from openai import NOT_GIVEN, APIConnectionError, AsyncOpenAI, RateLimitError, omit from openai.types.responses import ResponseCompletedEvent, ResponseErrorEvent @@ -59,7 +59,7 @@ async def create(self, **kwargs: Any) -> Any: class DummyResponsesClient: def __init__(self, responses: DummyResponses) -> None: self.responses = responses - self.base_url = httpx.URL("https://custom.example.test/v1/") + self.base_url = httpx2.URL("https://custom.example.test/v1/") responses = DummyResponses() model = OpenAIResponsesModel( @@ -75,19 +75,19 @@ def __init__(self, responses: DummyResponses) -> None: async def _run_responses_model_with_official_client( model_settings: ModelSettings | None = None, -) -> list[httpx.Request]: - requests: list[httpx.Request] = [] +) -> list[httpx2.Request]: + requests: list[httpx2.Request] = [] - async def handler(request: httpx.Request) -> httpx.Response: + async def handler(request: httpx2.Request) -> httpx2.Response: requests.append(request) - return httpx.Response( + return httpx2.Response( 200, content=get_response_obj([]).model_dump_json(), headers={"content-type": "application/json"}, request=request, ) - http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) try: client = AsyncOpenAI( api_key="test-key", @@ -134,7 +134,7 @@ async def close(self) -> None: class DummyWSClient: def __init__(self): - self.base_url = httpx.URL("https://api.openai.com/v1/") + self.base_url = httpx2.URL("https://api.openai.com/v1/") self.websocket_base_url = None self.default_query: dict[str, Any] = {} self.auth_headers = {"Authorization": "Bearer test-key"} @@ -2913,7 +2913,7 @@ async def fake_open( @pytest.mark.allow_call_model_methods def test_websocket_model_prepare_websocket_url_preserves_non_tls_scheme_mapping(): client = DummyWSClient() - client.base_url = httpx.URL("http://127.0.0.1:8080/v1/") + client.base_url = httpx2.URL("http://127.0.0.1:8080/v1/") model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] ws_url = model._prepare_websocket_url(extra_query=None) @@ -2928,12 +2928,28 @@ def test_websocket_model_prepare_websocket_url_appends_path_with_existing_query( model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"}) - parsed = httpx.URL(ws_url) + parsed = httpx2.URL(ws_url) assert parsed.path == "/v1/responses" assert dict(parsed.params) == {"token": "abc", "route": "team-a"} +@pytest.mark.allow_call_model_methods +def test_websocket_model_prepare_websocket_url_accepts_legacy_httpx_url(): + import httpx + + client = DummyWSClient() + client.websocket_base_url = httpx.URL("https://proxy.example.test/v1?token=abc") + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] + + ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"}) + parsed = httpx2.URL(ws_url) + + assert parsed.scheme == "wss" + assert parsed.path == "/v1/responses" + assert dict(parsed.params) == {"token": "abc", "route": "team-a"} + + @pytest.mark.allow_call_model_methods @pytest.mark.parametrize( ("configured_ws_base_url", "expected_scheme"), @@ -2950,7 +2966,7 @@ def test_websocket_model_prepare_websocket_url_normalizes_explicit_http_schemes( model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"}) - parsed = httpx.URL(ws_url) + parsed = httpx2.URL(ws_url) assert parsed.scheme == expected_scheme assert parsed.path == "/v1/responses" @@ -2967,7 +2983,7 @@ def test_websocket_model_prepare_websocket_url_treats_top_level_omit_sentinels_a model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] ws_url = model._prepare_websocket_url(extra_query=extra_query) - parsed = httpx.URL(ws_url) + parsed = httpx2.URL(ws_url) assert parsed.path == "/v1/responses" assert dict(parsed.params) == {"token": "abc"} @@ -2981,7 +2997,7 @@ def test_websocket_model_prepare_websocket_url_skips_not_given_query_values(): model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] ws_url = model._prepare_websocket_url(extra_query={"tenant": NOT_GIVEN, "region": "us"}) - parsed = httpx.URL(ws_url) + parsed = httpx2.URL(ws_url) assert parsed.path == "/v1/responses" assert dict(parsed.params) == {"token": "abc", "route": "team-a", "region": "us"} @@ -3264,7 +3280,7 @@ async def test_websocket_model_get_response_allows_zero_pool_timeout_when_lock_u monkeypatch, ): client = DummyWSClient() - client.timeout = httpx.Timeout(connect=1.0, read=1.0, write=1.0, pool=0.0) + client.timeout = httpx2.Timeout(connect=1.0, read=1.0, write=1.0, pool=0.0) model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] ws = DummyWSConnection([_response_completed_frame("resp-zero-pool", 1)]) @@ -3289,6 +3305,23 @@ async def fake_open( assert len(ws.sent_messages) == 1 +@pytest.mark.allow_call_model_methods +def test_websocket_model_request_timeouts_accept_legacy_httpx_timeout(): + import httpx + + client = DummyWSClient() + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] + + timeouts = model._get_websocket_request_timeouts( + httpx.Timeout(connect=1.0, read=2.0, write=3.0, pool=4.0) + ) + + assert timeouts.lock == 4.0 + assert timeouts.connect == 1.0 + assert timeouts.send == 3.0 + assert timeouts.recv == 2.0 + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_websocket_model_get_response_allows_zero_timeout_when_ws_ops_are_immediate( @@ -3325,7 +3358,7 @@ async def test_websocket_model_get_response_uses_client_default_timeout_when_no_ monkeypatch, ): client = DummyWSClient() - client.timeout = httpx.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0) + client.timeout = httpx2.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0) model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] class SlowRecvWSConnection(DummyWSConnection): @@ -3362,7 +3395,7 @@ async def test_websocket_model_get_response_uses_client_default_timeout_when_ove monkeypatch, ): client = DummyWSClient() - client.timeout = httpx.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0) + client.timeout = httpx2.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0) model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] class SlowRecvWSConnection(DummyWSConnection): @@ -3505,7 +3538,7 @@ def test_websocket_model_prepare_websocket_url_includes_client_default_query(): ws_url = model._prepare_websocket_url( extra_query={"route": "team-a", "api-version": "2026-01-01-preview"} ) - parsed = httpx.URL(ws_url) + parsed = httpx2.URL(ws_url) assert parsed.path == "/v1/responses" assert dict(parsed.params) == { @@ -3523,7 +3556,7 @@ def test_websocket_model_prepare_websocket_url_omit_removes_inherited_query_para model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] ws_url = model._prepare_websocket_url(extra_query={"token": omit, "route": omit, "keep": "1"}) - parsed = httpx.URL(ws_url) + parsed = httpx2.URL(ws_url) assert parsed.path == "/v1/responses" assert dict(parsed.params) == {"region": "us", "keep": "1"} @@ -3685,8 +3718,8 @@ async def fake_connect(*args: Any, **kwargs: Any) -> object: @pytest.mark.allow_call_model_methods def test_get_retry_advice_uses_openai_headers() -> None: - request = httpx.Request("POST", "https://api.openai.com/v1/responses") - response = httpx.Response( + request = httpx2.Request("POST", "https://api.openai.com/v1/responses") + response = httpx2.Response( 429, request=request, headers={ @@ -3724,7 +3757,7 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None: model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object())) error = APIConnectionError( message="connection error", - request=httpx.Request("POST", "https://api.openai.com/v1/responses"), + request=httpx2.Request("POST", "https://api.openai.com/v1/responses"), ) advice = model.get_retry_advice( @@ -3745,8 +3778,8 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None: @pytest.mark.allow_call_model_methods def test_get_retry_advice_marks_stateful_http_failures_replay_safe() -> None: - request = httpx.Request("POST", "https://api.openai.com/v1/responses") - response = httpx.Response( + request = httpx2.Request("POST", "https://api.openai.com/v1/responses") + response = httpx2.Response( 429, request=request, json={"error": {"code": "rate_limit"}}, @@ -3777,7 +3810,7 @@ def test_get_retry_advice_keeps_stateless_transport_failures_retryable() -> None model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object())) error = APIConnectionError( message="connection error", - request=httpx.Request("POST", "https://api.openai.com/v1/responses"), + request=httpx2.Request("POST", "https://api.openai.com/v1/responses"), ) advice = model.get_retry_advice( diff --git a/tests/models/test_openai_retry_helpers.py b/tests/models/test_openai_retry_helpers.py index 2f429561aa..d3a3eed444 100644 --- a/tests/models/test_openai_retry_helpers.py +++ b/tests/models/test_openai_retry_helpers.py @@ -11,6 +11,7 @@ from email.utils import format_datetime import httpx +import httpx2 from agents.models._openai_retry import get_openai_retry_advice from agents.models._retry_runtime import ( @@ -51,6 +52,11 @@ def test_header_lookup_httpx_headers() -> None: assert _header_lookup(None, "retry-after") is None +def test_header_lookup_httpx2_headers() -> None: + headers = httpx2.Headers({"retry-after": "7"}) + assert _header_lookup(headers, "retry-after") == "7" + + def test_get_header_value_reads_response_headers_attr() -> None: class _Err(Exception): response_headers = {"retry-after": "3"} @@ -132,6 +138,15 @@ class _WrapperError(Exception): assert runner_normalized.retry_after == 1.5 +def test_runner_normalizes_both_http_transport_families_as_network_errors() -> None: + errors = ( + httpx.ReadError("legacy", request=httpx.Request("GET", "https://example.com")), + httpx2.ReadError("native", request=httpx2.Request("GET", "https://example.com")), + ) + + assert all(_normalize_retry_error(error, None).is_network_error for error in errors) + + def test_advice_unsafe_to_replay() -> None: error = Exception("cannot replay") error.unsafe_to_replay = True # type: ignore[attr-defined] diff --git a/tests/test_config.py b/tests/test_config.py index 797580f14f..845bf213b5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,7 @@ import weakref from typing import Any, cast +import httpx2 import openai import pytest @@ -17,7 +18,7 @@ ) from agents.models import _openai_shared from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel -from agents.models.openai_provider import OpenAIProvider +from agents.models.openai_provider import OpenAIProvider, shared_http_client from agents.models.openai_responses import OpenAIResponsesModel, OpenAIResponsesWSModel @@ -71,6 +72,10 @@ def test_resp_set_default_openai_client(): assert resp_model._client.api_key == "test_key" # type: ignore +def test_openai_provider_shared_http_client_uses_httpx2() -> None: + assert isinstance(shared_http_client(), httpx2.AsyncClient) + + def test_set_default_openai_api(): assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), ( "Default should be responses" diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index e1ebfbb5b2..2ede56d834 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -8,7 +8,7 @@ from typing import Any, cast from unittest.mock import MagicMock, patch -import httpx +import httpx2 import pytest import agents._debug as _debug @@ -444,7 +444,7 @@ def mock_processor(): return processor -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_no_items(mock_client): exporter = BackendSpanExporter(api_key="test_key") exporter.export([]) @@ -453,7 +453,7 @@ def test_backend_span_exporter_no_items(mock_client): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_no_api_key(mock_client): # Ensure that os.environ is empty (sometimes devs have the openai api key set in their env) @@ -466,7 +466,7 @@ def test_backend_span_exporter_no_api_key(mock_client): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_2xx_success(mock_client): mock_response = MagicMock() mock_response.status_code = 200 @@ -480,7 +480,7 @@ def test_backend_span_exporter_2xx_success(mock_client): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") @pytest.mark.parametrize("redacted", [True, False]) def test_backend_span_exporter_4xx_client_error(mock_client, monkeypatch, caplog, redacted: bool): monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) @@ -509,7 +509,7 @@ def text(self) -> str: exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_5xx_retry(mock_client): mock_response = MagicMock() mock_response.status_code = 500 @@ -528,7 +528,7 @@ def test_backend_span_exporter_5xx_retry(mock_client): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_deadline_stops_during_5xx_retry_backoff(mock_client): mock_response = MagicMock() mock_response.status_code = 504 @@ -547,7 +547,7 @@ def test_backend_span_exporter_deadline_stops_during_5xx_retry_backoff(mock_clie exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_batch_trace_processor_shutdown_interrupts_exporter_retry_backoff(mock_client): post_called = threading.Event() mock_response = MagicMock() @@ -588,7 +588,7 @@ def post(**kwargs: Any) -> Any: exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_batch_trace_processor_shutdown_without_timeout_preserves_export_retries(mock_client): mock_response = MagicMock() mock_response.status_code = 504 @@ -645,7 +645,7 @@ def close(self): pass client = Always504Client() - with patch("agents.tracing.processors.httpx.Client", return_value=client): + with patch("agents.tracing.processors.httpx2.Client", return_value=client): exporter = BackendSpanExporter( api_key="test_key", max_retries=100, @@ -702,10 +702,10 @@ def timed_shutdown(*args, **kwargs): assert float(shutdown_elapsed_lines[0][len(shutdown_elapsed_prefix) :]) < 0.5 -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_request_error(mock_client): # Make post() raise a RequestError each time - mock_client.return_value.post.side_effect = httpx.RequestError("Network error") + mock_client.return_value.post.side_effect = httpx2.RequestError("Network error") exporter = BackendSpanExporter(api_key="test_key", max_retries=2, base_delay=0.1, max_delay=0.2) with patch.object(exporter._shutdown_event, "wait", return_value=False) as wait_for_retry: @@ -718,7 +718,7 @@ def test_backend_span_exporter_request_error(mock_client): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_close(mock_client): exporter = BackendSpanExporter(api_key="test_key") exporter.close() @@ -727,7 +727,7 @@ def test_backend_span_exporter_close(mock_client): mock_client.return_value.close.assert_called_once() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_sanitizes_generation_usage_for_openai_tracing(mock_client): """Unsupported usage keys should be stripped before POSTing to OpenAI tracing.""" @@ -782,7 +782,7 @@ def export(self): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_truncates_large_input_for_openai_tracing(mock_client): class DummyItem: tracing_api_key = None @@ -816,7 +816,7 @@ def export(self): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_truncates_large_structured_input_without_stringifying(mock_client): class NoStringifyDict(dict[str, Any]): def __str__(self) -> str: @@ -856,7 +856,7 @@ def export(self): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_keeps_generation_usage_for_custom_endpoint(mock_client): class DummyItem: tracing_api_key = None @@ -894,7 +894,7 @@ def export(self): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_drops_non_generation_usage_for_openai_endpoint(mock_client): class DummyItem: tracing_api_key = None @@ -920,7 +920,7 @@ def export(self): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_keeps_non_generation_usage_for_custom_endpoint(mock_client): class DummyItem: tracing_api_key = None @@ -965,7 +965,7 @@ def test_sanitize_for_openai_tracing_api_keeps_allowed_generation_usage(): exporter.close() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_keeps_large_input_for_custom_endpoint(mock_client): class DummyItem: tracing_api_key = None diff --git a/tests/tracing/test_import_side_effects.py b/tests/tracing/test_import_side_effects.py index c343f24091..0131b7914e 100644 --- a/tests/tracing/test_import_side_effects.py +++ b/tests/tracing/test_import_side_effects.py @@ -40,17 +40,17 @@ def test_import_agents_has_no_tracing_side_effects() -> None: payload = _run_python( """ import json -import httpx +import httpx2 client_init_calls = 0 -original_client_init = httpx.Client.__init__ +original_client_init = httpx2.Client.__init__ def tracking_client_init(self, *args, **kwargs): global client_init_calls client_init_calls += 1 original_client_init(self, *args, **kwargs) -httpx.Client.__init__ = tracking_client_init +httpx2.Client.__init__ = tracking_client_init import agents # noqa: F401 from agents.tracing import processors as tracing_processors @@ -77,6 +77,55 @@ def tracking_client_init(self, *args, **kwargs): assert payload["shutdown_handler_registered"] is False +def test_core_imports_do_not_require_legacy_httpx() -> None: + payload = _run_python( + """ +import importlib.abc +import json +import sys + +class BlockLegacyHttpx(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname == "httpx" or fullname.startswith("httpx."): + raise ModuleNotFoundError( + f"blocked undeclared core dependency: {fullname}", + name=fullname, + ) + return None + +sys.meta_path.insert(0, BlockLegacyHttpx()) + +import httpx2 +import agents +from agents.mcp import MCPServerStreamableHttp +from agents.run_internal.model_retry import _normalize_retry_error + +request = httpx2.Request("GET", "https://example.com") +error = httpx2.ReadError("connection dropped", request=request) +normalized = _normalize_retry_error(error, None) +generic = _normalize_retry_error(ValueError("not a transport error"), None) + +print( + json.dumps( + { + "agents_name": agents.__name__, + "mcp_server_name": MCPServerStreamableHttp.__name__, + "legacy_httpx_loaded": "httpx" in sys.modules, + "network_error": normalized.is_network_error, + "generic_network_error": generic.is_network_error, + } + ) +) +""" + ) + + assert payload["agents_name"] == "agents" + assert payload["mcp_server_name"] == "MCPServerStreamableHttp" + assert payload["legacy_httpx_loaded"] is False + assert payload["network_error"] is True + assert payload["generic_network_error"] is False + + def test_import_agents_does_not_require_sqlite3() -> None: payload = _run_python( """ diff --git a/tests/voice/test_openai_model_provider.py b/tests/voice/test_openai_model_provider.py index 9906b25b51..64a3152165 100644 --- a/tests/voice/test_openai_model_provider.py +++ b/tests/voice/test_openai_model_provider.py @@ -2,12 +2,13 @@ from typing import Any, cast +import httpx2 import openai import pytest from agents.exceptions import UserError from agents.models import _openai_shared -from agents.voice.models.openai_model_provider import OpenAIVoiceModelProvider +from agents.voice.models.openai_model_provider import OpenAIVoiceModelProvider, shared_http_client @pytest.mark.parametrize( @@ -32,6 +33,10 @@ def test_voice_provider_accepts_client_without_conflicting_args(): assert provider._get_client() is client +def test_voice_provider_shared_http_client_uses_httpx2() -> None: + assert isinstance(shared_http_client(), httpx2.AsyncClient) + + def test_voice_provider_preserves_falsy_default_client(monkeypatch): class FalsyClient: def __bool__(self) -> bool: diff --git a/uv.lock b/uv.lock index c092e351b5..b39b877ab3 100644 --- a/uv.lock +++ b/uv.lock @@ -2453,21 +2453,21 @@ wheels = [ [[package]] name = "openai" -version = "2.45.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/8c/2f500e8be09d1ae98c530467962535198b02cd4550cd418bbbaedc8b2910/openai-3.0.0.tar.gz", hash = "sha256:ffd00ef1678d70957e1f1ed98d5bfcf1d661f41ea4482f22e7d0144a66435a49", size = 1123740, upload-time = "2026-08-12T01:55:50.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0d/9850e7eddb5e66da4439ed503e78e09ad1fd0195e6df51e4236c75763581/openai-3.0.0-py3-none-any.whl", hash = "sha256:8d32ac3a6647a66910d6cb8a64f0fa5a6c823604b6e82db83d9d055c6709bd51", size = 1665775, upload-time = "2026-08-12T01:55:48.678Z" }, ] [[package]] @@ -2608,7 +2608,7 @@ requires-dist = [ { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<3" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, - { name = "openai", specifier = ">=2.45.0,<3" }, + { name = "openai", specifier = ">=3.0.0,<4" }, { name = "pydantic", specifier = ">=2.12.2,<3" }, { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.14" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=7" }, From f251c70c0c204a6ec3892034331a16e67e20dc5b Mon Sep 17 00:00:00 2001 From: Henry Su Date: Wed, 12 Aug 2026 23:48:25 -0500 Subject: [PATCH 300/473] fix(run-state): isolate interruption results (#4384) --- src/agents/run_state.py | 2 +- tests/test_run_state.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index adc44c3228..01b1cfb5b5 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -458,7 +458,7 @@ def get_interruptions(self) -> list[ToolApprovalItem]: if self._current_step is None or not isinstance(self._current_step, NextStepInterruption): return [] - return self._current_step.interruptions + return list(self._current_step.interruptions) @staticmethod def _approval_items_match( diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 81c091f253..748d0c064e 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -1614,6 +1614,25 @@ def test_get_interruptions_returns_interruptions_when_present(self): assert len(interruptions) == 1 assert interruptions[0] == approval_item + def test_get_interruptions_returns_a_snapshot(self): + """Mutating returned interruptions must not change pending approvals.""" + agent = Agent(name="SnapshotAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="cid-snapshot", + status="completed", + arguments="{}", + ), + ) + state = make_state_with_interruptions(agent, [approval_item]) + + state.get_interruptions().clear() + + assert state.get_interruptions() == [approval_item] + async def test_serializes_and_restores_approvals(self): """Test that approval state is preserved through serialization.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) From 6bef354de2f160047be7d4ffd5f360b1a5bca264 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 13 Aug 2026 02:01:12 -0500 Subject: [PATCH 301/473] fix(voice): reject non-positive audio frame rates (#4382) --- src/agents/voice/input.py | 3 +++ tests/voice/test_input.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/agents/voice/input.py b/src/agents/voice/input.py index 63f2313d1b..2ecfb67c83 100644 --- a/src/agents/voice/input.py +++ b/src/agents/voice/input.py @@ -22,6 +22,9 @@ def _buffer_to_audio_file( if sample_width not in {1, 2, 3, 4}: raise UserError("Sample width must be between 1 and 4 bytes") + if frame_rate <= 0: + raise UserError("Frame rate must be greater than zero") + if buffer.dtype not in (np.int16, np.float32): raise UserError("Buffer must be a numpy array of int16 or float32") diff --git a/tests/voice/test_input.py b/tests/voice/test_input.py index 06acf2b4bf..3868931fe1 100644 --- a/tests/voice/test_input.py +++ b/tests/voice/test_input.py @@ -116,6 +116,14 @@ def test_audio_input_rejects_non_positive_channels(channels): audio_input.to_audio_file() +@pytest.mark.parametrize("frame_rate", [0, -8000]) +def test_audio_input_rejects_non_positive_frame_rate(frame_rate): + audio_input = AudioInput(buffer=np.zeros(4, dtype=np.int16), frame_rate=frame_rate) + + with pytest.raises(UserError, match="Frame rate must be greater than zero"): + audio_input.to_audio_file() + + def test_buffer_to_audio_file_invalid_dtype(): # Create a buffer with invalid dtype (float64) buffer = np.array([1.0, 2.0, 3.0], dtype=np.float64) From 66ae98fb1ea0d464126c4f4127d7e9a4d23714cf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 13 Aug 2026 16:33:38 +0900 Subject: [PATCH 302/473] fix: freeze public testing module contracts (#4386) --- integration_tests/_contract_support.py | 45 ++++- src/agents/testing/model.py | 14 ++ src/agents/testing/sandbox.py | 12 ++ .../released_api_contract_policy.json | 144 +++++++++++++++ tests/test_released_api_contract.py | 174 +++++++++++++++--- 5 files changed, 350 insertions(+), 39 deletions(-) diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 7f51c0236f..64293a2ed9 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -325,6 +325,13 @@ def _default_contract(value: object) -> dict[str, object]: "type": f"{type(value).__module__}.{type(value).__qualname__}", "value": value, } + voice_testing = sys.modules.get("agents.voice.testing") + if voice_testing is not None and value is getattr(voice_testing, "_START_NOT_CONFIGURED", None): + return { + "kind": "sentinel", + "identity": "agents.voice.testing._START_NOT_CONFIGURED", + } + value_type = f"{type(value).__module__}.{type(value).__qualname__}" from agents.mcp.server import _UNSET as mcp_failure_error_unset from agents.retry import _UNSET as retry_unset from agents.tool import _UNSET_FAILURE_ERROR_FUNCTION as failure_error_function_unset @@ -339,7 +346,6 @@ def _default_contract(value: object) -> dict[str, object]: for sentinel, identity in sentinel_identities: if value is sentinel: return {"kind": "sentinel", "identity": identity} - value_type = f"{type(value).__module__}.{type(value).__qualname__}" if value_type == "pydantic.fields.FieldInfo": return {"kind": "repr", "type": value_type, "value": repr(value)} if isinstance(value, enum.Enum): @@ -718,6 +724,27 @@ def _optional_dependency_for_binding_in_modules( return None +def _optional_dependency_for_module_import( + contract: Mapping[str, Any], module_name: str +) -> str | None: + modules = contract.get("required_submodule_exports", {}) + module_contract = modules.get(module_name, {}) + names = module_contract.get("names", []) + try: + optional_bindings = _optional_dependency_modules( + module_contract.get("optional_bindings", {}), field_name="optional_bindings" + ) + optional_exports = _optional_dependency_modules( + module_contract.get("optional_exports", {}), field_name="optional_exports" + ) + except ValueError: + return None + dependencies = {optional_bindings.get(name) or optional_exports.get(name) for name in names} + if names and len(dependencies) == 1 and None not in dependencies: + return cast(str, next(iter(dependencies))) + return None + + def _preserve_released_callable_for_promotion( contract: Mapping[str, Any], callables: dict[str, Any], @@ -879,14 +906,7 @@ def build_released_api_contract( continue try: _signature(value) - except (TypeError, ValueError) as error: - _preserve_released_callable_for_promotion( - contract, - callables, - qualified_name, - fail_if_missing=is_new_canonical_import, - unavailable_reason=f"its signature cannot be inspected: {error!r}", - ) + except (TypeError, ValueError): continue callables[qualified_name] = _callable_contract(value) @@ -1266,6 +1286,13 @@ def validate_released_api_contract( except Exception as error: if _matches_platform_import_error(contract, module_name, error): continue + optional_dependency = _optional_dependency_for_module_import(contract, module_name) + if optional_dependency is not None and not ( + _optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue errors.append(f"Failed to import released module {module_name}: {error!r}") for module_name, released in contract.get("required_submodule_exports", {}).items(): diff --git a/src/agents/testing/model.py b/src/agents/testing/model.py index eafecb3b21..546b056aa0 100644 --- a/src/agents/testing/model.py +++ b/src/agents/testing/model.py @@ -1196,3 +1196,17 @@ def _response_usage_for_usage(usage: Any) -> ResponseUsage: reasoning_tokens=getattr(usage.output_tokens_details, "reasoning_tokens", 0) ), ) + + +__all__ = [ + "InvalidModelStep", + "ModelCall", + "ModelScriptError", + "ModelStep", + "ModelStepSpec", + "ScriptedModel", + "UnconsumedModelSteps", + "UnexpectedModelCall", + "assistant_message", + "function_call", +] diff --git a/src/agents/testing/sandbox.py b/src/agents/testing/sandbox.py index 1354459747..bfb1361843 100644 --- a/src/agents/testing/sandbox.py +++ b/src/agents/testing/sandbox.py @@ -568,3 +568,15 @@ def scripted_sandbox_session( """ normalized = [_normalize_step(step, index) for index, step in enumerate(steps)] return _ScriptedSandboxSession(normalized, manifest=manifest) + + +__all__ = [ + "InvalidSandboxStep", + "SandboxCall", + "SandboxCallMatcherError", + "SandboxScriptError", + "SandboxStepSpec", + "UnconsumedSandboxSteps", + "UnexpectedSandboxCall", + "scripted_sandbox_session", +] diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index eb71176757..953a086409 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -6,6 +6,114 @@ "module": "agents.items", "name": "InputItem" }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "InvalidModelStep", + "module": "agents.testing", + "name": "InvalidModelStep" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelCall", + "module": "agents.testing", + "name": "ModelCall" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelScriptError", + "module": "agents.testing", + "name": "ModelScriptError" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelStep", + "module": "agents.testing", + "name": "ModelStep" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelStepSpec", + "module": "agents.testing", + "name": "ModelStepSpec" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ScriptedModel", + "module": "agents.testing", + "name": "ScriptedModel" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "UnconsumedModelSteps", + "module": "agents.testing", + "name": "UnconsumedModelSteps" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "UnexpectedModelCall", + "module": "agents.testing", + "name": "UnexpectedModelCall" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "assistant_message", + "module": "agents.testing", + "name": "assistant_message" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "function_call", + "module": "agents.testing", + "name": "function_call" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "InvalidSandboxStep", + "module": "agents.testing", + "name": "InvalidSandboxStep" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxCall", + "module": "agents.testing", + "name": "SandboxCall" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxCallMatcherError", + "module": "agents.testing", + "name": "SandboxCallMatcherError" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxScriptError", + "module": "agents.testing", + "name": "SandboxScriptError" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxStepSpec", + "module": "agents.testing", + "name": "SandboxStepSpec" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "UnconsumedSandboxSteps", + "module": "agents.testing", + "name": "UnconsumedSandboxSteps" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "UnexpectedSandboxCall", + "module": "agents.testing", + "name": "UnexpectedSandboxCall" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "scripted_sandbox_session", + "module": "agents.testing", + "name": "scripted_sandbox_session" + }, { "canonical_module": "agents.extensions.sandbox.modal", "canonical_name": "ModalCloudBucketMountStrategy", @@ -101,6 +209,9 @@ "modal": { "extra": "modal" }, + "numpy": { + "extra": "voice" + }, "pymongo": { "extra": "mongodb" }, @@ -170,6 +281,39 @@ "VercelSandboxSession": "vercel", "VercelSandboxSessionState": "vercel" } + }, + "agents.realtime.testing": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.testing": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.testing.model": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.testing.sandbox": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.testing": { + "optional_bindings": { + "STTCall": "numpy", + "STTSessionCall": "numpy", + "ScriptedSTTModel": "numpy", + "ScriptedTTSModel": "numpy", + "ScriptedTranscriptionSession": "numpy", + "ScriptedVoiceWorkflow": "numpy", + "TTSCall": "numpy", + "TTSResult": "numpy", + "UnconsumedVoiceSteps": "numpy", + "UnexpectedVoiceCall": "numpy", + "VoiceScriptError": "numpy", + "pcm16_samples": "numpy" + }, + "optional_exports": {} } }, "public_properties": [ diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index cc9efa8d36..adf590fef7 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -1,3 +1,5 @@ +import builtins +import importlib import json import subprocess import sys @@ -1724,7 +1726,7 @@ def test_release_contract_policy_rejects_new_callable_on_unsupported_platform( ) -def test_release_contract_policy_rejects_new_callable_with_uninspectable_signature( +def test_release_contract_policy_promotes_new_uninspectable_canonical_surface( monkeypatch: pytest.MonkeyPatch, ) -> None: class UninspectableMeta(type): @@ -1756,35 +1758,31 @@ class Uninspectable(metaclass=UninspectableMeta): lambda module_name, _agents_module: modules[module_name], ) - with pytest.raises( - ValueError, - match=( - r"Cannot promote new canonical callable agents\.submodule\.Uninspectable because " - r"its signature cannot be inspected.*release preparation host" + canonical_entry = { + "canonical_module": "agents.submodule.impl", + "canonical_name": "Uninspectable", + "module": "agents.submodule", + "name": "Uninspectable", + } + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + { + "agents.submodule": { + "optional_bindings": {}, + "optional_exports": {}, + } + }, + canonical_imports=(canonical_entry,), ), - ): - build_released_api_contract( - contract, - baseline="v0.20.0", - baseline_commit="b" * 40, - agents_module=agents_module, - release_policy=_release_policy( - { - "agents.submodule": { - "optional_bindings": {}, - "optional_exports": {}, - } - }, - canonical_imports=( - { - "canonical_module": "agents.submodule.impl", - "canonical_name": "Uninspectable", - "module": "agents.submodule", - "name": "Uninspectable", - }, - ), - ), - ) + ) + + assert updated["canonical_imports"] == [canonical_entry] + assert "agents.submodule.Uninspectable" not in updated["callables"] def test_release_contract_policy_keeps_existing_uninspectable_canonical_surface( @@ -2285,7 +2283,7 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: for installation in policy.dependency_installations if installation.dependency_module == "vercel" ).unsupported_platforms == ("win32",) - assert {(entry["module"], entry["name"]) for entry in policy.canonical_imports} == { + assert {(entry["module"], entry["name"]) for entry in policy.canonical_imports} >= { ("agents.items", "InputItem"), ("agents.extensions.sandbox", "ModalCloudBucketMountStrategy"), ("agents.extensions.sandbox", "ModalSandboxClient"), @@ -2330,6 +2328,91 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: ) +def test_repository_release_policy_declares_public_testing_modules() -> None: + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + expected_modules = { + "agents.realtime.testing", + "agents.testing", + "agents.testing.model", + "agents.testing.sandbox", + "agents.voice.testing", + } + + assert expected_modules <= policy.modules.keys() + assert policy.modules["agents.voice.testing"] == { + "optional_bindings": { + export: "numpy" for export in importlib.import_module("agents.voice.testing").__all__ + }, + "optional_exports": {}, + } + assert ( + next( + installation + for installation in policy.dependency_installations + if installation.dependency_module == "numpy" + ).extra + == "voice" + ) + for module_name in expected_modules: + module = importlib.import_module(module_name) + assert module.__all__ + assert all(type(export) is str for export in module.__all__) + + expected_canonical_imports = { + ("agents.testing", name, "agents.testing.model", name) + for name in importlib.import_module("agents.testing.model").__all__ + } | { + ("agents.testing", name, "agents.testing.sandbox", name) + for name in importlib.import_module("agents.testing.sandbox").__all__ + } + actual_canonical_imports = { + ( + entry["module"], + entry["name"], + entry["canonical_module"], + entry["canonical_name"], + ) + for entry in policy.canonical_imports + if entry["module"] == "agents.testing" + } + + assert actual_canonical_imports == expected_canonical_imports + for module_name, name, canonical_module_name, canonical_name in actual_canonical_imports: + module = importlib.import_module(module_name) + canonical_module = importlib.import_module(canonical_module_name) + assert getattr(module, name) is getattr(canonical_module, canonical_name) + + +def test_voice_testing_start_sentinel_has_stable_contract_identity() -> None: + from agents.voice.testing import _START_NOT_CONFIGURED + + assert _default_contract(_START_NOT_CONFIGURED) == { + "kind": "sentinel", + "identity": "agents.voice.testing._START_NOT_CONFIGURED", + } + with pytest.raises(TypeError, match="Unsupported public API default value: builtins.object"): + _default_contract(object()) + + +def test_default_contract_does_not_import_voice_testing_for_unrelated_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import = builtins.__import__ + + def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "agents.voice.testing": + raise AssertionError("Unrelated defaults must not import the optional Voice package.") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + + assert _default_contract(()) == { + "kind": "sequence", + "type": "builtins.tuple", + "items": [], + } + + @pytest.mark.parametrize( "unsupported_platforms", ["win32", [""], ["win32", "win32"]], @@ -2422,6 +2505,37 @@ def test_public_api_contract_allows_declared_optional_submodule_binding( assert validate_released_api_contract(contract, agents_module=agents_module) == [] +def test_public_api_contract_skips_fully_optional_unimportable_submodule( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agents_module = SimpleNamespace(__all__=[]) + contract: dict[str, Any] = { + "required_top_level_exports": [], + "public_modules": ["agents.optional_submodule"], + "required_submodule_exports": { + "agents.optional_submodule": { + "names": ["OptionalClient", "OptionalConfig"], + "optional_bindings": { + "OptionalClient": "missing_optional_dependency", + "OptionalConfig": "missing_optional_dependency", + }, + "optional_exports": {}, + } + }, + "canonical_imports": [], + "callables": {}, + } + + def import_module(module_name: str, _agents_module: object) -> object: + if module_name == "agents": + return agents_module + raise ImportError("The optional dependency is unavailable.") + + monkeypatch.setattr(contract_support, "_import_contract_module", import_module) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [] + + def test_public_api_contract_allows_declared_optional_submodule_export( monkeypatch: pytest.MonkeyPatch, ) -> None: From 1816d2e9f93a0d7265094ec3c9c6c312ae5c3aff Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 13 Aug 2026 16:56:17 +0900 Subject: [PATCH 303/473] docs: scale verification by change risk --- .agents/skills/docs-sync/SKILL.md | 3 ++- AGENTS.md | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.agents/skills/docs-sync/SKILL.md b/.agents/skills/docs-sync/SKILL.md index 32b3bb46da..e00cf80fdc 100644 --- a/.agents/skills/docs-sync/SKILL.md +++ b/.agents/skills/docs-sync/SKILL.md @@ -51,7 +51,8 @@ Identify doc coverage gaps and inaccuracies by comparing main branch features an - Do **not** edit `docs/ja`, `docs/ko`, or `docs/zh`. - Keep changes aligned with the existing docs style and navigation. - Update `mkdocs.yml` when adding or renaming pages. - - Build docs with `make build-docs` after edits to verify the docs site still builds. + - Classify the complete diff with the Documentation Verification Tiers in `AGENTS.md` and run only the checks required by that tier. + - For content or structural changes, run `make build-docs` once after the edits and required review are stable. Do not run it for editorial-only changes. ## Output format diff --git a/AGENTS.md b/AGENTS.md index 3cb7790981..c44a3b406f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,16 @@ If isolation or a different checkout is needed, explain why and ask the user bef When a feature or bug fix introduces behavior that is not yet available in the latest published release, do not include `docs/` changes that describe that unreleased behavior in the feature or bug-fix pull request, and do not expect those changes as part of that pull request. Handle them in a separate docs-only pull request so maintainers can coordinate its merge timing with the release that makes the documentation accurate. This exception applies only when the documentation would be incorrect for the latest published release; documentation that is already accurate for released behavior remains part of the normal change scope. +### Documentation Verification Tiers + +Classify documentation changes before choosing review and verification work. Use the narrowest tier that covers the complete diff, and move to a higher tier when any changed file or claim requires it. + +- **Editorial:** Terminology, spelling, punctuation, formatting, or link-label changes that do not change documented behavior, runnable code, navigation, link targets, anchors, or generated reference content. Inspect the diff, run targeted searches for the corrected text, and run `git diff --check`. Check a link or anchor directly only when the edit can affect it. Skip `$implementation-final-review`, cross-language review, and `make build-docs` for this tier. +- **Content:** New or materially rewritten behavioral guidance, migration instructions, or runnable snippets that do not change documentation structure or tooling. Verify claims against the implementation and authoritative sources, execute or otherwise validate changed snippets when practical, perform the required focused cross-language review, and run `make build-docs` once after the content and review are stable. Do not repeat the full site build after edits that cannot affect its result. +- **Structural:** Added, removed, renamed, or moved pages; changes to `mkdocs.yml`, generated API reference inputs, documentation scripts, plugins, or build configuration. Run the relevant generators or focused tooling checks and `make build-docs` after the structure is stable. Apply `$code-change-verification` when the changed file is build or test configuration covered by that skill. + +Existing warnings from a successful documentation build are not findings for an unrelated docs change. Evaluate the exit status and identify new errors, broken references, or warnings caused by the diff instead of reviewing the complete warning stream line by line. Reserve `make build-full-docs` and generated translation output for translation-tooling changes, explicit localization work, or a specifically requested broad localization audit. + ### Scope Discipline and Complexity Reset - Implement the narrowest explicitly stated set of behaviors that satisfies the request. Do not interpret every shape accepted by a host-language protocol, third-party library, or reflection API unless those shapes are required by the task or supported behavior shipped in the latest release. @@ -176,10 +186,7 @@ The OpenAI Agents Python repository provides the Python Agents SDK, examples, an 3. If dependencies changed or you are setting up the repo, run `make sync`. 4. Implement changes and add or update tests alongside code updates. 5. Highlight compatibility or API risks in your plan before implementing changes that alter the latest released behavior or a released or explicitly supported durable external state boundary. -6. Build docs when you touch documentation: - ```bash - make build-docs - ``` +6. Verify documentation changes according to [Documentation Verification Tiers](#documentation-verification-tiers). Do not run a full documentation build for an editorial-only change. 7. When `$code-change-verification` applies, run it to execute the full verification stack before marking work complete. 8. Commit with concise, imperative messages; keep commits small and focused, then open a pull request. 9. Before reporting eligible code changes as complete, invoke `$pr-draft-summary` as the final handoff step unless the task falls under the documented skip cases. Do not omit it based on perceived change size or because the work remains local or uncommitted. @@ -261,9 +268,9 @@ make tests ``` - Documentation workflows: ```bash - make build-docs # build docs after editing docs + make build-docs # build stable content or structural docs changes make serve-docs # preview docs locally - make build-full-docs # run translations and build + make build-full-docs # run translations and build when explicitly required ``` - Snapshot helpers: ```bash From 95f1c7cc56484b147ac3926db4ee5e4d437fc03e Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 13 Aug 2026 15:58:40 -0700 Subject: [PATCH 304/473] fix(codex): preserve resume argument ordering (#4400) --- .../extensions/experimental/codex/exec.py | 5 +++- .../codex/test_codex_exec_thread.py | 30 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/agents/extensions/experimental/codex/exec.py b/src/agents/extensions/experimental/codex/exec.py index c83a0e98cd..0001b5700f 100644 --- a/src/agents/extensions/experimental/codex/exec.py +++ b/src/agents/extensions/experimental/codex/exec.py @@ -105,12 +105,15 @@ async def run(self, args: CodexExecArgs) -> AsyncGenerator[str, None]: command_args.extend(["--config", f'approval_policy="{args.approval_policy}"']) if args.thread_id: - command_args.extend(["resume", args.thread_id]) + command_args.append("resume") if args.images: for image in args.images: command_args.extend(["--image", image]) + if args.thread_id: + command_args.extend(["--", args.thread_id]) + # Codex CLI expects a prompt argument; "-" tells it to read from stdin. command_args.append("-") diff --git a/tests/extensions/experiemental/codex/test_codex_exec_thread.py b/tests/extensions/experiemental/codex/test_codex_exec_thread.py index c1012c51b3..51c635205e 100644 --- a/tests/extensions/experiemental/codex/test_codex_exec_thread.py +++ b/tests/extensions/experiemental/codex/test_codex_exec_thread.py @@ -340,9 +340,10 @@ async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess: "--config", 'approval_policy="on-request"', "resume", - "thread-123", "--image", "/tmp/img.png", + "--", + "thread-123", "-", ] @@ -353,6 +354,33 @@ async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess: assert env["CODEX_API_KEY"] == "api-key" +@pytest.mark.asyncio +@pytest.mark.parametrize("images", [None, ["/tmp/img.png"]], ids=["no-images", "with-image"]) +async def test_codex_exec_run_treats_option_like_thread_id_as_positional( + monkeypatch: pytest.MonkeyPatch, images: list[str] | None +) -> None: + captured_args: tuple[Any, ...] = () + + async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any) -> FakeProcess: + nonlocal captured_args + captured_args = args + return FakeProcess(stdout_lines=[]) + + monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + exec_client = exec_module.CodexExec(executable_path="/bin/codex") + args = exec_module.CodexExecArgs(input="hello", thread_id="--thread-option", images=images) + + _ = [line async for line in exec_client.run(args)] + + expected_args = ["/bin/codex", "exec", "--experimental-json", "resume"] + if images: + expected_args.extend(["--image", images[0]]) + expected_args.extend(["--", "--thread-option", "-"]) + + assert captured_args == tuple(expected_args) + + @pytest.mark.asyncio async def test_codex_exec_run_handles_large_single_line_events( monkeypatch: pytest.MonkeyPatch, From 079e745996af793d8fb3e4e448c1d80535c80f25 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 13 Aug 2026 18:45:12 -0500 Subject: [PATCH 305/473] fix(sandbox): snapshot HTTP proxy headers (#4397) --- src/agents/sandbox/session/sinks.py | 2 +- tests/sandbox/test_session_sinks.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/agents/sandbox/session/sinks.py b/src/agents/sandbox/session/sinks.py index d9fdfee609..0781d8407d 100644 --- a/src/agents/sandbox/session/sinks.py +++ b/src/agents/sandbox/session/sinks.py @@ -290,7 +290,7 @@ def __init__( payload_policy: EventPayloadPolicy | None = None, ) -> None: self.endpoint = endpoint - self.headers = headers or {} + self.headers = dict(headers or {}) self.timeout_s = timeout_s self.spool_path = spool_path self.mode = mode diff --git a/tests/sandbox/test_session_sinks.py b/tests/sandbox/test_session_sinks.py index 6f8708e16c..f4932ee93a 100644 --- a/tests/sandbox/test_session_sinks.py +++ b/tests/sandbox/test_session_sinks.py @@ -6,7 +6,7 @@ import tarfile import uuid from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from inline_snapshot import snapshot @@ -445,6 +445,20 @@ async def test_http_proxy_sink_spools_direct_timeout(tmp_path: Path) -> None: assert json.loads(lines[0])["seq"] == 1 +def test_http_proxy_sink_snapshots_headers() -> None: + headers = {"authorization": "Bearer original"} + sink = HttpProxySink("https://example.test/events", headers=headers) + headers["authorization"] = "Bearer changed" + response = MagicMock() + response.__enter__.return_value.read.return_value = b"" + + with patch("agents.sandbox.session.sinks.urlopen", return_value=response) as urlopen: + sink._post(b"{}", None) + + request = urlopen.call_args.args[0] + assert request.get_header("Authorization") == "Bearer original" + + @pytest.mark.asyncio async def test_sandbox_session_error_events_and_traces_include_retryability( tmp_path: Path, From 15989e50bccd978212f712a7d5b2266090a6ebd5 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 13 Aug 2026 18:47:54 -0500 Subject: [PATCH 306/473] fix(sandbox): snapshot per-op audit policies (#4398) --- src/agents/sandbox/session/manager.py | 2 +- tests/sandbox/test_session_manager.py | 30 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/session/manager.py b/src/agents/sandbox/session/manager.py index 8bb838b322..9c9b33e6aa 100644 --- a/src/agents/sandbox/session/manager.py +++ b/src/agents/sandbox/session/manager.py @@ -25,7 +25,7 @@ def __init__( ) -> None: self._sinks: list[EventSink] = list(sinks or []) self.payload_policy = payload_policy if payload_policy is not None else EventPayloadPolicy() - self.payload_policy_by_op = payload_policy_by_op or {} + self.payload_policy_by_op = dict(payload_policy_by_op or {}) self._tasks: set[asyncio.Task[None]] = set() @property diff --git a/tests/sandbox/test_session_manager.py b/tests/sandbox/test_session_manager.py index a6f5c6d70f..3b12347d65 100644 --- a/tests/sandbox/test_session_manager.py +++ b/tests/sandbox/test_session_manager.py @@ -73,6 +73,36 @@ async def test_instrumentation_per_op_policy_overrides_default(tmp_path: Path) - assert events[0].stdout == "hello" +@pytest.mark.asyncio +async def test_instrumentation_snapshots_per_op_policy_mapping(tmp_path: Path) -> None: + events: list[SandboxSessionEvent] = [] + session = _build_session(tmp_path) + sink = CallbackSink(lambda event, _session: events.append(event), mode="sync") + sink.bind(session) + policies = {"exec": EventPayloadPolicy(include_exec_output=False)} + instrumentation = Instrumentation( + sinks=[sink], + payload_policy=EventPayloadPolicy(include_exec_output=True), + payload_policy_by_op=policies, + ) + policies.clear() + event = SandboxSessionFinishEvent( + session_id=uuid.uuid4(), + seq=1, + op="exec", + span_id="span_exec", + ok=True, + duration_ms=0.0, + stdout_bytes=b"secret", + ) + + await instrumentation.emit(event) + + assert isinstance(events[0], SandboxSessionFinishEvent) + assert events[0].stdout is None + assert events[0].stdout_bytes is None + + @pytest.mark.asyncio async def test_instrumentation_per_sink_policy_overrides_per_op(tmp_path: Path) -> None: first: list[SandboxSessionEvent] = [] From 761fcd981908d9c2ff466ac19ff3cbd7ec17debf Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 13 Aug 2026 19:59:24 -0500 Subject: [PATCH 307/473] fix(tools): redact tool output value from output-type validation errors (#4396) --- src/agents/tool.py | 32 ++++++++++---- tests/test_error_logging_redaction.py | 61 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/agents/tool.py b/src/agents/tool.py index 625348dadf..b9e278afa4 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -2268,13 +2268,21 @@ def _validate_function_tool_output( """Validate a typed function output before it reaches hooks or serialization.""" if output_type_adapter is None: return output + base_message = ( + f"Function tool {tool_name} returned an output that does not match its declared output type" + ) try: return output_type_adapter.validate_python(output) except ValidationError as error: - raise UserError( - f"Function tool {tool_name} returned an output that does not match its declared " - f"output type: {error}" - ) from error + if not _debug.DONT_LOG_TOOL_DATA: + raise UserError(f"{base_message}: {error}") from error + # Tool-data redaction is enabled: the ValidationError repr embeds the raw output value. Drop + # the payload-bearing ``output`` local and raise outside the ``except`` block so the value + # cannot be recovered from this frame's traceback locals, and the ValidationError is not + # attached as the redacted error's ``__cause__``/``__context__`` (mirroring the tool argument + # validation path above). + output = None + raise UserError(base_message) def _validate_function_tool_callable_annotations( @@ -2638,11 +2646,17 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: else: result = await asyncio.to_thread(the_func, *args, **kwargs_dict) - result = _validate_function_tool_output( - tool_name=tool_name, - output=result, - output_type_adapter=output_type_adapter, - ) + try: + result = _validate_function_tool_output( + tool_name=tool_name, + output=result, + output_type_adapter=output_type_adapter, + ) + except UserError: + # Output validation failed. Drop the payload-bearing local so a redacted error + # cannot leak the raw output through this frame's traceback locals. + result = None + raise if _debug.DONT_LOG_TOOL_DATA: logger.debug("Tool %s completed.", tool_name) diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index 78d403c3cd..ad8d17dd35 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -883,6 +883,67 @@ async def test_agent_tool_validation_error_preserves_diagnostics_when_tool_data_ assert isinstance(error.__cause__, ValidationError) +_TOOL_OUTPUT_SECRET = "SECRET_TOOL_OUTPUT_123" + + +class _IntegerOutput(BaseModel): + value: int + + +def _returns_wrong_typed_output(ignored: str = "") -> Any: + # The declared output type expects an ``int`` for ``value``; returning the secret string + # instead triggers output validation, whose ValidationError repr embeds the raw output value. + return {"value": _TOOL_OUTPUT_SECRET} + + +@pytest.mark.asyncio +async def test_function_tool_output_validation_error_redacts_payload_when_tool_data_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + tool = function_tool( + _returns_wrong_typed_output, + name_override="output_tool", + output_type=_IntegerOutput, + failure_error_function=None, + ) + + with pytest.raises(UserError) as exc_info: + await tool.on_invoke_tool( + ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments="{}"), + "{}", + ) + + error = exc_info.value + assert _TOOL_OUTPUT_SECRET not in str(error) + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, _TOOL_OUTPUT_SECRET) + + +@pytest.mark.asyncio +async def test_function_tool_output_validation_error_preserves_diagnostics_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + tool = function_tool( + _returns_wrong_typed_output, + name_override="output_tool", + output_type=_IntegerOutput, + failure_error_function=None, + ) + + with pytest.raises(UserError) as exc_info: + await tool.on_invoke_tool( + ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments="{}"), + "{}", + ) + + error = exc_info.value + assert _TOOL_OUTPUT_SECRET in str(error) + assert isinstance(error.__cause__, ValidationError) + + _MODEL_OUTPUT_SECRET = "SECRET_MODEL_OUTPUT_123" _SENSITIVE_SCHEMA_SECRET = "SENSITIVE_HANDOFF_SCHEMA_SECRET_4207" _SENSITIVE_OUTPUT_SCHEMA_SECRET = "SENSITIVE_OUTPUT_SCHEMA_SECRET_4207" From 4c7713b93c87a1d28206fa25ce60d4761157cde5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 10:13:23 +0900 Subject: [PATCH 308/473] fix(mcp): protect manager lifecycle state snapshots (#4407) --- src/agents/mcp/manager.py | 56 ++++++++++++++++------------ tests/mcp/test_mcp_server_manager.py | 30 +++++++++++++++ 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index 716b673e1d..de19fd7494 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -211,10 +211,10 @@ def __init__( self._workers: dict[MCPServer, _ServerWorker] = {} self._lifecycle_lock = asyncio.Lock() - self.failed_servers: list[MCPServer] = [] + self._failed_servers: list[MCPServer] = [] self._failed_server_set: set[MCPServer] = set() self._connected_servers: set[MCPServer] = set() - self.errors: dict[MCPServer, BaseException] = {} + self._errors: dict[MCPServer, BaseException] = {} @property def active_servers(self) -> list[MCPServer]: @@ -226,6 +226,16 @@ def all_servers(self) -> list[MCPServer]: """Return all MCP servers managed by this instance.""" return list(self._all_servers) + @property + def failed_servers(self) -> list[MCPServer]: + """Return a snapshot of MCP servers with recorded failures.""" + return list(self._failed_servers) + + @property + def errors(self) -> dict[MCPServer, BaseException]: + """Return a snapshot of recorded MCP server errors.""" + return dict(self._errors) + @property def connect_timeout_seconds(self) -> float | None: """Return the lifecycle connect timeout.""" @@ -268,9 +278,9 @@ async def connect_all(self) -> list[MCPServer]: async def _connect_all(self) -> list[MCPServer]: previous_connected_servers = set(self._connected_servers) previous_active_servers = list(self._active_servers) - self.failed_servers = [] + self._failed_servers = [] self._failed_server_set = set() - self.errors = {} + self._errors = {} servers_to_connect = self._servers_to_connect(self._all_servers) connected_servers: list[MCPServer] = [] @@ -287,7 +297,7 @@ async def _connect_all(self) -> list[MCPServer]: await self._cleanup_servers(servers_to_connect) else: servers_to_cleanup = self._unique_servers( - [*connected_servers, *self.failed_servers] + [*connected_servers, *self._failed_servers] ) await self._cleanup_servers(servers_to_cleanup) if self.drop_failed_servers: @@ -318,14 +328,14 @@ async def reconnect(self, *, failed_only: bool = True) -> list[MCPServer]: async def _reconnect(self, *, failed_only: bool) -> list[MCPServer]: if failed_only: - failed_servers = self._unique_servers(self.failed_servers) + failed_servers = self._unique_servers(self._failed_servers) servers_to_retry = await self._cleanup_servers(failed_servers) else: await self._cleanup_all() servers_to_retry = list(self._all_servers) - self.failed_servers = [] + self._failed_servers = [] self._failed_server_set = set() - self.errors = {} + self._errors = {} servers_to_retry = self._servers_to_connect(servers_to_retry) try: @@ -368,14 +378,14 @@ async def _cleanup_all(self) -> None: get_mcp_server_log_message("Cleanup cancelled for MCP server", server), exc, ) - self.errors[server] = exc + self._errors[server] = exc except Exception as exc: log_tool_action_error( logger, get_mcp_server_log_message("Failed to cleanup MCP server", server), exc, ) - self.errors[server] = exc + self._errors[server] = exc async def _run_with_timeout( self, func: Callable[[], Awaitable[Any]], timeout_seconds: float | None @@ -390,9 +400,9 @@ async def _attempt_connect( try: await self._run_connect(server) self._connected_servers.add(server) - if server in self.failed_servers: + if server in self._failed_server_set: self._remove_failed_server(server) - self.errors.pop(server, None) + self._errors.pop(server, None) except asyncio.CancelledError as exc: # Always record so connect_all()'s failure cleanup includes this server. # Re-raising without recording left partially-opened servers uncleaned @@ -422,9 +432,9 @@ def _record_failure(self, server: MCPServer, exc: BaseException, phase: str) -> exc, ) if server not in self._failed_server_set: - self.failed_servers.append(server) + self._failed_servers.append(server) self._failed_server_set.add(server) - self.errors[server] = exc + self._errors[server] = exc async def _run_connect(self, server: MCPServer) -> None: if self.connect_in_parallel: @@ -469,14 +479,14 @@ async def _cleanup_servers(self, servers: Iterable[MCPServer]) -> list[MCPServer get_mcp_server_log_message("Cleanup cancelled for MCP server", server), exc, ) - self.errors[server] = exc + self._errors[server] = exc except Exception as exc: log_tool_action_error( logger, get_mcp_server_log_message("Failed to cleanup MCP server", server), exc, ) - self.errors[server] = exc + self._errors[server] = exc else: cleaned_servers.add(server) return [server for server in servers_list if server in cleaned_servers] @@ -494,19 +504,19 @@ async def _connect_all_parallel(self, servers: list[MCPServer]) -> None: for result in results: if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError): raise result - if self.strict and self.failed_servers: + if self.strict and self._failed_servers: first_failure = None if self.suppress_cancelled_error: - for server in self.failed_servers: - error = self.errors.get(server) + for server in self._failed_servers: + error = self._errors.get(server) if error is None or isinstance(error, asyncio.CancelledError): continue first_failure = server break else: - first_failure = self.failed_servers[0] + first_failure = self._failed_servers[0] if first_failure is not None: - error = self.errors.get(first_failure) + error = self._errors.get(first_failure) if error is not None: raise error raise RuntimeError(f"Failed to connect MCP server '{first_failure.name}'") @@ -540,8 +550,8 @@ def _discard_worker(self, server: MCPServer, worker: _ServerWorker) -> None: def _remove_failed_server(self, server: MCPServer) -> None: if server in self._failed_server_set: self._failed_server_set.remove(server) - self.failed_servers = [ - failed_server for failed_server in self.failed_servers if failed_server != server + self._failed_servers = [ + failed_server for failed_server in self._failed_servers if failed_server != server ] def _servers_to_connect(self, servers: Iterable[MCPServer]) -> list[MCPServer]: diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index 7b4e0c2d05..40476867a6 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -861,6 +861,36 @@ async def test_manager_reconnect_failed_only() -> None: assert manager.failed_servers == [] +@pytest.mark.asyncio +async def test_failed_servers_snapshot_mutation_does_not_suppress_reconnect() -> None: + server = FlakyServer(failures=1) + + async with MCPServerManager([server]) as manager: + failed_servers = manager.failed_servers + failed_servers.clear() + + assert manager.failed_servers == [server] + + await manager.reconnect(failed_only=True) + + assert server.connect_calls == 2 + assert manager.active_servers == [server] + assert manager.failed_servers == [] + + +@pytest.mark.asyncio +async def test_errors_snapshot_mutation_does_not_erase_diagnostics() -> None: + server = FlakyServer(failures=1) + + async with MCPServerManager([server]) as manager: + original_error = manager.errors[server] + errors = manager.errors + errors.clear() + errors[server] = RuntimeError("replacement error") + + assert manager.errors == {server: original_error} + + @pytest.mark.asyncio @pytest.mark.parametrize("connect_in_parallel", [False, True]) async def test_manager_reconnect_cleans_partial_failure_before_retry( From c8814171647e3aa7971759f4bd64e70b8eb8afde Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 10:34:49 +0900 Subject: [PATCH 309/473] fix: freeze public testing API state contracts (#4404) --- .../scripts/update_released_api_contract.py | 3 +- integration_tests/_contract_support.py | 272 +++++++++++++- tests/README.md | 2 +- .../released_api_contract_policy.json | 91 +++++ tests/test_released_api_contract.py | 343 +++++++++++++++++- 5 files changed, 689 insertions(+), 22 deletions(-) diff --git a/.github/scripts/update_released_api_contract.py b/.github/scripts/update_released_api_contract.py index a1b2f4c5db..4c71ac9849 100644 --- a/.github/scripts/update_released_api_contract.py +++ b/.github/scripts/update_released_api_contract.py @@ -126,7 +126,8 @@ def main() -> int: print(f"Removed exports: {sorted(previous_exports - current_exports)!r}") print( "Review shipped example imports and update released_api_contract_policy.json when " - "the release adds canonical imports, public properties, or public modules." + "the release adds canonical imports, public properties, public TypedDict fields, or " + "public modules." ) return 0 diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 64293a2ed9..c8dd69ccad 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -13,9 +13,11 @@ from importlib.util import find_spec from pathlib import Path from types import FunctionType, TracebackType -from typing import Any, cast +from typing import Any, ForwardRef, cast, get_origin, get_type_hints +import typing_extensions from pydantic import BaseModel +from typing_extensions import NotRequired, Required @dataclasses.dataclass(frozen=True) @@ -35,6 +37,7 @@ class SubmoduleExportPolicy: dependency_installations: tuple[OptionalDependencyInstallation, ...] canonical_imports: tuple[dict[str, str], ...] = () public_properties: tuple[dict[str, Any], ...] = () + public_typed_dicts: tuple[dict[str, Any], ...] = () def load_api_contract(path: Path) -> dict[str, Any]: @@ -48,7 +51,14 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: if not isinstance(value, dict): raise ValueError("submodule export policy must be an object") unknown_top_level_fields = sorted( - set(value) - {"canonical_imports", "modules", "optional_dependencies", "public_properties"} + set(value) + - { + "canonical_imports", + "modules", + "optional_dependencies", + "public_properties", + "public_typed_dicts", + } ) if unknown_top_level_fields: raise ValueError( @@ -155,6 +165,7 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: ), canonical_imports=_canonical_import_policy(value.get("canonical_imports", [])), public_properties=_public_property_policy(value.get("public_properties", [])), + public_typed_dicts=_public_typed_dict_policy(value.get("public_typed_dicts", [])), ) @@ -188,13 +199,61 @@ def _canonical_import_policy(value: object) -> tuple[dict[str, str], ...]: def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: if not isinstance(value, list): raise ValueError("submodule export policy public_properties must be a list") + entries: list[dict[str, Any]] = [] + identities: set[tuple[str, str, str]] = set() + for entry in value: + if not isinstance(entry, dict): + raise ValueError("submodule export policy public_properties entries must be objects") + owner_fields = {"class_name", "factory_name"} & set(entry) + if len(owner_fields) != 1 or set(entry) != {"module", "names", *owner_fields}: + raise ValueError( + "submodule export policy public_properties entries must contain exactly " + "module, names, and one of class_name or factory_name" + ) + owner_field = next(iter(owner_fields)) + module_name = entry["module"] + owner_name = entry[owner_field] + names = entry["names"] + if type(module_name) is not str or not module_name: + raise ValueError( + "submodule export policy public_properties module must be a non-empty string" + ) + if type(owner_name) is not str or not owner_name: + raise ValueError( + f"submodule export policy public_properties {owner_field} must be a non-empty " + "string" + ) + if ( + not isinstance(names, list) + or not names + or not all(type(name) is str and name for name in names) + or len(names) != len(set(names)) + ): + raise ValueError( + "submodule export policy public_properties names must be a non-empty list of " + "unique non-empty strings" + ) + identity = (owner_field, module_name, owner_name) + if identity in identities: + raise ValueError( + "submodule export policy public_properties must not repeat " + f"{module_name}.{owner_name}" + ) + identities.add(identity) + entries.append({owner_field: owner_name, "module": module_name, "names": list(names)}) + return tuple(entries) + + +def _public_typed_dict_policy(value: object) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_typed_dicts must be a list") required_fields = {"class_name", "module", "names"} entries: list[dict[str, Any]] = [] identities: set[tuple[str, str]] = set() for entry in value: if not isinstance(entry, dict) or set(entry) != required_fields: raise ValueError( - "submodule export policy public_properties entries must contain exactly " + "submodule export policy public_typed_dicts entries must contain exactly " "class_name, module, and names" ) module_name = entry["module"] @@ -202,11 +261,11 @@ def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: names = entry["names"] if type(module_name) is not str or not module_name: raise ValueError( - "submodule export policy public_properties module must be a non-empty string" + "submodule export policy public_typed_dicts module must be a non-empty string" ) if type(class_name) is not str or not class_name: raise ValueError( - "submodule export policy public_properties class_name must be a non-empty string" + "submodule export policy public_typed_dicts class_name must be a non-empty string" ) if ( not isinstance(names, list) @@ -215,13 +274,13 @@ def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: or len(names) != len(set(names)) ): raise ValueError( - "submodule export policy public_properties names must be a non-empty list of " + "submodule export policy public_typed_dicts names must be a non-empty list of " "unique non-empty strings" ) identity = (module_name, class_name) if identity in identities: raise ValueError( - "submodule export policy public_properties must not repeat " + "submodule export policy public_typed_dicts must not repeat " f"{module_name}.{class_name}" ) identities.add(identity) @@ -648,10 +707,10 @@ def _merge_public_properties( existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] ) -> list[dict[str, Any]]: result = [deepcopy(dict(entry)) for entry in existing] - by_identity = {(entry["module"], entry["class_name"]): entry for entry in result} + by_identity = {_public_property_identity(entry): entry for entry in result} for entry_value in promoted: entry = deepcopy(dict(entry_value)) - identity = (entry["module"], entry["class_name"]) + identity = _public_property_identity(entry) previous = by_identity.get(identity) if previous is None: result.append(entry) @@ -664,6 +723,118 @@ def _merge_public_properties( return result +def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]: + if "class_name" in entry: + return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"])) + return ("factory_name", cast(str, entry["module"]), cast(str, entry["factory_name"])) + + +def _annotation_contract(annotation: object) -> str: + if isinstance(annotation, ForwardRef): + annotation_text = annotation.__forward_arg__ + elif isinstance(annotation, str): + annotation_text = annotation + else: + annotation_text = inspect.formatannotation(annotation) + for wrapper_name in ("Required", "NotRequired"): + for module_name in ("typing", "typing_extensions"): + qualified_prefix = f"{module_name}.{wrapper_name}[" + if annotation_text.startswith(qualified_prefix): + return f"{wrapper_name}[{annotation_text.removeprefix(qualified_prefix)}" + return annotation_text + + +def _typed_dict_field_is_required(typed_dict: type, name: str, annotation: object) -> bool: + if isinstance(annotation, ForwardRef): + annotation_text = annotation.__forward_arg__ + if annotation_text.startswith( + ("Required[", "typing.Required[", "typing_extensions.Required[") + ): + return True + if annotation_text.startswith( + ("NotRequired[", "typing.NotRequired[", "typing_extensions.NotRequired[") + ): + return False + origin = get_origin(annotation) + if origin is Required: + return True + if origin is NotRequired: + return False + required_keys = getattr(typed_dict, "__required_keys__", frozenset()) + optional_keys = getattr(typed_dict, "__optional_keys__", frozenset()) + if name in required_keys: + return True + if name in optional_keys: + return False + return bool(getattr(typed_dict, "__total__", True)) + + +def _typed_dict_field_contract(typed_dict: type, name: str) -> dict[str, object] | None: + annotation = getattr(typed_dict, "__annotations__", {}).get(name) + if annotation is None: + return None + return { + "name": name, + "required": _typed_dict_field_is_required(typed_dict, name, annotation), + "annotation": _annotation_contract(annotation), + } + + +def _public_typed_dict_contract( + policy_entries: Iterable[Mapping[str, Any]], + agents_module: Any | None, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for policy_entry in policy_entries: + module_name = cast(str, policy_entry["module"]) + class_name = cast(str, policy_entry["class_name"]) + module = _import_contract_module(module_name, agents_module) + typed_dict = getattr(module, class_name, None) + if not typing_extensions.is_typeddict(typed_dict): + raise ValueError( + f"Cannot promote public TypedDict {module_name}.{class_name} because it is " + "missing or no longer a TypedDict" + ) + fields: list[dict[str, object]] = [] + for name in policy_entry["names"]: + field = _typed_dict_field_contract(typed_dict, name) + if field is None: + raise ValueError( + f"Cannot promote public TypedDict field {module_name}.{class_name}.{name} " + "because it is missing" + ) + fields.append(field) + entries.append({"class_name": class_name, "fields": fields, "module": module_name}) + return entries + + +def _merge_public_typed_dicts( + existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] +) -> list[dict[str, Any]]: + result = [deepcopy(dict(entry)) for entry in existing] + by_identity = {(entry["module"], entry["class_name"]): entry for entry in result} + for entry_value in promoted: + entry = deepcopy(dict(entry_value)) + identity = (entry["module"], entry["class_name"]) + previous = by_identity.get(identity) + if previous is None: + result.append(entry) + by_identity[identity] = entry + continue + previous_by_name = {field["name"]: field for field in previous["fields"]} + for field in entry["fields"]: + existing_field = previous_by_name.get(field["name"]) + if existing_field is not None and existing_field != field: + raise ValueError( + "release policy public TypedDict field conflicts with the released contract " + f"for {entry['module']}.{entry['class_name']}.{field['name']}" + ) + if existing_field is None: + previous["fields"].append(field) + previous_by_name[field["name"]] = field + return result + + def _optional_dependency_unsupported_platforms( contract: Mapping[str, Any], ) -> dict[str, tuple[str, ...]]: @@ -919,6 +1090,12 @@ def build_released_api_contract( contract.get("public_properties", []), release_policy.public_properties if release_policy is not None else (), ) + updated["public_typed_dicts"] = _merge_public_typed_dicts( + contract.get("public_typed_dicts", []), + _public_typed_dict_contract(release_policy.public_typed_dicts, agents_module) + if release_policy is not None + else (), + ) if release_policy is not None: updated["optional_dependency_unsupported_platforms"] = { dependency_module: list(platforms) @@ -1044,6 +1221,7 @@ def build_released_api_contract( "optional_dependency_unsupported_platforms", "platform_import_errors", "public_properties", + "public_typed_dicts", "public_modules", "required_submodule_exports", "required_top_level_exports", @@ -1139,8 +1317,8 @@ def _validate_public_property_contract( unsupported_platforms = unsupported_platforms or {} for entry in contract.get("public_properties", []): module_name = entry["module"] - class_name = entry["class_name"] - optional_dependency = _optional_dependency_for_binding(contract, module_name, class_name) + owner_name = entry.get("class_name", entry.get("factory_name")) + optional_dependency = _optional_dependency_for_binding(contract, module_name, owner_name) if optional_dependency is not None and not _optional_dependency_is_available_for_contract( optional_dependency, unsupported_platforms ): @@ -1150,20 +1328,75 @@ def _validate_public_property_contract( except Exception as error: errors.append(f"Failed to import released module {module_name}: {error!r}") continue - class_value = getattr(module, class_name, None) - if not isinstance(class_value, type): - errors.append(f"Missing released public class {module_name}.{class_name}") - continue + if "class_name" in entry: + class_value = getattr(module, owner_name, None) + if not isinstance(class_value, type): + errors.append(f"Missing released public class {module_name}.{owner_name}") + continue + else: + factory = getattr(module, owner_name, None) + if not callable(factory): + errors.append(f"Missing released public factory {module_name}.{owner_name}") + continue + try: + class_value = get_type_hints(factory)["return"] + except (KeyError, NameError, TypeError) as error: + errors.append( + f"Unable to resolve released public factory return type " + f"{module_name}.{owner_name}: {error!r}" + ) + continue + if not isinstance(class_value, type): + errors.append( + f"Released public factory {module_name}.{owner_name} no longer returns a class" + ) + continue for property_name in entry["names"]: descriptor = inspect.getattr_static(class_value, property_name, None) if not isinstance(descriptor, property): errors.append( - f"{module_name}.{class_name}.{property_name} " + f"{module_name}.{owner_name}.{property_name} " "removed or changed a released public property" ) return errors +def _validate_public_typed_dict_contract( + contract: dict[str, Any], + agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, +) -> list[str]: + errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} + for entry in contract.get("public_typed_dicts", []): + module_name = entry["module"] + class_name = entry["class_name"] + optional_dependency = _optional_dependency_for_binding(contract, module_name, class_name) + if optional_dependency is not None and not _optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ): + continue + try: + module = _import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + typed_dict = getattr(module, class_name, None) + if not typing_extensions.is_typeddict(typed_dict): + errors.append(f"Missing released public TypedDict {module_name}.{class_name}") + continue + for released_field in entry["fields"]: + current_field = _typed_dict_field_contract(typed_dict, released_field["name"]) + if current_field != released_field: + errors.append( + f"{module_name}.{class_name}.{released_field['name']} changed its released " + f"TypedDict field contract: expected {released_field!r}, got " + f"{current_field!r}" + ) + return errors + + def _submodule_export_contract( module: object, *, @@ -1269,6 +1502,13 @@ def validate_released_api_contract( unsupported_platforms=unsupported_platforms, ) ) + errors.extend( + _validate_public_typed_dict_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) missing_exports = sorted(set(contract["required_top_level_exports"]) - set(agents.__all__)) if missing_exports: diff --git a/tests/README.md b/tests/README.md index 1f453ff44e..2d2ef85fa3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -47,7 +47,7 @@ Compare test counts, skips, warnings, assertions, and lifecycle coverage as well Release compatibility unit tests must exercise policy and validation logic with explicit constructed modules instead of inspecting the current checkout's shared import state. The prospective release-contract job validates the current source checkout once in a dedicated Python process, and the packaged integration profiles validate real wheel, sdist, optional-extra, and platform surfaces in isolated environments. Keep the combined serial focused runtime of release compatibility unit tests below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. -The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable top-level class or function signature and execution kind, and every newly exported SDK-owned inspectable class or function in a tracked public submodule. Qualified submodule callables remain tracked in later releases. Callable contracts include Pydantic model field names and defaults plus the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented properties to `public_properties`, newly intended cross-module import identities to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. +The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable top-level class or function signature and execution kind, and every newly exported SDK-owned inspectable class or function in a tracked public submodule. Qualified submodule callables remain tracked in later releases. Callable contracts include Pydantic model field names and defaults plus the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented class properties or factory-result properties to `public_properties`, selected public `TypedDict` fields to `public_typed_dicts`, newly intended cross-module import identities to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator records each selected `TypedDict` field's requiredness and declared annotation without enrolling arbitrary `TypedDict` classes or fields. It merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. ## Snapshots diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index 953a086409..4b3e4af677 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -333,6 +333,57 @@ "stateful_request" ] }, + { + "class_name": "ScriptedModel", + "module": "agents.testing.model", + "names": [ + "calls", + "remaining_steps", + "first_call", + "last_call" + ] + }, + { + "class_name": "ScriptedRealtimeModel", + "module": "agents.realtime.testing", + "names": [ + "listeners", + "connect_calls", + "sent_events", + "remaining_steps" + ] + }, + { + "factory_name": "scripted_sandbox_session", + "module": "agents.testing.sandbox", + "names": [ + "calls", + "remaining_steps" + ] + }, + { + "class_name": "ScriptedSTTModel", + "module": "agents.voice.testing", + "names": [ + "calls", + "session_calls", + "created_sessions" + ] + }, + { + "class_name": "ScriptedTTSModel", + "module": "agents.voice.testing", + "names": [ + "calls" + ] + }, + { + "class_name": "ScriptedVoiceWorkflow", + "module": "agents.voice.testing", + "names": [ + "transcriptions" + ] + }, { "class_name": "RunloopPlatformClient", "module": "agents.extensions.sandbox", @@ -359,5 +410,45 @@ "mount_authority_rebound" ] } + ], + "public_typed_dicts": [ + { + "class_name": "ModelStepSpec", + "module": "agents.testing.model", + "names": [ + "output", + "usage", + "response_id", + "request_id", + "raw_usage", + "error", + "responder", + "stream_events", + "retry_advice" + ] + }, + { + "class_name": "SandboxStepSpec", + "module": "agents.testing.sandbox", + "names": [ + "method", + "match", + "result", + "responder", + "error" + ] + }, + { + "class_name": "RealtimeConnectCall", + "module": "agents.realtime.testing", + "names": [ + "api_key_provided", + "headers_provided", + "url", + "initial_model_settings", + "playback_tracker", + "call_id" + ] + } ] } diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index adf590fef7..98b3cf8663 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -14,6 +14,7 @@ import pytest from pydantic import BaseModel, Field +from typing_extensions import Required, TypedDict import integration_tests._contract_support as contract_support from integration_tests._contract_support import ( @@ -25,6 +26,7 @@ _public_class_member_contract, _validate_parameter_contract, _validate_public_property_contract, + _validate_public_typed_dict_contract, build_released_api_contract, load_api_contract, load_submodule_export_policy, @@ -40,12 +42,14 @@ def _release_policy( dependency_installations: tuple[OptionalDependencyInstallation, ...] = (), canonical_imports: tuple[dict[str, str], ...] = (), public_properties: tuple[dict[str, Any], ...] = (), + public_typed_dicts: tuple[dict[str, Any], ...] = (), ) -> SubmoduleExportPolicy: return SubmoduleExportPolicy( modules=modules, dependency_installations=dependency_installations, canonical_imports=canonical_imports, public_properties=public_properties, + public_typed_dicts=public_typed_dicts, ) @@ -304,6 +308,116 @@ def concrete_only(self) -> str: ] +def test_curated_public_property_contract_supports_factory_return_surfaces( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ScriptedSession: + @property + def calls(self) -> tuple[str, ...]: + return () + + def scripted_session() -> ScriptedSession: + return ScriptedSession() + + contract: dict[str, Any] = { + "public_properties": [ + { + "module": "agents.testing", + "factory_name": "scripted_session", + "names": ["calls", "remaining_steps"], + } + ] + } + testing_module = SimpleNamespace(scripted_session=scripted_session) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda _module_name, _agents_module: testing_module, + ) + + assert _validate_public_property_contract(contract, testing_module) == [ + "agents.testing.scripted_session.remaining_steps removed or changed a released public " + "property" + ] + + +def test_curated_public_typed_dict_contract_detects_field_shape_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ReleasedState(TypedDict, total=False): + required_name: Required[str] + count: int + + contract: dict[str, Any] = { + "public_typed_dicts": [ + { + "module": "agents.testing", + "class_name": "ReleasedState", + "fields": [ + { + "name": "required_name", + "required": True, + "annotation": "Required[str]", + }, + {"name": "count", "required": False, "annotation": "int"}, + ], + } + ] + } + testing_module = SimpleNamespace(ReleasedState=ReleasedState) + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda _module_name, _agents_module: testing_module, + ) + + assert _validate_public_typed_dict_contract(contract, testing_module) == [] + + class ChangedState(TypedDict, total=False): + required_name: int + + testing_module.ReleasedState = ChangedState + + assert _validate_public_typed_dict_contract(contract, testing_module) == [ + "agents.testing.ReleasedState.required_name changed its released TypedDict field " + "contract: expected {'name': 'required_name', 'required': True, 'annotation': " + "'Required[str]'}, got {'name': 'required_name', 'required': False, " + "'annotation': 'int'}", + "agents.testing.ReleasedState.count changed its released TypedDict field contract: " + "expected {'name': 'count', 'required': False, 'annotation': 'int'}, got None", + ] + + +@pytest.mark.parametrize( + ("formatted", "expected"), + [ + ("typing.Required[str]", "Required[str]"), + ("typing_extensions.Required[str]", "Required[str]"), + ("typing.NotRequired[str]", "NotRequired[str]"), + ("typing_extensions.NotRequired[str]", "NotRequired[str]"), + ("mytyping.Required[str]", "mytyping.Required[str]"), + ( + "vendor.typing_extensions.NotRequired[str]", + "vendor.typing_extensions.NotRequired[str]", + ), + ("list[typing.Required[str]]", "list[typing.Required[str]]"), + ], +) +def test_typed_dict_requiredness_annotation_contract_is_python_version_independent( + monkeypatch: pytest.MonkeyPatch, + formatted: str, + expected: str, +) -> None: + annotation = object() + monkeypatch.setattr( + contract_support.inspect, + "formatannotation", + lambda value: formatted if value is annotation else repr(value), + ) + + assert contract_support._annotation_contract(annotation) == expected + + def test_curated_public_property_contract_honors_optional_dependency_availability( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1504,7 +1618,7 @@ def import_module(module_name: str, _agents_module: object) -> object: assert validate_released_api_contract(updated, agents_module=agents_module) == [] -def test_release_contract_policy_promotes_canonical_imports_and_public_properties( +def test_release_contract_policy_promotes_curated_public_state_surfaces( monkeypatch: pytest.MonkeyPatch, ) -> None: class NewPublic: @@ -1516,8 +1630,25 @@ def __init__(self, value: str, optional: int = 1) -> None: def status(self) -> str: return "ready" + class FactoryResult: + @property + def calls(self) -> tuple[str, ...]: + return () + + def create_result() -> FactoryResult: + return FactoryResult() + + class PublicState(TypedDict, total=False): + label: Required[str] + count: int + agents_module = SimpleNamespace(__all__=[]) - submodule = SimpleNamespace(__all__=["NewPublic"], NewPublic=NewPublic) + submodule = SimpleNamespace( + __all__=["NewPublic", "PublicState", "create_result"], + NewPublic=NewPublic, + PublicState=PublicState, + create_result=create_result, + ) modules = { "agents": agents_module, "agents.submodule": submodule, @@ -1530,6 +1661,7 @@ def status(self) -> str: "public_modules": ["agents"], "canonical_imports": [], "public_properties": [], + "public_typed_dicts": [], "callables": {}, } monkeypatch.setattr( @@ -1559,6 +1691,18 @@ def status(self) -> str: "module": "agents.submodule", "names": ["status"], }, + { + "factory_name": "create_result", + "module": "agents.submodule", + "names": ["calls"], + }, + ), + public_typed_dicts=( + { + "class_name": "PublicState", + "module": "agents.submodule", + "names": ["label", "count"], + }, ), ), ) @@ -1576,11 +1720,79 @@ def status(self) -> str: "class_name": "NewPublic", "module": "agents.submodule", "names": ["status"], + }, + { + "factory_name": "create_result", + "module": "agents.submodule", + "names": ["calls"], + }, + ] + assert updated["public_typed_dicts"] == [ + { + "class_name": "PublicState", + "fields": [ + { + "name": "label", + "required": True, + "annotation": "Required[str]", + }, + {"name": "count", "required": False, "annotation": "int"}, + ], + "module": "agents.submodule", } ] assert updated["callables"]["agents.submodule.NewPublic"] == _callable_contract(NewPublic) +def test_typed_dict_only_promotion_updates_baseline_commit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class PublicState(TypedDict, total=False): + value: str + + agents_module = SimpleNamespace(__all__=[]) + submodule = SimpleNamespace(PublicState=PublicState) + contract: dict[str, Any] = { + "baseline": "v0.20.0", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents", "agents.submodule"], + "required_submodule_exports": {}, + "canonical_imports": [], + "public_properties": [], + "optional_dependency_unsupported_platforms": {}, + "submodule_export_exclusions": [], + "callables": {}, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: { + "agents": agents_module, + "agents.submodule": submodule, + }[module_name], + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {}, + public_typed_dicts=( + { + "class_name": "PublicState", + "module": "agents.submodule", + "names": ["value"], + }, + ), + ), + ) + + assert updated["baseline_commit"] == "b" * 40 + + def test_release_contract_policy_honors_unsupported_platform_during_promotion( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2217,6 +2429,9 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: '{"binding_dependency": {"requirement": "binding-package>=1"}, ' '"export_dependency": {"extra": "export-extra"}}, "public_properties": ' '[{"class_name": "ConditionalExport", "module": "agents.submodule", ' + '"names": ["status"]}, {"factory_name": "create_client", ' + '"module": "agents.submodule", "names": ["calls"]}], "public_typed_dicts": ' + '[{"class_name": "ClientState", "module": "agents.submodule", ' '"names": ["status"]}]}', encoding="utf-8", ) @@ -2251,12 +2466,34 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: "name": "ConditionalExport", }, ) - assert policy.public_properties == ( + assert tuple( + entry + for entry in policy.public_properties + if entry["module"] + not in { + "agents.testing.model", + "agents.testing.sandbox", + "agents.realtime.testing", + "agents.voice.testing", + } + ) == ( { "class_name": "ConditionalExport", "module": "agents.submodule", "names": ["status"], }, + { + "factory_name": "create_client", + "module": "agents.submodule", + "names": ["calls"], + }, + ) + assert policy.public_typed_dicts == ( + { + "class_name": "ClientState", + "module": "agents.submodule", + "names": ["status"], + }, ) @@ -2299,7 +2536,17 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: ("agents.extensions.sandbox", "VercelSandboxClient"), ("agents.extensions.sandbox", "VercelSandboxClientOptions"), } - assert policy.public_properties == ( + assert tuple( + entry + for entry in policy.public_properties + if entry["module"] + not in { + "agents.testing.model", + "agents.testing.sandbox", + "agents.realtime.testing", + "agents.voice.testing", + } + ) == ( { "class_name": "RunState", "module": "agents.run_state", @@ -2353,6 +2600,94 @@ def test_repository_release_policy_declares_public_testing_modules() -> None: ).extra == "voice" ) + + +def test_repository_release_policy_declares_public_testing_state_surfaces() -> None: + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + expected_modules = { + "agents.realtime.testing", + "agents.testing", + "agents.testing.model", + "agents.testing.sandbox", + "agents.voice.testing", + } + + assert tuple( + entry + for entry in policy.public_properties + if entry["module"] + in { + "agents.testing.model", + "agents.testing.sandbox", + "agents.realtime.testing", + "agents.voice.testing", + } + ) == ( + { + "class_name": "ScriptedModel", + "module": "agents.testing.model", + "names": ["calls", "remaining_steps", "first_call", "last_call"], + }, + { + "class_name": "ScriptedRealtimeModel", + "module": "agents.realtime.testing", + "names": ["listeners", "connect_calls", "sent_events", "remaining_steps"], + }, + { + "factory_name": "scripted_sandbox_session", + "module": "agents.testing.sandbox", + "names": ["calls", "remaining_steps"], + }, + { + "class_name": "ScriptedSTTModel", + "module": "agents.voice.testing", + "names": ["calls", "session_calls", "created_sessions"], + }, + { + "class_name": "ScriptedTTSModel", + "module": "agents.voice.testing", + "names": ["calls"], + }, + { + "class_name": "ScriptedVoiceWorkflow", + "module": "agents.voice.testing", + "names": ["transcriptions"], + }, + ) + assert policy.public_typed_dicts == ( + { + "class_name": "ModelStepSpec", + "module": "agents.testing.model", + "names": [ + "output", + "usage", + "response_id", + "request_id", + "raw_usage", + "error", + "responder", + "stream_events", + "retry_advice", + ], + }, + { + "class_name": "SandboxStepSpec", + "module": "agents.testing.sandbox", + "names": ["method", "match", "result", "responder", "error"], + }, + { + "class_name": "RealtimeConnectCall", + "module": "agents.realtime.testing", + "names": [ + "api_key_provided", + "headers_provided", + "url", + "initial_model_settings", + "playback_tracker", + "call_id", + ], + }, + ) for module_name in expected_modules: module = importlib.import_module(module_name) assert module.__all__ From dc1bef7b883fe260f8d0b76dec1a75186a41cc5a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 10:41:02 +0900 Subject: [PATCH 310/473] fix: expose the scripted sandbox session type (#4406) --- src/agents/testing/__init__.py | 2 ++ src/agents/testing/sandbox.py | 30 +++++++++++++++---- .../released_api_contract_policy.json | 14 +++++++++ tests/test_released_api_contract.py | 5 ++++ tests/test_scripted_sandbox.py | 18 ++++++++++- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/agents/testing/__init__.py b/src/agents/testing/__init__.py index 91fde0feba..93f1be19f6 100644 --- a/src/agents/testing/__init__.py +++ b/src/agents/testing/__init__.py @@ -18,6 +18,7 @@ SandboxCallMatcherError, SandboxScriptError, SandboxStepSpec, + ScriptedSandboxSession, UnconsumedSandboxSteps, UnexpectedSandboxCall, scripted_sandbox_session, @@ -34,6 +35,7 @@ "SandboxCallMatcherError", "SandboxScriptError", "SandboxStepSpec", + "ScriptedSandboxSession", "ScriptedModel", "UnconsumedModelSteps", "UnexpectedModelCall", diff --git a/src/agents/testing/sandbox.py b/src/agents/testing/sandbox.py index bfb1361843..d086ae7c9d 100644 --- a/src/agents/testing/sandbox.py +++ b/src/agents/testing/sandbox.py @@ -1,5 +1,6 @@ from __future__ import annotations +import abc import copy import inspect import io @@ -88,7 +89,7 @@ def __init__( super().__init__(message) self.call = call self.call_index = call_index - self.actual_method = call.method + self.actual_method: str = call.method self.expected_method = expected_method self.remaining_steps = remaining_steps @@ -100,7 +101,7 @@ def __init__(self, message: str, *, call: SandboxCall, call_index: int) -> None: super().__init__(message) self.call = call self.call_index = call_index - self.method = call.method + self.method: str = call.method class UnconsumedSandboxSteps(SandboxScriptError): @@ -297,9 +298,27 @@ def _normalize_step(input: object, input_index: int) -> _SandboxStep: ) -class _ScriptedSandboxSession(BaseSandboxSession): +class ScriptedSandboxSession(BaseSandboxSession, abc.ABC): + """The typed result interface for ``scripted_sandbox_session``.""" + + @property + @abc.abstractmethod + def calls(self) -> tuple[SandboxCall, ...]: + """Return detached call-history snapshots in invocation order.""" + + @property + @abc.abstractmethod + def remaining_steps(self) -> int: + """Return the number of configured calls that remain.""" + + @abc.abstractmethod + def assert_complete(self) -> None: + """Raise when configured sandbox calls remain unconsumed.""" + + +class _ScriptedSandboxSession(ScriptedSandboxSession): def __init__(self, steps: Sequence[_SandboxStep], *, manifest: Manifest | None) -> None: - self.state = SandboxSessionState( + self.state: SandboxSessionState = SandboxSessionState( type="scripted", snapshot=NoopSnapshot(id="scripted"), manifest=copy.deepcopy(manifest) if manifest is not None else Manifest(), @@ -555,7 +574,7 @@ def scripted_sandbox_session( steps: Iterable[SandboxStepSpec | Mapping[str, Any]] = (), *, manifest: Manifest | None = None, -) -> _ScriptedSandboxSession: +) -> ScriptedSandboxSession: """Create a deterministic provider-free sandbox session for agent workflow tests. Each FIFO step defines ``method`` plus exactly one of ``result``, ``responder``, or ``error``. @@ -576,6 +595,7 @@ def scripted_sandbox_session( "SandboxCallMatcherError", "SandboxScriptError", "SandboxStepSpec", + "ScriptedSandboxSession", "UnconsumedSandboxSteps", "UnexpectedSandboxCall", "scripted_sandbox_session", diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index 4b3e4af677..45fd718d12 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -84,6 +84,12 @@ "module": "agents.testing", "name": "SandboxCallMatcherError" }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "ScriptedSandboxSession", + "module": "agents.testing", + "name": "ScriptedSandboxSession" + }, { "canonical_module": "agents.testing.sandbox", "canonical_name": "SandboxScriptError", @@ -409,6 +415,14 @@ "mount_authority_redacted", "mount_authority_rebound" ] + }, + { + "class_name": "ScriptedSandboxSession", + "module": "agents.testing", + "names": [ + "calls", + "remaining_steps" + ] } ], "public_typed_dicts": [ diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 98b3cf8663..50ef7766c3 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -2572,6 +2572,11 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: "module": "agents.sandbox.session.sandbox_session_state", "names": ["mount_authority_redacted", "mount_authority_rebound"], }, + { + "class_name": "ScriptedSandboxSession", + "module": "agents.testing", + "names": ["calls", "remaining_steps"], + }, ) diff --git a/tests/test_scripted_sandbox.py b/tests/test_scripted_sandbox.py index 722a14d6a9..247e0022fc 100644 --- a/tests/test_scripted_sandbox.py +++ b/tests/test_scripted_sandbox.py @@ -1,11 +1,13 @@ from __future__ import annotations +import inspect import io from pathlib import Path from types import MappingProxyType -from typing import Any, cast +from typing import Any, cast, get_type_hints import pytest +from typing_extensions import assert_type from agents import RunConfig, Runner from agents.sandbox import ExecResult, Manifest, SandboxAgent @@ -18,12 +20,14 @@ SandboxCall, SandboxCallMatcherError, ScriptedModel, + ScriptedSandboxSession, UnconsumedSandboxSteps, UnexpectedSandboxCall, assistant_message, function_call, scripted_sandbox_session, ) +from agents.testing.sandbox import ScriptedSandboxSession as CanonicalScriptedSandboxSession class _CallableBytesIO(io.BytesIO): @@ -36,6 +40,18 @@ def __call__(self) -> None: pass +def test_scripted_sandbox_has_public_result_type() -> None: + session = scripted_sandbox_session() + assert_type(session, ScriptedSandboxSession) + base_session: BaseSandboxSession = session + + assert ScriptedSandboxSession is CanonicalScriptedSandboxSession + assert get_type_hints(scripted_sandbox_session)["return"] is ScriptedSandboxSession + assert inspect.isabstract(ScriptedSandboxSession) + assert isinstance(session, ScriptedSandboxSession) + assert base_session is session + + def test_scripted_sandbox_exposes_only_configured_scriptable_methods() -> None: session = scripted_sandbox_session( [{"method": "exec", "result": ExecResult(stdout=b"", stderr=b"", exit_code=0)}] From f5606931f05c6e27d61536a0d3262b422d33646d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 10:42:41 +0900 Subject: [PATCH 311/473] fix: align Responses parallel tool calls with converted tools (#4405) --- src/agents/extensions/models/any_llm_model.py | 11 +--- src/agents/models/openai_responses.py | 12 ++-- tests/models/test_any_llm_model.py | 38 ++++++++++++ tests/models/test_openai_responses.py | 62 ++++++++++++++++++- 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 81cf898b63..95489fc0ad 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -1051,14 +1051,6 @@ async def _fetch_responses_response( list_input = _to_dump_compatible(list_input) list_input = self._sanitize_any_llm_responses_input(list_input) - parallel_tool_calls = ( - True - if model_settings.parallel_tool_calls and tools - else False - if model_settings.parallel_tool_calls is False - else None - ) - tool_choice = OpenAIResponsesConverter.convert_tool_choice( model_settings.tool_choice, tools=tools, @@ -1073,6 +1065,9 @@ async def _fetch_responses_response( tool_choice=model_settings.tool_choice, ) converted_tools_payload = _materialize_responses_tool_params(converted_tools.tools) + parallel_tool_calls = ( + model_settings.parallel_tool_calls if converted_tools_payload else None + ) include_set = set(converted_tools.includes) if model_settings.response_include is not None: diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 4289d032e8..232b7ae258 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -790,13 +790,6 @@ def _build_response_create_kwargs( list_input = _to_dump_compatible(list_input) list_input = self._remove_openai_responses_api_incompatible_fields(list_input) - if model_settings.parallel_tool_calls and tools: - parallel_tool_calls: bool | Omit = True - elif model_settings.parallel_tool_calls is False: - parallel_tool_calls = False - else: - parallel_tool_calls = omit - should_omit_model = prompt is not None and not self._model_is_explicit effective_request_model: str | ChatModel | None = None if should_omit_model else self.model effective_computer_tool_model = Converter.resolve_computer_tool_model( @@ -825,6 +818,11 @@ def _build_response_create_kwargs( tool_choice=model_settings.tool_choice, ) converted_tools_payload = _materialize_responses_tool_params(converted_tools.tools) + parallel_tool_calls: bool | Omit = ( + self._non_null_or_omit(model_settings.parallel_tool_calls) + if prompt is not None or converted_tools_payload + else omit + ) response_format = Converter.get_response_format(output_schema) model_param: str | ChatModel | Omit = ( effective_request_model if effective_request_model is not None else omit diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 734f44a735..645905447f 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -712,6 +712,44 @@ async def test_any_llm_responses_path_is_used_when_supported(monkeypatch) -> Non assert response.output[0].content[0].text == "Hello" +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("parallel_tool_calls", [True, False, None]) +@pytest.mark.parametrize("tool_source", ["none", "function", "handoff"]) +async def test_any_llm_responses_parallel_tool_calls_follow_converted_tools( + monkeypatch: pytest.MonkeyPatch, + parallel_tool_calls: bool | None, + tool_source: str, +) -> None: + provider = FakeAnyLLMProvider(supports_responses=True, responses_response=_response("Hello")) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model="openai/gpt-5.4-mini", api="responses") + tools: list[Tool] = ( + [function_tool(lambda: "ok", name_override="test_tool")] + if tool_source == "function" + else [] + ) + handoffs = [handoff(Agent(name="handoff"))] if tool_source == "handoff" else [] + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(parallel_tool_calls=parallel_tool_calls), + tools=tools, + output_schema=None, + handoffs=handoffs, + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + params = provider.private_responses_calls[0]["params"] + expected_parallel_tool_calls = parallel_tool_calls if tool_source != "none" else None + assert params.parallel_tool_calls is expected_parallel_tool_calls + assert bool(params.tools) is (tool_source != "none") + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("payload_type", ["dict", "basemodel"]) diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 8ce1c40a0a..93a6da5703 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -21,8 +21,11 @@ ModelSettings, ModelTracing, Runner, + Tool, ToolSearchTool, __version__, + function_tool, + handoff, trace, ) from agents.exceptions import ModelBehaviorError, UserError @@ -180,6 +183,63 @@ class ConnectionClosedError(Exception): return ConnectionClosedError(message) +@pytest.mark.parametrize("parallel_tool_calls", [True, False, None]) +@pytest.mark.parametrize("tool_source", ["none", "function", "handoff"]) +def test_parallel_tool_calls_follow_converted_responses_tools( + parallel_tool_calls: bool | None, + tool_source: str, +) -> None: + tools: list[Tool] = ( + [function_tool(lambda: "ok", name_override="test_tool")] + if tool_source == "function" + else [] + ) + handoffs = [handoff(Agent(name="handoff"))] if tool_source == "handoff" else [] + model = OpenAIResponsesModel( + model="gpt-4", + openai_client=cast(Any, object()), + ) + + kwargs = model._build_response_create_kwargs( + system_instructions=None, + input="hi", + model_settings=ModelSettings(parallel_tool_calls=parallel_tool_calls), + tools=tools, + output_schema=None, + handoffs=handoffs, + ) + + expected_parallel_tool_calls = ( + parallel_tool_calls if tool_source != "none" and parallel_tool_calls is not None else omit + ) + assert kwargs["parallel_tool_calls"] is expected_parallel_tool_calls + assert bool(kwargs["tools"]) is (tool_source != "none") + + +@pytest.mark.parametrize("parallel_tool_calls", [True, False, None]) +def test_parallel_tool_calls_preserve_stored_prompt_overrides( + parallel_tool_calls: bool | None, +) -> None: + model = OpenAIResponsesModel( + model="gpt-4", + openai_client=cast(Any, object()), + ) + + kwargs = model._build_response_create_kwargs( + system_instructions=None, + input="hi", + model_settings=ModelSettings(parallel_tool_calls=parallel_tool_calls), + tools=[], + output_schema=None, + handoffs=[], + prompt={"id": "pmpt_123"}, + ) + + expected_parallel_tool_calls = parallel_tool_calls if parallel_tool_calls is not None else omit + assert kwargs["parallel_tool_calls"] is expected_parallel_tool_calls + assert kwargs["tools"] is omit + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("override_ua", [None, "test_user_agent"]) @@ -985,7 +1045,7 @@ async def test_responses_requests_normalize_dictionary_agent_settings(use_dictio assert kwargs["top_p"] == 1.0 assert kwargs["max_output_tokens"] == 64 assert "max_tokens" not in kwargs - assert kwargs["parallel_tool_calls"] is False + assert kwargs["parallel_tool_calls"] is omit assert kwargs["extra_headers"]["x-model-settings-parity"] == "preserved" assert kwargs["extra_query"] == {"model_settings_parity": "verified"} assert kwargs["extra_body"] == {"prompt_cache_key": "extra-body-cache-key"} From fc2be56cb0876588a1ed279642c85397dfa6ec30 Mon Sep 17 00:00:00 2001 From: Rakshit Sharma <132228481+rxits@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:26:13 +0530 Subject: [PATCH 312/473] fix(voice): stop buffering audio when audio tracing is disabled (#4411) --- src/agents/voice/models/openai_stt.py | 5 +- src/agents/voice/result.py | 5 +- tests/voice/test_openai_stt.py | 43 +++++++++++++++ tests/voice/test_pipeline.py | 76 +++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 24ab3e9b49..cf504d8892 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -280,7 +280,10 @@ async def _stream_audio( if buffer is None: break - self._turn_audio_buffer.append(buffer) + if self._trace_include_sensitive_audio_data: + # The buffer is only read back to populate the span input, so retaining it + # when audio tracing is off would hold a whole turn of PCM for nothing. + self._turn_audio_buffer.append(buffer) try: await self._websocket.send( json.dumps( diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index dcd1661fc5..42af29e704 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -143,7 +143,10 @@ async def _stream_audio( if chunk: buffer.append(chunk) - full_audio_data.append(chunk) + if self._voice_pipeline_config.trace_include_sensitive_audio_data: + # Only read back to populate the span output, so retaining it when + # audio tracing is off would hold a whole segment of PCM for nothing. + full_audio_data.append(chunk) if len(buffer) >= self._buffer_size: combined = pending_byte + b"".join(buffer) if len(combined) % 2 != 0: diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index de9e7b0631..50daf0c2ba 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -795,3 +795,46 @@ async def test_inactivity_timeout(): assert len(collected_turns) == 0, "No transcripts expected, but we got something?" await session.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("trace_include_sensitive_audio_data", [False, True]) +async def test_stream_audio_buffers_turn_audio_only_for_audio_tracing( + trace_include_sensitive_audio_data: bool, +) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=trace_include_sensitive_audio_data, + ) + session._websocket = AsyncMock() + + frames: list[npt.NDArray[np.int16]] = [ + np.zeros(2, dtype=np.int16), + np.ones(2, dtype=np.int16), + ] + audio_queue: asyncio.Queue[npt.NDArray[np.int16 | np.float32] | None] = asyncio.Queue() + for frame in frames: + await audio_queue.put(frame) + await audio_queue.put(None) + + with patch( + "agents.voice.models.openai_stt.transcription_span", + return_value=MagicMock(), + ): + await session._stream_audio(audio_queue) + + # Every frame still reaches the websocket regardless of the tracing setting. + assert session._websocket.send.await_count == len(frames) + + if trace_include_sensitive_audio_data: + assert len(session._turn_audio_buffer) == len(frames) + assert all( + buffered is frame + for buffered, frame in zip(session._turn_audio_buffer, frames, strict=True) + ) + else: + assert session._turn_audio_buffer == [] diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 43f207f525..470cc9543e 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1,10 +1,13 @@ from __future__ import annotations import asyncio +import gc import logging +import weakref from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass, field from typing import Any, Literal, cast +from unittest.mock import patch import numpy as np import numpy.typing as npt @@ -27,6 +30,7 @@ VoiceStreamEventAudio, VoiceStreamEventLifecycle, ) + from agents.voice.testing import ScriptedTTSModel from .helpers import extract_events from .pipeline_test_models import ( @@ -1532,3 +1536,75 @@ async def test_voice_workflow_errors_apply_model_and_tool_logging_policies( assert error in record.args assert record.exc_info is not None assert record.exc_info[1] is error + + +class _WeakrefableChunk(bytearray): + """A buffer subclass that supports weak references, unlike ``bytes`` itself.""" + + +class RetentionProbeTTSModel(ScriptedTTSModel): + """Report whether the previous chunk survived after the pipeline consumed it.""" + + def __init__(self, chunk_count: int = 4) -> None: + super().__init__(model_name="retention-probe-tts") + self.chunk_count = chunk_count + self.retained_after_consumption: list[bool] = [] + + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + chunk_refs: list[weakref.ref[_WeakrefableChunk]] = [] + for _ in range(self.chunk_count): + chunk = _WeakrefableChunk(np.zeros(2, dtype=np.int16).tobytes()) + chunk_refs.append(weakref.ref(chunk)) + yield cast(bytes, chunk) + # Control returns here only once the consumer has finished with the chunk. + del chunk + gc.collect() + # The consumer's `async for` variable still holds the chunk just yielded, so + # probe the one before it: by now only the span accumulator could keep it alive. + if len(chunk_refs) >= 2: + self.retained_after_consumption.append(chunk_refs[-2]() is not None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("trace_include_sensitive_audio_data", [False, True]) +async def test_segment_audio_is_retained_only_for_audio_tracing( + trace_include_sensitive_audio_data: bool, +) -> None: + tts_model = RetentionProbeTTSModel() + result = StreamedAudioResult( + tts_model, + # A buffer size of 1 flushes every chunk immediately, so the only thing that can + # still hold one afterwards is the span's audio accumulator. + TTSModelSettings(buffer_size=1), + VoicePipelineConfig( + trace_include_sensitive_audio_data=trace_include_sensitive_audio_data, + ), + ) + collected: list[list[bytes]] = [] + + def fake_audio_to_base64(audio_data: list[bytes]) -> str: + collected.append(list(audio_data)) + return "" + + local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() + with trace("test"): + with patch("agents.voice.result._audio_to_base64", fake_audio_to_base64): + await result._stream_audio("one two three", local_queue) + + expected_chunk = np.zeros(2, dtype=np.int16).tobytes() + if trace_include_sensitive_audio_data: + assert tts_model.retained_after_consumption == [True, True, True] + assert collected == [[expected_chunk] * tts_model.chunk_count] + else: + assert tts_model.retained_after_consumption == [False, False, False] + assert collected == [] + + # The emitted audio is identical either way. + emitted: list[bytes] = [] + while not local_queue.empty(): + event = local_queue.get_nowait() + if event is None: + continue + if isinstance(event, VoiceStreamEventAudio) and event.data is not None: + emitted.append(event.data.tobytes()) + assert emitted == [expected_chunk] * tts_model.chunk_count From 40927c9f95c5efc28382d4d76c028e3be2fb04df Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 18:07:31 +0900 Subject: [PATCH 313/473] fix: max-turn handler session semantics (#4412) Co-authored-by: DongBo <80384548+russeell@users.noreply.github.com> --- src/agents/exceptions.py | 97 ++- src/agents/result.py | 19 +- src/agents/run.py | 231 +++-- .../run_internal/agent_runner_helpers.py | 14 +- src/agents/run_internal/run_loop.py | 335 +++++-- tests/test_error_logging_redaction.py | 814 ++++++++++++++++++ tests/test_max_turns.py | 496 +++++++++++ 7 files changed, 1832 insertions(+), 174 deletions(-) diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index 4d30763b51..a07981906f 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -5,6 +5,7 @@ import sys import traceback import types +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, NoReturn, cast @@ -36,6 +37,10 @@ _SYSTEM_EXIT_CODE_DESCRIPTOR = cast(Any, SystemExit).__dict__["code"] +class _RedactedExceptionCancellationError(asyncio.CancelledError, Exception): + """Payload-free cancellation that remains catchable as an Exception.""" + + def _mark_error_to_drain_stream_events(error: BaseException) -> None: setattr(error, _DRAIN_STREAM_EVENTS_ATTR, True) @@ -151,6 +156,28 @@ def _detach_data_redacted_error_traceback(error: BaseException) -> None: descriptor.__set__(error, None) +async def _await_data_redacted_error_boundary( + awaitable_factory: Callable[[], Awaitable[Any]], +) -> Any: + """Create an awaitable lazily and re-raise marked failures without payload data.""" + awaitable: Awaitable[Any] | None = None + redacted_error: BaseException | None = None + try: + awaitable = awaitable_factory() + return await awaitable + except BaseException as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + + awaitable_factory = cast(Any, None) + awaitable = None + assert redacted_error is not None + _detach_data_redacted_error_traceback(redacted_error) + _raise_data_redacted_error(redacted_error) + + def _prepare_data_redacted_error( error: BaseException, *, @@ -188,13 +215,40 @@ def _prepare_data_redacted_error( return safe_error -def _replace_data_redacted_process_control_error( +def _base_exception_group_exceptions( + error: BaseException, +) -> tuple[BaseException, ...] | None: + """Read exception-group children without invoking subclass descriptors.""" + if not issubclass(type(error), BaseExceptionGroup): + return None + try: + if sys.version_info < (3, 11): + state = _base_exception_instance_dict(error) + raw_exceptions = ( + _exact_string_state_value(state, "_exceptions") if state is not None else None + ) + else: + descriptor = type.__getattribute__(BaseExceptionGroup, "__dict__")["exceptions"] + raw_exceptions = descriptor.__get__(error, type(error)) + except BaseException: + return None + if type(raw_exceptions) is not tuple: + return None + if not all(issubclass(type(candidate), BaseException) for candidate in raw_exceptions): + return None + return cast(tuple[BaseException, ...], raw_exceptions) + + +def _copy_data_redacted_process_control_error( error: BaseException, ) -> BaseException | None: - """Discard a process-control source and return a fresh value-free replacement.""" + """Return a fresh process-control replacement without mutating the source.""" error_type = type(error) if issubclass(error_type, asyncio.CancelledError): - safe_error: BaseException | None = asyncio.CancelledError() + if issubclass(error_type, Exception): + safe_error: BaseException | None = _RedactedExceptionCancellationError() + else: + safe_error = asyncio.CancelledError() elif issubclass(error_type, GeneratorExit): safe_error = GeneratorExit() elif issubclass(error_type, KeyboardInterrupt): @@ -217,7 +271,6 @@ def _replace_data_redacted_process_control_error( safe_error = SystemExit(effective_code) assert safe_error is not None - _discard_exception_graph(error) try: _mark_error_data_redacted(safe_error) except BaseException: @@ -225,6 +278,17 @@ def _replace_data_redacted_process_control_error( return safe_error +def _replace_data_redacted_process_control_error( + error: BaseException, +) -> BaseException | None: + """Discard a process-control source and return a fresh value-free replacement.""" + safe_error = _copy_data_redacted_process_control_error(error) + if safe_error is None: + return None + _discard_exception_graph(error) + return safe_error + + def _collect_nested_exceptions(value: object, linked: list[BaseException]) -> None: """Collect exceptions reachable through exact built-in containers without callbacks.""" pending = [value] @@ -268,30 +332,9 @@ def _discard_exception_graph(error: BaseException) -> None: group_exceptions: tuple[BaseException, ...] | None = None current_type = type(current) if issubclass(current_type, BaseExceptionGroup): - try: - if sys.version_info < (3, 11): - group_state = _base_exception_instance_dict(current) - raw_group_exceptions = ( - _exact_string_state_value(group_state, "_exceptions") - if group_state is not None - else None - ) - else: - group_descriptor = type.__getattribute__(BaseExceptionGroup, "__dict__")[ - "exceptions" - ] - raw_group_exceptions = group_descriptor.__get__(current, current_type) - if type(raw_group_exceptions) is tuple: - group_exceptions = tuple( - cast(BaseException, candidate) - for candidate in raw_group_exceptions - if issubclass(type(candidate), BaseException) - ) - else: - group_exceptions = () + group_exceptions = _base_exception_group_exceptions(current) + if group_exceptions is not None: linked.extend(group_exceptions) - except BaseException: - pass for descriptor in ( cast(Any, BaseException.__cause__), cast(Any, BaseException.__context__), diff --git a/src/agents/result.py b/src/agents/result.py index fc5a938f78..22219303f9 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -18,6 +18,7 @@ InputGuardrailTripwireTriggered, MaxTurnsExceeded, RunErrorDetails, + _await_data_redacted_error_boundary, _detach_data_redacted_error_traceback, _is_error_data_redacted, _should_drain_stream_events_before_raising, @@ -706,18 +707,28 @@ def ensure_sandbox_cleanup_on_completion(self) -> None: async def _await_run_and_cleanup() -> Any: try: result = await original_task - except asyncio.CancelledError: + except asyncio.CancelledError as error: if not original_task.done(): original_task.cancel() + if _is_error_data_redacted(error): + _detach_data_redacted_error_traceback(error) raise - except Exception: + except Exception as error: await self._run_sandbox_cleanup() + if _is_error_data_redacted(error): + _detach_data_redacted_error_traceback(error) + raise + except BaseException as error: + if _is_error_data_redacted(error): + _detach_data_redacted_error_traceback(error) raise await self._run_sandbox_cleanup() return result - self.run_loop_task = asyncio.create_task(_await_run_and_cleanup()) + self.run_loop_task = asyncio.create_task( + _await_data_redacted_error_boundary(_await_run_and_cleanup) + ) @property def run_loop_exception(self) -> BaseException | None: @@ -743,7 +754,7 @@ def run_loop_exception(self) -> BaseException | None: if task is None or not task.done() or task.cancelled(): return None error = task.exception() - if isinstance(error, Exception) and _is_error_data_redacted(error): + if error is not None and _is_error_data_redacted(error): _detach_data_redacted_error_traceback(error) return error diff --git a/src/agents/run.py b/src/agents/run.py index c2c4839fb3..b3fa3f132d 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -18,9 +18,12 @@ OutputGuardrailTripwireTriggered, RunErrorDetails, UserError, + _await_data_redacted_error_boundary, _clear_data_redacted_error_traceback, _detach_data_redacted_error_traceback, _is_error_data_redacted, + _prepare_data_redacted_error, + _raise_data_redacted_error, ) from .guardrail import ( InputGuardrailResult, @@ -80,10 +83,7 @@ from .run_internal.error_handlers import ( attach_generic_agent_error, build_run_error_data, - create_message_output_item, - format_final_output_text, resolve_run_error_handler_result, - validate_handler_final_output, ) from .run_internal.items import ( copy_input_items, @@ -96,11 +96,11 @@ from .run_internal.run_loop import ( _retained_items_for_blocked_output, cleanup_models_after_run, + finalize_max_turns_handler_output, get_all_tools, get_output_schema, initialize_computer_tools, resolve_interrupted_turn, - run_final_output_hooks, run_input_guardrails, run_output_guardrails, run_single_turn, @@ -177,6 +177,23 @@ def set_default_agent_runner(runner: AgentRunner | None) -> None: DEFAULT_AGENT_RUNNER = runner if runner is not None else AgentRunner() +def _data_redacted_sync_cancellation_source(error: BaseException) -> BaseException | None: + """Return the marked task cancellation wrapped by Python 3.10, if any.""" + if not issubclass(type(error), asyncio.CancelledError): + return None + try: + context = cast(Any, BaseException.__context__).__get__(error, type(error)) + except BaseException: + return None + if ( + context is not None + and issubclass(type(context), asyncio.CancelledError) + and _is_error_data_redacted(context) + ): + return cast(BaseException, context) + return None + + def get_default_agent_runner() -> AgentRunner: """ WARNING: this class is experimental and not part of the public API @@ -285,7 +302,7 @@ async def run( """ runner = DEFAULT_AGENT_RUNNER - redacted_error: AgentsException | None = None + redacted_error: BaseException | None = None try: return await runner.run( starting_agent, @@ -300,7 +317,7 @@ async def run( conversation_id=conversation_id, session=session, ) - except AgentsException as error: + except BaseException as error: if not _is_error_data_redacted(error): raise _detach_data_redacted_error_traceback(error) @@ -388,7 +405,7 @@ def run_sync( """ runner = DEFAULT_AGENT_RUNNER - redacted_error: AgentsException | None = None + redacted_error: BaseException | None = None try: return runner.run_sync( starting_agent, @@ -403,7 +420,7 @@ def run_sync( session=session, auto_previous_response_id=auto_previous_response_id, ) - except AgentsException as error: + except BaseException as error: if not _is_error_data_redacted(error): raise _detach_data_redacted_error_traceback(error) @@ -514,6 +531,29 @@ async def run( starting_agent: Agent[TContext], input: str | list[TResponseInputItem] | RunState[TContext], **kwargs: Unpack[RunOptions[TContext]], + ) -> RunResult: + redacted_error: BaseException | None = None + try: + return await self._run_impl(starting_agent, input, **kwargs) + except BaseException as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + + self = cast(Any, None) + starting_agent = cast(Any, None) + input = cast(Any, None) + cast(dict[str, Any], kwargs).clear() + assert redacted_error is not None + _detach_data_redacted_error_traceback(redacted_error) + raise redacted_error from None + + async def _run_impl( + self, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem] | RunState[TContext], + **kwargs: Unpack[RunOptions[TContext]], ) -> RunResult: context = kwargs.get("context") max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS) @@ -829,7 +869,9 @@ def _mark_response_hooks_started() -> None: # Output guardrails run once, at the end of the run. Accumulate their results # here so the failure handler below can report them on the raised exception. - output_guardrail_results: list[OutputGuardrailResult] = [] + output_guardrail_results: list[OutputGuardrailResult] = ( + list(run_state._output_guardrail_results) if run_state is not None else [] + ) tool_input_guardrail_results: list[ToolInputGuardrailResult] = ( list(getattr(run_state, "_tool_input_guardrail_results", [])) if run_state is not None @@ -1278,29 +1320,51 @@ def _mark_response_hooks_started() -> None: if handler_result is None: raise max_turns_error - validated_output = validate_handler_final_output( - current_agent, handler_result.final_output - ) - output_text = format_final_output_text(current_agent, validated_output) - synthesized_item = create_message_output_item(current_agent, output_text) include_in_history = handler_result.include_in_history - if include_in_history: - generated_items.append(synthesized_item) - session_items.append(synthesized_item) - - await run_final_output_hooks( - current_agent, - hooks, - context_wrapper, - validated_output, - ) - await run_output_guardrails( - current_agent.output_guardrails + (run_config.output_guardrails or []), - current_agent, + handler_output_recorded = False + handler_persisted_item_count = 0 + + async def _save_max_turns_handler_output( + items: list[RunItem], + store_setting: bool | None = store_setting, + generated_items: list[RunItem] = generated_items, + session_items: list[RunItem] = session_items, + ) -> None: + nonlocal handler_output_recorded, handler_persisted_item_count + handler_persisted_item_count = ( + await save_final_turn_items_after_guardrails( + session=session, + run_state=None, + session_persistence_enabled=session_persistence_enabled, + input_guardrail_results=_attempt_input_guardrail_results(), + items=items, + response_id=None, + reasoning_item_id_policy=resolved_reasoning_item_id_policy, + store=store_setting, + wrapper=context_wrapper, + ) + ) + if not items: + return + generated_items.extend(items) + session_items.extend(items) + handler_output_recorded = True + + ( validated_output, - context_wrapper, - output_guardrail_results, + synthesized_item, + ) = await finalize_max_turns_handler_output( + agent=current_agent, + hooks=hooks, + run_config=run_config, + output=handler_result.final_output, + context_wrapper=context_wrapper, + output_guardrail_results=output_guardrail_results, + save_items_after_guardrails=_save_max_turns_handler_output, + include_in_history=include_in_history, ) + if include_in_history and not handler_output_recorded: + await _save_max_turns_handler_output([synthesized_item]) current_step = getattr(run_state, "_current_step", None) approvals_from_state = approvals_from_step(current_step) result = RunResult( @@ -1325,27 +1389,7 @@ def _mark_response_hooks_started() -> None: ) if run_state is not None: result._trace_state = run_state._trace_state - if session_persistence_enabled and include_in_history: - handler_input_items_for_save: list[TResponseInputItem] = ( - session_input_items_for_persistence - if session_input_items_for_persistence is not None - else [] - ) - # The synthesized item is a fresh one-item list, not the - # cumulative turn item list, so the run state's per-turn - # persisted count must not be applied as a slice offset here. - # Pass the reasoning item id policy explicitly instead, the same - # way `save_resumed_turn_items` does. - await save_result_to_session( - session, - handler_input_items_for_save, - [synthesized_item], - None, - response_id=None, - reasoning_item_id_policy=resolved_reasoning_item_id_policy, - store=store_setting, - wrapper=context_wrapper, - ) + result._current_turn_persisted_item_count = handler_persisted_item_count result._original_input = copy_input_items(original_input) return _finalize_result(result) @@ -1796,15 +1840,15 @@ def _mark_response_hooks_started() -> None: turn_result.new_step_items.clear() except BaseException as exc: run_exception = exc - attach_generic_agent_error( - current_span, - exc, - trace_include_sensitive_data=run_config.trace_include_sensitive_data, - ) - if isinstance(exc, AgentsException): - if _is_error_data_redacted(exc): - _detach_data_redacted_error_traceback(exc) - else: + if _is_error_data_redacted(exc): + _detach_data_redacted_error_traceback(exc) + else: + attach_generic_agent_error( + current_span, + exc, + trace_include_sensitive_data=run_config.trace_include_sensitive_data, + ) + if isinstance(exc, AgentsException): _clear_data_redacted_error_traceback(exc) exc.run_data = RunErrorDetails( input=original_input, @@ -1874,6 +1918,37 @@ def run_sync( starting_agent: Agent[TContext], input: str | list[TResponseInputItem] | RunState[TContext], **kwargs: Unpack[RunOptions[TContext]], + ) -> RunResult: + redacted_error: BaseException | None = None + redacted_source: BaseException | None + try: + return self._run_sync_impl(starting_agent, input, **kwargs) + except BaseException as error: + if _is_error_data_redacted(error): + redacted_source = error + else: + redacted_source = _data_redacted_sync_cancellation_source(error) + if redacted_source is None: + raise + if isinstance(redacted_source, asyncio.CancelledError): + redacted_error = _prepare_data_redacted_error(redacted_source) + else: + _detach_data_redacted_error_traceback(redacted_source) + redacted_error = redacted_source + + self = cast(Any, None) + starting_agent = cast(Any, None) + input = cast(Any, None) + cast(dict[str, Any], kwargs).clear() + assert redacted_error is not None + _detach_data_redacted_error_traceback(redacted_error) + _raise_data_redacted_error(redacted_error) + + def _run_sync_impl( + self, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem] | RunState[TContext], + **kwargs: Unpack[RunOptions[TContext]], ) -> RunResult: context = kwargs.get("context") max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS) @@ -1948,7 +2023,7 @@ def run_sync( task.cancel() with contextlib.suppress(asyncio.CancelledError): default_loop.run_until_complete(task) - if isinstance(error, ModelBehaviorError): + if _is_error_data_redacted(error) or isinstance(error, ModelBehaviorError): _detach_data_redacted_error_traceback(error) raise finally: @@ -2185,23 +2260,25 @@ def run_streamed( # Kick off the actual agent loop in the background and return the streamed result object. streamed_result.run_loop_task = asyncio.create_task( - start_streaming( - starting_input=input_for_result, - streamed_result=streamed_result, - starting_agent=starting_agent, - max_turns=max_turns, - hooks=hooks, - context_wrapper=context_wrapper, - run_config=run_config, - error_handlers=error_handlers, - previous_response_id=previous_response_id, - auto_previous_response_id=auto_previous_response_id, - conversation_id=conversation_id, - session=session, - run_state=run_state, - trace_workflow_name=trace_workflow_name, - is_resumed_state=is_resumed_state, - sandbox_runtime=sandbox_runtime, + _await_data_redacted_error_boundary( + lambda: start_streaming( + starting_input=input_for_result, + streamed_result=streamed_result, + starting_agent=starting_agent, + max_turns=max_turns, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + error_handlers=error_handlers, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + conversation_id=conversation_id, + session=session, + run_state=run_state, + trace_workflow_name=trace_workflow_name, + is_resumed_state=is_resumed_state, + sandbox_runtime=sandbox_runtime, + ) ) ) if sandbox_runtime.enabled: diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index f4dca6b1ac..7d5b73ad5a 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -15,7 +15,7 @@ from ..memory import Session from ..models.openai_agent_registration import add_openai_harness_id_to_metadata from ..result import RunResult -from ..run_config import RunConfig +from ..run_config import ReasoningItemIdPolicy, RunConfig from ..run_context import RunContextWrapper, TContext from ..run_state import RunState from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult @@ -536,14 +536,15 @@ async def save_final_turn_items_after_guardrails( input_guardrail_results: list[InputGuardrailResult], items: list[RunItem], response_id: str | None, + reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, -) -> None: +) -> int: """Persist deferred final-turn items without skipping a partially persisted resumed turn.""" if not session_persistence_enabled or not items: - return + return 0 if input_guardrails_triggered(input_guardrail_results): - return + return 0 if run_state is not None and run_state._current_turn_persisted_item_count > 0: run_state._current_turn_persisted_item_count = await save_resumed_turn_items( session=session, @@ -554,13 +555,14 @@ async def save_final_turn_items_after_guardrails( store=store, wrapper=wrapper, ) - return - await save_result_to_session( + return run_state._current_turn_persisted_item_count + return await save_result_to_session( session, [], list(items), run_state, response_id=response_id, + reasoning_item_id_policy=reasoning_item_id_policy, store=store, wrapper=wrapper, ) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 4a2f9141d3..ff1b4a09da 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -30,17 +30,22 @@ from ..exceptions import ( _DATA_REDACTED_ERROR_MESSAGE, AgentsException, + BaseExceptionGroup, InputGuardrailTripwireTriggered, MaxTurnsExceeded, ModelBehaviorError, OutputGuardrailTripwireTriggered, RunErrorDetails, UserError, + _base_exception_group_exceptions, _clear_data_redacted_error_traceback, + _copy_data_redacted_process_control_error, _detach_data_redacted_error_traceback, _is_error_data_redacted, _mark_error_data_redacted, + _prepare_data_redacted_error, ) +from ..guardrail import OutputGuardrailResult from ..handoffs import Handoff from ..items import ( InputItem, @@ -251,6 +256,7 @@ "get_model_tracing_impl", "validate_run_hooks", "cleanup_models_after_run", + "finalize_max_turns_handler_output", "maybe_filter_model_input", "run_input_guardrails_with_queue", "start_streaming", @@ -522,6 +528,7 @@ async def _finalize_streamed_final_output( response_id: str | None, store_setting: bool | None, persist_before_output_guardrails: bool, + on_persisted_after_guardrails: Callable[[bool], None] | None = None, ) -> None: redacted_persistence_error: BaseException | None = None if persist_before_output_guardrails: @@ -560,15 +567,9 @@ async def _finalize_streamed_final_output( if not persist_before_output_guardrails: try: await save_items(items, response_id, store_setting) - except (Exception, asyncio.CancelledError) as persistence_error: + except BaseException as persistence_error: if guardrail_error_is_redacted: - if isinstance(persistence_error, asyncio.CancelledError): - safe_persistence_error: BaseException = asyncio.CancelledError( - _DATA_REDACTED_ERROR_MESSAGE - ) - else: - safe_persistence_error = UserError(_DATA_REDACTED_ERROR_MESSAGE) - _mark_error_data_redacted(safe_persistence_error) + safe_persistence_error = _safe_redacted_persistence_error(persistence_error) if ( isinstance(safe_persistence_error, asyncio.CancelledError) and streamed_result._cancel_mode != "immediate" @@ -596,25 +597,214 @@ async def _finalize_streamed_final_output( streamed_result._stored_exception = persistence_error if redacted_persistence_error is None: raise + else: + if on_persisted_after_guardrails is not None: + on_persisted_after_guardrails(False) if redacted_persistence_error is None: raise if redacted_persistence_error is not None: raise redacted_persistence_error from None - streamed_result.output_guardrail_results = output_guardrail_results - streamed_result.final_output = output - streamed_result.is_complete = True + streamed_result.output_guardrail_results.extend(output_guardrail_results) if not persist_before_output_guardrails: # Saved as one ordered batch so the session mirrors the model response. Doing it in two # halves would both reorder the turn and, because the first save advances the turn's # persisted-item count, make the second one a no-op. - await save_items(items, response_id, store_setting) + if on_persisted_after_guardrails is None: + await save_items(items, response_id, store_setting) + else: + try: + await save_items(items, response_id, store_setting) + except asyncio.CancelledError as persistence_error: + if streamed_result._cancel_mode == "immediate": + raise + streamed_result._stored_exception = persistence_error + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + return + + streamed_result.final_output = output + if on_persisted_after_guardrails is not None: + on_persisted_after_guardrails(True) + streamed_result.is_complete = True streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) +def _safe_redacted_persistence_leaf_error(error: BaseException) -> BaseException: + """Snapshot a payload-free non-group replacement without mutating the source.""" + safe_error = _copy_data_redacted_process_control_error(error) + if isinstance(safe_error, asyncio.CancelledError): + safe_error.args = (_DATA_REDACTED_ERROR_MESSAGE,) + _mark_error_data_redacted(safe_error) + return safe_error + if isinstance(safe_error, GeneratorExit | KeyboardInterrupt | SystemExit): + return safe_error + + if issubclass(type(error), Exception): + safe_error = UserError(_DATA_REDACTED_ERROR_MESSAGE) + _mark_error_data_redacted(safe_error) + return safe_error + + safe_error = BaseException() + _mark_error_data_redacted(safe_error) + return safe_error + + +class _RedactedBaseExceptionGroup(BaseExceptionGroup): + """Payload-free group that remains outside the Exception hierarchy.""" + + +def _safe_redacted_persistence_error(error: BaseException) -> BaseException: + """Return a payload-free persistence failure without swallowing control flow.""" + if _base_exception_group_exceptions(error) is None: + safe_error = _safe_redacted_persistence_leaf_error(error) + _prepare_data_redacted_error(error) + return safe_error + + group_children: dict[int, tuple[BaseException, ...]] = {} + group_is_exception: dict[int, bool] = {} + leaf_errors: dict[int, BaseException] = {} + safe_errors: dict[int, BaseException] = {} + group_postorder: list[BaseException] = [] + pending: list[tuple[BaseException, bool]] = [(error, False)] + try: + while pending: + current, expanded = pending.pop() + current_id = id(current) + children = _base_exception_group_exceptions(current) + if children is None: + leaf_errors.setdefault(current_id, current) + continue + if expanded: + group_postorder.append(current) + continue + if current_id in group_children: + continue + group_children[current_id] = children + group_is_exception[current_id] = issubclass(type(current), Exception) + pending.append((current, True)) + pending.extend((child, False) for child in reversed(children)) + + # Snapshot the full group topology before clearing any source exception. On Python 3.10, + # the exceptiongroup backport stores children in the instance dictionary, so sanitizing a + # linked leaf can otherwise erase a group that has not been converted yet. + for current_id, current in leaf_errors.items(): + safe_errors[current_id] = _safe_redacted_persistence_leaf_error(current) + + # Clear the complete provider-owned graph before constructing replacement groups. Group + # messages are read-only, so the source objects must never cross the public boundary. + _prepare_data_redacted_error(error) + + for group in group_postorder: + children = group_children[id(group)] + safe_children = [safe_errors[id(child)] for child in children] + if not safe_children: + safe_children = [BaseException()] + if group_is_exception[id(group)]: + safe_group: BaseException = BaseExceptionGroup( + _DATA_REDACTED_ERROR_MESSAGE, + safe_children, + ) + else: + safe_group = _RedactedBaseExceptionGroup( + _DATA_REDACTED_ERROR_MESSAGE, + safe_children, + ) + _mark_error_data_redacted(safe_group) + safe_errors[id(group)] = safe_group + return safe_errors[id(error)] + except BaseException as conversion_error: + # Fail closed if traversal or reconstruction itself fails. Preparing the conversion error + # also clears its implicit context, including any source group still referenced there. + _prepare_data_redacted_error(error) + safe_error = _safe_redacted_persistence_leaf_error(conversion_error) + _prepare_data_redacted_error(conversion_error) + return safe_error + + +async def finalize_max_turns_handler_output( + *, + agent: Agent[TContext], + hooks: RunHooks[TContext], + run_config: RunConfig, + output: Any, + context_wrapper: RunContextWrapper[TContext], + output_guardrail_results: list[OutputGuardrailResult], + save_items_after_guardrails: Callable[[list[RunItem]], Awaitable[None]], + include_in_history: bool, +) -> tuple[Any, RunItem]: + """Validate and finalize one synthesized max-turn handler output.""" + validated_output = validate_handler_final_output(agent, output) + output_text = format_final_output_text(agent, validated_output) + synthesized_item = create_message_output_item(agent, output_text) + + await run_final_output_hooks(agent, hooks, context_wrapper, validated_output) + + redacted_persistence_error: BaseException | None = None + try: + await run_output_guardrails( + agent.output_guardrails + (run_config.output_guardrails or []), + agent, + validated_output, + context_wrapper, + output_guardrail_results, + ) + except OutputGuardrailTripwireTriggered: + raise + except Exception as guardrail_error: + guardrail_error_is_redacted = _is_error_data_redacted(guardrail_error) + if guardrail_error_is_redacted: + _detach_data_redacted_error_traceback(guardrail_error) + try: + await save_items_after_guardrails([synthesized_item] if include_in_history else []) + except BaseException as persistence_error: + if not guardrail_error_is_redacted: + raise + redacted_persistence_error = _safe_redacted_persistence_error(persistence_error) + if redacted_persistence_error is None: + raise + + if redacted_persistence_error is not None: + raise redacted_persistence_error from None + return validated_output, synthesized_item + + +async def _persist_stream_input_if_needed( + *, + streamed_result: RunResultStreaming, + session: Session | None, + server_conversation_tracker: OpenAIServerConversationTracker | None, + context_wrapper: RunContextWrapper[TContext], +) -> None: + if ( + streamed_result._stream_input_persisted + or session is None + or server_conversation_tracker is not None + or streamed_result._original_input_for_persistence is None + or len(streamed_result._original_input_for_persistence) == 0 + ): + return + + input_items_to_save = [ + ensure_input_item_format(item) + for item in ItemHelpers.input_to_new_input_list( + streamed_result._original_input_for_persistence + ) + ] + if input_items_to_save: + await save_result_to_session( + session, + input_items_to_save, + [], + streamed_result._state, + wrapper=context_wrapper, + ) + streamed_result._stream_input_persisted = True + + def _accumulate_tool_guardrail_results( streamed_result: RunResultStreaming, turn_result: SingleStepResult, @@ -919,6 +1109,27 @@ async def _save_stream_items_without_count( update_persisted_count=False, store=store_setting, ) + + async def _save_max_turns_items( + items: list[RunItem], response_id: str | None, store_setting: bool | None + ) -> None: + if not await _should_persist_stream_items( + session=session, + server_conversation_tracker=server_conversation_tracker, + streamed_result=streamed_result, + ): + return + saved_count = await save_result_to_session( + session, + [], + list(items), + None, + response_id=response_id, + reasoning_item_id_policy=streamed_result._reasoning_item_id_policy, + store=store_setting, + wrapper=streamed_result.context_wrapper, + ) + streamed_result._current_turn_persisted_item_count += saved_count except BaseException: if current_task_span is not None: attach_usage_to_span( @@ -1280,48 +1491,61 @@ async def _save_stream_items_without_count( streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) break + await _persist_stream_input_if_needed( + streamed_result=streamed_result, + session=session, + server_conversation_tracker=server_conversation_tracker, + context_wrapper=context_wrapper, + ) + validated_output = validate_handler_final_output( current_agent, handler_result.final_output ) output_text = format_final_output_text(current_agent, validated_output) synthesized_item = create_message_output_item(current_agent, output_text) include_in_history = handler_result.include_in_history - if include_in_history: + store_setting = current_agent.model_settings.resolve( + run_config.model_settings + ).store + + await run_final_output_hooks( + current_agent, hooks, context_wrapper, validated_output + ) + + def _record_max_turns_handler_output( + publish_events: bool, + include_in_history: bool = include_in_history, + synthesized_item: RunItem = synthesized_item, + ) -> None: + if not include_in_history: + return streamed_result._model_input_items.append(synthesized_item) streamed_result.new_items.append(synthesized_item) - if run_state is not None: + if run_state is not None and not is_resumed_state: run_state._generated_items = list(streamed_result._model_input_items) run_state._clear_generated_items_last_processed_marker() run_state._session_items = list(streamed_result.new_items) - stream_step_items_to_queue([synthesized_item], streamed_result._event_queue) - store_setting = current_agent.model_settings.resolve( - run_config.model_settings - ).store - if is_resumed_state: - await _save_resumed_items([synthesized_item], None, store_setting) - else: - await _save_stream_items_with_count([synthesized_item], None, store_setting) + if publish_events: + stream_step_items_to_queue([synthesized_item], streamed_result._event_queue) - await run_final_output_hooks( - current_agent, hooks, context_wrapper, validated_output - ) - output_guardrail_results = await _run_output_guardrails_for_stream( + await _finalize_streamed_final_output( + streamed_result=streamed_result, agent=current_agent, run_config=run_config, output=validated_output, context_wrapper=context_wrapper, - streamed_result=streamed_result, + save_items=_save_max_turns_items, + items=[synthesized_item] if include_in_history else [], + response_id=None, + store_setting=store_setting, + persist_before_output_guardrails=False, + on_persisted_after_guardrails=_record_max_turns_handler_output, ) - streamed_result.output_guardrail_results = output_guardrail_results - streamed_result.final_output = validated_output - streamed_result.is_complete = True - streamed_result._stored_exception = None streamed_result._max_turns_handled = True streamed_result.current_turn = max_turns - if run_state is not None: + if run_state is not None and not is_resumed_state: run_state._current_turn = max_turns run_state._current_step = None - streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) break if current_turn == 1: @@ -1585,14 +1809,21 @@ async def _save_stream_items_without_count( ) raise except Exception as e: - attach_generic_agent_error( - current_span, - e, - trace_include_sensitive_data=run_config.trace_include_sensitive_data, - ) + if _is_error_data_redacted(e): + _detach_data_redacted_error_traceback(e) + else: + attach_generic_agent_error( + current_span, + e, + trace_include_sensitive_data=run_config.trace_include_sensitive_data, + ) streamed_result.is_complete = True streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) raise + except BaseException as error: + if _is_error_data_redacted(error): + _detach_data_redacted_error_traceback(error) + raise else: streamed_result.is_complete = True finally: @@ -1788,28 +2019,12 @@ async def raise_if_input_guardrail_tripwire_known() -> None: ), ) - if ( - not streamed_result._stream_input_persisted - and session is not None - and server_conversation_tracker is None - and streamed_result._original_input_for_persistence is not None - and len(streamed_result._original_input_for_persistence) > 0 - ): - streamed_result._stream_input_persisted = True - input_items_to_save = [ - ensure_input_item_format(item) - for item in ItemHelpers.input_to_new_input_list( - streamed_result._original_input_for_persistence - ) - ] - if input_items_to_save: - await save_result_to_session( - session, - input_items_to_save, - [], - streamed_result._state, - wrapper=context_wrapper, - ) + await _persist_stream_input_if_needed( + streamed_result=streamed_result, + session=session, + server_conversation_tracker=server_conversation_tracker, + context_wrapper=context_wrapper, + ) previous_response_id = ( server_conversation_tracker.previous_response_id diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index ad8d17dd35..6c26da31f0 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -12,6 +12,7 @@ import json import logging import pickle +import sys import threading import traceback import warnings @@ -41,12 +42,18 @@ RunErrorHandlerInput, RunErrorHandlerResult, Runner, + RunResultStreaming, UserError, function_tool, handoff, trace, ) from agents.agent_output import AgentOutputSchema +from agents.exceptions import ( + BaseExceptionGroup, + _detach_data_redacted_error_traceback, + _mark_error_data_redacted, +) from agents.logger import ( log_model_action_debug, log_model_action_error, @@ -60,6 +67,8 @@ log_tool_action_warning, ) from agents.realtime import RealtimeAgent, realtime_handoff +from agents.run import AgentRunner +from agents.run_internal.run_loop import _safe_redacted_persistence_error from agents.run_internal.tool_execution import ( log_tool_action_error, resolve_approval_rejection_message, @@ -105,6 +114,20 @@ def __setattr__(self, name: str, value: Any) -> None: raise RuntimeError("redacted handling mutated the handler exception") +class _DirectBaseException(BaseException): + pass + + +class _HostileClassBaseException(BaseException): + @property + def __class__(self) -> type[object]: + raise RuntimeError("hostile class descriptor secret") + + +class _HybridCancelledError(asyncio.CancelledError, Exception): + pass + + class _TruthinessException(Exception): def __init__(self, *, truthy: bool) -> None: super().__init__("diagnostic failure") @@ -1327,6 +1350,30 @@ def test_run_sync_surfaces_redacted_output_validation_error_without_runner_data( assert all(session is not value for frame in frame_locals for value in frame.values()) +def test_run_sync_preserves_redacted_hybrid_cancellation_catchability( + monkeypatch: pytest.MonkeyPatch, +) -> None: + hybrid_error = _HybridCancelledError("RUN_SYNC_HYBRID_SECRET") + _mark_error_data_redacted(hybrid_error) + + async def raise_hybrid_error(*_args: Any, **_kwargs: Any) -> Any: + raise hybrid_error + + monkeypatch.setattr(AgentRunner, "run", raise_hybrid_error) + + with pytest.raises(Exception) as exc_info: + AgentRunner().run_sync(Agent(name="A"), "RUN_SYNC_INPUT_SECRET") + + error = exc_info.value + assert isinstance(error, asyncio.CancelledError) + assert isinstance(error, Exception) + assert str(error) == "" + assert error.__cause__ is None + assert error.__context__ is None + _assert_secret_absent_from_agents_traceback(error, "RUN_SYNC_HYBRID_SECRET") + _assert_secret_absent_from_agents_traceback(error, "RUN_SYNC_INPUT_SECRET") + + @pytest.mark.asyncio async def test_run_preserves_diagnostic_wrapper_traceback_locals( monkeypatch: pytest.MonkeyPatch, @@ -1347,6 +1394,31 @@ async def test_run_preserves_diagnostic_wrapper_traceback_locals( assert any(frame.get("session") is session for frame in frame_locals) +@pytest.mark.asyncio +async def test_streaming_redaction_boundary_defers_inner_coroutine_until_task_starts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + start_streaming_called = False + + async def inner_streaming() -> None: + return None + + def start_streaming_factory(**_kwargs: Any) -> Any: + nonlocal start_streaming_called + start_streaming_called = True + return inner_streaming() + + monkeypatch.setattr("agents.run.start_streaming", start_streaming_factory) + result = AgentRunner().run_streamed(Agent(name="A", model=ScriptedModel()), "go") + assert result.run_loop_task is not None + + result.run_loop_task.cancel() + with pytest.raises(asyncio.CancelledError): + await result.run_loop_task + + assert not start_streaming_called + + def test_run_sync_preserves_diagnostic_wrapper_traceback_locals( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1614,6 +1686,748 @@ def output_guardrail( _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) +def _persistence_failure( + kind: Literal["exception", "cancelled", "direct_base", "exception_group", "group"], + secret: str, +) -> BaseException: + if kind == "exception": + return LookupError(f"session save failed: {secret}") + if kind == "cancelled": + return asyncio.CancelledError(f"session save cancelled: {secret}") + if kind == "direct_base": + return _DirectBaseException(f"session save aborted: {secret}") + if kind == "exception_group": + return BaseExceptionGroup( + f"session save exception group: {secret}", + [RuntimeError(f"session save child: {secret}")], + ) + return BaseExceptionGroup( + f"session save group: {secret}", + [ + RuntimeError(f"session save child: {secret}"), + asyncio.CancelledError(f"session save cancelled child: {secret}"), + ], + ) + + +def _exception_graph(error: BaseException) -> list[BaseException]: + graph: list[BaseException] = [] + pending = [error] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + graph.append(current) + if isinstance(current, BaseExceptionGroup): + pending.extend(current.exceptions) + if current.__cause__ is not None: + pending.append(current.__cause__) + if current.__context__ is not None: + pending.append(current.__context__) + return graph + + +def _assert_secret_absent_from_value_graph(value: Any, secret: str) -> None: + pending = [value] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + + if isinstance(current, str): + assert secret not in current + elif isinstance(current, bytes): + assert secret.encode() not in current + elif isinstance(current, BaseExceptionGroup): + assert secret not in current.message + pending.extend(current.exceptions) + pending.extend(current.args) + elif isinstance(current, BaseException): + assert secret not in str(current) + assert secret not in repr(current) + pending.extend(current.args) + elif type(current) is dict: + pending.extend(current.keys()) + pending.extend(current.values()) + elif type(current) in {list, tuple, set, frozenset}: + pending.extend(current) + else: + assert secret not in repr(current) + + +@pytest.mark.parametrize("redacted", [False, True]) +@pytest.mark.parametrize( + "failure_kind", + ["exception", "cancelled", "direct_base", "exception_group", "base_group"], +) +@pytest.mark.asyncio +async def test_sandbox_cleanup_wrapper_preserves_redaction_boundary( + redacted: bool, + failure_kind: Literal["exception", "cancelled", "direct_base", "exception_group", "base_group"], +) -> None: + secret = f"SANDBOX_WRAPPER_SECRET_{failure_kind}" + if failure_kind == "exception": + error: BaseException = RuntimeError() + elif failure_kind == "cancelled": + error = asyncio.CancelledError() + elif failure_kind == "direct_base": + error = _DirectBaseException() + elif failure_kind == "exception_group": + error = BaseExceptionGroup("Error details are redacted.", [RuntimeError()]) + else: + error = BaseExceptionGroup( + "Error details are redacted.", + [asyncio.CancelledError()], + ) + + async def original_task() -> None: + payload = secret + assert payload + try: + raise error + except BaseException as caught: + if redacted: + _mark_error_data_redacted(caught) + _detach_data_redacted_error_traceback(caught) + payload = None + raise + + async def cleanup() -> None: + return None + + result = RunResultStreaming( + input=secret, + new_items=[], + raw_responses=[], + final_output=None, + input_guardrail_results=[], + output_guardrail_results=[], + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + context_wrapper=RunContextWrapper(context=None), + current_agent=Agent(name="test"), + current_turn=0, + max_turns=1, + _current_agent_output_schema=None, + trace=None, + ) + result._sandbox_cleanup = cleanup + result.run_loop_task = asyncio.create_task(original_task()) + result.ensure_sandbox_cleanup_on_completion() + assert result.run_loop_task is not None + + callback_frame_locals: list[dict[str, Any]] = [] + task_done = asyncio.Event() + + def inspect_public_task(task: asyncio.Task[Any]) -> None: + try: + task.result() + except BaseException as caught: + callback_frame_locals.extend(_agents_traceback_frame_locals(caught)) + finally: + task_done.set() + + result.run_loop_task.add_done_callback(inspect_public_task) + await asyncio.wait_for(task_done.wait(), timeout=1) + + if redacted: + for frame_locals in callback_frame_locals: + _assert_secret_absent_from_value_graph(frame_locals, secret) + else: + if failure_kind == "cancelled" and sys.version_info < (3, 11): + assert callback_frame_locals == [] + else: + assert callback_frame_locals + assert any(secret in repr(frame) for frame in callback_frame_locals) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("failure_kind", ["exception", "cancelled", "direct_base", "group"]) +@pytest.mark.asyncio +async def test_max_turns_recovery_session_failure_preserves_complete_redaction_boundary( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + streamed: bool, + failure_kind: Literal["exception", "cancelled", "direct_base", "group"], +) -> None: + persistence_secret = "MAX_TURNS_SESSION_FAILURE_SECRET" + fallback_secret = "MAX_TURNS_HANDLER_OUTPUT_SECRET" + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + guardrail_failed = False + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + + class FailingMaxTurnsSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if guardrail_failed: + raise _persistence_failure(failure_kind, persistence_secret) + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + caplog.set_level(logging.ERROR, logger="openai.agents") + agent = Agent( + name="A", + model=ScriptedModel(), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = FailingMaxTurnsSession() + + captured_error: BaseException | None = None + try: + if streamed: + result = Runner.run_streamed( + agent, + "go", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: fallback_secret}, + ) + async for _ in result.stream_events(): + pass + else: + await Runner.run( + agent, + "go", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: fallback_secret}, + ) + except BaseException as error: + captured_error = error + else: # pragma: no cover + raise AssertionError("the session failure must propagate") + + assert captured_error is not None + error = captured_error + if failure_kind == "exception": + assert isinstance(error, UserError) + elif failure_kind == "cancelled": + assert isinstance(error, asyncio.CancelledError) + elif failure_kind == "direct_base": + assert type(error) is BaseException + else: + assert isinstance(error, BaseExceptionGroup) + assert not isinstance(error, Exception) + assert {type(child) for child in error.exceptions} == { + UserError, + asyncio.CancelledError, + } + + error_graph = _exception_graph(error) + assert error_graph + for current in error_graph: + assert current.__cause__ is None + assert current.__context__ is None + assert persistence_secret not in str(current) + assert persistence_secret not in repr(current) + assert fallback_secret not in str(current) + assert fallback_secret not in repr(current) + assert _MODEL_OUTPUT_SECRET not in str(current) + assert _MODEL_OUTPUT_SECRET not in repr(current) + _assert_secret_absent_from_agents_traceback( + current, + persistence_secret, + require_agents_frames=False, + ) + _assert_secret_absent_from_agents_traceback( + current, + fallback_secret, + require_agents_frames=False, + ) + _assert_secret_absent_from_agents_traceback( + current, + _MODEL_OUTPUT_SECRET, + require_agents_frames=False, + ) + + for record in caplog.records: + rendered_record = logging.Formatter().format(record) + record_state = repr(record.__dict__) + for secret in (persistence_secret, fallback_secret, _MODEL_OUTPUT_SECRET): + assert secret not in rendered_record + assert secret not in record_state + assert record.exc_info is None + + +def _direct_agent_runner_redaction_case( + monkeypatch: pytest.MonkeyPatch, + failure_kind: Literal["cancelled", "direct_base", "exception_group", "group"], +) -> tuple[Agent[Any], SimpleListSession, str, tuple[str, str, str, str]]: + persistence_secret = f"DIRECT_AGENT_RUNNER_PERSISTENCE_SECRET_{failure_kind}" + fallback_secret = f"DIRECT_AGENT_RUNNER_FALLBACK_SECRET_{failure_kind}" + input_secret = f"DIRECT_AGENT_RUNNER_INPUT_SECRET_{failure_kind}" + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + guardrail_failed = False + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + + class FailingSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if guardrail_failed: + raise _persistence_failure(failure_kind, persistence_secret) + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + agent = Agent( + name="A", + model=ScriptedModel(), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + secrets = (persistence_secret, fallback_secret, input_secret, _MODEL_OUTPUT_SECRET) + return agent, FailingSession(), input_secret, secrets + + +def _assert_direct_agent_runner_redaction_boundary( + error: BaseException, + secrets: tuple[str, str, str, str], +) -> None: + for current in _exception_graph(error): + assert current.__cause__ is None + assert current.__context__ is None + for secret in secrets: + assert secret not in str(current) + assert secret not in repr(current) + for frame_locals in _agents_traceback_frame_locals(current): + _assert_secret_absent_from_value_graph(frame_locals, secret) + + +@pytest.mark.parametrize( + "failure_kind", + ["cancelled", "direct_base", "exception_group", "group"], +) +@pytest.mark.asyncio +async def test_agent_runner_run_detaches_all_marked_recovery_failures( + monkeypatch: pytest.MonkeyPatch, + failure_kind: Literal["cancelled", "direct_base", "exception_group", "group"], +) -> None: + agent, session, input_secret, secrets = _direct_agent_runner_redaction_case( + monkeypatch, failure_kind + ) + + with pytest.raises(BaseException) as exc_info: + await AgentRunner().run( + agent, + input_secret, + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: secrets[1]}, + ) + + _assert_direct_agent_runner_redaction_boundary(exc_info.value, secrets) + + +@pytest.mark.parametrize( + "failure_kind", + ["cancelled", "direct_base", "exception_group", "group"], +) +def test_agent_runner_run_sync_detaches_all_marked_recovery_failures( + monkeypatch: pytest.MonkeyPatch, + failure_kind: Literal["cancelled", "direct_base", "exception_group", "group"], +) -> None: + agent, session, input_secret, secrets = _direct_agent_runner_redaction_case( + monkeypatch, failure_kind + ) + + with pytest.raises(BaseException) as exc_info: + AgentRunner().run_sync( + agent, + input_secret, + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: secrets[1]}, + ) + + _assert_direct_agent_runner_redaction_boundary(exc_info.value, secrets) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_max_turns_recovery_deep_exception_group_preserves_redaction_boundary( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + streamed: bool, +) -> None: + persistence_secret = "DEEP_MAX_TURNS_SESSION_FAILURE_SECRET" + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + guardrail_failed = False + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + + class DeepGroupSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if guardrail_failed: + error: BaseException = asyncio.CancelledError(persistence_secret) + for _ in range(1200): + error = BaseExceptionGroup(persistence_secret, [error]) + raise error + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + caplog.set_level(logging.ERROR, logger="openai.agents") + agent = Agent( + name="A", + model=ScriptedModel(), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = DeepGroupSession() + + captured_error: BaseException | None = None + try: + if streamed: + result = Runner.run_streamed( + agent, + "go", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback"}, + ) + async for _ in result.stream_events(): + pass + else: + await Runner.run( + agent, + "go", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback"}, + ) + except BaseException as error: + captured_error = error + else: # pragma: no cover + raise AssertionError("the deep exception group must propagate") + + assert isinstance(captured_error, BaseExceptionGroup) + error_graph = _exception_graph(captured_error) + assert len(error_graph) == 1201 + for current in error_graph: + assert current.__cause__ is None + assert current.__context__ is None + if isinstance(current, BaseExceptionGroup): + assert current.message == "Error details are redacted." + else: + assert isinstance(current, asyncio.CancelledError) + assert persistence_secret not in str(current) + assert persistence_secret not in repr(current) + for frame_locals in _agents_traceback_frame_locals(current): + _assert_secret_absent_from_value_graph(frame_locals, persistence_secret) + _assert_secret_absent_from_value_graph(frame_locals, _MODEL_OUTPUT_SECRET) + + for record in caplog.records: + assert persistence_secret not in logging.Formatter().format(record) + assert persistence_secret not in repr(record.__dict__) + assert _MODEL_OUTPUT_SECRET not in logging.Formatter().format(record) + assert _MODEL_OUTPUT_SECRET not in repr(record.__dict__) + assert record.exc_info is None + + +@pytest.mark.parametrize("redacted", [False, True]) +@pytest.mark.parametrize("failure_kind", ["direct_base", "group"]) +@pytest.mark.asyncio +async def test_max_turns_run_loop_exception_follows_redaction_policy_for_base_exceptions( + monkeypatch: pytest.MonkeyPatch, + redacted: bool, + failure_kind: Literal["direct_base", "group"], +) -> None: + persistence_secret = "RUN_LOOP_EXCEPTION_PERSISTENCE_SECRET" + fallback_secret = "RUN_LOOP_EXCEPTION_FALLBACK_SECRET" + payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}' + guardrail_failed = False + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + + class FailingMaxTurnsSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if guardrail_failed: + raise _persistence_failure(failure_kind, persistence_secret) + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + AgentOutputSchema(_RequiredOutput).validate_json(payload) + raise AssertionError("validation should fail") # pragma: no cover + + result = Runner.run_streamed( + Agent( + name="A", + model=ScriptedModel(), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ), + "go", + max_turns=0, + session=FailingMaxTurnsSession(), + error_handlers={"max_turns": lambda data: fallback_secret}, + ) + assert result.run_loop_task is not None + callback_frame_locals: list[dict[str, Any]] = [] + run_loop_done = asyncio.Event() + + def inspect_run_loop_task(task: asyncio.Task[Any]) -> None: + try: + task.result() + except BaseException as error: + callback_frame_locals.extend(_agents_traceback_frame_locals(error)) + finally: + run_loop_done.set() + + result.run_loop_task.add_done_callback(inspect_run_loop_task) + await asyncio.wait_for(run_loop_done.wait(), timeout=1) + + error = result.run_loop_exception + assert error is not None + frame_locals = _agents_traceback_frame_locals(error) + if redacted: + for traceback_locals in callback_frame_locals + frame_locals: + for secret in (persistence_secret, fallback_secret, _MODEL_OUTPUT_SECRET): + _assert_secret_absent_from_value_graph(traceback_locals, secret) + for current in _exception_graph(error): + assert current.__cause__ is None + assert current.__context__ is None + if isinstance(current, BaseExceptionGroup): + assert current.message == "Error details are redacted." + else: + for secret in (persistence_secret, fallback_secret, _MODEL_OUTPUT_SECRET): + assert secret not in str(current) + assert secret not in repr(current) + else: + assert callback_frame_locals + assert any(fallback_secret in repr(frame) for frame in callback_frame_locals) + assert frame_locals + assert any(fallback_secret in repr(frame) for frame in frame_locals) + assert persistence_secret in str(error) + + try: + async for _ in result.stream_events(): + pass + except BaseException as streamed_error: + assert streamed_error is error + else: # pragma: no cover + raise AssertionError("the session failure must propagate through the stream") + + +@pytest.mark.parametrize( + ("source", "expected_type"), + [ + (asyncio.CancelledError("secret"), asyncio.CancelledError), + (GeneratorExit("secret"), GeneratorExit), + (KeyboardInterrupt("secret"), KeyboardInterrupt), + (SystemExit("secret"), SystemExit), + (_DirectBaseException("secret"), BaseException), + ], +) +def test_safe_redacted_persistence_error_preserves_process_control_semantics( + source: BaseException, + expected_type: type[BaseException], +) -> None: + safe_error = _safe_redacted_persistence_error(source) + + assert type(safe_error) is expected_type + assert "secret" not in str(safe_error) + assert "secret" not in repr(safe_error) + assert safe_error.__cause__ is None + assert safe_error.__context__ is None + assert safe_error.__traceback__ is None + + +def test_safe_redacted_persistence_error_snapshots_linked_group_topology() -> None: + linked_group = BaseExceptionGroup( + "linked group secret", + [KeyboardInterrupt("process-control secret")], + ) + first_child = RuntimeError("first child secret") + first_child.__context__ = linked_group + source = BaseExceptionGroup("root group secret", [first_child, linked_group]) + + safe_error = _safe_redacted_persistence_error(source) + + assert isinstance(safe_error, BaseExceptionGroup) + assert len(safe_error.exceptions) == 2 + assert isinstance(safe_error.exceptions[0], UserError) + safe_linked_group = safe_error.exceptions[1] + assert isinstance(safe_linked_group, BaseExceptionGroup) + assert len(safe_linked_group.exceptions) == 1 + assert type(safe_linked_group.exceptions[0]) is KeyboardInterrupt + assert "secret" not in repr(safe_error) + + +def test_safe_redacted_persistence_error_preserves_provider_group_catch_category() -> None: + class ProviderBaseExceptionGroup(BaseExceptionGroup): + pass + + direct_source = ProviderBaseExceptionGroup( + "direct provider secret", + [RuntimeError("direct leaf secret")], + ) + nested_source = BaseExceptionGroup( + "root group secret", + [ + KeyboardInterrupt("process-control secret"), + ProviderBaseExceptionGroup( + "nested provider secret", + [RuntimeError("nested leaf secret")], + ), + ], + ) + + direct = _safe_redacted_persistence_error(direct_source) + nested = _safe_redacted_persistence_error(nested_source) + + assert isinstance(direct, BaseExceptionGroup) + assert not isinstance(direct, Exception) + assert len(direct.exceptions) == 1 + assert isinstance(direct.exceptions[0], UserError) + assert isinstance(nested, BaseExceptionGroup) + assert not isinstance(nested, Exception) + assert len(nested.exceptions) == 2 + nested_provider = nested.exceptions[1] + assert isinstance(nested_provider, BaseExceptionGroup) + assert not isinstance(nested_provider, Exception) + assert len(nested_provider.exceptions) == 1 + assert isinstance(nested_provider.exceptions[0], UserError) + assert "secret" not in repr(direct) + assert "secret" not in repr(nested) + + +def test_safe_redacted_persistence_error_snapshots_linked_system_exit_code() -> None: + system_exit = SystemExit(7) + first_child = RuntimeError("first child secret") + first_child.__context__ = system_exit + source = BaseExceptionGroup("root group secret", [first_child, system_exit]) + + safe_error = _safe_redacted_persistence_error(source) + + assert isinstance(safe_error, BaseExceptionGroup) + assert isinstance(safe_error.exceptions[0], UserError) + safe_system_exit = safe_error.exceptions[1] + assert type(safe_system_exit) is SystemExit + assert safe_system_exit.code == 7 + assert safe_system_exit.args == (7,) + assert "secret" not in repr(safe_error) + + +def test_safe_redacted_persistence_error_avoids_hostile_class_descriptor() -> None: + source = _HostileClassBaseException("persistence secret") + + safe_error = _safe_redacted_persistence_error(source) + + assert type(safe_error) is BaseException + assert safe_error.__cause__ is None + assert safe_error.__context__ is None + assert "secret" not in str(safe_error) + assert "secret" not in repr(safe_error) + + +def test_safe_redacted_persistence_error_preserves_hybrid_cancellation() -> None: + direct_source = _HybridCancelledError("direct secret") + grouped_source = BaseExceptionGroup("group secret", [_HybridCancelledError("child secret")]) + direct = _safe_redacted_persistence_error(direct_source) + grouped = _safe_redacted_persistence_error(grouped_source) + + assert isinstance(direct, asyncio.CancelledError) + assert isinstance(direct, Exception) is isinstance(direct_source, Exception) + assert str(direct) == "Error details are redacted." + assert isinstance(grouped, BaseExceptionGroup) + assert isinstance(grouped, Exception) is isinstance(grouped_source, Exception) + assert len(grouped.exceptions) == 1 + assert isinstance(grouped.exceptions[0], asyncio.CancelledError) + assert isinstance(grouped.exceptions[0], Exception) + assert "secret" not in repr(grouped) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_max_turns_recovery_session_failure_preserves_diagnostic_context( + monkeypatch: pytest.MonkeyPatch, + streamed: bool, +) -> None: + persistence_secret = "DIAGNOSTIC_MAX_TURNS_SESSION_SECRET" + guardrail_secret = "DIAGNOSTIC_MAX_TURNS_GUARDRAIL_SECRET" + guardrail_failed = False + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + + class FailingMaxTurnsSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if guardrail_failed: + raise LookupError(persistence_secret) + await super().add_items(items) + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _agent_output: Any, + ) -> GuardrailFunctionOutput: + nonlocal guardrail_failed + guardrail_failed = True + raise RuntimeError(guardrail_secret) + + agent = Agent( + name="A", + model=ScriptedModel(), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = FailingMaxTurnsSession() + + if streamed: + result = Runner.run_streamed( + agent, + "go", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback"}, + ) + with pytest.raises(LookupError, match=persistence_secret) as exc_info: + async for _ in result.stream_events(): + pass + else: + with pytest.raises(LookupError, match=persistence_secret) as exc_info: + await Runner.run( + agent, + "go", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback"}, + ) + + guardrail_error = exc_info.value.__context__ + assert isinstance(guardrail_error, RuntimeError) + assert guardrail_secret in str(guardrail_error) + + @pytest.mark.asyncio async def test_streamed_input_guardrail_omits_run_data_from_redacted_error( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_max_turns.py b/tests/test_max_turns.py index f7d7c433c4..d002609523 100644 --- a/tests/test_max_turns.py +++ b/tests/test_max_turns.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import json +from typing import Any, Literal import pytest from pydantic import BaseModel @@ -8,10 +10,14 @@ from agents import ( Agent, + GuardrailFunctionOutput, ItemHelpers, MaxTurnsExceeded, MessageOutputItem, ModelRefusalError, + OutputGuardrail, + OutputGuardrailTripwireTriggered, + RunContextWrapper, RunErrorHandlerResult, Runner, SQLiteSession, @@ -26,6 +32,7 @@ get_refusal_message, get_text_message, ) +from .utils.simple_session import SimpleListSession @pytest.mark.asyncio @@ -545,3 +552,492 @@ async def test_streamed_max_turns_handler_persists_output_to_session(): item_types = await _run_max_turns_handler_with_session(streamed=True) assert item_types == ["user", "function_call", "function_call_output", "message"] + + +@pytest.mark.parametrize("include_in_history", [False, True]) +@pytest.mark.asyncio +async def test_max_turns_handler_persisted_count_matches_after_tool_turn( + include_in_history: bool, +) -> None: + async def run_once(streamed: bool) -> tuple[int, list[str]]: + model = ScriptedModel( + steps=[[get_function_tool_call("some_function", json.dumps({"a": "b"}))]] + ) + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + session = SimpleListSession() + handler_result = RunErrorHandlerResult( + final_output="fallback answer", + include_in_history=include_in_history, + ) + + if streamed: + result = Runner.run_streamed( + agent, + "user_message", + max_turns=1, + session=session, + error_handlers={"max_turns": lambda data: handler_result}, + ) + async for _ in result.stream_events(): + pass + else: + result = await Runner.run( + agent, + "user_message", + max_turns=1, + session=session, + error_handlers={"max_turns": lambda data: handler_result}, + ) + + persisted_count = result.to_state()._current_turn_persisted_item_count + saved_types = [ + str(item.get("type", item.get("role"))) for item in await session.get_items() + ] + return persisted_count, saved_types + + non_streamed = await run_once(streamed=False) + streamed = await run_once(streamed=True) + + expected_types = ["user", "function_call", "function_call_output"] + expected_count = 0 + if include_in_history: + expected_types.append("message") + expected_count = 1 + assert non_streamed == streamed == (expected_count, expected_types) + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("outcome", ["pass", "error", "tripwire"]) +@pytest.mark.asyncio +async def test_max_turns_handler_output_guardrail_session_semantics( + streamed: bool, + outcome: Literal["pass", "error", "tripwire"], +) -> None: + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + if outcome == "error": + raise RuntimeError("guardrail failed") + return GuardrailFunctionOutput( + output_info=outcome, + tripwire_triggered=outcome == "tripwire", + ) + + agent = Agent( + name="test", + model=ScriptedModel(), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() + streamed_events: list[Any] = [] + streamed_result: Any = None + + async def run_once() -> Any: + nonlocal streamed_result + if not streamed: + return await Runner.run( + agent, + "user_message", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + streamed_result = Runner.run_streamed( + agent, + "user_message", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + streamed_events.extend([event async for event in streamed_result.stream_events()]) + return streamed_result + + if outcome == "error": + with pytest.raises(RuntimeError, match="guardrail failed"): + await run_once() + elif outcome == "tripwire": + with pytest.raises(OutputGuardrailTripwireTriggered): + await run_once() + else: + result = await run_once() + assert result.final_output == "fallback answer" + assert len(result.output_guardrail_results) == 1 + assert result.to_state()._current_turn_persisted_item_count == 1 + + saved_items = await session.get_items() + saved_types = [str(item.get("type", item.get("role"))) for item in saved_items] + if outcome == "tripwire": + assert saved_types == ["user"] + else: + assert saved_types == ["user", "message"] + + fallback_events = [ + event + for event in streamed_events + if isinstance(event, RunItemStreamEvent) + and isinstance(event.item, MessageOutputItem) + and ItemHelpers.text_message_output(event.item) == "fallback answer" + ] + assert len(fallback_events) == (1 if streamed and outcome == "pass" else 0) + + if streamed: + assert streamed_result is not None + expected_history_count = 0 if outcome == "tripwire" else 1 + assert ( + len([item for item in streamed_result.new_items if isinstance(item, MessageOutputItem)]) + == expected_history_count + ) + state = streamed_result.to_state() + assert ( + len([item for item in state._session_items if isinstance(item, MessageOutputItem)]) + == expected_history_count + ) + + +@pytest.mark.asyncio +async def test_streamed_max_turns_handler_validation_failure_persists_input() -> None: + agent = Agent(name="test", model=ScriptedModel(), output_type=Foo) + session = SimpleListSession() + result = Runner.run_streamed( + agent, + "user_message", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: {"summary": "invalid"}}, + ) + + with pytest.raises(UserError): + async for _ in result.stream_events(): + pass + + saved_items = await session.get_items() + assert [item.get("type", item.get("role")) for item in saved_items] == ["user"] + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_max_turns_handler_records_equal_message_occurrences(streamed: bool) -> None: + model = ScriptedModel( + steps=[ + [ + get_text_message("same answer"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + ] + ] + ) + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + session = SimpleListSession() + + if streamed: + result = Runner.run_streamed( + agent, + "user_message", + max_turns=1, + session=session, + error_handlers={"max_turns": lambda data: "same answer"}, + ) + async for _ in result.stream_events(): + pass + else: + await Runner.run( + agent, + "user_message", + max_turns=1, + session=session, + error_handlers={"max_turns": lambda data: "same answer"}, + ) + + saved_items = await session.get_items() + messages = [item for item in saved_items if item.get("type") == "message"] + assert len(messages) == 2 + + +@pytest.mark.asyncio +async def test_streamed_max_turns_handler_can_skip_history_with_session() -> None: + agent = Agent(name="test", model=ScriptedModel()) + session = SimpleListSession() + result = Runner.run_streamed( + agent, + "user_message", + max_turns=0, + session=session, + error_handlers={ + "max_turns": lambda data: RunErrorHandlerResult( + final_output="fallback answer", + include_in_history=False, + ) + }, + ) + + events = [event async for event in result.stream_events()] + + assert result.final_output == "fallback answer" + assert not any(isinstance(event, RunItemStreamEvent) for event in events) + saved_items = await session.get_items() + assert [item.get("type", item.get("role")) for item in saved_items] == ["user"] + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_max_turns_handler_session_cancellation_does_not_publish_output( + streamed: bool, +) -> None: + class CancellingFinalSaveSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if any(item.get("type") == "message" for item in items): + raise asyncio.CancelledError("session save cancelled") + await super().add_items(items) + + agent = Agent(name="test", model=ScriptedModel()) + session = CancellingFinalSaveSession() + streamed_result: Any = None + + with pytest.raises(asyncio.CancelledError, match="session save cancelled"): + if streamed: + streamed_result = Runner.run_streamed( + agent, + "user_message", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + async for _ in streamed_result.stream_events(): + pass + else: + await Runner.run( + agent, + "user_message", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + + saved_items = await session.get_items() + assert [item.get("type", item.get("role")) for item in saved_items] == ["user"] + if streamed: + assert streamed_result.final_output is None + assert streamed_result.new_items == [] + + +@pytest.mark.asyncio +async def test_non_streamed_max_turns_handler_session_failure_does_not_record_output() -> None: + class FailingFinalSaveSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if any(item.get("type") == "message" for item in items): + raise UserError("session save failed") + await super().add_items(items) + + agent = Agent(name="test", model=ScriptedModel()) + session = FailingFinalSaveSession() + + with pytest.raises(UserError, match="session save failed") as exc_info: + await Runner.run( + agent, + "user_message", + max_turns=0, + session=session, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + + saved_items = await session.get_items() + assert [item.get("type", item.get("role")) for item in saved_items] == ["user"] + assert exc_info.value.run_data is not None + assert ItemHelpers.text_message_outputs(exc_info.value.run_data.new_items) == "" + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize( + ("outcome", "include_in_history"), + [ + ("success", True), + ("success", False), + ("guardrail_error", True), + ("session_failure", True), + ], +) +@pytest.mark.asyncio +async def test_resumed_max_turns_handler_does_not_append_to_caller_state_items( + streamed: bool, + outcome: Literal["success", "guardrail_error", "session_failure"], + include_in_history: bool, +) -> None: + fail_message_save = False + + class FailingSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if fail_message_save and any(item.get("type") == "message" for item in items): + raise UserError("session save failed") + await super().add_items(items) + + model = ScriptedModel(steps=[[get_text_message("first response")]]) + agent = Agent(name="test", model=model) + session = FailingSession() + first = await Runner.run(agent, "first input", max_turns=1, session=session) + state = first.to_state() + generated_items_before = state.to_json()["generated_items"] + session_items_before = state.to_json()["session_items"] + + if outcome == "guardrail_error": + + def fail_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + raise RuntimeError("guardrail failed") + + agent.output_guardrails = [OutputGuardrail(guardrail_function=fail_guardrail)] + fail_message_save = outcome == "session_failure" + handler_result = RunErrorHandlerResult( + final_output="fallback answer", + include_in_history=include_in_history, + ) + + if streamed: + result = Runner.run_streamed( + agent, + state, + session=session, + error_handlers={"max_turns": lambda data: handler_result}, + ) + if outcome == "guardrail_error": + with pytest.raises(RuntimeError, match="guardrail failed"): + async for _ in result.stream_events(): + pass + elif outcome == "session_failure": + with pytest.raises(UserError, match="session save failed"): + async for _ in result.stream_events(): + pass + else: + async for _ in result.stream_events(): + pass + assert result.final_output == "fallback answer" + else: + if outcome == "guardrail_error": + with pytest.raises(RuntimeError, match="guardrail failed"): + await Runner.run( + agent, + state, + session=session, + error_handlers={"max_turns": lambda data: handler_result}, + ) + elif outcome == "session_failure": + with pytest.raises(UserError, match="session save failed"): + await Runner.run( + agent, + state, + session=session, + error_handlers={"max_turns": lambda data: handler_result}, + ) + else: + result = await Runner.run( + agent, + state, + session=session, + error_handlers={"max_turns": lambda data: handler_result}, + ) + assert result.final_output == "fallback answer" + + assert state.to_json()["generated_items"] == generated_items_before + assert state.to_json()["session_items"] == session_items_before + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_resumed_max_turns_handler_preserves_output_guardrail_results( + streamed: bool, +) -> None: + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=output, tripwire_triggered=False) + + agent = Agent( + name="test", + model=ScriptedModel(steps=[[get_text_message("first response")]]), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + first = await Runner.run(agent, "first input", max_turns=1) + if streamed: + result = Runner.run_streamed( + agent, + first.to_state(), + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + async for _ in result.stream_events(): + pass + else: + result = await Runner.run( + agent, + first.to_state(), + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + + assert [item.output.output_info for item in result.output_guardrail_results] == [ + "first response", + "fallback answer", + ] + assert [item.output.output_info for item in result.to_state()._output_guardrail_results] == [ + "first response", + "fallback answer", + ] + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_resumed_max_turns_handler_preserves_checkpoint_after_continuation( + streamed: bool, +) -> None: + agent = Agent( + name="test", + model=ScriptedModel(steps=[[get_text_message("first response")]]), + ) + first = await Runner.run(agent, "first input", max_turns=2) + state = first.to_state() + generated_before = state.to_json()["generated_items"] + session_before = state.to_json()["session_items"] + agent.model = ScriptedModel( + steps=[[get_function_tool_call("some_function", json.dumps({"a": "b"}))]] + ) + agent.tools = [get_function_tool("some_function", "result")] + + def fail_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + raise RuntimeError("guardrail failed") + + agent.output_guardrails = [OutputGuardrail(guardrail_function=fail_guardrail)] + + if streamed: + result = Runner.run_streamed( + agent, + state, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + with pytest.raises(RuntimeError, match="guardrail failed"): + async for _ in result.stream_events(): + pass + else: + with pytest.raises(RuntimeError, match="guardrail failed"): + await Runner.run( + agent, + state, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + + assert state.to_json()["generated_items"] == generated_before + assert state.to_json()["session_items"] == session_before From 95f9d9a103f1fec7e2937c4eee92c65d38b08f9a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 18:27:29 +0900 Subject: [PATCH 314/473] fix: detach RunState interruption snapshots (#4409) --- src/agents/items.py | 10 +- src/agents/run_state.py | 674 +++++++- tests/test_run_state.py | 1788 +++++++++++++++++++++- tests/test_tool_name_collision_policy.py | 33 +- 4 files changed, 2437 insertions(+), 68 deletions(-) diff --git a/src/agents/items.py b/src/agents/items.py index f3d2d1a464..a4da32fe97 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -12,7 +12,9 @@ from openai.types.responses import ( Response, ResponseComputerToolCall, + ResponseCustomToolCall, ResponseFileSearchToolCall, + ResponseFunctionShellToolCall, ResponseFunctionShellToolCallOutput, ResponseFunctionToolCall, ResponseFunctionWebSearch, @@ -547,7 +549,13 @@ def to_input_item(self) -> TResponseInputItem: # Union type for tool approval raw items - supports function tools, hosted tools, shell tools, etc. ToolApprovalRawItem: TypeAlias = ( - ResponseFunctionToolCall | McpCall | McpApprovalRequest | LocalShellCall | dict[str, Any] + ResponseFunctionToolCall + | ResponseCustomToolCall + | ResponseFunctionShellToolCall + | McpCall + | McpApprovalRequest + | LocalShellCall + | dict[str, Any] ) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 01b1cfb5b5..c8881b3423 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -6,12 +6,13 @@ import copy import dataclasses import json +import math import threading from collections import deque from collections.abc import Callable, Collection, Iterator, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, cast +from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, cast, get_args from uuid import uuid4 from openai.types.responses import ( @@ -36,7 +37,7 @@ Program, ProgramOutput, ) -from pydantic import StringConstraints, TypeAdapter, ValidationError +from pydantic import BaseModel, StringConstraints, TypeAdapter, ValidationError from typing_extensions import TypedDict, TypeVar from ._tool_identity import ( @@ -86,6 +87,7 @@ ReasoningItem, RunItem, ToolApprovalItem, + ToolApprovalRawItem, ToolCallItem, ToolCallOutputItem, ToolSearchCallItem, @@ -244,6 +246,18 @@ class _LocalShellCallOutputPayload(TypedDict): _MCP_APPROVAL_RESPONSE_ADAPTER: TypeAdapter[McpApprovalResponse] = TypeAdapter(McpApprovalResponse) _HANDOFF_OUTPUT_ADAPTER: TypeAdapter[TResponseInputItem] = TypeAdapter(TResponseInputItem) _LOCAL_SHELL_CALL_ADAPTER: TypeAdapter[LocalShellCall] = TypeAdapter(LocalShellCall) +_TOOL_APPROVAL_MODEL_TYPES: tuple[type[BaseModel], ...] = tuple( + raw_item_type + for raw_item_type in get_args(ToolApprovalRawItem) + if isinstance(raw_item_type, type) and issubclass(raw_item_type, BaseModel) +) +_TOOL_APPROVAL_MODEL_ADAPTERS: tuple[tuple[type[BaseModel], TypeAdapter[Any]], ...] = tuple( + (model_type, TypeAdapter(model_type)) for model_type in _TOOL_APPROVAL_MODEL_TYPES +) +_UNSAFE_PYDANTIC_SUBTYPE_HOOKS = frozenset({"__getattr__", "__getattribute__"}) +_PYDANTIC_PUBLIC_COPY_INSTANCE_ATTRIBUTES = frozenset( + {"__dict__", "__pydantic_extra__", "__pydantic_fields_set__"} +) _MISSING_CONTEXT_SENTINEL = object() _ALLOWED_MISSING_MESSAGE_FIELDS = frozenset({"status"}) @@ -253,6 +267,479 @@ def _deserialize_tool_origin(data: Any) -> ToolOrigin | None: return ToolOrigin.from_json_dict(data) +def _static_type_mro(value: Any) -> tuple[type[Any], ...]: + """Return an instance's real MRO without consulting instance attributes.""" + return cast(tuple[type[Any], ...], type.__getattribute__(type(value), "__mro__")) + + +def _declared_model_type_from_annotation( + annotation: Any, + value_mro: tuple[type[Any], ...], +) -> type[BaseModel] | None: + """Resolve a nested model from trusted Pydantic field annotation identities.""" + pending = [annotation] + visited: set[int] = set() + while pending: + candidate = pending.pop() + candidate_id = id(candidate) + if candidate_id in visited: + continue + visited.add(candidate_id) + if isinstance(candidate, type): + candidate_mro = type.__getattribute__(candidate, "__mro__") + if BaseModel in candidate_mro and candidate in value_mro: + return cast(type[BaseModel], candidate) + pending.extend(get_args(candidate)) + return None + + +def _copy_json_compatible_value(value: Any, active_container_ids: set[int]) -> Any: + """Copy bounded JSON-shaped data without invoking container or model hooks.""" + if value is None or type(value) is bool: + return value + value_mro = _static_type_mro(value) + if str in value_mro: + return str.__str__(value) + if int in value_mro: + return int.__int__(value) + if float in value_mro: + copied_float = float.__float__(value) + if not math.isfinite(copied_float): + raise TypeError("Non-finite number in tool approval payload") + return copied_float + value_id = id(value) + if value_id in active_container_ids: + raise TypeError("Cyclic tool approval payload") + if dict in value_mro: + active_container_ids.add(value_id) + try: + copied_dict: dict[str, Any] = {} + for key, item in dict.items(value): + if str not in _static_type_mro(key): + raise TypeError("Non-string key in tool approval payload") + normalized_key = str.__str__(key) + if normalized_key in copied_dict: + raise TypeError("Colliding key in tool approval payload") + copied_dict[normalized_key] = _copy_json_compatible_value( + item, + active_container_ids, + ) + return copied_dict + finally: + active_container_ids.remove(value_id) + if list in value_mro: + active_container_ids.add(value_id) + try: + return [ + _copy_json_compatible_value(item, active_container_ids) + for item in list.__iter__(value) + ] + finally: + active_container_ids.remove(value_id) + if tuple in value_mro: + active_container_ids.add(value_id) + try: + return [ + _copy_json_compatible_value(item, active_container_ids) + for item in tuple.__iter__(value) + ] + finally: + active_container_ids.remove(value_id) + raise TypeError("Unsupported value in tool approval payload") + + +def _copy_pydantic_value( + value: Any, + active_container_ids: set[int], + *, + allow_models: bool, + declared_model_type: type[BaseModel] | None = None, + declared_annotation: Any = None, +) -> Any: + """Copy a Pydantic value before public serialization can traverse untrusted data.""" + if value is None or type(value) is bool: + return value + value_mro = _static_type_mro(value) + if str in value_mro: + return str.__str__(value) + if int in value_mro: + return int.__int__(value) + if float in value_mro: + copied_float = float.__float__(value) + if not math.isfinite(copied_float): + raise TypeError("Non-finite number in tool approval payload") + return copied_float + + value_id = id(value) + if value_id in active_container_ids: + raise TypeError("Cyclic tool approval payload") + + if BaseModel in value_mro: + if not allow_models: + raise TypeError("Unsupported model in tool approval metadata") + if declared_model_type is None: + declared_model_type = _declared_model_type_from_annotation( + declared_annotation, + value_mro, + ) + if declared_model_type is None or declared_model_type not in value_mro: + raise TypeError("Unsupported model in tool approval payload") + trusted_model_mro = frozenset(type.__getattribute__(declared_model_type, "__mro__")) + for subtype in value_mro: + if subtype in trusted_model_mro: + continue + subtype_namespace = type.__getattribute__(subtype, "__dict__") + if _UNSAFE_PYDANTIC_SUBTYPE_HOOKS & subtype_namespace.keys(): + raise TypeError("Unsupported model hooks in tool approval payload") + for attribute_name in _PYDANTIC_PUBLIC_COPY_INSTANCE_ATTRIBUTES: + for attribute_owner in value_mro: + owner_namespace = type.__getattribute__(attribute_owner, "__dict__") + if attribute_name not in owner_namespace: + continue + if attribute_owner not in trusted_model_mro: + raise TypeError("Unsupported model storage hooks in tool approval payload") + break + active_container_ids.add(value_id) + try: + declared_fields = declared_model_type.model_fields + model_storage = object.__getattribute__(value, "__dict__") + if type(model_storage) is not dict: + raise TypeError("Unsupported tool approval model storage") + model_extra = BaseModel.model_extra.__get__(value, BaseModel) + copied_extra: dict[str, Any] = {} + if model_extra is not None: + if type(model_extra) is not dict: + raise TypeError("Unsupported tool approval model extras") + seen_extra_names = set(declared_fields) + for extra_name, extra_value in dict.items(model_extra): + if str not in _static_type_mro(extra_name): + raise TypeError("Non-string key in tool approval model extras") + normalized_name = str.__str__(extra_name) + if normalized_name in seen_extra_names: + raise TypeError("Colliding key in tool approval model extras") + seen_extra_names.add(normalized_name) + copied_extra[normalized_name] = _copy_pydantic_value( + extra_value, + active_container_ids, + allow_models=False, + ) + + copied_fields: dict[str, Any] = {} + for field_name, field_value in dict.items(model_storage): + if str not in _static_type_mro(field_name): + raise TypeError("Non-string field name in tool approval payload") + normalized_field_name = str.__str__(field_name) + if normalized_field_name not in declared_fields: + continue + if normalized_field_name in copied_fields: + raise TypeError("Colliding field name in tool approval payload") + copied_fields[normalized_field_name] = _copy_pydantic_value( + field_value, + active_container_ids, + allow_models=True, + declared_annotation=declared_fields[normalized_field_name].annotation, + ) + + source_fields_set = BaseModel.model_fields_set.__get__(value, BaseModel) + if type(source_fields_set) is not set: + raise TypeError("Unsupported tool approval model fields set") + copied_fields_set: set[str] = set() + allowed_fields_set = set(declared_fields) | set(copied_extra) + for field_name in set.__iter__(source_fields_set): + if str not in _static_type_mro(field_name): + raise TypeError("Non-string field name in tool approval fields set") + normalized_field_name = str.__str__(field_name) + if normalized_field_name in copied_fields_set: + raise TypeError("Colliding field name in tool approval fields set") + if normalized_field_name in allowed_fields_set: + copied_fields_set.add(normalized_field_name) + + copied_model = declared_model_type.model_construct( + _fields_set=set(copied_fields_set), + **copied_fields, + **copied_extra, + ) + constructed_fields_set = BaseModel.model_fields_set.__get__( + copied_model, + BaseModel, + ) + set.clear(constructed_fields_set) + set.update(constructed_fields_set, copied_fields_set) + return copied_model + finally: + active_container_ids.remove(value_id) + + if dict in value_mro: + active_container_ids.add(value_id) + try: + copied_dict: dict[str, Any] = {} + seen_names: set[str] = set() + for key, item in dict.items(value): + if str not in _static_type_mro(key): + raise TypeError("Non-string key in tool approval payload") + normalized_key = str.__str__(key) + if normalized_key in seen_names: + raise TypeError("Colliding key in tool approval payload") + seen_names.add(normalized_key) + copied_dict[normalized_key] = _copy_pydantic_value( + item, + active_container_ids, + allow_models=allow_models, + declared_annotation=declared_annotation, + ) + return copied_dict + finally: + active_container_ids.remove(value_id) + + if list in value_mro: + active_container_ids.add(value_id) + try: + return [ + _copy_pydantic_value( + item, + active_container_ids, + allow_models=allow_models, + declared_annotation=declared_annotation, + ) + for item in list.__iter__(value) + ] + finally: + active_container_ids.remove(value_id) + + if tuple in value_mro: + active_container_ids.add(value_id) + try: + return [ + _copy_pydantic_value( + item, + active_container_ids, + allow_models=allow_models, + declared_annotation=declared_annotation, + ) + for item in tuple.__iter__(value) + ] + finally: + active_container_ids.remove(value_id) + + raise TypeError("Unsupported value in tool approval payload") + + +def _merge_realized_declared_values( + explicit: Any, + realized: Any, + baseline: Any, +) -> Any: + """Keep realized declared values that differ from base-model defaults.""" + if type(explicit) is dict and type(realized) is dict and type(baseline) is dict: + merged = dict(explicit) + for key, realized_value in dict.items(realized): + if key not in baseline: + continue + baseline_value = baseline[key] + if key in explicit: + merged[key] = _merge_realized_declared_values( + explicit[key], + realized_value, + baseline_value, + ) + elif realized_value != baseline_value: + merged[key] = realized_value + return merged + if ( + type(explicit) is list + and type(realized) is list + and type(baseline) is list + and len(explicit) == len(realized) == len(baseline) + ): + return [ + _merge_realized_declared_values(explicit_item, realized_item, baseline_item) + for explicit_item, realized_item, baseline_item in zip( + explicit, + realized, + baseline, + strict=True, + ) + ] + return realized if realized != baseline else explicit + + +def _validate_declared_payload( + model_adapter: TypeAdapter[Any], + explicit: dict[str, Any], + realized: dict[str, Any], +) -> Any: + """Validate a declared payload after filling only missing required values.""" + while True: + try: + return model_adapter.validate_python(explicit) + except ValidationError as error: + filled_missing_value = False + for detail in error.errors( + include_url=False, + include_context=False, + include_input=False, + ): + if detail.get("type") != "missing": + continue + location = detail.get("loc") + if not isinstance(location, tuple) or not location: + continue + explicit_parent: Any = explicit + realized_parent: Any = realized + for part in location[:-1]: + if ( + type(part) is str + and type(explicit_parent) is dict + and type(realized_parent) is dict + and part in explicit_parent + and part in realized_parent + ): + explicit_parent = explicit_parent[part] + realized_parent = realized_parent[part] + elif ( + type(part) is int + and type(explicit_parent) is list + and type(realized_parent) is list + and 0 <= part < len(explicit_parent) + and part < len(realized_parent) + ): + explicit_parent = explicit_parent[part] + realized_parent = realized_parent[part] + else: + break + else: + missing_part = location[-1] + if ( + type(missing_part) is str + and type(explicit_parent) is dict + and type(realized_parent) is dict + and missing_part not in explicit_parent + and missing_part in realized_parent + ): + explicit_parent[missing_part] = realized_parent[missing_part] + filled_missing_value = True + if not filled_missing_value: + raise + + +def _restore_pydantic_fields_set(value: Any, source: Any) -> None: + """Restore declared field-set semantics after public Pydantic validation.""" + value_mro = _static_type_mro(value) + source_mro = _static_type_mro(source) + if BaseModel in value_mro and BaseModel in source_mro: + value_fields_set = BaseModel.model_fields_set.__get__(value, BaseModel) + source_fields_set = BaseModel.model_fields_set.__get__(source, BaseModel) + set.clear(value_fields_set) + set.update(value_fields_set, source_fields_set) + + source_values: dict[str, Any] = {} + for field_name, field_value in BaseModel.__iter__(source): + if str in _static_type_mro(field_name): + source_values[str.__str__(field_name)] = field_value + for field_name, field_value in BaseModel.__iter__(value): + if str not in _static_type_mro(field_name): + continue + source_value = source_values.get(str.__str__(field_name), _MISSING_CONTEXT_SENTINEL) + if source_value is not _MISSING_CONTEXT_SENTINEL: + _restore_pydantic_fields_set(field_value, source_value) + return + + if list in value_mro and list in source_mro: + for item, source_item in zip( + list.__iter__(value), + list.__iter__(source), + strict=False, + ): + _restore_pydantic_fields_set(item, source_item) + return + + if tuple in value_mro and tuple in source_mro: + for item, source_item in zip( + tuple.__iter__(value), + tuple.__iter__(source), + strict=False, + ): + _restore_pydantic_fields_set(item, source_item) + return + + if dict in value_mro and dict in source_mro: + for key, item in dict.items(value): + if str not in _static_type_mro(key): + continue + source_item = dict.get( + source, + str.__str__(key), + _MISSING_CONTEXT_SENTINEL, + ) + if source_item is not _MISSING_CONTEXT_SENTINEL: + _restore_pydantic_fields_set(item, source_item) + + +def _copy_tool_approval_raw_item(raw_item: Any) -> Any: + """Copy a supported approval raw item through public Pydantic APIs.""" + active_container_ids: set[int] = set() + raw_item_mro = _static_type_mro(raw_item) + for model_type, model_adapter in _TOOL_APPROVAL_MODEL_ADAPTERS: + if model_type not in raw_item_mro: + continue + copied_raw_item = _copy_pydantic_value( + raw_item, + active_container_ids, + allow_models=True, + declared_model_type=model_type, + ) + explicit = model_adapter.dump_python( + copied_raw_item, + mode="json", + round_trip=True, + exclude_unset=True, + warnings="error", + serialize_as_any=False, + by_alias=False, + ) + copied_explicit = _copy_json_compatible_value(explicit, active_container_ids) + if type(copied_explicit) is not dict: + raise TypeError("Unsupported serialized tool approval payload") + realized = model_adapter.dump_python( + copied_raw_item, + mode="json", + round_trip=True, + exclude_unset=False, + warnings="error", + serialize_as_any=False, + by_alias=False, + ) + copied_realized = _copy_json_compatible_value(realized, active_container_ids) + if type(copied_realized) is not dict: + raise TypeError("Unsupported serialized tool approval payload") + baseline_model = _validate_declared_payload( + model_adapter, + copied_explicit, + copied_realized, + ) + baseline = model_adapter.dump_python( + baseline_model, + mode="json", + round_trip=True, + exclude_unset=False, + warnings="error", + serialize_as_any=False, + by_alias=False, + ) + copied_baseline = _copy_json_compatible_value(baseline, active_container_ids) + merged = _merge_realized_declared_values( + copied_explicit, + copied_realized, + copied_baseline, + ) + validated_model = model_adapter.validate_python(merged) + _restore_pydantic_fields_set(validated_model, copied_raw_item) + return validated_model + if dict in raw_item_mro: + return _copy_json_compatible_value(raw_item, active_container_ids) + raise TypeError("Unsupported tool approval raw item") + + @dataclass class RunState(Generic[TContext, TAgent]): """Serializable snapshot of an agent run, including context, usage, and interruptions. @@ -452,20 +939,47 @@ def clear_pending_input(self) -> None: self._pending_input = [] def get_interruptions(self) -> list[ToolApprovalItem]: - """Return pending interruptions if the current step is an interruption.""" + """Return detached copies of pending interruptions for the current step.""" # Import at runtime to avoid circular import from .run_internal.run_steps import NextStepInterruption if self._current_step is None or not isinstance(self._current_step, NextStepInterruption): return [] - return list(self._current_step.interruptions) + copy_error: UserError | None = None + try: + interruptions: list[ToolApprovalItem] = [] + for item in self._current_step.interruptions: + copied_raw_item = _copy_tool_approval_raw_item(item.raw_item) + interruptions.append( + dataclasses.replace( + item, + agent=item.agent, + raw_item=copied_raw_item, + ) + ) + except Exception as error: + _prepare_data_redacted_error(error) + copy_error = UserError( + "Cannot safely copy pending tool approvals. Ensure each interruption uses a " + "supported tool call or contains only JSON-compatible mapping data." + ) + if copy_error is not None: + _mark_error_data_redacted(copy_error) + self = cast(Any, None) + item = cast(Any, None) + copied_raw_item = None + interruptions = [] + _raise_data_redacted_error(copy_error) + return interruptions @staticmethod def _approval_items_match( candidate: ToolApprovalItem, approval_item: ToolApprovalItem, - ) -> bool: - """Return whether two approval items identify the same nested invocation.""" + *, + approval_is_authoritative: bool = False, + ) -> bool | None: + """Compare approval identity, returning None when an owner is unsafe to distinguish.""" if candidate is approval_item: return True candidate_agent = candidate.agent @@ -476,18 +990,64 @@ def _approval_items_match( and candidate_agent is not approval_agent ): return False + try: + approval_raw_item = _copy_tool_approval_raw_item(approval_item.raw_item) + except Exception: + return None if approval_is_authoritative else False + try: + candidate_raw_item = _copy_tool_approval_raw_item(candidate.raw_item) + except Exception: + return None candidate_identity = tool_invocation_identity( - candidate.raw_item, + candidate_raw_item, tool_lookup_key=candidate.tool_lookup_key, tool_name=candidate.tool_name, ) approval_identity = tool_invocation_identity( - approval_item.raw_item, + approval_raw_item, tool_lookup_key=approval_item.tool_lookup_key, tool_name=approval_item.tool_name, ) return candidate_identity is not None and candidate_identity == approval_identity + def _find_current_approval_item( + self, + approval_item: ToolApprovalItem, + *, + approval_is_authoritative: bool | None = None, + ) -> ToolApprovalItem | None: + """Resolve a detached approval snapshot to current authoritative pending state.""" + from .run_internal.run_steps import NextStepInterruption + + if not isinstance(self._current_step, NextStepInterruption): + return None + if approval_is_authoritative is None: + approval_is_authoritative = any( + candidate is approval_item for candidate in self._current_step.interruptions + ) + canonical_matches: list[ToolApprovalItem] = [] + has_indeterminate_candidate = False + for candidate in self._current_step.interruptions: + if candidate is approval_item: + canonical_matches.append(candidate) + continue + match = self._approval_items_match( + candidate, + approval_item, + approval_is_authoritative=approval_is_authoritative, + ) + if match is None: + has_indeterminate_candidate = True + elif match: + canonical_matches.append(candidate) + if has_indeterminate_candidate or len(canonical_matches) > 1: + raise UserError( + "Cannot apply approval because multiple current pending approvals contain the " + "same tool invocation identity, or because it belongs to both the current run " + "and a nested agent-tool run. Use unique call IDs." + ) + return canonical_matches[0] if canonical_matches else None + def _find_nested_approval_state( self, approval_item: ToolApprovalItem, @@ -497,11 +1057,66 @@ def _find_nested_approval_state( return None from .agent_tool_state import peek_agent_tool_run_result + from .run_internal.run_steps import NextStepInterruption + nested_candidates: list[tuple[RunState[Any, Agent[Any]], ToolApprovalItem]] = [] + for function_run in self._last_processed_response.functions: + pending_result = peek_agent_tool_run_result( + function_run.tool_call, + scope_id=self._agent_tool_state_scope_id, + ) + interruptions = getattr(pending_result, "interruptions", None) + to_state = getattr(pending_result, "to_state", None) + if not isinstance(interruptions, list) or not callable(to_state): + continue + nested_state = to_state() + if not isinstance(nested_state, RunState) or nested_state is self: + continue + nested_candidates.extend( + (nested_state, candidate) + for candidate in interruptions + if isinstance(candidate, ToolApprovalItem) + ) + + current_candidates = ( + self._current_step.interruptions + if isinstance(self._current_step, NextStepInterruption) + else [] + ) + approval_is_authoritative = any( + candidate is approval_item for candidate in current_candidates + ) or any(candidate is approval_item for _, candidate in nested_candidates) + current_approval_item = self._find_current_approval_item( + approval_item, + approval_is_authoritative=approval_is_authoritative, + ) + canonical_matches: list[tuple[RunState[Any, Agent[Any]], ToolApprovalItem]] = [] + has_indeterminate_candidate = False + for nested_state, candidate in nested_candidates: + if candidate is approval_item: + canonical_matches.append((nested_state, candidate)) + continue + match = self._approval_items_match( + candidate, + approval_item, + approval_is_authoritative=approval_is_authoritative, + ) + if match is None: + has_indeterminate_candidate = True + elif match: + canonical_matches.append((nested_state, candidate)) + + if has_indeterminate_candidate: + raise UserError( + "Cannot apply approval because one or more nested agent-tool approvals cannot be " + "safely distinguished. Use JSON-compatible approval payloads and unique call IDs." + ) + + identity_item = current_approval_item or approval_item approval_identity = tool_invocation_identity_and_scope( - approval_item.raw_item, - tool_lookup_key=approval_item.tool_lookup_key, - tool_name=approval_item.tool_name, + identity_item.raw_item, + tool_lookup_key=identity_item.tool_lookup_key, + tool_name=identity_item.tool_name, ) current_state_owns_approval = False if approval_identity is not None and self._context is not None: @@ -579,38 +1194,11 @@ def _find_nested_approval_state( current_state_owns_approval and approval_identity in current_response_identities ) - exact_match: tuple[RunState[Any, Agent[Any]], ToolApprovalItem] | None = None - canonical_matches: list[tuple[RunState[Any, Agent[Any]], ToolApprovalItem]] = [] - for function_run in self._last_processed_response.functions: - pending_result = peek_agent_tool_run_result( - function_run.tool_call, - scope_id=self._agent_tool_state_scope_id, - ) - interruptions = getattr(pending_result, "interruptions", None) - to_state = getattr(pending_result, "to_state", None) - if not isinstance(interruptions, list) or not callable(to_state): - continue - nested_state = to_state() - if not isinstance(nested_state, RunState) or nested_state is self: - continue - for candidate in interruptions: - if not isinstance(candidate, ToolApprovalItem): - continue - if candidate is approval_item: - exact_match = (nested_state, candidate) - break - if self._approval_items_match(candidate, approval_item): - canonical_matches.append((nested_state, candidate)) - if exact_match is not None: - break - - if current_state_owns_approval and (exact_match is not None or canonical_matches): + if current_state_owns_approval and canonical_matches: raise UserError( "Cannot apply approval because the same tool invocation identity belongs to both " "the current run and a nested agent-tool run. Use distinct call IDs." ) - if exact_match is not None: - return exact_match if len(canonical_matches) == 1: return canonical_matches[0] if len(canonical_matches) > 1: @@ -629,7 +1217,11 @@ def approve(self, approval_item: ToolApprovalItem, always_approve: bool = False) nested_state, nested_item = nested_approval nested_state.approve(nested_item, always_approve=always_approve) return - self._context.approve_tool(approval_item, always_approve=always_approve) + current_approval_item = self._find_current_approval_item(approval_item) + self._context.approve_tool( + current_approval_item or approval_item, + always_approve=always_approve, + ) def reject( self, @@ -656,7 +1248,7 @@ def reject( ) return self._context.reject_tool( - approval_item, + self._find_current_approval_item(approval_item) or approval_item, always_reject=always_reject, rejection_message=rejection_message, ) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 748d0c064e..21475ca326 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -8,14 +8,16 @@ import logging from collections.abc import Callable, Mapping from copy import deepcopy -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime from pathlib import Path from types import SimpleNamespace -from typing import Any, TypeVar, cast +from typing import Any, ClassVar, Literal, TypeVar, cast import pytest from openai.types.responses import ( + ResponseCustomToolCall, + ResponseFunctionShellToolCall, ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText, @@ -30,13 +32,15 @@ from openai.types.responses.response_function_tool_call import CallerProgram from openai.types.responses.response_output_item import ( LocalShellCall, + LocalShellCallAction, McpApprovalRequest, + McpCall, Program, ProgramOutput, ) from openai.types.responses.response_usage import InputTokensDetails from openai.types.responses.tool_param import Mcp -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError, model_serializer from agents import Agent, ModelSettings, RunConfig, RunHooks, Runner, handoff, trace from agents._tool_invocation import tool_invocation_identity_and_scope @@ -1612,26 +1616,1318 @@ def test_get_interruptions_returns_interruptions_when_present(self): interruptions = state.get_interruptions() assert len(interruptions) == 1 - assert interruptions[0] == approval_item + assert interruptions[0] is not approval_item + assert interruptions[0].agent is agent + assert interruptions[0].tool_name == approval_item.tool_name + assert interruptions[0].raw_item.model_dump() == approval_item.raw_item.model_dump() + assert interruptions[0].raw_item is not approval_item.raw_item + + @pytest.mark.parametrize("raw_item_kind", ["pydantic", "mapping"]) + def test_get_interruptions_returns_detached_item_snapshots(self, raw_item_kind: str): + """Mutating returned interruption content must not change pending approvals.""" + agent = Agent(name="SnapshotAgent") + raw_item: Any + if raw_item_kind == "pydantic": + raw_item = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="cid-snapshot", + status="completed", + arguments='{"value": "original"}', + ) + else: + raw_item = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-snapshot", + "status": "completed", + "arguments": '{"value": "original"}', + "metadata": {"tags": ["original"]}, + } + approval_item = ToolApprovalItem( + agent=agent, + raw_item=raw_item, + ) + state = make_state_with_interruptions(agent, [approval_item]) + + interruption = state.get_interruptions()[0] + interruption.tool_name = "changed" + if isinstance(interruption.raw_item, dict): + interruption.raw_item["arguments"] = '{"value": "changed"}' + interruption.raw_item["metadata"]["tags"].append("changed") + else: + interruption.raw_item.arguments = '{"value": "changed"}' + + pending = state.get_interruptions()[0] + assert pending is not interruption + assert pending.agent is agent + assert pending.tool_name == "toolA" + if isinstance(pending.raw_item, dict): + assert pending.raw_item["arguments"] == '{"value": "original"}' + assert pending.raw_item["metadata"] == {"tags": ["original"]} + else: + assert pending.raw_item.arguments == '{"value": "original"}' + + @pytest.mark.parametrize("raw_item_kind", ["pydantic", "mapping"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_get_interruptions_snapshots_can_apply_approval_decisions( + self, + raw_item_kind: str, + approve: bool, + ) -> None: + """Detached snapshots must retain canonical approval identity.""" + agent = Agent(name="DecisionAgent") + raw_item: Any = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-decision", + "status": "completed", + "arguments": "{}", + } + if raw_item_kind == "pydantic": + raw_item = ResponseFunctionToolCall(**raw_item) + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + state = make_state_with_interruptions(agent, [approval_item]) + + interruption = state.get_interruptions()[0] + assert interruption is not approval_item + if approve: + state.approve(interruption) + else: + state.reject(interruption) + + assert state._context is not None + assert state._context.is_tool_approved("toolA", "cid-decision") is approve + + def test_get_interruptions_fails_before_returning_an_unsafe_snapshot(self): + """Uncopyable payloads must fail at the snapshot boundary.""" + + class Uncopyable: + def __deepcopy__(self, _memo: dict[int, Any]) -> Any: + raise RuntimeError("cannot copy") + + agent = Agent(name="UncopyableAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "toolA", + "call_id": "cid-uncopyable", + "status": "completed", + "arguments": "{}", + "metadata": Uncopyable(), + }, + ) + state = make_state_with_interruptions(agent, [approval_item]) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + def test_get_interruptions_clone_failure_drops_sensitive_exception_context(self) -> None: + """Clone failures must not retain payload data in the exception graph.""" + source_sentinel = "SENSITIVE_APPROVAL_CONTEXT_VALUE" + partial_sentinel = "SENSITIVE_PARTIAL_COPY_VALUE" + agent = Agent(name="CloneFailureContextAgent") + safe_item = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "safeTool", + "call_id": "cid-safe-before-sensitive-failure", + "arguments": "{}", + "metadata": {"secret": partial_sentinel}, + }, + ) + raw_item = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-sensitive-clone-failure", + "arguments": "{}", + "metadata": {"secret": source_sentinel, "unsafe": object()}, + } + failing_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + state = make_state_with_interruptions( + agent, + [safe_item, failing_item], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals") as exc: + state.get_interruptions() + + assert exc.value.__cause__ is None + assert exc.value.__context__ is None + assert source_sentinel not in repr(exc.value) + assert partial_sentinel not in repr(exc.value) + traceback = exc.value.__traceback__ + while traceback is not None: + frame = traceback.tb_frame + if "/src/agents/" in frame.f_code.co_filename: + local_values = tuple(frame.f_locals.values()) + assert all(value is not state for value in local_values) + assert all(value is not safe_item for value in local_values) + assert all(value is not failing_item for value in local_values) + assert all(value is not raw_item for value in local_values) + assert not any(isinstance(value, RunState) for value in local_values) + assert not any(isinstance(value, ToolApprovalItem) for value in local_values) + assert source_sentinel not in repr(frame.f_locals) + assert partial_sentinel not in repr(frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_get_interruptions_canonicalizes_custom_outer_models( + self, + approve: bool, + ) -> None: + """Declared model subtypes must retain canonical approval identity.""" + + class CustomCall(ResponseFunctionToolCall): + serializer_called: ClassVar[bool] = False + status: Literal["completed"] = "completed" + action: dict[str, str] + subtype_metadata: dict[str, list[str]] + subtype_only: Any + + @model_serializer(mode="wrap") + def serialize_custom_call(self, handler: Any) -> Any: + type(self).serializer_called = True + return handler(self) + + raw_item = CustomCall( + type="function_call", + name="toolA", + call_id="cid-custom-model", + arguments="{}", + action={"kind": "subtype-only"}, + subtype_metadata={"tags": ["subtype-only"]}, + subtype_only=object(), + ) + agent = Agent(name="CustomModelAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + assert state._context is not None + state._context._tool_invocation_status(raw_item) + CustomCall.serializer_called = False + snapshot = state.get_interruptions()[0] + + assert not CustomCall.serializer_called + assert type(snapshot.raw_item) is ResponseFunctionToolCall + assert snapshot.raw_item.call_id == "cid-custom-model" + assert snapshot.raw_item.status == "completed" + assert "status" not in snapshot.raw_item.model_fields_set + assert "status" not in snapshot.raw_item.model_dump(exclude_unset=True) + assert "subtype_metadata" not in snapshot.raw_item.model_dump() + if approve: + state.approve(snapshot) + else: + state.reject(snapshot) + assert state._context.is_tool_approved("toolA", "cid-custom-model") is approve + + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_get_interruptions_subtype_snapshots_route_to_nested_approval( + self, + approve: bool, + ) -> None: + """Canonical subtype snapshots must resolve to nested authoritative items.""" + from agents.agent_tool_state import drop_agent_tool_run_result, record_agent_tool_run_result + + class CustomCall(ResponseFunctionToolCall): + status: Literal["completed"] = "completed" + action: dict[str, str] + + agent = Agent(name="NestedSubtypeAgent") + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + nested_outer_call = make_tool_call( + call_id="outer-nested-subtype", + name="nested_agent_tool", + ) + raw_item = CustomCall( + type="function_call", + name="toolA", + call_id="cid-nested-subtype", + arguments="{}", + action={"kind": "subtype-only"}, + ) + nested_approval = ToolApprovalItem(agent=agent, raw_item=raw_item) + state = make_state_with_interruptions(agent, [nested_approval]) + state._last_processed_response = make_processed_response( + functions=[ + ToolRunFunction(tool_call=nested_outer_call, function_tool=nested_tool), + ] + ) + nested_state = make_state_with_interruptions(agent, [nested_approval]) + assert nested_state._context is not None + nested_state._context._tool_invocation_status(raw_item) + record_agent_tool_run_result( + nested_outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[nested_approval], + to_state=lambda: nested_state, + ), + ), + scope_id=state._agent_tool_state_scope_id, + ) + + try: + snapshot = state.get_interruptions()[0] + assert snapshot.raw_item.status == "completed" + if approve: + state.approve(snapshot) + else: + state.reject(snapshot) + assert ( + nested_state._context.is_tool_approved( + "toolA", + "cid-nested-subtype", + ) + is approve + ) + finally: + drop_agent_tool_run_result( + nested_outer_call, + scope_id=state._agent_tool_state_scope_id, + ) + + def test_get_interruptions_preserves_required_declared_subtype_defaults(self) -> None: + """Subtype defaults for base-required fields must survive canonicalization.""" + + class DefaultArgumentsCall(ResponseFunctionToolCall): + arguments: str = "{}" + + raw_item = DefaultArgumentsCall( + type="function_call", + name="toolA", + call_id="cid-required-subtype-default", + ) + agent = Agent(name="RequiredSubtypeDefaultAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + snapshot = state.get_interruptions()[0] + + assert type(snapshot.raw_item) is ResponseFunctionToolCall + assert snapshot.raw_item.arguments == "{}" + assert "arguments" not in snapshot.raw_item.model_fields_set + assert "arguments" not in snapshot.raw_item.model_dump(exclude_unset=True) + assert raw_item.arguments == "{}" + + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_get_interruptions_rejects_typed_extra_identity_collisions( + self, + approve: bool, + ) -> None: + """Typed extras must not replace declared approval identity fields.""" + agent = Agent(name="TypedExtraCollisionAgent") + raw_item = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="authoritative", + arguments="{}", + ) + assert raw_item.model_extra is not None + raw_item.model_extra["call_id"] = "forged" + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + snapshot = state.get_interruptions()[0] + if approve: + state.approve(snapshot) + else: + state.reject(snapshot) + + assert state._context is not None + assert state._context.is_tool_approved("toolA", "authoritative") is None + assert state._context.is_tool_approved("toolA", "forged") is None + + def test_get_interruptions_does_not_hash_typed_extra_keys(self) -> None: + """Typed-extra keys must be normalized before any hash-based lookup.""" + + class MutatingKey(str): + def __new__(cls, value: str, owner: ResponseFunctionToolCall) -> MutatingKey: + key = str.__new__(cls, value) + key.owner = owner + return key + + def __hash__(self) -> int: + object.__setattr__(self.owner, "arguments", "mutated-by-key-hash") + return str.__hash__(self) + + agent = Agent(name="TypedExtraKeyAgent") + raw_item = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="cid-typed-extra-key", + arguments="original", + ) + assert raw_item.model_extra is not None + key = MutatingKey("metadata", raw_item) + raw_item.model_extra[key] = {"safe": True} + object.__setattr__(raw_item, "arguments", "original") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + snapshot = state.get_interruptions()[0] + + assert raw_item.arguments == "original" + assert snapshot.raw_item.arguments == "original" + snapshot_extra = snapshot.raw_item.model_extra + assert snapshot_extra == {"metadata": {"safe": True}} + assert snapshot_extra is not None + assert all(type(extra_name) is str for extra_name in snapshot_extra) + + def test_get_interruptions_copies_typed_extra_container_subtypes(self) -> None: + """Hook-free built-in container subtypes remain detached and supported.""" + + class PlainDict(dict[str, Any]): + pass + + class PlainList(list[str]): + pass + + metadata = PlainDict(tags=PlainList(["original"])) + raw_item = ResponseFunctionToolCall.model_validate( + { + "type": "function_call", + "name": "toolA", + "call_id": "cid-typed-extra-containers", + "arguments": "{}", + "metadata": metadata, + } + ) + agent = Agent(name="TypedExtraContainerAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + snapshot = state.get_interruptions()[0] + + assert raw_item.model_extra is not None + assert snapshot.raw_item.model_extra is not None + source_metadata = raw_item.model_extra["metadata"] + copied_metadata = snapshot.raw_item.model_extra["metadata"] + assert isinstance(source_metadata, PlainDict) + assert isinstance(source_metadata["tags"], PlainList) + assert type(copied_metadata) is dict + assert type(copied_metadata["tags"]) is list + copied_metadata["tags"].append("changed") + assert source_metadata["tags"] == ["original"] + + @pytest.mark.parametrize("location", ["outer", "nested"]) + def test_get_interruptions_rejects_serializer_bearing_typed_extras( + self, + location: str, + ) -> None: + """Typed extras must fail before a user serializer can mutate pending state.""" + + class MutatingExtra(BaseModel): + serializer_called: ClassVar[bool] = False + value: str + + @model_serializer(mode="wrap") + def serialize_mutating_extra(self, handler: Any) -> Any: + type(self).serializer_called = True + self.value = "mutated-by-serializer" + return handler(self) + + extra = MutatingExtra(value="original") + if location == "outer": + raw_item: Any = ResponseFunctionToolCall.model_validate( + { + "type": "function_call", + "name": "toolA", + "call_id": "cid-serializer-extra", + "arguments": "{}", + "metadata": extra, + } + ) + else: + raw_item = LocalShellCall.model_validate( + { + "id": "local-shell-serializer-extra", + "action": LocalShellCallAction.model_validate( + { + "command": ["echo", "ok"], + "env": {}, + "type": "exec", + "metadata": extra, + } + ), + "call_id": "cid-serializer-extra", + "status": "completed", + "type": "local_shell_call", + } + ) + agent = Agent(name="SerializerExtraAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + assert extra.value == "original" + assert not MutatingExtra.serializer_called + + @pytest.mark.parametrize("location", ["outer", "nested"]) + def test_get_interruptions_rejects_typed_subtype_attribute_hooks( + self, + location: str, + ) -> None: + """Subtype attribute hooks must fail before authoritative model access.""" + + class MutatingCall(ResponseFunctionToolCall): + armed: bool = False + + def __getattribute__(self, name: str) -> Any: + if name in {"__dict__", "__pydantic_extra__"} and object.__getattribute__( + self, + "__dict__", + ).get("armed"): + object.__setattr__(self, "arguments", '{"mutated":true}') + return super().__getattribute__(name) + + class MutatingAction(LocalShellCallAction): + armed: bool = False + + def __getattribute__(self, name: str) -> Any: + if name in {"__dict__", "__pydantic_extra__"} and object.__getattribute__( + self, + "__dict__", + ).get("armed"): + object.__setattr__(self, "command", ["mutated"]) + return super().__getattribute__(name) + + if location == "outer": + raw_item: Any = MutatingCall( + type="function_call", + name="toolA", + call_id="cid-hook-bearing-subtype", + arguments="{}", + ) + raw_item.armed = True + else: + action = MutatingAction(command=["echo", "ok"], env={}, type="exec") + action.armed = True + raw_item = LocalShellCall( + id="local-shell-hook-bearing-subtype", + action=action, + call_id="cid-hook-bearing-subtype", + status="completed", + type="local_shell_call", + ) + agent = Agent(name="HookBearingSubtypeAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + if location == "outer": + assert raw_item.arguments == "{}" + else: + assert raw_item.action.command == ["echo", "ok"] + + def test_get_interruptions_rejects_hooks_in_post_declared_model_mixins(self) -> None: + """Subtype hooks must be rejected even when their mixin follows the declared base.""" + + class MutatingMixin: + def __getattribute__(self, name: str) -> Any: + if name in {"__dict__", "__pydantic_extra__"} and object.__getattribute__( + self, + "__dict__", + ).get("armed"): + object.__setattr__(self, "arguments", "mutated-by-post-declared-mixin") + return super().__getattribute__(name) + + class MutatingCall(ResponseFunctionToolCall, MutatingMixin): + armed: bool = False + + raw_item = MutatingCall( + type="function_call", + name="toolA", + call_id="cid-post-declared-mixin", + arguments="original", + ) + agent = Agent(name="PostDeclaredMixinAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + raw_item.armed = True + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + assert raw_item.arguments == "original" + + @pytest.mark.parametrize( + ("location", "storage_name"), + [ + ("outer", "__pydantic_extra__"), + ("nested", "__pydantic_fields_set__"), + ("outer", "__dict__"), + ], + ) + def test_get_interruptions_rejects_pydantic_storage_descriptors( + self, + location: str, + storage_name: str, + ) -> None: + """Subtype storage descriptors must fail before public Pydantic instance access.""" + hook_called = False + source_holder: dict[str, Any] = {} + + def mutate_on_access(_instance: BaseModel) -> Any: + nonlocal hook_called + hook_called = True + source_model = source_holder["model"] + object.__setattr__( + source_model, + source_holder["field"], + source_holder["mutated_value"], + ) + raise AssertionError("storage descriptor should not run") + + class CustomCall(ResponseFunctionToolCall): + pass + + class DictDescriptorCall(ResponseFunctionToolCall): + __dict__ = property(mutate_on_access) # type: ignore[assignment] + + class CustomAction(LocalShellCallAction): + pass + + if location == "outer": + call_type = ResponseFunctionToolCall if storage_name == "__dict__" else CustomCall + raw_item: Any = call_type( + type="function_call", + name="toolA", + call_id="cid-storage-descriptor", + arguments="original", + ) + if storage_name == "__dict__": + object.__setattr__(raw_item, "__class__", DictDescriptorCall) + source_model = raw_item + source_field = "arguments" + mutated_value: Any = "mutated-by-storage-descriptor" + else: + source_model = CustomAction(command=["echo", "ok"], env={}, type="exec") + raw_item = LocalShellCall( + id="local-shell-storage-descriptor", + action=source_model, + call_id="cid-storage-descriptor", + status="completed", + type="local_shell_call", + ) + source_field = "command" + mutated_value = ["mutated-by-storage-descriptor"] + + source_holder.update( + model=source_model, + field=source_field, + mutated_value=mutated_value, + ) + if storage_name != "__dict__": + setattr(type(source_model), storage_name, property(mutate_on_access)) + agent = Agent(name="StorageDescriptorAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + assert not hook_called + if location == "outer": + assert source_model.arguments == "original" + else: + assert source_model.command == ["echo", "ok"] + + @pytest.mark.parametrize("location", ["outer", "nested"]) + def test_get_interruptions_rejects_pydantic_storage_container_hooks( + self, + location: str, + ) -> None: + """Pydantic storage containers must be plain dicts before public iteration.""" + + class MutatingStorage(dict[str, Any]): + def __init__(self, *args: Any, field: str, mutated_value: Any) -> None: + super().__init__(*args) + self.field = field + self.mutated_value = mutated_value + + def items(self) -> Any: + self[self.field] = self.mutated_value + return super().items() + + if location == "outer": + raw_item: Any = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="cid-storage-container", + arguments="original", + ) + source_model = raw_item + source_field = "arguments" + mutated_value: Any = "mutated-by-storage-container" + else: + source_model = LocalShellCallAction(command=["echo", "ok"], env={}, type="exec") + raw_item = LocalShellCall( + id="local-shell-storage-container", + action=source_model, + call_id="cid-storage-container", + status="completed", + type="local_shell_call", + ) + source_field = "command" + mutated_value = ["mutated-by-storage-container"] + + storage = MutatingStorage( + object.__getattribute__(source_model, "__dict__"), + field=source_field, + mutated_value=mutated_value, + ) + object.__setattr__(source_model, "__dict__", storage) + agent = Agent(name="StorageContainerAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + if location == "outer": + assert source_model.arguments == "original" + else: + assert source_model.command == ["echo", "ok"] + + @pytest.mark.parametrize("location", ["outer", "nested"]) + def test_get_interruptions_does_not_dispatch_pydantic_storage_key_hooks( + self, + location: str, + ) -> None: + """Pydantic storage keys must be normalized without method dispatch.""" + hook_called = False + + class MutatingKey(str): + def __new__( + cls, + value: str, + owner: BaseModel, + field: str, + mutated_value: Any, + ) -> MutatingKey: + key = str.__new__(cls, value) + key.owner = owner + key.field = field + key.mutated_value = mutated_value + return key + + def startswith(self, *args: Any, **kwargs: Any) -> bool: + nonlocal hook_called + hook_called = True + object.__setattr__(self.owner, self.field, self.mutated_value) + return str.startswith(self, *args, **kwargs) + + if location == "outer": + raw_item: Any = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="cid-storage-key", + arguments="original", + ) + source_model = raw_item + source_field = "arguments" + original_value: Any = "original" + mutated_value: Any = "mutated-by-storage-key" + else: + source_model = LocalShellCallAction(command=["echo", "ok"], env={}, type="exec") + raw_item = LocalShellCall( + id="local-shell-storage-key", + action=source_model, + call_id="cid-storage-key", + status="completed", + type="local_shell_call", + ) + source_field = "command" + original_value = ["echo", "ok"] + mutated_value = ["mutated-by-storage-key"] + + storage = object.__getattribute__(source_model, "__dict__") + assert type(storage) is dict + dict.__setitem__( + storage, + MutatingKey( + "subtype_only", + source_model, + source_field, + mutated_value, + ), + "ignored", + ) + agent = Agent(name="StorageKeyAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + snapshot = state.get_interruptions()[0] + + assert not hook_called + assert getattr(source_model, source_field) == original_value + snapshot_raw_item = cast(Any, snapshot.raw_item) + snapshot_model = snapshot_raw_item if location == "outer" else snapshot_raw_item.action + assert getattr(snapshot_model, source_field) == original_value + + @pytest.mark.parametrize("location", ["mapping", "typed_extra"]) + def test_get_interruptions_does_not_dispatch_payload_class_properties( + self, + location: str, + ) -> None: + """Classifying arbitrary payload values must not access their __class__.""" + + class MutatingClassProbe: + def __init__(self, mutate: Callable[[], None]) -> None: + self.mutate = mutate + + @property + def __class__(self) -> type[object]: + self.mutate() + return object + + if location == "mapping": + raw_item: Any = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-class-property", + "arguments": "original", + } + probe = MutatingClassProbe( + lambda: raw_item.__setitem__("arguments", "mutated-by-class-property") + ) + raw_item["metadata"] = probe + else: + raw_item = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="cid-class-property", + arguments="original", + ) + probe = MutatingClassProbe( + lambda: object.__setattr__( + raw_item, + "arguments", + "mutated-by-class-property", + ) + ) + assert raw_item.model_extra is not None + raw_item.model_extra["metadata"] = probe + agent = Agent(name="ClassPropertyAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + if location == "mapping": + assert raw_item["arguments"] == "original" + else: + assert raw_item.arguments == "original" + + def test_get_interruptions_checks_later_adapter_subtypes_without_class_access(self) -> None: + """Adapter selection must reject hooks without reading instance __class__.""" + + class MutatingMcpCall(McpCall): + armed: bool = False + + def __getattribute__(self, name: str) -> Any: + if name == "__class__" and object.__getattribute__(self, "__dict__").get("armed"): + object.__setattr__(self, "arguments", "mutated-by-adapter-selection") + return super().__getattribute__(name) + + raw_item = MutatingMcpCall( + id="mcp-hook-bearing-subtype", + arguments="original", + name="toolA", + server_label="server", + type="mcp_call", + ) + agent = Agent(name="LaterAdapterSubtypeAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + raw_item.armed = True + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + assert raw_item.arguments == "original" + + def test_get_interruptions_uses_public_schema_for_nested_models(self) -> None: + """Base adapters must serialize nested models without subclass serializers.""" + + class CustomAction(LocalShellCallAction): + serializer_called: ClassVar[bool] = False + command: list[str] = ["echo", "ok"] + subtype_only: Any + + @model_serializer(mode="wrap") + def serialize_custom_action(self, handler: Any) -> Any: + type(self).serializer_called = True + return handler(self) + + action = CustomAction( + env={}, + type="exec", + subtype_only=object(), + ) + raw_item = LocalShellCall( + id="local-shell-public-schema", + action=action, + call_id="cid-local-shell-public-schema", + status="completed", + type="local_shell_call", + ) + agent = Agent(name="PublicSchemaAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + snapshot = state.get_interruptions()[0] + + assert type(snapshot.raw_item) is LocalShellCall + assert snapshot.raw_item.action.command == ["echo", "ok"] + assert type(snapshot.raw_item.action) is LocalShellCallAction + assert snapshot.raw_item.action is not action + assert "command" not in snapshot.raw_item.action.model_fields_set + assert "command" not in snapshot.raw_item.action.model_dump(exclude_unset=True) + assert not CustomAction.serializer_called + + def test_get_interruptions_uses_trusted_nested_model_annotations(self) -> None: + """Nested model discovery must not trust a caller-controlled module name.""" + source_holder: dict[str, Any] = {} + + class SpoofedAction(LocalShellCallAction): + construct_called: ClassVar[bool] = False + + @classmethod + def model_construct( + cls, + _fields_set: set[str] | None = None, + **values: object, + ) -> Any: + cls.construct_called = True + source_holder["action"].command = ["mutated"] + return super().model_construct(_fields_set=_fields_set, **values) + + SpoofedAction.__module__ = "openai.types.responses.spoofed" + action = SpoofedAction(command=["echo", "ok"], env={}, type="exec") + source_holder["action"] = action + raw_item = LocalShellCall( + id="local-shell-spoofed-module", + action=action, + call_id="cid-spoofed-module", + status="completed", + type="local_shell_call", + ) + agent = Agent(name="SpoofedNestedModelAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + snapshot = state.get_interruptions()[0] + + assert not SpoofedAction.construct_called + assert action.command == ["echo", "ok"] + assert type(snapshot.raw_item.action) is LocalShellCallAction + assert snapshot.raw_item.action.command == ["echo", "ok"] + + @pytest.mark.parametrize("location", ["mapping", "nested"]) + def test_get_interruptions_rejects_normalized_key_collisions(self, location: str) -> None: + """Distinct source keys must not collapse into one approval identity field.""" + + class DistinctString(str): + def __hash__(self) -> int: + return id(self) + + def __eq__(self, other: object) -> bool: + return self is other + + colliding_key = DistinctString("call_id") + agent = Agent(name="KeyCollisionAgent") + if location == "mapping": + raw_item: Any = { + "type": "function_call", + "name": "toolA", + "call_id": "original", + "arguments": "{}", + colliding_key: "replacement", + } + else: + metadata = {"call_id": "original", colliding_key: "replacement"} + raw_item = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-nested-collision", + "arguments": "{}", + "metadata": metadata, + } + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + if location == "nested": + assert metadata["call_id"] == "original" + assert metadata[colliding_key] == "replacement" + else: + assert raw_item["call_id"] == "original" + + def test_get_interruptions_bypasses_nested_container_hooks(self): + """Mapping snapshots must not invoke hooks on container subclasses.""" + + class MutatingDict(dict[str, Any]): + def items(self) -> Any: + self["serializer-side-effect"] = True + return super().items() + + class MutatingList(list[str]): + def __iter__(self) -> Any: + self.append("serializer-side-effect") + return super().__iter__() + + metadata = MutatingList(["original"]) + raw_item = MutatingDict( + type="function_call", + name="toolA", + call_id="cid-hooks", + arguments="{}", + metadata=metadata, + ) + agent = Agent(name="ContainerHooksAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + interruption = state.get_interruptions()[0] + + assert type(interruption.raw_item) is dict + assert interruption.raw_item["metadata"] == ["original"] + assert dict.__contains__(raw_item, "serializer-side-effect") is False + assert list.__len__(metadata) == 1 + + @pytest.mark.parametrize("non_finite", [float("nan"), float("inf"), float("-inf")]) + def test_get_interruptions_rejects_non_finite_mapping_values( + self, + non_finite: float, + ) -> None: + """Non-standard JSON numbers must fail before a snapshot is returned.""" + agent = Agent(name="NonFiniteAgent") + raw_item = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-non-finite", + "arguments": "{}", + "metadata": non_finite, + } + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + def test_get_interruptions_failure_does_not_expose_partial_snapshots(self): + """A later unsafe payload must fail without changing earlier pending items.""" + agent = Agent(name="PartialFailureAgent") + first_raw_item = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-safe", + "arguments": "original", + } + second_raw_item = { + "type": "function_call", + "name": "toolB", + "call_id": "cid-unsafe", + "arguments": "{}", + "metadata": object(), + } + state = make_state_with_interruptions( + agent, + [ + ToolApprovalItem(agent=agent, raw_item=first_raw_item), + ToolApprovalItem(agent=agent, raw_item=second_raw_item), + ], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + assert first_raw_item["arguments"] == "original" + + def test_get_interruptions_rejects_unsafe_typed_extra_metadata( + self, + ) -> None: + """Unsafe typed extras must fail through the same bounded copy path.""" + agent = Agent(name="TypedExtraFailureAgent") + raw_item = ResponseFunctionToolCall.model_validate( + { + "type": "function_call", + "name": "toolA", + "call_id": "cid-typed-extra", + "arguments": "{}", + "metadata": {"nested": object()}, + } + ) + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + def test_get_interruptions_rejects_cyclic_mapping_data(self) -> None: + """Cyclic mapping content must fail without changing authoritative state.""" + metadata: list[Any] = [] + metadata.append(metadata) + agent = Agent(name="CyclicMappingAgent") + raw_item = { + "type": "function_call", + "name": "toolA", + "call_id": "cid-cycle", + "arguments": "{}", + "metadata": metadata, + } + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() + + assert len(metadata) == 1 + assert metadata[0] is metadata + + @pytest.mark.parametrize( + "raw_item", + [ + ResponseFunctionToolCall.model_validate( + { + "type": "function_call", + "name": "toolA", + "call_id": "cid-function", + "status": "completed", + "arguments": "{}", + "metadata": {"tags": ["function"]}, + } + ), + ResponseCustomToolCall.model_validate( + { + "type": "custom_tool_call", + "name": "toolA", + "call_id": "cid-custom", + "input": "original", + "metadata": {"tags": ["custom"]}, + } + ), + ResponseFunctionShellToolCall.model_validate( + { + "id": "shell-call", + "action": { + "commands": ["echo", "ok"], + "metadata": {"tags": ["action"]}, + }, + "call_id": "cid-shell", + "status": "completed", + "type": "shell_call", + "metadata": {"tags": ["shell"]}, + } + ), + McpCall.model_validate( + { + "id": "mcp-call", + "arguments": "{}", + "name": "toolA", + "server_label": "server", + "type": "mcp_call", + "metadata": {"tags": ["mcp-call"]}, + } + ), + McpApprovalRequest.model_validate( + { + "id": "mcp-approval", + "arguments": "{}", + "name": "toolA", + "server_label": "server", + "type": "mcp_approval_request", + "metadata": {"tags": ["mcp-approval"]}, + } + ), + LocalShellCall.model_validate( + { + "id": "local-shell", + "action": LocalShellCallAction.model_validate( + { + "command": ["echo", "ok"], + "env": {}, + "type": "exec", + "metadata": {"tags": ["action"]}, + } + ), + "call_id": "cid-local-shell", + "status": "completed", + "type": "local_shell_call", + "metadata": {"tags": ["local-shell"]}, + } + ), + ], + ids=["function", "custom", "shell", "mcp-call", "mcp-approval", "local-shell"], + ) + def test_get_interruptions_copies_each_typed_raw_item(self, raw_item: Any) -> None: + """Each declared typed approval payload must be reconstructed safely.""" + agent = Agent(name="TypedRawItemAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) + + interruption = state.get_interruptions()[0] + + assert isinstance(interruption.raw_item, type(raw_item)) + assert interruption.raw_item.model_dump() == raw_item.model_dump() + assert interruption.raw_item is not raw_item + assert interruption.raw_item.model_fields_set == raw_item.model_fields_set + interruption_extra = interruption.raw_item.model_extra + raw_extra = raw_item.model_extra + assert interruption_extra == raw_extra + assert interruption_extra is not None + assert raw_extra is not None + assert interruption_extra is not raw_extra + + interruption_extra["metadata"]["tags"].append("changed") + assert raw_extra["metadata"]["tags"][-1] != "changed" + if isinstance(raw_item, LocalShellCall | ResponseFunctionShellToolCall): + interruption_action_extra = interruption.raw_item.action.model_extra + raw_action_extra = raw_item.action.model_extra + assert interruption_action_extra == raw_action_extra + assert interruption_action_extra is not None + assert raw_action_extra is not None + assert interruption_action_extra is not raw_action_extra + interruption_action_extra["metadata"]["tags"].append("changed") + assert raw_action_extra["metadata"]["tags"] == ["action"] + + @pytest.mark.parametrize( + ("raw_item", "tool_name", "call_id"), + [ + ( + ResponseCustomToolCall( + type="custom_tool_call", + name="custom_tool", + call_id="cid-custom-decision", + input="original", + ), + "custom_tool", + "cid-custom-decision", + ), + ( + ResponseFunctionShellToolCall.model_validate( + { + "id": "shell-decision", + "action": {"commands": ["echo", "ok"]}, + "call_id": "cid-shell-decision", + "status": "completed", + "type": "shell_call", + } + ), + "shell", + "cid-shell-decision", + ), + ], + ids=["custom", "shell"], + ) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_approval_pipeline_models_can_apply_detached_decisions( + self, + raw_item: Any, + tool_name: str, + call_id: str, + approve: bool, + ) -> None: + """Production approval models must detach and retain canonical routing identity.""" + agent = Agent(name="PipelineDecisionAgent") + approval_item = ToolApprovalItem( + agent=agent, + raw_item=raw_item, + tool_name=tool_name, + ) + state = make_state_with_interruptions(agent, [approval_item]) - def test_get_interruptions_returns_a_snapshot(self): - """Mutating returned interruptions must not change pending approvals.""" - agent = Agent(name="SnapshotAgent") + interruption = state.get_interruptions()[0] + if approve: + state.approve(interruption) + else: + state.reject(interruption) + + assert state._context is not None + assert state._context.is_tool_approved(tool_name, call_id) is approve + + def test_get_interruptions_detaches_a_nested_mutable_alias(self): + """Snapshot copying must not trust a nested object's deepcopy implementation.""" + + class SelfCopyingList(list[str]): + def __deepcopy__(self, _memo: dict[int, Any]) -> SelfCopyingList: + return self + + agent = Agent(name="AliasedAgent") + metadata = SelfCopyingList(["original"]) approval_item = ToolApprovalItem( agent=agent, - raw_item=ResponseFunctionToolCall( - type="function_call", - name="toolA", - call_id="cid-snapshot", - status="completed", - arguments="{}", - ), + raw_item={ + "type": "function_call", + "name": "toolA", + "call_id": "cid-aliased", + "status": "completed", + "arguments": "{}", + "metadata": metadata, + }, ) state = make_state_with_interruptions(agent, [approval_item]) - state.get_interruptions().clear() + interruption = state.get_interruptions()[0] + assert isinstance(interruption.raw_item, dict) + interruption.raw_item["metadata"].append("changed") - assert state.get_interruptions() == [approval_item] + assert metadata == ["original"] async def test_serializes_and_restores_approvals(self): """Test that approval state is preserved through serialization.""" @@ -4029,6 +5325,466 @@ async def test_ambiguous_current_and_nested_approval_identity_fails_closed( scope_id=target_state._agent_tool_state_scope_id, ) + @pytest.mark.parametrize("approval_input", ["snapshot", "exact"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_ambiguous_current_approval_identity_fails_closed( + self, + approval_input: str, + approve: bool, + ) -> None: + """A snapshot or exact approval shared by current owners must not be guessed.""" + agent = Agent(name="AmbiguousCurrentAgent") + raw_item = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="shared-current", + arguments="{}", + ) + first = ToolApprovalItem( + agent=agent, + raw_item=raw_item, + tool_name="toolA", + tool_lookup_key=("deferred_top_level", "toolA"), + _allow_bare_name_alias=True, + ) + second = replace( + first, + raw_item=raw_item.model_copy(deep=True), + _allow_bare_name_alias=False, + ) + state = make_state_with_interruptions(agent, [first, second]) + + selected = state.get_interruptions()[1] if approval_input == "snapshot" else second + with pytest.raises(UserError, match="multiple current pending approvals"): + if approve: + state.approve(selected) + else: + state.reject(selected) + + assert state._context is not None + assert state._context.is_tool_approved("toolA", "shared-current") is None + + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_unsafe_current_sibling_cannot_bypass_approval_ambiguity( + self, + approve: bool, + ) -> None: + """Unsafe same-Agent siblings must not be treated as distinct owners.""" + agent = Agent(name="UnsafeCurrentSiblingAgent") + first = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "toolA", + "call_id": "shared-unsafe-current", + "arguments": "{}", + "metadata": object(), + }, + ) + second = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "toolA", + "call_id": "shared-unsafe-current", + "arguments": "{}", + "metadata": object(), + }, + ) + state = make_state_with_interruptions(agent, [first, second]) + + with pytest.raises(UserError, match="multiple current pending approvals"): + if approve: + state.approve(second) + else: + state.reject(second) + + assert state._context is not None + assert state._context.is_tool_approved("toolA", "shared-unsafe-current") is None + + @pytest.mark.parametrize("location", ["current", "nested"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_uncopyable_noncanonical_approval_does_not_select_pending_owner( + self, + location: str, + approve: bool, + ) -> None: + """An uncopyable noncanonical input must not select a same-Agent pending owner.""" + from agents.agent_tool_state import drop_agent_tool_run_result, record_agent_tool_run_result + + agent = Agent(name="UncopyableDecisionAgent") + pending = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="cid-uncopyable-decision", + arguments="{}", + ), + ) + supplied = ToolApprovalItem(agent=agent, raw_item={"metadata": object()}) + + target_state = make_state_with_interruptions(agent, [pending]) + state = target_state + outer_call = make_tool_call( + call_id="outer-uncopyable-decision", + name="nested_agent_tool", + ) + if location == "nested": + state = make_state_with_interruptions(agent, []) + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=outer_call, function_tool=nested_tool)] + ) + record_agent_tool_run_result( + outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[pending], + to_state=lambda: target_state, + ), + ), + scope_id=state._agent_tool_state_scope_id, + ) + + try: + if approve: + state.approve(supplied) + else: + state.reject(supplied) + + assert target_state._context is not None + assert ( + target_state._context.is_tool_approved( + "toolA", + "cid-uncopyable-decision", + ) + is None + ) + finally: + if location == "nested": + drop_agent_tool_run_result( + outer_call, + scope_id=state._agent_tool_state_scope_id, + ) + + @pytest.mark.parametrize("location", ["current", "nested"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_safe_noncanonical_approval_does_not_select_uncopyable_pending_owner( + self, + location: str, + approve: bool, + ) -> None: + """A safe input must not be redirected to an unsafe same-Agent pending owner.""" + from agents.agent_tool_state import drop_agent_tool_run_result, record_agent_tool_run_result + + agent = Agent(name="UnsafePendingOwnerAgent") + pending = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "pending_tool", + "call_id": "pending-unsafe-owner", + "arguments": "{}", + "metadata": object(), + }, + ) + supplied = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="supplied_tool", + call_id="supplied-safe-owner", + arguments="{}", + ), + ) + + target_state = make_state_with_interruptions(agent, [pending]) + state = target_state + outer_call = make_tool_call(call_id="outer-unsafe-owner", name="nested_agent_tool") + if location == "nested": + state = make_state_with_interruptions(agent, []) + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=outer_call, function_tool=nested_tool)] + ) + record_agent_tool_run_result( + outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[pending], + to_state=lambda: target_state, + ), + ), + scope_id=state._agent_tool_state_scope_id, + ) + + try: + with pytest.raises(UserError, match="Cannot apply approval"): + if approve: + state.approve(supplied) + else: + state.reject(supplied) + + assert target_state._context is not None + assert ( + target_state._context.is_tool_approved( + "pending_tool", + "pending-unsafe-owner", + ) + is None + ) + assert ( + target_state._context.is_tool_approved( + "supplied_tool", + "supplied-safe-owner", + ) + is None + ) + finally: + if location == "nested": + drop_agent_tool_run_result( + outer_call, + scope_id=state._agent_tool_state_scope_id, + ) + + @pytest.mark.parametrize("approval_location", ["current", "nested"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_exact_uncopyable_approval_does_not_read_other_unsafe_owner( + self, + approval_location: str, + approve: bool, + ) -> None: + """An unsafe authoritative item must not expose another owner's raw payload.""" + from agents.agent_tool_state import drop_agent_tool_run_result, record_agent_tool_run_result + + hook_calls: list[tuple[str, object]] = [] + + class HookedDict(dict[str, Any]): + def get(self, key: str, default: Any = None) -> Any: + hook_calls.append(("get", key)) + return super().get(key, default) + + def __contains__(self, key: object) -> bool: + hook_calls.append(("contains", key)) + return super().__contains__(key) + + def __getitem__(self, key: str) -> Any: + hook_calls.append(("getitem", key)) + return super().__getitem__(key) + + agent = Agent(name="UnsafeAuthoritativeOwnerAgent") + current = ToolApprovalItem( + agent=agent, + raw_item=HookedDict( + type="function_call", + name="current_tool", + call_id="current-unsafe-authoritative", + arguments="{}", + metadata=object(), + ), + ) + nested = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "nested_tool", + "call_id": "nested-unsafe-authoritative", + "arguments": "{}", + "metadata": object(), + }, + ) + outer_state = make_state_with_interruptions(agent, [current]) + nested_state = make_state_with_interruptions(agent, [nested]) + outer_call = make_tool_call( + call_id="outer-unsafe-authoritative", + name="nested_agent_tool", + ) + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + outer_state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=outer_call, function_tool=nested_tool)] + ) + record_agent_tool_run_result( + outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[nested], + to_state=lambda: nested_state, + ), + ), + scope_id=outer_state._agent_tool_state_scope_id, + ) + hook_calls.clear() + approval_item = current if approval_location == "current" else nested + + try: + with pytest.raises(UserError, match="Cannot apply approval"): + if approve: + outer_state.approve(approval_item) + else: + outer_state.reject(approval_item) + + assert hook_calls == [] + assert outer_state._context is not None + assert nested_state._context is not None + assert ( + outer_state._context.is_tool_approved( + "current_tool", + "current-unsafe-authoritative", + ) + is None + ) + assert ( + nested_state._context.is_tool_approved( + "nested_tool", + "nested-unsafe-authoritative", + ) + is None + ) + finally: + drop_agent_tool_run_result( + outer_call, + scope_id=outer_state._agent_tool_state_scope_id, + ) + + @pytest.mark.parametrize("unsafe_metadata", [False, True], ids=["safe", "unsafe"]) + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_ambiguous_exact_nested_approval_identity_fails_closed( + self, + unsafe_metadata: bool, + approve: bool, + ) -> None: + """An exact nested approval must not bypass nested owner multiplicity.""" + from agents.agent_tool_state import drop_agent_tool_run_result, record_agent_tool_run_result + + agent = Agent(name="AmbiguousNestedAgent") + if unsafe_metadata: + raw_item: Any = { + "type": "function_call", + "name": "toolA", + "call_id": "shared-nested", + "arguments": "{}", + "metadata": object(), + } + second_raw_item: Any = {**raw_item, "metadata": object()} + else: + raw_item = ResponseFunctionToolCall( + type="function_call", + name="toolA", + call_id="shared-nested", + arguments="{}", + ) + second_raw_item = raw_item.model_copy(deep=True) + first = ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name="toolA") + second = replace(first, raw_item=second_raw_item) + nested_state = make_state_with_interruptions(agent, [first, second]) + outer_state = make_state_with_interruptions(agent, [first]) + outer_call = make_tool_call(call_id="outer-ambiguous-nested", name="nested_agent_tool") + nested_tool = function_tool(lambda: "nested", name_override="nested_agent_tool") + outer_state._last_processed_response = make_processed_response( + functions=[ToolRunFunction(tool_call=outer_call, function_tool=nested_tool)] + ) + record_agent_tool_run_result( + outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[first], + to_state=lambda: nested_state, + ), + ), + scope_id=outer_state._agent_tool_state_scope_id, + ) + + try: + with pytest.raises(UserError, match="multiple current pending approvals"): + if approve: + outer_state.approve(first) + else: + outer_state.reject(first) + assert nested_state._context is not None + assert nested_state._context.is_tool_approved("toolA", "shared-nested") is None + finally: + drop_agent_tool_run_result( + outer_call, + scope_id=outer_state._agent_tool_state_scope_id, + ) + + @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) + def test_unsafe_exact_approval_across_nested_states_fails_closed( + self, + approve: bool, + ) -> None: + """Exact unsafe input must preserve ambiguity across all nested owner states.""" + from agents.agent_tool_state import drop_agent_tool_run_result, record_agent_tool_run_result + + agent = Agent(name="UnsafeNestedOwnerAgent") + first = ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "toolA", + "call_id": "shared-unsafe-nested", + "arguments": "{}", + "metadata": object(), + }, + ) + second = replace(first, raw_item={**first.raw_item, "metadata": object()}) + first_state = make_state_with_interruptions(agent, [first]) + second_state = make_state_with_interruptions(agent, [second]) + outer_state = make_state_with_interruptions(agent, []) + first_outer_call = make_tool_call(call_id="outer-unsafe-first", name="nested_first") + second_outer_call = make_tool_call(call_id="outer-unsafe-second", name="nested_second") + first_tool = function_tool(lambda: "first", name_override="nested_first") + second_tool = function_tool(lambda: "second", name_override="nested_second") + outer_state._last_processed_response = make_processed_response( + functions=[ + ToolRunFunction(tool_call=first_outer_call, function_tool=first_tool), + ToolRunFunction(tool_call=second_outer_call, function_tool=second_tool), + ] + ) + for outer_call, item, nested_state in ( + (first_outer_call, first, first_state), + (second_outer_call, second, second_state), + ): + record_agent_tool_run_result( + outer_call, + cast( + Any, + SimpleNamespace( + interruptions=[item], + to_state=lambda nested_state=nested_state: nested_state, + ), + ), + scope_id=outer_state._agent_tool_state_scope_id, + ) + + try: + with pytest.raises(UserError, match="cannot be safely distinguished"): + if approve: + outer_state.approve(first) + else: + outer_state.reject(first) + + for nested_state in (first_state, second_state): + assert nested_state._context is not None + assert ( + nested_state._context.is_tool_approved( + "toolA", + "shared-unsafe-nested", + ) + is None + ) + finally: + for outer_call in (first_outer_call, second_outer_call): + drop_agent_tool_run_result( + outer_call, + scope_id=outer_state._agent_tool_state_scope_id, + ) + @pytest.mark.parametrize("round_trip", [False, True], ids=["live", "serialized"]) @pytest.mark.parametrize("approve", [True, False], ids=["approve", "reject"]) async def test_completed_current_invocation_does_not_own_nested_approval( diff --git a/tests/test_tool_name_collision_policy.py b/tests/test_tool_name_collision_policy.py index 2d01423034..d67e7b1dba 100644 --- a/tests/test_tool_name_collision_policy.py +++ b/tests/test_tool_name_collision_policy.py @@ -23,8 +23,9 @@ handoff, tool_namespace, ) -from agents.items import ToolCallOutputItem +from agents.items import ToolApprovalItem, ToolCallOutputItem from agents.lifecycle import RunHooks +from agents.run_internal.run_steps import NextStepInterruption from agents.testing import ScriptedModel from agents.tool import Tool, function_tool @@ -37,6 +38,15 @@ def _record(calls: list[str], value: str, result: str | None = None) -> str: return value if result is None else result +def _authoritative_interruption( + state: RunState[Any, Agent[Any]], + call_id: str, +) -> ToolApprovalItem: + """Return the RunState-owned approval used by corruption-path tests.""" + assert isinstance(state._current_step, NextStepInterruption) + return next(item for item in state._current_step.interruptions if item.call_id == call_id) + + @pytest.mark.asyncio async def test_resume_warn_mode_rebinds_queued_mcp_call_to_local_winner() -> None: calls: list[str] = [] @@ -776,7 +786,7 @@ async def test_resume_rejects_conflicting_persisted_identity_before_sibling_effe interruptions = state.get_interruptions() for interruption in interruptions: state.approve(interruption) - conflicting = next(item for item in interruptions if item.call_id == "conflicting_call") + conflicting = _authoritative_interruption(state, "conflicting_call") conflicting.raw_item = { "type": "function_call", "name": "lookup", @@ -812,6 +822,7 @@ async def test_resume_rejects_legacy_approval_name_change_before_side_effects() state = initial_result.to_state() interruption = state.get_interruptions()[0] state.approve(interruption) + interruption = _authoritative_interruption(state, "lookup_call") interruption.tool_lookup_key = None interruption.raw_item = { "type": "function_call", @@ -919,7 +930,7 @@ async def test_approved_malformed_approval_only_stays_pending_without_side_effec assert state._last_processed_response is not None state._last_processed_response.functions = [] state._model_responses[-1] = replace(state._model_responses[-1], output=[]) - interruption.raw_item = malformed_raw_item + _authoritative_interruption(state, "lookup_call").raw_item = malformed_raw_item resumed_result = await Runner.run(agent, state) @@ -966,7 +977,7 @@ async def get_all_tools( state = initial_result.to_state() approval = state.get_interruptions()[0] state.approve(approval) - approval_holder["approval"] = approval + approval_holder["approval"] = _authoritative_interruption(state, "lookup_call") resumed_result = await Runner.run(agent, state) @@ -1005,7 +1016,8 @@ def lookup(amount: int) -> str: state = await RunState.from_json(agent, initial_result.to_state().to_json()) approval = state.get_interruptions()[0] state.approve(approval) - cast(Any, approval.raw_item).arguments = '{"amount":999}' + authoritative = _authoritative_interruption(state, "lookup_call") + cast(Any, authoritative.raw_item).arguments = '{"amount":999}' resumed_result = await Runner.run(agent, state) @@ -1065,7 +1077,7 @@ async def get_all_tools( state._model_responses[-1], output=[program], ) - approval_holder["approval"] = approval + approval_holder["approval"] = _authoritative_interruption(state, "lookup_call") resumed_result = await Runner.run(agent, state) @@ -1116,7 +1128,8 @@ async def get_all_tools( state = initial_result.to_state() approval = state.get_interruptions()[0] state.approve(approval) - cast(Any, approval.raw_item).caller.caller_id = "forged_program" + authoritative = _authoritative_interruption(state, "lookup_call") + cast(Any, authoritative.raw_item).caller.caller_id = "forged_program" assert state._last_processed_response is not None state._last_processed_response.functions = [] state._model_responses[-1] = replace( @@ -1165,7 +1178,7 @@ async def test_resume_rejects_response_backed_approval_lookup_mismatch_before_ef for run in state._last_processed_response.functions if run.tool_call.call_id != "lookup_call" ] - lookup_approval = next(item for item in interruptions if item.call_id == "lookup_call") + lookup_approval = _authoritative_interruption(state, "lookup_call") cast(Any, lookup_approval.raw_item).name = "other" lookup_approval.tool_name = "other" lookup_approval.tool_lookup_key = ("bare", "other") @@ -1380,7 +1393,7 @@ async def test_approved_malformed_queued_approval_stays_pending_without_side_eff interruptions = state.get_interruptions() for interruption in interruptions: state.approve(interruption) - lookup_approval = next(item for item in interruptions if item.call_id == "lookup_call") + lookup_approval = _authoritative_interruption(state, "lookup_call") lookup_approval.raw_item = malformed_raw_item resumed_result = await Runner.run(agent, state) @@ -1417,7 +1430,7 @@ async def test_resume_rejects_cross_kind_approval_identity_before_sibling_effect interruptions = state.get_interruptions() for interruption in interruptions: state.approve(interruption) - lookup_approval = next(item for item in interruptions if item.call_id == "lookup_call") + lookup_approval = _authoritative_interruption(state, "lookup_call") lookup_approval.raw_item = { "type": "custom_tool_call", "name": "evil", From 3e87dc8ab154039e59764762155e1f7230950c5f Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Fri, 14 Aug 2026 12:31:42 +0300 Subject: [PATCH 315/473] test: order test spans by start sequence, not by started_at alone (#4392) --- tests/testing_processor.py | 21 +++++- tests/tracing/test_span_ordering.py | 102 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/tracing/test_span_ordering.py diff --git a/tests/testing_processor.py b/tests/testing_processor.py index 314f759ff8..525612a205 100644 --- a/tests/testing_processor.py +++ b/tests/testing_processor.py @@ -21,6 +21,17 @@ def __init__(self) -> None: self._spans: list[Span[Any]] = [] self._traces: list[Trace] = [] self._events: list[TestSpanProcessorEvent] = [] + # Order in which spans were started. `started_at` alone is not enough to + # order them: it comes from `datetime.now()`, whose resolution on Windows + # before Python 3.13 is ~15ms, so a parent and its child routinely carry + # the identical timestamp. Sorting on the timestamp alone then falls back + # to insertion order, which is span *end* order, and a child ends before + # its parent -- the exact reverse of what consumers need. + # + # Keyed by (trace_id, span_id): a span ID is only unique within its + # trace, and callers may pass an explicit one, so keying on the span ID + # alone would let a later trace inherit an earlier trace's position. + self._start_order: dict[tuple[str, str], int] = {} def on_trace_start(self, trace: Trace) -> None: with self._lock: @@ -36,6 +47,7 @@ def on_span_start(self, span: Span[Any]) -> None: with self._lock: # Purposely not appending the span here, we want to do that in on_span_end self._events.append("span_start") + self._start_order.setdefault((span.trace_id, span.span_id), len(self._start_order)) def on_span_end(self, span: Span[Any]) -> None: with self._lock: @@ -45,7 +57,13 @@ def on_span_end(self, span: Span[Any]) -> None: def get_ordered_spans(self, including_empty: bool = False) -> list[Span[Any]]: with self._lock: spans = [x for x in self._spans if including_empty or x.export()] - return sorted(spans, key=lambda x: x.started_at or 0) + return sorted( + spans, + key=lambda x: ( + x.started_at or "", + self._start_order.get((x.trace_id, x.span_id), 0), + ), + ) def get_traces(self, including_empty: bool = False) -> list[Trace]: with self._lock: @@ -57,6 +75,7 @@ def clear(self) -> None: self._spans.clear() self._traces.clear() self._events.clear() + self._start_order.clear() def shutdown(self) -> None: pass diff --git a/tests/tracing/test_span_ordering.py b/tests/tracing/test_span_ordering.py new file mode 100644 index 0000000000..06ccefd2ec --- /dev/null +++ b/tests/tracing/test_span_ordering.py @@ -0,0 +1,102 @@ +from typing import Any + +import pytest + +from agents import trace +from agents.tracing import agent_span, custom_span +from agents.tracing.spans import Span +from tests.testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans + +# `span_id` is caller-settable, so the same value can appear in two traces. +SHARED_SPAN_ID = "span_00000000000000000000000000" + + +@pytest.fixture +def frozen_clock(monkeypatch: pytest.MonkeyPatch) -> None: + """Make every span report the same ``started_at``. + + ``started_at`` comes from ``datetime.now()``, whose resolution on Windows + before Python 3.13 is coarse enough (~15ms) that a parent and its child + routinely land on the identical timestamp in a real run. Freezing it here + reproduces that deterministically on every platform instead of only where + the clock happens to be coarse. + """ + monkeypatch.setattr( + "agents.tracing.spans.util.time_iso", + lambda: "2026-01-01T00:00:00.000000+00:00", + ) + + +def _assert_every_parent_comes_first(ordered: list[Span[Any]]) -> None: + """Every span must appear after its parent. + + Span identity is ``(trace_id, span_id)``: a span ID is only unique within + its trace, and callers may pass an explicit one. + """ + seen: set[tuple[str, str]] = set() + for span in ordered: + assert span.parent_id is None or (span.trace_id, span.parent_id) in seen, ( + "a span was ordered before its parent" + ) + seen.add((span.trace_id, span.span_id)) + + +def test_ordered_spans_put_a_parent_before_its_child_on_a_tied_timestamp( + frozen_clock: None, +) -> None: + with trace(workflow_name="w"): + with agent_span(name="parent"): + with custom_span(name="child"): + pass + + ordered = SPAN_PROCESSOR_TESTING.get_ordered_spans() + started_at = {span.started_at for span in ordered} + assert len(started_at) == 1, "the fixture should have tied every timestamp" + + # Spans end innermost-first, so ordering on the tied timestamp alone would + # fall back to end order and put the child first. + _assert_every_parent_comes_first(ordered) + + +def test_normalized_spans_nest_on_a_tied_timestamp(frozen_clock: None) -> None: + with trace(workflow_name="w"): + with agent_span(name="parent"): + with custom_span(name="child"): + pass + + # This raised KeyError when the child was ordered ahead of its parent. + spans: list[dict[str, Any]] = fetch_normalized_spans() + + assert len(spans) == 1 + agent = spans[0]["children"][0] + assert agent["type"] == "agent" + assert agent["children"][0]["type"] == "custom" + + +def test_two_traces_reusing_one_span_id_keep_their_own_order(frozen_clock: None) -> None: + """A span ID is unique within a trace, not across traces. + + The first trace uses the shared ID for a span that starts early; the second + reuses it for a span that starts late. Recording start order per span ID + alone would give the second trace's child the first trace's position and + push it ahead of its own parent. + """ + with trace(workflow_name="first"): + with agent_span(name="first-parent", span_id=SHARED_SPAN_ID): + with custom_span(name="first-child"): + pass + + with trace(workflow_name="second"): + with agent_span(name="second-parent"): + with custom_span(name="second-child", span_id=SHARED_SPAN_ID): + pass + + _assert_every_parent_comes_first(SPAN_PROCESSOR_TESTING.get_ordered_spans()) + + spans: list[dict[str, Any]] = fetch_normalized_spans() + + assert [span["workflow_name"] for span in spans] == ["first", "second"] + for span in spans: + agent = span["children"][0] + assert agent["type"] == "agent" + assert agent["children"][0]["type"] == "custom" From 0b93ce8faa27d4631df399fe48856b52a8fd9897 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 20:50:23 +0900 Subject: [PATCH 316/473] fix: isolate RunState checkpoint tool decisions (#4413) --- src/agents/agent_tool_state.py | 89 ++++++++++++++++------------------ src/agents/result.py | 67 +++++++++++++++++++++++-- src/agents/run_context.py | 13 +++++ src/agents/run_state.py | 39 +++++++++++++++ tests/test_agent_tool_state.py | 14 ++++++ tests/test_run_state.py | 57 ++++++++++++++++++++++ 6 files changed, 227 insertions(+), 52 deletions(-) diff --git a/src/agents/agent_tool_state.py b/src/agents/agent_tool_state.py index 07cc3dfb8d..dcb398a90a 100644 --- a/src/agents/agent_tool_state.py +++ b/src/agents/agent_tool_state.py @@ -13,6 +13,7 @@ ToolCallSignature = tuple[str, str, str, str, str | None, str | None] ScopedToolCallSignature = tuple[str | None, ToolCallSignature] +ScopedToolCallObject = tuple[str | None, int] @dataclass @@ -34,14 +35,14 @@ def to_state(self) -> Any: # Ephemeral maps linking tool call objects to nested agent results within the same run. # Store by object identity, and index by a stable signature to avoid call ID collisions. _agent_tool_run_results_by_obj: dict[ - int, RunResult | RunResultStreaming | _AgentToolResumeCheckpoint + ScopedToolCallObject, RunResult | RunResultStreaming | _AgentToolResumeCheckpoint ] = {} _agent_tool_run_results_by_signature: dict[ ScopedToolCallSignature, - set[int], + set[ScopedToolCallObject], ] = {} _agent_tool_run_result_signature_by_obj: dict[ - int, + ScopedToolCallObject, ScopedToolCallSignature, ] = {} _agent_tool_call_refs_by_obj: dict[int, weakref.ReferenceType[ResponseFunctionToolCall]] = {} @@ -92,25 +93,26 @@ def _scoped_tool_call_signature( def _index_agent_tool_run_result( tool_call: ResponseFunctionToolCall, - tool_call_obj_id: int, + scoped_object: ScopedToolCallObject, *, scope_id: str | None, ) -> None: """Track tool call objects by signature for fallback lookup.""" signature = _scoped_tool_call_signature(tool_call, scope_id=scope_id) - _agent_tool_run_result_signature_by_obj[tool_call_obj_id] = signature - _agent_tool_run_results_by_signature.setdefault(signature, set()).add(tool_call_obj_id) + _agent_tool_run_result_signature_by_obj[scoped_object] = signature + _agent_tool_run_results_by_signature.setdefault(signature, set()).add(scoped_object) -def _drop_agent_tool_run_result(tool_call_obj_id: int) -> None: +def _drop_agent_tool_run_result(scoped_object: ScopedToolCallObject | int) -> None: """Remove a tool call object from the fallback index.""" - tool_call_refs = _agent_tool_call_refs_by_obj - if isinstance(tool_call_refs, dict): - tool_call_refs.pop(tool_call_obj_id, None) signature_by_obj = _agent_tool_run_result_signature_by_obj if not isinstance(signature_by_obj, dict): return - signature = signature_by_obj.pop(tool_call_obj_id, None) + if isinstance(scoped_object, int): + for candidate in [key for key in signature_by_obj if key[1] == scoped_object]: + _drop_agent_tool_run_result(candidate) + return + signature = signature_by_obj.pop(scoped_object, None) if signature is None: return results_by_signature = _agent_tool_run_results_by_signature @@ -119,9 +121,11 @@ def _drop_agent_tool_run_result(tool_call_obj_id: int) -> None: candidate_ids = results_by_signature.get(signature) if not candidate_ids: return - candidate_ids.discard(tool_call_obj_id) + candidate_ids.discard(scoped_object) if not candidate_ids: results_by_signature.pop(signature, None) + if not any(key[1] == scoped_object[1] for key in _agent_tool_run_results_by_obj): + _agent_tool_call_refs_by_obj.pop(scoped_object[1], None) def _register_tool_call_ref(tool_call: ResponseFunctionToolCall, tool_call_obj_id: int) -> None: @@ -130,8 +134,10 @@ def _register_tool_call_ref(tool_call: ResponseFunctionToolCall, tool_call_obj_i def _on_tool_call_gc(_ref: weakref.ReferenceType[ResponseFunctionToolCall]) -> None: run_results = _agent_tool_run_results_by_obj if isinstance(run_results, dict): - run_results.pop(tool_call_obj_id, None) - _drop_agent_tool_run_result(tool_call_obj_id) + scoped_objects = [key for key in run_results if key[1] == tool_call_obj_id] + for scoped_object in scoped_objects: + run_results.pop(scoped_object, None) + _drop_agent_tool_run_result(scoped_object) _agent_tool_call_refs_by_obj[tool_call_obj_id] = weakref.ref(tool_call, _on_tool_call_gc) @@ -144,8 +150,9 @@ def record_agent_tool_run_result( ) -> None: """Store the nested agent run result by tool call identity.""" tool_call_obj_id = id(tool_call) - _agent_tool_run_results_by_obj[tool_call_obj_id] = run_result - _index_agent_tool_run_result(tool_call, tool_call_obj_id, scope_id=scope_id) + scoped_object = (scope_id, tool_call_obj_id) + _agent_tool_run_results_by_obj[scoped_object] = run_result + _index_agent_tool_run_result(tool_call, scoped_object, scope_id=scope_id) _register_tool_call_ref(tool_call, tool_call_obj_id) @@ -198,26 +205,17 @@ def agent_tool_resume_checkpoint_owns_approval(run_result: Any, approval_item: A return identity is not None and identity in run_result.approval_identities -def _tool_call_obj_matches_scope(tool_call_obj_id: int, *, scope_id: str | None) -> bool: - scoped_signature = _agent_tool_run_result_signature_by_obj.get(tool_call_obj_id) - if scoped_signature is None: - # Fallback for unindexed entries. - return scope_id is None - return scoped_signature[0] == scope_id - - def consume_agent_tool_run_result( tool_call: ResponseFunctionToolCall, *, scope_id: str | None = None, ) -> RunResult | RunResultStreaming | _AgentToolResumeCheckpoint | None: """Return and drop the stored nested agent run result for the given tool call.""" - obj_id = id(tool_call) - if _tool_call_obj_matches_scope(obj_id, scope_id=scope_id): - run_result = _agent_tool_run_results_by_obj.pop(obj_id, None) - if run_result is not None: - _drop_agent_tool_run_result(obj_id) - return run_result + scoped_object = (scope_id, id(tool_call)) + run_result = _agent_tool_run_results_by_obj.pop(scoped_object, None) + if run_result is not None: + _drop_agent_tool_run_result(scoped_object) + return run_result signature = _scoped_tool_call_signature(tool_call, scope_id=scope_id) candidate_ids = _agent_tool_run_results_by_signature.get(signature) @@ -227,10 +225,9 @@ def consume_agent_tool_run_result( return None candidate_id = next(iter(candidate_ids)) - _agent_tool_run_results_by_signature.pop(signature, None) - _agent_tool_run_result_signature_by_obj.pop(candidate_id, None) - _agent_tool_call_refs_by_obj.pop(candidate_id, None) - return _agent_tool_run_results_by_obj.pop(candidate_id, None) + run_result = _agent_tool_run_results_by_obj.pop(candidate_id, None) + _drop_agent_tool_run_result(candidate_id) + return run_result def peek_agent_tool_run_result( @@ -239,11 +236,10 @@ def peek_agent_tool_run_result( scope_id: str | None = None, ) -> RunResult | RunResultStreaming | _AgentToolResumeCheckpoint | None: """Return the stored nested agent run result without removing it.""" - obj_id = id(tool_call) - if _tool_call_obj_matches_scope(obj_id, scope_id=scope_id): - run_result = _agent_tool_run_results_by_obj.get(obj_id) - if run_result is not None: - return run_result + scoped_object = (scope_id, id(tool_call)) + run_result = _agent_tool_run_results_by_obj.get(scoped_object) + if run_result is not None: + return run_result signature = _scoped_tool_call_signature(tool_call, scope_id=scope_id) candidate_ids = _agent_tool_run_results_by_signature.get(signature) @@ -262,12 +258,11 @@ def drop_agent_tool_run_result( scope_id: str | None = None, ) -> None: """Drop the stored nested agent run result, if present.""" - obj_id = id(tool_call) - if _tool_call_obj_matches_scope(obj_id, scope_id=scope_id): - run_result = _agent_tool_run_results_by_obj.pop(obj_id, None) - if run_result is not None: - _drop_agent_tool_run_result(obj_id) - return + scoped_object = (scope_id, id(tool_call)) + run_result = _agent_tool_run_results_by_obj.pop(scoped_object, None) + if run_result is not None: + _drop_agent_tool_run_result(scoped_object) + return signature = _scoped_tool_call_signature(tool_call, scope_id=scope_id) candidate_ids = _agent_tool_run_results_by_signature.get(signature) @@ -277,7 +272,5 @@ def drop_agent_tool_run_result( return candidate_id = next(iter(candidate_ids)) - _agent_tool_run_results_by_signature.pop(signature, None) - _agent_tool_run_result_signature_by_obj.pop(candidate_id, None) - _agent_tool_call_refs_by_obj.pop(candidate_id, None) _agent_tool_run_results_by_obj.pop(candidate_id, None) + _drop_agent_tool_run_result(candidate_id) diff --git a/src/agents/result.py b/src/agents/result.py index 22219303f9..0979631200 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -172,6 +172,61 @@ def _populate_state_from_result( return state +def _copy_pending_nested_agent_tool_states(state: RunState[Any], result: RunResultBase) -> None: + """Bind detached nested approval checkpoints to the new outer checkpoint scope.""" + if state._last_processed_response is None: + return + + from .agent_tool_state import ( + drop_agent_tool_run_result, + get_agent_tool_resume_state, + get_agent_tool_state_scope, + peek_agent_tool_run_result, + record_agent_tool_resume_state, + ) + + source_scope = get_agent_tool_state_scope(result.context_wrapper) + templates = getattr(result, "_checkpoint_nested_state_templates", None) + if not isinstance(templates, dict): + templates = {} + result.__dict__["_checkpoint_nested_state_templates"] = templates + for function_run in state._last_processed_response.functions: + template = templates.get(id(function_run.tool_call)) + if isinstance(template, RunState): + nested_state = template._copy_for_result_checkpoint() + resolved_interruptions = template.get_interruptions() + else: + pending_result = peek_agent_tool_run_result( + function_run.tool_call, + scope_id=source_scope, + ) + pending_interruptions = getattr(pending_result, "interruptions", None) + to_state = getattr(pending_result, "to_state", None) + if ( + not isinstance(pending_interruptions, list) + or not pending_interruptions + or not callable(to_state) + ): + continue + pending_state = get_agent_tool_resume_state(pending_result) + copy_for_checkpoint = getattr(pending_state, "_copy_for_result_checkpoint", None) + template = copy_for_checkpoint() if callable(copy_for_checkpoint) else to_state() + if not isinstance(template, RunState): + continue + templates[id(function_run.tool_call)] = template + drop_agent_tool_run_result(function_run.tool_call, scope_id=source_scope) + nested_state = template._copy_for_result_checkpoint() + resolved_interruptions = pending_interruptions + if not isinstance(nested_state, RunState) or nested_state is state: + continue + record_agent_tool_resume_state( + function_run.tool_call, + nested_state, + scope_id=state._agent_tool_state_scope_id, + approval_items=resolved_interruptions, + ) + + ToInputListMode = Literal["preserve_all", "normalized"] @@ -510,7 +565,7 @@ def to_state(self) -> RunState[Any]: # Create a RunState from the current result original_input_for_state = getattr(self, "_original_input", None) state = RunState( - context=self.context_wrapper, + context=self.context_wrapper._copy_for_run_state(), original_input=original_input_for_state if original_input_for_state is not None else self.input, @@ -518,7 +573,7 @@ def to_state(self) -> RunState[Any]: max_turns=self.max_turns, ) - return _populate_state_from_result( + state = _populate_state_from_result( state, self, current_turn=self._current_turn, @@ -529,6 +584,8 @@ def to_state(self) -> RunState[Any]: previous_response_id=self._previous_response_id, auto_previous_response_id=self._auto_previous_response_id, ) + _copy_pending_nested_agent_tool_states(state, self) + return state def __str__(self) -> str: return pretty_print_result(self) @@ -1111,13 +1168,13 @@ def to_state(self) -> RunState[Any]: # Use _original_input (updated on handoffs/resume when input history changes). # This avoids serializing a mutated view of input history. state = RunState( - context=self.context_wrapper, + context=self.context_wrapper._copy_for_run_state(), original_input=self._original_input if self._original_input is not None else self.input, starting_agent=_starting_agent_for_state(self), max_turns=self.max_turns, ) - return _populate_state_from_result( + state = _populate_state_from_result( state, self, current_turn=self.current_turn, @@ -1128,3 +1185,5 @@ def to_state(self) -> RunState[Any]: previous_response_id=self._previous_response_id, auto_previous_response_id=self._auto_previous_response_id, ) + _copy_pending_nested_agent_tool_states(state, self) + return state diff --git a/src/agents/run_context.py b/src/agents/run_context.py index e6fcd4141e..19e0161022 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -1,8 +1,10 @@ from __future__ import annotations +import copy from collections.abc import Callable, Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic +from uuid import uuid4 from typing_extensions import TypeVar @@ -112,6 +114,17 @@ def _share_tool_state_with(self, target: RunContextWrapper[Any]) -> None: ) target._restored_unbound_approval_call_ids = self._restored_unbound_approval_call_ids + def _copy_for_run_state(self) -> RunContextWrapper[TContext]: + """Copy SDK-owned tool state for an independently resumable checkpoint.""" + copied = copy.copy(self) + copied._approvals = copy.deepcopy(self._approvals) + copied._tool_invocations = copy.deepcopy(self._tool_invocations) + copied._restored_unbound_approval_call_ids = set(self._restored_unbound_approval_call_ids) + from .agent_tool_state import set_agent_tool_state_scope + + set_agent_tool_state_scope(copied, uuid4().hex) + return copied + @staticmethod def _to_str_or_none(value: Any) -> str | None: if isinstance(value, str): diff --git a/src/agents/run_state.py b/src/agents/run_state.py index c8881b3423..43c994103b 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -889,6 +889,45 @@ def __init__( self._agent_tool_state_scope_id = get_agent_tool_state_scope(context) + def _copy_for_result_checkpoint(self) -> RunState[TContext, TAgent]: + """Copy SDK-owned decision state when nesting this checkpoint in a result snapshot.""" + copied = copy.copy(self) + if self._context is None: + return copied + copied._context = self._context._copy_for_run_state() + from .agent_tool_state import ( + get_agent_tool_resume_state, + get_agent_tool_state_scope, + peek_agent_tool_run_result, + record_agent_tool_resume_state, + ) + + copied._agent_tool_state_scope_id = get_agent_tool_state_scope(copied._context) + if self._last_processed_response is None: + return copied + + for function_run in self._last_processed_response.functions: + pending_result = peek_agent_tool_run_result( + function_run.tool_call, + scope_id=self._agent_tool_state_scope_id, + ) + interruptions = getattr(pending_result, "interruptions", None) + to_state = getattr(pending_result, "to_state", None) + if not isinstance(interruptions, list) or not interruptions or not callable(to_state): + continue + pending_state = get_agent_tool_resume_state(pending_result) + copy_for_checkpoint = getattr(pending_state, "_copy_for_result_checkpoint", None) + nested_state = copy_for_checkpoint() if callable(copy_for_checkpoint) else to_state() + if not isinstance(nested_state, RunState) or nested_state is self: + continue + record_agent_tool_resume_state( + function_run.tool_call, + nested_state, + scope_id=copied._agent_tool_state_scope_id, + approval_items=interruptions, + ) + return copied + @property def pending_input(self) -> list[TResponseInputItem]: """Return a copy of input currently staged for the next resumed model call.""" diff --git a/tests/test_agent_tool_state.py b/tests/test_agent_tool_state.py index af6625d76d..330955cf82 100644 --- a/tests/test_agent_tool_state.py +++ b/tests/test_agent_tool_state.py @@ -86,6 +86,20 @@ def test_agent_tool_run_result_returns_none_for_ambiguous_signature_matches() -> assert tool_state.peek_agent_tool_run_result(restored_call, scope_id="other-scope") is None +def test_agent_tool_run_result_keeps_same_call_isolated_by_scope() -> None: + tool_call = _function_tool_call("lookup_account", "{}", call_id="call-1") + first_result = cast(Any, object()) + second_result = cast(Any, object()) + + tool_state.record_agent_tool_run_result(tool_call, first_result, scope_id="scope-1") + tool_state.record_agent_tool_run_result(tool_call, second_result, scope_id="scope-2") + + assert tool_state.peek_agent_tool_run_result(tool_call, scope_id="scope-1") is first_result + assert tool_state.peek_agent_tool_run_result(tool_call, scope_id="scope-2") is second_result + assert tool_state.consume_agent_tool_run_result(tool_call, scope_id="scope-1") is first_result + assert tool_state.peek_agent_tool_run_result(tool_call, scope_id="scope-2") is second_result + + def test_agent_tool_run_result_is_dropped_when_tool_call_is_collected() -> None: tool_call = _function_tool_call("lookup_account", "{}", call_id="call-1") tool_call_ref = weakref.ref(tool_call) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 21475ca326..4984def547 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -6572,6 +6572,63 @@ async def tool_func() -> str: assert result2.final_output == "Second response" + @pytest.mark.parametrize("streamed", [False, True]) + @pytest.mark.asyncio + async def test_result_to_state_detaches_tool_decision_ledgers(self, streamed: bool): + """States created from one result must not share approval decisions.""" + model = ScriptedModel() + executions: list[str] = [] + + @function_tool(needs_approval=True) + async def approval_tool() -> str: + executions.append("executed") + return "approved" + + agent = Agent(name="TestAgent", model=model, tools=[approval_tool]) + model.enqueue([get_function_tool_call("approval_tool", "{}")]) + + if streamed: + result = Runner.run_streamed(agent, "First input") + async for _ in result.stream_events(): + pass + else: + result = await Runner.run(agent, "First input") + + decided = result.to_state() + untouched = result.to_state() + untouched_approvals_before = untouched.to_json()["context"]["approvals"] + + decided.approve(decided.get_interruptions()[0]) + + assert decided._context is not untouched._context + assert decided._context is not None + assert untouched._context is not None + assert decided._context.context is untouched._context.context + assert decided._context._approvals is not untouched._context._approvals + assert decided._context._tool_invocations is not untouched._context._tool_invocations + assert untouched.to_json()["context"]["approvals"] == untouched_approvals_before + + if streamed: + untouched_result = Runner.run_streamed(agent, untouched) + async for _ in untouched_result.stream_events(): + pass + else: + untouched_result = await Runner.run(agent, untouched) + + assert untouched_result.interruptions + assert executions == [] + + def test_nested_resume_checkpoint_to_state_keeps_its_owned_decision_ledger(self): + """A scoped nested checkpoint returns the state that resume will consume.""" + from agents.agent_tool_state import _AgentToolResumeCheckpoint + + agent = Agent(name="NestedAgent") + approval_item = make_tool_approval_item(agent, call_id="nested-call") + state = make_state_with_interruptions(agent, [approval_item]) + checkpoint = _AgentToolResumeCheckpoint(state, frozenset()) + + assert checkpoint.to_state() is state + @pytest.mark.asyncio async def test_resume_from_run_state_streamed(self): """Test resuming a run from a RunState using run_streamed.""" From 50d65f65c367a3b09dcd3313ee8d78471c35885e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 14 Aug 2026 22:17:44 +0900 Subject: [PATCH 317/473] fix: resume recursive agent tool approvals (#4414) --- src/agents/agent.py | 40 ++++++---- src/agents/run_internal/turn_resolution.py | 22 ++++-- src/agents/run_state.py | 10 +-- tests/test_run_state.py | 85 ++++++++++++++++++++++ 4 files changed, 129 insertions(+), 28 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index d3e7cc81e9..d0df7267a8 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -746,21 +746,31 @@ def _nested_approvals_status( pending_run_result: RunResult | RunResultStreaming, ) -> Literal["approved", "pending", "rejected"]: interruptions = pending_run_result.interruptions - nested_decision_context = pending_run_result.to_state()._context + nested_state = pending_run_result.to_state() has_pending = False has_decision = False for interruption in interruptions: - call_id = get_tool_approval_item_call_id(interruption) + find_nested_owner = getattr( + nested_state, + "_find_nested_approval_state", + None, + ) + nested_owner = ( + find_nested_owner(interruption) if callable(find_nested_owner) else None + ) + decision_state, decision_item = nested_owner or (nested_state, interruption) + nested_decision_context = getattr(decision_state, "_context", None) + call_id = get_tool_approval_item_call_id(decision_item) if not call_id: has_pending = True continue - tool_namespace = RunContextWrapper._resolve_tool_namespace(interruption) + tool_namespace = RunContextWrapper._resolve_tool_namespace(decision_item) status = ( nested_decision_context.get_approval_status( - interruption.tool_name or "", + decision_item.tool_name or "", call_id, tool_namespace=tool_namespace, - existing_pending=interruption, + existing_pending=decision_item, ) if nested_decision_context is not None else None @@ -771,24 +781,24 @@ def _nested_approvals_status( and context._allow_legacy_approval_binding_reconstruction ): status = context.get_approval_status( - interruption.tool_name or "", + decision_item.tool_name or "", call_id, tool_namespace=tool_namespace, - existing_pending=interruption, + existing_pending=decision_item, ) if status is not None: legacy_namespace = RunContextWrapper._resolve_tool_namespace( - interruption + decision_item ) - legacy_tool_name = RunContextWrapper._resolve_tool_name(interruption) + legacy_tool_name = RunContextWrapper._resolve_tool_name(decision_item) legacy_qualified_key = ( f"{legacy_namespace}.{legacy_tool_name}" if legacy_namespace is not None else legacy_tool_name ) approval_keys = ( - RunContextWrapper._resolve_approval_key(interruption), - *RunContextWrapper._resolve_approval_keys(interruption), + RunContextWrapper._resolve_approval_key(decision_item), + *RunContextWrapper._resolve_approval_keys(decision_item), legacy_qualified_key, ) approval_record = next( @@ -802,7 +812,7 @@ def _nested_approvals_status( if status: RunContextWrapper.approve_tool( nested_decision_context, - interruption, + decision_item, always_approve=bool( approval_record and approval_record.approved is True ), @@ -810,15 +820,15 @@ def _nested_approvals_status( else: RunContextWrapper.reject_tool( nested_decision_context, - interruption, + decision_item, always_reject=bool( approval_record and approval_record.rejected is True ), rejection_message=context.get_rejection_message( - interruption.tool_name or "", + decision_item.tool_name or "", call_id, tool_namespace=tool_namespace, - existing_pending=interruption, + existing_pending=decision_item, ), ) if status is False: diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 8c756b0bc9..b1eead5351 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -1452,29 +1452,35 @@ def _nested_interruptions_status( ) -> Literal["approved", "pending", "rejected"]: interruptions = cast(Sequence[ToolApprovalItem], nested_run_result.interruptions) nested_state = nested_run_result.to_state() - nested_decision_context = getattr(nested_state, "_context", None) has_pending = False for interruption in interruptions: - call_id = get_tool_approval_item_call_id(interruption) + nested_owner = ( + nested_state._find_nested_approval_state(interruption) + if isinstance(nested_state, RunState) + else None + ) + decision_state, decision_item = nested_owner or (nested_state, interruption) + nested_decision_context = getattr(decision_state, "_context", None) + call_id = get_tool_approval_item_call_id(decision_item) if not call_id: has_pending = True continue status = ( nested_decision_context.get_approval_status( - interruption.tool_name or "", + decision_item.tool_name or "", call_id, - tool_namespace=interruption.tool_namespace, - existing_pending=interruption, + tool_namespace=decision_item.tool_namespace, + existing_pending=decision_item, ) if isinstance(nested_decision_context, RunContextWrapper) else None ) if status is None and context_wrapper._allow_legacy_approval_binding_reconstruction: status = context_wrapper.get_approval_status( - interruption.tool_name or "", + decision_item.tool_name or "", call_id, - tool_namespace=interruption.tool_namespace, - existing_pending=interruption, + tool_namespace=decision_item.tool_namespace, + existing_pending=decision_item, ) if status is False: return "rejected" diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 43c994103b..d2246521cc 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -1111,11 +1111,11 @@ def _find_nested_approval_state( nested_state = to_state() if not isinstance(nested_state, RunState) or nested_state is self: continue - nested_candidates.extend( - (nested_state, candidate) - for candidate in interruptions - if isinstance(candidate, ToolApprovalItem) - ) + for candidate in interruptions: + if not isinstance(candidate, ToolApprovalItem): + continue + recursive_owner = nested_state._find_nested_approval_state(candidate) + nested_candidates.append(recursive_owner or (nested_state, candidate)) current_candidates = ( self._current_step.interruptions diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 4984def547..66b35472c9 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -11464,6 +11464,91 @@ async def needs_ok(ctx: RunContextWrapper[dict[str, str]], text: str) -> str: ) +@pytest.mark.parametrize("nesting_edges", [2, 3]) +@pytest.mark.parametrize("approval_timing", ["live", "before_restore", "after_restore"]) +@pytest.mark.parametrize("approve", [True, False]) +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_resume_recursively_nested_agent_as_tool_decision( + streamed: bool, + approve: bool, + approval_timing: str, + nesting_edges: int, +) -> None: + """Tool decisions reach a protected tool through nested agent tools.""" + calls: list[str] = [] + + @function_tool(needs_approval=True) + async def protected(text: str) -> str: + calls.append(text) + return f"approved:{text}" + + leaf_model = ScriptedModel() + leaf_model.extend( + [ + [get_function_tool_call("protected", json.dumps({"text": "one"}), call_id="inner-1")], + [get_final_output_message("inner-done")], + ] + ) + outer = Agent(name="inner", model=leaf_model, tools=[protected]) + for edge in range(nesting_edges): + tool_name = f"agent_tool_{edge}" + model = ScriptedModel() + model.extend( + [ + [ + get_function_tool_call( + tool_name, + json.dumps({"input": "go"}), + call_id=f"agent-call-{edge}", + ) + ], + [get_final_output_message(f"done-{edge}")], + ] + ) + outer = Agent( + name=f"agent-{edge}", + model=model, + tools=[outer.as_tool(tool_name=tool_name, tool_description="Run nested agent")], + ) + + if streamed: + first = Runner.run_streamed(outer, "start") + async for _ in first.stream_events(): + pass + else: + first = await Runner.run(outer, "start") + + state = first.to_state() + assert len(state.get_interruptions()) == 1 + + def apply_decision() -> None: + if approve: + state.approve(state.get_interruptions()[0]) + else: + state.reject(state.get_interruptions()[0]) + + if approval_timing == "before_restore": + apply_decision() + state = await RunState.from_json(outer, state.to_json()) + elif approval_timing == "after_restore": + state = await RunState.from_json(outer, state.to_json()) + apply_decision() + else: + apply_decision() + + if streamed: + resumed = Runner.run_streamed(outer, state) + async for _ in resumed.stream_events(): + pass + else: + resumed = await Runner.run(outer, state) + + assert resumed.final_output == f"done-{nesting_edges - 1}" + assert resumed.interruptions == [] + assert calls == (["one"] if approve else []) + + @pytest.mark.asyncio async def test_hosted_mcp_approval_request_restores_matching_server_tool() -> None: class FalsyHostedMCPTool(HostedMCPTool): From c0f2ff7d8f064fd4e15799ec1c1ae21e2e21cf6f Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Fri, 14 Aug 2026 17:06:00 -0400 Subject: [PATCH 318/473] fix: preserve scripted annotation streaming across Python SDK releases (#4422) This pull request fixes scripted annotation streaming before the next Python SDK release introduces accurately typed annotation-added events (openai/openai-python#3617). The current helper passes output-text annotation models directly into streaming events. That works while the released SDK accepts an untyped annotation, but the upcoming SDK validates events against distinct event-specific annotation classes. As a result, the existing helper both fails static type checking and raises a validation error at runtime. - Preserve annotation contents, event ordering, and sequence numbers across both the released and upcoming Python SDKs. - Construct events from their serialized payload so each installed SDK applies its own event schema without relying on imports that do not exist in the current release. - Compare annotation payloads by their public serialized representation instead of requiring unrelated generated model classes to share an identity. --- src/agents/testing/model.py | 18 ++++++++++-------- tests/test_scripted_model.py | 4 +++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/agents/testing/model.py b/src/agents/testing/model.py index 546b056aa0..42ea6a9f2a 100644 --- a/src/agents/testing/model.py +++ b/src/agents/testing/model.py @@ -1007,14 +1007,16 @@ def _stream_events_for_step( events.append( cast( TResponseStreamEvent, - ResponseOutputTextAnnotationAddedEvent( - type="response.output_text.annotation.added", - item_id=output_item.id, - output_index=output_index, - content_index=content_index, - annotation_index=annotation_index, - annotation=copy.deepcopy(annotation), - sequence_number=sequence_number, + ResponseOutputTextAnnotationAddedEvent.model_validate( + { + "type": "response.output_text.annotation.added", + "item_id": output_item.id, + "output_index": output_index, + "content_index": content_index, + "annotation_index": annotation_index, + "annotation": annotation.model_dump(), + "sequence_number": sequence_number, + } ), ) ) diff --git a/tests/test_scripted_model.py b/tests/test_scripted_model.py index ebf3ab4c4e..8e5c3018b4 100644 --- a/tests/test_scripted_model.py +++ b/tests/test_scripted_model.py @@ -1987,7 +1987,9 @@ async def test_scripted_model_automatic_stream_emits_text_annotation_events() -> assert isinstance(added_part.part, ResponseOutputText) assert added_part.part.annotations == [] assert [event.annotation_index for event in annotation_events] == [0, 1] - assert [event.annotation for event in annotation_events] == annotations + assert [event.model_dump()["annotation"] for event in annotation_events] == [ + annotation.model_dump() for annotation in annotations + ] assert all(event.item_id == "message_1" for event in annotation_events) assert all(event.output_index == 0 for event in annotation_events) assert all(event.content_index == 0 for event in annotation_events) From 4e5e7b26956d3f4065c9b8681fd20c6a396011aa Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 14 Aug 2026 16:54:43 -0500 Subject: [PATCH 319/473] fix(realtime): handle non-finite audio rates (#4419) --- src/agents/realtime/audio_formats.py | 2 +- tests/realtime/test_audio_formats_unit.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/agents/realtime/audio_formats.py b/src/agents/realtime/audio_formats.py index a028c736e8..25ec40d9bc 100644 --- a/src/agents/realtime/audio_formats.py +++ b/src/agents/realtime/audio_formats.py @@ -35,7 +35,7 @@ def to_realtime_audio_format( rate = input_audio_format.get("rate") if fmt_type == "audio/pcm": pcm_rate: Literal[24000] | None - if isinstance(rate, int | float) and int(rate) == 24000: + if isinstance(rate, int | float) and rate == 24000: pcm_rate = 24000 elif rate is None: pcm_rate = 24000 diff --git a/tests/realtime/test_audio_formats_unit.py b/tests/realtime/test_audio_formats_unit.py index 52a9028228..19ce6317e9 100644 --- a/tests/realtime/test_audio_formats_unit.py +++ b/tests/realtime/test_audio_formats_unit.py @@ -1,4 +1,5 @@ import logging +import math from typing import Any import pytest @@ -58,6 +59,14 @@ def test_to_realtime_audio_format_from_mapping(): assert to_realtime_audio_format({"type": "audio/unknown", "rate": 8000}) is None +@pytest.mark.parametrize("rate", [math.nan, math.inf, -math.inf]) +def test_to_realtime_audio_format_falls_back_for_non_finite_pcm_rate(rate: float) -> None: + result = to_realtime_audio_format({"type": "audio/pcm", "rate": rate}) + + assert isinstance(result, AudioPCM) + assert result.rate == 24000 + + @pytest.mark.parametrize("tool_data_redacted", [False, True]) @pytest.mark.parametrize( ("input_audio_format", "expected_message", "expected_type"), From 2b2d7fe4747e36ff327a9af3b3a5d4ede27d3c4b Mon Sep 17 00:00:00 2001 From: Chinmay V Date: Sat, 15 Aug 2026 03:40:30 +0530 Subject: [PATCH 320/473] fix(mcp): stop handing the tools cache to callers (#4424) --- src/agents/mcp/server.py | 11 ++++- tests/mcp/test_caching.py | 99 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 053159c470..a4bbcc3974 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -854,7 +854,11 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC): @property def cached_tools(self) -> list[MCPTool] | None: - return self._tools_list + """A snapshot of the cached tools list, or `None` when nothing is cached. + + This returns a new list so callers cannot mutate the server's cache in place. + """ + return None if self._tools_list is None else list(self._tools_list) def __init__( self, @@ -1474,6 +1478,11 @@ async def fetch_pages() -> bool: filtered_tools = tools if self.tool_filter is not None: filtered_tools = await self._apply_tool_filter(filtered_tools, run_context, agent) + if filtered_tools is self._tools_list: + # The filters build a new list, but an absent filter — or a static filter with + # neither key set — passes the cached list straight through. Returning it would + # let a caller mutate the cache and corrupt every later `list_tools()` result. + return list(filtered_tools) return filtered_tools except mcp_compat.HTTP_STATUS_ERROR_TYPES as e: status_code = http_status_code(e) diff --git a/tests/mcp/test_caching.py b/tests/mcp/test_caching.py index 9a3ce885fa..5619e99998 100644 --- a/tests/mcp/test_caching.py +++ b/tests/mcp/test_caching.py @@ -95,3 +95,102 @@ async def test_paginated_tools_are_cached_before_filtering( call(), call(params=PaginatedRequestParams(cursor="")), ] + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_list_tools_does_not_expose_the_tools_cache( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """Mutating the list returned by `list_tools()` must not corrupt the server's cache.""" + server = MCPServerStdio(params={"command": tee}, cache_tools_list=True) + mock_list_tools.return_value = ListToolsResult( + tools=[MCPTool(name="tool1", inputSchema={}), MCPTool(name="tool2", inputSchema={})] + ) + + async with server: + run_context = RunContextWrapper(context=None) + agent = Agent(name="test_agent", instructions="Test agent") + + returned = await server.list_tools(run_context, agent) + assert returned is not server.cached_tools + returned.pop() + + assert [tool.name for tool in await server.list_tools(run_context, agent)] == [ + "tool1", + "tool2", + ] + assert mock_list_tools.call_count == 1, "the cache should still be serving both tools" + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_list_tools_does_not_expose_the_cache_with_a_no_op_static_filter( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """A static filter that sets neither key passes the cached list straight through.""" + server = MCPServerStdio(params={"command": tee}, cache_tools_list=True, tool_filter={}) + mock_list_tools.return_value = ListToolsResult( + tools=[MCPTool(name="tool1", inputSchema={}), MCPTool(name="tool2", inputSchema={})] + ) + + async with server: + run_context = RunContextWrapper(context=None) + agent = Agent(name="test_agent", instructions="Test agent") + + returned = await server.list_tools(run_context, agent) + returned.clear() + + assert [tool.name for tool in await server.list_tools(run_context, agent)] == [ + "tool1", + "tool2", + ] + assert mock_list_tools.call_count == 1 + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_cached_tools_returns_a_snapshot( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """`cached_tools` must not hand out the live cache: appending to it would inject a tool.""" + server = MCPServerStdio(params={"command": tee}, cache_tools_list=True) + mock_list_tools.return_value = ListToolsResult( + tools=[MCPTool(name="tool1", inputSchema={}), MCPTool(name="tool2", inputSchema={})] + ) + + async with server: + run_context = RunContextWrapper(context=None) + agent = Agent(name="test_agent", instructions="Test agent") + await server.list_tools(run_context, agent) + + snapshot = server.cached_tools + assert snapshot is not None + snapshot.append(MCPTool(name="injected", inputSchema={})) + + assert [tool.name for tool in (server.cached_tools or [])] == ["tool1", "tool2"] + assert [tool.name for tool in await server.list_tools(run_context, agent)] == [ + "tool1", + "tool2", + ] + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_cached_tools_is_none_before_the_first_list( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """The snapshot must preserve the `None` sentinel rather than reporting an empty cache.""" + server = MCPServerStdio(params={"command": tee}, cache_tools_list=True) + mock_list_tools.return_value = ListToolsResult(tools=[]) + + async with server: + assert server.cached_tools is None From 1a0c08868aec2a18eba964e5a07da4270a490c25 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:28:30 +0100 Subject: [PATCH 321/473] fix(sandbox): honor view_image extra path grants (#4417) --- .../sandbox/capabilities/tools/view_image.py | 19 +++++++--- .../capabilities/test_view_image_tool.py | 37 ++++++++++++++++++- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/agents/sandbox/capabilities/tools/view_image.py b/src/agents/sandbox/capabilities/tools/view_image.py index 65e8d07045..6647e074af 100644 --- a/src/agents/sandbox/capabilities/tools/view_image.py +++ b/src/agents/sandbox/capabilities/tools/view_image.py @@ -11,9 +11,10 @@ from ....run_context import RunContextWrapper from ....tool import FunctionTool, ToolOutputImage -from ...errors import WorkspaceReadNotFoundError +from ...errors import InvalidManifestPathError, WorkspaceReadNotFoundError from ...session.base_sandbox_session import BaseSandboxSession from ...types import User +from ...workspace_paths import sandbox_path_str _MAX_IMAGE_BYTES = 10 * 1024 * 1024 _MAX_IMAGE_SIZE_LABEL = "10MB" @@ -63,7 +64,10 @@ def _coerce_payload_bytes(payload: object) -> bytes: class ViewImageArgs(BaseModel): path: str = Field( - description="Path to the image file. Absolute and relative workspace paths are supported.", + description=( + "Path to the image file. Workspace paths and explicitly granted sandbox paths are " + "supported." + ), min_length=1, ) @@ -73,7 +77,8 @@ class ViewImageTool(FunctionTool): tool_name: ClassVar[str] = "view_image" args_model: ClassVar[type[ViewImageArgs]] = ViewImageArgs tool_description: ClassVar[str] = ( - "Loads an image from the sandbox workspace and returns it as a structured image output." + "Loads an image from the sandbox workspace or an explicitly granted sandbox path and " + "returns it as a structured image output." ) session: BaseSandboxSession = field(init=False, repr=False, compare=False) user: str | User | None = field(default=None, init=False, repr=False, compare=False) @@ -102,10 +107,12 @@ async def _invoke(self, _: object, raw_input: str) -> ToolOutputImage | str: return await self.run(self.args_model.model_validate_json(raw_input)) async def run(self, args: ViewImageArgs) -> ToolOutputImage | str: - input_path = Path(args.path) path_policy = self.session._workspace_path_policy() - resolved_path = path_policy.absolute_workspace_path(input_path) - display_path = path_policy.relative_path(input_path).as_posix() + resolved_path = path_policy.normalize_path(args.path) + try: + display_path = path_policy.relative_path(args.path).as_posix() + except InvalidManifestPathError: + display_path = sandbox_path_str(resolved_path) try: file_obj = await self.session.read(resolved_path, user=self.user) diff --git a/tests/sandbox/capabilities/test_view_image_tool.py b/tests/sandbox/capabilities/test_view_image_tool.py index 936d619ae4..64ecd403e6 100644 --- a/tests/sandbox/capabilities/test_view_image_tool.py +++ b/tests/sandbox/capabilities/test_view_image_tool.py @@ -7,9 +7,9 @@ import pytest -from agents.sandbox import Manifest +from agents.sandbox import Manifest, SandboxPathGrant from agents.sandbox.capabilities.tools import ViewImageTool -from agents.sandbox.errors import WorkspaceReadNotFoundError +from agents.sandbox.errors import InvalidManifestPathError, WorkspaceReadNotFoundError from agents.sandbox.types import User from agents.testing import scripted_sandbox_session from agents.tool import ToolOutputImage @@ -48,6 +48,39 @@ async def test_view_image_returns_tool_output_image_for_png(self) -> None: assert output.detail is None session.assert_complete() + @pytest.mark.asyncio + async def test_view_image_reads_absolute_extra_path_grant(self) -> None: + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(_PNG_BYTES)}], + manifest=Manifest( + root="/workspace", + extra_path_grants=(SandboxPathGrant(path="/shared", read_only=True),), + ), + ) + tool = ViewImageTool(session=session) + + output = await tool.on_invoke_tool( + cast(ToolContext[object], None), + '{"path":"/shared/dot.png"}', + ) + + assert isinstance(output, ToolOutputImage) + assert session.calls[0].args[0].as_posix() == "/shared/dot.png" + session.assert_complete() + + @pytest.mark.asyncio + async def test_view_image_still_rejects_ungranted_absolute_path(self) -> None: + session = scripted_sandbox_session(manifest=Manifest(root="/workspace")) + tool = ViewImageTool(session=session) + + with pytest.raises(InvalidManifestPathError): + await tool.on_invoke_tool( + cast(ToolContext[object], None), + '{"path":"/shared/dot.png"}', + ) + + assert session.calls == () + @pytest.mark.asyncio async def test_view_image_reads_as_bound_user(self) -> None: session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(_PNG_BYTES)}]) From 25aa6d94a1b9048772893d480fb467c3177ccd66 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 15 Aug 2026 11:43:38 +0900 Subject: [PATCH 322/473] release: 0.21.0 (#4387) --- pyproject.toml | 2 +- tests/fixtures/released_api_contract.json | 4520 ++++++++++++++++++++- uv.lock | 2 +- 3 files changed, 4388 insertions(+), 136 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d887efbdcb..5eb8913d15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.20.0" +version = "0.21.0" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/fixtures/released_api_contract.json b/tests/fixtures/released_api_contract.json index 67f75de166..be8f660e4d 100644 --- a/tests/fixtures/released_api_contract.json +++ b/tests/fixtures/released_api_contract.json @@ -1,6 +1,6 @@ { - "baseline": "v0.20.0", - "baseline_commit": "c0b876379e82095b164c78cec65fb659a699a98d", + "baseline": "v0.21.0", + "baseline_commit": "1a0c08868aec2a18eba964e5a07da4270a490c25", "callables": { "Agent": { "dataclass_fields": [ @@ -18422,6 +18422,13 @@ } ] }, + "agents.extensions.sandbox.RunloopExistingSecret": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [], + "parameters": [] + }, "agents.extensions.sandbox.RunloopGatewaySpec": { "dataclass_fields": [], "kind": "class", @@ -22732,6 +22739,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_max" } ] }, @@ -22997,6 +23013,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_max" } ] }, @@ -23262,6 +23287,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "custom_data_extractor" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_max" } ] }, @@ -24928,6 +24962,258 @@ } ] }, + "agents.realtime.testing.RealtimeStep": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "expect" + }, + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "init": true, + "name": "emit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "error" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "expect" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "emit" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + } + ] + }, + "agents.realtime.testing.ScriptedRealtimeModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "add_listener": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "listener" + } + ] + }, + "assert_complete": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "connect": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "options" + } + ] + }, + "emit": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "events" + } + ] + }, + "remove_listener": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "listener" + } + ] + }, + "send_event": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "event" + } + ] + }, + "send_event_if": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "event" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "send_if" + } + ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "steps" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "KEYWORD_ONLY", + "name": "connect_events" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "connect_error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "close_error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "strict" + } + ] + }, + "agents.realtime.testing.UnconsumedRealtimeSteps": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remaining_steps" + } + ] + }, + "agents.realtime.testing.UnexpectedRealtimeSend": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "actual" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "expected" + } + ] + }, "agents.sandbox.Capability": { "dataclass_fields": [], "kind": "class", @@ -36523,6 +36809,3082 @@ } ] }, + "agents.testing.InvalidModelStep": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "reason" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "input_index" + } + ] + }, + "agents.testing.InvalidSandboxStep": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "reason" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "input_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "method" + } + ] + }, + "agents.testing.ModelCall": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "prompt" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "streamed" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "streamed" + } + ] + }, + "agents.testing.ModelStep": { + "dataclass_fields": [ + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "init": true, + "name": "output" + }, + { + "default": { + "factory": "agents.usage.Usage", + "kind": "factory" + }, + "init": true, + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "resp-789" + }, + "init": true, + "name": "response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "request_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "raw_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "responder" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "stream_events" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retry_advice" + } + ], + "kind": "class", + "members": { + "raise_error": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "retry_advice" + } + ] + }, + "respond": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "responder" + } + ] + }, + "stream": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "events" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "KEYWORD_ONLY", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "resp-789" + }, + "kind": "KEYWORD_ONLY", + "name": "response_id" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "resp-789" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "responder" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "stream_events" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_advice" + } + ] + }, + "agents.testing.SandboxCall": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "call_index" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "method" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "args" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "kwargs" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "method" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "args" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "kwargs" + } + ] + }, + "agents.testing.SandboxCallMatcherError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_index" + } + ] + }, + "agents.testing.ScriptedModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_complete": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "enqueue": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "step" + } + ] + }, + "extend": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "steps" + } + ] + }, + "get_response": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "prompt" + } + ] + }, + "get_retry_advice": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request" + } + ] + }, + "set_default_usage": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + } + ] + }, + "stream_response": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "prompt" + } + ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "steps" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "emit_traces" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "default_usage" + } + ] + }, + "agents.testing.ScriptedSandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "assert_complete": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [] + }, + "agents.testing.UnconsumedModelSteps": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remaining_steps" + } + ] + }, + "agents.testing.UnconsumedSandboxSteps": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remaining_steps" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "pending_methods" + } + ] + }, + "agents.testing.UnexpectedModelCall": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_index" + } + ] + }, + "agents.testing.UnexpectedSandboxCall": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "expected_method" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remaining_steps" + } + ] + }, + "agents.testing.assistant_message": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "text" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "scripted-message" + }, + "kind": "KEYWORD_ONLY", + "name": "item_id" + } + ] + }, + "agents.testing.function_call": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "namespace" + } + ] + }, + "agents.testing.model.InvalidModelStep": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "reason" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "input_index" + } + ] + }, + "agents.testing.model.ModelCall": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "prompt" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "streamed" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "prompt" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "streamed" + } + ] + }, + "agents.testing.model.ModelStep": { + "dataclass_fields": [ + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "init": true, + "name": "output" + }, + { + "default": { + "factory": "agents.usage.Usage", + "kind": "factory" + }, + "init": true, + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "resp-789" + }, + "init": true, + "name": "response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "request_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "raw_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "responder" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "stream_events" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "retry_advice" + } + ], + "kind": "class", + "members": { + "raise_error": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "retry_advice" + } + ] + }, + "respond": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "responder" + } + ] + }, + "stream": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "events" + }, + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "KEYWORD_ONLY", + "name": "output" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "resp-789" + }, + "kind": "KEYWORD_ONLY", + "name": "response_id" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "resp-789" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "error" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "responder" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "stream_events" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_advice" + } + ] + }, + "agents.testing.model.ScriptedModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_complete": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "enqueue": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "step" + } + ] + }, + "extend": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "steps" + } + ] + }, + "get_response": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "prompt" + } + ] + }, + "get_retry_advice": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "request" + } + ] + }, + "set_default_usage": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + } + ] + }, + "stream_response": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "system_instructions" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "model_settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tools" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "output_schema" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "handoffs" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tracing" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "previous_response_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "conversation_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "prompt" + } + ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "steps" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "emit_traces" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "default_usage" + } + ] + }, + "agents.testing.model.UnconsumedModelSteps": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remaining_steps" + } + ] + }, + "agents.testing.model.UnexpectedModelCall": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_index" + } + ] + }, + "agents.testing.model.assistant_message": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "text" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "scripted-message" + }, + "kind": "KEYWORD_ONLY", + "name": "item_id" + } + ] + }, + "agents.testing.model.function_call": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "arguments" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "item_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "namespace" + } + ] + }, + "agents.testing.sandbox.InvalidSandboxStep": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "reason" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "input_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "method" + } + ] + }, + "agents.testing.sandbox.SandboxCall": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "call_index" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "method" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "args" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "kwargs" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "method" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "args" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "kwargs" + } + ] + }, + "agents.testing.sandbox.SandboxCallMatcherError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_index" + } + ] + }, + "agents.testing.sandbox.ScriptedSandboxSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "aclose": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "apply_manifest": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "only_ephemeral" + } + ] + }, + "apply_patch": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operations" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "v4a" + }, + "kind": "KEYWORD_ONLY", + "name": "patch_format" + } + ] + }, + "assert_complete": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "describe": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "exec": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "extract": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "compression_scheme" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "archive_limits" + } + ] + }, + "hydrate_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + } + ] + }, + "ls": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "mkdir": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "parents" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "normalize_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "for_write" + } + ] + }, + "persist_workspace": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "provision_manifest_accounts": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_exec_start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "VAR_POSITIONAL", + "name": "command" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": true + }, + "kind": "KEYWORD_ONLY", + "name": "shell" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "tty" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "pty_terminate_all": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "pty_write_stdin": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "session_id" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "chars" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "yield_time_s" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "max_output_tokens" + } + ] + }, + "read": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "register_persist_workspace_skip_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "register_pre_stop_hook": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "hook" + } + ] + }, + "resolve_exposed_port": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "port" + } + ] + }, + "rm": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "recursive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + }, + "run_pre_stop_hooks": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "running": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "set_dependencies": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "dependencies" + } + ] + }, + "should_provision_manifest_accounts_on_resume": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "shutdown": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "start": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "stop": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "supports_docker_volume_mounts": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "supports_pty": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "write": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "data" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "user" + } + ] + } + }, + "parameters": [] + }, + "agents.testing.sandbox.UnconsumedSandboxSteps": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remaining_steps" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "pending_methods" + } + ] + }, + "agents.testing.sandbox.UnexpectedSandboxCall": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "call_index" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "expected_method" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "remaining_steps" + } + ] + }, + "agents.testing.sandbox.scripted_sandbox_session": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "steps" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + } + ] + }, + "agents.testing.scripted_sandbox_session": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "steps" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "manifest" + } + ] + }, "agents.tool_context.ToolContext": { "dataclass_fields": [ { @@ -36716,157 +40078,618 @@ } ] }, - "get_approval_status": { + "get_approval_status": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "current_invocation" + } + ] + }, + "get_rejection_message": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "existing_pending" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_lookup_key" + } + ] + }, + "is_tool_approved": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "call_id" + } + ] + }, + "reject_tool": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approval_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "always_reject" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "rejection_message" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "context" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "usage" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_name" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call_id" + }, + { + "default": { + "identity": "agents.tool_context._MISSING", + "kind": "sentinel" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_arguments" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tool_call" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_namespace" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "agent" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "run_config" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "turn_input" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_approvals" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "tool_input" + } + ] + }, + "agents.voice.testing.STTCall": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "settings" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "trace_include_sensitive_data" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "trace_include_sensitive_audio_data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_data" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_audio_data" + } + ] + }, + "agents.voice.testing.STTSessionCall": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "settings" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "trace_include_sensitive_data" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "trace_include_sensitive_audio_data" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "settings" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_data" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_audio_data" + } + ] + }, + "agents.voice.testing.ScriptedSTTModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_complete": { "binding": "instance", "execution_kind": "sync", + "parameters": [] + }, + "create_session": { + "binding": "instance", + "execution_kind": "coroutine", "parameters": [ { "default": { "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_name" + "name": "input" }, { "default": { "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "call_id" - }, - { - "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null - }, - "kind": "KEYWORD_ONLY", - "name": "tool_namespace" - }, - { - "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null - }, - "kind": "KEYWORD_ONLY", - "name": "existing_pending" + "name": "settings" }, { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, - "kind": "KEYWORD_ONLY", - "name": "tool_lookup_key" + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_data" }, { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, - "kind": "KEYWORD_ONLY", - "name": "current_invocation" + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_audio_data" } ] }, - "get_rejection_message": { + "transcribe": { "binding": "instance", - "execution_kind": "sync", + "execution_kind": "coroutine", "parameters": [ { "default": { "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_name" + "name": "input" }, { "default": { "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "call_id" - }, - { - "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null - }, - "kind": "KEYWORD_ONLY", - "name": "tool_namespace" + "name": "settings" }, { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, - "kind": "KEYWORD_ONLY", - "name": "existing_pending" + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_data" }, { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, - "kind": "KEYWORD_ONLY", - "name": "tool_lookup_key" + "kind": "POSITIONAL_OR_KEYWORD", + "name": "trace_include_sensitive_audio_data" } ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "transcriptions" }, - "is_tool_approved": { + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "KEYWORD_ONLY", + "name": "sessions" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "scripted-stt" + }, + "kind": "KEYWORD_ONLY", + "name": "model_name" + } + ] + }, + "agents.voice.testing.ScriptedTTSModel": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_complete": { "binding": "instance", "execution_kind": "sync", + "parameters": [] + }, + "run": { + "binding": "instance", + "execution_kind": "async_generator", "parameters": [ { "default": { "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_name" + "name": "text" }, { "default": { "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "call_id" + "name": "settings" } ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "results" }, - "reject_tool": { + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "scripted-tts" + }, + "kind": "KEYWORD_ONLY", + "name": "model_name" + } + ] + }, + "agents.voice.testing.ScriptedTranscriptionSession": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_complete": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "close": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [] + }, + "transcribe_turns": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "turns" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "close_error" + } + ] + }, + "agents.voice.testing.ScriptedVoiceWorkflow": { + "dataclass_fields": [], + "kind": "class", + "members": { + "assert_complete": { "binding": "instance", "execution_kind": "sync", + "parameters": [] + }, + "on_start": { + "binding": "instance", + "execution_kind": "async_generator", + "parameters": [] + }, + "run": { + "binding": "instance", + "execution_kind": "async_generator", "parameters": [ { "default": { "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "approval_item" - }, - { - "default": { - "kind": "literal", - "type": "builtins.bool", - "value": false - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "always_reject" - }, - { - "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null - }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "rejection_message" + "name": "transcription" } ] } @@ -36874,105 +40697,135 @@ "parameters": [ { "default": { - "kind": "required" + "items": [], + "kind": "sequence", + "type": "builtins.tuple" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "context" + "name": "turns" }, { "default": { - "identity": "agents.tool_context._MISSING", + "identity": "agents.voice.testing._START_NOT_CONFIGURED", "kind": "sentinel" }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "usage" - }, + "kind": "KEYWORD_ONLY", + "name": "start" + } + ] + }, + "agents.voice.testing.TTSCall": { + "dataclass_fields": [ { "default": { - "identity": "agents.tool_context._MISSING", - "kind": "sentinel" + "kind": "required" }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_name" + "init": true, + "name": "text" }, { "default": { - "identity": "agents.tool_context._MISSING", - "kind": "sentinel" + "kind": "required" }, - "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_call_id" - }, + "init": true, + "name": "settings" + } + ], + "kind": "class", + "members": {}, + "parameters": [ { "default": { - "identity": "agents.tool_context._MISSING", - "kind": "sentinel" + "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_arguments" + "name": "text" }, { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, "kind": "POSITIONAL_OR_KEYWORD", - "name": "tool_call" - }, + "name": "settings" + } + ] + }, + "agents.voice.testing.TTSResult": { + "dataclass_fields": [ { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "factory": "builtins.tuple", + "kind": "factory" }, - "kind": "KEYWORD_ONLY", - "name": "tool_namespace" - }, + "init": true, + "name": "chunks" + } + ], + "kind": "class", + "members": {}, + "parameters": [ { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "factory" }, - "kind": "KEYWORD_ONLY", - "name": "agent" - }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "chunks" + } + ] + }, + "agents.voice.testing.UnconsumedVoiceSteps": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, - "kind": "KEYWORD_ONLY", - "name": "run_config" + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" }, { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, "kind": "KEYWORD_ONLY", - "name": "turn_input" - }, + "name": "remaining_steps" + } + ] + }, + "agents.voice.testing.UnexpectedVoiceCall": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, - "kind": "KEYWORD_ONLY", - "name": "_approvals" + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" }, { "default": { - "kind": "literal", - "type": "builtins.NoneType", - "value": null + "kind": "required" }, "kind": "KEYWORD_ONLY", - "name": "tool_input" + "name": "operation" + } + ] + }, + "agents.voice.testing.pcm16_samples": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "samples" } ] }, @@ -39703,6 +43556,120 @@ "canonical_name": "VercelSandboxClientOptions", "module": "agents.extensions.sandbox", "name": "VercelSandboxClientOptions" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "InvalidModelStep", + "module": "agents.testing", + "name": "InvalidModelStep" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelCall", + "module": "agents.testing", + "name": "ModelCall" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelScriptError", + "module": "agents.testing", + "name": "ModelScriptError" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelStep", + "module": "agents.testing", + "name": "ModelStep" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ModelStepSpec", + "module": "agents.testing", + "name": "ModelStepSpec" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "ScriptedModel", + "module": "agents.testing", + "name": "ScriptedModel" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "UnconsumedModelSteps", + "module": "agents.testing", + "name": "UnconsumedModelSteps" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "UnexpectedModelCall", + "module": "agents.testing", + "name": "UnexpectedModelCall" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "assistant_message", + "module": "agents.testing", + "name": "assistant_message" + }, + { + "canonical_module": "agents.testing.model", + "canonical_name": "function_call", + "module": "agents.testing", + "name": "function_call" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "InvalidSandboxStep", + "module": "agents.testing", + "name": "InvalidSandboxStep" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxCall", + "module": "agents.testing", + "name": "SandboxCall" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxCallMatcherError", + "module": "agents.testing", + "name": "SandboxCallMatcherError" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "ScriptedSandboxSession", + "module": "agents.testing", + "name": "ScriptedSandboxSession" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxScriptError", + "module": "agents.testing", + "name": "SandboxScriptError" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "SandboxStepSpec", + "module": "agents.testing", + "name": "SandboxStepSpec" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "UnconsumedSandboxSteps", + "module": "agents.testing", + "name": "UnconsumedSandboxSteps" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "UnexpectedSandboxCall", + "module": "agents.testing", + "name": "UnexpectedSandboxCall" + }, + { + "canonical_module": "agents.testing.sandbox", + "canonical_name": "scripted_sandbox_session", + "module": "agents.testing", + "name": "scripted_sandbox_session" } ], "optional_dependency_unsupported_platforms": { @@ -39780,7 +43747,12 @@ "agents.tool", "agents.tool_context", "agents.tool_guardrails", - "agents.tracing" + "agents.tracing", + "agents.realtime.testing", + "agents.testing", + "agents.testing.model", + "agents.testing.sandbox", + "agents.voice.testing" ], "public_properties": [ { @@ -39852,6 +43824,185 @@ "mount_authority_redacted", "mount_authority_rebound" ] + }, + { + "class_name": "ScriptedModel", + "module": "agents.testing.model", + "names": [ + "calls", + "remaining_steps", + "first_call", + "last_call" + ] + }, + { + "class_name": "ScriptedRealtimeModel", + "module": "agents.realtime.testing", + "names": [ + "listeners", + "connect_calls", + "sent_events", + "remaining_steps" + ] + }, + { + "factory_name": "scripted_sandbox_session", + "module": "agents.testing.sandbox", + "names": [ + "calls", + "remaining_steps" + ] + }, + { + "class_name": "ScriptedSTTModel", + "module": "agents.voice.testing", + "names": [ + "calls", + "session_calls", + "created_sessions" + ] + }, + { + "class_name": "ScriptedTTSModel", + "module": "agents.voice.testing", + "names": [ + "calls" + ] + }, + { + "class_name": "ScriptedVoiceWorkflow", + "module": "agents.voice.testing", + "names": [ + "transcriptions" + ] + }, + { + "class_name": "ScriptedSandboxSession", + "module": "agents.testing", + "names": [ + "calls", + "remaining_steps" + ] + } + ], + "public_typed_dicts": [ + { + "class_name": "ModelStepSpec", + "fields": [ + { + "annotation": "Sequence[TResponseOutputItem]", + "name": "output", + "required": false + }, + { + "annotation": "Usage", + "name": "usage", + "required": false + }, + { + "annotation": "str | None", + "name": "response_id", + "required": false + }, + { + "annotation": "str | None", + "name": "request_id", + "required": false + }, + { + "annotation": "dict[str, Any] | None", + "name": "raw_usage", + "required": false + }, + { + "annotation": "Exception | None", + "name": "error", + "required": false + }, + { + "annotation": "ModelResponder | None", + "name": "responder", + "required": false + }, + { + "annotation": "Sequence[TResponseStreamEvent] | ModelStreamFactory | None", + "name": "stream_events", + "required": false + }, + { + "annotation": "ModelRetryAdvice | None", + "name": "retry_advice", + "required": false + } + ], + "module": "agents.testing.model" + }, + { + "class_name": "SandboxStepSpec", + "fields": [ + { + "annotation": "SandboxMethod", + "name": "method", + "required": false + }, + { + "annotation": "SandboxMatcher", + "name": "match", + "required": false + }, + { + "annotation": "Any", + "name": "result", + "required": false + }, + { + "annotation": "SandboxResponder", + "name": "responder", + "required": false + }, + { + "annotation": "Exception", + "name": "error", + "required": false + } + ], + "module": "agents.testing.sandbox" + }, + { + "class_name": "RealtimeConnectCall", + "fields": [ + { + "annotation": "Required[bool]", + "name": "api_key_provided", + "required": true + }, + { + "annotation": "Required[bool]", + "name": "headers_provided", + "required": true + }, + { + "annotation": "str", + "name": "url", + "required": false + }, + { + "annotation": "RealtimeSessionModelSettings", + "name": "initial_model_settings", + "required": false + }, + { + "annotation": "RealtimePlaybackTracker", + "name": "playback_tracker", + "required": false + }, + { + "annotation": "str", + "name": "call_id", + "required": false + } + ], + "module": "agents.realtime.testing" } ], "required_submodule_exports": { @@ -39953,6 +44104,7 @@ "DEFAULT_RUNLOOP_WORKSPACE_ROOT", "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT", "RunloopAfterIdle", + "RunloopExistingSecret", "RunloopGatewaySpec", "RunloopLaunchParameters", "RunloopMcpSpec", @@ -39983,6 +44135,7 @@ "ModalSandboxSessionState": "modal", "RunloopAfterIdle": "runloop_api_client", "RunloopCloudBucketMountStrategy": "runloop_api_client", + "RunloopExistingSecret": "runloop_api_client", "RunloopGatewaySpec": "runloop_api_client", "RunloopLaunchParameters": "runloop_api_client", "RunloopMcpSpec": "runloop_api_client", @@ -40164,6 +44317,18 @@ "optional_bindings": {}, "optional_exports": {} }, + "agents.realtime.testing": { + "names": [ + "RealtimeConnectCall", + "RealtimeScriptError", + "RealtimeStep", + "ScriptedRealtimeModel", + "UnconsumedRealtimeSteps", + "UnexpectedRealtimeSend" + ], + "optional_bindings": {}, + "optional_exports": {} + }, "agents.responses_websocket_session": { "names": [ "ResponsesWebSocketSession", @@ -40340,6 +44505,62 @@ "optional_bindings": {}, "optional_exports": {} }, + "agents.testing": { + "names": [ + "InvalidModelStep", + "ModelCall", + "ModelScriptError", + "ModelStep", + "ModelStepSpec", + "InvalidSandboxStep", + "SandboxCall", + "SandboxCallMatcherError", + "SandboxScriptError", + "SandboxStepSpec", + "ScriptedSandboxSession", + "ScriptedModel", + "UnconsumedModelSteps", + "UnexpectedModelCall", + "UnconsumedSandboxSteps", + "UnexpectedSandboxCall", + "assistant_message", + "function_call", + "scripted_sandbox_session" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.testing.model": { + "names": [ + "InvalidModelStep", + "ModelCall", + "ModelScriptError", + "ModelStep", + "ModelStepSpec", + "ScriptedModel", + "UnconsumedModelSteps", + "UnexpectedModelCall", + "assistant_message", + "function_call" + ], + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.testing.sandbox": { + "names": [ + "InvalidSandboxStep", + "SandboxCall", + "SandboxCallMatcherError", + "SandboxScriptError", + "SandboxStepSpec", + "ScriptedSandboxSession", + "UnconsumedSandboxSteps", + "UnexpectedSandboxCall", + "scripted_sandbox_session" + ], + "optional_bindings": {}, + "optional_exports": {} + }, "agents.tracing": { "names": [ "add_trace_processor", @@ -40390,6 +44611,37 @@ ], "optional_bindings": {}, "optional_exports": {} + }, + "agents.voice.testing": { + "names": [ + "STTCall", + "STTSessionCall", + "ScriptedSTTModel", + "ScriptedTTSModel", + "ScriptedTranscriptionSession", + "ScriptedVoiceWorkflow", + "TTSCall", + "TTSResult", + "UnconsumedVoiceSteps", + "UnexpectedVoiceCall", + "VoiceScriptError", + "pcm16_samples" + ], + "optional_bindings": { + "STTCall": "numpy", + "STTSessionCall": "numpy", + "ScriptedSTTModel": "numpy", + "ScriptedTTSModel": "numpy", + "ScriptedTranscriptionSession": "numpy", + "ScriptedVoiceWorkflow": "numpy", + "TTSCall": "numpy", + "TTSResult": "numpy", + "UnconsumedVoiceSteps": "numpy", + "UnexpectedVoiceCall": "numpy", + "VoiceScriptError": "numpy", + "pcm16_samples": "numpy" + }, + "optional_exports": {} } }, "required_top_level_exports": [ diff --git a/uv.lock b/uv.lock index b39b877ab3..24689a38d1 100644 --- a/uv.lock +++ b/uv.lock @@ -2472,7 +2472,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.20.0" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 55bb0b19dea88addf712ba150694a4a80d90a764 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 15 Aug 2026 11:56:28 +0900 Subject: [PATCH 323/473] docs: updates for v0.21.0 release (#4381) --- docs/config.md | 24 ++ docs/ref/realtime/testing.md | 3 + docs/ref/testing.md | 3 + docs/ref/testing/model.md | 3 + docs/ref/testing/sandbox.md | 3 + docs/ref/voice/testing.md | 3 + docs/release.md | 13 + docs/testing.md | 571 +++++++++++++++++++++++++++++++++++ mkdocs.yml | 4 + 9 files changed, 627 insertions(+) create mode 100644 docs/ref/realtime/testing.md create mode 100644 docs/ref/testing.md create mode 100644 docs/ref/testing/model.md create mode 100644 docs/ref/testing/sandbox.md create mode 100644 docs/ref/voice/testing.md create mode 100644 docs/testing.md diff --git a/docs/config.md b/docs/config.md index 1d95561082..0a4e122419 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,6 +51,30 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` +### Custom HTTP clients with `openai` v3 + +Version 0.21.0 requires `openai>=3.0.0,<4`. The default OpenAI provider uses HTTPX2, so most applications do not need to configure an HTTP client directly. If your application passes `http_client=` to `AsyncOpenAI`, use HTTPX2 types for the custom client and its transport-facing options: + +```python +import httpx2 +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + +from agents import set_default_openai_client + +http_client = DefaultAsyncHttpx2Client( + timeout=httpx2.Timeout(30.0, connect=5.0), +) +custom_client = AsyncOpenAI( + api_key="...", + http_client=http_client, +) +set_default_openai_client(custom_client) +``` + +The same migration applies to custom transports, authentication, event hooks, mock transports, URLs, requests, responses, and transport exception handling. Use their `httpx2` equivalents. The Agents SDK does not convert arbitrary legacy `httpx` objects to HTTPX2. The OpenAI Python SDK provides a temporary compatibility path for legacy clients when the application installs `httpx` explicitly, but new and migrated code should use HTTPX2. + +This OpenAI client boundary is separate from local MCP transport customization. MCP Python SDK v1 uses its own legacy `httpx` dependency, while MCP Python SDK v2 uses `httpx2`; see [MCP Python SDK v1 and v2](mcp.md#mcp-python-sdk-v1-and-v2). + If you prefer environment-based endpoint configuration, the default OpenAI provider also reads `OPENAI_BASE_URL`. When you enable Responses websocket transport, it also reads `OPENAI_WEBSOCKET_BASE_URL` for the websocket `/responses` endpoint. ```bash diff --git a/docs/ref/realtime/testing.md b/docs/ref/realtime/testing.md new file mode 100644 index 0000000000..7f2154837c --- /dev/null +++ b/docs/ref/realtime/testing.md @@ -0,0 +1,3 @@ +# `Testing` + +::: agents.realtime.testing diff --git a/docs/ref/testing.md b/docs/ref/testing.md new file mode 100644 index 0000000000..56bb07bba8 --- /dev/null +++ b/docs/ref/testing.md @@ -0,0 +1,3 @@ +# `Testing` + +::: agents.testing diff --git a/docs/ref/testing/model.md b/docs/ref/testing/model.md new file mode 100644 index 0000000000..06820d60a0 --- /dev/null +++ b/docs/ref/testing/model.md @@ -0,0 +1,3 @@ +# `Model` + +::: agents.testing.model diff --git a/docs/ref/testing/sandbox.md b/docs/ref/testing/sandbox.md new file mode 100644 index 0000000000..18bbebef81 --- /dev/null +++ b/docs/ref/testing/sandbox.md @@ -0,0 +1,3 @@ +# `Sandbox` + +::: agents.testing.sandbox diff --git a/docs/ref/voice/testing.md b/docs/ref/voice/testing.md new file mode 100644 index 0000000000..4be44d6ab9 --- /dev/null +++ b/docs/ref/voice/testing.md @@ -0,0 +1,3 @@ +# `Testing` + +::: agents.voice.testing diff --git a/docs/release.md b/docs/release.md index 39453dd706..3f15056b2f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,6 +19,19 @@ We will increment `Z` for non-breaking changes: ## Breaking change changelog +### 0.21.0 + +Version 0.21.0 requires `openai` v3 and moves the Agents SDK's OpenAI HTTP integrations to HTTPX2. Applications that use the default OpenAI client do not need to change their client setup, but applications that customize the OpenAI HTTP layer may need to migrate transport-facing code. + +Highlights: + +- The required OpenAI dependency is now `openai>=3.0.0,<4`. A clean core installation uses HTTPX2 and no longer installs legacy `httpx` as a direct dependency. +- The default OpenAI provider, Voice provider, Responses WebSocket support, tracing exporter, and provider retry normalization now use HTTPX2. Their existing Agents SDK public configuration and runtime behavior remain unchanged. +- Applications that pass `http_client=` to `AsyncOpenAI` should migrate custom clients, transports, authentication, event hooks, mock transports, timeout values, URLs, requests, responses, and transport exception handling from `httpx` to `httpx2`. Prefer the OpenAI Python SDK's `DefaultAsyncHttpx2Client` when the application needs the OpenAI client's defaults plus custom HTTP options. See [Custom HTTP clients with `openai` v3](config.md#custom-http-clients-with-openai-v3). +- The Agents SDK does not convert arbitrary legacy HTTPX objects to HTTPX2. The OpenAI Python SDK's temporary legacy-client compatibility path requires an explicit `httpx` installation and should be treated as a migration bridge. +- Local MCP HTTP customization continues to follow the installed MCP package: MCP Python SDK v1 supplies and uses legacy `httpx`, while MCP Python SDK v2 uses `httpx2`. Ordinary MCP connections do not need application changes. See [MCP Python SDK v1 and v2](mcp.md#mcp-python-sdk-v1-and-v2). +- Public provider-neutral testing utilities now cover Agent model, Sandbox session, Realtime session, and Voice pipeline workflows without provider or process dependencies. See [Testing](testing.md) for recipes and guidance on when to keep the real provider adapter or integration boundary. + ### 0.20.0 Version 0.20.0 includes a potentially breaking MCP dependency migration for applications that customize local MCP HTTP transports. It also updates the SDK default model used when an agent or run does not explicitly select one. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000000..57e5064615 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,571 @@ +# Testing + +The SDK provides deterministic, provider-neutral testing utilities for Agent workflows, Sandbox sessions, Realtime sessions, and Voice pipelines. These utilities run in memory, make no model, sandbox-provider, or Realtime API requests, and record the normalized interactions that the SDK owns. The runnable recipes below disable tracing for each run so that the default trace processor does not upload test activity when an OpenAI API key is configured. + +Use them to test orchestration owned by your application and the SDK: tool execution, handoffs, guardrails, retries, streaming, session behavior, Sandbox capabilities, Realtime event handling, and Voice pipeline composition. Use real provider adapters or integration environments for behavior owned by an external model, network protocol, sandbox provider, or audio system. + +## Find the recipe you need + +| I want to... | Use | Go to | +| --- | --- | --- | +| Return a fixed final answer | `ScriptedModel` with `assistant_message()` | [Return a fixed response](#return-a-fixed-response) | +| Exercise a multi-turn tool loop | `function_call()` followed by an assistant response | [Test a tool workflow](#test-a-tool-workflow) | +| Choose a response from the request | `ModelStep.respond()` or a `responder` mapping | [Derive a response from the request](#derive-a-response-from-the-request) | +| Assert what the runner sent to the model | `calls`, `first_call`, or `last_call` | [Inspect model calls](#inspect-model-calls) | +| Test a streamed run | A normal response step, or `ModelStep.stream()` for exact events | [Test streaming](#test-streaming) | +| Test an error or retry decision | `ModelStep.raise_error()` | [Inject model failures](#inject-model-failures) | +| Detect an accidental workflow change | Exact FIFO steps plus `assert_complete()` | [Detect workflow drift](#detect-workflow-drift) | +| Test a `SandboxAgent` without starting a sandbox | `scripted_sandbox_session()` plus `ScriptedModel` | [Test a Sandbox Agent workflow](#test-a-sandbox-agent-workflow) | +| Match Sandbox calls or derive their results | `match` or `responder` on a Sandbox step | [Configure Sandbox steps](#configure-sandbox-steps) | +| Test a Realtime session without opening a connection | `ScriptedRealtimeModel` and `RealtimeStep` | [Test a Realtime session](#test-a-realtime-session) | +| Test a Realtime tool workflow | Emit a `RealtimeModelToolCallEvent` and expect tool output | [Test a Realtime tool workflow](#test-a-realtime-tool-workflow) | +| Test a static or streamed Voice pipeline | `ScriptedSTTModel`, `ScriptedTTSModel`, and a scripted or real workflow | [Test a Voice pipeline](#test-a-voice-pipeline) | +| Test provider serialization or wire payloads | The real provider adapter with a controlled network transport | [Choose the correct boundary](#choose-the-correct-boundary) | + +## Imports + +The testing APIs live next to the runtime boundary they replace: + +| Boundary | Import path | +| --- | --- | +| Agent model and Sandbox workflows | `agents.testing` | +| Realtime model transport | `agents.realtime.testing` | +| Voice STT, TTS, and workflow components | `agents.voice.testing` | + +Testing symbols are intentionally kept out of the top-level `agents` import. + +## Agent workflow recipes + +### Return a fixed response + +Pass one sequence of normalized output items for each expected model call. The output-sequence shorthand receives a deterministic response ID and usage for one request. + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ScriptedModel, assistant_message + + +@pytest.mark.asyncio +async def test_fixed_response() -> None: + model = ScriptedModel( + [[assistant_message("Paris is the capital of France.")]] + ) + agent = Agent(name="Geography assistant", model=model) + + result = await Runner.run( + agent, + "What is the capital of France?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Paris is the capital of France." + assert len(model.calls) == 1 + model.assert_complete() +``` + +Finish deterministic workflow tests with `model.assert_complete()`. It catches the case where the workflow stopped before consuming every configured step. + +### Test a tool workflow + +Script one model response that calls the tool and a second response that produces the final answer. The real SDK tool pipeline runs between those model calls. + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.decorators import tool +from agents.testing import ScriptedModel, assistant_message, function_call + + +@tool +def get_weather(city: str) -> str: + """Return the weather for a city.""" + return f"{city}: sunny" + + +@pytest.mark.asyncio +async def test_tool_workflow() -> None: + model = ScriptedModel( + [ + [function_call("get_weather", {"city": "Tokyo"}, call_id="call_1")], + [assistant_message("It is sunny in Tokyo.")], + ] + ) + agent = Agent(name="Weather assistant", model=model, tools=[get_weather]) + + result = await Runner.run( + agent, + "What is the weather in Tokyo?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "It is sunny in Tokyo." + assert len(model.calls) == 2 + assert model.last_call is not None + assert any( + item.get("type") == "function_call_output" + for item in model.last_call.input + ) + model.assert_complete() +``` + +This pattern covers tool input validation, execution, result conversion, hooks, guardrails, and the next model turn. Calling the Python function directly would bypass those SDK behaviors. + +### Derive a response from the request + +Use `ModelStep.respond()` when a response genuinely depends on the normalized model call or when an assertion belongs at the model boundary. The responder may be synchronous or asynchronous and may return any step shape accepted by `ScriptedModel`. + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ModelCall, ModelStep, ScriptedModel, assistant_message + + +def respond(call: ModelCall): + assert call.streamed is False + assert call.input == [{"content": "Summarize this", "role": "user"}] + return {"output": [assistant_message("Handled the normalized request.")]} + + +@pytest.mark.asyncio +async def test_request_aware_response() -> None: + model = ScriptedModel([ModelStep.respond(respond)]) + agent = Agent(name="Assistant", model=model) + + result = await Runner.run( + agent, + "Summarize this", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Handled the normalized request." + model.assert_complete() +``` + +`ScriptedModel` accepts `ModelStep`, the equivalent dictionary form, `ModelResponse`, a normalized output-item sequence, or an exception. Prefer fixed output sequences when a response does not depend on the call because fixed scripts make unexpected turns easier to diagnose. + +### Inspect model calls + +`ScriptedModel` records each call before it resolves or raises the selected step. + +| Member | Contains | +| --- | --- | +| `calls` | Every `ModelCall` in invocation order | +| `first_call` | The first call, or `None` | +| `last_call` | The most recent call, or `None` | +| `remaining_steps` | The number of configured steps not yet consumed | + +Common assertions include `call.input`, `call.model_settings`, `call.tools`, `call.handoffs`, and `call.streamed`. Mutable request data is snapshotted at the invocation boundary, and each public history accessor returns detached snapshots. Tool, handoff, output-schema, and tracing objects keep their runtime identity. + +Structured `call_index` and `input_index` error fields are zero-based so they directly index `calls[...]` or the supplied step sequence. Human-readable error messages display one-based call or step numbers. + +Use `enqueue()` or `extend()` when one test needs to append model steps incrementally. Create a new `ScriptedModel` for an independent scenario; the utility does not reset consumed steps or call history. + +### Test streaming + +A normal response step supports both `Runner.run()` and `Runner.run_streamed()`. For common assistant messages, reasoning items, function calls, and apply-patch calls, `ScriptedModel` generates normalized start, delta, item-completion, and terminal response events. The terminal response carries the complete output and usage. + +Use `ModelStep.stream()` only when the exact normalized `TResponseStreamEvent` sequence is part of the behavior under test: + +```python +step = ModelStep.stream( + events, + output=[assistant_message("The terminal output used by the runner.")], +) +``` + +`events` may be a fixed sequence or an async factory that receives the recorded `ModelCall`. The optional `output` is the response returned if the same step is used in a non-streaming call. Exact stream events are SDK-normalized events, not Responses API or Chat Completions wire chunks. + +Automatic streaming rejects normalized output-item kinds whose incremental lifecycle is not implemented. Use `ModelStep.stream(...)` for those items instead of relying on a partial event sequence. + +### Inject model failures + +Use `ModelStep.raise_error()` to fail one model call. Optional retry advice belongs to that exact scripted error: + +```python +from agents import ModelRetryAdvice +from agents.testing import ModelStep + + +step = ModelStep.raise_error( + RuntimeError("temporary failure"), + retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"), +) +``` + +The runner's retry policy decides whether advice causes another attempt. Each retry is another model call and consumes the next scripted step. The Python helper accepts a fixed `ModelRetryAdvice` value; use a custom `Model` when retry advice itself must vary dynamically by attempt. + +### Detect workflow drift + +Treat the scripted calls as the expected workflow shape. An extra model request raises `UnexpectedModelCall`; an early exit leaves steps for `assert_complete()` to report. + +When your test framework supports teardown or finalizers, place `assert_complete()` there if you also want unconsumed steps reported after another assertion fails. Do not catch mismatch errors in a normal regression test. + +| Error | Structured fields | Meaning | +| --- | --- | --- | +| `InvalidModelStep` | `reason`, `input_index` | A step is malformed and is rejected before entering the queue | +| `UnexpectedModelCall` | `call`, `call_index` | The workflow made another model call after the script ended | +| `UnconsumedModelSteps` | `remaining_steps` | The workflow ended before using every step | + +## Sandbox Agent recipes + +### Test a Sandbox Agent workflow + +Combine `ScriptedModel` with `scripted_sandbox_session()` to exercise the real `SandboxAgent` runtime without creating a local container or remote sandbox. The model script chooses a capability tool, while the Sandbox script defines what the corresponding `SandboxSession` method returns. + +```python +import pytest + +from agents import RunConfig, Runner +from agents.sandbox import ExecResult, SandboxAgent +from agents.sandbox.capabilities import Shell +from agents.testing import ( + ScriptedModel, + assistant_message, + function_call, + scripted_sandbox_session, +) + + +@pytest.mark.asyncio +async def test_sandbox_workflow() -> None: + sandbox = scripted_sandbox_session( + [ + { + "method": "exec", + "match": lambda call: call.args == ("pwd",), + "result": ExecResult( + stdout=b"/workspace\n", + stderr=b"", + exit_code=0, + ), + } + ] + ) + model = ScriptedModel( + [ + [function_call("exec_command", {"cmd": "pwd"}, call_id="call_1")], + [assistant_message("The workspace is /workspace.")], + ] + ) + agent = SandboxAgent( + name="Workspace assistant", + model=model, + capabilities=[Shell()], + ) + + async with sandbox: + result = await Runner.run( + agent, + "Which directory are you in?", + run_config=RunConfig( + sandbox={"session": sandbox}, + tracing_disabled=True, + ), + ) + + assert result.final_output == "The workspace is /workspace." + assert [call.method for call in sandbox.calls] == ["exec"] + sandbox.assert_complete() + model.assert_complete() +``` + +This test crosses two normalized SDK boundaries. It covers tool argument validation, capability routing, Sandbox session invocation, delivery of the tool result to the next model turn, and final output handling. It does not test whether a real model chooses the command or how a real sandbox provider executes it. + +### Configure Sandbox steps + +Each matching Sandbox call consumes the next step in one global FIFO sequence. A method mismatch, matcher rejection, or matcher exception leaves that step pending. Set `method`, choose exactly one outcome, and add `match` only when the call details matter. + +| Step member | Use it when... | +| --- | --- | +| `result` | The method should return a fixed typed value | +| `responder` | The result depends on the detached `SandboxCall` | +| `error` | The method should raise a specific exception | +| `match` | The call should be rejected before producing its outcome unless the matcher returns a value other than `False` | + +The supported scripted method names are `apply_patch`, `exec`, `ls`, `mkdir`, `pty_exec_start`, `pty_write_stdin`, `read`, `rm`, and `write`. Only configured model-facing capabilities are exposed. The two PTY methods are exposed together when either PTY method is configured because they form one interactive-shell capability, but calls still consume the global FIFO script. + +`sandbox.calls` contains detached `SandboxCall` snapshots with zero-based `call_index`, `method`, positional `args`, and read-only `kwargs`. Static results are also snapshotted when the script is created. `io.BytesIO` and `io.StringIO` values are supported; use a custom Sandbox session for other live stream objects or lifecycle behavior. + +| Error | Structured fields | Meaning | +| --- | --- | --- | +| `InvalidSandboxStep` | `reason`, `input_index`, `method` | A step is malformed or names an unsupported method | +| `UnexpectedSandboxCall` | `call`, `call_index`, `actual_method`, `expected_method`, `remaining_steps` | The workflow called the wrong method or continued after the script ended | +| `SandboxCallMatcherError` | `call`, `call_index`, `method` | A step matcher returned `False` | +| `UnconsumedSandboxSteps` | `remaining_steps`, `pending_methods` | The workflow ended before using every step | + +The returned object is the session itself. Pass it directly to `RunConfig(sandbox={"session": sandbox})`; there is no wrapper `.session` attribute. + +## Realtime recipes + +### Test a Realtime session + +`ScriptedRealtimeModel` implements the Python SDK's normalized `RealtimeModel` boundary. Each `RealtimeStep` matches one outbound `RealtimeModelSendEvent` and then emits normalized inbound `RealtimeModelEvent` objects or raises an injected error. + +```python +import pytest + +from agents.realtime import ( + RealtimeAgent, + RealtimeModelOutputTextDeltaEvent, + RealtimeModelSendUserInput, + RealtimeRawModelEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@pytest.mark.asyncio +async def test_realtime_message() -> None: + reply = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="Hello!", + response_id="response_1", + ) + model = ScriptedRealtimeModel( + [ + RealtimeStep( + expect=RealtimeModelSendUserInput(user_input="Hello"), + emit=[reply], + ) + ] + ) + runner = RealtimeRunner( + RealtimeAgent(name="Assistant"), + model=model, + config={"tracing_disabled": True}, + ) + + observed_reply = False + async with await runner.run() as session: + await session.send_message("Hello") + async for event in session: + if isinstance(event, RealtimeRawModelEvent) and event.data == reply: + observed_reply = True + break + + assert observed_reply + assert model.sent_events == (RealtimeModelSendUserInput(user_input="Hello"),) + assert model.closed is True + model.assert_complete() +``` + +An expectation may be an exact event value, an event class matched with `isinstance`, or a callable that receives the outbound event and returns `True` for a match. Strict mode is enabled by default. With `strict=False`, unrelated outbound events are recorded but do not consume a pending step; this is useful when a session emits incidental events that are outside the behavior under test. + +Use `connect_events` to emit inbound events during connection. Use `connect_error` or `close_error` for lifecycle failures, and use `RealtimeStep(error=...)` for a failure tied to one matched send. A step cannot define both `emit` and `error`. + +### Test a Realtime tool workflow + +Attach a real function tool to `RealtimeAgent`, emit a normalized tool call, and expect the SDK to send the tool output through the model boundary. Setting `async_tool_calls` to `False` makes this small example complete during connection without test-specific waiting machinery. + +```python +import pytest + +from agents.decorators import tool +from agents.realtime import ( + RealtimeAgent, + RealtimeModelSendToolOutput, + RealtimeModelToolCallEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@tool +def lookup_order(order_id: str) -> str: + """Look up an order by ID.""" + return f"Order {order_id} has shipped." + + +@pytest.mark.asyncio +async def test_realtime_tool_workflow() -> None: + tool_call = RealtimeModelToolCallEvent( + name="lookup_order", + call_id="call_1", + arguments='{"order_id":"order_123"}', + ) + + def matches_tool_output(event) -> bool: + return ( + isinstance(event, RealtimeModelSendToolOutput) + and event.tool_call.call_id == "call_1" + and event.output == "Order order_123 has shipped." + ) + + model = ScriptedRealtimeModel( + [RealtimeStep(expect=matches_tool_output)], + connect_events=[tool_call], + ) + agent = RealtimeAgent( + name="Order assistant", + tools=[lookup_order], + ) + runner = RealtimeRunner( + agent, + model=model, + config={"async_tool_calls": False, "tracing_disabled": True}, + ) + + async with await runner.run(): + pass + + model.assert_complete() +``` + +This exercises the real Realtime tool lookup, argument validation, execution, and output routing. It does not prove that a real model will choose the tool. + +### Inspect Realtime calls and lifecycle + +| Member | Contains | +| --- | --- | +| `connect_calls` | Credential-free, detached connection snapshots | +| `sent_events` | Detached outbound event snapshots in invocation order | +| `remaining_steps` | Expected outbound sends that remain | +| `listeners` | Currently registered listener objects | +| `connected`, `closed`, `close_calls` | Current in-memory lifecycle state | + +Connection history records only whether API-key or header fields were supplied; it never stores their values. URL snapshots remove user information, query parameters, and fragments. Mutable event data and settings are detached, while live SDK objects such as tools, handoffs, and playback trackers preserve identity. + +Finish with `model.assert_complete()` and let the `RealtimeSession` async context manager close the model. The Python utility intentionally does not provide pending expectation promises, implicit timeouts, or a separate `assert_closed()` helper. + +| Error | Structured fields | Meaning | +| --- | --- | --- | +| `UnexpectedRealtimeSend` | `actual`, `expected` | A strict outbound send did not match the next step, or no step remained | +| `UnconsumedRealtimeSteps` | `remaining_steps` | The session ended before using every expected send | +| `RealtimeScriptError` | none | The script was used in an invalid lifecycle state, such as sending while disconnected | + +## Voice pipeline recipes + +### Test a Voice pipeline + +Combine scripted STT and TTS models with `SingleAgentVoiceWorkflow` and an Agent backed by `ScriptedModel` to test the full speech-to-text -> Agent -> text-to-speech pipeline without provider requests. + +```python +import numpy as np +import pytest + +from agents import Agent +from agents.testing import ScriptedModel, assistant_message +from agents.voice import AudioInput, SingleAgentVoiceWorkflow, VoicePipeline +from agents.voice.testing import ( + ScriptedSTTModel, + ScriptedTTSModel, + TTSResult, + pcm16_samples, +) + + +@pytest.mark.asyncio +async def test_voice_pipeline() -> None: + model = ScriptedModel([[assistant_message("Hello there.")]]) + stt = ScriptedSTTModel("hello") + pcm = pcm16_samples([0, 100, -100, 0]) + tts = ScriptedTTSModel([TTSResult([pcm])]) + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow( + Agent(name="Voice assistant", model=model) + ), + stt_model=stt, + tts_model=tts, + config={"tracing_disabled": True, "tts_settings": {"buffer_size": 1}}, + ) + + result = await pipeline.run(AudioInput(np.zeros(2, dtype=np.int16))) + events = [event async for event in result.stream()] + + assert events + assert [call.text for call in tts.calls] == ["Hello there."] + stt.assert_complete() + tts.assert_complete() + model.assert_complete() +``` + +Use `ScriptedVoiceWorkflow` instead when the pipeline's STT/TTS lifecycle is under test but Agent orchestration is not: + +```python +from agents.voice.testing import ScriptedVoiceWorkflow + + +workflow = ScriptedVoiceWorkflow( + turns=["Hello there."], + start="Welcome.", +) +``` + +The `start` step is consumed by `on_start()`. `VoicePipeline` calls `on_start()` only for `StreamedAudioInput`; a static `AudioInput` run does not consume `start`. Each normal turn records its transcription and consumes one configured result. A string is one fragment; a sequence of strings controls fragment boundaries before text splitting and TTS. + +### Test streamed transcription + +`ScriptedSTTModel` accepts static `transcriptions` and independently scripted streamed `sessions`. A session may be a `ScriptedTranscriptionSession`, a sequence of transcription turns, an exception, or a single string: + +```python +from agents.voice.testing import ScriptedSTTModel, ScriptedTranscriptionSession + + +session = ScriptedTranscriptionSession(["first turn", "second turn"]) +stt = ScriptedSTTModel(sessions=[session]) +``` + +Closing `ScriptedTranscriptionSession` stops iteration and leaves skipped turns for `assert_complete()` to report. `ScriptedTTSModel` similarly consumes one `TTSResult`, byte-chunk sequence, or exception per call. + +### Inspect Voice calls + +| Component | Recorded history | +| --- | --- | +| `ScriptedSTTModel` | `calls`, `session_calls`, and live `created_sessions` identities | +| `ScriptedTTSModel` | `calls` containing text and detached settings | +| `ScriptedVoiceWorkflow` | `transcriptions` in turn order | + +Static audio buffers and mutable settings are snapshotted at invocation time. A `StreamedAudioInput` and created transcription-session objects keep their live identity because the pipeline continues to use them. + +| Error | Structured fields | Meaning | +| --- | --- | --- | +| `UnexpectedVoiceCall` | `operation` | A static transcription, streamed session, TTS call, workflow start, or workflow turn had no configured step | +| `UnconsumedVoiceSteps` | `remaining_steps` | One or more configured Voice steps remain | + +Call `assert_complete()` on every scripted Voice component that the test configures. `ScriptedSTTModel.assert_complete()` also checks turns in the transcription sessions that it created. + +## Choose the correct boundary + +Use `ScriptedModel` when a test should exercise the SDK run loop, tools, handoffs, guardrails, sessions, retries, or normalized streaming without depending on a model provider. + +Use `scripted_sandbox_session()` with `ScriptedModel` when a test should exercise `SandboxAgent` capabilities and orchestration without starting a sandbox provider. Keep provider creation, process execution, filesystem fidelity, persistence, resource limits, and isolation checks in integration tests against the real sandbox provider. + +Use `ScriptedRealtimeModel` when a test should exercise `RealtimeSession` behavior or `RealtimeAgent` tool and handoff orchestration without opening a WebSocket connection. Keep raw Realtime client/server events, authentication, network recovery, and audio transport behavior on the real transport or in an integration environment. Realtime API sessions keep a connection open while the client sends input and receives events, so those network and protocol concerns belong below the normalized model boundary. See the [OpenAI Realtime API guide](https://developers.openai.com/api/docs/guides/realtime) for production connection architectures. + +Use the Voice testing components when a test should exercise STT/TTS ordering, streamed transcription cleanup, workflow fragment delivery, or complete Voice pipeline composition without speech providers. Use real audio models and representative audio when transcription quality, generated speech, encoding compatibility, latency, or playback is the subject of the test. + +Do not use these utilities to test Responses API or Chat Completions request serialization, authentication headers, provider defaults, HTTP payloads, provider stream chunks, Realtime wire frames, or provider-specific lifecycle behavior. Keep the real adapter and replace or control its network boundary for those tests. With `openai` v3, OpenAI adapter tests should use `httpx2` request, response, transport, and exception types; legacy `httpx` is not a core dependency of the Agents SDK. + +## Final checklist + +- Script only interactions owned by the normalized model, Sandbox session, Realtime model, or Voice pipeline boundary. +- Assert important public request or call fields instead of private runner state. +- Prefer fixed response steps; use responders only for request-dependent behavior. +- Prefer automatic model streaming; use exact streams only when event-level behavior matters. +- End each scripted component test with its `assert_complete()` method. +- Use async context managers for Realtime and Sandbox lifecycle cleanup when the surrounding test owns that lifecycle. +- Assert structured error fields instead of parsing human-readable messages. +- Keep provider wire tests on real adapters with controlled network transports. + +## Scope and current limitations + +The testing modules deliberately do not provide: + +- Convenience builders for every normalized model output item. Use `assistant_message()` and `function_call()` for common cases, and pass other normalized items directly. +- A provider-protocol simulator. Exact model streams use normalized SDK events rather than Responses API or Chat Completions wire chunks. +- A high-level simulated Realtime server. Tests explicitly match normalized outbound sends and emit the normalized inbound events required by the scenario. +- Unordered Sandbox or Realtime expectations. Both utilities consume expected steps in one global order. +- Test-runner-specific matchers, fixtures, implicit timeouts, or automatic teardown. +- Reset APIs. `ScriptedModel` supports `enqueue()` and `extend()` for an incremental script, but create a new scripted component for an independent scenario. + +Use a custom implementation of the corresponding public interface when a test requires malformed streams, controlled suspension or concurrency, exact cancellation, or a lifecycle boundary that the scripted utilities cannot preserve. Document that specialized boundary in the test. + +## API reference + +- [`agents.testing`](ref/testing.md) +- [`agents.realtime.testing`](ref/realtime/testing.md) +- [`agents.voice.testing`](ref/voice/testing.md) diff --git a/mkdocs.yml b/mkdocs.yml index 7b94b8f943..53add5b1f9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,7 @@ plugins: - Configuration: config.md - Documentation: - Agents: agents.md + - Testing: testing.md - Sandbox agents: - Quickstart: sandbox_agents.md - Concepts: sandbox/guide.md @@ -97,6 +98,7 @@ plugins: - Runner: ref/run.md - Run config: ref/run_config.md - Run state: ref/run_state.md + - Testing: ref/testing.md - Sandbox: - Overview: ref/sandbox.md - SandboxAgent: ref/sandbox/sandbox_agent.md @@ -166,6 +168,7 @@ plugins: - Events: ref/realtime/events.md - Configuration: ref/realtime/config.md - Model: ref/realtime/model.md + - Testing: ref/realtime/testing.md - Voice: - Pipeline: ref/voice/pipeline.md - Workflow: ref/voice/workflow.md @@ -179,6 +182,7 @@ plugins: - OpenAI voice model provider: ref/voice/models/openai_provider.md - OpenAI STT: ref/voice/models/openai_stt.md - OpenAI TTS: ref/voice/models/openai_tts.md + - Testing: ref/voice/testing.md - Extensions: - Handoff filters: ref/extensions/handoff_filters.md - Handoff prompt: ref/extensions/handoff_prompt.md From 56783dd2a6a9c40d834822d1167d7b443f9f7526 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 15 Aug 2026 12:09:25 +0900 Subject: [PATCH 324/473] docs: update translated pages --- docs/ja/config.md | 86 ++++--- docs/ja/release.md | 143 ++++++----- docs/ja/testing.md | 575 +++++++++++++++++++++++++++++++++++++++++++++ docs/ko/config.md | 84 ++++--- docs/ko/release.md | 155 ++++++------ docs/ko/testing.md | 575 +++++++++++++++++++++++++++++++++++++++++++++ docs/zh/config.md | 88 ++++--- docs/zh/release.md | 163 +++++++------ docs/zh/testing.md | 575 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 2140 insertions(+), 304 deletions(-) create mode 100644 docs/ja/testing.md create mode 100644 docs/ko/testing.md create mode 100644 docs/zh/testing.md diff --git a/docs/ja/config.md b/docs/ja/config.md index ceb2beab36..fd02414f10 100644 --- a/docs/ja/config.md +++ b/docs/ja/config.md @@ -2,23 +2,23 @@ search: exclude: true --- -# 構成 +# 設定 -このページでは、デフォルトのOpenAIキーやクライアント、デフォルトのOpenAI API 形式、トレーシングのエクスポートに関するデフォルト設定、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 +このページでは、デフォルトの OpenAI キーやクライアント、デフォルトの OpenAI API 形式、トレーシングのエクスポートに関するデフォルト設定、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 -これらのデフォルトはサンドボックスベースのワークフローにも適用されますが、サンドボックスワークスペース、サンドボックスクライアント、セッションの再利用は個別に構成します。 +これらのデフォルトはサンドボックスベースのワークフローにも適用されますが、サンドボックスワークスペース、サンドボックスクライアント、セッションの再利用は個別に設定します。 -特定のエージェントや実行を構成する必要がある場合は、次のページから確認してください。 +特定のエージェントまたは実行を設定する必要がある場合は、以下を参照してください。 - 通常の `Agent` における instructions、ツール、出力型、ハンドオフ、ガードレールについては、[エージェント](agents.md)を参照してください。 - `RunConfig`、セッション、会話状態のオプションについては、[エージェントの実行](running_agents.md)を参照してください。 - `SandboxRunConfig`、マニフェスト、ケイパビリティ、サンドボックスクライアント固有のワークスペース設定については、[サンドボックスエージェント](sandbox/guide.md)を参照してください。 -- モデルの選択とプロバイダーの構成については、[モデル](models/index.md)を参照してください。 +- モデルの選択とプロバイダー設定については、[モデル](models/index.md)を参照してください。 - 実行ごとのトレーシングメタデータとカスタムトレースプロセッサーについては、[トレーシング](tracing.md)を参照してください。 -## 構成オブジェクトと辞書 +## 設定オブジェクトと辞書 -SDK で定義された構成パラメーターは、通常、型付き設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け付けます。これは、型アノテーションに辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各構成境界に適用されます。SDK で定義されたネストされた設定型でも、辞書を使用できます。 +SDK で定義される設定パラメーターは通常、型付きの設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け入れます。これは、型アノテーションに辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各設定境界に適用されます。SDK で定義されたネストされた設定型でも、辞書を使用できます。 ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK は、これらの辞書を対応する設定オブジェクトに正規化します。SDK で定義されたデータクラス構成型に不明なフィールドがあると `TypeError` が発生するため、オプション名の入力ミスを早期に検出できます。特定の境界が辞書を受け付けるかどうかを確認するには、そのパラメーターの型アノテーションまたは API リファレンスを参照してください。 +SDK は、これらの辞書を対応する設定オブジェクトに正規化します。SDK で定義されたデータクラス設定型に不明なフィールドがあると `TypeError` が発生するため、オプション名のスペルミスを早期に検出できます。特定の境界が辞書を受け入れるかどうかを確認するには、そのパラメーターの型アノテーションまたは API リファレンスを確認してください。 ## API キーとクライアント -デフォルトでは、SDK は LLMリクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が最初にOpenAIクライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 +デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が初めて OpenAI クライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -代わりに、使用するOpenAIクライアントを構成することもできます。デフォルトでは、SDK は環境変数の API キーまたは上記で設定したデフォルトキーを使用して、`AsyncOpenAI` インスタンスを作成します。[set_default_openai_client()][agents.set_default_openai_client] 関数を使用すると、この動作を変更できます。 +別の方法として、使用する OpenAI クライアントを設定することもできます。デフォルトでは、SDK は環境変数の API キー、または上記で設定したデフォルトキーを使用して `AsyncOpenAI` インスタンスを作成します。[set_default_openai_client()][agents.set_default_openai_client] 関数を使用すると、これを変更できます。 ```python from openai import AsyncOpenAI @@ -55,14 +55,38 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -環境ベースのエンドポイント構成を使用する場合、デフォルトのOpenAIプロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses の websocket トランスポートを有効にすると、websocket の `/responses` エンドポイント用に `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 +### `openai` v3 のカスタム HTTP クライアント + +バージョン 0.21.0 では `openai>=3.0.0,<4` が必要です。デフォルトの OpenAI プロバイダーは HTTPX2 を使用するため、ほとんどのアプリケーションでは HTTP クライアントを直接設定する必要はありません。アプリケーションから `AsyncOpenAI` に `http_client=` を渡す場合は、カスタムクライアントとそのトランスポート向けオプションに HTTPX2 の型を使用してください。 + +```python +import httpx2 +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + +from agents import set_default_openai_client + +http_client = DefaultAsyncHttpx2Client( + timeout=httpx2.Timeout(30.0, connect=5.0), +) +custom_client = AsyncOpenAI( + api_key="...", + http_client=http_client, +) +set_default_openai_client(custom_client) +``` + +同じ移行が、カスタムトランスポート、認証、イベントフック、モックトランスポート、URL、リクエスト、レスポンス、トランスポート例外処理にも適用されます。それぞれに対応する `httpx2` を使用してください。Agents SDK は、任意の従来の `httpx` オブジェクトを HTTPX2 に変換しません。アプリケーションで `httpx` を明示的にインストールすると、OpenAI Python SDK による従来のクライアント向けの一時的な互換性対応を利用できますが、新規コードおよび移行済みコードでは HTTPX2 を使用してください。 + +この OpenAI クライアント境界は、ローカル MCP トランスポートのカスタマイズとは別です。MCP Python SDK v1 は独自の従来の `httpx` 依存関係を使用し、MCP Python SDK v2 は `httpx2` を使用します。詳細については、[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 + +環境変数に基づくエンドポイント設定を使用する場合、デフォルトの OpenAI プロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses の WebSocket トランスポートを有効にすると、WebSocket の `/responses` エンドポイント用に `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -最後に、使用するOpenAI API をカスタマイズすることもできます。デフォルトでは、OpenAI Responses API を使用します。[set_default_openai_api()][agents.set_default_openai_api] 関数を使用すると、これをオーバーライドして Chat Completions API を使用できます。 +最後に、使用する OpenAI API をカスタマイズすることもできます。デフォルトでは、OpenAI Responses API を使用します。[set_default_openai_api()][agents.set_default_openai_api] 関数を使用すると、これをオーバーライドして Chat Completions API を使用できます。 ```python from agents import set_default_openai_api @@ -70,9 +94,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAIプロバイダーのデフォルト +## OpenAI プロバイダーのデフォルト -SDK のOpenAIバックエンドを使用するプロバイダーは、モデル名の文字列をモデルにマッピングするときに、SDK 全体のデフォルトも読み取ります。OpenAI Responses モデルでデフォルトとして websocket トランスポートを使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 +SDK の OpenAI バックエンドを使用するプロバイダーも、モデル名の文字列をモデルにマッピングするときに SDK 全体のデフォルトを読み取ります。OpenAI Responses モデルでデフォルトとして WebSocket トランスポートを使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 ```python from agents import set_default_openai_responses_transport @@ -80,9 +104,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトのOpenAIプロバイダーがモデル名を解決した結果として得られるOpenAI Responses モデルに影響します。プロバイダーレベルの設定、接続の再利用、キープアライブオプション、カスタム websocket エンドポイントについては、[Responses WebSocket トランスポート](models/index.md#responses-websocket-transport)を参照してください。 +これは、デフォルトの OpenAI プロバイダーがモデル名を解決した結果として得られる OpenAI Responses モデルに影響します。プロバイダーレベルの設定、接続の再利用、キープアライブオプション、カスタム WebSocket エンドポイントについては、[Responses WebSocket トランスポート](models/index.md#responses-websocket-transport)を参照してください。 -OpenAIの設定でプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度構成します。 +OpenAI の設定でプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度設定します。 ```python from agents import set_default_openai_harness @@ -100,11 +124,11 @@ set_default_openai_agent_registration( ) ``` -SDK のデフォルトが設定されていない場合、SDK のOpenAIバックエンドを使用するプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が構成されている場合、`RunConfig.trace_metadata` にそのキーがすでに存在しない限り、SDK はトレースメタデータに `agent_harness_id` として追加します。 +SDK のデフォルトが設定されていない場合、SDK の OpenAI バックエンドを使用するプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、`RunConfig.trace_metadata` にそのキーがすでに存在しない限り、SDK はその ID を `agent_harness_id` としてトレースメタデータに追加します。 ## トレーシング -トレーシングはデフォルトで有効です。デフォルトでは、前のセクションで説明したモデルリクエストと同じOpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。トレーシングに使用する API キーを明示的に設定するには、[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用します。 +トレーシングはデフォルトで有効です。デフォルトでは、上記のセクションで説明したモデルリクエストと同じ OpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用すると、トレーシングに使用する API キーを明示的に設定できます。 ```python from agents import set_tracing_export_api_key @@ -112,7 +136,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -モデルのトラフィックではあるキーまたはクライアントを使用し、トレーシングでは別のOpenAIキーを使用する必要がある場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡してから、トレーシングを個別に構成します。カスタムクライアントを使用していない場合は、[`set_default_openai_key()`][agents.set_default_openai_key] でも同じ方法を使用できます。 +モデルのトラフィックで使用するキーまたはクライアントとは異なる OpenAI キーをトレーシングで使用する場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡してから、トレーシングを個別に設定してください。カスタムクライアントを使用しない場合は、[`set_default_openai_key()`][agents.set_default_openai_key] でも同じパターンを使用できます。 ```python from openai import AsyncOpenAI @@ -127,7 +151,7 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -デフォルトのエクスポーターを使用するときに、トレースを特定の組織またはプロジェクトに関連付ける必要がある場合は、アプリの起動前に次の環境変数を設定します。 +デフォルトのエクスポーターを使用するときに、トレースを特定の組織またはプロジェクトに関連付ける必要がある場合は、アプリの起動前に以下の環境変数を設定します。 ```bash export OPENAI_ORG_ID="org_..." @@ -146,7 +170,7 @@ await Runner.run( ) ``` -[`set_tracing_disabled()`][agents.set_tracing_disabled] 関数を使用すると、トレーシングを完全に無効にすることもできます。 +[`set_tracing_disabled()`][agents.set_tracing_disabled] 関数を使用して、トレーシングを完全に無効にすることもできます。 ```python from agents import set_tracing_disabled @@ -154,7 +178,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -トレーシングを有効にしたまま、機密情報が含まれる可能性のある入力や出力をトレースペイロードから除外するには、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定します。 +トレーシングを有効にしたまま、機密情報が含まれる可能性のある入力や出力をトレースペイロードから除外する場合は、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定します。 ```python from agents import Runner, RunConfig @@ -166,19 +190,19 @@ await Runner.run( ) ``` -アプリの起動前に次の環境変数を設定することで、コードを変更せずにデフォルトを変更することもできます。 +アプリの起動前に以下の環境変数を設定することで、コードを変更せずにデフォルトを変更することもできます。 ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -トレーシングのすべての制御については、[トレーシングガイド](tracing.md)を参照してください。 +トレーシングのすべての制御方法については、[トレーシングガイド](tracing.md)を参照してください。 ## デバッグログ -SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーを追加しません。ログは、アプリケーションの Python ログ構成に従います。 +SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーをアタッチしません。ログは、アプリケーションの Python ロギング設定に従います。 -詳細ログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 +詳細なログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 ```python from agents import enable_verbose_stdout_logging @@ -186,7 +210,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -または、ハンドラー、フィルター、フォーマッターなどを追加してログをカスタマイズできます。詳細については、[Python ログガイド](https://docs.python.org/3/howto/logging.html)を参照してください。 +別の方法として、ハンドラー、フィルター、フォーマッターなどを追加してログをカスタマイズできます。詳細については、[Python ロギングガイド](https://docs.python.org/3/howto/logging.html)を参照してください。 ```python import logging @@ -207,20 +231,20 @@ logger.addHandler(logging.StreamHandler()) ### ログと診断に含まれる機密データ -一部のログや診断例外には、機密データ(モデルまたはツールの入力や出力など)が含まれる場合があります。 +一部のログや診断用例外には、機密データ(モデルまたはツールの入力と出力など)が含まれる場合があります。 -デフォルトでは、SDK は LLMの入力や出力、およびツールの入力や出力をログに記録 **しません**。これらの保護は、次の設定によって制御されます。 +デフォルトでは、SDK は LLM の入力と出力、またはツールの入力と出力を **ログに記録しません** 。これらの保護は以下によって制御されます。 ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -デバッグのためにこのデータを一時的に含める必要がある場合は、アプリの起動前にいずれかの変数を `0`(または `false`)に設定します。 +デバッグのためにこのデータを一時的に含める必要がある場合は、アプリの起動前に、いずれかの変数を `0`(または `false`)に設定します。 ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -これらのフラグは、影響を受ける失敗でペイロードを含む診断情報を保持するかどうかも制御します。たとえば、ツールデータの秘匿化が有効な場合、`FunctionTool` の引数が無効であると、基になる検証エラーを例外チェーンに含めず、汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、未加工のモデルデータやツールデータがログ、例外メッセージ、例外チェーン、その他の診断コンテキストに露出する可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file +これらのフラグは、影響を受ける失敗時に、ペイロードを含む診断の詳細を保持するかどうかも制御します。たとえば、ツールデータの秘匿化が有効な場合、`FunctionTool` に無効な引数を渡すと、基礎となる検証エラーを例外チェーンに含めず、汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、未加工のモデルデータやツールデータが、ログ、例外メッセージ、例外チェーン、その他の診断コンテキストに露出する可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file diff --git a/docs/ja/release.md b/docs/ja/release.md index 44036c0cd6..c62fc7484b 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -4,17 +4,17 @@ search: --- # リリースプロセス/変更履歴 -このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングを一部変更した方式に従います。先頭の `0` は、SDK がまだ急速に進化していることを示します。各構成要素は次のように更新します。 +このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングを一部変更した方式に従います。先頭の `0` は、SDK がまだ急速に進化していることを示します。各要素は次のように更新します。 ## マイナー(`Y`)バージョン -ベータと明記されていない公開インターフェースに **破壊的変更** を加える場合、マイナーバージョン `Y` を増やします。たとえば、`0.0.x` から `0.1.x` への更新には、破壊的変更が含まれる可能性があります。 +ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を上げます。たとえば、`0.0.x` から `0.1.x` への更新には、破壊的変更が含まれる可能性があります。 -破壊的変更を避けるには、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 +破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 ## パッチ(`Z`)バージョン -破壊的変更ではない変更の場合、`Z` を増やします。 +破壊的でない変更については、`Z` を上げます。 - バグ修正 - 新機能 @@ -23,47 +23,60 @@ search: ## 破壊的変更の変更履歴 +### 0.21.0 + +バージョン 0.21.0 では `openai` v3 が必要となり、Agents SDK の OpenAI HTTP 統合が HTTPX2 に移行します。デフォルトの OpenAI クライアントを使用するアプリケーションではクライアント設定を変更する必要はありませんが、OpenAI HTTP レイヤーをカスタマイズしているアプリケーションでは、トランスポート関連コードの移行が必要になる場合があります。 + +主な変更点: + +- 必須の OpenAI 依存関係は `openai>=3.0.0,<4` になりました。クリーンなコアインストールでは HTTPX2 が使用され、従来の `httpx` は直接の依存関係としてインストールされなくなりました。 +- デフォルトの OpenAI プロバイダー、音声プロバイダー、Responses WebSocket 対応、トレーシングエクスポーター、プロバイダー再試行の正規化で、HTTPX2 が使用されるようになりました。既存の Agents SDK の公開設定と実行時動作に変更はありません。 +- `AsyncOpenAI` に `http_client=` を渡すアプリケーションでは、カスタムクライアント、トランスポート、認証、イベントフック、モックトランスポート、タイムアウト値、URL、リクエスト、レスポンス、トランスポート例外処理を `httpx` から `httpx2` に移行する必要があります。OpenAI クライアントのデフォルト設定に加えてカスタム HTTP オプションが必要な場合は、OpenAI Python SDK の `DefaultAsyncHttpx2Client` を推奨します。[`openai` v3 でのカスタム HTTP クライアント](config.md#custom-http-clients-with-openai-v3)を参照してください。 +- Agents SDK は、任意の従来型 HTTPX オブジェクトを HTTPX2 に変換しません。OpenAI Python SDK の一時的な従来型クライアント互換パスには、`httpx` の明示的なインストールが必要であり、移行用の橋渡しとして扱う必要があります。 +- ローカル MCP の HTTP カスタマイズは、引き続きインストール済みの MCP パッケージに従います。MCP Python SDK v1 は従来の `httpx` を提供して使用し、MCP Python SDK v2 は `httpx2` を使用します。通常の MCP 接続では、アプリケーションを変更する必要はありません。[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 +- 公開されたプロバイダー非依存のテストユーティリティで、プロバイダーやプロセスへの依存なしに、エージェントモデル、サンドボックスセッション、Realtime セッション、音声パイプラインのワークフローを扱えるようになりました。レシピ、および実際のプロバイダーアダプターや統合境界を維持すべき場合のガイダンスについては、[テスト](testing.md)を参照してください。 + ### 0.20.0 -バージョン 0.20.0 には、ローカル MCP HTTP トランスポートをカスタマイズするアプリケーションに影響する可能性がある、破壊的な MCP 依存関係の移行が含まれます。また、エージェントまたは実行でモデルが明示的に選択されていない場合に使用される SDK のデフォルトモデルも更新されます。 +バージョン 0.20.0 には、ローカル MCP HTTP トランスポートをカスタマイズするアプリケーションにとって、破壊的変更となる可能性がある MCP 依存関係の移行が含まれます。また、エージェントまたは実行でモデルを明示的に選択しない場合に使用される SDK のデフォルトモデルも更新されます。 主な変更点: -- SDK のデフォルトモデルは、`gpt-5.4-mini` ではなく `gpt-5.6-luna` になりました。デフォルトの `reasoning.effort="none"` および `verbosity="low"` の設定に変更はありません。 -- エージェントに明示的に指定されたモデル、実行レベルのモデルオーバーライド、および `OPENAI_DEFAULT_MODEL` 環境変数は、引き続き SDK のデフォルトより優先されます。 -- Realtime 入力文字起こし設定で、`gpt-transcribe`、`gpt-live-transcribe`、`gpt-realtime-whisper` が認識されるようになりました。低レイテンシーの `gpt-live-transcribe` セッションでは、ネストされた `audio.input.transcription` 設定から `prompt`、`keywords`、および期待される複数の `languages` を指定できます。この SDK が固定している OpenAI クライアントのバージョンでは、`delay` のレイテンシー/精度レベルは `gpt-realtime-whisper` でのみサポートされます。確定済みの音声ターン後に文字起こしを行う場合、または検出された言語を出力する場合は、WebSocket 経由で `gpt-transcribe` を使用してください。`audio.input.turn_detection=None` を明示的に設定すると、ターンの自動検出が無効になります。[入力文字起こし設定](realtime/guide.md#input-transcription-settings)を参照してください。 -- Agents SDK によって作成されるローカル MCP 接続は、`mcp>=1.19.0,<3` を通じて v1 との互換性を維持しながら、MCP Python SDK v2 をサポートするようになりました。Agents SDK は、通常の stdio、SSE、Streamable HTTP 接続を自動的に適応させます。MCP v2 がインストールされている場合、これらの接続では `mcp.Client(mode="auto")` を使用してサポートされている最新のプロトコルを確認し、古いサーバーでは従来の `initialize` ハンドシェイクにフォールバックします。依存関係の解決で MCP v2 が選択された場合、カスタムの `httpx.Auth` オブジェクトまたは `httpx.AsyncClient` ファクトリーを指定するアプリケーションでは、それらの値を `httpx2` に移行するか、v1 HTTP スタックを維持するために `mcp<2` を固定する必要があります。`MCPServerStreamableHttp` の `params["ignore_initialized_notification_failure"] = True` オプションも、引き続き v1 専用です。移行の詳細については、[MCP Python SDK v1 と v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 -- サンドボックスのマウント検証では、サンドボックスまたはマウントヘルパーによる副作用が発生する前に、安全でない認証情報の配置を拒否するようになりました。信頼できるアプリケーションは、ストレージ機能テーブルを変更することなく、コンテナー内の正確なマウントパスに対するマウントスコープまたは広範な認証情報の露出を承認できます。これらの承認は実行時にのみ有効であり、シリアライズされたサンドボックス状態だけで認証情報への権限が付与されることはありません。保護されたマウント境界では、SDK は新たに生成された秘匿化済みの例外を返します。発生元の例外が、正確に認識された SDK のサンドボックスエラーであり、承認済みの構造化フィールドが検証に合格した場合、置換後の例外ではそのサブタイプと検証済みの安全なフィールドが保持されます。認識された `MountConfigError` では、SDK が生成した安全な検証メッセージも保持できます。それ以外の場合、SDK は新たに生成された汎用の秘匿化済みエラーを返します。プロバイダーが制御する、またはその他の理由で承認されていないメッセージ、コマンドデータ、注記、コンテキスト、原因、および発生元のトレースバック状態は保持されません。[マウントとリモートストレージ](sandbox/clients.md#mounts-and-remote-storage)および[セッション状態からの再開](sandbox/guide.md#resume-from-session-state)を参照してください。 -- 再試行ポリシーでは、安定したリプレイ安全性情報を確認し、プロバイダーが安全でないと判断した非ストリーミングリクエストに対して `RetryDecision(approve_unsafe_replay=True)` を明示的に設定できます。この承認によって、中止、すでに出力されたストリーミング結果、または Programmatic Tool Calling などのローカル側の副作用に対する個別の拒否が回避されることはありません。[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 -- 再開可能な `RunState` オブジェクトでは、次回のモデル呼び出し前に、`add_input()` を使用して永続的なユーザー入力をステージングできるようになりました。ステージングされた入力はシリアライズ後も維持され、入力ガードレールを通過し、ローカルセッションとサーバー管理の会話にわたって、永続的な SDK 入力を 1 回だけ生成します。安全でないリプレイが明示的に承認されている場合でも、入力がプロバイダーに再送信され、プロバイダー側の処理が繰り返される可能性があります。[再開前の入力追加](results.md#add-input-before-resuming)を参照してください。 -- 実行時の信頼性修正により、ストリーミング実行と非ストリーミング実行で[出力ガードレールのセッション永続化](guardrails.md#output-guardrails)の動作が統一され、コピーおよび名前空間の適用中も `FunctionTool` のサブクラスが保持されるようになりました。また、[サポートされていない Chat Completions の音声出力](models/index.md#chat-completions-compatibility-options)では、空のストリームを暗黙的に完了する代わりに、明示的なエラーが発生するようになりました。`OpenAIResponsesCompactionSession` ラッパーは、キャンセルが呼び出し元に伝わる前に、[コンパクション前の履歴復旧](sessions/index.md#auto-compaction-can-block-streaming)を試行して完了を待ちます。[`VoicePipeline`](voice/pipeline.md#results) のコンシューマーは、正常な実行後に文字起こしセッションのクローズに失敗した場合、その失敗を受け取るようになりました。一方、先行するターンの失敗は、後から発生したクローズの失敗より優先されます。`RunState` の往復変換では、ローカルシェルの出力、承認済みのコンピューター安全性チェック、デフォルト値が設定されたツール出力フィールド、および辞書、リスト、タプルの走査中に検出された Pydantic モデルまたは dataclass の出力が保持されるようになりました。MCP 変換では、自由形式のオブジェクトスキーマと画像出力が保持され、音声ブロックやリソースブロックなど、その他の raw コンテンツブロックは有効な JSON テキストとしてシリアライズされます。`MCPServerManager` は重複するライフサイクル操作を順番に実行し、接続とクリーンアップに有限のデフォルトタイムアウトを適用します。モデルのリプレイでは、出力項目を入力として使用する前に、サーバー所有の `created_by` メタデータが削除されます。 +- SDK のデフォルトモデルは、`gpt-5.4-mini` ではなく `gpt-5.6-luna` になりました。デフォルトの `reasoning.effort="none"` および `verbosity="low"` 設定に変更はありません。 +- エージェントで明示的に指定したモデル、実行レベルのモデルオーバーライド、および `OPENAI_DEFAULT_MODEL` 環境変数は、引き続き SDK のデフォルトより優先されます。 +- Realtime 入力文字起こし設定で、`gpt-transcribe`、`gpt-live-transcribe`、`gpt-realtime-whisper` が認識されるようになりました。低レイテンシーの `gpt-live-transcribe` セッションでは、ネストされた `audio.input.transcription` 設定で `prompt`、`keywords`、および複数の想定される `languages` を指定できます。この SDK が固定している OpenAI クライアントバージョンは、`delay` のレイテンシー/精度レベルを `gpt-realtime-whisper` でのみサポートします。確定済みの音声ターン後の文字起こし、または検出言語の出力には、WebSocket 経由で `gpt-transcribe` を使用してください。`audio.input.turn_detection=None` を明示的に設定すると、自動ターン検出が無効になります。[入力文字起こし設定](realtime/guide.md#input-transcription-settings)を参照してください。 +- Agents SDK によって作成されるローカル MCP 接続は、`mcp>=1.19.0,<3` を通じて v1 互換性を維持しながら、MCP Python SDK v2 をサポートするようになりました。Agents SDK は、通常の stdio、SSE、Streamable HTTP 接続を自動的に適応させます。MCP v2 がインストールされている場合、これらの接続は `mcp.Client(mode="auto")` を使用してサポート対象の最新プロトコルを検出し、古いサーバーでは従来の `initialize` ハンドシェイクにフォールバックします。依存関係の解決で MCP v2 が選択された場合、カスタム `httpx.Auth` オブジェクトまたは `httpx.AsyncClient` ファクトリーを提供するアプリケーションは、それらの値を `httpx2` に移行する必要があります。あるいは、v1 の HTTP スタックを維持するには `mcp<2` に固定してください。`MCPServerStreamableHttp` の `params["ignore_initialized_notification_failure"] = True` オプションも、引き続き v1 専用です。移行の詳細については、[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 +- サンドボックスのマウント検証では、サンドボックスまたはマウントヘルパーで副作用が発生する前に、安全でない認証情報の配置を拒否するようになりました。信頼できるアプリケーションでは、ストレージ機能テーブルを変更することなく、コンテナー内の正確なマウントパスについて、マウント範囲または広範囲の認証情報公開を承認できます。これらの承認は実行時にのみ有効であり、シリアライズされたサンドボックス状態だけで認証情報への権限が付与されることはありません。保護されたマウント境界では、SDK は新たにリダクトされた例外を返します。元の例外が、SDK で正確に認識されるサンドボックスエラーであり、承認された構造化フィールドが検証に合格した場合、置換後の例外にはそのサブタイプと検証済みの安全なフィールドが保持されます。認識された `MountConfigError` では、SDK が生成した安全な検証メッセージも保持できます。それ以外の場合、SDK は新たに汎用のリダクト済みエラーを返します。プロバイダーが制御するメッセージ、その他の未承認メッセージ、コマンドデータ、注記、コンテキスト、原因、および元のトレースバック状態は保持されません。[マウントとリモートストレージ](sandbox/clients.md#mounts-and-remote-storage)および[セッション状態からの再開](sandbox/guide.md#resume-from-session-state)を参照してください。 +- 再試行ポリシーでは、安定したリプレイ安全性情報を確認し、プロバイダーが安全でないと判断した非ストリーミングリクエストに対して `RetryDecision(approve_unsafe_replay=True)` を明示的に設定できます。この承認によって、中止、送出済みのストリーミング出力、または Programmatic Tool Calling などのローカル側の副作用に対する個別の拒否を回避することはできません。[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 +- 再開可能な `RunState` オブジェクトでは、次回のモデル呼び出し前に `add_input()` を使用して永続的なユーザー入力をステージングできるようになりました。ステージングされた入力はシリアライズ後も保持され、入力ガードレールを通過し、ローカルセッションとサーバー管理の会話全体で永続的な SDK 入力を 1 回生成します。安全でないリプレイを明示的に承認した場合は、引き続き入力がプロバイダーに再送信され、プロバイダー側の処理が繰り返される可能性があります。[再開前の入力追加](results.md#add-input-before-resuming)を参照してください。 +- 実行時の信頼性に関する修正により、ストリーミングと非ストリーミングの[出力ガードレールにおけるセッション永続化](guardrails.md#output-guardrails)の動作が統一され、コピーおよび名前空間設定の際に `FunctionTool` のサブクラスが保持されるようになりました。また、空のストリームを暗黙的に完了する代わりに、[サポートされていない Chat Completions 音声出力](models/index.md#chat-completions-compatibility-options)に対して明示的なエラーが発生するようになりました。`OpenAIResponsesCompactionSession` ラッパーは、キャンセルが呼び出し元に到達する前に、[コンパクション前の履歴復元](sessions/index.md#auto-compaction-can-block-streaming)を試行して完了を待ちます。[`VoicePipeline`](voice/pipeline.md#results) のコンシューマーは、正常な実行後に文字起こしセッションのクローズが失敗した場合、その失敗を受け取るようになりました。一方、先にターンが失敗していた場合は、後から発生したクローズ失敗よりも優先されます。`RunState` のラウンドトリップでは、ローカルシェル出力、承認済みのコンピューター安全性チェック、デフォルト値を持つツール出力フィールド、および辞書、リスト、タプルの走査中に検出された Pydantic モデルまたはデータクラスの出力が保持されるようになりました。MCP 変換では、自由形式のオブジェクトスキーマと画像出力が保持され、音声ブロックやリソースブロックなど、その他の raw コンテンツブロックは有効な JSON テキストとしてシリアライズされます。`MCPServerManager` は、重複するライフサイクル操作を直列化し、接続とクリーンアップに有限のデフォルトタイムアウトを適用します。モデルのリプレイでは、出力項目を入力として使用する前に、サーバーが所有する `created_by` メタデータが削除されます。 ### 0.19.0 -このマイナーリリースでは、破壊的変更は **導入されません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域である Programmatic Tool Calling を反映したものです。 +このマイナーリリースに破壊的変更は **ありません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域である Programmatic Tool Calling を反映したものです。 主な変更点: -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を追加しました。これにより、対応する OpenAI Responses モデルは、Programmatic Tool Calling の対象となるツールを連携させる JavaScript を生成できます。ツール単位の `allowed_callers`、`FunctionTool` インスタンスからの structured outputs、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。設定方法と制約については、[Programmatic Tool Calling](tools.md#programmatic-tool-calling)を参照してください。 -- 公開 `agents.decorators` モジュールと、既存のガードレールデコレーターに加えて、既存の `@function_tool` デコレーターの短いエイリアスである `@tool` を追加しました。`FunctionTool` インスタンスは、非同期の呼び出し可能オブジェクトもサポートするようになりました。 -- SDK の設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け入れるようになり、不明な設定も検証されます。 -- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体でエラーおよび診断ログを強化し、有用なデバッグコンテキストを維持しながら、raw の機密ペイロードが露出しないようにしました。 -- AnyLLM、LiteLLM、Chat Completions との互換性を改善し、モデルの再試行をまたいでセッション履歴が保持されるようにしました。また、レスポンス開始前に発生した WebSocket の過負荷に対するプロバイダー再試行ガイダンスを追加し、オプトインの Runner 再試行ポリシーで、許可されている場合に失敗した試行をリプレイできるようにしました。 -- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)を追加しました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外され、動的なマウント変更やセッションの再開は意図的にサポートされません。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] が追加されました。これにより、対応する OpenAI Responses モデルは JavaScript を生成し、Programmatic Tool Calling の対象となるツールを連携させることができます。ツールごとの `allowed_callers`、`FunctionTool` インスタンスからの structured outputs、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。設定と制約については、[Programmatic Tool Calling](tools.md#programmatic-tool-calling)を参照してください。 +- 公開 `agents.decorators` モジュールと、既存の `@function_tool` デコレーターの短いエイリアスである `@tool` が、既存のガードレールデコレーターとともに追加されました。`FunctionTool` インスタンスでは、非同期の呼び出し可能オブジェクトもサポートされるようになりました。 +- SDK 設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け付けるようになり、未知の設定に対する検証も追加されました。 +- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体で、エラーおよび診断ログが強化され、有用なデバッグコンテキストを維持しながら、raw の機密ペイロードが公開されないようになりました。 +- AnyLLM、LiteLLM、Chat Completions との互換性が向上し、モデルの再試行をまたいでセッション履歴が保持されるようになりました。また、レスポンス開始前に発生する WebSocket 過負荷に対するプロバイダー再試行のガイダンスが追加され、許可されている場合は、オプトインした Runner 再試行ポリシーで失敗した試行をリプレイできるようになりました。 +- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)が追加されました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外され、意図的に動的なマウント変更やセッション再開はサポートされません。 ### 0.18.0 -このマイナーリリースでは、破壊的変更は **導入されません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを反映したものです。 +このマイナーリリースに破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを目的としています。 主な変更点: -- Realtime エージェントはデフォルトモデルとして `gpt-realtime-2.1` を使用するようになったため、新しい Realtime 設定では追加の構成なしで最新の推奨モデルが使用されます。 +- Realtime エージェントでは、デフォルトモデルとして `gpt-realtime-2.1` が使用されるようになり、新しい Realtime 設定では追加設定なしで最新の推奨モデルが使用されます。 ### 0.17.0 -このバージョンでは、サンドボックスのローカルソースの実体化において、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` は実体化の `base_dir` 内に保持されます。`base_dir` は、マニフェストの適用時における SDK プロセスの現在の作業ディレクトリです。相対的なローカルソースはそのディレクトリを基準に解決されます。一方、絶対パスのローカルソースは、すでにそのディレクトリ内に存在するか、明示的な許可の対象である必要があります。これにより、ローカルアーティファクトの境界に関する問題が解消されますが、そのベースディレクトリ外にある信頼済みのホストファイルやディレクトリを、意図的にサンドボックスワークスペースへコピーしているアプリケーションに影響する可能性があります。 +このバージョンでは、サンドボックスのローカルソース実体化において、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` が実体化用の `base_dir` 内に維持されます。`base_dir` は、マニフェスト適用時の SDK プロセスの現在の作業ディレクトリです。相対ローカルソースはそのディレクトリを基準に解決されますが、絶対ローカルソースは、すでにそのディレクトリ内にあるか、明示的な許可の対象である必要があります。これによりローカル成果物の境界に関する問題は解消されますが、そのベースディレクトリ外にある信頼済みのホストファイルまたはディレクトリを、意図的にサンドボックスワークスペースへコピーするアプリケーションには影響する可能性があります。 -移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みのホストルートを許可してください。サンドボックスでそれらのファイルを読み取るだけの場合は、読み取り専用にすることをお勧めします。 +移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 ```python from pathlib import Path @@ -90,11 +103,11 @@ manifest = Manifest( ) ``` -`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスをすでに承認している場合を除き、モデルの出力やその他の信頼できないマニフェスト入力から許可を設定しないでください。 +`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションですでに対象ホストパスを承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可を設定しないでください。 ### 0.16.0 -このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルトが含まれるようになりました。 +このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` ではなく `gpt-5.4-mini` になりました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定に `reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルトが含まれるようになりました。 以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に指定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 @@ -104,14 +117,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 主な変更点: -- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で `max_turns=None` を指定し、ターン数の上限を無効にできるようになりました。 -- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、プロバイダー提供のすべてのサンドボックス実装において、絶対パスのシンボリックリンク先を含め、アーカイブルート外を指すシンボリックリンクを含む tar アーカイブを拒否するようになりました。 +- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で、ターン制限を無効にする `max_turns=None` を受け付けるようになりました。 +- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、およびプロバイダー提供のサンドボックス実装全体で、絶対パスのシンボリックリンク先を含め、アーカイブルート外を指すシンボリックリンクを含む tar アーカイブを拒否するようになりました。 ### 0.15.0 -このバージョンでは、モデルによる拒否は、空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` まで実行ループが再試行されたりするのではなく、`ModelRefusalError` として明示的に通知されるようになりました。 +このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に実行ループが `MaxTurnsExceeded` まで再試行されたりするのではなく、`ModelRefusalError` として明示的に提示されるようになりました。 -これは以前、拒否のみのモデルレスポンスが `final_output == ""` で完了することを想定していたコードに影響します。例外を発生させずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 +これは以前、拒否のみのモデルレスポンスが `final_output == ""` で完了することを期待していたコードに影響します。例外を発生させずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 ```python result = Runner.run_sync( @@ -121,81 +134,81 @@ result = Runner.run_sync( ) ``` -structured outputs を使用するエージェントの場合、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様に検証します。 +structured outputs を使用するエージェントでは、ハンドラーがエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様に検証します。 ### 0.14.0 -このマイナーリリースでは、破壊的変更は **導入されません**が、主要な新しいベータ機能領域であるサンドボックスエージェントと、ローカル環境、コンテナー環境、ホスト環境でそれらを使用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されます。 +このマイナーリリースに破壊的変更は **ありません** が、サンドボックスエージェントという主要な新しいベータ機能領域に加え、ローカル、コンテナー化、ホスト環境全体で利用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されます。 主な変更点: -- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースを追加しました。これにより、エージェントはファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開機能を備えた永続的で分離されたワークスペース内で作業できます。 -- `UnixLocalSandboxClient` と `DockerSandboxClient` によるローカルおよびコンテナー化された開発向けのサンドボックス実行バックエンドに加えて、Python パッケージのオプション依存関係 extras を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合を追加しました。 -- サンドボックスメモリのサポートを追加し、段階的開示、複数ターンのグループ化、構成可能な分離境界、S3 ベースのワークフローを含む永続化メモリのコード例により、今後の実行で以前の実行から得た知見を再利用できるようになりました。 -- ローカルおよび合成ワークスペースエントリー、S3/R2/GCS/Azure Blob Storage/S3 Files のリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットによる再開フローを含む、より包括的なワークスペースおよび再開モデルを追加しました。 -- `examples/sandbox/` 配下に、スキル、ハンドオフ、メモリを利用したコーディングタスク、プロバイダー固有の設定、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドのワークフローを扱う、多数のサンドボックスのコード例とチュートリアルを追加しました。 -- サンドボックス対応のセッション準備、機能のバインド、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーのデフォルト、機密性の高い MCP 出力のより安全な秘匿化により、コアランタイムとトレーシングスタックを拡張しました。 +- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムの API サーフェスが追加されました。これによりエージェントは、ファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開機能を備えた永続的な隔離ワークスペース内で作業できます。 +- `UnixLocalSandboxClient` と `DockerSandboxClient` によるローカルおよびコンテナー化された開発向けのサンドボックス実行バックエンドに加え、Python パッケージのオプション依存関係 extras を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合が追加されました。 +- サンドボックスメモリのサポートが追加され、段階的開示、複数ターンのグループ化、設定可能な分離境界、および S3 を利用したワークフローを含む永続化メモリのサンプルコードにより、今後の実行で過去の実行から得た知見を再利用できるようになりました。 +- ローカルおよび合成ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files 用のリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットによる再開フローを含む、より広範なワークスペースおよび再開モデルが追加されました。 +- `examples/sandbox/` 配下に多数のサンドボックスのサンプルコードとチュートリアルが追加されました。スキル、ハンドオフ、メモリ、プロバイダー固有の設定を使用するコーディングタスク、およびコードレビュー、データルーム QA、Web サイトの複製などのエンドツーエンドワークフローを扱います。 +- サンドボックス対応のセッション準備、機能のバインディング、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーのデフォルト、および機密性の高い MCP 出力のより安全なリダクションにより、コアランタイムとトレーシングスタックが拡張されました。 ### 0.13.0 -このマイナーリリースでは、破壊的変更は **導入されません**が、注目すべき Realtime のデフォルト更新に加え、新しい MCP 機能と実行時の安定性修正が含まれます。 +このマイナーリリースに破壊的変更は **ありません** が、重要な Realtime のデフォルト更新に加え、新しい MCP 機能とランタイム安定性の修正が含まれます。 主な変更点: -- デフォルトの WebSocket Realtime モデルは `gpt-realtime-1.5` になったため、新しい Realtime エージェント設定では追加の構成なしで新しいモデルが使用されます。 -- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開され、`MCPServerStreamableHttp` で `session_id` が公開されるようになりました。これにより、MCP Streamable HTTP トランスポートを使用するセッションを、再接続やステートレスワーカーをまたいで再開できます。 -- Chat Completions 統合では、`should_replay_reasoning_content` を通じて既存の推論内容の再送信をオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論やツール呼び出しの継続性が向上しました。 -- `SQLAlchemySession` での同時初回書き込み、推論の除去後に孤立したアシスタントメッセージ ID を含むコンパクションリクエスト、MCP/推論項目を残していた `remove_all_tools()`、`FunctionTool` インスタンスのバッチ実行機構における競合状態など、複数のランタイムおよびセッションのエッジケースを修正しました。 +- デフォルトの WebSocket Realtime モデルは `gpt-realtime-1.5` になり、新しい Realtime エージェント設定では追加設定なしで新しいモデルが使用されます。 +- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開され、`MCPServerStreamableHttp` で `session_id` が公開されるようになりました。これにより、MCP Streamable HTTP トランスポートを使用するセッションを、再接続またはステートレスワーカーをまたいで再開できます。 +- Chat Completions 統合では、`should_replay_reasoning_content` を使用して既存の推論内容を再送信するようオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論とツール呼び出しの連続性が向上しました。 +- `SQLAlchemySession` での最初の書き込みの競合、推論除去後に孤立したアシスタントメッセージ ID を含むコンパクションリクエスト、MCP/推論項目を残す `remove_all_tools()`、`FunctionTool` インスタンス用バッチエグゼキューターの競合状態など、複数のランタイムおよびセッションのエッジケースが修正されました。 ### 0.12.0 -このマイナーリリースでは、破壊的変更は **導入されません**。主な新機能については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を参照してください。 +このマイナーリリースに破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 ### 0.11.0 -このマイナーリリースでは、破壊的変更は **導入されません**。主な新機能については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を参照してください。 +このマイナーリリースに破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 ### 0.10.0 -このマイナーリリースでは、破壊的変更は **導入されません**が、OpenAI Responses ユーザー向けの重要な新機能領域である Responses API の WebSocket トランスポートサポートが含まれます。 +このマイナーリリースに破壊的変更は **ありません** が、OpenAI Responses ユーザー向けの重要な新機能領域として、Responses API の WebSocket トランスポート対応が含まれます。 主な変更点: -- OpenAI Responses モデルに WebSocket トランスポートのサポートを追加しました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 -- 複数ターンの実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` を追加しました。 -- ストリーミング、ツール、承認、フォローアップターンを扱う、新しい WebSocket ストリーミングのコード例(`examples/basic/stream_ws.py`)を追加しました。 +- OpenAI Responses モデル向けの WebSocket トランスポート対応が追加されました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 +- 複数ターンの実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` が追加されました。 +- ストリーミング、ツール、承認、後続ターンを扱う、新しい WebSocket ストリーミングのサンプルコード(`examples/basic/stream_ws.py`)が追加されました。 ### 0.9.0 -このバージョンでは、このメジャーバージョンが 3 か月前に EOL を迎えたため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンにアップグレードしてください。 +このバージョンでは、Python 3.9 のサポートが終了しました。このメジャーバージョンは 3 か月前に EOL を迎えています。より新しいランタイムバージョンにアップグレードしてください。 -さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に限定されました。この変更によって通常は破壊的な問題が発生することはありませんが、コードがより広範な共用体型に依存している場合は、アプリケーション側で調整が必要になる可能性があります。 +また、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に限定されました。通常、この変更が破壊的な問題を引き起こすことはありませんが、コードがより広い共用体型に依存している場合は、調整が必要になる可能性があります。 ### 0.8.0 -このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる可能性があります。 +このバージョンでは、次の 2 つのランタイム動作変更により、移行作業が必要になる可能性があります。 -- `FunctionTool` インスタンスでラップされた **同期** Python 呼び出し可能オブジェクトは、イベントループのスレッドで実行されるのではなく、`asyncio.to_thread(...)` を通じてワーカースレッドで実行されるようになりました。ツールのロジックがスレッドローカルな状態やスレッドに依存するリソースに依存している場合は、非同期ツール実装へ移行するか、ツールコード内でスレッドアフィニティを明示してください。 -- ローカル MCP ツールの失敗処理が構成可能になり、デフォルトの動作では実行全体を失敗させる代わりに、モデルから参照できるエラー出力を返せるようになりました。即時失敗の動作に依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定をオーバーライドするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 +- `FunctionTool` インスタンスでラップされた **同期** Python callable は、イベントループスレッドで実行されるのではなく、`asyncio.to_thread(...)` を通じてワーカースレッド上で実行されるようになりました。ツールロジックがスレッドローカル状態またはスレッドアフィンなリソースに依存している場合は、非同期ツール実装に移行するか、ツールコード内でスレッドアフィニティを明示してください。 +- ローカル MCP ツールの失敗処理が設定可能になり、デフォルト動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返せるようになりました。フェイルファストのセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 ### 0.7.0 -このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかあります。 +このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかありました。 -- ネストされたハンドオフ履歴は **オプトイン** になりました(デフォルトでは無効です)。v0.6.x のデフォルトであったネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 -- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルトによって設定されていた従来の `"low"` から `"none"` に変更されました。プロンプトまたは品質/コスト特性が `"low"` に依存している場合は、`model_settings` で明示的に設定してください。 +- ネストされたハンドオフ履歴は **オプトイン** になりました(デフォルトでは無効です)。v0.6.x のデフォルトのネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 +- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、`"none"` に変更されました(以前は SDK のデフォルトで設定された `"low"` でした)。プロンプトまたは品質/コストプロファイルが `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 ### 0.6.0 -このバージョンでは、デフォルトのハンドオフ履歴は、ユーザーとアシスタントの各ターンを別々のメッセージとして渡すのではなく、単一のアシスタントメッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます。 -- 既存の単一メッセージ形式のハンドオフ記録は、デフォルトで `` ブロックの前に、正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` を置いて開始するようになり、後続のエージェントは明確なラベル付きの要約を受け取ります。 +このバージョンでは、デフォルトのハンドオフ履歴が、ユーザーとアシスタントのターンを個別のメッセージとして渡すのではなく、単一のアシスタントメッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます。 +- 既存の単一メッセージ形式のハンドオフ記録は、デフォルトで `` ブロックの前に、正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` を付けて開始するようになり、後続のエージェントは明確にラベル付けされた要約を受け取れます。 ### 0.5.0 -このバージョンでは、目に見える破壊的変更は導入されませんが、新機能と内部の重要な更新がいくつか含まれます。 +このバージョンには外部から確認できる破壊的変更はありませんが、内部には新機能といくつかの重要な更新が含まれています。 -- `RealtimeRunner` に、[SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートを追加しました。 -- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックを大幅に改訂しました。 +- `RealtimeRunner` に、[SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートが追加されました。 +- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックが大幅に改訂されました。 ### 0.4.0 @@ -203,12 +216,12 @@ structured outputs を使用するエージェントの場合、ハンドラー ### 0.3.0 -このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA バージョン)に移行されます。 +このバージョンでは、Realtime API 対応が gpt-realtime モデルとその API インターフェース(GA 版)に移行します。 ### 0.2.0 -このバージョンでは、以前は引数として `Agent` を受け取っていた箇所の一部が、代わりに `AgentBase` を受け取るようになりました。たとえば、MCP サーバーの `list_tools()` メソッドシグネチャがこれに該当します。これは型指定のみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 +このバージョンでは、以前 `Agent` を引数として受け取っていたいくつかの箇所で、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、これは MCP サーバーの `list_tools()` メソッドシグネチャに適用されます。これは純粋に型付けのみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 ### 0.1.0 -このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされたすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file +このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` の 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされたすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file diff --git a/docs/ja/testing.md b/docs/ja/testing.md new file mode 100644 index 0000000000..8eac8ae49e --- /dev/null +++ b/docs/ja/testing.md @@ -0,0 +1,575 @@ +--- +search: + exclude: true +--- +# テスト + +SDK は、エージェントワークフロー、Sandbox セッション、Realtime セッション、Voice パイプライン向けに、決定論的でプロバイダーに依存しないテストユーティリティを提供します。これらのユーティリティはメモリ内で動作し、モデル、Sandbox プロバイダー、Realtime API へのリクエストを行わず、SDK が管理する正規化済みのやり取りを記録します。以下の実行可能なレシピでは、OpenAI API キーが設定されている場合にデフォルトのトレースプロセッサーがテストアクティビティをアップロードしないよう、実行ごとにトレーシングを無効にしています。 + +これらは、アプリケーションと SDK が管理するオーケストレーション(ツール実行、ハンドオフ、ガードレール、再試行、ストリーミング、セッション動作、Sandbox 機能、Realtime イベント処理、Voice パイプライン構成)のテストに使用します。外部のモデル、ネットワークプロトコル、Sandbox プロバイダー、音声システムが管理する動作については、実際のプロバイダーアダプターまたは統合環境を使用してください。 + +## 必要なレシピの検索 + +| 目的 | 使用するもの | 参照先 | +| --- | --- | --- | +| 固定の最終回答を返す | `ScriptedModel` と `assistant_message()` | [固定レスポンスの返却](#return-a-fixed-response) | +| 複数ターンのツールループを実行する | `function_call()` の後にアシスタントレスポンス | [ツールワークフローのテスト](#test-a-tool-workflow) | +| リクエストからレスポンスを選択する | `ModelStep.respond()` または `responder` のマッピング | [リクエストからのレスポンス導出](#derive-a-response-from-the-request) | +| ランナーがモデルに送信した内容をアサートする | `calls`、`first_call`、または `last_call` | [モデル呼び出しの検査](#inspect-model-calls) | +| ストリーミング実行をテストする | 通常のレスポンスステップ、またはイベントを厳密に指定する `ModelStep.stream()` | [ストリーミングのテスト](#test-streaming) | +| エラーまたは再試行の判断をテストする | `ModelStep.raise_error()` | [モデル障害の注入](#inject-model-failures) | +| 意図しないワークフロー変更を検出する | 厳密な FIFO ステップと `assert_complete()` | [ワークフローのドリフト検出](#detect-workflow-drift) | +| Sandbox を起動せずに `SandboxAgent` をテストする | `scripted_sandbox_session()` と `ScriptedModel` | [Sandbox エージェントワークフローのテスト](#test-a-sandbox-agent-workflow) | +| Sandbox 呼び出しを照合する、またはその実行結果を導出する | Sandbox ステップの `match` または `responder` | [Sandbox ステップの設定](#configure-sandbox-steps) | +| 接続を開かずに Realtime セッションをテストする | `ScriptedRealtimeModel` と `RealtimeStep` | [Realtime セッションのテスト](#test-a-realtime-session) | +| Realtime ツールワークフローをテストする | `RealtimeModelToolCallEvent` を発行し、ツール出力を期待する | [Realtime ツールワークフローのテスト](#test-a-realtime-tool-workflow) | +| 静的またはストリーミングの Voice パイプラインをテストする | `ScriptedSTTModel`、`ScriptedTTSModel`、およびスクリプト化された、または実際のワークフロー | [Voice パイプラインのテスト](#test-a-voice-pipeline) | +| プロバイダーのシリアライズまたはワイヤーペイロードをテストする | 制御されたネットワークトランスポートを備えた実際のプロバイダーアダプター | [適切な境界の選択](#choose-the-correct-boundary) | + +## インポート + +テスト API は、置き換えるランタイム境界の近くに配置されています。 + +| 境界 | インポートパス | +| --- | --- | +| エージェントモデルと Sandbox ワークフロー | `agents.testing` | +| Realtime モデルトランスポート | `agents.realtime.testing` | +| Voice の STT、TTS、およびワークフローコンポーネント | `agents.voice.testing` | + +テスト用シンボルは、意図的にトップレベルの `agents` インポートには含まれていません。 + +## エージェントワークフローのレシピ + +### 固定レスポンスの返却 + +想定されるモデル呼び出しごとに、正規化済み出力項目のシーケンスを 1 つ渡します。出力シーケンスの省略記法には、1 回のリクエスト用の決定論的なレスポンス ID と使用量が設定されます。 + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ScriptedModel, assistant_message + + +@pytest.mark.asyncio +async def test_fixed_response() -> None: + model = ScriptedModel( + [[assistant_message("Paris is the capital of France.")]] + ) + agent = Agent(name="Geography assistant", model=model) + + result = await Runner.run( + agent, + "What is the capital of France?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Paris is the capital of France." + assert len(model.calls) == 1 + model.assert_complete() +``` + +決定論的なワークフローテストの最後に `model.assert_complete()` を使用してください。設定されたすべてのステップを消費する前にワークフローが停止した場合を検出できます。 + +### ツールワークフローのテスト + +ツールを呼び出すモデルレスポンスを 1 つ、その後に最終回答を生成する 2 つ目のレスポンスをスクリプト化します。これらのモデル呼び出しの間では、実際の SDK ツールパイプラインが実行されます。 + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.decorators import tool +from agents.testing import ScriptedModel, assistant_message, function_call + + +@tool +def get_weather(city: str) -> str: + """Return the weather for a city.""" + return f"{city}: sunny" + + +@pytest.mark.asyncio +async def test_tool_workflow() -> None: + model = ScriptedModel( + [ + [function_call("get_weather", {"city": "Tokyo"}, call_id="call_1")], + [assistant_message("It is sunny in Tokyo.")], + ] + ) + agent = Agent(name="Weather assistant", model=model, tools=[get_weather]) + + result = await Runner.run( + agent, + "What is the weather in Tokyo?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "It is sunny in Tokyo." + assert len(model.calls) == 2 + assert model.last_call is not None + assert any( + item.get("type") == "function_call_output" + for item in model.last_call.input + ) + model.assert_complete() +``` + +このパターンは、ツール入力の検証、実行、実行結果の変換、フック、ガードレール、および次のモデルターンをカバーします。Python 関数を直接呼び出すと、これらの SDK の動作は迂回されます。 + +### リクエストからのレスポンス導出 + +レスポンスが正規化済みモデル呼び出しに実際に依存する場合、またはアサーションをモデル境界に配置する場合は、`ModelStep.respond()` を使用します。レスポンダーは同期または非同期にでき、`ScriptedModel` が受け付ける任意のステップ形式を返せます。 + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ModelCall, ModelStep, ScriptedModel, assistant_message + + +def respond(call: ModelCall): + assert call.streamed is False + assert call.input == [{"content": "Summarize this", "role": "user"}] + return {"output": [assistant_message("Handled the normalized request.")]} + + +@pytest.mark.asyncio +async def test_request_aware_response() -> None: + model = ScriptedModel([ModelStep.respond(respond)]) + agent = Agent(name="Assistant", model=model) + + result = await Runner.run( + agent, + "Summarize this", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Handled the normalized request." + model.assert_complete() +``` + +`ScriptedModel` は、`ModelStep`、同等の辞書形式、`ModelResponse`、正規化済み出力項目のシーケンス、または例外を受け付けます。レスポンスが呼び出しに依存しない場合は、固定スクリプトの方が予期しないターンを診断しやすいため、固定の出力シーケンスを優先してください。 + +### モデル呼び出しの検査 + +`ScriptedModel` は、選択されたステップを解決するか例外を発生させる前に、各呼び出しを記録します。 + +| メンバー | 内容 | +| --- | --- | +| `calls` | 呼び出し順のすべての `ModelCall` | +| `first_call` | 最初の呼び出し、または `None` | +| `last_call` | 最新の呼び出し、または `None` | +| `remaining_steps` | まだ消費されていない設定済みステップの数 | + +一般的なアサーションには、`call.input`、`call.model_settings`、`call.tools`、`call.handoffs`、および `call.streamed` があります。可変のリクエストデータは呼び出し境界でスナップショットされ、公開されている各履歴アクセサーは、切り離されたスナップショットを返します。ツール、ハンドオフ、出力スキーマ、およびトレーシングのオブジェクトは、ランタイム上の同一性を維持します。 + +構造化された `call_index` および `input_index` のエラーフィールドは 0 始まりであるため、`calls[...]` または指定されたステップシーケンスのインデックスとして直接使用できます。人が読めるエラーメッセージでは、呼び出し番号またはステップ番号が 1 始まりで表示されます。 + +1 つのテストでモデルステップを段階的に追加する必要がある場合は、`enqueue()` または `extend()` を使用します。独立したシナリオには、新しい `ScriptedModel` を作成してください。このユーティリティは、消費済みステップや呼び出し履歴をリセットしません。 + +### ストリーミングのテスト + +通常のレスポンスステップは、`Runner.run()` と `Runner.run_streamed()` の両方をサポートします。一般的なアシスタントメッセージ、推論項目、関数呼び出し、およびパッチ適用呼び出しについて、`ScriptedModel` は、正規化済みの開始、差分、項目完了、および終了レスポンスイベントを生成します。終了レスポンスには、完全な出力と使用量が含まれます。 + +正規化済み `TResponseStreamEvent` の厳密なシーケンスがテスト対象の動作に含まれる場合にのみ、`ModelStep.stream()` を使用してください。 + +```python +step = ModelStep.stream( + events, + output=[assistant_message("The terminal output used by the runner.")], +) +``` + +`events` には、固定シーケンス、または記録された `ModelCall` を受け取る非同期ファクトリーを指定できます。同じステップが非ストリーミング呼び出しで使用された場合に返されるレスポンスは、オプションの `output` です。厳密なストリームイベントは SDK で正規化されたイベントであり、Responses API や Chat Completions のワイヤーチャンクではありません。 + +自動ストリーミングでは、段階的なライフサイクルが実装されていない種類の正規化済み出力項目は拒否されます。不完全なイベントシーケンスに依存せず、それらの項目には `ModelStep.stream(...)` を使用してください。 + +### モデル障害の注入 + +1 回のモデル呼び出しを失敗させるには、`ModelStep.raise_error()` を使用します。オプションの再試行に関する指示は、そのスクリプト化されたエラーにのみ適用されます。 + +```python +from agents import ModelRetryAdvice +from agents.testing import ModelStep + + +step = ModelStep.raise_error( + RuntimeError("temporary failure"), + retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"), +) +``` + +ランナーの再試行ポリシーが、その指示によって再試行するかどうかを決定します。各再試行は別のモデル呼び出しであり、次のスクリプト化されたステップを消費します。Python ヘルパーは固定の `ModelRetryAdvice` 値を受け付けます。再試行に関する指示自体を試行ごとに動的に変える必要がある場合は、カスタム `Model` を使用してください。 + +### ワークフローのドリフト検出 + +スクリプト化された呼び出しを、想定されるワークフロー形状として扱います。余分なモデルリクエストがあると `UnexpectedModelCall` が発生し、早期終了するとステップが残り、`assert_complete()` によって報告されます。 + +テストフレームワークがティアダウンまたはファイナライザーをサポートしており、別のアサーションが失敗した後にも未消費ステップを報告したい場合は、そこに `assert_complete()` を配置してください。通常の回帰テストでは、不一致エラーをキャッチしないでください。 + +| エラー | 構造化フィールド | 意味 | +| --- | --- | --- | +| `InvalidModelStep` | `reason`、`input_index` | ステップの形式が不正であり、キューに入る前に拒否されました | +| `UnexpectedModelCall` | `call`、`call_index` | スクリプトの終了後にワークフローが別のモデル呼び出しを行いました | +| `UnconsumedModelSteps` | `remaining_steps` | すべてのステップを使用する前にワークフローが終了しました | + +## Sandbox エージェントのレシピ + +### Sandbox エージェントワークフローのテスト + +`ScriptedModel` と `scripted_sandbox_session()` を組み合わせると、ローカルコンテナまたはリモート Sandbox を作成せずに、実際の `SandboxAgent` ランタイムを実行できます。モデルスクリプトは機能ツールを選択し、Sandbox スクリプトは対応する `SandboxSession` メソッドが返す内容を定義します。 + +```python +import pytest + +from agents import RunConfig, Runner +from agents.sandbox import ExecResult, SandboxAgent +from agents.sandbox.capabilities import Shell +from agents.testing import ( + ScriptedModel, + assistant_message, + function_call, + scripted_sandbox_session, +) + + +@pytest.mark.asyncio +async def test_sandbox_workflow() -> None: + sandbox = scripted_sandbox_session( + [ + { + "method": "exec", + "match": lambda call: call.args == ("pwd",), + "result": ExecResult( + stdout=b"/workspace\n", + stderr=b"", + exit_code=0, + ), + } + ] + ) + model = ScriptedModel( + [ + [function_call("exec_command", {"cmd": "pwd"}, call_id="call_1")], + [assistant_message("The workspace is /workspace.")], + ] + ) + agent = SandboxAgent( + name="Workspace assistant", + model=model, + capabilities=[Shell()], + ) + + async with sandbox: + result = await Runner.run( + agent, + "Which directory are you in?", + run_config=RunConfig( + sandbox={"session": sandbox}, + tracing_disabled=True, + ), + ) + + assert result.final_output == "The workspace is /workspace." + assert [call.method for call in sandbox.calls] == ["exec"] + sandbox.assert_complete() + model.assert_complete() +``` + +このテストは、正規化された 2 つの SDK 境界を通過します。ツール引数の検証、機能のルーティング、Sandbox セッションの呼び出し、次のモデルターンへのツール実行結果の受け渡し、および最終出力の処理をカバーします。実際のモデルがコマンドを選択するかどうかや、実際の Sandbox プロバイダーがそれをどのように実行するかはテストしません。 + +### Sandbox ステップの設定 + +一致する各 Sandbox 呼び出しは、1 つのグローバル FIFO シーケンスから次のステップを消費します。メソッドの不一致、マッチャーによる拒否、またはマッチャーの例外が発生した場合、そのステップは保留中のままになります。`method` を設定し、結果を厳密に 1 つ選択し、呼び出しの詳細が重要な場合にのみ `match` を追加してください。 + +| ステップメンバー | 使用する場合 | +| --- | --- | +| `result` | メソッドが固定の型付き値を返す場合 | +| `responder` | 実行結果が切り離された `SandboxCall` に依存する場合 | +| `error` | メソッドが特定の例外を発生させる場合 | +| `match` | マッチャーが `False` 以外の値を返さない限り、結果を生成する前に呼び出しを拒否する場合 | + +サポートされるスクリプト化メソッド名は、`apply_patch`、`exec`、`ls`、`mkdir`、`pty_exec_start`、`pty_write_stdin`、`read`、`rm`、および `write` です。設定されたモデル向け機能のみが公開されます。2 つの PTY メソッドは 1 つの対話型シェル機能を構成するため、いずれかの PTY メソッドが設定されると、両方がまとめて公開されます。ただし、呼び出しは引き続きグローバル FIFO スクリプトを消費します。 + +`sandbox.calls` には、0 始まりの `call_index`、`method`、位置引数の `args`、および読み取り専用の `kwargs` を持つ、切り離された `SandboxCall` スナップショットが含まれます。静的な実行結果も、スクリプトの作成時にスナップショットされます。`io.BytesIO` と `io.StringIO` の値がサポートされています。その他のライブストリームオブジェクトまたはライフサイクル動作には、カスタム Sandbox セッションを使用してください。 + +| エラー | 構造化フィールド | 意味 | +| --- | --- | --- | +| `InvalidSandboxStep` | `reason`、`input_index`、`method` | ステップの形式が不正であるか、サポートされていないメソッド名が指定されています | +| `UnexpectedSandboxCall` | `call`、`call_index`、`actual_method`、`expected_method`、`remaining_steps` | ワークフローが誤ったメソッドを呼び出したか、スクリプトの終了後も続行しました | +| `SandboxCallMatcherError` | `call`、`call_index`、`method` | ステップマッチャーが `False` を返しました | +| `UnconsumedSandboxSteps` | `remaining_steps`、`pending_methods` | すべてのステップを使用する前にワークフローが終了しました | + +返されるオブジェクトはセッション自体です。`RunConfig(sandbox={"session": sandbox})` に直接渡してください。ラッパーの `.session` 属性はありません。 + +## Realtime のレシピ + +### Realtime セッションのテスト + +`ScriptedRealtimeModel` は、Python SDK の正規化済み `RealtimeModel` 境界を実装します。各 `RealtimeStep` は、1 つの送信 `RealtimeModelSendEvent` と照合し、その後、正規化済みの受信 `RealtimeModelEvent` オブジェクトを発行するか、注入されたエラーを発生させます。 + +```python +import pytest + +from agents.realtime import ( + RealtimeAgent, + RealtimeModelOutputTextDeltaEvent, + RealtimeModelSendUserInput, + RealtimeRawModelEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@pytest.mark.asyncio +async def test_realtime_message() -> None: + reply = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="Hello!", + response_id="response_1", + ) + model = ScriptedRealtimeModel( + [ + RealtimeStep( + expect=RealtimeModelSendUserInput(user_input="Hello"), + emit=[reply], + ) + ] + ) + runner = RealtimeRunner( + RealtimeAgent(name="Assistant"), + model=model, + config={"tracing_disabled": True}, + ) + + observed_reply = False + async with await runner.run() as session: + await session.send_message("Hello") + async for event in session: + if isinstance(event, RealtimeRawModelEvent) and event.data == reply: + observed_reply = True + break + + assert observed_reply + assert model.sent_events == (RealtimeModelSendUserInput(user_input="Hello"),) + assert model.closed is True + model.assert_complete() +``` + +期待値には、厳密なイベント値、`isinstance` で照合されるイベントクラス、または送信イベントを受け取り、一致した場合に `True` を返す callable を指定できます。デフォルトでは厳格モードが有効です。`strict=False` を使用すると、無関係な送信イベントは記録されますが、保留中のステップは消費されません。これは、テスト対象の動作に含まれない付随的なイベントをセッションが発行する場合に便利です。 + +接続中に受信イベントを発行するには、`connect_events` を使用します。ライフサイクルの障害には `connect_error` または `close_error` を使用し、1 回の照合済み送信に関連付けられた障害には `RealtimeStep(error=...)` を使用します。1 つのステップに `emit` と `error` の両方を定義することはできません。 + +### Realtime ツールワークフローのテスト + +実際の関数ツールを `RealtimeAgent` に接続し、正規化済みツール呼び出しを発行して、SDK がモデル境界を通じてツール出力を送信することを期待します。`async_tool_calls` を `False` に設定すると、この小さなコード例は、テスト専用の待機機構を使用せずに接続中に完了します。 + +```python +import pytest + +from agents.decorators import tool +from agents.realtime import ( + RealtimeAgent, + RealtimeModelSendToolOutput, + RealtimeModelToolCallEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@tool +def lookup_order(order_id: str) -> str: + """Look up an order by ID.""" + return f"Order {order_id} has shipped." + + +@pytest.mark.asyncio +async def test_realtime_tool_workflow() -> None: + tool_call = RealtimeModelToolCallEvent( + name="lookup_order", + call_id="call_1", + arguments='{"order_id":"order_123"}', + ) + + def matches_tool_output(event) -> bool: + return ( + isinstance(event, RealtimeModelSendToolOutput) + and event.tool_call.call_id == "call_1" + and event.output == "Order order_123 has shipped." + ) + + model = ScriptedRealtimeModel( + [RealtimeStep(expect=matches_tool_output)], + connect_events=[tool_call], + ) + agent = RealtimeAgent( + name="Order assistant", + tools=[lookup_order], + ) + runner = RealtimeRunner( + agent, + model=model, + config={"async_tool_calls": False, "tracing_disabled": True}, + ) + + async with await runner.run(): + pass + + model.assert_complete() +``` + +これにより、実際の Realtime ツール検索、引数検証、実行、および出力ルーティングが実行されます。実際のモデルがツールを選択することを証明するものではありません。 + +### Realtime の呼び出しとライフサイクルの検査 + +| メンバー | 内容 | +| --- | --- | +| `connect_calls` | 認証情報を含まない、切り離された接続スナップショット | +| `sent_events` | 呼び出し順の、切り離された送信イベントのスナップショット | +| `remaining_steps` | 残っている想定送信 | +| `listeners` | 現在登録されているリスナーオブジェクト | +| `connected`、`closed`、`close_calls` | 現在のメモリ内ライフサイクル状態 | + +接続履歴には、API キーまたはヘッダーフィールドが指定されたかどうかのみが記録され、その値は保存されません。URL のスナップショットからは、ユーザー情報、クエリパラメーター、およびフラグメントが削除されます。可変のイベントデータと設定は切り離されますが、ツール、ハンドオフ、再生トラッカーなどのライブ SDK オブジェクトは同一性を維持します。 + +最後に `model.assert_complete()` を使用し、`RealtimeSession` 非同期コンテキストマネージャーによってモデルを閉じてください。Python ユーティリティは、保留中の期待値を表す Promise、暗黙的なタイムアウト、または個別の `assert_closed()` ヘルパーを意図的に提供していません。 + +| エラー | 構造化フィールド | 意味 | +| --- | --- | --- | +| `UnexpectedRealtimeSend` | `actual`、`expected` | 厳格な送信が次のステップと一致しなかったか、ステップが残っていませんでした | +| `UnconsumedRealtimeSteps` | `remaining_steps` | 想定されたすべての送信を使用する前にセッションが終了しました | +| `RealtimeScriptError` | なし | 切断中の送信など、無効なライフサイクル状態でスクリプトが使用されました | + +## Voice パイプラインのレシピ + +### Voice パイプラインのテスト + +スクリプト化された STT および TTS モデルを、`SingleAgentVoiceWorkflow` と `ScriptedModel` を基盤とするエージェントと組み合わせると、プロバイダーへのリクエストを行わずに、音声テキスト変換 -> エージェント -> テキスト音声変換のパイプライン全体をテストできます。 + +```python +import numpy as np +import pytest + +from agents import Agent +from agents.testing import ScriptedModel, assistant_message +from agents.voice import AudioInput, SingleAgentVoiceWorkflow, VoicePipeline +from agents.voice.testing import ( + ScriptedSTTModel, + ScriptedTTSModel, + TTSResult, + pcm16_samples, +) + + +@pytest.mark.asyncio +async def test_voice_pipeline() -> None: + model = ScriptedModel([[assistant_message("Hello there.")]]) + stt = ScriptedSTTModel("hello") + pcm = pcm16_samples([0, 100, -100, 0]) + tts = ScriptedTTSModel([TTSResult([pcm])]) + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow( + Agent(name="Voice assistant", model=model) + ), + stt_model=stt, + tts_model=tts, + config={"tracing_disabled": True, "tts_settings": {"buffer_size": 1}}, + ) + + result = await pipeline.run(AudioInput(np.zeros(2, dtype=np.int16))) + events = [event async for event in result.stream()] + + assert events + assert [call.text for call in tts.calls] == ["Hello there."] + stt.assert_complete() + tts.assert_complete() + model.assert_complete() +``` + +パイプラインの STT/TTS ライフサイクルがテスト対象で、エージェントオーケストレーションが対象ではない場合は、代わりに `ScriptedVoiceWorkflow` を使用してください。 + +```python +from agents.voice.testing import ScriptedVoiceWorkflow + + +workflow = ScriptedVoiceWorkflow( + turns=["Hello there."], + start="Welcome.", +) +``` + +`start` ステップは、`on_start()` によって消費されます。`VoicePipeline` が `on_start()` を呼び出すのは `StreamedAudioInput` の場合のみです。静的な `AudioInput` 実行では、`start` は消費されません。通常の各ターンでは、文字起こしが記録され、設定された実行結果が 1 つ消費されます。文字列は 1 つのフラグメントです。文字列のシーケンスでは、テキスト分割と TTS の前のフラグメント境界を制御できます。 + +### ストリーミング文字起こしのテスト + +`ScriptedSTTModel` は、静的な `transcriptions` と、個別にスクリプト化されたストリーミング `sessions` を受け付けます。セッションには、`ScriptedTranscriptionSession`、文字起こしターンのシーケンス、例外、または単一の文字列を指定できます。 + +```python +from agents.voice.testing import ScriptedSTTModel, ScriptedTranscriptionSession + + +session = ScriptedTranscriptionSession(["first turn", "second turn"]) +stt = ScriptedSTTModel(sessions=[session]) +``` + +`ScriptedTranscriptionSession` を閉じると反復が停止し、スキップされたターンは `assert_complete()` による報告対象として残ります。同様に、`ScriptedTTSModel` は、呼び出しごとに 1 つの `TTSResult`、バイトチャンクのシーケンス、または例外を消費します。 + +### Voice 呼び出しの検査 + +| コンポーネント | 記録される履歴 | +| --- | --- | +| `ScriptedSTTModel` | `calls`、`session_calls`、およびライブ `created_sessions` の同一性 | +| `ScriptedTTSModel` | テキストと切り離された設定を含む `calls` | +| `ScriptedVoiceWorkflow` | ターン順の `transcriptions` | + +静的な音声バッファと可変の設定は、呼び出し時にスナップショットされます。`StreamedAudioInput` および作成された文字起こしセッションオブジェクトは、パイプラインが引き続き使用するため、ライブオブジェクトとしての同一性を維持します。 + +| エラー | 構造化フィールド | 意味 | +| --- | --- | --- | +| `UnexpectedVoiceCall` | `operation` | 静的な文字起こし、ストリーミングセッション、TTS 呼び出し、ワークフロー開始、またはワークフローターンに設定済みステップがありませんでした | +| `UnconsumedVoiceSteps` | `remaining_steps` | 1 つ以上の設定済み Voice ステップが残っています | + +テストで設定するスクリプト化された各 Voice コンポーネントに対して、`assert_complete()` を呼び出してください。`ScriptedSTTModel.assert_complete()` は、それが作成した文字起こしセッション内のターンも確認します。 + +## 適切な境界の選択 + +モデルプロバイダーに依存せずに、SDK の実行ループ、ツール、ハンドオフ、ガードレール、セッション、再試行、または正規化済みストリーミングをテストする場合は、`ScriptedModel` を使用します。 + +Sandbox プロバイダーを起動せずに `SandboxAgent` の機能とオーケストレーションをテストする場合は、`ScriptedModel` とともに `scripted_sandbox_session()` を使用します。プロバイダーの作成、プロセス実行、ファイルシステムの忠実性、永続性、リソース制限、および分離の検証は、実際の Sandbox プロバイダーに対する統合テストで行ってください。 + +WebSocket 接続を開かずに `RealtimeSession` の動作、または `RealtimeAgent` のツールとハンドオフのオーケストレーションをテストする場合は、`ScriptedRealtimeModel` を使用します。raw Realtime クライアント/サーバーイベント、認証、ネットワーク復旧、および音声トランスポートの動作は、実際のトランスポートまたは統合環境でテストしてください。Realtime API セッションでは、クライアントが入力を送信してイベントを受信している間、接続を開いたままにします。そのため、これらのネットワークおよびプロトコルに関する事項は、正規化済みモデル境界より下位に属します。本番環境の接続アーキテクチャについては、[OpenAI Realtime API ガイド](https://developers.openai.com/api/docs/guides/realtime)を参照してください。 + +音声プロバイダーを使用せずに、STT/TTS の順序、ストリーミング文字起こしのクリーンアップ、ワークフローフラグメントの受け渡し、または Voice パイプライン全体の構成をテストする場合は、Voice テストコンポーネントを使用します。文字起こし品質、生成音声、エンコード互換性、レイテンシ、または再生がテスト対象の場合は、実際の音声モデルと代表的な音声を使用してください。 + +Responses API または Chat Completions のリクエストシリアライズ、認証ヘッダー、プロバイダーのデフォルト値、HTTP ペイロード、プロバイダーのストリームチャンク、Realtime ワイヤーフレーム、またはプロバイダー固有のライフサイクル動作のテストには、これらのユーティリティを使用しないでください。そのようなテストでは実際のアダプターを維持し、そのネットワーク境界を置き換えるか制御してください。`openai` v3 では、OpenAI アダプターのテストに `httpx2` のリクエスト、レスポンス、トランスポート、および例外の型を使用する必要があります。従来の `httpx` は、Agents SDK のコア依存関係ではありません。 + +## 最終チェックリスト + +- 正規化済みモデル、Sandbox セッション、Realtime モデル、または Voice パイプライン境界が管理するやり取りのみをスクリプト化します。 +- ランナーのプライベート状態ではなく、重要な公開リクエストフィールドまたは呼び出しフィールドをアサートします。 +- 固定レスポンスステップを優先し、リクエスト依存の動作にのみレスポンダーを使用します。 +- モデルの自動ストリーミングを優先し、イベントレベルの動作が重要な場合にのみ厳密なストリームを使用します。 +- スクリプト化された各コンポーネントのテストを、その `assert_complete()` メソッドで終了します。 +- 周囲のテストが Realtime および Sandbox のライフサイクルを管理する場合は、非同期コンテキストマネージャーを使用してライフサイクルをクリーンアップします。 +- 人が読めるメッセージを解析するのではなく、構造化されたエラーフィールドをアサートします。 +- プロバイダーのワイヤーテストでは、制御されたネットワークトランスポートを備えた実際のアダプターを使用します。 + +## スコープと現在の制限 + +テストモジュールは、意図的に以下を提供していません。 + +- 正規化済みモデル出力項目ごとの便利なビルダー。一般的なケースには `assistant_message()` と `function_call()` を使用し、その他の正規化済み項目は直接渡してください。 +- プロバイダープロトコルのシミュレーター。厳密なモデルストリームでは、Responses API または Chat Completions のワイヤーチャンクではなく、正規化済み SDK イベントを使用します。 +- 高レベルのシミュレートされた Realtime サーバー。テストでは、正規化済みの送信を明示的に照合し、シナリオに必要な正規化済み受信イベントを発行します。 +- 順不同の Sandbox または Realtime の期待値。どちらのユーティリティも、1 つのグローバルな順序で想定ステップを消費します。 +- テストランナー固有のマッチャー、フィクスチャ、暗黙的なタイムアウト、または自動ティアダウン。 +- リセット API。`ScriptedModel` は段階的なスクリプト用の `enqueue()` と `extend()` をサポートしますが、独立したシナリオには新しいスクリプト化コンポーネントを作成してください。 + +不正な形式のストリーム、制御された中断または並行処理、厳密なキャンセル、またはスクリプト化ユーティリティでは維持できないライフサイクル境界がテストで必要な場合は、対応する公開インターフェースのカスタム実装を使用してください。その特殊な境界をテスト内に記載してください。 + +## API リファレンス + +- [`agents.testing`](ref/testing.md) +- [`agents.realtime.testing`](ref/realtime/testing.md) +- [`agents.voice.testing`](ref/voice/testing.md) \ No newline at end of file diff --git a/docs/ko/config.md b/docs/ko/config.md index 96ca5a73f5..f00c02902f 100644 --- a/docs/ko/config.md +++ b/docs/ko/config.md @@ -4,21 +4,21 @@ search: --- # 구성 -이 페이지에서는 기본 OpenAI 키 또는 클라이언트, 기본 OpenAI API 형식, 트레이싱 내보내기 기본값, 로깅 동작 등 애플리케이션 시작 시 일반적으로 한 번 설정하는 SDK 전역 기본값을 다룹니다. +이 페이지에서는 기본 OpenAI 키 또는 클라이언트, 기본 OpenAI API 형식, 트레이싱 내보내기 기본값, 로깅 동작처럼 애플리케이션 시작 시 일반적으로 한 번 설정하는 SDK 전역 기본값을 다룹니다. 이러한 기본값은 샌드박스 기반 워크플로에도 적용되지만, 샌드박스 워크스페이스, 샌드박스 클라이언트, 세션 재사용은 별도로 구성합니다. -특정 에이전트나 실행을 구성해야 하는 경우 다음 문서부터 참조하세요. +대신 특정 에이전트나 실행을 구성해야 한다면 다음 문서부터 확인하세요. -- 일반 에이전트 `Agent` 관련 instructions, tools, 출력 유형, 핸드오프, 가드레일은 [에이전트](agents.md)를 참조하세요. -- `RunConfig`, 세션, 대화 상태 옵션은 [에이전트 실행](running_agents.md)을 참조하세요. -- `SandboxRunConfig`, 매니페스트, 기능, 샌드박스 클라이언트별 워크스페이스 설정은 [샌드박스 에이전트](sandbox/guide.md)를 참조하세요. -- 모델 선택과 공급자 구성은 [모델](models/index.md)을 참조하세요. -- 실행별 트레이싱 메타데이터와 사용자 지정 트레이스 프로세서는 [트레이싱](tracing.md)을 참조하세요. +- [에이전트](agents.md): 일반 `Agent`의 instructions, tools, 출력 유형, 핸드오프, 가드레일 +- [에이전트 실행](running_agents.md): `RunConfig`, 세션, 대화 상태 옵션 +- [샌드박스 에이전트](sandbox/guide.md): `SandboxRunConfig`, 매니페스트, 기능, 샌드박스 클라이언트별 워크스페이스 설정 +- [모델](models/index.md): 모델 선택 및 공급자 구성 +- [트레이싱](tracing.md): 실행별 트레이싱 메타데이터 및 맞춤형 트레이스 프로세서 ## 구성 객체와 딕셔너리 -SDK에서 정의한 구성 매개변수는 일반적으로 형식이 지정된 설정 객체나 동일한 필드를 포함하는 딕셔너리 중 하나를 허용합니다. 이는 형식 주석에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스, 음성 구성 경계 전반에 적용됩니다. SDK에서 정의한 중첩 설정 유형에도 딕셔너리를 사용할 수 있습니다. +SDK에서 정의한 구성 매개변수는 일반적으로 형식이 지정된 설정 객체 또는 동일한 필드를 포함하는 딕셔너리를 허용합니다. 이는 형식 어노테이션에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스, 음성 구성 경계 전반에 적용됩니다. SDK에서 정의한 중첩 설정 유형에도 딕셔너리를 사용할 수 있습니다. ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK에서 정의한 dataclass 구성 유형에 알 수 없는 필드가 있으면 `TypeError` 오류가 발생하므로, 옵션 이름의 오타를 조기에 발견할 수 있습니다. 특정 경계에서 딕셔너리를 허용하는지 확인하려면 매개변수의 형식 주석이나 API 레퍼런스를 확인하세요. +SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK에서 정의한 데이터 클래스 구성 유형에 알 수 없는 필드가 있으면 `TypeError`가 발생하므로, 옵션 이름의 오타를 조기에 발견하는 데 도움이 됩니다. 특정 경계에서 딕셔너리를 허용하는지 확인하려면 해당 매개변수의 형식 어노테이션 또는 API 레퍼런스를 확인하세요. ## API 키와 클라이언트 -기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. SDK가 처음 OpenAI 클라이언트를 생성할 때 키가 확인되므로(지연 초기화), 첫 번째 모델 호출 전에 환경 변수를 설정하세요. 앱이 시작되기 전에 이 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용하여 키를 설정할 수 있습니다. +기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. SDK가 OpenAI 클라이언트를 처음 생성할 때 키가 확인되므로(지연 초기화), 첫 모델 호출 전에 환경 변수를 설정하세요. 앱 시작 전에 해당 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용하여 키를 설정할 수 있습니다. ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -또는 사용할 OpenAI 클라이언트를 구성할 수도 있습니다. 기본적으로 SDK는 환경 변수의 API 키나 위에서 설정한 기본 키를 사용하여 `AsyncOpenAI` 인스턴스를 생성합니다. [set_default_openai_client()][agents.set_default_openai_client] 함수를 사용하여 이를 변경할 수 있습니다. +또는 사용할 OpenAI 클라이언트를 구성할 수도 있습니다. 기본적으로 SDK는 환경 변수의 API 키 또는 위에서 설정한 기본 키를 사용하여 `AsyncOpenAI` 인스턴스를 생성합니다. [set_default_openai_client()][agents.set_default_openai_client] 함수를 사용하여 이를 변경할 수 있습니다. ```python from openai import AsyncOpenAI @@ -55,14 +55,38 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -환경 기반 엔드포인트 구성을 선호하는 경우 기본 OpenAI 공급자는 `OPENAI_BASE_URL` 환경 변수도 읽습니다. Responses WebSocket 전송을 활성화하면 WebSocket `/responses` 엔드포인트에 사용할 `OPENAI_WEBSOCKET_BASE_URL` 환경 변수도 읽습니다. +### `openai` v3 기반 맞춤형 HTTP 클라이언트 + +버전 0.21.0에는 `openai>=3.0.0,<4`이 필요합니다. 기본 OpenAI 공급자는 HTTPX2를 사용하므로 대부분의 애플리케이션에서는 HTTP 클라이언트를 직접 구성할 필요가 없습니다. 애플리케이션이 `AsyncOpenAI`에 `http_client=`을 전달하는 경우, 맞춤형 클라이언트와 전송 관련 옵션에 HTTPX2 유형을 사용하세요. + +```python +import httpx2 +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + +from agents import set_default_openai_client + +http_client = DefaultAsyncHttpx2Client( + timeout=httpx2.Timeout(30.0, connect=5.0), +) +custom_client = AsyncOpenAI( + api_key="...", + http_client=http_client, +) +set_default_openai_client(custom_client) +``` + +동일한 마이그레이션이 맞춤형 전송, 인증, 이벤트 훅, 모의 전송, URL, 요청, 응답, 전송 예외 처리에도 적용됩니다. 각각에 해당하는 `httpx2`을 사용하세요. Agents SDK는 임의의 레거시 `httpx` 객체를 HTTPX2로 변환하지 않습니다. 애플리케이션에서 `httpx`을 명시적으로 설치하면 OpenAI Python SDK가 레거시 클라이언트를 위한 임시 호환성 경로를 제공하지만, 새 코드와 마이그레이션된 코드는 HTTPX2를 사용해야 합니다. + +이 OpenAI 클라이언트 경계는 로컬 MCP 전송 맞춤 설정과 별개입니다. MCP Python SDK v1은 자체 레거시 `httpx` 종속성을 사용하고, MCP Python SDK v2는 `httpx2`을 사용합니다. 자세한 내용은 [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참조하세요. + +환경 기반 엔드포인트 구성을 선호하는 경우 기본 OpenAI 공급자는 `OPENAI_BASE_URL`도 읽습니다. Responses 웹소켓 전송을 활성화하면 웹소켓 `/responses` 엔드포인트에 사용할 `OPENAI_WEBSOCKET_BASE_URL`도 읽습니다. ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -마지막으로 사용되는 OpenAI API도 사용자 지정할 수 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용하면 이를 재정의하여 Chat Completions API를 사용할 수 있습니다. +마지막으로 사용할 OpenAI API도 맞춤 설정할 수 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용하여 Chat Completions API를 사용하도록 재정의할 수 있습니다. ```python from agents import set_default_openai_api @@ -72,7 +96,7 @@ set_default_openai_api("chat_completions") ## OpenAI 공급자 기본값 -SDK의 OpenAI 백엔드를 사용하는 공급자는 모델 이름 문자열을 모델에 매핑할 때 SDK 전역 기본값도 읽습니다. OpenAI Responses 모델이 기본적으로 WebSocket 전송을 사용하도록 하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]를 사용하세요. +SDK의 OpenAI 백엔드를 사용하는 공급자는 모델 이름 문자열을 모델에 매핑할 때 SDK 전역 기본값도 읽습니다. OpenAI Responses 모델에서 기본적으로 웹소켓 전송을 사용하도록 하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]을 사용하세요. ```python from agents import set_default_openai_responses_transport @@ -80,9 +104,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 공급자가 모델 이름을 해석할 때 생성되는 OpenAI Responses 모델에 영향을 줍니다. 공급자 수준 설정, 연결 재사용, keepalive 옵션, 사용자 지정 WebSocket 엔드포인트에 관한 자세한 내용은 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참조하세요. +이는 기본 OpenAI 공급자가 모델 이름을 확인하여 생성한 OpenAI Responses 모델에 영향을 줍니다. 공급자 수준 설정, 연결 재사용, keepalive 옵션, 맞춤형 웹소켓 엔드포인트에 관한 자세한 내용은 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참조하세요. -OpenAI 설정에 공급자 수준의 에이전트 등록 메타데이터가 필요한 경우 시작 시 기본 하네스 ID를 한 번 구성하세요. +OpenAI 설정에서 공급자 수준 에이전트 등록 메타데이터가 필요한 경우 시작 시 기본 하네스 ID를 한 번 구성하세요. ```python from agents import set_default_openai_harness @@ -100,11 +124,11 @@ set_default_openai_agent_registration( ) ``` -SDK 기본값이 설정되지 않은 경우 SDK의 OpenAI 백엔드를 사용하는 공급자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수를 대신 사용합니다. 하네스 ID가 구성되어 있으면 `RunConfig.trace_metadata` 내에 해당 키가 이미 존재하지 않는 한 SDK는 이를 `agent_harness_id` 항목으로 트레이스 메타데이터에 추가합니다. +SDK 기본값을 설정하지 않으면 SDK의 OpenAI 백엔드를 사용하는 공급자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수를 대신 사용합니다. 하네스 ID가 구성된 경우 `RunConfig.trace_metadata`에 해당 키가 아직 없으면 SDK가 이를 `agent_harness_id`으로 트레이스 메타데이터에 추가합니다. ## 트레이싱 -트레이싱은 기본적으로 활성화되어 있습니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수 또는 설정한 기본 키를 사용합니다. 트레이싱에 사용할 API 키는 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하여 별도로 설정할 수 있습니다. +트레이싱은 기본적으로 활성화되어 있습니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수 또는 설정한 기본 키를 사용합니다. 트레이싱에 사용할 API 키를 별도로 설정하려면 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하세요. ```python from agents import set_tracing_export_api_key @@ -112,7 +136,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -모델 트래픽에는 특정 키나 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 하는 경우 기본 키나 클라이언트를 설정할 때 `use_for_tracing=False` 옵션을 전달한 다음 트레이싱을 별도로 구성하세요. 사용자 지정 클라이언트를 사용하지 않는 경우 [`set_default_openai_key()`][agents.set_default_openai_key]에도 동일한 패턴을 적용할 수 있습니다. +모델 트래픽에는 한 키 또는 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 하는 경우, 기본 키 또는 클라이언트를 설정할 때 `use_for_tracing=False`을 전달한 다음 트레이싱을 별도로 구성하세요. 맞춤형 클라이언트를 사용하지 않는다면 [`set_default_openai_key()`][agents.set_default_openai_key]에도 같은 방식을 사용할 수 있습니다. ```python from openai import AsyncOpenAI @@ -127,14 +151,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -기본 익스포터를 사용할 때 트레이스를 특정 조직이나 프로젝트에 귀속해야 하는 경우 앱이 시작되기 전에 다음 환경 변수를 설정하세요. +기본 내보내기를 사용할 때 트레이스를 특정 조직이나 프로젝트에 귀속해야 한다면 앱 시작 전에 다음 환경 변수를 설정하세요. ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -전역 익스포터를 변경하지 않고 실행별 트레이싱 API 키를 설정할 수도 있습니다. +전역 내보내기를 변경하지 않고 실행별 트레이싱 API 키를 설정할 수도 있습니다. ```python from agents import Runner, RunConfig @@ -154,7 +178,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -트레이싱은 활성화된 상태로 유지하면서 잠재적으로 민감한 입력과 출력을 트레이스 페이로드에서 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 설정에 `False` 값을 지정하세요. +트레이싱을 활성화된 상태로 유지하되 민감할 수 있는 입력과 출력을 트레이스 페이로드에서 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]을 `False`로 설정하세요. ```python from agents import Runner, RunConfig @@ -166,13 +190,13 @@ await Runner.run( ) ``` -앱이 시작되기 전에 다음 환경 변수를 설정하면 코드 없이 기본값을 변경할 수도 있습니다. +앱 시작 전에 다음 환경 변수를 설정하여 코드 없이 기본값을 변경할 수도 있습니다. ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -트레이싱의 모든 제어 옵션은 [트레이싱 가이드](tracing.md)를 참조하세요. +전체 트레이싱 제어 옵션은 [트레이싱 가이드](tracing.md)를 참조하세요. ## 디버그 로깅 @@ -186,7 +210,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -또는 핸들러, 필터, 포매터 등을 추가하여 로그를 사용자 지정할 수 있습니다. 자세한 내용은 [Python 로깅 가이드](https://docs.python.org/3/howto/logging.html)를 참조하세요. +또는 핸들러, 필터, 포매터 등을 추가하여 로그를 맞춤 설정할 수 있습니다. 자세한 내용은 [Python 로깅 가이드](https://docs.python.org/3/howto/logging.html)를 참조하세요. ```python import logging @@ -205,22 +229,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 로그와 진단의 민감한 데이터 +### 로그 및 진단의 민감한 데이터 -일부 로그와 진단 예외에는 민감한 데이터(예: 모델 또는 도구의 입력과 출력)가 포함될 수 있습니다. +일부 로그와 진단 예외에는 민감한 데이터가 포함될 수 있습니다(예: 모델 또는 도구의 입력과 출력). -기본적으로 SDK는 LLM 입력과 출력 또는 도구 입력과 출력을 **로그에 기록하지 않습니다**. 이러한 보호 기능은 다음 설정으로 제어합니다. +기본적으로 SDK는 LLM 입력/출력이나 도구 입력/출력을 로그에 기록하지 **않습니다**. 이러한 보호 기능은 다음 항목으로 제어합니다. ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -디버깅을 위해 이 데이터를 일시적으로 포함해야 하는 경우 앱이 시작되기 전에 두 변수 중 하나에 `0` 값(또는 `false`)을 설정하세요. +디버깅을 위해 이 데이터를 일시적으로 포함해야 한다면 앱 시작 전에 두 변수 중 하나를 `0`(또는 `false`)로 설정하세요. ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -이 플래그는 영향을 받는 오류가 페이로드를 포함한 진단 세부정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 비식별화가 활성화된 상태에서 `FunctionTool` 인수가 유효하지 않으면, 근본적인 유효성 검사 오류를 예외 체인에 연결하지 않고 일반적인 `ModelBehaviorError` 오류가 발생합니다. 두 변수 중 하나에 `0` 값을 설정하면 가공되지 않은 모델 또는 도구 데이터가 로그, 예외 메시지, 예외 체인, 기타 진단 컨텍스트에 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file +이 플래그는 영향을 받는 실패가 페이로드를 포함한 진단 세부 정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 마스킹이 활성화된 경우 `FunctionTool`에 대한 잘못된 인수는 내부 검증 오류를 예외 체인으로 연결하지 않고 일반적인 `ModelBehaviorError`을 발생시킵니다. 두 변수 중 하나를 `0`로 설정하면 로그, 예외 메시지, 예외 체인, 기타 진단 컨텍스트에 가공되지 않은 모델 또는 도구 데이터가 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file diff --git a/docs/ko/release.md b/docs/ko/release.md index cf1882194f..3b52ca37cb 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -4,66 +4,79 @@ search: --- # 릴리스 프로세스/변경 로그 -이 프로젝트는 `0.Y.Z` 형식으로 의미론적 버전 관리(semantic versioning)를 약간 수정한 방식을 따릅니다. 앞의 `0`은 SDK가 아직 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. +이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 시맨틱 버전 관리 방식을 따릅니다. 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. ## 마이너(`Y`) 버전 -베타로 표시되지 않은 공개 인터페이스의 **호환성을 깨는 변경 사항**이 있을 때 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. +베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 있으면 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. 호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. ## 패치(`Z`) 버전 -호환성을 깨지 않는 다음 변경 사항이 있을 때 `Z`을 증가시킵니다. +호환성을 깨지 않는 다음 변경 사항에는 `Z`을 증가시킵니다. - 버그 수정 - 새로운 기능 - 비공개 인터페이스 변경 - 베타 기능 업데이트 -## 호환성을 깨는 변경 사항 변경 로그 +## 호환성을 깨는 변경 사항의 변경 로그 + +### 0.21.0 + +버전 0.21.0에는 `openai` v3이 필요하며 Agents SDK의 OpenAI HTTP 통합이 HTTPX2로 이전됩니다. 기본 OpenAI 클라이언트를 사용하는 애플리케이션은 클라이언트 설정을 변경할 필요가 없지만, OpenAI HTTP 계층을 사용자 지정하는 애플리케이션은 전송 계층 관련 코드를 마이그레이션해야 할 수 있습니다. + +주요 변경 사항: + +- 이제 필수 OpenAI 종속성은 `openai>=3.0.0,<4`입니다. 코어를 새로 설치하면 HTTPX2가 사용되며 더 이상 레거시 `httpx`이 직접 종속성으로 설치되지 않습니다. +- 이제 기본 OpenAI 제공자, 음성 제공자, Responses WebSocket 지원, 트레이싱 내보내기 도구, 제공자 재시도 정규화에서 HTTPX2를 사용합니다. 기존 Agents SDK 공개 구성과 런타임 동작은 변경되지 않습니다. +- `AsyncOpenAI`에 `http_client=`를 전달하는 애플리케이션은 사용자 지정 클라이언트, 전송, 인증, 이벤트 훅, 모의 전송, 시간 제한 값, URL, 요청, 응답, 전송 예외 처리를 `httpx`에서 `httpx2`로 마이그레이션해야 합니다. 애플리케이션에 OpenAI 클라이언트의 기본값과 사용자 지정 HTTP 옵션이 모두 필요한 경우 OpenAI Python SDK의 `DefaultAsyncHttpx2Client`을 사용하는 것이 좋습니다. [`openai` v3을 사용하는 사용자 지정 HTTP 클라이언트](config.md#custom-http-clients-with-openai-v3)를 참고하세요. +- Agents SDK는 임의의 레거시 HTTPX 객체를 HTTPX2로 변환하지 않습니다. OpenAI Python SDK의 임시 레거시 클라이언트 호환성 경로에는 명시적으로 `httpx`을 설치해야 하며, 이를 마이그레이션을 위한 임시 연결 수단으로 간주해야 합니다. +- 로컬 MCP HTTP 사용자 지정은 계속해서 설치된 MCP 패키지를 따릅니다. MCP Python SDK v1은 레거시 `httpx`을 제공하고 사용하며, MCP Python SDK v2는 `httpx2`을 사용합니다. 일반적인 MCP 연결은 애플리케이션을 변경할 필요가 없습니다. [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. +- 이제 제공자와 무관한 공개 테스트 유틸리티를 사용하여 제공자 또는 프로세스 종속성 없이 에이전트 모델, 샌드박스 세션, 실시간 세션, 음성 파이프라인 워크플로를 테스트할 수 있습니다. 사용 방법과 실제 제공자 어댑터 또는 통합 경계를 유지해야 하는 경우에 관한 지침은 [테스트](testing.md)를 참고하세요. ### 0.20.0 -버전 0.20.0에는 로컬 MCP HTTP 전송을 사용자 지정하는 애플리케이션에 호환성을 깨는 변경이 될 수 있는 MCP 종속성 마이그레이션이 포함됩니다. 또한 에이전트나 실행에서 모델을 명시적으로 선택하지 않을 때 사용하는 SDK 기본 모델도 업데이트됩니다. +버전 0.20.0에는 로컬 MCP HTTP 전송을 사용자 지정하는 애플리케이션에서 호환성을 깨뜨릴 가능성이 있는 MCP 종속성 마이그레이션이 포함됩니다. 에이전트나 실행에서 모델을 명시적으로 선택하지 않을 때 사용하는 SDK 기본 모델도 업데이트됩니다. -주요 내용: +주요 변경 사항: -- 이제 SDK 기본 모델은 `gpt-5.4-mini`이 아니라 `gpt-5.6-luna`입니다. 기본 `reasoning.effort="none"` 및 `verbosity="low"` 설정은 변경되지 않았습니다. -- 명시적인 에이전트 모델, 실행 수준 모델 재정의 및 `OPENAI_DEFAULT_MODEL` 환경 변수는 계속해서 SDK 기본값보다 우선합니다. -- 이제 실시간 입력 전사 설정에서 `gpt-transcribe`, `gpt-live-transcribe`, `gpt-realtime-whisper`을 인식합니다. 지연 시간이 짧은 `gpt-live-transcribe` 세션에서는 중첩된 `audio.input.transcription` 설정으로 `prompt`, `keywords` 및 예상되는 여러 `languages`을 제공할 수 있습니다. 이 SDK가 고정하여 사용하는 OpenAI 클라이언트 버전은 `delay` 지연 시간/정확도 수준을 `gpt-realtime-whisper`에서만 지원합니다. 커밋된 오디오 턴 이후의 전사 또는 감지된 언어 출력을 위해서는 WebSocket에서 `gpt-transcribe`을 사용합니다. `audio.input.turn_detection=None`을 명시적으로 설정하면 자동 턴 감지가 비활성화됩니다. [입력 전사 설정](realtime/guide.md#input-transcription-settings)을 참조하세요. -- 이제 Agents SDK에서 생성한 로컬 MCP 연결은 `mcp>=1.19.0,<3`을 통해 v1 호환성을 유지하면서 MCP Python SDK v2를 지원합니다. Agents SDK는 일반적인 stdio, SSE 및 Streamable HTTP 연결을 자동으로 조정합니다. MCP v2가 설치되어 있으면 이러한 연결은 `mcp.Client(mode="auto")`을 사용해 지원되는 최신 프로토콜을 탐색하고, 이전 서버에서는 레거시 `initialize` 핸드셰이크로 대체합니다. 종속성 확인 결과 MCP v2가 선택되는 경우, 사용자 지정 `httpx.Auth` 객체 또는 `httpx.AsyncClient` 팩토리를 제공하는 애플리케이션은 해당 값을 `httpx2`로 마이그레이션하거나, v1 HTTP 스택을 유지하도록 `mcp<2`을 고정해야 합니다. `MCPServerStreamableHttp`의 `params["ignore_initialized_notification_failure"] = True` 옵션도 계속 v1에서만 사용할 수 있습니다. 마이그레이션 세부 정보는 [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참조하세요. -- 이제 샌드박스 마운트 검증은 샌드박스나 마운트 헬퍼의 부작용이 발생하기 전에 안전하지 않은 자격 증명 배치를 거부합니다. 신뢰할 수 있는 애플리케이션은 스토리지 기능 테이블을 변경하지 않고도 컨테이너 내부의 정확한 마운트 경로에 대한 마운트 범위 또는 광범위한 자격 증명 노출을 확인할 수 있습니다. 이러한 확인은 런타임에서만 유효하며, 직렬화된 샌드박스 상태만으로는 자격 증명 권한이 부여되지 않습니다. 보호된 마운트 경계에서 SDK는 새로 생성한 수정된 예외를 반환합니다. 소스 예외가 정확히 인식되는 SDK 샌드박스 오류이고 승인된 구조화 필드가 검증을 통과하면, 대체 예외는 해당 하위 유형과 검증된 안전 필드를 유지합니다. 인식된 `MountConfigError`도 SDK에서 생성한 안전한 검증 메시지를 유지할 수 있습니다. 그 외의 경우 SDK는 새로 생성한 일반적인 수정된 오류를 반환합니다. 제공자가 제어하거나 승인되지 않은 메시지, 명령 데이터, 메모, 컨텍스트, 원인 및 소스 트레이스백 상태는 유지되지 않습니다. [마운트 및 원격 스토리지](sandbox/clients.md#mounts-and-remote-storage)와 [세션 상태에서 재개](sandbox/guide.md#resume-from-session-state)를 참조하세요. -- 재시도 정책은 안정적인 재실행 안전성 정보를 검사하고, 제공자가 안전하지 않다고 표시한 비스트리밍 요청에 대해 `RetryDecision(approve_unsafe_replay=True)`을 명시적으로 설정할 수 있습니다. 이 승인은 중단, 이미 방출된 스트리밍 출력 또는 프로그래밍 방식 도구 호출과 같은 별도의 로컬 부작용 거부를 우회하지 않습니다. [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. -- 이제 재개 가능한 `RunState` 객체는 다음 모델 호출 전에 `add_input()`을 사용해 지속 가능한 사용자 입력을 스테이징할 수 있습니다. 스테이징된 입력은 직렬화 후에도 유지되고 입력 가드레일을 거치며, 로컬 세션 및 서버 관리형 대화 전반에서 지속 가능한 SDK 입력 1건을 생성합니다. 안전하지 않은 재실행을 명시적으로 승인하더라도 입력이 제공자에게 다시 전송되어 제공자 측 작업이 반복될 수 있습니다. [재개 전 입력 추가](results.md#add-input-before-resuming)를 참조하세요. -- 런타임 안정성 수정으로 스트리밍 및 비스트리밍 [출력 가드레일 세션 지속성](guardrails.md#output-guardrails)을 일치시키고, 복사 및 네임스페이스 적용 중에 `FunctionTool` 하위 클래스를 유지하며, 지원되지 않는 [Chat Completions 오디오 출력](models/index.md#chat-completions-compatibility-options)에 대해 빈 스트림을 조용히 완료하는 대신 명시적인 오류를 발생시킵니다. `OpenAIResponsesCompactionSession` 래퍼는 취소가 호출자에게 전달되기 전에 [압축 전 기록 복구](sessions/index.md#auto-compaction-can-block-streaming)를 시도하고 완료될 때까지 기다립니다. 이제 [`VoicePipeline`](voice/pipeline.md#results) 소비자는 실행이 정상적으로 완료된 후 전사 세션 종료 실패를 전달받으며, 이전 턴의 실패가 이후 종료 실패보다 우선합니다. 이제 `RunState` 왕복 변환은 로컬 셸 출력, 확인된 컴퓨터 안전 검사, 기본값이 설정된 도구 출력 필드, 그리고 딕셔너리, 목록 또는 튜플을 순회하는 동안 발견한 Pydantic 모델이나 데이터 클래스 출력을 유지합니다. MCP 변환은 자유 형식 객체 스키마와 이미지 출력을 유지하며, 오디오 및 리소스 블록과 같은 기타 raw 콘텐츠 블록을 유효한 JSON 텍스트로 직렬화합니다. `MCPServerManager`은 겹치는 수명 주기 작업을 직렬화하고 연결 및 정리에 유한한 기본 제한 시간을 적용합니다. 모델 재실행은 출력 항목을 입력으로 사용하기 전에 서버 소유의 `created_by` 메타데이터를 제거합니다. +- 이제 SDK 기본 모델은 `gpt-5.4-mini` 대신 `gpt-5.6-luna`입니다. 기본 `reasoning.effort="none"` 및 `verbosity="low"` 설정은 변경되지 않습니다. +- 명시적인 에이전트 모델, 실행 수준 모델 재정의, `OPENAI_DEFAULT_MODEL` 환경 변수는 계속해서 SDK 기본값보다 우선합니다. +- 이제 실시간 입력 전사 설정에서 `gpt-transcribe`, `gpt-live-transcribe`, `gpt-realtime-whisper`을 인식합니다. 지연 시간이 짧은 `gpt-live-transcribe` 세션의 경우 중첩된 `audio.input.transcription` 설정에서 `prompt`, `keywords`, 예상되는 여러 `languages`을 제공할 수 있습니다. 이 SDK에서 고정한 OpenAI 클라이언트 버전은 `delay` 지연 시간/정확도 수준을 `gpt-realtime-whisper`에서만 지원합니다. 확정된 오디오 턴 이후의 전사 또는 감지된 언어 출력에는 WebSocket을 통해 `gpt-transcribe`을 사용하세요. `audio.input.turn_detection=None`을 명시적으로 설정하면 자동 턴 감지가 비활성화됩니다. [입력 전사 설정](realtime/guide.md#input-transcription-settings)을 참고하세요. +- 이제 Agents SDK에서 생성한 로컬 MCP 연결은 `mcp>=1.19.0,<3`을 통해 v1 호환성을 유지하면서 MCP Python SDK v2를 지원합니다. Agents SDK는 일반적인 stdio, SSE, Streamable HTTP 연결을 자동으로 조정합니다. MCP v2가 설치된 경우 이러한 연결은 `mcp.Client(mode="auto")`을 사용해 지원되는 최신 프로토콜을 탐색하고, 이전 서버에서는 레거시 `initialize` 핸드셰이크로 대체합니다. 종속성 해결 과정에서 MCP v2가 선택된 경우 사용자 지정 `httpx.Auth` 객체나 `httpx.AsyncClient` 팩터리를 제공하는 애플리케이션은 해당 값을 `httpx2`으로 마이그레이션하거나, v1 HTTP 스택을 유지하려면 `mcp<2`을 고정해야 합니다. `MCPServerStreamableHttp`의 `params["ignore_initialized_notification_failure"] = True` 옵션도 계속 v1에서만 사용할 수 있습니다. 마이그레이션 세부 정보는 [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. +- 이제 샌드박스 마운트 검증은 샌드박스 또는 마운트 도우미의 부수 효과가 발생하기 전에 안전하지 않은 자격 증명 배치를 거부합니다. 신뢰할 수 있는 애플리케이션은 저장소 기능 표를 변경하지 않고도 컨테이너 내부의 정확한 마운트 경로에 대해 마운트 범위 또는 광범위한 자격 증명 노출을 명시적으로 승인할 수 있습니다. 이러한 승인은 런타임에만 적용되며, 직렬화된 샌드박스 상태 자체로는 자격 증명 권한이 부여되지 않습니다. 보호된 마운트 경계에서 SDK는 민감 정보가 제거된 새 예외를 반환합니다. 소스 예외가 정확히 인식되는 SDK 샌드박스 오류이고 승인된 구조화 필드가 검증되면, 대체 예외는 해당 하위 타입과 검증된 안전 필드를 유지합니다. 인식된 `MountConfigError`은 SDK에서 생성한 안전한 검증 메시지도 유지할 수 있습니다. 그 외에는 SDK가 민감 정보가 제거된 새 일반 오류를 반환합니다. 제공자가 제어하거나 그 밖에 승인되지 않은 메시지, 명령 데이터, 참고 사항, 컨텍스트, 원인, 소스 트레이스백 상태는 유지되지 않습니다. [마운트 및 원격 저장소](sandbox/clients.md#mounts-and-remote-storage)와 [세션 상태에서 재개](sandbox/guide.md#resume-from-session-state)를 참고하세요. +- 재시도 정책은 안정적인 재실행 안전성 정보를 검사하고, 제공자가 안전하지 않다고 표시한 비스트리밍 요청에 대해 `RetryDecision(approve_unsafe_replay=True)`을 명시적으로 설정할 수 있습니다. 이 승인은 중단, 이미 방출된 스트리밍 출력 또는 프로그래밍 방식 도구 호출과 같은 별도의 로컬 부수 효과 거부를 우회하지 않습니다. [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참고하세요. +- 이제 재개 가능한 `RunState` 객체는 다음 모델 호출 전에 `add_input()`을 사용해 영속적인 사용자 입력을 준비할 수 있습니다. 준비된 입력은 직렬화 후에도 유지되고 입력 가드레일을 통과하며, 로컬 세션과 서버 관리형 대화 전반에서 영속적인 SDK 입력 발생 1건을 생성합니다. 안전하지 않은 재실행을 명시적으로 승인하면 입력을 제공자에게 다시 전송하고 제공자 측 작업을 반복할 수 있습니다. [재개 전 입력 추가](results.md#add-input-before-resuming)를 참고하세요. +- 런타임 안정성 수정으로 스트리밍 및 비스트리밍 [출력 가드레일 세션 영속성](guardrails.md#output-guardrails)이 일관되게 동작하고, 복사 및 네임스페이스 지정 과정에서 `FunctionTool` 하위 클래스가 유지되며, [지원되지 않는 Chat Completions 오디오 출력](models/index.md#chat-completions-compatibility-options)에 대해 빈 스트림으로 조용히 완료하는 대신 명시적인 오류가 발생합니다. `OpenAIResponsesCompactionSession` 래퍼는 취소가 호출자에게 전달되기 전에 [압축 전 기록 복구](sessions/index.md#auto-compaction-can-block-streaming)를 시도하고 완료될 때까지 기다립니다. 이제 [`VoicePipeline`](voice/pipeline.md#results) 소비자는 정상 실행 이후 발생한 전사 세션 종료 실패를 수신하며, 이전 턴의 실패는 이후 종료 실패보다 우선합니다. 이제 `RunState` 왕복 과정에서 로컬 셸 출력, 승인된 컴퓨터 안전 검사, 기본값이 있는 도구 출력 필드, 딕셔너리·목록·튜플을 순회하며 발견한 Pydantic 모델 또는 데이터 클래스 출력이 유지됩니다. MCP 변환은 자유 형식 객체 스키마와 이미지 출력을 유지하며, 오디오 및 리소스 블록과 같은 기타 raw 콘텐츠 블록을 유효한 JSON 텍스트로 직렬화합니다. `MCPServerManager`는 겹치는 수명 주기 작업을 직렬화하고 연결 및 정리에 유한한 기본 시간 제한을 적용합니다. 모델 재실행은 출력 항목을 입력으로 사용하기 전에 서버가 소유한 `created_by` 메타데이터를 제거합니다. ### 0.19.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새로운 기능 영역인 프로그래밍 방식 도구 호출을 반영합니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 마이너 버전 증가는 중요한 새 OpenAI Responses 기능 영역인 프로그래밍 방식 도구 호출을 반영합니다. -주요 내용: +주요 변경 사항: -- 지원되는 OpenAI Responses 모델이 프로그래밍 방식 도구 호출에 적합한 도구를 조정할 JavaScript를 생성할 수 있도록 하는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 추가했습니다. 도구별 `allowed_callers`, `FunctionTool` 인스턴스의 structured outputs, Runner 스트리밍, 가드레일, 승인, 세션 및 `RunState`과의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래밍 방식 도구 호출](tools.md#programmatic-tool-calling)을 참조하세요. -- 공개 `agents.decorators` 모듈과 기존 `@function_tool` 데코레이터의 짧은 별칭인 `@tool`을 기존 가드레일 데코레이터와 함께 추가했습니다. 이제 `FunctionTool` 인스턴스는 비동기 호출 가능 객체도 지원합니다. -- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 파이프라인 전반에서 형식이 지정된 설정 객체나 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. -- 모델, 도구, MCP, Realtime, 세션, 샌드박스 및 트레이싱 전반의 오류 및 진단 로깅을 강화하여, 유용한 디버깅 컨텍스트를 유지하면서도 가공되지 않은 민감한 페이로드가 노출되지 않도록 했습니다. -- AnyLLM, LiteLLM 및 Chat Completions 호환성을 개선하고, 모델 재시도 전반에서 세션 기록을 유지하며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 제공자 재시도 지침을 추가했습니다. 따라서 허용되는 경우 옵트인 Runner 재시도 정책이 실패한 시도를 다시 실행할 수 있습니다. -- `VercelCloudBucketMountStrategy`을 통해 [Vercel 샌드박스를 생성할 때만 구성할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)를 추가했습니다. 마운트된 세션은 작업 공간 지속성에서 버킷 콘텐츠를 제외하며, 의도적으로 동적 마운트 변경이나 세션 재개를 지원하지 않습니다. +- 지원되는 OpenAI Responses 모델이 프로그래밍 방식 도구 호출에 적합한 도구를 조정하기 위한 JavaScript를 생성할 수 있게 해 주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]이 추가되었습니다. 도구별 `allowed_callers`, `FunctionTool` 인스턴스의 structured outputs, Runner 스트리밍, 가드레일, 승인, 세션, `RunState`과의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래밍 방식 도구 호출](tools.md#programmatic-tool-calling)을 참고하세요. +- 공개 `agents.decorators` 모듈과 기존 가드레일 데코레이터에 더해 기존 `@function_tool` 데코레이터의 더 짧은 별칭인 `@tool`가 추가되었습니다. 이제 `FunctionTool` 인스턴스는 비동기 호출 가능 객체도 지원합니다. +- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스, 음성 파이프라인 전반에서 타입이 지정된 설정 객체 또는 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. +- 유용한 디버깅 컨텍스트를 유지하면서 가공되지 않은 민감한 페이로드가 노출되지 않도록 모델, 도구, MCP, 실시간 기능, 세션, 샌드박스, 트레이싱 전반의 오류 및 진단 로깅을 강화했습니다. +- AnyLLM, LiteLLM, Chat Completions 호환성을 개선하고, 모델 재시도 간에 세션 기록을 유지하며, 응답 시작 전에 발생한 WebSocket 과부하에 대한 제공자 재시도 지침을 추가했습니다. 따라서 명시적으로 활성화된 Runner 재시도 정책은 허용되는 경우 실패한 시도를 재실행할 수 있습니다. +- `VercelCloudBucketMountStrategy`을 통해 [Vercel 샌드박스를 생성할 때만 구성할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)가 추가되었습니다. 마운트된 세션은 워크스페이스 영속성에서 버킷 콘텐츠를 제외하며, 의도적으로 동적 마운트 변경이나 세션 재개를 지원하지 않습니다. ### 0.18.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만을 위한 것입니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 마이너 버전 증가는 실시간 에이전트 기본 모델 업데이트만을 위한 것입니다. -주요 내용: +주요 변경 사항: -- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서 추가 구성 없이 권장되는 최신 모델을 사용합니다. +- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 실시간 설정에서는 추가 구성 없이 최신 권장 모델을 사용합니다. ### 0.17.0 -이 버전에서는 소스 경로가 `Manifest.extra_path_grants`에 포함되지 않는 한, 샌드박스 로컬 소스 구체화 과정에서 `LocalFile.src`와 `LocalDir.src`이 구체화 `base_dir` 내부에 유지됩니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 확인되며, 절대 로컬 소스는 이미 그 안에 있거나 명시적으로 허용된 경로 아래에 있어야 합니다. 이는 로컬 아티팩트 경계 문제를 해결하지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 의도적으로 샌드박스 작업 공간에 복사하는 애플리케이션에 영향을 줄 수 있습니다. +이 버전에서 샌드박스 로컬 소스 구체화는 소스 경로가 `Manifest.extra_path_grants`의 적용 대상이 아닌 한 `LocalFile.src` 및 `LocalDir.src`을 구체화 `base_dir` 내부로 제한합니다. `base_dir`은 매니페스트가 적용되는 시점의 SDK 프로세스 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 그 내부 또는 명시적으로 허용된 경로 아래에 있어야 합니다. 이 변경으로 로컬 아티팩트 경계 문제가 해결되지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에는 영향을 줄 수 있습니다. -마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`을 사용해 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우 읽기 전용으로 설정하는 것이 좋습니다. +마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`을 사용해 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스가 해당 파일을 읽기만 하면 되는 경우에는 읽기 전용으로 설정하는 것이 좋습니다. ```python from pathlib import Path @@ -94,24 +107,24 @@ manifest = Manifest( ### 0.16.0 -이 버전에서는 이제 SDK 기본 모델이 `gpt-4.1`이 아니라 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본값이 GPT-5 모델이므로 암시적인 기본 모델 설정에 이제 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. +이 버전에서 SDK 기본 모델은 이제 `gpt-4.1` 대신 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본값은 GPT-5 모델이므로 암시적인 기본 모델 설정에 이제 `reasoning.effort="none"` 및 `verbosity="low"`과 같은 GPT-5 기본값이 포함됩니다. -이전 기본 모델 동작을 유지해야 한다면 에이전트나 실행 구성에 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. +이전 기본 모델 동작을 유지해야 한다면 에이전트 또는 실행 구성에 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. ```python agent = Agent(name="Assistant", model="gpt-4.1") ``` -주요 내용: +주요 변경 사항: -- 이제 `Runner.run`, `Runner.run_sync`, `Runner.run_streamed`은 턴 제한을 비활성화하기 위한 `max_turns=None`을 허용합니다. -- 이제 샌드박스 작업 공간 하이드레이션은 로컬, Docker 및 제공자 지원 샌드박스 구현 전반에서 절대 심볼릭 링크 대상을 포함해 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. +- 이제 `Runner.run`, `Runner.run_sync`, `Runner.run_streamed`은 턴 제한을 비활성화하는 `max_turns=None`을 허용합니다. +- 이제 샌드박스 워크스페이스 하이드레이션은 로컬, Docker, 제공자 기반 샌드박스 구현 전반에서 절대 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. ### 0.15.0 -이 버전에서는 모델 거부가 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하도록 하는 대신, 이제 `ModelRefusalError`로 명시적으로 노출됩니다. +이 버전에서 모델 거부는 더 이상 빈 텍스트 출력으로 처리되거나, structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하게 하지 않고 `ModelRefusalError`으로 명시적으로 노출됩니다. -이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. +이 변경은 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. ```python result = Runner.run_sync( @@ -121,94 +134,94 @@ result = Runner.run_sync( ) ``` -structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. +structured outputs 에이전트의 경우 핸들러는 에이전트의 출력 스키마과 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. ### 0.14.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 샌드박스 에이전트라는 주요 새 베타 기능 영역과 함께 로컬, 컨테이너화 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가됩니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않지만**, 샌드박스 에이전트라는 주요 새 베타 기능 영역과 로컬, 컨테이너화, 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드, 문서 지원이 추가됩니다. -주요 내용: +주요 변경 사항: -- `SandboxAgent`, `Manifest`, `SandboxRunConfig`을 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스를 추가하여 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷 및 재개 기능을 갖춘 지속적이고 격리된 작업 공간에서 작업할 수 있도록 했습니다. -- `UnixLocalSandboxClient` 및 `DockerSandboxClient`을 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드를 추가했으며, Python 패키지의 선택적 종속성 extras를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop 및 Vercel용 호스팅 제공자 통합도 추가했습니다. -- 이후 실행에서 이전 실행으로부터 얻은 교훈을 재사용할 수 있도록 샌드박스 메모리 지원을 추가했습니다. 여기에는 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계 및 S3 기반 워크플로를 포함한 지속형 메모리 예제가 포함됩니다. -- 로컬 및 합성 작업 공간 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, 그리고 `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하는 더 광범위한 작업 공간 및 재개 모델을 추가했습니다. -- `examples/sandbox/` 아래에 기술을 활용한 코딩 작업, 핸드오프, 메모리, 제공자별 설정 및 코드 검토, 데이터룸 QA, 웹사이트 복제와 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 예제와 튜토리얼을 추가했습니다. -- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값 및 더 안전한 민감한 MCP 출력 수정을 통해 핵심 런타임과 트레이싱 스택을 확장했습니다. +- `SandboxAgent`, `Manifest`, `SandboxRunConfig`을 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스가 추가되어 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷, 재개 지원을 갖춘 영속적인 격리 워크스페이스 내부에서 작업할 수 있습니다. +- `UnixLocalSandboxClient` 및 `DockerSandboxClient`을 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드가 추가되었으며, Python 패키지의 선택적 종속성 extras를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, Vercel용 호스팅 제공자 통합도 추가되었습니다. +- 향후 실행에서 이전 실행의 교훈을 재사용할 수 있도록 샌드박스 메모리 지원이 추가되었습니다. 여기에는 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계, S3 기반 워크플로를 포함한 영속 메모리 코드 예제가 포함됩니다. +- 로컬 및 합성 워크스페이스 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 저장소 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하여 더 광범위한 워크스페이스 및 재개 모델이 추가되었습니다. +- `examples/sandbox/` 아래에 기술, 핸드오프, 메모리, 제공자별 설정을 사용하는 코딩 작업과 코드 검토, 데이터룸 QA, 웹사이트 복제 같은 엔드투엔드 워크플로를 다루는 상당한 규모의 샌드박스 코드 예제 및 튜토리얼이 추가되었습니다. +- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값, 더 안전한 민감 MCP 출력 제거 기능으로 핵심 런타임과 트레이싱 스택이 확장되었습니다. ### 0.13.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함됩니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않지만**, 주목할 만한 실시간 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함됩니다. -주요 내용: +주요 변경 사항: -- 이제 기본 WebSocket Realtime 모델은 `gpt-realtime-1.5`이므로 새로운 실시간 에이전트 설정에서 추가 구성 없이 더 최신 모델을 사용합니다. -- 이제 `MCPServer`에서 `list_resources()`, `list_resource_templates()`, `read_resource()`을 노출하고, `MCPServerStreamableHttp`에서 `session_id`을 노출하므로 MCP Streamable HTTP 전송을 사용하는 세션을 재연결 또는 상태 비저장 워커 전반에서 재개할 수 있습니다. -- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠 재전송을 옵트인할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 제공자별 추론/도구 호출 연속성이 향상됩니다. -- `SQLAlchemySession`의 동시 첫 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경합 상태를 비롯한 여러 런타임 및 세션의 극단적 사례를 수정했습니다. +- 이제 기본 WebSocket 실시간 모델은 `gpt-realtime-1.5`이므로, 새로운 실시간 에이전트 설정에서는 추가 구성 없이 더 최신 모델을 사용합니다. +- 이제 `MCPServer`은 `list_resources()`, `list_resource_templates()`, `read_resource()`을 노출하고, `MCPServerStreamableHttp`은 `session_id`을 노출합니다. 따라서 MCP Streamable HTTP 전송을 사용하는 세션을 재연결 또는 상태 비저장 워커 간에 재개할 수 있습니다. +- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠 재전송을 활성화할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 제공자별 추론/도구 호출 연속성이 향상됩니다. +- `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남겨 두는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경합 상태 등 여러 런타임 및 세션 경계 사례를 수정했습니다. ### 0.12.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. ### 0.11.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. ### 0.10.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **포함되지 않지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함됩니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함됩니다. -주요 내용: +주요 변경 사항: -- OpenAI Responses 모델에 대한 WebSocket 전송 지원을 추가했습니다(옵트인이며 HTTP가 계속 기본 전송 방식입니다). -- 여러 턴의 실행에서 공유 WebSocket 지원 제공자와 `RunConfig`을 재사용하기 위한 `responses_websocket_session()` 헬퍼 / `ResponsesWebSocketSession`을 추가했습니다. -- 스트리밍, 도구, 승인 및 후속 턴을 다루는 새로운 WebSocket 스트리밍 예제(`examples/basic/stream_ws.py`)를 추가했습니다. +- OpenAI Responses 모델에 WebSocket 전송 지원이 추가되었습니다. 선택적으로 활성화할 수 있으며 HTTP가 계속 기본 전송 방식입니다. +- 여러 턴의 실행에서 공유 WebSocket 지원 제공자와 `RunConfig`을 재사용할 수 있도록 `responses_websocket_session()` 도우미/`ResponsesWebSocketSession`이 추가되었습니다. +- 스트리밍, 도구, 승인, 후속 턴을 다루는 새로운 WebSocket 스트리밍 코드 예제(`examples/basic/stream_ws.py`)가 추가되었습니다. ### 0.9.0 -이 버전에서는 주요 버전이 3개월 전에 지원 종료(EOL)에 도달함에 따라 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. +이 버전에서는 해당 메이저 버전이 3개월 전에 EOL에 도달했으므로 Python 3.9가 더 이상 지원되지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. -또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`으로 좁혀졌습니다. 이 변경으로 일반적으로 호환성이 깨지는 문제가 발생하지는 않지만, 코드가 더 넓은 유니언 타입에 의존한다면 일부 조정이 필요할 수 있습니다. +또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`으로 좁혀졌습니다. 일반적으로 이 변경으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니언 타입에 의존하는 경우에는 일부 조정이 필요할 수 있습니다. ### 0.8.0 -이 버전에서는 런타임 동작 변경 사항 두 가지로 인해 마이그레이션 작업이 필요할 수 있습니다. +이 버전에서는 다음 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. -- `FunctionTool` 인스턴스가 래핑하는 **동기식** Python 호출 가능 객체는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태 또는 특정 스레드에 종속된 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시적으로 지정하세요. -- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로, 명시적 핸들러가 있는 각 로컬 MCP 서버에 `failure_error_function=None`을 설정하세요. +- **동기식** Python 호출 가능 객체를 래핑하는 `FunctionTool` 인스턴스는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태 또는 스레드 종속 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시하세요. +- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로 명시적인 핸들러가 있는 각 로컬 MCP 서버에 `failure_error_function=None`을 설정하세요. ### 0.7.0 이 버전에는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경 사항이 있습니다. -- 이제 중첩된 핸드오프 기록은 **옵트인** 방식입니다(기본적으로 비활성화됨). v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. -- `gpt-5.1` / `gpt-5.2`의 기본 `reasoning.effort`이 SDK 기본값으로 구성되었던 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트나 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. +- 이제 중첩 핸드오프 기록은 **선택적 활성화** 방식이며 기본적으로 비활성화됩니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. +- `gpt-5.1`/`gpt-5.2`의 기본 `reasoning.effort`이 SDK 기본값으로 구성되던 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트 또는 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. ### 0.6.0 -이 버전에서는 사용자와 어시스턴트 턴을 별도의 메시지로 전달하는 대신, 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여 후속 에이전트에 간결하고 예측 가능한 요약을 제공합니다 -- 이제 기존의 단일 메시지 핸드오프 대화 기록은 기본적으로 `` 블록 앞에서 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`으로 시작하므로 후속 에이전트가 명확한 레이블이 있는 요약을 받습니다 +이 버전에서는 사용자와 어시스턴트의 턴을 별도 메시지로 전달하는 대신 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여, 이후 에이전트에 간결하고 예측 가능한 요약을 제공합니다 +- 기존 단일 메시지 핸드오프 기록은 이제 기본적으로 `` 블록 앞에 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`으로 시작하므로 이후 에이전트가 명확하게 표시된 요약을 받습니다 ### 0.5.0 -이 버전은 눈에 보이는 호환성을 깨는 변경 사항을 도입하지 않지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되어 있습니다. +이 버전에는 눈에 보이는 호환성을 깨는 변경 사항이 도입되지 않지만, 새로운 기능과 몇 가지 중요한 내부 업데이트가 포함됩니다. -- `RealtimeRunner`에 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip) 처리 지원을 추가했습니다. +- [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리하기 위한 지원이 `RealtimeRunner`에 추가되었습니다. - Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직을 대폭 수정했습니다. ### 0.4.0 -이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전을 더 이상 지원하지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. +이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전이 더 이상 지원되지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. ### 0.3.0 -이 버전에서는 Realtime API 지원이 gpt-realtime 모델 및 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. +이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. ### 0.2.0 -이 버전에서는 이전에 `Agent`을 인수로 받던 몇몇 위치가 이제 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처에 이 변경이 적용됩니다. 이는 순수한 타입 변경이며, 계속해서 `Agent` 객체를 받습니다. 업데이트하려면 `Agent`을 `AgentBase`으로 바꿔 타입 오류를 수정하면 됩니다. +이 버전에서는 이전에 인수로 `Agent`을 받던 일부 위치가 이제 대신 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처에 적용됩니다. 이는 순수한 타입 변경이며 계속 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`을 `AgentBase`으로 바꿔 타입 오류만 수정하면 됩니다. ### 0.1.0 -이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context`와 `agent`이라는 두 개의 새로운 매개변수가 추가되었습니다. `MCPServer` 하위 클래스에서 재정의한 모든 `MCPServer.list_tools()` 메서드에 이러한 매개변수를 추가해야 합니다. \ No newline at end of file +이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context` 및 `agent`이라는 두 개의 새로운 매개변수가 있습니다. `MCPServer`의 하위 클래스에서 재정의한 모든 `MCPServer.list_tools()` 메서드에 이 매개변수를 추가해야 합니다. \ No newline at end of file diff --git a/docs/ko/testing.md b/docs/ko/testing.md new file mode 100644 index 0000000000..bd0161eba4 --- /dev/null +++ b/docs/ko/testing.md @@ -0,0 +1,575 @@ +--- +search: + exclude: true +--- +# 테스트 + +SDK는 에이전트 워크플로, Sandbox 세션, Realtime 세션 및 Voice 파이프라인을 위한 결정론적이고 공급자 중립적인 테스트 유틸리티를 제공합니다. 이러한 유틸리티는 메모리에서 실행되고 모델, Sandbox 공급자 또는 Realtime API에 요청하지 않으며 SDK가 관리하는 정규화된 상호작용을 기록합니다. 아래의 실행 가능한 레시피는 각 실행에서 트레이싱을 비활성화하므로 OpenAI API 키가 구성되어 있어도 기본 트레이스 프로세서가 테스트 활동을 업로드하지 않습니다. + +이러한 유틸리티를 사용하여 애플리케이션과 SDK가 관리하는 오케스트레이션을 테스트할 수 있습니다. 여기에는 도구 실행, 핸드오프, 가드레일, 재시도, 스트리밍, 세션 동작, Sandbox 기능, Realtime 이벤트 처리 및 Voice 파이프라인 구성이 포함됩니다. 외부 모델, 네트워크 프로토콜, Sandbox 공급자 또는 오디오 시스템이 관리하는 동작에는 실제 공급자 어댑터나 통합 환경을 사용하세요. + +## 필요한 레시피 찾기 + +| 원하는 작업 | 사용 항목 | 이동 위치 | +| --- | --- | --- | +| 고정된 최종 답변 반환 | `ScriptedModel` 및 `assistant_message()` | [고정 응답 반환](#return-a-fixed-response) | +| 여러 턴에 걸친 도구 루프 실행 | `function_call()` 후 어시스턴트 응답 | [도구 워크플로 테스트](#test-a-tool-workflow) | +| 요청에서 응답 선택 | `ModelStep.respond()` 또는 `responder` 매핑 | [요청에서 응답 도출](#derive-a-response-from-the-request) | +| 러너가 모델에 전송한 내용 검증 | `calls`, `first_call` 또는 `last_call` | [모델 호출 검사](#inspect-model-calls) | +| 스트리밍 실행 테스트 | 일반 응답 단계 또는 정확한 이벤트를 위한 `ModelStep.stream()` | [스트리밍 테스트](#test-streaming) | +| 오류 또는 재시도 결정 테스트 | `ModelStep.raise_error()` | [모델 실패 주입](#inject-model-failures) | +| 의도하지 않은 워크플로 변경 감지 | 정확한 FIFO 단계 및 `assert_complete()` | [워크플로 드리프트 감지](#detect-workflow-drift) | +| Sandbox를 시작하지 않고 `SandboxAgent` 테스트 | `scripted_sandbox_session()` 및 `ScriptedModel` | [Sandbox 에이전트 워크플로 테스트](#test-a-sandbox-agent-workflow) | +| Sandbox 호출 매칭 또는 결과 도출 | Sandbox 단계의 `match` 또는 `responder` | [Sandbox 단계 구성](#configure-sandbox-steps) | +| 연결을 열지 않고 Realtime 세션 테스트 | `ScriptedRealtimeModel` 및 `RealtimeStep` | [Realtime 세션 테스트](#test-a-realtime-session) | +| Realtime 도구 워크플로 테스트 | `RealtimeModelToolCallEvent`을 내보내고 도구 출력 예상 | [Realtime 도구 워크플로 테스트](#test-a-realtime-tool-workflow) | +| 정적 또는 스트리밍 Voice 파이프라인 테스트 | `ScriptedSTTModel`, `ScriptedTTSModel` 및 스크립트된 워크플로나 실제 워크플로 | [Voice 파이프라인 테스트](#test-a-voice-pipeline) | +| 공급자 직렬화 또는 전송 페이로드 테스트 | 제어된 네트워크 전송을 사용하는 실제 공급자 어댑터 | [올바른 경계 선택](#choose-the-correct-boundary) | + +## 가져오기 + +테스트 API는 대체하는 런타임 경계와 나란히 위치합니다. + +| 경계 | 가져오기 경로 | +| --- | --- | +| 에이전트 모델 및 Sandbox 워크플로 | `agents.testing` | +| Realtime 모델 전송 | `agents.realtime.testing` | +| Voice STT, TTS 및 워크플로 구성 요소 | `agents.voice.testing` | + +테스트 심벌은 의도적으로 최상위 `agents` 가져오기에서 제외됩니다. + +## 에이전트 워크플로 레시피 + +### 고정 응답 반환 + +예상되는 각 모델 호출마다 정규화된 출력 항목 시퀀스를 하나씩 전달합니다. 출력 시퀀스 축약형은 하나의 요청에 대해 결정론적인 응답 ID와 사용량을 받습니다. + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ScriptedModel, assistant_message + + +@pytest.mark.asyncio +async def test_fixed_response() -> None: + model = ScriptedModel( + [[assistant_message("Paris is the capital of France.")]] + ) + agent = Agent(name="Geography assistant", model=model) + + result = await Runner.run( + agent, + "What is the capital of France?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Paris is the capital of France." + assert len(model.calls) == 1 + model.assert_complete() +``` + +결정론적 워크플로 테스트는 `model.assert_complete()`로 마무리하세요. 이 메서드는 구성된 모든 단계를 소비하기 전에 워크플로가 중지된 경우를 포착합니다. + +### 도구 워크플로 테스트 + +도구를 호출하는 모델 응답 하나와 최종 답변을 생성하는 두 번째 응답을 스크립트로 구성합니다. 이러한 모델 호출 사이에서 실제 SDK 도구 파이프라인이 실행됩니다. + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.decorators import tool +from agents.testing import ScriptedModel, assistant_message, function_call + + +@tool +def get_weather(city: str) -> str: + """Return the weather for a city.""" + return f"{city}: sunny" + + +@pytest.mark.asyncio +async def test_tool_workflow() -> None: + model = ScriptedModel( + [ + [function_call("get_weather", {"city": "Tokyo"}, call_id="call_1")], + [assistant_message("It is sunny in Tokyo.")], + ] + ) + agent = Agent(name="Weather assistant", model=model, tools=[get_weather]) + + result = await Runner.run( + agent, + "What is the weather in Tokyo?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "It is sunny in Tokyo." + assert len(model.calls) == 2 + assert model.last_call is not None + assert any( + item.get("type") == "function_call_output" + for item in model.last_call.input + ) + model.assert_complete() +``` + +이 패턴은 도구 입력 검증, 실행, 결과 변환, 훅, 가드레일 및 다음 모델 턴을 포괄합니다. Python 함수를 직접 호출하면 이러한 SDK 동작을 우회하게 됩니다. + +### 요청에서 응답 도출 + +응답이 실제로 정규화된 모델 호출에 따라 달라지거나 모델 경계에서 검증해야 할 때 `ModelStep.respond()`을 사용하세요. 응답자는 동기식 또는 비동기식일 수 있으며 `ScriptedModel`이 허용하는 모든 단계 형식을 반환할 수 있습니다. + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ModelCall, ModelStep, ScriptedModel, assistant_message + + +def respond(call: ModelCall): + assert call.streamed is False + assert call.input == [{"content": "Summarize this", "role": "user"}] + return {"output": [assistant_message("Handled the normalized request.")]} + + +@pytest.mark.asyncio +async def test_request_aware_response() -> None: + model = ScriptedModel([ModelStep.respond(respond)]) + agent = Agent(name="Assistant", model=model) + + result = await Runner.run( + agent, + "Summarize this", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Handled the normalized request." + model.assert_complete() +``` + +`ScriptedModel`은 `ModelStep`, 이에 해당하는 딕셔너리 형식, `ModelResponse`, 정규화된 출력 항목 시퀀스 또는 예외를 허용합니다. 응답이 호출에 따라 달라지지 않을 때는 고정 출력 시퀀스를 사용하는 것이 좋습니다. 고정 스크립트를 사용하면 예상하지 못한 턴을 더 쉽게 진단할 수 있습니다. + +### 모델 호출 검사 + +`ScriptedModel`은 선택된 단계를 해결하거나 예외를 발생시키기 전에 각 호출을 기록합니다. + +| 멤버 | 포함 내용 | +| --- | --- | +| `calls` | 호출 순서에 따른 모든 `ModelCall` | +| `first_call` | 첫 번째 호출 또는 `None` | +| `last_call` | 가장 최근 호출 또는 `None` | +| `remaining_steps` | 아직 소비되지 않은 구성된 단계의 수 | + +일반적으로 `call.input`, `call.model_settings`, `call.tools`, `call.handoffs` 및 `call.streamed`을 검증합니다. 변경 가능한 요청 데이터는 호출 경계에서 스냅샷으로 저장되며 각 공개 기록 접근자는 분리된 스냅샷을 반환합니다. 도구, 핸드오프, 출력 스키마 및 트레이싱 객체는 런타임 정체성을 유지합니다. + +구조화된 `call_index` 및 `input_index` 오류 필드는 0부터 시작하므로 `calls[...]` 또는 제공된 단계 시퀀스를 직접 인덱싱할 수 있습니다. 사람이 읽을 수 있는 오류 메시지에는 1부터 시작하는 호출 또는 단계 번호가 표시됩니다. + +하나의 테스트에서 모델 단계를 점진적으로 추가해야 할 때는 `enqueue()` 또는 `extend()`을 사용하세요. 독립적인 시나리오에는 새 `ScriptedModel`를 생성하세요. 이 유틸리티는 소비된 단계나 호출 기록을 재설정하지 않습니다. + +### 스트리밍 테스트 + +일반 응답 단계는 `Runner.run()`과 `Runner.run_streamed()`을 모두 지원합니다. 일반적인 어시스턴트 메시지, 추론 항목, 함수 호출 및 패치 적용 호출의 경우 `ScriptedModel`가 정규화된 시작, 델타, 항목 완료 및 최종 응답 이벤트를 생성합니다. 최종 응답에는 전체 출력과 사용량이 포함됩니다. + +정확히 정규화된 `TResponseStreamEvent` 시퀀스가 테스트 대상 동작의 일부인 경우에만 `ModelStep.stream()`을 사용하세요. + +```python +step = ModelStep.stream( + events, + output=[assistant_message("The terminal output used by the runner.")], +) +``` + +`events`는 고정 시퀀스이거나 기록된 `ModelCall`을 받는 비동기 팩토리일 수 있습니다. 선택적 `output`은 동일한 단계가 비스트리밍 호출에 사용될 때 반환되는 응답입니다. 정확한 스트림 이벤트는 SDK에서 정규화한 이벤트이며 Responses API 또는 Chat Completions의 전송 청크가 아닙니다. + +자동 스트리밍은 증분 수명 주기가 구현되지 않은 정규화된 출력 항목 유형을 거부합니다. 이러한 항목에는 부분적인 이벤트 시퀀스에 의존하지 말고 `ModelStep.stream(...)`을 사용하세요. + +### 모델 실패 주입 + +모델 호출 하나를 실패시키려면 `ModelStep.raise_error()`를 사용하세요. 선택적 재시도 권고는 해당 스크립트 오류에만 적용됩니다. + +```python +from agents import ModelRetryAdvice +from agents.testing import ModelStep + + +step = ModelStep.raise_error( + RuntimeError("temporary failure"), + retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"), +) +``` + +러너의 재시도 정책에 따라 권고가 추가 시도를 유발할지 결정됩니다. 각 재시도는 또 다른 모델 호출이며 다음 스크립트 단계를 소비합니다. Python 헬퍼는 고정된 `ModelRetryAdvice` 값을 허용합니다. 재시도 권고 자체가 시도마다 동적으로 달라져야 하는 경우 사용자 지정 `Model`을 사용하세요. + +### 워크플로 드리프트 감지 + +스크립트된 호출을 예상 워크플로 형태로 간주하세요. 추가 모델 요청이 발생하면 `UnexpectedModelCall`가 발생하며, 조기에 종료되면 `assert_complete()`이 보고할 단계가 남습니다. + +테스트 프레임워크가 정리 작업이나 finalizer를 지원하고 다른 검증이 실패한 후에도 소비되지 않은 단계를 보고하려면 `assert_complete()`를 그 위치에 배치하세요. 일반적인 회귀 테스트에서는 불일치 오류를 포착하지 마세요. + +| 오류 | 구조화된 필드 | 의미 | +| --- | --- | --- | +| `InvalidModelStep` | `reason`, `input_index` | 단계 형식이 잘못되어 큐에 들어가기 전에 거부됨 | +| `UnexpectedModelCall` | `call`, `call_index` | 스크립트가 끝난 후 워크플로가 또 다른 모델 호출을 수행함 | +| `UnconsumedModelSteps` | `remaining_steps` | 모든 단계를 사용하기 전에 워크플로가 종료됨 | + +## Sandbox 에이전트 레시피 + +### Sandbox 에이전트 워크플로 테스트 + +`ScriptedModel`과 `scripted_sandbox_session()`를 결합하면 로컬 컨테이너나 원격 Sandbox를 생성하지 않고도 실제 `SandboxAgent` 런타임을 실행할 수 있습니다. 모델 스크립트는 기능 도구를 선택하고, Sandbox 스크립트는 해당 `SandboxSession` 메서드가 반환할 값을 정의합니다. + +```python +import pytest + +from agents import RunConfig, Runner +from agents.sandbox import ExecResult, SandboxAgent +from agents.sandbox.capabilities import Shell +from agents.testing import ( + ScriptedModel, + assistant_message, + function_call, + scripted_sandbox_session, +) + + +@pytest.mark.asyncio +async def test_sandbox_workflow() -> None: + sandbox = scripted_sandbox_session( + [ + { + "method": "exec", + "match": lambda call: call.args == ("pwd",), + "result": ExecResult( + stdout=b"/workspace\n", + stderr=b"", + exit_code=0, + ), + } + ] + ) + model = ScriptedModel( + [ + [function_call("exec_command", {"cmd": "pwd"}, call_id="call_1")], + [assistant_message("The workspace is /workspace.")], + ] + ) + agent = SandboxAgent( + name="Workspace assistant", + model=model, + capabilities=[Shell()], + ) + + async with sandbox: + result = await Runner.run( + agent, + "Which directory are you in?", + run_config=RunConfig( + sandbox={"session": sandbox}, + tracing_disabled=True, + ), + ) + + assert result.final_output == "The workspace is /workspace." + assert [call.method for call in sandbox.calls] == ["exec"] + sandbox.assert_complete() + model.assert_complete() +``` + +이 테스트는 정규화된 SDK 경계 두 개를 통과합니다. 도구 인수 검증, 기능 라우팅, Sandbox 세션 호출, 다음 모델 턴으로의 도구 결과 전달 및 최종 출력 처리를 포괄합니다. 실제 모델이 명령을 선택하는지 또는 실제 Sandbox 공급자가 이를 어떻게 실행하는지는 테스트하지 않습니다. + +### Sandbox 단계 구성 + +일치하는 각 Sandbox 호출은 하나의 전역 FIFO 시퀀스에서 다음 단계를 소비합니다. 메서드 불일치, 매처 거부 또는 매처 예외가 발생하면 해당 단계는 대기 상태로 남습니다. `method`을 설정하고 결과를 정확히 하나 선택하며, 호출 세부 정보가 중요한 경우에만 `match`을 추가하세요. + +| 단계 멤버 | 사용 시점 | +| --- | --- | +| `result` | 메서드가 고정된 타입 값을 반환해야 할 때 | +| `responder` | 결과가 분리된 `SandboxCall`에 따라 달라질 때 | +| `error` | 메서드가 특정 예외를 발생시켜야 할 때 | +| `match` | 매처가 `False` 이외의 값을 반환하지 않으면 결과를 생성하기 전에 호출이 거부되어야 할 때 | + +지원되는 스크립트 메서드 이름은 `apply_patch`, `exec`, `ls`, `mkdir`, `pty_exec_start`, `pty_write_stdin`, `read`, `rm` 및 `write`입니다. 구성된 모델 대상 기능만 노출됩니다. 두 PTY 메서드는 하나의 대화형 셸 기능을 구성하므로 둘 중 하나라도 구성되면 함께 노출되지만, 호출은 계속 전역 FIFO 스크립트를 소비합니다. + +`sandbox.calls`에는 0부터 시작하는 `call_index`, `method`, 위치 인수 `args` 및 읽기 전용 `kwargs`이 포함된 분리된 `SandboxCall` 스냅샷이 들어 있습니다. 정적 결과도 스크립트가 생성될 때 스냅샷으로 저장됩니다. `io.BytesIO` 및 `io.StringIO` 값이 지원됩니다. 다른 라이브 스트림 객체나 수명 주기 동작에는 사용자 지정 Sandbox 세션을 사용하세요. + +| 오류 | 구조화된 필드 | 의미 | +| --- | --- | --- | +| `InvalidSandboxStep` | `reason`, `input_index`, `method` | 단계 형식이 잘못되었거나 지원되지 않는 메서드 이름을 사용함 | +| `UnexpectedSandboxCall` | `call`, `call_index`, `actual_method`, `expected_method`, `remaining_steps` | 워크플로가 잘못된 메서드를 호출했거나 스크립트가 끝난 후에도 계속 실행됨 | +| `SandboxCallMatcherError` | `call`, `call_index`, `method` | 단계 매처가 `False`을 반환함 | +| `UnconsumedSandboxSteps` | `remaining_steps`, `pending_methods` | 모든 단계를 사용하기 전에 워크플로가 종료됨 | + +반환되는 객체는 세션 자체입니다. 이를 `RunConfig(sandbox={"session": sandbox})`에 직접 전달하세요. 래퍼 `.session` 속성은 없습니다. + +## Realtime 레시피 + +### Realtime 세션 테스트 + +`ScriptedRealtimeModel`는 Python SDK의 정규화된 `RealtimeModel` 경계를 구현합니다. 각 `RealtimeStep`는 발신 `RealtimeModelSendEvent` 하나와 일치한 다음 정규화된 수신 `RealtimeModelEvent` 객체를 내보내거나 주입된 오류를 발생시킵니다. + +```python +import pytest + +from agents.realtime import ( + RealtimeAgent, + RealtimeModelOutputTextDeltaEvent, + RealtimeModelSendUserInput, + RealtimeRawModelEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@pytest.mark.asyncio +async def test_realtime_message() -> None: + reply = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="Hello!", + response_id="response_1", + ) + model = ScriptedRealtimeModel( + [ + RealtimeStep( + expect=RealtimeModelSendUserInput(user_input="Hello"), + emit=[reply], + ) + ] + ) + runner = RealtimeRunner( + RealtimeAgent(name="Assistant"), + model=model, + config={"tracing_disabled": True}, + ) + + observed_reply = False + async with await runner.run() as session: + await session.send_message("Hello") + async for event in session: + if isinstance(event, RealtimeRawModelEvent) and event.data == reply: + observed_reply = True + break + + assert observed_reply + assert model.sent_events == (RealtimeModelSendUserInput(user_input="Hello"),) + assert model.closed is True + model.assert_complete() +``` + +예상값은 정확한 이벤트 값, `isinstance`로 일치 여부를 판단하는 이벤트 클래스 또는 발신 이벤트를 받아 일치하면 `True`을 반환하는 호출 가능 객체일 수 있습니다. 엄격 모드는 기본적으로 활성화됩니다. `strict=False`를 사용하면 관련 없는 발신 이벤트는 기록되지만 대기 중인 단계를 소비하지 않습니다. 이는 세션이 테스트 대상 동작 범위 밖의 부수적인 이벤트를 내보낼 때 유용합니다. + +연결 중에 수신 이벤트를 내보내려면 `connect_events`을 사용하세요. 수명 주기 실패에는 `connect_error` 또는 `close_error`를 사용하고, 일치한 전송 하나와 관련된 실패에는 `RealtimeStep(error=...)`을 사용하세요. 한 단계에는 `emit`와 `error`를 동시에 정의할 수 없습니다. + +### Realtime 도구 워크플로 테스트 + +실제 함수 도구를 `RealtimeAgent`에 연결하고 정규화된 도구 호출을 내보낸 다음 SDK가 모델 경계를 통해 도구 출력을 전송하는지 확인합니다. `async_tool_calls`을 `False`로 설정하면 이 간단한 예제가 테스트 전용 대기 메커니즘 없이 연결 중에 완료됩니다. + +```python +import pytest + +from agents.decorators import tool +from agents.realtime import ( + RealtimeAgent, + RealtimeModelSendToolOutput, + RealtimeModelToolCallEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@tool +def lookup_order(order_id: str) -> str: + """Look up an order by ID.""" + return f"Order {order_id} has shipped." + + +@pytest.mark.asyncio +async def test_realtime_tool_workflow() -> None: + tool_call = RealtimeModelToolCallEvent( + name="lookup_order", + call_id="call_1", + arguments='{"order_id":"order_123"}', + ) + + def matches_tool_output(event) -> bool: + return ( + isinstance(event, RealtimeModelSendToolOutput) + and event.tool_call.call_id == "call_1" + and event.output == "Order order_123 has shipped." + ) + + model = ScriptedRealtimeModel( + [RealtimeStep(expect=matches_tool_output)], + connect_events=[tool_call], + ) + agent = RealtimeAgent( + name="Order assistant", + tools=[lookup_order], + ) + runner = RealtimeRunner( + agent, + model=model, + config={"async_tool_calls": False, "tracing_disabled": True}, + ) + + async with await runner.run(): + pass + + model.assert_complete() +``` + +이 테스트는 실제 Realtime 도구 조회, 인수 검증, 실행 및 출력 라우팅을 수행합니다. 실제 모델이 해당 도구를 선택한다는 사실까지 입증하지는 않습니다. + +### Realtime 호출 및 수명 주기 검사 + +| 멤버 | 포함 내용 | +| --- | --- | +| `connect_calls` | 자격 증명이 없고 분리된 연결 스냅샷 | +| `sent_events` | 호출 순서에 따른 분리된 발신 이벤트 스냅샷 | +| `remaining_steps` | 아직 남아 있는 예상 발신 전송 | +| `listeners` | 현재 등록된 리스너 객체 | +| `connected`, `closed`, `close_calls` | 현재 메모리 내 수명 주기 상태 | + +연결 기록에는 API 키 또는 헤더 필드가 제공되었는지만 기록되며 해당 값은 저장하지 않습니다. URL 스냅샷에서는 사용자 정보, 쿼리 매개변수 및 프래그먼트가 제거됩니다. 변경 가능한 이벤트 데이터와 설정은 분리되지만 도구, 핸드오프 및 재생 추적기와 같은 라이브 SDK 객체는 정체성을 유지합니다. + +`model.assert_complete()`으로 마무리하고 `RealtimeSession` 비동기 컨텍스트 관리자가 모델을 닫도록 하세요. Python 유틸리티는 의도적으로 대기 중인 예상값 프로미스, 암시적 시간 제한 또는 별도의 `assert_closed()` 헬퍼를 제공하지 않습니다. + +| 오류 | 구조화된 필드 | 의미 | +| --- | --- | --- | +| `UnexpectedRealtimeSend` | `actual`, `expected` | 엄격한 발신 전송이 다음 단계와 일치하지 않았거나 남은 단계가 없음 | +| `UnconsumedRealtimeSteps` | `remaining_steps` | 예상된 모든 전송을 사용하기 전에 세션이 종료됨 | +| `RealtimeScriptError` | 없음 | 연결이 끊긴 상태에서 전송하는 등 잘못된 수명 주기 상태에서 스크립트가 사용됨 | + +## Voice 파이프라인 레시피 + +### Voice 파이프라인 테스트 + +스크립트된 STT 및 TTS 모델을 `SingleAgentVoiceWorkflow`, 그리고 `ScriptedModel`이 지원하는 에이전트와 결합하면 공급자 요청 없이 전체 음성-텍스트 변환 -> 에이전트 -> 텍스트-음성 변환 파이프라인을 테스트할 수 있습니다. + +```python +import numpy as np +import pytest + +from agents import Agent +from agents.testing import ScriptedModel, assistant_message +from agents.voice import AudioInput, SingleAgentVoiceWorkflow, VoicePipeline +from agents.voice.testing import ( + ScriptedSTTModel, + ScriptedTTSModel, + TTSResult, + pcm16_samples, +) + + +@pytest.mark.asyncio +async def test_voice_pipeline() -> None: + model = ScriptedModel([[assistant_message("Hello there.")]]) + stt = ScriptedSTTModel("hello") + pcm = pcm16_samples([0, 100, -100, 0]) + tts = ScriptedTTSModel([TTSResult([pcm])]) + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow( + Agent(name="Voice assistant", model=model) + ), + stt_model=stt, + tts_model=tts, + config={"tracing_disabled": True, "tts_settings": {"buffer_size": 1}}, + ) + + result = await pipeline.run(AudioInput(np.zeros(2, dtype=np.int16))) + events = [event async for event in result.stream()] + + assert events + assert [call.text for call in tts.calls] == ["Hello there."] + stt.assert_complete() + tts.assert_complete() + model.assert_complete() +``` + +파이프라인의 STT/TTS 수명 주기가 테스트 대상이지만 에이전트 오케스트레이션은 대상이 아닐 때는 대신 `ScriptedVoiceWorkflow`을 사용하세요. + +```python +from agents.voice.testing import ScriptedVoiceWorkflow + + +workflow = ScriptedVoiceWorkflow( + turns=["Hello there."], + start="Welcome.", +) +``` + +`start` 단계는 `on_start()`에서 소비됩니다. `VoicePipeline`은 `StreamedAudioInput`에 대해서만 `on_start()`을 호출합니다. 정적 `AudioInput` 실행은 `start`를 소비하지 않습니다. 각 일반 턴은 전사 결과를 기록하고 구성된 결과 하나를 소비합니다. 문자열 하나는 하나의 프래그먼트이며, 문자열 시퀀스는 텍스트 분할 및 TTS 전에 프래그먼트 경계를 제어합니다. + +### 스트리밍 전사 테스트 + +`ScriptedSTTModel`는 정적 `transcriptions`과 독립적으로 스크립트된 스트리밍 `sessions`을 허용합니다. 세션은 `ScriptedTranscriptionSession`, 전사 턴 시퀀스, 예외 또는 단일 문자열일 수 있습니다. + +```python +from agents.voice.testing import ScriptedSTTModel, ScriptedTranscriptionSession + + +session = ScriptedTranscriptionSession(["first turn", "second turn"]) +stt = ScriptedSTTModel(sessions=[session]) +``` + +`ScriptedTranscriptionSession`을 닫으면 반복이 중지되고 건너뛴 턴이 남아 `assert_complete()`에서 보고됩니다. 마찬가지로 `ScriptedTTSModel`은 호출마다 `TTSResult`, 바이트 청크 시퀀스 또는 예외 하나를 소비합니다. + +### Voice 호출 검사 + +| 구성 요소 | 기록된 내역 | +| --- | --- | +| `ScriptedSTTModel` | `calls`, `session_calls` 및 라이브 `created_sessions` 정체성 | +| `ScriptedTTSModel` | 텍스트와 분리된 설정을 포함하는 `calls` | +| `ScriptedVoiceWorkflow` | 턴 순서에 따른 `transcriptions` | + +정적 오디오 버퍼와 변경 가능한 설정은 호출 시점에 스냅샷으로 저장됩니다. 파이프라인에서 계속 사용하므로 `StreamedAudioInput` 및 생성된 전사 세션 객체는 라이브 정체성을 유지합니다. + +| 오류 | 구조화된 필드 | 의미 | +| --- | --- | --- | +| `UnexpectedVoiceCall` | `operation` | 정적 전사, 스트리밍 세션, TTS 호출, 워크플로 시작 또는 워크플로 턴에 구성된 단계가 없음 | +| `UnconsumedVoiceSteps` | `remaining_steps` | 구성된 Voice 단계가 하나 이상 남아 있음 | + +테스트에서 구성한 모든 스크립트형 Voice 구성 요소에 `assert_complete()`을 호출하세요. `ScriptedSTTModel.assert_complete()`은 자신이 생성한 전사 세션의 턴도 검사합니다. + +## 올바른 경계 선택 + +모델 공급자에 의존하지 않고 SDK 실행 루프, 도구, 핸드오프, 가드레일, 세션, 재시도 또는 정규화된 스트리밍을 테스트해야 할 때 `ScriptedModel`을 사용하세요. + +Sandbox 공급자를 시작하지 않고 `SandboxAgent` 기능 및 오케스트레이션을 테스트해야 할 때 `ScriptedModel`과 함께 `scripted_sandbox_session()`을 사용하세요. 공급자 생성, 프로세스 실행, 파일 시스템 충실도, 지속성, 리소스 제한 및 격리 검사는 실제 Sandbox 공급자를 대상으로 하는 통합 테스트에서 수행하세요. + +WebSocket 연결을 열지 않고 `RealtimeSession` 동작 또는 `RealtimeAgent` 도구 및 핸드오프 오케스트레이션을 테스트해야 할 때 `ScriptedRealtimeModel`를 사용하세요. 가공되지 않은 Realtime 클라이언트/서버 이벤트, 인증, 네트워크 복구 및 오디오 전송 동작은 실제 전송 계층이나 통합 환경에서 테스트하세요. Realtime API 세션은 클라이언트가 입력을 보내고 이벤트를 수신하는 동안 연결을 열린 상태로 유지하므로 이러한 네트워크 및 프로토콜 문제는 정규화된 모델 경계 아래에 속합니다. 프로덕션 연결 아키텍처는 [OpenAI Realtime API 가이드](https://developers.openai.com/api/docs/guides/realtime)를 참조하세요. + +음성 공급자 없이 STT/TTS 순서, 스트리밍 전사 정리, 워크플로 프래그먼트 전달 또는 전체 Voice 파이프라인 구성을 테스트해야 할 때 Voice 테스트 구성 요소를 사용하세요. 전사 품질, 생성된 음성, 인코딩 호환성, 지연 시간 또는 재생이 테스트 대상인 경우 실제 오디오 모델과 대표성 있는 오디오를 사용하세요. + +이러한 유틸리티를 Responses API 또는 Chat Completions 요청 직렬화, 인증 헤더, 공급자 기본값, HTTP 페이로드, 공급자 스트림 청크, Realtime 전송 프레임 또는 공급자별 수명 주기 동작을 테스트하는 데 사용하지 마세요. 이러한 테스트에는 실제 어댑터를 유지하면서 해당 네트워크 경계를 대체하거나 제어하세요. `openai` v3에서는 OpenAI 어댑터 테스트에 `httpx2` 요청, 응답, 전송 및 예외 타입을 사용해야 합니다. 레거시 `httpx`은 Agents SDK의 핵심 종속성이 아닙니다. + +## 최종 체크리스트 + +- 정규화된 모델, Sandbox 세션, Realtime 모델 또는 Voice 파이프라인 경계가 관리하는 상호작용만 스크립트로 구성합니다. +- 비공개 러너 상태 대신 중요한 공개 요청 또는 호출 필드를 검증합니다. +- 고정 응답 단계를 우선 사용하고, 요청에 따라 달라지는 동작에만 응답자를 사용합니다. +- 자동 모델 스트리밍을 우선 사용하고, 이벤트 수준의 동작이 중요할 때만 정확한 스트림을 사용합니다. +- 각 스크립트형 구성 요소 테스트를 해당 `assert_complete()` 메서드로 마무리합니다. +- 주변 테스트가 Realtime 및 Sandbox 수명 주기를 소유하는 경우 수명 주기 정리에 비동기 컨텍스트 관리자를 사용합니다. +- 사람이 읽을 수 있는 메시지를 파싱하는 대신 구조화된 오류 필드를 검증합니다. +- 공급자 전송 테스트는 제어된 네트워크 전송을 사용하는 실제 어댑터에서 수행합니다. + +## 범위 및 현재 제한 사항 + +테스트 모듈은 의도적으로 다음 기능을 제공하지 않습니다. + +- 모든 정규화된 모델 출력 항목을 위한 편의 빌더. 일반적인 경우에는 `assistant_message()` 및 `function_call()`을 사용하고 다른 정규화된 항목은 직접 전달하세요. +- 공급자 프로토콜 시뮬레이터. 정확한 모델 스트림은 Responses API 또는 Chat Completions 전송 청크 대신 정규화된 SDK 이벤트를 사용합니다. +- 고수준 시뮬레이션 Realtime 서버. 테스트는 정규화된 발신 전송을 명시적으로 매칭하고 시나리오에 필요한 정규화된 수신 이벤트를 내보냅니다. +- 순서가 지정되지 않은 Sandbox 또는 Realtime 예상값. 두 유틸리티 모두 하나의 전역 순서로 예상 단계를 소비합니다. +- 테스트 러너별 매처, 픽스처, 암시적 시간 제한 또는 자동 정리 +- 재설정 API. `ScriptedModel`은 점진적 스크립트를 위한 `enqueue()` 및 `extend()`을 지원하지만, 독립적인 시나리오에는 새 스크립트형 구성 요소를 생성하세요. + +테스트에 잘못된 형식의 스트림, 제어된 일시 중지 또는 동시성, 정확한 취소, 혹은 스크립트형 유틸리티가 보존할 수 없는 수명 주기 경계가 필요한 경우 해당 공개 인터페이스의 사용자 지정 구현을 사용하세요. 테스트에 그 특수한 경계를 문서화하세요. + +## API 레퍼런스 + +- [`agents.testing`](ref/testing.md) +- [`agents.realtime.testing`](ref/realtime/testing.md) +- [`agents.voice.testing`](ref/voice/testing.md) \ No newline at end of file diff --git a/docs/zh/config.md b/docs/zh/config.md index 2893c94d1a..e4209d4e12 100644 --- a/docs/zh/config.md +++ b/docs/zh/config.md @@ -4,21 +4,21 @@ search: --- # 配置 -本页介绍通常在应用启动时仅需设置一次的 SDK 全局默认配置,例如默认 OpenAI 密钥或客户端、默认 OpenAI API 形式、追踪导出默认值以及日志记录行为。 +本页介绍通常在应用启动期间一次性设置的 SDK 全局默认值,例如默认OpenAI密钥或客户端、默认OpenAI API 形态、追踪导出默认设置以及日志行为。 -这些默认配置仍适用于基于沙箱的工作流,但沙箱工作区、沙箱客户端和会话复用需单独配置。 +这些默认值仍适用于基于沙箱的工作流,但沙箱工作区、沙箱客户端和会话复用需要单独配置。 -如果需要配置特定的智能体或运行,请从以下内容开始: +如果需要配置特定智能体或运行,请先参阅: -- [智能体](agents.md):了解普通 `Agent` 的指令、工具、输出类型、任务转移和安全防护措施。 -- [运行智能体](running_agents.md):了解 `RunConfig`、会话和对话状态选项。 -- [沙箱智能体](sandbox/guide.md):了解 `SandboxRunConfig`、清单、能力以及特定于沙箱客户端的工作区设置。 -- [模型](models/index.md):了解模型选择和提供商配置。 -- [追踪](tracing.md):了解每次运行的追踪元数据和自定义追踪处理器。 +- [智能体](agents.md):普通 `Agent` 的指令、工具、输出类型、任务转移和安全防护措施。 +- [运行智能体](running_agents.md):`RunConfig`、会话和对话状态选项。 +- [沙箱智能体](sandbox/guide.md):`SandboxRunConfig`、清单、能力和沙箱客户端专用的工作区设置。 +- [模型](models/index.md):模型选择和提供商配置。 +- [追踪](tracing.md):每次运行的追踪元数据和自定义追踪处理器。 ## 配置对象与字典 -SDK 定义的配置参数通常既接受其类型化设置对象,也接受包含相同字段的字典。这适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。SDK 定义的嵌套设置类型也可以使用字典。 +SDK 定义的配置参数通常既接受相应的类型化设置对象,也接受包含相同字段的字典。此规则适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。SDK 定义的嵌套设置类型也可以使用字典。 ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK 会将这些字典规范化为相应的设置对象。对于 SDK 定义的数据类配置类型,未知字段会引发 `TypeError`,这有助于尽早发现拼写错误的选项名称。请检查参数的类型注解或 API 参考文档,以确认特定边界是否接受字典。 +SDK 会将这些字典规范化为相应的设置对象。对于 SDK 定义的数据类配置类型,未知字段会引发 `TypeError`,这有助于及早发现拼写错误的选项名称。请查看参数的类型注解或 API 参考,确认特定边界是否接受字典。 ## API 密钥与客户端 -默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理 LLM 请求和追踪。SDK 首次创建 OpenAI 客户端时才会解析该密钥(延迟初始化),因此请在首次调用模型之前设置此环境变量。如果无法在应用启动前设置该环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 +默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理LLM请求和追踪。SDK 首次创建OpenAI客户端时会解析该密钥(延迟初始化),因此请在首次调用模型前设置该环境变量。如果无法在应用启动前设置此环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -或者,也可以配置要使用的 OpenAI 客户端。默认情况下,SDK 会创建一个 `AsyncOpenAI` 实例,并使用环境变量中的 API 密钥或上面设置的默认密钥。可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此行为。 +或者,也可以配置要使用的OpenAI客户端。默认情况下,SDK 会创建一个 `AsyncOpenAI` 实例,并使用环境变量中的 API 密钥或上面设置的默认密钥。可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此设置。 ```python from openai import AsyncOpenAI @@ -55,14 +55,38 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -如果偏好基于环境变量的端点配置,默认 OpenAI 提供商还会读取 `OPENAI_BASE_URL`。启用 Responses WebSocket 传输时,它还会读取 WebSocket `/responses` 端点的 `OPENAI_WEBSOCKET_BASE_URL`。 +### 使用 `openai` v3 的自定义 HTTP 客户端 + +0.21.0 版本要求使用 `openai>=3.0.0,<4`。默认OpenAI提供商使用 HTTPX2,因此大多数应用不需要直接配置 HTTP 客户端。如果应用将 `http_client=` 传递给 `AsyncOpenAI`,请为自定义客户端及其面向传输层的选项使用 HTTPX2 类型: + +```python +import httpx2 +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + +from agents import set_default_openai_client + +http_client = DefaultAsyncHttpx2Client( + timeout=httpx2.Timeout(30.0, connect=5.0), +) +custom_client = AsyncOpenAI( + api_key="...", + http_client=http_client, +) +set_default_openai_client(custom_client) +``` + +同样的迁移方式也适用于自定义传输、身份验证、事件钩子、模拟传输、URL、请求、响应和传输异常处理。请使用它们对应的 `httpx2` 类型。Agents SDK不会将任意旧版 `httpx` 对象转换为 HTTPX2。当应用显式安装 `httpx` 时,OpenAI Python SDK 会为旧版客户端提供临时兼容路径,但新增代码和迁移后的代码应使用 HTTPX2。 + +此OpenAI客户端边界独立于本地MCP传输自定义。MCP Python SDK v1 使用自己的旧版 `httpx` 依赖项,而 MCP Python SDK v2 使用 `httpx2`;请参阅 [MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 + +如果倾向于使用基于环境变量的端点配置,默认OpenAI提供商还会读取 `OPENAI_BASE_URL`。启用 Responses websocket 传输后,它还会读取 websocket `/responses` 端点所使用的 `OPENAI_WEBSOCKET_BASE_URL`。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -最后,还可以自定义使用的 OpenAI API。默认情况下,我们使用 OpenAI Responses API。可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数将其覆盖为 Chat Completions API。 +最后,还可以自定义所使用的OpenAI API。默认情况下,我们使用OpenAI Responses API。可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数将其改为Chat Completions API。 ```python from agents import set_default_openai_api @@ -70,9 +94,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 提供商默认配置 +## OpenAI提供商默认设置 -使用 SDK OpenAI 后端的提供商在将模型名称字符串映射到模型时,也会读取 SDK 全局默认配置。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使 OpenAI Responses 模型默认使用 WebSocket 传输: +使用 SDK 的OpenAI后端的提供商在将模型名称字符串映射到模型时,也会读取 SDK 全局默认值。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使OpenAI Responses 模型默认使用 websocket 传输: ```python from agents import set_default_openai_responses_transport @@ -80,9 +104,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -当默认 OpenAI 提供商解析模型名称时,这会影响由此生成的 OpenAI Responses 模型。有关提供商级别的设置、连接复用、keepalive 选项和自定义 WebSocket 端点,请参阅 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 +这会影响默认OpenAI提供商解析模型名称后生成的OpenAI Responses 模型。有关提供商级设置、连接复用、保活选项和自定义 websocket 端点,请参阅 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 -如果 OpenAI 设置需要提供商级别的智能体注册元数据,请在启动时一次性配置默认 harness ID: +如果OpenAI设置需要提供商级智能体注册元数据,请在启动时配置一次默认 harness ID: ```python from agents import set_default_openai_harness @@ -100,11 +124,11 @@ set_default_openai_agent_registration( ) ``` -如果未设置 SDK 默认值,使用 SDK OpenAI 后端的提供商会回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 +如果未设置 SDK 默认值,使用 SDK 的OpenAI后端的提供商将回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 ## 追踪 -追踪默认启用。默认情况下,它使用上一节中模型请求所用的同一 OpenAI API 密钥(即环境变量中的密钥或设置的默认密钥)。可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 +追踪默认启用。默认情况下,它使用与上一节模型请求相同的OpenAI API 密钥,即环境变量中的密钥或设置的默认密钥。可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 ```python from agents import set_tracing_export_api_key @@ -112,7 +136,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -如果模型流量使用一个密钥或客户端,但追踪应使用另一个 OpenAI 密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果不使用自定义客户端,也可以对 [`set_default_openai_key()`][agents.set_default_openai_key] 使用相同模式。 +如果模型流量使用一个密钥或客户端,而追踪需要使用另一个OpenAI密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果没有使用自定义客户端,也可以对 [`set_default_openai_key()`][agents.set_default_openai_key] 使用相同方式。 ```python from openai import AsyncOpenAI @@ -134,7 +158,7 @@ export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -也可以为每次运行设置追踪 API 密钥,而无需更改全局导出器。 +也可以为每次运行设置追踪 API 密钥,而不更改全局导出器。 ```python from agents import Runner, RunConfig @@ -154,7 +178,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -如果希望保持追踪启用,但从追踪载荷中排除可能敏感的输入/输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: +如果希望保持追踪启用,但从追踪负载中排除可能包含敏感信息的输入或输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: ```python from agents import Runner, RunConfig @@ -166,7 +190,7 @@ await Runner.run( ) ``` -也可以在应用启动前设置以下环境变量,从而无需编写代码即可更改默认值: +也可以在应用启动前设置以下环境变量,无需编写代码即可更改默认值: ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 @@ -176,9 +200,9 @@ export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ## 调试日志 -SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加处理器。日志遵循应用的 Python 日志配置。 +SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加任何处理器。日志遵循应用的 Python 日志配置。 -如需启用详细日志记录,请使用 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 函数。 +要启用详细日志记录,请使用 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 函数。 ```python from agents import enable_verbose_stdout_logging @@ -186,7 +210,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -或者,也可以通过添加处理器、过滤器、格式化程序等来自定义日志。更多信息请参阅 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 +或者,也可以通过添加处理器、过滤器和格式化程序等方式自定义日志。有关更多信息,请参阅 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 ```python import logging @@ -205,22 +229,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 日志和诊断中的敏感数据 +### 日志与诊断中的敏感数据 -某些日志和诊断异常可能包含敏感数据(例如模型或工具的输入和输出)。 +某些日志和诊断异常可能包含敏感数据,例如模型或工具的输入和输出。 -默认情况下,SDK **不会**记录 LLM 输入/输出或工具输入/输出。这些保护由以下设置控制: +默认情况下,SDK **不会**记录LLM输入和输出,也不会记录工具输入和输出。这些保护措施由以下变量控制: ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -如果需要在调试期间临时包含这些数据,请在应用启动前将任一变量设置为 `0`(或 `false`): +如果为了调试而需要临时包含这些数据,请在应用启动前将任一变量设置为 `0`(或 `false`): ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -这些标志还控制受影响的故障是否保留包含载荷的诊断详细信息。例如,启用工具数据脱敏后,`FunctionTool` 的无效参数会引发通用的 `ModelBehaviorError`,而不会以异常链形式附带底层验证错误。将任一变量设置为 `0` 可能会在日志、异常消息、异常链和其他诊断上下文中暴露原始模型或工具数据,因此只能在受控的开发环境中启用。 \ No newline at end of file +这些标志还会控制受影响的故障是否保留含有负载的诊断详细信息。例如,启用工具数据脱敏后,`FunctionTool` 的无效参数会引发通用的 `ModelBehaviorError`,且不会将底层验证错误链接到异常链中。将任一变量设置为 `0` 可能会在日志、异常消息、异常链和其他诊断上下文中暴露原始模型数据或工具数据,因此只能在受控的开发环境中启用。 \ No newline at end of file diff --git a/docs/zh/release.md b/docs/zh/release.md index c66395ff4c..107dd0a518 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -4,66 +4,79 @@ search: --- # 发布流程/变更日志 -本项目采用略作修改的语义化版本控制,格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各组成部分按以下方式递增: +本项目采用略作修改的语义化版本控制,版本格式为`0.Y.Z`。开头的`0`表示 SDK 仍在快速演进。各组成部分按以下方式递增: ## 次版本(`Y`) -对于任何未标记为 beta 的公共接口发生的**破坏性变更**,我们将递增次版本号 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 +对于任何未标记为 beta 的公共接口,如果存在**破坏性变更**,我们将递增次版本`Y`。例如,从`0.0.x`升级到`0.1.x`时可能包含破坏性变更。 -如果不希望引入破坏性变更,建议在项目中固定使用 `0.0.x` 版本。 +如果您不希望遇到破坏性变更,建议在项目中锁定`0.0.x`版本。 ## 补丁版本(`Z`) -对于非破坏性变更,我们将递增 `Z`: +对于非破坏性变更,我们将递增`Z`: -- Bug 修复 +- 错误修复 - 新功能 - 私有接口变更 - beta 功能更新 ## 破坏性变更日志 +### 0.21.0 + +版本 0.21.0 要求使用`openai` v3,并将 Agents SDK 的OpenAI HTTP 集成迁移至 HTTPX2。使用默认OpenAI客户端的应用程序无需更改客户端设置,但自定义OpenAI HTTP 层的应用程序可能需要迁移面向传输层的代码。 + +要点: + +- 现在要求的OpenAI依赖项为`openai>=3.0.0,<4`。全新安装核心包时将使用 HTTPX2,并且不再将旧版`httpx`作为直接依赖项安装。 +- 默认OpenAI提供方、语音提供方、Responses WebSocket 支持、追踪导出器以及提供方重试规范化现在均使用 HTTPX2。它们现有的 Agents SDK 公共配置和运行时行为保持不变。 +- 向`AsyncOpenAI`传递`http_client=`的应用程序,应将自定义客户端、传输、身份验证、事件钩子、模拟传输、超时值、URL、请求、响应以及传输异常处理从`httpx`迁移至`httpx2`。如果应用程序既需要OpenAI客户端的默认设置,又需要自定义 HTTP 选项,请优先使用OpenAI Python SDK 的`DefaultAsyncHttpx2Client`。请参阅[使用`openai` v3 的自定义 HTTP 客户端](config.md#custom-http-clients-with-openai-v3)。 +- Agents SDK 不会将任意旧版 HTTPX 对象转换为 HTTPX2。OpenAI Python SDK 的临时旧版客户端兼容路径要求显式安装`httpx`,并且应仅将其视为迁移桥梁。 +- 本地 MCP HTTP 自定义继续遵循已安装的 MCP 软件包:MCP Python SDK v1 提供并使用旧版`httpx`,而 MCP Python SDK v2 使用`httpx2`。普通 MCP 连接无需更改应用程序。请参阅[MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 +- 公共的提供方中立测试实用工具现在可以覆盖智能体模型、沙箱会话、Realtime 会话和语音管线工作流,而无需依赖提供方或进程。有关使用方法以及何时应保留实际提供方适配器或集成边界的指导,请参阅[测试](testing.md)。 + ### 0.20.0 -0.20.0 版本包含一项可能具有破坏性的 MCP 依赖迁移,会影响自定义本地 MCP HTTP 传输的应用程序。它还更新了智能体或运行未显式选择模型时使用的 SDK 默认模型。 +版本 0.20.0 包含一项可能造成破坏性变更的 MCP 依赖项迁移,会影响自定义本地 MCP HTTP 传输的应用程序。它还更新了智能体或运行未显式选择模型时使用的 SDK 默认模型。 -重点: +要点: -- SDK 默认模型现已从 `gpt-5.4-mini` 改为 `gpt-5.6-luna`。默认的 `reasoning.effort="none"` 和 `verbosity="low"` 设置保持不变。 -- 显式指定的智能体模型、运行级模型覆盖项以及 `OPENAI_DEFAULT_MODEL` 环境变量仍优先于 SDK 默认值。 -- Realtime 输入转录设置现在可识别 `gpt-transcribe`、`gpt-live-transcribe` 和 `gpt-realtime-whisper`。对于低延迟 `gpt-live-transcribe` 会话,嵌套的 `audio.input.transcription` 设置可以提供 `prompt`、`keywords` 和多个预期的 `languages`。此 SDK 固定使用的 OpenAI 客户端版本仅在搭配 `gpt-realtime-whisper` 时支持 `delay` 延迟/准确度级别。通过 WebSocket 使用 `gpt-transcribe`,可在已提交音频轮次后进行转录或输出检测到的语言。显式设置 `audio.input.turn_detection=None` 会禁用自动轮次检测。请参阅[输入转录设置](realtime/guide.md#input-transcription-settings)。 -- Agents SDK 创建的本地 MCP 连接现在支持 MCP Python SDK v2,同时通过 `mcp>=1.19.0,<3` 保持对 v1 的兼容性。Agents SDK 会自动适配普通的 stdio、SSE 和 Streamable HTTP 连接。安装 MCP v2 后,这些连接会使用 `mcp.Client(mode="auto")` 探测最新的受支持协议,并针对旧版服务器回退到传统的 `initialize` 握手。如果依赖解析选择了 MCP v2,提供自定义 `httpx.Auth` 对象或 `httpx.AsyncClient` 工厂的应用程序必须将这些值迁移至 `httpx2`,或者固定使用 `mcp<2` 以保留 v1 HTTP 栈。`MCPServerStreamableHttp` 的 `params["ignore_initialized_notification_failure"] = True` 选项也仍然仅支持 v1。有关迁移详情,请参阅[MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 -- 沙盒挂载验证现在会在产生沙盒或挂载辅助程序的副作用之前,拒绝不安全的凭据放置。可信应用程序可以针对准确的容器内挂载路径,确认挂载范围内或更广泛的凭据暴露,而无需更改存储能力表。这些确认仅在运行时有效,序列化后的沙盒状态本身绝不会授予凭据权限。在受保护的挂载边界处,SDK 会返回一个全新的、经过脱敏的异常。如果源异常是完全匹配的、可识别的 SDK 沙盒错误,且其获准的结构化字段通过验证,则替代异常会保留该子类型和已验证的安全字段。可识别的 `MountConfigError` 还可以保留由 SDK 生成的安全验证消息。否则,SDK 会返回一个全新的通用脱敏错误。由提供商控制或未经批准的消息、命令数据、注释、上下文、原因及源回溯状态均不会保留。请参阅[挂载与远程存储](sandbox/clients.md#mounts-and-remote-storage)和[从会话状态恢复](sandbox/guide.md#resume-from-session-state)。 -- 重试策略可以检查稳定的重放安全事实,并针对提供商标记为不安全的非流式请求显式设置 `RetryDecision(approve_unsafe_replay=True)`。此批准不会绕过中止、已发出的流式输出或单独的本地副作用否决机制,例如程序化工具调用。请参阅[由 Runner 管理的重试](models/index.md#runner-managed-retries)。 -- 可恢复的 `RunState` 对象现在可以在下一次模型调用前使用 `add_input()` 暂存持久用户输入。暂存的输入会在序列化后保留、经过输入安全防护措施,并在本地会话和服务器管理的对话中生成一次持久的 SDK 输入记录。经过显式批准的不安全重放仍可能向提供商重新发送输入,并重复提供商侧的工作。请参阅[恢复前添加输入](results.md#add-input-before-resuming)。 -- 运行时可靠性修复统一了流式与非流式的[输出安全防护措施会话持久化](guardrails.md#output-guardrails),在复制和命名空间处理期间保留 `FunctionTool` 子类,并针对[不受支持的 Chat Completions 音频输出](models/index.md#chat-completions-compatibility-options)抛出明确错误,而不是静默完成空流。`OpenAIResponsesCompactionSession` 包装器会在取消传递至调用方前,尝试并等待[压缩前的历史记录恢复](sessions/index.md#auto-compaction-can-block-streaming)。[`VoicePipeline`](voice/pipeline.md#results) 使用方现在会在正常运行结束后收到转录会话关闭失败,而较早发生的轮次失败仍优先于之后发生的关闭失败。`RunState` 往返转换现在会保留本地 shell 输出、已确认的计算机安全检查、采用默认值的工具输出字段,以及遍历字典、列表或元组时遇到的 Pydantic 模型或 dataclass 输出。MCP 转换会保留自由格式对象 schema 和图像输出,并将音频块、资源块等其他原始内容块序列化为有效的 JSON 文本。`MCPServerManager` 会对重叠的生命周期操作进行串行化,并为连接和清理应用有限的默认超时时间。模型重放会先从输出项中移除服务器所有的 `created_by` 元数据,再将其用作输入。 +- SDK 默认模型现在是`gpt-5.6-luna`,而不再是`gpt-5.4-mini`。默认的`reasoning.effort="none"`和`verbosity="low"`设置保持不变。 +- 显式指定的智能体模型、运行级模型覆盖以及`OPENAI_DEFAULT_MODEL`环境变量仍然优先于 SDK 默认值。 +- Realtime 输入转录设置现在可识别`gpt-transcribe`、`gpt-live-transcribe`和`gpt-realtime-whisper`。对于低延迟`gpt-live-transcribe`会话,嵌套的`audio.input.transcription`设置可以提供`prompt`、`keywords`以及多个预期的`languages`。此 SDK 锁定的OpenAI客户端版本仅在使用`gpt-realtime-whisper`时支持`delay`延迟/准确性级别。若要在提交一个音频轮次后进行转录,或输出检测到的语言,请通过 WebSocket 使用`gpt-transcribe`。显式设置`audio.input.turn_detection=None`会禁用自动轮次检测。请参阅[输入转录设置](realtime/guide.md#input-transcription-settings)。 +- Agents SDK 创建的本地 MCP 连接现在支持 MCP Python SDK v2,同时通过`mcp>=1.19.0,<3`保留对 v1 的兼容性。Agents SDK 会自动适配普通的 stdio、SSE 和 Streamable HTTP 连接。安装 MCP v2 后,这些连接会使用`mcp.Client(mode="auto")`探测支持的最新协议,并针对较旧的服务器回退到旧版`initialize`握手。如果依赖项解析选择 MCP v2,则提供自定义`httpx.Auth`对象或`httpx.AsyncClient`工厂的应用程序必须将这些值迁移至`httpx2`,或者锁定`mcp<2`以保留 v1 HTTP 栈。`MCPServerStreamableHttp`的`params["ignore_initialized_notification_failure"] = True`选项也仍然仅支持 v1。有关迁移详情,请参阅[MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 +- 沙箱挂载验证现在会在产生沙箱或挂载辅助程序的副作用之前,拒绝不安全的凭据放置。受信任的应用程序可以针对容器内的确切挂载路径,确认挂载范围或广泛的凭据暴露,而无需更改存储能力表。这些确认仅在运行时有效,序列化后的沙箱状态本身绝不会授予凭据权限。在受保护的挂载边界处,SDK 会返回一个新的、已脱敏的异常。如果源异常是 SDK 可准确识别的沙箱错误,且其获准的结构化字段通过验证,则替代异常会保留该子类型以及通过验证的安全字段。可识别的`MountConfigError`也可以保留由 SDK 生成的安全验证消息。否则,SDK 会返回一个新的通用脱敏错误。提供方控制的消息或其他未经批准的消息、命令数据、注释、上下文、原因以及源回溯状态均不会保留。请参阅[挂载与远程存储](sandbox/clients.md#mounts-and-remote-storage)和[从会话状态恢复](sandbox/guide.md#resume-from-session-state)。 +- 重试策略可以检查稳定的重放安全性事实,并针对被提供方标记为不安全的非流式请求显式设置`RetryDecision(approve_unsafe_replay=True)`。此批准不会绕过中止、已发出的流式输出或其他针对本地副作用的否决机制,例如程序化工具调用。请参阅[Runner 管理的重试](models/index.md#runner-managed-retries)。 +- 可恢复的`RunState`对象现在可以在下一次模型调用前,使用`add_input()`暂存持久化的用户输入。暂存的输入可以在序列化后继续保留,会经过输入安全防护措施,并在本地会话和服务器管理的对话中生成一次持久化的 SDK 输入记录。经显式批准的不安全重放仍可能将输入重新发送给提供方,并重复提供方侧的工作。请参阅[恢复前添加输入](results.md#add-input-before-resuming)。 +- 运行时可靠性修复统一了流式和非流式[输出安全防护措施的会话持久化行为](guardrails.md#output-guardrails),在复制和添加命名空间时保留`FunctionTool`子类,并针对[不受支持的 Chat Completions 音频输出](models/index.md#chat-completions-compatibility-options)引发明确错误,而不是静默完成空流。`OpenAIResponsesCompactionSession`包装器会在取消操作传递给调用方之前,尝试并等待[压缩前历史记录恢复](sessions/index.md#auto-compaction-can-block-streaming)。[`VoicePipeline`](voice/pipeline.md#results)使用方现在会在运行正常完成后收到转录会话关闭失败,而较早发生的轮次失败仍优先于稍后发生的关闭失败。`RunState`往返转换现在会保留本地 shell 输出、已确认的计算机安全检查、使用默认值的工具输出字段,以及遍历字典、列表或元组时遇到的 Pydantic 模型或 dataclass 输出。MCP 转换会保留自由形式的对象 schema 和图像输出,并将音频和资源块等其他原始内容块序列化为有效的 JSON 文本。`MCPServerManager`会串行化重叠的生命周期操作,并为连接和清理应用有限的默认超时。模型重放会先从输出项中移除服务器拥有的`created_by`元数据,再将其用作输入。 ### 0.19.0 -此次次版本发布**未**引入破坏性变更。次版本号递增反映了一项重要的 OpenAI Responses 新功能领域:程序化工具调用。 +此次次版本发布**没有**引入破坏性变更。次版本号的递增反映了一个重要的新OpenAI Responses 功能领域:程序化工具调用。 -重点: +要点: -- 新增 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],使受支持的 OpenAI Responses 模型能够生成 JavaScript,以协调符合程序化工具调用条件的工具。它支持每个工具的 `allowed_callers`、来自 `FunctionTool` 实例的 structured outputs,以及与 Runner 流式传输、安全防护措施、批准、会话和 `RunState` 的集成。有关设置和限制,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 -- 新增公共 `agents.decorators` 模块和 `@tool`,后者是现有 `@function_tool` 装饰器的较短别名,与现有安全防护措施装饰器并列提供。`FunctionTool` 实例现在也支持异步可调用对象。 -- SDK 配置现在可在智能体、运行、模型、会话、沙盒和语音管线中统一接受类型化设置对象或字典,并会验证未知设置。 -- 加强了模型、工具、MCP、Realtime、会话、沙盒和追踪中的错误与诊断日志记录,在保留有用调试上下文的同时,避免暴露原始敏感载荷。 -- 改进了 AnyLLM、LiteLLM 和 Chat Completions 兼容性,在模型重试期间保留会话历史记录,并针对响应开始前发生的 WebSocket 过载添加了提供商重试指引,使选择启用的 Runner 重试策略能够在获准时重放失败的尝试。 -- 通过 `VercelCloudBucketMountStrategy` 新增[只能在创建 Vercel 沙盒时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。具有挂载的会话不会将存储桶内容纳入工作区持久化,并且有意不支持动态挂载变更或会话恢复。 +- 新增[`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],使受支持的OpenAI Responses 模型能够生成 JavaScript,以协调符合程序化工具调用条件的工具。它支持按工具设置`allowed_callers`、来自`FunctionTool`实例的 structured outputs,并支持与 Runner 流式传输、安全防护措施、批准、会话和`RunState`集成。有关设置方式和约束,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 +- 新增公共`agents.decorators`模块和`@tool`,后者是现有`@function_tool`装饰器的较短别名,与现有安全防护措施装饰器并列提供。`FunctionTool`实例现在还支持异步可调用对象。 +- SDK 配置现在可在智能体、运行、模型、会话、沙箱和语音管线中一致地接受类型化设置对象或字典,并会验证未知设置。 +- 加强了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误与诊断日志,避免暴露原始敏感载荷,同时保留有用的调试上下文。 +- 改进了 AnyLLM、LiteLLM 和 Chat Completions 兼容性,在模型重试期间保留会话历史记录,并针对响应开始前发生的 WebSocket 过载新增了提供方重试指导,使选择启用的 Runner 重试策略能够在获得许可时重放失败的尝试。 +- 通过`VercelCloudBucketMountStrategy`新增了[只能在创建 Vercel 沙箱时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。已挂载的会话不会将存储桶内容纳入工作区持久化,并且有意不支持动态挂载变更或会话恢复。 ### 0.18.0 -此次次版本发布**未**引入破坏性变更。次版本号递增仅用于 Realtime 智能体默认模型更新。 +此次次版本发布**没有**引入破坏性变更。次版本号仅因 Realtime 智能体默认模型更新而递增。 -重点: +要点: -- Realtime 智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 设置无需额外配置即可使用最新的推荐模型。 +- Realtime智能体现在使用`gpt-realtime-2.1`作为默认模型,因此新的 Realtime 设置无需额外配置即可使用最新推荐模型。 ### 0.17.0 -在此版本中,沙盒本地源具体化会将 `LocalFile.src` 和 `LocalDir.src` 限制在具体化 `base_dir` 内,除非源路径由 `Manifest.extra_path_grants` 覆盖。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源会从该目录解析,而绝对本地源必须已经位于该目录内或处于显式授权范围内。此项变更修复了本地工件边界问题,但可能影响有意将该基础目录之外的可信主机文件或目录复制到沙盒工作区的应用程序。 +在此版本中,沙箱本地源具体化会将`LocalFile.src`和`LocalDir.src`限制在具体化`base_dir`之内,除非源路径受`Manifest.extra_path_grants`覆盖。应用清单时,`base_dir`是 SDK 进程的当前工作目录;相对本地源将从该目录解析,而绝对本地源必须已位于该目录内或显式授权的目录下。此变更修复了一个本地制品边界问题,但可能会影响有意将该基础目录之外的受信任主机文件或目录复制到沙箱工作区的应用程序。 -若要迁移,请使用 `SandboxPathGrant` 在清单级别授权可信主机根目录;如果沙盒只需读取这些文件,最好将其设为只读: +若要迁移,请在清单级别使用`SandboxPathGrant`授予对受信任主机根目录的访问权限;如果沙箱只需读取这些文件,最好授予只读权限: ```python from pathlib import Path @@ -90,28 +103,28 @@ manifest = Manifest( ) ``` -应将 `extra_path_grants` 视为可信应用程序配置。除非应用程序已经批准相关主机路径,否则不要根据模型输出或其他不可信的清单输入填充授权项。 +请将`extra_path_grants`视为受信任的应用程序配置。除非应用程序已批准这些主机路径,否则不要根据模型输出或其他不受信任的清单输入填充授权。 ### 0.16.0 -在此版本中,SDK 默认模型现已从 `gpt-4.1` 改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含 `reasoning.effort="none"` 和 `verbosity="low"` 等 GPT-5 默认值。 +在此版本中,SDK 默认模型现在是`gpt-5.4-mini`,而不再是`gpt-4.1`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含`reasoning.effort="none"`和`verbosity="low"`等 GPT-5 默认值。 -如果需要保留此前的默认模型行为,请在智能体或运行配置中显式设置模型,或设置 `OPENAI_DEFAULT_MODEL` 环境变量: +如果需要保留之前的默认模型行为,请在智能体或运行配置中显式设置模型,或者设置`OPENAI_DEFAULT_MODEL`环境变量: ```python agent = Agent(name="Assistant", model="gpt-4.1") ``` -重点: +要点: -- `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 -- 在本地、Docker 和提供商支持的各种沙盒实现中,沙盒工作区水合现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 +- `Runner.run`、`Runner.run_sync`和`Runner.run_streamed`现在接受`max_turns=None`以禁用轮次限制。 +- 对于本地、Docker 和提供方支持的沙箱实现,沙箱工作区填充现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 ### 0.15.0 -在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于 structured outputs,也不再导致运行循环持续重试直至 `MaxTurnsExceeded`。 +在此版本中,模型拒绝现在会显式呈现为`ModelRefusalError`,而不再被视为空文本输出;对于结构化输出,也不会再导致运行循环不断重试,直至触发`MaxTurnsExceeded`。 -这会影响此前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不抛出异常,请提供 `model_refusal` 运行错误处理程序: +这会影响此前预期仅包含拒绝的模型响应以`final_output == ""`完成的代码。若要在不引发异常的情况下处理拒绝,请提供`model_refusal`运行错误处理程序: ```python result = Runner.run_sync( @@ -121,94 +134,94 @@ result = Runner.run_sync( ) ``` -对于使用 structured outputs 的智能体,该处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样对其进行验证。 +对于结构化输出智能体,处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样验证该值。 ### 0.14.0 -此次次版本发布**未**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙盒智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 +此次次版本发布**没有**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 -重点: +要点: -- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙盒运行时接口,使智能体能够在支持文件、目录、Git 仓库、挂载、快照和恢复的持久隔离工作区中工作。 -- 通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 新增用于本地和容器化开发的沙盒执行后端,并通过 Python 包中的可选依赖 extras,为 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 提供托管提供商集成。 -- 新增沙盒记忆支持,使未来运行能够复用此前运行中的经验,并支持渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆代码示例。 -- 新增更广泛的工作区和恢复模型,包括本地与合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 -- 在 `examples/sandbox/` 下新增大量沙盒代码示例和教程,涵盖使用技能、任务转移和记忆的编码任务,特定于提供商的设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 -- 扩展核心运行时和追踪栈,增加可感知沙盒的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出脱敏。 +- 新增以`SandboxAgent`、`Manifest`和`SandboxRunConfig`为核心的 beta 沙箱运行时接口,使智能体可以在持久化的隔离工作区中处理文件、目录、Git 仓库、挂载和快照,并支持恢复。 +- 通过`UnixLocalSandboxClient`和`DockerSandboxClient`新增用于本地及容器化开发的沙箱执行后端,并通过 Python 软件包中的可选依赖 extras,为 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 新增托管提供方集成。 +- 新增沙箱记忆支持,使未来的运行能够复用以往运行中获得的经验,并提供渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆代码示例。 +- 新增更全面的工作区和恢复模型,包括本地及合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过`RunState`、`SandboxSessionState`或已保存快照执行的恢复流程。 +- 在`examples/sandbox/`下新增大量沙箱代码示例和教程,涵盖使用技能、任务转移和记忆完成编码任务、特定于提供方的设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 +- 扩展核心运行时和追踪栈,新增可感知沙箱的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出脱敏。 ### 0.13.0 -此次次版本发布**未**引入破坏性变更,但包含一项重要的 Realtime 默认值更新,以及新的 MCP 功能和运行时稳定性修复。 +此次次版本发布**没有**引入破坏性变更,但包含一项值得注意的 Realtime 默认值更新、新的 MCP 功能以及运行时稳定性修复。 -重点: +要点: -- 默认 WebSocket Realtime 模型现为 `gpt-realtime-1.5`,因此新的 Realtime 智能体设置无需额外配置即可使用较新的模型。 -- `MCPServer` 现在会公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在会公开 `session_id`,从而使使用 MCP Streamable HTTP 传输的会话能够在重新连接后或无状态工作进程之间恢复。 -- Chat Completions 集成现在可以通过 `should_replay_reasoning_content` 选择重新发送现有推理内容,从而改进 LiteLLM/DeepSeek 等适配器中特定于提供商的推理/工具调用连续性。 -- 修复了若干运行时和会话边界情况,包括 `SQLAlchemySession` 中并发的首次写入、移除推理内容后存在孤立 assistant 消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项,以及 `FunctionTool` 实例批量执行器中的竞争条件。 +- 默认 WebSocket Realtime 模型现在是`gpt-realtime-1.5`,因此新的 Realtime 智能体设置无需额外配置即可使用更新的模型。 +- `MCPServer`现在公开`list_resources()`、`list_resource_templates()`和`read_resource()`,而`MCPServerStreamableHttp`现在公开`session_id`,因此使用 MCP Streamable HTTP 传输的会话可以在重新连接或无状态工作进程之间恢复。 +- Chat Completions 集成现在可以通过`should_replay_reasoning_content`选择重新发送现有推理内容,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供方的推理/工具调用连续性。 +- 修复了若干运行时和会话边界情况,包括`SQLAlchemySession`中的并发首次写入、移除推理内容后存在孤立助手消息 ID 的压缩请求、`remove_all_tools()`遗留 MCP/推理项,以及`FunctionTool`实例的批处理执行器中的竞态条件。 ### 0.12.0 -此次次版本发布**未**引入破坏性变更。有关重要功能新增内容,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 +此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 ### 0.11.0 -此次次版本发布**未**引入破坏性变更。有关重要功能新增内容,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 +此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 ### 0.10.0 -此次次版本发布**未**引入破坏性变更,但为 OpenAI Responses 用户新增了一个重要功能领域:Responses API 的 WebSocket 传输支持。 +此次次版本发布**没有**引入破坏性变更,但为OpenAI Responses 用户新增了一个重要功能领域:Responses API 的 WebSocket 传输支持。 -重点: +要点: -- 为 OpenAI Responses 模型新增 WebSocket 传输支持(需选择启用;HTTP 仍为默认传输方式)。 -- 新增 `responses_websocket_session()` 辅助程序 / `ResponsesWebSocketSession`,用于在多轮运行中复用支持 WebSocket 的共享提供商和 `RunConfig`。 -- 新增 WebSocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、批准和后续轮次。 +- 新增对OpenAI Responses 模型的 WebSocket 传输支持(选择启用;HTTP 仍为默认传输)。 +- 新增`responses_websocket_session()`辅助程序/`ResponsesWebSocketSession`,用于在多轮运行中复用支持共享 WebSocket 的提供方和`RunConfig`。 +- 新增一个 WebSocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、批准和后续轮次。 ### 0.9.0 -在此版本中,不再支持 Python 3.9,因为此主要版本已于三个月前终止生命周期。请升级到较新的运行时版本。 +在此版本中,不再支持 Python 3.9,因为该主版本已于三个月前终止支持。请升级到更新的运行时版本。 -此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会引发破坏性问题,但如果代码依赖范围更广的联合类型,可能需要进行一些相应调整。 +此外,`Agent#as_tool()`方法返回值的类型提示已从`Tool`收窄为`FunctionTool`。此变更通常不会造成破坏性问题,但如果您的代码依赖较宽泛的联合类型,可能需要进行一些调整。 ### 0.8.0 -在此版本中,两项运行时行为变更可能需要迁移: +在此版本中,两项运行时行为变更可能需要执行迁移: -- `FunctionTool` 实例包装的**同步** Python 可调用对象现在会通过 `asyncio.to_thread(...)` 在工作线程上执行,而不再在事件循环线程上运行。如果工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中明确处理线程亲和性。 -- 本地 MCP 工具失败处理现在可配置,默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务器级 `failure_error_function` 值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地 MCP 服务器上设置 `failure_error_function=None`。 +- 包装**同步** Python 可调用对象的`FunctionTool`实例现在通过`asyncio.to_thread(...)`在工作线程上执行,而不再在事件循环线程上运行。如果工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移至异步工具实现,或者在工具代码中显式指定线程亲和性。 +- 本地 MCP 工具失败处理现在可配置,并且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果依赖快速失败语义,请设置`mcp_config={"failure_error_function": None}`。服务器级`failure_error_function`值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地 MCP 服务器上设置`failure_error_function=None`。 ### 0.7.0 -在此版本中,有几项行为变更可能影响现有应用程序: +在此版本中,有几项行为变更可能会影响现有应用程序: -- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果依赖 v0.6.x 中默认启用的嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 -- `gpt-5.1` / `gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(此前默认值为 SDK 默认配置的 `"low"`)。如果提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置它。 +- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果依赖 v0.6.x 中默认的嵌套行为,请显式设置`RunConfig(nest_handoff_history=True)`。 +- `gpt-5.1`/`gpt-5.2`的默认`reasoning.effort`已改为`"none"`(之前的默认值为 SDK 默认设置配置的`"low"`)。如果您的提示词或质量/成本配置依赖`"low"`,请在`model_settings`中显式设置它。 ### 0.6.0 -在此版本中,默认任务转移历史记录现在会打包为一条 assistant 消息,而不再将用户和 assistant 轮次作为单独消息传递,从而为下游智能体提供简洁且可预测的回顾 -- 现有的单消息任务转移记录现在默认在 `` 块之前以确切的字面文本 `For context, here is the conversation so far between the user and the previous agent:` 开头,从而为下游智能体提供带有明确标签的回顾 +在此版本中,默认任务转移历史记录现在会打包到单条助手消息中,而不是将用户和助手轮次作为单独消息传递,从而为下游智能体提供简洁、可预测的回顾 +- 现有的单消息任务转移记录现在默认以确切的字面文本`For context, here is the conversation so far between the user and the previous agent:`开头,后接``块,使下游智能体获得带有明确标签的回顾 ### 0.5.0 -此版本未引入任何可见的破坏性变更,但包含新功能和一些重要的底层更新: +此版本没有引入任何可见的破坏性变更,但包含新功能以及一些重要的底层更新: -- 在 `RealtimeRunner` 中新增对处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 -- 大幅修订 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 +- 在`RealtimeRunner`中新增了对处理[SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 +- 大幅修改了`Runner#run_sync`的内部逻辑,以兼容 Python 3.14 ### 0.4.0 -在此版本中,不再支持 [openai](https://pypi.org/project/openai/) 包的 v1.x 版本。请将 openai v2.x 与此 SDK 配合使用。 +在此版本中,不再支持 [openai](https://pypi.org/project/openai/) 软件包的 v1.x 版本。请将 openai v2.x 与此 SDK 配合使用。 ### 0.3.0 -在此版本中,Realtime API 支持迁移至 gpt-realtime 模型及其 API 接口(GA 版本)。 +在此版本中,Realtime API 支持迁移至 gpt-realtime 模型及其 API 接口(正式发布版本)。 ### 0.2.0 -在此版本中,少数原本接受 `Agent` 作为参数的位置,现改为接受 `AgentBase`。例如,这适用于 MCP 服务器中的 `list_tools()` 方法签名。这只是类型层面的变更,仍会收到 `Agent` 对象。更新时,只需将 `Agent` 替换为 `AgentBase`,以修复类型错误。 +在此版本中,一些过去接受`Agent`作为参数的位置现在改为接受`AgentBase`。例如,这适用于 MCP 服务器中的`list_tools()`方法签名。这纯粹是类型层面的变更,您仍会收到`Agent`对象。更新时,只需将`Agent`替换为`AgentBase`,以修复类型错误。 ### 0.1.0 -在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增两个参数:`run_context` 和 `agent`。需要将这些参数添加到 `MCPServer` 子类中所有被覆盖的 `MCPServer.list_tools()` 方法。 \ No newline at end of file +在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer]新增了两个参数:`run_context`和`agent`。您需要将这些参数添加到`MCPServer`子类中每个被重写的`MCPServer.list_tools()`方法。 \ No newline at end of file diff --git a/docs/zh/testing.md b/docs/zh/testing.md new file mode 100644 index 0000000000..accf3ec3ad --- /dev/null +++ b/docs/zh/testing.md @@ -0,0 +1,575 @@ +--- +search: + exclude: true +--- +# 测试 + +SDK 为智能体工作流、沙箱会话、Realtime 会话和语音管线提供确定性的、提供商中立的测试工具。这些工具在内存中运行,不会向模型、沙箱提供商或 Realtime API 发出请求,并会记录由 SDK 管理的规范化交互。以下可运行配方会在每次运行时禁用追踪,以便在配置了 OpenAI API 密钥时,默认追踪处理器不会上传测试活动。 + +使用这些工具测试由应用和 SDK 管理的编排:工具执行、任务转移、安全防护措施、重试、流式传输、会话行为、沙箱能力、Realtime 事件处理和语音管线组合。对于由外部模型、网络协议、沙箱提供商或音频系统管理的行为,请使用真实的提供商适配器或集成环境。 + +## 配方选择 + +| 目标 | 使用 | 参阅 | +| --- | --- | --- | +| 返回固定的最终答案 | 带有 `assistant_message()` 的 `ScriptedModel` | [固定响应返回](#return-a-fixed-response) | +| 执行多轮工具循环 | `function_call()`,后接智能体响应 | [工具工作流测试](#test-a-tool-workflow) | +| 根据请求选择响应 | `ModelStep.respond()` 或 `responder` 映射 | [从请求派生响应](#derive-a-response-from-the-request) | +| 断言运行器发送给模型的内容 | `calls`、`first_call` 或 `last_call` | [模型调用检查](#inspect-model-calls) | +| 测试流式运行 | 普通响应步骤,或用于精确事件的 `ModelStep.stream()` | [流式传输测试](#test-streaming) | +| 测试错误或重试决策 | `ModelStep.raise_error()` | [模型故障注入](#inject-model-failures) | +| 检测意外的工作流变更 | 精确的 FIFO 步骤加 `assert_complete()` | [工作流漂移检测](#detect-workflow-drift) | +| 在不启动沙箱的情况下测试 `SandboxAgent` | `scripted_sandbox_session()` 加 `ScriptedModel` | [沙箱智能体工作流测试](#test-a-sandbox-agent-workflow) | +| 匹配沙箱调用或派生其结果 | 沙箱步骤上的 `match` 或 `responder` | [沙箱步骤配置](#configure-sandbox-steps) | +| 在不建立连接的情况下测试 Realtime 会话 | `ScriptedRealtimeModel` 和 `RealtimeStep` | [Realtime 会话测试](#test-a-realtime-session) | +| 测试 Realtime 工具工作流 | 发出 `RealtimeModelToolCallEvent` 并预期工具输出 | [Realtime 工具工作流测试](#test-a-realtime-tool-workflow) | +| 测试静态或流式语音管线 | `ScriptedSTTModel`、`ScriptedTTSModel`,以及脚本化或真实的工作流 | [语音管线测试](#test-a-voice-pipeline) | +| 测试提供商序列化或线上传输载荷 | 使用受控网络传输的真实提供商适配器 | [正确边界选择](#choose-the-correct-boundary) | + +## 导入 + +测试 API 与其替代的运行时边界位于同一位置: + +| 边界 | 导入路径 | +| --- | --- | +| 智能体模型和沙箱工作流 | `agents.testing` | +| Realtime 模型传输 | `agents.realtime.testing` | +| 语音 STT、TTS 和工作流组件 | `agents.voice.testing` | + +测试符号有意不包含在顶层 `agents` 导入中。 + +## 智能体工作流配方 + +### 固定响应返回 + +为每个预期的模型调用传入一个规范化输出项序列。输出序列简写会为一个请求接收确定性的响应 ID 和用量。 + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ScriptedModel, assistant_message + + +@pytest.mark.asyncio +async def test_fixed_response() -> None: + model = ScriptedModel( + [[assistant_message("Paris is the capital of France.")]] + ) + agent = Agent(name="Geography assistant", model=model) + + result = await Runner.run( + agent, + "What is the capital of France?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Paris is the capital of France." + assert len(model.calls) == 1 + model.assert_complete() +``` + +使用 `model.assert_complete()` 完成确定性工作流测试。它可以捕获工作流在消耗所有已配置步骤之前停止的情况。 + +### 工具工作流测试 + +编写一个调用工具的模型响应脚本,再编写一个生成最终答案的响应脚本。真实的 SDK 工具管线会在这些模型调用之间运行。 + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.decorators import tool +from agents.testing import ScriptedModel, assistant_message, function_call + + +@tool +def get_weather(city: str) -> str: + """Return the weather for a city.""" + return f"{city}: sunny" + + +@pytest.mark.asyncio +async def test_tool_workflow() -> None: + model = ScriptedModel( + [ + [function_call("get_weather", {"city": "Tokyo"}, call_id="call_1")], + [assistant_message("It is sunny in Tokyo.")], + ] + ) + agent = Agent(name="Weather assistant", model=model, tools=[get_weather]) + + result = await Runner.run( + agent, + "What is the weather in Tokyo?", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "It is sunny in Tokyo." + assert len(model.calls) == 2 + assert model.last_call is not None + assert any( + item.get("type") == "function_call_output" + for item in model.last_call.input + ) + model.assert_complete() +``` + +此模式涵盖工具输入验证、执行、结果转换、钩子、安全防护措施和下一轮模型调用。直接调用 Python 函数会绕过这些 SDK 行为。 + +### 从请求派生响应 + +当响应确实依赖于规范化模型调用,或者断言应位于模型边界时,请使用 `ModelStep.respond()`。响应器可以是同步或异步的,并且可以返回 `ScriptedModel` 接受的任何步骤形式。 + +```python +import pytest + +from agents import Agent, RunConfig, Runner +from agents.testing import ModelCall, ModelStep, ScriptedModel, assistant_message + + +def respond(call: ModelCall): + assert call.streamed is False + assert call.input == [{"content": "Summarize this", "role": "user"}] + return {"output": [assistant_message("Handled the normalized request.")]} + + +@pytest.mark.asyncio +async def test_request_aware_response() -> None: + model = ScriptedModel([ModelStep.respond(respond)]) + agent = Agent(name="Assistant", model=model) + + result = await Runner.run( + agent, + "Summarize this", + run_config=RunConfig(tracing_disabled=True), + ) + + assert result.final_output == "Handled the normalized request." + model.assert_complete() +``` + +`ScriptedModel` 接受 `ModelStep`、等效的字典形式、`ModelResponse`、规范化输出项序列或异常。当响应不依赖调用时,优先使用固定输出序列,因为固定脚本更容易诊断意外轮次。 + +### 模型调用检查 + +`ScriptedModel` 会在解析每个调用或引发所选步骤之前记录该调用。 + +| 成员 | 内容 | +| --- | --- | +| `calls` | 按调用顺序排列的每个 `ModelCall` | +| `first_call` | 第一次调用,或 `None` | +| `last_call` | 最近一次调用,或 `None` | +| `remaining_steps` | 尚未消耗的已配置步骤数量 | + +常见断言包括 `call.input`、`call.model_settings`、`call.tools`、`call.handoffs` 和 `call.streamed`。可变请求数据会在调用边界创建快照,并且每个公共历史记录访问器都会返回分离的快照。工具、任务转移、输出模式和追踪对象会保留其运行时标识。 + +结构化的 `call_index` 和 `input_index` 错误字段从零开始,因此可以直接索引 `calls[...]` 或提供的步骤序列。供人阅读的错误消息会显示从一开始的调用编号或步骤编号。 + +当一个测试需要逐步追加模型步骤时,请使用 `enqueue()` 或 `extend()`。对于独立场景,请创建新的 `ScriptedModel`;该工具不会重置已消耗的步骤或调用历史记录。 + +### 流式传输测试 + +普通响应步骤同时支持 `Runner.run()` 和 `Runner.run_streamed()`。对于常见的智能体消息、推理项、函数调用和应用补丁调用,`ScriptedModel` 会生成规范化的开始、增量、项目完成和终止响应事件。终止响应包含完整的输出和用量。 + +仅当精确的规范化 `TResponseStreamEvent` 序列属于被测行为的一部分时,才使用 `ModelStep.stream()`: + +```python +step = ModelStep.stream( + events, + output=[assistant_message("The terminal output used by the runner.")], +) +``` + +`events` 可以是固定序列,也可以是接收已记录 `ModelCall` 的异步工厂。可选的 `output` 是在非流式调用中使用同一步骤时返回的响应。精确流事件是 SDK 规范化事件,而不是 Responses API 或 Chat Completions 的线上传输分块。 + +自动流式传输会拒绝尚未实现增量生命周期的规范化输出项类型。对于这些项目,请使用 `ModelStep.stream(...)`,而不要依赖不完整的事件序列。 + +### 模型故障注入 + +使用 `ModelStep.raise_error()` 使一次模型调用失败。可选的重试建议属于该特定脚本错误: + +```python +from agents import ModelRetryAdvice +from agents.testing import ModelStep + + +step = ModelStep.raise_error( + RuntimeError("temporary failure"), + retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"), +) +``` + +运行器的重试策略决定该建议是否会触发另一次尝试。每次重试都是另一次模型调用,并会消耗下一个脚本步骤。Python 辅助工具接受固定的 `ModelRetryAdvice` 值;如果重试建议本身需要根据尝试次数动态变化,请使用自定义 `Model`。 + +### 工作流漂移检测 + +将脚本化调用视为预期的工作流形态。额外的模型请求会引发 `UnexpectedModelCall`;提前退出则会留下步骤,供 `assert_complete()` 报告。 + +如果测试框架支持拆卸或终结器,并且还希望在另一个断言失败后报告未消耗的步骤,请将 `assert_complete()` 放在其中。在常规回归测试中,请勿捕获不匹配错误。 + +| 错误 | 结构化字段 | 含义 | +| --- | --- | --- | +| `InvalidModelStep` | `reason`、`input_index` | 步骤格式不正确,在进入队列前即被拒绝 | +| `UnexpectedModelCall` | `call`、`call_index` | 脚本结束后,工作流又进行了一次模型调用 | +| `UnconsumedModelSteps` | `remaining_steps` | 工作流在使用所有步骤之前结束 | + +## 沙箱智能体配方 + +### 沙箱智能体工作流测试 + +将 `ScriptedModel` 与 `scripted_sandbox_session()` 组合使用,可以在不创建本地容器或远程沙箱的情况下运行真实的 `SandboxAgent` 运行时。模型脚本选择一个能力工具,而沙箱脚本定义对应的 `SandboxSession` 方法返回什么内容。 + +```python +import pytest + +from agents import RunConfig, Runner +from agents.sandbox import ExecResult, SandboxAgent +from agents.sandbox.capabilities import Shell +from agents.testing import ( + ScriptedModel, + assistant_message, + function_call, + scripted_sandbox_session, +) + + +@pytest.mark.asyncio +async def test_sandbox_workflow() -> None: + sandbox = scripted_sandbox_session( + [ + { + "method": "exec", + "match": lambda call: call.args == ("pwd",), + "result": ExecResult( + stdout=b"/workspace\n", + stderr=b"", + exit_code=0, + ), + } + ] + ) + model = ScriptedModel( + [ + [function_call("exec_command", {"cmd": "pwd"}, call_id="call_1")], + [assistant_message("The workspace is /workspace.")], + ] + ) + agent = SandboxAgent( + name="Workspace assistant", + model=model, + capabilities=[Shell()], + ) + + async with sandbox: + result = await Runner.run( + agent, + "Which directory are you in?", + run_config=RunConfig( + sandbox={"session": sandbox}, + tracing_disabled=True, + ), + ) + + assert result.final_output == "The workspace is /workspace." + assert [call.method for call in sandbox.calls] == ["exec"] + sandbox.assert_complete() + model.assert_complete() +``` + +此测试跨越两个规范化 SDK 边界。它涵盖工具参数验证、能力路由、沙箱会话调用、将工具结果传递到下一轮模型调用,以及最终输出处理。它不会测试真实模型是否会选择该命令,也不会测试真实沙箱提供商如何执行该命令。 + +### 沙箱步骤配置 + +每个匹配的沙箱调用都会消耗一个全局 FIFO 序列中的下一个步骤。方法不匹配、匹配器拒绝或匹配器异常都会使该步骤保持待处理状态。设置 `method`,仅选择一种结果,并且仅当调用详情很重要时才添加 `match`。 + +| 步骤成员 | 适用情形 | +| --- | --- | +| `result` | 方法应返回固定的类型化值 | +| `responder` | 结果取决于分离的 `SandboxCall` | +| `error` | 方法应引发特定异常 | +| `match` | 除非匹配器返回 `False` 以外的值,否则应在产生结果前拒绝调用 | + +支持的脚本化方法名称为 `apply_patch`、`exec`、`ls`、`mkdir`、`pty_exec_start`、`pty_write_stdin`、`read`、`rm` 和 `write`。仅公开已配置的面向模型的能力。当配置了任一 PTY 方法时,两个 PTY 方法会一并公开,因为它们构成一个交互式 shell 能力,但调用仍会消耗全局 FIFO 脚本。 + +`sandbox.calls` 包含分离的 `SandboxCall` 快照,其中含有从零开始的 `call_index`、`method`、位置参数 `args` 和只读的 `kwargs`。创建脚本时也会为静态结果创建快照。支持 `io.BytesIO` 和 `io.StringIO` 值;对于其他实时流对象或生命周期行为,请使用自定义沙箱会话。 + +| 错误 | 结构化字段 | 含义 | +| --- | --- | --- | +| `InvalidSandboxStep` | `reason`、`input_index`、`method` | 步骤格式不正确或指定了不受支持的方法 | +| `UnexpectedSandboxCall` | `call`、`call_index`、`actual_method`、`expected_method`、`remaining_steps` | 工作流调用了错误的方法,或在脚本结束后仍继续运行 | +| `SandboxCallMatcherError` | `call`、`call_index`、`method` | 步骤匹配器返回了 `False` | +| `UnconsumedSandboxSteps` | `remaining_steps`、`pending_methods` | 工作流在使用所有步骤之前结束 | + +返回的对象就是会话本身。请将其直接传给 `RunConfig(sandbox={"session": sandbox})`;不存在包装器 `.session` 属性。 + +## Realtime 配方 + +### Realtime 会话测试 + +`ScriptedRealtimeModel` 实现 Python SDK 的规范化 `RealtimeModel` 边界。每个 `RealtimeStep` 匹配一个出站 `RealtimeModelSendEvent`,然后发出规范化的入站 `RealtimeModelEvent` 对象或引发注入的错误。 + +```python +import pytest + +from agents.realtime import ( + RealtimeAgent, + RealtimeModelOutputTextDeltaEvent, + RealtimeModelSendUserInput, + RealtimeRawModelEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@pytest.mark.asyncio +async def test_realtime_message() -> None: + reply = RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="Hello!", + response_id="response_1", + ) + model = ScriptedRealtimeModel( + [ + RealtimeStep( + expect=RealtimeModelSendUserInput(user_input="Hello"), + emit=[reply], + ) + ] + ) + runner = RealtimeRunner( + RealtimeAgent(name="Assistant"), + model=model, + config={"tracing_disabled": True}, + ) + + observed_reply = False + async with await runner.run() as session: + await session.send_message("Hello") + async for event in session: + if isinstance(event, RealtimeRawModelEvent) and event.data == reply: + observed_reply = True + break + + assert observed_reply + assert model.sent_events == (RealtimeModelSendUserInput(user_input="Hello"),) + assert model.closed is True + model.assert_complete() +``` + +预期项可以是精确的事件值、通过 `isinstance` 匹配的事件类,或接收出站事件并在匹配时返回 `True` 的可调用对象。默认启用严格模式。使用 `strict=False` 时,无关的出站事件会被记录,但不会消耗待处理步骤;当会话发出被测行为范围之外的附带事件时,这很有用。 + +使用 `connect_events` 在连接期间发出入站事件。使用 `connect_error` 或 `close_error` 注入生命周期故障,并使用 `RealtimeStep(error=...)` 注入与一次匹配发送相关的故障。一个步骤不能同时定义 `emit` 和 `error`。 + +### Realtime 工具工作流测试 + +将真实的函数工具附加到 `RealtimeAgent`,发出规范化工具调用,并预期 SDK 通过模型边界发送工具输出。将 `async_tool_calls` 设置为 `False`,可使这个小型代码示例在连接期间完成,而无需测试专用的等待机制。 + +```python +import pytest + +from agents.decorators import tool +from agents.realtime import ( + RealtimeAgent, + RealtimeModelSendToolOutput, + RealtimeModelToolCallEvent, + RealtimeRunner, +) +from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel + + +@tool +def lookup_order(order_id: str) -> str: + """Look up an order by ID.""" + return f"Order {order_id} has shipped." + + +@pytest.mark.asyncio +async def test_realtime_tool_workflow() -> None: + tool_call = RealtimeModelToolCallEvent( + name="lookup_order", + call_id="call_1", + arguments='{"order_id":"order_123"}', + ) + + def matches_tool_output(event) -> bool: + return ( + isinstance(event, RealtimeModelSendToolOutput) + and event.tool_call.call_id == "call_1" + and event.output == "Order order_123 has shipped." + ) + + model = ScriptedRealtimeModel( + [RealtimeStep(expect=matches_tool_output)], + connect_events=[tool_call], + ) + agent = RealtimeAgent( + name="Order assistant", + tools=[lookup_order], + ) + runner = RealtimeRunner( + agent, + model=model, + config={"async_tool_calls": False, "tracing_disabled": True}, + ) + + async with await runner.run(): + pass + + model.assert_complete() +``` + +这会运行真实的 Realtime 工具查找、参数验证、执行和输出路由。它无法证明真实模型会选择该工具。 + +### Realtime 调用与生命周期检查 + +| 成员 | 内容 | +| --- | --- | +| `connect_calls` | 不含凭据的分离连接快照 | +| `sent_events` | 按调用顺序排列的分离出站事件快照 | +| `remaining_steps` | 剩余的预期出站发送 | +| `listeners` | 当前注册的监听器对象 | +| `connected`、`closed`、`close_calls` | 当前内存中生命周期状态 | + +连接历史记录只记录是否提供了 API 密钥或标头字段,绝不会存储其值。URL 快照会移除用户信息、查询参数和片段。可变事件数据和设置会被分离,而工具、任务转移和播放追踪器等实时 SDK 对象会保留其标识。 + +使用 `model.assert_complete()` 完成测试,并让 `RealtimeSession` 异步上下文管理器关闭模型。Python 工具有意不提供待处理预期项 Promise、隐式超时或单独的 `assert_closed()` 辅助工具。 + +| 错误 | 结构化字段 | 含义 | +| --- | --- | --- | +| `UnexpectedRealtimeSend` | `actual`、`expected` | 严格的出站发送与下一个步骤不匹配,或已无剩余步骤 | +| `UnconsumedRealtimeSteps` | `remaining_steps` | 会话在使用所有预期发送之前结束 | +| `RealtimeScriptError` | 无 | 脚本在无效的生命周期状态下使用,例如在断开连接时发送 | + +## 语音管线配方 + +### 语音管线测试 + +将脚本化 STT 和 TTS 模型与 `SingleAgentVoiceWorkflow` 以及由 `ScriptedModel` 支持的智能体组合使用,可以在不发出提供商请求的情况下测试完整的语音转文本 -> 智能体 -> 文本转语音管线。 + +```python +import numpy as np +import pytest + +from agents import Agent +from agents.testing import ScriptedModel, assistant_message +from agents.voice import AudioInput, SingleAgentVoiceWorkflow, VoicePipeline +from agents.voice.testing import ( + ScriptedSTTModel, + ScriptedTTSModel, + TTSResult, + pcm16_samples, +) + + +@pytest.mark.asyncio +async def test_voice_pipeline() -> None: + model = ScriptedModel([[assistant_message("Hello there.")]]) + stt = ScriptedSTTModel("hello") + pcm = pcm16_samples([0, 100, -100, 0]) + tts = ScriptedTTSModel([TTSResult([pcm])]) + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow( + Agent(name="Voice assistant", model=model) + ), + stt_model=stt, + tts_model=tts, + config={"tracing_disabled": True, "tts_settings": {"buffer_size": 1}}, + ) + + result = await pipeline.run(AudioInput(np.zeros(2, dtype=np.int16))) + events = [event async for event in result.stream()] + + assert events + assert [call.text for call in tts.calls] == ["Hello there."] + stt.assert_complete() + tts.assert_complete() + model.assert_complete() +``` + +当被测对象是管线的 STT/TTS 生命周期而不是智能体编排时,请改用 `ScriptedVoiceWorkflow`: + +```python +from agents.voice.testing import ScriptedVoiceWorkflow + + +workflow = ScriptedVoiceWorkflow( + turns=["Hello there."], + start="Welcome.", +) +``` + +`start` 步骤由 `on_start()` 消耗。`VoicePipeline` 仅针对 `StreamedAudioInput` 调用 `on_start()`;静态 `AudioInput` 运行不会消耗 `start`。每个普通轮次都会记录其转录结果,并消耗一个已配置结果。一个字符串代表一个片段;字符串序列可在文本拆分和 TTS 之前控制片段边界。 + +### 流式转录测试 + +`ScriptedSTTModel` 接受静态 `transcriptions` 和独立脚本化的流式 `sessions`。会话可以是 `ScriptedTranscriptionSession`、转录轮次序列、异常或单个字符串: + +```python +from agents.voice.testing import ScriptedSTTModel, ScriptedTranscriptionSession + + +session = ScriptedTranscriptionSession(["first turn", "second turn"]) +stt = ScriptedSTTModel(sessions=[session]) +``` + +关闭 `ScriptedTranscriptionSession` 会停止迭代,并留下跳过的轮次供 `assert_complete()` 报告。类似地,`ScriptedTTSModel` 每次调用会消耗一个 `TTSResult`、字节块序列或异常。 + +### 语音调用检查 + +| 组件 | 记录的历史 | +| --- | --- | +| `ScriptedSTTModel` | `calls`、`session_calls` 和实时 `created_sessions` 标识 | +| `ScriptedTTSModel` | 包含文本和分离设置的 `calls` | +| `ScriptedVoiceWorkflow` | 按轮次顺序排列的 `transcriptions` | + +静态音频缓冲区和可变设置会在调用时创建快照。`StreamedAudioInput` 和已创建的转录会话对象会保留其实时标识,因为管线会继续使用它们。 + +| 错误 | 结构化字段 | 含义 | +| --- | --- | --- | +| `UnexpectedVoiceCall` | `operation` | 静态转录、流式会话、TTS 调用、工作流启动或工作流轮次没有已配置步骤 | +| `UnconsumedVoiceSteps` | `remaining_steps` | 仍剩余一个或多个已配置的语音步骤 | + +请对测试配置的每个脚本化语音组件调用 `assert_complete()`。`ScriptedSTTModel.assert_complete()` 还会检查其创建的转录会话中的轮次。 + +## 正确边界选择 + +当测试需要运行 SDK 运行循环、工具、任务转移、安全防护措施、会话、重试或规范化流式传输,而不依赖模型提供商时,请使用 `ScriptedModel`。 + +当测试需要运行 `SandboxAgent` 的能力和编排,而不启动沙箱提供商时,请将 `scripted_sandbox_session()` 与 `ScriptedModel` 配合使用。针对真实沙箱提供商的集成测试应保留提供商创建、进程执行、文件系统保真度、持久性、资源限制和隔离检查。 + +当测试需要运行 `RealtimeSession` 行为或 `RealtimeAgent` 工具及任务转移编排,而不建立 WebSocket 连接时,请使用 `ScriptedRealtimeModel`。原始 Realtime 客户端/服务器事件、身份验证、网络恢复和音频传输行为应在真实传输或集成环境中测试。Realtime API 会话会在客户端发送输入和接收事件期间保持连接,因此这些网络和协议问题属于规范化模型边界以下的层级。有关生产环境连接架构,请参阅 [OpenAI Realtime API 指南](https://developers.openai.com/api/docs/guides/realtime)。 + +当测试需要在不使用语音提供商的情况下运行 STT/TTS 排序、流式转录清理、工作流片段传递或完整的语音管线组合时,请使用语音测试组件。如果测试主题是转录质量、生成语音、编码兼容性、延迟或播放,请使用真实的音频模型和具有代表性的音频。 + +请勿使用这些工具测试 Responses API 或 Chat Completions 请求序列化、身份验证标头、提供商默认值、HTTP 载荷、提供商流分块、Realtime 线上传输帧或提供商特定的生命周期行为。对于这些测试,请保留真实适配器,并替换或控制其网络边界。使用 `openai` v3 时,OpenAI 适配器测试应使用 `httpx2` 的请求、响应、传输和异常类型;旧版 `httpx` 不是 Agents SDK 的核心依赖项。 + +## 最终检查清单 + +- 仅为规范化模型、沙箱会话、Realtime 模型或语音管线边界所管理的交互编写脚本。 +- 断言重要的公共请求或调用字段,而不是运行器私有状态。 +- 优先使用固定响应步骤;仅对依赖请求的行为使用响应器。 +- 优先使用自动模型流式传输;仅当事件级行为很重要时才使用精确流。 +- 每个脚本化组件测试结束时,都调用其 `assert_complete()` 方法。 +- 当外围测试拥有相应生命周期时,使用异步上下文管理器清理 Realtime 和沙箱生命周期。 +- 断言结构化错误字段,而不是解析供人阅读的消息。 +- 使用带受控网络传输的真实适配器进行提供商线上传输测试。 + +## 范围与当前限制 + +测试模块有意不提供: + +- 针对每种规范化模型输出项的便捷构建器。常见情形请使用 `assistant_message()` 和 `function_call()`,其他规范化项目则直接传入。 +- 提供商协议模拟器。精确模型流使用规范化 SDK 事件,而不是 Responses API 或 Chat Completions 的线上传输分块。 +- 高层级模拟 Realtime 服务器。测试会显式匹配规范化出站发送,并发出场景所需的规范化入站事件。 +- 无序的沙箱或 Realtime 预期项。这两种工具都会按一个全局顺序消耗预期步骤。 +- 测试运行器专用的匹配器、fixture、隐式超时或自动拆卸。 +- 重置 API。`ScriptedModel` 支持用于增量脚本的 `enqueue()` 和 `extend()`,但独立场景应创建新的脚本化组件。 + +当测试需要格式错误的流、受控暂停或并发、精确取消,或脚本化工具无法保留的生命周期边界时,请使用对应公共接口的自定义实现。在测试中记录该专用边界。 + +## API 参考 + +- [`agents.testing`](ref/testing.md) +- [`agents.realtime.testing`](ref/realtime/testing.md) +- [`agents.voice.testing`](ref/voice/testing.md) \ No newline at end of file From e4cbad9a516d3e099152aacd18e54e615864e6ef Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 15 Aug 2026 12:19:49 +0900 Subject: [PATCH 325/473] docs: place testing after tracing in sidebar --- mkdocs.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 53add5b1f9..8352c4fc7f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,7 +54,6 @@ plugins: - Configuration: config.md - Documentation: - Agents: agents.md - - Testing: testing.md - Sandbox agents: - Quickstart: sandbox_agents.md - Concepts: sandbox/guide.md @@ -86,6 +85,7 @@ plugins: - Usage: usage.md - Model context protocol (MCP): mcp.md - Tracing: tracing.md + - Testing: testing.md - Agent visualization: visualization.md - REPL utility: repl.md - Examples: examples.md @@ -238,6 +238,7 @@ plugins: - usage.md - mcp.md - tracing.md + - テスト: testing.md - visualization.md - repl.md - コード例: examples.md @@ -281,6 +282,7 @@ plugins: - usage.md - mcp.md - tracing.md + - 테스트: testing.md - visualization.md - repl.md - 코드 예제: examples.md @@ -324,6 +326,7 @@ plugins: - usage.md - mcp.md - tracing.md + - 测试: testing.md - visualization.md - repl.md - 示例: examples.md From 6b62225f03d34833d409fcdac608f0860509dfd5 Mon Sep 17 00:00:00 2001 From: roryc <72149771+Coiggahou2002@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:11:31 +0800 Subject: [PATCH 326/473] fix(core): reject partially matched stacked anchors (#4431) --- src/agents/apply_diff.py | 54 ++++++++++------- tests/sandbox/test_apply_patch.py | 23 ++++++++ tests/test_apply_diff.py | 98 ++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/agents/apply_diff.py b/src/agents/apply_diff.py index 9c3b4547eb..1ff9d43154 100644 --- a/src/agents/apply_diff.py +++ b/src/agents/apply_diff.py @@ -130,18 +130,20 @@ def _parse_update_diff(lines: list[str], input: str) -> ParsedUpdateDiff: cursor = 0 while not _is_done(parser, END_SECTION_MARKERS): - anchors, has_anchor = _read_anchors(parser) + anchors, anchor_count = _read_anchors(parser) - if not (has_anchor or cursor == 0): + if not (anchor_count > 0 or cursor == 0): current_line = parser.lines[parser.index] if parser.index < len(parser.lines) else "" raise ValueError(f"Invalid Line:\n{current_line}") + require_anchor_match = anchor_count > 1 for index, anchor in enumerate(anchors): cursor = _advance_cursor_to_anchor( anchor, input_lines, cursor, parser, + require_match=require_anchor_match, force_forward_search=index > 0, ) @@ -169,7 +171,7 @@ def _parse_update_diff(lines: list[str], input: str) -> ParsedUpdateDiff: return ParsedUpdateDiff(chunks=chunks, fuzz=parser.fuzz) -def _read_anchors(parser: ParserState) -> tuple[list[str], bool]: +def _read_anchors(parser: ParserState) -> tuple[list[str], int]: """Consume the ``@@`` header lines that introduce one hunk. The patch format lets a hunk carry several stacked headers, so nested code can be @@ -179,29 +181,27 @@ def _read_anchors(parser: ParserState) -> tuple[list[str], bool]: @@ def method(): Returns the non-empty headers in the order they should narrow the search, plus - whether a usable header was seen at all. + the total number of consumed headers, including bare ``@@`` markers. """ anchors: list[str] = [] - has_anchor = False + anchor_count = 0 while True: start_index = parser.index anchor = _read_str(parser, "@@ ") consumed = parser.index != start_index - bare = False if not consumed and parser.index < len(parser.lines) and parser.lines[parser.index] == "@@": parser.index += 1 - consumed = bare = True + consumed = True if not consumed: break - if anchor or bare: - has_anchor = True + anchor_count += 1 if anchor.strip(): anchors.append(anchor) - return anchors, has_anchor + return anchors, anchor_count def _advance_cursor_to_anchor( @@ -210,27 +210,39 @@ def _advance_cursor_to_anchor( cursor: int, parser: ParserState, *, + require_match: bool = False, force_forward_search: bool = False, ) -> int: found = False - if force_forward_search or not any(line == anchor for line in input_lines[:cursor]): + has_exact_match_before_cursor = not force_forward_search and any( + line == anchor for line in input_lines[:cursor] + ) + if has_exact_match_before_cursor: + found = True + else: for i in range(cursor, len(input_lines)): if input_lines[i] == anchor: cursor = i + 1 found = True break - if not found and ( - force_forward_search - or not any(line.strip() == anchor.strip() for line in input_lines[:cursor]) - ): - for i in range(cursor, len(input_lines)): - if input_lines[i].strip() == anchor.strip(): - cursor = i + 1 - parser.fuzz += 1 - found = True - break + if not found: + has_trimmed_match_before_cursor = not force_forward_search and any( + line.strip() == anchor.strip() for line in input_lines[:cursor] + ) + if has_trimmed_match_before_cursor: + found = True + else: + for i in range(cursor, len(input_lines)): + if input_lines[i].strip() == anchor.strip(): + cursor = i + 1 + parser.fuzz += 1 + found = True + break + + if require_match and not found: + raise ValueError(f"Invalid Anchor {cursor}:\n{anchor}") return cursor diff --git a/tests/sandbox/test_apply_patch.py b/tests/sandbox/test_apply_patch.py index fb74700918..32fb4a629d 100644 --- a/tests/sandbox/test_apply_patch.py +++ b/tests/sandbox/test_apply_patch.py @@ -88,6 +88,29 @@ async def test_apply_patch_update_uses_stacked_anchor_jump() -> None: ) +@pytest.mark.asyncio +async def test_apply_patch_update_rejects_partially_matched_stacked_anchors() -> None: + session = ApplyPatchSession() + path = Path("/workspace/stacked.py") + original = ( + b"class Target\n def helper():\n pass\n\n def desired():\n return 1\n" + ) + session.files[path] = original + + with pytest.raises(ApplyPatchDiffError, match="Invalid Anchor"): + await session.apply_patch( + ApplyPatchOperation( + type="update_file", + path="stacked.py", + diff=( + "@@ class Target\n@@ def missing():\n- pass\n+ return 99\n" + ), + ) + ) + + assert session.files[path] == original + + @pytest.mark.asyncio async def test_apply_patch_update_matches_end_of_file_context() -> None: session = ApplyPatchSession() diff --git a/tests/test_apply_diff.py b/tests/test_apply_diff.py index 8fd49ea93c..17c2bd43f9 100644 --- a/tests/test_apply_diff.py +++ b/tests/test_apply_diff.py @@ -81,6 +81,48 @@ def test_apply_diff_applies_stacked_anchors_from_the_tool_description() -> None: ) +def test_apply_diff_reuses_a_prior_parent_anchor_across_stacked_hunks() -> None: + input_text = ( + "\n".join( + [ + "class Target", + " def first():", + " pass", + "", + " def second():", + " pass", + ] + ) + + "\n" + ) + diff = "\n".join( + [ + "@@ class Target", + "@@ def first():", + "- pass", + "+ return 1", + "@@ class Target", + "@@ def second():", + "- pass", + "+ return 2", + ] + ) + + assert apply_diff(input_text, diff) == ( + "\n".join( + [ + "class Target", + " def first():", + " return 1", + "", + " def second():", + " return 2", + ] + ) + + "\n" + ) + + def test_apply_diff_stacked_anchors_narrow_to_the_named_block() -> None: """The second anchor skips an earlier matching body inside the selected class.""" input_text = ( @@ -129,14 +171,64 @@ def test_apply_diff_stacked_anchors_narrow_to_the_named_block() -> None: ) -def test_apply_diff_stacked_anchors_stay_advisory_when_unmatched() -> None: - """Same rule a single unmatched anchor already follows: locate by context, don't fail.""" +def test_apply_diff_single_anchor_stays_advisory_when_unmatched() -> None: + """A single unmatched anchor keeps its established context fallback.""" input_text = "a\nb\n" - diff = "\n".join(["@@ nope", "@@ also-nope", "-b", "+B"]) + diff = "\n".join(["@@ nope", "-b", "+B"]) assert apply_diff(input_text, diff) == "a\nB\n" +def test_apply_diff_rejects_partially_matched_stacked_anchors() -> None: + input_text = ( + "\n".join( + [ + "class Target", + " def helper():", + " pass", + "", + " def desired():", + " return 1", + ] + ) + + "\n" + ) + diff = "\n".join( + [ + "@@ class Target", + "@@ def missing():", + "- pass", + "+ return 99", + ] + ) + + with pytest.raises(ValueError, match="Invalid Anchor"): + apply_diff(input_text, diff) + + +def test_apply_diff_rejects_stacked_anchors_when_the_first_is_missing() -> None: + input_text = "class Wrong\n def desired():\n pass\n" + diff = "\n".join( + [ + "@@ class Target", + "@@ def desired():", + "- pass", + "+ return 99", + ] + ) + + with pytest.raises(ValueError, match="Invalid Anchor"): + apply_diff(input_text, diff) + + +def test_apply_diff_rejects_a_missing_anchor_followed_by_a_bare_marker() -> None: + input_text = "a\nb\n" + diff = "\n".join(["@@ missing", "@@", "-b", "+B"]) + + with pytest.raises(ValueError, match="Invalid Anchor"): + apply_diff(input_text, diff) + + def test_apply_diff_stacked_anchors_accept_a_trailing_bare_anchor() -> None: input_text = "class Only\n def run():\n pass\n" diff = "\n".join(["@@ class Only", "@@", "- pass", "+ return 1"]) From 9aba9002935b56d7253d28d99b2a8bca2d2450a7 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:00:26 +0100 Subject: [PATCH 327/473] fix(sandbox): keep model paths POSIX-normalized (#4416) --- .../sandbox/capabilities/tools/shell_tool.py | 3 +- tests/sandbox/test_posix_tool_paths.py | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/sandbox/test_posix_tool_paths.py diff --git a/src/agents/sandbox/capabilities/tools/shell_tool.py b/src/agents/sandbox/capabilities/tools/shell_tool.py index 8da9eddccf..1a0094adf9 100644 --- a/src/agents/sandbox/capabilities/tools/shell_tool.py +++ b/src/agents/sandbox/capabilities/tools/shell_tool.py @@ -5,7 +5,6 @@ import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from pathlib import Path from typing import Any, ClassVar from pydantic import BaseModel, Field @@ -73,7 +72,7 @@ def _resolve_workdir_command( if workdir is None or workdir.strip() == "": return command - resolved_workdir = session.normalize_path(Path(workdir)) + resolved_workdir = session.normalize_path(sandbox_path_str(workdir)) return f"cd {shlex.quote(sandbox_path_str(resolved_workdir))} && {command}" diff --git a/tests/sandbox/test_posix_tool_paths.py b/tests/sandbox/test_posix_tool_paths.py new file mode 100644 index 0000000000..8f6592cb64 --- /dev/null +++ b/tests/sandbox/test_posix_tool_paths.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import base64 +import io +import sys +from pathlib import Path + +import pytest + +from agents.sandbox import Manifest +from agents.sandbox.capabilities.tools import ViewImageArgs, ViewImageTool +from agents.sandbox.capabilities.tools.shell_tool import _resolve_workdir_command +from agents.testing import scripted_sandbox_session +from agents.tool import ToolOutputImage + +_PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a84QAAAAASUVORK5CYII=" +) + + +def test_shell_workdir_normalizes_backslashes_as_sandbox_separators() -> None: + session = scripted_sandbox_session(manifest=Manifest(root="/workspace")) + + command = _resolve_workdir_command( + session=session, + command="pwd", + workdir=r"src\project", + ) + + assert command == "cd /workspace/src/project && pwd" + + +@pytest.mark.skipif(sys.platform == "win32", reason="UnixLocalSandbox is Unix-only") +def test_shell_workdir_normalizes_backslashes_before_unix_local_resolution( + tmp_path: Path, +) -> None: + from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxSession, + UnixLocalSandboxSessionState, + ) + from agents.sandbox.snapshot import NoopSnapshot + + workspace = tmp_path / "workspace" + workspace.mkdir() + session = UnixLocalSandboxSession( + state=UnixLocalSandboxSessionState( + manifest=Manifest(root=str(workspace)), + snapshot=NoopSnapshot(id="noop"), + ) + ) + + command = _resolve_workdir_command( + session=session, + command="pwd", + workdir=r"src\project", + ) + + assert command == f"cd {workspace.as_posix()}/src/project && pwd" + + +@pytest.mark.asyncio +async def test_view_image_normalizes_backslashes_as_sandbox_separators() -> None: + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(_PNG_BYTES)}], + manifest=Manifest(root="/workspace"), + ) + tool = ViewImageTool(session=session) + + output = await tool.run(ViewImageArgs(path=r"images\plot.png")) + + assert isinstance(output, ToolOutputImage) + assert session.calls[0].args[0].as_posix() == "/workspace/images/plot.png" + session.assert_complete() From 7ab35c35167f7c2bb90b3230e183c57d5d5155b1 Mon Sep 17 00:00:00 2001 From: GGbond <2256433591@qq.com> Date: Sun, 16 Aug 2026 06:58:08 +0800 Subject: [PATCH 328/473] fix(core): close all MultiProvider children after failures (#4438) --- src/agents/models/multi_provider.py | 13 ++++- tests/models/test_map.py | 73 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/agents/models/multi_provider.py b/src/agents/models/multi_provider.py index 41cbeef69f..9ec55c6dfa 100644 --- a/src/agents/models/multi_provider.py +++ b/src/agents/models/multi_provider.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import Any, Literal, cast from openai import AsyncOpenAI @@ -258,6 +259,7 @@ async def aclose(self) -> None: providers.extend(self._fallback_providers.values()) seen: set[int] = set() + first_error: Exception | None = None for provider in providers: if provider is self: continue @@ -265,4 +267,13 @@ async def aclose(self) -> None: if provider_id in seen: continue seen.add(provider_id) - await provider.aclose() + try: + await provider.aclose() + except asyncio.CancelledError: + raise + except Exception as error: + if first_error is None: + first_error = error + + if first_error is not None: + raise first_error diff --git a/tests/models/test_map.py b/tests/models/test_map.py index 1ac2daf09b..33ee9b2437 100644 --- a/tests/models/test_map.py +++ b/tests/models/test_map.py @@ -1,3 +1,4 @@ +import asyncio from typing import Any, cast import pytest @@ -228,3 +229,75 @@ def test_multi_provider_rejects_invalid_prefix_modes(): with pytest.raises(UserError, match="unknown_prefix_mode"): MultiProvider(unknown_prefix_mode=bad_unknown_prefix_mode) + + +@pytest.mark.asyncio +async def test_multi_provider_aclose_continues_and_preserves_first_failure(): + close_error = RuntimeError("close failed") + later_error = ValueError("later close failed") + + class CloseTrackingProvider: + def __init__(self, error: Exception | None = None): + self.error = error + self.closed = False + + def get_model(self, model_name: str | None): + return object() + + async def aclose(self) -> None: + self.closed = True + if self.error is not None: + raise self.error + + failing_provider = CloseTrackingProvider(close_error) + later_provider = CloseTrackingProvider(later_error) + provider_map = MultiProviderMap() + provider_map.add_provider("failing", cast(Any, failing_provider)) + provider_map.add_provider("later", cast(Any, later_provider)) + provider = MultiProvider(provider_map=provider_map, openai_api_key="test") + + with pytest.raises(RuntimeError) as exc_info: + await provider.aclose() + + assert exc_info.value is close_error + assert failing_provider.closed + assert later_provider.closed + + +@pytest.mark.asyncio +async def test_multi_provider_aclose_propagates_hybrid_cancellation(): + closed: list[str] = [] + + class HybridCancellation(asyncio.CancelledError, Exception): + pass + + class CloseTrackingProvider: + def __init__(self, name: str, error: BaseException | None = None): + self.name = name + self.error = error + + def get_model(self, model_name: str | None): + return object() + + async def aclose(self) -> None: + closed.append(self.name) + if self.error is not None: + raise self.error + + cancellation = HybridCancellation("cancelled") + provider_map = MultiProviderMap() + provider_map.add_provider( + "failing", + cast(Any, CloseTrackingProvider("failing", RuntimeError("close failed"))), + ) + provider_map.add_provider( + "cancelling", cast(Any, CloseTrackingProvider("cancelling", cancellation)) + ) + provider_map.add_provider("later", cast(Any, CloseTrackingProvider("later"))) + provider = MultiProvider(provider_map=provider_map, openai_api_key="test") + + with pytest.raises(HybridCancellation) as exc_info: + await provider.aclose() + + assert exc_info.value is cancellation + assert closed == ["failing", "cancelling"] From 1c3b72019e547fe1cf1530419dc6fc687cc4df39 Mon Sep 17 00:00:00 2001 From: cerebrixos <248534569+cerebrixos@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:59:36 -0400 Subject: [PATCH 329/473] docs: list Tuning Engines tracing integration (#4440) --- docs/tracing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tracing.md b/docs/tracing.md index fbddad2eb9..d73a644209 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -231,3 +231,4 @@ The following community and vendor integrations support the tracing API surface - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) - [Latitude](https://docs.latitude.so/telemetry/frameworks/openai-agents) - [DProvenanceKit](https://dprovenance.dev/openai-agents/) +- [Tuning Engines](https://github.com/cerebrixos-org/tuning-engines-cli/tree/main/packages/tuning-agents#openai-agents-sdk) From 583fede12e8a0b29a9900c27755f781d9038d067 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 08:12:30 +0900 Subject: [PATCH 330/473] fix(sessions): include compaction usage in run totals (#4446) Co-authored-by: Arthi Arumugam --- .../openai_responses_compaction_session.py | 19 ++++++++++++-- .../run_internal/session_persistence.py | 3 ++- ...est_openai_responses_compaction_session.py | 25 ++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index 1e8456bb46..11cc8bb682 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -11,6 +11,7 @@ from ..logger import log_model_and_tool_action_warning from ..models._openai_shared import get_default_openai_client from ..run_internal.items import normalize_input_items_for_api +from ..usage import _response_usage_to_usage from .openai_conversations_session import OpenAIConversationsSession from .session import ( OpenAIResponsesCompactionArgs, @@ -19,6 +20,7 @@ ) if TYPE_CHECKING: + from ..run_context import RunContextWrapper from .session import Session logger = logging.getLogger("openai-agents.openai.compaction") @@ -165,8 +167,17 @@ def _resolve_compaction_mode_for_response( return "input" return _resolve_compaction_mode(mode, response_id=response_id, store=store) - async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None) -> None: - """Run compaction using responses.compact API.""" + async def run_compaction( + self, + args: OpenAIResponsesCompactionArgs | None = None, + *, + wrapper: RunContextWrapper[Any] | None = None, + ) -> None: + """Run compaction using responses.compact API. + + When a run context is provided, the billed compaction request contributes to + that run's usage totals. + """ if args and args.get("response_id"): self._response_id = args["response_id"] requested_mode = args.get("compaction_mode") if args else None @@ -226,6 +237,10 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None compacted = await self.client.responses.compact(**compact_kwargs) + compacted_usage = getattr(compacted, "usage", None) + if wrapper is not None and compacted_usage is not None: + wrapper.usage.add(_response_usage_to_usage(compacted_usage)) + output_items = _strip_orphaned_assistant_ids( _normalize_compaction_output_items(compacted.output or []) ) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 44c39d5cef..8ebba55802 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -565,6 +565,7 @@ async def save_result_to_session( if session is None: return 0 + compaction_wrapper = wrapper wrapper = _get_session_wrapper(session, wrapper) new_run_items: list[RunItem] @@ -691,7 +692,7 @@ async def save_result_to_session( await _call_session_method( session.run_compaction, compaction_args, - wrapper=wrapper, + wrapper=compaction_wrapper, ) return saved_run_items_count diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index 7731c27778..5519228ea6 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -8,6 +8,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from openai.types.responses.response_usage import ( + InputTokensDetails, + OutputTokensDetails, + ResponseUsage, +) import agents._debug as _debug from agents import Agent, Runner @@ -1452,6 +1457,16 @@ async def test_compaction_runs_during_runner_flow(self) -> None: underlying = SimpleListSession() compacted = SimpleNamespace( output=[{"type": "compaction", "encrypted_content": "enc"}], + usage=ResponseUsage( + input_tokens=150_000, + input_tokens_details=InputTokensDetails( + cached_tokens=50_000, + cache_write_tokens=0, + ), + output_tokens=42_000, + output_tokens_details=OutputTokensDetails(reasoning_tokens=10_000), + total_tokens=192_000, + ), ) mock_client = MagicMock() mock_client.responses.compact = AsyncMock(return_value=compacted) @@ -1466,9 +1481,17 @@ async def test_compaction_runs_during_runner_flow(self) -> None: model = ScriptedModel(steps=[[get_text_message("ok")]]) agent = Agent(name="assistant", model=model) - await Runner.run(agent, "hello", session=session) + result = await Runner.run(agent, "hello", session=session) mock_client.responses.compact.assert_awaited_once() + assert result.context_wrapper.usage.requests == 2 + assert result.context_wrapper.usage.input_tokens == 150_000 + assert result.context_wrapper.usage.output_tokens == 42_000 + assert result.context_wrapper.usage.total_tokens == 192_000 + assert result.context_wrapper.usage.input_tokens_details.cached_tokens == 50_000 + assert result.context_wrapper.usage.output_tokens_details.reasoning_tokens == 10_000 + assert len(result.context_wrapper.usage.request_usage_entries) == 1 + assert result.context_wrapper.usage.request_usage_entries[0].total_tokens == 192_000 items = await session.get_items() assert any(isinstance(item, dict) and item.get("type") == "compaction" for item in items) From 60482a3d6b61a7399ae82d95f7cbced4f8af4ff9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 08:14:15 +0900 Subject: [PATCH 331/473] fix(chat-completions): improve Chat Completions reasoning replay (#4432) --- src/agents/extensions/models/any_llm_model.py | 18 +- src/agents/models/chatcmpl_converter.py | 61 +++- src/agents/models/chatcmpl_stream_handler.py | 33 +- src/agents/models/reasoning_content_replay.py | 2 + tests/models/test_any_llm_model.py | 95 ++++++ .../test_openai_chatcompletions_stream.py | 119 +++++++ tests/models/test_reasoning_content.py | 308 ++++++++++++++++++ 7 files changed, 616 insertions(+), 20 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 95489fc0ad..b989d3c127 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -79,9 +79,10 @@ class InternalChatCompletionMessage(ChatCompletionMessage): - """Internal wrapper used to carry normalized reasoning content.""" + """Internal wrapper used to carry normalized reasoning fields.""" reasoning_content: str = "" + reasoning: str = "" def _usage_payload(response: Any) -> Any | None: @@ -200,7 +201,7 @@ def _flatten_any_llm_reasoning_value(value: Any) -> str: def _extract_any_llm_reasoning_text(value: Any) -> str: direct_reasoning_content = getattr(value, "reasoning_content", None) - if isinstance(direct_reasoning_content, str): + if isinstance(direct_reasoning_content, str) and direct_reasoning_content: return direct_reasoning_content reasoning = getattr(value, "reasoning", None) @@ -208,7 +209,7 @@ def _extract_any_llm_reasoning_text(value: Any) -> str: reasoning = value.get("reasoning") if reasoning is None: direct_reasoning_content = value.get("reasoning_content") - if isinstance(direct_reasoning_content, str): + if isinstance(direct_reasoning_content, str) and direct_reasoning_content: return direct_reasoning_content if reasoning is None: @@ -232,6 +233,11 @@ def _normalize_any_llm_message(message: ChatCompletionMessage) -> ChatCompletion _convert_any_llm_tool_call_to_openai(tool_call) for tool_call in message.tool_calls ] + reasoning_content = getattr(message, "reasoning_content", "") + if not isinstance(reasoning_content, str): + reasoning_content = "" + reasoning = "" if reasoning_content else _extract_any_llm_reasoning_text(message) + return InternalChatCompletionMessage( content=message.content, refusal=message.refusal, @@ -239,7 +245,8 @@ def _normalize_any_llm_message(message: ChatCompletionMessage) -> ChatCompletion annotations=message.annotations, audio=message.audio, tool_calls=tool_calls, - reasoning_content=_extract_any_llm_reasoning_text(message), + reasoning_content=reasoning_content, + reasoning=reasoning, ) @@ -1527,7 +1534,7 @@ def _fix_tool_message_ordering( if isinstance(tool_call, dict) and tool_call.get("id"): # Create a separate assistant message for each tool call. # Only the first split keeps the assistant text/thinking - # blocks/reasoning content; the rest carry tool_calls only, + # blocks/reasoning fields; the rest carry tool_calls only, # to avoid duplicating signed thinking blocks (which # Anthropic rejects) and assistant text in history. single_tool_msg = message_dict.copy() @@ -1537,6 +1544,7 @@ def _fix_tool_message_ordering( "content", "thinking_blocks", "reasoning_content", + "reasoning", ): single_tool_msg.pop(shared_field, None) tool_call_messages[str(tool_call["id"])] = ( diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index bd6832c9a4..68569578c8 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -62,6 +62,7 @@ from .chatcmpl_helpers import ChatCmplHelpers from .fake_id import FAKE_RESPONSES_ID from .reasoning_content_replay import ( + _CHAT_COMPLETIONS_REASONING_FIELD_KEY, ReasoningContentReplayContext, ReasoningContentSource, ShouldReplayReasoningContent, @@ -138,8 +139,9 @@ def message_to_output_items( # Check if message is agents.extensions.models.litellm_model.InternalChatCompletionMessage. # We can't actually import it here because litellm is an optional dependency. - # So we use hasattr to check for reasoning_content and thinking_blocks. + # So we use hasattr to check for provider-specific reasoning fields. reasoning_content = getattr(message, "reasoning_content", "") + raw_reasoning = getattr(message, "reasoning", "") raw_thinking_blocks = getattr(message, "thinking_blocks", None) thinking_blocks = ( [deepcopy(block) for block in raw_thinking_blocks if isinstance(block, dict)] @@ -147,7 +149,18 @@ def message_to_output_items( else [] ) - if reasoning_content or thinking_blocks: + # Prefer the existing structured/provider-native representations when a provider + # includes more than one reasoning field on the same message. + reasoning = ( + raw_reasoning + if isinstance(raw_reasoning, str) + and raw_reasoning + and not reasoning_content + and not thinking_blocks + else "" + ) + + if reasoning_content or reasoning or thinking_blocks: reasoning_kwargs: dict[str, Any] = { "id": FAKE_RESPONSES_ID, "summary": ( @@ -157,8 +170,12 @@ def message_to_output_items( ), "type": "reasoning", } + if reasoning: + reasoning_kwargs["content"] = [Content(text=reasoning, type="reasoning_text")] reasoning_provider_data = dict(provider_data or {}) + if reasoning: + reasoning_provider_data[_CHAT_COMPLETIONS_REASONING_FIELD_KEY] = "reasoning" if thinking_blocks: # The normalized reasoning fields below cannot represent empty thinking text or # redacted_thinking blocks. Keep the complete provider sequence as the replay @@ -574,27 +591,33 @@ def items_to_messages( pending_thinking_blocks: list[dict[str, Any]] | None = None pending_thinking_blocks_are_native = False pending_reasoning_content: str | None = None # For DeepSeek reasoning_content + pending_reasoning: str | None = None normalized_base_url = base_url.rstrip("/") if base_url is not None else None - def flush_assistant_message(*, clear_pending_reasoning: bool = True) -> None: - nonlocal current_assistant_msg, pending_reasoning_content + def clear_pending_reasoning_state() -> None: + nonlocal pending_reasoning, pending_reasoning_content nonlocal pending_thinking_blocks, pending_thinking_blocks_are_native + pending_reasoning = None + pending_reasoning_content = None + pending_thinking_blocks = None + pending_thinking_blocks_are_native = False + + def flush_assistant_message(*, clear_pending_reasoning: bool = True) -> None: + nonlocal current_assistant_msg, pending_reasoning, pending_reasoning_content if current_assistant_msg is not None: # The API doesn't support empty arrays for tool_calls if not current_assistant_msg.get("tool_calls"): del current_assistant_msg["tool_calls"] # prevents stale reasoning_content from contaminating later turns pending_reasoning_content = None + pending_reasoning = None result.append(current_assistant_msg) current_assistant_msg = None - elif clear_pending_reasoning: - pending_reasoning_content = None if clear_pending_reasoning: # Thinking blocks belong to the assistant turn that produced them, so a # reasoning item that is not directly followed by that turn's assistant # message must not leak its signed blocks into a later one. - pending_thinking_blocks = None - pending_thinking_blocks_are_native = False + clear_pending_reasoning_state() def apply_pending_thinking_blocks( assistant_msg: ChatCompletionAssistantMessageParam, @@ -629,10 +652,13 @@ def apply_pending_thinking_blocks( def apply_pending_reasoning_content( assistant_msg: ChatCompletionAssistantMessageParam, ) -> None: - nonlocal pending_reasoning_content + nonlocal pending_reasoning, pending_reasoning_content if pending_reasoning_content: assistant_msg["reasoning_content"] = pending_reasoning_content # type: ignore[typeddict-unknown-key] pending_reasoning_content = None + if pending_reasoning: + assistant_msg["reasoning"] = pending_reasoning # type: ignore[typeddict-unknown-key] + pending_reasoning = None def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: nonlocal current_assistant_msg, pending_thinking_blocks @@ -836,12 +862,14 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: # 7) reasoning message => extract thinking blocks if present elif reasoning_item := cls.maybe_reasoning_message(item): + clear_pending_reasoning_state() # Reconstruct thinking blocks from content (text) and encrypted_content (signature) content_items = reasoning_item.get("content", []) encrypted_content = reasoning_item.get("encrypted_content") item_provider_data: dict[str, Any] = reasoning_item.get("provider_data", {}) # type: ignore[assignment] item_model = item_provider_data.get("model", "") + reasoning_field = item_provider_data.get(_CHAT_COMPLETIONS_REASONING_FIELD_KEY) origin_provider_data = { key: value for key, value in item_provider_data.items() @@ -849,6 +877,18 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: } should_replay = False + if reasoning_field == "reasoning" and model == item_model: + reasoning_texts = [] + for content_item in content_items: + if ( + isinstance(content_item, dict) + and content_item.get("type") == "reasoning_text" + and content_item.get("text") + ): + reasoning_texts.append(content_item["text"]) + if reasoning_texts: + pending_reasoning = "\n".join(reasoning_texts) + if ( model and ("claude" in model.lower() or "anthropic" in model.lower()) @@ -864,9 +904,10 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: and complete_thinking_blocks and all(isinstance(block, dict) for block in complete_thinking_blocks) ): + pending_reasoning = None pending_thinking_blocks = deepcopy(complete_thinking_blocks) pending_thinking_blocks_are_native = True - elif content_items: + elif content_items and reasoning_field != "reasoning": signatures = encrypted_content.split("\n") if encrypted_content else [] reconstructed_thinking_blocks: list[dict[str, Any]] = [] diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index decdf78f6d..51dcfe8251 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -60,6 +60,7 @@ ) from .chatcmpl_helpers import ChatCmplHelpers from .fake_id import FAKE_RESPONSES_ID +from .reasoning_content_replay import _CHAT_COMPLETIONS_REASONING_FIELD_KEY # Define a Part class for internal use @@ -556,6 +557,20 @@ def _finalize_thinking_blocks(state: StreamingState) -> None: if last_signature: reasoning_item.encrypted_content = last_signature + @staticmethod + def _discard_plaintext_reasoning_replay_marker( + reasoning_item: ResponseReasoningItem, + ) -> None: + provider_data = getattr(reasoning_item, "provider_data", None) + if not isinstance(provider_data, dict): + return + if provider_data.get(_CHAT_COMPLETIONS_REASONING_FIELD_KEY) != "reasoning": + return + + reasoning_provider_data = provider_data.copy() + del reasoning_provider_data[_CHAT_COMPLETIONS_REASONING_FIELD_KEY] + reasoning_item.provider_data = reasoning_provider_data # type: ignore[attr-defined] + @classmethod def _finish_reasoning_item( cls, @@ -682,8 +697,8 @@ async def handle_stream( raise AgentsException("Audio is not currently supported") # Handle thinking blocks from Anthropic (for preserving signatures) + has_thinking_block = False if hasattr(delta, "thinking_blocks") and delta.thinking_blocks: - has_thinking_block = False for block in delta.thinking_blocks: if isinstance(block, dict): has_thinking_block |= state.accumulate_thinking_block(block) @@ -703,10 +718,14 @@ async def handle_stream( type="response.output_item.added", sequence_number=sequence_number.get_and_increment(), ) + if has_thinking_block and state.reasoning_content_index_and_output: + cls._discard_plaintext_reasoning_replay_marker( + state.reasoning_content_index_and_output[1] + ) # Handle reasoning content for reasoning summaries - if hasattr(delta, "reasoning_content"): - reasoning_content = delta.reasoning_content + reasoning_content = getattr(delta, "reasoning_content", None) + if reasoning_content is not None: if reasoning_content and not state.reasoning_content_index_and_output: reasoning_item = ResponseReasoningItem( id=FAKE_RESPONSES_ID, @@ -725,6 +744,7 @@ async def handle_stream( if reasoning_content and state.reasoning_content_index_and_output: reasoning_item = state.reasoning_content_index_and_output[1] + cls._discard_plaintext_reasoning_replay_marker(reasoning_item) if state.active_reasoning_summary_index is None: summary_index = len(reasoning_item.summary) reasoning_item.summary.append(Summary(text="", type="summary_text")) @@ -758,6 +778,8 @@ async def handle_stream( # Handle reasoning content from 3rd party platforms if hasattr(delta, "reasoning"): reasoning_text = delta.reasoning + if not isinstance(reasoning_text, str): + reasoning_text = "" if reasoning_text and not state.reasoning_content_index_and_output: reasoning_item = ResponseReasoningItem( id=FAKE_RESPONSES_ID, @@ -765,8 +787,9 @@ async def handle_stream( content=[Content(text="", type="reasoning_text")], type="reasoning", ) - if state.provider_data: - reasoning_item.provider_data = state.provider_data.copy() # type: ignore[attr-defined] + reasoning_provider_data = state.provider_data.copy() + reasoning_provider_data[_CHAT_COMPLETIONS_REASONING_FIELD_KEY] = "reasoning" + reasoning_item.provider_data = reasoning_provider_data # type: ignore[attr-defined] state.reasoning_content_index_and_output = (0, reasoning_item) yield ResponseOutputItemAddedEvent( item=reasoning_item, diff --git a/src/agents/models/reasoning_content_replay.py b/src/agents/models/reasoning_content_replay.py index 42335058e4..71a966f595 100644 --- a/src/agents/models/reasoning_content_replay.py +++ b/src/agents/models/reasoning_content_replay.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from typing import Any +_CHAT_COMPLETIONS_REASONING_FIELD_KEY = "_chat_completions_reasoning_field" + @dataclass class ReasoningContentSource: diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 645905447f..048d0f585c 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -637,6 +637,58 @@ async def test_any_llm_chat_path_normalizes_non_stream_payloads( assert response.output[0].content[0].text == "Hello" +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_path_preserves_plaintext_reasoning_for_replay(monkeypatch) -> None: + chat_response = _chat_completion("The answer is 42.") + chat_response.choices[0].message = ChatCompletionMessage.model_validate( + { + "role": "assistant", + "content": "The answer is 42.", + "reasoning_content": "", + "reasoning": "I should calculate this carefully.", + } + ) + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=chat_response) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model="openrouter/reasoning-model") + + model_settings = ModelSettings(reasoning=Reasoning(effort="high")) + response = await model.get_response( + system_instructions=None, + input="What is six times seven?", + model_settings=model_settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + await model.get_response( + system_instructions=None, + input=response.to_input_items(), + model_settings=model_settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + replayed_messages = provider.chat_calls[1]["messages"] + assert replayed_messages == [ + { + "role": "assistant", + "content": "The answer is 42.", + "reasoning": "I should calculate this carefully.", + } + ] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_any_llm_chat_path_preserves_gemini_tool_call_metadata(monkeypatch) -> None: @@ -1396,6 +1448,46 @@ def test_any_llm_reasoning_objects_prefer_content_attributes_over_iterable_pairs assert _extract_any_llm_reasoning_text(delta) == "用户" +def test_any_llm_stream_flattens_reasoning_object_when_reasoning_content_is_empty( + monkeypatch, +) -> None: + provider = FakeAnyLLMProvider(supports_responses=False) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + delta = ChoiceDelta.model_construct( + reasoning_content="", + reasoning=pytypes.SimpleNamespace(content="Plaintext reasoning"), + ) + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ChunkChoice(index=0, delta=delta)], + ) + + normalized = module.AnyLLMModel(model="openrouter/reasoning-model")._normalize_chat_chunk(chunk) + + assert normalized.choices[0].delta.reasoning == "Plaintext reasoning" + + +def test_any_llm_nonstream_preserves_native_reasoning_content_field(monkeypatch) -> None: + provider = FakeAnyLLMProvider(supports_responses=False) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + message = ChatCompletionMessage.model_validate( + { + "role": "assistant", + "content": "Answer", + "reasoning": "Plaintext reasoning", + "reasoning_content": "Native reasoning content", + } + ) + + normalized = module._normalize_any_llm_message(message) + + assert normalized.reasoning_content == "Native reasoning content" + assert normalized.reasoning == "" + + def test_any_llm_split_does_not_duplicate_content_or_thinking(monkeypatch) -> None: """Splitting multi-tool assistant messages must not duplicate text/thinking blocks. @@ -1416,6 +1508,7 @@ def test_any_llm_split_does_not_duplicate_content_or_thinking(monkeypatch) -> No "content": "Looking up both queries.", "thinking_blocks": [{"type": "thinking", "thinking": "plan", "signature": "sig_abc"}], "reasoning_content": "internal plan", + "reasoning": "plaintext plan", "tool_calls": [ { "id": "call_1", @@ -1441,10 +1534,12 @@ def test_any_llm_split_does_not_duplicate_content_or_thinking(monkeypatch) -> No assert assistants[0].get("content") == "Looking up both queries." assert "thinking_blocks" in assistants[0] assert "reasoning_content" in assistants[0] + assert "reasoning" in assistants[0] # Second split must NOT duplicate them. assert "content" not in assistants[1] assert "thinking_blocks" not in assistants[1] assert "reasoning_content" not in assistants[1] + assert "reasoning" not in assistants[1] # Tool calls are still split one-per-message. assert assistants[0]["tool_calls"][0]["id"] == "call_1" assert assistants[1]["tool_calls"][0]["id"] == "call_2" diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index af95339905..1cced004e6 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -911,6 +911,13 @@ async def test_stream_handler_converts_third_party_reasoning_text() -> None: object="chat.completion.chunk", choices=[Choice(index=0, delta=reasoning_delta2)], ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], + ), ] events = await _collect_handler_events(*chunks, model="third-party") @@ -937,10 +944,122 @@ async def test_stream_handler_converts_third_party_reasoning_text() -> None: assert completed_reasoning_item.content assert cast(Any, completed_reasoning_item.content[0]).text == "think hard" assert completed_reasoning_item.model_dump().get("provider_data") == { + "_chat_completions_reasoning_field": "reasoning", "model": "third-party", "response_id": "chunk-id", } + replayed_messages = Converter.items_to_messages( + [item.model_dump() for item in completed_event.response.output], + model="third-party", + ) + assert len(replayed_messages) == 1 + assert replayed_messages[0]["reasoning"] == "think hard" # type: ignore[typeddict-item] + + +@pytest.mark.asyncio +async def test_stream_handler_ignores_non_string_plaintext_reasoning() -> None: + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta.model_construct(reasoning={"bad": 1}))], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], + ), + ] + + events = await _collect_handler_events(*chunks, model="third-party") + + assert not any( + event.type in {"response.reasoning_text.delta", "response.reasoning_text.done"} + for event in events + ) + completed_event = next(event for event in events if event.type == "response.completed") + assert len(completed_event.response.output) == 1 + assert isinstance(completed_event.response.output[0], ResponseOutputMessage) + assert completed_event.response.output[0].content[0].text == "answer" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("split_across_chunks", [False, True]) +async def test_stream_handler_prefers_reasoning_content_over_plaintext_reasoning( + split_across_chunks: bool, +) -> None: + if split_across_chunks: + reasoning_deltas = [ + ChoiceDelta.model_construct(reasoning="raw"), + ChoiceDelta.model_construct(reasoning_content="summary"), + ] + else: + reasoning_deltas = [ + ChoiceDelta.model_construct(reasoning="raw", reasoning_content="summary") + ] + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=delta)], + ) + for delta in [*reasoning_deltas, ChoiceDelta(content="answer")] + ] + + events = await _collect_handler_events(*chunks, model="deepseek-r1") + completed_event = next(event for event in events if event.type == "response.completed") + reasoning_item = completed_event.response.output[0] + assert isinstance(reasoning_item, ResponseReasoningItem) + assert reasoning_item.summary[0].text == "summary" + assert reasoning_item.content + assert [part.text for part in reasoning_item.content] == ["raw"] + assert "_chat_completions_reasoning_field" not in cast(Any, reasoning_item).provider_data + + replayed_messages = Converter.items_to_messages( + [item.model_dump() for item in completed_event.response.output], + model="deepseek-r1", + ) + assert len(replayed_messages) == 1 + assert replayed_messages[0]["reasoning_content"] == "summary" # type: ignore[typeddict-item] + assert "reasoning" not in replayed_messages[0] + + +@pytest.mark.asyncio +async def test_stream_handler_prefers_later_thinking_blocks_over_plaintext_reasoning() -> None: + chunks = [ + _thinking_chunk(reasoning="raw"), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "hidden", "signature": "sig"}] + ), + _thinking_chunk(content="answer"), + ] + + events = await _collect_handler_events(*chunks, model="anthropic/claude-4-opus") + completed_event = next(event for event in events if event.type == "response.completed") + reasoning_item = completed_event.response.output[0] + assert isinstance(reasoning_item, ResponseReasoningItem) + assert reasoning_item.content + assert [part.text for part in reasoning_item.content] == ["raw", "hidden"] + assert "_chat_completions_reasoning_field" not in cast(Any, reasoning_item).provider_data + + replayed_messages = Converter.items_to_messages( + [item.model_dump() for item in completed_event.response.output], + model="anthropic/claude-4-opus", + preserve_thinking_blocks=True, + ) + assert len(replayed_messages) == 1 + assert replayed_messages[0]["thinking_blocks"] == [ # type: ignore[typeddict-item] + {"type": "thinking", "thinking": "hidden", "signature": "sig"} + ] + assert "reasoning" not in replayed_messages[0] + @pytest.mark.asyncio async def test_stream_handler_preserves_thinking_blocks_with_reasoning_summary() -> None: diff --git a/tests/models/test_reasoning_content.py b/tests/models/test_reasoning_content.py index dd11824a48..fc86dafd12 100644 --- a/tests/models/test_reasoning_content.py +++ b/tests/models/test_reasoning_content.py @@ -19,11 +19,319 @@ ) from agents.model_settings import ModelSettings +from agents.models.chatcmpl_converter import Converter from agents.models.interface import ModelTracing from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel from agents.models.openai_provider import OpenAIProvider +def test_plaintext_reasoning_round_trips_on_its_assistant_message() -> None: + message = ChatCompletionMessage.model_validate( + { + "role": "assistant", + "content": "The answer is 42.", + "reasoning": "I should calculate this carefully.", + } + ) + + items = Converter.message_to_output_items( + message, + provider_data={"model": "openrouter/reasoning-model", "response_id": "chatcmpl-test"}, + ) + messages = Converter.items_to_messages( + [item.model_dump() for item in items], model="openrouter/reasoning-model" + ) + + assert len(items) == 2 + assert isinstance(items[0], ResponseReasoningItem) + assert items[0].content + assert items[0].content[0].text == "I should calculate this carefully." + assert messages == [ + { + "role": "assistant", + "content": "The answer is 42.", + "reasoning": "I should calculate this carefully.", + } + ] + + +def test_plaintext_reasoning_round_trips_with_a_tool_call() -> None: + message = ChatCompletionMessage.model_validate( + { + "role": "assistant", + "content": None, + "reasoning": "I should call the weather tool.", + "tool_calls": [ + { + "id": "call-weather", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":"Tokyo"}'}, + } + ], + } + ) + + items = Converter.message_to_output_items( + message, + provider_data={"model": "openrouter/reasoning-model"}, + ) + messages = Converter.items_to_messages( + [item.model_dump() for item in items], model="openrouter/reasoning-model" + ) + + assert len(messages) == 1 + assistant = messages[0] + assert assistant["role"] == "assistant" + assert assistant["content"] is None + assert assistant["reasoning"] == "I should call the weather tool." # type: ignore[typeddict-item] + assert len(assistant["tool_calls"]) == 1 # type: ignore[typeddict-item] + + +def test_plaintext_reasoning_is_not_replayed_to_a_different_model() -> None: + message = ChatCompletionMessage.model_validate( + {"role": "assistant", "content": "Answer", "reasoning": "Private reasoning"} + ) + items = Converter.message_to_output_items( + message, + provider_data={"model": "openrouter/model-a"}, + ) + + messages = Converter.items_to_messages( + [item.model_dump() for item in items], model="openrouter/model-b" + ) + + assert len(messages) == 1 + assert "reasoning" not in messages[0] + + +@pytest.mark.parametrize( + "intervening_reasoning", + [ + { + "id": "__fake_id__", + "summary": [], + "type": "reasoning", + "content": [{"text": "Foreign reasoning", "type": "reasoning_text"}], + "provider_data": { + "model": "openrouter/model-b", + "_chat_completions_reasoning_field": "reasoning", + }, + }, + { + "id": "__fake_id__", + "summary": [{"text": "Higher-fidelity reasoning", "type": "summary_text"}], + "type": "reasoning", + "content": None, + "provider_data": {"model": "openrouter/model-a"}, + }, + ], +) +def test_intervening_reasoning_item_clears_pending_plaintext_reasoning( + intervening_reasoning: dict[str, Any], +) -> None: + items: list[Any] = [ + { + "id": "__fake_id__", + "summary": [], + "type": "reasoning", + "content": [{"text": "Stale reasoning", "type": "reasoning_text"}], + "provider_data": { + "model": "openrouter/model-a", + "_chat_completions_reasoning_field": "reasoning", + }, + }, + intervening_reasoning, + { + "id": "__fake_id__", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Answer", + "annotations": [], + "logprobs": [], + } + ], + }, + ] + + messages = Converter.items_to_messages(items, model="openrouter/model-a") + + assert len(messages) == 1 + assert "reasoning" not in messages[0] + + +def test_plaintext_reasoning_item_clears_pending_native_reasoning_content() -> None: + items: list[Any] = [ + { + "id": "__fake_id__", + "summary": [{"text": "Stale native reasoning", "type": "summary_text"}], + "type": "reasoning", + "content": None, + "provider_data": {"model": "deepseek-r1"}, + }, + { + "id": "__fake_id__", + "summary": [], + "type": "reasoning", + "content": [{"text": "Fresh plaintext reasoning", "type": "reasoning_text"}], + "provider_data": { + "model": "deepseek-r1", + "_chat_completions_reasoning_field": "reasoning", + }, + }, + { + "id": "__fake_id__", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Answer", + "annotations": [], + "logprobs": [], + } + ], + }, + ] + + messages = Converter.items_to_messages(items, model="deepseek-r1") + + assert len(messages) == 1 + assert messages[0]["reasoning"] == "Fresh plaintext reasoning" # type: ignore[typeddict-item] + assert "reasoning_content" not in messages[0] + + +def test_native_thinking_blocks_take_precedence_over_marked_plaintext_reasoning() -> None: + items: list[Any] = [ + { + "id": "__fake_id__", + "summary": [], + "type": "reasoning", + "content": [{"text": "Plaintext reasoning", "type": "reasoning_text"}], + "provider_data": { + "model": "anthropic/claude-x", + "_chat_completions_reasoning_field": "reasoning", + "thinking_blocks": [ + {"type": "thinking", "thinking": "Native thinking", "signature": "sig"} + ], + }, + }, + { + "id": "__fake_id__", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Answer", + "annotations": [], + "logprobs": [], + } + ], + }, + ] + + messages = Converter.items_to_messages( + items, + model="anthropic/claude-x", + preserve_thinking_blocks=True, + ) + + assert len(messages) == 1 + assert messages[0]["thinking_blocks"] == [ # type: ignore[typeddict-item] + {"type": "thinking", "thinking": "Native thinking", "signature": "sig"} + ] + assert "reasoning" not in messages[0] + + +@pytest.mark.parametrize("native_thinking_blocks", [False, True]) +def test_plaintext_reasoning_item_clears_pending_thinking_blocks( + native_thinking_blocks: bool, +) -> None: + provider_data: dict[str, Any] = {"model": "anthropic/claude-x"} + if native_thinking_blocks: + provider_data["thinking_blocks"] = [ + {"type": "thinking", "thinking": "Stale thinking", "signature": "sig"} + ] + items: list[Any] = [ + { + "id": "__fake_id__", + "summary": [], + "type": "reasoning", + "content": [{"text": "Stale thinking", "type": "reasoning_text"}], + "provider_data": provider_data, + }, + { + "id": "__fake_id__", + "summary": [], + "type": "reasoning", + "content": [{"text": "Fresh plaintext reasoning", "type": "reasoning_text"}], + "provider_data": { + "model": "anthropic/claude-x", + "_chat_completions_reasoning_field": "reasoning", + }, + }, + { + "id": "__fake_id__", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Answer", + "annotations": [], + "logprobs": [], + } + ], + }, + ] + + messages = Converter.items_to_messages( + items, + model="anthropic/claude-x", + preserve_thinking_blocks=True, + ) + + assert messages == [ + { + "role": "assistant", + "content": "Answer", + "reasoning": "Fresh plaintext reasoning", + } + ] + + +def test_reasoning_content_takes_precedence_over_plaintext_reasoning() -> None: + message = ChatCompletionMessage.model_validate( + { + "role": "assistant", + "content": "Answer", + "reasoning": "Plaintext reasoning", + "reasoning_content": "Existing reasoning content", + } + ) + + items = Converter.message_to_output_items( + message, + provider_data={"model": "deepseek-reasoner"}, + ) + messages = Converter.items_to_messages( + [item.model_dump() for item in items], model="deepseek-reasoner" + ) + + reasoning_item = cast(ResponseReasoningItem, items[0]) + assert reasoning_item.content is None + assert reasoning_item.summary[0].text == "Existing reasoning content" + assert messages[0]["reasoning_content"] == "Existing reasoning content" # type: ignore[typeddict-item] + assert "reasoning" not in messages[0] + + # Helper functions to create test objects consistently def create_content_delta(content: str) -> dict[str, Any]: """Create a delta dictionary with regular content""" From e90df3968671996fb05b7f799f94802068fe74e4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 08:16:22 +0900 Subject: [PATCH 332/473] fix: update cffi lock for Python 3.14 (#4448) --- uv.lock | 155 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 104 insertions(+), 51 deletions(-) diff --git a/uv.lock b/uv.lock index 24689a38d1..3c6e9b32f4 100644 --- a/uv.lock +++ b/uv.lock @@ -456,59 +456,112 @@ wheels = [ [[package]] name = "cffi" -version = "1.17.1" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] From 05c789fe4d6f8196b13137983f4ec25c684e1d4b Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:23:08 +0100 Subject: [PATCH 333/473] fix(sandbox): normalize apply_patch paths as POSIX (#4437) --- src/agents/sandbox/apply_patch.py | 31 +++++++--- .../capabilities/tools/apply_patch_tool.py | 23 +++++-- .../test_apply_patch_preflight.py | 62 +++++++++++++++++++ .../capabilities/test_apply_patch_tool.py | 56 +++++++++++++++++ tests/sandbox/test_apply_patch.py | 33 ++++++++++ 5 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 tests/sandbox/capabilities/test_apply_patch_preflight.py diff --git a/src/agents/sandbox/apply_patch.py b/src/agents/sandbox/apply_patch.py index 304c29eeca..7f89612993 100644 --- a/src/agents/sandbox/apply_patch.py +++ b/src/agents/sandbox/apply_patch.py @@ -120,19 +120,34 @@ async def apply_operation( path=operation.path, ) + def normalize_operation(self, operation: ApplyPatchOperation) -> ApplyPatchOperation: + """Return an operation whose paths use the workspace policy's canonical form.""" + normalized_path = self._validate_path(operation.path).as_posix() + normalized_move_to = ( + self._validate_path(operation.move_to).as_posix() + if operation.move_to is not None + else None + ) + return ApplyPatchOperation( + type=operation.type, + path=normalized_path, + diff=operation.diff, + ctx_wrapper=operation.ctx_wrapper, + move_to=normalized_move_to, + ) + def _validate_path(self, path: str | Path) -> Path: - if isinstance(path, str): - if not path.strip(): - raise ApplyPatchPathError(path=path, reason="empty") - normalized_path = Path(path) - else: - normalized_path = path + if isinstance(path, str) and not path.strip(): + raise ApplyPatchPathError(path=path, reason="empty") + # Keep raw model-provided strings intact until the sandbox path policy + # normalizes them. Converting through host-native Path first would make + # backslash handling depend on the SDK host operating system. try: - return self._session._workspace_path_policy().relative_path(normalized_path) + return self._session._workspace_path_policy().relative_path(path) except InvalidManifestPathError as exc: raise ApplyPatchPathError( - path=normalized_path, + path=path, reason="escape_root", cause=exc, ) from exc diff --git a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py index 5aa653a0ef..1bdba29bd3 100644 --- a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py +++ b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py @@ -15,6 +15,7 @@ from ....tool_context import ToolContext from ....util._approvals import evaluate_needs_approval_setting from ...apply_patch import WorkspaceEditor +from ...errors import ApplyPatchPathError from ...session.base_sandbox_session import BaseSandboxSession from ...types import User @@ -191,14 +192,22 @@ def runtime_needs_approval(self) -> CustomToolApprovalFunction: def parse_custom_input(self, raw_input: str) -> list[ApplyPatchOperation]: return _parse_custom_tool_input(raw_input) + def _normalize_operation(self, operation: ApplyPatchOperation) -> ApplyPatchOperation: + return WorkspaceEditor(self.session).normalize_operation(operation) + + def _parse_and_normalize_input(self, raw_input: str) -> list[ApplyPatchOperation]: + return [ + self._normalize_operation(operation) for operation in self.parse_custom_input(raw_input) + ] + async def _needs_custom_approval( self, ctx_wrapper: RunContextWrapper[Any], raw_input: str, call_id: str ) -> bool: try: - operations = self.parse_custom_input(raw_input) - except ValueError: - # Let malformed patches flow through normal tool execution so the model gets a - # recoverable tool error instead of aborting the whole run during approval pre-checks. + operations = self._parse_and_normalize_input(raw_input) + except (ValueError, ApplyPatchPathError): + # Let malformed patches and invalid paths flow through normal tool execution so the + # model gets a recoverable tool error instead of aborting during approval pre-checks. return False for operation in operations: @@ -214,8 +223,12 @@ async def _needs_custom_approval( return False async def _on_invoke_tool(self, ctx: ToolContext[Any], raw_input: str) -> str: + # Normalize every operation before executing any of them. This prevents a valid + # prefix from mutating the workspace when a later path is invalid. + operations = self._parse_and_normalize_input(raw_input) + operation_outputs: list[str] = [] - for operation in self.parse_custom_input(raw_input): + for operation in operations: operation.ctx_wrapper = ctx if operation.type == "create_file": result = await self.editor.create_file(operation) diff --git a/tests/sandbox/capabilities/test_apply_patch_preflight.py b/tests/sandbox/capabilities/test_apply_patch_preflight.py new file mode 100644 index 0000000000..f02f4d890d --- /dev/null +++ b/tests/sandbox/capabilities/test_apply_patch_preflight.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agents import Agent, RunHooks +from agents.items import ToolCallOutputItem +from agents.run import RunConfig +from agents.run_context import RunContextWrapper +from agents.run_internal.run_steps import ToolRunCustom +from agents.run_internal.tool_actions import CustomToolAction +from agents.sandbox.capabilities.tools import SandboxApplyPatchTool +from tests.sandbox._apply_patch_test_session import ApplyPatchSession +from tests.utils.hitl import make_context_wrapper + + +@pytest.mark.asyncio +async def test_invalid_later_path_does_not_mutate_valid_prefix() -> None: + session = ApplyPatchSession() + tool = SandboxApplyPatchTool(session=session, needs_approval=True) + raw_input = ( + "*** Begin Patch\n" + "*** Add File: safe.txt\n" + "+safe\n" + "*** Add File: ../escape.txt\n" + "+escape\n" + "*** End Patch\n" + ) + + result = await _execute_custom_tool_call( + tool, + context_wrapper=make_context_wrapper(), + raw_input=raw_input, + ) + + assert isinstance(result, ToolCallOutputItem) + assert session.files == {} + + +async def _execute_custom_tool_call( + tool: SandboxApplyPatchTool, + *, + context_wrapper: RunContextWrapper[Any], + raw_input: str, + call_id: str = "call_apply", +) -> Any: + return await CustomToolAction.execute( + agent=Agent(name="patcher", tools=[tool]), + call=ToolRunCustom( + custom_tool=tool, + tool_call={ + "type": "custom_tool_call", + "name": tool.name, + "call_id": call_id, + "input": raw_input, + }, + ), + hooks=RunHooks[Any](), + context_wrapper=context_wrapper, + config=RunConfig(), + ) diff --git a/tests/sandbox/capabilities/test_apply_patch_tool.py b/tests/sandbox/capabilities/test_apply_patch_tool.py index 82cb540c6d..5948cded24 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json from collections.abc import Awaitable from pathlib import Path from typing import Any, cast @@ -81,6 +82,61 @@ async def needs_approval( assert isinstance(result, ToolApprovalItem) + @pytest.mark.parametrize( + ("operation_payload", "expected_path", "expected_move_to"), + [ + ( + { + "type": "create_file", + "path": r"sensitive\secret.txt", + "diff": "+secret\n", + }, + "sensitive/secret.txt", + None, + ), + ( + { + "type": "update_file", + "path": "notes.txt", + "move_to": r"sensitive\secret.txt", + "diff": "@@\n-old\n+new\n", + }, + "notes.txt", + "sensitive/secret.txt", + ), + ], + ) + @pytest.mark.asyncio + async def test_needs_approval_receives_canonical_paths( + self, + operation_payload: dict[str, object], + expected_path: str, + expected_move_to: str | None, + ) -> None: + checked_paths: list[tuple[str, str | None]] = [] + + async def needs_approval( + _ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str + ) -> bool: + checked_paths.append((operation.path, operation.move_to)) + return operation.path == "sensitive/secret.txt" or operation.move_to == ( + "sensitive/secret.txt" + ) + + tool = SandboxApplyPatchTool( + session=ApplyPatchSession(), + needs_approval=needs_approval, + ) + + result = await _execute_custom_tool_call( + tool, + context_wrapper=make_context_wrapper(), + raw_input=json.dumps(operation_payload), + ) + + assert isinstance(result, ToolApprovalItem) + assert checked_paths == [(expected_path, expected_move_to)] + @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) @pytest.mark.asyncio async def test_multi_operation_checker_stops_when_approval_resolves( diff --git a/tests/sandbox/test_apply_patch.py b/tests/sandbox/test_apply_patch.py index 32fb4a629d..c4cd676fec 100644 --- a/tests/sandbox/test_apply_patch.py +++ b/tests/sandbox/test_apply_patch.py @@ -214,6 +214,39 @@ async def test_apply_patch_rejects_empty_path() -> None: ) +@pytest.mark.asyncio +async def test_apply_patch_normalizes_backslashes_in_string_path() -> None: + session = ApplyPatchSession() + + await session.apply_patch( + ApplyPatchOperation( + type="create_file", + path=r"nested\new.txt", + diff="+hello", + ) + ) + + assert session.files[Path("/workspace/nested/new.txt")] == b"hello" + + +@pytest.mark.asyncio +async def test_apply_patch_normalizes_backslashes_in_move_to() -> None: + session = ApplyPatchSession() + session.files[Path("/workspace/source.txt")] = b"alpha\n" + + await session.apply_patch( + ApplyPatchOperation( + type="update_file", + path="source.txt", + diff="@@\n-alpha\n+beta\n", + move_to=r"nested\moved.txt", + ) + ) + + assert session.files[Path("/workspace/nested/moved.txt")] == b"beta\n" + assert Path("/workspace/source.txt") not in session.files + + @pytest.mark.asyncio async def test_apply_patch_allows_absolute_path_within_root() -> None: session = ApplyPatchSession() From dde0bc99fd4e5a4e28f36be479c4b864249bc503 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 08:40:44 +0900 Subject: [PATCH 334/473] fix: honor exact call approval decisions (#4447) Co-authored-by: chiruu12 <103719146+chiruu12@users.noreply.github.com> --- src/agents/run_context.py | 36 ++-- src/agents/run_state.py | 3 +- tests/fixtures/run_state/README.md | 4 +- .../v1_16_per_call_approval_override.json | 86 ++++++++ tests/fixtures/run_state/generate_corpus.py | 80 ++++++- tests/fixtures/run_state/minimal/v1_16.json | 60 ++++++ tests/fixtures/run_state/sources.json | 16 ++ tests/test_run_context_wrapper.py | 99 +++++++++ tests/test_run_state.py | 201 ++++++++++++++++++ 9 files changed, 564 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/run_state/features/v1_16_per_call_approval_override.json create mode 100644 tests/fixtures/run_state/minimal/v1_16.json diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 19e0161022..064d540a63 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -629,17 +629,6 @@ def _get_approval_status_for_record( if approval_entry is None: return None - # Check for permanent approval/rejection - if approval_entry.approved is True and approval_entry.rejected is True: - # Approval takes precedence - return True - - if approval_entry.approved is True: - return True - - if approval_entry.rejected is True: - return False - approved_ids = ( set(approval_entry.approved) if isinstance(approval_entry.approved, list) else set() ) @@ -651,6 +640,18 @@ def _get_approval_status_for_record( return True if call_id in rejected_ids: return False + + # Exact call decisions override sticky defaults for the same approval key. + if approval_entry.approved is True and approval_entry.rejected is True: + # Approval takes precedence when sticky decisions conflict. + return True + + if approval_entry.approved is True: + return True + + if approval_entry.rejected is True: + return False + # Per-call approvals are scoped to the exact call ID, so other calls require a new decision. return None @@ -685,6 +686,8 @@ def _clear_rejection_message(record: _ApprovalRecord, call_id: str | None) -> No @staticmethod def _get_rejection_message_for_key(record: _ApprovalRecord, call_id: str) -> str | None: + if isinstance(record.approved, list) and call_id in record.approved: + return None if record.rejected is True: if call_id in record.rejection_messages: return record.rejection_messages[call_id] @@ -1011,8 +1014,15 @@ def _apply_approval_decision( opposite.remove(call_id) target = approval_entry.approved if approve else approval_entry.rejected - if isinstance(target, list) and call_id not in target: - target.append(call_id) + if target is not True: + if not isinstance(target, list): + target = [] + if approve: + approval_entry.approved = target + else: + approval_entry.rejected = target + if call_id not in target: + target.append(call_id) if approve: self._clear_rejection_message(approval_entry, call_id) elif call_id is not None: diff --git a/src/agents/run_state.py b/src/agents/run_state.py index d2246521cc..8b98fc3b36 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -179,7 +179,7 @@ def _default_run_state_validation_error( # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported # versions). -CURRENT_SCHEMA_VERSION = "1.15" +CURRENT_SCHEMA_VERSION = "1.16" _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13" _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. @@ -209,6 +209,7 @@ def _default_run_state_validation_error( "Persists canonical tool invocation identity plus sanitized mount authority and trusted " "rebind metadata, durable pending input, and resumable next-model-call state." ), + "1.16": "Lets an exact call approval decision override a sticky decision for the same tool.", } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) diff --git a/tests/fixtures/run_state/README.md b/tests/fixtures/run_state/README.md index f111591b0d..485f1f62ac 100644 --- a/tests/fixtures/run_state/README.md +++ b/tests/fixtures/run_state/README.md @@ -1,6 +1,6 @@ # RunState compatibility corpus -The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.15. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture. +The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.16. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture. Regenerate the feature corpus from the recorded historical source trees with: @@ -10,6 +10,6 @@ UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/ The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout. -Versions 1.7 and 1.8 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. Their fixtures are therefore marked `canonical_compatibility`: the recorded 1.9 writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. +Versions 1.7, 1.8, and 1.16 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison. diff --git a/tests/fixtures/run_state/features/v1_16_per_call_approval_override.json b/tests/fixtures/run_state/features/v1_16_per_call_approval_override.json new file mode 100644 index 0000000000..0d9c303f19 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_16_per_call_approval_override.json @@ -0,0 +1,86 @@ +{ + "$schemaVersion": "1.16", + "auto_previous_response_id": false, + "context": { + "approvals": { + "sensitive_tool": { + "approved": true, + "rejected": [ + "exception-call" + ], + "rejection_messages": { + "exception-call": "Denied exactly" + }, + "sticky_scope": "b6a3e5c0378ea20a4f9b146318a050924153377109c454065f977537d8d58300" + } + }, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": { + "exception-call": { + "approval_scope": "b6a3e5c0378ea20a4f9b146318a050924153377109c454065f977537d8d58300", + "completed": false, + "executed": false, + "fingerprint": "23eb3e956e31f397bdbf5fbcc53b72f004e4f61a546ba7ae501c038a0d29f203", + "type": "function_call" + }, + "sticky-call": { + "approval_scope": "b6a3e5c0378ea20a4f9b146318a050924153377109c454065f977537d8d58300", + "completed": false, + "executed": false, + "fingerprint": "23eb3e956e31f397bdbf5fbcc53b72f004e4f61a546ba7ae501c038a0d29f203", + "type": "function_call" + } + }, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "pending_input": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/generate_corpus.py b/tests/fixtures/run_state/generate_corpus.py index 5b56159348..f287a6e323 100644 --- a/tests/fixtures/run_state/generate_corpus.py +++ b/tests/fixtures/run_state/generate_corpus.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[3] OUTPUT = Path(__file__).resolve().parent / "features" +MINIMAL_OUTPUT = Path(__file__).resolve().parent / "minimal" SECURITY_OUTPUT = Path(__file__).resolve().parent / "security" RESUME_OUTPUT = Path(__file__).resolve().parent / "resume" @@ -29,6 +30,12 @@ ) """ +LEGACY_CANONICAL_COMPATIBILITY_NOTE = ( + "The release-boundary schema renumbering introduced this reader version without a writer " + "that emitted it. The recorded writer emitted 1.9; only the schema label is changed to " + "exercise the canonical compatibility branch." +) + @dataclass(frozen=True) class Scenario: @@ -38,6 +45,7 @@ class Scenario: code: str provenance: str = "historical_writer" emitted_version: str | None = None + note: str | None = None SCENARIOS = ( @@ -387,6 +395,54 @@ class Scenario: state.approve(approval) """, ), + Scenario( + "1.16", + "1c3b72019e547fe1cf1530419dc6fc687cc4df39", + "per_call_approval_override", + """ +from agents.items import ToolApprovalItem +from openai.types.responses import ResponseFunctionToolCall + +def approval(call_id): + return ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="sensitive_tool", + call_id=call_id, + status="completed", + arguments="{}", + ), + ) + +state.approve(approval("sticky-call"), always_approve=True) +state.reject(approval("exception-call"), rejection_message="Denied exactly") +""", + provenance="canonical_compatibility", + emitted_version="1.15", + note=( + "The schema transition introduced this reader version without a retained writer " + "commit that emitted it. The recorded writer emitted 1.15; only the schema label is " + "changed to exercise the canonical compatibility branch." + ), + ), +) + + +MINIMAL_SCENARIOS = ( + Scenario( + "1.16", + "1c3b72019e547fe1cf1530419dc6fc687cc4df39", + "minimal", + "", + provenance="canonical_compatibility", + emitted_version="1.15", + note=( + "The schema transition introduced this reader version without a retained writer " + "commit that emitted it. The recorded writer emitted 1.15; only the schema label is " + "changed to exercise the canonical compatibility branch." + ), + ), ) @@ -546,17 +602,31 @@ def main() -> None: } if scenario.emitted_version is not None: source["emitted_version"] = scenario.emitted_version - source["note"] = ( - "The release-boundary schema renumbering introduced this reader version " - "without a writer that emitted it. The recorded writer emitted 1.9; only " - "the schema label is changed to exercise the canonical compatibility branch." - ) + source["note"] = scenario.note or LEGACY_CANONICAL_COMPATIBILITY_NOTE feature_sources.append(source) sources_path = OUTPUT.parent / "sources.json" sources = json.loads(sources_path.read_text(encoding="utf-8")) sources["features"] = feature_sources + MINIMAL_OUTPUT.mkdir(parents=True, exist_ok=True) + for scenario in MINIMAL_SCENARIOS: + minimal_payload = _generate(scenario) + minimal_filename = f"v{scenario.version.replace('.', '_')}.json" + (MINIMAL_OUTPUT / minimal_filename).write_text( + json.dumps(minimal_payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + minimal_source = { + "commit": scenario.commit, + "fixture": f"minimal/{minimal_filename}", + } + if scenario.emitted_version is not None: + minimal_source["emitted_version"] = scenario.emitted_version + minimal_source["provenance"] = scenario.provenance + minimal_source["note"] = scenario.note or LEGACY_CANONICAL_COMPATIBILITY_NOTE + sources["versions"][scenario.version] = minimal_source + SECURITY_OUTPUT.mkdir(parents=True, exist_ok=True) security_payload = _generate(LEGACY_MOUNT_CREDENTIALS) security_filename = "v1_13_legacy_mount_credentials.json" diff --git a/tests/fixtures/run_state/minimal/v1_16.json b/tests/fixtures/run_state/minimal/v1_16.json new file mode 100644 index 0000000000..35224f9f3c --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_16.json @@ -0,0 +1,60 @@ +{ + "$schemaVersion": "1.16", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": {}, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "pending_input": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/sources.json b/tests/fixtures/run_state/sources.json index cdae672998..21d3d30bc1 100644 --- a/tests/fixtures/run_state/sources.json +++ b/tests/fixtures/run_state/sources.json @@ -109,6 +109,15 @@ "fixture": "features/v1_15_canonical_invocation_identity.json", "provenance": "historical_writer", "version": "1.15" + }, + { + "commit": "1c3b72019e547fe1cf1530419dc6fc687cc4df39", + "emitted_version": "1.15", + "feature": "per_call_approval_override", + "fixture": "features/v1_16_per_call_approval_override.json", + "note": "The schema transition introduced this reader version without a retained writer commit that emitted it. The recorded writer emitted 1.15; only the schema label is changed to exercise the canonical compatibility branch.", + "provenance": "canonical_compatibility", + "version": "1.16" } ], "resume": { @@ -163,6 +172,13 @@ "commit": "4720150fde047baa4e88b16082b282bee3a5e87d", "fixture": "minimal/v1_15.json" }, + "1.16": { + "commit": "1c3b72019e547fe1cf1530419dc6fc687cc4df39", + "emitted_version": "1.15", + "fixture": "minimal/v1_16.json", + "note": "The schema transition introduced this reader version without a retained writer commit that emitted it. The recorded writer emitted 1.15; only the schema label is changed to exercise the canonical compatibility branch.", + "provenance": "canonical_compatibility" + }, "1.2": { "commit": "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c", "fixture": "minimal/v1_2.json" diff --git a/tests/test_run_context_wrapper.py b/tests/test_run_context_wrapper.py index a023feeea6..ea1f6d70d0 100644 --- a/tests/test_run_context_wrapper.py +++ b/tests/test_run_context_wrapper.py @@ -207,3 +207,102 @@ def test_tool_approval_item_preserves_positional_type_argument() -> None: assert approval.type == "tool_approval_item" assert approval.tool_name == "lookup_account" assert approval.tool_namespace == "billing" + + +def test_exact_call_decisions_override_sticky_defaults() -> None: + agent = make_agent() + + def approval(call_id: str) -> ToolApprovalItem: + return ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": call_id, + "arguments": "{}", + }, + ) + + approved: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) + approved.approve_tool(approval("approve-sticky"), always_approve=True) + approved.reject_tool(approval("approve-exception"), rejection_message="denied by user") + + assert approved.is_tool_approved("tool_call", "approve-exception") is False + assert approved.get_rejection_message("tool_call", "approve-exception") == "denied by user" + assert approved.is_tool_approved("tool_call", "approve-other") is True + + rejected: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) + rejected.reject_tool( + approval("reject-sticky"), + always_reject=True, + rejection_message="denied by default", + ) + rejected.approve_tool(approval("reject-exception")) + + assert rejected.is_tool_approved("tool_call", "reject-exception") is True + assert rejected.get_rejection_message("tool_call", "reject-exception") is None + assert rejected.is_tool_approved("tool_call", "reject-other") is False + assert rejected.get_rejection_message("tool_call", "reject-other") == "denied by default" + + +def test_matching_exact_call_decisions_preserve_sticky_defaults() -> None: + agent = make_agent() + + def approval(call_id: str) -> ToolApprovalItem: + return ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": call_id, + "arguments": "{}", + }, + ) + + approved: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) + approved.approve_tool(approval("approve-sticky"), always_approve=True) + approved.approve_tool(approval("approve-match")) + assert approved.is_tool_approved("tool_call", "approve-other") is True + + rejected: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) + rejected.reject_tool(approval("reject-sticky"), always_reject=True) + rejected.reject_tool(approval("reject-match")) + assert rejected.is_tool_approved("tool_call", "reject-other") is False + + +def test_exact_call_reversals_keep_other_calls_on_sticky_default() -> None: + agent = make_agent() + + def approval(call_id: str) -> ToolApprovalItem: + return ToolApprovalItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "tool_call", + "call_id": call_id, + "arguments": "{}", + }, + ) + + approved: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) + approved.approve_tool(approval("approve-sticky"), always_approve=True) + approved.reject_tool(approval("approve-exception"), rejection_message="denied") + approved.approve_tool(approval("approve-exception")) + + assert approved.is_tool_approved("tool_call", "approve-exception") is True + assert approved.get_rejection_message("tool_call", "approve-exception") is None + assert approved.is_tool_approved("tool_call", "approve-other") is True + + rejected: RunContextWrapper[dict[str, object]] = RunContextWrapper(context={}) + rejected.reject_tool( + approval("reject-sticky"), + always_reject=True, + rejection_message="denied by default", + ) + rejected.approve_tool(approval("reject-exception")) + rejected.reject_tool(approval("reject-exception"), rejection_message="denied exactly") + + assert rejected.is_tool_approved("tool_call", "reject-exception") is False + assert rejected.get_rejection_message("tool_call", "reject-exception") == "denied exactly" + assert rejected.is_tool_approved("tool_call", "reject-other") is False + assert rejected.get_rejection_message("tool_call", "reject-other") == "denied by default" diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 66b35472c9..00e989db86 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -2967,6 +2967,89 @@ async def test_serializes_and_restores_approvals(self): assert new_state._context.is_tool_approved(tool_name="tool2", call_id="cid2") is False assert new_state._context.get_rejection_message("tool2", "cid2") is None + @pytest.mark.parametrize("sticky_approved", [True, False], ids=["approve", "reject"]) + async def test_exact_call_override_round_trips_with_sticky_default( + self, + sticky_approved: bool, + ) -> None: + """A current snapshot preserves an exact exception and the sticky default.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="MixedApprovalAgent") + state = make_state(agent, context=context, original_input="test") + + def approval(call_id: str) -> ToolApprovalItem: + return ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="tool1", + call_id=call_id, + status="completed", + arguments="{}", + ), + ) + + if sticky_approved: + state.approve(approval("sticky"), always_approve=True) + state.reject(approval("exception"), rejection_message="denied exactly") + else: + state.reject( + approval("sticky"), + always_reject=True, + rejection_message="denied by default", + ) + state.approve(approval("exception")) + + serialized = state.to_json() + assert serialized["$schemaVersion"] == "1.16" + + restored = await RunState.from_json(agent, serialized) + assert restored._context is not None + expected_exact = not sticky_approved + assert restored._context.is_tool_approved("tool1", "exception") is expected_exact + assert restored._context.is_tool_approved("tool1", "other") is sticky_approved + assert restored._context.get_rejection_message("tool1", "exception") == ( + "denied exactly" if sticky_approved else None + ) + + @pytest.mark.parametrize("sticky_approved", [True, False], ids=["approve", "reject"]) + async def test_schema_1_15_mixed_approval_record_keeps_exact_decision( + self, + sticky_approved: bool, + ) -> None: + """An explicit decision in a legacy snapshot remains authoritative.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="LegacyMixedApprovalAgent") + state = make_state(agent, context=context, original_input="test") + + def approval(call_id: str) -> ToolApprovalItem: + return ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="tool1", + call_id=call_id, + status="completed", + arguments="{}", + ), + ) + + if sticky_approved: + state.approve(approval("sticky"), always_approve=True) + state.reject(approval("exception"), rejection_message="denied exactly") + else: + state.reject(approval("sticky"), always_reject=True) + state.approve(approval("exception")) + + serialized = state.to_json() + serialized["$schemaVersion"] = "1.15" + + restored = await RunState.from_json(agent, serialized) + assert restored._context is not None + expected_exact = not sticky_approved + assert restored._context.is_tool_approved("tool1", "exception") is expected_exact + assert restored._context.is_tool_approved("tool1", "other") is sticky_approved + async def test_schema_1_13_restores_pending_approval_binding_from_interruption(self): """A 1.13 snapshot may resume only the exact invocation that was approved.""" agent = Agent(name="ApprovalLegacyAgent") @@ -6344,6 +6427,74 @@ async def test_missing_agent_in_map_error(self): class TestRunStateResumption: """Test resuming runs from RunState using Runner.run().""" + @pytest.mark.parametrize("streamed", [False, True], ids=["run", "run_streamed"]) + @pytest.mark.parametrize("sticky_approved", [True, False], ids=["approve", "reject"]) + @pytest.mark.asyncio + async def test_resume_executes_only_exact_override_result( + self, + streamed: bool, + sticky_approved: bool, + ) -> None: + """Public resume paths execute only calls authorized by mixed decisions.""" + model = ScriptedModel() + executions: list[str] = [] + + @function_tool(needs_approval=True) + async def approval_tool(value: str) -> str: + executions.append(value) + return f"approved:{value}" + + agent = Agent(name="MixedApprovalAgent", model=model, tools=[approval_tool]) + model.extend( + [ + [ + get_function_tool_call( + "approval_tool", + json.dumps({"value": "sticky"}), + call_id="sticky-call", + ), + get_function_tool_call( + "approval_tool", + json.dumps({"value": "exception"}), + call_id="exception-call", + ), + ], + [get_final_output_message("done")], + ] + ) + + initial = await Runner.run(agent, "start") + state = initial.to_state() + interruptions = { + cast(str, interruption.raw_item.call_id): interruption + for interruption in state.get_interruptions() + } + if sticky_approved: + state.approve(interruptions["sticky-call"], always_approve=True) + state.reject( + interruptions["exception-call"], + rejection_message="denied exactly", + ) + else: + state.reject( + interruptions["sticky-call"], + always_reject=True, + rejection_message="denied by default", + ) + state.approve(interruptions["exception-call"]) + + restored = await RunState.from_string(agent, state.to_string()) + if streamed: + resumed = Runner.run_streamed(agent, restored) + async for _ in resumed.stream_events(): + pass + else: + resumed = await Runner.run(agent, restored) + + assert resumed.final_output == "done" + assert resumed.interruptions == [] + assert executions == (["sticky"] if sticky_approved else ["exception"]) + @pytest.mark.asyncio async def test_resume_from_run_state(self): """Test resuming a run from a RunState.""" @@ -8936,6 +9087,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.12", "1.13", "1.14", + "1.15", CURRENT_SCHEMA_VERSION, } ) @@ -11667,6 +11819,55 @@ async def test_hosted_mcp_approval_round_trip_uses_typed_identity_records() -> N ) +@pytest.mark.asyncio +async def test_hosted_mcp_exact_rejection_overrides_sticky_approval_after_round_trip() -> None: + agent = Agent(name="test") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(agent, context=context) + + def approval(request_id: str) -> ToolApprovalItem: + return ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id=request_id, + type="mcp_approval_request", + arguments="{}", + name="lookup_account", + server_label="server-a", + ), + ) + + state.approve(approval("sticky-request"), always_approve=True) + state.reject(approval("exception-request"), rejection_message="denied exactly") + + restored = await RunState.from_json(agent, state.to_json()) + assert restored._context is not None + assert ( + restored._context.get_approval_status( + "lookup_account", + "exception-request", + existing_pending=approval("exception-request"), + ) + is False + ) + assert ( + restored._context.get_rejection_message( + "lookup_account", + "exception-request", + existing_pending=approval("exception-request"), + ) + == "denied exactly" + ) + assert ( + restored._context.get_approval_status( + "lookup_account", + "other-request", + existing_pending=approval("other-request"), + ) + is True + ) + + @pytest.mark.asyncio async def test_incomplete_hosted_mcp_query_cannot_create_approval_authority() -> None: agent = Agent(name="test") From cb8a2e7e7dd83a427cff9076e58356d00c4f90b2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 08:55:48 +0900 Subject: [PATCH 335/473] feat: add run-scoped sandbox working directories (#4427) Co-authored-by: Sergey Filimonov --- examples/sandbox/README.md | 1 + examples/sandbox/shared_session_workdirs.py | 289 ++++++++++++++ src/agents/run_config.py | 15 + src/agents/run_internal/turn_resolution.py | 140 ++++++- src/agents/sandbox/__init__.py | 3 +- src/agents/sandbox/apply_patch.py | 42 +- src/agents/sandbox/capabilities/capability.py | 11 + src/agents/sandbox/capabilities/filesystem.py | 17 +- src/agents/sandbox/capabilities/memory.py | 13 +- src/agents/sandbox/capabilities/shell.py | 9 +- src/agents/sandbox/capabilities/skills.py | 72 +++- .../capabilities/tools/apply_patch_tool.py | 50 ++- .../sandbox/capabilities/tools/shell_tool.py | 30 +- .../sandbox/capabilities/tools/view_image.py | 20 +- src/agents/sandbox/runtime.py | 92 ++++- .../sandbox/runtime_agent_preparation.py | 31 +- src/agents/sandbox/workspace_paths.py | 130 +++++++ .../capabilities/test_apply_patch_tool.py | 207 +++++++++- .../test_filesystem_capability.py | 20 +- .../capabilities/test_shell_capability.py | 82 +++- .../capabilities/test_skills_capability.py | 166 +++++++- .../capabilities/test_view_image_tool.py | 107 +++++- tests/sandbox/test_compatibility_guards.py | 2 + tests/sandbox/test_memory.py | 94 +++++ tests/sandbox/test_run_cwd.py | 360 ++++++++++++++++++ tests/sandbox/test_runtime.py | 323 +++++++++++++++- .../sandbox/test_runtime_agent_preparation.py | 30 +- tests/sandbox/test_workspace_paths.py | 138 ++++++- tests/test_run_config.py | 24 ++ tests/test_tool_approval_call_id_reuse.py | 201 ++++++++++ 30 files changed, 2648 insertions(+), 71 deletions(-) create mode 100644 examples/sandbox/shared_session_workdirs.py create mode 100644 tests/sandbox/test_run_cwd.py diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md index 733159a065..2248de310e 100644 --- a/examples/sandbox/README.md +++ b/examples/sandbox/README.md @@ -19,6 +19,7 @@ Most examples call a model through `Runner`, so set `OPENAI_API_KEY` in the repo | [`memory.py`](./memory.py) | `uv run python examples/sandbox/memory.py` | Runs one sandbox agent twice across a snapshot resume so it can read and write its own memory. | | [`memory_s3.py`](./memory_s3.py) | `source ~/.s3.env && uv run python examples/sandbox/memory_s3.py` | Runs sandbox memory across two fresh Docker sandboxes with S3-backed memory storage. | | [`memory_multi_agent_multiturn.py`](./memory_multi_agent_multiturn.py) | `uv run python examples/sandbox/memory_multi_agent_multiturn.py` | Shows separate memory layouts for two agents sharing one sandbox workspace. | +| [`shared_session_workdirs.py`](./shared_session_workdirs.py) | `uv run python examples/sandbox/shared_session_workdirs.py` | Shares one live sandbox between trusted agents while Shell, `view_image`, and `apply_patch` resolve relative paths from each run's `cwd`. This is not confinement; use separate sessions for untrusted agents or compute isolation. | | [`unix_local_pty.py`](./unix_local_pty.py) | `uv run python examples/sandbox/unix_local_pty.py` | Exercises an interactive pseudo-terminal in a Unix-local sandbox. | | [`unix_local_runner.py`](./unix_local_runner.py) | `uv run python examples/sandbox/unix_local_runner.py` | Runs against the Unix-local sandbox backend directly. | diff --git a/examples/sandbox/shared_session_workdirs.py b/examples/sandbox/shared_session_workdirs.py new file mode 100644 index 0000000000..e21e36281c --- /dev/null +++ b/examples/sandbox/shared_session_workdirs.py @@ -0,0 +1,289 @@ +"""Run two trusted agents in separate working directories of one live sandbox. + +Run-scoped working directories make relative paths consistent; they are not confinement. +Use separate sandbox sessions for untrusted agents or workloads that need compute isolation. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +from pathlib import Path + +from agents import ModelSettings, Runner, RunResult, ToolOutputImage +from agents.items import ToolCallItem, ToolCallOutputItem +from agents.run import RunConfig +from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig +from agents.sandbox.capabilities import Filesystem, Shell +from agents.sandbox.entries import BaseEntry, Dir, File +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient +from agents.sandbox.session import BaseSandboxSession + + +async def main(*, model: str) -> None: + client = UnixLocalSandboxClient() + agent_a = _build_agent(name="Task A worker", model=model) + agent_b = _build_agent(name="Task B worker", model=model) + shared_sandbox = await client.create(manifest=_build_manifest()) + + try: + # The session is shared, but each run gets a different base for relative paths. + run_a_config = RunConfig( + sandbox=SandboxRunConfig( + session=shared_sandbox, + cwd="tasks/task-a", + ), + workflow_name="Shared sandbox task A", + ) + run_b_config = RunConfig( + sandbox=SandboxRunConfig( + session=shared_sandbox, + cwd="tasks/task-b", + ), + workflow_name="Shared sandbox task B", + ) + + async with shared_sandbox: + run_tasks = [ + asyncio.create_task( + Runner.run( + agent_a, + _task_prompt("task-a"), + run_config=run_a_config, + max_turns=10, + ) + ), + asyncio.create_task( + Runner.run( + agent_b, + _task_prompt("task-b"), + run_config=run_b_config, + max_turns=10, + ) + ), + ] + result_a, result_b = await _run_concurrently(run_tasks) + + # These checks make the demo self-verifying; applications do not need them. + await _verify_task(shared_sandbox, task_name="task-a", result=result_a) + await _verify_task(shared_sandbox, task_name="task-b", result=result_b) + finally: + await client.delete(shared_sandbox) + + print("\nVerified: distinct run-scoped cwd values worked without session-global cwd mutation.") + + +# Demo setup. Applications can create task directories and inputs however they prefer. + +DEFAULT_MODEL = "gpt-5.6-sol" + +# The two task directories intentionally contain the same relative filenames. +TASKS = { + "task-a": ( + "red", + "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKklEQVR4nGO4o6ZGU8QwasGoBaMWjFowasGoBaMWjFowasGoBaMWDBULAIjyoD0k0I5JAAAAAElFTkSuQmCC", + ), + "task-b": ( + "blue", + "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKklEQVR4nGNQTX5NU8QwasGoBaMWjFowasGoBaMWjFowasGoBaMWDBULAMMtzEwilX6gAAAAAElFTkSuQmCC", + ), +} + +SHELL_COMMAND = "cp reference.png page.png && cat brief.md" + + +def _task_prompt(task_name: str) -> str: + return f""" +Complete {task_name} in your current run working directory. Use only these relative paths: + +1. Call `exec_command` with `{SHELL_COMMAND}`. Do not pass `workdir`. +2. Call `view_image` with `page.png` and identify its color. +3. Use `apply_patch` on `notes.md` to change only `status=pending-{task_name}` to + `status=complete-{task_name}`. +4. Reply with the task name and image color from the files you inspected. + +Do not prefix paths with `tasks/`, use absolute paths, or use the shell to edit `notes.md`. +""".strip() + + +def _build_manifest() -> Manifest: + task_dirs: dict[str | Path, BaseEntry] = {} + for task_name, (color, image_base64) in TASKS.items(): + task_dirs[task_name] = Dir( + children={ + "brief.md": File( + content=f"# {task_name}\n\nThe reference image is {color}.\n".encode() + ), + "reference.png": File(content=base64.b64decode(image_base64)), + "notes.md": File( + content=f"task={task_name}\nstatus=pending-{task_name}\n".encode() + ), + } + ) + return Manifest(entries={"tasks": Dir(children=task_dirs)}) + + +def _build_agent(*, name: str, model: str) -> SandboxAgent: + # These settings only make the demo's dependent tool sequence deterministic. + # Run-scoped cwd does not require them. + return SandboxAgent( + name=name, + model=model, + instructions=( + "Follow the requested tool sequence exactly. Treat the run working directory as " + "the base for every relative path." + ), + capabilities=[Shell(), Filesystem()], + model_settings=ModelSettings(tool_choice="required", parallel_tool_calls=False), + ) + + +async def _run_concurrently( + run_tasks: list[asyncio.Task[RunResult]], +) -> tuple[RunResult, RunResult]: + """Keep both runs terminal before the caller closes their shared session.""" + try: + result_a, result_b = await asyncio.gather(*run_tasks) + except BaseException: + for run_task in run_tasks: + if not run_task.done(): + run_task.cancel() + await asyncio.gather(*run_tasks, return_exceptions=True) + raise + return result_a, result_b + + +# Demo verification. Applications do not need to inspect raw tool calls this way. + + +def _tool_call_name(item: ToolCallItem) -> str: + raw_item = item.raw_item + if isinstance(raw_item, dict): + type_name = raw_item.get("type") + name = raw_item.get("name") + else: + type_name = getattr(raw_item, "type", None) + name = getattr(raw_item, "name", None) + + if type_name == "apply_patch_call": + return "apply_patch" + if isinstance(name, str): + return name + return type_name if isinstance(type_name, str) else "" + + +def _function_call_arguments(item: ToolCallItem) -> dict[str, object]: + raw_item = item.raw_item + if isinstance(raw_item, dict): + arguments = raw_item.get("arguments") + else: + arguments = getattr(raw_item, "arguments", None) + if not isinstance(arguments, str): + raise RuntimeError(f"{_tool_call_name(item)} did not provide JSON arguments") + + try: + parsed = json.loads(arguments) + except json.JSONDecodeError as error: + raise RuntimeError(f"{_tool_call_name(item)} provided invalid JSON arguments") from error + if not isinstance(parsed, dict): + raise RuntimeError(f"{_tool_call_name(item)} arguments were not an object") + return parsed + + +def _apply_patch_input(item: ToolCallItem) -> str: + raw_item = item.raw_item + if isinstance(raw_item, dict): + raw_input = raw_item.get("input") + else: + raw_input = getattr(raw_item, "input", None) + if not isinstance(raw_input, str): + raise RuntimeError("apply_patch did not provide patch input") + return raw_input + + +async def _read_workspace_bytes(session: BaseSandboxSession, path: Path) -> bytes: + handle = await session.read(path) + try: + payload = handle.read() + finally: + handle.close() + return payload.encode() if isinstance(payload, str) else bytes(payload) + + +async def _verify_task( + session: BaseSandboxSession, + *, + task_name: str, + result: RunResult, +) -> None: + color, image_base64 = TASKS[task_name] + task_root = Path("tasks") / task_name + + # Direct session operations remain workspace-root-relative, so verification uses full paths. + page = await _read_workspace_bytes(session, task_root / "page.png") + notes = await _read_workspace_bytes(session, task_root / "notes.md") + expected_page = base64.b64decode(image_base64) + expected_notes = f"task={task_name}\nstatus=complete-{task_name}\n".encode() + if page != expected_page: + raise RuntimeError(f"{task_name} copied the wrong relative reference.png") + if notes != expected_notes: + raise RuntimeError(f"{task_name} did not patch its own relative notes.md") + + tool_call_items = [item for item in result.new_items if isinstance(item, ToolCallItem)] + tool_calls = [_tool_call_name(item) for item in tool_call_items] + expected_tool_calls = ["exec_command", "view_image", "apply_patch"] + if tool_calls != expected_tool_calls: + raise RuntimeError( + f"{task_name} used {tool_calls}; expected the ordered calls {expected_tool_calls}" + ) + + shell_arguments = _function_call_arguments(tool_call_items[0]) + if shell_arguments.get("cmd") != SHELL_COMMAND or "workdir" in shell_arguments: + raise RuntimeError(f"{task_name} did not use the task-relative shell command") + + image_arguments = _function_call_arguments(tool_call_items[1]) + if image_arguments.get("path") != "page.png": + raise RuntimeError(f"{task_name} did not view relative page.png") + + patch_input = _apply_patch_input(tool_call_items[2]) + patch_lines = patch_input.splitlines() + file_directives = [ + line + for line in patch_lines + if line.startswith( + ("*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: ") + ) + ] + expected_old = f"-status=pending-{task_name}" + expected_new = f"+status=complete-{task_name}" + if ( + file_directives != ["*** Update File: notes.md"] + or expected_old not in patch_lines + or expected_new not in patch_lines + ): + raise RuntimeError(f"{task_name} did not patch relative notes.md with its own marker") + + expected_image_url = f"data:image/png;base64,{image_base64}" + image_urls = [ + item.output.image_url + for item in result.new_items + if isinstance(item, ToolCallOutputItem) and isinstance(item.output, ToolOutputImage) + ] + if expected_image_url not in image_urls: + raise RuntimeError(f"{task_name} viewed an image outside its run working directory") + + print(f"\n[{task_name}] cwd={task_root.as_posix()} color={color}") + print(f"tool calls: {', '.join(tool_calls)}") + print(f"notes.md: {notes.decode().strip()}") + print(f"final output: {result.final_output}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Run two task-local agents concurrently in one live sandbox session." + ) + parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.") + args = parser.parse_args() + asyncio.run(main(model=args.model)) diff --git a/src/agents/run_config.py b/src/agents/run_config.py index 986f62a969..c413910ebd 100644 --- a/src/agents/run_config.py +++ b/src/agents/run_config.py @@ -3,6 +3,7 @@ import os from collections.abc import Callable from dataclasses import dataclass, field +from pathlib import PurePath from typing import TYPE_CHECKING, Any, Generic, Literal from pydantic import TypeAdapter @@ -218,6 +219,15 @@ class SandboxRunConfig: Use `SandboxArchiveLimits()` to enable SDK defaults. """ + cwd: str | PurePath | None = None + """Optional model-facing working directory relative to the sandbox workspace root. + + Relative paths used by the built-in `exec_command`, `view_image`, and `apply_patch` tools + resolve from this directory. Custom path-bearing capabilities must apply their bound + `SandboxWorkspaceScope` explicitly. The directory must already exist when the run starts. + This setting does not change `Manifest.root` or direct `BaseSandboxSession` path behavior. + """ + if TYPE_CHECKING: def __init__( @@ -230,9 +240,14 @@ def __init__( snapshot: SnapshotSpec | SnapshotBase | dict[str, Any] | None = None, concurrency_limits: SandboxConcurrencyLimits | dict[str, Any] = ..., archive_limits: SandboxArchiveLimits | dict[str, Any] | None = None, + cwd: str | PurePath | None = None, ) -> None: ... def __post_init__(self) -> None: + if self.cwd is not None: + from .sandbox.workspace_paths import normalize_sandbox_cwd + + self.cwd = normalize_sandbox_cwd(self.cwd).as_posix() if isinstance(self.manifest, dict): from .sandbox.manifest import _coerce_manifest diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index b1eead5351..ae9fec7619 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -1149,6 +1149,7 @@ async def resolve_interrupted_turn( """Continue a turn that was previously interrupted waiting for tool approval.""" public_agent = bindings.public_agent execution_agent = bindings.execution_agent + output_index = _build_tool_output_index(original_pre_step_items) current_step = run_state._current_step if run_state is not None else None if ( @@ -1169,6 +1170,14 @@ async def resolve_interrupted_turn( if ( isinstance(current_step, NextStepInterruption) and current_step.response_accepted + and not any( + get_mapping_or_attr(output, "type") == "custom_tool_call" + and ( + (call_id := extract_tool_call_id(output)) is None + or ("custom_tool_call_output", call_id) not in output_index + ) + for output in new_response.output + ) and not processed_response.has_tools_or_approvals_to_run() ): return await execute_tools_and_side_effects( @@ -1303,8 +1312,6 @@ def _approval_matches_agent(approval: ToolApprovalItem) -> bool: pending_interruption_keys: set[str] = set() stable_function_approval_sources: dict[int, ToolApprovalItem] = {} - output_index = _build_tool_output_index(original_pre_step_items) - def _has_output_item(call_id: str, expected_type: str) -> bool: return (expected_type, call_id) in output_index @@ -1846,6 +1853,93 @@ def _coerce_approval_call( else None ) + custom_calls_to_reconcile: list[ResponseCustomToolCall] = [] + custom_call_identities: dict[str, tuple[str, str, str, str]] = {} + rejected_custom_approvals_by_call_id: dict[ + str, + tuple[ToolApprovalItem, ResponseCustomToolCall], + ] = {} + + def _custom_reconciliation_approval( + call: ResponseCustomToolCall, + identity: tuple[str, str, str, str], + ) -> tuple[ToolApprovalItem, bool | None] | None: + for approval in pending_approval_items: + if not _approval_matches_agent(approval): + continue + if get_mapping_or_attr(approval.raw_item, "type") != "custom_tool_call": + continue + if get_tool_approval_item_call_id(approval) != call.call_id: + continue + approval_name = get_mapping_or_attr(approval.raw_item, "name") + if not isinstance(approval_name, str): + continue + approval_identity = tool_invocation_identity_and_scope( + approval.raw_item, + tool_name=approval_name, + ) + if approval_identity != identity: + continue + return ( + approval, + context_wrapper.get_approval_status( + approval.tool_name or call.name, + call.call_id, + tool_namespace=approval.tool_namespace, + existing_pending=approval, + ), + ) + return None + + def _append_custom_reconciliation_call(raw_item: Any) -> None: + if isinstance(raw_item, ResponseCustomToolCall): + call = raw_item.model_copy(deep=True) + elif isinstance(raw_item, Mapping) and raw_item.get("type") == "custom_tool_call": + try: + call = ResponseCustomToolCall(**dict(raw_item)) + except Exception as error: + raise ModelBehaviorError( + "Persisted custom tool call is invalid. Start a new run instead of " + "resuming this RunState." + ) from error + else: + return + + call_id = extract_tool_call_id(call) + if call_id is None: + raise ModelBehaviorError("Custom tool call is missing call_id.") + identity = tool_invocation_identity_and_scope(call, tool_name=call.name) + if identity is None: + raise ModelBehaviorError("Custom tool call has an invalid invocation identity.") + previous_identity = custom_call_identities.get(call_id) + if previous_identity is not None: + if previous_identity != identity: + raise ModelBehaviorError( + "Run state reused a custom tool call ID for different invocations. " + "Start a new run instead of resuming this RunState." + ) + return + custom_call_identities[call_id] = identity + if _custom_tool_output_exists(call_id): + return + approval_decision = _custom_reconciliation_approval(call, identity) + if approval_decision is not None: + approval, approval_status = approval_decision + if approval_status is False: + rejected_custom_approvals_by_call_id[call_id] = (approval, call) + return + if approval_status is None: + return + custom_calls_to_reconcile.append(call) + + for output in new_response.output: + _append_custom_reconciliation_call(output) + for custom_run in processed_response.custom_tool_calls: + _append_custom_reconciliation_call(custom_run.tool_call) + for approval_item in pending_approval_items: + if _approval_matches_agent(approval_item): + _append_custom_reconciliation_call(approval_item.raw_item) + available_handoffs = await get_handoffs(execution_agent, context_wrapper) with execution_agent._use_mcp_handoff_snapshot(available_handoffs): current_tool_inventory = await execution_agent.get_all_tools(context_wrapper) @@ -1915,10 +2009,15 @@ def _append_reconciliation_call(call: ResponseFunctionToolCall) -> None: classifier_tools: list[Tool] = [*resolved_function_tools] classifier_tools.extend( - tool for tool in resolved_tools if isinstance(tool, ProgrammaticToolCallingTool) + tool + for tool in resolved_tools + if isinstance(tool, ProgrammaticToolCallingTool | CustomTool | ApplyPatchTool) ) classifier_response = ModelResponse( - output=cast(Any, [*classifier_context_items, *calls_to_reconcile]), + output=cast( + Any, + [*classifier_context_items, *calls_to_reconcile, *custom_calls_to_reconcile], + ), usage=new_response.usage, response_id=new_response.response_id, request_id=new_response.request_id, @@ -1938,6 +2037,37 @@ def _append_reconciliation_call(call: ResponseFunctionToolCall) -> None: current_functions = {run.tool_call.call_id: run for run in classified.functions} current_handoffs = {run.tool_call.call_id: run for run in classified.handoffs} current_missing = {run.tool_call.call_id: run for run in classified.function_tools_not_found} + processed_response.custom_tool_calls = [ + run + for run in processed_response.custom_tool_calls + if _custom_tool_output_exists(_custom_call_id_from_run(run)) + ] + processed_response.custom_tool_calls.extend(classified.custom_tool_calls) + rejected_custom_approval_outputs: list[RunItem] = [] + for call_id, (approval, rejected_custom_call) in rejected_custom_approvals_by_call_id.items(): + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=run_config, + tool_call=rejected_custom_call, + tool_type="custom", + tool_name=approval.tool_name or rejected_custom_call.name, + call_id=call_id, + tool_namespace=approval.tool_namespace, + existing_pending=approval, + ) + raw_item = { + "type": "custom_tool_call_output", + "call_id": call_id, + "output": rejection_message, + } + ItemHelpers.copy_tool_call_caller(rejected_custom_call, raw_item) + rejected_custom_approval_outputs.append( + ToolCallOutputItem( + agent=public_agent, + output=rejection_message, + raw_item=cast(Any, raw_item), + ) + ) pending_nested_transfers: list[tuple[ResponseFunctionToolCall, Any]] = [] pending_nested_drops: list[ResponseFunctionToolCall] = [] @@ -2393,6 +2523,8 @@ def _commit_tool_output(item: RunItem) -> None: append_if_new(shell_rejection) for custom_tool_rejection in rejected_custom_tool_results: append_if_new(custom_tool_rejection) + for custom_tool_rejection in rejected_custom_approval_outputs: + append_if_new(custom_tool_rejection) for apply_patch_rejection in rejected_apply_patch_results: append_if_new(apply_patch_rejection) for approved_response in plan.approved_mcp_responses: diff --git a/src/agents/sandbox/__init__.py b/src/agents/sandbox/__init__.py index 9dbeb9e3e1..841980ab44 100644 --- a/src/agents/sandbox/__init__.py +++ b/src/agents/sandbox/__init__.py @@ -26,7 +26,7 @@ resolve_snapshot, ) from .types import ExecResult, ExposedPortEndpoint, FileMode, Group, Permissions, User -from .workspace_paths import SandboxPathGrant +from .workspace_paths import SandboxPathGrant, SandboxWorkspaceScope __all__ = [ "Capability", @@ -52,6 +52,7 @@ "SandboxAgent", "SandboxArchiveLimits", "SandboxPathGrant", + "SandboxWorkspaceScope", "SandboxConcurrencyLimits", "SandboxError", "SandboxRunConfig", diff --git a/src/agents/sandbox/apply_patch.py b/src/agents/sandbox/apply_patch.py index 7f89612993..30623fdf82 100644 --- a/src/agents/sandbox/apply_patch.py +++ b/src/agents/sandbox/apply_patch.py @@ -14,6 +14,12 @@ InvalidManifestPathError, WorkspaceReadNotFoundError, ) +from .workspace_paths import ( + SandboxWorkspaceScope, + _is_absolute_sandbox_path, + coerce_posix_path, + posix_path_for_error, +) if TYPE_CHECKING: from .session.base_sandbox_session import BaseSandboxSession @@ -38,9 +44,11 @@ def __init__( session: BaseSandboxSession, *, user: str | User | None = None, + workspace_scope: SandboxWorkspaceScope | None = None, ) -> None: self._session = session self._user = user + self._workspace_scope = workspace_scope or SandboxWorkspaceScope() async def apply_patch( self, @@ -62,9 +70,8 @@ async def apply_operation( patch_format: PatchFormat | Literal["v4a"] = "v4a", ) -> ApplyPatchResult: format_impl = _resolve_patch_format(patch_format) - relative_path = self._validate_path(operation.path) + relative_path, display_path = self._resolve_path(operation.path) destination = self._session.normalize_path(relative_path) - display_path = relative_path.as_posix() if operation.type == "delete_file": await self._ensure_exists(destination, display_path=display_path) @@ -80,7 +87,16 @@ async def apply_operation( ) if operation.type == "update_file": - original_text = await self._read_text(destination, op_path=operation.path) + decode_path = destination + if self._workspace_scope.cwd is not None: + decode_path = posix_path_for_error( + operation.path if _is_absolute_sandbox_path(operation.path) else display_path + ) + original_text = await self._read_text( + destination, + op_path=operation.path, + decode_path=decode_path, + ) try: updated_text = format_impl.apply_diff(original_text, operation.diff, mode="default") except ValueError as exc: @@ -93,12 +109,11 @@ async def apply_operation( await self._write_text(destination, updated_text) return ApplyPatchResult(output=f"Updated {display_path}") - moved_relative_path = self._validate_path(operation.move_to) + moved_relative_path, moved_display_path = self._resolve_path(operation.move_to) moved_destination = self._session.normalize_path(moved_relative_path) await self._write_text(moved_destination, updated_text) if moved_destination != destination: await self._session.rm(destination, user=self._user) - moved_display_path = moved_relative_path.as_posix() return ApplyPatchResult( output=f"Updated {display_path}\nMoved {display_path} to {moved_display_path}" ) @@ -136,6 +151,15 @@ def normalize_operation(self, operation: ApplyPatchOperation) -> ApplyPatchOpera move_to=normalized_move_to, ) + def _resolve_path(self, path: str | Path) -> tuple[Path, str]: + relative_path = self._validate_path(path) + normalized_path = coerce_posix_path(path) + display_path = self._workspace_scope.display_path( + original_path=normalized_path, + workspace_relative_path=relative_path, + ).as_posix() + return relative_path, display_path + def _validate_path(self, path: str | Path) -> Path: if isinstance(path, str) and not path.strip(): raise ApplyPatchPathError(path=path, reason="empty") @@ -144,7 +168,9 @@ def _validate_path(self, path: str | Path) -> Path: # normalizes them. Converting through host-native Path first would make # backslash handling depend on the SDK host operating system. try: - return self._session._workspace_path_policy().relative_path(path) + normalized_path = coerce_posix_path(path) + scoped_path = self._workspace_scope.anchor(normalized_path) + return self._session._workspace_path_policy().relative_path(scoped_path) except InvalidManifestPathError as exc: raise ApplyPatchPathError( path=path, @@ -160,7 +186,7 @@ async def _ensure_exists(self, destination: Path, *, display_path: str) -> None: else: handle.close() - async def _read_text(self, destination: Path, *, op_path: str) -> str: + async def _read_text(self, destination: Path, *, op_path: str, decode_path: Path) -> str: try: handle = await self._session.read(destination, user=self._user) except (FileNotFoundError, WorkspaceReadNotFoundError) as exc: @@ -177,7 +203,7 @@ async def _read_text(self, destination: Path, *, op_path: str) -> str: try: return bytes(payload).decode("utf-8") except UnicodeDecodeError as exc: - raise ApplyPatchDecodeError(path=destination, cause=exc) from exc + raise ApplyPatchDecodeError(path=decode_path, cause=exc) from exc raise ApplyPatchDiffError( message=f"apply_patch read() returned non-text content: {type(payload).__name__}", path=op_path, diff --git a/src/agents/sandbox/capabilities/capability.py b/src/agents/sandbox/capabilities/capability.py index e0b169463c..b3e702961f 100644 --- a/src/agents/sandbox/capabilities/capability.py +++ b/src/agents/sandbox/capabilities/capability.py @@ -10,6 +10,7 @@ from ..manifest import Manifest from ..session.base_sandbox_session import BaseSandboxSession from ..types import User +from ..workspace_paths import SandboxWorkspaceScope class Capability(BaseModel): @@ -18,6 +19,11 @@ class Capability(BaseModel): type: str session: BaseSandboxSession | None = Field(default=None, exclude=True) run_as: User | None = Field(default=None, exclude=True) + workspace_scope: SandboxWorkspaceScope = Field( + default_factory=SandboxWorkspaceScope, + exclude=True, + repr=False, + ) def clone(self) -> "Capability": """Return a per-run copy of this capability.""" @@ -34,6 +40,10 @@ def bind_run_as(self, user: User | None) -> None: """Bind the sandbox user identity for model-facing operations.""" self.run_as = user + def bind_workspace_scope(self, scope: SandboxWorkspaceScope) -> None: + """Bind the immutable model-facing path scope for this run.""" + self.workspace_scope = scope + def required_capability_types(self) -> set[str]: """Return capability types that must be present alongside this capability.""" return set() @@ -65,6 +75,7 @@ def _clone_capability_value(value: Any) -> Any: if isinstance( value, BaseSandboxSession + | SandboxWorkspaceScope | asyncio.Event | asyncio.Lock | asyncio.Semaphore diff --git a/src/agents/sandbox/capabilities/filesystem.py b/src/agents/sandbox/capabilities/filesystem.py index aa023765f1..0a04d6baa1 100644 --- a/src/agents/sandbox/capabilities/filesystem.py +++ b/src/agents/sandbox/capabilities/filesystem.py @@ -1,12 +1,13 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Literal from pydantic import Field from ...tool import Tool +from ..workspace_paths import SandboxWorkspaceScope from .capability import Capability from .tools import SandboxApplyPatchTool, ViewImageTool @@ -17,6 +18,7 @@ class FilesystemToolSet: view_image: ViewImageTool apply_patch: SandboxApplyPatchTool + workspace_scope: SandboxWorkspaceScope = field(default_factory=SandboxWorkspaceScope) FilesystemToolConfigurator = Callable[[FilesystemToolSet], None] @@ -32,8 +34,17 @@ def tools(self) -> list[Tool]: raise ValueError("Filesystem capability is not bound to a SandboxSession") toolset = FilesystemToolSet( - view_image=ViewImageTool(session=self.session, user=self.run_as), - apply_patch=SandboxApplyPatchTool(session=self.session, user=self.run_as), + view_image=ViewImageTool( + session=self.session, + user=self.run_as, + workspace_scope=self.workspace_scope, + ), + apply_patch=SandboxApplyPatchTool( + session=self.session, + user=self.run_as, + workspace_scope=self.workspace_scope, + ), + workspace_scope=self.workspace_scope, ) if self.configure_tools is not None: self.configure_tools(toolset) diff --git a/src/agents/sandbox/capabilities/memory.py b/src/agents/sandbox/capabilities/memory.py index ed9e482479..cb0810ca2b 100644 --- a/src/agents/sandbox/capabilities/memory.py +++ b/src/agents/sandbox/capabilities/memory.py @@ -54,7 +54,8 @@ async def instructions(self, manifest: Manifest) -> str | None: if self.session is None: raise ValueError("Memory capability is not bound to a SandboxSession") - memory_summary_path = Path(self.layout.memories_dir) / "memory_summary.md" + memory_dir_path = Path(self.layout.memories_dir) + memory_summary_path = memory_dir_path / "memory_summary.md" try: handle = await self.session.read(memory_summary_path, user=self.run_as) except WorkspaceReadNotFoundError: @@ -72,8 +73,16 @@ async def instructions(self, manifest: Manifest) -> str | None: if not memory_summary: return None + model_memory_dir = ( + self.layout.memories_dir + if self.workspace_scope.cwd is None + else self.workspace_scope.model_resource_path( + workspace_root=manifest.root, + workspace_relative_path=memory_dir_path, + ).as_posix() + ) return render_memory_read_prompt( - memory_dir=self.layout.memories_dir, + memory_dir=model_memory_dir, memory_summary=memory_summary, live_update=self.read.live_update, ) diff --git a/src/agents/sandbox/capabilities/shell.py b/src/agents/sandbox/capabilities/shell.py index 44624f6f32..b753c5f7ed 100644 --- a/src/agents/sandbox/capabilities/shell.py +++ b/src/agents/sandbox/capabilities/shell.py @@ -9,6 +9,7 @@ from ...tool import Tool from ..manifest import Manifest +from ..workspace_paths import SandboxWorkspaceScope from .capability import Capability from .tools import ExecCommandTool, WriteStdinTool @@ -31,6 +32,7 @@ class ShellToolSet: exec_command: ExecCommandTool write_stdin: WriteStdinTool | None + workspace_scope: SandboxWorkspaceScope = SandboxWorkspaceScope() ShellToolConfigurator = Callable[[ShellToolSet], None] @@ -45,10 +47,15 @@ def tools(self) -> list[Tool]: if self.session is None: raise ValueError("Shell capability is not bound to a SandboxSession") toolset = ShellToolSet( - exec_command=ExecCommandTool(session=self.session, user=self.run_as), + exec_command=ExecCommandTool( + session=self.session, + user=self.run_as, + workspace_scope=self.workspace_scope, + ), write_stdin=WriteStdinTool(session=self.session) if self.session.supports_pty() else None, + workspace_scope=self.workspace_scope, ) if self.configure_tools is not None: self.configure_tools(toolset) diff --git a/src/agents/sandbox/capabilities/skills.py b/src/agents/sandbox/capabilities/skills.py index e68c437559..b46a3468d9 100644 --- a/src/agents/sandbox/capabilities/skills.py +++ b/src/agents/sandbox/capabilities/skills.py @@ -5,7 +5,7 @@ import stat from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator @@ -31,6 +31,20 @@ "and file path so you can open the source for full instructions when using a specific skill." ) +_SKILL_PATH_GUIDANCE = ( + "- Skill paths: Treat each listed path as the skill root. Resolve relative paths in " + "`SKILL.md`, including `scripts/`, `references/`, and `assets/`, against that root rather " + "than the shell working directory.", + "- Shared resources: Skill files belong to the sandbox session and may be visible to other " + "runs. Unless the task explicitly requires editing a skill, invoke scripts through the " + "listed skill root and write task inputs, outputs, caches, and temporary files in the run " + "working directory.", +) + +_SCOPED_SKILL_PATH_ERROR = ( + "skill path must be non-empty and workspace-relative when sandbox.cwd is configured" +) + _HOW_TO_USE_SKILLS_SECTION = "\n".join( [ "### How to use skills", @@ -666,6 +680,32 @@ def tools(self) -> list[Tool]: raise ValueError(f"{type(self).__name__} is not bound to a SandboxSession") return [_LoadSkillTool(skills=self)] + def _model_skill_path( + self, + *, + manifest: Manifest, + skill_name: str, + path: str | PurePath, + ) -> str: + if self.workspace_scope.cwd is None: + return str(path).replace("\\", "/") + try: + return self.workspace_scope.model_resource_path( + workspace_root=manifest.root, + workspace_relative_path=path, + ).as_posix() + except ValueError as exc: + raise SkillsConfigError( + message=_SCOPED_SKILL_PATH_ERROR, + context={ + "skill_name": skill_name, + "field": "path", + "path": path.as_posix() if isinstance(path, PurePath) else path, + "reason": "invalid", + }, + cause=exc, + ) from exc + async def load_skill(self, skill_name: str) -> dict[str, str]: if self.lazy_from is None: raise SkillsConfigError( @@ -674,12 +714,29 @@ async def load_skill(self, skill_name: str) -> dict[str, str]: ) if self.session is None: raise ValueError(f"{type(self).__name__} is not bound to a SandboxSession") - return await self.lazy_from.load_skill( + result = await self.lazy_from.load_skill( skill_name=skill_name, session=self.session, skills_path=self.skills_path, user=self.run_as, ) + if self.workspace_scope.cwd is None: + return result + + source_path = result.get("path") + if source_path is None: + raise SkillsConfigError( + message=_SCOPED_SKILL_PATH_ERROR, + context={"skill_name": skill_name, "field": "path", "reason": "missing"}, + ) + return { + **result, + "path": self._model_skill_path( + manifest=self.session.state.manifest, + skill_name=skill_name, + path=source_path, + ), + } async def _resolve_runtime_metadata(self, manifest: Manifest) -> list[SkillMetadata]: if self.session is None: @@ -787,7 +844,11 @@ async def instructions(self, manifest: Manifest) -> str | None: available_skill_lines: list[str] = [] for skill in skills: - path_str = str(skill.path).replace("\\", "/") + path_str = self._model_skill_path( + manifest=manifest, + skill_name=skill.name, + path=skill.path, + ) available_skill_lines.append(f"- {skill.name}: {skill.description} (file: {path_str})") how_to_use_section = ( @@ -801,6 +862,11 @@ async def instructions(self, manifest: Manifest) -> str | None: _SKILLS_SECTION_INTRO, "### Available skills", *available_skill_lines, + *( + ["### Run-scoped skill paths", *_SKILL_PATH_GUIDANCE] + if self.workspace_scope.cwd is not None + else [] + ), *( [ "### Lazy loading", diff --git a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py index 1bdba29bd3..6bfeeb4163 100644 --- a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py +++ b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py @@ -18,6 +18,7 @@ from ...errors import ApplyPatchPathError from ...session.base_sandbox_session import BaseSandboxSession from ...types import User +from ...workspace_paths import SandboxWorkspaceScope _APPLY_PATCH_CUSTOM_TOOL_GRAMMAR = r""" start: begin_patch hunk+ end_patch @@ -137,18 +138,37 @@ class SandboxApplyPatchEditor(ApplyPatchEditor): - def __init__(self, session: BaseSandboxSession, *, user: str | User | None = None) -> None: + def __init__( + self, + session: BaseSandboxSession, + *, + user: str | User | None = None, + workspace_scope: SandboxWorkspaceScope | None = None, + ) -> None: self.session = session self.user = user + self.workspace_scope = workspace_scope or SandboxWorkspaceScope() async def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: - return await WorkspaceEditor(self.session, user=self.user).apply_operation(operation) + return await WorkspaceEditor( + self.session, + user=self.user, + workspace_scope=self.workspace_scope, + ).apply_operation(operation) async def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: - return await WorkspaceEditor(self.session, user=self.user).apply_operation(operation) + return await WorkspaceEditor( + self.session, + user=self.user, + workspace_scope=self.workspace_scope, + ).apply_operation(operation) async def delete_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: - return await WorkspaceEditor(self.session, user=self.user).apply_operation(operation) + return await WorkspaceEditor( + self.session, + user=self.user, + workspace_scope=self.workspace_scope, + ).apply_operation(operation) class SandboxApplyPatchTool(CustomTool): @@ -164,9 +184,15 @@ def __init__( user: str | User | None = None, needs_approval: bool | ApplyPatchApprovalFunction = False, on_approval: ApplyPatchOnApprovalFunction | None = None, + workspace_scope: SandboxWorkspaceScope | None = None, ) -> None: self.session = session - self.editor = SandboxApplyPatchEditor(session, user=user) + self.workspace_scope = workspace_scope or SandboxWorkspaceScope() + self.editor = SandboxApplyPatchEditor( + session, + user=user, + workspace_scope=self.workspace_scope, + ) super().__init__( name="apply_patch", description=_APPLY_PATCH_CUSTOM_TOOL_DESCRIPTION, @@ -193,7 +219,10 @@ def parse_custom_input(self, raw_input: str) -> list[ApplyPatchOperation]: return _parse_custom_tool_input(raw_input) def _normalize_operation(self, operation: ApplyPatchOperation) -> ApplyPatchOperation: - return WorkspaceEditor(self.session).normalize_operation(operation) + return WorkspaceEditor( + self.session, + workspace_scope=self.workspace_scope, + ).normalize_operation(operation) def _parse_and_normalize_input(self, raw_input: str) -> list[ApplyPatchOperation]: return [ @@ -223,9 +252,12 @@ async def _needs_custom_approval( return False async def _on_invoke_tool(self, ctx: ToolContext[Any], raw_input: str) -> str: - # Normalize every operation before executing any of them. This prevents a valid - # prefix from mutating the workspace when a later path is invalid. - operations = self._parse_and_normalize_input(raw_input) + operations = self.parse_custom_input(raw_input) + # Validate every operation before executing any of them. Keep the raw paths for + # execution so an absolute model path does not lose its identity before the scoped + # editor applies the run cwd. + for operation in operations: + self._normalize_operation(operation) operation_outputs: list[str] = [] for operation in operations: diff --git a/src/agents/sandbox/capabilities/tools/shell_tool.py b/src/agents/sandbox/capabilities/tools/shell_tool.py index 1a0094adf9..3b6df76463 100644 --- a/src/agents/sandbox/capabilities/tools/shell_tool.py +++ b/src/agents/sandbox/capabilities/tools/shell_tool.py @@ -15,7 +15,7 @@ from ...session.base_sandbox_session import BaseSandboxSession from ...types import User from ...util.token_truncation import formatted_truncate_text_with_token_count -from ...workspace_paths import sandbox_path_str +from ...workspace_paths import SandboxWorkspaceScope, coerce_posix_path, sandbox_path_str _DEFAULT_EXEC_YIELD_TIME_MS = 10_000 _DEFAULT_WRITE_STDIN_YIELD_TIME_MS = 250 @@ -67,12 +67,21 @@ def _normalize_output(stdout: bytes, stderr: bytes) -> str: def _resolve_workdir_command( - *, session: BaseSandboxSession, command: str, workdir: str | None + *, + session: BaseSandboxSession, + workspace_scope: SandboxWorkspaceScope | None = None, + command: str, + workdir: str | None, ) -> str: + workspace_scope = workspace_scope or SandboxWorkspaceScope() if workdir is None or workdir.strip() == "": - return command + if workspace_scope.cwd is None: + return command + workdir = "." - resolved_workdir = session.normalize_path(sandbox_path_str(workdir)) + resolved_workdir = session.normalize_path( + sandbox_path_str(workspace_scope.anchor(coerce_posix_path(workdir))) + ) return f"cd {shlex.quote(sandbox_path_str(resolved_workdir))} && {command}" @@ -156,6 +165,12 @@ class ExecCommandTool(FunctionTool): ) session: BaseSandboxSession = field(init=False, repr=False, compare=False) user: str | User | None = field(default=None, init=False, repr=False, compare=False) + workspace_scope: SandboxWorkspaceScope = field( + default_factory=SandboxWorkspaceScope, + init=False, + repr=False, + compare=False, + ) def __init__( self, @@ -165,9 +180,11 @@ def __init__( needs_approval: ( bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]] ) = False, + workspace_scope: SandboxWorkspaceScope | None = None, ) -> None: self.session = session self.user = user + self.workspace_scope = workspace_scope or SandboxWorkspaceScope() super().__init__( name=self.tool_name, description=self.tool_description, @@ -184,7 +201,10 @@ async def run(self, args: ExecCommandArgs) -> str: start = time.perf_counter() timeout_s = args.yield_time_ms / 1000 wrapped_command = _resolve_workdir_command( - session=self.session, command=args.cmd, workdir=args.workdir + session=self.session, + workspace_scope=self.workspace_scope, + command=args.cmd, + workdir=args.workdir, ) shell = _resolve_shell(args.shell, args.login) fallback_notice: str | None = None diff --git a/src/agents/sandbox/capabilities/tools/view_image.py b/src/agents/sandbox/capabilities/tools/view_image.py index 6647e074af..fb8d475357 100644 --- a/src/agents/sandbox/capabilities/tools/view_image.py +++ b/src/agents/sandbox/capabilities/tools/view_image.py @@ -14,7 +14,7 @@ from ...errors import InvalidManifestPathError, WorkspaceReadNotFoundError from ...session.base_sandbox_session import BaseSandboxSession from ...types import User -from ...workspace_paths import sandbox_path_str +from ...workspace_paths import SandboxWorkspaceScope, coerce_posix_path, sandbox_path_str _MAX_IMAGE_BYTES = 10 * 1024 * 1024 _MAX_IMAGE_SIZE_LABEL = "10MB" @@ -82,6 +82,12 @@ class ViewImageTool(FunctionTool): ) session: BaseSandboxSession = field(init=False, repr=False, compare=False) user: str | User | None = field(default=None, init=False, repr=False, compare=False) + workspace_scope: SandboxWorkspaceScope = field( + default_factory=SandboxWorkspaceScope, + init=False, + repr=False, + compare=False, + ) def __init__( self, @@ -91,9 +97,11 @@ def __init__( needs_approval: ( bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]] ) = False, + workspace_scope: SandboxWorkspaceScope | None = None, ) -> None: self.session = session self.user = user + self.workspace_scope = workspace_scope or SandboxWorkspaceScope() super().__init__( name=self.tool_name, description=self.tool_description, @@ -107,10 +115,16 @@ async def _invoke(self, _: object, raw_input: str) -> ToolOutputImage | str: return await self.run(self.args_model.model_validate_json(raw_input)) async def run(self, args: ViewImageArgs) -> ToolOutputImage | str: + input_path = args.path + scoped_path = self.workspace_scope.anchor(coerce_posix_path(input_path)) path_policy = self.session._workspace_path_policy() - resolved_path = path_policy.normalize_path(args.path) + resolved_path = path_policy.normalize_path(scoped_path) try: - display_path = path_policy.relative_path(args.path).as_posix() + workspace_relative_path = path_policy.relative_path(scoped_path) + display_path = self.workspace_scope.display_path( + original_path=scoped_path, + workspace_relative_path=workspace_relative_path, + ).as_posix() except InvalidManifestPathError: display_path = sandbox_path_str(resolved_path) diff --git a/src/agents/sandbox/runtime.py b/src/agents/sandbox/runtime.py index 0378323a63..4927aa2392 100644 --- a/src/agents/sandbox/runtime.py +++ b/src/agents/sandbox/runtime.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from contextlib import nullcontext from dataclasses import dataclass +from pathlib import Path from typing import Any, Generic, cast from ..agent import Agent @@ -36,6 +37,7 @@ from .sandbox_agent import SandboxAgent from .session.base_sandbox_session import BaseSandboxSession from .types import User +from .workspace_paths import SandboxWorkspaceScope, sandbox_path_str logger = logging.getLogger(__name__) @@ -46,6 +48,13 @@ class _SandboxPreparedAgent(Generic[TContext]): input: str | list[TResponseInputItem] +@dataclass +class _SandboxPreparedAgentCache(Generic[TContext]): + agent: Agent[TContext] + session: BaseSandboxSession + run_as_name: str | None + + def _supports_trace_spans() -> bool: current_trace = get_current_trace() return current_trace is not None and current_trace.export() is not None @@ -74,6 +83,9 @@ def __init__( ) -> None: self._sandbox_config = run_config.sandbox if run_config is not None else None self._run_config_model = run_config.model if run_config is not None else None + self._workspace_scope = SandboxWorkspaceScope.from_cwd( + self._sandbox_config.cwd if self._sandbox_config is not None else None + ) # The runner resolves this before constructing the runtime. It can be None only when # sandbox is disabled or tests instantiate the runtime directly. self._rollout_id = rollout_id @@ -83,8 +95,7 @@ def __init__( sandbox_config=self._sandbox_config, run_state=run_state, ) - self._prepared_agents: dict[int, Agent[TContext]] = {} - self._prepared_sessions: dict[int, BaseSandboxSession] = {} + self._prepared_agents: dict[int, _SandboxPreparedAgentCache[TContext]] = {} @property def enabled(self) -> bool: @@ -206,27 +217,45 @@ async def prepare_agent( ) with span_cm: self._session_manager.acquire_agent(current_agent) - prepared_agent = self._prepared_agents.get(id(current_agent)) + cached_preparation = self._prepared_agents.get(id(current_agent)) prepared_capabilities = clone_capabilities(current_agent.capabilities) session = await self._session_manager.ensure_session( agent=current_agent, capabilities=prepared_capabilities, is_resumed_state=is_resumed_state, ) - if ( - prepared_agent is not None - and self._prepared_sessions.get(id(current_agent)) is session - ): + run_as = _coerce_run_as_user(current_agent.run_as) + await _validate_workspace_scope( + session=session, + scope=self._workspace_scope, + run_as=run_as, + ) + prepared_agent: Agent[TContext] + if cached_preparation is not None and cached_preparation.session is session: # Reuse the cached execution agent's bound capability instances so context # processing can depend on live session state and preserve per-run state. - _bind_capability_run_as( - cast(SandboxAgent[TContext], prepared_agent).capabilities, - _coerce_run_as_user(current_agent.run_as), - ) + cached_agent = cast(SandboxAgent[TContext], cached_preparation.agent) + prepared_agent = cached_agent + cached_capabilities = cached_agent.capabilities + _bind_capability_run_as(cached_capabilities, run_as) prepared_input = prepare_sandbox_input( - cast(SandboxAgent[TContext], prepared_agent).capabilities, + cached_capabilities, current_input, ) + run_as_name = run_as.name if run_as is not None else None + if cached_preparation.run_as_name != run_as_name: + prepared_agent = prepare_sandbox_agent( + agent=current_agent, + session=session, + capabilities=cached_capabilities, + run_config_model=self._run_config_model, + workspace_scope=self._workspace_scope, + ) + self._prepared_agents[id(current_agent)] = _SandboxPreparedAgentCache( + agent=prepared_agent, + session=session, + run_as_name=run_as_name, + ) return _SandboxPreparedAgent( bindings=bind_execution_agent( public_agent=current_agent, @@ -237,9 +266,9 @@ async def prepare_agent( # Bind before context processing: capabilities may inspect self.session while # transforming input. - run_as = _coerce_run_as_user(current_agent.run_as) for capability in prepared_capabilities: capability.bind(session) + capability.bind_workspace_scope(self._workspace_scope) _bind_capability_run_as(prepared_capabilities, run_as) prepared_input = prepare_sandbox_input(prepared_capabilities, current_input) prepared_agent = prepare_sandbox_agent( @@ -247,9 +276,13 @@ async def prepare_agent( session=session, capabilities=prepared_capabilities, run_config_model=self._run_config_model, + workspace_scope=self._workspace_scope, + ) + self._prepared_agents[id(current_agent)] = _SandboxPreparedAgentCache( + agent=prepared_agent, + session=session, + run_as_name=run_as.name if run_as is not None else None, ) - self._prepared_agents[id(current_agent)] = prepared_agent - self._prepared_sessions[id(current_agent)] = session return _SandboxPreparedAgent( bindings=bind_execution_agent( public_agent=current_agent, @@ -259,7 +292,7 @@ async def prepare_agent( ) async def cleanup(self) -> dict[str, object] | None: - should_trace_cleanup = self.current_session is not None or bool(self._prepared_sessions) + should_trace_cleanup = self.current_session is not None or bool(self._prepared_agents) span_cm = ( custom_span("sandbox.cleanup", data={}) if should_trace_cleanup and _supports_trace_spans() @@ -270,7 +303,6 @@ async def cleanup(self) -> dict[str, object] | None: return await self._session_manager.cleanup() finally: self._prepared_agents.clear() - self._prepared_sessions.clear() def _get_memory_capability(agent: Agent[TContext]) -> Memory | None: @@ -293,3 +325,29 @@ def _coerce_run_as_user(run_as: User | str | None) -> User | None: def _bind_capability_run_as(capabilities: Sequence[Capability], user: User | None) -> None: for capability in capabilities: capability.bind_run_as(user) + + +async def _validate_workspace_scope( + *, + session: BaseSandboxSession, + scope: SandboxWorkspaceScope, + run_as: User | None, +) -> None: + cwd = scope.cwd + if cwd is None: + return + + resolved_cwd = sandbox_path_str(session.normalize_path(cast(Path | str, scope.anchor(".")))) + for test_flag in ("-d", "-x"): + result = await session.exec( + "test", + test_flag, + resolved_cwd, + shell=False, + user=run_as, + ) + if not result.ok(): + raise UserError( + f"Sandbox working directory `{sandbox_path_str(cwd)}` does not exist or is not " + "accessible for the configured sandbox user" + ) diff --git a/src/agents/sandbox/runtime_agent_preparation.py b/src/agents/sandbox/runtime_agent_preparation.py index c82f303cab..841c43d072 100644 --- a/src/agents/sandbox/runtime_agent_preparation.py +++ b/src/agents/sandbox/runtime_agent_preparation.py @@ -22,6 +22,7 @@ from .sandbox_agent import SandboxAgent from .session.base_sandbox_session import BaseSandboxSession from .util.deep_merge import deep_merge +from .workspace_paths import SandboxWorkspaceScope, coerce_posix_path @lru_cache(maxsize=1) @@ -42,13 +43,33 @@ def clone_capabilities(capabilities: Sequence[Capability]) -> list[Capability]: return [capability.clone() for capability in capabilities] -def _filesystem_instructions(manifest: Manifest) -> str: +def _filesystem_instructions( + manifest: Manifest, + workspace_scope: SandboxWorkspaceScope | None = None, +) -> str: + effective_workspace_scope = workspace_scope or SandboxWorkspaceScope() header = textwrap.dedent( """ # Filesystem You have access to a container with a filesystem. The filesystem layout is: """ ).strip() + if effective_workspace_scope.cwd is not None: + workspace_root = coerce_posix_path(manifest.root) + working_directory = workspace_root / effective_workspace_scope.cwd + header = "\n".join( + [ + header, + f"For this run, the working directory is `{working_directory.as_posix()}`.", + "Relative paths passed to the built-in `exec_command`, `view_image`, and " + "`apply_patch` tools resolve from this directory.", + "Other sandbox tools follow their own path contract.", + f"The session workspace root remains `{workspace_root.as_posix()}`.", + "The working directory changes path resolution; it does not isolate this run " + "from the rest of the session workspace.", + "Files outside the working directory may be visible to or shared with other runs.", + ] + ) tree = render_manifest_description( root=manifest.root, entries=manifest.validated_entries(), @@ -68,8 +89,10 @@ def prepare_sandbox_agent( session: BaseSandboxSession, capabilities: Sequence[Capability], run_config_model: str | Model | None = None, + workspace_scope: SandboxWorkspaceScope | None = None, ) -> Agent[TContext]: manifest = session.state.manifest + effective_workspace_scope = workspace_scope or SandboxWorkspaceScope() available_capability_types = {capability.type for capability in capabilities} for capability in capabilities: @@ -98,6 +121,7 @@ def prepare_sandbox_agent( additional_instructions=agent.instructions, capabilities=capabilities, manifest=manifest, + workspace_scope=effective_workspace_scope, ), model_settings=replace( model_settings, @@ -155,7 +179,10 @@ def build_sandbox_instructions( | None, capabilities: Sequence[Capability], manifest: Manifest, + workspace_scope: SandboxWorkspaceScope | None = None, ) -> Callable[[RunContextWrapper[TContext], Agent[TContext]], Awaitable[str | None]]: + effective_workspace_scope = workspace_scope or SandboxWorkspaceScope() + async def _instructions( run_context: RunContextWrapper[TContext], current_agent: Agent[TContext], @@ -201,7 +228,7 @@ async def _instructions( if remote_mount_policy := build_remote_mount_policy_instructions(manifest): parts.append(_instruction_section("Sandbox remote mount policy", remote_mount_policy)) - parts.append(_filesystem_instructions(manifest)) + parts.append(_filesystem_instructions(manifest, effective_workspace_scope)) return "\n\n".join(parts) if parts else None diff --git a/src/agents/sandbox/workspace_paths.py b/src/agents/sandbox/workspace_paths.py index 4cdc49fefc..2a5b28a606 100644 --- a/src/agents/sandbox/workspace_paths.py +++ b/src/agents/sandbox/workspace_paths.py @@ -3,6 +3,7 @@ import os import posixpath import re +from dataclasses import dataclass from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath from typing import Literal, cast @@ -66,6 +67,135 @@ def sandbox_path_str(path: str | PurePath) -> str: return coerce_posix_path(path).as_posix() +def normalize_sandbox_cwd(cwd: str | PurePath) -> PurePosixPath: + """Validate and normalize a run working directory relative to the workspace root.""" + + if isinstance(cwd, PurePath): + raw_cwd = cwd.as_posix() + elif isinstance(cwd, str): + if "\\" in cwd: + raise ValueError("sandbox.cwd must use POSIX path separators") + raw_cwd = cwd + else: + raise ValueError("sandbox.cwd must be a string or Path") + + if not raw_cwd.strip(): + raise ValueError("sandbox.cwd must be non-empty") + if windows_absolute_path(cwd) is not None: + raise ValueError("sandbox.cwd must be workspace-relative") + + posix_cwd = PurePosixPath(raw_cwd) + if posix_cwd.is_absolute(): + raise ValueError("sandbox.cwd must be workspace-relative") + if ".." in posix_cwd.parts: + raise ValueError("sandbox.cwd must not contain parent segments") + + return PurePosixPath(posixpath.normpath(posix_cwd.as_posix())) + + +def _is_absolute_sandbox_path(path: str | PurePath) -> bool: + if windows_absolute_path(path) is not None: + return True + raw_path = path.as_posix() if isinstance(path, PurePath) else path + return PurePosixPath(raw_path).is_absolute() + + +@dataclass(frozen=True) +class SandboxWorkspaceScope: + """Immutable model-facing relative-path base for one sandbox run. + + This scope changes only how relative paths are anchored. The owning sandbox session and its + existing workspace policy remain responsible for access validation and filesystem operations. + """ + + cwd: PurePosixPath | None = None + + def __post_init__(self) -> None: + if self.cwd is not None: + object.__setattr__(self, "cwd", normalize_sandbox_cwd(self.cwd)) + + @classmethod + def from_cwd(cls, cwd: str | PurePath | None) -> SandboxWorkspaceScope: + """Create a scope from an optional workspace-relative working directory.""" + + return cls(cwd=normalize_sandbox_cwd(cwd) if cwd is not None else None) + + def anchor(self, path: str | PurePath) -> str | PurePath: + """Anchor a relative sandbox path beneath this scope's working directory.""" + + if self.cwd is None or _is_absolute_sandbox_path(path): + return path + raw_path = path.as_posix() if isinstance(path, PurePath) else path + return self.cwd / PurePosixPath(raw_path) + + def model_path(self, workspace_relative_path: str | PurePath) -> PurePosixPath: + """Render a workspace-root-relative path relative to the model-facing cwd.""" + + relative_path = coerce_posix_path(workspace_relative_path) + if relative_path.is_absolute(): + raise ValueError("workspace-relative display paths must not be absolute") + if self.cwd is None: + return PurePosixPath(posixpath.normpath(relative_path.as_posix())) + return PurePosixPath( + posixpath.relpath( + posixpath.normpath(relative_path.as_posix()), + start=self.cwd.as_posix(), + ) + ) + + def model_resource_path( + self, + *, + workspace_root: str | PurePath, + workspace_relative_path: str | PurePath, + ) -> PurePosixPath: + """Render a session-owned workspace resource for model-facing instructions. + + Without a run cwd, preserve the existing workspace-root-relative representation. With a + run cwd, use an absolute sandbox path so the resource remains addressable after a shell + command selects a nested workdir or changes directory. + """ + + if isinstance(workspace_relative_path, str) and "\\" in workspace_relative_path: + raise ValueError("session resource paths must use POSIX path separators") + if windows_absolute_path(workspace_relative_path) is not None: + raise ValueError("session resource paths must be workspace-relative") + + relative_path = coerce_posix_path(workspace_relative_path) + if relative_path.is_absolute() or ".." in relative_path.parts: + raise ValueError("session resource paths must be workspace-relative") + if relative_path.parts in [(), (".",)]: + raise ValueError("session resource paths must be non-empty") + + normalized_path = PurePosixPath(posixpath.normpath(relative_path.as_posix())) + if self.cwd is None: + return normalized_path + + windows_root = windows_absolute_path(workspace_root) + if windows_root is not None: + root_path = PurePosixPath(windows_root.as_posix()) + else: + if isinstance(workspace_root, str) and "\\" in workspace_root: + raise ValueError("sandbox workspace root must be POSIX absolute") + root_path = coerce_posix_path(workspace_root) + if not root_path.is_absolute(): + raise ValueError("sandbox workspace root must be POSIX absolute") + return PurePosixPath(posixpath.normpath((root_path / normalized_path).as_posix())) + + def display_path( + self, + *, + original_path: str | PurePath, + workspace_relative_path: str | PurePath, + ) -> PurePosixPath: + """Render a tool result path without changing existing absolute-input display behavior.""" + + relative_path = coerce_posix_path(workspace_relative_path) + if self.cwd is None or _is_absolute_sandbox_path(original_path): + return PurePosixPath(posixpath.normpath(relative_path.as_posix())) + return self.model_path(relative_path) + + def _native_path_from_windows_absolute(path: PureWindowsPath) -> Path | None: native_path = Path(path) return native_path if native_path.is_absolute() else None diff --git a/tests/sandbox/capabilities/test_apply_patch_tool.py b/tests/sandbox/capabilities/test_apply_patch_tool.py index 5948cded24..db877dea7f 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -3,11 +3,12 @@ import asyncio import json from collections.abc import Awaitable -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any, cast import pytest +import agents.sandbox.apply_patch as sandbox_apply_patch from agents import Agent, CustomTool, RunHooks from agents.editor import ApplyPatchOperation, ApplyPatchResult from agents.items import ToolApprovalItem, ToolCallOutputItem @@ -16,11 +17,14 @@ from agents.run_context import RunContextWrapper from agents.run_internal.run_steps import ToolRunCustom from agents.run_internal.tool_actions import CustomToolAction +from agents.sandbox import SandboxWorkspaceScope from agents.sandbox.capabilities.tools import SandboxApplyPatchTool +from agents.sandbox.errors import ApplyPatchDecodeError, ApplyPatchFileNotFoundError from agents.sandbox.types import User from agents.testing import scripted_sandbox_session from tests.sandbox._apply_patch_test_session import ( ApplyPatchSession, + ProviderNotFoundApplyPatchSession, UserRecordingApplyPatchSession, ) from tests.utils.hitl import make_context_wrapper @@ -263,6 +267,207 @@ async def test_editor_create_update_delete_round_trip(self) -> None: assert delete_result.output == "Deleted notes.txt" assert Path("/workspace/notes.txt") not in session.files + @pytest.mark.asyncio + async def test_editor_scopes_paths_and_move_outputs_to_run_cwd(self) -> None: + session = ApplyPatchSession() + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + create_result = await tool.editor.create_file( + ApplyPatchOperation( + type="create_file", + path="notes.txt", + diff="+hello\n", + ) + ) + move_result = await tool.editor.update_file( + ApplyPatchOperation( + type="update_file", + path="notes.txt", + diff="@@\n-hello\n+hi\n", + move_to="archive/notes.txt", + ) + ) + + assert create_result.output == "Created notes.txt" + assert move_result.output == "Updated notes.txt\nMoved notes.txt to archive/notes.txt" + assert Path("/workspace/tasks/a/notes.txt") not in session.files + assert session.files[Path("/workspace/tasks/a/archive/notes.txt")] == b"hi" + + @pytest.mark.asyncio + async def test_editor_keeps_absolute_path_behavior_with_workspace_scope(self) -> None: + session = ApplyPatchSession() + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + result = await tool.editor.create_file( + ApplyPatchOperation( + type="create_file", + path="/workspace/root.txt", + diff="+root\n", + ) + ) + + assert result.output == "Created root.txt" + assert session.files[Path("/workspace/root.txt")] == b"root" + + @pytest.mark.asyncio + async def test_editor_scoped_missing_error_uses_model_relative_path(self) -> None: + session = ProviderNotFoundApplyPatchSession() + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + with pytest.raises(ApplyPatchFileNotFoundError) as exc_info: + await tool.editor.update_file( + ApplyPatchOperation( + type="update_file", + path="missing.txt", + diff="@@\n-old\n+new\n", + ) + ) + + assert str(exc_info.value) == "apply_patch missing file: missing.txt" + assert exc_info.value.context["path"] == "missing.txt" + assert "/provider/private/root" not in str(exc_info.value) + + @pytest.mark.asyncio + async def test_editor_scoped_decode_error_uses_posix_model_relative_path_on_windows( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_apply_patch, "Path", PureWindowsPath) + session = ApplyPatchSession() + session.files[Path("/workspace/tasks/a/nested/binary.txt")] = b"\xff\xfe\xfd" + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + with pytest.raises(ApplyPatchDecodeError) as exc_info: + await tool.editor.update_file( + ApplyPatchOperation( + type="update_file", + path="nested/binary.txt", + diff="@@\n+replacement\n", + ) + ) + + assert str(exc_info.value) == "apply_patch could not decode file: nested/binary.txt" + assert exc_info.value.context["path"] == "nested/binary.txt" + assert "/workspace/tasks/a" not in str(exc_info.value) + + @pytest.mark.asyncio + async def test_editor_scoped_decode_error_keeps_posix_absolute_path_on_windows( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_apply_patch, "Path", PureWindowsPath) + session = ApplyPatchSession() + session.files[Path("/workspace/root/nested/binary.txt")] = b"\xff\xfe\xfd" + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + with pytest.raises(ApplyPatchDecodeError) as exc_info: + await tool.editor.update_file( + ApplyPatchOperation( + type="update_file", + path="/workspace/root/nested/binary.txt", + diff="@@\n+replacement\n", + ) + ) + + expected_path = "/workspace/root/nested/binary.txt" + assert str(exc_info.value) == f"apply_patch could not decode file: {expected_path}" + assert exc_info.value.context["path"] == expected_path + + @pytest.mark.asyncio + async def test_scoped_tool_approval_receives_execution_canonical_paths(self) -> None: + checked_operations: list[tuple[str, str | None]] = [] + + async def needs_approval( + _ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str + ) -> bool: + checked_operations.append((operation.path, operation.move_to)) + return False + + session = ApplyPatchSession() + session.files[Path("/workspace/tasks/a/notes.txt")] = b"old\n" + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + needs_approval=needs_approval, + ) + + result = await _execute_custom_tool_call( + tool, + context_wrapper=make_context_wrapper(), + raw_input=( + "*** Begin Patch\n" + "*** Update File: notes.txt\n" + "*** Move to: moved.txt\n" + "@@\n" + "-old\n" + "+new\n" + "*** End Patch\n" + ), + ) + + assert checked_operations == [("tasks/a/notes.txt", "tasks/a/moved.txt")] + assert result.output == "Updated notes.txt\nMoved notes.txt to moved.txt" + assert session.files[Path("/workspace/tasks/a/moved.txt")] == b"new\n" + + @pytest.mark.asyncio + async def test_scoped_custom_tool_keeps_absolute_path_at_workspace_root(self) -> None: + session = ApplyPatchSession() + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + result = await _execute_custom_tool_call( + tool, + context_wrapper=make_context_wrapper(), + raw_input=( + "*** Begin Patch\n*** Add File: /workspace/root.txt\n+root\n*** End Patch\n" + ), + ) + + assert result.output == "Created root.txt" + assert session.files[Path("/workspace/root.txt")] == b"root" + assert Path("/workspace/tasks/a/root.txt") not in session.files + + @pytest.mark.asyncio + async def test_direct_session_apply_patch_remains_workspace_root_relative(self) -> None: + session = ApplyPatchSession() + tool = SandboxApplyPatchTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + await session.apply_patch( + ApplyPatchOperation( + type="create_file", + path="direct.txt", + diff="+root\n", + ) + ) + await tool.editor.create_file( + ApplyPatchOperation( + type="create_file", + path="tool.txt", + diff="+scoped\n", + ) + ) + + assert session.files[Path("/workspace/direct.txt")] == b"root" + assert session.files[Path("/workspace/tasks/a/tool.txt")] == b"scoped" + @pytest.mark.asyncio async def test_editor_runs_file_operations_as_bound_user(self) -> None: session = UserRecordingApplyPatchSession() diff --git a/tests/sandbox/capabilities/test_filesystem_capability.py b/tests/sandbox/capabilities/test_filesystem_capability.py index 6bd3b5580f..081667cded 100644 --- a/tests/sandbox/capabilities/test_filesystem_capability.py +++ b/tests/sandbox/capabilities/test_filesystem_capability.py @@ -7,7 +7,7 @@ import pytest from agents.editor import ApplyPatchOperation -from agents.sandbox import Manifest +from agents.sandbox import Manifest, SandboxWorkspaceScope from agents.sandbox.capabilities import Filesystem, FilesystemToolSet from agents.sandbox.capabilities.tools import SandboxApplyPatchTool, ViewImageTool from agents.sandbox.sandboxes.unix_local import ( @@ -87,6 +87,7 @@ def configure_tools(toolset: FilesystemToolSet) -> None: nonlocal replacement_view_image replacement_view_image = ViewImageTool( session=toolset.view_image.session, + workspace_scope=toolset.workspace_scope, needs_approval=True, ) toolset.view_image = replacement_view_image @@ -102,6 +103,23 @@ def configure_tools(toolset: FilesystemToolSet) -> None: assert view_image_tool.needs_approval is True assert isinstance(tools[1], SandboxApplyPatchTool) + def test_tools_and_configurator_receive_bound_workspace_scope(self, tmp_path: Path) -> None: + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + configured_scopes: list[SandboxWorkspaceScope] = [] + + def configure_tools(toolset: FilesystemToolSet) -> None: + configured_scopes.append(toolset.workspace_scope) + + capability = Filesystem(configure_tools=configure_tools) + capability.bind(_make_session(tmp_path)) + capability.bind_workspace_scope(scope) + + tools = capability.tools() + + assert configured_scopes == [scope] + assert cast(ViewImageTool, tools[0]).workspace_scope is scope + assert cast(SandboxApplyPatchTool, tools[1]).workspace_scope is scope + def test_tools_passes_bound_run_as_to_file_tools(self, tmp_path: Path) -> None: run_as = User(name="sandbox-user") capability = Filesystem() diff --git a/tests/sandbox/capabilities/test_shell_capability.py b/tests/sandbox/capabilities/test_shell_capability.py index 38ff54c2a9..45375561a9 100644 --- a/tests/sandbox/capabilities/test_shell_capability.py +++ b/tests/sandbox/capabilities/test_shell_capability.py @@ -5,7 +5,7 @@ import pytest -from agents.sandbox import Manifest, SandboxPathGrant +from agents.sandbox import Manifest, SandboxPathGrant, SandboxWorkspaceScope from agents.sandbox.capabilities import Shell, ShellToolSet from agents.sandbox.capabilities.tools import ( ExecCommandArgs, @@ -196,6 +196,23 @@ def configure_tools(toolset: ShellToolSet) -> None: assert exec_command_tool is replacement_exec_command assert exec_command_tool.needs_approval is True + def test_configure_tools_receives_workspace_scope(self) -> None: + observed_scope: SandboxWorkspaceScope | None = None + + def configure_tools(toolset: ShellToolSet) -> None: + nonlocal observed_scope + observed_scope = toolset.workspace_scope + + capability = Shell(configure_tools=configure_tools) + capability.bind(_shell_session()) + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + capability.bind_workspace_scope(scope) + + tool = cast(ExecCommandTool, capability.tools()[0]) + + assert observed_scope is scope + assert tool.workspace_scope is scope + @pytest.mark.asyncio async def test_instructions_match_sandbox_shell_guidance(self) -> None: capability = Shell() @@ -263,6 +280,7 @@ async def test_exec_command_tool_runs_as_bound_user(self) -> None: ) capability.bind(session) capability.bind_run_as(User(name="sandbox-user")) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) tool = cast(FunctionTool, capability.tools()[0]) await tool.on_invoke_tool( @@ -270,6 +288,7 @@ async def test_exec_command_tool_runs_as_bound_user(self) -> None: ExecCommandArgs(cmd="pwd").model_dump_json(), ) + assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) assert session.calls[0].kwargs["user"] == User(name="sandbox-user") session.assert_complete() @@ -346,6 +365,57 @@ async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell( "stderr: cd /workspace/src/project && pwd" ) + @pytest.mark.asyncio + @pytest.mark.parametrize("workdir", [None, "", " "]) + async def test_exec_command_tool_defaults_to_workspace_scope_cwd( + self, + workdir: str | None, + ) -> None: + capability = Shell() + session = _shell_session() + capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) + tool = cast(FunctionTool, capability.tools()[0]) + + await tool.on_invoke_tool( + cast(ToolContext[object], None), + ExecCommandArgs(cmd="pwd", workdir=workdir).model_dump_json(), + ) + + assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) + + @pytest.mark.asyncio + async def test_exec_command_tool_resolves_relative_workdir_from_workspace_scope(self) -> None: + capability = Shell() + session = _shell_session() + capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) + tool = cast(FunctionTool, capability.tools()[0]) + + await tool.on_invoke_tool( + cast(ToolContext[object], None), + ExecCommandArgs(cmd="pwd", workdir="src/project").model_dump_json(), + ) + + assert session.calls[0].args == ("cd /workspace/tasks/a/src/project && pwd",) + + @pytest.mark.asyncio + async def test_exec_command_tool_normalizes_raw_backslashes_before_workspace_scope( + self, + ) -> None: + capability = Shell() + session = _shell_session() + capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) + tool = cast(FunctionTool, capability.tools()[0]) + + await tool.on_invoke_tool( + cast(ToolContext[object], None), + ExecCommandArgs(cmd="pwd", workdir=r"src\project").model_dump_json(), + ) + + assert session.calls[0].args == ("cd /workspace/tasks/a/src/project && pwd",) + @pytest.mark.asyncio async def test_exec_command_tool_allows_split_path_grant_workdir( self, @@ -365,6 +435,7 @@ async def test_exec_command_tool_allows_split_path_grant_workdir( ) ) capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) tool = cast(FunctionTool, capability.tools()[0]) _patch_shell_tool_clock( monkeypatch, @@ -415,6 +486,7 @@ async def test_exec_command_tool_uses_pty_when_supported( ] ) capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) tool = cast(FunctionTool, capability.tools()[0]) _patch_shell_tool_clock( monkeypatch, @@ -428,6 +500,7 @@ async def test_exec_command_tool_uses_pty_when_supported( ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(), ) + assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) assert session.calls[0].kwargs["yield_time_s"] == 0.0 assert ( output == "Chunk ID: abcdef\n" @@ -509,7 +582,10 @@ async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_trans }, ] ) - tool = ExecCommandTool(session=session) + tool = ExecCommandTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) _patch_shell_tool_clock( monkeypatch, chunk_id="44444444444444444444444444444444", @@ -526,6 +602,8 @@ async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_trans assert "Process exited with code 0" in output assert "Process running with session ID" not in output assert "fallback ok" in output + assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) + assert session.calls[1].args == ("cd /workspace/tasks/a && pwd",) @pytest.mark.asyncio async def test_exec_command_tool_does_not_fall_back_for_tty_sessions(self) -> None: diff --git a/tests/sandbox/capabilities/test_skills_capability.py b/tests/sandbox/capabilities/test_skills_capability.py index cb2961efe0..417e32cff3 100644 --- a/tests/sandbox/capabilities/test_skills_capability.py +++ b/tests/sandbox/capabilities/test_skills_capability.py @@ -2,13 +2,19 @@ import io import uuid -from pathlib import Path +from pathlib import Path, PurePath, PureWindowsPath from typing import cast import pytest from agents.sandbox import Manifest, SandboxPathGrant -from agents.sandbox.capabilities import LocalDirLazySkillSource, Skill, Skills +from agents.sandbox.capabilities import ( + LazySkillSource, + LocalDirLazySkillSource, + Skill, + SkillMetadata, + Skills, +) from agents.sandbox.entries import Dir, File, LocalDir from agents.sandbox.errors import ( SkillsConfigError, @@ -20,7 +26,11 @@ from agents.sandbox.session.sandbox_session import SandboxSession from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult, FileMode, Group, Permissions, User -from agents.sandbox.workspace_paths import coerce_posix_path, sandbox_path_str +from agents.sandbox.workspace_paths import ( + SandboxWorkspaceScope, + coerce_posix_path, + sandbox_path_str, +) from agents.testing import scripted_sandbox_session from agents.tool import FunctionTool from agents.tool_context import ToolContext @@ -47,6 +57,39 @@ def _user_name(user: object) -> str | None: return str(user) +class _StaticResultLazySkillSource(LazySkillSource): + result: dict[str, str] + metadata_path: PurePath | None = None + + def list_skill_metadata( + self, + *, + skills_path: str, + source_grants: tuple[SandboxPathGrant, ...] = (), + ) -> list[SkillMetadata]: + _ = (skills_path, source_grants) + if self.metadata_path is None: + return [] + return [ + SkillMetadata( + name="dynamic-skill", + description="dynamic description", + path=self.metadata_path, + ) + ] + + async def load_skill( + self, + *, + skill_name: str, + session: BaseSandboxSession, + skills_path: str, + user: str | User | None = None, + ) -> dict[str, str]: + _ = (skill_name, session, skills_path, user) + return dict(self.result) + + class _SkillsSession(BaseSandboxSession): def __init__(self, manifest: Manifest) -> None: self.state = TestSessionState( @@ -392,6 +435,7 @@ async def test_instructions_include_root_and_literal_index(self) -> None: assert "### How to use skills" in instructions assert "- a-skill: a description (file: .agents/a-skill)" in instructions assert "- z-skill: z description (file: .agents/z-skill)" in instructions + assert "### Run-scoped skill paths" not in instructions assert instructions.index( "- a-skill: a description (file: .agents/a-skill)" ) < instructions.index("- z-skill: z description (file: .agents/z-skill)") @@ -408,6 +452,20 @@ async def test_instructions_use_custom_skills_path(self) -> None: assert instructions is not None assert "- my-skill: desc (file: .sandbox/skills/my-skill)" in instructions + @pytest.mark.asyncio + async def test_instructions_render_session_owned_paths_as_absolute_with_run_cwd(self) -> None: + capability = Skills( + skills=[Skill(name="my-skill", description="desc", content="literal")], + ) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/task-a")) + + instructions = await capability.instructions(Manifest(root="/workspace")) + + assert instructions is not None + assert "- my-skill: desc (file: /workspace/.agents/my-skill)" in instructions + assert "Treat each listed path as the skill root" in instructions + assert "write task inputs, outputs, caches, and temporary files" in instructions + @pytest.mark.asyncio async def test_instructions_return_none_when_metadata_is_empty(self) -> None: capability = Skills(from_=Dir()) @@ -457,12 +515,14 @@ async def test_instructions_resolve_from_runtime_frontmatter(self, tmp_path: Pat session = _SkillsSession(manifest) await session.apply_manifest() capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/task-a")) instructions = await capability.instructions(session.state.manifest) assert instructions is not None assert ( - "- discovered-skill: loaded from runtime frontmatter (file: .agents/dynamic-skill)" + "- discovered-skill: loaded from runtime frontmatter " + f"(file: {workspace_root.as_posix()}/.agents/dynamic-skill)" ) in instructions @pytest.mark.asyncio @@ -557,6 +617,35 @@ async def test_lazy_local_dir_load_skill_tool_materializes_single_skill( loaded_skill = workspace_root / ".agents" / "dynamic-skill" / "SKILL.md" assert loaded_skill.read_text(encoding="utf-8") == "# dynamic skill\n" + @pytest.mark.asyncio + async def test_lazy_load_reports_absolute_path_without_relocating_skill( + self, tmp_path: Path + ) -> None: + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + src_root = tmp_path / "skills" + skill_dir = src_root / "dynamic-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8") + capability = Skills( + lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)), + ) + session = _SkillsSession( + capability.process_manifest(_source_granted_manifest(workspace_root, source=src_root)) + ) + capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/task-a")) + + output = await capability.load_skill("dynamic-skill") + + assert output == { + "status": "loaded", + "skill_name": "dynamic-skill", + "path": f"{workspace_root.as_posix()}/.agents/dynamic-skill", + } + assert (workspace_root / ".agents" / "dynamic-skill" / "SKILL.md").is_file() + assert not (workspace_root / "tasks" / "task-a" / ".agents").exists() + @pytest.mark.asyncio async def test_lazy_local_dir_load_skill_applies_source_metadata(self, tmp_path: Path) -> None: workspace_root = tmp_path / "workspace" @@ -622,6 +711,75 @@ async def test_lazy_local_dir_load_skill_keeps_default_permissions( class TestSkillsLazyLoading: + @pytest.mark.asyncio + async def test_custom_lazy_result_is_unchanged_without_run_cwd(self) -> None: + expected = {"status": "loaded", "detail": "opaque"} + capability = Skills(lazy_from=_StaticResultLazySkillSource(result=expected)) + capability.bind(scripted_sandbox_session(manifest=Manifest(root="/workspace"))) + + output = await capability.load_skill("dynamic-skill") + + assert output == expected + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("result", "reason"), + [ + ({"status": "loaded"}, "missing"), + ({"status": "loaded", "path": "../escape"}, "invalid"), + ({"status": "loaded", "path": r".agents\dynamic-skill"}, "invalid"), + ], + ) + async def test_custom_lazy_result_requires_valid_path_with_run_cwd( + self, + result: dict[str, str], + reason: str, + ) -> None: + capability = Skills(lazy_from=_StaticResultLazySkillSource(result=result)) + capability.bind(scripted_sandbox_session(manifest=Manifest(root="/workspace"))) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/task-a")) + + with pytest.raises(SkillsConfigError) as exc_info: + await capability.load_skill("dynamic-skill") + + assert exc_info.value.message == ( + "skill path must be non-empty and workspace-relative when sandbox.cwd is configured" + ) + assert exc_info.value.context["skill_name"] == "dynamic-skill" + assert exc_info.value.context["field"] == "path" + assert exc_info.value.context["reason"] == reason + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "metadata_path", + [Path("../outside"), PureWindowsPath("../outside")], + ) + async def test_custom_lazy_metadata_reports_invalid_scoped_path_as_config_error( + self, + metadata_path: PurePath, + ) -> None: + capability = Skills( + lazy_from=_StaticResultLazySkillSource( + result={"status": "loaded", "path": ".agents/dynamic-skill"}, + metadata_path=metadata_path, + ) + ) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/task-a")) + + with pytest.raises(SkillsConfigError) as exc_info: + await capability.instructions(Manifest(root="/workspace")) + + assert exc_info.value.message == ( + "skill path must be non-empty and workspace-relative when sandbox.cwd is configured" + ) + assert exc_info.value.context == { + "skill_name": "dynamic-skill", + "field": "path", + "path": "../outside", + "reason": "invalid", + } + assert isinstance(exc_info.value.cause, ValueError) + def test_tools_returns_empty_without_lazy_source(self) -> None: capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")]) diff --git a/tests/sandbox/capabilities/test_view_image_tool.py b/tests/sandbox/capabilities/test_view_image_tool.py index 64ecd403e6..5fc93f6bac 100644 --- a/tests/sandbox/capabilities/test_view_image_tool.py +++ b/tests/sandbox/capabilities/test_view_image_tool.py @@ -7,8 +7,8 @@ import pytest -from agents.sandbox import Manifest, SandboxPathGrant -from agents.sandbox.capabilities.tools import ViewImageTool +from agents.sandbox import Manifest, SandboxPathGrant, SandboxWorkspaceScope +from agents.sandbox.capabilities.tools import ViewImageArgs, ViewImageTool from agents.sandbox.errors import InvalidManifestPathError, WorkspaceReadNotFoundError from agents.sandbox.types import User from agents.testing import scripted_sandbox_session @@ -81,6 +81,109 @@ async def test_view_image_still_rejects_ungranted_absolute_path(self) -> None: assert session.calls == () + @pytest.mark.asyncio + async def test_view_image_resolves_relative_path_from_workspace_scope(self) -> None: + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(_PNG_BYTES)}], + manifest=Manifest(root="/workspace"), + ) + tool = ViewImageTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + output = await tool.on_invoke_tool( + cast(ToolContext[object], None), + '{"path":"images/dot.png"}', + ) + + assert isinstance(output, ToolOutputImage) + assert session.calls[0].args == (Path("/workspace/tasks/a/images/dot.png"),) + session.assert_complete() + + @pytest.mark.asyncio + async def test_view_image_normalizes_raw_backslashes_before_workspace_scope(self) -> None: + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(_PNG_BYTES)}], + manifest=Manifest(root="/workspace"), + ) + tool = ViewImageTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + output = await tool.run(ViewImageArgs(path=r"images\dot.png")) + + assert isinstance(output, ToolOutputImage) + assert session.calls[0].args == (Path("/workspace/tasks/a/images/dot.png"),) + session.assert_complete() + + @pytest.mark.asyncio + async def test_view_image_keeps_absolute_path_behavior_with_workspace_scope(self) -> None: + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(b"hello\n")}], + manifest=Manifest(root="/workspace"), + ) + tool = ViewImageTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + output = await tool.on_invoke_tool( + cast(ToolContext[object], None), + '{"path":"/workspace/notes.txt"}', + ) + + assert output == "image path `notes.txt` is not a supported image file" + assert session.calls[0].args == (Path("/workspace/notes.txt"),) + session.assert_complete() + + @pytest.mark.asyncio + async def test_view_image_reports_raw_posix_absolute_path_after_normalization(self) -> None: + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(b"hello\n")}], + manifest=Manifest(root="/workspace"), + ) + tool = ViewImageTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + output = await tool.run(ViewImageArgs(path=r"\workspace\root.txt")) + + assert output == "image path `root.txt` is not a supported image file" + assert session.calls[0].args == (Path("/workspace/root.txt"),) + session.assert_complete() + + @pytest.mark.asyncio + async def test_view_image_scoped_error_uses_model_relative_path(self) -> None: + provider_root = Path("/provider/private/root") + session = scripted_sandbox_session( + [ + { + "method": "read", + "error": WorkspaceReadNotFoundError( + path=provider_root / "tasks/a/images/missing.png" + ), + } + ], + manifest=Manifest(root="/workspace"), + ) + tool = ViewImageTool( + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + output = await tool.on_invoke_tool( + cast(ToolContext[object], None), + '{"path":"images/missing.png"}', + ) + + assert output == "image path `images/missing.png` was not found" + assert str(provider_root) not in output + assert session.calls[0].args == (Path("/workspace/tasks/a/images/missing.png"),) + session.assert_complete() + @pytest.mark.asyncio async def test_view_image_reads_as_bound_user(self) -> None: session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(_PNG_BYTES)}]) diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index 0e3a2bcea4..ab68c3f8fb 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -115,6 +115,7 @@ def test_core_sandbox_public_export_surface_is_stable() -> None: "SandboxAgent", "SandboxArchiveLimits", "SandboxPathGrant", + "SandboxWorkspaceScope", "SandboxConcurrencyLimits", "SandboxError", "SandboxRunConfig", @@ -368,6 +369,7 @@ def test_sandbox_dataclass_constructor_field_order_is_stable() -> None: "snapshot", "concurrency_limits", "archive_limits", + "cwd", ) diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index 75ad621124..bfd7d3663e 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -75,6 +75,7 @@ ) from agents.sandbox.runtime import _stream_memory_input_override from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient +from agents.sandbox.workspace_paths import SandboxWorkspaceScope from agents.testing import ScriptedModel from tests.test_responses import get_final_output_message, get_text_message from tests.utils.hitl import make_shell_call @@ -989,6 +990,99 @@ async def test_memory_capability_live_update_instructions() -> None: await client.delete(session) +@pytest.mark.asyncio +async def test_memory_capability_renders_session_owned_paths_as_absolute_with_run_cwd() -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + capability = Memory(generate=None) + + try: + async with session: + await session.mkdir("memories", parents=True) + await session.write( + Path("memories/memory_summary.md"), + io.BytesIO(b"summary entry"), + ) + capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/task-a")) + + instructions = await capability.instructions(session.state.manifest) + + assert instructions is not None + workspace_root = session.state.manifest.root + assert ( + f"{workspace_root}/memories/memory_summary.md " + "(already provided below; do NOT open again)" in instructions + ) + assert f"{workspace_root}/memories/MEMORY.md" in instructions + assert "summary entry" in instructions + finally: + await client.delete(session) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("memories_dir", [r"team\memory", "team//memory"]) +async def test_memory_capability_preserves_layout_spelling_without_run_cwd( + memories_dir: str, +) -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + capability = Memory( + layout=MemoryLayoutConfig(memories_dir=memories_dir), + generate=None, + ) + + try: + async with session: + await session.mkdir(Path(memories_dir), parents=True) + await session.write( + Path(memories_dir) / "memory_summary.md", + io.BytesIO(b"summary entry"), + ) + capability.bind(session) + + instructions = await capability.instructions(session.state.manifest) + + assert instructions is not None + assert f"{memories_dir}/memory_summary.md" in instructions + assert f"{memories_dir}/MEMORY.md" in instructions + finally: + await client.delete(session) + + +@pytest.mark.asyncio +async def test_memory_capability_uses_typed_layout_path_with_run_cwd() -> None: + memories_dir = r"team\memory" + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + capability = Memory( + layout=MemoryLayoutConfig(memories_dir=memories_dir), + generate=None, + ) + + try: + async with session: + memory_dir_path = Path(memories_dir) + await session.mkdir(memory_dir_path, parents=True) + await session.write( + memory_dir_path / "memory_summary.md", + io.BytesIO(b"summary entry"), + ) + capability.bind(session) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/task-a")) + + instructions = await capability.instructions(session.state.manifest) + + assert instructions is not None + workspace_root = session.state.manifest.root + assert ( + f"{workspace_root}/{memory_dir_path.as_posix()}/memory_summary.md" in instructions + ) + assert f"{workspace_root}/{memory_dir_path.as_posix()}/MEMORY.md" in instructions + finally: + await client.delete(session) + + @pytest.mark.asyncio async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: client = UnixLocalSandboxClient() diff --git a/tests/sandbox/test_run_cwd.py b/tests/sandbox/test_run_cwd.py new file mode 100644 index 0000000000..234d7019f7 --- /dev/null +++ b/tests/sandbox/test_run_cwd.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import asyncio +import base64 +import io +import shlex +import sys +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +from openai.types.responses import ResponseCustomToolCall + +from agents import RunConfig, Runner, ToolOutputImage +from agents.items import ToolCallOutputItem +from agents.run_state import RunState +from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig +from agents.sandbox.capabilities import ( + Filesystem, + FilesystemToolSet, + Shell, + ShellToolSet, + Skill, + Skills, +) +from agents.sandbox.entries import File +from agents.sandbox.errors import WorkspaceReadNotFoundError +from agents.sandbox.session.base_sandbox_session import BaseSandboxSession +from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call + +if TYPE_CHECKING or sys.platform != "win32": + from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient + +_PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a84QAAAAASUVORK5CYII=" +) +_SVG_BYTES = b'' +_IMAGE_BY_TASK = { + "task-a": ("image/png", _PNG_BYTES), + "task-b": ("image/svg+xml", _SVG_BYTES), +} + + +async def _read_bytes(session: BaseSandboxSession, path: str) -> bytes: + file_obj = await session.read(Path(path)) + try: + payload = file_obj.read() + finally: + file_obj.close() + return payload if isinstance(payload, bytes) else payload.encode("utf-8") + + +@pytest.mark.asyncio +@pytest.mark.skipif(sys.platform == "win32", reason="Unix local sandbox is unavailable on Windows") +async def test_concurrent_runs_scope_relative_paths_with_shared_live_session() -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + both_runs_ready = asyncio.Event() + ready_count = 0 + ready_lock = asyncio.Lock() + + def first_step(task_name: str) -> Callable[[Any], Awaitable[list[Any]]]: + async def respond(_call: Any) -> list[Any]: + nonlocal ready_count + async with ready_lock: + ready_count += 1 + if ready_count == 2: + both_runs_ready.set() + await asyncio.wait_for(both_runs_ready.wait(), timeout=5) + return [ + function_call( + "exec_command", + {"cmd": "cp seed.png plot.png", "login": False}, + call_id=f"{task_name}_shell", + ) + ] + + return respond + + def build_model(task_name: str) -> ScriptedModel: + return ScriptedModel( + [ + ModelStep.respond(first_step(task_name)), + [ + function_call( + "view_image", + {"path": "plot.png"}, + call_id=f"{task_name}_image", + ) + ], + [ + ResponseCustomToolCall( + id=f"{task_name}_patch_item", + type="custom_tool_call", + name="apply_patch", + call_id=f"{task_name}_patch", + input=( + "*** Begin Patch\n" + "*** Add File: notes.md\n" + f"+{task_name}\n" + "*** End Patch\n" + ), + ) + ], + [assistant_message("done", item_id=f"{task_name}_message")], + ] + ) + + models = {task_name: build_model(task_name) for task_name in ("task-a", "task-b")} + agents = { + task_name: SandboxAgent( + name=task_name, + model=models[task_name], + capabilities=[Shell(), Filesystem()], + ) + for task_name in models + } + + try: + async with session: + for task_name in models: + task_dir = f"tasks/{task_name}" + await session.mkdir(task_dir, parents=True) + await session.write( + Path(task_dir) / "seed.png", + io.BytesIO(_IMAGE_BY_TASK[task_name][1]), + ) + + results = await asyncio.gather( + *( + Runner.run( + agents[task_name], + "Create the requested task-local artifacts.", + run_config=RunConfig( + sandbox=SandboxRunConfig( + session=session, + cwd=f"tasks/{task_name}", + ) + ), + ) + for task_name in models + ) + ) + + for task_name, result in zip(models, results, strict=True): + assert result.final_output == "done" + outputs = { + item.call_id: item.output + for item in result.new_items + if isinstance(item, ToolCallOutputItem) + } + image_output = outputs[f"{task_name}_image"] + assert isinstance(image_output, ToolOutputImage) + mime_type, image_bytes = _IMAGE_BY_TASK[task_name] + assert image_output.image_url == ( + f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('ascii')}" + ) + assert outputs[f"{task_name}_patch"] == "Created notes.md" + assert await _read_bytes(session, f"tasks/{task_name}/plot.png") == image_bytes + assert ( + await _read_bytes(session, f"tasks/{task_name}/notes.md") == task_name.encode() + ) + models[task_name].assert_complete() + + for root_relative_path in ("plot.png", "notes.md"): + with pytest.raises(WorkspaceReadNotFoundError): + await session.read(Path(root_relative_path)) + finally: + await client.delete(session) + + +@pytest.mark.asyncio +@pytest.mark.skipif(sys.platform == "win32", reason="Unix local sandbox is unavailable on Windows") +async def test_python_skill_uses_absolute_root_from_nested_workdir() -> None: + skill_script = ( + b"from pathlib import Path\n" + b"skill_root = Path(__file__).parent.parent\n" + b"suffix = (skill_root / 'assets' / 'suffix.txt').read_text(encoding='utf-8')\n" + b"source = Path('input.txt').read_text(encoding='utf-8')\n" + b"Path('output.txt').write_text(source + suffix, encoding='utf-8')\n" + ) + skills = Skills( + skills=[ + Skill( + name="python-proof", + description="Proves shared Python skills keep task files local.", + content="# Python proof\n", + scripts={"prove.py": File(content=skill_script)}, + assets={"suffix.txt": File(content=b"-from-shared-skill")}, + ) + ] + ) + client = UnixLocalSandboxClient() + session = await client.create(manifest=skills.process_manifest(Manifest())) + + try: + async with session: + await session.mkdir("tasks/task-a/nested", parents=True) + await session.write( + Path("tasks/task-a/nested/input.txt"), + io.BytesIO(b"task-a"), + ) + workspace_root = session.state.manifest.root + skill_root = f"{workspace_root}/.agents/python-proof" + script_path = f"{skill_root}/scripts/prove.py" + model = ScriptedModel( + [ + [ + function_call( + "exec_command", + { + "cmd": ( + f"{shlex.quote(sys.executable)} {shlex.quote(script_path)}" + ), + "workdir": "nested", + "login": False, + }, + call_id="python_skill", + ) + ], + [assistant_message("done", item_id="python_skill_message")], + ] + ) + agent = SandboxAgent( + name="python-skill-task", + model=model, + capabilities=[Shell(), skills], + ) + + result = await Runner.run( + agent, + "Use the available Python skill.", + run_config=RunConfig(sandbox=SandboxRunConfig(session=session, cwd="tasks/task-a")), + ) + + assert result.final_output == "done" + assert await _read_bytes(session, "tasks/task-a/nested/output.txt") == ( + b"task-a-from-shared-skill" + ) + assert ( + await _read_bytes(session, ".agents/python-proof/scripts/prove.py") == skill_script + ) + instructions = model.calls[0].system_instructions + assert instructions is not None + assert f"(file: {skill_root})" in instructions + assert "Treat each listed path as the skill root" in instructions + assert "Files outside the working directory may be visible to or shared" in instructions + model.assert_complete() + finally: + await client.delete(session) + + +@pytest.mark.asyncio +@pytest.mark.skipif(sys.platform == "win32", reason="Unix local sandbox is unavailable on Windows") +async def test_resumed_run_rebinds_cwd_to_pending_sandbox_tool() -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + + def require_exec_approval(toolset: ShellToolSet) -> None: + toolset.exec_command.needs_approval = True + + model = ScriptedModel( + [ + [ + function_call( + "exec_command", + {"cmd": "printf resumed > marker.txt", "login": False}, + call_id="resumed_shell", + ) + ], + [assistant_message("done", item_id="resumed_message")], + ] + ) + agent = SandboxAgent( + name="resumed-task", + model=model, + capabilities=[Shell(configure_tools=require_exec_approval)], + ) + run_config = RunConfig(sandbox=SandboxRunConfig(session=session, cwd="tasks/resumed-task")) + + try: + async with session: + await session.mkdir("tasks/resumed-task", parents=True) + + first = await Runner.run(agent, "Create the marker.", run_config=run_config) + assert len(first.interruptions) == 1 + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed = await Runner.run(agent, state, run_config=run_config) + + assert resumed.final_output == "done" + assert await _read_bytes(session, "tasks/resumed-task/marker.txt") == b"resumed" + with pytest.raises(WorkspaceReadNotFoundError): + await session.read(Path("marker.txt")) + model.assert_complete() + finally: + await client.delete(session) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("serialize_state", [False, True], ids=["in-memory", "json"]) +@pytest.mark.skipif(sys.platform == "win32", reason="Unix local sandbox is unavailable on Windows") +async def test_resumed_apply_patch_uses_current_run_cwd(serialize_state: bool) -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest()) + + def require_apply_patch_approval(toolset: FilesystemToolSet) -> None: + toolset.apply_patch.needs_approval = True + + model = ScriptedModel( + [ + [ + ResponseCustomToolCall( + id="patch_item", + type="custom_tool_call", + name="apply_patch", + call_id="patch_call", + input=("*** Begin Patch\n*** Add File: marker.txt\n+resumed\n*** End Patch\n"), + ) + ], + [assistant_message("done", item_id="resumed_patch_message")], + ] + ) + agent = SandboxAgent( + name="resumed-patch-task", + model=model, + capabilities=[Filesystem(configure_tools=require_apply_patch_approval)], + ) + + try: + async with session: + await session.mkdir("tasks/a", parents=True) + await session.mkdir("tasks/b", parents=True) + + first = await Runner.run( + agent, + "Create the marker.", + run_config=RunConfig(sandbox=SandboxRunConfig(session=session, cwd="tasks/a")), + ) + assert len(first.interruptions) == 1 + state = first.to_state() + if serialize_state: + state = await RunState.from_json(agent, state.to_json()) + state.approve(state.get_interruptions()[0]) + + resumed = await Runner.run( + agent, + state, + run_config=RunConfig(sandbox=SandboxRunConfig(session=session, cwd="tasks/b")), + ) + + assert resumed.final_output == "done" + assert await _read_bytes(session, "tasks/b/marker.txt") == b"resumed" + with pytest.raises(WorkspaceReadNotFoundError): + await session.read(Path("tasks/a/marker.txt")) + model.assert_complete() + finally: + await client.delete(session) diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index fcef40cb0f..ef37d6fb01 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -12,7 +12,7 @@ import tempfile import uuid from collections.abc import Sequence -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any, ClassVar, Literal, TypedDict, cast import pytest @@ -55,6 +55,11 @@ Shell, StaticCompactionPolicy, ) +from agents.sandbox.capabilities.tools import ( + ExecCommandTool, + SandboxApplyPatchTool, + ViewImageTool, +) from agents.sandbox.entries import ( BaseEntry, DockerVolumeMountStrategy, @@ -209,6 +214,34 @@ async def _aclose_dependencies(self) -> None: await super()._aclose_dependencies() +class _CwdProbeSession(_FakeSession): + def __init__(self, manifest: Manifest, *, accessible: bool = True) -> None: + super().__init__(manifest) + self.accessible = accessible + self.cwd_probe_calls: list[tuple[tuple[str, ...], bool | list[str], User | str | None]] = [] + + async def exec( + self, + *command: str | Path, + timeout: float | None = None, + shell: bool | list[str] = True, + user: str | User | None = None, + ) -> ExecResult: + _ = timeout + self.cwd_probe_calls.append((tuple(str(part) for part in command), shell, user)) + return ExecResult( + stdout=b"", + stderr=b"" if self.accessible else b"not accessible", + exit_code=0 if self.accessible else 1, + ) + + +class _WindowsPathCwdProbeSession(_CwdProbeSession): + def normalize_path(self, path: Path | str, *, for_write: bool = False) -> Path: + _ = (path, for_write) + return cast(Path, PureWindowsPath("/workspace/tasks/a")) + + class _FailingStopSession(_FakeSession): async def stop(self) -> None: await super().stop() @@ -5863,6 +5896,192 @@ async def test_prepare_agent_rechecks_session_liveness_before_reusing_cached_age assert session.start_calls == 2 +@pytest.mark.asyncio +async def test_prepare_agent_revalidates_cwd_after_restarting_cached_session() -> None: + session = _CwdProbeSession(Manifest()) + client = _FakeClient(session) + agent = SandboxAgent( + name="sandbox", + model=ScriptedModel(steps=[[get_final_output_message("done")]]), + run_as="sandbox-user", + ) + runtime = SandboxRuntime( + starting_agent=agent, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options={"image": "sandbox"}, + cwd="tasks/a", + ) + ), + run_state=None, + ) + context_wrapper = RunContextWrapper(context=None) + + await runtime.prepare_agent( + current_agent=agent, + current_input="hello", + context_wrapper=context_wrapper, + is_resumed_state=False, + ) + session._running = False + session.accessible = False + + with pytest.raises( + UserError, + match=r"Sandbox working directory `tasks/a` does not exist or is not accessible", + ): + await runtime.prepare_agent( + current_agent=agent, + current_input="hello again", + context_wrapper=context_wrapper, + is_resumed_state=False, + ) + + assert session.start_calls == 2 + assert session.cwd_probe_calls == [ + (("test", "-d", "/workspace/tasks/a"), False, User(name="sandbox-user")), + (("test", "-x", "/workspace/tasks/a"), False, User(name="sandbox-user")), + (("test", "-d", "/workspace/tasks/a"), False, User(name="sandbox-user")), + ] + + +@pytest.mark.asyncio +async def test_prepare_agent_revalidates_cwd_when_cached_agent_run_as_changes() -> None: + session = _CwdProbeSession(Manifest()) + client = _FakeClient(session) + agent = SandboxAgent( + name="sandbox", + model=ScriptedModel(steps=[[get_final_output_message("done")]]), + run_as="first-user", + ) + runtime = SandboxRuntime( + starting_agent=agent, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options={"image": "sandbox"}, + cwd="tasks/a", + ) + ), + run_state=None, + ) + context_wrapper = RunContextWrapper(context=None) + + await runtime.prepare_agent( + current_agent=agent, + current_input="hello", + context_wrapper=context_wrapper, + is_resumed_state=False, + ) + agent.run_as = "second-user" + session.accessible = False + session.cwd_probe_calls.clear() + + with pytest.raises( + UserError, + match=r"Sandbox working directory `tasks/a` does not exist or is not accessible", + ): + await runtime.prepare_agent( + current_agent=agent, + current_input="hello again", + context_wrapper=context_wrapper, + is_resumed_state=False, + ) + + assert session.cwd_probe_calls == [ + ( + ("test", "-d", "/workspace/tasks/a"), + False, + User(name="second-user"), + ) + ] + + +@pytest.mark.asyncio +async def test_prepare_agent_rematerializes_cached_tools_when_run_as_changes() -> None: + session = _CwdProbeSession(Manifest()) + client = _FakeClient(session) + run_as = User(name="first-user") + agent = SandboxAgent( + name="sandbox", + model=ScriptedModel(steps=[[get_final_output_message("done")]]), + capabilities=[Shell(), Filesystem()], + run_as=run_as, + ) + runtime = SandboxRuntime( + starting_agent=agent, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options={"image": "sandbox"}, + cwd="tasks/a", + ) + ), + run_state=None, + ) + context_wrapper = RunContextWrapper(context=None) + + first = await runtime.prepare_agent( + current_agent=agent, + current_input="first", + context_wrapper=context_wrapper, + is_resumed_state=False, + ) + first_agent = cast(SandboxAgent[Any], first.bindings.execution_agent) + first_capabilities = list(first_agent.capabilities) + first_shell = next(tool for tool in first_agent.tools if isinstance(tool, ExecCommandTool)) + first_view = next(tool for tool in first_agent.tools if isinstance(tool, ViewImageTool)) + first_patch = next( + tool for tool in first_agent.tools if isinstance(tool, SandboxApplyPatchTool) + ) + assert first_shell.user == User(name="first-user") + assert first_view.user == User(name="first-user") + assert first_patch.editor.user == User(name="first-user") + + run_as.name = "second-user" + second = await runtime.prepare_agent( + current_agent=agent, + current_input="second", + context_wrapper=context_wrapper, + is_resumed_state=False, + ) + second_agent = cast(SandboxAgent[Any], second.bindings.execution_agent) + + assert second_agent is not first_agent + assert all( + second_capability is first_capability + for second_capability, first_capability in zip( + second_agent.capabilities, + first_capabilities, + strict=True, + ) + ) + second_shell = next(tool for tool in second_agent.tools if isinstance(tool, ExecCommandTool)) + second_view = next(tool for tool in second_agent.tools if isinstance(tool, ViewImageTool)) + second_patch = next( + tool for tool in second_agent.tools if isinstance(tool, SandboxApplyPatchTool) + ) + assert second_shell.user == User(name="second-user") + assert second_view.user == User(name="second-user") + assert second_patch.editor.user == User(name="second-user") + + third = await runtime.prepare_agent( + current_agent=agent, + current_input="third", + context_wrapper=context_wrapper, + is_resumed_state=False, + ) + + assert third.bindings.execution_agent is second_agent + assert session.cwd_probe_calls[-4:] == [ + (("test", "-d", "/workspace/tasks/a"), False, User(name="second-user")), + (("test", "-x", "/workspace/tasks/a"), False, User(name="second-user")), + (("test", "-d", "/workspace/tasks/a"), False, User(name="second-user")), + (("test", "-x", "/workspace/tasks/a"), False, User(name="second-user")), + ] + + @pytest.mark.asyncio async def test_prepare_agent_binds_run_as_to_cloned_capabilities() -> None: session = _FakeSession(Manifest()) @@ -5894,6 +6113,108 @@ async def test_prepare_agent_binds_run_as_to_cloned_capabilities() -> None: assert prepared_capability.run_as == User(name="sandbox-user") +@pytest.mark.asyncio +async def test_prepare_agent_binds_and_validates_run_workspace_scope() -> None: + session = _CwdProbeSession(Manifest()) + client = _FakeClient(session) + capability = _RecordingCapability() + agent = SandboxAgent( + name="sandbox", + model=ScriptedModel(steps=[[get_final_output_message("done")]]), + capabilities=[capability], + run_as="sandbox-user", + ) + runtime = SandboxRuntime( + starting_agent=agent, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options={"image": "sandbox"}, + cwd="tasks/a", + ) + ), + run_state=None, + ) + + prepared = await runtime.prepare_agent( + current_agent=agent, + current_input="hello", + context_wrapper=RunContextWrapper(context=None), + is_resumed_state=False, + ) + + execution_agent = cast(SandboxAgent[Any], prepared.bindings.execution_agent) + prepared_capability = cast(_RecordingCapability, execution_agent.capabilities[0]) + assert capability.workspace_scope.cwd is None + assert prepared_capability.workspace_scope.cwd == PurePosixPath("tasks/a") + assert session.cwd_probe_calls == [ + (("test", "-d", "/workspace/tasks/a"), False, User(name="sandbox-user")), + (("test", "-x", "/workspace/tasks/a"), False, User(name="sandbox-user")), + ] + + +@pytest.mark.asyncio +async def test_prepare_agent_serializes_cwd_probe_with_posix_sandbox_semantics() -> None: + session = _WindowsPathCwdProbeSession(Manifest()) + client = _FakeClient(session) + agent = SandboxAgent(name="sandbox", model=ScriptedModel()) + runtime = SandboxRuntime( + starting_agent=agent, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options={"image": "sandbox"}, + cwd="tasks/a", + ) + ), + run_state=None, + ) + await runtime.prepare_agent( + current_agent=agent, + current_input="hello", + context_wrapper=RunContextWrapper(context=None), + is_resumed_state=False, + ) + + assert session.cwd_probe_calls == [ + (("test", "-d", "/workspace/tasks/a"), False, None), + (("test", "-x", "/workspace/tasks/a"), False, None), + ] + + +@pytest.mark.asyncio +async def test_prepare_agent_rejects_inaccessible_run_workspace_scope() -> None: + session = _CwdProbeSession(Manifest(), accessible=False) + client = _FakeClient(session) + agent = SandboxAgent(name="sandbox", model=ScriptedModel()) + runtime = SandboxRuntime( + starting_agent=agent, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=client, + options={"image": "sandbox"}, + cwd="tasks/missing", + ) + ), + run_state=None, + ) + + with pytest.raises( + UserError, + match=r"Sandbox working directory `tasks/missing` does not exist or is not accessible", + ): + await runtime.prepare_agent( + current_agent=agent, + current_input="hello", + context_wrapper=RunContextWrapper(context=None), + is_resumed_state=False, + ) + + assert session.cwd_probe_calls == [ + (("test", "-d", "/workspace/tasks/missing"), False, None), + ] + + @pytest.mark.asyncio async def test_prepare_agent_processes_context_with_bound_cached_capabilities() -> None: session = _FakeSession(Manifest()) diff --git a/tests/sandbox/test_runtime_agent_preparation.py b/tests/sandbox/test_runtime_agent_preparation.py index d5054a8b75..2a78885e94 100644 --- a/tests/sandbox/test_runtime_agent_preparation.py +++ b/tests/sandbox/test_runtime_agent_preparation.py @@ -10,7 +10,11 @@ from agents import UserError from agents.models.default_models import get_default_model from agents.run_context import RunContextWrapper -from agents.sandbox import MemoryReadConfig, runtime_agent_preparation as sandbox_prep +from agents.sandbox import ( + MemoryReadConfig, + SandboxWorkspaceScope, + runtime_agent_preparation as sandbox_prep, +) from agents.sandbox.capabilities import Capability, Compaction, Memory from agents.sandbox.entries import BaseEntry, File from agents.sandbox.manifest import Manifest @@ -248,6 +252,30 @@ def test_filesystem_instructions_tell_model_to_ls_when_manifest_tree_is_truncate ) in result +def test_filesystem_instructions_describe_run_working_directory() -> None: + manifest = Manifest(root="/workspace", entries={"tasks/a": File(content=b"")}) + + result = sandbox_prep._filesystem_instructions( + manifest, + SandboxWorkspaceScope.from_cwd("tasks/a"), + ) + + assert "For this run, the working directory is `/workspace/tasks/a`." in result + assert ( + "Relative paths passed to the built-in `exec_command`, `view_image`, and `apply_patch` " + "tools resolve from this directory." + ) in result + assert "Other sandbox tools follow their own path contract." in result + assert "The session workspace root remains `/workspace`." in result + assert ( + "The working directory changes path resolution; it does not isolate this run from the " + "rest of the session workspace." + ) in result + assert ( + "Files outside the working directory may be visible to or shared with other runs." in result + ) + + def test_prepare_sandbox_agent_validates_required_capabilities() -> None: manifest = Manifest(root="/workspace") diff --git a/tests/sandbox/test_workspace_paths.py b/tests/sandbox/test_workspace_paths.py index e5d5cde093..ba76d79749 100644 --- a/tests/sandbox/test_workspace_paths.py +++ b/tests/sandbox/test_workspace_paths.py @@ -9,11 +9,12 @@ import pytest from pydantic import ValidationError -from agents.sandbox import Manifest, SandboxPathGrant +from agents.sandbox import Manifest, SandboxPathGrant, SandboxWorkspaceScope from agents.sandbox.errors import InvalidManifestPathError, WorkspaceArchiveWriteError from agents.sandbox.workspace_paths import ( WorkspacePathPolicy, coerce_posix_path, + normalize_sandbox_cwd, posix_path_as_path, sandbox_path_grant_host_path, ) @@ -35,6 +36,141 @@ def _policy(root: Path | str = "/workspace") -> WorkspacePathPolicy: return WorkspacePathPolicy(root=root) +def test_sandbox_workspace_scope_anchors_relative_paths() -> None: + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + + assert scope.anchor("plot.png") == PurePosixPath("tasks/a/plot.png") + assert scope.anchor(PureWindowsPath("reports/plot.png")) == PurePosixPath( + "tasks/a/reports/plot.png" + ) + assert scope.anchor("/workspace/plot.png") == "/workspace/plot.png" + assert scope.anchor(PureWindowsPath("C:/plot.png")) == PureWindowsPath("C:/plot.png") + + +def test_sandbox_workspace_scope_preserves_raw_tool_backslashes() -> None: + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + + anchored = scope.anchor(r"reports\plot.png") + + assert isinstance(anchored, PurePosixPath) + assert anchored.as_posix() == r"tasks/a/reports\plot.png" + + +def test_sandbox_workspace_scope_renders_model_paths() -> None: + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + + assert scope.model_path("tasks/a/plot.png") == PurePosixPath("plot.png") + assert scope.model_path("shared/skill/SKILL.md") == PurePosixPath("../../shared/skill/SKILL.md") + assert scope.display_path( + original_path="../shared.txt", + workspace_relative_path="tasks/shared.txt", + ) == PurePosixPath("../shared.txt") + assert scope.display_path( + original_path="/workspace/shared.txt", + workspace_relative_path="shared.txt", + ) == PurePosixPath("shared.txt") + + +def test_sandbox_workspace_scope_renders_session_resources_as_absolute_with_cwd() -> None: + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + + assert scope.model_resource_path( + workspace_root="/workspace", + workspace_relative_path=".agents/my-skill", + ) == PurePosixPath("/workspace/.agents/my-skill") + assert scope.model_resource_path( + workspace_root="/workspace", + workspace_relative_path=PureWindowsPath(r".agents\my-skill"), + ) == PurePosixPath("/workspace/.agents/my-skill") + assert scope.model_resource_path( + workspace_root=PureWindowsPath(r"C:\workspace"), + workspace_relative_path=".agents/my-skill", + ) == PurePosixPath("C:/workspace/.agents/my-skill") + assert scope.model_resource_path( + workspace_root=r"C:\workspace", + workspace_relative_path=".agents/my-skill", + ) == PurePosixPath("C:/workspace/.agents/my-skill") + + +def test_sandbox_workspace_scope_preserves_root_relative_resource_paths_without_cwd() -> None: + scope = SandboxWorkspaceScope() + + assert scope.model_resource_path( + workspace_root="/workspace", + workspace_relative_path=".agents/my-skill", + ) == PurePosixPath(".agents/my-skill") + + +@pytest.mark.parametrize( + ("path", "message"), + [ + ("/workspace/.agents/my-skill", "must be workspace-relative"), + ("../my-skill", "must be workspace-relative"), + ("C:/skills/my-skill", "must be workspace-relative"), + (PureWindowsPath("C:/skills/my-skill"), "must be workspace-relative"), + (r".agents\my-skill", "must use POSIX path separators"), + ("", "must be non-empty"), + ], +) +def test_sandbox_workspace_scope_rejects_invalid_session_resource_paths( + path: str | PurePath, + message: str, +) -> None: + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + + with pytest.raises(ValueError, match=message): + scope.model_resource_path( + workspace_root="/workspace", + workspace_relative_path=path, + ) + + +@pytest.mark.parametrize( + "root", + [r"\workspace", "workspace"], +) +def test_sandbox_workspace_scope_rejects_non_posix_absolute_resource_roots( + root: str | PurePath, +) -> None: + scope = SandboxWorkspaceScope.from_cwd("tasks/a") + + with pytest.raises(ValueError, match="sandbox workspace root must be POSIX absolute"): + scope.model_resource_path( + workspace_root=root, + workspace_relative_path=".agents/my-skill", + ) + + +def test_sandbox_workspace_scope_none_preserves_root_relative_paths() -> None: + scope = SandboxWorkspaceScope() + + assert scope.anchor("plot.png") == "plot.png" + assert scope.model_path("reports/plot.png") == PurePosixPath("reports/plot.png") + + +def test_sandbox_workspace_scope_constructor_validates_cwd() -> None: + with pytest.raises(ValueError, match="sandbox.cwd must not contain parent segments"): + SandboxWorkspaceScope(cwd=PurePosixPath("tasks/../a")) + + +@pytest.mark.parametrize( + ("cwd", "message"), + [ + ("", "sandbox.cwd must be non-empty"), + ("/workspace/tasks/a", "sandbox.cwd must be workspace-relative"), + ("tasks/../a", "sandbox.cwd must not contain parent segments"), + (r"tasks\a", "sandbox.cwd must use POSIX path separators"), + (PureWindowsPath("C:/tasks/a"), "sandbox.cwd must be workspace-relative"), + ], +) +def test_normalize_sandbox_cwd_rejects_invalid_values( + cwd: str | PurePath, + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + normalize_sandbox_cwd(cwd) + + def test_workspace_path_policy_rejects_relative_root() -> None: with pytest.raises(ValueError, match="sandbox workspace root must be absolute"): WorkspacePathPolicy(root="workspace") diff --git a/tests/test_run_config.py b/tests/test_run_config.py index 1e61f4e8d6..8c88046af5 100644 --- a/tests/test_run_config.py +++ b/tests/test_run_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import PureWindowsPath from typing import Any, cast import pytest @@ -47,6 +48,7 @@ def test_run_config_normalizes_first_party_dictionary_settings() -> None: "manifest": {"root": "/workspace"}, "snapshot": {"type": "noop"}, "concurrency_limits": {"manifest_entries": 3}, + "cwd": "tasks/a", }, ) @@ -63,6 +65,28 @@ def test_run_config_normalizes_first_party_dictionary_settings() -> None: assert isinstance(config.sandbox.snapshot, NoopSnapshotSpec) assert isinstance(config.sandbox.concurrency_limits, SandboxConcurrencyLimits) assert config.sandbox.concurrency_limits.manifest_entries == 3 + assert config.sandbox.cwd == "tasks/a" + + +def test_sandbox_run_config_normalizes_typed_cwd() -> None: + config = SandboxRunConfig(cwd=PureWindowsPath("tasks/a")) + + assert config.cwd == "tasks/a" + + +@pytest.mark.parametrize( + ("cwd", "message"), + [ + ("", "sandbox.cwd must be non-empty"), + ("/workspace/tasks/a", "sandbox.cwd must be workspace-relative"), + ("tasks/../a", "sandbox.cwd must not contain parent segments"), + (r"tasks\a", "sandbox.cwd must use POSIX path separators"), + (PureWindowsPath("C:/tasks/a"), "sandbox.cwd must be workspace-relative"), + ], +) +def test_sandbox_run_config_rejects_invalid_cwd(cwd: object, message: str) -> None: + with pytest.raises(ValueError, match=message): + SandboxRunConfig(cwd=cast(Any, cwd)) def test_run_config_preserves_typed_configuration_instances() -> None: diff --git a/tests/test_tool_approval_call_id_reuse.py b/tests/test_tool_approval_call_id_reuse.py index 6cff1d3444..3f9f0671ce 100644 --- a/tests/test_tool_approval_call_id_reuse.py +++ b/tests/test_tool_approval_call_id_reuse.py @@ -1103,6 +1103,207 @@ async def invoke(_context: Any, value: str) -> str: assert executed == ["safe"] +@pytest.mark.asyncio +async def test_approved_custom_tool_resume_rebinds_to_current_tool() -> None: + executed: list[str] = [] + + async def invoke_original(_context: Any, value: str) -> str: + executed.append(f"original:{value}") + return value + + async def invoke_replacement(_context: Any, value: str) -> str: + executed.append(f"replacement:{value}") + return value + + original = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke_original, + format={"type": "text"}, + needs_approval=True, + ) + replacement = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke_replacement, + format={"type": "text"}, + needs_approval=True, + ) + call = ResponseCustomToolCall( + type="custom_tool_call", + name="raw_editor", + call_id="call_0", + input="safe", + ) + model = ScriptedModel(steps=[[call], [get_text_message("done")]]) + agent = Agent(name="agent", model=model, tools=[original]) + + first = await Runner.run(agent, "edit text") + state = first.to_state() + state.approve(state.get_interruptions()[0]) + agent.tools = [replacement] + + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert executed == ["replacement:safe"] + + +@pytest.mark.asyncio +async def test_approved_custom_tool_resume_fails_when_current_tool_is_missing() -> None: + executed: list[str] = [] + + async def invoke(_context: Any, value: str) -> str: + executed.append(value) + return value + + tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke, + format={"type": "text"}, + needs_approval=True, + ) + call = ResponseCustomToolCall( + type="custom_tool_call", + name="raw_editor", + call_id="call_0", + input="safe", + ) + model = ScriptedModel(steps=[[call]]) + agent = Agent(name="agent", model=model, tools=[tool]) + + first = await Runner.run(agent, "edit text") + state = first.to_state() + state.approve(state.get_interruptions()[0]) + agent.tools = [] + + with pytest.raises(ModelBehaviorError, match="Tool raw_editor not found"): + await Runner.run(agent, state) + + assert executed == [] + + +@pytest.mark.asyncio +async def test_approved_apply_patch_custom_resume_uses_public_tool() -> None: + class RecordingEditor: + def __init__(self) -> None: + self.paths: list[str] = [] + + def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult: + self.paths.append(operation.path) + return ApplyPatchResult(status="completed", output="updated") + + editor = RecordingEditor() + tool = ApplyPatchTool(editor=cast(Any, editor), needs_approval=True) + call = ResponseCustomToolCall( + type="custom_tool_call", + name="apply_patch", + call_id="patch_0", + input=json.dumps( + { + "type": "update_file", + "path": "safe.txt", + "diff": "-old\n+new\n", + } + ), + ) + model = ScriptedModel(steps=[[call], [get_text_message("done")]]) + agent = Agent(name="agent", model=model, tools=[tool]) + + first = await Runner.run(agent, "update the file") + state = first.to_state() + state.approve(state.get_interruptions()[0]) + + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert editor.paths == ["safe.txt"] + + +@pytest.mark.asyncio +async def test_pending_custom_tool_resume_survives_missing_current_tool() -> None: + async def invoke(_context: Any, value: str) -> str: + raise AssertionError(f"pending custom tool must not execute: {value}") + + tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke, + format={"type": "text"}, + needs_approval=True, + ) + call = ResponseCustomToolCall( + type="custom_tool_call", + name="raw_editor", + call_id="call_0", + input="safe", + ) + agent = Agent( + name="agent", + model=ScriptedModel(steps=[[call]]), + tools=[tool], + ) + + first = await Runner.run(agent, "edit") + state = first.to_state() + agent.tools = [] + + resumed = await Runner.run(agent, state) + + assert len(resumed.interruptions) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("serialize_state", [False, True], ids=["in-memory", "json"]) +async def test_rejected_custom_tool_resume_emits_output_when_current_tool_is_missing( + serialize_state: bool, +) -> None: + executed: list[str] = [] + + async def invoke(_context: Any, value: str) -> str: + executed.append(value) + return value + + tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke, + format={"type": "text"}, + needs_approval=True, + ) + call = ResponseCustomToolCall( + type="custom_tool_call", + name="raw_editor", + call_id="call_0", + input="safe", + ) + model = ScriptedModel(steps=[[call], [get_text_message("done")]]) + agent = Agent(name="agent", model=model, tools=[tool]) + + first = await Runner.run(agent, "edit text") + state = first.to_state() + state.reject(state.get_interruptions()[0]) + agent.tools = [] + if serialize_state: + state = await RunState.from_json(agent, state.to_json()) + + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert executed == [] + model_input = model.calls[-1].input + assert isinstance(model_input, list) + rejection_outputs = [ + item + for item in model_input + if isinstance(item, dict) + and item.get("type") == "custom_tool_call_output" + and item.get("call_id") == "call_0" + ] + assert len(rejection_outputs) == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) async def test_changed_same_id_siblings_fail_before_approval_callback( From 94da8ed49cc505295e8c5fb2fb4e35f341e2bd71 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 10:28:43 +0900 Subject: [PATCH 336/473] fix(core): count Responses requests without usage (#4453) Co-authored-by: ayaangazali --- .../experimental/hosted_multi_agent/model.py | 6 + src/agents/models/openai_responses.py | 114 +++-- src/agents/usage.py | 18 +- .../hosted_multi_agent/test_model.py | 71 ++-- tests/models/test_openai_responses.py | 393 +++++++++++++++++- 5 files changed, 542 insertions(+), 60 deletions(-) diff --git a/src/agents/extensions/experimental/hosted_multi_agent/model.py b/src/agents/extensions/experimental/hosted_multi_agent/model.py index 320944c9b8..68ca287352 100644 --- a/src/agents/extensions/experimental/hosted_multi_agent/model.py +++ b/src/agents/extensions/experimental/hosted_multi_agent/model.py @@ -39,6 +39,7 @@ from ....models.openai_responses import OpenAIResponsesModel, _is_openai_omitted_value from ....tool import Tool from ....tool_context import ToolContext +from ....usage import _mark_requests_completed_without_usage _BETA_ID = "responses_multi_agent=v1" _ROOT_AGENT_NAME = "/root" @@ -784,6 +785,11 @@ async def _iter_websocket_turn( request_usages=active.request_usages, request_count=active.request_count, ) + if normalized_response.usage is None: + _mark_requests_completed_without_usage( + normalized_response, + active.request_count, + ) payload = _model_dump(completed_event) payload["response"] = normalized_response normalized_event = _construct_event("response.completed", payload) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 232b7ae258..a8aea40590 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -5,7 +5,7 @@ import inspect import json import weakref -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextvars import ContextVar from dataclasses import asdict, dataclass, is_dataclass from enum import Enum @@ -78,7 +78,9 @@ from ..usage import ( Usage, _attach_raw_usage_snapshot, + _mark_request_completed_without_usage, _raw_usage_snapshot, + _requests_for_response_without_usage, _response_usage_to_usage, model_usage_to_span_usage, ) @@ -229,6 +231,23 @@ class OpenAIResponsesWebSocketOptions(TypedDict): """ +def _mark_transport_request_without_usage(response: object) -> None: + """Mark one adapter-owned request when its completed response omits usage.""" + if isinstance(response, Response) and response.usage is None: + _mark_request_completed_without_usage(response) + + +def _usage_from_response(response: Response) -> Usage: + """Convert provider usage while preserving an adapter-owned request marker.""" + if response.usage is not None: + return _response_usage_to_usage(response.usage) + return Usage(requests=_requests_for_response_without_usage(response)) + + +async def _no_stream_cleanup() -> None: + """Provide a no-op cleanup callback for an externally owned stream.""" + + class _ResponseStreamWithRequestId: """Wrap an SDK event stream and retain the originating request ID.""" @@ -241,18 +260,20 @@ class _ResponseStreamWithRequestId: def __init__( self, - stream: AsyncIterator[ResponseStreamEvent], + stream: AsyncIterator[ResponseStreamEvent] | AsyncIterable[ResponseStreamEvent], *, request_id: str | None, cleanup: Callable[[], Awaitable[object]], ) -> None: - self._stream = stream + self._source = stream + self._stream: AsyncIterator[ResponseStreamEvent] | None = None self.request_id = request_id self._cleanup = cleanup self._closed = False self._stream_close_complete = False self._cleanup_complete = False self._yielded_terminal_event = False + self._close_task: asyncio.Future[None] | None = None def __aiter__(self) -> _ResponseStreamWithRequestId: return self @@ -261,8 +282,13 @@ async def __anext__(self) -> ResponseStreamEvent: if self._closed: raise StopAsyncIteration + stream = self._stream + if stream is None: + stream = self._source.__aiter__() + self._stream = stream + try: - event = await self._stream.__anext__() + event = await stream.__anext__() except StopAsyncIteration: self._closed = True await self._cleanup_after_exhaustion() @@ -272,14 +298,13 @@ async def __anext__(self) -> ResponseStreamEvent: event_type = getattr(event, "type", None) if event_type in self._TERMINAL_EVENT_TYPES: self._yielded_terminal_event = True + if event_type == "response.completed": + _mark_transport_request_without_usage(getattr(event, "response", None)) return event async def aclose(self) -> None: self._closed = True - try: - await self._close_stream_once() - finally: - await self._cleanup_once() + await self._close_stream_and_cleanup() async def close(self) -> None: await self.aclose() @@ -305,7 +330,7 @@ async def _cleanup_once(self) -> None: async def _cleanup_after_exhaustion(self) -> None: try: - await self._cleanup_once() + await self._close_stream_and_cleanup() except Exception as exc: if self._yielded_terminal_event: log_model_action_debug( @@ -314,17 +339,54 @@ async def _cleanup_after_exhaustion(self) -> None: return raise + async def _close_stream_and_cleanup(self) -> None: + if self._close_task is None: + self._close_task = asyncio.ensure_future(self._finish_stream_close_and_cleanup()) + await asyncio.shield(self._close_task) + + async def _finish_stream_close_and_cleanup(self) -> None: + try: + await self._close_stream_once() + except BaseException: + with contextlib.suppress(BaseException): + await self._cleanup_once() + raise + await self._cleanup_once() + async def _close_stream_once(self) -> None: if self._stream_close_complete: return self._stream_close_complete = True - aclose = getattr(self._stream, "aclose", None) + # An async iterable may create a separate iterator that owns its cleanup. Close both the + # resolved iterator and the source object, while avoiding duplicate close calls when they + # are the same object. + resolved = self._stream + if self._source is resolved: + await self._close_object(resolved) + return + + try: + await self._close_object(resolved) + except BaseException: + # The source may own transport cleanup independently of its iterator. Preserve the + # iterator's original error while still making a best effort to release the source. + with contextlib.suppress(BaseException): + await self._close_object(self._source) + raise + await self._close_object(self._source) + + @staticmethod + async def _close_object(target: object | None) -> None: + if target is None: + return + + aclose = getattr(target, "aclose", None) if callable(aclose): await aclose() return - close = getattr(self._stream, "close", None) + close = getattr(target, "close", None) if callable(close): close_result = close() if inspect.isawaitable(close_result): @@ -528,12 +590,8 @@ async def get_response( ), ) - usage = ( - _response_usage_to_usage(response.usage) - if response.usage is not None - else Usage() - ) - if response.usage is not None: + usage = _usage_from_response(response) + if response.usage is not None or usage.requests: span_response.span_data.usage = model_usage_to_span_usage(usage) if tracing.include_data(): @@ -610,6 +668,11 @@ async def stream_response( final_response = chunk.response if model_settings.preserve_raw_usage is True: _attach_raw_usage_snapshot(chunk.response, chunk.response.usage) + usage = _usage_from_response(chunk.response) + if chunk.response.usage is not None or usage.requests: + # Record before yielding the terminal event because consumers may + # close the generator immediately after receiving it. + span_response.span_data.usage = model_usage_to_span_usage(usage) elif chunk_type in { "response.failed", "response.incomplete", @@ -669,11 +732,6 @@ async def stream_response( if final_response is not None and tracing.include_data(): span_response.span_data.response = final_response span_response.span_data.input = input - if final_response is not None and final_response.usage is not None: - span_response.span_data.usage = model_usage_to_span_usage( - _response_usage_to_usage(final_response.usage) - ) - except Exception as e: span_response.set_error( SpanError( @@ -747,15 +805,21 @@ async def _fetch_response( if not stream: response = await client.responses.create(**create_kwargs) + _mark_transport_request_without_usage(response) return cast(Response, response) streaming_response = getattr(client.responses, "with_streaming_response", None) stream_create = getattr(streaming_response, "create", None) if not callable(stream_create): # Some tests and custom clients only implement `responses.create()`. Fall back to the - # older path in that case and simply omit request IDs for streamed calls. + # older path in that case and simply omit request IDs for streamed calls. Keep it in + # the existing stream wrapper so terminal request accounting stays transport-owned. response = await client.responses.create(**create_kwargs) - return cast(AsyncIterator[ResponseStreamEvent], response) + return _ResponseStreamWithRequestId( + cast(AsyncIterator[ResponseStreamEvent], response), + request_id=None, + cleanup=_no_stream_cleanup, + ) # Keep the raw API response open while callers consume the SSE stream so we can expose # its request ID on terminal response payloads before cleanup closes the transport. @@ -1277,6 +1341,8 @@ async def _iter_websocket_response_events( } if is_terminal_event: yielded_terminal_event = True + if event_type == "response.completed": + _mark_transport_request_without_usage(getattr(event, "response", None)) yield event if is_terminal_event: diff --git a/src/agents/usage.py b/src/agents/usage.py index a71f9155df..5e38fb65fc 100644 --- a/src/agents/usage.py +++ b/src/agents/usage.py @@ -321,16 +321,26 @@ def _mark_request_completed_without_usage(response: Any) -> None: Adapters call this instead of synthesizing a zero-filled usage payload, so the raw provider usage stays absent while the request itself is still counted. """ - object.__setattr__(response, _REQUEST_WITHOUT_USAGE_ATTR, True) + _mark_requests_completed_without_usage(response, 1) + + +def _mark_requests_completed_without_usage(response: Any, requests: int) -> None: + """Record an adapter-owned physical request count without provider usage.""" + if requests < 1: + raise ValueError("Completed request count must be at least one.") + object.__setattr__(response, _REQUEST_WITHOUT_USAGE_ATTR, requests) def _requests_for_response_without_usage(response: Any) -> int: """How many requests a usage-less response represents. - Defaults to zero so adapters that multiplex several provider responses into one - response, and report their counts separately, are not double-counted. + Defaults to zero so adapters must explicitly opt in for both singular responses and + responses that aggregate several physical provider requests. """ - return 1 if getattr(response, _REQUEST_WITHOUT_USAGE_ATTR, False) else 0 + requests = getattr(response, _REQUEST_WITHOUT_USAGE_ATTR, 0) + if requests is True: + return 1 + return requests if type(requests) is int and requests > 0 else 0 def _response_usage_to_usage(response_usage: Any) -> Usage: diff --git a/tests/extensions/experimental/hosted_multi_agent/test_model.py b/tests/extensions/experimental/hosted_multi_agent/test_model.py index 5fa4ace647..fce67c637f 100644 --- a/tests/extensions/experimental/hosted_multi_agent/test_model.py +++ b/tests/extensions/experimental/hosted_multi_agent/test_model.py @@ -510,8 +510,10 @@ def get_proposal(ctx: ToolContext[Any], proposal: str) -> str: @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("reports_usage", [False, True]) async def test_injection_failure_after_completion_starts_continuation_response( streamed: bool, + reports_usage: bool, ) -> None: function_call = { "id": "fc_fallback", @@ -528,13 +530,14 @@ async def test_injection_failure_after_completion_starts_continuation_response( "output": "document:alpha", } completed_first_response = _response("resp_old", [function_call]) - completed_first_response.usage = _usage( - input_tokens=12, - cached_tokens=3, - cache_write_tokens=1, - output_tokens=4, - reasoning_tokens=2, - ) + if reports_usage: + completed_first_response.usage = _usage( + input_tokens=12, + cached_tokens=3, + cache_write_tokens=1, + output_tokens=4, + reasoning_tokens=2, + ) first_events = [ _created("resp_old"), _done(function_call, sequence_number=2, output_index=0), @@ -556,13 +559,14 @@ async def test_injection_failure_after_completion_starts_continuation_response( ] final_message = _root_final_message("continued") completed_second_response = _response("resp_new", [final_message]) - completed_second_response.usage = _usage( - input_tokens=7, - cached_tokens=2, - cache_write_tokens=4, - output_tokens=3, - reasoning_tokens=1, - ) + if reports_usage: + completed_second_response.usage = _usage( + input_tokens=7, + cached_tokens=2, + cache_write_tokens=4, + output_tokens=3, + reasoning_tokens=1, + ) second_events = [ _created("resp_new"), _done(final_message, sequence_number=2, output_index=0), @@ -600,25 +604,30 @@ def lookup_document(section: str) -> str: ) assert result.final_output == "continued" - assert result.context_wrapper.usage.input_tokens == 19 - assert result.context_wrapper.usage.input_tokens_details.cached_tokens == 5 - assert ( - getattr( - result.context_wrapper.usage.input_tokens_details, - "cache_write_tokens", - None, + if reports_usage: + assert result.context_wrapper.usage.input_tokens == 19 + assert result.context_wrapper.usage.input_tokens_details.cached_tokens == 5 + assert ( + getattr( + result.context_wrapper.usage.input_tokens_details, + "cache_write_tokens", + None, + ) + == 5 ) - == 5 - ) - assert result.context_wrapper.usage.output_tokens == 7 - assert result.context_wrapper.usage.output_tokens_details.reasoning_tokens == 3 - assert result.context_wrapper.usage.total_tokens == 26 + assert result.context_wrapper.usage.output_tokens == 7 + assert result.context_wrapper.usage.output_tokens_details.reasoning_tokens == 3 + assert result.context_wrapper.usage.total_tokens == 26 + assert len(result.context_wrapper.usage.request_usage_entries) == 2 + assert [ + entry.total_tokens for entry in result.context_wrapper.usage.request_usage_entries + ] == [16, 10] + else: + assert result.context_wrapper.usage.input_tokens == 0 + assert result.context_wrapper.usage.output_tokens == 0 + assert result.context_wrapper.usage.total_tokens == 0 + assert result.context_wrapper.usage.request_usage_entries == [] assert result.context_wrapper.usage.requests == 2 - assert len(result.context_wrapper.usage.request_usage_entries) == 2 - assert [entry.total_tokens for entry in result.context_wrapper.usage.request_usage_entries] == [ - 16, - 10, - ] assert len(client.beta.responses.connections) == 1 connection = client.beta.responses.connections[0] diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 93a6da5703..ad2be94039 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -8,7 +8,7 @@ import httpx2 import pytest from openai import NOT_GIVEN, APIConnectionError, AsyncOpenAI, RateLimitError, omit -from openai.types.responses import ResponseCompletedEvent, ResponseErrorEvent +from openai.types.responses import Response, ResponseCompletedEvent, ResponseErrorEvent from openai.types.responses.response_create_params import ContextManagement, PromptCacheOptions from openai.types.responses.response_usage import ResponseUsage from openai.types.shared.reasoning import Reasoning @@ -40,6 +40,7 @@ OpenAIResponsesModel, OpenAIResponsesWSModel, ResponsesWebSocketError, + _ResponseStreamWithRequestId, _should_retry_pre_event_websocket_disconnect, ) from agents.retry import ModelRetryAdviceRequest @@ -4424,3 +4425,393 @@ def test_websocket_get_retry_advice_reports_no_response_started_for_stateful_req assert advice is not None assert advice.replay_safety == "unsafe" assert advice.response_started is False + + +def _response_without_usage() -> Response: + return Response( + id="resp-no-usage", + created_at=0, + model="fake", + object="response", + output=[], + tool_choice="none", + tools=[], + top_p=None, + parallel_tool_calls=False, + usage=None, + ) + + +def _completed_event_without_usage() -> ResponseCompletedEvent: + return ResponseCompletedEvent( + response=_response_without_usage(), + type="response.completed", + sequence_number=0, + ) + + +def _streaming_client_for(events: list[Any]) -> Any: + class IteratorStream: + def __init__(self) -> None: + self._remaining = list(events) + + def __aiter__(self) -> IteratorStream: + return self + + async def __anext__(self) -> Any: + if not self._remaining: + raise StopAsyncIteration + return self._remaining.pop(0) + + async def close(self) -> None: + return None + + stream = IteratorStream() + + class APIResponse: + request_id = "req-1" + + async def parse(self) -> Any: + return stream + + class StreamingContextManager: + async def __aenter__(self) -> APIResponse: + return APIResponse() + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + class Responses: + with_streaming_response = SimpleNamespace(create=lambda **kwargs: StreamingContextManager()) + + class Client: + responses = Responses() + base_url = httpx2.URL("https://custom.example.test/v1/") + + return Client() + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_request_is_counted_when_responses_provider_omits_usage() -> None: + class Responses: + async def create(self, **kwargs: Any) -> Response: + return _response_without_usage() + + class Client: + responses = Responses() + base_url = httpx2.URL("https://custom.example.test/v1/") + + model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, Client())) + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(preserve_raw_usage=True), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ) + + assert response.usage.requests == 1 + assert response.usage.total_tokens == 0 + assert response.raw_usage is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_streamed_request_is_counted_when_responses_provider_omits_usage() -> None: + completed = _completed_event_without_usage() + client = _streaming_client_for([completed]) + agent = Agent( + name="test", + model=OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, client)), + ) + + result = Runner.run_streamed(agent, "hi") + async for _ in result.stream_events(): + pass + + assert result.context_wrapper.usage.requests == 1 + assert result.context_wrapper.usage.total_tokens == 0 + assert completed.response.usage is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_fallback_response_stream_counts_request_without_usage() -> None: + completed = _completed_event_without_usage() + + class IteratorStream: + def __init__(self) -> None: + self._remaining = [completed] + self.close_calls = 0 + + def __aiter__(self) -> IteratorStream: + return self + + async def __anext__(self) -> ResponseCompletedEvent: + if not self._remaining: + raise StopAsyncIteration + return self._remaining.pop() + + async def aclose(self) -> None: + self.close_calls += 1 + + stream = IteratorStream() + + class Responses: + async def create(self, **kwargs: Any) -> IteratorStream: + return stream + + class Client: + responses = Responses() + base_url = httpx2.URL("https://custom.example.test/v1/") + + model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, Client())) + result = Runner.run_streamed(Agent(name="test", model=model), "hi") + async for _ in result.stream_events(): + pass + + assert result.context_wrapper.usage.requests == 1 + assert result.context_wrapper.usage.total_tokens == 0 + assert stream.close_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_fallback_response_stream_closes_resolved_iterable_iterator() -> None: + class IterableStream: + def __init__(self) -> None: + self.source_closed = False + self.iterator_closed = False + + async def __aiter__(self) -> Any: + try: + yield _completed_event_without_usage() + yield _completed_event_without_usage() + finally: + self.iterator_closed = True + + async def aclose(self) -> None: + self.source_closed = True + + source = IterableStream() + + class Responses: + async def create(self, **kwargs: Any) -> IterableStream: + return source + + class Client: + responses = Responses() + base_url = httpx2.URL("https://custom.example.test/v1/") + + model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, Client())) + stream = model.stream_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ) + async for _ in stream: + break + await stream.aclose() + + assert source.iterator_closed + assert source.source_closed + + +@pytest.mark.asyncio +async def test_response_stream_closes_source_when_resolved_iterator_close_fails() -> None: + class FailingIterator: + def __init__(self) -> None: + self._remaining = [_completed_event_without_usage()] + + def __aiter__(self) -> FailingIterator: + return self + + async def __anext__(self) -> ResponseCompletedEvent: + if not self._remaining: + raise StopAsyncIteration + return self._remaining.pop() + + async def aclose(self) -> None: + raise RuntimeError("iterator close failed") + + iterator = FailingIterator() + + class IterableSource: + def __init__(self) -> None: + self.closed = False + + def __aiter__(self) -> FailingIterator: + return iterator + + async def aclose(self) -> None: + self.closed = True + raise RuntimeError("source close failed") + + source = IterableSource() + + async def cleanup() -> None: + return None + + stream = _ResponseStreamWithRequestId(source, request_id=None, cleanup=cleanup) + await stream.__anext__() + + with pytest.raises(RuntimeError, match="iterator close failed"): + await stream.aclose() + assert source.closed + + +@pytest.mark.asyncio +async def test_response_stream_closes_distinct_source_after_normal_exhaustion() -> None: + class IterableSource: + def __init__(self) -> None: + self.source_close_calls = 0 + self.iterator_finalized = False + + async def __aiter__(self) -> Any: + try: + yield _completed_event_without_usage() + finally: + self.iterator_finalized = True + + async def aclose(self) -> None: + self.source_close_calls += 1 + + source = IterableSource() + cleanup_calls = 0 + + async def cleanup() -> None: + nonlocal cleanup_calls + cleanup_calls += 1 + + stream = _ResponseStreamWithRequestId(source, request_id=None, cleanup=cleanup) + async for _ in stream: + pass + + assert source.iterator_finalized + assert source.source_close_calls == 1 + assert cleanup_calls == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_websocket_request_is_counted_when_responses_provider_omits_usage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + frame = json.dumps( + { + "type": "response.completed", + "response": _response_without_usage().model_dump(), + "sequence_number": 1, + } + ) + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + + async def fake_open( + ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None + ) -> DummyWSConnection: + return DummyWSConnection([frame]) + + monkeypatch.setattr(model, "_open_websocket_connection", fake_open) + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ) + + assert response.usage.requests == 1 + assert response.usage.total_tokens == 0 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_websocket_stream_counts_request_without_usage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + frame = json.dumps( + { + "type": "response.completed", + "response": _response_without_usage().model_dump(), + "sequence_number": 1, + } + ) + model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient())) + + async def fake_open( + ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None + ) -> DummyWSConnection: + return DummyWSConnection([frame]) + + monkeypatch.setattr(model, "_open_websocket_connection", fake_open) + result = Runner.run_streamed(Agent(name="test", model=model), "hi") + async for _ in result.stream_events(): + pass + + assert result.context_wrapper.usage.requests == 1 + assert result.context_wrapper.usage.total_tokens == 0 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_response_span_counts_request_without_usage() -> None: + class Responses: + async def create(self, **kwargs: Any) -> Response: + return _response_without_usage() + + class Client: + responses = Responses() + base_url = httpx2.URL("https://custom.example.test/v1/") + + model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, Client())) + with trace("test"): + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + ) + + spans = [span.export() for span in fetch_ordered_spans() if span.span_data.type == "response"] + assert len(spans) == 1 + assert spans[0]["span_data"]["usage"]["requests"] == 1 # type: ignore[index] + assert spans[0]["span_data"]["usage"]["total_tokens"] == 0 # type: ignore[index] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_streamed_span_counts_request_before_terminal_event_close() -> None: + client = _streaming_client_for([_completed_event_without_usage()]) + model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, client)) + + with trace("test"): + stream = model.stream_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + ) + async for event in stream: + if event.type == "response.completed": + break + await stream.aclose() + + spans = [span.export() for span in fetch_ordered_spans() if span.span_data.type == "response"] + assert len(spans) == 1 + assert spans[0]["span_data"]["usage"]["requests"] == 1 # type: ignore[index] + assert spans[0]["span_data"]["usage"]["total_tokens"] == 0 # type: ignore[index] From 2588d154e4f7a769f4fa4b1004778a36a34be034 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 11:13:06 +0900 Subject: [PATCH 337/473] fix: prevent advanced SQLite structure table conflicts (#4454) Co-authored-by: ayaangazali --- .../memory/advanced_sqlite_session.py | 320 +++++++--- src/agents/memory/sqlite_session.py | 38 +- .../memory/test_advanced_sqlite_session.py | 561 +++++++++++++++++- 3 files changed, 809 insertions(+), 110 deletions(-) diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index d9858eb134..dbb8767f47 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -7,7 +7,7 @@ import time from contextlib import closing from pathlib import Path -from typing import Any, cast +from typing import Any, ClassVar, cast from agents.result import RunResult from agents.usage import Usage @@ -25,6 +25,17 @@ from ...memory.sqlite_session import _await_mutation +def _allow_all_sqlite_actions( + _action: int, + _arg1: str | None, + _arg2: str | None, + _database: str | None, + _source: str | None, +) -> int: + """Keep SQL authorized when an older Python cannot remove an authorizer.""" + return sqlite3.SQLITE_OK + + def _content_preview(content: Any, max_length: int | None = None) -> str: """Return a string preview of a stored user-message ``content``. @@ -61,21 +72,20 @@ def __init__( logger: The logger to use. Defaults to the module logger **kwargs: Additional keyword arguments to pass to the superclass """ # noqa: E501 - super().__init__( - session_id=session_id, - db_path=db_path, - session_settings=session_settings, - **kwargs, - ) - if create_tables: + self._create_structure_tables_on_init = create_tables + try: + super().__init__( + session_id=session_id, + db_path=db_path, + session_settings=session_settings, + **kwargs, + ) + except BaseException: try: - self._init_structure_tables() + self.close() except BaseException: - try: - self.close() - except BaseException: - pass - raise + pass + raise self._current_branch_id = "main" # Synchronized with the durable session_clear_generations row whenever a # branch pointer is established or a write begins. A mismatch means @@ -83,6 +93,17 @@ def __init__( self._generation = 0 self._logger = logger if logger is not None else logging.getLogger(__name__) + def _init_db_for_connection(self, conn: sqlite3.Connection) -> None: + """Initialize base tables only after validating advanced-table ownership.""" + if self._create_structure_tables_on_init: + conn.execute("BEGIN IMMEDIATE") + self._create_schema_for_connection(conn) + self._init_structure_tables(conn) + else: + self._claim_structure_tables(conn) + self._create_schema_for_connection(conn) + conn.commit() + def _commit_branch_pointer(self, branch_id: str, generation: int) -> bool: """Set the current-branch pointer unless a clear has committed meanwhile. @@ -108,80 +129,219 @@ def _commit_branch_pointer(self, branch_id: str, generation: int) -> bool: self._current_branch_id = branch_id return True - def _init_structure_tables(self): + # The structure tables that record which base-table pair owns a database file, and the + # foreign keys that record it. `branch_reservations` and `session_clear_generations` carry no + # foreign keys, but enforcing one pair per file keeps them unambiguous too. + _STRUCTURE_TABLE_OWNERS: ClassVar[dict[str, tuple[str, ...]]] = { + "message_structure": ("session_id", "message_id"), + "turn_usage": ("session_id",), + } + _OWNER_TARGET_COLUMNS: ClassVar[dict[str, str]] = { + "session_id": "session_id", + "message_id": "id", + } + + def _claim_structure_tables(self, conn: sqlite3.Connection) -> None: + """Require a complete structure-table layout owned by the configured base-table pair. + + The structure tables are not named after ``sessions_table``/``messages_table``, so a + database file can only hold the structure rows of a single pair. ``CREATE TABLE IF NOT + EXISTS`` keeps the first pair's foreign keys, so a second session configured with + different base table names would join ``message_structure`` against the wrong messages + table and read back rows that belong to the other pair. + + Missing or ambiguous ownership is rejected rather than accepted, so a + ``create_tables=False`` session cannot open a file before any pair has claimed it and + then have another pair claim it underneath. + """ + owners_by_table: dict[str, dict[str, list[tuple[Any, ...]]]] = {} + for table, columns in self._STRUCTURE_TABLE_OWNERS.items(): + foreign_keys = conn.execute(f"PRAGMA foreign_key_list({table})").fetchall() + owner_rows = { + column: [ + row for row in foreign_keys if self._identifiers_equal(conn, row[3], column) + ] + for column in columns + } + if any(len(owner_rows[column]) != 1 for column in columns): + raise ValueError( + f"The `{table}` table in {self.db_path} does not record exactly one owner " + "foreign key for each required base-table column. Construct an " + "AdvancedSQLiteSession with create_tables=True to create and claim the " + "structure tables before opening the database without them." + ) + owners_by_table[table] = owner_rows + + owned_by = self._resolve_base_table_owners(conn) + for table, columns in self._STRUCTURE_TABLE_OWNERS.items(): + owner_rows = owners_by_table[table] + if any( + not self._identifiers_equal(conn, owner_rows[column][0][2], owned_by[column]) + or not self._identifiers_equal( + conn, owner_rows[column][0][4], self._OWNER_TARGET_COLUMNS[column] + ) + for column in columns + ): + found = "/".join( + f"{owner_rows[column][0][2]}({owner_rows[column][0][4]})" for column in columns + ) + configured = "/".join(owned_by[column] for column in columns) + raise ValueError( + f"The `{table}` table in {self.db_path} already belongs to '{found}', not to " + f"the configured '{configured}'. Structure tables are shared per database " + "file, so give each sessions_table/messages_table pair its own db_path." + ) + + def _resolve_base_table_owners(self, conn: sqlite3.Connection) -> dict[str, str]: + """Return the base-table names after SQLite has resolved configured identifiers.""" + try: + sessions_table = self._resolve_table_identifier(conn, self.sessions_table) + messages_table = self._resolve_table_identifier(conn, self.messages_table) + except sqlite3.OperationalError as exc: + if "no such table" not in str(exc).lower(): + raise + raise ValueError( + f"The configured base tables in {self.db_path} are missing. Construct an " + "AdvancedSQLiteSession with create_tables=True to initialize and claim the " + "database before opening it without table creation." + ) from exc + + base_foreign_keys = conn.execute( + f"PRAGMA foreign_key_list({self.messages_table})" + ).fetchall() + sessions_rows = [ + row + for row in base_foreign_keys + if self._identifiers_equal(conn, row[3], "session_id") + and self._identifiers_equal(conn, row[4], "session_id") + ] + if len(sessions_rows) != 1 or not self._identifiers_equal( + conn, sessions_rows[0][2], sessions_table + ): + found = sessions_rows[0][2] if len(sessions_rows) == 1 else "ambiguous ownership" + raise ValueError( + f"The configured messages table '{messages_table}' in {self.db_path} belongs to " + f"sessions table '{found}', not to the configured '{sessions_table}'. Give each " + "sessions_table/messages_table pair its own db_path." + ) + return {"session_id": sessions_table, "message_id": messages_table} + + @staticmethod + def _resolve_table_identifier(conn: sqlite3.Connection, identifier: str) -> str: + """Ask SQLite for the table object selected by a configured identifier token.""" + tables: set[str] = set() + + def authorizer( + action: int, + arg1: str | None, + _arg2: str | None, + _database: str | None, + _source: str | None, + ) -> int: + if action == sqlite3.SQLITE_READ and arg1 is not None: + tables.add(arg1) + return sqlite3.SQLITE_OK + + conn.set_authorizer(authorizer) + try: + conn.execute(f"SELECT * FROM {identifier} LIMIT 0").fetchall() + finally: + conn.set_authorizer(None) + try: + conn.execute("SELECT 1").fetchone() + except sqlite3.DatabaseError as exc: + if "not authorized" not in str(exc).lower(): + raise + conn.set_authorizer(_allow_all_sqlite_actions) + if len(tables) != 1: + raise ValueError(f"The configured table identifier '{identifier}' is ambiguous.") + return tables.pop() + + @staticmethod + def _identifiers_equal(conn: sqlite3.Connection, left: str, right: str) -> bool: + """Compare two table names the way SQLite compares identifiers. + + SQLite folds identifiers with ASCII rules only, so `NOCASE` is asked directly rather than + reimplemented. Python's ``casefold()`` would equate names SQLite keeps distinct, for + example ``ßsessions`` and ``sssessions``. + """ + row = conn.execute("SELECT ? = ? COLLATE NOCASE", (left, right)).fetchone() + return bool(row[0]) + + def _init_structure_tables(self, conn: sqlite3.Connection) -> None: """Add structure and usage tracking tables. Creates the message_structure, branch_reservations, session_clear_generations, and turn_usage tables with appropriate indexes for conversation branching and usage analytics. """ - with self._write_connection() as conn: - # Message structure with branch support - conn.execute(f""" - CREATE TABLE IF NOT EXISTS message_structure ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - message_id INTEGER NOT NULL, - branch_id TEXT NOT NULL DEFAULT 'main', - message_type TEXT NOT NULL, - sequence_number INTEGER NOT NULL, - user_turn_number INTEGER, - branch_turn_number INTEGER, - tool_name TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (session_id) - REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE, - FOREIGN KEY (message_id) - REFERENCES {self.messages_table}(id) ON DELETE CASCADE - ) - """) - - # Turn-level usage tracking with branch support and full JSON details - conn.execute(f""" - CREATE TABLE IF NOT EXISTS turn_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - branch_id TEXT NOT NULL DEFAULT 'main', - user_turn_number INTEGER NOT NULL, - requests INTEGER DEFAULT 0, - input_tokens INTEGER DEFAULT 0, - output_tokens INTEGER DEFAULT 0, - total_tokens INTEGER DEFAULT 0, - input_tokens_details JSON, - output_tokens_details JSON, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (session_id) - REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE, - UNIQUE(session_id, branch_id, user_turn_number) - ) - """) + # Message structure with branch support + conn.execute(f""" + CREATE TABLE IF NOT EXISTS message_structure ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + message_id INTEGER NOT NULL, + branch_id TEXT NOT NULL DEFAULT 'main', + message_type TEXT NOT NULL, + sequence_number INTEGER NOT NULL, + user_turn_number INTEGER, + branch_turn_number INTEGER, + tool_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) + REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE, + FOREIGN KEY (message_id) + REFERENCES {self.messages_table}(id) ON DELETE CASCADE + ) + """) - self._ensure_branch_reservations_table(conn) - self._ensure_session_clear_generations_table(conn) + # Turn-level usage tracking with branch support and full JSON details + conn.execute(f""" + CREATE TABLE IF NOT EXISTS turn_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + branch_id TEXT NOT NULL DEFAULT 'main', + user_turn_number INTEGER NOT NULL, + requests INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + input_tokens_details JSON, + output_tokens_details JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) + REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE, + UNIQUE(session_id, branch_id, user_turn_number) + ) + """) + + # Validate the owner-bearing tables before any helper queries or indexes consume them. + self._claim_structure_tables(conn) - # Indexes - conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_structure_session_seq - ON message_structure(session_id, sequence_number) - """) - conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_structure_branch - ON message_structure(session_id, branch_id) - """) - conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_structure_turn - ON message_structure(session_id, branch_id, user_turn_number) - """) - conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_structure_branch_seq - ON message_structure(session_id, branch_id, sequence_number) - """) - conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_turn_usage_session_turn - ON turn_usage(session_id, branch_id, user_turn_number) - """) - - conn.commit() + self._ensure_branch_reservations_table(conn) + self._ensure_session_clear_generations_table(conn) + + # Indexes + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_structure_session_seq + ON message_structure(session_id, sequence_number) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_structure_branch + ON message_structure(session_id, branch_id) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_structure_turn + ON message_structure(session_id, branch_id, user_turn_number) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_structure_branch_seq + ON message_structure(session_id, branch_id, sequence_number) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_turn_usage_session_turn + ON turn_usage(session_id, branch_id, user_turn_number) + """) async def add_items(self, items: list[TResponseInputItem]) -> None: """Add items to the session. diff --git a/src/agents/memory/sqlite_session.py b/src/agents/memory/sqlite_session.py index fc9f3fdb8f..286b063f03 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -4,8 +4,9 @@ import json import sqlite3 import threading +import time from collections.abc import Awaitable, Iterator -from contextlib import contextmanager +from contextlib import closing, contextmanager from pathlib import Path from typing import Any, ClassVar, TypeVar @@ -98,15 +99,16 @@ def __init__( try: if self._is_memory_db: self._shared_connection = sqlite3.connect(":memory:", check_same_thread=False) - self._shared_connection.execute("PRAGMA journal_mode=WAL") + self._configure_connection(self._shared_connection) self._init_db_for_connection(self._shared_connection) else: # For file databases, initialize the schema once since it persists with self._lock: - init_conn = sqlite3.connect(str(self.db_path), check_same_thread=False) - init_conn.execute("PRAGMA journal_mode=WAL") - self._init_db_for_connection(init_conn) - init_conn.close() + with closing( + sqlite3.connect(str(self.db_path), check_same_thread=False) + ) as init_conn: + self._configure_connection(init_conn) + self._init_db_for_connection(init_conn) except Exception: if self._lock_path is not None and not self._lock_released: self._release_file_lock(self._lock_path) @@ -197,7 +199,7 @@ def _get_connection(self) -> sqlite3.Connection: str(self.db_path), check_same_thread=False, ) - connection.execute("PRAGMA journal_mode=WAL") + self._configure_connection(connection) self._local.connection = connection with self._connections_lock: self._connections.add(connection) @@ -206,8 +208,28 @@ def _get_connection(self) -> sqlite3.Connection: ) return self._local.connection + @staticmethod + def _configure_connection(conn: sqlite3.Connection) -> None: + """Enable WAL, retrying its transient cross-process initialization lock.""" + timeout_row = conn.execute("PRAGMA busy_timeout").fetchone() + timeout_seconds = (timeout_row[0] if timeout_row is not None else 0) / 1000 + deadline = time.monotonic() + timeout_seconds + while True: + try: + conn.execute("PRAGMA journal_mode=WAL") + return + except sqlite3.OperationalError as exc: + if "locked" not in str(exc).lower() or time.monotonic() >= deadline: + raise + time.sleep(min(0.01, max(0, deadline - time.monotonic()))) + def _init_db_for_connection(self, conn: sqlite3.Connection) -> None: """Initialize the database schema for a specific connection.""" + self._create_schema_for_connection(conn) + conn.commit() + + def _create_schema_for_connection(self, conn: sqlite3.Connection) -> None: + """Create the database schema without committing the current transaction.""" conn.execute( f""" CREATE TABLE IF NOT EXISTS {self.sessions_table} ( @@ -238,8 +260,6 @@ def _init_db_for_connection(self, conn: sqlite3.Connection) -> None: """ ) - conn.commit() - def _insert_items(self, conn: sqlite3.Connection, items: list[TResponseInputItem]) -> None: conn.execute( f""" diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 398ee0d522..5383e553fb 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -33,6 +33,103 @@ pytestmark = pytest.mark.asyncio +def _claim_structure_tables_in_process( + db_path: str, + sessions_table: str, + messages_table: str, + ready: Any, + start: Any, + results: Any, +) -> None: + """Construct a create_tables session in a child process and report the outcome.""" + pair = (sessions_table, messages_table) + ready.set() + start.wait(timeout=30) + try: + session = AdvancedSQLiteSession( + session_id="concurrent", + db_path=db_path, + create_tables=True, + sessions_table=sessions_table, + messages_table=messages_table, + ) + session.close() + except ValueError: + results.put(("rejected", pair)) + except BaseException as exc: # pragma: no cover - surfaced in the assertion below + results.put((f"error:{type(exc).__name__}", pair)) + else: + results.put(("claimed", pair)) + + +def _create_owner_bearing_structure_tables( + db_path: Path, + *, + create_base_tables: bool = True, + message_foreign_keys: str = "", + usage_foreign_key: str = "", + message_session_column: str = "session_id", + message_id_column: str = "message_id", + usage_session_column: str = "session_id", +) -> None: + """Create structurally usable owner tables with caller-selected ownership metadata.""" + message_constraints = f", {message_foreign_keys}" if message_foreign_keys else "" + usage_constraint = f", {usage_foreign_key}" if usage_foreign_key else "" + with contextlib.closing(sqlite3.connect(db_path)) as conn: + if create_base_tables: + conn.execute(""" + CREATE TABLE agent_sessions ( + session_id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + message_data TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES agent_sessions (session_id) + ON DELETE CASCADE + ) + """) + conn.execute("CREATE TABLE wrong_sessions (session_id TEXT PRIMARY KEY)") + conn.execute(f""" + CREATE TABLE message_structure ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + {message_session_column} TEXT NOT NULL, + {message_id_column} INTEGER NOT NULL, + branch_id TEXT NOT NULL DEFAULT 'main', + message_type TEXT NOT NULL, + sequence_number INTEGER NOT NULL, + user_turn_number INTEGER, + branch_turn_number INTEGER, + tool_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + {message_constraints} + ) + """) + conn.execute(f""" + CREATE TABLE turn_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + {usage_session_column} TEXT NOT NULL, + branch_id TEXT NOT NULL DEFAULT 'main', + user_turn_number INTEGER NOT NULL, + requests INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + input_tokens_details JSON, + output_tokens_details JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(session_id, branch_id, user_turn_number) + {usage_constraint} + ) + """) + conn.commit() + + def _multiprocessing_context() -> Any: method = "spawn" if sys.platform == "win32" else "forkserver" return multiprocessing.get_context(method) @@ -438,28 +535,16 @@ def fail_structure_metadata(*_args: Any) -> None: async def test_structure_initialization_failure_invalidates_connection( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): - """Initialization must release its write lock even when rollback also fails.""" - - class FailingRollbackConnection(sqlite3.Connection): - def rollback(self) -> None: - raise RuntimeError("rollback failed") + """Initialization must close its transaction connection after a schema failure.""" - captured_connections: list[sqlite3.Connection] = [] + class TrackingConnection(sqlite3.Connection): + closed = False - class FailingRollbackInitSession(AdvancedSQLiteSession): - def _get_connection(self) -> sqlite3.Connection: - if not hasattr(self, "_test_connection"): - connection = sqlite3.connect( - str(self.db_path), - check_same_thread=False, - factory=FailingRollbackConnection, - ) - self._test_connection = connection - captured_connections.append(connection) - with self._connections_lock: - self._connections.add(connection) - return self._test_connection + def close(self) -> None: + self.closed = True + super().close() db_path = tmp_path / "advanced_init_failure.db" setup = AdvancedSQLiteSession( @@ -481,18 +566,32 @@ def _get_connection(self) -> sqlite3.Connection: finally: conflict.close() + captured_connections: list[TrackingConnection] = [] + real_connect = sqlite3.connect + + def connect(*args: Any, **kwargs: Any) -> TrackingConnection: + connection = cast( + TrackingConnection, + real_connect(*args, **kwargs, factory=TrackingConnection), + ) + captured_connections.append(connection) + return connection + + monkeypatch.setattr(sqlite3, "connect", connect) with pytest.raises(sqlite3.OperationalError, match="already a table"): - FailingRollbackInitSession( + AdvancedSQLiteSession( session_id="advanced_init_failure", db_path=db_path, create_tables=True, ) assert len(captured_connections) == 1 + assert captured_connections[0].closed is True with pytest.raises(sqlite3.ProgrammingError): captured_connections[0].execute("SELECT 1") - probe = sqlite3.connect(str(db_path), timeout=0) + monkeypatch.setattr(sqlite3, "connect", real_connect) + probe = real_connect(str(db_path), timeout=0) try: probe.execute("CREATE TABLE IF NOT EXISTS probe_lock (x INTEGER)") probe.commit() @@ -3789,3 +3888,423 @@ def __getattr__(self, name): assert await session.get_items() == [] finally: session.close() + + +async def test_structure_tables_reject_a_second_base_table_pair(tmp_path: Path) -> None: + """A second base-table pair in one file would read the first pair's structure rows.""" + db_path = tmp_path / "advanced_shared_structure.db" + first = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="first_sessions", + messages_table="first_messages", + ) + try: + await first.add_items([{"role": "user", "content": "first"}]) + + with pytest.raises(ValueError, match="first_sessions"): + AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="second_sessions", + messages_table="second_messages", + ) + + with contextlib.closing(sqlite3.connect(db_path)) as conn: + rejected_objects = conn.execute(""" + SELECT name FROM sqlite_master + WHERE name IN ( + 'second_sessions', + 'second_messages', + 'idx_second_messages_session_id' + ) + """).fetchall() + assert rejected_objects == [] + assert await first.get_items() == [{"role": "user", "content": "first"}] + finally: + first.close() + + +async def test_structure_tables_reject_changed_sessions_with_shared_messages( + tmp_path: Path, +) -> None: + """Canonicalization must not replace a changed caller-selected sessions table.""" + db_path = tmp_path / "advanced_shared_messages.db" + first = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="first_sessions", + messages_table="shared_messages", + ) + try: + await first.add_items([{"role": "user", "content": "first"}]) + + with pytest.raises(ValueError, match="first_sessions"): + AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="second_sessions", + messages_table="shared_messages", + ) + + with contextlib.closing(sqlite3.connect(db_path)) as conn: + assert ( + conn.execute( + "SELECT name FROM sqlite_master WHERE name = 'second_sessions'" + ).fetchall() + == [] + ) + assert await first.get_items() == [{"role": "user", "content": "first"}] + finally: + first.close() + + +async def test_structure_tables_reject_changed_messages_with_shared_sessions( + tmp_path: Path, +) -> None: + """A changed messages table must not share structure rows under one sessions table.""" + db_path = tmp_path / "advanced_shared_sessions.db" + first = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="shared_sessions", + messages_table="first_messages", + ) + try: + await first.add_items([{"role": "user", "content": "first"}]) + + with pytest.raises(ValueError, match="first_messages"): + AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="shared_sessions", + messages_table="second_messages", + ) + + with contextlib.closing(sqlite3.connect(db_path)) as conn: + rejected_objects = conn.execute(""" + SELECT name FROM sqlite_master + WHERE name IN ( + 'second_messages', + 'idx_second_messages_session_id' + ) + """).fetchall() + assert rejected_objects == [] + assert await first.get_items() == [{"role": "user", "content": "first"}] + finally: + first.close() + + +async def test_structure_tables_accept_equivalent_identifier_casing(tmp_path: Path) -> None: + """SQLite resolves table names case-insensitively, so a recased pair is the same pair.""" + db_path = tmp_path / "advanced_recased_structure.db" + first = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="FooSessions", + messages_table="FooMessages", + ) + try: + await first.add_items([{"role": "user", "content": "first"}]) + finally: + first.close() + + recased = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="foosessions", + messages_table="foomessages", + ) + try: + assert await recased.get_items() == [{"role": "user", "content": "first"}] + finally: + recased.close() + + +@pytest.mark.parametrize("create_tables", [False, True]) +async def test_structure_tables_accept_quoted_custom_session_table( + tmp_path: Path, create_tables: bool +) -> None: + """Released SQLiteSession accepts SQL-quoted custom session-table identifiers.""" + db_path = tmp_path / "advanced_quoted_session_table.db" + sessions_table = '"quoted_sessions"' + messages_table = "quoted_messages" + + if not create_tables: + setup = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table=sessions_table, + messages_table=messages_table, + ) + setup.close() + + session = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=create_tables, + sessions_table=sessions_table, + messages_table=messages_table, + ) + try: + await session.add_items([{"role": "user", "content": "quoted"}]) + assert await session.get_items() == [{"role": "user", "content": "quoted"}] + finally: + session.close() + + +async def test_identifier_resolution_leaves_connection_authorized() -> None: + """Temporary ownership resolution must not deny later SQL on supported Python versions.""" + with contextlib.closing(sqlite3.connect(":memory:")) as conn: + conn.execute("CREATE TABLE FooSessions (session_id TEXT PRIMARY KEY)") + assert AdvancedSQLiteSession._resolve_table_identifier(conn, '"foosessions"') == ( + "FooSessions" + ) + assert conn.execute("SELECT 1").fetchone() == (1,) + + +async def test_structure_tables_reject_distinct_non_ascii_identifiers(tmp_path: Path) -> None: + """SQLite folds identifiers with ASCII rules, so these are two different pairs. + + Python's `casefold()` equates `ßsessions` and `sssessions`, which would let the second pair + through and restore the cross-table mixing this change prevents. + """ + assert "ßsessions".casefold() == "sssessions".casefold() + + db_path = tmp_path / "advanced_non_ascii_structure.db" + first = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="ßsessions", + messages_table="ßmessages", + ) + try: + await first.add_items([{"role": "user", "content": "first"}]) + + with pytest.raises(ValueError, match="ßsessions"): + AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=True, + sessions_table="sssessions", + messages_table="ssmessages", + ) + + assert await first.get_items() == [{"role": "user", "content": "first"}] + finally: + first.close() + + +async def test_no_create_session_rejects_a_database_without_an_owner(tmp_path: Path) -> None: + """A no-create session must not open a file before a pair has claimed the structure tables. + + Accepting it would let another pair claim the tables afterwards, leaving this session writing + and reading structure rows owned by that other pair. + """ + db_path = tmp_path / "advanced_unclaimed_structure.db" + + with pytest.raises(ValueError, match="create_tables=True"): + AdvancedSQLiteSession(session_id="shared", db_path=db_path, create_tables=False) + + with contextlib.closing(sqlite3.connect(db_path)) as conn: + assert conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() == [] + + owner = AdvancedSQLiteSession(session_id="shared", db_path=db_path, create_tables=True) + try: + await owner.add_items([{"role": "user", "content": "first"}]) + finally: + owner.close() + + # Once a pair owns the layout, the same pair may open it without creating anything. + reader = AdvancedSQLiteSession(session_id="shared", db_path=db_path, create_tables=False) + try: + assert await reader.get_items() == [{"role": "user", "content": "first"}] + finally: + reader.close() + + +async def test_no_create_session_rejects_owner_metadata_without_base_tables( + tmp_path: Path, +) -> None: + """Complete-looking owner metadata cannot substitute for the configured base tables.""" + db_path = tmp_path / "advanced_missing_base_tables.db" + _create_owner_bearing_structure_tables( + db_path, + create_base_tables=False, + message_foreign_keys="FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id), " + "FOREIGN KEY (message_id) REFERENCES agent_messages(id)", + usage_foreign_key="FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id)", + ) + + with pytest.raises(ValueError, match="configured base tables"): + AdvancedSQLiteSession(session_id="shared", db_path=db_path, create_tables=False) + + with contextlib.closing(sqlite3.connect(db_path)) as conn: + base_tables = conn.execute(""" + SELECT name FROM sqlite_master + WHERE name IN ('agent_sessions', 'agent_messages') + """).fetchall() + assert base_tables == [] + + +@pytest.mark.parametrize("create_tables", [False, True]) +async def test_structure_tables_reject_an_ownerless_layout( + tmp_path: Path, create_tables: bool +) -> None: + """An existing structure table without owner foreign keys is not a usable layout.""" + db_path = tmp_path / "advanced_ownerless_structure.db" + _create_owner_bearing_structure_tables(db_path) + + with pytest.raises(ValueError, match="exactly one owner foreign key"): + AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=create_tables, + ) + + +@pytest.mark.parametrize("create_tables", [False, True]) +@pytest.mark.parametrize( + ("message_foreign_keys", "usage_foreign_key", "error"), + [ + ( + "FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id), " + "FOREIGN KEY (session_id) REFERENCES wrong_sessions(session_id), " + "FOREIGN KEY (message_id) REFERENCES agent_messages(id)", + "FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id)", + "exactly one owner foreign key", + ), + ( + "FOREIGN KEY (session_id) REFERENCES agent_sessions(wrong_id), " + "FOREIGN KEY (message_id) REFERENCES agent_messages(id)", + "FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id)", + "already belongs", + ), + ( + "FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id), " + "FOREIGN KEY (message_id) REFERENCES agent_messages(id)", + "FOREIGN KEY (session_id) REFERENCES wrong_sessions(session_id)", + "already belongs", + ), + ], +) +async def test_structure_tables_reject_malformed_owner_layouts( + tmp_path: Path, + create_tables: bool, + message_foreign_keys: str, + usage_foreign_key: str, + error: str, +) -> None: + """Owner tables must have exactly one complete foreign-key signature per owner.""" + db_path = tmp_path / "advanced_malformed_structure.db" + _create_owner_bearing_structure_tables( + db_path, + message_foreign_keys=message_foreign_keys, + usage_foreign_key=usage_foreign_key, + ) + + with pytest.raises(ValueError, match=error): + AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=create_tables, + ) + + +@pytest.mark.parametrize("create_tables", [False, True]) +async def test_structure_tables_accept_equivalent_child_column_casing( + tmp_path: Path, create_tables: bool +) -> None: + """SQLite resolves the child and referenced sides of foreign keys identically.""" + db_path = tmp_path / "advanced_recased_owner_columns.db" + _create_owner_bearing_structure_tables( + db_path, + message_session_column="SESSION_ID", + message_id_column="MESSAGE_ID", + usage_session_column="SESSION_ID", + message_foreign_keys="FOREIGN KEY (SESSION_ID) REFERENCES agent_sessions(session_id), " + "FOREIGN KEY (MESSAGE_ID) REFERENCES agent_messages(id)", + usage_foreign_key="FOREIGN KEY (SESSION_ID) REFERENCES agent_sessions(session_id)", + ) + + session = AdvancedSQLiteSession( + session_id="shared", + db_path=db_path, + create_tables=create_tables, + ) + session.close() + + +@pytest.mark.review_optional +async def test_concurrent_structure_table_claims_leave_one_coherent_owner(tmp_path: Path) -> None: + """Two processes claiming a fresh file with different pairs must not split the layout.""" + db_path = tmp_path / "advanced_concurrent_claim.db" + pairs = [("a_sessions", "a_messages"), ("b_sessions", "b_messages")] + + context = _multiprocessing_context() + start = context.Event() + results = context.Queue() + ready_events = [context.Event(), context.Event()] + processes = [ + context.Process( + target=_claim_structure_tables_in_process, + args=(str(db_path), sessions_table, messages_table, ready, start, results), + ) + for (sessions_table, messages_table), ready in zip(pairs, ready_events, strict=False) + ] + + try: + for process in processes: + process.start() + for ready in ready_events: + assert ready.wait(timeout=30) + start.set() + for process in processes: + process.join(timeout=30) + assert process.exitcode == 0 + + outcomes = [results.get(timeout=5), results.get(timeout=5)] + claimed = [pair for status, pair in outcomes if status == "claimed"] + assert len(claimed) == 1, outcomes + assert all(status in {"claimed", "rejected"} for status, _ in outcomes), outcomes + + # Every owner-bearing structure table must name the one pair that won. + winner_sessions, winner_messages = claimed[0] + loser_sessions, loser_messages = next(pair for pair in pairs if pair != claimed[0]) + with contextlib.closing(sqlite3.connect(db_path)) as conn: + structure_owners = { + row[3]: row[2] for row in conn.execute("PRAGMA foreign_key_list(message_structure)") + } + usage_owners = { + row[3]: row[2] for row in conn.execute("PRAGMA foreign_key_list(turn_usage)") + } + assert structure_owners == { + "session_id": winner_sessions, + "message_id": winner_messages, + } + assert usage_owners == {"session_id": winner_sessions} + rejected_objects = conn.execute( + "SELECT name FROM sqlite_master WHERE name IN (?, ?, ?)", + ( + loser_sessions, + loser_messages, + f"idx_{loser_messages}_session_id", + ), + ).fetchall() + assert rejected_objects == [] + finally: + start.set() + for process in processes: + if process.is_alive(): + process.terminate() + process.join(timeout=5) From b4faf7090c1c27e9638de163c6b5197f3b250dd3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 11:15:36 +0900 Subject: [PATCH 338/473] feat(core): add model call timeouts (#4428) --- src/agents/__init__.py | 2 + src/agents/exceptions.py | 10 + src/agents/model_settings.py | 12 +- src/agents/models/openai_responses.py | 19 +- src/agents/run_internal/model_retry.py | 251 +++++++- src/agents/run_internal/run_loop.py | 2 + src/agents/testing/model.py | 20 +- src/agents/util/_error_tracing.py | 41 ++ tests/model_settings/test_serialization.py | 4 +- tests/models/test_model_retry.py | 708 +++++++++++++++++++++ tests/test_provider_span_errors.py | 33 + tests/test_scripted_model.py | 31 + 12 files changed, 1101 insertions(+), 32 deletions(-) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 3105b4aa56..a051befd2c 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -25,6 +25,7 @@ MCPToolCancellationError, ModelBehaviorError, ModelRefusalError, + ModelTimeoutError, OutputGuardrailTripwireTriggered, RunErrorDetails, ToolInputGuardrailTripwireTriggered, @@ -412,6 +413,7 @@ def enable_verbose_stdout_logging() -> None: "MCPToolCancellationError", "ModelBehaviorError", "ModelRefusalError", + "ModelTimeoutError", "ToolTimeoutError", "UserError", "InputGuardrail", diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index a07981906f..c490edc77d 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -474,6 +474,16 @@ def __init__(self, refusal: str): super().__init__(f"Model refused to produce output: {refusal}") +class ModelTimeoutError(AgentsException): + """Exception raised when a model-call attempt exceeds its configured timeout.""" + + timeout_seconds: float + + def __init__(self, timeout_seconds: float): + self.timeout_seconds = timeout_seconds + super().__init__(f"Model call timed out after {timeout_seconds:g} seconds.") + + class UserError(AgentsException): """Exception raised when the user makes an error using the SDK.""" diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py index d9db8daefa..b25bda5d7b 100644 --- a/src/agents/model_settings.py +++ b/src/agents/model_settings.py @@ -9,7 +9,7 @@ from openai.types.responses import ResponseIncludable from openai.types.responses.response_create_params import ContextManagement, PromptCacheOptions from openai.types.shared import Reasoning -from pydantic import GetCoreSchemaHandler, TypeAdapter +from pydantic import Field, FiniteFloat, GetCoreSchemaHandler, TypeAdapter from pydantic.dataclasses import dataclass from pydantic_core import core_schema @@ -81,6 +81,7 @@ class MCPToolChoice: "retry", "context_management", "prompt_cache_options", + "timeout", ) @@ -211,6 +212,14 @@ class ModelSettings: usage from the provider; use ``include_usage`` separately when a streaming provider requires it. """ + timeout: Annotated[FiniteFloat, Field(gt=0)] | None = None + """Maximum duration in seconds for each model-call attempt. + + The timeout is enforced cooperatively through normal asyncio cancellation. It bounds the + complete model attempt, including transport waits, but does not replace provider-specific + phase timeout configuration or bound the full run, tool calls, or retry backoff. + """ + if TYPE_CHECKING: def __init__( @@ -239,6 +248,7 @@ def __init__( context_management: list[ContextManagement] | None = None, prompt_cache_options: PromptCacheOptions | None = None, preserve_raw_usage: bool | None = None, + timeout: Annotated[FiniteFloat, Field(gt=0)] | None = None, ) -> None: ... def resolve(self, override: ModelSettings | dict[str, Any] | None) -> ModelSettings: diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index a8aea40590..a382c9d28a 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -84,7 +84,10 @@ _response_usage_to_usage, model_usage_to_span_usage, ) -from ..util._error_tracing import record_model_error_on_span +from ..util._error_tracing import ( + record_current_task_model_timeout_on_span, + record_model_error_on_span, +) from ..util._json import _to_dump_compatible from ..version import __version__ from ._openai_retry import get_openai_retry_advice @@ -597,6 +600,13 @@ async def get_response( if tracing.include_data(): span_response.span_data.response = response span_response.span_data.input = input + except asyncio.CancelledError: + record_current_task_model_timeout_on_span( + span_response, + message="Error getting response", + trace_include_sensitive_data=tracing.include_data(), + ) + raise except Exception as e: span_response.set_error( SpanError( @@ -732,6 +742,13 @@ async def stream_response( if final_response is not None and tracing.include_data(): span_response.span_data.response = final_response span_response.span_data.input = input + except asyncio.CancelledError: + record_current_task_model_timeout_on_span( + span_response, + message="Error streaming response", + trace_include_sensitive_data=tracing.include_data(), + ) + raise except Exception as e: span_response.set_error( SpanError( diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index 5452cdada2..994e1c0e32 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -4,12 +4,13 @@ import random from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping from inspect import isawaitable -from typing import Any +from typing import Any, TypeVar import httpx2 from openai import APIConnectionError, APITimeoutError, BadRequestError from .._httpx_compat import is_legacy_httpx_instance +from ..exceptions import ModelTimeoutError from ..items import ModelResponse, TResponseStreamEvent from ..logger import log_model_action_debug, logger from ..models._retry_runtime import ( @@ -34,11 +35,13 @@ retry_policy_retries_safe_transport_errors, ) from ..usage import RequestUsage, Usage +from ..util._error_tracing import mark_model_timeout_task GetResponseCallable = Callable[[], Awaitable[ModelResponse]] GetStreamCallable = Callable[[], AsyncIterator[TResponseStreamEvent]] RewindCallable = Callable[[], Awaitable[None]] GetRetryAdviceCallable = Callable[[ModelRetryAdviceRequest], ModelRetryAdvice | None] +T = TypeVar("T") DEFAULT_INITIAL_DELAY_SECONDS = 0.25 DEFAULT_MAX_DELAY_SECONDS = 2.0 @@ -69,6 +72,9 @@ def _is_conversation_locked_error(error: Exception) -> bool: def _is_abort_like_error(error: Exception) -> bool: + if isinstance(error, ModelTimeoutError): + return False + if isinstance(error, asyncio.CancelledError): return True @@ -120,7 +126,7 @@ def _normalize_retry_error( is_abort=_is_abort_like_error(error), is_network_error=_is_network_like_error(error), is_timeout=any( - isinstance(candidate, APITimeoutError | TimeoutError) + isinstance(candidate, APITimeoutError | TimeoutError | ModelTimeoutError) for candidate in _iter_error_chain(error) ), ) @@ -204,6 +210,111 @@ async def _sleep_for_retry(delay: float) -> None: await asyncio.sleep(delay) +async def _drain_model_attempt_task(task: asyncio.Future[Any]) -> BaseException | None: + """Wait for a cancelled model task without letting its outcome replace cancellation.""" + try: + await task + except BaseException as error: + return error + return None + + +async def _await_cleanup_ignoring_cancellation( + cleanup_task: asyncio.Task[BaseException | None], +) -> BaseException | None: + """Finish cooperative cleanup before restoring an already-received parent cancellation.""" + while True: + try: + return await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + if cleanup_task.done(): + return cleanup_task.result() + + +async def _cancel_and_drain_model_attempt_task( + task: asyncio.Future[Any], + timeout_error: ModelTimeoutError | None = None, + *, + cancel_cleanup: bool = False, +) -> BaseException | None: + if timeout_error is not None: + mark_model_timeout_task(task, timeout_error) + task.cancel() + if cancel_cleanup: + # Give a cancelled model operation one event-loop turn to enter its owner-owned cleanup, + # then interrupt a cooperative cleanup wait that must not outlive the SDK deadline. + # Caller-owned cancellation and early consumer close do not opt into this second cancel. + await asyncio.sleep(0) + if not task.done(): + task.cancel() + cleanup_task = asyncio.create_task(_drain_model_attempt_task(task)) + try: + return await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + await _await_cleanup_ignoring_cancellation(cleanup_task) + raise + + +async def _run_stream_attempt_in_one_task( + get_stream: GetStreamCallable, + requests: asyncio.Queue[None], + results: asyncio.Queue[tuple[str, TResponseStreamEvent | BaseException | None]], +) -> None: + """Own stream construction, pulls, and cleanup in one task context.""" + stream: AsyncIterator[TResponseStreamEvent] | None = None + terminal_result: tuple[str, TResponseStreamEvent | BaseException | None] | None = None + try: + stream = get_stream() + while True: + await requests.get() + try: + event = await stream.__anext__() + except StopAsyncIteration: + terminal_result = ("done", None) + break + except BaseException as error: + terminal_result = ("error", error) + break + results.put_nowait(("event", event)) + except BaseException as error: + terminal_result = ("error", error) + finally: + if stream is not None: + await _close_async_iterator_quietly(stream) + if terminal_result is not None: + results.put_nowait(terminal_result) + + +async def _await_model_attempt( + awaitable: Awaitable[T], + timeout: float | None, + *, + timeout_error_seconds: float | None = None, +) -> T: + """Await one model operation and turn only this deadline into a timeout error.""" + if timeout is None: + return await awaitable + + task = asyncio.ensure_future(awaitable) + try: + done, pending = await asyncio.wait({task}, timeout=timeout) + except asyncio.CancelledError: + await _cancel_and_drain_model_attempt_task(task) + raise + + if task in done: + return await task + + timeout_error = ModelTimeoutError( + timeout_error_seconds if timeout_error_seconds is not None else timeout + ) + await _cancel_and_drain_model_attempt_task(task, timeout_error, cancel_cleanup=True) + # A traceback retains this frame's locals. Discard every reference that can keep the + # cancelled provider task, its cleanup exception, or provider payload locals reachable. + del awaitable, task, done, pending + raise timeout_error from None + + def _build_zero_request_usage_entry() -> RequestUsage: return RequestUsage( input_tokens=0, @@ -468,6 +579,7 @@ async def get_response_with_retry( get_retry_advice: GetRetryAdviceCallable, previous_response_id: str | None, conversation_id: str | None, + timeout: float | None = None, replay_unsafe_request: bool = False, ) -> ModelResponse: request_attempt = 1 @@ -497,7 +609,7 @@ async def get_response_with_retry( ), websocket_pre_event_retries_disabled(disable_websocket_pre_event_retry), ): - response = await get_response() + response = await _await_model_attempt(get_response(), timeout) response.usage = apply_retry_attempt_usage( response.usage, failed_policy_attempts + compatibility_retries_taken, @@ -577,6 +689,7 @@ async def stream_response_with_retry( get_retry_advice: GetRetryAdviceCallable, previous_response_id: str | None, conversation_id: str | None, + timeout: float | None = None, failed_retry_attempts_out: list[int] | None = None, replay_unsafe_request: bool = False, ) -> AsyncGenerator[TResponseStreamEvent, None]: @@ -595,6 +708,8 @@ async def stream_response_with_retry( while True: emitted_retry_unsafe_event = False stream: AsyncIterator[TResponseStreamEvent] | None = None + stream_owner: asyncio.Task[None] | None = None + deadline = asyncio.get_running_loop().time() + timeout if timeout is not None else None try: disable_provider_managed_retries = _should_disable_provider_managed_retries( retry_settings, @@ -602,33 +717,113 @@ async def stream_response_with_retry( stateful_request=stateful_request, replay_unsafe_request=replay_unsafe_request, ) - # Pull stream events under the retry-disable context, but yield them outside it so - # unrelated model calls made by the consumer do not inherit this setting. - with ( - provider_managed_retries_disabled(disable_provider_managed_retries), - websocket_pre_event_retries_disabled(disable_websocket_pre_event_retry), - ): - stream = get_stream() - while True: - try: - with ( - provider_managed_retries_disabled(disable_provider_managed_retries), - websocket_pre_event_retries_disabled(disable_websocket_pre_event_retry), - ): - event = await stream.__anext__() - except StopAsyncIteration: - await _close_async_iterator_quietly(stream) - return - if _stream_event_blocks_retry(event): - emitted_retry_unsafe_event = True - if failed_retry_attempts_out is not None: - failed_retry_attempts_out[:] = [ - failed_policy_attempts + compatibility_retries_taken - ] - yield event + stream_requests: asyncio.Queue[None] | None = None + stream_results: ( + asyncio.Queue[tuple[str, TResponseStreamEvent | BaseException | None]] | None + ) = None + if timeout is None: + # Pull stream events under the retry-disable context, but yield them outside it + # so unrelated model calls made by the consumer do not inherit this setting. + with ( + provider_managed_retries_disabled(disable_provider_managed_retries), + websocket_pre_event_retries_disabled(disable_websocket_pre_event_retry), + ): + stream = get_stream() + else: + stream_requests = asyncio.Queue() + stream_results = asyncio.Queue() + with ( + provider_managed_retries_disabled(disable_provider_managed_retries), + websocket_pre_event_retries_disabled(disable_websocket_pre_event_retry), + ): + stream_owner = asyncio.create_task( + _run_stream_attempt_in_one_task( + get_stream, + stream_requests, + stream_results, + ) + ) + try: + while True: + try: + if timeout is None: + assert stream is not None + with ( + provider_managed_retries_disabled(disable_provider_managed_retries), + websocket_pre_event_retries_disabled( + disable_websocket_pre_event_retry + ), + ): + event = await stream.__anext__() + else: + assert stream_owner is not None + assert stream_requests is not None + assert stream_results is not None + assert deadline is not None + stream_requests.put_nowait(None) + remaining = max(deadline - asyncio.get_running_loop().time(), 0.0) + try: + result_kind, result_value = await _await_model_attempt( + stream_results.get(), + remaining, + timeout_error_seconds=timeout, + ) + except ModelTimeoutError as error: + await _cancel_and_drain_model_attempt_task( + stream_owner, + error, + cancel_cleanup=True, + ) + raise + except asyncio.CancelledError: + await _cancel_and_drain_model_attempt_task(stream_owner) + raise + if result_kind == "done": + remaining = max(deadline - asyncio.get_running_loop().time(), 0.0) + await _await_model_attempt( + stream_owner, + remaining, + timeout_error_seconds=timeout, + ) + return + if result_kind == "error": + remaining = max(deadline - asyncio.get_running_loop().time(), 0.0) + await _await_model_attempt( + stream_owner, + remaining, + timeout_error_seconds=timeout, + ) + assert isinstance(result_value, BaseException) + raise result_value + assert result_kind == "event" + assert result_value is not None + assert not isinstance(result_value, BaseException) + event = result_value + except StopAsyncIteration: + if stream_owner is None: + await _close_async_iterator_quietly(stream) + return + if _stream_event_blocks_retry(event): + emitted_retry_unsafe_event = True + if failed_retry_attempts_out is not None: + failed_retry_attempts_out[:] = [ + failed_policy_attempts + compatibility_retries_taken + ] + yield event + finally: + if stream_owner is not None and not stream_owner.done(): + await _cancel_and_drain_model_attempt_task(stream_owner) return except BaseException as error: - await _close_async_iterator_quietly(stream) + if isinstance(error, ModelTimeoutError): + # The timed owner has already been cancelled and drained. Do not retain its + # task or result queues in the public timeout traceback frame. + stream = None + stream_owner = None + stream_requests = None + stream_results = None + if stream_owner is None: + await _close_async_iterator_quietly(stream) if isinstance(error, asyncio.CancelledError | GeneratorExit): raise if not isinstance(error, Exception): diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index ff1b4a09da..90a1320a84 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -2081,6 +2081,7 @@ async def rewind_model_request() -> None: get_retry_advice=model.get_retry_advice, previous_response_id=previous_response_id, conversation_id=conversation_id, + timeout=model_settings.timeout, failed_retry_attempts_out=stream_failed_retry_attempts, replay_unsafe_request=any( isinstance(tool, ProgrammaticToolCallingTool) for tool in all_tools @@ -2463,6 +2464,7 @@ async def rewind_model_request() -> None: get_retry_advice=model.get_retry_advice, previous_response_id=previous_response_id, conversation_id=conversation_id, + timeout=model_settings.timeout, replay_unsafe_request=any( isinstance(tool, ProgrammaticToolCallingTool) for tool in all_tools ), diff --git a/src/agents/testing/model.py b/src/agents/testing/model.py index 42ea6a9f2a..d14576ee26 100644 --- a/src/agents/testing/model.py +++ b/src/agents/testing/model.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import copy import inspect import json @@ -80,7 +81,10 @@ _attach_raw_usage_snapshot, _raw_usage_snapshot, ) -from ..util._error_tracing import REDACTED_TRACE_ERROR_MESSAGE +from ..util._error_tracing import ( + REDACTED_TRACE_ERROR_MESSAGE, + record_current_task_model_timeout_on_span, +) class ModelScriptError(Exception): @@ -350,6 +354,13 @@ async def get_response( retry_advice_synced = True raise step.error return self._model_response(step, call.model_settings) + except asyncio.CancelledError: + record_current_task_model_timeout_on_span( + span, + message="Error", + trace_include_sensitive_data=call.tracing.include_data(), + ) + raise except Exception as error: if not retry_advice_synced: self._forget_retry_advice(error) @@ -426,6 +437,13 @@ async def stream_response( ) for event in events: yield event + except asyncio.CancelledError: + record_current_task_model_timeout_on_span( + span, + message="Error", + trace_include_sensitive_data=call.tracing.include_data(), + ) + raise except Exception as error: if not retry_advice_synced: self._forget_retry_advice(error) diff --git a/src/agents/util/_error_tracing.py b/src/agents/util/_error_tracing.py index 1967b6d31f..c06a4c6482 100644 --- a/src/agents/util/_error_tracing.py +++ b/src/agents/util/_error_tracing.py @@ -1,12 +1,46 @@ +import asyncio import contextlib from collections.abc import Iterator from typing import Any from .. import _debug +from ..exceptions import ModelTimeoutError from ..logger import logger from ..tracing import Span, SpanError, get_current_span REDACTED_TRACE_ERROR_MESSAGE = "Error details are redacted." +_MODEL_TIMEOUT_ERROR_ATTR = "_openai_agents_model_timeout_error" + + +def mark_model_timeout_task(task: asyncio.Future[Any], error: ModelTimeoutError) -> None: + """Mark a model-owned task so cancellation-aware spans can record its timeout.""" + setattr(task, _MODEL_TIMEOUT_ERROR_ATTR, error) + + +def get_current_task_model_timeout_error() -> ModelTimeoutError | None: + """Return the timeout that is cancelling the current model-owned task, if any.""" + task = asyncio.current_task() + error = getattr(task, _MODEL_TIMEOUT_ERROR_ATTR, None) if task is not None else None + return error if isinstance(error, ModelTimeoutError) else None + + +def record_current_task_model_timeout_on_span( + span: Span[Any], + *, + message: str, + trace_include_sensitive_data: bool, +) -> bool: + """Record a marked timeout cancellation on the current model span.""" + timeout_error = get_current_task_model_timeout_error() + if timeout_error is None: + return False + record_model_error_on_span( + span, + message=message, + error=timeout_error, + trace_include_sensitive_data=trace_include_sensitive_data, + ) + return True def get_trace_error( @@ -102,6 +136,13 @@ def model_span_errors( """ try: yield + except asyncio.CancelledError: + record_current_task_model_timeout_on_span( + span, + message=message, + trace_include_sensitive_data=trace_include_sensitive_data, + ) + raise except Exception as error: record_model_error_on_span( span, diff --git a/tests/model_settings/test_serialization.py b/tests/model_settings/test_serialization.py index d458a8a7b3..9d75eeacc5 100644 --- a/tests/model_settings/test_serialization.py +++ b/tests/model_settings/test_serialization.py @@ -127,6 +127,7 @@ def test_all_fields_serialization() -> None: context_management=[{"type": "compaction", "compact_threshold": 200000}], prompt_cache_options={"mode": "explicit", "ttl": "30m"}, preserve_raw_usage=True, + timeout=1.25, ) # Verify that every single field is set to a non-None value @@ -158,10 +159,11 @@ def test_gpt_5_6_reasoning_and_prompt_cache_serialization() -> None: def test_usage_preservation_is_appended_to_public_field_order() -> None: field_names = [field.name for field in fields(ModelSettings)] - assert field_names[-3:] == [ + assert field_names[-4:] == [ "context_management", "prompt_cache_options", "preserve_raw_usage", + "timeout", ] diff --git a/tests/models/test_model_retry.py b/tests/models/test_model_retry.py index 30efa2bb04..c8079ab8a7 100644 --- a/tests/models/test_model_retry.py +++ b/tests/models/test_model_retry.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import AsyncIterator +from contextvars import ContextVar from typing import Any, cast import httpx2 @@ -9,7 +10,9 @@ from openai import APIConnectionError, APIStatusError, BadRequestError from pydantic import ValidationError +from agents.exceptions import ModelTimeoutError from agents.items import ModelResponse, TResponseStreamEvent +from agents.model_settings import ModelSettings from agents.models._openai_retry import get_openai_retry_advice from agents.models._retry_runtime import ( should_disable_provider_managed_retries, @@ -58,6 +61,19 @@ def test_model_retry_backoff_settings_allow_zero_values() -> None: assert backoff.multiplier == 0 +@pytest.mark.parametrize("timeout", [0, -0.1, float("inf"), float("nan")]) +def test_model_settings_rejects_invalid_model_call_timeout(timeout: float) -> None: + with pytest.raises(ValidationError): + ModelSettings(timeout=timeout) + + +def test_model_settings_accepts_finite_model_call_timeout() -> None: + settings = ModelSettings(timeout=1.25) + + assert settings.timeout == 1.25 + assert settings.to_traceable_dict()["timeout"] == 1.25 + + def test_retry_capabilities_preserve_falsey_policy() -> None: class FalseyPolicy: _openai_agents_retries_safe_transport_errors: bool @@ -130,6 +146,265 @@ def _status_error_without_code(status_code: int, body_code: str = "server_error" ) +@pytest.mark.asyncio +async def test_get_response_with_retry_times_out_and_retries_stateless_attempt() -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + if calls == 1: + await asyncio.Event().wait() + return ModelResponse(output=[get_text_message("ok")], usage=Usage(), response_id="resp") + + result = await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ) + + assert calls == 2 + assert result.response_id == "resp" + + +@pytest.mark.asyncio +async def test_get_response_with_retry_blocks_unsafe_stateful_timeout_replay() -> None: + calls = 0 + + async def get_response() -> ModelResponse: + nonlocal calls + calls += 1 + await asyncio.Event().wait() + raise AssertionError("unreachable") + + with pytest.raises(ModelTimeoutError, match="timed out after 0.01 seconds"): + await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id="conv_123", + timeout=0.01, + ) + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_model_timeout_discards_cleanup_exception_graph() -> None: + sensitive_payload = "sensitive provider payload" + + async def get_response() -> ModelResponse: + try: + await asyncio.Event().wait() + except asyncio.CancelledError as exc: + raise RuntimeError(sensitive_payload) from exc + raise AssertionError("unreachable") + + with pytest.raises(ModelTimeoutError) as exc_info: + await get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ) + + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert sensitive_payload not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if traceback.tb_frame.f_code.co_name == "_await_model_attempt": + frame_locals = traceback.tb_frame.f_locals + assert "awaitable" not in frame_locals + assert "task" not in frame_locals + assert "done" not in frame_locals + assert "pending" not in frame_locals + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_stream_timeout_discards_owner_task_from_traceback_locals() -> None: + sensitive_payload = "sensitive stream cleanup payload" + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + try: + await asyncio.Event().wait() + finally: + raise RuntimeError(sensitive_payload) + yield cast(TResponseStreamEvent, {"type": "response.created"}) + + return iterator() + + with pytest.raises(ModelTimeoutError) as exc_info: + async for _event in stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ): + pass + + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + traceback = exc_info.value.__traceback__ + while traceback is not None: + if traceback.tb_frame.f_code.co_name == "stream_response_with_retry": + frame_locals = traceback.tb_frame.f_locals + assert frame_locals.get("stream_owner") is None + assert frame_locals.get("stream_requests") is None + assert frame_locals.get("stream_results") is None + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_model_timeout_cancels_blocked_cleanup_after_deadline() -> None: + cleanup_started = asyncio.Event() + release_cleanup = asyncio.Event() + + async def get_response() -> ModelResponse: + try: + await asyncio.Event().wait() + finally: + cleanup_started.set() + await release_cleanup.wait() + raise AssertionError("unreachable") + + task = asyncio.create_task( + get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ) + ) + await cleanup_started.wait() + done, _ = await asyncio.wait({task}, timeout=0.2) + if task not in done: + release_cleanup.set() + await task + pytest.fail("Non-streaming timeout cleanup did not receive a second cancellation.") + + with pytest.raises(ModelTimeoutError) as exc_info: + await task + + assert exc_info.value.timeout_seconds == 0.01 + + +@pytest.mark.asyncio +async def test_get_response_with_retry_preserves_parent_cancellation() -> None: + started = asyncio.Event() + + async def get_response() -> ModelResponse: + started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + task = asyncio.create_task( + get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=10, + ) + ) + await started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_get_response_with_retry_preserves_parent_cancellation_during_timeout_cleanup() -> ( + None +): + cleanup_started = asyncio.Event() + release_cleanup = asyncio.Event() + + async def get_response() -> ModelResponse: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cleanup_started.set() + await release_cleanup.wait() + raise AssertionError("unreachable") + + task = asyncio.create_task( + get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ) + ) + await cleanup_started.wait() + task.cancel() + release_cleanup.set() + + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_get_response_with_retry_preserves_parent_cancellation_over_cleanup_error() -> None: + started = asyncio.Event() + + async def get_response() -> ModelResponse: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError as exc: + raise RuntimeError("cleanup failed") from exc + raise AssertionError("unreachable") + + task = asyncio.create_task( + get_response_with_retry( + get_response=get_response, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=10, + ) + ) + await started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + @pytest.mark.asyncio async def test_programmatic_request_disables_hidden_provider_retries() -> None: provider_retry_flags: list[bool] = [] @@ -1516,6 +1791,439 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] +@pytest.mark.asyncio +async def test_stream_response_with_retry_retries_timeout_before_output() -> None: + attempts = 0 + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + if attempts == 1: + await asyncio.Event().wait() + yield cast(TResponseStreamEvent, {"type": "response.created"}) + + return iterator() + + events = [ + event + async for event in stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ) + ] + + assert attempts == 2 + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_keeps_one_context_for_timed_attempt() -> None: + context_value: ContextVar[str | None] = ContextVar("context_value", default=None) + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + token = context_value.set("active") + try: + yield cast(TResponseStreamEvent, {"type": "response.created"}) + yield cast(TResponseStreamEvent, {"type": "response.in_progress"}) + finally: + context_value.reset(token) + + return iterator() + + events = [ + event + async for event in stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=1, + ) + ] + + assert events == [ + cast(TResponseStreamEvent, {"type": "response.created"}), + cast(TResponseStreamEvent, {"type": "response.in_progress"}), + ] + assert context_value.get() is None + + +@pytest.mark.asyncio +async def test_timed_stream_constructs_and_closes_iterator_in_one_context() -> None: + context_value: ContextVar[str | None] = ContextVar("context_value", default=None) + + class ConstructionScopedStream: + def __init__(self) -> None: + self.token = context_value.set("active") + self.emitted = False + + def __aiter__(self) -> ConstructionScopedStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + if self.emitted: + raise StopAsyncIteration + self.emitted = True + return cast(TResponseStreamEvent, {"type": "response.created"}) + + async def aclose(self) -> None: + context_value.reset(self.token) + + events = [ + event + async for event in stream_response_with_retry( + get_stream=ConstructionScopedStream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=1, + ) + ] + + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + assert context_value.get() is None + + +@pytest.mark.asyncio +async def test_timed_stream_early_close_keeps_generator_cleanup_context() -> None: + context_value: ContextVar[str | None] = ContextVar("context_value", default=None) + cleanup_state: list[str] = [] + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + token = context_value.set("active") + try: + yield cast(TResponseStreamEvent, {"type": "response.created"}) + await asyncio.Event().wait() + finally: + context_value.reset(token) + cleanup_state.append("reset") + + return iterator() + + stream = stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=1, + ) + + assert await stream.__anext__() == cast(TResponseStreamEvent, {"type": "response.created"}) + await stream.aclose() + + assert cleanup_state == ["reset"] + assert context_value.get() is None + + +@pytest.mark.asyncio +async def test_timed_stream_early_close_allows_cooperative_cleanup() -> None: + cleanup_state: list[str] = [] + + class CooperativeCloseStream: + def __init__(self) -> None: + self.emitted = False + + def __aiter__(self) -> CooperativeCloseStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + if not self.emitted: + self.emitted = True + return cast(TResponseStreamEvent, {"type": "response.created"}) + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def aclose(self) -> None: + await asyncio.sleep(0) + cleanup_state.append("closed") + + stream = stream_response_with_retry( + get_stream=CooperativeCloseStream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=1, + ) + + assert await stream.__anext__() == cast(TResponseStreamEvent, {"type": "response.created"}) + await stream.aclose() + + assert cleanup_state == ["closed"] + + +@pytest.mark.asyncio +async def test_timed_stream_parent_cancellation_allows_cooperative_cleanup() -> None: + read_started = asyncio.Event() + cleanup_state: list[str] = [] + + class CooperativeCancelStream: + def __aiter__(self) -> CooperativeCancelStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + read_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def aclose(self) -> None: + await asyncio.sleep(0) + cleanup_state.append("closed") + + async def consume() -> None: + async for _event in stream_response_with_retry( + get_stream=CooperativeCancelStream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=1, + ): + pass + + task = asyncio.create_task(consume()) + await read_started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert cleanup_state == ["closed"] + + +@pytest.mark.asyncio +async def test_timed_stream_closes_provider_iterator_once() -> None: + class CloseCountingStream: + def __init__(self) -> None: + self.emitted = False + self.close_calls = 0 + + def __aiter__(self) -> CloseCountingStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + if self.emitted: + raise StopAsyncIteration + self.emitted = True + return cast(TResponseStreamEvent, {"type": "response.created"}) + + async def aclose(self) -> None: + self.close_calls += 1 + + provider_stream = CloseCountingStream() + events = [ + event + async for event in stream_response_with_retry( + get_stream=lambda: provider_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=1, + ) + ] + + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + assert provider_stream.close_calls == 1 + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_does_not_retry_timeout_after_output() -> None: + attempts = 0 + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + yield cast(TResponseStreamEvent, {"type": "response.output_item.added"}) + await asyncio.Event().wait() + + return iterator() + + with pytest.raises(ModelTimeoutError): + async for _event in stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ): + pass + + assert attempts == 1 + + +@pytest.mark.asyncio +async def test_stream_timeout_reports_configured_attempt_timeout() -> None: + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + yield cast(TResponseStreamEvent, {"type": "response.created"}) + await asyncio.Event().wait() + + return iterator() + + stream = stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.05, + ) + assert await stream.__anext__() == cast(TResponseStreamEvent, {"type": "response.created"}) + await asyncio.sleep(0.03) + + with pytest.raises(ModelTimeoutError) as exc_info: + await stream.__anext__() + + assert exc_info.value.timeout_seconds == 0.05 + assert str(exc_info.value) == "Model call timed out after 0.05 seconds." + + +@pytest.mark.asyncio +async def test_stream_timeout_bounds_owner_cleanup_after_exhaustion() -> None: + class SlowCloseStream: + def __aiter__(self) -> SlowCloseStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + raise StopAsyncIteration + + async def aclose(self) -> None: + await asyncio.Event().wait() + + with pytest.raises(ModelTimeoutError) as exc_info: + async for _event in stream_response_with_retry( + get_stream=SlowCloseStream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ): + pass + + assert exc_info.value.timeout_seconds == 0.01 + + +@pytest.mark.asyncio +async def test_stream_timeout_cancels_blocked_cleanup_after_blocked_read() -> None: + read_started = asyncio.Event() + close_started = asyncio.Event() + release_close = asyncio.Event() + + class BlockingReadAndCloseStream: + def __aiter__(self) -> BlockingReadAndCloseStream: + return self + + async def __anext__(self) -> TResponseStreamEvent: + read_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def aclose(self) -> None: + close_started.set() + await release_close.wait() + + async def consume() -> None: + async for _event in stream_response_with_retry( + get_stream=BlockingReadAndCloseStream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=0.01, + ): + pass + + task = asyncio.create_task(consume()) + await read_started.wait() + done, _ = await asyncio.wait({task}, timeout=0.2) + if task not in done: + release_close.set() + await task + pytest.fail("Timed stream cleanup did not receive a second cancellation.") + + with pytest.raises(ModelTimeoutError) as exc_info: + await task + + assert close_started.is_set() + assert exc_info.value.timeout_seconds == 0.01 + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_preserves_parent_cancellation_over_cleanup_error() -> ( + None +): + started = asyncio.Event() + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError as exc: + raise RuntimeError("stream cleanup failed") from exc + yield cast(TResponseStreamEvent, {"type": "response.created"}) + + return iterator() + + async def consume() -> None: + async for _event in stream_response_with_retry( + get_stream=get_stream, + rewind=lambda: asyncio.sleep(0), + retry_settings=ModelRetrySettings( + max_retries=1, + backoff={"initial_delay": 0}, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + timeout=10, + ): + pass + + task = asyncio.create_task(consume()) + await started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + @pytest.mark.asyncio async def test_stream_response_with_retry_keeps_provider_retries_on_first_attempt( monkeypatch, diff --git a/tests/test_provider_span_errors.py b/tests/test_provider_span_errors.py index 2ea1765363..b51b89948f 100644 --- a/tests/test_provider_span_errors.py +++ b/tests/test_provider_span_errors.py @@ -8,6 +8,7 @@ from __future__ import annotations +import asyncio from typing import Any import pytest @@ -347,6 +348,38 @@ def explode(*_args: Any, **_kwargs: Any) -> None: assert exc_info.value is original +@pytest.mark.asyncio +async def test_marked_model_timeout_cancellation_records_span_error() -> None: + from agents.exceptions import ModelTimeoutError + from agents.tracing import generation_span + from agents.util._error_tracing import mark_model_timeout_task, model_span_errors + + started = asyncio.Event() + + async def run() -> None: + with trace(workflow_name="test"): + with generation_span() as span: + with model_span_errors( + span, + message="Error getting response", + trace_include_sensitive_data=True, + ): + started.set() + await asyncio.Event().wait() + + task = asyncio.create_task(run()) + await started.wait() + mark_model_timeout_task(task, ModelTimeoutError(0.01)) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + error = _span_error("generation") + assert error is not None + assert error["data"]["error"] == "Model call timed out after 0.01 seconds." + + class _TerminalFailureEvent: """A terminal `response.failed` event with no response payload attached.""" diff --git a/tests/test_scripted_model.py b/tests/test_scripted_model.py index 8e5c3018b4..209c4e6b98 100644 --- a/tests/test_scripted_model.py +++ b/tests/test_scripted_model.py @@ -51,6 +51,7 @@ ModelRetryAdvice, ModelRetryAdviceRequest, ModelRetrySettings, + ModelTimeoutError, RunConfig, Runner, handoff, @@ -1326,6 +1327,36 @@ def respond(_call: ModelCall) -> Any: ] +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_scripted_model_timeout_records_generation_span_error(streamed: bool) -> None: + async def respond(_call: ModelCall) -> Any: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + model = ScriptedModel([ModelStep.respond(respond)], emit_traces=True) + agent = Agent( + name="test", + model=model, + model_settings=ModelSettings(timeout=0.01), + ) + + with pytest.raises(ModelTimeoutError): + if streamed: + result = Runner.run_streamed(agent, "hi") + async for _event in result.stream_events(): + pass + else: + await Runner.run(agent, "hi") + + assert fetch_span_errors("generation") == [ + { + "message": "Error", + "data": {"error": "Model call timed out after 0.01 seconds."}, + } + ] + + def test_scripted_model_ignores_span_attachment_failure() -> None: class FailingSpan: def set_error(self, _error: SpanError) -> None: From 3a888def33c309fc9521b8baa80f09db28f87473 Mon Sep 17 00:00:00 2001 From: Sergey Filimonov Date: Sat, 15 Aug 2026 23:24:33 -0400 Subject: [PATCH 339/473] feat(extensions): add Modal sandbox resource options (#4455) --- .../extensions/sandbox/modal/sandbox.py | 16 +++++ tests/extensions/sandbox/test_modal.py | 60 +++++++++++++++++++ tests/sandbox/test_client_options.py | 2 + tests/sandbox/test_compatibility_guards.py | 4 ++ 4 files changed, 82 insertions(+) diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index 71c7c551b8..848b8e12a3 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -296,6 +296,8 @@ class ModalSandboxClientOptions(BaseSandboxClientOptions): use_sleep_cmd: bool = True image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION idle_timeout: int | None = None + cpu: float | tuple[float, float] | None = None + memory: int | tuple[int, int] | None = None def __init__( self, @@ -311,6 +313,8 @@ def __init__( image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION, idle_timeout: int | None = None, *, + cpu: float | tuple[float, float] | None = None, + memory: int | tuple[int, int] | None = None, type: Literal["modal"] = "modal", ) -> None: super().__init__( @@ -326,6 +330,8 @@ def __init__( use_sleep_cmd=use_sleep_cmd, image_builder_version=image_builder_version, idle_timeout=idle_timeout, + cpu=cpu, + memory=memory, ) @@ -460,6 +466,8 @@ class ModalSandboxSessionState(SandboxSessionState): use_sleep_cmd: bool = True image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION idle_timeout: int | None = None + cpu: float | tuple[float, float] | None = None + memory: int | tuple[int, int] | None = None def _sanitize_persisted_provider_identity( self, @@ -730,6 +738,8 @@ async def _ensure_sandbox(self) -> bool: encrypted_ports=self.state.exposed_ports, volumes=volumes, gpu=self.state.gpu, + cpu=self.state.cpu, + memory=self.state.memory, timeout=self.state.timeout, idle_timeout=self.state.idle_timeout, ) @@ -1835,6 +1845,8 @@ async def _run_restore() -> None: encrypted_ports=self.state.exposed_ports, volumes=self._modal_cloud_bucket_mounts_for_manifest(), gpu=self.state.gpu, + cpu=self.state.cpu, + memory=self.state.memory, timeout=self.state.timeout, idle_timeout=self.state.idle_timeout, ) @@ -2090,6 +2102,8 @@ async def create( (async timeout for snapshot restore call) - timeout: int (maximum sandbox lifetime in seconds, default 300) - idle_timeout: int | None (maximum sandbox inactivity in seconds, default None) + - cpu: float | tuple[float, float] | None (CPU request or request/limit pair) + - memory: int | tuple[int, int] | None (memory request or request/limit pair in MiB) - image_builder_version: str | None (Modal image builder version, default "2025.06") """ @@ -2213,6 +2227,8 @@ async def create( use_sleep_cmd=options.use_sleep_cmd, image_builder_version=image_builder_version, idle_timeout=options.idle_timeout, + cpu=options.cpu, + memory=options.memory, ) if sandbox_create_timeout_s is not None: state.sandbox_create_timeout_s = float(sandbox_create_timeout_s) diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index 2a78623845..44a3fa5c72 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -503,6 +503,38 @@ async def test_modal_sandbox_create_passes_idle_timeout( assert session.state.idle_timeout == 60 +@pytest.mark.parametrize( + ("cpu", "memory"), + [ + (1.0, 2048), + ((1.0, 4.0), (2048, 8192)), + ], + ids=["requests", "requests-and-limits"], +) +@pytest.mark.asyncio +async def test_modal_sandbox_create_passes_resources( + monkeypatch: pytest.MonkeyPatch, + cpu: float | tuple[float, float], + memory: int | tuple[int, int], +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + + client = modal_module.ModalSandboxClient() + session = await client.create( + options=modal_module.ModalSandboxClientOptions( + app_name="sandbox-tests", + cpu=cpu, + memory=memory, + ), + ) + + assert create_calls + assert create_calls[0]["cpu"] == cpu + assert create_calls[0]["memory"] == memory + assert session.state.cpu == cpu + assert session.state.memory == memory + + @pytest.mark.asyncio async def test_modal_sandbox_create_sets_default_cmd_for_custom_registry_image( monkeypatch: pytest.MonkeyPatch, @@ -625,6 +657,30 @@ def test_modal_deserialize_session_state_defaults_missing_idle_timeout( assert restored.idle_timeout is None +def test_modal_deserialize_session_state_defaults_missing_resources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + cpu=(1.0, 4.0), + memory=(2048, 8192), + ) + payload = state.model_dump(mode="json") + payload.pop("cpu") + payload.pop("memory") + + restored = modal_module.ModalSandboxClient().deserialize_session_state( + cast(dict[str, object], payload) + ) + + assert restored.cpu is None + assert restored.memory is None + + @pytest.mark.asyncio async def test_modal_deserialize_discards_surviving_resource_identity( monkeypatch: pytest.MonkeyPatch, @@ -3444,6 +3500,8 @@ async def test_modal_snapshot_filesystem_restore_preserves_exposed_ports( workspace_persistence="snapshot_filesystem", exposed_ports=(8765,), idle_timeout=60, + cpu=(1.0, 4.0), + memory=(2048, 8192), ) session = modal_module.ModalSandboxSession.from_state(state) call_names: list[str] = [] @@ -3470,6 +3528,8 @@ async def _fake_call_modal( assert create_calls assert create_calls[0]["encrypted_ports"] == (8765,) assert create_calls[0]["idle_timeout"] == 60 + assert create_calls[0]["cpu"] == (1.0, 4.0) + assert create_calls[0]["memory"] == (2048, 8192) assert sys.modules["modal"].Image.from_id_calls == ["snap-123"] assert call_names == [] assert call_timeouts == [] diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index 8c71dc4028..5659541767 100644 --- a/tests/sandbox/test_client_options.py +++ b/tests/sandbox/test_client_options.py @@ -58,6 +58,8 @@ def test_sandbox_client_options_exclude_unset_preserves_type_discriminator() -> "use_sleep_cmd": True, "image_builder_version": "2025.06", "idle_timeout": None, + "cpu": None, + "memory": None, } diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index ab68c3f8fb..cb484b4cc8 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -453,6 +453,8 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( "use_sleep_cmd", "image_builder_version", "idle_timeout", + "cpu", + "memory", ), ), ( @@ -628,6 +630,8 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "use_sleep_cmd", "image_builder_version", "idle_timeout", + "cpu", + "memory", ), ), ( From 2f1c83d5b78ee8a5b402ef9fd74e4be8085d2ae4 Mon Sep 17 00:00:00 2001 From: "Vinove A." <57112127+koadegno@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:25:13 +0200 Subject: [PATCH 340/473] feat(sandbox): allow Docker sandboxes to disable networking (#4452) --- src/agents/run_state.py | 5 +- src/agents/sandbox/sandboxes/docker.py | 63 ++++ tests/sandbox/test_compatibility_guards.py | 3 +- tests/sandbox/test_docker.py | 9 +- tests/sandbox/test_docker_network_mode.py | 347 +++++++++++++++++++++ 5 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 tests/sandbox/test_docker_network_mode.py diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 8b98fc3b36..74e5bcbf04 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -209,7 +209,10 @@ def _default_run_state_validation_error( "Persists canonical tool invocation identity plus sanitized mount authority and trusted " "rebind metadata, durable pending input, and resumable next-model-call state." ), - "1.16": "Lets an exact call approval decision override a sticky decision for the same tool.", + "1.16": ( + "Persists Docker network-isolation state and lets an exact call approval decision " + "override a sticky decision for the same tool." + ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 3a8411b6f6..fd9ebbe556 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -25,6 +25,8 @@ from docker.models.containers import Container # type: ignore[import-untyped] from docker.types import DriverConfig, Mount as DockerSDKMount # type: ignore[import-untyped] from docker.utils import parse_repository_tag +from pydantic import model_validator +from typing_extensions import Self from .._mount_security import ( _manifest_has_configured_mount_authority, @@ -169,10 +171,28 @@ def _measure_stream(stream: io.IOBase) -> tuple[int, io.IOBase, io.IOBase | None ) +def _validate_docker_network_configuration( + *, + network_mode: Literal["none"] | None, + exposed_ports: tuple[int, ...], +) -> None: + if network_mode == "none" and exposed_ports: + raise ValueError("exposed_ports cannot be used when network_mode='none'") + + class DockerSandboxSessionState(SandboxSessionState): type: Literal["docker"] = "docker" image: str container_id: str + network_mode: Literal["none"] | None = None + + @model_validator(mode="after") + def _validate_network_configuration(self) -> Self: + _validate_docker_network_configuration( + network_mode=self.network_mode, + exposed_ports=self.exposed_ports, + ) + return self def _sanitize_persisted_provider_identity( self, @@ -193,6 +213,15 @@ class DockerSandboxClientOptions(BaseSandboxClientOptions): type: Literal["docker"] = "docker" image: str exposed_ports: tuple[int, ...] = () + network_mode: Literal["none"] | None = None + + @model_validator(mode="after") + def _validate_network_configuration(self) -> Self: + _validate_docker_network_configuration( + network_mode=self.network_mode, + exposed_ports=self.exposed_ports, + ) + return self def __init__( self, @@ -200,11 +229,13 @@ def __init__( exposed_ports: tuple[int, ...] = (), *, type: Literal["docker"] = "docker", + network_mode: Literal["none"] | None = None, ) -> None: super().__init__( type=type, image=image, exposed_ports=exposed_ports, + network_mode=network_mode, ) @@ -1502,6 +1533,7 @@ async def create( image, manifest=manifest, exposed_ports=options.exposed_ports, + network_mode=options.network_mode, session_id=session_id, ) container.start() @@ -1516,6 +1548,7 @@ async def create( snapshot=snapshot_instance, container_id=container_id, exposed_ports=options.exposed_ports, + network_mode=options.network_mode, ) inner = DockerSandboxSession( docker_client=self.docker_client, @@ -1616,6 +1649,10 @@ async def resume( reused_existing_container = container is not None if container is not None: _assert_existing_container_path_grants_match(container, state.manifest) + _assert_existing_container_network_configuration_matches( + container, + state.network_mode, + ) owns_replacement = container is None replacement_session_id = ( uuid.uuid4() @@ -1642,6 +1679,7 @@ async def resume( state.image, manifest=state.manifest, exposed_ports=state.exposed_ports, + network_mode=state.network_mode, session_id=replacement_session_id, ) container_id = container.id @@ -1675,6 +1713,7 @@ async def _create_container( *, manifest: Manifest | None = None, exposed_ports: tuple[int, ...] = (), + network_mode: Literal["none"] | None = None, session_id: uuid.UUID | None = None, ) -> Container: if manifest is not None: @@ -1695,6 +1734,8 @@ async def _create_container( "command": ["-f", "/dev/null"], "environment": environment, } + if network_mode is not None: + create_kwargs["network_mode"] = network_mode if manifest is not None: docker_mounts = _build_docker_volume_mounts(manifest, session_id=session_id) if docker_mounts: @@ -1832,6 +1873,28 @@ def _validate_docker_path_grants(manifest: Manifest) -> None: ) +def _assert_existing_container_network_configuration_matches( + container: Container, + network_mode: Literal["none"] | None, +) -> None: + if network_mode is None: + return + + container.reload() + attrs = getattr(container, "attrs", {}) or {} + host_config = attrs.get("HostConfig") + network_settings = attrs.get("NetworkSettings") + actual_network_mode = host_config.get("NetworkMode") if isinstance(host_config, dict) else None + networks = network_settings.get("Networks") if isinstance(network_settings, dict) else None + attached_networks = set(networks) if isinstance(networks, dict) else None + + if actual_network_mode != "none" or attached_networks is None or attached_networks - {"none"}: + raise ValueError( + "Existing Docker sandbox network configuration does not match persisted " + "network_mode='none'; create a fresh sandbox session" + ) + + def _assert_existing_container_path_grants_match( container: Container, manifest: Manifest, diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index cb484b4cc8..a358f76ea7 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -416,7 +416,7 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( ( "agents.sandbox.sandboxes.docker", "DockerSandboxClientOptions", - ("image", "exposed_ports"), + ("image", "exposed_ports", "network_mode"), ), ( "agents.extensions.sandbox.e2b", @@ -575,6 +575,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "workspace_root_ready", "image", "container_id", + "network_mode", ), ), ( diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index 366000d113..5c535e57bf 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -2809,9 +2809,11 @@ async def create_container( *, manifest: Manifest | None = None, exposed_ports: tuple[int, ...] = (), + network_mode: str | None = None, session_id: uuid.UUID | None = None, ) -> _StartedContainer: _ = (image, exposed_ports) + assert network_mode is None assert session_id == replacement_session_id assert stale_volume.remove_calls == 0 assert manifest is state.manifest @@ -4139,17 +4141,18 @@ async def test_docker_resume_resets_workspace_readiness_when_container_is_recrea docker_client=cast(object, _ResumeDockerClient(docker.errors.NotFound("missing"))) ) replacement = _ResumeContainer(status="created", container_id="replacement") - create_calls: list[tuple[str, Manifest | None, tuple[int, ...]]] = [] + create_calls: list[tuple[str, Manifest | None, tuple[int, ...], str | None]] = [] async def _fake_create_container( image: str, *, manifest: Manifest | None = None, exposed_ports: tuple[int, ...] = (), + network_mode: str | None = None, session_id: uuid.UUID | None = None, ) -> object: _ = session_id - create_calls.append((image, manifest, exposed_ports)) + create_calls.append((image, manifest, exposed_ports, network_mode)) return replacement monkeypatch.setattr(client, "_create_container", _fake_create_container) @@ -4171,7 +4174,7 @@ async def _fake_create_container( assert inner.state.workspace_root_ready is False assert inner._workspace_root_ready is False assert inner.should_provision_manifest_accounts_on_resume() is True - assert create_calls == [(DEFAULT_PYTHON_SANDBOX_IMAGE, inner.state.manifest, (8765,))] + assert create_calls == [(DEFAULT_PYTHON_SANDBOX_IMAGE, inner.state.manifest, (8765,), None)] @pytest.mark.asyncio diff --git a/tests/sandbox/test_docker_network_mode.py b/tests/sandbox/test_docker_network_mode.py new file mode 100644 index 0000000000..2068415a7d --- /dev/null +++ b/tests/sandbox/test_docker_network_mode.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Any, cast + +import docker.errors # type: ignore[import-untyped] +import pytest + +from agents import Agent +from agents.run_context import RunContextWrapper +from agents.run_state import RunState +from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE +from agents.sandbox.manifest import Manifest +from agents.sandbox.sandboxes.docker import ( + DockerSandboxClient, + DockerSandboxClientOptions, + DockerSandboxSession, + DockerSandboxSessionState, +) +from agents.sandbox.session import BaseSandboxClientOptions +from agents.sandbox.snapshot import NoopSnapshot + + +class _Images: + def get(self, image: str) -> object: + _ = image + return object() + + def pull(self, *args: object, **kwargs: object) -> None: + raise AssertionError(f"unexpected image pull: {args!r} {kwargs!r}") + + +class _Container: + id = "replacement-container" + status = "created" + attrs: dict[str, object] = {"Mounts": []} + + def reload(self) -> None: + return None + + def start(self) -> None: + self.status = "running" + + +class _ExistingContainer(_Container): + def __init__(self, attrs: dict[str, object]) -> None: + self.id = "existing-container" + self.status = "running" + self.attrs = attrs + + +class _Containers: + def __init__(self, existing: _Container | None = None) -> None: + self.created = _Container() + self.existing = existing + self.create_calls: list[dict[str, object]] = [] + + def create(self, **kwargs: object) -> _Container: + self.create_calls.append(dict(kwargs)) + return self.created + + def get(self, container_id: str) -> _Container: + _ = container_id + if self.existing is not None: + return self.existing + raise docker.errors.NotFound("container not found") + + +class _DockerClient: + def __init__(self, existing: _Container | None = None) -> None: + self.images = _Images() + self.containers = _Containers(existing) + + +class _NoDockerProviderAccess: + def __getattr__(self, name: str) -> object: + raise AssertionError(f"unexpected Docker provider access: {name}") + + +def _client( + existing: _Container | None = None, +) -> tuple[DockerSandboxClient, _DockerClient]: + docker_client = _DockerClient(existing) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + return client, docker_client + + +def _state(*, network_mode: str | None = None) -> DockerSandboxSessionState: + payload: dict[str, object] = { + "manifest": Manifest(), + "snapshot": NoopSnapshot(id="snapshot"), + "image": DEFAULT_PYTHON_SANDBOX_IMAGE, + "container_id": "missing-container", + } + if network_mode is not None: + payload["network_mode"] = network_mode + return DockerSandboxSessionState.model_validate(payload) + + +def test_docker_module_imports_self_from_typing_extensions() -> None: + module_path = ( + Path(__file__).parents[2] / "src" / "agents" / "sandbox" / "sandboxes" / "docker.py" + ) + tree = ast.parse(module_path.read_text(encoding="utf-8")) + typing_names = { + alias.name + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "typing" + for alias in node.names + } + typing_extensions_names = { + alias.name + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "typing_extensions" + for alias in node.names + } + + assert "Self" not in typing_names + assert "Self" in typing_extensions_names + + +def test_docker_options_accept_network_mode_none() -> None: + options = DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + network_mode="none", + ) + + assert options.network_mode == "none" + + +def test_docker_options_reject_other_network_modes() -> None: + with pytest.raises(ValueError): + DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + network_mode=cast(Any, "bridge"), + ) + + +def test_docker_options_reject_exposed_ports_with_network_mode_none() -> None: + with pytest.raises(ValueError, match="exposed_ports"): + DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + exposed_ports=(8080,), + network_mode="none", + ) + + +def test_docker_options_network_mode_round_trip() -> None: + options = DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + network_mode="none", + ) + + restored = BaseSandboxClientOptions.parse(options.model_dump(mode="json")) + + assert restored == options + assert isinstance(restored, DockerSandboxClientOptions) + assert restored.network_mode == "none" + + +def test_docker_options_omitted_network_mode_preserves_default_behavior() -> None: + restored = BaseSandboxClientOptions.parse( + { + "type": "docker", + "image": DEFAULT_PYTHON_SANDBOX_IMAGE, + } + ) + + assert isinstance(restored, DockerSandboxClientOptions) + assert restored.network_mode is None + + +@pytest.mark.asyncio +async def test_docker_client_create_applies_and_persists_network_mode_none() -> None: + client, docker_client = _client() + + session = await client.create( + options=DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + network_mode="none", + ) + ) + + assert docker_client.containers.create_calls[0]["network_mode"] == "none" + assert isinstance(session._inner, DockerSandboxSession) + assert session._inner.state.network_mode == "none" + + +@pytest.mark.asyncio +async def test_docker_create_container_passes_network_mode_none() -> None: + client, docker_client = _client() + + container = await client._create_container( + DEFAULT_PYTHON_SANDBOX_IMAGE, + network_mode="none", + ) + + assert container is docker_client.containers.created + assert docker_client.containers.create_calls == [ + { + "entrypoint": ["tail"], + "image": DEFAULT_PYTHON_SANDBOX_IMAGE, + "detach": True, + "command": ["-f", "/dev/null"], + "environment": None, + "network_mode": "none", + } + ] + + +@pytest.mark.asyncio +async def test_docker_create_container_omits_network_mode_by_default() -> None: + client, docker_client = _client() + + await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE) + + assert "network_mode" not in docker_client.containers.create_calls[0] + + +def test_docker_session_state_network_mode_round_trip() -> None: + client, _ = _client() + state = _state(network_mode="none") + + restored = client.deserialize_session_state(state.model_dump(mode="json")) + + assert isinstance(restored, DockerSandboxSessionState) + assert restored.network_mode == "none" + + +def test_docker_session_state_rejects_invalid_network_mode_before_provider_access() -> None: + client = DockerSandboxClient(docker_client=cast(object, _NoDockerProviderAccess())) + payload = _state().model_dump(mode="json") + payload["network_mode"] = "bridge" + + with pytest.raises(ValueError): + client.deserialize_session_state(payload) + + +def test_docker_state_rejects_no_network_exposed_ports_before_provider_access() -> None: + client = DockerSandboxClient(docker_client=cast(object, _NoDockerProviderAccess())) + payload = _state(network_mode="none").model_dump(mode="json") + payload["exposed_ports"] = [8080] + + with pytest.raises(ValueError): + client.deserialize_session_state(payload) + + +def test_docker_session_state_without_network_mode_preserves_old_payloads() -> None: + client, _ = _client() + payload = _state().model_dump(mode="json") + payload.pop("network_mode", None) + + restored = client.deserialize_session_state(payload) + + assert isinstance(restored, DockerSandboxSessionState) + assert restored.network_mode is None + + +@pytest.mark.asyncio +async def test_docker_resume_reapplies_network_mode_to_replacement_container() -> None: + client, docker_client = _client() + state = _state(network_mode="none") + + await client.resume(state) + + assert docker_client.containers.create_calls[0]["network_mode"] == "none" + assert state.container_id == "replacement-container" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "attrs", + [ + { + "Mounts": [], + "HostConfig": {"NetworkMode": "bridge"}, + "NetworkSettings": {"Networks": {"bridge": {}}}, + }, + { + "Mounts": [], + "HostConfig": {"NetworkMode": "none"}, + "NetworkSettings": {"Networks": {"bridge": {}}}, + }, + { + "Mounts": [], + "HostConfig": {"NetworkMode": "none"}, + "NetworkSettings": {}, + }, + ], + ids=["wrong-host-network-mode", "attached-after-create", "missing-network-map"], +) +async def test_docker_resume_rejects_reused_container_that_is_not_network_isolated( + attrs: dict[str, object], +) -> None: + existing = _ExistingContainer(attrs) + client, docker_client = _client(existing) + state = _state(network_mode="none") + state.container_id = existing.id + + with pytest.raises(ValueError, match="network"): + await client.resume(state) + + assert docker_client.containers.create_calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("networks", [{}, {"none": {}}], ids=["empty", "none-network"]) +async def test_docker_resume_reuses_container_that_is_network_isolated( + networks: dict[str, object], +) -> None: + existing = _ExistingContainer( + { + "Mounts": [], + "HostConfig": {"NetworkMode": "none"}, + "NetworkSettings": {"Networks": networks}, + } + ) + client, docker_client = _client(existing) + state = _state(network_mode="none") + state.container_id = existing.id + + await client.resume(state) + + assert docker_client.containers.create_calls == [] + + +@pytest.mark.asyncio +async def test_run_state_round_trip_preserves_docker_network_mode() -> None: + agent = Agent(name="sandbox") + run_state = RunState( + context=RunContextWrapper(context={}), + original_input="resume sandbox", + starting_agent=agent, + ) + run_state._sandbox = { + "backend_id": "docker", + "current_agent_name": agent.name, + "session_state": _state(network_mode="none").model_dump(mode="json"), + } + + restored = await RunState.from_json(agent, run_state.to_json()) + + assert restored._sandbox is not None + restored_session_state = restored._sandbox["session_state"] + assert isinstance(restored_session_state, dict) + assert restored_session_state["network_mode"] == "none" From e5f75fdf136a89ce7945a06ebccadd9e1554623e Mon Sep 17 00:00:00 2001 From: Chirag Honnyal <118997601+Chirag6722@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:17:05 +0530 Subject: [PATCH 341/473] fix(realtime): truncate audio at zero elapsed time (#4457) --- src/agents/realtime/openai_realtime.py | 2 +- tests/realtime/test_openai_realtime.py | 68 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 43877e9d59..44fd60a39a 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -1070,7 +1070,7 @@ async def _interrupt_audio_playback( ) else: current_item_content_index = current_item_content_index or 0 - if elapsed_ms > 0: + if elapsed_ms >= 0: if not response_scoped: try: await self._emit_event( diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index aec471e9d4..6819b6a609 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -18,6 +18,7 @@ from agents.realtime.model import RealtimeModelConfig, RealtimePlaybackTracker from agents.realtime.model_events import ( RealtimeModelAudioEvent, + RealtimeModelAudioInterruptedEvent, RealtimeModelErrorEvent, RealtimeModelOutputTextDeltaEvent, RealtimeModelRawServerEvent, @@ -1927,6 +1928,73 @@ async def test_interrupt_rejects_contradictory_modes_before_side_effects( send_raw.assert_not_awaited() emit_event.assert_not_awaited() + @pytest.mark.asyncio + async def test_interrupt_truncates_at_zero_when_no_time_has_elapsed(self, model, monkeypatch): + """An interrupt inside one clock tick still truncates, at position 0. + + Both readings are pinned to the same value, so ``elapsed_ms`` is exactly + 0. That is a real state rather than a contrived one: ``time.monotonic()`` + advances in ~15.6ms steps on Windows, so an interrupt arriving in the same + tick as the audio it interrupts produces it. Treating 0 as "nothing to + truncate" left the item holding audio the user never heard. + """ + model._audio_state_tracker.set_audio_format("pcm16") + with patch("agents.realtime._default_tracker.time.monotonic", return_value=1000.0): + model._audio_state_tracker.on_audio_delta("item_1", 0, b"\x00" * 4800) + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + with patch("agents.realtime.openai_realtime.time.monotonic", return_value=1000.0): + await model._send_interrupt(RealtimeModelSendInterrupt()) + + interrupted = [ + call.args[0] + for call in emit_event.await_args_list + if isinstance(call.args[0], RealtimeModelAudioInterruptedEvent) + ] + assert len(interrupted) == 1 + assert interrupted[0].item_id == "item_1" + assert interrupted[0].content_index == 0 + + truncates = [ + call.args[0] + for call in send_raw.await_args_list + if getattr(call.args[0], "type", None) == "conversation.item.truncate" + ] + assert len(truncates) == 1 + assert truncates[0].item_id == "item_1" + assert truncates[0].content_index == 0 + assert truncates[0].audio_end_ms == 0 + + @pytest.mark.asyncio + async def test_interrupt_skips_truncate_when_elapsed_is_negative(self, model, monkeypatch): + """A clock that went backwards is still not a truncation position.""" + model._audio_state_tracker.set_audio_format("pcm16") + with patch("agents.realtime._default_tracker.time.monotonic", return_value=1000.0): + model._audio_state_tracker.on_audio_delta("item_1", 0, b"\x00" * 4800) + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + with patch("agents.realtime.openai_realtime.time.monotonic", return_value=999.0): + await model._send_interrupt(RealtimeModelSendInterrupt()) + + assert not [ + call.args[0] + for call in send_raw.await_args_list + if getattr(call.args[0], "type", None) == "conversation.item.truncate" + ] + assert not [ + call.args[0] + for call in emit_event.await_args_list + if isinstance(call.args[0], RealtimeModelAudioInterruptedEvent) + ] + @pytest.mark.asyncio async def test_interrupt_respects_auto_cancellation_when_not_forced(self, model, monkeypatch): """Interrupt should avoid sending response.cancel when relying on automatic cancellation.""" From fdcec69620cec75bd2a8c962155e56ac9415210f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 15:10:35 +0900 Subject: [PATCH 342/473] fix: forward Docker network mode in security test --- integration_tests/security/test_local_sandbox_isolation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration_tests/security/test_local_sandbox_isolation.py b/integration_tests/security/test_local_sandbox_isolation.py index d029850ded..ddcc5ef481 100644 --- a/integration_tests/security/test_local_sandbox_isolation.py +++ b/integration_tests/security/test_local_sandbox_isolation.py @@ -5,7 +5,7 @@ import os import uuid from pathlib import Path -from typing import Any, cast +from typing import Any, Literal, cast import pytest from openai.types.responses import ( @@ -156,12 +156,14 @@ async def _create_container( *, manifest: Manifest | None = None, exposed_ports: tuple[int, ...] = (), + network_mode: Literal["none"] | None = None, session_id: uuid.UUID | None = None, ) -> Any: container = await super()._create_container( image, manifest=manifest, exposed_ports=exposed_ports, + network_mode=network_mode, session_id=session_id, ) container_id = container.id From 4cb461a7e3ad996f499c7e67b01e544cae4e8183 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 18:05:02 +0900 Subject: [PATCH 343/473] fix(sandbox): validate view_image raster content (#4462) Co-authored-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> --- .../sandbox/capabilities/tools/view_image.py | 6 +- .../test_view_image_content_validation.py | 97 +++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/sandbox/test_view_image_content_validation.py diff --git a/src/agents/sandbox/capabilities/tools/view_image.py b/src/agents/sandbox/capabilities/tools/view_image.py index fb8d475357..08ec95bcdd 100644 --- a/src/agents/sandbox/capabilities/tools/view_image.py +++ b/src/agents/sandbox/capabilities/tools/view_image.py @@ -1,7 +1,6 @@ from __future__ import annotations import base64 -import mimetypes from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from pathlib import Path @@ -39,9 +38,8 @@ def _detect_image_mime_type(path: Path, payload: bytes) -> str | None: if snippet.startswith(b"' +_SVG_BODY = _SVG_TEXT.encode() + + +@pytest.mark.asyncio +async def test_view_image_rejects_non_image_bytes_with_raster_extension() -> None: + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(b"not an image\n")}] + ) + tool = ViewImageTool(session=session) + + output = await tool.run(ViewImageArgs(path="images/fake.png")) + + assert output == "image path `images/fake.png` is not a supported image file" + session.assert_complete() + + +@pytest.mark.asyncio +async def test_view_image_ignores_mutated_mime_mapping_for_raster_extension( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(mimetypes, "guess_type", lambda _: ("image/svg+xml", None)) + session = scripted_sandbox_session( + [{"method": "read", "result": io.BytesIO(b"not an image\n")}] + ) + tool = ViewImageTool(session=session) + + output = await tool.run(ViewImageArgs(path="images/fake.png")) + + assert output == "image path `images/fake.png` is not a supported image file" + session.assert_complete() + + +@pytest.mark.asyncio +async def test_view_image_accepts_raster_signature_without_image_extension() -> None: + session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(_PNG_BYTES)}]) + tool = ViewImageTool(session=session) + + output = await tool.run(ViewImageArgs(path="images/payload.bin")) + + assert isinstance(output, ToolOutputImage) + assert output.image_url is not None + assert output.image_url.startswith("data:image/png;base64,") + session.assert_complete() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "svg_payload", + [ + b"\xef\xbb\xbf" + _SVG_BODY, + b"\n" + _SVG_BODY, + b'\n' + _SVG_BODY, + _SVG_TEXT.encode("utf-16"), + ], + ids=["utf8-bom", "comment", "doctype", "utf16"], +) +async def test_view_image_preserves_svg_filename_compatibility(svg_payload: bytes) -> None: + session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(svg_payload)}]) + tool = ViewImageTool(session=session) + + output = await tool.run(ViewImageArgs(path="images/vector.svg")) + + assert isinstance(output, ToolOutputImage) + assert output.image_url is not None + assert output.image_url.startswith("data:image/svg+xml;base64,") + session.assert_complete() + + +@pytest.mark.asyncio +async def test_view_image_preserves_svgz_filename_compatibility() -> None: + svgz_payload = gzip.compress(_SVG_BODY) + session = scripted_sandbox_session([{"method": "read", "result": io.BytesIO(svgz_payload)}]) + tool = ViewImageTool(session=session) + + output = await tool.run(ViewImageArgs(path="images/vector.svgz")) + + assert isinstance(output, ToolOutputImage) + assert output.image_url is not None + assert output.image_url.startswith("data:image/svg+xml;base64,") + session.assert_complete() From 2632043a4ed91fc819a7cfdee96958b54a00d247 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 16 Aug 2026 18:21:35 +0900 Subject: [PATCH 344/473] fix(realtime): end iteration after clean server close (#4461) Co-authored-by: ayaangazali --- src/agents/realtime/__init__.py | 2 + src/agents/realtime/model_events.py | 8 ++ src/agents/realtime/openai_realtime.py | 17 ++- src/agents/realtime/session.py | 8 +- tests/realtime/test_model_events.py | 7 + tests/realtime/test_openai_realtime.py | 169 ++++++++++++++++++++++++- tests/realtime/test_session.py | 40 ++++++ 7 files changed, 247 insertions(+), 4 deletions(-) diff --git a/src/agents/realtime/__init__.py b/src/agents/realtime/__init__.py index d3999b9602..96a8979d46 100644 --- a/src/agents/realtime/__init__.py +++ b/src/agents/realtime/__init__.py @@ -62,6 +62,7 @@ RealtimeModelAudioInterruptedEvent, RealtimeModelCachedTokensDetails, RealtimeModelConnectionStatusEvent, + RealtimeModelEndOfStreamEvent, RealtimeModelErrorEvent, RealtimeModelEvent, RealtimeModelExceptionEvent, @@ -166,6 +167,7 @@ "RealtimeModelAudioInterruptedEvent", "RealtimeModelCachedTokensDetails", "RealtimeModelConnectionStatusEvent", + "RealtimeModelEndOfStreamEvent", "RealtimeModelErrorEvent", "RealtimeModelEvent", "RealtimeModelExceptionEvent", diff --git a/src/agents/realtime/model_events.py b/src/agents/realtime/model_events.py index af21ce0a5f..ecffe6ab72 100644 --- a/src/agents/realtime/model_events.py +++ b/src/agents/realtime/model_events.py @@ -144,6 +144,13 @@ class RealtimeModelConnectionStatusEvent: type: Literal["connection_status"] = "connection_status" +@dataclass +class RealtimeModelEndOfStreamEvent: + """The model event stream ended permanently and will emit no further events.""" + + type: Literal["end_of_stream"] = "end_of_stream" + + @dataclass class RealtimeModelTurnStartedEvent: """Triggered when the model starts generating a response for a turn.""" @@ -248,6 +255,7 @@ class RealtimeModelRawServerEvent: | RealtimeModelItemUpdatedEvent | RealtimeModelItemDeletedEvent | RealtimeModelConnectionStatusEvent + | RealtimeModelEndOfStreamEvent | RealtimeModelTurnStartedEvent | RealtimeModelUsageEvent | RealtimeModelTurnEndedEvent diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 44fd60a39a..02cb55a1a6 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -126,6 +126,8 @@ RealtimeModelAudioEvent, RealtimeModelAudioInterruptedEvent, RealtimeModelCachedTokensDetails, + RealtimeModelConnectionStatusEvent, + RealtimeModelEndOfStreamEvent, RealtimeModelErrorEvent, RealtimeModelEvent, RealtimeModelExceptionEvent, @@ -552,6 +554,7 @@ def __init__(self, *, transport_config: TransportConfig | None = None) -> None: self._websocket: ClientConnection | None = None self._websocket_task: asyncio.Task[None] | None = None self._connection_attempt_active = False + self._close_requested = False self._response_create_tasks: set[asyncio.Task[None]] = set() self._user_input_lock = asyncio.Lock() self._listeners: list[RealtimeModelListener] = [] @@ -639,6 +642,7 @@ async def connect(self, options: RealtimeModelConfig) -> None: headers=headers, transport_config=self._transport_config, ) + self._close_requested = False try: self._websocket_task = asyncio.create_task(self._listen_for_messages()) await self._update_session_config(model_settings) @@ -724,6 +728,12 @@ async def _emit_event(self, event: RealtimeModelEvent) -> None: for listener in list(self._listeners): await listener.on_event(event) + async def _emit_normal_disconnect(self) -> None: + logger.debug("WebSocket connection closed normally") + if not self._close_requested: + await self._emit_event(RealtimeModelConnectionStatusEvent(status="disconnected")) + await self._emit_event(RealtimeModelEndOfStreamEvent()) + async def _listen_for_messages(self): assert self._websocket is not None, "Not connected" @@ -746,8 +756,7 @@ async def _listen_for_messages(self): ) except websockets.exceptions.ConnectionClosedOK: - # Normal connection closure - no exception event needed - logger.debug("WebSocket connection closed normally") + await self._emit_normal_disconnect() except websockets.exceptions.ConnectionClosed as e: await self._emit_event( RealtimeModelExceptionEvent( @@ -760,6 +769,9 @@ async def _listen_for_messages(self): exception=e, context="WebSocket error in message listener" ) ) + else: + # ClientConnection.__aiter__ consumes ConnectionClosedOK and returns normally. + await self._emit_normal_disconnect() finally: await self._cancel_response_create_tasks() await self._release_response_waiters() @@ -1249,6 +1261,7 @@ async def close(self) -> None: cleanup_error: BaseException | None = None if self._websocket: + self._close_requested = True try: await self._websocket.close() except BaseException as exc: diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index c610c04394..77d4da0937 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -80,6 +80,7 @@ ) from .model import RealtimeModel, RealtimeModelConfig, RealtimeModelListener from .model_events import ( + RealtimeModelEndOfStreamEvent, RealtimeModelEvent, RealtimeModelInputAudioTranscriptionCompletedEvent, RealtimeModelOutputTextDeltaEvent, @@ -227,6 +228,7 @@ def __init__( self._event_iterator_waiters = 0 self._closing = False self._closed = False + self._model_stream_ended = False self._cleanup_task: asyncio.Task[None] | None = None self._stored_exception: BaseException | None = None self._pending_tool_calls: dict[str, _PendingToolCall] = {} @@ -310,7 +312,7 @@ async def __aexit__(self, _exc_type: Any, _exc_val: Any, _exc_tb: Any) -> None: async def __aiter__(self) -> AsyncIterator[RealtimeSessionEvent]: """Iterate over events from the session.""" while True: - if self._closed and self._event_queue.empty(): + if (self._closed or self._model_stream_ended) and self._event_queue.empty(): return # Check if there's a stored exception to raise @@ -576,6 +578,10 @@ async def on_event(self, event: RealtimeModelEvent) -> None: ) elif event.type == "connection_status": pass + elif event.type == "end_of_stream": + assert isinstance(event, RealtimeModelEndOfStreamEvent) + self._model_stream_ended = True + self._wake_event_iterators() elif event.type == "turn_started": is_late_start_for_active_response = ( event.response_id is not None diff --git a/tests/realtime/test_model_events.py b/tests/realtime/test_model_events.py index 42213b2ebb..cb142ba0f2 100644 --- a/tests/realtime/test_model_events.py +++ b/tests/realtime/test_model_events.py @@ -27,6 +27,13 @@ def test_usage_event_types_are_publicly_exported() -> None: assert getattr(realtime, name) is not None +def test_end_of_stream_event_is_publicly_exported() -> None: + assert "RealtimeModelEndOfStreamEvent" in realtime.__all__ + + event = realtime.RealtimeModelEndOfStreamEvent() + assert event.type == "end_of_stream" + + def test_custom_model_can_construct_typed_usage_without_openai_types() -> None: event = realtime.RealtimeModelUsageEvent( usage=Usage(requests=1, input_tokens=8, output_tokens=5, total_tokens=13), diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 6819b6a609..eaf532bee9 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -14,11 +14,12 @@ from agents import Agent, WebSearchTool, function_tool from agents.exceptions import UserError from agents.handoffs import handoff -from agents.realtime import RealtimeSessionModelSettings +from agents.realtime import RealtimeAgent, RealtimeSession, RealtimeSessionModelSettings from agents.realtime.model import RealtimeModelConfig, RealtimePlaybackTracker from agents.realtime.model_events import ( RealtimeModelAudioEvent, RealtimeModelAudioInterruptedEvent, + RealtimeModelConnectionStatusEvent, RealtimeModelErrorEvent, RealtimeModelOutputTextDeltaEvent, RealtimeModelRawServerEvent, @@ -40,6 +41,10 @@ ) +async def _collect_session_events(session: RealtimeSession) -> list[Any]: + return [event async for event in session] + + class TestOpenAIRealtimeWebSocketModel: """Test suite for OpenAIRealtimeWebSocketModel connection and event handling.""" @@ -3261,6 +3266,168 @@ async def handler(websocket): await model.close() assert model._websocket is None + @pytest.mark.asyncio + async def test_normal_server_close_ends_session_iteration(self): + """A clean server close must end session iteration without an exception.""" + + async def handler(websocket): + await websocket.recv() + await websocket.close(code=1000, reason="session ended") + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + sockets = list(server.sockets) + port = sockets[0].getsockname()[1] + session = RealtimeSession( + OpenAIRealtimeWebSocketModel(), + RealtimeAgent(name="agent"), + None, + model_config={ + "api_key": "test-key", + "url": f"ws://127.0.0.1:{port}/v1/realtime", + "initial_model_settings": {"model_name": "gpt-realtime"}, + }, + ) + + await session.__aenter__() + try: + events = await asyncio.wait_for( + _collect_session_events(session), + timeout=1, + ) + assert session._closed is False + finally: + await session.close() + + disconnects = [ + event.data + for event in events + if event.type == "raw_model_event" and event.data.type == "connection_status" + ] + assert disconnects == [RealtimeModelConnectionStatusEvent(status="disconnected")] + assert any(event.type == "history_updated" for event in events) + + @pytest.mark.asyncio + async def test_client_close_does_not_emit_server_disconnect(self): + """Caller-owned close must not look like a clean server disconnect.""" + + connection_count = 0 + + async def handler(websocket): + nonlocal connection_count + connection_count += 1 + if connection_count == 1: + await websocket.wait_closed() + else: + await websocket.recv() + await websocket.close(code=1000, reason="session ended") + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + sockets = list(server.sockets) + port = sockets[0].getsockname()[1] + model = OpenAIRealtimeWebSocketModel() + listener = AsyncMock() + model.add_listener(listener) + + await model.connect( + { + "api_key": "test-key", + "url": f"ws://127.0.0.1:{port}/v1/realtime", + "initial_model_settings": {"model_name": "gpt-realtime"}, + } + ) + await model.close() + + first_connection_events = [call.args[0] for call in listener.on_event.await_args_list] + assert not any(event.type == "connection_status" for event in first_connection_events) + + await model.connect( + { + "api_key": "test-key", + "url": f"ws://127.0.0.1:{port}/v1/realtime", + "initial_model_settings": {"model_name": "gpt-realtime"}, + } + ) + assert model._websocket_task is not None + await asyncio.wait_for(model._websocket_task, timeout=1) + await model.close() + + emitted_events = [call.args[0] for call in listener.on_event.await_args_list] + disconnects = [event for event in emitted_events if event.type == "connection_status"] + assert disconnects == [RealtimeModelConnectionStatusEvent(status="disconnected")] + + @pytest.mark.asyncio + async def test_cancelled_close_before_websocket_handshake_preserves_server_disconnect(self): + """A cancelled preliminary close must not claim transport-close ownership.""" + allow_server_close = asyncio.Event() + + async def handler(websocket): + await websocket.recv() + await allow_server_close.wait() + await websocket.close(code=1000, reason="session ended") + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + sockets = list(server.sockets) + port = sockets[0].getsockname()[1] + model = OpenAIRealtimeWebSocketModel() + listener = AsyncMock() + model.add_listener(listener) + + await model.connect( + { + "api_key": "test-key", + "url": f"ws://127.0.0.1:{port}/v1/realtime", + "initial_model_settings": {"model_name": "gpt-realtime"}, + } + ) + + cancellation_started = asyncio.Event() + + async def wait_until_cancelled(): + cancellation_started.set() + await asyncio.Future() + + with patch.object(model, "_cancel_response_create_tasks", wait_until_cancelled): + close_task = asyncio.create_task(model.close()) + await asyncio.wait_for(cancellation_started.wait(), timeout=1) + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + + allow_server_close.set() + assert model._websocket_task is not None + await asyncio.wait_for(model._websocket_task, timeout=1) + await model.close() + + emitted_events = [call.args[0] for call in listener.on_event.await_args_list] + disconnects = [event for event in emitted_events if event.type == "connection_status"] + assert disconnects == [RealtimeModelConnectionStatusEvent(status="disconnected")] + + @pytest.mark.asyncio + async def test_abnormal_server_close_still_raises(self): + """An abnormal server close must retain the existing exception behavior.""" + + async def handler(websocket): + await websocket.recv() + await websocket.close(code=1011, reason="server failure") + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + sockets = list(server.sockets) + port = sockets[0].getsockname()[1] + session = RealtimeSession( + OpenAIRealtimeWebSocketModel(), + RealtimeAgent(name="agent"), + None, + model_config={ + "api_key": "test-key", + "url": f"ws://127.0.0.1:{port}/v1/realtime", + "initial_model_settings": {"model_name": "gpt-realtime"}, + }, + ) + + with pytest.raises(websockets.exceptions.ConnectionClosedError): + async with session: + await asyncio.wait_for(_collect_session_events(session), timeout=1) + @pytest.mark.asyncio async def test_ping_timeout_success_when_server_responds_quickly(self): """Test that connection stays alive when server responds to pings within timeout.""" diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 546b22a224..9d8f813bae 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -51,6 +51,7 @@ RealtimeModelAudioEvent, RealtimeModelAudioInterruptedEvent, RealtimeModelConnectionStatusEvent, + RealtimeModelEndOfStreamEvent, RealtimeModelErrorEvent, RealtimeModelInputAudioTranscriptionCompletedEvent, RealtimeModelItemDeletedEvent, @@ -1710,6 +1711,45 @@ async def test_ignored_events_only_generate_raw_events(self, mock_model, mock_ag event = await session._event_queue.get() assert isinstance(event, RealtimeRawModelEvent) + @pytest.mark.asyncio + async def test_transient_disconnect_does_not_end_iteration(self, mock_model, mock_agent): + """A reconnecting model can emit more events after a disconnected status.""" + session = RealtimeSession(mock_model, mock_agent, None) + event_iterator = session.__aiter__() + + await session.on_event(RealtimeModelConnectionStatusEvent(status="disconnected")) + disconnected = await anext(event_iterator) + assert isinstance(disconnected, RealtimeRawModelEvent) + + next_event = asyncio.create_task(anext(event_iterator)) + await asyncio.sleep(0) + assert not next_event.done() + + await session.on_event(RealtimeModelConnectionStatusEvent(status="connected")) + connected = await asyncio.wait_for(next_event, timeout=1) + assert isinstance(connected, RealtimeRawModelEvent) + assert connected.data == RealtimeModelConnectionStatusEvent(status="connected") + + await event_iterator.aclose() + + @pytest.mark.asyncio + async def test_end_of_stream_drains_events_then_ends_iteration(self, mock_model, mock_agent): + """An explicit end-of-stream signal terminates only after queued events drain.""" + session = RealtimeSession(mock_model, mock_agent, None) + event_iterator = session.__aiter__() + + await session.on_event(RealtimeModelConnectionStatusEvent(status="disconnected")) + await session.on_event(RealtimeModelEndOfStreamEvent()) + + disconnected = await anext(event_iterator) + end_of_stream = await anext(event_iterator) + assert isinstance(disconnected, RealtimeRawModelEvent) + assert isinstance(end_of_stream, RealtimeRawModelEvent) + assert isinstance(end_of_stream.data, RealtimeModelEndOfStreamEvent) + + with pytest.raises(StopAsyncIteration): + await anext(event_iterator) + @pytest.mark.asyncio async def test_function_call_event_triggers_tool_handling(self, mock_model, mock_agent): """Test that function_call events trigger tool call handling synchronously when disabled""" From b01ea1d342dfc0e3035b41579c2e9d21237f2334 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 17 Aug 2026 07:26:03 +0900 Subject: [PATCH 345/473] release: 0.21.1 (#4467) --- pyproject.toml | 2 +- tests/fixtures/released_api_contract.json | 499 +++++++++++++++++++++- uv.lock | 2 +- 3 files changed, 498 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5eb8913d15..1d76edc8e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.21.0" +version = "0.21.1" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/fixtures/released_api_contract.json b/tests/fixtures/released_api_contract.json index be8f660e4d..744e8d0102 100644 --- a/tests/fixtures/released_api_contract.json +++ b/tests/fixtures/released_api_contract.json @@ -1,6 +1,6 @@ { - "baseline": "v0.21.0", - "baseline_commit": "1a0c08868aec2a18eba964e5a07da4270a490c25", + "baseline": "v0.21.1", + "baseline_commit": "2632043a4ed91fc819a7cfdee96958b54a00d247", "callables": { "Agent": { "dataclass_fields": [ @@ -5578,6 +5578,15 @@ }, "init": true, "name": "preserve_raw_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "timeout" } ], "kind": "class", @@ -5822,6 +5831,29 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "preserve_raw_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ] + }, + "ModelTimeoutError": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout_seconds" } ] }, @@ -6626,6 +6658,15 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "args" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" } ] } @@ -16840,6 +16881,22 @@ "value": null }, "name": "idle_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "cpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "memory" } ], "parameters": [ @@ -16940,6 +16997,24 @@ "kind": "POSITIONAL_OR_KEYWORD", "name": "idle_timeout" }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "memory" + }, { "default": { "kind": "literal", @@ -17814,6 +17889,22 @@ "value": null }, "name": "idle_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "cpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "memory" } ], "parameters": [ @@ -17995,6 +18086,24 @@ }, "kind": "KEYWORD_ONLY", "name": "idle_timeout" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "memory" } ] }, @@ -23838,6 +23947,32 @@ } ] }, + "agents.realtime.RealtimeModelEndOfStreamEvent": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "end_of_stream" + }, + "init": true, + "name": "type" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "end_of_stream" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "type" + } + ] + }, "agents.realtime.RealtimePlaybackTracker": { "dataclass_fields": [], "kind": "class", @@ -25244,6 +25379,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -25334,6 +25482,13 @@ "value": null }, "name": "run_as" + }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" } ], "parameters": [ @@ -25361,6 +25516,13 @@ }, "kind": "KEYWORD_ONLY", "name": "run_as" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" } ] }, @@ -26805,6 +26967,15 @@ }, "init": true, "name": "archive_limits" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cwd" } ], "kind": "class", @@ -26879,6 +27050,121 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "archive_limits" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cwd" + } + ] + }, + "agents.sandbox.SandboxWorkspaceScope": { + "dataclass_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cwd" + } + ], + "kind": "class", + "members": { + "anchor": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "path" + } + ] + }, + "display_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "original_path" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_relative_path" + } + ] + }, + "from_cwd": { + "binding": "class", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cwd" + } + ] + }, + "model_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workspace_relative_path" + } + ] + }, + "model_resource_path": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_root" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_relative_path" + } + ] + } + }, + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cwd" } ] }, @@ -27003,6 +27289,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -27096,6 +27395,13 @@ }, "name": "run_as" }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -27133,6 +27439,13 @@ "kind": "KEYWORD_ONLY", "name": "run_as" }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -27159,6 +27472,14 @@ }, "init": true, "name": "apply_patch" + }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "init": true, + "name": "workspace_scope" } ], "kind": "class", @@ -27177,6 +27498,13 @@ }, "kind": "POSITIONAL_OR_KEYWORD", "name": "apply_patch" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workspace_scope" } ] }, @@ -27291,6 +27619,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -27397,6 +27738,13 @@ }, "name": "run_as" }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, { "default": { "factory": "agents.sandbox.config.MemoryLayoutConfig", @@ -27447,6 +27795,13 @@ "kind": "KEYWORD_ONLY", "name": "run_as" }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, { "default": { "kind": "factory" @@ -27500,6 +27855,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -27593,6 +27961,13 @@ }, "name": "run_as" }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -27630,6 +28005,13 @@ "kind": "KEYWORD_ONLY", "name": "run_as" }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -27671,6 +28053,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -27790,6 +28185,13 @@ }, "name": "run_as" }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, { "default": { "factory": "builtins.list", @@ -27850,6 +28252,13 @@ "kind": "KEYWORD_ONLY", "name": "run_as" }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, { "default": { "kind": "factory" @@ -27928,6 +28337,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -28021,6 +28443,13 @@ }, "name": "run_as" }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -28058,6 +28487,13 @@ "kind": "KEYWORD_ONLY", "name": "run_as" }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -28099,6 +28535,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -28205,6 +28654,13 @@ }, "name": "run_as" }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, { "default": { "factory": "agents.sandbox.config.MemoryLayoutConfig", @@ -28255,6 +28711,13 @@ "kind": "KEYWORD_ONLY", "name": "run_as" }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, { "default": { "kind": "factory" @@ -28308,6 +28771,19 @@ } ] }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, "clone": { "binding": "instance", "execution_kind": "sync", @@ -28401,6 +28877,13 @@ }, "name": "run_as" }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -28438,6 +28921,13 @@ "kind": "KEYWORD_ONLY", "name": "run_as" }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, { "default": { "kind": "literal", @@ -44282,6 +44772,7 @@ "RealtimeModelAudioInterruptedEvent", "RealtimeModelCachedTokensDetails", "RealtimeModelConnectionStatusEvent", + "RealtimeModelEndOfStreamEvent", "RealtimeModelErrorEvent", "RealtimeModelEvent", "RealtimeModelExceptionEvent", @@ -44407,6 +44898,7 @@ "SandboxAgent", "SandboxArchiveLimits", "SandboxPathGrant", + "SandboxWorkspaceScope", "SandboxConcurrencyLimits", "SandboxError", "SandboxRunConfig", @@ -44892,7 +45384,8 @@ "default_tool_error_function", "sandbox", "__version__", - "InputItem" + "InputItem", + "ModelTimeoutError" ], "submodule_export_exclusions": [ "agents.sandbox.sandboxes" diff --git a/uv.lock b/uv.lock index 3c6e9b32f4..9984ac6671 100644 --- a/uv.lock +++ b/uv.lock @@ -2525,7 +2525,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.21.0" +version = "0.21.1" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 86b3db59df9de5daddefefdbc0a036b170c13ed1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 17 Aug 2026 07:30:02 +0900 Subject: [PATCH 346/473] docs: document v0.21.1 runtime behavior (#4460) --- docs/models/index.md | 15 +++++++++++++++ docs/realtime/guide.md | 2 ++ docs/running_agents.md | 1 + docs/sandbox/clients.md | 29 +++++++++++++++++++++++++++++ docs/sandbox/guide.md | 39 +++++++++++++++++++++++++++++++++------ docs/sessions/index.md | 2 ++ docs/usage.md | 2 ++ src/agents/run_config.py | 8 ++++++-- 8 files changed, 90 insertions(+), 8 deletions(-) diff --git a/docs/models/index.md b/docs/models/index.md index d547d04c7e..4c495e383d 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -494,6 +494,21 @@ english_agent = Agent( ) ``` +## Model-call timeouts + +Set [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] to a positive number of seconds to bound each model-call attempt. The timeout applies to streaming and non-streaming calls and covers the complete attempt, including transport waits. It does not limit the full agent run, function-tool execution, or retry backoff. + +```python +from agents import Agent, ModelSettings + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(timeout=30.0), +) +``` + +If an attempt exceeds the limit, the SDK cancels the attempt and waits for its cleanup to finish before raising [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]. When runner-managed retries are enabled, the SDK passes the timeout failure to the retry policy with `context.normalized.is_timeout` set to `True`; for example, `retry_policies.network_error()` matches that classification. Each permitted retry receives a new per-attempt timeout. The SDK still applies the normal [replay-safety rules](#safety-boundaries) before retrying. + ## Runner-managed retries Retries are runtime-only and opt in. The SDK does not retry general model requests unless you set `ModelSettings(retry=...)` and your retry policy chooses to retry. diff --git a/docs/realtime/guide.md b/docs/realtime/guide.md index 433d836d73..99dacc53b1 100644 --- a/docs/realtime/guide.md +++ b/docs/realtime/guide.md @@ -32,6 +32,8 @@ Unlike text-only runs, `runner.run()` does not produce a final result immediatel By default, `RealtimeRunner` uses `OpenAIRealtimeWebSocketModel`, so the default Python path is a server-side WebSocket connection to the Realtime API. If you pass a different `RealtimeModel`, the same session lifecycle and agent features still apply, while the connection mechanics can change. +When the Realtime API server closes the default WebSocket connection normally, the model transport emits a `disconnected` [`RealtimeModelConnectionStatusEvent`][agents.realtime.model_events.RealtimeModelConnectionStatusEvent] followed by a [`RealtimeModelEndOfStreamEvent`][agents.realtime.model_events.RealtimeModelEndOfStreamEvent]. `RealtimeSession` forwards both inside `raw_model_event`, drains events that are already queued, and then ends asynchronous iteration without raising an exception. A caller-initiated `session.close()` does not synthesize these server-disconnect events. Unexpected WebSocket failures continue through the session's exception path instead of ending iteration as a normal server close. + ## Agent and session configuration `RealtimeAgent` is intentionally narrower than the regular `Agent` type: diff --git a/docs/running_agents.md b/docs/running_agents.md index 9dcf4d1f4a..3f3e64beb6 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -589,6 +589,7 @@ The SDK raises exceptions in certain cases. The full list is in [`agents.excepti - [`AgentsException`][agents.exceptions.AgentsException]: This is the base class for all exceptions that the SDK raises. It serves as a generic type from which all other specific exceptions are derived. - [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: This exception is raised when the agent's run exceeds the `max_turns` limit passed to the `Runner.run`, `Runner.run_sync`, or `Runner.run_streamed` methods. It indicates that the agent could not complete its task within the specified number of agent-loop turns (LLM calls). Set `max_turns=None` to disable the limit. +- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]: This exception is raised when a model-call attempt exceeds [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout]. See [Model-call timeouts](models/index.md#model-call-timeouts) for scope and retry behavior. - [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: This exception occurs when the underlying model (LLM) produces unexpected or invalid outputs. This can include: - Malformed JSON: When the model provides a malformed JSON structure for tool calls or in its direct output, especially if a specific `output_type` is defined. - Unexpected tool-related failures: When the model fails to use tools in an expected manner diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index 001ff33e0f..6f52a8d7b7 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -54,6 +54,19 @@ run_config = RunConfig( Use this when you want container isolation or want the sandbox image to match the image used in another environment. See [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py). +### Disable Docker networking + +Set `network_mode="none"` when a Docker sandbox must not have network access: + +```python +options = DockerSandboxClientOptions( + image="python:3.14-slim", + network_mode="none", +) +``` + +The only supported explicit network mode is `"none"`; omit `network_mode` to preserve Docker's default behavior. A network-disabled sandbox cannot expose ports, so combining `network_mode="none"` with a non-empty `exposed_ports` tuple fails during option validation. The setting is stored in sandbox session state and reapplied if the SDK must create a replacement container while resuming that state. + ## Mounts and remote storage Mount entries describe what storage to expose; mount strategies describe how a sandbox backend attaches that storage. Import the built-in mount entries and generic strategies from `agents.sandbox.entries`. Hosted-provider strategies are available from `agents.extensions.sandbox` or the provider-specific extension package. @@ -102,6 +115,22 @@ For provider-specific setup notes and links for the checked-in extension example +### Size Modal sandboxes + +Use `ModalSandboxClientOptions.cpu` and `ModalSandboxClientOptions.memory` to request resources for a new Modal sandbox. A single value requests that amount. A two-item `(request, limit)` tuple uses the first item as the request and the second item as the limit. Memory values are in MiB. + +```python +from agents.extensions.sandbox import ModalSandboxClientOptions + +options = ModalSandboxClientOptions( + app_name="agents-sandbox", + cpu=(1.0, 4.0), + memory=(2048, 8192), +) +``` + +Leave `cpu`, `memory`, or both as `None` to use Modal's default for each omitted resource. The selected values are preserved in sandbox session state so replacement sandboxes use the same resource configuration. + Hosted sandbox clients expose provider-specific mount strategies. Choose the backend and mount strategy that best fit your storage provider:
diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md index 90abb88c35..ba236cd62c 100644 --- a/docs/sandbox/guide.md +++ b/docs/sandbox/guide.md @@ -169,7 +169,7 @@ Good uses for `instructions` include: - [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) keeps the agent in one interactive process when PTY state matters. - [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) forbids the sandbox reviewer from answering the user directly after inspection. - [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) requires the final filled files to actually land in `output/`. -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) pins the exact verification command and clarifies workspace-root-relative patch paths. +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) pins the exact verification command and clarifies that patch paths are workspace-root relative when `SandboxRunConfig.cwd` is unset. Avoid copying the user's one-off task into `instructions`, embedding long reference material that belongs in the manifest, restating tool docs that built-in capabilities already inject, or mixing in local installation notes the model does not need at run time. @@ -186,7 +186,7 @@ Built-in capabilities include: | Capability | Add it when | Notes | | --- | --- | --- | | `Shell` | The agent needs shell access. | Adds `exec_command`, plus `write_stdin` when the sandbox client supports PTY interaction. | -| `Filesystem` | The agent needs to edit files or inspect local images. | Adds `apply_patch` and `view_image`; patch paths are workspace-root-relative. | +| `Filesystem` | The agent needs to edit files or inspect local images. | Adds `apply_patch` and `view_image`; relative paths use the workspace root by default and `SandboxRunConfig.cwd` when configured. | | `Skills` | You want skill discovery and materialization in the sandbox. | Prefer this over manually mounting `.agents` or `.agents/skills`; `Skills` indexes and materializes skills into the sandbox for you. | | `Memory` | Follow-on runs should read or generate memory artifacts. | Requires `Shell`; updating memory artifacts during a run also requires `Filesystem`. | | `Compaction` | Long-running flows need context trimming after compaction items. | Adjusts model sampling and input handling. | @@ -195,6 +195,8 @@ Built-in capabilities include: By default, `SandboxAgent.capabilities` uses `Capabilities.default()`, which includes `Filesystem()`, `Shell()`, and `Compaction()`. If you pass `capabilities=[...]`, that list replaces the default, so include any default capabilities you still want. +The `view_image` tool identifies PNG, JPEG, GIF, WebP, BMP, and TIFF raster images from their file content, not from the filename extension. A filename with a raster-image extension is rejected when its content is unsupported, while supported raster content can be loaded even when the filename has no image extension. For `.svg` and `.svgz` files, the tool retains filename-based compatibility in addition to recognizing SVG markup from file content. + For skills, choose the source based on how you want them materialized: - `Skills(lazy_from=LocalDirLazySkillSource(...))` is a good default for larger local skill directories because the model can discover the index first and load only what it needs. @@ -235,7 +237,7 @@ Use manifest entries for the material the agent needs before work begins: Mount entries describe what storage to expose; mount strategies describe how a sandbox backend attaches that storage. See [Sandbox clients](clients.md#mounts-and-remote-storage) for mount options and provider support. -Good manifest design usually means keeping the workspace contract narrow, putting long task recipes in workspace files such as `repo/task.md`, and using relative workspace paths in instructions, for example `repo/task.md` or `output/report.md`. If the agent edits files with the `Filesystem` capability's `apply_patch` tool, remember that patch paths are relative to the sandbox workspace root, not the shell `workdir`. +Good manifest design usually means keeping the workspace contract narrow, putting long task recipes in workspace files such as `repo/task.md`, and using relative workspace paths in instructions, for example `repo/task.md` or `output/report.md`. If the agent edits files with the `Filesystem` capability's `apply_patch` tool, remember that patch paths use the sandbox workspace root by default or `SandboxRunConfig.cwd` when configured; they do not use the shell `workdir`. Use `extra_path_grants` only when the agent needs a concrete absolute path outside the workspace or the manifest needs to copy a trusted local source outside the SDK process working directory. Examples include `/tmp` for temporary tool output, `/opt/toolchain` for a read-only runtime, or a generated skills directory that should be materialized into the sandbox. A grant applies to local source materialization and SDK file APIs. It also applies to shell execution when the backend can enforce filesystem policy: @@ -472,6 +474,31 @@ These options only matter when the runner is creating a fresh sandbox session:
+### Model-facing working directory + +Set `cwd` to a POSIX workspace-relative directory when several runs should share one sandbox session but operate in separate subdirectories. The directory must exist and be accessible to the configured sandbox user when the runner validates `cwd`. For a fresh session, the runner materializes the manifest first, so the manifest can create the directory before this validation. + +```python +from agents import Runner +from agents.run import RunConfig +from agents.sandbox import SandboxRunConfig + +result = await Runner.run( + agent, + "Work only on task A.", + run_config=RunConfig( + sandbox=SandboxRunConfig( + session=shared_sandbox, + cwd="tasks/task-a", + ), + ), +) +``` + +Relative paths used by the built-in `exec_command`, `view_image`, and `apply_patch` tools resolve from `cwd`. For the `cwd` value itself, absolute paths, parent segments such as `..`, and empty values are rejected. String values must use forward slashes. Relative `PurePath` values are normalized to POSIX form, while absolute `PurePath` values remain invalid. Direct `BaseSandboxSession` file APIs remain workspace-root relative, so `cwd` does not change `Manifest.root` or the session's underlying workspace boundary. The setting changes relative-path resolution only: it does not confine the run to `cwd` or prevent access to other paths allowed by the shared session's workspace policy. + +Custom path-bearing capabilities must apply their bound [`SandboxWorkspaceScope`][agents.sandbox.workspace_paths.SandboxWorkspaceScope] when resolving model-provided relative paths. See [examples/sandbox/shared_session_workdirs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/shared_session_workdirs.py) for two concurrent runs that share one sandbox session while keeping their model-facing working directories separate. + ### Materialization controls `concurrency_limits` controls how much sandbox materialization work can run in parallel. Use `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` when large manifests or local directory copies need tighter resource control. Set either value to `None` to disable that specific limit. @@ -520,9 +547,9 @@ def build_agent(model: str) -> SandboxAgent[None]: "and summarize the file changes and risks. " "Read `repo/task.md` before editing files. Stay grounded in the repository, preserve " "existing behavior, and mention the exact verification command you ran. " - "Use the `$credit-note-fixer` skill before editing files. If the repo lives under " - "`repo/`, remember that `apply_patch` paths stay relative to the sandbox workspace " - "root, so edits still target `repo/...`." + "Use the `$credit-note-fixer` skill before editing files. " + "This example leaves `SandboxRunConfig.cwd` unset, so `apply_patch` paths stay " + "relative to the sandbox workspace root and edits still target `repo/...`." ), # Put repos and task files in the manifest. default_manifest=Manifest( diff --git a/docs/sessions/index.md b/docs/sessions/index.md index dee68240b1..4fc468a7e2 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -276,6 +276,8 @@ print(result.final_output) By default, after each turn, the SDK checks whether the compaction candidate meets the threshold and compacts only if it does. +When automatic compaction runs, the SDK waits for it before `Runner.run(...)` returns or the streamed event iterator closes. Usage reported by the compaction request contributes to that run's [`Usage`](../usage.md) totals. By default, a manual `run_compaction()` call made later has no enclosing run context and does not update the completed run's usage object. + `compaction_mode="previous_response_id"` uses Responses API response IDs retained by the compaction session and works best while that response chain remains available. `compaction_mode="input"` rebuilds the compaction request from the current session items instead, which is useful when the response chain is unavailable or you want the session contents to be the source of truth. The default `"auto"` chooses the safest available option. If your agent runs with `ModelSettings(store=False)`, the Responses API does not retain the last response for later lookup. In that stateless setup, the default `"auto"` mode falls back to input-based compaction instead of relying on `previous_response_id`. See [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py) for a complete example. diff --git a/docs/usage.md b/docs/usage.md index 2d4f1d1987..95fc09ec5d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -30,6 +30,8 @@ print("Total tokens:", usage.total_tokens) Usage is aggregated across all model calls during the run, including model calls that produce tool calls or handoffs. +When an [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] automatically compacts history before the run finishes, usage reported by that `responses.compact` request is also added to the same run totals. A manual `run_compaction()` call made outside a run has no enclosing run context, so it does not update the usage object returned by an earlier run. See [OpenAI Responses compaction sessions](sessions/index.md#openai-responses-compaction-sessions). + ### Enabling usage with third-party adapters Usage reporting varies across third-party adapters and provider backends. If you access models through third-party adapters and need accurate `result.context_wrapper.usage` values: diff --git a/src/agents/run_config.py b/src/agents/run_config.py index c413910ebd..cf28ec5cf8 100644 --- a/src/agents/run_config.py +++ b/src/agents/run_config.py @@ -224,8 +224,12 @@ class SandboxRunConfig: Relative paths used by the built-in `exec_command`, `view_image`, and `apply_patch` tools resolve from this directory. Custom path-bearing capabilities must apply their bound - `SandboxWorkspaceScope` explicitly. The directory must already exist when the run starts. - This setting does not change `Manifest.root` or direct `BaseSandboxSession` path behavior. + `SandboxWorkspaceScope` explicitly. The directory must exist and be accessible to the + configured sandbox user when the runner validates `cwd`; for a fresh session, the runner + materializes the manifest before that validation. + This setting changes relative-path resolution only. It does not confine the run to `cwd`, + prevent access to other paths allowed by the shared session's workspace policy, change + `Manifest.root`, or change direct `BaseSandboxSession` path behavior. """ if TYPE_CHECKING: From 39327d7c5d04c120bf47f1ee9696c078e1f55441 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 17 Aug 2026 08:03:40 +0900 Subject: [PATCH 347/473] docs: update translated pages --- docs/ja/models/index.md | 316 ++++++++++++++++-------------- docs/ja/realtime/guide.md | 122 ++++++------ docs/ja/running_agents.md | 216 ++++++++++---------- docs/ja/sandbox/clients.md | 99 ++++++---- docs/ja/sandbox/guide.md | 385 +++++++++++++++++++----------------- docs/ja/sessions/index.md | 208 ++++++++++---------- docs/ja/tracing.md | 109 ++++++----- docs/ja/usage.md | 46 ++--- docs/ko/models/index.md | 273 ++++++++++++++------------ docs/ko/realtime/guide.md | 132 ++++++------- docs/ko/running_agents.md | 211 ++++++++++---------- docs/ko/sandbox/clients.md | 111 +++++++---- docs/ko/sandbox/guide.md | 365 ++++++++++++++++++---------------- docs/ko/sessions/index.md | 154 +++++++-------- docs/ko/tracing.md | 89 ++++----- docs/ko/usage.md | 46 ++--- docs/zh/models/index.md | 306 +++++++++++++++-------------- docs/zh/realtime/guide.md | 160 +++++++-------- docs/zh/running_agents.md | 227 +++++++++++----------- docs/zh/sandbox/clients.md | 99 ++++++---- docs/zh/sandbox/guide.md | 389 ++++++++++++++++++++----------------- docs/zh/sessions/index.md | 206 ++++++++++---------- docs/zh/tracing.md | 109 ++++++----- docs/zh/usage.md | 50 ++--- 24 files changed, 2331 insertions(+), 2097 deletions(-) diff --git a/docs/ja/models/index.md b/docs/ja/models/index.md index 96c3892ab0..287703afe2 100644 --- a/docs/ja/models/index.md +++ b/docs/ja/models/index.md @@ -4,43 +4,43 @@ search: --- # モデル -Agents SDK は、すぐに利用できる OpenAI モデルを次の 2 種類の形式でサポートしています。 +Agents SDK は、すぐに利用できる OpenAI モデルを 2 種類サポートしています。 -- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 -- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 +- **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 +- [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 ## モデル設定の選択 -まず、設定に合う最もシンプルな方法を選択してください。 +設定に適した最もシンプルな方法から始めてください。 | 目的 | 推奨される方法 | 詳細 | | --- | --- | --- | -| OpenAI モデルのみを使用する | デフォルトの OpenAI プロバイダーで Responses モデルのパスを使用する | [OpenAI モデル](#openai-models) | +| OpenAI モデルのみを使用する | デフォルトの OpenAI プロバイダーと Responses モデルのパスを使用する | [OpenAI モデル](#openai-models) | | WebSocket トランスポート経由で OpenAI Responses API を使用する | Responses モデルのパスを維持し、WebSocket トランスポートを有効にする | [Responses WebSocket トランスポート](#responses-websocket-transport) | -| OpenAI がホストするサブエージェントを使用する | 実験的なホスト型マルチエージェントモデルを使用する | [ホスト型マルチエージェント](#hosted-multi-agent-experimental) | -| OpenAI 以外の単一プロバイダーを使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI 以外のモデル](#non-openai-models) | -| エージェント間でモデルまたはプロバイダーを組み合わせる | 実行ごとまたはエージェントごとにプロバイダーを選択し、機能の違いを確認する | [単一ワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[複数プロバイダー間でのモデルの組み合わせ](#mixing-models-across-providers) | +| OpenAI がホストするサブエージェントを使用する | 実験的なホステッド・マルチエージェントモデルを使用する | [ホステッド・マルチエージェント](#hosted-multi-agent-experimental) | +| OpenAI 以外のプロバイダーを 1 つ使用する | 組み込みのプロバイダー統合ポイントから始める | [OpenAI 以外のモデル](#non-openai-models) | +| エージェント間でモデルまたはプロバイダーを組み合わせる | 実行単位またはエージェント単位でプロバイダーを選択し、機能の違いを確認する | [1 つのワークフローでのモデルの組み合わせ](#mixing-models-in-one-workflow)および[プロバイダー間でのモデルの組み合わせ](#mixing-models-across-providers) | | OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses のパスで `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | -| OpenAI 以外または複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポートされているベータ版アダプターを比較し、リリース予定のプロバイダーパスを検証する | [サードパーティ製アダプター](#third-party-adapters) | +| OpenAI 以外または複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポートされているベータ版アダプターを比較し、提供予定のプロバイダーパスを検証する | [サードパーティ製アダプター](#third-party-adapters) | ## OpenAI モデル -OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルのパスを維持することを推奨します。 +OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルのパスを維持する方法を推奨します。 -[`Agent`][agents.agent.Agent] でモデルを指定しない場合、Agents SDK はコスト重視で大量処理を行うエージェントワークフロー向けに、デフォルトで `reasoning.effort="none"` および `verbosity="low"` とともに [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna) を使用します。最先端の能力が必要なアプリケーションでは、`model="gpt-5.6-sol"` を明示的に設定し、ワークロードに適した `model_settings` を選択できます。 +[`Agent`][agents.agent.Agent] でモデルを指定しない場合、Agents SDK は、コスト重視で大量処理を行うエージェントワークフロー向けに、デフォルトで [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna) を `reasoning.effort="none"` および `verbosity="low"` とともに使用します。最先端の性能が必要なアプリケーションでは、`model="gpt-5.6-sol"` を明示的に設定し、ワークロードに適した `model_settings` を選択できます。 `gpt-5.6-sol` などの別のモデルに切り替える場合、エージェントを設定する方法は 2 つあります。 ### デフォルトモデル -まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用する場合は、エージェントを実行する前に `OPENAI_DEFAULT_MODEL` 環境変数を設定します。 +まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用するには、エージェントを実行する前に環境変数 `OPENAI_DEFAULT_MODEL` を設定します。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -次に、`RunConfig` を使用して、実行のデフォルトモデルを設定できます。エージェントにモデルを設定しなかった場合、この実行のモデルが使用されます。 +次に、`RunConfig` を使用して、実行のデフォルトモデルを設定できます。エージェントにモデルを設定しなかった場合は、この実行のモデルが使用されます。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 モデル -この方法で `gpt-5.6-sol` などの任意の GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。これには、ほとんどのユースケースに最適な設定が指定されています。デフォルトモデルの推論量を調整するには、独自の `ModelSettings` を渡します。 +この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースで最適に機能する設定が適用されます。デフォルトモデルの推論エフォートを調整するには、独自の `ModelSettings` を渡します。 ```python from openai.types.shared import Reasoning @@ -77,7 +77,7 @@ my_agent = Agent( レイテンシーを低減するには、GPT-5 モデルで `reasoning.effort="none"` を使用することを推奨します。 -GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、会話ターン間で引き継がれる推論コンテキスト、および `"max"` の effort レベルもサポートします。これらの制御は Responses API のパスで使用できます。 +GPT-5.6 は、既存の `reasoning` 設定を通じて、推論モード、会話ターン間で保持される推論コンテキスト、および `"max"` エフォートレベルもサポートします。これらの制御は Responses API のパスで利用できます。 ```python from openai.types.shared import Reasoning @@ -96,38 +96,38 @@ agent = Agent( ) ``` -`reasoning.mode` と `reasoning.context` は Responses 専用の設定です。Chat Completions は `reasoning.effort` のみを使用し、サポートされる effort レベルはモデルと API サーフェスによって異なります。GPT-5.6 の `"max"` effort には Responses API を使用してください。Chat Completions アダプターは警告を表示して mode と context を無視します。この警告をエラーに変更するには、OpenAI プロバイダーで `strict_feature_validation=True` を設定します。 +`reasoning.mode` と `reasoning.context` は、Responses 専用の設定です。Chat Completions では `reasoning.effort` のみが使用され、サポートされるエフォートレベルはモデルおよび API サーフェスによって異なります。GPT-5.6 の `"max"` エフォートには Responses API を使用してください。Chat Completions アダプターは警告を出してモードとコンテキストを無視します。この警告をエラーにするには、OpenAI プロバイダーで `strict_feature_validation=True` を設定します。 `context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の Responses API 会話、または次のリクエストに以前の推論項目を含めることで、会話を維持してください。ステートレスな `store=False` 呼び出しでは、レスポンスで `reasoning.encrypted_content` をリクエストし、その推論項目を次のリクエストの入力に含めます。 #### ComputerTool のモデル選択 -エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストにおける有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは、GA の組み込み `computer` ツールが使用されます。一方、明示的な `computer-use-preview` リクエストでは、従来の `computer_use_preview` ペイロードが維持されます。 +エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは、GA 版の組み込み `computer` ツールが使用されます。一方、明示的な `computer-use-preview` リクエストでは、従来の `computer_use_preview` ペイロードが維持されます。 -主な例外は、プロンプトで管理される呼び出しです。プロンプトテンプレートでモデルを指定し、SDK がリクエストから `model` を省略する場合、プロンプトでどのモデルが固定されているかを推測しないよう、SDK はプレビュー互換のコンピューターペイロードをデフォルトで使用します。このフローで GA のパスを維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` を使用して GA セレクターを強制します。 +主な例外は、プロンプトで管理される呼び出しです。プロンプトテンプレートでモデルを指定し、SDK がリクエストから `model` を省略する場合、SDK はプロンプトが固定するモデルを推測しないよう、プレビュー互換のコンピューターペイロードをデフォルトで使用します。このフローで GA のパスを維持するには、リクエストで `model="gpt-5.5"` を明示するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制します。 -[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターに正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は引き続き通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が登録されている場合、`tool_choice="computer"`、`"computer_use"`、および `"computer_use_preview"` は、有効なリクエストモデルに一致する組み込みセレクターへ正規化されます。`ComputerTool` が登録されていない場合、これらの文字列は通常の関数名として動作し続けます。 -プレビュー互換のリクエストでは、`environment` と表示サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用する、プロンプトで管理されたフローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエストを送信する前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 +プレビュー互換のリクエストでは、`environment` と画面サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエスト送信前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 #### GPT-5 以外のモデル -カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK は任意のモデルと互換性のある汎用の `ModelSettings` に戻ります。 +カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はどのモデルとも互換性がある汎用の `ModelSettings` に戻ります。 ### Responses 専用のツール機能 -以下のツール機能は、OpenAI Responses モデルでのみサポートされます。 +次のツール機能は、OpenAI Responses モデルでのみサポートされます。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] -- [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` およびその他の遅延読み込み対応の Responses ツールサーフェス -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`、および `tool_choice="programmatic_tool_calling"` +- [`ToolSearchTool`][agents.tool.ToolSearchTool] +- [`tool_namespace()`][agents.tool.tool_namespace] +- `@function_tool(defer_loading=True)` およびその他の遅延読み込み対応の Responses ツールサーフェス +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers`、および `tool_choice="programmatic_tool_calling"` -これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、単独の名前空間名や遅延読み込み専用の関数名を強制するのではなく、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホスト型ツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 +これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、名前空間名のみ、または遅延読み込み専用の関数名を強制する代わりに、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホステッドツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 ### Responses WebSocket トランスポート -デフォルトでは、OpenAI Responses API のリクエストは HTTP トランスポートを使用します。OpenAI Responses プロバイダーのパスを使用する場合は、WebSocket トランスポートを明示的に有効にできます。 +デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI Responses プロバイダーのパスを使用する場合は、WebSocket トランスポートを有効にできます。 #### 基本設定 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -これは、デフォルトの OpenAI プロバイダーがモデル名を解決した結果となる OpenAI Responses モデルに影響します。これには、`"gpt-5.6-sol"` などの文字列のモデル名も含まれます。 +これは、デフォルトの OpenAI プロバイダーがモデル名を解決した結果として得られる OpenAI Responses モデルに影響します。これには、`"gpt-5.6-sol"` などの文字列のモデル名も含まれます。 -トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡す場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡した場合は、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 +トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡した場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡した場合は、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 -#### プロバイダーまたは実行レベルの設定 +#### プロバイダー単位または実行単位の設定 -プロバイダーごと、または実行ごとに WebSocket トランスポートを設定することもできます。 +WebSocket トランスポートは、プロバイダー単位または実行単位でも設定できます。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -SDK の OpenAI 統合を経由してルーティングするプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAI の設定でハーネス ID などのプロバイダーレベルの登録メタデータが必要な場合の高度なオプションです。 +SDK の OpenAI 統合を介してルーティングするプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAI の設定でハーネス ID などのプロバイダー単位の登録メタデータが必要な場合に使用する高度なオプションです。 ```python from agents import ( @@ -190,14 +190,14 @@ result = await Runner.run( #### `MultiProvider` を使用した高度なルーティング -プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を組み合わせる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 +プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を混在させる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 -`MultiProvider` には、従来からのデフォルト動作が 2 つあります。 +`MultiProvider` には、次の 2 つの従来のデフォルトがあります。 -- `openai/...` は OpenAI プロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 -- 不明なプレフィックスは、そのまま渡されるのではなく `UserError` を発生させます。 +- `openai/...` は OpenAI プロバイダーのエイリアスとして扱われるため、`openai/gpt-4.1` はモデル `gpt-4.1` としてルーティングされます。 +- 不明なプレフィックスは、そのまま渡されるのではなく `UserError` を発生させます。 -OpenAI プロバイダーを、名前空間付きのモデル ID をそのまま要求する OpenAI 互換エンドポイントに接続する場合は、パススルー動作を明示的に有効にします。WebSocket を有効にした設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を維持してください。 +OpenAI プロバイダーを、名前空間付きのモデル ID をそのまま受け取ることを想定した OpenAI 互換エンドポイントに向ける場合は、パススルー動作を明示的に有効にしてください。WebSocket が有効な設定では、`MultiProvider` にも `openai_use_responses_websocket=True` を設定します。 ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -バックエンドが文字列 `openai/...` をそのまま要求する場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を要求する場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも機能します。この例で WebSocket を有効にしているのは、このセクションで説明しているトランスポート設定の一部だからです。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも使用できます。 +バックエンドがリテラルの `openai/...` 文字列を想定する場合は、`openai_prefix_mode="model_id"` を使用します。バックエンドが `openrouter/openai/gpt-4.1-mini` など、その他の名前空間付きモデル ID を想定する場合は、`unknown_prefix_mode="model_id"` を使用します。これらのオプションは、WebSocket トランスポート以外の `MultiProvider` でも動作します。この例では、このセクションで説明するトランスポート設定の一部であるため、WebSocket を有効なままにしています。同じオプションは [`responses_websocket_session()`][agents.responses_websocket_session] でも利用できます。 -`MultiProvider` を介してルーティングする際に、同じプロバイダーレベルの登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡します。これは基盤となる OpenAI プロバイダーへ転送されます。 +`MultiProvider` を通じてルーティングしながら、同じプロバイダー単位の登録メタデータが必要な場合は、`openai_agent_registration=OpenAIAgentRegistrationConfig(...)` を渡してください。基盤となる OpenAI プロバイダーへ転送されます。 カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket の `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 -#### 注意事項 +#### 注記 -- これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions には適用されません。OpenAI 以外のプロバイダーには、Responses WebSocket の `/responses` エンドポイントをサポートしている場合にのみ適用されます。 -- 環境にまだ存在しない場合は、`websockets` パッケージをインストールしてください。 -- WebSocket トランスポートを有効にした後、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数のターン間で同じ WebSocket 接続を再利用したいマルチターンワークフローでは、ネストされた Agents-as-tools 呼び出しも含め、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 -- 長時間の推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズします。遅延した pong フレームを許容するには `ping_timeout` を増やし、ping を有効にしたままハートビートのタイムアウトを無効にするには `ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを優先してください。 -- デフォルトでは、SDK は受信メッセージのサイズ上限を無効にします(`max_size=None`)。プロキシの背後にある長時間稼働のエージェントプロセスや、メモリに制約があるコンテナでは、メッセージごとのメモリ使用量を制限するために `responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定します。 -- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。この上限に達したら、新しい接続を開いてください。並列実行が必要な場合は、複数の接続を使用します。 -- サービスは、接続ローカルのメモリに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、`previous_response_id` が参照するレスポンスがそのメモリから削除されます。再接続後も、保存済みのレスポンスが利用可能であれば続行できますが、`store=False` および ZDR フローには永続化されたフォールバックがありません。`previous_response_id=None` で新しいチェーンを開始して完全な入力コンテキストを送信するか、ローカルで管理しているセッション状態からそのコンテキストを再構築してください。 +- これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions には適用されません。OpenAI 以外のプロバイダーには、Responses WebSocket の `/responses` エンドポイントをサポートしている場合にのみ適用されます。 +- 環境にまだ存在しない場合は、`websockets` パッケージをインストールしてください。 +- WebSocket トランスポートを有効にした後は、[`Runner.run_streamed()`][agents.run.Runner.run_streamed] を直接使用できます。複数ターンのワークフローで、ターン間およびネストされたエージェントをツールとして使用する呼び出し間で同じ WebSocket 接続を再利用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] ヘルパーを推奨します。[エージェントの実行](../running_agents.md)ガイドおよび [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py) を参照してください。 +- 長時間の推論ターンやレイテンシーが急増するネットワークでは、`responses_websocket_options` を使用して WebSocket のキープアライブ動作をカスタマイズしてください。遅延した pong フレームを許容するには `ping_timeout` を増やします。ping を有効なままハートビートのタイムアウトを無効にするには、`ping_timeout=None` を設定します。WebSocket のレイテンシーより信頼性が重要な場合は、HTTP/SSE トランスポートを優先してください。 +- デフォルトでは、SDK は受信メッセージのサイズ制限を無効にします(`max_size=None`)。プロキシの背後で長期間稼働するエージェントプロセスや、メモリに制約のあるコンテナでは、メッセージごとのメモリ使用量を制限するために `responses_websocket_options={"max_size": 8 * 1024 * 1024}` を設定してください。 +- [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。この上限に達したら新しい接続を開いてください。並列実行が必要な場合は複数の接続を使用します。 +- サービスは、接続ローカルのメモリに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、`previous_response_id` が参照するレスポンスがそのメモリから削除されます。再接続後も、保存済みのレスポンスが利用可能であれば継続できますが、`store=False` と ZDR のフローには永続化されたフォールバックがありません。`previous_response_id=None` で新しいチェーンを開始して完全な入力コンテキストを送信するか、ローカルで管理されるセッション状態からそのコンテキストを再構築してください。 -### ホスト型マルチエージェント(実験的) +### ホステッド・マルチエージェント(実験的) -OpenAI Responses API のホスト型マルチエージェントベータでは、GPT-5.6 のルートモデルがサーバーでホストされるサブエージェントを作成して連携できます。Agents SDK は通常どおり `Runner` を使用し続けられます。ホスト型オーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 +OpenAI Responses API のホステッド・マルチエージェントベータでは、GPT-5.6 のルートモデルが、サーバーでホストされるサブエージェントを作成および調整できます。Agents SDK は通常の `Runner` を引き続き使用できます。ホステッドオーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 -この統合は実験的であり、ローカル関数の出力を `response.inject` を使用してアクティブなホスト型エージェントへ返せるよう、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開する、バージョン 2.45.0 以降の `openai[realtime]` ビルドが必要です。インターフェースとベータ版の項目スキーマは、一般提供までに変更される可能性があります。 +この統合は実験的なもので、ローカル関数の出力を `response.inject` によってアクティブなホステッドエージェントへ返せるよう、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開しているバージョン 2.45.0 以降の `openai[realtime]` のビルドが必要です。インターフェースとベータ版の項目スキーマは、一般提供前に変更される可能性があります。 #### モデルの設定 @@ -260,11 +260,11 @@ agent = Agent( ) ``` -`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` を指定しない限り、このモデルはデフォルトの OpenAI クライアントを使用します。`max_concurrent_subagents` を省略すると、サービスのデフォルトが使用されます。 +`OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` を指定しない場合、モデルはデフォルトの OpenAI クライアントを使用します。`max_concurrent_subagents` を省略した場合は、サービスのデフォルトが使用されます。 #### ローカル関数ツール -すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは、Responses API が決定します。通常の SDK Runner が関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスに挿入します。これにより、サービスは元のホスト型呼び出し元を再開できます。関数の実行には、引き続き Runner の通常のガードレール、フック、および失敗時の変換が適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 +すべてのホステッドエージェントは、リクエストに設定されたモデルとツールを共有します。どのホステッドエージェントが関数を呼び出すかは、Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスへ注入します。これにより、サービスは元のホステッド呼び出し元を再開できます。関数の実行には、引き続き Runner の通常のガードレール、フック、および失敗時の変換が適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 ツールで呼び出し元を考慮したログ記録または認可が必要な場合は、`get_hosted_agent_metadata()` を使用します。 @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -ホスト型エージェントの名前は観測用のメタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、ツールの実行前または実行中に、必要な認可をアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 +ホステッドエージェント名は観測用のメタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、ツール実行前または実行中に、必要な認可をアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 #### 出力とストリーミングの動作 -`/root` に帰属し、フェーズが `final_answer` のメッセージだけが、通常の最終メッセージになります。実験的アダプターは、サブエージェントのメッセージとホスト型オーケストレーションのレコードを高レベルの `RunResult` から除外します。SDK がこれらのレコードをローカル関数として実行することはありません。 +フェーズが `final_answer` で、`/root` に属するメッセージだけが、通常の最終メッセージになります。実験的アダプターは、上位レベルの `RunResult` からサブエージェントのメッセージとホステッドオーケストレーションのレコードを除外します。SDK がそれらのレコードをローカル関数として実行することはありません。 -raw ストリーミングでは、ホスト型の出力項目や `response.inject.created` の確認応答を含む、ベータ版 Responses イベントが引き続き公開されます。アダプターは、関数呼び出しの準備ができた時点で、1 つのアクティブなプロバイダーレスポンスを SDK から見える論理モデルターンに分割します。その後、Runner が出力を生成すると、同じプロバイダーレスポンスを再開します。項目またはツール呼び出しが帰属するホスト型エージェントを識別するには、raw のホスト型項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用します。 +raw ストリーミングでは、ホステッド出力項目や `response.inject.created` の確認応答を含む、Responses のベータイベントが引き続き公開されます。アダプターは、関数呼び出しの準備が整ったときに 1 つのアクティブなプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。raw のホステッド項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用すると、その項目またはツール呼び出しがどのホステッドエージェントに属するかを識別できます。 #### SDK オーケストレーションとの関係 -ホスト型マルチエージェントは、SDK のハンドオフや Agents-as-tools とは別の機能です。 +ホステッド・マルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 -- ホスト型マルチエージェントは、OpenAI サービス上でサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 -- SDK のハンドオフは、アクティブなローカル SDK の `Agent` を変更します。この実験的モデルを使用する場合、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が発生するため、ハンドオフは拒否されます。 -- Agents-as-tools は引き続き使用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 +- ホステッド・マルチエージェントは、OpenAI サービス上にサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 +- SDK のハンドオフは、アクティブなローカル SDK の `Agent` を変更します。この実験的モデルを使用している場合、すべてのホステッドエージェントが同じハンドオフツールを受け取って所有権の競合が発生するため、ハンドオフは拒否されます。 +- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 #### 現在の制限事項 -実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版でサポートされていませんが、サービスが各ホスト型エージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 +実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスが各ホステッドエージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 -1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に所有できるアクティブなホスト型レスポンスは、最大 1 つです。ローカル関数の出力を待っている間に実行を中止した場合は、`await model.close()` を呼び出して WebSocket を解放してください。実行中のホスト型レスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 +1 つの `OpenAIHostedMultiAgentModel` インスタンスが同時に所有できるアクティブなホステッドレスポンスは、最大 1 つです。ローカル関数の出力を待っている間に実行を放棄した場合は、`await model.close()` を呼び出して WebSocket を解放してください。進行中のホステッドレスポンスを別のプロセスまたはイベントループで復元することは、現在サポートされていません。 -基盤となる Responses API ベータ版の動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。非ストリーミングおよびストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 +基盤となる Responses API ベータ版の動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。ストリーミングおよび非ストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 ## OpenAI 以外のモデル -OpenAI 以外のプロバイダーが必要な場合は、SDK の組み込みプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくても、これで十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 +OpenAI 以外のプロバイダーが必要な場合は、SDK に組み込まれたプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくてもこれで十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 ### OpenAI 以外のプロバイダーの統合方法 -| 方法 | 使用する状況 | スコープ | +| 方法 | 使用する状況 | 適用範囲 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI 互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルなデフォルト | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行ごと | -| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェントごと | -| サードパーティ製アダプター | 組み込みのパスでは提供されないプロバイダー対応範囲またはルーティングが、アダプターによって必要な場合 | [サードパーティ製アダプター](#third-party-adapters)を参照 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI 互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを 1 回の実行に適用する場合 | 実行単位 | +| [`Agent.model`][agents.agent.Agent.model] | エージェントごとに異なるプロバイダーまたは具体的なモデルオブジェクトが必要な場合 | エージェント単位 | +| サードパーティ製アダプター | 組み込みのパスでは提供されないプロバイダー対応範囲またはルーティングが必要な場合 | [サードパーティ製アダプター](#third-party-adapters)を参照 | 次の組み込みの方法で、他の LLM プロバイダーを統合できます。 -1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーに OpenAI 互換の API エンドポイントがあり、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで指定します。これにより、「この実行内のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 -3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスでモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせられます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 +1. [`set_default_openai_client`][agents.set_default_openai_client] は、`AsyncOpenAI` のインスタンスを LLM クライアントとしてグローバルに使用する場合に便利です。これは、LLM プロバイダーが OpenAI 互換 API エンドポイントを備え、`base_url` と `api_key` を設定できる場合に使用します。設定可能なコード例については、[examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py) を参照してください。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] は `Runner.run` レベルで機能します。これにより、「この実行のすべてのエージェントでカスタムモデルプロバイダーを使用する」と指定できます。設定可能なコード例については、[examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py) を参照してください。 +3. [`Agent.model`][agents.agent.Agent.model] を使用すると、特定の Agent インスタンスにモデルを指定できます。これにより、エージェントごとに異なるプロバイダーを組み合わせて使用できます。設定可能なコード例については、[examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py) を参照してください。 -`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` を使用してトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 +`platform.openai.com` の API キーがない場合は、`set_tracing_disabled()` でトレーシングを無効にするか、[別のトレーシングプロセッサー](../tracing.md)を設定することを推奨します。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - これらのコード例では、多くの LLM プロバイダーがまだ Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses API をサポートしている場合は、Responses の使用を推奨します。 + これらのコード例では、依然として多くの LLM プロバイダーが Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 -## 単一ワークフローでのモデルの組み合わせ +## 1 つのワークフローでのモデルの組み合わせ -単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小さく高速なモデルを使用し、複雑なタスクにはより大規模で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定するときは、次のいずれかの方法で特定のモデルを選択できます。 +1 つのワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する際は、次のいずれかの方法で特定のモデルを選択できます。 -1. モデル名を渡す。 -2. 任意のモデル名と、その名前を Model インスタンスにマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡す。 -3. [`Model`][agents.models.interface.Model] の実装を直接指定する。 +1. モデル名を渡します。 +2. 任意のモデル名と、その名前を Model インスタンスへマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡します。 +3. [`Model`][agents.models.interface.Model] の実装を直接指定します。 !!! note - SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、この 2 つの形式でサポートされる機能とツールが異なるため、ワークフローごとに単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用可能であることを確認してください。 + SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、2 つの形式でサポートされる機能とツールが異なるため、ワークフローごとに 1 つのモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 ```python import asyncio @@ -391,8 +391,8 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAI モデルの名前を直接設定します。 -2. [`Model`][agents.models.interface.Model] の実装を指定します。 +1. OpenAI モデルの名前を直接設定します。 +2. [`Model`][agents.models.interface.Model] の実装を指定します。 エージェントで使用するモデルをさらに設定する場合は、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。 @@ -409,22 +409,22 @@ english_agent = Agent( ## OpenAI Responses の高度な設定 -OpenAI Responses のパスを使用していて、より詳細な制御が必要な場合は、まず `ModelSettings` を使用します。 +OpenAI Responses のパスでより細かい制御が必要な場合は、`ModelSettings` から始めてください。 ### 一般的な高度な `ModelSettings` オプション -OpenAI Responses API を使用する場合、いくつかのリクエストフィールドには対応する `ModelSettings` フィールドがすでに直接用意されているため、それらに `extra_args` を使用する必要はありません。 +OpenAI Responses API を使用する場合、複数のリクエストフィールドには対応する `ModelSettings` フィールドがすでに直接用意されているため、それらに `extra_args` を使用する必要はありません。 - `parallel_tool_calls`: 同じターンで複数のツール呼び出しを許可または禁止します。 -- `truncation`: コンテキストが上限を超える場合に失敗させるのではなく、Responses API が最も古い会話項目を削除できるよう、`"auto"` を設定します。 -- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これは、レスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へフォールバックする必要があるセッション圧縮フローに影響します。 -- `context_management`: `compact_threshold` を使用する Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 -- `prompt_cache_retention`: 以前のモデルファミリー向けの保持期間延長を設定します。たとえば、 - `"24h"` を使用します。 +- `truncation`: コンテキストが上限を超える場合に失敗させる代わりに、Responses API が最も古い会話項目を削除できるよう、`"auto"` を設定します。 +- `store`: 生成されたレスポンスを後で取得できるよう、サーバー側に保存するかどうかを制御します。これはレスポンス ID に依存する後続ワークフローや、`store=False` の場合にローカル入力へのフォールバックが必要になる可能性があるセッション圧縮フローに関係します。 +- `context_management`: `compact_threshold` を使用した Responses の圧縮など、サーバー側のコンテキスト処理を設定します。 +- `prompt_cache_retention`: 以前のモデルファミリー向けに、たとえば + `"24h"` を使用して保持期間の延長を設定します。 - `prompt_cache_options`: 暗黙的または明示的なプロンプトキャッシュを選択し、GPT-5.6 では `"30m"` のキャッシュ TTL を設定します。 -- `response_include`: `web_search_call.action.sources`、`file_search_call.results`、`reasoning.encrypted_content` など、より詳細なレスポンスペイロードをリクエストします。 -- `top_logprobs`: 出力テキストについて、上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 -- `retry`: モデル呼び出しに対して Runner が管理する再試行設定を有効にします。[Runner が管理する再試行](#runner-managed-retries)を参照してください。 +- `response_include`: `web_search_call.action.sources`、`file_search_call.results`、または `reasoning.encrypted_content` など、より詳細なレスポンスペイロードをリクエストします。 +- `top_logprobs`: 出力テキストの上位トークンの logprobs をリクエストします。SDK は `message.output_text.logprobs` も自動的に追加します。 +- `retry`: Runner が管理するモデル呼び出しの再試行設定を有効にします。[Runner 管理の再試行](#runner-managed-retries)を参照してください。 ```python from agents import Agent, ModelSettings @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストにそのまま渡され、Chat Completions コンバーターは、テキスト、画像、音声、およびファイルのコンテンツ部分にあるブレークポイントを維持します。 +明示的なプロンプトキャッシュでは、再利用可能なプレフィックスの末尾となるコンテンツ部分にブレークポイントを追加します。同じ `ModelSettings.prompt_cache_options` フィールドが Responses と Chat Completions のリクエストでそのまま渡され、Chat Completions コンバーターはテキスト、画像、音声、およびファイルのコンテンツ部分にあるブレークポイントを維持します。 ```python from agents import Runner @@ -470,19 +470,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention` は、従来の保持制御を使用する以前のモデルファミリーでも引き続き利用できます。 -直接指定する `ModelSettings` フィールドと、`extra_args` 内の同じキーを -組み合わせないでください。 +従来の保持制御を使用する以前のモデルファミリーでは、`prompt_cache_retention` を引き続き利用できます。 +`ModelSettings` の直接フィールドと、`extra_args` 内の同じキーを併用しないでください。 -`store=False` を設定すると、Responses API はそのレスポンスを後からサーバー側で取得できる状態に維持しません。これはステートレスまたはゼロデータ保持形式のフローに便利ですが、通常であればレスポンス ID を再利用する機能が、代わりにローカルで管理される状態に依存する必要があることも意味します。たとえば、最後のレスポンスが保存されなかった場合、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] は、デフォルトの `"auto"` 圧縮パスを入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 +`store=False` を設定すると、Responses API はそのレスポンスを後でサーバー側から取得できる状態で保持しません。これはステートレスまたはゼロデータ保持形式のフローに便利ですが、通常はレスポンス ID を再利用する機能で、代わりにローカル管理の状態を使用する必要があることも意味します。たとえば、最後のレスポンスが保存されていない場合、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] はデフォルトの `"auto"` 圧縮パスを入力ベースの圧縮へ切り替えます。[セッションガイド](../sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は各 Responses API リクエストで送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を生成できます。`OpenAIResponsesCompactionSession` はターン間でスタンドアロンの `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 +サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は Responses API リクエストごとに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を出力できます。`OpenAIResponsesCompactionSession` はターン間で独立した `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 ### `extra_args` の受け渡し -SDK がトップレベルでまだ直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 +SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 -OpenAI モデルを使用する場合、`extra_args` は Responses API と Chat Completions API の両方にオプションのパラメーターを渡せます。たとえば、`user` や `service_tier` です。対応モデルで [Fast mode](https://developers.openai.com/api/docs/guides/fast-mode) を使用するには、`extra_args={"service_tier": "fast"}` を設定します。`"priority"` も同等です。同じリクエストフィールドを、直接指定する `ModelSettings` フィールドでも設定しないでください。 +OpenAI モデルを使用する場合、`extra_args` を使用すると、Responses API と Chat Completions API の両方にオプションのパラメーターを渡せます(例: `user` および `service_tier`)。サポート対象モデルで [Fast モード](https://developers.openai.com/api/docs/guides/fast-mode)を使用するには、`extra_args={"service_tier": "fast"}` を設定します。`"priority"` も同等です。同じリクエストフィールドを `ModelSettings` の直接フィールドでも設定しないでください。 ```python from agents import Agent, ModelSettings @@ -498,11 +497,26 @@ english_agent = Agent( ) ``` -## Runner が管理する再試行 +## モデル呼び出しのタイムアウト -再試行はランタイム専用であり、明示的に有効化する必要があります。`ModelSettings(retry=...)` を設定し、再試行ポリシーで再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 +モデル呼び出しの各試行を制限するには、[`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] に正の秒数を設定します。タイムアウトはストリーミングと非ストリーミングの呼び出しに適用され、トランスポートの待機時間を含む試行全体を対象とします。エージェント実行全体、関数ツールの実行、または再試行のバックオフは制限しません。 -Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` はレスポンス前の過負荷フレームと、コードのない `server_error` フレームを再試行の提案として認識します。これだけで再試行が有効になるわけではありません。引き続き `ModelRetrySettings` が必要であり、通常のリプレイ安全性チェックも適用されます。レスポンスイベントが 1 つでもすでに到着している場合、SDK はリクエストをリプレイしません。 +```python +from agents import Agent, ModelSettings + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(timeout=30.0), +) +``` + +試行が上限を超えると、SDK はその試行をキャンセルし、クリーンアップの完了を待ってから [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError] を発生させます。Runner 管理の再試行が有効な場合、SDK は `context.normalized.is_timeout` を `True` に設定して、タイムアウトによる失敗を再試行ポリシーへ渡します。たとえば、`retry_policies.network_error()` はこの分類に一致します。許可された各再試行には、試行ごとに新しいタイムアウトが適用されます。SDK は再試行前に通常の[リプレイ安全性ルール](#safety-boundaries)も適用します。 + +## Runner 管理の再試行 + +再試行はランタイム専用で、明示的な有効化が必要です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 + +Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` が、レスポンス前の過負荷フレームとコードのない `server_error` フレームを再試行の提案として認識します。これだけで再試行が有効になるわけではありません。引き続き `ModelRetrySettings` が必要で、通常のリプレイ安全性チェックも適用されます。レスポンスイベントが 1 つでもすでに到着している場合、SDK はリクエストを再実行しません。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -534,84 +548,84 @@ agent = Agent(
-| フィールド | 型 | 備考 | +| フィールド | 型 | 注記 | | --- | --- | --- | -| `max_retries` | `int | None` | 最初のリクエスト後に許可される再試行回数です。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行する場合の、デフォルトの遅延戦略です。`backoff.max_delay` は、この計算されたバックオフ遅延のみを制限します。ポリシーまたは retry-after ヒントが返す明示的な遅延は制限しません。 | -| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバックです。このフィールドはランタイム専用であり、シリアライズされません。 | +| `max_retries` | `int | None` | 最初のリクエスト後に許可される再試行回数。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | ポリシーが明示的な遅延を返さずに再試行するときの、デフォルトの遅延戦略。`backoff.max_delay` は、この計算されたバックオフ遅延のみを制限します。ポリシーから返される明示的な遅延や retry-after ヒントは制限しません。 | +| `policy` | `RetryPolicy | None` | 再試行するかどうかを決定するコールバック。このフィールドはランタイム専用で、シリアライズされません。 |
-再試行ポリシーは、次の情報を含む [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 +再試行ポリシーは、次の情報を持つ [`RetryPolicyContext`][agents.retry.RetryPolicyContext] を受け取ります。 -- 試行回数を考慮した判断を行うための `attempt` と `max_retries`。 +- 試行回数を考慮した判断を行うための `attempt` および `max_retries`。 - ストリーミングと非ストリーミングの動作を分岐するための `stream`。 -- raw データを調査するための `error`。 -- `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、`is_abort` などの `normalized` 情報。 +- raw の内容を確認するための `error`。 +- `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout`、および `is_abort` などの `normalized` 情報。 - 基盤となるモデルアダプターが再試行の指針を提供できる場合の `provider_advice`。 -- ポリシーの実行前に取得される、安定したリプレイ安全性情報としての `response_started`、`replay_safety`、および `stateful_request`。`replay_safety` は `"safe"`、`"unsafe"`、または `"unknown"` です。リクエストが `previous_response_id` または `conversation_id` を使用する場合、`stateful_request` は true です。 +- ポリシー実行前に取得される、安定したリプレイ安全性情報としての `response_started`、`replay_safety`、および `stateful_request`。`replay_safety` は `"safe"`、`"unsafe"`、または `"unknown"` です。リクエストが `previous_response_id` または `conversation_id` を使用する場合、`stateful_request` は true になります。 ポリシーは、次のいずれかを返せます。 -- 単純な再試行判断を示す `True` / `False`。 -- 遅延を上書きする場合、診断理由を付加する場合、または限定された範囲の安全でないリプレイを明示的に承認する場合の [`RetryDecision`][agents.retry.RetryDecision]。 +- 単純な再試行の判断を示す `True` / `False`。 +- 遅延をオーバーライドする、診断理由を付加する、または限定された範囲で安全でないリプレイを明示的に承認する場合の [`RetryDecision`][agents.retry.RetryDecision]。 -SDK は `retry_policies` で、すぐに使用できるヘルパーをエクスポートします。 +SDK は、`retry_policies` で既成のヘルパーを公開しています。 | ヘルパー | 動作 | | --- | --- | | `retry_policies.never()` | 常に再試行しません。 | -| `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行に関する助言に従います。 | -| `retry_policies.network_error()` | 一時的なトランスポート障害とタイムアウトに一致します。 | -| `retry_policies.http_status([...])` | 選択した HTTP ステータスコードに一致します。 | -| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合にのみ、その遅延を使用して再試行します。このヘルパーは retry-after 値を明示的なポリシー遅延として扱うため、`backoff.max_delay` はその値を制限しません。 | +| `retry_policies.provider_suggested()` | 利用可能な場合、プロバイダーの再試行に関する推奨に従います。 | +| `retry_policies.network_error()` | 一時的なトランスポート障害およびタイムアウトに一致します。 | +| `retry_policies.http_status([...])` | 選択された HTTP ステータスコードに一致します。 | +| `retry_policies.retry_after()` | retry-after ヒントが利用可能な場合にのみ、その遅延を使用して再試行します。このヘルパーは retry-after の値を明示的なポリシー遅延として扱うため、`backoff.max_delay` では制限されません。 | | `retry_policies.any(...)` | ネストされたポリシーのいずれかが再試行を選択した場合に再試行します。 | | `retry_policies.all(...)` | ネストされたすべてのポリシーが再試行を選択した場合にのみ再試行します。 | -ポリシーを組み合わせる場合、`provider_suggested()` は最も安全な最初の構成要素です。プロバイダーが拒否とリプレイ安全性の承認を区別できる場合に、それらを維持するためです。 +ポリシーを組み合わせる場合、`provider_suggested()` は最初の構成要素として最も安全です。これは、プロバイダーがそれらを区別できる場合に、プロバイダーによる拒否とリプレイ安全性の承認を維持するためです。 ##### 安全性の境界 一部の失敗は再試行されません。 -- 中止エラー。 -- 出力がすでに開始され、リプレイが安全でなくなるストリーミング実行。 -- Programmatic Tool Calling リクエストを含め、ローカルの副作用に対する独立したリプレイ拒否があるリクエスト。ただし、プロバイダーがリプレイを安全と個別に判断した場合を除きます。 +- 中断エラー。 +- リプレイが安全でなくなる形ですでに出力が開始されたストリーミング実行。 +- プロバイダーが独自にリプレイを安全とマークしていない限り、Programmatic Tool Calling リクエストを含む、ローカルでの副作用を理由とした別個のリプレイ拒否があるリクエスト。 -プロバイダーによって安全でないと判断された失敗も、デフォルトではブロックされます。独立したローカル副作用の拒否がない非ストリーミングリクエストでは、アプリケーションが `RetryDecision(retry=True, approve_unsafe_replay=True)` を返すことで、プロバイダー側のリプレイリスクを受け入れられます。この承認を与える前に、`context.response_started`、`context.replay_safety`、および `context.stateful_request` を確認し、プロバイダー側の処理を繰り返しても問題ない場合にのみ承認してください。通常の `RetryDecision(retry=True)` がリプレイ保護を回避することはありません。また、`approve_unsafe_replay=True` はストリーミングの再試行やローカルの副作用を承認できません。 +プロバイダーによって安全でないとマークされた失敗も、デフォルトではブロックされます。ローカルでの副作用を理由とした別個の拒否がない非ストリーミングリクエストでは、アプリケーションは `RetryDecision(retry=True, approve_unsafe_replay=True)` を返すことで、プロバイダー側のリプレイリスクを受け入れられます。この承認を与える前に、`context.response_started`、`context.replay_safety`、および `context.stateful_request` を確認し、プロバイダー側の処理を繰り返しても許容できる場合にのみ承認してください。通常の `RetryDecision(retry=True)` がリプレイ保護を回避することはなく、`approve_unsafe_replay=True` はストリーミングの再試行やローカルの副作用を承認できません。 -`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストは、リプレイの安全性が不明な場合に安全側に倒して失敗します。このようなリクエストでは、`network_error()` や `http_status([500])` など、プロバイダー由来ではない述語だけでは不十分です。通常は `retry_policies.provider_suggested()` を介して、プロバイダーからリプレイ安全性の承認を取得するか、前述の方法でプロバイダーが安全でないと判断した非ストリーミングの失敗を明示的に承認してください。 +`previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストは、リプレイの安全性が不明な場合、安全側に倒して失敗します。このようなリクエストでは、`network_error()` や `http_status([500])` など、プロバイダーに基づかない述語だけでは不十分です。通常は `retry_policies.provider_suggested()` を通じて、プロバイダーからリプレイ安全性の承認を含めるか、前述のとおり、プロバイダーが安全でないとマークした非ストリーミングの失敗を明示的に承認してください。 ##### Runner とエージェントのマージ動作 `retry` は、Runner レベルとエージェントレベルの `ModelSettings` の間でディープマージされます。 -- エージェントは `retry.max_retries` のみを上書きし、Runner の `policy` を継承できます。 -- エージェントは `retry.backoff` の一部のみを上書きし、Runner の他のバックオフフィールドを維持できます。 +- エージェントは `retry.max_retries` のみをオーバーライドし、Runner の `policy` を引き続き継承できます。 +- エージェントは `retry.backoff` の一部のみをオーバーライドし、Runner の同階層にある他のバックオフフィールドを維持できます。 - `policy` はランタイム専用であるため、シリアライズされた `ModelSettings` は `max_retries` と `backoff` を保持しますが、コールバック自体は省略します。 -より詳しいコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを利用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 +より詳細なコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを使用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 ## OpenAI 以外のプロバイダーのトラブルシューティング ### トレーシングクライアントのエラー 401 -トレーシングに関するエラーが発生するのは、トレースが OpenAI のサーバーにアップロードされる一方で、OpenAI API キーがないためです。これを解決するには、次の 3 つの方法があります。 +トレーシング関連のエラーが発生する場合、トレースが OpenAI サーバーへアップロードされる一方で、OpenAI API キーが設定されていないことが原因です。解決方法は 3 つあります。 -1. トレーシングを完全に無効にする: [`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. トレーシング用の OpenAI キーを設定する: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 -3. OpenAI 以外のトレースプロセッサーを使用する。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 +1. トレーシングを完全に無効にします: [`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 +2. トレーシング用の OpenAI キーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 +3. OpenAI 以外のトレースプロセッサーを使用します。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 ### Responses API のサポート -SDK はデフォルトで Responses API を使用しますが、他の多くの LLM プロバイダーはまだサポートしていません。その結果、404 エラーまたは同様の問題が発生する場合があります。解決するには、次の 2 つの方法があります。 +SDK はデフォルトで Responses API を使用しますが、依然として多くの他の LLM プロバイダーはこれをサポートしていません。その結果、404 などの問題が発生する場合があります。解決方法は 2 つあります。 -1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数を通じて `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 +1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数で `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] を使用します。コード例は[こちら](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)にあります。 ### Chat Completions の互換性オプション -Chat Completions を介してルーティングする場合、SDK は、Chat Completions では送信できない Responses 専用フィールドを通知なく削除することで互換性を維持します。これには、`previous_response_id`、`conversation_id`、Responses API の `prompt` フィールド、またはテキストのみではないツール出力などが含まれます。開発中にこのような不一致を即座に失敗させる場合は、OpenAI プロバイダーで厳格な機能検証を有効にします。 +Chat Completions を通じてルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、Responses API の `prompt` フィールド、またはテキストのみではないツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に破棄して互換性を維持します。開発中にこのような不一致を即座に失敗させるには、OpenAI プロバイダーで厳格な機能検証を有効にします。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -631,9 +645,9 @@ result = await Runner.run( [`MultiProvider`][agents.MultiProvider] を使用する場合は、代わりに `openai_strict_feature_validation=True` を渡します。 -OpenAI Chat Completions API は音声出力を返せますが、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は現在、音声出力を Agents SDK の実行項目へ変換しません。非ストリーミングメッセージまたはストリーミングの差分に音声出力が含まれる場合、アダプターは部分的または空の実行結果を返す代わりに `AgentsException("Audio is not currently supported")` を発生させます。SDK が管理する音声ワークフローには、[Realtime エージェント](../realtime/guide.md)または[音声エージェント](../voice/quickstart.md)を使用してください。 +OpenAI Chat Completions API は音声出力を返せますが、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は現在、音声出力を Agents SDK の実行項目へ変換しません。非ストリーミングメッセージまたはストリーミングの差分に音声出力が含まれる場合、アダプターは不完全または空の実行結果を返す代わりに、`AgentsException("Audio is not currently supported")` を発生させます。SDK が管理する音声ワークフローには、[Realtime エージェント](../realtime/guide.md)または[音声エージェント](../voice/quickstart.md)を使用してください。 -一部の OpenAI 互換 Chat Completions プロバイダーは、SDK がインクリメンタルに処理するには信頼性が不十分なチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされるツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが終了した後にのみ SDK がツール呼び出しを生成するようにします。 +一部の OpenAI 互換 Chat Completions プロバイダーは、SDK が増分処理するには十分な信頼性がないチャンクで、ツール呼び出しの差分をストリーミングします。その場合は、ストリーミングされるツール呼び出しのバッファリングを有効にし、プロバイダーのストリームが終了した後にのみ SDK がツール呼び出しを出力するようにします。 ```python from agents import OpenAIProvider @@ -648,7 +662,7 @@ provider = OpenAIProvider( ### structured outputs のサポート -一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。その場合、次のようなエラーが発生することがあります。 +一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。その結果、次のようなエラーが発生することがあります。 ``` @@ -656,42 +670,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていても、出力に使用する `json_schema` を指定できません。現在、この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを使用することを推奨します。そうしないと、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 +これは一部のモデルプロバイダーの制約です。JSON 出力には対応していますが、出力に使用する `json_schema` は指定できません。この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを使用することを推奨します。そうしない場合、不正な形式の JSON が原因でアプリが頻繁に動作しなくなる可能性があります。 -## 複数プロバイダー間でのモデルの組み合わせ +## プロバイダー間でのモデルの組み合わせ -モデルプロバイダー間の機能差を把握しておく必要があります。把握していないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホスト型のファイル検索および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限事項に注意してください。 +モデルプロバイダー間の機能差を把握しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホステッドファイル検索、および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 -- 理解できないプロバイダーに、サポートされていない `tools` を送信しないでください -- テキストのみを扱うモデルを呼び出す前に、マルチモーダル入力を除外してください -- 構造化された JSON 出力をサポートしないプロバイダーでは、不正な JSON が生成される場合があることに注意してください。 +- サポートされていない `tools` を、それを解釈できないプロバイダーへ送信しないでください +- テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください +- 構造化 JSON 出力をサポートしないプロバイダーでは、無効な JSON が生成されることがある点に注意してください。 ## サードパーティ製アダプター -SDK の組み込みプロバイダー統合ポイントでは不十分な場合にのみ、サードパーティ製アダプターを使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] のパスを優先してください。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる必要がある場合や、アダプターだけが提供するプロバイダー対応範囲またはルーティングが必要な場合に使用します。アダプターにより SDK と上流のモデルプロバイダーの間に互換性レイヤーが追加されるため、機能のサポートとリクエストのセマンティクスはプロバイダーによって異なる可能性があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 +サードパーティ製アダプターは、SDK に組み込まれたプロバイダー統合ポイントだけでは不十分な場合にのみ使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] のパスを優先してください。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる必要がある場合や、アダプターのみが提供するプロバイダー対応範囲またはルーティングが必要な場合のためのものです。アダプターは SDK と上流のモデルプロバイダーの間に別の互換性レイヤーを追加するため、機能のサポート状況とリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 ### Any-LLM -Any-LLM のサポートは、Any-LLM が管理するプロバイダー対応範囲またはルーティングが必要な場合向けに、ベストエフォートのベータ版として提供されています。 +Any-LLM のサポートは、Any-LLM が管理するプロバイダー対応範囲またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 上流のプロバイダーパスに応じて、Any-LLM は Responses API、Chat Completions 互換 API、またはプロバイダー固有の互換性レイヤーを使用する場合があります。 Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストールし、[`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) または [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) から始めてください。[`MultiProvider`][agents.MultiProvider] で `any-llm/...` のモデル名を使用するか、`AnyLLMModel` を直接インスタンス化するか、実行スコープで `AnyLLMProvider` を使用できます。モデルサーフェスを明示的に固定する必要がある場合は、`AnyLLMModel` の構築時に `api="responses"` または `api="chat_completions"` を渡します。 -Any-LLM は引き続きサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能差は SDK ではなく、上流の Any-LLM によって定義されます。上流のプロバイダーが使用量メトリクスを返す場合、それらは自動的に伝播されます。ただし、ストリーミングされる Chat Completions バックエンドでは、使用量チャンクを生成する前に `ModelSettings(include_usage=True)` が必要な場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 +Any-LLM はサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能上の不足は SDK ではなく、上流の Any-LLM によって定義されます。使用量メトリクスは上流のプロバイダーが返す場合に自動的に伝播されますが、ストリーミング Chat Completions のバックエンドでは、使用量のチャンクを出力する前に `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 ### LiteLLM -LiteLLM のサポートは、LiteLLM 固有のプロバイダー対応範囲またはルーティングが必要な場合向けに、ベストエフォートのベータ版として提供されています。 +LiteLLM のサポートは、LiteLLM 固有のプロバイダー対応範囲またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 LiteLLM が必要な場合は、`openai-agents[litellm]` をインストールし、[`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) または [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) から始めてください。`litellm/...` のモデル名を使用するか、[`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel] を直接インスタンス化できます。 -LiteLLM アダプターを通じて利用する一部のプロバイダーでは、デフォルトで SDK の使用量メトリクスが設定されません。使用量レポートが必要な場合は `ModelSettings(include_usage=True)` を渡し、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定のプロバイダーバックエンドを正確に検証してください。 +LiteLLM アダプターを通じてアクセスする一部のプロバイダーは、デフォルトでは SDK の使用量メトリクスを設定しません。使用量レポートが必要な場合は、`ModelSettings(include_usage=True)` を渡してください。また、structured outputs、ツール呼び出し、使用量レポート、またはアダプター固有のルーティング動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 -LiteLLM がレスポンスオブジェクトに対する Pydantic シリアライザーの警告を出力する場合は、LiteLLM アダプターをインポートする前に、SDK の互換性パッチを有効にできます。 +LiteLLM がレスポンスオブジェクトについて Pydantic シリアライザーの警告を出す場合、LiteLLM アダプターをインポートする前に、SDK の互換性パッチを有効にできます。 ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -このパッチはデフォルトでは無効であり、`1` または `true` の値でのみ有効になります。LiteLLM の非公開ログヘルパーをラップすることで、LiteLLM のレスポンスシリアライズに関する特定の種類の警告を抑制します。そのため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。LiteLLM の非公開 API に依存しているため、LiteLLM をアップグレードするときに再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file +このパッチはデフォルトでは無効で、`1` または `true` の値でのみ有効になります。プライベートな LiteLLM のログ記録ヘルパーをラップすることで、特定の種類の LiteLLM レスポンスシリアライズ警告を抑制するため、一般的なシリアライズ設定ではなく、対象を限定した回避策として扱ってください。プライベートな LiteLLM API に依存しているため、LiteLLM のアップグレード時には再度検証し、上流で警告が発生しなくなったら環境変数を削除してください。 \ No newline at end of file diff --git a/docs/ja/realtime/guide.md b/docs/ja/realtime/guide.md index bd960660bd..5c2526e450 100644 --- a/docs/ja/realtime/guide.md +++ b/docs/ja/realtime/guide.md @@ -4,17 +4,17 @@ search: --- # リアルタイムエージェントガイド -このガイドでは、OpenAI Agents SDK のリアルタイムレイヤーが OpenAI Realtime API にどのように対応するか、および Python SDK が追加する動作について説明します。 +このガイドでは、OpenAI Agents SDK のリアルタイムレイヤーが OpenAI Realtime API にどのように対応しているか、および Python SDK がその上にどのような追加動作を提供するかを説明します。 !!! note "はじめに" - デフォルトの Python 利用手順については、まず[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用すべきか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれていません。 + デフォルトの Python パスを使用する場合は、まず[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用すべきか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれていません。 ## 概要 -リアルタイムエージェントは Realtime API への長時間接続を維持するため、モデルはテキストと音声を逐次処理し、音声出力をストリーミングし、ツールを呼び出し、ターンごとに新しいリクエストを開始し直すことなく中断を処理できます。 +リアルタイムエージェントは Realtime API への長時間接続を維持するため、モデルは各ターンで新しいリクエストを開始し直すことなく、テキストとオーディオの段階的な処理、オーディオ出力のストリーミング、ツールの呼び出し、中断への対応を行えます。 -SDK の主なコンポーネントは次のとおりです。 +SDK の主要コンポーネントは次のとおりです。 - **RealtimeAgent**: 1 つのリアルタイム専門エージェントに対する指示、ツール、出力ガードレール、ハンドオフ - **RealtimeRunner**: 開始エージェントをリアルタイムトランスポートに接続するセッションファクトリー @@ -23,29 +23,31 @@ SDK の主なコンポーネントは次のとおりです。 ## セッションのライフサイクル -一般的なリアルタイムセッションの流れは次のとおりです。 +一般的なリアルタイムセッションは次のようになります。 1. 1 つ以上の `RealtimeAgent` を作成します。 2. 開始エージェントを指定して `RealtimeRunner` を作成します。 -3. `await runner.run()` を呼び出し、`RealtimeSession` を取得します。 +3. `await runner.run()` を呼び出して `RealtimeSession` を取得します。 4. `async with session:` または `await session.enter()` を使用してセッションに入ります。 5. `send_message()` または `send_audio()` を使用してユーザー入力を送信します。 6. 会話が終了するまでセッションイベントを反復処理します。 テキストのみの実行とは異なり、`runner.run()` は最終的な実行結果をすぐには生成しません。代わりに、ローカル履歴、バックグラウンドでのツール実行、ガードレールの状態、アクティブなエージェント設定をトランスポートレイヤーと同期し続けるライブセッションオブジェクトを返します。 -デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、デフォルトの Python 利用手順では Realtime API へのサーバー側 WebSocket 接続が使用されます。別の `RealtimeModel` を渡した場合も、接続メカニズムは変更できますが、同じセッションライフサイクルとエージェント機能が適用されます。 +デフォルトでは、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用するため、デフォルトの Python パスは Realtime API へのサーバー側 WebSocket 接続です。別の `RealtimeModel` を渡した場合も、接続の仕組みは変更できますが、同じセッションライフサイクルとエージェント機能が適用されます。 + +Realtime API サーバーがデフォルトの WebSocket 接続を正常に閉じると、モデルトランスポートは `disconnected` の [`RealtimeModelConnectionStatusEvent`][agents.realtime.model_events.RealtimeModelConnectionStatusEvent] を生成し、続いて [`RealtimeModelEndOfStreamEvent`][agents.realtime.model_events.RealtimeModelEndOfStreamEvent] を生成します。`RealtimeSession` は両方を `raw_model_event` 内で転送し、すでにキューに入っているイベントを処理した後、例外を発生させずに非同期反復を終了します。呼び出し元が開始した `session.close()` では、これらのサーバー切断イベントは合成されません。予期しない WebSocket 障害は、通常のサーバー切断として反復を終了するのではなく、引き続きセッションの例外処理パスを通ります。 ## エージェントとセッションの設定 -`RealtimeAgent` は、通常の `Agent` 型よりも意図的に対象範囲が限定されています。 +`RealtimeAgent` は、通常の `Agent` 型よりも意図的に対象範囲が狭くなっています。 -- モデルはエージェントごとではなく、セッションレベルで選択します。 -- structured outputs はサポートされていません。 -- 音声は設定できますが、セッションが音声を一度生成した後は変更できません。 -- 指示、関数ツール、ハンドオフ、フック、出力ガードレールはすべて引き続き使用できます。 +- モデルの選択はエージェント単位ではなく、セッションレベルで設定します。 +- structured outputs には対応していません。 +- 音声は設定できますが、セッションが音声オーディオを生成した後は変更できません。 +- 指示、関数ツール、ハンドオフ、フック、出力ガードレールはすべて引き続き機能します。 -`RealtimeSessionModelSettings` は、新しいネストされた `audio` 設定と、従来のフラットなエイリアスの両方をサポートします。新しいコードではネスト形式を推奨します。また、新しいリアルタイムエージェントには `gpt-realtime-2.1` を使用してください。 +`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と、従来のフラット形式のエイリアスの両方に対応しています。新しいコードではネスト形式を優先し、新しいリアルタイムエージェントでは `gpt-realtime-2.1` から始めてください。 ```python runner = RealtimeRunner( @@ -79,7 +81,7 @@ runner = RealtimeRunner( - `prompt` - `tracing` -`RealtimeRunner(config=...)` の便利な実行レベル設定には、次のものがあります。 +`RealtimeRunner(config=...)` で利用できる便利な実行レベルの設定には、次のものがあります。 - `async_tool_calls` - `output_guardrails` @@ -87,11 +89,11 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -型付きインターフェースの全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 +型付き API の全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 ### 入力文字起こし設定 -入力文字起こしは `audio.input.transcription` で設定します。低レイテンシーの逐次文字起こしには `gpt-live-transcribe` を使用します。音声ターンの確定後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合は、WebSocket 経由で `gpt-transcribe` を使用します。Agents SDK は、モデル固有の GA 文字起こし設定をネストされたセッション設定で転送します。 +入力の文字起こしは `audio.input.transcription` で設定します。低レイテンシーの段階的な文字起こしには `gpt-live-transcribe` を使用します。オーディオターンのコミット後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合は、WebSocket 経由で `gpt-transcribe` を使用します。Agents SDK は、モデル固有の GA 文字起こし設定をネストされたセッション設定で転送します。 ```python runner = RealtimeRunner( @@ -114,9 +116,9 @@ runner = RealtimeRunner( ) ``` -`gpt-live-transcribe` では、`prompt` に自由形式の録音コンテキストを指定し、`keywords` に音声内に出現する可能性がある用語をリテラルで列挙し、`languages` に想定される入力言語を列挙します。このモデルでは、単数形の `language` ではなく複数形の `languages` を使用します。両方のフィールドを送信しないでください。 +`gpt-live-transcribe` では、`prompt` に自由形式の録音コンテキスト、`keywords` にオーディオ内に含まれる可能性があるリテラル用語、`languages` に想定される入力言語を指定します。このモデルでは、単数形の `language` ではなく複数形の `languages` を使用します。両方のフィールドを送信しないでください。 -この SDK が固定しているバージョンの OpenAI クライアントでは、`delay` は `gpt-realtime-whisper` との組み合わせでのみサポートされます。このモデルのレイテンシーと精度のトレードオフは、次のように設定します。 +この SDK で固定されている OpenAI クライアントのバージョンは、`delay` を `gpt-realtime-whisper` と組み合わせた場合にのみ対応しています。そのモデルのレイテンシーと精度のトレードオフは、次のように設定します。 ```python runner = RealtimeRunner( @@ -137,15 +139,15 @@ runner = RealtimeRunner( ) ``` -`delay` 設定には、`minimal`、`low`、`medium`、`high`、または `xhigh` を指定できます。値が低いほど部分テキストが早く生成される可能性があり、値が高いほど文字起こしモデルに多くの音声コンテキストが提供され、認識精度が向上する可能性があります。各レベルの処理時間が一定であると想定せず、実際のユースケースを代表する音声でベンチマークしてください。 +`delay` 設定には、`minimal`、`low`、`medium`、`high`、`xhigh` のいずれかを指定できます。値を小さくすると部分的なテキストが早く生成される可能性があり、値を大きくすると文字起こしモデルに与えられるオーディオコンテキストが増え、認識精度が向上する可能性があります。各レベルのタイミングが固定されていると想定せず、代表的なオーディオを使用してベンチマークしてください。 -WebSocket 経由の Realtime セッションで `gpt-transcribe` を使用するのは、確定済みの音声ターンの後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合に限ります。モデルは、以前に文字起こしされたターンをコンテキストとして自動的に使用します。`gpt-transcribe` 完了イベントは、`languages` 出力フィールドで検出言語を報告します。この出力フィールドは、上記の想定言語入力である `gpt-live-transcribe` とは異なります。 +WebSocket 経由の Realtime セッションで `gpt-transcribe` を使用するのは、コミットされたオーディオターンの後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合に限ります。モデルは、以前に文字起こしされたターンをコンテキストとして自動的に使用します。`gpt-transcribe` 完了イベントは、検出された言語を `languages` 出力フィールドで報告します。この出力フィールドは、上記の想定言語を指定する入力フィールド `gpt-live-transcribe` とは異なります。 -`audio.input.turn_detection` を `None` に設定すると、自動ターン検出が無効になります。その場合、アプリケーションは音声ターンを確定し、[手動レスポンス制御](#manual-response-control)の説明に従ってレスポンスの作成を制御する必要があります。モデルの動作、検証ルール、レイテンシーに関するガイダンスについては、OpenAI API の[リアルタイム文字起こしガイド](https://developers.openai.com/api/docs/guides/realtime-transcription)を参照してください。 +`audio.input.turn_detection` を `None` に設定すると、自動ターン検出が無効になります。その場合、アプリケーションは[手動レスポンス制御](#manual-response-control)の説明に従って、オーディオターンをコミットし、レスポンスの作成を制御する必要があります。モデルの動作、検証ルール、レイテンシーのガイダンスについては、OpenAI API の [Realtime 文字起こしガイド](https://developers.openai.com/api/docs/guides/realtime-transcription)を参照してください。 ## 入出力 -### テキストと構造化ユーザーメッセージ +### テキストと構造化されたユーザーメッセージ プレーンテキストまたは構造化されたリアルタイムメッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] を使用します。 @@ -165,7 +167,7 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -構造化メッセージは、リアルタイム会話に画像入力を含めるための主な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) の Web デモ例では、この方法で `input_image` メッセージを転送します。 +構造化メッセージは、リアルタイム会話に画像入力を含めるための主要な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) のサンプル Web デモでは、`input_image` メッセージをこの方法で転送します。 ### オーディオ入力 @@ -175,21 +177,21 @@ raw オーディオバイトをストリーミングするには、[`session.sen await session.send_audio(audio_bytes) ``` -サーバー側のターン検出が無効になっている場合は、ターンの境界を指定する必要があります。高レベルの便利な方法は次のとおりです。 +サーバー側のターン検出が無効な場合は、ターンの境界を指定する必要があります。高レベルの便利な方法は次のとおりです。 ```python await session.send_audio(audio_bytes, commit=True) ``` -より低レベルの制御が必要な場合は、基盤となるモデルトランスポートを介して `input_audio_buffer.commit` などの Realtime API クライアントイベントを直接送信することもできます。 +より低レベルの制御が必要な場合は、`input_audio_buffer.commit` などの Realtime API クライアントイベントを、基盤となるモデルトランスポート経由で直接送信することもできます。 ### 手動レスポンス制御 -`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを開始します。一部の設定では、raw オーディオのバッファリングによって同じ処理が自動的に行われるとは**限りません**。 +`session.send_message()` は高レベルのパスを使用してユーザー入力を送信し、レスポンスを開始します。一部の設定では、raw オーディオのバッファリングだけでは同じ処理が **自動的には** 行われません。 -Realtime API レベルでの手動ターン制御では、`turn_detection` を `null` に設定する `session.update` イベントを送信してから、`input_audio_buffer.commit` と `response.create` を自身で送信します。 +Realtime API レベルでの手動ターン制御では、`turn_detection` を `null` に設定する `session.update` イベントを送信した後、`input_audio_buffer.commit` と `response.create` を自分で送信します。 -ターンを手動で管理する場合は、モデルトランスポートを介して raw クライアントイベントを送信できます。 +ターンを手動で管理する場合は、モデルトランスポート経由で raw クライアントイベントを送信できます。 ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -205,15 +207,15 @@ await session.model.send_event( このパターンは、次の場合に役立ちます。 -- `turn_detection` が無効で、モデルが応答するタイミングを決定したい場合 -- レスポンスを開始する前にユーザー入力を検査または制限したい場合 -- 帯域外レスポンスにカスタムプロンプトが必要な場合 +- `turn_detection` が無効で、モデルが応答するタイミングを決めたい場合 +- レスポンスを開始する前にユーザー入力を検査または制御したい場合 +- 帯域外レスポンス用のカスタムプロンプトが必要な場合 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) の SIP コード例では、raw `response.create` を使用して最初の挨拶を強制しています。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) の SIP コード例では、raw の `response.create` を使用して最初の挨拶を強制的に生成します。 ## イベント、履歴、中断 -`RealtimeSession` は高レベルの SDK イベントを発行しつつ、必要に応じて raw モデルイベントも転送します。 +`RealtimeSession` は高レベルの SDK イベントを生成すると同時に、必要に応じて raw モデルイベントも転送します。 重要なセッションイベントには、次のものがあります。 @@ -227,13 +229,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI の状態に最も役立つイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しなど、セッションのローカル履歴を `RealtimeItem` オブジェクトとして公開します。 +UI の状態管理に最も役立つイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しを含むセッションのローカル履歴を `RealtimeItem` オブジェクトとして公開します。 ### 使用量の集計 -完了したモデルレスポンスに使用量が含まれる場合、SDK の OpenAI `RealtimeModel` トランスポートは、`raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] を発行します。その `usage` フィールドには、そのレスポンスのトークン数が含まれます。また、`input_tokens_details` と `output_tokens_details` には、モダリティ別の内訳が任意で含まれます。 +完了したモデルレスポンスに使用量が含まれる場合、SDK の OpenAI `RealtimeModel` トランスポートは、`raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] を生成します。その `usage` フィールドにはそのレスポンスのトークン数が含まれ、`input_tokens_details` と `output_tokens_details` には任意のモダリティ別内訳が含まれます。 -セッションは各レスポンスの使用量を、共有される [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] にも追加します。ライブセッションの累積使用量を確認するには、`agent_end` など、その後の高レベルイベントの `event.info.context.usage` から読み取ります。 +また、セッションは各レスポンスの使用量を共有の [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage] に加算します。ライブセッションの累積使用量を確認するには、`agent_end` など、その後に発生する高レベルイベントの `event.info.context.usage` から読み取ります。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -251,13 +253,13 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めた場合にのみ報告されます。累積値の対象は、その `RealtimeSession` が受信したレスポンスです。複数のセッションを横断した合計ではありません。 +使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めた場合にのみ報告されます。累積値は、その `RealtimeSession` が受信したレスポンスを対象とし、複数のセッションをまたぐ合計値ではありません。 ### 中断と再生トラッキング -ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を発行し、ユーザーが実際に聞いた内容とサーバー側の会話が一致するように履歴を更新します。 +ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を生成し、ユーザーが実際に聞いた内容とサーバー側の会話が一致するように履歴を更新します。 -低レイテンシーのローカル再生では、通常はデフォルトの再生トラッカーで十分です。リモート再生や遅延再生、特に電話通信では、生成された音声がすべてすでに聞かれたと見なすのではなく、実際の再生位置で中断されたレスポンスを切り詰めるために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] を使用します。 +低レイテンシーのローカル再生では、多くの場合、デフォルトの再生トラッカーで十分です。リモート再生や遅延再生、特にテレフォニーでは、生成されたすべてのオーディオがすでに再生されたと想定するのではなく、実際の再生位置で中断されたレスポンスを切り詰めるために、[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker] を使用します。 [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) の Twilio コード例で、このパターンを確認できます。 @@ -265,7 +267,7 @@ async for event in session: ### 関数ツール -リアルタイムエージェントは、ライブ会話中の関数ツールをサポートします。 +リアルタイムエージェントは、ライブ会話中の関数ツールに対応しています。 ```python from agents.decorators import tool @@ -286,9 +288,9 @@ agent = RealtimeAgent( ### ツール承認 -関数ツールでは、実行前に人間による承認を必須にできます。この場合、セッションは `tool_approval_required` を発行し、`approve_tool_call()` または `reject_tool_call()` を呼び出すまでツールの実行を一時停止します。 +関数ツールでは、実行前に人間による承認を必須にできます。その場合、セッションは `tool_approval_required` を生成し、`approve_tool_call()` または `reject_tool_call()` を呼び出すまでツールの実行を一時停止します。 -ツールに入力ガードレールもある場合、承認後の実行直前にそれらのガードレールが実行されます。承認イベントが発行される前に実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を指定してランナーを作成します。この承認前チェックを通過した呼び出しも、承認後の実行前に再度チェックされます。 +ツールに入力ガードレールも設定されている場合、承認後、実行の直前にそのガードレールが実行されます。承認イベントが生成される前に入力ガードレールを実行するには、`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` を指定してランナーを作成します。この承認前チェックを通過した呼び出しも、実行前に承認後のチェックが再度行われます。 ```python async for event in session: @@ -296,11 +298,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) を参照してください。Human-in-the-loop のドキュメントでも、[Human in the loop](../human_in_the_loop.md) でこのフローを参照しています。 +具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) を参照してください。Human-in-the-loop のドキュメントでも、[Human in the loop](../human_in_the_loop.md)でこのフローを参照しています。 ### ハンドオフ -リアルタイムハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を引き継げます。 +リアルタイムハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を転送できます。 ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -322,11 +324,11 @@ main_agent = RealtimeAgent( ) ``` -ハンドオフとして直接使用される `RealtimeAgent` オブジェクトは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。リアルタイムハンドオフでは、通常のハンドオフの `input_filter` はサポートされていません。 +ハンドオフとして直接使用される `RealtimeAgent` オブジェクトは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、可用性をカスタマイズできます。リアルタイムハンドオフは、通常のハンドオフの `input_filter` には対応していません。 ### ガードレール -リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートします。出力ガードレールのチェックはデバウンスされます。各チェックは、部分的な差分ごとではなく、蓄積された出力テキストと音声文字起こしの差分に対して実行され、例外を発生させる代わりに `guardrail_tripped` を発行します。 +リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールに対応しています。出力ガードレールのチェックにはデバウンスが適用されます。各チェックは、部分的な差分ごとではなく、蓄積された出力テキストとオーディオ文字起こしの差分に対して実行され、例外を発生させる代わりに `guardrail_tripped` を生成します。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -346,15 +348,15 @@ agent = RealtimeAgent( ) ``` -音声文字起こしに対してリアルタイム出力ガードレールが作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制し、`guardrail_tripped` を発行します。さらに、作動したガードレールの名前を含むフォローアップのユーザーメッセージを送信し、モデルが代替レスポンスを生成できるようにします。トリップワイヤーが作動した時点で音声の一部がすでにバッファリングされている可能性があるため、音声プレイヤーでは引き続き `audio_interrupted` を監視し、ローカル再生を直ちに停止する必要があります。組み込みの OpenAI Realtime トランスポートでは、チェック対象のレスポンスが終了した後にガードレールチェックが完了した場合、セッションはそのレスポンスのバッファリング済み再生だけを中断し、後から開始されたレスポンスはキャンセルしません。テキストのみの出力では、代わりにレスポンス単位の `response.cancel` を送信します。停止すべき音声再生がないため、`audio_interrupted` は発行されません。組み込みの OpenAI Realtime モデルを使用する場合、テキストのみの経路でも同じ `guardrail_tripped` イベントとフォローアップのユーザーメッセージが発行されます。 +リアルタイム出力ガードレールがオーディオ文字起こしで作動すると、セッションはアクティブなレスポンスを中断し、`response.cancel` を強制的に実行し、`guardrail_tripped` を生成します。さらに、作動したガードレールの名前を示すフォローアップのユーザーメッセージを送信し、モデルが代替レスポンスを生成できるようにします。トリップワイヤーが作動した時点ですでに一部のオーディオがバッファリングされている可能性があるため、オーディオプレーヤーでは引き続き `audio_interrupted` を監視し、ローカル再生を直ちに停止する必要があります。組み込みの OpenAI Realtime トランスポートでは、チェック対象のレスポンスが終了した後にガードレールのチェックが完了した場合、セッションはそのレスポンスのバッファリング済み再生だけを中断し、後から開始されたレスポンスはキャンセルしません。テキストのみの出力では、代わりにレスポンススコープの `response.cancel` を送信します。停止すべきオーディオ再生がないため、`audio_interrupted` は生成しません。組み込みの OpenAI Realtime モデルを使用する場合、テキストのみのパスでも同じ `guardrail_tripped` イベントとフォローアップのユーザーメッセージが生成されます。 -カスタム `RealtimeModel` トランスポートでは、同じ発生元レスポンス単位の音声中断動作を実現するため、`RealtimeModelSendInterrupt.response_id` と `playback_only` に従う必要があります。また、テキストのみの出力経路で復旧メッセージをサポートするには、`RealtimeModel.send_event_if()` をオーバーライドする必要があります。実装では、トランスポートで実際にイベントを確定する境界において、指定された条件を再チェックするか、条件チェックとイベントの確定をまとめて直列化する必要があります。デフォルト実装は復旧メッセージを安全にスキップします。条件を一度チェックしてからイベントを別途送信すると、そのチェックからイベントの確定までの間に別のレスポンスが開始される可能性があるためです。レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 +カスタムの `RealtimeModel` トランスポートでは、同じ発生元スコープのオーディオ中断動作を提供するために、`RealtimeModelSendInterrupt.response_id` と `playback_only` を遵守する必要があります。また、テキストのみの出力パスで復旧メッセージに対応するには、`RealtimeModel.send_event_if()` をオーバーライドする必要があります。実装では、トランスポートが実際にイベントをコミットする境界で指定された条件を再確認するか、条件チェックとイベントのコミットを直列化する必要があります。デフォルト実装は復旧メッセージを安全にスキップします。条件を一度確認してからイベントを別途送信すると、その確認とイベントのコミットの間に別のレスポンスが開始される可能性があるためです。レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 -## SIP と電話通信 +## SIP とテレフォニー -Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] を介した正式サポートの SIP アタッチフローが含まれています。 +Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] を介した第一級の SIP 接続フローが含まれています。 -Realtime Calls API 経由で着信した通話に対し、生成された `call_id` にエージェントセッションをアタッチする場合に使用します。 +Realtime Calls API 経由で着信し、生成された `call_id` にエージェントセッションを接続する場合に使用します。 ```python from agents.realtime import RealtimeRunner @@ -371,20 +373,20 @@ async with await runner.run( ... ``` -最初に通話を受け入れる必要があり、受け入れペイロードをエージェントから生成されたセッション設定と一致させたい場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) にあります。 +先に通話を受け入れる必要があり、その受け入れペイロードをエージェントから導出されたセッション設定と一致させたい場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) で確認できます。 ## 低レベルアクセスとカスタムエンドポイント -基盤となるトランスポートオブジェクトには、`session.model` を介してアクセスできます。 +`session.model` を介して、基盤となるトランスポートオブジェクトにアクセスできます。 -次のものが必要な場合に使用します。 +これは、次のものが必要な場合に使用します。 -- `session.model.add_listener(...)` を使用したカスタムリスナー +- `session.model.add_listener(...)` を介したカスタムリスナー - `response.create` や `session.update` などの raw クライアントイベント -- `model_config` を介したカスタムの `url`、`headers`、または `api_key` の処理 -- 既存のリアルタイム通話への `call_id` によるアタッチ +- `model_config` を介したカスタムの `url`、`headers`、`api_key` 処理 +- 既存のリアルタイム通話への `call_id` 接続 -`RealtimeModelConfig` は次をサポートします。 +`RealtimeModelConfig` は、次のものに対応しています。 - `api_key` - `url` @@ -406,7 +408,7 @@ session = await runner.run( ) ``` -トークンベース認証では、`headers` に Bearer トークンを使用します。 +トークンベースの認証では、`headers` に Bearer トークンを使用します。 ```python session = await runner.run( @@ -417,7 +419,7 @@ session = await runner.run( ) ``` -`headers` を渡した場合、SDK は `Authorization` を自動的に追加しません。リアルタイムエージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を使用しないでください。 +`headers` を渡した場合、SDK は `Authorization` を自動的には追加しません。リアルタイムエージェントでは、従来のベータパス(`/openai/realtime?api-version=...`)を使用しないでください。 ## 関連資料 diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index cda0cc7feb..9073927355 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -4,11 +4,11 @@ search: --- # エージェントの実行 -エージェントは [`Runner`][agents.run.Runner] クラスを介して実行できます。次の 3 つの方法があります。 +[`Runner`][agents.run.Runner] クラスを使用してエージェントを実行できます。次の 3 つの方法があります。 -1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行され、[`RunResult`][agents.result.RunResult] を返します。 +1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行し、[`RunResult`][agents.result.RunResult] を返します。 2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドであり、内部では単に `.run()` を実行します。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行され、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、イベントを受信するたびにストリーミングします。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントを順次ストリーミングします。 ```python from agents import Agent, Runner @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -詳細については、[実行結果ガイド](results.md)をご覧ください。 +詳しくは、[実行結果ガイド](results.md)をご覧ください。 ## Runner のライフサイクルと設定 ### エージェントループ -上記 3 つの `Runner` メソッドのいずれかを呼び出すときは、開始エージェントと入力を渡します。入力には次のものを指定できます。 +上記 3 つの `Runner` メソッドのいずれかを呼び出すときは、開始エージェントと入力を渡します。入力には次のものを使用できます。 - 文字列(ユーザーメッセージとして扱われます) - OpenAI Responses API 形式の入力項目のリスト -- 一時停止した実行、または `cancel(mode="after_turn")` で停止した実行を再開する場合は、[`RunState`][agents.run_state.RunState]。状態には、[次回のモデル呼び出し再開時に使用するために準備された入力](results.md#add-input-before-resuming)を含めることもできます。 +- 一時停止した実行、または `cancel(mode="after_turn")` により停止した実行を再開する場合の [`RunState`][agents.run_state.RunState]。状態には、[次回の再開後のモデル呼び出し用に準備された入力](results.md#add-input-before-resuming)も保持できます。 -その後、Runner はループを実行します。 +Runner は次のループを実行します。 -1. 現在の入力を使用して、現在のエージェント向けに LLM を呼び出します。 +1. 現在のエージェントと現在の入力を使用して LLM を呼び出します。 2. LLM が出力を生成します。 1. Runner が LLM の出力を最終出力と判定した場合、ループを終了して実行結果を返します。 2. LLM がハンドオフを要求した場合、現在のエージェントと入力を更新し、ループを再実行します。 - 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行し、実行結果を追加して、ループを再実行します。 -3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 + 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行して実行結果を追加し、ループを再実行します。 +3. 渡された `max_turns` を超えると、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note - LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが存在しないことです。 + LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが含まれていないことです。 ### ストリーミング -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] に、新たに生成されたすべての出力を含む実行の完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)をご覧ください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] に、新たに生成されたすべての出力を含む実行の完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳しくは、[ストリーミングガイド](streaming.md)をご覧ください。 #### Responses WebSocket トランスポート(オプションのヘルパー) -OpenAI Responses の WebSocket トランスポートを有効にしても、通常の `Runner` API を引き続き使用できます。接続を再利用する場合は WebSocket セッションヘルパーを推奨しますが、必須ではありません。 +OpenAI Responses の WebSocket トランスポートを有効にした場合でも、通常の `Runner` API を引き続き使用できます。接続を再利用する場合は WebSocket セッションヘルパーを推奨しますが、必須ではありません。 これは WebSocket トランスポート経由の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 -トランスポートの選択規則と、具体的なモデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 +トランスポートの選択規則、および具体的なモデルオブジェクトやカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 -##### パターン 1:セッションヘルパーなし(動作可能) +##### パターン 1:セッションヘルパーなし(利用可能) -WebSocket トランスポートのみを使用し、SDK に共有プロバイダーやセッションを管理させる必要がない場合に使用します。 +WebSocket トランスポートのみが必要で、SDK に共有プロバイダーやセッションを管理させる必要がない場合に使用します。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -このパターンは単発の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続されることがあります。 +このパターンは単発の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 -##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) +##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用を推奨) -共有の WebSocket 対応プロバイダーと `RunConfig` を複数の実行で使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 +複数の実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 ```python import asyncio @@ -119,59 +119,59 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果を最後まで消費してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる場合があります。 +コンテキストを終了する前に、ストリーミングされた実行結果の消費を完了してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 -サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約を取り除くものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。復元動作の詳細については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)をご覧ください。 +サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約は解消されません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。完全な復元動作については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)をご覧ください。 -長時間の推論ターンで WebSocket のキープアライブがタイムアウトする場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートのタイムアウトを無効にしてください。WebSocket の低レイテンシーより信頼性を重視する実行では、HTTP/SSE トランスポートを使用してください。 +長時間の推論ターンで WebSocket のキープアライブタイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。WebSocket のレイテンシーよりも信頼性が重要な実行には、HTTP/SSE トランスポートを使用してください。 ### 実行設定 `run_config` パラメーターを使用すると、エージェントの実行に関する一部のグローバル設定を構成できます。 -#### 一般的な実行設定のカテゴリー +#### 一般的な実行設定カテゴリー -各エージェント定義を変更せずに単一の実行の動作を上書きするには、`RunConfig` を使用します。 +各エージェントの定義を変更せずに、単一の実行について動作を上書きするには、`RunConfig` を使用します。 -##### モデル、プロバイダー、セッションのデフォルト +##### モデル、プロバイダー、セッションのデフォルト設定 -- [`model`][agents.run.RunConfig.model]:各 Agent が持つ `model` にかかわらず、使用するグローバル LLM モデルを設定できます。 -- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 +- [`model`][agents.run.RunConfig.model]:各 Agent が持つ `model` に関係なく、使用するグローバル LLM モデルを設定できます。 +- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAIです。 - [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 -- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(`SessionSettings(limit=...)` など)を上書きします。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions を使用する場合に、`Runner` の各実行前に新しいユーザー入力をセッション履歴とマージする方法をカスタマイズします。コールバックは同期または非同期にできます。 +- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得するとき、セッションレベルのデフォルト設定(たとえば `SessionSettings(limit=...)`)を上書きします。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各 `Runner` 実行の前に新しいユーザー入力をセッション履歴と統合する方法をカスタマイズします。コールバックは同期または非同期にできます。 ##### ガードレール、ハンドオフ、モデル入力の整形 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信される入力を編集できます。詳細については、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、ロスレスなメッセージ項目を元の位置に保持しながら、要約可能な履歴を順序付きのアシスタント要約セグメントに圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、生のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、および `RunResult.to_input_list()` は、SDK のデフォルトのネストされた履歴にすでに含まれている同一のメッセージ出現を二重に追加することを避ける一方で、内容が同一でも別々のメッセージは保持します。すべての [Runner メソッド][agents.run.Runner]は、指定されていない場合に `RunConfig` を自動的に作成します。そのため、クイックスタートとコード例ではデフォルトが無効のままとなり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個別のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を介してこの設定を上書きできます。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインするたびに、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの呼び出し可能オブジェクトです。完全なハンドオフフィルターを記述せずに組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴の切り詰めやシステムプロンプトの挿入に使用できます。 -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力に変換するときに、推論項目 ID を保持するか省略するかを制御します。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだ設定されていない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信される入力を編集できます。詳しくは、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、要約可能な履歴を順序付きのアシスタント要約セグメントへ圧縮しながら、情報を失わないメッセージ項目を元の位置に保持する、オプトインのベータ機能です。ネストされたハンドオフを安定化している間はデフォルトで無効です。有効にするには `True` を設定し、raw のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、`RunResult.to_input_list()` は、SDK のデフォルトのネスト履歴がすでに所有しているメッセージとまったく同じ出現箇所を二重に追加しない一方で、別々の同一メッセージは保持します。すべての [Runner メソッド][agents.run.Runner]は、指定されていない場合に `RunConfig` を自動作成するため、クイックスタートとコード例ではデフォルトが無効のままになり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を通じてこの設定を上書きできます。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインした場合に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを作成することなく、組み込みの順序付き要約セグメントを置き換えるため、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴を切り詰めたり、システムプロンプトを挿入したりできます。 +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力に変換するとき、推論項目 ID を保持するか省略するかを制御します。 ##### トレーシングと可観測性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 - [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密である可能性のあるデータをトレースに含めるかどうかを設定します。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密情報である可能性のあるデータをトレースに含めるかどうかを設定します。 - [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 -##### ツール実行、承認、ツールエラーの動作 +##### ツールの実行、承認、エラー動作 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出し数の制限など、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合に、Runner がどう処理するかを設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから参照可能なエラー出力を返すようオプトインできます。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、Runner がどう処理するかを設定します。デフォルトの `"warn"` では、対処方法を示す警告をログに記録し、現在のディスパッチで優先されるものだけを公開します。`"error"` では、モデルが呼び出される前に `UserError` が発生します。名前空間付きツールと遅延読み込みツールの厳格な検証は変更されません。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから参照可能なツールエラーメッセージをカスタマイズします。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出しの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合に、Runner がどう処理するかを設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりにモデルから確認可能なエラー出力を返すようオプトインできます。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、Runner がどう処理するかを設定します。デフォルトの `"warn"` では、対処方法を示す警告をログに記録し、現在のディスパッチ先として選ばれたものだけを公開します。`"error"` では、モデルが呼び出される前に `UserError` が発生します。名前空間付きツールと遅延読み込みツールに対する厳格な検証は変更されません。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから確認可能なツールエラーメッセージをカスタマイズします。 -ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付きトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡すか、特定のハンドオフに対して有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントをロスレスなメッセージ項目の前後に配置します。生のトランスクリプトを保持する場合(デフォルト)は、フラグを未設定のままにするか、必要なとおりに会話を転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成される要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 +ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付きトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡すか、特定のハンドオフで有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントを、情報を失わないメッセージ項目の前後に配置します。デフォルトである raw のトランスクリプトを保持する場合は、フラグを未設定のままにするか、必要に応じて会話をそのまま転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを作成せずに、生成された要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 #### 実行設定の詳細 ##### `tool_execution` -実行時にローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 +実行時のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,15 +190,15 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成すると、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツール呼び出し数の上限を設定するには、整数値を指定します。 +`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成した場合、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツール呼び出しの数を制限するには、整数値を設定します。 -これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別のものです。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがツール呼び出しを生成した後に、SDK がローカル関数ツール呼び出しを実行する方法を制御します。 +これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別です。`parallel_tool_calls` は、モデルが単一のレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルによる生成後に SDK がローカル関数ツール呼び出しを実行する方法を制御します。 -`pre_approval_tool_input_guardrails=False` は、デフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、承認後の実行直前にのみツール入力ガードレールが実行されます。保留中の承認による中断が生成される前に、関数ツールの入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックに合格した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間に依存するチェックは実行前に再検証されます。 +`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後の実行直前にのみ動作します。保留中の承認による中断が通知される前に、関数ツールの入力ガードレールを実行する場合は、`True` を設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間依存のチェックは実行前に再検証されます。 ##### `tool_not_found_behavior` -デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 +デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は解決できなかったツール呼び出しに `function_call_output` を追加してモデルを再実行するため、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 @@ -214,22 +214,22 @@ result = await Runner.run( ) ``` -このオプションは現在、ツール名の検索に失敗した関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、従来のエラー動作が引き続き使用されます。 +現在、このオプションはツール名の検索に失敗した関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、既存のエラー動作が引き続き使用されます。 ##### `tool_error_formatter` -SDK がモデルから参照可能なツールエラー出力を作成するときにモデルへ返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 +SDK がモデルから確認可能なツールエラー出力を作成するとき、モデルに返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 -フォーマッターは、次の内容を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 +フォーマッターは、次の情報を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 - `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリー。 - `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)。 - `tool_name`:ツール名。 - `call_id`:ツール呼び出し ID。 -- `default_message`:SDK のデフォルトの、モデルから参照可能なメッセージ。 -- `run_context`:アクティブな実行コンテキストのラッパー。 +- `default_message`:SDK のデフォルトの、モデルから確認可能なメッセージ。 +- `run_context`:アクティブな実行コンテキストラッパー。 -メッセージを置き換える文字列を返すか、SDK のデフォルトを使用するには `None` を返します。 +メッセージを置き換える文字列を返すか、SDK のデフォルトを使用する場合は `None` を返します。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,56 +256,56 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` は、Runner が履歴を次のターンへ引き継ぐ際に、推論項目を次のターンのモデル入力へ変換する方法を制御します(たとえば、`RunResult.to_input_list()` またはセッションを基盤とする実行を使用する場合)。 +`reasoning_item_id_policy` は、Runner が履歴を引き継ぐとき(たとえば、`RunResult.to_input_list()` またはセッションを利用する実行を使用するとき)に、推論項目を次のターンのモデル入力へ変換する方法を制御します。 - `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 - `"omit"`:生成される次のターンの入力から推論項目 ID を削除します。 -`"omit"` は主に、推論項目が `id` とともに送信されたものの、後続に必要な項目(たとえば `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)がない場合に発生する、一連の Responses API 400 エラーへのオプトインの緩和策として使用します。 +`"omit"` は主に、推論項目が `id` とともに送信される一方で、必要な後続項目(たとえば `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)がない場合に発生する、一部の Responses API 400 エラーに対するオプトインの緩和策として使用します。 -これは、SDK が以前の出力から後続入力を構築する複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。推論項目 ID が保持されている一方、プロバイダーがその ID と対応する後続項目を常にペアにすることを求める場合に発生します。 +これは、複数ターンのエージェント実行で SDK が以前の出力から後続入力を構築するときに発生することがあります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。推論項目 ID が保持されている一方で、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合に発生します。 -`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` は削除されます。これにより、SDK が生成した後続入力でその API の不変条件に抵触することを回避できます。 +`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` は削除されます。これにより、SDK が生成した後続入力でその API 不変条件に違反することを回避できます。 適用範囲に関する注意事項: - これは、SDK が後続入力を構築するときに生成または転送する推論項目のみを変更します。 - ユーザーが指定した初期入力項目は書き換えません。 -- `call_model_input_filter` では、このポリシーが適用された後でも、意図的に推論 ID を再導入できます。 +- このポリシーの適用後でも、`call_model_input_filter` によって推論 ID を意図的に再導入できます。 ## 状態と会話の管理 ### メモリ戦略の選択 -次のターンへ状態を引き継ぐ一般的な方法は 4 つあります。 +次のターンに状態を引き継ぐ一般的な方法は 4 つあります。 | 戦略 | 状態の保存場所 | 最適な用途 | 次のターンで渡すもの | | --- | --- | --- | --- | -| `result.to_input_list()` | アプリのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | +| `result.to_input_list()` | アプリケーションのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | | `session` | ストレージと SDK | 永続的なチャット状態、再開可能な実行、カスタムストア | 同じ `session` インスタンス、または同じストアを参照する別のインスタンス | -| `conversation_id` | OpenAI Conversations API | ワーカーまたはサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と、新しいユーザーターンのみ | -| `previous_response_id` | OpenAI Responses API | 会話リソースを作成せずに行う、軽量なサーバー管理の継続 | `result.last_response_id` と、新しいユーザーターンのみ | +| `conversation_id` | OpenAI Conversations API | ワーカーやサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | +| `previous_response_id` | OpenAI Responses API | 会話リソースを作成しない、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | -`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。クライアント管理の履歴と OpenAI 管理の状態を混在させると、両方のレイヤーを意図的に調整しない限り、コンテキストが重複する可能性があります。 +`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。両方のレイヤーを意図的に調整しない限り、クライアント管理の履歴と OpenAI管理の状態を組み合わせると、コンテキストが重複する可能性があります。 !!! note - 同じ実行で、セッションの永続化とサーバー管理の会話設定 + セッションの永続化と、サーバー管理の会話設定 (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)を - 組み合わせることはできません。呼び出しごとに 1 つの方法を選択してください。 + 同じ実行内で組み合わせることはできません。呼び出しごとにいずれか 1 つの方法を選択してください。 ### 会話とチャットスレッド -いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される場合があります(したがって、1 回以上の LLM 呼び出しが行われます)が、チャット会話における論理的な 1 ターンを表します。次に例を示します。 +いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される場合があり、その結果、LLM が 1 回以上呼び出されることがあります。ただし、チャット会話における論理的な 1 ターンを表します。例: -1. ユーザーターン:ユーザーがテキストを入力します。 -2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントにハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 +1. ユーザーターン:ユーザーがテキストを入力します +2. Runner の実行:最初のエージェントが LLM を呼び出してツールを実行し、2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーへ何を表示するかを選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力のみを表示することもできます。いずれの場合も、その後ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ何を表示するかを選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力のみを表示することもできます。いずれの場合も、ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 -#### 手動による会話管理 +#### 会話の手動管理 -次のターンの入力を取得するには、[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] メソッドを使用して会話履歴を手動で管理できます。 +[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] メソッドを使用して次のターンの入力を取得し、会話履歴を手動で管理できます。 ```python from agents import Agent, Runner, trace @@ -327,9 +327,9 @@ async def main(): # California ``` -#### Sessions による自動会話管理 +#### Sessions による会話の自動管理 -より簡単な方法として、`.to_input_list()` を手動で呼び出すことなく会話履歴を自動的に処理するために、[Sessions](sessions/index.md) を使用できます。 +より簡単な方法として、[Sessions](sessions/index.md)を使用すると、`.to_input_list()` を手動で呼び出すことなく、会話履歴を自動的に処理できます。 ```python from agents import Agent, Runner, SQLiteSession, trace @@ -359,18 +359,18 @@ Sessions は次の処理を自動的に行います。 - 各実行後に新しいメッセージを保存します - セッション ID ごとに個別の会話を維持します -詳細については、[Sessions のドキュメント](sessions/index.md)をご覧ください。 +詳しくは、[Sessions のドキュメント](sessions/index.md)をご覧ください。 #### サーバー管理の会話 -`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能にサーバー側で会話状態を管理させることもできます。これにより、過去のすべてのメッセージを手動で再送信せずに会話履歴を保持できます。以下のいずれのサーバー管理方式でも、各リクエストでは新しいターンの入力のみを渡し、保存した ID を再利用します。詳細については、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 +`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAIの会話状態機能にサーバー側の会話状態を管理させることもできます。これにより、過去のすべてのメッセージを毎回手動で再送信することなく、会話履歴を保持できます。以下のいずれかのサーバー管理方式では、リクエストごとに新しいターンの入力のみを渡し、保存した ID を再利用します。詳しくは、[OpenAIの会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 -OpenAI では、ターン間で状態を追跡する方法を 2 つ提供しています。 +OpenAIでは、ターンをまたいで状態を追跡する方法を 2 つ提供しています。 ##### 1. `conversation_id` の使用 -最初に OpenAI Conversations API を使用して会話を作成し、その後の各呼び出しでその ID を再利用します。 +まず OpenAI Conversations API を使用して会話を作成し、以降のすべての呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -393,7 +393,7 @@ async def main(): ##### 2. `previous_response_id` の使用 -もう 1 つの選択肢は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的にリンクします。 +もう 1 つの選択肢は **レスポンスの連鎖** です。各ターンを前のターンのレスポンス ID に明示的にリンクします。 ```python from agents import Agent, Runner @@ -418,31 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` 設定を保持するため、再開したターンは同じサーバー管理の会話内で続行されます。 +承認のために実行が一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を保持するため、再開されたターンは同じサーバー管理の会話内で継続されます。 -`conversation_id` と `previous_response_id` は相互に排他的です。システム間で共有できる名前付き会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 +`conversation_id` と `previous_response_id` は同時に使用できません。システム間で共有できる名前付き会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 !!! note SDK は `conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話の実行では、再試行前に内部の会話追跡用入力を巻き戻し、 - 同じ準備済み項目を問題なく再送信できるようにします。 + 会話を使用する実行では、再試行前に内部の会話トラッカー入力を巻き戻し、準備済みの + 同じ項目を問題なく再送信できるようにします。 ローカルのセッションベースの実行(`conversation_id`、 `previous_response_id`、または `auto_previous_response_id` とは組み合わせられません)では、SDK は - 再試行後に履歴項目が重複することを減らすため、直近で永続化された入力項目の - ベストエフォートなロールバックも行います。 + 最近永続化された入力項目のベストエフォートなロールバックも行い、再試行後の履歴項目の重複を減らします。 この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも行われます。モデルリクエストに対する より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)をご覧ください。 ## フックとカスタマイズ -### モデル呼び出し入力フィルター +### モデル呼び出しの入力フィルター -モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは現在のエージェント、コンテキスト、結合された入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 +モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、統合済みの入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 -戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須であり、入力項目のリストでなければなりません。その他の形式を返すと `UserError` が発生します。 +戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須で、入力項目のリストでなければなりません。それ以外の形式を返すと、`UserError` が発生します。 ```python from agents import Agent, Runner, RunConfig @@ -461,19 +460,19 @@ result = Runner.run_sync( ) ``` -Runner は準備済み入力リストのコピーをフックに渡すため、呼び出し元の元のリストをその場で変更することなく、切り詰め、置換、並べ替えを行えます。 +Runner は準備済み入力リストのコピーをフックに渡すため、呼び出し元の元のリストをその場で変更することなく、切り詰め、置換、並べ替えができます。 -セッションを使用している場合、`call_model_input_filter` は、セッション履歴が読み込まれ、現在のターンとマージされた後に実行されます。それより前のマージ手順自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 +セッションを使用している場合、`call_model_input_filter` は、セッション履歴がすでに読み込まれ、現在のターンと統合された後に実行されます。この前段階の統合処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 -`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次回の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続処理で送信済みとしてマークされます。 +`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAIのサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴の完全な再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続で送信済みとしてマークされます。 -機密データの編集、長い履歴の切り詰め、追加のシステムガイダンスの挿入を行うには、`run_config` を介して実行ごとにフックを設定します。 +機密データの編集、長い履歴の切り詰め、追加のシステムガイダンスの挿入を行うには、`run_config` を通じて実行ごとにフックを設定します。 ## エラーと復旧 ### エラーハンドラー -すべての `Runner` エントリーポイントは、エラー種別をキーとする dict である `error_handlers` を受け取ります。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 +すべての `Runner` エントリーポイントは、エラー種別をキーとする dict の `error_handlers` を受け取ります。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 ```python from agents import ( @@ -502,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -モデルメッセージがエージェントの structured な `output_type` に対して検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してそれを検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと復旧を辞退します。フォールバックがない場合、空でない値の検証失敗では引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターンの動作が維持されます。 +モデルメッセージがエージェントの structured `output_type` に対する検証に失敗した場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してその値を検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行われません。`None` を返すと復旧を辞退します。フォールバックがない場合、空でない検証エラーでは引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターン動作が維持されます。 ```python from pydantic import BaseModel @@ -534,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数のハンドラーでは、合成されたフォールバック出力が会話履歴に追加され、設定済みのセッションに永続化されます。実行結果の履歴やセッションストレージに追加せず、フォールバックを呼び出し元に返す場合は、`include_in_history=False` を設定します。 +`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数ハンドラーの場合、合成されたフォールバック出力が会話履歴に追加され、設定済みのセッションに永続化されます。実行結果の履歴やセッションストレージに追加せず、フォールバックを呼び出し元へ返す場合は、`include_in_history=False` を設定します。 -モデルの拒否が `ModelRefusalError` で実行を終了する代わりにアプリケーション固有のフォールバックを生成する必要がある場合は、`"model_refusal"` を使用します。 +モデルによる拒否に対して、`ModelRefusalError` で実行を終了する代わりにアプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 ```python from pydantic import BaseModel @@ -568,35 +567,36 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続的な実行の統合とヒューマンインザループ +## 永続的な実行の統合と human-in-the-loop -ツール承認の一時停止と再開のパターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長時間の待機、再試行、またはプロセスの再起動にまたがる場合の永続的なオーケストレーションを目的としています。 +ツール承認の一時停止と再開のパターンについては、専用の [Human-in-the-loop ガイド](human_in_the_loop.md)をご覧ください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーション向けです。 ### Dapr -Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、ヒューマンインザループのワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立な [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの利用は、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)から開始できます。 +Agents SDKの [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、human-in-the-loop ワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAIエージェントの使用を開始するには、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)をご覧ください。 ### Temporal -Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間のタスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)でご覧いただけます。また、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)から確認できます。 +Agents SDKの [Temporal](https://temporal.io/) 統合を使用すると、human-in-the-loop タスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDKが連携して長時間実行タスクを完了するデモは[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認でき、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)で参照できます。 ### Restate -Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人間による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実行できます。この統合には、依存関係として Restate の単一バイナリランタイムが必要です。また、エージェントをプロセスやコンテナ、またはサーバーレス関数として実行できます。詳細については、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 +Agents SDKの [Restate](https://restate.dev/) 統合は、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントに使用できます。この統合には、依存関係として Restate の単一バイナリランタイムが必要であり、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳しくは、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 ### DBOS -Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動をまたいで進捗を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは SQLite または Postgres データベースのみです。詳細については、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 +Agents SDKの [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、human-in-the-loop ワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは、SQLite または Postgres データベースのみです。詳しくは、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 ## 例外 -SDK は特定の状況で例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 +SDK は特定の場合に例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 -- [`AgentsException`][agents.exceptions.AgentsException]:SDK が発生させるすべての例外の基底クラスです。その他すべての固有の例外が派生する汎用型として機能します。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドに渡された `max_turns` の制限を超えた場合に発生します。指定されたエージェントループのターン数(LLM 呼び出し数)以内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。次のような場合が含まれます。 - - 不正な形式の JSON:モデルがツール呼び出しまたは直接出力で不正な形式の JSON 構造を提供した場合。特に、特定の `output_type` が定義されている場合。 - - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合 +- [`AgentsException`][agents.exceptions.AgentsException]:SDK が発生させるすべての例外の基底クラスです。他のすべての具体的な例外の派生元となる汎用型です。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドに渡された `max_turns` の制限を超えた場合に発生します。これは、指定されたエージェントループのターン数(LLM 呼び出し回数)内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 +- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]:モデル呼び出しの試行が [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] を超えた場合に発生します。適用範囲と再試行動作については、[モデル呼び出しのタイムアウト](models/index.md#model-call-timeouts)をご覧ください。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない、または無効な出力を生成した場合に発生します。これには次のものが含まれます。 + - 不正な JSON:特に特定の `output_type` が定義されている場合に、モデルがツール呼び出しまたは直接出力で不正な JSON 構造を生成すること。 + - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できないこと - [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定済みのタイムアウトを超え、ツールが `timeout_behavior="raise_exception"` を使用している場合に発生します。 -- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードを記述している方が、SDK の使用時に誤りを犯した場合に発生します。通常、不正なコード実装、無効な設定、または SDK API の誤用が原因です。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:入力ガードレールの条件が満たされると `InputGuardrailTripwireTriggered` が発生し、出力ガードレールの条件が満たされると `OutputGuardrailTripwireTriggered` が発生します。入力ガードレールは処理前に受信メッセージを確認し、出力ガードレールは提供前にエージェントの最終レスポンスを確認します。 \ No newline at end of file +- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードの作成者が、SDK の使用中に誤りを犯した場合に発生します。通常は、コードの実装ミス、無効な設定、SDK API の誤用によって発生します。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:入力ガードレールの条件が満たされると `InputGuardrailTripwireTriggered` が発生し、出力ガードレールの条件が満たされると `OutputGuardrailTripwireTriggered` が発生します。入力ガードレールは処理前に受信メッセージをチェックし、出力ガードレールは配信前にエージェントの最終レスポンスをチェックします。 \ No newline at end of file diff --git a/docs/ja/sandbox/clients.md b/docs/ja/sandbox/clients.md index ce9e77ac12..67e8198f4a 100644 --- a/docs/ja/sandbox/clients.md +++ b/docs/ja/sandbox/clients.md @@ -4,42 +4,42 @@ search: --- # サンドボックスクライアント -このページでは、サンドボックスでの作業を実行する場所を選択します。ほとんどの場合、`SandboxAgent` の定義はそのままで、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 内のサンドボックスクライアントとクライアント固有のオプションのみを変更します。 +このページでは、サンドボックスでの作業を実行する場所を選択します。ほとんどの場合、`SandboxAgent` の定義はそのまま維持し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントとクライアント固有のオプションのみを変更します。 !!! warning "ベータ機能" - サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポート対象の機能が変更される可能性があり、今後さらに高度な機能が追加される予定です。 + サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 ## 選択ガイド
-| 目的 | 最初の選択肢 | 理由 | +| 目的 | 最初の選択 | 理由 | | --- | --- | --- | -| macOS または Linux で最速のローカル反復開発 | `UnixLocalSandboxClient` | 追加インストールが不要で、ローカルファイルシステムを使った開発が簡単です。 | -| 基本的なコンテナ分離 | `DockerSandboxClient` | 特定のイメージを使用して Docker 内で作業を実行します。 | -| ホステッド実行または本番環境相当の分離 | ホステッドサンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境に移します。 | +| macOS または Linux での最速のローカルイテレーション | `UnixLocalSandboxClient` | 追加インストールが不要で、ローカルファイルシステムを使用した開発が簡単です。 | +| 基本的なコンテナ分離 | `DockerSandboxClient` | 特定のイメージを使用して、Docker 内で作業を実行します。 | +| ホステッド実行または本番環境に近い分離 | ホステッドサンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境に移します。 |
## ローカルクライアント -ほとんどのユーザーは、次の 2 つのサンドボックスクライアントのいずれかから始めることをおすすめします。 +ほとんどのユーザーには、次の 2 つのサンドボックスクライアントのいずれかを推奨します。
-| クライアント | インストール | 適している場合 | 例 | +| クライアント | インストール | 選択する場合 | コード例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカル反復開発を行う場合。ローカル開発の優れたデフォルトです。 | [Unix ローカルのスターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離が必要な場合、または特定のイメージを使用して対象環境をローカルで再現する場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | なし | macOS または Linux で最速のローカルイテレーションが必要な場合。ローカル開発のデフォルトとして適しています。 | [Unix-local スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | コンテナ分離が必要な場合、または対象環境をローカルで再現するために特定のイメージを使用する場合。 | [Docker スターター](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local は、ローカルファイルシステムを対象に開発を始める最も簡単な方法です。より強力な環境分離や本番環境相当の一貫性が必要になったら、Docker またはホステッドプロバイダーに移行してください。 +Unix-local は、ローカルファイルシステムを対象とした開発を始める最も簡単な方法です。より強力な環境分離や本番環境に近い整合性が必要になった場合は、Docker またはホステッドプロバイダーに移行してください。 -`SandboxPathGrant.host_path` は Docker 専用で、ホストのパスをコンテナ内の別の POSIX パスにマッピングします。Unix-local では、同一パスへの許可のみがサポートされます。詳細については、[マニフェストのパス許可](guide.md#manifest)を参照してください。 +`SandboxPathGrant.host_path` は Docker 専用で、ホストパスをコンテナ内の別の POSIX パスにマッピングします。Unix-local では、同一パスへの許可のみをサポートします。詳細は、[マニフェストのパス許可](guide.md#manifest)を参照してください。 -Unix-local から Docker に切り替えるには、エージェント定義はそのままにして、実行設定のみを変更します。 +Unix-local から Docker に切り替えるには、エージェント定義をそのまま維持し、実行設定のみを変更します。 ```python from docker import from_env as docker_from_env @@ -56,45 +56,58 @@ run_config = RunConfig( ) ``` -コンテナ分離が必要な場合や、サンドボックスイメージを別の環境で使用されているイメージと一致させる場合に使用します。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 +コンテナ分離が必要な場合、またはサンドボックスイメージを別の環境で使用されるイメージと一致させる場合に使用します。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 + +### Docker ネットワークの無効化 + +Docker サンドボックスからネットワークにアクセスできないようにする必要がある場合は、`network_mode="none"` を設定します。 + +```python +options = DockerSandboxClientOptions( + image="python:3.14-slim", + network_mode="none", +) +``` + +明示的にサポートされるネットワークモードは `"none"` のみです。Docker のデフォルト動作を維持するには、`network_mode` を省略してください。ネットワークを無効化したサンドボックスはポートを公開できないため、`network_mode="none"` と空ではない `exposed_ports` タプルを組み合わせると、オプションの検証時に失敗します。この設定はサンドボックスのセッション状態に保存され、その状態を再開する際に SDK が代替コンテナを作成する必要がある場合にも再適用されます。 ## マウントとリモートストレージ -マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホステッドプロバイダー向けの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 +マウントエントリでは公開するストレージを記述し、マウント戦略ではサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホステッドプロバイダー向けの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 一般的なマウントオプションは次のとおりです。 - `mount_path`: サンドボックス内でストレージが表示される場所です。相対パスはマニフェストルートを基準に解決され、絶対パスはそのまま使用されます。 -- `read_only`: デフォルトは `True` です。サンドボックスからマウントされたストレージへ書き戻す必要がある場合にのみ、`False` を設定します。 +- `read_only`: デフォルトは `True` です。サンドボックスからマウント済みストレージへ書き戻す必要がある場合にのみ、`False` を設定してください。 - `mount_strategy`: 必須です。マウントエントリとサンドボックスバックエンドの両方に適合する戦略を使用してください。 -マウントは、一時的なワークスペースエントリとして扱われます。スナップショットおよび永続化のフローでは、マウントされたリモートストレージを保存済みワークスペースへコピーする代わりに、マウントされたパスを切り離すかスキップします。 +マウントは一時的なワークスペースエントリとして扱われます。スナップショットおよび永続化フローでは、マウントされたリモートストレージを保存済みワークスペースへコピーせず、マウントされたパスを切り離すかスキップします。 汎用のローカル/コンテナ戦略は次のとおりです。
-| 戦略またはパターン | 適している場合 | 注記 | +| 戦略またはパターン | 使用する場合 | 注記 | | --- | --- | --- | | `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | サンドボックスイメージで `rclone` を実行できる場合。 | S3、GCS、R2、Azure Blob、Box をサポートします。`RcloneMountPattern` は `fuse` モードまたは `nfs` モードで実行できます。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` があり、Mountpoint 形式で S3 または S3 互換ストレージにアクセスする場合。 | `S3Mount` と `GCSMount` をサポートします。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | イメージに `mount-s3` があり、Mountpoint 形式の S3 または S3 互換アクセスを使用する場合。 | `S3Mount` と `GCSMount` をサポートします。 | | `InContainerMountStrategy(pattern=FuseMountPattern(...))` | イメージに `blobfuse2` と FUSE サポートがある場合。 | `AzureBlobMount` をサポートします。 | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | イメージに `mount.s3files` があり、既存の S3 Files マウントターゲットへ接続できる場合。 | `S3FilesMount` をサポートします。 | -| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを利用したマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` を介してマウントできます。また、S3 と GCS は `mountpoint` を介してマウントすることもできます。 | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | イメージに `mount.s3files` があり、既存の S3 Files マウントターゲットに到達できる場合。 | `S3FilesMount` をサポートします。 | +| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを使用したマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` を介してマウントできます。S3 と GCS は `mountpoint` を介してマウントすることもできます。 |
-## サポート対象のホステッドプラットフォーム +## 対応ホステッドプラットフォーム -ホステッド環境が必要な場合、通常は同じ `SandboxAgent` 定義をそのまま使用し、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 内のサンドボックスクライアントのみを変更します。 +ホステッド環境が必要な場合、通常は同じ `SandboxAgent` 定義を引き継ぎ、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] のサンドボックスクライアントのみを変更します。 -このリポジトリのチェックアウトではなく公開版 SDK を使用している場合は、対応するパッケージの extras を使用してサンドボックスクライアントの依存関係をインストールしてください。 +このリポジトリのチェックアウトではなく公開済みの SDK を使用している場合は、対応するパッケージの extra を通じてサンドボックスクライアントの依存関係をインストールしてください。 プロバイダー固有の設定に関する注記と、リポジトリに含まれる拡張機能のコード例へのリンクについては、[examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md) を参照してください。
-| クライアント | インストール | 例 | +| クライアント | インストール | コード例 | | --- | --- | --- | | `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel ランナー](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | | `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare ランナー](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | @@ -106,28 +119,44 @@ run_config = RunConfig(
-ホステッドサンドボックスクライアントは、プロバイダー固有のマウント戦略を公開します。使用するストレージプロバイダーに最適なバックエンドとマウント戦略を選択してください。 +### Modal サンドボックスのリソースサイズ + +新しい Modal サンドボックスのリソースを要求するには、`ModalSandboxClientOptions.cpu` と `ModalSandboxClientOptions.memory` を使用します。単一の値では、その量を要求します。2 項目の `(request, limit)` タプルでは、最初の項目を要求値、2 番目の項目を上限値として使用します。メモリ値の単位は MiB です。 + +```python +from agents.extensions.sandbox import ModalSandboxClientOptions + +options = ModalSandboxClientOptions( + app_name="agents-sandbox", + cpu=(1.0, 4.0), + memory=(2048, 8192), +) +``` + +省略した各リソースに Modal のデフォルトを使用するには、`cpu`、`memory`、またはその両方を `None` のままにします。選択した値はサンドボックスのセッション状態に保持されるため、代替サンドボックスでも同じリソース設定が使用されます。 + +ホステッドサンドボックスクライアントは、プロバイダー固有のマウント戦略を公開します。ストレージプロバイダーに最適なバックエンドとマウント戦略を選択してください。
| バックエンド | マウントに関する注記 | | --- | --- | | Docker | `InContainerMountStrategy` や `DockerVolumeMountStrategy` などのローカル戦略により、`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` をサポートします。 | -| `ModalSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証を使用する `GCSMount` とともに `ModalCloudBucketMountStrategy` を使用することで、クラウドバケットのマウントをサポートします。インライン認証情報または名前付きの Modal Secret を使用できます。 | -| `CloudflareSandboxClient` | `S3Mount`、`R2Mount`、HMAC 認証を使用する `GCSMount` とともに `CloudflareBucketMountStrategy` を使用することで、バケットのマウントをサポートします。 | +| `ModalSandboxClient` | `ModalCloudBucketMountStrategy` を `S3Mount`、`R2Mount`、HMAC 認証を使用する `GCSMount` と組み合わせることで、クラウドバケットのマウントをサポートします。インライン認証情報または名前付き Modal Secret を使用できます。 | +| `CloudflareSandboxClient` | `CloudflareBucketMountStrategy` を `S3Mount`、`R2Mount`、HMAC 認証を使用する `GCSMount` と組み合わせることで、バケットのマウントをサポートします。 | | `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy` と `S3Mount`、`R2Mount`、または `GCSMount` のエントリを組み合わせることで、クラウドバケットのマウントをサポートします。また、`agents.extensions.sandbox.blaxel` から利用できる `BlaxelDriveMount` と `BlaxelDriveMountStrategy` により、永続的な Blaxel Drives もサポートします。 | | `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy` を使用し、`rclone` を介したクラウドストレージのマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用します。 | | `E2BSandboxClient` | `E2BCloudBucketMountStrategy` を使用し、`rclone` を介したクラウドストレージのマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用します。 | | `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy` を使用し、`rclone` を介したクラウドストレージのマウントをサポートします。`S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` と組み合わせて使用します。 | -| `VercelSandboxClient` | `VercelCloudBucketMountStrategy` と `S3Mount` のエントリを組み合わせることで、作成時に限り S3 および S3 互換バケットのマウントをサポートします。マウントされたセッションは再開できず、インライン認証情報には `allow_s3_credential_exposure=True` が必要です。 | +| `VercelSandboxClient` | `VercelCloudBucketMountStrategy` と `S3Mount` エントリを組み合わせることで、作成時のみの S3 および S3 互換バケットのマウントをサポートします。マウント済みセッションは再開できず、インライン認証情報には `allow_s3_credential_exposure=True` が必要です。 |
-マウント表は、各バックエンドが実行できるストレージタイプを示しています。チェックマークが付いていても、モデルが制御するサンドボックス内で実行されるマウントヘルパーの認証情報境界を回避できるわけではなく、すべての戦略が認証情報なしで動作できることを意味するものでもありません。Agents SDK が承認なしでコンテナ内マウントを受け入れるのは、選択したヘルパーが保護対象の権限なしで動作できる場合のみです。保護対象の権限を必要とするマウントについては、信頼できるアプリケーションコードが対象のマウントパスに対する権限の公開を明示的に承認しない限り、サンドボックスまたはマウントヘルパーを起動する前に拒否されます。 +マウント表は、各バックエンドで実行できるストレージタイプを示します。チェックマークが付いていても、モデルが制御するサンドボックス内で実行されるマウントヘルパーの認証情報境界を迂回できるわけではなく、すべての戦略が認証情報なしで動作できることも意味しません。Agents SDK は、選択したヘルパーが保護対象の権限なしで動作できる場合に限り、承認なしでコンテナ内マウントを受け入れます。保護対象の権限が必要なマウントについては、信頼できるアプリケーションコードが該当する正確なマウントパスへの権限公開を明示的に承認しない限り、サンドボックスまたはマウントヘルパーを起動する前に拒否します。 -認証情報を必要としない `rclone` のマウントは、S3、GCS、R2、Azure Blob に限定されます。コンテナ内の Box マウントには、非対話型の認証ソースと、そのソースに対応する承認が必要です。`FuseMountPattern` では、インライン認証情報が設定されていない場合でも `blobfuse2` が環境に存在する Azure 権限を検出するため、広範な承認が必要です。同様に、`S3FilesMountPattern` でも `mount.s3files` が環境に存在する IAM 権限を使用するため、広範な承認が必要です。これらの要件は、Docker がバックエンドの場合にも適用されます。以下のチェックマークは、該当する権限境界の要件を満たした後に、Docker がマウントを実行できることを示しています。 +認証情報を使用しない `rclone` マウントは、S3、GCS、R2、Azure Blob に限定されます。コンテナ内での Box マウントには、非対話型の認証ソースと、そのソースに対応する承認が必要です。インライン認証情報が設定されていない場合でも、`blobfuse2` は環境内に存在する Azure の権限を検出するため、`FuseMountPattern` には広範な承認が必要です。同様に、`mount.s3files` は環境内に存在する IAM 権限を使用するため、`S3FilesMountPattern` にも広範な承認が必要です。これらの要件は、Docker をバックエンドとして使用する場合にも適用されます。以下のチェックマークは、該当する権限境界の要件が満たされた後に Docker でマウントを実行できることを示します。 -`"data"` という名前のマウントエントリでは、設定された権限に対応する承認によって返される、コピー済みの `Manifest` を保持してください。 +`"data"` という名前のマウントエントリでは、設定された権限に対応する承認から返された、コピー済みの `Manifest` を保持してください。 ```python # Mount-scoped values such as inline access keys. @@ -137,11 +166,11 @@ manifest = manifest.with_in_container_mount_credential_exposure_acknowledged("da manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") ``` -承認が必要なすべてのマウントについて、正確なマウントパスをそれぞれ渡してください。両方の権限クラスを使用するマウントには、両方の承認が必要です。承認は実行時にのみ使用され、シリアライズされません。また、認証情報の使用範囲をマウント先のパスに限定することなく、ヘルパーが認証情報を受け取ることを許可します。利用可能な場合は外部戦略またはプロバイダーネイティブの戦略を優先し、それ以外の場合はサンドボックス単位で、短期間のみ有効な最小権限の認証情報を使用してください。 +承認が必要な正確なマウントパスをすべて渡してください。両方の権限クラスを使用するマウントには、両方の承認が必要です。承認は実行時にのみ有効で、シリアライズされません。また、認証情報の使用をマウントパス内に限定することなく、ヘルパーが認証情報を受け取ることを許可します。利用可能な場合は外部戦略またはプロバイダーネイティブ戦略を優先し、それ以外の場合はサンドボックスに限定された短期間有効かつ最小権限の認証情報を使用してください。 -`VercelSandboxClientOptions(allow_s3_credential_exposure=True)` は、マウント単位のインライン認証情報を使用して作成時に Vercel S3 をマウントするための互換性オプションとして引き続き利用できます。広範な認証情報へのアクセス権限を付与するものではありません。 +`VercelSandboxClientOptions(allow_s3_credential_exposure=True)` は、マウント範囲に限定されたインライン認証情報を使用する、作成時の Vercel S3 マウント向け互換オプションとして引き続き利用できます。広範な認証情報への権限を許可するものではありません。 -次の表は、各バックエンドが直接マウントできるリモートストレージエントリをまとめたものです。 +以下の表は、各バックエンドが直接マウントできるリモートストレージエントリをまとめたものです。
diff --git a/docs/ja/sandbox/guide.md b/docs/ja/sandbox/guide.md index 295ff09df1..095ae263dc 100644 --- a/docs/ja/sandbox/guide.md +++ b/docs/ja/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "ベータ機能" - サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 + サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があり、今後さらに高度な機能が追加される予定です。 -最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果を発揮します。 **サンドボックスエージェント** は、専用ツールやシェルコマンドを使用して、大規模なドキュメントセットの検索や操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために使用できる永続的なワークスペースをモデルに提供します。Agents SDKのサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。これにより、適切なファイルをファイルシステムに配置し、サンドボックスをオーケストレーションして、大規模なタスクを容易に開始、停止、再開できます。 +最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果的に機能します。**サンドボックスエージェント**は、専用ツールやシェルコマンドを使用して、大規模なドキュメントセットの検索や操作、ファイルの編集、成果物の生成、コマンドの実行を行えます。サンドボックスは、エージェントがユーザーに代わって作業するために利用できる永続的なワークスペースをモデルに提供します。Agents SDKのサンドボックスエージェントを使用すると、サンドボックス環境と組み合わせたエージェントを簡単に実行できます。また、適切なファイルをファイルシステムに配置し、サンドボックスをオーケストレーションして、大規模なタスクを簡単に開始、停止、再開できます。 エージェントが必要とするデータを中心にワークスペースを定義します。GitHub リポジトリ、ローカルのファイルやディレクトリ、合成されたタスクファイル、S3 や Azure Blob Storage などのリモートファイルシステム、およびその他の指定したサンドボックス入力から開始できます。
-![コンピュート機能を備えたサンドボックスエージェントハーネス](../assets/images/harness_with_compute.png) +![コンピュートを備えたサンドボックスエージェントハーネス](../assets/images/harness_with_compute.png)
-`SandboxAgent` は引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントインターフェースを維持し、引き続き通常の `Runner` API を介して実行されます。変わるのは実行境界です。 +`SandboxAgent` は引き続き `Agent` です。`instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、ガードレール、フックなど、通常のエージェントインターフェースを維持し、通常の `Runner` API を通じて実行されます。変わるのは実行境界です。 -- `SandboxAgent` はエージェント自体を定義します。これには、通常のエージェント設定に加えて、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト、およびファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能が含まれます。 -- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースに必要な初期コンテンツとレイアウトを宣言します。 -- サンドボックスセッションは、コマンドが実行され、ファイルが変更される、稼働中の分離された環境です。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションを直接注入する、シリアライズ済みのサンドボックスセッション状態から再接続する、サンドボックスクライアントを介して新しいサンドボックスセッションを作成するなど、実行がそのサンドボックスセッションを取得する方法を決定します。 -- 保存済みのサンドボックス状態とスナップショットにより、後続の実行で以前の作業に再接続したり、保存済みコンテンツから新しいサンドボックスセッションを初期化したりできます。 +- `SandboxAgent` は、通常のエージェント設定に加え、`default_manifest`、`base_instructions`、`run_as` などのサンドボックス固有のデフォルト、およびファイルシステムツール、シェルアクセス、スキル、メモリ、コンパクションなどの機能を含む、エージェント自体を定義します。 +- `Manifest` は、ファイル、リポジトリ、マウント、環境など、新しいサンドボックスワークスペースに必要な初期内容とレイアウトを宣言します。 +- サンドボックスセッションは、コマンドが実行され、ファイルが変更される、稼働中の分離環境です。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスを直接注入する、シリアライズされたサンドボックスセッション状態から再接続する、サンドボックスクライアントを通じて新しいサンドボックスセッションを作成するなど、この実行がサンドボックスセッションを取得する方法を決定します。 +- 保存されたサンドボックス状態とスナップショットを使用すると、後続の実行で以前の作業に再接続したり、保存済みの内容から新しいサンドボックスセッションを初期化したりできます。 -`Manifest` は、新規セッションのワークスペース契約であり、稼働中のすべてのサンドボックスに対する完全な信頼できる唯一の情報源ではありません。実行に有効なワークスペースは、再利用されたサンドボックスセッション、シリアライズ済みのサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合があります。 +`Manifest` は、新しいセッションのワークスペース契約であり、稼働中のすべてのサンドボックスに関する完全な信頼できる情報源ではありません。実行で有効になるワークスペースは、再利用されたサンドボックスセッション、シリアライズされたサンドボックスセッション状態、または実行時に選択されたスナップショットから取得される場合もあります。 -このページ全体で「サンドボックスセッション」とは、サンドボックスクライアントによって管理される稼働中の実行環境を指します。これは、[セッション](../sessions/index.md)で説明されている SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 +このページ全体で「サンドボックスセッション」とは、サンドボックスクライアントによって管理される稼働中の実行環境を意味します。これは、[セッション](../sessions/index.md)で説明されている SDK の会話用 [`Session`][agents.memory.session.Session] インターフェースとは異なります。 -外側のランタイムは、引き続き承認、トレーシング、ハンドオフ、および実行の再開に必要な状態の追跡を担います。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担います。この役割分担は、このモデルの中核を成します。 +外側のランタイムは引き続き、承認、トレーシング、ハンドオフ、および実行の再開に必要な状態の追跡を担います。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担います。この分担は、モデルの中核となる部分です。 -### 各要素の関係 +### 各構成要素の関係 -サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備して稼働中のサンドボックスセッションにバインドし、後続の実行用に状態を保存できます。 +サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。Runner はエージェントを準備して稼働中のサンドボックスセッションにバインドし、後続の実行に備えて状態を保存できます。 ```mermaid flowchart LR @@ -52,196 +52,198 @@ flowchart LR サンドボックス固有のデフォルトは `SandboxAgent` に保持します。実行ごとのサンドボックスセッションの選択は `SandboxRunConfig` に保持します。 -ライフサイクルは、次の 3 つのフェーズに分けて考えます。 +ライフサイクルは次の 3 つのフェーズで考えます。 -1. `SandboxAgent`、`Manifest`、および各種機能を使用して、エージェントと新規ワークスペースの契約を定義します。 -2. サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を `Runner` に渡して実行します。 -3. ランナーが管理する `RunState`、明示的なサンドボックス `session_state`、または保存済みワークスペーススナップショットから、後で処理を継続します。 +1. `SandboxAgent`、`Manifest`、および各種機能を使用して、エージェントと新しいワークスペースの契約を定義します。 +2. サンドボックスセッションを注入、再開、または作成する `SandboxRunConfig` を `Runner` に渡して、実行を開始します。 +3. Runner が管理する `RunState`、明示的なサンドボックスの `session_state`、または保存済みのワークスペーススナップショットから、後で作業を継続します。 -シェルアクセスをときどき使用する単なる 1 つのツールとして必要とする場合は、[ツールガイド](../tools.md)のホスト型シェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 +シェルアクセスが時折使用するツールの 1 つにすぎない場合は、[ツールガイド](../tools.md)のホスト型シェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 -## 使用場面 +## 適したユースケース サンドボックスエージェントは、次のようなワークスペース中心のワークフローに適しています。 -- コーディングとデバッグ。たとえば、GitHub リポジトリ内の Issue 報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 +- コーディングとデバッグ。たとえば、GitHub リポジトリの Issue 報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 - ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォームのドラフトを作成する場合 - ファイルに基づくレビューや分析。たとえば、回答前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 -- 分離されたマルチエージェントパターン。たとえば、各レビュー担当エージェントやコーディングサブエージェントに専用のワークスペースを割り当てる場合 -- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正し、後続の実行で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 +- 分離されたマルチエージェントパターン。たとえば、各レビュアーやコーディング用サブエージェントに独自のワークスペースを割り当てる場合 +- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正して後から回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 -ファイルへのアクセスや、状態を持つ変更可能なファイルシステムが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスがときどき必要となる機能の 1 つにすぎない場合は、ホスト型シェルを追加します。ワークスペース境界自体が機能の一部である場合は、サンドボックスエージェントを使用します。 +ファイルへのアクセスや、状態を保持する変更可能なファイルシステムが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスが時折必要になる機能の 1 つにすぎない場合は、ホスト型シェルを追加します。ワークスペース境界自体が機能の一部である場合は、サンドボックスエージェントを使用します。 ## サンドボックスクライアントの選択 -macOS または Linux でのローカル開発では、`UnixLocalSandboxClient` から始めてください。Windows では、`DockerSandboxClient` またはホスト型プロバイダーを使用します。サポート対象のどのプラットフォームでも、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に、プロバイダー管理の実行が必要な場合はホスト型プロバイダーに移行してください。 +macOS または Linux でのローカル開発では、`UnixLocalSandboxClient` から始めてください。Windows では、`DockerSandboxClient` またはホスト型プロバイダーを使用します。サポートされているどのプラットフォームでも、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に移行し、プロバイダー管理の実行が必要な場合はホスト型プロバイダーに移行します。 -ほとんどの場合、`SandboxAgent` の定義は変えずに、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 内のサンドボックスクライアントとそのオプションのみを変更します。ローカル、Docker、ホスト型、およびリモートマウントの各オプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +ほとんどの場合、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントとそのオプションを変更しても、`SandboxAgent` の定義は同じままです。ローカル、Docker、ホスト型、およびリモートマウントのオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 -## 中核要素 +## 中核となる構成要素
-| レイヤー | SDK の主要要素 | 回答する問い | +| レイヤー | 主な SDK の構成要素 | 答える内容 | | --- | --- | --- | | エージェント定義 | `SandboxAgent`、`Manifest`、各種機能 | どのエージェントを実行し、どの新規セッション用ワークスペース契約から開始するか? | -| サンドボックス実行 | `SandboxRunConfig`、サンドボックスクライアント、稼働中のサンドボックスセッション | この実行はどのように稼働中のサンドボックスセッションを取得し、どこで処理を実行するか? | -| 保存済みサンドボックス状態 | `RunState` のサンドボックスペイロード、`session_state`、スナップショット | このワークフローは、以前のサンドボックス作業にどのように再接続し、保存済みコンテンツから新しいサンドボックスセッションをどのように初期化するか? | +| サンドボックス実行 | `SandboxRunConfig`、サンドボックスクライアント、稼働中のサンドボックスセッション | この実行はどのように稼働中のサンドボックスセッションを取得し、作業はどこで実行されるか? | +| 保存済みのサンドボックス状態 | `RunState` のサンドボックスペイロード、`session_state`、スナップショット | このワークフローは、以前のサンドボックス作業にどのように再接続するか、または保存済みの内容から新しいサンドボックスセッションをどのように初期化するか? |
-SDK の主要要素は、次のように各レイヤーに対応します。 +主な SDK の構成要素は、次のようにこれらのレイヤーに対応します。
-| 要素 | 担当範囲 | 確認すべき問い | +| 構成要素 | 担当する内容 | 確認すべき問い | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を実行し、どのデフォルト設定を保持すべきか? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッションのワークスペースファイルとフォルダー | 実行開始時に、ファイルシステム上にどのファイルとフォルダーが存在すべきか? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、instructions の断片、またはランタイム動作をこのエージェントに関連付けるべきか? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションのソース | この実行ではサンドボックスセッションを注入、再開、または作成すべきか? | -| [`RunState`][agents.run_state.RunState] | ランナー管理の保存済みサンドボックス状態 | 以前のランナー管理ワークフローを再開し、そのサンドボックス状態を自動的に引き継いでいるか? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部ですでにシリアライズしたサンドボックス状態から再開するか? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション用に保存されたワークスペースコンテンツ | 新しいサンドボックスセッションを保存済みのファイルや成果物から開始するか? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | エージェント定義 | このエージェントは何を行い、どのデフォルト設定を引き継ぐべきか? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新規セッション用ワークスペースのファイルとフォルダー | 実行開始時に、どのファイルとフォルダーがファイルシステムに存在するべきか? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | サンドボックスネイティブの動作 | どのツール、instructions の断片、またはランタイム動作をこのエージェントに付加するべきか? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 実行ごとのサンドボックスクライアントとサンドボックスセッションの取得元 | この実行ではサンドボックスセッションを注入、再開、作成のどれで取得するべきか? | +| [`RunState`][agents.run_state.RunState] | Runner が管理する保存済みのサンドボックス状態 | Runner が管理していた以前のワークフローを再開し、そのサンドボックス状態を自動的に引き継いでいるか? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 明示的にシリアライズされたサンドボックスセッション状態 | `RunState` の外部ですでにシリアライズしたサンドボックス状態から再開したいか? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 新しいサンドボックスセッション用に保存されたワークスペース内容 | 新しいサンドボックスセッションを保存済みのファイルや成果物から開始するべきか? |
実用的な設計順序は次のとおりです。 -1. `Manifest` を使用して、新規セッションのワークスペース契約を定義します。 -2. `SandboxAgent` を使用して、エージェントを定義します。 -3. 組み込みまたはカスタムの機能を追加します。 +1. `Manifest` で新規セッション用ワークスペース契約を定義します。 +2. `SandboxAgent` でエージェントを定義します。 +3. 組み込み機能またはカスタム機能を追加します。 4. `RunConfig(sandbox=SandboxRunConfig(...))` で、各実行がサンドボックスセッションを取得する方法を決定します。 ## サンドボックス実行の準備 -実行時に、ランナーは定義を具体的なサンドボックスベースの実行に変換します。 +実行時に、Runner はその定義を具体的なサンドボックス対応の実行に変換します。 -1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡すと、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 -2. 実行に有効なワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、その既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは 1 回限りのマニフェストオーバーライドまたは `agent.default_manifest` から開始します。そのため、`Manifest` だけでは、すべての実行における最終的な稼働中ワークスペースは定義されません。 -3. 各機能に、生成されたマニフェストを処理させます。これにより、最終的なエージェントの準備前に、機能がファイル、マウント、またはその他のワークスペーススコープの動作を追加できます。 -4. 最終的な instructions を固定順序で構築します。まず SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドする場合は `base_instructions`、次に `instructions`、機能の instructions 断片、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 -5. 機能のツールを稼働中のサンドボックスセッションにバインドし、通常の `Runner` API を介して準備済みエージェントを実行します。 +1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡した場合、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 +2. 実行で有効になるワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、既存のサンドボックス状態が優先されます。それ以外の場合、Runner は一度限りのマニフェストオーバーライド、または `agent.default_manifest` から開始します。このため、すべての実行で最終的な稼働中のワークスペースが `Manifest` だけで決まるわけではありません。 +3. 各機能が生成されたマニフェストを処理できるようにします。これにより、最終的なエージェントを準備する前に、機能によってファイル、マウント、その他のワークスペーススコープの動作を追加できます。 +4. 最終的な instructions を固定順序で構築します。まず SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、続いて機能の instructions の断片、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 +5. 機能の tools を稼働中のサンドボックスセッションにバインドし、通常の `Runner` API を通じて準備済みのエージェントを実行します。 -サンドボックス化によって、ターンの意味は変わりません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックスアクションではありません。サンドボックス側の操作とターンの間に固定された 1:1 の対応関係はありません。一部の処理はサンドボックス実行レイヤー内で完結する場合がありますが、別のアクションでは、ツールの実行結果、承認、その他の状態など、追加のモデルステップを必要とする情報が返されます。実用上は、サンドボックスで処理が行われた後、エージェントランタイムが別のモデル応答を必要とする場合にのみ、追加のターンが消費されます。 +サンドボックス化によって、ターンの意味は変わりません。ターンは引き続きモデルの 1 ステップであり、単一のシェルコマンドやサンドボックスアクションではありません。サンドボックス側の操作とターンの間に、固定された 1 対 1 の対応はありません。一部の作業はサンドボックス実行レイヤー内に留まることがありますが、他のアクションでは、ツールの実行結果、承認、その他の状態など、別のモデルステップを必要とする情報が返されます。実用上は、サンドボックスでの作業後にエージェントランタイムが別のモデル応答を必要とする場合にのみ、次のターンが消費されます。 -これらの準備ステップがあるため、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` は、`SandboxAgent` を設計する際に考慮すべき主要なサンドボックス固有オプションです。 +これらの準備ステップがあるため、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` は、`SandboxAgent` を設計するときに考慮すべき主なサンドボックス固有のオプションです。 ## `SandboxAgent` のオプション -通常の `Agent` フィールドに加えて、次のサンドボックス固有オプションがあります。 +通常の `Agent` フィールドに加えて、次のサンドボックス固有のオプションがあります。
| オプション | 最適な用途 | | --- | --- | -| `default_manifest` | ランナーが作成する新しいサンドボックスセッションのデフォルトワークスペース。 | -| `instructions` | SDK のサンドボックスプロンプトの後に追加される、役割、ワークフロー、成功条件。 | -| `base_instructions` | SDK のサンドボックスプロンプトを置き換える高度なエスケープハッチ。 | -| `capabilities` | このエージェントに付随させるサンドボックスネイティブのツールと動作。 | -| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けサンドボックスツール用のユーザー ID。 | +| `default_manifest` | Runner が作成する新しいサンドボックスセッションのデフォルトワークスペース。 | +| `instructions` | SDK のサンドボックスプロンプトの後に追加される、役割、ワークフロー、成功基準。 | +| `base_instructions` | SDK のサンドボックスプロンプトを置き換える、高度なエスケープハッチ。 | +| `capabilities` | このエージェントとともに引き継ぐサンドボックスネイティブの tools と動作。 | +| `run_as` | シェルコマンド、ファイル読み取り、パッチなど、モデル向けサンドボックスツールで使用するユーザー ID。 |
-サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェントではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に設定します。 +サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェントではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に指定します。 ### `default_manifest` -`default_manifest` は、ランナーがこのエージェント用に新しいサンドボックスセッションを作成するときに使用するデフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。通常、エージェントが開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 +`default_manifest` は、Runner がこのエージェント用に新しいサンドボックスセッションを作成するときに使用するデフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。エージェントが通常開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 -これはデフォルトにすぎません。実行時に `SandboxRunConfig(manifest=...)` でオーバーライドでき、再利用または再開されたサンドボックスセッションは既存のワークスペース状態を維持します。 +これはデフォルトにすぎません。実行では `SandboxRunConfig(manifest=...)` を使用してオーバーライドでき、再利用または再開されたサンドボックスセッションでは既存のワークスペース状態が維持されます。 ### `instructions` と `base_instructions` -異なるプロンプト間でも維持すべき短いルールには、`instructions` を使用します。`SandboxAgent` では、これらの instructions が SDK のサンドボックス基本プロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しつつ、独自の役割、ワークフロー、成功条件を追加できます。 +異なるプロンプト間でも維持する必要がある短いルールには、`instructions` を使用します。`SandboxAgent` では、これらの instructions が SDK のサンドボックス基本プロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しながら、独自の役割、ワークフロー、成功基準を追加できます。 -SDK のサンドボックス基本プロンプトを置き換える場合にのみ、`base_instructions` を使用してください。ほとんどのエージェントでは設定しないでください。 +SDK のサンドボックス基本プロンプトを置き換えたい場合にのみ、`base_instructions` を使用してください。ほとんどのエージェントでは設定するべきではありません。
-| 設定先 | 用途 | 例 | +| 配置先 | 用途 | 例 | | --- | --- | --- | -| `instructions` | エージェントの安定した役割、ワークフロールール、成功条件。 | 「オンボーディング書類を調査してから、ハンドオフする。」「最終ファイルを `output/` に書き込む。」 | +| `instructions` | エージェントの安定した役割、ワークフロールール、成功基準。 | 「オンボーディング書類を確認してからハンドオフする。」「最終ファイルを `output/` に書き込む。」 | | `base_instructions` | SDK のサンドボックス基本プロンプトの完全な置き換え。 | カスタムの低レベルサンドボックスラッパープロンプト。 | -| ユーザープロンプト | この実行固有のリクエスト。 | 「このワークスペースを要約してください。」 | -| マニフェスト内のワークスペースファイル | 長いタスク仕様、リポジトリローカルの instructions、または範囲を限定した参考資料。 | `repo/task.md`、ドキュメントバンドル、サンプル資料。 | +| ユーザープロンプト | この実行における一度限りのリクエスト。 | 「このワークスペースを要約してください。」 | +| マニフェスト内のワークスペースファイル | より長いタスク仕様、リポジトリローカルの instructions、または範囲を限定した参考資料。 | `repo/task.md`、ドキュメントバンドル、サンプルパケット。 |
-`instructions` の適切な使用例は次のとおりです。 +`instructions` の適切な使用例には、次のものがあります。 -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY の状態が重要な場合に、エージェントを単一の対話型プロセス内に維持します。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスレビュー担当エージェントが調査後にユーザーへ直接回答することを禁止します。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的な記入済みファイルが実際に `output/` に配置されることを必須とします。 -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) では、正確な検証コマンドを固定し、ワークスペースルート相対のパッチパスを明確にします。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) では、PTY 状態が重要な場合に、エージェントを 1 つの対話型プロセス内に維持します。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) では、サンドボックスレビュアーが確認後にユーザーへ直接回答することを禁止します。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) では、最終的に記入されたファイルが実際に `output/` に配置されることを必須とします。 +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) では、正確な検証コマンドを固定し、`SandboxRunConfig.cwd` が未設定の場合にパッチパスがワークスペースルートからの相対パスになることを明確にします。 -ユーザーの 1 回限りのタスクを `instructions` にコピーすること、マニフェストに含めるべき長い参考資料を埋め込むこと、組み込み機能がすでに注入するツールドキュメントを繰り返すこと、モデルが実行時に必要としないローカルインストールの注意事項を混在させることは避けてください。 +ユーザーの一度限りのタスクを `instructions` にコピーすること、マニフェストに含めるべき長い参考資料を埋め込むこと、組み込み機能がすでに注入するツールドキュメントを繰り返すこと、モデルが実行時に必要としないローカルインストールの注意事項を混在させることは避けてください。 -`instructions` を省略しても、SDK にはデフォルトのサンドボックスプロンプトが含まれます。低レベルのラッパーにはそれで十分ですが、ユーザー向けエージェントの大半では、引き続き明示的な `instructions` を指定する必要があります。 +`instructions` を省略しても、SDK はデフォルトのサンドボックスプロンプトを含めます。低レベルのラッパーにはそれで十分ですが、ユーザー向けのほとんどのエージェントでは、明示的な `instructions` も指定する必要があります。 ### `capabilities` -機能は、サンドボックスネイティブの動作を `SandboxAgent` に関連付けます。実行開始前にワークスペースを構成し、サンドボックス固有の instructions を追加し、稼働中のサンドボックスセッションにバインドされるツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 +機能は、サンドボックスネイティブの動作を `SandboxAgent` に付加します。実行開始前にワークスペースを構成し、サンドボックス固有の instructions を追加し、稼働中のサンドボックスセッションにバインドされる tools を公開し、そのエージェントのモデル動作や入力処理を調整できます。 組み込み機能には次のものがあります。
-| 機能 | 追加する場合 | 注記 | +| 機能 | 追加する場合 | 備考 | | --- | --- | --- | -| `Shell` | エージェントがシェルアクセスを必要とする場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 対話をサポートする場合は `write_stdin` も追加します。 | -| `Filesystem` | エージェントがファイルの編集やローカル画像の調査を必要とする場合。 | `apply_patch` と `view_image` を追加します。パッチパスはワークスペースルート相対です。 | -| `Skills` | サンドボックス内でスキルを検出し、実体化する場合。 | `.agents` や `.agents/skills` を手動でマウントするよりも、こちらを推奨します。`Skills` がスキルのインデックスを作成し、サンドボックス内に実体化します。 | -| `Memory` | 後続の実行でメモリ成果物を読み取る、または生成する場合。 | `Shell` が必要です。実行中にメモリ成果物を更新する場合は、`Filesystem` も必要です。 | +| `Shell` | エージェントにシェルアクセスが必要な場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 操作をサポートする場合は `write_stdin` も追加します。 | +| `Filesystem` | エージェントがファイルを編集するか、ローカル画像を確認する必要がある場合。 | `apply_patch` と `view_image` を追加します。相対パスはデフォルトでワークスペースルートを使用し、設定されている場合は `SandboxRunConfig.cwd` を使用します。 | +| `Skills` | サンドボックス内でスキルの検出とマテリアライズを行う場合。 | `.agents` または `.agents/skills` を手動でマウントするよりも、こちらを推奨します。`Skills` がスキルのインデックス作成とサンドボックスへのマテリアライズを行います。 | +| `Memory` | 後続の実行でメモリ成果物を読み取るか生成する必要がある場合。 | `Shell` が必要です。実行中にメモリ成果物を更新する場合は、`Filesystem` も必要です。 | | `Compaction` | 長時間実行されるフローで、コンパクション項目の後にコンテキストを削減する必要がある場合。 | モデルのサンプリングと入力処理を調整します。 |
-デフォルトでは、`SandboxAgent.capabilities` は `Capabilities.default()` を使用し、これには `Filesystem()`、`Shell()`、`Compaction()` が含まれます。`capabilities=[...]` を渡すと、そのリストがデフォルトを置き換えるため、引き続き必要なデフォルト機能を含めてください。 +デフォルトでは、`SandboxAgent.capabilities` は `Capabilities.default()` を使用し、これには `Filesystem()`、`Shell()`、`Compaction()` が含まれます。`capabilities=[...]` を渡すと、そのリストがデフォルトを置き換えるため、引き続き使用したいデフォルト機能も含めてください。 -スキルについては、実体化する方法に応じてソースを選択します。 +`view_image` ツールは、ファイル名の拡張子ではなくファイル内容から、PNG、JPEG、GIF、WebP、BMP、TIFF のラスター画像を識別します。ラスター画像の拡張子を持つファイルでも、内容がサポートされていない場合は拒否されます。一方、サポートされるラスター画像の内容であれば、ファイル名に画像拡張子がなくても読み込めます。`.svg` および `.svgz` ファイルについては、ファイル内容からの SVG マークアップの認識に加え、ファイル名に基づく互換性も維持されます。 + +スキルについては、希望するマテリアライズ方法に応じて取得元を選択します。 - `Skills(lazy_from=LocalDirLazySkillSource(...))` は、モデルが最初にインデックスを検出し、必要なものだけを読み込めるため、大規模なローカルスキルディレクトリに適したデフォルトです。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージまたはワークスペース内にしか存在しないパスではなく、元のホスト側スキルディレクトリを渡してください。 -- `Skills(from_=LocalDir(src=...))` は、事前にステージングする小規模なローカルバンドルに適しています。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` は、SDK プロセスが実行されているファイルシステムから読み取ります。サンドボックスイメージやワークスペース内にのみ存在するパスではなく、元のホスト側スキルディレクトリを渡してください。 +- `Skills(from_=LocalDir(src=...))` は、事前にステージングしたい小規模なローカルバンドルに適しています。 - `Skills(from_=GitRepo(repo=..., ref=...))` は、スキル自体をリポジトリから取得する場合に適しています。 -`LocalDir.src` は SDK ホスト上のソースパスです。`skills_path` は、`load_skill` が呼び出されたときにスキルがステージングされる、サンドボックスワークスペース内の相対的な宛先パスです。 +`LocalDir.src` は SDK ホスト上のソースパスです。`skills_path` は、`load_skill` が呼び出されたときにスキルをステージングする、サンドボックスワークスペース内の相対的な宛先パスです。 -スキルがすでに `.agents/skills//SKILL.md` のような場所に保存されている場合は、`LocalDir(...)` にそのソースルートを指定し、引き続き `Skills(...)` を使用して公開します。別のサンドボックス内レイアウトに依存する既存のワークスペース契約がない限り、デフォルトの `skills_path=".agents"` を維持してください。 +スキルがすでに `.agents/skills//SKILL.md` のような場所に保存されている場合は、`LocalDir(...)` をそのソースルートに向けたうえで、引き続き `Skills(...)` を使用して公開します。サンドボックス内の異なるレイアウトに依存する既存のワークスペース契約がない限り、デフォルトの `skills_path=".agents"` を維持してください。 -適合する場合は、組み込み機能を優先してください。組み込み機能では対応できないサンドボックス固有のツールまたは instructions インターフェースが必要な場合にのみ、カスタム機能を作成します。 +適合する場合は、組み込み機能を優先してください。組み込み機能では対応できないサンドボックス固有のツールまたは instructions のインターフェースが必要な場合にのみ、カスタム機能を作成します。 ## 概念 ### マニフェスト -[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーまたはグループの定義、ワークスペース外の特定の絶対パスへのアクセス許可を行えます。 +[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーやグループの定義、およびワークスペース外にある特定の絶対パスへのアクセス許可を行えます。 -マニフェストエントリのパスは、ワークスペース相対です。絶対パスにすることも、`..` を使用してワークスペース外へ移動することもできません。これにより、ローカル、Docker、ホスト型クライアント間でワークスペース契約の移植性が維持されます。 +マニフェストエントリのパスは、ワークスペースからの相対パスです。絶対パスにすることも、`..` を使用してワークスペース外へ移動することもできません。これにより、ローカル、Docker、ホスト型クライアント間でワークスペース契約の移植性が維持されます。 -作業開始前にエージェントが必要とする資料には、マニフェストエントリを使用します。 +作業開始前にエージェントが必要とする素材には、マニフェストエントリを使用します。
| マニフェストエントリ | 用途 | | --- | --- | -| `File`、`Dir` | 小規模な合成入力、補助ファイル、または出力ディレクトリ。 | -| `LocalFile`、`LocalDir` | サンドボックス内に実体化するホストのファイルまたはディレクトリ。 | -| `GitRepo` | ワークスペースに取得するリポジトリ。 | -| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` などのマウント | サンドボックス内に表示する外部ストレージ。 | +| `File`、`Dir` | 小規模な合成入力、補助ファイル、出力ディレクトリ。 | +| `LocalFile`、`LocalDir` | サンドボックス内にマテリアライズするホストのファイルまたはディレクトリ。 | +| `GitRepo` | ワークスペース内に取得するリポジトリ。 | +| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` などのマウント | サンドボックス内に公開する外部ストレージ。 |
-`Dir` は、合成された子要素から、または出力先として、サンドボックスワークスペース内にディレクトリを作成します。ホストファイルシステムからは読み取りません。既存のホストディレクトリをサンドボックスワークスペースにコピーする場合は、`LocalDir` を使用してください。 +`Dir` は、合成された子要素から、または出力先として、サンドボックスワークスペース内にディレクトリを作成します。ホストファイルシステムからの読み取りは行いません。既存のホストディレクトリをサンドボックスワークスペースにコピーする場合は、`LocalDir` を使用します。 -デフォルトでは、`LocalFile.src` と `LocalDir.src` は SDK プロセスの作業ディレクトリを基準に解決されます。`extra_path_grants` で許可されていない限り、ソースはそのベースディレクトリ内にある必要があります。これにより、ローカルソースの実体化が、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に維持されます。 +`LocalFile.src` と `LocalDir.src` は、デフォルトで SDK プロセスの作業ディレクトリを基準に解決されます。`extra_path_grants` で許可されていない限り、ソースはそのベースディレクトリの配下にある必要があります。これにより、ローカルソースのマテリアライズは、サンドボックスマニフェストの他の部分と同じホストパスの信頼境界内に維持されます。 マウントエントリは公開するストレージを記述し、マウント戦略はサンドボックスバックエンドがそのストレージを接続する方法を記述します。マウントオプションとプロバイダーのサポートについては、[サンドボックスクライアント](clients.md#mounts-and-remote-storage)を参照してください。 -適切なマニフェスト設計では通常、ワークスペース契約を限定的に保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、instructions 内で `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールを使用してファイルを編集する場合、パッチパスはシェルの `workdir` ではなく、サンドボックスワークスペースルートからの相対パスであることに注意してください。 +優れたマニフェスト設計では通常、ワークスペース契約を必要最小限に保ち、長いタスク手順を `repo/task.md` などのワークスペースファイルに配置し、instructions では `repo/task.md` や `output/report.md` などのワークスペース相対パスを使用します。エージェントが `Filesystem` 機能の `apply_patch` ツールでファイルを編集する場合、パッチパスはデフォルトではサンドボックスワークスペースのルートを使用し、設定されている場合は `SandboxRunConfig.cwd` を使用することに注意してください。シェルの `workdir` は使用しません。 -エージェントがワークスペース外の具体的な絶対パスを必要とする場合、または SDK プロセスの作業ディレクトリ外にある信頼済みローカルソースをマニフェストでコピーする必要がある場合にのみ、`extra_path_grants` を使用します。たとえば、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内に実体化する生成済みスキルディレクトリなどです。許可は、ローカルソースの実体化と SDK ファイル API に適用されます。また、バックエンドがファイルシステムポリシーを適用できる場合は、シェル実行にも適用されます。 +エージェントがワークスペース外の具体的な絶対パスを必要とする場合、またはマニフェストが SDK プロセスの作業ディレクトリ外にある信頼済みローカルソースをコピーする必要がある場合にのみ、`extra_path_grants` を使用してください。たとえば、一時的なツール出力用の `/tmp`、読み取り専用ランタイム用の `/opt/toolchain`、サンドボックス内にマテリアライズする生成済みスキルディレクトリなどがあります。許可は、ローカルソースのマテリアライズと SDK のファイル API に適用されます。バックエンドがファイルシステムポリシーを適用できる場合は、シェル実行にも適用されます。 ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,15 +256,15 @@ manifest = Manifest( ) ``` -Docker が別の絶対ホストパスを、コンテナ内の絶対 POSIX `path` にバインドマウントする必要がある場合は、`host_path` を設定します。`UnixLocalSandboxClient` は両方のパスが同じであるパスのみの許可をサポートし、`host_path` は拒否します。サンドボックスで変更してはならないホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用します。 +Docker で別のホスト上の絶対パスを、コンテナ内の絶対 POSIX パス `path` にバインドマウントする場合は、`host_path` を設定します。`UnixLocalSandboxClient` は、両方のパスが同一であるパスのみの許可だけをサポートし、`host_path` を拒否します。サンドボックスが変更してはならないホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用します。 -`extra_path_grants` を含むマニフェストは、信頼済みの設定として扱ってください。アプリケーションがそれらのホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 +`extra_path_grants` を含むマニフェストは、信頼済み設定として扱ってください。アプリケーションが対象のホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 -スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスはランタイムアクセスであり、永続的なワークスペース状態ではありません。 +スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスは実行時アクセスであり、永続的なワークスペース状態ではありません。 ### 権限 -`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これは、サンドボックスが実体化するファイルに関するものであり、モデルの権限、承認ポリシー、API 認証情報に関するものではありません。 +`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これはサンドボックスがマテリアライズするファイルに関するものであり、モデルの権限、承認ポリシー、API 認証情報に関するものではありません。 デフォルトでは、マニフェストエントリは所有者が読み取り、書き込み、実行でき、グループとその他のユーザーが読み取り、実行できます。ステージングされたファイルを非公開、読み取り専用、または実行可能にする必要がある場合は、これをオーバーライドします。 @@ -280,9 +282,9 @@ private_notes = File( ) ``` -`Permissions` は、所有者、グループ、その他のユーザーの各ビットと、そのエントリがディレクトリであるかどうかを個別に保存します。直接構築するか、`Permissions.from_str(...)` でモード文字列から解析するか、`Permissions.from_mode(...)` で OS モードから取得できます。 +`Permissions` は、所有者、グループ、その他のユーザーそれぞれのビットと、エントリがディレクトリかどうかを保存します。直接構築するか、`Permissions.from_str(...)` でモード文字列から解析するか、`Permissions.from_mode(...)` で OS モードから導出できます。 -ユーザーは、作業を実行できるサンドボックス ID です。その ID をサンドボックス内に存在させる場合は、マニフェストに `User` を追加します。シェルコマンド、ファイル読み取り、パッチなどのモデル向けサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指す場合、ランナーがそのユーザーを有効なマニフェストに追加します。 +ユーザーは、作業を実行できるサンドボックス ID です。その ID をサンドボックス内に存在させる場合は、マニフェストに `User` を追加します。シェルコマンド、ファイル読み取り、パッチなど、モデル向けサンドボックスツールをそのユーザーとして実行する場合は、`SandboxAgent.run_as` を設定します。`run_as` がマニフェストにまだ存在しないユーザーを指している場合、Runner が有効なマニフェストにそのユーザーを追加します。 ```python from agents import Runner @@ -334,13 +336,13 @@ result = await Runner.run( ) ``` -ファイル単位の共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` のユーザーはサンドボックスネイティブのアクションを実行するユーザーを制御し、`Permissions` は、サンドボックスがワークスペースを実体化した後、そのユーザーが読み取り、書き込み、実行できるファイルを制御します。 +ファイルレベルの共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` ユーザーは、サンドボックスネイティブのアクションを実行する主体を制御します。`Permissions` は、サンドボックスがワークスペースをマテリアライズした後、そのユーザーが読み取り、書き込み、実行できるファイルを制御します。 ### SnapshotSpec -`SnapshotSpec` は、新しいサンドボックスセッションに対して、保存済みワークスペースコンテンツの復元元と保存先を指定します。これはサンドボックスワークスペースのスナップショットポリシーであり、`session_state` は特定のサンドボックスバックエンドを再開するためのシリアライズ済み接続状態です。 +`SnapshotSpec` は、保存済みのワークスペース内容をどこから新しいサンドボックスセッションに復元し、どこへ永続化するかを指定します。これはサンドボックスワークスペースのスナップショットポリシーです。一方、`session_state` は、特定のサンドボックスバックエンドを再開するためのシリアライズされた接続状態です。 -ローカルの永続的なスナップショットには `LocalSnapshotSpec` を使用し、アプリケーションがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。高度な呼び出し元は、ワークスペーススナップショットの永続化が不要な場合に、これを明示的に使用できます。 +ローカルの永続スナップショットには `LocalSnapshotSpec` を使用し、アプリケーションがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。ワークスペーススナップショットを永続化したくない高度な呼び出し元は、これを明示的に使用することもできます。 ```python from pathlib import Path @@ -357,13 +359,13 @@ run_config = RunConfig( ) ``` -ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントはそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、サンドボックスは実行を続行する前に保存済みワークスペースコンテンツを復元します。クリーンアップ時には、ランナー所有のサンドボックスセッションがワークスペースをアーカイブし、スナップショットを介して再度永続化します。 +Runner が新しいサンドボックスセッションを作成すると、サンドボックスクライアントがそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、サンドボックスは実行を続ける前に保存済みのワークスペース内容を復元します。クリーンアップ時には、Runner が所有するサンドボックスセッションがワークスペースをアーカイブし、スナップショットを通じて再び永続化します。 -`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット場所を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時的なパスは、永続的なワークスペースコンテンツとしてスナップショットにコピーされません。 +`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存先を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時パスは、永続的なワークスペース内容としてスナップショットにコピーされません。 ### サンドボックスのライフサイクル -ライフサイクルには、 **SDK 所有** と **開発者所有** の 2 つのモードがあります。 +ライフサイクルには、**SDK 所有**と**開発者所有**の 2 つのモードがあります。
@@ -391,7 +393,7 @@ sequenceDiagram
-サンドボックスが 1 回の実行中のみ存在すればよい場合は、SDK 所有のライフサイクルを使用します。`client`、必要に応じて `manifest` と `snapshot`、および必要なクライアントの `options` を渡します。ランナーはサンドボックスを作成または再開して開始し、エージェントを実行し、スナップショットベースのワークスペース状態を永続化し、サンドボックスセッションを終了して、ランナー所有のリソースをクライアントにクリーンアップさせます。 +サンドボックスを 1 回の実行中だけ存続させる場合は、SDK 所有のライフサイクルを使用します。`client`、必要に応じて `manifest` と `snapshot`、さらに必要なクライアント `options` を渡します。Runner はサンドボックスを作成または再開して起動し、エージェントを実行し、スナップショットに基づくワークスペース状態を永続化し、サンドボックスセッションを終了して、Runner が所有するリソースをクライアントにクリーンアップさせます。 ```python result = await Runner.run( @@ -403,7 +405,7 @@ result = await Runner.run( ) ``` -サンドボックスを事前に作成する、1 つの稼働中サンドボックスを複数の実行で再利用する、実行後にファイルを調査する、自分で作成したサンドボックス上でストリーミングする、クリーンアップの正確なタイミングを決定する場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中サンドボックスを使用しますが、代わりに閉じることはありません。 +サンドボックスを事前に作成する、稼働中の 1 つのサンドボックスを複数の実行で再利用する、実行後にファイルを確認する、自分で作成したサンドボックス上でストリーミングする、またはクリーンアップのタイミングを厳密に決定する場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、Runner はその稼働中のサンドボックスを使用しますが、自動的には閉じません。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -435,64 +437,89 @@ finally: await sandbox.aclose() ``` -`stop()` は、スナップショットベースのワークスペースコンテンツを永続化するだけで、サンドボックスを終了しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースをシャットダウンし、セッションスコープの依存関係を閉じます。 +`stop()` は、スナップショットに基づくワークスペース内容を永続化するだけで、サンドボックスを終了しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースを停止して、セッションスコープの依存関係を閉じます。 ## `SandboxRunConfig` のオプション [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションの取得元と、新しいセッションの初期化方法を決定する実行ごとのオプションを保持します。 -### サンドボックスのソース +### サンドボックスの取得元 -次のオプションは、ランナーがサンドボックスセッションを再利用、再開、または作成するかどうかを決定します。 +次のオプションは、Runner がサンドボックスセッションを再利用、再開、作成のどれで取得するかを決定します。
-| オプション | 使用する場合 | 注記 | +| オプション | 使用する場合 | 備考 | | --- | --- | --- | -| `client` | ランナーにサンドボックスセッションの作成、再開、クリーンアップを任せる場合。 | 稼働中のサンドボックス `session` を指定しない限り必須です。 | -| `session` | 稼働中のサンドボックスセッションをすでに自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中サンドボックスセッションを再利用します。 | -| `session_state` | シリアライズ済みのサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。ランナーはその明示的な状態から再開し、再開されたセッションのライフサイクルを所有します。 | +| `client` | Runner にサンドボックスセッションの作成、再開、クリーンアップを任せる場合。 | 稼働中のサンドボックス `session` を指定しない限り必須です。 | +| `session` | 稼働中のサンドボックスセッションをすでに自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、Runner はその稼働中のサンドボックスセッションを再利用します。 | +| `session_state` | シリアライズされたサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。Runner は明示的な状態から再開し、再開されたセッションのライフサイクルを所有します。 |
-実際には、ランナーは次の順序でサンドボックスセッションを解決します。 +実際には、Runner は次の順序でサンドボックスセッションを解決します。 -1. `run_config.sandbox.session` を注入すると、その稼働中のサンドボックスセッションを直接再利用します。 -2. それ以外で、実行が `RunState` から再開される場合は、保存されているサンドボックスセッション状態を再開します。 -3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的なシリアライズ済みサンドボックスセッション状態から再開します。 -4. それ以外の場合、ランナーは新しいサンドボックスセッションを作成します。その新規セッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 +1. `run_config.sandbox.session` を注入した場合、その稼働中のサンドボックスセッションを直接再利用します。 +2. それ以外で、`RunState` から実行を再開する場合は、保存されたサンドボックスセッション状態を再開します。 +3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的にシリアライズされたサンドボックスセッション状態から再開します。 +4. それ以外の場合、Runner は新しいサンドボックスセッションを作成します。その新しいセッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 ### 新規セッションの入力 -次のオプションは、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 +次のオプションは、Runner が新しいサンドボックスセッションを作成するときにのみ関係します。
-| オプション | 使用する場合 | 注記 | +| オプション | 使用する場合 | 備考 | | --- | --- | --- | -| `manifest` | 新規セッションのワークスペースを 1 回限りでオーバーライドする場合。 | 省略すると `agent.default_manifest` にフォールバックします。 | +| `manifest` | 新規セッション用ワークスペースを一度限りオーバーライドする場合。 | 省略すると `agent.default_manifest` にフォールバックします。 | | `snapshot` | 新しいサンドボックスセッションをスナップショットから初期化する場合。 | 再開に似たフローやリモートスナップショットクライアントに便利です。 | -| `options` | サンドボックスクライアントが作成時のオプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウト、および同様のクライアント固有設定で一般的です。 | +| `options` | サンドボックスクライアントが作成時オプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウトなど、クライアント固有の設定でよく使用します。 |
-### 実体化の制御 +### モデル向け作業ディレクトリ + +複数の実行で 1 つのサンドボックスセッションを共有しながら別々のサブディレクトリで作業する場合は、`cwd` に POSIX 形式のワークスペース相対ディレクトリを設定します。Runner が `cwd` を検証するとき、そのディレクトリが存在し、設定されたサンドボックスユーザーからアクセスできる必要があります。新しいセッションでは、Runner が最初にマニフェストをマテリアライズするため、この検証前にマニフェストでディレクトリを作成できます。 + +```python +from agents import Runner +from agents.run import RunConfig +from agents.sandbox import SandboxRunConfig + +result = await Runner.run( + agent, + "Work only on task A.", + run_config=RunConfig( + sandbox=SandboxRunConfig( + session=shared_sandbox, + cwd="tasks/task-a", + ), + ), +) +``` + +組み込みの `exec_command`、`view_image`、`apply_patch` ツールで使用される相対パスは、`cwd` を基準に解決されます。`cwd` の値自体では、絶対パス、`..` などの親ディレクトリ要素、空の値は拒否されます。文字列値にはスラッシュを使用する必要があります。相対的な `PurePath` 値は POSIX 形式に正規化されますが、絶対的な `PurePath` 値は無効なままです。直接使用する `BaseSandboxSession` ファイル API は引き続きワークスペースルートからの相対パスを使用するため、`cwd` は `Manifest.root` やセッションの基礎となるワークスペース境界を変更しません。この設定が変更するのは相対パスの解決方法だけです。実行を `cwd` 内に制限したり、共有セッションのワークスペースポリシーで許可されている他のパスへのアクセスを防止したりするものではありません。 + +パスを扱うカスタム機能は、モデルが指定した相対パスを解決するときに、バインドされた [`SandboxWorkspaceScope`][agents.sandbox.workspace_paths.SandboxWorkspaceScope] を適用する必要があります。モデル向け作業ディレクトリを分離しながら 1 つのサンドボックスセッションを共有する 2 つの並行実行については、[examples/sandbox/shared_session_workdirs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/shared_session_workdirs.py) を参照してください。 + +### マテリアライズの制御 -`concurrency_limits` は、並列で実行できるサンドボックス実体化処理の量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。いずれかの値を `None` に設定すると、その制限のみが無効になります。 +`concurrency_limits` は、並列実行できるサンドボックスのマテリアライズ作業量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。いずれかの値を `None` に設定すると、その制限だけを無効にできます。 -`archive_limits` は、アーカイブ抽出に対する SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定し、アーカイブにより厳密なリソース制御が必要な場合は `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` などの明示的な値を渡します。SDK のアーカイブリソース制限がないデフォルト動作を維持するには `archive_limits=None` のままにし、個別の制限のみを無効にするには、そのフィールドを `None` に設定します。 +`archive_limits` は、アーカイブ展開時の SDK 側のリソースチェックを制御します。SDK のデフォルトしきい値を有効にするには `archive_limits=SandboxArchiveLimits()` を設定し、アーカイブに対してより厳密なリソース制御が必要な場合は `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` などの明示的な値を渡します。SDK のアーカイブリソース制限を適用しないデフォルト動作を維持する場合は `archive_limits=None` のままにし、特定の制限だけを無効にする場合は個別のフィールドを `None` に設定します。 -次の点に注意してください。 +次の点にも注意してください。 -- 新規セッション: `manifest=` と `snapshot=` は、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 -- 再開とスナップショット: `session_state=` は以前にシリアライズされたサンドボックス状態に再接続しますが、`snapshot=` は保存済みワークスペースコンテンツから新しいサンドボックスセッションを初期化します。 -- クライアント固有オプション: `options=` はサンドボックスクライアントに依存します。Docker と多くのホスト型クライアントでは必須です。 -- 注入された稼働中セッション: 稼働中のサンドボックス `session` を渡した場合、機能によるマニフェスト更新で、互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリタイプの置き換え、マウントエントリの追加または変更はできません。 -- ランナー API: `SandboxAgent` の実行でも、通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 +- 新規セッション: `manifest=` と `snapshot=` は、Runner が新しいサンドボックスセッションを作成するときにのみ適用されます。 +- 再開とスナップショットの違い: `session_state=` は以前にシリアライズされたサンドボックス状態に再接続します。一方、`snapshot=` は保存済みのワークスペース内容から新しいサンドボックスセッションを初期化します。 +- クライアント固有のオプション: `options=` はサンドボックスクライアントに依存します。Docker と多くのホスト型クライアントでは必須です。 +- 注入された稼働中のセッション: 実行中のサンドボックス `session` を渡すと、機能によるマニフェスト更新で互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリ型の置き換え、マウントエントリの追加や変更はできません。 +- Runner API: `SandboxAgent` の実行でも、通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 -## 完全なコード例:コーディングタスク +## 完全なコード例: コーディングタスク -次のコーディング形式のコード例は、デフォルトの出発点として適しています。 +このコーディング形式のコード例は、デフォルトの出発点として適しています。 ```python import asyncio @@ -524,9 +551,9 @@ def build_agent(model: str) -> SandboxAgent[None]: "and summarize the file changes and risks. " "Read `repo/task.md` before editing files. Stay grounded in the repository, preserve " "existing behavior, and mention the exact verification command you ran. " - "Use the `$credit-note-fixer` skill before editing files. If the repo lives under " - "`repo/`, remember that `apply_patch` paths stay relative to the sandbox workspace " - "root, so edits still target `repo/...`." + "Use the `$credit-note-fixer` skill before editing files. " + "This example leaves `SandboxRunConfig.cwd` unset, so `apply_patch` paths stay " + "relative to the sandbox workspace root and edits still target `repo/...`." ), # Put repos and task files in the manifest. default_manifest=Manifest( @@ -571,19 +598,19 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。このコード例では、Unix ローカル実行間で決定論的に検証できるように、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリには、もちろん Python、JavaScript、その他任意のものを使用できます。 +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。このコード例では、Unix ローカル実行間で決定論的に検証できるように、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリは、もちろん Python、JavaScript、その他の任意のものを使用できます。 ## 一般的なパターン -上記の完全なコード例から始めてください。多くの場合、同じ `SandboxAgent` を維持したまま、サンドボックスクライアント、サンドボックスセッションのソース、またはワークスペースのソースだけを変更できます。 +上記の完全なコード例から始めてください。多くの場合、サンドボックスクライアント、サンドボックスセッションの取得元、またはワークスペースの取得元だけを変更し、同じ `SandboxAgent` をそのまま維持できます。 ### サンドボックスクライアントの切り替え -エージェント定義を変えずに、実行設定のみを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を、プロバイダー管理の実行が必要な場合はホスト型プロバイダーを使用します。コード例とプロバイダーのオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +エージェント定義を同じままにし、実行設定だけを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホスト型プロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 ### ワークスペースのオーバーライド -エージェント定義を変えずに、新規セッションのマニフェストのみを入れ替えます。 +エージェント定義を同じままにし、新規セッションのマニフェストだけを入れ替えます。 ```python from agents.run import RunConfig @@ -603,11 +630,11 @@ run_config = RunConfig( ) ``` -エージェントを再構築せずに、同じエージェントの役割を異なるリポジトリ、資料、またはタスクバンドルに対して実行する場合に使用します。上記の検証済みコーディングコード例では、1 回限りのオーバーライドではなく `default_manifest` を使用して、同じパターンを示しています。 +エージェントを再構築せず、同じエージェントの役割を異なるリポジトリ、パケット、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディングのコード例では、一度限りのオーバーライドではなく `default_manifest` を使用して同じパターンを示しています。 ### サンドボックスセッションの注入 -ライフサイクルの明示的な制御、実行後の調査、または出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 +明示的なライフサイクル制御、実行後の確認、または出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 ```python from agents import Runner @@ -628,11 +655,11 @@ async with sandbox: ) ``` -実行後にワークスペースを調査する場合や、すでに開始済みのサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) と [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 +実行後にワークスペースを確認する場合や、すでに起動済みのサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) と [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 ### セッション状態からの再開 -`RunState` の外部ですでにサンドボックス状態をシリアライズしている場合は、その状態からランナーを再接続します。 +`RunState` の外部ですでにサンドボックス状態をシリアライズしている場合は、その状態から Runner に再接続させます。 ```python from agents.run import RunConfig @@ -649,15 +676,15 @@ run_config = RunConfig( ) ``` -サンドボックス状態を独自のストレージやジョブシステムに保存し、`Runner` でそこから直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) を参照してください。 +サンドボックス状態を独自のストレージやジョブシステムに保存し、`Runner` から直接再開する場合に使用します。シリアライズとデシリアライズのフローについては、[examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) を参照してください。 -セッション状態のシリアライズでは、ネイティブの `host_path` 値が省略されます。ホストベースの許可を再開するには、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` から指定してください。指定しない場合、サンドボックスの開始前に再開が失敗します。シリアライズ済み入力やその他の信頼できない入力からホストパスを生成しないでください。 +セッション状態のシリアライズでは、ネイティブの `host_path` 値が省略されます。ホストに基づく許可を再開するには、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` で指定してください。指定しない場合、サンドボックスの開始前に再開が失敗します。シリアライズされた入力やその他の信頼できない入力からホストパスを導出しないでください。 -セッション状態と `RunState` のシリアライズでは、クラウドマウントの認証情報、認証情報を含む補助設定、コンテナ内での認証情報公開に対する確認も削除されます。マウント済みセッションの再開をサポートするバックエンドでは、状態に秘匿化されたマウント権限が含まれる場合、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` から指定してください。`"data"` という名前のマウントエントリにマウントスコープの確認が必要な場合は、再開前に `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` を使用して、コピーされたマニフェストを保持します。広範な権限には `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")` を使用し、マウントで両方の権限クラスを使用する場合は両方のメソッドを呼び出します。確認が必要な正確なマウントパスをすべて渡してください。Agents SDKは、現在の信頼済みマニフェストの認証情報を除いたマウントトポロジーが、永続化された状態と完全に一致する場合にのみ認証情報を復元します。信頼済み設定が不足している、または一致しない場合、サンドボックスの開始前に再開が失敗します。シリアライズ済み状態だけで権限が付与されることはありません。`VercelSandboxClient` はマウント済みセッションを再開できないため、代わりに信頼済みマニフェストを使用して新しいサンドボックスを開始してください。 +セッション状態と `RunState` のシリアライズでは、クラウドマウントの認証情報、認証情報を含む補助設定、コンテナ内での認証情報公開に関する確認も削除されます。マウント済みセッションの再開をサポートするバックエンドでは、状態に編集済みのマウント権限が含まれる場合、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` で指定してください。`"data"` という名前のマウントエントリで、マウントスコープの確認が必要な場合は、再開前に `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` を使用してコピー済みマニフェストを保持してください。広範な権限には `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")` を使用し、マウントが両方の権限クラスを使用する場合は両方のメソッドを呼び出します。確認が必要な正確なマウントパスをすべて渡してください。Agents SDKは、現在の信頼済みマニフェストが永続化された状態とまったく同じ、認証情報を除いたマウントトポロジーを持つ場合にのみ、認証情報を復元します。信頼済み設定が欠落または一致しない場合、サンドボックスの開始前に再開が失敗します。シリアライズされた状態だけで権限が付与されることはありません。`VercelSandboxClient` はマウント済みセッションを再開できないため、代わりに信頼済みマニフェストを使用して新しいサンドボックスを開始してください。 ### スナップショットからの開始 -保存済みのファイルと成果物から新しいサンドボックスを初期化します。 +保存済みのファイルや成果物から新しいサンドボックスを初期化します。 ```python from pathlib import Path @@ -674,11 +701,11 @@ run_config = RunConfig( ) ``` -新しいサンドボックスセッションを作成する実行で、`agent.default_manifest` だけでなく、保存済みワークスペースコンテンツから開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 +新しいサンドボックスセッションを作成する実行で、`agent.default_manifest` だけではなく、保存済みのワークスペース内容から開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 ### Git からのスキル読み込み -ローカルのスキルソースを、リポジトリベースのソースに置き換えます。 +ローカルのスキル取得元を、リポジトリに基づく取得元へ置き換えます。 ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -689,11 +716,11 @@ capabilities = Capabilities.default() + [ ] ``` -スキルバンドルに独自のリリースサイクルがある場合や、複数のサンドボックスで共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 +スキルバンドルに独自のリリースサイクルがある場合や、複数のサンドボックス間で共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 -### ツールとしての公開 +### tools としての公開 -ツールエージェントには、独自のサンドボックス境界を割り当てることも、親実行の稼働中サンドボックスを再利用させることもできます。再利用は、高速な読み取り専用エクスプローラーエージェントに便利です。別のサンドボックスの作成、ハイドレーション、スナップショット作成のコストをかけずに、親実行が使用しているワークスペースそのものを調査できます。 +ツールエージェントには、独自のサンドボックス境界を割り当てることも、親の実行で稼働中のサンドボックスを再利用させることもできます。再利用は、高速な読み取り専用の探索エージェントに便利です。別のサンドボックスの作成、初期化、スナップショット作成にコストをかけることなく、親の実行が使用しているものとまったく同じワークスペースを確認できます。 ```python from agents import Runner @@ -775,7 +802,7 @@ async with sandbox: ) ``` -ここでは、親エージェントが同じ稼働中サンドボックスセッション内で `coordinator` として実行され、エクスプローラーツールエージェントが `explorer` として実行されます。`pricing_packet/` エントリは `other` ユーザーが読み取り可能なため、エクスプローラーはすばやく調査できますが、書き込みビットはありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループのみが使用できるため、エクスプローラーを読み取り専用に維持したまま、親が最終成果物を書き込めます。 +ここでは、親エージェントは `coordinator` として実行され、探索用ツールエージェントは同じ稼働中のサンドボックスセッション内で `explorer` として実行されます。`pricing_packet/` エントリは `other` ユーザーが読み取れるため、探索エージェントはすばやく確認できますが、書き込み権限はありません。`work/` ディレクトリはコーディネーターのユーザーまたはグループだけが利用できるため、探索エージェントを読み取り専用に保ちながら、親は最終成果物を書き込めます。 ツールエージェントに実際の分離が必要な場合は、独自のサンドボックス `RunConfig` を割り当てます。 @@ -803,11 +830,11 @@ rollout_agent.as_tool( ) ``` -ツールエージェントが自由に変更を加える、信頼できないコマンドを実行する、または別のバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 +ツールエージェントが自由に変更を行う、信頼できないコマンドを実行する、または異なるバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 -### ローカルツールおよび MCP との組み合わせ +### ローカルツールおよび MCPとの組み合わせ -サンドボックスワークスペースを維持したまま、同じエージェントで通常のツールも使用します。 +同じエージェントで通常の tools も使用しながら、サンドボックスワークスペースを維持します。 ```python from agents.sandbox import SandboxAgent @@ -822,46 +849,46 @@ agent = SandboxAgent( ) ``` -ワークスペースの調査がエージェントの作業の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 +ワークスペースの確認がエージェントの仕事の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 ## メモリ -将来のサンドボックスエージェント実行で、以前の実行から学習させる場合は、`Memory` 機能を使用します。メモリは、SDK の会話用 `Session` メモリとは別のものです。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそれらのファイルを読み取れるようにします。 +将来のサンドボックスエージェントの実行で以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは SDK の会話用 `Session` メモリとは別のものです。学んだ内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 -セットアップ、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 +設定、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 ## 構成パターン -単一エージェントのパターンを理解したら、次に検討すべき設計上の問いは、より大きなシステムのどこにサンドボックス境界を配置するかです。 +単一エージェントのパターンを理解したら、次に検討すべき設計上の問題は、より大規模なシステムのどこにサンドボックス境界を配置するかです。 -サンドボックスエージェントは、引き続き SDK の他の機能と組み合わせられます。 +サンドボックスエージェントは、SDK の他の機能とも引き続き組み合わせられます。 -- [ハンドオフ](../handoffs.md): ドキュメント量の多い作業を、サンドボックスを使用しない受付エージェントからサンドボックスレビュー担当エージェントへハンドオフします。 -- [Agents as tools](../tools.md#agents-as-tools): 複数のサンドボックスエージェントをツールとして公開します。通常は各 `Agent.as_tool(...)` 呼び出しで `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡し、各ツールに独自のサンドボックス境界を割り当てます。 +- [ハンドオフ](../handoffs.md): サンドボックスを使用しない受付エージェントから、ドキュメントを多く扱う作業をサンドボックスレビュアーへハンドオフします。 +- [Agents as tools](../tools.md#agents-as-tools): 複数のサンドボックスエージェントを tools として公開します。通常は、各 `Agent.as_tool(...)` 呼び出しで `run_config=RunConfig(sandbox=SandboxRunConfig(...))` を渡し、それぞれのツールに独自のサンドボックス境界を割り当てます。 - [MCP](../mcp.md) と通常の関数ツール: サンドボックス機能は、`mcp_servers` および通常の Python ツールと共存できます。 - [エージェントの実行](../running_agents.md): サンドボックス実行でも、通常の `Runner` API を使用します。 特に一般的なのは、次の 2 つのパターンです。 -- ワークスペースの分離が必要なワークフロー部分に限り、サンドボックスを使用しないエージェントからサンドボックスエージェントへハンドオフする -- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は `Agent.as_tool(...)` 呼び出しごとに個別のサンドボックス `RunConfig` を割り当て、各ツールに独自の分離されたワークスペースを提供する +- ワークスペースの分離が必要なワークフロー部分だけを、サンドボックスを使用しないエージェントからサンドボックスエージェントへハンドオフする +- オーケストレーターが複数のサンドボックスエージェントを tools として公開し、通常は各 `Agent.as_tool(...)` 呼び出しで個別のサンドボックス `RunConfig` を使用して、それぞれのツールに独自の分離されたワークスペースを割り当てる ### ターンとサンドボックス実行 -ハンドオフと Agents-as-tools の呼び出しは、分けて説明すると理解しやすくなります。 +ハンドオフと Agents-as-toolsの呼び出しは、分けて説明すると理解しやすくなります。 -ハンドオフでは、トップレベルの実行とトップレベルのターンループは 1 つのままです。アクティブなエージェントは変わりますが、実行がネストされることはありません。サンドボックスを使用しない受付エージェントがサンドボックスレビュー担当エージェントにハンドオフすると、同じ実行内の次のモデル呼び出しがサンドボックスエージェント向けに準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフは、同じ実行の次のターンを担当するエージェントを変更します。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) を参照してください。 +ハンドオフでは、引き続き 1 つのトップレベル実行と 1 つのトップレベルターンループが存在します。アクティブなエージェントは変わりますが、実行がネストされることはありません。サンドボックスを使用しない受付エージェントがサンドボックスレビュアーへハンドオフすると、同じ実行内の次のモデル呼び出しがサンドボックスエージェント用に準備され、そのサンドボックスエージェントが次のターンを担当します。つまり、ハンドオフは、同じ実行の次のターンを担当するエージェントを変更します。[examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) を参照してください。 -`Agent.as_tool(...)` では、関係が異なります。外側のオーケストレーターは、ツールを呼び出すことを決定するために外側のターンを 1 つ使用し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認、通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合もあれば、複数ターンを要する場合もあります。外側のオーケストレーターから見ると、そのすべての処理は 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増えることはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 +`Agent.as_tool(...)` では関係が異なります。外側のオーケストレーターは、1 つの外側のターンを使ってツールを呼び出すことを決定し、そのツール呼び出しによってサンドボックスエージェントのネストされた実行が開始されます。ネストされた実行には、独自のターンループ、`max_turns`、承認、および通常は独自のサンドボックス `RunConfig` があります。ネストされた 1 ターンで完了する場合もあれば、複数のターンが必要な場合もあります。外側のオーケストレーターから見ると、そのすべての作業が 1 回のツール呼び出しの背後で行われるため、ネストされたターンによって外側の実行のターンカウンターが増えることはありません。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 -承認の動作も同じ区分に従います。 +承認の動作も同じ区別に従います。 -- ハンドオフでは、サンドボックスエージェントがその実行のアクティブなエージェントになるため、承認は同じトップレベルの実行に維持されます -- `Agent.as_tool(...)` では、サンドボックスツールエージェント内で発生した承認も外側の実行に提示されますが、保存されたネスト済み実行状態から取得され、外側の実行が再開されるとネストされたサンドボックス実行も再開されます +- ハンドオフでは、サンドボックスエージェントが同じ実行のアクティブなエージェントになるため、承認は同じトップレベル実行に留まります +- `Agent.as_tool(...)` では、サンドボックスのツールエージェント内で発生した承認も外側の実行に提示されますが、保存されたネスト実行状態から提示され、外側の実行が再開されたときにネストされたサンドボックス実行が再開されます ## 関連資料 - [クイックスタート](../sandbox_agents.md): サンドボックスエージェントを 1 つ実行します。 -- [サンドボックスクライアント](clients.md): ローカル、Docker、ホスト型、マウントの各オプションを選択します。 -- [エージェントメモリ](memory.md): 以前のサンドボックス実行で得られた学習内容を保持し、再利用します。 -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成の各パターンです。 \ No newline at end of file +- [サンドボックスクライアント](clients.md): ローカル、Docker、ホスト型、マウントのオプションを選択します。 +- [エージェントメモリ](memory.md): 以前のサンドボックス実行から得た知見を保存して再利用します。 +- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 実行可能なローカル、コーディング、メモリ、ハンドオフ、エージェント構成のパターンです。 \ No newline at end of file diff --git a/docs/ja/sessions/index.md b/docs/ja/sessions/index.md index e1a822215d..2494d16e68 100644 --- a/docs/ja/sessions/index.md +++ b/docs/ja/sessions/index.md @@ -4,11 +4,11 @@ search: --- # セッション -Agents SDK には、複数回のエージェント実行にわたって会話履歴を自動的に維持する組み込みのセッションメモリが用意されており、ターン間で `.to_input_list()` を手動管理する必要がなくなります。 +Agents SDK には組み込みのセッションメモリが用意されており、複数回のエージェント実行にわたって会話履歴を自動的に維持できるため、ターン間で `.to_input_list()` を手動で処理する必要がありません。 -セッションは特定のセッションの会話履歴を保存するため、明示的な手動のメモリ管理を必要とせずに、エージェントがコンテキストを維持できます。これは、エージェントに過去のやり取りを記憶させたいチャットアプリケーションや複数ターンの会話を構築する場合に特に便利です。 +セッションには特定のセッションの会話履歴が保存されるため、明示的にメモリを手動管理しなくても、エージェントはコンテキストを維持できます。これは、エージェントに以前のやり取りを記憶させたいチャットアプリケーションや複数ターンの会話を構築する場合に特に便利です。 -SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内では、セッションを実行レベルの継続オプション `conversation_id`、`previous_response_id`、`auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI のサーバー管理による継続を使用する場合は、セッションと重ねて使用せず、これらのメカニズムのいずれかを選択してください。 +SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内では、セッションを実行レベルの継続オプションである `conversation_id`、`previous_response_id`、`auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI のサーバーで管理される継続機能を使用する場合は、セッションと重ねて使用せず、それらのメカニズムのいずれかを選択してください。 ## クイックスタート @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 同じセッションによる中断された実行の再開 +## 同一セッションによる中断された実行の再開 -実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または、同じセッション ID と同じ基盤ストレージバックエンドを使用するよう設定された別のインスタンス)で再開し、再開後のターンが同じ保存済み会話履歴を引き継ぐようにしてください。 +実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または同じセッション ID と同じ基盤ストレージバックエンドを使用するよう設定された別のインスタンス)で再開し、再開後のターンが保存済みの同じ会話履歴を継続して使用できるようにします。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -65,29 +65,29 @@ if result.interruptions: ## セッションの基本動作 -セッションメモリが有効な場合は、次のように動作します。 +セッションメモリが有効な場合、次のように動作します。 -1. **各実行の前**: ランナーはセッションの会話履歴を自動的に取得し、入力項目の先頭に追加します。 -2. **各実行の後**: 実行中に生成されたすべての新しい項目(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 -3. **コンテキストの保持**: 同じセッションを使用する後続の各実行には完全な会話履歴が含まれるため、エージェントはコンテキストを維持できます。 +1. **各実行前**: Runner はセッションの会話履歴を自動的に取得し、入力アイテムの先頭に追加します。 +2. **各実行後**: 実行中に生成されたすべての新しいアイテム(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 +3. **コンテキストの維持**: 同じセッションを使用する後続の各実行には会話履歴全体が含まれるため、エージェントはコンテキストを維持できます。 -これにより、`.to_input_list()` を手動で呼び出したり、実行間で会話の状態を管理したりする必要がなくなります。 +これにより、`.to_input_list()` を手動で呼び出したり、実行間で会話状態を管理したりする必要がなくなります。 -## 履歴と新しい入力のマージ方法の制御 +## 履歴と新規入力のマージ方法の制御 -セッションを渡すと、通常、ランナーはモデル入力を次の順序で準備します。 +セッションを渡すと、Runner は通常、モデル入力を次の順序で準備します。 1. セッション履歴(`session.get_items(...)` から取得) 2. 新しいターンの入力 -モデル呼び出し前にこのマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは次の 2 つのリストを受け取ります。 +モデル呼び出し前のマージ処理をカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。コールバックは、次の 2 つのリストを受け取ります。 -- `history`: 取得されたセッション履歴(入力項目形式に正規化済み) -- `new_input`: 現在のターンの新しい入力項目 +- `history`: 取得されたセッション履歴(入力アイテム形式に正規化済み) +- `new_input`: 現在のターンの新しい入力アイテム -モデルに送信する最終的な入力項目のリストを返します。 +モデルに送信する最終的な入力アイテムのリストを返します。 -コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストはそのターンのモデル入力を制御しますが、SDK が永続化するのは、新しいターンに属する項目のみです。したがって、古い履歴の並べ替えやフィルタリングによって、古いセッション項目が新規入力として再度保存されることはありません。 +コールバックは両方のリストのコピーを受け取るため、安全に変更できます。返されたリストによってそのターンのモデル入力が制御されますが、SDK が永続化するのは引き続き新しいターンに属するアイテムだけです。そのため、古い履歴を並べ替えたり除外したりしても、古いセッションアイテムが新規入力として再度保存されることはありません。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,14 +109,14 @@ result = await Runner.run( ) ``` -セッションによる項目の保存方法を変更せずに、履歴を独自に削減、並べ替え、または選択的に追加する必要がある場合に使用します。モデル呼び出しの直前に最終処理を行う必要がある場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 +セッションでのアイテムの保存方法を変更せずに、履歴を独自に削減、並べ替え、または選択的に含める必要がある場合に使用します。モデル呼び出しの直前に、さらに最終処理を行う必要がある場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 ## 取得する履歴の制限 -各実行の前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 +各実行前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 -- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッション項目を取得します -- `SessionSettings(limit=N)`: 最新の `N` 項目のみを取得します +- `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッションアイテムを取得します +- `SessionSettings(limit=N)`: 最新の `N` 個のアイテムだけを取得します これは、[`RunConfig.session_settings`][agents.run.RunConfig.session_settings] を使用して実行ごとに適用できます。 @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` 内の `None` 以外の各値は、その実行に対応するデフォルト値を上書きします。これは、セッションのデフォルト動作を変更せずに、長い会話で取得件数を制限したい場合に便利です。 +セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` 内の `None` 以外の各値が、その実行に対応するデフォルト値を上書きします。これは、セッションのデフォルト動作を変更せずに、長い会話で取得サイズに上限を設けたい場合に便利です。 ## メモリ操作 ### 基本操作 -セッションでは、会話履歴を管理するための複数の操作を利用できます。 +セッションでは、会話履歴を管理するための複数の操作を使用できます。 ```python from agents import SQLiteSession @@ -165,9 +165,9 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 修正のための pop_item の使用 +### 修正での pop_item の使用 -`pop_item` メソッドは、会話内の最後の項目を取り消したり変更したりする場合に特に便利です。 +会話内の最後のアイテムを取り消したり変更したりする場合、`pop_item` メソッドが特に便利です。 ```python from agents import Agent, Runner, SQLiteSession @@ -198,32 +198,32 @@ print(f"Agent: {result.final_output}") ## 組み込みのセッション実装 -SDK には、さまざまなユースケース向けの複数のセッション実装が用意されています。 +SDK は、さまざまなユースケースに対応する複数のセッション実装を提供します。 ### 組み込みセッション実装の選択 -以下の詳細なコード例を読む前に、この表を使用して出発点を選択してください。 +以下の詳細な例を読む前に、この表を使用して出発点を選択してください。 -| セッションの種類 | 最適な用途 | 備考 | +| セッションタイプ | 最適な用途 | 注記 | | --- | --- | --- | -| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量、ファイルベースまたはインメモリ | +| `SQLiteSession` | ローカル開発とシンプルなアプリ | 組み込みで軽量。ファイルまたはメモリをバックエンドとして使用 | | `AsyncSQLiteSession` | `aiosqlite` を使用する非同期 SQLite | 非同期ドライバーをサポートする拡張バックエンド | -| `RedisSession` | ワーカーやサービス間での共有メモリ | 低レイテンシーの分散デプロイに最適 | -| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作 | -| `MongoDBSession` | MongoDB をすでに使用している、またはマルチプロセスストレージを必要とするアプリ | 非同期 pymongo。順序付け用のアトミックなシーケンスカウンター | -| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数のステートストアに加え、TTL と整合性の制御をサポート | -| `OpenAIConversationsSession` | OpenAI 内のサーバー管理ストレージ | OpenAI Conversations API を基盤とする履歴 | -| `OpenAIResponsesCompactionSession` | 自動コンパクションを必要とする長い会話 | 別のセッションバックエンドをラップ | -| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析が必要な場合 | より多機能。専用ページを参照 | -| `EncryptedSession` | 別のセッションに暗号化と TTL を追加する場合 | ラッパー。最初に基盤となるバックエンドを選択 | +| `RedisSession` | ワーカーやサービス間での共有メモリ | 低レイテンシの分散デプロイに適しています | +| `SQLAlchemySession` | 既存のデータベースを使用する本番アプリ | SQLAlchemy がサポートするデータベースで動作します | +| `MongoDBSession` | MongoDB をすでに使用しているアプリ、またはマルチプロセスストレージを必要とするアプリ | 非同期 pymongo。順序付けにアトミックなシーケンスカウンターを使用 | +| `DaprSession` | Dapr サイドカーを使用するクラウドネイティブなデプロイ | 複数の状態ストアに加え、TTL と整合性の制御をサポート | +| `OpenAIConversationsSession` | OpenAI 内のサーバー管理ストレージ | OpenAI Conversations API をバックエンドとする履歴 | +| `OpenAIResponsesCompactionSession` | 自動圧縮を必要とする長い会話 | 別のセッションバックエンドをラップします | +| `AdvancedSQLiteSession` | SQLite に加えて分岐や分析が必要な場合 | より多機能です。専用ページを参照してください | +| `EncryptedSession` | 別のセッションに暗号化と TTL を追加する場合 | ラッパーです。最初に基盤となるバックエンドを選択してください | -一部の実装には、追加の詳細を記載した専用ページがあります。各サブセクション内にリンクを掲載しています。 +一部の実装には、追加の詳細を説明する専用ページがあります。各サブセクション内にリンクがあります。 -ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドと項目の永続化に `chatkit.store.Store` 実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[`chatkit-python` による ChatKit データストアの実装ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 +ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドとアイテムを永続化するために、`chatkit.store.Store` の実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[ChatKit データストアの実装に関する `chatkit-python` ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 ### OpenAI Conversations API セッション -`OpenAIConversationsSession` を通じて [OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations) を使用します。 +`OpenAIConversationsSession` を通じて [OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations)を使用します。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -257,11 +257,11 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses コンパクションセッション +### OpenAI Responses 圧縮セッション -Responses API(`responses.compact`)を使用して、保存された会話履歴をコンパクションするには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動的にコンパクションできます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 +Responses API(`responses.compact`)で保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動的に圧縮できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 -#### 一般的な使用方法(自動コンパクション) +#### 一般的な使用方法(自動圧縮) ```python from agents import Agent, Runner, SQLiteSession @@ -278,19 +278,21 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -デフォルトでは、SDK は各ターン後にコンパクション候補がしきい値を満たしているか確認し、満たしている場合にのみコンパクションします。 +デフォルトでは、各ターン後に SDK が圧縮候補がしきい値を満たしているか確認し、満たしている場合にのみ圧縮します。 -`compaction_mode="previous_response_id"` は、コンパクションセッションによって保持されている Responses API のレスポンス ID を使用し、そのレスポンスチェーンが利用可能な間に最適に動作します。代わりに `compaction_mode="input"` は、現在のセッション項目からコンパクションリクエストを再構築します。これは、レスポンスチェーンが利用できない場合や、セッションの内容を信頼できる唯一の情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 +自動圧縮が実行されると、SDK はその完了を待ってから `Runner.run(...)` を返すか、ストリーミングイベントのイテレーターを閉じます。圧縮リクエストで報告された使用量は、その実行の [`Usage`](../usage.md) の合計に加算されます。デフォルトでは、後から手動で行う `run_compaction()` の呼び出しには、それを包含する実行コンテキストがないため、完了済みの実行の使用量オブジェクトは更新されません。 -エージェントを `ModelSettings(store=False)` で実行すると、Responses API は後から参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは `previous_response_id` に依存せず、入力ベースのコンパクションにフォールバックします。完全なコード例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py) を参照してください。 +`compaction_mode="previous_response_id"` は、圧縮セッションが保持している Responses API のレスポンス ID を使用し、そのレスポンスチェーンが利用可能な間に最も適切に動作します。一方、`compaction_mode="input"` は現在のセッションアイテムから圧縮リクエストを再構築します。これは、レスポンスチェーンが利用できない場合や、セッション内容を信頼できる唯一の情報源にしたい場合に便利です。デフォルトの `"auto"` は、利用可能な最も安全なオプションを選択します。 -#### 自動コンパクションによるストリーミングのブロック +エージェントを `ModelSettings(store=False)` で実行すると、Responses API は後から参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは、`previous_response_id` に依存せず、入力ベースの圧縮にフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 -コンパクションではセッション履歴を消去して書き直すため、SDK はコンパクションが完了するまで実行を完了したものと見なしません。ストリーミングモードでは、コンパクションの負荷が高い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 +#### 自動圧縮によるストリーミングのブロック -`OpenAIResponsesCompactionSession.run_compaction()` は、消去と再書き込みの操作を、ラッパー境界で復旧可能な置換として扱います。基盤となる履歴が変更された後に置換が失敗またはキャンセルされた場合、ラッパーは以前の履歴の復元を試み、元の例外またはキャンセルが呼び出し元に伝わる前に、その復旧処理が完了するまで待機します。復旧中に基盤バックエンドでも障害が発生した場合、以前の履歴が復元されないままになる可能性があり、SDK は復旧の失敗をログに記録します。ラッパーは `add_items()`、`pop_item()`、`clear_session()` の呼び出しを、ロックされた置換および復旧フェーズと直列化します。ただし、リモートのコンパクションリクエストがまだ進行中の間に変更が完了し、その後、正常な置換によって上書きされる可能性があります。手動コンパクションは、ラッパーへの変更が並行して行われていないターン間に実行し、コンパクションの実行中に基盤セッションを直接変更しないでください。 +圧縮ではセッション履歴を消去して書き直すため、SDK は圧縮が完了するまで実行を完了とは見なしません。ストリーミングモードでは、圧縮処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 -低レイテンシーのストリーミングや迅速なターン切り替えが必要な場合は、自動コンパクションを無効にし、ターン間(またはアイドル時)に `run_compaction()` を自分で呼び出してください。独自の基準に基づいて、コンパクションを強制するタイミングを決定できます。 +`OpenAIResponsesCompactionSession.run_compaction()` は、消去と再書き込みの操作を、ラッパー境界で復旧可能な置換として扱います。基盤となる履歴が変更された後に置換が失敗またはキャンセルされた場合、ラッパーは以前の履歴の復元を試み、その復旧処理が完了してから元の例外またはキャンセルを呼び出し元に伝えます。復旧中に基盤のバックエンドでも障害が発生した場合、以前の履歴が復元されないままになる可能性があり、SDK は復旧の失敗をログに記録します。ラッパーは、`add_items()`、`pop_item()`、`clear_session()` の呼び出しを、ロックされた置換および復旧フェーズと直列化します。ただし、リモート圧縮リクエストがまだ処理中の間に変更処理が完了し、その後、成功した置換によって上書きされる可能性があります。手動圧縮は、ラッパーに対する変更が同時に実行されていないターン間に行い、圧縮の実行中は基盤となるセッションを直接変更しないでください。 + +低レイテンシのストリーミングや素早いターン移行が必要な場合は、自動圧縮を無効にし、ターン間(またはアイドル時間中)に `run_compaction()` を手動で呼び出してください。独自の基準に基づいて、圧縮を強制するタイミングを決定できます。 ```python from agents import Agent, Runner, SQLiteSession @@ -313,7 +315,7 @@ await session.run_compaction({"force": True}) ### SQLite セッション -SQLite を使用するデフォルトの軽量なセッション実装です。 +SQLite を使用するデフォルトの軽量セッション実装です。 ```python from agents import SQLiteSession @@ -334,7 +336,7 @@ result = await Runner.run( ### 非同期 SQLite セッション -`aiosqlite` を基盤とする SQLite の永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 +`aiosqlite` をバックエンドとする SQLite 永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 ```bash pip install aiosqlite @@ -351,7 +353,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis セッション -複数のワーカーやサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 +複数のワーカーまたはサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 ```bash pip install openai-agents[redis] @@ -370,7 +372,7 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)` は Redis クライアントを作成し、その所有権を持ちます。`close()` の後、セッションは終了状態となり、それ以降のセッション操作では `RuntimeError` が発生します。`close()` は、繰り返しまたは並行して呼び出しても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を使用して `RedisSession(...)` を直接構築します。その場合、`close()` は何も行わず、クライアントの所有権とセッションの使用可能性はどちらも呼び出し元に保持されます。 +`from_url(...)` は Redis クライアントを作成し、所有します。`close()` の後、セッションは終了状態になり、それ以降のセッション操作では `RuntimeError` が発生します。`close()` は繰り返し呼び出したり同時に呼び出したりしても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築します。その場合、`close()` は何も行わず、呼び出し元が引き続きクライアントを所有し、セッションも使用できます。 ### SQLAlchemy セッション @@ -396,7 +398,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ### Dapr セッション -Dapr サイドカーをすでに実行している場合や、エージェントのコードを変更せずに構成済みのステートストアバックエンドを切り替えたい場合は、`DaprSession` を使用します。 +すでに Dapr サイドカーを実行している場合や、エージェントコードを変更せずに構成済みの状態ストアバックエンドを切り替えたい場合は、`DaprSession` を使用します。 ```bash pip install openai-agents[dapr] @@ -417,19 +419,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -注意事項: +注記: -- `from_address(...)` は Dapr クライアントを作成し、その所有権を持ちます。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を使用して `DaprSession(...)` を直接構築します。 -- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを使用するセッションは終了状態となり、それ以降のセッション操作では `RuntimeError` が発生します。一方、`close()` は、繰り返しまたは並行して呼び出しても安全です。注入されたクライアントを使用する場合、`close()` は何も行わず、セッションは引き続き使用できます。 -- 基盤のステートストアが TTL をサポートしている場合は、セッションデータに TTL の有効期限が自動適用されるよう、`ttl=...` を渡します。 -- 書き込み後の読み取りについて、より強い保証が必要な場合は、`consistency=DAPR_CONSISTENCY_STRONG` を渡します。 -- Dapr Python SDK は HTTP サイドカーエンドポイントも確認します。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` でも Dapr を起動してください。 -- ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py) を参照してください。 +- `from_address(...)` は Dapr クライアントを作成し、所有します。アプリがすでにクライアントを管理している場合は、`dapr_client=...` を指定して `DaprSession(...)` を直接構築します。 +- コンテキストを終了するか `close()` を呼び出すと、所有クライアントを使用するセッションは終了状態になります。それ以降のセッション操作では `RuntimeError` が発生しますが、`close()` は繰り返し呼び出したり同時に呼び出したりしても安全です。注入されたクライアントを使用する場合、`close()` は何も行わず、セッションは引き続き使用できます。 +- バックエンドの状態ストアが TTL をサポートしている場合は、セッションデータに TTL による有効期限を自動的に適用するため、`ttl=...` を渡します。 +- 書き込み後の読み取りについて、より強い保証が必要な場合は、`consistency=DAPR_CONSISTENCY_STRONG` を渡します。 +- Dapr Python SDK は HTTP サイドカーエンドポイントも確認します。ローカル開発では、`dapr_address` で使用する gRPC ポートに加えて、`--dapr-http-port 3500` を指定して Dapr を起動します。 +- ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)を参照してください。 ### MongoDB セッション -MongoDB をすでに使用している、または水平方向にスケール可能なマルチプロセスのセッションストレージを必要とするアプリケーションでは、`MongoDBSession` を使用します。 +すでに MongoDB を使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションでは、`MongoDBSession` を使用します。 ```bash pip install openai-agents[mongodb] @@ -452,16 +454,16 @@ print(result.final_output) await session.close() ``` -注意事項: +注記: -- `from_uri(...)` は `AsyncMongoClient` を作成して所有し、`session.close()` で閉じます。所有クライアントを使用するセッションは、`close()` の後に終了状態となり、それ以降のセッション操作では `RuntimeError` が発生します。アプリケーションがすでにクライアントを管理している場合は、`client=...` を使用して `MongoDBSession(...)` を直接構築します。その場合、`session.close()` は何も行わず、クライアントのライフサイクルに対する責任は呼び出し元に保持され、セッションは引き続き使用できます。 -- `mongodb+srv://user:password@cluster.example.mongodb.net` URI を `from_uri(...)` に渡すだけで、ほかに変更を加えることなく [MongoDB Atlas](https://www.mongodb.com/products/platform) に接続できます。 -- 2 つのコレクションが使用され、どちらの名前も `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。空でない `add_items()` の各呼び出しでは、単調増加する `seq` によって最終項目を基準にバッチが順序付けられた、1 つの論理バッチドキュメントが書き込まれます。従来の項目ごとのメッセージドキュメントも引き続き読み取り可能です。論理バッチは MongoDB の単一ドキュメントのサイズ上限内に収まる必要があります。サイズ超過のバッチは、部分的なバッチを保存することなくアトミックに失敗します。 -- 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 +- `from_uri(...)` は `AsyncMongoClient` を作成して所有し、`session.close()` でそれを閉じます。所有クライアントを使用するセッションは、`close()` の後に終了状態になり、それ以降のセッション操作では `RuntimeError` が発生します。アプリケーションがすでにクライアントを管理している場合は、`client=...` を指定して `MongoDBSession(...)` を直接構築します。その場合、`session.close()` は何も行わず、呼び出し元がクライアントのライフサイクルを引き続き管理し、セッションも使用できます。 +- `from_uri(...)` に `mongodb+srv://user:password@cluster.example.mongodb.net` URI を渡すだけで、ほかに変更を加えずに [MongoDB Atlas](https://www.mongodb.com/products/platform)へ接続できます。 +- 2 つのコレクションが使用され、両方の名前を `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。空でない `add_items()` の各呼び出しは、単調増加する `seq` によって最後のアイテムを基準にバッチの順序を決定する、1 つの論理バッチドキュメントを書き込みます。従来のアイテム単位のメッセージドキュメントも引き続き読み取れます。論理バッチは MongoDB の単一ドキュメントのサイズ制限内に収まる必要があります。サイズを超えたバッチは、部分的なバッチを保存することなくアトミックに失敗します。 +- 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 ### 高度な SQLite セッション -会話の分岐、使用状況分析、構造化クエリを備えた拡張 SQLite セッションです。 +会話の分岐、使用量分析、構造化クエリを備えた拡張 SQLite セッションです。 ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -485,7 +487,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 ### 暗号化セッション -任意のセッション実装に対応する透過的な暗号化ラッパーです。 +あらゆるセッション実装に対応する透過的な暗号化ラッパーです。 ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -510,7 +512,7 @@ result = await Runner.run(agent, "Hello", session=session) 詳細なドキュメントについては、[暗号化セッション](encrypted_session.md)を参照してください。 -### その他のセッション形式 +### その他のセッションタイプ ほかにもいくつかの組み込みオプションがあります。`examples/memory/` と `extensions/memory/` 配下のソースコードを参照してください。 @@ -518,24 +520,24 @@ result = await Runner.run(agent, "Hello", session=session) ### セッション ID の命名 -会話を整理しやすい、意味のあるセッション ID を使用してください。 +会話の整理に役立つ、意味のあるセッション ID を使用します。 -- ユーザーベース: `"user_12345"` -- スレッドベース: `"thread_abc123"` -- コンテキストベース: `"support_ticket_456"` +- ユーザーベース: `"user_12345"` +- スレッドベース: `"thread_abc123"` +- コンテキストベース: `"support_ticket_456"` ### メモリの永続化 -- 一時的な会話にはインメモリ SQLite(`SQLiteSession("session_id")`)を使用します -- 永続的な会話にはファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します -- `aiosqlite` ベースの実装が必要な場合は、非同期 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)を使用します -- 共有された低レイテンシーのセッションメモリには、Redis ベースのセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します -- SQLAlchemy がサポートする既存のデータベースを使用する本番システムには、SQLAlchemy ベースのセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)を使用します -- MongoDB をすでに使用している、または水平方向にスケール可能なマルチプロセスのセッションストレージを必要とするアプリケーションには、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します -- 組み込みのテレメトリ、トレーシング、データ分離、および 30 以上のデータベースバックエンドのサポートを必要とする本番環境のクラウドネイティブなデプロイには、Dapr ステートストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します -- OpenAI Conversations API に履歴を保存したい場合は、OpenAI がホストするストレージ(`OpenAIConversationsSession()`)を使用します -- 任意のセッションを透過的な暗号化と TTL ベースの有効期限でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します -- より高度なユースケースでは、ほかの本番システム(Django など)向けのカスタムセッションバックエンドの実装を検討してください +- 一時的な会話には、インメモリ SQLite(`SQLiteSession("session_id")`)を使用します +- 永続的な会話には、ファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します +- `aiosqlite` ベースの実装が必要な場合は、非同期 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`)を使用します +- 共有された低レイテンシのセッションメモリには、Redis をバックエンドとするセッション(`RedisSession.from_url("session_id", url="redis://...")`)を使用します +- SQLAlchemy がサポートする既存のデータベースを備えた本番システムには、SQLAlchemy を利用したセッション(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`)を使用します +- すでに MongoDB を使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションには、MongoDB セッション(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`)を使用します +- 組み込みのテレメトリ、トレーシング、データ分離を備え、30 種類以上のデータベースバックエンドをサポートする本番環境のクラウドネイティブデプロイには、Dapr 状態ストアセッション(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`)を使用します +- OpenAI Conversations API に履歴を保存したい場合は、OpenAI がホストするストレージ(`OpenAIConversationsSession()`)を使用します +- 任意のセッションを透過的な暗号化と TTL ベースの有効期限でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します +- より高度なユースケースでは、ほかの本番システム(Django など)向けにカスタムセッションバックエンドを実装することを検討してください ### 複数のセッション @@ -581,9 +583,9 @@ result2 = await Runner.run( ) ``` -## 完全なコード例 +## 完全な例 -セッションメモリの動作を示す完全なコード例を以下に示します。 +セッションメモリの動作を示す完全な例を次に示します。 ```python import asyncio @@ -647,7 +649,7 @@ if __name__ == "__main__": ## カスタムセッション実装 -[`Session`][agents.memory.session.Session] プロトコルに構造的に準拠するクラスを作成することで、独自のセッションメモリを実装できます。`SessionABC` を継承する必要はありません。`session_id` と `session_settings` を定義し、4 つの履歴メソッドを直接実装してください。 +[`Session`][agents.memory.session.Session] プロトコルに構造的に準拠するクラスを作成することで、独自のセッションメモリを実装できます。`SessionABC` から継承する必要はありません。`session_id` と `session_settings` を定義し、4 つの履歴メソッドを直接実装します。 ```python from agents import Agent, Runner, SessionSettings @@ -691,7 +693,7 @@ result = await Runner.run( ### カスタムセッションからの実行コンテキストへのアクセス -Agents SDK は、テナントルーティング、認可、またはアプリ固有のその他のストレージ判断のために、アクティブな [`RunContextWrapper`][agents.run_context.RunContextWrapper] をカスタムセッションに渡すことができます。Agents SDK がラッパーを渡せるようにするには、4 つの履歴メソッドすべてに、明示的に命名され、キーワード引数として使用可能な `wrapper` パラメーターを追加します。 +Agents SDK は、テナントルーティング、認可、またはアプリ固有のその他のストレージ判断のために、アクティブな [`RunContextWrapper`][agents.run_context.RunContextWrapper] をカスタムセッションへ渡すことができます。Agents SDK がラッパーを渡せるようにするには、4 つの履歴メソッドすべてに、明示的に命名され、キーワード引数として使用できる `wrapper` パラメーターを追加します。 ```python from typing import Any @@ -728,15 +730,15 @@ class ContextAwareSession: ) -> None: ... ``` -Agents SDK がこの統合を有効にするのは、`get_items`、`add_items`、`pop_item`、`clear_session` のすべてで `wrapper` が宣言されている場合のみです。汎用の `**kwargs` パラメーターでは、このシグネチャチェックを満たしません。`wrapper` を省略している既存のセッション実装では、公開済みの呼び出し形式が維持され、変更せずに引き続き動作します。 +Agents SDK がこの連携を有効にするのは、`get_items`、`add_items`、`pop_item`、`clear_session` のすべてで `wrapper` が宣言されている場合だけです。汎用の `**kwargs` パラメーターは、このシグネチャチェックを満たしません。`wrapper` を省略している既存のセッション実装は、公開済みの呼び出し形式を維持し、変更なしで引き続き動作します。 ## コミュニティによるセッション実装 -コミュニティは、追加のセッション実装を開発しています。 +コミュニティは追加のセッション実装を開発しています。 | パッケージ | 説明 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)向けの、Django ORM ベースのセッション | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | Django がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)向けの Django ORM ベースのセッション | セッション実装を構築した場合は、ここに追加するためのドキュメント PR をぜひ送信してください。 @@ -744,14 +746,14 @@ Agents SDK がこの統合を有効にするのは、`get_items`、`add_items` 詳細な API ドキュメントについては、以下を参照してください。 -- [`Session`][agents.memory.session.Session] - プロトコルインターフェース -- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API の実装 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API コンパクションラッパー -- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基本的な SQLite 実装 -- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` ベースの非同期 SQLite 実装 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis ベースのセッション実装 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy ベースの実装 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB ベースのセッション実装 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr ステートストア実装 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析を備えた拡張 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッション向けの暗号化ラッパー \ No newline at end of file +- [`Session`][agents.memory.session.Session] - プロトコルインターフェース +- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API の実装 +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API の圧縮ラッパー +- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基本的な SQLite 実装 +- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - `aiosqlite` ベースの非同期 SQLite 実装 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis をバックエンドとするセッション実装 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy を利用した実装 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB をバックエンドとするセッション実装 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状態ストアの実装 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 分岐と分析を備えた拡張 SQLite +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 任意のセッションに対応する暗号化ラッパー \ No newline at end of file diff --git a/docs/ja/tracing.md b/docs/ja/tracing.md index 6e52fc63b8..5d787898b2 100644 --- a/docs/ja/tracing.md +++ b/docs/ja/tracing.md @@ -4,49 +4,49 @@ search: --- # トレーシング -Agents SDKには組み込みのトレーシングが含まれており、エージェントの実行中に発生するイベント(LLM生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベントまで)の包括的な記録を収集します。[トレースダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境と本番環境でワークフローをデバッグ、可視化、監視できます。 +Agents SDK には組み込みのトレーシング機能があり、エージェントの実行中に発生する LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベントまで、包括的なイベント記録を収集します。[トレースダッシュボード](https://platform.openai.com/traces)を使用すると、開発時および本番環境でワークフローをデバッグ、可視化、監視できます。 !!!note - トレーシングはデフォルトで有効です。一般的な無効化方法は次の 3 つです: + トレーシングはデフォルトで有効です。一般的な無効化方法は次の 3 つです。 1. 環境変数 `OPENAI_AGENTS_DISABLE_TRACING=1` を設定して、トレーシングをグローバルに無効化できます 2. [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用して、コード内でトレーシングをグローバルに無効化できます 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定して、単一の実行に対するトレーシングを無効化できます -***Zero Data Retention (ZDR) ポリシーの下でOpenAIの API を使用する組織では、トレーシングを利用できません。*** +***Zero Data Retention (ZDR) ポリシーの下で OpenAI の API を使用する組織では、トレーシングを利用できません。*** ## トレースとスパン -- **トレース**: 「ワークフロー」における単一のエンドツーエンド操作を表します。トレースは複数のスパンで構成されます。トレースには次のプロパティがあります: - - `workflow_name`: 論理的なワークフローまたはアプリの名前です。たとえば、「コード生成」や「カスタマーサービス」です。 - - `trace_id`: トレースの一意な ID です。指定しなかった場合は自動的に生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 - - `group_id`: 同じ会話の複数のトレースを関連付けるための任意のグループ ID です。たとえば、チャットスレッド ID を使用できます。 +- **トレース** は、「ワークフロー」における単一のエンドツーエンド操作を表します。トレースは複数のスパンで構成されます。トレースには次のプロパティがあります。 + - `workflow_name`: 論理的なワークフローまたはアプリの名前です。たとえば、「コード生成」や「カスタマーサービス」などです。 + - `trace_id`: トレースの一意な ID です。指定しない場合は自動的に生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 + - `group_id`: 同じ会話に属する複数のトレースを関連付けるための、省略可能なグループ ID です。たとえば、チャットスレッド ID を使用できます。 - `disabled`: True の場合、トレースは記録されません。 - - `metadata`: トレースの任意のメタデータです。 -- **スパン**: 開始時刻と終了時刻を持つ操作を表します。スパンには次のものがあります: - - `started_at` と `ended_at` のタイムスタンプ。 + - `metadata`: トレースの省略可能なメタデータです。 +- **スパン** は、開始時刻と終了時刻を持つ操作を表します。スパンには次のプロパティがあります。 + - `started_at` および `ended_at` のタイムスタンプ。 - `trace_id`: 所属するトレースを表します - - `parent_id`: このスパンの親スパン(存在する場合)を指します - - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` にはLLM生成に関する情報が含まれます。 + - `parent_id`: このスパンの親スパンが存在する場合、その親スパンを指します + - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 ## デフォルトのトレーシング -デフォルトでは、SDK は次の項目をトレースします: +デフォルトでは、SDK は次の項目をトレーシングします。 - `Runner.{run, run_sync, run_streamed}()` 全体が `trace()` でラップされます。 -- Runner の各呼び出しが `task_span()` でラップされます。 -- モデルの各ターンが `turn_span()` でラップされます。 +- 各ランナー呼び出しが `task_span()` でラップされます。 +- 各モデルターンが `turn_span()` でラップされます。 - エージェントが実行されるたびに、`agent_span()` でラップされます -- LLM生成は `generation_span()` でラップされます -- 各関数ツール呼び出しは `function_span()` でラップされます -- ガードレールは `guardrail_span()` でラップされます -- ハンドオフは `handoff_span()` でラップされます -- 音声入力(音声テキスト変換)は `transcription_span()` でラップされます -- 音声出力(テキスト音声変換)は `speech_span()` でラップされます -- SDK は、関連する音声スパンを `speech_group_span()` の子として配置する場合があります +- LLM 生成が `generation_span()` でラップされます +- 各関数ツール呼び出しが `function_span()` でラップされます +- ガードレールが `guardrail_span()` でラップされます +- ハンドオフが `handoff_span()` でラップされます +- 音声入力(音声テキスト変換)が `transcription_span()` でラップされます +- 音声出力(テキスト音声変換)が `speech_span()` でラップされます +- SDK は、関連する音声スパンを `speech_group_span()` の配下にまとめる場合があります -デフォルトでは、トレース名はリテラル文字列 `Agent workflow` です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して名前やその他のプロパティを設定できます。 +デフォルトでは、トレース名はリテラル文字列 `Agent workflow` です。`trace` を使用する場合はこの名前を設定でき、[`RunConfig`][agents.run.RunConfig] を使用すれば名前やその他のプロパティを設定できます。 よりコンパクトな階層にする場合は、実行に対するタスクスパンとターンスパンの自動作成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、およびカスタムの各スパンは引き続き記録されます。 @@ -60,11 +60,11 @@ result = await Runner.run( ) ``` -さらに、[カスタムトレーシングプロセッサー](#custom-tracing-processors)を設定し、別の送信先(代替またはセカンダリの送信先)へトレースを送信できます。 +さらに、[カスタムトレースプロセッサー](#custom-tracing-processors)を設定して、別の送信先へトレースを送信できます。これは、既存の送信先の代替または追加の送信先として使用できます。 -## 長時間稼働ワーカーと即時エクスポート +## 長時間実行ワーカーと即時エクスポート -デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのしきい値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセスの終了時に最終フラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなど、長時間稼働するワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 +デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはインメモリキューがサイズのトリガー値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセスの終了時には最終フラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 作業単位の終了時に即時配信を保証する必要がある場合は、トレースコンテキストの終了後に [`flush_traces()`][agents.tracing.flush_traces] を呼び出します。 @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンのエクスポートが完了するまでブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 +[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンがエクスポートされるまで処理をブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 ## 上位レベルのトレース -複数の `run()` 呼び出しを 1 つのトレースに含めたい場合があります。その場合は、コード全体を `trace()` でラップします。 +複数回の `run()` 呼び出しを単一のトレースに含めたい場合があります。その場合は、コード全体を `trace()` でラップします。 ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. `Runner.run` の 2 回の呼び出しが `with trace()` でラップされているため、それぞれが別個のトレースを作成するのではなく、両方の実行が 1 つの全体的なトレースに含まれます。 +1. 2 回の `Runner.run` 呼び出しが `with trace()` でラップされているため、それぞれが個別のトレースを作成するのではなく、両方の実行が 1 つの全体的なトレースに含まれます。 ## トレースの作成 -[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始して終了する必要があります。その方法は次の 2 つです: +[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始および終了する必要があります。これには次の 2 つの方法があります。 -1. **推奨**: トレースをコンテキストマネージャーとして使用します(例:`with trace(...) as my_trace`)。これにより、適切なタイミングでトレースが自動的に開始および終了されます。 +1. **推奨**: トレースをコンテキストマネージャーとして、すなわち `with trace(...) as my_trace` の形式で使用します。これにより、適切なタイミングでトレースが自動的に開始および終了されます。 2. [`trace.start()`][agents.tracing.Trace.start] と [`trace.finish()`][agents.tracing.Trace.finish] を手動で呼び出すこともできます。 -現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始および終了する場合は、現在のトレースを更新するため、`start()` に `mark_as_current` を渡し、`finish()` に `reset_current` を渡します。 +現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始および終了する場合、現在のトレースを更新するには、`start()` に `mark_as_current` を渡し、`finish()` に `reset_current` を渡します。 ## スパンの作成 -さまざまな [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を使用できます。 +さまざまな [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するための [`custom_span()`][agents.tracing.custom_span] 関数も利用できます。 -スパンは自動的に現在のトレースに含まれ、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの下にネストされます。 +スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの配下にネストされます。 ## 機密データ -一部のスパンでは、機密である可能性のあるデータを取得する場合があります。 +一部のスパンでは、機密性の高い可能性があるデータがキャプチャされる場合があります。 -`generation_span()` はLLM生成の入力と出力を保存し、`function_span()` は関数呼び出しの入力と出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータの取得を無効にできます。 +`generation_span()` には LLM 生成の入力と出力が保存され、`function_span()` には関数呼び出しの入力と出力が保存されます。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータのキャプチャを無効化できます。 -同様に、音声スパンには、デフォルトで入力音声と出力音声の base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定することで、この音声データの取得を無効にできます。 +同様に、音声スパンには、デフォルトで入出力音声の Base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定することで、この音声データのキャプチャを無効化できます。 -デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定してエクスポートすると、コードを使用せずにデフォルト値を設定できます。 +デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定してエクスポートすることで、コードを変更せずにデフォルト値を設定できます。 -## カスタムトレーシングプロセッサー +## カスタムトレースプロセッサー -トレーシングの上位レベルのアーキテクチャは次のとおりです: +トレーシングの高レベルアーキテクチャは次のとおりです。 -- 初期化時に、トレースの作成を担うグローバルな [`TraceProvider`][agents.tracing.provider.TraceProvider] を作成します。 -- `TraceProvider` に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。これは、トレースとスパンをバッチで [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、そこからスパンとトレースをバッチでOpenAIバックエンドへエクスポートします。 +- 初期化時に、トレースの作成を担当するグローバルな [`TraceProvider`][agents.tracing.provider.TraceProvider] を作成します。 +- `TraceProvider` に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。これは、トレースとスパンをバッチで [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、そこからスパンとトレースをバッチで OpenAI バックエンドにエクスポートします。 -このデフォルト設定をカスタマイズし、代替または追加のバックエンドへトレースを送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります: +このデフォルト設定をカスタマイズし、代替または追加のバックエンドにトレースを送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備ができたトレースとスパンを受け取る**追加の**トレースプロセッサーを追加できます。これにより、OpenAIバックエンドへのトレース送信に加えて、独自の処理を実行できます。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで**置き換える**ことができます。その場合、トレースを送信する `TracingProcessor` を含めない限り、トレースはOpenAIバックエンドへ送信されません。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備が整ったトレースとスパンを受け取る **追加の** トレースプロセッサーを追加できます。これにより、トレースを OpenAI バックエンドに送信しながら、独自の処理も実行できます。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで **置き換える** ことができます。この場合、送信を行う `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 -## OpenAI以外のモデルでのトレーシング +## OpenAI 以外のモデルによるトレーシング -OpenAI以外のモデルを使用する場合、トレーシングを無効にすることなく、OpenAI Traces ダッシュボードで無料のトレーシングを有効にするため、トレーシングエクスポーターにOpenAI API キーを指定できます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティアダプター](models/index.md#third-party-adapters)セクションを参照してください。 +OpenAI 以外のモデルを使用する場合、トレーシングを無効化することなく OpenAI Traces ダッシュボードで無料のトレーシングを有効にするため、トレーシングエクスポーターに OpenAI API キーを指定できます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションを参照してください。 ```python import os @@ -198,20 +198,20 @@ await Runner.run( ``` ## 補足事項 -- OpenAI Traces ダッシュボードで無料のトレースを表示できます。 +- OpenAI Traces ダッシュボードで無料のトレースを確認できます。 -## エコシステム連携 +## エコシステム統合 -以下のコミュニティおよびベンダーによる連携は、OpenAI Agents SDKのトレーシング API サーフェスをサポートしています。 +以下のコミュニティおよびベンダー統合は、OpenAI Agents SDK のトレーシング API サーフェスをサポートしています。 -### 外部トレーシングプロセッサー一覧 +### 外部トレースプロセッサーの一覧 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) -- [MLflow (セルフホスト/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow (Databricks ホスト)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) +- [MLflow (セルフホスト型 / OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow (Databricks ホスト型)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) @@ -234,4 +234,5 @@ await Runner.run( - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) - [Latitude](https://docs.latitude.so/telemetry/frameworks/openai-agents) -- [DProvenanceKit](https://dprovenance.dev/openai-agents/) \ No newline at end of file +- [DProvenanceKit](https://dprovenance.dev/openai-agents/) +- [Tuning Engines](https://github.com/cerebrixos-org/tuning-engines-cli/tree/main/packages/tuning-agents#openai-agents-sdk) \ No newline at end of file diff --git a/docs/ja/usage.md b/docs/ja/usage.md index 75f748714d..047ee1b209 100644 --- a/docs/ja/usage.md +++ b/docs/ja/usage.md @@ -4,7 +4,7 @@ search: --- # 使用量 -Agents SDK は、実行ごとのトークン使用量を自動的に追跡します。実行コンテキストから使用量にアクセスし、コストの監視、上限の適用、分析データの記録に使用できます。 +Agents SDK は、実行ごとのトークン使用量を自動的に追跡します。実行コンテキストから使用量にアクセスし、コストの監視、制限の適用、分析データの記録に利用できます。 ## 追跡対象 @@ -20,7 +20,7 @@ Agents SDK は、実行ごとのトークン使用量を自動的に追跡しま ## 実行からの使用量へのアクセス -`Runner.run(...)` の実行後、`result.context_wrapper.usage` から使用量にアクセスします。 +`Runner.run(...)` の実行後、`result.context_wrapper.usage` を介して使用量にアクセスします。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -34,18 +34,20 @@ print("Total tokens:", usage.total_tokens) 使用量は、ツール呼び出しやハンドオフを生成するモデル呼び出しを含め、実行中のすべてのモデル呼び出しにわたって集計されます。 -### サードパーティー製アダプターでの使用量の有効化 +[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] が実行の完了前に履歴を自動的に圧縮した場合、その `responses.compact` リクエストによって報告された使用量も、同じ実行の合計に加算されます。実行外で手動による `run_compaction()` 呼び出しを行った場合、それを包含する実行コンテキストがないため、以前の実行によって返された使用量オブジェクトは更新されません。[OpenAI Responses 圧縮セッション](sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -使用量レポートは、サードパーティー製アダプターやプロバイダーのバックエンドによって異なります。サードパーティー製アダプター経由でモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、次の点に注意してください。 +### サードパーティーアダプターでの使用量の有効化 -- `AnyLLMModel` では、上流プロバイダーが使用量を返すと、自動的に伝播されます。Chat Completions バックエンドからレスポンスをストリーミングする場合、使用量チャンクを出力するには `ModelSettings(include_usage=True)` が必要になることがあります。 -- `LitellmModel` では、一部のプロバイダーのバックエンドはデフォルトで使用量を報告しないため、多くの場合 `ModelSettings(include_usage=True)` が必要です。 +使用量の報告方法は、サードパーティーアダプターやプロバイダーのバックエンドによって異なります。サードパーティーアダプターを介してモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、次の点に注意してください。 -Models ガイドの[サードパーティー製アダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注意事項を確認し、デプロイ予定のプロバイダーのバックエンドで使用量レポートを検証してください。 +- `AnyLLMModel` では、アップストリームプロバイダーが使用量を返すと、自動的に伝播されます。Chat Completions バックエンドからレスポンスをストリーミングする場合、使用量チャンクを送出するには `ModelSettings(include_usage=True)` が必要になることがあります。 +- `LitellmModel` では、一部のプロバイダーバックエンドはデフォルトで使用量を報告しないため、多くの場合 `ModelSettings(include_usage=True)` が必要です。 -## リクエストごとの使用量追跡 +Models ガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注意事項を確認し、デプロイ予定のプロバイダーバックエンドで使用量が正しく報告されることを検証してください。 -SDK は、各 API リクエストの使用量を `request_usage_entries` で自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウの消費量の監視に役立ちます。 +## リクエスト単位の使用量追跡 + +SDK は、`request_usage_entries` 内の各 API リクエストの使用量を自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウの消費量の監視に役立ちます。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -56,7 +58,7 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): ## プロバイダーの使用量ペイロードの保持 -Agents SDK は、プロバイダーの使用量を [`Usage`][agents.usage.Usage] フィールドに正規化し、モデルプロバイダー間で一貫した合計値を提供します。アプリケーションでプロバイダー固有の使用量フィールドを保持する必要がある場合や、省略されたフィールドとプロバイダーが報告したゼロを区別する必要がある場合は、[`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] を `True` に設定します。 +Agents SDK は、プロバイダーの使用量を、モデルプロバイダー間で一貫した合計を提供する [`Usage`][agents.usage.Usage] フィールドに正規化します。アプリケーションでプロバイダー固有の使用量フィールドを保持する必要がある場合や、省略されたフィールドとプロバイダーが報告したゼロを区別する必要がある場合は、[`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] を `True` に設定します。 ```python from agents import Agent, ModelSettings, Runner @@ -71,15 +73,15 @@ for response in result.raw_responses: print(response.raw_usage) ``` -Agents SDK は、各 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 値を、そのモデル呼び出しに対するプロバイダーペイロードの独立した JSON 互換スナップショットとして保存します。Agents SDK は、実行全体で `raw_usage` を集計しません。保持が無効な場合、プロバイダーが使用量ペイロードを返さない場合、または上流アダプターが元のフィールド有無の情報をすでに破棄している場合、この値は `None` のままです。 +Agents SDK は、各 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 値を、そのモデル呼び出しに対するプロバイダーのペイロードから切り離された、JSON 互換のスナップショットとして保存します。Agents SDK は、実行全体で `raw_usage` を集計しません。保持が無効になっている場合、プロバイダーが使用量ペイロードを返さない場合、またはアップストリームアダプターが元のフィールド有無の情報をすでに破棄している場合、この値は `None` のままです。 -`preserve_raw_usage` は、モデルアダプターに到達した使用量ペイロードのみを保持します。この設定によって、プロバイダーへ使用量が要求されることはありません。ストリーミングの Chat Completions プロバイダーで使用量の明示的な要求が必要な場合は、`ModelSettings(include_usage=True)` も設定してください。 +`preserve_raw_usage` が保持するのは、モデルアダプターに到達した使用量ペイロードのみです。この設定によってプロバイダーに使用量がリクエストされることはありません。ストリーミングの Chat Completions プロバイダーで明示的な使用量リクエストが必要な場合は、`ModelSettings(include_usage=True)` も設定してください。 -`LitellmModel` は現在、ストリーミング実行でも非ストリーミング実行でも `ModelResponse.raw_usage` を設定しないため、`preserve_raw_usage=True` はこのアダプターでは効果がありません。`LitellmModel` を使用する場合は、引き続き正規化された [`Usage`][agents.usage.Usage] フィールドを使用してください。プロバイダー固有のフィールドの有無を確認する必要がある場合は、raw 使用量の保持をサポートするアダプターを選択してください。 +`LitellmModel` は現在、ストリーミング実行と非ストリーミング実行のどちらでも `ModelResponse.raw_usage` を設定しないため、そのアダプターでは `preserve_raw_usage=True` は効果がありません。`LitellmModel` を使用する場合は、引き続き正規化された [`Usage`][agents.usage.Usage] フィールドを使用してください。プロバイダー固有のフィールドの有無を確認する必要がある場合は、raw 使用量の保持をサポートするアダプターを選択してください。 -## セッションでの使用量へのアクセス +## セッション使用時の使用量へのアクセス -`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その実行固有の使用量が返されます。セッションはコンテキストとして会話履歴を保持しますが、各実行の使用量は独立しています。 +`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その実行に固有の使用量が返されます。セッションはコンテキスト用に会話履歴を維持しますが、各実行の使用量は独立しています。 ```python session = SQLiteSession("my_conversation") @@ -91,11 +93,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用量メトリクスは、その実行のみを表すことに注意してください。セッションでは、以前のメッセージが各実行の入力として再度渡される場合があり、その後のターンの入力トークン数に影響します。 +セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用量メトリクスは、その実行のみを表します。セッションでは、以前のメッセージが各実行への入力として再送信される場合があり、後続のターンにおける入力トークン数に影響します。 ## フックでの使用量の利用 -`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの主要な時点で使用量を記録できます。 +`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの主要な時点で使用量をログに記録できます。 ```python class MyHooks(RunHooks): @@ -106,9 +108,9 @@ class MyHooks(RunHooks): ## API リファレンス -API の詳細なドキュメントについては、以下を参照してください。 +詳細な API ドキュメントについては、以下を参照してください。 -- [`Usage`][agents.usage.Usage] - 使用量追跡のデータ構造 -- [`RequestUsage`][agents.usage.RequestUsage] - リクエストごとの使用量の詳細 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストからの使用量へのアクセス -- [`RunHooks`][agents.run.RunHooks] - 使用量追跡ライフサイクルへのフックの追加 \ No newline at end of file +- [`Usage`][agents.usage.Usage] - 使用量追跡のデータ構造 +- [`RequestUsage`][agents.usage.RequestUsage] - リクエストごとの使用量の詳細 +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストからの使用量へのアクセス +- [`RunHooks`][agents.run.RunHooks] - 使用量追跡ライフサイクルへのフック \ No newline at end of file diff --git a/docs/ko/models/index.md b/docs/ko/models/index.md index 0617239f04..fe0aa28fe7 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -11,36 +11,36 @@ Agents SDK는 다음 두 가지 방식으로 OpenAI 모델을 즉시 사용할 ## 모델 설정 선택 -설정에 맞는 가장 간단한 경로부터 시작합니다. +먼저 설정에 맞는 가장 간단한 방식을 선택합니다. -| 수행하려는 작업 | 권장 경로 | 자세히 알아보기 | +| 목표 | 권장 방식 | 자세히 알아보기 | | --- | --- | --- | | OpenAI 모델만 사용 | Responses 모델 경로와 함께 기본 OpenAI 프로바이더 사용 | [OpenAI 모델](#openai-models) | -| 웹소켓 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 웹소켓 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | -| OpenAI에서 호스팅하는 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | +| WebSocket 전송을 통해 OpenAI Responses API 사용 | Responses 모델 경로를 유지하고 WebSocket 전송 활성화 | [Responses WebSocket 전송](#responses-websocket-transport) | +| OpenAI에서 호스트하는 하위 에이전트 사용 | 실험적 호스티드 멀티 에이전트 모델 사용 | [호스티드 멀티 에이전트](#hosted-multi-agent-experimental) | | OpenAI 이외의 프로바이더 하나 사용 | 기본 제공 프로바이더 통합 지점으로 시작 | [OpenAI 이외의 모델](#non-openai-models) | -| 에이전트 간 모델 또는 프로바이더 혼합 | 실행별 또는 에이전트별로 프로바이더를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [프로바이더 간 모델 혼합](#mixing-models-across-providers) | +| 에이전트 간에 모델 또는 프로바이더 혼합 | 실행별 또는 에이전트별로 프로바이더를 선택하고 기능 차이 검토 | [하나의 워크플로에서 모델 혼합](#mixing-models-in-one-workflow) 및 [프로바이더 간 모델 혼합](#mixing-models-across-providers) | | 고급 OpenAI Responses 요청 설정 조정 | OpenAI Responses 경로에서 `ModelSettings` 사용 | [고급 OpenAI Responses 설정](#advanced-openai-responses-settings) | -| OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 출시하려는 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | +| OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 배포할 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | ## OpenAI 모델 -OpenAI만 사용하는 대부분의 앱에는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 방식을 권장합니다. +OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 방식을 권장합니다. -[`Agent`][agents.agent.Agent]가 모델을 지정하지 않으면 Agents SDK는 비용에 민감한 대규모 에이전트 워크플로를 위해 기본적으로 `reasoning.effort="none"` 및 `verbosity="low"`과 함께 [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna)를 사용합니다. 최첨단 성능이 필요한 애플리케이션은 `model="gpt-5.6-sol"`을 명시적으로 설정하고 워크로드에 적합한 `model_settings`을 선택할 수 있습니다. +[`Agent`][agents.agent.Agent]가 모델을 지정하지 않으면 Agents SDK는 비용에 민감하고 처리량이 많은 에이전트 워크플로를 위해 기본적으로 `reasoning.effort="none"` 및 `verbosity="low"`과 함께 [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna)를 사용합니다. 최첨단 성능이 필요한 애플리케이션은 `model="gpt-5.6-sol"`을 명시적으로 설정하고 워크로드에 적합한 `model_settings`을 선택할 수 있습니다. -`gpt-5.6-sol` 같은 다른 모델로 전환하려면 에이전트를 구성하는 두 가지 방법이 있습니다. +`gpt-5.6-sol` 같은 다른 모델로 전환하려는 경우 두 가지 방법으로 에이전트를 구성할 수 있습니다. ### 기본 모델 -첫째, 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정합니다. +먼저, 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정합니다. ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -둘째, `RunConfig`을 통해 실행의 기본 모델을 설정할 수 있습니다. 에이전트에 모델을 설정하지 않으면 이 실행의 모델이 사용됩니다. +두 번째로, `RunConfig`을 통해 실행의 기본 모델을 설정할 수 있습니다. 에이전트에 모델을 설정하지 않으면 해당 실행의 모델이 사용됩니다. ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 모델 -이 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`을 적용합니다. 대부분의 사용 사례에 가장 적합한 값이 설정됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`을 전달합니다. +이러한 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`을 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 사용됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`을 전달합니다. ```python from openai.types.shared import Reasoning @@ -75,7 +75,7 @@ my_agent = Agent( ) ``` -지연 시간을 줄이려면 GPT-5 모델에서 `reasoning.effort="none"`을 사용하는 것이 좋습니다. +지연 시간을 줄이려면 GPT-5 모델에 `reasoning.effort="none"`을 사용하는 것이 좋습니다. GPT-5.6은 기존 `reasoning` 설정을 통해 추론 모드, 대화 턴 간에 유지되는 추론 컨텍스트, `"max"` 수준도 지원합니다. 이러한 제어 기능은 Responses API 경로에서 사용할 수 있습니다. @@ -96,23 +96,23 @@ agent = Agent( ) ``` -`reasoning.mode` 및 `reasoning.context`은 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 인터페이스에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용합니다. Chat Completions 어댑터는 경고와 함께 모드 및 컨텍스트를 무시합니다. 해당 경고를 오류로 전환하려면 OpenAI 프로바이더에서 `strict_feature_validation=True`을 설정합니다. +`reasoning.mode` 및 `reasoning.context`은 Responses 전용 설정입니다. Chat Completions는 `reasoning.effort`만 사용하며, 지원되는 수준은 모델과 API 표면에 따라 달라집니다. GPT-5.6의 `"max"` 수준에는 Responses API를 사용합니다. Chat Completions 어댑터는 경고와 함께 모드 및 컨텍스트를 무시합니다. 이 경고를 오류로 전환하려면 OpenAI 프로바이더에서 `strict_feature_validation=True`을 설정합니다. -`context="all_turns"`을 사용할 때는 `previous_response_id`, 서버 측 Responses API 대화 또는 다음 요청에 이전 추론 항목을 포함하는 방식으로 대화를 유지합니다. 상태 비저장 `store=False` 호출의 경우 응답에서 `reasoning.encrypted_content`을 요청한 다음, 해당 추론 항목을 다음 요청의 입력으로 포함합니다. +`context="all_turns"`을 사용할 때는 `previous_response_id`, 서버 측 Responses API 대화 또는 다음 요청에 이전 추론 항목을 포함하는 방식으로 대화를 보존합니다. 상태 비저장 `store=False` 호출의 경우 응답에서 `reasoning.encrypted_content`을 요청한 다음, 다음 요청의 입력에 해당 추론 항목을 포함합니다. #### ComputerTool 모델 선택 -에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청에 적용되는 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 GA 기본 제공 `computer` 도구를 사용하는 반면, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. +에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청의 최종 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 정식 출시된 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. -프롬프트 관리형 호출은 주요 예외입니다. 프롬프트 템플릿이 모델을 지정하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트가 고정한 모델을 추측하지 않도록 미리보기 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 GA 경로를 유지하려면 요청에 `model="gpt-5.5"`을 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택기를 강제 적용합니다. +프롬프트로 관리되는 호출은 주요 예외입니다. 프롬프트 템플릿에서 모델을 지정하고 SDK가 요청에서 `model`을 생략하면, SDK는 프롬프트에 고정된 모델을 추측하지 않도록 프리뷰 호환 컴퓨터 페이로드를 기본값으로 사용합니다. 이 흐름에서 정식 출시 경로를 유지하려면 요청에 `model="gpt-5.5"`을 명시하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 정식 출시 선택기를 강제합니다. -등록된 [`ComputerTool`][agents.tool.ComputerTool]이 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`은 실제 요청 모델에 맞는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. +[`ComputerTool`][agents.tool.ComputerTool]이 등록되어 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`은 최종 요청 모델과 일치하는 기본 제공 선택기로 정규화됩니다. 등록된 `ComputerTool`이 없으면 이러한 문자열은 일반 함수 이름처럼 계속 동작합니다. -미리보기 호환 요청은 `environment`과 디스플레이 크기를 미리 직렬화해야 하므로, [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리형 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나 요청을 보내기 전에 GA 선택기를 강제 적용해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. +프리뷰 호환 요청은 `environment`과 디스플레이 크기를 미리 직렬화해야 합니다. 따라서 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나, 요청을 보내기 전에 정식 출시 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. #### GPT-5 이외의 모델 -사용자 지정 `model_settings` 없이 GPT-5가 아닌 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`으로 되돌아갑니다. +사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 범용 `ModelSettings`으로 되돌아갑니다. ### Responses 전용 도구 기능 @@ -120,14 +120,14 @@ agent = Agent( - [`ToolSearchTool`][agents.tool.ToolSearchTool] - [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 인터페이스 +- `@function_tool(defer_loading=True)` 및 기타 지연 로딩 Responses 도구 표면 - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool], `allowed_callers`, `tool_choice="programmatic_tool_calling"` -이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용하는 경우 에이전트에 `ToolSearchTool()`을 추가하고, 단순 네임스페이스 이름이나 지연 로딩 전용 함수 이름을 강제 적용하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 합니다. 설정 세부 정보와 현재 제약 조건은 [호스티드 도구 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. +이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 네임스페이스 이름이나 지연 로딩 전용 함수 이름을 직접 강제하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 합니다. 설정 세부 정보와 현재 제약 조건은 [호스티드 툴 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. ### Responses WebSocket 전송 -기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI Responses 프로바이더 경로를 사용할 때 웹소켓 전송을 선택할 수 있습니다. +기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI Responses 프로바이더 경로를 사용할 때 WebSocket 전송을 사용하도록 설정할 수 있습니다. #### 기본 설정 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 프로바이더가 모델 이름을 해석할 때 생성되는 OpenAI Responses 모델에 영향을 줍니다. 여기에는 `"gpt-5.6-sol"` 같은 문자열 모델 이름도 포함됩니다. +이는 기본 OpenAI 프로바이더가 모델 이름을 해석할 때 생성되는 OpenAI Responses 모델에 적용되며, `"gpt-5.6-sol"` 같은 문자열 모델 이름도 포함됩니다. -SDK가 모델 이름을 모델 인스턴스로 해석할 때 전송 방식이 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 웹소켓을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 유지합니다. `RunConfig(model_provider=...)`을 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식 선택을 제어합니다. +SDK가 모델 이름을 모델 인스턴스로 해석할 때 전송 방식이 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 유지합니다. `RunConfig(model_provider=...)`을 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식을 제어합니다. #### 프로바이더 또는 실행 수준 설정 -프로바이더별 또는 실행별로 웹소켓 전송을 구성할 수도 있습니다. +프로바이더별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -SDK의 OpenAI 통합을 통해 라우팅하는 프로바이더는 선택적인 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하니스 ID 같은 프로바이더 수준의 등록 메타데이터를 요구하는 경우를 위한 고급 옵션입니다. +SDK의 OpenAI 통합을 통해 라우팅하는 프로바이더는 선택적인 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID 같은 프로바이더 수준 등록 메타데이터를 요구하는 경우를 위한 고급 옵션입니다. ```python from agents import ( @@ -190,14 +190,14 @@ result = await Runner.run( #### `MultiProvider`을 사용한 고급 라우팅 -접두사 기반 모델 라우팅이 필요한 경우(예: 한 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]을 사용하고 여기에서 `openai_use_responses_websocket=True`을 설정합니다. +접두사 기반 모델 라우팅이 필요한 경우(예: 하나의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]을 사용하고 그곳에 `openai_use_responses_websocket=True`을 설정합니다. `MultiProvider`은 다음 두 가지 기존 기본 동작을 유지합니다. - `openai/...`은 OpenAI 프로바이더의 별칭으로 처리되므로 `openai/gpt-4.1`은 모델 `gpt-4.1`으로 라우팅됩니다. - 알 수 없는 접두사는 그대로 전달되지 않고 `UserError`을 발생시킵니다. -OpenAI 프로바이더가 리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 가리키도록 설정할 때는 통과 동작을 명시적으로 선택합니다. 웹소켓이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`을 유지합니다. +리터럴 네임스페이스 모델 ID를 요구하는 OpenAI 호환 엔드포인트를 OpenAI 프로바이더에 지정할 때는 통과 동작을 명시적으로 활성화합니다. WebSocket이 활성화된 설정에서는 `MultiProvider`에도 `openai_use_responses_websocket=True`을 유지합니다. ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -백엔드가 리터럴 `openai/...` 문자열을 요구할 때 `openai_prefix_mode="model_id"`을 사용합니다. 백엔드가 `openrouter/openai/gpt-4.1-mini` 같은 다른 네임스페이스 모델 ID를 요구할 때 `unknown_prefix_mode="model_id"`을 사용합니다. 이러한 옵션은 웹소켓 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 웹소켓을 활성화한 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. +백엔드에서 리터럴 `openai/...` 문자열을 요구할 때는 `openai_prefix_mode="model_id"`을 사용합니다. 백엔드에서 `openrouter/openai/gpt-4.1-mini` 같은 다른 네임스페이스 모델 ID를 요구할 때는 `unknown_prefix_mode="model_id"`을 사용합니다. 이러한 옵션은 WebSocket 전송 외부의 `MultiProvider`에서도 작동합니다. 이 예제에서는 이 섹션에서 설명하는 전송 설정의 일부이므로 WebSocket을 활성화한 상태로 유지합니다. 동일한 옵션은 [`responses_websocket_session()`][agents.responses_websocket_session]에서도 사용할 수 있습니다. `MultiProvider`을 통해 라우팅하면서 동일한 프로바이더 수준 등록 메타데이터가 필요한 경우 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`을 전달하면 기본 OpenAI 프로바이더로 전달됩니다. -사용자 지정 OpenAI 호환 엔드포인트나 프록시를 사용하는 경우 웹소켓 전송에도 호환되는 웹소켓 `/responses` 엔드포인트가 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. +사용자 지정 OpenAI 호환 엔드포인트 또는 프록시를 사용하는 경우 WebSocket 전송에는 호환되는 WebSocket `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. #### 참고 사항 -- 이는 [Realtime API](../realtime/guide.md)가 아니라 웹소켓 전송을 통한 Responses API입니다. Chat Completions에는 적용되지 않습니다. OpenAI 이외의 프로바이더에는 해당 프로바이더가 Responses 웹소켓 `/responses` 엔드포인트를 지원하는 경우에만 적용됩니다. -- 환경에 `websockets` 패키지가 아직 없다면 설치합니다. -- 웹소켓 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 직접 사용할 수 있습니다. 여러 턴과 중첩된 에이전트 도구 호출에서 동일한 웹소켓 연결을 재사용하려는 멀티턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 헬퍼를 권장합니다. [에이전트 실행](../running_agents.md) 가이드 및 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)를 참조하세요. -- 긴 추론 턴이나 지연 시간이 급증하는 네트워크에서는 `responses_websocket_options`으로 웹소켓 연결 유지 동작을 사용자 지정합니다. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping을 활성화한 상태로 하트비트 제한 시간을 비활성화하려면 `ping_timeout=None`을 설정합니다. 웹소켓 지연 시간보다 안정성이 더 중요하면 HTTP/SSE 전송을 사용하는 것이 좋습니다. -- 기본적으로 SDK는 수신 메시지 크기 제한을 비활성화합니다(`max_size=None`). 프록시 뒤에서 실행되거나 메모리가 제한된 컨테이너에 있는 장기 실행 에이전트 프로세스에서는 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`을 설정합니다. -- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며 각 연결을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 여세요. 병렬 실행이 필요하면 여러 연결을 사용합니다. -- 서비스는 연결 로컬 메모리에 가장 최근 응답만 유지합니다. 실패한 `4xx` 또는 `5xx` 턴은 `previous_response_id`이 참조하는 응답을 해당 메모리에서 제거합니다. 다시 연결한 후에도 저장된 응답이 있으면 계속 이어갈 수 있지만, `store=False` 및 ZDR 흐름에는 지속 저장된 대체 항목이 없습니다. `previous_response_id=None`으로 새 체인을 시작하고 전체 입력 컨텍스트를 전송하거나 로컬에서 관리하는 세션 상태를 바탕으로 해당 컨텍스트를 다시 구성합니다. +- 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 사용하는 Responses API입니다. Chat Completions에는 적용되지 않습니다. OpenAI 이외의 프로바이더에는 해당 프로바이더가 Responses WebSocket `/responses` 엔드포인트를 지원하는 경우에만 적용됩니다. +- 환경에 `websockets` 패키지가 아직 없으면 설치합니다. +- WebSocket 전송을 활성화한 후 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]을 직접 사용할 수 있습니다. 여러 턴과 중첩된 에이전트 도구 호출에서 동일한 WebSocket 연결을 재사용하려는 다중 턴 워크플로에는 [`responses_websocket_session()`][agents.responses_websocket_session] 헬퍼를 권장합니다. [에이전트 실행](../running_agents.md) 가이드 및 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)을 참조하세요. +- 추론 턴이 길거나 네트워크 지연이 급증하는 경우 `responses_websocket_options`로 WebSocket 연결 유지 동작을 사용자 지정합니다. 지연된 pong 프레임을 허용하려면 `ping_timeout`을 늘리거나, ping은 활성화한 상태에서 하트비트 시간 초과를 비활성화하려면 `ping_timeout=None`을 설정합니다. WebSocket 지연 시간보다 안정성이 더 중요하다면 HTTP/SSE 전송을 권장합니다. +- 기본적으로 SDK는 수신 메시지 크기 제한(`max_size=None`)을 비활성화합니다. 프록시 뒤에서 실행되거나 메모리가 제한된 컨테이너에 있는 장기 실행 에이전트 프로세스의 경우 메시지별 메모리 사용량을 제한하도록 `responses_websocket_options={"max_size": 8 * 1024 * 1024}`을 설정합니다. +- [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며, 각 연결을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 엽니다. 병렬 실행이 필요할 때는 여러 연결을 사용합니다. +- 서비스는 연결 로컬 메모리에 가장 최근 응답만 보관합니다. 실패한 `4xx` 또는 `5xx` 턴은 `previous_response_id`이 참조하는 응답을 해당 메모리에서 제거합니다. 다시 연결한 후에도 저장된 응답을 사용할 수 있으면 계속 진행할 수 있지만, `store=False` 및 ZDR 흐름에는 영구 저장된 대체 수단이 없습니다. `previous_response_id=None`로 새 체인을 시작하고 전체 입력 컨텍스트를 보내거나, 로컬에서 관리하는 세션 상태로 해당 컨텍스트를 다시 구성합니다. ### 호스티드 멀티 에이전트(실험적) -OpenAI Responses API의 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스팅되는 하위 에이전트를 생성하고 조율할 수 있습니다. Agents SDK는 일반적인 `Runner`을 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되며 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. +OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 하위 에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`을 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. -이 통합은 실험적이며 로컬 함수 출력을 `response.inject`을 사용해 활성 상태인 호스티드 에이전트로 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`을 노출하는 `openai[realtime]` 버전 2.45.0 이상의 빌드가 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. +이 통합은 실험적이며 로컬 함수 출력을 `response.inject`을 사용하여 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`을 제공하는 `openai[realtime]` 버전 2.45.0 이상의 빌드가 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. #### 모델 구성 @@ -264,7 +264,7 @@ agent = Agent( #### 로컬 함수 도구 -모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. Responses API는 어느 호스티드 에이전트가 함수를 호출할지 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 있는 `function_call_output`을 활성 WebSocket 응답에 삽입하여 서비스가 원래 호스티드 호출자를 재개할 수 있도록 합니다. 함수 실행은 계속 Runner의 일반 가드레일, 훅, 실패 변환을 통과합니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`이 아닌 함수 도구는 요청을 보내기 전에 거부됩니다. +모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. Responses API는 어떤 호스티드 에이전트가 함수를 호출할지 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 있는 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 이를 통해 서비스가 원래 호스티드 호출자를 다시 시작할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 후크 및 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`이 아닌 함수 도구는 요청이 전송되기 전에 거부됩니다. 도구에 호출자 인식 로깅 또는 권한 부여가 필요한 경우 `get_hosted_agent_metadata()`을 사용합니다. @@ -283,47 +283,47 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -호스티드 에이전트 이름은 로컬 라우팅 메커니즘이 아니라 관찰용 메타데이터입니다. SDK가 제공하는 호출 ID를 사용해 출력을 라우팅합니다. 부작용이 있는 도구의 경우 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 실행 중에 애플리케이션 코드에서 필요한 권한 부여를 적용합니다. 이 모델에서는 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. +호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공하는 호출 ID를 사용하여 출력을 라우팅합니다. 부작용이 있는 도구의 경우 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 도중에 애플리케이션 코드에서 필요한 권한 부여를 적용합니다. 이 모델에 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. #### 출력 및 스트리밍 동작 -단계가 `final_answer`인 `/root`의 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 해당 레코드를 로컬 함수로 실행하지 않습니다. +단계가 `final_answer`이며 `/root`에 귀속된 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 이러한 레코드를 로컬 함수로 실행하지 않습니다. -raw 스트리밍에서는 호스티드 출력 항목과 `response.inject.created` 확인을 포함한 베타 Responses 이벤트를 계속 노출합니다. 어댑터는 함수 호출이 준비되면 활성 프로바이더 응답 하나를 SDK에 표시되는 논리적 모델 턴으로 나눈 다음, Runner가 출력을 생성한 후 동일한 프로바이더 응답을 재개합니다. 항목이나 도구 호출이 어느 호스티드 에이전트에 귀속되는지 식별하려면 raw 호스티드 항목 또는 `ToolContext`과 함께 `get_hosted_agent_metadata()`을 사용합니다. +raw 스트리밍에서는 호스티드 출력 항목 및 `response.inject.created` 확인을 포함한 베타 Responses 이벤트가 계속 노출됩니다. 어댑터는 함수 호출이 준비되면 하나의 활성 프로바이더 응답을 SDK에 표시되는 논리적 모델 턴으로 나눈 다음, Runner가 출력을 생성하면 동일한 프로바이더 응답을 다시 시작합니다. raw 호스티드 항목 또는 `ToolContext`과 함께 `get_hosted_agent_metadata()`을 사용하여 항목이나 도구 호출이 귀속된 호스티드 에이전트를 식별합니다. #### SDK 오케스트레이션과의 관계 호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. -- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 해당 하위 에이전트를 생성하거나 예약하지 않습니다. -- SDK 핸드오프는 활성 로컬 SDK `Agent`을 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 거부됩니다. -- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. +- 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 이러한 하위 에이전트를 생성하거나 예약하지 않습니다. +- SDK 핸드오프는 활성 로컬 SDK `Agent`을 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 핸드오프가 거부됩니다. +- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용 및 도구 노출을 신중하게 평가하세요. #### 현재 제한 사항 -실험적 모델은 `reasoning.summary`, `max_tool_calls` 및 호출자가 제공한 `multi_agent` 또는 `betas` 재정의를 거부합니다. 명시적인 `context_management.compact_threshold`은 사용할 수 있지만, Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하기 때문입니다. +실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공하는 `multi_agent` 또는 `betas` 재정의를 거부합니다. Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 다만 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`을 사용할 수 있습니다. -하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단되면 `await model.close()`을 호출해 WebSocket을 해제합니다. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. +하나의 `OpenAIHostedMultiAgentModel` 인스턴스는 한 번에 최대 하나의 활성 호스티드 응답을 소유합니다. 로컬 함수 출력을 기다리는 동안 실행이 중단된 경우 `await model.close()`을 호출하여 WebSocket을 해제합니다. 진행 중인 호스티드 응답을 다른 프로세스나 이벤트 루프에서 복원하는 기능은 현재 지원되지 않습니다. -기반 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)를 참조하세요. +기본 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)을 참조하세요. ## OpenAI 이외의 모델 -OpenAI 이외의 프로바이더가 필요한 경우 SDK의 기본 제공 프로바이더 통합 지점부터 시작합니다. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 이것만으로 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. +OpenAI 이외의 프로바이더가 필요한 경우 SDK의 기본 제공 프로바이더 통합 지점으로 시작합니다. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. -### OpenAI 이외의 프로바이더 통합 방식 +### OpenAI 이외의 프로바이더 통합 방법 | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트를 대부분 또는 모든 에이전트의 기본값으로 사용해야 할 때 | 전역 기본값 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 프로바이더를 단일 실행에 적용해야 할 때 | 실행별 | -| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 다른 프로바이더 또는 구체적인 모델 객체가 필요할 때 | 에이전트별 | -| 서드 파티 어댑터 | 기본 제공 경로가 제공하지 않는 프로바이더 지원 범위 또는 라우팅이 필요할 때 | [서드 파티 어댑터](#third-party-adapters) 참조 | +| [`set_default_openai_client`][agents.set_default_openai_client] | 하나의 OpenAI 호환 엔드포인트가 대부분 또는 모든 에이전트의 기본값이어야 하는 경우 | 전역 기본값 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 하나의 사용자 지정 프로바이더를 단일 실행에 적용해야 하는 경우 | 실행별 | +| [`Agent.model`][agents.agent.Agent.model] | 에이전트마다 다른 프로바이더 또는 구체적인 모델 객체가 필요한 경우 | 에이전트별 | +| 서드 파티 어댑터 | 기본 제공 경로에서 제공하지 않는 프로바이더 지원 범위 또는 라우팅이 필요한 경우 | [서드 파티 어댑터](#third-party-adapters) 참조 | -다음과 같은 기본 제공 경로를 사용해 다른 LLM 프로바이더를 통합할 수 있습니다. +다음 기본 제공 경로를 사용하여 다른 LLM 프로바이더를 통합할 수 있습니다. -1. [`set_default_openai_client`][agents.set_default_openai_client]은 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우 유용합니다. LLM 프로바이더에 OpenAI 호환 API 엔드포인트가 있어 `base_url` 및 `api_key`을 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. -2. [`ModelProvider`][agents.models.interface.ModelProvider]은 `Runner.run` 수준에 있습니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 프로바이더 사용"을 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. +1. [`set_default_openai_client`][agents.set_default_openai_client]은 `AsyncOpenAI` 인스턴스를 LLM 클라이언트로 전역에서 사용하려는 경우 유용합니다. LLM 프로바이더에 OpenAI 호환 API 엔드포인트가 있으며 `base_url` 및 `api_key`을 설정할 수 있는 경우에 사용합니다. 구성 가능한 예제는 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)를 참조하세요. +2. [`ModelProvider`][agents.models.interface.ModelProvider]은 `Runner.run` 수준에서 사용됩니다. 이를 통해 "이 실행의 모든 에이전트에 사용자 지정 모델 프로바이더를 사용"하도록 지정할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)를 참조하세요. 3. [`Agent.model`][agents.agent.Agent.model]을 사용하면 특정 Agent 인스턴스에 모델을 지정할 수 있습니다. 이를 통해 에이전트별로 서로 다른 프로바이더를 조합할 수 있습니다. 구성 가능한 예제는 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)를 참조하세요. `platform.openai.com`의 API 키가 없는 경우 `set_tracing_disabled()`을 통해 트레이싱을 비활성화하거나 [다른 트레이싱 프로세서](../tracing.md)를 설정하는 것이 좋습니다. @@ -341,11 +341,11 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 이 예제에서는 많은 LLM 프로바이더가 아직 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 프로바이더가 Responses API를 지원한다면 Responses를 사용하는 것이 좋습니다. + 이 예제에서는 여전히 많은 LLM 프로바이더가 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 프로바이더가 Responses를 지원한다면 Responses를 사용하는 것이 좋습니다. ## 하나의 워크플로에서 모델 혼합 -단일 워크플로 내에서 에이전트마다 서로 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. +단일 워크플로 내에서 에이전트마다 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. 1. 모델 이름 전달 2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider] 전달 @@ -353,7 +353,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 및 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식이 서로 다른 기능 및 도구 집합을 지원하므로 각 워크플로에서는 단일 모델 형식을 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용하는 모든 기능이 양쪽 모두에서 제공되는지 확인하세요. + SDK는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 및 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 형식을 모두 지원하지만, 두 형식이 서로 다른 기능 및 도구 집합을 지원하므로 각 워크플로에서는 하나의 모델 형식을 사용하는 것이 좋습니다. 워크플로에서 모델 형식을 혼합해야 한다면 사용 중인 모든 기능이 두 형식에서 모두 제공되는지 확인하세요. ```python import asyncio @@ -391,7 +391,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAI 모델의 이름을 직접 설정합니다. +1. OpenAI 모델 이름을 직접 설정합니다. 2. [`Model`][agents.models.interface.Model] 구현을 제공합니다. 에이전트에 사용되는 모델을 추가로 구성하려면 temperature 같은 선택적 모델 구성 매개변수를 제공하는 [`ModelSettings`][agents.model_settings.ModelSettings]을 전달할 수 있습니다. @@ -409,21 +409,21 @@ english_agent = Agent( ## 고급 OpenAI Responses 설정 -OpenAI Responses 경로에서 더 세밀한 제어가 필요하면 `ModelSettings`부터 사용합니다. +OpenAI Responses 경로에서 더 세부적인 제어가 필요한 경우 `ModelSettings`부터 사용합니다. ### 일반적인 고급 `ModelSettings` 옵션 -OpenAI Responses API를 사용하는 경우 여러 요청 필드에 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 해당 필드에는 `extra_args`이 필요하지 않습니다. +OpenAI Responses API를 사용할 때 여러 요청 필드에는 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 해당 필드에 `extra_args`을 사용할 필요가 없습니다. -- `parallel_tool_calls`: 동일한 턴에서 여러 도구 호출을 허용하거나 금지합니다. -- `truncation`: 컨텍스트가 한도를 초과할 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 제거하도록 `"auto"`을 설정합니다. -- `store`: 생성된 응답을 나중에 조회할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로 및 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. +- `parallel_tool_calls`: 같은 턴에서 여러 도구 호출을 허용하거나 금지합니다. +- `truncation`: 컨텍스트가 초과될 때 실패하는 대신 Responses API가 가장 오래된 대화 항목을 삭제하도록 `"auto"`을 설정합니다. +- `store`: 생성된 응답을 나중에 검색할 수 있도록 서버 측에 저장할지 제어합니다. 이는 응답 ID를 사용하는 후속 워크플로와 `store=False`일 때 로컬 입력으로 대체해야 할 수 있는 세션 압축 흐름에 중요합니다. - `context_management`: `compact_threshold`을 사용하는 Responses 압축 같은 서버 측 컨텍스트 처리를 구성합니다. -- `prompt_cache_retention`: 예를 들어 `"24h"`을 사용해 이전 모델 계열의 연장된 보존 기간을 구성합니다. -- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고, GPT-5.6의 경우 `"30m"` 캐시 TTL을 구성합니다. -- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 같은 더 풍부한 응답 페이로드를 요청합니다. +- `prompt_cache_retention`: 예를 들어 `"24h"`을 사용하여 이전 모델 계열의 연장된 보존 기간을 구성합니다. +- `prompt_cache_options`: 암시적 또는 명시적 프롬프트 캐싱을 선택하고, GPT-5.6에서는 `"30m"` 캐시 TTL을 구성합니다. +- `response_include`: `web_search_call.action.sources`, `file_search_call.results`, `reasoning.encrypted_content` 같은 더 상세한 응답 페이로드를 요청합니다. - `top_logprobs`: 출력 텍스트의 상위 토큰 logprobs를 요청합니다. SDK는 `message.output_text.logprobs`도 자동으로 추가합니다. -- `retry`: 모델 호출에 대해 Runner가 관리하는 재시도 설정을 활성화합니다. [Runner 관리형 재시도](#runner-managed-retries)를 참조하세요. +- `retry`: 모델 호출에 Runner가 관리하는 재시도 설정을 사용하도록 선택합니다. [Runner 관리 재시도](#runner-managed-retries)를 참조하세요. ```python from agents import Agent, ModelSettings @@ -443,7 +443,7 @@ research_agent = Agent( ) ``` -명시적 프롬프트 캐싱에서는 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가합니다. 동일한 `ModelSettings.prompt_cache_options` 필드는 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오, 파일 콘텐츠 부분의 중단점을 유지합니다. +명시적 프롬프트 캐싱을 사용하는 경우 재사용 가능한 접두사가 끝나는 콘텐츠 부분에 중단점을 추가합니다. 동일한 `ModelSettings.prompt_cache_options` 필드가 Responses 및 Chat Completions 요청에 그대로 전달되며, Chat Completions 변환기는 텍스트, 이미지, 오디오 및 파일 콘텐츠 부분의 중단점을 보존합니다. ```python from agents import Runner @@ -471,15 +471,15 @@ result = await Runner.run( `prompt_cache_retention`은 기존 보존 제어를 사용하는 이전 모델 계열에서 계속 사용할 수 있습니다. 직접 지정한 `ModelSettings` 필드와 `extra_args`의 동일한 키를 함께 사용하지 마세요. -`store=False`을 설정하면 Responses API는 나중에 서버 측에서 조회할 수 있도록 해당 응답을 보관하지 않습니다. 이는 상태 비저장 또는 데이터 미보존 방식의 흐름에 유용하지만, 응답 ID를 재사용하는 기능이 대신 로컬에서 관리하는 상태에 의존해야 함을 의미하기도 합니다. 예를 들어 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 마지막 응답이 저장되지 않은 경우 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. +`store=False`을 설정하면 Responses API는 나중에 서버 측에서 검색할 수 있도록 해당 응답을 보관하지 않습니다. 이는 상태 비저장 또는 데이터 비보존 방식의 흐름에 유용하지만, 원래 응답 ID를 재사용하는 기능이 대신 로컬에서 관리하는 상태에 의존해야 함을 의미합니다. 예를 들어 마지막 응답이 저장되지 않은 경우 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]은 기본 `"auto"` 압축 경로를 입력 기반 압축으로 전환합니다. [세션 가이드](../sessions/index.md#openai-responses-compaction-sessions)를 참조하세요. -서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`은 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 넘으면 API가 응답의 일부로 압축 항목을 내보낼 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. +서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`은 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 초과하면 API가 응답의 일부로 압축 항목을 생성할 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. ### `extra_args` 전달 -SDK가 아직 최상위 수준에서 직접 노출하지 않는 프로바이더별 또는 최신 요청 필드가 필요할 때 `extra_args`을 사용합니다. +SDK가 아직 최상위 수준에서 직접 제공하지 않는 프로바이더별 필드 또는 최신 요청 필드가 필요할 때 `extra_args`을 사용합니다. -OpenAI 모델을 사용할 때 `extra_args`은 선택적 매개변수를 Responses API와 Chat Completions API 모두에 전달할 수 있습니다(예: `user` 및 `service_tier`). 지원되는 모델에서는 [Fast 모드](https://developers.openai.com/api/docs/guides/fast-mode)를 사용하도록 `extra_args={"service_tier": "fast"}`을 설정할 수 있으며, `"priority"`도 동일하게 동작합니다. 직접 지정한 `ModelSettings` 필드를 통해 동일한 요청 필드를 함께 설정하지 마세요. +OpenAI 모델을 사용할 때 `extra_args`은 Responses API와 Chat Completions API 모두에 선택적 매개변수(예: `user` 및 `service_tier`)를 전달할 수 있습니다. 지원되는 모델에서는 `extra_args={"service_tier": "fast"}`을 설정하여 [고속 모드](https://developers.openai.com/api/docs/guides/fast-mode)를 사용합니다. `"priority"`도 동일하게 동작합니다. 직접 지정한 `ModelSettings` 필드를 통해 동일한 요청 필드를 함께 설정하지 마세요. ```python from agents import Agent, ModelSettings @@ -495,11 +495,26 @@ english_agent = Agent( ) ``` -## Runner 관리형 재시도 +## 모델 호출 시간 초과 -재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`을 설정하고 재시도 정책에서 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. +각 모델 호출 시도의 시간을 제한하려면 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout]을 양수인 초 단위 값으로 설정합니다. 시간 초과는 스트리밍 및 비스트리밍 호출에 적용되며 전송 대기를 포함한 전체 시도를 포괄합니다. 전체 에이전트 실행, 함수 도구 실행 또는 재시도 백오프는 제한하지 않습니다. -Responses 웹소켓 전송에서 `retry_policies.provider_suggested()`은 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`이 필요하며 일반적인 재실행 안전성 검사도 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. +```python +from agents import Agent, ModelSettings + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(timeout=30.0), +) +``` + +시도가 제한 시간을 초과하면 SDK는 시도를 취소하고 정리가 완료될 때까지 기다린 후 [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]를 발생시킵니다. Runner 관리 재시도가 활성화되면 SDK는 `context.normalized.is_timeout`을 `True`으로 설정한 상태로 시간 초과 실패를 재시도 정책에 전달합니다. 예를 들어 `retry_policies.network_error()`은 이 분류와 일치합니다. 허용된 각 재시도에는 새로운 시도별 시간 초과가 적용됩니다. SDK는 재시도 전에 일반적인 [재실행 안전 규칙](#safety-boundaries)을 계속 적용합니다. + +## Runner 관리 재시도 + +재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`을 설정하고 재시도 정책이 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. + +Responses WebSocket 전송에서 `retry_policies.provider_suggested()`은 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`이 필요하며 일반적인 재실행 안전 검사도 계속 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -527,64 +542,64 @@ agent = Agent( ) ``` -`ModelRetrySettings`에는 세 개의 필드가 있습니다. +`ModelRetrySettings`에는 세 가지 필드가 있습니다.
-| 필드 | 유형 | 참고 | +| 필드 | 타입 | 참고 사항 | | --- | --- | --- | -| `max_retries` | `int | None` | 최초 요청 후 허용되는 재시도 횟수 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적인 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`은 계산된 이 백오프 지연 시간만 제한합니다. 정책에서 반환한 명시적 지연 시간이나 retry-after 힌트는 제한하지 않습니다. | +| `max_retries` | `int | None` | 최초 요청 후 허용되는 재시도 횟수입니다. | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 정책이 명시적인 지연 시간을 반환하지 않고 재시도할 때 사용하는 기본 지연 전략입니다. `backoff.max_delay`은 계산된 이 백오프 지연만 제한합니다. 정책이 반환한 명시적 지연이나 retry-after 힌트는 제한하지 않습니다. | | `policy` | `RetryPolicy | None` | 재시도 여부를 결정하는 콜백입니다. 이 필드는 런타임 전용이며 직렬화되지 않습니다. |
-재시도 정책은 다음 정보를 포함하는 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. +재시도 정책은 다음을 포함하는 [`RetryPolicyContext`][agents.retry.RetryPolicyContext]를 받습니다. -- `attempt` 및 `max_retries`: 시도 횟수를 고려한 결정을 내리는 데 사용합니다. -- `stream`: 스트리밍 및 비스트리밍 동작을 분기하는 데 사용합니다. -- `error`: raw 검사에 사용합니다. +- `attempt` 및 `max_retries`: 시도 횟수를 고려한 결정을 내릴 수 있습니다. +- `stream`: 스트리밍과 비스트리밍 동작을 분기할 수 있습니다. +- `error`: raw 데이터를 검사할 수 있습니다. - `normalized`: `status_code`, `retry_after`, `error_code`, `is_network_error`, `is_timeout`, `is_abort` 같은 정보입니다. -- `provider_advice`: 기반 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용합니다. -- `response_started`, `replay_safety`, `stateful_request`: 정책 실행 전에 캡처되는 안정적인 재실행 안전성 정보입니다. `replay_safety`은 `"safe"`, `"unsafe"`, `"unknown"` 중 하나이며, 요청이 `previous_response_id` 또는 `conversation_id`을 사용하면 `stateful_request`은 true입니다. +- `provider_advice`: 기본 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용됩니다. +- `response_started`, `replay_safety`, `stateful_request`: 정책이 실행되기 전에 캡처된 안정적인 재실행 안전 정보입니다. `replay_safety`은 `"safe"`, `"unsafe"`, `"unknown"` 중 하나입니다. 요청이 `previous_response_id` 또는 `conversation_id`을 사용하면 `stateful_request`은 true입니다. 정책은 다음 중 하나를 반환할 수 있습니다. -- 간단한 재시도 결정을 위한 `True` / `False` -- 지연 시간을 재정의하거나, 진단 사유를 첨부하거나, 범위가 제한된 안전하지 않은 재실행을 명시적으로 승인하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] +- 단순한 재시도 결정을 위한 `True` / `False` +- 지연을 재정의하거나, 진단 사유를 첨부하거나, 범위가 좁은 안전하지 않은 재실행을 명시적으로 승인하려는 경우 [`RetryDecision`][agents.retry.RetryDecision] -SDK는 `retry_policies`에서 바로 사용할 수 있는 헬퍼를 내보냅니다. +SDK는 `retry_policies`에서 즉시 사용할 수 있는 다음 헬퍼를 내보냅니다. | 헬퍼 | 동작 | | --- | --- | | `retry_policies.never()` | 항상 재시도하지 않습니다. | -| `retry_policies.provider_suggested()` | 가능한 경우 프로바이더의 재시도 권고를 따릅니다. | -| `retry_policies.network_error()` | 일시적인 전송 및 제한 시간 실패에 일치합니다. | -| `retry_policies.http_status([...])` | 선택된 HTTP 상태 코드에 일치합니다. | -| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연 시간을 사용해 재시도합니다. 이 헬퍼는 retry-after 값을 명시적 정책 지연 시간으로 처리하므로 `backoff.max_delay`이 이를 제한하지 않습니다. | +| `retry_policies.provider_suggested()` | 프로바이더의 재시도 지침이 있으면 이를 따릅니다. | +| `retry_policies.network_error()` | 일시적인 전송 및 시간 초과 실패와 일치합니다. | +| `retry_policies.http_status([...])` | 선택된 HTTP 상태 코드와 일치합니다. | +| `retry_policies.retry_after()` | retry-after 힌트가 있을 때만 해당 지연을 사용하여 재시도합니다. 이 헬퍼는 retry-after 값을 명시적 정책 지연으로 처리하므로 `backoff.max_delay`이 이를 제한하지 않습니다. | | `retry_policies.any(...)` | 중첩된 정책 중 하나라도 재시도를 선택하면 재시도합니다. | -| `retry_policies.all(...)` | 중첩된 모든 정책이 재시도를 선택할 때만 재시도합니다. | +| `retry_policies.all(...)` | 모든 중첩 정책이 재시도를 선택할 때만 재시도합니다. | -정책을 조합할 때는 `provider_suggested()`이 가장 안전한 첫 번째 기본 구성 요소입니다. 프로바이더가 거부와 재실행 안전성 승인을 구분할 수 있는 경우 이를 유지하기 때문입니다. +정책을 조합할 때 `provider_suggested()`은 프로바이더가 거부 및 재실행 안전 승인을 구분할 수 있는 경우 이를 보존하므로 가장 안전한 첫 번째 기본 구성 요소입니다. ##### 안전 경계 -일부 실패는 재시도되지 않습니다. +일부 실패는 절대 재시도되지 않습니다. - 중단 오류 - 재실행이 안전하지 않게 되는 방식으로 출력이 이미 시작된 스트리밍 실행 -- 프로바이더가 독립적으로 재실행이 안전하다고 표시하지 않은 경우, Programmatic Tool Calling 요청을 포함해 별도의 로컬 부작용 재실행 거부가 있는 요청 +- 프로바이더가 재실행을 독립적으로 안전하다고 표시하지 않은 경우, 프로그래밍 방식 도구 호출 요청을 포함하여 별도의 로컬 부작용 재실행 거부가 있는 요청 -프로바이더가 안전하지 않다고 표시한 실패도 기본적으로 차단됩니다. 별도의 로컬 부작용 거부가 없는 비스트리밍 요청의 경우 애플리케이션은 `RetryDecision(retry=True, approve_unsafe_replay=True)`을 반환하여 프로바이더 측 재실행 위험을 수용할 수 있습니다. 이 승인을 제공하기 전에 `context.response_started`, `context.replay_safety`, `context.stateful_request`을 확인하고, 프로바이더 측 작업 반복을 허용할 수 있을 때만 승인하세요. 일반적인 `RetryDecision(retry=True)`은 재실행 보호를 우회하지 않으며, `approve_unsafe_replay=True`은 스트리밍 재시도나 로컬 부작용을 승인할 수 없습니다. +프로바이더가 안전하지 않다고 표시한 실패도 기본적으로 차단됩니다. 별도의 로컬 부작용 거부가 없는 비스트리밍 요청의 경우 애플리케이션은 `RetryDecision(retry=True, approve_unsafe_replay=True)`을 반환하여 프로바이더 측 재실행 위험을 수용할 수 있습니다. 이를 승인하기 전에 `context.response_started`, `context.replay_safety`, `context.stateful_request`을 확인하고, 프로바이더 측 작업 반복이 허용되는 경우에만 승인하세요. 일반적인 `RetryDecision(retry=True)`은 재실행 보호를 우회하지 않으며, `approve_unsafe_replay=True`은 스트리밍 재시도 또는 로컬 부작용을 승인할 수 없습니다. -`previous_response_id` 또는 `conversation_id`을 사용하는 상태 유지형 후속 요청은 재실행 안전성을 알 수 없으면 안전을 위해 실패합니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 프로바이더 이외의 조건만으로는 충분하지 않습니다. 일반적으로 `retry_policies.provider_suggested()`을 통해 프로바이더의 재실행 안전 승인을 포함하거나, 위에서 설명한 대로 프로바이더가 안전하지 않다고 표시한 비스트리밍 실패를 명시적으로 승인합니다. +`previous_response_id` 또는 `conversation_id`을 사용하는 상태 유지 후속 요청은 재실행 안전 여부를 알 수 없을 때 안전을 위해 실패합니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 프로바이더 외부 조건만으로는 충분하지 않습니다. 일반적으로 `retry_policies.provider_suggested()`을 통해 프로바이더의 재실행 안전 승인을 포함하거나, 위에서 설명한 대로 프로바이더가 안전하지 않다고 표시한 비스트리밍 실패를 명시적으로 승인합니다. -##### Runner 및 에이전트 병합 동작 +##### Runner와 에이전트의 병합 동작 -`retry`은 Runner 수준과 에이전트 수준의 `ModelSettings` 간에 심층 병합됩니다. +`retry`은 Runner 수준 및 에이전트 수준 `ModelSettings` 간에 깊은 병합 방식으로 결합됩니다. -- 에이전트는 `retry.max_retries`만 재정의하면서 Runner의 `policy`을 계속 상속할 수 있습니다. -- 에이전트는 `retry.backoff`의 일부만 재정의하면서 Runner의 다른 백오프 필드를 유지할 수 있습니다. +- 에이전트는 `retry.max_retries`만 재정의하면서도 Runner의 `policy`을 상속할 수 있습니다. +- 에이전트는 `retry.backoff`의 일부만 재정의하고 Runner의 동일 수준에 있는 다른 백오프 필드를 유지할 수 있습니다. - `policy`은 런타임 전용이므로 직렬화된 `ModelSettings`은 `max_retries` 및 `backoff`을 유지하지만 콜백 자체는 생략합니다. 더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. @@ -593,22 +608,22 @@ SDK는 `retry_policies`에서 바로 사용할 수 있는 헬퍼를 내보냅니 ### 트레이싱 클라이언트 오류 401 -트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버로 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. +트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. -1. 트레이싱 완전히 비활성화: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] -2. 트레이싱용 OpenAI 키 설정: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급받아야 합니다. -3. OpenAI 이외의 트레이스 프로세서 사용. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. +1. 트레이싱을 완전히 비활성화합니다: [`set_tracing_disabled(True)`][agents.set_tracing_disabled] +2. 트레이싱용 OpenAI 키를 설정합니다: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급된 키여야 합니다. +3. OpenAI 이외의 트레이스 프로세서를 사용합니다. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. ### Responses API 지원 -SDK는 기본적으로 Responses API를 사용하지만 다른 많은 LLM 프로바이더는 아직 이를 지원하지 않습니다. 그 결과 404 또는 이와 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. +SDK는 기본적으로 Responses API를 사용하지만, 여전히 많은 다른 LLM 프로바이더가 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. 1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]을 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. ### Chat Completions 호환성 옵션 -Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, Responses API의 `prompt` 필드 또는 텍스트 전용이 아닌 도구 출력처럼 Chat Completions가 전송할 수 없는 Responses 전용 필드를 경고 없이 제거하여 호환성을 유지합니다. 개발 중에 이러한 불일치를 빠르게 실패로 처리하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화합니다. +Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, Responses API의 `prompt` 필드 또는 텍스트 전용이 아닌 도구 출력처럼 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 별도 알림 없이 삭제하여 호환성을 유지합니다. 개발 중 이러한 불일치가 즉시 실패하도록 하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화합니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -628,9 +643,9 @@ result = await Runner.run( [`MultiProvider`][agents.MultiProvider]을 사용하는 경우 대신 `openai_strict_feature_validation=True`을 전달합니다. -OpenAI Chat Completions API는 오디오 출력을 반환할 수 있지만 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 현재 오디오 출력을 Agents SDK 실행 항목으로 변환하지 않습니다. 비스트리밍 메시지나 스트리밍 델타에 오디오 출력이 포함된 경우 어댑터는 부분적이거나 빈 결과를 반환하는 대신 `AgentsException("Audio is not currently supported")`을 발생시킵니다. SDK에서 관리하는 오디오 워크플로에는 [Realtime agents](../realtime/guide.md) 또는 [음성 에이전트](../voice/quickstart.md)를 사용하세요. +OpenAI Chat Completions API는 오디오 출력을 반환할 수 있지만 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 현재 오디오 출력을 Agents SDK 실행 항목으로 변환하지 않습니다. 비스트리밍 메시지 또는 스트리밍 델타에 오디오 출력이 포함되면 어댑터는 부분 결과나 빈 결과를 반환하는 대신 `AgentsException("Audio is not currently supported")`을 발생시킵니다. SDK가 관리하는 오디오 워크플로에는 [실시간 에이전트](../realtime/guide.md) 또는 [음성 에이전트](../voice/quickstart.md)를 사용하세요. -일부 OpenAI 호환 Chat Completions 프로바이더는 증분 SDK 처리에 충분히 신뢰할 수 없는 청크로 도구 호출 델타를 스트리밍합니다. 이 경우 SDK가 프로바이더 스트림이 완료된 후에만 도구 호출을 내보내도록 스트리밍 도구 호출 버퍼링을 활성화합니다. +일부 OpenAI 호환 Chat Completions 프로바이더는 SDK가 점진적으로 처리하기에는 신뢰성이 부족한 청크로 도구 호출 델타를 스트리밍합니다. 이 경우 스트리밍 도구 호출 버퍼링을 활성화하여 프로바이더 스트림이 완료된 후에만 SDK가 도구 호출을 생성하도록 합니다. ```python from agents import OpenAIProvider @@ -641,11 +656,11 @@ provider = OpenAIProvider( ) ``` -[`MultiProvider`][agents.MultiProvider]에는 `openai_buffer_streamed_tool_calls=True`을 사용합니다. +[`MultiProvider`][agents.MultiProvider]의 경우 `openai_buffer_streamed_tool_calls=True`을 사용합니다. ### structured outputs 지원 -일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이 경우 다음과 같은 오류가 발생할 수 있습니다. +일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이 경우 다음과 유사한 오류가 발생할 수 있습니다. ``` @@ -653,42 +668,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`을 지정하도록 허용하지 않습니다. 이 문제를 해결하기 위해 노력하고 있지만, JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. +이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`을 지정할 수 없습니다. 현재 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. ## 프로바이더 간 모델 혼합 -모델 프로바이더 간 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. +모델 프로바이더 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. - 이해하지 못하는 프로바이더에 지원되지 않는 `tools`을 전송하지 마세요. - 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. -- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 유효하지 않은 JSON을 생성할 수 있다는 점에 유의하세요. +- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 유효하지 않은 JSON을 생성한다는 점에 유의하세요. ## 서드 파티 어댑터 -SDK의 기본 제공 프로바이더 통합 지점으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용합니다. 이 SDK에서 OpenAI 모델만 사용하는 경우 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델을 OpenAI 이외의 프로바이더와 결합해야 하거나 어댑터만 제공하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우에 사용합니다. 어댑터는 SDK와 업스트림 모델 프로바이더 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원과 요청 의미 체계가 프로바이더에 따라 달라질 수 있습니다. 현재 SDK에는 Any-LLM 및 LiteLLM이 최선형 베타 어댑터 통합으로 포함되어 있습니다. +SDK의 기본 제공 프로바이더 통합 지점으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용합니다. 이 SDK에서 OpenAI 모델만 사용한다면 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델과 OpenAI 이외의 프로바이더를 결합하거나, 어댑터에서만 제공하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위한 것입니다. 어댑터는 SDK와 업스트림 모델 프로바이더 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원 및 요청 의미 체계가 프로바이더마다 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선 노력 기반의 베타 어댑터 통합으로 포함되어 있습니다. ### Any-LLM -Any-LLM 지원은 Any-LLM에서 관리하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. +Any-LLM 지원은 Any-LLM에서 관리하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 노력 기반의 베타 기능으로 포함되어 있습니다. 업스트림 프로바이더 경로에 따라 Any-LLM은 Responses API, Chat Completions 호환 API 또는 프로바이더별 호환성 계층을 사용할 수 있습니다. -Any-LLM이 필요하면 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)에서 시작합니다. [`MultiProvider`][agents.MultiProvider]과 함께 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`을 사용할 수 있습니다. 모델 인터페이스를 명시적으로 고정해야 한다면 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`을 전달합니다. +Any-LLM이 필요한 경우 `openai-agents[any-llm]`을 설치한 다음 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 또는 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py)에서 시작합니다. [`MultiProvider`][agents.MultiProvider]에서 `any-llm/...` 모델 이름을 사용하거나, `AnyLLMModel`을 직접 인스턴스화하거나, 실행 범위에서 `AnyLLMProvider`을 사용할 수 있습니다. 모델 표면을 명시적으로 고정해야 한다면 `AnyLLMModel`을 생성할 때 `api="responses"` 또는 `api="chat_completions"`을 전달합니다. -Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의합니다. 업스트림 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드가 사용량 청크를 내보내기 전에 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 관련 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. +Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의됩니다. 업스트림 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드는 사용량 청크를 생성하기 전에 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 관련 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. ### LiteLLM -LiteLLM 지원은 LiteLLM 전용 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선형 베타로 제공됩니다. +LiteLLM 지원은 LiteLLM 전용 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 노력 기반의 베타 기능으로 포함되어 있습니다. -LiteLLM이 필요하면 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)에서 시작합니다. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. +LiteLLM이 필요한 경우 `openai-agents[litellm]`을 설치한 다음 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 또는 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py)에서 시작합니다. `litellm/...` 모델 이름을 사용하거나 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]을 직접 인스턴스화할 수 있습니다. -LiteLLM 어댑터를 통해 액세스하는 일부 프로바이더는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요하면 `ModelSettings(include_usage=True)`을 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. +LiteLLM 어댑터를 통해 접근하는 일부 프로바이더는 기본적으로 SDK 사용량 메트릭을 채우지 않습니다. 사용량 보고가 필요하면 `ModelSettings(include_usage=True)`을 전달하고, structured outputs, 도구 호출, 사용량 보고 또는 어댑터별 라우팅 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. -LiteLLM이 응답 객체에 대해 Pydantic 직렬화 경고를 발생시키는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 활성화할 수 있습니다. +LiteLLM이 응답 객체에 대해 Pydantic 직렬화 경고를 생성하는 경우 LiteLLM 어댑터를 가져오기 전에 SDK의 호환성 패치를 명시적으로 활성화할 수 있습니다. ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -이 패치는 기본적으로 비활성화되어 있으며 `1` 또는 `true` 값에 대해서만 활성화됩니다. 비공개 LiteLLM 로깅 헬퍼를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로 일반 직렬화 설정이 아니라 특정 문제를 위한 우회책으로 취급하세요. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고, 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file +이 패치는 기본적으로 비활성화되어 있으며 `1` 또는 `true` 값에 대해서만 활성화됩니다. 비공개 LiteLLM 로깅 헬퍼를 래핑하여 특정 유형의 LiteLLM 응답 직렬화 경고를 억제하므로 일반적인 직렬화 설정이 아닌 제한적인 해결 방법으로 취급해야 합니다. 비공개 LiteLLM API에 의존하므로 LiteLLM을 업그레이드할 때 다시 검증하고, 업스트림 경고가 더 이상 발생하지 않으면 환경 변수를 제거하세요. \ No newline at end of file diff --git a/docs/ko/realtime/guide.md b/docs/ko/realtime/guide.md index b16cb5920d..27e5d40c0b 100644 --- a/docs/ko/realtime/guide.md +++ b/docs/ko/realtime/guide.md @@ -2,23 +2,23 @@ search: exclude: true --- -# Realtime agents 가이드 +# 실시간 에이전트 가이드 -이 가이드에서는 OpenAI Agents SDK의 실시간 계층이 OpenAI Realtime API에 어떻게 매핑되는지와 파이썬 SDK가 여기에 어떤 추가 동작을 제공하는지 설명합니다. +이 가이드에서는 OpenAI Agents SDK의 실시간 계층이 OpenAI Realtime API에 어떻게 매핑되는지와 Python SDK가 여기에 어떤 추가 동작을 제공하는지 설명합니다. !!! note "여기서 시작" - 기본 파이썬 경로를 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어보세요. 애플리케이션에서 서버 측 WebSocket과 SIP 중 무엇을 사용할지 결정하는 중이라면 [실시간 전송](transport.md)을 읽어보세요. 브라우저 WebRTC 전송은 파이썬 SDK에 포함되지 않습니다. + 기본 Python 경로를 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어 보세요. 애플리케이션에서 서버 측 WebSocket과 SIP 중 무엇을 사용할지 결정하려면 [실시간 전송](transport.md)을 읽어 보세요. 브라우저 WebRTC 전송은 Python SDK에 포함되지 않습니다. ## 개요 -Realtime agents는 Realtime API와의 장기 연결을 열린 상태로 유지하므로, 모델이 텍스트와 오디오를 점진적으로 처리하고 오디오 출력을 스트리밍하며 도구를 호출하고 매 턴마다 새 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. +실시간 에이전트는 Realtime API에 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하고, 도구를 호출하며, 매 턴마다 새로운 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. 주요 SDK 구성 요소는 다음과 같습니다. -- **RealtimeAgent**: 하나의 실시간 전문 에이전트를 위한 instructions, 도구, 출력 가드레일 및 핸드오프 +- **RealtimeAgent**: 하나의 실시간 전문가를 위한 instructions, 도구, 출력 가드레일, 핸드오프 - **RealtimeRunner**: 시작 에이전트를 실시간 전송에 연결하는 세션 팩토리 -- **RealtimeSession**: 입력을 전송하고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 활성 세션 +- **RealtimeSession**: 입력을 보내고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 라이브 세션 - **RealtimeModel**: 전송 추상화입니다. 기본값은 OpenAI의 서버 측 WebSocket 구현입니다. ## 세션 수명 주기 @@ -27,25 +27,27 @@ Realtime agents는 Realtime API와의 장기 연결을 열린 상태로 유지 1. 하나 이상의 `RealtimeAgent`을 생성합니다. 2. 시작 에이전트로 `RealtimeRunner`을 생성합니다. -3. `await runner.run()`을 호출하여 `RealtimeSession`을 가져옵니다. -4. `async with session:` 또는 `await session.enter()`을 사용해 세션에 진입합니다. -5. `send_message()` 또는 `send_audio()`을 사용해 사용자 입력을 전송합니다. -6. 대화가 종료될 때까지 세션 이벤트를 순회합니다. +3. `RealtimeSession`을 가져오려면 `await runner.run()`를 호출합니다. +4. `async with session:` 또는 `await session.enter()`를 사용해 세션에 진입합니다. +5. `send_message()` 또는 `send_audio()`을 사용해 사용자 입력을 보냅니다. +6. 대화가 끝날 때까지 세션 이벤트를 순회합니다. -텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태 및 활성 에이전트 구성을 전송 계층과 동기화된 상태로 유지하는 활성 세션 객체를 반환합니다. +텍스트 전용 실행과 달리 `runner.run()`은 최종 결과를 즉시 생성하지 않습니다. 대신 로컬 기록, 백그라운드 도구 실행, 가드레일 상태, 활성 에이전트 구성을 전송 계층과 동기화하는 라이브 세션 객체를 반환합니다. -기본적으로 `RealtimeRunner`은 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 파이썬 경로는 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달해도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 방식만 달라질 수 있습니다. +기본적으로 `RealtimeRunner`은 `OpenAIRealtimeWebSocketModel`을 사용하므로 기본 Python 경로는 Realtime API에 대한 서버 측 WebSocket 연결입니다. 다른 `RealtimeModel`을 전달해도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 메커니즘만 달라질 수 있습니다. + +Realtime API 서버가 기본 WebSocket 연결을 정상적으로 종료하면 모델 전송은 `disconnected` [`RealtimeModelConnectionStatusEvent`][agents.realtime.model_events.RealtimeModelConnectionStatusEvent]를 내보낸 다음 [`RealtimeModelEndOfStreamEvent`][agents.realtime.model_events.RealtimeModelEndOfStreamEvent]를 내보냅니다. `RealtimeSession`는 두 이벤트를 모두 `raw_model_event` 내부로 전달하고, 이미 대기열에 있는 이벤트를 모두 처리한 다음 예외를 발생시키지 않고 비동기 순회를 종료합니다. 호출자가 시작한 `session.close()`은 이러한 서버 연결 해제 이벤트를 합성하지 않습니다. 예기치 않은 WebSocket 오류는 정상적인 서버 종료처럼 순회를 끝내는 대신 세션의 예외 경로를 통해 계속 처리됩니다. ## 에이전트 및 세션 구성 -`RealtimeAgent`은 의도적으로 일반 `Agent` 타입보다 범위가 좁습니다. +`RealtimeAgent`은 의도적으로 일반 `Agent` 유형보다 범위가 좁습니다. - 모델 선택은 에이전트별이 아니라 세션 수준에서 구성합니다. -- Structured outputs는 지원되지 않습니다. -- 음성을 구성할 수 있지만 세션에서 음성 오디오가 이미 생성된 후에는 변경할 수 없습니다. -- Instructions, 함수 도구, 핸드오프, 훅 및 출력 가드레일은 모두 계속 작동합니다. +- structured outputs은 지원되지 않습니다. +- 음성을 구성할 수 있지만, 세션에서 음성 오디오를 이미 생성한 후에는 변경할 수 없습니다. +- instructions, 함수 도구, 핸드오프, 훅, 출력 가드레일은 모두 계속 작동합니다. -`RealtimeSessionModelSettings`은 새로운 중첩 `audio` 구성과 이전의 평면 별칭을 모두 지원합니다. 새 코드에는 중첩 구조를 권장하며, 새로운 Realtime agents에는 `gpt-realtime-2.1`부터 사용하세요. +`RealtimeSessionModelSettings`은 새로운 중첩 `audio` 구성과 이전의 플랫 별칭을 모두 지원합니다. 새 코드에는 중첩 구조를 사용하는 것이 좋으며, 새로운 실시간 에이전트에는 `gpt-realtime-2.1`로 시작하세요. ```python runner = RealtimeRunner( @@ -87,11 +89,11 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -전체 타입 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]을 참조하세요. +전체 유형화 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]과 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]을 참조하세요. -### 입력 전사 설정 +### 입력 트랜스크립션 설정 -입력 전사는 `audio.input.transcription`에서 구성합니다. 지연 시간이 짧은 증분 전사에는 `gpt-live-transcribe`을 사용하고, 오디오 턴이 커밋된 후 전사를 시작해야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우에는 WebSocket을 통해 `gpt-transcribe`을 사용하세요. Agents SDK는 모델별 GA 전사 설정을 중첩 세션 구성에 전달합니다. +입력 트랜스크립션은 `audio.input.transcription`에서 구성합니다. 지연 시간이 짧은 증분 트랜스크립트에는 `gpt-live-transcribe`을 사용하고, 오디오 턴이 커밋된 후 트랜스크립션을 시작해야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우 WebSocket에서 `gpt-transcribe`를 사용합니다. Agents SDK는 모델별 GA 트랜스크립션 설정을 중첩된 세션 구성으로 전달합니다. ```python runner = RealtimeRunner( @@ -114,9 +116,9 @@ runner = RealtimeRunner( ) ``` -`gpt-live-transcribe`의 경우 `prompt`은 자유 형식의 녹음 컨텍스트를 제공하고, `keywords`은 오디오에 포함될 수 있는 리터럴 용어를 나열하며, `languages`는 예상 입력 언어를 나열합니다. 이 모델은 단수형 `language` 대신 복수형 `languages`을 사용합니다. 두 필드를 모두 전송하지 마세요. +`gpt-live-transcribe`의 경우 `prompt`은 자유 형식의 녹음 컨텍스트를 제공하고, `keywords`은 오디오에 포함될 수 있는 리터럴 용어를 나열하며, `languages`은 예상 입력 언어를 나열합니다. 이 모델은 단수형 `language` 대신 복수형 `languages`를 사용합니다. 두 필드를 모두 보내지 마세요. -이 SDK에 고정된 OpenAI 클라이언트 버전은 `delay`을 `gpt-realtime-whisper`과 함께 사용하는 경우에만 지원합니다. 다음과 같이 이 모델의 지연 시간과 정확도 간 절충점을 구성하세요. +이 SDK에 고정된 OpenAI 클라이언트 버전은 `delay`을 `gpt-realtime-whisper`에서만 지원합니다. 해당 모델의 지연 시간과 정확도 간 절충은 다음과 같이 구성합니다. ```python runner = RealtimeRunner( @@ -137,17 +139,17 @@ runner = RealtimeRunner( ) ``` -`delay` 설정에는 `minimal`, `low`, `medium`, `high` 또는 `xhigh`를 사용할 수 있습니다. 값이 낮으면 부분 텍스트가 더 일찍 생성될 수 있으며, 값이 높으면 전사 모델에 더 많은 오디오 컨텍스트가 제공되어 인식 정확도가 향상될 수 있습니다. 각 수준에 고정된 타이밍이 있다고 가정하지 말고 대표적인 오디오를 벤치마킹하세요. +`delay` 설정에는 `minimal`, `low`, `medium`, `high` 또는 `xhigh`을 사용할 수 있습니다. 값이 낮으면 부분 텍스트가 더 일찍 생성될 수 있고, 값이 높으면 트랜스크립션 모델에 더 많은 오디오 컨텍스트를 제공하여 인식 정확도를 높일 수 있습니다. 각 수준의 타이밍이 고정되어 있다고 가정하지 말고 대표적인 오디오로 벤치마크하세요. -전사가 커밋된 오디오 턴 이후에 시작되어야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우에만 WebSocket 기반 Realtime 세션에서 `gpt-transcribe`을 사용하세요. 모델은 이전에 전사된 턴을 자동으로 컨텍스트로 사용합니다. `gpt-transcribe` 완료 이벤트는 `languages` 출력 필드에 감지된 언어를 보고합니다. 이 출력 필드는 위에 표시된 예상 언어 입력 `gpt-live-transcribe`과 다릅니다. +WebSocket 기반 Realtime 세션에서 `gpt-transcribe`은 커밋된 오디오 턴 이후에 트랜스크립션을 시작해야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우에만 사용합니다. 모델은 이전에 트랜스크립션된 턴을 컨텍스트로 자동 사용합니다. `gpt-transcribe` 완료 이벤트는 감지된 언어를 `languages` 출력 필드에 보고합니다. 이 출력 필드는 위에 표시된 예상 언어 입력 `gpt-live-transcribe`와 다릅니다. -`audio.input.turn_detection`을 `None`로 설정하면 자동 턴 감지가 비활성화됩니다. 그러면 애플리케이션이 [수동 응답 제어](#manual-response-control)에 설명된 대로 오디오 턴을 커밋하고 응답 생성을 제어해야 합니다. 모델 동작, 검증 규칙 및 지연 시간 지침은 OpenAI API의 [실시간 전사 가이드](https://developers.openai.com/api/docs/guides/realtime-transcription)를 참조하세요. +`audio.input.turn_detection`을 `None`로 설정하면 자동 턴 감지가 비활성화됩니다. 그러면 애플리케이션이 [수동 응답 제어](#manual-response-control)에 설명된 대로 오디오 턴을 커밋하고 응답 생성을 제어해야 합니다. 모델 동작, 유효성 검사 규칙, 지연 시간 지침은 OpenAI API의 [Realtime 트랜스크립션 가이드](https://developers.openai.com/api/docs/guides/realtime-transcription)를 참조하세요. ## 입력 및 출력 ### 텍스트 및 구조화된 사용자 메시지 -일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]을 사용하세요. +일반 텍스트 또는 구조화된 Realtime 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]를 사용합니다. ```python from agents.realtime import RealtimeUserInputMessage @@ -165,31 +167,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -구조화된 메시지는 실시간 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 이러한 방식으로 `input_image` 메시지를 전달합니다. +구조화된 메시지는 Realtime 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 이 방식으로 `input_image` 메시지를 전달합니다. ### 오디오 입력 -raw 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용하세요. +가공되지 않은 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용합니다. ```python await session.send_audio(audio_bytes) ``` -서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 다음과 같은 고수준 편의 기능을 사용할 수 있습니다. +서버 측 턴 감지가 비활성화된 경우 턴 경계를 직접 표시해야 합니다. 상위 수준의 편의 기능은 다음과 같습니다. ```python await session.send_audio(audio_bytes, commit=True) ``` -더 저수준의 제어가 필요한 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 Realtime API 클라이언트 이벤트를 직접 전송할 수도 있습니다. +더 낮은 수준의 제어가 필요한 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 Realtime API 클라이언트 이벤트를 직접 보낼 수도 있습니다. ### 수동 응답 제어 -`session.send_message()`은 고수준 경로를 사용하여 사용자 입력을 전송하고 응답을 시작합니다. 일부 구성에서는 raw 오디오 버퍼링이 동일한 작업을 자동으로 수행하지 **않습니다**. +`session.send_message()`은 상위 수준 경로를 사용해 사용자 입력을 보내고 응답을 시작합니다. 일부 구성에서는 가공되지 않은 오디오 버퍼링이 동일한 동작을 자동으로 수행하지 **않습니다**. -Realtime API 수준에서 수동 턴 제어란 `turn_detection`을 `null`로 설정하는 `session.update` 이벤트를 전송한 다음, `input_audio_buffer.commit`과 `response.create`을 직접 전송하는 것을 의미합니다. +Realtime API 수준에서 수동 턴 제어는 `turn_detection`를 `null`으로 설정하는 `session.update` 이벤트를 보낸 다음, `input_audio_buffer.commit`와 `response.create`를 직접 보내는 것을 의미합니다. -턴을 수동으로 관리하는 경우 모델 전송을 통해 raw 클라이언트 이벤트를 전송할 수 있습니다. +턴을 수동으로 관리하는 경우 모델 전송을 통해 가공되지 않은 클라이언트 이벤트를 보낼 수 있습니다. ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -205,15 +207,15 @@ await session.model.send_event( 이 패턴은 다음과 같은 경우에 유용합니다. -- `turn_detection`이 비활성화되어 있고 모델의 응답 시점을 직접 결정하려는 경우 -- 응답을 트리거하기 전에 사용자 입력을 검사하거나 제한하려는 경우 -- 대역 외 응답을 위한 사용자 지정 프롬프트가 필요한 경우 +- `turn_detection`이 비활성화되어 있고 모델이 응답할 시점을 직접 결정하려는 경우 +- 응답을 트리거하기 전에 사용자 입력을 검사하거나 통제하려는 경우 +- 대역 외 응답에 사용자 지정 프롬프트가 필요한 경우 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제에서는 시작 인사말을 강제로 생성하기 위해 raw `response.create`을 사용합니다. +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제는 시작 인사말을 강제로 생성하기 위해 가공되지 않은 `response.create`을 사용합니다. ## 이벤트, 기록 및 인터럽션(중단 처리) -`RealtimeSession`은 필요할 때 raw 모델 이벤트도 계속 전달하면서 고수준 SDK 이벤트를 내보냅니다. +`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내는 동시에 필요할 때 가공되지 않은 모델 이벤트도 계속 전달합니다. 중요한 세션 이벤트는 다음과 같습니다. @@ -227,13 +229,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`과 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지 및 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 노출합니다. +UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`와 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지, 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. ### 사용량 집계 -완료된 모델 응답에 사용량이 포함된 경우 SDK의 OpenAI `RealtimeModel` 전송은 `raw_model_event` 내부에서 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]을 내보냅니다. 해당 `usage` 필드에는 그 응답의 토큰 수가 포함되며, `input_tokens_details`과 `output_tokens_details`은 선택적인 모달리티별 세부 내역을 제공합니다. +완료된 모델 응답에 사용량이 포함된 경우 SDK의 OpenAI `RealtimeModel` 전송은 `raw_model_event` 내부에서 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]를 내보냅니다. `usage` 필드에는 해당 응답의 토큰 수가 포함되며, `input_tokens_details`와 `output_tokens_details`은 선택적 모달리티별 내역을 제공합니다. -또한 세션은 각 응답의 사용량을 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. `agent_end`과 같은 후속 고수준 이벤트의 `event.info.context.usage`에서 이를 읽어 활성 세션의 누적 사용량을 확인할 수 있습니다. +또한 세션은 각 응답의 사용량을 공유 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]에 추가합니다. 라이브 세션의 누적 사용량을 확인하려면 `agent_end`과 같은 후속 상위 수준 이벤트의 `event.info.context.usage`에서 읽습니다. ```python from agents.realtime import RealtimeModelUsageEvent @@ -251,13 +253,13 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -사용량은 모델 제공자가 완료된 응답에 사용량을 포함하는 경우에만 보고됩니다. 누적 값은 해당 `RealtimeSession`이 수신한 응답을 포함하며, 여러 세션을 아우르는 합계는 아닙니다. +사용량은 모델 제공자가 완료된 응답에 사용량을 포함한 경우에만 보고됩니다. 누적 값은 해당 `RealtimeSession`이 수신한 응답에 적용되며, 여러 세션에 걸친 합계가 아닙니다. ### 인터럽션(중단 처리) 및 재생 추적 사용자가 어시스턴트를 중단하면 세션은 `audio_interrupted`을 내보내고, 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 기록을 업데이트합니다. -지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기로 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 모든 오디오를 이미 들었다고 가정하지 않고 실제 재생 위치에서 중단된 응답을 잘라내도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]을 사용하세요. +지연 시간이 짧은 로컬 재생에서는 기본 재생 추적기로 충분한 경우가 많습니다. 원격 또는 지연 재생 시나리오, 특히 전화 통신에서는 생성된 오디오를 모두 이미 들었다고 가정하지 않고 실제 재생 위치에서 중단된 응답을 잘라내도록 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker]를 사용합니다. [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)의 Twilio 예제에서 이 패턴을 확인할 수 있습니다. @@ -265,7 +267,7 @@ async for event in session: ### 함수 도구 -Realtime agents는 실시간 대화 중 함수 도구를 지원합니다. +실시간 에이전트는 라이브 대화 중 함수 도구를 지원합니다. ```python from agents.decorators import tool @@ -286,9 +288,9 @@ agent = RealtimeAgent( ### 도구 승인 -함수 도구는 실행 전에 사람의 승인을 요구할 수 있습니다. 이 경우 세션은 `tool_approval_required`을 내보내고 `approve_tool_call()` 또는 `reject_tool_call()`을 호출할 때까지 도구 실행을 일시 중지합니다. +함수 도구를 실행하기 전에 사람의 승인을 요구하도록 설정할 수 있습니다. 이 경우 세션은 `tool_approval_required`을 내보내고 `approve_tool_call()` 또는 `reject_tool_call()`을 호출할 때까지 도구 실행을 일시 중지합니다. -도구에 입력 가드레일도 있는 경우 해당 가드레일은 승인 후 실행 직전에 수행됩니다. 승인 이벤트가 발생하기 전에 가드레일을 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성하세요. 이 사전 승인 검사를 통과한 호출도 실행 전 승인 이후에 다시 검사됩니다. +도구에 입력 가드레일도 있는 경우, 승인 후 실행 직전에 해당 가드레일이 실행됩니다. 승인 이벤트가 발생하기 전에 가드레일을 실행하려면 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`로 러너를 생성합니다. 이 사전 승인 검사를 통과한 호출도 승인 후 실행 전에 다시 검사됩니다. ```python async for event in session: @@ -296,11 +298,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)을 참조하세요. 휴먼인더루프 문서의 [휴먼인더루프 (HITL)](../human_in_the_loop.md)에서도 이 흐름을 안내합니다. +구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)를 참조하세요. 휴먼인더루프 문서의 [휴먼인더루프 (HITL)](../human_in_the_loop.md)에서도 이 흐름을 안내합니다. ### 핸드오프 -실시간 핸드오프를 사용하면 한 에이전트가 활성 대화를 다른 전문 에이전트에게 전달할 수 있습니다. +Realtime 핸드오프를 사용하면 한 에이전트가 라이브 대화를 다른 전문가에게 전달할 수 있습니다. ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -322,11 +324,11 @@ main_agent = RealtimeAgent( ) ``` -핸드오프로 직접 사용되는 `RealtimeAgent` 객체는 자동으로 래핑되며, `realtime_handoff(...)`을 사용해 이름, 설명, 검증, 콜백 및 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프 `input_filter`을 지원하지 **않습니다**. +핸드오프로 직접 사용되는 `RealtimeAgent` 객체는 자동으로 래핑되며, `realtime_handoff(...)`을 사용하면 이름, 설명, 유효성 검사, 콜백, 가용성을 사용자 지정할 수 있습니다. Realtime 핸드오프는 일반 핸드오프의 `input_filter`을 지원하지 **않습니다**. ### 가드레일 -Realtime agents는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일 검사는 디바운스됩니다. 각 검사는 모든 부분 델타가 아니라 누적된 출력 텍스트 및 오디오 전사 델타에 대해 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`을 내보냅니다. +실시간 에이전트는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일 검사는 디바운스됩니다. 각 검사는 모든 부분 델타마다 실행되는 대신 누적된 출력 텍스트 및 오디오 트랜스크립트 델타를 대상으로 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`를 내보냅니다. ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -346,15 +348,15 @@ agent = RealtimeAgent( ) ``` -실시간 출력 가드레일이 오디오 전사에서 트리거되면 세션은 활성 응답을 중단하고, `response.cancel`을 강제하고, `guardrail_tripped`을 내보낸 다음, 모델이 대체 응답을 생성할 수 있도록 트리거된 가드레일의 이름을 포함하는 후속 사용자 메시지를 전송합니다. 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되어 있을 수 있으므로 오디오 플레이어는 계속 `audio_interrupted`을 수신하고 로컬 재생을 즉시 중지해야 합니다. 기본 제공 OpenAI Realtime 전송을 사용할 때 가드레일 검사가 검사 대상 응답이 종료된 후 완료되면 세션은 해당 응답의 버퍼링된 재생만 중단하며, 이후에 시작된 응답은 취소하지 않습니다. 텍스트 전용 출력에서는 대신 세션이 응답 범위의 `response.cancel`을 전송합니다. 중지할 오디오 재생이 없으므로 `audio_interrupted`은 내보내지 않습니다. 기본 제공 OpenAI Realtime 모델을 사용할 때 텍스트 전용 경로에서도 동일한 `guardrail_tripped` 이벤트와 후속 사용자 메시지가 내보내집니다. +실시간 출력 가드레일이 오디오 트랜스크립트에서 트리거되면 세션은 활성 응답을 중단하고 `response.cancel`을 강제 적용하며 `guardrail_tripped`을 내보낸 다음, 트리거된 가드레일의 이름을 포함한 후속 사용자 메시지를 보내 모델이 대체 응답을 생성하도록 합니다. 트립와이어가 작동할 때 일부 오디오가 이미 버퍼링되어 있을 수 있으므로 오디오 플레이어는 계속 `audio_interrupted`를 수신하고 로컬 재생을 즉시 중지해야 합니다. 기본 제공 OpenAI Realtime 전송을 사용할 때 가드레일 검사가 검사 대상 응답이 끝난 후 완료되면, 세션은 해당 응답의 버퍼링된 재생만 중단하고 이후에 시작된 응답은 취소하지 않습니다. 텍스트 전용 출력의 경우 세션은 대신 응답 범위가 지정된 `response.cancel`을 보냅니다. 중지할 오디오 재생이 없으므로 `audio_interrupted`는 내보내지 않습니다. 기본 제공 OpenAI Realtime 모델을 사용할 때 텍스트 전용 경로에서도 동일한 `guardrail_tripped` 이벤트와 후속 사용자 메시지가 내보내집니다. -사용자 지정 `RealtimeModel` 전송은 동일한 소스 범위 오디오 인터럽션(중단 처리) 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`과 `playback_only`을 준수해야 합니다. 또한 텍스트 전용 출력 경로의 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`을 재정의해야 합니다. 구현은 전송의 실제 이벤트 커밋 경계에서 제공된 조건을 다시 검사하거나, 조건 검사와 이벤트 커밋을 함께 직렬화해야 합니다. 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 조건을 한 번 검사한 후 이벤트를 별도로 전송하면 해당 검사와 이벤트 커밋 사이에 다른 응답이 시작될 수 있기 때문입니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. +사용자 지정 `RealtimeModel` 전송은 동일한 소스 범위 오디오 중단 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`과 `playback_only`을 준수해야 합니다. 또한 텍스트 전용 출력 경로의 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`를 재정의해야 합니다. 구현은 전송에서 실제로 이벤트를 커밋하는 경계에서 제공된 조건을 다시 검사하거나, 조건 검사와 이벤트 커밋을 함께 직렬화해야 합니다. 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 조건을 한 번 검사한 뒤 이벤트를 별도로 보내면 해당 검사와 이벤트 커밋 사이에 다른 응답이 시작될 수 있기 때문입니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. ## SIP 및 전화 통신 -파이썬 SDK는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통한 일급 SIP 연결 흐름을 포함합니다. +Python SDK는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통해 일급 SIP 연결 흐름을 제공합니다. -Realtime Calls API를 통해 통화가 수신되고 그 결과 생성된 `call_id`에 에이전트 세션을 연결하려는 경우 이를 사용하세요. +Realtime Calls API를 통해 전화가 수신되고, 그 결과 생성된 `call_id`에 에이전트 세션을 연결하려는 경우 사용합니다. ```python from agents.realtime import RealtimeRunner @@ -371,20 +373,20 @@ async with await runner.run( ... ``` -먼저 통화를 수락해야 하며 수락 페이로드를 에이전트에서 파생된 세션 구성과 일치시키려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`을 사용하세요. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. +먼저 전화를 수락해야 하고 수락 페이로드를 에이전트에서 파생된 세션 구성과 일치시키려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`을 사용합니다. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. ## 저수준 접근 및 사용자 지정 엔드포인트 -`session.model`을 통해 기본 전송 객체에 접근할 수 있습니다. +`session.model`를 통해 기본 전송 객체에 접근할 수 있습니다. -다음이 필요한 경우 사용하세요. +다음과 같은 경우에 사용합니다. - `session.model.add_listener(...)`을 통한 사용자 지정 리스너 -- `response.create` 또는 `session.update`와 같은 raw 클라이언트 이벤트 -- `model_config`을 통한 사용자 지정 `url`, `headers` 또는 `api_key` 처리 -- 기존 실시간 통화에 `call_id` 연결 +- `response.create` 또는 `session.update`과 같은 가공되지 않은 클라이언트 이벤트 +- `model_config`를 통한 사용자 지정 `url`, `headers` 또는 `api_key` 처리 +- 기존 Realtime 통화에 대한 `call_id` 연결 -`RealtimeModelConfig`은 다음을 지원합니다. +`RealtimeModelConfig`는 다음을 지원합니다. - `api_key` - `url` @@ -393,9 +395,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -이 저장소에 포함된 `call_id` 예제는 SIP입니다. 더 광범위한 Realtime API에서도 일부 서버 측 제어 흐름에 `call_id`을 사용하지만, 여기서는 이러한 흐름을 파이썬 예제로 제공하지 않습니다. +이 저장소에 포함된 `call_id` 예제는 SIP입니다. 더 광범위한 Realtime API도 일부 서버 측 제어 흐름에 `call_id`를 사용하지만, 여기에서는 Python 예제로 패키징되어 있지 않습니다. -Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적인 헤더를 전달하세요. 예를 들면 다음과 같습니다. +Azure OpenAI에 연결할 때는 GA Realtime 엔드포인트 URL과 명시적인 헤더를 전달합니다. 예시는 다음과 같습니다. ```python session = await runner.run( @@ -406,7 +408,7 @@ session = await runner.run( ) ``` -토큰 기반 인증에는 `headers`에서 bearer 토큰을 사용하세요. +토큰 기반 인증에는 `headers`에 전달자 토큰을 사용합니다. ```python session = await runner.run( @@ -417,7 +419,7 @@ session = await runner.run( ) ``` -`headers`을 전달하면 SDK는 `Authorization`을 자동으로 추가하지 않습니다. Realtime agents에서는 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. +`headers`를 전달하면 SDK가 `Authorization`를 자동으로 추가하지 않습니다. 실시간 에이전트에서 기존 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. ## 추가 자료 diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index 178b56b1da..fa200a5674 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -6,9 +6,9 @@ search: [`Runner`][agents.run.Runner] 클래스를 통해 에이전트를 실행할 수 있습니다. 다음 3가지 옵션이 있습니다. -1. [`Runner.run()`][agents.run.Runner.run]은 비동기 방식으로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]은 동기 메서드이며 내부적으로 `.run()`을 실행합니다. -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]는 비동기 방식으로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고, 수신되는 이벤트를 사용자에게 스트리밍합니다. +1. [`Runner.run()`][agents.run.Runner.run]: 비동기로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 메서드이며 내부적으로 `.run()`을 실행합니다. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고 이벤트가 수신되는 즉시 스트리밍합니다. ```python from agents import Agent, Runner @@ -25,44 +25,44 @@ async def main(): 자세한 내용은 [결과 가이드](results.md)를 참조하세요. -## 러너 수명 주기 및 구성 +## Runner 수명 주기 및 구성 ### 에이전트 루프 -위 세 가지 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. +위의 세 가지 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. - 문자열(사용자 메시지로 처리) - OpenAI Responses API 형식의 입력 항목 목록 -- 일시 중지된 실행 또는 `cancel(mode="after_turn")`으로 중단된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState]. 상태에는 [다음 재개 모델 호출을 위해 준비된 입력](results.md#add-input-before-resuming)도 포함될 수 있습니다. +- 일시 중지된 실행 또는 `cancel(mode="after_turn")`로 중단된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState]. 이 상태에는 [다음 재개 모델 호출을 위해 준비된 입력](results.md#add-input-before-resuming)도 포함할 수 있습니다. -그런 다음 러너는 다음 루프를 실행합니다. +그런 다음 Runner는 다음 루프를 실행합니다. -1. 현재 입력을 사용해 현재 에이전트에 대해 LLM을 호출합니다. +1. 현재 입력과 함께 현재 에이전트에 대해 LLM을 호출합니다. 2. LLM이 출력을 생성합니다. - 1. 러너가 LLM의 출력을 최종 출력으로 분류하면 루프를 종료하고 결과를 반환합니다. + 1. Runner가 LLM의 출력을 최종 출력으로 분류하면 루프가 종료되고 결과를 반환합니다. 2. LLM이 핸드오프를 요청하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. 3. 전달된 `max_turns`을 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. + LLM 출력이 "최종 출력"으로 간주되는 기준은 원하는 유형의 텍스트 출력을 생성하며 도구 호출이 없는 경우입니다. ### 스트리밍 -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 비롯한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`을 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 생성된 모든 새 출력을 포함한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`을 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송 방식(선택적 헬퍼) +#### Responses WebSocket 전송(선택적 헬퍼) -OpenAI Responses websocket 전송 방식을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 websocket 세션 헬퍼 사용을 권장하지만 필수는 아닙니다. +OpenAI Responses WebSocket 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 WebSocket 세션 헬퍼를 사용하는 것이 권장되지만 필수는 아닙니다. -이는 websocket 전송 방식을 사용하는 Responses API이며, [Realtime API](realtime/guide.md)가 아닙니다. +이는 WebSocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. -전송 방식 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 공급자와 관련된 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. +전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 프로바이더 관련 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 헬퍼 미사용(작동함) +##### 패턴 1: 세션 헬퍼 미사용 -websocket 전송 방식만 필요하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. +WebSocket 전송만 사용하고 SDK가 공유 프로바이더나 세션을 관리할 필요가 없을 때 사용합니다. ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 직접 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. +이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 프로바이더 인스턴스를 직접 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. ##### 패턴 2: `responses_websocket_session()` 사용(다중 턴 재사용에 권장) -여러 실행에서 websocket을 지원하는 공급자와 `RunConfig`을 공유하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`을 상속하는 중첩된 에이전트 도구 호출도 포함됩니다. +여러 실행에 걸쳐 WebSocket을 지원하는 공유 프로바이더와 `RunConfig`을 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 동일한 `run_config`을 상속하는 중첩된 에이전트-도구 호출도 포함됩니다. ```python import asyncio @@ -119,11 +119,11 @@ async def main(): asyncio.run(main()) ``` -컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 사용해야 합니다. websocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. +컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 소비하세요. WebSocket 요청이 아직 진행 중인 상태에서 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. -서비스는 각 websocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 다시 연결한 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`을 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용해 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 방식 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. +서비스는 각 WebSocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 재연결 후 `store=False` 및 ZDR 흐름에서는 캐시되지 않은 `previous_response_id`을 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 바탕으로 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. -긴 추론 턴에서 websocket keepalive 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정해 하트비트 시간 초과를 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송 방식을 사용하세요. +긴 추론 턴에서 WebSocket 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. WebSocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. ### 실행 구성 @@ -133,39 +133,39 @@ asyncio.run(main()) 각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`을 사용하세요. -##### 모델, 공급자 및 세션 기본값 +##### 모델, 프로바이더 및 세션 기본값 -- [`model`][agents.run.RunConfig.model]: 각 에이전트가 가진 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. -- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며 기본값은 OpenAI입니다. +- [`model`][agents.run.RunConfig.model]: 각 에이전트가 보유한 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. +- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하기 위한 모델 프로바이더이며 기본값은 OpenAI입니다. - [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`을 설정할 수 있습니다. -- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 검색할 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions 사용 시 각 `Runner` 실행 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. +- [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 `Runner` 실행 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. ##### 가드레일, 핸드오프 및 모델 입력 구성 - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 입력 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 손실 없이 보존되는 메시지 항목을 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화됩니다. 활성화하려면 `True`으로 설정하고, 가공되지 않은 대화 기록을 그대로 전달하려면 `False`으로 두세요. Sessions, `RunState` 및 `RunResult.to_input_list()`은 SDK 기본 중첩 기록에 이미 포함된 동일한 메시지 발생 건을 두 번 추가하지 않으면서 별개의 동일 메시지는 보존합니다. 모든 [Runner 메서드][agents.run.Runner]는 사용자가 전달하지 않으면 자동으로 `RunConfig`을 생성하므로 빠른 시작과 코드 예제에서는 기본적으로 이 기능이 비활성화되어 있으며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백이 있으면 계속해서 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`을 옵트인할 때마다 정규화된 대화 기록(기록 + 핸드오프 항목)을 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순차 요약 세그먼트를 대체하도록 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 입력 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 손실 없이 보존되는 메시지 항목을 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화되어 있습니다. 활성화하려면 `True`으로 설정하고, raw 트랜스크립트를 그대로 전달하려면 `False`으로 두세요. Sessions, `RunState`, `RunResult.to_input_list()`은 SDK 기본 중첩 기록에 이미 포함된 동일한 메시지 항목을 두 번 추가하지 않으면서도 서로 별개인 동일 메시지는 보존합니다. [Runner 메서드][agents.run.Runner]는 명시적으로 전달하지 않으면 모두 자동으로 `RunConfig`을 생성하므로 빠른 시작과 코드 예제에서는 기본값이 비활성화된 상태로 유지되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`을 옵트인할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순서형 요약 세그먼트를 대체하며, 다음 에이전트로 전달할 정확한 입력 항목 목록을 반환해야 합니다. - [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: 러너가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 유지할지 생략할지 제어합니다. +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. ##### 트레이싱 및 관측 가능성 -- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에 대한 [트레이싱](tracing.md)을 비활성화할 수 있습니다. +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에서 [트레이싱](tracing.md)을 비활성화할 수 있습니다. - [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]을 전달합니다. -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출의 입력/출력 등 잠재적으로 민감한 데이터를 포함할지 구성합니다. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출 입력/출력과 같이 잠재적으로 민감한 데이터를 포함할지 구성합니다. - [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`은 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. ##### 도구 실행, 승인 및 도구 오류 동작 -- [`tool_execution`][agents.run.RunConfig.tool_execution]: 동시에 실행되는 로컬 함수 도구 호출 수 제한과 같은 로컬 도구 호출의 SDK 측 실행 동작을 구성합니다. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않을 때 러너가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`이 발생합니다. 대신 모델에 표시되는 오류 출력을 반환하도록 옵트인할 수 있습니다. -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구와 핸드오프 이름이 충돌할 때 러너가 처리하는 방식을 구성합니다. 기본값인 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치에서 선택된 항목만 노출합니다. `"error"`은 모델이 호출되기 전에 `UserError`을 발생시킵니다. 네임스페이스가 지정된 도구와 지연 로딩 도구에 대한 엄격한 검증은 변경되지 않습니다. -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인한 도구 미발견 출력 등 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행되는 로컬 함수 도구 호출 수 제한 등 로컬 도구 호출의 SDK 측 실행 동작을 구성합니다. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 함수 도구와 일치하지 않을 때 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`이 발생합니다. 대신 모델에 표시되는 오류 출력을 반환하려면 옵트인하세요. +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구 이름과 핸드오프 이름이 충돌할 때 Runner가 처리하는 방식을 구성합니다. 기본값인 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치 대상으로 선택된 항목만 노출합니다. `"error"`은 모델 호출 전에 `UserError`을 발생시킵니다. 네임스페이스가 지정되었거나 지연 로딩되는 도구에 대한 엄격한 검증은 변경되지 않습니다. +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인된 도구 미발견 출력처럼 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. -중첩된 핸드오프는 옵트인 베타로 제공됩니다. 순차 대화 기록 압축을 활성화하려면 `RunConfig(nest_handoff_history=True)`을 전달하거나 특정 핸드오프에 대해 `handoff(..., nest_handoff_history=True)`을 설정하세요. 기본 제공 매퍼는 전체 대화 기록을 하나의 메시지로 축약하는 대신, 손실 없이 보존되는 메시지 항목 주변에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 기본값인 가공되지 않은 대화 기록을 유지하려면 플래그를 설정하지 않거나, 필요한 방식 그대로 대화를 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)을 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출합니다. +중첩된 핸드오프는 옵트인 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`을 전달하여 순서가 지정된 트랜스크립트 압축을 활성화하거나, 특정 핸드오프에서 사용하려면 `handoff(..., nest_handoff_history=True)`을 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축소하는 대신 손실 없이 보존되는 메시지 항목 주위에 생성된 어시스턴트 요약 세그먼트를 배치합니다. raw 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 대화를 필요한 방식 그대로 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)을 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출합니다. #### 실행 구성 세부 정보 @@ -192,15 +192,15 @@ result = await Runner.run( `max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에서 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 호출 수를 제한하려면 정수 값을 설정하세요. -이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]와 별개입니다. `parallel_tool_calls`은 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`은 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. +이는 프로바이더 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]과 별개입니다. `parallel_tool_calls`은 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`은 모델이 로컬 함수 도구 호출을 생성한 후 SDK가 이를 실행하는 방식을 제어합니다. -`pre_approval_tool_input_guardrails=False`은 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요한 경우 실행이 먼저 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 실행됩니다. 보류 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`으로 설정하세요. 이 승인 전 검사를 통과한 호출에도 승인 후 동일한 입력 가드레일이 다시 실행되므로, 시간에 민감한 검사는 실행 전에 다시 검증됩니다. +`pre_approval_tool_input_guardrails=False`은 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 먼저 실행이 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`으로 설정하세요. 이 사전 승인 검사를 통과한 호출에서도 승인 후 동일한 입력 가드레일이 다시 실행되므로 시간에 민감한 검사는 실행 전에 다시 검증됩니다. ##### `tool_not_found_behavior` -기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 러너는 `ModelBehaviorError`을 발생시킵니다. +기본적으로 모델이 현재 에이전트에서 사용할 수 있는 함수 도구와 일치하지 않는 함수 도구 호출을 생성하면 Runner는 `ModelBehaviorError`을 발생시킵니다. -실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서는 SDK가 해결되지 않은 도구 호출에 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 답변할 수 있습니다. +실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서 SDK는 확인되지 않은 도구 호출에 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. ```python from agents import Agent, RunConfig, Runner @@ -214,7 +214,7 @@ result = await Runner.run( ) ``` -현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 외의 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 외 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. ##### `tool_error_formatter` @@ -226,10 +226,10 @@ SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 - `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`) - `tool_name`: 도구 이름 - `call_id`: 도구 호출 ID -- `default_message`: 모델에 표시되는 SDK의 기본 메시지 +- `default_message`: SDK의 기본 모델 표시 메시지 - `run_context`: 활성 실행 컨텍스트 래퍼 -메시지를 대체할 문자열을 반환하거나, SDK 기본값을 사용하려면 `None`을 반환하세요. +메시지를 대체할 문자열을 반환하거나 SDK 기본값을 사용하려면 `None`을 반환하세요. ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,22 +256,22 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`은 러너가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행 사용 시) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. +`reasoning_item_id_policy`은 Runner가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. -- `None` 또는 `"preserve"`(기본값): 추론 항목 ID 유지 -- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID 제거 +- `None` 또는 `"preserve"`(기본값): 추론 항목 ID를 유지합니다. +- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID를 제거합니다. -주로 추론 항목이 `id`과 함께 전송되지만 필수 후속 항목(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)은 없는 경우 발생하는 Responses API 400 오류 유형을 완화하는 옵트인 방식으로 `"omit"`을 사용하세요. +추론 항목이 `id`과 함께 전송되지만 필수 후속 항목(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)이 없는 경우에 발생하는 Responses API 400 오류 유형을 옵트인 방식으로 완화하려면 주로 `"omit"`을 사용하세요. -이 문제는 다중 턴 에이전트 실행에서 SDK가 이전 출력으로 후속 입력을 구성할 때 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 이때 추론 항목 ID가 보존되지만 공급자가 해당 ID를 그에 대응하는 후속 항목과 계속 쌍으로 유지하도록 요구할 수 있습니다. +SDK가 이전 출력에서 후속 입력을 구성하는 다중 턴 에이전트 실행에서 이러한 상황이 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 추론 항목 ID는 보존되지만 프로바이더가 해당 ID와 그에 대응하는 후속 항목을 함께 유지하도록 요구하는 경우입니다. -`reasoning_item_id_policy="omit"`을 설정하면 추론 콘텐츠는 유지하되 추론 항목의 `id`은 제거하므로, SDK가 생성한 후속 입력에서 해당 API 불변 조건이 트리거되는 것을 방지할 수 있습니다. +`reasoning_item_id_policy="omit"`을 설정하면 추론 내용은 유지하지만 추론 항목의 `id`을 제거하므로 SDK가 생성한 후속 입력이 해당 API 불변 조건을 위반하지 않습니다. 적용 범위 참고 사항: - SDK가 후속 입력을 구성할 때 생성하거나 전달하는 추론 항목만 변경합니다. - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. -- 이 정책이 적용된 후에도 `call_model_input_filter`이 의도적으로 추론 ID를 다시 추가할 수 있습니다. +- `call_model_input_filter`은 이 정책이 적용된 후에도 의도적으로 추론 ID를 다시 추가할 수 있습니다. ## 상태 및 대화 관리 @@ -279,29 +279,29 @@ result = Runner.run_sync( 다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | +| 전략 | 상태가 저장되는 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | | --- | --- | --- | --- | -| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | -| `session` | 사용자 스토리지 및 SDK | 영구 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 동일한 저장소를 가리키는 다른 인스턴스 | -| `conversation_id` | OpenAI Conversations API | 여러 워커 또는 서비스에서 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`과 새 사용자 턴만 전달 | -| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리 연속 실행 | `result.last_response_id`과 새 사용자 턴만 전달 | +| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 프로바이더 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | +| `session` | 자체 스토리지 및 SDK | 지속형 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 동일한 저장소를 가리키는 다른 인스턴스 | +| `conversation_id` | OpenAI Conversations API | 여러 워커 또는 서비스에서 공유할 명명된 서버 측 대화 | 동일한 `conversation_id`과 새 사용자 턴만 전달 | +| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 처리 | `result.last_response_id`과 새 사용자 턴만 전달 | -`result.to_input_list()`과 `session`은 클라이언트에서 관리합니다. `conversation_id`과 `previous_response_id`은 OpenAI에서 관리하며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 두 계층을 의도적으로 조정하는 경우가 아니라면 클라이언트 관리 기록과 OpenAI 관리 상태를 혼합할 때 컨텍스트가 중복될 수 있습니다. +`result.to_input_list()`과 `session`은 클라이언트에서 관리됩니다. `conversation_id`과 `previous_response_id`은 OpenAI에서 관리되며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 클라이언트 관리 기록과 OpenAI 관리 상태를 함께 사용하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. !!! note - 같은 실행에서는 세션 지속성을 서버 관리 대화 설정 - (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 - 함께 사용할 수 없습니다. 호출마다 한 가지 방식을 선택하세요. + 세션 지속성은 서버 관리 대화 설정 + (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 동일한 + 실행에서 함께 사용할 수 없습니다. 호출마다 하나의 방식을 선택하세요. ### 대화/채팅 스레드 -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며, 따라서 하나 이상의 LLM 호출이 이루어질 수 있습니다. 하지만 이는 채팅 대화에서 논리적으로 하나의 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있고, 그에 따라 하나 이상의 LLM 호출이 발생할 수 있지만, 채팅 대화에서는 논리적으로 하나의 턴을 나타냅니다. 예를 들면 다음과 같습니다. -1. 사용자 턴: 사용자가 텍스트 입력 -2. 러너 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프하며, 두 번째 에이전트가 추가 도구를 실행한 다음 출력을 생성 +1. 사용자 턴: 사용자가 텍스트를 입력합니다. +2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 뒤 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트는 추가 도구를 실행한 다음 출력을 생성합니다. -에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 표시하거나 최종 출력만 표시할 수 있습니다. 어떤 방식을 사용하든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. +에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 쪽이든 사용자가 후속 질문을 할 수 있으며, 이 경우 실행 메서드를 다시 호출할 수 있습니다. #### 수동 대화 관리 @@ -327,9 +327,9 @@ async def main(): # California ``` -#### Sessions를 통한 자동 대화 관리 +#### 세션을 사용한 자동 대화 관리 -더 간단한 방법으로는 `.to_input_list()`을 수동으로 호출하지 않고도 대화 기록을 자동으로 처리하는 [Sessions](sessions/index.md)를 사용할 수 있습니다. +더 간단한 방법으로, `.to_input_list()`을 수동으로 호출하지 않고 [Sessions](sessions/index.md)를 사용하여 대화 기록을 자동으로 처리할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession, trace @@ -353,20 +353,20 @@ async def main(): # California ``` -Sessions는 다음 작업을 자동으로 수행합니다. +Sessions는 자동으로 다음 작업을 수행합니다. - 각 실행 전에 대화 기록 검색 - 각 실행 후 새 메시지 저장 -- 서로 다른 세션 ID별로 별도의 대화 유지 +- 서로 다른 세션 ID에 대해 별도 대화 유지 자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. #### 서버 관리 대화 -`to_input_list()` 또는 `Sessions`을 사용해 로컬에서 처리하는 대신, OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전 메시지를 모두 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래의 서버 관리 방식 중 하나를 사용할 때는 요청마다 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. +`to_input_list()` 또는 `Sessions`을 사용하여 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 과거의 모든 메시지를 매번 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래의 서버 관리 방식 중 하나를 사용할 때는 각 요청에 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. -OpenAI는 턴 간 상태를 추적하는 두 가지 방법을 제공합니다. +OpenAI는 턴 사이의 상태를 추적하는 두 가지 방법을 제공합니다. ##### 1. `conversation_id` 사용 @@ -418,30 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -승인을 위해 실행이 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하는 경우, SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리 대화에서 계속 진행됩니다. +실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하는 경우 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리 대화에서 계속됩니다. -`conversation_id`과 `previous_response_id`은 상호 배타적입니다. 여러 시스템에서 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`을 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`을 사용하세요. +`conversation_id`과 `previous_response_id`은 상호 배타적입니다. 여러 시스템에서 공유할 수 있는 명명된 대화 리소스가 필요하면 `conversation_id`을 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 처리 기본 구성 요소가 필요하면 `previous_response_id`을 사용하세요. !!! note - SDK는 백오프를 적용해 `conversation_locked` 오류를 자동으로 재시도합니다. 서버 관리 + SDK는 `conversation_locked` 오류를 백오프와 함께 자동으로 재시도합니다. 서버 관리 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌려 동일하게 준비된 - 항목을 문제없이 다시 전송할 수 있도록 합니다. + 항목을 다시 올바르게 전송할 수 있도록 합니다. 로컬 세션 기반 실행(`conversation_id`, `previous_response_id` 또는 - `auto_previous_response_id`과 함께 사용할 수 없음)에서도 SDK는 재시도 후 기록 항목의 - 중복을 줄이기 위해 최근에 저장된 입력 항목을 최선의 방식으로 롤백합니다. + `auto_previous_response_id`과 함께 사용할 수 없음)에서도 SDK는 최근에 지속 저장된 + 입력 항목을 최선의 방식으로 롤백하여 재시도 후 기록 항목의 중복을 줄입니다. 이 호환성 재시도는 `ModelSettings.retry`을 구성하지 않아도 수행됩니다. 모델 요청에 - 대한 더 광범위한 옵트인 재시도 동작은 [러너 관리 재시도](models/index.md#runner-managed-retries)를 참조하세요. + 대해 더 광범위한 옵트인 재시도 동작을 사용하려면 [Runner 관리 재시도](models/index.md#runner-managed-retries)를 참조하세요. ## 훅 및 사용자 지정 ### 모델 호출 입력 필터 -모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`을 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 받고 새로운 `ModelInputData`을 반환합니다. +모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`을 사용하세요. 이 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(세션 기록이 있는 경우 이를 포함)을 받고 새로운 `ModelInputData`을 반환합니다. -반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형식을 반환하면 `UserError`이 발생합니다. +반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`이 발생합니다. ```python from agents import Agent, Runner, RunConfig @@ -460,19 +460,19 @@ result = Runner.run_sync( ) ``` -러너는 준비된 입력 목록의 사본을 훅에 전달하므로 호출자의 원래 목록을 인플레이스 방식으로 변경하지 않고도 항목을 줄이거나 대체하거나 재정렬할 수 있습니다. +Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원래 목록을 인플레이스 방식으로 변경하지 않고도 목록을 줄이거나 대체하거나 순서를 변경할 수 있습니다. -세션을 사용하는 경우 `call_model_input_filter`은 세션 기록을 이미 로드하여 현재 턴과 병합한 후 실행됩니다. 이보다 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. +세션을 사용하는 경우 `call_model_input_filter`은 세션 기록이 이미 로드되어 현재 턴과 병합된 후에 실행됩니다. 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. -`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`을 통해 OpenAI 서버 관리 대화 상태를 사용하는 경우, 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록 전체를 재현하는 대신 새 턴의 델타만 나타낼 수도 있습니다. 사용자가 반환한 항목만 해당 서버 관리 연속 실행에서 전송된 것으로 표시됩니다. +`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`을 사용하여 OpenAI 서버 관리 대화 상태를 사용하는 경우 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록의 전체 재전송이 아니라 새 턴의 델타만 이미 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리 연속 처리에 전송된 것으로 표시됩니다. -민감한 데이터를 삭제하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 삽입하려면 `run_config`을 통해 실행별로 훅을 설정하세요. +민감한 데이터를 수정하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 삽입하려면 `run_config`을 통해 실행별로 훅을 설정하세요. ## 오류 및 복구 -### 오류 핸들러 +### 오류 처리기 -모든 `Runner` 진입점은 오류 종류를 키로 사용하는 딕셔너리인 `error_handlers`을 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"` 및 `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. +모든 `Runner` 진입점은 오류 종류를 키로 사용하는 dict인 `error_handlers`을 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"`, `"invalid_final_output"`입니다. 실행을 해당 오류로 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. ```python from agents import ( @@ -501,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -모델 메시지가 에이전트의 구조화된 `output_type`에 대해 검증되지 않거나 모델이 구조화된 최종 메시지를 반환하지 않을 때 `"invalid_final_output"`을 사용하세요. 핸들러는 애플리케이션별 대체 값을 반환할 수 있으며, SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지는 않습니다. `None`을 반환하면 복구를 거부합니다. 대체 값이 없으면 비어 있지 않은 검증 실패는 계속해서 `ModelBehaviorError`을 발생시키고, 비어 있는 구조화된 응답은 기존의 다음 턴 동작을 유지합니다. +모델 메시지가 에이전트의 structured `output_type`에 대해 검증되지 않거나 모델이 structured 최종 메시지를 반환하지 않는 경우 `"invalid_final_output"`을 사용하세요. 처리기는 애플리케이션별 대체 값을 반환할 수 있으며, SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지는 않습니다. `None`을 반환하면 복구를 거부합니다. 대체 값이 없으면 비어 있지 않은 검증 실패에서 계속 `ModelBehaviorError`이 발생하며, 비어 있는 structured 응답은 기존의 다음 턴 동작을 유지합니다. ```python from pydantic import BaseModel @@ -533,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 수 핸들러에서는 합성된 대체 출력을 대화 기록에 추가하고 구성된 세션에 저장합니다. 결과 기록이나 세션 스토리지에 추가하지 않고 대체 출력을 호출자에게 반환하려면 `include_in_history=False`을 설정하세요. +`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 처리기에서는 합성된 대체 출력을 대화 기록에 추가하고 구성된 세션에 지속 저장합니다. 대체 출력을 결과 기록이나 세션 저장소에 추가하지 않고 호출자에게 반환하려면 `include_in_history=False`으로 설정하세요. -모델의 거부로 실행을 `ModelRefusalError`과 함께 종료하는 대신 애플리케이션별 대체 출력을 생성하려면 `"model_refusal"`을 사용하세요. +모델의 거부가 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 출력을 생성하도록 하려면 `"model_refusal"`을 사용하세요. ```python from pydantic import BaseModel @@ -567,35 +567,36 @@ result = Runner.run_sync( print(result.final_output) ``` -## 내구성 실행 통합 및 휴먼인더루프 (HITL) +## 내구성 있는 실행 통합 및 휴먼인더루프 (HITL) -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참조하세요. 아래 통합은 실행이 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 가이드](human_in_the_loop.md)에서 시작하세요. 아래 통합은 실행이 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. ### Dapr -Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 실패 시 자동으로 복구되고 휴먼인더루프 (HITL) 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급자 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. +Agents SDK의 [Dapr](https://dapr.io) Diagrid 통합을 사용하면 장애에서 자동으로 복구되고 휴먼인더루프 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 특정 공급업체에 종속되지 않는 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. ### Temporal -Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함해 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 작동하여 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 볼 수 있습니다. +Agents SDK의 [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 작업을 포함한 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. ### Restate -Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk) 또는 [문서](https://docs.restate.dev/ai)를 참조하세요. +Agents SDK의 [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 실행할 수 있습니다. 이 통합에는 Restate의 단일 바이너리 런타임이 종속성으로 필요하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)를 읽거나 [문서](https://docs.restate.dev/ai)를 참조하세요. ### DBOS -Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 실패 및 재시작 후에도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. +Agents SDK의 [DBOS](https://dbos.dev/) 통합을 사용하면 장애와 재시작이 발생해도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [저장소](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. ## 예외 -SDK는 특정 경우에 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에 있습니다. 개요는 다음과 같습니다. - -- [`AgentsException`][agents.exceptions.AgentsException]: SDK가 발생시키는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 유형입니다. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과하면 발생합니다. 지정된 에이전트 루프 턴 수(LLM 호출 횟수) 내에 에이전트가 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`을 설정하세요. -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상하지 못했거나 유효하지 않은 출력을 생성할 때 발생합니다. 여기에는 다음이 포함될 수 있습니다. - - 잘못된 형식의 JSON: 모델이 도구 호출이나 직접 출력에서 잘못된 형식의 JSON 구조를 제공하는 경우. 특히 특정 `output_type`이 정의된 경우 - - 예상하지 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못한 경우 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생합니다. -- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성하는 사람인 사용자가 SDK 사용 중 오류를 범하면 발생합니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API 오용으로 인해 발생합니다. -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 입력 가드레일 조건이 충족되면 `InputGuardrailTripwireTriggered`이 발생하고, 출력 가드레일 조건이 충족되면 `OutputGuardrailTripwireTriggered`이 발생합니다. 입력 가드레일은 처리 전에 들어오는 메시지를 확인하며, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 확인합니다. \ No newline at end of file +SDK는 특정한 경우 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. + +- [`AgentsException`][agents.exceptions.AgentsException]: SDK가 발생시키는 모든 예외의 기본 클래스입니다. 다른 모든 특정 예외가 파생되는 일반 유형입니다. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과하면 이 예외가 발생합니다. 이는 지정된 에이전트 루프 턴(LLM 호출) 횟수 내에 에이전트가 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`으로 설정하세요. +- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]: 모델 호출 시도가 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout]을 초과하면 이 예외가 발생합니다. 적용 범위와 재시도 동작은 [모델 호출 시간 초과](models/index.md#model-call-timeouts)를 참조하세요. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상치 못하거나 잘못된 출력을 생성할 때 이 예외가 발생합니다. 다음 경우가 포함될 수 있습니다. + - 잘못된 형식의 JSON: 모델이 도구 호출 또는 직접 출력에서 잘못된 JSON 구조를 제공하는 경우이며, 특히 특정 `output_type`이 정의되어 있을 때 발생합니다. + - 예상치 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못하는 경우 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 도구에서 `timeout_behavior="raise_exception"`을 사용하는 경우 이 예외가 발생합니다. +- [`UserError`][agents.exceptions.UserError]: SDK를 사용해 코드를 작성하는 사람이 SDK 사용 중 오류를 범하면 이 예외가 발생합니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 오용으로 인해 발생합니다. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 입력 가드레일의 조건이 충족되면 `InputGuardrailTripwireTriggered`이 발생하고, 출력 가드레일의 조건이 충족되면 `OutputGuardrailTripwireTriggered`이 발생합니다. 입력 가드레일은 처리 전에 수신 메시지를 확인하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 확인합니다. \ No newline at end of file diff --git a/docs/ko/sandbox/clients.md b/docs/ko/sandbox/clients.md index ccd68d0839..db4f3c330f 100644 --- a/docs/ko/sandbox/clients.md +++ b/docs/ko/sandbox/clients.md @@ -4,21 +4,21 @@ search: --- # 샌드박스 클라이언트 -이 페이지를 사용하여 샌드박스 작업을 실행할 위치를 선택합니다. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. +이 페이지에서 샌드박스 작업을 실행할 위치를 선택합니다. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고, [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 향후 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 예정입니다. -## 선택 가이드 +## 의사 결정 가이드
-| 목표 | 시작 항목 | 이유 | +| 목표 | 시작점 | 이유 | | --- | --- | --- | -| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치 없이 간단하게 로컬 파일 시스템에서 개발할 수 있습니다. | -| 기본적인 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지를 사용하는 Docker 내부에서 작업을 실행합니다. | -| 호스티드 실행 또는 프로덕션 환경 수준의 격리 | 호스티드 샌드박스 클라이언트 | 작업 공간 경계를 공급자가 관리하는 환경으로 이동합니다. | +| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치가 필요 없으며, 로컬 파일 시스템에서 간단하게 개발할 수 있습니다. | +| 기본적인 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지가 적용된 Docker 내부에서 작업을 실행합니다. | +| 호스티드 실행 또는 프로덕션 방식의 격리 | 호스티드 샌드박스 클라이언트 | 작업 공간 경계를 공급자가 관리하는 환경으로 이동합니다. |
@@ -28,18 +28,18 @@ search:
-| 클라이언트 | 설치 | 선택이 적합한 경우 | 예제 | +| 클라이언트 | 설치 | 선택하는 경우 | 예제 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠르게 로컬 반복 개발을 수행하려는 경우입니다. 로컬 개발에 적합한 기본 선택입니다. | [Unix-local 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `UnixLocalSandboxClient` | 없음 | macOS 또는 Linux에서 가장 빠른 로컬 반복 개발이 필요한 경우입니다. 로컬 개발에 적합한 기본 옵션입니다. | [Unix 로컬 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | | `DockerSandboxClient` | `openai-agents[docker]` | 컨테이너 격리가 필요하거나 대상 환경을 로컬에서 재현하기 위해 특정 이미지를 사용하려는 경우입니다. | [Docker 시작 예제](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
-Unix-local은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리나 프로덕션 환경과의 동등성이 필요하면 Docker 또는 호스티드 공급자로 전환합니다. +Unix 로컬은 로컬 파일 시스템을 대상으로 개발을 시작하는 가장 쉬운 방법입니다. 더 강력한 환경 격리 또는 프로덕션 방식과의 동등성이 필요하면 Docker나 호스티드 공급자로 전환합니다. -`SandboxPathGrant.host_path`은 Docker 전용이며 호스트 경로를 컨테이너 내부의 다른 POSIX 경로에 매핑합니다. Unix-local은 동일 경로 허용만 지원합니다. 자세한 내용은 [매니페스트 경로 허용](guide.md#manifest)을 참조하세요. +`SandboxPathGrant.host_path`은 Docker에서만 사용할 수 있으며 호스트 경로를 컨테이너 내부의 다른 POSIX 경로에 매핑합니다. Unix 로컬은 동일 경로 허용만 지원합니다. 자세한 내용은 [매니페스트 경로 허용](guide.md#manifest)을 참조하세요. -Unix-local에서 Docker로 전환하려면 에이전트 정의는 그대로 유지하고 실행 구성만 변경합니다. +Unix 로컬에서 Docker로 전환하려면 에이전트 정의는 그대로 유지하고 실행 구성만 변경합니다. ```python from docker import from_env as docker_from_env @@ -56,7 +56,20 @@ run_config = RunConfig( ) ``` -컨테이너 격리가 필요하거나 샌드박스 이미지를 다른 환경에서 사용하는 이미지와 일치시키려면 이 방식을 사용합니다. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. +컨테이너 격리가 필요하거나 샌드박스 이미지를 다른 환경에서 사용하는 이미지와 일치시키려는 경우 이 방법을 사용합니다. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. + +### Docker 네트워킹 비활성화 + +Docker 샌드박스에서 네트워크 액세스를 차단해야 하는 경우 `network_mode="none"`을 설정합니다. + +```python +options = DockerSandboxClientOptions( + image="python:3.14-slim", + network_mode="none", +) +``` + +명시적으로 지원되는 유일한 네트워크 모드는 `"none"`입니다. Docker의 기본 동작을 유지하려면 `network_mode`을 생략합니다. 네트워크가 비활성화된 샌드박스는 포트를 노출할 수 없으므로 `network_mode="none"`과 비어 있지 않은 `exposed_ports` 튜플을 함께 사용하면 옵션 검증 중 실패합니다. 이 설정은 샌드박스 세션 상태에 저장되며, SDK가 해당 상태를 재개하는 동안 대체 컨테이너를 생성해야 하는 경우 다시 적용됩니다. ## 마운트 및 원격 스토리지 @@ -64,33 +77,33 @@ run_config = RunConfig( 일반적인 마운트 옵션은 다음과 같습니다. -- `mount_path`: 샌드박스에서 스토리지가 표시되는 위치입니다. 상대 경로는 매니페스트 루트를 기준으로 해석되며, 절대 경로는 그대로 사용됩니다. -- `read_only`: 기본값은 `True`입니다. 샌드박스에서 마운트된 스토리지에 변경 사항을 다시 기록해야 하는 경우에만 `False`으로 설정합니다. -- `mount_strategy`: 필수 항목입니다. 마운트 항목과 샌드박스 백엔드 모두에 맞는 전략을 사용합니다. +- `mount_path`: 스토리지가 샌드박스에 표시되는 위치입니다. 상대 경로는 매니페스트 루트를 기준으로 해석되며, 절대 경로는 그대로 사용됩니다. +- `read_only`: 기본값은 `True`입니다. 샌드박스에서 마운트된 스토리지에 변경 사항을 다시 기록해야 하는 경우에만 `False`을 설정합니다. +- `mount_strategy`: 필수입니다. 마운트 항목과 샌드박스 백엔드 모두에 맞는 전략을 사용합니다. -마운트는 임시 작업 공간 항목으로 취급됩니다. 스냅샷 및 영속성 흐름에서는 마운트된 원격 스토리지를 저장된 작업 공간에 복사하는 대신 마운트된 경로를 분리하거나 건너뜁니다. +마운트는 임시 작업 공간 항목으로 취급됩니다. 스냅샷 및 지속성 흐름에서는 마운트된 원격 스토리지를 저장된 작업 공간으로 복사하는 대신 마운트된 경로를 분리하거나 건너뜁니다. 범용 로컬/컨테이너 전략은 다음과 같습니다.
-| 전략 또는 패턴 | 사용이 적합한 경우 | 참고 사항 | +| 전략 또는 패턴 | 사용하는 경우 | 참고 사항 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone`을 실행할 수 있는 경우입니다. | S3, GCS, R2, Azure Blob, Box를 지원합니다. `RcloneMountPattern`은 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3`이 있으며 Mountpoint 방식의 S3 또는 S3 호환 액세스를 사용하려는 경우입니다. | `S3Mount`와 `GCSMount`을 지원합니다. | -| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2`와 FUSE 지원이 있는 경우입니다. | `AzureBlobMount`을 지원합니다. | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 샌드박스 이미지에서 `rclone`을 실행할 수 있는 경우입니다. | S3, GCS, R2, Azure Blob 및 Box를 지원합니다. `RcloneMountPattern`는 `fuse` 모드 또는 `nfs` 모드로 실행할 수 있습니다. | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 이미지에 `mount-s3`이 있으며 Mountpoint 방식의 S3 또는 S3 호환 액세스를 사용하려는 경우입니다. | `S3Mount` 및 `GCSMount`을 지원합니다. | +| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 이미지에 `blobfuse2` 및 FUSE 지원이 있는 경우입니다. | `AzureBlobMount`을 지원합니다. | | `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 이미지에 `mount.s3files`이 있으며 기존 S3 Files 마운트 대상에 연결할 수 있는 경우입니다. | `S3FilesMount`를 지원합니다. | -| `DockerVolumeMountStrategy(driver=...)` | 컨테이너가 시작되기 전에 Docker가 볼륨 드라이버 기반 마운트를 연결해야 하는 경우입니다. | Docker 전용입니다. S3, GCS, R2, Azure Blob, Box는 `rclone`을 통해 마운트할 수 있으며, S3와 GCS는 `mountpoint`를 통해서도 마운트할 수 있습니다. | +| `DockerVolumeMountStrategy(driver=...)` | 컨테이너가 시작되기 전에 Docker가 볼륨 드라이버 기반 마운트를 연결해야 하는 경우입니다. | Docker 전용입니다. S3, GCS, R2, Azure Blob 및 Box는 `rclone`을 통해 마운트할 수 있으며, S3와 GCS는 `mountpoint`을 통해서도 마운트할 수 있습니다. |
## 지원되는 호스티드 플랫폼 -호스티드 환경이 필요한 경우에는 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트만 변경합니다. +호스티드 환경이 필요한 경우 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트만 변경합니다. -이 저장소의 체크아웃 대신 배포된 SDK를 사용하는 경우 해당 패키지 extra를 통해 샌드박스 클라이언트 종속성을 설치합니다. +이 저장소의 체크아웃 대신 배포된 SDK를 사용하는 경우 일치하는 패키지 extra를 통해 샌드박스 클라이언트 종속성을 설치합니다. -저장소에 포함된 확장 코드 예제의 공급자별 설정 참고 사항과 링크는 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)를 참조하세요. +저장소에 포함된 확장 예제의 공급자별 설정 참고 사항과 링크는 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)를 참조하세요.
@@ -106,28 +119,44 @@ run_config = RunConfig(
-호스티드 샌드박스 클라이언트는 공급자별 마운트 전략을 제공합니다. 스토리지 공급자에 가장 적합한 백엔드와 마운트 전략을 선택하세요. +### Modal 샌드박스 크기 지정 + +새 Modal 샌드박스의 리소스를 요청하려면 `ModalSandboxClientOptions.cpu`와 `ModalSandboxClientOptions.memory`를 사용합니다. 단일 값은 해당 양을 요청합니다. 항목이 두 개인 `(request, limit)` 튜플에서는 첫 번째 항목을 요청값으로, 두 번째 항목을 제한값으로 사용합니다. 메모리 값의 단위는 MiB입니다. + +```python +from agents.extensions.sandbox import ModalSandboxClientOptions + +options = ModalSandboxClientOptions( + app_name="agents-sandbox", + cpu=(1.0, 4.0), + memory=(2048, 8192), +) +``` + +생략된 각 리소스에 Modal의 기본값을 사용하려면 `cpu`, `memory` 또는 둘 다를 `None`으로 둡니다. 선택한 값은 샌드박스 세션 상태에 유지되므로 대체 샌드박스에서도 동일한 리소스 구성을 사용합니다. + +호스티드 샌드박스 클라이언트는 공급자별 마운트 전략을 제공합니다. 스토리지 공급자에 가장 적합한 백엔드와 마운트 전략을 선택합니다.
| 백엔드 | 마운트 참고 사항 | | --- | --- | -| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy`과 같은 로컬 전략을 사용하여 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount`를 지원합니다. | -| `ModalSandboxClient` | `ModalCloudBucketMountStrategy`을 `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount`과 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | -| `CloudflareSandboxClient` | `CloudflareBucketMountStrategy`을 `S3Mount`, `R2Mount`, HMAC 인증 방식의 `GCSMount`과 함께 사용하여 버킷 마운트를 지원합니다. | -| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy`을 `S3Mount`, `R2Mount`, `GCSMount` 항목 중 하나와 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 또한 `BlaxelDriveMount`와 `BlaxelDriveMountStrategy`을 통해 영속적인 Blaxel Drives를 지원하며, 둘 다 `agents.extensions.sandbox.blaxel`에서 사용할 수 있습니다. | -| `DaytonaSandboxClient` | `rclone`을 통해 `DaytonaCloudBucketMountStrategy`을 사용하여 클라우드 스토리지 마운트를 지원합니다. 이를 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`와 함께 사용합니다. | -| `E2BSandboxClient` | `rclone`를 통해 `E2BCloudBucketMountStrategy`를 사용하여 클라우드 스토리지 마운트를 지원합니다. 이를 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | -| `RunloopSandboxClient` | `rclone`를 통해 `RunloopCloudBucketMountStrategy`을 사용하여 클라우드 스토리지 마운트를 지원합니다. 이를 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`과 함께 사용합니다. | -| `VercelSandboxClient` | `VercelCloudBucketMountStrategy`을 `S3Mount` 항목과 함께 사용하여 생성 시점에만 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True`가 필요합니다. | +| Docker | `InContainerMountStrategy` 및 `DockerVolumeMountStrategy`과 같은 로컬 전략을 통해 `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount` 및 `S3FilesMount`를 지원합니다. | +| `ModalSandboxClient` | `ModalCloudBucketMountStrategy`를 `S3Mount`, `R2Mount` 및 HMAC 인증을 사용하는 `GCSMount`와 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 인라인 자격 증명 또는 이름이 지정된 Modal Secret을 사용할 수 있습니다. | +| `CloudflareSandboxClient` | `CloudflareBucketMountStrategy`를 `S3Mount`, `R2Mount` 및 HMAC 인증을 사용하는 `GCSMount`과 함께 사용하여 버킷 마운트를 지원합니다. | +| `BlaxelSandboxClient` | `BlaxelCloudBucketMountStrategy`를 `S3Mount`, `R2Mount` 또는 `GCSMount` 항목과 함께 사용하여 클라우드 버킷 마운트를 지원합니다. 또한 `BlaxelDriveMount` 및 `BlaxelDriveMountStrategy`를 통해 영구 Blaxel Drives를 지원하며, 둘 다 `agents.extensions.sandbox.blaxel`에서 사용할 수 있습니다. | +| `DaytonaSandboxClient` | `DaytonaCloudBucketMountStrategy`을 사용하여 `rclone`을 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount` 및 `BoxMount`과 함께 사용합니다. | +| `E2BSandboxClient` | `E2BCloudBucketMountStrategy`을 사용하여 `rclone`를 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount` 및 `BoxMount`과 함께 사용합니다. | +| `RunloopSandboxClient` | `RunloopCloudBucketMountStrategy`를 사용하여 `rclone`을 통한 클라우드 스토리지 마운트를 지원합니다. `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount` 및 `BoxMount`와 함께 사용합니다. | +| `VercelSandboxClient` | `VercelCloudBucketMountStrategy`을 `S3Mount` 항목과 함께 사용하여 생성 시점에만 S3 및 S3 호환 버킷 마운트를 지원합니다. 마운트된 세션은 재개할 수 없으며, 인라인 자격 증명을 사용하려면 `allow_s3_credential_exposure=True`이 필요합니다. |
-마운트 표에는 각 백엔드에서 실행할 수 있는 스토리지 유형이 설명되어 있습니다. 체크 표시는 모델이 제어하는 샌드박스 내부에서 실행되는 마운트 헬퍼의 자격 증명 경계를 우회하지 않으며, 모든 전략이 자격 증명 없이 작동할 수 있다는 의미도 아닙니다. 선택한 헬퍼가 보호된 권한 없이 작동할 수 있는 경우에만 Agents SDK는 승인 없이 컨테이너 내부 마운트를 허용합니다. 보호된 권한이 필요한 마운트는 신뢰할 수 있는 애플리케이션 코드가 해당 마운트 경로의 노출을 명시적으로 승인하지 않는 한 샌드박스 또는 마운트 헬퍼를 시작하기 전에 거부됩니다. +마운트 표는 각 백엔드에서 실행할 수 있는 스토리지 유형을 설명합니다. 체크 표시는 모델이 제어하는 샌드박스 내부에서 실행되는 마운트 도우미의 자격 증명 경계를 우회하지 않으며, 모든 전략이 자격 증명 없이 작동할 수 있다는 의미도 아닙니다. Agents SDK는 선택한 도우미가 보호된 권한 없이 작동할 수 있는 경우에만 별도의 확인 없이 컨테이너 내부 마운트를 허용합니다. 보호된 권한이 필요한 마운트는 신뢰할 수 있는 애플리케이션 코드에서 정확한 마운트 경로의 노출을 명시적으로 확인하지 않는 한 샌드박스 또는 마운트 도우미를 시작하기 전에 거부됩니다. -자격 증명이 없는 `rclone` 마운트는 S3, GCS, R2, Azure Blob으로 제한됩니다. 컨테이너 내부 Box 마운트에는 비대화형 인증 소스와 해당 소스에 맞는 승인이 필요합니다. 인라인 자격 증명을 구성하지 않은 경우에도 `blobfuse2`가 주변 환경의 Azure 권한을 검색하므로 `FuseMountPattern`에는 광범위한 승인이 필요합니다. 마찬가지로 `mount.s3files`이 주변 환경의 IAM 권한을 사용하므로 `S3FilesMountPattern`에도 광범위한 승인이 필요합니다. 이러한 요구 사항은 Docker가 백엔드인 경우에도 적용됩니다. 아래 체크 표시는 해당 권한 경계가 충족된 후 Docker가 마운트를 실행할 수 있음을 나타냅니다. +자격 증명이 없는 `rclone` 마운트는 S3, GCS, R2 및 Azure Blob으로 제한됩니다. 컨테이너 내부 Box 마운트에는 비대화형 인증 소스와 해당 소스에 부합하는 확인이 필요합니다. 인라인 자격 증명이 구성되지 않은 경우에도 `blobfuse2`이 주변 Azure 권한을 탐지하므로 `FuseMountPattern`에는 광범위한 확인이 필요합니다. 마찬가지로 `mount.s3files`이 주변 IAM 권한을 사용하므로 `S3FilesMountPattern`에도 광범위한 확인이 필요합니다. 이러한 요구 사항은 Docker가 백엔드인 경우에도 적용됩니다. 아래 체크 표시는 해당 권한 경계가 충족된 후 Docker에서 마운트를 실행할 수 있음을 나타냅니다. -이름이 `"data"`인 마운트 항목의 경우 구성된 권한과 일치하는 승인에서 반환된 복사본 `Manifest`를 유지합니다. +이름이 `"data"`인 마운트 항목의 경우 구성된 권한에 부합하는 확인에서 반환된 복사본 `Manifest`을 유지합니다. ```python # Mount-scoped values such as inline access keys. @@ -137,11 +166,11 @@ manifest = manifest.with_in_container_mount_credential_exposure_acknowledged("da manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") ``` -승인이 필요한 모든 정확한 마운트 경로를 전달합니다. 두 권한 클래스를 모두 사용하는 마운트에는 두 가지 승인이 모두 필요합니다. 승인은 런타임 전용이고 직렬화되지 않으며, 자격 증명의 사용을 마운트된 경로로 제한하지 않은 채 헬퍼가 자격 증명을 받을 수 있도록 허용합니다. 가능한 경우 외부 전략 또는 공급자 네이티브 전략을 사용하고, 그렇지 않으면 샌드박스 범위로 제한된 수명이 짧은 최소 권한 자격 증명을 사용하세요. +확인이 필요한 모든 정확한 마운트 경로를 전달합니다. 두 권한 클래스를 모두 사용하는 마운트에는 두 확인이 모두 필요합니다. 확인은 런타임에만 적용되고 직렬화되지 않으며, 도우미가 자격 증명을 사용할 수 있는 범위를 마운트된 경로로 제한하지 않은 채 자격 증명을 수신하도록 허용합니다. 가능한 경우 외부 또는 공급자 네이티브 전략을 우선 사용하고, 그렇지 않으면 샌드박스 범위의 수명이 짧은 최소 권한 자격 증명을 사용합니다. -`VercelSandboxClientOptions(allow_s3_credential_exposure=True)`은 인라인 마운트 범위 자격 증명을 사용하는 생성 시점의 Vercel S3 마운트를 위한 호환성 옵션으로 계속 제공됩니다. 이 옵션은 광범위한 자격 증명 권한을 허용하지 않습니다. +`VercelSandboxClientOptions(allow_s3_credential_exposure=True)`은 마운트 범위의 인라인 자격 증명을 사용하는 생성 시점 Vercel S3 마운트를 위한 호환성 옵션으로 유지됩니다. 이 옵션은 광범위한 자격 증명 권한을 승인하지 않습니다. -아래 표에는 각 백엔드가 직접 마운트할 수 있는 원격 스토리지 항목이 요약되어 있습니다. +아래 표는 각 백엔드에서 직접 마운트할 수 있는 원격 스토리지 항목을 요약합니다.
@@ -158,4 +187,4 @@ manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledg
-실행 가능한 코드 예제를 더 보려면 로컬, 코딩, 메모리, 핸드오프, 에이전트 구성 패턴은 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)에서, 호스티드 샌드박스 클라이언트는 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)에서 살펴보세요. \ No newline at end of file +실행 가능한 예제를 더 살펴보려면 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴은 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox)에서, 호스티드 샌드박스 클라이언트는 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)에서 확인하세요. \ No newline at end of file diff --git a/docs/ko/sandbox/guide.md b/docs/ko/sandbox/guide.md index 7dc141e1a8..f600f5ff8e 100644 --- a/docs/ko/sandbox/guide.md +++ b/docs/ko/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "베타 기능" - 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지나면서 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 단계입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지나면서 더욱 고급 기능이 추가될 수 있습니다. -최신 에이전트는 파일 시스템의 실제 파일을 직접 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특화된 도구와 셸 명령을 사용해 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 결과물을 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 에이전트가 사용자를 대신해 작업할 수 있는 영구 워크스페이스를 모델에 제공합니다. Agents SDK의 샌드박스 에이전트를 사용하면 샌드박스 환경과 결합된 에이전트를 쉽게 실행할 수 있으며, 적절한 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모 작업을 쉽게 시작, 중지, 재개할 수 있습니다. +최신 에이전트는 파일 시스템의 실제 파일을 다룰 수 있을 때 가장 효과적으로 작동합니다. **샌드박스 에이전트**는 특수 도구와 셸 명령을 사용하여 대규모 문서 집합을 검색하고 조작하며, 파일을 편집하고, 아티팩트를 생성하고, 명령을 실행할 수 있습니다. 샌드박스는 에이전트가 사용자를 대신해 작업하는 데 사용할 수 있는 영구 워크스페이스를 모델에 제공합니다. Agents SDK의 샌드박스 에이전트를 사용하면 샌드박스 환경과 결합된 에이전트를 쉽게 실행할 수 있으므로, 올바른 파일을 파일 시스템에 배치하고 샌드박스를 오케스트레이션하여 대규모 작업을 손쉽게 시작, 중지, 재개할 수 있습니다. -에이전트에 필요한 데이터를 중심으로 워크스페이스를 정의합니다. GitHub 저장소, 로컬 파일 및 디렉터리, 합성 작업 파일, S3나 Azure Blob Storage 같은 원격 파일 시스템 및 사용자가 제공하는 기타 샌드박스 입력으로 시작할 수 있습니다. +에이전트에 필요한 데이터를 중심으로 워크스페이스를 정의합니다. GitHub 저장소, 로컬 파일과 디렉터리, 합성 작업 파일, S3 또는 Azure Blob Storage 같은 원격 파일 시스템, 그 밖에 사용자가 제공하는 샌드박스 입력에서 시작할 수 있습니다.
-![컴퓨팅이 포함된 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png) +![컴퓨팅을 포함한 샌드박스 에이전트 하네스](../assets/images/harness_with_compute.png)
-`SandboxAgent`도 여전히 `Agent`입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일, 훅과 같은 일반적인 에이전트 인터페이스를 그대로 유지하며, 일반 `Runner` API를 통해 계속 실행됩니다. 달라지는 부분은 실행 경계입니다. +`SandboxAgent` 는 여전히 `Agent` 입니다. `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, 가드레일, 훅과 같은 일반적인 에이전트 구성 요소를 그대로 유지하며, 일반적인 `Runner` API를 통해 계속 실행됩니다. 달라지는 것은 실행 경계입니다. -- `SandboxAgent`은 에이전트 자체를 정의합니다. 여기에는 일반적인 에이전트 구성과 더불어 `default_manifest`, `base_instructions`, `run_as` 같은 샌드박스 전용 기본값 및 파일 시스템 도구, 셸 접근, 스킬, 메모리, 압축 같은 기능이 포함됩니다. -- `Manifest`는 파일, 저장소, 마운트, 환경을 포함해 새 샌드박스 워크스페이스의 원하는 초기 내용과 레이아웃을 선언합니다. -- 샌드박스 세션은 명령이 실행되고 파일이 변경되는 실제 격리 환경입니다. -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 라이브 샌드박스 세션을 직접 주입하거나, 직렬화된 샌드박스 세션 상태에서 다시 연결하거나, 샌드박스 클라이언트를 통해 새 샌드박스 세션을 만드는 등의 방식으로 실행이 샌드박스 세션을 얻는 방법을 결정합니다. -- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행이 이전 작업에 다시 연결되거나 저장된 내용으로 새 샌드박스 세션을 초기화할 수 있습니다. +- `SandboxAgent` 는 에이전트 자체를 정의합니다. 일반적인 에이전트 구성과 함께 `default_manifest`, `base_instructions`, `run_as` 같은 샌드박스별 기본값 및 파일 시스템 도구, 셸 액세스, 스킬, 메모리, 컴팩션 같은 기능을 정의합니다. +- `Manifest` 는 파일, 저장소, 마운트, 환경을 포함하여 새 샌드박스 워크스페이스에 원하는 초기 콘텐츠와 레이아웃을 선언합니다. +- 샌드박스 세션은 명령이 실행되고 파일이 변경되는 활성 격리 환경입니다. +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 는 실행에서 샌드박스 세션을 가져오는 방법을 결정합니다. 예를 들어 세션을 직접 주입하거나, 직렬화된 샌드박스 세션 상태에서 다시 연결하거나, 샌드박스 클라이언트를 통해 새 샌드박스 세션을 생성할 수 있습니다. +- 저장된 샌드박스 상태와 스냅샷을 사용하면 이후 실행에서 이전 작업에 다시 연결하거나 저장된 콘텐츠로 새 샌드박스 세션을 초기화할 수 있습니다. -`Manifest`은 새 세션의 워크스페이스 계약이며, 모든 라이브 샌드박스에 대한 완전한 단일 진실 공급원은 아닙니다. 실행의 유효 워크스페이스는 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택한 스냅샷에서 가져올 수도 있습니다. +`Manifest` 는 모든 활성 샌드박스의 완전한 정보 원본이 아니라 새 세션의 워크스페이스 계약입니다. 실행의 실제 워크스페이스는 재사용된 샌드박스 세션, 직렬화된 샌드박스 세션 상태 또는 실행 시 선택된 스냅샷에서 가져올 수도 있습니다. -이 페이지에서 "샌드박스 세션"은 샌드박스 클라이언트가 관리하는 라이브 실행 환경을 의미합니다. 이는 [세션](../sessions/index.md)에서 설명하는 SDK의 대화형 [`Session`][agents.memory.session.Session] 인터페이스와 다릅니다. +이 페이지에서 "샌드박스 세션"은 샌드박스 클라이언트가 관리하는 활성 실행 환경을 의미합니다. 이는 [세션](../sessions/index.md)에서 설명하는 SDK의 대화형 [`Session`][agents.memory.session.Session] 인터페이스와 다릅니다. -외부 런타임은 계속해서 승인, 트레이싱, 핸드오프 및 실행 재개에 필요한 상태 추적을 담당합니다. 샌드박스 세션은 명령, 파일 변경 및 환경 격리를 담당합니다. 이러한 분리는 모델의 핵심 요소입니다. +외부 런타임은 계속해서 승인, 트레이싱, 핸드오프와 실행 재개에 필요한 상태 추적을 담당합니다. 샌드박스 세션은 명령, 파일 변경, 환경 격리를 담당합니다. 이러한 역할 분리는 모델의 핵심 요소입니다. ### 구성 요소 간의 관계 -샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 라이브 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. +샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 활성 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. ```mermaid flowchart LR @@ -50,33 +50,33 @@ flowchart LR sandbox --> saved ``` -샌드박스 전용 기본값은 `SandboxAgent`에 유지합니다. 실행별 샌드박스 세션 선택은 `SandboxRunConfig`에 유지합니다. +샌드박스별 기본값은 `SandboxAgent` 에 둡니다. 실행별 샌드박스 세션 선택은 `SandboxRunConfig` 에 둡니다. -수명 주기는 다음 세 단계로 생각할 수 있습니다. +수명 주기를 세 단계로 생각하면 됩니다. -1. `SandboxAgent`, `Manifest` 및 기능을 사용해 에이전트와 새 워크스페이스 계약을 정의합니다. -2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig`을 `Runner`에 제공하여 실행합니다. +1. `SandboxAgent`, `Manifest`, 기능을 사용하여 에이전트와 새 워크스페이스 계약을 정의합니다. +2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig` 을 `Runner` 에 제공하여 실행합니다. 3. 러너가 관리하는 `RunState`, 명시적 샌드박스 `session_state` 또는 저장된 워크스페이스 스냅샷에서 나중에 작업을 계속합니다. -셸 접근이 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](../tools.md)의 호스티드 셸부터 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. +셸 액세스가 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](../tools.md)의 호스티드 셸부터 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. ## 사용 시점 샌드박스 에이전트는 다음과 같은 워크스페이스 중심 워크플로에 적합합니다. -- 코딩 및 디버깅. 예를 들어 GitHub 저장소의 이슈 보고서에 대한 자동 수정을 오케스트레이션하고 대상 테스트 실행 -- 문서 처리 및 편집. 예를 들어 사용자의 재무 문서에서 정보를 추출하고 작성된 세금 양식 초안 생성 -- 파일 기반 검토 또는 분석. 예를 들어 답변하기 전에 온보딩 패킷, 생성된 보고서 또는 결과물 번들 확인 -- 격리된 다중 에이전트 패턴. 예를 들어 각 검토자 또는 코딩 하위 에이전트에 별도 워크스페이스 제공 -- 다단계 워크스페이스 작업. 예를 들어 한 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개 +- 코딩 및 디버깅: 예를 들어 GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 대상 테스트 실행 +- 문서 처리 및 편집: 예를 들어 사용자의 재무 문서에서 정보를 추출하고 작성이 완료된 세금 양식 초안 생성 +- 파일 기반 검토 또는 분석: 예를 들어 답변 전에 온보딩 자료, 생성된 보고서 또는 아티팩트 번들 확인 +- 격리된 다중 에이전트 패턴: 예를 들어 각 검토자 또는 코딩 하위 에이전트에 자체 워크스페이스 제공 +- 여러 단계의 워크스페이스 작업: 예를 들어 한 번의 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개 -파일이나 상태를 유지하며 변경 가능한 파일 시스템에 접근할 필요가 없다면 `Agent`을 계속 사용하세요. 셸 접근이 가끔 필요한 기능일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. +파일 또는 상태를 유지하며 변경 가능한 파일 시스템에 액세스할 필요가 없다면 계속 `Agent` 을 사용하세요. 셸 액세스가 가끔 필요한 기능일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. ## 샌드박스 클라이언트 선택 -macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient`부터 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 제공자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리나 이미지 일관성이 필요하면 `DockerSandboxClient`로 전환하고, 제공자가 관리하는 실행이 필요하면 호스티드 제공자로 전환하세요. +macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient` 로 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 공급자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리나 이미지 동등성이 필요하면 `DockerSandboxClient` 로 전환하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자로 전환하세요. -대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 해당 옵션만 변경합니다. 로컬, Docker, 호스티드 및 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. +대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에서 샌드박스 클라이언트와 해당 옵션만 변경합니다. 로컬, Docker, 호스티드, 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. ## 핵심 구성 요소 @@ -84,141 +84,143 @@ macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient`부 | 계층 | 주요 SDK 구성 요소 | 답하는 질문 | | --- | --- | --- | -| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트가 실행되며, 어떤 새 세션 워크스페이스 계약에서 시작해야 하는가? | -| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트 및 라이브 샌드박스 세션 | 이 실행은 어떻게 라이브 샌드박스 세션을 얻으며, 작업은 어디에서 실행되는가? | -| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state` 및 스냅샷 | 이 워크플로는 어떻게 이전 샌드박스 작업에 다시 연결되거나 저장된 내용으로 새 샌드박스 세션을 초기화하는가? | +| 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트를 실행하며, 어떤 새 세션 워크스페이스 계약에서 시작해야 합니까? | +| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트, 활성 샌드박스 세션 | 이 실행에서 활성 샌드박스 세션을 어떻게 가져오며, 작업은 어디에서 실행됩니까? | +| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state`, 스냅샷 | 이 워크플로에서 이전 샌드박스 작업에 어떻게 다시 연결하거나 저장된 콘텐츠로 새 샌드박스 세션을 초기화합니까? |
-주요 SDK 구성 요소는 다음과 같이 이러한 계층에 대응합니다. +주요 SDK 구성 요소와 각 계층의 대응 관계는 다음과 같습니다.
-| 구성 요소 | 담당 범위 | 확인할 질문 | +| 구성 요소 | 담당 영역 | 확인할 질문 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트가 무엇을 해야 하며, 어떤 기본값이 에이전트와 함께 전달되어야 하는가? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션의 워크스페이스 파일 및 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 하는가? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 이 에이전트에 어떤 도구, instructions 조각 또는 런타임 동작을 연결해야 하는가? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트 및 샌드박스 세션 소스 | 이 실행에서 샌드박스 세션을 주입, 재개 또는 생성해야 하는가? | -| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 이전에 러너가 관리하던 워크플로를 재개하고 해당 샌드박스 상태를 자동으로 이어가는가? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태로부터 재개하려는가? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위한 저장된 워크스페이스 내용 | 새 샌드박스 세션을 저장된 파일과 결과물에서 시작해야 하는가? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트가 무엇을 해야 하며, 어떤 기본값이 에이전트와 함께 유지되어야 합니까? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 새 세션의 워크스페이스 파일과 폴더 | 실행이 시작될 때 파일 시스템에 어떤 파일과 폴더가 있어야 합니까? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 샌드박스 네이티브 동작 | 어떤 도구, instructions 조각 또는 런타임 동작을 이 에이전트에 연결해야 합니까? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 실행별 샌드박스 클라이언트 및 샌드박스 세션 소스 | 이 실행에서 샌드박스 세션을 주입, 재개 또는 생성해야 합니까? | +| [`RunState`][agents.run_state.RunState] | 러너가 관리하는 저장된 샌드박스 상태 | 러너가 관리하던 이전 워크플로를 재개하고 샌드박스 상태를 자동으로 이어가고 있습니까? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 명시적으로 직렬화된 샌드박스 세션 상태 | `RunState` 외부에서 이미 직렬화한 샌드박스 상태에서 재개하려고 합니까? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 새 샌드박스 세션을 위한 저장된 워크스페이스 콘텐츠 | 새 샌드박스 세션이 저장된 파일과 아티팩트에서 시작해야 합니까? |
실용적인 설계 순서는 다음과 같습니다. -1. `Manifest`를 사용해 새 세션의 워크스페이스 계약을 정의합니다. -2. `SandboxAgent`으로 에이전트를 정의합니다. +1. `Manifest` 로 새 세션의 워크스페이스 계약을 정의합니다. +2. `SandboxAgent` 으로 에이전트를 정의합니다. 3. 기본 제공 또는 사용자 지정 기능을 추가합니다. -4. `RunConfig(sandbox=SandboxRunConfig(...))`에서 각 실행이 샌드박스 세션을 얻는 방법을 결정합니다. +4. `RunConfig(sandbox=SandboxRunConfig(...))` 에서 각 실행이 샌드박스 세션을 가져오는 방법을 결정합니다. -## 샌드박스 실행 준비 +## 샌드박스 실행 준비 과정 실행 시 러너는 해당 정의를 구체적인 샌드박스 기반 실행으로 변환합니다. -1. `SandboxRunConfig`에서 샌드박스 세션을 결정합니다. `session=...`를 전달하면 해당 라이브 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...`을 사용해 샌드박스 세션을 생성하거나 재개합니다. -2. 실행에 사용할 유효 워크스페이스 입력을 결정합니다. 실행에서 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest`에서 시작합니다. 이 때문에 `Manifest`만으로는 모든 실행의 최종 라이브 워크스페이스를 정의할 수 없습니다. -3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트가 준비되기 전에 기능이 파일, 마운트 또는 기타 워크스페이스 범위 동작을 추가할 수 있습니다. -4. 고정된 순서로 최종 instructions를 구성합니다. 먼저 SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`, 그다음 `instructions`, 기능의 instructions 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리 순입니다. -5. 기능 도구를 라이브 샌드박스 세션에 바인딩하고 일반 `Runner` API를 통해 준비된 에이전트를 실행합니다. +1. `SandboxRunConfig` 에서 샌드박스 세션을 결정합니다. `session=...` 를 전달하면 해당 활성 샌드박스 세션을 재사용합니다. 그렇지 않으면 `client=...` 을 사용하여 세션을 생성하거나 재개합니다. +2. 실행에 적용할 실제 워크스페이스 입력을 결정합니다. 실행에서 샌드박스 세션을 주입하거나 재개하면 기존 샌드박스 상태가 우선합니다. 그렇지 않으면 러너는 일회성 매니페스트 재정의 또는 `agent.default_manifest` 에서 시작합니다. 이 때문에 `Manifest` 만으로는 모든 실행의 최종 활성 워크스페이스를 정의할 수 없습니다. +3. 기능이 결과 매니페스트를 처리하도록 합니다. 이를 통해 최종 에이전트가 준비되기 전에 기능이 파일, 마운트 또는 기타 워크스페이스 범위의 동작을 추가할 수 있습니다. +4. 고정된 순서로 최종 instructions를 구성합니다. SDK의 기본 샌드박스 프롬프트 또는 명시적으로 재정의한 경우 `base_instructions`, 그다음 `instructions`, 기능의 instructions 조각, 원격 마운트 정책 텍스트, 렌더링된 파일 시스템 트리 순서입니다. +5. 기능의 도구를 활성 샌드박스 세션에 바인딩하고 일반적인 `Runner` API를 통해 준비된 에이전트를 실행합니다. -샌드박스 사용 여부는 턴의 의미를 바꾸지 않습니다. 턴은 여전히 단일 셸 명령이나 샌드박스 작업이 아니라 모델 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내부에서 계속될 수 있지만, 도구 결과, 승인 또는 다른 종류의 상태처럼 또 다른 모델 단계가 필요한 정보를 반환하는 작업도 있습니다. 실용적인 기준으로는 샌드박스 작업이 발생한 후 에이전트 런타임에 또 다른 모델 응답이 필요할 때만 추가 턴이 소비됩니다. +샌드박스 사용은 턴의 의미를 바꾸지 않습니다. 턴은 여전히 단일 셸 명령이나 샌드박스 작업이 아니라 모델 단계입니다. 샌드박스 측 작업과 턴 사이에는 고정된 1:1 대응 관계가 없습니다. 일부 작업은 샌드박스 실행 계층 내부에서 계속 진행될 수 있지만, 도구 결과, 승인 또는 다른 유형의 상태처럼 다른 모델 단계가 필요한 정보를 반환하는 작업도 있습니다. 실용적인 원칙으로, 샌드박스 작업이 일어난 뒤 에이전트 런타임에 다른 모델 응답이 필요할 때만 추가 턴이 소비됩니다. -이러한 준비 단계 때문에 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as`은 `SandboxAgent`을 설계할 때 고려해야 할 주요 샌드박스 전용 옵션입니다. +이러한 준비 단계 때문에 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as` 은 `SandboxAgent` 을 설계할 때 고려해야 할 주요 샌드박스별 옵션입니다. ## `SandboxAgent` 옵션 -일반적인 `Agent` 필드에 추가되는 샌드박스 전용 옵션은 다음과 같습니다. +일반적인 `Agent` 필드에 추가되는 샌드박스별 옵션은 다음과 같습니다.
-| 옵션 | 적합한 용도 | +| 옵션 | 최적의 용도 | | --- | --- | | `default_manifest` | 러너가 생성하는 새 샌드박스 세션의 기본 워크스페이스 | -| `instructions` | SDK 샌드박스 프롬프트 뒤에 추가되는 역할, 워크플로 및 성공 기준 | -| `base_instructions` | SDK 샌드박스 프롬프트를 대체하는 고급 탈출구 | -| `capabilities` | 이 에이전트와 함께 전달되어야 하는 샌드박스 네이티브 도구 및 동작 | -| `run_as` | 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구의 사용자 ID | +| `instructions` | SDK 샌드박스 프롬프트 뒤에 추가되는 역할, 워크플로, 성공 기준 | +| `base_instructions` | SDK 샌드박스 프롬프트를 대체하는 고급 이스케이프 해치 | +| `capabilities` | 이 에이전트와 함께 유지되어야 하는 샌드박스 네이티브 도구와 동작 | +| `run_as` | 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구를 위한 사용자 ID |
-샌드박스 클라이언트 선택, 샌드박스 세션 재사용, 매니페스트 재정의 및 스냅샷 선택은 에이전트가 아니라 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에 속합니다. +샌드박스 클라이언트 선택, 샌드박스 세션 재사용, 매니페스트 재정의, 스냅샷 선택은 에이전트가 아니라 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에 속합니다. ### `default_manifest` -`default_manifest`은 러너가 이 에이전트용 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest]입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리 및 마운트를 지정하는 데 사용합니다. +`default_manifest` 는 러너가 이 에이전트의 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest] 입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리, 마운트에 사용하세요. -이는 기본값일 뿐입니다. 실행에서 `SandboxRunConfig(manifest=...)`으로 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 워크스페이스 상태를 유지합니다. +이는 기본값일 뿐입니다. 실행에서 `SandboxRunConfig(manifest=...)` 으로 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 워크스페이스 상태를 유지합니다. ### `instructions` 및 `base_instructions` -다양한 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`을 사용하세요. `SandboxAgent`에서 이러한 instructions는 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로 및 성공 기준을 추가할 수 있습니다. +다른 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions` 을 사용하세요. `SandboxAgent` 에서 이러한 instructions는 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로, 성공 기준을 추가할 수 있습니다. -SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions`을 사용하세요. 대부분의 에이전트는 이를 설정하지 않아야 합니다. +SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions` 을 사용하세요. 대부분의 에이전트에서는 설정하지 않는 것이 좋습니다.
| 배치 위치 | 용도 | 예시 | | --- | --- | --- | -| `instructions` | 에이전트의 안정적인 역할, 워크플로 규칙 및 성공 기준 | "온보딩 문서를 검사한 다음 핸드오프하세요.", "최종 파일을 `output/`에 작성하세요." | -| `base_instructions` | SDK 샌드박스 기본 프롬프트의 완전한 대체 | 사용자 지정 저수준 샌드박스 래퍼 프롬프트 | +| `instructions` | 에이전트의 안정적인 역할, 워크플로 규칙, 성공 기준 | "온보딩 문서를 검사한 다음 핸드오프하세요.", "최종 파일을 `output/` 에 작성하세요." | +| `base_instructions` | SDK 샌드박스 기본 프롬프트의 전체 대체 | 사용자 지정 저수준 샌드박스 래퍼 프롬프트 | | 사용자 프롬프트 | 이 실행을 위한 일회성 요청 | "이 워크스페이스를 요약하세요." | | 매니페스트의 워크스페이스 파일 | 더 긴 작업 명세, 저장소 로컬 instructions 또는 범위가 제한된 참고 자료 | `repo/task.md`, 문서 번들, 샘플 패킷 |
-`instructions`의 적절한 사용 예시는 다음과 같습니다. +`instructions` 의 적절한 사용 예시는 다음과 같습니다. - [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py)는 PTY 상태가 중요할 때 에이전트를 하나의 대화형 프로세스에 유지합니다. - [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)는 샌드박스 검토자가 검사 후 사용자에게 직접 답변하지 못하도록 합니다. -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)는 최종 작성 파일이 실제로 `output/`에 저장되도록 요구합니다. -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)는 정확한 검증 명령을 고정하고 워크스페이스 루트 기준 패치 경로를 명확히 합니다. +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)는 최종 작성 파일이 실제로 `output/` 에 저장되도록 요구합니다. +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)는 정확한 검증 명령을 고정하고 `SandboxRunConfig.cwd` 가 설정되지 않았을 때 패치 경로가 워크스페이스 루트 기준임을 명확히 합니다. -사용자의 일회성 작업을 `instructions`에 복사하거나, 매니페스트에 속하는 긴 참고 자료를 포함하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 다시 작성하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. +사용자의 일회성 작업을 `instructions` 에 복사하거나, 매니페스트에 속하는 긴 참고 자료를 포함하거나, 기본 제공 기능이 이미 주입하는 도구 문서를 다시 설명하거나, 모델이 실행 시 필요로 하지 않는 로컬 설치 참고 사항을 섞지 마세요. -`instructions`을 생략해도 SDK에는 기본 샌드박스 프롬프트가 포함됩니다. 저수준 래퍼에는 이것만으로 충분하지만, 대부분의 사용자 대상 에이전트는 여전히 명시적인 `instructions`을 제공해야 합니다. +`instructions` 을 생략해도 SDK는 기본 샌드박스 프롬프트를 포함합니다. 저수준 래퍼에는 이것으로 충분하지만, 대부분의 사용자 대상 에이전트는 여전히 명시적인 `instructions` 을 제공해야 합니다. ### `capabilities` -기능은 `SandboxAgent`에 샌드박스 네이티브 동작을 연결합니다. 실행이 시작되기 전에 워크스페이스를 구성하고, 샌드박스 전용 instructions를 추가하고, 라이브 샌드박스 세션에 바인딩되는 도구를 노출하며, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. +기능은 샌드박스 네이티브 동작을 `SandboxAgent` 에 연결합니다. 실행이 시작되기 전에 워크스페이스를 구성하고, 샌드박스별 instructions를 추가하고, 활성 샌드박스 세션에 바인딩되는 도구를 노출하며, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. 기본 제공 기능은 다음과 같습니다.
-| 기능 | 추가 시점 | 참고 사항 | +| 기능 | 추가 시점 | 참고 | | --- | --- | --- | -| `Shell` | 에이전트에 셸 접근이 필요할 때 | `exec_command`을 추가하며, 샌드박스 클라이언트가 PTY 상호작용을 지원하면 `write_stdin`도 추가합니다. | -| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 할 때 | `apply_patch`와 `view_image`를 추가합니다. 패치 경로는 워크스페이스 루트 기준입니다. | -| `Skills` | 샌드박스에서 스킬 검색 및 구체화를 사용하려 할 때 | `.agents` 또는 `.agents/skills`을 수동으로 마운트하는 것보다 이 기능을 권장합니다. `Skills`이 스킬을 인덱싱하고 샌드박스에 구체화합니다. | -| `Memory` | 후속 실행에서 메모리 결과물을 읽거나 생성해야 할 때 | `Shell`이 필요합니다. 실행 중 메모리 결과물을 업데이트하려면 `Filesystem`도 필요합니다. | -| `Compaction` | 장기 실행 흐름에서 압축 항목 이후 컨텍스트를 정리해야 할 때 | 모델 샘플링 및 입력 처리를 조정합니다. | +| `Shell` | 에이전트에 셸 액세스가 필요한 경우 | `exec_command` 를 추가하고, 샌드박스 클라이언트가 PTY 상호작용을 지원하면 `write_stdin` 도 추가합니다. | +| `Filesystem` | 에이전트가 파일을 편집하거나 로컬 이미지를 검사해야 하는 경우 | `apply_patch` 및 `view_image` 을 추가합니다. 상대 경로는 기본적으로 워크스페이스 루트를 사용하고, 구성된 경우 `SandboxRunConfig.cwd` 을 사용합니다. | +| `Skills` | 샌드박스에서 스킬 검색과 구체화를 사용하려는 경우 | `.agents` 또는 `.agents/skills` 을 직접 마운트하는 대신 이를 사용하는 것이 좋습니다. `Skills` 가 스킬을 인덱싱하고 샌드박스에 구체화합니다. | +| `Memory` | 후속 실행에서 메모리 아티팩트를 읽거나 생성해야 하는 경우 | `Shell` 이 필요하며, 실행 중 메모리 아티팩트를 업데이트하려면 `Filesystem` 도 필요합니다. | +| `Compaction` | 장기 실행 흐름에서 컴팩션 항목 이후 컨텍스트를 줄여야 하는 경우 | 모델 샘플링과 입력 처리를 조정합니다. |
-기본적으로 `SandboxAgent.capabilities`는 `Capabilities.default()`를 사용하며, 여기에는 `Filesystem()`, `Shell()`, `Compaction()`이 포함됩니다. `capabilities=[...]`을 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용하려는 기본 기능을 모두 포함하세요. +기본적으로 `SandboxAgent.capabilities` 는 `Capabilities.default()` 을 사용하며, 여기에는 `Filesystem()`, `Shell()`, `Compaction()` 이 포함됩니다. `capabilities=[...]` 을 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용하려는 기본 기능도 포함하세요. + +`view_image` 도구는 파일 이름 확장자가 아니라 파일 콘텐츠를 기준으로 PNG, JPEG, GIF, WebP, BMP, TIFF 래스터 이미지를 식별합니다. 래스터 이미지 확장자를 가진 파일도 콘텐츠가 지원되지 않으면 거부되며, 지원되는 래스터 콘텐츠라면 파일 이름에 이미지 확장자가 없어도 로드할 수 있습니다. `.svg` 및 `.svgz` 파일의 경우 파일 콘텐츠에서 SVG 마크업을 인식하는 것과 더불어 파일 이름 기반 호환성도 유지합니다. 스킬의 경우 원하는 구체화 방식에 따라 소스를 선택하세요. -- 모델이 먼저 인덱스를 탐색하고 필요한 항목만 불러올 수 있으므로, 규모가 큰 로컬 스킬 디렉터리에는 `Skills(lazy_from=LocalDirLazySkillSource(...))`이 적절한 기본값입니다. -- `LocalDirLazySkillSource(source=LocalDir(src=...))`은 SDK 프로세스가 실행되는 파일 시스템에서 읽습니다. 샌드박스 이미지나 워크스페이스 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. -- 미리 스테이징하려는 작은 로컬 번들에는 `Skills(from_=LocalDir(src=...))`가 더 적합합니다. -- 스킬 자체를 저장소에서 가져와야 한다면 `Skills(from_=GitRepo(repo=..., ref=...))`이 적합합니다. +- `Skills(lazy_from=LocalDirLazySkillSource(...))` 는 모델이 먼저 인덱스를 검색하고 필요한 항목만 로드할 수 있으므로 규모가 큰 로컬 스킬 디렉터리의 적절한 기본값입니다. +- `LocalDirLazySkillSource(source=LocalDir(src=...))` 은 SDK 프로세스가 실행 중인 파일 시스템에서 읽습니다. 샌드박스 이미지나 워크스페이스 내부에만 존재하는 경로가 아니라 원래 호스트 측 스킬 디렉터리를 전달하세요. +- `Skills(from_=LocalDir(src=...))` 은 미리 스테이징하려는 소규모 로컬 번들에 더 적합합니다. +- `Skills(from_=GitRepo(repo=..., ref=...))` 은 스킬 자체를 저장소에서 가져와야 할 때 적합합니다. -`LocalDir.src`은 SDK 호스트의 소스 경로입니다. `skills_path`는 `load_skill`이 호출될 때 스킬이 스테이징되는 샌드박스 워크스페이스 내부의 상대 대상 경로입니다. +`LocalDir.src` 는 SDK 호스트의 소스 경로입니다. `skills_path` 은 `load_skill` 가 호출될 때 스킬이 스테이징되는 샌드박스 워크스페이스 내부의 상대 대상 경로입니다. -스킬이 이미 `.agents/skills//SKILL.md` 같은 디스크 경로에 있다면 `LocalDir(...)`이 해당 소스 루트를 가리키도록 하고, 이를 노출할 때는 계속 `Skills(...)`을 사용하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 워크스페이스 계약이 없다면 기본 `skills_path=".agents"`을 유지하세요. +스킬이 이미 `.agents/skills//SKILL.md` 같은 디스크 경로에 있다면 `LocalDir(...)` 이 해당 소스 루트를 가리키도록 하고, 계속 `Skills(...)` 를 사용하여 노출하세요. 다른 샌드박스 내부 레이아웃에 의존하는 기존 워크스페이스 계약이 없다면 기본 `skills_path=".agents"` 를 유지하세요. -적합한 기본 제공 기능이 있다면 이를 우선 사용하세요. 기본 제공 기능으로 처리할 수 없는 샌드박스 전용 도구 또는 instructions 인터페이스가 필요한 경우에만 사용자 지정 기능을 작성하세요. +요구 사항에 맞는다면 기본 제공 기능을 우선 사용하세요. 기본 제공 기능이 지원하지 않는 샌드박스별 도구 또는 instructions 구성 요소가 필요한 경우에만 사용자 지정 기능을 작성하세요. ## 개념 ### 매니페스트 -[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root`을 설정하고, 파일과 디렉터리를 선언하고, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자 또는 그룹을 정의하며, 워크스페이스 외부의 특정 절대 경로에 대한 접근 권한을 부여할 수 있습니다. +[`Manifest`][agents.sandbox.manifest.Manifest] 는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root` 설정, 파일 및 디렉터리 선언, 로컬 파일 복사, Git 저장소 복제, 원격 스토리지 마운트 연결, 환경 변수 설정, 사용자 또는 그룹 정의, 워크스페이스 외부의 특정 절대 경로에 대한 액세스 허용을 지원합니다. -매니페스트 항목의 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..`을 통해 워크스페이스를 벗어날 수 없으므로, 로컬, Docker 및 호스티드 클라이언트 간에 워크스페이스 계약을 이식할 수 있습니다. +매니페스트 항목 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..` 로 워크스페이스를 벗어날 수 없으므로, 워크스페이스 계약을 로컬, Docker, 호스티드 클라이언트 간에 이식할 수 있습니다. 작업을 시작하기 전에 에이전트에 필요한 자료에는 매니페스트 항목을 사용하세요. @@ -226,22 +228,22 @@ SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instr | 매니페스트 항목 | 용도 | | --- | --- | -| `File`, `Dir` | 작은 합성 입력, 보조 파일 또는 출력 디렉터리 | +| `File`, `Dir` | 소규모 합성 입력, 보조 파일 또는 출력 디렉터리 | | `LocalFile`, `LocalDir` | 샌드박스에 구체화해야 하는 호스트 파일 또는 디렉터리 | | `GitRepo` | 워크스페이스로 가져와야 하는 저장소 | -| `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 같은 마운트 | 샌드박스 내부에 표시해야 하는 외부 스토리지 | +| `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` 같은 마운트 | 샌드박스 내부에 표시되어야 하는 외부 스토리지 | -`Dir`는 합성 하위 항목 또는 출력 위치로부터 샌드박스 워크스페이스 내부에 디렉터리를 생성하며, 호스트 파일 시스템에서 읽지는 않습니다. 기존 호스트 디렉터리를 샌드박스 워크스페이스로 복사해야 할 때는 `LocalDir`을 사용하세요. +`Dir` 는 합성 하위 항목으로 샌드박스 워크스페이스 내부에 디렉터리를 생성하거나 출력 위치를 만듭니다. 호스트 파일 시스템에서는 읽지 않습니다. 기존 호스트 디렉터리를 샌드박스 워크스페이스로 복사해야 할 때는 `LocalDir` 을 사용하세요. -`LocalFile.src`과 `LocalDir.src`은 기본적으로 SDK 프로세스 작업 디렉터리를 기준으로 해석됩니다. 소스는 `extra_path_grants`에 포함되지 않는 한 해당 기본 디렉터리 아래에 있어야 합니다. 이렇게 하면 로컬 소스 구체화가 나머지 샌드박스 매니페스트와 동일한 호스트 경로 신뢰 경계 내에 유지됩니다. +`LocalFile.src` 및 `LocalDir.src` 은 기본적으로 SDK 프로세스 작업 디렉터리를 기준으로 해석됩니다. `extra_path_grants` 에 포함되지 않는 한 소스는 해당 기본 디렉터리 아래에 있어야 합니다. 이를 통해 로컬 소스 구체화가 샌드박스 매니페스트의 나머지 부분과 동일한 호스트 경로 신뢰 경계 안에서 이루어집니다. -마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 설명합니다. 마운트 옵션과 제공자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참고하세요. +마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방법을 설명합니다. 마운트 옵션과 공급자 지원은 [샌드박스 클라이언트](clients.md#mounts-and-remote-storage)를 참조하세요. -적절한 매니페스트 설계는 일반적으로 워크스페이스 계약의 범위를 좁게 유지하고, 긴 작업 절차는 `repo/task.md` 같은 워크스페이스 파일에 넣으며, instructions에서 `repo/task.md` 또는 `output/report.md` 같은 상대 워크스페이스 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집한다면 패치 경로가 셸 `workdir`이 아니라 샌드박스 워크스페이스 루트를 기준으로 한다는 점에 유의하세요. +좋은 매니페스트 설계는 일반적으로 워크스페이스 계약의 범위를 좁게 유지하고, 긴 작업 절차를 `repo/task.md` 같은 워크스페이스 파일에 넣으며, instructions에서 `repo/task.md` 또는 `output/report.md` 같은 상대 워크스페이스 경로를 사용하는 것입니다. 에이전트가 `Filesystem` 기능의 `apply_patch` 도구로 파일을 편집하는 경우 패치 경로는 기본적으로 샌드박스 워크스페이스 루트를 사용하고, 구성된 경우 `SandboxRunConfig.cwd` 을 사용한다는 점에 유의하세요. 셸 `workdir` 은 사용하지 않습니다. -에이전트에 워크스페이스 외부의 구체적인 절대 경로가 필요하거나 매니페스트가 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 할 때만 `extra_path_grants`을 사용하세요. 예를 들어 임시 도구 출력용 `/tmp`, 읽기 전용 런타임용 `/opt/toolchain`, 또는 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리가 있습니다. 권한 부여는 로컬 소스 구체화 및 SDK 파일 API에 적용됩니다. 백엔드가 파일 시스템 정책을 적용할 수 있는 경우 셸 실행에도 적용됩니다. +에이전트에 워크스페이스 외부의 구체적인 절대 경로가 필요하거나, 매니페스트가 SDK 프로세스 작업 디렉터리 외부의 신뢰할 수 있는 로컬 소스를 복사해야 할 때만 `extra_path_grants` 를 사용하세요. 예로는 임시 도구 출력을 위한 `/tmp`, 읽기 전용 런타임을 위한 `/opt/toolchain`, 샌드박스에 구체화해야 하는 생성된 스킬 디렉터리 등이 있습니다. 권한 부여는 로컬 소스 구체화와 SDK 파일 API에 적용됩니다. 백엔드가 파일 시스템 정책을 적용할 수 있는 경우 셸 실행에도 적용됩니다. ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +256,17 @@ manifest = Manifest( ) ``` -Docker가 컨테이너 내부의 절대 POSIX `path`에 다른 절대 호스트 경로를 바인드 마운트해야 할 때는 `host_path`을 설정하세요. `UnixLocalSandboxClient`은 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path`을 거부합니다. 샌드박스가 수정해서는 안 되는 호스트 데이터에는 `read_only=True`을 사용하고, 복사만으로 충분하면 `LocalFile` 또는 `LocalDir`를 사용하세요. +Docker가 컨테이너 내부의 절대 POSIX `path` 에 다른 절대 호스트 경로를 바인드 마운트해야 할 때 `host_path` 를 설정하세요. `UnixLocalSandboxClient` 은 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path` 을 거부합니다. 샌드박스가 수정해서는 안 되는 호스트 데이터에는 `read_only=True` 를 사용하고, 복사만으로 충분하다면 `LocalFile` 또는 `LocalDir` 을 사용하세요. -`extra_path_grants`이 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션이 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력 또는 기타 신뢰할 수 없는 페이로드에서 권한 부여를 불러오지 마세요. +`extra_path_grants` 를 포함하는 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. -스냅샷과 `persist_workspace()`에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 접근용이며 영구 워크스페이스 상태가 아닙니다. +스냅샷과 `persist_workspace()` 에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스이며, 영구 워크스페이스 상태가 아닙니다. ### 권한 -`Permissions`은 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스가 구체화하는 파일에 관한 것이며 모델 권한, 승인 정책 또는 API 자격 증명에 관한 것이 아닙니다. +`Permissions` 는 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스가 구체화하는 파일에 관한 것이며, 모델 권한, 승인 정책 또는 API 자격 증명에 관한 것이 아닙니다. -기본적으로 매니페스트 항목은 소유자가 읽기, 쓰기 및 실행할 수 있고 그룹과 기타 사용자가 읽고 실행할 수 있습니다. 스테이징된 파일을 비공개, 읽기 전용 또는 실행 가능 상태로 만들어야 할 때 이를 재정의하세요. +기본적으로 매니페스트 항목은 소유자가 읽고 쓰고 실행할 수 있으며, 그룹과 기타 사용자는 읽고 실행할 수 있습니다. 스테이징된 파일이 비공개, 읽기 전용 또는 실행 가능해야 할 때 이를 재정의하세요. ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +282,9 @@ private_notes = File( ) ``` -`Permissions`은 소유자, 그룹 및 기타 사용자에 대한 비트를 별도로 저장하며, 항목이 디렉터리인지 여부도 저장합니다. 직접 구성하거나, `Permissions.from_str(...)`을 사용해 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)`을 사용해 OS 모드에서 파생할 수 있습니다. +`Permissions` 는 항목이 디렉터리인지 여부와 함께 소유자, 그룹, 기타 사용자 비트를 별도로 저장합니다. 직접 구성하거나, `Permissions.from_str(...)` 으로 모드 문자열에서 파싱하거나, `Permissions.from_mode(...)` 로 OS 모드에서 파생할 수 있습니다. -사용자는 샌드박스에서 작업을 실행할 수 있는 ID입니다. 해당 ID가 샌드박스에 존재하도록 하려면 매니페스트에 `User`을 추가한 다음, 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구가 해당 사용자로 실행되어야 할 때 `SandboxAgent.run_as`을 설정하세요. `run_as`이 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 유효 매니페스트에 자동으로 추가합니다. +사용자는 작업을 실행할 수 있는 샌드박스 ID입니다. 해당 ID가 샌드박스에 존재하도록 하려면 매니페스트에 `User` 을 추가하고, 셸 명령, 파일 읽기, 패치 같은 모델 대상 샌드박스 도구가 해당 사용자로 실행되어야 할 때 `SandboxAgent.run_as` 를 설정하세요. `run_as` 이 매니페스트에 아직 없는 사용자를 가리키면 러너가 해당 사용자를 실제 매니페스트에 추가합니다. ```python from agents import Runner @@ -334,13 +336,13 @@ result = await Runner.run( ) ``` -파일 수준 공유 규칙도 필요하다면 사용자와 매니페스트 그룹 및 항목 `group` 메타데이터를 함께 사용하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하고, `Permissions`은 샌드박스가 워크스페이스를 구체화한 후 해당 사용자가 읽고 쓰고 실행할 수 있는 파일을 제어합니다. +파일 수준 공유 규칙도 필요한 경우 사용자를 매니페스트 그룹 및 항목의 `group` 메타데이터와 결합하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하며, `Permissions` 은 샌드박스가 워크스페이스를 구체화한 후 해당 사용자가 읽고 쓰고 실행할 수 있는 파일을 제어합니다. ### SnapshotSpec -`SnapshotSpec`은 새 샌드박스 세션에 저장된 워크스페이스 내용을 복원할 위치와 다시 영속화할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state`은 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. +`SnapshotSpec` 는 저장된 워크스페이스 콘텐츠를 새 샌드박스 세션이 복원할 위치와 다시 저장할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state` 는 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. -로컬 영구 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 애플리케이션이 원격 스냅샷 클라이언트를 제공할 때는 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷 설정을 사용할 수 없으면 no-op 스냅샷이 대체 수단으로 사용되며, 고급 호출자는 워크스페이스 스냅샷 영속화를 원하지 않을 때 이를 명시적으로 사용할 수 있습니다. +로컬 영구 스냅샷에는 `LocalSnapshotSpec` 을 사용하고, 앱이 원격 스냅샷 클라이언트를 제공할 때는 `RemoteSnapshotSpec` 을 사용하세요. 로컬 스냅샷을 설정할 수 없으면 무작동 스냅샷이 대체 수단으로 사용되며, 워크스페이스 스냅샷을 영구 저장하지 않으려는 고급 호출자는 이를 명시적으로 사용할 수 있습니다. ```python from pathlib import Path @@ -357,9 +359,9 @@ run_config = RunConfig( ) ``` -러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 생성합니다. 시작 시 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 저장된 워크스페이스 내용을 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 워크스페이스를 보관하고 스냅샷을 통해 다시 영속화합니다. +러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작 시 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 저장된 워크스페이스 콘텐츠를 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 워크스페이스를 아카이브하고 스냅샷을 통해 다시 저장합니다. -`snapshot`을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 이를 설정할 수 없으면 no-op 스냅샷으로 대체합니다. 마운트된 경로와 임시 경로는 영구 워크스페이스 내용으로 스냅샷에 복사되지 않습니다. +`snapshot` 을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 이를 설정할 수 없으면 무작동 스냅샷으로 대체합니다. 마운트된 경로와 임시 경로는 영구 워크스페이스 콘텐츠로 스냅샷에 복사되지 않습니다. ### 샌드박스 수명 주기 @@ -391,7 +393,7 @@ sequenceDiagram -샌드박스를 한 번의 실행 동안만 유지해야 할 때는 SDK 소유 수명 주기를 사용하세요. `client`, 선택적으로 `manifest`와 `snapshot`, 그리고 필요한 클라이언트 `options`을 전달합니다. 러너는 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 영속화하고, 샌드박스 세션을 종료한 다음, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. +샌드박스를 한 번의 실행 동안만 유지하면 되는 경우 SDK 소유 수명 주기를 사용하세요. `client`, 선택적으로 `manifest` 및 `snapshot`, 필요한 클라이언트 `options` 를 전달합니다. 러너는 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 저장하고, 샌드박스 세션을 종료한 다음, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. ```python result = await Runner.run( @@ -403,7 +405,7 @@ result = await Runner.run( ) ``` -샌드박스를 미리 생성하거나, 여러 실행에서 하나의 라이브 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너는 해당 라이브 샌드박스를 사용하지만 사용자를 대신해 닫지는 않습니다. +샌드박스를 미리 생성하거나, 하나의 활성 샌드박스를 여러 실행에서 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...` 을 전달하면 러너가 해당 활성 샌드박스를 사용하지만 대신 닫지는 않습니다. ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +416,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -일반적으로는 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 애플리케이션에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. +컨텍스트 관리자가 일반적인 형태입니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 앱에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. ```python sandbox = await client.create( @@ -435,11 +437,11 @@ finally: await sandbox.aclose() ``` -`stop()`은 스냅샷 기반 워크스페이스 내용만 영속화하며 샌드박스를 종료하지 않습니다. `aclose()`은 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하며, 세션 범위 종속성을 닫습니다. +`stop()` 는 스냅샷 기반 워크스페이스 콘텐츠만 저장하며 샌드박스를 종료하지 않습니다. `aclose()` 는 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()` 을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. ## `SandboxRunConfig` 옵션 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에는 샌드박스 세션의 출처와 새 세션 초기화 방법을 결정하는 실행별 옵션이 포함됩니다. +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 는 샌드박스 세션의 출처와 새 세션의 초기화 방법을 결정하는 실행별 옵션을 보유합니다. ### 샌드박스 소스 @@ -447,20 +449,20 @@ finally:
-| 옵션 | 사용 시점 | 참고 사항 | +| 옵션 | 사용 시점 | 참고 | | --- | --- | --- | -| `client` | 러너가 샌드박스 세션을 생성, 재개 및 정리하도록 하려는 경우 | 라이브 샌드박스 `session`을 제공하지 않는 한 필수입니다. | -| `session` | 라이브 샌드박스 세션을 이미 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 라이브 샌드박스 세션을 재사용합니다. | -| `session_state` | 직렬화된 샌드박스 세션 상태는 있지만 라이브 샌드박스 세션 객체는 없는 경우 | `client`이 필요합니다. 러너는 해당 명시적 상태에서 재개하고 재개된 세션의 수명 주기를 소유합니다. | +| `client` | 러너가 샌드박스 세션을 생성, 재개, 정리하도록 하려는 경우 | 활성 샌드박스 `session` 을 제공하지 않는 한 필수입니다. | +| `session` | 활성 샌드박스 세션을 이미 직접 생성한 경우 | 호출자가 수명 주기를 소유하며, 러너는 해당 활성 샌드박스 세션을 재사용합니다. | +| `session_state` | 직렬화된 샌드박스 세션 상태가 있지만 활성 샌드박스 세션 객체는 없는 경우 | `client` 이 필요하며, 러너는 해당 명시적 상태에서 재개하고 재개된 세션의 수명 주기를 소유합니다. |
실제로 러너는 다음 순서로 샌드박스 세션을 결정합니다. -1. `run_config.sandbox.session`을 주입하면 해당 라이브 샌드박스 세션을 직접 재사용합니다. -2. 그렇지 않고 `RunState`에서 실행을 재개한다면 저장된 샌드박스 세션 상태를 재개합니다. -3. 그렇지 않고 `run_config.sandbox.session_state`을 전달하면 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. -4. 그렇지 않으면 새 샌드박스 세션을 생성합니다. 새 세션에는 제공된 경우 `run_config.sandbox.manifest`을 사용하고, 제공되지 않았다면 `agent.default_manifest`을 사용합니다. +1. `run_config.sandbox.session` 를 주입하면 해당 활성 샌드박스 세션을 직접 재사용합니다. +2. 그렇지 않고 `RunState` 에서 실행을 재개하는 경우 저장된 샌드박스 세션 상태를 재개합니다. +3. 그렇지 않고 `run_config.sandbox.session_state` 을 전달하면 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. +4. 그렇지 않으면 새 샌드박스 세션을 생성합니다. 이 새 세션에는 제공된 경우 `run_config.sandbox.manifest` 을 사용하고, 제공되지 않은 경우 `agent.default_manifest` 을 사용합니다. ### 새 세션 입력 @@ -468,31 +470,56 @@ finally:
-| 옵션 | 사용 시점 | 참고 사항 | +| 옵션 | 사용 시점 | 참고 | | --- | --- | --- | -| `manifest` | 일회성 새 세션 워크스페이스 재정의가 필요한 경우 | 생략하면 `agent.default_manifest`으로 대체됩니다. | +| `manifest` | 새 세션의 워크스페이스를 일회성으로 재정의하려는 경우 | 생략하면 `agent.default_manifest` 으로 대체됩니다. | | `snapshot` | 새 샌드박스 세션을 스냅샷에서 초기화해야 하는 경우 | 재개와 유사한 흐름이나 원격 스냅샷 클라이언트에 유용합니다. | -| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 이와 유사한 클라이언트별 설정에 흔히 사용됩니다. | +| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 시간 제한과 유사한 클라이언트별 설정에 일반적으로 사용됩니다. |
+### 모델 대상 작업 디렉터리 + +여러 실행에서 하나의 샌드박스 세션을 공유하면서 서로 다른 하위 디렉터리에서 작업해야 할 때 POSIX 워크스페이스 상대 디렉터리로 `cwd` 을 설정하세요. 러너가 `cwd` 을 검증할 때 해당 디렉터리가 존재하고 구성된 샌드박스 사용자가 액세스할 수 있어야 합니다. 새 세션의 경우 러너가 먼저 매니페스트를 구체화하므로 이 검증 전에 매니페스트가 디렉터리를 생성할 수 있습니다. + +```python +from agents import Runner +from agents.run import RunConfig +from agents.sandbox import SandboxRunConfig + +result = await Runner.run( + agent, + "Work only on task A.", + run_config=RunConfig( + sandbox=SandboxRunConfig( + session=shared_sandbox, + cwd="tasks/task-a", + ), + ), +) +``` + +기본 제공 `exec_command`, `view_image`, `apply_patch` 도구가 사용하는 상대 경로는 `cwd` 에서 해석됩니다. `cwd` 값 자체에는 절대 경로, `..` 같은 상위 경로 세그먼트, 빈 값을 사용할 수 없습니다. 문자열 값에는 슬래시를 사용해야 합니다. 상대 `PurePath` 값은 POSIX 형식으로 정규화되지만 절대 `PurePath` 값은 계속 유효하지 않습니다. 직접 사용하는 `BaseSandboxSession` 파일 API는 계속 워크스페이스 루트를 기준으로 하므로, `cwd` 는 `Manifest.root` 또는 세션의 기본 워크스페이스 경계를 변경하지 않습니다. 이 설정은 상대 경로 해석만 변경합니다. 실행을 `cwd` 에 제한하거나 공유 세션의 워크스페이스 정책에서 허용한 다른 경로에 대한 액세스를 차단하지 않습니다. + +경로를 포함하는 사용자 지정 기능은 모델이 제공한 상대 경로를 해석할 때 바인딩된 [`SandboxWorkspaceScope`][agents.sandbox.workspace_paths.SandboxWorkspaceScope] 을 적용해야 합니다. 하나의 샌드박스 세션을 공유하면서 모델 대상 작업 디렉터리를 분리하는 두 개의 동시 실행은 [examples/sandbox/shared_session_workdirs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/shared_session_workdirs.py)를 참조하세요. + ### 구체화 제어 -`concurrency_limits`은 동시에 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요하면 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`을 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None`으로 설정하세요. +`concurrency_limits` 은 병렬로 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요하면 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` 를 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None` 으로 설정하세요. -`archive_limits`은 아카이브 추출에 대한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임계값을 활성화하려면 `archive_limits=SandboxArchiveLimits()`로 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요하면 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한 없이 기본 동작을 유지하려면 `archive_limits=None`으로 두고, 특정 제한만 비활성화하려면 개별 필드를 `None`으로 설정하세요. +`archive_limits` 은 아카이브 추출에 대한 SDK 측 리소스 검사를 제어합니다. SDK 기본 임곗값을 활성화하려면 `archive_limits=SandboxArchiveLimits()` 를 설정하고, 아카이브에 더 엄격한 리소스 제어가 필요하면 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 같은 명시적 값을 전달하세요. SDK 아카이브 리소스 제한이 없는 기본 동작을 유지하려면 `archive_limits=None` 로 두고, 특정 제한만 비활성화하려면 개별 필드를 `None` 로 설정하세요. -다음과 같은 몇 가지 사항을 기억해 두는 것이 좋습니다. +다음과 같은 사항에 유의해야 합니다. -- 새 세션: `manifest=`와 `snapshot=`은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. -- 재개와 스냅샷: `session_state=`은 이전에 직렬화된 샌드박스 상태에 다시 연결하지만, `snapshot=`은 저장된 워크스페이스 내용으로 새 샌드박스 세션을 초기화합니다. -- 클라이언트별 옵션: `options=`은 샌드박스 클라이언트에 따라 달라집니다. Docker와 많은 호스티드 클라이언트에는 이 옵션이 필요합니다. -- 주입된 라이브 세션: 실행 중인 샌드박스 `session`을 전달하면 기능 기반 매니페스트 업데이트에서 호환되는 비마운트 항목을 추가할 수 있습니다. 그러나 `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups`을 변경하거나, 기존 항목을 제거하거나, 항목 유형을 대체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. -- 러너 API: `SandboxAgent` 실행은 계속해서 일반 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API를 사용합니다. +- 새 세션: `manifest=` 및 `snapshot=` 은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. +- 재개와 스냅샷: `session_state=` 은 이전에 직렬화된 샌드박스 상태에 다시 연결하고, `snapshot=` 는 저장된 워크스페이스 콘텐츠로 새 샌드박스 세션을 초기화합니다. +- 클라이언트별 옵션: `options=` 은 샌드박스 클라이언트에 따라 달라지며, Docker와 많은 호스티드 클라이언트에서 필요합니다. +- 주입된 활성 세션: 실행 중인 샌드박스 `session` 을 전달하면 기능 기반 매니페스트 업데이트에서 호환되는 비마운트 항목을 추가할 수 있습니다. `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups` 를 변경하거나, 기존 항목을 제거하거나, 항목 유형을 교체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. +- 러너 API: `SandboxAgent` 실행은 계속 일반적인 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API를 사용합니다. ## 전체 예제: 코딩 작업 -다음 코딩 스타일 예제는 적절한 기본 시작점입니다. +다음 코딩 스타일 예제는 기본 시작점으로 적합합니다. ```python import asyncio @@ -524,9 +551,9 @@ def build_agent(model: str) -> SandboxAgent[None]: "and summarize the file changes and risks. " "Read `repo/task.md` before editing files. Stay grounded in the repository, preserve " "existing behavior, and mention the exact verification command you ran. " - "Use the `$credit-note-fixer` skill before editing files. If the repo lives under " - "`repo/`, remember that `apply_patch` paths stay relative to the sandbox workspace " - "root, so edits still target `repo/...`." + "Use the `$credit-note-fixer` skill before editing files. " + "This example leaves `SandboxRunConfig.cwd` unset, so `apply_patch` paths stay " + "relative to the sandbox workspace root and edits still target `repo/...`." ), # Put repos and task files in the manifest. default_manifest=Manifest( @@ -571,19 +598,19 @@ if __name__ == "__main__": ) ``` -[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제는 Unix 로컬 실행에서 결정론적으로 검증할 수 있도록 작은 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 어떤 언어로도 구성할 수 있습니다. +[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참조하세요. 이 예제는 Unix 로컬 실행에서 결정론적으로 검증할 수 있도록 작은 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 무엇이든 사용할 수 있습니다. ## 일반적인 패턴 -위의 전체 예제에서 시작하세요. 많은 경우 동일한 `SandboxAgent`을 그대로 유지하면서 샌드박스 클라이언트, 샌드박스 세션 소스 또는 워크스페이스 소스만 변경할 수 있습니다. +위의 전체 예제에서 시작하세요. 많은 경우 동일한 `SandboxAgent` 을 그대로 유지하면서 샌드박스 클라이언트, 샌드박스 세션 소스 또는 워크스페이스 소스만 변경할 수 있습니다. ### 샌드박스 클라이언트 전환 -에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 일관성이 필요하면 Docker를 사용하고, 제공자가 관리하는 실행을 원하면 호스티드 제공자를 사용하세요. 예제와 제공자 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. +에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 동등성이 필요하면 Docker를 사용하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자를 사용하세요. 예제와 공급자 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. ### 워크스페이스 재정의 -에이전트 정의는 그대로 유지하고 새 세션의 매니페스트만 교체합니다. +에이전트 정의는 그대로 유지하고 새 세션 매니페스트만 교체하세요. ```python from agents.run import RunConfig @@ -603,11 +630,11 @@ run_config = RunConfig( ) ``` -에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 서로 다른 저장소, 패킷 또는 작업 번들에 적용해야 할 때 사용하세요. 위에서 검증된 코딩 예제는 일회성 재정의 대신 `default_manifest`을 사용해 동일한 패턴을 보여 줍니다. +에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 여러 저장소, 패킷 또는 작업 번들에 실행하려면 이를 사용하세요. 위의 검증된 코딩 예제는 일회성 재정의 대신 `default_manifest` 을 사용하는 동일한 패턴을 보여 줍니다. ### 샌드박스 세션 주입 -명시적인 수명 주기 제어, 실행 후 검사 또는 출력 복사가 필요할 때 라이브 샌드박스 세션을 주입합니다. +명시적인 수명 주기 제어, 실행 후 검사 또는 출력 복사가 필요하면 활성 샌드박스 세션을 주입하세요. ```python from agents import Runner @@ -628,11 +655,11 @@ async with sandbox: ) ``` -실행 후 워크스페이스를 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려 할 때 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)와 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참고하세요. +실행 후 워크스페이스를 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려면 이를 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 및 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. ### 세션 상태에서 재개 -이미 `RunState` 외부에서 샌드박스 상태를 직렬화했다면 러너가 해당 상태에 다시 연결하도록 합니다. +`RunState` 외부에서 샌드박스 상태를 이미 직렬화했다면 러너가 해당 상태에서 다시 연결하도록 하세요. ```python from agents.run import RunConfig @@ -649,15 +676,15 @@ run_config = RunConfig( ) ``` -샌드박스 상태가 자체 스토리지나 작업 시스템에 있고 `Runner`이 해당 상태에서 직접 재개하도록 하려는 경우 사용하세요. 직렬화 및 역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참고하세요. +샌드박스 상태가 자체 스토리지나 작업 시스템에 있으며 `Runner` 이 해당 상태에서 직접 재개하도록 하려면 이를 사용하세요. 직렬화/역직렬화 흐름은 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)를 참조하세요. -세션 상태 직렬화에서는 네이티브 `host_path` 값이 생략됩니다. 호스트 기반 권한 부여를 재개하려면 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`을 통해 현재의 신뢰할 수 있는 매니페스트를 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 입력이나 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생하지 마세요. +세션 상태 직렬화에서는 네이티브 `host_path` 값이 생략됩니다. 호스트 기반 권한 부여를 재개하려면 현재 신뢰할 수 있는 매니페스트를 `SandboxRunConfig.manifest` 또는 `agent.default_manifest` 을 통해 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 입력이나 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생하지 마세요. -세션 상태와 `RunState` 직렬화에서는 클라우드 마운트 자격 증명, 자격 증명이 포함된 보조 구성 및 컨테이너 내부 자격 증명 노출 승인도 제거됩니다. 마운트된 세션 재개를 지원하는 백엔드에서 상태에 삭제된 마운트 권한 정보가 포함된 경우 `SandboxRunConfig.manifest` 또는 `agent.default_manifest`을 통해 현재의 신뢰할 수 있는 매니페스트를 제공하세요. `"data"`이라는 마운트 항목에 마운트 범위 승인이 필요한 경우 재개하기 전에 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")`을 사용해 복사된 매니페스트를 유지하세요. 광범위한 권한에는 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")`을 사용하고, 마운트가 두 권한 클래스를 모두 사용하는 경우 두 메서드를 모두 호출하세요. 승인이 필요한 정확한 마운트 경로를 모두 전달하세요. Agents SDK는 현재 신뢰할 수 있는 매니페스트가 영속화된 상태와 자격 증명을 제외한 마운트 토폴로지가 정확히 동일한 경우에만 자격 증명을 복원합니다. 신뢰할 수 있는 구성이 없거나 일치하지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 상태 자체로는 권한이 부여되지 않습니다. `VercelSandboxClient`은 마운트된 세션을 재개할 수 없으므로, 대신 신뢰할 수 있는 매니페스트를 사용해 새 샌드박스를 시작하세요. +세션 상태 및 `RunState` 직렬화에서는 클라우드 마운트 자격 증명, 자격 증명을 포함하는 보조 구성, 컨테이너 내부 자격 증명 노출 승인도 제거됩니다. 마운트된 세션 재개를 지원하는 백엔드의 경우 상태에 삭제된 마운트 권한 정보가 포함되어 있다면 현재 신뢰할 수 있는 매니페스트를 `SandboxRunConfig.manifest` 또는 `agent.default_manifest` 를 통해 제공하세요. 이름이 `"data"` 인 마운트 항목에 마운트 범위 승인이 필요하면 재개 전에 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` 을 사용하여 복사된 매니페스트를 유지하세요. 광범위한 권한에는 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")` 를 사용하고, 마운트에서 두 권한 클래스를 모두 사용하는 경우 두 메서드를 모두 호출하세요. 승인이 필요한 모든 정확한 마운트 경로를 전달하세요. Agents SDK는 현재 신뢰할 수 있는 매니페스트의 자격 증명 없는 마운트 토폴로지가 저장된 상태와 정확히 일치하는 경우에만 자격 증명을 복원합니다. 신뢰할 수 있는 구성이 없거나 일치하지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 상태 자체로는 절대 권한이 부여되지 않습니다. `VercelSandboxClient` 은 마운트된 세션을 재개할 수 없으므로 신뢰할 수 있는 매니페스트로 새 샌드박스를 시작하세요. ### 스냅샷에서 시작 -저장된 파일과 결과물로 새 샌드박스를 초기화합니다. +저장된 파일과 아티팩트로 새 샌드박스를 초기화하세요. ```python from pathlib import Path @@ -674,11 +701,11 @@ run_config = RunConfig( ) ``` -새 샌드박스 세션을 생성하는 실행이 `agent.default_manifest`만 사용하는 대신 저장된 워크스페이스 내용에서 시작해야 할 때 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참고하세요. +새 샌드박스 세션을 생성하는 실행이 `agent.default_manifest` 만 사용하는 대신 저장된 워크스페이스 콘텐츠에서 시작해야 할 때 이를 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참조하세요. -### Git에서 스킬 불러오기 +### Git에서 스킬 로드 -로컬 스킬 소스를 저장소 기반 소스로 교체합니다. +로컬 스킬 소스를 저장소 기반 소스로 교체하세요. ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -689,11 +716,11 @@ capabilities = Capabilities.default() + [ ] ``` -스킬 번들의 릴리스 주기가 별도로 관리되거나 여러 샌드박스에서 공유해야 할 때 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참고하세요. +스킬 번들에 자체 릴리스 주기가 있거나 여러 샌드박스에서 공유해야 할 때 이를 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참조하세요. ### 도구로 노출 -도구 에이전트에는 자체 샌드박스 경계를 제공하거나 상위 실행의 라이브 샌드박스를 재사용하도록 할 수 있습니다. 빠른 읽기 전용 탐색기 에이전트에는 재사용이 유용합니다. 별도의 샌드박스를 생성하고, 채우고, 스냅샷으로 만드는 비용 없이 상위 실행이 사용하는 정확한 워크스페이스를 검사할 수 있습니다. +도구 에이전트는 자체 샌드박스 경계를 사용하거나 상위 실행의 활성 샌드박스를 재사용할 수 있습니다. 재사용은 빠른 읽기 전용 탐색 에이전트에 유용합니다. 다른 샌드박스를 생성하거나, 초기화하거나, 스냅샷하는 비용 없이 상위 실행이 사용하는 정확한 워크스페이스를 검사할 수 있습니다. ```python from agents import Runner @@ -775,9 +802,9 @@ async with sandbox: ) ``` -여기서 상위 에이전트는 `coordinator`으로 실행되고, 탐색기 도구 에이전트는 동일한 라이브 샌드박스 세션 내부에서 `explorer`으로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색기가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자 및 그룹에만 제공되므로, 상위 에이전트는 최종 결과물을 작성할 수 있지만 탐색기는 읽기 전용으로 유지됩니다. +여기서 상위 에이전트는 `coordinator` 로 실행되고, 탐색 도구 에이전트는 동일한 활성 샌드박스 세션 내부에서 `explorer` 으로 실행됩니다. `pricing_packet/` 항목은 `other` 사용자가 읽을 수 있으므로 탐색 에이전트가 빠르게 검사할 수 있지만 쓰기 비트는 없습니다. `work/` 디렉터리는 코디네이터의 사용자/그룹만 사용할 수 있으므로, 탐색 에이전트는 읽기 전용 상태를 유지하면서 상위 에이전트가 최종 아티팩트를 작성할 수 있습니다. -도구 에이전트에 실제 격리가 필요하다면 자체 샌드박스 `RunConfig`을 제공하세요. +도구 에이전트에 실제 격리가 필요하다면 자체 샌드박스 `RunConfig` 을 제공하세요. ```python from docker import from_env as docker_from_env @@ -803,11 +830,11 @@ rollout_agent.as_tool( ) ``` -도구 에이전트가 자유롭게 변경하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드 또는 이미지를 사용해야 할 때는 별도 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. +도구 에이전트가 자유롭게 변경하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드/이미지를 사용해야 할 때 별도의 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참조하세요. -### 로컬 도구 및 MCP와 결합 +### 로컬 도구 및 MCP와의 결합 -샌드박스 워크스페이스를 유지하면서 동일한 에이전트에서 일반 도구도 사용합니다. +동일한 에이전트에서 일반 도구를 계속 사용하면서 샌드박스 워크스페이스를 유지하세요. ```python from agents.sandbox import SandboxAgent @@ -822,46 +849,46 @@ agent = SandboxAgent( ) ``` -워크스페이스 검사가 에이전트 작업의 일부일 뿐인 경우 사용하세요. [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)를 참고하세요. +워크스페이스 검사가 에이전트 작업의 일부일 뿐일 때 이를 사용하세요. [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)를 참조하세요. ## 메모리 -향후 샌드박스 에이전트 실행이 이전 실행으로부터 학습해야 할 때 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제한 다음, 이후 실행에서 해당 파일을 읽을 수 있습니다. +이후 샌드박스 에이전트 실행이 이전 실행에서 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제한 다음 이후 실행에서 해당 파일을 읽을 수 있습니다. -설정, 읽기 및 생성 동작, 다중 턴 대화, 레이아웃 격리에 관한 내용은 [에이전트 메모리](memory.md)를 참고하세요. +설정, 읽기/생성 동작, 멀티턴 대화, 레이아웃 격리는 [에이전트 메모리](memory.md)를 참조하세요. ## 구성 패턴 단일 에이전트 패턴을 이해한 다음에는 더 큰 시스템에서 샌드박스 경계를 어디에 둘지 결정해야 합니다. -샌드박스 에이전트도 SDK의 나머지 부분과 함께 구성할 수 있습니다. +샌드박스 에이전트는 SDK의 나머지 요소와 계속 결합할 수 있습니다. - [핸드오프](../handoffs.md): 샌드박스를 사용하지 않는 접수 에이전트에서 문서 중심 작업을 샌드박스 검토자에게 핸드오프합니다. -- [Agents as tools](../tools.md#agents-as-tools): 여러 샌드박스 에이전트를 도구로 노출합니다. 일반적으로 각 `Agent.as_tool(...)` 호출에서 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`을 전달하여 각 도구에 자체 샌드박스 경계를 제공합니다. +- [Agents as tools](../tools.md#agents-as-tools): 여러 샌드박스 에이전트를 도구로 노출합니다. 일반적으로 각 `Agent.as_tool(...)` 호출에 `run_config=RunConfig(sandbox=SandboxRunConfig(...))` 을 전달하여 각 도구에 자체 샌드박스 경계를 제공합니다. - [MCP](../mcp.md) 및 일반 함수 도구: 샌드박스 기능은 `mcp_servers` 및 일반 Python 도구와 함께 사용할 수 있습니다. -- [에이전트 실행](../running_agents.md): 샌드박스 실행도 일반 `Runner` API를 사용합니다. +- [에이전트 실행](../running_agents.md): 샌드박스 실행도 일반적인 `Runner` API를 계속 사용합니다. -특히 다음 두 패턴이 흔히 사용됩니다. +특히 다음 두 가지 패턴이 일반적입니다. - 샌드박스를 사용하지 않는 에이전트가 워크스페이스 격리가 필요한 워크플로 부분만 샌드박스 에이전트로 핸드오프 -- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하며, 일반적으로 각 `Agent.as_tool(...)` 호출마다 별도의 샌드박스 `RunConfig`을 사용하여 각 도구에 자체 격리 워크스페이스 제공 +- 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하며, 일반적으로 각 `Agent.as_tool(...)` 호출마다 별도의 샌드박스 `RunConfig` 을 사용해 각 도구에 자체 격리 워크스페이스 제공 ### 턴과 샌드박스 실행 -핸드오프와 Agents as tools 호출을 별도로 설명하면 이해하는 데 도움이 됩니다. +핸드오프와 에이전트 도구 호출을 별도로 설명하면 이해하기 쉽습니다. -핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프가 있습니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면 동일한 실행의 다음 모델 호출이 샌드박스 에이전트용으로 준비되며, 해당 샌드박스 에이전트가 다음 턴을 맡습니다. 즉, 핸드오프는 동일한 실행의 다음 턴을 소유하는 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참고하세요. +핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프가 존재합니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면 동일한 실행의 다음 모델 호출이 샌드박스 에이전트용으로 준비되고, 해당 샌드박스 에이전트가 다음 턴을 담당합니다. 즉, 핸드오프는 동일한 실행의 다음 턴을 담당하는 에이전트를 변경합니다. [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)를 참조하세요. -`Agent.as_tool(...)`에서는 관계가 다릅니다. 외부 오케스트레이터는 하나의 외부 턴을 사용해 도구 호출을 결정하고, 해당 도구 호출은 샌드박스 에이전트의 중첩 실행을 시작합니다. 중첩 실행에는 자체 턴 루프, `max_turns`, 승인 및 일반적으로 자체 샌드박스 `RunConfig`이 있습니다. 중첩 턴 하나로 완료될 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터의 관점에서는 이 모든 작업이 하나의 도구 호출 뒤에서 이루어지므로, 중첩 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참고하세요. +`Agent.as_tool(...)` 에서는 관계가 다릅니다. 외부 오케스트레이터가 도구 호출을 결정하기 위해 하나의 외부 턴을 사용하며, 해당 도구 호출은 샌드박스 에이전트의 중첩 실행을 시작합니다. 중첩 실행에는 자체 턴 루프, `max_turns`, 승인, 그리고 일반적으로 자체 샌드박스 `RunConfig` 이 있습니다. 중첩 턴 하나로 끝날 수도 있고 여러 턴이 걸릴 수도 있습니다. 외부 오케스트레이터의 관점에서는 이 모든 작업이 하나의 도구 호출 뒤에서 이루어지므로 중첩 턴은 외부 실행의 턴 카운터를 증가시키지 않습니다. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참조하세요. -승인 동작도 동일한 구분을 따릅니다. +승인 동작도 동일하게 구분됩니다. - 핸드오프에서는 샌드박스 에이전트가 해당 실행의 활성 에이전트가 되므로 승인이 동일한 최상위 실행에 유지됩니다. -- `Agent.as_tool(...)`에서는 샌드박스 도구 에이전트 내부에서 발생한 승인도 외부 실행에 표시되지만, 저장된 중첩 실행 상태에서 가져오며 외부 실행이 재개될 때 중첩 샌드박스 실행을 재개합니다. +- `Agent.as_tool(...)` 에서는 샌드박스 도구 에이전트 내부에서 발생한 승인이 외부 실행에 계속 표시되지만, 저장된 중첩 실행 상태에서 제공되며 외부 실행이 재개될 때 중첩 샌드박스 실행을 재개합니다. ## 추가 자료 - [빠른 시작](../sandbox_agents.md): 샌드박스 에이전트 하나를 실행합니다. -- [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드 및 마운트 옵션을 선택합니다. +- [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드, 마운트 옵션을 선택합니다. - [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 얻은 학습 내용을 보존하고 재사용합니다. -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 실행 가능한 로컬, 코딩, 메모리, 핸드오프 및 에이전트 구성 패턴입니다. \ No newline at end of file +- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 실행 가능한 로컬, 코딩, 메모리, 핸드오프, 에이전트 구성 패턴입니다. \ No newline at end of file diff --git a/docs/ko/sessions/index.md b/docs/ko/sessions/index.md index 55c80c90c0..78473c2f3e 100644 --- a/docs/ko/sessions/index.md +++ b/docs/ko/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 세션 -Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 기본 제공 세션 메모리를 제공하므로, 턴 사이에서 `.to_input_list()`을 수동으로 처리할 필요가 없습니다. +Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 유지하는 내장 세션 메모리를 제공하므로, 턴 사이에 `.to_input_list()`을 수동으로 처리할 필요가 없습니다. -세션은 특정 세션의 대화 기록을 저장하므로, 명시적인 수동 메모리 관리 없이도 에이전트가 컨텍스트를 유지할 수 있습니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. +세션은 특정 세션의 대화 기록을 저장하여, 명시적으로 메모리를 수동 관리하지 않아도 에이전트가 컨텍스트를 유지할 수 있게 합니다. 이는 에이전트가 이전 상호작용을 기억해야 하는 채팅 애플리케이션이나 멀티턴 대화를 구축할 때 특히 유용합니다. -SDK가 클라이언트 측 메모리를 관리하도록 하려면 세션을 사용합니다. 동일한 실행에서는 세션을 실행 수준의 연속 실행 옵션인 `conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`과 함께 사용할 수 없습니다. 대신 OpenAI 서버에서 관리하는 연속 실행을 사용하려면 세션을 추가로 적용하지 말고 해당 메커니즘 중 하나를 선택합니다. +SDK가 클라이언트 측 메모리를 관리하게 하려면 세션을 사용하세요. 동일한 실행에서 세션은 실행 수준 연속 실행 옵션인 `conversation_id`, `previous_response_id`, `auto_previous_response_id`과 함께 사용할 수 없습니다. 대신 OpenAI 서버에서 관리하는 연속 실행을 원한다면 세션을 추가로 겹쳐 사용하지 말고 이러한 메커니즘 중 하나를 선택하세요. ## 빠른 시작 @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 동일한 세션을 사용한 인터럽션된 실행 재개 +## 동일한 세션을 사용한 인터럽션(중단 처리)된 실행 재개 -실행이 승인을 위해 일시 중지된 경우 동일한 세션 인스턴스 또는 동일한 세션 ID 및 동일한 기본 스토리지 백엔드로 구성된 다른 인스턴스를 사용하여 재개해야 합니다. 그래야 재개된 턴이 저장된 동일한 대화 기록을 이어서 사용합니다. +승인을 위해 실행이 일시 중지되면 동일한 세션 인스턴스(또는 동일한 세션 ID와 동일한 기본 스토리지 백엔드로 구성된 다른 인스턴스)를 사용하여 재개하세요. 그러면 재개된 턴이 저장된 동일한 대화 기록을 이어갑니다. ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -67,9 +67,9 @@ if result.interruptions: 세션 메모리가 활성화되면 다음과 같이 동작합니다. -1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 조회하여 입력 항목 앞에 추가합니다. +1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 가져와 입력 항목 앞에 추가합니다. 2. **각 실행 후**: 실행 중 생성된 모든 새 항목(사용자 입력, 어시스턴트 응답, 도구 호출 등)이 세션에 자동으로 저장됩니다. -3. **컨텍스트 보존**: 동일한 세션을 사용하는 각 후속 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. +3. **컨텍스트 보존**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. 따라서 `.to_input_list()`을 수동으로 호출하고 실행 사이의 대화 상태를 관리할 필요가 없습니다. @@ -77,17 +77,17 @@ if result.interruptions: 세션을 전달하면 러너는 일반적으로 다음 순서로 모델 입력을 준비합니다. -1. 세션 기록(`session.get_items(...)`에서 조회) +1. 세션 기록(`session.get_items(...)`에서 가져옴) 2. 새 턴 입력 -모델 호출 전에 이 병합 단계를 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용합니다. 콜백은 다음 두 목록을 받습니다. +모델 호출 전에 이 병합 단계를 맞춤 설정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. 콜백은 다음 두 목록을 받습니다. -- `history`: 조회된 세션 기록(이미 입력 항목 형식으로 정규화됨) +- `history`: 가져온 세션 기록(이미 입력 항목 형식으로 정규화됨) - `new_input`: 현재 턴의 새 입력 항목 -모델에 전송할 최종 입력 항목 목록을 반환합니다. +모델로 전송할 최종 입력 항목 목록을 반환하세요. -콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속하는 항목만 저장합니다. 따라서 이전 기록의 순서를 변경하거나 필터링하더라도 기존 세션 항목이 새 입력으로 다시 저장되지 않습니다. +콜백은 두 목록의 복사본을 받으므로 안전하게 변경할 수 있습니다. 반환된 목록은 해당 턴의 모델 입력을 제어하지만, SDK는 여전히 새 턴에 속하는 항목만 저장합니다. 따라서 이전 기록을 재정렬하거나 필터링해도 이전 세션 항목이 새로운 입력으로 다시 저장되지 않습니다. ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -세션이 항목을 저장하는 방식을 변경하지 않고 기록을 사용자 지정하여 정리하거나, 순서를 변경하거나, 선별적으로 포함해야 할 때 이 기능을 사용합니다. 모델 호출 직전에 추가적인 최종 처리 단계가 필요하면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]을 사용합니다. +세션의 항목 저장 방식을 변경하지 않고 기록을 맞춤 정리하거나 재정렬하거나 선택적으로 포함해야 할 때 사용하세요. 모델 호출 직전에 나중 단계의 최종 처리가 필요하다면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. -## 조회 기록 제한 +## 가져오는 기록 제한 -각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]을 사용합니다. +각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]을 사용하세요. -- `SessionSettings(limit=None)`(기본값): 사용 가능한 모든 세션 항목 조회 -- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 조회 +- `SessionSettings(limit=None)`(기본값): 사용 가능한 모든 세션 항목을 가져옴 +- `SessionSettings(limit=N)`: 가장 최근의 `N`개 항목만 가져옴 -[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]을 통해 실행별로 적용할 수 있습니다. +[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]를 통해 실행별로 적용할 수 있습니다. ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,13 +134,13 @@ result = await Runner.run( ) ``` -세션 구현에서 기본 세션 설정을 제공하는 경우 `RunConfig.session_settings`의 `None`이 아닌 각 값은 해당 실행에서 대응하는 기본값을 재정의합니다. 세션의 기본 동작을 변경하지 않고 조회 크기를 제한하려는 긴 대화에 유용합니다. +세션 구현에서 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`의 `None`이 아닌 각 값은 해당 실행에서 대응하는 기본값을 재정의합니다. 이는 세션의 기본 동작을 변경하지 않고 가져오는 기록의 크기를 제한하려는 긴 대화에 유용합니다. ## 메모리 작업 ### 기본 작업 -세션은 대화 기록 관리를 위한 여러 작업을 지원합니다. +세션은 대화 기록을 관리하기 위한 여러 작업을 지원합니다. ```python from agents import SQLiteSession @@ -196,34 +196,34 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 기본 제공 세션 구현 +## 내장 세션 구현 SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다. -### 기본 제공 세션 구현 선택 +### 내장 세션 구현 선택 -아래의 상세 예제를 읽기 전에 이 표를 참고하여 시작점을 선택합니다. +아래의 자세한 예제를 읽기 전에 이 표를 사용하여 시작점을 선택하세요. -| 세션 유형 | 적합한 용도 | 참고 사항 | +| 세션 유형 | 적합한 용도 | 참고 | | --- | --- | --- | -| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 기본 제공되며 가볍고, 파일 기반 또는 인메모리 방식 | +| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 내장형, 경량, 파일 기반 또는 인메모리 | | `AsyncSQLiteSession` | `aiosqlite`을 사용하는 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | -| `RedisSession` | 여러 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | -| `SQLAlchemySession` | 기존 데이터베이스가 있는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 호환 | -| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 스토리지가 필요한 앱 | 비동기 pymongo 사용, 순서 지정을 위한 원자적 시퀀스 카운터 제공 | +| `RedisSession` | 워커/서비스 간 공유 메모리 | 지연 시간이 짧은 분산 배포에 적합 | +| `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 호환 | +| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 스토리지가 필요한 앱 | 비동기 pymongo 사용, 순서 지정을 위한 원자적 시퀀스 카운터 | | `DaprSession` | Dapr 사이드카를 사용하는 클라우드 네이티브 배포 | 여러 상태 저장소와 TTL 및 일관성 제어 지원 | -| `OpenAIConversationsSession` | OpenAI의 서버 관리형 스토리지 | OpenAI Conversations API 기반 기록 | +| `OpenAIConversationsSession` | OpenAI에서 서버가 관리하는 스토리지 | OpenAI Conversations API 기반 기록 | | `OpenAIResponsesCompactionSession` | 자동 압축이 필요한 긴 대화 | 다른 세션 백엔드를 감싸는 래퍼 | -| `AdvancedSQLiteSession` | SQLite와 분기/분석 기능 | 더 많은 기능을 제공하며 전용 페이지 참조 | -| `EncryptedSession` | 다른 세션에 암호화 및 TTL 추가 | 래퍼이며 먼저 기본 백엔드 선택 필요 | +| `AdvancedSQLiteSession` | SQLite와 브랜칭/분석 | 더 많은 기능을 제공하며 전용 페이지 참조 | +| `EncryptedSession` | 다른 세션에 암호화와 TTL 추가 | 래퍼이며 먼저 기본 백엔드를 선택해야 함 | -일부 구현에는 추가 세부 정보를 제공하는 전용 페이지가 있으며, 해당 하위 섹션에 링크가 포함되어 있습니다. +일부 구현에는 추가 세부 정보를 제공하는 전용 페이지가 있으며, 각 하위 섹션에 인라인으로 링크되어 있습니다. -ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성을 위해 `chatkit.store.Store` 구현을 사용합니다. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만 ChatKit 저장소를 바로 대체할 수는 없습니다. [`chatkit-python` ChatKit 데이터 저장소 구현 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참조하세요. +ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성을 위해 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만 ChatKit 스토어를 그대로 대체할 수는 없습니다. [`chatkit-python` ChatKit 데이터 스토어 구현 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참조하세요. ### OpenAI Conversations API 세션 -`OpenAIConversationsSession`을 통해 [OpenAI Conversations API](https://platform.openai.com/docs/api-reference/conversations)를 사용합니다. +`OpenAIConversationsSession`를 통해 [OpenAI의 Conversations API](https://platform.openai.com/docs/api-reference/conversations)를 사용하세요. ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -259,7 +259,7 @@ print(result.final_output) # "California" ### OpenAI Responses 압축 세션 -Responses API(`responses.compact`)를 사용하여 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용합니다. 이 구현은 기본 세션을 감싸며 `should_trigger_compaction`에 따라 각 턴 후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 구현으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. +Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 클래스는 기본 세션을 감싸며 `should_trigger_compaction`에 따라 각 턴 후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 클래스로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. #### 일반적인 사용법(자동 압축) @@ -278,19 +278,21 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -기본적으로 SDK는 각 턴 후 압축 후보가 임곗값을 충족하는지 확인하고, 충족할 때만 압축합니다. +기본적으로 SDK는 각 턴 후 압축 후보가 임계값을 충족하는지 확인하고, 충족하는 경우에만 압축합니다. -`compaction_mode="previous_response_id"`은 압축 세션에서 유지하는 Responses API 응답 ID를 사용하며 해당 응답 체인을 계속 사용할 수 있을 때 가장 효과적입니다. 반면 `compaction_mode="input"`은 현재 세션 항목을 바탕으로 압축 요청을 다시 구성합니다. 이는 응답 체인을 사용할 수 없거나 세션 콘텐츠를 기준 데이터로 사용하려는 경우에 유용합니다. 기본값인 `"auto"`은 사용 가능한 가장 안전한 옵션을 선택합니다. +자동 압축이 실행되면 SDK는 `Runner.run(...)`이 반환되거나 스트리밍 이벤트 이터레이터가 닫히기 전에 압축이 완료될 때까지 기다립니다. 압축 요청에서 보고된 사용량은 해당 실행의 [`Usage`](../usage.md) 합계에 포함됩니다. 기본적으로 나중에 수행된 수동 `run_compaction()` 호출에는 이를 감싸는 실행 컨텍스트가 없으므로 완료된 실행의 사용량 객체를 업데이트하지 않습니다. -에이전트가 `ModelSettings(store=False)`으로 실행되면 Responses API는 나중에 조회할 수 있도록 마지막 응답을 보존하지 않습니다. 이러한 무상태 설정에서는 기본 `"auto"` 모드가 `previous_response_id`에 의존하는 대신 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)을 참조하세요. +`compaction_mode="previous_response_id"`은 압축 세션이 유지하는 Responses API 응답 ID를 사용하며 해당 응답 체인을 계속 사용할 수 있을 때 가장 잘 작동합니다. 반면 `compaction_mode="input"`은 현재 세션 항목으로 압축 요청을 다시 구성하므로, 응답 체인을 사용할 수 없거나 세션 콘텐츠를 신뢰할 수 있는 기준으로 사용하려는 경우에 유용합니다. 기본 `"auto"`는 사용 가능한 가장 안전한 옵션을 선택합니다. -#### 스트리밍을 차단할 수 있는 자동 압축 +에이전트가 `ModelSettings(store=False)`으로 실행되는 경우 Responses API는 나중에 조회할 수 있도록 마지막 응답을 유지하지 않습니다. 이러한 무상태 설정에서 기본 `"auto"` 모드는 `previous_response_id`에 의존하는 대신 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)을 참조하세요. -압축은 세션 기록을 지우고 다시 작성하므로 SDK는 압축이 완료될 때까지 기다린 후 실행이 완료된 것으로 간주합니다. 스트리밍 모드에서는 압축 작업이 무거울 경우 마지막 출력 토큰 이후에도 `run.stream_events()`이 몇 초 동안 열린 상태로 유지될 수 있습니다. +#### 자동 압축에 의한 스트리밍 차단 가능성 -`OpenAIResponsesCompactionSession.run_compaction()`은 지우기 및 다시 쓰기 작업을 래퍼 경계에서 복구 가능한 교체 작업으로 처리합니다. 기본 기록이 변경된 후 교체가 실패하거나 취소되면 래퍼는 이전 기록 복원을 시도하고, 해당 복구 시도가 완료될 때까지 기다린 후 원래 예외나 취소를 호출자에게 전달합니다. 복구 중 기본 백엔드에서도 오류가 발생하면 이전 기록이 복원되지 않은 상태로 남을 수 있으며 SDK는 복구 실패를 로그에 기록합니다. 래퍼는 `add_items()`, `pop_item()`, `clear_session()` 호출을 잠금이 적용된 교체 및 복구 단계와 직렬화합니다. 그러나 원격 압축 요청이 진행 중인 동안 변경 작업이 완료된 후 성공적인 교체로 덮어써질 수 있습니다. 동시 래퍼 변경 작업이 없는 턴 사이에 수동 압축을 실행하고, 압축이 실행되는 동안 기본 세션을 직접 변경하지 마세요. +압축은 세션 기록을 지우고 다시 작성하므로 SDK는 실행이 완료된 것으로 간주하기 전에 압축이 끝날 때까지 기다립니다. 스트리밍 모드에서는 압축 작업이 많은 경우 마지막 출력 토큰 이후에도 `run.stream_events()`이 몇 초 동안 열려 있을 수 있습니다. -지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요하면 자동 압축을 비활성화하고 턴 사이 또는 유휴 시간에 `run_compaction()`을 직접 호출합니다. 자체 기준에 따라 압축을 강제로 수행할 시점을 결정할 수 있습니다. +`OpenAIResponsesCompactionSession.run_compaction()`은 지우기 및 다시 쓰기 작업을 래퍼 경계에서 복구 가능한 교체로 처리합니다. 기본 기록이 변경된 후 교체에 실패하거나 취소되면 래퍼는 이전 기록의 복원을 시도하고, 이 복구 시도가 마무리될 때까지 기다린 후 원래 예외 또는 취소를 호출자에게 전달합니다. 복구 중 기본 백엔드에도 장애가 발생하면 이전 기록이 복원되지 않은 상태로 남을 수 있으며 SDK는 복구 실패를 로그에 기록합니다. 래퍼는 `add_items()`, `pop_item()`, `clear_session()` 호출을 잠금이 적용된 교체 및 복구 단계와 직렬화하지만, 원격 압축 요청이 아직 진행 중인 동안 변경 작업이 완료된 뒤 성공적인 교체로 덮어써질 수 있습니다. 동시 래퍼 변경 작업이 없는 상태에서 턴 사이에 수동 압축을 실행하고, 압축이 실행되는 동안 기본 세션을 직접 변경하지 마세요. + +지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요하다면 자동 압축을 비활성화하고 턴 사이에 또는 유휴 시간에 `run_compaction()`을 직접 호출하세요. 자체 기준에 따라 압축을 강제할 시점을 결정할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession @@ -334,7 +336,7 @@ result = await Runner.run( ### 비동기 SQLite 세션 -`aiosqlite` 기반의 SQLite 영속성이 필요한 경우 `AsyncSQLiteSession`을 사용합니다. +`aiosqlite` 기반 SQLite 영속성이 필요한 경우 `AsyncSQLiteSession`을 사용하세요. ```bash pip install aiosqlite @@ -351,7 +353,7 @@ result = await Runner.run(agent, "Hello", session=session) ### Redis 세션 -여러 워커 또는 서비스 간에 세션 메모리를 공유하려면 `RedisSession`을 사용합니다. +여러 워커 또는 서비스 간에 세션 메모리를 공유하려면 `RedisSession`를 사용하세요. ```bash pip install openai-agents[redis] @@ -370,11 +372,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후에는 세션이 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. `close()`을 반복하거나 동시에 호출해도 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하는 경우 `redis_client=...`을 사용하여 `RedisSession(...)`을 직접 생성합니다. 이 경우 `close()`은 아무 작업도 하지 않으며 호출자가 클라이언트 소유권을 유지하고 세션도 계속 사용할 수 있습니다. +`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후 세션은 종료 상태가 되며 이후 세션 작업에서는 `RuntimeError`이 발생합니다. 반복되거나 동시에 실행되는 `close()` 호출은 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하는 경우 `redis_client=...`을 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`은 아무 작업도 수행하지 않으며, 호출자가 클라이언트 소유권을 유지하고 세션도 계속 사용할 수 있습니다. ### SQLAlchemy 세션 -SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션용 Agents SDK 세션 영속성 구현입니다. +SQLAlchemy가 지원하는 모든 데이터베이스를 사용할 수 있는 프로덕션용 Agents SDK 세션 영속성 구현입니다. ```python from agents.extensions.memory import SQLAlchemySession @@ -396,7 +398,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ### Dapr 세션 -이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 구성된 상태 저장소 백엔드를 전환하려는 경우 `DaprSession`을 사용합니다. +이미 Dapr 사이드카를 실행하고 있거나 에이전트 코드를 변경하지 않고 구성된 상태 저장소 백엔드를 전환하려면 `DaprSession`을 사용하세요. ```bash pip install openai-agents[dapr] @@ -417,19 +419,19 @@ async with DaprSession.from_address( print(result.final_output) ``` -참고 사항: +참고: -- `from_address(...)`은 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리하는 경우 `dapr_client=...`을 사용하여 `DaprSession(...)`을 직접 생성합니다. -- 컨텍스트를 종료하거나 `close()`을 호출하면 소유 클라이언트를 사용하는 세션이 종료 상태가 됩니다. 이후 세션 작업은 `RuntimeError`을 발생시키지만 `close()`을 반복하거나 동시에 호출해도 안전합니다. 주입된 클라이언트를 사용하면 `close()`은 아무 작업도 하지 않으며 세션을 계속 사용할 수 있습니다. +- `from_address(...)`은 Dapr 클라이언트를 생성하고 소유합니다. 앱에서 이미 클라이언트를 관리하는 경우 `dapr_client=...`을 사용하여 `DaprSession(...)`를 직접 생성하세요. +- 컨텍스트를 종료하거나 `close()`을 호출하면 클라이언트를 소유한 세션이 종료 상태가 됩니다. 이후 세션 작업에서는 `RuntimeError`이 발생하지만 반복되거나 동시에 실행되는 `close()` 호출은 안전합니다. 주입된 클라이언트를 사용하면 `close()`은 아무 작업도 수행하지 않으며 세션은 계속 사용할 수 있습니다. - 기본 상태 저장소가 TTL을 지원하는 경우 `ttl=...`을 전달하면 세션 데이터에 TTL 만료가 자동으로 적용됩니다. -- 쓰기 후 읽기에 대해 더 강력한 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG`을 전달합니다. -- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발에서는 `dapr_address`에서 사용하는 gRPC 포트뿐만 아니라 `--dapr-http-port 3500`도 사용하여 Dapr을 시작합니다. +- 쓰기 후 읽기에 대해 더 강한 보장이 필요하면 `consistency=DAPR_CONSISTENCY_STRONG`을 전달하세요. +- Dapr Python SDK는 HTTP 사이드카 엔드포인트도 확인합니다. 로컬 개발에서는 `dapr_address`에 사용된 gRPC 포트와 함께 `--dapr-http-port 3500`으로 Dapr를 시작하세요. - 로컬 구성 요소와 문제 해결을 포함한 전체 설정 안내는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참조하세요. ### MongoDB 세션 -이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에서는 `MongoDBSession`을 사용합니다. +이미 MongoDB를 사용하는 애플리케이션이나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에서는 `MongoDBSession`을 사용하세요. ```bash pip install openai-agents[mongodb] @@ -452,16 +454,16 @@ print(result.final_output) await session.close() ``` -참고 사항: +참고: -- `from_uri(...)`은 `AsyncMongoClient`을 생성하고 소유하며 `session.close()`에서 이를 닫습니다. 소유 클라이언트를 사용하는 세션은 `close()` 이후 종료 상태가 되며, 이후 세션 작업은 `RuntimeError`을 발생시킵니다. 애플리케이션에서 이미 클라이언트를 관리하는 경우 `client=...`을 사용하여 `MongoDBSession(...)`을 직접 생성합니다. 이 경우 `session.close()`은 아무 작업도 하지 않고 호출자가 클라이언트 수명 주기를 관리할 책임을 유지하며 세션도 계속 사용할 수 있습니다. -- 별도의 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결합니다. -- 두 개의 컬렉션이 사용되며, 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`) 및 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 비어 있지 않은 각 `add_items()` 호출은 논리적 배치 문서 하나를 작성하며, 단조 증가하는 `seq`이 해당 배치의 마지막 항목을 기준으로 순서를 지정합니다. 기존의 항목별 메시지 문서도 계속 읽을 수 있습니다. 논리적 배치는 MongoDB의 단일 문서 크기 제한 이내여야 하며, 제한을 초과하는 배치는 일부만 저장되지 않고 원자적으로 실패합니다. -- 첫 실행 전에 연결을 확인하려면 `await session.ping()`을 사용합니다. +- `from_uri(...)`은 `AsyncMongoClient`을 생성하고 소유하며 `session.close()`에서 이를 닫습니다. 클라이언트를 소유한 세션은 `close()` 후에 종료 상태가 되며 이후 세션 작업에서는 `RuntimeError`이 발생합니다. 애플리케이션에서 이미 클라이언트를 관리하는 경우 `client=...`을 사용하여 `MongoDBSession(...)`을 직접 생성하세요. 이 경우 `session.close()`은 아무 작업도 수행하지 않고 호출자가 클라이언트 수명 주기를 관리할 책임을 유지하며 세션도 계속 사용할 수 있습니다. +- 다른 변경 없이 `mongodb+srv://user:password@cluster.example.mongodb.net` URI를 `from_uri(...)`에 전달하여 [MongoDB Atlas](https://www.mongodb.com/products/platform)에 연결할 수 있습니다. +- 두 개의 컬렉션이 사용되며 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`)과 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 비어 있지 않은 각 `add_items()` 호출은 단조 증가하는 `seq`이 마지막 항목을 기준으로 배치 순서를 지정하는 논리적 배치 문서 하나를 작성합니다. 기존의 항목별 메시지 문서도 계속 읽을 수 있습니다. 논리적 배치는 MongoDB의 단일 문서 크기 제한 이내여야 하며, 크기를 초과하는 배치는 일부를 저장하지 않고 원자적으로 실패합니다. +- 첫 실행 전에 연결 상태를 확인하려면 `await session.ping()`을 사용하세요. ### 고급 SQLite 세션 -대화 분기, 사용량 분석 및 구조화된 쿼리를 지원하는 향상된 SQLite 세션입니다. +대화 브랜칭, 사용량 분석 및 구조화된 쿼리를 지원하는 향상된 SQLite 세션입니다. ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -485,7 +487,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 ### 암호화된 세션 -모든 세션 구현에 적용할 수 있는 투명한 암호화 래퍼입니다. +모든 세션 구현에 사용할 수 있는 투명한 암호화 래퍼입니다. ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -512,13 +514,13 @@ result = await Runner.run(agent, "Hello", session=session) ### 기타 세션 유형 -몇 가지 기본 제공 옵션이 더 있습니다. `examples/memory/` 및 `extensions/memory/` 아래의 소스 코드를 참조하세요. +그 밖에도 몇 가지 내장 옵션이 있습니다. `examples/memory/`과 `extensions/memory/` 아래의 소스 코드를 참조하세요. ## 운영 패턴 -### 세션 ID 명명 규칙 +### 세션 ID 명명 방식 -대화를 체계적으로 관리하는 데 도움이 되는 의미 있는 세션 ID를 사용합니다. +대화를 정리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. - 사용자 기반: `"user_12345"` - 스레드 기반: `"thread_abc123"` @@ -527,15 +529,15 @@ result = await Runner.run(agent, "Hello", session=session) ### 메모리 영속성 - 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`) 사용 -- 영구 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) 사용 -- `aiosqlite` 기반 구현이 필요하면 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) 사용 +- 지속되는 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) 사용 +- `aiosqlite` 기반 구현이 필요한 경우 비동기 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) 사용 - 공유되는 저지연 세션 메모리에는 Redis 기반 세션(`RedisSession.from_url("session_id", url="redis://...")`) 사용 - SQLAlchemy가 지원하는 기존 데이터베이스를 사용하는 프로덕션 시스템에는 SQLAlchemy 기반 세션(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) 사용 - 이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 MongoDB 세션(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) 사용 -- 기본 제공 텔레메트리, 트레이싱, 데이터 격리와 30개 이상의 데이터베이스 백엔드 지원이 필요한 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 +- 내장 텔레메트리, 트레이싱 및 데이터 격리와 30개 이상의 데이터베이스 백엔드 지원이 필요한 프로덕션 클라우드 네이티브 배포에는 Dapr 상태 저장소 세션(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) 사용 - OpenAI Conversations API에 기록을 저장하려면 OpenAI 호스팅 스토리지(`OpenAIConversationsSession()`) 사용 -- 투명한 암호화 및 TTL 기반 만료를 모든 세션에 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 -- 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 사용자 정의 세션 백엔드 구현 고려 +- 모든 세션에 투명한 암호화 및 TTL 기반 만료를 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 +- 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 맞춤형 세션 백엔드 구현 고려 ### 여러 세션 @@ -583,7 +585,7 @@ result2 = await Runner.run( ## 전체 예제 -다음은 세션 메모리의 실제 동작을 보여 주는 전체 예제입니다. +다음은 세션 메모리의 실제 동작을 보여주는 전체 예제입니다. ```python import asyncio @@ -645,9 +647,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 사용자 정의 세션 구현 +## 맞춤형 세션 구현 -[`Session`][agents.memory.session.Session] 프로토콜의 구조를 따르는 클래스를 생성하여 자체 세션 메모리를 구현할 수 있습니다. `SessionABC`을 상속할 필요는 없습니다. `session_id` 및 `session_settings`을 정의하고 네 가지 기록 메서드를 직접 구현합니다. +[`Session`][agents.memory.session.Session] 프로토콜을 구조적으로 따르는 클래스를 생성하여 자체 세션 메모리를 구현할 수 있습니다. `SessionABC`을 상속할 필요는 없습니다. `session_id`과 `session_settings`을 정의하고 네 개의 기록 메서드를 직접 구현하세요. ```python from agents import Agent, Runner, SessionSettings @@ -689,9 +691,9 @@ result = await Runner.run( ) ``` -### 사용자 정의 세션의 실행 컨텍스트 접근 +### 맞춤형 세션에서 실행 컨텍스트 접근 -Agents SDK는 테넌트 라우팅, 권한 부여 또는 기타 앱별 스토리지 결정을 위해 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper]을 사용자 정의 세션에 전달할 수 있습니다. Agents SDK가 래퍼를 전달하도록 하려면 네 가지 기록 메서드 모두에 명시적인 이름을 가지며 키워드와 호환되는 `wrapper` 매개변수를 추가합니다. +Agents SDK는 테넌트 라우팅, 권한 부여 또는 기타 앱별 스토리지 결정을 위해 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper]을 맞춤형 세션에 전달할 수 있습니다. Agents SDK가 래퍼를 전달하도록 하려면 네 개의 기록 메서드 모두에 명시적으로 이름이 지정되고 키워드와 호환되는 `wrapper` 매개변수를 추가하세요. ```python from typing import Any @@ -728,7 +730,7 @@ class ContextAwareSession: ) -> None: ... ``` -Agents SDK는 `get_items`, `add_items`, `pop_item`, `clear_session`이 모두 `wrapper`을 선언하는 경우에만 이 통합을 활성화합니다. 일반적인 `**kwargs` 매개변수는 이 시그니처 검사를 충족하지 않습니다. `wrapper`을 생략한 기존 세션 구현은 릴리스된 호출 형식을 유지하며 변경 없이 계속 작동합니다. +Agents SDK는 `get_items`, `add_items`, `pop_item`, `clear_session`이 모두 `wrapper`을 선언하는 경우에만 이 통합을 활성화합니다. 일반적인 `**kwargs` 매개변수는 이 시그니처 검사를 충족하지 않습니다. `wrapper`을 생략하는 기존 세션 구현은 릴리스된 호출 형식을 유지하며 변경 없이 계속 작동합니다. ## 커뮤니티 세션 구현 @@ -753,5 +755,5 @@ Agents SDK는 `get_items`, `add_items`, `pop_item`, `clear_session`이 모두 `w - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy 기반 구현 - [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 기반 세션 구현 - [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 상태 저장소 구현 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 분기 및 분석 기능을 갖춘 향상된 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 모든 세션을 위한 암호화 래퍼 \ No newline at end of file +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 브랜칭 및 분석 기능이 포함된 향상된 SQLite +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 모든 세션을 위한 암호화된 래퍼 \ No newline at end of file diff --git a/docs/ko/tracing.md b/docs/ko/tracing.md index d17d7aa7e1..73a516d7ba 100644 --- a/docs/ko/tracing.md +++ b/docs/ko/tracing.md @@ -4,31 +4,31 @@ search: --- # 트레이싱 -Agents SDK에는 에이전트 실행 중 발생하는 LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지 포괄적으로 기록하는 트레이싱 기능이 기본으로 포함되어 있습니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고 시각화하며 모니터링할 수 있습니다. +Agents SDK에는 기본 제공 트레이싱 기능이 포함되어 있어 에이전트 실행 중 발생하는 이벤트의 포괄적인 기록을 수집합니다. 여기에는 LLM 생성, 도구 호출, 핸드오프, 가드레일은 물론 발생하는 사용자 지정 이벤트까지 포함됩니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고 시각화하며 모니터링할 수 있습니다. !!!note 트레이싱은 기본적으로 활성화되어 있습니다. 다음 세 가지 일반적인 방법으로 비활성화할 수 있습니다. - 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역으로 비활성화할 수 있습니다 - 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]을 사용하여 트레이싱을 전역으로 비활성화할 수 있습니다 - 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`으로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다 + 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역적으로 비활성화할 수 있습니다. + 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]을 사용하여 트레이싱을 전역적으로 비활성화할 수 있습니다. + 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`으로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다. -***OpenAI API를 데이터 미보존(Zero Data Retention, ZDR) 정책에 따라 사용하는 조직에서는 트레이싱을 사용할 수 없습니다.*** +***Zero Data Retention(ZDR) 정책에 따라 OpenAI API를 사용하는 조직에서는 트레이싱을 사용할 수 없습니다.*** ## 트레이스와 스팬 -- **트레이스**는 하나의 "워크플로"에서 이루어지는 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 가집니다. +- **트레이스**는 단일 "워크플로"의 시작부터 끝까지 이어지는 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 가집니다. - `workflow_name`: 논리적 워크플로 또는 앱의 이름입니다. 예를 들면 "코드 생성" 또는 "고객 서비스"입니다. - `trace_id`: 트레이스의 고유 ID입니다. 전달하지 않으면 자동으로 생성됩니다. 형식은 `trace_<32_alphanumeric>`이어야 합니다. - - `group_id`: 동일한 대화의 여러 트레이스를 연결하기 위한 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. + - `group_id`: 동일한 대화의 여러 트레이스를 연결하는 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. - `disabled`: True이면 트레이스가 기록되지 않습니다. - `metadata`: 트레이스의 선택적 메타데이터입니다. - **스팬**은 시작 및 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음 항목이 있습니다. - `started_at` 및 `ended_at` 타임스탬프 - - 자신이 속한 트레이스를 나타내는 `trace_id` - - 이 스팬의 부모 스팬이 있는 경우 이를 가리키는 `parent_id` - - 스팬에 대한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트에 대한 정보가, `GenerationSpanData`에는 LLM 생성에 대한 정보가 포함됩니다. + - 해당 스팬이 속한 트레이스를 나타내는 `trace_id` + - 이 스팬의 상위 스팬이 있는 경우 이를 가리키는 `parent_id` + - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트 정보가, `GenerationSpanData`에는 LLM 생성 정보 등이 포함됩니다. ## 기본 트레이싱 @@ -37,18 +37,18 @@ SDK는 기본적으로 다음 항목을 트레이싱합니다. - 전체 `Runner.{run, run_sync, run_streamed}()`은 `trace()`으로 래핑됩니다. - 각 러너 호출은 `task_span()`으로 래핑됩니다. - 각 모델 턴은 `turn_span()`으로 래핑됩니다. -- 에이전트가 실행될 때마다 `agent_span()`로 래핑됩니다 -- LLM 생성은 `generation_span()`로 래핑됩니다 -- 각 함수 도구 호출은 `function_span()`으로 래핑됩니다 -- 가드레일은 `guardrail_span()`로 래핑됩니다 -- 핸드오프는 `handoff_span()`로 래핑됩니다 -- 오디오 입력(음성 텍스트 변환)은 `transcription_span()`으로 래핑됩니다 -- 오디오 출력(텍스트 음성 변환)은 `speech_span()`로 래핑됩니다 -- SDK는 관련 오디오 스팬의 부모로 `speech_group_span()`을 지정할 수 있습니다 +- 에이전트가 실행될 때마다 `agent_span()`으로 래핑됩니다. +- LLM 생성은 `generation_span()`으로 래핑됩니다. +- 각 함수 도구 호출은 `function_span()`으로 래핑됩니다. +- 가드레일은 `guardrail_span()`으로 래핑됩니다. +- 핸드오프는 `handoff_span()`로 래핑됩니다. +- 오디오 입력(음성 텍스트 변환)은 `transcription_span()`으로 래핑됩니다. +- 오디오 출력(텍스트 음성 변환)은 `speech_span()`로 래핑됩니다. +- SDK는 관련 오디오 스팬을 `speech_group_span()` 아래에 배치할 수 있습니다. -기본적으로 트레이스 이름은 리터럴 문자열 `Agent workflow`입니다. `trace`을 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]을 사용하여 이름과 기타 속성을 구성할 수도 있습니다. +기본 트레이스 이름은 리터럴 문자열 `Agent workflow`입니다. `trace`을 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]을 사용하여 이름과 기타 속성을 구성할 수도 있습니다. -더 간결한 계층 구조가 필요하다면 실행의 자동 태스크 및 턴 스팬을 비활성화하세요. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. +더 간결한 계층 구조가 필요하다면 해당 실행에서 자동 작업 및 턴 스팬을 비활성화하세요. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -또한 트레이스를 다른 대상으로 전송하도록 [사용자 지정 트레이싱 프로세서](#custom-tracing-processors)를 설정할 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 추가할 수 있습니다. +또한 [사용자 지정 트레이싱 프로세서](#custom-tracing-processors)를 설정하여 트레이스를 다른 대상으로 전송할 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 사용할 수 있습니다. ## 장기 실행 워커와 즉시 내보내기 -기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내거나, 인메모리 큐가 크기 트리거에 도달하면 더 일찍 내보내며, 프로세스가 종료될 때 최종 플러시도 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 태스크와 같은 장기 실행 워커에서는 일반적으로 추가 코드 없이 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후 트레이스 대시보드에 표시되지 않을 수 있습니다. +기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 인메모리 큐가 크기 트리거에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 작업과 같은 장기 실행 워커에서는 별도의 코드 없이도 일반적으로 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후에는 트레이스 대시보드에 표시되지 않을 수 있습니다. -작업 단위가 끝날 때 즉시 전달되도록 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]을 호출하세요. +작업 단위가 끝날 때 즉시 전달되는 것을 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]을 호출하세요. ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces]은 현재 버퍼링된 트레이스와 스팬을 모두 내보낼 때까지 실행을 차단합니다. 따라서 부분적으로 생성된 트레이스가 플러시되지 않도록 `trace()`가 닫힌 후 호출하세요. 기본 내보내기 지연을 허용할 수 있다면 이 호출을 생략할 수 있습니다. +[`flush_traces()`][agents.tracing.flush_traces]은 현재 버퍼링된 트레이스와 스팬을 내보낼 때까지 실행을 차단합니다. 따라서 일부만 생성된 트레이스를 플러시하지 않도록 `trace()`가 닫힌 후 호출하세요. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. ## 상위 수준 트레이스 -여러 `run()` 호출을 하나의 트레이스에 포함하려는 경우가 있습니다. 전체 코드를 `trace()`로 래핑하면 됩니다. +여러 `run()` 호출을 단일 트레이스에 포함해야 하는 경우가 있습니다. 전체 코드를 `trace()`으로 래핑하면 됩니다. ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 두 `Runner.run` 호출이 `with trace()`로 래핑되므로 각 실행이 별도의 트레이스를 생성하는 대신 두 실행 모두 하나의 전체 트레이스에 포함됩니다. +1. 두 `Runner.run` 호출이 `with trace()`으로 래핑되므로, 각 실행이 별도의 트레이스를 생성하지 않고 두 실행 모두 하나의 전체 트레이스에 포함됩니다. ## 트레이스 생성 [`trace()`][agents.tracing.trace] 함수를 사용하여 트레이스를 생성할 수 있습니다. 트레이스는 시작하고 종료해야 합니다. 다음 두 가지 방법을 사용할 수 있습니다. 1. **권장**: 트레이스를 컨텍스트 관리자로 사용합니다. 즉, `with trace(...) as my_trace`을 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. -2. [`trace.start()`][agents.tracing.Trace.start]와 [`trace.finish()`][agents.tracing.Trace.finish]를 직접 호출할 수도 있습니다. +2. [`trace.start()`][agents.tracing.Trace.start] 및 [`trace.finish()`][agents.tracing.Trace.finish]를 직접 호출할 수도 있습니다. -현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하고 종료하는 경우 현재 트레이스를 업데이트하려면 `start()`에 `mark_as_current`를 전달하고 `finish()`에 `reset_current`을 전달하세요. +현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하고 종료하는 경우 현재 트레이스를 업데이트하려면 `mark_as_current`를 `start()`에 전달하고 `reset_current`을 `finish()`에 전달하세요. ## 스팬 생성 다양한 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 직접 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적할 수 있도록 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. -스팬은 자동으로 현재 트레이스에 포함되며, Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)을 통해 추적되는 가장 가까운 현재 스팬 아래에 중첩됩니다. +스팬은 자동으로 현재 트레이스에 포함되며 가장 가까운 현재 스팬 아래에 중첩됩니다. 현재 스팬은 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. ## 민감한 데이터 일부 스팬은 잠재적으로 민감한 데이터를 캡처할 수 있습니다. -`generation_span()`는 LLM 생성의 입력과 출력을 저장하고, `function_span()`은 함수 호출의 입력과 출력을 저장합니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. +`generation_span()`에는 LLM 생성의 입력/출력이 저장되고, `function_span()`에는 함수 호출의 입력/출력이 저장됩니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. -마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. +마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 Base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. -기본적으로 `trace_include_sensitive_data`은 `True`입니다. 코드 없이 기본값을 설정하려면 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 됩니다. +기본적으로 `trace_include_sensitive_data`은 `True`입니다. 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 코드 없이 기본값을 설정할 수 있습니다. ## 사용자 지정 트레이싱 프로세서 트레이싱의 상위 수준 아키텍처는 다음과 같습니다. -- 초기화할 때 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.provider.TraceProvider]를 생성합니다. -- 트레이스와 스팬을 배치 단위로 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]에 전송하는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]로 `TraceProvider`를 구성합니다. 이 익스포터는 스팬과 트레이스를 OpenAI 백엔드로 일괄 내보냅니다. +- 초기화 시 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.provider.TraceProvider]를 생성합니다. +- `TraceProvider`에 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]를 구성합니다. 이 프로세서는 트레이스와 스팬을 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]로 일괄 전송하며, 해당 익스포터는 스팬과 트레이스를 OpenAI 백엔드로 일괄 내보냅니다. -트레이스를 대체 또는 추가 백엔드로 전송하거나 익스포터 동작을 수정하는 등 이 기본 설정을 사용자 지정하는 방법은 두 가지입니다. +이 기본 설정을 사용자 지정하여 트레이스를 대체 또는 추가 백엔드로 전송하거나 익스포터 동작을 수정하려면 다음 두 가지 방법을 사용할 수 있습니다. -1. [`add_trace_processor()`][agents.tracing.add_trace_processor]를 사용하면 준비된 트레이스와 스팬을 수신할 **추가** 트레이싱 프로세서를 등록할 수 있습니다. 이를 통해 트레이스를 OpenAI 백엔드로 전송하는 동시에 자체 처리를 수행할 수 있습니다. -2. [`set_trace_processors()`][agents.tracing.set_trace_processors]을 사용하면 기본 프로세서를 자체 트레이싱 프로세서로 **대체**할 수 있습니다. 이 경우 해당 작업을 수행하는 `TracingProcessor`을 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. +1. [`add_trace_processor()`][agents.tracing.add_trace_processor]을 사용하면 준비된 트레이스와 스팬을 수신할 **추가** 트레이스 프로세서를 등록할 수 있습니다. 이를 통해 트레이스를 OpenAI 백엔드로 전송하는 동시에 자체 처리를 수행할 수 있습니다. +2. [`set_trace_processors()`][agents.tracing.set_trace_processors]을 사용하면 기본 프로세서를 자체 트레이스 프로세서로 **교체**할 수 있습니다. 이 경우 이를 수행하는 `TracingProcessor`을 포함하지 않는 한 트레이스가 OpenAI 백엔드로 전송되지 않습니다. -## 비OpenAI 모델을 사용한 트레이싱 +## 비 OpenAI 모델을 사용한 트레이싱 -비OpenAI 모델을 사용할 때 트레이싱 익스포터에 OpenAI API 키를 제공하면 트레이싱을 비활성화하지 않고 OpenAI 트레이스 대시보드에서 무료 트레이싱을 사용할 수 있습니다. 어댑터 선택 및 설정 시 주의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참조하세요. +비 OpenAI 모델을 사용할 때 트레이싱 익스포터에 OpenAI API 키를 제공하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 사용할 수 있습니다. 어댑터 선택 및 설정 시 주의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참조하세요. ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -단일 실행에만 다른 트레이싱 키가 필요한 경우 전역 익스포터를 변경하는 대신 `RunConfig`을 통해 전달하세요. +단일 실행에만 다른 트레이싱 키가 필요하다면 전역 익스포터를 변경하는 대신 `RunConfig`을 통해 전달하세요. ```python from agents import Runner, RunConfig @@ -203,15 +203,15 @@ await Runner.run( ## 에코시스템 통합 -다음 커뮤니티 및 공급업체 통합은 OpenAI Agents SDK의 트레이싱 API 인터페이스를 지원합니다. +다음 커뮤니티 및 벤더 통합은 OpenAI Agents SDK의 트레이싱 API 인터페이스를 지원합니다. ### 외부 트레이싱 프로세서 목록 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) -- [MLflow (자체 호스팅/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow (Databricks 호스팅)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) +- [MLflow(자체 호스팅/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow(Databricks 호스팅)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) @@ -234,4 +234,5 @@ await Runner.run( - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) - [Latitude](https://docs.latitude.so/telemetry/frameworks/openai-agents) -- [DProvenanceKit](https://dprovenance.dev/openai-agents/) \ No newline at end of file +- [DProvenanceKit](https://dprovenance.dev/openai-agents/) +- [Tuning Engines](https://github.com/cerebrixos-org/tuning-engines-cli/tree/main/packages/tuning-agents#openai-agents-sdk) \ No newline at end of file diff --git a/docs/ko/usage.md b/docs/ko/usage.md index 0aba0bf46f..3eb76b5b06 100644 --- a/docs/ko/usage.md +++ b/docs/ko/usage.md @@ -2,9 +2,9 @@ search: exclude: true --- -# 사용량 +# 사용법 -Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 접근하여 비용을 모니터링하고, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. +Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 접근하여 비용을 모니터링하거나, 제한을 적용하거나, 분석 데이터를 기록할 수 있습니다. ## 추적 항목 @@ -12,15 +12,15 @@ Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. - **input_tokens**: 전송된 총 입력 토큰 수 - **output_tokens**: 수신된 총 출력 토큰 수 - **total_tokens**: 입력 + 출력 -- **request_usage_entries**: 요청별 사용량 상세 내역 목록 +- **request_usage_entries**: 요청별 사용량 분석 목록 - **details**: - `input_tokens_details.cached_tokens` - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 실행에서 사용량 접근 +## 실행의 사용량 접근 -`Runner.run(...)` 이후에는 `result.context_wrapper.usage`를 통해 사용량에 접근합니다. +`Runner.run(...)` 실행 후 `result.context_wrapper.usage`를 통해 사용량에 접근합니다. ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -32,20 +32,22 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -사용량은 도구 호출이나 핸드오프를 생성하는 모델 호출을 포함하여 실행 중 발생한 모든 모델 호출에 걸쳐 집계됩니다. +사용량은 도구 호출이나 핸드오프를 생성하는 모델 호출을 포함하여 실행 중의 모든 모델 호출에 걸쳐 집계됩니다. -### 서드파티 어댑터의 사용량 활성화 +[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]가 실행 완료 전에 기록을 자동으로 압축하면 해당 `responses.compact` 요청이 보고한 사용량도 동일한 실행의 총합에 추가됩니다. 실행 외부에서 수행된 수동 `run_compaction()` 호출에는 이를 포함하는 실행 컨텍스트가 없으므로 이전 실행에서 반환된 사용량 객체를 업데이트하지 않습니다. [OpenAI Responses 압축 세션](sessions/index.md#openai-responses-compaction-sessions)을 참고하세요. -사용량 보고 방식은 서드파티 어댑터와 제공자 백엔드에 따라 다릅니다. 서드파티 어댑터를 통해 모델에 접근하면서 정확한 `result.context_wrapper.usage` 값이 필요한 경우: +### 서드 파티 어댑터의 사용량 활성화 -- `AnyLLMModel`에서는 업스트림 제공자가 사용량을 반환할 경우 자동으로 전파됩니다. Chat Completions 백엔드에서 응답을 스트리밍할 때 사용량 청크가 출력되도록 하려면 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. -- `LitellmModel`에서는 일부 제공자 백엔드가 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`가 필요한 경우가 많습니다. +사용량 보고 방식은 서드 파티 어댑터와 제공자 백엔드에 따라 다릅니다. 서드 파티 어댑터를 통해 모델에 접근하며 정확한 `result.context_wrapper.usage` 값이 필요한 경우: -Models 가이드의 [서드파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 검토하고, 배포하려는 정확한 제공자 백엔드에서 사용량 보고를 검증하세요. +- `AnyLLMModel`를 사용할 때 상위 제공자가 사용량을 반환하면 자동으로 전파됩니다. Chat Completions 백엔드에서 응답을 스트리밍할 때 사용량 청크가 전송되도록 하려면 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. +- `LitellmModel`을 사용할 때 일부 제공자 백엔드는 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`가 필요한 경우가 많습니다. + +Models 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 검토하고, 배포에 사용할 제공자 백엔드에서 사용량 보고가 정확한지 확인하세요. ## 요청별 사용량 추적 -SDK는 각 API 요청의 사용량을 `request_usage_entries`에서 자동으로 추적합니다. 이는 상세한 비용 계산과 컨텍스트 창 사용량 모니터링에 유용합니다. +SDK는 각 API 요청의 사용량을 `request_usage_entries`에서 자동으로 추적합니다. 이는 상세한 비용 계산과 컨텍스트 윈도 사용량 모니터링에 유용합니다. ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -56,7 +58,7 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): ## 제공자 사용량 페이로드 보존 -Agents SDK는 제공자 사용량을 여러 모델 제공자에 걸쳐 일관된 합계를 제공하는 [`Usage`][agents.usage.Usage] 필드로 정규화합니다. 애플리케이션에서 제공자별 사용량 필드를 유지하거나, 생략된 필드와 제공자가 보고한 0을 구분해야 하는 경우 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage]를 `True`으로 설정합니다. +Agents SDK는 제공자 사용량을 모델 제공자 전반에서 일관된 총합을 제공하는 [`Usage`][agents.usage.Usage] 필드로 정규화합니다. 애플리케이션에서 제공자별 사용량 필드를 유지하거나 누락된 필드와 제공자가 보고한 0을 구분해야 하는 경우 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage]를 `True`으로 설정합니다. ```python from agents import Agent, ModelSettings, Runner @@ -71,15 +73,15 @@ for response in result.raw_responses: print(response.raw_usage) ``` -Agents SDK는 각 모델 호출의 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 값을 제공자 페이로드에서 분리된 JSON 호환 스냅샷으로 저장합니다. Agents SDK는 실행 전체에 걸쳐 `raw_usage`를 집계하지 않습니다. 보존이 비활성화되어 있거나, 제공자가 사용량 페이로드를 반환하지 않거나, 업스트림 어댑터가 원래 필드의 존재 여부 정보를 이미 폐기한 경우 값은 `None`으로 유지됩니다. +Agents SDK는 각 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 값을 해당 모델 호출의 제공자 페이로드에서 분리된 JSON 호환 스냅샷으로 저장합니다. Agents SDK는 실행 전체에서 `raw_usage`을 집계하지 않습니다. 보존이 비활성화되어 있거나, 제공자가 사용량 페이로드를 반환하지 않거나, 상위 어댑터가 이미 원래 필드의 존재 여부 정보를 폐기한 경우 이 값은 `None`으로 유지됩니다. -`preserve_raw_usage`은 모델 어댑터에 도달한 사용량 페이로드만 보존하며, 이 설정은 제공자에게 사용량을 요청하지 않습니다. 스트리밍 Chat Completions 제공자가 명시적인 사용량 요청을 요구하는 경우 `ModelSettings(include_usage=True)`도 설정합니다. +`preserve_raw_usage`은 모델 어댑터에 도달한 사용량 페이로드만 보존하며, 이 설정으로 제공자에 사용량을 요청하지는 않습니다. 스트리밍 Chat Completions 제공자가 명시적인 사용량 요청을 요구하는 경우 `ModelSettings(include_usage=True)`도 설정합니다. -현재 `LitellmModel`는 스트리밍 또는 비스트리밍 실행 모두에서 `ModelResponse.raw_usage`을 채우지 않으므로 해당 어댑터에서는 `preserve_raw_usage=True`이 효과가 없습니다. `LitellmModel`을 사용할 때는 정규화된 [`Usage`][agents.usage.Usage] 필드를 계속 사용하거나, 제공자별 필드의 존재 여부가 필요한 경우 raw 사용량 보존을 지원하는 어댑터를 선택하세요. +현재 `LitellmModel`는 스트리밍 및 비스트리밍 실행 모두에서 `ModelResponse.raw_usage`을 채우지 않으므로 해당 어댑터에서는 `preserve_raw_usage=True`가 적용되지 않습니다. `LitellmModel`을 사용할 때는 정규화된 [`Usage`][agents.usage.Usage] 필드를 계속 사용하거나, 제공자별 필드의 존재 여부가 필요한 경우 raw 사용량 보존을 지원하는 어댑터를 선택하세요. -## 세션에서 사용량 접근 +## 세션 사용 시 사용량 접근 -`Session`(예: `SQLiteSession`)을 사용하면 `Runner.run(...)`에 대한 각 호출이 해당 실행의 사용량을 반환합니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만, 각 실행의 사용량은 독립적입니다. +`Session`(예: `SQLiteSession`)을 사용하면 각 `Runner.run(...)` 호출은 해당 실행의 사용량을 반환합니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만 각 실행의 사용량은 독립적입니다. ```python session = SQLiteSession("my_conversation") @@ -91,9 +93,9 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -세션은 실행 사이에 대화 컨텍스트를 보존하지만, 각 `Runner.run()` 호출이 반환하는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행의 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. +세션은 실행 간에 대화 컨텍스트를 보존하지만, 각 `Runner.run()` 호출이 반환하는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행에 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. -## 훅에서 사용량 활용 +## 훅에서의 사용량 활용 `RunHooks`을 사용하는 경우 각 훅에 전달되는 `context` 객체에는 `usage`이 포함됩니다. 이를 통해 주요 수명 주기 시점에 사용량을 기록할 수 있습니다. @@ -106,9 +108,9 @@ class MyHooks(RunHooks): ## API 레퍼런스 -자세한 API 문서는 다음을 참조하세요. +자세한 API 문서는 다음을 참고하세요. - [`Usage`][agents.usage.Usage] - 사용량 추적 데이터 구조 -- [`RequestUsage`][agents.usage.RequestUsage] - 요청별 사용량 상세 정보 +- [`RequestUsage`][agents.usage.RequestUsage] - 요청별 사용량 세부 정보 - [`RunContextWrapper`][agents.run.RunContextWrapper] - 실행 컨텍스트에서 사용량 접근 - [`RunHooks`][agents.run.RunHooks] - 사용량 추적 수명 주기에 훅 연결 \ No newline at end of file diff --git a/docs/zh/models/index.md b/docs/zh/models/index.md index 989a31ef11..eae057bff4 100644 --- a/docs/zh/models/index.md +++ b/docs/zh/models/index.md @@ -4,43 +4,43 @@ search: --- # 模型 -Agents SDK 原生支持两种 OpenAI 模型: +Agents SDK 开箱即用地支持两种 OpenAI 模型: -- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],它使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API。 -- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],它使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API。 +- **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API。 +- [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API。 ## 模型配置选择 -从符合你配置需求的最简单路径开始: +从最符合您配置的最简单方案开始: -| 如果你希望…… | 推荐路径 | 更多信息 | +| 如果您希望…… | 推荐方案 | 详细信息 | | --- | --- | --- | -| 仅使用 OpenAI 模型 | 使用默认 OpenAI 提供商和 Responses 模型路径 | [OpenAI 模型](#openai-models) | -| 通过 WebSocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径并启用 WebSocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | -| 使用由 OpenAI 托管的子智能体 | 使用实验性的托管多智能体模型 | [托管多智能体](#hosted-multi-agent-experimental) | -| 使用一个非 OpenAI 提供商 | 从内置的提供商集成点开始 | [非 OpenAI 模型](#non-openai-models) | -| 在不同智能体之间混用模型或提供商 | 按每次运行或每个智能体选择提供商,并查看功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | +| 仅使用 OpenAI模型 | 使用默认 OpenAI提供商和 Responses 模型路径 | [OpenAI模型](#openai-models) | +| 通过 WebSocket 传输使用 OpenAI Responses API | 保持使用 Responses 模型路径,并启用 WebSocket 传输 | [Responses WebSocket 传输](#responses-websocket-transport) | +| 使用由OpenAI托管的子智能体 | 使用实验性的托管多智能体模型 | [托管多智能体](#hosted-multi-agent-experimental) | +| 使用一个非 OpenAI提供商 | 从内置提供商集成点开始 | [非 OpenAI模型](#non-openai-models) | +| 在不同智能体之间混用模型或提供商 | 按运行或智能体选择提供商,并检查功能差异 | [在一个工作流中混用模型](#mixing-models-in-one-workflow)和[跨提供商混用模型](#mixing-models-across-providers) | | 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径上使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | -| 使用第三方适配器进行非 OpenAI 或混合提供商路由 | 比较受支持的 Beta 适配器,并验证计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | +| 使用第三方适配器进行非 OpenAI或混合提供商路由 | 比较受支持的 Beta 版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | -## OpenAI 模型 +## OpenAI模型 -对于大多数仅使用 OpenAI 的应用,推荐使用字符串模型名称和默认 OpenAI 提供商,并保持使用 Responses 模型路径。 +对于大多数仅使用 OpenAI的应用,推荐使用默认 OpenAI提供商配合字符串模型名称,并保持使用 Responses 模型路径。 -当 [`Agent`][agents.agent.Agent] 未指定模型时,为满足成本敏感型、高吞吐量智能体工作流的需求,Agents SDK 默认使用带有 `reasoning.effort="none"` 和 `verbosity="low"` 的 [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna)。需要前沿能力的应用可以显式设置 `model="gpt-5.6-sol"`,并选择适合相应工作负载的 `model_settings`。 +当 [`Agent`][agents.agent.Agent] 未指定模型时,对于成本敏感的高吞吐量智能体工作流,Agents SDK 默认使用 [`gpt-5.6-luna`](https://developers.openai.com/api/docs/models/gpt-5.6-luna),并搭配 `reasoning.effort="none"` 和 `verbosity="low"`。需要前沿能力的应用可以显式设置 `model="gpt-5.6-sol"`,并选择适合工作负载的 `model_settings`。 -如果要切换到 `gpt-5.6-sol` 等其他模型,可通过两种方式配置智能体。 +如果您想切换到 `gpt-5.6-sol` 等其他模型,可以通过两种方式配置智能体。 ### 默认模型 -首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 +首先,如果您希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 ```bash export OPENAI_DEFAULT_MODEL=gpt-5.6-sol python3 my_awesome_agent.py ``` -其次,可以通过 `RunConfig` 为一次运行设置默认模型。如果未给智能体设置模型,则会使用此次运行的模型。 +其次,您可以通过 `RunConfig` 为某次运行设置默认模型。如果未为智能体设置模型,则会使用本次运行的模型。 ```python from agents import Agent, RunConfig, Runner @@ -59,7 +59,7 @@ result = await Runner.run( #### GPT-5 模型 -以这种方式使用任何 GPT-5 模型(例如 `gpt-5.6-sol`)时,SDK 会应用默认的 `ModelSettings`。它会设置最适合大多数用例的值。若要调整默认模型的推理强度,请传入你自己的 `ModelSettings`: +以这种方式使用任何 GPT-5 模型(例如 `gpt-5.6-sol`)时,SDK 会应用默认的 `ModelSettings`,其中设置了适合大多数用例的最佳选项。若要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: ```python from openai.types.shared import Reasoning @@ -75,9 +75,9 @@ my_agent = Agent( ) ``` -若要降低延迟,建议为 GPT-5 模型使用 `reasoning.effort="none"`。 +为了降低延迟,建议将 GPT-5 模型与 `reasoning.effort="none"` 搭配使用。 -GPT-5.6 还支持推理模式、跨对话轮次保留的推理上下文,以及通过现有 `reasoning` 设置指定的 `"max"` 强度级别。这些控制项可用于 Responses API 路径: +GPT-5.6 还通过现有的 `reasoning` 设置支持推理模式、跨对话轮次保留的推理上下文,以及 `"max"` 强度级别。这些控制项可在 Responses API 路径上使用: ```python from openai.types.shared import Reasoning @@ -96,25 +96,25 @@ agent = Agent( ) ``` -`reasoning.mode` 和 `reasoning.context` 是仅限 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,支持的强度级别取决于模型和 API 接口。请使用 Responses API 设置 GPT-5.6 的 `"max"` 强度。Chat Completions 适配器会忽略模式和上下文并发出警告;在 OpenAI 提供商上设置 `strict_feature_validation=True` 可将该警告转为错误。 +`reasoning.mode` 和 `reasoning.context` 是仅适用于 Responses 的设置。Chat Completions 仅使用 `reasoning.effort`,且支持的强度级别取决于模型和 API 接口。若要使用 GPT-5.6 的 `"max"` 强度,请使用 Responses API。Chat Completions 适配器会忽略模式和上下文并发出警告;在 OpenAI提供商上设置 `strict_feature_validation=True`,可将该警告转为错误。 -使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端 Responses API 对话,或在下一次请求中包含之前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中请求 `reasoning.encrypted_content`,然后在下一次请求中将这些推理项作为输入包含在内。 +使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端 Responses API 对话,或在下一个请求中包含先前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中请求 `reasoning.encrypted_content`,然后在下一个请求中将这些推理项作为输入。 #### ComputerTool 模型选择 -如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],则实际 Responses 请求中生效的模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求则继续使用旧版 `computer_use_preview` 载荷。 +如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],则实际 Responses 请求上的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求继续使用较旧的 `computer_use_preview` 载荷。 -由提示词管理的调用是主要例外。如果提示词模板指定了模型,并且 SDK 在请求中省略了 `model`,SDK 会默认使用与预览版兼容的计算机载荷,以避免猜测提示词固定的是哪个模型。若要在此流程中继续使用正式发布路径,可以在请求中显式指定 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版本。 +由提示词管理的调用是主要例外。如果提示词模板指定了模型,而 SDK 在请求中省略了 `model`,SDK 将默认使用与预览版兼容的计算机载荷,从而避免猜测提示词固定的是哪个模型。若要在该流程中继续使用正式发布路径,请在请求中显式指定 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版本。 -注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会被规范化为与实际请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串将继续像普通函数名称一样工作。 +注册 [`ComputerTool`][agents.tool.ComputerTool] 后,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 会规范化为与有效请求模型匹配的内置选择器。如果未注册 `ComputerTool`,这些字符串会继续像普通函数名称一样工作。 -与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此,由提示词管理且使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂的流程,应传入具体的 `Computer` 或 `AsyncComputer` 实例,或在发送请求前强制使用正式发布选择器。有关完整迁移详情,请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 +与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此,使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂、由提示词管理的流程应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制使用正式发布版选择器。有关完整迁移详情,请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 #### 非 GPT-5 模型 -如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复使用与任何模型兼容的通用 `ModelSettings`。 +如果您传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 将恢复为与任何模型兼容的通用 `ModelSettings`。 -### 仅限 Responses 的工具功能 +### Responses 专属工具功能 以下工具功能仅受 OpenAI Responses 模型支持: @@ -123,11 +123,11 @@ agent = Agent( - `@function_tool(defer_loading=True)` 及其他延迟加载的 Responses 工具接口 - [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]、`allowed_callers` 和 `tool_choice="programmatic_tool_calling"` -Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请将 `ToolSearchTool()` 添加到智能体,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制使用单独的命名空间名称或仅限延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 +Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请将 `ToolSearchTool()` 添加到智能体,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制使用纯命名空间名称或仅限延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 ### Responses WebSocket 传输 -默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用 OpenAI Responses 提供商路径时,可以选择启用 WebSocket 传输。 +默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用 OpenAI Responses 提供商路径时,您可以选择启用 WebSocket 传输。 #### 基本配置 @@ -137,13 +137,13 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响默认 OpenAI 提供商解析模型名称时生成的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 +这会影响默认 OpenAI提供商解析模型名称后得到的 OpenAI Responses 模型,包括 `"gpt-5.6-sol"` 等字符串模型名称。 -SDK 将模型名称解析为模型实例时会选择传输方式。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已经固定:[​​`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 继续使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商而非全局默认配置控制传输方式的选择。 +传输方式的选择发生在 SDK 将模型名称解析为模型实例时。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已经固定:[ `OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 仍使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商而非全局默认设置控制传输方式的选择。 #### 提供商或运行级配置 -也可以按提供商或按运行配置 WebSocket 传输: +您也可以按提供商或按运行配置 WebSocket 传输: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -164,7 +164,7 @@ result = await Runner.run( ) ``` -通过 SDK 的 OpenAI 集成进行路由的提供商也接受可选的智能体注册配置。这是一项高级选项,适用于 OpenAI 配置需要提供商级注册元数据(例如测试框架 ID)的情况。 +通过 SDK 的 OpenAI集成进行路由的提供商也接受可选的智能体注册配置。这是一个高级选项,适用于 OpenAI配置需要提供商级注册元数据(例如框架 ID)的情况。 ```python from agents import ( @@ -190,14 +190,14 @@ result = await Runner.run( #### 使用 `MultiProvider` 的高级路由 -如果需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 +如果您需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 `MultiProvider` 保留了两个历史默认行为: -- `openai/...` 被视为 OpenAI 提供商的别名,因此 `openai/gpt-4.1` 会以模型 `gpt-4.1` 进行路由。 -- 未知前缀会引发 `UserError`,而不是直接传递。 +- `openai/...` 被视为 OpenAI提供商的别名,因此 `openai/gpt-4.1` 会以模型 `gpt-4.1` 进行路由。 +- 未知前缀会引发 `UserError`,而不是直接透传。 -将 OpenAI 提供商指向需要字面量命名空间模型 ID 的 OpenAI 兼容端点时,请显式启用直通行为。在启用 WebSocket 的配置中,也要在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: +当您将 OpenAI提供商指向要求使用字面命名空间模型 ID 的 OpenAI兼容端点时,请显式启用透传行为。在启用 WebSocket 的配置中,也要在 `MultiProvider` 上保留 `openai_use_responses_websocket=True`: ```python from agents import Agent, MultiProvider, RunConfig, Runner @@ -223,27 +223,27 @@ result = await Runner.run( ) ``` -当后端需要字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端需要 `openrouter/openai/gpt-4.1-mini` 等其他命名空间模型 ID 时,请使用 `unknown_prefix_mode="model_id"`。这些选项同样适用于 WebSocket 传输之外的 `MultiProvider`;此示例继续启用 WebSocket,是因为它属于本节所述的传输配置。同样的选项也适用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 +当后端要求使用字面量 `openai/...` 字符串时,请使用 `openai_prefix_mode="model_id"`。当后端要求使用其他命名空间模型 ID(例如 `openrouter/openai/gpt-4.1-mini`)时,请使用 `unknown_prefix_mode="model_id"`。这些选项也适用于 WebSocket 传输之外的 `MultiProvider`;此示例保持启用 WebSocket,因为它属于本节所述的传输配置。这些选项同样适用于 [`responses_websocket_session()`][agents.responses_websocket_session]。 -如果通过 `MultiProvider` 进行路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它会被转发给底层 OpenAI 提供商。 +如果通过 `MultiProvider` 路由时需要相同的提供商级注册元数据,请传入 `openai_agent_registration=OpenAIAgentRegistrationConfig(...)`,它将被转发到下层 OpenAI提供商。 -如果使用自定义 OpenAI 兼容端点或代理,WebSocket 传输还需要兼容的 WebSocket `/responses` 端点。在这些配置中,可能需要显式设置 `websocket_base_url`。 +如果使用自定义 OpenAI兼容端点或代理,WebSocket 传输还要求提供兼容的 WebSocket `/responses` 端点。在这些配置中,您可能需要显式设置 `websocket_base_url`。 #### 注意事项 -- 这是通过 WebSocket 传输的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions。只有非 OpenAI 提供商支持 Responses WebSocket `/responses` 端点时,它才适用于这些提供商。 -- 如果环境中尚未提供 `websockets` 软件包,请安装它。 -- 启用 WebSocket 传输后,可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次以及嵌套的“智能体作为工具”调用复用同一 WebSocket 连接的多轮工作流,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助工具。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 -- 对于长时间推理轮次或存在延迟峰值的网络,请使用 `responses_websocket_options` 自定义 WebSocket 保活行为。增大 `ping_timeout` 以容忍延迟的 pong 帧,或将 `ping_timeout=None` 设置为禁用心跳超时,同时继续启用 ping。当可靠性比 WebSocket 延迟更重要时,请优先使用 HTTP/SSE 传输。 -- 默认情况下,SDK 会禁用传入消息大小限制(`max_size=None`)。对于位于代理之后或内存受限容器中的长期运行智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}` 以限制每条消息的内存用量。 -- [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每个连接上一次处理一个响应,并将每个连接限制为 60 分钟。达到此限制后请打开新连接;需要并行运行时,请使用多个连接。 -- 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会从该内存中逐出 `previous_response_id` 引用的响应。重新连接后,只要已存储的响应仍可用,便仍可继续该响应;但 `store=False` 和 ZDR 流程没有持久化回退方案。请使用 `previous_response_id=None` 启动新链并发送完整输入上下文,或根据本地管理的会话状态重建该上下文。 +- 这是通过 WebSocket 传输的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions。仅当非 OpenAI提供商支持 Responses WebSocket `/responses` 端点时,才适用于这些提供商。 +- 如果您的环境中尚未提供 `websockets` 包,请安装它。 +- 启用 WebSocket 传输后,您可以直接使用 [`Runner.run_streamed()`][agents.run.Runner.run_streamed]。对于希望跨轮次以及嵌套的智能体即工具调用复用同一 WebSocket 连接的多轮工作流,建议使用 [`responses_websocket_session()`][agents.responses_websocket_session] 辅助工具。请参阅[运行智能体](../running_agents.md)指南和 [`examples/basic/stream_ws.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/stream_ws.py)。 +- 对于耗时较长的推理轮次或延迟会突增的网络,请使用 `responses_websocket_options` 自定义 WebSocket 保活行为。增大 `ping_timeout` 可容忍延迟的 pong 帧,或将 `ping_timeout=None` 设置为禁用心跳超时,同时保持启用 ping。当可靠性比 WebSocket 延迟更重要时,优先使用 HTTP/SSE 传输。 +- 默认情况下,SDK 会禁用传入消息的大小限制(`max_size=None`)。对于位于代理后方或内存受限容器中的长时间运行智能体进程,请设置 `responses_websocket_options={"max_size": 8 * 1024 * 1024}` 以限制每条消息的内存用量。 +- [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每个连接上一次处理一个响应,并将每个连接限制为 60 分钟。达到该限制后,请打开新连接;需要并行运行时,请使用多个连接。 +- 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会从该内存中逐出 `previous_response_id` 引用的响应。重新连接后,存储的响应在可用时仍可继续,但 `store=False` 和 ZDR 流程没有持久化回退方案。请使用 `previous_response_id=None` 开始新的链并发送完整的输入上下文,或根据本地管理的会话状态重建该上下文。 ### 托管多智能体(实验性) -OpenAI Responses API 托管多智能体 Beta 版允许 GPT-5.6 根模型创建并协调服务端托管的子智能体。Agents SDK 可以继续使用其常规 `Runner`:托管编排在服务端进行,而开发者定义的函数工具则在应用中执行。 +OpenAI Responses API 的托管多智能体 Beta 版允许 GPT-5.6 根模型创建并协调服务端托管的子智能体。Agents SDK 可以继续使用其常规的 `Runner`:托管编排在服务端进行,而开发者定义的函数工具在您的应用中执行。 -此集成为实验性功能,使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活跃的托管智能体。它要求使用 `openai[realtime]` 2.45.0 或更高版本的构建,该构建需公开 `client.beta.responses.connect`。接口和 Beta 项架构可能会在正式发布前发生变化。 +此集成为实验性功能,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活动的托管智能体。它要求使用 `openai[realtime]` 2.45.0 或更高版本的构建,且该构建需公开 `client.beta.responses.connect`。该接口和 Beta 版项目架构可能会在正式发布前发生变化。 #### 模型配置 @@ -260,11 +260,11 @@ agent = Agent( ) ``` -构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled` 并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则模型使用默认 OpenAI 客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 +构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则模型将使用默认 OpenAI客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 #### 本地函数工具 -所有托管智能体共享为请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 会在本地执行函数,并将具有相同调用 ID 的 `function_call_output` 注入活跃的 WebSocket 响应,使服务能够恢复最初的托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的函数工具都会在发送请求前被拒绝。 +所有托管智能体共享为该请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 在本地执行函数,并将具有相同调用 ID 的 `function_call_output` 注入活动的 WebSocket 响应,从而让服务恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的函数工具,都会在请求发送前被拒绝。 当工具需要感知调用方的日志记录或授权时,请使用 `get_hosted_agent_metadata()`: @@ -283,50 +283,50 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: return f"Contents for {section}" ``` -托管智能体名称是观测元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于具有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间通过应用代码实施所有必要的授权;请勿在此模型中使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 +托管智能体名称是观察性元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于具有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间,通过应用代码实施任何必要的授权;不要对此模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 #### 输出和流式传输行为 只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为常规最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管编排记录;SDK 绝不会将这些记录作为本地函数执行。 -原始流式传输会继续公开 Beta Responses 事件,包括托管输出项和 `response.inject.created` 确认。当函数调用就绪时,适配器会将一个活跃的提供商响应划分为 SDK 可见的逻辑模型轮次;Runner 生成输出后,再恢复同一个提供商响应。请将 `get_hosted_agent_metadata()` 与原始托管项或 `ToolContext` 一起使用,以识别该项或工具调用归属的托管智能体。 +原始流式传输会继续公开 Beta 版 Responses 事件,包括托管输出项和 `response.inject.created` 确认。当函数调用就绪时,适配器会将一个活动的提供商响应拆分为 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。使用原始托管项目或 `ToolContext` 的 `get_hosted_agent_metadata()`,可识别项目或工具调用归属的托管智能体。 #### 与 SDK 编排的关系 -托管多智能体与 SDK 任务转移和 Agents-as-tools 相互独立: +托管多智能体独立于 SDK 任务转移和 Agents-as-tools: -- 托管多智能体在 OpenAI 服务上创建子智能体。你的应用不会创建或调度这些子智能体。 -- SDK 任务转移会更改活跃的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,从而造成所有权冲突。 -- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估由此增加的延迟、成本和工具暴露范围。 +- 托管多智能体在 OpenAI服务上创建子智能体。您的应用不会创建或调度这些子智能体。 +- SDK 任务转移会更改活动的本地 SDK `Agent`。使用此实验性模型时会拒绝任务转移,因为每个托管智能体都会收到相同的任务转移工具,这会导致所有权冲突。 +- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估额外的延迟、成本和工具暴露。 #### 当前限制 -实验性模型会拒绝 `reasoning.summary`、`max_tool_calls`,以及调用方提供的 `multi_agent` 或 `betas` 覆盖。Beta 版不支持 Responses `/compact` 端点,不过可以使用显式的 `context_management.compact_threshold`,因为服务会自动分别压缩每个托管智能体的上下文。 +实验性模型会拒绝 `reasoning.summary`、`max_tool_calls`,以及调用方提供的 `multi_agent` 或 `betas` 覆盖值。Beta 版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 -一个 `OpenAIHostedMultiAgentModel` 实例一次最多拥有一个活跃的托管响应。如果在等待本地函数输出时放弃某次运行,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复进行中的托管响应。 +一个 `OpenAIHostedMultiAgentModel` 实例一次最多拥有一个活动的托管响应。如果某次运行在等待本地函数输出期间被放弃,请调用 `await model.close()` 释放其 WebSocket。目前不支持在其他进程或事件循环中恢复正在进行的托管响应。 -有关底层 Responses API Beta 行为,请参阅 [OpenAI 多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 +有关底层 Responses API Beta 版行为,请参阅 [OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 -## 非 OpenAI 模型 +## 非 OpenAI模型 -如果需要非 OpenAI 提供商,请从 SDK 的内置提供商集成点开始。对于许多配置,这已足够,无需添加第三方适配器。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 +如果您需要非 OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多配置中,无需添加第三方适配器即可满足需求。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 -### 非 OpenAI 提供商集成方式 +### 非 OpenAI提供商集成方式 | 方式 | 适用场景 | 作用域 | | --- | --- | --- | -| [`set_default_openai_client`][agents.set_default_openai_client] | 一个 OpenAI 兼容端点应作为大多数或所有智能体的默认端点 | 全局默认 | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应应用于单次运行 | 每次运行 | -| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同提供商或具体模型对象 | 每个智能体 | -| 第三方适配器 | 由于内置路径无法提供所需能力,因此需要适配器提供的提供商覆盖范围或路由 | 请参阅[第三方适配器](#third-party-adapters) | +| [`set_default_openai_client`][agents.set_default_openai_client] | 应将一个 OpenAI兼容端点作为大多数或所有智能体的默认端点 | 全局默认 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 一个自定义提供商应仅应用于单次运行 | 按运行 | +| [`Agent.model`][agents.agent.Agent.model] | 不同智能体需要不同提供商或具体模型对象 | 按智能体 | +| 第三方适配器 | 由于内置路径无法提供所需功能,因此您需要适配器提供的提供商覆盖范围或路由 | 请参阅[第三方适配器](#third-party-adapters) | -可以通过以下内置路径集成其他 LLM 提供商: +您可以通过以下内置路径集成其他 LLM 提供商: -1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望全局使用 `AsyncOpenAI` 实例作为 LLM 客户端的情况。这适用于 LLM 提供商具有 OpenAI 兼容 API 端点,并且可以设置 `base_url` 和 `api_key` 的情况。可配置示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 -2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 级别。这样可以指定“为此次运行中的所有智能体使用自定义模型提供商”。可配置示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 -3. [`Agent.model`][agents.agent.Agent.model] 允许在特定 Agent 实例上指定模型。这样可以为不同智能体灵活搭配不同提供商。可配置示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 +1. [`set_default_openai_client`][agents.set_default_openai_client] 适用于希望将 `AsyncOpenAI` 实例全局用作 LLM 客户端的情况。这适用于 LLM 提供商拥有 OpenAI兼容 API 端点,并且您可以设置 `base_url` 和 `api_key` 的情况。可配置的代码示例请参阅 [examples/model_providers/custom_example_global.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_global.py)。 +2. [`ModelProvider`][agents.models.interface.ModelProvider] 位于 `Runner.run` 级别。借助它,您可以指定“本次运行中的所有智能体都使用自定义模型提供商”。可配置的代码示例请参阅 [examples/model_providers/custom_example_provider.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_provider.py)。 +3. [`Agent.model`][agents.agent.Agent.model] 允许您在特定 Agent 实例上指定模型。借助它,您可以为不同智能体混用不同提供商。可配置的代码示例请参阅 [examples/model_providers/custom_example_agent.py](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/custom_example_agent.py)。 -如果没有 `platform.openai.com` 的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或配置[其他追踪处理器](../tracing.md)。 +如果您没有 `platform.openai.com` 提供的 API 密钥,建议通过 `set_tracing_disabled()` 禁用追踪,或设置[其他追踪处理器](../tracing.md)。 ``` python from agents import Agent, AsyncOpenAI, OpenAIChatCompletionsModel, set_tracing_disabled @@ -341,19 +341,19 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model !!! note - 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果你的 LLM 提供商支持 Responses API,建议使用 Responses。 + 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持该 API,我们建议使用 Responses。 ## 在一个工作流中混用模型 -在单个工作流中,可能希望每个智能体使用不同的模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: +在单个工作流中,您可能希望每个智能体使用不同模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: 1. 传入模型名称。 -2. 传入任意模型名称以及能够将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 +2. 传入任意模型名称以及可将该名称映射到 Model 实例的 [`ModelProvider`][agents.models.interface.ModelProvider]。 3. 直接提供 [`Model`][agents.models.interface.Model] 实现。 !!! note - 虽然 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 两种形式,但建议每个工作流只使用一种模型形式,因为两者支持的功能和工具集合不同。如果工作流需要混用模型形式,请确保正在使用的所有功能都同时受两者支持。 + 虽然我们的 SDK 同时支持 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 和 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 形式,但我们建议每个工作流仅使用一种模型形式,因为两者支持的功能和工具集合不同。如果您的工作流需要混用模型形式,请确保使用的所有功能都同时受两者支持。 ```python import asyncio @@ -391,7 +391,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 直接设置 OpenAI 模型的名称。 +1. 直接设置 OpenAI模型的名称。 2. 提供 [`Model`][agents.models.interface.Model] 实现。 如果要进一步配置智能体使用的模型,可以传入 [`ModelSettings`][agents.model_settings.ModelSettings],它提供 temperature 等可选模型配置参数。 @@ -409,21 +409,21 @@ english_agent = Agent( ## 高级 OpenAI Responses 设置 -使用 OpenAI Responses 路径并需要更多控制时,请从 `ModelSettings` 开始。 +当您使用 OpenAI Responses 路径并需要更多控制时,请从 `ModelSettings` 开始。 ### 常用高级 `ModelSettings` 选项 -使用 OpenAI Responses API 时,多个请求字段已具有对应的 `ModelSettings` 直接字段,因此无需为它们使用 `extra_args`。 +使用 OpenAI Responses API 时,若干请求字段已经有直接对应的 `ModelSettings` 字段,因此无需为它们使用 `extra_args`。 - `parallel_tool_calls`:允许或禁止在同一轮中进行多次工具调用。 -- `truncation`:设置 `"auto"`,使 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是请求失败。 -- `store`:控制生成的响应是否存储在服务端以供以后检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 +- `truncation`:设置 `"auto"`,让 Responses API 在上下文即将溢出时丢弃最早的对话项,而不是让请求失败。 +- `store`:控制生成的响应是否存储在服务端以供后续检索。这对于依赖响应 ID 的后续工作流,以及在 `store=False` 时可能需要回退到本地输入的会话压缩流程非常重要。 - `context_management`:配置服务端上下文处理,例如使用 `compact_threshold` 进行 Responses 压缩。 -- `prompt_cache_retention`:为较早的模型系列配置延长保留时间,例如 +- `prompt_cache_retention`:为较早的模型系列配置延长保留,例如 使用 `"24h"`。 - `prompt_cache_options`:选择隐式或显式提示词缓存,并为 GPT-5.6 配置 `"30m"` 缓存 TTL。 - `response_include`:请求更丰富的响应载荷,例如 `web_search_call.action.sources`、`file_search_call.results` 或 `reasoning.encrypted_content`。 -- `top_logprobs`:请求输出文本的最高概率 token logprobs。SDK 还会自动添加 `message.output_text.logprobs`。 +- `top_logprobs`:请求输出文本中排名靠前的 token 的 logprob。SDK 还会自动添加 `message.output_text.logprobs`。 - `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅[由 Runner 管理的重试](#runner-managed-retries)。 ```python @@ -444,7 +444,7 @@ research_agent = Agent( ) ``` -使用显式提示词缓存时,请在结束可复用前缀的内容部分添加断点。相同的 `ModelSettings.prompt_cache_options` 字段会透传到 Responses 和 Chat Completions 请求,Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 +使用显式提示词缓存时,请在可复用前缀结尾的内容部分添加断点。同一个 `ModelSettings.prompt_cache_options` 字段会透传到 Responses 和 Chat Completions 请求,并且 Chat Completions 转换器会保留文本、图像、音频和文件内容部分上的断点。 ```python from agents import Runner @@ -470,19 +470,18 @@ result = await Runner.run( ) ``` -`prompt_cache_retention` 仍可用于采用旧版 -保留控制的较早模型系列。请勿同时使用直接 `ModelSettings` 字段和 -`extra_args` 中的同名键。 +对于使用旧版保留控制的较早模型系列,`prompt_cache_retention` 仍然可用。不要将直接的 `ModelSettings` 字段与 +`extra_args` 中的相同键组合使用。 -设置 `store=False` 后,Responses API 不会保留该响应供以后在服务端检索。这适用于无状态或零数据保留类型的流程,但也意味着原本会复用响应 ID 的功能必须改为依赖本地管理的状态。例如,当上一个响应未存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将其默认 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 +设置 `store=False` 时,Responses API 不会保留该响应以供日后在服务端检索。这对于无状态或零数据保留风格的流程很有用,但这也意味着原本会复用响应 ID 的功能需要改为依赖本地管理的状态。例如,当最后一次响应未存储时,[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 会将其默认的 `"auto"` 压缩路径切换为基于输入的压缩。请参阅[会话指南](../sessions/index.md#openai-responses-compaction-sessions)。 -服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 随每次 Responses API 请求发送,当渲染后的上下文超过阈值时,API 可以在响应中发出压缩项。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 +服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每个 Responses API 请求发送,当渲染后的上下文超过阈值时,API 可以在响应中生成压缩项。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 ### `extra_args` 的传递 -如果需要 SDK 尚未在顶层直接公开的提供商特定字段或较新的请求字段,请使用 `extra_args`。 +如果需要 SDK 尚未直接在顶层公开的提供商特定字段或较新的请求字段,请使用 `extra_args`。 -使用 OpenAI 模型时,`extra_args` 可以向 Responses API 和 Chat Completions API 传递可选参数,例如 `user` 和 `service_tier`。对于受支持的模型,设置 `extra_args={"service_tier": "fast"}` 可使用[快速模式](https://developers.openai.com/api/docs/guides/fast-mode);`"priority"` 仍与其等效。请勿同时通过直接 `ModelSettings` 字段设置同一请求字段。 +使用 OpenAI模型时,`extra_args` 可以向 Responses API 和 Chat Completions API 传递可选参数,例如 `user` 和 `service_tier`。对于受支持的模型,请设置 `extra_args={"service_tier": "fast"}` 以使用[快速模式](https://developers.openai.com/api/docs/guides/fast-mode);`"priority"` 仍与之等效。请勿同时通过直接的 `ModelSettings` 字段设置同一个请求字段。 ```python from agents import Agent, ModelSettings @@ -498,11 +497,26 @@ english_agent = Agent( ) ``` +## 模型调用超时 + +将 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] 设置为正数秒值,以限制每次模型调用尝试。该超时适用于流式和非流式调用,并涵盖完整的调用尝试,包括等待传输的时间。它不会限制完整的智能体运行、函数工具执行或重试退避。 + +```python +from agents import Agent, ModelSettings + +agent = Agent( + name="Assistant", + model_settings=ModelSettings(timeout=30.0), +) +``` + +如果一次尝试超过限制,SDK 会取消该尝试并等待其清理完成,然后引发 [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]。启用由 Runner 管理的重试时,SDK 会将超时失败传递给重试策略,并将 `context.normalized.is_timeout` 设置为 `True`;例如,`retry_policies.network_error()` 会匹配该分类。每次允许的重试都会获得新的单次尝试超时。重试前,SDK 仍会应用常规的[重放安全规则](#safety-boundaries)。 + ## 由 Runner 管理的重试 -重试仅在运行时生效,并且需要选择启用。除非设置 `ModelSettings(retry=...)` 且重试策略决定重试,否则 SDK 不会重试常规模型请求。 +重试仅在运行时生效,并且需要选择启用。除非您设置 `ModelSettings(retry=...)` 且重试策略决定重试,否则 SDK 不会重试常规模型请求。 -在 Responses WebSocket 传输中,`retry_policies.provider_suggested()` 会将响应前的过载帧和无代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:仍需设置 `ModelRetrySettings`,并且常规重放安全检查仍然适用。如果已经收到任何响应事件,SDK 不会重放请求。 +在 Responses WebSocket 传输中,`retry_policies.provider_suggested()` 会将响应前的过载帧和没有代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:您仍需设置 `ModelRetrySettings`,并且常规的重放安全检查仍然适用。如果已经收到任何响应事件,SDK 不会重放请求。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -530,88 +544,88 @@ agent = Agent( ) ``` -`ModelRetrySettings` 包含三个字段: +`ModelRetrySettings` 有三个字段:
| 字段 | 类型 | 说明 | | --- | --- | --- | -| `max_retries` | `int | None` | 初始请求之后允许的重试次数。 | -| `backoff` | `ModelRetryBackoffSettings | dict | None` | 当策略进行重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制计算得出的退避延迟,不限制策略返回的显式延迟或 retry-after 提示。 | +| `max_retries` | `int | None` | 初始请求后允许的重试次数。 | +| `backoff` | `ModelRetryBackoffSettings | dict | None` | 策略决定重试但未返回显式延迟时使用的默认延迟策略。`backoff.max_delay` 仅限制由此计算出的退避延迟,不会限制策略返回的显式延迟或 retry-after 提示。 | | `policy` | `RetryPolicy | None` | 决定是否重试的回调。此字段仅在运行时生效,不会被序列化。 |
-重试策略会接收一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: +重试策略会收到一个 [`RetryPolicyContext`][agents.retry.RetryPolicyContext],其中包含: -- `attempt` 和 `max_retries`,以便根据尝试次数作出决策。 -- `stream`,以便区分流式与非流式行为。 -- `error`,用于原始数据检查。 +- `attempt` 和 `max_retries`,便于您根据尝试次数作出决定。 +- `stream`,便于您区分流式和非流式行为。 +- `error`,用于原始检查。 - `normalized` 事实,例如 `status_code`、`retry_after`、`error_code`、`is_network_error`、`is_timeout` 和 `is_abort`。 -- `provider_advice`,当底层模型适配器可以提供重试指导时使用。 -- `response_started`、`replay_safety` 和 `stateful_request`,它们是在策略运行前捕获的稳定重放安全事实。`replay_safety` 是 `"safe"`、`"unsafe"` 或 `"unknown"`;当请求使用 `previous_response_id` 或 `conversation_id` 时,`stateful_request` 为 true。 +- 当底层模型适配器可以提供重试指导时的 `provider_advice`。 +- `response_started`、`replay_safety` 和 `stateful_request`,它们是在策略运行前捕获的稳定重放安全事实。`replay_safety` 为 `"safe"`、`"unsafe"` 或 `"unknown"`;当请求使用 `previous_response_id` 或 `conversation_id` 时,`stateful_request` 为 true。 -策略可以返回以下任一内容: +策略可以返回以下任一结果: - `True` / `False`,用于简单的重试决策。 -- 当需要覆盖延迟、附加诊断原因或显式批准范围有限的不安全重放时,返回 [`RetryDecision`][agents.retry.RetryDecision]。 +- 当您希望覆盖延迟、附加诊断原因,或显式批准范围严格受限的不安全重放时,返回 [`RetryDecision`][agents.retry.RetryDecision]。 SDK 在 `retry_policies` 上导出了现成的辅助工具: | 辅助工具 | 行为 | | --- | --- | -| `retry_policies.never()` | 始终不启用。 | +| `retry_policies.never()` | 始终选择不重试。 | | `retry_policies.provider_suggested()` | 在提供商提供重试建议时遵循该建议。 | -| `retry_policies.network_error()` | 匹配临时传输和超时故障。 | +| `retry_policies.network_error()` | 匹配暂时性传输和超时失败。 | | `retry_policies.http_status([...])` | 匹配选定的 HTTP 状态码。 | | `retry_policies.retry_after()` | 仅在提供 retry-after 提示时重试,并使用该延迟。此辅助工具将 retry-after 值视为显式策略延迟,因此 `backoff.max_delay` 不会限制它。 | -| `retry_policies.any(...)` | 当任一嵌套策略选择启用时重试。 | -| `retry_policies.all(...)` | 仅当所有嵌套策略都选择启用时重试。 | +| `retry_policies.any(...)` | 当任一嵌套策略选择重试时进行重试。 | +| `retry_policies.all(...)` | 仅当每个嵌套策略都选择重试时才进行重试。 | -组合策略时,`provider_suggested()` 是最安全的首个基础组件,因为当提供商能够区分否决和重放安全批准时,它会保留这些信息。 +组合策略时,`provider_suggested()` 是最安全的第一个基本组件,因为当提供商能够区分否决和重放安全批准时,它会保留这些信息。 ##### 安全边界 -以下某些故障绝不会重试: +某些失败永远不会重试: - 中止错误。 -- 已经开始输出且重放会不安全的流式运行。 -- 存在单独本地副作用重放否决的请求,包括程序化工具调用请求,除非提供商已独立将重放标记为安全。 +- 输出已经以某种方式开始,导致重放不安全的流式运行。 +- 存在独立本地副作用重放否决的请求,包括程序化工具调用请求,除非提供商已独立将重放标记为安全。 -默认情况下,提供商标记为不安全的故障也会被阻止。对于不存在单独本地副作用否决的非流式请求,应用可以通过返回 `RetryDecision(retry=True, approve_unsafe_replay=True)` 接受提供商侧的重放风险。授予此批准前,请检查 `context.response_started`、`context.replay_safety` 和 `context.stateful_request`,并且仅在可以接受重复执行提供商侧工作时授予批准。普通的 `RetryDecision(retry=True)` 绝不会绕过重放保护,`approve_unsafe_replay=True` 也无法授权流式重试或本地副作用。 +默认情况下,提供商标记为不安全的失败也会被阻止。对于不存在独立本地副作用否决的非流式请求,应用可以通过返回 `RetryDecision(retry=True, approve_unsafe_replay=True)` 来接受提供商侧的重放风险。在授予此批准之前,请检查 `context.response_started`、`context.replay_safety` 和 `context.stateful_request`,并且仅在可以接受重复执行提供商侧工作时授予批准。普通的 `RetryDecision(retry=True)` 永远无法绕过重放保护,而 `approve_unsafe_replay=True` 无法授权流式重试或本地副作用。 -使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求在重放安全性未知时会以失败关闭。对于这些请求,仅使用 `network_error()` 或 `http_status([500])` 等非提供商谓词还不够。请包含提供商的重放安全批准,通常通过 `retry_policies.provider_suggested()` 实现;或者按照上述方式,显式批准提供商标记为不安全的非流式故障。 +使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求会在重放安全性未知时以关闭方式失败。对于这些请求,`network_error()` 或 `http_status([500])` 等非提供商谓词本身并不足够。请包含提供商提供的重放安全批准(通常通过 `retry_policies.provider_suggested()`),或按照上述方式显式批准提供商标记为不安全的非流式失败。 -##### Runner 与智能体合并行为 +##### Runner 和智能体合并行为 -`retry` 会在 Runner 级和智能体级 `ModelSettings` 之间进行深度合并: +Runner 级与智能体级 `ModelSettings` 之间会对 `retry` 进行深度合并: -- 智能体可以仅覆盖 `retry.max_retries`,并继续继承 Runner 的 `policy`。 -- 智能体可以仅覆盖 `retry.backoff` 的一部分,并保留 Runner 的同级退避字段。 -- `policy` 仅在运行时生效,因此序列化的 `ModelSettings` 会保留 `max_retries` 和 `backoff`,但省略回调本身。 +- 智能体可以仅覆盖 `retry.max_retries`,同时继续继承 Runner 的 `policy`。 +- 智能体可以仅覆盖 `retry.backoff` 的一部分,同时保留 Runner 中同级的退避字段。 +- `policy` 仅在运行时生效,因此序列化后的 `ModelSettings` 会保留 `max_retries` 和 `backoff`,但省略回调本身。 -更完整的代码示例请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[基于适配器的重试示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 +有关更完整的代码示例,请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[基于适配器的重试代码示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 -## 非 OpenAI 提供商故障排除 +## 非 OpenAI提供商故障排除 ### 追踪客户端错误 401 -如果遇到与追踪相关的错误,这是因为追踪数据会上传到 OpenAI 服务器,而你没有 OpenAI API 密钥。可通过以下三种方式解决: +如果遇到与追踪相关的错误,这是因为追踪数据会上传到 OpenAI服务器,而您没有 OpenAI API 密钥。您有以下三种解决方案: 1. 完全禁用追踪:[`set_tracing_disabled(True)`][agents.set_tracing_disabled]。 -2. 为追踪设置 OpenAI 密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 -3. 使用非 OpenAI 追踪处理器。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 +2. 为追踪设置 OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 +3. 使用非 OpenAI追踪处理器。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 ### Responses API 支持 -SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,可能会看到 404 或类似问题。可通过以下两种方式解决: +SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,您可能会看到 404 或类似问题。您有以下两个解决方案: -1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方式适用。 -2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。相关代码示例见[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 +1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方法有效。 +2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)提供了一些代码示例。 ### Chat Completions 兼容性选项 -通过 Chat Completions 路由时,SDK 会静默丢弃 Chat Completions 无法发送的仅限 Responses 字段,以保持兼容性,例如 `previous_response_id`、`conversation_id`、Responses API `prompt` 字段,或并非纯文本的工具输出。如果希望这些不匹配问题在开发期间快速失败,请在 OpenAI 提供商上启用严格功能验证: +通过 Chat Completions 路由时,SDK 会静默丢弃 Chat Completions 无法发送的 Responses 专属字段,从而保持兼容性,例如 `previous_response_id`、`conversation_id`、Responses API 的 `prompt` 字段,或并非纯文本的工具输出。如果您希望在开发过程中让这些不匹配情况快速失败,请在 OpenAI提供商上启用严格功能验证: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -631,9 +645,9 @@ result = await Runner.run( 如果使用 [`MultiProvider`][agents.MultiProvider],请改为传入 `openai_strict_feature_validation=True`。 -OpenAI Chat Completions API 可以返回音频输出,但 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 当前不会将音频输出转换为 Agents SDK 运行项。如果非流式消息或流式增量包含音频输出,适配器会引发 `AgentsException("Audio is not currently supported")`,而不是返回部分结果或空结果。对于由 SDK 管理的音频工作流,请使用[实时智能体](../realtime/guide.md)或[语音智能体](../voice/quickstart.md)。 +OpenAI Chat Completions API 可以返回音频输出,但 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 目前不会将音频输出转换为 Agents SDK 运行项。如果非流式消息或流式增量包含音频输出,适配器会引发 `AgentsException("Audio is not currently supported")`,而不是返回部分结果或空结果。对于由 SDK 管理的音频工作流,请使用[实时智能体](../realtime/guide.md)或[语音智能体](../voice/quickstart.md)。 -一些 OpenAI 兼容的 Chat Completions 提供商会以分块形式流式传输工具调用增量,其可靠性不足以支持 SDK 增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后发出工具调用: +某些兼容 OpenAI的 Chat Completions 提供商会以分块方式流式传输工具调用增量,而这些分块的可靠性不足以进行 SDK 增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后生成工具调用: ```python from agents import OpenAIProvider @@ -648,7 +662,7 @@ provider = OpenAIProvider( ### structured outputs 支持 -一些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: +某些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: ``` @@ -656,42 +670,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -这是某些模型提供商的不足之处——它们支持 JSON 输出,但不允许指定用于输出的 `json_schema`。我们正在解决此问题,但建议依赖支持 JSON schema 输出的提供商,否则应用经常会因格式错误的 JSON 而中断。 +这是某些模型提供商的不足之处——它们支持 JSON 输出,但不允许您指定输出使用的 `json_schema`。我们正在修复此问题,但建议依赖支持 JSON schema 输出的提供商,否则您的应用会经常因格式错误的 JSON 而中断。 ## 跨提供商混用模型 -你需要了解模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI 支持 structured outputs、多模态输入、托管文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: +您需要注意模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI支持 structured outputs、多模态输入,以及托管的文件检索和网络检索,但许多其他提供商并不支持这些功能。请注意以下限制: -- 不要向无法理解的提供商发送不受支持的 `tools` -- 调用纯文本模型前过滤掉多模态输入 +- 不要向无法理解不受支持的 `tools` 的提供商发送它们 +- 在调用纯文本模型前过滤掉多模态输入 - 请注意,不支持结构化 JSON 输出的提供商偶尔会生成无效 JSON。 ## 第三方适配器 -仅当 SDK 的内置提供商集成点不足以满足需求时,才使用第三方适配器。如果只通过此 SDK 使用 OpenAI 模型,请优先选择内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI 模型与非 OpenAI 提供商结合使用,或需要只有适配器才能提供的提供商覆盖范围或路由的情况。适配器会在 SDK 与上游模型提供商之间增加一层兼容层,因此功能支持和请求语义可能因提供商而异。SDK 当前以尽力支持的 Beta 适配器集成形式包含 Any-LLM 和 LiteLLM。 +仅当 SDK 的内置提供商集成点不足以满足需求时,才应使用第三方适配器。如果您在此 SDK 中仅使用 OpenAI模型,请优先使用内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI模型与非 OpenAI提供商结合使用,或需要仅由适配器提供的提供商覆盖范围或路由的情况。适配器会在 SDK 与上游模型提供商之间增加另一个兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的 Beta 版适配器集成形式包含 Any-LLM 和 LiteLLM。 ### Any-LLM -对于需要由 Any-LLM 管理提供商覆盖范围或路由的情况,Any-LLM 支持以尽力支持的 Beta 形式提供。 +Any-LLM 支持以尽力支持的 Beta 版形式提供,适用于需要由 Any-LLM 管理提供商覆盖范围或路由的情况。 -根据上游提供商路径,Any-LLM 可能会使用 Responses API、与 Chat Completions 兼容的 API,或提供商特定的兼容层。 +根据上游提供商路径,Any-LLM 可能使用 Responses API、兼容 Chat Completions 的 API,或提供商特定的兼容层。 -如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 配合使用,直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 +如果需要 Any-LLM,请安装 `openai-agents[any-llm]`,然后从 [`examples/model_providers/any_llm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_auto.py) 或 [`examples/model_providers/any_llm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/any_llm_provider.py) 开始。您可以将 `any-llm/...` 模型名称与 [`MultiProvider`][agents.MultiProvider] 搭配使用、直接实例化 `AnyLLMModel`,或在运行作用域使用 `AnyLLMProvider`。如果需要显式固定模型接口,请在构造 `AnyLLMModel` 时传入 `api="responses"` 或 `api="chat_completions"`。 -Any-LLM 仍是第三方适配器层,因此提供商依赖项和能力缺口由上游 Any-LLM 而非 SDK 定义。当上游提供商返回用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要 `ModelSettings(include_usage=True)` 才会发出用量数据块。如果依赖 structured outputs、工具调用、用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 +Any-LLM 仍然是第三方适配器层,因此提供商依赖项和能力缺口由上游 Any-LLM 而非 SDK 定义。当上游提供商返回使用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会生成使用量分块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 ### LiteLLM -对于需要 LiteLLM 特定提供商覆盖范围或路由的情况,LiteLLM 支持以尽力支持的 Beta 形式提供。 +LiteLLM 支持以尽力支持的 Beta 版形式提供,适用于需要 LiteLLM 特定提供商覆盖范围或路由的情况。 -如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。可以使用 `litellm/...` 模型名称,或直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 +如果需要 LiteLLM,请安装 `openai-agents[litellm]`,然后从 [`examples/model_providers/litellm_auto.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_auto.py) 或 [`examples/model_providers/litellm_provider.py`](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/litellm_provider.py) 开始。您可以使用 `litellm/...` 模型名称,或直接实例化 [`LitellmModel`][agents.extensions.models.litellm_model.LitellmModel]。 -通过 LiteLLM 适配器访问的部分提供商默认不会填充 SDK 用量指标。如果需要用量报告,请传入 `ModelSettings(include_usage=True)`;如果依赖 structured outputs、工具调用、用量报告或适配器特定的路由行为,请验证计划部署的具体提供商后端。 +通过 LiteLLM 适配器访问的某些提供商默认不会填充 SDK 使用量指标。如果需要使用量报告,请传入 `ModelSettings(include_usage=True)`;如果您依赖 structured outputs、工具调用、使用量报告或适配器特定路由行为,请验证计划部署的具体提供商后端。 -如果 LiteLLM 为响应对象发出 Pydantic 序列化器警告,可以在导入 LiteLLM 适配器之前选择启用 SDK 的兼容性补丁: +如果 LiteLLM 为响应对象发出 Pydantic 序列化器警告,您可以在导入 LiteLLM 适配器之前选择启用 SDK 的兼容性补丁: ```bash export OPENAI_AGENTS_ENABLE_LITELLM_SERIALIZER_PATCH=true ``` -该补丁默认禁用,并且仅对 `1` 或 `true` 值启用。它通过封装一个私有 LiteLLM 日志辅助工具来抑制特定类别的 LiteLLM 响应序列化警告,因此应将其视为针对性解决方法,而不是通用序列化设置。由于它依赖私有 LiteLLM API,升级 LiteLLM 时请重新验证;当上游警告不再出现时,请移除该环境变量。 \ No newline at end of file +该补丁默认禁用,仅对 `1` 或 `true` 值启用。它通过包装 LiteLLM 的私有日志辅助工具来抑制一类特定的 LiteLLM 响应序列化警告,因此应将其视为针对性解决方案,而不是通用序列化设置。由于它依赖 LiteLLM 的私有 API,升级 LiteLLM 时请重新验证,并在上游警告不再出现后移除该环境变量。 \ No newline at end of file diff --git a/docs/zh/realtime/guide.md b/docs/zh/realtime/guide.md index e2ba2410ee..050341f396 100644 --- a/docs/zh/realtime/guide.md +++ b/docs/zh/realtime/guide.md @@ -2,50 +2,52 @@ search: exclude: true --- -# 实时智能体指南 +# Realtime 智能体指南 -本指南说明OpenAI Agents SDK的实时层如何映射到OpenAI Realtime API,以及Python SDK在此基础上增加了哪些额外行为。 +本指南说明 OpenAI Agents SDK的 Realtime 层如何映射到 OpenAI Realtime API,以及 Python SDK 在此基础上增加了哪些额外行为。 -!!! note "从这里开始" +!!! note "入门指引" - 如果希望使用默认的Python路径,请先阅读[快速入门](quickstart.md)。如果正在决定应用应使用服务器端WebSocket还是SIP,请阅读[实时传输](transport.md)。浏览器WebRTC传输不属于Python SDK的一部分。 + 如果要使用默认的 Python 路径,请先阅读[快速入门](quickstart.md)。如果正在决定应用应使用服务器端 WebSocket 还是 SIP,请阅读 [Realtime 传输方式](transport.md)。浏览器 WebRTC 传输不属于 Python SDK。 ## 概述 -实时智能体与Realtime API保持长连接,使模型能够以增量方式处理文本和音频、流式传输音频输出、调用工具并处理中断,而无需在每轮对话时重新发起新请求。 +Realtime 智能体会与 Realtime API 保持长期连接,使模型能够以增量方式处理文本和音频、以流式方式输出音频、调用工具并处理中断,而无需在每个轮次都重新发起请求。 -主要SDK组件包括: +主要 SDK 组件包括: -- **RealtimeAgent**:一个实时专家的指令、工具、输出安全防护措施和任务转移 -- **RealtimeRunner**:将起始智能体连接到实时传输层的会话工厂 -- **RealtimeSession**:用于发送输入、接收事件、追踪历史记录和执行工具的实时会话 -- **RealtimeModel**:传输抽象。默认实现是OpenAI的服务器端WebSocket。 +- **RealtimeAgent**:一个 Realtime 专用智能体的指令、工具、输出安全防护措施和任务转移 +- **RealtimeRunner**:将起始智能体连接到 Realtime 传输层的会话工厂 +- **RealtimeSession**:发送输入、接收事件、追踪历史记录并执行工具的实时会话 +- **RealtimeModel**:传输抽象。默认实现是 OpenAI的服务器端 WebSocket。 ## 会话生命周期 -典型的实时会话如下: +典型的 Realtime 会话如下: -1. 创建一个或多个`RealtimeAgent`。 -2. 使用起始智能体创建`RealtimeRunner`。 -3. 调用`await runner.run()`以获取`RealtimeSession`。 -4. 使用`async with session:`或`await session.enter()`进入会话。 -5. 使用`send_message()`或`send_audio()`发送用户输入。 +1. 创建一个或多个 `RealtimeAgent`。 +2. 使用起始智能体创建 `RealtimeRunner`。 +3. 调用 `await runner.run()` 获取 `RealtimeSession`。 +4. 使用 `async with session:` 或 `await session.enter()` 进入会话。 +5. 使用 `send_message()` 或 `send_audio()` 发送用户输入。 6. 迭代处理会话事件,直到对话结束。 -与纯文本运行不同,`runner.run()`不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和活动智能体配置与传输层保持同步。 +与纯文本运行不同,`runner.run()` 不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和当前智能体配置与传输层保持同步。 -默认情况下,`RealtimeRunner`使用`OpenAIRealtimeWebSocketModel`,因此默认的Python路径是与Realtime API建立服务器端WebSocket连接。如果传入其他`RealtimeModel`,仍可使用相同的会话生命周期和智能体功能,但连接机制可以改变。 +默认情况下,`RealtimeRunner` 使用 `OpenAIRealtimeWebSocketModel`,因此默认 Python 路径是与 Realtime API 建立服务器端 WebSocket 连接。如果传入不同的 `RealtimeModel`,仍可使用相同的会话生命周期和智能体功能,但连接机制可以改变。 + +当 Realtime API 服务器正常关闭默认 WebSocket 连接时,模型传输层会发出 `disconnected` [`RealtimeModelConnectionStatusEvent`][agents.realtime.model_events.RealtimeModelConnectionStatusEvent],随后发出 [`RealtimeModelEndOfStreamEvent`][agents.realtime.model_events.RealtimeModelEndOfStreamEvent]。`RealtimeSession` 会在 `raw_model_event` 中转发这两个事件,处理完已进入队列的事件,然后结束异步迭代且不引发异常。由调用方发起的 `session.close()` 不会生成这些服务器断开连接事件。意外的 WebSocket 故障仍会进入会话的异常处理路径,而不会像服务器正常关闭一样结束迭代。 ## 智能体与会话配置 -`RealtimeAgent`的适用范围有意设计得比常规`Agent`类型更窄: +`RealtimeAgent` 的功能范围有意设计得比常规 `Agent` 类型更窄: - 模型选择在会话级别配置,而不是按智能体配置。 -- 不支持structured outputs。 +- 不支持 structured outputs。 - 可以配置语音,但会话生成语音音频后便无法更改。 - 指令、函数工具、任务转移、钩子和输出安全防护措施仍然全部可用。 -`RealtimeSessionModelSettings`既支持较新的嵌套`audio`配置,也支持旧版扁平别名。对于新代码,建议使用嵌套结构;对于新的实时智能体,请从`gpt-realtime-2.1`开始: +`RealtimeSessionModelSettings` 同时支持较新的嵌套 `audio` 配置和旧版扁平别名。新代码应优先使用嵌套结构,并对新的 Realtime 智能体使用 `gpt-realtime-2.1` 作为起点: ```python runner = RealtimeRunner( @@ -67,7 +69,7 @@ runner = RealtimeRunner( ) ``` -实用的会话级设置包括: +常用的会话级设置包括: - `audio.input.format`、`audio.output.format` - `audio.input.transcription` @@ -79,7 +81,7 @@ runner = RealtimeRunner( - `prompt` - `tracing` -`RealtimeRunner(config=...)`上的实用运行级设置包括: +`RealtimeRunner(config=...)` 上常用的运行级设置包括: - `async_tool_calls` - `output_guardrails` @@ -87,11 +89,11 @@ runner = RealtimeRunner( - `tool_error_formatter` - `tracing_disabled` -有关完整的类型化接口,请参阅[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]和[`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 +有关完整的类型化接口,请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 ### 输入转录设置 -在`audio.input.transcription`下配置输入转录。使用`gpt-live-transcribe`可获得低延迟增量转录;通过WebSocket使用`gpt-transcribe`,则可在提交一个音频轮次后开始转录,或在应用需要输出检测到的语言时进行转录。Agents SDK会在嵌套会话配置中转发特定于模型的GA转录设置: +在 `audio.input.transcription` 下配置输入转录。使用 `gpt-live-transcribe` 可获得低延迟的增量转录;如果应在提交一个音频轮次后开始转录,或应用需要输出检测到的语言,请通过 WebSocket 使用 `gpt-transcribe`。Agents SDK会在嵌套会话配置中转发特定于模型的 GA 转录设置: ```python runner = RealtimeRunner( @@ -114,9 +116,9 @@ runner = RealtimeRunner( ) ``` -对于`gpt-live-transcribe`,`prompt`提供自由形式的录音上下文,`keywords`列出音频中可能出现的字面术语,`languages`列出预期的输入语言。此模型使用复数形式的`languages`,而不是单数形式的`language`;请勿同时发送这两个字段。 +对于 `gpt-live-transcribe`,`prompt` 提供自由格式的录音上下文,`keywords` 列出音频中可能出现的确切词语,`languages` 列出预期的输入语言。此模型使用复数形式 `languages`,而不是单数形式 `language`;请勿同时发送这两个字段。 -此SDK固定使用的OpenAI客户端版本仅支持将`delay`与`gpt-realtime-whisper`配合使用。请按以下方式配置该模型的延迟与准确度权衡: +此 SDK 固定使用的 OpenAI客户端版本仅支持将 `delay` 与 `gpt-realtime-whisper` 搭配使用。按如下方式配置该模型在延迟与准确率之间的权衡: ```python runner = RealtimeRunner( @@ -137,17 +139,17 @@ runner = RealtimeRunner( ) ``` -`delay`设置接受`minimal`、`low`、`medium`、`high`或`xhigh`。较低的值可以更早生成部分文本,而较高的值可为转录模型提供更多音频上下文,并可能提高识别准确度。请使用有代表性的音频进行基准测试,不要假定任何级别具有固定的时间表现。 +`delay` 设置接受 `minimal`、`low`、`medium`、`high` 或 `xhigh`。较低的值可以更早生成部分文本,而较高的值会为转录模型提供更多音频上下文,并可能提高识别准确率。应使用具有代表性的音频进行基准测试,而不要假定任何级别都有固定的处理时长。 -仅当应在提交音频轮次后开始转录,或应用需要输出检测到的语言时,才应在通过WebSocket建立的实时会话中使用`gpt-transcribe`。该模型会自动将之前已转录的轮次用作上下文。`gpt-transcribe`完成事件会在其`languages`输出字段中报告检测到的语言。此输出字段不同于上文所示的`gpt-live-transcribe`预期语言输入。 +仅当转录应在提交音频轮次后开始,或应用需要输出检测到的语言时,才应在通过 WebSocket 建立的 Realtime 会话中使用 `gpt-transcribe`。该模型会自动将之前已转录的轮次用作上下文。`gpt-transcribe` 完成事件会在其 `languages` 输出字段中报告检测到的语言。此输出字段不同于上文所示的 `gpt-live-transcribe` 预期语言输入。 -将`audio.input.turn_detection`设为`None`会禁用自动轮次检测。随后,应用必须按照[手动响应控制](#manual-response-control)中的说明提交音频轮次并控制响应创建。有关模型行为、验证规则和延迟指导,请参阅OpenAI API的[实时转录指南](https://developers.openai.com/api/docs/guides/realtime-transcription)。 +将 `audio.input.turn_detection` 设置为 `None` 会禁用自动轮次检测。之后,应用必须按照[手动响应控制](#manual-response-control)中的说明提交音频轮次并控制响应创建。有关模型行为、验证规则和延迟指导,请参阅 OpenAI API 的 [Realtime 转录指南](https://developers.openai.com/api/docs/guides/realtime-transcription)。 ## 输入与输出 ### 文本与结构化用户消息 -使用[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]发送纯文本或结构化实时消息。 +使用 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] 发送纯文本或结构化 Realtime 消息。 ```python from agents.realtime import RealtimeUserInputMessage @@ -165,31 +167,31 @@ message: RealtimeUserInputMessage = { await session.send_message(message) ``` -结构化消息是在实时对话中包含图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)中的示例Web演示会以这种方式转发`input_image`消息。 +在 Realtime 对话中,结构化消息是加入图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) 中的 Web 演示代码示例会以这种方式转发 `input_image` 消息。 ### 音频输入 -使用[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]流式传输原始音频字节: +使用 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] 以流式方式发送原始音频字节: ```python await session.send_audio(audio_bytes) ``` -如果禁用了服务器端轮次检测,则需要自行标记轮次边界。高级便捷方式如下: +如果禁用了服务器端轮次检测,则需要自行标记轮次边界。高层便捷方式如下: ```python await session.send_audio(audio_bytes, commit=True) ``` -如果需要更低层级的控制,也可以直接通过底层模型传输层发送Realtime API客户端事件,例如`input_audio_buffer.commit`。 +如果需要更底层的控制,也可以通过底层模型传输对象直接发送 Realtime API 客户端事件,例如 `input_audio_buffer.commit`。 ### 手动响应控制 -`session.send_message()`使用高级路径发送用户输入,并为你启动响应。在某些配置中,原始音频缓冲**不会**自动执行相同操作。 +`session.send_message()` 会通过高层路径发送用户输入,并自动开始响应。在某些配置中,原始音频缓冲**不会**自动执行相同操作。 -在Realtime API层面,手动轮次控制意味着发送一个将`turn_detection`设为`null`的`session.update`事件,然后自行发送`input_audio_buffer.commit`和`response.create`。 +在 Realtime API 层面,手动轮次控制是指发送一个 `session.update` 事件,将 `turn_detection` 设置为 `null`,然后自行发送 `input_audio_buffer.commit` 和 `response.create`。 -如果正在手动管理轮次,可以通过模型传输层发送原始客户端事件: +如果要手动管理轮次,可以通过模型传输对象发送原始客户端事件: ```python from agents.realtime.model_inputs import RealtimeModelSendRawMessage @@ -205,15 +207,15 @@ await session.model.send_event( 此模式适用于以下情况: -- 已禁用`turn_detection`,并且希望自行决定模型何时响应 -- 希望在触发响应之前检查用户输入或设置门控 +- 禁用了 `turn_detection`,并且希望自行决定模型何时响应 +- 希望在触发响应前检查或拦截用户输入 - 需要为带外响应使用自定义提示词 -[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)中的SIP代码示例使用原始`response.create`强制生成开场问候语。 +[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) 中的 SIP 代码示例使用原始 `response.create` 强制生成开场问候语。 ## 事件、历史记录与中断 -`RealtimeSession`会发出更高级别的SDK事件,同时在需要时仍会转发原始模型事件。 +`RealtimeSession` 会发出更高层的 SDK 事件,同时仍会在需要时转发原始模型事件。 重要的会话事件包括: @@ -227,13 +229,13 @@ await session.model.send_event( - `error` - `raw_model_event` -对于UI状态,最实用的事件通常是`history_added`和`history_updated`。它们会以`RealtimeItem`对象的形式公开会话的本地历史记录,其中包括用户消息、助手消息和工具调用。 +对于 UI 状态而言,通常最有用的事件是 `history_added` 和 `history_updated`。它们将会话的本地历史记录公开为 `RealtimeItem` 对象,包括用户消息、助手消息和工具调用。 ### 用量统计 -当已完成的模型响应包含用量信息时,SDK的OpenAI `RealtimeModel`传输层会在`raw_model_event`中发出一个[`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其`usage`字段包含该响应的token计数,而`input_tokens_details`和`output_tokens_details`提供可选的模态明细。 +当已完成的模型响应包含用量信息时,SDK 的 OpenAI `RealtimeModel` 传输层会在 `raw_model_event` 中发出 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其 `usage` 字段包含该响应的 token 数量,而 `input_tokens_details` 和 `output_tokens_details` 则提供可选的模态明细。 -会话还会将每个响应的用量添加到共享的[`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]中。在后续高级事件(例如`agent_end`)中从`event.info.context.usage`读取它,即可检查实时会话的累计用量。 +会话还会将每个响应的用量添加到共享的 [`RunContextWrapper.usage`][agents.run_context.RunContextWrapper.usage]。可从后续高层事件(例如 `agent_end`)的 `event.info.context.usage` 中读取,以查看实时会话的累计用量。 ```python from agents.realtime import RealtimeModelUsageEvent @@ -251,21 +253,21 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -只有当模型提供商在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该`RealtimeSession`收到的响应;它不是跨会话总计。 +仅当模型提供方在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该 `RealtimeSession` 收到的响应;它不是跨会话总计。 -### 中断与播放追踪 +### 中断与播放进度追踪 -当用户打断助手时,会话会发出`audio_interrupted`并更新历史记录,使服务器端对话与用户实际听到的内容保持一致。 +当用户打断助手时,会话会发出 `audio_interrupted` 并更新历史记录,使服务器端对话与用户实际听到的内容保持一致。 -对于低延迟本地播放,默认的播放追踪器通常已经足够。在远程或延迟播放场景中,尤其是电话场景,请使用[`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],使被中断的响应在实际播放位置截断,而不是假定所有已生成的音频都已被用户听到。 +对于低延迟本地播放,默认播放追踪器通常已足够。在远程或延迟播放场景中,尤其是电话场景,请使用 [`RealtimePlaybackTracker`][agents.realtime.model.RealtimePlaybackTracker],以便在实际播放位置截断被中断的响应,而不是假定所有已生成的音频都已播放给用户。 -[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)中的Twilio代码示例展示了此模式。 +[`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) 中的 Twilio 代码示例展示了此模式。 -## 工具、审批、任务转移与安全防护措施 +## 工具、批准、任务转移与安全防护措施 ### 函数工具 -实时智能体支持在实时对话期间使用函数工具: +Realtime 智能体支持在实时对话期间使用函数工具: ```python from agents.decorators import tool @@ -284,11 +286,11 @@ agent = RealtimeAgent( ) ``` -### 工具审批 +### 工具批准 -函数工具可以要求在执行前进行人工审批。发生这种情况时,会话会发出`tool_approval_required`并暂停工具运行,直到调用`approve_tool_call()`或`reject_tool_call()`。 +函数工具可以要求在执行前获得人工批准。发生这种情况时,会话会发出 `tool_approval_required` 并暂停工具运行,直到调用 `approve_tool_call()` 或 `reject_tool_call()`。 -如果工具还具有输入安全防护措施,则这些安全防护措施会在审批后、执行前立即运行。若要在发出审批事件之前运行它们,请使用`RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})`创建运行器。通过此审批前检查的调用仍会在审批后、执行前再次接受检查。 +如果该工具还有输入安全防护措施,这些安全防护措施会在批准后的执行前立即运行。若要在发出批准事件之前运行它们,请使用 `RealtimeRunner(..., config={"tool_execution": {"pre_approval_tool_input_guardrails": True}})` 创建运行器。通过此批准前检查的调用在获批后、执行前仍会再次接受检查。 ```python async for event in session: @@ -296,11 +298,11 @@ async for event in session: await session.approve_tool_call(event.call_id) ``` -有关具体的服务器端审批循环,请参阅[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。[人工介入](../human_in_the_loop.md)文档也会引导你返回此流程。 +有关具体的服务器端批准循环,请参阅 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。人工参与流程文档中的[人工参与流程](../human_in_the_loop.md)也会指向此流程。 ### 任务转移 -实时任务转移允许一个智能体将实时对话转交给另一个专家: +Realtime 任务转移允许一个智能体将实时对话转交给另一个专用智能体: ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -322,11 +324,11 @@ main_agent = RealtimeAgent( ) ``` -直接用作任务转移的`RealtimeAgent`对象会被自动包装,而`realtime_handoff(...)`可用于自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的`input_filter`。 +直接用作任务转移的 `RealtimeAgent` 对象会被自动包装,而 `realtime_handoff(...)` 可用于自定义名称、描述、验证、回调和可用性。Realtime 任务转移**不**支持常规任务转移的 `input_filter`。 ### 安全防护措施 -实时智能体支持针对智能体响应的输出安全防护措施,以及针对函数工具调用的输入安全防护措施。输出安全防护措施检查会进行防抖:每次检查都基于累积的输出文本和音频转录增量运行,而不是针对每个部分增量运行,并且会发出`guardrail_tripped`而不是引发异常。 +Realtime 智能体支持针对智能体响应的输出安全防护措施,以及针对函数工具调用的输入安全防护措施。输出安全防护措施检查会进行防抖处理:每次检查都针对累积的输出文本和音频转录增量运行,而不是针对每个部分增量运行,并会发出 `guardrail_tripped`,而不是引发异常。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -346,15 +348,15 @@ agent = RealtimeAgent( ) ``` -当实时输出安全防护措施因音频转录而触发时,会话会中断活动响应、强制执行`response.cancel`、发出`guardrail_tripped`,并发送一条指出已触发安全防护措施的后续用户消息,使模型能够生成替代响应。音频播放器仍应监听`audio_interrupted`并立即停止本地播放,因为触发器触发时可能已有部分音频进入缓冲区。使用内置OpenAI Realtime传输层时,如果安全防护措施检查在其检查的响应结束后才完成,会话只会中断该响应的缓冲播放,而不会取消稍后启动的任何响应。对于纯文本输出,会话则会发送一个限定于响应的`response.cancel`;由于没有需要停止的音频播放,因此不会发出`audio_interrupted`。使用内置OpenAI Realtime模型时,纯文本路径也会发出相同的`guardrail_tripped`事件和后续用户消息。 +当 Realtime 输出安全防护措施因音频转录而触发时,会话会中断当前响应,强制执行 `response.cancel`,发出 `guardrail_tripped`,并发送一条指出已触发安全防护措施名称的后续用户消息,以便模型生成替代响应。音频播放器仍应监听 `audio_interrupted` 并立即停止本地播放,因为触发安全防护措施时,部分音频可能已进入缓冲区。使用内置 OpenAI Realtime 传输方式时,如果安全防护措施检查在被检查的响应结束后才完成,会话只会中断该响应的缓冲播放,而不会取消之后开始的任何响应。对于纯文本输出,会话会改为发送一个限定于该响应的 `response.cancel`;由于没有需要停止的音频播放,因此不会发出 `audio_interrupted`。使用内置 OpenAI Realtime 模型时,纯文本路径也会发出相同的 `guardrail_tripped` 事件和后续用户消息。 -自定义`RealtimeModel`传输层必须遵循`RealtimeModelSendInterrupt.response_id`和`playback_only`,以提供相同的源范围音频中断行为。它们还必须重写`RealtimeModel.send_event_if()`,以支持纯文本输出路径的恢复消息。实现必须在传输层实际提交事件的边界重新检查所提供的条件,或者将条件检查与事件提交串行化。默认实现会安全地跳过恢复消息,因为如果它只检查一次条件,然后单独发送事件,则在检查与事件提交之间可能会启动另一个响应;响应取消和`guardrail_tripped`事件仍会发生。 +自定义 `RealtimeModel` 传输方式必须遵循 `RealtimeModelSendInterrupt.response_id` 和 `playback_only`,才能提供同样限定于源响应的音频中断行为。它们还必须覆盖 `RealtimeModel.send_event_if()`,以支持纯文本输出路径的恢复消息。实现必须在传输层实际提交事件的边界重新检查所提供的条件,或者将条件检查与事件提交串行化。默认实现会安全地跳过恢复消息,因为如果只检查一次条件,然后单独发送事件,在检查与事件提交之间可能会启动另一个响应;响应取消和 `guardrail_tripped` 事件仍会发生。 -## SIP与电话通信 +## SIP 与电话 -Python SDK通过[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]提供一流的SIP附加流程。 +Python SDK 通过 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] 提供原生支持的 SIP 挂接流程。 -当呼叫通过Realtime Calls API到达,并且希望将智能体会话附加到生成的`call_id`时,请使用该流程: +当通话通过 Realtime Calls API 到达,并且希望将智能体会话挂接到生成的 `call_id` 时,请使用此流程: ```python from agents.realtime import RealtimeRunner @@ -371,20 +373,20 @@ async with await runner.run( ... ``` -如果需要先接受呼叫,并希望接受载荷与从智能体派生的会话配置匹配,请使用`OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程请参阅[`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 +如果需要先接听通话,并希望接听请求体与从智能体生成的会话配置保持一致,请使用 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程请参阅 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 ## 底层访问与自定义端点 -可以通过`session.model`访问底层传输对象。 +可以通过 `session.model` 访问底层传输对象。 -以下情况可使用此功能: +在以下情况下可使用此对象: -- 通过`session.model.add_listener(...)`添加自定义监听器 -- 发送原始客户端事件,例如`response.create`或`session.update` -- 通过`model_config`自定义处理`url`、`headers`或`api_key` -- 使用`call_id`附加到现有实时呼叫 +- 通过 `session.model.add_listener(...)` 添加自定义监听器 +- 发送原始客户端事件,例如 `response.create` 或 `session.update` +- 通过 `model_config` 自定义处理 `url`、`headers` 或 `api_key` +- 使用 `call_id` 挂接到现有 Realtime 通话 -`RealtimeModelConfig`支持: +`RealtimeModelConfig` 支持: - `api_key` - `url` @@ -393,9 +395,9 @@ async with await runner.run( - `playback_tracker` - `call_id` -此仓库随附的`call_id`代码示例使用SIP。更广泛的Realtime API也会在某些服务器端控制流程中使用`call_id`,但此处未将这些流程打包为Python代码示例。 +此代码仓库随附的 `call_id` 代码示例使用 SIP。更广泛的 Realtime API 也会在某些服务器端控制流程中使用 `call_id`,但此处未将其打包为 Python 代码示例。 -连接到Azure OpenAI时,请传入GA Realtime端点URL和显式请求头。例如: +连接 Azure OpenAI 时,请传入 GA Realtime 端点 URL 和显式请求头。例如: ```python session = await runner.run( @@ -406,7 +408,7 @@ session = await runner.run( ) ``` -对于基于token的身份验证,请在`headers`中使用Bearer token: +若使用基于 token 的身份验证,请在 `headers` 中使用 bearer token: ```python session = await runner.run( @@ -417,12 +419,12 @@ session = await runner.run( ) ``` -如果传入`headers`,SDK不会自动添加`Authorization`。请避免将旧版Beta路径(`/openai/realtime?api-version=...`)用于实时智能体。 +如果传入 `headers`,SDK 不会自动添加 `Authorization`。请勿对 Realtime 智能体使用旧版 beta 路径(`/openai/realtime?api-version=...`)。 ## 延伸阅读 -- [实时传输](transport.md) +- [Realtime 传输方式](transport.md) - [快速入门](quickstart.md) -- [OpenAI Realtime对话](https://developers.openai.com/api/docs/guides/realtime-conversations/) -- [OpenAI Realtime服务器端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/) +- [OpenAI Realtime 对话](https://developers.openai.com/api/docs/guides/realtime-conversations/) +- [OpenAI Realtime 服务器端控制](https://developers.openai.com/api/docs/guides/realtime-server-controls/) - [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) \ No newline at end of file diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index 54538bb416..c1b88c69ee 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -2,13 +2,13 @@ search: exclude: true --- -# 智能体运行 +# 运行智能体 你可以通过 [`Runner`][agents.run.Runner] 类运行智能体。你有 3 种选择: 1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回 [`RunResult`][agents.result.RunResult]。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,底层仅运行 `.run()`。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它会以流式传输模式调用 LLM,并在收到事件时将其传输给你。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其底层仅运行 `.run()`。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 ```python from agents import Agent, Runner @@ -23,7 +23,7 @@ async def main(): # Infinite loop's dance ``` -有关更多信息,请参阅[结果指南](results.md)。 +更多信息请参阅[结果指南](results.md)。 ## Runner 生命周期与配置 @@ -31,38 +31,38 @@ async def main(): 调用上述三个 `Runner` 方法中的任意一个时,你需要传入一个起始智能体和输入。输入可以是: -- 字符串(视为用户消息); -- OpenAI Responses API 格式的输入项列表;或 -- 在恢复暂停的运行或因 `cancel(mode="after_turn")` 而停止的运行时,使用 [`RunState`][agents.run_state.RunState]。该状态还可以携带[为下一次恢复后的模型调用暂存的输入](results.md#add-input-before-resuming)。 +- 字符串(视为用户消息), +- OpenAI Responses API 格式的输入项列表,或 +- 从暂停的运行或因 `cancel(mode="after_turn")` 而停止的运行恢复时使用的 [`RunState`][agents.run_state.RunState]。该状态还可以携带[为下一次恢复后的模型调用暂存的输入](results.md#add-input-before-resuming)。 -随后,Runner 会运行一个循环: +随后,Runner 会执行循环: -1. 使用当前输入调用当前智能体的 LLM。 +1. 使用当前输入,为当前智能体调用 LLM。 2. LLM 生成输出。 1. 如果 Runner 将 LLM 的输出归类为最终输出,则循环结束并返回结果。 - 2. 如果 LLM 请求任务转移,则更新当前智能体和输入,并重新运行循环。 - 3. 如果 LLM 生成工具调用,则运行这些工具调用,追加结果,并重新运行循环。 -3. 如果超过所传入的 `max_turns`,则会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 + 2. 如果 LLM 请求任务转移,我们会更新当前智能体和输入,然后重新运行循环。 + 3. 如果 LLM 生成工具调用,我们会运行这些工具调用、追加结果,然后重新运行循环。 +3. 如果超过传入的 `max_turns`,则会抛出 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 !!! note - 判断 LLM 输出是否属于“最终输出”的规则是:它生成了所需类型的文本输出,并且不存在工具调用。 + 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了所需类型的文本输出,且不存在工具调用。 ### 流式传输 -流式传输还允许你在 LLM 运行时接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含该次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。有关更多信息,请参阅[流式传输指南](streaming.md)。 +流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含本次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。更多信息请参阅[流式传输指南](streaming.md)。 #### Responses WebSocket 传输(可选辅助工具) 如果启用 OpenAI Responses WebSocket 传输,你仍可继续使用常规的 `Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 -这是基于 WebSocket 传输的 Responses API,而不是 [Realtime API](realtime/guide.md)。 +这是通过 WebSocket 传输使用的 Responses API,并非 [Realtime API](realtime/guide.md)。 -有关传输选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 +有关传输方式的选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 -##### 模式 1:不使用会话辅助工具(可用) +##### 模式 1:不使用会话辅助工具(可行) -如果你只需要 WebSocket 传输,而不需要 SDK 为你管理共享提供商或会话,请使用此模式。 +如果只想使用 WebSocket 传输,并且不需要 SDK 为你管理共享提供商或会话,请使用此模式。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -此模式适合单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,每次运行都可能重新连接,除非你手动复用同一个 `RunConfig` / 提供商实例。 +此模式适用于单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,每次运行都可能重新连接,除非你手动复用同一个 `RunConfig` / 提供商实例。 -##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) +##### 模式 2:使用 `responses_websocket_session()`(推荐用于多轮复用) -如果你希望在多次运行中共享支持 WebSocket 的提供商和 `RunConfig`,请使用 [`responses_websocket_session()`][agents.responses_websocket_session](包括继承同一个 `run_config` 的嵌套“智能体即工具”调用)。 +如果想在多次运行之间共享支持 WebSocket 的提供商和 `RunConfig`,请使用 [`responses_websocket_session()`][agents.responses_websocket_session],这也包括继承同一个 `run_config` 的嵌套“智能体即工具”调用。 ```python import asyncio @@ -121,57 +121,57 @@ asyncio.run(main()) 请在上下文退出前完成对流式结果的消费。如果在 WebSocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 -服务会在每个 WebSocket 连接上逐个处理响应,并将单个连接限制为 60 分钟。该辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文开始新的链,或根据本地管理的会话状态重新构建该链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 +服务会在每个 WebSocket 连接上一次处理一个响应,并将每个连接的时长限制为 60 分钟。该辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文启动新的调用链,或从本地管理的会话状态中重建该调用链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 -如果长时间推理轮次触发 WebSocket 保活超时,请增大 `ping_timeout`,或将 `ping_timeout=None` 设置为禁用心跳超时。对于可靠性比 WebSocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 +如果长时间推理轮次触发 WebSocket 保活超时,请增大 `ping_timeout`,或将 `ping_timeout=None` 设置为禁用心跳超时。如果运行中可靠性比 WebSocket 延迟更重要,请使用 HTTP/SSE 传输。 ### 运行配置 -通过 `run_config` 参数,你可以配置智能体运行的一些全局设置: +通过 `run_config` 参数,可以为智能体运行配置一些全局设置: #### 常见运行配置类别 使用 `RunConfig` 可覆盖单次运行的行为,而无需更改每个智能体的定义。 -##### 模型、提供商与会话默认值 +##### 模型、提供商和会话默认值 -- [`model`][agents.run.RunConfig.model]:用于设置全局使用的 LLM 模型,而不受每个智能体所设 `model` 的影响。 +- [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个智能体使用的 `model`。 - [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 -- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定设置。例如,你可以设置全局 `temperature` 或 `top_p`。 -- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认值(例如 `SessionSettings(limit=...)`)。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用会话时,自定义每次 `Runner` 运行前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 +- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体专属设置。例如,可以设置全局 `temperature` 或 `top_p`。 +- [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认设置(例如 `SessionSettings(limit=...)`)。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用会话时,自定义在每次 `Runner` 运行前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 ##### 安全防护措施、任务转移与模型输入调整 -- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要纳入所有运行的输入或输出安全防护措施列表。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未设置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许你编辑发送给新智能体的输入。有关更多详细信息,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 的文档。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项选择启用的测试版功能,在调用下一个智能体之前,将可汇总的历史记录压缩为有序的助手摘要片段,同时在原始位置保留无损消息项。在我们稳定嵌套任务转移功能期间,此功能默认禁用;将其设置为 `True` 可启用,或保留为 `False` 以直接传递原始记录。当 SDK 默认的嵌套历史记录中已包含某条消息时,会话、`RunState` 和 `RunResult.to_input_list()` 会避免重复追加该消息的同一次出现,同时仍保留彼此独立但内容相同的消息。如果你未传入 `RunConfig`,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,而任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。单个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:选择启用 `nest_handoff_history` 时,用于接收规范化记录(历史记录和任务转移项)的可选可调用对象。它必须返回要转发给下一个智能体的确切输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前立即编辑完整准备好的模型输入(instructions 和输入项)的钩子,例如修剪历史记录或注入系统提示词。 +- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要包含在所有运行中的输入或输出安全防护措施列表。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:适用于所有任务转移的全局输入过滤器,前提是相应任务转移尚未配置过滤器。输入过滤器允许你编辑发送给新智能体的输入。更多详情请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 中的文档。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选启用的 Beta 功能。在调用下一个智能体之前,它会将可总结的历史记录压缩为有序的助手摘要片段,同时将无损消息项保留在原始位置。由于我们仍在完善嵌套任务转移功能,该功能默认禁用;将其设置为 `True` 可启用,保留为 `False` 则会直接传递原始对话记录。当 SDK 默认的嵌套历史记录已包含某条消息时,会话、`RunState` 和 `RunResult.to_input_list()` 可避免重复追加完全相同的一次消息,同时仍保留彼此独立但内容相同的消息。如果你未传入 `RunConfig`,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,并且任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。各个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:可选的可调用对象。在你选择启用 `nest_handoff_history` 后,每次都会接收规范化的对话记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的确切输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:用于在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如裁剪历史记录或注入系统提示词。 - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 Runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 ##### 追踪与可观测性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许你为整个运行禁用[追踪](tracing.md)。 - [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],以覆盖追踪导出设置,例如每次运行的追踪 API 密钥。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含可能的敏感数据,例如 LLM 和工具调用的输入/输出。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是可选字段,可用于关联多次运行之间的追踪。 -- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要纳入所有追踪的元数据。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在敏感数据,例如 LLM 和工具调用的输入/输出。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置本次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行中的追踪。 +- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 ##### 工具执行、审批与工具错误行为 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 侧的执行行为,例如限制同时运行的本地函数工具调用数量。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置 Runner 如何处理模型发出的函数工具调用,其工具名称与当前智能体可用的任何函数工具均不匹配的情况。默认行为会引发 `ModelBehaviorError`;你可以选择改为返回模型可见的错误输出。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置 Runner 如何处理未命名空间化且发生冲突的函数工具名称和任务转移名称。默认值 `"warn"` 会记录一条可操作的警告,并仅公开当前的分派胜出项;`"error"` 会在调用模型前引发 `UserError`。针对已命名空间化和延迟加载工具的严格验证保持不变。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒和选择启用的“找不到工具”输出。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 侧的执行行为,例如限制可同时运行的本地函数工具调用数量。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置 Runner 如何处理模型发出的函数工具调用,而该调用的工具名称与当前智能体可用的任何函数工具均不匹配。默认行为是抛出 `ModelBehaviorError`;你也可以选择改为返回模型可见的错误输出。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置 Runner 如何处理发生冲突的无命名空间函数工具名称和任务转移名称。默认值 `"warn"` 会记录一条可指导采取行动的警告,并且只公开当前最终用于分派的对象;`"error"` 会在调用模型前抛出 `UserError`。对带命名空间和延迟加载工具的严格验证保持不变。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝和选择启用的“找不到工具”输出。 -嵌套任务转移是一项选择启用的测试版功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序记录压缩,或设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会在无损消息项前后放置生成的助手摘要片段,而不是将整个记录折叠成一条消息。如果你希望保留原始记录(默认行为),请不要设置该标志,或提供一个 `handoff_input_filter`(或 `handoff_history_mapper`),按你所需的确切方式转发对话。如果只想更改生成的摘要片段中使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 可恢复默认值)。 +嵌套任务转移是一项可选启用的 Beta 功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序对话记录压缩,或设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段放置在无损消息项周围,而不是将整个对话记录折叠为一条消息。如果你希望保留原始对话记录(默认行为),请不要设置该标志,或提供一个 `handoff_input_filter`(或 `handoff_history_mapper`),以便完全按照你的需要转发对话。如果想更改生成的摘要片段中使用的包装文本而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 恢复默认值)。 #### 运行配置详情 ##### `tool_execution` -如果要配置本地函数工具在 SDK 侧的行为,例如限制一次运行中的本地函数工具并发数,请使用 `tool_execution`。 +如果想配置本地函数工具在 SDK 侧的行为,例如限制一次运行中本地函数工具的并发数,请使用 `tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` 会保留默认行为:当模型在一个轮次中发出多个函数工具调用时,SDK 会启动所有已发出的本地函数工具调用。设置整数值可限制同时运行的本地函数工具调用数量。 +`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中发出多个函数工具调用时,SDK 会启动发出的所有本地函数工具调用。将其设置为整数值,可限制同时运行的本地函数工具调用数量。 -这与提供商侧的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地函数工具调用后,SDK 如何执行这些调用。 +这与提供商侧的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 相互独立。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地函数工具调用后,SDK 如何执行这些调用。 -`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果某个函数工具需要审批,运行会先暂停,工具输入安全防护措施仅在审批通过后、执行前立即运行。如果希望在发出待审批中断之前运行函数工具输入安全防护措施,请将其设置为 `True`。通过此审批前检查的调用在审批后仍会再次运行相同的输入安全防护措施,以便在执行前重新验证时效性检查。 +`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果函数工具需要审批,运行会先暂停,而工具输入安全防护措施仅在审批通过后、执行前立即运行。如果想在发出待审批的中断前运行函数工具输入安全防护措施,请将其设置为 `True`。通过此审批前检查的调用仍会在审批通过后再次运行相同的输入安全防护措施,因此会在执行前重新验证时效性要求较高的检查。 ##### `tool_not_found_behavior` -默认情况下,如果模型发出的函数工具调用与当前智能体可用的任何函数工具都不匹配,Runner 会引发 `ModelBehaviorError`。 +默认情况下,如果模型发出的函数工具调用与当前智能体可用的任何函数工具均不匹配,Runner 会抛出 `ModelBehaviorError`。 -如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在该模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,使模型可以选择可用工具,或不使用该工具直接作答。 +如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在该模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,使模型可以选择可用工具,或在不使用该工具的情况下作答。 ```python from agents import Agent, RunConfig, Runner @@ -214,13 +214,13 @@ result = await Runner.run( ) ``` -目前,此选项仅适用于工具名称查找失败的函数工具调用。其他无效工具载荷仍会使用其现有错误处理行为。 +此选项目前仅适用于因工具名称查找失败而无法执行的函数工具调用。其他无效工具载荷仍沿用现有的错误处理行为。 ##### `tool_error_formatter` -当 SDK 创建模型可见的工具错误输出时,可使用 `tool_error_formatter` 自定义返回给模型的消息。 +使用 `tool_error_formatter` 可自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 -格式化程序会接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: +格式化器会接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: - `kind`:错误类别,例如 `"approval_rejected"` 或 `"tool_not_found"`。 - `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"` 或 `"custom"`)。 @@ -229,7 +229,7 @@ result = await Runner.run( - `default_message`:SDK 默认的模型可见消息。 - `run_context`:当前运行上下文包装器。 -返回字符串以替换该消息,或返回 `None` 以使用 SDK 默认值。 +返回字符串可替换该消息,返回 `None` 则使用 SDK 默认值。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,56 +256,56 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -当 Runner 将历史记录向后传递时(例如使用 `RunResult.to_input_list()` 或由会话支持的运行),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 +当 Runner 继续传递历史记录时(例如使用 `RunResult.to_input_list()` 或由会话支持的运行),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 -- `None` 或 `"preserve"`(默认值):保留推理项 ID。 +- `None` 或 `"preserve"`(默认):保留推理项 ID。 - `"omit"`:从生成的下一轮输入中移除推理项 ID。 -`"omit"` 主要用于选择性缓解一类 Responses API 400 错误:推理项带有 `id`,但缺少其后所需的项(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` 主要用于选择启用一种缓解措施,以应对某类 Responses API 400 错误:发送的推理项包含 `id`,但缺少其后所需的项目(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -在多轮智能体运行中,如果 SDK 根据先前输出构建后续输入(包括会话持久化、服务器管理的对话增量、流式/非流式后续轮次和恢复路径),并且保留了推理项 ID,但提供商要求该 ID 必须与其对应的后续项配对,就可能发生这种情况。 +在多轮智能体运行中,SDK 根据先前的输出构建后续输入时可能出现这种情况,其中包括会话持久化、服务器管理的对话增量、流式/非流式后续轮次以及恢复路径。如果保留了推理项 ID,而提供商要求该 ID 必须与其对应的后续项配对,就会发生此错误。 -设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量约束。 +设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量。 -范围说明: +适用范围说明: -- 这只会更改 SDK 构建后续输入时生成或转发的推理项。 -- 它不会改写用户提供的初始输入项。 +- 此设置只会更改 SDK 构建后续输入时生成或转发的推理项。 +- 它不会重写用户提供的初始输入项。 - 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 ## 状态与对话管理 -### 记忆策略选择 +### 记忆策略的选择 -将状态带入下一轮通常有四种方式: +有四种常见方式可将状态带入下一轮: -| 策略 | 状态所在位置 | 最适合 | 下一轮传入的内容 | +| 策略 | 状态存储位置 | 最适用场景 | 下一轮传入的内容 | | --- | --- | --- | --- | -| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()` 中的列表加上下一条用户消息 | -| `session` | 你的存储和 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | -| `conversation_id` | OpenAI Conversations API | 希望在工作进程或服务之间共享的命名服务器端对话 | 同一个 `conversation_id`,外加仅包含新用户轮次的内容 | -| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务器管理延续 | `result.last_response_id`,外加仅包含新用户轮次的内容 | +| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任意提供商 | `result.to_input_list()` 返回的列表,加上下一条用户消息 | +| `session` | 你的存储加 SDK | 持久化聊天状态、可恢复的运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | +| `conversation_id` | OpenAI Conversations API | 希望在多个工作进程或服务之间共享的具名服务器端对话 | 同一个 `conversation_id`,加上且仅加上新的用户轮次 | +| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务器管理延续机制 | `result.last_response_id`,加上且仅加上新的用户轮次 | -`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅在使用 OpenAI Responses API 时适用。在大多数应用中,每个对话应选择一种持久化策略。除非你有意协调这两个层级,否则混用客户端管理的历史记录与 OpenAI 管理的状态可能会导致上下文重复。 +`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅适用于使用 OpenAI Responses API 的情况。对于大多数应用,请为每个对话选择一种持久化策略。混用客户端管理的历史记录和 OpenAI 管理的状态可能导致上下文重复,除非你有意协调这两个层级。 !!! note - 同一次运行中,会话持久化不能与服务器管理的对话设置 + 在同一次运行中,会话持久化不能与服务器管理的对话设置 (`conversation_id`、`previous_response_id` 或 `auto_previous_response_id`) 结合使用。每次调用请选择一种方式。 ### 对话/聊天线程 -调用任何运行方法都可能导致一个或多个智能体运行(因而进行一次或多次 LLM 调用),但在聊天对话中,它表示一个逻辑轮次。例如: +调用任意运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但在聊天对话中,这表示单个逻辑轮次。例如: 1. 用户轮次:用户输入文本 -2. Runner 运行:第一个智能体调用 LLM、运行工具并将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 +2. Runner 运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论采用哪种方式,用户之后都可能提出后续问题,此时你可以再次调用运行方法。 #### 手动对话管理 -你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法获取下一轮输入,从而手动管理对话历史记录: +你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法获取下一轮的输入,从而手动管理对话历史记录: ```python from agents import Agent, Runner, trace @@ -327,9 +327,9 @@ async def main(): # California ``` -#### 使用会话的自动对话管理 +#### 使用会话自动管理对话 -要采用更简单的方法,可以使用[会话](sessions/index.md)自动处理对话历史记录,而无需手动调用 `.to_input_list()`: +如需更简单的方式,可以使用[会话](sessions/index.md)自动处理对话历史记录,而无需手动调用 `.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -357,14 +357,14 @@ async def main(): - 在每次运行前检索对话历史记录 - 在每次运行后存储新消息 -- 为不同的会话 ID 维护独立对话 +- 为不同的会话 ID 维护独立的对话 -有关更多详细信息,请参阅[会话文档](sessions/index.md)。 +更多详情请参阅[会话文档](sessions/index.md)。 #### 服务器管理的对话 -你也可以让 OpenAI 对话状态功能在服务器端管理对话状态,而不是使用 `to_input_list()` 或 `Sessions` 在本地处理。这样,无需每次手动重新发送所有历史消息即可保留对话历史记录。使用下述任一服务器管理方式时,每个请求只需传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 +你也可以使用 OpenAI 对话状态功能在服务器端管理对话状态,而不是通过 `to_input_list()` 或 `Sessions` 在本地进行处理。这样无需手动重新发送所有历史消息即可保留对话历史记录。使用以下任一服务器管理方式时,请在每个请求中仅传入新轮次的输入,并复用已保存的 ID。更多详情请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 OpenAI 提供两种跨轮次追踪状态的方式: @@ -393,7 +393,7 @@ async def main(): ##### 2. 使用 `previous_response_id` -另一个选项是**响应链式关联**,其中每个轮次都会显式关联上一轮的响应 ID。 +另一种方式是**响应链式衔接**,其中每一轮都会显式链接到上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -418,30 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,以便恢复后的轮次继续使用同一服务器管理的对话。 +如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,使恢复后的轮次继续在同一服务器管理的对话中运行。 -`conversation_id` 与 `previous_response_id` 互斥。如果希望使用可跨系统共享的命名对话资源,请使用 `conversation_id`。如果希望使用最轻量的 Responses API 基本组件在轮次之间延续,请使用 `previous_response_id`。 +`conversation_id` 和 `previous_response_id` 互斥。如果需要可跨系统共享的具名对话资源,请使用 `conversation_id`。如果需要在轮次之间使用最轻量的 Responses API 延续基本组件,请使用 `previous_response_id`。 !!! note - SDK 会自动采用退避策略重试 `conversation_locked` 错误。在服务器管理的 - 对话运行中,SDK 会在重试前回退内部对话追踪器的输入,以便完整地重新发送 - 相同的已准备项。 + SDK 会以退避策略自动重试 `conversation_locked` 错误。在服务器管理的 + 对话运行中,它会先回退内部对话追踪器的输入再进行重试,以便干净地重新发送 + 相同的已准备项目。 - 在基于本地会话的运行中(无法与 `conversation_id`、 + 在基于本地会话的运行中(此类运行不能与 `conversation_id`、 `previous_response_id` 或 `auto_previous_response_id` 结合使用),SDK 还会尽力 - 回滚最近持久化的输入项,以减少重试后出现重复历史记录条目的情况。 + 回滚近期持久化的输入项,以减少重试后重复的历史记录条目。 - 即使你未配置 `ModelSettings.retry`,也会执行此兼容性重试。有关针对模型请求 - 更广泛的选择启用式重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 + 即使未配置 `ModelSettings.retry`,也会进行此兼容性重试。有关模型请求中 + 范围更广的可选重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 ## 钩子与自定义 ### 模型调用输入过滤器 -使用 `call_model_input_filter` 可在调用模型前编辑模型输入。该钩子接收当前智能体、上下文和合并后的输入项(包括存在的会话历史记录),并返回新的 `ModelInputData`。 +使用 `call_model_input_filter` 可在调用模型前编辑模型输入。该钩子会接收当前智能体、上下文和合并后的输入项(如有会话历史记录,也会包含在内),并返回新的 `ModelInputData`。 -返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填项,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 +返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填项,并且必须是输入项列表。返回任何其他结构都会抛出 `UserError`。 ```python from agents import Agent, Runner, RunConfig @@ -460,19 +460,19 @@ result = Runner.run_sync( ) ``` -Runner 会将已准备输入列表的副本传给该钩子,因此你可以修剪、替换或重新排序,而无需就地修改调用方的原始列表。 +Runner 会将已准备输入列表的副本传递给钩子,因此你可以对其进行裁剪、替换或重新排序,而不会就地修改调用方的原始列表。 -如果使用会话,`call_model_input_filter` 会在会话历史记录已加载并与当前轮次合并后运行。如果希望自定义此前的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 +如果使用会话,`call_model_input_filter` 会在会话历史记录已加载并与当前轮次合并后运行。如果想自定义该合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 -如果使用 OpenAI 服务器管理的对话状态以及 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id`,该钩子会对下一次 Responses API 调用的已准备载荷运行。该载荷可能已经只表示新轮次的增量,而不是对先前完整历史记录的重放。只有你返回的项才会被标记为已发送,用于该服务器管理的延续。 +如果通过 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 使用 OpenAI 服务器管理的对话状态,该钩子会针对下一次 Responses API 调用准备的载荷运行。该载荷可能已经只表示新轮次的增量,而不是对先前完整历史记录的重放。只有你返回的项目会被标记为已发送,用于该服务器管理的延续流程。 -通过 `run_config` 为每次运行设置该钩子,以隐去敏感数据、修剪过长的历史记录或注入额外的系统指引。 +通过 `run_config` 为每次运行设置该钩子,以编校敏感数据、裁剪过长的历史记录或注入额外的系统指导。 ## 错误与恢复 ### 错误处理程序 -所有 `Runner` 入口点都接受 `error_handlers`,它是一个以错误类型为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是让运行因相应错误而结束,请使用这些键。 +所有 `Runner` 入口点都接受 `error_handlers`,它是一个按错误类型设定键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些键。 ```python from agents import ( @@ -501,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -当模型消息无法通过智能体的结构化 `output_type` 验证,或模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。该处理程序可以返回应用特定的回退值,SDK 会根据同一个 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空验证失败仍会引发 `ModelBehaviorError`,而空结构化响应则保留现有的下一轮行为。 +当模型消息未通过智能体结构化 `output_type` 的验证,或模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。处理程序可以返回应用专属的回退值,SDK 会使用相同的 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空的验证失败仍会抛出 `ModelBehaviorError`,而空结构化响应会保留现有的下一轮行为。 ```python from pydantic import BaseModel @@ -533,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理程序,这会将合成的回退输出追加到对话历史记录中,并将其持久化到已配置的会话。如果希望将回退值返回给调用方,但不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 +`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理程序,此设置会将合成的回退输出追加到对话历史记录中,并将其持久化到已配置的会话。如果希望向调用方返回回退值,但不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 -当模型拒绝响应时,如果希望生成应用特定的回退值,而不是让运行以 `ModelRefusalError` 结束,请使用 `"model_refusal"`。 +如果希望模型拒绝时生成应用专属的回退值,而不是以 `ModelRefusalError` 结束运行,请使用 `"model_refusal"`。 ```python from pydantic import BaseModel @@ -567,35 +567,36 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人在回路 +## 持久执行集成与人机协同 -对于工具审批的暂停/恢复模式,请先参阅专门的[人在回路指南](human_in_the_loop.md)。下述集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 +对于工具审批的暂停/恢复模式,请先参阅专门的[人机协同指南](human_in_the_loop.md)。以下集成适用于持久编排,可用于运行可能经历长时间等待、重试或进程重启的情况。 ### Dapr -你可以使用 Agents SDK的 [Dapr](https://dapr.io) Diagrid 集成,运行持久的长时间运行智能体,使其自动从故障中恢复并支持人在回路工作流。Dapr 是一个供应商中立的 [CNCF](https://cncf.io) 工作流编排器。可从[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI智能体。 +你可以使用 Agents SDK 的 [Dapr](https://dapr.io) Diagrid 集成来运行持久、长时间运行的智能体。这些智能体可自动从故障中恢复,并支持人机协同工作流。Dapr 是一个供应商中立的 [CNCF](https://cncf.io) 工作流编排器。请从[这里](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI 智能体。 ### Temporal -你可以使用 Agents SDK的 [Temporal](https://temporal.io/) 集成来运行持久的长时间运行工作流,包括人在回路任务。你可以在[此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK实际协作完成长时间运行任务的演示,并在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 +你可以使用 Agents SDK 的 [Temporal](https://temporal.io/) 集成运行持久、长时间运行的工作流,包括人机协同任务。你可以在[此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并可在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 ### Restate -你可以使用 Agents SDK的 [Restate](https://restate.dev/) 集成来构建轻量且持久的智能体,包括人工审批、任务转移和会话管理。该集成需要将 Restate 的单二进制运行时作为依赖项,并支持以进程/容器或无服务器函数的形式运行智能体。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 +你可以使用 Agents SDK 的 [Restate](https://restate.dev/) 集成构建轻量级、持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务器函数运行。更多详情请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 ### DBOS -你可以使用 Agents SDK的 [DBOS](https://dbos.dev/) 集成来运行可靠的智能体,并在发生故障和重启时保留进度。它支持长时间运行的智能体、人在回路工作流和任务转移,也同时支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 +你可以使用 Agents SDK 的 [DBOS](https://dbos.dev/) 集成运行可靠的智能体,使其在故障和重启时保留进度。它支持长时间运行的智能体、人机协同工作流和任务转移,并同时支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。更多详情请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 ## 异常 -SDK 会在特定情况下引发异常。完整列表位于 [`agents.exceptions`][]。概述如下: - -- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 所引发全部异常的基类。它是一个通用类型,其他所有特定异常都派生自此类。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。它表示智能体无法在指定数量的智能体循环轮次(LLM 调用)内完成任务。设置 `max_turns=None` 可禁用此限制。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。具体情况可能包括: - - 格式错误的 JSON:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 时。 - - 意外的工具相关失败:模型未按预期方式使用工具时 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当函数工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会引发此异常。 -- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,就会引发此异常。通常是由于代码实现错误、配置无效或误用 SDK API 所致。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:当输入安全防护措施的条件满足时,会引发 `InputGuardrailTripwireTriggered`;当输出安全防护措施的条件满足时,会引发 `OutputGuardrailTripwireTriggered`。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file +SDK 会在某些情况下抛出异常。完整列表请参阅 [`agents.exceptions`][]。概述如下: + +- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 抛出的所有异常的基类。它是一种通用类型,所有其他具体异常均派生自此类。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会抛出此异常。它表示智能体未能在指定的智能体循环轮次数(LLM 调用次数)内完成任务。设置 `max_turns=None` 可禁用该限制。 +- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]:当一次模型调用尝试超过 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] 时,会抛出此异常。有关适用范围和重试行为,请参阅[模型调用超时](models/index.md#model-call-timeouts)。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。这可能包括: + - 格式错误的 JSON:模型为工具调用或直接输出提供了格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 的情况下。 + - 意外的工具相关失败:模型未按预期方式使用工具 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当函数工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会抛出此异常。 +- [`UserError`][agents.exceptions.UserError]:当你(编写使用 SDK 的代码的人)在使用 SDK 时出错,会抛出此异常。这通常由错误的代码实现、无效配置或误用 SDK API 导致。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:满足输入安全防护措施的条件时,会抛出 `InputGuardrailTripwireTriggered`;满足输出安全防护措施的条件时,会抛出 `OutputGuardrailTripwireTriggered`。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file diff --git a/docs/zh/sandbox/clients.md b/docs/zh/sandbox/clients.md index f2c945ece4..35c81331ea 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -4,11 +4,11 @@ search: --- # 沙箱客户端 -使用本页选择沙箱工作应在何处运行。在大多数情况下,`SandboxAgent` 定义保持不变,仅需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端和客户端特定选项。 +使用本页选择沙箱工作应在何处运行。在大多数情况下,`SandboxAgent` 定义保持不变,而沙箱客户端和客户端专属选项会在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中发生变化。 !!! warning "Beta 功能" - 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且未来将提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且后续将逐步提供更高级的功能。 ## 决策指南 @@ -16,9 +16,9 @@ search: | 目标 | 首选方案 | 原因 | | --- | --- | --- | -| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,适合简单的本地文件系统开发。 | -| 基础容器隔离 | `DockerSandboxClient` | 使用指定镜像在 Docker 中运行工作。 | -| 托管执行或生产级隔离 | 托管沙箱客户端 | 将工作区边界迁移到由提供商管理的环境。 | +| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于使用本地文件系统进行开发。 | +| 基本的容器隔离 | `DockerSandboxClient` | 使用特定镜像在 Docker 内运行工作。 | +| 托管执行或生产级隔离 | 托管沙箱客户端 | 将工作区边界移至由提供商管理的环境。 | @@ -30,16 +30,16 @@ search: | 客户端 | 安装 | 适用场景 | 示例 | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上实现最快的本地迭代。是本地开发的良好默认选择。 | [Unix 本地入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | -| `DockerSandboxClient` | `openai-agents[docker]` | 希望使用容器隔离,或使用指定镜像在本地复现目标环境。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | +| `UnixLocalSandboxClient` | 无 | 在 macOS 或 Linux 上实现最快的本地迭代。适合作为本地开发的默认选择。 | [Unix 本地入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `DockerSandboxClient` | `openai-agents[docker]` | 需要容器隔离,或需要使用特定镜像在本地复现目标环境。 | [Docker 入门示例](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | -Unix 本地客户端是基于本地文件系统开始开发的最简便方式。当需要更强的环境隔离或与生产环境保持一致时,可迁移到 Docker 或托管提供商。 +Unix 本地客户端是基于本地文件系统开始开发的最简便方式。当你需要更强的环境隔离或与生产环境保持一致时,可迁移到 Docker 或托管提供商。 -`SandboxPathGrant.host_path` 仅适用于 Docker,它会将主机路径映射到容器内不同的 POSIX 路径。Unix 本地客户端仅支持同路径授权。有关详细信息,请参阅[清单路径授权](guide.md#manifest)。 +`SandboxPathGrant.host_path` 仅适用于 Docker,它会将主机路径映射到容器内不同的 POSIX 路径。Unix 本地客户端仅支持相同路径的授权。有关详细信息,请参阅[清单路径授权](guide.md#manifest)。 -要从 Unix 本地客户端切换到 Docker,请保持智能体定义不变,仅更改运行配置: +如需从 Unix 本地客户端切换到 Docker,请保持智能体定义不变,仅更改运行配置: ```python from docker import from_env as docker_from_env @@ -56,19 +56,32 @@ run_config = RunConfig( ) ``` -当需要容器隔离,或希望沙箱镜像与其他环境中使用的镜像保持一致时,请使用此方式。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 +当你需要容器隔离,或希望沙箱镜像与其他环境中使用的镜像保持一致时,请使用此方式。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 + +### Docker 网络禁用 + +当 Docker 沙箱不得访问网络时,请设置 `network_mode="none"`: + +```python +options = DockerSandboxClientOptions( + image="python:3.14-slim", + network_mode="none", +) +``` + +唯一受支持的显式网络模式是 `"none"`;省略 `network_mode` 可保留 Docker 的默认行为。禁用网络的沙箱无法暴露端口,因此将 `network_mode="none"` 与非空的 `exposed_ports` 元组组合使用,会在选项验证期间失败。此设置会存储在沙箱会话状态中;如果 SDK 在恢复该状态时必须创建替代容器,此设置也会重新应用。 ## 挂载与远程存储 -挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管提供商策略可从 `agents.extensions.sandbox` 或提供商专用扩展包中获取。 +挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管提供商策略可从 `agents.extensions.sandbox` 或提供商专属扩展包中获取。 常用挂载选项: - `mount_path`:存储在沙箱中的显示位置。相对路径基于清单根目录解析;绝对路径按原样使用。 - `read_only`:默认为 `True`。仅当沙箱应将更改写回已挂载存储时,才设置 `False`。 -- `mount_strategy`:必填。请使用同时匹配挂载条目和沙箱后端的策略。 +- `mount_strategy`:必需。请使用同时匹配挂载条目和沙箱后端的策略。 -挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不会将已挂载的远程存储复制到保存的工作区中。 +挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不会将挂载的远程存储复制到保存的工作区中。 通用本地/容器策略: @@ -76,21 +89,21 @@ run_config = RunConfig( | 策略或模式 | 适用场景 | 说明 | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙箱镜像可以运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可在 `fuse` 模式或 `nfs` 模式下运行。 | -| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像包含 `mount-s3`,并且需要 Mountpoint 风格的 S3 或兼容 S3 的访问方式。 | 支持 `S3Mount` 和 `GCSMount`。 | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | 沙箱镜像可以运行 `rclone`。 | 支持 S3、GCS、R2、Azure Blob 和 Box。`RcloneMountPattern` 可以在 `fuse` 模式或 `nfs` 模式下运行。 | +| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | 镜像包含 `mount-s3`,并且你希望以 Mountpoint 方式访问 S3 或 S3 兼容存储。 | 支持 `S3Mount` 和 `GCSMount`。 | | `InContainerMountStrategy(pattern=FuseMountPattern(...))` | 镜像包含 `blobfuse2` 并支持 FUSE。 | 支持 `AzureBlobMount`。 | -| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像包含 `mount.s3files`,并且可以访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | +| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | 镜像包含 `mount.s3files`,并且能够访问现有的 S3 Files 挂载目标。 | 支持 `S3FilesMount`。 | | `DockerVolumeMountStrategy(driver=...)` | Docker 应在容器启动前附加由卷驱动程序支持的挂载。 | 仅适用于 Docker。S3、GCS、R2、Azure Blob 和 Box 可通过 `rclone` 挂载;S3 和 GCS 也可通过 `mountpoint` 挂载。 | ## 支持的托管平台 -需要托管环境时,通常可以沿用同一个 `SandboxAgent` 定义,仅更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端。 +当你需要托管环境时,通常可以继续使用相同的 `SandboxAgent` 定义,仅需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端。 -如果使用的是已发布的 SDK,而不是此代码仓库的检出版本,请通过对应的软件包 extra 安装沙箱客户端依赖项。 +如果你使用的是已发布的 SDK,而非此代码仓库的检出版本,请通过匹配的软件包 extra 安装沙箱客户端依赖项。 -有关提供商特定的设置说明,以及代码仓库中扩展代码示例的链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。 +有关代码仓库中扩展代码示例的提供商专属设置说明和链接,请参阅 [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md)。
@@ -106,28 +119,44 @@ run_config = RunConfig(
-托管沙箱客户端会提供特定于提供商的挂载策略。请选择最适合所用存储提供商的后端和挂载策略: +### Modal 沙箱规格 + +使用 `ModalSandboxClientOptions.cpu` 和 `ModalSandboxClientOptions.memory` 为新的 Modal 沙箱请求资源。单个值表示请求该数量的资源。包含两个元素的 `(request, limit)` 元组将第一个元素用作请求值,第二个元素用作限制值。内存值的单位为 MiB。 + +```python +from agents.extensions.sandbox import ModalSandboxClientOptions + +options = ModalSandboxClientOptions( + app_name="agents-sandbox", + cpu=(1.0, 4.0), + memory=(2048, 8192), +) +``` + +将 `cpu`、`memory` 或两者保留为 `None`,即可对每项省略的资源使用 Modal 的默认值。选定的值会保留在沙箱会话状态中,以便替代沙箱使用相同的资源配置。 + +托管沙箱客户端会提供提供商专属的挂载策略。请选择最适合你的存储提供商的后端和挂载策略:
| 后端 | 挂载说明 | | --- | --- | | Docker | 支持将 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount` 和 `S3FilesMount` 与 `InContainerMountStrategy`、`DockerVolumeMountStrategy` 等本地策略配合使用。 | -| `ModalSandboxClient` | 支持通过 `ModalCloudBucketMountStrategy` 使用 `S3Mount`、`R2Mount` 和经 HMAC 身份验证的 `GCSMount` 来挂载云存储桶。可以使用内联凭证或具名 Modal Secret。 | -| `CloudflareSandboxClient` | 支持通过 `CloudflareBucketMountStrategy` 使用 `S3Mount`、`R2Mount` 和经 HMAC 身份验证的 `GCSMount` 来挂载存储桶。 | -| `BlaxelSandboxClient` | 支持将 `BlaxelCloudBucketMountStrategy` 与 `S3Mount`、`R2Mount` 或 `GCSMount` 条目配对来挂载云存储桶。还支持使用 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 挂载持久化 Blaxel Drives,两者均可从 `agents.extensions.sandbox.blaxel` 获取。 | -| `DaytonaSandboxClient` | 支持通过 `rclone` 使用 `DaytonaCloudBucketMountStrategy` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `E2BSandboxClient` | 支持通过 `rclone` 使用 `E2BCloudBucketMountStrategy` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `RunloopSandboxClient` | 支持通过 `rclone` 使用 `RunloopCloudBucketMountStrategy` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | -| `VercelSandboxClient` | 支持将 `VercelCloudBucketMountStrategy` 与 `S3Mount` 条目配对,以挂载仅能在创建时配置的 S3 和兼容 S3 的存储桶;已挂载的会话无法恢复,并且内联凭证需要 `allow_s3_credential_exposure=True`。 | +| `ModalSandboxClient` | 支持使用 `ModalCloudBucketMountStrategy` 搭配 `S3Mount`、`R2Mount` 和通过 HMAC 认证的 `GCSMount` 来挂载云存储桶。你可以使用内联凭据或命名的 Modal Secret。 | +| `CloudflareSandboxClient` | 支持使用 `CloudflareBucketMountStrategy` 搭配 `S3Mount`、`R2Mount` 和通过 HMAC 认证的 `GCSMount` 来挂载存储桶。 | +| `BlaxelSandboxClient` | 支持将 `BlaxelCloudBucketMountStrategy` 与 `S3Mount`、`R2Mount` 或 `GCSMount` 条目配对,以挂载云存储桶。还支持通过 `BlaxelDriveMount` 和 `BlaxelDriveMountStrategy` 使用持久化 Blaxel Drives,两者均可从 `agents.extensions.sandbox.blaxel` 获取。 | +| `DaytonaSandboxClient` | 支持使用 `DaytonaCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `E2BSandboxClient` | 支持使用 `E2BCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `RunloopSandboxClient` | 支持使用 `RunloopCloudBucketMountStrategy` 通过 `rclone` 挂载云存储;可将其与 `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount` 和 `BoxMount` 配合使用。 | +| `VercelSandboxClient` | 支持将 `VercelCloudBucketMountStrategy` 与 `S3Mount` 条目配对,以挂载仅能在创建时配置的 S3 和 S3 兼容存储桶;已挂载的会话无法恢复,并且内联凭据需要 `allow_s3_credential_exposure=True`。 |
-挂载表描述了每个后端能够执行哪些存储类型。对于在由模型控制的沙箱内运行的挂载辅助程序,勾选标记并不会绕过凭证边界,也不表示每种策略都可以在没有凭证的情况下运行。仅当所选辅助程序可以在不使用受保护权限的情况下运行时,Agents SDK 才会接受未经确认的容器内挂载。如果挂载需要受保护权限,Agents SDK 会在启动沙箱或挂载辅助程序之前拒绝该挂载,除非可信的应用程序代码针对确切的挂载路径明确确认允许暴露该权限。 +挂载表说明了每个后端可以处理哪些存储类型。对于在模型控制的沙箱内运行的挂载辅助程序,勾选标记并不会绕过其凭据边界,也不表示每种策略都能在没有凭据的情况下运行。只有当所选辅助程序无需受保护的权限即可运行时,Agents SDK才会接受未附带确认的容器内挂载。如果挂载需要受保护的权限,而受信任的应用程序代码未明确确认要为该确切挂载路径暴露此权限,Agents SDK会在启动沙箱或挂载辅助程序之前拒绝该挂载。 -无需凭证的 `rclone` 挂载仅限于 S3、GCS、R2 和 Azure Blob。容器内的 Box 挂载需要非交互式身份验证来源,并且需要与该来源匹配的确认。`FuseMountPattern` 需要广泛权限确认,因为即使未配置内联凭证,`blobfuse2` 也会发现环境中的 Azure 权限。类似地,`S3FilesMountPattern` 也需要广泛权限确认,因为 `mount.s3files` 会使用环境中的 IAM 权限。当 Docker 作为后端时,这些要求同样适用;下表中的勾选标记表示在满足适用的权限边界后,Docker 可以执行该挂载。 +无凭据的 `rclone` 挂载仅限于 S3、GCS、R2 和 Azure Blob。容器内的 Box 挂载需要非交互式身份验证来源,以及与该来源匹配的确认。`FuseMountPattern` 需要广泛权限确认,因为 `blobfuse2` 会发现环境中已有的 Azure 权限,即使未配置内联凭据也是如此。同样,`S3FilesMountPattern` 也需要广泛权限确认,因为 `mount.s3files` 会使用环境中已有的 IAM 权限。当 Docker 作为后端时,这些要求同样适用;下表中的勾选标记表示,在满足适用的权限边界后,Docker 可以执行该挂载。 -对于名为 `"data"` 的挂载条目,请保留由与已配置权限匹配的确认操作所返回的 `Manifest` 副本: +对于名为 `"data"` 的挂载条目,请保留由与所配置权限匹配的确认所返回并复制的 `Manifest`: ```python # Mount-scoped values such as inline access keys. @@ -137,11 +166,11 @@ manifest = manifest.with_in_container_mount_credential_exposure_acknowledged("da manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data") ``` -请传入需要确认的每个确切挂载路径。同时使用两种权限类别的挂载需要两项确认。这些确认仅在运行时有效,不会被序列化,并且会允许辅助程序接收凭证,而不会将凭证的使用范围限制在已挂载路径内。应优先使用外部策略或提供商原生策略;否则,请使用作用域限定于沙箱、有效期短且遵循最小权限原则的凭证。 +请传入所有需要确认的确切挂载路径。使用两类权限的挂载需要两项确认。确认仅在运行时有效,不会被序列化,并允许辅助程序接收凭据,但不会将凭据的使用限制在挂载路径内。如果可用,请优先选择外部策略或提供商原生策略;否则,请使用限定在沙箱范围内、短期有效且遵循最小权限原则的凭据。 -`VercelSandboxClientOptions(allow_s3_credential_exposure=True)` 仍可作为兼容性选项,用于在创建 Vercel S3 挂载时使用作用域限定于挂载的内联凭证。它不会授予广泛的凭证权限。 +`VercelSandboxClientOptions(allow_s3_credential_exposure=True)` 仍然是一个兼容性选项,适用于在创建 Vercel S3 挂载时使用内联且限定于挂载范围的凭据。它不授予广泛的凭据权限。 -下表汇总了每个后端可以直接挂载的远程存储条目。 +下表汇总了每个后端可以直接挂载哪些远程存储条目。
@@ -158,4 +187,4 @@ manifest = manifest.with_in_container_mount_broad_credential_exposure_acknowledg
-如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),其中包含本地运行、编码、内存、任务转移和智能体组合模式;有关托管沙箱客户端,请浏览 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions)。 \ No newline at end of file +如需更多可运行的代码示例,请浏览 [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox),其中包含本地、编码、内存、任务转移和智能体组合模式;另请浏览 [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions),其中包含托管沙箱客户端。 \ No newline at end of file diff --git a/docs/zh/sandbox/guide.md b/docs/zh/sandbox/guide.md index f3cff7526a..f311380fb4 100644 --- a/docs/zh/sandbox/guide.md +++ b/docs/zh/sandbox/guide.md @@ -6,35 +6,35 @@ search: !!! warning "Beta 功能" - 沙箱智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认值和支持的能力可能会发生变化,并且后续会逐步提供更多高级功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布前,API 细节、默认值和受支持的功能可能会发生变化,并且未来会逐步提供更高级的功能。 -现代智能体在能够操作文件系统中的真实文件时表现最佳。**沙箱智能体**可以使用专用工具和 shell 命令搜索及操作大型文档集、编辑文件、生成产物并运行命令。沙箱为模型提供持久化工作区,智能体可使用该工作区代您完成工作。Agents SDK 中的沙箱智能体可帮助您轻松运行与沙箱环境配对的智能体,方便您将所需文件放入文件系统,并对沙箱进行编排,从而轻松地大规模启动、停止和恢复任务。 +现代智能体在能够操作文件系统中的真实文件时效果最佳。**沙箱智能体**可以利用专用工具和 shell 命令检索及处理大型文档集、编辑文件、生成产物和运行命令。沙箱为模型提供一个持久工作区,智能体可以使用它代您执行工作。Agents SDK 中的沙箱智能体可帮助您轻松运行与沙箱环境配对的智能体,从而便捷地将所需文件放入文件系统,并编排沙箱,以便大规模启动、停止和恢复任务。 -您可以围绕智能体所需的数据定义工作区。工作区可以从 GitHub 仓库、本地文件和目录、合成任务文件、S3 或 Azure Blob Storage 等远程文件系统,以及您提供的其他沙箱输入开始构建。 +您可以围绕智能体所需的数据定义工作区。工作区可以基于 GitHub 仓库、本地文件和目录、合成任务文件、S3 或 Azure Blob Storage 等远程文件系统,以及您提供的其他沙箱输入来创建。
-![带计算能力的沙箱智能体运行框架](../assets/images/harness_with_compute.png) +![带计算环境的沙箱智能体运行框架](../assets/images/harness_with_compute.png)
-`SandboxAgent` 仍然是 `Agent`。它保留常规智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规 `Runner` API 运行。变化的是执行边界: +`SandboxAgent` 仍然是一个 `Agent`。它保留了常见的智能体接口,例如 `instructions`、`prompt`、`tools`、`handoffs`、`mcp_servers`、`model_settings`、`output_type`、安全防护措施和钩子,并且仍通过常规的 `Runner` API 运行。变化的是执行边界: -- `SandboxAgent` 定义智能体本身:常规智能体配置,加上 `default_manifest`、`base_instructions`、`run_as` 等沙箱专属默认值,以及文件系统工具、shell 访问、技能、记忆或压缩等能力。 -- `Manifest` 声明新沙箱工作区所需的初始内容和布局,包括文件、仓库、挂载和环境。 -- 沙箱会话是运行命令和更改文件的实时隔离环境。 -- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定本次运行如何获得该沙箱会话,例如直接注入、从序列化的沙箱会话状态重新连接,或通过沙箱客户端创建新的沙箱会话。 -- 保存的沙箱状态和快照使后续运行可以重新连接到先前的工作,或从保存的内容为新的沙箱会话设定初始状态。 +- `SandboxAgent` 定义智能体本身:包括常规智能体配置、`default_manifest`、`base_instructions`、`run_as` 等沙箱专用默认值,以及文件系统工具、shell 访问、技能、记忆或压缩等功能。 +- `Manifest` 声明新沙箱工作区所需的初始内容和布局,包括文件、仓库、挂载点和环境。 +- 沙箱会话是运行命令和修改文件的实时隔离环境。 +- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 决定运行如何获得该沙箱会话,例如直接注入会话、通过序列化的沙箱会话状态重新连接,或通过沙箱客户端创建新的沙箱会话。 +- 已保存的沙箱状态和快照可让后续运行重新连接到之前的工作,或根据已保存的内容初始化新的沙箱会话。 -`Manifest` 是新会话的工作区契约,而不是每个实时沙箱的完整事实来源。某次运行的有效工作区也可以来自复用的沙箱会话、序列化的沙箱会话状态,或运行时选择的快照。 +`Manifest` 是新会话的工作区约定,而不是每个实时沙箱的完整事实来源。一次运行的有效工作区也可以来自复用的沙箱会话、序列化的沙箱会话状态,或运行时选择的快照。 -在本页中,“沙箱会话”指由沙箱客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中所述的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 +在本页中,“沙箱会话”指由沙箱客户端管理的实时执行环境。它不同于[会话](../sessions/index.md)中介绍的 SDK 对话式 [`Session`][agents.memory.session.Session] 接口。 -外层运行时仍负责审批、追踪、任务转移,以及跟踪恢复运行所需的状态。沙箱会话负责命令、文件更改和环境隔离。这种职责划分是该模型的核心部分。 +外层运行时仍负责审批、追踪、任务转移,以及跟踪恢复运行所需的状态。沙箱会话负责命令、文件变更和环境隔离。这种职责划分是该模型的核心组成部分。 -### 各组件的协作方式 +### 各组件的组合方式 -沙箱运行将智能体定义与每次运行的沙箱配置结合起来。运行器会准备智能体,将其绑定到实时沙箱会话,并可保存状态供后续运行使用。 +沙箱运行会将智能体定义与每次运行的沙箱配置组合起来。运行器会准备智能体,将其绑定到实时沙箱会话,并可保存状态供后续运行使用。 ```mermaid flowchart LR @@ -50,43 +50,43 @@ flowchart LR sandbox --> saved ``` -沙箱专属默认值保留在 `SandboxAgent` 中。每次运行的沙箱会话选项保留在 `SandboxRunConfig` 中。 +沙箱专用默认值保留在 `SandboxAgent` 上。每次运行的沙箱会话选择则保留在 `SandboxRunConfig` 中。 -可以从三个阶段理解其生命周期: +可以将生命周期分为三个阶段: -1. 使用 `SandboxAgent`、`Manifest` 和能力定义智能体及新工作区契约。 -2. 向 `Runner` 提供 `SandboxRunConfig` 来执行运行,由其注入、恢复或创建沙箱会话。 -3. 后续从运行器管理的 `RunState`、显式沙箱 `session_state` 或保存的工作区快照继续运行。 +1. 使用 `SandboxAgent`、`Manifest` 和各项功能定义智能体及新工作区约定。 +2. 向 `Runner` 提供一个 `SandboxRunConfig`,以注入、恢复或创建沙箱会话并执行运行。 +3. 后续从运行器管理的 `RunState`、显式沙箱 `session_state` 或已保存的工作区快照继续运行。 -如果 shell 访问只是偶尔使用的一项工具,请先使用[工具指南](../tools.md)中的托管 shell。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,请使用沙箱智能体。 +如果 shell 访问只是您偶尔使用的一项工具,请先参阅[工具指南](../tools.md)中的托管 shell。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,再使用沙箱智能体。 ## 适用场景 沙箱智能体非常适合以工作区为中心的工作流,例如: -- 编码和调试,例如编排对 GitHub 仓库中问题报告的自动修复,并运行针对性测试 -- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完成的税务表单草稿 -- 基于文件的审查或分析,例如在回答前检查入职材料包、生成的报告或产物包 -- 隔离的多智能体模式,例如为每个审查智能体或编码子智能体提供各自的工作区 -- 多步骤工作区任务,例如在一次运行中修复错误,之后添加回归测试,或从快照或沙箱会话状态恢复 +- 编码和调试,例如针对 GitHub 仓库中的问题报告编排自动修复并运行针对性测试 +- 文档处理和编辑,例如从用户的财务文档中提取信息,并创建填写完成的税表草稿 +- 基于文件的审核或分析,例如在回答前检查入职资料包、生成的报告或产物包 +- 隔离的多智能体模式,例如为每个审核智能体或编码子智能体提供各自的工作区 +- 多步骤工作区任务,例如在一次运行中修复错误,之后再添加回归测试,或从快照或沙箱会话状态恢复 -如果您不需要访问文件或有状态、可变的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔需要的一项能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 +如果您不需要访问文件或使用有状态、可变的文件系统,请继续使用 `Agent`。如果 shell 访问只是一项偶尔使用的功能,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 -## 沙箱客户端选择 +## 沙箱客户端的选择 -在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,当您需要容器隔离或镜像一致性时,请迁移到 `DockerSandboxClient`;当您需要由提供商管理的执行环境时,请使用托管提供商。 +在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,当您需要容器隔离或镜像一致性时,请迁移到 `DockerSandboxClient`;当您需要由提供商管理执行时,请迁移到托管提供商。 -在大多数情况下,`SandboxAgent` 定义保持不变,仅需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙箱客户端](clients.md)。 +在大多数情况下,`SandboxAgent` 定义保持不变,只需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙箱客户端](clients.md)。 ## 核心组件
-| 层 | 主要 SDK 组件 | 解答的问题 | +| 层 | 主要 SDK 组件 | 回答的问题 | | --- | --- | --- | -| 智能体定义 | `SandboxAgent`、`Manifest`、能力 | 将运行哪个智能体,以及它应从什么样的新会话工作区契约开始? | -| 沙箱执行 | `SandboxRunConfig`、沙箱客户端和实时沙箱会话 | 本次运行如何获得实时沙箱会话,以及工作在哪里执行? | -| 保存的沙箱状态 | `RunState` 沙箱载荷、`session_state` 和快照 | 此工作流如何重新连接到先前的沙箱工作,或根据保存的内容为新沙箱会话设定初始状态? | +| 智能体定义 | `SandboxAgent`、`Manifest`、功能 | 将运行哪个智能体,它应从怎样的新会话工作区约定开始? | +| 沙箱执行 | `SandboxRunConfig`、沙箱客户端和实时沙箱会话 | 此次运行如何获得实时沙箱会话,工作在哪里执行? | +| 已保存的沙箱状态 | `RunState` 沙箱载荷、`session_state` 和快照 | 此工作流如何重新连接到之前的沙箱工作,或使用已保存的内容初始化新的沙箱会话? |
@@ -94,52 +94,52 @@ flowchart LR
-| 组件 | 负责的内容 | 应询问的问题 | +| 组件 | 负责的内容 | 应提出的问题 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应该做什么,哪些默认值应随它一起使用? | -| [`Manifest`][agents.sandbox.manifest.Manifest] | 新会话工作区中的文件和文件夹 | 运行开始时,文件系统中应存在哪些文件和文件夹? | -| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 沙箱原生行为 | 应为此智能体附加哪些工具、指令片段或运行时行为? | -| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 每次运行的沙箱客户端和沙箱会话来源 | 本次运行应注入、恢复还是创建沙箱会话? | -| [`RunState`][agents.run_state.RunState] | 由运行器管理的已保存沙箱状态 | 我是否正在恢复先前由运行器管理的工作流,并自动将其沙箱状态延续下去? | -| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙箱会话状态 | 我是否希望从已在 `RunState` 外部序列化的沙箱状态恢复? | -| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于新沙箱会话的已保存工作区内容 | 新沙箱会话是否应从保存的文件和产物开始? | +| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 智能体定义 | 此智能体应执行什么操作,哪些默认值应随其一同使用? | +| [`Manifest`][agents.sandbox.manifest.Manifest] | 新会话工作区的文件和文件夹 | 运行开始时,文件系统中应存在哪些文件和文件夹? | +| [`Capability`][agents.sandbox.capabilities.capability.Capability] | 沙箱原生行为 | 应向此智能体附加哪些工具、指令片段或运行时行为? | +| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | 每次运行的沙箱客户端和沙箱会话来源 | 此次运行应注入、恢复还是创建沙箱会话? | +| [`RunState`][agents.run_state.RunState] | 运行器管理的已保存沙箱状态 | 我是否正在恢复之前由运行器管理的工作流,并自动延续其沙箱状态? | +| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | 显式序列化的沙箱会话状态 | 我是否要从已在 `RunState` 外部序列化的沙箱状态恢复? | +| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | 用于新沙箱会话的已保存工作区内容 | 新的沙箱会话是否应从已保存的文件和产物开始? |
实用的设计顺序如下: -1. 使用 `Manifest` 定义新会话工作区契约。 +1. 使用 `Manifest` 定义新会话工作区约定。 2. 使用 `SandboxAgent` 定义智能体。 -3. 添加内置或自定义能力。 -4. 在 `RunConfig(sandbox=SandboxRunConfig(...))` 中决定每次运行应如何获取沙箱会话。 +3. 添加内置或自定义功能。 +4. 在 `RunConfig(sandbox=SandboxRunConfig(...))` 中决定每次运行应如何获得其沙箱会话。 ## 沙箱运行的准备过程 -运行时,运行器会将该定义转换为由沙箱支持的具体运行: +在运行时,运行器会将该定义转换为具体的沙箱支持运行: -1. 它从 `SandboxRunConfig` 解析沙箱会话。如果您传入 `session=...`,它会复用该实时沙箱会话。否则,它会使用 `client=...` 创建或恢复沙箱会话。 -2. 它确定本次运行的有效工作区输入。如果运行注入或恢复了沙箱会话,则以该现有沙箱状态为准。否则,运行器会从一次性清单覆盖项或 `agent.default_manifest` 开始。这就是为什么仅靠 `Manifest` 无法定义每次运行最终的实时工作区。 -3. 它允许能力处理生成的清单。这样,能力便可在最终智能体准备完成前添加文件、挂载或其他工作区范围的行为。 -4. 它按固定顺序构建最终指令:SDK 的默认沙箱提示词;如果您显式覆盖,则使用 `base_instructions`;之后是 `instructions`、能力指令片段、任何远程挂载策略文本,最后是渲染后的文件系统树。 -5. 它将能力工具绑定到实时沙箱会话,并通过常规 `Runner` API 运行准备好的智能体。 +1. 它从 `SandboxRunConfig` 解析沙箱会话。如果您传入 `session=...`,它会复用该实时沙箱会话。否则,它会使用 `client=...` 创建或恢复会话。 +2. 它确定此次运行的有效工作区输入。如果此次运行注入或恢复沙箱会话,则以现有沙箱状态为准。否则,运行器会从一次性的清单覆盖项或 `agent.default_manifest` 开始。这就是为什么仅靠 `Manifest` 无法定义每次运行的最终实时工作区。 +3. 它允许各项功能处理生成的清单。这样,功能便可在准备最终智能体之前添加文件、挂载点或其他工作区范围的行为。 +4. 它按固定顺序构建最终指令:首先是 SDK 的默认沙箱提示词;如果您显式覆盖它,则使用 `base_instructions`;然后是 `instructions`、功能指令片段、任何远程挂载策略文本,最后是渲染后的文件系统树。 +5. 它将功能工具绑定到实时沙箱会话,并通过常规的 `Runner` API 运行准备好的智能体。 -沙箱不会改变轮次的含义。一个轮次仍是一次模型步骤,而不是一条 shell 命令或一次沙箱操作。沙箱侧操作与轮次之间没有固定的 1:1 映射:有些工作可能始终留在沙箱执行层中,而其他操作会返回需要另一次模型步骤的信息,例如工具结果、审批或其他类型的状态。实际而言,只有在沙箱工作完成后,智能体运行时还需要另一次模型响应时,才会消耗另一个轮次。 +沙箱不会改变轮次的含义。一个轮次仍然是一个模型步骤,而不是单条 shell 命令或沙箱操作。沙箱侧操作与轮次之间不存在固定的一对一映射:部分工作可能一直留在沙箱执行层中,而其他操作则会返回需要另一个模型步骤的信息,例如工具结果、审批或其他类型的状态。作为实用规则,仅当智能体运行时需要在沙箱工作完成后获取另一个模型响应时,才会消耗另一个轮次。 -这些准备步骤说明了为什么在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 是需要重点考虑的主要沙箱专属选项。 +正是由于这些准备步骤,在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 才是需要重点考虑的主要沙箱专用选项。 ## `SandboxAgent` 选项 -除了常规 `Agent` 字段外,还提供以下沙箱专属选项: +除常规的 `Agent` 字段外,还提供以下沙箱专用选项:
| 选项 | 最佳用途 | | --- | --- | -| `default_manifest` | 运行器创建的新沙箱会话所使用的默认工作区。 | -| `instructions` | 附加在 SDK 沙箱提示词之后的额外角色、工作流和成功标准。 | -| `base_instructions` | 替换 SDK 沙箱提示词的高级逃生舱口。 | -| `capabilities` | 应随此智能体一起使用的沙箱原生工具和行为。 | -| `run_as` | 用于 shell 命令、文件读取和补丁等面向模型的沙箱工具的用户身份。 | +| `default_manifest` | 由运行器创建的新沙箱会话的默认工作区。 | +| `instructions` | 追加在 SDK 沙箱提示词之后的其他角色、工作流和成功标准。 | +| `base_instructions` | 用于替换 SDK 沙箱提示词的高级后备选项。 | +| `capabilities` | 应随此智能体一同使用的沙箱原生工具和行为。 | +| `run_as` | 用于面向模型的沙箱工具(例如 shell 命令、文件读取和补丁)的用户身份。 |
@@ -147,101 +147,103 @@ flowchart LR ### `default_manifest` -`default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。请使用它指定智能体通常应具备的初始文件、仓库、辅助材料、输出目录和挂载。 +`default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。使用它指定智能体通常应从哪些文件、仓库、辅助材料、输出目录和挂载点开始。 -这只是默认值。运行可以使用 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 +这只是默认值。运行可以通过 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 ### `instructions` 和 `base_instructions` -对于应在不同提示词之间保持不变的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会附加在 SDK 的沙箱基础提示词之后,因此您可以保留内置沙箱指南,同时添加自己的角色、工作流和成功标准。 +对于应在不同提示词中保持有效的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会追加到 SDK 的沙箱基础提示词之后,因此您可以保留内置沙箱指导,同时添加自己的角色、工作流和成功标准。 -仅当您希望替换 SDK 沙箱基础提示词时,才使用 `base_instructions`。大多数智能体不应设置它。 +只有当您希望替换 SDK 沙箱基础提示词时,才应使用 `base_instructions`。大多数智能体都不应设置它。
| 放置位置 | 用途 | 示例 | | --- | --- | --- | -| `instructions` | 智能体的稳定角色、工作流规则和成功标准。 | “检查入职文档,然后进行任务转移。”“将最终文件写入 `output/`。” | -| `base_instructions` | 完整替换 SDK 沙箱基础提示词。 | 自定义底层沙箱包装器提示词。 | -| 用户提示词 | 本次运行的一次性请求。 | “总结此工作区。” | -| 清单中的工作区文件 | 较长的任务规范、仓库本地指令或范围明确的参考材料。 | `repo/task.md`、文档包、样本材料包。 | +| `instructions` | 智能体的稳定角色、工作流规则和成功标准。 | “检查入职文档,然后进行任务转移。”、“将最终文件写入 `output/`。” | +| `base_instructions` | 完整替换 SDK 沙箱基础提示词。 | 自定义底层沙箱包装提示词。 | +| 用户提示词 | 此次运行的一次性请求。 | “总结此工作区。” | +| 清单中的工作区文件 | 较长的任务规范、仓库本地指令或有明确范围的参考材料。 | `repo/task.md`、文档包、示例资料包。 |
`instructions` 的良好用法包括: -- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 在 PTY 状态很重要时,让智能体始终停留在同一个交互式进程中。 -- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙箱审查智能体在检查后直接回答用户。 -- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写好的文件实际写入 `output/`。 -- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定确切的验证命令,并明确补丁路径相对于工作区根目录。 +- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) 在 PTY 状态很重要时,让智能体始终在同一个交互式进程中运行。 +- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) 禁止沙箱审核智能体在检查后直接回答用户。 +- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) 要求最终填写完成的文件必须实际保存到 `output/` 中。 +- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 固定确切的验证命令,并明确说明当 `SandboxRunConfig.cwd` 未设置时,补丁路径相对于工作区根目录。 -请避免将用户的一次性任务复制到 `instructions`、嵌入应放入清单的长篇参考材料、重复说明内置能力已经注入的工具文档,或混入模型在运行时不需要的本地安装说明。 +请避免将用户的一次性任务复制到 `instructions` 中、嵌入本应放在清单中的长篇参考材料、重复说明内置功能已经注入的工具文档,或混入模型在运行时不需要的本地安装说明。 -如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。这对于底层包装器已经足够,但大多数面向用户的智能体仍应提供显式的 `instructions`。 +如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。对于底层包装器而言,这已经足够,但大多数面向用户的智能体仍应提供显式的 `instructions`。 ### `capabilities` -能力可将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前塑造工作区、附加沙箱专属指令、公开绑定到实时沙箱会话的工具,以及调整该智能体的模型行为或输入处理。 +功能可将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、追加沙箱专用指令、公开绑定到实时沙箱会话的工具,并调整该智能体的模型行为或输入处理方式。 -内置能力包括: +内置功能包括:
-| 能力 | 添加时机 | 说明 | +| 功能 | 添加条件 | 说明 | | --- | --- | --- | | `Shell` | 智能体需要 shell 访问。 | 添加 `exec_command`;当沙箱客户端支持 PTY 交互时,还会添加 `write_stdin`。 | -| `Filesystem` | 智能体需要编辑文件或检查本地图像。 | 添加 `apply_patch` 和 `view_image`;补丁路径相对于工作区根目录。 | -| `Skills` | 您希望在沙箱中发现并具体化技能。 | 应优先使用此能力,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您建立技能索引并将其具体化到沙箱中。 | +| `Filesystem` | 智能体需要编辑文件或检查本地图像。 | 添加 `apply_patch` 和 `view_image`;默认情况下,相对路径使用工作区根目录,配置后则使用 `SandboxRunConfig.cwd`。 | +| `Skills` | 您希望在沙箱中进行技能发现和实体化。 | 应优先使用此功能,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您将技能编入索引并实体化到沙箱中。 | | `Memory` | 后续运行应读取或生成记忆产物。 | 需要 `Shell`;在运行期间更新记忆产物还需要 `Filesystem`。 | | `Compaction` | 长时间运行的流程需要在压缩项之后裁剪上下文。 | 调整模型采样和输入处理。 |
-默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包括 `Filesystem()`、`Shell()` 和 `Compaction()`。如果您传入 `capabilities=[...]`,该列表会替换默认列表,因此请包含仍要使用的所有默认能力。 +默认情况下,`SandboxAgent.capabilities` 使用 `Capabilities.default()`,其中包括 `Filesystem()`、`Shell()` 和 `Compaction()`。如果传入 `capabilities=[...]`,该列表将替换默认列表,因此请将仍需使用的所有默认功能包含在内。 -对于技能,请根据您希望其具体化的方式选择来源: +`view_image` 工具根据文件内容而非文件扩展名识别 PNG、JPEG、GIF、WebP、BMP 和 TIFF 光栅图像。如果文件名具有光栅图像扩展名,但内容不受支持,则会被拒绝;即使文件名没有图像扩展名,只要光栅内容受支持,也可以加载。对于 `.svg` 和 `.svgz` 文件,除了通过文件内容识别 SVG 标记外,该工具还会保留基于文件名的兼容性。 -- `Skills(lazy_from=LocalDirLazySkillSource(...))` 是较大本地技能目录的良好默认选项,因为模型可以先发现索引,然后只加载所需内容。 -- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从运行 SDK 进程的文件系统中读取。请传入原始宿主机侧技能目录,而不是仅存在于沙箱镜像或工作区内的路径。 -- `Skills(from_=LocalDir(src=...))` 更适合您希望预先暂存的小型本地包。 -- 当技能本身应来自仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 是合适的选择。 +对于技能,请根据您希望其如何实体化来选择来源: -`LocalDir.src` 是 SDK 宿主机上的源路径。`skills_path` 是沙箱工作区内的相对目标路径;调用 `load_skill` 时,技能会暂存于此。 +- `Skills(lazy_from=LocalDirLazySkillSource(...))` 是较大型本地技能目录的良好默认选择,因为模型可以先发现索引,然后仅加载所需内容。 +- `LocalDirLazySkillSource(source=LocalDir(src=...))` 从 SDK 进程运行所在的文件系统读取。请传入原始主机侧技能目录,而不是仅存在于沙箱镜像或工作区中的路径。 +- `Skills(from_=LocalDir(src=...))` 更适合希望预先暂存的小型本地包。 +- 当技能本身应来自某个仓库时,`Skills(from_=GitRepo(repo=..., ref=...))` 是合适的选择。 -如果您的技能已存储在类似 `.agents/skills//SKILL.md` 的磁盘路径中,请将 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 将其公开。除非现有工作区契约依赖不同的沙箱内布局,否则请保留默认的 `skills_path=".agents"`。 +`LocalDir.src` 是 SDK 主机上的源路径。`skills_path` 是沙箱工作区内的相对目标路径;调用 `load_skill` 时,技能会暂存到该路径中。 -如果内置能力可以满足需求,请优先使用它们。只有当您需要内置能力未覆盖的沙箱专属工具或指令接口时,才应编写自定义能力。 +如果您的技能已存储在类似 `.agents/skills//SKILL.md` 的磁盘路径下,请将 `LocalDir(...)` 指向该源根目录,并仍使用 `Skills(...)` 将其公开。除非现有工作区约定依赖不同的沙箱内布局,否则请保留默认的 `skills_path=".agents"`。 + +如果内置功能符合需求,请优先使用它们。仅当您需要内置功能未涵盖的沙箱专用工具或指令接口时,才编写自定义功能。 ## 概念 ### 清单 -[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 +[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载点、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 -清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 跳出工作区,从而使工作区契约可以在本地、Docker 和托管客户端之间移植。 +清单条目路径相对于工作区。它们不能是绝对路径,也不能通过 `..` 逸出工作区,从而确保工作区约定可在本地、Docker 和托管客户端之间移植。 -请使用清单条目指定智能体开始工作前所需的材料: +使用清单条目指定智能体开始工作前所需的材料:
| 清单条目 | 用途 | | --- | --- | | `File`、`Dir` | 小型合成输入、辅助文件或输出目录。 | -| `LocalFile`、`LocalDir` | 应具体化到沙箱中的宿主机文件或目录。 | +| `LocalFile`、`LocalDir` | 应实体化到沙箱中的主机文件或目录。 | | `GitRepo` | 应提取到工作区中的仓库。 | -| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` 等挂载 | 应显示在沙箱内的外部存储。 | +| `S3Mount`、`GCSMount`、`R2Mount`、`AzureBlobMount`、`BoxMount`、`S3FilesMount` 等挂载项 | 应在沙箱内部呈现的外部存储。 |
-`Dir` 根据合成子项在沙箱工作区内创建目录,或创建用作输出位置的目录;它不会从宿主机文件系统读取内容。如果需要将现有宿主机目录复制到沙箱工作区,请使用 `LocalDir`。 +`Dir` 根据合成子项在沙箱工作区内创建目录,或将其创建为输出位置;它不会从主机文件系统读取内容。如果应将现有主机目录复制到沙箱工作区,请使用 `LocalDir`。 -默认情况下,`LocalFile.src` 和 `LocalDir.src` 相对于 SDK 进程工作目录进行解析。源必须位于该基础目录下,除非它包含在 `extra_path_grants` 中。这样可以让本地源的具体化与沙箱清单的其他部分保持在相同的宿主机路径信任边界内。 +默认情况下,`LocalFile.src` 和 `LocalDir.src` 相对于 SDK 进程的工作目录解析。源必须位于该基础目录之下,除非它包含在 `extra_path_grants` 中。这样可确保本地源实体化与沙箱清单的其余部分处于相同的主机路径信任边界内。 -挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。有关挂载选项和提供商支持,请参阅[沙箱客户端](clients.md#mounts-and-remote-storage)。 +挂载条目描述要公开的存储;挂载策略描述沙箱后端如何附加该存储。有关挂载选项和提供商支持,请参阅[沙箱客户端](clients.md#mounts-and-remote-storage)。 -良好的清单设计通常意味着保持工作区契约精简,将较长的任务说明放在 `repo/task.md` 等工作区文件中,并在指令中使用相对工作区路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 能力的 `apply_patch` 工具编辑文件,请记住补丁路径相对于沙箱工作区根目录,而不是 shell 的 `workdir`。 +良好的清单设计通常意味着保持工作区约定精简、将较长的任务流程放入 `repo/task.md` 等工作区文件,并在指令中使用相对工作区路径,例如 `repo/task.md` 或 `output/report.md`。如果智能体使用 `Filesystem` 功能的 `apply_patch` 工具编辑文件,请记住:补丁路径默认使用沙箱工作区根目录,配置后则使用 `SandboxRunConfig.cwd`;它们不使用 shell 的 `workdir`。 -仅当智能体需要工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录之外受信任的本地源时,才使用 `extra_path_grants`。示例包括用于临时工具输出的 `/tmp`、用于只读运行时的 `/opt/toolchain`,或应具体化到沙箱中的已生成技能目录。授权适用于本地源具体化和 SDK 文件 API。当后端能够强制实施文件系统策略时,它也适用于 shell 执行: +仅当智能体需要工作区外的具体绝对路径,或清单需要复制 SDK 进程工作目录之外受信任的本地源时,才使用 `extra_path_grants`。示例包括用于临时工具输出的 `/tmp`、用于只读运行时的 `/opt/toolchain`,或应实体化到沙箱中的已生成技能目录。授权适用于本地源实体化和 SDK 文件 API。当后端可以强制执行文件系统策略时,它也适用于 shell 执行: ```python from agents.sandbox import Manifest, SandboxPathGrant @@ -254,17 +256,17 @@ manifest = Manifest( ) ``` -当 Docker 应将不同的宿主机绝对路径绑定挂载到容器内的 POSIX 绝对路径 `path` 时,请设置 `host_path`。`UnixLocalSandboxClient` 仅支持两个路径相同的纯路径授权,并拒绝 `host_path`。对于沙箱不应修改的宿主机数据,请使用 `read_only=True`;如果复制即可满足需求,则使用 `LocalFile` 或 `LocalDir`。 +当 Docker 应将其他绝对主机路径绑定挂载到容器内的绝对 POSIX `path` 时,请设置 `host_path`。`UnixLocalSandboxClient` 仅支持路径相同的纯路径授权,并拒绝 `host_path`。对于沙箱不应修改的主机数据,请使用 `read_only=True`;如果复制即可满足需求,请使用 `LocalFile` 或 `LocalDir`。 -应将包含 `extra_path_grants` 的清单视为受信任配置。除非应用已经批准这些宿主机路径,否则请勿从模型输出或其他不受信任的载荷中加载授权。 +请将包含 `extra_path_grants` 的清单视为受信任配置。除非应用已经批准这些主机路径,否则不要从模型输出或其他不受信任的载荷中加载授权。 -快照和 `persist_workspace()` 仍然只包含工作区根目录。额外授权的路径是运行时访问权限,而不是持久化工作区状态。 +快照和 `persist_workspace()` 仍然只包含工作区根目录。额外授权的路径属于运行时访问权限,而不是持久工作区状态。 ### 权限 -`Permissions` 控制清单条目的文件系统权限。它针对沙箱具体化的文件,而不是模型权限、审批策略或 API 凭据。 +`Permissions` 控制清单条目的文件系统权限。它涉及沙箱实体化的文件,而非模型权限、审批策略或 API 凭据。 -默认情况下,清单条目对所有者可读、可写、可执行,对组和其他用户可读、可执行。当暂存文件应为私有、只读或可执行文件时,请覆盖此设置: +默认情况下,清单条目允许所有者读取、写入和执行,并允许组和其他用户读取和执行。当暂存文件应为私有、只读或可执行文件时,请覆盖此设置: ```python from agents.sandbox import FileMode, Permissions @@ -280,9 +282,9 @@ private_notes = File( ) ``` -`Permissions` 分别存储所有者、组和其他用户的权限位,以及该条目是否为目录。您可以直接构建它、使用 `Permissions.from_str(...)` 从模式字符串解析,或使用 `Permissions.from_mode(...)` 从操作系统模式派生。 +`Permissions` 分别存储所有者、组和其他用户的权限位,以及条目是否为目录。您可以直接构建它、使用 `Permissions.from_str(...)` 从模式字符串解析它,或使用 `Permissions.from_mode(...)` 从操作系统模式派生它。 -用户是可以在沙箱中执行工作的身份。如果您希望该身份存在于沙箱中,请向清单添加 `User`;随后,当 shell 命令、文件读取和补丁等面向模型的沙箱工具应以该用户身份运行时,请设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到有效清单。 +用户是可在沙箱中执行工作的身份。如果希望某个身份存在于沙箱中,请向清单添加 `User`;当面向模型的沙箱工具(例如 shell 命令、文件读取和补丁)应以该用户身份运行时,再设置 `SandboxAgent.run_as`。如果 `run_as` 指向清单中尚不存在的用户,运行器会自动将其添加到有效清单。 ```python from agents import Runner @@ -334,13 +336,13 @@ result = await Runner.run( ) ``` -如果还需要文件级共享规则,请将用户与清单组及条目的 `group` 元数据结合使用。`run_as` 用户控制谁执行沙箱原生操作;沙箱具体化工作区后,`Permissions` 控制该用户可以读取、写入或执行哪些文件。 +如果还需要文件级共享规则,请将用户与清单组及条目 `group` 元数据结合使用。`run_as` 用户控制谁执行沙箱原生操作;在沙箱实体化工作区后,`Permissions` 控制该用户可以读取、写入或执行哪些文件。 ### SnapshotSpec -`SnapshotSpec` 指示新沙箱会话应从何处恢复保存的工作区内容,以及将内容持久化回何处。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 +`SnapshotSpec` 指定新沙箱会话应从何处恢复已保存的工作区内容,以及将其持久化回何处。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 -对于本地持久快照,请使用 `LocalSnapshotSpec`;当您的应用提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。本地快照设置不可用时,会使用空操作快照作为回退;不希望持久化工作区快照的高级调用方也可以显式使用它。 +使用 `LocalSnapshotSpec` 创建本地持久快照;当应用提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。如果无法设置本地快照,则使用空操作快照作为后备;不希望持久化工作区快照的高级调用方也可以显式使用空操作快照。 ```python from pathlib import Path @@ -357,13 +359,13 @@ run_config = RunConfig( ) ``` -当运行器创建新沙箱会话时,沙箱客户端会为该会话构建快照实例。启动时,如果快照可恢复,沙箱会先恢复保存的工作区内容,然后再继续运行。清理时,由运行器拥有的沙箱会话会归档工作区,并通过快照将其持久化。 +当运行器创建新沙箱会话时,沙箱客户端会为该会话构建一个快照实例。启动时,如果快照可恢复,沙箱会先恢复已保存的工作区内容,再继续运行。清理时,运行器所有的沙箱会话会归档工作区,并通过快照将其持久化回去。 -如果省略 `snapshot`,运行时会在可行时尝试使用默认本地快照位置。如果无法完成设置,则回退为空操作快照。挂载路径和临时路径不会作为持久化工作区内容复制到快照中。 +如果省略 `snapshot`,运行时会尽可能尝试使用默认的本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 ### 沙箱生命周期 -生命周期分为两种模式:**SDK 所有**和**开发者所有**。 +生命周期分为两种模式:**SDK 管理型**和**开发者管理型**。
@@ -391,7 +393,7 @@ sequenceDiagram
-当沙箱只需在一次运行期间存活时,请使用 SDK 所有的生命周期。传入 `client`,以及可选的 `manifest` 和 `snapshot`,再加上所需的任何客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、结束沙箱会话,并让客户端清理由运行器拥有的资源。 +如果沙箱只需在一次运行期间存在,请使用 SDK 管理型生命周期。传入一个 `client`,并可选择传入 `manifest`、`snapshot` 和所需的任何客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、结束沙箱会话,并让客户端清理运行器所有的资源。 ```python result = await Runner.run( @@ -403,7 +405,7 @@ result = await Runner.run( ) ``` -当您希望提前创建沙箱、在多次运行中复用同一个实时沙箱、在运行后检查文件、通过自行创建的沙箱进行流式传输,或精确决定清理时机时,请使用开发者所有的生命周期。传入 `session=...` 会指示运行器使用该实时沙箱,但不会代您关闭它。 +如果您希望立即创建沙箱、在多次运行间复用一个实时沙箱、在运行后检查文件、通过自己创建的沙箱进行流式传输,或精确决定何时清理,请使用开发者管理型生命周期。传入 `session=...` 会让运行器使用该实时沙箱,但不会替您关闭它。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -414,7 +416,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -上下文管理器是常用形式:进入时启动沙箱,退出时运行会话清理生命周期。如果您的应用无法使用上下文管理器,请直接调用生命周期方法: +上下文管理器是常见用法:进入时启动沙箱,退出时运行会话清理生命周期。如果应用无法使用上下文管理器,请直接调用生命周期方法: ```python sandbox = await client.create( @@ -435,11 +437,11 @@ finally: await sandbox.aclose() ``` -`stop()` 只会持久化由快照支持的工作区内容;它不会销毁沙箱。`aclose()` 是完整的会话清理路径:它运行停止前钩子、调用 `stop()`、关闭沙箱资源并关闭会话范围的依赖项。 +`stop()` 只持久化由快照支持的工作区内容;它不会拆除沙箱。`aclose()` 是完整的会话清理路径:它会运行停止前钩子、调用 `stop()`、关闭沙箱资源,并关闭会话范围的依赖项。 ## `SandboxRunConfig` 选项 -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 保存每次运行的选项,用于决定沙箱会话的来源,以及应如何初始化新会话。 +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 包含每次运行的选项,用于决定沙箱会话的来源,以及应如何初始化新会话。 ### 沙箱来源 @@ -447,52 +449,77 @@ finally:
-| 选项 | 使用时机 | 说明 | +| 选项 | 使用场景 | 说明 | | --- | --- | --- | -| `client` | 您希望运行器代您创建、恢复和清理沙箱会话。 | 除非您提供实时沙箱 `session`,否则此项为必需。 | -| `session` | 您已经自行创建了实时沙箱会话。 | 生命周期由调用方负责;运行器复用该实时沙箱会话。 | -| `session_state` | 您有序列化的沙箱会话状态,但没有实时沙箱会话对象。 | 需要 `client`;运行器从该显式状态恢复,并负责恢复后会话的生命周期。 | +| `client` | 您希望运行器替您创建、恢复和清理沙箱会话。 | 除非提供实时沙箱 `session`,否则为必需项。 | +| `session` | 您已自行创建实时沙箱会话。 | 调用方负责生命周期;运行器复用该实时沙箱会话。 | +| `session_state` | 您已有序列化的沙箱会话状态,但没有实时沙箱会话对象。 | 需要 `client`;运行器从该显式状态恢复,并负责已恢复会话的生命周期。 |
-实际使用中,运行器按以下顺序解析沙箱会话: +在实践中,运行器按以下顺序解析沙箱会话: 1. 如果注入 `run_config.sandbox.session`,则直接复用该实时沙箱会话。 -2. 否则,如果运行正从 `RunState` 恢复,则恢复其中存储的沙箱会话状态。 +2. 否则,如果此次运行正在从 `RunState` 恢复,则恢复已存储的沙箱会话状态。 3. 否则,如果传入 `run_config.sandbox.session_state`,运行器会从该显式序列化沙箱会话状态恢复。 -4. 否则,运行器会创建新沙箱会话。对于该新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 +4. 否则,运行器会创建新的沙箱会话。对于该新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 ### 新会话输入 -以下选项仅在运行器创建新沙箱会话时生效: +以下选项仅在运行器创建新的沙箱会话时有效:
-| 选项 | 使用时机 | 说明 | +| 选项 | 使用场景 | 说明 | | --- | --- | --- | -| `manifest` | 您希望一次性覆盖新会话工作区。 | 省略时回退到 `agent.default_manifest`。 | -| `snapshot` | 新沙箱会话应从快照设定初始状态。 | 适用于类似恢复的流程或远程快照客户端。 | -| `options` | 沙箱客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时和类似的客户端专属设置。 | +| `manifest` | 您希望对新会话工作区进行一次性覆盖。 | 省略时回退到 `agent.default_manifest`。 | +| `snapshot` | 新沙箱会话应从快照初始化。 | 适用于类似恢复的流程或远程快照客户端。 | +| `options` | 沙箱客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时及类似的客户端专用设置。 |
-### 具体化控制 +### 面向模型的工作目录 + +当多次运行应共享一个沙箱会话,但需要在不同子目录中操作时,请将 `cwd` 设置为相对于工作区的 POSIX 目录。运行器验证 `cwd` 时,该目录必须存在,并且已配置的沙箱用户必须能够访问它。对于新会话,运行器会先实体化清单,因此清单可以在验证前创建该目录。 + +```python +from agents import Runner +from agents.run import RunConfig +from agents.sandbox import SandboxRunConfig + +result = await Runner.run( + agent, + "Work only on task A.", + run_config=RunConfig( + sandbox=SandboxRunConfig( + session=shared_sandbox, + cwd="tasks/task-a", + ), + ), +) +``` + +内置 `exec_command`、`view_image` 和 `apply_patch` 工具使用的相对路径从 `cwd` 开始解析。对于 `cwd` 值本身,绝对路径、`..` 等父目录段和空值均会被拒绝。字符串值必须使用正斜杠。相对 `PurePath` 值会规范化为 POSIX 格式,而绝对 `PurePath` 值仍然无效。直接使用的 `BaseSandboxSession` 文件 API 仍相对于工作区根目录,因此 `cwd` 不会更改 `Manifest.root` 或会话的底层工作区边界。该设置仅更改相对路径解析方式:它不会将运行限制在 `cwd` 内,也不会阻止访问共享会话工作区策略允许的其他路径。 + +带路径的自定义功能在解析模型提供的相对路径时,必须应用其绑定的 [`SandboxWorkspaceScope`][agents.sandbox.workspace_paths.SandboxWorkspaceScope]。有关共享一个沙箱会话、同时保持各自面向模型的工作目录相互独立的两个并发运行,请参阅 [examples/sandbox/shared_session_workdirs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/shared_session_workdirs.py)。 + +### 实体化控制 -`concurrency_limits` 控制可并行运行的沙箱具体化工作量。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用对应的特定限制。 +`concurrency_limits` 控制可并行运行的沙箱实体化工作量。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用对应限制。 -`archive_limits` 控制 SDK 侧针对归档提取的资源检查。将其设置为 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持不设 SDK 归档资源限制的默认行为;也可以将单个字段设置为 `None`,仅禁用对应限制。 +`archive_limits` 控制 SDK 侧对归档提取的资源检查。将其设置为 `archive_limits=SandboxArchiveLimits()` 可启用 SDK 默认阈值;当归档需要更严格的资源控制时,也可传入 `SandboxArchiveLimits(max_input_bytes=..., max_extracted_bytes=..., max_members=...)` 等显式值。保留 `archive_limits=None` 可维持不设 SDK 归档资源限制的默认行为,或将单个字段设置为 `None`,只禁用该项限制。 请注意以下几点: - 新会话:`manifest=` 和 `snapshot=` 仅在运行器创建新沙箱会话时适用。 -- 恢复与快照:`session_state=` 重新连接到先前序列化的沙箱状态,而 `snapshot=` 根据保存的工作区内容为新沙箱会话设定初始状态。 -- 客户端专属选项:`options=` 取决于沙箱客户端;Docker 和许多托管客户端都需要它。 -- 注入的实时会话:如果传入正在运行的沙箱 `session`,由能力驱动的清单更新可以添加兼容的非挂载条目。它们不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;不能删除现有条目;不能替换条目类型;也不能添加或更改挂载条目。 -- 运行器 API:`SandboxAgent` 执行仍使用常规 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API。 +- 恢复与快照:`session_state=` 重新连接到之前序列化的沙箱状态,而 `snapshot=` 使用已保存的工作区内容初始化新的沙箱会话。 +- 客户端专用选项:`options=` 取决于沙箱客户端;Docker 和许多托管客户端都需要它。 +- 注入的实时会话:如果传入正在运行的沙箱 `session`,由功能驱动的清单更新可以添加兼容的非挂载条目,但不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;也不能删除现有条目、替换条目类型,或添加或更改挂载条目。 +- 运行器 API:`SandboxAgent` 执行仍使用常规的 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API。 ## 完整示例:编码任务 -以下编码类示例是一个良好的默认起点: +以下编码风格示例是一个良好的默认起点: ```python import asyncio @@ -524,9 +551,9 @@ def build_agent(model: str) -> SandboxAgent[None]: "and summarize the file changes and risks. " "Read `repo/task.md` before editing files. Stay grounded in the repository, preserve " "existing behavior, and mention the exact verification command you ran. " - "Use the `$credit-note-fixer` skill before editing files. If the repo lives under " - "`repo/`, remember that `apply_patch` paths stay relative to the sandbox workspace " - "root, so edits still target `repo/...`." + "Use the `$credit-note-fixer` skill before editing files. " + "This example leaves `SandboxRunConfig.cwd` unset, so `apply_patch` paths stay " + "relative to the sandbox workspace root and edits still target `repo/...`." ), # Put repos and task files in the manifest. default_manifest=Manifest( @@ -571,19 +598,19 @@ if __name__ == "__main__": ) ``` -请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用基于 shell 的小型仓库,因此可以在 Unix 本地运行中以确定性的方式验证示例。您的实际任务仓库当然可以使用 Python、JavaScript 或其他任何语言。 +请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 shell 的微型仓库,因此可以在 Unix 本地运行中以确定性方式验证该示例。当然,您的实际任务仓库可以使用 Python、JavaScript 或任何其他语言。 ## 常见模式 -请从上面的完整示例开始。在许多情况下,可以保持同一个 `SandboxAgent` 不变,只更改沙箱客户端、沙箱会话来源或工作区来源。 +请从上面的完整示例开始。在许多情况下,同一个 `SandboxAgent` 可以保持不变,只需更改沙箱客户端、沙箱会话来源或工作区来源。 -### 沙箱客户端切换 +### 沙箱客户端的切换 -保持智能体定义不变,仅更改运行配置。当您需要容器隔离或镜像一致性时使用 Docker;当您需要由提供商管理的执行环境时使用托管提供商。有关代码示例和提供商选项,请参阅[沙箱客户端](clients.md)。 +保持智能体定义不变,只更改运行配置。如果需要容器隔离或镜像一致性,请使用 Docker;如果需要由提供商管理执行,请使用托管提供商。有关代码示例和提供商选项,请参阅[沙箱客户端](clients.md)。 -### 工作区覆盖 +### 工作区的覆盖 -保持智能体定义不变,仅替换新会话清单: +保持智能体定义不变,只替换新会话清单: ```python from agents.run import RunConfig @@ -603,11 +630,11 @@ run_config = RunConfig( ) ``` -当同一个智能体角色应针对不同仓库、材料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了相同模式,但使用 `default_manifest`,而不是一次性覆盖项。 +当同一智能体角色需要针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了相同模式,但它使用 `default_manifest`,而不是一次性覆盖。 -### 沙箱会话注入 +### 沙箱会话的注入 -当您需要显式控制生命周期、运行后检查或复制输出时,请注入实时沙箱会话: +当您需要显式控制生命周期、在运行后进行检查或复制输出时,请注入实时沙箱会话: ```python from agents import Runner @@ -630,9 +657,9 @@ async with sandbox: 当您希望在运行后检查工作区,或通过已启动的沙箱会话进行流式传输时,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 -### 会话状态恢复 +### 会话状态的恢复 -如果您已在 `RunState` 外部序列化沙箱状态,可让运行器从该状态重新连接: +如果您已在 `RunState` 外部序列化沙箱状态,请让运行器从该状态重新连接: ```python from agents.run import RunConfig @@ -649,15 +676,15 @@ run_config = RunConfig( ) ``` -当沙箱状态存储在您自己的存储系统或作业系统中,并且希望 `Runner` 直接从中恢复时,请使用此模式。有关序列化和反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 +当沙箱状态存储在您自己的存储或作业系统中,并希望 `Runner` 直接从中恢复时,请使用此模式。有关序列化/反序列化流程,请参阅 [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py)。 -会话状态序列化会省略原生 `host_path` 值。要恢复由宿主机支持的授权,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单;否则,恢复会在沙箱启动前失败。切勿从序列化输入或其他不受信任的输入中派生宿主机路径。 +会话状态序列化会省略原生 `host_path` 值。要恢复由主机支持的授权,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单;否则,恢复会在沙箱启动前失败。绝不要从序列化输入或其他不受信任的输入中派生主机路径。 -会话状态和 `RunState` 序列化还会移除云挂载凭据、含凭据的辅助配置,以及容器内凭据公开确认。对于支持恢复已挂载会话的后端,当状态中包含经过编辑的挂载权限时,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单。当名为 `"data"` 的挂载条目需要挂载范围确认时,请在恢复前通过 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` 保留复制的清单。对于广泛权限,请使用 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")`;当挂载同时使用这两类权限时,请同时调用这两个方法。请传入需要确认的每一个确切挂载路径。仅当当前受信任清单与持久化状态具有完全相同的不含凭据的挂载拓扑时,Agents SDK 才会恢复凭据。缺失或不匹配的受信任配置会导致恢复在沙箱启动前失败;序列化状态本身绝不会授予权限。`VercelSandboxClient` 无法恢复已挂载会话,因此应改为使用受信任清单启动新沙箱。 +会话状态和 `RunState` 序列化还会移除云挂载凭据、包含凭据的辅助配置,以及对容器内凭据暴露的确认。对于支持恢复已挂载会话的后端,当状态包含已遮盖的挂载权限时,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单。当名为 `"data"` 的挂载条目需要挂载范围的确认时,请在恢复前使用 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` 保留复制的清单。对于广泛权限,请使用 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")`;当挂载使用两类权限时,请调用这两种方法。请传入需要确认的每个确切挂载路径。只有当前受信任清单具有与持久化状态完全相同且不含凭据的挂载拓扑时,Agents SDK 才会恢复凭据。缺失或不匹配的受信任配置会导致恢复在沙箱启动前失败;序列化状态本身绝不会授予权限。`VercelSandboxClient` 无法恢复已挂载会话,因此应改为使用受信任清单启动新沙箱。 -### 快照启动 +### 快照的使用 -根据保存的文件和产物为新沙箱设定初始状态: +使用已保存的文件和产物初始化新沙箱: ```python from pathlib import Path @@ -674,11 +701,11 @@ run_config = RunConfig( ) ``` -当创建新沙箱会话的运行应从保存的工作区内容开始,而不是仅从 `agent.default_manifest` 开始时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 +当创建新沙箱会话的运行应从已保存的工作区内容开始,而不只是从 `agent.default_manifest` 开始时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 ### 从 Git 加载技能 -将本地技能来源替换为由仓库支持的来源: +将本地技能源替换为由仓库支持的技能源: ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -689,11 +716,11 @@ capabilities = Capabilities.default() + [ ] ``` -当技能包有自己的发布周期,或应在多个沙箱之间共享时,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 +当技能包有自己的发布节奏,或应在多个沙箱间共享时,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 -### 工具公开 +### 工具形式的公开 -工具智能体既可以使用自己的沙箱边界,也可以复用父运行中的实时沙箱。复用适合快速、只读的探索智能体:它可以检查父运行正在使用的确切工作区,而无需承担创建、填充或快照另一个沙箱的成本。 +工具智能体既可以拥有自己的沙箱边界,也可以复用父级运行中的实时沙箱。复用适用于快速的只读探索智能体:它可以检查父级运行正在使用的确切工作区,而无需承担创建、填充或快照另一个沙箱的成本。 ```python from agents import Runner @@ -775,9 +802,9 @@ async with sandbox: ) ``` -此处,父智能体以 `coordinator` 身份运行,探索工具智能体则在同一个实时沙箱会话内以 `explorer` 身份运行。`pricing_packet/` 条目可由 `other` 用户读取,因此探索智能体可以快速检查这些条目,但没有写入权限位。`work/` 目录仅对协调器的用户或组可用,因此父智能体可以写入最终产物,而探索智能体保持只读。 +此处,父智能体以 `coordinator` 身份运行,探索工具智能体则以 `explorer` 身份在同一实时沙箱会话中运行。`pricing_packet/` 条目允许 `other` 用户读取,因此探索智能体可以快速检查它们,但没有写入权限位。`work/` 目录仅对协调器的用户/组开放,因此父智能体可以写入最终产物,而探索智能体保持只读。 -如果工具智能体需要真正的隔离,请为其提供自己的沙箱 `RunConfig`: +当工具智能体需要真正隔离时,请为其提供自己的沙箱 `RunConfig`: ```python from docker import from_env as docker_from_env @@ -803,11 +830,11 @@ rollout_agent.as_tool( ) ``` -当工具智能体应自由修改内容、运行不受信任的命令或使用不同后端或镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +当工具智能体应自由修改内容、运行不受信任的命令,或使用不同后端/镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -### 与本地工具和 MCP 组合 +### 与本地工具和 MCP 的组合 -保留沙箱工作区,同时在同一智能体上使用常规工具: +保留沙箱工作区,同时在同一个智能体上使用普通工具: ```python from agents.sandbox import SandboxAgent @@ -826,42 +853,42 @@ agent = SandboxAgent( ## 记忆 -当未来的沙箱智能体运行应从先前运行中学习时,请使用 `Memory` 能力。记忆与 SDK 的对话式 `Session` 记忆不同:它会将经验提炼为沙箱工作区中的文件,后续运行可以读取这些文件。 +当未来的沙箱智能体运行应从之前的运行中学习时,请使用 `Memory` 功能。该记忆与 SDK 的对话式 `Session` 记忆不同:它会将经验提炼为沙箱工作区内的文件,后续运行可以读取这些文件。 -有关设置、读取和生成行为、多轮对话及布局隔离,请参阅[智能体记忆](memory.md)。 +有关设置、读取/生成行为、多轮对话和布局隔离,请参阅[智能体记忆](memory.md)。 ## 组合模式 -明确单智能体模式后,下一个设计问题是沙箱边界应位于较大系统中的何处。 +明确单智能体模式后,下一个设计问题是沙箱边界在大型系统中应位于何处。 -沙箱智能体仍可与 SDK 的其他部分组合: +沙箱智能体仍可与 SDK 的其余部分组合: -- [任务转移](../handoffs.md):将文档密集型工作从非沙箱接收智能体转移给沙箱审查智能体。 -- [Agents as tools](../tools.md#agents-as-tools):将多个沙箱智能体公开为工具,通常在每次 `Agent.as_tool(...)` 调用中传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,使每个工具拥有自己的沙箱边界。 -- [MCP](../mcp.md) 和常规函数工具:沙箱能力可与 `mcp_servers` 和普通 Python 工具共存。 -- [智能体运行](../running_agents.md):沙箱运行仍使用常规 `Runner` API。 +- [任务转移](../handoffs.md):将文档密集型工作从非沙箱接收智能体转交给沙箱审核智能体。 +- [Agents as tools](../tools.md#agents-as-tools):将多个沙箱智能体公开为工具,通常在每次 `Agent.as_tool(...)` 调用时传入 `run_config=RunConfig(sandbox=SandboxRunConfig(...))`,使每个工具拥有自己的沙箱边界。 +- [MCP](../mcp.md) 和普通函数工具:沙箱功能可与 `mcp_servers` 和普通 Python 工具共存。 +- [运行智能体](../running_agents.md):沙箱运行仍使用常规的 `Runner` API。 以下两种模式尤其常见: -- 非沙箱智能体仅在工作流中需要工作区隔离的部分将任务转移给沙箱智能体 -- 编排器将多个沙箱智能体公开为工具,通常每次 `Agent.as_tool(...)` 调用都使用独立的沙箱 `RunConfig`,使每个工具获得自己的隔离工作区 +- 非沙箱智能体仅针对工作流中需要工作区隔离的部分,将任务转移给沙箱智能体 +- 编排器将多个沙箱智能体公开为工具,通常为每次 `Agent.as_tool(...)` 调用提供单独的沙箱 `RunConfig`,使每个工具拥有自己的隔离工作区 ### 轮次与沙箱运行 -分别说明任务转移和智能体工具调用有助于理解两者。 +分别说明任务转移和智能体即工具调用会更容易理解。 -使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活跃智能体会发生变化,但运行不会变成嵌套运行。如果非沙箱接收智能体将任务转移给沙箱审查智能体,则同一次运行中的下一次模型调用会针对沙箱智能体进行准备,该沙箱智能体将成为执行下一轮次的智能体。换言之,任务转移会改变同一次运行中由哪个智能体负责下一轮次。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 +使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活跃智能体会发生变化,但运行不会变为嵌套运行。如果非沙箱接收智能体将任务转移给沙箱审核智能体,则同一运行中的下一个模型调用会针对该沙箱智能体进行准备,并由该沙箱智能体执行下一轮。换言之,任务转移会改变哪个智能体负责同一次运行的下一轮。请参阅 [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py)。 -使用 `Agent.as_tool(...)` 时,关系则不同。外层编排器使用一个外层轮次来决定调用工具,该工具调用会为沙箱智能体启动嵌套运行。嵌套运行有自己的轮次循环、`max_turns`、审批,并且通常有自己的沙箱 `RunConfig`。它可能在一个嵌套轮次中完成,也可能需要多个轮次。从外层编排器的角度看,所有这些工作仍封装在一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 +对于 `Agent.as_tool(...)`,两者之间的关系则不同。外层编排器使用一个外层轮次决定调用工具,而该工具调用会为沙箱智能体启动嵌套运行。嵌套运行拥有自己的轮次循环、`max_turns`、审批,以及通常独立的沙箱 `RunConfig`。它可能在一个嵌套轮次中完成,也可能需要多个轮次。从外层编排器的角度看,所有这些工作仍位于一次工具调用之后,因此嵌套轮次不会增加外层运行的轮次计数器。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -审批行为也遵循相同的职责划分: +审批行为遵循相同的职责划分: -- 使用任务转移时,审批仍位于同一个顶层运行中,因为沙箱智能体现在是该运行中的活跃智能体 -- 使用 `Agent.as_tool(...)` 时,沙箱工具智能体内部发起的审批仍会显示在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 +- 使用任务转移时,审批仍属于同一个顶层运行,因为沙箱智能体现在是该运行中的活跃智能体 +- 使用 `Agent.as_tool(...)` 时,沙箱工具智能体内部触发的审批仍会呈现在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 ## 延伸阅读 - [快速入门](../sandbox_agents.md):运行一个沙箱智能体。 - [沙箱客户端](clients.md):选择本地、Docker、托管和挂载选项。 -- [智能体记忆](memory.md):保留并复用先前沙箱运行中的经验。 +- [智能体记忆](memory.md):保留并复用之前沙箱运行中的经验。 - [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):可运行的本地、编码、记忆、任务转移和智能体组合模式。 \ No newline at end of file diff --git a/docs/zh/sessions/index.md b/docs/zh/sessions/index.md index 38f1a2a0dc..d605c097e7 100644 --- a/docs/zh/sessions/index.md +++ b/docs/zh/sessions/index.md @@ -4,11 +4,11 @@ search: --- # 会话 -Agents SDK 提供内置会话记忆功能,可在多次智能体运行之间自动维护对话历史记录,无需在轮次之间手动处理`.to_input_list()`。 +Agents SDK提供内置的会话记忆功能,可在多次智能体运行之间自动维护对话历史记录,无需在轮次之间手动处理`.to_input_list()`。 -会话存储特定会话的对话历史记录,使智能体无需显式的手动记忆管理即可保持上下文。这对于构建聊天应用或多轮对话尤其有用,因为在这些场景中,您希望智能体能够记住之前的交互。 +会话会存储特定会话的对话历史记录,使智能体无需显式手动管理记忆即可保持上下文。这对于构建聊天应用或多轮对话尤其有用,因为你希望智能体能够记住之前的交互。 -如果您希望由 SDK 管理客户端侧记忆,请使用会话。在同一次运行中,会话不能与运行级续接选项`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果您希望改用由OpenAI服务器管理的续接机制,请选择其中一种机制,而不要在其上叠加会话。 +如果希望由SDK为你管理客户端记忆,请使用会话。在同一次运行中,会话不能与运行级续接选项`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果希望改用由OpenAI服务器管理的续接机制,请选择其中一种机制,而不要在其上叠加会话。 ## 快速入门 @@ -51,7 +51,7 @@ print(result.final_output) # "Approximately 39 million" ## 使用同一会话恢复中断的运行 -如果运行因等待审批而暂停,请使用同一会话实例恢复运行(或使用配置了相同会话 ID 和相同底层存储后端的另一实例),以便恢复后的轮次继续沿用同一份已存储的对话历史记录。 +如果运行因等待批准而暂停,请使用同一会话实例恢复运行(或使用另一个实例,该实例配置了相同的会话ID和相同的底层存储后端),以便恢复后的轮次继续使用同一份已存储对话历史记录。 ```python result = await Runner.run(agent, "Delete temporary files that are no longer needed.", session=session) @@ -68,26 +68,26 @@ if result.interruptions: 启用会话记忆后: 1. **每次运行前**:运行器会自动检索该会话的对话历史记录,并将其添加到输入项之前。 -2. **每次运行后**:运行期间生成的所有新项目(用户输入、助手响应、工具调用等)都会自动存储到会话中。 -3. **上下文保留**:使用同一会话的每次后续运行都会包含完整的对话历史记录,使智能体能够保持上下文。 +2. **每次运行后**:运行期间生成的所有新项目(用户输入、助手响应、工具调用等)都会自动存储在会话中。 +3. **上下文保留**:之后每次使用同一会话运行时,都会包含完整的对话历史记录,使智能体能够保持上下文。 -这样便无需手动调用`.to_input_list()`以及在运行之间管理对话状态。 +这样便无需手动调用`.to_input_list()`并在运行之间管理对话状态。 ## 历史记录与新输入的合并控制 -传入会话时,运行器通常会按以下顺序准备模型输入: +传入会话时,运行器通常按以下顺序准备模型输入: -1. 会话历史记录(从`session.get_items(...)`中检索) +1. 会话历史记录(从`session.get_items(...)`检索) 2. 新轮次输入 -使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可在调用模型之前自定义该合并步骤。回调接收两个列表: +使用[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]可在调用模型前自定义该合并步骤。回调接收两个列表: - `history`:检索到的会话历史记录(已规范化为输入项格式) - `new_input`:当前轮次的新输入项 返回应发送给模型的最终输入项列表。 -回调接收的是这两个列表的副本,因此您可以安全地修改它们。返回的列表控制该轮次的模型输入,但 SDK 仍只会持久化属于新轮次的项目。因此,对旧历史记录重新排序或进行筛选,不会导致旧会话项目再次作为新输入保存。 +回调接收的是两个列表的副本,因此可以安全地修改它们。返回的列表会控制该轮次的模型输入,但SDK仍只会持久化属于新轮次的项目。因此,对旧历史记录进行重新排序或筛选,不会导致旧会话项目再次作为新输入保存。 ```python from agents import Agent, RunConfig, Runner, SQLiteSession @@ -109,16 +109,16 @@ result = await Runner.run( ) ``` -当您需要自定义裁剪、重新排序或选择性地纳入历史记录,同时又不改变会话存储项目的方式时,请使用此功能。如果您需要在调用模型前立即执行后续的最终处理,请使用[运行智能体指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 +当你需要自定义历史记录的裁剪、重新排序或选择性包含方式,但不希望改变会话存储项目的方式时,请使用此功能。如果需要在调用模型前进行最后一次处理,请使用[运行智能体指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 ## 检索历史记录的限制 -使用[`SessionSettings`][agents.memory.SessionSettings]控制每次运行前获取的历史记录数量。 +使用[`SessionSettings`][agents.memory.SessionSettings]控制每次运行前获取的历史记录量。 - `SessionSettings(limit=None)`(默认):检索所有可用的会话项目 - `SessionSettings(limit=N)`:仅检索最近的`N`个项目 -您可以通过[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]按每次运行应用此设置: +你可以通过[`RunConfig.session_settings`][agents.run.RunConfig.session_settings]为每次运行应用此设置: ```python from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession @@ -134,7 +134,7 @@ result = await Runner.run( ) ``` -如果您的会话实现提供默认会话设置,则`RunConfig.session_settings`中每个非`None`值都会覆盖该次运行对应的默认值。这适用于长对话,可在不改变会话默认行为的情况下限制检索数量。 +如果会话实现提供默认会话设置,则`RunConfig.session_settings`中每个非`None`值都会覆盖该次运行对应的默认值。对于长对话,这很有用,因为你可以限制检索数量,而无需更改会话的默认行为。 ## 记忆操作 @@ -167,7 +167,7 @@ await session.clear_session() ### 使用 pop_item 进行更正 -当您想撤销或修改对话中的最后一个项目时,`pop_item`方法尤其有用: +当你希望撤销或修改对话中的最后一个项目时,`pop_item`方法特别有用: ```python from agents import Agent, Runner, SQLiteSession @@ -198,32 +198,32 @@ print(f"Agent: {result.final_output}") ## 内置会话实现 -SDK 针对不同用例提供了多种会话实现: +SDK针对不同用例提供了多种会话实现: ### 内置会话实现的选择 -在阅读下方详细示例之前,可使用此表选择起点。 +在阅读下方的详细代码示例前,可使用此表选择起点。 -| 会话类型 | 最适用场景 | 备注 | +| 会话类型 | 最适合 | 说明 | | --- | --- | --- | | `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可使用文件或内存作为后端 | -| `AsyncSQLiteSession` | 搭配`aiosqlite`使用异步 SQLite | 支持异步驱动程序的扩展后端 | -| `RedisSession` | 在工作进程或服务之间共享记忆 | 适合低延迟分布式部署 | -| `SQLAlchemySession` | 使用现有数据库的生产应用 | 适用于 SQLAlchemy 支持的数据库 | -| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;使用原子序列计数器排序 | -| `DaprSession` | 使用 Dapr sidecar 的云原生部署 | 支持多种状态存储以及 TTL 和一致性控制 | -| `OpenAIConversationsSession` | OpenAI中的服务器托管存储 | 由 OpenAI Conversations API 支持的历史记录 | -| `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 对另一会话后端的包装器 | -| `AdvancedSQLiteSession` | SQLite 以及分支和分析功能 | 功能集更丰富;请参阅专门页面 | -| `EncryptedSession` | 在另一会话上添加加密和 TTL | 包装器;请先选择底层后端 | +| `AsyncSQLiteSession` | 搭配`aiosqlite`使用异步SQLite | 支持异步驱动程序的扩展后端 | +| `RedisSession` | 在多个工作进程或服务之间共享记忆 | 适用于低延迟分布式部署 | +| `SQLAlchemySession` | 使用现有数据库的生产应用 | 支持SQLAlchemy兼容的数据库 | +| `MongoDBSession` | 已使用MongoDB或需要多进程存储的应用 | 异步pymongo;使用原子序列计数器确保顺序 | +| `DaprSession` | 使用Dapr边车的云原生部署 | 支持多种状态存储,以及TTL和一致性控制 | +| `OpenAIConversationsSession` | OpenAI中的服务器托管存储 | 由OpenAI Conversations API支持的历史记录 | +| `OpenAIResponsesCompactionSession` | 需要自动压缩的长对话 | 封装另一个会话后端 | +| `AdvancedSQLiteSession` | SQLite以及分支和分析功能 | 功能集更全面;请参阅专用页面 | +| `EncryptedSession` | 在另一个会话之上增加加密和TTL | 封装器;请先选择底层后端 | -部分实现有专门页面提供更多详细信息,其链接位于对应的小节中。 +某些实现有专门的页面提供更多详细信息;其子章节中包含对应的内联链接。 -如果您正在为 ChatKit 实现 Python 服务器,请使用`chatkit.store.Store`实现来持久化 ChatKit 的线程和项目。`SQLAlchemySession`等 Agents SDK 会话用于管理 SDK 侧的对话历史记录,但不能直接替代 ChatKit 的存储。请参阅[`chatkit-python` ChatKit 数据存储实现指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 +如果你正在为ChatKit实现Python服务器,请使用`chatkit.store.Store`实现来持久化ChatKit的线程和项目。`SQLAlchemySession`等Agents SDK会话用于管理SDK侧的对话历史记录,但不能直接替代ChatKit的存储。请参阅[有关实现ChatKit数据存储的`chatkit-python`指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 -### OpenAI Conversations API 会话 +### OpenAI Conversations API会话 -通过`OpenAIConversationsSession`使用[OpenAI的 Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 +通过`OpenAIConversationsSession`使用[OpenAI的Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 ```python from agents import Agent, Runner, OpenAIConversationsSession @@ -257,9 +257,9 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses 压缩会话 +### OpenAI Responses压缩会话 -使用`OpenAIResponsesCompactionSession`通过 Responses API(`responses.compact`)压缩已存储的对话历史记录。它包装一个底层会话,并可在每轮结束后根据`should_trigger_compaction`自动执行压缩。不要用它包装`OpenAIConversationsSession`;这两项功能采用不同方式管理历史记录。 +使用`OpenAIResponsesCompactionSession`通过Responses API(`responses.compact`)压缩已存储的对话历史记录。它会封装底层会话,并可根据`should_trigger_compaction`在每个轮次后自动执行压缩。不要用它封装`OpenAIConversationsSession`;这两项功能以不同方式管理历史记录。 #### 典型用法(自动压缩) @@ -278,19 +278,21 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -默认情况下,每轮结束后,SDK 会检查压缩候选内容是否达到阈值,且仅在达到阈值时执行压缩。 +默认情况下,每个轮次结束后,SDK都会检查压缩候选内容是否达到阈值,并仅在达到阈值时进行压缩。 -`compaction_mode="previous_response_id"`使用压缩会话保留的 Responses API 响应 ID,在该响应链仍然可用时效果最佳。`compaction_mode="input"`则根据当前会话项目重新构建压缩请求;当响应链不可用,或您希望以会话内容作为事实来源时,这种方式很有用。默认的`"auto"`会选择最安全的可用选项。 +自动压缩运行时,SDK会等待其完成,然后`Runner.run(...)`才会返回,或流式事件迭代器才会关闭。压缩请求报告的用量会计入该次运行的[`Usage`](../usage.md)总量。默认情况下,之后手动调用`run_compaction()`时没有所属的运行上下文,因此不会更新已完成运行的用量对象。 -如果您的智能体使用`ModelSettings(store=False)`运行,Responses API 不会保留最后一个响应供后续查询。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不是依赖`previous_response_id`。完整示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 +`compaction_mode="previous_response_id"`使用压缩会话保留的Responses API响应ID,并且在该响应链仍然可用时效果最佳。`compaction_mode="input"`则根据当前会话项目重新构建压缩请求,适用于响应链不可用,或希望以会话内容作为事实来源的情况。默认的`"auto"`会选择最安全的可用选项。 + +如果智能体使用`ModelSettings(store=False)`运行,Responses API不会保留最后一个响应以供后续查找。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不依赖`previous_response_id`。完整代码示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 #### 自动压缩对流式传输的阻塞 -压缩会清除并重写会话历史记录,因此 SDK 会等待压缩完成后才将运行视为完成。在流式传输模式下,如果压缩任务较重,这意味着最后一个输出 token 产生后,`run.stream_events()`可能还会保持打开数秒。 +压缩会清除并重写会话历史记录,因此SDK会等待压缩完成后,才会将运行视为已完成。在流式传输模式下,如果压缩任务较重,这意味着最后一个输出token生成后,`run.stream_events()`仍可能保持打开数秒。 -`OpenAIResponsesCompactionSession.run_compaction()`在包装器边界将清除并重写操作视为可恢复的替换。如果底层历史记录发生更改后,替换失败或被取消,包装器会尝试恢复之前的历史记录,并等待该恢复尝试完成,然后再将原始异常或取消传递给调用方。如果底层后端在恢复期间也发生故障,之前的历史记录可能无法恢复,SDK 会记录此次恢复失败。包装器会将对`add_items()`、`pop_item()`和`clear_session()`的调用与加锁的替换及恢复阶段串行执行;但远程压缩请求仍在进行时,修改操作可能已经完成,随后又被成功的替换操作覆盖。请在轮次之间且包装器没有并发修改操作时执行手动压缩,并且不要在压缩运行期间直接修改底层会话。 +`OpenAIResponsesCompactionSession.run_compaction()`会在封装器边界将清除并重写操作视为可恢复的替换。如果底层历史记录发生变化后,替换失败或被取消,封装器会尝试恢复先前的历史记录,并等待恢复尝试结束,然后再将原始异常或取消传递给调用方。如果底层后端在恢复过程中也失败,先前的历史记录可能仍无法恢复,SDK会记录该恢复失败。封装器会对`add_items()`、`pop_item()`和`clear_session()`的调用与受锁保护的替换及恢复阶段进行串行化,但在远程压缩请求仍在进行时,修改操作可能已经完成,并随后被成功的替换操作覆盖。请在轮次之间执行手动压缩,且不要并发修改封装器;压缩运行期间,不要直接修改底层会话。 -如果您需要低延迟流式传输或快速轮次交互,请禁用自动压缩,并在轮次之间(或空闲期间)自行调用`run_compaction()`。您可以根据自己的标准决定何时强制执行压缩。 +如果希望获得低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲时)自行调用`run_compaction()`。你可以根据自己的标准决定何时强制执行压缩。 ```python from agents import Agent, Runner, SQLiteSession @@ -311,9 +313,9 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite 会话 +### SQLite会话 -使用 SQLite 的默认轻量级会话实现: +使用SQLite的默认轻量级会话实现: ```python from agents import SQLiteSession @@ -332,9 +334,9 @@ result = await Runner.run( ) ``` -### 异步 SQLite 会话 +### 异步SQLite会话 -如果您希望 SQLite 持久化由`aiosqlite`提供支持,请使用`AsyncSQLiteSession`。 +如果希望使用由`aiosqlite`支持的SQLite持久化,请使用`AsyncSQLiteSession`。 ```bash pip install aiosqlite @@ -349,9 +351,9 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis 会话 +### Redis会话 -使用`RedisSession`在多个工作进程或服务之间共享会话记忆。 +使用`RedisSession`可在多个工作进程或服务之间共享会话记忆。 ```bash pip install openai-agents[redis] @@ -370,11 +372,11 @@ result = await Runner.run(agent, "Hello", session=session) await session.close() ``` -`from_url(...)`会创建并拥有 Redis 客户端。执行`close()`后,会话将进入终止状态,后续会话操作会引发`RuntimeError`;重复或并发调用`close()`是安全的。如果您的应用已经管理 Redis 客户端,请通过`redis_client=...`直接构造`RedisSession(...)`。在这种情况下,`close()`不执行任何操作,调用方仍拥有客户端所有权,并且会话可继续使用。 +`from_url(...)`会创建并拥有Redis客户端。调用`close()`后,会话将进入终止状态,后续会话操作会引发`RuntimeError`;重复或并发调用`close()`是安全的。如果应用已经管理Redis客户端,请直接使用`redis_client=...`构造`RedisSession(...)`。在这种情况下,`close()`不执行任何操作,调用方仍拥有客户端,并且会话仍可使用。 -### SQLAlchemy 会话 +### SQLAlchemy会话 -使用任何 SQLAlchemy 支持的数据库,实现可用于生产环境的 Agents SDK 会话持久化: +使用任何SQLAlchemy支持的数据库,实现适用于生产环境的Agents SDK会话持久化: ```python from agents.extensions.memory import SQLAlchemySession @@ -392,11 +394,11 @@ engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") session = SQLAlchemySession("user_123", engine=engine, create_tables=True) ``` -详细文档请参阅[SQLAlchemy 会话](sqlalchemy_session.md)。 +详细文档请参阅[SQLAlchemy会话](sqlalchemy_session.md)。 -### Dapr 会话 +### Dapr会话 -如果您已经运行 Dapr sidecar,或希望在不更改智能体代码的情况下切换已配置的状态存储后端,请使用`DaprSession`。 +如果已经运行Dapr边车,或希望无需更改智能体代码即可切换已配置的状态存储后端,请使用`DaprSession`。 ```bash pip install openai-agents[dapr] @@ -419,17 +421,17 @@ async with DaprSession.from_address( 注意事项: -- `from_address(...)`会为您创建并拥有 Dapr 客户端。如果您的应用已管理客户端,请通过`dapr_client=...`直接构造`DaprSession(...)`。 -- 退出上下文或调用`close()`会使拥有客户端的会话进入终止状态;后续会话操作会引发`RuntimeError`,而重复或并发调用`close()`是安全的。使用注入的客户端时,`close()`不执行任何操作,会话仍可继续使用。 -- 如果底层状态存储支持 TTL,请传入`ttl=...`,以自动对会话数据应用 TTL 过期机制。 +- `from_address(...)`会为你创建并拥有Dapr客户端。如果应用已经管理Dapr客户端,请直接使用`dapr_client=...`构造`DaprSession(...)`。 +- 退出上下文或调用`close()`会使拥有客户端的会话进入终止状态;后续会话操作会引发`RuntimeError`,而重复或并发调用`close()`是安全的。使用注入的客户端时,`close()`不执行任何操作,并且会话仍可使用。 +- 如果底层状态存储支持TTL,请传入`ttl=...`,以便自动对会话数据应用TTL过期机制。 - 需要更强的写后读保证时,请传入`consistency=DAPR_CONSISTENCY_STRONG`。 -- Dapr Python SDK 还会检查 HTTP sidecar 端点。在本地开发中,启动 Dapr 时,除了`dapr_address`中使用的 gRPC 端口外,还应指定`--dapr-http-port 3500`。 -- 有关完整的设置演练(包括本地组件和故障排除),请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 +- Dapr Python SDK还会检查HTTP边车端点。在本地开发中,除`dapr_address`所使用的gRPC端口外,启动Dapr时还需使用`--dapr-http-port 3500`。 +- 完整设置演练(包括本地组件和故障排除)请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 -### MongoDB 会话 +### MongoDB会话 -对于已经使用 MongoDB,或需要可水平扩展的多进程会话存储的应用,请使用`MongoDBSession`。 +对于已使用MongoDB,或需要可横向扩展的多进程会话存储的应用,请使用`MongoDBSession`。 ```bash pip install openai-agents[mongodb] @@ -454,14 +456,14 @@ await session.close() 注意事项: -- `from_uri(...)`会创建并拥有`AsyncMongoClient`,并在`session.close()`时将其关闭。执行`close()`后,拥有客户端的会话将进入终止状态,后续会话操作会引发`RuntimeError`。如果您的应用已管理客户端,请通过`client=...`直接构造`MongoDBSession(...)`;在这种情况下,`session.close()`不执行任何操作,调用方仍负责管理客户端生命周期,并且会话可继续使用。 -- 将`mongodb+srv://user:password@cluster.example.mongodb.net`URI 传入`from_uri(...)`,无需进行其他更改,即可连接到[MongoDB Atlas](https://www.mongodb.com/products/platform)。 -- 此实现使用两个集合,二者的名称均可配置:`sessions_collection=`(默认值为`agent_sessions`)和`messages_collection=`(默认值为`agent_messages`)。首次使用时会自动创建索引。每次非空的`add_items()`调用都会写入一个逻辑批次文档,其中单调递增的`seq`会根据该批次的最后一个项目对其排序;旧版的逐项目消息文档仍然可读。一个逻辑批次必须在 MongoDB 的单文档大小限制之内;过大的批次会以原子方式失败,不会存储部分批次。 +- `from_uri(...)`会创建并拥有`AsyncMongoClient`,并在调用`session.close()`时将其关闭。调用`close()`后,拥有客户端的会话将进入终止状态,后续会话操作会引发`RuntimeError`。如果应用已经管理客户端,请直接使用`client=...`构造`MongoDBSession(...)`;在这种情况下,`session.close()`不执行任何操作,调用方仍负责客户端生命周期,并且会话仍可使用。 +- 如需连接到[MongoDB Atlas](https://www.mongodb.com/products/platform),只需将`mongodb+srv://user:password@cluster.example.mongodb.net` URI传递给`from_uri(...)`,无需进行其他更改。 +- 此实现使用两个集合,二者的名称均可配置,分别通过`sessions_collection=`(默认为`agent_sessions`)和`messages_collection=`(默认为`agent_messages`)设置。首次使用时会自动创建索引。每次非空的`add_items()`调用都会写入一个逻辑批次文档,其单调递增的`seq`会按批次的最后一个项目对该批次排序;旧版的逐项目消息文档仍可读取。逻辑批次必须符合MongoDB的单文档大小限制;过大的批次会以原子方式失败,不会存储部分批次。 - 在首次运行前,使用`await session.ping()`验证连接。 -### 高级 SQLite 会话 +### 高级SQLite会话 -增强型 SQLite 会话,支持对话分支、用量分析和结构化查询: +增强型SQLite会话,支持对话分支、用量分析和结构化查询: ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -481,11 +483,11 @@ await session.store_run_usage(result) # Track token usage await session.create_branch_from_turn(2) # Branch from turn 2 ``` -详细文档请参阅[高级 SQLite 会话](advanced_sqlite_session.md)。 +详细文档请参阅[高级SQLite会话](advanced_sqlite_session.md)。 ### 加密会话 -适用于任何会话实现的透明加密包装器: +适用于任何会话实现的透明加密封装器: ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -512,13 +514,13 @@ result = await Runner.run(agent, "Hello", session=session) ### 其他会话类型 -还有一些其他内置选项。请参阅`examples/memory/`以及`extensions/memory/`下的源代码。 +此外还有一些其他内置选项。请参阅`examples/memory/`以及`extensions/memory/`下的源代码。 -## 操作模式 +## 运维模式 -### 会话 ID 命名 +### 会话ID命名 -使用有意义的会话 ID 来帮助组织对话: +使用有意义的会话ID来帮助组织对话: - 基于用户:`"user_12345"` - 基于线程:`"thread_abc123"` @@ -526,16 +528,16 @@ result = await Runner.run(agent, "Hello", session=session) ### 记忆持久化 -- 对临时对话使用内存 SQLite(`SQLiteSession("session_id")`) -- 对持久化对话使用基于文件的 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) -- 需要基于`aiosqlite`的实现时,使用异步 SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) -- 对共享的低延迟会话记忆使用 Redis 后端会话(`RedisSession.from_url("session_id", url="redis://...")`) -- 对使用 SQLAlchemy 所支持现有数据库的生产系统,使用由 SQLAlchemy 驱动的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) -- 对已使用 MongoDB 或需要可水平扩展的多进程会话存储的应用,使用 MongoDB 会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) -- 对生产环境的云原生部署,使用 Dapr 状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`),它内置遥测、追踪和数据隔离功能,并支持 30 多种数据库后端 -- 如果您希望将历史记录存储在 OpenAI Conversations API 中,请使用由OpenAI托管的存储(`OpenAIConversationsSession()`) -- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)为任意会话添加透明加密和基于 TTL 的过期机制 -- 对于更高级的用例,可考虑为其他生产系统(例如 Django)实现自定义会话后端 +- 对于临时对话,使用内存SQLite(`SQLiteSession("session_id")`) +- 对于持久化对话,使用基于文件的SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) +- 需要基于`aiosqlite`的实现时,使用异步SQLite(`AsyncSQLiteSession("session_id", db_path="...")`) +- 对于共享的低延迟会话记忆,使用Redis支持的会话(`RedisSession.from_url("session_id", url="redis://...")`) +- 对于已有SQLAlchemy所支持数据库的生产系统,使用由SQLAlchemy提供支持的会话(`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) +- 对于已使用MongoDB,或需要多进程、可横向扩展会话存储的应用,使用MongoDB会话(`MongoDBSession.from_uri("session_id", uri="mongodb://localhost:27017")`) +- 对于需要内置遥测、追踪和数据隔离,并需要支持30多种数据库后端的生产云原生部署,使用Dapr状态存储会话(`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) +- 如果希望将历史记录存储在OpenAI Conversations API中,请使用由OpenAI托管的存储(`OpenAIConversationsSession()`) +- 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)封装任何会话,以提供透明加密和基于TTL的过期机制 +- 对于更高级的用例,可考虑为其他生产系统(例如Django)实现自定义会话后端 ### 多个会话 @@ -581,9 +583,9 @@ result2 = await Runner.run( ) ``` -## 完整示例 +## 完整代码示例 -以下完整示例展示了会话记忆的实际使用方式: +下面是一个展示会话记忆实际运作方式的完整代码示例: ```python import asyncio @@ -647,7 +649,7 @@ if __name__ == "__main__": ## 自定义会话实现 -您可以创建一个在结构上遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话记忆。您无需继承`SessionABC`;只需定义`session_id`和`session_settings`,并直接实现四个历史记录方法: +你可以创建一个在结构上遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话记忆。无需继承`SessionABC`;请定义`session_id`和`session_settings`,并直接实现四个历史记录方法: ```python from agents import Agent, Runner, SessionSettings @@ -689,9 +691,9 @@ result = await Runner.run( ) ``` -### 自定义会话对运行上下文的访问 +### 从自定义会话访问运行上下文 -Agents SDK 可将活动的[`RunContextWrapper`][agents.run_context.RunContextWrapper]传递给自定义会话,以用于租户路由、授权或其他应用特定的存储决策。要让 Agents SDK 传递该包装器,请为全部四个历史记录方法添加一个具有显式名称且与关键字调用兼容的`wrapper`参数: +Agents SDK可以将当前的[`RunContextWrapper`][agents.run_context.RunContextWrapper]传递给自定义会话,用于租户路由、授权或其他应用特定的存储决策。若要让Agents SDK传递该封装器,请为所有四个历史记录方法添加一个具有显式名称且兼容关键字调用的`wrapper`参数: ```python from typing import Any @@ -728,30 +730,30 @@ class ContextAwareSession: ) -> None: ... ``` -仅当`get_items`、`add_items`、`pop_item`和`clear_session`都声明了`wrapper`时,Agents SDK 才会启用此集成。通用的`**kwargs`参数不满足此签名检查。省略`wrapper`的现有会话实现会保持其已发布的调用形式,并且无需更改即可继续工作。 +仅当`get_items`、`add_items`、`pop_item`和`clear_session`都声明`wrapper`时,Agents SDK才会启用此集成。通用的`**kwargs`参数不满足此签名检查。省略`wrapper`的现有会话实现会保留其已发布的调用形式,并可继续正常工作,无需更改。 ## 社区会话实现 -社区已开发出更多会话实现: +社区已开发更多会话实现: -| 软件包 | 描述 | +| 软件包 | 说明 | |---------|-------------| -| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 适用于任何 Django 所支持数据库(PostgreSQL、MySQL、SQLite 等)的 Django ORM 会话 | +| [openai-django-sessions](https://pypi.org/project/openai-django-sessions/) | 基于Django ORM的会话,适用于Django支持的任何数据库(PostgreSQL、MySQL、SQLite等) | -如果您构建了会话实现,欢迎提交文档 PR 将其添加到此处! +如果你构建了会话实现,欢迎提交文档PR,将其添加到这里! -## API 参考 +## API参考 -有关详细的 API 文档,请参阅: +详细API文档请参阅: - [`Session`][agents.memory.session.Session] - 协议接口 -- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 实现 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API 压缩包装器 -- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础 SQLite 实现 -- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于`aiosqlite`的异步 SQLite 实现 -- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis 后端会话实现 -- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 由 SQLAlchemy 驱动的实现 -- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB 后端会话实现 -- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr 状态存储实现 -- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析功能的增强型 SQLite -- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任意会话的加密包装器 \ No newline at end of file +- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API实现 +- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses API压缩封装器 +- [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - 基础SQLite实现 +- [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - 基于`aiosqlite`的异步SQLite实现 +- [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis支持的会话实现 +- [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 由SQLAlchemy提供支持的实现 +- [`MongoDBSession`][agents.extensions.memory.mongodb_session.MongoDBSession] - MongoDB支持的会话实现 +- [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr状态存储实现 +- [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 支持分支和分析的增强型SQLite实现 +- [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 适用于任何会话的加密封装器 \ No newline at end of file diff --git a/docs/zh/tracing.md b/docs/zh/tracing.md index 8e431427bd..7eda5d5dc6 100644 --- a/docs/zh/tracing.md +++ b/docs/zh/tracing.md @@ -4,51 +4,51 @@ search: --- # 追踪 -Agents SDK内置追踪功能,可收集智能体运行期间的完整事件记录:LLM生成、工具调用、任务转移、安全防护措施,乃至发生的自定义事件。借助[追踪仪表板](https://platform.openai.com/traces),您可以在开发和生产环境中调试、可视化并监控工作流。 +Agents SDK 内置了追踪功能,可收集智能体运行期间的完整事件记录:LLM 生成、工具调用、任务转移、安全防护措施,甚至包括发生的自定义事件。借助[追踪仪表板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化和监控工作流。 !!!note - 默认启用追踪。您可以通过以下三种常见方式禁用追踪: + 追踪默认启用。你可以通过以下三种常用方式将其禁用: - 1. 设置环境变量 `OPENAI_AGENTS_DISABLE_TRACING=1`,在全局范围内禁用追踪 - 2. 在代码中使用 [`set_tracing_disabled(True)`][agents.set_tracing_disabled],在全局范围内禁用追踪 + 1. 设置环境变量 `OPENAI_AGENTS_DISABLE_TRACING=1`,全局禁用追踪 + 2. 在代码中使用 [`set_tracing_disabled(True)`][agents.set_tracing_disabled],全局禁用追踪 3. 将 [`agents.run.RunConfig.tracing_disabled`][] 设置为 `True`,为单次运行禁用追踪 -***对于根据零数据保留(ZDR)政策使用OpenAI API的组织,追踪不可用。*** +***对于根据零数据保留(ZDR)政策使用OpenAI API 的组织,追踪功能不可用。*** -## 追踪与跨度 +## 追踪和跨度 - **追踪**表示一次“工作流”的端到端操作。它们由跨度组成。追踪具有以下属性: - - `workflow_name`:逻辑工作流或应用的名称。例如,“代码生成”或“客户服务”。 - - `trace_id`:追踪的唯一 ID。如果您未传入,则会自动生成。格式必须为 `trace_<32_alphanumeric>`。 - - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,您可以使用聊天线程 ID。 + - `workflow_name`:逻辑工作流或应用的名称。例如“代码生成”或“客户服务”。 + - `trace_id`:追踪的唯一 ID。如果未传入,则会自动生成。格式必须为 `trace_<32_alphanumeric>`。 + - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,你可以使用聊天会话 ID。 - `disabled`:如果为 True,则不会记录该追踪。 - `metadata`:追踪的可选元数据。 -- **跨度**表示具有开始和结束时间的操作。跨度具有: +- **跨度**表示具有开始和结束时间的操作。跨度包含: - `started_at` 和 `ended_at` 时间戳。 - `trace_id`,表示它们所属的追踪 - - `parent_id`,指向此跨度的父跨度(如果存在) - - `span_data`,即有关跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关LLM生成的信息,依此类推。 + - `parent_id`,指向此跨度的父跨度(如果有) + - `span_data`,即有关跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM 生成的信息,依此类推。 ## 默认追踪 默认情况下,SDK 会追踪以下内容: -- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在一个 `trace()` 中。 -- 每次运行器调用都封装在一个 `task_span()` 中。 -- 每个模型轮次都封装在一个 `turn_span()` 中。 -- 每次智能体运行时,都会封装在 `agent_span()` 中 -- LLM生成封装在 `generation_span()` 中 -- 每个函数工具调用都封装在 `function_span()` 中 +- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在 `trace()` 中。 +- 每次运行器调用都封装在 `task_span()` 中。 +- 每个模型轮次都封装在 `turn_span()` 中。 +- 智能体每次运行时,都会封装在 `agent_span()` 中 +- LLM 生成封装在 `generation_span()` 中 +- 每次函数工具调用都封装在 `function_span()` 中 - 安全防护措施封装在 `guardrail_span()` 中 - 任务转移封装在 `handoff_span()` 中 -- 音频输入(语音转文本)封装在一个 `transcription_span()` 中 -- 音频输出(文本转语音)封装在一个 `speech_span()` 中 -- SDK 可能会将相关音频跨度作为 `speech_group_span()` 的子跨度 +- 音频输入(语音转文本)封装在 `transcription_span()` 中 +- 音频输出(文本转语音)封装在 `speech_span()` 中 +- SDK 可能会将相关的音频跨度置于 `speech_group_span()` 下 -默认情况下,追踪名称是字面字符串 `Agent workflow`。如果您使用 `trace`,则可以设置此名称;也可以通过 [`RunConfig`][agents.run.RunConfig] 配置名称和其他属性。 +默认情况下,追踪名称是字面字符串 `Agent workflow`。如果使用 `trace`,你可以设置此名称;也可以通过 [`RunConfig`][agents.run.RunConfig] 配置名称和其他属性。 -如果您希望层级结构更紧凑,可禁用某次运行的自动任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 +如果希望层次结构更紧凑,可以为某次运行禁用自动创建的任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -此外,您可以设置[自定义追踪处理器](#custom-tracing-processors),将追踪推送到其他目标位置(作为替代目标或次要目标)。 +此外,你还可以设置[自定义追踪处理器](#custom-tracing-processors),将追踪发送到其他目标位置(作为替代目标或辅助目标)。 -## 长时间运行的工作器与即时导出 +## 长时运行工作进程和即时导出 -默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出追踪;当内存队列达到其大小触发阈值时,则会更早导出;此外,还会在进程退出时执行最后一次刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时间运行的工作器,这意味着通常无需任何额外代码即可自动导出追踪,但每项作业完成后,追踪可能不会立即显示在追踪仪表板中。 +默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出追踪;如果内存队列达到大小阈值,则会更早导出;进程退出时还会执行最终刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时运行的工作进程,这意味着通常无需任何额外代码即可自动导出追踪,但它们不一定会在每个作业完成后立即显示在追踪仪表板中。 -如果您需要确保在一个工作单元结束时立即交付,请在追踪上下文退出后调用 [`flush_traces()`][agents.tracing.flush_traces]。 +如果需要确保在一个工作单元结束时立即完成传送,请在退出追踪上下文后调用 [`flush_traces()`][agents.tracing.flush_traces]。 ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此请在 `trace()` 关闭后调用它,以免刷新尚未完整构建的追踪。如果默认导出延迟可以接受,则可以跳过此调用。 +[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此请在 `trace()` 关闭后调用它,以免刷新尚未构建完成的追踪。如果可以接受默认的导出延迟,则可以跳过此调用。 -## 高层级追踪 +## 更高层级的追踪 -有时,您可能希望对 `run()` 的多次调用都属于同一个追踪。为此,您可以将整个代码封装在一个 `trace()` 中。 +有时,你可能希望多次调用 `run()` 时都归入同一个追踪。为此,可以将整个代码封装在 `trace()` 中。 ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 由于对 `Runner.run` 的两次调用都封装在一个 `with trace()` 中,因此这两次运行会成为同一个整体追踪的一部分,而不是各自创建单独的追踪。 +1. 由于两次 `Runner.run` 调用都封装在 `with trace()` 中,因此两次运行会成为同一个整体追踪的一部分,而不是各自创建单独的追踪。 ## 追踪的创建 -您可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。为此,您有以下两种选择: +你可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。你可以通过以下两种方式完成: -1. **推荐**:将追踪用作上下文管理器,即 `with trace(...) as my_trace`。这样会在正确的时机自动启动和结束追踪。 -2. 您也可以手动调用 [`trace.start()`][agents.tracing.Trace.start] 和 [`trace.finish()`][agents.tracing.Trace.finish]。 +1. **推荐**:将追踪用作上下文管理器,即 `with trace(...) as my_trace`。这会在正确的时间自动启动和结束追踪。 +2. 你也可以手动调用 [`trace.start()`][agents.tracing.Trace.start] 和 [`trace.finish()`][agents.tracing.Trace.finish]。 -当前追踪通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它会自动支持并发。如果您手动启动和结束追踪,请将 `mark_as_current` 传给 `start()`,并将 `reset_current` 传给 `finish()`,以更新当前追踪。 +当前追踪通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它可自动支持并发。如果手动启动和结束追踪,请将 `mark_as_current` 传给 `start()`,并将 `reset_current` 传给 `finish()`,以更新当前追踪。 ## 跨度的创建 -您可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。一般而言,您无需手动创建跨度。您可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 +你可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。通常无需手动创建跨度。你可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 -跨度会自动成为当前追踪的一部分,并嵌套在最近的当前跨度之下;当前跨度通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 +跨度会自动成为当前追踪的一部分,并嵌套在距离最近的当前跨度下;当前跨度通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 ## 敏感数据 某些跨度可能会捕获潜在的敏感数据。 -`generation_span()` 会存储LLM生成的输入和输出,`function_span()` 会存储函数调用的输入和输出。这些内容可能包含敏感数据,因此您可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获这些数据。 +`generation_span()` 会存储 LLM 生成的输入和输出,而 `function_span()` 会存储函数调用的输入和输出。这些内容可能包含敏感数据,因此你可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获这些数据。 -同样,默认情况下,音频跨度会包含输入和输出音频的 base64 编码 PCM 数据。您可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data],禁止捕获这些音频数据。 +同样,默认情况下,音频跨度会包含输入和输出音频的 Base64 编码 PCM 数据。你可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] 禁止捕获这些音频数据。 -默认情况下,`trace_include_sensitive_data` 为 `True`。您可以在运行应用之前,将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,从而在不编写代码的情况下设置默认值。 +默认情况下,`trace_include_sensitive_data` 为 `True`。你可以在运行应用之前,将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,从而无需编写代码即可设置默认值。 ## 自定义追踪处理器 -追踪的高层级架构如下: +追踪功能的高层架构如下: -- 初始化时,我们会创建一个全局 [`TraceProvider`][agents.tracing.provider.TraceProvider],负责创建追踪。 -- 我们为 `TraceProvider` 配置一个 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],它会将追踪和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者会将跨度和追踪分批导出到OpenAI后端。 +- 初始化时,我们会创建一个全局 [`TraceProvider`][agents.tracing.provider.TraceProvider],用于创建追踪。 +- 我们为 `TraceProvider` 配置一个 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],它会将追踪和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者会将这些跨度和追踪分批导出到OpenAI后端。 -如果要自定义此默认设置,以便将追踪发送到替代或额外的后端,或修改导出器行为,您有以下两种选择: +如需自定义此默认设置,将追踪发送到其他或额外的后端,或者修改导出器行为,你有以下两个选项: -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 允许您添加一个**额外的**追踪处理器,该处理器会在追踪和跨度准备就绪时接收它们。这样,除了将追踪发送到OpenAI后端之外,您还可以自行处理它们。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许您使用自己的追踪处理器**替换**默认处理器。这意味着,除非您包含一个能够发送追踪的 `TracingProcessor`,否则追踪不会发送到OpenAI后端。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 允许你添加一个**额外的**追踪处理器,在追踪和跨度准备就绪时接收它们。这样,除了将追踪发送到OpenAI后端外,你还可以自行处理它们。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许你使用自己的追踪处理器**替换**默认处理器。这意味着,除非你包含一个可将追踪发送到OpenAI后端的 `TracingProcessor`,否则追踪不会发送到该后端。 ## 非OpenAI模型的追踪 -使用非OpenAI模型时,您可以向追踪导出器提供 OpenAI API 密钥,从而在不禁用追踪的情况下,在OpenAI追踪仪表板中启用免费追踪。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 +使用非OpenAI模型时,你可以向追踪导出器提供 OpenAI API 密钥,从而无需禁用追踪,即可在OpenAI追踪仪表板中使用免费追踪功能。有关适配器的选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -如果您只需要为单次运行使用不同的追踪密钥,请通过 `RunConfig` 传入该密钥,而不要更改全局导出器。 +如果只需为单次运行使用不同的追踪密钥,请通过 `RunConfig` 传入该密钥,而不要更改全局导出器。 ```python from agents import Runner, RunConfig @@ -197,21 +197,21 @@ await Runner.run( ) ``` -## 其他说明 -- 在OpenAI追踪仪表板中查看免费追踪。 +## 补充说明 +- 可在OpenAI追踪仪表板中查看免费的追踪记录。 ## 生态系统集成 -以下社区和供应商集成支持OpenAI Agents SDK的追踪 API 接口。 +以下社区和供应商集成支持 OpenAI Agents SDK 的追踪 API 接口。 ### 外部追踪处理器列表 - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) -- [MLflow (self-hosted/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow (Databricks hosted)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) +- [MLflow(自托管/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow(Databricks 托管)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) @@ -234,4 +234,5 @@ await Runner.run( - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) - [Latitude](https://docs.latitude.so/telemetry/frameworks/openai-agents) -- [DProvenanceKit](https://dprovenance.dev/openai-agents/) \ No newline at end of file +- [DProvenanceKit](https://dprovenance.dev/openai-agents/) +- [Tuning Engines](https://github.com/cerebrixos-org/tuning-engines-cli/tree/main/packages/tuning-agents#openai-agents-sdk) \ No newline at end of file diff --git a/docs/zh/usage.md b/docs/zh/usage.md index 4984df52da..90cd73b0b2 100644 --- a/docs/zh/usage.md +++ b/docs/zh/usage.md @@ -4,13 +4,13 @@ search: --- # 用量 -Agents SDK 会自动追踪每次运行的 token 用量。你可以从运行上下文中访问这些信息,并用其监控成本、实施限制或记录分析数据。 +Agents SDK会自动追踪每次运行的令牌用量。你可以从运行上下文中访问这些数据,并用其监控成本、执行限额或记录分析数据。 ## 追踪内容 -- **requests**:发起的 LLM API 调用次数 -- **input_tokens**:发送的输入 token 总数 -- **output_tokens**:接收的输出 token 总数 +- **requests**:LLM API调用次数 +- **input_tokens**:发送的输入令牌总数 +- **output_tokens**:接收的输出令牌总数 - **total_tokens**:输入 + 输出 - **request_usage_entries**:每个请求的用量明细列表 - **details**: @@ -18,9 +18,9 @@ Agents SDK 会自动追踪每次运行的 token 用量。你可以从运行上 - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 从运行中访问用量 +## 运行用量的访问 -执行 `Runner.run(...)` 后,通过 `result.context_wrapper.usage` 访问用量。 +在 `Runner.run(...)` 执行后,通过 `result.context_wrapper.usage` 访问用量。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -34,18 +34,20 @@ print("Total tokens:", usage.total_tokens) 用量会汇总运行期间的所有模型调用,包括生成工具调用或任务转移的模型调用。 -### 为第三方适配器启用用量统计 +当 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 在运行结束前自动压缩历史记录时,该 `responses.compact` 请求报告的用量也会添加到同一次运行的总量中。在运行之外手动调用 `run_compaction()` 时,不存在相应的运行上下文,因此不会更新此前运行返回的用量对象。请参阅 [OpenAI响应压缩会话](sessions/index.md#openai-responses-compaction-sessions)。 -不同第三方适配器和提供商后端的用量报告方式有所不同。如果你通过第三方适配器访问模型,并且需要准确的 `result.context_wrapper.usage` 值: +### 第三方适配器的用量启用 -- 使用 `AnyLLMModel` 时,如果上游提供商返回用量信息,该信息会自动传递。通过 Chat Completions 后端进行流式响应时,可能需要设置 `ModelSettings(include_usage=True)`,以发送用量数据块。 +不同第三方适配器和提供商后端的用量报告方式各不相同。如果你通过第三方适配器访问模型,并且需要准确的 `result.context_wrapper.usage` 值: + +- 使用 `AnyLLMModel` 时,如果上游提供商返回用量数据,系统会自动传递这些数据。从Chat Completions后端流式传输响应时,可能需要设置 `ModelSettings(include_usage=True)` 才能发送用量数据块。 - 使用 `LitellmModel` 时,某些提供商后端默认不报告用量,因此通常需要设置 `ModelSettings(include_usage=True)`。 -请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)部分的适配器特定说明,并在计划部署的具体提供商后端上验证用量报告。 +请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)一节的适配器专属说明,并在计划部署的具体提供商后端上验证用量报告。 -## 按请求追踪用量 +## 按请求的用量追踪 -SDK 会自动在 `request_usage_entries` 中追踪每个 API 请求的用量,这有助于详细计算成本和监控上下文窗口消耗。 +SDK会在 `request_usage_entries` 中自动追踪每个 API 请求的用量,这有助于进行详细的成本计算和监控上下文窗口消耗。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -54,9 +56,9 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 提供商用量有效载荷的保留 +## 提供商用量载荷的保留 -Agents SDK 会将提供商用量标准化为 [`Usage`][agents.usage.Usage] 字段,从而在不同模型提供商之间提供一致的总量。当应用必须保留提供商特定的用量字段,或需要区分字段被省略与提供商报告值为零时,请将 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] 设置为 `True`: +Agents SDK会将提供商用量标准化为 [`Usage`][agents.usage.Usage] 字段,从而在不同模型提供商之间提供一致的用量总计。当应用必须保留提供商特有的用量字段,或区分被省略的字段与提供商报告的零值时,请将 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] 设置为 `True`: ```python from agents import Agent, ModelSettings, Runner @@ -71,15 +73,15 @@ for response in result.raw_responses: print(response.raw_usage) ``` -Agents SDK 会将每个 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 值存储为该模型调用的提供商有效载荷的独立、兼容 JSON 的快照。Agents SDK 不会在整个运行期间汇总 `raw_usage`。当禁用保留功能、提供商未返回用量有效载荷,或上游适配器已经丢弃原始字段存在性信息时,该值会保持为 `None`。 +Agents SDK会将每个 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 值存储为该次模型调用中提供商载荷的独立 JSON 兼容快照。Agents SDK不会在整个运行期间汇总 `raw_usage`。当禁用保留、提供商未返回用量载荷,或上游适配器已丢弃原始字段存在性信息时,该值仍为 `None`。 -`preserve_raw_usage` 仅保留已传递至模型适配器的用量有效载荷;该设置不会向提供商请求用量信息。当流式 Chat Completions 提供商要求显式请求用量信息时,还需设置 `ModelSettings(include_usage=True)`。 +`preserve_raw_usage` 仅保留到达模型适配器的用量载荷;此设置不会向提供商请求用量数据。当流式Chat Completions提供商要求显式请求用量时,还需设置 `ModelSettings(include_usage=True)`。 -目前,无论是流式还是非流式运行,`LitellmModel` 都不会填充 `ModelResponse.raw_usage`,因此 `preserve_raw_usage=True` 对该适配器不起作用。使用 `LitellmModel` 时,请继续使用标准化的 [`Usage`][agents.usage.Usage] 字段;如果需要保留提供商特定字段的存在性信息,请选择支持保留原始用量的适配器。 +`LitellmModel` 目前不会在流式或非流式运行中填充 `ModelResponse.raw_usage`,因此 `preserve_raw_usage=True` 对该适配器无效。使用 `LitellmModel` 时,请继续使用标准化的 [`Usage`][agents.usage.Usage] 字段;如果需要保留提供商特有的字段存在性信息,请选择支持保留原始用量的适配器。 -## 通过会话访问用量 +## 会话中的用量访问 -使用 `Session`(例如 `SQLiteSession`)时,每次调用 `Runner.run(...)` 都会返回该次特定运行的用量。会话会保留对话历史以提供上下文,但每次运行的用量彼此独立。 +使用 `Session`(例如 `SQLiteSession`)时,每次调用 `Runner.run(...)` 都会返回该次特定运行的用量。会话会保留对话历史记录以提供上下文,但每次运行的用量相互独立。 ```python session = SQLiteSession("my_conversation") @@ -91,11 +93,11 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -请注意,虽然会话会在不同运行之间保留对话上下文,但每次调用 `Runner.run()` 返回的用量指标仅代表该次执行。在会话中,先前的消息可能会在每次运行时再次作为输入提供,这会影响后续轮次的输入 token 数量。 +请注意,尽管会话会在多次运行之间保留对话上下文,但每次调用 `Runner.run()` 返回的用量指标仅代表该次执行。在会话中,之前的消息可能会作为输入重新提供给每次运行,这会影响后续轮次的输入令牌数量。 -## 在钩子中使用用量 +## 钩子中的用量使用 -如果你使用 `RunHooks`,传递给每个钩子的 `context` 对象都包含 `usage`。借助该对象,你可以在关键生命周期节点记录用量。 +如果你使用 `RunHooks`,传递给每个钩子的 `context` 对象都包含 `usage`。借助此对象,你可以在生命周期的关键时刻记录用量。 ```python class MyHooks(RunHooks): @@ -109,6 +111,6 @@ class MyHooks(RunHooks): 有关详细的 API 文档,请参阅: - [`Usage`][agents.usage.Usage] - 用量追踪数据结构 -- [`RequestUsage`][agents.usage.RequestUsage] - 按请求统计的用量详情 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文中访问用量 +- [`RequestUsage`][agents.usage.RequestUsage] - 每个请求的用量详情 +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文访问用量 - [`RunHooks`][agents.run.RunHooks] - 接入用量追踪生命周期 \ No newline at end of file From 37a7aa20cee5f16d3720214c39dc66ca9f143e74 Mon Sep 17 00:00:00 2001 From: li2631026381-alt Date: Mon, 17 Aug 2026 12:53:50 +0800 Subject: [PATCH 348/473] fix(sandbox): require apply_patch update hunks (#4470) --- .../capabilities/tools/apply_patch_tool.py | 4 +- .../capabilities/test_apply_patch_tool.py | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py index 6bfeeb4163..bcf4462c1a 100644 --- a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py +++ b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py @@ -28,7 +28,7 @@ hunk: add_hunk | delete_hunk | update_hunk add_hunk: "*** Add File: " filename LF add_line+ delete_hunk: "*** Delete File: " filename LF -update_hunk: "*** Update File: " filename LF change_move? change? +update_hunk: "*** Update File: " filename LF change_move? change filename: /(.+)/ add_line: "+" /(.*)/ LF -> line @@ -94,7 +94,7 @@ FileOp := AddFile | DeleteFile | UpdateFile AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE } DeleteFile := "*** Delete File: " path NEWLINE -UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk } +UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] Hunk { Hunk } MoveTo := "*** Move to: " newPath NEWLINE Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ] HunkLine := (" " | "-" | "+") text NEWLINE diff --git a/tests/sandbox/capabilities/test_apply_patch_tool.py b/tests/sandbox/capabilities/test_apply_patch_tool.py index db877dea7f..c0f5f46ad8 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -41,6 +41,20 @@ def test_exposes_custom_apply_patch_tool(self) -> None: assert tool.tool_config["format"]["type"] == "grammar" assert tool.tool_config["format"]["syntax"] == "lark" + def test_grammar_requires_update_diff_after_optional_move(self) -> None: + tool = SandboxApplyPatchTool(session=scripted_sandbox_session()) + + grammar = cast(dict[str, Any], tool.tool_config["format"])["definition"] + assert isinstance(grammar, str) + update_rule = next(line for line in grammar.splitlines() if line.startswith("update_hunk:")) + assert update_rule == 'update_hunk: "*** Update File: " filename LF change_move? change' + + description = tool.tool_config["description"] + assert isinstance(description, str) + assert ( + 'UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] Hunk { Hunk }' in description + ) + def test_converter_uses_sandbox_custom_apply_patch_tool_config(self) -> None: tool = SandboxApplyPatchTool(session=scripted_sandbox_session()) @@ -54,6 +68,30 @@ def test_converter_uses_sandbox_custom_apply_patch_tool_config(self) -> None: assert "A full patch can combine several operations" in description tool_format = cast(dict[str, Any], converted.tools[0]["format"]) assert tool_format["syntax"] == "lark" + assert tool_format["definition"] == tool.tool_config["format"]["definition"] + + @pytest.mark.parametrize( + "update_body", + [ + "", + "*** Move to: moved.txt\n", + ], + ids=["empty", "move-only"], + ) + @pytest.mark.asyncio + async def test_runtime_rejects_updates_without_diff(self, update_body: str) -> None: + tool = SandboxApplyPatchTool(session=scripted_sandbox_session()) + + result = await _execute_custom_tool_call( + tool, + context_wrapper=make_context_wrapper(), + raw_input=( + f"*** Begin Patch\n*** Update File: notes.txt\n{update_body}*** End Patch\n" + ), + ) + + assert isinstance(result, ToolCallOutputItem) + assert "Update File patch for notes.txt must include a hunk" in result.output def test_needs_approval_exposes_operation_typed_setting(self) -> None: async def needs_approval( From d40f5d9832c657d64ef1bd858fd0a977eec6262e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 17 Aug 2026 17:56:36 +0900 Subject: [PATCH 349/473] ci: align Python version coverage (#4475) --- .github/workflows/docs.yml | 1 + .github/workflows/publish.yml | 1 + .github/workflows/release-tag.yml | 2 +- .github/workflows/tests.yml | 25 ++++++--- uv.lock | 86 ++++++++++++++++++------------- 5 files changed, 71 insertions(+), 44 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8992ff41dd..f6d73d651e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,6 +41,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true + python-version: "3.14" - name: Install dependencies if: steps.docs-only.outputs.skip != 'true' run: make sync diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9085f075c5..a32e7eb23a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,6 +28,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true + python-version: "3.14" - name: Install dependencies run: make sync - name: Build package diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 483cb17a16..e2e7d5fcc8 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -34,7 +34,7 @@ jobs: - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: - python-version: "3.11" + python-version: "3.14" - name: Configure git run: | git config user.name "github-actions[bot]" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f56ce64498..d6d9156b94 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true + python-version: "3.14" - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync @@ -59,6 +60,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true + python-version: "3.14" - name: Restore mypy cache if: steps.changes.outputs.run == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -109,10 +111,10 @@ jobs: if: steps.changes.outputs.run == 'true' run: make sync - name: Run tests with coverage - if: steps.changes.outputs.run == 'true' && matrix.python-version == '3.12' + if: steps.changes.outputs.run == 'true' && matrix.python-version == '3.14' run: make coverage - name: Run tests - if: steps.changes.outputs.run == 'true' && matrix.python-version != '3.12' + if: steps.changes.outputs.run == 'true' && matrix.python-version != '3.14' run: make tests - name: Run async teardown stability tests if: steps.changes.outputs.run == 'true' && (matrix.python-version == '3.10' || matrix.python-version == '3.14') @@ -139,7 +141,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true - python-version: "3.12" + python-version: "3.14" - name: Run packaged MCP v1 compatibility tests if: steps.changes.outputs.run == 'true' run: make integration-tests-mcp-v1 @@ -193,7 +195,7 @@ jobs: runs-on: windows-latest timeout-minutes: 15 env: - OPENAI_AGENTS_INTEGRATION_PYTHON: "3.13" + OPENAI_AGENTS_INTEGRATION_PYTHON: "3.14" OPENAI_API_KEY: fake-for-tests steps: - name: Checkout repository @@ -209,7 +211,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true - python-version: "3.13" + python-version: "3.14" - name: Download prospective release contract if: steps.changes.outputs.run == 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -229,7 +231,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 env: - OPENAI_AGENTS_INTEGRATION_PYTHON: "3.12" OPENAI_API_KEY: fake-for-tests steps: - name: Checkout repository @@ -244,7 +245,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true - python-version: "3.12" + python-version: "3.14" - name: Install all optional dependencies if: steps.changes.outputs.run == 'true' run: make sync @@ -267,6 +268,13 @@ jobs: tests-windows: runs-on: windows-latest timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + # Keep Python 3.13 here because Daytona's locked obstore dependency has no Windows Python 3.14 wheel, so 3.14 builds it from source and exceeds this job's timeout. + python-version: + - "3.10" + - "3.13" env: OPENAI_API_KEY: fake-for-tests steps: @@ -283,7 +291,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true - python-version: "3.13" + python-version: ${{ matrix.python-version }} - name: Install dependencies if: steps.changes.outputs.run == 'true' run: uv sync --all-extras --all-packages --group dev @@ -312,6 +320,7 @@ jobs: version: "0.11.14" enable-cache: true prune-cache: true + python-version: "3.14" - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync diff --git a/uv.lock b/uv.lock index 9984ac6671..099b9bfec5 100644 --- a/uv.lock +++ b/uv.lock @@ -226,45 +226,61 @@ wheels = [ [[package]] name = "asyncpg" -version = "0.30.0" +version = "0.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/07/1650a8c30e3a5c625478fa8aafd89a8dd7d85999bf7169b16f54973ebf2c/asyncpg-0.30.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfb4dd5ae0699bad2b233672c8fc5ccbd9ad24b89afded02341786887e37927e", size = 673143, upload-time = "2024-10-20T00:29:08.846Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9a/568ff9b590d0954553c56806766914c149609b828c426c5118d4869111d3/asyncpg-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc1f62c792752a49f88b7e6f774c26077091b44caceb1983509edc18a2222ec0", size = 645035, upload-time = "2024-10-20T00:29:12.02Z" }, - { url = "https://files.pythonhosted.org/packages/de/11/6f2fa6c902f341ca10403743701ea952bca896fc5b07cc1f4705d2bb0593/asyncpg-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3152fef2e265c9c24eec4ee3d22b4f4d2703d30614b0b6753e9ed4115c8a146f", size = 2912384, upload-time = "2024-10-20T00:29:13.644Z" }, - { url = "https://files.pythonhosted.org/packages/83/83/44bd393919c504ffe4a82d0aed8ea0e55eb1571a1dea6a4922b723f0a03b/asyncpg-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7255812ac85099a0e1ffb81b10dc477b9973345793776b128a23e60148dd1af", size = 2947526, upload-time = "2024-10-20T00:29:15.871Z" }, - { url = "https://files.pythonhosted.org/packages/08/85/e23dd3a2b55536eb0ded80c457b0693352262dc70426ef4d4a6fc994fa51/asyncpg-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:578445f09f45d1ad7abddbff2a3c7f7c291738fdae0abffbeb737d3fc3ab8b75", size = 2895390, upload-time = "2024-10-20T00:29:19.346Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/fa96c8f4877d47dc6c1864fef5500b446522365da3d3d0ee89a5cce71a3f/asyncpg-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c42f6bb65a277ce4d93f3fba46b91a265631c8df7250592dd4f11f8b0152150f", size = 3015630, upload-time = "2024-10-20T00:29:21.186Z" }, - { url = "https://files.pythonhosted.org/packages/34/00/814514eb9287614188a5179a8b6e588a3611ca47d41937af0f3a844b1b4b/asyncpg-0.30.0-cp310-cp310-win32.whl", hash = "sha256:aa403147d3e07a267ada2ae34dfc9324e67ccc4cdca35261c8c22792ba2b10cf", size = 568760, upload-time = "2024-10-20T00:29:22.769Z" }, - { url = "https://files.pythonhosted.org/packages/f0/28/869a7a279400f8b06dd237266fdd7220bc5f7c975348fea5d1e6909588e9/asyncpg-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb622c94db4e13137c4c7f98834185049cc50ee01d8f657ef898b6407c7b9c50", size = 625764, upload-time = "2024-10-20T00:29:25.882Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0e/f5d708add0d0b97446c402db7e8dd4c4183c13edaabe8a8500b411e7b495/asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a", size = 674506, upload-time = "2024-10-20T00:29:27.988Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a0/67ec9a75cb24a1d99f97b8437c8d56da40e6f6bd23b04e2f4ea5d5ad82ac/asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed", size = 645922, upload-time = "2024-10-20T00:29:29.391Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d9/a7584f24174bd86ff1053b14bb841f9e714380c672f61c906eb01d8ec433/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a", size = 3079565, upload-time = "2024-10-20T00:29:30.832Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/a4c0f9660e333114bdb04d1a9ac70db690dd4ae003f34f691139a5cbdae3/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956", size = 3109962, upload-time = "2024-10-20T00:29:33.114Z" }, - { url = "https://files.pythonhosted.org/packages/3c/21/199fd16b5a981b1575923cbb5d9cf916fdc936b377e0423099f209e7e73d/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056", size = 3064791, upload-time = "2024-10-20T00:29:34.677Z" }, - { url = "https://files.pythonhosted.org/packages/77/52/0004809b3427534a0c9139c08c87b515f1c77a8376a50ae29f001e53962f/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454", size = 3188696, upload-time = "2024-10-20T00:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/fbad941cd466117be58b774a3f1cc9ecc659af625f028b163b1e646a55fe/asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d", size = 567358, upload-time = "2024-10-20T00:29:37.915Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0a/0a32307cf166d50e1ad120d9b81a33a948a1a5463ebfa5a96cc5606c0863/asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f", size = 629375, upload-time = "2024-10-20T00:29:39.987Z" }, - { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162, upload-time = "2024-10-20T00:29:41.88Z" }, - { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025, upload-time = "2024-10-20T00:29:43.352Z" }, - { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243, upload-time = "2024-10-20T00:29:44.922Z" }, - { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059, upload-time = "2024-10-20T00:29:46.891Z" }, - { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596, upload-time = "2024-10-20T00:29:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632, upload-time = "2024-10-20T00:29:50.768Z" }, - { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186, upload-time = "2024-10-20T00:29:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064, upload-time = "2024-10-20T00:29:53.757Z" }, - { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, - { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] [[package]] From 9ecfdfa49844c3666df620ac77b383e49500c5a8 Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Mon, 17 Aug 2026 23:39:21 +0300 Subject: [PATCH 350/473] test: use sys.executable instead of tee in tests (#4478) --- tests/mcp/helpers.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py index 6e7d080b2d..47c6dbc85f 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -2,7 +2,7 @@ import asyncio import json -import shutil +import sys from typing import Any from mcp import Tool as MCPToolType @@ -24,8 +24,7 @@ from .model_compat import ListResourceTemplatesResult, Tool as MCPTool -tee = shutil.which("tee") or "" -assert tee, "tee not found" +tee = sys.executable # Added dummy stream classes for patching stdio_client to avoid real I/O during tests From f5491c5cbb2c7691228ab20ce52f934ffd5e25bf Mon Sep 17 00:00:00 2001 From: abhijeet sharma Date: Tue, 18 Aug 2026 02:13:47 +0530 Subject: [PATCH 351/473] docs: correct Agent.clone list attribute semantics (#4474) --- src/agents/agent.py | 17 ++++++--- src/agents/realtime/agent.py | 17 ++++++--- tests/test_agent_clone_shallow_copy.py | 49 ++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index d0df7267a8..72d3e03265 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -548,11 +548,18 @@ def __post_init__(self): def clone(self, **kwargs: Any) -> Agent[TContext]: """Make a copy of the agent, with the given arguments changed. Notes: - - Uses `dataclasses.replace`, which performs a **shallow copy**. - - Mutable attributes like `tools` and `handoffs` are shallow-copied: - new list objects are created only if overridden, but their contents - (tool functions and handoff objects) are shared with the original. - - To modify these independently, pass new lists when calling `clone()`. + - Uses `dataclasses.replace`, which performs a **shallow copy** and never copies a + list attribute such as `tools`, `handoffs`, `mcp_servers`, `input_guardrails`, or + `output_guardrails`. Each of those attributes is whatever the merged arguments hold. + - An attribute you do not pass arrives as the original agent's own list, so both + agents hold that one list and its entries. Appending through either agent, for + example `cloned.tools.append(extra_tool)`, therefore also changes the other. + - An attribute you do pass is used exactly as given, so it shares a list or an entry + with the original agent only where you reused one. `agent.clone(tools=agent.tools)` + still shares that list, while `agent.clone(tools=[other_tool])` shares nothing. + - To give the clone a list that no other agent holds, pass a new one, for example + `agent.clone(tools=[*agent.tools, extra_tool])`. The entries copied into it remain + the same objects the original agent holds. Example: ```python new_agent = agent.clone(instructions="New instructions") diff --git a/src/agents/realtime/agent.py b/src/agents/realtime/agent.py index 0fcead874b..aa325eff02 100644 --- a/src/agents/realtime/agent.py +++ b/src/agents/realtime/agent.py @@ -103,11 +103,18 @@ def clone(self, **kwargs: Any) -> RealtimeAgent[TContext]: """Make a copy of the agent, with the given arguments changed. Notes: - - Uses `dataclasses.replace`, which performs a **shallow copy**. - - Mutable attributes like `tools` and `handoffs` are shallow-copied: - new list objects are created only if overridden, but their contents - (tool functions and handoff objects) are shared with the original. - - To modify these independently, pass new lists when calling `clone()`. + - Uses `dataclasses.replace`, which performs a **shallow copy** and never copies a + list attribute such as `tools`, `handoffs`, `mcp_servers`, or `output_guardrails`. + Each of those attributes is whatever the merged arguments hold. + - An attribute you do not pass arrives as the original agent's own list, so both + agents hold that one list and its entries. Appending through either agent, for + example `cloned.tools.append(extra_tool)`, therefore also changes the other. + - An attribute you do pass is used exactly as given, so it shares a list or an entry + with the original agent only where you reused one. `agent.clone(tools=agent.tools)` + still shares that list, while `agent.clone(tools=[other_tool])` shares nothing. + - To give the clone a list that no other agent holds, pass a new one, for example + `agent.clone(tools=[*agent.tools, extra_tool])`. The entries copied into it remain + the same objects the original agent holds. Example: ```python diff --git a/tests/test_agent_clone_shallow_copy.py b/tests/test_agent_clone_shallow_copy.py index 44b41bd3d0..79559898b2 100644 --- a/tests/test_agent_clone_shallow_copy.py +++ b/tests/test_agent_clone_shallow_copy.py @@ -30,3 +30,52 @@ def test_agent_clone_shallow_copy(): assert cloned.tools[0] is original.tools[0], "Tool objects should be same instance" assert cloned.handoffs is not original.handoffs, "Handoffs should be different list" assert cloned.handoffs[0] is original.handoffs[0], "Handoff objects should be same instance" + + +def test_agent_clone_keeps_list_attributes_it_is_not_given(): + """An attribute that clone() is not given arrives as the original agent's own list.""" + target_agent = Agent(name="Target") + original = Agent(name="Original", tools=[greet], handoffs=[handoff(target_agent)]) + + cloned = original.clone(name="Cloned") + + assert cloned.tools is original.tools + assert cloned.handoffs is original.handoffs + + +def test_agent_clone_uses_a_given_list_as_is(): + """An attribute passed to clone() is used exactly as given, entries included.""" + + @function_tool + def farewell(name: str) -> str: + return f"Goodbye, {name}!" + + original = Agent(name="Original", tools=[greet]) + supplied = [farewell] + + cloned = original.clone(name="Cloned", tools=supplied) + + assert cloned.tools is supplied + assert original.tools == [greet] + # Passing a list does not by itself share entries with the original agent. + assert all(tool is not greet for tool in cloned.tools) + + +def test_agent_clone_still_shares_when_given_the_original_list(): + """Passing the original agent's own list keeps both agents on that one list.""" + original = Agent(name="Original", tools=[greet]) + + cloned = original.clone(name="Cloned", tools=original.tools) + + assert cloned.tools is original.tools + + +def test_agent_clone_shared_list_mutation_affects_both_agents(): + """Appending through either agent changes the other while they hold one list.""" + original = Agent(name="Original", tools=[greet]) + cloned = original.clone(name="Cloned") + + cloned.tools.append(greet) + + assert original.tools == cloned.tools + assert len(original.tools) == 2 From 057ab1019aa3d7e0e5984aa4166c497256567f46 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:08:50 +0100 Subject: [PATCH 352/473] fix(core): preserve Griffe logger inheritance (#4494) --- src/agents/function_schema.py | 2 +- tests/test_function_schema_logger_restore.py | 26 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/test_function_schema_logger_restore.py diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 26d6d1f3b8..378715dcb4 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -139,7 +139,7 @@ def _detect_docstring_style(doc: str) -> DocstringStyle: def _suppress_griffe_logging(): # Suppresses warnings about missing annotations for params logger = logging.getLogger("griffe") - previous_level = logger.getEffectiveLevel() + previous_level = logger.level logger.setLevel(logging.ERROR) try: yield diff --git a/tests/test_function_schema_logger_restore.py b/tests/test_function_schema_logger_restore.py new file mode 100644 index 0000000000..c67acbacb4 --- /dev/null +++ b/tests/test_function_schema_logger_restore.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import logging + +from agents.function_schema import _suppress_griffe_logging + + +def test_suppress_griffe_logging_restores_configured_notset_level() -> None: + logger = logging.getLogger("griffe") + root_logger = logging.getLogger() + previous_logger_level = logger.level + previous_root_level = root_logger.level + + try: + logger.setLevel(logging.NOTSET) + root_logger.setLevel(logging.WARNING) + assert logger.getEffectiveLevel() == logging.WARNING + + with _suppress_griffe_logging(): + assert logger.level == logging.ERROR + + assert logger.level == logging.NOTSET + assert logger.getEffectiveLevel() == logging.WARNING + finally: + logger.setLevel(previous_logger_level) + root_logger.setLevel(previous_root_level) From a77d37e6bc8ea81757dc79b66fe6074eca51f25b Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:18:47 +0100 Subject: [PATCH 353/473] fix(core): reject ignored explicit-client options for OpenAIProvider (#4497) --- src/agents/models/openai_provider.py | 9 ++++--- tests/test_openai_provider_client_options.py | 28 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 tests/test_openai_provider_client_options.py diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index acb944b566..642e99df68 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -88,10 +88,13 @@ def __init__( chunk semantics are not reliable enough for incremental processing. """ if openai_client is not None: - if api_key is not None or base_url is not None or websocket_base_url is not None: + if any( + value is not None + for value in (api_key, base_url, websocket_base_url, organization, project) + ): raise UserError( - "Don't provide api_key, base_url, or websocket_base_url if you provide " - "openai_client" + "Don't provide api_key, base_url, websocket_base_url, organization, or project " + "if you provide openai_client" ) self._client: AsyncOpenAI | None = openai_client else: diff --git a/tests/test_openai_provider_client_options.py b/tests/test_openai_provider_client_options.py new file mode 100644 index 0000000000..3f3683d06b --- /dev/null +++ b/tests/test_openai_provider_client_options.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any, cast + +import pytest +from openai import AsyncOpenAI + +from agents.exceptions import UserError +from agents.models.openai_provider import OpenAIProvider + + +@pytest.mark.parametrize( + "client_option", + [ + {"organization": "org-test"}, + {"project": "proj-test"}, + ], +) +def test_openai_provider_rejects_ignored_options_with_explicit_client( + client_option: dict[str, str], +) -> None: + client = cast(AsyncOpenAI, object()) + + with pytest.raises(UserError, match="organization, or project"): + OpenAIProvider( + openai_client=client, + **cast(dict[str, Any], client_option), + ) From 62f02e333347712406327f07775b2f61ad6f5a65 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:20:08 +0100 Subject: [PATCH 354/473] fix(tracing): respect model-data logging redaction for record_model_error_on_span (#4496) --- src/agents/util/_error_tracing.py | 10 ++++-- tests/test_model_error_logging_redaction.py | 34 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 tests/test_model_error_logging_redaction.py diff --git a/src/agents/util/_error_tracing.py b/src/agents/util/_error_tracing.py index c06a4c6482..230ef3acf2 100644 --- a/src/agents/util/_error_tracing.py +++ b/src/agents/util/_error_tracing.py @@ -5,7 +5,7 @@ from .. import _debug from ..exceptions import ModelTimeoutError -from ..logger import logger +from ..logger import log_model_action_warning, logger from ..tracing import Span, SpanError, get_current_span REDACTED_TRACE_ERROR_MESSAGE = "Error details are redacted." @@ -114,8 +114,12 @@ def record_model_error_on_span( }, ), ) - except Exception: - logger.warning("Could not record the model error on the span", exc_info=True) + except Exception as tracing_error: + log_model_action_warning( + logger, + "Could not record the model error on the span", + tracing_error, + ) @contextlib.contextmanager diff --git a/tests/test_model_error_logging_redaction.py b/tests/test_model_error_logging_redaction.py new file mode 100644 index 0000000000..deac487b1c --- /dev/null +++ b/tests/test_model_error_logging_redaction.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import logging +from typing import Any, cast + +import pytest + +from agents import _debug +from agents.tracing import Span +from agents.util._error_tracing import record_model_error_on_span + + +class _FailingSpan: + def set_error(self, _error: Any) -> None: + raise RuntimeError("span-annotation-secret") + + +def test_model_error_annotation_failure_respects_log_redaction( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + record_model_error_on_span( + cast(Span[Any], _FailingSpan()), + message="Error getting response", + error=RuntimeError("model-secret"), + trace_include_sensitive_data=False, + ) + + assert "span-annotation-secret" not in caplog.text + assert "model-secret" not in caplog.text + assert all(record.exc_info is None for record in caplog.records) From e5831826fd89193ba84299f4770fef91c9e24040 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Aug 2026 06:37:51 +0900 Subject: [PATCH 355/473] fix(sandbox): enforce Windows mypy compatibility (#4499) Co-authored-by: rome-xi --- .github/workflows/tests.yml | 35 ++++++++++++++++++++++ src/agents/sandbox/sandboxes/docker.py | 2 +- src/agents/sandbox/sandboxes/unix_local.py | 4 +-- src/agents/sandbox/util/tar_utils.py | 3 +- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d6d9156b94..2b9434bb33 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,6 +79,41 @@ jobs: if: steps.changes.outputs.run != 'true' run: echo "Skipping typecheck for non-code changes." + mypy-win32: + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Detect code changes + id: changes + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: true + prune-cache: true + python-version: "3.14" + - name: Restore mypy cache + if: steps.changes.outputs.run == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .mypy_cache + key: mypy-win32-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'Makefile') }}-${{ github.sha }} + restore-keys: | + mypy-win32-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'Makefile') }}- + - name: Install dependencies + if: steps.changes.outputs.run == 'true' + run: make sync + - name: Run mypy for Windows + if: steps.changes.outputs.run == 'true' + run: uv run mypy --platform win32 src + - name: Skip Windows mypy + if: steps.changes.outputs.run != 'true' + run: echo "Skipping Windows mypy for non-code changes." + tests: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index fd9ebbe556..70ce1a96da 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -1427,7 +1427,7 @@ async def hydrate_workspace(self, data: io.IOBase) -> None: archive.seek(0) await self._stream_into_exec( cmd=["tar", "-x", "-C", root.as_posix()], - stream=archive, + stream=cast(io.IOBase, archive), error_path=error_root, ) diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 4d8595b15e..d0c2ea28b7 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -336,8 +336,8 @@ def _preexec() -> None: else: with suppress(OSError): os.close(secondary_fd) - entry = _UnixPtyProcessEntry(process=process, tty=True, primary_fd=primary_fd) - entry.pump_tasks = [asyncio.create_task(self._pump_pty_primary_fd(entry))] + entry = _UnixPtyProcessEntry(process=process, tty=True, primary_fd=primary_fd) + entry.pump_tasks = [asyncio.create_task(self._pump_pty_primary_fd(entry))] else: process = await asyncio.create_subprocess_exec( *exec_command, diff --git a/src/agents/sandbox/util/tar_utils.py b/src/agents/sandbox/util/tar_utils.py index cf3c4595ac..55adbd77e4 100644 --- a/src/agents/sandbox/util/tar_utils.py +++ b/src/agents/sandbox/util/tar_utils.py @@ -8,6 +8,7 @@ import tempfile from collections.abc import Iterable from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import cast class UnsafeTarMemberError(ValueError): @@ -158,7 +159,7 @@ def strip_tar_member_prefix(data: io.IOBase, *, prefix: str | Path) -> io.IOBase with tarfile.open(fileobj=out, mode="r:*") as tar: validate_tarfile(tar) out.seek(0) - return out + return cast(io.IOBase, out) except Exception: out.close() raise From c5f6a71ea9d8abf5234e496d46bc49f17baa7056 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:48:35 +0100 Subject: [PATCH 356/473] fix(tracing): clean up processors after tracing is disabled (#4502) --- src/agents/tracing/provider.py | 4 ---- tests/test_disabled_trace_provider_shutdown.py | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 tests/test_disabled_trace_provider_shutdown.py diff --git a/src/agents/tracing/provider.py b/src/agents/tracing/provider.py index 29bf0d2d5a..f7f76b8de1 100644 --- a/src/agents/tracing/provider.py +++ b/src/agents/tracing/provider.py @@ -507,10 +507,6 @@ def force_flush(self) -> None: log_model_and_tool_action_error(logger, "Error flushing trace provider", e) def shutdown(self, timeout: float | None = None) -> None: - self._refresh_disabled_flag() - if self._disabled: - return - try: _safe_debug("Shutting down trace provider") self._multi_processor.shutdown(timeout=timeout) diff --git a/tests/test_disabled_trace_provider_shutdown.py b/tests/test_disabled_trace_provider_shutdown.py new file mode 100644 index 0000000000..cbb8fd446c --- /dev/null +++ b/tests/test_disabled_trace_provider_shutdown.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from agents.tracing.provider import DefaultTraceProvider + + +def test_disabled_trace_provider_still_shuts_down_registered_processors() -> None: + provider = DefaultTraceProvider() + processor = MagicMock() + provider.register_processor(processor) + provider.set_disabled(True) + + provider.shutdown() + + processor.shutdown.assert_called_once_with() From 1a4cfa20a343779b729bb77e2adb515c715e668e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Aug 2026 08:04:02 +0900 Subject: [PATCH 357/473] fix: harden agent workflow validation --- .../implementation-final-review/SKILL.md | 25 +- .../references/reviewer-brief.md | 23 +- .../scripts/review_protocol.py | 479 ++++++++++-- .../scripts/review_state.py | 407 ++++++++-- .../scripts/test_review_protocol.py | 720 +++++++++++++++++- .../scripts/test_review_state.py | 527 ++++++++++++- .../scripts/test_skill_contract.py | 93 ++- .../skills/implementation-kickoff/SKILL.md | 2 +- .../scripts/test_validate_handoff.py | 190 +++++ .../scripts/validate_handoff.py | 90 ++- 10 files changed, 2370 insertions(+), 186 deletions(-) diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index f7736dec21..bee3dbe10c 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -14,6 +14,17 @@ Treat implementation and final review as separate phases. Reconstruct the change - Require independent review. A same-context self-review cannot satisfy the clean-review gate. - Freeze task-owned content while reviewers inspect a fingerprint. - Treat an exact normalized file path in the task and component manifests as authoritative even when ignore rules match that file. An existing exact file takes literal precedence over Git pathspec metacharacters; use explicit `:(glob)` magic when pattern semantics are intended. A directory or glob pathspec never promotes ignored operational files into the review. +- Require the repository and every initialized submodule index to have no unresolved merge stages before fingerprinting. +- Require every initialized submodule, including nested submodules, to be clean and checked out at the commit recorded by its parent index before freezing review state. Stage reviewable gitlink pointer changes in the parent repository; fail closed on dirty worktrees, hidden index flags, ignored nested changes, and untracked embedded repositories. Reject cyclic or aliased submodule worktree graphs before recursive inspection. +- Require two consecutive identical observations of HEAD, status, diffs, task and repository workspace content, and component workspace content before accepting a review-state snapshot. Fail closed when repository state changes during capture. +- Reject task-owned filesystem entries that Git cannot represent as finite blobs, including FIFOs, sockets, and devices. +- Require packet, ledger, manifest, receipt, reviewer-output, and evidence paths to resolve to finite regular files. Canonicalize each path before opening. Verify the file type after opening and read content from that same descriptor; never authorize a path with `stat` and then reopen it. Reject evidence, receipt, and current-versus-prior ledger aliases by the opened descriptor's device and inode identity. Before accepting reviewer output or a reusable receipt, re-read the packet and current and prior ledgers and require their validated digests to remain unchanged; also re-read the indexed receipt before reporting it reusable. Materialize devices, FIFOs, sockets, or generated streams into regular files before validation. +- Bind every canonical root-owned evidence ID and inventory ID in the ledger with `contract_evidence_sha256` and `inventory_sha256`. Preserve those digest bindings across rounds so an existing ID cannot change content; inventory digests exclude only the ID itself so a renamed copy is not new semantic inventory. +- Count evidence or inventory as new for a canonical root only when its digest is absent from that root's prior ownership. A new root proposal requires an evidence digest absent from every canonical root and every distinct root proposed in the same output; it cannot reuse canonical inventory before implementer promotion. Require every credited receipt to have a unique content digest and exact command. +- Require unique keys and standard finite numbers in every JSON object. Duplicate keys, JavaScript-style `NaN` or infinity constants, and numeric exponents that overflow to infinity are invalid. Convert runtime numeric-size and nesting-limit failures into protocol errors instead of leaking parser exceptions. +- Give the two reviewers distinct normalized primary and high-risk specialties, and require every preflight command to be unique before any receipt can claim it. +- Encode `manifests.dependency_map` as an object that maps every component name to a nonempty array of exact `pathspec` and `reason` records. Reject prose-only claims, missing components, empty dependency sets, duplicate pathspecs, and extra record fields. +- Treat verification receipts, reviewer outputs, findings, root-cause evidence, unchecked-inventory records, and sibling-scenario scans as exact schemas. Reject unknown fields instead of ignoring potentially conflicting evidence. - Repeat commit-hook inspection, every safe rewriting step, second-pass idempotence, and generated-provenance validation before every fingerprint freeze, including post-fix and delta-review rounds. Record the exact executable inspection and rewriting commands plus their results in packet preflight evidence; a prose label is not an executable command. - Start independent reviewers without inherited conversation history. Fresh judgment does not require repeatedly replaying the implementer's context. - Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. @@ -31,6 +42,8 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor ## Workflow +Persist the current combined content fingerprint as `ledger.round_fingerprint` and bind it to the packet fingerprint. A same-round retry is valid only when that value and the authorized budget history match the immutable prior ledger snapshot; a changed fingerprint or newly authorized budget advances the round. + 1. Finish the initial implementation and focused tests. Apply formatting before review when formatting can rewrite the diff. Inspect the actual final commit-hook configuration and run the exact safe, non-committing equivalent of every hook step that can rewrite task-owned content before freezing the first review fingerprint. Run each rewriting step until a second execution is content-idempotent. Normalize generated files before computing embedded hashes or provenance so the hook cannot invalidate them later. Record any hook step that cannot safely run before review; if that step later changes task content, apply the normal invalidation rules without exception. 2. Re-read the original user request and the current implementation scope contract. If no contract exists, record the required behavior, compatibility requirements, intentionally unsupported cases and failure behavior, and supported alternative or `none`. 3. Resolve the intended target and merge base. If a supplied target or base is not an ancestor of `HEAD`, compute their common merge base and treat `merge-base...HEAD` as the task-owned diff. Use the latest release tag separately when released compatibility is the relevant boundary. Include committed, staged, unstaged, and untracked changes that belong to the task. @@ -54,13 +67,13 @@ Keep the same task identity and ledger, preserve its canonical root-cause histor - unsupported neighboring case that should fail earlier; - unnecessary machinery or duplicated source of truth; - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. -10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with new contract evidence or newly uncovered inventory IDs; only the implementer may promote that proposal into the canonical ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Plans, review ledgers, packets, traces, temporary reports, and other workflow artifacts are operational-only by default even when repository policy requires creating them; include one only when the original requirement or repository policy explicitly makes that exact path a committed deliverable. Keep operational files outside the shipped manifest and account for them as repository exclusions. Keep the shipped manifest stable and update it only when task-owned deliverable paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. For each component, record an exact semantic dependency-input pathspec set plus the reason each input can affect the component; do not use a coarse directory or prose-only `none` claim when build configuration, generated-surface owners, or shared runtime code are dependencies. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ... --complete-diff-output `; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Always generate the complete-diff artifact through `--complete-diff-output`; a standalone `git diff` omits ordinary untracked deliverables. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. -11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the raw output produced by that command's `--complete-diff-output` as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires `complete_diff_paths` to equal the task workspace exactly, requires the complete-diff digest to match its `complete_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix and canonical root ownership, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. -12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. A reviewer process that fails before producing a protocol-valid output because of startup, service, content-filter, context, or tool infrastructure has produced neither a finding nor clean credit and does not advance `ledger.current_round` or consume another fingerprint round. Replace only that reviewer on the same frozen packet and assignment; a protocol-valid output already accepted from the other reviewer remains usable while the task fingerprint, packet, and assignment are unchanged. The original two-reviewer concurrent dispatch satisfies the round's concurrency requirement; the accepted peer output plus one independently launched replacement output on the identical packet and assignment form the required pair. If an independent replacement remains unavailable, report the gate as unavailable instead of counting an infrastructure failure as review evidence. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. +10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with content-new contract evidence; only the implementer may promote that proposal and assign canonical inventory in the ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Plans, review ledgers, packets, traces, temporary reports, and other workflow artifacts are operational-only by default even when repository policy requires creating them; include one only when the original requirement or repository policy explicitly makes that exact path a committed deliverable. Keep operational files outside the shipped manifest and account for them as repository exclusions. Keep the shipped manifest stable and update it only when task-owned deliverable paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. For each component, record an exact semantic dependency-input pathspec set plus the reason each input can affect the component; do not use a coarse directory or prose-only `none` claim when build configuration, generated-surface owners, or shared runtime code are dependencies. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ... --complete-diff-output `; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Always generate the complete-diff artifact through `--complete-diff-output`; a standalone `git diff` omits ordinary untracked deliverables. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. +11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the raw output produced by that command's `--complete-diff-output` as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires `complete_diff_paths` to equal the task workspace exactly, requires the complete-diff digest to match its `complete_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact, and the ledger digest maps must bind the exact owned evidence and inventory content. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix, canonical root ownership, and owned-content digests, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. +12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. A reviewer process that fails before producing a protocol-valid output because of startup, service, content-filter, context, or tool infrastructure has produced neither a finding nor clean credit and does not advance `ledger.current_round` or consume another fingerprint round. Replace only that reviewer on the same frozen packet and assignment; a protocol-valid output already accepted from the other reviewer remains usable while the task fingerprint, packet, and assignment are unchanged. The original two-reviewer concurrent dispatch satisfies the round's concurrency requirement; the accepted peer output plus one independently launched replacement output on the identical packet and assignment form the required pair. If an independent replacement remains unavailable, report the gate as unavailable instead of counting an infrastructure failure as review evidence. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests-review`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. 13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence unless the change later qualifies for one of the narrow final-gate closures in step 20. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged, except for a narrowly recorded step 20 closure. Require two concurrent independent delta reviews of every other changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. -14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, and reopening a closed canonical root without evidence IDs that are new to that root. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. +14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, changed prior evidence or inventory bindings, reopening a closed canonical root without content-new evidence or semantic inventory, and distinct new roots that reuse one evidence digest. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. 15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. -16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the canonical root-cause ID closed. Do not reopen it for another local patch without new contract evidence or a newly uncovered inventory ID; reject aliases, renamed IDs, and bare unknown IDs instead of treating them as new roots. If it cannot be closed coherently, escalate instead of consuming more rounds. +16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the canonical root-cause ID closed. Do not reopen it for another local patch without content-new contract evidence or semantic inventory; reject aliases, renamed or copied content, and bare unknown IDs instead of treating them as new roots. If it cannot be closed coherently, escalate instead of consuming more rounds. 17. Increment the fingerprint round, repeat the full commit-hook parity gate from step 1, and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> hook parity -> review without waiting for another user prompt. 18. Apply the non-convergence guard before another local fix: - If the same root-cause group produces another P0/P1 after a complexity reset, return to the merge base and replace task-owned branch-local machinery with the narrowest coherent implementation. @@ -114,7 +127,7 @@ An independent review uses a fresh no-history context that did not implement the - Use two concurrent fresh reviewers for every round. For the high-risk conditions in step 12, assign complementary high-risk specialties while requiring each reviewer to inspect the complete diff. Both reviewers of the same unchanged diff are one fingerprint round. Do not duplicate broad test execution. - Concurrent reviewers receive the same fingerprint and raw context but different primary specialties. They must not communicate during the round. - Give the reviewer existing verification commands and results as raw evidence. The reviewer should inspect code and tests, then run only focused probes needed to resolve a decision-relevant uncertainty. A probe must be demonstrably non-mutating or run in an isolated temporary checkout; any mutation of the reviewed worktree invalidates the round. Do not rerun the repository's broad test, typecheck, lint, build, or integration suites merely to reconfirm the implementer's evidence; the implementer runs the complete stack once after the clean-review gate. -- Require the structured JSON output from the reviewer brief. `clean` alone is never sufficient: the reviewer must return the exact fingerprint, checked and unchecked inventory IDs, high-risk dimensions checked, probes or `none`, unresolved uncertainty or `none`, findings, sibling-scenario scan, and inspection-budget accounting. +- Require the structured JSON output from the reviewer brief. `clean` alone is never sufficient: the reviewer must return the exact packet SHA-256 and fingerprints, checked and unchecked inventory IDs, high-risk dimensions checked, probes or `none`, unresolved uncertainty or `none`, findings, sibling-scenario scan, and inspection-budget accounting. - After fixes, review the exact final diff again. Preserve earlier clean credit only under the explicit component-delta rule; do not infer that a change is isolated merely from its file location. When an independent reviewer is unavailable, rebuild context from the original request, scope contract, source, and complete diff before a best-effort self-review. Explicitly discard incremental-review assumptions, label the result non-independent, and do not count it toward the clean-review gate. Report the unavailable gate at handoff instead of silently weakening it. diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index 2a719667c9..1f6286c84d 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -1,5 +1,15 @@ # Independent Reviewer Brief +The ledger contains a `round_fingerprint` equal to the packet's combined content fingerprint. A same-round retry must preserve the immutable prior snapshot's `round_fingerprint` and authorized budget history; a changed fingerprint or newly authorized budget requires advancing exactly one round. + +Every packet, ledger, manifest, receipt, reviewer-output, and evidence path must resolve to a finite regular file. Canonicalize each path before opening. Verify the file type after opening and read content from that same descriptor; a path-level `stat` must not authorize a later reopen. Evidence artifacts and credited receipts must have unique opened-file device and inode identities, and current and prior ledgers must have distinct identities. Before accepting reviewer output or a reusable receipt, the validator re-reads the packet and current and prior ledgers and requires their validated digests to remain unchanged; it also re-reads the indexed receipt before reporting it reusable. Materialize devices, FIFOs, sockets, or generated streams before validation. + +An evidence or inventory ID is new for a canonical root only when its content digest is absent from that root's prior ownership. The ledger binds every canonical root-owned evidence ID in `contract_evidence_sha256` and every inventory ID in `inventory_sha256`; prior bindings are immutable. Inventory digests exclude only the ID itself, so renaming a copied row does not make it new. A new root proposal requires an evidence digest absent from every canonical root and every distinct root proposed in the same output; it cannot reuse canonical inventory before implementer promotion. Credited receipt content digests and exact commands must be unique. + +Every JSON object must use unique keys and standard finite numbers. Duplicate keys, JavaScript-style `NaN`, `Infinity`, and `-Infinity` constants, and numeric exponents that overflow to infinity are invalid. Runtime numeric-size and nesting-limit failures are protocol errors rather than raw parser exceptions. + +Generate review state only from two consecutive identical repository observations. A changed HEAD, status, diff, task or repository workspace, or component workspace invalidates the capture. Task-owned FIFOs, sockets, devices, and other entries that Git cannot represent as finite blobs are invalid. + Use this template to prepare one self-contained, factual snapshot packet per fingerprint round. Fill every field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Fill it once, reuse the shared body byte-for-byte for every reviewer, and vary only the final specialty assignment. Keep this control-plane brief near 12 KB when practical. Store larger evidence in indexed files and reference each file by exact path and SHA-256 digest. Do not omit decision-relevant evidence merely to meet the soft size target. Do not include implementer conclusions, suspected bugs, prior findings, or intended fixes. The verified final-gate type-erasure and base-advance closures defined in `SKILL.md` step 20 do not create a fingerprint round, reviewer packet, or reviewer assignment. For a type-erasure closure, record its exact delta, before and after fingerprints, final-gate failure, runtime-identity basis, and focused verification in the task-global ledger and final verification evidence. For a base-advance closure, record the old and new base, head, fingerprints, byte-identical task and component workspace evidence, identical tracked-diff digest, complete upstream changed-path list and diff digest, exact dependency-input pathspecs, and focused integration checks. If every condition for the applicable exception is not mechanically established, prepare the normal delta-review packet instead. @@ -42,11 +52,11 @@ Store the shared packet index as one JSON object and validate it before dispatch The active implementation control plane is trusted to record real reviewer dispatches, waits, outputs, and verification executions. The local helper validates completeness, digests, identity, state transitions, and reuse against those records; it does not provide cryptographic attestation against a malicious control plane that fabricates every input. Platform-issued signed execution provenance is intentionally unsupported here and requires a separate trusted service. -The packet object uses integer `schema_version: 1` and contains these required top-level fields: `packet_overage_reason`, `task`, `scope_contract`, `repository`, `ledger`, `manifests`, `review_state`, `verification`, `architecture_references`, `evidence_artifacts`, `inventory`, `selected_high_risk_dimensions`, and `reviewer_assignments`. Mirror the factual fields above rather than adding conclusions. Encode `verification.preflight_results` as an array of exact `command` and `result` objects; use an empty array when no focused preflight ran. Set `verification.eligible_concurrent_gates` to the exact string `none`, and list the repository-wide lint, typecheck, test, build, examples, and integration gates that remain applicable in `verification.deferred_gates`; packet preflight rejects any attempt to overlap a broad final gate with review. Store exactly one evidence artifact with `role: "review-state"` containing the unmodified `review_state.py` JSON, exactly one with `role: "complete-diff"` generated by the same command's `--complete-diff-output`, and exactly one with `role: "repository-status"` containing unfiltered porcelain-v1 `-z` status. The `review_state` packet object contains exactly `evidence_id`, which names the review-state artifact, and the exact revalidation command; extra copied fingerprint or state fields are invalid. The repository object names the status artifact with `status_evidence_id` and lists every changed path outside the task manifest in `exclusions` with a concrete reason. `manifests.dependency_map` must name each component, list exact base pathspecs for every semantic, generated-surface, hook, and build/test configuration input that can invalidate it, and state why; a prose-only claim that a component has no dependencies cannot support a later base-advance closure. Use two reviewer assignments whose combined IDs cover every inventory row and selected high-risk dimension. Every reviewer assignment must include every component boundary and all three control artifacts; supporting evidence may remain specialty-specific. The validator derives fingerprints from the digested review-state artifact, requires repository base and head to match it, requires the task and component manifests to match its pathspecs exactly, requires `complete_diff_paths` to match the task workspace exactly, requires the complete-diff artifact digest to equal its `complete_diff_sha256`, requires the status digest to equal its unfiltered status fingerprint, and requires exclusions to account exactly for every unfiltered changed path outside the task workspace. It reports the packet's actual path, byte size, SHA-256 digest, review-state path, fingerprint, components, inventory IDs, and reviewer IDs; copy that output into the dispatch record. If the packet exceeds 12 KiB, replace `packet_overage_reason: "none"` with the decision-relevant reason it could not be split further. +The packet object uses integer `schema_version: 1` and contains these required top-level fields: `packet_overage_reason`, `task`, `scope_contract`, `repository`, `ledger`, `manifests`, `review_state`, `verification`, `architecture_references`, `evidence_artifacts`, `inventory`, `selected_high_risk_dimensions`, and `reviewer_assignments`. Mirror the factual fields above rather than adding conclusions. Encode `verification.preflight_results` as an array of exact, unique `command` and `result` objects; use an empty array when no focused preflight ran. Set `verification.eligible_concurrent_gates` to the exact string `none`, and list the repository-wide lint, typecheck, test, build, examples, and integration gates that remain applicable in `verification.deferred_gates`; packet preflight rejects any attempt to overlap a broad final gate with review. Store exactly one evidence artifact with `role: "review-state"` containing the unmodified `review_state.py` JSON, exactly one with `role: "complete-diff"` generated by the same command's `--complete-diff-output`, and exactly one with `role: "repository-status"` containing unfiltered porcelain-v1 `-z` status. The `review_state` packet object contains exactly `evidence_id`, which names the review-state artifact, and the exact revalidation command; extra copied fingerprint or state fields are invalid. The repository object names the status artifact with `status_evidence_id` and lists every changed path outside the task manifest in `exclusions` with a concrete reason. Encode `manifests.dependency_map` as an object whose keys exactly match the component names and whose values are nonempty arrays of exact `pathspec` and `reason` records with unique pathspecs. Each pathspec must cover a semantic, generated-surface, hook, or build/test configuration input that can invalidate the component, and its reason must state why; prose-only or empty dependency claims cannot support a later base-advance closure. Use two reviewer assignments whose combined IDs cover every inventory row and selected high-risk dimension; after trimming and case-folding labels, their primary and high-risk specialties must not overlap. Every reviewer assignment must include every component boundary and all three control artifacts; supporting evidence may remain specialty-specific. The validator derives fingerprints from the digested review-state artifact, requires repository base and head to match it, requires the task and component manifests to match its pathspecs exactly, requires `complete_diff_paths` to match the task workspace exactly, requires the complete-diff artifact digest to equal its `complete_diff_sha256`, requires the status digest to equal its unfiltered status fingerprint, and requires exclusions to account exactly for every unfiltered changed path outside the task workspace. It reports the packet's actual path, byte size, packet and current-ledger SHA-256 digests, review-state path, fingerprint, components, inventory IDs, and reviewer IDs; copy that output into the dispatch record. If the packet exceeds 12 KiB, replace `packet_overage_reason: "none"` with the decision-relevant reason it could not be split further. -The ledger contains `task_id`, `authorized_round_budgets`, `current_round`, `remaining_budget`, and `root_causes`. Supply the task ID and absolute task-global ledger path independently on every validator command. For every round after round 1, also supply the immediately preceding round's immutable ledger snapshot and its SHA-256 digest from the control plane; never derive either argument from the packet under validation. The immutable snapshot must be a distinct file, not the mutable current ledger under another argument. The validator requires the packet, current ledger, and prior ledger identity to match those control-plane arguments. It requires `current_round` plus `remaining_budget` to equal the sum of the positive integer budget history, the current budget history to preserve the prior prefix, the current round to equal the prior round for a same-round retry or advance by exactly one, every prior canonical root and its ownership to remain present, and the current ledger file's JSON object to match the packet ledger exactly. Each `ledger.root_causes` entry contains `id`, `status`, `inventory_ids`, and `contract_evidence_ids`. Every root must own at least one inventory ID, and each inventory ID has exactly one canonical root owner. Every contract evidence ID must resolve to an `evidence_artifacts[].id`; the ledger cannot establish evidence authority with an unindexed string. The implementer owns canonical IDs. Reviewers must reuse one supplied ID or propose `NEW:` with evidence or inventory not already owned by any canonical root; reviewers must not mint a renamed bare ID. Only the implementer promotes a proposal into the ledger. +The ledger contains `task_id`, `round_fingerprint`, `authorized_round_budgets`, `current_round`, `remaining_budget`, `root_causes`, `contract_evidence_sha256`, and `inventory_sha256`. Supply the task ID and absolute task-global ledger path independently on every validator command. For every round after round 1, also supply the immediately preceding round's immutable ledger snapshot and its SHA-256 digest from the control plane; never derive either argument from the packet under validation. The immutable snapshot must be a distinct file, not the mutable current ledger under another argument. The validator requires the packet, current ledger, and prior ledger identity to match those control-plane arguments. It requires `round_fingerprint` to match the packet fingerprint, `current_round` plus `remaining_budget` to equal the sum of the positive integer budget history, the current budget history to preserve the prior prefix, the current round to equal the prior round for a same-round retry or advance by exactly one, a same-round retry to preserve the prior `round_fingerprint`, every prior canonical root and its ownership and digest bindings to remain present, and the current ledger file's JSON object to match the packet ledger exactly. Each `ledger.root_causes` entry contains `id`, `status`, `inventory_ids`, and `contract_evidence_ids`. Every root must own at least one inventory ID, and each inventory ID has exactly one canonical root owner. The two digest maps must bind exactly the currently owned IDs and match the indexed artifact bytes and semantic inventory rows. Every contract evidence ID must resolve to an `evidence_artifacts[].id`; the ledger cannot establish evidence authority with an unindexed string. The implementer owns canonical IDs. Reviewers must reuse one supplied ID or propose `NEW:` with evidence content not already owned by any canonical or distinct proposed root; reviewers must not mint a renamed bare ID or reuse canonical inventory for a new proposal. Only the implementer promotes a proposal into the ledger. -Each credited verification receipt uses integer `schema_version: 1` and integer `exit_status: 0`, and contains `command`, `environment`, `non_mutation_basis`, and exact `before` and `after` objects with `combined`, `components`, and `repository` fingerprints. JSON booleans are not integers for protocol purposes. Add an object with its absolute `path` and `sha256` digest to `verification.credited_receipts`; packet preflight rejects replacement, a failed command, task or repository-state drift, or before/after drift. The standalone check accepts only a receipt path already indexed by the validated packet; it does not grant credit to an arbitrary same-fingerprint file: +Each credited verification receipt contains exactly integer `schema_version: 1`, integer `exit_status: 0`, `command`, `environment`, `non_mutation_basis`, and exact `before` and `after` objects with `combined`, `components`, and `repository` fingerprints. Unknown receipt fields are invalid, and JSON booleans are not integers for protocol purposes. Add an object with its absolute `path` and `sha256` digest to `verification.credited_receipts`; packet preflight rejects replacement, a failed command, task or repository-state drift, or before/after drift. The standalone check accepts only a receipt path already indexed by the validated packet; it does not grant credit to an arbitrary same-fingerprint file: The validator recomputes the content, component, and repository fingerprints from the complete typed workspace entries in the review-state artifact and rejects an incomplete or unknown key for any workspace kind or a non-partitioning component workspace. A credited receipt's exact command must also appear in `verification.preflight_results`; unrelated successful commands are ineligible for credit. @@ -80,7 +90,7 @@ Encode those columns in each `kind: "authority-data-flow"` inventory object as ` ## Reviewer instructions -Perform exactly one read-only review round on the frozen fingerprint. Your context must be created with no inherited implementer conversation; the dispatcher uses `fork_turns: "none"` when available. First run the supplied revalidation command and calculate the merge base. Then inspect the complete raw diff, surrounding source, tests, and supplied references. Validate every assigned inventory row rather than trusting the implementer. You may report blockers outside your specialty. +Perform exactly one read-only review round on the frozen fingerprint. Your context must be created with no inherited implementer conversation; the dispatcher uses `fork_turns: "none"` when available. First run the supplied revalidation command and calculate the merge base. Then inspect the complete raw diff, surrounding source, tests, and supplied references. Validate every assigned inventory row rather than trusting the implementer. Return the packet SHA-256 reported by preflight so the validator rejects credit after any packet field or evidence descriptor changes. You may report blockers outside your specialty. Do not edit or stage files, recursively invoke the review workflow, spawn another reviewer, run broad repository verification, inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag. Inherit the supplied implementation scope contract; if it is inconsistent or leaves a decision-relevant ambiguity, report that uncertainty to the implementer instead of launching a strategy pass. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, report the missing field and do not return a creditable clean verdict. Reopen primary source or released evidence only when supplied evidence is inconsistent or leaves a decision-relevant uncertainty; do not use reopening to replace missing packet contents. Run only focused non-mutating probes needed to resolve such uncertainty. @@ -92,6 +102,7 @@ Return exactly one JSON object with this shape and no prose outside it: { "verdict": "clean | findings require fixes | complexity reset required | incomplete packet", "reviewed_fingerprints": { + "packet": "...", "combined": "...", "components": {"component-name": "..."} }, @@ -125,9 +136,11 @@ Return exactly one JSON object with this shape and no prose outside it: Use empty arrays for `focused_probes`, `remaining_uncertainty`, `findings`, or `sibling_scenario_scan` when there are none. Every assigned inventory ID must appear in either `checked_inventory_ids` or `unchecked_inventory_ids`. Each sibling-scenario scan must reuse a canonical root ID or a `NEW:` root proposed by a finding in the same output, and every scan inventory ID must resolve to an indexed inventory row. A `clean` verdict requires an empty `unchecked_inventory_ids`, `remaining_uncertainty`, and `findings` array. +The reviewer output and every finding, root-cause evidence, unchecked-inventory, and sibling-scenario object must use exactly the fields shown above. Unknown fields are invalid rather than ignored. + Every `focused_probes[].command` must contain the exact executable command that ran. For a non-shell tool call, provide the complete tool name and arguments. Prose-only labels, omitted arguments, and placeholders such as `` are incomplete and earn no clean credit. If the exact command would be too large to return, place the probe code in an indexed evidence artifact before execution and return its path, SHA-256 digest, and exact execution command. -For each finding, reuse a canonical root-cause ID supplied in the packet or propose `NEW:`. Populate both `root_cause_evidence` arrays, using empty arrays when there is no new evidence. Every submitted contract evidence ID must name an indexed `evidence_artifacts[].id`, and every submitted inventory ID must name an indexed `inventory[].id`. For a canonical root, submitted IDs must be additions owned by that root in the current ledger relative to the prior immutable snapshot; an inventory ID owned by another root cannot be reassigned as finding evidence. A new proposal requires at least one indexed contract evidence or inventory ID that is not owned by any canonical root. A closed root may be reopened only with the same kind of new evidence; renaming or aliasing it does not create a new root. If a reviewer discovers evidence that is absent from the frozen packet, add and digest that evidence in the packet, rerun packet preflight on the same fingerprint round, and then resubmit the output. The implementer validates each saved response before accepting findings or clean credit: +For each finding, reuse a canonical root-cause ID supplied in the packet or propose `NEW:`. Populate both `root_cause_evidence` arrays, using empty arrays when there is no new evidence. Every submitted contract evidence ID must name an indexed `evidence_artifacts[].id`, and every submitted inventory ID must name an indexed `inventory[].id`. For a canonical root, submitted IDs must be additions owned by that root in the current ledger relative to the prior immutable snapshot; an inventory ID owned by another root cannot be reassigned as finding evidence. A new proposal requires indexed evidence content that is not owned by any canonical root or a distinct root proposed in the same output, and its inventory array must remain empty until implementer promotion. A closed root may be reopened only with content-new evidence or semantic inventory; renaming, copying, or aliasing prior content does not make it new. If a reviewer discovers evidence that is absent from the frozen packet, add and digest that evidence in the packet, rerun packet preflight on the same fingerprint round, and then resubmit the output. The implementer validates each saved response before accepting findings or clean credit: `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` diff --git a/.agents/skills/implementation-final-review/scripts/review_protocol.py b/.agents/skills/implementation-final-review/scripts/review_protocol.py index 61591fbabb..a40f8d52ca 100644 --- a/.agents/skills/implementation-final-review/scripts/review_protocol.py +++ b/.agents/skills/implementation-final-review/scripts/review_protocol.py @@ -6,11 +6,17 @@ import argparse import hashlib import json +import math import re from pathlib import Path from typing import Any -from review_state import _content_fingerprint, _repository_fingerprint +from review_state import ( + _content_fingerprint, + _NonRegularFileError, + _read_regular_file, + _repository_fingerprint, +) PACKET_SOFT_LIMIT_BYTES = 12 * 1024 SENTINELS = {"none", "not applicable"} @@ -36,7 +42,6 @@ "repository.complete_diff_command", "ledger.path", "manifests.task", - "manifests.dependency_map", "review_state.evidence_id", "review_state.revalidation_command", "verification.eligible_concurrent_gates", @@ -109,6 +114,16 @@ def _object(value: Any, context: str) -> dict[str, Any]: return value +def _require_exact_fields(value: dict[str, Any], expected: set[str], context: str) -> None: + missing = sorted(expected - value.keys()) + unexpected = sorted(value.keys() - expected) + if missing or unexpected: + raise ProtocolError( + f"{context} does not match the exact schema: " + f"missing={missing}, unexpected={unexpected}." + ) + + def _array(value: Any, context: str) -> list[Any]: if not isinstance(value, list): raise ProtocolError(f"{context} must be an array.") @@ -148,43 +163,81 @@ def _at(value: dict[str, Any], dotted_path: str) -> Any: return current -def _read_bytes(value: Any, context: str) -> tuple[Path, bytes]: - path = Path(_text(value, context, concrete=True)) - if not path.is_absolute(): - raise ProtocolError(f"{context} must be an absolute path: {path}.") +FileIdentity = tuple[int, int] + + +def _read_bytes(value: Any, context: str) -> tuple[Path, bytes, FileIdentity]: + requested_path = Path(_text(value, context, concrete=True)) + if not requested_path.is_absolute(): + raise ProtocolError(f"{context} must be an absolute path: {requested_path}.") try: - return path, path.read_bytes() - except OSError as error: - raise ProtocolError(f"Cannot read {context} {path}: {error}") from error + path = requested_path.resolve(strict=True) + data, file_stat = _read_regular_file(path) + except _NonRegularFileError as error: + raise ProtocolError(f"{context} must be a regular file: {requested_path}.") from error + except (OSError, ValueError) as error: + raise ProtocolError(f"Cannot read {context} {requested_path}: {error}") from error + return path, data, (file_stat.st_dev, file_stat.st_ino) def _json_bytes(data: bytes, context: str) -> dict[str, Any]: + def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProtocolError(f"Duplicate JSON key in {context}: {key!r}.") + result[key] = value + return result + + def reject_constant(value: str) -> None: + raise ProtocolError(f"Non-finite JSON number in {context}: {value}.") + + def finite_float(value: str) -> float: + parsed = float(value) + if not math.isfinite(parsed): + raise ProtocolError(f"Non-finite JSON number in {context}: {value}.") + return parsed + try: - value = json.loads(data) - except (UnicodeError, json.JSONDecodeError) as error: + value = json.loads( + data, + object_pairs_hook=unique_object, + parse_constant=reject_constant, + parse_float=finite_float, + ) + except ProtocolError: + raise + except (RecursionError, UnicodeError, ValueError) as error: raise ProtocolError(f"Cannot read JSON object from {context}: {error}") from error return _object(value, context) def _load_json(path: Path) -> dict[str, Any]: - _, data = _read_bytes(str(path.resolve()), str(path)) + _, data, _ = _read_bytes(str(path.resolve()), str(path)) return _json_bytes(data, str(path)) -def _descriptor(value: Any, context: str) -> tuple[Path, bytes, str]: +def _descriptor(value: Any, context: str) -> tuple[Path, bytes, str, FileIdentity]: descriptor = _object(value, context) - path, data = _read_bytes(descriptor.get("path"), f"{context}.path") + path, data, identity = _read_bytes(descriptor.get("path"), f"{context}.path") expected = _text(descriptor.get("sha256"), f"{context}.sha256") if not SHA256.fullmatch(expected): raise ProtocolError(f"{context}.sha256 must be a lowercase SHA-256 digest.") actual = hashlib.sha256(data).hexdigest() if actual != expected: raise ProtocolError(f"{context} digest mismatch for {path}.") - return path, data, actual + return path, data, actual, identity + + +def _read_unchanged(path: Path, expected_digest: str, context: str) -> bytes: + _, data, _ = _read_bytes(str(path.resolve()), context) + if hashlib.sha256(data).hexdigest() != expected_digest: + raise ProtocolError(f"{context} changed during protocol validation.") + return data def _pathspec_file(value: Any, context: str) -> list[str]: - _, data = _read_bytes(value, context) + _, data, _ = _read_bytes(value, context) try: lines = [line for line in data.decode().splitlines() if line] except UnicodeError as error: @@ -194,8 +247,30 @@ def _pathspec_file(value: Any, context: str) -> list[str]: return lines +def _dependency_map(value: Any, component_names: set[str]) -> None: + dependencies = _object(value, "manifests.dependency_map") + if set(dependencies) != component_names: + raise ProtocolError("manifests.dependency_map must cover the exact component names.") + for component_name in sorted(component_names): + context = f"manifests.dependency_map[{component_name!r}]" + entries = _array(dependencies[component_name], context) + if not entries: + raise ProtocolError(f"{context} must contain at least one dependency.") + pathspecs: set[str] = set() + for index, raw_entry in enumerate(entries): + entry_context = f"{context}[{index}]" + entry = _object(raw_entry, entry_context) + _require_exact_fields(entry, {"pathspec", "reason"}, entry_context) + pathspec = _text(entry.get("pathspec"), f"{entry_context}.pathspec", concrete=True) + _text(entry.get("reason"), f"{entry_context}.reason", concrete=True) + if pathspec in pathspecs: + raise ProtocolError(f"{context} contains duplicate pathspec {pathspec!r}.") + pathspecs.add(pathspec) + + def _command_result(value: Any, context: str) -> None: record = _object(value, context) + _require_exact_fields(record, {"command", "result"}, context) command = _text(record.get("command"), f"{context}.command", concrete=True) _text(record.get("result"), f"{context}.result", concrete=True) if PLACEHOLDER_TOKEN.search(command): @@ -209,6 +284,27 @@ def _sha256(value: Any, context: str) -> str: return digest +def _inventory_digest(row: dict[str, Any]) -> str: + content = {key: value for key, value in row.items() if key != "id"} + canonical = json.dumps(content, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def _digest_map(value: Any, context: str, expected_ids: set[str]) -> dict[str, str]: + digests = { + _text(raw_id, f"{context} key", concrete=True): _sha256(digest, f"{context}.{raw_id}") + for raw_id, digest in _object(value, context).items() + } + actual_ids = set(digests) + if actual_ids != expected_ids: + raise ProtocolError( + f"{context} must bind the exact owned IDs: " + f"missing={sorted(expected_ids - actual_ids)}, " + f"unexpected={sorted(actual_ids - expected_ids)}." + ) + return digests + + def _workspace_entries(value: Any, context: str) -> dict[str, dict[str, Any]]: entries: dict[str, dict[str, Any]] = {} for index, raw_entry in enumerate(_array(value, context)): @@ -220,7 +316,7 @@ def _workspace_entries(value: Any, context: str) -> dict[str, dict[str, Any]]: required_fields = { "file": {"path", "kind", "executable", "sha256"}, "symlink": {"path", "kind", "sha256"}, - "gitlink": {"path", "kind", "head", "status_sha256"}, + "gitlink": {"path", "kind", "head"}, "directory": {"path", "kind"}, "missing": {"path", "kind"}, } @@ -241,7 +337,6 @@ def _workspace_entries(value: Any, context: str) -> dict[str, dict[str, Any]]: head = _text(entry["head"], f"{context}[{index}].head") if not re.fullmatch(r"[0-9a-f]{40,64}", head): raise ProtocolError(f"{context}[{index}].head must be a Git object ID.") - _sha256(entry["status_sha256"], f"{context}[{index}].status_sha256") entries[path] = entry if list(entries) != sorted(entries): raise ProtocolError(f"{context} must be sorted by path.") @@ -254,6 +349,7 @@ def _workspace_paths(value: Any, context: str) -> set[str]: def _evidence_artifacts(packet: dict[str, Any]) -> dict[str, dict[str, Any]]: artifacts: dict[str, dict[str, Any]] = {} + artifact_identities: dict[FileIdentity, str] = {} role_ids: dict[str, set[str]] = { "complete-diff": set(), "review-state": set(), @@ -266,7 +362,14 @@ def _evidence_artifacts(packet: dict[str, Any]) -> dict[str, dict[str, Any]]: artifact_id = _text(artifact.get("id"), f"evidence_artifacts[{index}].id") if artifact_id in artifacts: raise ProtocolError(f"Duplicate evidence artifact ID: {artifact_id}.") - path, data, digest = _descriptor(artifact, f"evidence artifact {artifact_id}") + path, data, digest, identity = _descriptor(artifact, f"evidence artifact {artifact_id}") + existing_artifact = artifact_identities.get(identity) + if existing_artifact is not None: + raise ProtocolError( + f"Duplicate evidence artifact file identity for " + f"{existing_artifact} and {artifact_id}." + ) + artifact_identities[identity] = artifact_id role = artifact.get("role") if role not in {"complete-diff", "review-state", "repository-status", "supporting"}: raise ProtocolError(f"Evidence artifact {artifact_id} has an invalid role: {role!r}.") @@ -394,9 +497,7 @@ def validate_receipt_data( "before", "after", } - missing = sorted(required - receipt.keys()) - if missing: - raise ProtocolError(f"Verification receipt is missing fields: {missing}.") + _require_exact_fields(receipt, required, "Verification receipt") if type(receipt["schema_version"]) is not int or receipt["schema_version"] != 1: raise ProtocolError("Verification receipt schema_version must be integer 1.") if type(receipt["exit_status"]) is not int or receipt["exit_status"] != 0: @@ -429,7 +530,8 @@ def validate_packet( prior_ledger_path: Path | None = None, prior_ledger_sha256: str | None = None, ) -> dict[str, Any]: - packet = _load_json(path) + packet_path, packet_data, _ = _read_bytes(str(path.resolve()), "packet") + packet = _json_bytes(packet_data, str(packet_path)) if type(packet.get("schema_version")) is not int or packet["schema_version"] != 1: raise ProtocolError("Packet schema_version must be integer 1.") for dotted_path in REQUIRED_PACKET_TEXT: @@ -450,7 +552,7 @@ def validate_packet( if _at(packet, "task.id") != expected_task_id: raise ProtocolError("packet task.id must match the control-plane task ID.") - packet_size = path.stat().st_size + packet_size = len(packet_data) overage_reason = _text(packet.get("packet_overage_reason"), "packet_overage_reason") if packet_size > PACKET_SOFT_LIMIT_BYTES and overage_reason.strip().lower() in SENTINELS: raise ProtocolError( @@ -506,6 +608,7 @@ def validate_packet( component_manifests = _object(manifests.get("components"), "manifests.components") if set(component_manifests) != set(components): raise ProtocolError("Component manifest and review-state names must match exactly.") + _dependency_map(manifests.get("dependency_map"), set(components)) for name, manifest_path in component_manifests.items(): if ( _pathspec_file(manifest_path, f"manifests.components[{name!r}]") @@ -514,6 +617,7 @@ def validate_packet( raise ProtocolError(f"Component manifest {name!r} must match review state exactly.") inventory_ids: set[str] = set() + inventory_digests: dict[str, str] = {} for index, raw_row in enumerate(_array(packet.get("inventory"), "inventory")): row = _object(raw_row, f"inventory[{index}]") row_id = _text(row.get("id"), f"inventory[{index}].id", concrete=True) @@ -529,6 +633,7 @@ def validate_packet( raise ProtocolError(f"Inventory {row_id} is missing {kind} fields: {missing_fields}.") for field in INVENTORY_FIELDS[kind]: _text(row[field], f"inventory[{index}].{field}") + inventory_digests[row_id] = _inventory_digest(row) if not inventory_ids: raise ProtocolError("inventory must not be empty.") @@ -545,13 +650,19 @@ def validate_packet( ) ledger = _object(packet.get("ledger"), "ledger") - ledger_path, ledger_data = _read_bytes(ledger.get("path"), "ledger.path") - if ledger_path.resolve() != expected_ledger_path: + ledger_path, ledger_data, ledger_identity = _read_bytes(ledger.get("path"), "ledger.path") + if ledger_path != expected_ledger_path: raise ProtocolError("ledger.path must match the control-plane ledger path.") if _json_bytes(ledger_data, str(ledger_path)) != ledger: raise ProtocolError("ledger.path content must match the packet ledger exactly.") if ledger.get("task_id") != expected_task_id: raise ProtocolError("ledger.task_id must match the control-plane task ID.") + round_fingerprint = _sha256( + ledger.get("round_fingerprint"), + "ledger.round_fingerprint", + ) + if round_fingerprint != combined: + raise ProtocolError("ledger.round_fingerprint must match the packet fingerprint.") authorized_budgets = [ _integer(value, f"ledger.authorized_round_budgets[{index}]", minimum=1) for index, value in enumerate( @@ -571,6 +682,7 @@ def validate_packet( ) canonical_roots: dict[str, dict[str, Any]] = {} inventory_owners: dict[str, str] = {} + owned_evidence_ids: set[str] = set() for index, raw_root in enumerate(_array(ledger.get("root_causes"), "ledger.root_causes")): root = _object(raw_root, f"ledger.root_causes[{index}]") root_id = _text(root.get("id"), f"ledger.root_causes[{index}].id") @@ -602,20 +714,39 @@ def validate_packet( "inventory_ids": root_inventory, "contract_evidence_ids": root_evidence, } + owned_evidence_ids.update(root_evidence) if set(inventory_owners) != inventory_ids: raise ProtocolError( "Every inventory ID must have exactly one canonical root owner; " f"unowned={sorted(inventory_ids - set(inventory_owners))}." ) + evidence_bindings = _digest_map( + ledger.get("contract_evidence_sha256"), + "ledger.contract_evidence_sha256", + owned_evidence_ids, + ) + inventory_bindings = _digest_map( + ledger.get("inventory_sha256"), + "ledger.inventory_sha256", + inventory_ids, + ) + for evidence_id, digest in evidence_bindings.items(): + if artifacts[evidence_id]["digest"] != digest: + raise ProtocolError(f"ledger evidence digest mismatch for {evidence_id}.") + for inventory_id, digest in inventory_bindings.items(): + if inventory_digests[inventory_id] != digest: + raise ProtocolError(f"ledger inventory digest mismatch for {inventory_id}.") if current_round > 1 and (prior_ledger_path is None or prior_ledger_sha256 is None): raise ProtocolError("Rounds after 1 require a digest-bound prior ledger snapshot.") if prior_ledger_path is not None or prior_ledger_sha256 is not None: if prior_ledger_path is None or prior_ledger_sha256 is None: raise ProtocolError("Prior ledger path and SHA-256 must be supplied together.") - if prior_ledger_path.resolve() == expected_ledger_path: + prior_path, prior_data, prior_identity = _read_bytes( + str(prior_ledger_path), "prior ledger path" + ) + if prior_identity == ledger_identity: raise ProtocolError("Prior ledger snapshot must be distinct from the current ledger.") - prior_path, prior_data = _read_bytes(str(prior_ledger_path), "prior ledger path") if not SHA256.fullmatch(prior_ledger_sha256): raise ProtocolError("Prior ledger SHA-256 must be a lowercase SHA-256 digest.") if hashlib.sha256(prior_data).hexdigest() != prior_ledger_sha256: @@ -639,6 +770,10 @@ def validate_packet( ) ] prior_round = _integer(prior.get("current_round"), "prior ledger.current_round", minimum=1) + prior_round_fingerprint = _sha256( + prior.get("round_fingerprint"), + "prior ledger.round_fingerprint", + ) prior_remaining = _integer( prior.get("remaining_budget"), "prior ledger.remaining_budget", minimum=0 ) @@ -650,7 +785,17 @@ def validate_packet( raise ProtocolError( "ledger.current_round must match the prior round or advance by exactly one." ) + if current_round == prior_round and authorized_budgets != prior_budgets: + raise ProtocolError( + "A same-round retry budget history must match the prior ledger snapshot." + ) + if current_round == prior_round and round_fingerprint != prior_round_fingerprint: + raise ProtocolError( + "A same-round retry fingerprint must match the prior ledger snapshot." + ) prior_roots: dict[str, dict[str, Any]] = {} + prior_owned_evidence_ids: set[str] = set() + prior_owned_inventory_ids: set[str] = set() for index, raw_root in enumerate( _array(prior.get("root_causes"), "prior ledger.root_causes") ): @@ -672,6 +817,18 @@ def validate_packet( ) ), } + prior_owned_evidence_ids.update(prior_roots[prior_id]["contract_evidence_ids"]) + prior_owned_inventory_ids.update(prior_roots[prior_id]["inventory_ids"]) + prior_evidence_bindings = _digest_map( + prior.get("contract_evidence_sha256"), + "prior ledger.contract_evidence_sha256", + prior_owned_evidence_ids, + ) + prior_inventory_bindings = _digest_map( + prior.get("inventory_sha256"), + "prior ledger.inventory_sha256", + prior_owned_inventory_ids, + ) for prior_id, prior_root in prior_roots.items(): current_root = canonical_roots.get(prior_id) if current_root is None: @@ -686,12 +843,38 @@ def validate_packet( current_root["contract_evidence_ids"] ): raise ProtocolError(f"ledger regressed ownership for prior root {prior_id}.") + for evidence_id in prior_root["contract_evidence_ids"]: + if evidence_bindings[evidence_id] != prior_evidence_bindings[evidence_id]: + raise ProtocolError(f"ledger changed prior evidence {evidence_id}.") + for inventory_id in prior_root["inventory_ids"]: + if inventory_bindings[inventory_id] != prior_inventory_bindings[inventory_id]: + raise ProtocolError(f"ledger changed prior inventory {inventory_id}.") + prior_evidence_digests = { + prior_evidence_bindings[evidence_id] + for evidence_id in prior_root["contract_evidence_ids"] + } + prior_inventory_digests = { + prior_inventory_bindings[inventory_id] + for inventory_id in prior_root["inventory_ids"] + } + content_new_evidence = { + evidence_id + for evidence_id in new_evidence + if artifacts[evidence_id]["digest"] not in prior_evidence_digests + } + content_new_inventory = { + inventory_id + for inventory_id in new_inventory + if inventory_bindings[inventory_id] not in prior_inventory_digests + } if ( prior_root["status"] == "closed" and current_root["status"] == "open" - and not (new_inventory or new_evidence) + and not (content_new_inventory or content_new_evidence) ): - raise ProtocolError(f"ledger reopened prior root {prior_id} without new evidence.") + raise ProtocolError( + f"ledger reopened prior root {prior_id} without content-new evidence." + ) selected_dimensions = set( _strings(packet.get("selected_high_risk_dimensions"), "selected_high_risk_dimensions") @@ -702,6 +885,8 @@ def validate_packet( reviewer_ids: set[str] = set() assigned_inventory: set[str] = set() assigned_dimensions: set[str] = set() + primary_specialty_owners: dict[str, str] = {} + high_risk_specialty_owners: dict[str, str] = {} for index, raw_assignment in enumerate(assignments): assignment = _object(raw_assignment, f"reviewer_assignments[{index}]") reviewer_id = _text(assignment.get("reviewer_id"), f"reviewer_assignments[{index}].id") @@ -718,10 +903,28 @@ def validate_packet( raise ProtocolError( f"Reviewer {reviewer_id} requires inventory and a primary specialty." ) + for dimension in primary_dimensions: + normalized = dimension.strip().casefold() + existing_reviewer = primary_specialty_owners.get(normalized) + if existing_reviewer is not None: + raise ProtocolError( + f"Reviewers {existing_reviewer} and {reviewer_id} have an overlapping " + f"primary specialty: {dimension!r}." + ) + primary_specialty_owners[normalized] = reviewer_id assigned_inventory.update(reviewer_inventory) reviewer_dimensions = set( _strings(assignment.get("high_risk_dimensions"), f"reviewer {reviewer_id} dimensions") ) + for dimension in reviewer_dimensions: + normalized = dimension.strip().casefold() + existing_reviewer = high_risk_specialty_owners.get(normalized) + if existing_reviewer is not None: + raise ProtocolError( + f"Reviewers {existing_reviewer} and {reviewer_id} have an overlapping " + f"high-risk specialty: {dimension!r}." + ) + high_risk_specialty_owners[normalized] = reviewer_id assigned_dimensions.update(reviewer_dimensions) reviewer_components = set( _strings(assignment.get("expected_components"), f"reviewer {reviewer_id} components") @@ -752,41 +955,78 @@ def validate_packet( _array(verification.get("preflight_results"), "verification.preflight_results") ): _command_result(result, f"verification.preflight_results[{index}]") - preflight_commands.add(result["command"]) - receipt_paths: set[Path] = set() + command = result["command"] + if command in preflight_commands: + raise ProtocolError(f"Duplicate preflight command: {command!r}.") + preflight_commands.add(command) + receipt_identities: set[FileIdentity] = set() + receipt_digests: set[str] = set() + receipt_digests_by_path: dict[str, str] = {} + receipt_commands: set[str] = set() for index, raw_receipt in enumerate( _array(verification.get("credited_receipts"), "verification.credited_receipts") ): - receipt_path, receipt_data, _ = _descriptor( + receipt_path, receipt_data, receipt_digest, receipt_identity = _descriptor( raw_receipt, f"verification.credited_receipts[{index}]" ) - if receipt_path in receipt_paths: - raise ProtocolError(f"Duplicate credited receipt path: {receipt_path}.") - receipt_paths.add(receipt_path) + if receipt_identity in receipt_identities: + raise ProtocolError(f"Duplicate credited receipt file identity: {receipt_path}.") + if receipt_digest in receipt_digests: + raise ProtocolError(f"Duplicate credited receipt digest: {receipt_digest}.") + receipt_identities.add(receipt_identity) + receipt_digests.add(receipt_digest) + receipt_digests_by_path[str(receipt_path)] = receipt_digest + receipt = _json_bytes(receipt_data, str(receipt_path)) validate_receipt_data( - _json_bytes(receipt_data, str(receipt_path)), + receipt, combined, components, state["repository_fingerprint"], preflight_commands, ) + command = receipt["command"] + if command in receipt_commands: + raise ProtocolError(f"Duplicate credited receipt command: {command!r}.") + receipt_commands.add(command) _strings(packet.get("architecture_references"), "architecture_references") return { - "packet_path": str(path.resolve()), + "packet_path": str(packet_path), "packet_size_bytes": packet_size, - "packet_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "packet_sha256": hashlib.sha256(packet_data).hexdigest(), + "ledger_sha256": hashlib.sha256(ledger_data).hexdigest(), "review_state_path": str(artifacts[_at(packet, "review_state.evidence_id")]["path"]), "combined_fingerprint": combined, "components": components, "inventory_ids": sorted(inventory_ids), "reviewer_ids": sorted(reviewer_ids), - "credited_receipt_paths": sorted( - str(receipt_path.resolve()) for receipt_path in receipt_paths - ), + "credited_receipt_digests": dict(sorted(receipt_digests_by_path.items())), + "credited_receipt_paths": sorted(receipt_digests_by_path), } +def _revalidate_control_files( + packet_path: Path, + expected_ledger_path: Path, + prior_ledger_path: Path | None, + prior_ledger_sha256: str | None, + summary: dict[str, Any], +) -> tuple[dict[str, Any], bytes | None]: + packet_data = _read_unchanged(packet_path, summary["packet_sha256"], "Packet") + packet = _json_bytes(packet_data, str(packet_path.resolve())) + _read_unchanged(expected_ledger_path, summary["ledger_sha256"], "Current ledger") + if prior_ledger_path is None: + return packet, None + if prior_ledger_sha256 is None: + raise ProtocolError("Prior ledger path and SHA-256 must be supplied together.") + prior_ledger_data = _read_unchanged( + prior_ledger_path, + prior_ledger_sha256, + "Prior ledger", + ) + return packet, prior_ledger_data + + def validate_reviewer_output( packet_path: Path, reviewer_id: str, @@ -803,11 +1043,15 @@ def validate_reviewer_output( prior_ledger_path, prior_ledger_sha256, ) - packet = _load_json(packet_path) + packet, prior_ledger_data = _revalidate_control_files( + packet_path, + expected_ledger_path, + prior_ledger_path, + prior_ledger_sha256, + summary, + ) output = _load_json(output_path) - missing = sorted(REVIEWER_OUTPUT_FIELDS - output.keys()) - if missing: - raise ProtocolError(f"Reviewer output is missing fields: {missing}.") + _require_exact_fields(output, REVIEWER_OUTPUT_FIELDS, "Reviewer output") if output["verdict"] not in { "clean", "findings require fixes", @@ -816,11 +1060,12 @@ def validate_reviewer_output( }: raise ProtocolError(f"Invalid reviewer verdict: {output['verdict']!r}.") expected_fingerprints = { + "packet": summary["packet_sha256"], "combined": summary["combined_fingerprint"], "components": summary["components"], } if output["reviewed_fingerprints"] != expected_fingerprints: - raise ProtocolError("Reviewer fingerprints do not match the packet exactly.") + raise ProtocolError("Reviewer packet digest or fingerprints do not match exactly.") assignment = next( (item for item in packet["reviewer_assignments"] if item.get("reviewer_id") == reviewer_id), None, @@ -834,6 +1079,11 @@ def validate_reviewer_output( _array(output["unchecked_inventory_ids"], "unchecked_inventory_ids") ): item = _object(raw_item, f"unchecked_inventory_ids[{index}]") + _require_exact_fields( + item, + {"id", "reason"}, + f"unchecked_inventory_ids[{index}]", + ) unchecked_id = _text(item.get("id"), f"unchecked_inventory_ids[{index}].id") if unchecked_id in unchecked: raise ProtocolError(f"Duplicate unchecked inventory ID: {unchecked_id}.") @@ -850,19 +1100,25 @@ def validate_reviewer_output( canonical_roots = {root["id"]: root for root in packet["ledger"]["root_causes"]} prior_canonical_roots: dict[str, dict[str, Any]] = {} - if prior_ledger_path is not None: - prior_ledger = _load_json(prior_ledger_path) + prior_evidence_bindings: dict[str, str] = {} + prior_inventory_bindings: dict[str, str] = {} + if prior_ledger_data is not None: + prior_ledger = _json_bytes(prior_ledger_data, str(prior_ledger_path)) prior_canonical_roots = {root["id"]: root for root in prior_ledger["root_causes"]} - indexed_evidence = {artifact["id"] for artifact in packet["evidence_artifacts"]} + prior_evidence_bindings = prior_ledger["contract_evidence_sha256"] + prior_inventory_bindings = prior_ledger["inventory_sha256"] + evidence_digests = { + artifact["id"]: artifact["sha256"] for artifact in packet["evidence_artifacts"] + } + inventory_digests = packet["ledger"]["inventory_sha256"] + indexed_evidence = set(evidence_digests) indexed_inventory = {row["id"] for row in packet["inventory"]} owned_evidence = { evidence_id for root in canonical_roots.values() for evidence_id in root["contract_evidence_ids"] } - owned_inventory = { - inventory_id for root in canonical_roots.values() for inventory_id in root["inventory_ids"] - } + owned_evidence_digests = {evidence_digests[evidence_id] for evidence_id in owned_evidence} inventory_owners = { inventory_id: root_id for root_id, root in canonical_roots.items() @@ -870,17 +1126,21 @@ def validate_reviewer_output( } findings = _array(output["findings"], "findings") proposed_roots: set[str] = set() + proposed_evidence_owners: dict[str, str] = {} for index, raw_finding in enumerate(findings): finding = _object(raw_finding, f"findings[{index}]") - missing_finding = sorted(FINDING_FIELDS - finding.keys()) - if missing_finding: - raise ProtocolError(f"Finding {index} is missing fields: {missing_finding}.") + _require_exact_fields(finding, FINDING_FIELDS, f"Finding {index}") if finding["priority"] not in {"P0", "P1", "P2", "P3"}: raise ProtocolError(f"Finding {index} has an invalid priority.") for field in FINDING_FIELDS - {"priority", "root_cause_id", "root_cause_evidence"}: _text(finding[field], f"findings[{index}].{field}") root_id = _text(finding["root_cause_id"], f"findings[{index}].root_cause_id") evidence = _object(finding["root_cause_evidence"], f"findings[{index}].root_cause_evidence") + _require_exact_fields( + evidence, + {"new_contract_evidence_ids", "new_inventory_ids"}, + f"findings[{index}].root_cause_evidence", + ) new_evidence = set( _strings(evidence.get("new_contract_evidence_ids"), f"finding {index} evidence") ) @@ -914,20 +1174,64 @@ def validate_reviewer_output( raise ProtocolError( f"Finding {index} root evidence must be new in the current ledger round." ) - new_for_root = bool(new_evidence or new_inventory) + prior_evidence_digests = { + prior_evidence_bindings[evidence_id] for evidence_id in prior_evidence + } + prior_inventory_digests = { + prior_inventory_bindings[inventory_id] for inventory_id in prior_inventory + } + content_new_evidence = { + evidence_id + for evidence_id in new_evidence + if evidence_digests[evidence_id] not in prior_evidence_digests + } + content_new_inventory = { + inventory_id + for inventory_id in new_inventory + if inventory_digests[inventory_id] not in prior_inventory_digests + } + new_for_root = bool(content_new_evidence or content_new_inventory) if root["status"] == "closed" and not new_for_root: raise ProtocolError( - f"Finding {index} reopens closed root {root_id} without new evidence." + f"Finding {index} reopens closed root {root_id} without content-new evidence." ) elif not NEW_ROOT_CAUSE_ID.fullmatch(root_id): raise ProtocolError( f"Finding {index} must reuse a canonical root ID or propose NEW:." ) - elif not (new_evidence - owned_evidence or new_inventory - owned_inventory): - raise ProtocolError( - f"Finding {index} proposes {root_id} without globally unowned evidence." - ) else: + if new_inventory: + raise ProtocolError( + f"Finding {index} new root proposal cannot reuse canonical inventory: " + f"{sorted(new_inventory)}." + ) + proposal_digests = { + evidence_digests[evidence_id] + for evidence_id in new_evidence + if evidence_digests[evidence_id] not in owned_evidence_digests + } + available_digests = { + digest + for digest in proposal_digests + if proposed_evidence_owners.get(digest) in {None, root_id} + } + if not available_digests: + existing_owners = sorted( + { + proposed_evidence_owners[digest] + for digest in proposal_digests + if digest in proposed_evidence_owners + } + ) + if existing_owners: + raise ProtocolError( + f"Finding {index} reuses evidence owned by proposed root {existing_owners}." + ) + raise ProtocolError( + f"Finding {index} proposes {root_id} without content-new evidence." + ) + for digest in available_digests: + proposed_evidence_owners.setdefault(digest, root_id) proposed_roots.add(root_id) uncertainty = _strings(output["remaining_uncertainty"], "remaining_uncertainty") @@ -946,6 +1250,11 @@ def validate_reviewer_output( _array(output["sibling_scenario_scan"], "sibling_scenario_scan") ): scan = _object(raw_scan, f"sibling_scenario_scan[{index}]") + _require_exact_fields( + scan, + {"root_cause_id", "inventory_ids", "result"}, + f"sibling_scenario_scan[{index}]", + ) root_id = _text(scan.get("root_cause_id"), f"sibling_scenario_scan[{index}].root_cause_id") if root_id not in canonical_roots and root_id not in proposed_roots: raise ProtocolError( @@ -964,6 +1273,41 @@ def validate_reviewer_output( } +def _validate_credited_receipt( + packet_path: Path, + receipt_path: Path, + expected_task_id: str, + expected_ledger_path: Path, + prior_ledger_path: Path | None = None, + prior_ledger_sha256: str | None = None, +) -> dict[str, Any]: + summary = validate_packet( + packet_path, + expected_task_id, + expected_ledger_path, + prior_ledger_path, + prior_ledger_sha256, + ) + _revalidate_control_files( + packet_path, + expected_ledger_path, + prior_ledger_path, + prior_ledger_sha256, + summary, + ) + canonical_receipt_path = receipt_path.resolve() + expected_digest = summary["credited_receipt_digests"].get(str(canonical_receipt_path)) + if expected_digest is None: + raise ProtocolError("The receipt path is not indexed by the validated packet.") + _read_unchanged(canonical_receipt_path, expected_digest, "Receipt") + return { + "receipt_path": str(canonical_receipt_path), + "receipt_sha256": expected_digest, + "combined_fingerprint": summary["combined_fingerprint"], + "reusable": True, + } + + def main() -> None: parser = argparse.ArgumentParser() commands = parser.add_subparsers(dest="command", required=True) @@ -1002,21 +1346,14 @@ def main() -> None: args.prior_ledger_sha256, ) else: - summary = validate_packet( + result = _validate_credited_receipt( args.packet, + args.receipt, args.task_id, args.ledger, args.prior_ledger, args.prior_ledger_sha256, ) - receipt_path = str(args.receipt.resolve()) - if receipt_path not in summary["credited_receipt_paths"]: - raise ProtocolError("The receipt path is not indexed by the validated packet.") - result = { - "receipt_path": receipt_path, - "combined_fingerprint": summary["combined_fingerprint"], - "reusable": True, - } except ProtocolError as error: parser.error(str(error)) print(json.dumps(result, indent=2, sort_keys=True)) diff --git a/.agents/skills/implementation-final-review/scripts/review_state.py b/.agents/skills/implementation-final-review/scripts/review_state.py index 6ae9858897..3fdbe1a613 100644 --- a/.agents/skills/implementation-final-review/scripts/review_state.py +++ b/.agents/skills/implementation-final-review/scripts/review_state.py @@ -8,7 +8,10 @@ import json import os import re +import stat import subprocess +import tempfile +from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -35,6 +38,166 @@ def _digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +@dataclass(frozen=True, slots=True) +class _Snapshot: + tracked_diff: bytes + complete_diff: bytes + status: bytes + workspace: list[dict[str, object]] + unfiltered_status: bytes + unfiltered_workspace: list[dict[str, object]] + component_workspaces: dict[str, list[dict[str, object]]] + + +class _NonRegularFileError(ValueError): + pass + + +def _nonblocking_opener(path: str, flags: int) -> int: + return os.open(path, flags | getattr(os, "O_NONBLOCK", 0)) + + +def _read_regular_file(path: Path) -> tuple[bytes, os.stat_result]: + with open(path, "rb", opener=_nonblocking_opener) as file: + file_stat = os.fstat(file.fileno()) + if not stat.S_ISREG(file_stat.st_mode): + raise _NonRegularFileError(path) + return file.read(), file_stat + + +def _unsafe_index_paths(repo: Path) -> tuple[tuple[str, str], ...]: + raw_entries = _git(repo, "ls-files", "-v", "-z") + unsafe_paths: list[tuple[str, str]] = [] + for entry in raw_entries.split(b"\0"): + if len(entry) < 3 or entry[1:2] != b" ": + continue + tag = entry[:1] + relative_path = os.fsdecode(entry[2:]) + if tag.islower(): + unsafe_paths.append(("assume-unchanged", relative_path)) + elif tag == b"S": + candidate = repo / relative_path + if candidate.exists() or candidate.is_symlink(): + unsafe_paths.append(("materialized skip-worktree", relative_path)) + for entry in _git(repo, "ls-files", "--unmerged", "-z").split(b"\0"): + _, separator, raw_path = entry.partition(b"\t") + if separator: + unsafe_paths.append(("unmerged", os.fsdecode(raw_path))) + return tuple(sorted(set(unsafe_paths))) + + +def _require_reviewable_index(repo: Path, context: str = "repository") -> None: + unsafe_paths = _unsafe_index_paths(repo) + if unsafe_paths: + details = ", ".join(f"{kind}={path}" for kind, path in unsafe_paths) + raise ValueError(f"The {context} contains unsupported index state: {details}") + + +def _index_gitlinks(repo: Path) -> dict[str, str]: + raw_entries = _git(repo, "ls-files", "--stage", "-z") + gitlinks: dict[str, str] = {} + for raw_entry in raw_entries.split(b"\0"): + metadata, separator, raw_path = raw_entry.partition(b"\t") + fields = metadata.split() + if separator and len(fields) == 3 and fields[0] == b"160000" and fields[2] == b"0": + gitlinks[os.fsdecode(raw_path)] = fields[1].decode() + return gitlinks + + +def _is_repository_root(path: Path) -> bool: + try: + top_level = _git(path, "rev-parse", "--show-toplevel") + except (subprocess.CalledProcessError, FileNotFoundError): + return False + return Path(os.fsdecode(top_level.rstrip(b"\n"))).resolve() == path.resolve() + + +def _require_clean_submodule( + repo: Path, + display_path: str, + expected_head: str, + ancestors: frozenset[Path], +) -> None: + resolved_repo = repo.resolve() + if resolved_repo in ancestors: + raise ValueError(f"Cyclic submodule worktree is unsupported: {display_path}") + ancestors |= {resolved_repo} + _require_reviewable_index(repo, f"submodule {display_path}") + actual_head = _git(repo, "rev-parse", "HEAD^{commit}").decode().strip() + if actual_head != expected_head: + raise ValueError(f"Submodule HEAD does not match the parent index: {display_path}") + for nested_relative_path, nested_head in _index_gitlinks(repo).items(): + nested_path = repo / nested_relative_path + _require_clean_gitlink( + nested_path, + f"{display_path}/{nested_relative_path}", + nested_head, + ancestors, + ) + if _git( + repo, + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ): + raise ValueError(f"Dirty submodule worktrees are unsupported: {display_path}") + + +def _require_clean_gitlink( + path: Path, + display_path: str, + expected_head: str, + ancestors: frozenset[Path], +) -> None: + if _is_repository_root(path): + _require_clean_submodule(path, display_path, expected_head, ancestors) + elif path.is_dir() and any(path.iterdir()): + raise ValueError(f"Materialized gitlink is not an initialized submodule: {display_path}") + + +def _require_clean_submodules(repo: Path) -> None: + ancestors = frozenset({repo.resolve()}) + for relative_path, expected_head in _index_gitlinks(repo).items(): + _require_clean_gitlink( + repo / relative_path, + relative_path, + expected_head, + ancestors, + ) + + +def _write_bytes_atomically(path: Path, data: bytes) -> None: + descriptor, temporary_name = tempfile.mkstemp( + prefix=".review-state-diff-", + dir=path.parent, + ) + try: + with os.fdopen(descriptor, "wb") as temporary_file: + temporary_file.write(data) + os.replace(temporary_name, path) + finally: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + + +def _directory_is_within(path: Path, root: Path) -> bool: + current = path + while True: + try: + if current.samefile(root): + return True + except OSError: + pass + parent = current.parent + if parent == current: + return False + current = parent + + def _canonical_pathspecs(pathspecs: tuple[str, ...]) -> tuple[str, ...]: canonical: list[str] = [] seen: set[str] = set() @@ -49,14 +212,42 @@ def _canonical_pathspecs(pathspecs: tuple[str, ...]) -> tuple[str, ...]: return tuple(canonical) +def _base_has_literal_path(repo: Path, base: str, pathspec: str) -> bool: + raw_path = os.fsencode(pathspec) + entries = _git( + repo, + "ls-tree", + "-z", + base, + "--", + f":(literal){pathspec}", + ) + for entry in entries.split(b"\0"): + metadata, separator, entry_path = entry.partition(b"\t") + fields = metadata.split() + if separator and entry_path == raw_path and len(fields) >= 2 and fields[1] != b"tree": + return True + return False + + def _load_pathspec_file(path: Path) -> tuple[str, ...]: try: - values = [line for line in path.read_text().splitlines() if line] - except (OSError, UnicodeError) as error: + data, _ = _read_regular_file(path) + values = [line for line in data.decode().splitlines() if line] + except (OSError, UnicodeError, ValueError) as error: raise ValueError(f"Cannot read pathspec file {path}: {error}") from error return _canonical_pathspecs(tuple(values)) +def _read_workspace_file(path: Path, relative_path: str) -> tuple[bytes, os.stat_result]: + try: + return _read_regular_file(path) + except _NonRegularFileError as error: + raise ValueError(f"Unsupported workspace file type: {relative_path}") from error + except OSError as error: + raise ValueError(f"Cannot read workspace file: {relative_path}") from error + + def _workspace_entry(repo: Path, relative_path: str) -> dict[str, object]: path = repo / relative_path if path.is_symlink(): @@ -67,43 +258,52 @@ def _workspace_entry(repo: Path, relative_path: str) -> dict[str, object]: "sha256": _digest(content), } if path.is_file(): - content = b"file\0" + path.read_bytes() + file_content, file_stat = _read_workspace_file(path, relative_path) + content = b"file\0" + file_content return { "path": relative_path, "kind": "file", - "executable": bool(path.stat().st_mode & 0o111), + "executable": bool(file_stat.st_mode & 0o100), "sha256": _digest(content), } + indexed_head = _index_gitlinks(repo).get(relative_path) if path.is_dir(): - try: - submodule_head = _git(path, "rev-parse", "HEAD^{commit}").decode().strip() - submodule_status = _git(path, "status", "--porcelain=v1", "-z") - except (subprocess.CalledProcessError, FileNotFoundError): - return {"path": relative_path, "kind": "directory"} + if indexed_head is not None: + return { + "path": relative_path, + "kind": "gitlink", + "head": indexed_head, + } + if _is_repository_root(path): + raise ValueError(f"Untracked nested Git repositories are unsupported: {relative_path}") + return {"path": relative_path, "kind": "directory"} + if indexed_head is not None: return { "path": relative_path, "kind": "gitlink", - "head": submodule_head, - "status_sha256": _digest(submodule_status), + "head": indexed_head, } + if path.exists(): + raise ValueError(f"Unsupported workspace file type: {relative_path}") return {"path": relative_path, "kind": "missing"} def _workspace_entries( repo: Path, base: str, pathspecs: tuple[str, ...] ) -> list[dict[str, object]]: - git_pathspecs = _git_pathspecs(repo, pathspecs) + git_pathspecs = _git_pathspecs(repo, base, pathspecs) tracked_paths = _git( repo, "diff", "--name-only", "--no-renames", + "--ignore-submodules=none", "-z", base, "--", *git_pathspecs, ) - untracked_paths = _untracked_paths(repo, pathspecs) + untracked_paths = _untracked_paths(repo, base, pathspecs) paths = { os.fsdecode(raw_path) for raw_path in (*tracked_paths.split(b"\0"), *untracked_paths) @@ -112,8 +312,8 @@ def _workspace_entries( return [_workspace_entry(repo, relative_path) for relative_path in sorted(paths)] -def _untracked_paths(repo: Path, pathspecs: tuple[str, ...]) -> tuple[bytes, ...]: - literal_pathspecs = _literal_pathspecs(repo, pathspecs) +def _untracked_paths(repo: Path, base: str, pathspecs: tuple[str, ...]) -> tuple[bytes, ...]: + literal_pathspecs = _literal_pathspecs(repo, base, pathspecs) raw_paths = _git( repo, "ls-files", @@ -121,7 +321,7 @@ def _untracked_paths(repo: Path, pathspecs: tuple[str, ...]) -> tuple[bytes, ... "--exclude-standard", "-z", "--", - *_git_pathspecs(repo, pathspecs, literal_pathspecs), + *_git_pathspecs(repo, base, pathspecs, literal_pathspecs), ) paths = {raw_path for raw_path in raw_paths.split(b"\0") if raw_path} for pathspec in literal_pathspecs: @@ -132,11 +332,9 @@ def _untracked_paths(repo: Path, pathspecs: tuple[str, ...]) -> tuple[bytes, ... return tuple(sorted(paths)) -def _literal_pathspecs(repo: Path, pathspecs: tuple[str, ...]) -> frozenset[str]: +def _literal_pathspecs(repo: Path, base: str, pathspecs: tuple[str, ...]) -> frozenset[str]: literal_pathspecs: set[str] = set() for pathspec in pathspecs: - if pathspec.startswith(":("): - continue relative_path = PurePosixPath(pathspec) if ( relative_path.is_absolute() @@ -147,17 +345,24 @@ def _literal_pathspecs(repo: Path, pathspecs: tuple[str, ...]) -> frozenset[str] candidate = repo.joinpath(*relative_path.parts) raw_path = os.fsencode(pathspec) tracked_paths = _git(repo, "ls-files", "-z", "--", f":(literal){pathspec}") - if candidate.is_file() or candidate.is_symlink() or raw_path in tracked_paths.split(b"\0"): + if ( + (candidate.exists() and not candidate.is_dir()) + or candidate.is_symlink() + or raw_path in tracked_paths.split(b"\0") + or _base_has_literal_path(repo, base, pathspec) + ): literal_pathspecs.add(pathspec) return frozenset(literal_pathspecs) def _git_pathspecs( repo: Path, + base: str, pathspecs: tuple[str, ...], literal_pathspecs: frozenset[str] | None = None, ) -> tuple[str, ...]: - literal_pathspecs = literal_pathspecs or _literal_pathspecs(repo, pathspecs) + if literal_pathspecs is None: + literal_pathspecs = _literal_pathspecs(repo, base, pathspecs) return tuple( f":(literal){pathspec}" if pathspec in literal_pathspecs else pathspec for pathspec in pathspecs @@ -171,12 +376,13 @@ def _complete_diff(repo: Path, base: str, pathspecs: tuple[str, ...]) -> bytes: "diff", "--binary", "--full-index", + "--ignore-submodules=none", base, "--", - *_git_pathspecs(repo, pathspecs), + *_git_pathspecs(repo, base, pathspecs), ) ] - for raw_path in _untracked_paths(repo, pathspecs): + for raw_path in _untracked_paths(repo, base, pathspecs): chunks.append( _git_diff( repo, @@ -195,7 +401,7 @@ def _complete_diff(repo: Path, base: str, pathspecs: tuple[str, ...]) -> bytes: def _content_fingerprint(base: str, workspace: list[dict[str, object]]) -> str: canonical = json.dumps( {"base": base, "workspace": workspace}, - ensure_ascii=False, + ensure_ascii=True, sort_keys=True, separators=(",", ":"), ) @@ -229,6 +435,59 @@ def _repository_fingerprint( return _digest(canonical.encode()) +def _capture_snapshot( + repo: Path, + base: str, + pathspecs: tuple[str, ...], + components: dict[str, tuple[str, ...]], +) -> _Snapshot: + workspace = _workspace_entries(repo, base, pathspecs) + unfiltered_workspace = _workspace_entries(repo, base, ()) + unfiltered_by_path = {str(entry["path"]): entry for entry in unfiltered_workspace} + for entry in workspace: + unfiltered_by_path.setdefault(str(entry["path"]), entry) + unfiltered_workspace = [unfiltered_by_path[path] for path in sorted(unfiltered_by_path)] + component_workspaces = { + name: _workspace_entries(repo, base, component_pathspecs) + for name, component_pathspecs in components.items() + } + git_pathspecs = _git_pathspecs(repo, base, pathspecs) + return _Snapshot( + tracked_diff=_git( + repo, + "diff", + "--binary", + "--full-index", + "--ignore-submodules=none", + base, + "--", + *git_pathspecs, + ), + complete_diff=_complete_diff(repo, base, pathspecs), + status=_git( + repo, + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + "--", + *git_pathspecs, + ), + workspace=workspace, + unfiltered_status=_git( + repo, + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ), + unfiltered_workspace=unfiltered_workspace, + component_workspaces=component_workspaces, + ) + + def review_state( repo: Path, base: str, @@ -237,6 +496,17 @@ def review_state( complete_diff_output: Path | None = None, ) -> dict[str, object]: repo = repo.resolve() + top_level = Path( + os.fsdecode(_git(repo, "rev-parse", "--show-toplevel").rstrip(b"\n")) + ).resolve() + if not top_level.samefile(repo): + raise ValueError(f"Repository path must be the worktree root: {top_level}") + if complete_diff_output is not None: + complete_diff_output = complete_diff_output.expanduser().resolve() + if _directory_is_within(complete_diff_output.parent, repo): + raise ValueError("Complete diff output must be outside the repository.") + _require_reviewable_index(repo) + _require_clean_submodules(repo) pathspecs = _canonical_pathspecs(pathspecs) if components and not pathspecs: pathspecs = _canonical_pathspecs( @@ -252,47 +522,29 @@ def review_state( _git(repo, "merge-base", "--is-ancestor", resolved_base, head) except subprocess.CalledProcessError as error: raise ValueError("Base must be an ancestor of HEAD.") from error - tracked_diff = _git( - repo, - "diff", - "--binary", - "--full-index", - resolved_base, - "--", - *_git_pathspecs(repo, pathspecs), - ) - complete_diff = _complete_diff(repo, resolved_base, pathspecs) - status = _git( - repo, - "status", - "--porcelain=v1", - "-z", - "--untracked-files=all", - "--", - *_git_pathspecs(repo, pathspecs), - ) - workspace = _workspace_entries(repo, resolved_base, pathspecs) - unfiltered_status = _git( - repo, - "status", - "--porcelain=v1", - "-z", - "--untracked-files=all", - ) - unfiltered_workspace = _workspace_entries(repo, resolved_base, ()) - unfiltered_by_path = {str(entry["path"]): entry for entry in unfiltered_workspace} - for entry in workspace: - unfiltered_by_path.setdefault(str(entry["path"]), entry) - unfiltered_workspace = [unfiltered_by_path[path] for path in sorted(unfiltered_by_path)] - - content_fingerprint = _content_fingerprint(resolved_base, workspace) - component_states: dict[str, dict[str, object]] = {} - component_owners: dict[str, list[str]] = {} + canonical_components: dict[str, tuple[str, ...]] = {} for name, component_pathspecs in sorted((components or {}).items()): canonical_component_pathspecs = _canonical_pathspecs(component_pathspecs) if not canonical_component_pathspecs: raise ValueError(f"Component manifest is empty: {name}") - component_workspace = _workspace_entries(repo, resolved_base, canonical_component_pathspecs) + canonical_components[name] = canonical_component_pathspecs + + snapshot = _capture_snapshot(repo, resolved_base, pathspecs, canonical_components) + _require_reviewable_index(repo) + _require_clean_submodules(repo) + final_snapshot = _capture_snapshot(repo, resolved_base, pathspecs, canonical_components) + final_head = _git(repo, "rev-parse", "HEAD^{commit}").decode().strip() + _require_reviewable_index(repo) + _require_clean_submodules(repo) + if final_head != head or final_snapshot != snapshot: + raise ValueError("Repository changed while review state was captured.") + snapshot = final_snapshot + + content_fingerprint = _content_fingerprint(resolved_base, snapshot.workspace) + component_states: dict[str, dict[str, object]] = {} + component_owners: dict[str, list[str]] = {} + for name, canonical_component_pathspecs in canonical_components.items(): + component_workspace = snapshot.component_workspaces[name] for entry in component_workspace: component_owners.setdefault(str(entry["path"]), []).append(name) component_states[name] = { @@ -301,7 +553,7 @@ def review_state( "workspace": component_workspace, } if component_states: - combined_paths = {str(entry["path"]) for entry in workspace} + combined_paths = {str(entry["path"]) for entry in snapshot.workspace} component_paths = set(component_owners) missing_paths = sorted(combined_paths - component_paths) extra_paths = sorted(component_paths - combined_paths) @@ -318,29 +570,31 @@ def review_state( repository_state = { "content_fingerprint": content_fingerprint, "head": head, - "status_sha256": _digest(status), - "tracked_diff_sha256": _digest(tracked_diff), - "complete_diff_sha256": _digest(complete_diff), + "status_sha256": _digest(snapshot.status), + "tracked_diff_sha256": _digest(snapshot.tracked_diff), + "complete_diff_sha256": _digest(snapshot.complete_diff), } repository_fingerprint = _repository_fingerprint( **repository_state, - unfiltered_status_sha256=_digest(unfiltered_status), - unfiltered_content_fingerprint=_content_fingerprint(resolved_base, unfiltered_workspace), + unfiltered_status_sha256=_digest(snapshot.unfiltered_status), + unfiltered_content_fingerprint=_content_fingerprint( + resolved_base, snapshot.unfiltered_workspace + ), ) if complete_diff_output is not None: - complete_diff_output.write_bytes(complete_diff) + _write_bytes_atomically(complete_diff_output, snapshot.complete_diff) return { "fingerprint": content_fingerprint, "content_fingerprint": content_fingerprint, "repository_fingerprint": repository_fingerprint, "base": resolved_base, "pathspecs": list(pathspecs), - "workspace": workspace, - "complete_diff_paths": [str(entry["path"]) for entry in workspace], + "workspace": snapshot.workspace, + "complete_diff_paths": [str(entry["path"]) for entry in snapshot.workspace], "components": component_states, "unfiltered": { - "status_sha256": _digest(unfiltered_status), - "workspace": unfiltered_workspace, + "status_sha256": _digest(snapshot.unfiltered_status), + "workspace": snapshot.unfiltered_workspace, }, **repository_state, } @@ -400,7 +654,12 @@ def main() -> None: metavar="NAME=PATHSPEC", help="Named component pathspec. Repeat a name to group paths into one fingerprint.", ) - parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Repository worktree path.") + parser.add_argument( + "--repo", + type=Path, + default=Path.cwd(), + help="Repository worktree root path.", + ) parser.add_argument( "--complete-diff-output", type=Path, @@ -442,7 +701,7 @@ def main() -> None: print( json.dumps( state, - ensure_ascii=False, + ensure_ascii=True, indent=2 if args.pretty else None, sort_keys=True, ) diff --git a/.agents/skills/implementation-final-review/scripts/test_review_protocol.py b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py index 4f409cb393..fe7cf4a378 100644 --- a/.agents/skills/implementation-final-review/scripts/test_review_protocol.py +++ b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py @@ -5,16 +5,22 @@ import copy import hashlib import json +import os import subprocess import sys import tempfile import unittest from pathlib import Path +from typing import Any +from unittest import mock sys.path.insert(0, str(Path(__file__).parent)) from review_protocol import ( ProtocolError, + _inventory_digest, + _read_bytes, + _validate_credited_receipt, _workspace_entries, validate_packet, validate_receipt_data, @@ -129,7 +135,7 @@ def _validate_output(self, reviewer_id: str) -> dict[str, object]: ) def _packet(self) -> dict[str, object]: - return { + packet: dict[str, Any] = { "schema_version": 1, "packet_overage_reason": "none", "task": { @@ -156,6 +162,7 @@ def _packet(self) -> dict[str, object]: "ledger": { "path": str(self.ledger_path), "task_id": "task-123", + "round_fingerprint": self.combined, "authorized_round_budgets": [6], "current_round": 1, "remaining_budget": 5, @@ -177,7 +184,14 @@ def _packet(self) -> dict[str, object]: "manifests": { "task": str(self.task_manifest), "components": {"api-contract": str(self.component_manifest)}, - "dependency_map": "api-contract has no task-owned dependents.", + "dependency_map": { + "api-contract": [ + { + "pathspec": "src/example.py", + "reason": "The source file defines the reviewed API contract.", + } + ] + }, }, "review_state": { "evidence_id": "E-STATE", @@ -279,6 +293,20 @@ def _packet(self) -> dict[str, object]: }, ], } + owned_evidence = { + evidence_id + for root in packet["ledger"]["root_causes"] + for evidence_id in root["contract_evidence_ids"] + } + packet["ledger"]["contract_evidence_sha256"] = { + artifact["id"]: artifact["sha256"] + for artifact in packet["evidence_artifacts"] + if artifact["id"] in owned_evidence + } + packet["ledger"]["inventory_sha256"] = { + row["id"]: _inventory_digest(row) for row in packet["inventory"] + } + return packet def _receipt(self) -> dict[str, object]: fingerprints = { @@ -300,6 +328,7 @@ def _output(self) -> dict[str, object]: return { "verdict": "clean", "reviewed_fingerprints": { + "packet": hashlib.sha256(self.packet_path.read_bytes()).hexdigest(), "combined": self.combined, "components": {"api-contract": self.component}, }, @@ -339,6 +368,49 @@ def test_valid_packet_reports_dispatch_digest_and_size(self) -> None: self.assertEqual( summary["packet_sha256"], hashlib.sha256(self.packet_path.read_bytes()).hexdigest() ) + self.assertEqual( + summary["ledger_sha256"], hashlib.sha256(self.ledger_path.read_bytes()).hexdigest() + ) + + def test_packet_rejects_duplicate_json_keys(self) -> None: + packet_text = self.packet_path.read_text() + self.packet_path.write_text( + packet_text.replace( + '"task": {\n "id": "task-123",', + '"task": {\n "id": "task-123",\n "id": "task-123",', + 1, + ) + ) + + with self.assertRaisesRegex(ProtocolError, "Duplicate JSON key.*id"): + self._validate_packet() + + def test_packet_rejects_non_finite_json_numbers(self) -> None: + """Reject numeric constants and exponents that parse as non-finite.""" + for encoded_value in ("NaN", "1e999"): + with self.subTest(encoded_value=encoded_value): + packet = copy.deepcopy(self.packet) + packet["ignored_number"] = 0 + self._write_packet(self.packet_path, packet) + packet_text = self.packet_path.read_text().replace( + '"ignored_number": 0', + f'"ignored_number": {encoded_value}', + 1, + ) + self.packet_path.write_text(packet_text) + + with self.assertRaisesRegex(ProtocolError, "Non-finite JSON number"): + self._validate_packet() + + def test_packet_reports_json_parser_limits_as_protocol_errors(self) -> None: + """Convert runtime parser limits into concise protocol failures.""" + for error in (ValueError("integer limit"), RecursionError("nesting limit")): + with ( + self.subTest(error=type(error).__name__), + mock.patch("review_protocol.json.loads", side_effect=error), + self.assertRaisesRegex(ProtocolError, "Cannot read JSON object"), + ): + self._validate_packet() def test_packet_defers_broad_final_gates_until_clean_review(self) -> None: packet = copy.deepcopy(self.packet) @@ -414,6 +486,35 @@ def test_packet_resolves_manifest_and_ledger_authority(self) -> None: with self.assertRaisesRegex(ProtocolError, "Cannot read JSON object"): self._validate_packet() + def test_dependency_map_requires_exact_component_entries(self) -> None: + """Require complete machine-readable component dependency boundaries.""" + valid_entry = { + "pathspec": "src/example.py", + "reason": "The source file defines the reviewed API contract.", + } + cases = ( + ("api-contract has no dependencies.", "dependency_map must be an object"), + ({}, "must cover the exact component names"), + ({"api-contract": []}, "must contain at least one dependency"), + ( + {"api-contract": [{**valid_entry, "note": "unvalidated"}]}, + "unexpected=\\['note'\\]", + ), + ( + {"api-contract": [valid_entry, copy.deepcopy(valid_entry)]}, + "contains duplicate pathspec", + ), + ) + + for dependency_map, expected in cases: + with self.subTest(expected=expected): + packet = copy.deepcopy(self.packet) + packet["manifests"]["dependency_map"] = dependency_map + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, expected): + self._validate_packet() + def test_ledger_task_identity_must_match_packet(self) -> None: packet = copy.deepcopy(self.packet) packet["ledger"]["task_id"] = "another-task" @@ -453,6 +554,48 @@ def test_ledger_budget_history_is_authoritative(self) -> None: with self.assertRaisesRegex(ProtocolError, "must be a positive integer"): self._validate_packet() + def test_ledger_round_fingerprint_matches_packet(self) -> None: + packet = copy.deepcopy(self.packet) + packet["ledger"]["round_fingerprint"] = "0" * 64 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "must match the packet fingerprint"): + self._validate_packet() + + def test_ledger_digest_maps_cover_exact_owned_ids(self) -> None: + """Require digest bindings for exactly the IDs owned by roots.""" + packet = copy.deepcopy(self.packet) + del packet["ledger"]["contract_evidence_sha256"]["E-ROOT"] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "must bind the exact owned IDs"): + self._validate_packet() + + packet = copy.deepcopy(self.packet) + packet["ledger"]["contract_evidence_sha256"]["E-NEW"] = hashlib.sha256( + self.new_evidence.read_bytes() + ).hexdigest() + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, r"unexpected=\['E-NEW'\]"): + self._validate_packet() + + def test_ledger_digest_maps_match_indexed_content(self) -> None: + """Reject ledger bindings that differ from current indexed content.""" + packet = copy.deepcopy(self.packet) + packet["ledger"]["contract_evidence_sha256"]["E-ROOT"] = "0" * 64 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "evidence digest mismatch for E-ROOT"): + self._validate_packet() + + packet = copy.deepcopy(self.packet) + packet["ledger"]["inventory_sha256"]["INV-2"] = "0" * 64 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "inventory digest mismatch for INV-2"): + self._validate_packet() + def test_canonical_roots_cannot_alias_the_same_ownership(self) -> None: packet = copy.deepcopy(self.packet) alias = copy.deepcopy(packet["ledger"]["root_causes"][0]) @@ -525,6 +668,7 @@ def test_prior_ledger_makes_history_append_only(self) -> None: "contract_evidence_ids": ["E-DIFF"], } ] + del removed_root["ledger"]["contract_evidence_sha256"]["E-ROOT"] self._write_packet(self.packet_path, removed_root) with self.assertRaisesRegex(ProtocolError, "removed prior canonical root"): validate_packet( @@ -535,6 +679,129 @@ def test_prior_ledger_makes_history_append_only(self) -> None: prior_digest, ) + def test_prior_ledger_binds_owned_evidence_content(self) -> None: + """Reject content replacement under a previously owned evidence ID.""" + prior_path = self.root / "prior-ledger.json" + prior = copy.deepcopy(self.packet["ledger"]) + prior["contract_evidence_sha256"] = { + "E-DIFF": hashlib.sha256(self.evidence.read_bytes()).hexdigest(), + "E-ROOT": hashlib.sha256(self.root_evidence.read_bytes()).hexdigest(), + } + self._write_json(prior_path, prior) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + + self.root_evidence.write_text("replacement root evidence\n") + packet = copy.deepcopy(self.packet) + root_artifact = next( + artifact for artifact in packet["evidence_artifacts"] if artifact["id"] == "E-ROOT" + ) + replacement_digest = hashlib.sha256(self.root_evidence.read_bytes()).hexdigest() + root_artifact["sha256"] = replacement_digest + packet["ledger"]["contract_evidence_sha256"] = { + "E-DIFF": hashlib.sha256(self.evidence.read_bytes()).hexdigest(), + "E-ROOT": replacement_digest, + } + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "changed prior evidence E-ROOT"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + def test_prior_ledger_binds_owned_inventory_content(self) -> None: + """Reject content replacement under a previously owned inventory ID.""" + prior_path = self.root / "prior-ledger.json" + prior = copy.deepcopy(self.packet["ledger"]) + prior["inventory_sha256"] = { + row["id"]: _inventory_digest(row) for row in self.packet["inventory"] + } + self._write_json(prior_path, prior) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + + packet = copy.deepcopy(self.packet) + inventory_row = next(row for row in packet["inventory"] if row["id"] == "INV-2") + inventory_row["validation"] = "replacement validation contract" + packet["ledger"]["inventory_sha256"] = { + row["id"]: _inventory_digest(row) for row in packet["inventory"] + } + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "changed prior inventory INV-2"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + def test_same_round_retry_requires_the_prior_fingerprint(self) -> None: + prior_path = self.root / "prior-ledger.json" + prior = copy.deepcopy(self.packet["ledger"]) + prior["round_fingerprint"] = "0" * 64 + self._write_json(prior_path, prior) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + + with self.assertRaisesRegex(ProtocolError, "same-round retry fingerprint"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + advanced = copy.deepcopy(self.packet) + advanced["ledger"]["current_round"] = 2 + advanced["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, advanced) + + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + def test_same_round_retry_cannot_expand_the_budget_history(self) -> None: + prior_path = self.root / "prior-ledger.json" + prior = copy.deepcopy(self.packet["ledger"]) + self._write_json(prior_path, prior) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + expanded = copy.deepcopy(self.packet) + expanded["ledger"]["authorized_round_budgets"] = [6, 2] + expanded["ledger"]["remaining_budget"] = 7 + self._write_packet(self.packet_path, expanded) + + with self.assertRaisesRegex(ProtocolError, "same-round retry budget history"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + expanded["ledger"]["current_round"] = 2 + expanded["ledger"]["remaining_budget"] = 6 + self._write_packet(self.packet_path, expanded) + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + def test_later_round_requires_digest_bound_prior_ledger(self) -> None: packet = copy.deepcopy(self.packet) packet["ledger"]["current_round"] = 2 @@ -561,6 +828,25 @@ def test_current_ledger_cannot_authorize_its_own_history(self) -> None: current_digest, ) + def test_prior_ledger_hardlink_cannot_alias_current_ledger(self) -> None: + packet = copy.deepcopy(self.packet) + packet["ledger"]["authorized_round_budgets"] = [2] + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 0 + self._write_packet(self.packet_path, packet) + prior_path = self.root / "prior-ledger.json" + os.link(self.ledger_path, prior_path) + current_digest = hashlib.sha256(self.ledger_path.read_bytes()).hexdigest() + + with self.assertRaisesRegex(ProtocolError, "distinct from the current ledger"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + current_digest, + ) + def test_inventory_requires_kind_specific_evidence(self) -> None: cases = ((0, "surface", "contract fields"), (1, "validation", "authority-data-flow")) for index, field, expected in cases: @@ -637,6 +923,23 @@ def test_preflight_results_require_exact_command_result_records(self) -> None: with self.assertRaisesRegex(ProtocolError, "command contains a placeholder token"): self._validate_packet() + packet = copy.deepcopy(self.packet) + packet["verification"]["preflight_results"][0]["details"] = "Unvalidated metadata." + self._write_packet(self.packet_path, packet) + with self.assertRaisesRegex(ProtocolError, "unexpected=\\['details'\\]"): + self._validate_packet() + + def test_preflight_commands_must_be_unique(self) -> None: + """Reject repeated preflight records for the same exact command.""" + packet = copy.deepcopy(self.packet) + duplicate = copy.deepcopy(packet["verification"]["preflight_results"][0]) + duplicate["result"] = "The same command passed again." + packet["verification"]["preflight_results"].append(duplicate) + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "Duplicate preflight command"): + self._validate_packet() + def test_every_reviewer_receives_all_components_and_complete_diff(self) -> None: cases = [] no_components = copy.deepcopy(self.packet) @@ -653,6 +956,24 @@ def test_every_reviewer_receives_all_components_and_complete_diff(self) -> None: with self.assertRaisesRegex(ProtocolError, re_escape(expected)): self._validate_packet(path) + def test_reviewer_assignments_require_distinct_specialties(self) -> None: + """Require the two reviewers to have complementary specialties.""" + packet = copy.deepcopy(self.packet) + packet["reviewer_assignments"][1]["primary_dimensions"] = ["requirement and scope"] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "overlapping primary specialty"): + self._validate_packet() + + packet = copy.deepcopy(self.packet) + packet["selected_high_risk_dimensions"] = ["persistence"] + for assignment in packet["reviewer_assignments"]: + assignment["high_risk_dimensions"] = ["persistence"] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "overlapping high-risk specialty"): + self._validate_packet() + def test_packet_and_ledger_reject_json_booleans_as_integers(self) -> None: cases = [] schema = copy.deepcopy(self.packet) @@ -675,6 +996,85 @@ def test_packet_rejects_changed_evidence(self) -> None: with self.assertRaisesRegex(ProtocolError, "digest mismatch"): self._validate_packet() + @unittest.skipIf(os.name == "nt", "Symlink creation requires platform privileges.") + def test_packet_rejects_aliased_evidence_paths(self) -> None: + alias = self.root / "root-evidence-alias.txt" + alias.symlink_to(self.root_evidence) + packet = copy.deepcopy(self.packet) + packet["evidence_artifacts"].append( + { + "id": "E-ROOT-ALIAS", + "path": str(alias), + "sha256": hashlib.sha256(self.root_evidence.read_bytes()).hexdigest(), + "role": "supporting", + "purpose": "Alias of existing root-cause evidence.", + } + ) + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "Duplicate evidence artifact file identity"): + self._validate_packet() + + def test_copied_evidence_does_not_reopen_a_closed_root(self) -> None: + prior_path = self.root / "prior-ledger.json" + self._write_json(prior_path, self.packet["ledger"]) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + copied_evidence = self.root / "copied-root-evidence.txt" + copied_evidence.write_bytes(self.root_evidence.read_bytes()) + packet = copy.deepcopy(self.packet) + packet["evidence_artifacts"].append( + { + "id": "E-COPY", + "path": str(copied_evidence), + "sha256": hashlib.sha256(copied_evidence.read_bytes()).hexdigest(), + "role": "supporting", + "purpose": "Byte copy of existing root-cause evidence.", + } + ) + closed_root = next( + root for root in packet["ledger"]["root_causes"] if root["id"] == "ROOT_CLOSED" + ) + closed_root["status"] = "open" + closed_root["contract_evidence_ids"].append("E-COPY") + packet["ledger"]["contract_evidence_sha256"]["E-COPY"] = hashlib.sha256( + copied_evidence.read_bytes() + ).hexdigest() + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "without content-new evidence"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + @unittest.skipUnless(Path("/dev/null").exists(), "Requires a POSIX device path.") + def test_packet_rejects_non_regular_evidence_files(self) -> None: + packet = copy.deepcopy(self.packet) + packet["evidence_artifacts"][0]["path"] = "/dev/null" + packet["evidence_artifacts"][0]["sha256"] = hashlib.sha256(b"").hexdigest() + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "must be a regular file"): + self._validate_packet() + + @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.") + def test_artifact_file_type_is_verified_after_open(self) -> None: + fifo = self.root / "artifact.pipe" + os.mkfifo(fifo) + regular_stat = self.packet_path.stat() + + with ( + mock.patch.object(Path, "stat", return_value=regular_stat), + mock.patch.object(Path, "read_bytes", return_value=b"not from the FIFO"), + self.assertRaisesRegex(ProtocolError, "must be a regular file"), + ): + _read_bytes(str(fifo), "artifact") + def test_complete_diff_must_match_review_state(self) -> None: partial_diff = self.root / "partial.diff" partial_diff.write_text("partial diff\n") @@ -728,7 +1128,6 @@ def test_review_state_rejects_unknown_fields_for_every_workspace_kind(self) -> N "path": "gitlink", "kind": "gitlink", "head": "c" * 40, - "status_sha256": "d" * 64, }, {"path": "directory", "kind": "directory"}, {"path": "missing", "kind": "missing"}, @@ -739,6 +1138,39 @@ def test_review_state_rejects_unknown_fields_for_every_workspace_kind(self) -> N with self.assertRaisesRegex(ProtocolError, r"unexpected=\['authority'\]"): _workspace_entries([entry_with_unknown], "review_state.workspace") + def test_review_state_accepts_complete_gitlink_entry(self) -> None: + state = copy.deepcopy(self.review_state) + workspace = [ + { + "path": "src/example.py", + "kind": "gitlink", + "head": "c" * 40, + } + ] + combined = _content_fingerprint(state["base"], workspace) + state["fingerprint"] = combined + state["content_fingerprint"] = combined + state["workspace"] = workspace + state["unfiltered"]["workspace"] = workspace + state["components"]["api-contract"]["content_fingerprint"] = combined + state["components"]["api-contract"]["workspace"] = workspace + state["repository_fingerprint"] = _repository_fingerprint( + content_fingerprint=combined, + head=state["head"], + status_sha256=state["status_sha256"], + tracked_diff_sha256=state["tracked_diff_sha256"], + complete_diff_sha256=state["complete_diff_sha256"], + unfiltered_status_sha256=state["unfiltered"]["status_sha256"], + unfiltered_content_fingerprint=combined, + ) + packet = copy.deepcopy(self.packet) + packet["ledger"]["round_fingerprint"] = combined + self._write_review_state(state, packet) + + summary = self._validate_packet() + + self.assertEqual(summary["combined_fingerprint"], combined) + def test_review_state_artifact_rejects_unknown_workspace_fields(self) -> None: state = copy.deepcopy(self.review_state) state["workspace"][0]["authority"] = "unsupported" @@ -845,6 +1277,16 @@ def test_receipt_rejects_boolean_exit_status(self) -> None: receipt, self.combined, {"api-contract": self.component}, self.repository ) + def test_receipt_rejects_unknown_fields(self) -> None: + """Reject conflicting evidence outside the receipt schema.""" + receipt = self._receipt() + receipt["exit_code"] = 1 + + with self.assertRaisesRegex(ProtocolError, "Verification receipt.*unexpected"): + validate_receipt_data( + receipt, self.combined, {"api-contract": self.component}, self.repository + ) + def test_packet_validates_every_credited_receipt(self) -> None: self._write_json(self.receipt_path, self._receipt()) packet = copy.deepcopy(self.packet) @@ -868,6 +1310,61 @@ def test_packet_validates_every_credited_receipt(self) -> None: with self.assertRaisesRegex(ProtocolError, "exit_status 0"): self._validate_packet() + @unittest.skipIf(os.name == "nt", "Symlink creation requires platform privileges.") + def test_packet_rejects_aliased_credited_receipts(self) -> None: + self._write_json(self.receipt_path, self._receipt()) + alias = self.root / "receipt-alias.json" + alias.symlink_to(self.receipt_path) + digest = hashlib.sha256(self.receipt_path.read_bytes()).hexdigest() + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + {"path": str(self.receipt_path), "sha256": digest}, + {"path": str(alias), "sha256": digest}, + ] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "Duplicate credited receipt file identity"): + self._validate_packet() + + def test_packet_rejects_copied_credited_receipts(self) -> None: + self._write_json(self.receipt_path, self._receipt()) + copied_receipt = self.root / "copied-receipt.json" + copied_receipt.write_bytes(self.receipt_path.read_bytes()) + digest = hashlib.sha256(self.receipt_path.read_bytes()).hexdigest() + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + {"path": str(self.receipt_path), "sha256": digest}, + {"path": str(copied_receipt), "sha256": digest}, + ] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "Duplicate credited receipt digest"): + self._validate_packet() + + def test_packet_rejects_multiple_receipts_for_one_command(self) -> None: + """Reject distinct receipt files that credit the same command.""" + first_receipt = self._receipt() + self._write_json(self.receipt_path, first_receipt) + second_receipt = copy.deepcopy(first_receipt) + second_receipt["environment"] = "The same gate rerun in a fresh local process." + second_receipt_path = self.root / "second-receipt.json" + self._write_json(second_receipt_path, second_receipt) + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + { + "path": str(self.receipt_path), + "sha256": hashlib.sha256(self.receipt_path.read_bytes()).hexdigest(), + }, + { + "path": str(second_receipt_path), + "sha256": hashlib.sha256(second_receipt_path.read_bytes()).hexdigest(), + }, + ] + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "Duplicate credited receipt command"): + self._validate_packet() + def test_packet_rejects_receipt_for_unrelated_successful_command(self) -> None: receipt = self._receipt() receipt["command"] = "true" @@ -938,6 +1435,39 @@ def test_receipt_cli_rejects_unindexed_replacement(self) -> None: self.assertEqual(completed.returncode, 2) self.assertIn("receipt path is not indexed", completed.stderr) + def test_receipt_validation_rechecks_the_indexed_file(self) -> None: + """Reject a receipt replaced after packet validation.""" + receipt = self._receipt() + self._write_json(self.receipt_path, receipt) + packet = copy.deepcopy(self.packet) + packet["verification"]["credited_receipts"] = [ + { + "path": str(self.receipt_path), + "sha256": hashlib.sha256(self.receipt_path.read_bytes()).hexdigest(), + } + ] + self._write_packet(self.packet_path, packet) + original_validate_packet = validate_packet + + def validate_then_replace_receipt(*args: Any, **kwargs: Any) -> dict[str, Any]: + summary = original_validate_packet(*args, **kwargs) + receipt["environment"] = "A replacement environment after packet validation." + self._write_json(self.receipt_path, receipt) + return summary + + with ( + mock.patch( + "review_protocol.validate_packet", side_effect=validate_then_replace_receipt + ), + self.assertRaisesRegex(ProtocolError, "Receipt changed"), + ): + _validate_credited_receipt( + self.packet_path, + self.receipt_path, + "task-123", + self.ledger_path, + ) + def test_clean_output_must_match_assignment_and_fingerprint(self) -> None: output = self._output() self._write_json(self.output_path, output) @@ -950,6 +1480,94 @@ def test_clean_output_must_match_assignment_and_fingerprint(self) -> None: with self.assertRaisesRegex(ProtocolError, "inventory accounting differs"): self._validate_output("requirements") + def test_reviewer_output_rejects_unknown_fields(self) -> None: + """Reject reviewer conclusions outside the documented schema.""" + output = self._output() + output["issues"] = [{"title": "Ignored finding"}] + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "Reviewer output.*unexpected"): + self._validate_output("requirements") + + def test_finding_and_root_evidence_reject_unknown_fields(self) -> None: + """Reject finding data that the protocol would otherwise ignore.""" + for field_path in ("finding", "root_evidence"): + with self.subTest(field_path=field_path): + output = self._output() + output["verdict"] = "findings require fixes" + finding = self._finding("ROOT_EXISTING") + if field_path == "finding": + finding["alternative_root"] = "ROOT_CLOSED" + else: + finding["root_cause_evidence"]["note"] = "Ignored evidence metadata." + output["findings"] = [finding] + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "unexpected"): + self._validate_output("requirements") + + def test_reviewer_output_is_bound_to_the_exact_packet(self) -> None: + output = self._output() + self._write_json(self.output_path, output) + packet = copy.deepcopy(self.packet) + packet["task"]["original_requirement"] = "A changed review requirement." + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "packet digest"): + self._validate_output("requirements") + + def test_reviewer_output_rechecks_current_ledger_after_packet_validation(self) -> None: + """Reject a current ledger replaced after packet validation.""" + output = self._output() + self._write_json(self.output_path, output) + original_validate_packet = validate_packet + + def validate_then_replace_ledger(*args: Any, **kwargs: Any) -> dict[str, Any]: + summary = original_validate_packet(*args, **kwargs) + replacement = copy.deepcopy(self.packet["ledger"]) + replacement["remaining_budget"] = 99 + self._write_json(self.ledger_path, replacement) + return summary + + with ( + mock.patch("review_protocol.validate_packet", side_effect=validate_then_replace_ledger), + self.assertRaisesRegex(ProtocolError, "Current ledger changed"), + ): + self._validate_output("requirements") + + def test_reviewer_output_rechecks_prior_ledger_after_packet_validation(self) -> None: + """Reject a prior ledger replaced after packet validation.""" + prior_path = self.root / "prior-ledger.json" + self._write_json(prior_path, self.packet["ledger"]) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + packet = copy.deepcopy(self.packet) + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, packet) + self._write_json(self.output_path, self._output()) + original_validate_packet = validate_packet + + def validate_then_replace_prior(*args: Any, **kwargs: Any) -> dict[str, Any]: + summary = original_validate_packet(*args, **kwargs) + replacement = copy.deepcopy(self.packet["ledger"]) + replacement["root_causes"] = [] + self._write_json(prior_path, replacement) + return summary + + with ( + mock.patch("review_protocol.validate_packet", side_effect=validate_then_replace_prior), + self.assertRaisesRegex(ProtocolError, "Prior ledger changed"), + ): + validate_reviewer_output( + self.packet_path, + "requirements", + self.output_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + def test_unknown_root_must_be_new_proposal_with_evidence(self) -> None: output = self._output() output["verdict"] = "findings require fixes" @@ -962,7 +1580,7 @@ def test_unknown_root_must_be_new_proposal_with_evidence(self) -> None: output["findings"][0]["root_cause_id"] = "NEW:new-boundary" output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = ["INV-1"] self._write_json(self.output_path, output) - with self.assertRaisesRegex(ProtocolError, "without globally unowned evidence"): + with self.assertRaisesRegex(ProtocolError, "cannot reuse canonical inventory"): self._validate_output("requirements") output["findings"][0]["root_cause_evidence"]["new_inventory_ids"] = [] @@ -970,6 +1588,73 @@ def test_unknown_root_must_be_new_proposal_with_evidence(self) -> None: self._write_json(self.output_path, output) self._validate_output("requirements") + def test_copied_evidence_does_not_support_a_new_root(self) -> None: + copied_evidence = self.root / "copied-root-evidence.txt" + copied_evidence.write_bytes(self.root_evidence.read_bytes()) + packet = copy.deepcopy(self.packet) + packet["evidence_artifacts"].append( + { + "id": "E-COPY", + "path": str(copied_evidence), + "sha256": hashlib.sha256(copied_evidence.read_bytes()).hexdigest(), + "role": "supporting", + "purpose": "Byte copy of existing root-cause evidence.", + } + ) + self._write_packet(self.packet_path, packet) + output = self._output() + output["verdict"] = "findings require fixes" + finding = self._finding("NEW:copied-evidence") + finding["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-COPY"] + output["findings"] = [finding] + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "without content-new evidence"): + self._validate_output("requirements") + + def test_copied_inventory_does_not_reopen_a_closed_root(self) -> None: + """Reject a renamed copy of inventory as closed-root evidence.""" + prior_path = self.root / "prior-ledger.json" + self._write_json(prior_path, self.packet["ledger"]) + prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() + packet = copy.deepcopy(self.packet) + copied_inventory = copy.deepcopy(packet["inventory"][1]) + copied_inventory["id"] = "INV-COPY" + packet["inventory"].append(copied_inventory) + packet["ledger"]["inventory_sha256"]["INV-COPY"] = _inventory_digest(copied_inventory) + closed_root = next( + root for root in packet["ledger"]["root_causes"] if root["id"] == "ROOT_CLOSED" + ) + closed_root["status"] = "open" + closed_root["inventory_ids"].append("INV-COPY") + packet["reviewer_assignments"][1]["inventory_ids"].append("INV-COPY") + packet["ledger"]["current_round"] = 2 + packet["ledger"]["remaining_budget"] = 4 + self._write_packet(self.packet_path, packet) + + with self.assertRaisesRegex(ProtocolError, "without content-new evidence"): + validate_packet( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + prior_digest, + ) + + def test_distinct_new_roots_cannot_share_one_evidence_digest(self) -> None: + """Require distinct new roots to own distinct evidence content.""" + output = self._output() + output["verdict"] = "findings require fixes" + first = self._finding("NEW:first-root") + first["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-NEW"] + second = self._finding("NEW:second-root") + second["root_cause_evidence"]["new_contract_evidence_ids"] = ["E-NEW"] + output["findings"] = [first, second] + self._write_json(self.output_path, output) + + with self.assertRaisesRegex(ProtocolError, "reuses evidence owned by proposed root"): + self._validate_output("requirements") + def test_closed_root_requires_new_evidence(self) -> None: output = self._output() output["verdict"] = "findings require fixes" @@ -1037,6 +1722,8 @@ def test_newly_promoted_root_uses_current_round_ownership(self) -> None: prior_path = self.root / "prior-ledger.json" prior = copy.deepcopy(self.packet["ledger"]) prior["root_causes"] = [prior["root_causes"][0]] + prior["contract_evidence_sha256"] = {"E-DIFF": prior["contract_evidence_sha256"]["E-DIFF"]} + prior["inventory_sha256"] = {"INV-1": prior["inventory_sha256"]["INV-1"]} self._write_json(prior_path, prior) prior_digest = hashlib.sha256(prior_path.read_bytes()).hexdigest() @@ -1108,6 +1795,31 @@ def test_cli_reports_protocol_errors_without_traceback(self) -> None: self.assertIn("schema_version must be integer 1", completed.stderr) self.assertNotIn("Traceback", completed.stderr) + def test_cli_reports_invalid_artifact_paths_without_traceback(self) -> None: + invalid = copy.deepcopy(self.packet) + invalid["evidence_artifacts"][0]["path"] = "/tmp/invalid\0path" + self._write_packet(self.packet_path, invalid) + + completed = subprocess.run( + ( + sys.executable, + str(Path(__file__).with_name("review_protocol.py")), + "packet", + "--packet", + str(self.packet_path), + "--task-id", + "task-123", + "--ledger", + str(self.ledger_path), + ), + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 2) + self.assertIn("Cannot read evidence artifact E-DIFF.path", completed.stderr) + self.assertNotIn("Traceback", completed.stderr) + def re_escape(value: str) -> str: """Escape a literal string for assertRaisesRegex without importing re in each test.""" diff --git a/.agents/skills/implementation-final-review/scripts/test_review_state.py b/.agents/skills/implementation-final-review/scripts/test_review_state.py index 048c00868b..9a469d0cb4 100644 --- a/.agents/skills/implementation-final-review/scripts/test_review_state.py +++ b/.agents/skills/implementation-final-review/scripts/test_review_state.py @@ -4,21 +4,32 @@ import hashlib import json +import os import subprocess import sys import tempfile import unittest from pathlib import Path +from unittest import mock sys.path.insert(0, str(Path(__file__).parent)) -from review_state import _component, _load_pathspec_file, review_state +import review_state as review_state_module +from review_state import ( + _component, + _content_fingerprint, + _load_pathspec_file, + _workspace_entry, + review_state, +) class ReviewStateTest(unittest.TestCase): def setUp(self) -> None: self.temporary_directory = tempfile.TemporaryDirectory() - self.repo = Path(self.temporary_directory.name) + self.root = Path(self.temporary_directory.name) + self.repo = self.root / "repo" + self.repo.mkdir() self._git("init", "-q") self._git("config", "user.email", "review-state@example.test") self._git("config", "user.name", "Review State Test") @@ -66,6 +77,88 @@ def test_equivalent_pathspecs_have_the_same_content_fingerprint(self) -> None: explicit["content_fingerprint"], with_ignored_artifact["content_fingerprint"] ) + def test_repository_path_must_be_the_worktree_root(self) -> None: + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + + with self.assertRaisesRegex(ValueError, "worktree root"): + review_state(self.repo / "src", self.base, ("src/runtime.py",)) + + def test_repository_changes_during_snapshot_fail_closed(self) -> None: + """Reject a diff and workspace fingerprint captured from different states.""" + runtime = self.repo / "src" / "runtime.py" + runtime.write_text("VALUE = 2\n") + original_complete_diff = review_state_module._complete_diff + mutated = False + + def complete_diff_then_mutate(repo: Path, base: str, pathspecs: tuple[str, ...]) -> bytes: + nonlocal mutated + result = original_complete_diff(repo, base, pathspecs) + if not mutated: + runtime.write_text("VALUE = 3\n") + mutated = True + return result + + with ( + mock.patch.object( + review_state_module, + "_complete_diff", + side_effect=complete_diff_then_mutate, + ), + self.assertRaisesRegex(ValueError, "changed while review state was captured"), + ): + review_state(self.repo, self.base, ("src/runtime.py",)) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.") + def test_exact_special_file_path_fails_closed(self) -> None: + """Reject a task manifest entry that cannot produce a finite diff.""" + fifo = self.repo / "artifact.pipe" + os.mkfifo(fifo) + + with self.assertRaisesRegex(ValueError, "Unsupported workspace file type"): + review_state(self.repo, self.base, ("artifact.pipe",)) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.") + def test_workspace_file_type_is_verified_after_open(self) -> None: + """Do not trust a stale file-type check when reading workspace content.""" + fifo = self.repo / "artifact.pipe" + os.mkfifo(fifo) + + with ( + mock.patch.object(Path, "is_file", return_value=True), + mock.patch.object(Path, "read_bytes", return_value=b"not from the FIFO"), + self.assertRaisesRegex(ValueError, "Unsupported workspace file type"), + ): + _workspace_entry(self.repo, "artifact.pipe") + + @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.") + def test_pathspec_file_type_is_verified_after_open(self) -> None: + """Do not trust a path-based read when loading a task manifest.""" + fifo = self.root / "task.paths" + os.mkfifo(fifo) + + with ( + mock.patch.object(Path, "read_text", return_value="src/runtime.py\n"), + self.assertRaisesRegex(ValueError, "Cannot read pathspec file"), + ): + _load_pathspec_file(fifo) + + @unittest.skipIf(os.name == "nt", "Executable mode normalization requires POSIX.") + def test_executable_uses_git_owner_bit(self) -> None: + runtime = self.repo / "src" / "runtime.py" + runtime.write_text("VALUE = 2\n") + runtime.chmod(0o744) + executable = review_state(self.repo, self.base, ("src/runtime.py",)) + + runtime.chmod(0o654) + non_executable = review_state(self.repo, self.base, ("src/runtime.py",)) + + self.assertTrue(executable["workspace"][0]["executable"]) + self.assertFalse(non_executable["workspace"][0]["executable"]) + self.assertNotEqual( + executable["content_fingerprint"], + non_executable["content_fingerprint"], + ) + def test_component_fingerprints_invalidate_only_changed_content(self) -> None: runtime = self.repo / "src" / "runtime.py" tests = self.repo / "tests" / "test_runtime.py" @@ -103,7 +196,7 @@ def test_unfiltered_workspace_accounts_for_changes_outside_manifest(self) -> Non def test_complete_diff_includes_task_owned_untracked_files(self) -> None: new_test = self.repo / "tests" / "test_new.py" new_test.write_text("assert 2 == 2\n") - complete_diff = self.repo / "complete.diff" + complete_diff = self.root / "complete.diff" state = review_state( self.repo, @@ -125,7 +218,7 @@ def test_complete_diff_includes_task_owned_untracked_files(self) -> None: def test_exact_manifest_path_includes_ignored_untracked_file(self) -> None: ignored = self.repo / "plans" / "private.md" ignored.write_text("shipped fixture\n") - complete_diff = self.repo / "complete.diff" + complete_diff = self.root / "complete.diff" state = review_state( self.repo, @@ -159,6 +252,29 @@ def test_literal_filename_with_pathspec_metacharacters_is_exact(self) -> None: self.assertEqual(state["complete_diff_paths"], ["plans/[a].md"]) + def test_existing_magic_prefixed_filename_is_exact(self) -> None: + """Treat an existing magic-prefixed filename as an exact path.""" + (self.repo / ":(glob)literal").write_text("literal filename\n") + (self.repo / "literal").write_text("glob match\n") + + state = review_state(self.repo, self.base, (":(glob)literal",)) + + self.assertEqual(state["complete_diff_paths"], [":(glob)literal"]) + + def test_deleted_magic_prefixed_filename_is_exact_from_base(self) -> None: + """Treat a deleted base filename with magic syntax as exact.""" + magic_prefixed = self.repo / ":(glob)literal" + magic_prefixed.write_text("deleted literal filename\n") + (self.repo / "literal").write_text("glob match\n") + self._git("add", ".") + self._git("commit", "-qm", "add magic-prefixed filename") + self.base = self._git("rev-parse", "HEAD").strip() + self._git("rm", "-q", "--", ":(literal):(glob)literal") + + state = review_state(self.repo, self.base, (":(glob)literal",)) + + self.assertEqual(state["complete_diff_paths"], [":(glob)literal"]) + def test_explicit_glob_magic_preserves_pattern_semantics(self) -> None: (self.repo / "plans" / "[a].md").write_text("literal\n") (self.repo / "plans" / "a.md").write_text("glob match\n") @@ -167,9 +283,410 @@ def test_explicit_glob_magic_preserves_pattern_semantics(self) -> None: self.assertEqual(state["complete_diff_paths"], ["plans/[a].md", "plans/a.md"]) + def test_submodule_changes_require_reviewable_gitlinks(self) -> None: + """Accept staged pointers and reject other submodule worktree changes.""" + source = self.repo / ".fixtures" / "dependency-source" + source.mkdir(parents=True) + subprocess.run(("git", "init", "-q", str(source)), check=True) + subprocess.run( + ("git", "-C", str(source), "config", "user.email", "submodule@example.test"), + check=True, + ) + subprocess.run( + ("git", "-C", str(source), "config", "user.name", "Submodule Test"), + check=True, + ) + (source / "tracked.txt").write_text("committed\n") + subprocess.run(("git", "-C", str(source), "add", "."), check=True) + subprocess.run(("git", "-C", str(source), "commit", "-qm", "initial"), check=True) + with (self.repo / ".gitignore").open("a") as gitignore: + gitignore.write(".fixtures/\n") + self._git( + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + str(source), + "vendor/dependency", + ) + self._git( + "config", + "-f", + ".gitmodules", + "submodule.vendor/dependency.ignore", + "all", + ) + self._git("add", ".") + self._git("commit", "-qm", "add dependency") + self.base = self._git("rev-parse", "HEAD").strip() + (source / "tracked.txt").write_text("updated commit\n") + subprocess.run(("git", "-C", str(source), "commit", "-qam", "update"), check=True) + updated_head = subprocess.check_output( + ("git", "-C", str(source), "rev-parse", "HEAD"), + text=True, + ).strip() + self._git("-C", "vendor/dependency", "fetch", "-q", "origin") + self._git("-C", "vendor/dependency", "checkout", "-q", updated_head) + + with self.assertRaisesRegex(ValueError, "HEAD does not match.*vendor/dependency"): + review_state(self.repo, self.base, ("vendor/dependency",)) + + self._git("add", "vendor/dependency") + clean_state = review_state(self.repo, self.base, ("vendor/dependency",)) + + self.assertEqual( + clean_state["workspace"], + [ + { + "path": "vendor/dependency", + "kind": "gitlink", + "head": updated_head, + } + ], + ) + tracked = self.repo / "vendor" / "dependency" / "tracked.txt" + tracked.write_text("dirty body\n") + + with self.assertRaisesRegex(ValueError, "Dirty submodule.*vendor/dependency"): + review_state(self.repo, self.base, ("vendor/dependency",)) + + def test_materialized_uninitialized_gitlink_fails_closed(self) -> None: + """Reject arbitrary directory content hidden behind an index gitlink.""" + self._git( + "update-index", + "--add", + "--cacheinfo", + f"160000,{self.base},vendor/dependency", + ) + dependency = self.repo / "vendor" / "dependency" + dependency.mkdir(parents=True) + (dependency / "unreviewed.txt").write_text("first body\n") + + with self.assertRaisesRegex(ValueError, "Materialized gitlink.*vendor/dependency"): + review_state(self.repo, self.base, ("vendor/dependency",)) + + @unittest.skipIf(os.name == "nt", "Directory symlinks require platform privileges.") + def test_cyclic_gitlink_worktree_fails_closed(self) -> None: + """Reject a gitlink alias that resolves back to an ancestor repository.""" + self._git( + "update-index", + "--add", + "--cacheinfo", + f"160000,{self.base},vendor/self", + ) + vendor = self.repo / "vendor" + vendor.mkdir() + os.symlink("..", vendor / "self", target_is_directory=True) + original_limit = sys.getrecursionlimit() + sys.setrecursionlimit(120) + self.addCleanup(sys.setrecursionlimit, original_limit) + + with self.assertRaisesRegex(ValueError, "Cyclic submodule worktree"): + review_state(self.repo, self.base, ("vendor/self",)) + + def test_hidden_nested_submodule_changes_fail_closed(self) -> None: + """Reject nested pointer and content changes hidden by configuration.""" + leaf_source = self.repo / ".fixtures" / "leaf-source" + leaf_source.mkdir(parents=True) + subprocess.run(("git", "init", "-q", str(leaf_source)), check=True) + subprocess.run( + ("git", "-C", str(leaf_source), "config", "user.email", "leaf@example.test"), + check=True, + ) + subprocess.run( + ("git", "-C", str(leaf_source), "config", "user.name", "Leaf Test"), + check=True, + ) + (leaf_source / "tracked.txt").write_text("committed\n") + subprocess.run(("git", "-C", str(leaf_source), "add", "."), check=True) + subprocess.run( + ("git", "-C", str(leaf_source), "commit", "-qm", "initial"), + check=True, + ) + + parent_source = self.repo / ".fixtures" / "parent-source" + parent_source.mkdir() + subprocess.run(("git", "init", "-q", str(parent_source)), check=True) + subprocess.run( + ("git", "-C", str(parent_source), "config", "user.email", "parent@example.test"), + check=True, + ) + subprocess.run( + ("git", "-C", str(parent_source), "config", "user.name", "Parent Test"), + check=True, + ) + subprocess.run( + ( + "git", + "-C", + str(parent_source), + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + str(leaf_source), + "nested", + ), + check=True, + ) + subprocess.run( + ( + "git", + "-C", + str(parent_source), + "config", + "-f", + ".gitmodules", + "submodule.nested.ignore", + "all", + ), + check=True, + ) + subprocess.run(("git", "-C", str(parent_source), "add", ".gitmodules"), check=True) + subprocess.run(("git", "-C", str(parent_source), "commit", "-qam", "initial"), check=True) + + with (self.repo / ".gitignore").open("a") as gitignore: + gitignore.write(".fixtures/\n") + self._git( + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + str(parent_source), + "vendor/dependency", + ) + self._git( + "-C", + "vendor/dependency", + "-c", + "protocol.file.allow=always", + "submodule", + "update", + "--init", + "-q", + ) + self._git("add", ".") + self._git("commit", "-qm", "add nested dependency") + self.base = self._git("rev-parse", "HEAD").strip() + expected_nested_head = self._git( + "-C", + "vendor/dependency", + "rev-parse", + "HEAD:nested", + ).strip() + (leaf_source / "tracked.txt").write_text("updated commit\n") + subprocess.run( + ("git", "-C", str(leaf_source), "commit", "-qam", "update"), + check=True, + ) + updated_nested_head = subprocess.check_output( + ("git", "-C", str(leaf_source), "rev-parse", "HEAD"), + text=True, + ).strip() + self._git("-C", "vendor/dependency/nested", "fetch", "-q", "origin") + self._git( + "-C", + "vendor/dependency/nested", + "checkout", + "-q", + updated_nested_head, + ) + parent_status = self._git("-C", "vendor/dependency", "status", "--porcelain=v1") + + self.assertEqual(parent_status, "") + with self.assertRaisesRegex( + ValueError, + "HEAD does not match.*vendor/dependency/nested", + ): + review_state(self.repo, self.base, ("vendor/dependency",)) + + self._git( + "-C", + "vendor/dependency/nested", + "checkout", + "-q", + expected_nested_head, + ) + tracked = self.repo / "vendor" / "dependency" / "nested" / "tracked.txt" + tracked.write_text("dirty body\n") + parent_status = self._git("-C", "vendor/dependency", "status", "--porcelain=v1") + + self.assertEqual(parent_status, "") + with self.assertRaisesRegex( + ValueError, + "Dirty submodule.*vendor/dependency/nested", + ): + review_state(self.repo, self.base, ("vendor/dependency",)) + + @unittest.skipIf(os.name == "nt", "Non-UTF-8 filenames require POSIX filesystem bytes.") + def test_non_utf8_filename_has_stable_fingerprint(self) -> None: + """Preserve surrogateescaped Git path bytes in review artifacts.""" + raw_relative_path = b"tests/non-utf8-\xff.py" + git = (b"git", b"-C", os.fsencode(self.repo)) + blob = subprocess.check_output( + (*git, b"hash-object", b"-w", b"--stdin"), + input=b"assert True\n", + ).strip() + subprocess.run( + ( + *git, + b"update-index", + b"--add", + b"--cacheinfo", + b"100644," + blob + b"," + raw_relative_path, + ), + check=True, + ) + self._git("commit", "-qm", "add non-UTF-8 filename") + self.base = self._git("rev-parse", "HEAD").strip() + subprocess.run( + (*git, b"update-index", b"--force-remove", b"--", raw_relative_path), + check=True, + ) + relative_path = os.fsdecode(raw_relative_path) + + state = review_state(self.repo, self.base, ("tests",)) + + self.assertEqual(state["complete_diff_paths"], [relative_path]) + self.assertEqual( + _content_fingerprint(state["base"], state["workspace"]), + state["content_fingerprint"], + ) + json.dumps(state, ensure_ascii=True) + + completed = self._run_cli("--pathspec", "tests") + + self.assertEqual(completed.returncode, 0, completed.stderr) + cli_state = json.loads(completed.stdout) + self.assertEqual(cli_state["complete_diff_paths"], [relative_path]) + self.assertEqual(cli_state["content_fingerprint"], state["content_fingerprint"]) + + def test_complete_diff_output_must_be_outside_repository(self) -> None: + """Reject an operational diff artifact inside the worktree.""" + complete_diff = self.repo / "complete.diff" + + with self.assertRaisesRegex(ValueError, "outside the repository"): + review_state( + self.repo, + self.base, + complete_diff_output=complete_diff, + ) + + self.assertFalse(complete_diff.exists()) + + def test_complete_diff_output_rejects_case_alias_inside_repository(self) -> None: + """Reject case aliases that resolve to the worktree on this filesystem.""" + alternate_repo = self.repo.with_name(self.repo.name.swapcase()) + if not alternate_repo.exists() or not alternate_repo.samefile(self.repo): + self.skipTest("Filesystem is case-sensitive.") + complete_diff = alternate_repo / "complete.diff" + + with self.assertRaisesRegex(ValueError, "outside the repository"): + review_state( + self.repo, + self.base, + complete_diff_output=complete_diff, + ) + + self.assertFalse(complete_diff.exists()) + + def test_complete_diff_output_does_not_follow_hardlink_into_repository(self) -> None: + """Replace an outside hardlink without mutating its repository peer.""" + runtime = self.repo / "src" / "runtime.py" + complete_diff = self.root / "complete.diff" + os.link(runtime, complete_diff) + (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n") + + state = review_state( + self.repo, + self.base, + complete_diff_output=complete_diff, + ) + + self.assertEqual(runtime.read_text(), "VALUE = 1\n") + self.assertEqual( + hashlib.sha256(complete_diff.read_bytes()).hexdigest(), + state["complete_diff_sha256"], + ) + + def test_external_complete_diff_output_keeps_state_stable(self) -> None: + """Keep consecutive review states stable when writing an artifact.""" + complete_diff = self.root / "complete.diff" + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + + first = review_state( + self.repo, + self.base, + complete_diff_output=complete_diff, + ) + second = review_state( + self.repo, + self.base, + complete_diff_output=complete_diff, + ) + + self.assertEqual(first, second) + self.assertEqual( + hashlib.sha256(complete_diff.read_bytes()).hexdigest(), + second["complete_diff_sha256"], + ) + + def test_assume_unchanged_paths_fail_closed(self) -> None: + """Reject index flags that can hide worktree content changes.""" + self._git("update-index", "--assume-unchanged", "src/runtime.py") + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + + with self.assertRaisesRegex(ValueError, "assume-unchanged.*src/runtime.py"): + review_state(self.repo, self.base, ("src/runtime.py",)) + + def test_materialized_skip_worktree_paths_fail_closed(self) -> None: + """Reject materialized sparse paths that can hide worktree changes.""" + self._git("update-index", "--skip-worktree", "src/runtime.py") + (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n") + + with self.assertRaisesRegex(ValueError, "skip-worktree.*src/runtime.py"): + review_state(self.repo, self.base, ("src/runtime.py",)) + + def test_unmerged_index_paths_fail_closed(self) -> None: + """Reject unresolved index stages before fingerprinting worktree content.""" + self._git("checkout", "-qb", "other") + (self.repo / "src" / "runtime.py").write_text("VALUE = 'other'\n") + self._git("commit", "-qam", "other change") + self._git("checkout", "-qb", "current", self.base) + (self.repo / "src" / "runtime.py").write_text("VALUE = 'current'\n") + self._git("commit", "-qam", "current change") + merged = subprocess.run( + ("git", "-C", str(self.repo), "merge", "other"), + capture_output=True, + text=True, + ) + self.assertEqual(merged.returncode, 1) + + with self.assertRaisesRegex(ValueError, "unmerged=src/runtime.py"): + review_state(self.repo, self.base, ("src/runtime.py",)) + + def test_ordinary_directory_is_not_a_gitlink(self) -> None: + """Do not discover the parent repository through a directory.""" + self.assertEqual( + _workspace_entry(self.repo, "plans"), + {"path": "plans", "kind": "directory"}, + ) + + def test_untracked_nested_repository_fails_closed(self) -> None: + """Reject embedded repositories that have no reviewable gitlink.""" + nested = self.repo / "nested" + subprocess.run(("git", "init", "-q", str(nested)), check=True) + (nested / "untracked.txt").write_text("not represented by a gitlink\n") + + with self.assertRaisesRegex(ValueError, "Untracked nested Git repositories.*nested"): + review_state(self.repo, self.base) + def test_cli_writes_complete_diff_output(self) -> None: (self.repo / "tests" / "test_new.py").write_text("assert True\n") - complete_diff = self.repo / "complete.diff" + complete_diff = self.root / "complete.diff" completed = self._run_cli( "--pathspec", diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index 9ca387cbbd..f6e869ca73 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -95,8 +95,9 @@ def test_full_verification_waits_for_clean_review(self) -> None: required_text = ( "Do not start any broad final repository gate while review is incomplete or " "finding-bearing", - "defer `make lint`, `make typecheck`, `make tests`, repository-wide builds, " - "examples runners, and integration suites until step 19 establishes clean review", + "defer `make lint`, `make typecheck`, `make tests-review`, `make tests`, " + "repository-wide builds, examples runners, and integration suites until step 19 " + "establishes clean review", "Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` " "during an iterative review round", "Set `verification.eligible_concurrent_gates` to `none`", @@ -179,12 +180,55 @@ def test_complete_diff_includes_untracked_task_deliverables(self) -> None: self.assertIn( "ordinary task-owned untracked files are present", self.implementation_kickoff ) + self.assertIn( + "shipped-path manifest must be a finite regular file", self.implementation_kickoff + ) self.assertIn("authoritative even when ignore rules match that file", self.skill) self.assertIn("literal precedence over Git pathspec metacharacters", self.skill) self.assertIn("use explicit `:(glob)` magic", self.skill) self.assertIn( "directory or glob pathspec never promotes ignored operational files", self.skill ) + self.assertIn( + "every initialized submodule, including nested submodules, to be clean", self.skill + ) + self.assertIn("Stage reviewable gitlink pointer changes", self.skill) + self.assertIn("fail closed on dirty worktrees, hidden index flags", self.skill) + self.assertIn("Reject cyclic or aliased submodule worktree graphs", self.skill) + self.assertIn("two consecutive identical observations of HEAD", self.skill) + self.assertIn("state changes during capture", self.skill) + self.assertIn("including FIFOs, sockets, and devices", self.skill) + self.assertIn("Require unique keys and standard finite numbers", self.skill) + self.assertIn("JavaScript-style `NaN` or infinity constants", self.skill) + self.assertIn("numeric exponents that overflow to infinity", self.skill) + self.assertIn("numeric-size and nesting-limit failures", self.skill) + self.assertIn( + "Every JSON object must use unique keys and standard finite numbers", + self.reviewer_brief, + ) + self.assertIn("`NaN`, `Infinity`, and `-Infinity` constants", self.reviewer_brief) + self.assertIn("numeric exponents that overflow to infinity", self.reviewer_brief) + self.assertIn("numeric-size and nesting-limit failures", self.reviewer_brief) + self.assertIn("distinct normalized primary and high-risk specialties", self.skill) + self.assertIn("require every preflight command to be unique", self.skill) + self.assertIn("exact, unique `command` and `result` objects", self.reviewer_brief) + self.assertIn("primary and high-risk specialties must not overlap", self.reviewer_brief) + self.assertIn("Reject unknown fields instead of ignoring", self.skill) + self.assertIn("Unknown receipt fields are invalid", self.reviewer_brief) + self.assertIn("Unknown fields are invalid rather than ignored", self.reviewer_brief) + self.assertIn("two consecutive identical repository observations", self.reviewer_brief) + self.assertIn("path-level `stat` must not authorize a later reopen", self.reviewer_brief) + self.assertIn("unique opened-file device and inode identities", self.reviewer_brief) + self.assertIn("evidence digest absent from every canonical root", self.reviewer_brief) + self.assertIn("`contract_evidence_sha256`", self.reviewer_brief) + self.assertIn("`inventory_sha256`", self.reviewer_brief) + self.assertIn("prior bindings are immutable", self.reviewer_brief) + self.assertIn("every distinct root proposed in the same output", self.reviewer_brief) + self.assertIn( + "Credited receipt content digests and exact commands must be unique", + self.reviewer_brief, + ) + self.assertIn("requires their validated digests to remain unchanged", self.reviewer_brief) def test_verified_base_advance_closure_is_strict_and_keeps_final_verification(self) -> None: required_text = ( @@ -204,7 +248,13 @@ def test_verified_base_advance_closure_is_strict_and_keeps_final_verification(se self.assertIn("verified base-advance closure", self.implementation_kickoff) self.assertIn("rerun every mandatory final verification gate", self.implementation_kickoff) self.assertIn("exact base pathspecs", self.reviewer_brief) - self.assertIn("prose-only claim", self.reviewer_brief) + self.assertIn("prose-only or empty dependency claims", self.reviewer_brief) + self.assertIn("keys exactly match the component names", self.reviewer_brief) + self.assertIn( + "nonempty arrays of exact `pathspec` and `reason` records", + self.reviewer_brief, + ) + self.assertIn("maps every component name to a nonempty array", self.skill) def test_operational_artifacts_are_excluded_from_the_handoff_manifest(self) -> None: required_kickoff_text = ( @@ -264,7 +314,8 @@ def test_shared_typescript_improvements_keep_python_boundaries(self) -> None: required_text = ( "package exports and generated public surfaces when applicable", "protocol capability ownership, pagination termination, cache ownership", - "defer `make lint`, `make typecheck`, `make tests`, repository-wide builds", + "defer `make lint`, `make typecheck`, `make tests-review`, `make tests`, " + "repository-wide builds", "the implementer runs the complete stack once after the clean-review gate", ) @@ -336,6 +387,21 @@ def test_round_budget_preserves_history_across_feedback_cycles(self) -> None: "append the feedback cycle's default two-round budget to the same ledger without " "another authorization prompt", "Persist enough task identity, used and authorized round budgets", + "Persist the current combined content fingerprint as `ledger.round_fingerprint`", + "A same-round retry is valid only when that value and the authorized budget history " + "match", + "exact packet SHA-256 and fingerprints", + "no unresolved merge stages before fingerprinting", + "resolve to finite regular files", + "Verify the file type after opening", + "read content from that same descriptor", + "Canonicalize each path before opening", + "opened descriptor's device and inode identity", + "require their validated digests to remain unchanged", + "evidence or inventory as new for a canonical root only when its digest is absent", + "Preserve those digest bindings across rounds", + "every distinct root proposed in the same output", + "credited receipt to have a unique content digest and exact command", ) for text in required_text: with self.subTest(text=text): @@ -347,9 +413,9 @@ def test_second_related_finding_closes_the_root_cause_group(self) -> None: "run the complexity reset once", "scan the complete inventory for sibling scenarios", "mark the canonical root-cause ID closed", - "Do not reopen it for another local patch without new contract evidence or a newly " - "uncovered inventory ID", - "reject aliases, renamed IDs, and bare unknown IDs", + "Do not reopen it for another local patch without content-new contract evidence or " + "semantic inventory", + "reject aliases, renamed or copied content, and bare unknown IDs", ) for text in required_text: with self.subTest(text=text): @@ -390,8 +456,9 @@ def test_snapshot_packet_and_structured_output_bound_repeated_work(self) -> None def test_semantic_clean_credit_fails_closed_on_dependency_changes(self) -> None: required_text = ( "Partition the manifest by the narrowest stable semantic boundaries", - "`api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, " - "`integration-runner`, `tests-examples`, and `release-metadata`", + "`api-contract`, `runstate-persistence`, `security-sandbox`, " + "`session-lifecycle`, `integration-runner`, `tests-examples`, and " + "`release-metadata`", "fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, " "and risk tier are all unchanged", "changed or dependency-invalidated component", @@ -464,9 +531,11 @@ def test_machine_readable_protocol_closes_observed_convergence_gaps(self) -> Non "Every contract evidence ID must resolve to an `evidence_artifacts[].id`", "JSON booleans are not integers for protocol purposes", "Each sibling-scenario scan must reuse a canonical root ID", - "verification.preflight_results` as an array of exact `command` and `result` objects", + "verification.preflight_results` as an array of exact, unique `command` and " + "`result` objects", "ledger file's JSON object to match the packet ledger exactly", - "not already owned by any canonical root", + "not already owned by any canonical or distinct proposed root", + "digest maps must bind exactly the currently owned IDs", "absolute `path` and `sha256` digest", 'role: "review-state"', 'role: "repository-status"', @@ -474,7 +543,7 @@ def test_machine_readable_protocol_closes_observed_convergence_gaps(self) -> Non "extra copied fingerprint or state fields are invalid", "requires the complete-diff artifact digest to equal its `complete_diff_sha256`", "Supply the task ID and absolute task-global ledger path independently", - "requires `current_round` plus `remaining_budget` to equal the sum", + "`current_round` plus `remaining_budget` to equal the sum", "immediately preceding round's immutable ledger snapshot and its SHA-256 digest", "same-round retry or advance by exactly one", "immutable snapshot must be a distinct file", diff --git a/.agents/skills/implementation-kickoff/SKILL.md b/.agents/skills/implementation-kickoff/SKILL.md index fc8a7e7e23..c68a23aac1 100644 --- a/.agents/skills/implementation-kickoff/SKILL.md +++ b/.agents/skills/implementation-kickoff/SKILL.md @@ -93,7 +93,7 @@ Branch creation and committing identical content are repository bookkeeping and ## 8. Validate and hand off -Run `python .agents/skills/implementation-kickoff/scripts/validate_handoff.py --repo --base --expected-branch --shipped-path-manifest `. For a takeover, also pass `--required-trailer-email ` for each identity that must be credited. The manifest contains one exact repository-relative shipped path per line and excludes operational artifacts. +Run `python .agents/skills/implementation-kickoff/scripts/validate_handoff.py --repo --base --expected-branch --shipped-path-manifest `. For a takeover, also pass `--required-trailer-email ` for each identity that must be credited. The shipped-path manifest must be a finite regular file whose type and content are read from one opened descriptor. It contains one exact repository-relative shipped path per line and excludes operational artifacts. Independently confirm that the committed diff has the reviewed content fingerprint when final review supplied one. The validator checks Git topology and repository cleanliness; it does not replace semantic review or fingerprint verification. diff --git a/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py b/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py index 4431f0eeb6..5d82d49a3a 100644 --- a/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py +++ b/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py @@ -1,10 +1,12 @@ from __future__ import annotations +import os import subprocess import tempfile import unittest from argparse import Namespace from pathlib import Path +from unittest import mock from validate_handoff import load_shipped_paths, validate @@ -102,6 +104,194 @@ def test_manifest_paths_must_be_normalized_and_unique(self) -> None: with self.assertRaisesRegex(ValueError, "normalized repository-relative paths"): load_shipped_paths(manifest) + @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.") + def test_manifest_file_type_is_verified_after_open(self) -> None: + fifo = self.root / "shipped.paths" + os.mkfifo(fifo) + + with ( + mock.patch.object(Path, "read_text", return_value="src/change.py\n"), + self.assertRaisesRegex(ValueError, "regular file"), + ): + load_shipped_paths(fifo) + + def test_body_line_is_not_accepted_as_coauthor_trailer(self) -> None: + path = self.repo / "src" / "change.py" + path.parent.mkdir() + path.write_text("value = 1\n") + self._git("add", "src/change.py") + self._git( + "commit", + "-qm", + "change workflow", + "-m", + "Co-authored-by: Example User ", + "-m", + "This paragraph makes the preceding line part of the body.", + ) + manifest = self.root / "shipped.paths" + manifest.write_text("src/change.py\n") + args = self._args(manifest) + args.required_trailer_email = ["example@example.com"] + + report, failures = validate(args) + + self.assertFalse(report["valid"]) + self.assertTrue( + any("Missing required Co-authored-by trailer" in failure for failure in failures) + ) + + def test_terminal_coauthor_trailer_is_accepted(self) -> None: + path = self.repo / "src" / "change.py" + path.parent.mkdir() + path.write_text("value = 1\n") + self._git("add", "src/change.py") + self._git( + "commit", + "-qm", + "change workflow", + "-m", + "Commit body.", + "-m", + "Co-authored-by: Example User ", + ) + manifest = self.root / "shipped.paths" + manifest.write_text("src/change.py\n") + args = self._args(manifest) + args.required_trailer_email = ["EXAMPLE@example.com"] + + report, failures = validate(args) + + self.assertEqual(failures, []) + self.assertEqual(report["coauthor_trailer_emails"], ["example@example.com"]) + + def test_assume_unchanged_path_is_not_a_clean_handoff(self) -> None: + self._commit({"src/change.py": "value = 1\n"}) + self._git("update-index", "--assume-unchanged", "README.md") + (self.repo / "README.md").write_text("hidden change\n") + manifest = self.root / "shipped.paths" + manifest.write_text("src/change.py\n") + + report, failures = validate(self._args(manifest)) + + self.assertFalse(report["valid"]) + self.assertTrue(any("assume-unchanged" in failure for failure in failures)) + + def test_materialized_skip_worktree_path_is_not_a_clean_handoff(self) -> None: + self._commit({"src/change.py": "value = 1\n"}) + self._git("update-index", "--skip-worktree", "README.md") + (self.repo / "README.md").write_text("hidden change\n") + manifest = self.root / "shipped.paths" + manifest.write_text("src/change.py\n") + + report, failures = validate(self._args(manifest)) + + self.assertFalse(report["valid"]) + self.assertTrue(any("skip-worktree" in failure for failure in failures)) + + def test_ignored_dirty_submodule_is_not_a_clean_handoff(self) -> None: + source = self.root / "dependency-source" + source.mkdir() + subprocess.run(("git", "init", "-q", str(source)), check=True) + subprocess.run( + ("git", "-C", str(source), "config", "user.name", "Submodule Test"), + check=True, + ) + subprocess.run( + ("git", "-C", str(source), "config", "user.email", "submodule@example.test"), + check=True, + ) + (source / "tracked.txt").write_text("committed\n") + subprocess.run(("git", "-C", str(source), "add", "tracked.txt"), check=True) + subprocess.run(("git", "-C", str(source), "commit", "-qm", "initial"), check=True) + self._git( + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + str(source), + "vendor/dependency", + ) + self._git( + "config", + "-f", + ".gitmodules", + "submodule.vendor/dependency.ignore", + "all", + ) + self._git("add", ".gitmodules", "vendor/dependency") + self._git("commit", "-qm", "add dependency") + manifest = self.root / "shipped.paths" + manifest.write_text(".gitmodules\nvendor/dependency\n") + (self.repo / "vendor" / "dependency" / "tracked.txt").write_text("dirty\n") + + self.assertEqual(self._git("status", "--porcelain=v1").stdout, "") + report, failures = validate(self._args(manifest)) + + self.assertFalse(report["valid"]) + self.assertIn("Worktree is not clean.", failures) + + def test_submodule_hidden_index_path_is_not_a_clean_handoff(self) -> None: + source = self.root / "dependency-source" + source.mkdir() + subprocess.run(("git", "init", "-q", str(source)), check=True) + subprocess.run( + ("git", "-C", str(source), "config", "user.name", "Submodule Test"), + check=True, + ) + subprocess.run( + ("git", "-C", str(source), "config", "user.email", "submodule@example.test"), + check=True, + ) + (source / "tracked.txt").write_text("committed\n") + subprocess.run(("git", "-C", str(source), "add", "tracked.txt"), check=True) + subprocess.run(("git", "-C", str(source), "commit", "-qm", "initial"), check=True) + self._git( + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + str(source), + "vendor/dependency", + ) + self._git("commit", "-qam", "add dependency") + manifest = self.root / "shipped.paths" + manifest.write_text(".gitmodules\nvendor/dependency\n") + self._git( + "-C", + "vendor/dependency", + "update-index", + "--assume-unchanged", + "tracked.txt", + ) + (self.repo / "vendor" / "dependency" / "tracked.txt").write_text("hidden change\n") + + self.assertEqual( + self._git("status", "--porcelain=v1", "--ignore-submodules=none").stdout, + "", + ) + report, failures = validate(self._args(manifest)) + + self.assertFalse(report["valid"]) + self.assertFalse(report["clean"]) + self.assertTrue( + any("assume-unchanged=vendor/dependency/tracked.txt" in failure for failure in failures) + ) + + def test_missing_repository_report_is_explicitly_invalid(self) -> None: + args = self._args(self.root / "unused.paths") + args.repo = self.root / "missing" + + report, failures = validate(args) + + self.assertFalse(report["valid"]) + self.assertEqual( + failures, + [f"Repository path does not exist: {args.repo.resolve()}"], + ) + if __name__ == "__main__": unittest.main() diff --git a/.agents/skills/implementation-kickoff/scripts/validate_handoff.py b/.agents/skills/implementation-kickoff/scripts/validate_handoff.py index accc867b45..af35b09c33 100755 --- a/.agents/skills/implementation-kickoff/scripts/validate_handoff.py +++ b/.agents/skills/implementation-kickoff/scripts/validate_handoff.py @@ -5,7 +5,9 @@ import argparse import json +import os import re +import stat import subprocess import sys from pathlib import Path, PurePosixPath @@ -22,6 +24,7 @@ def run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedP check=False, capture_output=True, text=True, + errors="surrogateescape", ) if check and result.returncode != 0: command = "git " + " ".join(args) @@ -63,8 +66,15 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +def _nonblocking_opener(path: str, flags: int) -> int: + return os.open(path, flags | getattr(os, "O_NONBLOCK", 0)) + + def load_shipped_paths(path: Path) -> set[str]: - lines = path.read_text().splitlines() + with open(path, "rb", opener=_nonblocking_opener) as file: + if not stat.S_ISREG(os.fstat(file.fileno()).st_mode): + raise ValueError(f"Shipped-path manifest must be a regular file: {path}") + lines = file.read().decode().splitlines() if not lines: raise ValueError(f"Shipped-path manifest is empty: {path}") @@ -84,20 +94,78 @@ def load_shipped_paths(path: Path) -> set[str]: return shipped_paths +def is_repository_root(path: Path) -> bool: + if not path.is_dir(): + return False + result = run_git(path, "rev-parse", "--show-toplevel", check=False) + return result.returncode == 0 and Path(result.stdout.strip()).resolve() == path.resolve() + + +def hidden_index_paths( + repo: Path, + prefix: str = "", + seen_repositories: frozenset[Path] = frozenset(), +) -> list[str]: + resolved_repo = repo.resolve() + if resolved_repo in seen_repositories: + return [] + seen_repositories |= {resolved_repo} + + def display_path(relative_path: str) -> str: + return f"{prefix}/{relative_path}" if prefix else relative_path + + hidden_paths: list[str] = [] + for entry in run_git(repo, "ls-files", "-v", "-z").stdout.split("\0"): + if len(entry) < 3 or entry[1] != " ": + continue + tag = entry[0] + relative_path = entry[2:] + if tag.islower(): + hidden_paths.append(f"assume-unchanged={display_path(relative_path)}") + elif tag == "S": + candidate = repo / relative_path + if candidate.exists() or candidate.is_symlink(): + hidden_paths.append(f"materialized skip-worktree={display_path(relative_path)}") + for entry in run_git(repo, "ls-files", "--stage", "-z").stdout.split("\0"): + metadata, separator, relative_path = entry.partition("\t") + fields = metadata.split() + if not separator or len(fields) != 3 or fields[0] != "160000" or fields[2] != "0": + continue + submodule_path = repo / relative_path + if is_repository_root(submodule_path): + hidden_paths.extend( + hidden_index_paths( + submodule_path, + display_path(relative_path), + seen_repositories, + ) + ) + return sorted(hidden_paths) + + def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]: repo = args.repo.expanduser().resolve() failures: list[str] = [] if not repo.is_dir(): - return {"repo": str(repo)}, [f"Repository path does not exist: {repo}"] + return {"repo": str(repo), "valid": False}, [f"Repository path does not exist: {repo}"] top_level = Path(run_git(repo, "rev-parse", "--show-toplevel").stdout.strip()).resolve() if top_level != repo: failures.append(f"--repo must be the worktree root: expected {top_level}, got {repo}") - status = run_git(repo, "status", "--porcelain=v1", "--untracked-files=all").stdout + status = run_git( + repo, + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignore-submodules=none", + ).stdout if status: failures.append("Worktree is not clean.") + hidden_paths = hidden_index_paths(repo) + if hidden_paths: + failures.append(f"Index flags can hide worktree changes: {hidden_paths}.") branch_result = run_git(repo, "symbolic-ref", "--quiet", "--short", "HEAD", check=False) branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None @@ -151,12 +219,18 @@ def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]: if not subject: failures.append("HEAD commit subject is empty.") - body = run_git(repo, "show", "-s", "--format=%B", "HEAD").stdout - trailer_pattern = re.compile(r"^Co-authored-by:\s*.+\s+<([^>]+)>\s*$", re.IGNORECASE) + trailer_values = run_git( + repo, + "show", + "-s", + "--format=%(trailers:key=Co-authored-by,valueonly,unfold,separator=%x00)", + "HEAD", + ).stdout.rstrip("\n") + email_pattern = re.compile(r"^.+\s+<([^>\n]+)>$") trailer_emails = { match.group(1).strip().casefold() - for line in body.splitlines() - if (match := trailer_pattern.match(line)) is not None + for value in trailer_values.split("\0") + if (match := email_pattern.match(value)) is not None } for email in args.required_trailer_email: if email.strip().casefold() not in trailer_emails: @@ -169,7 +243,7 @@ def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]: "branch": branch, "subject": subject, "ahead": ahead, - "clean": not status, + "clean": not status and not hidden_paths, "coauthor_trailer_emails": sorted(trailer_emails), "shipped_path_manifest": shipped_manifest, "shipped_paths": shipped_paths, From 9648a401a041919cef91fd68069ef2514708f10e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Aug 2026 16:04:39 +0900 Subject: [PATCH 358/473] fix: make runtime probe approval explicit in skills --- .agents/skills/maintainer-review/SKILL.md | 39 +++++++------------ .../maintainer-review/agents/openai.yaml | 2 +- .../references/evaluation-framework.md | 17 ++++---- .../skills/runtime-behavior-probe/SKILL.md | 16 +++++--- .../runtime-behavior-probe/agents/openai.yaml | 2 +- 5 files changed, 35 insertions(+), 41 deletions(-) diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index 4f511633bc..8f08c57657 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -24,7 +24,7 @@ Make a maintainer decision, not a generic code-review summary. Separate these qu Treat an issue's requested field, callback, flag, class, or implementation strategy as a proposed mechanism, not as the accepted requirement. Do not begin by asking how to implement it. First prove that a concrete user outcome is not already supported and that the proposed mechanism is better than the available alternatives. -Lead with the current review state. Use `Preliminary assessment` while approval-gated runtime work or decision-relevant evidence is pending, and `Maintainer decision` only when the review can be concluded. Use the diff, issue narrative, or contributor effort as evidence, not as a proxy for impact. +Lead with the current review state. Use `Preliminary assessment` while decision-relevant evidence is pending, and `Maintainer decision` only when the review can be concluded. Use the diff, issue narrative, or contributor effort as evidence, not as a proxy for impact. ## Workflow @@ -86,23 +86,24 @@ Do this before deeply evaluating a specified PR. A PR URL selects the starting p When multiple candidates exist, compare them on need coverage, runtime correctness, scope, implementation layer, tests, compatibility, complexity, readiness, remaining maintainer work, and whether useful parts can be combined. Prefer the best maintainable solution, not the first submission or the smallest diff by default. -### 4. Use a two-stage evidence flow +### 4. Use a desk-review evidence flow Always begin with a desk review. Inspect the concrete runtime path before judging a small change as either trivial or meaningful. Check callers, adjacent helpers, validation layers, fallback paths, and existing tests. Search history or documentation only when it changes the decision. Inspecting test code is part of the desk review; executing tests, imports, examples, reproductions, benchmarks, or service calls is a runtime probe. +This skill does not plan or execute runtime probes. Invoking this skill, asking for a review, or supplying an issue or pull-request URL does not authorize tests, imports, examples, reproductions, benchmarks, service calls, or another runtime-probe skill. If decision-relevant runtime evidence remains after desk review, keep the assessment preliminary and suggest a separate runtime investigation. State the unresolved question, why it could change the decision, the evidence needed, and an appropriate base, release, or known-good control. Do not provide an exact command, request approval, invoke another skill, or execute code from this skill. + For repository-specific runtime invariants, start with `.agents/references/README.md` and open only the references that match the affected boundary. Treat `.agents/references/` as read-only during issue and PR review: use it to identify expected invariants, adjacent surfaces, and regression risks, then verify the current claim against the remote change, current code, tests, docs, release boundary, and focused runtime evidence. Do not edit references as a side effect of the review, infer current issue or PR status from them, or treat old issue or PR outcomes as current evidence. If the review reveals a reusable invariant that should be captured, recommend a separate repository-maintenance update unless the user explicitly asks to update references in the same task. -Use this evidence order across the two stages: +Use this evidence order: 1. Trace the closest existing supported capabilities and determine whether they already satisfy the underlying user outcome. 2. Inspect existing tests and complete the code-path trace, including the mandatory interleaving and ownership pass when triggered, without executing code. -3. Proactively run a focused local reproduction of the exact claim when the desk-review rules below require it and it stays within the local-probe authorization below. -4. A comparison with the released version, base branch, or known-good control. -5. A broader runtime matrix only when the maintainer decision remains uncertain and the additional cost and scope are justified; request approval when the expansion crosses the authorization boundary below. +3. Compare the implementation and existing evidence with the released version, base branch, or known-good control without executing code. +4. If a decision-relevant runtime uncertainty remains, stop and suggest a separate runtime investigation using the evidence requirements below. -#### Stage 1: desk review +#### Desk review -Produce an initial result from static evidence before running code: +Produce the result from static evidence: ##### Mandatory unmet-need and design pass @@ -128,24 +129,14 @@ Run this pass before any positive PR assessment when a patch adds, removes, or r 5. Compare base and head for the survivor invariant. Replacing duplicated work with missing handlers, a closed shared resource, reverted state, or a failed surviving task is a regression, not successful cleanup. Do not dismiss stale cleanup as pre-existing when the patch newly invokes it for another failure, cancellation, or retry path. 6. Inspect tests for controlled interleavings using deferred futures, callbacks, or events. Require assertions about the failing and surviving operations' observable behavior and final resource coherence, not only listener counts or individual exception results. -Do not mark a concurrency-sensitive patch `Merge-worthy as-is` merely because sequential reconnect, retry, failure, and close tests pass. A triggered ownership pass is incomplete unless the evidence records the complete mutation surface, concrete ownership mechanism, strongest distinct-mutator interleaving, and survivor and coherence result. If the code trace proves an unsafe interleaving, conclude from static evidence and request a focused fix and regression test. If ownership remains ambiguous, keep the result preliminary until the smallest decisive runtime probe or equivalent evidence resolves it. +Do not mark a concurrency-sensitive patch `Merge-worthy as-is` merely because sequential reconnect, retry, failure, and close tests pass. A triggered ownership pass is incomplete unless the evidence records the complete mutation surface, concrete ownership mechanism, strongest distinct-mutator interleaving, and survivor and coherence result. If the code trace proves an unsafe interleaving, conclude from static evidence and request a focused fix and regression test. If ownership remains ambiguous, keep the result preliminary and state the exact runtime evidence needed to resolve it. - If the claim or PR is decisively negative from a complete reachable code-path trace, conclude the review without a runtime probe. Examples include an impossible or unsupported path, duplicated existing handling, a demonstrated no-op, a direct compatibility break, or a clearly wrong abstraction. Do not call an ambiguous result negative merely to avoid a probe. -- If the initial result is positive and there is no unresolved runtime concern, and any triggered interleaving and ownership pass is complete, the desk review may be sufficient for a final maintainer decision. Do not run a probe only to restate evidence that cannot plausibly change the decision. -- If there is any unresolved runtime concern that could plausibly change claim validity, severity, merge-worthiness, required changes, or the preferred competing PR, run the smallest decisive local probe and control when authorized below. If the probe requires approval or is not practical, report a `Preliminary assessment`, name the concern, and explain the exact evidence still needed. -- A purely stylistic, documentation, CI-status, or repository-readiness concern does not trigger a runtime probe unless it masks a runtime question. - -Do not issue a definitive positive maintainer decision while a decision-relevant runtime concern remains unresolved. If an approval-gated probe is declined or a material concern is practically probeable but remains untested, keep the result preliminary and state the exact confidence limitation. - -#### Stage 2: focused runtime probe - -Invocation of this skill authorizes focused local-only probes that use existing dependencies and temporary or disposable data, do not use credentials, live APIs, or external services, do not modify tracked repository content or persistent external state, and remain narrowly scoped to the review question. Announce the probe before running it, then exercise the real public or internal path and include a base, release, or known-good control when relevant. Do not wait for separate approval for a qualifying local probe, and do not stop at a happy-path smoke check when failure behavior determines the decision. - -Ask for explicit approval before using credentials, a live API, or an external service; installing dependencies; modifying tracked repository content or persistent external state; or starting a materially broad, expensive, or long-running probe. Return to the user for separate approval before expanding an authorized local probe across one of those boundaries. - -For latency, timeout, buffering, backpressure, or cleanup claims, measure at least one observable elapsed-time or state-transition path when feasible. Do not assume that a mocked unit test exercises real scheduling or provider behavior. Prefer a local probe first; use an approval-gated live-service probe only when local evidence cannot settle the decision. +- If the initial result is positive and there is no unresolved runtime concern, and any triggered interleaving and ownership pass is complete, the desk review may be sufficient for a final maintainer decision. Do not suggest additional runtime investigation only to restate evidence that cannot plausibly change the decision. +- If there is any unresolved runtime concern that could plausibly change claim validity, severity, merge-worthiness, required changes, or the preferred competing PR, report a `Preliminary assessment`. State the unresolved question, why it could change the decision, the evidence needed, and an appropriate control, then suggest a separate runtime investigation without planning or executing it. +- A purely stylistic, documentation, CI-status, or repository-readiness concern does not justify suggesting a runtime investigation unless it masks a runtime question. -Use `$runtime-behavior-probe` only when the user explicitly invokes it and the skill is available, or when the user explicitly approves using it for the proposed runtime work. Preserve its environment-variable approval, live-service, cost, cleanup, and reporting gates. Do not make ordinary maintainer review depend on that skill being available. +Do not issue a definitive positive maintainer decision while a decision-relevant runtime concern remains unresolved. If the needed runtime evidence is unavailable or remains untested, keep the result preliminary and state the exact confidence limitation. For changes involving validation, fail-fast behavior, cleanup, retries, interruption, or concurrency, trace lifecycle ordering in addition to the main behavior: @@ -206,7 +197,7 @@ Choose the assessment language using this precedence: Do not infer the assessment language from the GitHub URL, contributor, code, or browser locale. Maintainer comment drafts remain English regardless of the assessment language. Keep the report decision-oriented and compact. Use no more than five evidence bullets by default; add more only when the decision genuinely depends on them. -Use the matching compact report variant in `references/evaluation-framework.md`. While approval-gated runtime work or decision-relevant evidence is pending, use its preliminary-assessment variant and end with the approval request or evidence limitation instead of presenting a final recommendation. Collapse sections for simple cases rather than padding the answer. Put unexpected or negative runtime findings first, and name the preferred PR or approach explicitly when candidates compete. +Use the matching compact report variant in `references/evaluation-framework.md`. While decision-relevant evidence is pending, use its preliminary-assessment variant and end with the evidence limitation and optional suggestion for a separate runtime investigation instead of presenting a final recommendation. Collapse sections for simple cases rather than padding the answer. Put unexpected or negative runtime findings first, and name the preferred PR or approach explicitly when candidates compete. For PRs, put `Need evidence` before code recommendation. When the need is not `Demonstrated`, lead with that result, omit repository readiness, and avoid presenting patch fixes as the primary maintainer action. diff --git a/.agents/skills/maintainer-review/agents/openai.yaml b/.agents/skills/maintainer-review/agents/openai.yaml index 5b2212b12d..b1051c00ef 100644 --- a/.agents/skills/maintainer-review/agents/openai.yaml +++ b/.agents/skills/maintainer-review/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Maintainer Review" short_description: "Gate PR value on demonstrated user need" - default_prompt: "Use $maintainer-review with this GitHub issue or PR URL. Before evaluating implementation quality, verify that linked evidence matches the exact runtime variant and assign Need evidence as Demonstrated, Plausible but unproven, Already covered, or Unsupported. Require either observed practical impact or a complete realistic trigger-to-material-consequence trace; reject harmless speculative logic-only improvements even when the patch is small and correct. Then compare existing and alternative approaches, complete the desk review and required lifecycle ownership checks, proactively run focused local-only probes for decision-relevant concerns, request approval before live API, credentialed, external, mutating, or materially broad runtime work, compare credible competing PRs, recommend the best maintainer action, and include an English comment draft when closure or changes are needed." + default_prompt: "Use $maintainer-review with this GitHub issue or PR URL. Before evaluating implementation quality, verify that linked evidence matches the exact runtime variant and assign Need evidence as Demonstrated, Plausible but unproven, Already covered, or Unsupported. Require either observed practical impact or a complete realistic trigger-to-material-consequence trace; reject harmless speculative logic-only improvements even when the patch is small and correct. Then compare existing and alternative approaches and complete the desk review and required lifecycle ownership checks without executing tests, imports, examples, reproductions, benchmarks, service calls, or another runtime-probe skill. If decision-relevant runtime evidence remains, stop with a Preliminary assessment and suggest a separate runtime investigation by stating the unresolved question, decision impact, evidence needed, and control. Do not plan a command, request probe approval, execute code, or invoke another skill from this review. Compare credible competing PRs, recommend the best maintainer action, and include an English comment draft when closure or changes are needed." diff --git a/.agents/skills/maintainer-review/references/evaluation-framework.md b/.agents/skills/maintainer-review/references/evaluation-framework.md index fcb8fdbaf8..36d4973063 100644 --- a/.agents/skills/maintainer-review/references/evaluation-framework.md +++ b/.agents/skills/maintainer-review/references/evaluation-framework.md @@ -20,7 +20,7 @@ Use this reference when a claim is ambiguous, severity is disputed, or a PR is t ## Decision model -Treat validity, severity, and merge-worthiness as separate results. Also distinguish a `Preliminary assessment`, which may still require approval-gated runtime work or other decision-relevant evidence, from a final `Maintainer decision`. Do not label a provisional positive result as a verdict or final decision. +Treat validity, severity, and merge-worthiness as separate results. Also distinguish a `Preliminary assessment`, which may still require decision-relevant evidence, from a final `Maintainer decision`. Do not label a provisional positive result as a verdict or final decision. | Dimension | Questions | Strong evidence | |---|---|---| @@ -301,9 +301,9 @@ I am going to close this for now. If you can provide - -## Proposed runtime probe -- Concern: -- Probe: +## Additional runtime investigation suggested +- Unresolved question: +- Decision impact: +- Evidence needed: - Control: -- Approval boundary: -## Approval request - + ``` ### Issue diff --git a/.agents/skills/runtime-behavior-probe/SKILL.md b/.agents/skills/runtime-behavior-probe/SKILL.md index d6e1cf29ee..198b7d88bf 100644 --- a/.agents/skills/runtime-behavior-probe/SKILL.md +++ b/.agents/skills/runtime-behavior-probe/SKILL.md @@ -12,9 +12,12 @@ Use this skill to investigate real runtime behavior, not to restate code or docu ## Core Rules - Treat this skill as manual-only. Do not rely on implicit invocation. +- Invoking this skill authorizes planning only. Every runtime probe requires explicit user approval after the exact probe has been proposed. Do not infer execution approval from the skill invocation or a general request to investigate runtime behavior. +- Before requesting approval, disclose the source identity, exact command, transitively executed material, known filesystem, environment, network, and host-service capabilities, expected side effects, and control for the proposed probe. Mark unknown capabilities as unknown rather than assuming that they are unavailable. +- Wait for an affirmative response before executing the probe. Approval is bound to the disclosed source, command, executed material, and capability scope. Obtain new approval before changing any of those fields, adding another probe, or expanding the approved matrix. - A baseline success or smoke case is often the right entry point, but do not stop there when the real question involves edge cases, drift, or failure behavior. - Plan before running anything. Write the case matrix first, then fill it in with observed results. The matrix can live in a scratch note, a temporary file, or the probe script header. -- Default to local or read-only probes. Consider a live service only when it is clearly relevant, then apply the lightweight gates below before you run it. +- Default to proposing local or read-only probes. Consider a live service only when it is clearly relevant, then apply the lightweight gates below before requesting approval. - Size the probe to the decision. Start with the smallest matrix that can disqualify or validate the current hypothesis, then expand only when uncertainty remains. - Before a live probe, apply three lightweight gates: - Destination gate. Use only a live destination that is clearly allowed for the task. @@ -58,11 +61,12 @@ Use this skill to investigate real runtime behavior, not to restate code or docu - Run Python probes from the repository root with `uv run python` when practical. - Record the current commit, working directory, Python executable, and Python version. - Avoid accidental imports from a different checkout or site-packages location. If you must deviate from `uv run python`, say exactly why and what interpreter or environment was used instead. -13. Execute the matrix and capture evidence. Record request shape, setup, observation summary, unexpected or negative result, error details, timing, runtime context, approved environment-variable names, repeat counts, warm-up handling, variance when relevant, cleanup behavior, and for comparisons note what was held constant plus any response-shape or usage notes that affect interpretation. -14. Update the matrix with actual outcomes, not guesses. -15. Keep temporary artifacts until the final response is drafted. Then delete them unless the user asked to keep them or they are needed for follow-up. Benchmark and repeat-heavy probes often need follow-up, so keeping artifacts is normal when the result may be revisited. If deleted, retain and report a short run summary. -16. Report findings first, with unexpected or negative findings first. Then summarize how the validation was performed and which cases were covered. -17. If the probe isolates one clear defect, you may include a short implementation hypothesis or minimal repro direction. Do not expand into a larger next-step plan unless the user asked for it. +13. Present the complete probe proposal with the disclosures required above, including the exact command for each case or approved matrix, then ask the user for explicit approval and wait. +14. Execute only the approved matrix and capture evidence. Record request shape, setup, observation summary, unexpected or negative result, error details, timing, runtime context, approved environment-variable names, repeat counts, warm-up handling, variance when relevant, cleanup behavior, and for comparisons note what was held constant plus any response-shape or usage notes that affect interpretation. +15. Update the matrix with actual outcomes, not guesses. +16. Keep temporary artifacts until the final response is drafted. Then delete them unless the user asked to keep them or they are needed for follow-up. Benchmark and repeat-heavy probes often need follow-up, so keeping artifacts is normal when the result may be revisited. If deleted, retain and report a short run summary. +17. Report findings first, with unexpected or negative findings first. Then summarize how the validation was performed and which cases were covered. +18. If the probe isolates one clear defect, you may include a short implementation hypothesis or minimal repro direction. Do not expand into a larger next-step plan unless the user asked for it. ## Validation Matrix diff --git a/.agents/skills/runtime-behavior-probe/agents/openai.yaml b/.agents/skills/runtime-behavior-probe/agents/openai.yaml index fd7635d397..aead5dc46c 100644 --- a/.agents/skills/runtime-behavior-probe/agents/openai.yaml +++ b/.agents/skills/runtime-behavior-probe/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Runtime Behavior Probe" short_description: "Plan and run runtime behavior probes" - default_prompt: "Use $runtime-behavior-probe to investigate actual runtime behavior with a validation matrix, explicit state controls, and a findings-first report." + default_prompt: "Use $runtime-behavior-probe to plan an investigation of actual runtime behavior with a validation matrix and explicit state controls. Before executing every probe, disclose the source identity, exact command, transitively executed material, available filesystem, environment, network, and host-service capabilities, side effects, and control, then wait for explicit user approval of that exact probe or matrix. Invoking this skill is not execution approval. After approval, run only the approved scope and produce a findings-first report." policy: allow_implicit_invocation: false From 82e3571fc55a8583239c74a0cec8c5497f0d7a2c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Aug 2026 19:34:26 +0900 Subject: [PATCH 359/473] refactor: move workflow execution out of repository skills --- .agents/skills/examples-auto-run/SKILL.md | 86 ---------- .../examples-auto-run/agents/openai.yaml | 4 - .agents/skills/examples-run-analysis/SKILL.md | 51 ++++++ .../examples-run-analysis/agents/openai.yaml | 4 + .agents/skills/integration-tests/SKILL.md | 66 -------- .../integration-tests/agents/openai.yaml | 4 - .github/scripts/detect-changes.sh | 2 +- .../run.sh => .github/scripts/run_examples.sh | 65 +------- .github/scripts/run_integration_tests.py | 117 ++++++++++---- .github/workflows/tests.yml | 2 +- Makefile | 68 ++++++-- examples/README.md | 18 +++ examples/run_examples.py | 73 --------- integration_tests/README.md | 13 +- tests/test_integration_runner.py | 151 ++++++++++++++++++ tests/test_repository_workflow_interfaces.py | 130 +++++++++++++++ tests/test_run_examples_script.py | 21 +++ 17 files changed, 530 insertions(+), 345 deletions(-) delete mode 100644 .agents/skills/examples-auto-run/SKILL.md delete mode 100644 .agents/skills/examples-auto-run/agents/openai.yaml create mode 100644 .agents/skills/examples-run-analysis/SKILL.md create mode 100644 .agents/skills/examples-run-analysis/agents/openai.yaml delete mode 100644 .agents/skills/integration-tests/SKILL.md delete mode 100644 .agents/skills/integration-tests/agents/openai.yaml rename .agents/skills/examples-auto-run/scripts/run.sh => .github/scripts/run_examples.sh (69%) create mode 100644 examples/README.md create mode 100644 tests/test_repository_workflow_interfaces.py diff --git a/.agents/skills/examples-auto-run/SKILL.md b/.agents/skills/examples-auto-run/SKILL.md deleted file mode 100644 index 35285e9c41..0000000000 --- a/.agents/skills/examples-auto-run/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: examples-auto-run -description: Run python examples in auto mode with logging, rerun helpers, and background control. ---- - -# examples-auto-run - -## What it does - -- Runs `uv run examples/run_examples.py` with: - - Optional dependency extras enabled by default: - `litellm`, `any-llm`, `sqlalchemy`, `redis`, `blaxel`, `modal`, `runloop`, and `temporal`. - - `EXAMPLES_INTERACTIVE_MODE=auto` (auto-input/auto-approve). - - Per-example logs under `.tmp/examples-start-logs/`. - - Main summary log path passed via `--main-log` (also under `.tmp/examples-start-logs/`). - - Generates a rerun list of failures at `.tmp/examples-rerun.txt` when `--write-rerun` is set. -- Provides start/stop/status/logs/tail/collect/rerun helpers via `run.sh`. -- Background option keeps the process running with a pidfile; `stop` cleans it up. - -## Usage - -```bash -# Start (auto mode; interactive included by default) -.agents/skills/examples-auto-run/scripts/run.sh start [extra args to run_examples.py] -# Examples: -.agents/skills/examples-auto-run/scripts/run.sh start --filter basic -.agents/skills/examples-auto-run/scripts/run.sh start --include-server --include-audio - -# Check status -.agents/skills/examples-auto-run/scripts/run.sh status - -# Stop running job -.agents/skills/examples-auto-run/scripts/run.sh stop - -# List logs -.agents/skills/examples-auto-run/scripts/run.sh logs - -# Tail latest log (or specify one) -.agents/skills/examples-auto-run/scripts/run.sh tail -.agents/skills/examples-auto-run/scripts/run.sh tail main_20260113-123000.log - -# Collect rerun list from a main log (defaults to latest main_*.log) -.agents/skills/examples-auto-run/scripts/run.sh collect - -# Rerun only failed entries from rerun file (auto mode) -.agents/skills/examples-auto-run/scripts/run.sh rerun -``` - -## Codex execution environment - -- When Codex invokes `start` or `rerun`, run the command outside the Codex sandbox by default (`sandbox_permissions=require_escalated`). Many examples start nested sandboxes, browsers, npm helpers, or cloud/local service processes; running from inside the Codex sandbox can produce environment-only failures such as `sandbox-exec: sandbox_apply: Operation not permitted`, Playwright cache permission errors, or npm cache permission errors. -- Use sandboxed execution only when the user explicitly asks for it or when running a narrow dry-run / log inspection command that does not execute examples. - -## Defaults (overridable via env) - -- `EXAMPLES_INTERACTIVE_MODE=auto` -- `EXAMPLES_INCLUDE_INTERACTIVE=1` -- `EXAMPLES_INCLUDE_SERVER=0` -- `EXAMPLES_INCLUDE_AUDIO=0` -- `EXAMPLES_INCLUDE_EXTERNAL=0` -- `EXAMPLES_UV_EXTRAS="litellm any-llm sqlalchemy redis blaxel modal runloop temporal"` (set to an empty string to disable extras) -- Auto-approvals in auto mode: `APPLY_PATCH_AUTO_APPROVE=1`, `SHELL_AUTO_APPROVE=1`, `AUTO_APPROVE_MCP=1` - -## Log locations - -- Main logs: `.tmp/examples-start-logs/main_*.log` -- Per-example logs (from `run_examples.py`): `.tmp/examples-start-logs/.log` -- Rerun list: `.tmp/examples-rerun.txt` -- Stdout logs: `.tmp/examples-start-logs/stdout_*.log` - -## Notes - -- The runner delegates to `uv run --extra ... examples/run_examples.py`, which already writes per-example logs and supports `--collect`, `--rerun-file`, and `--print-auto-skip`. -- `examples/sandbox/extensions/vercel_runner.py` is temporarily excluded from auto runs due to credential issues. Do not force-run it until the credential setup is fixed. -- `start` uses `--write-rerun` so failures are captured automatically. -- If `.tmp/examples-rerun.txt` exists and is non-empty, invoking the skill with no args runs `rerun` by default. - -## Behavioral validation (Codex/LLM responsibility) - -The runner does not perform any automated behavioral validation. After every foreground `start` or `rerun`, **Codex must manually validate** all exit-0 entries: - -1. Read the example source (and comments) to infer intended flow, tools used, and expected key outputs. -2. Open the matching per-example log under `.tmp/examples-start-logs/`. -3. Confirm the intended actions/results occurred; flag omissions or divergences. -4. Do this for **all passed examples**, not just a sample. -5. Report immediately after the run with concise citations to the exact log lines that justify the validation. diff --git a/.agents/skills/examples-auto-run/agents/openai.yaml b/.agents/skills/examples-auto-run/agents/openai.yaml deleted file mode 100644 index bb9b66c695..0000000000 --- a/.agents/skills/examples-auto-run/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Examples Auto Run" - short_description: "Run examples in auto mode with logs and rerun helpers" - default_prompt: "Use $examples-auto-run to run the repo examples in auto mode, collect logs, and summarize any failures." diff --git a/.agents/skills/examples-run-analysis/SKILL.md b/.agents/skills/examples-run-analysis/SKILL.md new file mode 100644 index 0000000000..bcabae2e13 --- /dev/null +++ b/.agents/skills/examples-run-analysis/SKILL.md @@ -0,0 +1,51 @@ +--- +name: examples-run-analysis +description: Analyze artifacts from the latest completed manual examples Make run. Read the main log, every relevant per-example log, and example source; validate every exit-0 example and classify failures, skips, and environment restrictions. Never execute or control examples. +--- + +# Examples Run Analysis + +Use this skill only to analyze artifacts that already exist after a user has manually invoked an examples Make target. This skill is read-only and analysis-only. + +## Hard boundary + +- Never start, retry, stop, or otherwise execute examples. +- Never invoke an examples Make target or `.github/scripts/run_examples.sh`. +- Never request elevated execution, alter an environment, remove a pid file, or own or signal a background process. +- Never treat an older completed run as current when the newest run is active, incomplete, or stale. +- If usable results are missing, stale, incomplete, or still running, stop the analysis and ask the user to run the appropriate Make target manually. Give the exact command but do not execute it. + +The supported workflow is an explicit manual Make invocation followed by analysis of the generated artifacts. + +## Artifacts to inspect + +- Background pid file: `.tmp/examples-auto-run.pid`. +- Main logs: `.tmp/examples-start-logs/main_*.log`. +- Per-example logs named by each `log=` field in the selected main log. +- Example sources named by `PASSED`, `FAILED`, and `SKIPPED` records. +- Runner sources that define artifact meaning: `examples/run_examples.py`, `.github/scripts/run_examples.sh`, and the example source files included in the run. + +Use only read-only inspection commands such as `git status`, `git log`, `find`, `ls`, `stat`, `ps`, `sed`, and `rg`. Do not call a command that can update an artifact or process. + +## Analysis workflow + +1. Inspect the process table and `.tmp/examples-auto-run.pid` without changing either. Treat a process as an active examples run only when its command line is rooted in the current repository and invokes `.github/scripts/run_examples.sh` or `examples/run_examples.py`, including foreground and background runs. Use the pid file only to correlate a background process; an absent or stale pid file does not prove that no run is active. If a matching process is live, stop the analysis. Tell the user to wait for a foreground Make run to finish, or ask the user to run `make examples-status` manually for a background run, before requesting analysis again. +2. Select the newest `main_*.log`. Require exactly one terminal `# summary executed= skipped= failed=` record. Treat a missing or malformed summary, a changing log, or a matching active examples process as incomplete. +3. Treat the result as stale when relevant runner or selected example source content changed after the run. Use Git history and file timestamps as evidence. If freshness cannot be established, say so and request a new manual run instead of assuming the artifacts apply. +4. Parse every `PASSED`, `FAILED`, and `SKIPPED` record. Reconcile their counts with the terminal summary. Confirm that every referenced per-example log exists. +5. For every `PASSED` record, without sampling, read the complete example source and its per-example log. Infer the intended flow, tools, side effects, and key result from the source and comments, then verify that the log demonstrates those behaviors. Exit status 0 alone is not behavioral validation. +6. Read the relevant per-example logs for failures and environment-related skips. Classify each result as an example or SDK defect, dependency or credential problem, provider or network failure, local service or platform restriction, intentional runner skip, or unresolved. Keep genuine product failures separate from environment restrictions. +7. Report the selected main log, freshness and completeness evidence, summary counts, validation status for every exit-0 example, classified failures and skips, and exact source/log line references that support each conclusion. + +## Manual commands to request when artifacts are unusable + +Choose the narrowest applicable command and ask the user to run it in a terminal: + +```bash +make examples-run +make examples-run EXAMPLES_ARGS="--filter basic" +make examples-run-background EXAMPLES_ARGS="--include-server --include-audio" +make examples-status +``` + +Do not execute any of these commands as part of this skill. diff --git a/.agents/skills/examples-run-analysis/agents/openai.yaml b/.agents/skills/examples-run-analysis/agents/openai.yaml new file mode 100644 index 0000000000..a3753614e5 --- /dev/null +++ b/.agents/skills/examples-run-analysis/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Examples Run Analysis" + short_description: "Analyze completed example-run artifacts" + default_prompt: "Use $examples-run-analysis to inspect the latest completed manual examples run and validate every exit-0 example without executing or controlling any process." diff --git a/.agents/skills/integration-tests/SKILL.md b/.agents/skills/integration-tests/SKILL.md deleted file mode 100644 index 10992d4ea7..0000000000 --- a/.agents/skills/integration-tests/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: integration-tests -description: Run the packaged OpenAI Agents Python SDK integration tests from clean wheel and source-distribution environments. Use for release readiness, live OpenAI regression checks, package import compatibility, optional-extra validation, or when asked to run integration tests after examples-auto-run. ---- - -# Integration Tests - -## Overview - -Run the release-oriented integration suite against the exact wheel and source distribution produced by `uv build`. The runner installs both artifacts into isolated environments and validates supported imports, optional extras, OpenAI model adapters, hosted tools, Realtime, and voice workflows. - -## Execution requirements - -- Fresh isolated environments download optional dependencies from PyPI and connect to the configured API providers. -- When the execution environment requires approval for package downloads or configured provider connections, request elevated command execution (`sandbox_permissions=require_escalated`). Retry with the required network permissions before classifying a connectivity failure as an SDK regression. - -## Release workflow - -Run this command from the repository root: - -```bash -env UV_DEFAULT_INDEX=https://pypi.org/simple \ - OPENAI_AGENTS_INTEGRATION_STRICT=1 \ - OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS=1 \ - OPENAI_AGENTS_INTEGRATION_DIRECT_PROVIDERS=0 \ - make integration-tests-release -``` - -- Use the release profile as the default whenever `$integration-tests` is invoked without a narrower request. -- Use OpenRouter as the standard multi-provider gateway. Add provider-specific direct connections only when the user explicitly requests that additional credential matrix. -- Use existing `OPENAI_API_KEY` and `OPENROUTER_API_KEY` values without printing them. The release target enforces strict mode, so missing required service configuration fails instead of skipping. -- The command rebuilds the wheel and source distribution, creates isolated virtual environments, checks public imports and optional dependencies, runs the release-oriented live suites, and executes the local Docker security contract against both artifacts. -- Do not run watch mode, modify source files, create a branch, commit, push, or open a pull request as part of this skill. - -## Paired release validation - -When the user requests both pre-release checks, run `$examples-auto-run` first and follow that skill's required per-example behavioral validation. Then run the command above and report the examples and integration outcomes separately. Invoking `$integration-tests` alone does not implicitly start the examples suite. - -## Focused commands - -Use a focused target only when the user specifically asks to narrow the run: - -```bash -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-packaging -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-security -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-core -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-providers -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-hosted -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-realtime -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-voice -env UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests-extras -``` - -For the minimum supported Python package boundary, use: - -```bash -env UV_DEFAULT_INDEX=https://pypi.org/simple \ - OPENAI_AGENTS_INTEGRATION_PYTHON=3.10 \ - make integration-tests-packaging -``` - -Nightly and manual profiles include additional capability-specific or higher-cost checks. Run them only when explicitly requested; use the configured OpenRouter matrix by default and include direct providers only when explicitly selected. - -## Reporting - -Report the final pass, fail, skip, and deselection counts for each isolated environment. If a command fails, identify the exact profile, package environment, failing test, and actionable error. Separate product regressions from missing credentials, unsupported hosted features, dependency installation failures, and execution-environment restrictions. diff --git a/.agents/skills/integration-tests/agents/openai.yaml b/.agents/skills/integration-tests/agents/openai.yaml deleted file mode 100644 index cd918c14f2..0000000000 --- a/.agents/skills/integration-tests/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Integration Tests" - short_description: "Run packaged Python SDK integration tests" - default_prompt: "Use $integration-tests to run the packaged Python SDK integration suite." diff --git a/.github/scripts/detect-changes.sh b/.github/scripts/detect-changes.sh index 93423ded9e..540c77b28f 100755 --- a/.github/scripts/detect-changes.sh +++ b/.github/scripts/detect-changes.sh @@ -46,7 +46,7 @@ changed_files=$(git diff --name-only "$base_sha" "$head_sha" || true) case "$mode" in code) - pattern='^(src/|tests/|integration_tests/|examples/|\.github/scripts/(detect-changes\.sh|run_integration_tests\.py|update_released_api_contract\.py)$|\.github/workflows/tests\.yml$|pyproject.toml$|uv.lock$|Makefile$)' + pattern='^(src/|tests/|integration_tests/|examples/|\.agents/skills/(examples-auto-run|examples-run-analysis|integration-tests)/|\.github/scripts/(detect-changes\.sh|run_examples\.sh|run_integration_tests\.py|update_released_api_contract\.py)$|\.github/workflows/tests\.yml$|pyproject.toml$|uv.lock$|Makefile$)' ;; docs) pattern='^(docs/|mkdocs.yml$)' diff --git a/.agents/skills/examples-auto-run/scripts/run.sh b/.github/scripts/run_examples.sh similarity index 69% rename from .agents/skills/examples-auto-run/scripts/run.sh rename to .github/scripts/run_examples.sh index 9d8d1987db..a483051b8e 100755 --- a/.agents/skills/examples-auto-run/scripts/run.sh +++ b/.github/scripts/run_examples.sh @@ -1,10 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PID_FILE="$ROOT/.tmp/examples-auto-run.pid" LOG_DIR="$ROOT/.tmp/examples-start-logs" -RERUN_FILE="$ROOT/.tmp/examples-rerun.txt" DEFAULT_UV_EXTRAS="litellm any-llm sqlalchemy redis blaxel modal runloop temporal" build_uv_prefix() { @@ -49,7 +48,6 @@ cmd_start() { local run_cmd=( "${UV_RUN[@]}" examples/run_examples.py --auto-mode - --write-rerun --main-log "$main_log" --logs-dir "$LOG_DIR" ) @@ -81,7 +79,7 @@ cmd_start() { echo "Started run_examples.py (pid=$pid)" echo "Main log: $main_log" echo "Stdout log: $stdout_log" - echo "Run '.agents/skills/examples-auto-run/scripts/run.sh validate \"$main_log\"' after it finishes." + echo "After the run completes, use examples-run-analysis to inspect its artifacts." return 0 fi @@ -159,78 +157,31 @@ cmd_tail() { tail -f "$LOG_DIR/$file" } -collect_rerun() { - ensure_dirs - local log_file="${1:-}" - if [[ -z "$log_file" ]]; then - log_file="$(ls -1t "$LOG_DIR"/main_*.log 2>/dev/null | head -n1)" - fi - if [[ -z "$log_file" ]] || [[ ! -f "$log_file" ]]; then - echo "No main log file found." - exit 1 - fi - cd "$ROOT" - build_uv_prefix - "${UV_RUN[@]}" examples/run_examples.py --collect "$log_file" --output "$RERUN_FILE" -} - -cmd_rerun() { - ensure_dirs - local file="${1:-$RERUN_FILE}" - if [[ ! -s "$file" ]]; then - echo "Rerun list is empty: $file" - exit 0 - fi - local ts main_log stdout_log - ts="$(date +%Y%m%d-%H%M%S)" - main_log="$LOG_DIR/main_${ts}.log" - stdout_log="$LOG_DIR/stdout_${ts}.log" - cd "$ROOT" - export EXAMPLES_INTERACTIVE_MODE="${EXAMPLES_INTERACTIVE_MODE:-auto}" - export APPLY_PATCH_AUTO_APPROVE="${APPLY_PATCH_AUTO_APPROVE:-1}" - export SHELL_AUTO_APPROVE="${SHELL_AUTO_APPROVE:-1}" - export AUTO_APPROVE_MCP="${AUTO_APPROVE_MCP:-1}" - build_uv_prefix - set +e - "${UV_RUN[@]}" examples/run_examples.py --auto-mode --rerun-file "$file" --write-rerun --main-log "$main_log" --logs-dir "$LOG_DIR" 2>&1 | tee "$stdout_log" - local run_status=${PIPESTATUS[0]} - set -e - return "$run_status" -} - usage() { cat <<'EOF' -Usage: run.sh [args...] +Usage: run_examples.sh [args...] Commands: start [--filter ... | other args] Run examples in auto mode (foreground). Pass --background to run detached. - stop Kill the running auto-run (if any). - status Show whether it is running. + stop Kill the running examples job (if any). + status Show whether an examples job is running. logs List log files (.tmp/examples-start-logs). tail [logfile] Tail the latest (or specified) log. - collect [main_log] Parse a main log and write failed examples to .tmp/examples-rerun.txt. - rerun [rerun_file] Run only the examples listed in .tmp/examples-rerun.txt. Environment overrides: EXAMPLES_INTERACTIVE_MODE (default auto) EXAMPLES_INCLUDE_SERVER/INTERACTIVE/AUDIO/EXTERNAL (defaults: 0/1/0/0) - EXAMPLES_UV_EXTRAS (default: litellm any-llm sqlalchemy redis blaxel modal runloop; set empty to disable) + EXAMPLES_UV_EXTRAS (default: litellm any-llm sqlalchemy redis blaxel modal runloop temporal; set empty to disable) APPLY_PATCH_AUTO_APPROVE, SHELL_AUTO_APPROVE, AUTO_APPROVE_MCP (default 1 in auto mode) EOF } -default_cmd="start" -if [[ $# -eq 0 && -s "$RERUN_FILE" ]]; then - default_cmd="rerun" -fi - -case "${1:-$default_cmd}" in +case "${1:-start}" in start) shift || true; cmd_start "$@" ;; stop) shift || true; cmd_stop ;; status) shift || true; cmd_status ;; logs) shift || true; cmd_logs ;; tail) shift; cmd_tail "${1:-}" ;; - collect) shift || true; collect_rerun "${1:-}" ;; - rerun) shift || true; cmd_rerun "${1:-}" ;; + help | --help | -h) usage ;; *) usage; exit 1 ;; esac diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 86d0b382d1..6e46c5a8b9 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -7,16 +7,10 @@ import subprocess import sys import xml.etree.ElementTree as ET +from collections.abc import Callable, MutableMapping, Sequence from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from integration_tests._contract_support import ( # noqa: E402 - SubmoduleExportPolicy, - load_submodule_export_policy, -) - WORKSPACE = ROOT / ".tmp" / "integration-tests" DIST = WORKSPACE / "dist" RESULTS = WORKSPACE / "results" @@ -36,22 +30,89 @@ "s3", ) STRICT_PROFILES = frozenset({"release", "security"}) -PROFILES = ( - "packaging", - "prospective-contract", - "prospective-platform", - "security", - "mcp-v1", - "core", - "providers", - "realtime", - "voice", - "hosted", - "extras", - "full", - "release", - "nightly", - "manual", +LOCAL_ONLY_CREDENTIAL_CLASS = "local-only" +LIVE_CREDENTIAL_CLASS = "live" +PROFILE_CREDENTIAL_CLASSES = { + "packaging": LOCAL_ONLY_CREDENTIAL_CLASS, + "prospective-contract": LOCAL_ONLY_CREDENTIAL_CLASS, + "prospective-platform": LOCAL_ONLY_CREDENTIAL_CLASS, + "security": LOCAL_ONLY_CREDENTIAL_CLASS, + "mcp-v1": LOCAL_ONLY_CREDENTIAL_CLASS, + "extras": LOCAL_ONLY_CREDENTIAL_CLASS, + "core": LIVE_CREDENTIAL_CLASS, + "providers": LIVE_CREDENTIAL_CLASS, + "realtime": LIVE_CREDENTIAL_CLASS, + "voice": LIVE_CREDENTIAL_CLASS, + "hosted": LIVE_CREDENTIAL_CLASS, + "full": LIVE_CREDENTIAL_CLASS, + "release": LIVE_CREDENTIAL_CLASS, + "nightly": LIVE_CREDENTIAL_CLASS, + "manual": LIVE_CREDENTIAL_CLASS, +} +PROFILES = tuple(PROFILE_CREDENTIAL_CLASSES) +BOOTSTRAPPED_ENV = "OPENAI_AGENTS_INTEGRATION_RUNNER_BOOTSTRAPPED" + + +def parse_args(arguments: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run packaged openai-agents integration tests.") + parser.add_argument("--profile", choices=PROFILES, default="full") + parser.add_argument( + "--all", + action="store_true", + help="Include configured direct Anthropic and Gemini providers alongside OpenRouter.", + ) + return parser.parse_args(arguments) + + +def prepare_profile_environment( + profile: str, + environ: MutableMapping[str, str] | None = None, +) -> str: + environment = os.environ if environ is None else environ + try: + credential_class = PROFILE_CREDENTIAL_CLASSES[profile] + except KeyError as error: + raise RuntimeError(f"Integration profile {profile!r} has no credential class.") from error + + if credential_class == LIVE_CREDENTIAL_CLASS: + if environment.get("OPENAI_API_KEY_SOURCE") != "service-account": + raise RuntimeError( + f"Live integration profile {profile!r} requires " + "OPENAI_API_KEY_SOURCE=service-account before any build or subprocess starts. " + "Load the approved service-account environment and retry the Make target." + ) + elif credential_class == LOCAL_ONLY_CREDENTIAL_CLASS: + environment.pop("OPENAI_API_KEY", None) + else: + raise RuntimeError( + f"Integration profile {profile!r} has unknown credential class {credential_class!r}." + ) + return credential_class + + +def bootstrap_in_uv( + arguments: Sequence[str], + environ: MutableMapping[str, str], + exec_function: Callable[[str, list[str], dict[str, str]], object] = os.execvpe, +) -> None: + args = parse_args(arguments) + prepare_profile_environment(args.profile, environ) + child_env = dict(environ) + child_env[BOOTSTRAPPED_ENV] = "1" + command = ["uv", "run", "python", str(Path(__file__).resolve()), *arguments] + exec_function(command[0], command, child_env) + raise RuntimeError("The uv integration runner bootstrap returned unexpectedly.") + + +if __name__ == "__main__" and os.environ.get(BOOTSTRAPPED_ENV) != "1": + bootstrap_in_uv(sys.argv[1:], os.environ) + + +sys.path.insert(0, str(ROOT)) + +from integration_tests._contract_support import ( # noqa: E402 + SubmoduleExportPolicy, + load_submodule_export_policy, ) @@ -355,14 +416,8 @@ def _sanitize_and_load_junit(result_path: Path) -> ET.Element | None: def main() -> None: - parser = argparse.ArgumentParser(description="Run packaged openai-agents integration tests.") - parser.add_argument("--profile", choices=PROFILES, default="full") - parser.add_argument( - "--all", - action="store_true", - help="Include configured direct Anthropic and Gemini providers alongside OpenRouter.", - ) - args = parser.parse_args() + args = parse_args() + prepare_profile_environment(args.profile) prospective_policy: SubmoduleExportPolicy | None = None if args.profile in {"prospective-contract", "prospective-platform"}: prospective_contract = os.environ.get(PROSPECTIVE_CONTRACT_ENV) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2b9434bb33..961e6dfd39 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -257,7 +257,7 @@ jobs: if: steps.changes.outputs.run == 'true' env: OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT: ${{ github.workspace }}/.tmp/prospective_released_api_contract.json - run: uv run python .github/scripts/run_integration_tests.py --profile prospective-platform + run: make integration-tests-prospective-platform - name: Skip Windows prospective contract smoke test if: steps.changes.outputs.run != 'true' run: echo "Skipping Windows prospective contract smoke test for non-code changes." diff --git a/Makefile b/Makefile index 76eedc5b8b..40fb3266ac 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,8 @@ PROSPECTIVE_RELEASED_API_CONTRACT ?= .tmp/prospective_released_api_contract.json .PHONY: prepare-prospective-released-api-contract prepare-prospective-released-api-contract: - @version="$$(uv run python -c 'from importlib.metadata import version; print(version("openai-agents"))')"; \ + @unset OPENAI_API_KEY; \ + version="$$(uv run python -c 'from importlib.metadata import version; print(version("openai-agents"))')"; \ uv run python .github/scripts/update_released_api_contract.py \ --version "$$version" \ --output "$(PROSPECTIVE_RELEASED_API_CONTRACT)" @@ -92,53 +93,86 @@ tests-serial: tests-serial-review: uv run python .github/scripts/run_serial_tests.py --exclude-review-optional +EXAMPLES_RUNNER := bash .github/scripts/run_examples.sh +EXAMPLES_ARGS ?= +EXAMPLES_LOG ?= +INTEGRATION_TEST_RUNNER := python .github/scripts/run_integration_tests.py + +.PHONY: examples-run +examples-run: + $(EXAMPLES_RUNNER) start $(EXAMPLES_ARGS) + +.PHONY: examples-run-background +examples-run-background: + $(EXAMPLES_RUNNER) start --background $(EXAMPLES_ARGS) + +.PHONY: examples-status +examples-status: + $(EXAMPLES_RUNNER) status + +.PHONY: examples-stop +examples-stop: + $(EXAMPLES_RUNNER) stop + +.PHONY: examples-logs +examples-logs: + $(EXAMPLES_RUNNER) logs + +.PHONY: examples-tail +examples-tail: + $(EXAMPLES_RUNNER) tail $(EXAMPLES_LOG) + .PHONY: integration-tests integration-tests: - uv run python .github/scripts/run_integration_tests.py --profile full $(filter --all,$(MAKECMDGOALS)) + $(INTEGRATION_TEST_RUNNER) --profile full $(filter --all,$(MAKECMDGOALS)) .PHONY: integration-tests-release integration-tests-release: - uv run python .github/scripts/run_integration_tests.py --profile release $(filter --all,$(MAKECMDGOALS)) + $(INTEGRATION_TEST_RUNNER) --profile release $(filter --all,$(MAKECMDGOALS)) .PHONY: integration-tests-nightly integration-tests-nightly: - uv run python .github/scripts/run_integration_tests.py --profile nightly $(filter --all,$(MAKECMDGOALS)) + $(INTEGRATION_TEST_RUNNER) --profile nightly $(filter --all,$(MAKECMDGOALS)) .PHONY: integration-tests-manual integration-tests-manual: - uv run python .github/scripts/run_integration_tests.py --profile manual $(filter --all,$(MAKECMDGOALS)) + $(INTEGRATION_TEST_RUNNER) --profile manual $(filter --all,$(MAKECMDGOALS)) .PHONY: integration-tests-packaging integration-tests-packaging: - uv run python .github/scripts/run_integration_tests.py --profile packaging + $(INTEGRATION_TEST_RUNNER) --profile packaging .PHONY: integration-tests-prospective-contract integration-tests-prospective-contract: - uv run python .github/scripts/run_integration_tests.py --profile prospective-contract + $(INTEGRATION_TEST_RUNNER) --profile prospective-contract + +.PHONY: integration-tests-prospective-platform +integration-tests-prospective-platform: + $(INTEGRATION_TEST_RUNNER) --profile prospective-platform .PHONY: integration-tests-security integration-tests-security: - uv run python .github/scripts/run_integration_tests.py --profile security + $(INTEGRATION_TEST_RUNNER) --profile security .PHONY: integration-tests-mcp-v1 integration-tests-mcp-v1: - uv run python .github/scripts/run_integration_tests.py --profile mcp-v1 + $(INTEGRATION_TEST_RUNNER) --profile mcp-v1 .PHONY: integration-tests-core integration-tests-core: - uv run python .github/scripts/run_integration_tests.py --profile core + $(INTEGRATION_TEST_RUNNER) --profile core .PHONY: integration-tests-providers integration-tests-providers: - uv run python .github/scripts/run_integration_tests.py --profile providers $(filter --all,$(MAKECMDGOALS)) + $(INTEGRATION_TEST_RUNNER) --profile providers $(filter --all,$(MAKECMDGOALS)) .PHONY: integration-tests-providers-external integration-tests-providers-external: - OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS=1 uv run python .github/scripts/run_integration_tests.py --profile providers $(filter --all,$(MAKECMDGOALS)) + OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS=1 $(INTEGRATION_TEST_RUNNER) --profile providers $(filter --all,$(MAKECMDGOALS)) .PHONY: integration-tests-providers-all integration-tests-providers-all: - uv run python .github/scripts/run_integration_tests.py --profile providers --all + $(INTEGRATION_TEST_RUNNER) --profile providers --all .PHONY: --all --all: @@ -146,19 +180,19 @@ integration-tests-providers-all: .PHONY: integration-tests-realtime integration-tests-realtime: - uv run python .github/scripts/run_integration_tests.py --profile realtime + $(INTEGRATION_TEST_RUNNER) --profile realtime .PHONY: integration-tests-voice integration-tests-voice: - uv run python .github/scripts/run_integration_tests.py --profile voice + $(INTEGRATION_TEST_RUNNER) --profile voice .PHONY: integration-tests-hosted integration-tests-hosted: - uv run python .github/scripts/run_integration_tests.py --profile hosted + $(INTEGRATION_TEST_RUNNER) --profile hosted .PHONY: integration-tests-extras integration-tests-extras: - uv run python .github/scripts/run_integration_tests.py --profile extras + $(INTEGRATION_TEST_RUNNER) --profile extras .PHONY: coverage coverage: diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..996995fa4d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,18 @@ +# Running the example suite + +Example execution is owned by the repository runner and Make targets. Run the complete auto-mode workflow in the foreground with: + + make examples-run + +Pass runner arguments through `EXAMPLES_ARGS`, for example: + + make examples-run EXAMPLES_ARGS="--filter basic" + make examples-run EXAMPLES_ARGS="--include-server --include-audio" + +Use `make examples-run-background` for a background run. The remaining lifecycle targets are `make examples-status`, `make examples-stop`, `make examples-logs`, and `make examples-tail`. Set `EXAMPLES_LOG` to select a specific file for `examples-tail`. + +Every normal run writes a main log and per-example logs under `.tmp/examples-start-logs/`. Use `EXAMPLES_ARGS="--filter "` to run a focused subset again when needed. + +The defaults preserve auto input and approvals, include interactive examples, and exclude server, audio, and external examples unless selected. `EXAMPLES_UV_EXTRAS` controls the optional dependency extras installed by `uv`; set it to an empty value to disable extras. `EXAMPLES_INCLUDE_INTERACTIVE`, `EXAMPLES_INCLUDE_SERVER`, `EXAMPLES_INCLUDE_AUDIO`, and `EXAMPLES_INCLUDE_EXTERNAL` provide environment-based inclusion overrides. + +The repository skill `examples-run-analysis` is analysis-only. After a manual run completes, use it to inspect the main log, every relevant per-example log, and example source. The skill never starts, retries, stops, or controls the example process. diff --git a/examples/run_examples.py b/examples/run_examples.py index 8a139af41e..4cc36e0588 100644 --- a/examples/run_examples.py +++ b/examples/run_examples.py @@ -6,7 +6,6 @@ * Auto mode (``EXAMPLES_INTERACTIVE_MODE=auto``) enables deterministic inputs, auto-approvals, and turns on interactive examples by default. * Writes per-example logs to ``.tmp/examples-start-logs`` and a main summary log. -* Generates a rerun list of failures at ``.tmp/examples-rerun.txt``. """ from __future__ import annotations @@ -36,7 +35,6 @@ LOG_DIR_DEFAULT = ROOT_DIR / ".tmp" / "examples-start-logs" ARTIFACTS_DIR_DEFAULT = ROOT_DIR / ".tmp" / "examples-artifacts" -RERUN_FILE_DEFAULT = ROOT_DIR / ".tmp" / "examples-rerun.txt" DEFAULT_MAIN_LOG = LOG_DIR_DEFAULT / f"main_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}.log" REDIS_SESSION_EXAMPLE = "examples/memory/redis_session_example.py" DAPR_SESSION_EXAMPLE = "examples/memory/dapr_session_example.py" @@ -422,23 +420,6 @@ def parse_args() -> argparse.Namespace: default=str(ARTIFACTS_DIR_DEFAULT), help="Directory for example-generated artifacts.", ) - parser.add_argument( - "--rerun-file", - help="Only run examples listed in this file (one relative path per line).", - ) - parser.add_argument( - "--write-rerun", - action="store_true", - help="Write failures to .tmp/examples-rerun.txt after the run.", - ) - parser.add_argument( - "--collect", - help="Parse a previous main log to emit a rerun list instead of running examples.", - ) - parser.add_argument( - "--output", - help="Output path for --collect rerun list (defaults to stdout).", - ) parser.add_argument( "--print-auto-skip", action="store_true", @@ -592,24 +573,6 @@ def artifact_dir_for_example(relpath: str, artifacts_dir: Path) -> Path: return artifacts_dir / stem.replace("/", "__") -def parse_rerun_from_log(log_path: Path) -> list[str]: - if not log_path.exists(): - raise FileNotFoundError(log_path) - rerun: list[str] = [] - with log_path.open("r", encoding="utf-8") as handle: - for line in handle: - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - parts = stripped.split() - if len(parts) < 2: - continue - status, relpath = parts[0].upper(), parts[1] - if status in {"FAILED", "ERROR", "UNKNOWN"}: - rerun.append(normalize_relpath(relpath)) - return rerun - - def run_examples(examples: Sequence[ExampleScript], args: argparse.Namespace) -> int: overrides: set[str] = set() if args.include_interactive or env_flag("EXAMPLES_INCLUDE_INTERACTIVE"): @@ -633,8 +596,6 @@ def run_examples(examples: Sequence[ExampleScript], args: argparse.Namespace) -> ensure_dirs(logs_dir, is_file=False) ensure_dirs(artifacts_dir, is_file=False) ensure_dirs(main_log_path, is_file=True) - rerun_entries: list[str] = [] - if not examples: print("No example entry points found that match the filters.") return 0 @@ -841,18 +802,8 @@ def run_single(example: ExampleScript) -> ExampleResult: executed += 1 elif result.status == "failed": failed += 1 - rerun_entries.append(ex.relpath) safe_write_main(f"# summary executed={executed} skipped={skipped} failed={failed}") - if args.write_rerun: - ensure_dirs(RERUN_FILE_DEFAULT, is_file=True) - if rerun_entries: - contents = "\n".join(rerun_entries) + "\n" - else: - contents = "" - RERUN_FILE_DEFAULT.write_text(contents, encoding="utf-8") - print(f"Wrote rerun list to {RERUN_FILE_DEFAULT}") - print(f"Main log: {main_log_path}") print(f"Done. Ran {executed} example(s), skipped {skipped}, failed {failed}.") @@ -882,31 +833,7 @@ def main() -> int: print(entry) return 0 - if args.collect: - paths = parse_rerun_from_log(Path(args.collect)) - if args.output: - out = Path(args.output) - ensure_dirs(out, is_file=True) - out.write_text("\n".join(paths) + "\n", encoding="utf-8") - print(f"Wrote {len(paths)} entries to {out}") - else: - for p in paths: - print(p) - return 0 - examples = discover_examples(args.filter) - if args.rerun_file: - rerun_set = { - line.strip() - for line in Path(args.rerun_file).read_text(encoding="utf-8").splitlines() - if line.strip() - } - examples = [ex for ex in examples if ex.relpath in rerun_set] - if not examples: - print("Rerun list is empty; nothing to do.") - return 0 - print(f"Rerun mode: {len(examples)} example(s) from {args.rerun_file}") - return run_examples(examples, args) diff --git a/integration_tests/README.md b/integration_tests/README.md index fad18b4a73..7e50f1b981 100644 --- a/integration_tests/README.md +++ b/integration_tests/README.md @@ -1,21 +1,24 @@ -# Packaged live integration tests +# Packaged integration tests These tests exercise the exact wheel produced by `uv build` after installing it into clean virtual environments. The `integration_tests/` directory, repository automation metadata, and local dependency/type-checking caches are excluded from published distributions. Run the complete release-oriented matrix with: export UV_DEFAULT_INDEX=https://pypi.org/simple + test "${OPENAI_API_KEY_SOURCE:-}" = service-account make integration-tests -`make integration-tests-release` runs the release-safe live matrix and the local Docker security contract in strict mode, so an unavailable daemon, image, credential, or required capability fails the release gate instead of becoming a skip. The focused `make integration-tests-security` target runs the same wheel and sdist security contract in strict mode without the live provider matrix; the security profile remains separate from the credential-free PR packaging job. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-security`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The packaging profile validates the released public API manifest and historical `RunState` corpus from base wheel and sdist environments, then validates the public API again from wheel and sdist environments with the Cloudflare extra installed so dependency-conditional exports are required. The security profile installs the Docker extra for both distribution formats, checks packaged credential redaction, and runs model-controlled environment, filesystem, and process inspection inside a local Docker sandbox through the public `Runner` lifecycle. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. +`make integration-tests-release` runs the release-safe live matrix and the local Docker security contract in strict mode, so an unavailable daemon, image, credential, or required capability fails the release gate instead of becoming a skip. The focused `make integration-tests-security` target runs the same wheel and sdist security contract in strict mode without the live provider matrix; the security profile remains separate from the credential-free PR packaging job. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-prospective-contract`, `make integration-tests-prospective-platform`, `make integration-tests-security`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The packaging profile validates the released public API manifest and historical `RunState` corpus from base wheel and sdist environments, then validates the public API again from wheel and sdist environments with the Cloudflare extra installed so dependency-conditional exports are required. The security profile installs the Docker extra for both distribution formats, checks packaged credential redaction, and runs model-controlled environment, filesystem, and process inspection inside a local Docker sandbox through the public `Runner` lifecycle. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. Release PR preparation updates the rolling API manifest locally rather than in a credentialed GitHub workflow. After the release branch version bump, run `make update-released-api-contract VERSION=`, review and commit the manifest diff, then run `make check-released-api-contract VERSION=` after subsequent rebases. Promotion fails before writing if the candidate breaks the committed released contract. The prospective release-contract job performs this source validation in one dedicated Python process so provider behavior tests cannot change its import graph. Inspectable top-level classes and functions are promoted automatically; documented properties, intended submodule paths, and canonical aliases remain explicit review decisions recorded in the manifest. The packaged profiles remain the artifact-level verification that the committed contract holds for core and policy-declared optional surfaces across wheel, sdist, and supported platforms. -Invoke the repository-local `$integration-tests` skill to run the release profile with configured OpenRouter-backed provider checks. OpenRouter provides a single configured gateway for the standard multi-provider matrix; provider-specific direct connections are optional extensions selected explicitly. When a release review also requires runnable examples, run `$examples-auto-run` first and then `$integration-tests`. +Integration execution is available only through these Make targets and `.github/scripts/run_integration_tests.py`; there is no integration execution skill. When a release review also requires runnable examples, run the relevant `make examples-*` target manually, analyze its completed artifacts with `examples-run-analysis`, and then run the selected `make integration-tests-*` target. -Set `OPENAI_API_KEY` for live OpenAI calls. Override `OPENAI_AGENTS_INTEGRATION_MODEL`, `OPENAI_AGENTS_INTEGRATION_REALTIME_MODEL`, `OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS`, and `OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS` when testing different models or configured providers. Provider model lists contain comma-separated adapter model names and require the credentials matching each selected provider. Set `OPENAI_AGENTS_INTEGRATION_MCP_SERVER_URL` to use another trusted DeepWiki-compatible hosted MCP server that exposes the `ask_question` tool and can answer questions about the `openai/openai-agents-python` repository. +Every integration profile has one credential class. Local-only profiles are `packaging`, `prospective-contract`, `prospective-platform`, `security`, `mcp-v1`, and `extras`; the runner removes an inherited `OPENAI_API_KEY` before building distributions or starting child processes for these profiles. Live profiles are `core`, `providers`, `realtime`, `voice`, `hosted`, `full`, `release`, `nightly`, and `manual`; the runner refuses them before any build or child process unless `OPENAI_API_KEY_SOURCE=service-account`. Load the approved service-account environment before invoking a live Make target. -Run `make integration-tests-providers-external` with `OPENROUTER_API_KEY` to exercise current OpenAI, Anthropic, and Google models through one provider gateway. To extend the matrix with separately configured direct-provider credentials, use `make integration-tests-providers-external -- --all`, `make integration-tests-providers-all`, or `uv run python .github/scripts/run_integration_tests.py --profile providers --all`. Set `ANTHROPIC_API_KEY` and `GEMINI_API_KEY` or `GOOGLE_API_KEY` for the direct providers you want to include. Override `OPENAI_AGENTS_INTEGRATION_ANTHROPIC_MODEL`, `OPENAI_AGENTS_INTEGRATION_GEMINI_MODEL`, or the comma-separated `OPENAI_AGENTS_INTEGRATION_OPENROUTER_MODELS` to select provider models. +Set `OPENAI_API_KEY` to the approved service-account key and set `OPENAI_API_KEY_SOURCE=service-account` for live OpenAI calls. Override `OPENAI_AGENTS_INTEGRATION_MODEL`, `OPENAI_AGENTS_INTEGRATION_REALTIME_MODEL`, `OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS`, and `OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS` when testing different models or configured providers. Provider model lists contain comma-separated adapter model names and require the credentials matching each selected provider. Set `OPENAI_AGENTS_INTEGRATION_MCP_SERVER_URL` to use another trusted DeepWiki-compatible hosted MCP server that exposes the `ask_question` tool and can answer questions about the `openai/openai-agents-python` repository. + +Run `make integration-tests-providers-external` with `OPENROUTER_API_KEY` to exercise current OpenAI, Anthropic, and Google models through one provider gateway. To extend the matrix with separately configured direct-provider credentials, use `make integration-tests-providers-external -- --all` or `make integration-tests-providers-all`. Set `ANTHROPIC_API_KEY` and `GEMINI_API_KEY` or `GOOGLE_API_KEY` for the direct providers you want to include. Override `OPENAI_AGENTS_INTEGRATION_ANTHROPIC_MODEL`, `OPENAI_AGENTS_INTEGRATION_GEMINI_MODEL`, or the comma-separated `OPENAI_AGENTS_INTEGRATION_OPENROUTER_MODELS` to select provider models. The default general model is `gpt-5.6`, while LiteLLM function-tool cases use the Chat Completions-native `openai/gpt-4.1-mini`. This avoids LiteLLM's separate Responses API bridge and keeps the adapter regression focused on its actual Chat Completions contract. diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 20595e4a38..1e28581f88 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -23,6 +23,154 @@ def _run_suite() -> Callable[..., None]: return cast(Callable[..., None], runpy.run_path(str(RUNNER))["run_suite"]) +def test_every_integration_profile_has_exactly_one_credential_class() -> None: + namespace = runpy.run_path(str(RUNNER)) + profile_classes = namespace["PROFILE_CREDENTIAL_CLASSES"] + + assert tuple(profile_classes) == namespace["PROFILES"] + assert set(profile_classes.values()) == { + namespace["LIVE_CREDENTIAL_CLASS"], + namespace["LOCAL_ONLY_CREDENTIAL_CLASS"], + } + assert { + profile + for profile, credential_class in profile_classes.items() + if credential_class == namespace["LOCAL_ONLY_CREDENTIAL_CLASS"] + } == { + "packaging", + "prospective-contract", + "prospective-platform", + "security", + "mcp-v1", + "extras", + } + assert { + profile + for profile, credential_class in profile_classes.items() + if credential_class == namespace["LIVE_CREDENTIAL_CLASS"] + } == { + "core", + "providers", + "realtime", + "voice", + "hosted", + "full", + "release", + "nightly", + "manual", + } + + +@pytest.mark.parametrize( + "profile", + ["core", "providers", "realtime", "voice", "hosted", "full", "release", "nightly", "manual"], +) +def test_live_profiles_refuse_untrusted_credentials_before_side_effects( + profile: str, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + bootstrap_in_uv = cast(Callable[..., None], namespace["bootstrap_in_uv"]) + child_processes: list[str] = [] + + with pytest.raises( + RuntimeError, + match="requires OPENAI_API_KEY_SOURCE=service-account before any build or subprocess", + ): + bootstrap_in_uv( + ["--profile", profile], + {"OPENAI_API_KEY": "inherited-employee-key"}, + lambda *args: child_processes.append("uv"), + ) + + assert child_processes == [] + + +@pytest.mark.parametrize( + "profile", + ["packaging", "prospective-contract", "prospective-platform", "security", "mcp-v1", "extras"], +) +def test_local_only_profiles_remove_key_before_uv_child_process(profile: str) -> None: + namespace = runpy.run_path(str(RUNNER)) + bootstrap_in_uv = cast(Callable[..., None], namespace["bootstrap_in_uv"]) + environment = { + "OPENAI_API_KEY": "inherited-employee-key", + "OPENAI_API_KEY_SOURCE": "employee", + } + captured: list[tuple[str, list[str], dict[str, str]]] = [] + + def capture_exec(file: str, command: list[str], child_env: dict[str, str]) -> None: + captured.append((file, command, child_env)) + + with pytest.raises(RuntimeError, match="bootstrap returned unexpectedly"): + bootstrap_in_uv(["--profile", profile], environment, capture_exec) + + assert len(captured) == 1 + assert captured[0][0] == "uv" + assert captured[0][1][0:3] == ["uv", "run", "python"] + assert "OPENAI_API_KEY" not in captured[0][2] + assert captured[0][2][namespace["BOOTSTRAPPED_ENV"]] == "1" + assert "OPENAI_API_KEY" not in environment + + +def test_local_only_profile_removes_key_before_cleanup_build_and_children( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + main = cast(Callable[[], None], namespace["main"]) + observed_steps: list[str] = [] + + def assert_sanitized(step: str) -> None: + assert "OPENAI_API_KEY" not in os.environ + observed_steps.append(step) + + def fake_build_distributions() -> tuple[Path, Path]: + assert_sanitized("build") + return tmp_path / "candidate.whl", tmp_path / "candidate.tar.gz" + + def fake_create_environment( + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), + ) -> Path: + _ = (name, distribution, extras, optional_extra, additional_requirements) + assert_sanitized("create-environment") + return tmp_path / "python" + + def fake_run_suite(*args: object, **kwargs: Any) -> None: + _ = (args, kwargs) + assert_sanitized("run-suite") + + monkeypatch.setenv("OPENAI_API_KEY", "inherited-employee-key") + monkeypatch.setenv("OPENAI_API_KEY_SOURCE", "employee") + monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "packaging"]) + monkeypatch.setitem(main.__globals__, "build_distributions", fake_build_distributions) + monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) + monkeypatch.setitem(main.__globals__, "run_suite", fake_run_suite) + monkeypatch.setattr( + main.__globals__["shutil"], + "rmtree", + lambda *args, **kwargs: assert_sanitized("cleanup"), + ) + + main() + + assert observed_steps[0:2] == ["cleanup", "build"] + assert "create-environment" in observed_steps + assert "run-suite" in observed_steps + + +def test_unknown_profile_fails_closed_during_credential_classification() -> None: + namespace = runpy.run_path(str(RUNNER)) + prepare_profile_environment = cast(Callable[..., str], namespace["prepare_profile_environment"]) + + with pytest.raises(RuntimeError, match="has no credential class"): + prepare_profile_environment("unclassified", {"OPENAI_API_KEY": "inherited"}) + + def test_junit_sanitizer_removes_failure_details_and_captured_output(tmp_path: Path) -> None: sentinel = "JUNIT_SECRET_SENTINEL_42" report = tmp_path / "results.xml" @@ -345,6 +493,8 @@ def test_code_change_detection_includes_packaged_contract_inputs() -> None: assert "integration_tests/" in detector assert "detect-changes\\.sh" in detector assert "run_integration_tests\\.py" in detector + assert "run_examples\\.sh" in detector + assert "examples-run-analysis" in detector assert "update_released_api_contract\\.py" in detector assert "\\.github/workflows/tests\\.yml" in detector @@ -440,6 +590,7 @@ def fake_run_suite(*args: object, **kwargs: Any) -> None: suites.append(kwargs) monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "0") + monkeypatch.setenv("OPENAI_API_KEY_SOURCE", "service-account") monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "release"]) monkeypatch.setitem(main.__globals__, "build_distributions", fake_build_distributions) monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) diff --git a/tests/test_repository_workflow_interfaces.py b/tests/test_repository_workflow_interfaces.py new file mode 100644 index 0000000000..3df2f936a0 --- /dev/null +++ b/tests/test_repository_workflow_interfaces.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import re +import runpy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MAKEFILE = ROOT / "Makefile" +EXAMPLE_RUNNER = ROOT / ".github" / "scripts" / "run_examples.sh" +EXAMPLE_SUITE = ROOT / "examples" / "run_examples.py" +SKILLS = ROOT / ".agents" / "skills" + + +def _make_recipes() -> dict[str, str]: + recipes: dict[str, str] = {} + current_target: str | None = None + for line in MAKEFILE.read_text(encoding="utf-8").splitlines(): + target_match = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_-]*):(?:\s.*)?", line) + if target_match: + current_target = target_match.group(1) + recipes[current_target] = "" + elif current_target is not None and line.startswith("\t"): + recipes[current_target] += line.removeprefix("\t") + "\n" + elif line and not line.startswith((" ", "\t")): + current_target = None + return recipes + + +def test_examples_run_analysis_skill_has_no_execution_path() -> None: + analysis_skill = SKILLS / "examples-run-analysis" + assert not (SKILLS / "examples-auto-run").exists() + assert not (SKILLS / "integration-tests").exists() + assert sorted( + path.relative_to(analysis_skill).as_posix() + for path in analysis_skill.rglob("*") + if path.is_file() + ) == ["SKILL.md", "agents/openai.yaml"] + + instructions = (analysis_skill / "SKILL.md").read_text(encoding="utf-8") + prompt = (analysis_skill / "agents" / "openai.yaml").read_text(encoding="utf-8") + assert "This skill is read-only and analysis-only." in instructions + assert ( + "Never invoke an examples Make target or `.github/scripts/run_examples.sh`." in instructions + ) + assert "Inspect the process table and `.tmp/examples-auto-run.pid`" in instructions + assert "including foreground and background runs" in instructions + assert "an absent or stale pid file does not prove that no run is active" in instructions + assert ".tmp/examples-run.pid" not in instructions + assert "Do not execute any of these commands as part of this skill." in instructions + assert "without executing or controlling any process" in prompt + + +def test_makefile_exposes_every_preserved_example_operation() -> None: + recipes = _make_recipes() + expected_commands = { + "examples-run": "$(EXAMPLES_RUNNER) start $(EXAMPLES_ARGS)", + "examples-run-background": "$(EXAMPLES_RUNNER) start --background $(EXAMPLES_ARGS)", + "examples-status": "$(EXAMPLES_RUNNER) status", + "examples-stop": "$(EXAMPLES_RUNNER) stop", + "examples-logs": "$(EXAMPLES_RUNNER) logs", + "examples-tail": "$(EXAMPLES_RUNNER) tail $(EXAMPLES_LOG)", + } + + assert EXAMPLE_RUNNER.is_file() + assert "EXAMPLES_RUNNER := bash .github/scripts/run_examples.sh" in MAKEFILE.read_text( + encoding="utf-8" + ) + for target, command in expected_commands.items(): + assert recipes[target].strip() == command + assert "examples-rerun" not in recipes + assert "examples-collect-rerun" not in recipes + + +def test_repository_example_script_preserves_runner_contract() -> None: + runner = EXAMPLE_RUNNER.read_text(encoding="utf-8") + + assert 'PID_FILE="$ROOT/.tmp/examples-auto-run.pid"' in runner + assert 'LOG_DIR="$ROOT/.tmp/examples-start-logs"' in runner + assert ( + 'DEFAULT_UV_EXTRAS="litellm any-llm sqlalchemy redis blaxel modal runloop temporal"' + in runner + ) + for required_argument in ("--auto-mode", "--main-log", "--logs-dir"): + assert required_argument in runner + for optional_mode in ( + "EXAMPLES_INCLUDE_INTERACTIVE", + "EXAMPLES_INCLUDE_SERVER", + "EXAMPLES_INCLUDE_AUDIO", + "EXAMPLES_INCLUDE_EXTERNAL", + ): + assert optional_mode in runner + for operation in ("start", "status", "stop", "logs", "tail"): + assert re.search(rf"(?:^|\n) {operation}\)", runner) + assert 'rm -f "$PID_FILE"' in runner + + +def test_examples_rerun_mechanism_is_removed() -> None: + sources = [ + MAKEFILE.read_text(encoding="utf-8"), + EXAMPLE_RUNNER.read_text(encoding="utf-8"), + EXAMPLE_SUITE.read_text(encoding="utf-8"), + (ROOT / "examples" / "README.md").read_text(encoding="utf-8"), + (SKILLS / "examples-run-analysis" / "SKILL.md").read_text(encoding="utf-8"), + ] + + assert all("rerun" not in source.lower() for source in sources) + + +def test_all_make_integration_entry_points_use_classified_profiles() -> None: + namespace = runpy.run_path(str(ROOT / ".github" / "scripts" / "run_integration_tests.py")) + classified_profiles = set(namespace["PROFILE_CREDENTIAL_CLASSES"]) + + recipes = _make_recipes() + integration_recipes = { + target: recipe + for target, recipe in recipes.items() + if target == "integration-tests" or target.startswith("integration-tests-") + } + assert integration_recipes + for target, recipe in integration_recipes.items(): + profile = re.search(r"--profile ([a-z0-9-]+)", recipe) + assert profile is not None, target + assert profile.group(1) in classified_profiles + + +def test_prospective_contract_preparation_removes_api_key_before_uv() -> None: + recipe = _make_recipes()["prepare-prospective-released-api-contract"] + + assert recipe.startswith("@unset OPENAI_API_KEY; \\\n") + assert recipe.index("unset OPENAI_API_KEY") < recipe.index("uv run") diff --git a/tests/test_run_examples_script.py b/tests/test_run_examples_script.py index 51f73e2c46..e561e0a2f4 100644 --- a/tests/test_run_examples_script.py +++ b/tests/test_run_examples_script.py @@ -1,7 +1,10 @@ from __future__ import annotations +import sys from pathlib import Path +import pytest + import examples.run_examples as run_examples @@ -77,6 +80,24 @@ def test_artifact_dir_for_example_uses_tmp_safe_stem(tmp_path: Path) -> None: assert artifact_dir == tmp_path / "examples__sandbox__tutorials__vision_website_clone__main" +@pytest.mark.parametrize( + "removed_arguments", + [ + ["--rerun-file", ".tmp/failed.txt"], + ["--write-rerun"], + ["--collect", ".tmp/main.log"], + ["--output", ".tmp/failed.txt"], + ], +) +def test_removed_rerun_arguments_are_rejected( + monkeypatch: pytest.MonkeyPatch, removed_arguments: list[str] +) -> None: + monkeypatch.setattr(sys, "argv", ["run_examples.py", *removed_arguments]) + + with pytest.raises(SystemExit, match="2"): + run_examples.parse_args() + + def test_prepare_redis_for_example_uses_existing_local_redis(monkeypatch) -> None: env: dict[str, str] = {} monkeypatch.setattr(run_examples, "redis_ping_url", lambda url, timeout=0.5: True) From ebb746dc00b0dd6a90c30bc5ccb7e9c445e55493 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Aug 2026 20:46:40 +0900 Subject: [PATCH 360/473] fix: keep Codex verification for development sandboxed (#4508) --- .../skills/code-change-verification/SKILL.md | 28 +-- .../agents/openai.yaml | 2 +- .github/scripts/detect-changes.sh | 2 +- .github/workflows/tests.yml | 30 +++ pyproject.toml | 1 + tests/conftest.py | 23 +- tests/sandbox/_filesystem_test_session.py | 212 ++++++++++++++++++ tests/sandbox/integration_tests/_helpers.py | 4 +- .../test_runner_pause_resume.py | 8 +- tests/sandbox/test_memory.py | 62 ++--- tests/sandbox/test_run_cwd.py | 2 + tests/sandbox/test_runtime.py | 13 +- tests/sandbox/test_session_sinks.py | 55 ++++- tests/sandbox/test_unix_local.py | 5 + tests/test_code_change_verification_policy.py | 60 +++++ 15 files changed, 443 insertions(+), 64 deletions(-) create mode 100644 tests/sandbox/_filesystem_test_session.py create mode 100644 tests/test_code_change_verification_policy.py diff --git a/.agents/skills/code-change-verification/SKILL.md b/.agents/skills/code-change-verification/SKILL.md index 29fc41466a..f4326129b0 100644 --- a/.agents/skills/code-change-verification/SKILL.md +++ b/.agents/skills/code-change-verification/SKILL.md @@ -12,12 +12,13 @@ Ensure work is only marked complete after formatting, linting, type checking, an ## Quick start 1. Keep this skill at `./.agents/skills/code-change-verification` so it loads automatically for the repository. -2. macOS/Linux: `env UV_DEFAULT_INDEX=https://pypi.org/simple bash .agents/skills/code-change-verification/scripts/run.sh`. -3. Windows: `powershell -ExecutionPolicy Bypass -File .agents/skills/code-change-verification/scripts/run.ps1`. -4. The scripts run `make format` first, then run `make lint`, `make typecheck`, and `make tests` in parallel with fail-fast semantics. -5. While the parallel steps are still running, the scripts emit periodic heartbeat updates so you can tell that work is still in progress. -6. If any command fails, fix the issue, rerun the script, and report the failing output. -7. Confirm completion only when all commands succeed with no remaining issues. +2. Codex on macOS/Linux: `/usr/bin/env -u OPENAI_API_KEY OPENAI_AGENTS_TEST_IN_CODEX_SANDBOX=1 UV_DEFAULT_INDEX=https://pypi.org/simple bash .agents/skills/code-change-verification/scripts/run.sh`. +3. Other macOS/Linux environments: `env UV_DEFAULT_INDEX=https://pypi.org/simple bash .agents/skills/code-change-verification/scripts/run.sh`. +4. Windows: `powershell -ExecutionPolicy Bypass -File .agents/skills/code-change-verification/scripts/run.ps1`. +5. The scripts run `make format` first, then run `make lint`, `make typecheck`, and `make tests` in parallel with fail-fast semantics. +6. While the parallel steps are still running, the scripts emit periodic heartbeat updates so you can tell that work is still in progress. +7. If any command fails, fix the issue, rerun the script, and report the failing output. +8. Confirm completion only when all commands succeed with no remaining issues. ## Start condition and host capacity @@ -28,20 +29,11 @@ Ensure work is only marked complete after formatting, linting, type checking, an ## Codex execution policy -The full test suite exercises `UnixLocalSandboxSession`, which starts its own macOS sandbox. A -nested run inside the Codex filesystem sandbox fails with -`sandbox-exec: sandbox_apply: Operation not permitted` even when the implementation is correct. +Repository verification and all child processes must remain in the normal Codex workspace sandbox. Never request elevated sandbox permissions for the verification wrapper, and never retry the wrapper with broader host access after a failure. -When Codex invokes the macOS/Linux verification command: +On macOS, tests marked `requires_native_macos_sandbox` need to start their own `sandbox-exec` process. The Codex command sets `OPENAI_AGENTS_TEST_IN_CODEX_SANDBOX=1`, which skips only that marker before nested sandbox creation. All other tests remain enabled. Ordinary local and CI runs do not set this variable and therefore keep the marked tests enabled. -- Use the exact command from Quick start so it matches the persistent command allow rule. -- Set `sandbox_permissions=require_escalated` on the first invocation. The matching allow rule makes - this non-interactive; it does not require a new user approval. -- Do not first run the suite inside the Codex sandbox and retry after the expected UnixLocal - failures. - -This execution policy changes only where the verification process runs. The SDK sandbox tests must -remain enabled and unchanged. +The marked tests run separately on a disposable GitHub-hosted macOS runner. If that trusted runner is unavailable, report the missing native-macOS coverage; do not compensate by weakening the Codex sandbox boundary. ## Environment setup diff --git a/.agents/skills/code-change-verification/agents/openai.yaml b/.agents/skills/code-change-verification/agents/openai.yaml index 62d149ca5f..555019386c 100644 --- a/.agents/skills/code-change-verification/agents/openai.yaml +++ b/.agents/skills/code-change-verification/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Code Change Verification" short_description: "Run the required local verification stack" - default_prompt: "Use $code-change-verification to run the required local verification stack. On macOS/Linux, invoke its canonical env command outside the Codex sandbox on the first attempt so nested UnixLocal sandbox tests run without an approval retry. Report any failures." + default_prompt: "Use $code-change-verification to run the required local verification stack inside the normal Codex workspace sandbox. On macOS/Linux, use the documented Codex command so only tests that require a native macOS sandbox are skipped locally. Never request elevated sandbox permissions or retry with broader host access. Report any failures." diff --git a/.github/scripts/detect-changes.sh b/.github/scripts/detect-changes.sh index 540c77b28f..79275fcca1 100755 --- a/.github/scripts/detect-changes.sh +++ b/.github/scripts/detect-changes.sh @@ -46,7 +46,7 @@ changed_files=$(git diff --name-only "$base_sha" "$head_sha" || true) case "$mode" in code) - pattern='^(src/|tests/|integration_tests/|examples/|\.agents/skills/(examples-auto-run|examples-run-analysis|integration-tests)/|\.github/scripts/(detect-changes\.sh|run_examples\.sh|run_integration_tests\.py|update_released_api_contract\.py)$|\.github/workflows/tests\.yml$|pyproject.toml$|uv.lock$|Makefile$)' + pattern='^(src/|tests/|integration_tests/|examples/|\.agents/skills/(code-change-verification|examples-auto-run|examples-run-analysis|integration-tests)/|\.github/scripts/(detect-changes\.sh|run_examples\.sh|run_integration_tests\.py|update_released_api_contract\.py)$|\.github/workflows/tests\.yml$|pyproject.toml$|uv.lock$|Makefile$)' ;; docs) pattern='^(docs/|mkdocs.yml$)' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 961e6dfd39..e7ee288b8d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -158,6 +158,36 @@ jobs: if: steps.changes.outputs.run != 'true' run: echo "Skipping tests for non-code changes." + native-macos-sandbox: + runs-on: macos-latest + timeout-minutes: 15 + env: + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Detect code changes + id: changes + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: false + python-version: "3.14" + - name: Install dependencies + if: steps.changes.outputs.run == 'true' + run: make sync + - name: Run native macOS sandbox tests + if: steps.changes.outputs.run == 'true' + run: uv run pytest -m requires_native_macos_sandbox + - name: Skip native macOS sandbox tests + if: steps.changes.outputs.run != 'true' + run: echo "Skipping native macOS sandbox tests for non-code changes." + mcp-v1-compat: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/pyproject.toml b/pyproject.toml index 1d76edc8e2..1e475a3276 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -221,6 +221,7 @@ filterwarnings = [ ] markers = [ "allow_call_model_methods: mark test as allowing calls to real model implementations", + "requires_native_macos_sandbox: mark test as requiring its own macOS sandbox-exec process", "review_optional: mark a slow subsystem-specific test that an unrelated iterative review check may omit", "serial: mark test as requiring exclusive execution after all xdist workers exit", ] diff --git a/tests/conftest.py b/tests/conftest.py index de07690f7e..8e83cafa70 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import os import sys -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping import pytest @@ -24,6 +24,8 @@ "https_proxy", ) _PROXY_OPT_IN_ENVIRONMENT_VARIABLE = "OPENAI_AGENTS_TEST_USE_PROXY" +_CODEX_SANDBOX_ENVIRONMENT_VARIABLE = "OPENAI_AGENTS_TEST_IN_CODEX_SANDBOX" +_NATIVE_MACOS_SANDBOX_MARKER = "requires_native_macos_sandbox" def _remove_ambient_proxy_environment(environment: MutableMapping[str, str]) -> None: @@ -41,6 +43,25 @@ def _remove_ambient_proxy_environment(environment: MutableMapping[str, str]) -> _remove_ambient_proxy_environment(os.environ) + +def _running_in_nested_codex_macos_sandbox( + *, platform: str, environment: Mapping[str, str] +) -> bool: + return platform == "darwin" and environment.get(_CODEX_SANDBOX_ENVIRONMENT_VARIABLE) == "1" + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + if not _running_in_nested_codex_macos_sandbox(platform=sys.platform, environment=os.environ): + return + + skip_native_macos_sandbox = pytest.mark.skip( + reason="requires a native macOS sandbox and cannot run inside the Codex outer sandbox" + ) + for item in items: + if item.get_closest_marker(_NATIVE_MACOS_SANDBOX_MARKER) is not None: + item.add_marker(skip_native_macos_sandbox) + + collect_ignore: list[str] = [] if sys.platform == "win32": diff --git a/tests/sandbox/_filesystem_test_session.py b/tests/sandbox/_filesystem_test_session.py new file mode 100644 index 0000000000..83f1d4d400 --- /dev/null +++ b/tests/sandbox/_filesystem_test_session.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import io +import os +import shutil +import tempfile +import uuid +from pathlib import Path +from typing import cast + +from agents.sandbox.errors import ( + ExecNonZeroError, + WorkspaceArchiveReadError, + WorkspaceArchiveWriteError, + WorkspaceReadNotFoundError, +) +from agents.sandbox.files import EntryKind, FileEntry +from agents.sandbox.manifest import Manifest +from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxSessionState, +) +from agents.sandbox.session import SandboxSession +from agents.sandbox.session.base_sandbox_session import BaseSandboxSession +from agents.sandbox.session.sandbox_session_state import SandboxSessionState +from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase, SnapshotSpec +from agents.sandbox.types import ExecResult, Permissions, User + + +class FilesystemTestSandboxSession(BaseSandboxSession): + """Host-filesystem test double with no process-execution implementation.""" + + def __init__(self, state: UnixLocalSandboxSessionState) -> None: + self.state = state + self._running = False + + async def start(self) -> None: + Path(self.state.manifest.root).mkdir(parents=True, exist_ok=True) + self._running = True + self.state.workspace_root_ready = True + + async def stop(self) -> None: + self._running = False + + async def shutdown(self) -> None: + self._running = False + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + _ = timeout + command_parts = tuple(str(part) for part in command) + if len(command_parts) == 3 and command_parts[:2] in {("test", "-d"), ("test", "-f")}: + path = Path(command_parts[2]) + exists = path.is_dir() if command_parts[1] == "-d" else path.is_file() + return ExecResult(stdout=b"", stderr=b"", exit_code=0 if exists else 1) + raise AssertionError(f"Unexpected filesystem test command: {command_parts!r}") + + @staticmethod + def _reject_user(user: str | User | None) -> None: + if user is not None: + raise AssertionError( + "FilesystemTestSandboxSession does not support user-scoped filesystem operations" + ) + + async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase: + self._reject_user(user) + workspace_path = self.normalize_path(path) + try: + return workspace_path.open("rb") + except FileNotFoundError as error: + raise WorkspaceReadNotFoundError(path=path, cause=error) from error + except OSError as error: + raise WorkspaceArchiveReadError(path=path, cause=error) from error + + async def write( + self, + path: Path, + data: io.IOBase, + *, + user: str | User | None = None, + ) -> None: + self._reject_user(user) + workspace_path = self.normalize_path(path, for_write=True) + try: + workspace_path.parent.mkdir(parents=True, exist_ok=True) + with workspace_path.open("wb") as stream: + shutil.copyfileobj(data, stream) + except OSError as error: + raise WorkspaceArchiveWriteError(path=path, cause=error) from error + + async def ls( + self, + path: Path | str, + *, + user: str | User | None = None, + ) -> list[FileEntry]: + self._reject_user(user) + workspace_path = self.normalize_path(path) + try: + with os.scandir(workspace_path) as entries: + listed: list[FileEntry] = [] + for entry in entries: + stat_result = entry.stat(follow_symlinks=False) + if entry.is_symlink(): + kind = EntryKind.SYMLINK + elif entry.is_dir(follow_symlinks=False): + kind = EntryKind.DIRECTORY + elif entry.is_file(follow_symlinks=False): + kind = EntryKind.FILE + else: + kind = EntryKind.OTHER + listed.append( + FileEntry( + path=entry.path, + permissions=Permissions.from_mode(stat_result.st_mode), + owner=str(stat_result.st_uid), + group=str(stat_result.st_gid), + size=stat_result.st_size, + kind=kind, + ) + ) + return listed + except OSError as error: + raise ExecNonZeroError( + ExecResult(stdout=b"", stderr=str(error).encode(), exit_code=1), + command=("ls", "-la", "--", str(workspace_path)), + cause=error, + ) from error + + async def mkdir( + self, + path: Path | str, + *, + parents: bool = False, + user: str | User | None = None, + ) -> None: + self._reject_user(user) + self.normalize_path(path, for_write=True).mkdir(parents=parents, exist_ok=True) + + async def rm( + self, + path: Path | str, + *, + recursive: bool = False, + user: str | User | None = None, + ) -> None: + self._reject_user(user) + workspace_path = self.normalize_path(path, for_write=True) + if workspace_path.is_dir() and not workspace_path.is_symlink(): + if recursive: + shutil.rmtree(workspace_path) + else: + workspace_path.rmdir() + else: + workspace_path.unlink() + + async def running(self) -> bool: + return self._running + + async def persist_workspace(self) -> io.IOBase: + raise AssertionError("FilesystemTestSandboxSession does not support workspace persistence") + + async def hydrate_workspace(self, data: io.IOBase) -> None: + _ = data + raise AssertionError("FilesystemTestSandboxSession does not support workspace hydration") + + +class FilesystemTestSandboxClient(UnixLocalSandboxClient): + """Client test double that creates ``FilesystemTestSandboxSession`` instances.""" + + async def create( + self, + *, + snapshot: SnapshotSpec | SnapshotBase | None = None, + manifest: Manifest | None = None, + options: object | None = None, + ) -> SandboxSession: + _ = (snapshot, options) + resolved_manifest = manifest if manifest is not None else Manifest() + workspace_root_owned = resolved_manifest.root == Manifest().root + if workspace_root_owned: + workspace_root = tempfile.mkdtemp(prefix="filesystem-test-workspace-") + resolved_manifest = resolved_manifest.model_copy( + update={"root": workspace_root}, + deep=True, + ) + state = UnixLocalSandboxSessionState( + session_id=uuid.uuid4(), + manifest=resolved_manifest, + snapshot=NoopSnapshot(id=str(uuid.uuid4())), + workspace_root_owned=workspace_root_owned, + ) + return self._wrap_session( + FilesystemTestSandboxSession(state=state), + instrumentation=self._instrumentation, + ) + + async def delete(self, session: SandboxSession) -> SandboxSession: + inner = cast(FilesystemTestSandboxSession, session._inner) + if inner.state.workspace_root_owned: + shutil.rmtree(inner.state.manifest.root, ignore_errors=True) + return session + + async def resume(self, state: SandboxSessionState) -> SandboxSession: + unix_state = cast(UnixLocalSandboxSessionState, state) + return self._wrap_session( + FilesystemTestSandboxSession(state=unix_state), + instrumentation=self._instrumentation, + ) diff --git a/tests/sandbox/integration_tests/_helpers.py b/tests/sandbox/integration_tests/_helpers.py index 681001afda..19c2fac7de 100644 --- a/tests/sandbox/integration_tests/_helpers.py +++ b/tests/sandbox/integration_tests/_helpers.py @@ -145,10 +145,12 @@ def calls(self) -> list[str]: def install_mock_external_tools( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + *, + writable_root: Path, ) -> MockExternalTools: bin_dir = tmp_path / "mock-bin" bin_dir.mkdir() - log_path = tmp_path / "mock-tool-calls.tsv" + log_path = writable_root / "mock-tool-calls.tsv" log_path.write_text("", encoding="utf-8") for name in MOCK_TOOL_NAMES: diff --git a/tests/sandbox/integration_tests/test_runner_pause_resume.py b/tests/sandbox/integration_tests/test_runner_pause_resume.py index ce723958a8..239c960679 100644 --- a/tests/sandbox/integration_tests/test_runner_pause_resume.py +++ b/tests/sandbox/integration_tests/test_runner_pause_resume.py @@ -23,16 +23,22 @@ @pytest.mark.asyncio @pytest.mark.review_optional +@pytest.mark.requires_native_macos_sandbox async def test_runner_preserves_unix_local_lifecycle_state_across_pause_and_resume( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - install_mock_external_tools(monkeypatch, tmp_path) source_root = create_local_sources(tmp_path) + mock_tools = install_mock_external_tools( + monkeypatch, + tmp_path, + writable_root=source_root, + ) manifest = build_manifest_with_all_entry_types( workspace_root=Path("/workspace"), source_root=source_root, ) + assert mock_tools.log_path.parent == source_root events: list[SandboxSessionEvent] = [] client = UnixLocalSandboxClient( instrumentation=Instrumentation( diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index bfd7d3663e..746d7335c3 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -74,9 +74,9 @@ _updated_at_sort_key, ) from agents.sandbox.runtime import _stream_memory_input_override -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient from agents.sandbox.workspace_paths import SandboxWorkspaceScope from agents.testing import ScriptedModel +from tests.sandbox._filesystem_test_session import FilesystemTestSandboxClient from tests.test_responses import get_final_output_message, get_text_message from tests.utils.hitl import make_shell_call @@ -92,7 +92,7 @@ class _DeclaredProviderMemoryGenerateConfig(MemoryGenerateConfig): phase_two_model_settings: _DeclaredProviderModelSettings | None = None -class _DeleteTrackingUnixLocalSandboxClient(UnixLocalSandboxClient): +class _DeleteTrackingFilesystemTestSandboxClient(FilesystemTestSandboxClient): def __init__(self) -> None: super().__init__() self.deleted_roots: list[Path] = [] @@ -215,7 +215,7 @@ def _raw_memory_record( async def _cleanup_session( - client: UnixLocalSandboxClient, + client: FilesystemTestSandboxClient, session: Any, *, close: bool = True, @@ -600,7 +600,7 @@ def test_updated_at_sort_key_places_unknown_timestamps_last() -> None: @pytest.mark.asyncio async def test_phase_two_selection_tracks_added_retained_and_removed_rollouts() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) try: @@ -649,7 +649,7 @@ async def test_runner_memory_generation_sanitizes_and_truncates_phase_one_prompt monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(phase_one_module, "_PHASE_ONE_ROLLOUT_TOKEN_LIMIT", 1000) - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) memory = _memory_config(phase_one_model=phase_one_model) @@ -721,7 +721,7 @@ async def test_runner_memory_generation_sanitizes_and_truncates_phase_one_prompt @pytest.mark.asyncio async def test_sandbox_agent_without_memory_capability_skips_memory_generation() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) agent = SandboxAgent( name="worker", @@ -746,7 +746,7 @@ async def test_sandbox_agent_without_memory_capability_skips_memory_generation() @pytest.mark.asyncio async def test_memory_capability_returns_none_without_memory_summary() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) capability = Memory(generate=None) @@ -937,7 +937,7 @@ def test_memory_generate_config_rejects_too_many_raw_memories() -> None: async def test_memory_capability_injects_truncated_memory_summary( monkeypatch: pytest.MonkeyPatch, ) -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) capability = Memory(generate=None) @@ -966,7 +966,7 @@ async def test_memory_capability_injects_truncated_memory_summary( @pytest.mark.asyncio async def test_memory_capability_live_update_instructions() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) capability = Memory(generate=None) @@ -992,7 +992,7 @@ async def test_memory_capability_live_update_instructions() -> None: @pytest.mark.asyncio async def test_memory_capability_renders_session_owned_paths_as_absolute_with_run_cwd() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) capability = Memory(generate=None) @@ -1025,7 +1025,7 @@ async def test_memory_capability_renders_session_owned_paths_as_absolute_with_ru async def test_memory_capability_preserves_layout_spelling_without_run_cwd( memories_dir: str, ) -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) capability = Memory( layout=MemoryLayoutConfig(memories_dir=memories_dir), @@ -1053,7 +1053,7 @@ async def test_memory_capability_preserves_layout_spelling_without_run_cwd( @pytest.mark.asyncio async def test_memory_capability_uses_typed_layout_path_with_run_cwd() -> None: memories_dir = r"team\memory" - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) capability = Memory( layout=MemoryLayoutConfig(memories_dir=memories_dir), @@ -1085,7 +1085,7 @@ async def test_memory_capability_uses_typed_layout_path_with_run_cwd() -> None: @pytest.mark.asyncio async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) phase_two_model = ScriptedModel( @@ -1161,7 +1161,7 @@ async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None: @pytest.mark.asyncio async def test_sandbox_memory_uses_custom_layout() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_two_model = ScriptedModel( steps=[ @@ -1211,7 +1211,7 @@ async def test_sandbox_memory_uses_custom_layout() -> None: @pytest.mark.asyncio async def test_sandbox_memory_supports_multiple_generating_layouts_in_one_session() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_two_model_a = ScriptedModel( steps=[ @@ -1282,7 +1282,7 @@ async def test_sandbox_memory_supports_multiple_generating_layouts_in_one_sessio @pytest.mark.asyncio async def test_sandbox_memory_rejects_different_generate_configs_for_same_layout() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) memory = _memory_config() different_memory = _memory_config( @@ -1300,7 +1300,7 @@ async def test_sandbox_memory_rejects_different_generate_configs_for_same_layout @pytest.mark.asyncio async def test_sandbox_memory_rollout_payload_uses_validated_rollout_id() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) memory = _memory_config() @@ -1327,7 +1327,7 @@ async def test_sandbox_memory_rollout_payload_uses_validated_rollout_id() -> Non @pytest.mark.asyncio async def test_sandbox_memory_rejects_different_sessions_dirs_for_same_memories_dir() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) first_memory = _memory_config( layout=MemoryLayoutConfig(memories_dir="shared_memory", sessions_dir="sessions_a") @@ -1347,7 +1347,7 @@ async def test_sandbox_memory_rejects_different_sessions_dirs_for_same_memories_ @pytest.mark.asyncio async def test_sandbox_memory_rejects_shared_sessions_dir_for_different_memories_dirs() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) first_memory = _memory_config( layout=MemoryLayoutConfig(memories_dir="memory_a", sessions_dir="shared_sessions") @@ -1367,7 +1367,7 @@ async def test_sandbox_memory_rejects_shared_sessions_dir_for_different_memories @pytest.mark.asyncio async def test_sandbox_memory_groups_segments_by_sdk_session_until_close() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_one_model = ScriptedModel(steps=[[_phase_one_message(raw_memory="joined raw\n")]]) phase_two_model = ScriptedModel( @@ -1450,7 +1450,7 @@ async def test_sandbox_memory_groups_segments_by_sdk_session_until_close() -> No @pytest.mark.asyncio async def test_sandbox_memory_fallback_does_not_mutate_run_config() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) agent_model = ScriptedModel() agent_model.extend( @@ -1490,7 +1490,7 @@ async def test_sandbox_memory_fallback_does_not_mutate_run_config() -> None: @pytest.mark.asyncio async def test_sandbox_memory_uses_conversation_id_when_sdk_session_is_absent() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) agent = SandboxAgent( name="worker", @@ -1518,7 +1518,7 @@ async def test_sandbox_memory_uses_conversation_id_when_sdk_session_is_absent() @pytest.mark.asyncio async def test_sandbox_memory_uses_group_id_when_sdk_session_is_absent() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) agent_model = ScriptedModel() agent_model.extend( @@ -1555,7 +1555,7 @@ async def test_sandbox_memory_uses_group_id_when_sdk_session_is_absent() -> None @pytest.mark.asyncio async def test_sandbox_memory_uses_per_run_conversation_when_no_conversation_id() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) agent_model = ScriptedModel() agent_model.extend( @@ -1588,7 +1588,7 @@ async def test_sandbox_memory_uses_per_run_conversation_when_no_conversation_id( @pytest.mark.asyncio async def test_sandbox_memory_caps_phase_two_selection_and_surfaces_removed_rollouts() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_one_model = ScriptedModel() phase_one_model.extend( @@ -1670,7 +1670,7 @@ async def test_sandbox_memory_caps_phase_two_selection_and_surfaces_removed_roll @pytest.mark.asyncio async def test_sandbox_memory_runs_phase_one_and_phase_two_on_session_close() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) phase_two_model = ScriptedModel( @@ -1712,7 +1712,7 @@ async def test_sandbox_memory_runs_phase_one_and_phase_two_on_session_close() -> @pytest.mark.asyncio async def test_sandbox_memory_unregisters_manager_on_session_close() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) memory = _memory_config() @@ -1744,7 +1744,7 @@ async def test_sandbox_memory_flush_propagates_worker_base_exception_without_han monkeypatch: pytest.MonkeyPatch, worker_error: BaseException, ) -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) memory = _memory_config() manager = get_or_create_memory_generation_manager(session=session, memory=memory) @@ -1781,7 +1781,7 @@ async def fail_processing(_rollout_file_name: str) -> None: async def test_sandbox_memory_flush_parent_cancellation_stops_worker( monkeypatch: pytest.MonkeyPatch, ) -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) memory = _memory_config() manager = get_or_create_memory_generation_manager(session=session, memory=memory) @@ -1862,7 +1862,7 @@ async def _raise_write_rollout(*args: Any, **kwargs: Any) -> Path: monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) caplog.set_level(logging.WARNING) - client = _DeleteTrackingUnixLocalSandboxClient() + client = _DeleteTrackingFilesystemTestSandboxClient() agent = SandboxAgent( name="worker", model=ScriptedModel(steps=[[get_final_output_message("done")]]), @@ -1907,7 +1907,7 @@ async def _raise_write_rollout(*args: Any, **kwargs: Any) -> Path: @pytest.mark.asyncio async def test_sandbox_memory_marks_interrupted_runs_in_phase_one_prompt() -> None: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() session = await client.create(manifest=Manifest()) phase_one_model = ScriptedModel(steps=[[_phase_one_message()]]) phase_two_model = ScriptedModel( diff --git a/tests/sandbox/test_run_cwd.py b/tests/sandbox/test_run_cwd.py index 234d7019f7..45c6bf9a4e 100644 --- a/tests/sandbox/test_run_cwd.py +++ b/tests/sandbox/test_run_cwd.py @@ -41,6 +41,8 @@ "task-b": ("image/svg+xml", _SVG_BYTES), } +pytestmark = pytest.mark.requires_native_macos_sandbox + async def _read_bytes(session: BaseSandboxSession, path: str) -> bytes: file_obj = await session.read(Path(path)) diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index ef37d6fb01..7b4d7f540b 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -2809,20 +2809,23 @@ async def test_unix_local_persist_workspace_excludes_mounted_directory_contents( @pytest.mark.asyncio -async def test_runner_allows_fresh_unix_local_sessions_without_options() -> None: +async def test_runner_allows_fresh_sessions_for_clients_with_default_options() -> None: agent = SandboxAgent( name="sandbox", model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", + default_manifest=Manifest(), ) + client = _ManifestSessionClient() result = await Runner.run( agent, "hello", - run_config=_unix_local_run_config(), + run_config=RunConfig(sandbox=SandboxRunConfig(client=client)), ) assert result.final_output == "done" + assert len(client.created_manifests) == 1 @pytest.mark.asyncio @@ -3139,6 +3142,7 @@ async def test_runner_rejects_unix_local_manifest_user_and_group_provisioning() @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_runner_persists_workspace_and_tool_choice_state_across_sandbox_resume() -> None: client = UnixLocalSandboxClient() file_capability = _SessionFileCapability() @@ -3239,6 +3243,7 @@ def approval_tool() -> str: @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_runner_restores_all_sandbox_agents_from_run_state_across_handoffs() -> None: client = UnixLocalSandboxClient() file_capability = _SessionFileCapability() @@ -3345,6 +3350,7 @@ def approval_tool() -> str: @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_runner_serializes_unique_sandbox_resume_keys_for_duplicate_agent_names() -> None: client = UnixLocalSandboxClient() file_capability = _SessionFileCapability() @@ -4818,6 +4824,7 @@ def _make_agent(readme: bytes, capability_text: str) -> SandboxAgent[None]: @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_runner_restores_duplicate_name_sandbox_sessions_after_json_roundtrip() -> None: client = UnixLocalSandboxClient() file_capability = _SessionFileCapability() @@ -4917,6 +4924,7 @@ def approval_tool() -> str: @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_runner_restores_legacy_current_sandbox_payload_after_json_roundtrip() -> None: client = UnixLocalSandboxClient() @@ -4995,6 +5003,7 @@ def approval_tool() -> str: @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox @pytest.mark.skipif( sys.platform != "darwin" or shutil.which("sandbox-exec") is None, reason="sandbox-exec is only available on macOS when installed", diff --git a/tests/sandbox/test_session_sinks.py b/tests/sandbox/test_session_sinks.py index f4932ee93a..2fa520e002 100644 --- a/tests/sandbox/test_session_sinks.py +++ b/tests/sandbox/test_session_sinks.py @@ -36,6 +36,7 @@ from agents.sandbox.snapshot import LocalSnapshot from agents.sandbox.types import ExecResult from agents.tracing import custom_span, trace +from tests.sandbox._filesystem_test_session import FilesystemTestSandboxSession from tests.testing_processor import fetch_normalized_spans, fetch_ordered_spans @@ -60,7 +61,37 @@ def _build_unix_local_session( return UnixLocalSandboxSession.from_state(state) +def _build_filesystem_test_session( + tmp_path: Path, + *, + manifest: Manifest | None = None, +) -> FilesystemTestSandboxSession: + workspace = tmp_path / "workspace" + session_manifest = ( + manifest.model_copy(update={"root": str(workspace)}, deep=True) + if manifest is not None + else Manifest(root=str(workspace)) + ) + state = UnixLocalSandboxSessionState( + manifest=session_manifest, + snapshot=LocalSnapshot(id=str(uuid.uuid4()), base_path=tmp_path), + ) + return FilesystemTestSandboxSession(state=state) + + @pytest.mark.asyncio +async def test_filesystem_test_session_rejects_process_backed_operations(tmp_path: Path) -> None: + session = _build_filesystem_test_session(tmp_path) + + assert session.supports_pty() is False + with pytest.raises(NotImplementedError, match="PTY execution is not supported"): + await session.pty_exec_start("echo hi") + with pytest.raises(AssertionError, match="user-scoped filesystem operations"): + await session.write(Path("x.txt"), io.BytesIO(b"hello"), user="sandbox-user") + + +@pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_sandbox_session_exec_emits_stdout_when_enabled(tmp_path: Path) -> None: events: list[SandboxSessionEvent] = [] instrumentation = Instrumentation( @@ -91,7 +122,7 @@ async def test_sandbox_session_write_does_not_include_bytes_when_disabled( payload_policy=EventPayloadPolicy(include_write_len=False), ) - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) async with SandboxSession(inner, instrumentation=instrumentation) as session: await session.write(Path("x.txt"), io.BytesIO(b"hello")) @@ -198,6 +229,7 @@ def _callback(_event: SandboxSessionEvent, _session: BaseSandboxSession) -> None @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_workspace_jsonl_sink_writes_into_workspace_and_persists(tmp_path: Path) -> None: inner = _build_unix_local_session(tmp_path) instrumentation = Instrumentation( @@ -219,6 +251,7 @@ async def test_workspace_jsonl_sink_writes_into_workspace_and_persists(tmp_path: @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_workspace_jsonl_sink_supports_session_id_template(tmp_path: Path) -> None: inner = _build_unix_local_session(tmp_path) relpath = Path("logs/events-{session_id}.jsonl") @@ -245,7 +278,7 @@ async def test_workspace_jsonl_sink_supports_session_id_template(tmp_path: Path) @pytest.mark.asyncio async def test_workspace_jsonl_sink_preserves_preexisting_outbox_contents(tmp_path: Path) -> None: - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) relpath = Path(f"logs/events-{inner.state.session_id}.jsonl") old_line = b'{"old":true}\n' @@ -285,7 +318,7 @@ async def test_workspace_jsonl_sink_preserves_preexisting_outbox_contents(tmp_pa async def test_workspace_jsonl_sink_does_not_duplicate_lines_across_flushes( tmp_path: Path, ) -> None: - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) relpath = Path(f"logs/events-{inner.state.session_id}.jsonl") async with inner: @@ -310,7 +343,7 @@ async def test_workspace_jsonl_sink_does_not_duplicate_lines_across_flushes( @pytest.mark.asyncio async def test_workspace_jsonl_sink_clears_flushed_buffer(tmp_path: Path) -> None: - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) relpath = Path(f"logs/events-{inner.state.session_id}.jsonl") async with inner: @@ -335,6 +368,7 @@ async def test_workspace_jsonl_sink_clears_flushed_buffer(tmp_path: Path) -> Non @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_workspace_jsonl_sink_ephemeral_excludes_runtime_outbox_with_existing_parent( tmp_path: Path, ) -> None: @@ -373,6 +407,7 @@ async def test_workspace_jsonl_sink_ephemeral_excludes_runtime_outbox_with_exist @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_workspace_jsonl_sink_flushes_on_stop_when_flush_every_gt_one( tmp_path: Path, ) -> None: @@ -403,6 +438,7 @@ async def test_workspace_jsonl_sink_flushes_on_stop_when_flush_every_gt_one( @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_callback_sink_receives_bound_inner_session(tmp_path: Path) -> None: inner = _build_unix_local_session(tmp_path) seen: list[tuple[str, BaseSandboxSession]] = [] @@ -467,7 +503,7 @@ async def test_sandbox_session_error_events_and_traces_include_retryability( instrumentation = Instrumentation( sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")] ) - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) with trace("sandbox_retryability_test"): async with SandboxSession(inner, instrumentation=instrumentation) as session: @@ -506,7 +542,7 @@ async def test_expected_read_span_error_is_call_scoped_and_preserves_audit_failu instrumentation = Instrumentation( sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")] ) - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) expected_path = Path("expected-missing.txt") ordinary_path = Path("ordinary-missing.txt") @@ -561,7 +597,7 @@ def fail_read_finish(event: SandboxSessionEvent, _session: BaseSandboxSession) - instrumentation = Instrumentation( sinks=[CallbackSink(fail_read_finish, mode="sync", on_error="raise")] ) - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) with trace("sandbox_expected_read_sink_failure_test"): async with SandboxSession(inner, instrumentation=instrumentation) as session: @@ -583,6 +619,7 @@ def fail_read_finish(event: SandboxSessionEvent, _session: BaseSandboxSession) - @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_exec_span_records_cancellation_during_finish_sink_delivery(tmp_path: Path) -> None: finish_delivery_started = asyncio.Event() completed_exit_codes: list[int] = [] @@ -622,6 +659,7 @@ async def block_exec_finish(event: SandboxSessionEvent, _session: BaseSandboxSes @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_sandbox_session_ops_nest_under_sdk_trace_and_events_carry_trace_ids( tmp_path: Path, ) -> None: @@ -885,6 +923,7 @@ async def test_sandbox_session_ops_nest_under_sdk_trace_and_events_carry_trace_i @pytest.mark.asyncio +@pytest.mark.requires_native_macos_sandbox async def test_sandbox_session_events_fallback_to_audit_ids_under_disabled_parent_span( tmp_path: Path, ) -> None: @@ -916,7 +955,7 @@ async def test_sandbox_session_events_fallback_to_audit_ids_under_disabled_paren @pytest.mark.asyncio async def test_sandbox_session_aclose_flushes_best_effort_sink_tasks(tmp_path: Path) -> None: - inner = _build_unix_local_session(tmp_path) + inner = _build_filesystem_test_session(tmp_path) seen: list[tuple[str, str]] = [] async def _callback(event: SandboxSessionEvent, _session: BaseSandboxSession) -> None: diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 8b097c002c..67ea2416ed 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -111,6 +111,7 @@ async def blocked_to_thread(*args: object, **kwargs: object) -> None: assert session._fd_close_tasks == set() @pytest.mark.asyncio + @pytest.mark.requires_native_macos_sandbox async def test_pty_exec_write_poll_and_unknown_session_errors(self, tmp_path: Path) -> None: client = UnixLocalSandboxClient() manifest = Manifest(root=str(tmp_path / "workspace")) @@ -144,6 +145,7 @@ async def test_pty_exec_write_poll_and_unknown_session_errors(self, tmp_path: Pa await session.pty_write_stdin(session_id=999_999, chars="") @pytest.mark.asyncio + @pytest.mark.requires_native_macos_sandbox async def test_pty_ctrl_c_interrupts_long_running_process(self, tmp_path: Path) -> None: client = UnixLocalSandboxClient() manifest = Manifest(root=str(tmp_path / "workspace")) @@ -188,6 +190,7 @@ async def test_pty_ctrl_c_interrupts_long_running_process(self, tmp_path: Path) ], ) @pytest.mark.asyncio + @pytest.mark.requires_native_macos_sandbox async def test_pty_terminal_signals_interrupt_even_if_parent_ignores_signal( self, tmp_path: Path, signum: signal.Signals, chars: str ) -> None: @@ -221,6 +224,7 @@ async def test_pty_terminal_signals_interrupt_even_if_parent_ignores_signal( signal.signal(signum, previous_handler) @pytest.mark.asyncio + @pytest.mark.requires_native_macos_sandbox async def test_non_tty_pty_session_rejects_stdin_and_can_still_be_polled( self, tmp_path: Path ) -> None: @@ -260,6 +264,7 @@ async def test_non_tty_pty_session_rejects_stdin_and_can_still_be_polled( await session.pty_write_stdin(session_id=started.process_id, chars="") @pytest.mark.asyncio + @pytest.mark.requires_native_macos_sandbox async def test_stop_terminates_active_pty_sessions(self, tmp_path: Path) -> None: client = UnixLocalSandboxClient() manifest = Manifest(root=str(tmp_path / "workspace")) diff --git a/tests/test_code_change_verification_policy.py b/tests/test_code_change_verification_policy.py new file mode 100644 index 0000000000..9247ddaf58 --- /dev/null +++ b/tests/test_code_change_verification_policy.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from tests.conftest import _running_in_nested_codex_macos_sandbox + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_SKILL_PATH = _REPOSITORY_ROOT / ".agents/skills/code-change-verification/SKILL.md" +_PROMPT_PATH = _REPOSITORY_ROOT / ".agents/skills/code-change-verification/agents/openai.yaml" +_CHANGE_DETECTOR_PATH = _REPOSITORY_ROOT / ".github/scripts/detect-changes.sh" + + +def test_code_change_verification_keeps_codex_execution_sandboxed() -> None: + skill = _SKILL_PATH.read_text(encoding="utf-8") + prompt = _PROMPT_PATH.read_text(encoding="utf-8") + combined = f"{skill}\n{prompt}" + + assert "sandbox_permissions=require_escalated" not in combined + assert "persistent command allow rule" not in combined + assert "outside the Codex sandbox" not in combined + assert "retry with broader host access" in combined + assert ( + "/usr/bin/env -u OPENAI_API_KEY OPENAI_AGENTS_TEST_IN_CODEX_SANDBOX=1 " + "UV_DEFAULT_INDEX=https://pypi.org/simple" + ) in skill + + +def test_code_change_detection_includes_verification_skill() -> None: + detector = _CHANGE_DETECTOR_PATH.read_text(encoding="utf-8") + code_pattern = re.search(r"^\s*pattern='([^']+)'$", detector, flags=re.MULTILINE) + + assert code_pattern is not None + assert re.match( + code_pattern.group(1), + ".agents/skills/code-change-verification/SKILL.md", + ) + + +@pytest.mark.parametrize( + ("platform", "environment", "expected"), + [ + pytest.param("darwin", {"OPENAI_AGENTS_TEST_IN_CODEX_SANDBOX": "1"}, True), + pytest.param("darwin", {}, False), + pytest.param("darwin", {"OPENAI_AGENTS_TEST_IN_CODEX_SANDBOX": "0"}, False), + pytest.param("linux", {"OPENAI_AGENTS_TEST_IN_CODEX_SANDBOX": "1"}, False), + ], +) +def test_native_macos_sandbox_skip_requires_explicit_nested_mode( + platform: str, environment: dict[str, str], expected: bool +) -> None: + assert ( + _running_in_nested_codex_macos_sandbox( + platform=platform, + environment=environment, + ) + is expected + ) From 32e452671c085ce9335ff756430e204a1735e5b0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 19 Aug 2026 06:57:46 +0900 Subject: [PATCH 361/473] ci: change windows ci configuration for stability --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e7ee288b8d..cd038cf88f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -275,7 +275,8 @@ jobs: with: version: "0.11.14" enable-cache: true - prune-cache: true + # Disable cache pruning on Windows to avoid intermittent post-job cleanup failures. + prune-cache: false python-version: "3.14" - name: Download prospective release contract if: steps.changes.outputs.run == 'true' From 21a1f9b4e681a8e2fd4a067efd6234124f417175 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 19 Aug 2026 07:16:06 +0900 Subject: [PATCH 362/473] fix: avoid Windows integration bootstrap crashes --- .github/scripts/run_integration_tests.py | 3 ++ tests/test_integration_runner.py | 43 +++++++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 6e46c5a8b9..5a6d16ac51 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -100,6 +100,9 @@ def bootstrap_in_uv( child_env = dict(environ) child_env[BOOTSTRAPPED_ENV] = "1" command = ["uv", "run", "python", str(Path(__file__).resolve()), *arguments] + if sys.platform == "win32": + completed = subprocess.run(command, env=child_env, check=False) + raise SystemExit(completed.returncode) exec_function(command[0], command, child_env) raise RuntimeError("The uv integration runner bootstrap returned unexpectedly.") diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 1e28581f88..c98cdcbe37 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -78,7 +78,7 @@ def test_live_profiles_refuse_untrusted_credentials_before_side_effects( ): bootstrap_in_uv( ["--profile", profile], - {"OPENAI_API_KEY": "inherited-employee-key"}, + {"OPENAI_API_KEY": "placeholder-key"}, lambda *args: child_processes.append("uv"), ) @@ -89,11 +89,13 @@ def test_live_profiles_refuse_untrusted_credentials_before_side_effects( "profile", ["packaging", "prospective-contract", "prospective-platform", "security", "mcp-v1", "extras"], ) -def test_local_only_profiles_remove_key_before_uv_child_process(profile: str) -> None: +def test_local_only_profiles_remove_key_before_uv_child_process( + profile: str, monkeypatch: pytest.MonkeyPatch +) -> None: namespace = runpy.run_path(str(RUNNER)) bootstrap_in_uv = cast(Callable[..., None], namespace["bootstrap_in_uv"]) environment = { - "OPENAI_API_KEY": "inherited-employee-key", + "OPENAI_API_KEY": "placeholder-key", "OPENAI_API_KEY_SOURCE": "employee", } captured: list[tuple[str, list[str], dict[str, str]]] = [] @@ -101,6 +103,8 @@ def test_local_only_profiles_remove_key_before_uv_child_process(profile: str) -> def capture_exec(file: str, command: list[str], child_env: dict[str, str]) -> None: captured.append((file, command, child_env)) + monkeypatch.setattr(bootstrap_in_uv.__globals__["sys"], "platform", "linux") + with pytest.raises(RuntimeError, match="bootstrap returned unexpectedly"): bootstrap_in_uv(["--profile", profile], environment, capture_exec) @@ -112,6 +116,37 @@ def capture_exec(file: str, command: list[str], child_env: dict[str, str]) -> No assert "OPENAI_API_KEY" not in environment +def test_windows_bootstrap_uses_subprocess_and_propagates_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + bootstrap_in_uv = cast(Callable[..., None], namespace["bootstrap_in_uv"]) + environment = {"OPENAI_API_KEY": "placeholder-key"} + captured: list[tuple[list[str], dict[str, str], bool]] = [] + + def capture_run(command: list[str], *, env: dict[str, str], check: bool) -> SimpleNamespace: + captured.append((command, env, check)) + return SimpleNamespace(returncode=23) + + monkeypatch.setattr(bootstrap_in_uv.__globals__["sys"], "platform", "win32") + monkeypatch.setattr(bootstrap_in_uv.__globals__["subprocess"], "run", capture_run) + + with pytest.raises(SystemExit) as exc_info: + bootstrap_in_uv( + ["--profile", "prospective-platform"], + environment, + lambda *_args: pytest.fail("Windows bootstrap must not call os.execvpe"), + ) + + assert exc_info.value.code == 23 + assert len(captured) == 1 + assert captured[0][0][0:3] == ["uv", "run", "python"] + assert "OPENAI_API_KEY" not in captured[0][1] + assert captured[0][1][namespace["BOOTSTRAPPED_ENV"]] == "1" + assert captured[0][2] is False + assert "OPENAI_API_KEY" not in environment + + def test_local_only_profile_removes_key_before_cleanup_build_and_children( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -144,7 +179,7 @@ def fake_run_suite(*args: object, **kwargs: Any) -> None: _ = (args, kwargs) assert_sanitized("run-suite") - monkeypatch.setenv("OPENAI_API_KEY", "inherited-employee-key") + monkeypatch.setenv("OPENAI_API_KEY", "placeholder-key") monkeypatch.setenv("OPENAI_API_KEY_SOURCE", "employee") monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "packaging"]) monkeypatch.setitem(main.__globals__, "build_distributions", fake_build_distributions) From 2c5560339cd7f77b4dabcf7d85c5d150594fd74c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 19 Aug 2026 07:31:35 +0900 Subject: [PATCH 363/473] test: make stream event ordering deterministic --- tests/test_stream_events.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index d6ad434545..d1e93dd8ba 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -1,5 +1,4 @@ import asyncio -import time from copy import deepcopy from typing import Any, cast @@ -112,18 +111,22 @@ async def test_stream_events_main(): agent, input="Hello", ) - tool_call_start_time = -1 - tool_call_end_time = -1 + event_index = 0 + tool_call_start_index = -1 + tool_call_end_index = -1 async for event in result.stream_events(): + event_index += 1 if event.type == "run_item_stream_event": if event.item.type == "tool_call_item": - tool_call_start_time = time.time_ns() + tool_call_start_index = event_index elif event.item.type == "tool_call_output_item": - tool_call_end_time = time.time_ns() + tool_call_end_index = event_index - assert tool_call_start_time > 0, "tool_call_item was not observed" - assert tool_call_end_time > 0, "tool_call_output_item was not observed" - assert tool_call_start_time < tool_call_end_time, "Tool call ended before or equals it started?" + assert tool_call_start_index > 0, "tool_call_item was not observed" + assert tool_call_end_index > 0, "tool_call_output_item was not observed" + assert tool_call_start_index < tool_call_end_index, ( + "Tool call ended before or equals it started?" + ) @pytest.mark.asyncio From ed644fc7c3a8c013ded9e5cde7ff6a79d48c8b75 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 19 Aug 2026 15:39:24 +0900 Subject: [PATCH 364/473] fix(core): redact blocked tool outputs from replay state (#4507) --- src/agents/run.py | 358 ++++++-- .../run_internal/agent_runner_helpers.py | 26 + src/agents/run_internal/blocked_output.py | 831 +++++++++++++++++ src/agents/run_internal/run_loop.py | 396 +++++--- tests/test_agent_runner.py | 853 +++++++++++++++++- tests/test_agent_runner_streamed.py | 760 ++++++++++++++-- tests/test_error_logging_redaction.py | 63 ++ tests/test_max_turns.py | 116 ++- 8 files changed, 3160 insertions(+), 243 deletions(-) create mode 100644 src/agents/run_internal/blocked_output.py diff --git a/src/agents/run.py b/src/agents/run.py index b3fa3f132d..9d6621e66c 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -77,9 +77,23 @@ snapshot_usage, update_run_state_for_interruption, usage_delta, + validate_output_guardrails_with_server_managed_conversation, validate_session_conversation_settings, ) from .run_internal.approvals import approvals_from_step +from .run_internal.blocked_output import ( + _blocked_output_failure_items, + _BlockedOutputOwnerStarts, + _current_response_boundary, + _final_turn_items_for_persistence, + _has_output_guardrails, + _is_terminal_tool_output_response, + _retained_items_for_blocked_response, + _sanitize_blocked_output_guardrail_results, + _should_defer_interrupted_session_items, + _synchronize_accepted_run_state, + _validate_resumed_session_output_guardrail_safety, +) from .run_internal.error_handlers import ( attach_generic_agent_error, build_run_error_data, @@ -94,7 +108,7 @@ from .run_internal.prompt_cache_key import PromptCacheKeyResolver from .run_internal.run_grouping import resolve_run_grouping_id from .run_internal.run_loop import ( - _retained_items_for_blocked_output, + _safe_redacted_persistence_error, cleanup_models_after_run, finalize_max_turns_handler_output, get_all_tools, @@ -892,6 +906,12 @@ def _mark_response_hooks_started() -> None: current_agent = run_state._current_agent else: current_agent = starting_agent + _validate_resumed_session_output_guardrail_safety( + agent=current_agent, + run_config=run_config, + session=session, + run_state=run_state if is_resumed_state else None, + ) sandbox_runtime.assert_agent_supported(current_agent) should_run_agent_start_hooks = True store_setting = current_agent.model_settings.resolve( @@ -937,6 +957,13 @@ def _mark_response_hooks_started() -> None: try: while True: + validate_output_guardrails_with_server_managed_conversation( + current_agent, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) if TYPE_CHECKING: # Keep loop-carried types explicit to bound Pyright's flow analysis. original_input = cast( # type: ignore[redundant-cast] @@ -1040,6 +1067,23 @@ def _mark_response_hooks_started() -> None: ) raise UserError("No processed response found in previous state") + resumed_response_boundary = _current_response_boundary( + (), + run_state._last_processed_response, + run_state, + ) + blocked_output_owner_starts = _BlockedOutputOwnerStarts( + nonstreamed_session_items=(resumed_response_boundary.session_start), + run_state_generated_items=( + resumed_response_boundary.generated_start + ), + run_state_session_items=resumed_response_boundary.session_start, + run_state_model_responses=len(run_state._model_responses) - 1, + run_state_tool_output_guardrail_results=len( + run_state._tool_output_guardrail_results + ), + ) + turn_result = await resolve_interrupted_turn( bindings=current_bindings, original_input=original_input, @@ -1049,7 +1093,9 @@ def _mark_response_hooks_started() -> None: hooks=hooks, context_wrapper=context_wrapper, run_config=run_config, - server_manages_conversation=server_conversation_tracker is not None, + server_manages_conversation=( + server_conversation_tracker is not None + ), run_state=run_state, error_handlers=error_handlers, ) @@ -1086,6 +1132,14 @@ def _mark_response_hooks_started() -> None: session_persistence_enabled and turn_session_items and run_state is not None + and not isinstance(turn_result.next_step, NextStepFinalOutput) + and not ( + isinstance(turn_result.next_step, NextStepInterruption) + and _should_defer_interrupted_session_items( + current_agent, + run_config, + ) + ) ): run_state._current_turn_persisted_item_count = ( await save_resumed_turn_items( @@ -1168,13 +1222,119 @@ def _mark_response_hooks_started() -> None: ) if isinstance(turn_result.next_step, NextStepFinalOutput): - await run_output_guardrails( - current_agent.output_guardrails - + (run_config.output_guardrails or []), + if run_state is not None and _has_output_guardrails( + current_agent, run_config + ): + run_state._tool_output_guardrail_results = list( + tool_output_guardrail_results + ) + current_processed_response = ( + turn_result.processed_response + if turn_result.processed_response is not None + else run_state._last_processed_response + ) + output_guardrail_result_start = len(output_guardrail_results) + try: + await run_output_guardrails( + current_agent.output_guardrails + + (run_config.output_guardrails or []), + current_agent, + turn_result.next_step.output, + context_wrapper, + output_guardrail_results, + ) + except OutputGuardrailTripwireTriggered as exc: + if not _is_terminal_tool_output_response( + turn_session_items, + current_processed_response, + run_state, + ): + raise + sanitized_results = _sanitize_blocked_output_guardrail_results( + output_guardrail_results[output_guardrail_result_start:], + exc, + ) + output_guardrail_results[output_guardrail_result_start:] = ( + sanitized_results + ) + session_items = _blocked_output_failure_items( + session_items, + (), + blocked_output_owner_starts, + ) + retained_items = _retained_items_for_blocked_response( + turn_session_items, + turn_result.model_response, + run_state, + current_processed_response, + owner_starts=blocked_output_owner_starts, + ) + list.extend(session_items, retained_items) + try: + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=( + session_persistence_enabled + ), + input_guardrail_results=( + _attempt_input_guardrail_results() + ), + items=retained_items, + response_id=turn_result.model_response.response_id, + store=store_setting, + wrapper=context_wrapper, + ) + except BaseException as persistence_error: + raise _safe_redacted_persistence_error( + persistence_error + ) from None + raise + except (Exception, asyncio.CancelledError) as guardrail_error: + if not isinstance( + guardrail_error, asyncio.CancelledError + ) or not _is_terminal_tool_output_response( + turn_session_items, + current_processed_response, + run_state, + ): + final_turn_items = _final_turn_items_for_persistence( + turn_session_items, + current_processed_response, + run_state, + current_agent, + run_config, + ) + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=session_persistence_enabled, + input_guardrail_results=( + _attempt_input_guardrail_results() + ), + items=final_turn_items, + response_id=turn_result.model_response.response_id, + store=store_setting, + wrapper=context_wrapper, + ) + raise + + final_turn_items = _final_turn_items_for_persistence( + turn_session_items, + current_processed_response, + run_state, current_agent, - turn_result.next_step.output, - context_wrapper, - output_guardrail_results, + run_config, + ) + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=session_persistence_enabled, + input_guardrail_results=_attempt_input_guardrail_results(), + items=final_turn_items, + response_id=turn_result.model_response.response_id, + store=store_setting, + wrapper=context_wrapper, ) current_step = getattr(run_state, "_current_step", None) approvals_from_state = approvals_from_step(current_step) @@ -1202,21 +1362,6 @@ def _mark_response_hooks_started() -> None: ) != list(session_items) if run_state is not None: result._trace_state = run_state._trace_state - if session_persistence_enabled: - input_items_for_save_1: list[TResponseInputItem] = ( - session_input_items_for_persistence - if session_input_items_for_persistence is not None - else [] - ) - await save_result_to_session( - session, - input_items_for_save_1, - session_items_for_turn(turn_result), - run_state, - response_id=turn_result.model_response.response_id, - store=store_setting, - wrapper=context_wrapper, - ) result._original_input = copy_input_items(original_input) run_state._current_step = None return _finalize_result(result) @@ -1393,7 +1538,9 @@ async def _save_max_turns_handler_output( result._original_input = copy_input_items(original_input) return _finalize_result(result) - if run_state is not None and not resuming_turn: + if run_state is not None and ( + not resuming_turn or isinstance(run_state._current_step, NextStepRunAgain) + ): run_state._current_turn_persisted_item_count = 0 logger.debug("Running agent %s (turn %s)", current_agent.name, current_turn) @@ -1406,6 +1553,35 @@ async def _save_max_turns_handler_output( except Exception: last_saved_input_snapshot_for_rewind = None + if run_state is not None and _has_output_guardrails(current_agent, run_config): + _synchronize_accepted_run_state( + run_state, + generated_items=generated_items, + session_items=session_items, + model_responses=model_responses, + tool_input_guardrail_results=tool_input_guardrail_results, + tool_output_guardrail_results=tool_output_guardrail_results, + current_turn=current_turn, + ) + + blocked_output_owner_starts = _BlockedOutputOwnerStarts( + nonstreamed_session_items=len(session_items), + run_state_generated_items=( + len(run_state._generated_items) if run_state is not None else None + ), + run_state_session_items=( + len(run_state._session_items) if run_state is not None else None + ), + run_state_model_responses=( + len(run_state._model_responses) if run_state is not None else None + ), + run_state_tool_output_guardrail_results=( + len(run_state._tool_output_guardrail_results) + if run_state is not None + else None + ), + ) + items_for_model = ( pending_server_items if server_conversation_tracker is not None and pending_server_items @@ -1665,6 +1841,13 @@ async def _save_max_turns_handler_output( try: if isinstance(turn_result.next_step, NextStepFinalOutput): + if run_state is not None and _has_output_guardrails( + current_agent, run_config + ): + run_state._tool_output_guardrail_results = list( + tool_output_guardrail_results + ) + output_guardrail_result_start = len(output_guardrail_results) try: await run_output_guardrails( current_agent.output_guardrails @@ -1674,39 +1857,93 @@ async def _save_max_turns_handler_output( context_wrapper, output_guardrail_results, ) - except OutputGuardrailTripwireTriggered: - await save_final_turn_items_after_guardrails( - session=session, - run_state=run_state, - session_persistence_enabled=session_persistence_enabled, - input_guardrail_results=_attempt_input_guardrail_results(), - items=_retained_items_for_blocked_output(items_to_save_turn), - response_id=turn_result.model_response.response_id, - store=store_setting, - wrapper=context_wrapper, + except OutputGuardrailTripwireTriggered as exc: + if not _is_terminal_tool_output_response( + turn_session_items, + turn_result.processed_response, + run_state, + ): + raise + sanitized_results = _sanitize_blocked_output_guardrail_results( + output_guardrail_results[output_guardrail_result_start:], + exc, ) - raise - except (Exception, asyncio.CancelledError): - # Preserve the released non-stream behavior for guardrail errors - # and cancellation: the completed final turn remains replayable. - await save_final_turn_items_after_guardrails( - session=session, - run_state=run_state, - session_persistence_enabled=session_persistence_enabled, - input_guardrail_results=_attempt_input_guardrail_results(), - items=items_to_save_turn, - response_id=turn_result.model_response.response_id, - store=store_setting, - wrapper=context_wrapper, + output_guardrail_results[output_guardrail_result_start:] = ( + sanitized_results ) + session_items = _blocked_output_failure_items( + session_items, + (), + blocked_output_owner_starts, + ) + retained_items = _retained_items_for_blocked_response( + turn_session_items, + turn_result.model_response, + run_state, + turn_result.processed_response, + owner_starts=blocked_output_owner_starts, + ) + list.extend(session_items, retained_items) + try: + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=(session_persistence_enabled), + input_guardrail_results=( + _attempt_input_guardrail_results() + ), + items=retained_items, + response_id=turn_result.model_response.response_id, + store=store_setting, + wrapper=context_wrapper, + ) + except BaseException as persistence_error: + raise _safe_redacted_persistence_error( + persistence_error + ) from None + raise + except (Exception, asyncio.CancelledError) as guardrail_error: + if not isinstance( + guardrail_error, asyncio.CancelledError + ) or not _is_terminal_tool_output_response( + turn_session_items, + turn_result.processed_response, + run_state, + ): + final_turn_items = _final_turn_items_for_persistence( + turn_session_items, + turn_result.processed_response, + run_state, + current_agent, + run_config, + ) + await save_final_turn_items_after_guardrails( + session=session, + run_state=run_state, + session_persistence_enabled=session_persistence_enabled, + input_guardrail_results=( + _attempt_input_guardrail_results() + ), + items=final_turn_items, + response_id=turn_result.model_response.response_id, + store=store_setting, + wrapper=context_wrapper, + ) raise + final_turn_items = _final_turn_items_for_persistence( + turn_session_items, + turn_result.processed_response, + run_state, + current_agent, + run_config, + ) await save_final_turn_items_after_guardrails( session=session, run_state=run_state, session_persistence_enabled=session_persistence_enabled, input_guardrail_results=_attempt_input_guardrail_results(), - items=items_to_save_turn, + items=final_turn_items, response_id=turn_result.model_response.response_id, store=store_setting, wrapper=context_wrapper, @@ -1745,7 +1982,12 @@ async def _save_max_turns_handler_output( run_state._current_step = None return _finalize_result(result) elif isinstance(turn_result.next_step, NextStepInterruption): - if session_persistence_enabled: + if session_persistence_enabled and not ( + _should_defer_interrupted_session_items( + current_agent, + run_config, + ) + ): if not input_guardrails_triggered( _attempt_input_guardrail_results() ): @@ -2141,6 +2383,19 @@ def run_streamed( if run_state is not None: run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy + schema_agent = ( + run_state._current_agent + if run_state is not None and run_state._current_agent is not None + else starting_agent + ) + validate_output_guardrails_with_server_managed_conversation( + schema_agent, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) + ( trace_workflow_name, trace_id, @@ -2176,11 +2431,6 @@ def run_streamed( run_state=run_state, ) - schema_agent = ( - run_state._current_agent - if run_state is not None and run_state._current_agent is not None - else starting_agent - ) sandbox_runtime.assert_agent_supported(schema_agent) output_schema = get_output_schema(schema_agent) diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 7d5b73ad5a..be1d976724 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -14,6 +14,7 @@ from ..items import ModelResponse, RunItem, ToolApprovalItem, TResponseInputItem from ..memory import Session from ..models.openai_agent_registration import add_openai_harness_id_to_metadata +from ..models.openai_chatcompletions import OpenAIChatCompletionsModel from ..result import RunResult from ..run_config import ReasoningItemIdPolicy, RunConfig from ..run_context import RunContextWrapper, TContext @@ -42,6 +43,7 @@ ) from .session_persistence import save_result_to_session, save_resumed_turn_items from .tool_use_tracker import AgentToolUseTracker, serialize_tool_use_tracker +from .turn_preparation import get_model __all__ = [ "apply_resumed_conversation_settings", @@ -55,6 +57,7 @@ "finalize_conversation_tracking", "get_unsent_tool_call_ids_for_interrupted_state", "input_guardrails_triggered", + "validate_output_guardrails_with_server_managed_conversation", "validate_session_conversation_settings", "resolve_trace_settings", "resolve_processed_response", @@ -257,6 +260,29 @@ def validate_session_conversation_settings( ) +def validate_output_guardrails_with_server_managed_conversation( + agent: Agent[Any], + run_config: RunConfig, + *, + conversation_id: str | None, + previous_response_id: str | None, + auto_previous_response_id: bool, +) -> None: + """Reject an output-guardrail run whose rejected history cannot be locally replaced.""" + if conversation_id is None and previous_response_id is None and not auto_previous_response_id: + return + if not agent.output_guardrails and not run_config.output_guardrails: + return + if isinstance(get_model(agent, run_config), OpenAIChatCompletionsModel): + # Chat Completions owns its released warn-and-ignore or strict rejection behavior. + return + raise UserError( + "Output guardrails cannot be combined with conversation_id, previous_response_id, " + "or auto_previous_response_id because rejected output cannot be removed from " + "server-managed conversation history." + ) + + def resolve_trace_settings( *, run_state: RunState[TContext] | None, diff --git a/src/agents/run_internal/blocked_output.py b/src/agents/run_internal/blocked_output.py new file mode 100644 index 0000000000..bb1da4ffaa --- /dev/null +++ b/src/agents/run_internal/blocked_output.py @@ -0,0 +1,831 @@ +"""Canonical data-free function-tool payloads rejected by an output guardrail.""" + +from __future__ import annotations + +import dataclasses as _dc +from collections.abc import Sequence +from typing import Any, TypeVar, cast + +from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call import CallerDirect + +from ..agent import Agent +from ..exceptions import ( + AgentsException, + OutputGuardrailTripwireTriggered, + UserError, + _detach_data_redacted_error_traceback, + _mark_error_data_redacted, + _prepare_data_redacted_error, +) +from ..guardrail import GuardrailFunctionOutput, OutputGuardrailResult +from ..items import ModelResponse, RunItem, ToolCallItem, ToolCallOutputItem +from ..memory import Session +from ..result import RunResultStreaming +from ..run_config import RunConfig +from ..run_state import RunState +from ..tool_guardrails import ( + ToolGuardrailFunctionOutput, + ToolInputGuardrailResult, + ToolOutputGuardrailResult, +) +from .run_steps import NextStepInterruption, ProcessedResponse + +OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT = "Output withheld by an output guardrail." + +_RESPONSE_OUTPUT_STATUSES = frozenset({"in_progress", "completed", "incomplete"}) + + +def _exact_dict_field(values: dict[Any, Any], field: str) -> Any: + """Read one exact string key without invoking stored-key equality hooks.""" + for key, value in dict.items(values): + if type(key) is str and str.__eq__(key, field) is True: + return value + return None + + +def _payload_field(raw_item: Any, field: str) -> Any: + """Read an allowlisted field without copying extras or invoking instance hooks.""" + if type(raw_item) is dict: + values = raw_item + elif type(raw_item) is ResponseFunctionToolCall: + values = object.__getattribute__(raw_item, "__dict__") + else: + raise AgentsException("Cannot sanitize an unsupported tool item variant.") + if type(values) is not dict: + raise AgentsException("Cannot sanitize an unsupported tool item representation.") + return _exact_dict_field(values, field) + + +def _required_string(raw_item: Any, field: str) -> str: + value = _payload_field(raw_item, field) + if type(value) is not str or not value: + raise AgentsException(f"Cannot sanitize a function tool item without {field}.") + return value + + +def _copy_optional_string( + sanitized: dict[str, Any], + raw_item: Any, + field: str, +) -> None: + value = _payload_field(raw_item, field) + if value is None: + return + if type(value) is not str or not value: + raise AgentsException(f"Cannot sanitize a function tool item with an invalid {field}.") + sanitized[field] = value + + +def _copy_optional_status(sanitized: dict[str, Any], raw_item: Any) -> None: + status = _payload_field(raw_item, "status") + if status is None: + return + if type(status) is not str or status not in _RESPONSE_OUTPUT_STATUSES: + raise AgentsException("Cannot sanitize a function tool item with an invalid status.") + sanitized["status"] = status + + +def _copy_optional_direct_caller(sanitized: dict[str, Any], raw_item: Any) -> None: + caller = _payload_field(raw_item, "caller") + if caller is None: + return + if type(caller) is CallerDirect: + values = object.__getattribute__(caller, "__dict__") + caller_type = _exact_dict_field(values, "type") if type(values) is dict else None + elif type(caller) is dict: + caller_type = _exact_dict_field(caller, "type") + else: + caller_type = None + if type(caller_type) is str and str.__eq__(caller_type, "direct") is True: + sanitized["caller"] = {"type": "direct"} + return + raise AgentsException("Cannot sanitize a function tool item with a non-direct caller.") + + +def blocked_function_call_payload(raw_item: Any) -> dict[str, Any]: + """Build a provider-valid function call from explicitly allowlisted fields.""" + item_type = _payload_field(raw_item, "type") + if type(item_type) is not str or str.__eq__(item_type, "function_call") is not True: + raise AgentsException("Cannot sanitize an unsupported tool call variant.") + arguments = _payload_field(raw_item, "arguments") + if type(arguments) is not str: + raise AgentsException("Cannot sanitize a function tool item without arguments.") + sanitized: dict[str, Any] = { + "type": "function_call", + "name": _required_string(raw_item, "name"), + "arguments": arguments, + "call_id": _required_string(raw_item, "call_id"), + } + _copy_optional_string(sanitized, raw_item, "id") + _copy_optional_string(sanitized, raw_item, "namespace") + _copy_optional_status(sanitized, raw_item) + _copy_optional_direct_caller(sanitized, raw_item) + try: + validated = ResponseFunctionToolCall(**sanitized) + except Exception: + raise AgentsException("Sanitized function_call is not valid for replay.") from None + return validated.model_dump(exclude_unset=True) + + +def blocked_function_output_payload(raw_item: Any) -> dict[str, Any]: + """Build a replay-valid function output from explicitly allowlisted fields.""" + item_type = _payload_field(raw_item, "type") + if type(item_type) is not str or str.__eq__(item_type, "function_call_output") is not True: + raise AgentsException("Cannot sanitize an unsupported tool output variant.") + sanitized: dict[str, Any] = { + "type": "function_call_output", + "call_id": _required_string(raw_item, "call_id"), + "output": OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + } + _copy_optional_string(sanitized, raw_item, "id") + _copy_optional_status(sanitized, raw_item) + _copy_optional_direct_caller(sanitized, raw_item) + try: + from ..run_state import _deserialize_tool_call_output_raw_item + + restored = _deserialize_tool_call_output_raw_item(sanitized) + except Exception: + raise AgentsException("Sanitized function_call_output is not valid for replay.") from None + if restored is None: + raise AgentsException("Sanitized function_call_output is not valid for replay.") + return sanitized + + +_SIDE_EFFECT_ITEM_TYPES = frozenset({"tool_call_item", "tool_call_output_item"}) + + +def _sanitize_blocked_output_guardrail_results( + results: Sequence[OutputGuardrailResult], + tripwire: OutputGuardrailTripwireTriggered, +) -> list[OutputGuardrailResult]: + """Build data-free guardrail results and detach the tripwire from raw output.""" + sanitized_by_id: dict[int, OutputGuardrailResult] = {} + + def sanitize(result: OutputGuardrailResult) -> OutputGuardrailResult: + existing = sanitized_by_id.get(id(result)) + if existing is not None: + return existing + sanitized = OutputGuardrailResult( + guardrail=result.guardrail, + agent_output=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + agent=result.agent, + output=GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=result.output.tripwire_triggered, + ), + ) + sanitized_by_id[id(result)] = sanitized + return sanitized + + sanitized_results = [sanitize(result) for result in results] + object.__setattr__(tripwire, "guardrail_result", sanitize(tripwire.guardrail_result)) + _mark_error_data_redacted(tripwire) + _detach_data_redacted_error_traceback(tripwire) + return sanitized_results + + +@_dc.dataclass(frozen=True) +class _CurrentResponseBoundary: + """A current-response suffix proven only by lifecycle position or object identity.""" + + items: tuple[RunItem, ...] + processed_items: tuple[RunItem, ...] + generated_start: int | None + session_start: int | None + proven: bool + + +@_dc.dataclass(frozen=True) +class _BlockedOutputSnapshot: + """Prepared data-free replacements for one complete current response.""" + + items: tuple[RunItem, ...] + processed_items: tuple[RunItem, ...] + model_response: ModelResponse | None + + +@_dc.dataclass(frozen=True) +class _BlockedOutputOwnerPlan: + """Prebuilt trusted-owner assignments for application or emergency cleanup.""" + + assignments: tuple[tuple[Any, str, Any], ...] + + +@_dc.dataclass(frozen=True) +class _BlockedOutputOwnerStarts: + """Owner-specific current-response starts captured at trusted lifecycle boundaries.""" + + nonstreamed_session_items: int | None = None + run_state_generated_items: int | None = None + run_state_session_items: int | None = None + run_state_model_responses: int | None = None + run_state_tool_output_guardrail_results: int | None = None + streamed_new_items: int | None = None + streamed_model_input_items: int | None = None + streamed_raw_responses: int | None = None + streamed_tool_output_guardrail_results: int | None = None + + +@_dc.dataclass(frozen=True) +class _BlockedOutputOwnerPrefixes: + """Accepted owner prefixes allocated before any blocked-output replacement begins.""" + + run_state_generated_items: list[RunItem] + run_state_session_items: list[RunItem] + run_state_model_responses: list[ModelResponse] + run_state_tool_output_guardrail_results: list[ToolOutputGuardrailResult] + streamed_new_items: list[RunItem] + streamed_model_input_items: list[RunItem] + streamed_raw_responses: list[ModelResponse] + streamed_tool_output_guardrail_results: list[ToolOutputGuardrailResult] + + +_OwnerItemT = TypeVar("_OwnerItemT") + + +def _has_output_guardrails(agent: Agent[Any], run_config: RunConfig) -> bool: + return bool(agent.output_guardrails or run_config.output_guardrails) + + +def _synchronize_accepted_run_state( + run_state: RunState[Any], + *, + generated_items: Sequence[RunItem], + session_items: Sequence[RunItem], + model_responses: Sequence[ModelResponse], + tool_input_guardrail_results: Sequence[ToolInputGuardrailResult], + tool_output_guardrail_results: Sequence[ToolOutputGuardrailResult], + current_turn: int, +) -> None: + """Capture accepted run history before a guardrail-owned model response begins.""" + run_state._generated_items = list(generated_items) + run_state._session_items = list(session_items) + run_state._model_responses = list(model_responses) + run_state._tool_input_guardrail_results = list(tool_input_guardrail_results) + run_state._tool_output_guardrail_results = list(tool_output_guardrail_results) + run_state._current_turn = current_turn + + +def _should_defer_interrupted_session_items( + agent: Agent[Any], + run_config: RunConfig, +) -> bool: + """Defer only approval state that could still become guarded terminal tool output.""" + return _has_output_guardrails(agent, run_config) and agent.tool_use_behavior != "run_llm_again" + + +def _validate_resumed_session_output_guardrail_safety( + *, + agent: Agent[Any], + run_config: RunConfig, + session: Session | None, + run_state: RunState[Any] | None, +) -> None: + """Reject approval resumes whose current-response boundary is not structurally provable.""" + if run_state is None or not _has_output_guardrails(agent, run_config): + return + if not isinstance(run_state._current_step, NextStepInterruption): + return + boundary = _current_response_boundary( + (), + run_state._last_processed_response, + run_state, + ) + if not boundary.proven: + raise UserError( + "Cannot resume a serialized approval checkpoint with output guardrails because the " + "current response boundary cannot be proven. Start a new run from safe input." + ) + if run_state._current_turn_persisted_item_count > 0 and ( + _should_defer_interrupted_session_items(agent, run_config) + ): + if session is not None: + raise UserError( + "Cannot resume an approval checkpoint with output guardrails after current-turn " + "items were persisted. Start a new run from safe input." + ) + # A detached Session cannot contribute its old persisted prefix to this run. + run_state._current_turn_persisted_item_count = 0 + + +def _identity_sequence_start( + container: Sequence[RunItem], + sequence: Sequence[RunItem], +) -> int | None: + if not sequence or len(sequence) > len(container): + return None + for start in range(len(container) - len(sequence) + 1): + if all(container[start + offset] is item for offset, item in enumerate(sequence)): + return start + return None + + +def _current_response_boundary( + new_items: Sequence[RunItem], + processed_response: ProcessedResponse | None, + run_state: RunState[Any] | None, +) -> _CurrentResponseBoundary: + """Collect one response using only SDK lifecycle position and exact object identity.""" + processed_items = tuple(processed_response.new_items) if processed_response is not None else () + supplied_items = tuple(new_items) + supplied_start = _identity_sequence_start(supplied_items, processed_items) + response_items = ( + supplied_items[supplied_start:] if supplied_start is not None else supplied_items + ) + generated_start = None + session_start = None + proven = run_state is None or not processed_items or supplied_start is not None + suffixes: list[RunItem] = [] + if run_state is not None: + anchor_items = processed_items or response_items + if anchor_items: + generated_start = _identity_sequence_start(run_state._generated_items, anchor_items) + session_start = _identity_sequence_start(run_state._session_items, anchor_items) + if generated_start is not None: + suffixes.extend(run_state._generated_items[generated_start:]) + proven = True + if session_start is not None: + suffixes.extend(run_state._session_items[session_start:]) + proven = True + if generated_start is None and session_start is None and run_state._current_turn == 1: + current_response_prefix = tuple(run_state._generated_items[: len(processed_items)]) + if len(current_response_prefix) == len(processed_items) and all( + type(actual) is type(expected) + for actual, expected in zip(current_response_prefix, processed_items, strict=False) + ): + # Serialization rebuilds item identities, but turn one has no accepted prefix. + processed_items = current_response_prefix + generated_start = 0 + session_start = 0 + suffixes.extend(run_state._generated_items) + suffixes.extend(run_state._session_items) + proven = True + + current_items: list[RunItem] = [] + seen: set[int] = set() + for item in (*processed_items, *suffixes, *response_items): + if id(item) in seen: + continue + seen.add(id(item)) + current_items.append(item) + return _CurrentResponseBoundary( + items=tuple(current_items), + processed_items=processed_items, + generated_start=generated_start, + session_start=session_start, + proven=proven, + ) + + +def _current_response_items_for_persistence( + items: Sequence[RunItem], + processed_response: ProcessedResponse | None, + run_state: RunState[Any] | None = None, +) -> list[RunItem]: + """Return the complete current response or fail before using an ambiguous boundary.""" + boundary = _current_response_boundary(items, processed_response, run_state) + if not boundary.proven: + raise UserError( + "Cannot persist an ambiguous resumed response with output guardrails. " + "Start a new run from safe input." + ) + return list(boundary.items) + + +def _final_turn_items_for_persistence( + items: Sequence[RunItem], + processed_response: ProcessedResponse | None, + run_state: RunState[Any] | None, + agent: Agent[Any], + run_config: RunConfig, +) -> list[RunItem]: + """Use released resumed suffix persistence unless output guardrails defer the response.""" + if not _has_output_guardrails(agent, run_config): + return list(items) + return _current_response_items_for_persistence(items, processed_response, run_state) + + +def _is_terminal_tool_output_response( + items: Sequence[RunItem], + processed_response: ProcessedResponse | None, + run_state: RunState[Any] | None = None, +) -> bool: + """Return whether the structurally owned current response produced a tool final output.""" + boundary = _current_response_boundary(items, processed_response, run_state) + return boundary.proven and any(isinstance(item, ToolCallOutputItem) for item in boundary.items) + + +def _prepare_blocked_output_snapshot( + boundary: _CurrentResponseBoundary, + model_response: ModelResponse | None, +) -> _BlockedOutputSnapshot: + """Build an allowlist-only function call/output snapshot before changing live state.""" + current_items = list(boundary.items) + if any(item.type == "reasoning_item" for item in current_items): + raise AgentsException("Cannot sanitize a response containing reasoning items.") + retained_indexes = { + index for index, item in enumerate(current_items) if item.type in _SIDE_EFFECT_ITEM_TYPES + } + replacements: dict[int, RunItem] = {} + calls_by_id: dict[str, int] = {} + outputs_by_id: dict[str, int] = {} + for index in sorted(retained_indexes): + item = current_items[index] + if isinstance(item, ToolCallItem): + payload = blocked_function_call_payload(item.raw_item) + call_id = cast(str, payload["call_id"]) + if call_id in calls_by_id: + raise AgentsException("Cannot sanitize duplicate function calls.") + calls_by_id[call_id] = index + replacements[index] = ToolCallItem( + agent=item.agent, + raw_item=cast(Any, payload), + description=item.description, + title=item.title, + tool_origin=item.tool_origin, + _resolved_tool_name=item._resolved_tool_name, + ) + elif isinstance(item, ToolCallOutputItem): + payload = blocked_function_output_payload(item.raw_item) + call_id = cast(str, payload["call_id"]) + if call_id in outputs_by_id: + raise AgentsException("Cannot sanitize duplicate function outputs.") + outputs_by_id[call_id] = index + replacements[index] = ToolCallOutputItem( + agent=item.agent, + raw_item=cast(Any, payload), + output=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + tool_origin=item.tool_origin, + custom_data=None, + ) + else: + raise AgentsException("Cannot sanitize an unsupported side-effect item.") + + if not outputs_by_id or set(outputs_by_id) - set(calls_by_id): + raise AgentsException("Cannot sanitize an incomplete function call/output batch.") + retained_indexes = { + index + for call_id in outputs_by_id + for index in (calls_by_id[call_id], outputs_by_id[call_id]) + } + retained_items = tuple(replacements[index] for index in sorted(retained_indexes)) + processed_indexes = {id(item): index for index, item in enumerate(current_items)} + retained_processed_items = tuple( + replacements.get(processed_indexes[id(item)], item) + for item in boundary.processed_items + if processed_indexes.get(id(item)) in retained_indexes + ) + sanitized_response = None + if model_response is not None: + sanitized_response = ModelResponse( + output=cast(Any, [item.raw_item for item in retained_processed_items]), + usage=model_response.usage, + response_id=model_response.response_id, + request_id=model_response.request_id, + raw_usage=model_response.raw_usage, + ) + return _BlockedOutputSnapshot( + items=retained_items, + processed_items=retained_processed_items, + model_response=sanitized_response, + ) + + +def _blocked_output_owner_prefix(items: list[_OwnerItemT], start: int | None) -> list[_OwnerItemT]: + """Copy a structurally captured prefix without consulting item values or identities.""" + if start is None or start < 0 or start > len(items): + return [] + return list.__getitem__(items, slice(0, start)) + + +def _blocked_output_failure_items( + items: list[RunItem], + retained_items: Sequence[RunItem], + owner_starts: _BlockedOutputOwnerStarts, +) -> list[RunItem]: + """Build the non-streamed accepted prefix plus the data-free current response.""" + return [ + *_blocked_output_owner_prefix(items, owner_starts.nonstreamed_session_items), + *retained_items, + ] + + +def _prepare_blocked_output_owner_prefixes( + run_state: RunState[Any] | None, + streamed_result: RunResultStreaming | None, + owner_starts: _BlockedOutputOwnerStarts, +) -> _BlockedOutputOwnerPrefixes: + """Allocate every accepted owner prefix before snapshot application begins.""" + return _BlockedOutputOwnerPrefixes( + run_state_generated_items=( + _blocked_output_owner_prefix( + run_state._generated_items, + owner_starts.run_state_generated_items, + ) + if run_state is not None + else [] + ), + run_state_session_items=( + _blocked_output_owner_prefix( + run_state._session_items, + owner_starts.run_state_session_items, + ) + if run_state is not None + else [] + ), + run_state_model_responses=( + _blocked_output_owner_prefix( + run_state._model_responses, + owner_starts.run_state_model_responses, + ) + if run_state is not None + else [] + ), + run_state_tool_output_guardrail_results=( + _blocked_output_owner_prefix( + run_state._tool_output_guardrail_results, + owner_starts.run_state_tool_output_guardrail_results, + ) + if run_state is not None + else [] + ), + streamed_new_items=( + _blocked_output_owner_prefix( + streamed_result.new_items, + owner_starts.streamed_new_items, + ) + if streamed_result is not None + else [] + ), + streamed_model_input_items=( + _blocked_output_owner_prefix( + streamed_result._model_input_items, + owner_starts.streamed_model_input_items, + ) + if streamed_result is not None + else [] + ), + streamed_raw_responses=( + _blocked_output_owner_prefix( + streamed_result.raw_responses, + owner_starts.streamed_raw_responses, + ) + if streamed_result is not None + else [] + ), + streamed_tool_output_guardrail_results=( + _blocked_output_owner_prefix( + streamed_result.tool_output_guardrail_results, + owner_starts.streamed_tool_output_guardrail_results, + ) + if streamed_result is not None + else [] + ), + ) + + +def _prepare_blocked_output_cleanup_plan( + run_state: RunState[Any] | None, + streamed_result: RunResultStreaming | None, + prefixes: _BlockedOutputOwnerPrefixes, +) -> _BlockedOutputOwnerPlan: + """Prepare accepted-prefix cleanup containers before snapshot application begins.""" + assignments: list[tuple[Any, str, Any]] = [] + if run_state is not None: + assignments.extend( + [ + (run_state, "_generated_items", prefixes.run_state_generated_items), + (run_state, "_session_items", prefixes.run_state_session_items), + (run_state, "_model_responses", prefixes.run_state_model_responses), + (run_state, "_last_processed_response", None), + (run_state, "_current_step", None), + (run_state, "_generated_items_last_processed_marker", None), + ( + run_state, + "_tool_output_guardrail_results", + prefixes.run_state_tool_output_guardrail_results, + ), + ] + ) + if streamed_result is not None: + assignments.extend( + [ + (streamed_result, "new_items", prefixes.streamed_new_items), + (streamed_result, "raw_responses", prefixes.streamed_raw_responses), + ( + streamed_result, + "_model_input_items", + prefixes.streamed_model_input_items, + ), + (streamed_result, "_last_processed_response", None), + ( + streamed_result, + "tool_output_guardrail_results", + prefixes.streamed_tool_output_guardrail_results, + ), + ] + ) + return _BlockedOutputOwnerPlan(assignments=tuple(assignments)) + + +def _sever_blocked_output_replay_graph(cleanup_plan: _BlockedOutputOwnerPlan) -> None: + """Best-effort leaf cleanup using only containers allocated before application.""" + for owner, field, value in cleanup_plan.assignments: + try: + object.__setattr__(owner, field, value) + except BaseException: + continue + + +def _data_free_tool_output_guardrail_results( + results: Sequence[ToolOutputGuardrailResult], +) -> tuple[ToolOutputGuardrailResult, ...]: + """Rebuild current-turn tool guardrail results without retaining caller output data.""" + replacements: list[ToolOutputGuardrailResult] = [] + try: + for result in results: + if not isinstance(result, ToolOutputGuardrailResult): + return () + original_output = object.__getattribute__(result, "output") + behavior = object.__getattribute__(original_output, "behavior") + if type(behavior) is not dict: + return () + behavior_type = _exact_dict_field(behavior, "type") + if type(behavior_type) is not str: + return () + if str.__eq__(behavior_type, "allow") is True: + sanitized_output = ToolGuardrailFunctionOutput.allow( + output_info=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + ) + elif str.__eq__(behavior_type, "reject_content") is True: + sanitized_output = ToolGuardrailFunctionOutput.reject_content( + message=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + output_info=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + ) + elif str.__eq__(behavior_type, "raise_exception") is True: + sanitized_output = ToolGuardrailFunctionOutput.raise_exception( + output_info=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + ) + else: + return () + replacements.append( + ToolOutputGuardrailResult( + guardrail=object.__getattribute__(result, "guardrail"), + output=sanitized_output, + ) + ) + except Exception: + return () + return tuple(replacements) + + +def _prepare_blocked_output_owner_plan( + boundary: _CurrentResponseBoundary, + snapshot: _BlockedOutputSnapshot | None, + model_response: ModelResponse | None, + run_state: RunState[Any] | None, + streamed_result: RunResultStreaming | None, + prefixes: _BlockedOutputOwnerPrefixes, + cleanup_plan: _BlockedOutputOwnerPlan, +) -> _BlockedOutputOwnerPlan: + """Build every owner replacement before applying any of them.""" + safe_items = list(snapshot.items) if snapshot is not None else [] + safe_response = snapshot.model_response if snapshot is not None else None + assignments: list[tuple[Any, str, Any]] = [] + if streamed_result is not None: + public_results = streamed_result.tool_output_guardrail_results + current_results = list.__getitem__( + public_results, + slice(len(prefixes.streamed_tool_output_guardrail_results), None), + ) + elif run_state is not None: + current_results = list.__getitem__( + run_state._tool_output_guardrail_results, + slice(len(prefixes.run_state_tool_output_guardrail_results), None), + ) + else: + current_results = [] + safe_tool_output_guardrail_results = _data_free_tool_output_guardrail_results(current_results) + if streamed_result is not None: + public_safe_results = [ + *prefixes.streamed_tool_output_guardrail_results, + *safe_tool_output_guardrail_results, + ] + else: + public_safe_results = [] + + if run_state is not None: + if boundary.proven: + responses = [ + *prefixes.run_state_model_responses, + *( + [safe_response] + if model_response is not None and safe_response is not None + else [] + ), + ] + run_state_safe_results = [ + *prefixes.run_state_tool_output_guardrail_results, + *safe_tool_output_guardrail_results, + ] + assignments.extend( + [ + ( + run_state, + "_generated_items", + [*prefixes.run_state_generated_items, *safe_items], + ), + ( + run_state, + "_session_items", + [*prefixes.run_state_session_items, *safe_items], + ), + (run_state, "_model_responses", responses), + (run_state, "_last_processed_response", None), + (run_state, "_current_step", None), + (run_state, "_generated_items_last_processed_marker", None), + (run_state, "_tool_output_guardrail_results", run_state_safe_results), + ] + ) + else: + return cleanup_plan + + if streamed_result is not None: + responses = [ + *prefixes.streamed_raw_responses, + *([safe_response] if model_response is not None and safe_response is not None else []), + ] + assignments.extend( + [ + ( + streamed_result, + "new_items", + [*prefixes.streamed_new_items, *safe_items], + ), + ( + streamed_result, + "_model_input_items", + [*prefixes.streamed_model_input_items, *safe_items], + ), + (streamed_result, "raw_responses", responses), + (streamed_result, "_last_processed_response", None), + ( + streamed_result, + "tool_output_guardrail_results", + public_safe_results, + ), + ] + ) + return _BlockedOutputOwnerPlan(assignments=tuple(assignments)) + + +def _apply_blocked_output_owner_plan(plan: _BlockedOutputOwnerPlan) -> None: + """Apply only values that were fully constructed before the first owner swap.""" + for owner, field, value in plan.assignments: + object.__setattr__(owner, field, value) + + +def _retained_items_for_blocked_response( + items: list[RunItem], + model_response: ModelResponse | None, + run_state: RunState[Any] | None = None, + processed_response: ProcessedResponse | None = None, + streamed_result: RunResultStreaming | None = None, + owner_starts: _BlockedOutputOwnerStarts | None = None, +) -> list[RunItem]: + """Return a complete data-free response or discard the entire unsupported suffix.""" + boundary = _current_response_boundary(items, processed_response, run_state) + prefixes = _prepare_blocked_output_owner_prefixes( + run_state, + streamed_result, + owner_starts if owner_starts is not None else _BlockedOutputOwnerStarts(), + ) + cleanup_plan = _prepare_blocked_output_cleanup_plan(run_state, streamed_result, prefixes) + snapshot: _BlockedOutputSnapshot | None = None + try: + if boundary.proven: + snapshot = _prepare_blocked_output_snapshot(boundary, model_response) + except Exception: + snapshot = None + except BaseException: + _sever_blocked_output_replay_graph(cleanup_plan) + raise + try: + owner_plan = _prepare_blocked_output_owner_plan( + boundary, + snapshot, + model_response, + run_state, + streamed_result, + prefixes, + cleanup_plan, + ) + _apply_blocked_output_owner_plan(owner_plan) + except Exception as error: + _sever_blocked_output_replay_graph(cleanup_plan) + raise _prepare_data_redacted_error(error) from None + except BaseException: + _sever_blocked_output_replay_graph(cleanup_plan) + raise + return list(snapshot.items) if snapshot is not None else [] diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 90a1320a84..6125e56d8f 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -84,6 +84,7 @@ Tool, dispose_resolved_computers, ) +from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult from ..tracing import Span, SpanError, agent_span, get_current_trace, task_span, turn_span from ..tracing.config import include_task_and_turn_spans from ..tracing.model_tracing import get_model_tracing_impl @@ -103,8 +104,22 @@ get_unsent_tool_call_ids_for_interrupted_state, snapshot_usage, usage_delta, + validate_output_guardrails_with_server_managed_conversation, ) from .approvals import approvals_from_step +from .blocked_output import ( + OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + _BlockedOutputOwnerStarts, + _current_response_boundary, + _final_turn_items_for_persistence, + _has_output_guardrails, + _is_terminal_tool_output_response, + _retained_items_for_blocked_response, + _sanitize_blocked_output_guardrail_results, + _should_defer_interrupted_session_items, + _synchronize_accepted_run_state, + _validate_resumed_session_output_guardrail_safety, +) from .error_handlers import ( attach_generic_agent_error, build_run_error_data, @@ -273,6 +288,7 @@ "input_guardrail_tripwire_triggered_for_stream", ] +_OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT = OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT _STREAM_EVENT_ITEM_OCCURRENCE_KEY = "_agents_stream_event_item_occurrence_key" @@ -450,70 +466,23 @@ async def _run_output_guardrails_for_stream( # Publish at a single boundary so no failure path can omit results that already # finished. A guardrail raising a non-tripwire error reports the same completed # results a tripwire does. + if not isinstance(exc, OutputGuardrailTripwireTriggered): + log_model_action_error(logger, "Unexpected error in output guardrails", exc) streamed_result.output_guardrail_results = ( streamed_result.output_guardrail_results + completed_results ) - if not isinstance(exc, OutputGuardrailTripwireTriggered): - log_model_action_error(logger, "Unexpected error in output guardrails", exc) raise -_SIDE_EFFECT_ITEM_TYPES = frozenset({"tool_call_item", "tool_call_output_item"}) - - -def _reasoning_indexes_tied_to_retained_items( +def _retained_items_for_blocked_output( items: list[RunItem], - retained_indexes: set[int], -) -> set[int]: - """Indexes of the reasoning items whose tied item is being retained. - - Applies the same association rule as - ``agents.run_internal.items._drop_reasoning_items_preceding_dropped_calls``: a reasoning item - is tied to the next *non-reasoning* model-emitted item. Keeping a group whose following item is - dropped would leave a dangling reasoning item, which the Responses API rejects on the next - request (``reasoning was provided without its required following item``); dropping a group - whose following item is retained would strip the context that call needs to be replayed. - - A trailing reasoning group - one with no following non-reasoning item at all - is not tied to - anything retained, so it is dropped. Note this is stricter than the reference, which keeps such - a group because the item it belongs to may still arrive later in a longer history; here the - turn is complete, so there is nothing left to tie it to. - """ - tied: set[int] = set() - for index in range(len(items) - 1, -1, -1): - if items[index].type != "reasoning_item": - continue - for next_index in range(index + 1, len(items)): - if items[next_index].type == "reasoning_item": - continue - if next_index in retained_indexes: - tied.add(index) - break - return tied - - -def _retained_items_for_blocked_output(items: list[RunItem]) -> list[RunItem]: - """Pick out the items of a final turn to keep when its output is not deliverable. - - A tool that already ran has to stay in the session, together with the context needed to replay - its call. Everything else - the assistant message the guardrail rejected above all - is dropped, - including the reasoning that belongs to the rejected message rather than to a retained call. - - ``_SIDE_EFFECT_ITEM_TYPES`` is enumerated rather than derived, so an item type added later is - *discarded* here by default and has to be classified deliberately. A record of a side effect - that goes unclassified is a bug, so the safer default is the one that surfaces as a missing item - rather than as a rejected message quietly reaching the session. - """ - retained_indexes = { - index for index, item in enumerate(items) if item.type in _SIDE_EFFECT_ITEM_TYPES - } - if not retained_indexes: - return [] - # Reasoning items are not side effects themselves, but a reasoning model requires the reasoning - # item tied to a function call to accompany it in the next request. - retained_indexes |= _reasoning_indexes_tied_to_retained_items(items, retained_indexes) - # Indexed rather than filtered by type so the retained items keep the model's own order. - return [item for index, item in enumerate(items) if index in retained_indexes] + model_response: ModelResponse | None = None, +) -> list[RunItem]: + """Return trusted retained items without consulting earlier provider identities.""" + return _retained_items_for_blocked_response( + items, + model_response, + ) async def _finalize_streamed_final_output( @@ -525,17 +494,15 @@ async def _finalize_streamed_final_output( context_wrapper: RunContextWrapper[TContext], save_items: Callable[[list[RunItem], str | None, bool | None], Awaitable[None]], items: list[RunItem], + model_response: ModelResponse | None, + processed_response: ProcessedResponse | None, + owner_starts: _BlockedOutputOwnerStarts, response_id: str | None, store_setting: bool | None, - persist_before_output_guardrails: bool, on_persisted_after_guardrails: Callable[[bool], None] | None = None, ) -> None: + output_guardrail_result_start = len(streamed_result.output_guardrail_results) redacted_persistence_error: BaseException | None = None - if persist_before_output_guardrails: - # A resumed approval has already committed the tool side effect, so keep its call/output - # pair even when an agent output guardrail blocks delivery of the final result. - await save_items(items, response_id, store_setting) - try: output_guardrail_results = await _run_output_guardrails_for_stream( agent=agent, @@ -544,62 +511,85 @@ async def _finalize_streamed_final_output( context_wrapper=context_wrapper, streamed_result=streamed_result, ) - except OutputGuardrailTripwireTriggered: + except OutputGuardrailTripwireTriggered as exc: + if not _is_terminal_tool_output_response( + items, + processed_response, + streamed_result._state, + ): + raise # The blocked output itself is not persisted, but a tool that already ran is: the next run # has to see that side effect rather than re-issue it. This turn reaches here with tool # items when `tool_use_behavior="stop_on_first_tool"` (or `stop_at_tool_names`, or a custom # callable) turned a tool result straight into the final output. - if not persist_before_output_guardrails: - retained_items = _retained_items_for_blocked_output(items) - if retained_items: + sanitized_results = _sanitize_blocked_output_guardrail_results( + streamed_result.output_guardrail_results[output_guardrail_result_start:], + exc, + ) + streamed_result.output_guardrail_results = [ + *streamed_result.output_guardrail_results[:output_guardrail_result_start], + *sanitized_results, + ] + retained_items = _retained_items_for_blocked_response( + items, + model_response, + streamed_result._state, + processed_response, + streamed_result, + owner_starts, + ) + if retained_items: + try: await save_items(retained_items, response_id, store_setting) + except BaseException as persistence_error: + safe_error = _safe_redacted_persistence_error(persistence_error) + if ( + isinstance(persistence_error, asyncio.CancelledError) + and streamed_result._cancel_mode != "immediate" + ): + streamed_result._stored_exception = safe_error + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + return + raise safe_error from None raise except Exception as guardrail_error: - # Only a tripwire means the output was judged undeliverable. A guardrail error leaves the - # verdict unknown, so the completed final turn is persisted whole and remains replayable. - # `asyncio.CancelledError` is deliberately not caught here: `cancel()` in its default - # immediate mode has to stay prompt, and awaiting a session write would block - # `stream_events()` on an arbitrary backend. `after_turn` is the mode that finishes the - # turn and saves. guardrail_error_is_redacted = _is_error_data_redacted(guardrail_error) if guardrail_error_is_redacted: _detach_data_redacted_error_traceback(guardrail_error) - if not persist_before_output_guardrails: - try: - await save_items(items, response_id, store_setting) - except BaseException as persistence_error: - if guardrail_error_is_redacted: - safe_persistence_error = _safe_redacted_persistence_error(persistence_error) - if ( - isinstance(safe_persistence_error, asyncio.CancelledError) - and streamed_result._cancel_mode != "immediate" - ): - # A cancelled session write is distinct from the caller requesting - # immediate cancellation. Retain a safe cancellation for `stream_events()` - # without completing the run-loop task with the payload-bearing backend - # exception. - streamed_result._stored_exception = safe_persistence_error - streamed_result.is_complete = True - streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) - return - if isinstance(safe_persistence_error, asyncio.CancelledError): - # Public immediate cancellation already owns stream completion and must - # not surface a recovery failure. - return - redacted_persistence_error = safe_persistence_error + try: + final_turn_items = _final_turn_items_for_persistence( + items, + processed_response, + streamed_result._state, + agent, + run_config, + ) + await save_items(final_turn_items, response_id, store_setting) + except BaseException as persistence_error: + if guardrail_error_is_redacted: + safe_persistence_error = _safe_redacted_persistence_error(persistence_error) if ( - isinstance(persistence_error, asyncio.CancelledError) + isinstance(safe_persistence_error, asyncio.CancelledError) and streamed_result._cancel_mode != "immediate" ): - # A cancelled session write is distinct from the caller requesting immediate - # cancellation. The run-loop task itself becomes cancelled, so retain the - # backend cancellation for `stream_events()` to surface. - streamed_result._stored_exception = persistence_error - if redacted_persistence_error is None: - raise - else: - if on_persisted_after_guardrails is not None: - on_persisted_after_guardrails(False) + streamed_result._stored_exception = safe_persistence_error + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + return + if isinstance(safe_persistence_error, asyncio.CancelledError): + return + redacted_persistence_error = safe_persistence_error + if ( + isinstance(persistence_error, asyncio.CancelledError) + and streamed_result._cancel_mode != "immediate" + ): + streamed_result._stored_exception = persistence_error + if redacted_persistence_error is None: + raise + else: + if on_persisted_after_guardrails is not None: + on_persisted_after_guardrails(False) if redacted_persistence_error is None: raise @@ -607,23 +597,29 @@ async def _finalize_streamed_final_output( raise redacted_persistence_error from None streamed_result.output_guardrail_results.extend(output_guardrail_results) + final_turn_items = _final_turn_items_for_persistence( + items, + processed_response, + streamed_result._state, + agent, + run_config, + ) - if not persist_before_output_guardrails: - # Saved as one ordered batch so the session mirrors the model response. Doing it in two - # halves would both reorder the turn and, because the first save advances the turn's - # persisted-item count, make the second one a no-op. - if on_persisted_after_guardrails is None: - await save_items(items, response_id, store_setting) - else: - try: - await save_items(items, response_id, store_setting) - except asyncio.CancelledError as persistence_error: - if streamed_result._cancel_mode == "immediate": - raise - streamed_result._stored_exception = persistence_error - streamed_result.is_complete = True - streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) - return + # Saved as one ordered batch so the session mirrors the model response. Doing it in two + # halves would both reorder the turn and, because the first save advances the turn's + # persisted-item count, make the second one a no-op. + if on_persisted_after_guardrails is None: + await save_items(final_turn_items, response_id, store_setting) + else: + try: + await save_items(final_turn_items, response_id, store_setting) + except asyncio.CancelledError as persistence_error: + if streamed_result._cancel_mode == "immediate": + raise + streamed_result._stored_exception = persistence_error + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + return streamed_result.final_output = output if on_persisted_after_guardrails is not None: @@ -808,6 +804,9 @@ async def _persist_stream_input_if_needed( def _accumulate_tool_guardrail_results( streamed_result: RunResultStreaming, turn_result: SingleStepResult, + *, + accepted_input_results: list[ToolInputGuardrailResult], + accepted_output_results: list[ToolOutputGuardrailResult], ) -> None: """Carry a turn's tool guardrail results onto the streamed result. @@ -820,6 +819,9 @@ def _accumulate_tool_guardrail_results( streamed_result.tool_output_guardrail_results = ( streamed_result.tool_output_guardrail_results + turn_result.tool_output_guardrail_results ) + if isinstance(turn_result.next_step, NextStepRunAgain | NextStepHandoff): + accepted_input_results.extend(turn_result.tool_input_guardrail_results) + accepted_output_results.extend(turn_result.tool_output_guardrail_results) async def _finalize_streamed_interruption( @@ -970,10 +972,26 @@ def _sync_conversation_tracking_from_tracker() -> None: current_agent = run_state._current_agent else: current_agent = starting_agent + _validate_resumed_session_output_guardrail_safety( + agent=current_agent, + run_config=run_config, + session=session, + run_state=run_state if is_resumed_state else None, + ) + if run_state is not None and session is None: + streamed_result._current_turn_persisted_item_count = ( + run_state._current_turn_persisted_item_count + ) if run_state is not None: current_turn = run_state._current_turn else: current_turn = 0 + accepted_tool_input_guardrail_results = ( + list(run_state._tool_input_guardrail_results) if run_state is not None else [] + ) + accepted_tool_output_guardrail_results = ( + list(run_state._tool_output_guardrail_results) if run_state is not None else [] + ) should_run_agent_start_hooks = True tool_use_tracker = AgentToolUseTracker() if run_state is not None: @@ -1146,6 +1164,13 @@ async def _save_max_turns_items( try: while True: + validate_output_guardrails_with_server_managed_conversation( + current_agent, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) all_input_guardrails = ( starting_agent.input_guardrails + (run_config.input_guardrails or []) if current_turn == 0 and not is_resumed_state @@ -1227,6 +1252,25 @@ async def _save_max_turns_items( raise UserError("No processed response found in previous state") last_model_response = run_state._model_responses[-1] + resumed_response_boundary = _current_response_boundary( + (), + run_state._last_processed_response, + run_state, + ) + blocked_output_owner_starts = _BlockedOutputOwnerStarts( + run_state_generated_items=resumed_response_boundary.generated_start, + run_state_session_items=resumed_response_boundary.session_start, + run_state_model_responses=len(run_state._model_responses) - 1, + run_state_tool_output_guardrail_results=len( + run_state._tool_output_guardrail_results + ), + streamed_new_items=resumed_response_boundary.session_start, + streamed_model_input_items=resumed_response_boundary.generated_start, + streamed_raw_responses=len(streamed_result.raw_responses) - 1, + streamed_tool_output_guardrail_results=len( + streamed_result.tool_output_guardrail_results + ), + ) turn_result = await resolve_interrupted_turn( bindings=current_bindings, @@ -1299,13 +1343,25 @@ async def _save_max_turns_items( # but skips a resumed turn that loops back to the model, so a guardrail that # re-runs for the same tool call on resume is not counted twice. if not isinstance(turn_result.next_step, NextStepRunAgain): - _accumulate_tool_guardrail_results(streamed_result, turn_result) + _accumulate_tool_guardrail_results( + streamed_result, + turn_result, + accepted_input_results=accepted_tool_input_guardrail_results, + accepted_output_results=accepted_tool_output_guardrail_results, + ) if isinstance(turn_result.next_step, NextStepInterruption): await _finalize_streamed_interruption( streamed_result=streamed_result, save_items=_save_resumed_items, - items=list(turn_session_items), + items=( + [] + if _should_defer_interrupted_session_items( + current_agent, + run_config, + ) + else list(turn_session_items) + ), response_id=turn_result.model_response.response_id, store_setting=store_setting, interruptions=approvals_from_step(turn_result.next_step), @@ -1346,10 +1402,18 @@ async def _save_max_turns_items( context_wrapper=context_wrapper, save_items=_save_resumed_items, items=list(turn_session_items), + model_response=turn_result.model_response, + processed_response=( + turn_result.processed_response + if turn_result.processed_response is not None + else run_state._last_processed_response + ), + owner_starts=blocked_output_owner_starts, response_id=turn_result.model_response.response_id, store_setting=store_setting, - persist_before_output_guardrails=True, ) + if streamed_result._stored_exception is not None: + break run_state._current_step = None break @@ -1536,11 +1600,36 @@ def _record_max_turns_handler_output( context_wrapper=context_wrapper, save_items=_save_max_turns_items, items=[synthesized_item] if include_in_history else [], + model_response=None, + processed_response=None, + owner_starts=_BlockedOutputOwnerStarts( + run_state_generated_items=( + len(run_state._generated_items) if run_state is not None else None + ), + run_state_session_items=( + len(run_state._session_items) if run_state is not None else None + ), + run_state_model_responses=( + len(run_state._model_responses) if run_state is not None else None + ), + run_state_tool_output_guardrail_results=( + len(run_state._tool_output_guardrail_results) + if run_state is not None + else None + ), + streamed_new_items=len(streamed_result.new_items), + streamed_model_input_items=len(streamed_result._model_input_items), + streamed_raw_responses=len(streamed_result.raw_responses), + streamed_tool_output_guardrail_results=len( + streamed_result.tool_output_guardrail_results + ), + ), response_id=None, store_setting=store_setting, - persist_before_output_guardrails=False, on_persisted_after_guardrails=_record_max_turns_handler_output, ) + if streamed_result._stored_exception is not None: + break streamed_result._max_turns_handled = True streamed_result.current_turn = max_turns if run_state is not None and not is_resumed_state: @@ -1588,6 +1677,39 @@ def _record_max_turns_handler_output( ) ) try: + if run_state is not None and _has_output_guardrails(current_agent, run_config): + _synchronize_accepted_run_state( + run_state, + generated_items=streamed_result._model_input_items, + session_items=streamed_result.new_items, + model_responses=streamed_result.raw_responses, + tool_input_guardrail_results=accepted_tool_input_guardrail_results, + tool_output_guardrail_results=accepted_tool_output_guardrail_results, + current_turn=current_turn, + ) + + blocked_output_owner_starts = _BlockedOutputOwnerStarts( + run_state_generated_items=( + len(run_state._generated_items) if run_state is not None else None + ), + run_state_session_items=( + len(run_state._session_items) if run_state is not None else None + ), + run_state_model_responses=( + len(run_state._model_responses) if run_state is not None else None + ), + run_state_tool_output_guardrail_results=( + len(run_state._tool_output_guardrail_results) + if run_state is not None + else None + ), + streamed_new_items=len(streamed_result.new_items), + streamed_model_input_items=len(streamed_result._model_input_items), + streamed_raw_responses=len(streamed_result.raw_responses), + streamed_tool_output_guardrail_results=len( + streamed_result.tool_output_guardrail_results + ), + ) logger.debug( "Starting turn %s, current_agent=%s", current_turn, @@ -1660,7 +1782,12 @@ def _record_max_turns_handler_output( streamed_result.raw_responses = streamed_result.raw_responses + [ turn_result.model_response ] - _accumulate_tool_guardrail_results(streamed_result, turn_result) + _accumulate_tool_guardrail_results( + streamed_result, + turn_result, + accepted_input_results=accepted_tool_input_guardrail_results, + accepted_output_results=accepted_tool_output_guardrail_results, + ) input_before_turn_rewrite = streamed_result.input streamed_result.input = turn_result.original_input if isinstance(turn_result.next_step, NextStepHandoff): @@ -1738,10 +1865,14 @@ def _record_max_turns_handler_output( context_wrapper=context_wrapper, save_items=_save_stream_items_with_count, items=turn_session_items, + model_response=turn_result.model_response, + processed_response=turn_result.processed_response, + owner_starts=blocked_output_owner_starts, response_id=turn_result.model_response.response_id, store_setting=store_setting, - persist_before_output_guardrails=False, ) + if streamed_result._stored_exception is not None: + break if run_state is not None: run_state._current_step = None break @@ -1763,7 +1894,14 @@ def _record_max_turns_handler_output( await _finalize_streamed_interruption( streamed_result=streamed_result, save_items=_save_stream_items_with_count, - items=turn_session_items, + items=( + [] + if _should_defer_interrupted_session_items( + current_agent, + run_config, + ) + else turn_session_items + ), response_id=turn_result.model_response.response_id, store_setting=store_setting, interruptions=approvals_from_step(turn_result.next_step), diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 8f33883012..f822fefea0 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -14,10 +14,12 @@ import pytest from openai import APIConnectionError, BadRequestError, NotFoundError from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call import CallerDirect, CallerProgram from openai.types.responses.response_output_item import McpApprovalRequest from openai.types.responses.response_output_text import AnnotationFileCitation, ResponseOutputText from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from openai.types.responses.tool_param import Mcp +from pydantic import BaseModel from typing_extensions import TypedDict import agents._debug as _debug @@ -29,6 +31,7 @@ HandoffInputData, InputGuardrail, InputGuardrailTripwireTriggered, + MaxTurnsExceeded, ModelBehaviorError, ModelRetryAdvice, ModelRetrySettings, @@ -46,12 +49,14 @@ ToolGuardrailFunctionOutput, ToolInputGuardrailData, ToolNameCollisionPolicy, + ToolOutputGuardrailData, ToolTimeoutError, UserError, handoff, retry_policies, tool_input_guardrail, tool_namespace, + tool_output_guardrail, ) from agents._tool_identity import resolve_tool_name_collisions from agents.agent import ToolsToFinalOutputResult @@ -69,8 +74,10 @@ from agents.lifecycle import RunHooks from agents.memory import SessionSettings from agents.models.fake_id import FAKE_RESPONSES_ID +from agents.result import RunResultStreaming from agents.run import AgentRunner, get_default_agent_runner, set_default_agent_runner from agents.run_config import _default_trace_include_sensitive_data +from agents.run_internal import blocked_output, run_loop from agents.run_internal.agent_bindings import bind_public_agent from agents.run_internal.agent_runner_helpers import build_resumed_stream_debug_extra from agents.run_internal.items import ( @@ -124,6 +131,835 @@ def to_input_item(self) -> dict[str, Any]: return self._payload +@pytest.mark.parametrize("arguments", ["{}", ""], ids=["json-object", "empty"]) +def test_blocked_function_batch_is_rebuilt_from_allowlisted_fields(arguments: str) -> None: + agent = Agent(name="test") + call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "commit_tool", + "arguments": arguments, + "call_id": "call-commit", + "provider_data": {"secret": "call-secret"}, + }, + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + "provider_data": {"secret": "output-secret"}, + }, + output="sdk-secret", + custom_data={"secret": "custom-secret"}, + ) + + retained = run_loop._retained_items_for_blocked_output([call, output]) + + assert [item.type for item in retained] == ["tool_call_item", "tool_call_output_item"] + retained_call = cast(ToolCallItem, retained[0]) + retained_output = cast(ToolCallOutputItem, retained[1]) + assert "provider_data" not in cast(dict[str, Any], retained_call.raw_item) + assert cast(dict[str, Any], retained_call.raw_item)["arguments"] == arguments + assert cast(dict[str, Any], retained_output.raw_item) == { + "type": "function_call_output", + "call_id": "call-commit", + "output": run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + } + assert retained_output.output == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert retained_output.custom_data is None + + +def test_blocked_function_batch_accepts_exact_typed_direct_caller() -> None: + agent = Agent(name="test") + call = ToolCallItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="commit_tool", + arguments="{}", + call_id="call-commit", + caller=CallerDirect(type="direct"), + ), + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + ) + + retained = run_loop._retained_items_for_blocked_output([call, output]) + + assert len(retained) == 2 + retained_call = cast(ToolCallItem, retained[0]) + assert cast(dict[str, Any], retained_call.raw_item)["caller"] == {"type": "direct"} + + +def test_blocked_function_batch_ignores_hash_collision_key_hooks() -> None: + equality_calls: list[Any] = [] + + class HashCollisionKey: + def __init__(self, field: str) -> None: + self.field = field + + def __hash__(self) -> int: + return hash(self.field) + + def __eq__(self, other: object) -> bool: + equality_calls.append(other) + return False + + caller: dict[Any, Any] = { + HashCollisionKey("type"): "caller-secret", + "type": "direct", + } + raw_call: dict[Any, Any] = { + HashCollisionKey("type"): "type-secret", + HashCollisionKey("name"): "name-secret", + HashCollisionKey("arguments"): "arguments-secret", + HashCollisionKey("call_id"): "call-id-secret", + HashCollisionKey("id"): "id-secret", + HashCollisionKey("namespace"): "namespace-secret", + HashCollisionKey("status"): "status-secret", + HashCollisionKey("caller"): "caller-secret", + "type": "function_call", + "name": "commit_tool", + "arguments": "{}", + "call_id": "call-commit", + "caller": caller, + } + equality_calls.clear() + agent = Agent(name="test") + call = ToolCallItem(agent=agent, raw_item=cast(Any, raw_call)) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + ) + + retained = run_loop._retained_items_for_blocked_output([call, output]) + + assert len(retained) == 2 + assert equality_calls == [] + retained_call = cast(ToolCallItem, retained[0]) + assert cast(dict[str, Any], retained_call.raw_item)["caller"] == {"type": "direct"} + + +@pytest.mark.parametrize("discriminator", ["call", "output", "caller"]) +def test_blocked_function_batch_rejects_equality_impostor_discriminators_without_hooks( + discriminator: str, +) -> None: + equality_calls: list[Any] = [] + + class EqualityImpostor: + def __eq__(self, other: object) -> bool: + equality_calls.append(other) + return True + + raw_call: dict[str, Any] = { + "type": EqualityImpostor() if discriminator == "call" else "function_call", + "name": "commit_tool", + "arguments": "{}", + "call_id": "call-commit", + } + if discriminator == "caller": + raw_call["caller"] = {"type": EqualityImpostor()} + agent = Agent(name="test") + call = ToolCallItem(agent=agent, raw_item=cast(Any, raw_call)) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": (EqualityImpostor() if discriminator == "output" else "function_call_output"), + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + ) + + assert run_loop._retained_items_for_blocked_output([call, output]) == [] + assert equality_calls == [] + + +def test_blocked_function_batch_rejects_non_direct_typed_callers() -> None: + class GenericCaller(BaseModel): + type: str + + agent = Agent(name="test") + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + ) + for caller in ( + CallerProgram(type="program", caller_id="program-call"), + GenericCaller(type="direct"), + ): + call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "commit_tool", + "arguments": "{}", + "call_id": "call-commit", + "caller": caller, + }, + ) + + assert run_loop._retained_items_for_blocked_output([call, output]) == [] + + +def test_blocked_unknown_tool_variant_discards_the_complete_response() -> None: + agent = Agent(name="test") + call = ToolCallItem( + agent=agent, + raw_item={"type": "custom_tool_call", "call_id": "call-custom", "secret": "call"}, + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "custom_tool_call_output", + "call_id": "call-custom", + "output": "raw-secret", + }, + output="sdk-secret", + custom_data={"secret": "custom-secret"}, + ) + + assert run_loop._retained_items_for_blocked_output([call, output]) == [] + + +def test_blocked_reasoning_item_discards_the_complete_response() -> None: + agent = Agent(name="test") + reasoning = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + id="reasoning-id", + type="reasoning", + summary=[], + encrypted_content="reasoning-secret", + ), + ) + call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "commit_tool", + "arguments": "{}", + "call_id": "call-commit", + }, + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + ) + + assert run_loop._retained_items_for_blocked_output([reasoning, call, output]) == [] + + +def test_blocked_snapshot_preserves_accepted_prefix_with_reused_provider_id() -> None: + agent = Agent(name="test") + prior_call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "prior_tool", + "arguments": "{}", + "call_id": "reused-call-id", + }, + ) + prior_output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "reused-call-id", + "output": "accepted-prior-output", + }, + output="accepted-prior-output", + ) + current_call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "current_tool", + "arguments": "{}", + "call_id": "reused-call-id", + }, + ) + current_output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "reused-call-id", + "output": "rejected-current-output", + }, + output="rejected-current-output", + ) + prior_response = ModelResponse( + output=[cast(Any, prior_call.raw_item)], + usage=Usage(), + response_id="prior-response", + ) + current_response = ModelResponse( + output=[cast(Any, current_call.raw_item)], + usage=Usage(), + response_id="current-response", + ) + state = make_run_state( + agent, + context=make_context_wrapper(), + original_input="test", + max_turns=2, + ) + state._generated_items = [prior_call, prior_output, current_call, current_output] + state._session_items = [prior_call, prior_output, current_call, current_output] + state._model_responses = [prior_response, current_response] + + retained = run_loop._retained_items_for_blocked_response( + [current_call, current_output], + current_response, + run_state=state, + owner_starts=run_loop._BlockedOutputOwnerStarts( + run_state_generated_items=2, + run_state_session_items=2, + run_state_model_responses=1, + run_state_tool_output_guardrail_results=0, + ), + ) + + assert state._generated_items[:2] == [prior_call, prior_output] + assert state._generated_items[0] is prior_call + assert state._generated_items[1] is prior_output + assert state._session_items[:2] == [prior_call, prior_output] + assert state._model_responses[0] is prior_response + assert retained == state._generated_items[2:] + assert cast(ToolCallOutputItem, retained[1]).output == ( + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + ) + assert prior_output.output == "accepted-prior-output" + + +def test_blocked_snapshot_cancellation_severs_replay_graph_and_propagates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent = Agent(name="test") + call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "commit_tool", + "arguments": "{}", + "call_id": "call-commit", + }, + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + custom_data={"secret": "custom-secret"}, + ) + response = ModelResponse( + output=[cast(Any, call.raw_item)], + usage=Usage(), + response_id="response-id", + ) + state = make_run_state( + agent, + context=make_context_wrapper(), + original_input="test", + max_turns=1, + ) + state._generated_items = [call, output] + state._session_items = [call, output] + state._model_responses = [response] + cancellation = asyncio.CancelledError("original cancellation") + + def cancel_preparation(_raw_item: Any) -> dict[str, Any]: + raise cancellation + + monkeypatch.setattr(blocked_output, "blocked_function_output_payload", cancel_preparation) + + with pytest.raises(asyncio.CancelledError) as exc_info: + run_loop._retained_items_for_blocked_response( + [call, output], + response, + run_state=state, + ) + + assert exc_info.value is cancellation + assert state._generated_items == [] + assert state._session_items == [] + assert state._model_responses == [] + + +@pytest.mark.parametrize("fail_after_first_swap", [False, True]) +def test_blocked_snapshot_application_baseexception_severs_every_owner( + monkeypatch: pytest.MonkeyPatch, + fail_after_first_swap: bool, +) -> None: + agent = Agent(name="test") + prior_call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "prior_tool", + "arguments": "{}", + "call_id": "prior-call", + }, + ) + prior_output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "prior-call", + "output": "accepted-prior-output", + }, + output="accepted-prior-output", + ) + call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "commit_tool", + "arguments": "{}", + "call_id": "call-commit", + }, + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + ) + prior_response = ModelResponse( + output=[cast(Any, prior_call.raw_item)], + usage=Usage(), + response_id="prior-response", + ) + response = ModelResponse( + output=[cast(Any, call.raw_item)], + usage=Usage(), + response_id="response-id", + ) + state = make_run_state( + agent, + context=make_context_wrapper(), + original_input="test", + max_turns=1, + ) + prior_guardrail_result = cast(Any, object()) + current_guardrail_result = cast(Any, object()) + state._generated_items = [prior_call, prior_output, call, output] + state._session_items = [prior_call, prior_output, call, output] + state._model_responses = [prior_response, response] + state._tool_output_guardrail_results = [prior_guardrail_result, current_guardrail_result] + streamed_result = RunResultStreaming( + input="test", + new_items=[prior_call, prior_output, call, output], + raw_responses=[prior_response, response], + final_output=None, + input_guardrail_results=[], + output_guardrail_results=[], + tool_input_guardrail_results=[], + tool_output_guardrail_results=[prior_guardrail_result, current_guardrail_result], + context_wrapper=make_context_wrapper(), + current_agent=agent, + current_turn=2, + max_turns=2, + _current_agent_output_schema=None, + trace=None, + ) + streamed_result._model_input_items = [prior_call, prior_output, call, output] + streamed_result._state = state + application_error = KeyboardInterrupt("application failed") + + def fail_application(plan: Any) -> None: + if fail_after_first_swap: + owner, field, value = plan.assignments[0] + object.__setattr__(owner, field, value) + raise application_error + + monkeypatch.setattr(blocked_output, "_apply_blocked_output_owner_plan", fail_application) + + with pytest.raises(KeyboardInterrupt) as exc_info: + run_loop._retained_items_for_blocked_response( + [call, output], + response, + run_state=state, + streamed_result=streamed_result, + owner_starts=run_loop._BlockedOutputOwnerStarts( + run_state_generated_items=2, + run_state_session_items=2, + run_state_model_responses=1, + run_state_tool_output_guardrail_results=1, + streamed_new_items=2, + streamed_model_input_items=2, + streamed_raw_responses=1, + streamed_tool_output_guardrail_results=1, + ), + ) + + assert exc_info.value is application_error + assert state._generated_items == [prior_call, prior_output] + assert state._session_items == [prior_call, prior_output] + assert state._model_responses == [prior_response] + assert state._tool_output_guardrail_results == [prior_guardrail_result] + assert streamed_result.new_items == [prior_call, prior_output] + assert streamed_result._model_input_items == [prior_call, prior_output] + assert streamed_result.raw_responses == [prior_response] + assert streamed_result.tool_output_guardrail_results == [prior_guardrail_result] + + +def test_blocked_snapshot_application_exception_becomes_fixed_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent = Agent(name="test") + call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "commit_tool", + "arguments": "{}", + "call_id": "call-commit", + }, + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call-commit", + "output": "raw-secret", + }, + output="sdk-secret", + ) + state = make_run_state( + agent, + context=make_context_wrapper(), + original_input="test", + max_turns=1, + ) + state._generated_items = [call, output] + state._session_items = [call, output] + + def fail_application(_plan: Any) -> None: + raise ValueError("application-secret") + + monkeypatch.setattr(blocked_output, "_apply_blocked_output_owner_plan", fail_application) + + with pytest.raises(RuntimeError) as exc_info: + run_loop._retained_items_for_blocked_response( + [call, output], + None, + run_state=state, + ) + + assert "application-secret" not in str(exc_info.value) + assert state._generated_items == [] + assert state._session_items == [] + + +@pytest.mark.asyncio +async def test_non_streamed_trip_preserves_prior_run_state_side_effect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + side_effects: list[str] = [] + memory_items: list[RunItem] | None = None + + @function_tool(name_override="accepted_tool") + def accepted_tool() -> str: + side_effects.append("accepted") + return "accepted-output" + + @function_tool(name_override="terminal_tool") + def terminal_tool() -> str: + side_effects.append("terminal") + return "rejected-output" + + model = ScriptedModel( + steps=[ + [get_function_tool_call("accepted_tool", "{}", call_id="accepted-call")], + [get_text_message("accepted-final")], + ] + ) + agent = Agent(name="test", model=model, tools=[accepted_tool, terminal_tool]) + first = await Runner.run(agent, "run accepted tool", max_turns=5) + state = first.to_state() + prior_generated = list(state._generated_items) + prior_session = list(state._session_items) + prior_responses = list(state._model_responses) + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + agent.tool_use_behavior = {"stop_at_tool_names": ["terminal_tool"]} + agent.output_guardrails = [OutputGuardrail(guardrail_function=reject_output)] + + async def capture_memory_payload( + _runtime: Any, + *, + input: Any, + new_items: list[Any], + final_output: object, + interruptions: list[Any], + terminal_metadata: Any, + ) -> None: + del input, final_output, interruptions, terminal_metadata + nonlocal memory_items + memory_items = cast(list[RunItem], new_items) + + monkeypatch.setattr( + "agents.run.SandboxRuntime.enqueue_memory_payload", + capture_memory_payload, + ) + model.enqueue( + [ + ResponseReasoningItem( + id="reasoning-current", + type="reasoning", + summary=[Summary(text="calling terminal tool", type="summary_text")], + ), + get_function_tool_call("terminal_tool", "{}", call_id="current-call"), + ] + ) + + with pytest.raises(OutputGuardrailTripwireTriggered): + await Runner.run(agent, state) + + assert side_effects == ["accepted", "terminal"] + assert state._generated_items == prior_generated + assert state._session_items == prior_session + assert state._model_responses == prior_responses + serialized_state = json.dumps(state.to_json()) + assert "accepted-output" in serialized_state + assert "rejected-output" not in serialized_state + assert "reasoning-current" not in serialized_state + assert memory_items is not None + serialized_memory_items = json.dumps([item.to_input_item() for item in memory_items]) + assert "accepted-output" in serialized_memory_items + assert "rejected-output" not in serialized_memory_items + assert "reasoning-current" not in serialized_memory_items + + +@pytest.mark.parametrize("streamed", [False, True], ids=["non-streamed", "streamed"]) +@pytest.mark.parametrize("handoff_turn", [False, True], ids=["run-again", "handoff"]) +@pytest.mark.asyncio +async def test_resumed_trip_preserves_accepted_turns_and_turn_budget( + streamed: bool, + handoff_turn: bool, +) -> None: + side_effects: list[str] = [] + + @tool_input_guardrail + def record_accepted_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="accepted-input-audit") + + @tool_output_guardrail + def record_accepted_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info="accepted-output-audit") + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + side_effects.append("approved") + return "approved-output" + + @function_tool( + name_override="accepted_tool", + tool_input_guardrails=[record_accepted_input], + tool_output_guardrails=[record_accepted_output], + ) + def accepted_tool() -> str: + side_effects.append("accepted") + return "accepted-output" + + @function_tool(name_override="terminal_tool") + def terminal_tool() -> str: + side_effects.append("terminal") + return "rejected-secret" + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = ScriptedModel() + target = Agent( + name="target", + model=model, + tools=[terminal_tool], + tool_use_behavior={"stop_at_tool_names": ["terminal_tool"]}, + output_guardrails=[OutputGuardrail(guardrail_function=reject_output)], + ) + agent = Agent( + name="source", + model=model, + tools=[approval_tool, accepted_tool, terminal_tool], + handoffs=[target] if handoff_turn else [], + tool_use_behavior={"stop_at_tool_names": ["terminal_tool"]}, + output_guardrails=( + [] if handoff_turn else [OutputGuardrail(guardrail_function=reject_output)] + ), + ) + accepted_response = [get_function_tool_call("accepted_tool", "{}", call_id="accepted-call")] + if handoff_turn: + accepted_response.append(get_handoff_tool_call(target)) + model.extend( + [ + [get_function_tool_call("approval_tool", "{}", call_id="approved-call")], + accepted_response, + [get_function_tool_call("terminal_tool", "{}", call_id="terminal-call")], + ] + ) + + interrupted = await Runner.run(agent, "run approved tools", max_turns=3) + state = interrupted.to_state() + state.approve(interrupted.interruptions[0]) + + with pytest.raises(OutputGuardrailTripwireTriggered): + if streamed: + result = Runner.run_streamed(agent, state) + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, state) + + assert side_effects == ["approved", "accepted", "terminal"] + assert state._current_turn == 3 + assert len(state._model_responses) == 3 + assert [result.output.output_info for result in state._tool_input_guardrail_results] == [ + "accepted-input-audit" + ] + assert [result.output.output_info for result in state._tool_output_guardrail_results] == [ + "accepted-output-audit" + ] + for items in (state._generated_items, state._session_items): + outputs = [item for item in items if isinstance(item, ToolCallOutputItem)] + assert [item.output for item in outputs] == [ + "approved-output", + "accepted-output", + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + ] + + serialized_state = json.dumps(state.to_json()) + assert "approved-output" in serialized_state + assert "accepted-output" in serialized_state + assert "accepted-input-audit" in serialized_state + assert "accepted-output-audit" in serialized_state + assert "rejected-secret" not in serialized_state + + with pytest.raises(MaxTurnsExceeded): + await Runner.run(agent, state) + assert side_effects == ["approved", "accepted", "terminal"] + + +@pytest.mark.asyncio +async def test_non_streamed_trip_uses_safe_items_for_sandbox_memory_after_session_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + accepted_output = "accepted-tool-output" + tool_output_secret = "sandbox-memory-tool-output-secret" + persistence_secret = "sandbox-memory-session-failure-secret" + memory_items: list[RunItem] | None = None + + @function_tool(name_override="accepted_tool") + def accepted_tool() -> str: + return accepted_output + + @function_tool(name_override="terminal_tool") + def terminal_tool() -> str: + return tool_output_secret + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + class FailingBlockedSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if any( + type(item) is dict + and item.get("type") == "function_call_output" + and item.get("call_id") == "terminal-call" + for item in items + ): + raise LookupError(persistence_secret) + await super().add_items(items) + + async def capture_memory_payload( + _runtime: Any, + *, + input: Any, + new_items: list[Any], + final_output: object, + interruptions: list[Any], + terminal_metadata: Any, + ) -> None: + del input, final_output, interruptions, terminal_metadata + nonlocal memory_items + memory_items = cast(list[RunItem], new_items) + + monkeypatch.setattr( + "agents.run.SandboxRuntime.enqueue_memory_payload", + capture_memory_payload, + ) + agent = Agent( + name="test", + model=ScriptedModel( + steps=[ + [get_function_tool_call("accepted_tool", "{}", call_id="accepted-call")], + [get_function_tool_call("terminal_tool", "{}", call_id="terminal-call")], + ] + ), + tools=[accepted_tool, terminal_tool], + tool_use_behavior={"stop_at_tool_names": ["terminal_tool"]}, + output_guardrails=[OutputGuardrail(guardrail_function=reject_output)], + ) + + with pytest.raises(UserError, match="Error details are redacted") as exc_info: + await Runner.run(agent, "run terminal tool", session=FailingBlockedSession()) + + assert exc_info.value.run_data is None + assert persistence_secret not in str(exc_info.value) + assert memory_items is not None + serialized_memory_items = json.dumps([item.to_input_item() for item in memory_items]) + assert accepted_output in serialized_memory_items + assert tool_output_secret not in serialized_memory_items + assert persistence_secret not in serialized_memory_items + assert run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT in serialized_memory_items + + async def run_execute_approved_tools( agent: Agent[Any], approval_item: ToolApprovalItem, @@ -4473,8 +5309,9 @@ def guardrail_function( ] == ["user"] +@pytest.mark.parametrize("streamed", [False, True]) @pytest.mark.asyncio -async def test_output_guardrail_error_preserves_final_output_in_session() -> None: +async def test_output_guardrail_error_preserves_final_output_in_session(streamed: bool) -> None: def guardrail_function( _context: RunContextWrapper[Any], _agent: Agent[Any], _agent_output: Any ) -> GuardrailFunctionOutput: @@ -4490,7 +5327,12 @@ def guardrail_function( ) with pytest.raises(RuntimeError, match="guardrail failed"): - await Runner.run(agent, input="user_message", session=session) + if streamed: + result = Runner.run_streamed(agent, input="user_message", session=session) + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, input="user_message", session=session) items = await session.get_items() assert [ @@ -4626,7 +5468,7 @@ def commit_tool() -> str: result = await Runner.run(agent, state, session=session) assert result.final_output == "committed-result" - assert state._current_turn_persisted_item_count == 4 + assert state._current_turn_persisted_item_count == 2 items = await session.get_items() assert [ ( @@ -4641,6 +5483,11 @@ def commit_tool() -> str: ("function_call", "call-second"), ("function_call_output", "call-second"), ] + assert cast(dict[str, Any], items[-1]).get("output") == ( + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT if tripwire_triggered else "committed-result" + ) + if tripwire_triggered: + assert "committed-result" not in json.dumps(items[-2:]) @pytest.mark.asyncio diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index d962a1b393..e5c2a03fd8 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -32,6 +32,7 @@ ModelBehaviorError, ModelRetrySettings, ModelSettings, + OpenAIChatCompletionsModel, OpenAIResponsesWSModel, OutputGuardrail, OutputGuardrailTripwireTriggered, @@ -41,6 +42,7 @@ ToolGuardrailFunctionOutput, ToolInputGuardrailData, ToolOutputGuardrailData, + ToolsToFinalOutputResult, UserError, function_tool, handoff, @@ -52,10 +54,15 @@ from agents.run import RunConfig from agents.run_internal import run_loop from agents.run_internal.run_loop import QueueCompleteSentinel +from agents.run_state import RunState from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, StreamEvent from agents.testing import ModelStep, ScriptedModel from agents.tool import FunctionTool -from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail +from agents.tool_guardrails import ( + ToolOutputGuardrailResult, + tool_input_guardrail, + tool_output_guardrail, +) from agents.usage import Usage, _attach_raw_usage_snapshot from tests.model_test_helpers import get_response_obj @@ -2237,15 +2244,232 @@ async def test_tool() -> str: @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) -@pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"]) @pytest.mark.asyncio -async def test_resumed_approved_tool_final_persists_call_output_before_output_guardrails( +async def test_run_llm_again_approval_persists_completed_sibling(mode: str) -> None: + side_effects: list[str] = [] + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + side_effects.append("approved") + return "approved-output" + + @function_tool(name_override="sibling_tool") + def sibling_tool() -> str: + side_effects.append("sibling") + return "sibling-output" + + model = ScriptedModel( + [ + [ + get_function_tool_call("approval_tool", "{}", call_id="call-approved"), + get_function_tool_call("sibling_tool", "{}", call_id="call-sibling"), + ], + [get_text_message("done")], + ] + ) + agent = Agent( + name="test", + model=model, + tools=[approval_tool, sibling_tool], + output_guardrails=[ + OutputGuardrail( + guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=False, + ) + ) + ], + ) + session = SimpleListSession() + + async def run_once(input_value: Any) -> Any: + if mode == "non_streamed": + return await Runner.run(agent, input_value, session=session) + result = Runner.run_streamed(agent, input_value, session=session) + await consume_stream(result) + return result + + first = await run_once("Use both tools") + assert len(first.interruptions) == 1 + assert side_effects == ["sibling"] + + saved_before_resume = await session.get_items() + saved_sibling_items = [ + item + for item in saved_before_resume + if isinstance(item, dict) and item.get("call_id") == "call-sibling" + ] + assert [item.get("type") for item in saved_sibling_items] == [ + "function_call", + "function_call_output", + ] + assert saved_sibling_items[1].get("output") == "sibling-output" + + state = first.to_state() + assert state._current_turn_persisted_item_count > 0 + state.approve(first.interruptions[0]) + resumed = await run_once(state) + + assert resumed.final_output == "done" + assert side_effects == ["sibling", "approved"] + saved_after_resume = await session.get_items() + for call_id in ("call-sibling", "call-approved"): + assert [ + item.get("type") + for item in saved_after_resume + if isinstance(item, dict) and item.get("call_id") == call_id + ] == ["function_call", "function_call_output"] + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("terminal_behavior", ["first", "named", "custom"]) +@pytest.mark.asyncio +async def test_terminal_behaviors_defer_completed_approval_siblings( mode: str, - tripwire: bool, + terminal_behavior: str, +) -> None: + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + return "approved-output" + + @function_tool(name_override="sibling_tool") + def sibling_tool() -> str: + return "sibling-output" + + model = ScriptedModel( + [ + [ + get_function_tool_call("approval_tool", "{}", call_id="call-approved"), + get_function_tool_call("sibling_tool", "{}", call_id="call-sibling"), + ] + ] + ) + agent = Agent( + name="test", + model=model, + tools=[approval_tool, sibling_tool], + output_guardrails=[ + OutputGuardrail( + guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=False, + ) + ) + ], + ) + if terminal_behavior == "first": + agent.tool_use_behavior = "stop_on_first_tool" + elif terminal_behavior == "named": + agent.tool_use_behavior = {"stop_at_tool_names": ["approval_tool"]} + else: + agent.tool_use_behavior = lambda _context, results: ToolsToFinalOutputResult( + is_final_output=True, + final_output=results[0].output, + ) + + session = SimpleListSession() + if mode == "non_streamed": + result = await Runner.run(agent, "Use both tools", session=session) + else: + result = Runner.run_streamed(agent, "Use both tools", session=session) + await consume_stream(result) + + assert len(result.interruptions) == 1 + assert result.to_state()._current_turn_persisted_item_count == 0 + assert "sibling-output" not in json.dumps(await session.get_items()) + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("terminal_behavior", ["first", "named", "custom"]) +@pytest.mark.asyncio +async def test_persisted_run_llm_again_checkpoint_rejects_terminal_behavior_change( + mode: str, + terminal_behavior: str, ) -> None: - guardrail_state = {"tripwire": tripwire} + side_effects: list[str] = [] @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + side_effects.append("approved") + return "approved-output" + + @function_tool(name_override="sibling_tool") + def sibling_tool() -> str: + side_effects.append("sibling") + return "sibling-output" + + model = ScriptedModel( + [ + [ + get_function_tool_call("approval_tool", "{}", call_id="call-approved"), + get_function_tool_call("sibling_tool", "{}", call_id="call-sibling"), + ] + ] + ) + agent = Agent( + name="test", + model=model, + tools=[approval_tool, sibling_tool], + output_guardrails=[ + OutputGuardrail( + guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=False, + ) + ) + ], + ) + session = SimpleListSession() + + if mode == "non_streamed": + first = await Runner.run(agent, "Use both tools", session=session) + else: + first = Runner.run_streamed(agent, "Use both tools", session=session) + await consume_stream(first) + + assert side_effects == ["sibling"] + state = first.to_state() + assert state._current_turn_persisted_item_count > 0 + state.approve(first.interruptions[0]) + + if terminal_behavior == "first": + agent.tool_use_behavior = "stop_on_first_tool" + elif terminal_behavior == "named": + agent.tool_use_behavior = {"stop_at_tool_names": ["approval_tool"]} + else: + agent.tool_use_behavior = lambda _context, results: ToolsToFinalOutputResult( + is_final_output=True, + final_output=results[0].output, + ) + + with pytest.raises(UserError, match="after current-turn items were persisted"): + if mode == "non_streamed": + await Runner.run(agent, state, session=session) + else: + result = Runner.run_streamed(agent, state, session=session) + await consume_stream(result) + + assert side_effects == ["sibling"] + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("outcome", ["passes", "trips", "error"]) +@pytest.mark.asyncio +async def test_resumed_approved_tool_final_persists_complete_post_verdict_batch( + mode: str, + outcome: str, +) -> None: + guardrail_state = {"outcome": outcome} + + @tool_output_guardrail + def record_tool_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info=data.output) + + @function_tool( + name_override="approval_tool", + needs_approval=True, + tool_output_guardrails=[record_tool_output], + ) def approval_tool() -> str: return "approved-result" @@ -2254,9 +2478,11 @@ def output_guardrail( _agent: Agent[Any], _output: Any, ) -> GuardrailFunctionOutput: + if guardrail_state["outcome"] == "error": + raise RuntimeError("guardrail failed") return GuardrailFunctionOutput( output_info=None, - tripwire_triggered=guardrail_state["tripwire"], + tripwire_triggered=guardrail_state["outcome"] == "trips", ) model = ScriptedModel() @@ -2282,9 +2508,15 @@ async def run_once(input_value: Any) -> Any: state = first.to_state() state.approve(first.interruptions[0]) - if tripwire: + if outcome == "trips": with pytest.raises(OutputGuardrailTripwireTriggered): await run_once(state) + assert [result.output.output_info for result in state._tool_output_guardrail_results] == [ + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + ] + elif outcome == "error": + with pytest.raises(RuntimeError, match="guardrail failed"): + await run_once(state) else: resumed = await run_once(state) assert resumed.final_output == "approved-result" @@ -2303,10 +2535,13 @@ async def run_once(input_value: Any) -> Any: ("function_call", "call-approved"), ("function_call_output", "call-approved"), ] - assert saved_tool_items[1].get("output") == "approved-result" + expected_output = ( + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT if outcome == "trips" else "approved-result" + ) + assert saved_tool_items[1].get("output") == expected_output - if tripwire: - guardrail_state["tripwire"] = False + if outcome == "trips": + guardrail_state["outcome"] = "passes" model.enqueue([get_text_message("done")]) next_result = await run_once("Continue") assert next_result.final_output == "done" @@ -2323,13 +2558,233 @@ async def run_once(input_value: Any) -> Any: ("function_call", "call-approved"), ("function_call_output", "call-approved"), ] - assert replayed_tool_items[1].get("output") == "approved-result" + assert replayed_tool_items[1].get("output") == ( + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + ) + assert "approved-result" not in json.dumps(model_input) + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.asyncio +async def test_ambiguous_serialized_approval_state_fails_before_tool_execution( + mode: str, +) -> None: + tool_calls = 0 + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + nonlocal tool_calls + tool_calls += 1 + return "secret-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + model = ScriptedModel( + [[get_function_tool_call("approval_tool", "{}", call_id="call-approved")]] + ) + agent = Agent( + name="test", + model=model, + tools=[approval_tool], + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + first = await Runner.run(agent, "Use approval_tool") + state = first.to_state() + state._current_turn = 2 + state._current_turn_persisted_item_count = 1 + restored = await RunState.from_json(agent, state.to_json()) + restored.approve(restored.get_interruptions()[0]) + + with pytest.raises(UserError, match="current response boundary cannot be proven"): + if mode == "non_streamed": + await Runner.run(agent, restored, session=None) + else: + result = Runner.run_streamed(agent, restored, session=None) + await consume_stream(result) + + assert tool_calls == 0 @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("serialized", [False, True], ids=["live", "serialized"]) +@pytest.mark.parametrize("attach_session", [False, True], ids=["without-session", "with-session"]) +@pytest.mark.asyncio +async def test_legacy_approval_checkpoint_uses_current_session_ownership( + mode: str, + serialized: bool, + attach_session: bool, +) -> None: + side_effects: list[str] = [] + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + side_effects.append("executed") + return "approved-result" + + model = ScriptedModel( + [[get_function_tool_call("approval_tool", "{}", call_id="call-approved")]] + ) + agent = Agent( + name="test", + model=model, + tools=[approval_tool], + tool_use_behavior="stop_on_first_tool", + ) + legacy_session = SimpleListSession() + first = await Runner.run(agent, "Use approval_tool", session=legacy_session) + state = first.to_state() + assert state._current_turn_persisted_item_count > 0 + if serialized: + state = await RunState.from_json(agent, state.to_json()) + state.approve(state.get_interruptions()[0]) + agent.output_guardrails = [ + OutputGuardrail( + guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=False, + ) + ) + ] + session = legacy_session if attach_session else None + + if attach_session: + with pytest.raises(UserError, match="after current-turn items were persisted"): + if mode == "non_streamed": + await Runner.run(agent, state, session=session) + else: + result = Runner.run_streamed(agent, state, session=session) + await consume_stream(result) + assert side_effects == [] + return + + if mode == "non_streamed": + result = await Runner.run(agent, state, session=None) + else: + result = Runner.run_streamed(agent, state, session=None) + await consume_stream(result) + + assert result.final_output == "approved-result" + assert state._current_turn_persisted_item_count == 0 + assert side_effects == ["executed"] + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.asyncio +async def test_output_guardrails_fail_closed_with_server_managed_history(mode: str) -> None: + model = ScriptedModel([[get_text_message("unreachable")]]) + agent = Agent( + name="test", + model=model, + output_guardrails=[ + OutputGuardrail( + guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=False, + ) + ) + ], + ) + + with pytest.raises(UserError, match="server-managed conversation history"): + if mode == "non_streamed": + await Runner.run(agent, "hello", previous_response_id="response-id") + else: + Runner.run_streamed(agent, "hello", previous_response_id="response-id") + + assert not model.calls + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("strict", [False, True], ids=["default", "strict"]) +@pytest.mark.parametrize("use_run_config_model", [False, True], ids=["agent-model", "run-model"]) +@pytest.mark.asyncio +async def test_chat_completions_output_guardrails_use_adapter_conversation_policy( + mode: str, + strict: bool, + use_run_config_model: bool, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + scripted_model = ScriptedModel([[get_text_message("accepted-output")]]) + chat_model = OpenAIChatCompletionsModel( + model="test", + openai_client=cast(Any, object()), + strict_feature_validation=strict, + ) + + async def get_response(*args: Any, **kwargs: Any) -> Any: + chat_model._handle_unsupported_server_managed_conversation_state( + previous_response_id=kwargs.get("previous_response_id"), + conversation_id=kwargs.get("conversation_id"), + ) + return await scripted_model.get_response(*args, **kwargs) + + async def stream_response(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + chat_model._handle_unsupported_server_managed_conversation_state( + previous_response_id=kwargs.get("previous_response_id"), + conversation_id=kwargs.get("conversation_id"), + ) + async for event in scripted_model.stream_response(*args, **kwargs): + yield event + + monkeypatch.setattr(chat_model, "get_response", get_response) + monkeypatch.setattr(chat_model, "stream_response", stream_response) + agent = Agent( + name="test", + model=ScriptedModel() if use_run_config_model else chat_model, + output_guardrails=[ + OutputGuardrail( + guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=False, + ) + ) + ], + ) + run_config = RunConfig(model=chat_model) if use_run_config_model else None + caplog.set_level(logging.WARNING, logger="openai.agents") + + async def run_once() -> Any: + if mode == "non_streamed": + return await Runner.run( + agent, + "hello", + previous_response_id="response-id", + run_config=run_config, + ) + result = Runner.run_streamed( + agent, + "hello", + previous_response_id="response-id", + run_config=run_config, + ) + await consume_stream(result) + return result + + if strict: + with pytest.raises(UserError, match="OpenAIChatCompletionsModel does not support"): + await run_once() + assert not scripted_model.calls + return + + assert (await run_once()).final_output == "accepted-output" + assert "Ignoring unsupported server-managed conversation state" in caplog.text + assert len(scripted_model.calls) == 1 + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("session_kind", ["simple", "openai_conversations"]) +@pytest.mark.parametrize("arguments", ["{}", ""], ids=["json-object", "empty"]) @pytest.mark.asyncio async def test_stop_on_first_tool_final_persists_committed_tool_items_on_tripwire( mode: str, + session_kind: str, + arguments: str, ) -> None: """A blocked final output must not discard the session record of a tool that already ran.""" @@ -2348,7 +2803,7 @@ def output_guardrail( return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) model = ScriptedModel() - model.enqueue([get_function_tool_call("commit_tool", "{}", call_id="call-committed")]) + model.enqueue([get_function_tool_call("commit_tool", arguments, call_id="call-committed")]) agent = Agent( name="test", model=model, @@ -2356,13 +2811,38 @@ def output_guardrail( tool_use_behavior="stop_on_first_tool", output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], ) - session = SimpleListSession() + + class DummyOpenAIConversationsSession(OpenAIConversationsSession): + def __init__(self) -> None: + self.history: list[TResponseInputItem] = [] + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + return list(self.history if limit is None else self.history[-limit:]) + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.history.extend(items) + + async def pop_item(self) -> TResponseInputItem | None: + return self.history.pop() if self.history else None + + async def clear_session(self) -> None: + self.history.clear() + + session = SimpleListSession() if session_kind == "simple" else DummyOpenAIConversationsSession() + run_config = RunConfig( + session_input_callback=lambda history, new_input: [*reversed(history), *new_input] + ) with pytest.raises(OutputGuardrailTripwireTriggered): if mode == "non_streamed": - await Runner.run(agent, "Use commit_tool", session=session) + await Runner.run(agent, "Use commit_tool", session=session, run_config=run_config) else: - result = Runner.run_streamed(agent, "Use commit_tool", session=session) + result = Runner.run_streamed( + agent, + "Use commit_tool", + session=session, + run_config=run_config, + ) await consume_stream(result) assert calls == ["ran"], "the tool never ran, so the test proves nothing" @@ -2378,14 +2858,28 @@ def output_guardrail( ("function_call", "call-committed"), ("function_call_output", "call-committed"), ] + assert cast(dict[str, Any], saved_items[-1]).get("output") == ( + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + ) + assert "committed-result" not in json.dumps(saved_items) # The next run must see the completed call instead of re-issuing the same side effect. agent.output_guardrails = [] model.enqueue([get_text_message("done")]) if mode == "non_streamed": - followup: Any = await Runner.run(agent, "Continue", session=session) + followup: Any = await Runner.run( + agent, + "Continue", + session=session, + run_config=run_config, + ) else: - followup = Runner.run_streamed(agent, "Continue", session=session) + followup = Runner.run_streamed( + agent, + "Continue", + session=session, + run_config=run_config, + ) await consume_stream(followup) assert followup.final_output == "done" assert calls == ["ran"] @@ -2397,10 +2891,17 @@ def output_guardrail( for item in model_input if isinstance(item, dict) and item.get("type") in {"function_call", "function_call_output"} ] - assert replayed == [ + assert set(replayed) == { ("function_call", "call-committed"), ("function_call_output", "call-committed"), - ] + } + replayed_output = next( + item.get("output") + for item in model_input + if isinstance(item, dict) and item.get("type") == "function_call_output" + ) + assert replayed_output == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert "committed-result" not in json.dumps(model_input) @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @@ -2450,7 +2951,10 @@ def output_guardrail( _agent: Agent[Any], _output: Any, ) -> GuardrailFunctionOutput: - return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + return GuardrailFunctionOutput( + output_info={"reason": "message rejected"}, + tripwire_triggered=True, + ) model = ScriptedModel() model.extend( @@ -2467,13 +2971,16 @@ def output_guardrail( ) session = SimpleListSession() - with pytest.raises(OutputGuardrailTripwireTriggered): + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: if mode == "non_streamed": await Runner.run(agent, "Use commit_tool", session=session) else: result = Runner.run_streamed(agent, "Use commit_tool", session=session) await consume_stream(result) + assert exc_info.value.guardrail_result.agent_output == "should_not_be_saved" + assert exc_info.value.guardrail_result.output.output_info == {"reason": "message rejected"} + saved_items = await session.get_items() saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] assert saved == ["user", "function_call", "function_call_output"] @@ -2549,12 +3056,7 @@ async def run_once() -> Any: async def test_failing_output_guardrail_keeps_the_whole_final_turn( mode: str, ) -> None: - """A guardrail *error* is not a tripwire: the completed final turn stays replayable. - - Only a tripwire means the output was judged undeliverable. An ordinary guardrail exception - leaves the verdict unknown, so the turn must be persisted whole, exactly as the non-streamed - path does. - """ + """A guardrail error leaves no rejection, so the completed turn remains replayable.""" @function_tool(name_override="commit_tool") def commit_tool() -> str: @@ -2772,16 +3274,11 @@ def output_guardrail( @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"]) @pytest.mark.asyncio -async def test_blocked_tool_final_keeps_reasoning_context_with_the_committed_call( +async def test_blocked_tool_final_discards_reasoning_response_suffix_on_trip( mode: str, tripwire: bool, ) -> None: - """A retained tool call keeps the reasoning item it belongs to, in order. - - A reasoning model requires the reasoning item that preceded a function call to accompany that - call in the next request, so persisting the call/output pair without it leaves an unreplayable - turn. Asserted on both the session contents and the next run's model input. - """ + """A reasoning-bearing response is preserved on pass and discarded completely on trip.""" @function_tool(name_override="commit_tool") def commit_tool() -> str: @@ -2829,7 +3326,10 @@ async def run_once(input_value: Any) -> Any: saved_items = await session.get_items() saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] - assert saved == ["user", "reasoning", "function_call", "function_call_output"] + expected_saved = ( + ["user"] if tripwire else ["user", "reasoning", "function_call", "function_call_output"] + ) + assert saved == expected_saved # The reasoning/call/output group has to reach the next request in that order. agent.output_guardrails = [] @@ -2845,22 +3345,16 @@ async def run_once(input_value: Any) -> Any: if isinstance(item, dict) and item.get("type") in {"reasoning", "function_call", "function_call_output"} ] - assert replayed == ["reasoning", "function_call", "function_call_output"] + expected_replayed = [] if tripwire else ["reasoning", "function_call", "function_call_output"] + assert replayed == expected_replayed @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.asyncio -async def test_blocked_tool_final_drops_reasoning_tied_to_the_rejected_message( +async def test_blocked_tool_final_discards_suffix_with_multiple_reasoning_groups( mode: str, ) -> None: - """Only the reasoning tied to a retained call survives; the message's reasoning goes with it. - - The turn is `reasoning_for_message -> message -> reasoning_for_call -> function_call`. A - reasoning item belongs to the next non-reasoning item, so retaining every reasoning item - whenever the turn happens to contain a tool call would leave the rejected message's reasoning - dangling in the next request. - - """ + """Any reasoning item makes the complete rejected current-response suffix unsupported.""" @function_tool(name_override="commit_tool") def commit_tool() -> str: @@ -2911,17 +3405,14 @@ async def run_once(input_value: Any) -> Any: saved_items = await session.get_items() saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)] - assert saved == ["user", "reasoning", "function_call", "function_call_output"] + assert saved == ["user"] saved_reasoning_ids = [ item.get("id") for item in saved_items if isinstance(item, dict) and item.get("id") ] - assert "rs_committed" in saved_reasoning_ids - assert "rs_rejected" not in saved_reasoning_ids, ( - "reasoning tied to the rejected message must not be persisted" - ) + assert saved_reasoning_ids == [] - # ...and the surviving group still replays in order, with no dangling reasoning item. + # The unsupported response contributes nothing to the next model request. agent.output_guardrails = [] model.enqueue([get_text_message("done")]) followup = await run_once("Continue") @@ -2935,7 +3426,94 @@ async def run_once(input_value: Any) -> Any: if isinstance(item, dict) and item.get("type") in {"reasoning", "message", "function_call", "function_call_output"} ] - assert replayed == ["reasoning", "function_call", "function_call_output"] + assert replayed == [] + + +@pytest.mark.parametrize("reasoning_suffix", [False, True], ids=["canonical", "reasoning"]) +@pytest.mark.asyncio +async def test_streamed_trip_preserves_accepted_tool_prefix( + reasoning_suffix: bool, +) -> None: + """Only the rejected current response is replaced or dropped from replay owners.""" + side_effects: list[str] = [] + + @function_tool(name_override="accepted_tool") + def accepted_tool() -> str: + side_effects.append("accepted") + return "accepted-output" + + @function_tool(name_override="terminal_tool") + def terminal_tool() -> str: + side_effects.append("terminal") + return "rejected-output" + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + terminal_response: list[Any] = [] + if reasoning_suffix: + terminal_response.append( + ResponseReasoningItem( + id="reasoning-current", + summary=[Summary(text="calling terminal tool", type="summary_text")], + type="reasoning", + ) + ) + terminal_response.append(get_function_tool_call("terminal_tool", "{}", call_id="current-call")) + model = ScriptedModel( + steps=[ + [get_function_tool_call("accepted_tool", "{}", call_id="accepted-call")], + terminal_response, + ] + ) + agent = Agent( + name="test", + model=model, + tools=[accepted_tool, terminal_tool], + tool_use_behavior={"stop_at_tool_names": ["terminal_tool"]}, + output_guardrails=[OutputGuardrail(guardrail_function=reject_output)], + ) + + result = Runner.run_streamed(agent, "run both tools") + with pytest.raises(OutputGuardrailTripwireTriggered): + await consume_stream(result) + + assert side_effects == ["accepted", "terminal"] + + def call_ids(items: list[RunItem]) -> list[str]: + return [ + call_id + for item in items + if ( + call_id := ( + item.raw_item.get("call_id") + if isinstance(item.raw_item, dict) + else getattr(item.raw_item, "call_id", None) + ) + ) + is not None + ] + + expected_call_ids = ["accepted-call", "accepted-call"] + if not reasoning_suffix: + expected_call_ids.extend(["current-call", "current-call"]) + assert call_ids(result.new_items) == expected_call_ids + assert call_ids(result._model_input_items) == expected_call_ids + + state = result.to_state() + assert call_ids(state._generated_items) == expected_call_ids + assert call_ids(state._session_items) == expected_call_ids + serialized_state = json.dumps(state.to_json()) + assert "accepted-output" in serialized_state + assert "rejected-output" not in serialized_state + if reasoning_suffix: + assert "reasoning-current" not in serialized_state + else: + assert run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT in serialized_state @pytest.mark.asyncio @@ -3263,6 +3841,84 @@ async def test_streamed_run_reports_tool_guardrail_results(): assert result.tool_output_guardrail_results[0].output.output_info == "output-checked" +@pytest.mark.parametrize("tool_guardrail_behavior", ["allow", "reject_content"]) +@pytest.mark.asyncio +async def test_streamed_trip_replaces_current_tool_output_guardrail_results( + tool_guardrail_behavior: str, +) -> None: + """A copied terminal tool result is replaced in public and RunState guardrail results.""" + original_outputs: list[ToolGuardrailFunctionOutput] = [] + + @tool_output_guardrail + def retain_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + if tool_guardrail_behavior == "reject_content": + output = ToolGuardrailFunctionOutput.reject_content( + message=f"Rejected sensitive tool output: {data.output}", + output_info=data.output, + ) + else: + output = ToolGuardrailFunctionOutput.allow(output_info=data.output) + original_outputs.append(output) + return output + + @function_tool(name_override="secret_tool", tool_output_guardrails=[retain_output]) + def secret_tool() -> str: + return "blocked-secret" + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = ScriptedModel() + model.enqueue([get_function_tool_call("secret_tool", "{}", call_id="call-secret")]) + agent = Agent( + name="test", + model=model, + tools=[secret_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=reject_output)], + ) + + result = Runner.run_streamed(agent, "run") + prior_output = ToolGuardrailFunctionOutput.allow(output_info="prior-safe") + prior_result = ToolOutputGuardrailResult(guardrail=retain_output, output=prior_output) + result.tool_output_guardrail_results.append(prior_result) + with pytest.raises(OutputGuardrailTripwireTriggered): + await consume_stream(result) + + assert len(original_outputs) == 1 + assert len(result.tool_output_guardrail_results) == 2 + assert result.tool_output_guardrail_results[0] is prior_result + assert result.tool_output_guardrail_results[0].output is prior_output + public_output = result.tool_output_guardrail_results[1].output + assert public_output is not original_outputs[0] + assert public_output.output_info == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert public_output.behavior["type"] == tool_guardrail_behavior + if public_output.behavior["type"] == "reject_content": + assert public_output.behavior["message"] == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert original_outputs[0].behavior["type"] == "reject_content" + assert "blocked-secret" in original_outputs[0].behavior["message"] + assert result._state is not None + # The caller-added public result was never owned by RunState, so only the current + # data-free result is added to that owner. + assert len(result._state._tool_output_guardrail_results) == 1 + state_output = result._state._tool_output_guardrail_results[0].output + assert state_output is public_output + assert state_output.output_info == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert state_output.behavior["type"] == tool_guardrail_behavior + serialized_state = result.to_state().to_json() + serialized_results = serialized_state["tool_output_guardrail_results"] + assert serialized_results[0]["output"]["behavior"]["type"] == "allow" + serialized_behavior = serialized_results[-1]["output"]["behavior"] + assert serialized_behavior["type"] == tool_guardrail_behavior + if tool_guardrail_behavior == "reject_content": + assert serialized_behavior["message"] == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert "blocked-secret" not in json.dumps(serialized_state) + + @pytest.mark.asyncio async def test_streamed_tool_guardrail_results_match_non_streamed(): """The same run reports the same tool guardrail results in both execution modes.""" diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index 6c26da31f0..1de64711df 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -1686,6 +1686,69 @@ def output_guardrail( _assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET) +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_blocked_terminal_tool_session_failure_is_data_redacted( + streamed: bool, +) -> None: + tool_output_secret = "BLOCKED_TERMINAL_TOOL_OUTPUT_SECRET" + persistence_secret = "BLOCKED_TERMINAL_SESSION_FAILURE_SECRET" + + @function_tool(name_override="terminal_tool") + def terminal_tool() -> str: + return tool_output_secret + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + class FailingBlockedSession(SimpleListSession): + async def add_items(self, items: list[Any]) -> None: + if any( + type(item) is dict and item.get("type") == "function_call_output" for item in items + ): + error = LookupError(f"session save failed: {persistence_secret}") + error.run_data = items # type: ignore[attr-defined] + raise error + await super().add_items(items) + + agent = Agent( + name="test", + model=ScriptedModel( + steps=[[get_function_tool_call("terminal_tool", "{}", call_id="terminal-call")]] + ), + tools=[terminal_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=reject_output)], + ) + session = FailingBlockedSession() + + if streamed: + result = Runner.run_streamed(agent, "run terminal tool", session=session) + with pytest.raises(UserError) as exc_info: + async for _ in result.stream_events(): + pass + else: + with pytest.raises(UserError) as exc_info: + await Runner.run(agent, "run terminal tool", session=session) + + error = exc_info.value + assert str(error) == "Error details are redacted." + assert error.run_data is None + assert error.__cause__ is None + assert error.__context__ is None + for secret in (tool_output_secret, persistence_secret): + assert secret not in repr(error) + _assert_secret_absent_from_agents_traceback( + error, + secret, + require_agents_frames=False, + ) + + def _persistence_failure( kind: Literal["exception", "cancelled", "direct_base", "exception_group", "group"], secret: str, diff --git a/tests/test_max_turns.py b/tests/test_max_turns.py index d002609523..02817e57cc 100644 --- a/tests/test_max_turns.py +++ b/tests/test_max_turns.py @@ -662,8 +662,10 @@ async def run_once() -> Any: with pytest.raises(RuntimeError, match="guardrail failed"): await run_once() elif outcome == "tripwire": - with pytest.raises(OutputGuardrailTripwireTriggered): + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: await run_once() + assert exc_info.value.guardrail_result.agent_output == "fallback answer" + assert exc_info.value.guardrail_result.output.output_info == "tripwire" else: result = await run_once() assert result.final_output == "fallback answer" @@ -672,10 +674,10 @@ async def run_once() -> Any: saved_items = await session.get_items() saved_types = [str(item.get("type", item.get("role"))) for item in saved_items] - if outcome == "tripwire": - assert saved_types == ["user"] - else: + if outcome in {"pass", "error"}: assert saved_types == ["user", "message"] + else: + assert saved_types == ["user"] fallback_events = [ event @@ -688,7 +690,7 @@ async def run_once() -> Any: if streamed: assert streamed_result is not None - expected_history_count = 0 if outcome == "tripwire" else 1 + expected_history_count = 1 if outcome in {"pass", "error"} else 0 assert ( len([item for item in streamed_result.new_items if isinstance(item, MessageOutputItem)]) == expected_history_count @@ -700,6 +702,64 @@ async def run_once() -> Any: ) +@pytest.mark.asyncio +async def test_streamed_max_turns_trip_preserves_completed_tool_prefix() -> None: + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = ScriptedModel( + steps=[[get_function_tool_call("some_function", "{}", call_id="accepted-call")]] + ) + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("some_function", "accepted-output")], + output_guardrails=[OutputGuardrail(guardrail_function=reject_output)], + ) + session = SimpleListSession() + result = Runner.run_streamed( + agent, + "run the tool", + max_turns=1, + session=session, + error_handlers={"max_turns": lambda data: "rejected fallback"}, + ) + + with pytest.raises(OutputGuardrailTripwireTriggered): + async for _ in result.stream_events(): + pass + + def call_ids(items: list[Any]) -> list[str]: + return [ + call_id + for item in items + if ( + call_id := ( + item.raw_item.get("call_id") + if isinstance(item.raw_item, dict) + else getattr(item.raw_item, "call_id", None) + ) + ) + is not None + ] + + assert call_ids(result.new_items) == ["accepted-call", "accepted-call"] + assert call_ids(result._model_input_items) == ["accepted-call", "accepted-call"] + state = result.to_state() + assert call_ids(state._generated_items) == ["accepted-call", "accepted-call"] + assert call_ids(state._session_items) == ["accepted-call", "accepted-call"] + serialized_state = json.dumps(state.to_json()) + assert "accepted-output" in serialized_state + assert "rejected fallback" in serialized_state + + saved_types = [item.get("type", item.get("role")) for item in await session.get_items()] + assert saved_types == ["user", "function_call", "function_call_output"] + + @pytest.mark.asyncio async def test_streamed_max_turns_handler_validation_failure_persists_input() -> None: agent = Agent(name="test", model=ScriptedModel(), output_type=Foo) @@ -995,6 +1055,52 @@ def output_guardrail( ] +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +async def test_resumed_max_turns_trip_preserves_current_guardrail_result( + streamed: bool, +) -> None: + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=output, + tripwire_triggered=output == "fallback answer", + ) + + agent = Agent( + name="test", + model=ScriptedModel(steps=[[get_text_message("first response")]]), + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + first = await Runner.run(agent, "first input", max_turns=1) + state = first.to_state() + prior_result = state._output_guardrail_results[0] + + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: + if streamed: + result = Runner.run_streamed( + agent, + state, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + async for _ in result.stream_events(): + pass + else: + await Runner.run( + agent, + state, + error_handlers={"max_turns": lambda data: "fallback answer"}, + ) + + assert state._output_guardrail_results == [prior_result] + assert prior_result.output.output_info == "first response" + assert exc_info.value.guardrail_result.agent_output == "fallback answer" + assert exc_info.value.guardrail_result.output.output_info == "fallback answer" + + @pytest.mark.parametrize("streamed", [False, True]) @pytest.mark.asyncio async def test_resumed_max_turns_handler_preserves_checkpoint_after_continuation( From 36dbc36857f2097145018934e4d111612271f0b2 Mon Sep 17 00:00:00 2001 From: teachershuang <148217352+teachershuang@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:41:56 +0800 Subject: [PATCH 365/473] docs: add testing resources to llms indexes (#4509) --- docs/llms-full.txt | 4 ++++ docs/llms.txt | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f700844061..90f710e1a4 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -21,6 +21,7 @@ The Agents SDK delivers a focused set of Python primitives—agents, tools, guar - [Streaming](https://openai.github.io/openai-agents-python/streaming/): Shows how to subscribe to incremental events, stream tool progress, and render partial model outputs in real time. - [REPL](https://openai.github.io/openai-agents-python/repl/): Interactive runner for exploring agent behavior, step-by-step execution, and debugging tool calls. - [Visualization](https://openai.github.io/openai-agents-python/visualization/): Demonstrates embeddable visualizations for session timelines, message flows, and tool interactions. +- [Testing](https://openai.github.io/openai-agents-python/testing/): Build deterministic, provider-neutral tests for Agent, Sandbox, Realtime, and Voice workflows. ## Coordination, Safety, and Tooling - [Handoffs](https://openai.github.io/openai-agents-python/handoffs/): Implements delegation between agents, argument passing, completion handling, and error recovery across agent boundaries. @@ -47,6 +48,7 @@ The Agents SDK delivers a focused set of Python primitives—agents, tools, guar - [memory interfaces](https://openai.github.io/openai-agents-python/ref/memory/): Session memory primitives, storage adapters, and utilities for retrieving historical context. - [repl utilities](https://openai.github.io/openai-agents-python/ref/repl/): Programmatic access to the interactive REPL loop and inspection helpers. - [tool base classes](https://openai.github.io/openai-agents-python/ref/tool/): Tool registration, invocation, and structured argument parsing. +- [testing utilities](https://openai.github.io/openai-agents-python/ref/testing/): Deterministic utilities for Agent model calls and Sandbox workflows. - [tool context helpers](https://openai.github.io/openai-agents-python/ref/tool_context/): Manage shared resources, dependency injection, and cleanup for tool execution. - [result objects](https://openai.github.io/openai-agents-python/ref/result/): Fields exposed on run results, including final content, tool call summaries, and attachments. - [stream events](https://openai.github.io/openai-agents-python/ref/stream_events/): Event models emitted during streaming runs and their payload schemas. @@ -85,6 +87,7 @@ The Agents SDK delivers a focused set of Python primitives—agents, tools, guar - [Realtime events](https://openai.github.io/openai-agents-python/ref/realtime/events/): Event payload types delivered over realtime channels. - [Realtime config](https://openai.github.io/openai-agents-python/ref/realtime/config/): Configuration models for realtime transports and behaviors. - [Realtime model interface](https://openai.github.io/openai-agents-python/ref/realtime/model/): Interfaces for plugging in realtime-capable models. +- [Realtime testing](https://openai.github.io/openai-agents-python/ref/realtime/testing/): Scripted model utilities for Realtime session and tool workflow tests. ## API Reference – Voice - [Voice pipeline API](https://openai.github.io/openai-agents-python/ref/voice/pipeline/): Programmatic control over the voice pipeline and event flow. @@ -96,6 +99,7 @@ The Agents SDK delivers a focused set of Python primitives—agents, tools, guar - [Voice exceptions](https://openai.github.io/openai-agents-python/ref/voice/exceptions/): Exception types for voice pipelines and error handling guidance. - [Voice model adapters](https://openai.github.io/openai-agents-python/ref/voice/model/): Interfaces for voice-enabled models and synthesis engines. - [Voice utility helpers](https://openai.github.io/openai-agents-python/ref/voice/utils/): Audio conversion, streaming helpers, and testing utilities. +- [Voice testing](https://openai.github.io/openai-agents-python/ref/voice/testing/): Scripted STT, TTS, transcription, and workflow test utilities. - [OpenAI voice provider](https://openai.github.io/openai-agents-python/ref/voice/models/openai_provider/): Adapter for OpenAI voice models. - [OpenAI speech-to-text provider](https://openai.github.io/openai-agents-python/ref/voice/models/openai_stt/): Integration for STT models used in the pipeline. - [OpenAI text-to-speech provider](https://openai.github.io/openai-agents-python/ref/voice/models/openai_tts/): Adapter for OpenAI TTS output. diff --git a/docs/llms.txt b/docs/llms.txt index 1665255e9d..639aebee5c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -17,6 +17,7 @@ The SDK focuses on a concise set of primitives so you can orchestrate multi-agen - [Streaming](https://openai.github.io/openai-agents-python/streaming/): Stream intermediate tool usage and LLM responses for responsive UIs. - [REPL](https://openai.github.io/openai-agents-python/repl/): Use the interactive runner to prototype agents and inspect execution step by step. - [Context strategies](https://openai.github.io/openai-agents-python/context/): Control what past messages, attachments, and tool runs are injected into prompts. +- [Testing](https://openai.github.io/openai-agents-python/testing/): Test Agent, Sandbox, Realtime, and Voice workflows deterministically without provider requests. ## Coordination and Safety - [Handoffs](https://openai.github.io/openai-agents-python/handoffs/): Delegate tasks between agents with intent classification, argument passing, and return values. @@ -46,6 +47,7 @@ The SDK focuses on a concise set of primitives so you can orchestrate multi-agen - [Runs and sessions](https://openai.github.io/openai-agents-python/ref/run/): API for launching runs, streaming updates, and handling cancellations. - [Results objects](https://openai.github.io/openai-agents-python/ref/result/): Data structures returned from agent runs, including final output and tool calls. - [Tool interfaces](https://openai.github.io/openai-agents-python/ref/tool/): Create tools, parse arguments, and manage tool execution contexts. +- [Testing APIs](https://openai.github.io/openai-agents-python/ref/testing/): Reference provider-neutral testing utilities for Agent model calls and Sandbox workflows. - [Tracing APIs](https://openai.github.io/openai-agents-python/ref/tracing/index/): Programmatic interfaces for creating traces, spans, and integrating custom processors. - [Realtime APIs](https://openai.github.io/openai-agents-python/ref/realtime/agent/): Classes for realtime agents, runners, sessions, and event payloads. - [Voice APIs](https://openai.github.io/openai-agents-python/ref/voice/pipeline/): Configure voice pipelines, inputs, events, and model adapters. From 0486792662bd44791dfa5838425c54c52e971d08 Mon Sep 17 00:00:00 2001 From: Weike Zhang <66246918+weike-zhang@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:40:49 +0800 Subject: [PATCH 366/473] fix(core/extensions): reject terminal failed/incomplete responses in non-streaming get_response (#4516) --- src/agents/extensions/models/any_llm_model.py | 3 ++ src/agents/models/openai_responses.py | 2 + tests/models/test_any_llm_model.py | 47 +++++++++++++++++++ tests/models/test_openai_responses.py | 46 ++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index b989d3c127..a737bb8389 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -405,6 +405,9 @@ async def _get_response_via_responses( prompt=prompt, ) + if getattr(response, "status", None) in {"failed", "incomplete"}: + raise response_terminal_failure_error(f"response.{response.status}", response) + if _debug.DONT_LOG_MODEL_DATA: logger.debug("LLM responded") else: diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index a382c9d28a..f6ff8e2b3c 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -822,6 +822,8 @@ async def _fetch_response( if not stream: response = await client.responses.create(**create_kwargs) + if getattr(response, "status", None) in {"failed", "incomplete"}: + raise response_terminal_failure_error(f"response.{response.status}", response) _mark_transport_request_without_usage(response) return cast(Response, response) diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 048d0f585c..b80f7c0395 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -23,6 +23,7 @@ ResponseOutputMessage, ResponseOutputRefusal, ) +from openai.types.responses.response import IncompleteDetails from openai.types.responses.response_created_event import ResponseCreatedEvent from openai.types.responses.response_error_event import ResponseErrorEvent from openai.types.responses.response_failed_event import ResponseFailedEvent @@ -176,6 +177,24 @@ def _response(text: str, response_id: str = "resp_123") -> Response: ) +def _responses_response_with_terminal_status(status: str) -> Response: + return Response( + id="resp_terminal", + created_at=123, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + usage=None, + status=status, + incomplete_details=( + IncompleteDetails(reason="max_output_tokens") if status == "incomplete" else None + ), + ) + + def _chat_completion_with_tool_call(*, thought_signature: str) -> ChatCompletion: return ChatCompletion( id="chatcmpl_tool_123", @@ -764,6 +783,34 @@ async def test_any_llm_responses_path_is_used_when_supported(monkeypatch) -> Non assert response.output[0].content[0].text == "Hello" +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["incomplete", "failed"]) +async def test_any_llm_responses_path_rejects_failed_terminal_status( + monkeypatch, status: str +) -> None: + provider = FakeAnyLLMProvider( + supports_responses=True, + responses_response=_responses_response_with_terminal_status(status), + ) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + + model = module.AnyLLMModel(model="openai/gpt-5.4-mini", api_key="openai-key") + with pytest.raises(ModelBehaviorError, match=f"response.{status}"): + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("parallel_tool_calls", [True, False, None]) diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index ad2be94039..3bfd3bef00 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -9,6 +9,7 @@ import pytest from openai import NOT_GIVEN, APIConnectionError, AsyncOpenAI, RateLimitError, omit from openai.types.responses import Response, ResponseCompletedEvent, ResponseErrorEvent +from openai.types.responses.response import IncompleteDetails from openai.types.responses.response_create_params import ContextManagement, PromptCacheOptions from openai.types.responses.response_usage import ResponseUsage from openai.types.shared.reasoning import Reasoning @@ -2141,6 +2142,32 @@ async def fake_open( ) +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["incomplete", "failed"]) +async def test_get_response_rejects_failed_terminal_response_status(status: str) -> None: + class Responses: + async def create(self, **kwargs: Any) -> Response: + return _response_with_terminal_status(status) + + class Client: + responses = Responses() + base_url = httpx2.URL("https://custom.example.test/v1/") + + model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, Client())) + + with pytest.raises(ModelBehaviorError, match=f"response.{status}"): + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ) + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("terminal_event_type", ["response.incomplete", "response.failed"]) @@ -4442,6 +4469,25 @@ def _response_without_usage() -> Response: ) +def _response_with_terminal_status(status: str) -> Response: + return Response( + id="resp-terminal", + created_at=0, + model="fake", + object="response", + output=[], + tool_choice="none", + tools=[], + top_p=None, + parallel_tool_calls=False, + usage=None, + status=status, + incomplete_details=( + IncompleteDetails(reason="max_output_tokens") if status == "incomplete" else None + ), + ) + + def _completed_event_without_usage() -> ResponseCompletedEvent: return ResponseCompletedEvent( response=_response_without_usage(), From 9432f7ed30b9554ab5eaa84a4c0977059f96d5f0 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Wed, 19 Aug 2026 03:41:33 -0500 Subject: [PATCH 367/473] fix(visualization): expand handoff() targets in agent graphs (#4517) --- src/agents/extensions/visualization.py | 50 ++++++++++++++++++++------ tests/test_visualization.py | 40 +++++++++++++++++++-- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 71e6d3dfa6..9bf9702314 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -22,6 +22,15 @@ def _escape_label(name: str) -> str: ) +def _handoff_target_agent(handoff: Handoff) -> Agent | None: + """Return the live Agent target for a ``handoff()`` object, if available.""" + agent_ref = handoff._agent_ref + if agent_ref is None: + return None + target = agent_ref() + return target if isinstance(target, Agent) else None + + def get_main_graph(agent: Agent) -> str: """ Generates the main graph structure in DOT format for the given agent. @@ -99,13 +108,6 @@ def get_all_nodes( ) for handoff in agent.handoffs: - if isinstance(handoff, Handoff): - name = _escape_label(handoff.agent_name) - parts.append( - f'"{name}" [label="{name}", ' - f'shape=box, style="filled,rounded", ' - f"fillcolor=lightyellow, width=1.5, height=0.8];" - ) if isinstance(handoff, Agent): if handoff.name not in visited: name = _escape_label(handoff.name) @@ -115,6 +117,26 @@ def get_all_nodes( f"fillcolor=lightyellow, width=1.5, height=0.8];" ) parts.append(get_all_nodes(handoff, agent, visited)) + continue + + if isinstance(handoff, Handoff): + target = _handoff_target_agent(handoff) + if target is not None: + if target.name not in visited: + name = _escape_label(target.name) + parts.append( + f'"{name}" [label="{name}", ' + f'shape=box, style="filled,rounded", ' + f"fillcolor=lightyellow, width=1.5, height=0.8];" + ) + parts.append(get_all_nodes(target, agent, visited)) + else: + name = _escape_label(handoff.agent_name) + parts.append( + f'"{name}" [label="{name}", ' + f'shape=box, style="filled,rounded", ' + f"fillcolor=lightyellow, width=1.5, height=0.8];" + ) return "".join(parts) @@ -158,13 +180,21 @@ def get_all_edges( "{server_name}" -> "{agent_name}" [style=dashed, penwidth=1.5];""") for handoff in agent.handoffs: - if isinstance(handoff, Handoff): - parts.append(f""" - "{agent_name}" -> "{_escape_label(handoff.agent_name)}";""") if isinstance(handoff, Agent): parts.append(f""" "{agent_name}" -> "{_escape_label(handoff.name)}";""") parts.append(get_all_edges(handoff, agent, visited)) + continue + + if isinstance(handoff, Handoff): + target = _handoff_target_agent(handoff) + if target is not None: + parts.append(f""" + "{agent_name}" -> "{_escape_label(target.name)}";""") + parts.append(get_all_edges(target, agent, visited)) + else: + parts.append(f""" + "{agent_name}" -> "{_escape_label(handoff.agent_name)}";""") if not agent.handoffs: parts.append(f'"{agent_name}" -> "__end__";') diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 066aa2fa4e..5b9efb9474 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -270,7 +270,13 @@ def test_draw_graph_with_real_handoff_object(): get_all_edges (rather than the ``isinstance(handoff, Agent)`` branches), using the public ``handoff()`` factory rather than ``Mock(spec=Handoff)``. """ - child_agent = Agent(name="ChildAgent", instructions="Child instructions") + child_tool = Mock() + child_tool.name = "ChildTool" + child_agent = Agent( + name="ChildAgent", + instructions="Child instructions", + tools=[child_tool], + ) real_handoff = handoff(child_agent) assert isinstance(real_handoff, Handoff) @@ -284,12 +290,40 @@ def test_draw_graph_with_real_handoff_object(): assert isinstance(graph, graphviz.Source) assert '"ParentAgent"' in graph.source - # Node uses agent_name from the Handoff object + # Node uses the live handoff target agent, matching Agent-in-handoffs graphs. assert ( '"ChildAgent" [label="ChildAgent", shape=box, style="filled,rounded", ' "fillcolor=lightyellow, width=1.5, height=0.8];" in graph.source ) - # Edge points from parent to handoff agent_name + assert ( + '"ChildTool" [label="ChildTool", shape=ellipse, style=filled, ' + "fillcolor=lightgreen, width=0.5, height=0.3];" in graph.source + ) + # Edge points from parent to handoff target assert '"ParentAgent" -> "ChildAgent";' in graph.source # Parent has handoffs, so should NOT connect directly to __end__ assert '"ParentAgent" -> "__end__"' not in graph.source + # Child has no handoffs, so should connect to __end__ like Agent handoffs. + assert '"ChildAgent" -> "__end__"' in graph.source + + +def test_draw_graph_keeps_stub_for_handoff_without_agent_ref(): + """Handoff objects without a recoverable agent remain name-only stubs.""" + stub_handoff = Mock(spec=Handoff) + stub_handoff.agent_name = "ExternalHandoff" + stub_handoff._agent_ref = None + + parent_agent = Agent( + name="ParentAgent", + instructions="Parent instructions", + handoffs=[stub_handoff], + ) + + graph = draw_graph(parent_agent) + + assert ( + '"ExternalHandoff" [label="ExternalHandoff", shape=box, style="filled,rounded", ' + "fillcolor=lightyellow, width=1.5, height=0.8];" in graph.source + ) + assert '"ParentAgent" -> "ExternalHandoff";' in graph.source + assert '"ExternalHandoff" -> "__end__"' not in graph.source From fb8fa1ba5c23f7ec61ca20c735999cf81e829a8e Mon Sep 17 00:00:00 2001 From: Chirag Gupta <103719146+chiruu12@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:20:31 +0530 Subject: [PATCH 368/473] fix(core): isolate usage between RunState checkpoints (#4479) --- src/agents/agent.py | 13 +++--- src/agents/run_context.py | 5 +++ tests/test_agent_as_tool.py | 79 +++++++++++++++++++++++++++++++++++++ tests/test_run_state.py | 48 ++++++++++++++++++++++ 4 files changed, 139 insertions(+), 6 deletions(-) diff --git a/src/agents/agent.py b/src/agents/agent.py index 72d3e03265..922a43b85b 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -869,12 +869,6 @@ def _nested_approvals_status( should_record_run_result = False elif status in ("approved", "rejected"): resume_state = resolved_pending_result.to_state() - if resume_state._context is not None: - # Keep accumulating nested post-resume usage on the parent - # ToolContext accumulator. resolve_resumed_context only - # replaces application .context and would otherwise leave - # the restored nested wrapper on a detached Usage object. - resume_state._context.usage = context.usage record_agent_tool_resume_state( context.tool_call, resume_state, @@ -882,6 +876,13 @@ def _nested_approvals_status( approval_items=resolved_pending_result.interruptions, ) + # A cached nested Agent.as_tool() resume reuses resume_state without + # going through the branch above, so rebind usage on both paths: nested + # post-resume model turns must accrue on the current outer ToolContext, + # not on the detached copy _copy_for_run_state now isolates. + if resume_state is not None and resume_state._context is not None: + resume_state._context.usage = context.usage + if run_result is None: if on_stream is not None: stream_handler = on_stream diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 064d540a63..05ed57a6a1 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -117,6 +117,11 @@ def _share_tool_state_with(self, target: RunContextWrapper[Any]) -> None: def _copy_for_run_state(self) -> RunContextWrapper[TContext]: """Copy SDK-owned tool state for an independently resumable checkpoint.""" copied = copy.copy(self) + # Usage accrues in place through Usage.add, which also extends + # request_usage_entries, so a shared instance would let one resumed + # checkpoint's tokens land on every other checkpoint and on the result + # the checkpoints came from. + copied.usage = copy.deepcopy(self.usage) copied._approvals = copy.deepcopy(self._approvals) copied._tool_invocations = copy.deepcopy(self._tool_invocations) copied._restored_unbound_approval_call_ids = set(self._restored_unbound_approval_call_ids) diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index 777cbbc246..513335cab1 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -41,6 +41,7 @@ from agents.agent_tool_input import StructuredToolInputBuilderOptions from agents.agent_tool_state import ( get_agent_tool_state_scope, + record_agent_tool_resume_state, record_agent_tool_run_result, set_agent_tool_state_scope, ) @@ -1540,6 +1541,84 @@ async def extractor(result: Any) -> str: assert run_inputs == [resume_state] +@pytest.mark.asyncio +async def test_agent_as_tool_cached_resume_rebinds_usage_to_outer_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cached nested resume must bill its post-resume turns on the outer run's usage. + + _copy_for_run_state now deep-copies usage so top-level checkpoints stay isolated. + That also detaches the nested Agent.as_tool() resume checkpoint, so the cached + resume path has to rebind usage onto the current outer ToolContext or the nested + model turns go missing from the outer RunResult. + """ + + agent = Agent(name="outer") + tool_call = make_function_tool_call( + "outer_tool", + call_id="outer-1", + arguments='{"input": "hello"}', + ) + tool_context = ToolContext( + context=None, + tool_name="outer_tool", + tool_call_id="outer-1", + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + tool_context.usage.requests = 3 + + class DummyState: + def __init__(self, nested_context: ToolContext) -> None: + self._context = nested_context + + detached_context = ToolContext( + context=None, + tool_name=tool_call.name, + tool_call_id=tool_call.call_id, + tool_arguments=tool_call.arguments, + tool_call=tool_call, + ) + resume_state = DummyState(detached_context) + assert resume_state._context.usage is not tool_context.usage + + # Store it as an in-flight resume checkpoint so the cached resume branch fires. + record_agent_tool_resume_state(tool_call, cast(Any, resume_state)) + + class DummyResumedResult: + def __init__(self) -> None: + self.interruptions: list[Any] = [] + self.final_output = "done" + + resumed_result = DummyResumedResult() + seen_usage: list[Any] = [] + + async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: + assert input is resume_state + # The rebind must land before the nested run so its turns accrue on the outer usage. + seen_usage.append(input._context.usage) + return resumed_result + + monkeypatch.setattr(Runner, "run", classmethod(run_resume)) + + async def extractor(result: Any) -> str: + assert result is resumed_result + return "from_resume" + + tool = agent.as_tool( + tool_name="outer_tool", + tool_description="Outer agent tool", + custom_output_extractor=extractor, + is_enabled=True, + ) + + output = await tool.on_invoke_tool(tool_context, tool_call.arguments) + + assert output == "from_resume" + assert seen_usage == [tool_context.usage] + assert resume_state._context.usage is tool_context.usage + + @pytest.mark.asyncio async def test_agent_as_tool_wrapped_hosted_mcp_exact_decision_resumes_run( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 00e989db86..f65999c3e9 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -6535,6 +6535,54 @@ async def test_resume_from_run_state_does_not_mutate_source_result(self): assert len(result1.raw_responses) == 1 assert result1.raw_responses is not result2.raw_responses + @pytest.mark.asyncio + async def test_resume_from_run_state_does_not_mutate_source_result_usage(self): + """Resuming from a state must not add its tokens to the usage already returned.""" + model = ScriptedModel() + agent = Agent(name="TestAgent", model=model) + + model.enqueue([get_text_message("First response")]) + result1 = await Runner.run(agent, "First input") + requests_after_first_run = result1.context_wrapper.usage.requests + + state = result1.to_state() + + model.enqueue([get_text_message("Second response")]) + result2 = await Runner.run(agent, state) + + # The resumed run carries the first run's totals forward, but the RunResult + # already handed back to the caller must keep only its own. + assert result2.context_wrapper.usage.requests > requests_after_first_run + assert result1.context_wrapper.usage.requests == requests_after_first_run + assert result1.context_wrapper.usage is not result2.context_wrapper.usage + + @pytest.mark.asyncio + async def test_two_checkpoints_from_one_result_do_not_share_usage(self): + """Two checkpoints must bill their own resumed run, not each other's.""" + model = ScriptedModel() + agent = Agent(name="TestAgent", model=model) + + model.enqueue([get_text_message("First response")]) + result = await Runner.run(agent, "First input") + + first_checkpoint = result.to_state() + second_checkpoint = result.to_state() + + model.enqueue([get_text_message("Second response")]) + first_resume = await Runner.run(agent, first_checkpoint) + + model.enqueue([get_text_message("Third response")]) + second_resume = await Runner.run(agent, second_checkpoint) + + # Each checkpoint resumed exactly once from the same one-request run, so both + # must report the same total instead of the second inheriting the first's. + assert first_resume.context_wrapper.usage.requests == 2 + assert second_resume.context_wrapper.usage.requests == 2 + assert ( + first_resume.context_wrapper.usage.request_usage_entries + is not second_resume.context_wrapper.usage.request_usage_entries + ) + @pytest.mark.asyncio async def test_resume_does_not_append_to_the_state_it_resumed_from(self): """A resumed run must not accumulate its responses into the caller's checkpoint.""" From 4df9ecfae1761ca6fea67cc5a20b383c1d492024 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 19 Aug 2026 22:41:43 +0900 Subject: [PATCH 369/473] release: 0.22.0 (#4523) --- pyproject.toml | 2 +- tests/fixtures/released_api_contract.json | 4 ++-- uv.lock | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1e475a3276..db7a3a6195 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.21.1" +version = "0.22.0" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/fixtures/released_api_contract.json b/tests/fixtures/released_api_contract.json index 744e8d0102..b89f6c8362 100644 --- a/tests/fixtures/released_api_contract.json +++ b/tests/fixtures/released_api_contract.json @@ -1,6 +1,6 @@ { - "baseline": "v0.21.1", - "baseline_commit": "2632043a4ed91fc819a7cfdee96958b54a00d247", + "baseline": "v0.22.0", + "baseline_commit": "fb8fa1ba5c23f7ec61ca20c735999cf81e829a8e", "callables": { "Agent": { "dataclass_fields": [ diff --git a/uv.lock b/uv.lock index 099b9bfec5..8db8ff84b8 100644 --- a/uv.lock +++ b/uv.lock @@ -2541,7 +2541,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.21.1" +version = "0.22.0" source = { editable = "." } dependencies = [ { name = "griffelib" }, From 727e729f212d7d8e396480ea73786c3d1cf74ac7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 19 Aug 2026 22:45:45 +0900 Subject: [PATCH 370/473] docs: document v0.22.0 behavior changes (#4522) --- docs/agents.md | 2 ++ docs/config.md | 2 ++ docs/guardrails.md | 2 ++ docs/release.md | 13 +++++++++++++ docs/results.md | 2 ++ docs/running_agents.md | 1 + docs/usage.md | 18 ++++++++++++++++++ docs/visualization.md | 6 ++++-- 8 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 7d36e5245a..37ee01a508 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -319,6 +319,8 @@ robot_agent = pirate_agent.clone( ) ``` +`clone()` uses `dataclasses.replace`, so it performs a shallow copy. A list attribute that you do not override, such as `tools`, `handoffs`, `mcp_servers`, `input_guardrails`, or `output_guardrails`, remains the exact list held by the original agent. Mutating that list through either agent therefore affects both agents. To give the clone an independent list container, pass a new list, for example `pirate_agent.clone(tools=[*pirate_agent.tools, extra_tool])`. The entries copied into that new list remain the same tool or handoff objects unless you replace those entries too. + ## Forcing tool use Supplying a list of tools doesn't always mean the LLM will use a tool. You can force tool use by setting [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]. Valid values are: diff --git a/docs/config.md b/docs/config.md index 0a4e122419..98de64ea0a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,6 +51,8 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` +When you pass an explicit client to [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider], that client owns its connection and account settings. Do not also pass `api_key`, `base_url`, `websocket_base_url`, `organization`, or `project` to `OpenAIProvider`; combining `openai_client` with any of those arguments raises [`UserError`][agents.exceptions.UserError] instead of silently ignoring the duplicate value. Set the intended values when constructing `AsyncOpenAI`. + ### Custom HTTP clients with `openai` v3 Version 0.21.0 requires `openai>=3.0.0,<4`. The default OpenAI provider uses HTTPX2, so most applications do not need to configure an HTTP client directly. If your application passes `http_client=` to `AsyncOpenAI`, use HTTPX2 types for the custom client and its transport-facing options: diff --git a/docs/guardrails.md b/docs/guardrails.md index 9b258e824d..5ae8485628 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -53,6 +53,8 @@ Output guardrails run in 3 steps: An output tripwire and an exception raised by the guardrail function have different session behavior. A tripwire rejects the candidate final output. When a tripwire fires, the runner asks the configured session to persist already-completed tool call and tool output items, together with any reasoning context required to replay those calls, while excluding the rejected candidate final output. The runner applies this tripwire rule to both streaming and non-streaming runs. When the guardrail function raises an exception instead of returning a tripwire result, the runner treats the verdict as unknown and asks the configured session to persist the completed final-turn items before surfacing the guardrail exception. If that session write also fails, the session write error takes precedence. Streaming runs use the same persistence ordering as non-streaming runs and raise the terminal exception from `stream_events()`. An immediate [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel] call while the output guardrail is running cancels the in-flight guardrail and does not start a final-turn session write. +Terminal function-tool output needs additional handling because the tool has already run before the agent-level output guardrail checks the value. When [`Agent.tool_use_behavior`][agents.agent.Agent.tool_use_behavior] makes that tool result the final output and an output tripwire rejects it, the SDK retains a replay-valid function call/output pair only when it can rebuild the pair from validated fields. The retained `function_call_output` payload is replaced with the fixed text `"Output withheld by an output guardrail."`; the original tool-output payload is not retained in the session, `RunState`, streamed result state, or sandbox memory input. The SDK does retain validated function-call metadata required for replay, including the function arguments, so that metadata can contain data that also appeared in the rejected output. Current-response [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] objects also replace `agent_output` with the fixed text and clear `output_info`. Current-response [`ToolOutputGuardrailResult`][agents.tool_guardrails.ToolOutputGuardrailResult] objects preserve the allow/reject behavior type but replace payload-bearing `output_info` and rejection messages with the same text. Earlier accepted turns and guardrail results remain unchanged. If the response contains reasoning or another shape that the SDK cannot sanitize safely, the SDK discards the complete current-response suffix instead of retaining the rejected output payload. A guardrail function that raises an exception has not returned a rejection verdict, so the completed terminal-tool turn follows the exception persistence behavior described above. + ## Tool guardrails Tool guardrails wrap **`FunctionTool` instances** and let you validate or block calls to those tools before and after execution. They are configured on the tool itself and run every time that tool is invoked. diff --git a/docs/release.md b/docs/release.md index 3f15056b2f..df7cd888b7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,6 +19,19 @@ We will increment `Z` for non-breaking changes: ## Breaking change changelog +### 0.22.0 + +Version 0.22.0 tightens failure handling and data isolation for several existing APIs. Applications that construct `OpenAIProvider` with an explicit client and also pass `organization` or `project` to the provider must remove those duplicate arguments. + +Highlights: + +- When an agent-level output guardrail blocks final output produced directly by a terminal function tool, the SDK retains a replay-valid call/output pair only when validated fields permit safe reconstruction. The original `function_call_output` payload is replaced with the fixed text `"Output withheld by an output guardrail."` in session history, `RunState`, and streamed result state, and payload-bearing current-response guardrail metadata is cleared or replaced. If the current response contains reasoning or another unsupported shape, the SDK discards the complete current-response suffix instead. Earlier accepted turns and guardrail results remain available. See [Output guardrails](guardrails.md#output-guardrails). +- Non-streaming OpenAI Responses calls now raise `ModelBehaviorError` when the returned response has terminal status `failed` or `incomplete`, matching the existing streamed terminal-event handling. This applies to `OpenAIResponsesModel` and the Responses path in `AnyLLMModel`. See [Exceptions](running_agents.md#exceptions). +- [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider] now also raises `UserError` when `openai_client` is combined with `organization` or `project`. The existing conflicts with `api_key`, `base_url`, and `websocket_base_url` are unchanged. Configure these values on the explicit `AsyncOpenAI` client instead. See [API keys and clients](config.md#api-keys-and-clients). +- Each `RunResult.to_state()` checkpoint now owns an independent usage snapshot. A resumed result starts with the checkpoint totals and adds its own model calls without mutating the source result or sibling checkpoints. Nested `Agent.as_tool()` resumes continue to aggregate post-resume usage into the active outer run. See [Usage in RunState checkpoints](usage.md#usage-in-runstate-checkpoints). +- Agent visualization now recursively expands the tools, MCP servers, and downstream handoffs of a target registered with `handoff(agent)`, matching direct `Agent` entries in an agent's `handoffs` list. See [Generating a graph](visualization.md#generating-a-graph). +- The `Agent.clone()` and `RealtimeAgent.clone()` API guidance now states their existing shallow-copy behavior precisely: list attributes that are not overridden remain the same list objects. Pass a new list when the clone must own the container independently. See [Cloning/copying agents](agents.md#cloningcopying-agents). + ### 0.21.0 Version 0.21.0 requires `openai` v3 and moves the Agents SDK's OpenAI HTTP integrations to HTTPX2. Applications that use the default OpenAI client do not need to change their client setup, but applications that customize the OpenAI HTTP layer may need to migrate transport-facing code. diff --git a/docs/results.md b/docs/results.md index 16631c8948..5f9126b170 100644 --- a/docs/results.md +++ b/docs/results.md @@ -211,6 +211,8 @@ Tool guardrails are exposed separately as [`tool_input_guardrail_results`][agent These arrays accumulate across the run, so they are useful for logging decisions, storing extra guardrail metadata, or debugging why a run was blocked. +One redaction rule applies when an agent-level output guardrail blocks final output produced directly by a terminal function tool. For the blocked current response, `output_guardrail_results` replaces the rejected agent output and clears payload-bearing output metadata, while `tool_output_guardrail_results` replaces payload-bearing tool metadata. Earlier accepted results remain unchanged. The sanitized output-guardrail result is exposed as `guardrail_result` on [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]. Sanitized output-guardrail and tool-output-guardrail results are also exposed through streamed result state and `RunState`; see [Output guardrails](guardrails.md#output-guardrails). + ### Context and usage [`context_wrapper`][agents.result.RunResultBase.context_wrapper] exposes your app context together with SDK-managed runtime metadata such as approvals, usage, and nested `tool_input`. diff --git a/docs/running_agents.md b/docs/running_agents.md index 3f3e64beb6..273e0617bb 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -593,6 +593,7 @@ The SDK raises exceptions in certain cases. The full list is in [`agents.excepti - [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: This exception occurs when the underlying model (LLM) produces unexpected or invalid outputs. This can include: - Malformed JSON: When the model provides a malformed JSON structure for tool calls or in its direct output, especially if a specific `output_type` is defined. - Unexpected tool-related failures: When the model fails to use tools in an expected manner + - Failed or incomplete non-streaming Responses calls: `OpenAIResponsesModel` and the Responses path in `AnyLLMModel` raise this exception when the returned response has terminal status `failed` or `incomplete`. The exception identifies the terminal status and includes available error or incomplete details from the response. - [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: This exception is raised when a function tool call exceeds its configured timeout and the tool uses `timeout_behavior="raise_exception"`. - [`UserError`][agents.exceptions.UserError]: This exception is raised when you (the person writing code using the SDK) make an error while using the SDK. This typically results from incorrect code implementation, invalid configuration, or misuse of the SDK's API. - [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: `InputGuardrailTripwireTriggered` is raised when an input guardrail's conditions are met, and `OutputGuardrailTripwireTriggered` is raised when an output guardrail's conditions are met. Input guardrails check incoming messages before processing, while output guardrails check the agent's final response before delivery. diff --git a/docs/usage.md b/docs/usage.md index 95fc09ec5d..f752dc0e49 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -91,6 +91,24 @@ print(second.context_wrapper.usage.total_tokens) # Usage for second run Note that while sessions preserve conversation context between runs, the usage metrics returned by each `Runner.run()` call represent only that particular execution. In sessions, previous messages may be re-fed as input to each run, which affects the input token count in subsequent turns. +## Usage in RunState checkpoints + +[`RunResult.to_state()`][agents.result.RunResult.to_state] captures an independent snapshot of the usage accumulated so far. A run resumed from that checkpoint starts with the captured totals and adds usage from its own model calls. The resumed run does not add those new totals to the original `RunResult` or to another checkpoint created from that result. + +```python +first = await Runner.run(agent, "First request") +checkpoint_a = first.to_state() +checkpoint_b = first.to_state() + +resumed_a = await Runner.run(agent, checkpoint_a) +resumed_b = await Runner.run(agent, checkpoint_b) + +assert resumed_a.context_wrapper.usage is not first.context_wrapper.usage +assert resumed_b.context_wrapper.usage is not resumed_a.context_wrapper.usage +``` + +This isolation also applies to the `request_usage_entries` list inside [`Usage`][agents.usage.Usage]. A resumed nested [`Agent.as_tool()`][agents.agent.Agent.as_tool] run is the exception to independent top-level accounting: its post-resume model usage is deliberately aggregated into the active outer run's usage, just like the nested run's earlier model calls. + ## Using usage in hooks If you're using `RunHooks`, the `context` object passed to each hook contains `usage`. This lets you log usage at key lifecycle moments. diff --git a/docs/visualization.md b/docs/visualization.md index 9b5e015697..cf173a63ec 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -24,7 +24,7 @@ You can generate an agent visualization using the `draw_graph` function. This fu ```python import os -from agents import Agent +from agents import Agent, handoff from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph @@ -56,7 +56,7 @@ mcp_server = MCPServerStdio( triage_agent = Agent( name="Triage agent", instructions="Handoff to the appropriate agent based on the language of the request.", - handoffs=[spanish_agent, english_agent], + handoffs=[handoff(spanish_agent), handoff(english_agent)], tools=[get_weather], mcp_servers=[mcp_server], ) @@ -68,6 +68,8 @@ draw_graph(triage_agent) This generates a graph that visually represents the structure of the **triage agent** and its connections to sub-agents and tools. +`draw_graph()` recursively expands target agents supplied directly in `handoffs` or registered through `handoff(agent)`. In both forms, the graph includes each target's tools, MCP servers, and downstream handoffs. A custom `Handoff` without an available target `Agent` is rendered as a named destination only, so the graph cannot expand resources behind that destination. + ## Understanding the visualization From aee76c8e9707799158d2dd25bc1ea8202003eb3d Mon Sep 17 00:00:00 2001 From: Chair403 <98937891+Chair403@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:53:11 +0800 Subject: [PATCH 371/473] docs: fix wording in handoff example prompts (#4520) --- examples/handoffs/message_filter.py | 4 ++-- examples/handoffs/message_filter_streaming.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/handoffs/message_filter.py b/examples/handoffs/message_filter.py index ce519cf913..83bc821356 100644 --- a/examples/handoffs/message_filter.py +++ b/examples/handoffs/message_filter.py @@ -93,7 +93,7 @@ async def main(): input=result.to_input_list() + [ { - "content": "I live in New York City. Whats the population of the city?", + "content": "I live in New York City. What's the population of the city?", "role": "user", } ], @@ -152,7 +152,7 @@ async def main(): "type": "message" } { - "content": "I live in New York City. Whats the population of the city?", + "content": "I live in New York City. What's the population of the city?", "role": "user" } { diff --git a/examples/handoffs/message_filter_streaming.py b/examples/handoffs/message_filter_streaming.py index 4652d61574..0b1055fd26 100644 --- a/examples/handoffs/message_filter_streaming.py +++ b/examples/handoffs/message_filter_streaming.py @@ -93,7 +93,7 @@ async def main(): input=result.to_input_list() + [ { - "content": "I live in New York City. Whats the population of the city?", + "content": "I live in New York City. What's the population of the city?", "role": "user", } ], @@ -152,7 +152,7 @@ async def main(): "type": "message" } { - "content": "I live in New York City. Whats the population of the city?", + "content": "I live in New York City. What's the population of the city?", "role": "user" } { From 629f9b56a5d32d52d1216f8866f5f0beae2484dc Mon Sep 17 00:00:00 2001 From: green3sf <222944370+green3sf@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:55:32 +0800 Subject: [PATCH 372/473] fix(core): detach aggregated request usage entries (#4519) --- src/agents/usage.py | 6 +++--- tests/test_usage.py | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/agents/usage.py b/src/agents/usage.py index 5e38fb65fc..81adfff27c 100644 --- a/src/agents/usage.py +++ b/src/agents/usage.py @@ -297,7 +297,7 @@ def add(self, other: Usage) -> None: # (this preserves nested token details that would otherwise be discarded # when synthesizing an entry from only the top-level fields). if other.request_usage_entries: - self.request_usage_entries.extend(other.request_usage_entries) + self.request_usage_entries.extend(copy.deepcopy(other.request_usage_entries)) elif other.requests == 1 and other.total_tokens > 0: # Otherwise, if the other Usage represents a single request with tokens, record it. input_details = other.input_tokens_details or _make_input_tokens_details() @@ -306,8 +306,8 @@ def add(self, other: Usage) -> None: input_tokens=other.input_tokens, output_tokens=other.output_tokens, total_tokens=other.total_tokens, - input_tokens_details=input_details, - output_tokens_details=output_details, + input_tokens_details=copy.deepcopy(input_details), + output_tokens_details=copy.deepcopy(output_details), ) self.request_usage_entries.append(request_usage) diff --git a/tests/test_usage.py b/tests/test_usage.py index 58e5031438..5eef65543c 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -366,6 +366,58 @@ def test_usage_add_preserves_existing_entries_when_top_level_also_set(): assert entry.output_tokens_details.reasoning_tokens == 5 +def test_usage_add_detaches_pre_existing_request_usage_entries(): + source_entry = RequestUsage( + input_tokens=100, + output_tokens=50, + total_tokens=150, + input_tokens_details=InputTokensDetails.model_validate( + {"cache_write_tokens": 0, "cached_tokens": 10} + ), + output_tokens_details=OutputTokensDetails(reasoning_tokens=5), + ) + source = Usage( + requests=1, + input_tokens=100, + output_tokens=50, + total_tokens=150, + request_usage_entries=[source_entry], + ) + aggregate = Usage() + + aggregate.add(source) + source_entry.input_tokens = 999 + source_entry.input_tokens_details.cached_tokens = 99 + source_entry.output_tokens_details.reasoning_tokens = 99 + + aggregate_entry = aggregate.request_usage_entries[0] + assert aggregate_entry.input_tokens == 100 + assert aggregate_entry.input_tokens_details.cached_tokens == 10 + assert aggregate_entry.output_tokens_details.reasoning_tokens == 5 + + +def test_usage_add_detaches_synthesized_request_usage_details(): + source = Usage( + requests=1, + input_tokens=100, + output_tokens=50, + total_tokens=150, + input_tokens_details=InputTokensDetails.model_validate( + {"cache_write_tokens": 0, "cached_tokens": 10} + ), + output_tokens_details=OutputTokensDetails(reasoning_tokens=5), + ) + aggregate = Usage() + + aggregate.add(source) + source.input_tokens_details.cached_tokens = 99 + source.output_tokens_details.reasoning_tokens = 99 + + aggregate_entry = aggregate.request_usage_entries[0] + assert aggregate_entry.input_tokens_details.cached_tokens == 10 + assert aggregate_entry.output_tokens_details.reasoning_tokens == 5 + + def test_usage_request_usage_entries_default_empty(): """Test that request_usage_entries defaults to an empty list.""" u = Usage() From fe34ba3aa9a0f53e190badb41fc8b921ec2ca51b Mon Sep 17 00:00:00 2001 From: zhewen tan <127607634+tandede@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:07:35 +0800 Subject: [PATCH 373/473] fix(visualization): preserve nodes with duplicate names (#4512) --- src/agents/extensions/visualization.py | 269 +++++++++++++++++++++---- tests/test_visualization.py | 152 +++++++++++++- 2 files changed, 386 insertions(+), 35 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 9bf9702314..5f1881759f 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -1,10 +1,122 @@ from __future__ import annotations +from collections import Counter + import graphviz # type: ignore from agents import Agent from agents.handoffs import Handoff +_NodeKey = tuple[str, int] + + +class _GraphNodeIds: + """Assign stable DOT identifiers without conflating nodes that share a label.""" + + def __init__( + self, + agent: Agent, + *, + initially_visited_names: frozenset[str] = frozenset(), + ) -> None: + nodes: list[tuple[_NodeKey, str]] = [] + previsited_agents: list[tuple[_NodeKey, str]] = [] + node_keys: set[_NodeKey] = set() + visited_agents: set[int] = set() + + def add_node(key: _NodeKey, label: str) -> None: + if key not in node_keys: + node_keys.add(key) + nodes.append((key, label)) + + def visit(current_agent: Agent) -> None: + agent_key = self.agent_key(current_agent) + if id(current_agent) in visited_agents: + return + visited_agents.add(id(current_agent)) + if current_agent.name in initially_visited_names: + previsited_agents.append((agent_key, current_agent.name)) + return + add_node(agent_key, current_agent.name) + + for tool in current_agent.tools: + add_node(self.tool_key(tool), tool.name) + for server in current_agent.mcp_servers: + add_node(self.mcp_server_key(server), server.name) + for handoff in current_agent.handoffs: + if isinstance(handoff, Agent): + visit(handoff) + continue + if isinstance(handoff, Handoff): + target = _handoff_target_agent(handoff) + if target is not None: + visit(target) + else: + add_node(self.handoff_key(handoff), handoff.agent_name) + + visit(agent) + + escaped_labels = [(key, label, _escape_label(label)) for key, label in nodes] + escaped_previsited_agents = [ + (key, label, _escape_label(label)) for key, label in previsited_agents + ] + label_counts = Counter(escaped_label for _, _, escaped_label in escaped_labels) + raw_labels = { + escaped_label for _, _, escaped_label in [*escaped_previsited_agents, *escaped_labels] + } + used_ids = {"__start__", "__end__"} + self._ids: dict[_NodeKey, str] = {} + generated_id = 0 + + def generate_id(key: _NodeKey) -> str: + nonlocal generated_id + while True: + node_id = f"__agents_graph_{key[0]}_{generated_id}__" + generated_id += 1 + if node_id not in raw_labels and node_id not in used_ids: + return node_id + + for key, label, escaped_label in escaped_previsited_agents: + node_id = label if escaped_label not in used_ids else generate_id(key) + used_ids.add(_escape_label(node_id)) + self._ids[key] = node_id + + for key, label, escaped_label in escaped_labels: + if label_counts[escaped_label] == 1 and escaped_label not in used_ids: + node_id = label + else: + node_id = generate_id(key) + used_ids.add(_escape_label(node_id)) + self._ids[key] = node_id + + @staticmethod + def agent_key(agent: Agent) -> _NodeKey: + return ("agent", id(agent)) + + @staticmethod + def tool_key(tool: object) -> _NodeKey: + return ("tool", id(tool)) + + @staticmethod + def mcp_server_key(server: object) -> _NodeKey: + return ("mcp", id(server)) + + @staticmethod + def handoff_key(handoff: object) -> _NodeKey: + return ("handoff", id(handoff)) + + def agent(self, agent: Agent) -> str: + return self._ids[self.agent_key(agent)] + + def tool(self, tool: object) -> str: + return self._ids[self.tool_key(tool)] + + def mcp_server(self, server: object) -> str: + return self._ids[self.mcp_server_key(server)] + + def handoff(self, handoff: object) -> str: + return self._ids[self.handoff_key(handoff)] + def _escape_label(name: str) -> str: """Escape a name for use inside a Graphviz double-quoted ID or label. @@ -49,8 +161,9 @@ def get_main_graph(agent: Agent) -> str: edge [penwidth=1.5]; """ ] - parts.append(get_all_nodes(agent)) - parts.append(get_all_edges(agent)) + node_ids = _GraphNodeIds(agent) + parts.append(_get_all_nodes(agent, node_ids=node_ids)) + parts.append(_get_all_edges(agent, node_ids=node_ids)) parts.append("}") return "".join(parts) @@ -67,11 +180,34 @@ def get_all_nodes( Returns: str: The DOT format string representing the nodes. """ - if visited is None: - visited = set() - if agent.name in visited: + visited_names = visited if visited is not None else set() + initially_visited_names = frozenset(visited_names) + return _get_all_nodes( + agent, + parent=parent, + visited_names=visited_names, + initially_visited_names=initially_visited_names, + node_ids=_GraphNodeIds(agent, initially_visited_names=initially_visited_names), + ) + + +def _get_all_nodes( + agent: Agent, + *, + node_ids: _GraphNodeIds, + parent: Agent | None = None, + visited_names: set[str] | None = None, + initially_visited_names: frozenset[str] = frozenset(), + visited_agents: set[int] | None = None, +) -> str: + if visited_names is None: + visited_names = set() + if visited_agents is None: + visited_agents = set() + if id(agent) in visited_agents or agent.name in initially_visited_names: return "" - visited.add(agent.name) + visited_agents.add(id(agent)) + visited_names.add(agent.name) parts = [] @@ -84,56 +220,80 @@ def get_all_nodes( "fillcolor=lightblue, width=0.5, height=0.3];" ) # Ensure parent agent node is colored + node_id = _escape_label(node_ids.agent(agent)) name = _escape_label(agent.name) parts.append( - f'"{name}" [label="{name}", ' + f'"{node_id}" [label="{name}", ' "shape=box, style=filled, " "fillcolor=lightyellow, width=1.5, height=0.8];" ) for tool in agent.tools: + node_id = _escape_label(node_ids.tool(tool)) name = _escape_label(tool.name) parts.append( - f'"{name}" [label="{name}", ' + f'"{node_id}" [label="{name}", ' "shape=ellipse, style=filled, " "fillcolor=lightgreen, width=0.5, height=0.3];" ) for mcp_server in agent.mcp_servers: + node_id = _escape_label(node_ids.mcp_server(mcp_server)) name = _escape_label(mcp_server.name) parts.append( - f'"{name}" [label="{name}", ' + f'"{node_id}" [label="{name}", ' "shape=box, style=filled, " "fillcolor=lightgrey, width=1, height=0.5];" ) for handoff in agent.handoffs: if isinstance(handoff, Agent): - if handoff.name not in visited: + if id(handoff) not in visited_agents and handoff.name not in initially_visited_names: + node_id = _escape_label(node_ids.agent(handoff)) name = _escape_label(handoff.name) parts.append( - f'"{name}" [label="{name}", ' + f'"{node_id}" [label="{name}", ' f'shape=box, style="filled,rounded", ' f"fillcolor=lightyellow, width=1.5, height=0.8];" ) - parts.append(get_all_nodes(handoff, agent, visited)) + parts.append( + _get_all_nodes( + handoff, + parent=agent, + visited_names=visited_names, + initially_visited_names=initially_visited_names, + visited_agents=visited_agents, + node_ids=node_ids, + ) + ) continue if isinstance(handoff, Handoff): target = _handoff_target_agent(handoff) if target is not None: - if target.name not in visited: + if id(target) not in visited_agents and target.name not in initially_visited_names: + node_id = _escape_label(node_ids.agent(target)) name = _escape_label(target.name) parts.append( - f'"{name}" [label="{name}", ' + f'"{node_id}" [label="{name}", ' f'shape=box, style="filled,rounded", ' f"fillcolor=lightyellow, width=1.5, height=0.8];" ) - parts.append(get_all_nodes(target, agent, visited)) + parts.append( + _get_all_nodes( + target, + parent=agent, + visited_names=visited_names, + initially_visited_names=initially_visited_names, + visited_agents=visited_agents, + node_ids=node_ids, + ) + ) else: + node_id = _escape_label(node_ids.handoff(handoff)) name = _escape_label(handoff.agent_name) parts.append( - f'"{name}" [label="{name}", ' + f'"{node_id}" [label="{name}", ' f'shape=box, style="filled,rounded", ' f"fillcolor=lightyellow, width=1.5, height=0.8];" ) @@ -154,50 +314,91 @@ def get_all_edges( Returns: str: The DOT format string representing the edges. """ - if visited is None: - visited = set() - if agent.name in visited: + visited_names = visited if visited is not None else set() + initially_visited_names = frozenset(visited_names) + return _get_all_edges( + agent, + parent=parent, + visited_names=visited_names, + initially_visited_names=initially_visited_names, + node_ids=_GraphNodeIds(agent, initially_visited_names=initially_visited_names), + ) + + +def _get_all_edges( + agent: Agent, + *, + node_ids: _GraphNodeIds, + parent: Agent | None = None, + visited_names: set[str] | None = None, + initially_visited_names: frozenset[str] = frozenset(), + visited_agents: set[int] | None = None, +) -> str: + if visited_names is None: + visited_names = set() + if visited_agents is None: + visited_agents = set() + if id(agent) in visited_agents or agent.name in initially_visited_names: return "" - visited.add(agent.name) + visited_agents.add(id(agent)) + visited_names.add(agent.name) parts = [] - agent_name = _escape_label(agent.name) + agent_id = _escape_label(node_ids.agent(agent)) if parent is None: - parts.append(f'"__start__" -> "{agent_name}";') + parts.append(f'"__start__" -> "{agent_id}";') for tool in agent.tools: - tool_name = _escape_label(tool.name) + tool_id = _escape_label(node_ids.tool(tool)) parts.append(f""" - "{agent_name}" -> "{tool_name}" [style=dotted, penwidth=1.5]; - "{tool_name}" -> "{agent_name}" [style=dotted, penwidth=1.5];""") + "{agent_id}" -> "{tool_id}" [style=dotted, penwidth=1.5]; + "{tool_id}" -> "{agent_id}" [style=dotted, penwidth=1.5];""") for mcp_server in agent.mcp_servers: - server_name = _escape_label(mcp_server.name) + server_id = _escape_label(node_ids.mcp_server(mcp_server)) parts.append(f""" - "{agent_name}" -> "{server_name}" [style=dashed, penwidth=1.5]; - "{server_name}" -> "{agent_name}" [style=dashed, penwidth=1.5];""") + "{agent_id}" -> "{server_id}" [style=dashed, penwidth=1.5]; + "{server_id}" -> "{agent_id}" [style=dashed, penwidth=1.5];""") for handoff in agent.handoffs: if isinstance(handoff, Agent): parts.append(f""" - "{agent_name}" -> "{_escape_label(handoff.name)}";""") - parts.append(get_all_edges(handoff, agent, visited)) + "{agent_id}" -> "{_escape_label(node_ids.agent(handoff))}";""") + parts.append( + _get_all_edges( + handoff, + parent=agent, + visited_names=visited_names, + initially_visited_names=initially_visited_names, + visited_agents=visited_agents, + node_ids=node_ids, + ) + ) continue if isinstance(handoff, Handoff): target = _handoff_target_agent(handoff) if target is not None: parts.append(f""" - "{agent_name}" -> "{_escape_label(target.name)}";""") - parts.append(get_all_edges(target, agent, visited)) + "{agent_id}" -> "{_escape_label(node_ids.agent(target))}";""") + parts.append( + _get_all_edges( + target, + parent=agent, + visited_names=visited_names, + initially_visited_names=initially_visited_names, + visited_agents=visited_agents, + node_ids=node_ids, + ) + ) else: parts.append(f""" - "{agent_name}" -> "{_escape_label(handoff.agent_name)}";""") + "{agent_id}" -> "{_escape_label(node_ids.handoff(handoff))}";""") if not agent.handoffs: - parts.append(f'"{agent_name}" -> "__end__";') + parts.append(f'"{agent_id}" -> "__end__";') return "".join(parts) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 5b9efb9474..e168390f1c 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -1,4 +1,5 @@ -from unittest.mock import Mock +import re +from unittest.mock import Mock, PropertyMock import graphviz # type: ignore import pytest @@ -186,6 +187,155 @@ def test_cycle_detection(): assert '"B" -> "A"' in edges +def test_graph_keeps_different_node_types_with_the_same_name_distinct(): + shared_tool = Mock() + shared_tool.name = "shared" + shared_handoff = Mock(spec=Handoff) + shared_handoff.agent_name = "shared" + agent = Mock(spec=Agent) + agent.name = "shared" + agent.tools = [shared_tool] + agent.mcp_servers = [FakeMCPServer(server_name="shared")] + agent.handoffs = [shared_handoff] + + source = get_main_graph(agent) + + node_ids = re.findall(r'"([^"]+)" \[label="shared"', source) + assert len(node_ids) == 4 + assert len(set(node_ids)) == 4 + agent_id, tool_id, server_id, handoff_id = node_ids + assert f'"{agent_id}" -> "{tool_id}" [style=dotted' in source + assert f'"{agent_id}" -> "{server_id}" [style=dashed' in source + assert f'"{agent_id}" -> "{handoff_id}";' in source + assert all(f'"{node_id}" -> "{node_id}"' not in source for node_id in node_ids) + + +def test_graph_keeps_names_that_escape_to_the_same_id_distinct(): + shared_tool = Mock() + shared_tool.name = "shared\r" + shared_handoff = Mock(spec=Handoff) + shared_handoff.agent_name = "shared\r\n" + shared_handoff._agent_ref = None + agent = Mock(spec=Agent) + agent.name = "shared\n" + agent.tools = [shared_tool] + agent.mcp_servers = [] + agent.handoffs = [shared_handoff] + + source = get_main_graph(agent) + + node_ids = re.findall(r'"([^"]+)" \[label="shared\\n"', source) + assert len(node_ids) == 3 + assert len(set(node_ids)) == 3 + agent_id, tool_id, handoff_id = node_ids + assert f'"{agent_id}" -> "{tool_id}" [style=dotted' in source + assert f'"{agent_id}" -> "{handoff_id}";' in source + assert all(f'"{node_id}" -> "{node_id}"' not in source for node_id in node_ids) + + +@pytest.mark.parametrize("use_handoff_object", [False, True]) +def test_graph_traverses_different_agents_with_the_same_name( + use_handoff_object: bool, +): + child_tool = Mock() + child_tool.name = "child_tool" + child = Agent(name="duplicate", tools=[child_tool]) + child_handoff = handoff(child) if use_handoff_object else child + parent = Agent(name="duplicate", handoffs=[child_handoff]) + + source = get_main_graph(parent) + + agent_ids = re.findall(r'"([^"]+)" \[label="duplicate"', source) + assert len(agent_ids) == 2 + assert len(set(agent_ids)) == 2 + parent_id, child_id = agent_ids + assert f'"{parent_id}" -> "{child_id}";' in source + assert f'"{child_id}" -> "child_tool" [style=dotted' in source + + +@pytest.mark.parametrize("use_handoff_object", [False, True]) +def test_get_all_nodes_honors_prepopulated_visited_names( + use_handoff_object: bool, +): + child = Agent(name="child") + child_handoff = handoff(child) if use_handoff_object else child + parent = Agent(name="parent", handoffs=[child_handoff]) + visited = {"child"} + + nodes = get_all_nodes(parent, visited=visited) + + assert '"child" [label="child"' not in nodes + assert visited == {"parent", "child"} + + +@pytest.mark.parametrize("renderer", [get_all_nodes, get_all_edges]) +def test_graph_does_not_inspect_previsited_root(renderer): + agent = Mock(spec=Agent) + agent.name = "visited" + type(agent).tools = PropertyMock( + side_effect=AssertionError("previsited agent should not be inspected") + ) + + assert renderer(agent, visited={"visited"}) == "" + + +@pytest.mark.parametrize("renderer", [get_all_nodes, get_all_edges]) +def test_previsited_subgraph_does_not_affect_included_node_ids(renderer): + included_tool = Mock() + included_tool.name = "shared" + skipped_tool = Mock() + skipped_tool.name = "shared" + child = Agent(name="child", tools=[skipped_tool]) + parent = Agent(name="parent", tools=[included_tool], handoffs=[child]) + + source = renderer(parent, visited={"child"}) + + assert '"shared"' in source + assert "__agents_graph_tool_" not in source + + +@pytest.mark.parametrize("use_handoff_object", [False, True]) +def test_previsited_agent_reserves_its_id(use_handoff_object: bool): + tool = Mock() + tool.name = "shared" + child = Agent(name="shared") + child_handoff = handoff(child) if use_handoff_object else child + parent = Agent(name="parent", tools=[tool], handoffs=[child_handoff]) + + nodes = get_all_nodes(parent, visited={"shared"}) + edges = get_all_edges(parent, visited={"shared"}) + + tool_ids = re.findall(r'"([^"]+)" \[label="shared"', nodes) + assert len(tool_ids) == 1 + assert tool_ids[0].startswith("__agents_graph_tool_") + assert f'"parent" -> "{tool_ids[0]}" [style=dotted' in edges + assert '"parent" -> "shared";' in edges + + +def test_collision_free_escaped_ids_keep_legacy_names(): + tool = Mock() + tool.name = "shared\n" + agent = Agent(name=r"shared\n", tools=[tool]) + + source = get_main_graph(agent) + + assert '"shared\\\\n" [label="shared\\\\n"' in source + assert '"shared\\n" [label="shared\\n"' in source + assert "__agents_graph_" not in source + + +def test_graph_reserves_start_and_end_node_ids(): + agent = Agent(name="__start__") + + source = get_main_graph(agent) + + start_ids = re.findall(r'"([^"]+)" \[label="__start__"', source) + assert len(start_ids) == 2 + assert len(set(start_ids)) == 2 + assert start_ids[0] == "__start__" + assert f'"__start__" -> "{start_ids[1]}";' in source + + def test_names_with_quotes_and_backslashes_are_escaped(mock_agent): """Names containing double quotes or backslashes must be escaped in DOT. From 3e0dc82ebd15379505d9341359022f063362b573 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 19 Aug 2026 23:41:10 +0900 Subject: [PATCH 374/473] docs: update translated pages --- docs/ja/agents.md | 114 ++++++------ docs/ja/config.md | 70 +++---- docs/ja/guardrails.md | 56 +++--- docs/ja/release.md | 157 +++++++++------- docs/ja/results.md | 134 +++++++------- docs/ja/running_agents.md | 200 ++++++++++---------- docs/ja/usage.md | 78 +++++--- docs/ja/visualization.md | 26 +-- docs/ko/config.md | 72 +++---- docs/ko/guardrails.md | 58 +++--- docs/ko/release.md | 151 ++++++++------- docs/ko/results.md | 118 ++++++------ docs/ko/running_agents.md | 237 ++++++++++++------------ docs/ko/usage.md | 58 ++++-- docs/ko/visualization.md | 20 +- docs/ref/run_internal/blocked_output.md | 3 + docs/zh/config.md | 70 +++---- docs/zh/guardrails.md | 60 +++--- docs/zh/release.md | 157 +++++++++------- docs/zh/results.md | 128 ++++++------- docs/zh/running_agents.md | 229 +++++++++++------------ docs/zh/usage.md | 80 ++++---- docs/zh/visualization.md | 42 +++-- 23 files changed, 1222 insertions(+), 1096 deletions(-) create mode 100644 docs/ref/run_internal/blocked_output.md diff --git a/docs/ja/agents.md b/docs/ja/agents.md index 4503987b4b..38ec1b158c 100644 --- a/docs/ja/agents.md +++ b/docs/ja/agents.md @@ -4,26 +4,26 @@ search: --- # エージェント -エージェントは、アプリの中核となる構成要素です。エージェントとは、指示、ツール、およびハンドオフ、ガードレール、structured outputs などの任意の実行時動作を設定した大規模言語モデル(LLM)です。 +エージェントは、アプリの中核となる構成要素です。エージェントは、指示、ツール、およびハンドオフ、ガードレール、structured outputs などのオプションのランタイム動作を設定した大規模言語モデル (LLM) です。 -`SandboxAgent` ではなく、単一の基本 `Agent` を定義またはカスタマイズする場合は、このページを使用してください。複数のエージェントをどのように連携させるかを決定する場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。マニフェストで定義されたファイルとサンドボックスネイティブの機能を備えた分離ワークスペース内でエージェントを実行する場合は、[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 +`SandboxAgent` ではなく、単一の基本 `Agent` を定義またはカスタマイズする場合は、このページを使用してください。複数のエージェントをどのように連携させるかを決める場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。マニフェストで定義されたファイルとサンドボックスネイティブの機能を備えた分離ワークスペース内でエージェントを実行する場合は、[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 -SDK は、OpenAIモデルに対してデフォルトで Responses API を使用しますが、ここで重要なのはオーケストレーションです。`Agent` と `Runner` を組み合わせることで、SDK がターン、ツール、ガードレール、ハンドオフ、セッションを管理できます。このループを自身で管理する場合は、代わりに Responses API を直接使用してください。 +SDK は、OpenAI モデルに対してデフォルトで Responses API を使用しますが、ここでの違いはオーケストレーションにあります。`Agent` と `Runner` を組み合わせることで、SDK がターン、ツール、ガードレール、ハンドオフ、セッションを管理します。このループを自分で管理したい場合は、代わりに Responses API を直接使用してください。 ## 次のガイドの選択 -このページは、エージェント定義のハブとして使用してください。次に決定する必要がある内容に応じて、関連するガイドに進んでください。 +このページを、エージェント定義のハブとして使用してください。次に行う必要がある判断に合った関連ガイドに進んでください。 | 目的 | 次に読むガイド | | --- | --- | | モデルまたはプロバイダーの設定を選択する | [モデル](models/index.md) | | エージェントに機能を追加する | [ツール](tools.md) | -| 実際のリポジトリ、ドキュメント一式、または分離ワークスペースを対象にエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md) | -| マネージャー方式のオーケストレーションとハンドオフのどちらを使用するか決定する | [エージェントオーケストレーション](multi_agent.md) | +| 実際のリポジトリ、ドキュメント一式、または分離ワークスペースに対してエージェントを実行する | [サンドボックスエージェントのクイックスタート](sandbox_agents.md) | +| マネージャー形式のオーケストレーションとハンドオフのどちらを使用するか決める | [エージェントオーケストレーション](multi_agent.md) | | ハンドオフの動作を設定する | [ハンドオフ](handoffs.md) | | ターンの実行、イベントのストリーミング、または会話状態の管理を行う | [エージェントの実行](running_agents.md) | | 最終出力、実行項目、または再開可能な状態を確認する | [実行結果](results.md) | -| ローカルの依存関係と実行時状態を共有する | [コンテキスト管理](context.md) | +| ローカルの依存関係とランタイム状態を共有する | [コンテキスト管理](context.md) | ## 基本設定 @@ -33,20 +33,20 @@ SDK は、OpenAIモデルに対してデフォルトで Responses API を使用 | --- | --- | --- | | `name` | はい | 人が読める形式のエージェント名です。 | | `instructions` | いいえ | システムプロンプトまたは動的な指示のコールバックです。使用を強く推奨します。[動的な指示](#dynamic-instructions)を参照してください。 | -| `prompt` | いいえ | OpenAIの Responses API 用プロンプト設定です。静的なプロンプトオブジェクトまたは関数を受け取ります。[プロンプトテンプレート](#prompt-templates)を参照してください。 | -| `handoff_description` | いいえ | このエージェントがハンドオフ先として提示される際に公開される短い説明です。 | -| `handoffs` | いいえ | 会話を専門エージェントに委譲します。[ハンドオフ](handoffs.md)を参照してください。 | -| `model` | いいえ | 使用するLLMです。[モデル](models/index.md)を参照してください。 | +| `prompt` | いいえ | OpenAI Responses API のプロンプト設定です。静的なプロンプトオブジェクトまたは関数を受け取ります。[プロンプトテンプレート](#prompt-templates)を参照してください。 | +| `handoff_description` | いいえ | このエージェントがハンドオフ先として提示される際に表示される短い説明です。 | +| `handoffs` | いいえ | 会話を専門エージェントに委任します。[ハンドオフ](handoffs.md)を参照してください。 | +| `model` | いいえ | 使用する LLM です。[モデル](models/index.md)を参照してください。 | | `model_settings` | いいえ | `temperature`、`top_p`、`tool_choice` などのモデル調整パラメーターです。 | | `tools` | いいえ | エージェントが呼び出せるツールです。[ツール](tools.md)を参照してください。 | -| `mcp_servers` | いいえ | MCP対応ツールをエージェントに提供するMCPサーバーです。[MCPガイド](mcp.md)を参照してください。 | -| `mcp_config` | いいえ | スキーマの strict モードへの変換やMCPエラーの形式調整など、MCPツールの準備方法を詳細に調整します。[MCPガイド](mcp.md#agent-level-mcp-configuration)を参照してください。 | +| `mcp_servers` | いいえ | MCP ベースのツールをエージェントに提供する MCP サーバーです。[MCP ガイド](mcp.md)を参照してください。 | +| `mcp_config` | いいえ | スキーマの strict モードへの変換や MCP エラーの書式設定など、MCP ツールの準備方法を詳細に調整します。[MCP ガイド](mcp.md#agent-level-mcp-configuration)を参照してください。 | | `input_guardrails` | いいえ | このエージェントチェーンへの最初のユーザー入力に対して実行されるガードレールです。[ガードレール](guardrails.md)を参照してください。 | | `output_guardrails` | いいえ | このエージェントの最終出力に対して実行されるガードレールです。[ガードレール](guardrails.md)を参照してください。 | | `output_type` | いいえ | プレーンテキストの代わりに使用する構造化された出力型です。[出力型](#output-types)を参照してください。 | -| `hooks` | いいえ | エージェント単位のライフサイクルコールバックです。[ライフサイクルイベント(フック)](#lifecycle-events-hooks)を参照してください。 | -| `tool_use_behavior` | いいえ | ツールの実行結果をモデルに戻してループを継続するか、実行を終了するかを制御します。[ツール使用時の動作](#tool-use-behavior)を参照してください。 | -| `reset_tool_choice` | いいえ | ツール使用ループを回避するため、ツール呼び出し後に `tool_choice` をリセットします(デフォルト:`True`)。[ツール使用の強制](#forcing-tool-use)を参照してください。 | +| `hooks` | いいえ | エージェント単位のライフサイクルコールバックです。[ライフサイクルイベント (フック)](#lifecycle-events-hooks)を参照してください。 | +| `tool_use_behavior` | いいえ | ツールの実行結果をモデルに戻すか、実行を終了するかを制御します。[ツール使用時の動作](#tool-use-behavior)を参照してください。 | +| `reset_tool_choice` | いいえ | ツール使用のループを回避するため、ツール呼び出し後に `tool_choice` をリセットします (デフォルト: `True`)。[ツール使用の強制](#forcing-tool-use)を参照してください。 | ```python from agents import Agent @@ -65,15 +65,15 @@ agent = Agent( ) ``` -このセクションの内容はすべて `Agent` に適用されます。`SandboxAgent` は同じ考え方を基盤とし、ワークスペース単位の実行向けに `default_manifest`、`base_instructions`、`capabilities`、`run_as` を追加します。[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 +このセクションの内容はすべて `Agent` に適用されます。`SandboxAgent` は同じ考え方を基盤とし、さらにワークスペース単位の実行用に `default_manifest`、`base_instructions`、`capabilities`、`run_as` を追加します。[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 ## プロンプトテンプレート -`prompt` を設定することで、OpenAIプラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API 経由でOpenAIモデルにアクセスする場合に機能します。 +`prompt` を設定すると、OpenAI プラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API を介して OpenAI モデルにアクセスする場合に機能します。 -使用手順は次のとおりです。 +使用するには、次の手順を行ってください。 -1. https://platform.openai.com/playground/prompts にアクセスします。 +1. https://platform.openai.com/playground/prompts に移動します 2. 新しいプロンプト変数 `poem_style` を作成します。 3. 次の内容でシステムプロンプトを作成します。 @@ -128,9 +128,9 @@ result = await Runner.run( ## コンテキスト -エージェントは、その `context` 型に関してジェネリックです。コンテキストは依存性注入の仕組みです。自身で作成して `Runner.run()` に渡すオブジェクトであり、すべてのエージェント、ツール、ハンドオフなどに渡されます。また、エージェント実行に必要な依存関係と状態をまとめる柔軟なコンテナとして機能します。コンテキストには任意の Python オブジェクトを指定できます。 +エージェントは `context` 型に対してジェネリックです。コンテキストは依存性注入のためのツールです。コンテキストは、自分で作成して `Runner.run()` に渡すオブジェクトであり、すべてのエージェント、ツール、ハンドオフなどに渡されます。また、エージェント実行に必要な依存関係や状態をまとめて保持します。任意の Python オブジェクトをコンテキストとして指定できます。 -`RunContextWrapper` の全機能、共有の使用量追跡、ネストされた `tool_input`、シリアライズに関する注意事項については、[コンテキストガイド](context.md)を参照してください。 +`RunContextWrapper` の全機能、共有される使用量の追跡、ネストされた `tool_input`、シリアライズに関する注意事項については、[コンテキストガイド](context.md)を参照してください。 ```python from dataclasses import dataclass @@ -156,7 +156,7 @@ agent = Agent[UserContext]( ## 出力型 -デフォルトでは、エージェントはプレーンテキスト(つまり `str`)形式の出力を生成します。エージェントに特定の型の出力を生成させる場合は、`output_type` パラメーターを使用できます。一般的には [Pydantic](https://docs.pydantic.dev/) オブジェクトを使用しますが、Pydantic の [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) でラップできる任意の型をサポートしています。これには、dataclass、リスト、TypedDict などが含まれます。 +デフォルトでは、エージェントはプレーンテキスト (つまり `str`) の出力を生成します。エージェントに特定の型の出力を生成させる場合は、`output_type` パラメーターを使用できます。一般的には [Pydantic](https://docs.pydantic.dev/) オブジェクトを使用しますが、Pydantic の [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) でラップできる任意の型をサポートしています。たとえば、データクラス、リスト、TypedDict などです。 ```python from pydantic import BaseModel @@ -177,20 +177,20 @@ agent = Agent( !!! note - `output_type` を渡すと、通常のプレーンテキストレスポンスではなく、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs)を使用するようモデルに指示します。 + `output_type` を渡すと、通常のプレーンテキスト応答ではなく [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) を使用するようモデルに指示します。 ## マルチエージェントシステムの設計パターン -マルチエージェントシステムには多くの設計方法がありますが、一般的には広く適用できる次の 2 つのパターンが使用されます。 +マルチエージェントシステムを設計する方法は多数ありますが、一般的に幅広く適用できる次の 2 つのパターンがよく見られます。 -1. マネージャー(agents as tools):中央のマネージャー/オーケストレーターが専門サブエージェントをツールとして呼び出し、会話の制御を維持します。 -2. ハンドオフ:対等なエージェントが、会話を引き継ぐ専門エージェントに制御をハンドオフします。これは分散型のパターンです。 +1. マネージャー (agents as tools): 中央のマネージャー/オーケストレーターが、専門のサブエージェントをツールとして呼び出し、会話の制御を維持します。 +2. ハンドオフ: 同等の立場にあるエージェントが、会話を引き継ぐ専門エージェントへ制御をハンドオフします。これは分散型のパターンです。 詳細については、[エージェント構築の実践ガイド](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)を参照してください。 -### マネージャー(agents as tools) +### マネージャー (agents as tools) -`customer_facing_agent` はすべてのユーザー操作を処理し、ツールとして公開された専門サブエージェントを呼び出します。詳細については、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 +`customer_facing_agent` はすべてのユーザー操作を処理し、ツールとして公開された専門のサブエージェントを呼び出します。詳細については、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 ```python from agents import Agent @@ -219,7 +219,7 @@ customer_facing_agent = Agent( ### ハンドオフ -設定されたハンドオフ先は、エージェントが処理を委譲できるサブエージェントです。ハンドオフが発生すると、委譲先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに特化したモジュール式の専門エージェントを構築できます。詳細については、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 +設定されたハンドオフ先は、エージェントが処理を委任できるサブエージェントです。ハンドオフが発生すると、委任先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに優れたモジュール式の専門エージェントを構築できます。詳細については、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 ```python from agents import Agent @@ -240,7 +240,7 @@ triage_agent = Agent( ## 動的な指示 -ほとんどの場合、エージェントの作成時に指示を指定できます。ただし、関数を使用して動的な指示を指定することもできます。この関数はエージェントとコンテキストを受け取り、プロンプトを返す必要があります。通常の関数と `async` 関数の両方を使用できます。 +ほとんどの場合、エージェントの作成時に指示を指定できます。ただし、関数を介して動的な指示を指定することもできます。この関数はエージェントとコンテキストを受け取り、プロンプトを返す必要があります。通常の関数と `async` 関数の両方を使用できます。 ```python from agents import Agent, RunContextWrapper @@ -257,28 +257,28 @@ agent = Agent[UserContext]( ) ``` -## ライフサイクルイベント(フック) +## ライフサイクルイベント (フック) -エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生したときに、イベントのログ記録、データの事前取得、使用量の記録を行う場合です。 +エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生したときに、イベントのログ記録、データの事前取得、使用量の記録を行いたい場合があります。 フックには次の 2 つのスコープがあります。 - [`RunHooks`][agents.lifecycle.RunHooks] は、他のエージェントへのハンドオフを含む `Runner.run(...)` 呼び出し全体を監視します。 -- [`AgentHooks`][agents.lifecycle.AgentHooks] は、`agent.hooks` を介して特定のエージェントインスタンスにアタッチされます。 +- [`AgentHooks`][agents.lifecycle.AgentHooks] は、`agent.hooks` を介して特定のエージェントインスタンスに関連付けられます。 -コールバックのコンテキストも、イベントに応じて変わります。 +コールバックのコンテキストも、イベントによって異なります。 -- エージェントの開始/終了フックは、元のコンテキストをラップし、共有の実行使用量状態を保持する [`AgentHookContext`][agents.run_context.AgentHookContext] を受け取ります。 -- LLM、ツール、ハンドオフの各フックは、[`RunContextWrapper`][agents.run_context.RunContextWrapper] を受け取ります。 +- エージェントの開始/終了フックは [`AgentHookContext`][agents.run_context.AgentHookContext] を受け取ります。これは元のコンテキストをラップし、共有される実行使用量の状態を保持します。 +- LLM、ツール、ハンドオフのフックは [`RunContextWrapper`][agents.run_context.RunContextWrapper] を受け取ります。 一般的なフックのタイミングは次のとおりです。 -- `on_agent_start`:特定のエージェントが実行を開始したとき。`on_agent_end`:そのエージェントが最終出力の生成を完了したとき。 -- `on_llm_start` / `on_llm_end`:各モデル呼び出しの直前と直後。 -- `on_tool_start` / `on_tool_end`:各ローカルツール呼び出しの前後。関数ツールの場合、フックの `context` は通常 `ToolContext` であるため、`tool_call_id` などのツール呼び出しメタデータを確認できます。 -- `on_handoff`:制御があるエージェントから別のエージェントに移ったとき。 +- `on_agent_start`: 特定のエージェントが実行を開始したとき。`on_agent_end`: そのエージェントが最終出力の生成を完了したとき。 +- `on_llm_start` / `on_llm_end`: 各モデル呼び出しの直前/直後。 +- `on_tool_start` / `on_tool_end`: 各ローカルツール呼び出しの前後。関数ツールの場合、フックの `context` は通常 `ToolContext` であるため、`tool_call_id` などのツール呼び出しメタデータを確認できます。 +- `on_handoff`: 制御があるエージェントから別のエージェントに移ったとき。 -ワークフロー全体を単一のオブザーバーで監視する場合は `RunHooks` を使用し、特定のエージェントに限定されたライフサイクルコールバックが必要な場合は `AgentHooks` を使用してください。 +ワークフロー全体を 1 つのオブザーバーで監視する場合は `RunHooks` を使用し、特定のエージェントに限定したライフサイクルコールバックが必要な場合は `AgentHooks` を使用します。 ```python from agents import Agent, RunHooks, Runner @@ -300,13 +300,13 @@ result = await Runner.run(agent, "Explain quines", hooks=LoggingHooks()) print(result.final_output) ``` -コールバックの全機能については、[ライフサイクル API リファレンス](ref/lifecycle.md)を参照してください。 +コールバックの全機能については、[Lifecycle API リファレンス](ref/lifecycle.md)を参照してください。 ## ガードレール -ガードレールを使用すると、エージェントの実行と並行してユーザー入力に対するチェック/検証を実行し、生成後のエージェント出力に対してもチェック/検証を実行できます。たとえば、ユーザー入力とエージェント出力の関連性を確認できます。詳細については、[ガードレール](guardrails.md)のドキュメントを参照してください。 +ガードレールを使用すると、エージェントの実行と並行してユーザー入力に対するチェック/検証を実行し、エージェントの出力が生成された後にその出力をチェックできます。たとえば、ユーザー入力とエージェント出力が関連性のある内容かどうかを審査できます。詳細については、[ガードレール](guardrails.md)のドキュメントを参照してください。 -## エージェントのクローン/コピー +## エージェントの複製/コピー エージェントの `clone()` メソッドを使用すると、エージェントを複製し、必要に応じて任意のプロパティを変更できます。 @@ -323,16 +323,18 @@ robot_agent = pirate_agent.clone( ) ``` +`clone()` は `dataclasses.replace` を使用するため、シャローコピーを実行します。`tools`、`handoffs`、`mcp_servers`、`input_guardrails`、`output_guardrails` など、上書きしないリスト属性は、元のエージェントが保持するものとまったく同じリストのままです。したがって、どちらかのエージェントを介してそのリストを変更すると、両方のエージェントに影響します。クローンに独立したリストコンテナーを持たせるには、たとえば `pirate_agent.clone(tools=[*pirate_agent.tools, extra_tool])` のように新しいリストを渡します。その新しいリストにコピーされた項目は、それらの項目も置き換えない限り、同じツールまたはハンドオフオブジェクトのままです。 + ## ツール使用の強制 -ツールのリストを指定しても、LLMが必ずツールを使用するとは限りません。[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] を設定することで、ツールの使用を強制できます。有効な値は次のとおりです。 +ツールのリストを指定しても、LLM が必ずツールを使用するとは限りません。[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] を設定することで、ツールの使用を強制できます。有効な値は次のとおりです。 -1. `auto`:ツールを使用するかどうかをLLMが判断できます。 -2. `required`:LLMにツールの使用を要求しますが、どのツールを使用するかはLLMが適切に判断できます。 -3. `none`:LLMにツールを _使用させない_ ことを要求します。 -4. `my_tool` などの特定の文字列を設定すると、LLMにその特定のツールの使用を要求します。 +1. `auto`: ツールを使用するかどうかを LLM が判断できます。 +2. `required`: LLM にツールの使用を必須としますが、使用するツールは LLM が適切に判断できます。 +3. `none`: LLM にツールを使用 _させない_ ようにします。 +4. `my_tool` などの特定の文字列を設定すると、LLM にその特定のツールの使用を必須とします。 -OpenAI Responses のツール検索を使用する場合、名前付きツールの選択にはさらに制約があります。`tool_choice` では、修飾なしの名前空間名や遅延のみのツールを指定できず、`tool_choice="tool_search"` では [`ToolSearchTool`][agents.tool.ToolSearchTool] を指定できません。このような場合は、`auto` または `required` を使用してください。Responses 固有の制約については、[ホスト型ツール検索](tools.md#hosted-tool-search)を参照してください。 +OpenAI Responses のツール検索を使用する場合、名前を指定したツール選択には、より多くの制限があります。`tool_choice` では、単独の名前空間名や遅延専用ツールを指定できません。また、`tool_choice="tool_search"` では [`ToolSearchTool`][agents.tool.ToolSearchTool] を指定できません。その場合は、`auto` または `required` の使用を推奨します。Responses 固有の制約については、[ホスト型ツール検索](tools.md#hosted-tool-search)を参照してください。 ```python from agents import Agent, ModelSettings @@ -355,8 +357,8 @@ agent = Agent( `Agent` 設定の `tool_use_behavior` パラメーターは、ツール出力の処理方法を制御します。 -- `"run_llm_again"`:デフォルトです。ツールが実行され、その結果をLLMが処理して最終レスポンスを生成します。 -- `"stop_on_first_tool"`:最初のツール呼び出しの出力を、LLMによる追加処理なしで最終レスポンスとして使用します。 +- `"run_llm_again"`: デフォルトです。ツールが実行され、LLM が実行結果を処理して最終応答を生成します。 +- `"stop_on_first_tool"`: 最初のツール呼び出しの出力を、LLM で追加処理せずに最終応答として使用します。 ```python from agents import Agent @@ -375,7 +377,7 @@ agent = Agent( ) ``` -- `StopAtTools(stop_at_tool_names=[...])`:指定されたツールのいずれかが呼び出されると停止し、その出力を最終レスポンスとして使用します。 +- `StopAtTools(stop_at_tool_names=[...])`: 指定したツールのいずれかが呼び出された場合に停止し、その出力を最終応答として使用します。 ```python from agents import Agent @@ -400,7 +402,7 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`:ツールの実行結果を処理し、最終出力で実行を終了するか、LLMによる処理を続行するかを決定するカスタム関数です。 +- `ToolsToFinalOutputFunction`: ツールの実行結果を処理し、最終出力で実行を終了するか、LLM による処理を続行するかを決定するカスタム関数です。 ```python from agents import Agent, FunctionToolResult, RunContextWrapper @@ -439,4 +441,4 @@ agent = Agent( !!! note - 無限ループを防ぐため、フレームワークはツール呼び出し後に `tool_choice` を自動的に「auto」にリセットします。この動作は、[`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice] で設定できます。無限ループが発生する理由は、ツールの実行結果がLLMに送信された後、`tool_choice` によってLLMがさらに別のツール呼び出しを生成し続けるためです。 \ No newline at end of file + 無限ループを防ぐため、フレームワークはツール呼び出し後に `tool_choice` を自動的に「auto」にリセットします。この動作は [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice] で設定できます。無限ループが発生するのは、ツールの実行結果が LLM に送信され、その後 `tool_choice` によって LLM が別のツール呼び出しを生成し、この処理が無限に繰り返されるためです。 \ No newline at end of file diff --git a/docs/ja/config.md b/docs/ja/config.md index fd02414f10..9d28c1252d 100644 --- a/docs/ja/config.md +++ b/docs/ja/config.md @@ -4,21 +4,21 @@ search: --- # 設定 -このページでは、デフォルトの OpenAI キーやクライアント、デフォルトの OpenAI API 形式、トレーシングのエクスポートに関するデフォルト設定、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 +このページでは、デフォルトの OpenAI キーまたはクライアント、デフォルトの OpenAI API 形式、トレーシングのエクスポートに関するデフォルト設定、ログ動作など、通常はアプリケーションの起動時に一度だけ設定する SDK 全体のデフォルトについて説明します。 -これらのデフォルトはサンドボックスベースのワークフローにも適用されますが、サンドボックスワークスペース、サンドボックスクライアント、セッションの再利用は個別に設定します。 +これらのデフォルトはサンドボックスベースのワークフローにも適用されますが、サンドボックスのワークスペース、サンドボックスクライアント、セッションの再利用は個別に設定します。 -特定のエージェントまたは実行を設定する必要がある場合は、以下を参照してください。 +代わりに特定のエージェントまたは実行を設定する必要がある場合は、以下から始めてください。 -- 通常の `Agent` における instructions、ツール、出力型、ハンドオフ、ガードレールについては、[エージェント](agents.md)を参照してください。 -- `RunConfig`、セッション、会話状態のオプションについては、[エージェントの実行](running_agents.md)を参照してください。 -- `SandboxRunConfig`、マニフェスト、ケイパビリティ、サンドボックスクライアント固有のワークスペース設定については、[サンドボックスエージェント](sandbox/guide.md)を参照してください。 -- モデルの選択とプロバイダー設定については、[モデル](models/index.md)を参照してください。 -- 実行ごとのトレーシングメタデータとカスタムトレースプロセッサーについては、[トレーシング](tracing.md)を参照してください。 +- [エージェント](agents.md):通常の `Agent` に対する指示、ツール、出力型、ハンドオフ、ガードレール。 +- [エージェントの実行](running_agents.md):`RunConfig`、セッション、会話状態のオプション。 +- [サンドボックスエージェント](sandbox/guide.md):`SandboxRunConfig`、マニフェスト、ケイパビリティ、サンドボックスクライアント固有のワークスペース設定。 +- [モデル](models/index.md):モデルの選択とプロバイダーの設定。 +- [トレーシング](tracing.md):実行ごとのトレーシングメタデータとカスタムトレースプロセッサー。 ## 設定オブジェクトと辞書 -SDK で定義される設定パラメーターは通常、型付きの設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け入れます。これは、型アノテーションに辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各設定境界に適用されます。SDK で定義されたネストされた設定型でも、辞書を使用できます。 +SDK で定義されている設定パラメーターは、通常、型付きの設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け付けます。これは、型注釈に辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各設定境界に適用されます。SDK で定義されたネストされた設定型でも辞書を使用できます。 ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK は、これらの辞書を対応する設定オブジェクトに正規化します。SDK で定義されたデータクラス設定型に不明なフィールドがあると `TypeError` が発生するため、オプション名のスペルミスを早期に検出できます。特定の境界が辞書を受け入れるかどうかを確認するには、そのパラメーターの型アノテーションまたは API リファレンスを確認してください。 +SDK はこれらの辞書を、対応する設定オブジェクトへ正規化します。SDK で定義されたデータクラス設定型に不明なフィールドがあると `TypeError` が発生するため、スペルを誤ったオプション名を早期に検出できます。特定の境界が辞書を受け付けるかどうかは、そのパラメーターの型注釈または API リファレンスで確認してください。 ## API キーとクライアント -デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が初めて OpenAI クライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 +デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が最初に OpenAI クライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -別の方法として、使用する OpenAI クライアントを設定することもできます。デフォルトでは、SDK は環境変数の API キー、または上記で設定したデフォルトキーを使用して `AsyncOpenAI` インスタンスを作成します。[set_default_openai_client()][agents.set_default_openai_client] 関数を使用すると、これを変更できます。 +また、使用する OpenAI クライアントを設定することもできます。デフォルトでは、SDK は環境変数の API キーまたは上記で設定したデフォルトキーを使用して、`AsyncOpenAI` インスタンスを作成します。[set_default_openai_client()][agents.set_default_openai_client] 関数を使用すると、これを変更できます。 ```python from openai import AsyncOpenAI @@ -55,9 +55,11 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -### `openai` v3 のカスタム HTTP クライアント +明示的なクライアントを [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider] に渡すと、そのクライアントが接続とアカウントの設定を管理します。`api_key`、`base_url`、`websocket_base_url`、`organization`、`project` を `OpenAIProvider` に同時に渡さないでください。`openai_client` とこれらの引数のいずれかを組み合わせると、重複する値が暗黙に無視されるのではなく、[`UserError`][agents.exceptions.UserError] が発生します。目的の値は `AsyncOpenAI` の構築時に設定してください。 -バージョン 0.21.0 では `openai>=3.0.0,<4` が必要です。デフォルトの OpenAI プロバイダーは HTTPX2 を使用するため、ほとんどのアプリケーションでは HTTP クライアントを直接設定する必要はありません。アプリケーションから `AsyncOpenAI` に `http_client=` を渡す場合は、カスタムクライアントとそのトランスポート向けオプションに HTTPX2 の型を使用してください。 +### `openai` v3 でのカスタム HTTP クライアント + +バージョン 0.21.0 では `openai>=3.0.0,<4` が必要です。デフォルトの OpenAI プロバイダーは HTTPX2 を使用するため、ほとんどのアプリケーションでは HTTP クライアントを直接設定する必要はありません。アプリケーションが `http_client=` を `AsyncOpenAI` に渡す場合は、カスタムクライアントとそのトランスポート向けオプションに HTTPX2 型を使用してください。 ```python import httpx2 @@ -75,11 +77,11 @@ custom_client = AsyncOpenAI( set_default_openai_client(custom_client) ``` -同じ移行が、カスタムトランスポート、認証、イベントフック、モックトランスポート、URL、リクエスト、レスポンス、トランスポート例外処理にも適用されます。それぞれに対応する `httpx2` を使用してください。Agents SDK は、任意の従来の `httpx` オブジェクトを HTTPX2 に変換しません。アプリケーションで `httpx` を明示的にインストールすると、OpenAI Python SDK による従来のクライアント向けの一時的な互換性対応を利用できますが、新規コードおよび移行済みコードでは HTTPX2 を使用してください。 +同じ移行は、カスタムトランスポート、認証、イベントフック、モックトランスポート、URL、リクエスト、レスポンス、トランスポート例外の処理にも適用されます。それぞれに対応する `httpx2` を使用してください。Agents SDK は、任意の従来の `httpx` オブジェクトを HTTPX2 に変換しません。アプリケーションが `httpx` を明示的にインストールすると、OpenAI Python SDK は従来のクライアント向けに一時的な互換パスを提供しますが、新規コードおよび移行後のコードでは HTTPX2 を使用してください。 -この OpenAI クライアント境界は、ローカル MCP トランスポートのカスタマイズとは別です。MCP Python SDK v1 は独自の従来の `httpx` 依存関係を使用し、MCP Python SDK v2 は `httpx2` を使用します。詳細については、[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 +この OpenAI クライアント境界は、ローカル MCP トランスポートのカスタマイズとは別のものです。MCP Python SDK v1 は独自の従来の `httpx` 依存関係を使用し、MCP Python SDK v2 は `httpx2` を使用します。[MCP Python SDK v1 と v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 -環境変数に基づくエンドポイント設定を使用する場合、デフォルトの OpenAI プロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses の WebSocket トランスポートを有効にすると、WebSocket の `/responses` エンドポイント用に `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 +環境ベースのエンドポイント設定を使用する場合、デフォルトの OpenAI プロバイダーは `OPENAI_BASE_URL` も読み取ります。Responses の WebSocket トランスポートを有効にすると、WebSocket の `/responses` エンドポイントとして `OPENAI_WEBSOCKET_BASE_URL` も読み取ります。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" @@ -94,9 +96,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI プロバイダーのデフォルト +## OpenAI プロバイダーのデフォルト設定 -SDK の OpenAI バックエンドを使用するプロバイダーも、モデル名の文字列をモデルにマッピングするときに SDK 全体のデフォルトを読み取ります。OpenAI Responses モデルでデフォルトとして WebSocket トランスポートを使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 +SDK の OpenAI バックエンドを使用するプロバイダーも、モデル名の文字列をモデルにマッピングする際に SDK 全体のデフォルト設定を読み取ります。OpenAI Responses モデルで WebSocket トランスポートをデフォルトで使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 ```python from agents import set_default_openai_responses_transport @@ -124,11 +126,11 @@ set_default_openai_agent_registration( ) ``` -SDK のデフォルトが設定されていない場合、SDK の OpenAI バックエンドを使用するプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、`RunConfig.trace_metadata` にそのキーがすでに存在しない限り、SDK はその ID を `agent_harness_id` としてトレースメタデータに追加します。 +SDK のデフォルトが設定されていない場合、SDK の OpenAI バックエンドを使用するプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、`RunConfig.trace_metadata` にそのキーがすでに存在しない限り、SDK はそれを `agent_harness_id` としてトレースメタデータに追加します。 ## トレーシング -トレーシングはデフォルトで有効です。デフォルトでは、上記のセクションで説明したモデルリクエストと同じ OpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用すると、トレーシングに使用する API キーを明示的に設定できます。 +トレーシングはデフォルトで有効です。デフォルトでは、上記のセクションにあるモデルリクエストと同じ OpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。トレーシングに使用する API キーを個別に設定するには、[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用します。 ```python from agents import set_tracing_export_api_key @@ -136,7 +138,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -モデルのトラフィックで使用するキーまたはクライアントとは異なる OpenAI キーをトレーシングで使用する場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡してから、トレーシングを個別に設定してください。カスタムクライアントを使用しない場合は、[`set_default_openai_key()`][agents.set_default_openai_key] でも同じパターンを使用できます。 +モデルのトラフィックではあるキーまたはクライアントを使用し、トレーシングでは別の OpenAI キーを使用する必要がある場合は、デフォルトのキーまたはクライアントを設定するときに `use_for_tracing=False` を渡し、その後トレーシングを個別に設定します。カスタムクライアントを使用していない場合は、[`set_default_openai_key()`][agents.set_default_openai_key] でも同じパターンを使用できます。 ```python from openai import AsyncOpenAI @@ -158,7 +160,7 @@ export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -グローバルエクスポーターを変更せずに、実行ごとにトレーシング API キーを設定することもできます。 +グローバルエクスポーターを変更せずに、実行ごとのトレーシング API キーを設定することもできます。 ```python from agents import Runner, RunConfig @@ -178,7 +180,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -トレーシングを有効にしたまま、機密情報が含まれる可能性のある入力や出力をトレースペイロードから除外する場合は、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定します。 +トレーシングを有効なままにしつつ、機密情報を含む可能性のある入力や出力をトレースペイロードから除外する場合は、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を `False` に設定します。 ```python from agents import Runner, RunConfig @@ -190,19 +192,19 @@ await Runner.run( ) ``` -アプリの起動前に以下の環境変数を設定することで、コードを変更せずにデフォルトを変更することもできます。 +アプリの起動前に以下の環境変数を設定することで、コードを使用せずにデフォルトを変更することもできます。 ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -トレーシングのすべての制御方法については、[トレーシングガイド](tracing.md)を参照してください。 +トレーシングのすべての制御項目については、[トレーシングガイド](tracing.md)を参照してください。 ## デバッグログ -SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーをアタッチしません。ログは、アプリケーションの Python ロギング設定に従います。 +SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーを追加しません。ログには、アプリケーションの Python ログ設定が適用されます。 -詳細なログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 +詳細ログを有効にするには、[`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 関数を使用します。 ```python from agents import enable_verbose_stdout_logging @@ -210,7 +212,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -別の方法として、ハンドラー、フィルター、フォーマッターなどを追加してログをカスタマイズできます。詳細については、[Python ロギングガイド](https://docs.python.org/3/howto/logging.html)を参照してください。 +また、ハンドラー、フィルター、フォーマッターなどを追加してログをカスタマイズすることもできます。詳細については、[Python ログガイド](https://docs.python.org/3/howto/logging.html)を参照してください。 ```python import logging @@ -229,22 +231,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### ログと診断に含まれる機密データ +### ログと診断における機密データ -一部のログや診断用例外には、機密データ(モデルまたはツールの入力と出力など)が含まれる場合があります。 +一部のログと診断例外には、機密データ(モデルまたはツールの入力と出力など)が含まれる場合があります。 -デフォルトでは、SDK は LLM の入力と出力、またはツールの入力と出力を **ログに記録しません** 。これらの保護は以下によって制御されます。 +デフォルトでは、SDK は LLM の入力と出力、およびツールの入力と出力を **ログに記録しません** 。これらの保護は、以下によって制御されます。 ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -デバッグのためにこのデータを一時的に含める必要がある場合は、アプリの起動前に、いずれかの変数を `0`(または `false`)に設定します。 +デバッグのために一時的にこのデータを含める必要がある場合は、アプリの起動前にいずれかの変数を `0`(または `false`)に設定します。 ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -これらのフラグは、影響を受ける失敗時に、ペイロードを含む診断の詳細を保持するかどうかも制御します。たとえば、ツールデータの秘匿化が有効な場合、`FunctionTool` に無効な引数を渡すと、基礎となる検証エラーを例外チェーンに含めず、汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、未加工のモデルデータやツールデータが、ログ、例外メッセージ、例外チェーン、その他の診断コンテキストに露出する可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file +これらのフラグは、影響を受けるエラーが、ペイロードを含む診断の詳細を保持するかどうかも制御します。たとえば、ツールデータの秘匿化が有効な場合、`FunctionTool` の無効な引数によって、根本の検証エラーを例外チェーンに含まない汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、ログ、例外メッセージ、例外チェーン、その他の診断コンテキストに未加工のモデルデータまたはツールデータが公開される可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index 3a56a59192..88b580fa74 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -4,81 +4,83 @@ search: --- # ガードレール -ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、顧客からのリクエストに対応するため、非常に高性能である一方、低速かつ高コストなモデルを使用するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストなモデルを使用してガードレールを実行できます。ガードレールが悪意のある利用を検出した場合、直ちにエラーを発生させ、時間とコストを節約できます。ブロッキング実行では、高コストなモデルが起動しないことが保証されます。一方、並列実行では、ガードレールが完了する前に高コストなモデルがすでに起動している可能性があります。詳細については、以下の「実行モード」を参照してください。 +ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、非常に高性能である一方、低速かつ高コストなモデルを使用して顧客からのリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストなモデルを使用してガードレールを実行できます。ガードレールが悪意のある使用を検出した場合、即座にエラーを発生させ、時間とコストを節約できます。ブロッキング実行では、高コストなモデルが起動しないことが保証されます。一方、並列実行では、ガードレールが完了する前に高コストなモデルがすでに起動している可能性があります。詳細については、以下の「実行モード」を参照してください。 ガードレールには、次の 2 種類があります。 1. 入力ガードレールは、最初のユーザー入力に対して実行されます -2. 出力ガードレールは、エージェントの最終出力に対して実行されます +2. 出力ガードレールは、最終的なエージェント出力に対して実行されます ## ワークフローの境界 -ガードレールはエージェントとツールに関連付けられますが、ワークフロー内ですべてが同じタイミングに実行されるわけではありません。 +ガードレールはエージェントとツールに関連付けられますが、ワークフロー内ですべてが同じ時点に実行されるわけではありません。 - **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 - **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 -- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前、出力ガードレールは実行後に実行されます。 +- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前に、出力ガードレールは実行後に実行されます。 -マネージャー、ハンドオフ、または委任先の専門エージェントを含むワークフローで、各カスタム関数ツールの呼び出し前後にチェックが必要な場合は、エージェントレベルの入力/出力ガードレールだけに依存せず、ツールガードレールを使用してください。 +マネージャー、ハンドオフ、または委任されたスペシャリストを含むワークフローで、カスタム関数ツールの各呼び出しの前後いずれか、または両方でチェックが必要な場合は、エージェントレベルの入力 / 出力ガードレールだけに依存せず、ツールガードレールを使用してください。 ## 入力ガードレール 入力ガードレールは、次の 3 ステップで実行されます。 1. 最初に、ガードレールはエージェントに渡されたものと同じ入力を受け取ります。 -2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] が生成されます。その後、これは [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 +2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成します。これは [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] でラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合は [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、その例外を処理できます。 !!! Note - 入力ガードレールはユーザー入力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最初* のエージェントである場合にのみ実行されます。なぜ `guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェント上にあるのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初の* エージェントである場合にのみ実行されます。なぜ `guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェントに設定されているのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 ### 実行モード 入力ガードレールは、次の 2 つの実行モードをサポートしています。 -- **並列実行** (デフォルト、 `run_in_parallel=True` ):ガードレールはエージェントの実行と同時に実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールのトリップワイヤーが作動した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 +- **並列実行** (デフォルト、`run_in_parallel=True`): ガードレールはエージェントの実行と並行して実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールのトリップワイヤーが作動した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 -- **ブロッキング実行** ( `run_in_parallel=False` ):ガードレールは、エージェントが起動する *前* に実行され、完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは一切実行されないため、トークンの消費とツールの実行を防止できます。これは、コストを最適化したい場合や、ツール呼び出しによる潜在的な副作用を回避したい場合に最適です。 +- **ブロッキング実行** (`run_in_parallel=False`): ガードレールは、エージェントが開始する *前に* 実行されて完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防止できます。これは、コストの最適化や、ツール呼び出しによる潜在的な副作用を回避したい場合に適しています。 ## 出力ガードレール 出力ガードレールは、次の 3 ステップで実行されます。 1. 最初に、ガードレールはエージェントが生成した出力を受け取ります。 -2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] が生成されます。その後、これは [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] にラップされます -3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、例外を処理できます。 +2. 次に、ガードレール関数が実行され、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を生成します。これは [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] でラップされます +3. 最後に、[`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] が true かどうかを確認します。true の場合は [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 例外が発生するため、ユーザーに適切に応答するか、その例外を処理できます。 !!! Note - 出力ガードレールはエージェントの最終出力に対して実行することを意図しているため、エージェントのガードレールは、そのエージェントが *最後* のエージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連する傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 + 出力ガードレールは最終的なエージェント出力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最後の* エージェントである場合にのみ実行されます。入力ガードレールと同様に、これはガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 - 出力ガードレールは常にエージェントの完了後に実行されるため、 `run_in_parallel` パラメーターをサポートしていません。 + 出力ガードレールは常にエージェントの完了後に実行されるため、`run_in_parallel` パラメーターはサポートされません。 -出力トリップワイヤーと、ガードレール関数によって発生した例外では、セッションの動作が異なります。トリップワイヤーは、最終出力の候補を拒否します。トリップワイヤーが作動すると、ランナーは設定済みのセッションに対して、すでに完了したツール呼び出しとツール出力の項目を、それらの呼び出しの再実行に必要な推論コンテキストとともに永続化するよう要求します。このとき、拒否された最終出力の候補は除外されます。ランナーは、このトリップワイヤーのルールをストリーミング実行と非ストリーミング実行の両方に適用します。ガードレール関数がトリップワイヤーの結果を返す代わりに例外を発生させた場合、ランナーは判定を不明として扱い、ガードレール例外を通知する前に、完了した最終ターンの項目を永続化するよう設定済みのセッションに要求します。そのセッションへの書き込みも失敗した場合は、セッション書き込みエラーが優先されます。ストリーミング実行では、非ストリーミング実行と同じ永続化順序が使用され、 `stream_events()` から終端例外が発生します。出力ガードレールの実行中に [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel] を直ちに呼び出すと、実行中のガードレールがキャンセルされ、最終ターンのセッション書き込みは開始されません。 +出力トリップワイヤーと、ガードレール関数によって発生した例外では、セッションの動作が異なります。トリップワイヤーは、最終出力の候補を拒否します。トリップワイヤーが作動すると、ランナーは設定されたセッションに対し、拒否された最終出力候補を除外しつつ、すでに完了したツール呼び出しとツール出力の項目を、それらの呼び出しの再実行に必要な推論コンテキストとともに永続化するよう要求します。ランナーは、ストリーミング実行と非ストリーミング実行の両方にこのトリップワイヤーのルールを適用します。ガードレール関数がトリップワイヤーの実行結果を返す代わりに例外を発生させた場合、ランナーは判定を不明として扱い、ガードレール例外を通知する前に、完了した最終ターンの項目を永続化するよう設定済みセッションに要求します。そのセッションへの書き込みも失敗した場合は、セッション書き込みエラーが優先されます。ストリーミング実行では、非ストリーミング実行と同じ永続化順序を使用し、`stream_events()` から終端例外を発生させます。出力ガードレールの実行中に [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel] を即座に呼び出すと、実行中のガードレールがキャンセルされ、最終ターンのセッション書き込みは開始されません。 + +終端となる関数ツールの出力については、エージェントレベルの出力ガードレールが値を確認する前にツールがすでに実行されているため、追加の処理が必要です。[`Agent.tool_use_behavior`][agents.agent.Agent.tool_use_behavior] によってそのツールの実行結果が最終出力となり、出力トリップワイヤーがそれを拒否した場合、SDK は検証済みフィールドから関数呼び出し / 出力のペアを再構築できる場合に限り、再実行可能な有効なペアを保持します。保持される `function_call_output` ペイロードは、固定テキスト `"Output withheld by an output guardrail."` に置き換えられます。元のツール出力ペイロードは、セッション、`RunState`、ストリーミングされた実行結果の状態、サンドボックスのメモリ入力のいずれにも保持されません。SDK は、関数の引数など、再実行に必要な検証済みの関数呼び出しメタデータを保持するため、そのメタデータには拒否された出力にも含まれていたデータが含まれる可能性があります。現在のレスポンスの [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] オブジェクトでも、`agent_output` は固定テキストに置き換えられ、`output_info` はクリアされます。現在のレスポンスの [`ToolOutputGuardrailResult`][agents.tool_guardrails.ToolOutputGuardrailResult] オブジェクトでは、許可 / 拒否の動作タイプは保持されますが、ペイロードを含む `output_info` と拒否メッセージは同じテキストに置き換えられます。それ以前に受け入れられたターンとガードレールの実行結果は変更されません。レスポンスに推論や、SDK が安全にサニタイズできない別の形式が含まれている場合、SDK は拒否された出力ペイロードを保持する代わりに、現在のレスポンスのサフィックス全体を破棄します。例外を発生させたガードレール関数は拒否判定を返していないため、完了済みの終端ツールのターンには、前述の例外発生時の永続化動作が適用されます。 ## ツールガードレール -ツールガードレールは **`FunctionTool` のインスタンス** をラップし、それらのツールの呼び出しを実行前後に検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 +ツールガードレールは **`FunctionTool` インスタンス** をラップし、それらのツールの呼び出しを実行前後に検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 -- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、メッセージによる出力の置き換え、またはトリップワイヤーの作動が可能です。 +- 入力ツールガードレールはツールの実行前に実行され、呼び出しのスキップ、出力のメッセージへの置き換え、またはトリップワイヤーの作動が可能です。 - 出力ツールガードレールはツールの実行後に実行され、出力の置き換えまたはトリップワイヤーの作動が可能です。 -- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に実行されます。保留中の承認による中断が発生する前にこれらの入力チェックを実行するには、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定してください。この事前承認チェックに合格した呼び出しも、ツールの実行前に承認後のチェックを再度受けます。 -- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。OpenAI がホストするツール( `WebSearchTool` 、 `FileSearchTool` 、 `HostedMCPTool` 、 `CodeInterpreterTool` 、 `ImageGenerationTool` )および組み込み実行ツール( `ComputerTool` 、 `ShellTool` 、 `ApplyPatchTool` 、 `LocalShellTool` )も、このガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 +- 関数ツールに承認が必要な場合、通常、入力ツールガードレールは承認後、実行直前に実行されます。保留中の承認による中断が通知される前にこれらの入力チェックを実行する場合は、[`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] を [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig] に設定します。この承認前チェックを通過した呼び出しも、ツールの実行前に承認後の再チェックを受けます。 +- ツールガードレールは、[`function_tool`][agents.tool.function_tool] で作成された関数ツールにのみ適用されます。ハンドオフは通常の関数ツールパイプラインではなく、SDK のハンドオフパイプラインを通じて実行されるため、ツールガードレールはハンドオフ呼び出し自体には適用されません。ホスト型ツール(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)と組み込み実行ツール(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)も、このガードレールパイプラインを使用しません。また、[`Agent.as_tool()`][agents.agent.Agent.as_tool] は現在、ツールガードレールのオプションを直接公開していません。 詳細については、以下のコードスニペットを参照してください。 ## トリップワイヤー -エージェントの入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーによってそれを通知できます。ランナーは直ちに `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を発生させ、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外が使用されます。 +エージェントの入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーでそのことを通知できます。ランナーは即座に `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を発生させ、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外が使用されます。 -エージェントレベルのトリップワイヤーでは、例外の `guardrail_result` によって、トリップワイヤーを作動させたガードレールを特定できます。ランナーによって入力トリップワイヤーが発生した場合、 `exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの結果が含まれます。これには、トリップワイヤーを作動させた結果も含まれます。出力トリップワイヤーでは、同等の累積結果が `exception.run_data.output_guardrail_results` を通じて提供されます。 +エージェントレベルのトリップワイヤーでは、例外の `guardrail_result` により、トリップワイヤーを作動させたガードレールを特定できます。ランナーによって発生した入力トリップワイヤーの場合、`exception.run_data.input_guardrail_results` には、実行が停止する前に完了したすべての入力ガードレールの実行結果が含まれます。これには、トリップワイヤーを作動させた実行結果も含まれます。出力トリップワイヤーでは、`exception.run_data.output_guardrail_results` を通じて同等の累積実行結果が提供されます。 -一方、ツールトリップワイヤー例外では、作動の原因となった `guardrail` と `output` が直接公開されます。その `run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` のリストには、失敗前に完了したターンから累積された結果が保持されます。作動の原因となった結果は、例外の `output` を通じて取得できます。 `MaxTurnsExceeded` など、ランナーが管理するその他の失敗でも、完了したツールガードレールの結果がこれらのリストに保持されます。 `stream_events()` が例外を発生させた後、ストリーミング実行結果には、同じく累積されたエージェントおよびツールガードレールの結果リストが公開されます。ランナーが管理する実行パスの外部で例外が発生した場合、 `run_data` は `None` になることがあります。 +一方、ツールのトリップワイヤー例外では、作動の原因となった `guardrail` と `output` が直接公開されます。これらの `run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` リストには、失敗前の完了済みターンから蓄積された実行結果が保持されます。作動の原因となった実行結果は、例外の `output` から取得できます。`MaxTurnsExceeded` など、ランナーが管理するその他の失敗でも、完了済みのツールガードレールの実行結果がこれらのリストに保持されます。`stream_events()` が例外を発生させた後、ストリーミングされた実行結果では、同じ累積済みのエージェントおよびツールガードレールの実行結果リストが公開されます。ランナーが管理する実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 ## ガードレールの実装 -入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することによって、これを実現します。 +入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することで実装します。 ```python from pydantic import BaseModel @@ -131,9 +133,9 @@ async def main(): print("Math homework guardrail tripped") ``` -1. このエージェントをガードレール関数で使用します。 -2. これは、エージェントの入力/コンテキストを受け取り、結果を返すガードレール関数です。 -3. ガードレールの結果には、追加情報を含めることができます。 +1. このエージェントをガードレール関数内で使用します。 +2. これは、エージェントの入力 / コンテキストを受け取り、実行結果を返すガードレール関数です。 +3. ガードレールの実行結果には、追加情報を含めることができます。 4. これは、ワークフローを定義する実際のエージェントです。 出力ガードレールも同様です。 @@ -191,7 +193,7 @@ async def main(): 1. これは、実際のエージェントの出力型です。 2. これは、ガードレールの出力型です。 -3. これは、エージェントの出力を受け取り、結果を返すガードレール関数です。 +3. これは、エージェントの出力を受け取り、実行結果を返すガードレール関数です。 4. これは、ワークフローを定義する実際のエージェントです。 最後に、ツールガードレールの例を示します。 diff --git a/docs/ja/release.md b/docs/ja/release.md index c62fc7484b..5a69f09a07 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -4,79 +4,92 @@ search: --- # リリースプロセス/変更履歴 -このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングを一部変更した方式に従います。先頭の `0` は、SDK がまだ急速に進化していることを示します。各要素は次のように更新します。 +このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングを一部変更した方式に従います。先頭の `0` は、SDK が現在も急速に進化していることを示します。各構成要素は次のように増分します。 ## マイナー(`Y`)バージョン -ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を上げます。たとえば、`0.0.x` から `0.1.x` への更新には、破壊的変更が含まれる可能性があります。 +ベータと明記されていない公開インターフェースに **破壊的変更** が加えられる場合、マイナーバージョン `Y` を増分します。たとえば、`0.0.x` から `0.1.x` への変更には、破壊的変更が含まれる可能性があります。 破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 ## パッチ(`Z`)バージョン -破壊的でない変更については、`Z` を上げます。 +非破壊的変更では `Z` を増分します。 - バグ修正 - 新機能 -- 非公開インターフェースの変更 +- 非公開インターフェースへの変更 - ベータ機能の更新 -## 破壊的変更の変更履歴 +## 破壊的変更の履歴 + +### 0.22.0 + +バージョン 0.22.0 では、既存の複数の API に対する失敗処理とデータ分離が強化されました。明示的なクライアントを指定して `OpenAIProvider` を構築し、さらにプロバイダーへ `organization` または `project` を渡しているアプリケーションでは、重複するこれらの引数を削除する必要があります。 + +主な変更点: + +- エージェントレベルの出力ガードレールが、終端関数ツールによって直接生成された最終出力をブロックした場合、検証済みフィールドによって安全に再構築できる場合に限り、SDK は再実行可能な呼び出し/出力ペアを保持します。元の `function_call_output` ペイロードは、セッション履歴、`RunState`、およびストリーミングされた実行結果の状態において、固定テキスト `"Output withheld by an output guardrail."` に置き換えられます。また、ペイロードを含む現在のレスポンスのガードレールメタデータは、消去または置換されます。現在のレスポンスに推論やその他の未対応形式が含まれる場合、SDK は代わりに現在のレスポンスの接尾部分全体を破棄します。以前に受理されたターンとガードレールの結果は引き続き利用できます。[出力ガードレール](guardrails.md#output-guardrails)を参照してください。 +- 非ストリーミングの OpenAI Responses 呼び出しでは、返されたレスポンスの終端ステータスが `failed` または `incomplete` の場合、既存のストリーミング終端イベント処理と同様に `ModelBehaviorError` が送出されるようになりました。これは、`OpenAIResponsesModel` と `AnyLLMModel` の Responses 経路に適用されます。[例外](running_agents.md#exceptions)を参照してください。 +- [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider] は、`openai_client` と `organization` または `project` を組み合わせた場合にも `UserError` を送出するようになりました。既存の `api_key`、`base_url`、`websocket_base_url` との競合に変更はありません。代わりに、明示的な `AsyncOpenAI` クライアントでこれらの値を設定してください。[API キーとクライアント](config.md#api-keys-and-clients)を参照してください。 +- 各 `RunResult.to_state()` チェックポイントが、独立した使用量スナップショットを保持するようになりました。再開された実行結果はチェックポイントの合計値から始まり、元の実行結果や同階層のチェックポイントを変更することなく、それ自体のモデル呼び出しを加算します。ネストされた `Agent.as_tool()` の再開では、再開後の使用量が引き続きアクティブな外側の実行に集約されます。[RunState チェックポイントでの使用量](usage.md#usage-in-runstate-checkpoints)を参照してください。 +- エージェントの可視化では、`handoff(agent)` で登録された対象のツール、MCP サーバー、および後続のハンドオフが再帰的に展開されるようになりました。これは、エージェントの `handoffs` リスト内の直接的な `Agent` エントリと同じ動作です。[グラフの生成](visualization.md#generating-a-graph)を参照してください。 +- `Agent.clone()` および `RealtimeAgent.clone()` の API ガイダンスでは、既存のシャローコピー動作を正確に説明するようになりました。オーバーライドされていないリスト属性は、同じリストオブジェクトのままです。クローンがコンテナーを独立して所有する必要がある場合は、新しいリストを渡してください。[エージェントのクローン/コピー](agents.md#cloningcopying-agents)を参照してください。 ### 0.21.0 -バージョン 0.21.0 では `openai` v3 が必要となり、Agents SDK の OpenAI HTTP 統合が HTTPX2 に移行します。デフォルトの OpenAI クライアントを使用するアプリケーションではクライアント設定を変更する必要はありませんが、OpenAI HTTP レイヤーをカスタマイズしているアプリケーションでは、トランスポート関連コードの移行が必要になる場合があります。 +バージョン 0.21.0 では `openai` v3 が必須となり、Agents SDK の OpenAI HTTP 統合が HTTPX2 に移行されました。デフォルトの OpenAI クライアントを使用するアプリケーションではクライアント設定を変更する必要はありませんが、OpenAI HTTP レイヤーをカスタマイズしているアプリケーションでは、トランスポート関連コードの移行が必要になる場合があります。 主な変更点: -- 必須の OpenAI 依存関係は `openai>=3.0.0,<4` になりました。クリーンなコアインストールでは HTTPX2 が使用され、従来の `httpx` は直接の依存関係としてインストールされなくなりました。 -- デフォルトの OpenAI プロバイダー、音声プロバイダー、Responses WebSocket 対応、トレーシングエクスポーター、プロバイダー再試行の正規化で、HTTPX2 が使用されるようになりました。既存の Agents SDK の公開設定と実行時動作に変更はありません。 -- `AsyncOpenAI` に `http_client=` を渡すアプリケーションでは、カスタムクライアント、トランスポート、認証、イベントフック、モックトランスポート、タイムアウト値、URL、リクエスト、レスポンス、トランスポート例外処理を `httpx` から `httpx2` に移行する必要があります。OpenAI クライアントのデフォルト設定に加えてカスタム HTTP オプションが必要な場合は、OpenAI Python SDK の `DefaultAsyncHttpx2Client` を推奨します。[`openai` v3 でのカスタム HTTP クライアント](config.md#custom-http-clients-with-openai-v3)を参照してください。 -- Agents SDK は、任意の従来型 HTTPX オブジェクトを HTTPX2 に変換しません。OpenAI Python SDK の一時的な従来型クライアント互換パスには、`httpx` の明示的なインストールが必要であり、移行用の橋渡しとして扱う必要があります。 -- ローカル MCP の HTTP カスタマイズは、引き続きインストール済みの MCP パッケージに従います。MCP Python SDK v1 は従来の `httpx` を提供して使用し、MCP Python SDK v2 は `httpx2` を使用します。通常の MCP 接続では、アプリケーションを変更する必要はありません。[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 -- 公開されたプロバイダー非依存のテストユーティリティで、プロバイダーやプロセスへの依存なしに、エージェントモデル、サンドボックスセッション、Realtime セッション、音声パイプラインのワークフローを扱えるようになりました。レシピ、および実際のプロバイダーアダプターや統合境界を維持すべき場合のガイダンスについては、[テスト](testing.md)を参照してください。 +- 必須の OpenAI 依存関係は `openai>=3.0.0,<4` になりました。クリーンなコアインストールでは HTTPX2 が使用され、従来の `httpx` は直接依存関係としてインストールされなくなりました。 +- デフォルトの OpenAI プロバイダー、音声プロバイダー、Responses WebSocket サポート、トレーシングエクスポーター、およびプロバイダーのリトライ正規化で HTTPX2 が使用されるようになりました。既存の Agents SDK の公開設定と実行時動作に変更はありません。 +- `AsyncOpenAI` に `http_client=` を渡すアプリケーションでは、カスタムクライアント、トランスポート、認証、イベントフック、モックトランスポート、タイムアウト値、URL、リクエスト、レスポンス、およびトランスポート例外処理を `httpx` から `httpx2` へ移行してください。OpenAI クライアントのデフォルト設定とカスタム HTTP オプションの両方が必要な場合は、OpenAI Python SDK の `DefaultAsyncHttpx2Client` を使用することを推奨します。[`openai` v3 でのカスタム HTTP クライアント](config.md#custom-http-clients-with-openai-v3)を参照してください。 +- Agents SDK は、従来の任意の HTTPX オブジェクトを HTTPX2 に変換しません。OpenAI Python SDK の一時的なレガシークライアント互換経路では、`httpx` を明示的にインストールする必要があり、移行のための橋渡しとして扱う必要があります。 +- ローカル MCP の HTTP カスタマイズでは、引き続きインストール済みの MCP パッケージに従います。MCP Python SDK v1 は従来の `httpx` を提供して使用し、MCP Python SDK v2 は `httpx2` を使用します。通常の MCP 接続では、アプリケーションを変更する必要はありません。[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 +- 公開されたプロバイダー非依存のテストユーティリティで、プロバイダーやプロセスへの依存なしに、エージェントモデル、サンドボックスセッション、Realtime セッション、および音声パイプラインのワークフローを扱えるようになりました。実際のプロバイダーアダプターまたは統合境界を維持すべき場合のレシピとガイダンスについては、[テスト](testing.md)を参照してください。 ### 0.20.0 -バージョン 0.20.0 には、ローカル MCP HTTP トランスポートをカスタマイズするアプリケーションにとって、破壊的変更となる可能性がある MCP 依存関係の移行が含まれます。また、エージェントまたは実行でモデルを明示的に選択しない場合に使用される SDK のデフォルトモデルも更新されます。 +バージョン 0.20.0 には、ローカル MCP HTTP トランスポートをカスタマイズするアプリケーションにとって破壊的となる可能性がある MCP 依存関係の移行が含まれます。また、エージェントまたは実行でモデルを明示的に選択しない場合に使用される SDK のデフォルトモデルも更新されました。 主な変更点: -- SDK のデフォルトモデルは、`gpt-5.4-mini` ではなく `gpt-5.6-luna` になりました。デフォルトの `reasoning.effort="none"` および `verbosity="low"` 設定に変更はありません。 -- エージェントで明示的に指定したモデル、実行レベルのモデルオーバーライド、および `OPENAI_DEFAULT_MODEL` 環境変数は、引き続き SDK のデフォルトより優先されます。 -- Realtime 入力文字起こし設定で、`gpt-transcribe`、`gpt-live-transcribe`、`gpt-realtime-whisper` が認識されるようになりました。低レイテンシーの `gpt-live-transcribe` セッションでは、ネストされた `audio.input.transcription` 設定で `prompt`、`keywords`、および複数の想定される `languages` を指定できます。この SDK が固定している OpenAI クライアントバージョンは、`delay` のレイテンシー/精度レベルを `gpt-realtime-whisper` でのみサポートします。確定済みの音声ターン後の文字起こし、または検出言語の出力には、WebSocket 経由で `gpt-transcribe` を使用してください。`audio.input.turn_detection=None` を明示的に設定すると、自動ターン検出が無効になります。[入力文字起こし設定](realtime/guide.md#input-transcription-settings)を参照してください。 -- Agents SDK によって作成されるローカル MCP 接続は、`mcp>=1.19.0,<3` を通じて v1 互換性を維持しながら、MCP Python SDK v2 をサポートするようになりました。Agents SDK は、通常の stdio、SSE、Streamable HTTP 接続を自動的に適応させます。MCP v2 がインストールされている場合、これらの接続は `mcp.Client(mode="auto")` を使用してサポート対象の最新プロトコルを検出し、古いサーバーでは従来の `initialize` ハンドシェイクにフォールバックします。依存関係の解決で MCP v2 が選択された場合、カスタム `httpx.Auth` オブジェクトまたは `httpx.AsyncClient` ファクトリーを提供するアプリケーションは、それらの値を `httpx2` に移行する必要があります。あるいは、v1 の HTTP スタックを維持するには `mcp<2` に固定してください。`MCPServerStreamableHttp` の `params["ignore_initialized_notification_failure"] = True` オプションも、引き続き v1 専用です。移行の詳細については、[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 -- サンドボックスのマウント検証では、サンドボックスまたはマウントヘルパーで副作用が発生する前に、安全でない認証情報の配置を拒否するようになりました。信頼できるアプリケーションでは、ストレージ機能テーブルを変更することなく、コンテナー内の正確なマウントパスについて、マウント範囲または広範囲の認証情報公開を承認できます。これらの承認は実行時にのみ有効であり、シリアライズされたサンドボックス状態だけで認証情報への権限が付与されることはありません。保護されたマウント境界では、SDK は新たにリダクトされた例外を返します。元の例外が、SDK で正確に認識されるサンドボックスエラーであり、承認された構造化フィールドが検証に合格した場合、置換後の例外にはそのサブタイプと検証済みの安全なフィールドが保持されます。認識された `MountConfigError` では、SDK が生成した安全な検証メッセージも保持できます。それ以外の場合、SDK は新たに汎用のリダクト済みエラーを返します。プロバイダーが制御するメッセージ、その他の未承認メッセージ、コマンドデータ、注記、コンテキスト、原因、および元のトレースバック状態は保持されません。[マウントとリモートストレージ](sandbox/clients.md#mounts-and-remote-storage)および[セッション状態からの再開](sandbox/guide.md#resume-from-session-state)を参照してください。 -- 再試行ポリシーでは、安定したリプレイ安全性情報を確認し、プロバイダーが安全でないと判断した非ストリーミングリクエストに対して `RetryDecision(approve_unsafe_replay=True)` を明示的に設定できます。この承認によって、中止、送出済みのストリーミング出力、または Programmatic Tool Calling などのローカル側の副作用に対する個別の拒否を回避することはできません。[Runner が管理する再試行](models/index.md#runner-managed-retries)を参照してください。 -- 再開可能な `RunState` オブジェクトでは、次回のモデル呼び出し前に `add_input()` を使用して永続的なユーザー入力をステージングできるようになりました。ステージングされた入力はシリアライズ後も保持され、入力ガードレールを通過し、ローカルセッションとサーバー管理の会話全体で永続的な SDK 入力を 1 回生成します。安全でないリプレイを明示的に承認した場合は、引き続き入力がプロバイダーに再送信され、プロバイダー側の処理が繰り返される可能性があります。[再開前の入力追加](results.md#add-input-before-resuming)を参照してください。 -- 実行時の信頼性に関する修正により、ストリーミングと非ストリーミングの[出力ガードレールにおけるセッション永続化](guardrails.md#output-guardrails)の動作が統一され、コピーおよび名前空間設定の際に `FunctionTool` のサブクラスが保持されるようになりました。また、空のストリームを暗黙的に完了する代わりに、[サポートされていない Chat Completions 音声出力](models/index.md#chat-completions-compatibility-options)に対して明示的なエラーが発生するようになりました。`OpenAIResponsesCompactionSession` ラッパーは、キャンセルが呼び出し元に到達する前に、[コンパクション前の履歴復元](sessions/index.md#auto-compaction-can-block-streaming)を試行して完了を待ちます。[`VoicePipeline`](voice/pipeline.md#results) のコンシューマーは、正常な実行後に文字起こしセッションのクローズが失敗した場合、その失敗を受け取るようになりました。一方、先にターンが失敗していた場合は、後から発生したクローズ失敗よりも優先されます。`RunState` のラウンドトリップでは、ローカルシェル出力、承認済みのコンピューター安全性チェック、デフォルト値を持つツール出力フィールド、および辞書、リスト、タプルの走査中に検出された Pydantic モデルまたはデータクラスの出力が保持されるようになりました。MCP 変換では、自由形式のオブジェクトスキーマと画像出力が保持され、音声ブロックやリソースブロックなど、その他の raw コンテンツブロックは有効な JSON テキストとしてシリアライズされます。`MCPServerManager` は、重複するライフサイクル操作を直列化し、接続とクリーンアップに有限のデフォルトタイムアウトを適用します。モデルのリプレイでは、出力項目を入力として使用する前に、サーバーが所有する `created_by` メタデータが削除されます。 +- SDK のデフォルトモデルは、`gpt-5.4-mini` から `gpt-5.6-luna` に変更されました。デフォルトの `reasoning.effort="none"` および `verbosity="low"` 設定に変更はありません。 +- 明示的なエージェントモデル、実行レベルのモデルオーバーライド、および `OPENAI_DEFAULT_MODEL` 環境変数は、引き続き SDK のデフォルトより優先されます。 +- Realtime 入力文字起こし設定で、`gpt-transcribe`、`gpt-live-transcribe`、`gpt-realtime-whisper` が認識されるようになりました。低レイテンシーの `gpt-live-transcribe` セッションでは、ネストされた `audio.input.transcription` 設定で `prompt`、`keywords`、および複数の想定 `languages` を指定できます。この SDK が固定している OpenAI クライアントのバージョンでは、`delay` のレイテンシー/精度レベルは `gpt-realtime-whisper` でのみサポートされます。確定済みの音声ターン後の文字起こし、または検出言語の出力には、WebSocket 経由で `gpt-transcribe` を使用してください。`audio.input.turn_detection=None` を明示的に設定すると、自動ターン検出が無効になります。[入力文字起こし設定](realtime/guide.md#input-transcription-settings)を参照してください。 +- Agents SDK によって作成されるローカル MCP 接続では、`mcp>=1.19.0,<3` による v1 互換性を維持しながら、MCP Python SDK v2 がサポートされるようになりました。Agents SDK は通常の stdio、SSE、および Streamable HTTP 接続を自動的に適応させます。MCP v2 がインストールされている場合、これらの接続は `mcp.Client(mode="auto")` を使用してサポート対象の最新プロトコルを探索し、古いサーバーでは従来の `initialize` ハンドシェイクへフォールバックします。依存関係の解決で MCP v2 が選択された場合、カスタム `httpx.Auth` オブジェクトまたは `httpx.AsyncClient` ファクトリーを提供するアプリケーションでは、それらの値を `httpx2` に移行するか、v1 HTTP スタックを維持するために `mcp<2` に固定する必要があります。`MCPServerStreamableHttp` の `params["ignore_initialized_notification_failure"] = True` オプションも引き続き v1 専用です。移行の詳細については、[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 +- サンドボックスのマウント検証では、サンドボックスまたはマウントヘルパーの副作用が発生する前に、安全でない認証情報の配置を拒否するようになりました。信頼済みアプリケーションでは、ストレージ機能テーブルを変更せずに、コンテナー内の正確なマウントパスに対するマウント範囲または広範な認証情報の公開を承認できます。これらの承認は実行時にのみ有効であり、シリアライズされたサンドボックス状態自体が認証情報への権限を付与することはありません。保護されたマウント境界では、SDK は新しい秘匿化済み例外を返します。元の例外が正確に認識された SDK サンドボックスエラーであり、承認済みの構造化フィールドが検証に合格した場合、置換後もそのサブタイプと検証済みの安全なフィールドが維持されます。認識された `MountConfigError` では、SDK が生成した安全な検証メッセージも維持できます。それ以外の場合、SDK は新しい汎用の秘匿化済みエラーを返します。プロバイダーによって制御されるメッセージや、その他の未承認のメッセージ、コマンドデータ、注記、コンテキスト、原因、および元のトレースバック状態は保持されません。[マウントとリモートストレージ](sandbox/clients.md#mounts-and-remote-storage)および[セッション状態からの再開](sandbox/guide.md#resume-from-session-state)を参照してください。 +- リトライポリシーでは、安定した再実行安全性の情報を確認し、プロバイダーが安全でないと判断した非ストリーミングリクエストに対して `RetryDecision(approve_unsafe_replay=True)` を明示的に設定できます。この承認によって、中止、送出済みのストリーミング出力、または Programmatic Tool Calling などの別個のローカル副作用拒否を回避することはできません。[Runner が管理するリトライ](models/index.md#runner-managed-retries)を参照してください。 +- 再開可能な `RunState` オブジェクトでは、次回のモデル呼び出し前に `add_input()` を使用して永続的なユーザー入力を準備できるようになりました。準備された入力はシリアライズ後も保持され、入力ガードレールを通過し、ローカルセッションおよびサーバー管理の会話全体で永続的な SDK 入力を 1 回生成します。安全でない再実行が明示的に承認されている場合、入力がプロバイダーへ再送信され、プロバイダー側の処理が繰り返される可能性があります。[再開前の入力追加](results.md#add-input-before-resuming)を参照してください。 +- 実行時の信頼性修正により、ストリーミングと非ストリーミングの[出力ガードレールのセッション永続化](guardrails.md#output-guardrails)が統一され、コピーおよび名前空間化の際に `FunctionTool` のサブクラスが維持されるようになりました。また、[未対応の Chat Completions 音声出力](models/index.md#chat-completions-compatibility-options)では、空のストリームを暗黙的に完了する代わりに、明示的なエラーが送出されるようになりました。`OpenAIResponsesCompactionSession` ラッパーは、キャンセルが呼び出し元へ到達する前に、[コンパクション前の履歴復元](sessions/index.md#auto-compaction-can-block-streaming)を試行して完了を待ちます。[`VoicePipeline`](voice/pipeline.md#results) のコンシューマーは、正常な実行後に文字起こしセッションのクローズが失敗した場合、その失敗を受け取るようになりました。一方、先に発生したターンの失敗は、後から発生したクローズの失敗より優先されます。`RunState` のラウンドトリップでは、ローカルシェル出力、承認済みのコンピューター安全性チェック、デフォルト値のツール出力フィールド、および辞書、リスト、タプルの走査中に検出された Pydantic モデルまたは dataclass の出力が維持されるようになりました。MCP 変換では、自由形式のオブジェクトスキーマと画像出力が維持され、音声ブロックやリソースブロックなど、その他の raw コンテンツブロックは有効な JSON テキストとしてシリアライズされます。`MCPServerManager` は、重複するライフサイクル操作を直列化し、接続とクリーンアップに有限のデフォルトタイムアウトを適用します。モデルの再実行では、出力項目を入力として使用する前に、サーバー所有の `created_by` メタデータが削除されます。 ### 0.19.0 -このマイナーリリースに破壊的変更は **ありません**。マイナーバージョンの更新は、OpenAI Responses の重要な新機能領域である Programmatic Tool Calling を反映したものです。 +このマイナーリリースでは、破壊的変更は導入されて **いません**。マイナーバージョンの増分は、OpenAI Responses の重要な新機能領域である Programmatic Tool Calling を反映したものです。 主な変更点: -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] が追加されました。これにより、対応する OpenAI Responses モデルは JavaScript を生成し、Programmatic Tool Calling の対象となるツールを連携させることができます。ツールごとの `allowed_callers`、`FunctionTool` インスタンスからの structured outputs、および Runner のストリーミング、ガードレール、承認、セッション、`RunState` との統合をサポートします。設定と制約については、[Programmatic Tool Calling](tools.md#programmatic-tool-calling)を参照してください。 -- 公開 `agents.decorators` モジュールと、既存の `@function_tool` デコレーターの短いエイリアスである `@tool` が、既存のガードレールデコレーターとともに追加されました。`FunctionTool` インスタンスでは、非同期の呼び出し可能オブジェクトもサポートされるようになりました。 -- SDK 設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け付けるようになり、未知の設定に対する検証も追加されました。 -- モデル、ツール、MCP、Realtime、セッション、サンドボックス、トレーシング全体で、エラーおよび診断ログが強化され、有用なデバッグコンテキストを維持しながら、raw の機密ペイロードが公開されないようになりました。 -- AnyLLM、LiteLLM、Chat Completions との互換性が向上し、モデルの再試行をまたいでセッション履歴が保持されるようになりました。また、レスポンス開始前に発生する WebSocket 過負荷に対するプロバイダー再試行のガイダンスが追加され、許可されている場合は、オプトインした Runner 再試行ポリシーで失敗した試行をリプレイできるようになりました。 -- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)が追加されました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外され、意図的に動的なマウント変更やセッション再開はサポートされません。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] が追加されました。これにより、対応する OpenAI Responses モデルは JavaScript を生成し、Programmatic Tool Calling の対象となるツールを連携させることができます。ツール単位の `allowed_callers`、`FunctionTool` インスタンスからの structured outputs、Runner のストリーミング、ガードレール、承認、セッション、および `RunState` との統合をサポートします。設定と制約については、[Programmatic Tool Calling](tools.md#programmatic-tool-calling)を参照してください。 +- 公開 `agents.decorators` モジュール、および既存のガードレールデコレーターとともに、既存の `@function_tool` デコレーターの短い別名として `@tool` が追加されました。`FunctionTool` インスタンスでは、非同期呼び出し可能オブジェクトもサポートされるようになりました。 +- SDK 設定では、エージェント、実行、モデル、セッション、サンドボックス、音声パイプラインの全体で、型付き設定オブジェクトまたは辞書のいずれかを一貫して受け入れるようになり、不明な設定も検証されます。 +- モデル、ツール、MCP、Realtime、セッション、サンドボックス、およびトレーシング全体のエラーと診断ログが強化され、有用なデバッグコンテキストを維持しながら、raw の機密ペイロードが公開されないようになりました。 +- AnyLLM、LiteLLM、および Chat Completions の互換性が向上し、モデルのリトライ間でセッション履歴が維持されるようになりました。また、レスポンス開始前に発生した WebSocket の過負荷に関するプロバイダーのリトライガイダンスが追加され、オプトインした Runner のリトライポリシーで、許可されている場合に失敗した試行を再実行できるようになりました。 +- `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)が追加されました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外され、動的なマウント変更やセッション再開は意図的にサポートされません。 ### 0.18.0 -このマイナーリリースに破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを目的としています。 +このマイナーリリースでは、破壊的変更は導入されて **いません**。マイナーバージョンの増分は、Realtime エージェントのデフォルトモデル更新のみを目的としています。 主な変更点: -- Realtime エージェントでは、デフォルトモデルとして `gpt-realtime-2.1` が使用されるようになり、新しい Realtime 設定では追加設定なしで最新の推奨モデルが使用されます。 +- Realtime エージェントのデフォルトモデルとして `gpt-realtime-2.1` が使用されるようになり、新しい Realtime 設定では追加設定なしで最新の推奨モデルが使用されます。 ### 0.17.0 -このバージョンでは、サンドボックスのローカルソース実体化において、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` が実体化用の `base_dir` 内に維持されます。`base_dir` は、マニフェスト適用時の SDK プロセスの現在の作業ディレクトリです。相対ローカルソースはそのディレクトリを基準に解決されますが、絶対ローカルソースは、すでにそのディレクトリ内にあるか、明示的な許可の対象である必要があります。これによりローカル成果物の境界に関する問題は解消されますが、そのベースディレクトリ外にある信頼済みのホストファイルまたはディレクトリを、意図的にサンドボックスワークスペースへコピーするアプリケーションには影響する可能性があります。 +このバージョンでは、ソースパスが `Manifest.extra_path_grants` の対象でない限り、サンドボックスのローカルソースの実体化において、`LocalFile.src` と `LocalDir.src` が実体化の `base_dir` 内に保持されます。`base_dir` は、マニフェストが適用される時点での SDK プロセスの現在の作業ディレクトリです。相対ローカルソースはそのディレクトリを基準に解決されますが、絶対ローカルソースは、すでにそのディレクトリ内または明示的に許可された範囲内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼済みホストのファイルまたはディレクトリを、そのベースディレクトリの外部からサンドボックスワークスペースへ意図的にコピーするアプリケーションに影響する可能性があります。 -移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 +移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みホストのルートを許可してください。サンドボックスでそれらのファイルを読み取るだけの場合は、読み取り専用にすることを推奨します。 ```python from pathlib import Path @@ -103,11 +116,11 @@ manifest = Manifest( ) ``` -`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションですでに対象ホストパスを承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可を設定しないでください。 +`extra_path_grants` は、信頼済みアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可設定を作成しないでください。 ### 0.16.0 -このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` ではなく `gpt-5.4-mini` になりました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定に `reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルトが含まれるようになりました。 +このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルトが含まれるようになりました。 以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に指定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 @@ -117,14 +130,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 主な変更点: -- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` で、ターン制限を無効にする `max_turns=None` を受け付けるようになりました。 -- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、およびプロバイダー提供のサンドボックス実装全体で、絶対パスのシンボリックリンク先を含め、アーカイブルート外を指すシンボリックリンクを含む tar アーカイブを拒否するようになりました。 +- `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` では、ターン制限を無効にするための `max_turns=None` を受け入れるようになりました。 +- サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、プロバイダー対応の各サンドボックス実装において、絶対シンボリックリンク先を含め、アーカイブルートの外部を指すシンボリックリンクを含む tar アーカイブを拒否するようになりました。 ### 0.15.0 -このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に実行ループが `MaxTurnsExceeded` まで再試行されたりするのではなく、`ModelRefusalError` として明示的に提示されるようになりました。 +このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に実行ループが `MaxTurnsExceeded` までリトライされたりする代わりに、`ModelRefusalError` として明示的に提示されるようになりました。 -これは以前、拒否のみのモデルレスポンスが `final_output == ""` で完了することを期待していたコードに影響します。例外を発生させずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 +これは、拒否のみのモデルレスポンスが `final_output == ""` で完了することを以前に想定していたコードに影響します。例外を送出せずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 ```python result = Runner.run_sync( @@ -134,81 +147,81 @@ result = Runner.run_sync( ) ``` -structured outputs を使用するエージェントでは、ハンドラーがエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様に検証します。 +structured outputs を使用するエージェントでは、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 ### 0.14.0 -このマイナーリリースに破壊的変更は **ありません** が、サンドボックスエージェントという主要な新しいベータ機能領域に加え、ローカル、コンテナー化、ホスト環境全体で利用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されます。 +このマイナーリリースでは、破壊的変更は導入されて **いません**。ただし、主要な新しいベータ機能領域であるサンドボックスエージェントに加え、ローカル、コンテナー化、ホスト環境で使用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されています。 主な変更点: -- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムの API サーフェスが追加されました。これによりエージェントは、ファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開機能を備えた永続的な隔離ワークスペース内で作業できます。 -- `UnixLocalSandboxClient` と `DockerSandboxClient` によるローカルおよびコンテナー化された開発向けのサンドボックス実行バックエンドに加え、Python パッケージのオプション依存関係 extras を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel のホスト型プロバイダー統合が追加されました。 -- サンドボックスメモリのサポートが追加され、段階的開示、複数ターンのグループ化、設定可能な分離境界、および S3 を利用したワークフローを含む永続化メモリのサンプルコードにより、今後の実行で過去の実行から得た知見を再利用できるようになりました。 -- ローカルおよび合成ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files 用のリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットによる再開フローを含む、より広範なワークスペースおよび再開モデルが追加されました。 -- `examples/sandbox/` 配下に多数のサンドボックスのサンプルコードとチュートリアルが追加されました。スキル、ハンドオフ、メモリ、プロバイダー固有の設定を使用するコーディングタスク、およびコードレビュー、データルーム QA、Web サイトの複製などのエンドツーエンドワークフローを扱います。 -- サンドボックス対応のセッション準備、機能のバインディング、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーのデフォルト、および機密性の高い MCP 出力のより安全なリダクションにより、コアランタイムとトレーシングスタックが拡張されました。 +- `SandboxAgent`、`Manifest`、`SandboxRunConfig` を中心とする新しいベータ版サンドボックスランタイムインターフェースが追加され、エージェントがファイル、ディレクトリ、Git リポジトリ、マウント、スナップショット、再開サポートを備えた永続的な分離ワークスペース内で作業できるようになりました。 +- `UnixLocalSandboxClient` と `DockerSandboxClient` を通じたローカルおよびコンテナー化された開発向けのサンドボックス実行バックエンドに加え、Python パッケージのオプション依存関係 extras を通じて、Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop、Vercel 向けのホスト型プロバイダー統合が追加されました。 +- サンドボックスのメモリサポートが追加され、段階的開示、複数ターンのグループ化、設定可能な分離境界、S3 を利用したワークフローを含む永続メモリのコード例により、将来の実行で以前の実行から得た知見を再利用できるようになりました。 +- ローカルおよび合成ワークスペースエントリ、S3/R2/GCS/Azure Blob Storage/S3 Files 向けのリモートストレージマウント、移植可能なスナップショット、`RunState`、`SandboxSessionState`、または保存済みスナップショットを使用する再開フローを含む、より包括的なワークスペースおよび再開モデルが追加されました。 +- `examples/sandbox/` 配下に、多数のサンドボックスのコード例とチュートリアルが追加されました。スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有の設定、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドワークフローを扱います。 +- サンドボックス対応のセッション準備、機能のバインド、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーデフォルト、および機密性の高い MCP 出力のより安全な秘匿化により、コアランタイムとトレーシングスタックが拡張されました。 ### 0.13.0 -このマイナーリリースに破壊的変更は **ありません** が、重要な Realtime のデフォルト更新に加え、新しい MCP 機能とランタイム安定性の修正が含まれます。 +このマイナーリリースでは、破壊的変更は導入されて **いません**。ただし、注目すべき Realtime のデフォルト更新、新しい MCP 機能、およびランタイムの安定性修正が含まれます。 主な変更点: -- デフォルトの WebSocket Realtime モデルは `gpt-realtime-1.5` になり、新しい Realtime エージェント設定では追加設定なしで新しいモデルが使用されます。 -- `MCPServer` で `list_resources()`、`list_resource_templates()`、`read_resource()` が公開され、`MCPServerStreamableHttp` で `session_id` が公開されるようになりました。これにより、MCP Streamable HTTP トランスポートを使用するセッションを、再接続またはステートレスワーカーをまたいで再開できます。 -- Chat Completions 統合では、`should_replay_reasoning_content` を使用して既存の推論内容を再送信するようオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論とツール呼び出しの連続性が向上しました。 -- `SQLAlchemySession` での最初の書き込みの競合、推論除去後に孤立したアシスタントメッセージ ID を含むコンパクションリクエスト、MCP/推論項目を残す `remove_all_tools()`、`FunctionTool` インスタンス用バッチエグゼキューターの競合状態など、複数のランタイムおよびセッションのエッジケースが修正されました。 +- デフォルトの WebSocket Realtime モデルが `gpt-realtime-1.5` になり、新しい Realtime エージェント設定では追加設定なしで新しいモデルが使用されます。 +- `MCPServer` では `list_resources()`、`list_resource_templates()`、`read_resource()` が公開され、`MCPServerStreamableHttp` では `session_id` が公開されるようになりました。これにより、MCP Streamable HTTP トランスポートを使用するセッションを、再接続やステートレスワーカーをまたいで再開できます。 +- Chat Completions 統合では、`should_replay_reasoning_content` を使用して既存の推論内容を再送信することをオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論およびツール呼び出しの連続性が向上しました。 +- `SQLAlchemySession` での同時初回書き込み、推論除去後に孤立した assistant メッセージ ID を含むコンパクションリクエスト、MCP/推論項目を残す `remove_all_tools()`、`FunctionTool` インスタンス向けバッチエグゼキューターの競合状態など、複数のランタイムおよびセッションのエッジケースが修正されました。 ### 0.12.0 -このマイナーリリースに破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 +このマイナーリリースでは、破壊的変更は導入されて **いません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 ### 0.11.0 -このマイナーリリースに破壊的変更は **ありません**。主な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 +このマイナーリリースでは、破壊的変更は導入されて **いません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 ### 0.10.0 -このマイナーリリースに破壊的変更は **ありません** が、OpenAI Responses ユーザー向けの重要な新機能領域として、Responses API の WebSocket トランスポート対応が含まれます。 +このマイナーリリースでは、破壊的変更は導入されて **いません**。ただし、OpenAI Responses のユーザー向けに、Responses API の WebSocket トランスポートサポートという重要な新機能領域が含まれます。 主な変更点: -- OpenAI Responses モデル向けの WebSocket トランスポート対応が追加されました(オプトイン方式であり、HTTP が引き続きデフォルトのトランスポートです)。 -- 複数ターンの実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` が追加されました。 -- ストリーミング、ツール、承認、後続ターンを扱う、新しい WebSocket ストリーミングのサンプルコード(`examples/basic/stream_ws.py`)が追加されました。 +- OpenAI Responses モデル向けの WebSocket トランスポートサポートが追加されました(オプトイン方式であり、HTTP は引き続きデフォルトのトランスポートです)。 +- 複数ターンの実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` が追加されました。 +- ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングのコード例(`examples/basic/stream_ws.py`)が追加されました。 ### 0.9.0 -このバージョンでは、Python 3.9 のサポートが終了しました。このメジャーバージョンは 3 か月前に EOL を迎えています。より新しいランタイムバージョンにアップグレードしてください。 +このバージョンでは、このメジャーバージョンが 3 か月前に EOL を迎えたため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンへアップグレードしてください。 -また、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に限定されました。通常、この変更が破壊的な問題を引き起こすことはありませんが、コードがより広い共用体型に依存している場合は、調整が必要になる可能性があります。 +さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に絞り込まれました。通常、この変更によって破壊的な問題が発生することはありませんが、コードがより広いユニオン型に依存している場合は、調整が必要になる可能性があります。 ### 0.8.0 -このバージョンでは、次の 2 つのランタイム動作変更により、移行作業が必要になる可能性があります。 +このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる場合があります。 -- `FunctionTool` インスタンスでラップされた **同期** Python callable は、イベントループスレッドで実行されるのではなく、`asyncio.to_thread(...)` を通じてワーカースレッド上で実行されるようになりました。ツールロジックがスレッドローカル状態またはスレッドアフィンなリソースに依存している場合は、非同期ツール実装に移行するか、ツールコード内でスレッドアフィニティを明示してください。 -- ローカル MCP ツールの失敗処理が設定可能になり、デフォルト動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返せるようになりました。フェイルファストのセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定を上書きするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 +- `FunctionTool` インスタンスがラップする **同期** Python callable は、イベントループスレッド上で実行される代わりに、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールロジックがスレッドローカル状態またはスレッドアフィニティを持つリソースに依存している場合は、非同期ツール実装へ移行するか、ツールコード内でスレッドアフィニティを明示してください。 +- ローカル MCP ツールの失敗処理が設定可能になり、デフォルト動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返せるようになりました。即時失敗のセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定をオーバーライドするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 ### 0.7.0 -このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかありました。 +このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかあります。 -- ネストされたハンドオフ履歴は **オプトイン** になりました(デフォルトでは無効です)。v0.6.x のデフォルトのネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 -- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、`"none"` に変更されました(以前は SDK のデフォルトで設定された `"low"` でした)。プロンプトまたは品質/コストプロファイルが `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 +- ネストされたハンドオフ履歴は、**オプトイン** 方式(デフォルトでは無効)になりました。v0.6.x のデフォルトのネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 +- `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルトで設定されていた以前のデフォルト `"low"` から `"none"` に変更されました。プロンプトまたは品質/コストの特性が `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 ### 0.6.0 -このバージョンでは、デフォルトのハンドオフ履歴が、ユーザーとアシスタントのターンを個別のメッセージとして渡すのではなく、単一のアシスタントメッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます。 -- 既存の単一メッセージ形式のハンドオフ記録は、デフォルトで `` ブロックの前に、正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` を付けて開始するようになり、後続のエージェントは明確にラベル付けされた要約を受け取れます。 +このバージョンでは、ユーザーと assistant の各ターンを別々のメッセージとして渡す代わりに、デフォルトのハンドオフ履歴が単一の assistant メッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます +- 既存の単一メッセージによるハンドオフのトランスクリプトは、デフォルトで `` ブロックの前に正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` から始まるようになり、後続のエージェントに明確なラベル付きの要約が提供されます ### 0.5.0 -このバージョンには外部から確認できる破壊的変更はありませんが、内部には新機能といくつかの重要な更新が含まれています。 +このバージョンでは、目に見える破壊的変更は導入されていませんが、新機能と内部の重要な更新がいくつか含まれています。 - `RealtimeRunner` に、[SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートが追加されました。 -- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックが大幅に改訂されました。 +- Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックが大幅に改訂されました ### 0.4.0 @@ -216,12 +229,12 @@ structured outputs を使用するエージェントでは、ハンドラーが ### 0.3.0 -このバージョンでは、Realtime API 対応が gpt-realtime モデルとその API インターフェース(GA 版)に移行します。 +このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA バージョン)へ移行します。 ### 0.2.0 -このバージョンでは、以前 `Agent` を引数として受け取っていたいくつかの箇所で、代わりに `AgentBase` を引数として受け取るようになりました。たとえば、これは MCP サーバーの `list_tools()` メソッドシグネチャに適用されます。これは純粋に型付けのみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 +このバージョンでは、以前は引数として `Agent` を受け取っていた箇所の一部が、代わりに `AgentBase` を受け取るようになりました。たとえば、これは MCP サーバーの `list_tools()` メソッドシグネチャに適用されます。これは型指定のみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 ### 0.1.0 -このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` の 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされたすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file +このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされているすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file diff --git a/docs/ja/results.md b/docs/ja/results.md index 5f60bd1d2c..de3be919c0 100644 --- a/docs/ja/results.md +++ b/docs/ja/results.md @@ -9,24 +9,24 @@ search: - `Runner.run(...)` または `Runner.run_sync(...)` からの [`RunResult`][agents.result.RunResult] - `Runner.run_streamed(...)` からの [`RunResultStreaming`][agents.result.RunResultStreaming] -どちらも [`RunResultBase`][agents.result.RunResultBase] を継承し、`final_output`、`new_items`、`last_agent`、`raw_responses`、`to_state()` など、共通の結果インターフェースを公開します。 +どちらも [`RunResultBase`][agents.result.RunResultBase] を継承しており、`final_output`、`new_items`、`last_agent`、`raw_responses`、`to_state()` など、共通の実行結果サーフェスを公開します。 -`RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御機能も追加されています。 +`RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御が追加されています。 -## 適切な結果インターフェースの選択 +## 適切な実行結果サーフェスの選択 -ほとんどのアプリケーションで必要なのは、少数の結果プロパティまたはヘルパーのみです。 +ほとんどのアプリケーションで必要になる実行結果のプロパティやヘルパーは、ごく一部です。 | 必要なもの | 使用するもの | | --- | --- | | ユーザーに表示する最終回答 | `final_output` | -| ローカルの完全なトランスクリプトを含む、再実行可能な次ターン入力リスト | `to_input_list()` | +| ローカルの完全な会話記録を含む、再実行可能な次ターンの入力リスト | `to_input_list()` | | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な実行項目 | `new_items` | -| 通常、次のユーザーターンを処理するエージェント | `last_agent` | +| 通常、次のユーザーターンを処理すべきエージェント | `last_agent` | | `previous_response_id` を使用した OpenAI Responses API のチェーン | `last_response_id` | -| 保留中の承認と再開可能なスナップショット | `interruptions` および `to_state()` | +| 保留中の承認と再開可能なスナップショット | `interruptions` と `to_state()` | | 現在のネストされた `Agent.as_tool()` 呼び出しに関するメタデータ | `agent_tool_invocation` | -| raw モデル呼び出しまたはガードレールの診断情報 | `raw_responses` およびガードレール結果の配列 | +| raw モデル呼び出しまたはガードレールの診断情報 | `raw_responses` とガードレールの実行結果配列 | ## 最終出力 @@ -44,49 +44,49 @@ search: ## 入力、次ターンの履歴、新規項目 -これらのインターフェースは、それぞれ異なる目的に対応します。 +これらのサーフェスは、それぞれ異なる問いに対応します。 -| プロパティまたはヘルパー | 含まれる内容 | 最適な用途 | +| プロパティまたはヘルパー | 含まれるもの | 最適な用途 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基本入力です。ハンドオフ入力フィルターによって履歴が書き換えられた場合は、実行の継続に使用されたフィルター済み入力が反映されます。 | この実行で実際に使用された入力の監査 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として表したビューです。デフォルトの `mode="preserve_all"` は、`new_items` から変換された履歴を維持します。ただし、SDK のデフォルトのネストされたハンドオフ履歴へすでに移動された、セッション項目の同一の出現箇所を再度追加することはありません。`mode="normalized"` は、ハンドオフフィルタリングによってモデル履歴が書き換えられた場合に、正規の継続入力を優先します。 | 手動チャットループ、クライアント管理の会話状態、プレーンな項目としての履歴確認 | -| [`new_items`][agents.result.RunResultBase.new_items] | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な [`RunItem`][agents.items.RunItem] ラッパーです。 | ログ、UI、監査、デバッグ | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行内の各モデル呼び出しから取得された raw [`ModelResponse`][agents.items.ModelResponse] オブジェクトです。 | プロバイダーレベルの診断または raw レスポンスの確認 | +| [`input`][agents.result.RunResultBase.input] | この実行セグメントの基本入力。ハンドオフ入力フィルターによって履歴が書き換えられた場合は、実行の続行に使用されたフィルター適用後の入力が反映されます。 | この実行で実際に入力として使用された内容の監査 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 実行を入力項目として表したビュー。デフォルトの `mode="preserve_all"` では、`new_items` から変換された履歴が維持されます。ただし、SDK のデフォルトのネストされたハンドオフ履歴へすでに移されたセッション項目の同一の出現は、再度追加されません。`mode="normalized"` では、ハンドオフフィルタリングによってモデル履歴が書き換えられた場合、正規の続行用入力が優先されます。 | 手動のチャットループ、クライアント管理の会話状態、プレーンな項目による履歴の確認 | +| [`new_items`][agents.result.RunResultBase.new_items] | エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な [`RunItem`][agents.items.RunItem] ラッパー。 | ログ、UI、監査、デバッグ | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 実行内の各モデル呼び出しから得られた raw [`ModelResponse`][agents.items.ModelResponse] オブジェクト。 | プロバイダーレベルの診断または raw レスポンスの確認 | 実際には、次のように使い分けます。 - 実行をプレーンな入力項目として確認する場合は、`to_input_list()` を使用します。 -- ハンドオフフィルタリングまたはネストされたハンドオフ履歴の書き換え後に、次の `Runner.run(..., input=...)` 呼び出しで使用する正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 +- ハンドオフフィルタリングまたはネストされたハンドオフ履歴の書き換え後、次の `Runner.run(..., input=...)` 呼び出しに使用する正規のローカル入力が必要な場合は、`to_input_list(mode="normalized")` を使用します。 - SDK に履歴の読み込みと保存を任せる場合は、[`session=...`](sessions/index.md) を使用します。 -- `conversation_id` または `previous_response_id` を使用して OpenAI のサーバー管理状態を利用している場合、通常は `to_input_list()` を再送信せず、新しいユーザー入力のみを渡して保存済み ID を再利用します。 -- ログ、UI、または監査用に変換済みの完全な履歴が必要な場合は、デフォルトの `to_input_list()` モードまたは `new_items` を使用します。 +- `conversation_id` または `previous_response_id` を使用して OpenAI のサーバー管理状態を利用している場合、通常は `to_input_list()` を再送信せず、新しいユーザー入力のみを渡して、保存されている ID を再利用します。 +- ログ、UI、監査のために変換済みの完全な履歴が必要な場合は、デフォルトの `to_input_list()` モードまたは `new_items` を使用します。 -SDK のデフォルトのネストされたハンドオフ履歴でメッセージ項目がそのまま保持される場合、Sessions、`RunState`、`to_input_list()` は、内容によって重複排除するのではなく、所有する正確な出現箇所を追跡します。別々に発生した同一メッセージは別々のまま保持され、すでに所有されている出現箇所だけが 2 回目の追加を回避されます。 +SDK のデフォルトのネストされたハンドオフ履歴がメッセージ項目をそのまま保持する場合、Sessions、`RunState`、`to_input_list()` は内容に基づいて重複を排除するのではなく、所有している同一の出現を追跡します。同じ内容のメッセージが別々に発生した場合は別々に保持され、すでに所有されている出現のみが再度追加されないようになります。 -JavaScript SDK とは異なり、Python には実行中に新たに生成されたモデル形式の項目のみを含む独立した `output` プロパティはありません。SDK のメタデータが必要な場合は `new_items` を使用し、raw モデルペイロードが必要な場合は `raw_responses` を確認してください。 +JavaScript SDK とは異なり、Python には実行中に新たに生成されたモデル形式の項目だけを含む独立した `output` プロパティはありません。SDK のメタデータが必要な場合は `new_items` を使用し、raw モデルペイロードが必要な場合は `raw_responses` を確認してください。 -コンピュータツールの項目を会話入力として再送信する場合は、raw Responses ペイロード形式が使用されます。プレビューモデルの `computer_call` 項目では単一の `action` が保持される一方、`gpt-5.5` コンピュータ呼び出しでは、バッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] はモデルが生成した形式を保持するため、これらの項目を会話入力として手動で再送信する場合、一時停止と再開のフロー、保存済みトランスクリプトは、プレビュー版と GA 版の両方のコンピュータツール呼び出しで引き続き動作します。ローカルの実行結果は、引き続き `new_items` 内に `computer_call_output` 項目として表示されます。 +コンピュータツール項目を会話入力として再送信する場合は、raw Responses ペイロード形式が使用されます。プレビューモデルの `computer_call` 項目では単一の `action` が保持されますが、`gpt-5.5` のコンピュータ呼び出しでは、バッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] はモデルが生成した形式をそのまま保持するため、これらの項目を会話入力として手動で再送信する処理、一時停止と再開のフロー、保存された会話記録は、プレビュー版と GA 版の両方のコンピュータツール呼び出しで引き続き機能します。ローカルの実行結果は、引き続き `new_items` 内に `computer_call_output` 項目として表示されます。 ### 新規項目 -[`new_items`][agents.result.RunResultBase.new_items] では、実行中に起きたことを最も詳細に確認できます。一般的な項目型は次のとおりです。 +[`new_items`][agents.result.RunResultBase.new_items] では、実行中に発生した内容を最も詳細に確認できます。一般的な項目の型は次のとおりです。 -- 再開されたモデル呼び出しの直前に `RunState.pending_input` から受け入れられた入力を表す [`InputItem`][agents.items.InputItem] +- 再開後のモデル呼び出しの直前に `RunState.pending_input` から受け入れられた入力を表す [`InputItem`][agents.items.InputItem] - アシスタントメッセージを表す [`MessageOutputItem`][agents.items.MessageOutputItem] - 推論項目を表す [`ReasoningItem`][agents.items.ReasoningItem] -- Responses のツール検索リクエストと読み込まれたツール検索結果を表す [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] および [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- ツール呼び出しとその実行結果を表す [`ToolCallItem`][agents.items.ToolCallItem] および [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] -- 承認のために一時停止したツール呼び出しを表す [`ToolApprovalItem`][agents.items.ToolApprovalItem] +- Responses のツール検索リクエストと読み込まれたツール検索結果を表す [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] と [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- ツール呼び出しとその実行結果を表す [`ToolCallItem`][agents.items.ToolCallItem] と [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- 承認待ちで一時停止したツール呼び出しを表す [`ToolApprovalItem`][agents.items.ToolApprovalItem] - ホスト型 MCP の承認とツールカタログを表す [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem]、[`MCPListToolsItem`][agents.items.MCPListToolsItem] -- ハンドオフリクエストと完了した移管を表す [`HandoffCallItem`][agents.items.HandoffCallItem] および [`HandoffOutputItem`][agents.items.HandoffOutputItem] +- ハンドオフリクエストと完了した転送を表す [`HandoffCallItem`][agents.items.HandoffCallItem] と [`HandoffOutputItem`][agents.items.HandoffOutputItem] -エージェントとの関連付け、ツール出力、ハンドオフの境界、または承認の境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 +エージェントとの関連付け、ツール出力、ハンドオフの境界、承認の境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 -ホスト型ツール検索を使用する場合は、モデルが発行した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を、該当ターンで読み込まれた名前空間、関数、またはホスト型 MCP サーバーを確認するには `ToolSearchOutputItem.raw_item` を調べてください。 +ホスト型ツール検索を使用する場合は、モデルが発行した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を、該当ターンで読み込まれた名前空間、関数、ホスト型 MCP サーバーを確認するには `ToolSearchOutputItem.raw_item` を調べます。 -プログラムによるツール呼び出しでは、生成された `program` は `ToolCallItem` です。そのプログラムが所有する通常の子ツール呼び出しも `ToolCallItem` エントリであり、対応する `program_output` は `ToolCallOutputItem` です。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外であり、それぞれ `MCPApprovalRequestItem` エントリと `MCPListToolsItem` エントリになります。 +Programmatic Tool Calling では、生成された `program` は `ToolCallItem` になり、そのプログラムが所有する通常の子ツール呼び出しも `ToolCallItem` のエントリになり、対応する `program_output` は `ToolCallOutputItem` になります。プログラムが所有するホスト型 MCP の `mcp_approval_request` 項目と `mcp_list_tools` 項目は例外であり、それぞれ `MCPApprovalRequestItem` エントリと `MCPListToolsItem` エントリになります。 -raw 項目は、型付きの Responses オブジェクトまたはマッピングである場合があります。特に、プログラムが所有する shell 呼び出しと apply-patch 呼び出しではマッピングが使用されます。マッピングでも安全な次の検査パターンを使用してください。 +raw 項目は、型付きの Responses オブジェクトまたはマッピングの場合があります。特に、プログラムが所有するシェル呼び出しとパッチ適用呼び出しではマッピングが使用されます。マッピングに対して安全な確認パターンを使用してください。 ```python from collections.abc import Mapping @@ -108,23 +108,23 @@ caller_id = ( ) ``` -プログラムが所有する子呼び出しでは、`caller` の `type` フィールドは `program` であり、`caller_id` は親プログラム呼び出しを識別します。 +プログラムが所有する子呼び出しでは、`caller` の `type` フィールドは `program` であり、`caller_id` によって親プログラム呼び出しが識別されます。 -## 会話の継続または再開 +## 会話の続行または再開 ### 次ターンのエージェント -[`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが含まれます。多くの場合、ハンドオフ後の次のユーザーターンで再利用するのに最適なエージェントです。 +[`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが含まれます。多くの場合、ハンドオフ後の次のユーザーターンで再利用するには、このエージェントが最適です。 ストリーミングモードでは、実行の進行に伴って [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が更新されるため、ストリームが完了する前にハンドオフを確認できます。 ### 中断と実行状態 -ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接呼び出されたツール、ハンドオフ後に到達したツール、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって要求された承認が含まれる場合があります。 +ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接のツール、ハンドオフ後に到達したツール、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって発生した承認が含まれる場合があります。 -[`to_state()`][agents.result.RunResult.to_state] を呼び出して、再開可能な [`RunState`][agents.run_state.RunState] を取得します。保留中の項目を承認または却下し、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 +再開可能な [`RunState`][agents.run_state.RunState] を取得するには、[`to_state()`][agents.result.RunResult.to_state] を呼び出します。次に、保留中の項目を承認または拒否し、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 -[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] の出力が Pydantic モデルまたはデータクラスの場合、`RunState` はその出力を構造化データとしてシリアライズします。`RunState` は辞書、リスト、タプルも再帰的に処理し、それらのコンテナ内で検出した Pydantic モデルまたはデータクラスを変換します。タプルは JSON のラウンドトリップ後にリストとして復元されます。JSON と互換性のないその他の値は文字列表現にフォールバックする場合があるため、正確なカスタム型をシリアライズ後も保持する必要がある場合は、明示的に JSON 互換のデータを返してください。 +[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] の出力が Pydantic モデルまたはデータクラスの場合、`RunState` はその出力を構造化データとしてシリアライズします。`RunState` は辞書、リスト、タプルも走査し、それらのコンテナ内で検出した Pydantic モデルまたはデータクラスを変換します。タプルは JSON のラウンドトリップ後にリストとして復元されます。JSON と互換性のないその他の値は、文字列表現にフォールバックする場合があります。そのため、カスタム型を正確にシリアライズ後も維持する必要がある場合は、JSON と明示的に互換性のあるデータを返してください。 ```python from agents import Agent, Runner @@ -141,7 +141,7 @@ if result.interruptions: #### 再開前の入力追加 -実行が一時停止した後、または完了したターンの後で停止したものの、未完了の実行が次のモデル呼び出しに到達する前に新しいユーザー入力を受け取った場合は、[`RunState.add_input()`][agents.run_state.RunState.add_input] を使用します。文字列はユーザーメッセージになり、複数回の呼び出しでは挿入順序が維持されます。ステージ済み入力はシリアライズされた `RunState` の一部であるため、`to_json()` / `from_json()` および `to_string()` / `from_string()` のラウンドトリップ後も保持されます。 +実行が一時停止した後、または完了済みのターンの後で停止した後に新しいユーザー入力が到着し、未完了の実行が次のモデル呼び出しに到達する前である場合は、[`RunState.add_input()`][agents.run_state.RunState.add_input] を使用します。文字列はユーザーメッセージになり、複数回呼び出した場合は挿入順が維持されます。ステージングされた入力はシリアライズ済みの `RunState` に含まれるため、`to_json()` / `from_json()` および `to_string()` / `from_string()` のラウンドトリップ後も維持されます。 ```python state = result.to_state() @@ -153,70 +153,72 @@ for interruption in state.get_interruptions(): result = await Runner.run(agent, state) ``` -再開時、Runner は現在のエージェントの入力ガードレールと [`RunConfig`][agents.run.RunConfig] の入力ガードレールの両方を、ステージ済み入力のみに適用します。クライアント管理の [`Session`][agents.memory.session.Session] が構成されている場合、Runner は受け入れられたステージ済み入力を永続的な [`InputItem`][agents.items.InputItem] に変換し、モデルリクエストを発行する前にセッションへの書き込み完了を待ちます。クライアント管理セッションもサーバー管理の会話もない場合、Runner はモデルリクエストを発行する前に、受け入れられたステージ済み入力を `InputItem` に変換します。サーバー管理の会話では、サーバーリクエストが受け入れるまで入力は保留状態のままです。シリアライズ、再開、再実行しても安全な再試行を通じて、SDK は永続的な `InputItem` の出現を 1 つだけ保持します。この SDK による出現回数の保証は、プロバイダーへの配信保証ではありません。リクエストがプロバイダーに到達した可能性がある後で再試行ポリシーが `RetryDecision(approve_unsafe_replay=True)` を返した場合、Runner はステージ済み入力を再送信する可能性があり、プロバイダー側の処理が繰り返されることがあります。正常に受け入れられた入力は、`new_items` に `InputItem` として表示されます。分離されたコピーを取得するには [`RunState.pending_input`][agents.run_state.RunState.pending_input] を読み取り、再開前にすべてのステージ済み入力を破棄するには [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input] を呼び出します。 +再開時、ランナーは現在のエージェントの入力ガードレールと [`RunConfig`][agents.run.RunConfig] の入力ガードレールの両方を、ステージングされた入力にのみ適用します。クライアント管理の [`Session`][agents.memory.session.Session] が設定されている場合、ランナーは受け入れられたステージング入力を永続的な [`InputItem`][agents.items.InputItem] に変換し、モデルリクエストを発行する前にセッションへの書き込みを待機します。クライアント管理セッションまたはサーバー管理の会話がない場合、ランナーはモデルリクエストを発行する前に、受け入れられたステージング入力を `InputItem` に変換します。サーバー管理の会話では、サーバーリクエストが受け入れるまで入力は保留状態のままです。シリアライズ、再開、再実行に対して安全なリトライを通じて、SDK は永続的な `InputItem` の出現を 1 つ保持します。この SDK による出現回数の保証は、プロバイダーへの配信を保証するものではありません。リクエストがプロバイダーに到達した可能性がある状態でリトライポリシーが `RetryDecision(approve_unsafe_replay=True)` を返した場合、ランナーはステージングされた入力を再送信する可能性があり、プロバイダー側の処理が繰り返されることがあります。正常に受け入れられた入力は、`new_items` に `InputItem` として表示されます。切り離されたコピーを取得するには [`RunState.pending_input`][agents.run_state.RunState.pending_input] を読み取り、再開前にステージングされた入力をすべて破棄するには [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input] を呼び出します。 -`RunState.add_input()` は、終端状態、モデルの残りターンがない状態、受け入れられたモデルレスポンスがローカル処理を待っている状態、および保留中のツール実行結果によって次のモデル呼び出し前に実行が終了する可能性がある中断状態を拒否します。このような場合は、現在の実行を完了してから、新しいユーザーターンを開始してください。 +`RunState.add_input()` は、終端状態、モデルの残りターンがない状態、受け入れ済みのモデルレスポンスがローカル処理を待っている状態、または保留中のツールの実行結果によって次のモデル呼び出し前に実行が終了する可能性がある中断状態を拒否します。このような場合は、現在の実行を完了してから、新しいユーザーターンを開始してください。 -ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了し、その後 `result.interruptions` を確認して `result.to_state()` から再開します。承認フロー全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 +ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了してから、`result.interruptions` を確認し、`result.to_state()` から再開します。承認フロー全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 -### サーバー管理の継続 +### サーバー管理による続行 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API のチェーンを継続する場合は、次のターンで `previous_response_id` として再度渡します。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API のチェーンを続行する場合は、次のターンでこれを `previous_response_id` として渡します。 -すでに `to_input_list()`、`session`、または `conversation_id` を使用して会話を継続している場合、通常は `last_response_id` は必要ありません。複数ステップの実行からすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 +すでに `to_input_list()`、`session`、`conversation_id` を使用して会話を続行している場合、通常は `last_response_id` は必要ありません。複数ステップの実行からすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 -## ツールとしてのエージェントのメタデータ +## エージェントをツールとして使用する場合のメタデータ -ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から結果が返された場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は、それを囲む `Agent.as_tool()` 呼び出しに関する変更不可能なメタデータを公開します。 +実行結果がネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から得られた場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は、それを包含する `Agent.as_tool()` 呼び出しに関するイミュータブルなメタデータを公開します。 - `tool_name` - `tool_call_id` - `tool_arguments` -通常のトップレベル実行では、`agent_tool_invocation` は `None` です。 +通常のトップレベルの実行では、`agent_tool_invocation` は `None` です。 -これは特に `custom_output_extractor` 内で、ネストされた実行結果を後処理する際に、それを囲む `Agent.as_tool()` 呼び出しのツール名、呼び出し ID、または raw 引数が必要な場合に役立ちます。関連する `Agent.as_tool()` のパターンについては、[ツール](tools.md)を参照してください。 +これは特に `custom_output_extractor` 内で役立ちます。ネストされた実行を後処理する際に、それを包含する `Agent.as_tool()` 呼び出しのツール名、呼び出し ID、raw 引数が必要になる場合があるためです。関連する `Agent.as_tool()` のパターンについては、[ツール](tools.md)を参照してください。 -そのネストされた実行に対するパース済みの構造化入力も必要な場合は、`context_wrapper.tool_input` を読み取ります。これは、[`RunState`][agents.run_state.RunState] がネストされたツール入力として汎用的にシリアライズするフィールドです。一方、`agent_tool_invocation` は、現在のネストされた呼び出しのメタデータを結果上で直接公開します。 +そのネストされた実行について、パース済みの構造化入力も必要な場合は、`context_wrapper.tool_input` を読み取ります。これは、[`RunState`][agents.run_state.RunState] がネストされたツール入力用に汎用的にシリアライズするフィールドです。一方、`agent_tool_invocation` は現在のネストされた呼び出しのメタデータを実行結果上で直接公開します。 ## ストリーミングのライフサイクルと診断 -[`RunResultStreaming`][agents.result.RunResultStreaming] は前述と同じ結果インターフェースを継承しますが、ストリーミング固有の制御機能も追加されています。 +[`RunResultStreaming`][agents.result.RunResultStreaming] は上記と同じ実行結果サーフェスを継承し、さらにストリーミング固有の制御を追加します。 - セマンティックなストリームイベントを消費するための [`stream_events()`][agents.result.RunResultStreaming.stream_events] -- 実行中のアクティブなエージェントを追跡するための [`current_agent`][agents.result.RunResultStreaming.current_agent] -- ストリーミング実行が完全に終了したかどうかを確認するための [`is_complete`][agents.result.RunResultStreaming.is_complete] -- 実行を即座に、または現在のターンの後で停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] +- 実行中にアクティブなエージェントを追跡するための [`current_agent`][agents.result.RunResultStreaming.current_agent] +- ストリーミングされた実行が完全に終了したかどうかを確認するための [`is_complete`][agents.result.RunResultStreaming.is_complete] +- 実行を即時または現在のターンの後で停止するための [`cancel(...)`][agents.result.RunResultStreaming.cancel] -非同期イテレーターが終了するまで `stream_events()` を消費し続けてください。このイテレーターが終了するまでストリーミング実行は完了していません。また、最後の可視トークンが到着した後も、`final_output`、`interruptions`、`raw_responses` などの概要プロパティや、セッション永続化の副作用が処理中である可能性があります。 +非同期イテレーターが完了するまで、`stream_events()` の消費を続けてください。そのイテレーターが終了するまで、ストリーミング実行は完了していません。また、最後の可視トークンが到着した後も、`final_output`、`interruptions`、`raw_responses` などの概要プロパティや、セッション永続化の副作用に関する処理が続いている場合があります。 -`cancel()` を呼び出した場合は、キャンセルとクリーンアップを正しく完了できるように、`stream_events()` の消費を続けてください。 +`cancel()` を呼び出した場合は、キャンセルとクリーンアップが正しく完了するように、`stream_events()` の消費を続けてください。 -Python には、ストリーミングされた独立の `completed` Promise や `error` プロパティはありません。実行を終了させるストリーミングエラーは `stream_events()` によって送出され、`is_complete` は実行が終端状態に到達したかどうかを示します。 +Python には、ストリーミング用の独立した `completed` Promise または `error` プロパティはありません。実行を終了させるストリーミングエラーは `stream_events()` によって送出され、`is_complete` は実行が終端状態に到達したかどうかを示します。 -### Raw レスポンス +### raw レスポンス -[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが含まれます。複数ステップの実行では、ハンドオフやモデル、ツール、モデルというサイクルの繰り返しなどにより、複数のレスポンスが生成される場合があります。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが含まれます。複数ステップの実行では、ハンドオフや繰り返されるモデル/ツール/モデルのサイクルなどにより、複数のレスポンスが生成される場合があります。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリから取得した ID にすぎません。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリに含まれる ID にすぎません。 -各 [`ModelResponse`][agents.items.ModelResponse] では、個々のモデル呼び出しに適用される次の 2 つの診断情報も公開されます。 +各 [`ModelResponse`][agents.items.ModelResponse] は、個々のモデル呼び出しに適用される次の 2 つの診断情報も公開します。 -- [`request_id`][agents.items.ModelResponse.request_id] は、モデルアダプターとトランスポートが ID を伝播する場合のトランスポートリクエスト ID です。組み込みの `OpenAIResponsesModel` と `OpenAIChatCompletionsModel` は、HTTP および SSE のトランスポート経路で、利用可能なサーバー生成の `x-request-id` を伝播します。構成されたエンドポイントが OpenAI API の場合は、本番環境で `None` ではない値をログに記録すると、障害を OpenAI サポートに問い合わせる際に関連付けられます。OpenAI 互換プロバイダーまたはプロキシの場合は、代わりにそのサービスのサポート窓口を使用してください。現在、`OpenAIResponsesWSModel` では `request_id` は `None` のままです。サードパーティー製アダプターでは、リクエスト ID の伝播は保証されません。AnyLLM Chat Completions アダプターと `LitellmModel` では、現在 `request_id` は `None` のままです。Agents SDK の AnyLLM Responses アダプターでも、トランスポートリクエスト ID を保持せずにプロバイダーレスポンスを正規化した場合、`request_id` が `None` のままになることがあります。 -- [`raw_usage`][agents.items.ModelResponse.raw_usage] は、Agents SDK がペイロードを正規化する前の、プロバイダーの使用量ペイロードに関するオプトインの JSON 互換スナップショットです。`ModelSettings(preserve_raw_usage=True)` を指定して `raw_usage` を有効にします。[プロバイダーの使用量ペイロードの保持](usage.md#preserving-provider-usage-payloads)を参照してください。 +- [`request_id`][agents.items.ModelResponse.request_id] は、モデルアダプターとトランスポートが ID を伝播する場合のトランスポートリクエスト ID です。組み込みの `OpenAIResponsesModel` と `OpenAIChatCompletionsModel` は、HTTP および SSE のトランスポートパスで、利用可能なサーバー生成の `x-request-id` を伝播します。設定されたエンドポイントが OpenAI API の場合は、本番環境で `None` ではない値をログに記録し、障害を OpenAI サポートに関連付けられるようにしてください。OpenAI 互換のプロバイダーまたはプロキシの場合は、代わりにそのサービスのサポート窓口を利用してください。`OpenAIResponsesWSModel` は現在、`request_id` を `None` のままにします。サードパーティー製アダプターでは、リクエスト ID の伝播は保証されません。AnyLLM Chat Completions アダプターと `LitellmModel` は現在、`request_id` を `None` のままにします。Agents SDK の AnyLLM Responses アダプターでも、トランスポートリクエスト ID を保持せずにプロバイダーレスポンスを正規化した場合、`request_id` が `None` のままになることがあります。 +- [`raw_usage`][agents.items.ModelResponse.raw_usage] は、Agents SDK がペイロードを正規化する前の、プロバイダーの使用量ペイロードを JSON 互換形式で保存したオプトインのスナップショットです。`ModelSettings(preserve_raw_usage=True)` を使用して `raw_usage` を有効にしてください。[プロバイダーの使用量ペイロードの保持](usage.md#preserving-provider-usage-payloads)を参照してください。 `ModelResponse.request_id` と `ModelResponse.raw_usage` はそれぞれ `None` になる可能性があるため、これらの値は会話状態ではなく、オプションの診断情報として扱ってください。 -### ガードレール結果 +### ガードレールの実行結果 -エージェントレベルのガードレールは、[`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] および [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] として公開されます。 +エージェントレベルのガードレールは、[`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] と [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] として公開されます。 -ツールのガードレールは、[`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] および [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] として個別に公開されます。 +ツールのガードレールは、[`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] と [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] として個別に公開されます。 -これらの配列は実行全体を通じて蓄積されるため、判断のログ記録、追加のガードレールメタデータの保存、または実行がブロックされた理由のデバッグに役立ちます。 +これらの配列には実行全体の実行結果が蓄積されるため、判断内容のログ記録、追加のガードレールメタデータの保存、実行がブロックされた理由のデバッグに役立ちます。 + +エージェントレベルの出力ガードレールが、終端となる関数ツールによって直接生成された最終出力をブロックした場合、1 つの秘匿化ルールが適用されます。ブロックされた現在のレスポンスでは、`output_guardrail_results` が拒否されたエージェント出力を置き換え、ペイロードを含む出力メタデータをクリアします。また、`tool_output_guardrail_results` がペイロードを含むツールメタデータを置き換えます。それ以前に受け入れられた実行結果は変更されません。サニタイズされた出力ガードレールの実行結果は、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] の `guardrail_result` として公開されます。サニタイズされた出力ガードレールとツール出力ガードレールの実行結果は、ストリーミングされた実行結果の状態と `RunState` からも公開されます。[出力ガードレール](guardrails.md#output-guardrails)を参照してください。 ### コンテキストと使用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータとともにアプリケーションコンテキストを公開します。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、アプリのコンテキストに加えて、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータを公開します。 使用量は `context_wrapper.usage` で追跡されます。ストリーミング実行では、ストリームの最後のチャンクが処理されるまで、使用量の合計への反映が遅れる場合があります。ラッパーの完全な形式と永続化に関する注意事項については、[コンテキスト管理](context.md)を参照してください。 \ No newline at end of file diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index 9073927355..dc70b0d1b6 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -4,11 +4,11 @@ search: --- # エージェントの実行 -[`Runner`][agents.run.Runner] クラスを使用してエージェントを実行できます。次の 3 つの方法があります。 +[`Runner`][agents.run.Runner] クラスを使用して、エージェントを実行できます。次の 3 つの方法があります。 -1. [`Runner.run()`][agents.run.Runner.run]:非同期で実行し、[`RunResult`][agents.result.RunResult] を返します。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同期メソッドであり、内部では単に `.run()` を実行します。 -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:非同期で実行し、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、受信したイベントを順次ストリーミングします。 +1. [`Runner.run()`][agents.run.Runner.run] は、非同期で実行され、[`RunResult`][agents.result.RunResult] を返します。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync] は同期メソッドで、内部では単に `.run()` を実行します。 +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed] は、非同期で実行され、[`RunResultStreaming`][agents.result.RunResultStreaming] を返します。LLM をストリーミングモードで呼び出し、イベントを受信すると同時にストリーミングします。 ```python from agents import Agent, Runner @@ -29,40 +29,40 @@ async def main(): ### エージェントループ -上記 3 つの `Runner` メソッドのいずれかを呼び出すときは、開始エージェントと入力を渡します。入力には次のものを使用できます。 +上記 3 つの `Runner` メソッドのいずれかを呼び出すときは、開始エージェントと入力を渡します。入力には次のものを指定できます。 - 文字列(ユーザーメッセージとして扱われます) - OpenAI Responses API 形式の入力項目のリスト -- 一時停止した実行、または `cancel(mode="after_turn")` により停止した実行を再開する場合の [`RunState`][agents.run_state.RunState]。状態には、[次回の再開後のモデル呼び出し用に準備された入力](results.md#add-input-before-resuming)も保持できます。 +- 一時停止された実行、または `cancel(mode="after_turn")` で停止された実行を再開する場合の [`RunState`][agents.run_state.RunState]。状態には、[次回の再開後のモデル呼び出し用に準備された入力](results.md#add-input-before-resuming)も含められます。 -Runner は次のループを実行します。 +その後、Runner は次のループを実行します。 -1. 現在のエージェントと現在の入力を使用して LLM を呼び出します。 +1. 現在のエージェントについて、現在の入力を使用して LLM を呼び出します。 2. LLM が出力を生成します。 1. Runner が LLM の出力を最終出力と判定した場合、ループを終了して実行結果を返します。 2. LLM がハンドオフを要求した場合、現在のエージェントと入力を更新し、ループを再実行します。 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行して実行結果を追加し、ループを再実行します。 -3. 渡された `max_turns` を超えると、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 +3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note - LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが含まれていないことです。 + LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しがないことです。 ### ストリーミング -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] に、新たに生成されたすべての出力を含む実行の完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳しくは、[ストリーミングガイド](streaming.md)をご覧ください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む実行の完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳しくは、[ストリーミングガイド](streaming.md)をご覧ください。 #### Responses WebSocket トランスポート(オプションのヘルパー) -OpenAI Responses の WebSocket トランスポートを有効にした場合でも、通常の `Runner` API を引き続き使用できます。接続を再利用する場合は WebSocket セッションヘルパーを推奨しますが、必須ではありません。 +OpenAI Responses の WebSocket トランスポートを有効にしても、通常の `Runner` API を引き続き使用できます。接続を再利用する場合は WebSocket セッションヘルパーを推奨しますが、必須ではありません。 これは WebSocket トランスポート経由の Responses API であり、[Realtime API](realtime/guide.md)ではありません。 -トランスポートの選択規則、および具体的なモデルオブジェクトやカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 +トランスポートの選択規則や、具象モデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 -##### パターン 1:セッションヘルパーなし(利用可能) +##### パターン 1:セッションヘルパーなし(動作可能) -WebSocket トランスポートのみが必要で、SDK に共有プロバイダーやセッションを管理させる必要がない場合に使用します。 +WebSocket トランスポートだけが必要で、共有プロバイダーやセッションを SDK で管理する必要がない場合に使用します。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -このパターンは単発の実行に適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出す場合、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 +このパターンは、単一の実行には適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出すと、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 -##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用を推奨) +##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) -複数の実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 +複数の実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 ```python import asyncio @@ -119,53 +119,53 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果の消費を完了してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 +コンテキストを終了する前に、ストリーミングされた実行結果を最後まで消費してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる場合があります。 -サービスは各 WebSocket 接続で一度に 1 つのレスポンスを処理し、接続時間を 60 分に制限します。ヘルパーは接続を再利用しますが、これらの制約は解消されません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストを使用して新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。完全な復元動作については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)をご覧ください。 +各 WebSocket 接続では、一度に 1 つのレスポンスが処理され、接続時間は 60 分に制限されます。ヘルパーは接続を再利用しますが、これらの制約を取り除くものではありません。再接続後、`store=False` および ZDR フローでは、キャッシュされていない `previous_response_id` を復元できません。完全な入力コンテキストで新しいチェーンを開始するか、ローカルで管理しているセッション状態から再構築してください。復元動作の詳細については、[Responses WebSocket トランスポートに関する注意事項](models/index.md#responses-websocket-transport)をご覧ください。 -長時間の推論ターンで WebSocket のキープアライブタイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。WebSocket のレイテンシーよりも信頼性が重要な実行には、HTTP/SSE トランスポートを使用してください。 +長時間の推論ターンで WebSocket のキープアライブタイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。WebSocket のレイテンシより信頼性が重要な実行では、HTTP/SSE トランスポートを使用してください。 ### 実行設定 `run_config` パラメーターを使用すると、エージェントの実行に関する一部のグローバル設定を構成できます。 -#### 一般的な実行設定カテゴリー +#### 一般的な実行設定のカテゴリー -各エージェントの定義を変更せずに、単一の実行について動作を上書きするには、`RunConfig` を使用します。 +各エージェントの定義を変更せず、単一の実行だけ動作を上書きするには、`RunConfig` を使用します。 -##### モデル、プロバイダー、セッションのデフォルト設定 +##### モデル、プロバイダー、セッションのデフォルト -- [`model`][agents.run.RunConfig.model]:各 Agent が持つ `model` に関係なく、使用するグローバル LLM モデルを設定できます。 -- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAIです。 +- [`model`][agents.run.RunConfig.model]:各 Agent が持つ `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 +- [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 - [`model_settings`][agents.run.RunConfig.model_settings]:エージェント固有の設定を上書きします。たとえば、グローバルな `temperature` または `top_p` を設定できます。 -- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得するとき、セッションレベルのデフォルト設定(たとえば `SessionSettings(limit=...)`)を上書きします。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各 `Runner` 実行の前に新しいユーザー入力をセッション履歴と統合する方法をカスタマイズします。コールバックは同期または非同期にできます。 +- [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(たとえば `SessionSettings(limit=...)`)を上書きします。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各 `Runner` の実行前に新しいユーザー入力をセッション履歴と結合する方法をカスタマイズします。コールバックは同期または非同期にできます。 ##### ガードレール、ハンドオフ、モデル入力の整形 - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだ設定されていない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信される入力を編集できます。詳しくは、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、要約可能な履歴を順序付きのアシスタント要約セグメントへ圧縮しながら、情報を失わないメッセージ項目を元の位置に保持する、オプトインのベータ機能です。ネストされたハンドオフを安定化している間はデフォルトで無効です。有効にするには `True` を設定し、raw のトランスクリプトをそのまま渡すには `False` のままにします。Sessions、`RunState`、`RunResult.to_input_list()` は、SDK のデフォルトのネスト履歴がすでに所有しているメッセージとまったく同じ出現箇所を二重に追加しない一方で、別々の同一メッセージは保持します。すべての [Runner メソッド][agents.run.Runner]は、指定されていない場合に `RunConfig` を自動作成するため、クイックスタートとコード例ではデフォルトが無効のままになり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を通じてこの設定を上書きできます。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインした場合に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを作成することなく、組み込みの順序付き要約セグメントを置き換えるため、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴を切り詰めたり、システムプロンプトを挿入したりできます。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信される入力を編集できます。詳しくは、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:次のエージェントを呼び出す前に、ロスレスなメッセージ項目を元の位置に保持しながら、要約可能な履歴を順序付きのアシスタント要約セグメントへ圧縮する、オプトインのベータ機能です。ネストされたハンドオフの安定化を進めているため、デフォルトでは無効です。有効にするには `True` を設定し、未加工のトランスクリプトをそのまま渡すには `False` のままにします。SDK のデフォルトのネスト履歴がメッセージをすでに保持している場合、Sessions、`RunState`、`RunResult.to_input_list()` は同一のメッセージ出現を二重に追加しない一方、別々に存在する同一メッセージは保持します。すべての [Runner メソッド][agents.run.Runner]は、渡されなかった場合に `RunConfig` を自動的に作成するため、クイックスタートとコード例ではデフォルトが無効のままになり、明示的な [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] コールバックは引き続きこの設定を上書きします。個々のハンドオフでは、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] を使用してこの設定を上書きできます。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:`nest_handoff_history` をオプトインした場合に、正規化されたトランスクリプト(履歴とハンドオフ項目)を受け取るオプションの callable です。完全なハンドオフフィルターを記述せずに、組み込みの順序付き要約セグメントを置き換え、次のエージェントへ転送する入力項目の正確なリストを返す必要があります。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴のトリミングやシステムプロンプトの挿入に使用できます。 - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力に変換するとき、推論項目 ID を保持するか省略するかを制御します。 ##### トレーシングと可観測性 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 - [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM やツール呼び出しの入出力など、機密情報である可能性のあるデータをトレースに含めるかどうかを設定します。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にわたってトレースを関連付けるためのオプションフィールドです。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:LLM およびツール呼び出しの入出力など、機密である可能性のあるデータをトレースに含めるかどうかを設定します。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にまたがるトレースを関連付けるためのオプションフィールドです。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 ##### ツールの実行、承認、エラー動作 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出しの数を制限するなど、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合に、Runner がどう処理するかを設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりにモデルから確認可能なエラー出力を返すようオプトインできます。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が衝突した場合に、Runner がどう処理するかを設定します。デフォルトの `"warn"` では、対処方法を示す警告をログに記録し、現在のディスパッチ先として選ばれたものだけを公開します。`"error"` では、モデルが呼び出される前に `UserError` が発生します。名前空間付きツールと遅延読み込みツールに対する厳格な検証は変更されません。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから確認可能なツールエラーメッセージをカスタマイズします。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出し数の制限など、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合の Runner の処理方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから認識できるエラー出力を返すようオプトインできます。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:名前空間のない関数ツール名とハンドオフ名が競合した場合の Runner の処理方法を設定します。デフォルトの `"warn"` では、対処可能な警告をログに記録し、現在のディスパッチ先として選ばれたものだけを公開します。`"error"` では、モデルが呼び出される前に `UserError` が発生します。名前空間付きツールと遅延読み込みツールに対する厳密な検証は変更されません。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:承認の拒否や、オプトインしたツール未検出時の出力など、モデルから認識できるツールエラーメッセージをカスタマイズします。 -ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付きトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡すか、特定のハンドオフで有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントを、情報を失わないメッセージ項目の前後に配置します。デフォルトである raw のトランスクリプトを保持する場合は、フラグを未設定のままにするか、必要に応じて会話をそのまま転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを作成せずに、生成された要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 +ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付きトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡し、特定のハンドオフで有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントをロスレスなメッセージ項目の前後に配置します。未加工のトランスクリプトを保持する場合(デフォルト)は、フラグを設定しないか、必要な形式で会話を転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成される要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 #### 実行設定の詳細 @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成した場合、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツール呼び出しの数を制限するには、整数値を設定します。 +`max_function_tool_concurrency=None` はデフォルトの動作を維持します。モデルが 1 ターンで複数の関数ツール呼び出しを生成すると、SDK は生成されたすべてのローカル関数ツール呼び出しを開始します。同時に実行するローカル関数ツール呼び出し数を制限するには、整数値を設定します。 -これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別です。`parallel_tool_calls` は、モデルが単一のレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルによる生成後に SDK がローカル関数ツール呼び出しを実行する方法を制御します。 +これは、プロバイダー側の [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] とは別のものです。`parallel_tool_calls` は、モデルが 1 つのレスポンスで複数のツール呼び出しを生成できるかどうかを制御します。`tool_execution.max_function_tool_concurrency` は、モデルがツール呼び出しを生成した後に、SDK がローカル関数ツール呼び出しをどのように実行するかを制御します。 -`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後の実行直前にのみ動作します。保留中の承認による中断が通知される前に、関数ツールの入力ガードレールを実行する場合は、`True` を設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間依存のチェックは実行前に再検証されます。 +`pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後、実行直前にのみ実行されます。保留中の承認による中断が生成される前に関数ツールの入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間に依存するチェックは実行前に再検証されます。 ##### `tool_not_found_behavior` -デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能な関数ツールのいずれとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 +デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 -実行を復旧可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は解決できなかったツール呼び出しに `function_call_output` を追加してモデルを再実行するため、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 +実行を復元可能な状態に保つ場合は、`tool_not_found_behavior="return_error_to_model"` を設定します。このモードでは、SDK は解決できなかったツール呼び出しに `function_call_output` を追加してモデルを再実行するため、モデルは利用可能なツールを選択するか、そのツールを使用せずに回答できます。 ```python from agents import Agent, RunConfig, Runner @@ -218,15 +218,15 @@ result = await Runner.run( ##### `tool_error_formatter` -SDK がモデルから確認可能なツールエラー出力を作成するとき、モデルに返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 +SDK がモデルから認識できるツールエラー出力を作成する際、モデルに返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 -フォーマッターは、次の情報を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 +フォーマッターは、次の内容を持つ [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs] を受け取ります。 - `kind`:`"approval_rejected"` や `"tool_not_found"` などのエラーカテゴリー。 - `tool_type`:ツールランタイム(`"function"`、`"computer"`、`"shell"`、`"apply_patch"`、または `"custom"`)。 - `tool_name`:ツール名。 - `call_id`:ツール呼び出し ID。 -- `default_message`:SDK のデフォルトの、モデルから確認可能なメッセージ。 +- `default_message`:SDK のデフォルトの、モデルから認識できるメッセージ。 - `run_context`:アクティブな実行コンテキストラッパー。 メッセージを置き換える文字列を返すか、SDK のデフォルトを使用する場合は `None` を返します。 @@ -256,52 +256,52 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy` は、Runner が履歴を引き継ぐとき(たとえば、`RunResult.to_input_list()` またはセッションを利用する実行を使用するとき)に、推論項目を次のターンのモデル入力へ変換する方法を制御します。 +`reasoning_item_id_policy` は、Runner が履歴を引き継ぐとき(たとえば、`RunResult.to_input_list()` またはセッションに基づく実行を使用するとき)、推論項目を次のターンのモデル入力へ変換する方法を制御します。 - `None` または `"preserve"`(デフォルト):推論項目 ID を保持します。 -- `"omit"`:生成される次のターンの入力から推論項目 ID を削除します。 +- `"omit"`:生成される次のターンの入力から推論項目 ID を取り除きます。 -`"omit"` は主に、推論項目が `id` とともに送信される一方で、必要な後続項目(たとえば `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)がない場合に発生する、一部の Responses API 400 エラーに対するオプトインの緩和策として使用します。 +推論項目が `id` とともに送信される一方で、必要な後続項目(たとえば `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)がないために発生する、一種の Responses API 400 エラーへのオプトインの緩和策として、主に `"omit"` を使用します。 -これは、複数ターンのエージェント実行で SDK が以前の出力から後続入力を構築するときに発生することがあります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングまたは非ストリーミングの後続ターン、再開パスが含まれます。推論項目 ID が保持されている一方で、プロバイダーがその ID と対応する後続項目との組み合わせを維持するよう要求する場合に発生します。 +これは、SDK が以前の出力から後続入力を構築する複数ターンのエージェント実行で発生する可能性があります。これには、セッションの永続化、サーバー管理の会話差分、ストリーミングおよび非ストリーミングの後続ターン、再開パスが含まれます。このとき推論項目 ID が保持されていても、プロバイダーがその ID と対応する後続項目のペアを維持するよう要求する場合があります。 -`reasoning_item_id_policy="omit"` を設定すると、推論内容は保持されますが、推論項目の `id` は削除されます。これにより、SDK が生成した後続入力でその API 不変条件に違反することを回避できます。 +`reasoning_item_id_policy="omit"` を設定すると、推論内容は維持されますが、推論項目の `id` は取り除かれます。これにより、SDK が生成する後続入力でこの API の不変条件に違反することを回避できます。 適用範囲に関する注意事項: - これは、SDK が後続入力を構築するときに生成または転送する推論項目のみを変更します。 - ユーザーが指定した初期入力項目は書き換えません。 -- このポリシーの適用後でも、`call_model_input_filter` によって推論 ID を意図的に再導入できます。 +- `call_model_input_filter` は、このポリシーが適用された後でも意図的に推論 ID を再導入できます。 ## 状態と会話の管理 ### メモリ戦略の選択 -次のターンに状態を引き継ぐ一般的な方法は 4 つあります。 +次のターンへ状態を引き継ぐ一般的な方法は 4 つあります。 | 戦略 | 状態の保存場所 | 最適な用途 | 次のターンで渡すもの | | --- | --- | --- | --- | -| `result.to_input_list()` | アプリケーションのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | +| `result.to_input_list()` | アプリのメモリ | 小規模なチャットループ、完全な手動制御、任意のプロバイダー | `result.to_input_list()` のリストと次のユーザーメッセージ | | `session` | ストレージと SDK | 永続的なチャット状態、再開可能な実行、カスタムストア | 同じ `session` インスタンス、または同じストアを参照する別のインスタンス | | `conversation_id` | OpenAI Conversations API | ワーカーやサービス間で共有する、名前付きのサーバー側会話 | 同じ `conversation_id` と新しいユーザーターンのみ | | `previous_response_id` | OpenAI Responses API | 会話リソースを作成しない、軽量なサーバー管理の継続 | `result.last_response_id` と新しいユーザーターンのみ | -`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。両方のレイヤーを意図的に調整しない限り、クライアント管理の履歴と OpenAI管理の状態を組み合わせると、コンテキストが重複する可能性があります。 +`result.to_input_list()` と `session` はクライアント管理です。`conversation_id` と `previous_response_id` は OpenAI 管理であり、OpenAI Responses API を使用している場合にのみ適用されます。ほとんどのアプリケーションでは、会話ごとに 1 つの永続化戦略を選択してください。両方のレイヤーを意図的に調整している場合を除き、クライアント管理の履歴と OpenAI 管理の状態を混在させると、コンテキストが重複する可能性があります。 !!! note - セッションの永続化と、サーバー管理の会話設定 + 同じ実行で、セッションの永続化とサーバー管理の会話設定 (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)を - 同じ実行内で組み合わせることはできません。呼び出しごとにいずれか 1 つの方法を選択してください。 + 組み合わせることはできません。呼び出しごとに 1 つの方法を選択してください。 ### 会話とチャットスレッド -いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される場合があり、その結果、LLM が 1 回以上呼び出されることがあります。ただし、チャット会話における論理的な 1 ターンを表します。例: +いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があり、その結果として 1 回以上の LLM 呼び出しが行われます。ただし、チャット会話上は 1 つの論理ターンを表します。たとえば、次のようになります。 1. ユーザーターン:ユーザーがテキストを入力します -2. Runner の実行:最初のエージェントが LLM を呼び出してツールを実行し、2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 +2. Runner の実行:最初のエージェントが LLM を呼び出してツールを実行し、2 番目のエージェントへハンドオフします。2 番目のエージェントはさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーへ何を表示するかを選択できます。たとえば、エージェントが生成した新しい項目をすべて表示することも、最終出力のみを表示することもできます。いずれの場合も、ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力だけを表示することもできます。いずれの場合も、その後ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 #### 会話の手動管理 @@ -329,7 +329,7 @@ async def main(): #### Sessions による会話の自動管理 -より簡単な方法として、[Sessions](sessions/index.md)を使用すると、`.to_input_list()` を手動で呼び出すことなく、会話履歴を自動的に処理できます。 +より簡単な方法として、[Sessions](sessions/index.md)を使用すると、`.to_input_list()` を手動で呼び出さずに会話履歴を自動的に処理できます。 ```python from agents import Agent, Runner, SQLiteSession, trace @@ -353,7 +353,7 @@ async def main(): # California ``` -Sessions は次の処理を自動的に行います。 +Sessions は、次の処理を自動的に行います。 - 各実行前に会話履歴を取得します - 各実行後に新しいメッセージを保存します @@ -364,13 +364,13 @@ Sessions は次の処理を自動的に行います。 #### サーバー管理の会話 -`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAIの会話状態機能にサーバー側の会話状態を管理させることもできます。これにより、過去のすべてのメッセージを毎回手動で再送信することなく、会話履歴を保持できます。以下のいずれかのサーバー管理方式では、リクエストごとに新しいターンの入力のみを渡し、保存した ID を再利用します。詳しくは、[OpenAIの会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 +`to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能でサーバー側の会話状態を管理することもできます。これにより、過去のすべてのメッセージを手動で再送信せずに会話履歴を保持できます。以下のどちらのサーバー管理方式でも、各リクエストでは新しいターンの入力だけを渡し、保存した ID を再利用します。詳しくは、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 -OpenAIでは、ターンをまたいで状態を追跡する方法を 2 つ提供しています。 +OpenAI では、ターン間の状態を追跡する方法を 2 つ提供しています。 ##### 1. `conversation_id` の使用 -まず OpenAI Conversations API を使用して会話を作成し、以降のすべての呼び出しでその ID を再利用します。 +まず OpenAI Conversations API を使用して会話を作成し、それ以降のすべての呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -393,7 +393,7 @@ async def main(): ##### 2. `previous_response_id` の使用 -もう 1 つの選択肢は **レスポンスの連鎖** です。各ターンを前のターンのレスポンス ID に明示的にリンクします。 +もう 1 つの方法は **レスポンスチェイニング** です。各ターンを、直前のターンのレスポンス ID に明示的に関連付けます。 ```python from agents import Agent, Runner @@ -418,30 +418,31 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -承認のために実行が一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存された `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を保持するため、再開されたターンは同じサーバー管理の会話内で継続されます。 +実行が承認待ちで一時停止し、[`RunState`][agents.run_state.RunState] から再開する場合、SDK は保存されている `conversation_id` / `previous_response_id` / `auto_previous_response_id` の設定を維持するため、再開されたターンは同じサーバー管理の会話で継続されます。 `conversation_id` と `previous_response_id` は同時に使用できません。システム間で共有できる名前付き会話リソースが必要な場合は、`conversation_id` を使用します。ターン間で最も軽量な Responses API の継続用基本コンポーネントが必要な場合は、`previous_response_id` を使用します。 !!! note - SDK は `conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話を使用する実行では、再試行前に内部の会話トラッカー入力を巻き戻し、準備済みの - 同じ項目を問題なく再送信できるようにします。 + SDK は、`conversation_locked` エラーをバックオフ付きで自動的に再試行します。サーバー管理の + 会話を使用する実行では、再試行前に内部の会話トラッカー入力を巻き戻すため、準備済みの + 同じ項目を問題なく再送信できます。 ローカルのセッションベースの実行(`conversation_id`、 - `previous_response_id`、または `auto_previous_response_id` とは組み合わせられません)では、SDK は - 最近永続化された入力項目のベストエフォートなロールバックも行い、再試行後の履歴項目の重複を減らします。 + `previous_response_id`、または `auto_previous_response_id` とは組み合わせられません)では、 + SDK は最近永続化した入力項目のベストエフォートなロールバックも行い、再試行後の履歴項目の + 重複を抑えます。 - この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも行われます。モデルリクエストに対する - より広範なオプトインの再試行動作については、[Runner が管理する再試行](models/index.md#runner-managed-retries)をご覧ください。 + この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも実行されます。 + モデルリクエストに対する、より広範なオプトインの再試行動作については、[Runner 管理の再試行](models/index.md#runner-managed-retries)をご覧ください。 ## フックとカスタマイズ -### モデル呼び出しの入力フィルター +### モデル呼び出し入力フィルター -モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、統合済みの入力項目(存在する場合はセッション履歴を含む)を受け取り、新しい `ModelInputData` を返します。 +モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、結合済みの入力項目(存在する場合はセッション履歴を含みます)を受け取り、新しい `ModelInputData` を返します。 -戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトである必要があります。その `input` フィールドは必須で、入力項目のリストでなければなりません。それ以外の形式を返すと、`UserError` が発生します。 +戻り値は [`ModelInputData`][agents.run.ModelInputData] オブジェクトでなければなりません。その `input` フィールドは必須で、入力項目のリストでなければなりません。それ以外の形式を返すと、`UserError` が発生します。 ```python from agents import Agent, Runner, RunConfig @@ -460,19 +461,19 @@ result = Runner.run_sync( ) ``` -Runner は準備済み入力リストのコピーをフックに渡すため、呼び出し元の元のリストをその場で変更することなく、切り詰め、置換、並べ替えができます。 +Runner は準備済み入力リストのコピーをフックへ渡すため、呼び出し元の元のリストをその場で変更することなく、トリミング、置換、並べ替えができます。 -セッションを使用している場合、`call_model_input_filter` は、セッション履歴がすでに読み込まれ、現在のターンと統合された後に実行されます。この前段階の統合処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 +セッションを使用している場合、`call_model_input_filter` はセッション履歴が読み込まれ、現在のターンと結合された後に実行されます。それより前の結合処理自体をカスタマイズする場合は、[`session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。 -`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAIのサーバー管理の会話状態を利用している場合、フックは次の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴の完全な再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目のみが、そのサーバー管理の継続で送信済みとしてマークされます。 +`conversation_id`、`previous_response_id`、または `auto_previous_response_id` を使用して OpenAI のサーバー管理の会話状態を利用している場合、フックは次回の Responses API 呼び出し用に準備されたペイロードに対して実行されます。そのペイロードは、以前の履歴全体の再送ではなく、新しいターンの差分のみをすでに表している場合があります。返した項目だけが、そのサーバー管理の継続処理で送信済みとして記録されます。 -機密データの編集、長い履歴の切り詰め、追加のシステムガイダンスの挿入を行うには、`run_config` を通じて実行ごとにフックを設定します。 +機密データの編集、長い履歴のトリミング、追加のシステムガイダンスの挿入を行うには、`run_config` を使用して実行ごとにフックを設定します。 ## エラーと復旧 ### エラーハンドラー -すべての `Runner` エントリーポイントは、エラー種別をキーとする dict の `error_handlers` を受け取ります。サポートされるキーは `"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 +すべての `Runner` エントリーポイントは、エラー種別をキーとする辞書 `error_handlers` を受け取ります。サポートされるキーは、`"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 ```python from agents import ( @@ -501,7 +502,7 @@ result = Runner.run_sync( print(result.final_output) ``` -モデルメッセージがエージェントの structured `output_type` に対する検証に失敗した場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対してその値を検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行われません。`None` を返すと復旧を辞退します。フォールバックがない場合、空でない検証エラーでは引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターン動作が維持されます。 +モデルメッセージがエージェントの structured `output_type` に対して検証を通過しない場合、またはモデルが structured な最終メッセージを返さない場合は、`"invalid_final_output"` を使用します。ハンドラーはアプリケーション固有のフォールバックを返すことができ、SDK は同じ `output_type` に対して検証します。モデル呼び出しの再試行や、ツールの副作用の再実行は行いません。`None` を返すと、復旧を行いません。フォールバックがない場合、空でない値の検証失敗では引き続き `ModelBehaviorError` が発生し、空の structured レスポンスでは既存の次ターン動作が維持されます。 ```python from pydantic import BaseModel @@ -533,9 +534,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数ハンドラーの場合、合成されたフォールバック出力が会話履歴に追加され、設定済みのセッションに永続化されます。実行結果の履歴やセッションストレージに追加せず、フォールバックを呼び出し元へ返す場合は、`include_in_history=False` を設定します。 +`RunErrorHandlerResult.include_in_history` のデフォルトは `True` です。最大ターン数ハンドラーでは、合成されたフォールバック出力が会話履歴に追加され、設定済みのセッションに永続化されます。実行結果の履歴やセッションストレージに追加せず、フォールバックを呼び出し元へ返す場合は、`include_in_history=False` を設定します。 -モデルによる拒否に対して、`ModelRefusalError` で実行を終了する代わりにアプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 +モデルによる拒否で `ModelRefusalError` により実行を終了する代わりに、アプリケーション固有のフォールバックを生成する場合は、`"model_refusal"` を使用します。 ```python from pydantic import BaseModel @@ -567,36 +568,37 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続的な実行の統合と human-in-the-loop +## 永続実行との統合とヒューマンインザループ -ツール承認の一時停止と再開のパターンについては、専用の [Human-in-the-loop ガイド](human_in_the_loop.md)をご覧ください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーション向けです。 +ツール承認の一時停止と再開のパターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 ### Dapr -Agents SDKの [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、human-in-the-loop ワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAIエージェントの使用を開始するには、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)をご覧ください。 +Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、ヒューマンインザループのワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの使用を開始するには、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)をご覧ください。 ### Temporal -Agents SDKの [Temporal](https://temporal.io/) 統合を使用すると、human-in-the-loop タスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDKが連携して長時間実行タスクを完了するデモは[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認でき、[ドキュメントはこちら](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)で参照できます。 +Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[こちらのドキュメント](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)もご覧ください。 ### Restate -Agents SDKの [Restate](https://restate.dev/) 統合は、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントに使用できます。この統合には、依存関係として Restate の単一バイナリランタイムが必要であり、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳しくは、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 +Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実現できます。この統合では Restate の単一バイナリランタイムが依存関係として必要であり、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳しくは、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 ### DBOS -Agents SDKの [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、human-in-the-loop ワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方をサポートします。この統合に必要なのは、SQLite または Postgres データベースのみです。詳しくは、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)と[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 +Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方に対応しています。この統合に必要なのは SQLite または Postgres データベースだけです。詳しくは、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)および[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 ## 例外 -SDK は特定の場合に例外を発生させます。完全な一覧は [`agents.exceptions`][] にあります。概要は次のとおりです。 +SDK は特定の場合に例外を発生させます。完全なリストは [`agents.exceptions`][] にあります。概要は次のとおりです。 -- [`AgentsException`][agents.exceptions.AgentsException]:SDK が発生させるすべての例外の基底クラスです。他のすべての具体的な例外の派生元となる汎用型です。 +- [`AgentsException`][agents.exceptions.AgentsException]:SDK が発生させるすべての例外の基底クラスです。その他すべての固有の例外は、この汎用型から派生します。 - [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:エージェントの実行が、`Runner.run`、`Runner.run_sync`、または `Runner.run_streamed` メソッドに渡された `max_turns` の制限を超えた場合に発生します。これは、指定されたエージェントループのターン数(LLM 呼び出し回数)内にエージェントがタスクを完了できなかったことを示します。制限を無効にするには、`max_turns=None` を設定します。 - [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]:モデル呼び出しの試行が [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] を超えた場合に発生します。適用範囲と再試行動作については、[モデル呼び出しのタイムアウト](models/index.md#model-call-timeouts)をご覧ください。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない、または無効な出力を生成した場合に発生します。これには次のものが含まれます。 - - 不正な JSON:特に特定の `output_type` が定義されている場合に、モデルがツール呼び出しまたは直接出力で不正な JSON 構造を生成すること。 - - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できないこと +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:基盤となるモデル(LLM)が予期しない出力または無効な出力を生成した場合に発生します。これには次のものが含まれます。 + - 不正な JSON:モデルがツール呼び出しまたは直接出力で不正な JSON 構造を返した場合。特に、特定の `output_type` が定義されている場合が該当します。 + - 予期しないツール関連の失敗:モデルが想定された方法でツールを使用できなかった場合 + - 失敗または未完了の非ストリーミング Responses 呼び出し:返されたレスポンスの最終ステータスが `failed` または `incomplete` の場合、`OpenAIResponsesModel` および `AnyLLMModel` の Responses パスはこの例外を発生させます。例外には最終ステータスが示され、レスポンスから取得可能なエラーまたは未完了の詳細が含まれます。 - [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:関数ツール呼び出しが設定済みのタイムアウトを超え、ツールが `timeout_behavior="raise_exception"` を使用している場合に発生します。 -- [`UserError`][agents.exceptions.UserError]:SDK を使用するコードの作成者が、SDK の使用中に誤りを犯した場合に発生します。通常は、コードの実装ミス、無効な設定、SDK API の誤用によって発生します。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:入力ガードレールの条件が満たされると `InputGuardrailTripwireTriggered` が発生し、出力ガードレールの条件が満たされると `OutputGuardrailTripwireTriggered` が発生します。入力ガードレールは処理前に受信メッセージをチェックし、出力ガードレールは配信前にエージェントの最終レスポンスをチェックします。 \ No newline at end of file +- [`UserError`][agents.exceptions.UserError]:SDK を使用してコードを記述する人が、SDK の使用時に誤りを犯した場合に発生します。通常、コードの実装ミス、無効な設定、SDK の API の誤用が原因です。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:入力ガードレールの条件が満たされると `InputGuardrailTripwireTriggered` が発生し、出力ガードレールの条件が満たされると `OutputGuardrailTripwireTriggered` が発生します。入力ガードレールは処理前に受信メッセージを確認し、出力ガードレールは配信前にエージェントの最終レスポンスを確認します。 \ No newline at end of file diff --git a/docs/ja/usage.md b/docs/ja/usage.md index 047ee1b209..27bcfcdce7 100644 --- a/docs/ja/usage.md +++ b/docs/ja/usage.md @@ -2,25 +2,25 @@ search: exclude: true --- -# 使用量 +# 使用状況 -Agents SDK は、実行ごとのトークン使用量を自動的に追跡します。実行コンテキストから使用量にアクセスし、コストの監視、制限の適用、分析データの記録に利用できます。 +Agents SDK は、実行ごとのトークン使用状況を自動的に追跡します。実行コンテキストからアクセスし、コストの監視、制限の適用、分析データの記録に使用できます。 ## 追跡対象 - **requests**: 実行された LLM API 呼び出しの数 - **input_tokens**: 送信された入力トークンの合計 - **output_tokens**: 受信した出力トークンの合計 -- **total_tokens**: 入力と出力の合計 -- **request_usage_entries**: リクエストごとの使用量内訳のリスト +- **total_tokens**: 入力 + 出力 +- **request_usage_entries**: リクエストごとの使用状況の内訳のリスト - **details**: - `input_tokens_details.cached_tokens` - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 実行からの使用量へのアクセス +## 実行からの使用状況へのアクセス -`Runner.run(...)` の実行後、`result.context_wrapper.usage` を介して使用量にアクセスします。 +`Runner.run(...)` の実行後、`result.context_wrapper.usage` から使用状況にアクセスします。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -32,22 +32,22 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -使用量は、ツール呼び出しやハンドオフを生成するモデル呼び出しを含め、実行中のすべてのモデル呼び出しにわたって集計されます。 +使用状況は、ツール呼び出しやハンドオフを生成するモデル呼び出しを含め、実行中のすべてのモデル呼び出しについて集計されます。 -[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] が実行の完了前に履歴を自動的に圧縮した場合、その `responses.compact` リクエストによって報告された使用量も、同じ実行の合計に加算されます。実行外で手動による `run_compaction()` 呼び出しを行った場合、それを包含する実行コンテキストがないため、以前の実行によって返された使用量オブジェクトは更新されません。[OpenAI Responses 圧縮セッション](sessions/index.md#openai-responses-compaction-sessions)を参照してください。 +[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] が実行の終了前に履歴を自動的にコンパクト化した場合、その `responses.compact` リクエストによって報告された使用状況も、同じ実行の合計に加算されます。実行の外部で手動による `run_compaction()` 呼び出しを行った場合、包含する実行コンテキストがないため、以前の実行から返された使用状況オブジェクトは更新されません。[OpenAI Responses のコンパクションセッション](sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -### サードパーティーアダプターでの使用量の有効化 +### サードパーティーアダプターでの使用状況の有効化 -使用量の報告方法は、サードパーティーアダプターやプロバイダーのバックエンドによって異なります。サードパーティーアダプターを介してモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、次の点に注意してください。 +使用状況の報告は、サードパーティーアダプターやプロバイダーバックエンドによって異なります。サードパーティーアダプター経由でモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、以下を確認してください。 -- `AnyLLMModel` では、アップストリームプロバイダーが使用量を返すと、自動的に伝播されます。Chat Completions バックエンドからレスポンスをストリーミングする場合、使用量チャンクを送出するには `ModelSettings(include_usage=True)` が必要になることがあります。 -- `LitellmModel` では、一部のプロバイダーバックエンドはデフォルトで使用量を報告しないため、多くの場合 `ModelSettings(include_usage=True)` が必要です。 +- `AnyLLMModel` では、上流プロバイダーが使用状況を返すと、自動的に伝播されます。Chat Completions バックエンドからレスポンスをストリーミングする場合、使用状況チャンクを出力するために `ModelSettings(include_usage=True)` が必要になることがあります。 +- `LitellmModel` では、一部のプロバイダーバックエンドがデフォルトで使用状況を報告しないため、多くの場合 `ModelSettings(include_usage=True)` が必要です。 -Models ガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注意事項を確認し、デプロイ予定のプロバイダーバックエンドで使用量が正しく報告されることを検証してください。 +モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注意事項を確認し、デプロイ予定のプロバイダーバックエンドで使用状況が正しく報告されることを検証してください。 -## リクエスト単位の使用量追跡 +## リクエストごとの使用状況の追跡 -SDK は、`request_usage_entries` 内の各 API リクエストの使用量を自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウの消費量の監視に役立ちます。 +SDK は、`request_usage_entries` 内の各 API リクエストの使用状況を自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウの消費量の監視に役立ちます。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -56,9 +56,9 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## プロバイダーの使用量ペイロードの保持 +## プロバイダーの使用状況ペイロードの保持 -Agents SDK は、プロバイダーの使用量を、モデルプロバイダー間で一貫した合計を提供する [`Usage`][agents.usage.Usage] フィールドに正規化します。アプリケーションでプロバイダー固有の使用量フィールドを保持する必要がある場合や、省略されたフィールドとプロバイダーが報告したゼロを区別する必要がある場合は、[`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] を `True` に設定します。 +Agents SDK は、プロバイダーの使用状況を、モデルプロバイダー間で一貫した合計値を提供する [`Usage`][agents.usage.Usage] フィールドに正規化します。アプリケーションでプロバイダー固有の使用状況フィールドを保持する必要がある場合、または省略されたフィールドとプロバイダーが報告したゼロを区別する必要がある場合は、[`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] を `True` に設定します。 ```python from agents import Agent, ModelSettings, Runner @@ -73,15 +73,15 @@ for response in result.raw_responses: print(response.raw_usage) ``` -Agents SDK は、各 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 値を、そのモデル呼び出しに対するプロバイダーのペイロードから切り離された、JSON 互換のスナップショットとして保存します。Agents SDK は、実行全体で `raw_usage` を集計しません。保持が無効になっている場合、プロバイダーが使用量ペイロードを返さない場合、またはアップストリームアダプターが元のフィールド有無の情報をすでに破棄している場合、この値は `None` のままです。 +Agents SDK は、各モデル呼び出しのプロバイダーペイロードについて、各 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 値を分離された JSON 互換スナップショットとして保存します。Agents SDK は、実行全体で `raw_usage` を集計しません。保持が無効になっている場合、プロバイダーが使用状況ペイロードを返さない場合、または上流アダプターが元のフィールドの存在有無に関する情報をすでに破棄している場合、この値は `None` のままです。 -`preserve_raw_usage` が保持するのは、モデルアダプターに到達した使用量ペイロードのみです。この設定によってプロバイダーに使用量がリクエストされることはありません。ストリーミングの Chat Completions プロバイダーで明示的な使用量リクエストが必要な場合は、`ModelSettings(include_usage=True)` も設定してください。 +`preserve_raw_usage` は、モデルアダプターに到達した使用状況ペイロードのみを保持します。この設定によって、プロバイダーに使用状況を要求することはありません。ストリーミング対応の Chat Completions プロバイダーで明示的な使用状況リクエストが必要な場合は、`ModelSettings(include_usage=True)` も設定してください。 -`LitellmModel` は現在、ストリーミング実行と非ストリーミング実行のどちらでも `ModelResponse.raw_usage` を設定しないため、そのアダプターでは `preserve_raw_usage=True` は効果がありません。`LitellmModel` を使用する場合は、引き続き正規化された [`Usage`][agents.usage.Usage] フィールドを使用してください。プロバイダー固有のフィールドの有無を確認する必要がある場合は、raw 使用量の保持をサポートするアダプターを選択してください。 +`LitellmModel` は現在、ストリーミング実行と非ストリーミング実行のいずれでも `ModelResponse.raw_usage` を設定しないため、そのアダプターでは `preserve_raw_usage=True` は効果がありません。`LitellmModel` を使用する場合は、正規化された [`Usage`][agents.usage.Usage] フィールドを引き続き使用してください。プロバイダー固有のフィールドの存在有無を保持する必要がある場合は、raw 使用状況の保持をサポートするアダプターを選択してください。 -## セッション使用時の使用量へのアクセス +## セッションでの使用状況へのアクセス -`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その実行に固有の使用量が返されます。セッションはコンテキスト用に会話履歴を維持しますが、各実行の使用量は独立しています。 +`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その特定の実行の使用状況が返されます。セッションはコンテキストのために会話履歴を保持しますが、各実行の使用状況は独立しています。 ```python session = SQLiteSession("my_conversation") @@ -93,11 +93,29 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用量メトリクスは、その実行のみを表します。セッションでは、以前のメッセージが各実行への入力として再送信される場合があり、後続のターンにおける入力トークン数に影響します。 +セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用状況の指標は、その特定の実行のみを表します。セッションでは、以前のメッセージが各実行への入力として再度渡される場合があり、後続のターンにおける入力トークン数に影響します。 -## フックでの使用量の利用 +## RunState チェックポイントでの使用状況 -`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの主要な時点で使用量をログに記録できます。 +[`RunResult.to_state()`][agents.result.RunResult.to_state] は、それまでに蓄積された使用状況の独立したスナップショットを取得します。そのチェックポイントから再開された実行は、取得済みの合計値から開始し、独自のモデル呼び出しによる使用状況を加算します。再開された実行では、これらの新しい合計値は元の `RunResult` にも、その実行結果から作成された別のチェックポイントにも加算されません。 + +```python +first = await Runner.run(agent, "First request") +checkpoint_a = first.to_state() +checkpoint_b = first.to_state() + +resumed_a = await Runner.run(agent, checkpoint_a) +resumed_b = await Runner.run(agent, checkpoint_b) + +assert resumed_a.context_wrapper.usage is not first.context_wrapper.usage +assert resumed_b.context_wrapper.usage is not resumed_a.context_wrapper.usage +``` + +この分離は、[`Usage`][agents.usage.Usage] 内の `request_usage_entries` リストにも適用されます。ただし、再開されたネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] 実行は、独立したトップレベルの集計の例外です。再開後のモデル使用状況は、ネストされた実行の以前のモデル呼び出しと同様に、アクティブな外側の実行の使用状況へ意図的に集計されます。 + +## フックでの使用状況 + +`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの重要な時点で使用状況を記録できます。 ```python class MyHooks(RunHooks): @@ -108,9 +126,9 @@ class MyHooks(RunHooks): ## API リファレンス -詳細な API ドキュメントについては、以下を参照してください。 +API の詳細なドキュメントについては、以下を参照してください。 -- [`Usage`][agents.usage.Usage] - 使用量追跡のデータ構造 -- [`RequestUsage`][agents.usage.RequestUsage] - リクエストごとの使用量の詳細 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストからの使用量へのアクセス -- [`RunHooks`][agents.run.RunHooks] - 使用量追跡ライフサイクルへのフック \ No newline at end of file +- [`Usage`][agents.usage.Usage] - 使用状況追跡のデータ構造 +- [`RequestUsage`][agents.usage.RequestUsage] - リクエストごとの使用状況の詳細 +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 実行コンテキストからの使用状況へのアクセス +- [`RunHooks`][agents.run.RunHooks] - 使用状況追跡のライフサイクルへのフック \ No newline at end of file diff --git a/docs/ja/visualization.md b/docs/ja/visualization.md index d6e5de230d..26b0f4d073 100644 --- a/docs/ja/visualization.md +++ b/docs/ja/visualization.md @@ -4,7 +4,7 @@ search: --- # エージェントの可視化 -エージェントの可視化では、 **Graphviz** を使用して、エージェントと、他のエージェント、ツール、MCPサーバーとの接続を構造化されたグラフィカル表現として生成できます。これは、アプリケーション内でエージェント、ツール、ハンドオフがどのように連携するかを理解するのに役立ちます。 +エージェントの可視化では、 **Graphviz** を使用して、エージェントと、他のエージェント、ツール、MCP サーバーとの接続を構造化されたグラフとして生成できます。これは、アプリケーション内でエージェント、ツール、ハンドオフがどのように連携するかを理解するのに役立ちます。 ## インストール @@ -19,7 +19,7 @@ pip install "openai-agents[viz]" `draw_graph` 関数を使用して、エージェントの可視化を生成できます。この関数は、次のような有向グラフを作成します。 - **エージェント** は黄色のボックスで表されます。 -- **MCPサーバー** は灰色のボックスで表されます。 +- **MCP サーバー** は灰色のボックスで表されます。 - **ツール** は緑色の楕円で表されます。 - **ハンドオフ** は、あるエージェントから別のエージェントへの有向エッジで表されます。 @@ -28,7 +28,7 @@ pip install "openai-agents[viz]" ```python import os -from agents import Agent +from agents import Agent, handoff from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph @@ -60,7 +60,7 @@ mcp_server = MCPServerStdio( triage_agent = Agent( name="Triage agent", instructions="Handoff to the appropriate agent based on the language of the request.", - handoffs=[spanish_agent, english_agent], + handoffs=[handoff(spanish_agent), handoff(english_agent)], tools=[get_weather], mcp_servers=[mcp_server], ) @@ -72,34 +72,36 @@ draw_graph(triage_agent) これにより、 **トリアージエージェント** の構造と、サブエージェントおよびツールとの接続を視覚的に表すグラフが生成されます。 +`draw_graph()` は、`handoffs` で直接指定された対象エージェント、または `handoff(agent)` を通じて登録された対象エージェントを再帰的に展開します。どちらの形式でも、グラフには各対象のツール、MCP サーバー、およびその先のハンドオフが含まれます。利用可能な対象 `Agent` がないカスタム `Handoff` は、名前付きの接続先としてのみ描画されるため、グラフではその接続先の背後にあるリソースを展開できません。 + ## 可視化の構成 生成されるグラフには、次の要素が含まれます。 -- エントリーポイントを示す **開始ノード** (`__start__`)。 +- エントリーポイントを示す **開始ノード**(`__start__`)。 - 黄色で塗りつぶされた **長方形** で表されるエージェント。 - 緑色で塗りつぶされた **楕円** で表されるツール。 -- 灰色で塗りつぶされた **長方形** で表されるMCPサーバー。 -- インタラクションを示す有向エッジ。 +- 灰色で塗りつぶされた **長方形** で表される MCP サーバー。 +- インタラクションを示す有向エッジ: - エージェント間のハンドオフを示す **実線の矢印**。 - ツール呼び出しを示す **点線の矢印**。 - - MCPサーバー呼び出しを示す **破線の矢印**。 -- 実行が終了する場所を示す **終了ノード** (`__end__`)。 + - MCP サーバー呼び出しを示す **破線の矢印**。 +- 実行が終了する場所を示す **終了ノード**(`__end__`)。 -**注:** MCPサーバーは、 **v0.2.8** を含む最近のバージョンの `agents` パッケージでレンダリングされ、この動作が確認されています。可視化にMCPサーバーのボックスが表示されない場合は、最新リリースにアップグレードしてください。 +**注:** MCP サーバーは、`agents` パッケージの最近のバージョンで描画されます。この動作が確認されている **v0.2.8** も含まれます。可視化に MCP のボックスが表示されない場合は、最新リリースにアップグレードしてください。 ## グラフのカスタマイズ ### グラフの表示 -デフォルトでは、 `draw_graph` はグラフをインラインで表示します。グラフを別のウィンドウに表示するには、次のように記述します。 +デフォルトでは、`draw_graph` はグラフをインラインで表示します。グラフを別ウィンドウに表示するには、次のように記述します。 ```python draw_graph(triage_agent).view() ``` ### グラフの保存 -デフォルトでは、 `draw_graph` はグラフをインラインで表示します。ファイルとして保存するには、ファイル名を指定します。 +デフォルトでは、`draw_graph` はグラフをインラインで表示します。ファイルとして保存するには、ファイル名を指定します。 ```python draw_graph(triage_agent, filename="agent_graph") diff --git a/docs/ko/config.md b/docs/ko/config.md index f00c02902f..084614d6c0 100644 --- a/docs/ko/config.md +++ b/docs/ko/config.md @@ -6,19 +6,19 @@ search: 이 페이지에서는 기본 OpenAI 키 또는 클라이언트, 기본 OpenAI API 형식, 트레이싱 내보내기 기본값, 로깅 동작처럼 애플리케이션 시작 시 일반적으로 한 번 설정하는 SDK 전역 기본값을 다룹니다. -이러한 기본값은 샌드박스 기반 워크플로에도 적용되지만, 샌드박스 워크스페이스, 샌드박스 클라이언트, 세션 재사용은 별도로 구성합니다. +이러한 기본값은 샌드박스 기반 워크플로에도 적용되지만, 샌드박스 워크스페이스, 샌드박스 클라이언트 및 세션 재사용은 별도로 구성합니다. -대신 특정 에이전트나 실행을 구성해야 한다면 다음 문서부터 확인하세요. +대신 특정 에이전트나 실행을 구성해야 한다면 다음 문서부터 살펴보세요. -- [에이전트](agents.md): 일반 `Agent`의 instructions, tools, 출력 유형, 핸드오프, 가드레일 -- [에이전트 실행](running_agents.md): `RunConfig`, 세션, 대화 상태 옵션 -- [샌드박스 에이전트](sandbox/guide.md): `SandboxRunConfig`, 매니페스트, 기능, 샌드박스 클라이언트별 워크스페이스 설정 -- [모델](models/index.md): 모델 선택 및 공급자 구성 -- [트레이싱](tracing.md): 실행별 트레이싱 메타데이터 및 맞춤형 트레이스 프로세서 +- 일반 `Agent`의 instructions, tools, 출력 유형, 핸드오프 및 가드레일은 [에이전트](agents.md)를 참고하세요. +- `RunConfig`, 세션 및 대화 상태 옵션은 [에이전트 실행](running_agents.md)을 참고하세요. +- `SandboxRunConfig`, 매니페스트, 기능 및 샌드박스 클라이언트별 워크스페이스 설정은 [샌드박스 에이전트](sandbox/guide.md)를 참고하세요. +- 모델 선택 및 제공자 구성은 [모델](models/index.md)을 참고하세요. +- 실행별 트레이싱 메타데이터 및 사용자 지정 트레이스 프로세서는 [트레이싱](tracing.md)을 참고하세요. ## 구성 객체와 딕셔너리 -SDK에서 정의한 구성 매개변수는 일반적으로 형식이 지정된 설정 객체 또는 동일한 필드를 포함하는 딕셔너리를 허용합니다. 이는 형식 어노테이션에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스, 음성 구성 경계 전반에 적용됩니다. SDK에서 정의한 중첩 설정 유형에도 딕셔너리를 사용할 수 있습니다. +SDK에서 정의한 구성 매개변수는 일반적으로 형식이 지정된 설정 객체 또는 동일한 필드를 포함하는 딕셔너리를 허용합니다. 이는 형식 어노테이션에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 구성 경계 전반에 적용됩니다. SDK에서 정의한 중첩 설정 형식에도 딕셔너리를 사용할 수 있습니다. ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK에서 정의한 데이터 클래스 구성 유형에 알 수 없는 필드가 있으면 `TypeError`가 발생하므로, 옵션 이름의 오타를 조기에 발견하는 데 도움이 됩니다. 특정 경계에서 딕셔너리를 허용하는지 확인하려면 해당 매개변수의 형식 어노테이션 또는 API 레퍼런스를 확인하세요. +SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK에서 정의한 데이터 클래스 구성 형식에 알 수 없는 필드가 있으면 `TypeError`이 발생하므로, 옵션 이름의 오타를 조기에 발견하는 데 도움이 됩니다. 특정 경계에서 딕셔너리를 허용하는지 확인하려면 해당 매개변수의 형식 어노테이션 또는 API 레퍼런스를 확인하세요. ## API 키와 클라이언트 -기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. SDK가 OpenAI 클라이언트를 처음 생성할 때 키가 확인되므로(지연 초기화), 첫 모델 호출 전에 환경 변수를 설정하세요. 앱 시작 전에 해당 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용하여 키를 설정할 수 있습니다. +기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. SDK가 처음 OpenAI 클라이언트를 생성할 때 키를 확인하므로(지연 초기화), 첫 번째 모델 호출 전에 환경 변수를 설정하세요. 앱이 시작되기 전에 해당 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용하여 키를 설정할 수 있습니다. ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -또는 사용할 OpenAI 클라이언트를 구성할 수도 있습니다. 기본적으로 SDK는 환경 변수의 API 키 또는 위에서 설정한 기본 키를 사용하여 `AsyncOpenAI` 인스턴스를 생성합니다. [set_default_openai_client()][agents.set_default_openai_client] 함수를 사용하여 이를 변경할 수 있습니다. +또는 사용할 OpenAI 클라이언트를 구성할 수도 있습니다. 기본적으로 SDK는 환경 변수의 API 키나 위에서 설정한 기본 키를 사용하여 `AsyncOpenAI` 인스턴스를 생성합니다. [set_default_openai_client()][agents.set_default_openai_client] 함수를 사용하여 이를 변경할 수 있습니다. ```python from openai import AsyncOpenAI @@ -55,9 +55,11 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` -### `openai` v3 기반 맞춤형 HTTP 클라이언트 +[`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider]에 명시적 클라이언트를 전달하면 해당 클라이언트가 연결 및 계정 설정을 관리합니다. `OpenAIProvider`에 `api_key`, `base_url`, `websocket_base_url`, `organization` 또는 `project`을 함께 전달하지 마세요. `openai_client`을 이러한 인수 중 하나와 함께 사용하면 중복 값을 조용히 무시하는 대신 [`UserError`][agents.exceptions.UserError]가 발생합니다. `AsyncOpenAI`을 생성할 때 원하는 값을 설정하세요. -버전 0.21.0에는 `openai>=3.0.0,<4`이 필요합니다. 기본 OpenAI 공급자는 HTTPX2를 사용하므로 대부분의 애플리케이션에서는 HTTP 클라이언트를 직접 구성할 필요가 없습니다. 애플리케이션이 `AsyncOpenAI`에 `http_client=`을 전달하는 경우, 맞춤형 클라이언트와 전송 관련 옵션에 HTTPX2 유형을 사용하세요. +### `openai` v3 기반 사용자 지정 HTTP 클라이언트 + +버전 0.21.0에는 `openai>=3.0.0,<4`이 필요합니다. 기본 OpenAI 제공자는 HTTPX2를 사용하므로 대부분의 애플리케이션에서는 HTTP 클라이언트를 직접 구성할 필요가 없습니다. 애플리케이션에서 `AsyncOpenAI`에 `http_client=`을 전달한다면 사용자 지정 클라이언트와 전송 관련 옵션에 HTTPX2 형식을 사용하세요. ```python import httpx2 @@ -75,18 +77,18 @@ custom_client = AsyncOpenAI( set_default_openai_client(custom_client) ``` -동일한 마이그레이션이 맞춤형 전송, 인증, 이벤트 훅, 모의 전송, URL, 요청, 응답, 전송 예외 처리에도 적용됩니다. 각각에 해당하는 `httpx2`을 사용하세요. Agents SDK는 임의의 레거시 `httpx` 객체를 HTTPX2로 변환하지 않습니다. 애플리케이션에서 `httpx`을 명시적으로 설치하면 OpenAI Python SDK가 레거시 클라이언트를 위한 임시 호환성 경로를 제공하지만, 새 코드와 마이그레이션된 코드는 HTTPX2를 사용해야 합니다. +사용자 지정 전송, 인증, 이벤트 훅, 모의 전송, URL, 요청, 응답 및 전송 예외 처리에도 동일한 마이그레이션이 적용됩니다. 각각에 해당하는 `httpx2`을 사용하세요. Agents SDK는 임의의 레거시 `httpx` 객체를 HTTPX2로 변환하지 않습니다. 애플리케이션에서 `httpx`을 명시적으로 설치하면 OpenAI Python SDK가 레거시 클라이언트를 위한 임시 호환 경로를 제공하지만, 신규 코드와 마이그레이션된 코드에서는 HTTPX2를 사용해야 합니다. -이 OpenAI 클라이언트 경계는 로컬 MCP 전송 맞춤 설정과 별개입니다. MCP Python SDK v1은 자체 레거시 `httpx` 종속성을 사용하고, MCP Python SDK v2는 `httpx2`을 사용합니다. 자세한 내용은 [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참조하세요. +이 OpenAI 클라이언트 경계는 로컬 MCP 전송 사용자 지정과 별개입니다. MCP Python SDK v1은 자체 레거시 `httpx` 종속성을 사용하고 MCP Python SDK v2는 `httpx2`를 사용합니다. [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. -환경 기반 엔드포인트 구성을 선호하는 경우 기본 OpenAI 공급자는 `OPENAI_BASE_URL`도 읽습니다. Responses 웹소켓 전송을 활성화하면 웹소켓 `/responses` 엔드포인트에 사용할 `OPENAI_WEBSOCKET_BASE_URL`도 읽습니다. +환경 기반 엔드포인트 구성을 선호한다면 기본 OpenAI 제공자는 `OPENAI_BASE_URL`도 읽습니다. Responses 웹소켓 전송을 활성화하면 웹소켓 `/responses` 엔드포인트에 사용할 `OPENAI_WEBSOCKET_BASE_URL`도 읽습니다. ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -마지막으로 사용할 OpenAI API도 맞춤 설정할 수 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용하여 Chat Completions API를 사용하도록 재정의할 수 있습니다. +마지막으로 사용할 OpenAI API도 사용자 지정할 수 있습니다. 기본적으로 OpenAI Responses API를 사용합니다. [set_default_openai_api()][agents.set_default_openai_api] 함수를 사용하면 이를 재정의하여 Chat Completions API를 사용할 수 있습니다. ```python from agents import set_default_openai_api @@ -94,9 +96,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 공급자 기본값 +## OpenAI 제공자 기본값 -SDK의 OpenAI 백엔드를 사용하는 공급자는 모델 이름 문자열을 모델에 매핑할 때 SDK 전역 기본값도 읽습니다. OpenAI Responses 모델에서 기본적으로 웹소켓 전송을 사용하도록 하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]을 사용하세요. +SDK의 OpenAI 백엔드를 사용하는 제공자는 모델 이름 문자열을 모델에 매핑할 때 SDK 전역 기본값도 읽습니다. OpenAI Responses 모델이 기본적으로 웹소켓 전송을 사용하도록 하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]을 사용하세요. ```python from agents import set_default_openai_responses_transport @@ -104,9 +106,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -이는 기본 OpenAI 공급자가 모델 이름을 확인하여 생성한 OpenAI Responses 모델에 영향을 줍니다. 공급자 수준 설정, 연결 재사용, keepalive 옵션, 맞춤형 웹소켓 엔드포인트에 관한 자세한 내용은 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참조하세요. +이는 기본 OpenAI 제공자가 모델 이름을 확인할 때 생성되는 OpenAI Responses 모델에 영향을 줍니다. 제공자 수준 설정, 연결 재사용, keepalive 옵션 및 사용자 지정 웹소켓 엔드포인트에 대해서는 [Responses WebSocket 전송](models/index.md#responses-websocket-transport)을 참고하세요. -OpenAI 설정에서 공급자 수준 에이전트 등록 메타데이터가 필요한 경우 시작 시 기본 하네스 ID를 한 번 구성하세요. +OpenAI 설정에서 제공자 수준의 에이전트 등록 메타데이터가 필요하다면 시작 시 기본 하네스 ID를 한 번 구성하세요. ```python from agents import set_default_openai_harness @@ -124,11 +126,11 @@ set_default_openai_agent_registration( ) ``` -SDK 기본값을 설정하지 않으면 SDK의 OpenAI 백엔드를 사용하는 공급자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수를 대신 사용합니다. 하네스 ID가 구성된 경우 `RunConfig.trace_metadata`에 해당 키가 아직 없으면 SDK가 이를 `agent_harness_id`으로 트레이스 메타데이터에 추가합니다. +SDK 기본값이 설정되지 않은 경우 SDK의 OpenAI 백엔드를 사용하는 제공자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수로 대체합니다. 하네스 ID가 구성되어 있으면 `RunConfig.trace_metadata`에 해당 키가 이미 존재하지 않는 한 SDK가 이를 `agent_harness_id`으로 트레이스 메타데이터에 추가합니다. ## 트레이싱 -트레이싱은 기본적으로 활성화되어 있습니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수 또는 설정한 기본 키를 사용합니다. 트레이싱에 사용할 API 키를 별도로 설정하려면 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하세요. +트레이싱은 기본적으로 활성화됩니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수 또는 설정한 기본 키를 사용합니다. [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하여 트레이싱에 사용할 API 키를 별도로 설정할 수 있습니다. ```python from agents import set_tracing_export_api_key @@ -136,7 +138,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -모델 트래픽에는 한 키 또는 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 하는 경우, 기본 키 또는 클라이언트를 설정할 때 `use_for_tracing=False`을 전달한 다음 트레이싱을 별도로 구성하세요. 맞춤형 클라이언트를 사용하지 않는다면 [`set_default_openai_key()`][agents.set_default_openai_key]에도 같은 방식을 사용할 수 있습니다. +모델 트래픽에는 한 키나 클라이언트를 사용하지만 트레이싱에는 다른 OpenAI 키를 사용해야 한다면 기본 키 또는 클라이언트를 설정할 때 `use_for_tracing=False`을 전달한 다음 트레이싱을 별도로 구성하세요. 사용자 지정 클라이언트를 사용하지 않는 경우 [`set_default_openai_key()`][agents.set_default_openai_key]에도 동일한 패턴을 적용할 수 있습니다. ```python from openai import AsyncOpenAI @@ -151,14 +153,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -기본 내보내기를 사용할 때 트레이스를 특정 조직이나 프로젝트에 귀속해야 한다면 앱 시작 전에 다음 환경 변수를 설정하세요. +기본 내보내기 도구를 사용할 때 트레이스를 특정 조직이나 프로젝트에 귀속해야 한다면 앱이 시작되기 전에 다음 환경 변수를 설정하세요. ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -전역 내보내기를 변경하지 않고 실행별 트레이싱 API 키를 설정할 수도 있습니다. +전역 내보내기 도구를 변경하지 않고 실행별로 트레이싱 API 키를 설정할 수도 있습니다. ```python from agents import Runner, RunConfig @@ -178,7 +180,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -트레이싱을 활성화된 상태로 유지하되 민감할 수 있는 입력과 출력을 트레이스 페이로드에서 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]을 `False`로 설정하세요. +트레이싱을 활성화된 상태로 유지하면서 잠재적으로 민감한 입력/출력을 트레이스 페이로드에서 제외하려면 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]을 `False`로 설정하세요. ```python from agents import Runner, RunConfig @@ -190,13 +192,13 @@ await Runner.run( ) ``` -앱 시작 전에 다음 환경 변수를 설정하여 코드 없이 기본값을 변경할 수도 있습니다. +앱이 시작되기 전에 다음 환경 변수를 설정하여 코드 없이 기본값을 변경할 수도 있습니다. ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -전체 트레이싱 제어 옵션은 [트레이싱 가이드](tracing.md)를 참조하세요. +전체 트레이싱 제어 기능은 [트레이싱 가이드](tracing.md)를 참고하세요. ## 디버그 로깅 @@ -210,7 +212,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -또는 핸들러, 필터, 포매터 등을 추가하여 로그를 맞춤 설정할 수 있습니다. 자세한 내용은 [Python 로깅 가이드](https://docs.python.org/3/howto/logging.html)를 참조하세요. +또는 핸들러, 필터, 포매터 등을 추가하여 로그를 사용자 지정할 수 있습니다. 자세한 내용은 [Python 로깅 가이드](https://docs.python.org/3/howto/logging.html)를 참고하세요. ```python import logging @@ -229,22 +231,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 로그 및 진단의 민감한 데이터 +### 로그와 진단 정보의 민감한 데이터 -일부 로그와 진단 예외에는 민감한 데이터가 포함될 수 있습니다(예: 모델 또는 도구의 입력과 출력). +일부 로그와 진단 예외에는 민감한 데이터(예: 모델 또는 도구 입력과 출력)가 포함될 수 있습니다. -기본적으로 SDK는 LLM 입력/출력이나 도구 입력/출력을 로그에 기록하지 **않습니다**. 이러한 보호 기능은 다음 항목으로 제어합니다. +기본적으로 SDK는 LLM 입력/출력이나 도구 입력/출력을 로깅하지 **않습니다**. 이러한 보호 기능은 다음 항목으로 제어됩니다. ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -디버깅을 위해 이 데이터를 일시적으로 포함해야 한다면 앱 시작 전에 두 변수 중 하나를 `0`(또는 `false`)로 설정하세요. +디버깅을 위해 이 데이터를 일시적으로 포함해야 한다면 앱이 시작되기 전에 두 변수 중 하나를 `0`(또는 `false`)로 설정하세요. ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -이 플래그는 영향을 받는 실패가 페이로드를 포함한 진단 세부 정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 마스킹이 활성화된 경우 `FunctionTool`에 대한 잘못된 인수는 내부 검증 오류를 예외 체인으로 연결하지 않고 일반적인 `ModelBehaviorError`을 발생시킵니다. 두 변수 중 하나를 `0`로 설정하면 로그, 예외 메시지, 예외 체인, 기타 진단 컨텍스트에 가공되지 않은 모델 또는 도구 데이터가 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file +이러한 플래그는 영향을 받는 실패에 페이로드가 포함된 진단 세부 정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 삭제가 활성화된 상태에서 `FunctionTool`의 인수가 유효하지 않으면, 내부 검증 오류를 예외 체인에 연결하지 않고 일반적인 `ModelBehaviorError`가 발생합니다. 두 변수 중 하나를 `0`로 설정하면 로그, 예외 메시지, 예외 체인 및 기타 진단 컨텍스트에 가공되지 않은 모델 또는 도구 데이터가 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index e8be0d0adc..19ef96abc1 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -4,58 +4,60 @@ search: --- # 가드레일 -가드레일을 사용하면 사용자 입력과 에이전트 출력을 검사하고 검증할 수 있습니다. 예를 들어 매우 지능적이어서 느리고 비용이 많이 드는 모델을 사용해 고객 요청을 처리하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와 달라고 요청하게 두고 싶지는 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 시간과 비용을 절약할 수 있습니다. 차단 실행은 비용이 많이 드는 모델이 시작되지 않도록 보장합니다. 반면 병렬 실행에서는 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 시작되었을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참고하세요. +가드레일을 사용하면 사용자 입력과 에이전트 출력을 확인하고 검증할 수 있습니다. 예를 들어 고객 요청을 지원하기 위해 매우 지능적이고 그만큼 느리며 비용이 많이 드는 모델을 사용하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 것은 원하지 않을 것입니다. 이 경우 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 시간과 비용을 절약할 수 있습니다. 차단 실행은 고비용 모델이 시작되지 않도록 보장합니다. 병렬 실행에서는 가드레일이 완료되기 전에 고비용 모델이 이미 시작되었을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요. -가드레일에는 두 종류가 있습니다. +가드레일에는 두 가지 종류가 있습니다. -1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다. -2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다. +1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다 +2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다 ## 워크플로 경계 -가드레일은 에이전트와 도구에 연결되지만, 워크플로의 모든 지점에서 실행되는 것은 아닙니다. +가드레일은 에이전트와 도구에 연결되지만, 워크플로에서 모두 같은 시점에 실행되는 것은 아닙니다. - **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. - **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. -- **도구 가드레일**은 사용자 정의 함수 도구가 호출될 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. +- **도구 가드레일**은 사용자 정의 함수 도구를 호출할 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. -관리자, 핸드오프 또는 작업을 위임받은 전문가가 포함된 워크플로에서 각 사용자 정의 함수 도구 호출 전후에 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. +매니저, 핸드오프 또는 위임된 전문 에이전트가 포함된 워크플로에서 각 사용자 정의 함수 도구 호출 전후에 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. ## 입력 가드레일 입력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트에 전달된 것과 동일한 입력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이는 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다. -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]가 true인지 확인합니다. true이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 값은 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult]로 래핑됩니다 +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]이 참인지 확인합니다. 참이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트의 가드레일은 해당 에이전트가 *첫 번째* 에이전트인 경우에만 실행됩니다. `guardrails` 속성을 `Runner.run`에 전달하지 않고 에이전트에 두는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 한곳에 배치하면 가독성에 도움이 됩니다. + 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 그렇다면 왜 `guardrails` 속성을 `Runner.run`에 전달하지 않고 에이전트에 지정하는지 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. ### 실행 모드 입력 가드레일은 두 가지 실행 모드를 지원합니다. -- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘이 동시에 시작되므로 지연 시간이 가장 짧습니다. 하지만 가드레일의 트립와이어가 트리거되면 에이전트가 취소되기 전에 이미 토큰을 사용하고 도구를 실행했을 수 있습니다. +- **병렬 실행** (기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 동시에 시작하므로 지연 시간이 가장 짧습니다. 그러나 가드레일의 트립와이어가 작동하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. -- **차단 실행**(`run_in_parallel=False`): 에이전트가 시작되기 *전에* 가드레일이 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출로 인해 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. +- **차단 실행** (`run_in_parallel=False`): 가드레일이 에이전트보다 *먼저* 실행되어 완료됩니다. 가드레일 트립와이어가 작동하면 에이전트가 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출로 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. ## 출력 가드레일 출력 가드레일은 다음 3단계로 실행됩니다. 1. 먼저 가드레일은 에이전트가 생성한 출력을 받습니다. -2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이는 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다. -3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]이 true인지 확인합니다. true이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. +2. 다음으로 가드레일 함수가 실행되어 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 생성하고, 이 값은 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult]로 래핑됩니다 +3. 마지막으로 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered]이 참인지 확인합니다. 참이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트의 가드레일은 해당 에이전트가 *마지막* 에이전트인 경우에만 실행됩니다. 입력 가드레일과 마찬가지로 이렇게 하는 이유는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 한곳에 배치하면 가독성에 도움이 됩니다. + 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로, 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문에 이렇게 동작합니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. - 출력 가드레일은 항상 에이전트 실행이 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. + 출력 가드레일은 항상 에이전트가 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. -출력 트립와이어와 가드레일 함수가 발생시킨 예외는 세션에서 서로 다르게 동작합니다. 트립와이어는 최종 출력 후보를 거부합니다. 트립와이어가 작동하면 러너는 거부된 최종 출력 후보를 제외하고, 이미 완료된 도구 호출 및 도구 출력 항목과 해당 호출을 재실행하는 데 필요한 추론 컨텍스트를 구성된 세션에 저장하도록 요청합니다. 러너는 이 트립와이어 규칙을 스트리밍 실행과 비스트리밍 실행 모두에 적용합니다. 가드레일 함수가 트립와이어 결과를 반환하는 대신 예외를 발생시키면 러너는 판정을 알 수 없는 것으로 간주하고, 가드레일 예외를 표면화하기 전에 완료된 최종 턴 항목을 저장하도록 구성된 세션에 요청합니다. 이 세션 쓰기도 실패하면 세션 쓰기 오류가 우선합니다. 스트리밍 실행은 비스트리밍 실행과 동일한 저장 순서를 사용하며 `stream_events()`에서 최종 예외를 발생시킵니다. 출력 가드레일이 실행 중일 때 [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel]을 즉시 호출하면 진행 중인 가드레일이 취소되고 최종 턴 세션 쓰기는 시작되지 않습니다. +출력 트립와이어와 가드레일 함수에서 발생한 예외는 세션에서 서로 다르게 처리됩니다. 트립와이어는 후보 최종 출력을 거부합니다. 트립와이어가 작동하면 러너는 구성된 세션에 이미 완료된 도구 호출 및 도구 출력 항목과 해당 호출을 재현하는 데 필요한 모든 추론 컨텍스트를 저장하되, 거부된 후보 최종 출력은 제외하도록 요청합니다. 러너는 스트리밍 실행과 비스트리밍 실행 모두에 이 트립와이어 규칙을 적용합니다. 가드레일 함수가 트립와이어 결과를 반환하는 대신 예외를 발생시키면 러너는 판정을 알 수 없는 것으로 간주하고, 가드레일 예외를 표면화하기 전에 완료된 최종 턴 항목을 저장하도록 구성된 세션에 요청합니다. 해당 세션 쓰기까지 실패하면 세션 쓰기 오류가 우선합니다. 스트리밍 실행은 비스트리밍 실행과 동일한 저장 순서를 사용하며 `stream_events()`에서 최종 예외를 발생시킵니다. 출력 가드레일이 실행되는 동안 [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel]을 즉시 호출하면 진행 중인 가드레일이 취소되고 최종 턴 세션 쓰기는 시작되지 않습니다. + +터미널 함수 도구 출력은 에이전트 수준 출력 가드레일이 값을 검사하기 전에 도구가 이미 실행되었으므로 추가 처리가 필요합니다. [`Agent.tool_use_behavior`][agents.agent.Agent.tool_use_behavior]에 따라 해당 도구 결과가 최종 출력이 되고 출력 트립와이어가 이를 거부하는 경우, SDK는 검증된 필드로 함수 호출/출력 쌍을 다시 구성할 수 있을 때만 재현 가능한 유효한 쌍을 유지합니다. 유지되는 `function_call_output` 페이로드는 고정 텍스트 `"Output withheld by an output guardrail."`로 대체됩니다. 원래 도구 출력 페이로드는 세션, `RunState`, 스트리밍 결과 상태 또는 샌드박스 메모리 입력에 유지되지 않습니다. SDK는 함수 인수를 포함하여 재현에 필요한 검증된 함수 호출 메타데이터를 유지하므로, 해당 메타데이터에는 거부된 출력에도 나타난 데이터가 포함될 수 있습니다. 현재 응답의 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 객체도 `agent_output`을 고정 텍스트로 대체하고 `output_info`을 비웁니다. 현재 응답의 [`ToolOutputGuardrailResult`][agents.tool_guardrails.ToolOutputGuardrailResult] 객체는 허용/거부 동작 유형을 유지하지만, 페이로드를 포함하는 `output_info`과 거부 메시지를 동일한 텍스트로 대체합니다. 이전에 수락된 턴과 가드레일 결과는 변경되지 않습니다. 응답에 추론 또는 SDK가 안전하게 정리할 수 없는 다른 형식이 포함된 경우, SDK는 거부된 출력 페이로드를 유지하는 대신 현재 응답의 전체 후행 부분을 폐기합니다. 예외를 발생시킨 가드레일 함수는 거부 판정을 반환하지 않은 것이므로, 완료된 터미널 도구 턴에는 위에서 설명한 예외 저장 동작이 적용됩니다. ## 도구 가드레일 @@ -63,22 +65,22 @@ search: - 입력 도구 가드레일은 도구 실행 전에 실행되며, 호출을 건너뛰거나 출력을 메시지로 대체하거나 트립와이어를 발생시킬 수 있습니다. - 출력 도구 가드레일은 도구 실행 후에 실행되며, 출력을 대체하거나 트립와이어를 발생시킬 수 있습니다. -- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후 실행 직전에 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 도구가 실행되기 전에 다시 검사됩니다. -- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성한 함수 도구에만 적용됩니다. 핸드오프는 일반적인 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 통해 실행되므로, 도구 가드레일은 핸드오프 호출 자체에 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 내장 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 현재 도구 가드레일 옵션을 직접 노출하지 않습니다. +- 함수 도구에 승인이 필요한 경우 입력 도구 가드레일은 일반적으로 승인 후, 실행 직전에 실행됩니다. 승인 대기 인터럽션(중단 처리)이 발생하기 전에 이러한 입력 검사를 실행하려면 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution]을 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]으로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 도구가 실행되기 전에 다시 검사됩니다. +- 도구 가드레일은 [`function_tool`][agents.tool.function_tool]로 생성된 함수 도구에만 적용됩니다. 핸드오프는 일반 함수 도구 파이프라인이 아니라 SDK의 핸드오프 파이프라인을 통해 실행되므로 도구 가드레일은 핸드오프 호출 자체에 적용되지 않습니다. 호스티드 툴(`WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `CodeInterpreterTool`, `ImageGenerationTool`)과 기본 제공 실행 도구(`ComputerTool`, `ShellTool`, `ApplyPatchTool`, `LocalShellTool`)도 이 가드레일 파이프라인을 사용하지 않으며, [`Agent.as_tool()`][agents.agent.Agent.as_tool]은 현재 도구 가드레일 옵션을 직접 제공하지 않습니다. -자세한 내용은 아래 코드 스니펫을 참고하세요. +자세한 내용은 아래 코드 스니펫을 참조하세요. ## 트립와이어 -에이전트 입력이나 출력이 가드레일을 통과하지 못하면 가드레일은 트립와이어로 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 각각 해당하는 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. +에이전트 입력이나 출력이 가드레일을 통과하지 못하면 가드레일은 트립와이어로 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 이에 대응하는 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. -에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`은 트립와이어를 트리거한 가드레일을 식별합니다. 러너가 발생시킨 입력 트립와이어의 경우 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 트립와이어를 트리거한 결과도 포함됩니다. 출력 트립와이어는 `exception.run_data.output_guardrail_results`를 통해 이에 해당하는 누적 결과를 제공합니다. +에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`는 트립와이어를 작동시킨 가드레일을 식별합니다. 러너가 입력 트립와이어를 발생시키면 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 여기에는 트립와이어를 작동시킨 결과도 포함됩니다. 출력 트립와이어는 이에 상응하는 누적 결과를 `exception.run_data.output_guardrail_results`를 통해 제공합니다. -반면 도구 트립와이어 예외는 트리거한 `guardrail`과 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록은 실패 전에 완료된 턴에서 누적된 결과를 보존하며, 트리거한 결과는 예외의 `output`을 통해 확인할 수 있습니다. `MaxTurnsExceeded`과 같이 러너가 관리하는 다른 실패도 완료된 도구 가드레일 결과를 이러한 목록에 보존합니다. `stream_events()`에서 예외가 발생한 후 스트리밍 결과는 동일하게 누적된 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생한 경우 `run_data`은 `None`일 수 있습니다. +반면 도구 트립와이어 예외는 트립와이어를 작동시킨 `guardrail`와 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록에는 실패 전에 완료된 턴에서 누적된 결과가 유지되며, 트립와이어를 작동시킨 결과는 예외의 `output`를 통해 확인할 수 있습니다. `MaxTurnsExceeded`과 같이 러너가 관리하는 다른 실패도 완료된 도구 가드레일 결과를 이 목록에 유지합니다. `stream_events()`에서 예외가 발생한 후 스트리밍 결과는 동일하게 누적된 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생하면 `run_data`는 `None`일 수 있습니다. ## 가드레일 구현 -입력을 받고 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행하여 이를 구현합니다. +입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행하여 이를 구현합니다. ```python from pydantic import BaseModel @@ -132,11 +134,11 @@ async def main(): ``` 1. 가드레일 함수에서 이 에이전트를 사용합니다. -2. 에이전트의 입력과 컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. +2. 에이전트의 입력/컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. 3. 가드레일 결과에 추가 정보를 포함할 수 있습니다. 4. 워크플로를 정의하는 실제 에이전트입니다. -출력 가드레일도 유사합니다. +출력 가드레일도 이와 유사합니다. ```python from pydantic import BaseModel diff --git a/docs/ko/release.md b/docs/ko/release.md index 3b52ca37cb..e150103075 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -4,11 +4,11 @@ search: --- # 릴리스 프로세스/변경 로그 -이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 시맨틱 버전 관리 방식을 따릅니다. 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가시킵니다. +이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 시맨틱 버저닝을 따릅니다. 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가합니다. ## 마이너(`Y`) 버전 -베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 있으면 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. +베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 있을 때 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. 호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. @@ -21,62 +21,75 @@ search: - 비공개 인터페이스 변경 - 베타 기능 업데이트 -## 호환성을 깨는 변경 사항의 변경 로그 +## 호환성을 깨는 변경 사항 로그 + +### 0.22.0 + +버전 0.22.0에서는 여러 기존 API의 실패 처리와 데이터 격리가 강화되었습니다. 명시적 클라이언트로 `OpenAIProvider`을 생성하면서 프로바이더에도 `organization` 또는 `project`을 전달하는 애플리케이션은 중복 인수를 제거해야 합니다. + +주요 변경 사항: + +- 에이전트 수준 출력 가드레일이 종결 함수 도구에서 직접 생성된 최종 출력을 차단하면, SDK는 검증된 필드로 안전하게 재구성할 수 있는 경우에만 재실행에 유효한 호출/출력 쌍을 유지합니다. 원래 `function_call_output` 페이로드는 세션 기록, `RunState`, 스트리밍된 결과 상태에서 고정 텍스트 `"Output withheld by an output guardrail."`으로 대체되며, 페이로드가 포함된 현재 응답의 가드레일 메타데이터는 제거되거나 대체됩니다. 현재 응답에 추론 또는 지원되지 않는 다른 형태가 포함되어 있으면 SDK는 대신 현재 응답의 접미부 전체를 폐기합니다. 이전에 수락된 턴과 가드레일 결과는 계속 사용할 수 있습니다. [출력 가드레일](guardrails.md#output-guardrails)을 참고하세요. +- 이제 비스트리밍 OpenAI Responses 호출은 반환된 응답의 최종 상태가 `failed` 또는 `incomplete`이면 기존의 스트리밍 최종 이벤트 처리와 동일하게 `ModelBehaviorError`을 발생시킵니다. 이는 `OpenAIResponsesModel`과 `AnyLLMModel`의 Responses 경로에 적용됩니다. [예외](running_agents.md#exceptions)를 참고하세요. +- 이제 [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider]은 `openai_client`가 `organization` 또는 `project`와 함께 사용될 때도 `UserError`을 발생시킵니다. `api_key`, `base_url`, `websocket_base_url`과의 기존 충돌은 변경되지 않습니다. 이러한 값은 명시적 `AsyncOpenAI` 클라이언트에 구성하세요. [API 키와 클라이언트](config.md#api-keys-and-clients)를 참고하세요. +- 이제 각 `RunResult.to_state()` 체크포인트는 독립적인 사용량 스냅샷을 소유합니다. 재개된 결과는 체크포인트 합계로 시작하고 자체 모델 호출을 추가하며, 원본 결과나 다른 체크포인트를 변경하지 않습니다. 중첩된 `Agent.as_tool()` 재개는 재개 이후의 사용량을 활성 외부 실행에 계속 집계합니다. [RunState 체크포인트의 사용량](usage.md#usage-in-runstate-checkpoints)을 참고하세요. +- 이제 에이전트 시각화는 `handoff(agent)`로 등록된 대상의 도구, MCP 서버, 이후 핸드오프를 재귀적으로 확장하며, 이는 에이전트의 `handoffs` 목록에 있는 직접적인 `Agent` 항목과 동일합니다. [그래프 생성](visualization.md#generating-a-graph)을 참고하세요. +- 이제 `Agent.clone()` 및 `RealtimeAgent.clone()` API 안내에는 기존의 얕은 복사 동작이 정확히 명시되어 있습니다. 재정의되지 않은 목록 속성은 동일한 목록 객체로 유지됩니다. 복제본이 컨테이너를 독립적으로 소유해야 한다면 새 목록을 전달하세요. [에이전트 복제/복사](agents.md#cloningcopying-agents)를 참고하세요. ### 0.21.0 -버전 0.21.0에는 `openai` v3이 필요하며 Agents SDK의 OpenAI HTTP 통합이 HTTPX2로 이전됩니다. 기본 OpenAI 클라이언트를 사용하는 애플리케이션은 클라이언트 설정을 변경할 필요가 없지만, OpenAI HTTP 계층을 사용자 지정하는 애플리케이션은 전송 계층 관련 코드를 마이그레이션해야 할 수 있습니다. +버전 0.21.0에는 `openai` v3가 필요하며, Agents SDK의 OpenAI HTTP 통합이 HTTPX2로 이전되었습니다. 기본 OpenAI 클라이언트를 사용하는 애플리케이션은 클라이언트 설정을 변경할 필요가 없지만, OpenAI HTTP 계층을 사용자 지정하는 애플리케이션은 전송 계층 관련 코드를 마이그레이션해야 할 수 있습니다. 주요 변경 사항: -- 이제 필수 OpenAI 종속성은 `openai>=3.0.0,<4`입니다. 코어를 새로 설치하면 HTTPX2가 사용되며 더 이상 레거시 `httpx`이 직접 종속성으로 설치되지 않습니다. -- 이제 기본 OpenAI 제공자, 음성 제공자, Responses WebSocket 지원, 트레이싱 내보내기 도구, 제공자 재시도 정규화에서 HTTPX2를 사용합니다. 기존 Agents SDK 공개 구성과 런타임 동작은 변경되지 않습니다. -- `AsyncOpenAI`에 `http_client=`를 전달하는 애플리케이션은 사용자 지정 클라이언트, 전송, 인증, 이벤트 훅, 모의 전송, 시간 제한 값, URL, 요청, 응답, 전송 예외 처리를 `httpx`에서 `httpx2`로 마이그레이션해야 합니다. 애플리케이션에 OpenAI 클라이언트의 기본값과 사용자 지정 HTTP 옵션이 모두 필요한 경우 OpenAI Python SDK의 `DefaultAsyncHttpx2Client`을 사용하는 것이 좋습니다. [`openai` v3을 사용하는 사용자 지정 HTTP 클라이언트](config.md#custom-http-clients-with-openai-v3)를 참고하세요. -- Agents SDK는 임의의 레거시 HTTPX 객체를 HTTPX2로 변환하지 않습니다. OpenAI Python SDK의 임시 레거시 클라이언트 호환성 경로에는 명시적으로 `httpx`을 설치해야 하며, 이를 마이그레이션을 위한 임시 연결 수단으로 간주해야 합니다. -- 로컬 MCP HTTP 사용자 지정은 계속해서 설치된 MCP 패키지를 따릅니다. MCP Python SDK v1은 레거시 `httpx`을 제공하고 사용하며, MCP Python SDK v2는 `httpx2`을 사용합니다. 일반적인 MCP 연결은 애플리케이션을 변경할 필요가 없습니다. [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. -- 이제 제공자와 무관한 공개 테스트 유틸리티를 사용하여 제공자 또는 프로세스 종속성 없이 에이전트 모델, 샌드박스 세션, 실시간 세션, 음성 파이프라인 워크플로를 테스트할 수 있습니다. 사용 방법과 실제 제공자 어댑터 또는 통합 경계를 유지해야 하는 경우에 관한 지침은 [테스트](testing.md)를 참고하세요. +- 이제 필수 OpenAI 의존성은 `openai>=3.0.0,<4`입니다. 코어를 새로 설치하면 HTTPX2를 사용하며, 더 이상 레거시 `httpx`을 직접 의존성으로 설치하지 않습니다. +- 이제 기본 OpenAI 프로바이더, Voice 프로바이더, Responses WebSocket 지원, 트레이싱 익스포터, 프로바이더 재시도 정규화는 HTTPX2를 사용합니다. 기존 Agents SDK의 공개 구성과 런타임 동작은 변경되지 않습니다. +- `AsyncOpenAI`에 `http_client=`을 전달하는 애플리케이션은 사용자 지정 클라이언트, 전송, 인증, 이벤트 훅, 모의 전송, 타임아웃 값, URL, 요청, 응답, 전송 예외 처리를 `httpx`에서 `httpx2`로 마이그레이션해야 합니다. 애플리케이션에 OpenAI 클라이언트의 기본값과 사용자 지정 HTTP 옵션이 모두 필요한 경우 OpenAI Python SDK의 `DefaultAsyncHttpx2Client`을 사용하는 것이 좋습니다. [`openai` v3의 사용자 지정 HTTP 클라이언트](config.md#custom-http-clients-with-openai-v3)를 참고하세요. +- Agents SDK는 임의의 레거시 HTTPX 객체를 HTTPX2로 변환하지 않습니다. OpenAI Python SDK의 임시 레거시 클라이언트 호환성 경로에는 명시적인 `httpx` 설치가 필요하며, 이를 마이그레이션용 연결 경로로 간주해야 합니다. +- 로컬 MCP HTTP 사용자 지정은 설치된 MCP 패키지를 계속 따릅니다. MCP Python SDK v1은 레거시 `httpx`을 제공하고 사용하며, MCP Python SDK v2는 `httpx2`을 사용합니다. 일반적인 MCP 연결에는 애플리케이션 변경이 필요하지 않습니다. [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. +- 이제 공개된 프로바이더 중립적 테스트 유틸리티는 프로바이더나 프로세스 의존성 없이 에이전트 모델, 샌드박스 세션, Realtime 세션, Voice 파이프라인 워크플로를 지원합니다. 실제 프로바이더 어댑터 또는 통합 경계를 유지해야 하는 경우에 대한 방법과 안내는 [테스트](testing.md)를 참고하세요. ### 0.20.0 -버전 0.20.0에는 로컬 MCP HTTP 전송을 사용자 지정하는 애플리케이션에서 호환성을 깨뜨릴 가능성이 있는 MCP 종속성 마이그레이션이 포함됩니다. 에이전트나 실행에서 모델을 명시적으로 선택하지 않을 때 사용하는 SDK 기본 모델도 업데이트됩니다. +버전 0.20.0에는 로컬 MCP HTTP 전송을 사용자 지정하는 애플리케이션에 잠재적으로 호환성을 깨는 MCP 의존성 마이그레이션이 포함됩니다. 또한 에이전트 또는 실행에서 모델을 명시적으로 선택하지 않을 때 사용하는 SDK 기본 모델이 업데이트되었습니다. 주요 변경 사항: - 이제 SDK 기본 모델은 `gpt-5.4-mini` 대신 `gpt-5.6-luna`입니다. 기본 `reasoning.effort="none"` 및 `verbosity="low"` 설정은 변경되지 않습니다. -- 명시적인 에이전트 모델, 실행 수준 모델 재정의, `OPENAI_DEFAULT_MODEL` 환경 변수는 계속해서 SDK 기본값보다 우선합니다. -- 이제 실시간 입력 전사 설정에서 `gpt-transcribe`, `gpt-live-transcribe`, `gpt-realtime-whisper`을 인식합니다. 지연 시간이 짧은 `gpt-live-transcribe` 세션의 경우 중첩된 `audio.input.transcription` 설정에서 `prompt`, `keywords`, 예상되는 여러 `languages`을 제공할 수 있습니다. 이 SDK에서 고정한 OpenAI 클라이언트 버전은 `delay` 지연 시간/정확도 수준을 `gpt-realtime-whisper`에서만 지원합니다. 확정된 오디오 턴 이후의 전사 또는 감지된 언어 출력에는 WebSocket을 통해 `gpt-transcribe`을 사용하세요. `audio.input.turn_detection=None`을 명시적으로 설정하면 자동 턴 감지가 비활성화됩니다. [입력 전사 설정](realtime/guide.md#input-transcription-settings)을 참고하세요. -- 이제 Agents SDK에서 생성한 로컬 MCP 연결은 `mcp>=1.19.0,<3`을 통해 v1 호환성을 유지하면서 MCP Python SDK v2를 지원합니다. Agents SDK는 일반적인 stdio, SSE, Streamable HTTP 연결을 자동으로 조정합니다. MCP v2가 설치된 경우 이러한 연결은 `mcp.Client(mode="auto")`을 사용해 지원되는 최신 프로토콜을 탐색하고, 이전 서버에서는 레거시 `initialize` 핸드셰이크로 대체합니다. 종속성 해결 과정에서 MCP v2가 선택된 경우 사용자 지정 `httpx.Auth` 객체나 `httpx.AsyncClient` 팩터리를 제공하는 애플리케이션은 해당 값을 `httpx2`으로 마이그레이션하거나, v1 HTTP 스택을 유지하려면 `mcp<2`을 고정해야 합니다. `MCPServerStreamableHttp`의 `params["ignore_initialized_notification_failure"] = True` 옵션도 계속 v1에서만 사용할 수 있습니다. 마이그레이션 세부 정보는 [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. -- 이제 샌드박스 마운트 검증은 샌드박스 또는 마운트 도우미의 부수 효과가 발생하기 전에 안전하지 않은 자격 증명 배치를 거부합니다. 신뢰할 수 있는 애플리케이션은 저장소 기능 표를 변경하지 않고도 컨테이너 내부의 정확한 마운트 경로에 대해 마운트 범위 또는 광범위한 자격 증명 노출을 명시적으로 승인할 수 있습니다. 이러한 승인은 런타임에만 적용되며, 직렬화된 샌드박스 상태 자체로는 자격 증명 권한이 부여되지 않습니다. 보호된 마운트 경계에서 SDK는 민감 정보가 제거된 새 예외를 반환합니다. 소스 예외가 정확히 인식되는 SDK 샌드박스 오류이고 승인된 구조화 필드가 검증되면, 대체 예외는 해당 하위 타입과 검증된 안전 필드를 유지합니다. 인식된 `MountConfigError`은 SDK에서 생성한 안전한 검증 메시지도 유지할 수 있습니다. 그 외에는 SDK가 민감 정보가 제거된 새 일반 오류를 반환합니다. 제공자가 제어하거나 그 밖에 승인되지 않은 메시지, 명령 데이터, 참고 사항, 컨텍스트, 원인, 소스 트레이스백 상태는 유지되지 않습니다. [마운트 및 원격 저장소](sandbox/clients.md#mounts-and-remote-storage)와 [세션 상태에서 재개](sandbox/guide.md#resume-from-session-state)를 참고하세요. -- 재시도 정책은 안정적인 재실행 안전성 정보를 검사하고, 제공자가 안전하지 않다고 표시한 비스트리밍 요청에 대해 `RetryDecision(approve_unsafe_replay=True)`을 명시적으로 설정할 수 있습니다. 이 승인은 중단, 이미 방출된 스트리밍 출력 또는 프로그래밍 방식 도구 호출과 같은 별도의 로컬 부수 효과 거부를 우회하지 않습니다. [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참고하세요. -- 이제 재개 가능한 `RunState` 객체는 다음 모델 호출 전에 `add_input()`을 사용해 영속적인 사용자 입력을 준비할 수 있습니다. 준비된 입력은 직렬화 후에도 유지되고 입력 가드레일을 통과하며, 로컬 세션과 서버 관리형 대화 전반에서 영속적인 SDK 입력 발생 1건을 생성합니다. 안전하지 않은 재실행을 명시적으로 승인하면 입력을 제공자에게 다시 전송하고 제공자 측 작업을 반복할 수 있습니다. [재개 전 입력 추가](results.md#add-input-before-resuming)를 참고하세요. -- 런타임 안정성 수정으로 스트리밍 및 비스트리밍 [출력 가드레일 세션 영속성](guardrails.md#output-guardrails)이 일관되게 동작하고, 복사 및 네임스페이스 지정 과정에서 `FunctionTool` 하위 클래스가 유지되며, [지원되지 않는 Chat Completions 오디오 출력](models/index.md#chat-completions-compatibility-options)에 대해 빈 스트림으로 조용히 완료하는 대신 명시적인 오류가 발생합니다. `OpenAIResponsesCompactionSession` 래퍼는 취소가 호출자에게 전달되기 전에 [압축 전 기록 복구](sessions/index.md#auto-compaction-can-block-streaming)를 시도하고 완료될 때까지 기다립니다. 이제 [`VoicePipeline`](voice/pipeline.md#results) 소비자는 정상 실행 이후 발생한 전사 세션 종료 실패를 수신하며, 이전 턴의 실패는 이후 종료 실패보다 우선합니다. 이제 `RunState` 왕복 과정에서 로컬 셸 출력, 승인된 컴퓨터 안전 검사, 기본값이 있는 도구 출력 필드, 딕셔너리·목록·튜플을 순회하며 발견한 Pydantic 모델 또는 데이터 클래스 출력이 유지됩니다. MCP 변환은 자유 형식 객체 스키마와 이미지 출력을 유지하며, 오디오 및 리소스 블록과 같은 기타 raw 콘텐츠 블록을 유효한 JSON 텍스트로 직렬화합니다. `MCPServerManager`는 겹치는 수명 주기 작업을 직렬화하고 연결 및 정리에 유한한 기본 시간 제한을 적용합니다. 모델 재실행은 출력 항목을 입력으로 사용하기 전에 서버가 소유한 `created_by` 메타데이터를 제거합니다. +- 명시적인 에이전트 모델, 실행 수준 모델 재정의, `OPENAI_DEFAULT_MODEL` 환경 변수는 계속 SDK 기본값보다 우선합니다. +- 이제 Realtime 입력 전사 설정은 `gpt-transcribe`, `gpt-live-transcribe`, `gpt-realtime-whisper`를 인식합니다. 지연 시간이 짧은 `gpt-live-transcribe` 세션에서는 중첩된 `audio.input.transcription` 설정을 통해 `prompt`, `keywords`, 여러 개의 예상 `languages`을 제공할 수 있습니다. 이 SDK에서 고정한 OpenAI 클라이언트 버전은 `delay` 지연 시간/정확도 수준을 `gpt-realtime-whisper`에서만 지원합니다. 커밋된 오디오 턴 이후의 전사 또는 감지된 언어 출력을 위해서는 WebSocket에서 `gpt-transcribe`을 사용하세요. `audio.input.turn_detection=None`을 명시적으로 설정하면 자동 턴 감지가 비활성화됩니다. [입력 전사 설정](realtime/guide.md#input-transcription-settings)을 참고하세요. +- 이제 Agents SDK에서 생성한 로컬 MCP 연결은 `mcp>=1.19.0,<3`을 통해 v1 호환성을 유지하면서 MCP Python SDK v2를 지원합니다. Agents SDK는 일반적인 stdio, SSE, Streamable HTTP 연결을 자동으로 조정합니다. MCP v2가 설치된 경우 이러한 연결은 `mcp.Client(mode="auto")`을 사용해 지원되는 최신 프로토콜을 탐색하고, 이전 서버에서는 레거시 `initialize` 핸드셰이크로 대체합니다. 의존성 해석에서 MCP v2가 선택되면 사용자 지정 `httpx.Auth` 객체 또는 `httpx.AsyncClient` 팩토리를 제공하는 애플리케이션은 해당 값을 `httpx2`으로 마이그레이션하거나, v1 HTTP 스택을 유지하도록 `mcp<2`을 고정해야 합니다. `MCPServerStreamableHttp`의 `params["ignore_initialized_notification_failure"] = True` 옵션도 계속 v1에서만 사용할 수 있습니다. 마이그레이션에 대한 자세한 내용은 [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. +- 이제 샌드박스 마운트 검증은 샌드박스 또는 마운트 도우미의 부작용이 발생하기 전에 안전하지 않은 자격 증명 배치를 거부합니다. 신뢰할 수 있는 애플리케이션은 스토리지 기능 테이블을 변경하지 않고도 정확한 컨테이너 내부 마운트 경로에 대해 마운트 범위 또는 광범위한 자격 증명 노출을 확인할 수 있습니다. 이러한 확인은 런타임에만 적용되며, 직렬화된 샌드박스 상태 자체는 자격 증명 권한을 부여하지 않습니다. 보호된 마운트 경계에서 SDK는 새로 편집된 예외를 반환합니다. 원본 예외가 정확히 인식되는 SDK 샌드박스 오류이고 승인된 구조화 필드가 검증되면, 대체 예외는 해당 하위 유형과 검증된 안전한 필드를 유지합니다. 인식된 `MountConfigError`은 SDK가 생성한 안전한 검증 메시지도 유지할 수 있습니다. 그 외에는 SDK가 새로 편집된 일반 오류를 반환합니다. 프로바이더가 제어하거나 승인되지 않은 메시지, 명령 데이터, 참고 사항, 컨텍스트, 원인, 원본 트레이스백 상태는 유지되지 않습니다. [마운트 및 원격 스토리지](sandbox/clients.md#mounts-and-remote-storage)와 [세션 상태에서 재개](sandbox/guide.md#resume-from-session-state)를 참고하세요. +- 재시도 정책은 안정적인 재실행 안전성 정보를 검사하고, 프로바이더가 안전하지 않다고 표시한 비스트리밍 요청에 대해 `RetryDecision(approve_unsafe_replay=True)`을 명시적으로 설정할 수 있습니다. 이 승인은 중단, 이미 내보낸 스트리밍 출력 또는 Programmatic Tool Calling과 같은 별도의 로컬 부작용 거부를 우회하지 않습니다. [Runner 관리형 재시도](models/index.md#runner-managed-retries)를 참고하세요. +- 이제 재개 가능한 `RunState` 객체는 다음 모델 호출 전에 `add_input()`을 사용해 영구 사용자 입력을 스테이징할 수 있습니다. 스테이징된 입력은 직렬화 후에도 유지되고 입력 가드레일을 통과하며, 로컬 세션과 서버 관리형 대화 전체에서 하나의 영구적인 SDK 입력 발생 기록을 생성합니다. 안전하지 않은 재실행을 명시적으로 승인하면 입력이 프로바이더에 다시 전송되고 프로바이더 측 작업이 반복될 수 있습니다. [재개 전 입력 추가](results.md#add-input-before-resuming)를 참고하세요. +- 런타임 안정성 수정으로 스트리밍 및 비스트리밍 [출력 가드레일 세션 영속성](guardrails.md#output-guardrails)이 일치하고, 복사 및 네임스페이스 지정 중에 `FunctionTool` 하위 클래스가 보존되며, 지원되지 않는 [Chat Completions 오디오 출력](models/index.md#chat-completions-compatibility-options)에 대해 빈 스트림을 조용히 완료하는 대신 명시적 오류가 발생합니다. `OpenAIResponsesCompactionSession` 래퍼는 취소가 호출자에게 전달되기 전에 [압축 전 기록 복구](sessions/index.md#auto-compaction-can-block-streaming)를 시도하고 완료될 때까지 기다립니다. 이제 [`VoicePipeline`](voice/pipeline.md#results) 소비자는 실행이 정상적으로 끝난 후 전사 세션 종료 실패를 수신하며, 이전 턴의 실패가 이후 종료 실패보다 우선합니다. 이제 `RunState` 왕복 변환은 로컬 셸 출력, 확인된 컴퓨터 안전 검사, 기본값이 설정된 도구 출력 필드, 딕셔너리·목록·튜플을 순회하는 중 발견한 Pydantic 모델 또는 데이터클래스 출력을 보존합니다. MCP 변환은 자유 형식 객체 스키마와 이미지 출력을 보존하며, 오디오 및 리소스 블록과 같은 기타 raw 콘텐츠 블록을 유효한 JSON 텍스트로 직렬화합니다. `MCPServerManager`는 겹치는 수명 주기 작업을 직렬화하고 연결 및 정리에 유한한 기본 타임아웃을 적용합니다. 모델 재실행은 출력 항목을 입력으로 사용하기 전에 서버 소유 `created_by` 메타데이터를 제거합니다. ### 0.19.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 마이너 버전 증가는 중요한 새 OpenAI Responses 기능 영역인 프로그래밍 방식 도구 호출을 반영합니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새 기능 영역인 Programmatic Tool Calling을 반영합니다. 주요 변경 사항: -- 지원되는 OpenAI Responses 모델이 프로그래밍 방식 도구 호출에 적합한 도구를 조정하기 위한 JavaScript를 생성할 수 있게 해 주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]이 추가되었습니다. 도구별 `allowed_callers`, `FunctionTool` 인스턴스의 structured outputs, Runner 스트리밍, 가드레일, 승인, 세션, `RunState`과의 통합을 지원합니다. 설정 및 제약 조건은 [프로그래밍 방식 도구 호출](tools.md#programmatic-tool-calling)을 참고하세요. -- 공개 `agents.decorators` 모듈과 기존 가드레일 데코레이터에 더해 기존 `@function_tool` 데코레이터의 더 짧은 별칭인 `@tool`가 추가되었습니다. 이제 `FunctionTool` 인스턴스는 비동기 호출 가능 객체도 지원합니다. -- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스, 음성 파이프라인 전반에서 타입이 지정된 설정 객체 또는 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. -- 유용한 디버깅 컨텍스트를 유지하면서 가공되지 않은 민감한 페이로드가 노출되지 않도록 모델, 도구, MCP, 실시간 기능, 세션, 샌드박스, 트레이싱 전반의 오류 및 진단 로깅을 강화했습니다. -- AnyLLM, LiteLLM, Chat Completions 호환성을 개선하고, 모델 재시도 간에 세션 기록을 유지하며, 응답 시작 전에 발생한 WebSocket 과부하에 대한 제공자 재시도 지침을 추가했습니다. 따라서 명시적으로 활성화된 Runner 재시도 정책은 허용되는 경우 실패한 시도를 재실행할 수 있습니다. +- 지원되는 OpenAI Responses 모델이 Programmatic Tool Calling에 적합한 도구를 조정하기 위한 JavaScript를 생성할 수 있게 해주는 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]이 추가되었습니다. 도구별 `allowed_callers`, `FunctionTool` 인스턴스의 structured outputs, Runner 스트리밍, 가드레일, 승인, 세션, `RunState`과의 통합을 지원합니다. 설정 및 제약 조건은 [Programmatic Tool Calling](tools.md#programmatic-tool-calling)을 참고하세요. +- 공개 `agents.decorators` 모듈과 기존 `@function_tool` 데코레이터의 짧은 별칭인 `@tool`이 기존 가드레일 데코레이터와 함께 추가되었습니다. 이제 `FunctionTool` 인스턴스는 비동기 호출 가능 객체도 지원합니다. +- 이제 SDK 구성은 에이전트, 실행, 모델, 세션, 샌드박스, Voice 파이프라인 전반에서 타입이 지정된 설정 객체 또는 딕셔너리를 일관되게 허용하며, 알 수 없는 설정을 검증합니다. +- 유용한 디버깅 컨텍스트를 유지하면서 가공되지 않은 민감한 페이로드가 노출되지 않도록 모델, 도구, MCP, Realtime, 세션, 샌드박스, 트레이싱 전반의 오류 및 진단 로깅이 강화되었습니다. +- AnyLLM, LiteLLM, Chat Completions 호환성이 개선되고 모델 재시도 전반에서 세션 기록이 보존되며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 프로바이더 재시도 안내가 추가되었습니다. 이에 따라 허용되는 경우 명시적으로 활성화한 Runner 재시도 정책이 실패한 시도를 재실행할 수 있습니다. - `VercelCloudBucketMountStrategy`을 통해 [Vercel 샌드박스를 생성할 때만 구성할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)가 추가되었습니다. 마운트된 세션은 워크스페이스 영속성에서 버킷 콘텐츠를 제외하며, 의도적으로 동적 마운트 변경이나 세션 재개를 지원하지 않습니다. ### 0.18.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 마이너 버전 증가는 실시간 에이전트 기본 모델 업데이트만을 위한 것입니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 Realtime 에이전트의 기본 모델 업데이트만을 위한 것입니다. 주요 변경 사항: -- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 실시간 설정에서는 추가 구성 없이 최신 권장 모델을 사용합니다. +- 이제 Realtime 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서는 별도 구성 없이 최신 권장 모델을 사용합니다. ### 0.17.0 -이 버전에서 샌드박스 로컬 소스 구체화는 소스 경로가 `Manifest.extra_path_grants`의 적용 대상이 아닌 한 `LocalFile.src` 및 `LocalDir.src`을 구체화 `base_dir` 내부로 제한합니다. `base_dir`은 매니페스트가 적용되는 시점의 SDK 프로세스 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 그 내부 또는 명시적으로 허용된 경로 아래에 있어야 합니다. 이 변경으로 로컬 아티팩트 경계 문제가 해결되지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에는 영향을 줄 수 있습니다. +이 버전에서 샌드박스 로컬 소스 구체화는 소스 경로가 `Manifest.extra_path_grants`의 적용을 받지 않는 한 `LocalFile.src` 및 `LocalDir.src`을 구체화 `base_dir` 내부로 제한합니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 그 내부에 있거나 명시적 허용 범위 아래에 있어야 합니다. 이 변경은 로컬 아티팩트 경계 문제를 해결하지만, 신뢰할 수 있는 호스트 파일이나 디렉터리를 해당 기본 디렉터리 외부에서 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에 영향을 줄 수 있습니다. -마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`을 사용해 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스가 해당 파일을 읽기만 하면 되는 경우에는 읽기 전용으로 설정하는 것이 좋습니다. +마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`을 사용해 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스가 해당 파일을 읽기만 하면 되는 경우 읽기 전용으로 설정하는 것이 좋습니다. ```python from pathlib import Path @@ -103,11 +116,11 @@ manifest = Manifest( ) ``` -`extra_path_grants`을 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 신뢰할 수 없는 기타 매니페스트 입력으로 허용 목록을 채우지 마세요. +`extra_path_grants`을 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 매니페스트 입력으로 허용 범위를 채우지 마세요. ### 0.16.0 -이 버전에서 SDK 기본 모델은 이제 `gpt-4.1` 대신 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새 기본값은 GPT-5 모델이므로 암시적인 기본 모델 설정에 이제 `reasoning.effort="none"` 및 `verbosity="low"`과 같은 GPT-5 기본값이 포함됩니다. +이 버전에서 SDK 기본 모델은 `gpt-4.1` 대신 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새로운 기본값은 GPT-5 모델이므로 암시적 기본 모델 설정에는 이제 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. 이전 기본 모델 동작을 유지해야 한다면 에이전트 또는 실행 구성에 모델을 명시적으로 설정하거나 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정하세요. @@ -118,13 +131,13 @@ agent = Agent(name="Assistant", model="gpt-4.1") 주요 변경 사항: - 이제 `Runner.run`, `Runner.run_sync`, `Runner.run_streamed`은 턴 제한을 비활성화하는 `max_turns=None`을 허용합니다. -- 이제 샌드박스 워크스페이스 하이드레이션은 로컬, Docker, 제공자 기반 샌드박스 구현 전반에서 절대 심볼릭 링크 대상을 포함하여 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. +- 이제 로컬, Docker, 프로바이더 기반 샌드박스 구현 전체에서 샌드박스 워크스페이스 하이드레이션은 절대 심볼릭 링크 대상을 포함해 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. ### 0.15.0 -이 버전에서 모델 거부는 더 이상 빈 텍스트 출력으로 처리되거나, structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하게 하지 않고 `ModelRefusalError`으로 명시적으로 노출됩니다. +이 버전에서는 이제 모델 거부가 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하게 하는 대신 `ModelRefusalError`으로 명시적으로 노출됩니다. -이 변경은 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. +이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. ```python result = Runner.run_sync( @@ -134,94 +147,94 @@ result = Runner.run_sync( ) ``` -structured outputs 에이전트의 경우 핸들러는 에이전트의 출력 스키마과 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. +structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 스키마과 일치하는 값을 반환할 수 있으며, SDK는 이를 다른 실행 오류 핸들러의 최종 출력과 동일하게 검증합니다. ### 0.14.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않지만**, 샌드박스 에이전트라는 주요 새 베타 기능 영역과 로컬, 컨테이너화, 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드, 문서 지원이 추가됩니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주요한 새 베타 기능 영역인 샌드박스 에이전트와 로컬, 컨테이너화 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드, 문서 지원이 추가되었습니다. 주요 변경 사항: -- `SandboxAgent`, `Manifest`, `SandboxRunConfig`을 중심으로 하는 새로운 베타 샌드박스 런타임 인터페이스가 추가되어 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷, 재개 지원을 갖춘 영속적인 격리 워크스페이스 내부에서 작업할 수 있습니다. -- `UnixLocalSandboxClient` 및 `DockerSandboxClient`을 통해 로컬 및 컨테이너화된 개발을 위한 샌드박스 실행 백엔드가 추가되었으며, Python 패키지의 선택적 종속성 extras를 통해 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, Vercel용 호스팅 제공자 통합도 추가되었습니다. -- 향후 실행에서 이전 실행의 교훈을 재사용할 수 있도록 샌드박스 메모리 지원이 추가되었습니다. 여기에는 점진적 공개, 멀티턴 그룹화, 구성 가능한 격리 경계, S3 기반 워크플로를 포함한 영속 메모리 코드 예제가 포함됩니다. -- 로컬 및 합성 워크스페이스 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 저장소 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하여 더 광범위한 워크스페이스 및 재개 모델이 추가되었습니다. -- `examples/sandbox/` 아래에 기술, 핸드오프, 메모리, 제공자별 설정을 사용하는 코딩 작업과 코드 검토, 데이터룸 QA, 웹사이트 복제 같은 엔드투엔드 워크플로를 다루는 상당한 규모의 샌드박스 코드 예제 및 튜토리얼이 추가되었습니다. -- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값, 더 안전한 민감 MCP 출력 제거 기능으로 핵심 런타임과 트레이싱 스택이 확장되었습니다. +- `SandboxAgent`, `Manifest`, `SandboxRunConfig`을 중심으로 한 새로운 베타 샌드박스 런타임 인터페이스가 추가되어, 에이전트가 파일, 디렉터리, Git 저장소, 마운트, 스냅샷, 재개 지원을 갖춘 영구 격리 워크스페이스 내에서 작업할 수 있습니다. +- `UnixLocalSandboxClient` 및 `DockerSandboxClient`을 통한 로컬 및 컨테이너화 개발용 샌드박스 실행 백엔드와 Python 패키지의 선택적 의존성 extras를 통한 Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, Vercel 호스팅 프로바이더 통합이 추가되었습니다. +- 향후 실행에서 이전 실행의 학습 내용을 재사용할 수 있도록 샌드박스 메모리 지원이 추가되었습니다. 여기에는 점진적 공개, 다중 턴 그룹화, 구성 가능한 격리 경계, S3 기반 워크플로를 포함한 영구 메모리 예제가 포함됩니다. +- 로컬 및 합성 워크스페이스 항목, S3/R2/GCS/Azure Blob Storage/S3 Files용 원격 스토리지 마운트, 이식 가능한 스냅샷, `RunState`, `SandboxSessionState` 또는 저장된 스냅샷을 통한 재개 흐름을 포함하는 더 광범위한 워크스페이스 및 재개 모델이 추가되었습니다. +- `examples/sandbox/` 아래에 스킬, 핸드오프, 메모리, 프로바이더별 설정을 활용한 코딩 작업과 코드 검토, 데이터룸 QA, 웹사이트 복제 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제 및 튜토리얼이 추가되었습니다. +- 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값, 더 안전한 민감한 MCP 출력 편집을 통해 코어 런타임과 트레이싱 스택이 확장되었습니다. ### 0.13.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않지만**, 주목할 만한 실시간 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함됩니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함됩니다. 주요 변경 사항: -- 이제 기본 WebSocket 실시간 모델은 `gpt-realtime-1.5`이므로, 새로운 실시간 에이전트 설정에서는 추가 구성 없이 더 최신 모델을 사용합니다. -- 이제 `MCPServer`은 `list_resources()`, `list_resource_templates()`, `read_resource()`을 노출하고, `MCPServerStreamableHttp`은 `session_id`을 노출합니다. 따라서 MCP Streamable HTTP 전송을 사용하는 세션을 재연결 또는 상태 비저장 워커 간에 재개할 수 있습니다. -- 이제 Chat Completions 통합에서 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠 재전송을 활성화할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 제공자별 추론/도구 호출 연속성이 향상됩니다. -- `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남겨 두는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경합 상태 등 여러 런타임 및 세션 경계 사례를 수정했습니다. +- 이제 기본 websocket Realtime 모델은 `gpt-realtime-1.5`이므로, 새로운 Realtime 에이전트 설정에서는 별도 구성 없이 최신 모델을 사용합니다. +- 이제 `MCPServer`은 `list_resources()`, `list_resource_templates()`, `read_resource()`을 노출하고, `MCPServerStreamableHttp`은 `session_id`을 노출하므로 MCP Streamable HTTP 전송을 사용하는 세션을 재연결 또는 상태 비저장 워커 간에 재개할 수 있습니다. +- 이제 Chat Completions 통합은 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠를 다시 전송하도록 선택할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 프로바이더별 추론/도구 호출 연속성이 향상됩니다. +- `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경쟁 상태를 포함한 여러 런타임 및 세션 경계 사례가 수정되었습니다. ### 0.12.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. ### 0.11.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. ### 0.10.0 -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 도입되지 **않지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 WebSocket 전송 지원이 포함됩니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 websocket 전송 지원이 포함됩니다. 주요 변경 사항: -- OpenAI Responses 모델에 WebSocket 전송 지원이 추가되었습니다. 선택적으로 활성화할 수 있으며 HTTP가 계속 기본 전송 방식입니다. -- 여러 턴의 실행에서 공유 WebSocket 지원 제공자와 `RunConfig`을 재사용할 수 있도록 `responses_websocket_session()` 도우미/`ResponsesWebSocketSession`이 추가되었습니다. -- 스트리밍, 도구, 승인, 후속 턴을 다루는 새로운 WebSocket 스트리밍 코드 예제(`examples/basic/stream_ws.py`)가 추가되었습니다. +- OpenAI Responses 모델에 대한 websocket 전송 지원이 추가되었습니다(선택 사항이며 HTTP가 계속 기본 전송입니다). +- 여러 턴의 실행에서 공유 websocket 지원 프로바이더와 `RunConfig`을 재사용하기 위한 `responses_websocket_session()` 도우미 / `ResponsesWebSocketSession`가 추가되었습니다. +- 스트리밍, 도구, 승인, 후속 턴을 다루는 새로운 websocket 스트리밍 예제(`examples/basic/stream_ws.py`)가 추가되었습니다. ### 0.9.0 -이 버전에서는 해당 메이저 버전이 3개월 전에 EOL에 도달했으므로 Python 3.9가 더 이상 지원되지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. +이 버전에서는 주요 버전의 지원 종료(EOL) 후 3개월이 지났으므로 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. -또한 `Agent#as_tool()` 메서드가 반환하는 값의 타입 힌트가 `Tool`에서 `FunctionTool`으로 좁혀졌습니다. 일반적으로 이 변경으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니언 타입에 의존하는 경우에는 일부 조정이 필요할 수 있습니다. +또한 `Agent#as_tool()` 메서드에서 반환되는 값의 타입 힌트가 `Tool`에서 `FunctionTool`으로 좁혀졌습니다. 이 변경은 일반적으로 호환성을 깨는 문제를 일으키지 않지만, 코드가 더 넓은 유니온 타입에 의존하는 경우 일부 조정이 필요할 수 있습니다. ### 0.8.0 -이 버전에서는 다음 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. +이 버전에서는 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. -- **동기식** Python 호출 가능 객체를 래핑하는 `FunctionTool` 인스턴스는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태 또는 스레드 종속 리소스에 의존한다면 비동기 도구 구현으로 마이그레이션하거나 도구 코드에서 스레드 종속성을 명시하세요. -- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패 동작에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준의 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로 명시적인 핸들러가 있는 각 로컬 MCP 서버에 `failure_error_function=None`을 설정하세요. +- **동기식** Python 호출 가능 객체를 래핑하는 `FunctionTool` 인스턴스는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태 또는 특정 스레드에 종속된 리소스에 의존하는 경우 비동기 도구 구현으로 마이그레이션하거나 도구 코드에 스레드 종속성을 명시하세요. +- 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패하는 의미 체계에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로 명시적 핸들러가 있는 각 로컬 MCP 서버에 `failure_error_function=None`을 설정하세요. ### 0.7.0 -이 버전에는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경 사항이 있습니다. +이 버전에서는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경이 있었습니다. -- 이제 중첩 핸드오프 기록은 **선택적 활성화** 방식이며 기본적으로 비활성화됩니다. v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. -- `gpt-5.1`/`gpt-5.2`의 기본 `reasoning.effort`이 SDK 기본값으로 구성되던 이전 기본값 `"low"`에서 `"none"`으로 변경되었습니다. 프롬프트 또는 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 이를 명시적으로 설정하세요. +- 이제 중첩된 핸드오프 기록은 **명시적으로 활성화해야 합니다**(기본적으로 비활성화됨). v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. +- `gpt-5.1` / `gpt-5.2`의 기본 `reasoning.effort`이 `"none"`으로 변경되었습니다(SDK 기본값으로 구성된 이전 기본값은 `"low"`). 프롬프트 또는 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 명시적으로 설정하세요. ### 0.6.0 -이 버전에서는 사용자와 어시스턴트의 턴을 별도 메시지로 전달하는 대신 기본 핸드오프 기록을 단일 어시스턴트 메시지로 패키징하여, 이후 에이전트에 간결하고 예측 가능한 요약을 제공합니다 -- 기존 단일 메시지 핸드오프 기록은 이제 기본적으로 `` 블록 앞에 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`으로 시작하므로 이후 에이전트가 명확하게 표시된 요약을 받습니다 +이 버전에서 기본 핸드오프 기록은 사용자와 어시스턴트 턴을 별도 메시지로 전달하는 대신 하나의 어시스턴트 메시지로 패키징되므로 이후 에이전트가 간결하고 예측 가능한 요약을 받습니다 +- 기존 단일 메시지 핸드오프 기록은 이제 기본적으로 `` 블록 앞에 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`으로 시작하므로 이후 에이전트가 명확히 표시된 요약을 받습니다 ### 0.5.0 -이 버전에는 눈에 보이는 호환성을 깨는 변경 사항이 도입되지 않지만, 새로운 기능과 몇 가지 중요한 내부 업데이트가 포함됩니다. +이 버전에는 사용자에게 드러나는 호환성을 깨는 변경 사항이 없지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함됩니다. -- [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip)을 처리하기 위한 지원이 `RealtimeRunner`에 추가되었습니다. -- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직을 대폭 수정했습니다. +- `RealtimeRunner`에 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip) 처리 지원이 추가되었습니다. +- Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직이 대폭 수정되었습니다. ### 0.4.0 -이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전이 더 이상 지원되지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. +이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전을 더 이상 지원하지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. ### 0.3.0 -이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. +이 버전에서 Realtime API 지원은 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. ### 0.2.0 -이 버전에서는 이전에 인수로 `Agent`을 받던 일부 위치가 이제 대신 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처에 적용됩니다. 이는 순수한 타입 변경이며 계속 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`을 `AgentBase`으로 바꿔 타입 오류만 수정하면 됩니다. +이 버전에서는 이전에 `Agent`을 인수로 받던 몇몇 위치가 이제 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처가 이에 해당합니다. 이는 순수한 타입 변경이며, 계속 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`을 `AgentBase`로 대체해 타입 오류를 수정하면 됩니다. ### 0.1.0 -이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context` 및 `agent`이라는 두 개의 새로운 매개변수가 있습니다. `MCPServer`의 하위 클래스에서 재정의한 모든 `MCPServer.list_tools()` 메서드에 이 매개변수를 추가해야 합니다. \ No newline at end of file +이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context` 및 `agent`이라는 두 가지 새로운 매개변수가 추가되었습니다. `MCPServer`의 하위 클래스에서 재정의한 모든 `MCPServer.list_tools()` 메서드에 이 매개변수를 추가해야 합니다. \ No newline at end of file diff --git a/docs/ko/results.md b/docs/ko/results.md index c84db2cf58..f87db85513 100644 --- a/docs/ko/results.md +++ b/docs/ko/results.md @@ -6,10 +6,10 @@ search: `Runner.run` 메서드를 호출하면 다음 두 결과 유형 중 하나를 받습니다. -- `Runner.run(...)` 또는 `Runner.run_sync(...)`에서 반환되는 [`RunResult`][agents.result.RunResult] -- `Runner.run_streamed(...)`에서 반환되는 [`RunResultStreaming`][agents.result.RunResultStreaming] +- `Runner.run(...)` 또는 `Runner.run_sync(...)`의 [`RunResult`][agents.result.RunResult] +- `Runner.run_streamed(...)`의 [`RunResultStreaming`][agents.result.RunResultStreaming] -두 유형 모두 [`RunResultBase`][agents.result.RunResultBase]을 상속하며, 이 기본 클래스는 `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()` 같은 공통 결과 인터페이스를 제공합니다. +두 유형 모두 [`RunResultBase`][agents.result.RunResultBase]에서 상속되며, `final_output`, `new_items`, `last_agent`, `raw_responses`, `to_state()` 같은 공통 결과 인터페이스를 제공합니다. `RunResultStreaming`에는 [`stream_events()`][agents.result.RunResultStreaming.stream_events], [`current_agent`][agents.result.RunResultStreaming.current_agent], [`is_complete`][agents.result.RunResultStreaming.is_complete], [`cancel(...)`][agents.result.RunResultStreaming.cancel] 같은 스트리밍 전용 제어 기능이 추가됩니다. @@ -20,11 +20,11 @@ search: | 필요한 항목 | 사용 대상 | | --- | --- | | 사용자에게 표시할 최종 답변 | `final_output` | -| 전체 로컬 대화 기록이 포함된 재실행 가능한 다음 턴 입력 목록 | `to_input_list()` | -| 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 실행 항목 | `new_items` | +| 전체 로컬 대화 기록이 포함된 재생 가능한 다음 턴 입력 목록 | `to_input_list()` | +| 에이전트, 도구, 핸드오프 및 승인 메타데이터를 포함하는 풍부한 실행 항목 | `new_items` | | 일반적으로 다음 사용자 턴을 처리해야 하는 에이전트 | `last_agent` | | `previous_response_id`을 사용하는 OpenAI Responses API 체이닝 | `last_response_id` | -| 대기 중인 승인과 재개 가능한 스냅샷 | `interruptions` 및 `to_state()` | +| 대기 중인 승인 및 재개 가능한 스냅샷 | `interruptions` 및 `to_state()` | | 현재 중첩된 `Agent.as_tool()` 호출에 관한 메타데이터 | `agent_tool_invocation` | | 가공되지 않은 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | @@ -34,13 +34,13 @@ search: - 마지막 에이전트에 `output_type`이 정의되지 않은 경우 `str` - 마지막 에이전트에 출력 유형이 정의된 경우 `last_agent.output_type` 유형의 객체 -- 승인 인터럽션(중단 처리)에서 일시 중지되는 등 최종 출력이 생성되기 전에 실행이 중단된 경우 `None` +- 승인 인터럽션(중단 처리)으로 일시 중지된 경우처럼 최종 출력이 생성되기 전에 실행이 중지된 경우 `None` !!! note - `final_output`의 유형은 `Any`입니다. 핸드오프로 인해 실행을 완료하는 에이전트가 변경될 수 있으므로 SDK는 가능한 출력 유형 전체를 정적으로 알 수 없습니다. + `final_output`은 `Any`로 형식이 지정됩니다. 핸드오프에 따라 실행을 완료하는 에이전트가 달라질 수 있으므로 SDK는 가능한 출력 유형 전체를 정적으로 알 수 없습니다. -스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`가 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참고하세요. +스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`가 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참조하세요. ## 입력, 다음 턴 기록 및 새 항목 @@ -48,45 +48,45 @@ search: | 속성 또는 헬퍼 | 포함 내용 | 적합한 용도 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성했다면 실행이 계속될 때 사용한 필터링된 입력을 반영합니다. | 이 실행에서 실제로 입력으로 사용한 내용 감사 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행을 입력 항목 형태로 보여 줍니다. 기본 `mode="preserve_all"`은 `new_items`에서 변환된 기록을 유지하지만, SDK 기본 중첩 핸드오프 기록으로 이미 이동된 정확히 동일한 세션 항목 인스턴스는 다시 추가하지 않습니다. 핸드오프 필터링이 모델 기록을 다시 작성하는 경우 `mode="normalized"`은 표준 연속 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리 대화 상태, 일반 항목 기록 검사 | -| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프, 승인 메타데이터가 포함된 풍부한 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사, 디버깅 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 반환된 가공되지 않은 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 제공자 수준의 진단 또는 가공되지 않은 응답 검사 | +| [`input`][agents.result.RunResultBase.input] | 이 실행 구간의 기본 입력입니다. 핸드오프 입력 필터가 기록을 다시 작성한 경우 실행이 계속될 때 사용한 필터링된 입력이 반영됩니다. | 이 실행에서 실제로 사용한 입력 감사 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 실행의 입력 항목 뷰입니다. 기본 `mode="preserve_all"`은 `new_items`에서 변환된 기록을 유지하지만, SDK 기본 중첩 핸드오프 기록으로 이미 이동된 정확히 동일한 세션 항목을 두 번째로 추가하지는 않습니다. `mode="normalized"`은 핸드오프 필터링으로 모델 기록이 다시 작성될 때 표준 계속 입력을 우선합니다. | 수동 채팅 루프, 클라이언트 관리형 대화 상태 및 일반 항목 기록 검사 | +| [`new_items`][agents.result.RunResultBase.new_items] | 에이전트, 도구, 핸드오프 및 승인 메타데이터가 포함된 풍부한 [`RunItem`][agents.items.RunItem] 래퍼입니다. | 로그, UI, 감사 및 디버깅 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 실행의 각 모델 호출에서 수집된 가공되지 않은 [`ModelResponse`][agents.items.ModelResponse] 객체입니다. | 제공자 수준 진단 또는 가공되지 않은 응답 검사 | 실제로는 다음과 같이 사용합니다. - 실행을 일반 입력 항목 형태로 확인하려면 `to_input_list()`을 사용합니다. -- 핸드오프 필터링 또는 중첩 핸드오프 기록 재작성 후 다음 `Runner.run(..., input=...)` 호출을 위한 표준 로컬 입력이 필요하면 `to_input_list(mode="normalized")`을 사용합니다. -- SDK가 기록을 로드하고 저장하도록 하려면 [`session=...`](sessions/index.md)을 사용합니다. -- `conversation_id` 또는 `previous_response_id`을 사용하여 OpenAI 서버 관리 상태를 이용하는 경우, 일반적으로 `to_input_list()`을 다시 전송하는 대신 새 사용자 입력만 전달하고 저장된 ID를 재사용합니다. +- 핸드오프 필터링 또는 중첩 핸드오프 기록 재작성 후 다음 `Runner.run(..., input=...)` 호출에 사용할 표준 로컬 입력이 필요하면 `to_input_list(mode="normalized")`을 사용합니다. +- SDK에서 기록을 불러오고 저장하도록 하려면 [`session=...`](sessions/index.md)을 사용합니다. +- `conversation_id` 또는 `previous_response_id`을 사용하여 OpenAI 서버 관리형 상태를 이용하는 경우 일반적으로 `to_input_list()`을 다시 보내지 말고 새 사용자 입력만 전달한 후 저장된 ID를 재사용합니다. - 로그, UI 또는 감사에 사용할 전체 변환 기록이 필요하면 기본 `to_input_list()` 모드 또는 `new_items`을 사용합니다. -SDK 기본 중첩 핸드오프 기록이 메시지 항목을 그대로 보존하는 경우 Sessions, `RunState`, `to_input_list()`은 콘텐츠를 기준으로 중복을 제거하지 않고 정확히 소유된 인스턴스를 추적합니다. 서로 별도로 발생한 동일한 메시지는 별도로 유지되며, 이미 소유된 인스턴스만 다시 추가되지 않습니다. +SDK 기본 중첩 핸드오프 기록에서 메시지 항목을 그대로 보존할 때 Sessions, `RunState`, `to_input_list()`은 콘텐츠를 기준으로 중복 제거하지 않고 정확히 소유된 항목을 추적합니다. 별도로 발생한 동일한 메시지는 별개로 유지되며, 이미 소유된 항목만 두 번째로 추가되지 않습니다. JavaScript SDK와 달리 Python은 실행 중 새로 생성된 모델 형식 항목만 포함하는 별도의 `output` 속성을 제공하지 않습니다. SDK 메타데이터가 필요하면 `new_items`을 사용하고, 가공되지 않은 모델 페이로드가 필요하면 `raw_responses`을 검사합니다. -컴퓨터 도구 항목을 대화 입력으로 다시 제출할 때는 가공되지 않은 Responses 페이로드 형식을 사용합니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 보존하는 반면, `gpt-5.5` 컴퓨터 호출은 일괄 처리된 `actions[]`을 보존할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list] 및 [`RunState`][agents.run_state.RunState]은 모델이 생성한 형식을 그대로 유지하므로, 이러한 항목을 대화 입력으로 수동 재제출하는 작업, 일시 중지/재개 흐름, 저장된 대화 기록이 프리뷰 및 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 여전히 `new_items`에서 `computer_call_output` 항목으로 나타납니다. +컴퓨터 도구 항목을 대화 입력으로 다시 제출할 때는 가공되지 않은 Responses 페이로드 형식을 사용합니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 유지하는 반면, `gpt-5.5` 컴퓨터 호출은 배치된 `actions[]`을 유지할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list] 및 [`RunState`][agents.run_state.RunState]은 모델이 생성한 형식을 그대로 유지하므로 해당 항목을 대화 입력으로 수동 재제출하는 작업, 일시 중지 및 재개 흐름, 저장된 대화 기록이 프리뷰 및 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 계속 `new_items`에서 `computer_call_output` 항목으로 표시됩니다. ### 새 항목 [`new_items`][agents.result.RunResultBase.new_items]은 실행 중 발생한 작업을 가장 풍부한 형태로 보여 줍니다. 일반적인 항목 유형은 다음과 같습니다. -- 재개된 모델 호출 직전에 `RunState.pending_input`에서 수용된 입력을 나타내는 [`InputItem`][agents.items.InputItem] +- 재개된 모델 호출 직전에 `RunState.pending_input`에서 허용된 입력을 나타내는 [`InputItem`][agents.items.InputItem] - 어시스턴트 메시지를 나타내는 [`MessageOutputItem`][agents.items.MessageOutputItem] - 추론 항목을 나타내는 [`ReasoningItem`][agents.items.ReasoningItem] -- Responses 도구 검색 요청 및 로드된 도구 검색 결과를 나타내는 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 및 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] -- 도구 호출과 그 결과를 나타내는 [`ToolCallItem`][agents.items.ToolCallItem] 및 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] +- Responses 도구 검색 요청과 불러온 도구 검색 결과를 나타내는 [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 및 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem] +- 도구 호출 및 그 결과를 나타내는 [`ToolCallItem`][agents.items.ToolCallItem] 및 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] - 승인을 위해 일시 중지된 도구 호출을 나타내는 [`ToolApprovalItem`][agents.items.ToolApprovalItem] - 호스티드 MCP 승인 및 도구 카탈로그를 나타내는 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem], [`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem], [`MCPListToolsItem`][agents.items.MCPListToolsItem] -- 핸드오프 요청과 완료된 전달을 나타내는 [`HandoffCallItem`][agents.items.HandoffCallItem] 및 [`HandoffOutputItem`][agents.items.HandoffOutputItem] +- 핸드오프 요청 및 완료된 전환을 나타내는 [`HandoffCallItem`][agents.items.HandoffCallItem] 및 [`HandoffOutputItem`][agents.items.HandoffOutputItem] 에이전트 연결 관계, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 `to_input_list()`보다 `new_items`을 선택합니다. -호스티드 도구 검색을 사용할 때는 `ToolSearchCallItem.raw_item`을 검사하여 모델이 생성한 검색 요청을 확인하고, `ToolSearchOutputItem.raw_item`을 검사하여 해당 턴에 로드된 네임스페이스, 함수 또는 호스티드 MCP 서버를 확인합니다. +호스티드 툴 검색을 사용할 때는 모델이 생성한 검색 요청을 확인하려면 `ToolSearchCallItem.raw_item`을 검사하고, 해당 턴에 불러온 네임스페이스, 함수 또는 호스티드 MCP 서버를 확인하려면 `ToolSearchOutputItem.raw_item`을 검사합니다. -Programmatic Tool Calling을 사용할 때 생성된 `program`은 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출 역시 `ToolCallItem` 항목이며, 이에 대응하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램 소유의 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외로, `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. +프로그래밍 방식 도구 호출을 사용하면 생성된 `program`은 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출도 `ToolCallItem` 항목이며, 일치하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램이 소유한 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외이며, 각각 `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. -가공되지 않은 항목은 유형이 지정된 Responses 객체 또는 매핑일 수 있습니다. 특히 프로그램 소유의 셸 및 패치 적용 호출은 매핑을 사용합니다. 매핑에 안전한 다음 검사 패턴을 사용합니다. +가공되지 않은 항목은 형식이 지정된 Responses 객체 또는 매핑일 수 있습니다. 특히 프로그램이 소유한 셸 및 패치 적용 호출은 매핑을 사용합니다. 매핑에 안전한 검사 패턴을 사용하세요. ```python from collections.abc import Mapping @@ -108,23 +108,23 @@ caller_id = ( ) ``` -프로그램 소유 하위 호출의 경우 `caller`의 `type` 필드는 `program`이고, `caller_id`은 상위 프로그램 호출을 식별합니다. +프로그램이 소유한 하위 호출의 경우 `caller`의 `type` 필드는 `program`이며, `caller_id`은 상위 프로그램 호출을 식별합니다. ## 대화 계속 또는 재개 ### 다음 턴 에이전트 -[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 핸드오프 후 다음 사용자 턴에서 재사용할 에이전트로 적합한 경우가 많습니다. +[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 핸드오프 후 다음 사용자 턴에 재사용할 에이전트로 적합한 경우가 많습니다. -스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로 스트림이 완료되기 전에 핸드오프를 관찰할 수 있습니다. +스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]이 업데이트되므로 스트림이 끝나기 전에 핸드오프를 확인할 수 있습니다. ### 인터럽션(중단 처리) 및 실행 상태 -도구에 승인이 필요한 경우 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. +도구에 승인이 필요한 경우 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 호출된 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. -[`to_state()`][agents.result.RunResult.to_state]를 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]을 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`을 사용하여 재개합니다. +재개 가능한 [`RunState`][agents.run_state.RunState]을 캡처하려면 [`to_state()`][agents.result.RunResult.to_state]을 호출하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`로 재개합니다. -[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] 출력이 Pydantic 모델 또는 데이터 클래스인 경우 `RunState`은 해당 출력을 structured outputs로 직렬화합니다. `RunState`은 딕셔너리, 목록, 튜플도 순회하며 해당 컨테이너에서 발견한 Pydantic 모델 또는 데이터 클래스를 변환합니다. 튜플은 JSON 왕복 변환 후 목록으로 복원됩니다. JSON과 호환되지 않는 다른 값은 문자열 표현으로 대체될 수 있으므로, 정확한 사용자 지정 유형이 직렬화 후에도 유지되어야 한다면 명시적으로 JSON과 호환되는 데이터를 반환합니다. +[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] 출력이 Pydantic 모델 또는 데이터 클래스인 경우 `RunState`은 해당 출력을 structured data로 직렬화합니다. `RunState`은 딕셔너리, 목록, 튜플도 순회하며 해당 컨테이너에서 발견한 Pydantic 모델 또는 데이터 클래스를 변환합니다. 튜플은 JSON 왕복 처리 후 목록으로 복원됩니다. JSON과 호환되지 않는 다른 값은 문자열 표현으로 대체될 수 있으므로 사용자 지정 유형을 직렬화 후에도 정확하게 유지해야 한다면 명시적으로 JSON과 호환되는 데이터를 반환하세요. ```python from agents import Agent, Runner @@ -141,7 +141,7 @@ if result.interruptions: #### 재개 전 입력 추가 -실행이 일시 중지되거나 완료된 턴 이후 중단되었지만 완료되지 않은 실행이 다음 모델 호출에 도달하기 전에 새 사용자 입력이 도착한 경우 [`RunState.add_input()`][agents.run_state.RunState.add_input]을 사용합니다. 문자열은 사용자 메시지가 되며 여러 번 호출하면 삽입 순서가 유지됩니다. 준비된 입력은 직렬화된 `RunState`의 일부이므로 `to_json()` / `from_json()` 및 `to_string()` / `from_string()` 왕복 변환 후에도 유지됩니다. +실행이 일시 중지되거나 완료된 턴 이후 중지된 다음, 완료되지 않은 실행이 다음 모델 호출에 도달하기 전에 새 사용자 입력이 도착하면 [`RunState.add_input()`][agents.run_state.RunState.add_input]을 사용합니다. 문자열은 사용자 메시지가 되며 여러 번 호출하면 삽입 순서가 유지됩니다. 준비된 입력은 직렬화된 `RunState`의 일부이므로 `to_json()` / `from_json()` 및 `to_string()` / `from_string()` 왕복 처리 후에도 유지됩니다. ```python state = result.to_state() @@ -153,70 +153,72 @@ for interruption in state.get_interruptions(): result = await Runner.run(agent, state) ``` -재개 시 러너는 현재 에이전트의 입력 가드레일과 [`RunConfig`][agents.run.RunConfig]의 입력 가드레일을 준비된 입력에만 적용합니다. 클라이언트 관리형 [`Session`][agents.memory.session.Session]이 구성된 경우 러너는 수용된 준비 입력을 영구적인 [`InputItem`][agents.items.InputItem]으로 변환하고, 모델 요청을 보내기 전에 세션 쓰기가 완료되기를 기다립니다. 클라이언트 관리형 세션이나 서버 관리형 대화가 없으면 러너는 모델 요청을 보내기 전에 수용된 준비 입력을 `InputItem`으로 변환합니다. 서버 관리형 대화에서는 서버 요청이 입력을 수락할 때까지 입력이 대기 상태로 유지됩니다. 직렬화, 재개 및 재실행에 안전한 재시도 전반에서 SDK는 하나의 영구적인 `InputItem` 인스턴스를 보존합니다. 이 SDK 인스턴스 보장은 제공자 전달 보장이 아닙니다. 요청이 제공자에게 도달했을 가능성이 있는 상태에서 재시도 정책이 `RetryDecision(approve_unsafe_replay=True)`을 반환하면 러너가 준비된 입력을 다시 전송할 수 있고 제공자 측 작업이 반복될 수 있습니다. 성공적으로 수용된 입력은 `new_items`에 `InputItem`으로 나타납니다. 분리된 복사본을 가져오려면 [`RunState.pending_input`][agents.run_state.RunState.pending_input]을 읽고, 재개하기 전에 준비된 입력을 모두 삭제하려면 [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input]을 호출합니다. +재개 시 러너는 준비된 입력에만 현재 에이전트의 입력 가드레일과 [`RunConfig`][agents.run.RunConfig]의 입력 가드레일을 모두 적용합니다. 클라이언트 관리형 [`Session`][agents.memory.session.Session]이 구성된 경우 러너는 허용된 준비 입력을 영구 [`InputItem`][agents.items.InputItem]으로 변환하고, 세션 쓰기가 완료될 때까지 기다린 후 모델 요청을 전송합니다. 클라이언트 관리형 세션이나 서버 관리형 대화가 없으면 러너는 모델 요청을 전송하기 전에 허용된 준비 입력을 `InputItem`로 변환합니다. 서버 관리형 대화에서는 서버 요청이 입력을 수락할 때까지 입력이 대기 상태로 유지됩니다. 직렬화, 재개 및 재생에 안전한 재시도 전반에서 SDK는 하나의 영구적인 `InputItem` 항목을 유지합니다. 이 SDK 항목 보장은 제공자 전달 보장이 아닙니다. 요청이 제공자에게 도달했을 가능성이 있는 상황에서 재시도 정책이 `RetryDecision(approve_unsafe_replay=True)`을 반환하면 러너가 준비된 입력을 다시 보낼 수 있으며 제공자 측 작업이 반복될 수 있습니다. 성공적으로 허용된 입력은 `new_items`에 `InputItem`으로 표시됩니다. 분리된 복사본을 가져오려면 [`RunState.pending_input`][agents.run_state.RunState.pending_input]을 읽고, 재개하기 전에 준비된 입력을 모두 삭제하려면 [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input]을 호출합니다. -`RunState.add_input()`은 종료 상태, 남은 모델 턴이 없는 상태, 수락된 모델 응답이 로컬 처리를 기다리는 상태, 대기 중인 도구 결과가 다른 모델 호출 전에 실행을 종료할 수 있는 인터럽션(중단 처리) 상태를 거부합니다. 이러한 경우에는 현재 실행을 완료하고 새 사용자 턴을 시작합니다. +`RunState.add_input()`은 종료 상태, 남은 모델 턴이 없는 상태, 수락된 모델 응답이 로컬 처리를 기다리는 상태, 대기 중인 도구 결과가 다른 모델 호출 전에 실행을 종료할 수 있는 인터럽션(중단 처리) 상태를 거부합니다. 이러한 경우에는 현재 실행을 완료한 후 새 사용자 턴을 시작하세요. -스트리밍 실행에서는 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 소비를 완료한 다음 `result.interruptions`을 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)를 참고하세요. +스트리밍 실행의 경우 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 소비를 완료한 다음 `result.interruptions`을 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)를 참조하세요. -### 서버 관리형 연속 실행 +### 서버 관리형 계속 -[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 가장 최근 모델 응답의 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이를 `previous_response_id`으로 다시 전달합니다. +[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 가장 최근의 모델 응답 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이 ID를 `previous_response_id`으로 다시 전달합니다. -이미 `to_input_list()`, `session` 또는 `conversation_id`을 사용하여 대화를 계속하고 있다면 일반적으로 `last_response_id`은 필요하지 않습니다. 여러 단계로 이루어진 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`을 검사합니다. +이미 `to_input_list()`, `session` 또는 `conversation_id`을 사용하여 대화를 계속하고 있다면 일반적으로 `last_response_id`은 필요하지 않습니다. 여러 단계로 이루어진 실행의 모든 모델 응답이 필요하면 `raw_responses`을 검사합니다. -## 도구로 사용하는 에이전트 메타데이터 +## 에이전트 도구 메타데이터 -중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 반환되면 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 이를 둘러싼 `Agent.as_tool()` 호출에 관한 변경 불가능한 메타데이터를 제공합니다. +중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 나온 경우 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 이를 둘러싼 `Agent.as_tool()` 호출에 관한 변경 불가능한 메타데이터를 제공합니다. - `tool_name` - `tool_call_id` - `tool_arguments` -일반적인 최상위 실행에서 `agent_tool_invocation`는 `None`입니다. +일반적인 최상위 실행에서 `agent_tool_invocation`은 `None`입니다. -이는 중첩된 결과를 후처리하면서 이를 둘러싼 `Agent.as_tool()` 호출의 도구 이름, 호출 ID 또는 가공되지 않은 인수가 필요할 수 있는 `custom_output_extractor` 내부에서 특히 유용합니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참고하세요. +이는 중첩된 결과를 후처리하면서 이를 둘러싼 `Agent.as_tool()` 호출의 도구 이름, 호출 ID 또는 가공되지 않은 인수가 필요할 수 있는 `custom_output_extractor` 내부에서 특히 유용합니다. 관련 `Agent.as_tool()` 패턴은 [도구](tools.md)를 참조하세요. -해당 중첩 실행에서 파싱된 구조화 입력도 필요하면 `context_wrapper.tool_input`을 읽습니다. 이는 [`RunState`][agents.run_state.RunState]이 중첩 도구 입력을 위해 일반적으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출의 메타데이터를 결과에 직접 노출합니다. +해당 중첩 실행에 대해 파싱된 structured input도 필요한 경우 `context_wrapper.tool_input`을 읽습니다. 이 필드는 [`RunState`][agents.run_state.RunState]이 중첩 도구 입력을 위해 일반적인 방식으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출의 메타데이터를 결과에 직접 노출합니다. ## 스트리밍 수명 주기 및 진단 -[`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 인터페이스를 상속하지만 다음과 같은 스트리밍 전용 제어 기능을 추가합니다. +[`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 인터페이스를 상속하지만 다음과 같은 스트리밍 전용 제어 기능이 추가됩니다. - 의미론적 스트림 이벤트를 소비하는 [`stream_events()`][agents.result.RunResultStreaming.stream_events] -- 실행 중 활성 에이전트를 추적하는 [`current_agent`][agents.result.RunResultStreaming.current_agent] -- 스트리밍된 실행이 완전히 완료되었는지 확인하는 [`is_complete`][agents.result.RunResultStreaming.is_complete] +- 실행 도중 활성 에이전트를 추적하는 [`current_agent`][agents.result.RunResultStreaming.current_agent] +- 스트리밍 실행이 완전히 종료되었는지 확인하는 [`is_complete`][agents.result.RunResultStreaming.is_complete] - 실행을 즉시 또는 현재 턴 이후 중지하는 [`cancel(...)`][agents.result.RunResultStreaming.cancel] -비동기 이터레이터가 완료될 때까지 `stream_events()`을 계속 소비합니다. 이 이터레이터가 끝날 때까지 스트리밍 실행은 완료되지 않으며, 마지막으로 표시되는 토큰이 도착한 뒤에도 `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 영속화 부수 효과가 아직 처리 중일 수 있습니다. +비동기 반복기가 종료될 때까지 `stream_events()`을 계속 소비합니다. 해당 반복기가 종료되기 전까지 스트리밍 실행은 완료되지 않으며, 마지막으로 표시되는 토큰이 도착한 후에도 `final_output`, `interruptions`, `raw_responses` 같은 요약 속성과 세션 지속성 부수 효과가 계속 처리 중일 수 있습니다. -`cancel()`을 호출한 경우 취소 및 정리가 올바르게 완료될 수 있도록 `stream_events()`을 계속 소비합니다. +`cancel()`을 호출한 경우 취소 및 정리가 올바르게 완료되도록 `stream_events()`을 계속 소비합니다. -Python은 별도의 스트리밍된 `completed` 프로미스 또는 `error` 속성을 제공하지 않습니다. 실행을 종료시키는 스트리밍 실패는 `stream_events()`에서 예외로 발생하며, `is_complete`은 실행이 종료 상태에 도달했는지를 나타냅니다. +Python은 별도의 스트리밍된 `completed` 프로미스나 `error` 속성을 제공하지 않습니다. 실행을 종료시키는 스트리밍 실패는 `stream_events()`에서 발생하며, `is_complete`은 실행이 종료 상태에 도달했는지를 나타냅니다. ### 가공되지 않은 응답 -[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 가공되지 않은 모델 응답이 포함됩니다. 여러 단계로 이루어진 실행은 핸드오프 또는 반복되는 모델/도구/모델 주기 등으로 인해 둘 이상의 응답을 생성할 수 있습니다. +[`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 가공되지 않은 모델 응답이 포함됩니다. 여러 단계로 이루어진 실행에서는 핸드오프 또는 반복되는 모델/도구/모델 주기에 걸쳐 둘 이상의 응답이 생성될 수 있습니다. -[`last_response_id`][agents.result.RunResultBase.last_response_id]는 `raw_responses`의 마지막 항목에 있는 ID일 뿐입니다. +[`last_response_id`][agents.result.RunResultBase.last_response_id]는 `raw_responses`의 마지막 항목에서 가져온 ID일 뿐입니다. 각 [`ModelResponse`][agents.items.ModelResponse]은 해당 개별 모델 호출에 적용되는 두 가지 진단 정보도 제공합니다. -- [`request_id`][agents.items.ModelResponse.request_id]는 모델 어댑터와 전송 계층이 요청 ID를 전파하는 경우의 전송 요청 ID입니다. 기본 제공되는 `OpenAIResponsesModel` 및 `OpenAIChatCompletionsModel`은 HTTP 및 SSE 전송 경로에서 사용 가능한 서버 생성 `x-request-id`을 전파합니다. 구성된 엔드포인트가 OpenAI API인 경우 프로덕션에서 `None`이 아닌 값을 기록하여 장애를 OpenAI 지원팀과 연관 지을 수 있도록 합니다. OpenAI 호환 제공자 또는 프록시의 경우에는 해당 서비스의 지원 채널을 사용합니다. 현재 `OpenAIResponsesWSModel`은 `request_id`을 `None`으로 둡니다. 서드 파티 어댑터는 요청 ID 전파를 보장하지 않습니다. AnyLLM Chat Completions 어댑터와 `LitellmModel`은 현재 `request_id`을 `None`으로 둡니다. Agents SDK AnyLLM Responses 어댑터도 전송 요청 ID를 보존하지 않고 제공자 응답을 정규화하는 경우 `request_id`을 `None`으로 둘 수 있습니다. -- [`raw_usage`][agents.items.ModelResponse.raw_usage]는 Agents SDK가 페이로드를 정규화하기 전 제공자의 사용량 페이로드를 JSON 호환 형식으로 캡처한 옵트인 스냅샷입니다. `ModelSettings(preserve_raw_usage=True)`을 사용하여 `raw_usage`을 활성화합니다. [제공자 사용량 페이로드 보존](usage.md#preserving-provider-usage-payloads)을 참고하세요. +- [`request_id`][agents.items.ModelResponse.request_id]는 모델 어댑터와 전송 계층에서 요청 ID를 전파할 때의 전송 요청 ID입니다. 기본 제공 `OpenAIResponsesModel` 및 `OpenAIChatCompletionsModel`는 HTTP 및 SSE 전송 경로에서 사용 가능한 서버 생성 `x-request-id`을 전파합니다. 구성된 엔드포인트가 OpenAI API인 경우 프로덕션에서 `None`이 아닌 값을 기록하면 장애를 OpenAI 지원팀과 연관 지어 조사할 수 있습니다. OpenAI 호환 제공자 또는 프록시의 경우 해당 서비스의 지원 채널을 대신 사용하세요. `OpenAIResponsesWSModel`은 현재 `request_id`을 `None`으로 유지합니다. 서드 파티 어댑터는 요청 ID 전파를 보장하지 않습니다. AnyLLM Chat Completions 어댑터와 `LitellmModel`은 현재 `request_id`을 `None`으로 유지합니다. Agents SDK AnyLLM Responses 어댑터도 전송 요청 ID를 보존하지 않고 제공자 응답을 정규화할 때 `request_id`을 `None`으로 유지할 수 있습니다. +- [`raw_usage`][agents.items.ModelResponse.raw_usage]는 Agents SDK가 페이로드를 정규화하기 전 제공자의 사용량 페이로드를 캡처한 선택적 JSON 호환 스냅샷입니다. `ModelSettings(preserve_raw_usage=True)`을 사용하여 `raw_usage`을 활성화하세요. [제공자 사용량 페이로드 보존](usage.md#preserving-provider-usage-payloads)을 참조하세요. -`ModelResponse.request_id`과 `ModelResponse.raw_usage`은 각각 `None`일 수 있으므로 이러한 값은 대화 상태가 아닌 선택적 진단 정보로 처리합니다. +`ModelResponse.request_id`과 `ModelResponse.raw_usage`은 각각 `None`일 수 있으므로 이러한 값을 대화 상태가 아닌 선택적 진단 정보로 처리합니다. ### 가드레일 결과 에이전트 수준 가드레일은 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 및 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results]로 제공됩니다. -도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도로 제공됩니다. +도구 가드레일은 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 및 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results]로 별도 제공됩니다. -이러한 배열은 실행 전반에 걸쳐 누적되므로 결정 사항 기록, 추가 가드레일 메타데이터 저장 또는 실행이 차단된 이유 디버깅에 유용합니다. +이러한 배열은 실행 전반에 걸쳐 누적되므로 의사 결정을 기록하거나, 추가 가드레일 메타데이터를 저장하거나, 실행이 차단된 이유를 디버깅하는 데 유용합니다. + +에이전트 수준 출력 가드레일이 종료 함수 도구에서 직접 생성된 최종 출력을 차단할 때는 하나의 수정 규칙이 적용됩니다. 차단된 현재 응답의 경우 `output_guardrail_results`은 거부된 에이전트 출력을 대체하고 페이로드가 포함된 출력 메타데이터를 지우며, `tool_output_guardrail_results`은 페이로드가 포함된 도구 메타데이터를 대체합니다. 이전에 수락된 결과는 변경되지 않습니다. 정제된 출력 가드레일 결과는 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]의 `guardrail_result`로 제공됩니다. 정제된 출력 가드레일 및 도구 출력 가드레일 결과는 스트리밍 결과 상태와 `RunState`을 통해서도 제공됩니다. [출력 가드레일](guardrails.md#output-guardrails)을 참조하세요. ### 컨텍스트 및 사용량 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper]은 승인, 사용량, 중첩된 `tool_input` 같은 SDK 관리 런타임 메타데이터와 함께 애플리케이션 컨텍스트를 제공합니다. +[`context_wrapper`][agents.result.RunResultBase.context_wrapper]은 승인, 사용량, 중첩된 `tool_input` 같은 SDK 관리형 런타임 메타데이터와 함께 애플리케이션 컨텍스트를 제공합니다. -사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행에서는 스트림의 마지막 청크가 처리될 때까지 사용량 합계 반영이 지연될 수 있습니다. 전체 래퍼 구조와 영속성 관련 주의 사항은 [컨텍스트 관리](context.md)를 참고하세요. \ No newline at end of file +사용량은 `context_wrapper.usage`에서 추적됩니다. 스트리밍 실행에서는 스트림의 최종 청크 처리가 완료될 때까지 사용량 합계가 지연될 수 있습니다. 전체 래퍼 형식과 지속성 관련 주의 사항은 [컨텍스트 관리](context.md)를 참조하세요. \ No newline at end of file diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index fa200a5674..690188856b 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -7,8 +7,8 @@ search: [`Runner`][agents.run.Runner] 클래스를 통해 에이전트를 실행할 수 있습니다. 다음 3가지 옵션이 있습니다. 1. [`Runner.run()`][agents.run.Runner.run]: 비동기로 실행되며 [`RunResult`][agents.result.RunResult]를 반환합니다. -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 메서드이며 내부적으로 `.run()`을 실행합니다. -3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. LLM을 스트리밍 모드로 호출하고 이벤트가 수신되는 즉시 스트리밍합니다. +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]: 동기 메서드이며 내부적으로 `.run()`를 실행합니다. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]: 비동기로 실행되며 [`RunResultStreaming`][agents.result.RunResultStreaming]을 반환합니다. 스트리밍 모드로 LLM을 호출하고, 이벤트가 수신되는 즉시 스트리밍합니다. ```python from agents import Agent, Runner @@ -25,44 +25,44 @@ async def main(): 자세한 내용은 [결과 가이드](results.md)를 참조하세요. -## Runner 수명 주기 및 구성 +## 실행기 수명 주기 및 구성 ### 에이전트 루프 -위의 세 가지 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. +위 세 가지 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. - 문자열(사용자 메시지로 처리) - OpenAI Responses API 형식의 입력 항목 목록 -- 일시 중지된 실행 또는 `cancel(mode="after_turn")`로 중단된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState]. 이 상태에는 [다음 재개 모델 호출을 위해 준비된 입력](results.md#add-input-before-resuming)도 포함할 수 있습니다. +- 일시 중지된 실행이나 `cancel(mode="after_turn")`로 중단된 실행을 재개할 때 사용하는 [`RunState`][agents.run_state.RunState]. 상태에는 [다음 재개 모델 호출을 위해 준비된 입력](results.md#add-input-before-resuming)도 포함될 수 있습니다. -그런 다음 Runner는 다음 루프를 실행합니다. +그런 다음 실행기는 다음과 같은 루프를 실행합니다. -1. 현재 입력과 함께 현재 에이전트에 대해 LLM을 호출합니다. +1. 현재 입력으로 현재 에이전트의 LLM을 호출합니다. 2. LLM이 출력을 생성합니다. - 1. Runner가 LLM의 출력을 최종 출력으로 분류하면 루프가 종료되고 결과를 반환합니다. + 1. 실행기가 LLM의 출력을 최종 출력으로 분류하면 루프를 종료하고 결과를 반환합니다. 2. LLM이 핸드오프를 요청하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. - 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. -3. 전달된 `max_turns`을 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. + 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 다음 루프를 다시 실행합니다. +3. 전달된 `max_turns`을 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외를 발생시킵니다. 이 턴 제한을 비활성화하려면 `max_turns=None`를 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 기준은 원하는 유형의 텍스트 출력을 생성하며 도구 호출이 없는 경우입니다. + LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 타입의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. ### 스트리밍 -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 생성된 모든 새 출력을 포함한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`을 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트를 추가로 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 비롯한 실행의 전체 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송(선택적 헬퍼) +#### Responses WebSocket 전송(선택적 도우미) -OpenAI Responses WebSocket 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결 재사용을 위해 WebSocket 세션 헬퍼를 사용하는 것이 권장되지만 필수는 아닙니다. +OpenAI Responses websocket 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결을 재사용하려면 websocket 세션 도우미를 사용하는 것이 좋지만 필수는 아닙니다. -이는 WebSocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. +이는 websocket 전송을 통한 Responses API이며 [Realtime API](realtime/guide.md)가 아닙니다. -전송 선택 규칙과 구체적인 모델 객체 또는 사용자 지정 프로바이더 관련 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. +구체적인 모델 객체 또는 사용자 지정 공급자와 관련된 전송 선택 규칙 및 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 헬퍼 미사용 +##### 패턴 1: 세션 도우미 없음(작동함) -WebSocket 전송만 사용하고 SDK가 공유 프로바이더나 세션을 관리할 필요가 없을 때 사용합니다. +websocket 전송만 필요하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복해서 호출하면 동일한 `RunConfig` / 프로바이더 인스턴스를 직접 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. +이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복적으로 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. -##### 패턴 2: `responses_websocket_session()` 사용(다중 턴 재사용에 권장) +##### 패턴 2: `responses_websocket_session()` 사용(여러 턴 재사용에 권장) -여러 실행에 걸쳐 WebSocket을 지원하는 공유 프로바이더와 `RunConfig`을 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 동일한 `run_config`을 상속하는 중첩된 에이전트-도구 호출도 포함됩니다. +여러 실행에서 websocket을 지원하는 공유 공급자와 `RunConfig`을 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 에이전트 도구 호출도 포함됩니다. ```python import asyncio @@ -119,11 +119,11 @@ async def main(): asyncio.run(main()) ``` -컨텍스트가 종료되기 전에 스트리밍된 결과를 모두 소비하세요. WebSocket 요청이 아직 진행 중인 상태에서 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. +컨텍스트가 종료되기 전에 스트리밍 결과 사용을 완료하세요. websocket 요청이 아직 진행 중일 때 컨텍스트를 종료하면 공유 연결이 강제로 닫힐 수 있습니다. -서비스는 각 WebSocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 헬퍼는 연결을 재사용하지만 이러한 제약을 제거하지는 않습니다. 재연결 후 `store=False` 및 ZDR 흐름에서는 캐시되지 않은 `previous_response_id`을 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 바탕으로 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. +서비스는 각 websocket 연결에서 한 번에 하나의 응답을 처리하며 연결 시간을 60분으로 제한합니다. 도우미는 연결을 재사용하지만 이러한 제약을 없애지는 않습니다. 재연결 후에는 `store=False` 및 ZDR 흐름에서 캐시되지 않은 `previous_response_id`를 복구할 수 없습니다. 전체 입력 컨텍스트로 새 체인을 시작하거나 로컬에서 관리하는 세션 상태를 사용하여 다시 구성하세요. 전체 복구 동작은 [Responses WebSocket 전송 참고 사항](models/index.md#responses-websocket-transport)을 참조하세요. -긴 추론 턴에서 WebSocket 연결 유지 시간 초과가 발생하면 `ping_timeout`을 늘리거나 `ping_timeout=None`으로 설정하여 하트비트 시간 초과를 비활성화하세요. WebSocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. +긴 추론 턴에서 websocket keepalive 시간 초과가 발생하면 `ping_timeout`를 늘리거나 `ping_timeout=None`으로 설정하여 heartbeat 시간 초과를 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. ### 실행 구성 @@ -133,45 +133,45 @@ asyncio.run(main()) 각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`을 사용하세요. -##### 모델, 프로바이더 및 세션 기본값 +##### 모델, 공급자 및 세션 기본값 -- [`model`][agents.run.RunConfig.model]: 각 에이전트가 보유한 `model`과 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. -- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하기 위한 모델 프로바이더이며 기본값은 OpenAI입니다. -- [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`을 설정할 수 있습니다. +- [`model`][agents.run.RunConfig.model]: 각 에이전트가 어떤 `model`을 갖는지와 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. +- [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며 기본값은 OpenAI입니다. +- [`model_settings`][agents.run.RunConfig.model_settings]: 에이전트별 설정을 재정의합니다. 예를 들어 전역 `temperature` 또는 `top_p`를 설정할 수 있습니다. - [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 `Runner` 실행 전에 새 사용자 입력을 세션 기록과 병합하는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 `Runner` 실행 전에 새 사용자 입력이 세션 기록과 병합되는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. -##### 가드레일, 핸드오프 및 모델 입력 구성 +##### 가드레일, 핸드오프 및 모델 입력 조정 - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 자체 입력 필터가 아직 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 손실 없이 보존되는 메시지 항목을 원래 위치에 유지하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하는 옵트인 베타 기능입니다. 중첩된 핸드오프를 안정화하는 동안에는 기본적으로 비활성화되어 있습니다. 활성화하려면 `True`으로 설정하고, raw 트랜스크립트를 그대로 전달하려면 `False`으로 두세요. Sessions, `RunState`, `RunResult.to_input_list()`은 SDK 기본 중첩 기록에 이미 포함된 동일한 메시지 항목을 두 번 추가하지 않으면서도 서로 별개인 동일 메시지는 보존합니다. [Runner 메서드][agents.run.Runner]는 명시적으로 전달하지 않으면 모두 자동으로 `RunConfig`을 생성하므로 빠른 시작과 코드 예제에서는 기본값이 비활성화된 상태로 유지되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이 설정을 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]를 통해 이 설정을 재정의할 수 있습니다. -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`을 옵트인할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 받는 선택적 호출 가능 객체입니다. 전체 핸드오프 필터를 작성하지 않고도 기본 제공 순서형 요약 세그먼트를 대체하며, 다음 에이전트로 전달할 정확한 입력 항목 목록을 반환해야 합니다. -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. -- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: Runner가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 이미 입력 필터가 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: 다음 에이전트를 호출하기 전에 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축하면서 손실 없는 메시지 항목은 원래 위치에 보존하는 옵트인 베타 기능입니다. 중첩 핸드오프를 안정화하는 동안에는 기본적으로 비활성화됩니다. 활성화하려면 `True`로 설정하고, raw 트랜스크립트를 그대로 전달하려면 `False`로 두세요. Sessions, `RunState`, `RunResult.to_input_list()`은 SDK 기본 중첩 기록에 이미 포함된 정확히 동일한 메시지 인스턴스를 두 번 추가하지 않으면서 별개의 동일 메시지는 보존합니다. 모든 [실행기 메서드][agents.run.Runner]는 전달되지 않은 경우 자동으로 `RunConfig`을 생성하므로 빠른 시작과 코드 예제에서는 기본값이 비활성화된 상태로 유지되며, 명시적인 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 콜백은 계속 이를 재정의합니다. 개별 핸드오프는 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]을 통해 이 설정을 재정의할 수 있습니다. +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: `nest_handoff_history`를 선택할 때마다 정규화된 트랜스크립트(기록 + 핸드오프 항목)를 수신하는 선택적 callable입니다. 전체 핸드오프 필터를 작성하지 않고 기본 제공 순차 요약 세그먼트를 대체하여 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환해야 합니다. +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 잘라내거나 시스템 프롬프트를 삽입할 수 있습니다. +- [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: 실행기가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. -##### 트레이싱 및 관측 가능성 +##### 트레이싱 및 관찰 가능성 - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에서 [트레이싱](tracing.md)을 비활성화할 수 있습니다. - [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]을 전달합니다. -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: 트레이스에 LLM 및 도구 호출 입력/출력과 같이 잠재적으로 민감한 데이터를 포함할지 구성합니다. -- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`은 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: LLM 및 도구 호출의 입력/출력처럼 잠재적으로 민감한 데이터를 트레이스에 포함할지 구성합니다. +- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`는 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. ##### 도구 실행, 승인 및 도구 오류 동작 -- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행되는 로컬 함수 도구 호출 수 제한 등 로컬 도구 호출의 SDK 측 실행 동작을 구성합니다. -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 함수 도구와 일치하지 않을 때 Runner가 처리하는 방식을 구성합니다. 기본적으로 `ModelBehaviorError`이 발생합니다. 대신 모델에 표시되는 오류 출력을 반환하려면 옵트인하세요. -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구 이름과 핸드오프 이름이 충돌할 때 Runner가 처리하는 방식을 구성합니다. 기본값인 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치 대상으로 선택된 항목만 노출합니다. `"error"`은 모델 호출 전에 `UserError`을 발생시킵니다. 네임스페이스가 지정되었거나 지연 로딩되는 도구에 대한 엄격한 검증은 변경되지 않습니다. -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인된 도구 미발견 출력처럼 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. +- [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행할 로컬 함수 도구 호출 수를 제한하는 등 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않을 때 실행기가 처리하는 방식을 구성합니다. 기본값은 `ModelBehaviorError`을 발생시킵니다. 대신 모델에 표시되는 오류 출력을 반환하도록 옵트인할 수 있습니다. +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]: 네임스페이스가 없는 함수 도구 이름과 핸드오프 이름이 충돌할 때 실행기가 처리하는 방식을 구성합니다. 기본값인 `"warn"`은 조치 가능한 경고를 기록하고 현재 디스패치에서 선택된 항목만 노출합니다. `"error"`는 모델을 호출하기 전에 `UserError`을 발생시킵니다. 네임스페이스가 있는 도구 및 지연 로딩 도구에 대한 엄격한 검증은 변경되지 않습니다. +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]: 승인 거부 및 옵트인된 도구 미발견 출력 등 모델에 표시되는 도구 오류 메시지를 사용자 지정합니다. -중첩된 핸드오프는 옵트인 베타 기능으로 제공됩니다. `RunConfig(nest_handoff_history=True)`을 전달하여 순서가 지정된 트랜스크립트 압축을 활성화하거나, 특정 핸드오프에서 사용하려면 `handoff(..., nest_handoff_history=True)`을 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축소하는 대신 손실 없이 보존되는 메시지 항목 주위에 생성된 어시스턴트 요약 세그먼트를 배치합니다. raw 트랜스크립트를 유지하려면(기본값) 플래그를 설정하지 않거나 대화를 필요한 방식 그대로 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)을 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출합니다. +중첩 핸드오프는 옵트인 베타로 제공됩니다. 순차 트랜스크립트 압축을 활성화하려면 `RunConfig(nest_handoff_history=True)`를 전달하거나 특정 핸드오프에 대해 `handoff(..., nest_handoff_history=True)`을 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축소하는 대신 손실 없는 메시지 항목 주위에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 기본값인 raw 트랜스크립트를 유지하려면 플래그를 설정하지 않거나 대화를 필요한 형태 그대로 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출하세요. #### 실행 구성 세부 정보 ##### `tool_execution` -실행에서 로컬 함수 도구의 동시 실행 수를 제한하는 등 로컬 함수 도구의 SDK 측 동작을 구성하려면 `tool_execution`을 사용하세요. +실행 시 로컬 함수 도구의 동시 실행 수를 제한하는 등 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`를 사용하세요. ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에서 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 동시에 실행되는 로컬 함수 도구 호출 수를 제한하려면 정수 값을 설정하세요. +`max_function_tool_concurrency=None`은 기본 동작을 유지합니다. 모델이 한 턴에서 여러 함수 도구 호출을 생성하면 SDK는 생성된 모든 로컬 함수 도구 호출을 시작합니다. 정숫값을 설정하면 동시에 실행되는 로컬 함수 도구 호출 수를 제한할 수 있습니다. -이는 프로바이더 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]과 별개입니다. `parallel_tool_calls`은 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지 제어합니다. `tool_execution.max_function_tool_concurrency`은 모델이 로컬 함수 도구 호출을 생성한 후 SDK가 이를 실행하는 방식을 제어합니다. +이는 공급자 측 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls]과 별개입니다. `parallel_tool_calls`는 모델이 단일 응답에서 여러 도구 호출을 생성할 수 있는지를 제어합니다. `tool_execution.max_function_tool_concurrency`은 모델이 도구 호출을 생성한 후 SDK가 로컬 함수 도구 호출을 실행하는 방식을 제어합니다. -`pre_approval_tool_input_guardrails=False`은 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요하면 먼저 실행이 일시 중지되고, 도구 입력 가드레일은 승인 후 실행 직전에만 실행됩니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`으로 설정하세요. 이 사전 승인 검사를 통과한 호출에서도 승인 후 동일한 입력 가드레일이 다시 실행되므로 시간에 민감한 검사는 실행 전에 다시 검증됩니다. +`pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요한 경우 실행이 먼저 일시 중지되며 도구 입력 가드레일은 승인 후 실행 직전에만 동작합니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 동일한 입력 가드레일을 다시 실행하므로, 시간에 민감한 검사가 실행 전에 다시 검증됩니다. ##### `tool_not_found_behavior` -기본적으로 모델이 현재 에이전트에서 사용할 수 있는 함수 도구와 일치하지 않는 함수 도구 호출을 생성하면 Runner는 `ModelBehaviorError`을 발생시킵니다. +기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 실행기는 `ModelBehaviorError`을 발생시킵니다. -실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서 SDK는 확인되지 않은 도구 호출에 `function_call_output`을 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. +실행을 복구 가능한 상태로 유지하려면 `tool_not_found_behavior="return_error_to_model"`을 설정하세요. 이 모드에서 SDK는 해결되지 않은 도구 호출에 `function_call_output`를 추가하고 모델을 다시 실행하므로, 모델이 사용 가능한 도구를 선택하거나 해당 도구를 사용하지 않고 응답할 수 있습니다. ```python from agents import Agent, RunConfig, Runner @@ -214,22 +214,22 @@ result = await Runner.run( ) ``` -현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 외 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 밖의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. ##### `tool_error_formatter` SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`을 사용하세요. -포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 받습니다. +포매터는 다음 항목이 포함된 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs]를 수신합니다. -- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`과 같은 오류 카테고리 +- `kind`: `"approval_rejected"` 또는 `"tool_not_found"`와 같은 오류 카테고리 - `tool_type`: 도구 런타임(`"function"`, `"computer"`, `"shell"`, `"apply_patch"` 또는 `"custom"`) - `tool_name`: 도구 이름 - `call_id`: 도구 호출 ID -- `default_message`: SDK의 기본 모델 표시 메시지 +- `default_message`: SDK에서 기본적으로 모델에 표시하는 메시지 - `run_context`: 활성 실행 컨텍스트 래퍼 -메시지를 대체할 문자열을 반환하거나 SDK 기본값을 사용하려면 `None`을 반환하세요. +메시지를 대체하려면 문자열을 반환하고, SDK 기본값을 사용하려면 `None`을 반환하세요. ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -256,22 +256,22 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -`reasoning_item_id_policy`은 Runner가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. +`reasoning_item_id_policy`은 실행기가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. -- `None` 또는 `"preserve"`(기본값): 추론 항목 ID를 유지합니다. -- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID를 제거합니다. +- `None` 또는 `"preserve"`(기본값): 추론 항목 ID 유지 +- `"omit"`: 생성된 다음 턴 입력에서 추론 항목 ID 제거 -추론 항목이 `id`과 함께 전송되지만 필수 후속 항목(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)이 없는 경우에 발생하는 Responses API 400 오류 유형을 옵트인 방식으로 완화하려면 주로 `"omit"`을 사용하세요. +추론 항목이 `id`와 함께 전송되지만 필수 후속 항목(예: `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)이 없는 경우 발생하는 Responses API 400 오류 유형을 완화하기 위한 옵트인 옵션으로 `"omit"`을 주로 사용하세요. -SDK가 이전 출력에서 후속 입력을 구성하는 다중 턴 에이전트 실행에서 이러한 상황이 발생할 수 있습니다. 여기에는 세션 지속성, 서버 관리 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로가 포함됩니다. 추론 항목 ID는 보존되지만 프로바이더가 해당 ID와 그에 대응하는 후속 항목을 함께 유지하도록 요구하는 경우입니다. +SDK가 이전 출력에서 후속 입력을 구성하고(세션 영속성, 서버 관리형 대화 델타, 스트리밍/비스트리밍 후속 턴 및 재개 경로 포함) 추론 항목 ID를 보존하지만, 공급자가 해당 ID와 그에 대응하는 후속 항목이 계속 쌍을 이루도록 요구하는 경우 여러 턴의 에이전트 실행에서 이런 문제가 발생할 수 있습니다. -`reasoning_item_id_policy="omit"`을 설정하면 추론 내용은 유지하지만 추론 항목의 `id`을 제거하므로 SDK가 생성한 후속 입력이 해당 API 불변 조건을 위반하지 않습니다. +`reasoning_item_id_policy="omit"`을 설정하면 추론 콘텐츠는 유지되지만 추론 항목 `id`이 제거되므로 SDK가 생성한 후속 입력에서 해당 API 불변 조건이 트리거되지 않습니다. 적용 범위 참고 사항: - SDK가 후속 입력을 구성할 때 생성하거나 전달하는 추론 항목만 변경합니다. - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. -- `call_model_input_filter`은 이 정책이 적용된 후에도 의도적으로 추론 ID를 다시 추가할 수 있습니다. +- 이 정책이 적용된 후에도 `call_model_input_filter`에서 의도적으로 추론 ID를 다시 추가할 수 있습니다. ## 상태 및 대화 관리 @@ -279,33 +279,33 @@ SDK가 이전 출력에서 후속 입력을 구성하는 다중 턴 에이전트 다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태가 저장되는 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | +| 전략 | 상태 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | | --- | --- | --- | --- | -| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 프로바이더 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | -| `session` | 자체 스토리지 및 SDK | 지속형 채팅 상태, 재개 가능한 실행, 사용자 지정 저장소 | 동일한 `session` 인스턴스 또는 동일한 저장소를 가리키는 다른 인스턴스 | -| `conversation_id` | OpenAI Conversations API | 여러 워커 또는 서비스에서 공유할 명명된 서버 측 대화 | 동일한 `conversation_id`과 새 사용자 턴만 전달 | -| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 처리 | `result.last_response_id`과 새 사용자 턴만 전달 | +| `result.to_input_list()` | 애플리케이션 메모리 | 소규모 채팅 루프, 완전한 수동 제어, 모든 공급자 | `result.to_input_list()`의 목록과 다음 사용자 메시지 | +| `session` | 스토리지 및 SDK | 영속적인 채팅 상태, 재개 가능한 실행, 사용자 지정 스토어 | 동일한 `session` 인스턴스 또는 동일한 스토어를 가리키는 다른 인스턴스 | +| `conversation_id` | OpenAI Conversations API | 작업자 또는 서비스 간에 공유하려는 이름이 지정된 서버 측 대화 | 동일한 `conversation_id`와 새 사용자 턴만 | +| `previous_response_id` | OpenAI Responses API | 대화 리소스를 생성하지 않는 경량 서버 관리형 연속 실행 | `result.last_response_id`과 새 사용자 턴만 | -`result.to_input_list()`과 `session`은 클라이언트에서 관리됩니다. `conversation_id`과 `previous_response_id`은 OpenAI에서 관리되며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 지속성 전략을 선택하세요. 클라이언트 관리 기록과 OpenAI 관리 상태를 함께 사용하면 두 계층을 의도적으로 조정하지 않는 한 컨텍스트가 중복될 수 있습니다. +`result.to_input_list()`과 `session`은 클라이언트 관리형입니다. `conversation_id`와 `previous_response_id`은 OpenAI 관리형이며 OpenAI Responses API를 사용할 때만 적용됩니다. 대부분의 애플리케이션에서는 대화마다 하나의 영속성 전략을 선택하세요. 두 계층을 의도적으로 조정하지 않는 한 클라이언트 관리형 기록과 OpenAI 관리형 상태를 혼합하면 컨텍스트가 중복될 수 있습니다. !!! note - 세션 지속성은 서버 관리 대화 설정 - (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)과 동일한 - 실행에서 함께 사용할 수 없습니다. 호출마다 하나의 방식을 선택하세요. + 같은 실행에서 세션 영속성과 서버 관리형 대화 설정 + (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)을 + 함께 사용할 수 없습니다. 호출마다 한 가지 방식을 선택하세요. ### 대화/채팅 스레드 -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있고, 그에 따라 하나 이상의 LLM 호출이 발생할 수 있지만, 채팅 대화에서는 논리적으로 하나의 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며 이에 따라 하나 이상의 LLM 호출이 발생할 수 있지만, 채팅 대화에서는 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. -1. 사용자 턴: 사용자가 텍스트를 입력합니다. -2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 뒤 두 번째 에이전트로 핸드오프합니다. 두 번째 에이전트는 추가 도구를 실행한 다음 출력을 생성합니다. +1. 사용자 턴: 사용자가 텍스트 입력 +2. 실행기 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프하고, 두 번째 에이전트가 추가 도구를 실행한 다음 출력 생성 -에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 쪽이든 사용자가 후속 질문을 할 수 있으며, 이 경우 실행 메서드를 다시 호출할 수 있습니다. +에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 표시하거나 최종 출력만 표시할 수 있습니다. 어떤 경우든 사용자가 후속 질문을 할 수 있으며, 이때 실행 메서드를 다시 호출할 수 있습니다. #### 수동 대화 관리 -[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 메서드를 사용하여 다음 턴의 입력을 가져오고 대화 기록을 수동으로 관리할 수 있습니다. +[`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 메서드로 다음 턴의 입력을 가져와 대화 기록을 수동으로 관리할 수 있습니다. ```python from agents import Agent, Runner, trace @@ -329,7 +329,7 @@ async def main(): #### 세션을 사용한 자동 대화 관리 -더 간단한 방법으로, `.to_input_list()`을 수동으로 호출하지 않고 [Sessions](sessions/index.md)를 사용하여 대화 기록을 자동으로 처리할 수 있습니다. +더 간단한 방법으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 수동으로 호출하지 않고도 대화 기록을 자동으로 처리할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession, trace @@ -353,24 +353,24 @@ async def main(): # California ``` -Sessions는 자동으로 다음 작업을 수행합니다. +Sessions는 다음 작업을 자동으로 수행합니다. -- 각 실행 전에 대화 기록 검색 +- 각 실행 전에 대화 기록 가져오기 - 각 실행 후 새 메시지 저장 -- 서로 다른 세션 ID에 대해 별도 대화 유지 +- 서로 다른 세션 ID에 대해 별도의 대화 유지 자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. -#### 서버 관리 대화 +#### 서버 관리형 대화 -`to_input_list()` 또는 `Sessions`을 사용하여 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 과거의 모든 메시지를 매번 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래의 서버 관리 방식 중 하나를 사용할 때는 각 요청에 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. +`to_input_list()` 또는 `Sessions`로 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래 서버 관리형 방식 중 하나를 사용할 때는 요청마다 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. -OpenAI는 턴 사이의 상태를 추적하는 두 가지 방법을 제공합니다. +OpenAI는 여러 턴에 걸쳐 상태를 추적하는 두 가지 방법을 제공합니다. ##### 1. `conversation_id` 사용 -먼저 OpenAI Conversations API를 사용하여 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. +먼저 OpenAI Conversations API로 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. ```python from agents import Agent, Runner @@ -418,28 +418,28 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하는 경우 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리 대화에서 계속됩니다. +실행이 승인을 위해 일시 중지되고 [`RunState`][agents.run_state.RunState]에서 재개하면 SDK는 저장된 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 설정을 유지하므로 재개된 턴이 동일한 서버 관리형 대화에서 계속됩니다. -`conversation_id`과 `previous_response_id`은 상호 배타적입니다. 여러 시스템에서 공유할 수 있는 명명된 대화 리소스가 필요하면 `conversation_id`을 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 처리 기본 구성 요소가 필요하면 `previous_response_id`을 사용하세요. +`conversation_id`와 `previous_response_id`는 상호 배타적입니다. 시스템 간에 공유할 수 있는 이름이 지정된 대화 리소스가 필요하면 `conversation_id`을 사용하세요. 한 턴에서 다음 턴으로 이어지는 가장 가벼운 Responses API 연속 실행 기본 구성 요소가 필요하면 `previous_response_id`을 사용하세요. !!! note - SDK는 `conversation_locked` 오류를 백오프와 함께 자동으로 재시도합니다. 서버 관리 - 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌려 동일하게 준비된 - 항목을 다시 올바르게 전송할 수 있도록 합니다. + SDK는 `conversation_locked` 오류에 대해 백오프를 적용하여 자동으로 재시도합니다. 서버 관리형 + 대화 실행에서는 재시도 전에 내부 대화 추적기 입력을 되돌려 동일하게 준비된 항목을 + 문제없이 다시 전송할 수 있도록 합니다. 로컬 세션 기반 실행(`conversation_id`, `previous_response_id` 또는 - `auto_previous_response_id`과 함께 사용할 수 없음)에서도 SDK는 최근에 지속 저장된 - 입력 항목을 최선의 방식으로 롤백하여 재시도 후 기록 항목의 중복을 줄입니다. + `auto_previous_response_id`과 함께 사용할 수 없음)에서도 SDK는 재시도 후 기록 항목의 + 중복을 줄이기 위해 최근에 영속화된 입력 항목을 최선을 다해 롤백합니다. - 이 호환성 재시도는 `ModelSettings.retry`을 구성하지 않아도 수행됩니다. 모델 요청에 - 대해 더 광범위한 옵트인 재시도 동작을 사용하려면 [Runner 관리 재시도](models/index.md#runner-managed-retries)를 참조하세요. + 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 대한 + 더 광범위한 옵트인 재시도 동작은 [실행기 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. ## 훅 및 사용자 지정 ### 모델 호출 입력 필터 -모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`을 사용하세요. 이 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(세션 기록이 있는 경우 이를 포함)을 받고 새로운 `ModelInputData`을 반환합니다. +모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`을 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 수신하고 새 `ModelInputData`를 반환합니다. 반환 값은 [`ModelInputData`][agents.run.ModelInputData] 객체여야 합니다. 해당 객체의 `input` 필드는 필수이며 입력 항목 목록이어야 합니다. 다른 형태를 반환하면 `UserError`이 발생합니다. @@ -460,19 +460,19 @@ result = Runner.run_sync( ) ``` -Runner는 준비된 입력 목록의 복사본을 훅에 전달하므로 호출자의 원래 목록을 인플레이스 방식으로 변경하지 않고도 목록을 줄이거나 대체하거나 순서를 변경할 수 있습니다. +실행기는 준비된 입력 목록의 사본을 훅에 전달하므로 호출자의 원래 목록을 그 자리에서 변경하지 않고도 항목을 잘라내거나 대체하거나 순서를 변경할 수 있습니다. -세션을 사용하는 경우 `call_model_input_filter`은 세션 기록이 이미 로드되어 현재 턴과 병합된 후에 실행됩니다. 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. +세션을 사용 중이라면 세션 기록이 이미 로드되어 현재 턴과 병합된 후 `call_model_input_filter`이 실행됩니다. 이보다 앞선 병합 단계 자체를 사용자 지정하려면 [`session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용하세요. -`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`을 사용하여 OpenAI 서버 관리 대화 상태를 사용하는 경우 훅은 다음 Responses API 호출을 위해 준비된 페이로드에서 실행됩니다. 해당 페이로드는 이전 기록의 전체 재전송이 아니라 새 턴의 델타만 이미 나타낼 수 있습니다. 반환한 항목만 해당 서버 관리 연속 처리에 전송된 것으로 표시됩니다. +`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`로 OpenAI 서버 관리형 대화 상태를 사용 중이라면 다음 Responses API 호출을 위해 준비된 페이로드에서 훅이 실행됩니다. 이 페이로드는 이전 기록 전체를 재현한 것이 아니라 새 턴의 델타만 이미 나타낼 수 있습니다. 반환하는 항목만 해당 서버 관리형 연속 실행에 전송된 것으로 표시됩니다. -민감한 데이터를 수정하거나, 긴 기록을 줄이거나, 추가 시스템 지침을 삽입하려면 `run_config`을 통해 실행별로 훅을 설정하세요. +민감한 데이터를 수정하거나, 긴 기록을 잘라내거나, 추가 시스템 지침을 삽입하려면 `run_config`을 통해 실행별로 훅을 설정하세요. ## 오류 및 복구 ### 오류 처리기 -모든 `Runner` 진입점은 오류 종류를 키로 사용하는 dict인 `error_handlers`을 받습니다. 지원되는 키는 `"max_turns"`, `"model_refusal"`, `"invalid_final_output"`입니다. 실행을 해당 오류로 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. +모든 `Runner` 진입점은 오류 종류를 키로 사용하는 dict인 `error_handlers`를 허용합니다. 지원되는 키는 `"max_turns"`, `"model_refusal"`, `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. ```python from agents import ( @@ -501,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -모델 메시지가 에이전트의 structured `output_type`에 대해 검증되지 않거나 모델이 structured 최종 메시지를 반환하지 않는 경우 `"invalid_final_output"`을 사용하세요. 처리기는 애플리케이션별 대체 값을 반환할 수 있으며, SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부작용을 다시 실행하지는 않습니다. `None`을 반환하면 복구를 거부합니다. 대체 값이 없으면 비어 있지 않은 검증 실패에서 계속 `ModelBehaviorError`이 발생하며, 비어 있는 structured 응답은 기존의 다음 턴 동작을 유지합니다. +모델 메시지가 에이전트의 구조화된 `output_type`에 대해 검증되지 않거나 모델이 구조화된 최종 메시지를 반환하지 않을 때는 `"invalid_final_output"`를 사용하세요. 처리기는 애플리케이션별 대체 값을 반환할 수 있으며 SDK는 동일한 `output_type`에 대해 이를 검증합니다. 모델 호출을 재시도하거나 도구의 부수 효과를 다시 실행하지는 않습니다. `None`을 반환하면 복구를 거부합니다. 대체 값이 없으면 비어 있지 않은 검증 실패는 계속 `ModelBehaviorError`을 발생시키며, 빈 구조화 응답에는 기존의 다음 턴 동작이 유지됩니다. ```python from pydantic import BaseModel @@ -533,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 처리기에서는 합성된 대체 출력을 대화 기록에 추가하고 구성된 세션에 지속 저장합니다. 대체 출력을 결과 기록이나 세션 저장소에 추가하지 않고 호출자에게 반환하려면 `include_in_history=False`으로 설정하세요. +`RunErrorHandlerResult.include_in_history`의 기본값은 `True`입니다. 최대 턴 처리기에서는 합성된 대체 출력을 대화 기록에 추가하고 구성된 세션에 영속화합니다. 결과 기록이나 세션 스토리지에 추가하지 않고 호출자에게 대체 값을 반환하려면 `include_in_history=False`을 설정하세요. -모델의 거부가 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 출력을 생성하도록 하려면 `"model_refusal"`을 사용하세요. +모델의 거부로 인해 `ModelRefusalError`로 실행을 종료하는 대신 애플리케이션별 대체 값을 생성하려면 `"model_refusal"`을 사용하세요. ```python from pydantic import BaseModel @@ -567,36 +567,37 @@ result = Runner.run_sync( print(result.final_output) ``` -## 내구성 있는 실행 통합 및 휴먼인더루프 (HITL) +## 내구성 실행 통합 및 휴먼인더루프 (HITL) -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 가이드](human_in_the_loop.md)에서 시작하세요. 아래 통합은 실행이 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 가이드](human_in_the_loop.md)에서 시작하세요. 아래 통합은 실행이 오랜 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. ### Dapr -Agents SDK의 [Dapr](https://dapr.io) Diagrid 통합을 사용하면 장애에서 자동으로 복구되고 휴먼인더루프 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 특정 공급업체에 종속되지 않는 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. +Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 장애에서 자동으로 복구되고 휴먼인더루프 (HITL) 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. ### Temporal -Agents SDK의 [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 작업을 포함한 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. Temporal과 Agents SDK가 함께 장기 실행 작업을 완료하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인할 수 있으며, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인할 수 있습니다. +Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함한 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. 장기 실행 작업을 완료하기 위해 Temporal과 Agents SDK가 함께 작동하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인하고, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인하세요. ### Restate -Agents SDK의 [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함하는 경량의 내구성 있는 에이전트를 실행할 수 있습니다. 이 통합에는 Restate의 단일 바이너리 런타임이 종속성으로 필요하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있습니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)를 읽거나 [문서](https://docs.restate.dev/ai)를 참조하세요. +Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함한 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있도록 지원합니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)를 읽거나 [문서](https://docs.restate.dev/ai)를 참조하세요. ### DBOS -Agents SDK의 [DBOS](https://dbos.dev/) 통합을 사용하면 장애와 재시작이 발생해도 진행 상태를 보존하는 안정적인 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [저장소](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. +Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 장애 및 재시작 이후에도 진행 상황을 보존하는 신뢰할 수 있는 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. ## 예외 -SDK는 특정한 경우 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. - -- [`AgentsException`][agents.exceptions.AgentsException]: SDK가 발생시키는 모든 예외의 기본 클래스입니다. 다른 모든 특정 예외가 파생되는 일반 유형입니다. -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과하면 이 예외가 발생합니다. 이는 지정된 에이전트 루프 턴(LLM 호출) 횟수 내에 에이전트가 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`으로 설정하세요. -- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]: 모델 호출 시도가 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout]을 초과하면 이 예외가 발생합니다. 적용 범위와 재시도 동작은 [모델 호출 시간 초과](models/index.md#model-call-timeouts)를 참조하세요. -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상치 못하거나 잘못된 출력을 생성할 때 이 예외가 발생합니다. 다음 경우가 포함될 수 있습니다. - - 잘못된 형식의 JSON: 모델이 도구 호출 또는 직접 출력에서 잘못된 JSON 구조를 제공하는 경우이며, 특히 특정 `output_type`이 정의되어 있을 때 발생합니다. - - 예상치 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못하는 경우 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 도구에서 `timeout_behavior="raise_exception"`을 사용하는 경우 이 예외가 발생합니다. -- [`UserError`][agents.exceptions.UserError]: SDK를 사용해 코드를 작성하는 사람이 SDK 사용 중 오류를 범하면 이 예외가 발생합니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 오용으로 인해 발생합니다. -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 입력 가드레일의 조건이 충족되면 `InputGuardrailTripwireTriggered`이 발생하고, 출력 가드레일의 조건이 충족되면 `OutputGuardrailTripwireTriggered`이 발생합니다. 입력 가드레일은 처리 전에 수신 메시지를 확인하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 확인합니다. \ No newline at end of file +SDK는 특정 상황에서 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. + +- [`AgentsException`][agents.exceptions.AgentsException]: SDK가 발생시키는 모든 예외의 기본 클래스입니다. 다른 모든 구체적인 예외가 파생되는 일반 타입입니다. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]: 에이전트 실행이 `Runner.run`, `Runner.run_sync` 또는 `Runner.run_streamed` 메서드에 전달된 `max_turns` 제한을 초과할 때 발생합니다. 에이전트가 지정된 에이전트 루프 턴(LLM 호출) 수 내에 작업을 완료하지 못했음을 나타냅니다. 제한을 비활성화하려면 `max_turns=None`을 설정하세요. +- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]: 모델 호출 시도가 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout]을 초과할 때 발생합니다. 적용 범위 및 재시도 동작은 [모델 호출 시간 초과](models/index.md#model-call-timeouts)를 참조하세요. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]: 기반 모델(LLM)이 예상치 못했거나 유효하지 않은 출력을 생성할 때 발생합니다. 다음을 포함할 수 있습니다. + - 잘못된 형식의 JSON: 모델이 도구 호출 또는 직접 출력에서 잘못된 형식의 JSON 구조를 제공하는 경우로, 특히 특정 `output_type`이 정의된 경우 + - 예상치 못한 도구 관련 실패: 모델이 예상된 방식으로 도구를 사용하지 못한 경우 + - 실패하거나 완료되지 않은 비스트리밍 Responses 호출: 반환된 응답의 종료 상태가 `failed` 또는 `incomplete`이면 `OpenAIResponsesModel` 및 `AnyLLMModel`의 Responses 경로가 이 예외를 발생시킵니다. 예외는 종료 상태를 식별하고 응답에서 사용 가능한 오류 또는 미완료 세부 정보를 포함합니다. +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]: 함수 도구 호출이 구성된 시간 제한을 초과하고 도구가 `timeout_behavior="raise_exception"`을 사용할 때 발생합니다. +- [`UserError`][agents.exceptions.UserError]: SDK를 사용하는 코드를 작성한 사용자가 SDK 사용 중 오류를 범했을 때 발생합니다. 일반적으로 잘못된 코드 구현, 유효하지 않은 구성 또는 SDK API의 오용으로 인해 발생합니다. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]: 입력 가드레일 조건이 충족되면 `InputGuardrailTripwireTriggered`이 발생하고 출력 가드레일 조건이 충족되면 `OutputGuardrailTripwireTriggered`이 발생합니다. 입력 가드레일은 처리 전에 수신 메시지를 검사하고, 출력 가드레일은 전달 전에 에이전트의 최종 응답을 검사합니다. \ No newline at end of file diff --git a/docs/ko/usage.md b/docs/ko/usage.md index 3eb76b5b06..6570a71eb5 100644 --- a/docs/ko/usage.md +++ b/docs/ko/usage.md @@ -2,9 +2,9 @@ search: exclude: true --- -# 사용법 +# 사용량 -Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 접근하여 비용을 모니터링하거나, 제한을 적용하거나, 분석 데이터를 기록할 수 있습니다. +Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 접근하여 비용을 모니터링하거나, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. ## 추적 항목 @@ -12,13 +12,13 @@ Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. - **input_tokens**: 전송된 총 입력 토큰 수 - **output_tokens**: 수신된 총 출력 토큰 수 - **total_tokens**: 입력 + 출력 -- **request_usage_entries**: 요청별 사용량 분석 목록 +- **request_usage_entries**: 요청별 사용량 세부 내역 목록 - **details**: - `input_tokens_details.cached_tokens` - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 실행의 사용량 접근 +## 실행에서 사용량 접근 `Runner.run(...)` 실행 후 `result.context_wrapper.usage`를 통해 사용량에 접근합니다. @@ -32,22 +32,22 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -사용량은 도구 호출이나 핸드오프를 생성하는 모델 호출을 포함하여 실행 중의 모든 모델 호출에 걸쳐 집계됩니다. +사용량은 도구 호출이나 핸드오프를 생성하는 모델 호출을 포함하여 실행 중 발생한 모든 모델 호출에 걸쳐 집계됩니다. -[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]가 실행 완료 전에 기록을 자동으로 압축하면 해당 `responses.compact` 요청이 보고한 사용량도 동일한 실행의 총합에 추가됩니다. 실행 외부에서 수행된 수동 `run_compaction()` 호출에는 이를 포함하는 실행 컨텍스트가 없으므로 이전 실행에서 반환된 사용량 객체를 업데이트하지 않습니다. [OpenAI Responses 압축 세션](sessions/index.md#openai-responses-compaction-sessions)을 참고하세요. +[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]가 실행이 완료되기 전에 기록을 자동으로 압축하면 해당 `responses.compact` 요청에서 보고된 사용량도 같은 실행의 합계에 추가됩니다. 실행 외부에서 수동으로 수행한 `run_compaction()` 호출에는 이를 포함하는 실행 컨텍스트가 없으므로 이전 실행에서 반환된 사용량 객체를 업데이트하지 않습니다. [OpenAI Responses 압축 세션](sessions/index.md#openai-responses-compaction-sessions)을 참고하세요. ### 서드 파티 어댑터의 사용량 활성화 -사용량 보고 방식은 서드 파티 어댑터와 제공자 백엔드에 따라 다릅니다. 서드 파티 어댑터를 통해 모델에 접근하며 정확한 `result.context_wrapper.usage` 값이 필요한 경우: +사용량 보고 방식은 서드 파티 어댑터와 제공자 백엔드에 따라 다릅니다. 서드 파티 어댑터를 통해 모델에 접근하면서 정확한 `result.context_wrapper.usage` 값이 필요한 경우 다음 사항을 참고하세요. -- `AnyLLMModel`를 사용할 때 상위 제공자가 사용량을 반환하면 자동으로 전파됩니다. Chat Completions 백엔드에서 응답을 스트리밍할 때 사용량 청크가 전송되도록 하려면 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. -- `LitellmModel`을 사용할 때 일부 제공자 백엔드는 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`가 필요한 경우가 많습니다. +- `AnyLLMModel`에서는 업스트림 제공자가 사용량을 반환할 때 자동으로 전달됩니다. Chat Completions 백엔드에서 응답을 스트리밍할 때 사용량 청크가 생성되도록 하려면 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. +- `LitellmModel`에서는 일부 제공자 백엔드가 기본적으로 사용량을 보고하지 않으므로 `ModelSettings(include_usage=True)`가 필요한 경우가 많습니다. -Models 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 검토하고, 배포에 사용할 제공자 백엔드에서 사용량 보고가 정확한지 확인하세요. +Models 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 확인하고, 배포하려는 제공자 백엔드에서 사용량이 정확하게 보고되는지 검증하세요. ## 요청별 사용량 추적 -SDK는 각 API 요청의 사용량을 `request_usage_entries`에서 자동으로 추적합니다. 이는 상세한 비용 계산과 컨텍스트 윈도 사용량 모니터링에 유용합니다. +SDK는 각 API 요청의 사용량을 `request_usage_entries`에서 자동으로 추적합니다. 이는 상세한 비용 계산과 컨텍스트 창 사용량 모니터링에 유용합니다. ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -58,7 +58,7 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): ## 제공자 사용량 페이로드 보존 -Agents SDK는 제공자 사용량을 모델 제공자 전반에서 일관된 총합을 제공하는 [`Usage`][agents.usage.Usage] 필드로 정규화합니다. 애플리케이션에서 제공자별 사용량 필드를 유지하거나 누락된 필드와 제공자가 보고한 0을 구분해야 하는 경우 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage]를 `True`으로 설정합니다. +Agents SDK는 제공자 사용량을 여러 모델 제공자에서 일관된 합계를 제공하는 [`Usage`][agents.usage.Usage] 필드로 정규화합니다. 애플리케이션에서 제공자별 사용량 필드를 유지하거나 생략된 필드와 제공자가 보고한 0을 구분해야 하는 경우 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage]를 `True`으로 설정합니다. ```python from agents import Agent, ModelSettings, Runner @@ -73,15 +73,15 @@ for response in result.raw_responses: print(response.raw_usage) ``` -Agents SDK는 각 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 값을 해당 모델 호출의 제공자 페이로드에서 분리된 JSON 호환 스냅샷으로 저장합니다. Agents SDK는 실행 전체에서 `raw_usage`을 집계하지 않습니다. 보존이 비활성화되어 있거나, 제공자가 사용량 페이로드를 반환하지 않거나, 상위 어댑터가 이미 원래 필드의 존재 여부 정보를 폐기한 경우 이 값은 `None`으로 유지됩니다. +Agents SDK는 각 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 값을 해당 모델 호출에 대한 제공자 페이로드의 분리된 JSON 호환 스냅샷으로 저장합니다. Agents SDK는 실행 전체에서 `raw_usage`을 집계하지 않습니다. 보존이 비활성화되어 있거나, 제공자가 사용량 페이로드를 반환하지 않거나, 업스트림 어댑터가 원래의 필드 존재 여부 정보를 이미 삭제한 경우 이 값은 `None`으로 유지됩니다. -`preserve_raw_usage`은 모델 어댑터에 도달한 사용량 페이로드만 보존하며, 이 설정으로 제공자에 사용량을 요청하지는 않습니다. 스트리밍 Chat Completions 제공자가 명시적인 사용량 요청을 요구하는 경우 `ModelSettings(include_usage=True)`도 설정합니다. +`preserve_raw_usage`은 모델 어댑터에 도달한 사용량 페이로드만 보존하며, 이 설정이 제공자에게 사용량을 요청하지는 않습니다. 스트리밍 Chat Completions 제공자가 명시적인 사용량 요청을 요구하는 경우 `ModelSettings(include_usage=True)`도 설정합니다. -현재 `LitellmModel`는 스트리밍 및 비스트리밍 실행 모두에서 `ModelResponse.raw_usage`을 채우지 않으므로 해당 어댑터에서는 `preserve_raw_usage=True`가 적용되지 않습니다. `LitellmModel`을 사용할 때는 정규화된 [`Usage`][agents.usage.Usage] 필드를 계속 사용하거나, 제공자별 필드의 존재 여부가 필요한 경우 raw 사용량 보존을 지원하는 어댑터를 선택하세요. +`LitellmModel`는 현재 스트리밍 및 비스트리밍 실행 모두에서 `ModelResponse.raw_usage`을 채우지 않으므로 `preserve_raw_usage=True`는 해당 어댑터에서 효과가 없습니다. `LitellmModel`을 사용할 때는 계속해서 정규화된 [`Usage`][agents.usage.Usage] 필드를 사용하거나, 제공자별 필드 존재 여부가 필요한 경우 raw 사용량 보존을 지원하는 어댑터를 선택하세요. -## 세션 사용 시 사용량 접근 +## 세션에서 사용량 접근 -`Session`(예: `SQLiteSession`)을 사용하면 각 `Runner.run(...)` 호출은 해당 실행의 사용량을 반환합니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만 각 실행의 사용량은 독립적입니다. +`Session`(예: `SQLiteSession`)을 사용하는 경우 `Runner.run(...)`를 호출할 때마다 해당 실행의 사용량이 반환됩니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만 각 실행의 사용량은 독립적입니다. ```python session = SQLiteSession("my_conversation") @@ -93,9 +93,27 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -세션은 실행 간에 대화 컨텍스트를 보존하지만, 각 `Runner.run()` 호출이 반환하는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행에 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. +세션은 실행 간 대화 컨텍스트를 보존하지만, 각 `Runner.run()` 호출에서 반환되는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행의 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. -## 훅에서의 사용량 활용 +## RunState 체크포인트의 사용량 + +[`RunResult.to_state()`][agents.result.RunResult.to_state]는 그 시점까지 누적된 사용량의 독립적인 스냅샷을 캡처합니다. 해당 체크포인트에서 재개된 실행은 캡처된 합계로 시작하며 자체 모델 호출의 사용량을 추가합니다. 재개된 실행은 이러한 새 합계를 원래 `RunResult` 또는 해당 결과에서 생성된 다른 체크포인트에 추가하지 않습니다. + +```python +first = await Runner.run(agent, "First request") +checkpoint_a = first.to_state() +checkpoint_b = first.to_state() + +resumed_a = await Runner.run(agent, checkpoint_a) +resumed_b = await Runner.run(agent, checkpoint_b) + +assert resumed_a.context_wrapper.usage is not first.context_wrapper.usage +assert resumed_b.context_wrapper.usage is not resumed_a.context_wrapper.usage +``` + +이러한 격리는 [`Usage`][agents.usage.Usage] 내부의 `request_usage_entries` 목록에도 적용됩니다. 재개된 중첩 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행은 독립적인 최상위 사용량 집계의 예외입니다. 재개 후의 모델 사용량은 중첩 실행의 이전 모델 호출과 마찬가지로 활성 외부 실행의 사용량에 의도적으로 집계됩니다. + +## 훅에서 사용량 활용 `RunHooks`을 사용하는 경우 각 훅에 전달되는 `context` 객체에는 `usage`이 포함됩니다. 이를 통해 주요 수명 주기 시점에 사용량을 기록할 수 있습니다. @@ -113,4 +131,4 @@ class MyHooks(RunHooks): - [`Usage`][agents.usage.Usage] - 사용량 추적 데이터 구조 - [`RequestUsage`][agents.usage.RequestUsage] - 요청별 사용량 세부 정보 - [`RunContextWrapper`][agents.run.RunContextWrapper] - 실행 컨텍스트에서 사용량 접근 -- [`RunHooks`][agents.run.RunHooks] - 사용량 추적 수명 주기에 훅 연결 \ No newline at end of file +- [`RunHooks`][agents.run.RunHooks] - 사용량 추적 수명 주기에 연결 \ No newline at end of file diff --git a/docs/ko/visualization.md b/docs/ko/visualization.md index 35c5eaab0b..f4388d5ff3 100644 --- a/docs/ko/visualization.md +++ b/docs/ko/visualization.md @@ -4,11 +4,11 @@ search: --- # 에이전트 시각화 -에이전트 시각화를 사용하면 **Graphviz**를 통해 에이전트와 다른 에이전트, 도구 및 MCP 서버 간 연결을 구조화된 그래픽 표현으로 생성할 수 있습니다. 이는 애플리케이션 내에서 에이전트, 도구, 핸드오프가 상호작용하는 방식을 이해하는 데 유용합니다. +에이전트 시각화를 사용하면 **Graphviz**를 통해 에이전트와 다른 에이전트, 도구 및 MCP 서버 간 연결을 구조화된 그래프로 생성할 수 있습니다. 이는 애플리케이션 내에서 에이전트, 도구 및 핸드오프가 상호작용하는 방식을 이해하는 데 유용합니다. ## 설치 -선택적 `viz` 종속성 그룹을 설치합니다. +선택적 `viz` 의존성 그룹을 설치합니다. ```bash pip install "openai-agents[viz]" @@ -21,14 +21,14 @@ pip install "openai-agents[viz]" - **에이전트**는 노란색 상자로 표시됩니다. - **MCP 서버**는 회색 상자로 표시됩니다. - **도구**는 녹색 타원으로 표시됩니다. -- **핸드오프**는 한 에이전트에서 다른 에이전트로 향하는 방향성 간선으로 표시됩니다. +- **핸드오프**는 한 에이전트에서 다른 에이전트로 향하는 방향 간선으로 표시됩니다. ### 사용 예시 ```python import os -from agents import Agent +from agents import Agent, handoff from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph @@ -60,7 +60,7 @@ mcp_server = MCPServerStdio( triage_agent = Agent( name="Triage agent", instructions="Handoff to the appropriate agent based on the language of the request.", - handoffs=[spanish_agent, english_agent], + handoffs=[handoff(spanish_agent), handoff(english_agent)], tools=[get_weather], mcp_servers=[mcp_server], ) @@ -72,6 +72,8 @@ draw_graph(triage_agent) 이 코드는 **트리아지 에이전트**의 구조와 하위 에이전트 및 도구와의 연결을 시각적으로 나타내는 그래프를 생성합니다. +`draw_graph()`는 `handoffs`에 직접 제공되거나 `handoff(agent)`를 통해 등록된 대상 에이전트를 재귀적으로 확장합니다. 두 방식 모두 그래프에 각 대상의 도구, MCP 서버 및 후속 핸드오프가 포함됩니다. 사용 가능한 대상 `Agent`가 없는 사용자 지정 `Handoff`는 이름이 지정된 목적지로만 렌더링되므로, 그래프가 해당 목적지 이면의 리소스를 확장할 수 없습니다. + ## 시각화 이해 @@ -81,7 +83,7 @@ draw_graph(triage_agent) - 노란색으로 채워진 **직사각형**으로 표시되는 에이전트 - 녹색으로 채워진 **타원**으로 표시되는 도구 - 회색으로 채워진 **직사각형**으로 표시되는 MCP 서버 -- 상호작용을 나타내는 방향성 간선: +- 상호작용을 나타내는 방향 간선 - 에이전트 간 핸드오프를 나타내는 **실선 화살표** - 도구 호출을 나타내는 **점선 화살표** - MCP 서버 호출을 나타내는 **파선 화살표** @@ -92,17 +94,17 @@ draw_graph(triage_agent) ## 그래프 사용자 지정 ### 그래프 표시 -기본적으로 `draw_graph`는 그래프를 인라인으로 표시합니다. 별도의 창에 그래프를 표시하려면 다음과 같이 작성합니다. +기본적으로 `draw_graph`은 그래프를 인라인으로 표시합니다. 별도의 창에 그래프를 표시하려면 다음과 같이 작성합니다. ```python draw_graph(triage_agent).view() ``` ### 그래프 저장 -기본적으로 `draw_graph`는 그래프를 인라인으로 표시합니다. 파일로 저장하려면 파일 이름을 지정합니다. +기본적으로 `draw_graph`은 그래프를 인라인으로 표시합니다. 파일로 저장하려면 파일 이름을 지정합니다. ```python draw_graph(triage_agent, filename="agent_graph") ``` -그러면 작업 디렉터리에 `agent_graph.png`이 생성됩니다. \ No newline at end of file +그러면 작업 디렉터리에 `agent_graph.png`가 생성됩니다. \ No newline at end of file diff --git a/docs/ref/run_internal/blocked_output.md b/docs/ref/run_internal/blocked_output.md new file mode 100644 index 0000000000..ec4b6c785e --- /dev/null +++ b/docs/ref/run_internal/blocked_output.md @@ -0,0 +1,3 @@ +# `Blocked Output` + +::: agents.run_internal.blocked_output diff --git a/docs/zh/config.md b/docs/zh/config.md index e4209d4e12..3aa12ab6a0 100644 --- a/docs/zh/config.md +++ b/docs/zh/config.md @@ -4,21 +4,21 @@ search: --- # 配置 -本页介绍通常在应用启动期间一次性设置的 SDK 全局默认值,例如默认OpenAI密钥或客户端、默认OpenAI API 形态、追踪导出默认设置以及日志行为。 +本页介绍通常在应用启动时一次性设置的 SDK 全局默认配置,例如默认OpenAI密钥或客户端、默认OpenAI API 形式、追踪导出默认配置以及日志行为。 -这些默认值仍适用于基于沙箱的工作流,但沙箱工作区、沙箱客户端和会话复用需要单独配置。 +这些默认配置仍适用于基于沙箱的工作流,但沙箱工作区、沙箱客户端和会话复用需要单独配置。 如果需要配置特定智能体或运行,请先参阅: -- [智能体](agents.md):普通 `Agent` 的指令、工具、输出类型、任务转移和安全防护措施。 -- [运行智能体](running_agents.md):`RunConfig`、会话和对话状态选项。 -- [沙箱智能体](sandbox/guide.md):`SandboxRunConfig`、清单、能力和沙箱客户端专用的工作区设置。 -- [模型](models/index.md):模型选择和提供商配置。 -- [追踪](tracing.md):每次运行的追踪元数据和自定义追踪处理器。 +- [智能体](agents.md):了解普通 `Agent` 的指令、工具、输出类型、任务转移和安全防护措施。 +- [运行智能体](running_agents.md):了解 `RunConfig`、会话和对话状态选项。 +- [沙箱智能体](sandbox/guide.md):了解 `SandboxRunConfig`、清单、能力以及特定于沙箱客户端的工作区设置。 +- [模型](models/index.md):了解模型选择和提供商配置。 +- [追踪](tracing.md):了解每次运行的追踪元数据和自定义追踪处理器。 ## 配置对象与字典 -SDK 定义的配置参数通常既接受相应的类型化设置对象,也接受包含相同字段的字典。此规则适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。SDK 定义的嵌套设置类型也可以使用字典。 +SDK 定义的配置参数通常既接受相应的强类型设置对象,也接受包含相同字段的字典。这适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。SDK 定义的嵌套设置类型也可以使用字典。 ```python from agents import Agent @@ -33,11 +33,11 @@ agent = Agent( ) ``` -SDK 会将这些字典规范化为相应的设置对象。对于 SDK 定义的数据类配置类型,未知字段会引发 `TypeError`,这有助于及早发现拼写错误的选项名称。请查看参数的类型注解或 API 参考,确认特定边界是否接受字典。 +SDK 会将这些字典规范化为相应的设置对象。对于 SDK 定义的 dataclass 配置类型,未知字段会引发 `TypeError`,这有助于尽早发现拼写错误的选项名称。请查看参数的类型注解或 API 参考文档,以确认特定配置边界是否接受字典。 ## API 密钥与客户端 -默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理LLM请求和追踪。SDK 首次创建OpenAI客户端时会解析该密钥(延迟初始化),因此请在首次调用模型前设置该环境变量。如果无法在应用启动前设置此环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 +默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理 LLM 请求和追踪。SDK 首次创建OpenAI客户端时才会解析该密钥(延迟初始化),因此请在首次调用模型之前设置该环境变量。如果无法在应用启动前设置该环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 ```python from agents import set_default_openai_key @@ -45,7 +45,7 @@ from agents import set_default_openai_key set_default_openai_key("sk-...") ``` -或者,也可以配置要使用的OpenAI客户端。默认情况下,SDK 会创建一个 `AsyncOpenAI` 实例,并使用环境变量中的 API 密钥或上面设置的默认密钥。可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此设置。 +或者,也可以配置要使用的OpenAI客户端。默认情况下,SDK 会使用环境变量中的 API 密钥或上述默认密钥创建 `AsyncOpenAI` 实例。可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此行为。 ```python from openai import AsyncOpenAI @@ -55,9 +55,11 @@ custom_client = AsyncOpenAI(base_url="...", api_key="...") set_default_openai_client(custom_client) ``` +向 [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider] 传入显式客户端后,该客户端将负责管理其连接和账户设置。请勿同时向 `OpenAIProvider` 传入 `api_key`、`base_url`、`websocket_base_url`、`organization` 或 `project`;将 `openai_client` 与其中任何参数结合使用时,会引发 [`UserError`][agents.exceptions.UserError],而不是静默忽略重复值。请在构造 `AsyncOpenAI` 时设置所需值。 + ### 使用 `openai` v3 的自定义 HTTP 客户端 -0.21.0 版本要求使用 `openai>=3.0.0,<4`。默认OpenAI提供商使用 HTTPX2,因此大多数应用不需要直接配置 HTTP 客户端。如果应用将 `http_client=` 传递给 `AsyncOpenAI`,请为自定义客户端及其面向传输层的选项使用 HTTPX2 类型: +0.21.0 版本要求使用 `openai>=3.0.0,<4`。默认OpenAI提供商使用 HTTPX2,因此大多数应用不需要直接配置 HTTP 客户端。如果应用向 `AsyncOpenAI` 传入 `http_client=`,请为自定义客户端及其传输层相关选项使用 HTTPX2 类型: ```python import httpx2 @@ -75,18 +77,18 @@ custom_client = AsyncOpenAI( set_default_openai_client(custom_client) ``` -同样的迁移方式也适用于自定义传输、身份验证、事件钩子、模拟传输、URL、请求、响应和传输异常处理。请使用它们对应的 `httpx2` 类型。Agents SDK不会将任意旧版 `httpx` 对象转换为 HTTPX2。当应用显式安装 `httpx` 时,OpenAI Python SDK 会为旧版客户端提供临时兼容路径,但新增代码和迁移后的代码应使用 HTTPX2。 +相同的迁移方式也适用于自定义传输、身份验证、事件钩子、模拟传输、URL、请求、响应和传输异常处理。请使用它们对应的 `httpx2`。Agents SDK 不会将任意旧版 `httpx` 对象转换为 HTTPX2。当应用显式安装 `httpx` 时,OpenAI Python SDK 会为旧版客户端提供临时兼容路径,但新增代码和已迁移代码应使用 HTTPX2。 -此OpenAI客户端边界独立于本地MCP传输自定义。MCP Python SDK v1 使用自己的旧版 `httpx` 依赖项,而 MCP Python SDK v2 使用 `httpx2`;请参阅 [MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 +此OpenAI客户端边界与本地 MCP 传输自定义相互独立。MCP Python SDK v1 使用其自身的旧版 `httpx` 依赖项,而 MCP Python SDK v2 使用 `httpx2`;请参阅 [MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 -如果倾向于使用基于环境变量的端点配置,默认OpenAI提供商还会读取 `OPENAI_BASE_URL`。启用 Responses websocket 传输后,它还会读取 websocket `/responses` 端点所使用的 `OPENAI_WEBSOCKET_BASE_URL`。 +如果倾向于使用基于环境变量的端点配置,默认OpenAI提供商还会读取 `OPENAI_BASE_URL`。启用 Responses WebSocket 传输后,它还会读取 `OPENAI_WEBSOCKET_BASE_URL`,作为 WebSocket 的 `/responses` 端点。 ```bash export OPENAI_BASE_URL="https://your-openai-compatible-endpoint.example/v1" export OPENAI_WEBSOCKET_BASE_URL="wss://your-openai-compatible-endpoint.example/v1" ``` -最后,还可以自定义所使用的OpenAI API。默认情况下,我们使用OpenAI Responses API。可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数将其改为Chat Completions API。 +此外,还可以自定义所使用的OpenAI API。默认情况下,我们使用OpenAI Responses API。可以使用 [set_default_openai_api()][agents.set_default_openai_api] 函数将其替换为 Chat Completions API。 ```python from agents import set_default_openai_api @@ -94,9 +96,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI提供商默认设置 +## OpenAI提供商默认配置 -使用 SDK 的OpenAI后端的提供商在将模型名称字符串映射到模型时,也会读取 SDK 全局默认值。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使OpenAI Responses 模型默认使用 websocket 传输: +使用 SDK OpenAI后端的提供商在将模型名称字符串映射到模型时,也会读取 SDK 全局默认配置。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使OpenAI Responses 模型默认使用 WebSocket 传输: ```python from agents import set_default_openai_responses_transport @@ -104,9 +106,9 @@ from agents import set_default_openai_responses_transport set_default_openai_responses_transport("websocket") ``` -这会影响默认OpenAI提供商解析模型名称后生成的OpenAI Responses 模型。有关提供商级设置、连接复用、保活选项和自定义 websocket 端点,请参阅 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 +这会影响默认OpenAI提供商解析模型名称时生成的OpenAI Responses 模型。有关提供商级设置、连接复用、保活选项和自定义 WebSocket 端点,请参阅 [Responses WebSocket 传输](models/index.md#responses-websocket-transport)。 -如果OpenAI设置需要提供商级智能体注册元数据,请在启动时配置一次默认 harness ID: +如果OpenAI设置需要提供商级智能体注册元数据,请在启动时一次性配置默认 harness ID: ```python from agents import set_default_openai_harness @@ -124,11 +126,11 @@ set_default_openai_agent_registration( ) ``` -如果未设置 SDK 默认值,使用 SDK 的OpenAI后端的提供商将回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 +如果未设置 SDK 默认值,使用 SDK OpenAI后端的提供商会回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 ## 追踪 -追踪默认启用。默认情况下,它使用与上一节模型请求相同的OpenAI API 密钥,即环境变量中的密钥或设置的默认密钥。可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 +追踪默认处于启用状态。默认情况下,它使用与上一节中的模型请求相同的OpenAI API 密钥,即环境变量中的密钥或设置的默认密钥。可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 ```python from agents import set_tracing_export_api_key @@ -136,7 +138,7 @@ from agents import set_tracing_export_api_key set_tracing_export_api_key("sk-...") ``` -如果模型流量使用一个密钥或客户端,而追踪需要使用另一个OpenAI密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果没有使用自定义客户端,也可以对 [`set_default_openai_key()`][agents.set_default_openai_key] 使用相同方式。 +如果模型流量使用某个密钥或客户端,而追踪应使用另一个OpenAI密钥,请在设置默认密钥或客户端时传入 `use_for_tracing=False`,然后单独配置追踪。如果不使用自定义客户端,同样的方式也适用于 [`set_default_openai_key()`][agents.set_default_openai_key]。 ```python from openai import AsyncOpenAI @@ -151,14 +153,14 @@ set_default_openai_client(custom_client, use_for_tracing=False) set_tracing_export_api_key("sk-tracing") ``` -使用默认导出器时,如果需要将追踪归属于特定组织或项目,请在应用启动前设置以下环境变量: +使用默认导出器时,如果需要将追踪归属到特定组织或项目,请在应用启动前设置以下环境变量: ```bash export OPENAI_ORG_ID="org_..." export OPENAI_PROJECT_ID="proj_..." ``` -也可以为每次运行设置追踪 API 密钥,而不更改全局导出器。 +也可以为每次运行设置追踪 API 密钥,而无需更改全局导出器。 ```python from agents import Runner, RunConfig @@ -178,7 +180,7 @@ from agents import set_tracing_disabled set_tracing_disabled(True) ``` -如果希望保持追踪启用,但从追踪负载中排除可能包含敏感信息的输入或输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: +如果希望保持追踪启用,但从追踪负载中排除可能包含敏感信息的输入和输出,请将 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 设置为 `False`: ```python from agents import Runner, RunConfig @@ -190,7 +192,7 @@ await Runner.run( ) ``` -也可以在应用启动前设置以下环境变量,无需编写代码即可更改默认值: +也可以在应用启动前设置以下环境变量,以便在不修改代码的情况下更改默认值: ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 @@ -200,9 +202,9 @@ export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ## 调试日志 -SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加任何处理器。日志遵循应用的 Python 日志配置。 +SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加处理器。日志遵循应用的 Python 日志配置。 -要启用详细日志记录,请使用 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 函数。 +如需启用详细日志,请使用 [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] 函数。 ```python from agents import enable_verbose_stdout_logging @@ -210,7 +212,7 @@ from agents import enable_verbose_stdout_logging enable_verbose_stdout_logging() ``` -或者,也可以通过添加处理器、过滤器和格式化程序等方式自定义日志。有关更多信息,请参阅 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 +或者,也可以通过添加处理器、过滤器、格式化器等来自定义日志。有关更多信息,请参阅 [Python 日志指南](https://docs.python.org/3/howto/logging.html)。 ```python import logging @@ -229,22 +231,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 日志与诊断中的敏感数据 +### 日志与诊断信息中的敏感数据 某些日志和诊断异常可能包含敏感数据,例如模型或工具的输入和输出。 -默认情况下,SDK **不会**记录LLM输入和输出,也不会记录工具输入和输出。这些保护措施由以下变量控制: +默认情况下,SDK **不会**记录 LLM 输入/输出或工具输入/输出。以下配置控制这些保护措施: ```bash OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 ``` -如果为了调试而需要临时包含这些数据,请在应用启动前将任一变量设置为 `0`(或 `false`): +如果需要为调试临时包含这些数据,请在应用启动前将任一变量设置为 `0`(或 `false`): ```bash export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=0 export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 ``` -这些标志还会控制受影响的故障是否保留含有负载的诊断详细信息。例如,启用工具数据脱敏后,`FunctionTool` 的无效参数会引发通用的 `ModelBehaviorError`,且不会将底层验证错误链接到异常链中。将任一变量设置为 `0` 可能会在日志、异常消息、异常链和其他诊断上下文中暴露原始模型数据或工具数据,因此只能在受控的开发环境中启用。 \ No newline at end of file +这些标志还控制相关故障是否保留包含负载的诊断详情。例如,启用工具数据脱敏后,`FunctionTool` 的无效参数会引发通用的 `ModelBehaviorError`,而不会链接底层验证错误。将任一变量设置为 `0`,可能会在日志、异常消息、异常链和其他诊断上下文中暴露原始模型或工具数据,因此请仅在受控的开发环境中启用。 \ No newline at end of file diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index 5a92c207e8..4d0aa36842 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -4,42 +4,42 @@ search: --- # 安全防护措施 -安全防护措施使你能够检查和验证用户输入与智能体输出。例如,假设你有一个使用非常智能(因而速度慢、成本高)的模型来协助处理客户请求的智能体。你不会希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用一个速度快、成本低的模型运行安全防护措施。如果安全防护措施检测到恶意使用,就可以立即引发错误,从而节省时间和成本。阻塞执行可保证高成本模型不会启动;采用并行执行时,高成本模型可能在安全防护措施完成之前就已经启动。有关详细信息,请参阅下文的“执行模式”。 +安全防护措施可用于检查和验证用户输入及智能体输出。例如,假设你有一个智能体,它使用非常智能(因而速度较慢且成本较高)的模型来协助处理客户请求。你不会希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用速度快、成本低的模型运行安全防护措施。如果安全防护措施检测到恶意使用,可以立即抛出错误,从而节省时间和成本。阻塞执行可保证高成本模型不会启动;使用并行执行时,高成本模型可能已在安全防护措施完成前启动。有关详情,请参阅下文的“执行模式”。 安全防护措施分为两类: 1. 输入安全防护措施针对初始用户输入运行 -2. 输出安全防护措施针对最终智能体输出运行 +2. 输出安全防护措施针对智能体的最终输出运行 ## 工作流边界 -安全防护措施附加到智能体和工具,但并非都会在工作流中的相同节点运行: +安全防护措施会附加到智能体和工具上,但它们并非都在工作流中的相同节点运行: -- **输入安全防护措施**仅针对链中的第一个智能体运行。 -- **输出安全防护措施**仅针对生成最终输出的智能体运行。 -- **工具安全防护措施**会在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 +- **输入安全防护措施**仅针对链中的第一个智能体运行。 +- **输出安全防护措施**仅针对生成最终输出的智能体运行。 +- **工具安全防护措施**在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 -如果需要在包含管理者、任务转移或受委派专家的工作流中,于每次自定义函数工具调用之前和/或之后执行检查,请使用工具安全防护措施,而不要仅依赖智能体级别的输入/输出安全防护措施。 +如果需要在包含管理者、任务转移或受委派专家的工作流中,于每次自定义函数工具调用前和/或调用后执行检查,请使用工具安全防护措施,而不要只依赖智能体级别的输入/输出安全防护措施。 ## 输入安全防护措施 输入安全防护措施分 3 个步骤运行: -1. 首先,安全防护措施接收传递给智能体的同一输入。 +1. 首先,安全防护措施接收与传给智能体相同的输入。 2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装在 [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会抛出 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 !!! Note - 输入安全防护措施旨在针对用户输入运行,因此,仅当某个智能体是*第一个*智能体时,才会运行该智能体的安全防护措施。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传递给 `Runner.run`?这是因为安全防护措施往往与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 + 输入安全防护措施旨在针对用户输入运行,因此仅当某个智能体是*第一个*智能体时,才会运行该智能体的安全防护措施。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传给 `Runner.run`?这是因为安全防护措施往往与实际的智能体相关——你会为不同智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 ### 执行模式 输入安全防护措施支持两种执行模式: -- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体执行并发运行。由于二者同时启动,因此这种模式可以实现最低延迟。但是,如果安全防护措施的触发器被触发,智能体可能在取消之前已经消耗了 token 并执行了工具。 +- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体并发执行。由于两者同时启动,因此这种模式可实现最低延迟。但是,如果安全防护措施的触发器被触发,智能体在取消前可能已经消耗了 token 并执行了工具。 -- **阻塞执行**(`run_in_parallel=False`):安全防护措施在智能体启动*之前*运行并完成。如果安全防护措施触发器被触发,智能体将永远不会执行,从而避免消耗 token 和执行工具。这非常适合成本优化,以及希望避免工具调用可能产生副作用的场景。 +- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将完全不会执行,从而避免消耗 token 和执行工具。这种模式非常适合成本优化,以及需要避免工具调用产生潜在副作用的场景。 ## 输出安全防护措施 @@ -47,38 +47,40 @@ search: 1. 首先,安全防护措施接收智能体生成的输出。 2. 接下来,安全防护措施函数运行并生成一个 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput],随后将其封装在 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 中 -3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会引发 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 +3. 最后,我们检查 [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] 是否为 true。如果为 true,则会抛出 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 异常,以便你适当地响应用户或处理该异常。 !!! Note - 输出安全防护措施旨在针对最终智能体输出运行,因此,仅当某个智能体是*最后一个*智能体时,才会运行该智能体的安全防护措施。与输入安全防护措施类似,我们这样做是因为安全防护措施往往与实际的智能体相关——你会为不同的智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 + 输出安全防护措施旨在针对智能体的最终输出运行,因此仅当某个智能体是*最后一个*智能体时,才会运行该智能体的安全防护措施。与输入安全防护措施类似,我们这样做是因为安全防护措施往往与实际的智能体相关——你会为不同智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 - 输出安全防护措施始终在智能体完成后运行,因此不支持 `run_in_parallel` 参数。 + 输出安全防护措施总是在智能体完成后运行,因此不支持 `run_in_parallel` 参数。 -输出触发器与安全防护措施函数引发的异常具有不同的会话行为。触发器会拒绝候选最终输出。当触发器触发时,运行器会请求已配置的会话持久化已完成的工具调用和工具输出项目,以及重放这些调用所需的任何推理上下文,同时排除被拒绝的候选最终输出。运行器会对流式传输和非流式传输运行应用这项触发器规则。当安全防护措施函数引发异常而不是返回触发器结果时,运行器会将判定视为未知,并请求已配置的会话持久化已完成的最终轮次项目,然后再抛出安全防护措施异常。如果该会话写入也失败,则会话写入错误优先。流式传输运行采用与非流式传输运行相同的持久化顺序,并从 `stream_events()` 引发终止异常。如果在输出安全防护措施运行期间立即调用 [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel],则会取消正在进行的安全防护措施,并且不会启动最终轮次的会话写入。 +输出触发器和安全防护措施函数抛出的异常会导致不同的会话行为。触发器会拒绝候选最终输出。当触发器触发时,运行器会要求已配置的会话持久化已完成的工具调用和工具输出项,以及重放这些调用所需的所有推理上下文,同时排除被拒绝的候选最终输出。运行器会将此触发器规则同时应用于流式传输和非流式传输运行。当安全防护措施函数抛出异常而不是返回触发器结果时,运行器会将判定视为未知,并要求已配置的会话在向上抛出安全防护措施异常之前持久化最终轮次中已完成的项。如果该会话写入也失败,则会话写入错误具有更高优先级。流式传输运行使用与非流式传输运行相同的持久化顺序,并从 `stream_events()` 抛出终止异常。在输出安全防护措施运行期间立即调用 [`RunResultStreaming.cancel()`][agents.result.RunResultStreaming.cancel],会取消正在运行的安全防护措施,并且不会启动最终轮次的会话写入。 + +终止型函数工具输出需要额外处理,因为在智能体级别的输出安全防护措施检查该值之前,工具已经运行。当 [`Agent.tool_use_behavior`][agents.agent.Agent.tool_use_behavior] 将该工具结果设为最终输出,而输出触发器将其拒绝时,只有在可以根据已验证字段重建函数调用/输出对的情况下,SDK 才会保留可有效重放的函数调用/输出对。保留的 `function_call_output` 载荷会替换为固定文本 `"Output withheld by an output guardrail."`;原始工具输出载荷不会保留在会话、`RunState`、流式传输结果状态或沙箱内存输入中。SDK 会保留重放所需的已验证函数调用元数据,包括函数参数,因此该元数据可能包含也曾出现在被拒绝输出中的数据。当前响应的 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 对象也会将 `agent_output` 替换为该固定文本,并清除 `output_info`。当前响应的 [`ToolOutputGuardrailResult`][agents.tool_guardrails.ToolOutputGuardrailResult] 对象会保留允许/拒绝行为类型,但会将包含载荷的 `output_info` 和拒绝消息替换为相同文本。此前已接受的轮次和安全防护措施结果保持不变。如果响应包含推理内容或其他 SDK 无法安全清理的结构,SDK 会丢弃当前响应的完整后缀,而不是保留被拒绝的输出载荷。抛出异常的安全防护措施函数并未返回拒绝判定,因此已完成的终止工具轮次会遵循上述异常持久化行为。 ## 工具安全防护措施 -工具安全防护措施封装**`FunctionTool` 实例**,使你能够在执行前后验证或阻止对这些工具的调用。它们在工具本身上配置,并在每次调用该工具时运行。 +工具安全防护措施会包装**`FunctionTool` 实例**,使你能够在这些工具执行前后验证或阻止对它们的调用。它们配置在工具本身上,并在每次调用该工具时运行。 -- 输入工具安全防护措施在工具执行前运行,可以跳过调用、用消息替换输出或引发触发器。 -- 输出工具安全防护措施在工具执行后运行,可以替换输出或引发触发器。 -- 如果函数工具需要审批,输入工具安全防护措施通常会在审批后、执行前立即运行。如果希望这些输入检查在发出待审批中断之前运行,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过此次审批前检查的调用仍会在获得审批后、工具执行前再次接受检查。 -- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的函数工具。任务转移通过 SDK 的任务转移管线运行,而不是通过常规函数工具管线运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管线,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 当前不直接提供工具安全防护措施选项。 +- 输入工具安全防护措施在工具执行前运行,可以跳过调用、将输出替换为消息,或触发触发器。 +- 输出工具安全防护措施在工具执行后运行,可以替换输出或触发触发器。 +- 如果函数工具需要审批,输入工具安全防护措施通常会在审批后、执行前立即运行。如果希望在发出待审批中断前运行这些输入检查,请将 [`RunConfig.tool_execution`][agents.run.RunConfig.tool_execution] 设置为 [`ToolExecutionConfig(pre_approval_tool_input_guardrails=True)`][agents.run.ToolExecutionConfig]。通过这项审批前检查的调用,在审批通过后、工具执行前仍会再次接受检查。 +- 工具安全防护措施仅适用于使用 [`function_tool`][agents.tool.function_tool] 创建的函数工具。任务转移通过 SDK 的任务转移管道运行,而不是通过常规函数工具管道运行,因此工具安全防护措施不适用于任务转移调用本身。托管工具(`WebSearchTool`、`FileSearchTool`、`HostedMCPTool`、`CodeInterpreterTool`、`ImageGenerationTool`)和内置执行工具(`ComputerTool`、`ShellTool`、`ApplyPatchTool`、`LocalShellTool`)也不使用此安全防护措施管道,并且 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 目前不直接提供工具安全防护措施选项。 -有关详细信息,请参阅下方的代码片段。 +有关详情,请参阅下方代码片段。 ## 触发器 -如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即引发 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止智能体执行。工具安全防护措施使用相应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 +如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即抛出 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止执行智能体。工具安全防护措施使用对应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 -对于智能体级别的触发器,异常的 `guardrail_result` 用于标识触发该触发器的安全防护措施。对于运行器引发的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,包括触发该触发器的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供等效的累积结果。 +对于智能体级别的触发器,异常的 `guardrail_result` 会标识触发该触发器的安全防护措施。对于运行器抛出的输入触发器,`exception.run_data.input_guardrail_results` 包含运行停止前已完成的所有输入安全防护措施结果,其中包括触发该触发器的结果。输出触发器通过 `exception.run_data.output_guardrail_results` 提供对应的累积结果。 -工具触发器异常则会直接公开触发该异常的 `guardrail` 和 `output`。它们的 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留失败前已完成轮次中累积的结果;触发结果可通过异常的 `output` 获取。其他由运行器管理的失败(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。`stream_events()` 引发异常后,流式传输结果会公开相同的累积智能体和工具安全防护措施结果列表。当异常在运行器管理的执行路径之外引发时,`run_data` 可以是 `None`。 +工具触发器异常则会直接公开触发它的 `guardrail` 和 `output`。其中的 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留失败前已完成轮次中累积的结果;触发结果可通过异常的 `output` 获取。其他由运行器管理的故障(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。在 `stream_events()` 抛出异常后,流式传输结果会公开相同的智能体和工具安全防护措施累积结果列表。在运行器管理的执行路径之外抛出异常时,`run_data` 可以是 `None`。 -## 安全防护措施的实现 +## 安全防护措施实现 -你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将通过在底层运行一个智能体来实现。 +你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在底层运行一个智能体来实现这一点。 ```python from pydantic import BaseModel @@ -136,7 +138,7 @@ async def main(): 3. 我们可以在安全防护措施结果中包含额外信息。 4. 这是定义工作流的实际智能体。 -输出安全防护措施与此类似。 +输出安全防护措施与之类似。 ```python from pydantic import BaseModel @@ -194,7 +196,7 @@ async def main(): 3. 这是接收智能体输出并返回结果的安全防护措施函数。 4. 这是定义工作流的实际智能体。 -最后,以下是工具安全防护措施的代码示例。 +最后,以下是工具安全防护措施的示例。 ```python import json diff --git a/docs/zh/release.md b/docs/zh/release.md index 107dd0a518..00ff37d720 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -4,17 +4,17 @@ search: --- # 发布流程/变更日志 -本项目采用略作修改的语义化版本控制,版本格式为`0.Y.Z`。开头的`0`表示 SDK 仍在快速演进。各组成部分按以下方式递增: +本项目采用略作修改的语义化版本控制,格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各部分按以下方式递增: ## 次版本(`Y`) -对于任何未标记为 beta 的公共接口,如果存在**破坏性变更**,我们将递增次版本`Y`。例如,从`0.0.x`升级到`0.1.x`时可能包含破坏性变更。 +对于任何未标记为 beta 的公共接口发生的**破坏性变更**,我们会递增次版本 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 -如果您不希望遇到破坏性变更,建议在项目中锁定`0.0.x`版本。 +如果您不希望引入破坏性变更,建议在项目中固定使用 `0.0.x` 版本。 ## 补丁版本(`Z`) -对于非破坏性变更,我们将递增`Z`: +对于非破坏性变更,我们会递增 `Z`: - 错误修复 - 新功能 @@ -23,60 +23,73 @@ search: ## 破坏性变更日志 +### 0.22.0 + +版本 0.22.0 加强了多个现有 API 的失败处理和数据隔离。使用显式客户端构造 `OpenAIProvider`,同时还向提供商传递 `organization` 或 `project` 的应用程序,必须移除这些重复参数。 + +要点: + +- 当智能体级输出安全防护措施阻止由终止函数工具直接生成的最终输出时,仅当经过验证的字段允许安全重建时,SDK 才会保留可用于重放的调用/输出对。原始 `function_call_output` 载荷会在会话历史记录、`RunState` 和流式结果状态中替换为固定文本 `"Output withheld by an output guardrail."`,而包含载荷的当前响应安全防护措施元数据会被清除或替换。如果当前响应包含推理内容或其他不受支持的结构,SDK 会改为丢弃完整的当前响应后缀。此前已接受的轮次和安全防护措施结果仍然可用。请参阅[输出安全防护措施](guardrails.md#output-guardrails)。 +- 对于非流式 OpenAI Responses 调用,当返回响应的终止状态为 `failed` 或 `incomplete` 时,现在会引发 `ModelBehaviorError`,与现有的流式终止事件处理方式一致。这适用于 `OpenAIResponsesModel` 以及 `AnyLLMModel` 中的 Responses 路径。请参阅[异常](running_agents.md#exceptions)。 +- 当 `openai_client` 与 `organization` 或 `project` 结合使用时,[`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider] 现在也会引发 `UserError`。与 `api_key`、`base_url` 和 `websocket_base_url` 的现有冲突保持不变。请改为在显式 `AsyncOpenAI` 客户端上配置这些值。请参阅 [API 密钥和客户端](config.md#api-keys-and-clients)。 +- 每个 `RunResult.to_state()` 检查点现在都拥有独立的用量快照。恢复后的结果以检查点总量为起点,并累加自身的模型调用,而不会修改源结果或同级检查点。嵌套的 `Agent.as_tool()` 恢复仍会将恢复后的用量汇总到当前活跃的外层运行中。请参阅 [RunState 检查点中的用量](usage.md#usage-in-runstate-checkpoints)。 +- 智能体可视化现在会递归展开通过 `handoff(agent)` 注册的目标所包含的工具、MCP服务器和下游任务转移,其行为与智能体 `handoffs` 列表中的直接 `Agent` 条目一致。请参阅[图形生成](visualization.md#generating-a-graph)。 +- `Agent.clone()` 和 `RealtimeAgent.clone()` 的 API 指南现在准确说明了其现有的浅拷贝行为:未被覆盖的列表属性仍是相同的列表对象。如果克隆对象必须独立拥有该容器,请传入新列表。请参阅[智能体的克隆/复制](agents.md#cloningcopying-agents)。 + ### 0.21.0 -版本 0.21.0 要求使用`openai` v3,并将 Agents SDK 的OpenAI HTTP 集成迁移至 HTTPX2。使用默认OpenAI客户端的应用程序无需更改客户端设置,但自定义OpenAI HTTP 层的应用程序可能需要迁移面向传输层的代码。 +版本 0.21.0 要求使用 `openai` v3,并将 Agents SDK的OpenAI HTTP 集成迁移到 HTTPX2。使用默认 OpenAI客户端的应用程序无需更改客户端设置,但自定义 OpenAI HTTP 层的应用程序可能需要迁移面向传输层的代码。 要点: -- 现在要求的OpenAI依赖项为`openai>=3.0.0,<4`。全新安装核心包时将使用 HTTPX2,并且不再将旧版`httpx`作为直接依赖项安装。 -- 默认OpenAI提供方、语音提供方、Responses WebSocket 支持、追踪导出器以及提供方重试规范化现在均使用 HTTPX2。它们现有的 Agents SDK 公共配置和运行时行为保持不变。 -- 向`AsyncOpenAI`传递`http_client=`的应用程序,应将自定义客户端、传输、身份验证、事件钩子、模拟传输、超时值、URL、请求、响应以及传输异常处理从`httpx`迁移至`httpx2`。如果应用程序既需要OpenAI客户端的默认设置,又需要自定义 HTTP 选项,请优先使用OpenAI Python SDK 的`DefaultAsyncHttpx2Client`。请参阅[使用`openai` v3 的自定义 HTTP 客户端](config.md#custom-http-clients-with-openai-v3)。 -- Agents SDK 不会将任意旧版 HTTPX 对象转换为 HTTPX2。OpenAI Python SDK 的临时旧版客户端兼容路径要求显式安装`httpx`,并且应仅将其视为迁移桥梁。 -- 本地 MCP HTTP 自定义继续遵循已安装的 MCP 软件包:MCP Python SDK v1 提供并使用旧版`httpx`,而 MCP Python SDK v2 使用`httpx2`。普通 MCP 连接无需更改应用程序。请参阅[MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 -- 公共的提供方中立测试实用工具现在可以覆盖智能体模型、沙箱会话、Realtime 会话和语音管线工作流,而无需依赖提供方或进程。有关使用方法以及何时应保留实际提供方适配器或集成边界的指导,请参阅[测试](testing.md)。 +- 现在要求的 OpenAI依赖项为 `openai>=3.0.0,<4`。全新的核心安装使用 HTTPX2,并且不再将旧版 `httpx` 作为直接依赖项安装。 +- 默认 OpenAI提供商、语音提供商、Responses WebSocket 支持、追踪导出器和提供商重试规范化现在使用 HTTPX2。其现有的 Agents SDK公共配置和运行时行为保持不变。 +- 向 `AsyncOpenAI` 传递 `http_client=` 的应用程序,应将自定义客户端、传输、身份验证、事件钩子、模拟传输、超时值、URL、请求、响应和传输异常处理从 `httpx` 迁移到 `httpx2`。如果应用程序既需要 OpenAI客户端的默认设置,又需要自定义 HTTP 选项,请优先使用 OpenAI Python SDK的 `DefaultAsyncHttpx2Client`。请参阅[使用 `openai` v3 的自定义 HTTP 客户端](config.md#custom-http-clients-with-openai-v3)。 +- Agents SDK不会将任意旧版 HTTPX 对象转换为 HTTPX2。OpenAI Python SDK的临时旧版客户端兼容路径要求显式安装 `httpx`,并且应将其视为迁移过渡方案。 +- 本地 MCP HTTP 自定义继续遵循已安装的 MCP软件包:MCP Python SDK v1 提供并使用旧版 `httpx`,而 MCP Python SDK v2 使用 `httpx2`。普通 MCP连接无需更改应用程序。请参阅 [MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 +- 公共的提供商中立测试实用工具现在无需依赖提供商或进程,即可覆盖智能体模型、沙箱会话、Realtime 会话和语音管线工作流。有关操作方法以及何时应保留真实提供商适配器或集成边界的指南,请参阅[测试](testing.md)。 ### 0.20.0 -版本 0.20.0 包含一项可能造成破坏性变更的 MCP 依赖项迁移,会影响自定义本地 MCP HTTP 传输的应用程序。它还更新了智能体或运行未显式选择模型时使用的 SDK 默认模型。 +版本 0.20.0 包含一项可能具有破坏性的 MCP依赖项迁移,影响自定义本地 MCP HTTP 传输的应用程序。它还更新了智能体或运行未显式选择模型时所使用的 SDK 默认模型。 要点: -- SDK 默认模型现在是`gpt-5.6-luna`,而不再是`gpt-5.4-mini`。默认的`reasoning.effort="none"`和`verbosity="low"`设置保持不变。 -- 显式指定的智能体模型、运行级模型覆盖以及`OPENAI_DEFAULT_MODEL`环境变量仍然优先于 SDK 默认值。 -- Realtime 输入转录设置现在可识别`gpt-transcribe`、`gpt-live-transcribe`和`gpt-realtime-whisper`。对于低延迟`gpt-live-transcribe`会话,嵌套的`audio.input.transcription`设置可以提供`prompt`、`keywords`以及多个预期的`languages`。此 SDK 锁定的OpenAI客户端版本仅在使用`gpt-realtime-whisper`时支持`delay`延迟/准确性级别。若要在提交一个音频轮次后进行转录,或输出检测到的语言,请通过 WebSocket 使用`gpt-transcribe`。显式设置`audio.input.turn_detection=None`会禁用自动轮次检测。请参阅[输入转录设置](realtime/guide.md#input-transcription-settings)。 -- Agents SDK 创建的本地 MCP 连接现在支持 MCP Python SDK v2,同时通过`mcp>=1.19.0,<3`保留对 v1 的兼容性。Agents SDK 会自动适配普通的 stdio、SSE 和 Streamable HTTP 连接。安装 MCP v2 后,这些连接会使用`mcp.Client(mode="auto")`探测支持的最新协议,并针对较旧的服务器回退到旧版`initialize`握手。如果依赖项解析选择 MCP v2,则提供自定义`httpx.Auth`对象或`httpx.AsyncClient`工厂的应用程序必须将这些值迁移至`httpx2`,或者锁定`mcp<2`以保留 v1 HTTP 栈。`MCPServerStreamableHttp`的`params["ignore_initialized_notification_failure"] = True`选项也仍然仅支持 v1。有关迁移详情,请参阅[MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 -- 沙箱挂载验证现在会在产生沙箱或挂载辅助程序的副作用之前,拒绝不安全的凭据放置。受信任的应用程序可以针对容器内的确切挂载路径,确认挂载范围或广泛的凭据暴露,而无需更改存储能力表。这些确认仅在运行时有效,序列化后的沙箱状态本身绝不会授予凭据权限。在受保护的挂载边界处,SDK 会返回一个新的、已脱敏的异常。如果源异常是 SDK 可准确识别的沙箱错误,且其获准的结构化字段通过验证,则替代异常会保留该子类型以及通过验证的安全字段。可识别的`MountConfigError`也可以保留由 SDK 生成的安全验证消息。否则,SDK 会返回一个新的通用脱敏错误。提供方控制的消息或其他未经批准的消息、命令数据、注释、上下文、原因以及源回溯状态均不会保留。请参阅[挂载与远程存储](sandbox/clients.md#mounts-and-remote-storage)和[从会话状态恢复](sandbox/guide.md#resume-from-session-state)。 -- 重试策略可以检查稳定的重放安全性事实,并针对被提供方标记为不安全的非流式请求显式设置`RetryDecision(approve_unsafe_replay=True)`。此批准不会绕过中止、已发出的流式输出或其他针对本地副作用的否决机制,例如程序化工具调用。请参阅[Runner 管理的重试](models/index.md#runner-managed-retries)。 -- 可恢复的`RunState`对象现在可以在下一次模型调用前,使用`add_input()`暂存持久化的用户输入。暂存的输入可以在序列化后继续保留,会经过输入安全防护措施,并在本地会话和服务器管理的对话中生成一次持久化的 SDK 输入记录。经显式批准的不安全重放仍可能将输入重新发送给提供方,并重复提供方侧的工作。请参阅[恢复前添加输入](results.md#add-input-before-resuming)。 -- 运行时可靠性修复统一了流式和非流式[输出安全防护措施的会话持久化行为](guardrails.md#output-guardrails),在复制和添加命名空间时保留`FunctionTool`子类,并针对[不受支持的 Chat Completions 音频输出](models/index.md#chat-completions-compatibility-options)引发明确错误,而不是静默完成空流。`OpenAIResponsesCompactionSession`包装器会在取消操作传递给调用方之前,尝试并等待[压缩前历史记录恢复](sessions/index.md#auto-compaction-can-block-streaming)。[`VoicePipeline`](voice/pipeline.md#results)使用方现在会在运行正常完成后收到转录会话关闭失败,而较早发生的轮次失败仍优先于稍后发生的关闭失败。`RunState`往返转换现在会保留本地 shell 输出、已确认的计算机安全检查、使用默认值的工具输出字段,以及遍历字典、列表或元组时遇到的 Pydantic 模型或 dataclass 输出。MCP 转换会保留自由形式的对象 schema 和图像输出,并将音频和资源块等其他原始内容块序列化为有效的 JSON 文本。`MCPServerManager`会串行化重叠的生命周期操作,并为连接和清理应用有限的默认超时。模型重放会先从输出项中移除服务器拥有的`created_by`元数据,再将其用作输入。 +- SDK 默认模型现在是 `gpt-5.6-luna`,而不再是 `gpt-5.4-mini`。默认的 `reasoning.effort="none"` 和 `verbosity="low"` 设置保持不变。 +- 显式指定的智能体模型、运行级模型覆盖以及 `OPENAI_DEFAULT_MODEL` 环境变量仍然优先于 SDK 默认值。 +- Realtime 输入转录设置现在可识别 `gpt-transcribe`、`gpt-live-transcribe` 和 `gpt-realtime-whisper`。对于低延迟 `gpt-live-transcribe` 会话,嵌套的 `audio.input.transcription` 设置可以提供 `prompt`、`keywords` 和多个预期的 `languages`。此 SDK 固定使用的 OpenAI客户端版本仅在搭配 `gpt-realtime-whisper` 时支持 `delay` 延迟/准确度级别。若要在提交音频轮次后进行转录,或获取检测到的语言输出,请通过 WebSocket 使用 `gpt-transcribe`。显式设置 `audio.input.turn_detection=None` 会禁用自动轮次检测。请参阅[输入转录设置](realtime/guide.md#input-transcription-settings)。 +- 由 Agents SDK创建的本地 MCP连接现在支持 MCP Python SDK v2,同时通过 `mcp>=1.19.0,<3` 保持与 v1 的兼容性。Agents SDK会自动适配普通的 stdio、SSE 和 Streamable HTTP 连接。安装 MCP v2 后,这些连接会使用 `mcp.Client(mode="auto")` 探测最新受支持的协议,并针对旧版服务器回退到传统的 `initialize` 握手。如果依赖项解析选择了 MCP v2,则提供自定义 `httpx.Auth` 对象或 `httpx.AsyncClient` 工厂的应用程序必须将这些值迁移到 `httpx2`,或者固定使用 `mcp<2` 以保留 v1 HTTP 栈。`MCPServerStreamableHttp` 的 `params["ignore_initialized_notification_failure"] = True` 选项也仍然仅支持 v1。有关迁移详情,请参阅 [MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 +- 沙箱挂载验证现在会在产生沙箱或挂载辅助程序的副作用之前,拒绝不安全的凭证放置方式。受信任的应用程序可以针对容器内的确切挂载路径,确认挂载范围内或广泛的凭证暴露,而无需更改存储能力表。这些确认仅在运行时有效,序列化的沙箱状态本身绝不会授予凭证权限。在受保护的挂载边界处,SDK 会返回一个新的、已脱敏的异常。如果源异常是可明确识别的 SDK 沙箱错误,并且其获准的结构化字段通过验证,则替代异常会保留该子类型和经过验证的安全字段。可识别的 `MountConfigError` 也可以保留由 SDK 生成的安全验证消息。否则,SDK 会返回一个新的通用脱敏错误。由提供商控制或未经批准的消息、命令数据、注释、上下文、原因和源回溯状态均不会保留。请参阅[挂载与远程存储](sandbox/clients.md#mounts-and-remote-storage)和[从会话状态恢复](sandbox/guide.md#resume-from-session-state)。 +- 重试策略可以检查稳定的重放安全事实,并为提供商标记为不安全的非流式请求显式设置 `RetryDecision(approve_unsafe_replay=True)`。此批准不会绕过中止、已发出的流式输出,也不会绕过诸如程序化工具调用等单独的本地副作用否决。请参阅[由 Runner 管理的重试](models/index.md#runner-managed-retries)。 +- 可恢复的 `RunState` 对象现在可以在下次模型调用之前,使用 `add_input()` 暂存持久化用户输入。暂存的输入可在序列化后保留,会经过输入安全防护措施,并在本地会话和服务器管理的对话中产生一次持久化 SDK 输入记录。显式批准的不安全重放仍可能向提供商重新发送输入,并重复提供商侧的工作。请参阅[恢复前添加输入](results.md#add-input-before-resuming)。 +- 运行时可靠性修复统一了流式和非流式的[输出安全防护措施会话持久化](guardrails.md#output-guardrails),在复制和命名空间处理期间保留 `FunctionTool` 子类,并针对[不受支持的 Chat Completions 音频输出](models/index.md#chat-completions-compatibility-options)引发显式错误,而不是静默完成空流。`OpenAIResponsesCompactionSession` 包装器会在取消操作到达调用方之前,尝试并等待[压缩前历史记录恢复](sessions/index.md#auto-compaction-can-block-streaming)。[`VoicePipeline`](voice/pipeline.md#results) 使用方现在会在运行正常结束后收到转录会话关闭失败;如果某个轮次更早发生失败,则该失败的优先级高于之后的关闭失败。`RunState` 往返转换现在会保留本地 shell 输出、已确认的计算机安全检查、采用默认值的工具输出字段,以及遍历字典、列表或元组时遇到的 Pydantic 模型或数据类输出。MCP转换会保留自由形式的对象 schema 和图像输出,并将音频块、资源块等其他原始内容块序列化为有效的 JSON 文本。`MCPServerManager` 会对重叠的生命周期操作进行串行化,并为连接和清理应用有限的默认超时。模型重放会先从输出项中移除服务器拥有的 `created_by` 元数据,再将其用作输入。 ### 0.19.0 -此次次版本发布**没有**引入破坏性变更。次版本号的递增反映了一个重要的新OpenAI Responses 功能领域:程序化工具调用。 +此次次版本发布**不会**引入破坏性变更。次版本号递增是因为新增了一个重要的 OpenAI Responses 功能领域:程序化工具调用。 要点: -- 新增[`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],使受支持的OpenAI Responses 模型能够生成 JavaScript,以协调符合程序化工具调用条件的工具。它支持按工具设置`allowed_callers`、来自`FunctionTool`实例的 structured outputs,并支持与 Runner 流式传输、安全防护措施、批准、会话和`RunState`集成。有关设置方式和约束,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 -- 新增公共`agents.decorators`模块和`@tool`,后者是现有`@function_tool`装饰器的较短别名,与现有安全防护措施装饰器并列提供。`FunctionTool`实例现在还支持异步可调用对象。 -- SDK 配置现在可在智能体、运行、模型、会话、沙箱和语音管线中一致地接受类型化设置对象或字典,并会验证未知设置。 -- 加强了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误与诊断日志,避免暴露原始敏感载荷,同时保留有用的调试上下文。 -- 改进了 AnyLLM、LiteLLM 和 Chat Completions 兼容性,在模型重试期间保留会话历史记录,并针对响应开始前发生的 WebSocket 过载新增了提供方重试指导,使选择启用的 Runner 重试策略能够在获得许可时重放失败的尝试。 -- 通过`VercelCloudBucketMountStrategy`新增了[只能在创建 Vercel 沙箱时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。已挂载的会话不会将存储桶内容纳入工作区持久化,并且有意不支持动态挂载变更或会话恢复。 +- 新增 [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool],支持的 OpenAI Responses模型可通过它生成 JavaScript,以协调符合程序化工具调用条件的工具。它支持每个工具的 `allowed_callers`、来自 `FunctionTool` 实例的 structured outputs,以及与 Runner 流式传输、安全防护措施、审批、会话和 `RunState` 的集成。有关设置和约束,请参阅[程序化工具调用](tools.md#programmatic-tool-calling)。 +- 新增公共 `agents.decorators` 模块,并增加 `@tool`,作为现有 `@function_tool` 装饰器的较短别名,同时保留现有安全防护措施装饰器。`FunctionTool` 实例现在也支持异步可调用对象。 +- 现在,SDK 配置可在智能体、运行、模型、会话、沙箱和语音管线中一致地接受带类型的设置对象或字典,并会验证未知设置。 +- 强化了模型、工具、MCP、Realtime、会话、沙箱和追踪中的错误及诊断日志记录,在保留有用调试上下文的同时避免暴露原始敏感载荷。 +- 改进了 AnyLLM、LiteLLM 和 Chat Completions 的兼容性,在模型重试期间保留会话历史记录,并为响应开始前发生的 WebSocket 过载添加了提供商重试指南,使选择启用的 Runner 重试策略可以在获准时重放失败的尝试。 +- 通过 `VercelCloudBucketMountStrategy` 新增了[仅能在创建 Vercel 沙箱时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。已挂载的会话会从工作区持久化中排除存储桶内容,并且有意不支持动态挂载变更或会话恢复。 ### 0.18.0 -此次次版本发布**没有**引入破坏性变更。次版本号仅因 Realtime 智能体默认模型更新而递增。 +此次次版本发布**不会**引入破坏性变更。次版本号递增仅用于更新 Realtime 智能体的默认模型。 要点: -- Realtime智能体现在使用`gpt-realtime-2.1`作为默认模型,因此新的 Realtime 设置无需额外配置即可使用最新推荐模型。 +- Realtime 智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 设置无需额外配置即可使用最新的推荐模型。 ### 0.17.0 -在此版本中,沙箱本地源具体化会将`LocalFile.src`和`LocalDir.src`限制在具体化`base_dir`之内,除非源路径受`Manifest.extra_path_grants`覆盖。应用清单时,`base_dir`是 SDK 进程的当前工作目录;相对本地源将从该目录解析,而绝对本地源必须已位于该目录内或显式授权的目录下。此变更修复了一个本地制品边界问题,但可能会影响有意将该基础目录之外的受信任主机文件或目录复制到沙箱工作区的应用程序。 +在此版本中,除非源路径由 `Manifest.extra_path_grants` 覆盖,否则沙箱本地源实体化会将 `LocalFile.src` 和 `LocalDir.src` 限制在实体化 `base_dir` 内。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源从该目录解析,而绝对本地源必须已经位于其中或位于显式授权的路径下。此变更修复了本地产物边界问题,但可能影响有意将该基础目录之外的受信任主机文件或目录复制到沙箱工作区的应用程序。 -若要迁移,请在清单级别使用`SandboxPathGrant`授予对受信任主机根目录的访问权限;如果沙箱只需读取这些文件,最好授予只读权限: +若要迁移,请使用 `SandboxPathGrant` 在清单级别授予对受信任主机根目录的访问权限;如果沙箱只需读取这些文件,最好授予只读权限: ```python from pathlib import Path @@ -103,13 +116,13 @@ manifest = Manifest( ) ``` -请将`extra_path_grants`视为受信任的应用程序配置。除非应用程序已批准这些主机路径,否则不要根据模型输出或其他不受信任的清单输入填充授权。 +请将 `extra_path_grants` 视为受信任的应用程序配置。除非应用程序已批准这些主机路径,否则不要根据模型输出或其他不受信任的清单输入填充授权。 ### 0.16.0 -在此版本中,SDK 默认模型现在是`gpt-5.4-mini`,而不再是`gpt-4.1`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含`reasoning.effort="none"`和`verbosity="low"`等 GPT-5 默认值。 +在此版本中,SDK 默认模型现在是 `gpt-5.4-mini`,而不再是 `gpt-4.1`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包括 `reasoning.effort="none"` 和 `verbosity="low"` 等 GPT-5 默认值。 -如果需要保留之前的默认模型行为,请在智能体或运行配置中显式设置模型,或者设置`OPENAI_DEFAULT_MODEL`环境变量: +如果您需要保留之前的默认模型行为,请在智能体或运行配置中显式设置模型,或者设置 `OPENAI_DEFAULT_MODEL` 环境变量: ```python agent = Agent(name="Assistant", model="gpt-4.1") @@ -117,14 +130,14 @@ agent = Agent(name="Assistant", model="gpt-4.1") 要点: -- `Runner.run`、`Runner.run_sync`和`Runner.run_streamed`现在接受`max_turns=None`以禁用轮次限制。 -- 对于本地、Docker 和提供方支持的沙箱实现,沙箱工作区填充现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,包括目标为绝对路径的符号链接。 +- `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 +- 对于本地、Docker 和提供商支持的沙箱实现,沙箱工作区填充现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,其中也包括目标为绝对路径的符号链接。 ### 0.15.0 -在此版本中,模型拒绝现在会显式呈现为`ModelRefusalError`,而不再被视为空文本输出;对于结构化输出,也不会再导致运行循环不断重试,直至触发`MaxTurnsExceeded`。 +在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不会被视为空文本输出;对于 structured outputs,也不会再导致运行循环持续重试直至 `MaxTurnsExceeded`。 -这会影响此前预期仅包含拒绝的模型响应以`final_output == ""`完成的代码。若要在不引发异常的情况下处理拒绝,请提供`model_refusal`运行错误处理程序: +这会影响此前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不引发异常,请提供 `model_refusal` 运行错误处理程序: ```python result = Runner.run_sync( @@ -134,94 +147,94 @@ result = Runner.run_sync( ) ``` -对于结构化输出智能体,处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样验证该值。 +对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样验证该值。 ### 0.14.0 -此次次版本发布**没有**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 +此次次版本发布**不会**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 要点: -- 新增以`SandboxAgent`、`Manifest`和`SandboxRunConfig`为核心的 beta 沙箱运行时接口,使智能体可以在持久化的隔离工作区中处理文件、目录、Git 仓库、挂载和快照,并支持恢复。 -- 通过`UnixLocalSandboxClient`和`DockerSandboxClient`新增用于本地及容器化开发的沙箱执行后端,并通过 Python 软件包中的可选依赖 extras,为 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 新增托管提供方集成。 +- 新增以 `SandboxAgent`、`Manifest` 和 `SandboxRunConfig` 为核心的 beta 沙箱运行时接口,使智能体能够在持久化的隔离工作区内处理文件、目录、Git 仓库、挂载和快照,并支持恢复。 +- 新增通过 `UnixLocalSandboxClient` 和 `DockerSandboxClient` 实现的本地及容器化开发沙箱执行后端,并通过 Python 软件包中的可选依赖 extras,为 Blaxel、Cloudflare、Daytona、E2B、Modal、Runloop 和 Vercel 提供托管提供商集成。 - 新增沙箱记忆支持,使未来的运行能够复用以往运行中获得的经验,并提供渐进式披露、多轮分组、可配置的隔离边界,以及包括 S3 支持工作流在内的持久化记忆代码示例。 -- 新增更全面的工作区和恢复模型,包括本地及合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过`RunState`、`SandboxSessionState`或已保存快照执行的恢复流程。 -- 在`examples/sandbox/`下新增大量沙箱代码示例和教程,涵盖使用技能、任务转移和记忆完成编码任务、特定于提供方的设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 -- 扩展核心运行时和追踪栈,新增可感知沙箱的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP 输出脱敏。 +- 新增更广泛的工作区和恢复模型,包括本地及合成工作区条目、S3/R2/GCS/Azure Blob Storage/S3 Files 的远程存储挂载、可移植快照,以及通过 `RunState`、`SandboxSessionState` 或已保存快照实现的恢复流程。 +- 在 `examples/sandbox/` 下新增大量沙箱代码示例和教程,涵盖使用技能、任务转移和记忆完成编码任务、提供商专用设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 +- 扩展了核心运行时和追踪栈,新增沙箱感知的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP输出脱敏。 ### 0.13.0 -此次次版本发布**没有**引入破坏性变更,但包含一项值得注意的 Realtime 默认值更新、新的 MCP 功能以及运行时稳定性修复。 +此次次版本发布**不会**引入破坏性变更,但包含一项值得注意的 Realtime 默认值更新、新的 MCP能力以及运行时稳定性修复。 要点: -- 默认 WebSocket Realtime 模型现在是`gpt-realtime-1.5`,因此新的 Realtime 智能体设置无需额外配置即可使用更新的模型。 -- `MCPServer`现在公开`list_resources()`、`list_resource_templates()`和`read_resource()`,而`MCPServerStreamableHttp`现在公开`session_id`,因此使用 MCP Streamable HTTP 传输的会话可以在重新连接或无状态工作进程之间恢复。 -- Chat Completions 集成现在可以通过`should_replay_reasoning_content`选择重新发送现有推理内容,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供方的推理/工具调用连续性。 -- 修复了若干运行时和会话边界情况,包括`SQLAlchemySession`中的并发首次写入、移除推理内容后存在孤立助手消息 ID 的压缩请求、`remove_all_tools()`遗留 MCP/推理项,以及`FunctionTool`实例的批处理执行器中的竞态条件。 +- 默认 websocket Realtime 模型现在是 `gpt-realtime-1.5`,因此新的 Realtime 智能体设置无需额外配置即可使用更新的模型。 +- `MCPServer` 现在公开 `list_resources()`、`list_resource_templates()` 和 `read_resource()`,而 `MCPServerStreamableHttp` 现在公开 `session_id`,因此使用 MCP Streamable HTTP 传输的会话可以在重新连接后或无状态工作进程之间恢复。 +- Chat Completions 集成现在可以通过 `should_replay_reasoning_content` 选择重新发送现有推理内容,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理/工具调用连续性。 +- 修复了多个运行时和会话边界情况,包括 `SQLAlchemySession` 中的并发首次写入、移除推理内容后带有孤立助手消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项,以及 `FunctionTool` 实例批处理执行器中的竞态条件。 ### 0.12.0 -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 +此次次版本发布**不会**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 ### 0.11.0 -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 +此次次版本发布**不会**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 ### 0.10.0 -此次次版本发布**没有**引入破坏性变更,但为OpenAI Responses 用户新增了一个重要功能领域:Responses API 的 WebSocket 传输支持。 +此次次版本发布**不会**引入破坏性变更,但为 OpenAI Responses用户新增了一个重要功能领域:Responses API 的 websocket 传输支持。 要点: -- 新增对OpenAI Responses 模型的 WebSocket 传输支持(选择启用;HTTP 仍为默认传输)。 -- 新增`responses_websocket_session()`辅助程序/`ResponsesWebSocketSession`,用于在多轮运行中复用支持共享 WebSocket 的提供方和`RunConfig`。 -- 新增一个 WebSocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、批准和后续轮次。 +- 新增对 OpenAI Responses模型的 websocket 传输支持(选择启用;HTTP 仍是默认传输)。 +- 新增 `responses_websocket_session()` 辅助函数/`ResponsesWebSocketSession`,用于在多轮运行中复用支持 websocket 的共享提供商和 `RunConfig`。 +- 新增 websocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 ### 0.9.0 -在此版本中,不再支持 Python 3.9,因为该主版本已于三个月前终止支持。请升级到更新的运行时版本。 +在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前终止支持。请升级到较新的运行时版本。 -此外,`Agent#as_tool()`方法返回值的类型提示已从`Tool`收窄为`FunctionTool`。此变更通常不会造成破坏性问题,但如果您的代码依赖较宽泛的联合类型,可能需要进行一些调整。 +此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会造成破坏性问题,但如果您的代码依赖更宽泛的联合类型,可能需要进行一些调整。 ### 0.8.0 -在此版本中,两项运行时行为变更可能需要执行迁移: +在此版本中,两项运行时行为变更可能需要迁移: -- 包装**同步** Python 可调用对象的`FunctionTool`实例现在通过`asyncio.to_thread(...)`在工作线程上执行,而不再在事件循环线程上运行。如果工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移至异步工具实现,或者在工具代码中显式指定线程亲和性。 -- 本地 MCP 工具失败处理现在可配置,并且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果依赖快速失败语义,请设置`mcp_config={"failure_error_function": None}`。服务器级`failure_error_function`值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地 MCP 服务器上设置`failure_error_function=None`。 +- 包装**同步** Python 可调用对象的 `FunctionTool` 实例现在会通过 `asyncio.to_thread(...)` 在工作线程中执行,而不再在事件循环线程上运行。如果您的工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中显式指定线程亲和性。 +- 本地 MCP工具失败处理现在可以配置,并且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果您依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务器级 `failure_error_function` 值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地 MCP服务器上设置 `failure_error_function=None`。 ### 0.7.0 在此版本中,有几项行为变更可能会影响现有应用程序: -- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果依赖 v0.6.x 中默认的嵌套行为,请显式设置`RunConfig(nest_handoff_history=True)`。 -- `gpt-5.1`/`gpt-5.2`的默认`reasoning.effort`已改为`"none"`(之前的默认值为 SDK 默认设置配置的`"low"`)。如果您的提示词或质量/成本配置依赖`"low"`,请在`model_settings`中显式设置它。 +- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果您依赖 v0.6.x 的默认嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 +- `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(之前是由 SDK 默认值配置的 `"low"`)。如果您的提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置它。 ### 0.6.0 -在此版本中,默认任务转移历史记录现在会打包到单条助手消息中,而不是将用户和助手轮次作为单独消息传递,从而为下游智能体提供简洁、可预测的回顾 -- 现有的单消息任务转移记录现在默认以确切的字面文本`For context, here is the conversation so far between the user and the previous agent:`开头,后接``块,使下游智能体获得带有明确标签的回顾 +在此版本中,默认任务转移历史记录现在会封装为一条助手消息,而不再将用户和助手轮次作为单独消息传递,从而为下游智能体提供简洁且可预测的摘要 +- 现有的单消息任务转移记录现在默认会在 `` 块之前,以完全一致的字面文本 `For context, here is the conversation so far between the user and the previous agent:` 开头,以便下游智能体获得带有清晰标签的摘要 ### 0.5.0 -此版本没有引入任何可见的破坏性变更,但包含新功能以及一些重要的底层更新: +此版本不会引入任何可见的破坏性变更,但包含新功能和若干重要的底层更新: -- 在`RealtimeRunner`中新增了对处理[SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 -- 大幅修改了`Runner#run_sync`的内部逻辑,以兼容 Python 3.14 +- 在 `RealtimeRunner` 中新增对处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 +- 大幅修订了 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 ### 0.4.0 -在此版本中,不再支持 [openai](https://pypi.org/project/openai/) 软件包的 v1.x 版本。请将 openai v2.x 与此 SDK 配合使用。 +在此版本中,不再支持 [openai](https://pypi.org/project/openai/) 软件包 v1.x 版本。请将 openai v2.x 与此 SDK 搭配使用。 ### 0.3.0 -在此版本中,Realtime API 支持迁移至 gpt-realtime 模型及其 API 接口(正式发布版本)。 +在此版本中,Realtime API支持迁移到 gpt-realtime 模型及其 API 接口(GA 版本)。 ### 0.2.0 -在此版本中,一些过去接受`Agent`作为参数的位置现在改为接受`AgentBase`。例如,这适用于 MCP 服务器中的`list_tools()`方法签名。这纯粹是类型层面的变更,您仍会收到`Agent`对象。更新时,只需将`Agent`替换为`AgentBase`,以修复类型错误。 +在此版本中,之前有几处接受 `Agent` 作为参数的位置,现在改为接受 `AgentBase`。例如,这适用于 MCP服务器中的 `list_tools()` 方法签名。这只是类型方面的变更,您仍会收到 `Agent` 对象。若要更新,只需将 `Agent` 替换为 `AgentBase`,以修复类型错误。 ### 0.1.0 -在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer]新增了两个参数:`run_context`和`agent`。您需要将这些参数添加到`MCPServer`子类中每个被重写的`MCPServer.list_tools()`方法。 \ No newline at end of file +在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。您需要将这些参数添加到 `MCPServer` 子类中每个被重写的 `MCPServer.list_tools()` 方法。 \ No newline at end of file diff --git a/docs/zh/results.md b/docs/zh/results.md index 0024caea1f..e444d3a692 100644 --- a/docs/zh/results.md +++ b/docs/zh/results.md @@ -6,31 +6,31 @@ search: 调用 `Runner.run` 方法时,你会收到以下两种结果类型之一: -- 从 `Runner.run(...)` 或 `Runner.run_sync(...)` 返回的 [`RunResult`][agents.result.RunResult] -- 从 `Runner.run_streamed(...)` 返回的 [`RunResultStreaming`][agents.result.RunResultStreaming] +- 来自 `Runner.run(...)` 或 `Runner.run_sync(...)` 的 [`RunResult`][agents.result.RunResult] +- 来自 `Runner.run_streamed(...)` 的 [`RunResultStreaming`][agents.result.RunResultStreaming] -二者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者提供共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 +两者都继承自 [`RunResultBase`][agents.result.RunResultBase],后者公开了共享的结果接口,例如 `final_output`、`new_items`、`last_agent`、`raw_responses` 和 `to_state()`。 -`RunResultStreaming` 还提供流式传输专用的控制项,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 +`RunResultStreaming` 增加了流式传输专用的控制项,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 -## 适当结果接口的选择 +## 合适的结果接口 大多数应用只需要少数几个结果属性或辅助方法: | 如果你需要…… | 使用 | | --- | --- | | 向用户显示的最终答案 | `final_output` | -| 包含完整本地对话记录、可直接用于重放的下一轮输入列表 | `to_input_list()` | +| 包含完整本地对话记录、可供重放的下一轮输入列表 | `to_input_list()` | | 包含智能体、工具、任务转移和审批元数据的丰富运行项 | `new_items` | | 通常应处理下一轮用户输入的智能体 | `last_agent` | -| 使用 `previous_response_id` 进行OpenAI的 Responses API 链式调用 | `last_response_id` | -| 待处理的审批和可恢复快照 | `interruptions` 和 `to_state()` | +| 使用 `previous_response_id` 的 OpenAI Responses API 链式调用 | `last_response_id` | +| 待处理的审批和可恢复的快照 | `interruptions` 和 `to_state()` | | 当前嵌套 `Agent.as_tool()` 调用的元数据 | `agent_tool_invocation` | | 原始模型调用或安全防护措施诊断信息 | `raw_responses` 和安全防护措施结果数组 | ## 最终输出 -[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后运行的智能体所产生的最终输出。它可能是: +[`final_output`][agents.result.RunResultBase.final_output] 属性包含最后运行的智能体所生成的最终输出。它可能是: - 如果最后一个智能体未定义 `output_type`,则为 `str` - 如果最后一个智能体定义了输出类型,则为 `last_agent.output_type` 类型的对象 @@ -38,55 +38,55 @@ search: !!! note - `final_output` 的类型标注为 `Any`。任务转移可能会改变最终结束运行的智能体,因此 SDK 无法静态获知所有可能的输出类型。 + `final_output` 的类型标注为 `Any`。任务转移可能会改变完成运行的智能体,因此 SDK 无法静态确定所有可能的输出类型。 -在流式传输模式下,`final_output` 会一直保持为 `None`,直到流处理完成。有关逐事件的处理流程,请参阅[流式传输](streaming.md)。 +在流式传输模式下,`final_output` 会一直保持为 `None`,直到流处理完成。有关逐事件流程,请参阅[流式传输](streaming.md)。 ## 输入、下一轮历史记录和新项目 -以下接口分别回答不同的问题: +这些接口分别回答不同的问题: -| 属性或辅助方法 | 包含的内容 | 最适合的场景 | +| 属性或辅助方法 | 包含的内容 | 最适合 | | --- | --- | --- | -| [`input`][agents.result.RunResultBase.input] | 此运行片段的基础输入。如果任务转移输入过滤器重写了历史记录,这里会反映运行继续执行时所使用的过滤后输入。 | 审计此次运行实际使用的输入 | -| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 此次运行的输入项视图。默认的 `mode="preserve_all"` 会保留来自 `new_items` 的转换后历史记录,但不会再次追加已移入 SDK 默认嵌套任务转移历史记录的同一会话项实例;当任务转移过滤重写模型历史记录时,`mode="normalized"` 会优先使用标准续接输入。 | 手动聊天循环、由客户端管理的对话状态,以及普通项目历史记录检查 | -| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 包装器。 | 日志、UI、审计和调试 | -| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 此次运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供商级诊断或原始响应检查 | +| [`input`][agents.result.RunResultBase.input] | 此运行片段的基础输入。如果任务转移输入过滤器重写了历史记录,这里会反映运行继续使用的已过滤输入。 | 审核此运行实际使用的输入 | +| [`to_input_list()`][agents.result.RunResultBase.to_input_list] | 运行的输入项视图。默认的 `mode="preserve_all"` 会保留来自 `new_items` 的转换后历史记录,但不会再次追加已移入 SDK 默认嵌套任务转移历史记录中的同一会话项;当任务转移过滤重写模型历史记录时,`mode="normalized"` 会优先采用规范的延续输入。 | 手动聊天循环、由客户端管理的对话状态,以及普通项目形式的历史记录检查 | +| [`new_items`][agents.result.RunResultBase.new_items] | 包含智能体、工具、任务转移和审批元数据的丰富 [`RunItem`][agents.items.RunItem] 包装器。 | 日志、UI、审核和调试 | +| [`raw_responses`][agents.result.RunResultBase.raw_responses] | 运行中每次模型调用产生的原始 [`ModelResponse`][agents.items.ModelResponse] 对象。 | 提供商级别的诊断或原始响应检查 | -在实践中: +实际使用时: -- 如果需要此次运行的普通输入项视图,请使用 `to_input_list()`。 -- 如果在任务转移过滤或嵌套任务转移历史记录重写后,需要用于下一次 `Runner.run(..., input=...)` 调用的标准本地输入,请使用 `to_input_list(mode="normalized")`。 +- 如果需要运行的普通输入项视图,请使用 `to_input_list()`。 +- 如果在任务转移过滤或嵌套任务转移历史记录重写后,需要用于下一次 `Runner.run(..., input=...)` 调用的规范本地输入,请使用 `to_input_list(mode="normalized")`。 - 如果希望 SDK 为你加载和保存历史记录,请使用 [`session=...`](sessions/index.md)。 -- 如果使用由OpenAI管理且带有 `conversation_id` 或 `previous_response_id` 的服务端状态,通常只需传入新的用户输入并复用已存储的 ID,而不必重新发送 `to_input_list()`。 -- 如果需要用于日志、UI 或审计的完整转换后历史记录,请使用默认的 `to_input_list()` 模式或 `new_items`。 +- 如果正在使用通过 `conversation_id` 或 `previous_response_id` 实现的 OpenAI服务器托管状态,通常只需传递新的用户输入并复用已存储的 ID,而不是重新发送 `to_input_list()`。 +- 如果日志、UI 或审核需要完整的转换后历史记录,请使用默认的 `to_input_list()` 模式或 `new_items`。 -当 SDK 默认的嵌套任务转移历史记录逐字保留消息项时,会话、`RunState` 和 `to_input_list()` 会追踪归其所有的确切实例,而不是按内容去重。分别出现的相同消息仍会保持独立;只有已归其所有的实例不会被再次追加。 +当 SDK 默认的嵌套任务转移历史记录逐字保留某个消息项时,Sessions、`RunState` 和 `to_input_list()` 会追踪准确的自有项实例,而不是按内容去重。分别出现的相同消息仍会保持分离;只会避免再次追加已经归属其中的项实例。 -与 JavaScript SDK 不同,Python 不提供单独的 `output` 属性来仅包含运行期间新生成的模型格式项目。需要 SDK 元数据时,请使用 `new_items`;需要原始模型载荷时,请检查 `raw_responses`。 +与 JavaScript SDK 不同,Python 不会公开单独的 `output` 属性来仅包含运行期间新生成的模型格式项目。需要 SDK 元数据时,请使用 `new_items`;需要原始模型载荷时,请检查 `raw_responses`。 -将计算机工具项目作为对话输入重新提交时,会使用原始 Responses 载荷结构。预览模型的 `computer_call` 项目会保留单个 `action`,而 `gpt-5.5` 计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的结构,因此,无论是手动将这些项目作为对话输入重新提交、执行暂停/恢复流程,还是使用已存储的对话记录,都能同时兼容预览版和正式版计算机工具调用。本地执行结果仍会作为 `computer_call_output` 项目出现在 `new_items` 中。 +将计算机工具项目作为对话输入重新提交时,会使用原始 Responses 载荷结构。预览模型的 `computer_call` 项目会保留单个 `action`,而 `gpt-5.5` 计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的结构,因此,在将这些项目手动重新提交为对话输入时,暂停/恢复流程和已存储的对话记录都能继续兼容预览版和 GA 版计算机工具调用。本地执行结果仍会在 `new_items` 中显示为 `computer_call_output` 项目。 ### 新项目 -[`new_items`][agents.result.RunResultBase.new_items] 提供此次运行期间所发生事件的最丰富视图。常见项目类型包括: +[`new_items`][agents.result.RunResultBase.new_items] 提供运行过程中所发生事件的最丰富视图。常见项目类型包括: -- [`InputItem`][agents.items.InputItem],表示在恢复的模型调用之前立即从 `RunState.pending_input` 接纳的输入 +- [`InputItem`][agents.items.InputItem],表示在恢复后的模型调用之前立即从 `RunState.pending_input` 接纳的输入 - [`MessageOutputItem`][agents.items.MessageOutputItem],表示助手消息 - [`ReasoningItem`][agents.items.ReasoningItem],表示推理项目 - [`ToolSearchCallItem`][agents.items.ToolSearchCallItem] 和 [`ToolSearchOutputItem`][agents.items.ToolSearchOutputItem],表示 Responses 工具搜索请求和已加载的工具搜索结果 - [`ToolCallItem`][agents.items.ToolCallItem] 和 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem],表示工具调用及其结果 - [`ToolApprovalItem`][agents.items.ToolApprovalItem],表示因等待审批而暂停的工具调用 -- [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem],表示托管式 MCP 审批和工具目录 +- [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem] 和 [`MCPListToolsItem`][agents.items.MCPListToolsItem],表示托管 MCP 的审批和工具目录 - [`HandoffCallItem`][agents.items.HandoffCallItem] 和 [`HandoffOutputItem`][agents.items.HandoffOutputItem],表示任务转移请求和已完成的转移 -每当需要智能体关联信息、工具输出、任务转移边界或审批边界时,应选择 `new_items`,而不是 `to_input_list()`。 +只要需要智能体关联信息、工具输出、任务转移边界或审批边界,就应选择 `new_items`,而不是 `to_input_list()`。 -使用托管式工具搜索时,请检查 `ToolSearchCallItem.raw_item` 以查看模型发出的搜索请求,并检查 `ToolSearchOutputItem.raw_item` 以查看本轮加载了哪些命名空间、函数或托管式 MCP 服务器。 +使用托管工具搜索时,请检查 `ToolSearchCallItem.raw_item` 以查看模型发出的搜索请求,并检查 `ToolSearchOutputItem.raw_item` 以查看该轮加载了哪些命名空间、函数或托管 MCP 服务器。 -使用程序化工具调用时,生成的 `program` 是 `ToolCallItem`,归该程序所有的普通子工具调用也是 `ToolCallItem` 条目,与之匹配的 `program_output` 是 `ToolCallOutputItem`。归程序所有的托管式 MCP `mcp_approval_request` 和 `mcp_list_tools` 项目属于例外:它们会成为 `MCPApprovalRequestItem` 和 `MCPListToolsItem` 条目。 +使用程序化工具调用时,生成的 `program` 是一个 `ToolCallItem`,该程序拥有的普通子工具调用也是 `ToolCallItem` 条目,而对应的 `program_output` 是一个 `ToolCallOutputItem`。程序拥有的托管 MCP `mcp_approval_request` 和 `mcp_list_tools` 项目属于例外:它们会成为 `MCPApprovalRequestItem` 和 `MCPListToolsItem` 条目。 -原始项目可以是有类型的 Responses 对象或映射。特别是,归程序所有的 shell 和 apply-patch 调用使用映射。请使用兼容映射的检查模式: +原始项目可以是有类型的 Responses 对象或映射。特别是,程序拥有的 shell 和 apply-patch 调用使用映射。请使用映射安全的检查模式: ```python from collections.abc import Mapping @@ -108,23 +108,23 @@ caller_id = ( ) ``` -对于归程序所有的子调用,`caller` 的 `type` 字段为 `program`,而 `caller_id` 用于标识父程序调用。 +对于程序拥有的子调用,`caller` 的 `type` 字段为 `program`,而 `caller_id` 用于标识父程序调用。 ## 对话的继续或恢复 ### 下一轮智能体 -[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。在发生任务转移后,它通常是下一轮用户输入最适合复用的智能体。 +[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。任务转移后,它通常是下一轮用户输入最适合复用的智能体。 -在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行推进而更新,因此你可以在流结束前观察任务转移。 +在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行进展而更新,因此你可以在流结束前观察任务转移。 ### 中断和运行状态 -如果工具需要审批,待处理的审批会在 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中公开。其中可能包括由直接工具、任务转移后触达的工具或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行触发的审批。 +如果某个工具需要审批,待处理的审批会公开在 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中。其中可能包括直接工具、任务转移后调用的工具,或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行所触发的审批。 调用 [`to_state()`][agents.result.RunResult.to_state] 以捕获可恢复的 [`RunState`][agents.run_state.RunState],批准或拒绝待处理项目,然后使用 `Runner.run(...)` 或 `Runner.run_streamed(...)` 恢复运行。 -当 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] 的输出是 Pydantic 模型或数据类时,`RunState` 会将该输出序列化为结构化数据。`RunState` 还会遍历字典、列表和元组,并转换在这些容器中遇到的 Pydantic 模型或数据类;经过 JSON 往返转换后,元组会恢复为列表。其他与 JSON 不兼容的值可能会回退为其字符串表示形式,因此,如果必须让某个确切的自定义类型在序列化后保持不变,请返回明确兼容 JSON 的数据。 +当 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] 的输出是 Pydantic 模型或数据类时,`RunState` 会将该输出序列化为结构化数据。`RunState` 还会遍历字典、列表和元组,并转换在这些容器中遇到的 Pydantic 模型或数据类;经过 JSON 往返转换后,元组会还原为列表。其他与 JSON 不兼容的值可能会回退为其字符串表示形式,因此,如果某个自定义类型必须在序列化后保持精确,请返回明确与 JSON 兼容的数据。 ```python from agents import Agent, Runner @@ -139,9 +139,9 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -#### 恢复前的输入添加 +#### 恢复前添加输入 -当运行在完成一轮后暂停或停止,但尚未完成的运行还未到达下一次模型调用时,如果有新的用户输入到达,请使用 [`RunState.add_input()`][agents.run_state.RunState.add_input]。字符串会转换为用户消息,多次调用则会保留插入顺序。暂存输入是序列化 `RunState` 的一部分,因此在 `to_json()` / `from_json()` 和 `to_string()` / `from_string()` 往返转换后仍会保留。 +如果运行在暂停后,或在完成一轮后停止,但尚未执行未完成运行中的下一次模型调用时有新的用户输入到达,请使用 [`RunState.add_input()`][agents.run_state.RunState.add_input]。字符串会成为一条用户消息,多次调用会保留插入顺序。暂存输入是已序列化 `RunState` 的一部分,因此在 `to_json()` / `from_json()` 和 `to_string()` / `from_string()` 往返转换后仍会保留。 ```python state = result.to_state() @@ -153,19 +153,19 @@ for interruption in state.get_interruptions(): result = await Runner.run(agent, state) ``` -恢复运行时,运行器仅对暂存输入应用当前智能体的输入安全防护措施,以及来自 [`RunConfig`][agents.run.RunConfig] 的输入安全防护措施。如果配置了由客户端管理的 [`Session`][agents.memory.session.Session],运行器会将已接纳的暂存输入转换为持久化的 [`InputItem`][agents.items.InputItem],等待会话写入完成后再发出模型请求。如果既没有由客户端管理的会话,也没有服务端管理的对话,运行器会在发出模型请求前将已接纳的暂存输入转换为 `InputItem`。对于服务端管理的对话,输入会一直处于待处理状态,直到服务端请求接纳它。在序列化、恢复和可安全重放的重试过程中,SDK 会保留一个持久化的 `InputItem` 实例。此 SDK 实例保证并不等同于提供商交付保证:如果请求可能已到达提供商后,重试策略返回 `RetryDecision(approve_unsafe_replay=True)`,运行器可能会重新发送暂存输入,并导致提供商侧的工作重复执行。成功接纳的输入会作为 `InputItem` 出现在 `new_items` 中。读取 [`RunState.pending_input`][agents.run_state.RunState.pending_input] 可获得独立副本,也可以调用 [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input] 在恢复前丢弃所有暂存输入。 +恢复时,运行器仅对暂存输入应用当前智能体的输入安全防护措施,以及 [`RunConfig`][agents.run.RunConfig] 中的输入安全防护措施。配置由客户端管理的 [`Session`][agents.memory.session.Session] 后,运行器会将已接受的暂存输入转换为持久化的 [`InputItem`][agents.items.InputItem],等待会话写入完成,然后才发出模型请求。如果没有由客户端管理的会话或服务器托管的对话,运行器会在发出模型请求前,将已接受的暂存输入转换为 `InputItem`。对于服务器托管的对话,输入会保持待处理状态,直到服务器请求接受它。在序列化、恢复和可安全重放的重试过程中,SDK 会保留一个持久化的 `InputItem` 实例。此 SDK 实例保证并不代表提供商交付保证:如果请求可能已到达提供商后,重试策略返回 `RetryDecision(approve_unsafe_replay=True)`,运行器可能会重新发送暂存输入,提供商侧的工作也可能重复执行。成功接纳的输入会在 `new_items` 中显示为 `InputItem`。读取 [`RunState.pending_input`][agents.run_state.RunState.pending_input] 可获取一个分离副本,或调用 [`RunState.clear_pending_input()`][agents.run_state.RunState.clear_pending_input] 在恢复前丢弃所有暂存输入。 -在以下情况下,`RunState.add_input()` 会拒绝操作:状态已终止、状态中没有剩余的模型轮次、已接受的模型响应正在等待本地处理,或中断状态中的待处理工具结果可能会在下一次模型调用前结束运行。遇到这些情况时,应完成当前运行,然后开始新一轮用户交互。 +`RunState.add_input()` 会拒绝以下状态:终止状态、没有剩余模型轮次的状态、已接受的模型响应正在等待本地处理的状态,以及待处理工具结果可能在下一次模型调用前结束运行的中断状态。在这些情况下,应完成当前运行,然后开始新的用户轮次。 -对于流式运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions`,并从 `result.to_state()` 恢复。有关完整的审批流程,请参阅[人在回路](human_in_the_loop.md)。 +对于流式传输运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions`,并从 `result.to_state()` 恢复。有关完整审批流程,请参阅[人在回路](human_in_the_loop.md)。 -### 服务端管理的续接 +### 服务器托管的延续 -[`last_response_id`][agents.result.RunResultBase.last_response_id] 是此次运行中最新的模型响应 ID。如果希望继续OpenAI的 Responses API 调用链,请在下一轮将它作为 `previous_response_id` 传回。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] 是运行中最新的模型响应 ID。如果希望在下一轮继续 OpenAI Responses API 链,请将其作为 `previous_response_id` 传回。 -如果已经使用 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 +如果已通过 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 -## 智能体作为工具时的元数据 +## 智能体作为工具的元数据 当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会公开有关外层 `Agent.as_tool()` 调用的不可变元数据: @@ -175,48 +175,50 @@ result = await Runner.run(agent, state) 对于普通的顶层运行,`agent_tool_invocation` 为 `None`。 -这在 `custom_output_extractor` 内尤其有用,因为对嵌套结果进行后处理时,你可能需要外层 `Agent.as_tool()` 调用的工具名称、调用 ID 或原始参数。有关相关的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 +这在 `custom_output_extractor` 中尤其有用,因为在对嵌套结果进行后处理时,你可能需要外层 `Agent.as_tool()` 调用的工具名称、调用 ID 或原始参数。有关相关的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 -如果还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 为嵌套工具输入进行通用序列化的字段,而 `agent_tool_invocation` 则直接在结果上公开当前嵌套调用的元数据。 +如果还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 为嵌套工具输入进行通用序列化的字段,而 `agent_tool_invocation` 会直接在结果中公开当前嵌套调用的元数据。 -## 流式传输生命周期和诊断信息 +## 流式传输生命周期和诊断 -[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同的结果接口,但增加了流式传输专用的控制项: +[`RunResultStreaming`][agents.result.RunResultStreaming] 继承了上述相同的结果接口,但增加了流式传输专用的控制项: - [`stream_events()`][agents.result.RunResultStreaming.stream_events],用于消费语义流事件 -- [`current_agent`][agents.result.RunResultStreaming.current_agent],用于在运行过程中追踪当前活跃的智能体 -- [`is_complete`][agents.result.RunResultStreaming.is_complete],用于查看流式运行是否已完全结束 +- [`current_agent`][agents.result.RunResultStreaming.current_agent],用于在运行过程中追踪活动智能体 +- [`is_complete`][agents.result.RunResultStreaming.is_complete],用于查看流式传输运行是否已完全结束 - [`cancel(...)`][agents.result.RunResultStreaming.cancel],用于立即停止运行或在当前轮次结束后停止运行 -持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式运行才算完成;在最后一个可见 token 到达后,`final_output`、`interruptions` 和 `raw_responses` 等汇总属性以及会话持久化副作用可能仍在完成处理。 +持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式传输运行才算完成;在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在收尾。 如果调用 `cancel()`,请继续消费 `stream_events()`,以便正确完成取消和清理。 -Python 不提供单独的流式 `completed` promise 或 `error` 属性。导致运行终止的流式传输故障会由 `stream_events()` 抛出,而 `is_complete` 则反映运行是否已达到终止状态。 +Python 不会公开单独的流式 `completed` promise 或 `error` 属性。导致运行终止的流式传输失败会由 `stream_events()` 抛出,而 `is_complete` 会反映运行是否已达到终止状态。 ### 原始响应 -[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能会产生多个响应,例如跨任务转移或重复的模型/工具/模型循环。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能会生成多个响应,例如在任务转移期间或重复的模型/工具/模型循环中。 [`last_response_id`][agents.result.RunResultBase.last_response_id] 只是 `raw_responses` 中最后一个条目的 ID。 -每个 [`ModelResponse`][agents.items.ModelResponse] 还会公开两个适用于该次模型调用的诊断信息: +每个 [`ModelResponse`][agents.items.ModelResponse] 还会公开两项适用于单次模型调用的诊断信息: -- [`request_id`][agents.items.ModelResponse.request_id] 是模型适配器和传输层进行传递时的传输请求 ID。内置的 `OpenAIResponsesModel` 和 `OpenAIChatCompletionsModel` 会在其 HTTP 和 SSE 传输路径上传递可用的服务端生成 `x-request-id`。当配置的端点是OpenAI的 API 时,请在生产环境中记录非 `None` 值,以便将故障与OpenAI支持团队关联;对于兼容OpenAI的提供商或代理,请改用相应服务的支持渠道。`OpenAIResponsesWSModel` 目前会让 `request_id` 保持为 `None`。第三方适配器不保证传递请求 ID。AnyLLM Chat Completions 适配器和 `LitellmModel` 目前会让 `request_id` 保持为 `None`。当 Agents SDK 的 AnyLLM Responses 适配器在规范化提供商响应时未保留传输请求 ID,也可能会让 `request_id` 保持为 `None`。 -- [`raw_usage`][agents.items.ModelResponse.raw_usage] 是一个需要显式启用且兼容 JSON 的快照,它保存提供商的用量载荷在被 Agents SDK 规范化之前的状态。使用 `ModelSettings(preserve_raw_usage=True)` 启用 `raw_usage`;请参阅[保留提供商用量载荷](usage.md#preserving-provider-usage-payloads)。 +- [`request_id`][agents.items.ModelResponse.request_id] 是模型适配器和传输层传播请求 ID 时的传输请求 ID。内置的 `OpenAIResponsesModel` 和 `OpenAIChatCompletionsModel` 会在其 HTTP 和 SSE 传输路径中传播可用的、由服务器生成的 `x-request-id`。当配置的端点为 OpenAI API 时,请在生产环境中记录非 `None` 值,以便将故障与 OpenAI支持关联起来;对于与 OpenAI兼容的提供商或代理,请改用相应服务的支持渠道。`OpenAIResponsesWSModel` 当前会将 `request_id` 保持为 `None`。第三方适配器不保证会传播请求 ID。AnyLLM Chat Completions 适配器和 `LitellmModel` 当前会将 `request_id` 保持为 `None`。当 Agents SDK AnyLLM Responses 适配器在规范化提供商响应时未保留传输请求 ID,它也可能会将 `request_id` 保持为 `None`。 +- [`raw_usage`][agents.items.ModelResponse.raw_usage] 是可选启用的、与 JSON 兼容的提供商用量载荷快照,捕获时机是在 Agents SDK 规范化该载荷之前。使用 `ModelSettings(preserve_raw_usage=True)` 启用 `raw_usage`;请参阅[保留提供商用量载荷](usage.md#preserving-provider-usage-payloads)。 `ModelResponse.request_id` 和 `ModelResponse.raw_usage` 都可能是 `None`,因此应将这些值视为可选诊断信息,而不是对话状态。 ### 安全防护措施结果 -智能体级安全防护措施通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 公开。 +智能体级安全防护措施分别通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 公开。 -工具安全防护措施则通过 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 和 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] 单独公开。 +工具安全防护措施则分别通过 [`tool_input_guardrail_results`][agents.result.RunResultBase.tool_input_guardrail_results] 和 [`tool_output_guardrail_results`][agents.result.RunResultBase.tool_output_guardrail_results] 公开。 -这些数组会在整个运行过程中持续累积,因此可用于记录决策、存储额外的安全防护措施元数据,或调试运行被阻止的原因。 +这些数组会在整个运行期间持续累积,因此可用于记录决策、存储额外的安全防护措施元数据,或调试运行被阻止的原因。 + +当智能体级输出安全防护措施阻止由终止函数工具直接生成的最终输出时,会应用一条脱敏规则。对于当前被阻止的响应,`output_guardrail_results` 会替换被拒绝的智能体输出,并清除包含载荷的输出元数据,而 `tool_output_guardrail_results` 会替换包含载荷的工具元数据。此前已接受的结果保持不变。经过净化的输出安全防护措施结果会在 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 上公开为 `guardrail_result`。经过净化的输出安全防护措施和工具输出安全防护措施结果也会通过流式传输结果状态和 `RunState` 公开;请参阅[输出安全防护措施](guardrails.md#output-guardrails)。 ### 上下文和用量 -[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 会公开应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 +[`context_wrapper`][agents.result.RunResultBase.context_wrapper] 会公开你的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 -用量在 `context_wrapper.usage` 上追踪。对于流式运行,在处理完流的最终数据块之前,用量总计可能会有所延迟。有关完整的包装器结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file +用量会在 `context_wrapper.usage` 上追踪。对于流式传输运行,用量总计可能会滞后,直到处理完流的最后几个数据块。有关完整的包装器结构和持久化注意事项,请参阅[上下文管理](context.md)。 \ No newline at end of file diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index c1b88c69ee..856e799c54 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -2,12 +2,12 @@ search: exclude: true --- -# 运行智能体 +# 智能体运行 你可以通过 [`Runner`][agents.run.Runner] 类运行智能体。你有 3 种选择: 1. [`Runner.run()`][agents.run.Runner.run]:异步运行并返回 [`RunResult`][agents.result.RunResult]。 -2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其底层仅运行 `.run()`。 +2. [`Runner.run_sync()`][agents.run.Runner.run_sync]:同步方法,其底层只是运行 `.run()`。 3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed]:异步运行并返回 [`RunResultStreaming`][agents.result.RunResultStreaming]。它以流式传输模式调用 LLM,并在收到事件时将其流式传输给你。 ```python @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -更多信息请参阅[结果指南](results.md)。 +有关更多信息,请阅读[结果指南](results.md)。 ## Runner 生命周期与配置 ### 智能体循环 -调用上述三个 `Runner` 方法中的任意一个时,你需要传入一个起始智能体和输入。输入可以是: +调用上述三个 `Runner` 方法中的任何一个时,需要传入起始智能体和输入。输入可以是: -- 字符串(视为用户消息), +- 字符串(视为用户消息)、 - OpenAI Responses API 格式的输入项列表,或 -- 从暂停的运行或因 `cancel(mode="after_turn")` 而停止的运行恢复时使用的 [`RunState`][agents.run_state.RunState]。该状态还可以携带[为下一次恢复后的模型调用暂存的输入](results.md#add-input-before-resuming)。 +- 在恢复已暂停的运行或因 `cancel(mode="after_turn")` 而停止的运行时使用的 [`RunState`][agents.run_state.RunState]。状态还可以携带[为下一次恢复后的模型调用暂存的输入](results.md#add-input-before-resuming)。 -随后,Runner 会执行循环: +然后,Runner 会执行循环: -1. 使用当前输入,为当前智能体调用 LLM。 +1. 使用当前输入为当前智能体调用 LLM。 2. LLM 生成输出。 - 1. 如果 Runner 将 LLM 的输出归类为最终输出,则循环结束并返回结果。 + 1. 如果 Runner 将 LLM 的输出归类为最终输出,循环便会结束并返回结果。 2. 如果 LLM 请求任务转移,我们会更新当前智能体和输入,然后重新运行循环。 3. 如果 LLM 生成工具调用,我们会运行这些工具调用、追加结果,然后重新运行循环。 -3. 如果超过传入的 `max_turns`,则会抛出 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 +3. 如果超过所传入的 `max_turns`,则会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 !!! note - 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了所需类型的文本输出,且不存在工具调用。 + 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了所需类型的文本输出,并且不存在工具调用。 ### 流式传输 -流式传输允许你在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含本次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。更多信息请参阅[流式传输指南](streaming.md)。 +流式传输让你能够在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含有关此次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。有关更多信息,请阅读[流式传输指南](streaming.md)。 #### Responses WebSocket 传输(可选辅助工具) -如果启用 OpenAI Responses WebSocket 传输,你仍可继续使用常规的 `Runner` API。建议使用 WebSocket 会话辅助工具来复用连接,但这并非必需。 +如果启用 OpenAI Responses websocket 传输,你仍可继续使用常规的 `Runner` API。建议使用 websocket 会话辅助工具来复用连接,但这并非必需。 -这是通过 WebSocket 传输使用的 Responses API,并非 [Realtime API](realtime/guide.md)。 +这是通过 websocket 传输使用的 Responses API,而不是 [Realtime API](realtime/guide.md)。 -有关传输方式的选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 +有关传输方式选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 ##### 模式 1:不使用会话辅助工具(可行) -如果只想使用 WebSocket 传输,并且不需要 SDK 为你管理共享提供商或会话,请使用此模式。 +如果你只需要 websocket 传输,而不需要 SDK 为你管理共享的提供商/会话,请使用此模式。 ```python import asyncio @@ -85,11 +85,11 @@ async def main(): asyncio.run(main()) ``` -此模式适用于单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,每次运行都可能重新连接,除非你手动复用同一个 `RunConfig` / 提供商实例。 +此模式适用于单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个 `RunConfig` / 提供商实例,否则每次运行都可能重新连接。 -##### 模式 2:使用 `responses_websocket_session()`(推荐用于多轮复用) +##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) -如果想在多次运行之间共享支持 WebSocket 的提供商和 `RunConfig`,请使用 [`responses_websocket_session()`][agents.responses_websocket_session],这也包括继承同一个 `run_config` 的嵌套“智能体即工具”调用。 +如果希望在多次运行中共享支持 websocket 的提供商和 `RunConfig`(包括继承相同 `run_config` 的嵌套 Agents-as-tools 调用),请使用 [`responses_websocket_session()`][agents.responses_websocket_session]。 ```python import asyncio @@ -119,59 +119,59 @@ async def main(): asyncio.run(main()) ``` -请在上下文退出前完成对流式结果的消费。如果在 WebSocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 +请在退出上下文之前完成流式结果的消费。如果在 websocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 -服务会在每个 WebSocket 连接上一次处理一个响应,并将每个连接的时长限制为 60 分钟。该辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整输入上下文启动新的调用链,或从本地管理的会话状态中重建该调用链。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 +服务会在每个 websocket 连接上一次处理一个响应,并将单个连接的时长限制为 60 分钟。辅助工具会复用连接,但不会消除这些限制。重新连接后,`store=False` 和 ZDR 流程无法恢复未缓存的 `previous_response_id`;请使用完整的输入上下文启动新链,或根据本地管理的会话状态重建它。有关完整的恢复行为,请参阅 [Responses WebSocket 传输说明](models/index.md#responses-websocket-transport)。 -如果长时间推理轮次触发 WebSocket 保活超时,请增大 `ping_timeout`,或将 `ping_timeout=None` 设置为禁用心跳超时。如果运行中可靠性比 WebSocket 延迟更重要,请使用 HTTP/SSE 传输。 +如果长时间推理轮次触发 websocket keepalive 超时,请增大 `ping_timeout`,或设置 `ping_timeout=None` 以禁用心跳超时。对于可靠性比 websocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 ### 运行配置 -通过 `run_config` 参数,可以为智能体运行配置一些全局设置: +通过 `run_config` 参数可以配置智能体运行的一些全局设置: #### 常见运行配置类别 -使用 `RunConfig` 可覆盖单次运行的行为,而无需更改每个智能体的定义。 +使用 `RunConfig` 可覆盖单次运行的行为,而无须更改各个智能体定义。 -##### 模型、提供商和会话默认值 +##### 模型、提供商与会话默认设置 -- [`model`][agents.run.RunConfig.model]:允许设置要使用的全局 LLM 模型,而不考虑每个智能体使用的 `model`。 +- [`model`][agents.run.RunConfig.model]:用于设置要使用的全局 LLM 模型,而不考虑每个智能体具有的 `model`。 - [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 -- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体专属设置。例如,可以设置全局 `temperature` 或 `top_p`。 +- [`model_settings`][agents.run.RunConfig.model_settings]:覆盖智能体特定的设置。例如,可以设置全局 `temperature` 或 `top_p`。 - [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认设置(例如 `SessionSettings(limit=...)`)。 -- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用会话时,自定义在每次 `Runner` 运行前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 +- [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每次运行 `Runner` 之前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 ##### 安全防护措施、任务转移与模型输入调整 -- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要包含在所有运行中的输入或输出安全防护措施列表。 -- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:适用于所有任务转移的全局输入过滤器,前提是相应任务转移尚未配置过滤器。输入过滤器允许你编辑发送给新智能体的输入。更多详情请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 中的文档。 -- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:一项可选启用的 Beta 功能。在调用下一个智能体之前,它会将可总结的历史记录压缩为有序的助手摘要片段,同时将无损消息项保留在原始位置。由于我们仍在完善嵌套任务转移功能,该功能默认禁用;将其设置为 `True` 可启用,保留为 `False` 则会直接传递原始对话记录。当 SDK 默认的嵌套历史记录已包含某条消息时,会话、`RunState` 和 `RunResult.to_input_list()` 可避免重复追加完全相同的一次消息,同时仍保留彼此独立但内容相同的消息。如果你未传入 `RunConfig`,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会保持默认关闭状态,并且任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。各个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 -- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:可选的可调用对象。在你选择启用 `nest_handoff_history` 后,每次都会接收规范化的对话记录(历史记录 + 任务转移项)。它必须返回要转发给下一个智能体的确切输入项列表,以替换内置的有序摘要片段,而无需编写完整的任务转移过滤器。 -- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:用于在调用模型前立即编辑已完整准备的模型输入(instructions 和输入项)的钩子,例如裁剪历史记录或注入系统提示词。 +- [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要在所有运行中包含的输入或输出安全防护措施列表。 +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未配置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许编辑发送给新智能体的输入。有关更多详细信息,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 的文档。 +- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]:需选择启用的 Beta 功能,在调用下一个智能体之前,将可总结的历史记录压缩为有序的助手摘要片段,同时在原始位置保留无损消息项。在我们逐步稳定嵌套任务转移功能期间,此功能默认禁用;将其设置为 `True` 即可启用,或保留为 `False` 以直接传递原始记录。当 SDK 默认的嵌套历史记录已包含某条消息时,Sessions、`RunState` 和 `RunResult.to_input_list()` 会避免再次追加完全相同的消息实例,同时仍会保留彼此独立但内容相同的消息。如果未传入 `RunConfig`,所有 [Runner 方法][agents.run.Runner]都会自动创建一个,因此快速入门和代码示例会维持默认关闭状态,而任何显式的 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 回调仍会覆盖此设置。单个任务转移可以通过 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] 覆盖此设置。 +- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]:每当选择启用 `nest_handoff_history` 时,接收标准化记录(历史记录 + 任务转移项)的可选可调用对象。它必须返回要转发给下一个智能体的确切输入项列表,用于替换内置的有序摘要片段,而无须编写完整的任务转移过滤器。 +- [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前一刻编辑已完全准备好的模型输入(instructions 和输入项)的钩子,例如用于裁剪历史记录或注入系统提示词。 - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 Runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 ##### 追踪与可观测性 -- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:允许你为整个运行禁用[追踪](tracing.md)。 -- [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],以覆盖追踪导出设置,例如每次运行的追踪 API 密钥。 -- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪是否包含潜在敏感数据,例如 LLM 和工具调用的输入/输出。 -- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置本次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行中的追踪。 -- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪中的元数据。 +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:用于为整个运行禁用[追踪](tracing.md)。 +- [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],可覆盖追踪导出设置,例如每次运行使用的追踪 API 密钥。 +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]:配置追踪记录是否包含潜在敏感数据,例如 LLM 和工具调用的输入/输出。 +- [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行的追踪记录。 +- [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪记录中的元数据。 ##### 工具执行、审批与工具错误行为 -- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置本地工具调用在 SDK 侧的执行行为,例如限制可同时运行的本地函数工具调用数量。 -- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置 Runner 如何处理模型发出的函数工具调用,而该调用的工具名称与当前智能体可用的任何函数工具均不匹配。默认行为是抛出 `ModelBehaviorError`;你也可以选择改为返回模型可见的错误输出。 -- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置 Runner 如何处理发生冲突的无命名空间函数工具名称和任务转移名称。默认值 `"warn"` 会记录一条可指导采取行动的警告,并且只公开当前最终用于分派的对象;`"error"` 会在调用模型前抛出 `UserError`。对带命名空间和延迟加载工具的严格验证保持不变。 -- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝和选择启用的“找不到工具”输出。 +- [`tool_execution`][agents.run.RunConfig.tool_execution]:配置 SDK 侧针对本地工具调用的执行行为,例如限制同时运行的本地函数工具调用数量。 +- [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置当模型发出的函数工具调用名称与当前智能体可用的任何函数工具都不匹配时,Runner 应如何处理。默认行为是引发 `ModelBehaviorError`;也可以选择改为返回模型可见的错误输出。 +- [`tool_name_collision_policy`][agents.run.RunConfig.tool_name_collision_policy]:配置当不带命名空间的函数工具名称与任务转移名称发生冲突时,Runner 应如何处理。默认值 `"warn"` 会记录一条可据以采取行动的警告,并且只公开当前的分派胜出项;`"error"` 会在调用模型之前引发 `UserError`。针对带命名空间和延迟加载工具的严格验证保持不变。 +- [`tool_error_formatter`][agents.run.RunConfig.tool_error_formatter]:自定义模型可见的工具错误消息,例如审批被拒绝和选择启用的工具未找到输出。 -嵌套任务转移是一项可选启用的 Beta 功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序对话记录压缩,或设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段放置在无损消息项周围,而不是将整个对话记录折叠为一条消息。如果你希望保留原始对话记录(默认行为),请不要设置该标志,或提供一个 `handoff_input_filter`(或 `handoff_history_mapper`),以便完全按照你的需要转发对话。如果想更改生成的摘要片段中使用的包装文本而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](并调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 恢复默认值)。 +嵌套任务转移是一项需选择启用的 Beta 功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序记录压缩,或者设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段放置在无损消息项周围,而不是将整个记录压缩成一条消息。如果希望保留原始记录(默认行为),请不要设置此标志,或者提供按所需方式原样转发对话的 `handoff_input_filter`(或 `handoff_history_mapper`)。如果希望更改生成的摘要片段中使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 可恢复默认值)。 #### 运行配置详情 ##### `tool_execution` -如果想配置本地函数工具在 SDK 侧的行为,例如限制一次运行中本地函数工具的并发数,请使用 `tool_execution`。 +如果希望配置 SDK 侧针对本地函数工具的行为,例如限制一次运行中的本地函数工具并发数,请使用 `tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -190,17 +190,17 @@ result = await Runner.run( ) ``` -`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中发出多个函数工具调用时,SDK 会启动发出的所有本地函数工具调用。将其设置为整数值,可限制同时运行的本地函数工具调用数量。 +`max_function_tool_concurrency=None` 会保留默认行为:当模型在一轮中发出多个函数工具调用时,SDK 会启动所有已发出的本地函数工具调用。将其设置为整数值,可以限制同时运行的本地函数工具调用数量。 -这与提供商侧的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 相互独立。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地函数工具调用后,SDK 如何执行这些调用。 +这与提供商侧的 [`ModelSettings.parallel_tool_calls`][agents.model_settings.ModelSettings.parallel_tool_calls] 不同。`parallel_tool_calls` 控制是否允许模型在单个响应中发出多个工具调用。`tool_execution.max_function_tool_concurrency` 控制模型发出本地函数工具调用后,SDK 如何执行这些调用。 -`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果函数工具需要审批,运行会先暂停,而工具输入安全防护措施仅在审批通过后、执行前立即运行。如果想在发出待审批的中断前运行函数工具输入安全防护措施,请将其设置为 `True`。通过此审批前检查的调用仍会在审批通过后再次运行相同的输入安全防护措施,因此会在执行前重新验证时效性要求较高的检查。 +`pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果函数工具需要审批,运行会先暂停,而工具输入安全防护措施只会在审批后、即将执行前运行。如果希望在发出待审批中断之前运行函数工具输入安全防护措施,请将其设置为 `True`。通过此审批前检查的调用仍会在审批后再次运行相同的输入安全防护措施,因此会在执行前重新验证时效性检查。 ##### `tool_not_found_behavior` -默认情况下,如果模型发出的函数工具调用与当前智能体可用的任何函数工具均不匹配,Runner 会抛出 `ModelBehaviorError`。 +默认情况下,如果模型发出的函数工具调用与当前智能体可用的任何函数工具都不匹配,Runner 会引发 `ModelBehaviorError`。 -如果希望运行仍可恢复,请设置 `tool_not_found_behavior="return_error_to_model"`。在该模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,使模型可以选择可用工具,或在不使用该工具的情况下作答。 +如果希望运行保持可恢复状态,请设置 `tool_not_found_behavior="return_error_to_model"`。在此模式下,SDK 会为无法解析的工具调用追加一个 `function_call_output`,然后再次运行模型,以便模型选择可用工具或在不使用该工具的情况下回答。 ```python from agents import Agent, RunConfig, Runner @@ -214,13 +214,13 @@ result = await Runner.run( ) ``` -此选项目前仅适用于因工具名称查找失败而无法执行的函数工具调用。其他无效工具载荷仍沿用现有的错误处理行为。 +此选项目前仅适用于工具名称查找失败的函数工具调用。其他无效工具载荷会继续使用其现有的错误处理行为。 ##### `tool_error_formatter` 使用 `tool_error_formatter` 可自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 -格式化器会接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: +格式化程序接收 [`ToolErrorFormatterArgs`][agents.run_config.ToolErrorFormatterArgs],其中包含: - `kind`:错误类别,例如 `"approval_rejected"` 或 `"tool_not_found"`。 - `tool_type`:工具运行时(`"function"`、`"computer"`、`"shell"`、`"apply_patch"` 或 `"custom"`)。 @@ -256,52 +256,52 @@ result = Runner.run_sync( ##### `reasoning_item_id_policy` -当 Runner 继续传递历史记录时(例如使用 `RunResult.to_input_list()` 或由会话支持的运行),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 +当 Runner 继续携带历史记录时(例如使用 `RunResult.to_input_list()` 或基于会话的运行时),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 - `None` 或 `"preserve"`(默认):保留推理项 ID。 - `"omit"`:从生成的下一轮输入中移除推理项 ID。 -`"omit"` 主要用于选择启用一种缓解措施,以应对某类 Responses API 400 错误:发送的推理项包含 `id`,但缺少其后所需的项目(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 +`"omit"` 主要用于选择启用一种缓解措施,以处理一类 Responses API 400 错误:发送的推理项带有 `id`,但缺少其后必需的项目(例如 `Item 'rs_...' of type 'reasoning' was provided without its required following item.`)。 -在多轮智能体运行中,SDK 根据先前的输出构建后续输入时可能出现这种情况,其中包括会话持久化、服务器管理的对话增量、流式/非流式后续轮次以及恢复路径。如果保留了推理项 ID,而提供商要求该 ID 必须与其对应的后续项配对,就会发生此错误。 +在多轮智能体运行中,如果 SDK 根据先前输出构造后续输入(包括会话持久化、服务器管理的对话增量、流式/非流式后续轮次和恢复路径),并且保留了推理项 ID,但提供商要求该 ID 必须与其对应的后续项目保持配对,就可能发生这种情况。 -设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量。 +设置 `reasoning_item_id_policy="omit"` 会保留推理内容,但移除推理项的 `id`,从而避免 SDK 生成的后续输入触发该 API 不变量约束。 -适用范围说明: +作用范围说明: -- 此设置只会更改 SDK 构建后续输入时生成或转发的推理项。 +- 这只会更改 SDK 在构建后续输入时生成/转发的推理项。 - 它不会重写用户提供的初始输入项。 - 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 ## 状态与对话管理 -### 记忆策略的选择 +### 内存策略选择 -有四种常见方式可将状态带入下一轮: +将状态带入下一轮通常有四种方式: -| 策略 | 状态存储位置 | 最适用场景 | 下一轮传入的内容 | +| 策略 | 状态存放位置 | 最适合 | 下一轮传入内容 | | --- | --- | --- | --- | -| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任意提供商 | `result.to_input_list()` 返回的列表,加上下一条用户消息 | -| `session` | 你的存储加 SDK | 持久化聊天状态、可恢复的运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | -| `conversation_id` | OpenAI Conversations API | 希望在多个工作进程或服务之间共享的具名服务器端对话 | 同一个 `conversation_id`,加上且仅加上新的用户轮次 | -| `previous_response_id` | OpenAI Responses API | 无需创建对话资源的轻量级服务器管理延续机制 | `result.last_response_id`,加上且仅加上新的用户轮次 | +| `result.to_input_list()` | 应用内存 | 小型聊天循环、完全手动控制、任何提供商 | `result.to_input_list()` 返回的列表,加上下一条用户消息 | +| `session` | 你的存储加 SDK | 持久化聊天状态、可恢复运行、自定义存储 | 同一个 `session` 实例,或指向同一存储的另一个实例 | +| `conversation_id` | OpenAI Conversations API | 希望跨工作进程或服务共享的具名服务器端对话 | 同一个 `conversation_id`,并且只传入新的用户轮次 | +| `previous_response_id` | OpenAI Responses API | 无须创建对话资源的轻量级服务器管理续接 | `result.last_response_id`,并且只传入新的用户轮次 | -`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅适用于使用 OpenAI Responses API 的情况。对于大多数应用,请为每个对话选择一种持久化策略。混用客户端管理的历史记录和 OpenAI 管理的状态可能导致上下文重复,除非你有意协调这两个层级。 +`result.to_input_list()` 和 `session` 由客户端管理。`conversation_id` 和 `previous_response_id` 由 OpenAI 管理,并且仅在使用 OpenAI Responses API 时适用。在大多数应用中,应为每个对话选择一种持久化策略。混合使用客户端管理的历史记录与 OpenAI 管理的状态可能会导致上下文重复,除非你有意协调这两个层级。 !!! note - 在同一次运行中,会话持久化不能与服务器管理的对话设置 + 会话持久化不能在同一次运行中与服务器管理的对话设置 (`conversation_id`、`previous_response_id` 或 `auto_previous_response_id`) - 结合使用。每次调用请选择一种方式。 + 组合使用。每次调用请选择一种方式。 ### 对话/聊天线程 -调用任意运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但在聊天对话中,这表示单个逻辑轮次。例如: +调用任何运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但这代表聊天对话中的单个逻辑轮次。例如: 1. 用户轮次:用户输入文本 2. Runner 运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 -智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论采用哪种方式,用户之后都可能提出后续问题,此时你可以再次调用运行方法。 +智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论哪种方式,用户随后都可能提出后续问题,此时可以再次调用运行方法。 #### 手动对话管理 @@ -327,9 +327,9 @@ async def main(): # California ``` -#### 使用会话自动管理对话 +#### 使用 Sessions 自动管理对话 -如需更简单的方式,可以使用[会话](sessions/index.md)自动处理对话历史记录,而无需手动调用 `.to_input_list()`: +如需更简单的方法,可以使用 [Sessions](sessions/index.md) 自动处理对话历史记录,而无须手动调用 `.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -353,24 +353,24 @@ async def main(): # California ``` -会话会自动: +Sessions 会自动: - 在每次运行前检索对话历史记录 - 在每次运行后存储新消息 -- 为不同的会话 ID 维护独立的对话 +- 为不同的会话 ID 维护彼此独立的对话 -更多详情请参阅[会话文档](sessions/index.md)。 +有关更多详细信息,请参阅 [Sessions 文档](sessions/index.md)。 #### 服务器管理的对话 -你也可以使用 OpenAI 对话状态功能在服务器端管理对话状态,而不是通过 `to_input_list()` 或 `Sessions` 在本地进行处理。这样无需手动重新发送所有历史消息即可保留对话历史记录。使用以下任一服务器管理方式时,请在每个请求中仅传入新轮次的输入,并复用已保存的 ID。更多详情请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 +你也可以让 OpenAI 的对话状态功能在服务器端管理对话状态,而不是通过 `to_input_list()` 或 `Sessions` 在本地处理。这样便可保留对话历史记录,而无须手动重新发送所有过去的消息。使用下述任一服务器管理方式时,每次请求只需传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 -OpenAI 提供两种跨轮次追踪状态的方式: +OpenAI 提供两种跨轮次跟踪状态的方式: ##### 1. 使用 `conversation_id` -首先使用 OpenAI Conversations API 创建对话,然后在后续每次调用中复用其 ID: +首先使用 OpenAI Conversations API 创建对话,然后在之后的每次调用中复用其 ID: ```python from agents import Agent, Runner @@ -393,7 +393,7 @@ async def main(): ##### 2. 使用 `previous_response_id` -另一种方式是**响应链式衔接**,其中每一轮都会显式链接到上一轮的响应 ID。 +另一个选项是**响应链式衔接**,其中每个轮次都显式链接到上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -418,30 +418,30 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,使恢复后的轮次继续在同一服务器管理的对话中运行。 +如果运行因等待审批而暂停,并且你从 [`RunState`][agents.run_state.RunState] 恢复运行,SDK 会保留已保存的 `conversation_id` / `previous_response_id` / `auto_previous_response_id` 设置,以便恢复后的轮次继续使用同一个服务器管理的对话。 -`conversation_id` 和 `previous_response_id` 互斥。如果需要可跨系统共享的具名对话资源,请使用 `conversation_id`。如果需要在轮次之间使用最轻量的 Responses API 延续基本组件,请使用 `previous_response_id`。 +`conversation_id` 和 `previous_response_id` 互斥。如果希望使用可跨系统共享的具名对话资源,请使用 `conversation_id`。如果希望使用最轻量的 Responses API 基本组件从一个轮次续接到下一个轮次,请使用 `previous_response_id`。 !!! note - SDK 会以退避策略自动重试 `conversation_locked` 错误。在服务器管理的 - 对话运行中,它会先回退内部对话追踪器的输入再进行重试,以便干净地重新发送 + SDK 会使用退避机制自动重试 `conversation_locked` 错误。在服务器管理的 + 对话运行中,SDK 会在重试前回退内部对话跟踪器的输入,以便重新完整发送 相同的已准备项目。 - 在基于本地会话的运行中(此类运行不能与 `conversation_id`、 - `previous_response_id` 或 `auto_previous_response_id` 结合使用),SDK 还会尽力 - 回滚近期持久化的输入项,以减少重试后重复的历史记录条目。 + 在基于本地会话的运行中(不能与 `conversation_id`、 + `previous_response_id` 或 `auto_previous_response_id` 组合使用),SDK 还会尽力 + 回滚最近持久化的输入项,以减少重试后重复的历史记录条目。 - 即使未配置 `ModelSettings.retry`,也会进行此兼容性重试。有关模型请求中 - 范围更广的可选重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 + 即使未配置 `ModelSettings.retry`,也会进行这种兼容性重试。有关针对 + 模型请求的更广泛选择启用式重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 ## 钩子与自定义 ### 模型调用输入过滤器 -使用 `call_model_input_filter` 可在调用模型前编辑模型输入。该钩子会接收当前智能体、上下文和合并后的输入项(如有会话历史记录,也会包含在内),并返回新的 `ModelInputData`。 +使用 `call_model_input_filter` 可在模型调用前一刻编辑模型输入。该钩子接收当前智能体、上下文和合并后的输入项(如有会话历史记录,也包括在内),并返回新的 `ModelInputData`。 -返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填项,并且必须是输入项列表。返回任何其他结构都会抛出 `UserError`。 +返回值必须是 [`ModelInputData`][agents.run.ModelInputData] 对象。其 `input` 字段为必填字段,并且必须是输入项列表。返回任何其他结构都会引发 `UserError`。 ```python from agents import Agent, Runner, RunConfig @@ -460,19 +460,19 @@ result = Runner.run_sync( ) ``` -Runner 会将已准备输入列表的副本传递给钩子,因此你可以对其进行裁剪、替换或重新排序,而不会就地修改调用方的原始列表。 +Runner 会将准备好的输入列表副本传递给钩子,因此你可以裁剪、替换或重新排序该列表,而不会原地修改调用方的原始列表。 -如果使用会话,`call_model_input_filter` 会在会话历史记录已加载并与当前轮次合并后运行。如果想自定义该合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 +如果使用会话,`call_model_input_filter` 会在会话历史记录已加载并与当前轮次合并后运行。如果希望自定义前面的合并步骤本身,请使用 [`session_input_callback`][agents.run.RunConfig.session_input_callback]。 -如果通过 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 使用 OpenAI 服务器管理的对话状态,该钩子会针对下一次 Responses API 调用准备的载荷运行。该载荷可能已经只表示新轮次的增量,而不是对先前完整历史记录的重放。只有你返回的项目会被标记为已发送,用于该服务器管理的延续流程。 +如果通过 `conversation_id`、`previous_response_id` 或 `auto_previous_response_id` 使用 OpenAI 服务器管理的对话状态,该钩子会针对下一次 Responses API 调用所准备的载荷运行。该载荷可能已经只表示新轮次的增量,而不是完整重放先前的历史记录。只有你返回的项目才会被标记为已发送到该服务器管理的续接流程。 -通过 `run_config` 为每次运行设置该钩子,以编校敏感数据、裁剪过长的历史记录或注入额外的系统指导。 +通过 `run_config` 为每次运行设置该钩子,可用于隐去敏感数据、裁剪过长的历史记录或注入额外的系统指导。 ## 错误与恢复 ### 错误处理程序 -所有 `Runner` 入口点都接受 `error_handlers`,它是一个按错误类型设定键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些键。 +所有 `Runner` 入口点都接受 `error_handlers`,这是一个以错误种类为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些键。 ```python from agents import ( @@ -501,7 +501,7 @@ result = Runner.run_sync( print(result.final_output) ``` -当模型消息未通过智能体结构化 `output_type` 的验证,或模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。处理程序可以返回应用专属的回退值,SDK 会使用相同的 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示拒绝恢复。如果没有回退值,非空的验证失败仍会抛出 `ModelBehaviorError`,而空结构化响应会保留现有的下一轮行为。 +当模型消息无法通过智能体的结构化 `output_type` 验证,或者模型未返回结构化最终消息时,请使用 `"invalid_final_output"`。处理程序可以返回应用特定的后备值,SDK 会使用同一个 `output_type` 对其进行验证。它不会重试模型调用,也不会重放任何工具副作用。返回 `None` 表示放弃恢复。如果没有后备值,非空验证失败仍会引发 `ModelBehaviorError`,而空的结构化响应会保留现有的下一轮行为。 ```python from pydantic import BaseModel @@ -533,9 +533,9 @@ result = Runner.run_sync( print(result.final_output) ``` -`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理程序,此设置会将合成的回退输出追加到对话历史记录中,并将其持久化到已配置的会话。如果希望向调用方返回回退值,但不将其添加到结果历史记录或会话存储中,请设置 `include_in_history=False`。 +`RunErrorHandlerResult.include_in_history` 默认为 `True`。对于最大轮次处理程序,这会将合成的后备输出追加到对话历史记录中,并将其持久化到配置的会话。若希望将后备值返回给调用方,而不将其添加到结果历史记录或会话存储,请设置 `include_in_history=False`。 -如果希望模型拒绝时生成应用专属的回退值,而不是以 `ModelRefusalError` 结束运行,请使用 `"model_refusal"`。 +当模型拒绝响应时,如果希望生成应用特定的后备值,而不是以 `ModelRefusalError` 结束运行,请使用 `"model_refusal"`。 ```python from pydantic import BaseModel @@ -567,36 +567,37 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人机协同 +## 持久执行集成与人在回路 -对于工具审批的暂停/恢复模式,请先参阅专门的[人机协同指南](human_in_the_loop.md)。以下集成适用于持久编排,可用于运行可能经历长时间等待、重试或进程重启的情况。 +有关工具审批的暂停/恢复模式,请首先阅读专门的[人在回路指南](human_in_the_loop.md)。以下集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 ### Dapr -你可以使用 Agents SDK 的 [Dapr](https://dapr.io) Diagrid 集成来运行持久、长时间运行的智能体。这些智能体可自动从故障中恢复,并支持人机协同工作流。Dapr 是一个供应商中立的 [CNCF](https://cncf.io) 工作流编排器。请从[这里](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI 智能体。 +你可以使用 Agents SDK 的 [Dapr](https://dapr.io) Diagrid 集成来运行持久、长期运行的智能体,这些智能体可自动从故障中恢复并支持人在回路工作流。Dapr 是一个厂商中立的 [CNCF](https://cncf.io) 工作流编排器。可从[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI 智能体。 ### Temporal -你可以使用 Agents SDK 的 [Temporal](https://temporal.io/) 集成运行持久、长时间运行的工作流,包括人机协同任务。你可以在[此视频中](https://www.youtube.com/watch?v=fFBZqzT4DD8)观看 Temporal 与 Agents SDK 协同完成长时间运行任务的演示,并可在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 +你可以使用 Agents SDK 的 [Temporal](https://temporal.io/) 集成来运行持久、长期运行的工作流,包括人在回路任务。可在[此视频](https://www.youtube.com/watch?v=fFBZqzT4DD8)中观看 Temporal 与 Agents SDK 协同完成长期任务的实际演示,并在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 ### Restate -你可以使用 Agents SDK 的 [Restate](https://restate.dev/) 集成构建轻量级、持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务器函数运行。更多详情请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 +你可以使用 Agents SDK 的 [Restate](https://restate.dev/) 集成来运行轻量级、持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务器函数运行。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 ### DBOS -你可以使用 Agents SDK 的 [DBOS](https://dbos.dev/) 集成运行可靠的智能体,使其在故障和重启时保留进度。它支持长时间运行的智能体、人机协同工作流和任务转移,并同时支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。更多详情请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 +你可以使用 Agents SDK 的 [DBOS](https://dbos.dev/) 集成来运行可靠的智能体,并在故障和重启期间保留进度。它支持长期运行的智能体、人在回路工作流和任务转移,也支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 ## 异常 -SDK 会在某些情况下抛出异常。完整列表请参阅 [`agents.exceptions`][]。概述如下: - -- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 抛出的所有异常的基类。它是一种通用类型,所有其他具体异常均派生自此类。 -- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会抛出此异常。它表示智能体未能在指定的智能体循环轮次数(LLM 调用次数)内完成任务。设置 `max_turns=None` 可禁用该限制。 -- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]:当一次模型调用尝试超过 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] 时,会抛出此异常。有关适用范围和重试行为,请参阅[模型调用超时](models/index.md#model-call-timeouts)。 -- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成意外或无效的输出时,会发生此异常。这可能包括: - - 格式错误的 JSON:模型为工具调用或直接输出提供了格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 的情况下。 - - 意外的工具相关失败:模型未按预期方式使用工具 -- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当函数工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会抛出此异常。 -- [`UserError`][agents.exceptions.UserError]:当你(编写使用 SDK 的代码的人)在使用 SDK 时出错,会抛出此异常。这通常由错误的代码实现、无效配置或误用 SDK API 导致。 -- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:满足输入安全防护措施的条件时,会抛出 `InputGuardrailTripwireTriggered`;满足输出安全防护措施的条件时,会抛出 `OutputGuardrailTripwireTriggered`。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file +SDK 会在特定情况下引发异常。完整列表位于 [`agents.exceptions`][]。概览如下: + +- [`AgentsException`][agents.exceptions.AgentsException]:这是 SDK 引发的所有异常的基类。它是一个通用类型,其他所有特定异常均派生自该类型。 +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded]:当智能体运行超过传递给 `Runner.run`、`Runner.run_sync` 或 `Runner.run_streamed` 方法的 `max_turns` 限制时,会引发此异常。这表示智能体无法在指定数量的智能体循环轮次(LLM 调用)内完成任务。设置 `max_turns=None` 可禁用此限制。 +- [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]:当一次模型调用尝试超过 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] 时,会引发此异常。有关作用范围和重试行为,请参阅[模型调用超时](models/index.md#model-call-timeouts)。 +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError]:当底层模型(LLM)生成非预期或无效输出时,会出现此异常。这可能包括: + - 格式错误的 JSON:模型为工具调用或直接输出提供格式错误的 JSON 结构,尤其是在定义了特定 `output_type` 时。 + - 非预期的工具相关故障:模型未能以预期方式使用工具。 + - 失败或未完成的非流式 Responses 调用:当返回的响应具有终止状态 `failed` 或 `incomplete` 时,`OpenAIResponsesModel` 和 `AnyLLMModel` 中的 Responses 路径会引发此异常。该异常会标明终止状态,并包含响应中可用的错误或未完成详情。 +- [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]:当函数工具调用超过其配置的超时时间,并且该工具使用 `timeout_behavior="raise_exception"` 时,会引发此异常。 +- [`UserError`][agents.exceptions.UserError]:当你(使用 SDK 编写代码的人)在使用 SDK 时出错,会引发此异常。这通常是由错误的代码实现、无效配置或误用 SDK API 导致的。 +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered]、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]:满足输入安全防护措施的条件时,会引发 `InputGuardrailTripwireTriggered`;满足输出安全防护措施的条件时,会引发 `OutputGuardrailTripwireTriggered`。输入安全防护措施会在处理前检查传入消息,而输出安全防护措施会在交付前检查智能体的最终响应。 \ No newline at end of file diff --git a/docs/zh/usage.md b/docs/zh/usage.md index 90cd73b0b2..d6c2d7860f 100644 --- a/docs/zh/usage.md +++ b/docs/zh/usage.md @@ -2,25 +2,25 @@ search: exclude: true --- -# 用量 +# 使用量 -Agents SDK会自动追踪每次运行的令牌用量。你可以从运行上下文中访问这些数据,并用其监控成本、执行限额或记录分析数据。 +Agents SDK会自动追踪每次运行的 token 使用量。你可以从运行上下文中访问这些数据,用于监控成本、强制执行限制或记录分析数据。 ## 追踪内容 -- **requests**:LLM API调用次数 -- **input_tokens**:发送的输入令牌总数 -- **output_tokens**:接收的输出令牌总数 +- **requests**:发起的 LLM API 调用次数 +- **input_tokens**:发送的输入 token 总数 +- **output_tokens**:接收的输出 token 总数 - **total_tokens**:输入 + 输出 -- **request_usage_entries**:每个请求的用量明细列表 +- **request_usage_entries**:每个请求的使用量明细列表 - **details**: - `input_tokens_details.cached_tokens` - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 运行用量的访问 +## 从运行中访问使用量 -在 `Runner.run(...)` 执行后,通过 `result.context_wrapper.usage` 访问用量。 +执行 `Runner.run(...)` 后,通过 `result.context_wrapper.usage` 访问使用量。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -32,22 +32,22 @@ print("Output tokens:", usage.output_tokens) print("Total tokens:", usage.total_tokens) ``` -用量会汇总运行期间的所有模型调用,包括生成工具调用或任务转移的模型调用。 +使用量会汇总运行期间的所有模型调用,包括生成工具调用或任务转移的模型调用。 -当 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 在运行结束前自动压缩历史记录时,该 `responses.compact` 请求报告的用量也会添加到同一次运行的总量中。在运行之外手动调用 `run_compaction()` 时,不存在相应的运行上下文,因此不会更新此前运行返回的用量对象。请参阅 [OpenAI响应压缩会话](sessions/index.md#openai-responses-compaction-sessions)。 +当 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 在运行结束前自动压缩历史记录时,该 `responses.compact` 请求报告的使用量也会添加到同一次运行的总量中。在运行之外手动调用 `run_compaction()` 时,由于没有包含该调用的运行上下文,因此不会更新先前运行返回的使用量对象。请参阅 [OpenAI Responses 压缩会话](sessions/index.md#openai-responses-compaction-sessions)。 -### 第三方适配器的用量启用 +### 使用第三方适配器启用使用量统计 -不同第三方适配器和提供商后端的用量报告方式各不相同。如果你通过第三方适配器访问模型,并且需要准确的 `result.context_wrapper.usage` 值: +不同第三方适配器和提供商后端的使用量报告方式各不相同。如果你通过第三方适配器访问模型,并且需要准确的 `result.context_wrapper.usage` 值: -- 使用 `AnyLLMModel` 时,如果上游提供商返回用量数据,系统会自动传递这些数据。从Chat Completions后端流式传输响应时,可能需要设置 `ModelSettings(include_usage=True)` 才能发送用量数据块。 -- 使用 `LitellmModel` 时,某些提供商后端默认不报告用量,因此通常需要设置 `ModelSettings(include_usage=True)`。 +- 使用 `AnyLLMModel` 时,如果上游提供商返回使用量数据,系统会自动传递这些数据。从 Chat Completions 后端以流式方式获取响应时,可能需要设置 `ModelSettings(include_usage=True)`,才能发出使用量数据块。 +- 使用 `LitellmModel` 时,某些提供商后端默认不报告使用量,因此通常需要设置 `ModelSettings(include_usage=True)`。 -请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)一节的适配器专属说明,并在计划部署的具体提供商后端上验证用量报告。 +请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)一节的适配器专属说明,并在计划部署的具体提供商后端上验证使用量报告。 -## 按请求的用量追踪 +## 按请求追踪使用量 -SDK会在 `request_usage_entries` 中自动追踪每个 API 请求的用量,这有助于进行详细的成本计算和监控上下文窗口消耗。 +SDK 会在 `request_usage_entries` 中自动追踪每个 API 请求的使用量,这有助于详细计算成本和监控上下文窗口消耗。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -56,9 +56,9 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 提供商用量载荷的保留 +## 提供商使用量有效载荷的保留 -Agents SDK会将提供商用量标准化为 [`Usage`][agents.usage.Usage] 字段,从而在不同模型提供商之间提供一致的用量总计。当应用必须保留提供商特有的用量字段,或区分被省略的字段与提供商报告的零值时,请将 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] 设置为 `True`: +Agents SDK会将提供商使用量标准化为 [`Usage`][agents.usage.Usage] 字段,从而在不同模型提供商之间提供一致的总量。当应用必须保留提供商特定的使用量字段,或需要区分被省略的字段与提供商报告为零的字段时,请将 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] 设置为 `True`: ```python from agents import Agent, ModelSettings, Runner @@ -73,15 +73,15 @@ for response in result.raw_responses: print(response.raw_usage) ``` -Agents SDK会将每个 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 值存储为该次模型调用中提供商载荷的独立 JSON 兼容快照。Agents SDK不会在整个运行期间汇总 `raw_usage`。当禁用保留、提供商未返回用量载荷,或上游适配器已丢弃原始字段存在性信息时,该值仍为 `None`。 +Agents SDK会将每个 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usage] 值存储为该模型调用的提供商有效载荷的独立 JSON 兼容快照。Agents SDK不会在整个运行过程中汇总 `raw_usage`。当禁用保留功能、提供商未返回使用量有效载荷,或上游适配器已丢弃原始字段是否存在的信息时,该值仍为 `None`。 -`preserve_raw_usage` 仅保留到达模型适配器的用量载荷;此设置不会向提供商请求用量数据。当流式Chat Completions提供商要求显式请求用量时,还需设置 `ModelSettings(include_usage=True)`。 +`preserve_raw_usage` 只会保留到达模型适配器的使用量有效载荷;此设置不会向提供商请求使用量数据。当流式 Chat Completions 提供商要求显式请求使用量数据时,还应设置 `ModelSettings(include_usage=True)`。 -`LitellmModel` 目前不会在流式或非流式运行中填充 `ModelResponse.raw_usage`,因此 `preserve_raw_usage=True` 对该适配器无效。使用 `LitellmModel` 时,请继续使用标准化的 [`Usage`][agents.usage.Usage] 字段;如果需要保留提供商特有的字段存在性信息,请选择支持保留原始用量的适配器。 +无论是流式运行还是非流式运行,`LitellmModel` 目前都不会填充 `ModelResponse.raw_usage`,因此 `preserve_raw_usage=True` 对该适配器不起作用。使用 `LitellmModel` 时,请继续使用标准化的 [`Usage`][agents.usage.Usage] 字段;如果需要提供商特定字段是否存在的信息,请选择支持保留原始使用量的适配器。 -## 会话中的用量访问 +## 通过会话访问使用量 -使用 `Session`(例如 `SQLiteSession`)时,每次调用 `Runner.run(...)` 都会返回该次特定运行的用量。会话会保留对话历史记录以提供上下文,但每次运行的用量相互独立。 +使用 `Session`(例如 `SQLiteSession`)时,每次调用 `Runner.run(...)` 都会返回该次特定运行的使用量。会话会保留对话历史记录作为上下文,但每次运行的使用量相互独立。 ```python session = SQLiteSession("my_conversation") @@ -93,11 +93,29 @@ second = await Runner.run(agent, "Can you elaborate?", session=session) print(second.context_wrapper.usage.total_tokens) # Usage for second run ``` -请注意,尽管会话会在多次运行之间保留对话上下文,但每次调用 `Runner.run()` 返回的用量指标仅代表该次执行。在会话中,之前的消息可能会作为输入重新提供给每次运行,这会影响后续轮次的输入令牌数量。 +请注意,虽然会话会在不同运行之间保留对话上下文,但每次调用 `Runner.run()` 返回的使用量指标仅代表该次执行。在会话中,先前的消息可能会作为输入重新传入每次运行,从而影响后续轮次的输入 token 数量。 -## 钩子中的用量使用 +## RunState 检查点中的使用量 -如果你使用 `RunHooks`,传递给每个钩子的 `context` 对象都包含 `usage`。借助此对象,你可以在生命周期的关键时刻记录用量。 +[`RunResult.to_state()`][agents.result.RunResult.to_state] 会捕获截至当前已累计使用量的独立快照。从该检查点恢复的运行以捕获的总量为起点,并在此基础上添加自身模型调用的使用量。恢复后的运行不会将这些新增总量添加到原始 `RunResult`,也不会添加到根据该结果创建的其他检查点。 + +```python +first = await Runner.run(agent, "First request") +checkpoint_a = first.to_state() +checkpoint_b = first.to_state() + +resumed_a = await Runner.run(agent, checkpoint_a) +resumed_b = await Runner.run(agent, checkpoint_b) + +assert resumed_a.context_wrapper.usage is not first.context_wrapper.usage +assert resumed_b.context_wrapper.usage is not resumed_a.context_wrapper.usage +``` + +这种隔离也适用于 [`Usage`][agents.usage.Usage] 中的 `request_usage_entries` 列表。恢复后的嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行是顶层独立计量的例外:该嵌套运行恢复后的模型使用量会被有意汇总到当前外层运行的使用量中,与该嵌套运行先前的模型调用处理方式相同。 + +## 钩子中的使用量 + +如果你使用 `RunHooks`,传递给每个钩子的 `context` 对象都包含 `usage`。这样便可在生命周期的关键时刻记录使用量。 ```python class MyHooks(RunHooks): @@ -110,7 +128,7 @@ class MyHooks(RunHooks): 有关详细的 API 文档,请参阅: -- [`Usage`][agents.usage.Usage] - 用量追踪数据结构 -- [`RequestUsage`][agents.usage.RequestUsage] - 每个请求的用量详情 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文访问用量 -- [`RunHooks`][agents.run.RunHooks] - 接入用量追踪生命周期 \ No newline at end of file +- [`Usage`][agents.usage.Usage] - 使用量追踪数据结构 +- [`RequestUsage`][agents.usage.RequestUsage] - 每个请求的使用量详情 +- [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文中访问使用量 +- [`RunHooks`][agents.run.RunHooks] - 接入使用量追踪生命周期 \ No newline at end of file diff --git a/docs/zh/visualization.md b/docs/zh/visualization.md index 08b2baeadf..225738a0e7 100644 --- a/docs/zh/visualization.md +++ b/docs/zh/visualization.md @@ -4,7 +4,7 @@ search: --- # 智能体可视化 -智能体可视化功能允许你使用 **Graphviz**,生成智能体及其与其他智能体、工具和MCP服务器之间连接关系的结构化图形表示。这有助于理解应用程序中智能体、工具和任务转移之间的交互方式。 +智能体可视化允许你使用 **Graphviz** 生成智能体及其与其他智能体、工具和 MCP 服务器之间连接关系的结构化图形表示。这有助于理解智能体、工具和任务转移在应用程序中如何交互。 ## 安装 @@ -16,19 +16,19 @@ pip install "openai-agents[viz]" ## 图形生成 -你可以使用 `draw_graph` 函数生成智能体可视化图形。此函数会创建一个有向图,其中: +你可以使用 `draw_graph` 函数生成智能体可视化图。此函数会创建一个有向图,其中: -- **智能体**表示为黄色方框。 -- **MCP服务器**表示为灰色方框。 -- **工具**表示为绿色椭圆。 -- **任务转移**表示为从一个智能体指向另一个智能体的有向边。 +- **智能体**以黄色方框表示。 +- **MCP 服务器**以灰色方框表示。 +- **工具**以绿色椭圆表示。 +- **任务转移**以从一个智能体指向另一个智能体的有向边表示。 -### 使用示例 +### 用法示例 ```python import os -from agents import Agent +from agents import Agent, handoff from agents.decorators import tool from agents.mcp.server import MCPServerStdio from agents.extensions.visualization import draw_graph @@ -60,7 +60,7 @@ mcp_server = MCPServerStdio( triage_agent = Agent( name="Triage agent", instructions="Handoff to the appropriate agent based on the language of the request.", - handoffs=[spanish_agent, english_agent], + handoffs=[handoff(spanish_agent), handoff(english_agent)], tools=[get_weather], mcp_servers=[mcp_server], ) @@ -70,24 +70,26 @@ draw_graph(triage_agent) ![智能体图](../assets/images/graph.png) -这会生成一幅图形,以可视化方式表示**分诊智能体**的结构及其与子智能体和工具的连接关系。 +这会生成一张图,以可视化方式展示**分诊智能体**的结构及其与子智能体和工具之间的连接。 +`draw_graph()` 会递归展开直接在 `handoffs` 中提供或通过 `handoff(agent)` 注册的目标智能体。无论采用哪种方式,图中都会包含每个目标的工具、MCP 服务器和下游任务转移。如果自定义 `Handoff` 没有可用的目标 `Agent`,则只会将其渲染为具名目标,因此图中无法展开该目标背后的资源。 -## 可视化解读 -生成的图形包括: +## 可视化说明 -- 表示入口点的**起始节点**(`__start__`)。 -- 以黄色填充的**矩形**表示智能体。 -- 以绿色填充的**椭圆**表示工具。 -- 以灰色填充的**矩形**表示MCP服务器。 +生成的图包括: + +- 一个表示入口点的**起始节点**(`__start__`)。 +- 以黄色填充的**矩形**表示的智能体。 +- 以绿色填充的**椭圆**表示的工具。 +- 以灰色填充的**矩形**表示的 MCP 服务器。 - 表示交互的有向边: - **实线箭头**表示智能体之间的任务转移。 - **点线箭头**表示工具调用。 - - **虚线箭头**表示MCP服务器调用。 -- 表示执行终止位置的**结束节点**(`__end__`)。 + - **虚线箭头**表示 MCP 服务器调用。 +- 一个表示执行终止位置的**结束节点**(`__end__`)。 -**注意:**较新版本的 `agents` 软件包会渲染MCP服务器,包括已验证此行为的 **v0.2.8**。如果可视化图形中没有显示MCP服务器方框,请升级到最新版本。 +**注意:**在较新版本的 `agents` 包中会渲染 MCP 服务器,包括已验证此行为的 **v0.2.8**。如果在可视化图中看不到 MCP 方框,请升级到最新版本。 ## 图形自定义 @@ -105,4 +107,4 @@ draw_graph(triage_agent).view() draw_graph(triage_agent, filename="agent_graph") ``` -这会在工作目录中生成 `agent_graph.png`。 \ No newline at end of file +这将在工作目录中生成 `agent_graph.png`。 \ No newline at end of file From 7e55afc9500d12937687988f1e91e900dcb4ad09 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:01:14 +0100 Subject: [PATCH 375/473] fix(voice) reject ignored explicit-client options (#4527) --- src/agents/voice/models/openai_model_provider.py | 7 +++++-- tests/voice/test_openai_model_provider.py | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/models/openai_model_provider.py b/src/agents/voice/models/openai_model_provider.py index 17cd1dc129..d918392c47 100644 --- a/src/agents/voice/models/openai_model_provider.py +++ b/src/agents/voice/models/openai_model_provider.py @@ -59,8 +59,11 @@ def __init__( agent_registration: Optional agent registration configuration. """ if openai_client is not None: - if api_key is not None or base_url is not None: - raise UserError("Don't provide api_key or base_url if you provide openai_client") + if any(value is not None for value in (api_key, base_url, organization, project)): + raise UserError( + "Don't provide api_key, base_url, organization, or project if you provide " + "openai_client" + ) self._client: AsyncOpenAI | None = openai_client else: self._client = None diff --git a/tests/voice/test_openai_model_provider.py b/tests/voice/test_openai_model_provider.py index 64a3152165..7aae338b77 100644 --- a/tests/voice/test_openai_model_provider.py +++ b/tests/voice/test_openai_model_provider.py @@ -16,6 +16,8 @@ [ {"api_key": "other_key"}, {"base_url": "https://example.com"}, + {"organization": "org_test"}, + {"project": "proj_test"}, {"api_key": "other_key", "base_url": "https://example.com"}, ], ) From bfb981d63e10ab21adf1d2fa8e1df42379c8ecc8 Mon Sep 17 00:00:00 2001 From: green3sf <222944370+green3sf@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:55:37 +0800 Subject: [PATCH 376/473] fix(core/voice): honor OpenAI provider options over default clients (#4530) --- src/agents/models/openai_provider.py | 30 ++++++-- .../voice/models/openai_model_provider.py | 19 +++++- tests/test_openai_provider_client_options.py | 68 +++++++++++++++++++ tests/voice/test_openai_model_provider.py | 38 +++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index 642e99df68..f46fdc55ca 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -137,15 +137,37 @@ def agent_registration(self) -> ResolvedOpenAIAgentRegistrationConfig | None: # AsyncOpenAI() raises an error if you don't have an API key set. def _get_client(self) -> AsyncOpenAI: if self._client is None: - default_client = _openai_shared.get_default_openai_client() + has_explicit_client_options = any( + value is not None + for value in ( + self._stored_api_key, + self._stored_base_url, + self._stored_websocket_base_url, + self._stored_organization, + self._stored_project, + ) + ) + default_client = ( + None if has_explicit_client_options else _openai_shared.get_default_openai_client() + ) self._client = ( default_client if default_client is not None else AsyncOpenAI( - api_key=self._stored_api_key or _openai_shared.get_default_openai_key(), - base_url=self._stored_base_url or os.getenv("OPENAI_BASE_URL"), + api_key=( + self._stored_api_key + if self._stored_api_key is not None + else _openai_shared.get_default_openai_key() + ), + base_url=( + self._stored_base_url + if self._stored_base_url is not None + else os.getenv("OPENAI_BASE_URL") + ), websocket_base_url=( - self._stored_websocket_base_url or os.getenv("OPENAI_WEBSOCKET_BASE_URL") + self._stored_websocket_base_url + if self._stored_websocket_base_url is not None + else os.getenv("OPENAI_WEBSOCKET_BASE_URL") ), organization=self._stored_organization, project=self._stored_project, diff --git a/src/agents/voice/models/openai_model_provider.py b/src/agents/voice/models/openai_model_provider.py index d918392c47..d662664bd9 100644 --- a/src/agents/voice/models/openai_model_provider.py +++ b/src/agents/voice/models/openai_model_provider.py @@ -81,12 +81,27 @@ def agent_registration(self) -> ResolvedOpenAIAgentRegistrationConfig | None: # AsyncOpenAI() raises an error if you don't have an API key set. def _get_client(self) -> AsyncOpenAI: if self._client is None: - default_client = _openai_shared.get_default_openai_client() + has_explicit_client_options = any( + value is not None + for value in ( + self._stored_api_key, + self._stored_base_url, + self._stored_organization, + self._stored_project, + ) + ) + default_client = ( + None if has_explicit_client_options else _openai_shared.get_default_openai_client() + ) self._client = ( default_client if default_client is not None else AsyncOpenAI( - api_key=self._stored_api_key or _openai_shared.get_default_openai_key(), + api_key=( + self._stored_api_key + if self._stored_api_key is not None + else _openai_shared.get_default_openai_key() + ), base_url=self._stored_base_url, organization=self._stored_organization, project=self._stored_project, diff --git a/tests/test_openai_provider_client_options.py b/tests/test_openai_provider_client_options.py index 3f3683d06b..fa7c4a7034 100644 --- a/tests/test_openai_provider_client_options.py +++ b/tests/test_openai_provider_client_options.py @@ -6,6 +6,7 @@ from openai import AsyncOpenAI from agents.exceptions import UserError +from agents.models import _openai_shared, openai_provider from agents.models.openai_provider import OpenAIProvider @@ -26,3 +27,70 @@ def test_openai_provider_rejects_ignored_options_with_explicit_client( openai_client=client, **cast(dict[str, Any], client_option), ) + + +@pytest.mark.parametrize( + ("option_name", "option_value"), + [ + ("api_key", "sk-provider"), + ("base_url", "https://provider.example.test/v1"), + ("websocket_base_url", "wss://provider.example.test/v1"), + ("organization", "org-provider"), + ("project", "proj-provider"), + ], +) +def test_openai_provider_explicit_options_override_default_client( + monkeypatch: pytest.MonkeyPatch, + option_name: str, + option_value: str, +) -> None: + default_client = cast(AsyncOpenAI, object()) + created_client = cast(AsyncOpenAI, object()) + captured_kwargs: dict[str, Any] = {} + + def create_client(**kwargs: Any) -> AsyncOpenAI: + captured_kwargs.update(kwargs) + return created_client + + monkeypatch.setattr(_openai_shared, "get_default_openai_client", lambda: default_client) + monkeypatch.setattr(openai_provider, "AsyncOpenAI", create_client) + monkeypatch.setattr(openai_provider, "shared_http_client", object) + + provider = OpenAIProvider(**cast(dict[str, Any], {option_name: option_value})) + + assert provider._get_client() is created_client + assert captured_kwargs[option_name] == option_value + + +@pytest.mark.parametrize( + ("option_name", "environment_name"), + [ + ("api_key", None), + ("base_url", "OPENAI_BASE_URL"), + ("websocket_base_url", "OPENAI_WEBSOCKET_BASE_URL"), + ], +) +def test_openai_provider_preserves_explicit_empty_options( + monkeypatch: pytest.MonkeyPatch, + option_name: str, + environment_name: str | None, +) -> None: + default_client = cast(AsyncOpenAI, object()) + created_client = cast(AsyncOpenAI, object()) + captured_kwargs: dict[str, Any] = {} + + def create_client(**kwargs: Any) -> AsyncOpenAI: + captured_kwargs.update(kwargs) + return created_client + + monkeypatch.setattr(_openai_shared, "get_default_openai_client", lambda: default_client) + monkeypatch.setattr(_openai_shared, "get_default_openai_key", lambda: "sk-global") + monkeypatch.setattr(openai_provider, "AsyncOpenAI", create_client) + monkeypatch.setattr(openai_provider, "shared_http_client", object) + if environment_name is not None: + monkeypatch.setenv(environment_name, "https://global.example.test/v1") + + provider = OpenAIProvider(**cast(dict[str, Any], {option_name: ""})) + + assert provider._get_client() is created_client + assert captured_kwargs[option_name] == "" diff --git a/tests/voice/test_openai_model_provider.py b/tests/voice/test_openai_model_provider.py index 7aae338b77..b27d3750f6 100644 --- a/tests/voice/test_openai_model_provider.py +++ b/tests/voice/test_openai_model_provider.py @@ -8,6 +8,7 @@ from agents.exceptions import UserError from agents.models import _openai_shared +from agents.voice.models import openai_model_provider from agents.voice.models.openai_model_provider import OpenAIVoiceModelProvider, shared_http_client @@ -48,3 +49,40 @@ def __bool__(self) -> bool: monkeypatch.setattr(_openai_shared, "get_default_openai_client", lambda: client) assert OpenAIVoiceModelProvider()._get_client() is client + + +@pytest.mark.parametrize( + ("option_name", "option_value"), + [ + ("api_key", "sk-voice"), + ("base_url", "https://voice.example.test/v1"), + ("organization", "org-voice"), + ("project", "proj-voice"), + ("api_key", ""), + ("base_url", ""), + ("organization", ""), + ("project", ""), + ], +) +def test_voice_provider_explicit_options_override_default_client( + monkeypatch: pytest.MonkeyPatch, + option_name: str, + option_value: str, +) -> None: + default_client = cast(openai.AsyncOpenAI, object()) + created_client = cast(openai.AsyncOpenAI, object()) + captured_kwargs: dict[str, Any] = {} + + def create_client(**kwargs: Any) -> openai.AsyncOpenAI: + captured_kwargs.update(kwargs) + return created_client + + monkeypatch.setattr(_openai_shared, "get_default_openai_client", lambda: default_client) + monkeypatch.setattr(_openai_shared, "get_default_openai_key", lambda: "sk-global") + monkeypatch.setattr(openai_model_provider, "AsyncOpenAI", create_client) + monkeypatch.setattr(openai_model_provider, "shared_http_client", object) + + provider = OpenAIVoiceModelProvider(**cast(dict[str, Any], {option_name: option_value})) + + assert provider._get_client() is created_client + assert captured_kwargs[option_name] == option_value From 9fd6c81c597785500a4a7676ea7cffed47cbf333 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:55:56 +0100 Subject: [PATCH 377/473] fix(voice): include current OpenAI TTS voices (#4535) --- src/agents/voice/model.py | 16 +++++++++++++++- tests/voice/test_tts_voice_types.py | 7 +++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/voice/test_tts_voice_types.py diff --git a/src/agents/voice/model.py b/src/agents/voice/model.py index ab1b5f754b..5698fbeefe 100644 --- a/src/agents/voice/model.py +++ b/src/agents/voice/model.py @@ -14,7 +14,21 @@ ) DEFAULT_TTS_BUFFER_SIZE = 120 -TTSVoice = Literal["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer"] +TTSVoice = Literal[ + "alloy", + "ash", + "ballad", + "coral", + "echo", + "fable", + "onyx", + "nova", + "sage", + "shimmer", + "verse", + "marin", + "cedar", +] """Exportable type for the TTSModelSettings voice enum""" diff --git a/tests/voice/test_tts_voice_types.py b/tests/voice/test_tts_voice_types.py new file mode 100644 index 0000000000..346f73132f --- /dev/null +++ b/tests/voice/test_tts_voice_types.py @@ -0,0 +1,7 @@ +from typing import get_args + +from agents.voice.model import TTSVoice + + +def test_tts_voice_type_includes_current_openai_builtin_voices() -> None: + assert {"ballad", "verse", "marin", "cedar"} <= set(get_args(TTSVoice)) From 2af94722d93a5a1719af33ab7559ba79cc778f7f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 20 Aug 2026 11:52:52 +0900 Subject: [PATCH 378/473] fix: keep checkout line endings LF --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..d593909df1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep checkouts as LF so scripts and exact-content checks stay stable on Windows. +* text=auto eol=lf From 75d6a6f142edd6a396623304cb403b4b5d219276 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 20 Aug 2026 14:42:43 +0900 Subject: [PATCH 379/473] chore: update review policies for runtimne validation code changes --- .agents/skills/maintainer-review/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index 8f08c57657..586ac1e8cd 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -71,6 +71,10 @@ Do not treat a test proving that new code can work as evidence that the feature API symmetry, naming consistency, and parity with an adjacent tool, provider, or output type are design arguments, not evidence of need. Parity may justify work when it removes existing complexity or enforces a broad demonstrated invariant, but adding branches, tests, documentation, or public behavior requires independent practical justification. +Treat an explicit public `Literal`, enum, discriminated union, or equivalent static type restriction as evidence that other values are outside the supported contract. Avoid adding duplicate client-side runtime validation solely to reject values that the public type already excludes. Require evidence that the SDK itself ingests untyped data, that fail-fast behavior before side effects protects a documented contract or material invariant, or that the invalid value causes meaningful impact on a supported path. The host language's ability to bypass type hints, pass adversarial runtime objects, or mutate attributes after construction is not by itself sufficient reason to add permanent validation branches and tests. + +When an upstream server or provider already rejects an unsupported request, treat that boundary as the source of truth and avoid duplicating the same acceptance rules in the client. Add fail-fast client validation only when waiting for the server rejection creates a demonstrated, substantial pitfall or material efficiency problem, such as avoidable billable work, repeated network latency or resource consumption, an irreversible side effect or state mutation, or an error that arrives too late or is too opaque for reasonable correction. Prefer the server's evolving validation over copied provider allowlists or constraints that can drift. + If the need is not `Demonstrated`, inspect the patch only far enough to understand its contract, risk, and maintenance cost. Do not turn implementation defects, missing tests, or documentation gaps into a request-changes recommendation, because those questions become merge-blocking only after the need gate passes. If the report provides no concrete scenario, the existing functionality appears sufficient, or the requested mechanism solves only a hypothetical convenience problem, prefer `Needs evidence`, `Close`, `Supersede with a simpler alternative`, or `Not worth completing` over designing the requested feature on the reporter's behalf. ### 3. Discover competing open PRs proportionally From 502bccddd3b68f4183f8679687ff3a340928b817 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:12:09 +0100 Subject: [PATCH 380/473] fix(tracing): flush buffered data after tracing is disabled (#4528) --- src/agents/tracing/provider.py | 3 --- tests/test_disabled_trace_provider_flush.py | 18 ++++++++++++++++++ tests/test_trace_processor.py | 4 ++-- tests/tracing/test_tracing_env_disable.py | 13 +++++++++++++ 4 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 tests/test_disabled_trace_provider_flush.py diff --git a/src/agents/tracing/provider.py b/src/agents/tracing/provider.py index f7f76b8de1..b0e10b0bd2 100644 --- a/src/agents/tracing/provider.py +++ b/src/agents/tracing/provider.py @@ -498,9 +498,6 @@ def create_span( def force_flush(self) -> None: """Force all processors to flush their buffers immediately.""" self._refresh_disabled_flag() - if self._disabled: - return - try: self._multi_processor.force_flush() except Exception as e: diff --git a/tests/test_disabled_trace_provider_flush.py b/tests/test_disabled_trace_provider_flush.py new file mode 100644 index 0000000000..ad2f5d3974 --- /dev/null +++ b/tests/test_disabled_trace_provider_flush.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import agents.tracing as tracing +from agents.tracing.provider import DefaultTraceProvider + + +def test_flush_traces_still_flushes_registered_processors_when_disabled(monkeypatch) -> None: + provider = DefaultTraceProvider() + processor = MagicMock() + provider.register_processor(processor) + provider.set_disabled(True) + monkeypatch.setattr(tracing, "get_trace_provider", lambda: provider) + + tracing.flush_traces() + + processor.force_flush.assert_called_once_with() diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index 2ede56d834..07e975ccb9 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -360,7 +360,7 @@ def test_flush_traces_is_importable_from_top_level_agents_package(): assert top_level_flush_traces is flush_traces -def test_default_trace_provider_force_flush_respects_disabled_flag(): +def test_default_trace_provider_force_flush_still_flushes_when_disabled(): provider = DefaultTraceProvider() mock_processor = MagicMock() provider.register_processor(mock_processor) @@ -368,7 +368,7 @@ def test_default_trace_provider_force_flush_respects_disabled_flag(): provider.set_disabled(True) provider.force_flush() - mock_processor.force_flush.assert_not_called() + mock_processor.force_flush.assert_called_once_with() def test_trace_provider_force_flush_and_shutdown_default_to_noops(): diff --git a/tests/tracing/test_tracing_env_disable.py b/tests/tracing/test_tracing_env_disable.py index 2c62de1bf2..2da967b3a2 100644 --- a/tests/tracing/test_tracing_env_disable.py +++ b/tests/tracing/test_tracing_env_disable.py @@ -39,6 +39,19 @@ def __repr__(self) -> str: assert "Tracing is disabled. Not creating span" in caplog.text +def test_force_flush_initializes_env_disable_cache(monkeypatch): + """Force flush preserves the first-use timing for the env disable flag.""" + monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "1") + provider = DefaultTraceProvider() + + provider.force_flush() + + monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "0") + trace = provider.create_trace("still-disabled") + + assert isinstance(trace, NoOpTrace) + + def test_env_cached_after_first_use(monkeypatch): """Env flag is cached after the first trace and later env changes do not flip it.""" monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "0") From eb3a5d5b5d1539e304c452b207639a320d89ac6e Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 20 Aug 2026 01:13:32 -0500 Subject: [PATCH 381/473] fix(mcp): deep-copy cached tools before returning them (#4525) --- src/agents/mcp/server.py | 25 +++--- tests/mcp/test_caching.py | 162 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 169 insertions(+), 18 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index a4bbcc3974..977352330e 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -128,6 +128,11 @@ class RequireApprovalObject(TypedDict, total=False): _SAFE_EXCEPTION_MESSAGE = "An additional error occurred during the MCP request." +def _snapshot_tools(tools: list[MCPTool]) -> list[MCPTool]: + """Return deep-copied tools so callers cannot mutate cached schemas.""" + return [tool.model_copy(deep=True) for tool in tools] + + def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | float | None: """Convert an MCP read timeout while intentionally treating zero as no timeout.""" if timeout_seconds is None: @@ -856,9 +861,9 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC): def cached_tools(self) -> list[MCPTool] | None: """A snapshot of the cached tools list, or `None` when nothing is cached. - This returns a new list so callers cannot mutate the server's cache in place. + This returns deep-copied tools so callers cannot mutate the server's cache. """ - return None if self._tools_list is None else list(self._tools_list) + return None if self._tools_list is None else _snapshot_tools(self._tools_list) def __init__( self, @@ -1034,10 +1039,10 @@ async def _apply_dynamic_tool_filter( ) filtered_tools = [] - for tool in tools: + for tool, detached in zip(tools, _snapshot_tools(tools), strict=True): try: - # Call the filter function with context - result = tool_filter_func(filter_context, tool) + # Inspect a detached copy so a mutating filter cannot corrupt the cache. + result = tool_filter_func(filter_context, detached) if inspect.isawaitable(result): should_include = await result @@ -1478,12 +1483,10 @@ async def fetch_pages() -> bool: filtered_tools = tools if self.tool_filter is not None: filtered_tools = await self._apply_tool_filter(filtered_tools, run_context, agent) - if filtered_tools is self._tools_list: - # The filters build a new list, but an absent filter — or a static filter with - # neither key set — passes the cached list straight through. Returning it would - # let a caller mutate the cache and corrupt every later `list_tools()` result. - return list(filtered_tools) - return filtered_tools + # Always deep-copy tools. Even when filters build a new list, the Tool + # objects (and nested input schemas) would otherwise remain shared with + # the cache and let callers corrupt required-parameter validation. + return _snapshot_tools(filtered_tools) except mcp_compat.HTTP_STATUS_ERROR_TYPES as e: status_code = http_status_code(e) transport_error = UserError( diff --git a/tests/mcp/test_caching.py b/tests/mcp/test_caching.py index 5619e99998..dc30f5d61f 100644 --- a/tests/mcp/test_caching.py +++ b/tests/mcp/test_caching.py @@ -159,10 +159,20 @@ async def test_list_tools_does_not_expose_the_cache_with_a_no_op_static_filter( async def test_cached_tools_returns_a_snapshot( mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client ): - """`cached_tools` must not hand out the live cache: appending to it would inject a tool.""" + """`cached_tools` must not hand out the live cache: mutating it must not leak into listings.""" server = MCPServerStdio(params={"command": tee}, cache_tools_list=True) mock_list_tools.return_value = ListToolsResult( - tools=[MCPTool(name="tool1", inputSchema={}), MCPTool(name="tool2", inputSchema={})] + tools=[ + MCPTool( + name="tool1", + inputSchema={ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + ), + MCPTool(name="tool2", inputSchema={}), + ] ) async with server: @@ -173,12 +183,150 @@ async def test_cached_tools_returns_a_snapshot( snapshot = server.cached_tools assert snapshot is not None snapshot.append(MCPTool(name="injected", inputSchema={})) + snapshot[0].description = "mutated" + snapshot[0].input_schema["required"] = [] - assert [tool.name for tool in (server.cached_tools or [])] == ["tool1", "tool2"] - assert [tool.name for tool in await server.list_tools(run_context, agent)] == [ - "tool1", - "tool2", - ] + later_cached = server.cached_tools + later_listed = await server.list_tools(run_context, agent) + assert [tool.name for tool in (later_cached or [])] == ["tool1", "tool2"] + assert [tool.name for tool in later_listed] == ["tool1", "tool2"] + assert (later_cached or [])[0].description is None + assert later_listed[0].description is None + assert (later_cached or [])[0].input_schema.get("required") == ["q"] + assert later_listed[0].input_schema.get("required") == ["q"] + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_list_tools_snapshots_tool_objects( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """Mutating a returned tool must not corrupt the cached tool or its schema.""" + schema = { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + } + server = MCPServerStdio(params={"command": tee}, cache_tools_list=True) + mock_list_tools.return_value = ListToolsResult( + tools=[MCPTool(name="tool1", inputSchema=schema)] + ) + + async with server: + run_context = RunContextWrapper(context=None) + agent = Agent(name="test_agent", instructions="Test agent") + + returned = await server.list_tools(run_context, agent) + cached = server.cached_tools + assert cached is not None + assert returned[0] is not cached[0] + assert returned[0].input_schema is not cached[0].input_schema + + returned[0].input_schema["required"] = [] + returned[0].description = "mutated" + + later = await server.list_tools(run_context, agent) + assert later[0].description is None + assert later[0].input_schema.get("required") == ["q"] + assert (server.cached_tools or [])[0].input_schema.get("required") == ["q"] + assert mock_list_tools.call_count == 1 + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.call_tool", new_callable=AsyncMock) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_list_tools_mutation_cannot_bypass_required_parameter_validation( + mock_list_tools: AsyncMock, + mock_call_tool: AsyncMock, + mock_initialize: AsyncMock, + mock_stdio_client, +): + """Clearing required fields on a returned tool must not skip call-time validation.""" + from mcp.types import CallToolResult, TextContent + + from agents.exceptions import UserError + + schema = { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + } + server = MCPServerStdio(params={"command": tee}, cache_tools_list=True) + mock_list_tools.return_value = ListToolsResult( + tools=[MCPTool(name="tool1", inputSchema=schema)] + ) + mock_call_tool.return_value = CallToolResult(content=[TextContent(type="text", text="ok")]) + + async with server: + run_context = RunContextWrapper(context=None) + agent = Agent(name="test_agent", instructions="Test agent") + returned = await server.list_tools(run_context, agent) + returned[0].input_schema["required"] = [] + + with pytest.raises(UserError, match="missing required parameters: q"): + await server.call_tool("tool1", {}) + assert mock_call_tool.call_count == 0 + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.call_tool", new_callable=AsyncMock) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_dynamic_filter_mutation_cannot_corrupt_cached_tool_schemas( + mock_list_tools: AsyncMock, + mock_call_tool: AsyncMock, + mock_initialize: AsyncMock, + mock_stdio_client, +): + """A callable filter that mutates nested schemas must not affect later listings or calls.""" + from mcp.types import CallToolResult, TextContent + + from agents.exceptions import UserError + + schema = { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + } + + def mutating_filter(_context, tool: MCPTool) -> bool: + tool.input_schema["required"] = [] + tool.description = "mutated" + return True + + server = MCPServerStdio( + params={"command": tee}, + cache_tools_list=True, + tool_filter=mutating_filter, + ) + mock_list_tools.return_value = ListToolsResult( + tools=[MCPTool(name="tool1", inputSchema=schema)] + ) + mock_call_tool.return_value = CallToolResult(content=[TextContent(type="text", text="ok")]) + + async with server: + run_context = RunContextWrapper(context=None) + agent = Agent(name="test_agent", instructions="Test agent") + first = await server.list_tools(run_context, agent) + later = await server.list_tools(run_context, agent) + cached = server.cached_tools + + assert first[0].input_schema.get("required") == ["q"] + assert later[0].input_schema.get("required") == ["q"] + assert cached is not None + assert cached[0].input_schema.get("required") == ["q"] + assert first[0].description is None + assert later[0].description is None + assert cached[0].description is None + + with pytest.raises(UserError, match="missing required parameters: q"): + await server.call_tool("tool1", {}) + assert mock_call_tool.call_count == 0 @pytest.mark.asyncio From e26a7d8aed59141ee13fb0a1fa16445017b0ccf1 Mon Sep 17 00:00:00 2001 From: Weike Zhang <66246918+weike-zhang@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:49:29 +0800 Subject: [PATCH 382/473] fix(chat-completions): raise ModelBehaviorError on truncated empty completions (#4513) --- src/agents/models/chatcmpl_stream_handler.py | 95 ++++-- src/agents/models/openai_chatcompletions.py | 44 ++- tests/models/test_openai_chatcompletions.py | 167 ++++++++++- .../test_openai_chatcompletions_stream.py | 280 +++++++++++++++++- 4 files changed, 550 insertions(+), 36 deletions(-) diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 51dcfe8251..289e116b06 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -445,11 +445,11 @@ async def buffer_tool_call_stream( if has_passthrough_output: passthrough_choices.append(choice) - elif choice.finish_reason == "content_filter": - # A content-filtered choice ends the stream with an empty delta, so it - # would otherwise be dropped here and the handler would never see the - # finish_reason it needs to synthesize the refusal. Forward a - # delta-stripped copy so buffering semantics are unchanged. + elif choice.finish_reason in {"content_filter", "length"}: + # A content-filtered or truncated choice ends the stream with an empty + # delta, so it would otherwise be dropped here and the handler would + # never see the finish_reason it needs to act on. + # Forward a delta-stripped copy so buffering semantics are unchanged. passthrough_choices.append(choice.model_copy(update={"delta": ChoiceDelta()})) if passthrough_choices or chunk.usage is not None: @@ -609,6 +609,7 @@ async def handle_stream( model: str | None = None, strict_feature_validation: bool = False, preserve_raw_usage: bool = False, + raise_on_length_truncation: bool = False, ) -> AsyncIterator[TResponseStreamEvent]: """ Handle a streaming chat completion response and yield response events. @@ -620,6 +621,11 @@ async def handle_stream( provider-specific stream processing. preserve_raw_usage: Whether to retain the last provider usage payload before converting it to the Responses usage shape. + raise_on_length_truncation: Whether to raise ModelBehaviorError when the + stream terminates with finish_reason == "length" and no visible output. + This is an internal option enabled only by OpenAIChatCompletionsModel: + the shared handler also serves LiteLLM and AnyLLM, whose streaming + behavior must remain unchanged. """ usage: CompletionUsage | None = None raw_usage: dict[str, Any] | None = None @@ -630,7 +636,11 @@ async def handle_stream( # safety block only through finish_reason == "content_filter" with an # empty delta and no refusal field. Track it so we can synthesize an # explicit refusal after the stream if nothing else was emitted. + # A completion truncated before any visible token (finish_reason == + # "length") has the same shape; track it too so we can surface a model + # behavior error instead of collapsing into an empty turn. saw_content_filter = False + saw_length = False async for chunk in stream: if not state.started: state.started = True @@ -675,6 +685,8 @@ async def handle_stream( if choice.finish_reason == "content_filter": saw_content_filter = True + elif choice.finish_reason == "length": + saw_length = True if not choice.delta: continue @@ -1174,6 +1186,34 @@ async def handle_stream( sequence_number=sequence_number.get_and_increment(), ) + # A completion truncated before any visible token (finish_reason == + # "length") is a token- or reasoning-budget exhaustion, not a policy + # refusal. Surface it as a model behavior error rather than manufacturing + # a refusal that would route through model_refusal handlers. This is an + # internal behavior enabled only by OpenAIChatCompletionsModel: the + # shared handler also serves LiteLLM and AnyLLM, whose streaming behavior + # must remain unchanged. + if ( + saw_length + and raise_on_length_truncation + and state.text_content_index_and_output is None + and state.refusal_content_index_and_output is None + and not state.function_calls + ): + # Preserve the request and any reported token usage on the response + # so the caller can attach it to the generation span before the + # error propagates. + if usage is not None: + response.usage = cls._build_response_usage(usage) + else: + _mark_request_completed_without_usage(response) + if preserve_raw_usage and raw_usage is not None: + _attach_raw_usage_snapshot(response, raw_usage) + raise ModelBehaviorError( + "Chat Completions stream terminated with finish_reason='length' " + "but produced no assistant text, tool call, or refusal." + ) + cls._finalize_thinking_blocks(state) for event in cls._finish_reasoning_item(state, sequence_number): yield event @@ -1294,27 +1334,7 @@ async def handle_stream( final_response = response.model_copy() final_response.output = outputs - final_response.usage = ( - ResponseUsage( - input_tokens=usage.prompt_tokens or 0, - output_tokens=usage.completion_tokens or 0, - total_tokens=usage.total_tokens or 0, - output_tokens_details=OutputTokensDetails( - reasoning_tokens=usage.completion_tokens_details.reasoning_tokens - if usage.completion_tokens_details - and usage.completion_tokens_details.reasoning_tokens - else 0 - ), - input_tokens_details=_make_input_tokens_details( - cached_tokens=usage.prompt_tokens_details.cached_tokens - if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens - else 0, - cache_write_tokens=_cache_write_tokens(usage.prompt_tokens_details), - ), - ) - if usage - else None - ) + final_response.usage = cls._build_response_usage(usage) if preserve_raw_usage: _attach_raw_usage_snapshot(final_response, raw_usage) if usage is None: @@ -1328,3 +1348,26 @@ async def handle_stream( type="response.completed", sequence_number=sequence_number.get_and_increment(), ) + + @staticmethod + def _build_response_usage(usage: CompletionUsage | None) -> ResponseUsage | None: + """Convert the streamed provider usage into a Responses usage payload.""" + if usage is None: + return None + return ResponseUsage( + input_tokens=usage.prompt_tokens or 0, + output_tokens=usage.completion_tokens or 0, + total_tokens=usage.total_tokens or 0, + output_tokens_details=OutputTokensDetails( + reasoning_tokens=usage.completion_tokens_details.reasoning_tokens + if usage.completion_tokens_details + and usage.completion_tokens_details.reasoning_tokens + else 0 + ), + input_tokens_details=_make_input_tokens_details( + cached_tokens=usage.prompt_tokens_details.cached_tokens + if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens + else 0, + cache_write_tokens=_cache_write_tokens(usage.prompt_tokens_details), + ), + ) diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index c5e4509126..83d2618262 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -300,6 +300,18 @@ async def get_response( else Usage(requests=1) ) + # Record the request and token usage on the span before the terminal + # branches below, so a ModelBehaviorError raised for a truncated empty + # completion still leaves the request and usage accounted for. + span_generation.span_data.usage = { + "requests": usage.requests, + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "total_tokens": usage.total_tokens, + "input_tokens_details": usage.input_tokens_details.model_dump(), + "output_tokens_details": usage.output_tokens_details.model_dump(), + } + # Some providers signal a filtered non-streaming completion only through # finish_reason="content_filter" and an otherwise empty message. Preserve # that terminal signal as a refusal instead of returning an empty output. @@ -313,18 +325,27 @@ async def get_response( ): message.refusal = "Response withheld by the provider's content filter." + # A completion truncated before any visible token (finish_reason="length") + # is a token- or reasoning-budget exhaustion, not a policy refusal. + # Surface it as a model behavior error rather than manufacturing a + # refusal that would route through model_refusal handlers. + if ( + message is not None + and first_choice is not None + and first_choice.finish_reason == "length" + and not message.content + and not message.refusal + and not message.tool_calls + ): + raise ModelBehaviorError( + "Chat Completions response terminated with finish_reason='length' " + "but produced no assistant text, tool call, or refusal." + ) + if tracing.include_data(): span_generation.span_data.output = ( [message.model_dump()] if message is not None else [] ) - span_generation.span_data.usage = { - "requests": usage.requests, - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "total_tokens": usage.total_tokens, - "input_tokens_details": usage.input_tokens_details.model_dump(), - "output_tokens_details": usage.output_tokens_details.model_dump(), - } # Build provider_data for provider_specific_fields provider_data = {"model": self.model} @@ -470,6 +491,7 @@ async def stream_response( cast(AsyncStream[ChatCompletionChunk], stream_for_handler), model=self.model, strict_feature_validation=self._strict_feature_validation, + raise_on_length_truncation=True, **raw_usage_options, ): if chunk.type == "response.completed": @@ -483,6 +505,12 @@ async def stream_response( ) yield chunk + except ModelBehaviorError: + # The handler preserves the request and any reported token usage on the + # base response before raising (e.g. a token-budget-exhausted empty + # completion). Attach it to the span before the error surfaces. + self._populate_stream_generation_span(span_generation, response, tracing) + raise except asyncio.CancelledError: close_stream_in_background = True self._schedule_async_iterator_close(stream) diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index b8198e6ec5..c3d196b595 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -50,7 +50,7 @@ generation_span, trace, ) -from agents.exceptions import UserError +from agents.exceptions import ModelBehaviorError, UserError from agents.models._retry_runtime import provider_managed_retries_disabled from agents.models.chatcmpl_helpers import HEADERS_OVERRIDE, ChatCmplHelpers from agents.models.fake_id import FAKE_RESPONSES_ID @@ -426,6 +426,171 @@ async def test_get_response_preserves_empty_nonfiltered_output(monkeypatch) -> N assert resp.output == [] +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_raises_on_truncated_empty_turn(monkeypatch) -> None: + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + await _get_response_for_choice( + monkeypatch, + Choice( + index=0, + finish_reason="length", + message=ChatCompletionMessage(role="assistant", content=None), + ), + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_raises_on_truncated_empty_string_turn(monkeypatch) -> None: + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + await _get_response_for_choice( + monkeypatch, + Choice( + index=0, + finish_reason="length", + message=ChatCompletionMessage(role="assistant", content=""), + ), + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_traces_error_on_truncated_empty_turn(monkeypatch) -> None: + with trace(workflow_name="truncation-error"): + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + await _get_response_for_choice( + monkeypatch, + Choice( + index=0, + finish_reason="length", + message=ChatCompletionMessage(role="assistant", content=None), + ), + tracing=ModelTracing.ENABLED, + ) + + generation_spans = [ + span for span in fetch_ordered_spans() if span.span_data.type == "generation" + ] + assert len(generation_spans) == 1 + generation = generation_spans[0] + exported_span = generation.export() + assert exported_span is not None + assert exported_span["error"] is not None + # The request (and any reported tokens) must be preserved on the span even though + # the call raised, so the run's usage accounting does not lose the request. + assert generation.span_data.usage is not None + assert generation.span_data.usage["requests"] == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_traces_usage_on_truncated_empty_turn(monkeypatch) -> None: + """The token usage reported before a truncated empty completion raises must be + preserved on the generation span, alongside the request.""" + chat = ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[ + Choice( + index=0, + finish_reason="length", + message=ChatCompletionMessage(role="assistant", content=None), + ) + ], + usage=CompletionUsage( + completion_tokens=0, + prompt_tokens=7, + total_tokens=7, + prompt_tokens_details=PromptTokensDetails(cached_tokens=2), + ), + ) + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + with trace(workflow_name="truncation-usage"): + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + generation = next(span for span in fetch_ordered_spans() if span.span_data.type == "generation") + assert generation.span_data.usage is not None + assert generation.span_data.usage["requests"] == 1 + assert generation.span_data.usage["input_tokens"] == 7 + assert generation.span_data.usage["total_tokens"] == 7 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("message", "expected_output_type", "expected_content_type"), + [ + ( + ChatCompletionMessage(role="assistant", content="partial"), + ResponseOutputMessage, + ResponseOutputText, + ), + ( + ChatCompletionMessage(role="assistant", content=None, refusal="provider refusal"), + ResponseOutputMessage, + ResponseOutputRefusal, + ), + ( + ChatCompletionMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call-1", + type="function", + function=Function(name="do_thing", arguments="{}"), + ) + ], + ), + ResponseFunctionToolCall, + None, + ), + ], +) +async def test_get_response_preserves_nonempty_truncated_output( + monkeypatch, + message: ChatCompletionMessage, + expected_output_type: type[object], + expected_content_type: type[object] | None, +) -> None: + resp = await _get_response_for_choice( + monkeypatch, + Choice(index=0, finish_reason="length", message=message), + ) + + assert len(resp.output) == 1 + assert isinstance(resp.output[0], expected_output_type) + if expected_content_type is not None: + assert isinstance(resp.output[0], ResponseOutputMessage) + assert len(resp.output[0].content) == 1 + assert isinstance(resp.output[0].content[0], expected_content_type) + if isinstance(resp.output[0], ResponseOutputMessage) and isinstance( + resp.output[0].content[0], ResponseOutputRefusal + ): + assert resp.output[0].content[0].refusal == "provider refusal" + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize( diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 1cced004e6..5c79493769 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -83,11 +83,15 @@ async def _completion_stream( async def _collect_handler_events( *chunks: ChatCompletionChunk, model: str | None = None, + raise_on_length_truncation: bool = False, ) -> list[Any]: return [ event async for event in ChatCmplStreamHandler.handle_stream( - _empty_response(), cast(Any, _completion_stream(*chunks)), model=model + _empty_response(), + cast(Any, _completion_stream(*chunks)), + model=model, + raise_on_length_truncation=raise_on_length_truncation, ) ] @@ -3780,6 +3784,149 @@ async def source() -> AsyncIterator[ChatCompletionChunk]: assert not ChatCmplStreamHandler._delta_has_passthrough_output(terminal_choices[0].delta) +@pytest.mark.asyncio +async def test_handler_stream_raises_on_length_truncation() -> None: + """A stream that terminates with finish_reason == "length" and no emitted + content must raise ModelBehaviorError instead of manufacturing a refusal, + matching the non-streaming path, when the internal option is enabled.""" + terminal = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="length")], + ) + + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + await _collect_handler_events(terminal, raise_on_length_truncation=True) + + +@pytest.mark.asyncio +async def test_handler_stream_length_is_ignored_without_internal_option() -> None: + """Without the internal option, a length terminal with no output must keep the + released behavior: no error and no refusal synthesis (an empty turn). + + The shared handler also serves LiteLLM and AnyLLM, so the length-error behavior + must be opt-in rather than changing those providers' streaming output. + """ + terminal = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="length")], + ) + + output_events = await _collect_handler_events(terminal) + + assert "response.refusal.delta" not in [e.type for e in output_events] + completed_event = output_events[-1] + assert isinstance(completed_event, ResponseCompletedEvent) + assert completed_event.response.output == [] + + +@pytest.mark.asyncio +async def test_handler_stream_length_does_not_clobber_text() -> None: + """A length finish_reason arriving after real text was streamed must not + synthesize a refusal.""" + chunk1 = _chunk_with([Choice(index=0, delta=ChoiceDelta(content="answer"))]) + chunk2 = _chunk_with([Choice(index=0, delta=ChoiceDelta(), finish_reason="length")]) + + output_events = await _collect_handler_events(chunk1, chunk2, raise_on_length_truncation=True) + + assert "response.refusal.delta" not in [e.type for e in output_events] + completed_event = output_events[-1] + assert isinstance(completed_event, ResponseCompletedEvent) + assistant_msg = completed_event.response.output[0] + assert isinstance(assistant_msg, ResponseOutputMessage) + text_part = assistant_msg.content[0] + assert isinstance(text_part, ResponseOutputText) + assert text_part.text == "answer" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_buffered_stream_raises_on_length_truncation(monkeypatch) -> None: + """With tool-call buffering enabled, a stream that terminates with + finish_reason == "length" and no emitted content must still raise + ModelBehaviorError, mirroring the non-buffered behavior.""" + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(role="assistant", content=""))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="length")], + usage=CompletionUsage(completion_tokens=0, prompt_tokens=7, total_tokens=7), + ) + + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + await _buffered_stream_events(monkeypatch, [chunk1, chunk2]) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_buffered_stream_length_does_not_clobber_text(monkeypatch) -> None: + """A length finish_reason arriving after real text was streamed must not + synthesize a refusal, even with buffering enabled.""" + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="length")], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=7, total_tokens=8), + ) + + output_events = await _buffered_stream_events(monkeypatch, [chunk1, chunk2]) + + assert "response.refusal.delta" not in [e.type for e in output_events] + completed_event = output_events[-1] + assert isinstance(completed_event, ResponseCompletedEvent) + assistant_msg = completed_event.response.output[0] + assert isinstance(assistant_msg, ResponseOutputMessage) + text_part = assistant_msg.content[0] + assert isinstance(text_part, ResponseOutputText) + assert text_part.text == "answer" + + +@pytest.mark.asyncio +async def test_buffer_tool_call_stream_forwards_length_finish_reason() -> None: + """The buffering layer must forward a length-truncated terminal choice even + though its delta is empty, so the finish_reason reaches the handler instead + of being swallowed (mirrors the content_filter forwarding behavior).""" + chunks = [ + _chunk_with([Choice(index=0, delta=ChoiceDelta(content=""))]), + _chunk_with([Choice(index=0, delta=ChoiceDelta(), finish_reason="length")]), + ] + + async def source() -> AsyncIterator[ChatCompletionChunk]: + for chunk in chunks: + yield chunk + + buffered = [c async for c in ChatCmplStreamHandler.buffer_tool_call_stream(source())] + + terminal_choices = [ + choice for chunk in buffered for choice in chunk.choices if choice.finish_reason == "length" + ] + assert len(terminal_choices) == 1 + # The forwarded copy carries no delta output. + assert not ChatCmplStreamHandler._delta_has_passthrough_output(terminal_choices[0].delta) + + @pytest.mark.asyncio async def test_buffer_tool_call_stream_does_not_duplicate_tool_calls_finish() -> None: """finish_reason == "tool_calls" is still emitted only by the synthesized @@ -4214,3 +4361,134 @@ async def test_stream_span_is_recorded_for_a_consumer_that_stops_at_the_terminal generation = next(s for s in fetch_ordered_spans() if s.span_data.type == "generation") assert generation.span_data.usage is not None assert generation.span_data.usage["requests"] == 1 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_streamed_span_preserves_usage_on_length_truncation(monkeypatch) -> None: + """A stream terminated by finish_reason == "length" with no output raises + ModelBehaviorError, but the request and reported token usage must still be + preserved on the generation span before the error surfaces.""" + + def _length_stream_patch(): + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="length")], + usage=CompletionUsage( + completion_tokens=0, + prompt_tokens=7, + total_tokens=7, + prompt_tokens_details=PromptTokensDetails(cached_tokens=2), + ), + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + yield chunk + + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, fake_stream() + + return patched_fetch_response + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", _length_stream_patch()) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + with trace(workflow_name="stream-length-truncation"): + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + async for _ in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + pass + + generation = next(s for s in fetch_ordered_spans() if s.span_data.type == "generation") + exported_span = generation.export() + assert exported_span is not None + assert exported_span["error"] is not None + assert generation.span_data.usage is not None + assert generation.span_data.usage["requests"] == 1 + assert generation.span_data.usage["input_tokens"] == 7 + assert generation.span_data.usage["total_tokens"] == 7 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_streamed_span_counts_request_on_length_truncation_without_usage( + monkeypatch, +) -> None: + """When the provider omits usage, a length-truncated empty completion still + counts the request on the generation span (tokens stay at zero), mirroring + the non-streaming path.""" + + def _length_stream_patch(): + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="length")], + usage=None, + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + yield chunk + + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, fake_stream() + + return patched_fetch_response + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", _length_stream_patch()) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + + with trace(workflow_name="stream-length-no-usage"): + with pytest.raises(ModelBehaviorError, match="finish_reason='length'"): + async for _ in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ): + pass + + generation = next(s for s in fetch_ordered_spans() if s.span_data.type == "generation") + assert generation.span_data.usage is not None + assert generation.span_data.usage["requests"] == 1 + assert generation.span_data.usage["total_tokens"] == 0 From f73e747530d898328ba56eaf45c6f6d1ec806cc8 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:36:45 +0100 Subject: [PATCH 383/473] fix(voice): support custom OpenAI TTS voice IDs (#4541) --- src/agents/voice/__init__.py | 2 ++ src/agents/voice/model.py | 45 +++++++++++++++++++---------- tests/voice/test_tts_voice_types.py | 24 +++++++++++++-- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/agents/voice/__init__.py b/src/agents/voice/__init__.py index e11ee4467f..749c6c5ed0 100644 --- a/src/agents/voice/__init__.py +++ b/src/agents/voice/__init__.py @@ -5,6 +5,7 @@ StreamedTranscriptionSession, STTModel, STTModelSettings, + TTSCustomVoice, TTSModel, TTSModelSettings, TTSVoice, @@ -29,6 +30,7 @@ "StreamedAudioInput", "STTModel", "STTModelSettings", + "TTSCustomVoice", "TTSModel", "TTSModelSettings", "TTSVoice", diff --git a/src/agents/voice/model.py b/src/agents/voice/model.py index 5698fbeefe..8ed3c5b62f 100644 --- a/src/agents/voice/model.py +++ b/src/agents/voice/model.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from typing import Any, Literal +from typing_extensions import TypedDict + from .imports import np, npt from .input import AudioInput, StreamedAudioInput from .utils import get_sentence_based_splitter @@ -14,22 +16,33 @@ ) DEFAULT_TTS_BUFFER_SIZE = 120 -TTSVoice = Literal[ - "alloy", - "ash", - "ballad", - "coral", - "echo", - "fable", - "onyx", - "nova", - "sage", - "shimmer", - "verse", - "marin", - "cedar", -] -"""Exportable type for the TTSModelSettings voice enum""" + +class TTSCustomVoice(TypedDict): + """A custom OpenAI TTS voice reference.""" + + id: str + """The custom voice ID.""" + + +TTSVoice = ( + Literal[ + "alloy", + "ash", + "ballad", + "coral", + "echo", + "fable", + "onyx", + "nova", + "sage", + "shimmer", + "verse", + "marin", + "cedar", + ] + | TTSCustomVoice +) +"""Exportable type for built-in TTS voices and custom voice IDs.""" @dataclass diff --git a/tests/voice/test_tts_voice_types.py b/tests/voice/test_tts_voice_types.py index 346f73132f..8cb919a159 100644 --- a/tests/voice/test_tts_voice_types.py +++ b/tests/voice/test_tts_voice_types.py @@ -1,7 +1,25 @@ -from typing import get_args +from typing import Literal, get_args, get_origin -from agents.voice.model import TTSVoice +import agents.voice as voice +from agents.voice import TTSCustomVoice, TTSModelSettings, TTSVoice + + +def _builtin_voice_values() -> set[str]: + literal_type = next(arg for arg in get_args(TTSVoice) if get_origin(arg) is Literal) + return set(get_args(literal_type)) def test_tts_voice_type_includes_current_openai_builtin_voices() -> None: - assert {"ballad", "verse", "marin", "cedar"} <= set(get_args(TTSVoice)) + assert {"ballad", "verse", "marin", "cedar"} <= _builtin_voice_values() + + +def test_tts_voice_type_accepts_custom_voice_ids() -> None: + custom_voice: TTSCustomVoice = {"id": "voice_1234"} + settings = TTSModelSettings(voice=custom_voice) + + assert TTSCustomVoice in get_args(TTSVoice) + assert settings.voice == {"id": "voice_1234"} + + +def test_tts_custom_voice_is_exported_from_agents_voice() -> None: + assert "TTSCustomVoice" in voice.__all__ From 1b7eb28f2543150a65fc27d7f0c7ae77b3c91880 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 20 Aug 2026 15:55:29 -0500 Subject: [PATCH 384/473] fix(core): fail closed on empty tool arguments (#4545) --- src/agents/util/_approvals.py | 4 +++- tests/realtime/test_session.py | 1 + tests/test_hitl_error_scenarios.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agents/util/_approvals.py b/src/agents/util/_approvals.py index 0aa86d9965..8992f5ada2 100644 --- a/src/agents/util/_approvals.py +++ b/src/agents/util/_approvals.py @@ -17,9 +17,11 @@ def _reject_nonstandard_json_constant(value: str) -> NoReturn: def parse_function_tool_arguments(arguments: str | None) -> dict[str, Any] | None: """Return parsed object arguments, or None when an approval policy cannot inspect them.""" + if arguments is None or not arguments.strip(): + return None try: parsed = json.loads( - arguments or "{}", + arguments, parse_constant=_reject_nonstandard_json_constant, ) except ValueError: diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 9d8f813bae..ab0ac5ebfc 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -3125,6 +3125,7 @@ async def test_function_tool_needs_approval_emits_event( @pytest.mark.parametrize( "arguments", [ + "", '{"subject": "refund"', "null", "[]", diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index d8518088cf..eb59cb1ce1 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -919,6 +919,7 @@ def bad_tool() -> str: @pytest.mark.parametrize( "arguments", [ + "", '{"subject": "refund"', "null", "[]", From 17ba331bb0ad1622a4ff4ecdc914c77118075dad Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 20 Aug 2026 15:55:48 -0500 Subject: [PATCH 385/473] fix(extensions): nest extra_body on the any-llm chat path (#4544) --- src/agents/extensions/models/any_llm_model.py | 8 ++-- tests/models/test_any_llm_model.py | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index a737bb8389..8a12fdbd9c 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -1363,12 +1363,12 @@ def _consume_background_cleanup_task_result(task: asyncio.Future[Any]) -> None: def _build_chat_extra_kwargs(self, model_settings: ModelSettings) -> dict[str, Any]: extra_kwargs: dict[str, Any] = {} - if model_settings.extra_query: + if model_settings.extra_query is not None: extra_kwargs["extra_query"] = copy(model_settings.extra_query) - if model_settings.metadata: + if model_settings.metadata is not None: extra_kwargs["metadata"] = copy(model_settings.metadata) - if isinstance(model_settings.extra_body, dict): - extra_kwargs.update(model_settings.extra_body) + if model_settings.extra_body is not None: + extra_kwargs["extra_body"] = copy(model_settings.extra_body) if model_settings.extra_args: extra_kwargs.update(model_settings.extra_args) return extra_kwargs diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index b80f7c0395..3e02b28ebd 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -371,6 +371,47 @@ def __bool__(self) -> bool: assert provider.chat_calls[0]["reasoning_effort"] == "low" +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_chat_nests_extra_body_instead_of_flattening( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model="openrouter/openai/gpt-5.4-mini") + extra_body = {"cached_content": "some_cache", "foo": 123, "temperature": 0.9} + settings = ModelSettings( + temperature=0.1, + extra_body=extra_body, + extra_query={}, + metadata={}, + ) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + call = provider.chat_calls[0] + assert call["temperature"] == 0.1 + assert call["extra_body"] == extra_body + assert call["extra_body"] is not extra_body + assert call["extra_query"] == {} + assert call["metadata"] == {} + assert "cached_content" not in call + assert "foo" not in call + extra_body["foo"] = 999 + assert call["extra_body"]["foo"] == 123 + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @pytest.mark.parametrize("provider_name", ["gemini", "vertexai"]) From 077ec65edcffa9208a5a47a19628ec6e8b5ce25a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 21 Aug 2026 14:05:32 +0900 Subject: [PATCH 386/473] chore: clarify edge-case policies --- .agents/skills/maintainer-review/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index 586ac1e8cd..c0cb5e73d3 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -75,6 +75,20 @@ Treat an explicit public `Literal`, enum, discriminated union, or equivalent sta When an upstream server or provider already rejects an unsupported request, treat that boundary as the source of truth and avoid duplicating the same acceptance rules in the client. Add fail-fast client validation only when waiting for the server rejection creates a demonstrated, substantial pitfall or material efficiency problem, such as avoidable billable work, repeated network latency or resource consumption, an irreversible side effect or state mutation, or an error that arrives too late or is too opaque for reasonable correction. Prefer the server's evolving validation over copied provider allowlists or constraints that can drift. +#### Synthetic edge-case and extreme-value gate + +Do not accept an issue or PR whose need is established only by constructing values that ordinary supported producers cannot emit or that have no realistic origin in supported use. This includes non-finite numbers such as `NaN` or infinity, astronomically large magnitudes, impossible enum or discriminated-union members, manually corrupted typed objects, and direct helper calls that bypass the owning public or wire boundary. A unit test that reaches such a branch proves constructibility, not a problem worth maintaining code for. + +Default these reports to `Close` or `Not worth completing`, even when the patch is small and technically correct, unless the evidence establishes at least one of the following: + +1. A supported provider, parser, public API workflow, or credible user report produces the exact value under realistic conditions. +2. The released public contract intentionally accepts the value category and ordinary caller code can generate it without first violating that contract. +3. A complete security trace shows that attacker-controlled input can cross an actual trust boundary and cause realistically exploitable resource exhaustion or another concrete security-boundary violation. + +Claims such as "this could sleep forever," "this could overflow," or "this might disable a limit" are insufficient without proving the realistic source of the value and the complete supported path to the consequence. Do not treat a security label as an exception by itself: identify the trust boundary, who can control the input, how it reaches the SDK, and the concrete protected outcome. A malformed value from an actually untrusted wire boundary may justify a fix when that trace is complete; a hypothetical hostile provider, monkeypatched object, or manually constructed payload does not by itself do so. + +When this gate fails, do not spend review effort refining implementation, tests, or error wording. Recommend closing both the issue and its PR, if one exists, and state the exact real-world evidence that would justify reconsideration only when such evidence is plausible. + If the need is not `Demonstrated`, inspect the patch only far enough to understand its contract, risk, and maintenance cost. Do not turn implementation defects, missing tests, or documentation gaps into a request-changes recommendation, because those questions become merge-blocking only after the need gate passes. If the report provides no concrete scenario, the existing functionality appears sufficient, or the requested mechanism solves only a hypothetical convenience problem, prefer `Needs evidence`, `Close`, `Supersede with a simpler alternative`, or `Not worth completing` over designing the requested feature on the reporter's behalf. ### 3. Discover competing open PRs proportionally From 707457064e4008090344959c5fe09df55345f2c4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 21 Aug 2026 14:13:58 +0900 Subject: [PATCH 387/473] chore: refine repo skills --- .agents/skills/docs-sync/SKILL.md | 2 +- .../implementation-final-review/SKILL.md | 5 ++- .../skills/implementation-kickoff/SKILL.md | 2 ++ .../skills/implementation-strategy/SKILL.md | 5 +++ .agents/skills/maintainer-review/SKILL.md | 2 +- .../maintainer-review/agents/openai.yaml | 2 +- .../skills/runtime-behavior-probe/SKILL.md | 35 ++++++++++--------- .../runtime-behavior-probe/agents/openai.yaml | 2 +- .../skills/test-coverage-improver/SKILL.md | 2 +- 9 files changed, 34 insertions(+), 23 deletions(-) diff --git a/.agents/skills/docs-sync/SKILL.md b/.agents/skills/docs-sync/SKILL.md index e00cf80fdc..8e023b8832 100644 --- a/.agents/skills/docs-sync/SKILL.md +++ b/.agents/skills/docs-sync/SKILL.md @@ -15,7 +15,7 @@ Identify doc coverage gaps and inaccuracies by comparing main branch features an - Identify the current branch and default branch (usually `main`). - Prefer analyzing the current branch to keep work aligned with in-flight changes. - If the current branch is not `main`, analyze only the diff vs `main` to scope doc updates. - - Avoid switching branches if it would disrupt local changes; use `git show main:` or `git worktree add` when needed. + - Avoid switching branches if it would disrupt local changes. Prefer read-only inspection such as `git show main:`. If a separate checkout is genuinely required, stop and obtain the explicit approval required by `AGENTS.md` before creating or switching a worktree. 2. Build a feature inventory from the selected scope - If on `main`: inventory the full surface area and review docs comprehensively. diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index bee3dbe10c..304e42e1f4 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -1,6 +1,6 @@ --- name: implementation-final-review -description: Perform a risk-tiered zero-base final review loop before an implementation is declared complete. Use only when the user explicitly invokes $implementation-final-review or repository instructions authorize automatic invocation after implementation. Audit the complete merge-base diff for requirement fit, contract-surface coverage, await-boundary and lifecycle gaps, released compatibility, security, protocol, and persistence boundaries, unnecessary complexity, package and generated public surfaces, and adversarial test coverage; use compact self-contained reviewer packets and two concurrent no-history independent reviewers per round, defer broad final repository verification until review is clean and observable host capacity is available, preserve clean evidence for unchanged semantic components, close repeated root-cause groups instead of accumulating local patches, and enforce bounded review cycles in one task-global ledger. +description: Perform the repository's risk-tiered independent final review before implementation completion. Use only when explicitly invoked or when repository instructions require it after behavior-impacting implementation work; audit the complete task diff, supported contracts, lifecycle and security boundaries, complexity, and tests before final verification. --- # Implementation Final Review @@ -178,6 +178,9 @@ Choose dimensions based on the changed boundary; do not mechanically invent find ### Tests and generated public surfaces - Prefer public-boundary or caller-visible adversarial tests. +- Exercise the highest stable caller boundary that reproduces the required behavior. A helper-only test is insufficient when a caller transforms the input, owns the lifecycle, or determines the observable result before or after invoking that helper. +- Require expected values and failure signals to come from the contract, a worked example, a baseline, or another independent oracle. Do not accept an assertion that recomputes the expected result with the same logic as the implementation. +- Use a narrower internal boundary when a lifecycle, concurrency, provider-wire, or malformed-stream scenario cannot be controlled reliably through a public entry point, and record why that boundary is necessary. - Add controlled interleavings for concurrency instead of relying only on sequential tests. - Test the required behavior, the nearest supported alternative, and one representative input per unsupported category. - Do not accept passing existing tests as proof when they encode the same assumptions as the implementation. diff --git a/.agents/skills/implementation-kickoff/SKILL.md b/.agents/skills/implementation-kickoff/SKILL.md index c68a23aac1..e3174cf5d4 100644 --- a/.agents/skills/implementation-kickoff/SKILL.md +++ b/.agents/skills/implementation-kickoff/SKILL.md @@ -35,6 +35,8 @@ Do not create the final branch yet. A detached worktree makes the eventual `$pr- Keep the task diff uncommitted through implementation, focused tests, formatting, and review fixes. Track new files explicitly because ordinary diff statistics omit untracked files. Maintain one canonical shipped-path manifest separately from operational artifacts and require a concrete deliverable reason for every path in it. Use the applicable repository skills and references, including `$implementation-strategy` before user-facing or runtime changes. +When the task can be decomposed without temporarily breaking a supported contract, implement one narrow end-to-end behavior slice at a time and run its focused test before adding the next slice. Do not force cross-cutting migrations or atomic compatibility changes into artificial slices that cannot remain valid independently. + Do not create checkpoint commits. If an external interruption requires extra protection, leave the dedicated worktree intact or use a clearly named temporary stash; restore the changes before continuing and do not treat the stash as a deliverable. ### Taking over an existing pull request diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index 9354df1769..fcbe68a187 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -61,6 +61,9 @@ Example: if successive findings require traversing a direct wrapper, partial, ne - Unreleased persisted schema versions may be renumbered or squashed when intermediate snapshots are intentionally unsupported; update the support set and tests together. - Do not equate a broad Python or third-party protocol with support for every representable shape. - Prefer the nearest existing pipeline and one source of truth for schema, documentation, validation, identity, and invocation. +- Treat an interface as everything a caller must know to use the behavior correctly, including ordering, errors, lifecycle, configuration, and performance constraints when relevant; do not judge its size from the signature alone. +- Apply the deletion test before retaining a new abstraction: keep it when removing it would distribute required complexity across callers, but remove it when the complexity itself would disappear. +- Add a replaceable boundary only for demonstrated variation, ownership, or testability. One hypothetical adapter or a test-only indirection is not enough when the existing pipeline already provides a stable boundary. - Add abstractions, state, classifications, branches, configuration, dependencies, or parallel paths only for a stated requirement, supported contract, or verified risk. - Prefer deletion or direct replacement for unreleased code. Treat branch-local implementation and tests as disposable. - Prefer an actionable construction- or validation-time error plus an existing alternative over partial protocol emulation. @@ -117,7 +120,9 @@ Before declaring the design complete, answer all of these with concrete evidence - Can the required behavior be described without naming internal helper types or reflection mechanics? - Does the implementation reuse the nearest existing pipeline rather than maintain a parallel interpretation? - Does every new abstraction and branch map to the scope contract or a verified risk? +- Would deleting each new abstraction merely push required complexity into multiple callers, and does each new boundary correspond to demonstrated variation, ownership, or testability? - Are unsupported neighboring cases rejected before side effects with an existing alternative identified? +- Do tests exercise the highest stable caller boundary that reproduces the required behavior, with expected values independent of the implementation logic? - Do the complete diff and tests cover the contract without making every constructible permutation supported? - Does the latest review revision shrink or preserve the behavior space rather than widen it without evidence? - When a complexity reset occurred, does every retained abstraction, branch, and test map to the frozen reset spec, with later findings classified against it? diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index c0cb5e73d3..3994538fcb 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -1,6 +1,6 @@ --- name: maintainer-review -description: Review a GitHub issue or pull request URL as an openai-agents-python maintainer, with a staged assessment of whether the claim is real, practically important, already solvable with supported functionality, correctly scoped, better served by another design, and worth maintainer and contributor effort. Use when assessing issue validity or severity, deciding whether an issue should be prioritized or closed, determining whether a requested feature represents an unmet need rather than a discoverability or usage gap, judging whether a PR is worth bringing to mergeable quality, comparing open PRs or alternative designs, separating code quality from repository readiness, or drafting a concise maintainer assessment. When closure, additional evidence, or code changes should be requested, also produce a polite, concise, complete, copy-paste-ready maintainer comment. +description: Assess an openai-agents-python GitHub issue or pull request as a maintainer. Use to verify the claimed need and practical impact, compare supported alternatives or competing approaches, separate code quality from repository readiness, recommend the maintainer action, and draft a copy-ready comment when evidence, changes, or closure should be requested. --- # Maintainer Review diff --git a/.agents/skills/maintainer-review/agents/openai.yaml b/.agents/skills/maintainer-review/agents/openai.yaml index b1051c00ef..edbffab315 100644 --- a/.agents/skills/maintainer-review/agents/openai.yaml +++ b/.agents/skills/maintainer-review/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Maintainer Review" short_description: "Gate PR value on demonstrated user need" - default_prompt: "Use $maintainer-review with this GitHub issue or PR URL. Before evaluating implementation quality, verify that linked evidence matches the exact runtime variant and assign Need evidence as Demonstrated, Plausible but unproven, Already covered, or Unsupported. Require either observed practical impact or a complete realistic trigger-to-material-consequence trace; reject harmless speculative logic-only improvements even when the patch is small and correct. Then compare existing and alternative approaches and complete the desk review and required lifecycle ownership checks without executing tests, imports, examples, reproductions, benchmarks, service calls, or another runtime-probe skill. If decision-relevant runtime evidence remains, stop with a Preliminary assessment and suggest a separate runtime investigation by stating the unresolved question, decision impact, evidence needed, and control. Do not plan a command, request probe approval, execute code, or invoke another skill from this review. Compare credible competing PRs, recommend the best maintainer action, and include an English comment draft when closure or changes are needed." + default_prompt: "Use $maintainer-review to verify the demonstrated need and practical impact for this issue or PR, keep the assessment desk-review-only, recommend the maintainer action, and provide a copy-ready English comment when changes, evidence, or closure are needed." diff --git a/.agents/skills/runtime-behavior-probe/SKILL.md b/.agents/skills/runtime-behavior-probe/SKILL.md index 198b7d88bf..d8b503c489 100644 --- a/.agents/skills/runtime-behavior-probe/SKILL.md +++ b/.agents/skills/runtime-behavior-probe/SKILL.md @@ -1,6 +1,6 @@ --- name: runtime-behavior-probe -description: Plan and execute runtime-behavior investigations with temporary probe scripts, validation matrices, state controls, and findings-first reports. Use only when the user explicitly invokes this skill to verify actual runtime behavior beyond normal code-level checks, especially to uncover edge cases, undocumented behavior, or common failure modes in local or live integrations. A baseline smoke check is fine as an entry point, but do not stop at happy-path confirmation. +description: Plan and, after explicit approval, execute runtime-behavior probes for local or live integrations. Use only when explicitly invoked to verify behavior that code review and normal tests cannot settle; define a controlled validation matrix and report observed evidence. --- # Runtime Behavior Probe @@ -40,8 +40,9 @@ Use this skill to investigate real runtime behavior, not to restate code or docu 1. Restate the investigation target in operational terms. Name the runtime surface, the key uncertainty, and the highest-risk behaviors to test. 2. Do a short preflight. Check the relevant code or docs first, decide whether the question needs local or live validation, and note any repo, baseline, or release boundary that matters. -3. Create a validation matrix before executing probes. Cover both baseline behavior and the most relevant failure or drift cases. The matrix can live in a scratch note, a temporary file, or a structured header inside the probe script. -4. For each case, choose an execution mode up front: +3. Define the decision signal before building the matrix. For a suspected defect, name the exact user-visible symptom, the command or probe that can distinguish it from correct behavior, the expected failing observation, and a known-good control. Confirm that the signal exercises the real producer and caller path rather than only an adjacent helper. Prefer a fast, deterministic local loop when one can answer the question. If no credible signal can be built, state the missing access or artifact and do not substitute a nearby behavior as proof. +4. Create a validation matrix before executing probes. Cover both baseline behavior and the most relevant failure or drift cases. The matrix can live in a scratch note, a temporary file, or a structured header inside the probe script. +5. For each case, choose an execution mode up front: - `single-shot` for deterministic one-run checks. - `repeat-N` for cache, retry, streaming, interruption, rate-limit, concurrency, or other run-to-run-sensitive behavior. - `warm-up + repeat-N` when first-run cold-start effects could distort the result. @@ -50,23 +51,23 @@ Use this skill to investigate real runtime behavior, not to restate code or docu - Decision-grade latency or release recommendation: `warm-up + repeat-10`. - Costly live cases: start at `repeat-3`, then expand only if the answer remains unclear. If it is genuinely unclear whether extra runs are worth the time or cost, ask the user before expanding the probe. -5. When the question is benchmark-like or comparative, run in phases. Start with a high-signal pilot matrix against a control, then expand only the surviving candidates or unresolved cases. -6. If the question is about a suspected regression or behavior change, add at least one known-good control case such as `origin/main`, the latest release, or the same request without the suspected option. -7. For comparative probes, define parity before execution. Record prompt or input shape, tool-choice setup, model-settings parity, state reuse rules, and any response-shape constraint that keeps the comparison fair. If materially different output length could bias the result, record usage or token notes too. -8. If the question asks whether one option has the same intelligence or quality as another, decide whether the matrix supports only example-pattern parity or a broader quality claim. For broader claims, add at least one harder or more open-ended case. Otherwise say explicitly that the result is limited to the covered patterns. -9. Plan state controls before execution when hidden state could affect the result. Record whether each case uses fresh or reused state, how cache reuse or cache busting is handled, what unique IDs isolate repeated runs, and how cleanup is verified. -10. If any live case will read environment variables, list the exact variable names and purpose for each case, then ask the user for approval before execution. Prefer `request_user_input` for this gate when it is available, with no auto-resolution and choices that grant or deny only this specific probe. Keep the approval ask short and include destination, read-only versus mutating or costly risk, exact variable names, and cleanup or rollback if relevant. -11. Build task-specific probe scripts in a temporary location. Keep the script small, observable, and easy to discard. -12. In `openai-agents-python`, make the runtime context explicit: +6. When the question is benchmark-like or comparative, run in phases. Start with a high-signal pilot matrix against a control, then expand only the surviving candidates or unresolved cases. +7. If the question is about a suspected regression or behavior change, add at least one known-good control case such as `origin/main`, the latest release, or the same request without the suspected option. +8. For comparative probes, define parity before execution. Record prompt or input shape, tool-choice setup, model-settings parity, state reuse rules, and any response-shape constraint that keeps the comparison fair. If materially different output length could bias the result, record usage or token notes too. +9. If the question asks whether one option has the same intelligence or quality as another, decide whether the matrix supports only example-pattern parity or a broader quality claim. For broader claims, add at least one harder or more open-ended case. Otherwise say explicitly that the result is limited to the covered patterns. +10. Plan state controls before execution when hidden state could affect the result. Record whether each case uses fresh or reused state, how cache reuse or cache busting is handled, what unique IDs isolate repeated runs, and how cleanup is verified. +11. If any live case will read environment variables, list the exact variable names and purpose for each case, then ask the user for approval before execution. Prefer `request_user_input` for this gate when it is available, with no auto-resolution and choices that grant or deny only this specific probe. Keep the approval ask short and include destination, read-only versus mutating or costly risk, exact variable names, and cleanup or rollback if relevant. +12. Build task-specific probe scripts in a temporary location. Keep the script small, observable, and easy to discard. +13. In `openai-agents-python`, make the runtime context explicit: - Run Python probes from the repository root with `uv run python` when practical. - Record the current commit, working directory, Python executable, and Python version. - Avoid accidental imports from a different checkout or site-packages location. If you must deviate from `uv run python`, say exactly why and what interpreter or environment was used instead. -13. Present the complete probe proposal with the disclosures required above, including the exact command for each case or approved matrix, then ask the user for explicit approval and wait. -14. Execute only the approved matrix and capture evidence. Record request shape, setup, observation summary, unexpected or negative result, error details, timing, runtime context, approved environment-variable names, repeat counts, warm-up handling, variance when relevant, cleanup behavior, and for comparisons note what was held constant plus any response-shape or usage notes that affect interpretation. -15. Update the matrix with actual outcomes, not guesses. -16. Keep temporary artifacts until the final response is drafted. Then delete them unless the user asked to keep them or they are needed for follow-up. Benchmark and repeat-heavy probes often need follow-up, so keeping artifacts is normal when the result may be revisited. If deleted, retain and report a short run summary. -17. Report findings first, with unexpected or negative findings first. Then summarize how the validation was performed and which cases were covered. -18. If the probe isolates one clear defect, you may include a short implementation hypothesis or minimal repro direction. Do not expand into a larger next-step plan unless the user asked for it. +14. Present the complete probe proposal with the disclosures required above, including the exact command for each case or approved matrix, then ask the user for explicit approval and wait. +15. Execute only the approved matrix and capture evidence. Record request shape, setup, observation summary, unexpected or negative result, error details, timing, runtime context, approved environment-variable names, repeat counts, warm-up handling, variance when relevant, cleanup behavior, and for comparisons note what was held constant plus any response-shape or usage notes that affect interpretation. +16. Update the matrix with actual outcomes, not guesses. +17. Keep temporary artifacts until the final response is drafted. Then delete them unless the user asked to keep them or they are needed for follow-up. Benchmark and repeat-heavy probes often need follow-up, so keeping artifacts is normal when the result may be revisited. If deleted, retain and report a short run summary. +18. Report findings first, with unexpected or negative findings first. Then summarize how the validation was performed and which cases were covered. +19. If the probe isolates one clear defect, you may include a short implementation hypothesis or minimal repro direction. Do not expand into a larger next-step plan unless the user asked for it. ## Validation Matrix diff --git a/.agents/skills/runtime-behavior-probe/agents/openai.yaml b/.agents/skills/runtime-behavior-probe/agents/openai.yaml index aead5dc46c..0d9c58cbaf 100644 --- a/.agents/skills/runtime-behavior-probe/agents/openai.yaml +++ b/.agents/skills/runtime-behavior-probe/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Runtime Behavior Probe" short_description: "Plan and run runtime behavior probes" - default_prompt: "Use $runtime-behavior-probe to plan an investigation of actual runtime behavior with a validation matrix and explicit state controls. Before executing every probe, disclose the source identity, exact command, transitively executed material, available filesystem, environment, network, and host-service capabilities, side effects, and control, then wait for explicit user approval of that exact probe or matrix. Invoking this skill is not execution approval. After approval, run only the approved scope and produce a findings-first report." + default_prompt: "Use $runtime-behavior-probe to plan this runtime investigation; invocation authorizes planning only, so disclose the exact probe and capabilities and obtain explicit approval before execution, then report only observed evidence from the approved scope." policy: allow_implicit_invocation: false diff --git a/.agents/skills/test-coverage-improver/SKILL.md b/.agents/skills/test-coverage-improver/SKILL.md index 2dff569bd5..634c85de2a 100644 --- a/.agents/skills/test-coverage-improver/SKILL.md +++ b/.agents/skills/test-coverage-improver/SKILL.md @@ -39,4 +39,4 @@ Use this skill whenever coverage needs assessment or improvement (coverage regre - Keep any added comments or code in English. - Do not create `scripts/`, `references/`, or `assets/` unless needed later. -- If coverage artifacts are missing or stale, rerun `pnpm test:coverage` instead of guessing. +- If coverage artifacts are missing or stale, rerun `make coverage` instead of guessing. From 4c2810c11c38e67a5c5215a5a624c2f7da3e50f7 Mon Sep 17 00:00:00 2001 From: Sean <118865326+seanxuu@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:53:52 +0800 Subject: [PATCH 388/473] feat(sandbox): allow labels on Docker sandbox containers (#4564) --- src/agents/run_state.py | 3 +- src/agents/sandbox/sandboxes/docker.py | 32 ++- tests/fixtures/run_state/README.md | 4 +- .../features/v1_17_docker_labels.json | 111 +++++++++ tests/fixtures/run_state/generate_corpus.py | 47 ++++ tests/fixtures/run_state/minimal/v1_17.json | 60 +++++ tests/fixtures/run_state/sources.json | 16 ++ tests/sandbox/test_client_options.py | 14 ++ tests/sandbox/test_compatibility_guards.py | 3 +- tests/sandbox/test_docker.py | 232 +++++++++++++++++- tests/test_run_state.py | 3 +- 11 files changed, 518 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/run_state/features/v1_17_docker_labels.json create mode 100644 tests/fixtures/run_state/minimal/v1_17.json diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 74e5bcbf04..b3a0af49b4 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -179,7 +179,7 @@ def _default_run_state_validation_error( # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported # versions). -CURRENT_SCHEMA_VERSION = "1.16" +CURRENT_SCHEMA_VERSION = "1.17" _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13" _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. @@ -213,6 +213,7 @@ def _default_run_state_validation_error( "Persists Docker network-isolation state and lets an exact call approval decision " "override a sticky decision for the same tool." ), + "1.17": "Persists Docker container labels across sandbox resume and replacement.", } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 70ce1a96da..8ca4febe85 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -25,7 +25,7 @@ from docker.models.containers import Container # type: ignore[import-untyped] from docker.types import DriverConfig, Mount as DockerSDKMount # type: ignore[import-untyped] from docker.utils import parse_repository_tag -from pydantic import model_validator +from pydantic import Field, model_validator from typing_extensions import Self from .._mount_security import ( @@ -185,6 +185,7 @@ class DockerSandboxSessionState(SandboxSessionState): image: str container_id: str network_mode: Literal["none"] | None = None + labels: dict[str, str] = Field(default_factory=dict) @model_validator(mode="after") def _validate_network_configuration(self) -> Self: @@ -214,6 +215,7 @@ class DockerSandboxClientOptions(BaseSandboxClientOptions): image: str exposed_ports: tuple[int, ...] = () network_mode: Literal["none"] | None = None + labels: dict[str, str] = Field(default_factory=dict) @model_validator(mode="after") def _validate_network_configuration(self) -> Self: @@ -230,12 +232,14 @@ def __init__( *, type: Literal["docker"] = "docker", network_mode: Literal["none"] | None = None, + labels: dict[str, str] | None = None, ) -> None: super().__init__( type=type, image=image, exposed_ports=exposed_ports, network_mode=network_mode, + labels={} if labels is None else labels, ) @@ -1535,6 +1539,7 @@ async def create( exposed_ports=options.exposed_ports, network_mode=options.network_mode, session_id=session_id, + labels=options.labels, ) container.start() container_id = container.id @@ -1549,6 +1554,7 @@ async def create( container_id=container_id, exposed_ports=options.exposed_ports, network_mode=options.network_mode, + labels=options.labels, ) inner = DockerSandboxSession( docker_client=self.docker_client, @@ -1653,6 +1659,7 @@ async def resume( container, state.network_mode, ) + _assert_existing_container_labels_match(container, state.labels) owns_replacement = container is None replacement_session_id = ( uuid.uuid4() @@ -1681,6 +1688,7 @@ async def resume( exposed_ports=state.exposed_ports, network_mode=state.network_mode, session_id=replacement_session_id, + labels=state.labels, ) container_id = container.id assert container_id is not None @@ -1715,6 +1723,7 @@ async def _create_container( exposed_ports: tuple[int, ...] = (), network_mode: Literal["none"] | None = None, session_id: uuid.UUID | None = None, + labels: dict[str, str] | None = None, ) -> Container: if manifest is not None: _validate_docker_path_grants(manifest) @@ -1736,6 +1745,8 @@ async def _create_container( } if network_mode is not None: create_kwargs["network_mode"] = network_mode + if labels: + create_kwargs["labels"] = labels if manifest is not None: docker_mounts = _build_docker_volume_mounts(manifest, session_id=session_id) if docker_mounts: @@ -1895,6 +1906,25 @@ def _assert_existing_container_network_configuration_matches( ) +def _assert_existing_container_labels_match( + container: Container, + labels: dict[str, str], +) -> None: + if not labels: + return + + container.reload() + attrs = getattr(container, "attrs", {}) or {} + config = attrs.get("Config") + actual_labels = config.get("Labels") if isinstance(config, dict) else None + actual_labels = actual_labels if isinstance(actual_labels, dict) else {} + if any(actual_labels.get(key) != value for key, value in labels.items()): + raise ValueError( + "Existing Docker sandbox labels do not match persisted labels; " + "create a fresh sandbox session" + ) + + def _assert_existing_container_path_grants_match( container: Container, manifest: Manifest, diff --git a/tests/fixtures/run_state/README.md b/tests/fixtures/run_state/README.md index 485f1f62ac..82836b310d 100644 --- a/tests/fixtures/run_state/README.md +++ b/tests/fixtures/run_state/README.md @@ -1,6 +1,6 @@ # RunState compatibility corpus -The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.16. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture. +The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.17. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture. Regenerate the feature corpus from the recorded historical source trees with: @@ -10,6 +10,6 @@ UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/ The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout. -Versions 1.7, 1.8, and 1.16 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. +Versions 1.7, 1.8, 1.16, and 1.17 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. The 1.17 labels transition uses the labels-capable 1.16 writer and changes only the schema label. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison. diff --git a/tests/fixtures/run_state/features/v1_17_docker_labels.json b/tests/fixtures/run_state/features/v1_17_docker_labels.json new file mode 100644 index 0000000000..6638df0757 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_17_docker_labels.json @@ -0,0 +1,111 @@ +{ + "$schemaVersion": "1.17", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": {}, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "pending_input": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "sandbox": { + "backend_id": "docker", + "current_agent_name": "compat-agent", + "session_state": { + "container_id": "container", + "exposed_ports": [], + "image": "python:3.14-slim", + "labels": { + "com.example.owner": "worker-123" + }, + "manifest": { + "entries": {}, + "environment": { + "value": {} + }, + "extra_path_grants": [], + "groups": [], + "remote_mount_command_allowlist": [ + "ls", + "find", + "stat", + "cat", + "less", + "head", + "tail", + "du", + "grep", + "rg", + "wc", + "sort", + "cut", + "cp", + "tee", + "echo", + "mkdir", + "rm" + ], + "root": "/workspace", + "users": [], + "version": 1 + }, + "network_mode": null, + "session_id": "00000000-0000-0000-0000-000000000117", + "snapshot": { + "id": "snapshot", + "type": "noop" + }, + "type": "docker", + "workspace_root_ready": false + } + }, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/generate_corpus.py b/tests/fixtures/run_state/generate_corpus.py index f287a6e323..ef7a33ee2e 100644 --- a/tests/fixtures/run_state/generate_corpus.py +++ b/tests/fixtures/run_state/generate_corpus.py @@ -426,6 +426,40 @@ def approval(call_id): "changed to exercise the canonical compatibility branch." ), ), + Scenario( + "1.17", + "2baa1b1bcc4cebc64e197debd4c59e4bee1093be", + "docker_labels", + """ +from agents.sandbox import Manifest +from agents.sandbox.snapshot import NoopSnapshot + +session_state = { + "type": "docker", + "session_id": "00000000-0000-0000-0000-000000000117", + "snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"), + "manifest": Manifest().model_dump(mode="json"), + "exposed_ports": [], + "workspace_root_ready": False, + "image": "python:3.14-slim", + "container_id": "container", + "network_mode": None, + "labels": {"com.example.owner": "worker-123"}, +} +state._sandbox = { + "backend_id": "docker", + "current_agent_name": agent.name, + "session_state": session_state, +} +""", + provenance="canonical_compatibility", + emitted_version="1.16", + note=( + "The labels implementation was first emitted with the unreleased 1.16 writer. " + "The fixture changes only the schema label to exercise the 1.17 compatibility " + "reader while preserving the Docker session payload." + ), + ), ) @@ -443,6 +477,19 @@ def approval(call_id): "changed to exercise the canonical compatibility branch." ), ), + Scenario( + "1.17", + "2baa1b1bcc4cebc64e197debd4c59e4bee1093be", + "minimal", + "", + provenance="canonical_compatibility", + emitted_version="1.16", + note=( + "The labels implementation was first emitted with the unreleased 1.16 writer. " + "The fixture changes only the schema label to exercise the 1.17 compatibility " + "reader while preserving older payload compatibility." + ), + ), ) diff --git a/tests/fixtures/run_state/minimal/v1_17.json b/tests/fixtures/run_state/minimal/v1_17.json new file mode 100644 index 0000000000..fe4a0f00bc --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_17.json @@ -0,0 +1,60 @@ +{ + "$schemaVersion": "1.17", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": {}, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "pending_input": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/sources.json b/tests/fixtures/run_state/sources.json index 21d3d30bc1..fc7be3fb55 100644 --- a/tests/fixtures/run_state/sources.json +++ b/tests/fixtures/run_state/sources.json @@ -118,6 +118,15 @@ "note": "The schema transition introduced this reader version without a retained writer commit that emitted it. The recorded writer emitted 1.15; only the schema label is changed to exercise the canonical compatibility branch.", "provenance": "canonical_compatibility", "version": "1.16" + }, + { + "commit": "2baa1b1bcc4cebc64e197debd4c59e4bee1093be", + "emitted_version": "1.16", + "feature": "docker_labels", + "fixture": "features/v1_17_docker_labels.json", + "note": "The labels implementation was first emitted with the unreleased 1.16 writer. The fixture changes only the schema label to exercise the 1.17 compatibility reader while preserving the Docker session payload.", + "provenance": "canonical_compatibility", + "version": "1.17" } ], "resume": { @@ -179,6 +188,13 @@ "note": "The schema transition introduced this reader version without a retained writer commit that emitted it. The recorded writer emitted 1.15; only the schema label is changed to exercise the canonical compatibility branch.", "provenance": "canonical_compatibility" }, + "1.17": { + "commit": "2baa1b1bcc4cebc64e197debd4c59e4bee1093be", + "emitted_version": "1.16", + "fixture": "minimal/v1_17.json", + "note": "The labels implementation was first emitted with the unreleased 1.16 writer. The fixture changes only the schema label to exercise the 1.17 compatibility reader while preserving older payload compatibility.", + "provenance": "canonical_compatibility" + }, "1.2": { "commit": "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c", "fixture": "minimal/v1_2.json" diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index 5659541767..fff08732f3 100644 --- a/tests/sandbox/test_client_options.py +++ b/tests/sandbox/test_client_options.py @@ -27,6 +27,20 @@ def test_sandbox_client_options_parse_uses_registered_builtin_type() -> None: ) +def test_docker_client_options_roundtrip_preserves_labels() -> None: + options = DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + labels={"com.example.owner": "worker-123"}, + ) + + payload = options.model_dump(mode="json") + restored = BaseSandboxClientOptions.parse(payload) + + assert restored == options + assert isinstance(restored, DockerSandboxClientOptions) + assert restored.labels == {"com.example.owner": "worker-123"} + + def test_sandbox_client_options_parse_passthrough_existing_instance() -> None: options = UnixLocalSandboxClientOptions(exposed_ports=(8080,)) diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index a358f76ea7..ee4a9f13c7 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -416,7 +416,7 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( ( "agents.sandbox.sandboxes.docker", "DockerSandboxClientOptions", - ("image", "exposed_ports", "network_mode"), + ("image", "exposed_ports", "network_mode", "labels"), ), ( "agents.extensions.sandbox.e2b", @@ -576,6 +576,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "image", "container_id", "network_mode", + "labels", ), ), ( diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index 5c535e57bf..e4c7cc812f 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -20,6 +20,9 @@ from pydantic import Field, PrivateAttr import agents.sandbox.sandboxes.docker as docker_sandbox +from agents import Agent +from agents.run_context import RunContextWrapper +from agents.run_state import CURRENT_SCHEMA_VERSION, RunState from agents.sandbox import SandboxPathGrant from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE @@ -1830,6 +1833,142 @@ async def test_docker_create_container_publishes_exposed_ports( ] +@pytest.mark.asyncio +async def test_docker_create_container_applies_labels( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + labels = {"com.example.owner": "worker-123"} + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + created = await client._create_container( + DEFAULT_PYTHON_SANDBOX_IMAGE, + labels=labels, + ) + + assert created is container + assert docker_client.containers.calls[0]["labels"] == labels + + +@pytest.mark.asyncio +async def test_docker_create_container_omits_empty_labels( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, labels={}) + + assert "labels" not in docker_client.containers.calls[0] + + +def test_docker_session_state_roundtrip_preserves_labels() -> None: + client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient())) + labels = {"com.example.owner": "worker-123"} + state = DockerSandboxSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + labels=labels, + ) + + restored = client.deserialize_session_state(client.serialize_session_state(state)) + + assert isinstance(restored, DockerSandboxSessionState) + assert restored.labels == labels + + +def test_docker_session_state_without_labels_preserves_old_payloads() -> None: + client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient())) + state = DockerSandboxSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ) + payload = client.serialize_session_state(state) + payload.pop("labels", None) + + restored = client.deserialize_session_state(payload) + + assert isinstance(restored, DockerSandboxSessionState) + assert restored.labels == {} + + +@pytest.mark.asyncio +async def test_docker_labels_roundtrip_through_run_state() -> None: + agent = Agent(name="sandbox") + labels = {"com.example.owner": "worker-123"} + run_state = RunState( + context=RunContextWrapper(context={}), + original_input="resume sandbox", + starting_agent=agent, + ) + run_state._sandbox = { + "backend_id": "docker", + "current_agent_name": agent.name, + "session_state": DockerSandboxSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + labels=labels, + ).model_dump(mode="json"), + } + + serialized = run_state.to_json() + restored = await RunState.from_json(agent, serialized) + + assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION == "1.17" + assert restored._sandbox is not None + restored_session_state = restored._sandbox["session_state"] + assert isinstance(restored_session_state, dict) + assert restored_session_state["labels"] == labels + + +@pytest.mark.asyncio +async def test_docker_create_persists_configured_labels( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _StartedContainer() + client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient())) + labels = {"com.example.owner": "worker-123"} + forwarded_labels: list[dict[str, str] | None] = [] + + async def _fake_create_container( + image: str, + *, + manifest: Manifest | None = None, + exposed_ports: tuple[int, ...] = (), + network_mode: str | None = None, + session_id: uuid.UUID | None = None, + labels: dict[str, str] | None = None, + ) -> _StartedContainer: + _ = (image, manifest, exposed_ports, network_mode, session_id) + forwarded_labels.append(labels) + return container + + monkeypatch.setattr(client, "_create_container", _fake_create_container) + + session = await client.create( + options=DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + labels=labels, + ) + ) + + assert isinstance(session._inner, DockerSandboxSession) + assert session._inner.state.labels == labels + assert forwarded_labels == [labels] + + @pytest.mark.asyncio async def test_docker_create_container_mounts_explicit_host_path( tmp_path: Path, @@ -2811,8 +2950,9 @@ async def create_container( exposed_ports: tuple[int, ...] = (), network_mode: str | None = None, session_id: uuid.UUID | None = None, + labels: dict[str, str] | None = None, ) -> _StartedContainer: - _ = (image, exposed_ports) + _ = (image, exposed_ports, labels) assert network_mode is None assert session_id == replacement_session_id assert stale_volume.remove_calls == 0 @@ -3332,6 +3472,7 @@ def __init__( workspace_exists: bool = False, published_ports: dict[str, list[dict[str, str]] | None] | None = None, mounts: list[dict[str, object]] | None = None, + labels: dict[str, str] | None = None, ) -> None: self.status = status self.id = container_id @@ -3340,6 +3481,7 @@ def __init__( self.attrs = { "NetworkSettings": {"Ports": published_ports or {}}, "Mounts": mounts or [], + "Config": {"Labels": labels or {}}, } def reload(self) -> None: @@ -4150,8 +4292,10 @@ async def _fake_create_container( exposed_ports: tuple[int, ...] = (), network_mode: str | None = None, session_id: uuid.UUID | None = None, + labels: dict[str, str] | None = None, ) -> object: _ = session_id + _ = labels create_calls.append((image, manifest, exposed_ports, network_mode)) return replacement @@ -4177,6 +4321,92 @@ async def _fake_create_container( assert create_calls == [(DEFAULT_PYTHON_SANDBOX_IMAGE, inner.state.manifest, (8765,), None)] +@pytest.mark.asyncio +async def test_docker_resume_forwards_persisted_labels_when_recreating_container( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = DockerSandboxClient( + docker_client=cast(object, _ResumeDockerClient(docker.errors.NotFound("missing"))) + ) + replacement = _ResumeContainer(status="created", container_id="replacement") + labels = {"com.example.owner": "worker-123"} + forwarded_labels: list[dict[str, str] | None] = [] + + async def _fake_create_container( + image: str, + *, + manifest: Manifest | None = None, + exposed_ports: tuple[int, ...] = (), + network_mode: str | None = None, + session_id: uuid.UUID | None = None, + labels: dict[str, str] | None = None, + ) -> _ResumeContainer: + _ = (image, manifest, exposed_ports, network_mode, session_id) + forwarded_labels.append(labels) + return replacement + + monkeypatch.setattr(client, "_create_container", _fake_create_container) + + resumed = await client.resume( + DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="missing", + labels=labels, + ) + ) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert forwarded_labels == [labels] + + +@pytest.mark.asyncio +async def test_docker_resume_reuses_container_with_matching_labels() -> None: + labels = {"com.example.owner": "worker-123"} + container = _ResumeContainer( + status="running", + labels={**labels, "com.example.extra": "preserved"}, + ) + client = DockerSandboxClient(docker_client=_ResumeDockerClient(container)) + state = DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id=container.id, + labels=labels, + ) + + resumed = await client.resume(state) + + assert isinstance(resumed._inner, DockerSandboxSession) + assert resumed._inner._container is container + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "actual_labels", + [{}, {"com.example.owner": "different"}], + ids=["missing", "mismatched"], +) +async def test_docker_resume_rejects_mismatched_existing_labels( + actual_labels: dict[str, str], +) -> None: + expected_labels = {"com.example.owner": "worker-123"} + container = _ResumeContainer(status="running", labels=actual_labels) + client = DockerSandboxClient(docker_client=_ResumeDockerClient(container)) + state = DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id=container.id, + labels=expected_labels, + ) + + with pytest.raises(ValueError, match="labels"): + await client.resume(state) + + @pytest.mark.asyncio async def test_docker_resume_recovers_workspace_workdir_for_direct_state( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_run_state.py b/tests/test_run_state.py index f65999c3e9..309171584f 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3001,7 +3001,7 @@ def approval(call_id: str) -> ToolApprovalItem: state.approve(approval("exception")) serialized = state.to_json() - assert serialized["$schemaVersion"] == "1.16" + assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION restored = await RunState.from_json(agent, serialized) assert restored._context is not None @@ -9136,6 +9136,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.13", "1.14", "1.15", + "1.16", CURRENT_SCHEMA_VERSION, } ) From 119ad2a4924420a501d4ee51d75ed7f04d54f259 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 22 Aug 2026 08:31:11 +0900 Subject: [PATCH 389/473] fix(voice): forward streamed STT language and prompt (#4574) Co-authored-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> --- src/agents/voice/models/openai_stt.py | 11 +++- tests/voice/test_openai_stt_session_config.py | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/voice/test_openai_stt_session_config.py diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index cf504d8892..a1d95746ea 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -175,6 +175,15 @@ async def _event_listener(self) -> None: async def _configure_session(self) -> None: assert self._websocket is not None, "Websocket not initialized" + transcription_config: dict[str, Any] = {"model": self._model} + if self._settings.language is not None: + if self._model in {"gpt-transcribe", "gpt-live-transcribe"}: + transcription_config["languages"] = [self._settings.language] + else: + transcription_config["language"] = self._settings.language + if self._settings.prompt is not None: + transcription_config["prompt"] = self._settings.prompt + await self._websocket.send( json.dumps( { @@ -184,7 +193,7 @@ async def _configure_session(self) -> None: "audio": { "input": { "format": {"type": "audio/pcm", "rate": 24000}, - "transcription": {"model": self._model}, + "transcription": transcription_config, "turn_detection": self._turn_detection, } }, diff --git a/tests/voice/test_openai_stt_session_config.py b/tests/voice/test_openai_stt_session_config.py new file mode 100644 index 0000000000..65388f6c7c --- /dev/null +++ b/tests/voice/test_openai_stt_session_config.py @@ -0,0 +1,61 @@ +import json +from unittest.mock import AsyncMock + +import pytest + +from agents.voice import StreamedAudioInput, STTModelSettings +from agents.voice.models.openai_stt import OpenAISTTTranscriptionSession + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("model", "language_field", "language_value"), + [ + ("gpt-4o-transcribe", "language", "fr"), + ("gpt-transcribe", "languages", ["fr"]), + ("gpt-live-transcribe", "languages", ["fr"]), + ], +) +async def test_streaming_stt_sends_language_and_prompt( + model: str, + language_field: str, + language_value: str | list[str], +) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model=model, + settings=STTModelSettings(language="fr", prompt="domain vocabulary"), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + websocket = AsyncMock() + session._websocket = websocket + + await session._configure_session() + + payload = json.loads(websocket.send.await_args.args[0]) + assert payload["session"]["audio"]["input"]["transcription"] == { + "model": model, + language_field: language_value, + "prompt": "domain vocabulary", + } + + +@pytest.mark.asyncio +async def test_streaming_stt_omits_unset_language_and_prompt() -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="gpt-4o-transcribe", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + websocket = AsyncMock() + session._websocket = websocket + + await session._configure_session() + + payload = json.loads(websocket.send.await_args.args[0]) + assert payload["session"]["audio"]["input"]["transcription"] == {"model": "gpt-4o-transcribe"} From d22234480f37642fa52f527113b5ce4d87170476 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 22 Aug 2026 08:32:57 +0900 Subject: [PATCH 390/473] fix(voice): honor client config for streamed STT (#4575) Co-authored-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> --- src/agents/models/_openai_websocket.py | 137 +++++++++++++++++ src/agents/models/openai_responses.py | 90 +++-------- src/agents/voice/models/openai_stt.py | 33 +++- tests/models/test_openai_responses.py | 2 + tests/voice/test_openai_stt.py | 46 ++++-- .../voice/test_openai_stt_api_key_refresh.py | 71 +++++++++ tests/voice/test_openai_stt_client_config.py | 143 ++++++++++++++++++ 7 files changed, 430 insertions(+), 92 deletions(-) create mode 100644 src/agents/models/_openai_websocket.py create mode 100644 tests/voice/test_openai_stt_api_key_refresh.py create mode 100644 tests/voice/test_openai_stt_client_config.py diff --git a/src/agents/models/_openai_websocket.py b/src/agents/models/_openai_websocket.py new file mode 100644 index 0000000000..7568e2b5c0 --- /dev/null +++ b/src/agents/models/_openai_websocket.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +import httpx2 +from openai import AsyncOpenAI, NotGiven, Omit + +from .._httpx_compat import is_legacy_httpx_instance +from ..exceptions import UserError + + +class _OpenAIWebSocketLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] + """Prevent the WebSocket dependency from logging sensitive connection data.""" + + def isEnabledFor(self, level: int) -> bool: + if level <= logging.DEBUG: + return False + return super().isEnabledFor(level) + + +_OPENAI_WEBSOCKET_LOGGER = _OpenAIWebSocketLoggerAdapter( + logging.getLogger("websockets.client"), + {}, +) + + +def get_openai_websocket_logger() -> logging.LoggerAdapter[logging.Logger]: + """Return the logger used for OpenAI WebSocket connections.""" + return _OPENAI_WEBSOCKET_LOGGER + + +def _is_openai_omitted_value(value: Any) -> bool: + return isinstance(value, Omit | NotGiven) + + +async def refresh_openai_client_api_key_if_supported(client: Any) -> None: + """Refresh dynamic OpenAI client credentials before materializing handshake headers.""" + refresh_api_key = getattr(client, "_refresh_api_key", None) + if callable(refresh_api_key): + await refresh_api_key() + + +def _remove_header(headers: dict[str, str], key: object) -> None: + header_key = str(key) + for existing_key in list(headers): + if existing_key.lower() == header_key.lower(): + del headers[existing_key] + + +def _set_header(headers: dict[str, str], key: object, value: object) -> None: + header_key = str(key) + _remove_header(headers, header_key) + headers[header_key] = str(value) + + +def merge_openai_client_websocket_headers( + client: AsyncOpenAI, + *, + extra_headers: Mapping[str, Any] | None = None, +) -> dict[str, str]: + """Materialize OpenAI client auth/default headers for a WebSocket handshake.""" + headers: dict[str, str] = {} + for source in ( + getattr(client, "auth_headers", {}), + getattr(client, "default_headers", {}), + ): + for key, value in source.items(): + if isinstance(value, NotGiven): + continue + if isinstance(value, Omit): + _remove_header(headers, key) + continue + _set_header(headers, key, value) + + for key, value in (extra_headers or {}).items(): + if isinstance(value, NotGiven): + continue + _remove_header(headers, key) + if isinstance(value, Omit): + continue + headers[str(key)] = str(value) + + return headers + + +def _merge_query_values(params: dict[str, Any], values: Mapping[str, Any]) -> None: + for key, value in values.items(): + query_key = str(key) + if isinstance(value, Omit): + params.pop(query_key, None) + continue + if isinstance(value, NotGiven): + continue + params[query_key] = value + + +def prepare_openai_client_websocket_base_url( + client: AsyncOpenAI, + *, + extra_query: Any = None, + context: str, +) -> httpx2.URL: + """Build the client-derived WebSocket base URL and normalized query parameters. + + Endpoint suffixes and transport-specific fixed query parameters are intentionally left to + each caller. + """ + websocket_base_url = getattr(client, "websocket_base_url", None) + if websocket_base_url is not None: + if is_legacy_httpx_instance(websocket_base_url, "URL"): + websocket_base_url = str(websocket_base_url) + base_url = httpx2.URL(websocket_base_url) + else: + client_base_url = client.base_url + if is_legacy_httpx_instance(client_base_url, "URL"): + base_url = httpx2.URL(str(client_base_url)) + else: + base_url = httpx2.URL(client_base_url) + + ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) + base_url = base_url.copy_with(scheme=ws_scheme) + params: dict[str, Any] = dict(base_url.params) + + default_query = getattr(client, "default_query", None) + if default_query is not None and not _is_openai_omitted_value(default_query): + if not isinstance(default_query, Mapping): + raise UserError(f"{context} client default_query must be a mapping.") + _merge_query_values(params, default_query) + + if extra_query is not None and not _is_openai_omitted_value(extra_query): + if not isinstance(extra_query, Mapping): + raise UserError(f"{context} extra_query must be a mapping.") + _merge_query_values(params, extra_query) + + return base_url.copy_with(params=params) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index f6ff8e2b3c..75987c175d 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -91,6 +91,12 @@ from ..util._json import _to_dump_compatible from ..version import __version__ from ._openai_retry import get_openai_retry_advice +from ._openai_websocket import ( + get_openai_websocket_logger, + merge_openai_client_websocket_headers, + prepare_openai_client_websocket_base_url, + refresh_openai_client_api_key_if_supported, +) from ._response_terminal import response_error_event_failure_error, response_terminal_failure_error from ._retry_runtime import ( should_disable_provider_managed_retries, @@ -177,10 +183,8 @@ def _materialize_responses_tool_params( async def _refresh_openai_client_api_key_if_supported(client: Any) -> None: - """Refresh client auth if the current OpenAI SDK exposes a refresh hook.""" - refresh_api_key = getattr(client, "_refresh_api_key", None) - if callable(refresh_api_key): - await refresh_api_key() + """Backward-compatible wrapper around shared WebSocket client credential refresh.""" + await refresh_openai_client_api_key_if_supported(client) def _construct_response_stream_event_from_payload( @@ -1533,76 +1537,19 @@ async def _prepare_websocket_request( return frame, ws_url, handshake_headers def _merge_websocket_headers(self, extra_headers: Mapping[str, Any]) -> dict[str, str]: - headers: dict[str, str] = {} - for source in ( - getattr(self._client, "auth_headers", {}), - self._client.default_headers, - ): - for key, value in source.items(): - if _is_openai_omitted_value(value): - continue - header_key = str(key) - for existing_key in list(headers): - if existing_key.lower() == header_key.lower(): - del headers[existing_key] - headers[header_key] = str(value) - - for key, value in extra_headers.items(): - if isinstance(value, NotGiven): - continue - header_key = str(key) - for existing_key in list(headers): - if existing_key.lower() == header_key.lower(): - del headers[existing_key] - if isinstance(value, Omit): - continue - headers[header_key] = str(value) - - return headers + return merge_openai_client_websocket_headers( + self._client, + extra_headers=extra_headers, + ) def _prepare_websocket_url(self, extra_query: Any) -> str: - if self._client.websocket_base_url is not None: - websocket_base_url = self._client.websocket_base_url - if is_legacy_httpx_instance(websocket_base_url, "URL"): - websocket_base_url = str(websocket_base_url) - base_url = httpx2.URL(websocket_base_url) - ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) - base_url = base_url.copy_with(scheme=ws_scheme) - else: - client_base_url = self._client.base_url - ws_scheme = {"http": "ws", "https": "wss"}.get( - client_base_url.scheme, client_base_url.scheme - ) - base_url = client_base_url.copy_with(scheme=ws_scheme) - - params: dict[str, Any] = dict(base_url.params) - default_query = getattr(self._client, "default_query", None) - if default_query is not None and not _is_openai_omitted_value(default_query): - if not isinstance(default_query, Mapping): - raise UserError("Responses websocket client default_query must be a mapping.") - for key, value in default_query.items(): - query_key = str(key) - if isinstance(value, Omit): - params.pop(query_key, None) - continue - if isinstance(value, NotGiven): - continue - params[query_key] = value - - if extra_query is not None and not _is_openai_omitted_value(extra_query): - if not isinstance(extra_query, Mapping): - raise UserError("Responses websocket extra_query must be a mapping.") - for key, value in extra_query.items(): - query_key = str(key) - if isinstance(value, Omit): - params.pop(query_key, None) - continue - if isinstance(value, NotGiven): - continue - params[query_key] = value - + base_url = prepare_openai_client_websocket_base_url( + self._client, + extra_query=extra_query, + context="Responses websocket", + ) path = base_url.path.rstrip("/") + "/responses" - return str(base_url.copy_with(path=path, params=params)) + return str(base_url.copy_with(path=path)) async def _ensure_websocket_connection( self, @@ -1746,6 +1693,7 @@ async def _open_websocket_connection( connect_kwargs: dict[str, Any] = { "user_agent_header": None, "additional_headers": dict(headers), + "logger": get_openai_websocket_logger(), "max_size": None, "open_timeout": connect_timeout, } diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index a1d95746ea..3aefb5d71b 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -13,6 +13,12 @@ from ... import _debug from ...exceptions import AgentsException, UserError from ...logger import logger +from ...models._openai_websocket import ( + get_openai_websocket_logger, + merge_openai_client_websocket_headers, + prepare_openai_client_websocket_base_url, + refresh_openai_client_api_key_if_supported, +) from ...tracing import Span, SpanError, TranscriptionSpanData, transcription_span from ...util._error_tracing import get_trace_error from ..exceptions import STTWebsocketConnectionError @@ -58,6 +64,24 @@ def _audio_buffer_to_base64(buffer: npt.NDArray[np.int16 | np.float32]) -> str: return base64.b64encode(buffer.tobytes()).decode("utf-8") +def _prepare_websocket_url(client: AsyncOpenAI) -> str: + base_url = prepare_openai_client_websocket_base_url( + client, + context="Streamed STT websocket", + ) + params: dict[str, Any] = dict(base_url.params) + params["intent"] = "transcription" + path = base_url.path.rstrip("/") + "/realtime" + return str(base_url.copy_with(path=path, params=params)) + + +def _prepare_websocket_headers(client: AsyncOpenAI) -> dict[str, str]: + return merge_openai_client_websocket_headers( + client, + extra_headers={"OpenAI-Log-Session": "1"}, + ) + + async def _wait_for_event( event_queue: asyncio.Queue[dict[str, Any] | ErrorSentinel], expected_types: list[str], @@ -312,12 +336,11 @@ async def _stream_audio( async def _process_websocket_connection(self) -> None: try: + await refresh_openai_client_api_key_if_supported(self._client) async with websockets.connect( - "wss://api.openai.com/v1/realtime?intent=transcription", - additional_headers={ - "Authorization": f"Bearer {self._client.api_key}", - "OpenAI-Log-Session": "1", - }, + _prepare_websocket_url(self._client), + additional_headers=_prepare_websocket_headers(self._client), + logger=get_openai_websocket_logger(), ) as ws: await self._setup_connection(ws) self._process_events_task = asyncio.create_task(self._handle_events()) diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 3bfd3bef00..96ddfe4bed 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -2,6 +2,7 @@ import asyncio import json +import logging from types import SimpleNamespace from typing import Any, cast @@ -1986,6 +1987,7 @@ async def fake_connect(ws_url: str, **kwargs: Any) -> DummyWSConnection: assert opened is ws assert captured_kwargs["ws_url"] == "wss://example.test/v1/responses" assert captured_kwargs["additional_headers"] == {"Authorization": "Bearer test-key"} + assert captured_kwargs["logger"].isEnabledFor(logging.DEBUG) is False assert captured_kwargs["open_timeout"] == 10.0 assert captured_kwargs["ping_interval"] == 45.0 assert captured_kwargs["ping_timeout"] is None diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index 50daf0c2ba..11ae7e1423 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -9,9 +9,11 @@ from typing import cast from unittest.mock import AsyncMock, MagicMock, patch +import httpx2 import numpy as np import numpy.typing as npt import pytest +from openai import AsyncOpenAI import agents._debug as _debug from agents import trace @@ -55,6 +57,17 @@ def create_mock_websocket(messages: list[str]) -> AsyncMock: return mock_ws +def create_mock_openai_client(api_key: str = "FAKE_KEY") -> AsyncOpenAI: + client = AsyncMock(api_key=api_key) + client.websocket_base_url = None + client.base_url = httpx2.URL("https://api.openai.com/v1/") + client.default_query = {} + client.auth_headers = {"Authorization": f"Bearer {api_key}"} + client.default_headers = {} + client._refresh_api_key = AsyncMock() + return cast(AsyncOpenAI, client) + + def fake_time(increment: int): current = 1000 while True: @@ -67,7 +80,7 @@ def fake_time(increment: int): async def test_transcribe_turns_propagates_consumer_cancellation(monkeypatch) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -105,7 +118,7 @@ async def hold_connection_open() -> None: async def test_transcribe_turns_closes_owned_tasks_after_yield(monkeypatch) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -165,7 +178,7 @@ async def hold_connection_open() -> None: async def test_close_finishes_span_started_while_websocket_close_is_pending() -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -223,7 +236,7 @@ async def test_transcribe_turns_preserves_consumer_exception_when_cleanup_fails( ) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -270,7 +283,7 @@ async def fail_cleanup() -> None: async def test_transcribe_turns_propagates_cancellation_during_cleanup(monkeypatch) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -307,7 +320,7 @@ async def test_transcribe_turns_preserves_terminal_error_when_close_fails( ) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -372,7 +385,7 @@ async def test_non_json_messages_should_crash(): session = OpenAISTTTranscriptionSession( input=input_audio, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -412,7 +425,7 @@ async def test_session_connects_and_configures_successfully(): session = OpenAISTTTranscriptionSession( input=input_audio, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -430,6 +443,7 @@ async def test_session_connects_and_configures_successfully(): assert "wss://api.openai.com/v1/realtime?intent=transcription" in args[0] headers = kwargs.get("additional_headers", {}) assert headers.get("Authorization") == "Bearer FAKE_KEY" + assert kwargs["logger"].isEnabledFor(logging.DEBUG) is False assert headers.get("OpenAI-Beta") is None assert headers.get("OpenAI-Log-Session") == "1" @@ -472,7 +486,7 @@ async def test_stream_audio_sends_pcm16( session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -548,7 +562,7 @@ async def test_transcription_event_puts_output_in_queue(created, updated, comple session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -594,7 +608,7 @@ def fake_time_func(): session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -643,7 +657,7 @@ async def test_session_error_event(monkeypatch: pytest.MonkeyPatch): session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -679,7 +693,7 @@ async def test_session_error_event_before_session_created(): audio_input = await StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -722,7 +736,7 @@ async def messages_then_timeout() -> AsyncGenerator[str, None]: audio_input = await StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -778,7 +792,7 @@ async def test_inactivity_timeout(): session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -804,7 +818,7 @@ async def test_stream_audio_buffers_turn_audio_only_for_audio_tracing( ) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, diff --git a/tests/voice/test_openai_stt_api_key_refresh.py b/tests/voice/test_openai_stt_api_key_refresh.py new file mode 100644 index 0000000000..2a8695cfe3 --- /dev/null +++ b/tests/voice/test_openai_stt_api_key_refresh.py @@ -0,0 +1,71 @@ +from typing import Any, cast +from unittest.mock import AsyncMock + +import httpx2 +import pytest +from openai import AsyncOpenAI + +from agents.voice import StreamedAudioInput, STTModelSettings +from agents.voice.models import openai_stt +from agents.voice.models.openai_stt import OpenAISTTTranscriptionSession + + +class _RotatingClient: + def __init__(self) -> None: + self.api_key = "" + self.refresh_calls = 0 + self.websocket_base_url = None + self.base_url = httpx2.URL("https://api.openai.com/v1/") + self.default_query: dict[str, str] = {} + self.auth_headers = {"Authorization": "Bearer stale"} + self.default_headers: dict[str, str] = {} + + async def _refresh_api_key(self) -> None: + self.refresh_calls += 1 + self.api_key = "sk-refreshed" + self.auth_headers = {"Authorization": f"Bearer {self.api_key}"} + + +class _WebSocketContext: + async def __aenter__(self) -> Any: + return object() + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +@pytest.mark.asyncio +async def test_streamed_stt_refreshes_callable_api_key_before_handshake(monkeypatch) -> None: + client = _RotatingClient() + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=cast(AsyncOpenAI, client), + model="gpt-4o-mini-transcribe", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + captured_headers: dict[str, str] = {} + + def connect( + _url: str, + *, + additional_headers: dict[str, str], + logger: object, + ) -> _WebSocketContext: + captured_headers.update(additional_headers) + return _WebSocketContext() + + monkeypatch.setattr(openai_stt.websockets, "connect", connect) + monkeypatch.setattr( + session, + "_setup_connection", + AsyncMock(side_effect=RuntimeError("stop after handshake")), + ) + + with pytest.raises(RuntimeError, match="stop after handshake"): + await session._process_websocket_connection() + + assert client.refresh_calls == 1 + assert captured_headers["Authorization"] == "Bearer sk-refreshed" diff --git a/tests/voice/test_openai_stt_client_config.py b/tests/voice/test_openai_stt_client_config.py new file mode 100644 index 0000000000..acc240bafc --- /dev/null +++ b/tests/voice/test_openai_stt_client_config.py @@ -0,0 +1,143 @@ +import logging +from typing import cast +from unittest.mock import MagicMock + +import httpx2 +from openai import NOT_GIVEN, AsyncOpenAI, omit + +from agents.models._openai_websocket import get_openai_websocket_logger +from agents.voice.models.openai_stt import ( + _prepare_websocket_headers, + _prepare_websocket_url, +) + + +def _mock_client(**attributes: object) -> AsyncOpenAI: + attributes.setdefault("default_query", {}) + return cast(AsyncOpenAI, MagicMock(**attributes)) + + +def test_openai_websocket_logger_does_not_emit_debug_connection_data(caplog) -> None: + logger = get_openai_websocket_logger() + + with caplog.at_level(logging.DEBUG, logger="websockets.client"): + logger.debug("> GET %s HTTP/1.1", "/v1/realtime?proxy_token=query-secret") + logger.debug("> %s: %s", "X-Proxy-Token", "header-secret") + logger.debug("> TEXT %r", "audio-or-model-data") + + assert "query-secret" not in caplog.text + assert "header-secret" not in caplog.text + assert "audio-or-model-data" not in caplog.text + + +def test_streaming_stt_websocket_url_uses_client_base_url() -> None: + client = _mock_client( + websocket_base_url=None, + base_url=httpx2.URL("https://voice-proxy.example.test/v1/"), + ) + + url = httpx2.URL(_prepare_websocket_url(client)) + + assert url.scheme == "wss" + assert url.host == "voice-proxy.example.test" + assert url.path == "/v1/realtime" + assert url.params["intent"] == "transcription" + + +def test_streaming_stt_websocket_url_prefers_websocket_base_url() -> None: + client = _mock_client( + websocket_base_url="https://voice-ws.example.test/custom/?tenant=one", + base_url=httpx2.URL("https://ignored.example.test/v1/"), + ) + + url = httpx2.URL(_prepare_websocket_url(client)) + + assert url.scheme == "wss" + assert url.host == "voice-ws.example.test" + assert url.path == "/custom/realtime" + assert url.params["tenant"] == "one" + assert url.params["intent"] == "transcription" + + +def test_streaming_stt_websocket_url_merges_client_default_query() -> None: + client = _mock_client( + websocket_base_url="wss://voice-ws.example.test/custom/?tenant=one&remove=base", + base_url=httpx2.URL("https://ignored.example.test/v1/"), + default_query={ + "api-version": "2026-08-01-preview", + "remove": omit, + "skip": NOT_GIVEN, + }, + ) + + url = httpx2.URL(_prepare_websocket_url(client)) + + assert url.params["tenant"] == "one" + assert url.params["api-version"] == "2026-08-01-preview" + assert url.params["intent"] == "transcription" + assert "remove" not in url.params + assert "skip" not in url.params + + +def test_streaming_stt_websocket_headers_use_client_configuration() -> None: + client = _mock_client( + auth_headers={"Authorization": "Bearer sk-client"}, + default_headers={ + "OpenAI-Organization": "org-client", + "OpenAI-Project": "proj-client", + "X-Proxy-Token": "proxy-token", + }, + ) + + headers = _prepare_websocket_headers(client) + + assert headers["Authorization"] == "Bearer sk-client" + assert headers["OpenAI-Organization"] == "org-client" + assert headers["OpenAI-Project"] == "proj-client" + assert headers["X-Proxy-Token"] == "proxy-token" + assert headers["OpenAI-Log-Session"] == "1" + + +def test_streaming_stt_websocket_headers_skip_openai_omission_sentinels() -> None: + client = _mock_client( + auth_headers={"Authorization": "Bearer sk-client"}, + default_headers={ + "OpenAI-Organization": omit, + "OpenAI-Project": NOT_GIVEN, + "X-Proxy-Token": "proxy-token", + }, + ) + + headers = _prepare_websocket_headers(client) + + assert headers["Authorization"] == "Bearer sk-client" + assert headers["X-Proxy-Token"] == "proxy-token" + assert "OpenAI-Organization" not in headers + assert "OpenAI-Project" not in headers + assert headers["OpenAI-Log-Session"] == "1" + + +def test_streaming_stt_websocket_headers_omit_removes_inherited_header() -> None: + client = _mock_client( + auth_headers={"Authorization": "Bearer sk-client"}, + default_headers={"authorization": omit}, + ) + + headers = _prepare_websocket_headers(client) + + assert all(key.lower() != "authorization" for key in headers) + assert headers["OpenAI-Log-Session"] == "1" + + +def test_streaming_stt_websocket_fixed_session_header_replaces_client_casing() -> None: + client = _mock_client( + auth_headers={}, + default_headers={"openai-log-session": "0"}, + ) + + headers = _prepare_websocket_headers(client) + + session_headers = { + key: value for key, value in headers.items() if key.lower() == "openai-log-session" + } + assert session_headers == {"OpenAI-Log-Session": "1"} From 4ccc32e3745444c75f256db8aed659c3b1f2530b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 22 Aug 2026 09:57:12 +0900 Subject: [PATCH 391/473] test: update auto-run list --- examples/run_examples.py | 1 + tests/test_run_examples_script.py | 1 + 2 files changed, 2 insertions(+) diff --git a/examples/run_examples.py b/examples/run_examples.py index 4cc36e0588..98f080e63d 100644 --- a/examples/run_examples.py +++ b/examples/run_examples.py @@ -78,6 +78,7 @@ "examples/sandbox/docker/mounts/s3_mount_read_write.py", # Blaxel 0.3.2 still imports an MCP v1 module that was removed in MCP v2. "examples/sandbox/extensions/blaxel_runner.py", + "examples/sandbox/extensions/cloudflare_runner.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py", # Temporarily disabled due to credential issues. diff --git a/tests/test_run_examples_script.py b/tests/test_run_examples_script.py index e561e0a2f4..96cff470f9 100644 --- a/tests/test_run_examples_script.py +++ b/tests/test_run_examples_script.py @@ -14,6 +14,7 @@ def test_default_auto_skip_excludes_prerequisite_bound_examples() -> None: "examples/sandbox/docker/mounts/gcs_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", "examples/sandbox/extensions/blaxel_runner.py", + "examples/sandbox/extensions/cloudflare_runner.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py", "examples/sandbox/extensions/vercel_runner.py", From 904bc6988fd8e855c565de7fa65b223847101ed0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 22 Aug 2026 09:55:50 +0900 Subject: [PATCH 392/473] test: stabilize release integration tests --- integration_tests/openai/test_execution_controls.py | 3 ++- integration_tests/security/test_local_sandbox_isolation.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/integration_tests/openai/test_execution_controls.py b/integration_tests/openai/test_execution_controls.py index f0807fa7a5..e2db08722d 100644 --- a/integration_tests/openai/test_execution_controls.py +++ b/integration_tests/openai/test_execution_controls.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import re from pathlib import Path from typing import Any, cast @@ -205,7 +206,7 @@ def filter_model_input(data: CallModelData[Any]) -> ModelInputData: assert callback_inputs[0][0] >= 2 assert callback_inputs[0][1] == "PLACEHOLDER_NEW_INPUT" assert filter_inputs == ["What release word did I provide? Reply only with that word."] - assert result.final_output == "FILTERED:JASPER" + assert re.fullmatch(r"FILTERED:\s*JASPER", result.final_output) assert any("What release word" in str(item.get("content", "")) for item in persisted) assert not any("PLACEHOLDER_NEW_INPUT" in str(item.get("content", "")) for item in persisted) diff --git a/integration_tests/security/test_local_sandbox_isolation.py b/integration_tests/security/test_local_sandbox_isolation.py index ddcc5ef481..fd932787ef 100644 --- a/integration_tests/security/test_local_sandbox_isolation.py +++ b/integration_tests/security/test_local_sandbox_isolation.py @@ -158,6 +158,7 @@ async def _create_container( exposed_ports: tuple[int, ...] = (), network_mode: Literal["none"] | None = None, session_id: uuid.UUID | None = None, + labels: dict[str, str] | None = None, ) -> Any: container = await super()._create_container( image, @@ -165,6 +166,7 @@ async def _create_container( exposed_ports=exposed_ports, network_mode=network_mode, session_id=session_id, + labels=labels, ) container_id = container.id assert container_id is not None From 60c2c4120e28098f5c10d9be2f21d2fd341b9a7c Mon Sep 17 00:00:00 2001 From: saime428 <51110572+saime428@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:52:33 -1000 Subject: [PATCH 393/473] docs: preserve English heading anchors in translated pages (#4580) --- docs/scripts/translate_docs.py | 139 ++++++++++++++++++++++++++++++ tests/docs/test_translate_docs.py | 129 +++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 tests/docs/test_translate_docs.py diff --git a/docs/scripts/translate_docs.py b/docs/scripts/translate_docs.py index adaf49cc2e..b400c84da1 100644 --- a/docs/scripts/translate_docs.py +++ b/docs/scripts/translate_docs.py @@ -6,7 +6,12 @@ import sys from collections import Counter from pathlib import Path +from typing import Any from openai import OpenAI +from markdown import Markdown +from markdown.blockprocessors import HashHeaderProcessor +from markdown.extensions.attr_list import AttrListTreeprocessor +from mkdocs.utils import yaml_load from concurrent.futures import ThreadPoolExecutor # import logging @@ -347,6 +352,120 @@ def remove_fenced_code_blocks(markdown: str) -> str: return "".join(parts) +# The parser's own grammars, so nothing here has to agree with them by hand. +ATX_HEADING_RE = HashHeaderProcessor.RE +ATTR_LIST_RE = AttrListTreeprocessor.HEADER_RE +# The only attribute list this script writes, and therefore the only one it rewrites. +OWN_ID_ATTR_RE = re.compile(r"^#[A-Za-z0-9_-]+$") + + +def mkdocs_markdown() -> Markdown: + """Build the Markdown parser the same way mkdocs does from mkdocs.yml. + + Heading ids have to come from the same parse that renders the site, so the + extension list is read from the config rather than kept in a second place. + """ + with open(REPO_ROOT / "mkdocs.yml", encoding="utf-8") as f: + config = yaml_load(f) + # mkdocs puts these in front of the configured extensions. + extensions: list[str] = ["toc", "tables", "fenced_code"] + extension_configs: dict[str, dict[str, Any]] = {} + for item in config.get("markdown_extensions", []): + if isinstance(item, dict): + for name, options in item.items(): + extensions.append(name) + extension_configs[name] = options or {} + else: + extensions.append(item) + return Markdown(extensions=extensions, extension_configs=extension_configs) + + +def heading_ids(source_markdown: str) -> list[tuple[int, str]]: + """Return (level, id) for every heading in document order, as the toc extension assigns them.""" + parser = mkdocs_markdown() + parser.convert(source_markdown) + ids: list[tuple[int, str]] = [] + + def walk(tokens: list[dict[str, Any]]) -> None: + for token in tokens: + ids.append((token["level"], token["id"])) + walk(token.get("children", [])) + + walk(parser.toc_tokens) + return ids + + +def headings_outside_code(markdown_text: str) -> list[tuple[int, int, str]]: + """Return (line index, level, text) for every ATX heading outside fenced code.""" + headings: list[tuple[int, int, str]] = [] + open_fence: tuple[str, int] | None = None + for index, line in enumerate(markdown_text.splitlines()): + if open_fence is None: + opening = opening_fence(line) + if opening is not None: + open_fence = opening + continue + match = ATX_HEADING_RE.match(line) + if match is not None: + headings.append((index, len(match.group("level")), match.group("header").strip())) + elif is_closing_fence(line, *open_fence): + open_fence = None + return headings + + +def preserve_heading_anchors( + source_markdown: str, translated_markdown: str, *, name: str = "translation" +) -> str: + """Give each translated heading the id mkdocs derives from the English heading. + + mkdocs builds a heading id from the rendered heading text, so a translated heading + gets a different id and every `#...` link written against the English page stops + resolving. With `attr_list` enabled a heading can carry an explicit `{#id}`, which + the toc extension uses instead of slugifying the text. + + The contract is the shape the docs use: one ATX heading per English heading, with + no attribute list other than the `{#id}` written here. A page outside it is + reported and returned unchanged. Before the result is returned it is parsed again + and has to render exactly the English ids, so a written page is a correct page. + """ + source_headings = heading_ids(source_markdown) + translated_headings = headings_outside_code(translated_markdown) + source_levels = [level for level, _ in source_headings] + translated_levels = [level for _, level, _ in translated_headings] + if source_levels != translated_levels: + print(f"Skipping heading anchors for {name}: headings do not line up with the source.") + return translated_markdown + + lines = translated_markdown.splitlines(keepends=True) + for (level, heading_id), (index, _, text) in zip(source_headings, translated_headings): + # Leave the H1 alone: mkdocs reads the page title from it. + if level == 1: + continue + attrs = ATTR_LIST_RE.search(text) + if attrs is not None: + if OWN_ID_ATTR_RE.match(attrs.group(1).strip()) is None: + print( + f"Skipping heading anchors for {name}: heading carries an attribute list " + f"this script does not manage: {text!r}" + ) + return translated_markdown + text = text[: attrs.start()] + line = lines[index] + ending = line[len(line.rstrip("\r\n")) :] + hashes = "#" * level + lines[index] = f"{hashes} {text} {{#{heading_id}}}{ending}" + rewritten = "".join(lines) + + # The H1 keeps its translated id; everything else has to come out as the English id. + def without_h1_ids(headings: list[tuple[int, str]]) -> list[tuple[int, str | None]]: + return [(level, heading_id if level > 1 else None) for level, heading_id in headings] + + if without_h1_ids(heading_ids(rewritten)) != without_h1_ids(source_headings): + print(f"Skipping heading anchors for {name}: the rewritten page does not render the ids.") + return translated_markdown + return rewritten + + def protect_fenced_code(markdown: str, *, namespace: str) -> tuple[str, list[str]]: parts: list[str] = [] code_blocks: list[str] = [] @@ -561,6 +680,7 @@ def translate_file(file_path: str, target_path: str, lang_code: str) -> None: f"Protected Markdown changed after 3 translation attempts for {file_path} to {lang_code}" ) + translated_text = preserve_heading_anchors(content, translated_text, name=target_path) # FIXME: enable mkdocs search plugin to seamlessly work with i18n plugin translated_text = SEARCH_EXCLUSION + translated_text # Save the combined translated content @@ -599,6 +719,24 @@ def should_translate_based_on_translation(file_path: str) -> bool: return ja_timestamp < en_timestamp +def refresh_heading_anchors(file_path: str, relative_path: str) -> None: + """Re-apply the English heading ids to existing translations without retranslating.""" + with open(file_path, encoding="utf-8") as f: + content = f.read() + for lang_code in languages: + target_path = os.path.join(source_dir, lang_code, relative_path) + if not os.path.exists(target_path): + continue + with open(target_path, encoding="utf-8", newline="") as f: + translated_text = f.read() + updated_text = preserve_heading_anchors(content, translated_text, name=target_path) + if updated_text == translated_text: + continue + print(f"Refreshing heading anchors in {target_path}") + with open(target_path, "w", encoding="utf-8", newline="") as f: + f.write(updated_text) + + def translate_single_source_file( file_path: str, *, check_translation_outdated: bool = True ) -> None: @@ -607,6 +745,7 @@ def translate_single_source_file( return if check_translation_outdated and not should_translate_based_on_translation(file_path): print(f"Skipping {file_path}: The translated one is up-to-date.") + refresh_heading_anchors(file_path, relative_path) return for lang_code in languages: diff --git a/tests/docs/test_translate_docs.py b/tests/docs/test_translate_docs.py new file mode 100644 index 0000000000..719db06d57 --- /dev/null +++ b/tests/docs/test_translate_docs.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "docs" / "scripts" / "translate_docs.py" + +SOURCE = """# Agents + +## Dynamic instructions + +Text. + +## Example + +```python +# not a heading +``` + +## Example +""" + +TRANSLATED = """# エージェント + +## 動的な指示 + +本文。 + +## 例 + +```python +# not a heading +``` + +## 例 +""" + + +@pytest.fixture +def translate_docs(monkeypatch: pytest.MonkeyPatch) -> ModuleType: + # The script builds an OpenAI client at import time; nothing here sends a request. + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + spec = importlib.util.spec_from_file_location("translate_docs", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_translated_headings_carry_the_english_ids(translate_docs: ModuleType) -> None: + result = translate_docs.preserve_heading_anchors(SOURCE, TRANSLATED) + + assert "## 動的な指示 {#dynamic-instructions}\n" in result + assert "## 例 {#example}\n" in result + assert "## 例 {#example_1}\n" in result + # The H1 is left for mkdocs to read the page title from. + assert result.startswith("# エージェント\n") + # A comment inside a fenced block is not a heading. + assert "# not a heading\n" in result + assert "# not a heading {#" not in result + + +def test_heading_ids_come_from_the_rendered_english_headings(translate_docs: ModuleType) -> None: + source = ( + "## Using `Agent` with [tools](tools.md)\n\n" + "## [API][ref]\n\n" + "## A & B\n\n" + "## run loop\n\n" + "[ref]: https://example.com\n" + ) + translated = "## `Agent` とツール\n\n## API\n\n## A と B\n\n## 実行ループ\n" + + result = translate_docs.preserve_heading_anchors(source, translated) + + assert result == ( + "## `Agent` とツール {#using-agent-with-tools}\n\n" + "## API {#api}\n\n" + "## A と B {#a-b}\n\n" + "## 実行ループ {#run-loop}\n" + ) + + +def test_preserve_heading_anchors_is_idempotent(translate_docs: ModuleType) -> None: + once = translate_docs.preserve_heading_anchors(SOURCE, TRANSLATED) + + assert translate_docs.preserve_heading_anchors(SOURCE, once) == once + + +def test_an_id_written_earlier_follows_the_english_heading(translate_docs: ModuleType) -> None: + result = translate_docs.preserve_heading_anchors("## Alpha\n", "## アルファ {#old}\n") + + assert result == "## アルファ {#alpha}\n" + + +def test_mismatched_headings_are_left_alone(translate_docs: ModuleType) -> None: + missing_one_heading = TRANSLATED.replace("\n## 例\n", "\n", 1) + + result = translate_docs.preserve_heading_anchors(SOURCE, missing_one_heading) + + assert result == missing_one_heading + + +def test_an_english_setext_heading_still_yields_its_id(translate_docs: ModuleType) -> None: + # The English side goes through the parser, so setext is just another heading there. + source = "Alpha\n-----\n\n## Beta\n" + translated = "## アルファ\n\n## ベータ\n" + + result = translate_docs.preserve_heading_anchors(source, translated) + + assert result == "## アルファ {#alpha}\n\n## ベータ {#beta}\n" + + +def test_a_setext_heading_in_the_translation_is_outside_the_contract( + translate_docs: ModuleType, +) -> None: + source = "## Alpha\n\n## Beta\n" + translated = "アルファ\n-----\n\n## ベータ\n" + + assert translate_docs.preserve_heading_anchors(source, translated) == translated + + +def test_a_heading_with_its_own_attribute_list_is_not_rewritten(translate_docs: ModuleType) -> None: + source = "## Alpha\n\n## Beta\n" + translated = "## アルファ {.lead}\n\n## ベータ\n" + + assert translate_docs.preserve_heading_anchors(source, translated) == translated From 5f6a733284122ff1d21b11a3f6aa5706eb201074 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 22 Aug 2026 17:39:14 +0900 Subject: [PATCH 394/473] chore: update review skill details --- .agents/skills/maintainer-review/SKILL.md | 24 +++++++++++++++---- .../references/evaluation-framework.md | 20 +++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index 3994538fcb..db4b841c63 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -35,6 +35,7 @@ Lead with the current review state. Use `Preliminary assessment` while decision- - For a PR, inspect the current remote base and head, full patch, commit history when relevant, tests, linked issue, and review discussion. Do not substitute the current local checkout for the remote change under review. - State the claim in one falsifiable sentence. Distinguish the reported symptom from the reporter's proposed cause or fix. - Identify the released behavior boundary when compatibility or regression claims matter. +- When a proposed change removes, reorders, or reinterprets an established observable or an explicit existing test expectation, inspect the introducing commit, blame, and original tests before any positive assessment. Intentional released coverage is compatibility-risk evidence even when it is not by itself a permanent public contract. - Verify whether linked evidence matches the PR's exact runtime variant, provider or tool type, triggering condition, and user outcome. A generic issue title, conceptual similarity, or wording such as `Related to` does not transfer evidence of need to an adjacent extension. If the reported scenario has already been fixed, treat additional variants as new needs requiring their own evidence. Respect repository instructions for remote access and mutation. A review does not authorize comments, labels, branch changes, pushes, or other remote writes. @@ -43,7 +44,7 @@ Respect repository instructions for remote access and mutation. A review does no Complete this pass before deeply evaluating a proposed implementation and before any positive issue or PR assessment. -First assign one `Need evidence` status: +First assign one `Need status`: - **Demonstrated**: The exact scope has a concrete supported scenario, a real-path reproduction, a released compatibility requirement, repeated demand, or a broad invariant with a meaningful consequence. - **Plausible but unproven**: The path can exist, but realistic provider behavior, user reach, frequency, consequence, or demand is not established. @@ -52,6 +53,15 @@ First assign one `Need evidence` status: Only `Demonstrated` need may receive `Merge-worthy as-is` or `Merge-worthy after focused changes`. For `Plausible but unproven`, prefer `Needs evidence` or `Not worth completing`; for `Already covered` or `Unsupported`, prefer closure or the relevant simpler alternative. +Keep four decisions separate and record them before comparing implementations: + +1. **Observation validity**: whether the reported output, state, or code-path difference is real. +2. **Downstream consequence**: what concrete user, operational, compatibility, or durable-state result changes because of it. +3. **Need status**: one of the four evidence classifications above. +4. **Issue action**: prioritize, accept, narrow, request evidence, or close. + +A real observation can still have no demonstrated need and a `Close` action. Do not describe an issue as simply "valid" when only the observation is confirmed. If the downstream consequence is missing, do not choose among proposed semantic contracts, select a competing PR, or draft implementation changes yet. + Before assigning `Demonstrated`, require one of these evidence paths: 1. **Observed impact**: A supported scenario, real-path reproduction, or credible user report shows a meaningful user-visible, operational, compatibility, or durable-state consequence. @@ -59,6 +69,10 @@ Before assigning `Demonstrated`, require one of these evidence paths: For both paths, trace `realistic trigger -> supported execution path -> observable or durable effect`. A local intermediate inconsistency, constructible branch, redundant operation, defensive improvement, or theoretically cleaner invariant is not a demonstrated need without a meaningful downstream effect. A small diff, technically correct patch, or inexpensive test does not lower this threshold. Material preventive outcomes include security or privacy exposure, credential leakage, persistent data or state corruption, duplicate external side effects, unrecoverable compatibility breaks, deadlock or indefinite hangs, and realistically repeatable resource exhaustion. +For a representation-only change, identify one concrete consumer computation, decision, or persisted interpretation that differs before and after the patch, then establish why the current result is wrong. If the patch only changes list shape, placeholder presence, metadata, ordering, or terminology without recovering information or changing a meaningful consumer outcome, the need is not `Demonstrated`. + +An ambiguous contract is not itself evidence that the contract should change. When multiple current shapes are released or intentionally test-covered, prefer no code change until a demonstrated outcome justifies selecting a different semantic contract. Do not choose one shape only because it is more symmetric or easier to explain. + When a report establishes only a harmless or speculative logic-level improvement, prefer `Not worth completing` or `Close` rather than requesting implementation refinements. Use `Needs evidence` only when a specific missing reproduction or consequence trace could realistically change the practical-impact decision. 1. Restate the desired user outcome without naming the requested API, class, file, option, or implementation. Separate the actual constraint from the reporter's preferred mechanism. @@ -168,7 +182,7 @@ Do not over-investigate. Stop when additional evidence is unlikely to change val Use `references/evaluation-framework.md` to assess claim validity, realistic reach, consequence, breadth, frequency, recoverability, compatibility, and severity. Keep observed facts separate from inference and state any missing evidence that could change the decision. -Report the `Need evidence` status before classifying the need as a capability gap, ergonomics or discoverability gap, unsupported use case, or no demonstrated gap. Do not assign practical impact to the absence of the requested mechanism when an existing supported workflow already produces the requested outcome. Do not infer practical importance merely from reachability, API asymmetry, or a technically successful patch. +Report the `Need status` before classifying the need as a capability gap, ergonomics or discoverability gap, unsupported use case, or no demonstrated gap. Do not assign practical impact to the absence of the requested mechanism when an existing supported workflow already produces the requested outcome. Do not infer practical importance merely from reachability, API asymmetry, or a technically successful patch. For a PR, make `Severity` describe the underlying issue or user need only. Do not combine it with the risk created by the proposed patch. Report a meaningful patch-induced regression, compatibility, lifecycle, or maintenance risk separately as `Patch risk`. @@ -185,7 +199,7 @@ Use one code recommendation: - **Supersede with a simpler alternative**: real need, but a smaller or more coherent fix is preferable. - **Not worth completing**: negligible or unsupported impact, no-op behavior, wrong abstraction, or excessive completion cost. -`Merge-worthy as-is` and `Merge-worthy after focused changes` are invalid unless `Need evidence` is `Demonstrated`. A bounded set of implementation fixes cannot promote a `Plausible but unproven` need into a merge-worthy recommendation. +`Merge-worthy as-is` and `Merge-worthy after focused changes` are invalid unless `Need status` is `Demonstrated`. A bounded set of implementation fixes cannot promote a `Plausible but unproven` need into a merge-worthy recommendation. For `Merge-worthy as-is` and `Merge-worthy after focused changes`, use one repository-readiness status when it helps communicate the integration state: @@ -217,7 +231,7 @@ Do not infer the assessment language from the GitHub URL, contributor, code, or Use the matching compact report variant in `references/evaluation-framework.md`. While decision-relevant evidence is pending, use its preliminary-assessment variant and end with the evidence limitation and optional suggestion for a separate runtime investigation instead of presenting a final recommendation. Collapse sections for simple cases rather than padding the answer. Put unexpected or negative runtime findings first, and name the preferred PR or approach explicitly when candidates compete. -For PRs, put `Need evidence` before code recommendation. When the need is not `Demonstrated`, lead with that result, omit repository readiness, and avoid presenting patch fixes as the primary maintainer action. +For PRs, put `Need status` before code recommendation. When the need is not `Demonstrated`, lead with that result, omit repository readiness, and avoid presenting patch fixes as the primary maintainer action. When existing functionality or a better alternative materially affects the decision, state it explicitly in the evidence and recommendation. Name the exact supported path, what it does and does not cover, and why it is preferable. Do not bury a `Not worth completing` or `Supersede with a simpler alternative` conclusion beneath praise for implementation quality. @@ -225,6 +239,8 @@ When recommending closure, requesting more evidence, requesting code changes, or Before returning any maintainer comment draft, perform a GitHub paste-readiness pass using the repository-wide rule in `AGENTS.md` and the detailed guidance in `references/evaluation-framework.md`. In the draft, use `#123` for same-repository issues or PRs and `owner/repo#123` for cross-repository references. Remove Markdown-linked issue or PR labels, Codex navigation links, local file links, Codex-only citation markers or footnotes, and app directives from the copy-ready draft. Preserve ordinary descriptive links to API docs, design notes, and other targets without native GitHub issue or pull-request syntax. +Also perform an action-delta pass. Every imperative sentence must correspond to a concrete difference between the current remote head and the desired state. Remove requests to "keep", "preserve", document, test, or change behavior that the current head already satisfies or that is not merge-blocking. A `Merge-worthy as-is` result must not contain change-request language. Keep portfolio comparisons out of a contributor-facing draft unless duplicate or supersession handling is the action for that target. + For request-changes comments, phrase maintainer-owned semantic decisions as a directive, not as a menu. It is fine to mention the rejected alternative briefly in the rationale, but the requested action must identify the chosen behavior, scope, or compatibility boundary. Use "please do X because..." instead of "either do X or Y" when X versus Y changes the SDK contract or user-visible semantics. Do not produce a line-by-line review unless requested. Do not equate passing tests with merge-worthiness, or a logically correct patch with practical value. diff --git a/.agents/skills/maintainer-review/references/evaluation-framework.md b/.agents/skills/maintainer-review/references/evaluation-framework.md index 36d4973063..08010f451e 100644 --- a/.agents/skills/maintainer-review/references/evaluation-framework.md +++ b/.agents/skills/maintainer-review/references/evaluation-framework.md @@ -29,7 +29,7 @@ Treat validity, severity, and merge-worthiness as separate results. Also disting | Consequence | What fails, and is the result silent or recoverable? | Observed output/error/state plus downstream effect | | Breadth | Who is affected? | Supported providers, platforms, versions, and configurations identified precisely | | Frequency | Is this normal, intermittent, or pathological? | Repeat runs, telemetry or reports when available, deterministic preconditions | -| Need evidence | Is the exact scope demonstrated, merely plausible, already covered, or unsupported? | Observed impact or a complete realistic trigger-to-material-consequence trace for prevention | +| Need status | Is the exact scope demonstrated, merely plausible, already covered, or unsupported? | Observed impact or a complete realistic trigger-to-material-consequence trace for prevention | | Unmet need | What user outcome cannot be achieved through supported behavior today? | Concrete scenario plus a trace showing why the closest existing path is insufficient | | Existing capability | Can configuration, composition, cloning, callbacks, extension points, or a caller-owned layer already satisfy the outcome? | Current release code, tests, docs, and an exact supported workflow | | Compatibility | Is released behavior or durable state changed? | Latest release comparison and explicit contract inspection | @@ -58,6 +58,7 @@ Before calling a claim confirmed, answer: - Are setup failures, stale builds, environment leakage, proxies, caches, or unsupported options excluded? - Does an adjacent helper or equivalent path follow different semantics? - Is the observed behavior prohibited by an actual contract, or merely surprising? +- If the patch removes or reinterprets an established observable or explicit test expectation, what did the introducing commit and original tests intend? Treat that history as compatibility-risk evidence, not automatic proof that the behavior must never change. - For latency, timeout, buffering, backpressure, or cleanup claims, was observable elapsed time or a real state transition measured when feasible rather than inferred only from mocks? - For shared asynchronous state, do tests control completion order and prove that stale failure or cleanup cannot affect the surviving operation? @@ -71,7 +72,7 @@ Issue reports often combine a desired outcome with a proposed API or implementat Evidence from a linked issue applies only when the issue and PR share the same runtime variant, provider or tool type, trigger, supported configuration, and user outcome. A broad title, ordinary reference, `Related to` statement, or conceptual similarity is not enough. If an earlier change already resolved the concrete reported scenario, an adjacent extension starts with no inherited evidence of need. -### Need evidence status +### Need status Assign one status before deep implementation review: @@ -82,6 +83,8 @@ Assign one status before deep implementation review: Only `Demonstrated` need can support a merge-worthy code recommendation. `Plausible but unproven` maps to `Needs evidence` or `Not worth completing`, even when the patch is technically correct and its remaining fixes are bounded. `Already covered` and `Unsupported` normally map to closure or a simpler non-core alternative. +The need status is an evidence classification, not the issue action. Record observation validity, downstream consequence, need status, and issue action separately. A reported shape or branch difference can be confirmed while the need remains `Plausible but unproven` and the correct issue action is `Close`. + ### Practical-impact gate Do not accept a change merely because desk review identifies a local logical flaw, defensive improvement, or constructible edge case. Trace the complete consequence chain: @@ -90,6 +93,8 @@ Do not accept a change merely because desk review identifies a local logical fla A local intermediate inconsistency, redundant operation, surprising branch, or theoretically cleaner invariant is not a demonstrated need when it has no meaningful downstream effect. Reachability, a passing new test, a small diff, and low implementation cost establish neither practical impact nor maintenance value. +For representation-only changes, name the concrete consumer computation, decision, or persisted interpretation that changes before and after the patch. If the patch only changes list shape, placeholder presence, metadata, ordering, or terminology without recovering information or changing a meaningful result, it does not establish practical impact. Contract ambiguity and API symmetry are insufficient by themselves, especially when the current shapes are released or intentionally test-covered. + Use one of these evidence paths: | Evidence path | Required proof | Insufficient proof | @@ -136,6 +141,8 @@ Choose one primary action: When requesting evidence, ask only for information that could change the disposition. +For external contribution triage, default a `Plausible but unproven` need to `Close` when the current report shows only a logic-level or representation-level inconsistency. Use the `Needs evidence` issue action only when maintainers intentionally want to keep the issue open and can name one bounded piece of evidence likely to change the decision. A closed issue may still state the concrete evidence that would justify reconsideration. + ## PR quality and value Assess these independently: @@ -269,6 +276,13 @@ Use GitHub-native references in every draft: Before returning the draft, normalize any same-repository URL or qualified reference to `#`, normalize any cross-repository issue or pull-request URL to `owner/repo#`, and rescan the draft. Do not return it while a Markdown-linked issue or pull-request label, `openai/openai-agents-python#`, or bare GitHub issue or pull-request URL remains. +Perform an action-delta pass after the paste-readiness pass: + +- Map every imperative sentence to a concrete difference between the current remote head and the desired state. +- Remove requests for behavior, tests, documentation, or scope that the current head already satisfies or that the recommendation does not require. +- Do not use change-request language for `Merge-worthy as-is`. +- Keep portfolio comparisons out of a contributor-facing draft unless that target is being closed or redirected as a duplicate or superseded implementation. + Do not include internal labels such as `severity: low`, speculate about AI authorship or contributor intent, repeat the full review, or soften the message until the requested action becomes unclear. Do not ask contributors to choose maintainer-owned semantics. If two implementations are technically possible but one changes the SDK contract, decide the contract in the review and make the comment actionable. Use a short rationale such as "This keeps the new handler scoped to the existing raise site" or "This makes the handler name match all invalid final messages", then request the exact code and tests for that decision. @@ -347,7 +361,7 @@ Use `Maintainer decision` for a concluded review. Use `Preliminary assessment` w ```markdown ## Maintainer decision -- Need evidence: +- Need status: - Code recommendation: - Repository readiness: From 5b8f6c71747e5feef143c7047a165ebadf0a021d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 22 Aug 2026 18:08:27 +0900 Subject: [PATCH 395/473] fix: freeze the public voice API contract (#4578) --- integration_tests/_contract_support.py | 357 ++++++++++- src/agents/voice/__init__.py | 8 +- .../released_api_contract_policy.json | 359 +++++++++++ tests/test_released_api_contract.py | 565 +++++++++++++++++- 4 files changed, 1262 insertions(+), 27 deletions(-) diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index c8dd69ccad..241b4a972c 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -12,8 +12,8 @@ from copy import deepcopy from importlib.util import find_spec from pathlib import Path -from types import FunctionType, TracebackType -from typing import Any, ForwardRef, cast, get_origin, get_type_hints +from types import FunctionType, TracebackType, UnionType +from typing import Any, ForwardRef, Literal, Union, cast, get_args, get_origin, get_type_hints import typing_extensions from pydantic import BaseModel @@ -36,7 +36,9 @@ class SubmoduleExportPolicy: modules: dict[str, dict[str, dict[str, str]]] dependency_installations: tuple[OptionalDependencyInstallation, ...] canonical_imports: tuple[dict[str, str], ...] = () + public_class_contracts: tuple[dict[str, Any], ...] = () public_properties: tuple[dict[str, Any], ...] = () + public_type_aliases: tuple[dict[str, str], ...] = () public_typed_dicts: tuple[dict[str, Any], ...] = () @@ -56,7 +58,9 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: "canonical_imports", "modules", "optional_dependencies", + "public_class_contracts", "public_properties", + "public_type_aliases", "public_typed_dicts", } ) @@ -164,7 +168,11 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: ) ), canonical_imports=_canonical_import_policy(value.get("canonical_imports", [])), + public_class_contracts=_public_class_contract_policy( + value.get("public_class_contracts", []) + ), public_properties=_public_property_policy(value.get("public_properties", [])), + public_type_aliases=_public_type_alias_policy(value.get("public_type_aliases", [])), public_typed_dicts=_public_typed_dict_policy(value.get("public_typed_dicts", [])), ) @@ -205,7 +213,8 @@ def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: if not isinstance(entry, dict): raise ValueError("submodule export policy public_properties entries must be objects") owner_fields = {"class_name", "factory_name"} & set(entry) - if len(owner_fields) != 1 or set(entry) != {"module", "names", *owner_fields}: + required_fields = {"module", "names", *owner_fields} + if len(owner_fields) != 1 or set(entry) != required_fields: raise ValueError( "submodule export policy public_properties entries must contain exactly " "module, names, and one of class_name or factory_name" @@ -240,7 +249,75 @@ def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: f"{module_name}.{owner_name}" ) identities.add(identity) - entries.append({owner_field: owner_name, "module": module_name, "names": list(names)}) + normalized_entry = { + owner_field: owner_name, + "module": module_name, + "names": list(names), + } + entries.append(normalized_entry) + return tuple(entries) + + +def _public_class_contract_policy(value: object) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_class_contracts must be a list") + required_fields = {"class_name", "module"} + contract_fields = {"abstract", "abstract_members"} + entries: list[dict[str, Any]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if ( + not isinstance(entry, dict) + or not required_fields.issubset(entry) + or not set(entry).issubset(required_fields | contract_fields) + or not (set(entry) & contract_fields) + ): + raise ValueError( + "submodule export policy public_class_contracts entries must contain exactly " + "module, class_name, and at least one of abstract or abstract_members" + ) + module_name = entry["module"] + class_name = entry["class_name"] + if type(module_name) is not str or not module_name: + raise ValueError( + "submodule export policy public_class_contracts module must be a non-empty string" + ) + if type(class_name) is not str or not class_name: + raise ValueError( + "submodule export policy public_class_contracts class_name must be a non-empty " + "string" + ) + if "abstract" in entry and type(entry["abstract"]) is not bool: + raise ValueError( + "submodule export policy public_class_contracts abstract must be a boolean" + ) + abstract_members = entry.get("abstract_members") + if "abstract_members" in entry and ( + not isinstance(abstract_members, list) + or not abstract_members + or not all(type(name) is str and name for name in abstract_members) + or len(abstract_members) != len(set(abstract_members)) + ): + raise ValueError( + "submodule export policy public_class_contracts abstract_members must be a " + "non-empty list of unique non-empty strings" + ) + identity = (module_name, class_name) + if identity in identities: + raise ValueError( + "submodule export policy public_class_contracts must not repeat " + f"{module_name}.{class_name}" + ) + identities.add(identity) + normalized_entry: dict[str, Any] = { + "class_name": class_name, + "module": module_name, + } + if "abstract" in entry: + normalized_entry["abstract"] = entry["abstract"] + if "abstract_members" in entry: + normalized_entry["abstract_members"] = sorted(abstract_members) + entries.append(normalized_entry) return tuple(entries) @@ -288,6 +365,33 @@ def _public_typed_dict_policy(value: object) -> tuple[dict[str, Any], ...]: return tuple(entries) +def _public_type_alias_policy(value: object) -> tuple[dict[str, str], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_type_aliases must be a list") + required_fields = {"module", "name"} + entries: list[dict[str, str]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if not isinstance(entry, dict) or set(entry) != required_fields: + raise ValueError( + "submodule export policy public_type_aliases entries must contain exactly " + "module and name" + ) + if not all(type(entry[field]) is str and entry[field] for field in required_fields): + raise ValueError( + "submodule export policy public_type_aliases values must be non-empty strings" + ) + identity = (entry["module"], entry["name"]) + if identity in identities: + raise ValueError( + "submodule export policy public_type_aliases must not repeat " + f"{entry['module']}.{entry['name']}" + ) + identities.add(identity) + entries.append({"module": entry["module"], "name": entry["name"]}) + return tuple(entries) + + def _add_legacy_literal_types(value: object) -> None: if isinstance(value, dict): if value.get("kind") == "literal" and "value" in value and "type" not in value: @@ -414,6 +518,11 @@ def _default_contract(value: object) -> dict[str, object]: "name": value.name, "value": _default_contract(value.value), } + if isinstance(value, type): + return { + "kind": "type", + "identity": f"{value.__module__}.{value.__qualname__}", + } if isinstance(value, tuple | list): return { "kind": "sequence", @@ -723,6 +832,31 @@ def _merge_public_properties( return result +def _merge_public_class_contracts( + existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] +) -> list[dict[str, Any]]: + result = [deepcopy(dict(entry)) for entry in existing] + by_identity = {(entry["module"], entry["class_name"]): entry for entry in result} + for entry_value in promoted: + entry = deepcopy(dict(entry_value)) + identity = (entry["module"], entry["class_name"]) + previous = by_identity.get(identity) + if previous is None: + result.append(entry) + by_identity[identity] = entry + continue + for field_name in ("abstract", "abstract_members"): + if field_name not in entry: + continue + previous_value = previous.setdefault(field_name, entry[field_name]) + if previous_value != entry[field_name]: + raise ValueError( + "release policy public class contract conflicts with the released contract " + f"for {entry['module']}.{entry['class_name']} field {field_name}" + ) + return result + + def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]: if "class_name" in entry: return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"])) @@ -744,6 +878,86 @@ def _annotation_contract(annotation: object) -> str: return annotation_text +def _sorted_type_alias_members(members: Iterable[dict[str, object]]) -> list[dict[str, object]]: + return sorted( + members, + key=lambda member: ( + cast(str, member["kind"]), + json.dumps(member, sort_keys=True, separators=(",", ":")), + ), + ) + + +def _type_alias_definition(value: object) -> dict[str, object]: + origin = get_origin(value) + if origin is Literal: + literal_values: list[dict[str, object]] = [] + for literal_value in get_args(value): + literal_contract = _default_contract(literal_value) + if literal_contract["kind"] not in {"literal", "enum"}: + raise TypeError( + "public type alias Literal members must use supported literal or enum values" + ) + literal_values.append(literal_contract) + return { + "kind": "literal", + "values": _sorted_type_alias_members(literal_values), + } + if origin in {Union, UnionType}: + members = [_type_alias_definition(member) for member in get_args(value)] + return { + "kind": "union", + "members": _sorted_type_alias_members(members), + } + if isinstance(value, type) and ( + value.__module__ == "agents" or value.__module__.startswith("agents.") + ): + return { + "kind": "type", + "identity": f"{value.__module__}.{value.__qualname__}", + } + raise TypeError(f"unsupported public type alias member: {value!r}") + + +def _public_type_alias_contract( + policy_entries: Iterable[Mapping[str, str]], + agents_module: Any | None, +) -> list[dict[str, object]]: + entries: list[dict[str, object]] = [] + missing = object() + for policy_entry in policy_entries: + module_name = policy_entry["module"] + alias_name = policy_entry["name"] + module = _import_contract_module(module_name, agents_module) + alias = getattr(module, alias_name, missing) + if alias is missing: + raise ValueError( + f"Cannot promote public type alias {module_name}.{alias_name} because it is missing" + ) + try: + definition = _type_alias_definition(alias) + except TypeError as error: + raise ValueError( + f"Cannot promote public type alias {module_name}.{alias_name}: {error}" + ) from None + entries.append({"definition": definition, "module": module_name, "name": alias_name}) + return entries + + +def _merge_public_type_aliases( + existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] +) -> list[dict[str, Any]]: + result = [deepcopy(dict(entry)) for entry in existing] + identities = {(entry["module"], entry["name"]) for entry in result} + for entry_value in promoted: + entry = deepcopy(dict(entry_value)) + identity = (entry["module"], entry["name"]) + if identity not in identities: + result.append(entry) + identities.add(identity) + return result + + def _typed_dict_field_is_required(typed_dict: type, name: str, annotation: object) -> bool: if isinstance(annotation, ForwardRef): annotation_text = annotation.__forward_arg__ @@ -879,9 +1093,21 @@ def _optional_dependency_is_unsupported_for_contract( def _optional_dependency_for_binding( contract: Mapping[str, Any], module_name: str, binding_name: str ) -> str | None: - return _optional_dependency_for_binding_in_modules( + dependency = _optional_dependency_for_binding_in_modules( contract.get("required_submodule_exports", {}), module_name, binding_name ) + if dependency is not None: + return dependency + canonical_dependencies = { + _optional_dependency_for_binding_in_modules( + contract.get("required_submodule_exports", {}), entry["module"], entry["name"] + ) + for entry in contract.get("canonical_imports", []) + if entry["canonical_module"] == module_name and entry["canonical_name"] == binding_name + } + if canonical_dependencies and len(canonical_dependencies) == 1: + return next(iter(canonical_dependencies)) + return None def _optional_dependency_for_binding_in_modules( @@ -913,6 +1139,17 @@ def _optional_dependency_for_module_import( dependencies = {optional_bindings.get(name) or optional_exports.get(name) for name in names} if names and len(dependencies) == 1 and None not in dependencies: return cast(str, next(iter(dependencies))) + if names: + return None + canonical_dependencies = { + _optional_dependency_for_binding(contract, entry["module"], entry["name"]) + for entry in contract.get("canonical_imports", []) + if entry["canonical_module"] == module_name + } + if canonical_dependencies and len(canonical_dependencies) == 1: + dependency = next(iter(canonical_dependencies)) + if dependency is not None: + return dependency return None @@ -1086,10 +1323,20 @@ def build_released_api_contract( updated["required_top_level_exports"] = ordered_exports updated["callables"] = callables updated["canonical_imports"] = canonical_imports + updated["public_class_contracts"] = _merge_public_class_contracts( + contract.get("public_class_contracts", []), + release_policy.public_class_contracts if release_policy is not None else (), + ) updated["public_properties"] = _merge_public_properties( contract.get("public_properties", []), release_policy.public_properties if release_policy is not None else (), ) + updated["public_type_aliases"] = _merge_public_type_aliases( + contract.get("public_type_aliases", []), + _public_type_alias_contract(release_policy.public_type_aliases, agents_module) + if release_policy is not None + else (), + ) updated["public_typed_dicts"] = _merge_public_typed_dicts( contract.get("public_typed_dicts", []), _public_typed_dict_contract(release_policy.public_typed_dicts, agents_module) @@ -1220,7 +1467,9 @@ def build_released_api_contract( "callables", "optional_dependency_unsupported_platforms", "platform_import_errors", + "public_class_contracts", "public_properties", + "public_type_aliases", "public_typed_dicts", "public_modules", "required_submodule_exports", @@ -1361,6 +1610,48 @@ def _validate_public_property_contract( return errors +def _validate_public_class_contract( + contract: dict[str, Any], + agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, +) -> list[str]: + errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} + for entry in contract.get("public_class_contracts", []): + module_name = entry["module"] + class_name = entry["class_name"] + optional_dependency = _optional_dependency_for_binding(contract, module_name, class_name) + if optional_dependency is not None and not _optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ): + continue + try: + module = _import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + class_value = getattr(module, class_name, None) + if not isinstance(class_value, type): + errors.append(f"Missing released public class {module_name}.{class_name}") + continue + if "abstract" in entry and inspect.isabstract(class_value) != entry["abstract"]: + expected_state = "abstract" if entry["abstract"] else "concrete" + current_state = "abstract" if inspect.isabstract(class_value) else "concrete" + errors.append( + f"{module_name}.{class_name} changed its released public class state: " + f"expected {expected_state}, got {current_state}" + ) + if "abstract_members" in entry: + current_members = sorted(getattr(class_value, "__abstractmethods__", ())) + if current_members != entry["abstract_members"]: + errors.append( + f"{module_name}.{class_name} changed its released public abstract members: " + f"expected {entry['abstract_members']!r}, got {current_members!r}" + ) + return errors + + def _validate_public_typed_dict_contract( contract: dict[str, Any], agents_module: Any | None, @@ -1397,6 +1688,48 @@ def _validate_public_typed_dict_contract( return errors +def _validate_public_type_alias_contract( + contract: dict[str, Any], + agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, +) -> list[str]: + errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} + missing = object() + for entry in contract.get("public_type_aliases", []): + module_name = entry["module"] + alias_name = entry["name"] + optional_dependency = _optional_dependency_for_binding(contract, module_name, alias_name) + if optional_dependency is not None and not _optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ): + continue + try: + module = _import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + alias = getattr(module, alias_name, missing) + if alias is missing: + errors.append(f"Missing released public type alias {module_name}.{alias_name}") + continue + try: + current_definition = _type_alias_definition(alias) + except TypeError as error: + errors.append( + f"{module_name}.{alias_name} no longer has a supported released public type " + f"alias definition: {error}" + ) + continue + if current_definition != entry["definition"]: + errors.append( + f"{module_name}.{alias_name} changed its released public type alias: " + f"expected {entry['definition']!r}, got {current_definition!r}" + ) + return errors + + def _submodule_export_contract( module: object, *, @@ -1495,6 +1828,13 @@ def validate_released_api_contract( errors.append(f"Invalid released optional dependency platform declarations: {error}") unsupported_platforms = {} + errors.extend( + _validate_public_class_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) errors.extend( _validate_public_property_contract( contract, @@ -1502,6 +1842,13 @@ def validate_released_api_contract( unsupported_platforms=unsupported_platforms, ) ) + errors.extend( + _validate_public_type_alias_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) errors.extend( _validate_public_typed_dict_contract( contract, diff --git a/src/agents/voice/__init__.py b/src/agents/voice/__init__.py index 749c6c5ed0..df9295f0dd 100644 --- a/src/agents/voice/__init__.py +++ b/src/agents/voice/__init__.py @@ -1,4 +1,9 @@ -from .events import VoiceStreamEvent, VoiceStreamEventAudio, VoiceStreamEventLifecycle +from .events import ( + VoiceStreamEvent, + VoiceStreamEventAudio, + VoiceStreamEventError, + VoiceStreamEventLifecycle, +) from .exceptions import STTWebsocketConnectionError from .input import AudioInput, StreamedAudioInput from .model import ( @@ -41,6 +46,7 @@ "OpenAISTTModel", "OpenAITTSModel", "VoiceStreamEventAudio", + "VoiceStreamEventError", "VoiceStreamEventLifecycle", "VoiceStreamEvent", "VoicePipeline", diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index 45fd718d12..bdd3e007b0 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -197,6 +197,168 @@ "canonical_name": "VercelSandboxClientOptions", "module": "agents.extensions.sandbox", "name": "VercelSandboxClientOptions" + }, + { + "canonical_module": "agents.voice.input", + "canonical_name": "AudioInput", + "module": "agents.voice", + "name": "AudioInput" + }, + { + "canonical_module": "agents.voice.input", + "canonical_name": "StreamedAudioInput", + "module": "agents.voice", + "name": "StreamedAudioInput" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "STTModel", + "module": "agents.voice", + "name": "STTModel" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "STTModelSettings", + "module": "agents.voice", + "name": "STTModelSettings" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "TTSCustomVoice", + "module": "agents.voice", + "name": "TTSCustomVoice" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "TTSModel", + "module": "agents.voice", + "name": "TTSModel" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "TTSModelSettings", + "module": "agents.voice", + "name": "TTSModelSettings" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "TTSVoice", + "module": "agents.voice", + "name": "TTSVoice" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "VoiceModelProvider", + "module": "agents.voice", + "name": "VoiceModelProvider" + }, + { + "canonical_module": "agents.voice.result", + "canonical_name": "StreamedAudioResult", + "module": "agents.voice", + "name": "StreamedAudioResult" + }, + { + "canonical_module": "agents.voice.workflow", + "canonical_name": "SingleAgentVoiceWorkflow", + "module": "agents.voice", + "name": "SingleAgentVoiceWorkflow" + }, + { + "canonical_module": "agents.voice.models.openai_model_provider", + "canonical_name": "OpenAIVoiceModelProvider", + "module": "agents.voice", + "name": "OpenAIVoiceModelProvider" + }, + { + "canonical_module": "agents.voice.models.openai_stt", + "canonical_name": "OpenAISTTModel", + "module": "agents.voice", + "name": "OpenAISTTModel" + }, + { + "canonical_module": "agents.voice.models.openai_tts", + "canonical_name": "OpenAITTSModel", + "module": "agents.voice", + "name": "OpenAITTSModel" + }, + { + "canonical_module": "agents.voice.events", + "canonical_name": "VoiceStreamEventAudio", + "module": "agents.voice", + "name": "VoiceStreamEventAudio" + }, + { + "canonical_module": "agents.voice.events", + "canonical_name": "VoiceStreamEventLifecycle", + "module": "agents.voice", + "name": "VoiceStreamEventLifecycle" + }, + { + "canonical_module": "agents.voice.events", + "canonical_name": "VoiceStreamEvent", + "module": "agents.voice", + "name": "VoiceStreamEvent" + }, + { + "canonical_module": "agents.voice.events", + "canonical_name": "VoiceStreamEventError", + "module": "agents.voice", + "name": "VoiceStreamEventError" + }, + { + "canonical_module": "agents.voice.pipeline", + "canonical_name": "VoicePipeline", + "module": "agents.voice", + "name": "VoicePipeline" + }, + { + "canonical_module": "agents.voice.pipeline_config", + "canonical_name": "VoicePipelineConfig", + "module": "agents.voice", + "name": "VoicePipelineConfig" + }, + { + "canonical_module": "agents.voice.utils", + "canonical_name": "get_sentence_based_splitter", + "module": "agents.voice", + "name": "get_sentence_based_splitter" + }, + { + "canonical_module": "agents.voice.workflow", + "canonical_name": "VoiceWorkflowHelper", + "module": "agents.voice", + "name": "VoiceWorkflowHelper" + }, + { + "canonical_module": "agents.voice.workflow", + "canonical_name": "VoiceWorkflowBase", + "module": "agents.voice", + "name": "VoiceWorkflowBase" + }, + { + "canonical_module": "agents.voice.workflow", + "canonical_name": "SingleAgentWorkflowCallbacks", + "module": "agents.voice", + "name": "SingleAgentWorkflowCallbacks" + }, + { + "canonical_module": "agents.voice.model", + "canonical_name": "StreamedTranscriptionSession", + "module": "agents.voice", + "name": "StreamedTranscriptionSession" + }, + { + "canonical_module": "agents.voice.models.openai_stt", + "canonical_name": "OpenAISTTTranscriptionSession", + "module": "agents.voice", + "name": "OpenAISTTTranscriptionSession" + }, + { + "canonical_module": "agents.voice.exceptions", + "canonical_name": "STTWebsocketConnectionError", + "module": "agents.voice", + "name": "STTWebsocketConnectionError" } ], "optional_dependencies": { @@ -304,6 +466,86 @@ "optional_bindings": {}, "optional_exports": {} }, + "agents.voice": { + "optional_bindings": { + "AudioInput": "numpy", + "OpenAISTTModel": "numpy", + "OpenAISTTTranscriptionSession": "numpy", + "OpenAITTSModel": "numpy", + "OpenAIVoiceModelProvider": "numpy", + "STTModel": "numpy", + "STTModelSettings": "numpy", + "STTWebsocketConnectionError": "numpy", + "SingleAgentVoiceWorkflow": "numpy", + "SingleAgentWorkflowCallbacks": "numpy", + "StreamedAudioInput": "numpy", + "StreamedAudioResult": "numpy", + "StreamedTranscriptionSession": "numpy", + "TTSCustomVoice": "numpy", + "TTSModel": "numpy", + "TTSModelSettings": "numpy", + "TTSVoice": "numpy", + "VoiceModelProvider": "numpy", + "VoicePipeline": "numpy", + "VoicePipelineConfig": "numpy", + "VoiceStreamEvent": "numpy", + "VoiceStreamEventAudio": "numpy", + "VoiceStreamEventError": "numpy", + "VoiceStreamEventLifecycle": "numpy", + "VoiceWorkflowBase": "numpy", + "VoiceWorkflowHelper": "numpy", + "get_sentence_based_splitter": "numpy" + }, + "optional_exports": {} + }, + "agents.voice.events": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.exceptions": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.input": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.imports": { + "optional_bindings": { + "np": "numpy", + "npt": "numpy", + "websockets": "numpy" + }, + "optional_exports": {} + }, + "agents.voice.model": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.models.openai_model_provider": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.models.openai_stt": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.models.openai_tts": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.pipeline": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.pipeline_config": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.result": { + "optional_bindings": {}, + "optional_exports": {} + }, "agents.voice.testing": { "optional_bindings": { "STTCall": "numpy", @@ -320,8 +562,73 @@ "pcm16_samples": "numpy" }, "optional_exports": {} + }, + "agents.voice.utils": { + "optional_bindings": {}, + "optional_exports": {} + }, + "agents.voice.workflow": { + "optional_bindings": {}, + "optional_exports": {} } }, + "public_class_contracts": [ + { + "abstract_members": [ + "create_session", + "model_name", + "transcribe" + ], + "class_name": "STTModel", + "module": "agents.voice.model" + }, + { + "abstract_members": [ + "close", + "transcribe_turns" + ], + "class_name": "StreamedTranscriptionSession", + "module": "agents.voice.model" + }, + { + "abstract_members": [ + "model_name", + "run" + ], + "class_name": "TTSModel", + "module": "agents.voice.model" + }, + { + "abstract_members": [ + "get_stt_model", + "get_tts_model" + ], + "class_name": "VoiceModelProvider", + "module": "agents.voice.model" + }, + { + "abstract": false, + "class_name": "OpenAISTTModel", + "module": "agents.voice.models.openai_stt" + }, + { + "abstract": false, + "class_name": "OpenAISTTTranscriptionSession", + "module": "agents.voice.models.openai_stt" + }, + { + "abstract": false, + "class_name": "OpenAITTSModel", + "module": "agents.voice.models.openai_tts" + }, + { + "abstract_members": [ + "run" + ], + "class_name": "VoiceWorkflowBase", + "module": "agents.voice.workflow" + } + ], "public_properties": [ { "class_name": "RunState", @@ -390,6 +697,41 @@ "transcriptions" ] }, + { + "class_name": "STTModel", + "module": "agents.voice.model", + "names": [ + "model_name" + ] + }, + { + "class_name": "TTSModel", + "module": "agents.voice.model", + "names": [ + "model_name" + ] + }, + { + "class_name": "OpenAISTTModel", + "module": "agents.voice.models.openai_stt", + "names": [ + "model_name" + ] + }, + { + "class_name": "OpenAITTSModel", + "module": "agents.voice.models.openai_tts", + "names": [ + "model_name" + ] + }, + { + "class_name": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider", + "names": [ + "agent_registration" + ] + }, { "class_name": "RunloopPlatformClient", "module": "agents.extensions.sandbox", @@ -425,6 +767,16 @@ ] } ], + "public_type_aliases": [ + { + "module": "agents.voice.model", + "name": "TTSVoice" + }, + { + "module": "agents.voice.events", + "name": "VoiceStreamEvent" + } + ], "public_typed_dicts": [ { "class_name": "ModelStepSpec", @@ -463,6 +815,13 @@ "playback_tracker", "call_id" ] + }, + { + "class_name": "TTSCustomVoice", + "module": "agents.voice", + "names": [ + "id" + ] } ] } diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 50ef7766c3..843adfeeb8 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -1,5 +1,7 @@ +import abc import builtins import importlib +import inspect import json import subprocess import sys @@ -10,7 +12,7 @@ from inspect import Parameter, Signature from pathlib import Path from types import SimpleNamespace -from typing import Any +from typing import Any, Literal, cast import pytest from pydantic import BaseModel, Field @@ -25,7 +27,9 @@ _parameter_contract, _public_class_member_contract, _validate_parameter_contract, + _validate_public_class_contract, _validate_public_property_contract, + _validate_public_type_alias_contract, _validate_public_typed_dict_contract, build_released_api_contract, load_api_contract, @@ -41,14 +45,18 @@ def _release_policy( *, dependency_installations: tuple[OptionalDependencyInstallation, ...] = (), canonical_imports: tuple[dict[str, str], ...] = (), + public_class_contracts: tuple[dict[str, Any], ...] = (), public_properties: tuple[dict[str, Any], ...] = (), + public_type_aliases: tuple[dict[str, str], ...] = (), public_typed_dicts: tuple[dict[str, Any], ...] = (), ) -> SubmoduleExportPolicy: return SubmoduleExportPolicy( modules=modules, dependency_installations=dependency_installations, canonical_imports=canonical_imports, + public_class_contracts=public_class_contracts, public_properties=public_properties, + public_type_aliases=public_type_aliases, public_typed_dicts=public_typed_dicts, ) @@ -79,6 +87,59 @@ def changed_callable(value: object = changed) -> None: assert "changed its released positional parameter prefix" in errors[0] +def test_type_default_contract_preserves_identity() -> None: + assert _default_contract(int) == { + "kind": "type", + "identity": "builtins.int", + } + + def released_callable(value: type = int) -> None: + _ = value + + def changed_callable(value: type = float) -> None: + _ = value + + errors = _validate_parameter_contract( + "Example", + _parameter_contract(released_callable), + _parameter_contract(changed_callable), + ) + + assert len(errors) == 1 + assert "changed its released positional parameter prefix" in errors[0] + + +def test_optional_dependency_for_module_import_uses_canonical_bindings() -> None: + contract = { + "required_submodule_exports": { + "agents.voice": { + "names": ["VoicePipeline"], + "optional_bindings": {"VoicePipeline": "numpy"}, + "optional_exports": {}, + } + }, + "canonical_imports": [ + { + "canonical_module": "agents.voice.pipeline", + "canonical_name": "VoicePipeline", + "module": "agents.voice", + "name": "VoicePipeline", + } + ], + } + + assert ( + contract_support._optional_dependency_for_module_import(contract, "agents.voice.pipeline") + == "numpy" + ) + assert ( + contract_support._optional_dependency_for_binding( + contract, "agents.voice.pipeline", "VoicePipeline" + ) + == "numpy" + ) + + def test_released_api_contract_fixture_matches_installed_version() -> None: contract = load_api_contract(CONTRACT) assert contract["baseline"] == f"v{version('openai-agents')}" @@ -255,12 +316,19 @@ def property_value(self) -> str: def test_curated_public_property_contract_detects_removed_or_changed_properties() -> None: - class ReleasedBase: + class ReleasedBase(metaclass=abc.ABCMeta): + @abc.abstractmethod + def base_requirement(self) -> None: + pass + @property def retained(self) -> str: return "value" class Released(ReleasedBase): + def base_requirement(self) -> None: + pass + @property def retained(self) -> str: return "value" @@ -293,21 +361,89 @@ def concrete_only(self) -> str: "agents.ReleasedBase.removed removed or changed a released public property" ] - Changed = type( - "Changed", - (ReleasedBase,), - {"retained": lambda self: "value"}, - ) + class Changed(ReleasedBase): + def base_requirement(self) -> None: + pass + + @abc.abstractmethod + def new_requirement(self) -> None: + pass + + with pytest.raises(TypeError): + cast(type[Any], Changed)() agents_module.Released = Changed assert _validate_public_property_contract(contract, agents_module) == [ "agents.ReleasedBase.removed removed or changed a released public property", - "agents.Released.retained removed or changed a released public property", "agents.Released.concrete_only removed or changed a released public property", ] +def test_curated_public_class_contract_detects_abstract_member_and_state_changes() -> None: + class ReleasedBase(metaclass=abc.ABCMeta): + @abc.abstractmethod + def base_requirement(self) -> None: + pass + + class Released(ReleasedBase): + def base_requirement(self) -> None: + pass + + contract: dict[str, Any] = { + "public_class_contracts": [ + { + "abstract_members": ["base_requirement"], + "class_name": "ReleasedBase", + "module": "agents", + }, + { + "abstract": False, + "class_name": "Released", + "module": "agents", + }, + ] + } + agents_module = SimpleNamespace(__all__=[], ReleasedBase=ReleasedBase, Released=Released) + + assert _validate_public_class_contract(contract, agents_module) == [] + + class ChangedBase(ReleasedBase): + @abc.abstractmethod + def new_requirement(self) -> None: + pass + + class Changed(ChangedBase): + def base_requirement(self) -> None: + pass + + def new_requirement(self) -> None: + pass + + class ExistingExternalSubclass(ChangedBase): + def base_requirement(self) -> None: + pass + + assert not inspect.isabstract(Changed) + with pytest.raises(TypeError): + cast(type[Any], ExistingExternalSubclass)() + + agents_module.ReleasedBase = ChangedBase + agents_module.Released = Changed + + assert _validate_public_class_contract(contract, agents_module) == [ + "agents.ReleasedBase changed its released public abstract members: expected " + "['base_requirement'], got ['base_requirement', 'new_requirement']" + ] + + agents_module.Released = ExistingExternalSubclass + assert _validate_public_class_contract(contract, agents_module) == [ + "agents.ReleasedBase changed its released public abstract members: expected " + "['base_requirement'], got ['base_requirement', 'new_requirement']", + "agents.Released changed its released public class state: expected concrete, got abstract", + ] + + def test_curated_public_property_contract_supports_factory_return_surfaces( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -341,6 +477,125 @@ def scripted_session() -> ScriptedSession: ] +def test_curated_public_type_alias_contract_records_and_validates_members( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class EventA: + pass + + class EventB: + pass + + EventA.__module__ = "agents.aliases" + EventA.__qualname__ = "EventA" + EventB.__module__ = "agents.aliases" + EventB.__qualname__ = "EventB" + + agents_module = SimpleNamespace(__all__=[]) + aliases_module = SimpleNamespace( + __all__=[], + PublicAlias=Literal["b", "a"] | EventB | EventA, + ) + modules = { + "agents": agents_module, + "agents.aliases": aliases_module, + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: modules[module_name], + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {"agents.aliases": {"optional_bindings": {}, "optional_exports": {}}}, + public_type_aliases=( + { + "module": "agents.aliases", + "name": "PublicAlias", + }, + ), + ), + ) + + assert updated["public_type_aliases"] == [ + { + "definition": { + "kind": "union", + "members": [ + { + "kind": "literal", + "values": [ + { + "kind": "literal", + "type": "builtins.str", + "value": "a", + }, + { + "kind": "literal", + "type": "builtins.str", + "value": "b", + }, + ], + }, + {"identity": "agents.aliases.EventA", "kind": "type"}, + {"identity": "agents.aliases.EventB", "kind": "type"}, + ], + }, + "module": "agents.aliases", + "name": "PublicAlias", + } + ] + + aliases_module.PublicAlias = Literal["a", "b"] | EventA | EventB + assert _validate_public_type_alias_contract(updated, agents_module) == [] + + aliases_module.PublicAlias = Literal["a"] | EventA + errors = _validate_public_type_alias_contract(updated, agents_module) + assert len(errors) == 1 + assert errors[0].startswith("agents.aliases.PublicAlias changed its released public type alias") + + aliases_module.PublicAlias = list[str] + with pytest.raises( + ValueError, + match=( + r"agents\.aliases\.PublicAlias no longer has a supported released public type alias " + r"definition: unsupported" + ), + ): + build_released_api_contract( + updated, + baseline="v0.20.1", + baseline_commit="c" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {"agents.aliases": {"optional_bindings": {}, "optional_exports": {}}}, + public_type_aliases=( + { + "module": "agents.aliases", + "name": "PublicAlias", + }, + ), + ), + ) + + def test_curated_public_typed_dict_contract_detects_field_shape_drift( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1685,6 +1940,13 @@ class PublicState(TypedDict, total=False): "name": "NewPublic", }, ), + public_class_contracts=( + { + "abstract": False, + "class_name": "NewPublic", + "module": "agents.submodule", + }, + ), public_properties=( { "class_name": "NewPublic", @@ -1727,6 +1989,13 @@ class PublicState(TypedDict, total=False): "names": ["calls"], }, ] + assert updated["public_class_contracts"] == [ + { + "abstract": False, + "class_name": "NewPublic", + "module": "agents.submodule", + } + ] assert updated["public_typed_dicts"] == [ { "class_name": "PublicState", @@ -2428,9 +2697,14 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: '{"ConditionalExport": "export_dependency"}}}, "optional_dependencies": ' '{"binding_dependency": {"requirement": "binding-package>=1"}, ' '"export_dependency": {"extra": "export-extra"}}, "public_properties": ' - '[{"class_name": "ConditionalExport", "module": "agents.submodule", ' - '"names": ["status"]}, {"factory_name": "create_client", ' - '"module": "agents.submodule", "names": ["calls"]}], "public_typed_dicts": ' + '[{"class_name": "ConditionalExport", ' + '"module": "agents.submodule", "names": ["status"]}, ' + '{"factory_name": "create_client", ' + '"module": "agents.submodule", "names": ["calls"]}], "public_class_contracts": ' + '[{"abstract": false, "class_name": "ConditionalExport", ' + '"module": "agents.submodule"}], "public_type_aliases": ' + '[{"module": "agents.submodule", "name": "PublicAlias"}], ' + '"public_typed_dicts": ' '[{"class_name": "ClientState", "module": "agents.submodule", ' '"names": ["status"]}]}', encoding="utf-8", @@ -2466,6 +2740,13 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: "name": "ConditionalExport", }, ) + assert policy.public_class_contracts == ( + { + "abstract": False, + "class_name": "ConditionalExport", + "module": "agents.submodule", + }, + ) assert tuple( entry for entry in policy.public_properties @@ -2488,6 +2769,12 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: "names": ["calls"], }, ) + assert policy.public_type_aliases == ( + { + "module": "agents.submodule", + "name": "PublicAlias", + }, + ) assert policy.public_typed_dicts == ( { "class_name": "ClientState", @@ -2497,6 +2784,23 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path: ) +def test_load_submodule_export_policy_rejects_invalid_class_abstract_state( + tmp_path: Path, +) -> None: + policy_path = tmp_path / "policy.json" + policy_path.write_text( + '{"modules": {}, "optional_dependencies": {}, "public_class_contracts": ' + '[{"abstract": "false", "class_name": "Released", "module": "agents"}]}', + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="public_class_contracts abstract must be a boolean", + ): + load_submodule_export_policy(policy_path) + + def test_load_submodule_export_policy_collects_unsupported_platforms(tmp_path: Path) -> None: policy_path = tmp_path / "policy.json" policy_path.write_text( @@ -2544,6 +2848,10 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: "agents.testing.model", "agents.testing.sandbox", "agents.realtime.testing", + "agents.voice.model", + "agents.voice.models.openai_model_provider", + "agents.voice.models.openai_stt", + "agents.voice.models.openai_tts", "agents.voice.testing", } ) == ( @@ -2580,23 +2888,56 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None: ) -def test_repository_release_policy_declares_public_testing_modules() -> None: +def test_repository_release_policy_declares_public_optional_modules() -> None: policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) - expected_modules = { + documented_voice_modules = { + "agents.voice.events", + "agents.voice.exceptions", + "agents.voice.input", + "agents.voice.imports", + "agents.voice.model", + "agents.voice.models.openai_model_provider", + "agents.voice.models.openai_stt", + "agents.voice.models.openai_tts", + "agents.voice.pipeline", + "agents.voice.pipeline_config", + "agents.voice.result", + "agents.voice.testing", + "agents.voice.utils", + "agents.voice.workflow", + } + expected_modules = documented_voice_modules | { "agents.realtime.testing", "agents.testing", "agents.testing.model", "agents.testing.sandbox", - "agents.voice.testing", + "agents.voice", } - assert expected_modules <= policy.modules.keys() - assert policy.modules["agents.voice.testing"] == { - "optional_bindings": { - export: "numpy" for export in importlib.import_module("agents.voice.testing").__all__ - }, - "optional_exports": {}, + documented_directive_modules = { + line.removeprefix("::: ") + for path in (CONTRACT.parents[2] / "docs" / "ref" / "voice").rglob("*.md") + for line in path.read_text(encoding="utf-8").splitlines() + if line.startswith("::: agents.voice") } + + assert documented_directive_modules == documented_voice_modules + assert expected_modules <= policy.modules.keys() + for module_name in documented_voice_modules - { + "agents.voice.imports", + "agents.voice.testing", + }: + assert policy.modules[module_name] == { + "optional_bindings": {}, + "optional_exports": {}, + } + for module_name in ("agents.voice", "agents.voice.imports", "agents.voice.testing"): + assert policy.modules[module_name] == { + "optional_bindings": { + export: "numpy" for export in importlib.import_module(module_name).__all__ + }, + "optional_exports": {}, + } assert ( next( installation @@ -2607,7 +2948,7 @@ def test_repository_release_policy_declares_public_testing_modules() -> None: ) -def test_repository_release_policy_declares_public_testing_state_surfaces() -> None: +def test_repository_release_policy_declares_public_state_surfaces() -> None: policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) expected_modules = { "agents.realtime.testing", @@ -2659,6 +3000,133 @@ def test_repository_release_policy_declares_public_testing_state_surfaces() -> N "names": ["transcriptions"], }, ) + assert tuple( + entry + for entry in policy.public_properties + if entry["module"] + in { + "agents.voice.model", + "agents.voice.models.openai_model_provider", + "agents.voice.models.openai_stt", + "agents.voice.models.openai_tts", + } + ) == ( + { + "class_name": "STTModel", + "module": "agents.voice.model", + "names": ["model_name"], + }, + { + "class_name": "TTSModel", + "module": "agents.voice.model", + "names": ["model_name"], + }, + { + "class_name": "OpenAISTTModel", + "module": "agents.voice.models.openai_stt", + "names": ["model_name"], + }, + { + "class_name": "OpenAITTSModel", + "module": "agents.voice.models.openai_tts", + "names": ["model_name"], + }, + { + "class_name": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider", + "names": ["agent_registration"], + }, + ) + assert policy.public_class_contracts == ( + { + "class_name": "STTModel", + "module": "agents.voice.model", + "abstract_members": ["create_session", "model_name", "transcribe"], + }, + { + "class_name": "StreamedTranscriptionSession", + "module": "agents.voice.model", + "abstract_members": ["close", "transcribe_turns"], + }, + { + "class_name": "TTSModel", + "module": "agents.voice.model", + "abstract_members": ["model_name", "run"], + }, + { + "class_name": "VoiceModelProvider", + "module": "agents.voice.model", + "abstract_members": ["get_stt_model", "get_tts_model"], + }, + { + "abstract": False, + "class_name": "OpenAISTTModel", + "module": "agents.voice.models.openai_stt", + }, + { + "abstract": False, + "class_name": "OpenAISTTTranscriptionSession", + "module": "agents.voice.models.openai_stt", + }, + { + "abstract": False, + "class_name": "OpenAITTSModel", + "module": "agents.voice.models.openai_tts", + }, + { + "class_name": "VoiceWorkflowBase", + "module": "agents.voice.workflow", + "abstract_members": ["run"], + }, + ) + assert policy.public_type_aliases == ( + { + "module": "agents.voice.model", + "name": "TTSVoice", + }, + { + "module": "agents.voice.events", + "name": "VoiceStreamEvent", + }, + ) + type_aliases: dict[tuple[str, str], dict[str, Any]] = { + (cast(str, entry["module"]), cast(str, entry["name"])): cast( + dict[str, Any], entry["definition"] + ) + for entry in contract_support._public_type_alias_contract(policy.public_type_aliases, None) + } + tts_voice = type_aliases[("agents.voice.model", "TTSVoice")] + assert tts_voice["kind"] == "union" + assert [ + value["value"] + for member in tts_voice["members"] + if member["kind"] == "literal" + for value in member["values"] + ] == [ + "alloy", + "ash", + "ballad", + "cedar", + "coral", + "echo", + "fable", + "marin", + "nova", + "onyx", + "sage", + "shimmer", + "verse", + ] + assert {member["identity"] for member in tts_voice["members"] if member["kind"] == "type"} == { + "agents.voice.model.TTSCustomVoice" + } + voice_stream_event = type_aliases[("agents.voice.events", "VoiceStreamEvent")] + assert voice_stream_event["kind"] == "union" + assert {member["identity"] for member in voice_stream_event["members"]} == { + "agents.voice.events.VoiceStreamEventAudio", + "agents.voice.events.VoiceStreamEventError", + "agents.voice.events.VoiceStreamEventLifecycle", + } assert policy.public_typed_dicts == ( { "class_name": "ModelStepSpec", @@ -2692,6 +3160,11 @@ def test_repository_release_policy_declares_public_testing_state_surfaces() -> N "call_id", ], }, + { + "class_name": "TTSCustomVoice", + "module": "agents.voice", + "names": ["id"], + }, ) for module_name in expected_modules: module = importlib.import_module(module_name) @@ -2722,6 +3195,56 @@ def test_repository_release_policy_declares_public_testing_state_surfaces() -> N canonical_module = importlib.import_module(canonical_module_name) assert getattr(module, name) is getattr(canonical_module, canonical_name) + voice_canonical_modules = { + "AudioInput": "agents.voice.input", + "StreamedAudioInput": "agents.voice.input", + "STTModel": "agents.voice.model", + "STTModelSettings": "agents.voice.model", + "TTSCustomVoice": "agents.voice.model", + "TTSModel": "agents.voice.model", + "TTSModelSettings": "agents.voice.model", + "TTSVoice": "agents.voice.model", + "VoiceModelProvider": "agents.voice.model", + "StreamedAudioResult": "agents.voice.result", + "SingleAgentVoiceWorkflow": "agents.voice.workflow", + "OpenAIVoiceModelProvider": "agents.voice.models.openai_model_provider", + "OpenAISTTModel": "agents.voice.models.openai_stt", + "OpenAITTSModel": "agents.voice.models.openai_tts", + "VoiceStreamEventAudio": "agents.voice.events", + "VoiceStreamEventError": "agents.voice.events", + "VoiceStreamEventLifecycle": "agents.voice.events", + "VoiceStreamEvent": "agents.voice.events", + "VoicePipeline": "agents.voice.pipeline", + "VoicePipelineConfig": "agents.voice.pipeline_config", + "get_sentence_based_splitter": "agents.voice.utils", + "VoiceWorkflowHelper": "agents.voice.workflow", + "VoiceWorkflowBase": "agents.voice.workflow", + "SingleAgentWorkflowCallbacks": "agents.voice.workflow", + "StreamedTranscriptionSession": "agents.voice.model", + "OpenAISTTTranscriptionSession": "agents.voice.models.openai_stt", + "STTWebsocketConnectionError": "agents.voice.exceptions", + } + expected_voice_canonical_imports = { + ("agents.voice", name, canonical_module_name, name) + for name, canonical_module_name in voice_canonical_modules.items() + } + actual_voice_canonical_imports = { + ( + entry["module"], + entry["name"], + entry["canonical_module"], + entry["canonical_name"], + ) + for entry in policy.canonical_imports + if entry["module"] == "agents.voice" + } + + assert actual_voice_canonical_imports == expected_voice_canonical_imports + for module_name, name, canonical_module_name, canonical_name in actual_voice_canonical_imports: + module = importlib.import_module(module_name) + canonical_module = importlib.import_module(canonical_module_name) + assert getattr(module, name) is getattr(canonical_module, canonical_name) + def test_voice_testing_start_sentinel_has_stable_contract_identity() -> None: from agents.voice.testing import _START_NOT_CONFIGURED From 3e6715573d9cb3d3a55c10620bdb0409c182a9cf Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:20:26 +0100 Subject: [PATCH 396/473] fix(mcp): clear active MCP servers after cleanup (#4586) --- src/agents/mcp/manager.py | 46 ++++++++++--------- .../test_mcp_server_manager_cleanup_state.py | 45 ++++++++++++++++++ 2 files changed, 70 insertions(+), 21 deletions(-) create mode 100644 tests/mcp/test_mcp_server_manager_cleanup_state.py diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index de19fd7494..94df096d04 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -367,25 +367,28 @@ async def _acquire_lifecycle_lock(self) -> bool: return True async def _cleanup_all(self) -> None: - for server in reversed(self._all_servers): - try: - await self._cleanup_server(server) - except asyncio.CancelledError as exc: - if not self.suppress_cancelled_error: - raise - log_tool_action_debug( - logger, - get_mcp_server_log_message("Cleanup cancelled for MCP server", server), - exc, - ) - self._errors[server] = exc - except Exception as exc: - log_tool_action_error( - logger, - get_mcp_server_log_message("Failed to cleanup MCP server", server), - exc, - ) - self._errors[server] = exc + try: + for server in reversed(self._all_servers): + try: + await self._cleanup_server(server) + except asyncio.CancelledError as exc: + if not self.suppress_cancelled_error: + raise + log_tool_action_debug( + logger, + get_mcp_server_log_message("Cleanup cancelled for MCP server", server), + exc, + ) + self._errors[server] = exc + except Exception as exc: + log_tool_action_error( + logger, + get_mcp_server_log_message("Failed to cleanup MCP server", server), + exc, + ) + self._errors[server] = exc + finally: + self._refresh_active_servers() async def _run_with_timeout( self, func: Callable[[], Awaitable[Any]], timeout_seconds: float | None @@ -420,8 +423,9 @@ async def _attempt_connect( def _refresh_active_servers(self) -> None: if self.drop_failed_servers: - failed = set(self._failed_server_set) - self._active_servers = [server for server in self._all_servers if server not in failed] + self._active_servers = [ + server for server in self._all_servers if server in self._connected_servers + ] else: self._active_servers = list(self._all_servers) diff --git a/tests/mcp/test_mcp_server_manager_cleanup_state.py b/tests/mcp/test_mcp_server_manager_cleanup_state.py new file mode 100644 index 0000000000..bf6c82ef65 --- /dev/null +++ b/tests/mcp/test_mcp_server_manager_cleanup_state.py @@ -0,0 +1,45 @@ +import asyncio +from typing import cast +from unittest.mock import AsyncMock, Mock + +import pytest + +from agents.mcp import MCPServer, MCPServerManager + + +@pytest.mark.asyncio +async def test_cleanup_all_removes_cleaned_servers_from_active_servers() -> None: + server = cast(MCPServer, Mock(spec=MCPServer)) + server.connect = AsyncMock() + server.cleanup = AsyncMock() + + manager = MCPServerManager([server]) + assert await manager.connect_all() == [server] + + await manager.cleanup_all() + + assert manager.active_servers == [] + assert manager._connected_servers == set() + + assert await manager.reconnect() == [] + assert manager.active_servers == [] + assert server.connect.await_count == 1 + + assert await manager.connect_all() == [server] + assert server.connect.await_count == 2 + + +@pytest.mark.asyncio +async def test_cleanup_all_refreshes_active_servers_when_cancellation_propagates() -> None: + server = cast(MCPServer, Mock(spec=MCPServer)) + server.connect = AsyncMock() + server.cleanup = AsyncMock(side_effect=asyncio.CancelledError) + + manager = MCPServerManager([server], suppress_cancelled_error=False) + assert await manager.connect_all() == [server] + + with pytest.raises(asyncio.CancelledError): + await manager.cleanup_all() + + assert manager.active_servers == [] + assert manager._connected_servers == set() From 7f7a44f8dc0650296bd5ab6c745c9bcbaa6ac3b7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 22 Aug 2026 22:27:36 +0900 Subject: [PATCH 397/473] docs: updated heading anchors in translated pages --- docs/ja/agents.md | 28 ++++---- docs/ja/config.md | 14 ++-- docs/ja/context.md | 8 +-- docs/ja/examples.md | 2 +- docs/ja/guardrails.md | 14 ++-- docs/ja/handoffs.md | 14 ++-- docs/ja/human_in_the_loop.md | 18 ++--- docs/ja/index.md | 12 ++-- docs/ja/mcp.md | 50 +++++++------- docs/ja/models/index.md | 74 ++++++++++----------- docs/ja/multi_agent.md | 8 +-- docs/ja/quickstart.md | 26 ++++---- docs/ja/realtime/guide.md | 38 +++++------ docs/ja/realtime/quickstart.md | 22 +++--- docs/ja/realtime/transport.md | 12 ++-- docs/ja/release.md | 50 +++++++------- docs/ja/results.md | 28 ++++---- docs/ja/running_agents.md | 70 +++++++++---------- docs/ja/sandbox/clients.md | 12 ++-- docs/ja/sandbox/guide.md | 66 +++++++++--------- docs/ja/sandbox/memory.md | 10 +-- docs/ja/sandbox_agents.md | 10 +-- docs/ja/sessions/advanced_sqlite_session.md | 40 +++++------ docs/ja/sessions/encrypted_session.md | 24 +++---- docs/ja/sessions/index.md | 66 +++++++++--------- docs/ja/sessions/sqlalchemy_session.md | 12 ++-- docs/ja/streaming.md | 10 +-- docs/ja/testing.md | 50 +++++++------- docs/ja/tools.md | 44 ++++++------ docs/ja/tracing.md | 24 +++---- docs/ja/usage.md | 18 ++--- docs/ja/visualization.md | 14 ++-- docs/ja/voice/pipeline.md | 10 +-- docs/ja/voice/quickstart.md | 12 ++-- docs/ko/agents.md | 28 ++++---- docs/ko/config.md | 14 ++-- docs/ko/context.md | 8 +-- docs/ko/examples.md | 2 +- docs/ko/guardrails.md | 14 ++-- docs/ko/handoffs.md | 14 ++-- docs/ko/human_in_the_loop.md | 18 ++--- docs/ko/index.md | 12 ++-- docs/ko/mcp.md | 50 +++++++------- docs/ko/models/index.md | 74 ++++++++++----------- docs/ko/multi_agent.md | 8 +-- docs/ko/quickstart.md | 26 ++++---- docs/ko/realtime/guide.md | 38 +++++------ docs/ko/realtime/quickstart.md | 22 +++--- docs/ko/realtime/transport.md | 12 ++-- docs/ko/release.md | 50 +++++++------- docs/ko/results.md | 28 ++++---- docs/ko/running_agents.md | 70 +++++++++---------- docs/ko/sandbox/clients.md | 12 ++-- docs/ko/sandbox/guide.md | 66 +++++++++--------- docs/ko/sandbox/memory.md | 10 +-- docs/ko/sandbox_agents.md | 10 +-- docs/ko/sessions/advanced_sqlite_session.md | 40 +++++------ docs/ko/sessions/encrypted_session.md | 24 +++---- docs/ko/sessions/index.md | 66 +++++++++--------- docs/ko/sessions/sqlalchemy_session.md | 12 ++-- docs/ko/streaming.md | 10 +-- docs/ko/testing.md | 50 +++++++------- docs/ko/tools.md | 44 ++++++------ docs/ko/tracing.md | 24 +++---- docs/ko/usage.md | 18 ++--- docs/ko/visualization.md | 14 ++-- docs/ko/voice/pipeline.md | 10 +-- docs/ko/voice/quickstart.md | 12 ++-- docs/zh/agents.md | 28 ++++---- docs/zh/config.md | 14 ++-- docs/zh/context.md | 8 +-- docs/zh/examples.md | 2 +- docs/zh/guardrails.md | 14 ++-- docs/zh/handoffs.md | 14 ++-- docs/zh/human_in_the_loop.md | 18 ++--- docs/zh/index.md | 12 ++-- docs/zh/mcp.md | 50 +++++++------- docs/zh/models/index.md | 74 ++++++++++----------- docs/zh/multi_agent.md | 8 +-- docs/zh/quickstart.md | 26 ++++---- docs/zh/realtime/guide.md | 38 +++++------ docs/zh/realtime/quickstart.md | 22 +++--- docs/zh/realtime/transport.md | 12 ++-- docs/zh/release.md | 50 +++++++------- docs/zh/results.md | 28 ++++---- docs/zh/running_agents.md | 70 +++++++++---------- docs/zh/sandbox/clients.md | 12 ++-- docs/zh/sandbox/guide.md | 66 +++++++++--------- docs/zh/sandbox/memory.md | 10 +-- docs/zh/sandbox_agents.md | 10 +-- docs/zh/sessions/advanced_sqlite_session.md | 40 +++++------ docs/zh/sessions/encrypted_session.md | 24 +++---- docs/zh/sessions/index.md | 66 +++++++++--------- docs/zh/sessions/sqlalchemy_session.md | 12 ++-- docs/zh/streaming.md | 10 +-- docs/zh/testing.md | 50 +++++++------- docs/zh/tools.md | 44 ++++++------ docs/zh/tracing.md | 24 +++---- docs/zh/usage.md | 18 ++--- docs/zh/visualization.md | 14 ++-- docs/zh/voice/pipeline.md | 10 +-- docs/zh/voice/quickstart.md | 12 ++-- 102 files changed, 1365 insertions(+), 1365 deletions(-) diff --git a/docs/ja/agents.md b/docs/ja/agents.md index 38ec1b158c..8ab54d9a03 100644 --- a/docs/ja/agents.md +++ b/docs/ja/agents.md @@ -10,7 +10,7 @@ search: SDK は、OpenAI モデルに対してデフォルトで Responses API を使用しますが、ここでの違いはオーケストレーションにあります。`Agent` と `Runner` を組み合わせることで、SDK がターン、ツール、ガードレール、ハンドオフ、セッションを管理します。このループを自分で管理したい場合は、代わりに Responses API を直接使用してください。 -## 次のガイドの選択 +## 次のガイドの選択 {#choose-the-next-guide} このページを、エージェント定義のハブとして使用してください。次に行う必要がある判断に合った関連ガイドに進んでください。 @@ -25,7 +25,7 @@ SDK は、OpenAI モデルに対してデフォルトで Responses API を使用 | 最終出力、実行項目、または再開可能な状態を確認する | [実行結果](results.md) | | ローカルの依存関係とランタイム状態を共有する | [コンテキスト管理](context.md) | -## 基本設定 +## 基本設定 {#basic-configuration} エージェントで最も一般的なプロパティは次のとおりです。 @@ -67,7 +67,7 @@ agent = Agent( このセクションの内容はすべて `Agent` に適用されます。`SandboxAgent` は同じ考え方を基盤とし、さらにワークスペース単位の実行用に `default_manifest`、`base_instructions`、`capabilities`、`run_as` を追加します。[サンドボックスエージェントの概念](sandbox/guide.md)を参照してください。 -## プロンプトテンプレート +## プロンプトテンプレート {#prompt-templates} `prompt` を設定すると、OpenAI プラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API を介して OpenAI モデルにアクセスする場合に機能します。 @@ -126,7 +126,7 @@ result = await Runner.run( ) ``` -## コンテキスト +## コンテキスト {#context} エージェントは `context` 型に対してジェネリックです。コンテキストは依存性注入のためのツールです。コンテキストは、自分で作成して `Runner.run()` に渡すオブジェクトであり、すべてのエージェント、ツール、ハンドオフなどに渡されます。また、エージェント実行に必要な依存関係や状態をまとめて保持します。任意の Python オブジェクトをコンテキストとして指定できます。 @@ -154,7 +154,7 @@ agent = Agent[UserContext]( ) ``` -## 出力型 +## 出力型 {#output-types} デフォルトでは、エージェントはプレーンテキスト (つまり `str`) の出力を生成します。エージェントに特定の型の出力を生成させる場合は、`output_type` パラメーターを使用できます。一般的には [Pydantic](https://docs.pydantic.dev/) オブジェクトを使用しますが、Pydantic の [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) でラップできる任意の型をサポートしています。たとえば、データクラス、リスト、TypedDict などです。 @@ -179,7 +179,7 @@ agent = Agent( `output_type` を渡すと、通常のプレーンテキスト応答ではなく [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) を使用するようモデルに指示します。 -## マルチエージェントシステムの設計パターン +## マルチエージェントシステムの設計パターン {#multi-agent-system-design-patterns} マルチエージェントシステムを設計する方法は多数ありますが、一般的に幅広く適用できる次の 2 つのパターンがよく見られます。 @@ -188,7 +188,7 @@ agent = Agent( 詳細については、[エージェント構築の実践ガイド](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)を参照してください。 -### マネージャー (agents as tools) +### マネージャー (agents as tools) {#manager-agents-as-tools} `customer_facing_agent` はすべてのユーザー操作を処理し、ツールとして公開された専門のサブエージェントを呼び出します。詳細については、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 @@ -217,7 +217,7 @@ customer_facing_agent = Agent( ) ``` -### ハンドオフ +### ハンドオフ {#handoffs} 設定されたハンドオフ先は、エージェントが処理を委任できるサブエージェントです。ハンドオフが発生すると、委任先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに優れたモジュール式の専門エージェントを構築できます。詳細については、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 @@ -238,7 +238,7 @@ triage_agent = Agent( ) ``` -## 動的な指示 +## 動的な指示 {#dynamic-instructions} ほとんどの場合、エージェントの作成時に指示を指定できます。ただし、関数を介して動的な指示を指定することもできます。この関数はエージェントとコンテキストを受け取り、プロンプトを返す必要があります。通常の関数と `async` 関数の両方を使用できます。 @@ -257,7 +257,7 @@ agent = Agent[UserContext]( ) ``` -## ライフサイクルイベント (フック) +## ライフサイクルイベント (フック) {#lifecycle-events-hooks} エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生したときに、イベントのログ記録、データの事前取得、使用量の記録を行いたい場合があります。 @@ -302,11 +302,11 @@ print(result.final_output) コールバックの全機能については、[Lifecycle API リファレンス](ref/lifecycle.md)を参照してください。 -## ガードレール +## ガードレール {#guardrails} ガードレールを使用すると、エージェントの実行と並行してユーザー入力に対するチェック/検証を実行し、エージェントの出力が生成された後にその出力をチェックできます。たとえば、ユーザー入力とエージェント出力が関連性のある内容かどうかを審査できます。詳細については、[ガードレール](guardrails.md)のドキュメントを参照してください。 -## エージェントの複製/コピー +## エージェントの複製/コピー {#cloningcopying-agents} エージェントの `clone()` メソッドを使用すると、エージェントを複製し、必要に応じて任意のプロパティを変更できます。 @@ -325,7 +325,7 @@ robot_agent = pirate_agent.clone( `clone()` は `dataclasses.replace` を使用するため、シャローコピーを実行します。`tools`、`handoffs`、`mcp_servers`、`input_guardrails`、`output_guardrails` など、上書きしないリスト属性は、元のエージェントが保持するものとまったく同じリストのままです。したがって、どちらかのエージェントを介してそのリストを変更すると、両方のエージェントに影響します。クローンに独立したリストコンテナーを持たせるには、たとえば `pirate_agent.clone(tools=[*pirate_agent.tools, extra_tool])` のように新しいリストを渡します。その新しいリストにコピーされた項目は、それらの項目も置き換えない限り、同じツールまたはハンドオフオブジェクトのままです。 -## ツール使用の強制 +## ツール使用の強制 {#forcing-tool-use} ツールのリストを指定しても、LLM が必ずツールを使用するとは限りません。[`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] を設定することで、ツールの使用を強制できます。有効な値は次のとおりです。 @@ -353,7 +353,7 @@ agent = Agent( ) ``` -## ツール使用時の動作 +## ツール使用時の動作 {#tool-use-behavior} `Agent` 設定の `tool_use_behavior` パラメーターは、ツール出力の処理方法を制御します。 diff --git a/docs/ja/config.md b/docs/ja/config.md index 9d28c1252d..6c87885174 100644 --- a/docs/ja/config.md +++ b/docs/ja/config.md @@ -16,7 +16,7 @@ search: - [モデル](models/index.md):モデルの選択とプロバイダーの設定。 - [トレーシング](tracing.md):実行ごとのトレーシングメタデータとカスタムトレースプロセッサー。 -## 設定オブジェクトと辞書 +## 設定オブジェクトと辞書 {#configuration-objects-and-dictionaries} SDK で定義されている設定パラメーターは、通常、型付きの設定オブジェクト、または同じフィールドを含む辞書のいずれかを受け付けます。これは、型注釈に辞書が含まれる、エージェント、実行、モデル、セッション、サンドボックス、音声の各設定境界に適用されます。SDK で定義されたネストされた設定型でも辞書を使用できます。 @@ -35,7 +35,7 @@ agent = Agent( SDK はこれらの辞書を、対応する設定オブジェクトへ正規化します。SDK で定義されたデータクラス設定型に不明なフィールドがあると `TypeError` が発生するため、スペルを誤ったオプション名を早期に検出できます。特定の境界が辞書を受け付けるかどうかは、そのパラメーターの型注釈または API リファレンスで確認してください。 -## API キーとクライアント +## API キーとクライアント {#api-keys-and-clients} デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。キーは、SDK が最初に OpenAI クライアントを作成するときに解決されるため(遅延初期化)、最初のモデル呼び出しより前に環境変数を設定してください。アプリの起動前にその環境変数を設定できない場合は、[set_default_openai_key()][agents.set_default_openai_key] 関数を使用してキーを設定できます。 @@ -57,7 +57,7 @@ set_default_openai_client(custom_client) 明示的なクライアントを [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider] に渡すと、そのクライアントが接続とアカウントの設定を管理します。`api_key`、`base_url`、`websocket_base_url`、`organization`、`project` を `OpenAIProvider` に同時に渡さないでください。`openai_client` とこれらの引数のいずれかを組み合わせると、重複する値が暗黙に無視されるのではなく、[`UserError`][agents.exceptions.UserError] が発生します。目的の値は `AsyncOpenAI` の構築時に設定してください。 -### `openai` v3 でのカスタム HTTP クライアント +### `openai` v3 でのカスタム HTTP クライアント {#custom-http-clients-with-openai-v3} バージョン 0.21.0 では `openai>=3.0.0,<4` が必要です。デフォルトの OpenAI プロバイダーは HTTPX2 を使用するため、ほとんどのアプリケーションでは HTTP クライアントを直接設定する必要はありません。アプリケーションが `http_client=` を `AsyncOpenAI` に渡す場合は、カスタムクライアントとそのトランスポート向けオプションに HTTPX2 型を使用してください。 @@ -96,7 +96,7 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI プロバイダーのデフォルト設定 +## OpenAI プロバイダーのデフォルト設定 {#openai-provider-defaults} SDK の OpenAI バックエンドを使用するプロバイダーも、モデル名の文字列をモデルにマッピングする際に SDK 全体のデフォルト設定を読み取ります。OpenAI Responses モデルで WebSocket トランスポートをデフォルトで使用するには、[`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] を使用します。 @@ -128,7 +128,7 @@ set_default_openai_agent_registration( SDK のデフォルトが設定されていない場合、SDK の OpenAI バックエンドを使用するプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、`RunConfig.trace_metadata` にそのキーがすでに存在しない限り、SDK はそれを `agent_harness_id` としてトレースメタデータに追加します。 -## トレーシング +## トレーシング {#tracing} トレーシングはデフォルトで有効です。デフォルトでは、上記のセクションにあるモデルリクエストと同じ OpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。トレーシングに使用する API キーを個別に設定するには、[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用します。 @@ -200,7 +200,7 @@ export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 トレーシングのすべての制御項目については、[トレーシングガイド](tracing.md)を参照してください。 -## デバッグログ +## デバッグログ {#debug-logging} SDK は 2 つの Python ロガー(`openai.agents` と `openai.agents.tracing`)を定義しますが、デフォルトではハンドラーを追加しません。ログには、アプリケーションの Python ログ設定が適用されます。 @@ -231,7 +231,7 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### ログと診断における機密データ +### ログと診断における機密データ {#sensitive-data-in-logs-and-diagnostics} 一部のログと診断例外には、機密データ(モデルまたはツールの入力と出力など)が含まれる場合があります。 diff --git a/docs/ja/context.md b/docs/ja/context.md index 99d52f9d76..b9486858ba 100644 --- a/docs/ja/context.md +++ b/docs/ja/context.md @@ -9,7 +9,7 @@ search: 1. コードからローカルに利用できるコンテキスト: ツール関数の実行時、`on_handoff` などのコールバック時、ライフサイクルフック内などで必要となる可能性があるデータや依存関係です。 2. LLM が利用できるコンテキスト: LLM が応答を生成するときに参照するデータです。 -## ローカルコンテキスト +## ローカルコンテキスト {#local-context} これは、[`RunContextWrapper`][agents.run_context.RunContextWrapper] クラスと、そのクラス内の [`context`][agents.run_context.RunContextWrapper.context] プロパティによって表されます。仕組みは次のとおりです。 @@ -33,7 +33,7 @@ search: 単一の実行内では、派生したラッパーは基盤となるアプリコンテキスト、承認状態、使用量追跡を共有します。ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行では、別の `tool_input` を関連付けることができますが、デフォルトではアプリ状態の独立したコピーは作成されません。 -### `RunContextWrapper` の公開情報 +### `RunContextWrapper` の公開情報 {#what-runcontextwrapper-exposes} [`RunContextWrapper`][agents.run_context.RunContextWrapper] は、アプリで定義したコンテキストオブジェクトのラッパーです。実際には、主に次のものを使用します。 @@ -94,7 +94,7 @@ if __name__ == "__main__": --- -### 高度な機能: `ToolContext` +### 高度な機能: `ToolContext` {#advanced-toolcontext} 場合によっては、実行中のツールについて、その名前、呼び出し ID、raw 引数文字列などの追加メタデータにアクセスしたいことがあります。 その場合は、`RunContextWrapper` を拡張する [`ToolContext`][agents.tool_context.ToolContext] クラスを使用できます。 @@ -140,7 +140,7 @@ agent = Agent( --- -## エージェント / LLM コンテキスト +## エージェント / LLM コンテキスト {#agentllm-context} LLM が呼び出されたとき、LLM が参照できるのは会話履歴に含まれるデータ **だけ** です。つまり、新しいデータを LLM から利用可能にするには、その履歴に含まれる形で提供する必要があります。これには、次のような方法があります。 diff --git a/docs/ja/examples.md b/docs/ja/examples.md index 0320e0ae9d..4332a20adc 100644 --- a/docs/ja/examples.md +++ b/docs/ja/examples.md @@ -6,7 +6,7 @@ search: SDK を使用したさまざまなサンプル実装は、[リポジトリ](https://github.com/openai/openai-agents-python/tree/main/examples)の examples セクションで確認できます。コード例は、さまざまなパターンや機能を示す複数のカテゴリーに分かれています。 -## カテゴリー +## カテゴリー {#categories} - **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** このカテゴリーのコード例では、次のような一般的なエージェント設計パターンを示します。 diff --git a/docs/ja/guardrails.md b/docs/ja/guardrails.md index 88b580fa74..59e0d55f2d 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -11,7 +11,7 @@ search: 1. 入力ガードレールは、最初のユーザー入力に対して実行されます 2. 出力ガードレールは、最終的なエージェント出力に対して実行されます -## ワークフローの境界 +## ワークフローの境界 {#workflow-boundaries} ガードレールはエージェントとツールに関連付けられますが、ワークフロー内ですべてが同じ時点に実行されるわけではありません。 @@ -21,7 +21,7 @@ search: マネージャー、ハンドオフ、または委任されたスペシャリストを含むワークフローで、カスタム関数ツールの各呼び出しの前後いずれか、または両方でチェックが必要な場合は、エージェントレベルの入力 / 出力ガードレールだけに依存せず、ツールガードレールを使用してください。 -## 入力ガードレール +## 入力ガードレール {#input-guardrails} 入力ガードレールは、次の 3 ステップで実行されます。 @@ -33,7 +33,7 @@ search: 入力ガードレールはユーザー入力に対して実行することを目的としているため、エージェントのガードレールは、そのエージェントが *最初の* エージェントである場合にのみ実行されます。なぜ `guardrails` プロパティが `Runner.run` に渡されるのではなく、エージェントに設定されているのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 -### 実行モード +### 実行モード {#execution-modes} 入力ガードレールは、次の 2 つの実行モードをサポートしています。 @@ -41,7 +41,7 @@ search: - **ブロッキング実行** (`run_in_parallel=False`): ガードレールは、エージェントが開始する *前に* 実行されて完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防止できます。これは、コストの最適化や、ツール呼び出しによる潜在的な副作用を回避したい場合に適しています。 -## 出力ガードレール +## 出力ガードレール {#output-guardrails} 出力ガードレールは、次の 3 ステップで実行されます。 @@ -59,7 +59,7 @@ search: 終端となる関数ツールの出力については、エージェントレベルの出力ガードレールが値を確認する前にツールがすでに実行されているため、追加の処理が必要です。[`Agent.tool_use_behavior`][agents.agent.Agent.tool_use_behavior] によってそのツールの実行結果が最終出力となり、出力トリップワイヤーがそれを拒否した場合、SDK は検証済みフィールドから関数呼び出し / 出力のペアを再構築できる場合に限り、再実行可能な有効なペアを保持します。保持される `function_call_output` ペイロードは、固定テキスト `"Output withheld by an output guardrail."` に置き換えられます。元のツール出力ペイロードは、セッション、`RunState`、ストリーミングされた実行結果の状態、サンドボックスのメモリ入力のいずれにも保持されません。SDK は、関数の引数など、再実行に必要な検証済みの関数呼び出しメタデータを保持するため、そのメタデータには拒否された出力にも含まれていたデータが含まれる可能性があります。現在のレスポンスの [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] オブジェクトでも、`agent_output` は固定テキストに置き換えられ、`output_info` はクリアされます。現在のレスポンスの [`ToolOutputGuardrailResult`][agents.tool_guardrails.ToolOutputGuardrailResult] オブジェクトでは、許可 / 拒否の動作タイプは保持されますが、ペイロードを含む `output_info` と拒否メッセージは同じテキストに置き換えられます。それ以前に受け入れられたターンとガードレールの実行結果は変更されません。レスポンスに推論や、SDK が安全にサニタイズできない別の形式が含まれている場合、SDK は拒否された出力ペイロードを保持する代わりに、現在のレスポンスのサフィックス全体を破棄します。例外を発生させたガードレール関数は拒否判定を返していないため、完了済みの終端ツールのターンには、前述の例外発生時の永続化動作が適用されます。 -## ツールガードレール +## ツールガードレール {#tool-guardrails} ツールガードレールは **`FunctionTool` インスタンス** をラップし、それらのツールの呼び出しを実行前後に検証またはブロックできるようにします。ツール自体に設定され、そのツールが呼び出されるたびに実行されます。 @@ -70,7 +70,7 @@ search: 詳細については、以下のコードスニペットを参照してください。 -## トリップワイヤー +## トリップワイヤー {#tripwires} エージェントの入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーでそのことを通知できます。ランナーは即座に `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を発生させ、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外が使用されます。 @@ -78,7 +78,7 @@ search: 一方、ツールのトリップワイヤー例外では、作動の原因となった `guardrail` と `output` が直接公開されます。これらの `run_data.tool_input_guardrail_results` および `run_data.tool_output_guardrail_results` リストには、失敗前の完了済みターンから蓄積された実行結果が保持されます。作動の原因となった実行結果は、例外の `output` から取得できます。`MaxTurnsExceeded` など、ランナーが管理するその他の失敗でも、完了済みのツールガードレールの実行結果がこれらのリストに保持されます。`stream_events()` が例外を発生させた後、ストリーミングされた実行結果では、同じ累積済みのエージェントおよびツールガードレールの実行結果リストが公開されます。ランナーが管理する実行パスの外部で例外が発生した場合、`run_data` は `None` になることがあります。 -## ガードレールの実装 +## ガードレールの実装 {#implementing-a-guardrail} 入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することで実装します。 diff --git a/docs/ja/handoffs.md b/docs/ja/handoffs.md index 5adf89bf29..073fb6c013 100644 --- a/docs/ja/handoffs.md +++ b/docs/ja/handoffs.md @@ -8,7 +8,7 @@ search: ハンドオフは、LLM に対してツールとして表現されます。そのため、`Refund Agent` という名前のエージェントへのハンドオフがある場合、ツール名は `transfer_to_refund_agent` になります。 -## ハンドオフの作成 +## ハンドオフの作成 {#creating-a-handoff} すべてのエージェントには [`handoffs`][agents.agent.Agent.handoffs] パラメーターがあり、`Agent` を直接受け取ることも、ハンドオフをカスタマイズする `Handoff` オブジェクトを受け取ることもできます。 @@ -16,7 +16,7 @@ search: Agents SDK が提供する [`handoff()`][agents.handoffs.handoff] 関数を使用して、ハンドオフを作成できます。この関数では、ハンドオフ先のエージェントに加えて、オプションのオーバーライドと入力フィルターを指定できます。 -### 基本的な使用方法 +### 基本的な使用方法 {#basic-usage} 簡単なハンドオフは次のように作成できます。 @@ -32,7 +32,7 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun 1. エージェントを直接使用することも(`billing_agent` のように)、`handoff()` 関数を使用することもできます。 -### `handoff()` 関数によるハンドオフのカスタマイズ +### `handoff()` 関数によるハンドオフのカスタマイズ {#customizing-handoffs-via-the-handoff-function} [`handoff()`][agents.handoffs.handoff] 関数を使用すると、さまざまな項目をカスタマイズできます。 @@ -63,7 +63,7 @@ handoff_obj = handoff( ) ``` -## ハンドオフ入力 +## ハンドオフ入力 {#handoff-inputs} 状況によっては、LLM がハンドオフを呼び出す際に、何らかのデータを提供するようにしたい場合があります。たとえば、「エスカレーションエージェント」へのハンドオフを考えてみましょう。ログに記録できるよう、モデルに理由を提供させることができます。 @@ -93,7 +93,7 @@ handoff_obj = handoff( `input_type` は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] とも異なります。ローカルにすでに存在するアプリケーションの状態や依存関係ではなく、ハンドオフ時にモデルが決定するメタデータには `input_type` を使用してください。 -### `input_type` の使用タイミング +### `input_type` の使用タイミング {#when-to-use-input_type} ハンドオフに、`reason`、`language`、`priority`、`summary` など、モデルが生成する小さなメタデータが必要な場合は、`input_type` を使用します。たとえば、トリアージエージェントは `{ "reason": "duplicate_charge", "priority": "high" }` を伴って返金エージェントにハンドオフでき、返金エージェントが引き継ぐ前に `on_handoff` でそのメタデータをログに記録したり永続化したりできます。 @@ -104,7 +104,7 @@ handoff_obj = handoff( - 専門エージェントの候補が複数ある場合は、移行先ごとに 1 つのハンドオフを登録します。`input_type` は選択されたハンドオフにメタデータを追加できますが、移行先を振り分けるものではありません。 - 会話を移行せずに、ネストされた専門エージェントへ構造化入力を渡す場合は、[`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool] の使用を推奨します。[ツール](tools.md#structured-input-for-tool-agents)を参照してください。 -## 入力フィルター +## 入力フィルター {#input-filters} ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、それまでの会話履歴全体を参照できる状態になります。これを変更するには、[`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、[`HandoffInputData`][agents.handoffs.HandoffInputData] を介して既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 @@ -140,7 +140,7 @@ handoff_obj = handoff( 1. `FAQ agent` が呼び出されると、履歴からツール関連の項目がすべて自動的に削除されます。 -## 推奨プロンプト +## 推奨プロンプト {#recommended-prompts} LLM がハンドオフを正しく理解できるようにするため、エージェントにハンドオフに関する情報を含めることを推奨します。[`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] に推奨プレフィックスが用意されています。また、[`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] を呼び出して、推奨データをプロンプトに自動的に追加することもできます。 diff --git a/docs/ja/human_in_the_loop.md b/docs/ja/human_in_the_loop.md index c97f3f6279..4962546461 100644 --- a/docs/ja/human_in_the_loop.md +++ b/docs/ja/human_in_the_loop.md @@ -12,7 +12,7 @@ search: このページでは、`interruptions` を介した手動承認フローを中心に説明します。アプリがコード内で判断できる場合、一部のツールタイプではプログラムによる承認コールバックもサポートされているため、実行を一時停止せずに続行できます。 -## 承認が必要なツールの指定 +## 承認が必要なツールの指定 {#marking-tools-that-need-approval} 常に承認を要求するには `needs_approval` を `True` に設定し、呼び出しごとに判断するには非同期関数を指定します。この callable は、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 @@ -46,7 +46,7 @@ agent = Agent( `needs_approval` は、[`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool]、[`ApplyPatchTool`][agents.tool.ApplyPatchTool] で利用できます。ローカル MCP サーバーでも、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse]、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] の `require_approval` を介して承認をサポートしています。ホスト型 MCP サーバーでは、`tool_config={"require_approval": "always"}` と任意の `on_approval_request` コールバックを指定した [`HostedMCPTool`][agents.tool.HostedMCPTool] を介して承認をサポートしています。Shell ツールと apply_patch ツールでは、割り込みを提示せずに自動承認または自動拒否する場合、`on_approval` コールバックを利用できます。 -## 承認フローの仕組み +## 承認フローの仕組み {#how-the-approval-flow-works} 1. モデルがツール呼び出しを出力すると、ランナーはその承認ルール(`needs_approval`、`require_approval`、またはホスト型 MCP に相当するもの)を評価します。 2. そのツール呼び出しに対する承認判断がすでに [`RunContextWrapper`][agents.run_context.RunContextWrapper] に保存されている場合、ランナーは確認せずに処理を続行します。呼び出し単位の承認は、特定の呼び出し ID に限定されます。実行の残りの期間中、同じツール識別情報に対する今後の呼び出しにも同じ判断を保持するには、`always_approve=True` または `always_reject=True` を渡します。 @@ -60,7 +60,7 @@ agent = Agent( 保留中の承認をすべて同じ処理内で解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認を混在させることができます。一部の項目だけを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決のものは `interruptions` に残り、実行は再び一時停止します。 -## カスタム拒否メッセージ +## カスタム拒否メッセージ {#custom-rejection-messages} デフォルトでは、拒否されたツール呼び出しについて、SDK の標準的な拒否テキストが実行に返されます。このメッセージは、次の 2 つの層でカスタマイズできます。 @@ -90,7 +90,7 @@ state.reject( 両方の層を組み合わせた完全な例については、[`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py) を参照してください。 -## 自動承認判断 +## 自動承認判断 {#automatic-approval-decisions} 手動の `interruptions` は最も汎用的なパターンですが、唯一の方法ではありません。 @@ -100,13 +100,13 @@ state.reject( これらのコールバックが判断を返すと、人の応答を待つために一時停止することなく実行が続行されます。Realtime API と音声セッション API については、[Realtime ガイド](realtime/guide.md)の承認フローを参照してください。 -## ストリーミングとセッション +## ストリーミングとセッション {#streaming-and-sessions} 同じ割り込みフローをストリーミング実行でも利用できます。ストリーミング実行が一時停止した後も、イテレーターが終了するまで [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events] を消費し続け、[`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] を確認して解決します。再開後の出力でもストリーミングを継続する場合は、[`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] で再開します。このパターンのストリーミング版については、[ストリーミング](streaming.md)を参照してください。 セッションも使用している場合は、`RunState` から再開するときに同じセッションインスタンスを引き続き渡すか、同じセッション ID とバッキングストア向けに構成された別のセッションオブジェクトを渡します。再開されたターンは、同じ保存済み会話履歴に追加されます。セッションのライフサイクルの詳細については、[セッション](sessions/index.md)を参照してください。 -## 一時停止、承認、再開の例 +## 一時停止、承認、再開の例 {#example-pause-approve-resume} 以下のスニペットは JavaScript の HITL ガイドと同様に、ツールに承認が必要な場合に一時停止し、状態をディスクに保持して再読み込みし、判断を取得した後に再開します。 @@ -177,7 +177,7 @@ if __name__ == "__main__": 承認のために一時停止する可能性がある実行でストリーミングを使用するには、`Runner.run_streamed` を呼び出し、完了するまで `result.stream_events()` を消費した後、上記と同じ `result.to_state()` および再開の手順に従います。 -## リポジトリのパターンとコード例 +## リポジトリのパターンとコード例 {#repository-patterns-and-examples} - **ストリーミング承認**: `examples/agent_patterns/human_in_the_loop_stream.py` は、`stream_events()` を最後まで消費し、保留中のツール呼び出しを承認してから `Runner.run_streamed(agent, state)` で再開する方法を示します。 - **カスタム拒否テキスト**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py` は、承認が拒否されたときに、実行レベルの `tool_error_formatter` と呼び出し単位の `rejection_message` オーバーライドを組み合わせる方法を示します。 @@ -188,7 +188,7 @@ if __name__ == "__main__": - **セッションとメモリ**: 承認と会話履歴を複数のターンにわたって保持するには、`Runner.run` にセッションを渡します。SQLite および OpenAI Conversations のセッションバリアントは、`examples/memory/memory_session_hitl_example.py` と `examples/memory/openai_session_hitl_example.py` にあります。 - **Realtime エージェント**: Realtime デモでは、`RealtimeSession` の `approve_tool_call` / `reject_tool_call` を介してツール呼び出しを承認または拒否する WebSocket メッセージを公開しています(サーバー側のハンドラーについては `examples/realtime/app/server.py`、API サーフェスについては [Realtime ガイド](realtime/guide.md#tool-approvals)を参照)。 -## 長時間にわたる承認 +## 長時間にわたる承認 {#long-running-approvals} `RunState` は永続性を考慮して設計されています。保留中の処理をデータベースやキューに保存するには `state.to_json()` または `state.to_string()` を使用し、後から再作成するには `RunState.from_json(...)` または `RunState.from_string(...)` を使用します。 @@ -202,6 +202,6 @@ if __name__ == "__main__": シリアライズ済みの実行状態には、アプリのコンテキストに加えて、承認、使用量、シリアライズ済みの `tool_input`、ネストされたツールとしてのエージェントの再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK が管理するランタイムメタデータが含まれます。シリアライズ済みの状態を保存または送信する場合は、`RunContextWrapper.context` を永続化データとして扱い、意図的に状態とともに移動させる場合を除き、そこにシークレットを格納しないでください。 -## 保留中タスクのバージョニング +## 保留中タスクのバージョニング {#versioning-pending-tasks} 承認が長期間保留される可能性がある場合は、シリアライズ済みの状態とともに、エージェント定義または SDK のバージョンマーカーを保存します。これにより、モデル、プロンプト、またはツール定義が変更された場合でも、対応するコードパスにデシリアライズを振り分け、非互換性を回避できます。 \ No newline at end of file diff --git a/docs/ja/index.md b/docs/ja/index.md index 1f8824479f..7f32c05680 100644 --- a/docs/ja/index.md +++ b/docs/ja/index.md @@ -12,7 +12,7 @@ search: これらの基本コンポーネントを Python と組み合わせることで、ツールとエージェント間の複雑な関係を表現し、学習コストを抑えながら実用的なアプリケーションを構築できます。さらに、SDK には組み込みの **トレーシング** が含まれており、エージェント型フローの可視化とデバッグに加え、評価やアプリケーション向けモデルのファインチューニングも行えます。 -## Agents SDK を使用する理由 +## Agents SDK を使用する理由 {#why-use-the-agents-sdk} SDK には、設計を支える 2 つの原則があります。 @@ -34,7 +34,7 @@ SDK の主な機能は次のとおりです。 - **Human in the loop**: エージェントの実行中に人間を関与させるための組み込みの仕組みです。 - **トレーシング**: ワークフローを可視化、デバッグ、監視するための組み込みのトレーシングです。OpenAI の評価、ファインチューニング、蒸留ツール群をサポートしています。 -## Agents SDK と Responses API の選択 +## Agents SDK と Responses API の選択 {#agents-sdk-or-responses-api} SDK は、OpenAI モデルに対してデフォルトで Responses API を使用しますが、モデル呼び出しをより高レベルのランタイムでラップします。 @@ -51,13 +51,13 @@ SDK は、OpenAI モデルに対してデフォルトで Responses API を使用 アプリケーション全体で、どちらか一方だけを選択する必要はありません。多くのアプリケーションでは、管理されたワークフローに SDK を使用し、より低レベルの処理では Responses API を直接呼び出します。 -## インストール +## インストール {#installation} ```bash pip install openai-agents ``` -## Hello world の例 +## Hello world の例 {#hello-world-example} ```python from agents import Agent, Runner @@ -78,14 +78,14 @@ print(result.final_output) export OPENAI_API_KEY=sk-... ``` -## はじめに +## はじめに {#start-here} - [クイックスタート](quickstart.md)で、最初のテキストベースのエージェントを構築します。 - 次に、[エージェントの実行](running_agents.md#choose-a-memory-strategy)で、ターン間で状態を引き継ぐ方法を決定します。 - タスクが実際のファイル、リポジトリ、またはエージェントごとに隔離されたワークスペースの状態に依存する場合は、[サンドボックスエージェントのクイックスタート](sandbox_agents.md)を参照してください。 - ハンドオフとマネージャー型オーケストレーションのどちらを使用するか決める場合は、[エージェントオーケストレーション](multi_agent.md)を参照してください。 -## 目的別ガイド +## 目的別ガイド {#choose-your-path} 実行したい作業は決まっていても、説明がどのページにあるか分からない場合は、次の表を使用してください。 diff --git a/docs/ja/mcp.md b/docs/ja/mcp.md index e844fd485e..a6cf591361 100644 --- a/docs/ja/mcp.md +++ b/docs/ja/mcp.md @@ -16,7 +16,7 @@ Agents Python SDK は、複数の MCP トランスポートを認識します。 MCP ツールは、モデルコンテキストのデータを公開し、提供された認証情報を使用して操作を実行できます。信頼できるサーバーのみに接続し、最小権限の認証情報を使用してください。また、アクセストークンは URL ではなく認証フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)も参照してください。 -## MCP 統合の選択 +## MCP 統合の選択 {#choosing-an-mcp-integration} MCP サーバーをエージェントに接続する前に、ツール呼び出しをどこで実行するか、またどのトランスポートにアクセスできるかを決めます。以下の表は、Python SDK がサポートするオプションをまとめたものです。 @@ -29,7 +29,7 @@ MCP サーバーをエージェントに接続する前に、ツール呼び出 以下のセクションでは、各オプション、その設定方法、および各トランスポートを選択すべき状況について説明します。 -## MCP Python SDK v1 と v2 +## MCP Python SDK v1 と v2 {#mcp-python-sdk-v1-and-v2} Agents SDK は、依存関係の範囲 `mcp>=1.19.0,<3` を通じて、`mcp` Python パッケージの両方のメジャーバージョンをサポートします。インストールされている `mcp` パッケージのバージョンは、サーバーとの間でネゴシエートされる MCP プロトコルバージョンとは別です。Agents SDK は、インストールされているパッケージのメジャーバージョンを検出し、stdio、SSE、および Streamable HTTP 接続を自動的に調整するため、通常のサーバー設定ではバージョンを切り替える必要はありません。 @@ -57,7 +57,7 @@ HTTP トランスポートのカスタマイズでは、インストールされ これらのローカルな `mcp` の依存関係要件は、リモート MCP 接続を OpenAI Responses API が管理するため、[`HostedMCPTool`][agents.tool.HostedMCPTool] には適用されません。 -## エージェントレベルの MCP 設定 +## エージェントレベルの MCP 設定 {#agent-level-mcp-configuration} トランスポートの選択に加えて、`Agent.mcp_config` を設定することで、MCP ツールの準備方法を調整できます。 @@ -87,7 +87,7 @@ agent = Agent( - サーバーレベルの `failure_error_function` は、そのサーバーについて `Agent.mcp_config["failure_error_function"]` を上書きします。 - `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバープレフィックス付きの名前でモデルに公開されます。これは、複数の MCP サーバーが同名のツールを公開する場合の衝突回避に役立ちます。生成される名前は ASCII セーフで、`FunctionTool` インスタンスの名前の長さ制限内に収まり、同じエージェントに設定されたローカル `FunctionTool` インスタンスの名前や、有効なハンドオフの名前とは衝突しません。SDK は引き続き、元のサーバー上で元の MCP ツール名を使用して呼び出します。 -## トランスポート間の共通パターン +## トランスポート間の共通パターン {#shared-patterns-across-transports} トランスポートを選択した後、ほとんどの統合では、次の事項について判断する必要があります。 @@ -98,11 +98,11 @@ agent = Agent( ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP のセクションでは最も完全なコード例を示しており、同じパターンを他のローカルトランスポートにも適用できます。 -## 1. ホスト型 MCP サーバーツール +## 1. ホスト型 MCP サーバーツール {#1-hosted-mcp-server-tools} ホスト型ツールでは、ツールのラウンドトリップ全体が OpenAI のインフラストラクチャ内で実行されます。コード側でツールを一覧表示して呼び出す代わりに、[`HostedMCPTool`][agents.tool.HostedMCPTool] がサーバーラベルと、必要に応じてコネクターのメタデータを Responses API に転送します。モデルは、Python プロセスへの追加のコールバックを行わずに、リモートサーバーのツールを一覧表示して呼び出します。現在、ホスト型ツールは、Responses API のホスト型 MCP 統合をサポートする OpenAI モデルで動作します。 -### 基本的なホスト型 MCP ツール +### 基本的なホスト型 MCP ツール {#basic-hosted-mcp-tool} エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加して、ホスト型ツールを作成します。`tool_config` の辞書は、REST API に送信する JSON と同じ構造です。 @@ -141,7 +141,7 @@ asyncio.run(main()) ホスト型ツール検索によってホスト型 MCP サーバーを遅延読み込みする場合は、`tool_config["defer_loading"] = True` を設定し、[`ToolSearchTool`][agents.tool.ToolSearchTool] をエージェントに追加します。これは OpenAI Responses モデルでのみサポートされます。ツール検索の完全な設定と制約については、[ツール](tools.md#hosted-tool-search)を参照してください。 -### ホスト型 MCP の実行結果のストリーミング +### ホスト型 MCP の実行結果のストリーミング {#streaming-hosted-mcp-results} ホスト型ツールでは、関数ツールとまったく同じ方法で実行結果のストリーミングがサポートされます。モデルが処理中の間に、`Runner.run_streamed` を使用して MCP の増分出力を受け取ります。 @@ -154,7 +154,7 @@ async for event in result.stream_events(): print(result.final_output) ``` -### オプションの承認フロー +### オプションの承認フロー {#optional-approval-flows} サーバーが機密性の高い操作を実行できる場合、各ツールの実行前に人間またはプログラムによる承認を必須にできます。`tool_config` 内の `require_approval` に、単一のポリシー(`"always"`、`"never"`)またはツール名をポリシーにマッピングする辞書を設定します。Python 内で判断するには、`on_approval_request` コールバックを指定します。 @@ -186,7 +186,7 @@ agent = Agent( コールバックは同期または非同期にでき、モデルが実行を継続するために承認データを必要とするたびに呼び出されます。 -### コネクターを基盤とするホスト型サーバー +### コネクターを基盤とするホスト型サーバー {#connector-backed-hosted-servers} ホスト型 MCP は OpenAI コネクターもサポートします。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 @@ -206,7 +206,7 @@ HostedMCPTool( ストリーミング、承認、コネクターを含む、完全に動作するホスト型ツールのサンプルは、[`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)にあります。 -## 2. Streamable HTTP MCP サーバー +## 2. Streamable HTTP MCP サーバー {#2-streamable-http-mcp-servers} ネットワーク接続を自身で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを制御する場合や、低レイテンシーを維持しながら自身のインフラストラクチャ内でサーバーを実行する場合に最適です。 @@ -253,7 +253,7 @@ asyncio.run(main()) - `failure_error_function` は、モデルに表示される MCP ツールの失敗メッセージをカスタマイズします。代わりにエラーを発生させるには、`None` に設定します。 - `tool_meta_resolver` は、`call_tool()` の前に、呼び出しごとの MCP `_meta` ペイロードを挿入します。 -### ローカル MCP サーバーの承認ポリシー +### ローカル MCP サーバーの承認ポリシー {#approval-policies-for-local-mcp-servers} `MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp` は、いずれも `require_approval` を受け入れます。 @@ -275,7 +275,7 @@ async with MCPServerStreamableHttp( 一時停止と再開を含む完全なフローについては、[Human-in-the-loop](human_in_the_loop.md)および `examples/mcp/get_all_mcp_tools_example/main.py` を参照してください。 -### `tool_meta_resolver` による呼び出しごとのメタデータ +### `tool_meta_resolver` による呼び出しごとのメタデータ {#per-call-metadata-with-tool_meta_resolver} MCP サーバーが `_meta` 内にリクエストメタデータ(テナント ID やトレースコンテキストなど)を必要とする場合は、`tool_meta_resolver` を使用します。以下の例では、`dict` を `context` として `Runner.run(...)` に渡すことを前提としています。 @@ -300,11 +300,11 @@ server = MCPServerStreamableHttp( 実行コンテキストが Pydantic モデル、dataclass、またはカスタムクラスの場合は、属性アクセスを使用してテナント ID を読み取ります。 -### MCP ツールの出力:テキスト、画像、その他のコンテンツ +### MCP ツールの出力:テキスト、画像、その他のコンテンツ {#mcp-tool-outputs-text-images-and-other-content} MCP の実行結果でコンテンツブロックが使用されている場合、SDK はテキストコンテンツをテキスト出力として転送し、画像コンテンツをツール出力内の画像型エントリーにマッピングします。音声ブロックやリソースブロックを含むその他の MCP コンテンツブロック型については、SDK は、そのブロックを有効な JSON としてシリアライズした値を持つテキスト出力を転送します。複数のコンテンツブロックを含むレスポンスは、出力項目のリストとして転送されます。`use_structured_content=True` が、空でなくエラーでもない `structuredContent` ペイロードを選択した場合、その構造化ペイロードがこれらのコンテンツブロックより優先されます。構造化コンテンツが存在しないか空の場合は、コンテンツブロックにフォールバックします。 -## 3. SSE 対応 HTTP MCP サーバー +## 3. SSE 対応 HTTP MCP サーバー {#3-http-with-sse-mcp-servers} !!! warning @@ -337,7 +337,7 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP サーバー +## 4. stdio MCP サーバー {#4-stdio-mcp-servers} ローカルサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを生成し、パイプを開いたまま維持し、コンテキストマネージャーの終了時に自動的に閉じます。このオプションは、簡単な概念実証や、サーバーがコマンドラインのエントリーポイントのみを公開する場合に役立ちます。 @@ -365,7 +365,7 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP サーバーマネージャー +## 5. MCP サーバーマネージャー {#5-mcp-server-manager} 複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、正常に接続されたサーバーのみをエージェントに公開します。コンストラクターのオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 @@ -397,15 +397,15 @@ async with MCPServerManager(servers) as manager: - `connect_all()`、`reconnect()`、`cleanup_all()` の呼び出しは直列化されます。あるライフサイクル操作がすでに実行中の場合、別のライフサイクル操作は、同じサーバーへの接続やクリーンアップを同時に行わず、その操作が完了するまで待機します。 - ライフサイクルの動作を調整するには、`connect_timeout_seconds`、`cleanup_timeout_seconds`、`connect_in_parallel` を設定します。どちらのライフサイクルタイムアウトもデフォルトは 10 秒です。正の有限秒、または無効にするための `None` を受け入れ、構築時と代入時の両方で検証されます。即時の期限が設定されてしまうため、0 は拒否されます。 -## サーバーに共通する機能 +## サーバーに共通する機能 {#common-server-capabilities} 以下のセクションは、MCP サーバーの各トランスポートに共通して適用されます(具体的な API サーフェスはサーバークラスによって異なります)。 -## ツールフィルタリング +## ツールフィルタリング {#tool-filtering} 各 MCP サーバーはツールフィルターをサポートしているため、エージェントが必要とする関数のみを公開できます。フィルタリングは、構築時に静的に行うことも、実行ごとに動的に行うこともできます。 -### 静的ツールフィルタリング +### 静的ツールフィルタリング {#static-tool-filtering} 単純な許可リストとブロックリストを設定するには、[`create_static_tool_filter`][agents.mcp.create_static_tool_filter] を使用します。 @@ -427,7 +427,7 @@ filesystem_server = MCPServerStdio( `allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK は最初に許可リストを適用し、その後、残ったツールからブロック対象のツールを削除します。 -### 動的ツールフィルタリング +### 動的ツールフィルタリング {#dynamic-tool-filtering} より複雑なロジックでは、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る callable を渡します。callable は同期または非同期にでき、ツールを公開する場合は `True` を返します。 @@ -455,7 +455,7 @@ async with MCPServerStdio( フィルターコンテキストからは、アクティブな `run_context`、ツールを要求している `agent`、および `server_name` にアクセスできます。 -## プロンプト +## プロンプト {#prompts} MCP サーバーは、エージェントへの指示を動的に生成するプロンプトも提供できます。プロンプトをサポートするサーバーは、次の 2 つの メソッドを公開します。 @@ -479,17 +479,17 @@ agent = Agent( ) ``` -## ページネーション +## ページネーション {#pagination} 組み込みのローカル MCP サーバークラスは、ツールとプロンプトを一覧表示する際に `nextCursor` を自動的にたどります。`list_tools()` は、フィルターの適用またはキャッシュへの格納前に完全なツール一覧を収集し、`list_prompts()` は `nextCursor=None` を含む 1 つの統合された実行結果を返します。後続のページが失敗した場合や、サーバーが同じカーソルを繰り返した場合、部分的な実行結果を公開またはキャッシュする代わりに、操作はエラーを発生させます。 リソースは引き続き明示的にページ分割されます。次のページを取得するには、`list_resources()` または `list_resource_templates()` から取得した `nextCursor` を、`cursor` 引数として再度渡します。 -## キャッシュ +## キャッシュ {#caching} エージェントを実行するたびに、各 MCP サーバー上で `list_tools()` が呼び出されます。リモートサーバーでは顕著なレイテンシーが生じる可能性があるため、すべての MCP サーバークラスは `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新の一覧を強制的に取得するには、サーバーインスタンス上で `invalidate_tools_cache()` を呼び出します。 -## トレーシング +## トレーシング {#tracing} [トレーシング](./tracing.md)では、次の項目を含む MCP アクティビティが自動的に記録されます。 @@ -498,7 +498,7 @@ agent = Agent( ![MCP トレーシングのスクリーンショット](../assets/images/mcp-tracing.jpg) -## 関連資料 +## 関連資料 {#further-reading} - [Model Context Protocol](https://modelcontextprotocol.io/) – 仕様および設計ガイド。 - [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 実行可能な stdio、SSE、Streamable HTTP のサンプル。 diff --git a/docs/ja/models/index.md b/docs/ja/models/index.md index 287703afe2..18d45254b3 100644 --- a/docs/ja/models/index.md +++ b/docs/ja/models/index.md @@ -9,7 +9,7 @@ Agents SDK は、すぐに利用できる OpenAI モデルを 2 種類サポー - **推奨**: 新しい [Responses API](https://platform.openai.com/docs/api-reference/responses) を使用して OpenAI API を呼び出す [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]。 - [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) を使用して OpenAI API を呼び出す [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。 -## モデル設定の選択 +## モデル設定の選択 {#choosing-a-model-setup} 設定に適した最もシンプルな方法から始めてください。 @@ -23,7 +23,7 @@ Agents SDK は、すぐに利用できる OpenAI モデルを 2 種類サポー | OpenAI Responses の高度なリクエスト設定を調整する | OpenAI Responses のパスで `ModelSettings` を使用する | [OpenAI Responses の高度な設定](#advanced-openai-responses-settings) | | OpenAI 以外または複数プロバイダーのルーティングにサードパーティ製アダプターを使用する | サポートされているベータ版アダプターを比較し、提供予定のプロバイダーパスを検証する | [サードパーティ製アダプター](#third-party-adapters) | -## OpenAI モデル +## OpenAI モデル {#openai-models} OpenAI のみを使用するほとんどのアプリでは、デフォルトの OpenAI プロバイダーで文字列のモデル名を使用し、Responses モデルのパスを維持する方法を推奨します。 @@ -31,7 +31,7 @@ OpenAI のみを使用するほとんどのアプリでは、デフォルトの `gpt-5.6-sol` などの別のモデルに切り替える場合、エージェントを設定する方法は 2 つあります。 -### デフォルトモデル +### デフォルトモデル {#default-model} まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用するには、エージェントを実行する前に環境変数 `OPENAI_DEFAULT_MODEL` を設定します。 @@ -57,7 +57,7 @@ result = await Runner.run( ) ``` -#### GPT-5 モデル +#### GPT-5 モデル {#gpt-5-models} この方法で `gpt-5.6-sol` などの GPT-5 モデルを使用すると、SDK はデフォルトの `ModelSettings` を適用します。ほとんどのユースケースで最適に機能する設定が適用されます。デフォルトモデルの推論エフォートを調整するには、独自の `ModelSettings` を渡します。 @@ -100,7 +100,7 @@ agent = Agent( `context="all_turns"` を使用する場合は、`previous_response_id`、サーバー側の Responses API 会話、または次のリクエストに以前の推論項目を含めることで、会話を維持してください。ステートレスな `store=False` 呼び出しでは、レスポンスで `reasoning.encrypted_content` をリクエストし、その推論項目を次のリクエストの入力に含めます。 -#### ComputerTool のモデル選択 +#### ComputerTool のモデル選択 {#computertool-model-selection} エージェントに [`ComputerTool`][agents.tool.ComputerTool] が含まれる場合、実際の Responses リクエストで有効なモデルによって、SDK が送信するコンピューターツールのペイロードが決まります。明示的な `gpt-5.5` リクエストでは、GA 版の組み込み `computer` ツールが使用されます。一方、明示的な `computer-use-preview` リクエストでは、従来の `computer_use_preview` ペイロードが維持されます。 @@ -110,11 +110,11 @@ agent = Agent( プレビュー互換のリクエストでは、`environment` と画面サイズを事前にシリアライズする必要があります。そのため、[`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを使用するプロンプト管理フローでは、具体的な `Computer` または `AsyncComputer` インスタンスを渡すか、リクエスト送信前に GA セレクターを強制する必要があります。移行の詳細については、[ツール](../tools.md#computertool-and-the-responses-computer-tool)を参照してください。 -#### GPT-5 以外のモデル +#### GPT-5 以外のモデル {#non-gpt-5-models} カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はどのモデルとも互換性がある汎用の `ModelSettings` に戻ります。 -### Responses 専用のツール機能 +### Responses 専用のツール機能 {#responses-only-tool-features} 次のツール機能は、OpenAI Responses モデルでのみサポートされます。 @@ -125,11 +125,11 @@ agent = Agent( これらの機能は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。遅延読み込みツールを使用する場合は、エージェントに `ToolSearchTool()` を追加し、名前空間名のみ、または遅延読み込み専用の関数名を強制する代わりに、`auto` または `required` のツール選択を通じてモデルにツールを読み込ませます。設定の詳細と現在の制約については、[ホステッドツール検索](../tools.md#hosted-tool-search)および[プログラムによるツール呼び出し](../tools.md#programmatic-tool-calling)を参照してください。 -### Responses WebSocket トランスポート +### Responses WebSocket トランスポート {#responses-websocket-transport} デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI Responses プロバイダーのパスを使用する場合は、WebSocket トランスポートを有効にできます。 -#### 基本設定 +#### 基本設定 {#basic-setup} ```python from agents import set_default_openai_responses_transport @@ -141,7 +141,7 @@ set_default_openai_responses_transport("websocket") トランスポートの選択は、SDK がモデル名をモデルインスタンスへ解決するときに行われます。具体的な [`Model`][agents.models.interface.Model] オブジェクトを渡した場合、そのトランスポートはすでに固定されています。[`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] は WebSocket、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] は HTTP を使用し、[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] は Chat Completions のままです。`RunConfig(model_provider=...)` を渡した場合は、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 -#### プロバイダー単位または実行単位の設定 +#### プロバイダー単位または実行単位の設定 {#provider-or-run-level-setup} WebSocket トランスポートは、プロバイダー単位または実行単位でも設定できます。 @@ -188,7 +188,7 @@ result = await Runner.run( ) ``` -#### `MultiProvider` を使用した高度なルーティング +#### `MultiProvider` を使用した高度なルーティング {#advanced-routing-with-multiprovider} プレフィックスに基づくモデルルーティングが必要な場合、たとえば 1 回の実行で `openai/...` と `any-llm/...` のモデル名を混在させる場合は、[`MultiProvider`][agents.MultiProvider] を使用し、そこで `openai_use_responses_websocket=True` を設定します。 @@ -229,7 +229,7 @@ result = await Runner.run( カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket の `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 -#### 注記 +#### 注記 {#notes} - これは WebSocket トランスポート経由の Responses API であり、[Realtime API](../realtime/guide.md) ではありません。Chat Completions には適用されません。OpenAI 以外のプロバイダーには、Responses WebSocket の `/responses` エンドポイントをサポートしている場合にのみ適用されます。 - 環境にまだ存在しない場合は、`websockets` パッケージをインストールしてください。 @@ -239,13 +239,13 @@ result = await Runner.run( - [Responses API WebSocket サービス](https://developers.openai.com/api/docs/guides/websocket-mode)は、各接続で一度に 1 つのレスポンスを処理し、各接続を 60 分に制限します。この上限に達したら新しい接続を開いてください。並列実行が必要な場合は複数の接続を使用します。 - サービスは、接続ローカルのメモリに最新のレスポンスのみを保持します。失敗した `4xx` または `5xx` のターンでは、`previous_response_id` が参照するレスポンスがそのメモリから削除されます。再接続後も、保存済みのレスポンスが利用可能であれば継続できますが、`store=False` と ZDR のフローには永続化されたフォールバックがありません。`previous_response_id=None` で新しいチェーンを開始して完全な入力コンテキストを送信するか、ローカルで管理されるセッション状態からそのコンテキストを再構築してください。 -### ホステッド・マルチエージェント(実験的) +### ホステッド・マルチエージェント(実験的) {#hosted-multi-agent-experimental} OpenAI Responses API のホステッド・マルチエージェントベータでは、GPT-5.6 のルートモデルが、サーバーでホストされるサブエージェントを作成および調整できます。Agents SDK は通常の `Runner` を引き続き使用できます。ホステッドオーケストレーションはサービス上で行われ、開発者が定義した関数ツールはアプリケーション内で実行されます。 この統合は実験的なもので、ローカル関数の出力を `response.inject` によってアクティブなホステッドエージェントへ返せるよう、Responses WebSocket トランスポートを使用します。`client.beta.responses.connect` を公開しているバージョン 2.45.0 以降の `openai[realtime]` のビルドが必要です。インターフェースとベータ版の項目スキーマは、一般提供前に変更される可能性があります。 -#### モデルの設定 +#### モデルの設定 {#configure-the-model} 実験的モジュールからモデルをインポートし、SDK の `Agent` に割り当てます。 @@ -262,7 +262,7 @@ agent = Agent( `OpenAIHostedMultiAgentModel` を構築すると `multi_agent.enabled` が有効になり、`OpenAI-Beta: responses_multi_agent=v1` WebSocket ヘッダーが送信されます。`openai_client` を指定しない場合、モデルはデフォルトの OpenAI クライアントを使用します。`max_concurrent_subagents` を省略した場合は、サービスのデフォルトが使用されます。 -#### ローカル関数ツール +#### ローカル関数ツール {#local-function-tools} すべてのホステッドエージェントは、リクエストに設定されたモデルとツールを共有します。どのホステッドエージェントが関数を呼び出すかは、Responses API が決定します。通常の SDK Runner は関数をローカルで実行し、同じ呼び出し ID を持つ `function_call_output` をアクティブな WebSocket レスポンスへ注入します。これにより、サービスは元のホステッド呼び出し元を再開できます。関数の実行には、引き続き Runner の通常のガードレール、フック、および失敗時の変換が適用されます。SDK のツール承認による中断はサポートされません。`needs_approval` 設定が `False` ではない関数ツールは、リクエストの送信前に拒否されます。 @@ -285,13 +285,13 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: ホステッドエージェント名は観測用のメタデータであり、ローカルのルーティング機構ではありません。SDK が提供する呼び出し ID を使用して出力をルーティングしてください。副作用を伴うツールでは、その呼び出し ID を冪等性キーとして使用し、ツール実行前または実行中に、必要な認可をアプリケーションコードで適用してください。このモデルでは `needs_approval` を使用しないでください。ツールの引数と出力は Responses API の境界を越えます。 -#### 出力とストリーミングの動作 +#### 出力とストリーミングの動作 {#output-and-streaming-behavior} フェーズが `final_answer` で、`/root` に属するメッセージだけが、通常の最終メッセージになります。実験的アダプターは、上位レベルの `RunResult` からサブエージェントのメッセージとホステッドオーケストレーションのレコードを除外します。SDK がそれらのレコードをローカル関数として実行することはありません。 raw ストリーミングでは、ホステッド出力項目や `response.inject.created` の確認応答を含む、Responses のベータイベントが引き続き公開されます。アダプターは、関数呼び出しの準備が整ったときに 1 つのアクティブなプロバイダーレスポンスを SDK から見える論理的なモデルターンに分割し、Runner が出力を生成した後に同じプロバイダーレスポンスを再開します。raw のホステッド項目または `ToolContext` とともに `get_hosted_agent_metadata()` を使用すると、その項目またはツール呼び出しがどのホステッドエージェントに属するかを識別できます。 -#### SDK オーケストレーションとの関係 +#### SDK オーケストレーションとの関係 {#relationship-to-sdk-orchestration} ホステッド・マルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 @@ -299,7 +299,7 @@ raw ストリーミングでは、ホステッド出力項目や `response.injec - SDK のハンドオフは、アクティブなローカル SDK の `Agent` を変更します。この実験的モデルを使用している場合、すべてのホステッドエージェントが同じハンドオフツールを受け取って所有権の競合が発生するため、ハンドオフは拒否されます。 - Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 -#### 現在の制限事項 +#### 現在の制限事項 {#current-limitations} 実験的モデルは、`reasoning.summary`、`max_tool_calls`、および呼び出し元が指定する `multi_agent` または `betas` のオーバーライドを拒否します。Responses の `/compact` エンドポイントはベータ版ではサポートされません。ただし、サービスが各ホステッドエージェントのコンテキストを個別に自動圧縮するため、明示的な `context_management.compact_threshold` は使用できます。 @@ -307,11 +307,11 @@ raw ストリーミングでは、ホステッド出力項目や `response.injec 基盤となる Responses API ベータ版の動作については、[OpenAI マルチエージェントガイド](https://developers.openai.com/api/docs/guides/tools-multi-agent)を参照してください。ストリーミングおよび非ストリーミングでの SDK の使用方法については、[`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py) を参照してください。 -## OpenAI 以外のモデル +## OpenAI 以外のモデル {#non-openai-models} OpenAI 以外のプロバイダーが必要な場合は、SDK に組み込まれたプロバイダー統合ポイントから始めてください。多くの設定では、サードパーティ製アダプターを追加しなくてもこれで十分です。各パターンのコード例は、[examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/) にあります。 -### OpenAI 以外のプロバイダーの統合方法 +### OpenAI 以外のプロバイダーの統合方法 {#ways-to-integrate-non-openai-providers} | 方法 | 使用する状況 | 適用範囲 | | --- | --- | --- | @@ -343,7 +343,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model これらのコード例では、依然として多くの LLM プロバイダーが Responses API をサポートしていないため、Chat Completions API/モデルを使用しています。LLM プロバイダーが Responses をサポートしている場合は、Responses の使用を推奨します。 -## 1 つのワークフローでのモデルの組み合わせ +## 1 つのワークフローでのモデルの組み合わせ {#mixing-models-in-one-workflow} 1 つのワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する際は、次のいずれかの方法で特定のモデルを選択できます。 @@ -407,11 +407,11 @@ english_agent = Agent( ) ``` -## OpenAI Responses の高度な設定 +## OpenAI Responses の高度な設定 {#advanced-openai-responses-settings} OpenAI Responses のパスでより細かい制御が必要な場合は、`ModelSettings` から始めてください。 -### 一般的な高度な `ModelSettings` オプション +### 一般的な高度な `ModelSettings` オプション {#common-advanced-modelsettings-options} OpenAI Responses API を使用する場合、複数のリクエストフィールドには対応する `ModelSettings` フィールドがすでに直接用意されているため、それらに `extra_args` を使用する必要はありません。 @@ -477,7 +477,7 @@ result = await Runner.run( サーバー側の圧縮は、[`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] とは異なります。`context_management=[{"type": "compaction", "compact_threshold": ...}]` は Responses API リクエストごとに送信され、レンダリングされたコンテキストがしきい値を超えると、API はレスポンスの一部として圧縮項目を出力できます。`OpenAIResponsesCompactionSession` はターン間で独立した `responses.compact` エンドポイントを呼び出し、ローカルのセッション履歴を書き換えます。 -### `extra_args` の受け渡し +### `extra_args` の受け渡し {#passing-extra_args} SDK がまだトップレベルで直接公開していない、プロバイダー固有または新しいリクエストフィールドが必要な場合は、`extra_args` を使用します。 @@ -497,7 +497,7 @@ english_agent = Agent( ) ``` -## モデル呼び出しのタイムアウト +## モデル呼び出しのタイムアウト {#model-call-timeouts} モデル呼び出しの各試行を制限するには、[`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] に正の秒数を設定します。タイムアウトはストリーミングと非ストリーミングの呼び出しに適用され、トランスポートの待機時間を含む試行全体を対象とします。エージェント実行全体、関数ツールの実行、または再試行のバックオフは制限しません。 @@ -512,7 +512,7 @@ agent = Agent( 試行が上限を超えると、SDK はその試行をキャンセルし、クリーンアップの完了を待ってから [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError] を発生させます。Runner 管理の再試行が有効な場合、SDK は `context.normalized.is_timeout` を `True` に設定して、タイムアウトによる失敗を再試行ポリシーへ渡します。たとえば、`retry_policies.network_error()` はこの分類に一致します。許可された各再試行には、試行ごとに新しいタイムアウトが適用されます。SDK は再試行前に通常の[リプレイ安全性ルール](#safety-boundaries)も適用します。 -## Runner 管理の再試行 +## Runner 管理の再試行 {#runner-managed-retries} 再試行はランタイム専用で、明示的な有効化が必要です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 @@ -584,7 +584,7 @@ SDK は、`retry_policies` で既成のヘルパーを公開しています。 ポリシーを組み合わせる場合、`provider_suggested()` は最初の構成要素として最も安全です。これは、プロバイダーがそれらを区別できる場合に、プロバイダーによる拒否とリプレイ安全性の承認を維持するためです。 -##### 安全性の境界 +##### 安全性の境界 {#safety-boundaries} 一部の失敗は再試行されません。 @@ -596,7 +596,7 @@ SDK は、`retry_policies` で既成のヘルパーを公開しています。 `previous_response_id` または `conversation_id` を使用するステートフルな後続リクエストは、リプレイの安全性が不明な場合、安全側に倒して失敗します。このようなリクエストでは、`network_error()` や `http_status([500])` など、プロバイダーに基づかない述語だけでは不十分です。通常は `retry_policies.provider_suggested()` を通じて、プロバイダーからリプレイ安全性の承認を含めるか、前述のとおり、プロバイダーが安全でないとマークした非ストリーミングの失敗を明示的に承認してください。 -##### Runner とエージェントのマージ動作 +##### Runner とエージェントのマージ動作 {#runner-and-agent-merge-behavior} `retry` は、Runner レベルとエージェントレベルの `ModelSettings` の間でディープマージされます。 @@ -606,9 +606,9 @@ SDK は、`retry_policies` で既成のヘルパーを公開しています。 より詳細なコード例については、[`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) および[アダプターを使用した再試行のコード例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)を参照してください。 -## OpenAI 以外のプロバイダーのトラブルシューティング +## OpenAI 以外のプロバイダーのトラブルシューティング {#troubleshooting-non-openai-providers} -### トレーシングクライアントのエラー 401 +### トレーシングクライアントのエラー 401 {#tracing-client-error-401} トレーシング関連のエラーが発生する場合、トレースが OpenAI サーバーへアップロードされる一方で、OpenAI API キーが設定されていないことが原因です。解決方法は 3 つあります。 @@ -616,14 +616,14 @@ SDK は、`retry_policies` で既成のヘルパーを公開しています。 2. トレーシング用の OpenAI キーを設定します: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。この API キーはトレースのアップロードにのみ使用され、[platform.openai.com](https://platform.openai.com/) で発行されたものである必要があります。 3. OpenAI 以外のトレースプロセッサーを使用します。[トレーシングのドキュメント](../tracing.md#custom-tracing-processors)を参照してください。 -### Responses API のサポート +### Responses API のサポート {#responses-api-support} SDK はデフォルトで Responses API を使用しますが、依然として多くの他の LLM プロバイダーはこれをサポートしていません。その結果、404 などの問題が発生する場合があります。解決方法は 2 つあります。 1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api] を呼び出します。これは、環境変数で `OPENAI_API_KEY` と `OPENAI_BASE_URL` を設定している場合に機能します。 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] を使用します。コード例は[こちら](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)にあります。 -### Chat Completions の互換性オプション +### Chat Completions の互換性オプション {#chat-completions-compatibility-options} Chat Completions を通じてルーティングする場合、SDK は、`previous_response_id`、`conversation_id`、Responses API の `prompt` フィールド、またはテキストのみではないツール出力など、Chat Completions では送信できない Responses 専用フィールドを暗黙的に破棄して互換性を維持します。開発中にこのような不一致を即座に失敗させるには、OpenAI プロバイダーで厳格な機能検証を有効にします。 @@ -660,7 +660,7 @@ provider = OpenAIProvider( [`MultiProvider`][agents.MultiProvider] では、`openai_buffer_streamed_tool_calls=True` を使用します。 -### structured outputs のサポート +### structured outputs のサポート {#structured-outputs-support} 一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。その結果、次のようなエラーが発生することがあります。 @@ -672,7 +672,7 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' これは一部のモデルプロバイダーの制約です。JSON 出力には対応していますが、出力に使用する `json_schema` は指定できません。この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを使用することを推奨します。そうしない場合、不正な形式の JSON が原因でアプリが頻繁に動作しなくなる可能性があります。 -## プロバイダー間でのモデルの組み合わせ +## プロバイダー間でのモデルの組み合わせ {#mixing-models-across-providers} モデルプロバイダー間の機能差を把握しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホステッドファイル検索、および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 @@ -680,11 +680,11 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' - テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください - 構造化 JSON 出力をサポートしないプロバイダーでは、無効な JSON が生成されることがある点に注意してください。 -## サードパーティ製アダプター +## サードパーティ製アダプター {#third-party-adapters} サードパーティ製アダプターは、SDK に組み込まれたプロバイダー統合ポイントだけでは不十分な場合にのみ使用してください。この SDK で OpenAI モデルのみを使用する場合は、Any-LLM や LiteLLM ではなく、組み込みの [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] のパスを優先してください。サードパーティ製アダプターは、OpenAI モデルと OpenAI 以外のプロバイダーを組み合わせる必要がある場合や、アダプターのみが提供するプロバイダー対応範囲またはルーティングが必要な場合のためのものです。アダプターは SDK と上流のモデルプロバイダーの間に別の互換性レイヤーを追加するため、機能のサポート状況とリクエストのセマンティクスはプロバイダーによって異なる場合があります。SDK には現在、ベストエフォートのベータ版アダプター統合として Any-LLM と LiteLLM が含まれています。 -### Any-LLM +### Any-LLM {#any-llm} Any-LLM のサポートは、Any-LLM が管理するプロバイダー対応範囲またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 @@ -694,7 +694,7 @@ Any-LLM が必要な場合は、`openai-agents[any-llm]` をインストール Any-LLM はサードパーティ製アダプターレイヤーであるため、プロバイダーの依存関係と機能上の不足は SDK ではなく、上流の Any-LLM によって定義されます。使用量メトリクスは上流のプロバイダーが返す場合に自動的に伝播されますが、ストリーミング Chat Completions のバックエンドでは、使用量のチャンクを出力する前に `ModelSettings(include_usage=True)` が必要になる場合があります。structured outputs、ツール呼び出し、使用量レポート、または Responses 固有の動作に依存する場合は、デプロイ予定の正確なプロバイダーバックエンドを検証してください。 -### LiteLLM +### LiteLLM {#litellm} LiteLLM のサポートは、LiteLLM 固有のプロバイダー対応範囲またはルーティングが必要な場合に向けて、ベストエフォートのベータ版として提供されています。 diff --git a/docs/ja/multi_agent.md b/docs/ja/multi_agent.md index 95abe8d70b..93c0ea6e29 100644 --- a/docs/ja/multi_agent.md +++ b/docs/ja/multi_agent.md @@ -11,7 +11,7 @@ search: これらのパターンは組み合わせて使用できます。それぞれにトレードオフがあり、以下で説明します。 -## LLM によるオーケストレーション +## LLM によるオーケストレーション {#orchestrating-via-llm} エージェントは、指示、ツール、ハンドオフを備えた LLM です。つまり、オープンエンドなタスクが与えられると、LLM はそのタスクへの取り組み方を自律的に計画できます。ツールを使用してアクションの実行やデータの取得を行い、ハンドオフを使用してサブエージェントにタスクを委任します。たとえば、リサーチエージェントには次のような機能を持たせることができます。 @@ -21,7 +21,7 @@ search: - データ分析を行うためのコード実行 - 計画やレポート作成などを得意とする専門エージェントへのハンドオフ -### SDK の主要パターン +### SDK の主要パターン {#core-sdk-patterns} Python SDK では、次の 2 つのオーケストレーションパターンが最もよく使用されます。 @@ -44,7 +44,7 @@ Python SDK では、次の 2 つのオーケストレーションパターンが このオーケストレーション方式の基盤となる SDK の基本コンポーネントについては、[ツール](tools.md)、[ハンドオフ](handoffs.md)、[エージェントの実行](running_agents.md)から参照してください。 -## コードによるオーケストレーション +## コードによるオーケストレーション {#orchestrating-via-code} LLM によるオーケストレーションは強力ですが、コードによるオーケストレーションでは、速度、コスト、パフォーマンスの面でタスクをより決定論的かつ予測可能にできます。一般的なパターンは次のとおりです。 @@ -55,7 +55,7 @@ LLM によるオーケストレーションは強力ですが、コードによ [`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns) には、多数のコード例があります。 -## 関連ガイド +## 関連ガイド {#related-guides} - 構成パターンとエージェント設定については、[エージェント](agents.md)を参照してください。 - `Agent.as_tool()` とマネージャー方式のオーケストレーションについては、[ツール](tools.md#agents-as-tools)を参照してください。 diff --git a/docs/ja/quickstart.md b/docs/ja/quickstart.md index 4af9a86d96..8d78b84e23 100644 --- a/docs/ja/quickstart.md +++ b/docs/ja/quickstart.md @@ -4,7 +4,7 @@ search: --- # クイックスタート -## プロジェクトと仮想環境の作成 +## プロジェクトと仮想環境の作成 {#create-a-project-and-virtual-environment} これは一度だけ行えば十分です。 @@ -14,7 +14,7 @@ cd my_project python -m venv .venv ``` -### 仮想環境の有効化 +### 仮想環境の有効化 {#activate-the-virtual-environment} 新しいターミナルセッションを開始するたびに行ってください。 @@ -30,13 +30,13 @@ Windows の場合: .venv\Scripts\activate ``` -### Agents SDK のインストール +### Agents SDK のインストール {#install-the-agents-sdk} ```bash pip install openai-agents # or `uv add openai-agents`, etc ``` -### OpenAI API キーの設定 +### OpenAI API キーの設定 {#set-an-openai-api-key} まだ持っていない場合は、[こちらの手順](https://platform.openai.com/docs/quickstart#create-and-export-an-api-key)に従って OpenAI API キーを作成してください。 @@ -60,7 +60,7 @@ Windows コマンドプロンプトの場合: set "OPENAI_API_KEY=sk-..." ``` -## 最初のエージェントの作成 +## 最初のエージェントの作成 {#create-your-first-agent} エージェントは、instructions、名前、および特定のモデルなどの任意の設定で定義します。 @@ -73,7 +73,7 @@ agent = Agent( ) ``` -## 最初のエージェントの実行 +## 最初のエージェントの実行 {#run-your-first-agent} [`Runner`][agents.run.Runner] を使用してエージェントを実行し、[`RunResult`][agents.result.RunResult] を取得します。 @@ -108,7 +108,7 @@ if __name__ == "__main__": タスクが主にプロンプト、ツール、会話状態で完結する場合は、シンプルな `Agent` と `Runner` を使います。エージェントが分離されたワークスペース内の実ファイルを検査または変更する必要がある場合は、[Sandbox エージェントのクイックスタート](sandbox_agents.md)に進んでください。 -## エージェントへのツールの付与 +## エージェントへのツールの付与 {#give-your-agent-tools} エージェントにツールを与えることで、情報を調べたりアクションを実行したりできます。 @@ -143,7 +143,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## さらにいくつかのエージェントの追加 +## さらにいくつかのエージェントの追加 {#add-a-few-more-agents} マルチエージェントパターンを選ぶ前に、最終回答の主導権を誰が持つべきかを決めてください。 @@ -170,7 +170,7 @@ math_tutor_agent = Agent( ) ``` -## ハンドオフの定義 +## ハンドオフの定義 {#define-your-handoffs} エージェントには、タスクを解決する際に選択できるハンドオフ先の選択肢の一覧を定義できます。 @@ -182,7 +182,7 @@ triage_agent = Agent( ) ``` -## エージェントオーケストレーションの実行 +## エージェントオーケストレーションの実行 {#run-the-agent-orchestration} ランナーは、個々のエージェントの実行、すべてのハンドオフ、すべてのツール呼び出しを処理します。 @@ -204,7 +204,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 参考コード例 +## 参考コード例 {#reference-examples} このリポジトリには、同じ主要パターンに対応する完全なスクリプトが含まれています: @@ -212,11 +212,11 @@ if __name__ == "__main__": - [`examples/basic/tools.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/tools.py) は関数ツールの例です。 - [`examples/agent_patterns/routing.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/routing.py) はマルチエージェントルーティングの例です。 -## トレースの表示 +## トレースの表示 {#view-your-traces} エージェントの実行中に何が起きたかを確認するには、[OpenAI ダッシュボードのトレースビューアー](https://platform.openai.com/traces)に移動して、エージェント実行のトレースを表示してください。 -## 次のステップ +## 次のステップ {#next-steps} より複雑なエージェント型フローの構築方法を学びましょう: diff --git a/docs/ja/realtime/guide.md b/docs/ja/realtime/guide.md index 5c2526e450..c2ea6f936e 100644 --- a/docs/ja/realtime/guide.md +++ b/docs/ja/realtime/guide.md @@ -10,7 +10,7 @@ search: デフォルトの Python パスを使用する場合は、まず[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用すべきか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれていません。 -## 概要 +## 概要 {#overview} リアルタイムエージェントは Realtime API への長時間接続を維持するため、モデルは各ターンで新しいリクエストを開始し直すことなく、テキストとオーディオの段階的な処理、オーディオ出力のストリーミング、ツールの呼び出し、中断への対応を行えます。 @@ -21,7 +21,7 @@ SDK の主要コンポーネントは次のとおりです。 - **RealtimeSession**: 入力の送信、イベントの受信、履歴の追跡、ツールの実行を行うライブセッション - **RealtimeModel**: トランスポートの抽象化。デフォルトは OpenAI のサーバー側 WebSocket 実装です。 -## セッションのライフサイクル +## セッションのライフサイクル {#session-lifecycle} 一般的なリアルタイムセッションは次のようになります。 @@ -38,7 +38,7 @@ SDK の主要コンポーネントは次のとおりです。 Realtime API サーバーがデフォルトの WebSocket 接続を正常に閉じると、モデルトランスポートは `disconnected` の [`RealtimeModelConnectionStatusEvent`][agents.realtime.model_events.RealtimeModelConnectionStatusEvent] を生成し、続いて [`RealtimeModelEndOfStreamEvent`][agents.realtime.model_events.RealtimeModelEndOfStreamEvent] を生成します。`RealtimeSession` は両方を `raw_model_event` 内で転送し、すでにキューに入っているイベントを処理した後、例外を発生させずに非同期反復を終了します。呼び出し元が開始した `session.close()` では、これらのサーバー切断イベントは合成されません。予期しない WebSocket 障害は、通常のサーバー切断として反復を終了するのではなく、引き続きセッションの例外処理パスを通ります。 -## エージェントとセッションの設定 +## エージェントとセッションの設定 {#agent-and-session-configuration} `RealtimeAgent` は、通常の `Agent` 型よりも意図的に対象範囲が狭くなっています。 @@ -91,7 +91,7 @@ runner = RealtimeRunner( 型付き API の全体については、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 -### 入力文字起こし設定 +### 入力文字起こし設定 {#input-transcription-settings} 入力の文字起こしは `audio.input.transcription` で設定します。低レイテンシーの段階的な文字起こしには `gpt-live-transcribe` を使用します。オーディオターンのコミット後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合は、WebSocket 経由で `gpt-transcribe` を使用します。Agents SDK は、モデル固有の GA 文字起こし設定をネストされたセッション設定で転送します。 @@ -145,9 +145,9 @@ WebSocket 経由の Realtime セッションで `gpt-transcribe` を使用する `audio.input.turn_detection` を `None` に設定すると、自動ターン検出が無効になります。その場合、アプリケーションは[手動レスポンス制御](#manual-response-control)の説明に従って、オーディオターンをコミットし、レスポンスの作成を制御する必要があります。モデルの動作、検証ルール、レイテンシーのガイダンスについては、OpenAI API の [Realtime 文字起こしガイド](https://developers.openai.com/api/docs/guides/realtime-transcription)を参照してください。 -## 入出力 +## 入出力 {#inputs-and-outputs} -### テキストと構造化されたユーザーメッセージ +### テキストと構造化されたユーザーメッセージ {#text-and-structured-user-messages} プレーンテキストまたは構造化されたリアルタイムメッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] を使用します。 @@ -169,7 +169,7 @@ await session.send_message(message) 構造化メッセージは、リアルタイム会話に画像入力を含めるための主要な方法です。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) のサンプル Web デモでは、`input_image` メッセージをこの方法で転送します。 -### オーディオ入力 +### オーディオ入力 {#audio-input} raw オーディオバイトをストリーミングするには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用します。 @@ -185,7 +185,7 @@ await session.send_audio(audio_bytes, commit=True) より低レベルの制御が必要な場合は、`input_audio_buffer.commit` などの Realtime API クライアントイベントを、基盤となるモデルトランスポート経由で直接送信することもできます。 -### 手動レスポンス制御 +### 手動レスポンス制御 {#manual-response-control} `session.send_message()` は高レベルのパスを使用してユーザー入力を送信し、レスポンスを開始します。一部の設定では、raw オーディオのバッファリングだけでは同じ処理が **自動的には** 行われません。 @@ -213,7 +213,7 @@ await session.model.send_event( [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) の SIP コード例では、raw の `response.create` を使用して最初の挨拶を強制的に生成します。 -## イベント、履歴、中断 +## イベント、履歴、中断 {#events-history-and-interruptions} `RealtimeSession` は高レベルの SDK イベントを生成すると同時に、必要に応じて raw モデルイベントも転送します。 @@ -231,7 +231,7 @@ await session.model.send_event( UI の状態管理に最も役立つイベントは、通常 `history_added` と `history_updated` です。これらは、ユーザーメッセージ、アシスタントメッセージ、ツール呼び出しを含むセッションのローカル履歴を `RealtimeItem` オブジェクトとして公開します。 -### 使用量の集計 +### 使用量の集計 {#usage-accounting} 完了したモデルレスポンスに使用量が含まれる場合、SDK の OpenAI `RealtimeModel` トランスポートは、`raw_model_event` 内で [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent] を生成します。その `usage` フィールドにはそのレスポンスのトークン数が含まれ、`input_tokens_details` と `output_tokens_details` には任意のモダリティ別内訳が含まれます。 @@ -255,7 +255,7 @@ async for event in session: 使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めた場合にのみ報告されます。累積値は、その `RealtimeSession` が受信したレスポンスを対象とし、複数のセッションをまたぐ合計値ではありません。 -### 中断と再生トラッキング +### 中断と再生トラッキング {#interruptions-and-playback-tracking} ユーザーがアシスタントを中断すると、セッションは `audio_interrupted` を生成し、ユーザーが実際に聞いた内容とサーバー側の会話が一致するように履歴を更新します。 @@ -263,9 +263,9 @@ async for event in session: [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) の Twilio コード例で、このパターンを確認できます。 -## ツール、承認、ハンドオフ、ガードレール +## ツール、承認、ハンドオフ、ガードレール {#tools-approvals-handoffs-and-guardrails} -### 関数ツール +### 関数ツール {#function-tools} リアルタイムエージェントは、ライブ会話中の関数ツールに対応しています。 @@ -286,7 +286,7 @@ agent = RealtimeAgent( ) ``` -### ツール承認 +### ツール承認 {#tool-approvals} 関数ツールでは、実行前に人間による承認を必須にできます。その場合、セッションは `tool_approval_required` を生成し、`approve_tool_call()` または `reject_tool_call()` を呼び出すまでツールの実行を一時停止します。 @@ -300,7 +300,7 @@ async for event in session: 具体的なサーバー側の承認ループについては、[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) を参照してください。Human-in-the-loop のドキュメントでも、[Human in the loop](../human_in_the_loop.md)でこのフローを参照しています。 -### ハンドオフ +### ハンドオフ {#handoffs} リアルタイムハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を転送できます。 @@ -326,7 +326,7 @@ main_agent = RealtimeAgent( ハンドオフとして直接使用される `RealtimeAgent` オブジェクトは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、可用性をカスタマイズできます。リアルタイムハンドオフは、通常のハンドオフの `input_filter` には対応していません。 -### ガードレール +### ガードレール {#guardrails} リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールに対応しています。出力ガードレールのチェックにはデバウンスが適用されます。各チェックは、部分的な差分ごとではなく、蓄積された出力テキストとオーディオ文字起こしの差分に対して実行され、例外を発生させる代わりに `guardrail_tripped` を生成します。 @@ -352,7 +352,7 @@ agent = RealtimeAgent( カスタムの `RealtimeModel` トランスポートでは、同じ発生元スコープのオーディオ中断動作を提供するために、`RealtimeModelSendInterrupt.response_id` と `playback_only` を遵守する必要があります。また、テキストのみの出力パスで復旧メッセージに対応するには、`RealtimeModel.send_event_if()` をオーバーライドする必要があります。実装では、トランスポートが実際にイベントをコミットする境界で指定された条件を再確認するか、条件チェックとイベントのコミットを直列化する必要があります。デフォルト実装は復旧メッセージを安全にスキップします。条件を一度確認してからイベントを別途送信すると、その確認とイベントのコミットの間に別のレスポンスが開始される可能性があるためです。レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 -## SIP とテレフォニー +## SIP とテレフォニー {#sip-and-telephony} Python SDK には、[`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] を介した第一級の SIP 接続フローが含まれています。 @@ -375,7 +375,7 @@ async with await runner.run( 先に通話を受け入れる必要があり、その受け入れペイロードをエージェントから導出されたセッション設定と一致させたい場合は、`OpenAIRealtimeSIPModel.build_initial_session_payload(...)` を使用します。完全なフローは [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) で確認できます。 -## 低レベルアクセスとカスタムエンドポイント +## 低レベルアクセスとカスタムエンドポイント {#low-level-access-and-custom-endpoints} `session.model` を介して、基盤となるトランスポートオブジェクトにアクセスできます。 @@ -421,7 +421,7 @@ session = await runner.run( `headers` を渡した場合、SDK は `Authorization` を自動的には追加しません。リアルタイムエージェントでは、従来のベータパス(`/openai/realtime?api-version=...`)を使用しないでください。 -## 関連資料 +## 関連資料 {#further-reading} - [リアルタイムトランスポート](transport.md) - [クイックスタート](quickstart.md) diff --git a/docs/ja/realtime/quickstart.md b/docs/ja/realtime/quickstart.md index 34703627f0..69776192bb 100644 --- a/docs/ja/realtime/quickstart.md +++ b/docs/ja/realtime/quickstart.md @@ -10,13 +10,13 @@ Python SDK のリアルタイムエージェントは、WebSocket トランス Python SDK は、ブラウザー向け WebRTC トランスポートを **提供しません** 。このページでは、サーバー側の WebSocket を介して Python で管理されるリアルタイムセッションのみを扱います。この SDK は、サーバー側のオーケストレーション、ツール、承認、テレフォニー統合に使用してください。[リアルタイムトランスポート](transport.md)も参照してください。 -## 前提条件 +## 前提条件 {#prerequisites} - Python 3.10 以降 - OpenAI API キー - OpenAI Agents SDKの基本的な知識 -## インストール +## インストール {#installation} まだインストールしていない場合は、OpenAI Agents SDKをインストールします。 @@ -24,9 +24,9 @@ Python SDK のリアルタイムエージェントは、WebSocket トランス pip install openai-agents ``` -## サーバー側リアルタイムセッションの作成 +## サーバー側リアルタイムセッションの作成 {#create-a-server-side-realtime-session} -### 1. リアルタイムコンポーネントのインポート +### 1. リアルタイムコンポーネントのインポート {#1-import-the-realtime-components} ```python import asyncio @@ -34,7 +34,7 @@ import asyncio from agents.realtime import RealtimeAgent, RealtimeRunner ``` -### 2. 開始エージェントの定義 +### 2. 開始エージェントの定義 {#2-define-the-starting-agent} ```python agent = RealtimeAgent( @@ -43,7 +43,7 @@ agent = RealtimeAgent( ) ``` -### 3. ランナーの設定 +### 3. ランナーの設定 {#3-configure-the-runner} 新しいコードでは、ネストされた `audio.input` / `audio.output` セッション設定形式を推奨します。新しいリアルタイムエージェントでは、`gpt-realtime-2.1` から始めてください。 @@ -72,7 +72,7 @@ runner = RealtimeRunner( ) ``` -### 4. セッションの開始と入力の送信 +### 4. セッションの開始と入力の送信 {#4-start-the-session-and-send-input} `runner.run()` は `RealtimeSession` を返します。セッションコンテキストに入ると、接続が開かれます。 @@ -102,12 +102,12 @@ if __name__ == "__main__": `session.send_message()` は、プレーン文字列または構造化されたリアルタイムメッセージを受け付けます。raw オーディオチャンクには、[`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] を使用してください。 -## 本クイックスタートの対象外 +## 本クイックスタートの対象外 {#what-this-quickstart-does-not-include} - マイク入力とスピーカー再生のコード。[`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) のリアルタイムコード例を参照してください。 - SIP / テレフォニーの接続フロー。[リアルタイムトランスポート](transport.md)および [SIP セクション](guide.md#sip-and-telephony)を参照してください。 -## 主要な設定 +## 主要な設定 {#key-settings} 基本的なセッションが動作した後、多くの場合に次に使用される設定は以下のとおりです。 @@ -126,7 +126,7 @@ if __name__ == "__main__": 完全なスキーマについては、[`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] および [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings] を参照してください。 -## 接続オプション +## 接続オプション {#connection-options} 環境変数に API キーを設定します。 @@ -151,7 +151,7 @@ session = await runner.run(model_config={"api_key": "your-api-key"}) Azure OpenAIに接続する場合は、`model_config["url"]` を GA 版 Realtime エンドポイント URL に設定し、ヘッダーを明示的に渡してください。リアルタイムエージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を避けてください。詳細については、[リアルタイムエージェントガイド](guide.md#low-level-access-and-custom-endpoints)を参照してください。 -## 次のステップ +## 次のステップ {#next-steps} - サーバー側 WebSocket と SIP のどちらを使用するか選択するには、[リアルタイムトランスポート](transport.md)をお読みください。 - ライフサイクル、構造化入力、承認、ハンドオフ、ガードレール、低レベル制御については、[リアルタイムエージェントガイド](guide.md)をお読みください。 diff --git a/docs/ja/realtime/transport.md b/docs/ja/realtime/transport.md index c887ff3adb..bc2f1495c7 100644 --- a/docs/ja/realtime/transport.md +++ b/docs/ja/realtime/transport.md @@ -10,7 +10,7 @@ search: Python SDK には、ブラウザー向け WebRTC トランスポートは **含まれていません** 。このページでは、Python SDK のトランスポートの選択肢である、サーバー側 WebSocket と SIP 接続フローのみを扱います。ブラウザー WebRTC は別のプラットフォームトピックであり、公式の [WebRTC を使用する Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) ガイドに記載されています。 -## 選択ガイド +## 選択ガイド {#decision-guide} | 目的 | 最初に参照するもの | 理由 | | --- | --- | --- | @@ -18,7 +18,7 @@ search: | 選択すべきトランスポートとデプロイ構成を理解する | このページ | トランスポートまたはデプロイ構成を決定する前に、このページを参照してください。 | | エージェントを電話または SIP 通話に接続する | [リアルタイムガイド](guide.md)および [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | このリポジトリには、`call_id` によって駆動される SIP 接続フローが含まれています。 | -## Python のデフォルトパスとなるサーバー側 WebSocket +## Python のデフォルトパスとなるサーバー側 WebSocket {#server-side-websocket-is-the-default-python-path} カスタムの `RealtimeModel` を渡さない限り、`RealtimeRunner` は `OpenAIRealtimeWebSocketModel` を使用します。 @@ -37,7 +37,7 @@ search: サーバーが音声パイプライン、ツール実行、承認フロー、および履歴処理を担う場合は、このパスを使用してください。 -### 低レベル WebSocket の調整 +### 低レベル WebSocket の調整 {#low-level-websocket-tuning} 基盤となるサーバー側 WebSocket 接続を調整する必要がある場合は、`OpenAIRealtimeWebSocketModel` に `transport_config` を渡します。 @@ -69,7 +69,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) これらの設定は Realtime APIセッションではなく、クライアント接続を構成します。エンドポイント、認証、通話への接続、および再生設定には、引き続き `RealtimeModelConfig` を使用してください。 -## 電話通信向けの SIP 接続 +## 電話通信向けの SIP 接続 {#sip-attach-is-the-telephony-path} このリポジトリに記載されている電話通信フローでは、Python SDK は `call_id` を介して既存のリアルタイム通話に接続します。 @@ -84,7 +84,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) より広範な Realtime APIでは、一部のサーバー側制御パターンに `call_id` も使用しますが、このリポジトリに含まれる接続のコード例では SIP を使用しています。 -## SDK の対象外となるブラウザー WebRTC +## SDK の対象外となるブラウザー WebRTC {#browser-webrtc-is-outside-this-sdk} アプリの主要クライアントが Realtime WebRTC を使用するブラウザーである場合は、次の点に注意してください。 @@ -95,7 +95,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) また、このリポジトリには現在、ブラウザー WebRTC と Python サイドバンドを組み合わせたコード例も含まれていません。 -## カスタムエンドポイントと接続ポイント +## カスタムエンドポイントと接続ポイント {#custom-endpoints-and-attach-points} [`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] のトランスポート設定インターフェースを使用すると、デフォルトのトランスポート動作をカスタマイズできます。 diff --git a/docs/ja/release.md b/docs/ja/release.md index 5a69f09a07..c79b1f7d61 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -6,13 +6,13 @@ search: このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングを一部変更した方式に従います。先頭の `0` は、SDK が現在も急速に進化していることを示します。各構成要素は次のように増分します。 -## マイナー(`Y`)バージョン +## マイナー(`Y`)バージョン {#minor-y-versions} ベータと明記されていない公開インターフェースに **破壊的変更** が加えられる場合、マイナーバージョン `Y` を増分します。たとえば、`0.0.x` から `0.1.x` への変更には、破壊的変更が含まれる可能性があります。 破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 -## パッチ(`Z`)バージョン +## パッチ(`Z`)バージョン {#patch-z-versions} 非破壊的変更では `Z` を増分します。 @@ -21,9 +21,9 @@ search: - 非公開インターフェースへの変更 - ベータ機能の更新 -## 破壊的変更の履歴 +## 破壊的変更の履歴 {#breaking-change-changelog} -### 0.22.0 +### 0.22.0 {#0220} バージョン 0.22.0 では、既存の複数の API に対する失敗処理とデータ分離が強化されました。明示的なクライアントを指定して `OpenAIProvider` を構築し、さらにプロバイダーへ `organization` または `project` を渡しているアプリケーションでは、重複するこれらの引数を削除する必要があります。 @@ -36,7 +36,7 @@ search: - エージェントの可視化では、`handoff(agent)` で登録された対象のツール、MCP サーバー、および後続のハンドオフが再帰的に展開されるようになりました。これは、エージェントの `handoffs` リスト内の直接的な `Agent` エントリと同じ動作です。[グラフの生成](visualization.md#generating-a-graph)を参照してください。 - `Agent.clone()` および `RealtimeAgent.clone()` の API ガイダンスでは、既存のシャローコピー動作を正確に説明するようになりました。オーバーライドされていないリスト属性は、同じリストオブジェクトのままです。クローンがコンテナーを独立して所有する必要がある場合は、新しいリストを渡してください。[エージェントのクローン/コピー](agents.md#cloningcopying-agents)を参照してください。 -### 0.21.0 +### 0.21.0 {#0210} バージョン 0.21.0 では `openai` v3 が必須となり、Agents SDK の OpenAI HTTP 統合が HTTPX2 に移行されました。デフォルトの OpenAI クライアントを使用するアプリケーションではクライアント設定を変更する必要はありませんが、OpenAI HTTP レイヤーをカスタマイズしているアプリケーションでは、トランスポート関連コードの移行が必要になる場合があります。 @@ -49,7 +49,7 @@ search: - ローカル MCP の HTTP カスタマイズでは、引き続きインストール済みの MCP パッケージに従います。MCP Python SDK v1 は従来の `httpx` を提供して使用し、MCP Python SDK v2 は `httpx2` を使用します。通常の MCP 接続では、アプリケーションを変更する必要はありません。[MCP Python SDK v1 および v2](mcp.md#mcp-python-sdk-v1-and-v2)を参照してください。 - 公開されたプロバイダー非依存のテストユーティリティで、プロバイダーやプロセスへの依存なしに、エージェントモデル、サンドボックスセッション、Realtime セッション、および音声パイプラインのワークフローを扱えるようになりました。実際のプロバイダーアダプターまたは統合境界を維持すべき場合のレシピとガイダンスについては、[テスト](testing.md)を参照してください。 -### 0.20.0 +### 0.20.0 {#0200} バージョン 0.20.0 には、ローカル MCP HTTP トランスポートをカスタマイズするアプリケーションにとって破壊的となる可能性がある MCP 依存関係の移行が含まれます。また、エージェントまたは実行でモデルを明示的に選択しない場合に使用される SDK のデフォルトモデルも更新されました。 @@ -64,7 +64,7 @@ search: - 再開可能な `RunState` オブジェクトでは、次回のモデル呼び出し前に `add_input()` を使用して永続的なユーザー入力を準備できるようになりました。準備された入力はシリアライズ後も保持され、入力ガードレールを通過し、ローカルセッションおよびサーバー管理の会話全体で永続的な SDK 入力を 1 回生成します。安全でない再実行が明示的に承認されている場合、入力がプロバイダーへ再送信され、プロバイダー側の処理が繰り返される可能性があります。[再開前の入力追加](results.md#add-input-before-resuming)を参照してください。 - 実行時の信頼性修正により、ストリーミングと非ストリーミングの[出力ガードレールのセッション永続化](guardrails.md#output-guardrails)が統一され、コピーおよび名前空間化の際に `FunctionTool` のサブクラスが維持されるようになりました。また、[未対応の Chat Completions 音声出力](models/index.md#chat-completions-compatibility-options)では、空のストリームを暗黙的に完了する代わりに、明示的なエラーが送出されるようになりました。`OpenAIResponsesCompactionSession` ラッパーは、キャンセルが呼び出し元へ到達する前に、[コンパクション前の履歴復元](sessions/index.md#auto-compaction-can-block-streaming)を試行して完了を待ちます。[`VoicePipeline`](voice/pipeline.md#results) のコンシューマーは、正常な実行後に文字起こしセッションのクローズが失敗した場合、その失敗を受け取るようになりました。一方、先に発生したターンの失敗は、後から発生したクローズの失敗より優先されます。`RunState` のラウンドトリップでは、ローカルシェル出力、承認済みのコンピューター安全性チェック、デフォルト値のツール出力フィールド、および辞書、リスト、タプルの走査中に検出された Pydantic モデルまたは dataclass の出力が維持されるようになりました。MCP 変換では、自由形式のオブジェクトスキーマと画像出力が維持され、音声ブロックやリソースブロックなど、その他の raw コンテンツブロックは有効な JSON テキストとしてシリアライズされます。`MCPServerManager` は、重複するライフサイクル操作を直列化し、接続とクリーンアップに有限のデフォルトタイムアウトを適用します。モデルの再実行では、出力項目を入力として使用する前に、サーバー所有の `created_by` メタデータが削除されます。 -### 0.19.0 +### 0.19.0 {#0190} このマイナーリリースでは、破壊的変更は導入されて **いません**。マイナーバージョンの増分は、OpenAI Responses の重要な新機能領域である Programmatic Tool Calling を反映したものです。 @@ -77,7 +77,7 @@ search: - AnyLLM、LiteLLM、および Chat Completions の互換性が向上し、モデルのリトライ間でセッション履歴が維持されるようになりました。また、レスポンス開始前に発生した WebSocket の過負荷に関するプロバイダーのリトライガイダンスが追加され、オプトインした Runner のリトライポリシーで、許可されている場合に失敗した試行を再実行できるようになりました。 - `VercelCloudBucketMountStrategy` を通じて、[Vercel サンドボックスの作成時にのみ設定できる S3 マウント](sandbox/clients.md#mounts-and-remote-storage)が追加されました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外され、動的なマウント変更やセッション再開は意図的にサポートされません。 -### 0.18.0 +### 0.18.0 {#0180} このマイナーリリースでは、破壊的変更は導入されて **いません**。マイナーバージョンの増分は、Realtime エージェントのデフォルトモデル更新のみを目的としています。 @@ -85,7 +85,7 @@ search: - Realtime エージェントのデフォルトモデルとして `gpt-realtime-2.1` が使用されるようになり、新しい Realtime 設定では追加設定なしで最新の推奨モデルが使用されます。 -### 0.17.0 +### 0.17.0 {#0170} このバージョンでは、ソースパスが `Manifest.extra_path_grants` の対象でない限り、サンドボックスのローカルソースの実体化において、`LocalFile.src` と `LocalDir.src` が実体化の `base_dir` 内に保持されます。`base_dir` は、マニフェストが適用される時点での SDK プロセスの現在の作業ディレクトリです。相対ローカルソースはそのディレクトリを基準に解決されますが、絶対ローカルソースは、すでにそのディレクトリ内または明示的に許可された範囲内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼済みホストのファイルまたはディレクトリを、そのベースディレクトリの外部からサンドボックスワークスペースへ意図的にコピーするアプリケーションに影響する可能性があります。 @@ -118,7 +118,7 @@ manifest = Manifest( `extra_path_grants` は、信頼済みアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可設定を作成しないでください。 -### 0.16.0 +### 0.16.0 {#0160} このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルトが含まれるようになりました。 @@ -133,7 +133,7 @@ agent = Agent(name="Assistant", model="gpt-4.1") - `Runner.run`、`Runner.run_sync`、`Runner.run_streamed` では、ターン制限を無効にするための `max_turns=None` を受け入れるようになりました。 - サンドボックスワークスペースのハイドレーションでは、ローカル、Docker、プロバイダー対応の各サンドボックス実装において、絶対シンボリックリンク先を含め、アーカイブルートの外部を指すシンボリックリンクを含む tar アーカイブを拒否するようになりました。 -### 0.15.0 +### 0.15.0 {#0150} このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に実行ループが `MaxTurnsExceeded` までリトライされたりする代わりに、`ModelRefusalError` として明示的に提示されるようになりました。 @@ -149,7 +149,7 @@ result = Runner.run_sync( structured outputs を使用するエージェントでは、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 -### 0.14.0 +### 0.14.0 {#0140} このマイナーリリースでは、破壊的変更は導入されて **いません**。ただし、主要な新しいベータ機能領域であるサンドボックスエージェントに加え、ローカル、コンテナー化、ホスト環境で使用するために必要なランタイム、バックエンド、ドキュメントのサポートが追加されています。 @@ -162,7 +162,7 @@ structured outputs を使用するエージェントでは、ハンドラーは - `examples/sandbox/` 配下に、多数のサンドボックスのコード例とチュートリアルが追加されました。スキル、ハンドオフ、メモリを使用するコーディングタスク、プロバイダー固有の設定、コードレビュー、データルーム QA、Web サイトのクローン作成などのエンドツーエンドワークフローを扱います。 - サンドボックス対応のセッション準備、機能のバインド、状態のシリアライズ、統合トレーシング、プロンプトキャッシュキーデフォルト、および機密性の高い MCP 出力のより安全な秘匿化により、コアランタイムとトレーシングスタックが拡張されました。 -### 0.13.0 +### 0.13.0 {#0130} このマイナーリリースでは、破壊的変更は導入されて **いません**。ただし、注目すべき Realtime のデフォルト更新、新しい MCP 機能、およびランタイムの安定性修正が含まれます。 @@ -173,15 +173,15 @@ structured outputs を使用するエージェントでは、ハンドラーは - Chat Completions 統合では、`should_replay_reasoning_content` を使用して既存の推論内容を再送信することをオプトインできるようになり、LiteLLM/DeepSeek などのアダプターで、プロバイダー固有の推論およびツール呼び出しの連続性が向上しました。 - `SQLAlchemySession` での同時初回書き込み、推論除去後に孤立した assistant メッセージ ID を含むコンパクションリクエスト、MCP/推論項目を残す `remove_all_tools()`、`FunctionTool` インスタンス向けバッチエグゼキューターの競合状態など、複数のランタイムおよびセッションのエッジケースが修正されました。 -### 0.12.0 +### 0.12.0 {#0120} このマイナーリリースでは、破壊的変更は導入されて **いません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)を確認してください。 -### 0.11.0 +### 0.11.0 {#0110} このマイナーリリースでは、破壊的変更は導入されて **いません**。主要な機能追加については、[リリースノート](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)を確認してください。 -### 0.10.0 +### 0.10.0 {#0100} このマイナーリリースでは、破壊的変更は導入されて **いません**。ただし、OpenAI Responses のユーザー向けに、Responses API の WebSocket トランスポートサポートという重要な新機能領域が含まれます。 @@ -191,50 +191,50 @@ structured outputs を使用するエージェントでは、ハンドラーは - 複数ターンの実行にわたって、WebSocket 対応の共有プロバイダーと `RunConfig` を再利用するための `responses_websocket_session()` ヘルパー/`ResponsesWebSocketSession` が追加されました。 - ストリーミング、ツール、承認、後続ターンを扱う新しい WebSocket ストリーミングのコード例(`examples/basic/stream_ws.py`)が追加されました。 -### 0.9.0 +### 0.9.0 {#090} このバージョンでは、このメジャーバージョンが 3 か月前に EOL を迎えたため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンへアップグレードしてください。 さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に絞り込まれました。通常、この変更によって破壊的な問題が発生することはありませんが、コードがより広いユニオン型に依存している場合は、調整が必要になる可能性があります。 -### 0.8.0 +### 0.8.0 {#080} このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる場合があります。 - `FunctionTool` インスタンスがラップする **同期** Python callable は、イベントループスレッド上で実行される代わりに、`asyncio.to_thread(...)` を介してワーカースレッド上で実行されるようになりました。ツールロジックがスレッドローカル状態またはスレッドアフィニティを持つリソースに依存している場合は、非同期ツール実装へ移行するか、ツールコード内でスレッドアフィニティを明示してください。 - ローカル MCP ツールの失敗処理が設定可能になり、デフォルト動作では実行全体を失敗させる代わりに、モデルから参照可能なエラー出力を返せるようになりました。即時失敗のセマンティクスに依存している場合は、`mcp_config={"failure_error_function": None}` を設定してください。サーバーレベルの `failure_error_function` 値はエージェントレベルの設定をオーバーライドするため、明示的なハンドラーを持つ各ローカル MCP サーバーで `failure_error_function=None` を設定してください。 -### 0.7.0 +### 0.7.0 {#070} このバージョンでは、既存のアプリケーションに影響する可能性がある動作変更がいくつかあります。 - ネストされたハンドオフ履歴は、**オプトイン** 方式(デフォルトでは無効)になりました。v0.6.x のデフォルトのネスト動作に依存していた場合は、`RunConfig(nest_handoff_history=True)` を明示的に設定してください。 - `gpt-5.1`/`gpt-5.2` のデフォルトの `reasoning.effort` が、SDK のデフォルトで設定されていた以前のデフォルト `"low"` から `"none"` に変更されました。プロンプトまたは品質/コストの特性が `"low"` に依存していた場合は、`model_settings` で明示的に設定してください。 -### 0.6.0 +### 0.6.0 {#060} このバージョンでは、ユーザーと assistant の各ターンを別々のメッセージとして渡す代わりに、デフォルトのハンドオフ履歴が単一の assistant メッセージにまとめられるようになり、後続のエージェントに簡潔で予測可能な要約が提供されます - 既存の単一メッセージによるハンドオフのトランスクリプトは、デフォルトで `` ブロックの前に正確なリテラルテキスト `For context, here is the conversation so far between the user and the previous agent:` から始まるようになり、後続のエージェントに明確なラベル付きの要約が提供されます -### 0.5.0 +### 0.5.0 {#050} このバージョンでは、目に見える破壊的変更は導入されていませんが、新機能と内部の重要な更新がいくつか含まれています。 - `RealtimeRunner` に、[SIP プロトコル接続](https://platform.openai.com/docs/guides/realtime-sip)を処理するためのサポートが追加されました。 - Python 3.14 との互換性のため、`Runner#run_sync` の内部ロジックが大幅に改訂されました -### 0.4.0 +### 0.4.0 {#040} このバージョンでは、[openai](https://pypi.org/project/openai/) パッケージの v1.x バージョンはサポートされなくなりました。この SDK とともに openai v2.x を使用してください。 -### 0.3.0 +### 0.3.0 {#030} このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA バージョン)へ移行します。 -### 0.2.0 +### 0.2.0 {#020} このバージョンでは、以前は引数として `Agent` を受け取っていた箇所の一部が、代わりに `AgentBase` を受け取るようになりました。たとえば、これは MCP サーバーの `list_tools()` メソッドシグネチャに適用されます。これは型指定のみの変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正してください。 -### 0.1.0 +### 0.1.0 {#010} このバージョンでは、[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] に `run_context` と `agent` という 2 つの新しいパラメーターが追加されました。`MCPServer` のサブクラスでオーバーライドされているすべての `MCPServer.list_tools()` メソッドに、これらのパラメーターを追加する必要があります。 \ No newline at end of file diff --git a/docs/ja/results.md b/docs/ja/results.md index de3be919c0..54f5e36f90 100644 --- a/docs/ja/results.md +++ b/docs/ja/results.md @@ -13,7 +13,7 @@ search: `RunResultStreaming` には、[`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete]、[`cancel(...)`][agents.result.RunResultStreaming.cancel] など、ストリーミング固有の制御が追加されています。 -## 適切な実行結果サーフェスの選択 +## 適切な実行結果サーフェスの選択 {#choose-the-right-result-surface} ほとんどのアプリケーションで必要になる実行結果のプロパティやヘルパーは、ごく一部です。 @@ -28,7 +28,7 @@ search: | 現在のネストされた `Agent.as_tool()` 呼び出しに関するメタデータ | `agent_tool_invocation` | | raw モデル呼び出しまたはガードレールの診断情報 | `raw_responses` とガードレールの実行結果配列 | -## 最終出力 +## 最終出力 {#final-output} [`final_output`][agents.result.RunResultBase.final_output] プロパティには、最後に実行されたエージェントの最終出力が含まれます。これは次のいずれかです。 @@ -42,7 +42,7 @@ search: ストリーミングモードでは、ストリームの処理が完了するまで `final_output` は `None` のままです。イベントごとのフローについては、[ストリーミング](streaming.md)を参照してください。 -## 入力、次ターンの履歴、新規項目 +## 入力、次ターンの履歴、新規項目 {#input-next-turn-history-and-new-items} これらのサーフェスは、それぞれ異なる問いに対応します。 @@ -67,7 +67,7 @@ JavaScript SDK とは異なり、Python には実行中に新たに生成され コンピュータツール項目を会話入力として再送信する場合は、raw Responses ペイロード形式が使用されます。プレビューモデルの `computer_call` 項目では単一の `action` が保持されますが、`gpt-5.5` のコンピュータ呼び出しでは、バッチ化された `actions[]` を保持できます。[`to_input_list()`][agents.result.RunResultBase.to_input_list] と [`RunState`][agents.run_state.RunState] はモデルが生成した形式をそのまま保持するため、これらの項目を会話入力として手動で再送信する処理、一時停止と再開のフロー、保存された会話記録は、プレビュー版と GA 版の両方のコンピュータツール呼び出しで引き続き機能します。ローカルの実行結果は、引き続き `new_items` 内に `computer_call_output` 項目として表示されます。 -### 新規項目 +### 新規項目 {#new-items} [`new_items`][agents.result.RunResultBase.new_items] では、実行中に発生した内容を最も詳細に確認できます。一般的な項目の型は次のとおりです。 @@ -110,15 +110,15 @@ caller_id = ( プログラムが所有する子呼び出しでは、`caller` の `type` フィールドは `program` であり、`caller_id` によって親プログラム呼び出しが識別されます。 -## 会話の続行または再開 +## 会話の続行または再開 {#continue-or-resume-the-conversation} -### 次ターンのエージェント +### 次ターンのエージェント {#next-turn-agent} [`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが含まれます。多くの場合、ハンドオフ後の次のユーザーターンで再利用するには、このエージェントが最適です。 ストリーミングモードでは、実行の進行に伴って [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が更新されるため、ストリームが完了する前にハンドオフを確認できます。 -### 中断と実行状態 +### 中断と実行状態 {#interruptions-and-run-state} ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接のツール、ハンドオフ後に到達したツール、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行によって発生した承認が含まれる場合があります。 @@ -139,7 +139,7 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -#### 再開前の入力追加 +#### 再開前の入力追加 {#add-input-before-resuming} 実行が一時停止した後、または完了済みのターンの後で停止した後に新しいユーザー入力が到着し、未完了の実行が次のモデル呼び出しに到達する前である場合は、[`RunState.add_input()`][agents.run_state.RunState.add_input] を使用します。文字列はユーザーメッセージになり、複数回呼び出した場合は挿入順が維持されます。ステージングされた入力はシリアライズ済みの `RunState` に含まれるため、`to_json()` / `from_json()` および `to_string()` / `from_string()` のラウンドトリップ後も維持されます。 @@ -159,13 +159,13 @@ result = await Runner.run(agent, state) ストリーミング実行では、まず [`stream_events()`][agents.result.RunResultStreaming.stream_events] の消費を完了してから、`result.interruptions` を確認し、`result.to_state()` から再開します。承認フロー全体については、[Human-in-the-loop](human_in_the_loop.md)を参照してください。 -### サーバー管理による続行 +### サーバー管理による続行 {#server-managed-continuation} [`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API のチェーンを続行する場合は、次のターンでこれを `previous_response_id` として渡します。 すでに `to_input_list()`、`session`、`conversation_id` を使用して会話を続行している場合、通常は `last_response_id` は必要ありません。複数ステップの実行からすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 -## エージェントをツールとして使用する場合のメタデータ +## エージェントをツールとして使用する場合のメタデータ {#agent-as-tool-metadata} 実行結果がネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行から得られた場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は、それを包含する `Agent.as_tool()` 呼び出しに関するイミュータブルなメタデータを公開します。 @@ -179,7 +179,7 @@ result = await Runner.run(agent, state) そのネストされた実行について、パース済みの構造化入力も必要な場合は、`context_wrapper.tool_input` を読み取ります。これは、[`RunState`][agents.run_state.RunState] がネストされたツール入力用に汎用的にシリアライズするフィールドです。一方、`agent_tool_invocation` は現在のネストされた呼び出しのメタデータを実行結果上で直接公開します。 -## ストリーミングのライフサイクルと診断 +## ストリーミングのライフサイクルと診断 {#streaming-lifecycle-and-diagnostics} [`RunResultStreaming`][agents.result.RunResultStreaming] は上記と同じ実行結果サーフェスを継承し、さらにストリーミング固有の制御を追加します。 @@ -194,7 +194,7 @@ result = await Runner.run(agent, state) Python には、ストリーミング用の独立した `completed` Promise または `error` プロパティはありません。実行を終了させるストリーミングエラーは `stream_events()` によって送出され、`is_complete` は実行が終端状態に到達したかどうかを示します。 -### raw レスポンス +### raw レスポンス {#raw-responses} [`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが含まれます。複数ステップの実行では、ハンドオフや繰り返されるモデル/ツール/モデルのサイクルなどにより、複数のレスポンスが生成される場合があります。 @@ -207,7 +207,7 @@ Python には、ストリーミング用の独立した `completed` Promise ま `ModelResponse.request_id` と `ModelResponse.raw_usage` はそれぞれ `None` になる可能性があるため、これらの値は会話状態ではなく、オプションの診断情報として扱ってください。 -### ガードレールの実行結果 +### ガードレールの実行結果 {#guardrail-results} エージェントレベルのガードレールは、[`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] と [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] として公開されます。 @@ -217,7 +217,7 @@ Python には、ストリーミング用の独立した `completed` Promise ま エージェントレベルの出力ガードレールが、終端となる関数ツールによって直接生成された最終出力をブロックした場合、1 つの秘匿化ルールが適用されます。ブロックされた現在のレスポンスでは、`output_guardrail_results` が拒否されたエージェント出力を置き換え、ペイロードを含む出力メタデータをクリアします。また、`tool_output_guardrail_results` がペイロードを含むツールメタデータを置き換えます。それ以前に受け入れられた実行結果は変更されません。サニタイズされた出力ガードレールの実行結果は、[`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] の `guardrail_result` として公開されます。サニタイズされた出力ガードレールとツール出力ガードレールの実行結果は、ストリーミングされた実行結果の状態と `RunState` からも公開されます。[出力ガードレール](guardrails.md#output-guardrails)を参照してください。 -### コンテキストと使用量 +### コンテキストと使用量 {#context-and-usage} [`context_wrapper`][agents.result.RunResultBase.context_wrapper] は、アプリのコンテキストに加えて、承認、使用量、ネストされた `tool_input` など、SDK が管理するランタイムメタデータを公開します。 diff --git a/docs/ja/running_agents.md b/docs/ja/running_agents.md index dc70b0d1b6..fc095d6705 100644 --- a/docs/ja/running_agents.md +++ b/docs/ja/running_agents.md @@ -25,9 +25,9 @@ async def main(): 詳しくは、[実行結果ガイド](results.md)をご覧ください。 -## Runner のライフサイクルと設定 +## Runner のライフサイクルと設定 {#runner-lifecycle-and-configuration} -### エージェントループ +### エージェントループ {#the-agent-loop} 上記 3 つの `Runner` メソッドのいずれかを呼び出すときは、開始エージェントと入力を渡します。入力には次のものを指定できます。 @@ -48,11 +48,11 @@ async def main(): LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しがないことです。 -### ストリーミング +### ストリーミング {#streaming} ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む実行の完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳しくは、[ストリーミングガイド](streaming.md)をご覧ください。 -#### Responses WebSocket トランスポート(オプションのヘルパー) +#### Responses WebSocket トランスポート(オプションのヘルパー) {#responses-websocket-transport-optional-helper} OpenAI Responses の WebSocket トランスポートを有効にしても、通常の `Runner` API を引き続き使用できます。接続を再利用する場合は WebSocket セッションヘルパーを推奨しますが、必須ではありません。 @@ -60,7 +60,7 @@ OpenAI Responses の WebSocket トランスポートを有効にしても、通 トランスポートの選択規則や、具象モデルオブジェクトまたはカスタムプロバイダーに関する注意事項については、[モデル](models/index.md#responses-websocket-transport)をご覧ください。 -##### パターン 1:セッションヘルパーなし(動作可能) +##### パターン 1:セッションヘルパーなし(動作可能) {#pattern-1-no-session-helper-works} WebSocket トランスポートだけが必要で、共有プロバイダーやセッションを SDK で管理する必要がない場合に使用します。 @@ -87,7 +87,7 @@ asyncio.run(main()) このパターンは、単一の実行には適しています。`Runner.run()` / `Runner.run_streamed()` を繰り返し呼び出すと、同じ `RunConfig` / プロバイダーインスタンスを手動で再利用しない限り、実行ごとに再接続される可能性があります。 -##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) +##### パターン 2:`responses_websocket_session()` の使用(複数ターンでの再利用に推奨) {#pattern-2-use-responses_websocket_session-recommended-for-multi-turn-reuse} 複数の実行で、WebSocket 対応の共有プロバイダーと `RunConfig` を使用する場合は、[`responses_websocket_session()`][agents.responses_websocket_session] を使用します。同じ `run_config` を継承する、エージェントをツールとして使用するネストされた呼び出しも対象です。 @@ -125,15 +125,15 @@ asyncio.run(main()) 長時間の推論ターンで WebSocket のキープアライブタイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。WebSocket のレイテンシより信頼性が重要な実行では、HTTP/SSE トランスポートを使用してください。 -### 実行設定 +### 実行設定 {#run-config} `run_config` パラメーターを使用すると、エージェントの実行に関する一部のグローバル設定を構成できます。 -#### 一般的な実行設定のカテゴリー +#### 一般的な実行設定のカテゴリー {#common-run-config-categories} 各エージェントの定義を変更せず、単一の実行だけ動作を上書きするには、`RunConfig` を使用します。 -##### モデル、プロバイダー、セッションのデフォルト +##### モデル、プロバイダー、セッションのデフォルト {#model-provider-and-session-defaults} - [`model`][agents.run.RunConfig.model]:各 Agent が持つ `model` に関係なく、使用するグローバルな LLM モデルを設定できます。 - [`model_provider`][agents.run.RunConfig.model_provider]:モデル名を検索するためのモデルプロバイダーです。デフォルトは OpenAI です。 @@ -141,7 +141,7 @@ asyncio.run(main()) - [`session_settings`][agents.run.RunConfig.session_settings]:実行中に履歴を取得する際のセッションレベルのデフォルト(たとえば `SessionSettings(limit=...)`)を上書きします。 - [`session_input_callback`][agents.run.RunConfig.session_input_callback]:Sessions の使用時に、各 `Runner` の実行前に新しいユーザー入力をセッション履歴と結合する方法をカスタマイズします。コールバックは同期または非同期にできます。 -##### ガードレール、ハンドオフ、モデル入力の整形 +##### ガードレール、ハンドオフ、モデル入力の整形 {#guardrails-handoffs-and-model-input-shaping} - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:すべての実行に含める入力または出力ガードレールのリストです。 - [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:ハンドオフに入力フィルターがまだない場合、すべてのハンドオフに適用するグローバル入力フィルターです。入力フィルターを使用すると、新しいエージェントに送信される入力を編集できます。詳しくは、[`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] のドキュメントをご覧ください。 @@ -150,7 +150,7 @@ asyncio.run(main()) - [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:モデル呼び出しの直前に、完全に準備されたモデル入力(instructions と入力項目)を編集するためのフックです。たとえば、履歴のトリミングやシステムプロンプトの挿入に使用できます。 - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:Runner が以前の出力を次のターンのモデル入力に変換するとき、推論項目 ID を保持するか省略するかを制御します。 -##### トレーシングと可観測性 +##### トレーシングと可観測性 {#tracing-and-observability} - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:実行全体の[トレーシング](tracing.md)を無効にできます。 - [`tracing`][agents.run.RunConfig.tracing]:実行ごとのトレーシング API キーなど、トレースのエクスポート設定を上書きするには、[`TracingConfig`][agents.tracing.TracingConfig] を渡します。 @@ -158,7 +158,7 @@ asyncio.run(main()) - [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:実行のトレーシングワークフロー名、トレース ID、トレースグループ ID を設定します。少なくとも `workflow_name` を設定することを推奨します。グループ ID は、複数の実行にまたがるトレースを関連付けるためのオプションフィールドです。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 -##### ツールの実行、承認、エラー動作 +##### ツールの実行、承認、エラー動作 {#tool-execution-approval-and-tool-error-behavior} - [`tool_execution`][agents.run.RunConfig.tool_execution]:同時に実行するローカル関数ツール呼び出し数の制限など、ローカルツール呼び出しに対する SDK 側の実行動作を設定します。 - [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:モデルが生成した関数ツール呼び出しのツール名が、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合の Runner の処理方法を設定します。デフォルトでは `ModelBehaviorError` が発生します。代わりに、モデルから認識できるエラー出力を返すようオプトインできます。 @@ -167,9 +167,9 @@ asyncio.run(main()) ネストされたハンドオフは、オプトインのベータ機能として利用できます。順序付きトランスクリプト圧縮を有効にするには `RunConfig(nest_handoff_history=True)` を渡し、特定のハンドオフで有効にするには `handoff(..., nest_handoff_history=True)` を設定します。組み込みのマッパーは、トランスクリプト全体を 1 つのメッセージにまとめるのではなく、生成されたアシスタント要約セグメントをロスレスなメッセージ項目の前後に配置します。未加工のトランスクリプトを保持する場合(デフォルト)は、フラグを設定しないか、必要な形式で会話を転送する `handoff_input_filter`(または `handoff_history_mapper`)を指定します。カスタムマッパーを記述せずに、生成される要約セグメントで使用するラッパーテキストを変更するには、[`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します(デフォルトに戻すには [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します)。 -#### 実行設定の詳細 +#### 実行設定の詳細 {#run-config-details} -##### `tool_execution` +##### `tool_execution` {#tool_execution} 実行時のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 @@ -196,7 +196,7 @@ result = await Runner.run( `pre_approval_tool_input_guardrails=False` はデフォルトの承認フローを維持します。関数ツールに承認が必要な場合、まず実行が一時停止し、ツール入力ガードレールは承認後、実行直前にのみ実行されます。保留中の承認による中断が生成される前に関数ツールの入力ガードレールを実行する場合は、`True` に設定します。この承認前チェックを通過した呼び出しでも、承認後に同じ入力ガードレールが再度実行されるため、時間に依存するチェックは実行前に再検証されます。 -##### `tool_not_found_behavior` +##### `tool_not_found_behavior` {#tool_not_found_behavior} デフォルトでは、モデルが生成した関数ツール呼び出しが、現在のエージェントで利用可能ないずれの関数ツールとも一致しない場合、Runner は `ModelBehaviorError` を発生させます。 @@ -216,7 +216,7 @@ result = await Runner.run( 現在、このオプションはツール名の検索に失敗した関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、既存のエラー動作が引き続き使用されます。 -##### `tool_error_formatter` +##### `tool_error_formatter` {#tool_error_formatter} SDK がモデルから認識できるツールエラー出力を作成する際、モデルに返すメッセージをカスタマイズするには、`tool_error_formatter` を使用します。 @@ -254,7 +254,7 @@ result = Runner.run_sync( ) ``` -##### `reasoning_item_id_policy` +##### `reasoning_item_id_policy` {#reasoning_item_id_policy} `reasoning_item_id_policy` は、Runner が履歴を引き継ぐとき(たとえば、`RunResult.to_input_list()` またはセッションに基づく実行を使用するとき)、推論項目を次のターンのモデル入力へ変換する方法を制御します。 @@ -273,9 +273,9 @@ result = Runner.run_sync( - ユーザーが指定した初期入力項目は書き換えません。 - `call_model_input_filter` は、このポリシーが適用された後でも意図的に推論 ID を再導入できます。 -## 状態と会話の管理 +## 状態と会話の管理 {#state-and-conversation-management} -### メモリ戦略の選択 +### メモリ戦略の選択 {#choose-a-memory-strategy} 次のターンへ状態を引き継ぐ一般的な方法は 4 つあります。 @@ -294,7 +294,7 @@ result = Runner.run_sync( (`conversation_id`、`previous_response_id`、または `auto_previous_response_id`)を 組み合わせることはできません。呼び出しごとに 1 つの方法を選択してください。 -### 会話とチャットスレッド +### 会話とチャットスレッド {#conversationschat-threads} いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があり、その結果として 1 回以上の LLM 呼び出しが行われます。ただし、チャット会話上は 1 つの論理ターンを表します。たとえば、次のようになります。 @@ -303,7 +303,7 @@ result = Runner.run_sync( エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力だけを表示することもできます。いずれの場合も、その後ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 -#### 会話の手動管理 +#### 会話の手動管理 {#manual-conversation-management} [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] メソッドを使用して次のターンの入力を取得し、会話履歴を手動で管理できます。 @@ -327,7 +327,7 @@ async def main(): # California ``` -#### Sessions による会話の自動管理 +#### Sessions による会話の自動管理 {#automatic-conversation-management-with-sessions} より簡単な方法として、[Sessions](sessions/index.md)を使用すると、`.to_input_list()` を手動で呼び出さずに会話履歴を自動的に処理できます。 @@ -362,13 +362,13 @@ Sessions は、次の処理を自動的に行います。 詳しくは、[Sessions のドキュメント](sessions/index.md)をご覧ください。 -#### サーバー管理の会話 +#### サーバー管理の会話 {#server-managed-conversations} `to_input_list()` または `Sessions` を使用してローカルで処理する代わりに、OpenAI の会話状態機能でサーバー側の会話状態を管理することもできます。これにより、過去のすべてのメッセージを手動で再送信せずに会話履歴を保持できます。以下のどちらのサーバー管理方式でも、各リクエストでは新しいターンの入力だけを渡し、保存した ID を再利用します。詳しくは、[OpenAI の会話状態ガイド](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)をご覧ください。 OpenAI では、ターン間の状態を追跡する方法を 2 つ提供しています。 -##### 1. `conversation_id` の使用 +##### 1. `conversation_id` の使用 {#1-using-conversation_id} まず OpenAI Conversations API を使用して会話を作成し、それ以降のすべての呼び出しでその ID を再利用します。 @@ -391,7 +391,7 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -##### 2. `previous_response_id` の使用 +##### 2. `previous_response_id` の使用 {#2-using-previous_response_id} もう 1 つの方法は **レスポンスチェイニング** です。各ターンを、直前のターンのレスポンス ID に明示的に関連付けます。 @@ -436,9 +436,9 @@ async def main(): この互換性のための再試行は、`ModelSettings.retry` を設定していない場合でも実行されます。 モデルリクエストに対する、より広範なオプトインの再試行動作については、[Runner 管理の再試行](models/index.md#runner-managed-retries)をご覧ください。 -## フックとカスタマイズ +## フックとカスタマイズ {#hooks-and-customization} -### モデル呼び出し入力フィルター +### モデル呼び出し入力フィルター {#call-model-input-filter} モデル呼び出しの直前にモデル入力を編集するには、`call_model_input_filter` を使用します。このフックは、現在のエージェント、コンテキスト、結合済みの入力項目(存在する場合はセッション履歴を含みます)を受け取り、新しい `ModelInputData` を返します。 @@ -469,9 +469,9 @@ Runner は準備済み入力リストのコピーをフックへ渡すため、 機密データの編集、長い履歴のトリミング、追加のシステムガイダンスの挿入を行うには、`run_config` を使用して実行ごとにフックを設定します。 -## エラーと復旧 +## エラーと復旧 {#errors-and-recovery} -### エラーハンドラー +### エラーハンドラー {#error-handlers} すべての `Runner` エントリーポイントは、エラー種別をキーとする辞書 `error_handlers` を受け取ります。サポートされるキーは、`"max_turns"`、`"model_refusal"`、`"invalid_final_output"` です。対応するエラーで実行を終了する代わりに、制御された最終出力を返す場合に使用します。 @@ -568,27 +568,27 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続実行との統合とヒューマンインザループ +## 永続実行との統合とヒューマンインザループ {#durable-execution-integrations-and-human-in-the-loop} ツール承認の一時停止と再開のパターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から始めてください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 -### Dapr +### Dapr {#dapr} Agents SDK の [Dapr](https://dapr.io) Diagrid 統合を使用すると、障害から自動的に復旧し、ヒューマンインザループのワークフローをサポートする、永続的で長時間実行されるエージェントを実行できます。Dapr はベンダー中立の [CNCF](https://cncf.io) ワークフローオーケストレーターです。Dapr と OpenAI エージェントの使用を開始するには、[こちら](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)をご覧ください。 -### Temporal +### Temporal {#temporal} Agents SDK の [Temporal](https://temporal.io/) 統合を使用すると、ヒューマンインザループのタスクを含む、永続的で長時間実行されるワークフローを実行できます。Temporal と Agents SDK が連携して長時間実行タスクを完了するデモは、[こちらの動画](https://www.youtube.com/watch?v=fFBZqzT4DD8)で確認できます。また、[こちらのドキュメント](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)もご覧ください。 -### Restate +### Restate {#restate} Agents SDK の [Restate](https://restate.dev/) 統合を使用すると、人による承認、ハンドオフ、セッション管理を含む、軽量で永続的なエージェントを実現できます。この統合では Restate の単一バイナリランタイムが依存関係として必要であり、エージェントをプロセス、コンテナ、またはサーバーレス関数として実行できます。詳しくは、[概要](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)または[ドキュメント](https://docs.restate.dev/ai)をご覧ください。 -### DBOS +### DBOS {#dbos} Agents SDK の [DBOS](https://dbos.dev/) 統合を使用すると、障害や再起動が発生しても進行状況を保持する、信頼性の高いエージェントを実行できます。長時間実行されるエージェント、ヒューマンインザループのワークフロー、ハンドオフをサポートします。同期メソッドと非同期メソッドの両方に対応しています。この統合に必要なのは SQLite または Postgres データベースだけです。詳しくは、統合の[リポジトリ](https://github.com/dbos-inc/dbos-openai-agents)および[ドキュメント](https://docs.dbos.dev/integrations/openai-agents)をご覧ください。 -## 例外 +## 例外 {#exceptions} SDK は特定の場合に例外を発生させます。完全なリストは [`agents.exceptions`][] にあります。概要は次のとおりです。 diff --git a/docs/ja/sandbox/clients.md b/docs/ja/sandbox/clients.md index 67e8198f4a..81f0849112 100644 --- a/docs/ja/sandbox/clients.md +++ b/docs/ja/sandbox/clients.md @@ -10,7 +10,7 @@ search: サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -## 選択ガイド +## 選択ガイド {#decision-guide}
@@ -22,7 +22,7 @@ search:
-## ローカルクライアント +## ローカルクライアント {#local-clients} ほとんどのユーザーには、次の 2 つのサンドボックスクライアントのいずれかを推奨します。 @@ -58,7 +58,7 @@ run_config = RunConfig( コンテナ分離が必要な場合、またはサンドボックスイメージを別の環境で使用されるイメージと一致させる場合に使用します。[examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 -### Docker ネットワークの無効化 +### Docker ネットワークの無効化 {#disable-docker-networking} Docker サンドボックスからネットワークにアクセスできないようにする必要がある場合は、`network_mode="none"` を設定します。 @@ -71,7 +71,7 @@ options = DockerSandboxClientOptions( 明示的にサポートされるネットワークモードは `"none"` のみです。Docker のデフォルト動作を維持するには、`network_mode` を省略してください。ネットワークを無効化したサンドボックスはポートを公開できないため、`network_mode="none"` と空ではない `exposed_ports` タプルを組み合わせると、オプションの検証時に失敗します。この設定はサンドボックスのセッション状態に保存され、その状態を再開する際に SDK が代替コンテナを作成する必要がある場合にも再適用されます。 -## マウントとリモートストレージ +## マウントとリモートストレージ {#mounts-and-remote-storage} マウントエントリでは公開するストレージを記述し、マウント戦略ではサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `agents.sandbox.entries` からインポートします。ホステッドプロバイダー向けの戦略は、`agents.extensions.sandbox` またはプロバイダー固有の拡張パッケージから利用できます。 @@ -97,7 +97,7 @@ options = DockerSandboxClientOptions( -## 対応ホステッドプラットフォーム +## 対応ホステッドプラットフォーム {#supported-hosted-platforms} ホステッド環境が必要な場合、通常は同じ `SandboxAgent` 定義を引き継ぎ、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] のサンドボックスクライアントのみを変更します。 @@ -119,7 +119,7 @@ options = DockerSandboxClientOptions( -### Modal サンドボックスのリソースサイズ +### Modal サンドボックスのリソースサイズ {#size-modal-sandboxes} 新しい Modal サンドボックスのリソースを要求するには、`ModalSandboxClientOptions.cpu` と `ModalSandboxClientOptions.memory` を使用します。単一の値では、その量を要求します。2 項目の `(request, limit)` タプルでは、最初の項目を要求値、2 番目の項目を上限値として使用します。メモリ値の単位は MiB です。 diff --git a/docs/ja/sandbox/guide.md b/docs/ja/sandbox/guide.md index 095ae263dc..c8d0c9482a 100644 --- a/docs/ja/sandbox/guide.md +++ b/docs/ja/sandbox/guide.md @@ -32,7 +32,7 @@ search: 外側のランタイムは引き続き、承認、トレーシング、ハンドオフ、および実行の再開に必要な状態の追跡を担います。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担います。この分担は、モデルの中核となる部分です。 -### 各構成要素の関係 +### 各構成要素の関係 {#how-the-pieces-fit-together} サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。Runner はエージェントを準備して稼働中のサンドボックスセッションにバインドし、後続の実行に備えて状態を保存できます。 @@ -60,7 +60,7 @@ flowchart LR シェルアクセスが時折使用するツールの 1 つにすぎない場合は、[ツールガイド](../tools.md)のホスト型シェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 -## 適したユースケース +## 適したユースケース {#when-to-use-them} サンドボックスエージェントは、次のようなワークスペース中心のワークフローに適しています。 @@ -72,13 +72,13 @@ flowchart LR ファイルへのアクセスや、状態を保持する変更可能なファイルシステムが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスが時折必要になる機能の 1 つにすぎない場合は、ホスト型シェルを追加します。ワークスペース境界自体が機能の一部である場合は、サンドボックスエージェントを使用します。 -## サンドボックスクライアントの選択 +## サンドボックスクライアントの選択 {#choose-a-sandbox-client} macOS または Linux でのローカル開発では、`UnixLocalSandboxClient` から始めてください。Windows では、`DockerSandboxClient` またはホスト型プロバイダーを使用します。サポートされているどのプラットフォームでも、コンテナ分離やイメージの同等性が必要な場合は `DockerSandboxClient` に移行し、プロバイダー管理の実行が必要な場合はホスト型プロバイダーに移行します。 ほとんどの場合、[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] でサンドボックスクライアントとそのオプションを変更しても、`SandboxAgent` の定義は同じままです。ローカル、Docker、ホスト型、およびリモートマウントのオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 -## 中核となる構成要素 +## 中核となる構成要素 {#core-pieces}
@@ -113,7 +113,7 @@ macOS または Linux でのローカル開発では、`UnixLocalSandboxClient` 3. 組み込み機能またはカスタム機能を追加します。 4. `RunConfig(sandbox=SandboxRunConfig(...))` で、各実行がサンドボックスセッションを取得する方法を決定します。 -## サンドボックス実行の準備 +## サンドボックス実行の準備 {#how-a-sandbox-run-is-prepared} 実行時に、Runner はその定義を具体的なサンドボックス対応の実行に変換します。 @@ -127,7 +127,7 @@ macOS または Linux でのローカル開発では、`UnixLocalSandboxClient` これらの準備ステップがあるため、`default_manifest`、`instructions`、`base_instructions`、`capabilities`、`run_as` は、`SandboxAgent` を設計するときに考慮すべき主なサンドボックス固有のオプションです。 -## `SandboxAgent` のオプション +## `SandboxAgent` のオプション {#sandboxagent-options} 通常の `Agent` フィールドに加えて、次のサンドボックス固有のオプションがあります。 @@ -145,13 +145,13 @@ macOS または Linux でのローカル開発では、`UnixLocalSandboxClient` サンドボックスクライアントの選択、サンドボックスセッションの再利用、マニフェストのオーバーライド、スナップショットの選択は、エージェントではなく [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] に指定します。 -### `default_manifest` +### `default_manifest` {#default_manifest} `default_manifest` は、Runner がこのエージェント用に新しいサンドボックスセッションを作成するときに使用するデフォルトの [`Manifest`][agents.sandbox.manifest.Manifest] です。エージェントが通常開始時に必要とするファイル、リポジトリ、補助資料、出力ディレクトリ、マウントに使用します。 これはデフォルトにすぎません。実行では `SandboxRunConfig(manifest=...)` を使用してオーバーライドでき、再利用または再開されたサンドボックスセッションでは既存のワークスペース状態が維持されます。 -### `instructions` と `base_instructions` +### `instructions` と `base_instructions` {#instructions-and-base_instructions} 異なるプロンプト間でも維持する必要がある短いルールには、`instructions` を使用します。`SandboxAgent` では、これらの instructions が SDK のサンドボックス基本プロンプトの後に追加されるため、組み込みのサンドボックスガイダンスを維持しながら、独自の役割、ワークフロー、成功基準を追加できます。 @@ -179,7 +179,7 @@ SDK のサンドボックス基本プロンプトを置き換えたい場合に `instructions` を省略しても、SDK はデフォルトのサンドボックスプロンプトを含めます。低レベルのラッパーにはそれで十分ですが、ユーザー向けのほとんどのエージェントでは、明示的な `instructions` も指定する必要があります。 -### `capabilities` +### `capabilities` {#capabilities} 機能は、サンドボックスネイティブの動作を `SandboxAgent` に付加します。実行開始前にワークスペースを構成し、サンドボックス固有の instructions を追加し、稼働中のサンドボックスセッションにバインドされる tools を公開し、そのエージェントのモデル動作や入力処理を調整できます。 @@ -214,9 +214,9 @@ SDK のサンドボックス基本プロンプトを置き換えたい場合に 適合する場合は、組み込み機能を優先してください。組み込み機能では対応できないサンドボックス固有のツールまたは instructions のインターフェースが必要な場合にのみ、カスタム機能を作成します。 -## 概念 +## 概念 {#concepts_1} -### マニフェスト +### マニフェスト {#manifest} [`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーやグループの定義、およびワークスペース外にある特定の絶対パスへのアクセス許可を行えます。 @@ -262,7 +262,7 @@ Docker で別のホスト上の絶対パスを、コンテナ内の絶対 POSIX スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスは実行時アクセスであり、永続的なワークスペース状態ではありません。 -### 権限 +### 権限 {#permissions} `Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これはサンドボックスがマテリアライズするファイルに関するものであり、モデルの権限、承認ポリシー、API 認証情報に関するものではありません。 @@ -338,7 +338,7 @@ result = await Runner.run( ファイルレベルの共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` ユーザーは、サンドボックスネイティブのアクションを実行する主体を制御します。`Permissions` は、サンドボックスがワークスペースをマテリアライズした後、そのユーザーが読み取り、書き込み、実行できるファイルを制御します。 -### SnapshotSpec +### SnapshotSpec {#snapshotspec} `SnapshotSpec` は、保存済みのワークスペース内容をどこから新しいサンドボックスセッションに復元し、どこへ永続化するかを指定します。これはサンドボックスワークスペースのスナップショットポリシーです。一方、`session_state` は、特定のサンドボックスバックエンドを再開するためのシリアライズされた接続状態です。 @@ -363,7 +363,7 @@ Runner が新しいサンドボックスセッションを作成すると、サ `snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存先を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時パスは、永続的なワークスペース内容としてスナップショットにコピーされません。 -### サンドボックスのライフサイクル +### サンドボックスのライフサイクル {#sandbox-lifecycle} ライフサイクルには、**SDK 所有**と**開発者所有**の 2 つのモードがあります。 @@ -439,11 +439,11 @@ finally: `stop()` は、スナップショットに基づくワークスペース内容を永続化するだけで、サンドボックスを終了しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースを停止して、セッションスコープの依存関係を閉じます。 -## `SandboxRunConfig` のオプション +## `SandboxRunConfig` のオプション {#sandboxrunconfig-options} [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションの取得元と、新しいセッションの初期化方法を決定する実行ごとのオプションを保持します。 -### サンドボックスの取得元 +### サンドボックスの取得元 {#sandbox-source} 次のオプションは、Runner がサンドボックスセッションを再利用、再開、作成のどれで取得するかを決定します。 @@ -464,7 +464,7 @@ finally: 3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的にシリアライズされたサンドボックスセッション状態から再開します。 4. それ以外の場合、Runner は新しいサンドボックスセッションを作成します。その新しいセッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 -### 新規セッションの入力 +### 新規セッションの入力 {#fresh-session-inputs} 次のオプションは、Runner が新しいサンドボックスセッションを作成するときにのみ関係します。 @@ -478,7 +478,7 @@ finally:
-### モデル向け作業ディレクトリ +### モデル向け作業ディレクトリ {#model-facing-working-directory} 複数の実行で 1 つのサンドボックスセッションを共有しながら別々のサブディレクトリで作業する場合は、`cwd` に POSIX 形式のワークスペース相対ディレクトリを設定します。Runner が `cwd` を検証するとき、そのディレクトリが存在し、設定されたサンドボックスユーザーからアクセスできる必要があります。新しいセッションでは、Runner が最初にマニフェストをマテリアライズするため、この検証前にマニフェストでディレクトリを作成できます。 @@ -503,7 +503,7 @@ result = await Runner.run( パスを扱うカスタム機能は、モデルが指定した相対パスを解決するときに、バインドされた [`SandboxWorkspaceScope`][agents.sandbox.workspace_paths.SandboxWorkspaceScope] を適用する必要があります。モデル向け作業ディレクトリを分離しながら 1 つのサンドボックスセッションを共有する 2 つの並行実行については、[examples/sandbox/shared_session_workdirs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/shared_session_workdirs.py) を参照してください。 -### マテリアライズの制御 +### マテリアライズの制御 {#materialization-controls} `concurrency_limits` は、並列実行できるサンドボックスのマテリアライズ作業量を制御します。大規模なマニフェストやローカルディレクトリのコピーで、より厳密なリソース制御が必要な場合は、`SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` を使用します。いずれかの値を `None` に設定すると、その制限だけを無効にできます。 @@ -517,7 +517,7 @@ result = await Runner.run( - 注入された稼働中のセッション: 実行中のサンドボックス `session` を渡すと、機能によるマニフェスト更新で互換性のあるマウント以外のエントリを追加できます。ただし、`manifest.root`、`manifest.environment`、`manifest.users`、`manifest.groups` の変更、既存エントリの削除、エントリ型の置き換え、マウントエントリの追加や変更はできません。 - Runner API: `SandboxAgent` の実行でも、通常の `Runner.run()`、`Runner.run_sync()`、`Runner.run_streamed()` API を使用します。 -## 完全なコード例: コーディングタスク +## 完全なコード例: コーディングタスク {#full-example-coding-task} このコーディング形式のコード例は、デフォルトの出発点として適しています。 @@ -600,15 +600,15 @@ if __name__ == "__main__": [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。このコード例では、Unix ローカル実行間で決定論的に検証できるように、小規模なシェルベースのリポジトリを使用しています。実際のタスクリポジトリは、もちろん Python、JavaScript、その他の任意のものを使用できます。 -## 一般的なパターン +## 一般的なパターン {#common-patterns} 上記の完全なコード例から始めてください。多くの場合、サンドボックスクライアント、サンドボックスセッションの取得元、またはワークスペースの取得元だけを変更し、同じ `SandboxAgent` をそのまま維持できます。 -### サンドボックスクライアントの切り替え +### サンドボックスクライアントの切り替え {#switch-sandbox-clients} エージェント定義を同じままにし、実行設定だけを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホスト型プロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 -### ワークスペースのオーバーライド +### ワークスペースのオーバーライド {#override-the-workspace} エージェント定義を同じままにし、新規セッションのマニフェストだけを入れ替えます。 @@ -632,7 +632,7 @@ run_config = RunConfig( エージェントを再構築せず、同じエージェントの役割を異なるリポジトリ、パケット、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディングのコード例では、一度限りのオーバーライドではなく `default_manifest` を使用して同じパターンを示しています。 -### サンドボックスセッションの注入 +### サンドボックスセッションの注入 {#inject-a-sandbox-session} 明示的なライフサイクル制御、実行後の確認、または出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 @@ -657,7 +657,7 @@ async with sandbox: 実行後にワークスペースを確認する場合や、すでに起動済みのサンドボックスセッション上でストリーミングする場合に使用します。[examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) と [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) を参照してください。 -### セッション状態からの再開 +### セッション状態からの再開 {#resume-from-session-state} `RunState` の外部ですでにサンドボックス状態をシリアライズしている場合は、その状態から Runner に再接続させます。 @@ -682,7 +682,7 @@ run_config = RunConfig( セッション状態と `RunState` のシリアライズでは、クラウドマウントの認証情報、認証情報を含む補助設定、コンテナ内での認証情報公開に関する確認も削除されます。マウント済みセッションの再開をサポートするバックエンドでは、状態に編集済みのマウント権限が含まれる場合、現在の信頼済みマニフェストを `SandboxRunConfig.manifest` または `agent.default_manifest` で指定してください。`"data"` という名前のマウントエントリで、マウントスコープの確認が必要な場合は、再開前に `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` を使用してコピー済みマニフェストを保持してください。広範な権限には `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")` を使用し、マウントが両方の権限クラスを使用する場合は両方のメソッドを呼び出します。確認が必要な正確なマウントパスをすべて渡してください。Agents SDKは、現在の信頼済みマニフェストが永続化された状態とまったく同じ、認証情報を除いたマウントトポロジーを持つ場合にのみ、認証情報を復元します。信頼済み設定が欠落または一致しない場合、サンドボックスの開始前に再開が失敗します。シリアライズされた状態だけで権限が付与されることはありません。`VercelSandboxClient` はマウント済みセッションを再開できないため、代わりに信頼済みマニフェストを使用して新しいサンドボックスを開始してください。 -### スナップショットからの開始 +### スナップショットからの開始 {#start-from-a-snapshot} 保存済みのファイルや成果物から新しいサンドボックスを初期化します。 @@ -703,7 +703,7 @@ run_config = RunConfig( 新しいサンドボックスセッションを作成する実行で、`agent.default_manifest` だけではなく、保存済みのワークスペース内容から開始する場合に使用します。ローカルスナップショットのフローについては [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)、リモートスナップショットクライアントについては [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) を参照してください。 -### Git からのスキル読み込み +### Git からのスキル読み込み {#load-skills-from-git} ローカルのスキル取得元を、リポジトリに基づく取得元へ置き換えます。 @@ -718,7 +718,7 @@ capabilities = Capabilities.default() + [ スキルバンドルに独自のリリースサイクルがある場合や、複数のサンドボックス間で共有する場合に使用します。[examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) を参照してください。 -### tools としての公開 +### tools としての公開 {#expose-as-tools} ツールエージェントには、独自のサンドボックス境界を割り当てることも、親の実行で稼働中のサンドボックスを再利用させることもできます。再利用は、高速な読み取り専用の探索エージェントに便利です。別のサンドボックスの作成、初期化、スナップショット作成にコストをかけることなく、親の実行が使用しているものとまったく同じワークスペースを確認できます。 @@ -832,7 +832,7 @@ rollout_agent.as_tool( ツールエージェントが自由に変更を行う、信頼できないコマンドを実行する、または異なるバックエンドやイメージを使用する場合は、別のサンドボックスを使用します。[examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py) を参照してください。 -### ローカルツールおよび MCPとの組み合わせ +### ローカルツールおよび MCPとの組み合わせ {#combine-with-local-tools-and-mcp} 同じエージェントで通常の tools も使用しながら、サンドボックスワークスペースを維持します。 @@ -851,13 +851,13 @@ agent = SandboxAgent( ワークスペースの確認がエージェントの仕事の一部にすぎない場合に使用します。[examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py) を参照してください。 -## メモリ +## メモリ {#memory} 将来のサンドボックスエージェントの実行で以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは SDK の会話用 `Session` メモリとは別のものです。学んだ内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 設定、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 -## 構成パターン +## 構成パターン {#composition-patterns} 単一エージェントのパターンを理解したら、次に検討すべき設計上の問題は、より大規模なシステムのどこにサンドボックス境界を配置するかです。 @@ -873,7 +873,7 @@ agent = SandboxAgent( - ワークスペースの分離が必要なワークフロー部分だけを、サンドボックスを使用しないエージェントからサンドボックスエージェントへハンドオフする - オーケストレーターが複数のサンドボックスエージェントを tools として公開し、通常は各 `Agent.as_tool(...)` 呼び出しで個別のサンドボックス `RunConfig` を使用して、それぞれのツールに独自の分離されたワークスペースを割り当てる -### ターンとサンドボックス実行 +### ターンとサンドボックス実行 {#turns-and-sandbox-runs} ハンドオフと Agents-as-toolsの呼び出しは、分けて説明すると理解しやすくなります。 @@ -886,7 +886,7 @@ agent = SandboxAgent( - ハンドオフでは、サンドボックスエージェントが同じ実行のアクティブなエージェントになるため、承認は同じトップレベル実行に留まります - `Agent.as_tool(...)` では、サンドボックスのツールエージェント内で発生した承認も外側の実行に提示されますが、保存されたネスト実行状態から提示され、外側の実行が再開されたときにネストされたサンドボックス実行が再開されます -## 関連資料 +## 関連資料 {#further-reading} - [クイックスタート](../sandbox_agents.md): サンドボックスエージェントを 1 つ実行します。 - [サンドボックスクライアント](clients.md): ローカル、Docker、ホスト型、マウントのオプションを選択します。 diff --git a/docs/ja/sandbox/memory.md b/docs/ja/sandbox/memory.md index 3588af3201..9ec801e613 100644 --- a/docs/ja/sandbox/memory.md +++ b/docs/ja/sandbox/memory.md @@ -18,7 +18,7 @@ search: バグの修正、メモリの生成、スナップショットの再開、そのメモリを使用したフォローアップの検証実行を含む、完全な 2 回実行のコード例については、[examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) を参照してください。メモリレイアウトを分離したマルチターン、マルチエージェントのコード例については、[examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py) を参照してください。 -## メモリの有効化 +## メモリの有効化 {#enable-memory} サンドボックスエージェントのケイパビリティとして `Memory()` を追加します。 @@ -48,7 +48,7 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d `Memory()` は、メモリの読み取りと生成の両方を有効にします。メモリを読み取る必要はあるものの、新しいメモリを生成すべきでないエージェントには、`Memory(generate=None)` を使用します。たとえば、内部エージェント、サブエージェント、チェッカー、単発のツールエージェントによる実行では、有用な情報があまり追加されない場合があります。後で使用するメモリを実行で生成する必要はあるものの、既存のメモリがその実行に影響することをユーザーが望まない場合は、`Memory(read=None)` を使用します。 -## メモリの読み取り +## メモリの読み取り {#read-memory} メモリの読み取りには段階的開示が使用されます。実行開始時に、SDK は一般的に役立つヒント、ユーザーの好み、利用可能なメモリをまとめた小さなサマリー(`memory_summary.md`)をエージェントの開発者プロンプトに注入します。これにより、エージェントは過去の作業が関連する可能性を判断するのに十分なコンテキストを得られます。 @@ -56,7 +56,7 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d メモリは古くなる可能性があります。エージェントは、メモリをあくまで参考情報として扱い、現在の環境を信頼するよう指示されます。デフォルトでは、メモリの読み取りで `live_update` が有効になっているため、エージェントが古くなったメモリを検出すると、同じ実行内で設定済みの `MEMORY.md` を更新できます。エージェントがメモリを読み取る必要はあるものの、実行中に変更すべきでない場合は、ライブ更新を無効にしてください。たとえば、レイテンシーが重視される実行が該当します。 -## メモリの生成 +## メモリの生成 {#generate-memory} 実行が終了すると、サンドボックスランタイムはその実行セグメントを会話ファイルに追記します。蓄積された会話ファイルは、サンドボックスセッションの終了時に処理されます。 @@ -101,7 +101,7 @@ memory = Memory( 最近の未加工メモリが `max_raw_memories_for_consolidation`(デフォルトは 256)を超えると、フェーズ 2 は最新の会話から得たメモリのみを保持し、それより古いものを削除します。新しさは、会話が最後に更新された時刻に基づきます。この忘却メカニズムにより、メモリに最新の環境を反映しやすくなります。 -## マルチターン会話 +## マルチターン会話 {#multi-turn-conversations} マルチターンのサンドボックスチャットでは、通常の SDK `Session` を同じライブサンドボックスセッションと組み合わせて使用します。 @@ -141,7 +141,7 @@ async with sandbox: 3. `RunConfig.group_id`(上記のいずれも存在しない場合) 4. 安定した識別子が存在しない場合は、実行ごとに生成される ID -## 異なるレイアウトによるエージェントごとのメモリ分離 +## 異なるレイアウトによるエージェントごとのメモリ分離 {#use-different-layouts-to-isolate-memory-for-different-agents} メモリの分離は、エージェント名ではなく `MemoryLayoutConfig` に基づきます。同じレイアウトと同じメモリ会話 ID を持つエージェントは、1 つのメモリ会話と 1 つの統合済みメモリを共有します。異なるレイアウトを持つエージェントは、同じサンドボックスワークスペースを共有している場合でも、ロールアウトファイル、未加工メモリ、`MEMORY.md`、`memory_summary.md` を個別に保持します。 diff --git a/docs/ja/sandbox_agents.md b/docs/ja/sandbox_agents.md index 90a6b30ef1..6bb752653f 100644 --- a/docs/ja/sandbox_agents.md +++ b/docs/ja/sandbox_agents.md @@ -12,13 +12,13 @@ search: SDK は、ファイルのステージング、ファイルシステムツール、シェルアクセス、サンドボックスのライフサイクル、スナップショット、プロバイダー固有の連携を自分で組み合わせることなく、この実行基盤を提供します。通常の `Agent` と `Runner` のフローを維持したまま、ワークスペース用の `Manifest`、サンドボックスネイティブツールの機能、作業の実行場所を指定する `SandboxRunConfig` を追加します。 -## 前提条件 +## 前提条件 {#prerequisites} - Python 3.10 以降 - OpenAI Agents SDK に関する基本的な知識 - サンドボックスクライアント。ローカル開発では、まず `UnixLocalSandboxClient` を使用します。 -## インストール +## インストール {#installation} SDK をまだインストールしていない場合: @@ -32,7 +32,7 @@ Docker ベースのサンドボックスの場合: pip install "openai-agents[docker]" ``` -## ローカルサンドボックスエージェントの作成 +## ローカルサンドボックスエージェントの作成 {#create-a-local-sandbox-agent} この例では、`repo/` 配下にローカルリポジトリをステージングし、ローカルスキルを遅延読み込みして、実行時にランナーが Unix ローカルのサンドボックスセッションを作成します。 @@ -96,7 +96,7 @@ if __name__ == "__main__": [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) を参照してください。このコード例では、シェルベースの小さなリポジトリを使用しているため、Unix ローカルでの実行全体にわたって決定論的に検証できます。 -## 主な選択肢 +## 主な選択肢 {#key-choices} 基本的な実行が機能した後、多くの方が次に検討する選択肢は以下のとおりです。 @@ -108,7 +108,7 @@ if __name__ == "__main__": - `SandboxRunConfig.client`:サンドボックスのバックエンド - `SandboxRunConfig.session`、`session_state`、または `snapshot`:後続の実行を以前の作業に再接続する方法 -## 次のステップ +## 次のステップ {#where-to-go-next} - [概念](sandbox/guide.md):マニフェスト、機能、権限、スナップショット、実行設定、構成パターンについて説明します。 - [サンドボックスクライアント](sandbox/clients.md):Unix ローカル、Docker、ホステッドプロバイダー、マウント戦略を選択します。 diff --git a/docs/ja/sessions/advanced_sqlite_session.md b/docs/ja/sessions/advanced_sqlite_session.md index bb53fc024d..a7c8c5111f 100644 --- a/docs/ja/sessions/advanced_sqlite_session.md +++ b/docs/ja/sessions/advanced_sqlite_session.md @@ -6,7 +6,7 @@ search: `AdvancedSQLiteSession` は、基本的な `SQLiteSession` の拡張版であり、会話の分岐、詳細な使用量分析、構造化された会話クエリなど、高度な会話管理機能を提供します。 -## 機能 +## 機能 {#features} - **会話の分岐**: 任意のユーザーメッセージから別の会話経路を作成できます - **使用量の追跡**: ターンごとの詳細なトークン使用量分析と、JSON 形式の完全な内訳を提供します @@ -14,7 +14,7 @@ search: - **ブランチ管理**: ブランチを個別に切り替えて管理できます - **メッセージ構造メタデータ**: メッセージタイプ、ツール使用状況、会話フローを追跡できます -## クイックスタート +## クイックスタート {#quick-start} ```python from agents import Agent, Runner @@ -54,7 +54,7 @@ print(result.final_output) # "California" await session.store_run_usage(result) ``` -## 初期化 +## 初期化 {#initialization} ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -82,18 +82,18 @@ session = AdvancedSQLiteSession( ) ``` -### パラメーター +### パラメーター {#parameters} - `session_id` (str): 会話セッションの一意な識別子 - `db_path` (str | Path): SQLite データベースファイルへのパス。デフォルトは、インメモリストレージを使用する `:memory:` です - `create_tables` (bool): 拡張テーブルを自動的に作成するかどうか。デフォルトは `False` です - `logger` (logging.Logger | None): セッション用のカスタムロガー。デフォルトはモジュールロガーです -## 使用量の追跡 +## 使用量の追跡 {#usage-tracking} AdvancedSQLiteSession は、会話の各ターンのトークン使用量データを保存することで、詳細な使用量分析を提供します。 **この機能は、各エージェント実行後に `store_run_usage` メソッドが呼び出されることに全面的に依存します。** -### 使用量データの保存 +### 使用量データの保存 {#storing-usage-data} ```python # After each agent run, store the usage data @@ -107,7 +107,7 @@ await session.store_run_usage(result) # - Detailed JSON token information (if available) ``` -### 使用統計の取得 +### 使用統計の取得 {#retrieving-usage-statistics} ```python # Get session-level usage (all branches) @@ -135,11 +135,11 @@ for turn_data in turn_usage: turn_2_usage = await session.get_turn_usage(user_turn_number=2) ``` -## 会話の分岐 +## 会話の分岐 {#conversation-branching} AdvancedSQLiteSession の主要機能の 1 つは、任意のユーザーメッセージから会話のブランチを作成し、別の会話経路を探索できることです。 -### ブランチの作成 +### ブランチの作成 {#creating-branches} ```python # Get available turns for branching @@ -167,7 +167,7 @@ branch_id = await session.create_branch_from_content( ブランチ ID は、セッション ID が存続する間、一意です。ブランチを削除したりセッションをクリアしたりすると、その会話データは削除されますが、以前に使用したブランチ ID が再び利用可能になるわけではありません。別のブランチを作成するときは、新しい名前を使用してください。 -### ブランチ管理 +### ブランチ管理 {#branch-management} ```python # List all branches @@ -184,7 +184,7 @@ await session.switch_to_branch(branch_id) await session.delete_branch(branch_id, force=True) # force=True allows deleting current branch ``` -### ブランチのワークフロー例 +### ブランチのワークフロー例 {#branch-workflow-example} ```python # Original conversation @@ -217,11 +217,11 @@ result = await Runner.run( await session.store_run_usage(result) ``` -## 構造化クエリ +## 構造化クエリ {#structured-queries} AdvancedSQLiteSession は、会話の構造と内容を分析するための複数のメソッドを提供します。 -### 会話分析 +### 会話分析 {#conversation-analysis} ```python # Get conversation organized by turns @@ -245,7 +245,7 @@ for turn in matching_turns: print(f"Turn {turn['turn']}: {turn['content']}") ``` -### メッセージ構造 +### メッセージ構造 {#message-structure} セッションでは、以下を含むメッセージ構造が自動的に追跡されます。 @@ -255,11 +255,11 @@ for turn in matching_turns: - ブランチとの関連付け - タイムスタンプ -## データベーススキーマ +## データベーススキーマ {#database-schema} AdvancedSQLiteSession は、基本的な SQLite スキーマを 3 つの追加テーブルで拡張します。 -### message_structure テーブル +### message_structure テーブル {#message_structure-table} ```sql CREATE TABLE message_structure ( @@ -278,7 +278,7 @@ CREATE TABLE message_structure ( ); ``` -### branch_reservations テーブル +### branch_reservations テーブル {#branch_reservations-table} ```sql CREATE TABLE branch_reservations ( @@ -290,7 +290,7 @@ CREATE TABLE branch_reservations ( このテーブルは、コピーされたプレフィックスが空のブランチも含め、ブランチ ID をアトミックに予約します。予約行は、ブランチが削除された場合もセッションがクリアされた場合も保持されるため、古いセッションインスタンスが、同じ ID を再利用した後続のブランチに履歴をマージすることはできません。 -### turn_usage テーブル +### turn_usage テーブル {#turn_usage-table} ```sql CREATE TABLE turn_usage ( @@ -310,12 +310,12 @@ CREATE TABLE turn_usage ( ); ``` -## 完全なコード例 +## 完全なコード例 {#complete-example} すべての機能を包括的に紹介する[完全なコード例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)をご覧ください。 -## API リファレンス +## API リファレンス {#api-reference} - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - メインクラス - [`Session`][agents.memory.session.Session] - 基底セッションプロトコル \ No newline at end of file diff --git a/docs/ja/sessions/encrypted_session.md b/docs/ja/sessions/encrypted_session.md index b65b0d8008..72f95c929d 100644 --- a/docs/ja/sessions/encrypted_session.md +++ b/docs/ja/sessions/encrypted_session.md @@ -6,14 +6,14 @@ search: `EncryptedSession` は、任意のセッション実装に透過的な暗号化を提供し、古いアイテムの自動期限切れによって会話データを保護します。 -## 機能 +## 機能 {#features} - **透過的な暗号化**: 任意のセッションを Fernet 暗号化でラップします - **セッションごとのキー**: HKDF キー導出を使用して、セッションごとに一意の暗号化を行います - **自動期限切れ**: TTL が期限切れになると、古いアイテムは取得時に黙ってスキップされます - **ドロップイン置換**: 既存の任意のセッション実装で動作します -## インストール +## インストール {#installation} 暗号化セッションには `encrypt` extra が必要です。 @@ -21,7 +21,7 @@ search: pip install openai-agents[encrypt] ``` -## クイックスタート +## クイックスタート {#quick-start} ```python import asyncio @@ -53,9 +53,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 設定 +## 設定 {#configuration} -### 暗号化キー +### 暗号化キー {#encryption-key} 暗号化キーには、Fernet キーまたは任意の文字列を指定できます。 @@ -79,7 +79,7 @@ session = EncryptedSession( ) ``` -### TTL (有効期間) +### TTL (有効期間) {#ttl-time-to-live} 暗号化されたアイテムが有効であり続ける期間を設定します。 @@ -101,9 +101,9 @@ session = EncryptedSession( ) ``` -## さまざまなセッションタイプでの使用 +## さまざまなセッションタイプでの使用 {#usage-with-different-session-types} -### SQLite セッションでの使用 +### SQLite セッションでの使用 {#with-sqlite-sessions} ```python from agents import SQLiteSession @@ -119,7 +119,7 @@ session = EncryptedSession( ) ``` -### SQLAlchemy セッションでの使用 +### SQLAlchemy セッションでの使用 {#with-sqlalchemy-sessions} ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -147,7 +147,7 @@ session = EncryptedSession( -## キー導出 +## キー導出 {#key-derivation} EncryptedSession は HKDF (HMAC-based Key Derivation Function) を使用して、セッションごとに一意の暗号化キーを導出します。 @@ -161,7 +161,7 @@ EncryptedSession は HKDF (HMAC-based Key Derivation Function) を使用して - マスターキーがなければキーを導出できません - セッションデータを異なるセッション間で復号できません -## 自動期限切れ +## 自動期限切れ {#automatic-expiration} アイテムが TTL を超えると、取得時に自動的にスキップされます。 @@ -173,7 +173,7 @@ items = await session.get_items() # Only returns non-expired items result = await Runner.run(agent, "Continue conversation", session=session) ``` -## API リファレンス +## API リファレンス {#api-reference} - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - メインクラス - [`Session`][agents.memory.session.Session] - ベースセッションプロトコル \ No newline at end of file diff --git a/docs/ja/sessions/index.md b/docs/ja/sessions/index.md index 2494d16e68..b6a39bb064 100644 --- a/docs/ja/sessions/index.md +++ b/docs/ja/sessions/index.md @@ -10,7 +10,7 @@ Agents SDK には組み込みのセッションメモリが用意されており SDK にクライアント側のメモリを管理させたい場合は、セッションを使用します。同じ実行内では、セッションを実行レベルの継続オプションである `conversation_id`、`previous_response_id`、`auto_previous_response_id` と組み合わせることはできません。代わりに OpenAI のサーバーで管理される継続機能を使用する場合は、セッションと重ねて使用せず、それらのメカニズムのいずれかを選択してください。 -## クイックスタート +## クイックスタート {#quick-start} ```python from agents import Agent, Runner, SQLiteSession @@ -49,7 +49,7 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 同一セッションによる中断された実行の再開 +## 同一セッションによる中断された実行の再開 {#resuming-interrupted-runs-with-the-same-session} 実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または同じセッション ID と同じ基盤ストレージバックエンドを使用するよう設定された別のインスタンス)で再開し、再開後のターンが保存済みの同じ会話履歴を継続して使用できるようにします。 @@ -63,7 +63,7 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## セッションの基本動作 +## セッションの基本動作 {#core-session-behavior} セッションメモリが有効な場合、次のように動作します。 @@ -73,7 +73,7 @@ if result.interruptions: これにより、`.to_input_list()` を手動で呼び出したり、実行間で会話状態を管理したりする必要がなくなります。 -## 履歴と新規入力のマージ方法の制御 +## 履歴と新規入力のマージ方法の制御 {#control-how-history-and-new-input-merge} セッションを渡すと、Runner は通常、モデル入力を次の順序で準備します。 @@ -111,7 +111,7 @@ result = await Runner.run( セッションでのアイテムの保存方法を変更せずに、履歴を独自に削減、並べ替え、または選択的に含める必要がある場合に使用します。モデル呼び出しの直前に、さらに最終処理を行う必要がある場合は、[エージェント実行ガイド](../running_agents.md)の [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter] を使用してください。 -## 取得する履歴の制限 +## 取得する履歴の制限 {#limiting-retrieved-history} 各実行前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 @@ -136,9 +136,9 @@ result = await Runner.run( セッション実装がデフォルトのセッション設定を公開している場合、`RunConfig.session_settings` 内の `None` 以外の各値が、その実行に対応するデフォルト値を上書きします。これは、セッションのデフォルト動作を変更せずに、長い会話で取得サイズに上限を設けたい場合に便利です。 -## メモリ操作 +## メモリ操作 {#memory-operations} -### 基本操作 +### 基本操作 {#basic-operations} セッションでは、会話履歴を管理するための複数の操作を使用できます。 @@ -165,7 +165,7 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 修正での pop_item の使用 +### 修正での pop_item の使用 {#using-pop_item-for-corrections} 会話内の最後のアイテムを取り消したり変更したりする場合、`pop_item` メソッドが特に便利です。 @@ -196,11 +196,11 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 組み込みのセッション実装 +## 組み込みのセッション実装 {#built-in-session-implementations} SDK は、さまざまなユースケースに対応する複数のセッション実装を提供します。 -### 組み込みセッション実装の選択 +### 組み込みセッション実装の選択 {#choose-a-built-in-session-implementation} 以下の詳細な例を読む前に、この表を使用して出発点を選択してください。 @@ -221,7 +221,7 @@ SDK は、さまざまなユースケースに対応する複数のセッショ ChatKit 用の Python サーバーを実装する場合は、ChatKit のスレッドとアイテムを永続化するために、`chatkit.store.Store` の実装を使用してください。`SQLAlchemySession` などの Agents SDK セッションは SDK 側の会話履歴を管理しますが、ChatKit のストアをそのまま置き換えるものではありません。[ChatKit データストアの実装に関する `chatkit-python` ガイド](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)を参照してください。 -### OpenAI Conversations API セッション +### OpenAI Conversations API セッション {#openai-conversations-api-sessions} `OpenAIConversationsSession` を通じて [OpenAI の Conversations API](https://platform.openai.com/docs/api-reference/conversations)を使用します。 @@ -257,11 +257,11 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses 圧縮セッション +### OpenAI Responses 圧縮セッション {#openai-responses-compaction-sessions} Responses API(`responses.compact`)で保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動的に圧縮できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 -#### 一般的な使用方法(自動圧縮) +#### 一般的な使用方法(自動圧縮) {#typical-usage-auto-compaction} ```python from agents import Agent, Runner, SQLiteSession @@ -286,7 +286,7 @@ print(result.final_output) エージェントを `ModelSettings(store=False)` で実行すると、Responses API は後から参照できるように最後のレスポンスを保持しません。このステートレスな構成では、デフォルトの `"auto"` モードは、`previous_response_id` に依存せず、入力ベースの圧縮にフォールバックします。完全な例については、[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)を参照してください。 -#### 自動圧縮によるストリーミングのブロック +#### 自動圧縮によるストリーミングのブロック {#auto-compaction-can-block-streaming} 圧縮ではセッション履歴を消去して書き直すため、SDK は圧縮が完了するまで実行を完了とは見なしません。ストリーミングモードでは、圧縮処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 @@ -313,7 +313,7 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite セッション +### SQLite セッション {#sqlite-sessions} SQLite を使用するデフォルトの軽量セッション実装です。 @@ -334,7 +334,7 @@ result = await Runner.run( ) ``` -### 非同期 SQLite セッション +### 非同期 SQLite セッション {#async-sqlite-sessions} `aiosqlite` をバックエンドとする SQLite 永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 @@ -351,7 +351,7 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis セッション +### Redis セッション {#redis-sessions} 複数のワーカーまたはサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 @@ -374,7 +374,7 @@ await session.close() `from_url(...)` は Redis クライアントを作成し、所有します。`close()` の後、セッションは終了状態になり、それ以降のセッション操作では `RuntimeError` が発生します。`close()` は繰り返し呼び出したり同時に呼び出したりしても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築します。その場合、`close()` は何も行わず、呼び出し元が引き続きクライアントを所有し、セッションも使用できます。 -### SQLAlchemy セッション +### SQLAlchemy セッション {#sqlalchemy-sessions} SQLAlchemy がサポートする任意のデータベースを使用した、本番環境対応の Agents SDK セッション永続化です。 @@ -396,7 +396,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) 詳細なドキュメントについては、[SQLAlchemy セッション](sqlalchemy_session.md)を参照してください。 -### Dapr セッション +### Dapr セッション {#dapr-sessions} すでに Dapr サイドカーを実行している場合や、エージェントコードを変更せずに構成済みの状態ストアバックエンドを切り替えたい場合は、`DaprSession` を使用します。 @@ -429,7 +429,7 @@ async with DaprSession.from_address( - ローカルコンポーネントやトラブルシューティングを含む完全なセットアップ手順については、[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)を参照してください。 -### MongoDB セッション +### MongoDB セッション {#mongodb-sessions} すでに MongoDB を使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションでは、`MongoDBSession` を使用します。 @@ -461,7 +461,7 @@ await session.close() - 2 つのコレクションが使用され、両方の名前を `sessions_collection=`(デフォルトは `agent_sessions`)と `messages_collection=`(デフォルトは `agent_messages`)で設定できます。インデックスは初回使用時に自動的に作成されます。空でない `add_items()` の各呼び出しは、単調増加する `seq` によって最後のアイテムを基準にバッチの順序を決定する、1 つの論理バッチドキュメントを書き込みます。従来のアイテム単位のメッセージドキュメントも引き続き読み取れます。論理バッチは MongoDB の単一ドキュメントのサイズ制限内に収まる必要があります。サイズを超えたバッチは、部分的なバッチを保存することなくアトミックに失敗します。 - 最初の実行前に接続を確認するには、`await session.ping()` を使用します。 -### 高度な SQLite セッション +### 高度な SQLite セッション {#advanced-sqlite-sessions} 会話の分岐、使用量分析、構造化クエリを備えた拡張 SQLite セッションです。 @@ -485,7 +485,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 詳細なドキュメントについては、[高度な SQLite セッション](advanced_sqlite_session.md)を参照してください。 -### 暗号化セッション +### 暗号化セッション {#encrypted-sessions} あらゆるセッション実装に対応する透過的な暗号化ラッパーです。 @@ -512,13 +512,13 @@ result = await Runner.run(agent, "Hello", session=session) 詳細なドキュメントについては、[暗号化セッション](encrypted_session.md)を参照してください。 -### その他のセッションタイプ +### その他のセッションタイプ {#other-session-types} ほかにもいくつかの組み込みオプションがあります。`examples/memory/` と `extensions/memory/` 配下のソースコードを参照してください。 -## 運用パターン +## 運用パターン {#operational-patterns} -### セッション ID の命名 +### セッション ID の命名 {#session-id-naming} 会話の整理に役立つ、意味のあるセッション ID を使用します。 @@ -526,7 +526,7 @@ result = await Runner.run(agent, "Hello", session=session) - スレッドベース: `"thread_abc123"` - コンテキストベース: `"support_ticket_456"` -### メモリの永続化 +### メモリの永続化 {#memory-persistence} - 一時的な会話には、インメモリ SQLite(`SQLiteSession("session_id")`)を使用します - 永続的な会話には、ファイルベースの SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`)を使用します @@ -539,7 +539,7 @@ result = await Runner.run(agent, "Hello", session=session) - 任意のセッションを透過的な暗号化と TTL ベースの有効期限でラップするには、暗号化セッション(`EncryptedSession(session_id, underlying_session, encryption_key)`)を使用します - より高度なユースケースでは、ほかの本番システム(Django など)向けにカスタムセッションバックエンドを実装することを検討してください -### 複数のセッション +### 複数のセッション {#multiple-sessions} ```python from agents import Agent, Runner, SQLiteSession @@ -562,7 +562,7 @@ result2 = await Runner.run( ) ``` -### セッションの共有 +### セッションの共有 {#session-sharing} ```python # Different agents can share the same session @@ -583,7 +583,7 @@ result2 = await Runner.run( ) ``` -## 完全な例 +## 完全な例 {#complete-example} セッションメモリの動作を示す完全な例を次に示します。 @@ -647,7 +647,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## カスタムセッション実装 +## カスタムセッション実装 {#custom-session-implementations} [`Session`][agents.memory.session.Session] プロトコルに構造的に準拠するクラスを作成することで、独自のセッションメモリを実装できます。`SessionABC` から継承する必要はありません。`session_id` と `session_settings` を定義し、4 つの履歴メソッドを直接実装します。 @@ -691,7 +691,7 @@ result = await Runner.run( ) ``` -### カスタムセッションからの実行コンテキストへのアクセス +### カスタムセッションからの実行コンテキストへのアクセス {#accessing-run-context-from-a-custom-session} Agents SDK は、テナントルーティング、認可、またはアプリ固有のその他のストレージ判断のために、アクティブな [`RunContextWrapper`][agents.run_context.RunContextWrapper] をカスタムセッションへ渡すことができます。Agents SDK がラッパーを渡せるようにするには、4 つの履歴メソッドすべてに、明示的に命名され、キーワード引数として使用できる `wrapper` パラメーターを追加します。 @@ -732,7 +732,7 @@ class ContextAwareSession: Agents SDK がこの連携を有効にするのは、`get_items`、`add_items`、`pop_item`、`clear_session` のすべてで `wrapper` が宣言されている場合だけです。汎用の `**kwargs` パラメーターは、このシグネチャチェックを満たしません。`wrapper` を省略している既存のセッション実装は、公開済みの呼び出し形式を維持し、変更なしで引き続き動作します。 -## コミュニティによるセッション実装 +## コミュニティによるセッション実装 {#community-session-implementations} コミュニティは追加のセッション実装を開発しています。 @@ -742,7 +742,7 @@ Agents SDK がこの連携を有効にするのは、`get_items`、`add_items` セッション実装を構築した場合は、ここに追加するためのドキュメント PR をぜひ送信してください。 -## API リファレンス +## API リファレンス {#api-reference} 詳細な API ドキュメントについては、以下を参照してください。 diff --git a/docs/ja/sessions/sqlalchemy_session.md b/docs/ja/sessions/sqlalchemy_session.md index 2baa2aca3b..68faea2f36 100644 --- a/docs/ja/sessions/sqlalchemy_session.md +++ b/docs/ja/sessions/sqlalchemy_session.md @@ -6,7 +6,7 @@ search: `SQLAlchemySession` は SQLAlchemy を使用して本番環境対応のセッション実装を提供し、SQLAlchemy がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)をセッションストレージとして使用できるようにします。 -## インストール +## インストール {#installation} SQLAlchemy セッションには、`openai-agents` パッケージの optional-dependency extra `sqlalchemy` が必要です。 @@ -14,9 +14,9 @@ SQLAlchemy セッションには、`openai-agents` パッケージの optional-d pip install openai-agents[sqlalchemy] ``` -## クイックスタート +## クイックスタート {#quick-start} -### データベース URL の使用 +### データベース URL の使用 {#using-database-url} 最も簡単に開始する方法は次のとおりです。 @@ -42,7 +42,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 既存のエンジンの使用 +### 既存のエンジンの使用 {#using-existing-engine} 既存の SQLAlchemy エンジンを使用するアプリケーションの場合は、次のようにします。 @@ -73,7 +73,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 非 ASCII テキストの保存 +## 非 ASCII テキストの保存 {#storing-non-ascii-text} デフォルトでは、`SQLAlchemySession` はセッション項目を JSON にシリアライズする際に、非 ASCII 文字をエスケープします。これにより、従来の保存形式を維持しながら、項目の読み込み時には元のテキストを復元できます。 @@ -91,7 +91,7 @@ session = SQLAlchemySession.from_url( 既存のエンジンを使用する場合は、同じオプションを `SQLAlchemySession(...)` に直接渡すことができます。この設定によって変更されるのはデータベースに保存される JSON 表現のみであり、セッションメソッドが返す値は変更されません。 -## API リファレンス +## API リファレンス {#api-reference} - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - メインクラス - [`Session`][agents.memory.session.Session] - 基本セッションプロトコル \ No newline at end of file diff --git a/docs/ja/streaming.md b/docs/ja/streaming.md index b2f18f7a90..66f5a05582 100644 --- a/docs/ja/streaming.md +++ b/docs/ja/streaming.md @@ -10,7 +10,7 @@ search: 非同期イテレーターが終了するまで、`result.stream_events()` を受け取り続けてください。ストリーミング実行はイテレーターが終了するまで完了しません。また、セッションの永続化、承認の記録管理、履歴の圧縮などの後処理は、最後に表示されるトークンが到着した後に完了する場合があります。ループが終了すると、`result.is_complete` に最終的な実行状態が反映されます。 -## Raw レスポンスイベント +## Raw レスポンスイベント {#raw-response-events} [`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] オブジェクトは、LLM から直接渡される raw イベントをラップします。各オブジェクトの `data` フィールドには、`response.created` や `response.output_text.delta` などの型を持つ OpenAI Responses API イベントが含まれます。これらのイベントは、応答メッセージが生成され次第、ユーザーにストリーミングする場合に役立ちます。 @@ -39,7 +39,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## ストリーミングと承認 +## ストリーミングと承認 {#streaming-and-approvals} ストリーミングは、ツールの承認待ちで一時停止する実行にも対応しています。ツールに承認が必要な場合、`result.stream_events()` が終了し、保留中の承認が [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] に公開されます。`result.to_state()` を使用して実行結果を [`RunState`][agents.run_state.RunState] に変換し、中断を承認または拒否してから、`Runner.run_streamed(...)` で再開します。 @@ -59,7 +59,7 @@ if result.interruptions: 一時停止と再開の手順全体については、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 -## 現在のターン後のストリーミング停止 +## 現在のターン後のストリーミング停止 {#cancel-streaming-after-the-current-turn} ストリーミング実行を途中で停止する必要がある場合は、[`result.cancel()`][agents.result.RunResultStreaming.cancel] を呼び出します。デフォルトでは、実行は直ちに停止します。停止する前に現在のターンを正常に完了させるには、代わりに `result.cancel(mode="after_turn")` を呼び出します。 @@ -71,11 +71,11 @@ if result.interruptions: - ストリーミング実行がツールの承認待ちで停止した場合、それを新しいターンとして扱わないでください。ストリームを最後まで受け取り、`result.interruptions` を確認して、代わりに `result.to_state()` から再開します。 - 次のモデル呼び出しの前に、取得したセッション履歴と新しいユーザー入力をどのように統合するかをカスタマイズするには、[`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] を使用します。そこで新しいターンの項目を書き換えた場合、その書き換え後のバージョンがそのターンについて永続化されます。 -## 実行項目イベントとエージェントイベント +## 実行項目イベントとエージェントイベント {#run-item-events-and-agent-events} [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より上位レベルのイベントです。項目の生成が完全に完了した時点で通知されます。これにより、トークンごとではなく、「メッセージが生成された」「ツールが実行された」などの単位で進捗状況を通知できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(たとえば、ハンドオフの結果として)に更新を提供します。 -### 実行項目イベント名 +### 実行項目イベント名 {#run-item-event-names} `RunItemStreamEvent.name` では、次の固定されたセマンティックイベント名を使用します。 diff --git a/docs/ja/testing.md b/docs/ja/testing.md index 8eac8ae49e..6e983252a8 100644 --- a/docs/ja/testing.md +++ b/docs/ja/testing.md @@ -8,7 +8,7 @@ SDK は、エージェントワークフロー、Sandbox セッション、Realt これらは、アプリケーションと SDK が管理するオーケストレーション(ツール実行、ハンドオフ、ガードレール、再試行、ストリーミング、セッション動作、Sandbox 機能、Realtime イベント処理、Voice パイプライン構成)のテストに使用します。外部のモデル、ネットワークプロトコル、Sandbox プロバイダー、音声システムが管理する動作については、実際のプロバイダーアダプターまたは統合環境を使用してください。 -## 必要なレシピの検索 +## 必要なレシピの検索 {#find-the-recipe-you-need} | 目的 | 使用するもの | 参照先 | | --- | --- | --- | @@ -26,7 +26,7 @@ SDK は、エージェントワークフロー、Sandbox セッション、Realt | 静的またはストリーミングの Voice パイプラインをテストする | `ScriptedSTTModel`、`ScriptedTTSModel`、およびスクリプト化された、または実際のワークフロー | [Voice パイプラインのテスト](#test-a-voice-pipeline) | | プロバイダーのシリアライズまたはワイヤーペイロードをテストする | 制御されたネットワークトランスポートを備えた実際のプロバイダーアダプター | [適切な境界の選択](#choose-the-correct-boundary) | -## インポート +## インポート {#imports} テスト API は、置き換えるランタイム境界の近くに配置されています。 @@ -38,9 +38,9 @@ SDK は、エージェントワークフロー、Sandbox セッション、Realt テスト用シンボルは、意図的にトップレベルの `agents` インポートには含まれていません。 -## エージェントワークフローのレシピ +## エージェントワークフローのレシピ {#agent-workflow-recipes} -### 固定レスポンスの返却 +### 固定レスポンスの返却 {#return-a-fixed-response} 想定されるモデル呼び出しごとに、正規化済み出力項目のシーケンスを 1 つ渡します。出力シーケンスの省略記法には、1 回のリクエスト用の決定論的なレスポンス ID と使用量が設定されます。 @@ -71,7 +71,7 @@ async def test_fixed_response() -> None: 決定論的なワークフローテストの最後に `model.assert_complete()` を使用してください。設定されたすべてのステップを消費する前にワークフローが停止した場合を検出できます。 -### ツールワークフローのテスト +### ツールワークフローのテスト {#test-a-tool-workflow} ツールを呼び出すモデルレスポンスを 1 つ、その後に最終回答を生成する 2 つ目のレスポンスをスクリプト化します。これらのモデル呼び出しの間では、実際の SDK ツールパイプラインが実行されます。 @@ -117,7 +117,7 @@ async def test_tool_workflow() -> None: このパターンは、ツール入力の検証、実行、実行結果の変換、フック、ガードレール、および次のモデルターンをカバーします。Python 関数を直接呼び出すと、これらの SDK の動作は迂回されます。 -### リクエストからのレスポンス導出 +### リクエストからのレスポンス導出 {#derive-a-response-from-the-request} レスポンスが正規化済みモデル呼び出しに実際に依存する場合、またはアサーションをモデル境界に配置する場合は、`ModelStep.respond()` を使用します。レスポンダーは同期または非同期にでき、`ScriptedModel` が受け付ける任意のステップ形式を返せます。 @@ -151,7 +151,7 @@ async def test_request_aware_response() -> None: `ScriptedModel` は、`ModelStep`、同等の辞書形式、`ModelResponse`、正規化済み出力項目のシーケンス、または例外を受け付けます。レスポンスが呼び出しに依存しない場合は、固定スクリプトの方が予期しないターンを診断しやすいため、固定の出力シーケンスを優先してください。 -### モデル呼び出しの検査 +### モデル呼び出しの検査 {#inspect-model-calls} `ScriptedModel` は、選択されたステップを解決するか例外を発生させる前に、各呼び出しを記録します。 @@ -168,7 +168,7 @@ async def test_request_aware_response() -> None: 1 つのテストでモデルステップを段階的に追加する必要がある場合は、`enqueue()` または `extend()` を使用します。独立したシナリオには、新しい `ScriptedModel` を作成してください。このユーティリティは、消費済みステップや呼び出し履歴をリセットしません。 -### ストリーミングのテスト +### ストリーミングのテスト {#test-streaming} 通常のレスポンスステップは、`Runner.run()` と `Runner.run_streamed()` の両方をサポートします。一般的なアシスタントメッセージ、推論項目、関数呼び出し、およびパッチ適用呼び出しについて、`ScriptedModel` は、正規化済みの開始、差分、項目完了、および終了レスポンスイベントを生成します。終了レスポンスには、完全な出力と使用量が含まれます。 @@ -185,7 +185,7 @@ step = ModelStep.stream( 自動ストリーミングでは、段階的なライフサイクルが実装されていない種類の正規化済み出力項目は拒否されます。不完全なイベントシーケンスに依存せず、それらの項目には `ModelStep.stream(...)` を使用してください。 -### モデル障害の注入 +### モデル障害の注入 {#inject-model-failures} 1 回のモデル呼び出しを失敗させるには、`ModelStep.raise_error()` を使用します。オプションの再試行に関する指示は、そのスクリプト化されたエラーにのみ適用されます。 @@ -202,7 +202,7 @@ step = ModelStep.raise_error( ランナーの再試行ポリシーが、その指示によって再試行するかどうかを決定します。各再試行は別のモデル呼び出しであり、次のスクリプト化されたステップを消費します。Python ヘルパーは固定の `ModelRetryAdvice` 値を受け付けます。再試行に関する指示自体を試行ごとに動的に変える必要がある場合は、カスタム `Model` を使用してください。 -### ワークフローのドリフト検出 +### ワークフローのドリフト検出 {#detect-workflow-drift} スクリプト化された呼び出しを、想定されるワークフロー形状として扱います。余分なモデルリクエストがあると `UnexpectedModelCall` が発生し、早期終了するとステップが残り、`assert_complete()` によって報告されます。 @@ -214,9 +214,9 @@ step = ModelStep.raise_error( | `UnexpectedModelCall` | `call`、`call_index` | スクリプトの終了後にワークフローが別のモデル呼び出しを行いました | | `UnconsumedModelSteps` | `remaining_steps` | すべてのステップを使用する前にワークフローが終了しました | -## Sandbox エージェントのレシピ +## Sandbox エージェントのレシピ {#sandbox-agent-recipes} -### Sandbox エージェントワークフローのテスト +### Sandbox エージェントワークフローのテスト {#test-a-sandbox-agent-workflow} `ScriptedModel` と `scripted_sandbox_session()` を組み合わせると、ローカルコンテナまたはリモート Sandbox を作成せずに、実際の `SandboxAgent` ランタイムを実行できます。モデルスクリプトは機能ツールを選択し、Sandbox スクリプトは対応する `SandboxSession` メソッドが返す内容を定義します。 @@ -279,7 +279,7 @@ async def test_sandbox_workflow() -> None: このテストは、正規化された 2 つの SDK 境界を通過します。ツール引数の検証、機能のルーティング、Sandbox セッションの呼び出し、次のモデルターンへのツール実行結果の受け渡し、および最終出力の処理をカバーします。実際のモデルがコマンドを選択するかどうかや、実際の Sandbox プロバイダーがそれをどのように実行するかはテストしません。 -### Sandbox ステップの設定 +### Sandbox ステップの設定 {#configure-sandbox-steps} 一致する各 Sandbox 呼び出しは、1 つのグローバル FIFO シーケンスから次のステップを消費します。メソッドの不一致、マッチャーによる拒否、またはマッチャーの例外が発生した場合、そのステップは保留中のままになります。`method` を設定し、結果を厳密に 1 つ選択し、呼び出しの詳細が重要な場合にのみ `match` を追加してください。 @@ -303,9 +303,9 @@ async def test_sandbox_workflow() -> None: 返されるオブジェクトはセッション自体です。`RunConfig(sandbox={"session": sandbox})` に直接渡してください。ラッパーの `.session` 属性はありません。 -## Realtime のレシピ +## Realtime のレシピ {#realtime-recipes} -### Realtime セッションのテスト +### Realtime セッションのテスト {#test-a-realtime-session} `ScriptedRealtimeModel` は、Python SDK の正規化済み `RealtimeModel` 境界を実装します。各 `RealtimeStep` は、1 つの送信 `RealtimeModelSendEvent` と照合し、その後、正規化済みの受信 `RealtimeModelEvent` オブジェクトを発行するか、注入されたエラーを発生させます。 @@ -361,7 +361,7 @@ async def test_realtime_message() -> None: 接続中に受信イベントを発行するには、`connect_events` を使用します。ライフサイクルの障害には `connect_error` または `close_error` を使用し、1 回の照合済み送信に関連付けられた障害には `RealtimeStep(error=...)` を使用します。1 つのステップに `emit` と `error` の両方を定義することはできません。 -### Realtime ツールワークフローのテスト +### Realtime ツールワークフローのテスト {#test-a-realtime-tool-workflow} 実際の関数ツールを `RealtimeAgent` に接続し、正規化済みツール呼び出しを発行して、SDK がモデル境界を通じてツール出力を送信することを期待します。`async_tool_calls` を `False` に設定すると、この小さなコード例は、テスト専用の待機機構を使用せずに接続中に完了します。 @@ -421,7 +421,7 @@ async def test_realtime_tool_workflow() -> None: これにより、実際の Realtime ツール検索、引数検証、実行、および出力ルーティングが実行されます。実際のモデルがツールを選択することを証明するものではありません。 -### Realtime の呼び出しとライフサイクルの検査 +### Realtime の呼び出しとライフサイクルの検査 {#inspect-realtime-calls-and-lifecycle} | メンバー | 内容 | | --- | --- | @@ -441,9 +441,9 @@ async def test_realtime_tool_workflow() -> None: | `UnconsumedRealtimeSteps` | `remaining_steps` | 想定されたすべての送信を使用する前にセッションが終了しました | | `RealtimeScriptError` | なし | 切断中の送信など、無効なライフサイクル状態でスクリプトが使用されました | -## Voice パイプラインのレシピ +## Voice パイプラインのレシピ {#voice-pipeline-recipes} -### Voice パイプラインのテスト +### Voice パイプラインのテスト {#test-a-voice-pipeline} スクリプト化された STT および TTS モデルを、`SingleAgentVoiceWorkflow` と `ScriptedModel` を基盤とするエージェントと組み合わせると、プロバイダーへのリクエストを行わずに、音声テキスト変換 -> エージェント -> テキスト音声変換のパイプライン全体をテストできます。 @@ -501,7 +501,7 @@ workflow = ScriptedVoiceWorkflow( `start` ステップは、`on_start()` によって消費されます。`VoicePipeline` が `on_start()` を呼び出すのは `StreamedAudioInput` の場合のみです。静的な `AudioInput` 実行では、`start` は消費されません。通常の各ターンでは、文字起こしが記録され、設定された実行結果が 1 つ消費されます。文字列は 1 つのフラグメントです。文字列のシーケンスでは、テキスト分割と TTS の前のフラグメント境界を制御できます。 -### ストリーミング文字起こしのテスト +### ストリーミング文字起こしのテスト {#test-streamed-transcription} `ScriptedSTTModel` は、静的な `transcriptions` と、個別にスクリプト化されたストリーミング `sessions` を受け付けます。セッションには、`ScriptedTranscriptionSession`、文字起こしターンのシーケンス、例外、または単一の文字列を指定できます。 @@ -515,7 +515,7 @@ stt = ScriptedSTTModel(sessions=[session]) `ScriptedTranscriptionSession` を閉じると反復が停止し、スキップされたターンは `assert_complete()` による報告対象として残ります。同様に、`ScriptedTTSModel` は、呼び出しごとに 1 つの `TTSResult`、バイトチャンクのシーケンス、または例外を消費します。 -### Voice 呼び出しの検査 +### Voice 呼び出しの検査 {#inspect-voice-calls} | コンポーネント | 記録される履歴 | | --- | --- | @@ -532,7 +532,7 @@ stt = ScriptedSTTModel(sessions=[session]) テストで設定するスクリプト化された各 Voice コンポーネントに対して、`assert_complete()` を呼び出してください。`ScriptedSTTModel.assert_complete()` は、それが作成した文字起こしセッション内のターンも確認します。 -## 適切な境界の選択 +## 適切な境界の選択 {#choose-the-correct-boundary} モデルプロバイダーに依存せずに、SDK の実行ループ、ツール、ハンドオフ、ガードレール、セッション、再試行、または正規化済みストリーミングをテストする場合は、`ScriptedModel` を使用します。 @@ -544,7 +544,7 @@ WebSocket 接続を開かずに `RealtimeSession` の動作、または `Realtim Responses API または Chat Completions のリクエストシリアライズ、認証ヘッダー、プロバイダーのデフォルト値、HTTP ペイロード、プロバイダーのストリームチャンク、Realtime ワイヤーフレーム、またはプロバイダー固有のライフサイクル動作のテストには、これらのユーティリティを使用しないでください。そのようなテストでは実際のアダプターを維持し、そのネットワーク境界を置き換えるか制御してください。`openai` v3 では、OpenAI アダプターのテストに `httpx2` のリクエスト、レスポンス、トランスポート、および例外の型を使用する必要があります。従来の `httpx` は、Agents SDK のコア依存関係ではありません。 -## 最終チェックリスト +## 最終チェックリスト {#final-checklist} - 正規化済みモデル、Sandbox セッション、Realtime モデル、または Voice パイプライン境界が管理するやり取りのみをスクリプト化します。 - ランナーのプライベート状態ではなく、重要な公開リクエストフィールドまたは呼び出しフィールドをアサートします。 @@ -555,7 +555,7 @@ Responses API または Chat Completions のリクエストシリアライズ、 - 人が読めるメッセージを解析するのではなく、構造化されたエラーフィールドをアサートします。 - プロバイダーのワイヤーテストでは、制御されたネットワークトランスポートを備えた実際のアダプターを使用します。 -## スコープと現在の制限 +## スコープと現在の制限 {#scope-and-current-limitations} テストモジュールは、意図的に以下を提供していません。 @@ -568,7 +568,7 @@ Responses API または Chat Completions のリクエストシリアライズ、 不正な形式のストリーム、制御された中断または並行処理、厳密なキャンセル、またはスクリプト化ユーティリティでは維持できないライフサイクル境界がテストで必要な場合は、対応する公開インターフェースのカスタム実装を使用してください。その特殊な境界をテスト内に記載してください。 -## API リファレンス +## API リファレンス {#api-reference} - [`agents.testing`](ref/testing.md) - [`agents.realtime.testing`](ref/realtime/testing.md) diff --git a/docs/ja/tools.md b/docs/ja/tools.md index fa71beea2e..dd5732f4a3 100644 --- a/docs/ja/tools.md +++ b/docs/ja/tools.md @@ -12,7 +12,7 @@ search: - Agents as tools: 完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 - 実験的機能: Codex ツール: ツール呼び出しからワークスペーススコープの Codex タスクを実行します。 -## ツールタイプの選択 +## ツールタイプの選択 {#choosing-a-tool-type} このページをカタログとして使用し、管理するランタイムに対応するセクションに進んでください。 @@ -26,7 +26,7 @@ search: | ハンドオフせずに、あるエージェントから別のエージェントを呼び出し | [Agents as tools](#agents-as-tools) | | エージェントからワークスペーススコープの Codex タスクを実行 | [実験的機能: Codex ツール](#experimental-codex-tool) | -## ホストされたツール +## ホストされたツール {#hosted-tools} [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合、OpenAI はいくつかの組み込みツールを提供します。 @@ -62,7 +62,7 @@ async def main(): print(result.final_output) ``` -### ホストされたツール検索 +### ホストされたツール検索 {#hosted-tool-search} ツール検索を使用すると、OpenAI Responses モデルは大規模なツールセットの読み込みをランタイムまで遅延できるため、モデルは現在のターンに必要なサブセットのみを読み込みます。多数の関数ツール、名前空間グループ、またはホストされた MCP サーバーがあり、すべてのツールを事前に公開せずにツールスキーマのトークン数を削減したい場合に役立ちます。 @@ -126,7 +126,7 @@ print(result.final_output) - 名前空間による読み込みとトップレベルの遅延ツールの両方を扱う、完全に実行可能なコード例については、`examples/tools/tool_search.py` を参照してください。 - 公式プラットフォームガイド: [ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 -### プログラムによるツール呼び出し +### プログラムによるツール呼び出し {#programmatic-tool-calling} プログラムによるツール呼び出しを使用すると、対応する OpenAI Responses モデルが JavaScript を生成し、対象ツールを呼び出して、その出力を組み合わせ、1 つの結果をモデルに返せます。ツール呼び出しのたびにモデルとのラウンドトリップを行わず、ループ、分岐、並列呼び出し、中間計算を活用できる範囲限定のワークフローに役立ちます。 @@ -180,7 +180,7 @@ print(result.final_output) - 完全な並行在庫計画のコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 - 公式プラットフォームガイド: [プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 -### ホストされたコンテナシェルとスキル +### ホストされたコンテナシェルとスキル {#hosted-container-shell-skills} `ShellTool` は、OpenAI がホストするコンテナでの実行もサポートします。ローカルランタイムではなく、管理されたコンテナでモデルにシェルコマンドを実行させたい場合は、このモードを使用してください。 @@ -229,7 +229,7 @@ print(result.final_output) - 完全なコード例については、`examples/tools/container_shell_skill_reference.py` と `examples/tools/container_shell_inline_skill.py` を参照してください。 - OpenAI プラットフォームガイド: [シェル](https://platform.openai.com/docs/guides/tools-shell)と[スキル](https://platform.openai.com/docs/guides/tools-skills)。 -## ローカルランタイムツール +## ローカルランタイムツール {#local-runtime-tools} ローカルランタイムツールは、モデルのレスポンス自体の外部で実行されます。モデルが呼び出すタイミングを決定する点は変わりませんが、実際の処理はアプリケーションまたは設定された実行環境が行います。 @@ -245,7 +245,7 @@ print(result.final_output) 有限のシェルアクションタイムアウトには、正の整数のミリ秒値を使用します。0 は実行プログラムの実装間で共通の意味を持たないため、SDK はローカルの `ShellTool` 実行プログラムを呼び出す前に、`0` と `None` の両方を明示的なタイムアウトなしとして扱います。その他の値は、実行プログラムの呼び出し前に拒否されます。これはタイムアウトフィールドに固有の動作です。キャプチャされる出力を空にするリクエストとして、`max_output_length=0` は引き続きサポートされます。 -### ComputerTool と Responses のコンピュータツール +### ComputerTool と Responses のコンピュータツール {#computertool-and-the-responses-computer-tool} `ComputerTool` は引き続きローカルハーネスです。[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供すると、SDK がそのハーネスを OpenAI Responses API のコンピュータ操作インターフェースにマッピングします。 @@ -304,7 +304,7 @@ agent = Agent( ) ``` -## 関数ツール +## 関数ツール {#function-tools} 任意の Python 関数をツールとして使用できます。Agents SDK がツールを自動的に設定します。 @@ -445,7 +445,7 @@ for tool in agent.tools: } ``` -### 関数ツールからの画像またはファイルの返却 +### 関数ツールからの画像またはファイルの返却 {#returning-images-or-files-from-function-tools} テキスト出力に加えて、関数ツールの出力として 1 つ以上の画像またはファイルを返せます。そのためには、次のいずれかを返します。 @@ -453,7 +453,7 @@ for tool in agent.tools: - ファイル: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) - テキスト: 文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) -### カスタム関数ツール +### カスタム関数ツール {#custom-function-tools} Python 関数をツールとして使用したくない場合もあります。必要に応じて、[`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。次の項目を指定する必要があります。 @@ -493,7 +493,7 @@ tool = FunctionTool( ) ``` -### 引数と docstring の自動解析 +### 引数と docstring の自動解析 {#automatic-argument-and-docstring-parsing} 前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと個々の引数の説明を抽出します。これについて、いくつか留意点があります。 @@ -502,7 +502,7 @@ tool = FunctionTool( スキーマ抽出のコードは、[`agents.function_schema`][] にあります。 -### Pydantic Field による引数の制約と説明 +### Pydantic Field による引数の制約と説明 {#constraining-and-describing-arguments-with-pydantic-field} Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用すると、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値ベースの形式(`arg: int = Field(..., ge=1)`)と `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)の両方がサポートされます。生成される JSON スキーマと検証には、これらの制約が含まれます。 @@ -522,7 +522,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr return f"Score recorded: {score}" ``` -### 関数ツールのタイムアウト +### 関数ツールのタイムアウト {#function-tool-timeouts} `@function_tool(timeout=...)` を使用すると、非同期関数ツールに呼び出し単位のタイムアウトを設定できます。 @@ -577,7 +577,7 @@ except ToolTimeoutError as e: タイムアウト設定は、非同期の `@function_tool` ハンドラーでのみサポートされます。 -### 関数ツールのエラー処理 +### 関数ツールのエラー処理 {#handling-errors-in-function-tools} `@function_tool` を介して関数ツールを作成する場合、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 @@ -609,7 +609,7 @@ def get_user_profile(user_id: str) -> str: `FunctionTool` オブジェクトを手動で作成する場合は、`on_invoke_tool` 関数内でエラーを処理する必要があります。 -## Agents as tools +## Agents as tools {#agents-as-tools} ワークフローによっては、制御をハンドオフするのではなく、中央のエージェントで専門エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 @@ -655,7 +655,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### ツールエージェントのカスタマイズ +### ツールエージェントのカスタマイズ {#customizing-tool-agents} `agent.as_tool` は、エージェントをツールに変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` による構造化入力もサポートします。 @@ -681,7 +681,7 @@ async def run_my_agent() -> str: return str(result.final_output) ``` -### ツールエージェントの構造化入力 +### ツールエージェントの構造化入力 {#structured-input-for-tool-agents} デフォルトでは、`Agent.as_tool()` は文字列フィールド `input`(`{"input": "..."}`)を 1 つ持つオブジェクトを想定しますが、`parameters`(Pydantic モデル型または dataclass 型)を渡すことで、構造化スキーマを公開できます。 @@ -711,11 +711,11 @@ translator_tool = translator_agent.as_tool( 完全に実行可能なコード例については、`examples/agent_patterns/agents_as_tools_structured.py` を参照してください。 -### ツールエージェントの承認ゲート +### ツールエージェントの承認ゲート {#approval-gates-for-tool-agents} `Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合は実行が一時停止し、保留中の項目が `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開してください。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 -### カスタム出力抽出 +### カスタム出力抽出 {#custom-output-extraction} 場合によっては、中央のエージェントに返す前に、ツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 @@ -744,7 +744,7 @@ json_tool = data_agent.as_tool( カスタム抽出プログラム内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、raw 引数が必要な場合に役立ちます。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 -### ネストされたエージェント実行のストリーミング +### ネストされたエージェント実行のストリーミング {#streaming-nested-agent-runs} `as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが出力するストリーミングイベントをリッスンしながら、ストリームの完了後に最終出力を返せます。 @@ -772,7 +772,7 @@ billing_agent_tool = billing_agent.as_tool( - モデルのツール呼び出しを介してツールが呼び出された場合、`tool_call` が存在します。直接呼び出した場合は、`None` のままになる可能性があります。 - 完全に実行可能なサンプルについては、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 -### 条件付きツール有効化 +### 条件付きツール有効化 {#conditional-tool-enabling} `is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的に絞り込めます。 @@ -842,7 +842,7 @@ asyncio.run(main()) - 異なるツール設定の A/B テスト - ランタイム状態に基づく動的なツール絞り込み -## 実験的機能: Codex ツール +## 実験的機能: Codex ツール {#experimental-codex-tool} `codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペーススコープのタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。このインターフェースは実験的機能であり、変更される可能性があります。 diff --git a/docs/ja/tracing.md b/docs/ja/tracing.md index 5d787898b2..d26afe4548 100644 --- a/docs/ja/tracing.md +++ b/docs/ja/tracing.md @@ -16,7 +16,7 @@ Agents SDK には組み込みのトレーシング機能があり、エージェ ***Zero Data Retention (ZDR) ポリシーの下で OpenAI の API を使用する組織では、トレーシングを利用できません。*** -## トレースとスパン +## トレースとスパン {#traces-and-spans} - **トレース** は、「ワークフロー」における単一のエンドツーエンド操作を表します。トレースは複数のスパンで構成されます。トレースには次のプロパティがあります。 - `workflow_name`: 論理的なワークフローまたはアプリの名前です。たとえば、「コード生成」や「カスタマーサービス」などです。 @@ -30,7 +30,7 @@ Agents SDK には組み込みのトレーシング機能があり、エージェ - `parent_id`: このスパンの親スパンが存在する場合、その親スパンを指します - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 -## デフォルトのトレーシング +## デフォルトのトレーシング {#default-tracing} デフォルトでは、SDK は次の項目をトレーシングします。 @@ -62,7 +62,7 @@ result = await Runner.run( さらに、[カスタムトレースプロセッサー](#custom-tracing-processors)を設定して、別の送信先へトレースを送信できます。これは、既存の送信先の代替または追加の送信先として使用できます。 -## 長時間実行ワーカーと即時エクスポート +## 長時間実行ワーカーと即時エクスポート {#long-running-workers-and-immediate-exports} デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはインメモリキューがサイズのトリガー値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセスの終了時には最終フラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 @@ -105,7 +105,7 @@ async def run(prompt: str, background_tasks: BackgroundTasks): [`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンがエクスポートされるまで処理をブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 -## 上位レベルのトレース +## 上位レベルのトレース {#higher-level-traces} 複数回の `run()` 呼び出しを単一のトレースに含めたい場合があります。その場合は、コード全体を `trace()` でラップします。 @@ -124,7 +124,7 @@ async def main(): 1. 2 回の `Runner.run` 呼び出しが `with trace()` でラップされているため、それぞれが個別のトレースを作成するのではなく、両方の実行が 1 つの全体的なトレースに含まれます。 -## トレースの作成 +## トレースの作成 {#creating-traces} [`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始および終了する必要があります。これには次の 2 つの方法があります。 @@ -133,13 +133,13 @@ async def main(): 現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始および終了する場合、現在のトレースを更新するには、`start()` に `mark_as_current` を渡し、`finish()` に `reset_current` を渡します。 -## スパンの作成 +## スパンの作成 {#creating-spans} さまざまな [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するための [`custom_span()`][agents.tracing.custom_span] 関数も利用できます。 スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの配下にネストされます。 -## 機密データ +## 機密データ {#sensitive-data} 一部のスパンでは、機密性の高い可能性があるデータがキャプチャされる場合があります。 @@ -149,7 +149,7 @@ async def main(): デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定してエクスポートすることで、コードを変更せずにデフォルト値を設定できます。 -## カスタムトレースプロセッサー +## カスタムトレースプロセッサー {#custom-tracing-processors} トレーシングの高レベルアーキテクチャは次のとおりです。 @@ -162,7 +162,7 @@ async def main(): 2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで **置き換える** ことができます。この場合、送信を行う `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 -## OpenAI 以外のモデルによるトレーシング +## OpenAI 以外のモデルによるトレーシング {#tracing-with-non-openai-models} OpenAI 以外のモデルを使用する場合、トレーシングを無効化することなく OpenAI Traces ダッシュボードで無料のトレーシングを有効にするため、トレーシングエクスポーターに OpenAI API キーを指定できます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションを参照してください。 @@ -197,15 +197,15 @@ await Runner.run( ) ``` -## 補足事項 +## 補足事項 {#additional-notes} - OpenAI Traces ダッシュボードで無料のトレースを確認できます。 -## エコシステム統合 +## エコシステム統合 {#ecosystem-integrations} 以下のコミュニティおよびベンダー統合は、OpenAI Agents SDK のトレーシング API サーフェスをサポートしています。 -### 外部トレースプロセッサーの一覧 +### 外部トレースプロセッサーの一覧 {#external-tracing-processors-list} - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) diff --git a/docs/ja/usage.md b/docs/ja/usage.md index 27bcfcdce7..99800ea46c 100644 --- a/docs/ja/usage.md +++ b/docs/ja/usage.md @@ -6,7 +6,7 @@ search: Agents SDK は、実行ごとのトークン使用状況を自動的に追跡します。実行コンテキストからアクセスし、コストの監視、制限の適用、分析データの記録に使用できます。 -## 追跡対象 +## 追跡対象 {#what-is-tracked} - **requests**: 実行された LLM API 呼び出しの数 - **input_tokens**: 送信された入力トークンの合計 @@ -18,7 +18,7 @@ Agents SDK は、実行ごとのトークン使用状況を自動的に追跡し - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 実行からの使用状況へのアクセス +## 実行からの使用状況へのアクセス {#accessing-usage-from-a-run} `Runner.run(...)` の実行後、`result.context_wrapper.usage` から使用状況にアクセスします。 @@ -36,7 +36,7 @@ print("Total tokens:", usage.total_tokens) [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] が実行の終了前に履歴を自動的にコンパクト化した場合、その `responses.compact` リクエストによって報告された使用状況も、同じ実行の合計に加算されます。実行の外部で手動による `run_compaction()` 呼び出しを行った場合、包含する実行コンテキストがないため、以前の実行から返された使用状況オブジェクトは更新されません。[OpenAI Responses のコンパクションセッション](sessions/index.md#openai-responses-compaction-sessions)を参照してください。 -### サードパーティーアダプターでの使用状況の有効化 +### サードパーティーアダプターでの使用状況の有効化 {#enabling-usage-with-third-party-adapters} 使用状況の報告は、サードパーティーアダプターやプロバイダーバックエンドによって異なります。サードパーティーアダプター経由でモデルにアクセスし、正確な `result.context_wrapper.usage` 値が必要な場合は、以下を確認してください。 @@ -45,7 +45,7 @@ print("Total tokens:", usage.total_tokens) モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションにあるアダプター固有の注意事項を確認し、デプロイ予定のプロバイダーバックエンドで使用状況が正しく報告されることを検証してください。 -## リクエストごとの使用状況の追跡 +## リクエストごとの使用状況の追跡 {#per-request-usage-tracking} SDK は、`request_usage_entries` 内の各 API リクエストの使用状況を自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウの消費量の監視に役立ちます。 @@ -56,7 +56,7 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## プロバイダーの使用状況ペイロードの保持 +## プロバイダーの使用状況ペイロードの保持 {#preserving-provider-usage-payloads} Agents SDK は、プロバイダーの使用状況を、モデルプロバイダー間で一貫した合計値を提供する [`Usage`][agents.usage.Usage] フィールドに正規化します。アプリケーションでプロバイダー固有の使用状況フィールドを保持する必要がある場合、または省略されたフィールドとプロバイダーが報告したゼロを区別する必要がある場合は、[`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] を `True` に設定します。 @@ -79,7 +79,7 @@ Agents SDK は、各モデル呼び出しのプロバイダーペイロードに `LitellmModel` は現在、ストリーミング実行と非ストリーミング実行のいずれでも `ModelResponse.raw_usage` を設定しないため、そのアダプターでは `preserve_raw_usage=True` は効果がありません。`LitellmModel` を使用する場合は、正規化された [`Usage`][agents.usage.Usage] フィールドを引き続き使用してください。プロバイダー固有のフィールドの存在有無を保持する必要がある場合は、raw 使用状況の保持をサポートするアダプターを選択してください。 -## セッションでの使用状況へのアクセス +## セッションでの使用状況へのアクセス {#accessing-usage-with-sessions} `Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その特定の実行の使用状況が返されます。セッションはコンテキストのために会話履歴を保持しますが、各実行の使用状況は独立しています。 @@ -95,7 +95,7 @@ print(second.context_wrapper.usage.total_tokens) # Usage for second run セッションは実行間で会話コンテキストを保持しますが、各 `Runner.run()` 呼び出しによって返される使用状況の指標は、その特定の実行のみを表します。セッションでは、以前のメッセージが各実行への入力として再度渡される場合があり、後続のターンにおける入力トークン数に影響します。 -## RunState チェックポイントでの使用状況 +## RunState チェックポイントでの使用状況 {#usage-in-runstate-checkpoints} [`RunResult.to_state()`][agents.result.RunResult.to_state] は、それまでに蓄積された使用状況の独立したスナップショットを取得します。そのチェックポイントから再開された実行は、取得済みの合計値から開始し、独自のモデル呼び出しによる使用状況を加算します。再開された実行では、これらの新しい合計値は元の `RunResult` にも、その実行結果から作成された別のチェックポイントにも加算されません。 @@ -113,7 +113,7 @@ assert resumed_b.context_wrapper.usage is not resumed_a.context_wrapper.usage この分離は、[`Usage`][agents.usage.Usage] 内の `request_usage_entries` リストにも適用されます。ただし、再開されたネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] 実行は、独立したトップレベルの集計の例外です。再開後のモデル使用状況は、ネストされた実行の以前のモデル呼び出しと同様に、アクティブな外側の実行の使用状況へ意図的に集計されます。 -## フックでの使用状況 +## フックでの使用状況 {#using-usage-in-hooks} `RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの重要な時点で使用状況を記録できます。 @@ -124,7 +124,7 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API リファレンス +## API リファレンス {#api-reference} API の詳細なドキュメントについては、以下を参照してください。 diff --git a/docs/ja/visualization.md b/docs/ja/visualization.md index 26b0f4d073..723dbc0824 100644 --- a/docs/ja/visualization.md +++ b/docs/ja/visualization.md @@ -6,7 +6,7 @@ search: エージェントの可視化では、 **Graphviz** を使用して、エージェントと、他のエージェント、ツール、MCP サーバーとの接続を構造化されたグラフとして生成できます。これは、アプリケーション内でエージェント、ツール、ハンドオフがどのように連携するかを理解するのに役立ちます。 -## インストール +## インストール {#installation} オプションの `viz` 依存関係グループをインストールします。 @@ -14,7 +14,7 @@ search: pip install "openai-agents[viz]" ``` -## グラフの生成 +## グラフの生成 {#generating-a-graph} `draw_graph` 関数を使用して、エージェントの可視化を生成できます。この関数は、次のような有向グラフを作成します。 @@ -23,7 +23,7 @@ pip install "openai-agents[viz]" - **ツール** は緑色の楕円で表されます。 - **ハンドオフ** は、あるエージェントから別のエージェントへの有向エッジで表されます。 -### 使用例 +### 使用例 {#example-usage} ```python import os @@ -75,7 +75,7 @@ draw_graph(triage_agent) `draw_graph()` は、`handoffs` で直接指定された対象エージェント、または `handoff(agent)` を通じて登録された対象エージェントを再帰的に展開します。どちらの形式でも、グラフには各対象のツール、MCP サーバー、およびその先のハンドオフが含まれます。利用可能な対象 `Agent` がないカスタム `Handoff` は、名前付きの接続先としてのみ描画されるため、グラフではその接続先の背後にあるリソースを展開できません。 -## 可視化の構成 +## 可視化の構成 {#understanding-the-visualization} 生成されるグラフには、次の要素が含まれます。 @@ -91,16 +91,16 @@ draw_graph(triage_agent) **注:** MCP サーバーは、`agents` パッケージの最近のバージョンで描画されます。この動作が確認されている **v0.2.8** も含まれます。可視化に MCP のボックスが表示されない場合は、最新リリースにアップグレードしてください。 -## グラフのカスタマイズ +## グラフのカスタマイズ {#customizing-the-graph} -### グラフの表示 +### グラフの表示 {#showing-the-graph} デフォルトでは、`draw_graph` はグラフをインラインで表示します。グラフを別ウィンドウに表示するには、次のように記述します。 ```python draw_graph(triage_agent).view() ``` -### グラフの保存 +### グラフの保存 {#saving-the-graph} デフォルトでは、`draw_graph` はグラフをインラインで表示します。ファイルとして保存するには、ファイル名を指定します。 ```python diff --git a/docs/ja/voice/pipeline.md b/docs/ja/voice/pipeline.md index 91b8a57967..2dbf794eb3 100644 --- a/docs/ja/voice/pipeline.md +++ b/docs/ja/voice/pipeline.md @@ -32,7 +32,7 @@ graph LR ``` -## パイプラインの設定 +## パイプラインの設定 {#configuring-a-pipeline} パイプラインを作成するとき、次の項目を設定できます。 @@ -43,14 +43,14 @@ graph LR - トレーシングを無効にするかどうか、音声ファイルをアップロードするかどうか、ワークフロー名、トレース ID などのトレーシング設定 - プロンプト、言語、使用するデータ型など、TTS モデルと STT モデルの設定 -## パイプラインの実行 +## パイプラインの実行 {#running-a-pipeline} [`run()`][agents.voice.pipeline.VoicePipeline.run] メソッドを使用してパイプラインを実行できます。このメソッドには、次の 2 つの形式で音声入力を渡せます。 1. [`AudioInput`][agents.voice.input.AudioInput] は、完全な音声入力があり、その結果を生成するだけの場合に使用します。これは、話者が話し終えたタイミングを検出する必要がない場合に便利です。たとえば、事前に録音された音声がある場合や、ユーザーが話し終えたタイミングが明確なプッシュ・トゥ・トークアプリの場合です。 2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] は、ユーザーが話し終えたタイミングを検出する必要がある場合に使用します。検出された音声チャンクを順次プッシュでき、音声パイプラインは「アクティビティ検出」と呼ばれる処理を通じて、適切なタイミングでエージェントのワークフローを自動的に実行します。 -## 結果 +## 結果 {#results} 音声パイプラインの実行結果は [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult] です。これは、イベントの発生に応じてストリーミングできるオブジェクトです。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] には、次のようないくつかの種類があります。 @@ -76,8 +76,8 @@ async for event in result.stream(): pass ``` -## ベストプラクティス +## ベストプラクティス {#best-practices} -### 割り込み +### 割り込み {#interruptions} 現在、Agents SDK は [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] に対する組み込みの割り込み処理を提供していません。代わりに、検出されたターンごとにワークフローが個別に実行されます。アプリケーション内で割り込みを処理する場合は、[`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] イベントをリッスンできます。`turn_started` は、新しいターンが文字起こしされ、処理が開始されたことを示します。`turn_ended` は、該当するターンのすべての音声が送信された後にトリガーされます。これらのイベントを使用して、モデルがターンを開始したときに話者のマイクをミュートし、アプリケーションがそのターンに関連するすべての音声の再生を終えた後にミュートを解除できます。 \ No newline at end of file diff --git a/docs/ja/voice/quickstart.md b/docs/ja/voice/quickstart.md index c2514e3014..7670967e51 100644 --- a/docs/ja/voice/quickstart.md +++ b/docs/ja/voice/quickstart.md @@ -4,7 +4,7 @@ search: --- # クイックスタート -## 前提条件 +## 前提条件 {#prerequisites} Agents SDKの基本的な[クイックスタート手順](../quickstart.md)に従い、仮想環境をセットアップしていることを確認してください。次に、SDK からオプションの音声依存関係をインストールします。 @@ -18,7 +18,7 @@ pip install 'openai-agents[voice]' pip install sounddevice ``` -## 概念 +## 概念 {#concepts} 理解しておくべき主な概念は [`VoicePipeline`][agents.voice.pipeline.VoicePipeline] です。これは次の 3 ステップのプロセスです。 @@ -52,7 +52,7 @@ graph LR ``` -## エージェント +## エージェント {#agents} まず、複数のエージェントをセットアップします。この SDK でエージェントを構築したことがあれば、見慣れた内容です。2 つのエージェント、設定済みのハンドオフ、ツールを 1 つ用意します。 @@ -92,7 +92,7 @@ agent = Agent( ) ``` -## 音声パイプライン +## 音声パイプライン {#voice-pipeline} ワークフローに [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] を使用して、シンプルな音声パイプラインをセットアップします。 @@ -101,7 +101,7 @@ from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` -## パイプラインの実行 +## パイプラインの実行 {#run-the-pipeline} ```python import numpy as np @@ -126,7 +126,7 @@ async for event in result.stream(): ``` -## 全体の統合 +## 全体の統合 {#put-it-all-together} ```python import asyncio diff --git a/docs/ko/agents.md b/docs/ko/agents.md index 2b5108adde..878ec6243c 100644 --- a/docs/ko/agents.md +++ b/docs/ko/agents.md @@ -10,7 +10,7 @@ search: SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기서 중요한 차이는 오케스트레이션입니다. `Agent`와 `Runner`을 사용하면 SDK가 턴, 도구, 가드레일, 핸드오프 및 세션을 대신 관리할 수 있습니다. 이 루프를 직접 관리하려면 Responses API를 직접 사용하세요. -## 다음 가이드 선택 +## 다음 가이드 선택 {#choose-the-next-guide} 이 페이지를 에이전트 정의의 중심 가이드로 활용하세요. 다음에 내려야 할 결정에 맞는 인접 가이드로 이동하세요. @@ -25,7 +25,7 @@ SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 여기 | 최종 출력, 실행 항목 또는 재개 가능한 상태 검사 | [결과](results.md) | | 로컬 종속성과 런타임 상태 공유 | [컨텍스트 관리](context.md) | -## 기본 구성 +## 기본 구성 {#basic-configuration} 에이전트의 가장 일반적인 속성은 다음과 같습니다. @@ -67,7 +67,7 @@ agent = Agent( 이 섹션의 모든 내용은 `Agent`에 적용됩니다. `SandboxAgent`은 동일한 개념을 기반으로 하며, 워크스페이스 범위 실행을 위한 `default_manifest`, `base_instructions`, `capabilities`, `run_as`을 추가합니다. [샌드박스 에이전트 개념](sandbox/guide.md)을 참조하세요. -## 프롬프트 템플릿 +## 프롬프트 템플릿 {#prompt-templates} `prompt`을 설정하여 OpenAI 플랫폼에서 생성한 프롬프트 템플릿을 참조할 수 있습니다. 이 기능은 Responses API를 통해 OpenAI 모델에 접근할 때 작동합니다. @@ -126,7 +126,7 @@ result = await Runner.run( ) ``` -## 컨텍스트 +## 컨텍스트 {#context} 에이전트는 `context` 타입에 대해 제네릭입니다. 컨텍스트는 종속성 주입 도구입니다. 컨텍스트는 사용자가 생성하여 `Runner.run()`에 전달하는 객체로, 모든 에이전트, 도구, 핸드오프 등에 전달되며 에이전트 실행에 필요한 종속성과 상태를 담는 컨테이너 역할을 합니다. 모든 Python 객체를 컨텍스트로 제공할 수 있습니다. @@ -154,7 +154,7 @@ agent = Agent[UserContext]( ) ``` -## 출력 타입 +## 출력 타입 {#output-types} 기본적으로 에이전트는 일반 텍스트(즉, `str`) 출력을 생성합니다. 에이전트가 특정 타입의 출력을 생성하도록 하려면 `output_type` 매개변수를 사용할 수 있습니다. 일반적으로 [Pydantic](https://docs.pydantic.dev/) 객체를 사용하지만, 데이터 클래스, 리스트, TypedDict 등 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/)로 래핑할 수 있는 모든 타입을 지원합니다. @@ -179,7 +179,7 @@ agent = Agent( `output_type`을 전달하면 모델이 일반적인 일반 텍스트 응답 대신 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 사용하도록 지정합니다. -## 다중 에이전트 시스템 설계 패턴 +## 다중 에이전트 시스템 설계 패턴 {#multi-agent-system-design-patterns} 다중 에이전트 시스템을 설계하는 방법은 다양하지만, 일반적으로 폭넓게 적용할 수 있는 다음 두 가지 패턴이 사용됩니다. @@ -188,7 +188,7 @@ agent = Agent( 자세한 내용은 [에이전트 구축 실전 가이드](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)를 참조하세요. -### 관리자(Agents as tools) +### 관리자(Agents as tools) {#manager-agents-as-tools} `customer_facing_agent`은 모든 사용자 상호작용을 처리하고 도구로 노출된 전문 하위 에이전트를 호출합니다. 자세한 내용은 [도구](tools.md#agents-as-tools) 문서를 참조하세요. @@ -217,7 +217,7 @@ customer_facing_agent = Agent( ) ``` -### 핸드오프 +### 핸드오프 {#handoffs} 구성된 핸드오프 대상은 에이전트가 작업을 위임할 수 있는 하위 에이전트입니다. 핸드오프가 발생하면 위임받은 에이전트가 대화 기록을 전달받아 대화를 이어갑니다. 이 패턴을 사용하면 단일 작업에 특화된 모듈식 전문 에이전트를 구성할 수 있습니다. 자세한 내용은 [핸드오프](handoffs.md) 문서를 참조하세요. @@ -238,7 +238,7 @@ triage_agent = Agent( ) ``` -## 동적 지침 +## 동적 지침 {#dynamic-instructions} 대부분의 경우 에이전트를 생성할 때 지침을 제공할 수 있습니다. 하지만 함수를 통해 동적 지침을 제공할 수도 있습니다. 함수는 에이전트와 컨텍스트를 전달받으며 프롬프트를 반환해야 합니다. 일반 함수와 `async` 함수가 모두 허용됩니다. @@ -257,7 +257,7 @@ agent = Agent[UserContext]( ) ``` -## 수명 주기 이벤트(훅) +## 수명 주기 이벤트(훅) {#lifecycle-events-hooks} 에이전트의 수명 주기를 관찰해야 하는 경우가 있습니다. 예를 들어 특정 이벤트가 발생할 때 이벤트를 기록하거나, 데이터를 미리 가져오거나, 사용량을 기록할 수 있습니다. @@ -302,11 +302,11 @@ print(result.final_output) 전체 콜백 인터페이스는 [수명 주기 API 레퍼런스](ref/lifecycle.md)를 참조하세요. -## 가드레일 +## 가드레일 {#guardrails} 가드레일을 사용하면 에이전트 실행과 병렬로 사용자 입력에 대한 검사/검증을 실행하고, 에이전트 출력이 생성된 후 해당 출력을 검사할 수 있습니다. 예를 들어 사용자 입력과 에이전트 출력의 관련성을 확인할 수 있습니다. 자세한 내용은 [가드레일](guardrails.md) 문서를 참조하세요. -## 에이전트 복제/복사 +## 에이전트 복제/복사 {#cloningcopying-agents} 에이전트의 `clone()` 메서드를 사용하면 에이전트를 복제하고 원하는 속성을 선택적으로 변경할 수 있습니다. @@ -323,7 +323,7 @@ robot_agent = pirate_agent.clone( ) ``` -## 도구 사용 강제 +## 도구 사용 강제 {#forcing-tool-use} 도구 목록을 제공한다고 해서 LLM이 항상 도구를 사용하는 것은 아닙니다. [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]을 설정하여 도구 사용을 강제할 수 있습니다. 유효한 값은 다음과 같습니다. @@ -351,7 +351,7 @@ agent = Agent( ) ``` -## 도구 사용 동작 +## 도구 사용 동작 {#tool-use-behavior} `Agent` 구성의 `tool_use_behavior` 매개변수는 도구 출력의 처리 방식을 제어합니다. diff --git a/docs/ko/config.md b/docs/ko/config.md index 084614d6c0..51d7e50027 100644 --- a/docs/ko/config.md +++ b/docs/ko/config.md @@ -16,7 +16,7 @@ search: - 모델 선택 및 제공자 구성은 [모델](models/index.md)을 참고하세요. - 실행별 트레이싱 메타데이터 및 사용자 지정 트레이스 프로세서는 [트레이싱](tracing.md)을 참고하세요. -## 구성 객체와 딕셔너리 +## 구성 객체와 딕셔너리 {#configuration-objects-and-dictionaries} SDK에서 정의한 구성 매개변수는 일반적으로 형식이 지정된 설정 객체 또는 동일한 필드를 포함하는 딕셔너리를 허용합니다. 이는 형식 어노테이션에 딕셔너리가 포함된 에이전트, 실행, 모델, 세션, 샌드박스 및 음성 구성 경계 전반에 적용됩니다. SDK에서 정의한 중첩 설정 형식에도 딕셔너리를 사용할 수 있습니다. @@ -35,7 +35,7 @@ agent = Agent( SDK는 이러한 딕셔너리를 해당 설정 객체로 정규화합니다. SDK에서 정의한 데이터 클래스 구성 형식에 알 수 없는 필드가 있으면 `TypeError`이 발생하므로, 옵션 이름의 오타를 조기에 발견하는 데 도움이 됩니다. 특정 경계에서 딕셔너리를 허용하는지 확인하려면 해당 매개변수의 형식 어노테이션 또는 API 레퍼런스를 확인하세요. -## API 키와 클라이언트 +## API 키와 클라이언트 {#api-keys-and-clients} 기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. SDK가 처음 OpenAI 클라이언트를 생성할 때 키를 확인하므로(지연 초기화), 첫 번째 모델 호출 전에 환경 변수를 설정하세요. 앱이 시작되기 전에 해당 환경 변수를 설정할 수 없다면 [set_default_openai_key()][agents.set_default_openai_key] 함수를 사용하여 키를 설정할 수 있습니다. @@ -57,7 +57,7 @@ set_default_openai_client(custom_client) [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider]에 명시적 클라이언트를 전달하면 해당 클라이언트가 연결 및 계정 설정을 관리합니다. `OpenAIProvider`에 `api_key`, `base_url`, `websocket_base_url`, `organization` 또는 `project`을 함께 전달하지 마세요. `openai_client`을 이러한 인수 중 하나와 함께 사용하면 중복 값을 조용히 무시하는 대신 [`UserError`][agents.exceptions.UserError]가 발생합니다. `AsyncOpenAI`을 생성할 때 원하는 값을 설정하세요. -### `openai` v3 기반 사용자 지정 HTTP 클라이언트 +### `openai` v3 기반 사용자 지정 HTTP 클라이언트 {#custom-http-clients-with-openai-v3} 버전 0.21.0에는 `openai>=3.0.0,<4`이 필요합니다. 기본 OpenAI 제공자는 HTTPX2를 사용하므로 대부분의 애플리케이션에서는 HTTP 클라이언트를 직접 구성할 필요가 없습니다. 애플리케이션에서 `AsyncOpenAI`에 `http_client=`을 전달한다면 사용자 지정 클라이언트와 전송 관련 옵션에 HTTPX2 형식을 사용하세요. @@ -96,7 +96,7 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 제공자 기본값 +## OpenAI 제공자 기본값 {#openai-provider-defaults} SDK의 OpenAI 백엔드를 사용하는 제공자는 모델 이름 문자열을 모델에 매핑할 때 SDK 전역 기본값도 읽습니다. OpenAI Responses 모델이 기본적으로 웹소켓 전송을 사용하도록 하려면 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport]을 사용하세요. @@ -128,7 +128,7 @@ set_default_openai_agent_registration( SDK 기본값이 설정되지 않은 경우 SDK의 OpenAI 백엔드를 사용하는 제공자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수로 대체합니다. 하네스 ID가 구성되어 있으면 `RunConfig.trace_metadata`에 해당 키가 이미 존재하지 않는 한 SDK가 이를 `agent_harness_id`으로 트레이스 메타데이터에 추가합니다. -## 트레이싱 +## 트레이싱 {#tracing} 트레이싱은 기본적으로 활성화됩니다. 기본적으로 위 섹션의 모델 요청과 동일한 OpenAI API 키, 즉 환경 변수 또는 설정한 기본 키를 사용합니다. [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 함수를 사용하여 트레이싱에 사용할 API 키를 별도로 설정할 수 있습니다. @@ -200,7 +200,7 @@ export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 전체 트레이싱 제어 기능은 [트레이싱 가이드](tracing.md)를 참고하세요. -## 디버그 로깅 +## 디버그 로깅 {#debug-logging} SDK는 두 개의 Python 로거(`openai.agents` 및 `openai.agents.tracing`)를 정의하며 기본적으로 핸들러를 연결하지 않습니다. 로그는 애플리케이션의 Python 로깅 구성을 따릅니다. @@ -231,7 +231,7 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 로그와 진단 정보의 민감한 데이터 +### 로그와 진단 정보의 민감한 데이터 {#sensitive-data-in-logs-and-diagnostics} 일부 로그와 진단 예외에는 민감한 데이터(예: 모델 또는 도구 입력과 출력)가 포함될 수 있습니다. diff --git a/docs/ko/context.md b/docs/ko/context.md index 398329a491..98cd4a4173 100644 --- a/docs/ko/context.md +++ b/docs/ko/context.md @@ -9,7 +9,7 @@ search: 1. 코드에서 로컬로 사용할 수 있는 컨텍스트: 도구 함수가 실행될 때, `on_handoff` 같은 콜백이나 수명 주기 훅 등에서 필요할 수 있는 데이터와 종속성입니다. 2. LLM에서 사용할 수 있는 컨텍스트: 응답을 생성할 때 LLM이 확인하는 데이터입니다. -## 로컬 컨텍스트 +## 로컬 컨텍스트 {#local-context} 이는 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 클래스와 그 안의 [`context`][agents.run_context.RunContextWrapper.context] 속성으로 표현됩니다. 작동 방식은 다음과 같습니다. @@ -33,7 +33,7 @@ search: 단일 실행 내에서 파생된 래퍼는 동일한 기본 애플리케이션 컨텍스트, 승인 상태, 사용량 추적을 공유합니다. 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에는 다른 `tool_input`가 연결될 수 있지만, 기본적으로 애플리케이션 상태의 격리된 사본이 제공되지는 않습니다. -### `RunContextWrapper`에서 제공되는 항목 +### `RunContextWrapper`에서 제공되는 항목 {#what-runcontextwrapper-exposes} [`RunContextWrapper`][agents.run_context.RunContextWrapper]는 애플리케이션에서 정의한 컨텍스트 객체의 래퍼입니다. 실제로는 다음 항목을 가장 자주 사용합니다. @@ -94,7 +94,7 @@ if __name__ == "__main__": --- -### 고급: `ToolContext` +### 고급: `ToolContext` {#advanced-toolcontext} 경우에 따라 실행 중인 도구의 이름, 호출 ID 또는 가공되지 않은 인수 문자열 같은 추가 메타데이터에 액세스해야 할 수 있습니다. 이를 위해 `RunContextWrapper`를 확장한 [`ToolContext`][agents.tool_context.ToolContext] 클래스를 사용할 수 있습니다. @@ -140,7 +140,7 @@ agent = Agent( --- -## 에이전트/LLM 컨텍스트 +## 에이전트/LLM 컨텍스트 {#agentllm-context} LLM이 호출될 때 확인할 수 있는 데이터는 대화 기록에 있는 데이터**뿐**입니다. 따라서 LLM이 새로운 데이터를 사용할 수 있게 하려면 해당 데이터가 대화 기록에 포함되도록 해야 합니다. 이를 수행하는 방법은 몇 가지가 있습니다. diff --git a/docs/ko/examples.md b/docs/ko/examples.md index 80562b8d45..eab871e339 100644 --- a/docs/ko/examples.md +++ b/docs/ko/examples.md @@ -6,7 +6,7 @@ search: [저장소](https://github.com/openai/openai-agents-python/tree/main/examples)의 examples 섹션에서 SDK를 사용하는 다양한 샘플 구현을 확인해 보세요. 예제는 서로 다른 패턴과 기능을 보여 주는 여러 카테고리로 구성되어 있습니다. -## 카테고리 +## 카테고리 {#categories} - **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** 이 카테고리의 예제는 다음과 같은 일반적인 에이전트 설계 패턴을 보여 줍니다. diff --git a/docs/ko/guardrails.md b/docs/ko/guardrails.md index 19ef96abc1..d71a4c8a93 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -11,7 +11,7 @@ search: 1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다 2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다 -## 워크플로 경계 +## 워크플로 경계 {#workflow-boundaries} 가드레일은 에이전트와 도구에 연결되지만, 워크플로에서 모두 같은 시점에 실행되는 것은 아닙니다. @@ -21,7 +21,7 @@ search: 매니저, 핸드오프 또는 위임된 전문 에이전트가 포함된 워크플로에서 각 사용자 정의 함수 도구 호출 전후에 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. -## 입력 가드레일 +## 입력 가드레일 {#input-guardrails} 입력 가드레일은 다음 3단계로 실행됩니다. @@ -33,7 +33,7 @@ search: 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 그렇다면 왜 `guardrails` 속성을 `Runner.run`에 전달하지 않고 에이전트에 지정하는지 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. -### 실행 모드 +### 실행 모드 {#execution-modes} 입력 가드레일은 두 가지 실행 모드를 지원합니다. @@ -41,7 +41,7 @@ search: - **차단 실행** (`run_in_parallel=False`): 가드레일이 에이전트보다 *먼저* 실행되어 완료됩니다. 가드레일 트립와이어가 작동하면 에이전트가 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출로 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. -## 출력 가드레일 +## 출력 가드레일 {#output-guardrails} 출력 가드레일은 다음 3단계로 실행됩니다. @@ -59,7 +59,7 @@ search: 터미널 함수 도구 출력은 에이전트 수준 출력 가드레일이 값을 검사하기 전에 도구가 이미 실행되었으므로 추가 처리가 필요합니다. [`Agent.tool_use_behavior`][agents.agent.Agent.tool_use_behavior]에 따라 해당 도구 결과가 최종 출력이 되고 출력 트립와이어가 이를 거부하는 경우, SDK는 검증된 필드로 함수 호출/출력 쌍을 다시 구성할 수 있을 때만 재현 가능한 유효한 쌍을 유지합니다. 유지되는 `function_call_output` 페이로드는 고정 텍스트 `"Output withheld by an output guardrail."`로 대체됩니다. 원래 도구 출력 페이로드는 세션, `RunState`, 스트리밍 결과 상태 또는 샌드박스 메모리 입력에 유지되지 않습니다. SDK는 함수 인수를 포함하여 재현에 필요한 검증된 함수 호출 메타데이터를 유지하므로, 해당 메타데이터에는 거부된 출력에도 나타난 데이터가 포함될 수 있습니다. 현재 응답의 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 객체도 `agent_output`을 고정 텍스트로 대체하고 `output_info`을 비웁니다. 현재 응답의 [`ToolOutputGuardrailResult`][agents.tool_guardrails.ToolOutputGuardrailResult] 객체는 허용/거부 동작 유형을 유지하지만, 페이로드를 포함하는 `output_info`과 거부 메시지를 동일한 텍스트로 대체합니다. 이전에 수락된 턴과 가드레일 결과는 변경되지 않습니다. 응답에 추론 또는 SDK가 안전하게 정리할 수 없는 다른 형식이 포함된 경우, SDK는 거부된 출력 페이로드를 유지하는 대신 현재 응답의 전체 후행 부분을 폐기합니다. 예외를 발생시킨 가드레일 함수는 거부 판정을 반환하지 않은 것이므로, 완료된 터미널 도구 턴에는 위에서 설명한 예외 저장 동작이 적용됩니다. -## 도구 가드레일 +## 도구 가드레일 {#tool-guardrails} 도구 가드레일은 **`FunctionTool` 인스턴스**를 래핑하며, 해당 도구의 실행 전후에 호출을 검증하거나 차단할 수 있게 합니다. 도구 자체에 구성되며 해당 도구가 호출될 때마다 실행됩니다. @@ -70,7 +70,7 @@ search: 자세한 내용은 아래 코드 스니펫을 참조하세요. -## 트립와이어 +## 트립와이어 {#tripwires} 에이전트 입력이나 출력이 가드레일을 통과하지 못하면 가드레일은 트립와이어로 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 이에 대응하는 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. @@ -78,7 +78,7 @@ search: 반면 도구 트립와이어 예외는 트립와이어를 작동시킨 `guardrail`와 `output`을 직접 노출합니다. 해당 예외의 `run_data.tool_input_guardrail_results` 및 `run_data.tool_output_guardrail_results` 목록에는 실패 전에 완료된 턴에서 누적된 결과가 유지되며, 트립와이어를 작동시킨 결과는 예외의 `output`를 통해 확인할 수 있습니다. `MaxTurnsExceeded`과 같이 러너가 관리하는 다른 실패도 완료된 도구 가드레일 결과를 이 목록에 유지합니다. `stream_events()`에서 예외가 발생한 후 스트리밍 결과는 동일하게 누적된 에이전트 및 도구 가드레일 결과 목록을 노출합니다. 러너가 관리하는 실행 경로 외부에서 예외가 발생하면 `run_data`는 `None`일 수 있습니다. -## 가드레일 구현 +## 가드레일 구현 {#implementing-a-guardrail} 입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행하여 이를 구현합니다. diff --git a/docs/ko/handoffs.md b/docs/ko/handoffs.md index 7e55bcb173..95eac022af 100644 --- a/docs/ko/handoffs.md +++ b/docs/ko/handoffs.md @@ -8,7 +8,7 @@ search: 핸드오프는 LLM에 도구로 표시됩니다. 따라서 `Refund Agent`라는 에이전트로 핸드오프하는 경우 도구 이름은 `transfer_to_refund_agent`이 됩니다. -## 핸드오프 생성 +## 핸드오프 생성 {#creating-a-handoff} 모든 에이전트에는 [`handoffs`][agents.agent.Agent.handoffs] 매개변수가 있으며, `Agent`를 직접 받거나 핸드오프를 사용자 지정하는 `Handoff` 객체를 받을 수 있습니다. @@ -16,7 +16,7 @@ search: Agents SDK에서 제공하는 [`handoff()`][agents.handoffs.handoff] 함수를 사용하여 핸드오프를 생성할 수 있습니다. 이 함수를 사용하면 선택적 재정의 및 입력 필터와 함께 핸드오프할 에이전트를 지정할 수 있습니다. -### 기본 사용법 +### 기본 사용법 {#basic-usage} 다음과 같이 간단한 핸드오프를 생성할 수 있습니다. @@ -32,7 +32,7 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun 1. 에이전트를 직접 사용하거나(`billing_agent`에서처럼) `handoff()` 함수를 사용할 수 있습니다. -### `handoff()` 함수를 통한 핸드오프 사용자 지정 +### `handoff()` 함수를 통한 핸드오프 사용자 지정 {#customizing-handoffs-via-the-handoff-function} [`handoff()`][agents.handoffs.handoff] 함수를 사용하면 여러 항목을 사용자 지정할 수 있습니다. @@ -63,7 +63,7 @@ handoff_obj = handoff( ) ``` -## 핸드오프 입력 +## 핸드오프 입력 {#handoff-inputs} 특정 상황에서는 LLM이 핸드오프를 호출할 때 일부 데이터를 제공하도록 해야 할 수 있습니다. 예를 들어 "에스컬레이션 에이전트"로 핸드오프한다고 가정해 보겠습니다. 모델이 이유를 제공하도록 하여 이를 기록할 수 있습니다. @@ -93,7 +93,7 @@ handoff_obj = handoff( `input_type` 항목은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]와도 별개입니다. 이미 로컬에 있는 애플리케이션 상태나 종속성이 아니라, 핸드오프 시점에 모델이 결정하는 메타데이터에 `input_type`을 사용합니다. -### `input_type` 사용 시점 +### `input_type` 사용 시점 {#when-to-use-input_type} 핸드오프에 `reason`, `language`, `priority`, `summary` 같은 소량의 모델 생성 메타데이터가 필요한 경우 `input_type`을 사용합니다. 예를 들어 분류 에이전트는 `{ "reason": "duplicate_charge", "priority": "high" }`와 함께 환불 에이전트로 핸드오프할 수 있으며, 환불 에이전트가 작업을 넘겨받기 전에 `on_handoff`에서 해당 메타데이터를 기록하거나 저장할 수 있습니다. @@ -104,7 +104,7 @@ handoff_obj = handoff( - 가능한 전문 에이전트가 여러 개라면 대상마다 하나의 핸드오프를 등록합니다. `input_type`을 사용하면 선택된 핸드오프에 메타데이터를 추가할 수 있지만 대상 간 디스패치를 수행하지는 않습니다. - 대화를 이전하지 않고 중첩된 전문 에이전트에 구조화된 입력을 제공하려면 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]을 사용하는 것이 좋습니다. [도구](tools.md#structured-input-for-tool-agents)를 참조하세요. -## 입력 필터 +## 입력 필터 {#input-filters} 핸드오프가 발생하면 새 에이전트가 대화를 넘겨받아 이전의 전체 대화 히스토리를 확인하는 것과 같습니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]을 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받고 새로운 `HandoffInputData`를 반환해야 하는 함수입니다. @@ -140,7 +140,7 @@ handoff_obj = handoff( 1. `FAQ agent` 호출 시 히스토리에서 모든 도구 관련 항목을 자동으로 제거합니다. -## 권장 프롬프트 +## 권장 프롬프트 {#recommended-prompts} LLM이 핸드오프를 올바르게 이해하도록 하려면 에이전트에 핸드오프 관련 정보를 포함하는 것이 좋습니다. [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][]에 권장 접두사가 있으며, [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][]을 호출하여 프롬프트에 권장 데이터를 자동으로 추가할 수도 있습니다. diff --git a/docs/ko/human_in_the_loop.md b/docs/ko/human_in_the_loop.md index 9eb6a65692..fea74bf37c 100644 --- a/docs/ko/human_in_the_loop.md +++ b/docs/ko/human_in_the_loop.md @@ -12,7 +12,7 @@ search: 이 페이지에서는 `interruptions`를 통한 수동 승인 흐름을 중점적으로 설명합니다. 애플리케이션이 코드에서 결정을 내릴 수 있다면 일부 도구 유형은 프로그래밍 방식의 승인 콜백도 지원하므로 실행을 일시 중지하지 않고 계속할 수 있습니다. -## 승인이 필요한 도구 표시 +## 승인이 필요한 도구 표시 {#marking-tools-that-need-approval} 항상 승인을 요구하려면 `needs_approval`을 `True`로 설정하고, 호출별로 결정하려면 비동기 함수를 제공합니다. 이 호출 가능 객체는 실행 컨텍스트, 파싱된 도구 매개변수, 도구 호출 ID를 받습니다. @@ -46,7 +46,7 @@ agent = Agent( `needs_approval`은 [`function_tool`][agents.tool.function_tool], [`Agent.as_tool`][agents.agent.Agent.as_tool], [`ShellTool`][agents.tool.ShellTool], [`ApplyPatchTool`][agents.tool.ApplyPatchTool]에서 사용할 수 있습니다. 로컬 MCP 서버도 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]의 `require_approval`을 통해 승인을 지원합니다. 호스티드 MCP 서버는 [`HostedMCPTool`][agents.tool.HostedMCPTool]에서 `tool_config={"require_approval": "always"}` 및 선택적인 `on_approval_request` 콜백을 통해 승인을 지원합니다. 셸 및 apply_patch 도구에서는 인터럽션(중단 처리)을 노출하지 않고 자동으로 승인하거나 거부하려는 경우 `on_approval` 콜백을 사용할 수 있습니다. -## 승인 흐름의 작동 방식 +## 승인 흐름의 작동 방식 {#how-the-approval-flow-works} 1. 모델이 도구 호출을 생성하면 Runner가 해당 승인 규칙(`needs_approval`, `require_approval` 또는 이에 해당하는 호스티드 MCP 규칙)을 평가합니다. 2. 해당 도구 호출에 관한 승인 결정이 이미 [`RunContextWrapper`][agents.run_context.RunContextWrapper]에 저장되어 있으면 Runner는 확인을 요청하지 않고 진행합니다. 호출별 승인은 특정 호출 ID에만 적용됩니다. 실행의 나머지 기간에 동일한 도구 ID를 사용하는 향후 호출에도 같은 결정을 유지하려면 `always_approve=True` 또는 `always_reject=True`을 전달합니다. @@ -60,7 +60,7 @@ agent = Agent( 대기 중인 모든 승인을 한 번에 처리할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 함께 포함될 수 있습니다. 일부 항목만 승인하거나 거부한 후 다시 실행하면 처리된 호출은 계속 진행되고, 미처리된 호출은 `interruptions`에 남아 실행을 다시 일시 중지합니다. -## 사용자 지정 거부 메시지 +## 사용자 지정 거부 메시지 {#custom-rejection-messages} 기본적으로 거부된 도구 호출은 SDK의 표준 거부 텍스트를 실행에 반환합니다. 이 메시지는 두 계층에서 사용자 지정할 수 있습니다. @@ -90,7 +90,7 @@ state.reject( 두 계층을 함께 사용하는 전체 예제는 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)을 참조하세요. -## 자동 승인 결정 +## 자동 승인 결정 {#automatic-approval-decisions} 수동 `interruptions`이 가장 일반적인 패턴이지만 유일한 방식은 아닙니다. @@ -100,13 +100,13 @@ state.reject( 이러한 콜백이 결정을 반환하면 사람의 응답을 기다리기 위해 일시 중지하지 않고 실행이 계속됩니다. Realtime 및 음성 세션 API는 [Realtime 가이드](realtime/guide.md)의 승인 흐름을 참조하세요. -## 스트리밍 및 세션 +## 스트리밍 및 세션 {#streaming-and-sessions} 동일한 인터럽션(중단 처리) 흐름이 스트리밍 실행에서도 작동합니다. 스트리밍된 실행이 일시 중지된 후 반복자가 끝날 때까지 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events]을 계속 소비하고, [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]을 검사하여 처리한 다음, 재개된 출력에서도 스트리밍을 유지하려면 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed]으로 재개합니다. 이 패턴의 스트리밍 버전은 [스트리밍](streaming.md)을 참조하세요. 세션도 사용 중이라면 `RunState`에서 재개할 때 동일한 세션 인스턴스를 계속 전달하거나, 동일한 세션 ID 및 백업 스토어를 사용하도록 구성된 다른 세션 객체를 전달합니다. 그러면 재개된 턴이 동일하게 저장된 대화 기록에 추가됩니다. 세션 수명 주기에 관한 자세한 내용은 [세션](sessions/index.md)을 참조하세요. -## 예제: 일시 중지, 승인, 재개 +## 예제: 일시 중지, 승인, 재개 {#example-pause-approve-resume} 아래 코드 조각은 JavaScript HITL 가이드와 동일한 흐름을 보여 줍니다. 도구에 승인이 필요하면 일시 중지하고, 상태를 디스크에 저장하고, 다시 로드한 후 결정을 수집하여 재개합니다. @@ -177,7 +177,7 @@ if __name__ == "__main__": 승인을 위해 일시 중지될 수 있는 실행에서 스트리밍을 사용하려면 `Runner.run_streamed`을 호출하고 완료될 때까지 `result.stream_events()`을 소비한 다음, 위에 나온 것과 동일한 `result.to_state()` 및 재개 단계를 따릅니다. -## 저장소 패턴 및 코드 예제 +## 저장소 패턴 및 코드 예제 {#repository-patterns-and-examples} - **스트리밍 승인**: `examples/agent_patterns/human_in_the_loop_stream.py`은 `stream_events()`을 모두 소비한 다음, `Runner.run_streamed(agent, state)`으로 재개하기 전에 대기 중인 도구 호출을 승인하는 방법을 보여 줍니다. - **사용자 지정 거부 텍스트**: `examples/agent_patterns/human_in_the_loop_custom_rejection.py`은 승인이 거부될 때 실행 수준 `tool_error_formatter`과 호출별 `rejection_message` 재정의를 결합하는 방법을 보여 줍니다. @@ -188,7 +188,7 @@ if __name__ == "__main__": - **세션 및 메모리**: 승인과 대화 기록이 여러 턴에 걸쳐 유지되도록 `Runner.run`에 세션을 전달합니다. SQLite 및 OpenAI Conversations 세션 변형은 `examples/memory/memory_session_hitl_example.py` 및 `examples/memory/openai_session_hitl_example.py`에 있습니다. - **실시간 에이전트**: 실시간 데모는 `RealtimeSession`에서 `approve_tool_call` / `reject_tool_call`을 통해 도구 호출을 승인하거나 거부하는 WebSocket 메시지를 제공합니다. 서버 측 핸들러는 `examples/realtime/app/server.py`을, API 인터페이스는 [Realtime 가이드](realtime/guide.md#tool-approvals)를 참조하세요. -## 장기 실행 승인 +## 장기 실행 승인 {#long-running-approvals} `RunState`은 지속 가능하도록 설계되었습니다. `state.to_json()` 또는 `state.to_string()`을 사용하여 대기 중인 작업을 데이터베이스나 큐에 저장하고, 나중에 `RunState.from_json(...)` 또는 `RunState.from_string(...)`으로 다시 생성합니다. @@ -202,6 +202,6 @@ if __name__ == "__main__": 직렬화된 실행 상태에는 애플리케이션 컨텍스트와 함께 승인, 사용량, 직렬화된 `tool_input`, 중첩된 도구로서의 에이전트 실행 재개, 트레이스 메타데이터, 서버 관리형 대화 설정 등 SDK가 관리하는 런타임 메타데이터가 포함됩니다. 직렬화된 상태를 저장하거나 전송하려는 경우 `RunContextWrapper.context`를 영구 데이터로 취급하고, 상태와 함께 이동하도록 의도한 경우가 아니라면 여기에 비밀 정보를 넣지 마세요. -## 대기 중인 작업의 버전 관리 +## 대기 중인 작업의 버전 관리 {#versioning-pending-tasks} 승인이 장시간 대기할 수 있다면 직렬화된 상태와 함께 에이전트 정의 또는 SDK의 버전 표시를 저장합니다. 그러면 역직렬화 시 일치하는 코드 경로로 라우팅하여 모델, 프롬프트 또는 도구 정의가 변경될 때 발생하는 비호환성을 방지할 수 있습니다. \ No newline at end of file diff --git a/docs/ko/index.md b/docs/ko/index.md index 6acb6674fe..d165afb5e9 100644 --- a/docs/ko/index.md +++ b/docs/ko/index.md @@ -12,7 +12,7 @@ search: 이러한 기본 구성 요소를 Python과 함께 사용하면 도구와 에이전트 간의 복잡한 관계를 표현할 수 있으며, 가파른 학습 곡선 없이 실제 애플리케이션을 구축할 수 있습니다. 또한 SDK에는 에이전트 기반 흐름을 시각화하고 디버깅할 뿐만 아니라 평가하고 애플리케이션에 맞게 모델을 파인튜닝할 수도 있는 **트레이싱** 기능이 내장되어 있습니다. -## Agents SDK를 사용하는 이유 +## Agents SDK를 사용하는 이유 {#why-use-the-agents-sdk} SDK는 다음 두 가지 설계 원칙을 따릅니다. @@ -34,7 +34,7 @@ SDK의 주요 기능은 다음과 같습니다. - **휴먼인더루프 (HITL)**: 에이전트 실행 중 사람이 참여할 수 있도록 하는 내장 메커니즘입니다. - **트레이싱**: 워크플로를 시각화하고 디버깅하며 모니터링하기 위한 내장 트레이싱 기능으로, OpenAI의 평가, 파인튜닝, 증류 도구 모음을 지원합니다. -## Agents SDK와 Responses API의 선택 +## Agents SDK와 Responses API의 선택 {#agents-sdk-or-responses-api} SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 모델 호출을 더 높은 수준의 런타임으로 래핑합니다. @@ -51,13 +51,13 @@ SDK는 OpenAI 모델에 기본적으로 Responses API를 사용하지만, 모델 전체 애플리케이션에서 하나만 선택할 필요는 없습니다. 많은 애플리케이션이 관리형 워크플로에는 SDK를 사용하고, 저수준 경로에는 Responses API를 직접 호출합니다. -## 설치 +## 설치 {#installation} ```bash pip install openai-agents ``` -## Hello world 예제 +## Hello world 예제 {#hello-world-example} ```python from agents import Agent, Runner @@ -78,14 +78,14 @@ print(result.final_output) export OPENAI_API_KEY=sk-... ``` -## 시작 안내 +## 시작 안내 {#start-here} - [빠른 시작](quickstart.md)에서 첫 번째 텍스트 기반 에이전트를 구축합니다. - 그런 다음 [에이전트 실행](running_agents.md#choose-a-memory-strategy)에서 턴 간 상태를 유지할 방법을 결정합니다. - 작업이 실제 파일, 리포지토리 또는 에이전트별로 격리된 워크스페이스 상태에 의존한다면 [샌드박스 에이전트 빠른 시작](sandbox_agents.md)을 읽어 보세요. - 핸드오프와 관리자 스타일 오케스트레이션 중 하나를 선택하려면 [에이전트 오케스트레이션](multi_agent.md)을 읽어 보세요. -## 경로 선택 +## 경로 선택 {#choose-your-path} 수행하려는 작업은 알지만 어느 페이지에서 설명하는지 모를 때 이 표를 사용하세요. diff --git a/docs/ko/mcp.md b/docs/ko/mcp.md index cba5938c4c..57b118676c 100644 --- a/docs/ko/mcp.md +++ b/docs/ko/mcp.md @@ -17,7 +17,7 @@ Agents Python SDK는 여러 MCP 전송 방식을 지원합니다. 따라서 기 MCP 도구는 모델 컨텍스트의 데이터를 노출하고 제공된 자격 증명으로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 자격 증명을 사용하며, 액세스 토큰을 URL이 아닌 인증 필드나 헤더에 보관하고, 민감한 작업에는 승인을 요구해야 합니다. [OpenAI MCP 보안 지침](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)을 참고하세요. -## MCP 통합 선택 +## MCP 통합 선택 {#choosing-an-mcp-integration} MCP 서버를 에이전트에 연결하기 전에 도구 호출을 실행할 위치와 접근 가능한 전송 방식을 결정해야 합니다. 아래 표는 Python SDK가 지원하는 옵션을 요약합니다. @@ -30,7 +30,7 @@ MCP 서버를 에이전트에 연결하기 전에 도구 호출을 실행할 위 아래 섹션에서는 각 옵션의 구성 방법과 특정 전송 방식을 다른 방식보다 우선해야 하는 경우를 설명합니다. -## MCP Python SDK v1 및 v2 +## MCP Python SDK v1 및 v2 {#mcp-python-sdk-v1-and-v2} Agents SDK는 `mcp>=1.19.0,<3` 종속성 범위를 통해 `mcp` Python 패키지의 두 주요 버전을 모두 지원합니다. 설치된 `mcp` 패키지 버전은 서버와 협상하는 MCP 프로토콜 버전과 별개입니다. Agents SDK는 설치된 패키지의 메이저 버전을 감지하고 stdio, SSE, Streamable HTTP 연결을 자동으로 조정하므로 일반적인 서버 구성에는 버전 전환 설정이 필요하지 않습니다. @@ -58,7 +58,7 @@ HTTP 전송 방식의 사용자 정의에는 설치된 MCP 패키지가 소유 이러한 로컬 `mcp` 종속성 요구 사항은 원격 MCP 연결을 OpenAI Responses API가 관리하는 [`HostedMCPTool`][agents.tool.HostedMCPTool]에는 적용되지 않습니다. -## 에이전트 수준 MCP 구성 +## 에이전트 수준 MCP 구성 {#agent-level-mcp-configuration} 전송 방식을 선택하는 것 외에도 `Agent.mcp_config`을 설정하여 MCP 도구의 준비 방식을 조정할 수 있습니다. @@ -88,7 +88,7 @@ agent = Agent( - 서버 수준의 `failure_error_function`은 해당 서버에 대해 `Agent.mcp_config["failure_error_function"]`을 재정의합니다. - `include_server_in_tool_names`은 선택적으로 활성화해야 합니다. 활성화하면 각 로컬 MCP 도구가 결정론적으로 생성된 서버 접두사 이름으로 모델에 노출되므로 여러 MCP 서버가 같은 이름의 도구를 게시할 때 충돌을 방지하는 데 도움이 됩니다. 생성된 이름은 ASCII에 안전하고 `FunctionTool` 인스턴스의 이름 길이 제한을 준수하며, 같은 에이전트에 구성된 로컬 `FunctionTool` 인스턴스의 이름이나 활성화된 핸드오프와 충돌하지 않습니다. SDK는 계속해서 원래 서버에서 원래 MCP 도구 이름을 호출합니다. -## 전송 방식 공통 패턴 +## 전송 방식 공통 패턴 {#shared-patterns-across-transports} 전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 결정을 내려야 합니다. @@ -99,11 +99,11 @@ agent = Agent( 로컬 MCP 서버(`MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`)에서는 승인 정책과 호출별 `_meta` 페이로드도 공통 개념입니다. Streamable HTTP 섹션에서 가장 완전한 예제를 제공하며, 동일한 패턴이 다른 로컬 전송 방식에도 적용됩니다. -## 1. 호스티드 MCP 서버 도구 +## 1. 호스티드 MCP 서버 도구 {#1-hosted-mcp-server-tools} 호스티드 툴은 전체 도구 왕복 과정을 OpenAI 인프라에서 처리합니다. 코드에서 도구 목록을 조회하고 호출하는 대신 [`HostedMCPTool`][agents.tool.HostedMCPTool]이 서버 레이블과 선택적 커넥터 메타데이터를 Responses API에 전달합니다. 모델은 Python 프로세스에 추가 콜백을 보내지 않고 원격 서버의 도구 목록을 조회하고 호출합니다. 현재 호스티드 툴은 Responses API의 호스티드 MCP 통합을 지원하는 OpenAI 모델에서 작동합니다. -### 기본 호스티드 MCP 도구 +### 기본 호스티드 MCP 도구 {#basic-hosted-mcp-tool} 에이전트의 `tools` 목록에 [`HostedMCPTool`][agents.tool.HostedMCPTool]을 추가하여 호스티드 툴을 생성합니다. `tool_config` 딕셔너리는 REST API로 전송할 JSON과 동일한 구조를 사용합니다. @@ -142,7 +142,7 @@ asyncio.run(main()) 호스티드 도구 검색에서 호스티드 MCP 서버를 지연 로드하려면 `tool_config["defer_loading"] = True`을 설정하고 [`ToolSearchTool`][agents.tool.ToolSearchTool]을 에이전트에 추가하세요. 이 기능은 OpenAI Responses 모델에서만 지원됩니다. 전체 도구 검색 구성과 제약 조건은 [도구](tools.md#hosted-tool-search)를 참고하세요. -### 호스티드 MCP 결과 스트리밍 +### 호스티드 MCP 결과 스트리밍 {#streaming-hosted-mcp-results} 호스티드 툴은 함수 도구와 정확히 같은 방식으로 결과 스트리밍을 지원합니다. 모델이 계속 작업하는 동안 증분 MCP 출력을 사용하려면 `Runner.run_streamed`을 사용하세요. @@ -155,7 +155,7 @@ async for event in result.stream_events(): print(result.final_output) ``` -### 선택적 승인 흐름 +### 선택적 승인 흐름 {#optional-approval-flows} 서버가 민감한 작업을 수행할 수 있다면 각 도구를 실행하기 전에 사람 또는 프로그램의 승인을 요구할 수 있습니다. `tool_config`의 `require_approval`을 단일 정책(`"always"`, `"never"`) 또는 도구 이름을 정책에 매핑하는 딕셔너리로 구성하세요. Python에서 결정을 내리려면 `on_approval_request` 콜백을 제공하세요. @@ -187,7 +187,7 @@ agent = Agent( 콜백은 동기식 또는 비동기식일 수 있으며, 모델이 실행을 계속하기 위해 승인 데이터가 필요할 때마다 호출됩니다. -### 커넥터 기반 호스티드 서버 +### 커넥터 기반 호스티드 서버 {#connector-backed-hosted-servers} 호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`와 액세스 토큰을 제공하세요. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 노출합니다. @@ -207,7 +207,7 @@ HostedMCPTool( 스트리밍, 승인, 커넥터를 포함하여 완전히 작동하는 호스티드 툴 샘플은 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)에서 확인할 수 있습니다. -## 2. Streamable HTTP MCP 서버 +## 2. Streamable HTTP MCP 서버 {#2-streamable-http-mcp-servers} 네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 사용하세요. Streamable HTTP 서버는 전송 방식을 직접 제어하거나, 짧은 지연 시간을 유지하면서 자체 인프라 내에서 서버를 실행하려는 경우에 적합합니다. @@ -254,7 +254,7 @@ asyncio.run(main()) - `failure_error_function`은 모델에 표시되는 MCP 도구 실패 메시지를 사용자 정의합니다. 대신 오류를 발생시키려면 `None`로 설정하세요. - `tool_meta_resolver`은 `call_tool()` 전에 호출별 MCP `_meta` 페이로드를 삽입합니다. -### 로컬 MCP 서버의 승인 정책 +### 로컬 MCP 서버의 승인 정책 {#approval-policies-for-local-mcp-servers} `MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp`은 모두 `require_approval`을 지원합니다. @@ -276,7 +276,7 @@ async with MCPServerStreamableHttp( 전체 일시 중지/재개 흐름은 [휴먼인더루프](human_in_the_loop.md)와 `examples/mcp/get_all_mcp_tools_example/main.py`을 참고하세요. -### `tool_meta_resolver`을 사용한 호출별 메타데이터 +### `tool_meta_resolver`을 사용한 호출별 메타데이터 {#per-call-metadata-with-tool_meta_resolver} MCP 서버가 `_meta`에서 요청 메타데이터(예: 테넌트 ID 또는 트레이스 컨텍스트)를 기대하는 경우 `tool_meta_resolver`을 사용하세요. 아래 예제에서는 `dict`을 `Runner.run(...)`의 `context`으로 전달한다고 가정합니다. @@ -301,11 +301,11 @@ server = MCPServerStreamableHttp( 실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 정의 클래스라면 속성 접근 방식으로 테넌트 ID를 읽으세요. -### MCP 도구 출력: 텍스트, 이미지 및 기타 콘텐츠 +### MCP 도구 출력: 텍스트, 이미지 및 기타 콘텐츠 {#mcp-tool-outputs-text-images-and-other-content} MCP 결과가 콘텐츠 블록을 사용하면 SDK는 텍스트 콘텐츠를 텍스트 출력으로 전달하고 이미지 콘텐츠를 도구 출력의 이미지 타입 항목으로 매핑합니다. 오디오 및 리소스 블록을 비롯한 다른 MCP 콘텐츠 블록 타입의 경우 SDK는 해당 블록을 유효한 JSON으로 직렬화한 값을 텍스트 출력으로 전달합니다. 여러 콘텐츠 블록이 포함된 응답은 출력 항목 목록으로 전달됩니다. `use_structured_content=True`이 비어 있지 않고 오류가 없는 `structuredContent` 페이로드를 선택하면 해당 structured payload가 이러한 콘텐츠 블록보다 우선합니다. structured content가 누락되었거나 비어 있으면 콘텐츠 블록으로 폴백합니다. -## 3. SSE 기반 HTTP MCP 서버 +## 3. SSE 기반 HTTP MCP 서버 {#3-http-with-sse-mcp-servers} !!! warning @@ -338,7 +338,7 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP 서버 +## 4. stdio MCP 서버 {#4-stdio-mcp-servers} 로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]을 사용하세요. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며 컨텍스트 관리자가 종료될 때 자동으로 닫습니다. 이 옵션은 빠르게 개념 증명을 만들거나 서버가 명령줄 진입점만 노출하는 경우에 유용합니다. @@ -366,7 +366,7 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP 서버 관리자 +## 5. MCP 서버 관리자 {#5-mcp-server-manager} MCP 서버가 여러 개라면 `MCPServerManager`을 사용하여 미리 연결하고, 연결에 성공한 서버만 에이전트에 노출하세요. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참고하세요. @@ -398,15 +398,15 @@ async with MCPServerManager(servers) as manager: - `connect_all()`, `reconnect()`, `cleanup_all()` 호출은 직렬화됩니다. 수명 주기 작업이 이미 실행 중이라면 다른 수명 주기 작업은 같은 서버에 동시에 연결하거나 정리하지 않고 기존 작업이 끝날 때까지 기다립니다. - 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정하세요. 두 수명 주기 타임아웃의 기본값은 10초입니다. 양의 유한한 초 단위 값 또는 비활성화를 위한 `None`을 지원하며, 생성 시와 할당 시 모두 검증됩니다. 0은 즉시 기한 만료를 발생시키므로 거부됩니다. -## 공통 서버 기능 +## 공통 서버 기능 {#common-server-capabilities} 아래 섹션은 MCP 서버 전송 방식 전반에 적용됩니다. 정확한 API 인터페이스는 서버 클래스에 따라 달라집니다. -## 도구 필터링 +## 도구 필터링 {#tool-filtering} 각 MCP 서버는 에이전트에 필요한 함수만 노출할 수 있도록 도구 필터를 지원합니다. 필터링은 생성 시점에 수행하거나 실행별로 동적으로 수행할 수 있습니다. -### 정적 도구 필터링 +### 정적 도구 필터링 {#static-tool-filtering} 간단한 허용/차단 목록을 구성하려면 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter]을 사용하세요. @@ -428,7 +428,7 @@ filesystem_server = MCPServerStdio( `allowed_tool_names`과 `blocked_tool_names`이 모두 제공되면 SDK는 먼저 허용 목록을 적용한 후 남은 집합에서 차단된 도구를 제거합니다. -### 동적 도구 필터링 +### 동적 도구 필터링 {#dynamic-tool-filtering} 더 정교한 로직을 구현하려면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]을 받는 호출 가능 객체를 전달하세요. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며, 도구를 노출해야 하는 경우 `True`을 반환합니다. @@ -456,7 +456,7 @@ async with MCPServerStdio( 필터 컨텍스트는 활성 `run_context`, 도구를 요청하는 `agent`, `server_name`을 노출합니다. -## 프롬프트 +## 프롬프트 {#prompts} MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 다음 두 메서드를 노출합니다. @@ -480,17 +480,17 @@ agent = Agent( ) ``` -## 페이지네이션 +## 페이지네이션 {#pagination} 기본 제공 로컬 MCP 서버 클래스는 도구와 프롬프트 목록을 조회할 때 `nextCursor`을 자동으로 따릅니다. `list_tools()`은 필터를 적용하거나 캐시를 채우기 전에 전체 도구 목록을 수집하고, `list_prompts()`은 `nextCursor=None`과 함께 하나로 결합된 결과를 반환합니다. 이후 페이지에서 오류가 발생하거나 서버가 커서를 반복하면 일부 결과를 노출하거나 캐싱하는 대신 작업에서 오류가 발생합니다. 리소스는 명시적 페이지네이션을 계속 사용합니다. 다음 페이지를 가져오려면 `list_resources()` 또는 `list_resource_templates()`에서 반환된 `nextCursor`을 `cursor` 인수로 다시 전달하세요. -## 캐싱 +## 캐싱 {#caching} 각 에이전트 실행은 모든 MCP 서버에서 `list_tools()`을 호출합니다. 원격 서버는 상당한 지연 시간을 유발할 수 있으므로 모든 MCP 서버 클래스는 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신하는 경우에만 `True`로 설정하세요. 나중에 목록을 새로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`을 호출하세요. -## 트레이싱 +## 트레이싱 {#tracing} [트레이싱](./tracing.md)은 다음을 포함한 MCP 활동을 자동으로 캡처합니다. @@ -499,7 +499,7 @@ agent = Agent( ![MCP 트레이싱 스크린샷](../assets/images/mcp-tracing.jpg) -## 추가 자료 +## 추가 자료 {#further-reading} - [Model Context Protocol](https://modelcontextprotocol.io/) – 사양 및 설계 가이드 - [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 실행 가능한 stdio, SSE, Streamable HTTP 샘플 diff --git a/docs/ko/models/index.md b/docs/ko/models/index.md index fe0aa28fe7..3d655f9cb3 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -9,7 +9,7 @@ Agents SDK는 다음 두 가지 방식으로 OpenAI 모델을 즉시 사용할 - **권장**: 새로운 [Responses API](https://platform.openai.com/docs/api-reference/responses)를 사용하여 OpenAI API를 호출하는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] - [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)를 사용하여 OpenAI API를 호출하는 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] -## 모델 설정 선택 +## 모델 설정 선택 {#choosing-a-model-setup} 먼저 설정에 맞는 가장 간단한 방식을 선택합니다. @@ -23,7 +23,7 @@ Agents SDK는 다음 두 가지 방식으로 OpenAI 모델을 즉시 사용할 | 고급 OpenAI Responses 요청 설정 조정 | OpenAI Responses 경로에서 `ModelSettings` 사용 | [고급 OpenAI Responses 설정](#advanced-openai-responses-settings) | | OpenAI 이외의 프로바이더 또는 혼합 프로바이더 라우팅에 서드 파티 어댑터 사용 | 지원되는 베타 어댑터를 비교하고 배포할 프로바이더 경로 검증 | [서드 파티 어댑터](#third-party-adapters) | -## OpenAI 모델 +## OpenAI 모델 {#openai-models} OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 프로바이더와 문자열 모델 이름을 사용하고 Responses 모델 경로를 유지하는 방식을 권장합니다. @@ -31,7 +31,7 @@ OpenAI 모델만 사용하는 대부분의 앱에서는 기본 OpenAI 프로바 `gpt-5.6-sol` 같은 다른 모델로 전환하려는 경우 두 가지 방법으로 에이전트를 구성할 수 있습니다. -### 기본 모델 +### 기본 모델 {#default-model} 먼저, 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정합니다. @@ -57,7 +57,7 @@ result = await Runner.run( ) ``` -#### GPT-5 모델 +#### GPT-5 모델 {#gpt-5-models} 이러한 방식으로 `gpt-5.6-sol` 같은 GPT-5 모델을 사용하면 SDK가 기본 `ModelSettings`을 적용합니다. 대부분의 사용 사례에 가장 적합한 설정이 사용됩니다. 기본 모델의 추론 수준을 조정하려면 자체 `ModelSettings`을 전달합니다. @@ -100,7 +100,7 @@ agent = Agent( `context="all_turns"`을 사용할 때는 `previous_response_id`, 서버 측 Responses API 대화 또는 다음 요청에 이전 추론 항목을 포함하는 방식으로 대화를 보존합니다. 상태 비저장 `store=False` 호출의 경우 응답에서 `reasoning.encrypted_content`을 요청한 다음, 다음 요청의 입력에 해당 추론 항목을 포함합니다. -#### ComputerTool 모델 선택 +#### ComputerTool 모델 선택 {#computertool-model-selection} 에이전트에 [`ComputerTool`][agents.tool.ComputerTool]이 포함된 경우 실제 Responses 요청의 최종 모델에 따라 SDK가 전송하는 컴퓨터 도구 페이로드가 결정됩니다. 명시적인 `gpt-5.5` 요청은 정식 출시된 기본 제공 `computer` 도구를 사용하고, 명시적인 `computer-use-preview` 요청은 이전 `computer_use_preview` 페이로드를 유지합니다. @@ -110,11 +110,11 @@ agent = Agent( 프리뷰 호환 요청은 `environment`과 디스플레이 크기를 미리 직렬화해야 합니다. 따라서 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리를 사용하는 프롬프트 관리 흐름에서는 구체적인 `Computer` 또는 `AsyncComputer` 인스턴스를 전달하거나, 요청을 보내기 전에 정식 출시 선택기를 강제해야 합니다. 전체 마이그레이션 세부 정보는 [도구](../tools.md#computertool-and-the-responses-computer-tool)를 참조하세요. -#### GPT-5 이외의 모델 +#### GPT-5 이외의 모델 {#non-gpt-5-models} 사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 범용 `ModelSettings`으로 되돌아갑니다. -### Responses 전용 도구 기능 +### Responses 전용 도구 기능 {#responses-only-tool-features} 다음 도구 기능은 OpenAI Responses 모델에서만 지원됩니다. @@ -125,11 +125,11 @@ agent = Agent( 이러한 기능은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. 지연 로딩 도구를 사용할 때는 에이전트에 `ToolSearchTool()`을 추가하고, 네임스페이스 이름이나 지연 로딩 전용 함수 이름을 직접 강제하는 대신 모델이 `auto` 또는 `required` 도구 선택을 통해 도구를 로드하도록 합니다. 설정 세부 정보와 현재 제약 조건은 [호스티드 툴 검색](../tools.md#hosted-tool-search) 및 [프로그래밍 방식 도구 호출](../tools.md#programmatic-tool-calling)을 참조하세요. -### Responses WebSocket 전송 +### Responses WebSocket 전송 {#responses-websocket-transport} 기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI Responses 프로바이더 경로를 사용할 때 WebSocket 전송을 사용하도록 설정할 수 있습니다. -#### 기본 설정 +#### 기본 설정 {#basic-setup} ```python from agents import set_default_openai_responses_transport @@ -141,7 +141,7 @@ set_default_openai_responses_transport("websocket") SDK가 모델 이름을 모델 인스턴스로 해석할 때 전송 방식이 선택됩니다. 구체적인 [`Model`][agents.models.interface.Model] 객체를 전달하면 해당 전송 방식은 이미 고정되어 있습니다. [`OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel]은 WebSocket을 사용하고, [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]은 HTTP를 사용하며, [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]은 Chat Completions를 유지합니다. `RunConfig(model_provider=...)`을 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식을 제어합니다. -#### 프로바이더 또는 실행 수준 설정 +#### 프로바이더 또는 실행 수준 설정 {#provider-or-run-level-setup} 프로바이더별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. @@ -188,7 +188,7 @@ result = await Runner.run( ) ``` -#### `MultiProvider`을 사용한 고급 라우팅 +#### `MultiProvider`을 사용한 고급 라우팅 {#advanced-routing-with-multiprovider} 접두사 기반 모델 라우팅이 필요한 경우(예: 하나의 실행에서 `openai/...` 및 `any-llm/...` 모델 이름 혼합) [`MultiProvider`][agents.MultiProvider]을 사용하고 그곳에 `openai_use_responses_websocket=True`을 설정합니다. @@ -229,7 +229,7 @@ result = await Runner.run( 사용자 지정 OpenAI 호환 엔드포인트 또는 프록시를 사용하는 경우 WebSocket 전송에는 호환되는 WebSocket `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. -#### 참고 사항 +#### 참고 사항 {#notes} - 이는 [Realtime API](../realtime/guide.md)가 아니라 WebSocket 전송을 사용하는 Responses API입니다. Chat Completions에는 적용되지 않습니다. OpenAI 이외의 프로바이더에는 해당 프로바이더가 Responses WebSocket `/responses` 엔드포인트를 지원하는 경우에만 적용됩니다. - 환경에 `websockets` 패키지가 아직 없으면 설치합니다. @@ -239,13 +239,13 @@ result = await Runner.run( - [Responses API WebSocket 서비스](https://developers.openai.com/api/docs/guides/websocket-mode)는 각 연결에서 한 번에 하나의 응답을 처리하며, 각 연결을 60분으로 제한합니다. 이 제한에 도달하면 새 연결을 엽니다. 병렬 실행이 필요할 때는 여러 연결을 사용합니다. - 서비스는 연결 로컬 메모리에 가장 최근 응답만 보관합니다. 실패한 `4xx` 또는 `5xx` 턴은 `previous_response_id`이 참조하는 응답을 해당 메모리에서 제거합니다. 다시 연결한 후에도 저장된 응답을 사용할 수 있으면 계속 진행할 수 있지만, `store=False` 및 ZDR 흐름에는 영구 저장된 대체 수단이 없습니다. `previous_response_id=None`로 새 체인을 시작하고 전체 입력 컨텍스트를 보내거나, 로컬에서 관리하는 세션 상태로 해당 컨텍스트를 다시 구성합니다. -### 호스티드 멀티 에이전트(실험적) +### 호스티드 멀티 에이전트(실험적) {#hosted-multi-agent-experimental} OpenAI Responses API 호스티드 멀티 에이전트 베타를 사용하면 GPT-5.6 루트 모델이 서버에서 호스트되는 하위 에이전트를 생성하고 조정할 수 있습니다. Agents SDK는 일반적인 `Runner`을 계속 사용할 수 있습니다. 호스티드 오케스트레이션은 서비스에서 유지되고, 개발자가 정의한 함수 도구는 애플리케이션에서 실행됩니다. 이 통합은 실험적이며 로컬 함수 출력을 `response.inject`을 사용하여 활성 호스티드 에이전트에 반환할 수 있도록 Responses WebSocket 전송을 사용합니다. `client.beta.responses.connect`을 제공하는 `openai[realtime]` 버전 2.45.0 이상의 빌드가 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. -#### 모델 구성 +#### 모델 구성 {#configure-the-model} 실험적 모듈에서 모델을 가져와 SDK `Agent`에 할당합니다. @@ -262,7 +262,7 @@ agent = Agent( `OpenAIHostedMultiAgentModel`을 생성하면 `multi_agent.enabled`이 활성화되고 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 헤더가 전송됩니다. `openai_client`이 제공되지 않으면 모델은 기본 OpenAI 클라이언트를 사용합니다. `max_concurrent_subagents`이 생략되면 서비스 기본값이 사용됩니다. -#### 로컬 함수 도구 +#### 로컬 함수 도구 {#local-function-tools} 모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. Responses API는 어떤 호스티드 에이전트가 함수를 호출할지 결정합니다. 일반 SDK Runner는 함수를 로컬에서 실행하고 동일한 호출 ID가 있는 `function_call_output`을 활성 WebSocket 응답에 삽입합니다. 이를 통해 서비스가 원래 호스티드 호출자를 다시 시작할 수 있습니다. 함수 실행에는 Runner의 일반 가드레일, 후크 및 실패 변환이 계속 적용됩니다. SDK 도구 승인 인터럽션(중단 처리)은 지원되지 않습니다. `needs_approval` 설정이 `False`이 아닌 함수 도구는 요청이 전송되기 전에 거부됩니다. @@ -285,13 +285,13 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: 호스티드 에이전트 이름은 관찰용 메타데이터이며 로컬 라우팅 메커니즘이 아닙니다. SDK가 제공하는 호출 ID를 사용하여 출력을 라우팅합니다. 부작용이 있는 도구의 경우 해당 호출 ID를 멱등성 키로 사용하고 도구 실행 전이나 도중에 애플리케이션 코드에서 필요한 권한 부여를 적용합니다. 이 모델에 `needs_approval`을 사용하지 마세요. 도구 인수와 출력은 Responses API 경계를 통과합니다. -#### 출력 및 스트리밍 동작 +#### 출력 및 스트리밍 동작 {#output-and-streaming-behavior} 단계가 `final_answer`이며 `/root`에 귀속된 메시지만 일반 최종 메시지가 됩니다. 실험적 어댑터는 상위 수준 `RunResult`에서 하위 에이전트 메시지와 호스티드 오케스트레이션 레코드를 필터링합니다. SDK는 이러한 레코드를 로컬 함수로 실행하지 않습니다. raw 스트리밍에서는 호스티드 출력 항목 및 `response.inject.created` 확인을 포함한 베타 Responses 이벤트가 계속 노출됩니다. 어댑터는 함수 호출이 준비되면 하나의 활성 프로바이더 응답을 SDK에 표시되는 논리적 모델 턴으로 나눈 다음, Runner가 출력을 생성하면 동일한 프로바이더 응답을 다시 시작합니다. raw 호스티드 항목 또는 `ToolContext`과 함께 `get_hosted_agent_metadata()`을 사용하여 항목이나 도구 호출이 귀속된 호스티드 에이전트를 식별합니다. -#### SDK 오케스트레이션과의 관계 +#### SDK 오케스트레이션과의 관계 {#relationship-to-sdk-orchestration} 호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. @@ -299,7 +299,7 @@ raw 스트리밍에서는 호스티드 출력 항목 및 `response.inject.create - SDK 핸드오프는 활성 로컬 SDK `Agent`을 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 핸드오프가 거부됩니다. - Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용 및 도구 노출을 신중하게 평가하세요. -#### 현재 제한 사항 +#### 현재 제한 사항 {#current-limitations} 실험적 모델은 `reasoning.summary`, `max_tool_calls`, 호출자가 제공하는 `multi_agent` 또는 `betas` 재정의를 거부합니다. Responses `/compact` 엔드포인트는 베타에서 지원되지 않습니다. 다만 서비스가 각 호스티드 에이전트 컨텍스트를 독립적으로 자동 압축하므로 명시적인 `context_management.compact_threshold`을 사용할 수 있습니다. @@ -307,11 +307,11 @@ raw 스트리밍에서는 호스티드 출력 항목 및 `response.inject.create 기본 Responses API 베타 동작은 [OpenAI 멀티 에이전트 가이드](https://developers.openai.com/api/docs/guides/tools-multi-agent)를 참조하세요. 비스트리밍 및 스트리밍 SDK 사용법은 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)을 참조하세요. -## OpenAI 이외의 모델 +## OpenAI 이외의 모델 {#non-openai-models} OpenAI 이외의 프로바이더가 필요한 경우 SDK의 기본 제공 프로바이더 통합 지점으로 시작합니다. 많은 설정에서는 서드 파티 어댑터를 추가하지 않아도 충분합니다. 각 패턴의 예제는 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에 있습니다. -### OpenAI 이외의 프로바이더 통합 방법 +### OpenAI 이외의 프로바이더 통합 방법 {#ways-to-integrate-non-openai-providers} | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | @@ -343,7 +343,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model 이 예제에서는 여전히 많은 LLM 프로바이더가 Responses API를 지원하지 않으므로 Chat Completions API/모델을 사용합니다. LLM 프로바이더가 Responses를 지원한다면 Responses를 사용하는 것이 좋습니다. -## 하나의 워크플로에서 모델 혼합 +## 하나의 워크플로에서 모델 혼합 {#mixing-models-in-one-workflow} 단일 워크플로 내에서 에이전트마다 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. @@ -407,11 +407,11 @@ english_agent = Agent( ) ``` -## 고급 OpenAI Responses 설정 +## 고급 OpenAI Responses 설정 {#advanced-openai-responses-settings} OpenAI Responses 경로에서 더 세부적인 제어가 필요한 경우 `ModelSettings`부터 사용합니다. -### 일반적인 고급 `ModelSettings` 옵션 +### 일반적인 고급 `ModelSettings` 옵션 {#common-advanced-modelsettings-options} OpenAI Responses API를 사용할 때 여러 요청 필드에는 이미 직접 대응하는 `ModelSettings` 필드가 있으므로 해당 필드에 `extra_args`을 사용할 필요가 없습니다. @@ -475,7 +475,7 @@ result = await Runner.run( 서버 측 압축은 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]과 다릅니다. `context_management=[{"type": "compaction", "compact_threshold": ...}]`은 각 Responses API 요청과 함께 전송되며, 렌더링된 컨텍스트가 임계값을 초과하면 API가 응답의 일부로 압축 항목을 생성할 수 있습니다. `OpenAIResponsesCompactionSession`은 턴 사이에 독립형 `responses.compact` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. -### `extra_args` 전달 +### `extra_args` 전달 {#passing-extra_args} SDK가 아직 최상위 수준에서 직접 제공하지 않는 프로바이더별 필드 또는 최신 요청 필드가 필요할 때 `extra_args`을 사용합니다. @@ -495,7 +495,7 @@ english_agent = Agent( ) ``` -## 모델 호출 시간 초과 +## 모델 호출 시간 초과 {#model-call-timeouts} 각 모델 호출 시도의 시간을 제한하려면 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout]을 양수인 초 단위 값으로 설정합니다. 시간 초과는 스트리밍 및 비스트리밍 호출에 적용되며 전송 대기를 포함한 전체 시도를 포괄합니다. 전체 에이전트 실행, 함수 도구 실행 또는 재시도 백오프는 제한하지 않습니다. @@ -510,7 +510,7 @@ agent = Agent( 시도가 제한 시간을 초과하면 SDK는 시도를 취소하고 정리가 완료될 때까지 기다린 후 [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]를 발생시킵니다. Runner 관리 재시도가 활성화되면 SDK는 `context.normalized.is_timeout`을 `True`으로 설정한 상태로 시간 초과 실패를 재시도 정책에 전달합니다. 예를 들어 `retry_policies.network_error()`은 이 분류와 일치합니다. 허용된 각 재시도에는 새로운 시도별 시간 초과가 적용됩니다. SDK는 재시도 전에 일반적인 [재실행 안전 규칙](#safety-boundaries)을 계속 적용합니다. -## Runner 관리 재시도 +## Runner 관리 재시도 {#runner-managed-retries} 재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`을 설정하고 재시도 정책이 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. @@ -582,7 +582,7 @@ SDK는 `retry_policies`에서 즉시 사용할 수 있는 다음 헬퍼를 내 정책을 조합할 때 `provider_suggested()`은 프로바이더가 거부 및 재실행 안전 승인을 구분할 수 있는 경우 이를 보존하므로 가장 안전한 첫 번째 기본 구성 요소입니다. -##### 안전 경계 +##### 안전 경계 {#safety-boundaries} 일부 실패는 절대 재시도되지 않습니다. @@ -594,7 +594,7 @@ SDK는 `retry_policies`에서 즉시 사용할 수 있는 다음 헬퍼를 내 `previous_response_id` 또는 `conversation_id`을 사용하는 상태 유지 후속 요청은 재실행 안전 여부를 알 수 없을 때 안전을 위해 실패합니다. 이러한 요청에서는 `network_error()` 또는 `http_status([500])` 같은 프로바이더 외부 조건만으로는 충분하지 않습니다. 일반적으로 `retry_policies.provider_suggested()`을 통해 프로바이더의 재실행 안전 승인을 포함하거나, 위에서 설명한 대로 프로바이더가 안전하지 않다고 표시한 비스트리밍 실패를 명시적으로 승인합니다. -##### Runner와 에이전트의 병합 동작 +##### Runner와 에이전트의 병합 동작 {#runner-and-agent-merge-behavior} `retry`은 Runner 수준 및 에이전트 수준 `ModelSettings` 간에 깊은 병합 방식으로 결합됩니다. @@ -604,9 +604,9 @@ SDK는 `retry_policies`에서 즉시 사용할 수 있는 다음 헬퍼를 내 더 자세한 예제는 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 및 [어댑터 기반 재시도 예제](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)를 참조하세요. -## OpenAI 이외의 프로바이더 문제 해결 +## OpenAI 이외의 프로바이더 문제 해결 {#troubleshooting-non-openai-providers} -### 트레이싱 클라이언트 오류 401 +### 트레이싱 클라이언트 오류 401 {#tracing-client-error-401} 트레이싱 관련 오류가 발생하는 이유는 트레이스가 OpenAI 서버에 업로드되지만 OpenAI API 키가 없기 때문입니다. 다음 세 가지 방법으로 해결할 수 있습니다. @@ -614,14 +614,14 @@ SDK는 `retry_policies`에서 즉시 사용할 수 있는 다음 헬퍼를 내 2. 트레이싱용 OpenAI 키를 설정합니다: [`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]. 이 API 키는 트레이스 업로드에만 사용되며 [platform.openai.com](https://platform.openai.com/)에서 발급된 키여야 합니다. 3. OpenAI 이외의 트레이스 프로세서를 사용합니다. [트레이싱 문서](../tracing.md#custom-tracing-processors)를 참조하세요. -### Responses API 지원 +### Responses API 지원 {#responses-api-support} SDK는 기본적으로 Responses API를 사용하지만, 여전히 많은 다른 LLM 프로바이더가 이를 지원하지 않습니다. 그 결과 404 또는 유사한 문제가 발생할 수 있습니다. 다음 두 가지 방법으로 해결할 수 있습니다. 1. [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]을 호출합니다. 환경 변수를 통해 `OPENAI_API_KEY` 및 `OPENAI_BASE_URL`을 설정하는 경우 사용할 수 있습니다. 2. [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]을 사용합니다. 예제는 [여기](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)에서 확인할 수 있습니다. -### Chat Completions 호환성 옵션 +### Chat Completions 호환성 옵션 {#chat-completions-compatibility-options} Chat Completions를 통해 라우팅할 때 SDK는 `previous_response_id`, `conversation_id`, Responses API의 `prompt` 필드 또는 텍스트 전용이 아닌 도구 출력처럼 Chat Completions에서 전송할 수 없는 Responses 전용 필드를 별도 알림 없이 삭제하여 호환성을 유지합니다. 개발 중 이러한 불일치가 즉시 실패하도록 하려면 OpenAI 프로바이더에서 엄격한 기능 검증을 활성화합니다. @@ -658,7 +658,7 @@ provider = OpenAIProvider( [`MultiProvider`][agents.MultiProvider]의 경우 `openai_buffer_streamed_tool_calls=True`을 사용합니다. -### structured outputs 지원 +### structured outputs 지원 {#structured-outputs-support} 일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이 경우 다음과 유사한 오류가 발생할 수 있습니다. @@ -670,7 +670,7 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' 이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`을 지정할 수 없습니다. 현재 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. -## 프로바이더 간 모델 혼합 +## 프로바이더 간 모델 혼합 {#mixing-models-across-providers} 모델 프로바이더 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. @@ -678,11 +678,11 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' - 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. - 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 유효하지 않은 JSON을 생성한다는 점에 유의하세요. -## 서드 파티 어댑터 +## 서드 파티 어댑터 {#third-party-adapters} SDK의 기본 제공 프로바이더 통합 지점으로 충분하지 않은 경우에만 서드 파티 어댑터를 사용합니다. 이 SDK에서 OpenAI 모델만 사용한다면 Any-LLM 또는 LiteLLM 대신 기본 제공 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 경로를 사용하는 것이 좋습니다. 서드 파티 어댑터는 OpenAI 모델과 OpenAI 이외의 프로바이더를 결합하거나, 어댑터에서만 제공하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위한 것입니다. 어댑터는 SDK와 업스트림 모델 프로바이더 사이에 또 하나의 호환성 계층을 추가하므로 기능 지원 및 요청 의미 체계가 프로바이더마다 다를 수 있습니다. 현재 SDK에는 Any-LLM과 LiteLLM이 최선 노력 기반의 베타 어댑터 통합으로 포함되어 있습니다. -### Any-LLM +### Any-LLM {#any-llm} Any-LLM 지원은 Any-LLM에서 관리하는 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 노력 기반의 베타 기능으로 포함되어 있습니다. @@ -692,7 +692,7 @@ Any-LLM이 필요한 경우 `openai-agents[any-llm]`을 설치한 다음 [`examp Any-LLM은 서드 파티 어댑터 계층이므로 프로바이더 종속성과 기능 격차는 SDK가 아니라 Any-LLM 업스트림에서 정의됩니다. 업스트림 프로바이더가 사용량 메트릭을 반환하면 자동으로 전파되지만, 스트리밍 Chat Completions 백엔드는 사용량 청크를 생성하기 전에 `ModelSettings(include_usage=True)`이 필요할 수 있습니다. structured outputs, 도구 호출, 사용량 보고 또는 Responses 관련 동작에 의존한다면 배포하려는 정확한 프로바이더 백엔드를 검증하세요. -### LiteLLM +### LiteLLM {#litellm} LiteLLM 지원은 LiteLLM 전용 프로바이더 지원 범위 또는 라우팅이 필요한 경우를 위해 최선 노력 기반의 베타 기능으로 포함되어 있습니다. diff --git a/docs/ko/multi_agent.md b/docs/ko/multi_agent.md index 9e443f8ddc..5866b7a6cc 100644 --- a/docs/ko/multi_agent.md +++ b/docs/ko/multi_agent.md @@ -11,7 +11,7 @@ search: 이러한 패턴을 조합하여 사용할 수도 있습니다. 각 패턴에는 아래에서 설명하는 장단점이 있습니다. -## LLM을 통한 오케스트레이션 +## LLM을 통한 오케스트레이션 {#orchestrating-via-llm} 에이전트는 지침, 도구, 핸드오프가 제공된 LLM입니다. 즉, 개방형 작업이 주어지면 LLM은 작업을 어떻게 처리할지 자율적으로 계획하고, 도구를 사용하여 작업을 수행하고 데이터를 수집하며, 핸드오프를 사용하여 하위 에이전트에 작업을 위임할 수 있습니다. 예를 들어 리서치 에이전트에는 다음과 같은 기능을 제공할 수 있습니다. @@ -21,7 +21,7 @@ search: - 데이터 분석을 위한 코드 실행 - 계획 수립, 보고서 작성 등에 뛰어난 전문 에이전트로의 핸드오프. -### 핵심 SDK 패턴 +### 핵심 SDK 패턴 {#core-sdk-patterns} Python SDK에서는 다음 두 가지 오케스트레이션 패턴이 가장 많이 사용됩니다. @@ -44,7 +44,7 @@ Python SDK에서는 다음 두 가지 오케스트레이션 패턴이 가장 많 이러한 오케스트레이션 방식의 기반이 되는 핵심 SDK 기본 구성 요소를 알아보려면 [도구](tools.md), [핸드오프](handoffs.md), [에이전트 실행](running_agents.md)부터 살펴보세요. -## 코드를 통한 오케스트레이션 +## 코드를 통한 오케스트레이션 {#orchestrating-via-code} LLM을 통한 오케스트레이션은 강력하지만, 코드를 통한 오케스트레이션을 사용하면 속도, 비용 및 성능 측면에서 작업을 더욱 결정론적이고 예측 가능하게 만들 수 있습니다. 일반적인 패턴은 다음과 같습니다. @@ -55,7 +55,7 @@ LLM을 통한 오케스트레이션은 강력하지만, 코드를 통한 오케 [`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns)에서 다양한 코드 예제를 확인할 수 있습니다. -## 관련 가이드 +## 관련 가이드 {#related-guides} - 구성 패턴 및 에이전트 설정은 [에이전트](agents.md)를 참고하세요. - `Agent.as_tool()` 및 관리자 스타일 오케스트레이션은 [도구](tools.md#agents-as-tools)를 참고하세요. diff --git a/docs/ko/quickstart.md b/docs/ko/quickstart.md index 462687d1f9..d1cfd03070 100644 --- a/docs/ko/quickstart.md +++ b/docs/ko/quickstart.md @@ -4,7 +4,7 @@ search: --- # 빠른 시작 -## 프로젝트 및 가상 환경 생성 +## 프로젝트 및 가상 환경 생성 {#create-a-project-and-virtual-environment} 이 작업은 한 번만 수행하면 됩니다. @@ -14,7 +14,7 @@ cd my_project python -m venv .venv ``` -### 가상 환경 활성화 +### 가상 환경 활성화 {#activate-the-virtual-environment} 새 터미널 세션을 시작할 때마다 이 작업을 수행하세요. @@ -30,13 +30,13 @@ Windows: .venv\Scripts\activate ``` -### Agents SDK 설치 +### Agents SDK 설치 {#install-the-agents-sdk} ```bash pip install openai-agents # or `uv add openai-agents`, etc ``` -### OpenAI API 키 설정 +### OpenAI API 키 설정 {#set-an-openai-api-key} API 키가 없다면 [이 지침](https://platform.openai.com/docs/quickstart#create-and-export-an-api-key)에 따라 OpenAI API 키를 생성하세요. @@ -60,7 +60,7 @@ Windows Command Prompt: set "OPENAI_API_KEY=sk-..." ``` -## 첫 에이전트 생성 +## 첫 에이전트 생성 {#create-your-first-agent} 에이전트는 instructions, 이름, 특정 모델과 같은 선택적 구성으로 정의됩니다. @@ -73,7 +73,7 @@ agent = Agent( ) ``` -## 첫 에이전트 실행 +## 첫 에이전트 실행 {#run-your-first-agent} [`Runner`][agents.run.Runner]를 사용해 에이전트를 실행하고 [`RunResult`][agents.result.RunResult]를 반환받습니다. @@ -108,7 +108,7 @@ if __name__ == "__main__": 작업이 주로 프롬프트, 도구, 대화 상태 안에서 이루어진다면 일반 `Agent`와 `Runner`를 사용하세요. 에이전트가 격리된 워크스페이스에서 실제 파일을 검사하거나 수정해야 한다면 [샌드박스 에이전트 빠른 시작](sandbox_agents.md)으로 이동하세요. -## 에이전트에 도구 제공 +## 에이전트에 도구 제공 {#give-your-agent-tools} 에이전트에 정보를 조회하거나 작업을 수행할 수 있는 도구를 제공할 수 있습니다. @@ -143,7 +143,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 에이전트 몇 개 더 추가 +## 에이전트 몇 개 더 추가 {#add-a-few-more-agents} 멀티 에이전트 패턴을 선택하기 전에, 최종 답변을 누가 담당할지 결정하세요. @@ -170,7 +170,7 @@ math_tutor_agent = Agent( ) ``` -## 핸드오프 정의 +## 핸드오프 정의 {#define-your-handoffs} 에이전트에서는 작업을 해결하는 동안 선택할 수 있는 발신 핸드오프 옵션 목록을 정의할 수 있습니다. @@ -182,7 +182,7 @@ triage_agent = Agent( ) ``` -## 에이전트 오케스트레이션 실행 +## 에이전트 오케스트레이션 실행 {#run-the-agent-orchestration} 러너는 개별 에이전트 실행, 모든 핸드오프, 모든 도구 호출을 처리합니다. @@ -204,7 +204,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 참조 예제 +## 참조 예제 {#reference-examples} 저장소에는 동일한 핵심 패턴에 대한 전체 스크립트가 포함되어 있습니다. @@ -212,11 +212,11 @@ if __name__ == "__main__": - [`examples/basic/tools.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/tools.py): 함수 도구 - [`examples/agent_patterns/routing.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/routing.py): 멀티 에이전트 라우팅 -## 트레이스 보기 +## 트레이스 보기 {#view-your-traces} 에이전트 실행 중 발생한 일을 검토하려면 [OpenAI Dashboard의 Trace viewer](https://platform.openai.com/traces)로 이동해 에이전트 실행 트레이스를 확인하세요. -## 다음 단계 +## 다음 단계 {#next-steps} 더 복잡한 에이전트형 흐름을 구축하는 방법을 알아보세요. diff --git a/docs/ko/realtime/guide.md b/docs/ko/realtime/guide.md index 27e5d40c0b..3da9d2a07a 100644 --- a/docs/ko/realtime/guide.md +++ b/docs/ko/realtime/guide.md @@ -10,7 +10,7 @@ search: 기본 Python 경로를 사용하려면 먼저 [빠른 시작](quickstart.md)을 읽어 보세요. 애플리케이션에서 서버 측 WebSocket과 SIP 중 무엇을 사용할지 결정하려면 [실시간 전송](transport.md)을 읽어 보세요. 브라우저 WebRTC 전송은 Python SDK에 포함되지 않습니다. -## 개요 +## 개요 {#overview} 실시간 에이전트는 Realtime API에 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하고, 도구를 호출하며, 매 턴마다 새로운 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. @@ -21,7 +21,7 @@ search: - **RealtimeSession**: 입력을 보내고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 라이브 세션 - **RealtimeModel**: 전송 추상화입니다. 기본값은 OpenAI의 서버 측 WebSocket 구현입니다. -## 세션 수명 주기 +## 세션 수명 주기 {#session-lifecycle} 일반적인 실시간 세션은 다음과 같습니다. @@ -38,7 +38,7 @@ search: Realtime API 서버가 기본 WebSocket 연결을 정상적으로 종료하면 모델 전송은 `disconnected` [`RealtimeModelConnectionStatusEvent`][agents.realtime.model_events.RealtimeModelConnectionStatusEvent]를 내보낸 다음 [`RealtimeModelEndOfStreamEvent`][agents.realtime.model_events.RealtimeModelEndOfStreamEvent]를 내보냅니다. `RealtimeSession`는 두 이벤트를 모두 `raw_model_event` 내부로 전달하고, 이미 대기열에 있는 이벤트를 모두 처리한 다음 예외를 발생시키지 않고 비동기 순회를 종료합니다. 호출자가 시작한 `session.close()`은 이러한 서버 연결 해제 이벤트를 합성하지 않습니다. 예기치 않은 WebSocket 오류는 정상적인 서버 종료처럼 순회를 끝내는 대신 세션의 예외 경로를 통해 계속 처리됩니다. -## 에이전트 및 세션 구성 +## 에이전트 및 세션 구성 {#agent-and-session-configuration} `RealtimeAgent`은 의도적으로 일반 `Agent` 유형보다 범위가 좁습니다. @@ -91,7 +91,7 @@ runner = RealtimeRunner( 전체 유형화 인터페이스는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig]과 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]을 참조하세요. -### 입력 트랜스크립션 설정 +### 입력 트랜스크립션 설정 {#input-transcription-settings} 입력 트랜스크립션은 `audio.input.transcription`에서 구성합니다. 지연 시간이 짧은 증분 트랜스크립트에는 `gpt-live-transcribe`을 사용하고, 오디오 턴이 커밋된 후 트랜스크립션을 시작해야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우 WebSocket에서 `gpt-transcribe`를 사용합니다. Agents SDK는 모델별 GA 트랜스크립션 설정을 중첩된 세션 구성으로 전달합니다. @@ -145,9 +145,9 @@ WebSocket 기반 Realtime 세션에서 `gpt-transcribe`은 커밋된 오디오 `audio.input.turn_detection`을 `None`로 설정하면 자동 턴 감지가 비활성화됩니다. 그러면 애플리케이션이 [수동 응답 제어](#manual-response-control)에 설명된 대로 오디오 턴을 커밋하고 응답 생성을 제어해야 합니다. 모델 동작, 유효성 검사 규칙, 지연 시간 지침은 OpenAI API의 [Realtime 트랜스크립션 가이드](https://developers.openai.com/api/docs/guides/realtime-transcription)를 참조하세요. -## 입력 및 출력 +## 입력 및 출력 {#inputs-and-outputs} -### 텍스트 및 구조화된 사용자 메시지 +### 텍스트 및 구조화된 사용자 메시지 {#text-and-structured-user-messages} 일반 텍스트 또는 구조화된 Realtime 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]를 사용합니다. @@ -169,7 +169,7 @@ await session.send_message(message) 구조화된 메시지는 Realtime 대화에 이미지 입력을 포함하는 주요 방법입니다. [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)의 웹 데모 예제는 이 방식으로 `input_image` 메시지를 전달합니다. -### 오디오 입력 +### 오디오 입력 {#audio-input} 가공되지 않은 오디오 바이트를 스트리밍하려면 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용합니다. @@ -185,7 +185,7 @@ await session.send_audio(audio_bytes, commit=True) 더 낮은 수준의 제어가 필요한 경우 기본 모델 전송을 통해 `input_audio_buffer.commit`과 같은 Realtime API 클라이언트 이벤트를 직접 보낼 수도 있습니다. -### 수동 응답 제어 +### 수동 응답 제어 {#manual-response-control} `session.send_message()`은 상위 수준 경로를 사용해 사용자 입력을 보내고 응답을 시작합니다. 일부 구성에서는 가공되지 않은 오디오 버퍼링이 동일한 동작을 자동으로 수행하지 **않습니다**. @@ -213,7 +213,7 @@ await session.model.send_event( [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)의 SIP 예제는 시작 인사말을 강제로 생성하기 위해 가공되지 않은 `response.create`을 사용합니다. -## 이벤트, 기록 및 인터럽션(중단 처리) +## 이벤트, 기록 및 인터럽션(중단 처리) {#events-history-and-interruptions} `RealtimeSession`은 상위 수준 SDK 이벤트를 내보내는 동시에 필요할 때 가공되지 않은 모델 이벤트도 계속 전달합니다. @@ -231,7 +231,7 @@ await session.model.send_event( UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`와 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지, 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. -### 사용량 집계 +### 사용량 집계 {#usage-accounting} 완료된 모델 응답에 사용량이 포함된 경우 SDK의 OpenAI `RealtimeModel` 전송은 `raw_model_event` 내부에서 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]를 내보냅니다. `usage` 필드에는 해당 응답의 토큰 수가 포함되며, `input_tokens_details`와 `output_tokens_details`은 선택적 모달리티별 내역을 제공합니다. @@ -255,7 +255,7 @@ async for event in session: 사용량은 모델 제공자가 완료된 응답에 사용량을 포함한 경우에만 보고됩니다. 누적 값은 해당 `RealtimeSession`이 수신한 응답에 적용되며, 여러 세션에 걸친 합계가 아닙니다. -### 인터럽션(중단 처리) 및 재생 추적 +### 인터럽션(중단 처리) 및 재생 추적 {#interruptions-and-playback-tracking} 사용자가 어시스턴트를 중단하면 세션은 `audio_interrupted`을 내보내고, 서버 측 대화가 사용자가 실제로 들은 내용과 일치하도록 기록을 업데이트합니다. @@ -263,9 +263,9 @@ async for event in session: [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py)의 Twilio 예제에서 이 패턴을 확인할 수 있습니다. -## 도구, 승인, 핸드오프 및 가드레일 +## 도구, 승인, 핸드오프 및 가드레일 {#tools-approvals-handoffs-and-guardrails} -### 함수 도구 +### 함수 도구 {#function-tools} 실시간 에이전트는 라이브 대화 중 함수 도구를 지원합니다. @@ -286,7 +286,7 @@ agent = RealtimeAgent( ) ``` -### 도구 승인 +### 도구 승인 {#tool-approvals} 함수 도구를 실행하기 전에 사람의 승인을 요구하도록 설정할 수 있습니다. 이 경우 세션은 `tool_approval_required`을 내보내고 `approve_tool_call()` 또는 `reject_tool_call()`을 호출할 때까지 도구 실행을 일시 중지합니다. @@ -300,7 +300,7 @@ async for event in session: 구체적인 서버 측 승인 루프는 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)를 참조하세요. 휴먼인더루프 문서의 [휴먼인더루프 (HITL)](../human_in_the_loop.md)에서도 이 흐름을 안내합니다. -### 핸드오프 +### 핸드오프 {#handoffs} Realtime 핸드오프를 사용하면 한 에이전트가 라이브 대화를 다른 전문가에게 전달할 수 있습니다. @@ -326,7 +326,7 @@ main_agent = RealtimeAgent( 핸드오프로 직접 사용되는 `RealtimeAgent` 객체는 자동으로 래핑되며, `realtime_handoff(...)`을 사용하면 이름, 설명, 유효성 검사, 콜백, 가용성을 사용자 지정할 수 있습니다. Realtime 핸드오프는 일반 핸드오프의 `input_filter`을 지원하지 **않습니다**. -### 가드레일 +### 가드레일 {#guardrails} 실시간 에이전트는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일 검사는 디바운스됩니다. 각 검사는 모든 부분 델타마다 실행되는 대신 누적된 출력 텍스트 및 오디오 트랜스크립트 델타를 대상으로 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`를 내보냅니다. @@ -352,7 +352,7 @@ agent = RealtimeAgent( 사용자 지정 `RealtimeModel` 전송은 동일한 소스 범위 오디오 중단 동작을 제공하기 위해 `RealtimeModelSendInterrupt.response_id`과 `playback_only`을 준수해야 합니다. 또한 텍스트 전용 출력 경로의 복구 메시지를 지원하려면 `RealtimeModel.send_event_if()`를 재정의해야 합니다. 구현은 전송에서 실제로 이벤트를 커밋하는 경계에서 제공된 조건을 다시 검사하거나, 조건 검사와 이벤트 커밋을 함께 직렬화해야 합니다. 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 조건을 한 번 검사한 뒤 이벤트를 별도로 보내면 해당 검사와 이벤트 커밋 사이에 다른 응답이 시작될 수 있기 때문입니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. -## SIP 및 전화 통신 +## SIP 및 전화 통신 {#sip-and-telephony} Python SDK는 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel]을 통해 일급 SIP 연결 흐름을 제공합니다. @@ -375,7 +375,7 @@ async with await runner.run( 먼저 전화를 수락해야 하고 수락 페이로드를 에이전트에서 파생된 세션 구성과 일치시키려면 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`을 사용합니다. 전체 흐름은 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)에 나와 있습니다. -## 저수준 접근 및 사용자 지정 엔드포인트 +## 저수준 접근 및 사용자 지정 엔드포인트 {#low-level-access-and-custom-endpoints} `session.model`를 통해 기본 전송 객체에 접근할 수 있습니다. @@ -421,7 +421,7 @@ session = await runner.run( `headers`를 전달하면 SDK가 `Authorization`를 자동으로 추가하지 않습니다. 실시간 에이전트에서 기존 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. -## 추가 자료 +## 추가 자료 {#further-reading} - [실시간 전송](transport.md) - [빠른 시작](quickstart.md) diff --git a/docs/ko/realtime/quickstart.md b/docs/ko/realtime/quickstart.md index f477a694fb..91ba32d4da 100644 --- a/docs/ko/realtime/quickstart.md +++ b/docs/ko/realtime/quickstart.md @@ -10,13 +10,13 @@ Python SDK의 실시간 에이전트는 WebSocket 전송을 통해 OpenAI Realti Python SDK는 브라우저 WebRTC 전송을 제공하지 **않습니다**. 이 페이지에서는 서버 측 WebSocket을 통해 Python으로 관리하는 실시간 세션만 다룹니다. 서버 측 오케스트레이션, 도구, 승인 및 전화 통신 통합에는 이 SDK를 사용하세요. [실시간 전송](transport.md)도 참고하세요. -## 사전 요구 사항 +## 사전 요구 사항 {#prerequisites} - Python 3.10 이상 - OpenAI API 키 - OpenAI Agents SDK에 대한 기본 지식 -## 설치 +## 설치 {#installation} 아직 설치하지 않았다면 OpenAI Agents SDK를 설치합니다. @@ -24,9 +24,9 @@ Python SDK의 실시간 에이전트는 WebSocket 전송을 통해 OpenAI Realti pip install openai-agents ``` -## 서버 측 실시간 세션 생성 +## 서버 측 실시간 세션 생성 {#create-a-server-side-realtime-session} -### 1. 실시간 구성 요소 가져오기 +### 1. 실시간 구성 요소 가져오기 {#1-import-the-realtime-components} ```python import asyncio @@ -34,7 +34,7 @@ import asyncio from agents.realtime import RealtimeAgent, RealtimeRunner ``` -### 2. 시작 에이전트 정의 +### 2. 시작 에이전트 정의 {#2-define-the-starting-agent} ```python agent = RealtimeAgent( @@ -43,7 +43,7 @@ agent = RealtimeAgent( ) ``` -### 3. 러너 구성 +### 3. 러너 구성 {#3-configure-the-runner} 새 코드에는 중첩된 `audio.input` / `audio.output` 세션 설정 구조를 사용하는 것이 좋습니다. 새 실시간 에이전트에는 `gpt-realtime-2.1`부터 사용하세요. @@ -72,7 +72,7 @@ runner = RealtimeRunner( ) ``` -### 4. 세션 시작 및 입력 전송 +### 4. 세션 시작 및 입력 전송 {#4-start-the-session-and-send-input} `runner.run()`는 `RealtimeSession`를 반환합니다. 세션 컨텍스트에 진입하면 연결이 열립니다. @@ -102,12 +102,12 @@ if __name__ == "__main__": `session.send_message()`는 일반 문자열 또는 구조화된 실시간 메시지를 받습니다. raw 오디오 청크에는 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]을 사용하세요. -## 이 빠른 시작에서 다루지 않는 내용 +## 이 빠른 시작에서 다루지 않는 내용 {#what-this-quickstart-does-not-include} - 마이크 캡처 및 스피커 재생 코드. [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime)의 실시간 코드 예제를 참고하세요. - SIP / 전화 통신 연결 흐름. [실시간 전송](transport.md) 및 [SIP 섹션](guide.md#sip-and-telephony)을 참고하세요. -## 주요 설정 +## 주요 설정 {#key-settings} 기본 세션이 작동한 후 일반적으로 가장 먼저 사용하는 설정은 다음과 같습니다. @@ -126,7 +126,7 @@ if __name__ == "__main__": 전체 스키마는 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 및 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]을 참고하세요. -## 연결 옵션 +## 연결 옵션 {#connection-options} 환경에 API 키를 설정합니다. @@ -151,7 +151,7 @@ session = await runner.run(model_config={"api_key": "your-api-key"}) Azure OpenAI에 연결할 때는 `model_config["url"]`를 GA Realtime 엔드포인트 URL로 설정하고 헤더를 명시적으로 전달하세요. 실시간 에이전트에는 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. 자세한 내용은 [실시간 에이전트 가이드](guide.md#low-level-access-and-custom-endpoints)를 참고하세요. -## 다음 단계 +## 다음 단계 {#next-steps} - 서버 측 WebSocket과 SIP 중에서 선택하려면 [실시간 전송](transport.md)을 읽어보세요. - 수명 주기, 구조화된 입력, 승인, 핸드오프, 가드레일 및 저수준 제어에 관한 내용은 [실시간 에이전트 가이드](guide.md)를 읽어보세요. diff --git a/docs/ko/realtime/transport.md b/docs/ko/realtime/transport.md index 4ecec74d50..4eb2b9521e 100644 --- a/docs/ko/realtime/transport.md +++ b/docs/ko/realtime/transport.md @@ -10,7 +10,7 @@ search: Python SDK에는 브라우저 WebRTC 트랜스포트가 포함되어 있지 **않습니다**. 이 페이지에서는 Python SDK의 트랜스포트 선택지인 서버 측 WebSocket과 SIP 연결 흐름만 다룹니다. 브라우저 WebRTC는 별도의 플랫폼 주제이며, 공식 [WebRTC를 사용하는 Realtime API](https://developers.openai.com/api/docs/guides/realtime-webrtc/) 가이드에 문서화되어 있습니다. -## 선택 가이드 +## 선택 가이드 {#decision-guide} | 목표 | 시작 지점 | 이유 | | --- | --- | --- | @@ -18,7 +18,7 @@ search: | 선택할 트랜스포트와 배포 구조 파악 | 이 페이지 | 트랜스포트나 배포 구조를 확정하기 전에 이 페이지를 참조합니다. | | 에이전트를 전화 또는 SIP 통화에 연결 | [실시간 가이드](guide.md) 및 [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | 저장소는 `call_id`에서 구동하는 SIP 연결 흐름을 제공합니다. | -## 서버 측 WebSocket 기반의 기본 Python 경로 +## 서버 측 WebSocket 기반의 기본 Python 경로 {#server-side-websocket-is-the-default-python-path} 사용자 지정 `RealtimeModel`를 전달하지 않으면 `RealtimeRunner`은 `OpenAIRealtimeWebSocketModel`를 사용합니다. @@ -37,7 +37,7 @@ search: 서버에서 오디오 파이프라인, 도구 실행, 승인 흐름 및 기록 처리를 담당하는 경우 이 경로를 사용합니다. -### 저수준 WebSocket 조정 +### 저수준 WebSocket 조정 {#low-level-websocket-tuning} 기반 서버 측 WebSocket 연결을 조정해야 할 때 `transport_config`를 `OpenAIRealtimeWebSocketModel`에 전달합니다. @@ -69,7 +69,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 이 설정은 Realtime API 세션이 아닌 클라이언트 연결을 구성합니다. 엔드포인트, 인증, 통화 연결 및 재생 설정에는 계속해서 `RealtimeModelConfig`을 사용합니다. -## 텔레포니 경로인 SIP 연결 +## 텔레포니 경로인 SIP 연결 {#sip-attach-is-the-telephony-path} 이 저장소에 문서화된 텔레포니 흐름에서 Python SDK는 `call_id`를 통해 기존 실시간 통화에 연결합니다. @@ -84,7 +84,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 더 광범위한 Realtime API에서는 일부 서버 측 제어 패턴에 `call_id`도 사용하지만, 이 저장소에서 제공하는 연결 예제는 SIP입니다. -## SDK 범위 밖의 브라우저 WebRTC +## SDK 범위 밖의 브라우저 WebRTC {#browser-webrtc-is-outside-this-sdk} 앱의 기본 클라이언트가 Realtime WebRTC를 사용하는 브라우저인 경우 다음 사항에 유의합니다. @@ -95,7 +95,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 현재 이 저장소는 브라우저 WebRTC와 Python 사이드밴드를 함께 사용하는 예제도 제공하지 않습니다. -## 사용자 지정 엔드포인트 및 연결 지점 +## 사용자 지정 엔드포인트 및 연결 지점 {#custom-endpoints-and-attach-points} [`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig]의 트랜스포트 구성 인터페이스를 사용하면 기본 트랜스포트 동작을 사용자 지정할 수 있습니다. diff --git a/docs/ko/release.md b/docs/ko/release.md index e150103075..8668aef405 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -6,13 +6,13 @@ search: 이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 시맨틱 버저닝을 따릅니다. 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가합니다. -## 마이너(`Y`) 버전 +## 마이너(`Y`) 버전 {#minor-y-versions} 베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 있을 때 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. 호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. -## 패치(`Z`) 버전 +## 패치(`Z`) 버전 {#patch-z-versions} 호환성을 깨지 않는 다음 변경 사항에는 `Z`을 증가시킵니다. @@ -21,9 +21,9 @@ search: - 비공개 인터페이스 변경 - 베타 기능 업데이트 -## 호환성을 깨는 변경 사항 로그 +## 호환성을 깨는 변경 사항 로그 {#breaking-change-changelog} -### 0.22.0 +### 0.22.0 {#0220} 버전 0.22.0에서는 여러 기존 API의 실패 처리와 데이터 격리가 강화되었습니다. 명시적 클라이언트로 `OpenAIProvider`을 생성하면서 프로바이더에도 `organization` 또는 `project`을 전달하는 애플리케이션은 중복 인수를 제거해야 합니다. @@ -36,7 +36,7 @@ search: - 이제 에이전트 시각화는 `handoff(agent)`로 등록된 대상의 도구, MCP 서버, 이후 핸드오프를 재귀적으로 확장하며, 이는 에이전트의 `handoffs` 목록에 있는 직접적인 `Agent` 항목과 동일합니다. [그래프 생성](visualization.md#generating-a-graph)을 참고하세요. - 이제 `Agent.clone()` 및 `RealtimeAgent.clone()` API 안내에는 기존의 얕은 복사 동작이 정확히 명시되어 있습니다. 재정의되지 않은 목록 속성은 동일한 목록 객체로 유지됩니다. 복제본이 컨테이너를 독립적으로 소유해야 한다면 새 목록을 전달하세요. [에이전트 복제/복사](agents.md#cloningcopying-agents)를 참고하세요. -### 0.21.0 +### 0.21.0 {#0210} 버전 0.21.0에는 `openai` v3가 필요하며, Agents SDK의 OpenAI HTTP 통합이 HTTPX2로 이전되었습니다. 기본 OpenAI 클라이언트를 사용하는 애플리케이션은 클라이언트 설정을 변경할 필요가 없지만, OpenAI HTTP 계층을 사용자 지정하는 애플리케이션은 전송 계층 관련 코드를 마이그레이션해야 할 수 있습니다. @@ -49,7 +49,7 @@ search: - 로컬 MCP HTTP 사용자 지정은 설치된 MCP 패키지를 계속 따릅니다. MCP Python SDK v1은 레거시 `httpx`을 제공하고 사용하며, MCP Python SDK v2는 `httpx2`을 사용합니다. 일반적인 MCP 연결에는 애플리케이션 변경이 필요하지 않습니다. [MCP Python SDK v1 및 v2](mcp.md#mcp-python-sdk-v1-and-v2)를 참고하세요. - 이제 공개된 프로바이더 중립적 테스트 유틸리티는 프로바이더나 프로세스 의존성 없이 에이전트 모델, 샌드박스 세션, Realtime 세션, Voice 파이프라인 워크플로를 지원합니다. 실제 프로바이더 어댑터 또는 통합 경계를 유지해야 하는 경우에 대한 방법과 안내는 [테스트](testing.md)를 참고하세요. -### 0.20.0 +### 0.20.0 {#0200} 버전 0.20.0에는 로컬 MCP HTTP 전송을 사용자 지정하는 애플리케이션에 잠재적으로 호환성을 깨는 MCP 의존성 마이그레이션이 포함됩니다. 또한 에이전트 또는 실행에서 모델을 명시적으로 선택하지 않을 때 사용하는 SDK 기본 모델이 업데이트되었습니다. @@ -64,7 +64,7 @@ search: - 이제 재개 가능한 `RunState` 객체는 다음 모델 호출 전에 `add_input()`을 사용해 영구 사용자 입력을 스테이징할 수 있습니다. 스테이징된 입력은 직렬화 후에도 유지되고 입력 가드레일을 통과하며, 로컬 세션과 서버 관리형 대화 전체에서 하나의 영구적인 SDK 입력 발생 기록을 생성합니다. 안전하지 않은 재실행을 명시적으로 승인하면 입력이 프로바이더에 다시 전송되고 프로바이더 측 작업이 반복될 수 있습니다. [재개 전 입력 추가](results.md#add-input-before-resuming)를 참고하세요. - 런타임 안정성 수정으로 스트리밍 및 비스트리밍 [출력 가드레일 세션 영속성](guardrails.md#output-guardrails)이 일치하고, 복사 및 네임스페이스 지정 중에 `FunctionTool` 하위 클래스가 보존되며, 지원되지 않는 [Chat Completions 오디오 출력](models/index.md#chat-completions-compatibility-options)에 대해 빈 스트림을 조용히 완료하는 대신 명시적 오류가 발생합니다. `OpenAIResponsesCompactionSession` 래퍼는 취소가 호출자에게 전달되기 전에 [압축 전 기록 복구](sessions/index.md#auto-compaction-can-block-streaming)를 시도하고 완료될 때까지 기다립니다. 이제 [`VoicePipeline`](voice/pipeline.md#results) 소비자는 실행이 정상적으로 끝난 후 전사 세션 종료 실패를 수신하며, 이전 턴의 실패가 이후 종료 실패보다 우선합니다. 이제 `RunState` 왕복 변환은 로컬 셸 출력, 확인된 컴퓨터 안전 검사, 기본값이 설정된 도구 출력 필드, 딕셔너리·목록·튜플을 순회하는 중 발견한 Pydantic 모델 또는 데이터클래스 출력을 보존합니다. MCP 변환은 자유 형식 객체 스키마와 이미지 출력을 보존하며, 오디오 및 리소스 블록과 같은 기타 raw 콘텐츠 블록을 유효한 JSON 텍스트로 직렬화합니다. `MCPServerManager`는 겹치는 수명 주기 작업을 직렬화하고 연결 및 정리에 유한한 기본 타임아웃을 적용합니다. 모델 재실행은 출력 항목을 입력으로 사용하기 전에 서버 소유 `created_by` 메타데이터를 제거합니다. -### 0.19.0 +### 0.19.0 {#0190} 이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새 기능 영역인 Programmatic Tool Calling을 반영합니다. @@ -77,7 +77,7 @@ search: - AnyLLM, LiteLLM, Chat Completions 호환성이 개선되고 모델 재시도 전반에서 세션 기록이 보존되며, 응답이 시작되기 전에 발생하는 WebSocket 과부하에 대한 프로바이더 재시도 안내가 추가되었습니다. 이에 따라 허용되는 경우 명시적으로 활성화한 Runner 재시도 정책이 실패한 시도를 재실행할 수 있습니다. - `VercelCloudBucketMountStrategy`을 통해 [Vercel 샌드박스를 생성할 때만 구성할 수 있는 S3 마운트](sandbox/clients.md#mounts-and-remote-storage)가 추가되었습니다. 마운트된 세션은 워크스페이스 영속성에서 버킷 콘텐츠를 제외하며, 의도적으로 동적 마운트 변경이나 세션 재개를 지원하지 않습니다. -### 0.18.0 +### 0.18.0 {#0180} 이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 Realtime 에이전트의 기본 모델 업데이트만을 위한 것입니다. @@ -85,7 +85,7 @@ search: - 이제 Realtime 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서는 별도 구성 없이 최신 권장 모델을 사용합니다. -### 0.17.0 +### 0.17.0 {#0170} 이 버전에서 샌드박스 로컬 소스 구체화는 소스 경로가 `Manifest.extra_path_grants`의 적용을 받지 않는 한 `LocalFile.src` 및 `LocalDir.src`을 구체화 `base_dir` 내부로 제한합니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 그 내부에 있거나 명시적 허용 범위 아래에 있어야 합니다. 이 변경은 로컬 아티팩트 경계 문제를 해결하지만, 신뢰할 수 있는 호스트 파일이나 디렉터리를 해당 기본 디렉터리 외부에서 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에 영향을 줄 수 있습니다. @@ -118,7 +118,7 @@ manifest = Manifest( `extra_path_grants`을 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 매니페스트 입력으로 허용 범위를 채우지 마세요. -### 0.16.0 +### 0.16.0 {#0160} 이 버전에서 SDK 기본 모델은 `gpt-4.1` 대신 `gpt-5.4-mini`입니다. 이는 모델을 명시적으로 설정하지 않은 에이전트와 실행에 영향을 줍니다. 새로운 기본값은 GPT-5 모델이므로 암시적 기본 모델 설정에는 이제 `reasoning.effort="none"` 및 `verbosity="low"`와 같은 GPT-5 기본값이 포함됩니다. @@ -133,7 +133,7 @@ agent = Agent(name="Assistant", model="gpt-4.1") - 이제 `Runner.run`, `Runner.run_sync`, `Runner.run_streamed`은 턴 제한을 비활성화하는 `max_turns=None`을 허용합니다. - 이제 로컬, Docker, 프로바이더 기반 샌드박스 구현 전체에서 샌드박스 워크스페이스 하이드레이션은 절대 심볼릭 링크 대상을 포함해 아카이브 루트 외부를 가리키는 심볼릭 링크가 있는 tar 아카이브를 거부합니다. -### 0.15.0 +### 0.15.0 {#0150} 이 버전에서는 이제 모델 거부가 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하게 하는 대신 `ModelRefusalError`으로 명시적으로 노출됩니다. @@ -149,7 +149,7 @@ result = Runner.run_sync( structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 스키마과 일치하는 값을 반환할 수 있으며, SDK는 이를 다른 실행 오류 핸들러의 최종 출력과 동일하게 검증합니다. -### 0.14.0 +### 0.14.0 {#0140} 이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주요한 새 베타 기능 영역인 샌드박스 에이전트와 로컬, 컨테이너화 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드, 문서 지원이 추가되었습니다. @@ -162,7 +162,7 @@ structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 - `examples/sandbox/` 아래에 스킬, 핸드오프, 메모리, 프로바이더별 설정을 활용한 코딩 작업과 코드 검토, 데이터룸 QA, 웹사이트 복제 같은 엔드투엔드 워크플로를 다루는 다양한 샌드박스 코드 예제 및 튜토리얼이 추가되었습니다. - 샌드박스를 인식하는 세션 준비, 기능 바인딩, 상태 직렬화, 통합 트레이싱, 프롬프트 캐시 키 기본값, 더 안전한 민감한 MCP 출력 편집을 통해 코어 런타임과 트레이싱 스택이 확장되었습니다. -### 0.13.0 +### 0.13.0 {#0130} 이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주목할 만한 Realtime 기본값 업데이트와 새로운 MCP 기능 및 런타임 안정성 수정이 포함됩니다. @@ -173,15 +173,15 @@ structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 - 이제 Chat Completions 통합은 `should_replay_reasoning_content`을 통해 기존 추론 콘텐츠를 다시 전송하도록 선택할 수 있어 LiteLLM/DeepSeek 같은 어댑터의 프로바이더별 추론/도구 호출 연속성이 향상됩니다. - `SQLAlchemySession`의 동시 최초 쓰기, 추론 제거 후 고립된 어시스턴트 메시지 ID가 포함된 압축 요청, MCP/추론 항목을 남기는 `remove_all_tools()`, `FunctionTool` 인스턴스용 배치 실행기의 경쟁 상태를 포함한 여러 런타임 및 세션 경계 사례가 수정되었습니다. -### 0.12.0 +### 0.12.0 {#0120} 이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)를 확인하세요. -### 0.11.0 +### 0.11.0 {#0110} 이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 주요 기능 추가 사항은 [릴리스 노트](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)를 확인하세요. -### 0.10.0 +### 0.10.0 {#0100} 이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, OpenAI Responses 사용자를 위한 중요한 새 기능 영역인 Responses API의 websocket 전송 지원이 포함됩니다. @@ -191,50 +191,50 @@ structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 - 여러 턴의 실행에서 공유 websocket 지원 프로바이더와 `RunConfig`을 재사용하기 위한 `responses_websocket_session()` 도우미 / `ResponsesWebSocketSession`가 추가되었습니다. - 스트리밍, 도구, 승인, 후속 턴을 다루는 새로운 websocket 스트리밍 예제(`examples/basic/stream_ws.py`)가 추가되었습니다. -### 0.9.0 +### 0.9.0 {#090} 이 버전에서는 주요 버전의 지원 종료(EOL) 후 3개월이 지났으므로 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. 또한 `Agent#as_tool()` 메서드에서 반환되는 값의 타입 힌트가 `Tool`에서 `FunctionTool`으로 좁혀졌습니다. 이 변경은 일반적으로 호환성을 깨는 문제를 일으키지 않지만, 코드가 더 넓은 유니온 타입에 의존하는 경우 일부 조정이 필요할 수 있습니다. -### 0.8.0 +### 0.8.0 {#080} 이 버전에서는 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. - **동기식** Python 호출 가능 객체를 래핑하는 `FunctionTool` 인스턴스는 이제 이벤트 루프 스레드에서 실행되는 대신 `asyncio.to_thread(...)`을 통해 워커 스레드에서 실행됩니다. 도구 로직이 스레드 로컬 상태 또는 특정 스레드에 종속된 리소스에 의존하는 경우 비동기 도구 구현으로 마이그레이션하거나 도구 코드에 스레드 종속성을 명시하세요. - 이제 로컬 MCP 도구 실패 처리를 구성할 수 있으며, 기본 동작은 전체 실행을 실패시키는 대신 모델에 표시되는 오류 출력을 반환할 수 있습니다. 즉시 실패하는 의미 체계에 의존한다면 `mcp_config={"failure_error_function": None}`을 설정하세요. 서버 수준 `failure_error_function` 값은 에이전트 수준 설정을 재정의하므로 명시적 핸들러가 있는 각 로컬 MCP 서버에 `failure_error_function=None`을 설정하세요. -### 0.7.0 +### 0.7.0 {#070} 이 버전에서는 기존 애플리케이션에 영향을 줄 수 있는 몇 가지 동작 변경이 있었습니다. - 이제 중첩된 핸드오프 기록은 **명시적으로 활성화해야 합니다**(기본적으로 비활성화됨). v0.6.x의 기본 중첩 동작에 의존했다면 `RunConfig(nest_handoff_history=True)`을 명시적으로 설정하세요. - `gpt-5.1` / `gpt-5.2`의 기본 `reasoning.effort`이 `"none"`으로 변경되었습니다(SDK 기본값으로 구성된 이전 기본값은 `"low"`). 프롬프트 또는 품질/비용 프로필이 `"low"`에 의존했다면 `model_settings`에서 명시적으로 설정하세요. -### 0.6.0 +### 0.6.0 {#060} 이 버전에서 기본 핸드오프 기록은 사용자와 어시스턴트 턴을 별도 메시지로 전달하는 대신 하나의 어시스턴트 메시지로 패키징되므로 이후 에이전트가 간결하고 예측 가능한 요약을 받습니다 - 기존 단일 메시지 핸드오프 기록은 이제 기본적으로 `` 블록 앞에 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`으로 시작하므로 이후 에이전트가 명확히 표시된 요약을 받습니다 -### 0.5.0 +### 0.5.0 {#050} 이 버전에는 사용자에게 드러나는 호환성을 깨는 변경 사항이 없지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함됩니다. - `RealtimeRunner`에 [SIP 프로토콜 연결](https://platform.openai.com/docs/guides/realtime-sip) 처리 지원이 추가되었습니다. - Python 3.14 호환성을 위해 `Runner#run_sync`의 내부 로직이 대폭 수정되었습니다. -### 0.4.0 +### 0.4.0 {#040} 이 버전에서는 [openai](https://pypi.org/project/openai/) 패키지 v1.x 버전을 더 이상 지원하지 않습니다. 이 SDK와 함께 openai v2.x를 사용하세요. -### 0.3.0 +### 0.3.0 {#030} 이 버전에서 Realtime API 지원은 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. -### 0.2.0 +### 0.2.0 {#020} 이 버전에서는 이전에 `Agent`을 인수로 받던 몇몇 위치가 이제 `AgentBase`을 인수로 받습니다. 예를 들어 MCP 서버의 `list_tools()` 메서드 시그니처가 이에 해당합니다. 이는 순수한 타입 변경이며, 계속 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`을 `AgentBase`로 대체해 타입 오류를 수정하면 됩니다. -### 0.1.0 +### 0.1.0 {#010} 이 버전에서 [`MCPServer.list_tools()`][agents.mcp.server.MCPServer]에는 `run_context` 및 `agent`이라는 두 가지 새로운 매개변수가 추가되었습니다. `MCPServer`의 하위 클래스에서 재정의한 모든 `MCPServer.list_tools()` 메서드에 이 매개변수를 추가해야 합니다. \ No newline at end of file diff --git a/docs/ko/results.md b/docs/ko/results.md index f87db85513..029666bc7d 100644 --- a/docs/ko/results.md +++ b/docs/ko/results.md @@ -13,7 +13,7 @@ search: `RunResultStreaming`에는 [`stream_events()`][agents.result.RunResultStreaming.stream_events], [`current_agent`][agents.result.RunResultStreaming.current_agent], [`is_complete`][agents.result.RunResultStreaming.is_complete], [`cancel(...)`][agents.result.RunResultStreaming.cancel] 같은 스트리밍 전용 제어 기능이 추가됩니다. -## 적절한 결과 인터페이스 선택 +## 적절한 결과 인터페이스 선택 {#choose-the-right-result-surface} 대부분의 애플리케이션에는 몇 가지 결과 속성이나 헬퍼만 필요합니다. @@ -28,7 +28,7 @@ search: | 현재 중첩된 `Agent.as_tool()` 호출에 관한 메타데이터 | `agent_tool_invocation` | | 가공되지 않은 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | -## 최종 출력 +## 최종 출력 {#final-output} [`final_output`][agents.result.RunResultBase.final_output] 속성에는 마지막으로 실행된 에이전트의 최종 출력이 포함됩니다. 다음 중 하나입니다. @@ -42,7 +42,7 @@ search: 스트리밍 모드에서는 스트림 처리가 완료될 때까지 `final_output`가 `None`으로 유지됩니다. 이벤트별 흐름은 [스트리밍](streaming.md)을 참조하세요. -## 입력, 다음 턴 기록 및 새 항목 +## 입력, 다음 턴 기록 및 새 항목 {#input-next-turn-history-and-new-items} 다음 인터페이스는 서로 다른 질문에 답합니다. @@ -67,7 +67,7 @@ JavaScript SDK와 달리 Python은 실행 중 새로 생성된 모델 형식 항 컴퓨터 도구 항목을 대화 입력으로 다시 제출할 때는 가공되지 않은 Responses 페이로드 형식을 사용합니다. 프리뷰 모델의 `computer_call` 항목은 단일 `action`을 유지하는 반면, `gpt-5.5` 컴퓨터 호출은 배치된 `actions[]`을 유지할 수 있습니다. [`to_input_list()`][agents.result.RunResultBase.to_input_list] 및 [`RunState`][agents.run_state.RunState]은 모델이 생성한 형식을 그대로 유지하므로 해당 항목을 대화 입력으로 수동 재제출하는 작업, 일시 중지 및 재개 흐름, 저장된 대화 기록이 프리뷰 및 GA 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 계속 `new_items`에서 `computer_call_output` 항목으로 표시됩니다. -### 새 항목 +### 새 항목 {#new-items} [`new_items`][agents.result.RunResultBase.new_items]은 실행 중 발생한 작업을 가장 풍부한 형태로 보여 줍니다. 일반적인 항목 유형은 다음과 같습니다. @@ -110,15 +110,15 @@ caller_id = ( 프로그램이 소유한 하위 호출의 경우 `caller`의 `type` 필드는 `program`이며, `caller_id`은 상위 프로그램 호출을 식별합니다. -## 대화 계속 또는 재개 +## 대화 계속 또는 재개 {#continue-or-resume-the-conversation} -### 다음 턴 에이전트 +### 다음 턴 에이전트 {#next-turn-agent} [`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 핸드오프 후 다음 사용자 턴에 재사용할 에이전트로 적합한 경우가 많습니다. 스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]이 업데이트되므로 스트림이 끝나기 전에 핸드오프를 확인할 수 있습니다. -### 인터럽션(중단 처리) 및 실행 상태 +### 인터럽션(중단 처리) 및 실행 상태 {#interruptions-and-run-state} 도구에 승인이 필요한 경우 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 호출된 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. @@ -139,7 +139,7 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -#### 재개 전 입력 추가 +#### 재개 전 입력 추가 {#add-input-before-resuming} 실행이 일시 중지되거나 완료된 턴 이후 중지된 다음, 완료되지 않은 실행이 다음 모델 호출에 도달하기 전에 새 사용자 입력이 도착하면 [`RunState.add_input()`][agents.run_state.RunState.add_input]을 사용합니다. 문자열은 사용자 메시지가 되며 여러 번 호출하면 삽입 순서가 유지됩니다. 준비된 입력은 직렬화된 `RunState`의 일부이므로 `to_json()` / `from_json()` 및 `to_string()` / `from_string()` 왕복 처리 후에도 유지됩니다. @@ -159,13 +159,13 @@ result = await Runner.run(agent, state) 스트리밍 실행의 경우 먼저 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 소비를 완료한 다음 `result.interruptions`을 검사하고 `result.to_state()`에서 재개합니다. 전체 승인 흐름은 [휴먼인더루프 (HITL)](human_in_the_loop.md)를 참조하세요. -### 서버 관리형 계속 +### 서버 관리형 계속 {#server-managed-continuation} [`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 가장 최근의 모델 응답 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이 ID를 `previous_response_id`으로 다시 전달합니다. 이미 `to_input_list()`, `session` 또는 `conversation_id`을 사용하여 대화를 계속하고 있다면 일반적으로 `last_response_id`은 필요하지 않습니다. 여러 단계로 이루어진 실행의 모든 모델 응답이 필요하면 `raw_responses`을 검사합니다. -## 에이전트 도구 메타데이터 +## 에이전트 도구 메타데이터 {#agent-as-tool-metadata} 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 결과가 나온 경우 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 이를 둘러싼 `Agent.as_tool()` 호출에 관한 변경 불가능한 메타데이터를 제공합니다. @@ -179,7 +179,7 @@ result = await Runner.run(agent, state) 해당 중첩 실행에 대해 파싱된 structured input도 필요한 경우 `context_wrapper.tool_input`을 읽습니다. 이 필드는 [`RunState`][agents.run_state.RunState]이 중첩 도구 입력을 위해 일반적인 방식으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출의 메타데이터를 결과에 직접 노출합니다. -## 스트리밍 수명 주기 및 진단 +## 스트리밍 수명 주기 및 진단 {#streaming-lifecycle-and-diagnostics} [`RunResultStreaming`][agents.result.RunResultStreaming]은 위와 동일한 결과 인터페이스를 상속하지만 다음과 같은 스트리밍 전용 제어 기능이 추가됩니다. @@ -194,7 +194,7 @@ result = await Runner.run(agent, state) Python은 별도의 스트리밍된 `completed` 프로미스나 `error` 속성을 제공하지 않습니다. 실행을 종료시키는 스트리밍 실패는 `stream_events()`에서 발생하며, `is_complete`은 실행이 종료 상태에 도달했는지를 나타냅니다. -### 가공되지 않은 응답 +### 가공되지 않은 응답 {#raw-responses} [`raw_responses`][agents.result.RunResultBase.raw_responses]에는 실행 중 수집된 가공되지 않은 모델 응답이 포함됩니다. 여러 단계로 이루어진 실행에서는 핸드오프 또는 반복되는 모델/도구/모델 주기에 걸쳐 둘 이상의 응답이 생성될 수 있습니다. @@ -207,7 +207,7 @@ Python은 별도의 스트리밍된 `completed` 프로미스나 `error` 속성 `ModelResponse.request_id`과 `ModelResponse.raw_usage`은 각각 `None`일 수 있으므로 이러한 값을 대화 상태가 아닌 선택적 진단 정보로 처리합니다. -### 가드레일 결과 +### 가드레일 결과 {#guardrail-results} 에이전트 수준 가드레일은 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 및 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results]로 제공됩니다. @@ -217,7 +217,7 @@ Python은 별도의 스트리밍된 `completed` 프로미스나 `error` 속성 에이전트 수준 출력 가드레일이 종료 함수 도구에서 직접 생성된 최종 출력을 차단할 때는 하나의 수정 규칙이 적용됩니다. 차단된 현재 응답의 경우 `output_guardrail_results`은 거부된 에이전트 출력을 대체하고 페이로드가 포함된 출력 메타데이터를 지우며, `tool_output_guardrail_results`은 페이로드가 포함된 도구 메타데이터를 대체합니다. 이전에 수락된 결과는 변경되지 않습니다. 정제된 출력 가드레일 결과는 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered]의 `guardrail_result`로 제공됩니다. 정제된 출력 가드레일 및 도구 출력 가드레일 결과는 스트리밍 결과 상태와 `RunState`을 통해서도 제공됩니다. [출력 가드레일](guardrails.md#output-guardrails)을 참조하세요. -### 컨텍스트 및 사용량 +### 컨텍스트 및 사용량 {#context-and-usage} [`context_wrapper`][agents.result.RunResultBase.context_wrapper]은 승인, 사용량, 중첩된 `tool_input` 같은 SDK 관리형 런타임 메타데이터와 함께 애플리케이션 컨텍스트를 제공합니다. diff --git a/docs/ko/running_agents.md b/docs/ko/running_agents.md index 690188856b..248a720ded 100644 --- a/docs/ko/running_agents.md +++ b/docs/ko/running_agents.md @@ -25,9 +25,9 @@ async def main(): 자세한 내용은 [결과 가이드](results.md)를 참조하세요. -## 실행기 수명 주기 및 구성 +## 실행기 수명 주기 및 구성 {#runner-lifecycle-and-configuration} -### 에이전트 루프 +### 에이전트 루프 {#the-agent-loop} 위 세 가지 `Runner` 메서드 중 하나를 호출할 때 시작 에이전트와 입력을 전달합니다. 입력은 다음 중 하나일 수 있습니다. @@ -48,11 +48,11 @@ async def main(): LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 타입의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. -### 스트리밍 +### 스트리밍 {#streaming} 스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트를 추가로 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 비롯한 실행의 전체 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송(선택적 도우미) +#### Responses WebSocket 전송(선택적 도우미) {#responses-websocket-transport-optional-helper} OpenAI Responses websocket 전송을 활성화해도 일반 `Runner` API를 계속 사용할 수 있습니다. 연결을 재사용하려면 websocket 세션 도우미를 사용하는 것이 좋지만 필수는 아닙니다. @@ -60,7 +60,7 @@ OpenAI Responses websocket 전송을 활성화해도 일반 `Runner` API를 계 구체적인 모델 객체 또는 사용자 지정 공급자와 관련된 전송 선택 규칙 및 주의 사항은 [모델](models/index.md#responses-websocket-transport)을 참조하세요. -##### 패턴 1: 세션 도우미 없음(작동함) +##### 패턴 1: 세션 도우미 없음(작동함) {#pattern-1-no-session-helper-works} websocket 전송만 필요하고 SDK가 공유 공급자/세션을 관리할 필요가 없을 때 사용합니다. @@ -87,7 +87,7 @@ asyncio.run(main()) 이 패턴은 단일 실행에 적합합니다. `Runner.run()` / `Runner.run_streamed()`을 반복적으로 호출하면 동일한 `RunConfig` / 공급자 인스턴스를 수동으로 재사용하지 않는 한 실행할 때마다 다시 연결될 수 있습니다. -##### 패턴 2: `responses_websocket_session()` 사용(여러 턴 재사용에 권장) +##### 패턴 2: `responses_websocket_session()` 사용(여러 턴 재사용에 권장) {#pattern-2-use-responses_websocket_session-recommended-for-multi-turn-reuse} 여러 실행에서 websocket을 지원하는 공유 공급자와 `RunConfig`을 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 에이전트 도구 호출도 포함됩니다. @@ -125,15 +125,15 @@ asyncio.run(main()) 긴 추론 턴에서 websocket keepalive 시간 초과가 발생하면 `ping_timeout`를 늘리거나 `ping_timeout=None`으로 설정하여 heartbeat 시간 초과를 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. -### 실행 구성 +### 실행 구성 {#run-config} `run_config` 매개변수를 사용하면 에이전트 실행의 일부 전역 설정을 구성할 수 있습니다. -#### 일반적인 실행 구성 카테고리 +#### 일반적인 실행 구성 카테고리 {#common-run-config-categories} 각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`을 사용하세요. -##### 모델, 공급자 및 세션 기본값 +##### 모델, 공급자 및 세션 기본값 {#model-provider-and-session-defaults} - [`model`][agents.run.RunConfig.model]: 각 에이전트가 어떤 `model`을 갖는지와 관계없이 사용할 전역 LLM 모델을 설정할 수 있습니다. - [`model_provider`][agents.run.RunConfig.model_provider]: 모델 이름을 조회하는 모델 공급자이며 기본값은 OpenAI입니다. @@ -141,7 +141,7 @@ asyncio.run(main()) - [`session_settings`][agents.run.RunConfig.session_settings]: 실행 중 기록을 가져올 때 세션 수준 기본값(예: `SessionSettings(limit=...)`)을 재정의합니다. - [`session_input_callback`][agents.run.RunConfig.session_input_callback]: Sessions를 사용할 때 각 `Runner` 실행 전에 새 사용자 입력이 세션 기록과 병합되는 방식을 사용자 지정합니다. 콜백은 동기 또는 비동기일 수 있습니다. -##### 가드레일, 핸드오프 및 모델 입력 조정 +##### 가드레일, 핸드오프 및 모델 입력 조정 {#guardrails-handoffs-and-model-input-shaping} - [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: 모든 실행에 포함할 입력 또는 출력 가드레일 목록입니다. - [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: 핸드오프에 이미 입력 필터가 없는 경우 모든 핸드오프에 적용할 전역 입력 필터입니다. 입력 필터를 사용하면 새 에이전트로 전송되는 입력을 편집할 수 있습니다. 자세한 내용은 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 문서를 참조하세요. @@ -150,7 +150,7 @@ asyncio.run(main()) - [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]: 모델 호출 직전에 완전히 준비된 모델 입력(instructions 및 입력 항목)을 편집하는 훅입니다. 예를 들어 기록을 잘라내거나 시스템 프롬프트를 삽입할 수 있습니다. - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]: 실행기가 이전 출력을 다음 턴의 모델 입력으로 변환할 때 추론 항목 ID를 보존할지 생략할지 제어합니다. -##### 트레이싱 및 관찰 가능성 +##### 트레이싱 및 관찰 가능성 {#tracing-and-observability} - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: 전체 실행에서 [트레이싱](tracing.md)을 비활성화할 수 있습니다. - [`tracing`][agents.run.RunConfig.tracing]: 실행별 트레이싱 API 키와 같은 트레이스 내보내기 설정을 재정의하려면 [`TracingConfig`][agents.tracing.TracingConfig]을 전달합니다. @@ -158,7 +158,7 @@ asyncio.run(main()) - [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: 실행의 트레이싱 워크플로 이름, 트레이스 ID 및 트레이스 그룹 ID를 설정합니다. 최소한 `workflow_name`는 설정하는 것이 좋습니다. 그룹 ID는 여러 실행의 트레이스를 연결할 수 있는 선택적 필드입니다. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: 모든 트레이스에 포함할 메타데이터입니다. -##### 도구 실행, 승인 및 도구 오류 동작 +##### 도구 실행, 승인 및 도구 오류 동작 {#tool-execution-approval-and-tool-error-behavior} - [`tool_execution`][agents.run.RunConfig.tool_execution]: 한 번에 실행할 로컬 함수 도구 호출 수를 제한하는 등 로컬 도구 호출에 대한 SDK 측 실행 동작을 구성합니다. - [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]: 모델이 생성한 함수 도구 호출의 도구 이름이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않을 때 실행기가 처리하는 방식을 구성합니다. 기본값은 `ModelBehaviorError`을 발생시킵니다. 대신 모델에 표시되는 오류 출력을 반환하도록 옵트인할 수 있습니다. @@ -167,9 +167,9 @@ asyncio.run(main()) 중첩 핸드오프는 옵트인 베타로 제공됩니다. 순차 트랜스크립트 압축을 활성화하려면 `RunConfig(nest_handoff_history=True)`를 전달하거나 특정 핸드오프에 대해 `handoff(..., nest_handoff_history=True)`을 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축소하는 대신 손실 없는 메시지 항목 주위에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 기본값인 raw 트랜스크립트를 유지하려면 플래그를 설정하지 않거나 대화를 필요한 형태 그대로 전달하는 `handoff_input_filter`(또는 `handoff_history_mapper`)를 제공하세요. 사용자 지정 매퍼를 작성하지 않고 생성된 요약 세그먼트에 사용되는 래퍼 텍스트를 변경하려면 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출하세요. 기본값을 복원하려면 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출하세요. -#### 실행 구성 세부 정보 +#### 실행 구성 세부 정보 {#run-config-details} -##### `tool_execution` +##### `tool_execution` {#tool_execution} 실행 시 로컬 함수 도구의 동시 실행 수를 제한하는 등 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`를 사용하세요. @@ -196,7 +196,7 @@ result = await Runner.run( `pre_approval_tool_input_guardrails=False`는 기본 승인 흐름을 유지합니다. 함수 도구에 승인이 필요한 경우 실행이 먼저 일시 중지되며 도구 입력 가드레일은 승인 후 실행 직전에만 동작합니다. 대기 중인 승인 인터럽션(중단 처리)이 발생하기 전에 함수 도구 입력 가드레일을 실행하려면 `True`로 설정하세요. 이 사전 승인 검사를 통과한 호출도 승인 후 동일한 입력 가드레일을 다시 실행하므로, 시간에 민감한 검사가 실행 전에 다시 검증됩니다. -##### `tool_not_found_behavior` +##### `tool_not_found_behavior` {#tool_not_found_behavior} 기본적으로 모델이 현재 에이전트에서 사용할 수 있는 어떤 함수 도구와도 일치하지 않는 함수 도구 호출을 생성하면 실행기는 `ModelBehaviorError`을 발생시킵니다. @@ -216,7 +216,7 @@ result = await Runner.run( 현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 밖의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. -##### `tool_error_formatter` +##### `tool_error_formatter` {#tool_error_formatter} SDK가 모델에 표시되는 도구 오류 출력을 생성할 때 모델에 반환되는 메시지를 사용자 지정하려면 `tool_error_formatter`을 사용하세요. @@ -254,7 +254,7 @@ result = Runner.run_sync( ) ``` -##### `reasoning_item_id_policy` +##### `reasoning_item_id_policy` {#reasoning_item_id_policy} `reasoning_item_id_policy`은 실행기가 기록을 다음 턴으로 전달할 때(예: `RunResult.to_input_list()` 또는 세션 기반 실행을 사용할 때) 추론 항목을 다음 턴의 모델 입력으로 변환하는 방식을 제어합니다. @@ -273,9 +273,9 @@ SDK가 이전 출력에서 후속 입력을 구성하고(세션 영속성, 서 - 사용자가 제공한 초기 입력 항목은 다시 작성하지 않습니다. - 이 정책이 적용된 후에도 `call_model_input_filter`에서 의도적으로 추론 ID를 다시 추가할 수 있습니다. -## 상태 및 대화 관리 +## 상태 및 대화 관리 {#state-and-conversation-management} -### 메모리 전략 선택 +### 메모리 전략 선택 {#choose-a-memory-strategy} 다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. @@ -294,7 +294,7 @@ SDK가 이전 출력에서 후속 입력을 구성하고(세션 영속성, 서 (`conversation_id`, `previous_response_id` 또는 `auto_previous_response_id`)을 함께 사용할 수 없습니다. 호출마다 한 가지 방식을 선택하세요. -### 대화/채팅 스레드 +### 대화/채팅 스레드 {#conversationschat-threads} 실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며 이에 따라 하나 이상의 LLM 호출이 발생할 수 있지만, 채팅 대화에서는 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. @@ -303,7 +303,7 @@ SDK가 이전 출력에서 후속 입력을 구성하고(세션 영속성, 서 에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 표시하거나 최종 출력만 표시할 수 있습니다. 어떤 경우든 사용자가 후속 질문을 할 수 있으며, 이때 실행 메서드를 다시 호출할 수 있습니다. -#### 수동 대화 관리 +#### 수동 대화 관리 {#manual-conversation-management} [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 메서드로 다음 턴의 입력을 가져와 대화 기록을 수동으로 관리할 수 있습니다. @@ -327,7 +327,7 @@ async def main(): # California ``` -#### 세션을 사용한 자동 대화 관리 +#### 세션을 사용한 자동 대화 관리 {#automatic-conversation-management-with-sessions} 더 간단한 방법으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 수동으로 호출하지 않고도 대화 기록을 자동으로 처리할 수 있습니다. @@ -362,13 +362,13 @@ Sessions는 다음 작업을 자동으로 수행합니다. 자세한 내용은 [Sessions 문서](sessions/index.md)를 참조하세요. -#### 서버 관리형 대화 +#### 서버 관리형 대화 {#server-managed-conversations} `to_input_list()` 또는 `Sessions`로 로컬에서 처리하는 대신 OpenAI 대화 상태 기능이 서버 측에서 대화 상태를 관리하도록 할 수도 있습니다. 이를 통해 이전의 모든 메시지를 수동으로 다시 전송하지 않고도 대화 기록을 보존할 수 있습니다. 아래 서버 관리형 방식 중 하나를 사용할 때는 요청마다 새 턴의 입력만 전달하고 저장된 ID를 재사용하세요. 자세한 내용은 [OpenAI 대화 상태 가이드](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)를 참조하세요. OpenAI는 여러 턴에 걸쳐 상태를 추적하는 두 가지 방법을 제공합니다. -##### 1. `conversation_id` 사용 +##### 1. `conversation_id` 사용 {#1-using-conversation_id} 먼저 OpenAI Conversations API로 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. @@ -391,7 +391,7 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -##### 2. `previous_response_id` 사용 +##### 2. `previous_response_id` 사용 {#2-using-previous_response_id} 또 다른 옵션은 각 턴을 이전 턴의 응답 ID에 명시적으로 연결하는 **응답 체이닝**입니다. @@ -435,9 +435,9 @@ async def main(): 이 호환성 재시도는 `ModelSettings.retry`를 구성하지 않아도 수행됩니다. 모델 요청에 대한 더 광범위한 옵트인 재시도 동작은 [실행기 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. -## 훅 및 사용자 지정 +## 훅 및 사용자 지정 {#hooks-and-customization} -### 모델 호출 입력 필터 +### 모델 호출 입력 필터 {#call-model-input-filter} 모델 호출 직전에 모델 입력을 편집하려면 `call_model_input_filter`을 사용하세요. 훅은 현재 에이전트, 컨텍스트 및 결합된 입력 항목(있는 경우 세션 기록 포함)을 수신하고 새 `ModelInputData`를 반환합니다. @@ -468,9 +468,9 @@ result = Runner.run_sync( 민감한 데이터를 수정하거나, 긴 기록을 잘라내거나, 추가 시스템 지침을 삽입하려면 `run_config`을 통해 실행별로 훅을 설정하세요. -## 오류 및 복구 +## 오류 및 복구 {#errors-and-recovery} -### 오류 처리기 +### 오류 처리기 {#error-handlers} 모든 `Runner` 진입점은 오류 종류를 키로 사용하는 dict인 `error_handlers`를 허용합니다. 지원되는 키는 `"max_turns"`, `"model_refusal"`, `"invalid_final_output"`입니다. 해당 오류로 실행을 종료하는 대신 제어된 최종 출력을 반환하려면 이를 사용하세요. @@ -567,27 +567,27 @@ result = Runner.run_sync( print(result.final_output) ``` -## 내구성 실행 통합 및 휴먼인더루프 (HITL) +## 내구성 실행 통합 및 휴먼인더루프 (HITL) {#durable-execution-integrations-and-human-in-the-loop} 도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 가이드](human_in_the_loop.md)에서 시작하세요. 아래 통합은 실행이 오랜 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 있는 오케스트레이션을 위한 것입니다. -### Dapr +### Dapr {#dapr} Agents SDK [Dapr](https://dapr.io) Diagrid 통합을 사용하면 장애에서 자동으로 복구되고 휴먼인더루프 (HITL) 워크플로를 지원하는 내구성 있는 장기 실행 에이전트를 실행할 수 있습니다. Dapr는 공급업체 중립적인 [CNCF](https://cncf.io) 워크플로 오케스트레이터입니다. Dapr와 OpenAI 에이전트는 [여기](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)에서 시작할 수 있습니다. -### Temporal +### Temporal {#temporal} Agents SDK [Temporal](https://temporal.io/) 통합을 사용하면 휴먼인더루프 (HITL) 작업을 포함한 내구성 있는 장기 실행 워크플로를 실행할 수 있습니다. 장기 실행 작업을 완료하기 위해 Temporal과 Agents SDK가 함께 작동하는 데모는 [이 동영상](https://www.youtube.com/watch?v=fFBZqzT4DD8)에서 확인하고, [문서는 여기](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)에서 확인하세요. -### Restate +### Restate {#restate} Agents SDK [Restate](https://restate.dev/) 통합을 사용하면 사람의 승인, 핸드오프 및 세션 관리를 포함한 경량의 내구성 있는 에이전트를 구현할 수 있습니다. 이 통합은 Restate의 단일 바이너리 런타임을 종속성으로 요구하며, 에이전트를 프로세스/컨테이너 또는 서버리스 함수로 실행할 수 있도록 지원합니다. 자세한 내용은 [개요](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)를 읽거나 [문서](https://docs.restate.dev/ai)를 참조하세요. -### DBOS +### DBOS {#dbos} Agents SDK [DBOS](https://dbos.dev/) 통합을 사용하면 장애 및 재시작 이후에도 진행 상황을 보존하는 신뢰할 수 있는 에이전트를 실행할 수 있습니다. 장기 실행 에이전트, 휴먼인더루프 (HITL) 워크플로 및 핸드오프를 지원합니다. 동기 및 비동기 메서드를 모두 지원합니다. 이 통합에는 SQLite 또는 Postgres 데이터베이스만 필요합니다. 자세한 내용은 통합 [리포지토리](https://github.com/dbos-inc/dbos-openai-agents)와 [문서](https://docs.dbos.dev/integrations/openai-agents)를 참조하세요. -## 예외 +## 예외 {#exceptions} SDK는 특정 상황에서 예외를 발생시킵니다. 전체 목록은 [`agents.exceptions`][]에서 확인할 수 있습니다. 개요는 다음과 같습니다. diff --git a/docs/ko/sandbox/clients.md b/docs/ko/sandbox/clients.md index db4f3c330f..8f96ccf128 100644 --- a/docs/ko/sandbox/clients.md +++ b/docs/ko/sandbox/clients.md @@ -10,7 +10,7 @@ search: 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 예정입니다. -## 의사 결정 가이드 +## 의사 결정 가이드 {#decision-guide}
@@ -22,7 +22,7 @@ search:
-## 로컬 클라이언트 +## 로컬 클라이언트 {#local-clients} 대부분의 사용자는 다음 두 샌드박스 클라이언트 중 하나로 시작하는 것이 좋습니다. @@ -58,7 +58,7 @@ run_config = RunConfig( 컨테이너 격리가 필요하거나 샌드박스 이미지를 다른 환경에서 사용하는 이미지와 일치시키려는 경우 이 방법을 사용합니다. [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. -### Docker 네트워킹 비활성화 +### Docker 네트워킹 비활성화 {#disable-docker-networking} Docker 샌드박스에서 네트워크 액세스를 차단해야 하는 경우 `network_mode="none"`을 설정합니다. @@ -71,7 +71,7 @@ options = DockerSandboxClientOptions( 명시적으로 지원되는 유일한 네트워크 모드는 `"none"`입니다. Docker의 기본 동작을 유지하려면 `network_mode`을 생략합니다. 네트워크가 비활성화된 샌드박스는 포트를 노출할 수 없으므로 `network_mode="none"`과 비어 있지 않은 `exposed_ports` 튜플을 함께 사용하면 옵션 검증 중 실패합니다. 이 설정은 샌드박스 세션 상태에 저장되며, SDK가 해당 상태를 재개하는 동안 대체 컨테이너를 생성해야 하는 경우 다시 적용됩니다. -## 마운트 및 원격 스토리지 +## 마운트 및 원격 스토리지 {#mounts-and-remote-storage} 마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 설명합니다. 기본 제공 마운트 항목과 범용 전략은 `agents.sandbox.entries`에서 가져옵니다. 호스티드 공급자 전략은 `agents.extensions.sandbox` 또는 공급자별 확장 패키지에서 사용할 수 있습니다. @@ -97,7 +97,7 @@ options = DockerSandboxClientOptions( -## 지원되는 호스티드 플랫폼 +## 지원되는 호스티드 플랫폼 {#supported-hosted-platforms} 호스티드 환경이 필요한 경우 일반적으로 동일한 `SandboxAgent` 정의를 그대로 사용하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트만 변경합니다. @@ -119,7 +119,7 @@ options = DockerSandboxClientOptions( -### Modal 샌드박스 크기 지정 +### Modal 샌드박스 크기 지정 {#size-modal-sandboxes} 새 Modal 샌드박스의 리소스를 요청하려면 `ModalSandboxClientOptions.cpu`와 `ModalSandboxClientOptions.memory`를 사용합니다. 단일 값은 해당 양을 요청합니다. 항목이 두 개인 `(request, limit)` 튜플에서는 첫 번째 항목을 요청값으로, 두 번째 항목을 제한값으로 사용합니다. 메모리 값의 단위는 MiB입니다. diff --git a/docs/ko/sandbox/guide.md b/docs/ko/sandbox/guide.md index f600f5ff8e..153a068c0a 100644 --- a/docs/ko/sandbox/guide.md +++ b/docs/ko/sandbox/guide.md @@ -32,7 +32,7 @@ search: 외부 런타임은 계속해서 승인, 트레이싱, 핸드오프와 실행 재개에 필요한 상태 추적을 담당합니다. 샌드박스 세션은 명령, 파일 변경, 환경 격리를 담당합니다. 이러한 역할 분리는 모델의 핵심 요소입니다. -### 구성 요소 간의 관계 +### 구성 요소 간의 관계 {#how-the-pieces-fit-together} 샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 활성 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. @@ -60,7 +60,7 @@ flowchart LR 셸 액세스가 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](../tools.md)의 호스티드 셸부터 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. -## 사용 시점 +## 사용 시점 {#when-to-use-them} 샌드박스 에이전트는 다음과 같은 워크스페이스 중심 워크플로에 적합합니다. @@ -72,13 +72,13 @@ flowchart LR 파일 또는 상태를 유지하며 변경 가능한 파일 시스템에 액세스할 필요가 없다면 계속 `Agent` 을 사용하세요. 셸 액세스가 가끔 필요한 기능일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. -## 샌드박스 클라이언트 선택 +## 샌드박스 클라이언트 선택 {#choose-a-sandbox-client} macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient` 로 시작하세요. Windows에서는 `DockerSandboxClient` 또는 호스티드 공급자를 사용하세요. 지원되는 모든 플랫폼에서 컨테이너 격리나 이미지 동등성이 필요하면 `DockerSandboxClient` 로 전환하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자로 전환하세요. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에서 샌드박스 클라이언트와 해당 옵션만 변경합니다. 로컬, Docker, 호스티드, 원격 마운트 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. -## 핵심 구성 요소 +## 핵심 구성 요소 {#core-pieces}
@@ -113,7 +113,7 @@ macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient` 3. 기본 제공 또는 사용자 지정 기능을 추가합니다. 4. `RunConfig(sandbox=SandboxRunConfig(...))` 에서 각 실행이 샌드박스 세션을 가져오는 방법을 결정합니다. -## 샌드박스 실행 준비 과정 +## 샌드박스 실행 준비 과정 {#how-a-sandbox-run-is-prepared} 실행 시 러너는 해당 정의를 구체적인 샌드박스 기반 실행으로 변환합니다. @@ -127,7 +127,7 @@ macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient` 이러한 준비 단계 때문에 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as` 은 `SandboxAgent` 을 설계할 때 고려해야 할 주요 샌드박스별 옵션입니다. -## `SandboxAgent` 옵션 +## `SandboxAgent` 옵션 {#sandboxagent-options} 일반적인 `Agent` 필드에 추가되는 샌드박스별 옵션은 다음과 같습니다. @@ -145,13 +145,13 @@ macOS 또는 Linux에서 로컬 개발을 할 때는 `UnixLocalSandboxClient` 샌드박스 클라이언트 선택, 샌드박스 세션 재사용, 매니페스트 재정의, 스냅샷 선택은 에이전트가 아니라 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 에 속합니다. -### `default_manifest` +### `default_manifest` {#default_manifest} `default_manifest` 는 러너가 이 에이전트의 새 샌드박스 세션을 생성할 때 사용하는 기본 [`Manifest`][agents.sandbox.manifest.Manifest] 입니다. 에이전트가 일반적으로 시작할 때 필요한 파일, 저장소, 보조 자료, 출력 디렉터리, 마운트에 사용하세요. 이는 기본값일 뿐입니다. 실행에서 `SandboxRunConfig(manifest=...)` 으로 재정의할 수 있으며, 재사용되거나 재개된 샌드박스 세션은 기존 워크스페이스 상태를 유지합니다. -### `instructions` 및 `base_instructions` +### `instructions` 및 `base_instructions` {#instructions-and-base_instructions} 다른 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions` 을 사용하세요. `SandboxAgent` 에서 이러한 instructions는 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로, 성공 기준을 추가할 수 있습니다. @@ -179,7 +179,7 @@ SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instr `instructions` 을 생략해도 SDK는 기본 샌드박스 프롬프트를 포함합니다. 저수준 래퍼에는 이것으로 충분하지만, 대부분의 사용자 대상 에이전트는 여전히 명시적인 `instructions` 을 제공해야 합니다. -### `capabilities` +### `capabilities` {#capabilities} 기능은 샌드박스 네이티브 동작을 `SandboxAgent` 에 연결합니다. 실행이 시작되기 전에 워크스페이스를 구성하고, 샌드박스별 instructions를 추가하고, 활성 샌드박스 세션에 바인딩되는 도구를 노출하며, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. @@ -214,9 +214,9 @@ SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instr 요구 사항에 맞는다면 기본 제공 기능을 우선 사용하세요. 기본 제공 기능이 지원하지 않는 샌드박스별 도구 또는 instructions 구성 요소가 필요한 경우에만 사용자 지정 기능을 작성하세요. -## 개념 +## 개념 {#concepts_1} -### 매니페스트 +### 매니페스트 {#manifest} [`Manifest`][agents.sandbox.manifest.Manifest] 는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root` 설정, 파일 및 디렉터리 선언, 로컬 파일 복사, Git 저장소 복제, 원격 스토리지 마운트 연결, 환경 변수 설정, 사용자 또는 그룹 정의, 워크스페이스 외부의 특정 절대 경로에 대한 액세스 허용을 지원합니다. @@ -262,7 +262,7 @@ Docker가 컨테이너 내부의 절대 POSIX `path` 에 다른 절대 호스트 스냅샷과 `persist_workspace()` 에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스이며, 영구 워크스페이스 상태가 아닙니다. -### 권한 +### 권한 {#permissions} `Permissions` 는 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스가 구체화하는 파일에 관한 것이며, 모델 권한, 승인 정책 또는 API 자격 증명에 관한 것이 아닙니다. @@ -338,7 +338,7 @@ result = await Runner.run( 파일 수준 공유 규칙도 필요한 경우 사용자를 매니페스트 그룹 및 항목의 `group` 메타데이터와 결합하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하며, `Permissions` 은 샌드박스가 워크스페이스를 구체화한 후 해당 사용자가 읽고 쓰고 실행할 수 있는 파일을 제어합니다. -### SnapshotSpec +### SnapshotSpec {#snapshotspec} `SnapshotSpec` 는 저장된 워크스페이스 콘텐츠를 새 샌드박스 세션이 복원할 위치와 다시 저장할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state` 는 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. @@ -363,7 +363,7 @@ run_config = RunConfig( `snapshot` 을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 이를 설정할 수 없으면 무작동 스냅샷으로 대체합니다. 마운트된 경로와 임시 경로는 영구 워크스페이스 콘텐츠로 스냅샷에 복사되지 않습니다. -### 샌드박스 수명 주기 +### 샌드박스 수명 주기 {#sandbox-lifecycle} 수명 주기 모드는 **SDK 소유**와 **개발자 소유** 두 가지입니다. @@ -439,11 +439,11 @@ finally: `stop()` 는 스냅샷 기반 워크스페이스 콘텐츠만 저장하며 샌드박스를 종료하지 않습니다. `aclose()` 는 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()` 을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. -## `SandboxRunConfig` 옵션 +## `SandboxRunConfig` 옵션 {#sandboxrunconfig-options} [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 는 샌드박스 세션의 출처와 새 세션의 초기화 방법을 결정하는 실행별 옵션을 보유합니다. -### 샌드박스 소스 +### 샌드박스 소스 {#sandbox-source} 다음 옵션은 러너가 샌드박스 세션을 재사용, 재개 또는 생성할지 결정합니다. @@ -464,7 +464,7 @@ finally: 3. 그렇지 않고 `run_config.sandbox.session_state` 을 전달하면 명시적으로 직렬화된 해당 샌드박스 세션 상태에서 재개합니다. 4. 그렇지 않으면 새 샌드박스 세션을 생성합니다. 이 새 세션에는 제공된 경우 `run_config.sandbox.manifest` 을 사용하고, 제공되지 않은 경우 `agent.default_manifest` 을 사용합니다. -### 새 세션 입력 +### 새 세션 입력 {#fresh-session-inputs} 다음 옵션은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. @@ -478,7 +478,7 @@ finally:
-### 모델 대상 작업 디렉터리 +### 모델 대상 작업 디렉터리 {#model-facing-working-directory} 여러 실행에서 하나의 샌드박스 세션을 공유하면서 서로 다른 하위 디렉터리에서 작업해야 할 때 POSIX 워크스페이스 상대 디렉터리로 `cwd` 을 설정하세요. 러너가 `cwd` 을 검증할 때 해당 디렉터리가 존재하고 구성된 샌드박스 사용자가 액세스할 수 있어야 합니다. 새 세션의 경우 러너가 먼저 매니페스트를 구체화하므로 이 검증 전에 매니페스트가 디렉터리를 생성할 수 있습니다. @@ -503,7 +503,7 @@ result = await Runner.run( 경로를 포함하는 사용자 지정 기능은 모델이 제공한 상대 경로를 해석할 때 바인딩된 [`SandboxWorkspaceScope`][agents.sandbox.workspace_paths.SandboxWorkspaceScope] 을 적용해야 합니다. 하나의 샌드박스 세션을 공유하면서 모델 대상 작업 디렉터리를 분리하는 두 개의 동시 실행은 [examples/sandbox/shared_session_workdirs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/shared_session_workdirs.py)를 참조하세요. -### 구체화 제어 +### 구체화 제어 {#materialization-controls} `concurrency_limits` 은 병렬로 실행할 수 있는 샌드박스 구체화 작업의 양을 제어합니다. 대규모 매니페스트 또는 로컬 디렉터리 복사에 더 엄격한 리소스 제어가 필요하면 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` 를 사용하세요. 특정 제한을 비활성화하려면 해당 값을 `None` 으로 설정하세요. @@ -517,7 +517,7 @@ result = await Runner.run( - 주입된 활성 세션: 실행 중인 샌드박스 `session` 을 전달하면 기능 기반 매니페스트 업데이트에서 호환되는 비마운트 항목을 추가할 수 있습니다. `manifest.root`, `manifest.environment`, `manifest.users`, `manifest.groups` 를 변경하거나, 기존 항목을 제거하거나, 항목 유형을 교체하거나, 마운트 항목을 추가 또는 변경할 수는 없습니다. - 러너 API: `SandboxAgent` 실행은 계속 일반적인 `Runner.run()`, `Runner.run_sync()`, `Runner.run_streamed()` API를 사용합니다. -## 전체 예제: 코딩 작업 +## 전체 예제: 코딩 작업 {#full-example-coding-task} 다음 코딩 스타일 예제는 기본 시작점으로 적합합니다. @@ -600,15 +600,15 @@ if __name__ == "__main__": [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참조하세요. 이 예제는 Unix 로컬 실행에서 결정론적으로 검증할 수 있도록 작은 셸 기반 저장소를 사용합니다. 실제 작업 저장소는 물론 Python, JavaScript 또는 다른 무엇이든 사용할 수 있습니다. -## 일반적인 패턴 +## 일반적인 패턴 {#common-patterns} 위의 전체 예제에서 시작하세요. 많은 경우 동일한 `SandboxAgent` 을 그대로 유지하면서 샌드박스 클라이언트, 샌드박스 세션 소스 또는 워크스페이스 소스만 변경할 수 있습니다. -### 샌드박스 클라이언트 전환 +### 샌드박스 클라이언트 전환 {#switch-sandbox-clients} 에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 동등성이 필요하면 Docker를 사용하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자를 사용하세요. 예제와 공급자 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. -### 워크스페이스 재정의 +### 워크스페이스 재정의 {#override-the-workspace} 에이전트 정의는 그대로 유지하고 새 세션 매니페스트만 교체하세요. @@ -632,7 +632,7 @@ run_config = RunConfig( 에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 여러 저장소, 패킷 또는 작업 번들에 실행하려면 이를 사용하세요. 위의 검증된 코딩 예제는 일회성 재정의 대신 `default_manifest` 을 사용하는 동일한 패턴을 보여 줍니다. -### 샌드박스 세션 주입 +### 샌드박스 세션 주입 {#inject-a-sandbox-session} 명시적인 수명 주기 제어, 실행 후 검사 또는 출력 복사가 필요하면 활성 샌드박스 세션을 주입하세요. @@ -657,7 +657,7 @@ async with sandbox: 실행 후 워크스페이스를 검사하거나 이미 시작된 샌드박스 세션에서 스트리밍하려면 이를 사용하세요. [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 및 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)를 참조하세요. -### 세션 상태에서 재개 +### 세션 상태에서 재개 {#resume-from-session-state} `RunState` 외부에서 샌드박스 상태를 이미 직렬화했다면 러너가 해당 상태에서 다시 연결하도록 하세요. @@ -682,7 +682,7 @@ run_config = RunConfig( 세션 상태 및 `RunState` 직렬화에서는 클라우드 마운트 자격 증명, 자격 증명을 포함하는 보조 구성, 컨테이너 내부 자격 증명 노출 승인도 제거됩니다. 마운트된 세션 재개를 지원하는 백엔드의 경우 상태에 삭제된 마운트 권한 정보가 포함되어 있다면 현재 신뢰할 수 있는 매니페스트를 `SandboxRunConfig.manifest` 또는 `agent.default_manifest` 를 통해 제공하세요. 이름이 `"data"` 인 마운트 항목에 마운트 범위 승인이 필요하면 재개 전에 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` 을 사용하여 복사된 매니페스트를 유지하세요. 광범위한 권한에는 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")` 를 사용하고, 마운트에서 두 권한 클래스를 모두 사용하는 경우 두 메서드를 모두 호출하세요. 승인이 필요한 모든 정확한 마운트 경로를 전달하세요. Agents SDK는 현재 신뢰할 수 있는 매니페스트의 자격 증명 없는 마운트 토폴로지가 저장된 상태와 정확히 일치하는 경우에만 자격 증명을 복원합니다. 신뢰할 수 있는 구성이 없거나 일치하지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 상태 자체로는 절대 권한이 부여되지 않습니다. `VercelSandboxClient` 은 마운트된 세션을 재개할 수 없으므로 신뢰할 수 있는 매니페스트로 새 샌드박스를 시작하세요. -### 스냅샷에서 시작 +### 스냅샷에서 시작 {#start-from-a-snapshot} 저장된 파일과 아티팩트로 새 샌드박스를 초기화하세요. @@ -703,7 +703,7 @@ run_config = RunConfig( 새 샌드박스 세션을 생성하는 실행이 `agent.default_manifest` 만 사용하는 대신 저장된 워크스페이스 콘텐츠에서 시작해야 할 때 이를 사용하세요. 로컬 스냅샷 흐름은 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를, 원격 스냅샷 클라이언트는 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)를 참조하세요. -### Git에서 스킬 로드 +### Git에서 스킬 로드 {#load-skills-from-git} 로컬 스킬 소스를 저장소 기반 소스로 교체하세요. @@ -718,7 +718,7 @@ capabilities = Capabilities.default() + [ 스킬 번들에 자체 릴리스 주기가 있거나 여러 샌드박스에서 공유해야 할 때 이를 사용하세요. [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)를 참조하세요. -### 도구로 노출 +### 도구로 노출 {#expose-as-tools} 도구 에이전트는 자체 샌드박스 경계를 사용하거나 상위 실행의 활성 샌드박스를 재사용할 수 있습니다. 재사용은 빠른 읽기 전용 탐색 에이전트에 유용합니다. 다른 샌드박스를 생성하거나, 초기화하거나, 스냅샷하는 비용 없이 상위 실행이 사용하는 정확한 워크스페이스를 검사할 수 있습니다. @@ -832,7 +832,7 @@ rollout_agent.as_tool( 도구 에이전트가 자유롭게 변경하거나, 신뢰할 수 없는 명령을 실행하거나, 다른 백엔드/이미지를 사용해야 할 때 별도의 샌드박스를 사용하세요. [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)를 참조하세요. -### 로컬 도구 및 MCP와의 결합 +### 로컬 도구 및 MCP와의 결합 {#combine-with-local-tools-and-mcp} 동일한 에이전트에서 일반 도구를 계속 사용하면서 샌드박스 워크스페이스를 유지하세요. @@ -851,13 +851,13 @@ agent = SandboxAgent( 워크스페이스 검사가 에이전트 작업의 일부일 뿐일 때 이를 사용하세요. [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)를 참조하세요. -## 메모리 +## 메모리 {#memory} 이후 샌드박스 에이전트 실행이 이전 실행에서 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제한 다음 이후 실행에서 해당 파일을 읽을 수 있습니다. 설정, 읽기/생성 동작, 멀티턴 대화, 레이아웃 격리는 [에이전트 메모리](memory.md)를 참조하세요. -## 구성 패턴 +## 구성 패턴 {#composition-patterns} 단일 에이전트 패턴을 이해한 다음에는 더 큰 시스템에서 샌드박스 경계를 어디에 둘지 결정해야 합니다. @@ -873,7 +873,7 @@ agent = SandboxAgent( - 샌드박스를 사용하지 않는 에이전트가 워크스페이스 격리가 필요한 워크플로 부분만 샌드박스 에이전트로 핸드오프 - 오케스트레이터가 여러 샌드박스 에이전트를 도구로 노출하며, 일반적으로 각 `Agent.as_tool(...)` 호출마다 별도의 샌드박스 `RunConfig` 을 사용해 각 도구에 자체 격리 워크스페이스 제공 -### 턴과 샌드박스 실행 +### 턴과 샌드박스 실행 {#turns-and-sandbox-runs} 핸드오프와 에이전트 도구 호출을 별도로 설명하면 이해하기 쉽습니다. @@ -886,7 +886,7 @@ agent = SandboxAgent( - 핸드오프에서는 샌드박스 에이전트가 해당 실행의 활성 에이전트가 되므로 승인이 동일한 최상위 실행에 유지됩니다. - `Agent.as_tool(...)` 에서는 샌드박스 도구 에이전트 내부에서 발생한 승인이 외부 실행에 계속 표시되지만, 저장된 중첩 실행 상태에서 제공되며 외부 실행이 재개될 때 중첩 샌드박스 실행을 재개합니다. -## 추가 자료 +## 추가 자료 {#further-reading} - [빠른 시작](../sandbox_agents.md): 샌드박스 에이전트 하나를 실행합니다. - [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드, 마운트 옵션을 선택합니다. diff --git a/docs/ko/sandbox/memory.md b/docs/ko/sandbox/memory.md index eeeedbff3f..c841b2ad48 100644 --- a/docs/ko/sandbox/memory.md +++ b/docs/ko/sandbox/memory.md @@ -18,7 +18,7 @@ search: 버그를 수정하고, 메모리를 생성하고, 스냅샷을 재개하고, 후속 검증 실행에서 해당 메모리를 사용하는 완전한 2회 실행 예제는 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)를 참고하세요. 메모리 레이아웃을 분리한 멀티턴 및 멀티 에이전트 예제는 [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py)를 참고하세요. -## 메모리 활성화 +## 메모리 활성화 {#enable-memory} 샌드박스 에이전트에 `Memory()`을 기능으로 추가합니다. @@ -48,7 +48,7 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d `Memory()`은 메모리 읽기와 생성을 모두 활성화합니다. 내부 에이전트, 하위 에이전트, 검사기 또는 일회성 도구 에이전트의 실행처럼 새로운 신호를 크게 추가하지 않는 실행에서 메모리를 읽되 새 메모리는 생성하지 않아야 하는 에이전트에는 `Memory(generate=None)`을 사용하세요. 이후 사용할 메모리는 생성해야 하지만 사용자가 기존 메모리의 영향을 받지 않기를 원하는 실행에는 `Memory(read=None)`을 사용하세요. -## 메모리 읽기 +## 메모리 읽기 {#read-memory} 메모리 읽기에는 점진적 공개 방식이 사용됩니다. 실행이 시작될 때 SDK는 일반적으로 유용한 팁, 사용자 선호 사항 및 사용 가능한 메모리의 간단한 요약(`memory_summary.md`)을 에이전트의 개발자 프롬프트에 주입합니다. 이를 통해 에이전트는 이전 작업이 관련될 수 있는지 판단하기에 충분한 컨텍스트를 얻습니다. @@ -56,7 +56,7 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d 메모리는 오래되어 현재 상태와 맞지 않을 수 있습니다. 에이전트는 메모리를 지침으로만 활용하고 현재 환경을 신뢰하도록 지시받습니다. 기본적으로 메모리 읽기에는 `live_update`이 활성화되어 있으므로, 에이전트가 오래된 메모리를 발견하면 같은 실행에서 구성된 `MEMORY.md`을 업데이트할 수 있습니다. 에이전트가 메모리를 읽되 실행 중에는 수정하지 않아야 하는 경우(예: 지연 시간에 민감한 실행) 실시간 업데이트를 비활성화하세요. -## 메모리 생성 +## 메모리 생성 {#generate-memory} 실행이 끝나면 샌드박스 런타임이 해당 실행 구간을 대화 파일에 추가합니다. 누적된 대화 파일은 샌드박스 세션이 종료될 때 처리됩니다. @@ -101,7 +101,7 @@ GTM 에이전트에서 고객 및 회사 세부 정보처럼 사용 사례에 최근 raw 메모리 수가 `max_raw_memories_for_consolidation`(기본값 256)을 초과하면 2단계에서는 가장 최근 대화의 메모리만 유지하고 오래된 메모리는 제거합니다. 최신성은 대화가 마지막으로 업데이트된 시간을 기준으로 결정됩니다. 이 망각 메커니즘은 메모리가 최신 환경을 반영하도록 지원합니다. -## 멀티턴 대화 +## 멀티턴 대화 {#multi-turn-conversations} 멀티턴 샌드박스 채팅에서는 동일한 실시간 샌드박스 세션과 함께 일반 SDK `Session`을 사용하세요. @@ -141,7 +141,7 @@ async with sandbox: 3. 위 항목이 모두 없는 경우의 `RunConfig.group_id` 4. 안정적인 식별자가 없는 경우 실행별로 생성되는 ID -## 에이전트별 메모리 격리를 위한 서로 다른 레이아웃 사용 +## 에이전트별 메모리 격리를 위한 서로 다른 레이아웃 사용 {#use-different-layouts-to-isolate-memory-for-different-agents} 메모리 격리는 에이전트 이름이 아니라 `MemoryLayoutConfig`을 기준으로 합니다. 레이아웃과 메모리 대화 ID가 같은 에이전트는 하나의 메모리 대화와 통합 메모리를 공유합니다. 레이아웃이 다른 에이전트는 같은 샌드박스 워크스페이스를 공유하더라도 롤아웃 파일, raw 메모리, `MEMORY.md` 및 `memory_summary.md`을 별도로 유지합니다. diff --git a/docs/ko/sandbox_agents.md b/docs/ko/sandbox_agents.md index 8d562c10d9..9d23147ce9 100644 --- a/docs/ko/sandbox_agents.md +++ b/docs/ko/sandbox_agents.md @@ -12,13 +12,13 @@ search: SDK는 파일 스테이징, 파일 시스템 도구, 셸 액세스, 샌드박스 수명 주기, 스냅샷, 제공업체별 연동 코드를 직접 연결하지 않아도 이러한 실행 하네스를 제공합니다. 기존 `Agent` 및 `Runner` 흐름을 유지하면서 작업 공간용 `Manifest`, 샌드박스 네이티브 도구의 기능, 작업이 실행될 위치를 지정하는 `SandboxRunConfig`을 추가하면 됩니다. -## 사전 요구 사항 +## 사전 요구 사항 {#prerequisites} - Python 3.10 이상 - OpenAI Agents SDK에 대한 기본 지식 - 샌드박스 클라이언트. 로컬 개발에서는 `UnixLocalSandboxClient`로 시작 -## 설치 +## 설치 {#installation} 아직 SDK를 설치하지 않았다면 다음을 실행합니다. @@ -32,7 +32,7 @@ Docker 기반 샌드박스의 경우: pip install "openai-agents[docker]" ``` -## 로컬 샌드박스 에이전트 생성 +## 로컬 샌드박스 에이전트 생성 {#create-a-local-sandbox-agent} 이 예제는 `repo/` 아래에 로컬 저장소를 스테이징하고, 로컬 스킬을 지연 로드하며, 러너가 실행을 위한 Unix 로컬 샌드박스 세션을 생성하도록 합니다. @@ -96,7 +96,7 @@ if __name__ == "__main__": [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)를 참고하세요. 이 예제는 소규모 셸 기반 저장소를 사용하므로 Unix 로컬 실행 전반에서 결정론적으로 검증할 수 있습니다. -## 주요 선택 사항 +## 주요 선택 사항 {#key-choices} 기본 실행이 정상적으로 작동한 후 대부분 다음 항목을 선택합니다. @@ -108,7 +108,7 @@ if __name__ == "__main__": - `SandboxRunConfig.client`: 샌드박스 백엔드 - `SandboxRunConfig.session`, `session_state` 또는 `snapshot`: 후속 실행에서 이전 작업에 다시 연결하는 방법 -## 다음 단계 +## 다음 단계 {#where-to-go-next} - [개념](sandbox/guide.md): 매니페스트, 기능, 권한, 스냅샷, 실행 구성 및 구성 패턴을 이해합니다. - [샌드박스 클라이언트](sandbox/clients.md): Unix 로컬, Docker, 호스티드 제공업체 및 마운트 전략을 선택합니다. diff --git a/docs/ko/sessions/advanced_sqlite_session.md b/docs/ko/sessions/advanced_sqlite_session.md index e5d1419efe..36fc8e8895 100644 --- a/docs/ko/sessions/advanced_sqlite_session.md +++ b/docs/ko/sessions/advanced_sqlite_session.md @@ -6,7 +6,7 @@ search: `AdvancedSQLiteSession`은 기본 `SQLiteSession`의 향상된 버전으로, 대화 브랜칭, 상세한 사용량 분석, 구조화된 대화 쿼리 등 고급 대화 관리 기능을 제공합니다. -## 기능 +## 기능 {#features} - **대화 브랜칭**: 모든 사용자 메시지에서 대체 대화 경로 생성 - **사용량 추적**: 전체 JSON 세부 내역을 포함한 턴별 상세 토큰 사용량 분석 @@ -14,7 +14,7 @@ search: - **브랜치 관리**: 독립적인 브랜치 전환 및 관리 - **메시지 구조 메타데이터**: 메시지 유형, 도구 사용, 대화 흐름 추적 -## 빠른 시작 +## 빠른 시작 {#quick-start} ```python from agents import Agent, Runner @@ -54,7 +54,7 @@ print(result.final_output) # "California" await session.store_run_usage(result) ``` -## 초기화 +## 초기화 {#initialization} ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -82,18 +82,18 @@ session = AdvancedSQLiteSession( ) ``` -### 매개변수 +### 매개변수 {#parameters} - `session_id` (str): 대화 세션의 고유 식별자 - `db_path` (str | Path): SQLite 데이터베이스 파일 경로. 기본값은 인메모리 스토리지를 사용하는 `:memory:`입니다 - `create_tables` (bool): 고급 테이블을 자동으로 생성할지 여부. 기본값은 `False`입니다 - `logger` (logging.Logger | None): 세션의 사용자 지정 로거. 기본적으로 모듈 로거를 사용합니다 -## 사용량 추적 +## 사용량 추적 {#usage-tracking} AdvancedSQLiteSession은 대화 턴별 토큰 사용량 데이터를 저장하여 상세한 사용량 분석을 제공합니다. **이 기능은 각 에이전트 실행 후 `store_run_usage` 메서드를 호출하는 것에 전적으로 의존합니다.** -### 사용량 데이터 저장 +### 사용량 데이터 저장 {#storing-usage-data} ```python # After each agent run, store the usage data @@ -107,7 +107,7 @@ await session.store_run_usage(result) # - Detailed JSON token information (if available) ``` -### 사용량 통계 조회 +### 사용량 통계 조회 {#retrieving-usage-statistics} ```python # Get session-level usage (all branches) @@ -135,11 +135,11 @@ for turn_data in turn_usage: turn_2_usage = await session.get_turn_usage(user_turn_number=2) ``` -## 대화 브랜칭 +## 대화 브랜칭 {#conversation-branching} AdvancedSQLiteSession의 핵심 기능 중 하나는 모든 사용자 메시지에서 대화 브랜치를 생성하여 대체 대화 경로를 탐색할 수 있다는 것입니다. -### 브랜치 생성 +### 브랜치 생성 {#creating-branches} ```python # Get available turns for branching @@ -167,7 +167,7 @@ branch_id = await session.create_branch_from_content( 브랜치 ID는 세션 ID의 전체 수명 동안 고유합니다. 브랜치를 삭제하거나 세션을 지우면 해당 대화 데이터는 제거되지만, 이전에 사용한 브랜치 ID를 다시 사용할 수 있게 되지는 않습니다. 다른 브랜치를 생성할 때는 새 이름을 사용하세요. -### 브랜치 관리 +### 브랜치 관리 {#branch-management} ```python # List all branches @@ -184,7 +184,7 @@ await session.switch_to_branch(branch_id) await session.delete_branch(branch_id, force=True) # force=True allows deleting current branch ``` -### 브랜치 워크플로 예제 +### 브랜치 워크플로 예제 {#branch-workflow-example} ```python # Original conversation @@ -217,11 +217,11 @@ result = await Runner.run( await session.store_run_usage(result) ``` -## 구조화된 쿼리 +## 구조화된 쿼리 {#structured-queries} AdvancedSQLiteSession은 대화 구조와 콘텐츠를 분석하기 위한 여러 메서드를 제공합니다. -### 대화 분석 +### 대화 분석 {#conversation-analysis} ```python # Get conversation organized by turns @@ -245,7 +245,7 @@ for turn in matching_turns: print(f"Turn {turn['turn']}: {turn['content']}") ``` -### 메시지 구조 +### 메시지 구조 {#message-structure} 세션은 다음을 포함한 메시지 구조를 자동으로 추적합니다. @@ -255,11 +255,11 @@ for turn in matching_turns: - 브랜치 연결 관계 - 타임스탬프 -## 데이터베이스 스키마 +## 데이터베이스 스키마 {#database-schema} AdvancedSQLiteSession은 기본 SQLite 스키마에 세 개의 테이블을 추가합니다. -### message_structure 테이블 +### message_structure 테이블 {#message_structure-table} ```sql CREATE TABLE message_structure ( @@ -278,7 +278,7 @@ CREATE TABLE message_structure ( ); ``` -### branch_reservations 테이블 +### branch_reservations 테이블 {#branch_reservations-table} ```sql CREATE TABLE branch_reservations ( @@ -290,7 +290,7 @@ CREATE TABLE branch_reservations ( 이 테이블은 복사된 접두사가 비어 있는 브랜치를 포함하여 브랜치 ID를 원자적으로 예약합니다. 예약 행은 브랜치를 삭제하거나 세션을 지운 경우에도 유지되므로, 오래된 세션 인스턴스가 같은 ID를 재사용한 이후의 브랜치에 기록을 병합할 수 없습니다. -### turn_usage 테이블 +### turn_usage 테이블 {#turn_usage-table} ```sql CREATE TABLE turn_usage ( @@ -310,12 +310,12 @@ CREATE TABLE turn_usage ( ); ``` -## 전체 예제 +## 전체 예제 {#complete-example} 모든 기능에 대한 포괄적인 데모는 [전체 예제](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py)를 참조하세요. -## API 레퍼런스 +## API 레퍼런스 {#api-reference} - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 기본 클래스 - [`Session`][agents.memory.session.Session] - 기본 세션 프로토콜 \ No newline at end of file diff --git a/docs/ko/sessions/encrypted_session.md b/docs/ko/sessions/encrypted_session.md index 0ddfb914eb..6c87a054c5 100644 --- a/docs/ko/sessions/encrypted_session.md +++ b/docs/ko/sessions/encrypted_session.md @@ -6,14 +6,14 @@ search: `EncryptedSession`은 모든 세션 구현에 투명한 암호화를 제공하여, 오래된 항목의 자동 만료와 함께 대화 데이터를 보호합니다. -## 기능 +## 기능 {#features} - **투명한 암호화**: 모든 세션을 Fernet 암호화로 래핑합니다 - **세션별 키**: HKDF 키 파생을 사용하여 세션마다 고유한 암호화를 적용합니다 - **자동 만료**: TTL이 만료되면 오래된 항목을 조용히 건너뜁니다 - **드롭인 대체**: 기존의 모든 세션 구현과 함께 작동합니다 -## 설치 +## 설치 {#installation} 암호화된 세션에는 `encrypt` extra가 필요합니다: @@ -21,7 +21,7 @@ search: pip install openai-agents[encrypt] ``` -## 빠른 시작 +## 빠른 시작 {#quick-start} ```python import asyncio @@ -53,9 +53,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 구성 +## 구성 {#configuration} -### 암호화 키 +### 암호화 키 {#encryption-key} 암호화 키는 Fernet 키이거나 임의의 문자열일 수 있습니다: @@ -79,7 +79,7 @@ session = EncryptedSession( ) ``` -### TTL(time to live) +### TTL(time to live) {#ttl-time-to-live} 암호화된 항목이 유효한 기간을 설정합니다: @@ -101,9 +101,9 @@ session = EncryptedSession( ) ``` -## 다양한 세션 유형과 함께 사용 +## 다양한 세션 유형과 함께 사용 {#usage-with-different-session-types} -### SQLite 세션과 함께 사용 +### SQLite 세션과 함께 사용 {#with-sqlite-sessions} ```python from agents import SQLiteSession @@ -119,7 +119,7 @@ session = EncryptedSession( ) ``` -### SQLAlchemy 세션과 함께 사용 +### SQLAlchemy 세션과 함께 사용 {#with-sqlalchemy-sessions} ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -147,7 +147,7 @@ session = EncryptedSession( -## 키 파생 +## 키 파생 {#key-derivation} EncryptedSession은 HKDF(HMAC-based Key Derivation Function)를 사용하여 세션별로 고유한 암호화 키를 파생합니다: @@ -161,7 +161,7 @@ EncryptedSession은 HKDF(HMAC-based Key Derivation Function)를 사용하여 세 - 마스터 키 없이는 키를 파생할 수 없습니다 - 서로 다른 세션 간에는 세션 데이터를 복호화할 수 없습니다 -## 자동 만료 +## 자동 만료 {#automatic-expiration} 항목이 TTL을 초과하면 조회 중 자동으로 건너뜁니다: @@ -173,7 +173,7 @@ items = await session.get_items() # Only returns non-expired items result = await Runner.run(agent, "Continue conversation", session=session) ``` -## API 참조 +## API 참조 {#api-reference} - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 기본 클래스 - [`Session`][agents.memory.session.Session] - 기본 세션 프로토콜 \ No newline at end of file diff --git a/docs/ko/sessions/index.md b/docs/ko/sessions/index.md index 78473c2f3e..78c2e719be 100644 --- a/docs/ko/sessions/index.md +++ b/docs/ko/sessions/index.md @@ -10,7 +10,7 @@ Agents SDK는 여러 에이전트 실행에 걸쳐 대화 기록을 자동으로 SDK가 클라이언트 측 메모리를 관리하게 하려면 세션을 사용하세요. 동일한 실행에서 세션은 실행 수준 연속 실행 옵션인 `conversation_id`, `previous_response_id`, `auto_previous_response_id`과 함께 사용할 수 없습니다. 대신 OpenAI 서버에서 관리하는 연속 실행을 원한다면 세션을 추가로 겹쳐 사용하지 말고 이러한 메커니즘 중 하나를 선택하세요. -## 빠른 시작 +## 빠른 시작 {#quick-start} ```python from agents import Agent, Runner, SQLiteSession @@ -49,7 +49,7 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 동일한 세션을 사용한 인터럽션(중단 처리)된 실행 재개 +## 동일한 세션을 사용한 인터럽션(중단 처리)된 실행 재개 {#resuming-interrupted-runs-with-the-same-session} 승인을 위해 실행이 일시 중지되면 동일한 세션 인스턴스(또는 동일한 세션 ID와 동일한 기본 스토리지 백엔드로 구성된 다른 인스턴스)를 사용하여 재개하세요. 그러면 재개된 턴이 저장된 동일한 대화 기록을 이어갑니다. @@ -63,7 +63,7 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## 핵심 세션 동작 +## 핵심 세션 동작 {#core-session-behavior} 세션 메모리가 활성화되면 다음과 같이 동작합니다. @@ -73,7 +73,7 @@ if result.interruptions: 따라서 `.to_input_list()`을 수동으로 호출하고 실행 사이의 대화 상태를 관리할 필요가 없습니다. -## 기록과 새 입력의 병합 방식 제어 +## 기록과 새 입력의 병합 방식 제어 {#control-how-history-and-new-input-merge} 세션을 전달하면 러너는 일반적으로 다음 순서로 모델 입력을 준비합니다. @@ -111,7 +111,7 @@ result = await Runner.run( 세션의 항목 저장 방식을 변경하지 않고 기록을 맞춤 정리하거나 재정렬하거나 선택적으로 포함해야 할 때 사용하세요. 모델 호출 직전에 나중 단계의 최종 처리가 필요하다면 [에이전트 실행 가이드](../running_agents.md)의 [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]를 사용하세요. -## 가져오는 기록 제한 +## 가져오는 기록 제한 {#limiting-retrieved-history} 각 실행 전에 가져올 기록의 양을 제어하려면 [`SessionSettings`][agents.memory.SessionSettings]을 사용하세요. @@ -136,9 +136,9 @@ result = await Runner.run( 세션 구현에서 기본 세션 설정을 제공하는 경우, `RunConfig.session_settings`의 `None`이 아닌 각 값은 해당 실행에서 대응하는 기본값을 재정의합니다. 이는 세션의 기본 동작을 변경하지 않고 가져오는 기록의 크기를 제한하려는 긴 대화에 유용합니다. -## 메모리 작업 +## 메모리 작업 {#memory-operations} -### 기본 작업 +### 기본 작업 {#basic-operations} 세션은 대화 기록을 관리하기 위한 여러 작업을 지원합니다. @@ -165,7 +165,7 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 수정 시 pop_item 사용 +### 수정 시 pop_item 사용 {#using-pop_item-for-corrections} 대화의 마지막 항목을 실행 취소하거나 수정하려는 경우 `pop_item` 메서드가 특히 유용합니다. @@ -196,11 +196,11 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 내장 세션 구현 +## 내장 세션 구현 {#built-in-session-implementations} SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다. -### 내장 세션 구현 선택 +### 내장 세션 구현 선택 {#choose-a-built-in-session-implementation} 아래의 자세한 예제를 읽기 전에 이 표를 사용하여 시작점을 선택하세요. @@ -221,7 +221,7 @@ SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다 ChatKit용 Python 서버를 구현하는 경우 ChatKit의 스레드 및 항목 영속성을 위해 `chatkit.store.Store` 구현을 사용하세요. `SQLAlchemySession`과 같은 Agents SDK 세션은 SDK 측 대화 기록을 관리하지만 ChatKit 스토어를 그대로 대체할 수는 없습니다. [`chatkit-python` ChatKit 데이터 스토어 구현 가이드](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)를 참조하세요. -### OpenAI Conversations API 세션 +### OpenAI Conversations API 세션 {#openai-conversations-api-sessions} `OpenAIConversationsSession`를 통해 [OpenAI의 Conversations API](https://platform.openai.com/docs/api-reference/conversations)를 사용하세요. @@ -257,11 +257,11 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses 압축 세션 +### OpenAI Responses 압축 세션 {#openai-responses-compaction-sessions} Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 클래스는 기본 세션을 감싸며 `should_trigger_compaction`에 따라 각 턴 후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 클래스로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. -#### 일반적인 사용법(자동 압축) +#### 일반적인 사용법(자동 압축) {#typical-usage-auto-compaction} ```python from agents import Agent, Runner, SQLiteSession @@ -286,7 +286,7 @@ print(result.final_output) 에이전트가 `ModelSettings(store=False)`으로 실행되는 경우 Responses API는 나중에 조회할 수 있도록 마지막 응답을 유지하지 않습니다. 이러한 무상태 설정에서 기본 `"auto"` 모드는 `previous_response_id`에 의존하는 대신 입력 기반 압축으로 대체됩니다. 전체 예제는 [`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)을 참조하세요. -#### 자동 압축에 의한 스트리밍 차단 가능성 +#### 자동 압축에 의한 스트리밍 차단 가능성 {#auto-compaction-can-block-streaming} 압축은 세션 기록을 지우고 다시 작성하므로 SDK는 실행이 완료된 것으로 간주하기 전에 압축이 끝날 때까지 기다립니다. 스트리밍 모드에서는 압축 작업이 많은 경우 마지막 출력 토큰 이후에도 `run.stream_events()`이 몇 초 동안 열려 있을 수 있습니다. @@ -313,7 +313,7 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite 세션 +### SQLite 세션 {#sqlite-sessions} SQLite를 사용하는 기본 경량 세션 구현입니다. @@ -334,7 +334,7 @@ result = await Runner.run( ) ``` -### 비동기 SQLite 세션 +### 비동기 SQLite 세션 {#async-sqlite-sessions} `aiosqlite` 기반 SQLite 영속성이 필요한 경우 `AsyncSQLiteSession`을 사용하세요. @@ -351,7 +351,7 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis 세션 +### Redis 세션 {#redis-sessions} 여러 워커 또는 서비스 간에 세션 메모리를 공유하려면 `RedisSession`를 사용하세요. @@ -374,7 +374,7 @@ await session.close() `from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후 세션은 종료 상태가 되며 이후 세션 작업에서는 `RuntimeError`이 발생합니다. 반복되거나 동시에 실행되는 `close()` 호출은 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하는 경우 `redis_client=...`을 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`은 아무 작업도 수행하지 않으며, 호출자가 클라이언트 소유권을 유지하고 세션도 계속 사용할 수 있습니다. -### SQLAlchemy 세션 +### SQLAlchemy 세션 {#sqlalchemy-sessions} SQLAlchemy가 지원하는 모든 데이터베이스를 사용할 수 있는 프로덕션용 Agents SDK 세션 영속성 구현입니다. @@ -396,7 +396,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) 자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참조하세요. -### Dapr 세션 +### Dapr 세션 {#dapr-sessions} 이미 Dapr 사이드카를 실행하고 있거나 에이전트 코드를 변경하지 않고 구성된 상태 저장소 백엔드를 전환하려면 `DaprSession`을 사용하세요. @@ -429,7 +429,7 @@ async with DaprSession.from_address( - 로컬 구성 요소와 문제 해결을 포함한 전체 설정 안내는 [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)를 참조하세요. -### MongoDB 세션 +### MongoDB 세션 {#mongodb-sessions} 이미 MongoDB를 사용하는 애플리케이션이나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에서는 `MongoDBSession`을 사용하세요. @@ -461,7 +461,7 @@ await session.close() - 두 개의 컬렉션이 사용되며 두 이름 모두 `sessions_collection=`(기본값 `agent_sessions`)과 `messages_collection=`(기본값 `agent_messages`)을 통해 구성할 수 있습니다. 인덱스는 처음 사용할 때 자동으로 생성됩니다. 비어 있지 않은 각 `add_items()` 호출은 단조 증가하는 `seq`이 마지막 항목을 기준으로 배치 순서를 지정하는 논리적 배치 문서 하나를 작성합니다. 기존의 항목별 메시지 문서도 계속 읽을 수 있습니다. 논리적 배치는 MongoDB의 단일 문서 크기 제한 이내여야 하며, 크기를 초과하는 배치는 일부를 저장하지 않고 원자적으로 실패합니다. - 첫 실행 전에 연결 상태를 확인하려면 `await session.ping()`을 사용하세요. -### 고급 SQLite 세션 +### 고급 SQLite 세션 {#advanced-sqlite-sessions} 대화 브랜칭, 사용량 분석 및 구조화된 쿼리를 지원하는 향상된 SQLite 세션입니다. @@ -485,7 +485,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참조하세요. -### 암호화된 세션 +### 암호화된 세션 {#encrypted-sessions} 모든 세션 구현에 사용할 수 있는 투명한 암호화 래퍼입니다. @@ -512,13 +512,13 @@ result = await Runner.run(agent, "Hello", session=session) 자세한 문서는 [암호화된 세션](encrypted_session.md)을 참조하세요. -### 기타 세션 유형 +### 기타 세션 유형 {#other-session-types} 그 밖에도 몇 가지 내장 옵션이 있습니다. `examples/memory/`과 `extensions/memory/` 아래의 소스 코드를 참조하세요. -## 운영 패턴 +## 운영 패턴 {#operational-patterns} -### 세션 ID 명명 방식 +### 세션 ID 명명 방식 {#session-id-naming} 대화를 정리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. @@ -526,7 +526,7 @@ result = await Runner.run(agent, "Hello", session=session) - 스레드 기반: `"thread_abc123"` - 컨텍스트 기반: `"support_ticket_456"` -### 메모리 영속성 +### 메모리 영속성 {#memory-persistence} - 임시 대화에는 인메모리 SQLite(`SQLiteSession("session_id")`) 사용 - 지속되는 대화에는 파일 기반 SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) 사용 @@ -539,7 +539,7 @@ result = await Runner.run(agent, "Hello", session=session) - 모든 세션에 투명한 암호화 및 TTL 기반 만료를 적용하려면 암호화된 세션(`EncryptedSession(session_id, underlying_session, encryption_key)`) 사용 - 더 고급 사용 사례에서는 다른 프로덕션 시스템(예: Django)을 위한 맞춤형 세션 백엔드 구현 고려 -### 여러 세션 +### 여러 세션 {#multiple-sessions} ```python from agents import Agent, Runner, SQLiteSession @@ -562,7 +562,7 @@ result2 = await Runner.run( ) ``` -### 세션 공유 +### 세션 공유 {#session-sharing} ```python # Different agents can share the same session @@ -583,7 +583,7 @@ result2 = await Runner.run( ) ``` -## 전체 예제 +## 전체 예제 {#complete-example} 다음은 세션 메모리의 실제 동작을 보여주는 전체 예제입니다. @@ -647,7 +647,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 맞춤형 세션 구현 +## 맞춤형 세션 구현 {#custom-session-implementations} [`Session`][agents.memory.session.Session] 프로토콜을 구조적으로 따르는 클래스를 생성하여 자체 세션 메모리를 구현할 수 있습니다. `SessionABC`을 상속할 필요는 없습니다. `session_id`과 `session_settings`을 정의하고 네 개의 기록 메서드를 직접 구현하세요. @@ -691,7 +691,7 @@ result = await Runner.run( ) ``` -### 맞춤형 세션에서 실행 컨텍스트 접근 +### 맞춤형 세션에서 실행 컨텍스트 접근 {#accessing-run-context-from-a-custom-session} Agents SDK는 테넌트 라우팅, 권한 부여 또는 기타 앱별 스토리지 결정을 위해 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper]을 맞춤형 세션에 전달할 수 있습니다. Agents SDK가 래퍼를 전달하도록 하려면 네 개의 기록 메서드 모두에 명시적으로 이름이 지정되고 키워드와 호환되는 `wrapper` 매개변수를 추가하세요. @@ -732,7 +732,7 @@ class ContextAwareSession: Agents SDK는 `get_items`, `add_items`, `pop_item`, `clear_session`이 모두 `wrapper`을 선언하는 경우에만 이 통합을 활성화합니다. 일반적인 `**kwargs` 매개변수는 이 시그니처 검사를 충족하지 않습니다. `wrapper`을 생략하는 기존 세션 구현은 릴리스된 호출 형식을 유지하며 변경 없이 계속 작동합니다. -## 커뮤니티 세션 구현 +## 커뮤니티 세션 구현 {#community-session-implementations} 커뮤니티에서 추가 세션 구현을 개발했습니다. @@ -742,7 +742,7 @@ Agents SDK는 `get_items`, `add_items`, `pop_item`, `clear_session`이 모두 `w 세션 구현을 개발했다면 여기에 추가할 수 있도록 문서 PR을 자유롭게 제출해 주세요! -## API 레퍼런스 +## API 레퍼런스 {#api-reference} 자세한 API 문서는 다음을 참조하세요. diff --git a/docs/ko/sessions/sqlalchemy_session.md b/docs/ko/sessions/sqlalchemy_session.md index 71cb6d10ec..818d9c068c 100644 --- a/docs/ko/sessions/sqlalchemy_session.md +++ b/docs/ko/sessions/sqlalchemy_session.md @@ -6,7 +6,7 @@ search: `SQLAlchemySession`는 SQLAlchemy를 사용하여 프로덕션 환경에서 바로 사용할 수 있는 세션 구현을 제공하므로, SQLAlchemy가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 세션 스토리지로 사용할 수 있습니다. -## 설치 +## 설치 {#installation} SQLAlchemy 세션을 사용하려면 `openai-agents` 패키지의 `sqlalchemy` optional-dependency extra가 필요합니다. @@ -14,9 +14,9 @@ SQLAlchemy 세션을 사용하려면 `openai-agents` 패키지의 `sqlalchemy` o pip install openai-agents[sqlalchemy] ``` -## 빠른 시작 +## 빠른 시작 {#quick-start} -### 데이터베이스 URL 사용 +### 데이터베이스 URL 사용 {#using-database-url} 시작하는 가장 간단한 방법은 다음과 같습니다. @@ -42,7 +42,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 기존 엔진 사용 +### 기존 엔진 사용 {#using-existing-engine} 기존 SQLAlchemy 엔진이 있는 애플리케이션에서는 다음과 같이 사용합니다. @@ -73,7 +73,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 비 ASCII 텍스트 저장 +## 비 ASCII 텍스트 저장 {#storing-non-ascii-text} 기본적으로 `SQLAlchemySession`는 세션 항목을 JSON으로 직렬화할 때 비 ASCII 문자를 이스케이프합니다. 이렇게 하면 기존 스토리지 형식을 유지하면서도 항목을 로드할 때 원래 텍스트를 그대로 복원할 수 있습니다. @@ -91,7 +91,7 @@ session = SQLAlchemySession.from_url( 기존 엔진을 사용할 때는 동일한 옵션을 `SQLAlchemySession(...)`에 직접 전달할 수 있습니다. 이 설정은 데이터베이스에 저장되는 JSON 표현만 변경하며, 세션 메서드가 반환하는 값은 변경하지 않습니다. -## API 레퍼런스 +## API 레퍼런스 {#api-reference} - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 주요 클래스 - [`Session`][agents.memory.session.Session] - 기본 세션 프로토콜 \ No newline at end of file diff --git a/docs/ko/streaming.md b/docs/ko/streaming.md index ca41b97924..bc1b1b256f 100644 --- a/docs/ko/streaming.md +++ b/docs/ko/streaming.md @@ -10,7 +10,7 @@ search: 비동기 이터레이터가 완료될 때까지 `result.stream_events()`를 계속 소비해야 합니다. 스트리밍 실행은 이터레이터가 종료될 때까지 완료된 것이 아니며, 세션 영속화, 승인 기록 관리, 기록 압축과 같은 후처리는 마지막으로 표시되는 토큰이 도착한 후에도 계속될 수 있습니다. 루프가 종료되면 `result.is_complete`에 최종 실행 상태가 반영됩니다. -## 가공되지 않은 응답 이벤트 +## 가공되지 않은 응답 이벤트 {#raw-response-events} [`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 객체는 LLM에서 직접 전달된 가공되지 않은 이벤트를 래핑합니다. 각 객체의 `data` 필드에는 `response.created` 또는 `response.output_text.delta` 같은 유형의 OpenAI Responses API 이벤트가 포함됩니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. @@ -39,7 +39,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 스트리밍 및 승인 +## 스트리밍 및 승인 {#streaming-and-approvals} 스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요하면 `result.stream_events()`가 완료되고, 보류 중인 승인은 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. `result.to_state()`를 사용하여 결과를 [`RunState`][agents.run_state.RunState]로 변환하고, 인터럽션(중단 처리)을 승인하거나 거부한 다음 `Runner.run_streamed(...)`으로 재개합니다. @@ -59,7 +59,7 @@ if result.interruptions: 전체 일시 중지 및 재개 과정은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참고하세요. -## 현재 턴 이후 스트리밍 취소 +## 현재 턴 이후 스트리밍 취소 {#cancel-streaming-after-the-current-turn} 진행 중인 스트리밍 실행을 중간에 중지해야 하는 경우 [`result.cancel()`][agents.result.RunResultStreaming.cancel]을 호출합니다. 기본적으로 실행이 즉시 중지됩니다. 중지하기 전에 현재 턴이 정상적으로 완료되도록 하려면 대신 `result.cancel(mode="after_turn")`를 호출합니다. @@ -71,11 +71,11 @@ if result.interruptions: - 스트리밍 실행이 도구 승인을 위해 중지된 경우 이를 새 턴으로 취급하지 마세요. 스트림을 끝까지 소비하고 `result.interruptions`를 검사한 다음 `result.to_state()`에서 재개합니다. - 다음 모델 호출 전에 조회된 세션 기록과 새 사용자 입력을 병합하는 방식을 사용자 지정하려면 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback]을 사용합니다. 여기에서 새 턴 항목을 다시 작성하면 다시 작성된 버전이 해당 턴에 영속화됩니다. -## 실행 항목 이벤트 및 에이전트 이벤트 +## 실행 항목 이벤트 및 에이전트 이벤트 {#run-item-events-and-agent-events} [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 상위 수준의 이벤트입니다. 항목이 완전히 생성되었을 때 이를 알려 줍니다. 따라서 각 토큰 대신 "메시지 생성됨", "도구 실행됨" 등의 수준으로 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때 업데이트를 제공합니다(예: 핸드오프의 결과). -### 실행 항목 이벤트 이름 +### 실행 항목 이벤트 이름 {#run-item-event-names} `RunItemStreamEvent.name`는 정해진 의미론적 이벤트 이름 집합을 사용합니다. diff --git a/docs/ko/testing.md b/docs/ko/testing.md index bd0161eba4..5d621f9d07 100644 --- a/docs/ko/testing.md +++ b/docs/ko/testing.md @@ -8,7 +8,7 @@ SDK는 에이전트 워크플로, Sandbox 세션, Realtime 세션 및 Voice 파 이러한 유틸리티를 사용하여 애플리케이션과 SDK가 관리하는 오케스트레이션을 테스트할 수 있습니다. 여기에는 도구 실행, 핸드오프, 가드레일, 재시도, 스트리밍, 세션 동작, Sandbox 기능, Realtime 이벤트 처리 및 Voice 파이프라인 구성이 포함됩니다. 외부 모델, 네트워크 프로토콜, Sandbox 공급자 또는 오디오 시스템이 관리하는 동작에는 실제 공급자 어댑터나 통합 환경을 사용하세요. -## 필요한 레시피 찾기 +## 필요한 레시피 찾기 {#find-the-recipe-you-need} | 원하는 작업 | 사용 항목 | 이동 위치 | | --- | --- | --- | @@ -26,7 +26,7 @@ SDK는 에이전트 워크플로, Sandbox 세션, Realtime 세션 및 Voice 파 | 정적 또는 스트리밍 Voice 파이프라인 테스트 | `ScriptedSTTModel`, `ScriptedTTSModel` 및 스크립트된 워크플로나 실제 워크플로 | [Voice 파이프라인 테스트](#test-a-voice-pipeline) | | 공급자 직렬화 또는 전송 페이로드 테스트 | 제어된 네트워크 전송을 사용하는 실제 공급자 어댑터 | [올바른 경계 선택](#choose-the-correct-boundary) | -## 가져오기 +## 가져오기 {#imports} 테스트 API는 대체하는 런타임 경계와 나란히 위치합니다. @@ -38,9 +38,9 @@ SDK는 에이전트 워크플로, Sandbox 세션, Realtime 세션 및 Voice 파 테스트 심벌은 의도적으로 최상위 `agents` 가져오기에서 제외됩니다. -## 에이전트 워크플로 레시피 +## 에이전트 워크플로 레시피 {#agent-workflow-recipes} -### 고정 응답 반환 +### 고정 응답 반환 {#return-a-fixed-response} 예상되는 각 모델 호출마다 정규화된 출력 항목 시퀀스를 하나씩 전달합니다. 출력 시퀀스 축약형은 하나의 요청에 대해 결정론적인 응답 ID와 사용량을 받습니다. @@ -71,7 +71,7 @@ async def test_fixed_response() -> None: 결정론적 워크플로 테스트는 `model.assert_complete()`로 마무리하세요. 이 메서드는 구성된 모든 단계를 소비하기 전에 워크플로가 중지된 경우를 포착합니다. -### 도구 워크플로 테스트 +### 도구 워크플로 테스트 {#test-a-tool-workflow} 도구를 호출하는 모델 응답 하나와 최종 답변을 생성하는 두 번째 응답을 스크립트로 구성합니다. 이러한 모델 호출 사이에서 실제 SDK 도구 파이프라인이 실행됩니다. @@ -117,7 +117,7 @@ async def test_tool_workflow() -> None: 이 패턴은 도구 입력 검증, 실행, 결과 변환, 훅, 가드레일 및 다음 모델 턴을 포괄합니다. Python 함수를 직접 호출하면 이러한 SDK 동작을 우회하게 됩니다. -### 요청에서 응답 도출 +### 요청에서 응답 도출 {#derive-a-response-from-the-request} 응답이 실제로 정규화된 모델 호출에 따라 달라지거나 모델 경계에서 검증해야 할 때 `ModelStep.respond()`을 사용하세요. 응답자는 동기식 또는 비동기식일 수 있으며 `ScriptedModel`이 허용하는 모든 단계 형식을 반환할 수 있습니다. @@ -151,7 +151,7 @@ async def test_request_aware_response() -> None: `ScriptedModel`은 `ModelStep`, 이에 해당하는 딕셔너리 형식, `ModelResponse`, 정규화된 출력 항목 시퀀스 또는 예외를 허용합니다. 응답이 호출에 따라 달라지지 않을 때는 고정 출력 시퀀스를 사용하는 것이 좋습니다. 고정 스크립트를 사용하면 예상하지 못한 턴을 더 쉽게 진단할 수 있습니다. -### 모델 호출 검사 +### 모델 호출 검사 {#inspect-model-calls} `ScriptedModel`은 선택된 단계를 해결하거나 예외를 발생시키기 전에 각 호출을 기록합니다. @@ -168,7 +168,7 @@ async def test_request_aware_response() -> None: 하나의 테스트에서 모델 단계를 점진적으로 추가해야 할 때는 `enqueue()` 또는 `extend()`을 사용하세요. 독립적인 시나리오에는 새 `ScriptedModel`를 생성하세요. 이 유틸리티는 소비된 단계나 호출 기록을 재설정하지 않습니다. -### 스트리밍 테스트 +### 스트리밍 테스트 {#test-streaming} 일반 응답 단계는 `Runner.run()`과 `Runner.run_streamed()`을 모두 지원합니다. 일반적인 어시스턴트 메시지, 추론 항목, 함수 호출 및 패치 적용 호출의 경우 `ScriptedModel`가 정규화된 시작, 델타, 항목 완료 및 최종 응답 이벤트를 생성합니다. 최종 응답에는 전체 출력과 사용량이 포함됩니다. @@ -185,7 +185,7 @@ step = ModelStep.stream( 자동 스트리밍은 증분 수명 주기가 구현되지 않은 정규화된 출력 항목 유형을 거부합니다. 이러한 항목에는 부분적인 이벤트 시퀀스에 의존하지 말고 `ModelStep.stream(...)`을 사용하세요. -### 모델 실패 주입 +### 모델 실패 주입 {#inject-model-failures} 모델 호출 하나를 실패시키려면 `ModelStep.raise_error()`를 사용하세요. 선택적 재시도 권고는 해당 스크립트 오류에만 적용됩니다. @@ -202,7 +202,7 @@ step = ModelStep.raise_error( 러너의 재시도 정책에 따라 권고가 추가 시도를 유발할지 결정됩니다. 각 재시도는 또 다른 모델 호출이며 다음 스크립트 단계를 소비합니다. Python 헬퍼는 고정된 `ModelRetryAdvice` 값을 허용합니다. 재시도 권고 자체가 시도마다 동적으로 달라져야 하는 경우 사용자 지정 `Model`을 사용하세요. -### 워크플로 드리프트 감지 +### 워크플로 드리프트 감지 {#detect-workflow-drift} 스크립트된 호출을 예상 워크플로 형태로 간주하세요. 추가 모델 요청이 발생하면 `UnexpectedModelCall`가 발생하며, 조기에 종료되면 `assert_complete()`이 보고할 단계가 남습니다. @@ -214,9 +214,9 @@ step = ModelStep.raise_error( | `UnexpectedModelCall` | `call`, `call_index` | 스크립트가 끝난 후 워크플로가 또 다른 모델 호출을 수행함 | | `UnconsumedModelSteps` | `remaining_steps` | 모든 단계를 사용하기 전에 워크플로가 종료됨 | -## Sandbox 에이전트 레시피 +## Sandbox 에이전트 레시피 {#sandbox-agent-recipes} -### Sandbox 에이전트 워크플로 테스트 +### Sandbox 에이전트 워크플로 테스트 {#test-a-sandbox-agent-workflow} `ScriptedModel`과 `scripted_sandbox_session()`를 결합하면 로컬 컨테이너나 원격 Sandbox를 생성하지 않고도 실제 `SandboxAgent` 런타임을 실행할 수 있습니다. 모델 스크립트는 기능 도구를 선택하고, Sandbox 스크립트는 해당 `SandboxSession` 메서드가 반환할 값을 정의합니다. @@ -279,7 +279,7 @@ async def test_sandbox_workflow() -> None: 이 테스트는 정규화된 SDK 경계 두 개를 통과합니다. 도구 인수 검증, 기능 라우팅, Sandbox 세션 호출, 다음 모델 턴으로의 도구 결과 전달 및 최종 출력 처리를 포괄합니다. 실제 모델이 명령을 선택하는지 또는 실제 Sandbox 공급자가 이를 어떻게 실행하는지는 테스트하지 않습니다. -### Sandbox 단계 구성 +### Sandbox 단계 구성 {#configure-sandbox-steps} 일치하는 각 Sandbox 호출은 하나의 전역 FIFO 시퀀스에서 다음 단계를 소비합니다. 메서드 불일치, 매처 거부 또는 매처 예외가 발생하면 해당 단계는 대기 상태로 남습니다. `method`을 설정하고 결과를 정확히 하나 선택하며, 호출 세부 정보가 중요한 경우에만 `match`을 추가하세요. @@ -303,9 +303,9 @@ async def test_sandbox_workflow() -> None: 반환되는 객체는 세션 자체입니다. 이를 `RunConfig(sandbox={"session": sandbox})`에 직접 전달하세요. 래퍼 `.session` 속성은 없습니다. -## Realtime 레시피 +## Realtime 레시피 {#realtime-recipes} -### Realtime 세션 테스트 +### Realtime 세션 테스트 {#test-a-realtime-session} `ScriptedRealtimeModel`는 Python SDK의 정규화된 `RealtimeModel` 경계를 구현합니다. 각 `RealtimeStep`는 발신 `RealtimeModelSendEvent` 하나와 일치한 다음 정규화된 수신 `RealtimeModelEvent` 객체를 내보내거나 주입된 오류를 발생시킵니다. @@ -361,7 +361,7 @@ async def test_realtime_message() -> None: 연결 중에 수신 이벤트를 내보내려면 `connect_events`을 사용하세요. 수명 주기 실패에는 `connect_error` 또는 `close_error`를 사용하고, 일치한 전송 하나와 관련된 실패에는 `RealtimeStep(error=...)`을 사용하세요. 한 단계에는 `emit`와 `error`를 동시에 정의할 수 없습니다. -### Realtime 도구 워크플로 테스트 +### Realtime 도구 워크플로 테스트 {#test-a-realtime-tool-workflow} 실제 함수 도구를 `RealtimeAgent`에 연결하고 정규화된 도구 호출을 내보낸 다음 SDK가 모델 경계를 통해 도구 출력을 전송하는지 확인합니다. `async_tool_calls`을 `False`로 설정하면 이 간단한 예제가 테스트 전용 대기 메커니즘 없이 연결 중에 완료됩니다. @@ -421,7 +421,7 @@ async def test_realtime_tool_workflow() -> None: 이 테스트는 실제 Realtime 도구 조회, 인수 검증, 실행 및 출력 라우팅을 수행합니다. 실제 모델이 해당 도구를 선택한다는 사실까지 입증하지는 않습니다. -### Realtime 호출 및 수명 주기 검사 +### Realtime 호출 및 수명 주기 검사 {#inspect-realtime-calls-and-lifecycle} | 멤버 | 포함 내용 | | --- | --- | @@ -441,9 +441,9 @@ async def test_realtime_tool_workflow() -> None: | `UnconsumedRealtimeSteps` | `remaining_steps` | 예상된 모든 전송을 사용하기 전에 세션이 종료됨 | | `RealtimeScriptError` | 없음 | 연결이 끊긴 상태에서 전송하는 등 잘못된 수명 주기 상태에서 스크립트가 사용됨 | -## Voice 파이프라인 레시피 +## Voice 파이프라인 레시피 {#voice-pipeline-recipes} -### Voice 파이프라인 테스트 +### Voice 파이프라인 테스트 {#test-a-voice-pipeline} 스크립트된 STT 및 TTS 모델을 `SingleAgentVoiceWorkflow`, 그리고 `ScriptedModel`이 지원하는 에이전트와 결합하면 공급자 요청 없이 전체 음성-텍스트 변환 -> 에이전트 -> 텍스트-음성 변환 파이프라인을 테스트할 수 있습니다. @@ -501,7 +501,7 @@ workflow = ScriptedVoiceWorkflow( `start` 단계는 `on_start()`에서 소비됩니다. `VoicePipeline`은 `StreamedAudioInput`에 대해서만 `on_start()`을 호출합니다. 정적 `AudioInput` 실행은 `start`를 소비하지 않습니다. 각 일반 턴은 전사 결과를 기록하고 구성된 결과 하나를 소비합니다. 문자열 하나는 하나의 프래그먼트이며, 문자열 시퀀스는 텍스트 분할 및 TTS 전에 프래그먼트 경계를 제어합니다. -### 스트리밍 전사 테스트 +### 스트리밍 전사 테스트 {#test-streamed-transcription} `ScriptedSTTModel`는 정적 `transcriptions`과 독립적으로 스크립트된 스트리밍 `sessions`을 허용합니다. 세션은 `ScriptedTranscriptionSession`, 전사 턴 시퀀스, 예외 또는 단일 문자열일 수 있습니다. @@ -515,7 +515,7 @@ stt = ScriptedSTTModel(sessions=[session]) `ScriptedTranscriptionSession`을 닫으면 반복이 중지되고 건너뛴 턴이 남아 `assert_complete()`에서 보고됩니다. 마찬가지로 `ScriptedTTSModel`은 호출마다 `TTSResult`, 바이트 청크 시퀀스 또는 예외 하나를 소비합니다. -### Voice 호출 검사 +### Voice 호출 검사 {#inspect-voice-calls} | 구성 요소 | 기록된 내역 | | --- | --- | @@ -532,7 +532,7 @@ stt = ScriptedSTTModel(sessions=[session]) 테스트에서 구성한 모든 스크립트형 Voice 구성 요소에 `assert_complete()`을 호출하세요. `ScriptedSTTModel.assert_complete()`은 자신이 생성한 전사 세션의 턴도 검사합니다. -## 올바른 경계 선택 +## 올바른 경계 선택 {#choose-the-correct-boundary} 모델 공급자에 의존하지 않고 SDK 실행 루프, 도구, 핸드오프, 가드레일, 세션, 재시도 또는 정규화된 스트리밍을 테스트해야 할 때 `ScriptedModel`을 사용하세요. @@ -544,7 +544,7 @@ WebSocket 연결을 열지 않고 `RealtimeSession` 동작 또는 `RealtimeAgent 이러한 유틸리티를 Responses API 또는 Chat Completions 요청 직렬화, 인증 헤더, 공급자 기본값, HTTP 페이로드, 공급자 스트림 청크, Realtime 전송 프레임 또는 공급자별 수명 주기 동작을 테스트하는 데 사용하지 마세요. 이러한 테스트에는 실제 어댑터를 유지하면서 해당 네트워크 경계를 대체하거나 제어하세요. `openai` v3에서는 OpenAI 어댑터 테스트에 `httpx2` 요청, 응답, 전송 및 예외 타입을 사용해야 합니다. 레거시 `httpx`은 Agents SDK의 핵심 종속성이 아닙니다. -## 최종 체크리스트 +## 최종 체크리스트 {#final-checklist} - 정규화된 모델, Sandbox 세션, Realtime 모델 또는 Voice 파이프라인 경계가 관리하는 상호작용만 스크립트로 구성합니다. - 비공개 러너 상태 대신 중요한 공개 요청 또는 호출 필드를 검증합니다. @@ -555,7 +555,7 @@ WebSocket 연결을 열지 않고 `RealtimeSession` 동작 또는 `RealtimeAgent - 사람이 읽을 수 있는 메시지를 파싱하는 대신 구조화된 오류 필드를 검증합니다. - 공급자 전송 테스트는 제어된 네트워크 전송을 사용하는 실제 어댑터에서 수행합니다. -## 범위 및 현재 제한 사항 +## 범위 및 현재 제한 사항 {#scope-and-current-limitations} 테스트 모듈은 의도적으로 다음 기능을 제공하지 않습니다. @@ -568,7 +568,7 @@ WebSocket 연결을 열지 않고 `RealtimeSession` 동작 또는 `RealtimeAgent 테스트에 잘못된 형식의 스트림, 제어된 일시 중지 또는 동시성, 정확한 취소, 혹은 스크립트형 유틸리티가 보존할 수 없는 수명 주기 경계가 필요한 경우 해당 공개 인터페이스의 사용자 지정 구현을 사용하세요. 테스트에 그 특수한 경계를 문서화하세요. -## API 레퍼런스 +## API 레퍼런스 {#api-reference} - [`agents.testing`](ref/testing.md) - [`agents.realtime.testing`](ref/realtime/testing.md) diff --git a/docs/ko/tools.md b/docs/ko/tools.md index abe26a3894..58fb36a62c 100644 --- a/docs/ko/tools.md +++ b/docs/ko/tools.md @@ -12,7 +12,7 @@ search: - Agents as tools: 전체 핸드오프 없이 에이전트를 호출 가능한 도구로 노출합니다. - 실험적 기능: Codex 도구: 도구 호출을 통해 워크스페이스 범위의 Codex 작업을 실행합니다. -## 도구 유형 선택 +## 도구 유형 선택 {#choosing-a-tool-type} 이 페이지를 카탈로그로 활용한 다음, 제어하는 런타임에 해당하는 섹션으로 이동하세요. @@ -26,7 +26,7 @@ search: | 핸드오프 없이 한 에이전트가 다른 에이전트 호출 | [Agents as tools](#agents-as-tools) | | 에이전트에서 워크스페이스 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | -## 호스티드 툴 +## 호스티드 툴 {#hosted-tools} OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 다음과 같은 기본 제공 도구를 제공합니다. @@ -62,7 +62,7 @@ async def main(): print(result.final_output) ``` -### 호스티드 도구 검색 +### 호스티드 도구 검색 {#hosted-tool-search} 도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 범위의 로드를 런타임까지 지연하여 현재 턴에 필요한 하위 집합만 불러올 수 있습니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많을 때 모든 도구를 미리 노출하지 않고 도구 스키마 토큰을 줄이는 데 유용합니다. @@ -126,7 +126,7 @@ print(result.final_output) - 네임스페이스 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 예제는 `examples/tools/tool_search.py`을 참고하세요. - 공식 플랫폼 가이드: [도구 검색](https://developers.openai.com/api/docs/guides/tools-tool-search) -### 프로그래밍 방식 도구 호출 +### 프로그래밍 방식 도구 호출 {#programmatic-tool-calling} 프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 출력을 결합하고, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델과의 왕복 없이 루프, 분기, 병렬 호출 또는 중간 계산을 활용하는 범위가 제한된 워크플로에 유용합니다. @@ -180,7 +180,7 @@ print(result.final_output) - 완전한 동시성 재고 계획 예제는 `examples/tools/programmatic_tool_calling.py`을 참고하세요. - 공식 플랫폼 가이드: [프로그래밍 방식 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) -### 호스티드 컨테이너 셸 및 스킬 +### 호스티드 컨테이너 셸 및 스킬 {#hosted-container-shell-skills} `ShellTool`는 OpenAI 호스티드 컨테이너 실행도 지원합니다. 로컬 런타임 대신 관리형 컨테이너에서 모델이 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. @@ -229,7 +229,7 @@ print(result.final_output) - 완전한 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참고하세요. - OpenAI 플랫폼 가이드: [셸](https://platform.openai.com/docs/guides/tools-shell) 및 [스킬](https://platform.openai.com/docs/guides/tools-skills) -## 로컬 런타임 도구 +## 로컬 런타임 도구 {#local-runtime-tools} 로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 호출 시점을 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. @@ -245,7 +245,7 @@ print(result.final_output) 셸 작업 시간 제한은 유한한 시간 제한에 양의 정수 밀리초를 사용합니다. 0은 실행기 구현 간에 이식 가능한 의미를 갖지 않으므로 SDK는 로컬 `ShellTool` 실행기를 호출하기 전에 `0`과 `None`를 모두 명시적 시간 제한 없음으로 처리합니다. 그 밖의 값은 실행기 호출 전에 거부됩니다. 이는 시간 제한 필드에만 해당합니다. `max_output_length=0`는 캡처된 빈 출력 요청으로 계속 지원됩니다. -### ComputerTool과 Responses 컴퓨터 도구 +### ComputerTool과 Responses 컴퓨터 도구 {#computertool-and-the-responses-computer-tool} `ComputerTool`는 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API 컴퓨터 인터페이스에 매핑합니다. @@ -304,7 +304,7 @@ agent = Agent( ) ``` -## 함수 도구 +## 함수 도구 {#function-tools} 모든 Python 함수를 도구로 사용할 수 있습니다. Agents SDK가 도구를 자동으로 설정합니다. @@ -445,7 +445,7 @@ for tool in agent.tools: } ``` -### 함수 도구에서 이미지 또는 파일 반환 +### 함수 도구에서 이미지 또는 파일 반환 {#returning-images-or-files-from-function-tools} 텍스트 출력뿐 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 이를 위해 다음 중 하나를 반환할 수 있습니다. @@ -453,7 +453,7 @@ for tool in agent.tools: - 파일: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent] 또는 TypedDict 버전인 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict] - 텍스트: 문자열, 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText] 또는 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] -### 사용자 지정 함수 도구 +### 사용자 지정 함수 도구 {#custom-function-tools} Python 함수를 도구로 사용하지 않으려는 경우도 있습니다. 원하는 경우 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. @@ -493,7 +493,7 @@ tool = FunctionTool( ) ``` -### 자동 인수 및 docstring 구문 분석 +### 자동 인수 및 docstring 구문 분석 {#automatic-argument-and-docstring-parsing} 앞서 설명한 것처럼 함수 시그니처를 자동으로 구문 분석하여 도구 스키마를 추출하고, docstring을 구문 분석하여 도구와 개별 인수의 설명을 추출합니다. 다음 사항을 참고하세요. @@ -502,7 +502,7 @@ tool = FunctionTool( 스키마 추출 코드는 [`agents.function_schema`][]에 있습니다. -### Pydantic Field를 사용한 인수 제약 및 설명 +### Pydantic Field를 사용한 인수 제약 및 설명 {#constraining-and-describing-arguments-with-pydantic-field} Pydantic의 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/)를 사용하여 도구 인수에 제약 조건(예: 숫자의 최솟값/최댓값, 문자열의 길이 또는 패턴)과 설명을 추가할 수 있습니다. Pydantic과 마찬가지로 기본값 기반 형식(`arg: int = Field(..., ge=1)`)과 `Annotated` 형식(`arg: Annotated[int, Field(..., ge=1)]`)을 모두 지원합니다. 생성된 JSON 스키마와 검증에는 이러한 제약 조건이 포함됩니다. @@ -522,7 +522,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr return f"Score recorded: {score}" ``` -### 함수 도구 시간 제한 +### 함수 도구 시간 제한 {#function-tool-timeouts} `@function_tool(timeout=...)`을 사용하여 비동기 함수 도구의 호출별 시간 제한을 설정할 수 있습니다. @@ -577,7 +577,7 @@ except ToolTimeoutError as e: 시간 제한 구성은 비동기 `@function_tool` 핸들러에서만 지원됩니다. -### 함수 도구 오류 처리 +### 함수 도구 오류 처리 {#handling-errors-in-function-tools} `@function_tool`를 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이는 도구 호출이 중단되는 경우 LLM에 오류 응답을 제공하는 함수입니다. @@ -609,7 +609,7 @@ def get_user_profile(user_id: str) -> str: `FunctionTool` 객체를 수동으로 생성하는 경우 `on_invoke_tool` 함수 내부에서 오류를 처리해야 합니다. -## Agents as tools +## Agents as tools {#agents-as-tools} 일부 워크플로에서는 제어를 핸드오프하는 대신 중앙 에이전트가 특화된 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. @@ -655,7 +655,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 도구 에이전트 사용자 지정 +### 도구 에이전트 사용자 지정 {#customizing-tool-agents} `agent.as_tool`은 에이전트를 도구로 변환하는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`을 통한 구조화된 입력도 지원합니다. @@ -681,7 +681,7 @@ async def run_my_agent() -> str: return str(result.final_output) ``` -### 도구 에이전트의 구조화된 입력 +### 도구 에이전트의 구조화된 입력 {#structured-input-for-tool-agents} 기본적으로 `Agent.as_tool()`는 하나의 문자열 필드 `input`(`{"input": "..."}`)이 있는 객체를 예상하지만, Pydantic 모델 유형 또는 데이터 클래스 유형인 `parameters`를 전달하여 구조화된 스키마를 노출할 수 있습니다. @@ -711,11 +711,11 @@ translator_tool = translator_agent.as_tool( 완전한 실행 가능 예제는 `examples/agent_patterns/agents_as_tools_structured.py`을 참고하세요. -### 도구 에이전트의 승인 게이트 +### 도구 에이전트의 승인 게이트 {#approval-gates-for-tool-agents} `Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요하면 실행이 일시 중지되고 대기 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`을 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참고하세요. -### 사용자 지정 출력 추출 +### 사용자 지정 출력 추출 {#custom-output-extraction} 특정한 경우 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 경우에 유용합니다. @@ -744,7 +744,7 @@ json_tool = data_agent.as_tool( 사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 중첩 결과를 후처리하는 동안 외부 도구 이름, 호출 ID 또는 raw 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참고하세요. -### 중첩 에이전트 실행 스트리밍 +### 중첩 에이전트 실행 스트리밍 {#streaming-nested-agent-runs} 스트림이 완료되면 최종 출력을 반환하면서 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하려면 `as_tool`에 `on_stream` 콜백을 전달하세요. @@ -772,7 +772,7 @@ billing_agent_tool = billing_agent.as_tool( - 모델 도구 호출을 통해 도구가 호출되면 `tool_call`가 존재합니다. 직접 호출에서는 `None`일 수 있습니다. - 완전한 실행 가능 샘플은 `examples/agent_patterns/agents_as_tools_streaming.py`을 참고하세요. -### 조건부 도구 활성화 +### 조건부 도구 활성화 {#conditional-tool-enabling} `is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 기본 설정 또는 런타임 조건을 기준으로 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. @@ -842,7 +842,7 @@ asyncio.run(main()) - 다양한 도구 구성의 A/B 테스트 - 런타임 상태에 따른 동적 도구 필터링 -## 실험적 기능: Codex 도구 +## 실험적 기능: Codex 도구 {#experimental-codex-tool} `codex_tool`는 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있게 합니다. 이 인터페이스는 실험적이며 변경될 수 있습니다. diff --git a/docs/ko/tracing.md b/docs/ko/tracing.md index 73a516d7ba..3ddbf67b3a 100644 --- a/docs/ko/tracing.md +++ b/docs/ko/tracing.md @@ -16,7 +16,7 @@ Agents SDK에는 기본 제공 트레이싱 기능이 포함되어 있어 에이 ***Zero Data Retention(ZDR) 정책에 따라 OpenAI API를 사용하는 조직에서는 트레이싱을 사용할 수 없습니다.*** -## 트레이스와 스팬 +## 트레이스와 스팬 {#traces-and-spans} - **트레이스**는 단일 "워크플로"의 시작부터 끝까지 이어지는 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 가집니다. - `workflow_name`: 논리적 워크플로 또는 앱의 이름입니다. 예를 들면 "코드 생성" 또는 "고객 서비스"입니다. @@ -30,7 +30,7 @@ Agents SDK에는 기본 제공 트레이싱 기능이 포함되어 있어 에이 - 이 스팬의 상위 스팬이 있는 경우 이를 가리키는 `parent_id` - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트 정보가, `GenerationSpanData`에는 LLM 생성 정보 등이 포함됩니다. -## 기본 트레이싱 +## 기본 트레이싱 {#default-tracing} SDK는 기본적으로 다음 항목을 트레이싱합니다. @@ -62,7 +62,7 @@ result = await Runner.run( 또한 [사용자 지정 트레이싱 프로세서](#custom-tracing-processors)를 설정하여 트레이스를 다른 대상으로 전송할 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 사용할 수 있습니다. -## 장기 실행 워커와 즉시 내보내기 +## 장기 실행 워커와 즉시 내보내기 {#long-running-workers-and-immediate-exports} 기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 인메모리 큐가 크기 트리거에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 작업과 같은 장기 실행 워커에서는 별도의 코드 없이도 일반적으로 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후에는 트레이스 대시보드에 표시되지 않을 수 있습니다. @@ -105,7 +105,7 @@ async def run(prompt: str, background_tasks: BackgroundTasks): [`flush_traces()`][agents.tracing.flush_traces]은 현재 버퍼링된 트레이스와 스팬을 내보낼 때까지 실행을 차단합니다. 따라서 일부만 생성된 트레이스를 플러시하지 않도록 `trace()`가 닫힌 후 호출하세요. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. -## 상위 수준 트레이스 +## 상위 수준 트레이스 {#higher-level-traces} 여러 `run()` 호출을 단일 트레이스에 포함해야 하는 경우가 있습니다. 전체 코드를 `trace()`으로 래핑하면 됩니다. @@ -124,7 +124,7 @@ async def main(): 1. 두 `Runner.run` 호출이 `with trace()`으로 래핑되므로, 각 실행이 별도의 트레이스를 생성하지 않고 두 실행 모두 하나의 전체 트레이스에 포함됩니다. -## 트레이스 생성 +## 트레이스 생성 {#creating-traces} [`trace()`][agents.tracing.trace] 함수를 사용하여 트레이스를 생성할 수 있습니다. 트레이스는 시작하고 종료해야 합니다. 다음 두 가지 방법을 사용할 수 있습니다. @@ -133,13 +133,13 @@ async def main(): 현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하고 종료하는 경우 현재 트레이스를 업데이트하려면 `mark_as_current`를 `start()`에 전달하고 `reset_current`을 `finish()`에 전달하세요. -## 스팬 생성 +## 스팬 생성 {#creating-spans} 다양한 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 직접 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적할 수 있도록 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. 스팬은 자동으로 현재 트레이스에 포함되며 가장 가까운 현재 스팬 아래에 중첩됩니다. 현재 스팬은 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. -## 민감한 데이터 +## 민감한 데이터 {#sensitive-data} 일부 스팬은 잠재적으로 민감한 데이터를 캡처할 수 있습니다. @@ -149,7 +149,7 @@ async def main(): 기본적으로 `trace_include_sensitive_data`은 `True`입니다. 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 코드 없이 기본값을 설정할 수 있습니다. -## 사용자 지정 트레이싱 프로세서 +## 사용자 지정 트레이싱 프로세서 {#custom-tracing-processors} 트레이싱의 상위 수준 아키텍처는 다음과 같습니다. @@ -162,7 +162,7 @@ async def main(): 2. [`set_trace_processors()`][agents.tracing.set_trace_processors]을 사용하면 기본 프로세서를 자체 트레이스 프로세서로 **교체**할 수 있습니다. 이 경우 이를 수행하는 `TracingProcessor`을 포함하지 않는 한 트레이스가 OpenAI 백엔드로 전송되지 않습니다. -## 비 OpenAI 모델을 사용한 트레이싱 +## 비 OpenAI 모델을 사용한 트레이싱 {#tracing-with-non-openai-models} 비 OpenAI 모델을 사용할 때 트레이싱 익스포터에 OpenAI API 키를 제공하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 사용할 수 있습니다. 어댑터 선택 및 설정 시 주의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참조하세요. @@ -197,15 +197,15 @@ await Runner.run( ) ``` -## 추가 참고 사항 +## 추가 참고 사항 {#additional-notes} - OpenAI 트레이스 대시보드에서 무료 트레이스를 확인할 수 있습니다. -## 에코시스템 통합 +## 에코시스템 통합 {#ecosystem-integrations} 다음 커뮤니티 및 벤더 통합은 OpenAI Agents SDK의 트레이싱 API 인터페이스를 지원합니다. -### 외부 트레이싱 프로세서 목록 +### 외부 트레이싱 프로세서 목록 {#external-tracing-processors-list} - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) diff --git a/docs/ko/usage.md b/docs/ko/usage.md index 6570a71eb5..79fff2ae4f 100644 --- a/docs/ko/usage.md +++ b/docs/ko/usage.md @@ -6,7 +6,7 @@ search: Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 접근하여 비용을 모니터링하거나, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. -## 추적 항목 +## 추적 항목 {#what-is-tracked} - **requests**: 수행된 LLM API 호출 수 - **input_tokens**: 전송된 총 입력 토큰 수 @@ -18,7 +18,7 @@ Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 실행에서 사용량 접근 +## 실행에서 사용량 접근 {#accessing-usage-from-a-run} `Runner.run(...)` 실행 후 `result.context_wrapper.usage`를 통해 사용량에 접근합니다. @@ -36,7 +36,7 @@ print("Total tokens:", usage.total_tokens) [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]가 실행이 완료되기 전에 기록을 자동으로 압축하면 해당 `responses.compact` 요청에서 보고된 사용량도 같은 실행의 합계에 추가됩니다. 실행 외부에서 수동으로 수행한 `run_compaction()` 호출에는 이를 포함하는 실행 컨텍스트가 없으므로 이전 실행에서 반환된 사용량 객체를 업데이트하지 않습니다. [OpenAI Responses 압축 세션](sessions/index.md#openai-responses-compaction-sessions)을 참고하세요. -### 서드 파티 어댑터의 사용량 활성화 +### 서드 파티 어댑터의 사용량 활성화 {#enabling-usage-with-third-party-adapters} 사용량 보고 방식은 서드 파티 어댑터와 제공자 백엔드에 따라 다릅니다. 서드 파티 어댑터를 통해 모델에 접근하면서 정확한 `result.context_wrapper.usage` 값이 필요한 경우 다음 사항을 참고하세요. @@ -45,7 +45,7 @@ print("Total tokens:", usage.total_tokens) Models 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션에서 어댑터별 참고 사항을 확인하고, 배포하려는 제공자 백엔드에서 사용량이 정확하게 보고되는지 검증하세요. -## 요청별 사용량 추적 +## 요청별 사용량 추적 {#per-request-usage-tracking} SDK는 각 API 요청의 사용량을 `request_usage_entries`에서 자동으로 추적합니다. 이는 상세한 비용 계산과 컨텍스트 창 사용량 모니터링에 유용합니다. @@ -56,7 +56,7 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 제공자 사용량 페이로드 보존 +## 제공자 사용량 페이로드 보존 {#preserving-provider-usage-payloads} Agents SDK는 제공자 사용량을 여러 모델 제공자에서 일관된 합계를 제공하는 [`Usage`][agents.usage.Usage] 필드로 정규화합니다. 애플리케이션에서 제공자별 사용량 필드를 유지하거나 생략된 필드와 제공자가 보고한 0을 구분해야 하는 경우 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage]를 `True`으로 설정합니다. @@ -79,7 +79,7 @@ Agents SDK는 각 [`ModelResponse.raw_usage`][agents.items.ModelResponse.raw_usa `LitellmModel`는 현재 스트리밍 및 비스트리밍 실행 모두에서 `ModelResponse.raw_usage`을 채우지 않으므로 `preserve_raw_usage=True`는 해당 어댑터에서 효과가 없습니다. `LitellmModel`을 사용할 때는 계속해서 정규화된 [`Usage`][agents.usage.Usage] 필드를 사용하거나, 제공자별 필드 존재 여부가 필요한 경우 raw 사용량 보존을 지원하는 어댑터를 선택하세요. -## 세션에서 사용량 접근 +## 세션에서 사용량 접근 {#accessing-usage-with-sessions} `Session`(예: `SQLiteSession`)을 사용하는 경우 `Runner.run(...)`를 호출할 때마다 해당 실행의 사용량이 반환됩니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만 각 실행의 사용량은 독립적입니다. @@ -95,7 +95,7 @@ print(second.context_wrapper.usage.total_tokens) # Usage for second run 세션은 실행 간 대화 컨텍스트를 보존하지만, 각 `Runner.run()` 호출에서 반환되는 사용량 지표는 해당 실행만 나타냅니다. 세션에서는 이전 메시지가 각 실행의 입력으로 다시 제공될 수 있으며, 이는 이후 턴의 입력 토큰 수에 영향을 줍니다. -## RunState 체크포인트의 사용량 +## RunState 체크포인트의 사용량 {#usage-in-runstate-checkpoints} [`RunResult.to_state()`][agents.result.RunResult.to_state]는 그 시점까지 누적된 사용량의 독립적인 스냅샷을 캡처합니다. 해당 체크포인트에서 재개된 실행은 캡처된 합계로 시작하며 자체 모델 호출의 사용량을 추가합니다. 재개된 실행은 이러한 새 합계를 원래 `RunResult` 또는 해당 결과에서 생성된 다른 체크포인트에 추가하지 않습니다. @@ -113,7 +113,7 @@ assert resumed_b.context_wrapper.usage is not resumed_a.context_wrapper.usage 이러한 격리는 [`Usage`][agents.usage.Usage] 내부의 `request_usage_entries` 목록에도 적용됩니다. 재개된 중첩 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행은 독립적인 최상위 사용량 집계의 예외입니다. 재개 후의 모델 사용량은 중첩 실행의 이전 모델 호출과 마찬가지로 활성 외부 실행의 사용량에 의도적으로 집계됩니다. -## 훅에서 사용량 활용 +## 훅에서 사용량 활용 {#using-usage-in-hooks} `RunHooks`을 사용하는 경우 각 훅에 전달되는 `context` 객체에는 `usage`이 포함됩니다. 이를 통해 주요 수명 주기 시점에 사용량을 기록할 수 있습니다. @@ -124,7 +124,7 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API 레퍼런스 +## API 레퍼런스 {#api-reference} 자세한 API 문서는 다음을 참고하세요. diff --git a/docs/ko/visualization.md b/docs/ko/visualization.md index f4388d5ff3..4008e3a117 100644 --- a/docs/ko/visualization.md +++ b/docs/ko/visualization.md @@ -6,7 +6,7 @@ search: 에이전트 시각화를 사용하면 **Graphviz**를 통해 에이전트와 다른 에이전트, 도구 및 MCP 서버 간 연결을 구조화된 그래프로 생성할 수 있습니다. 이는 애플리케이션 내에서 에이전트, 도구 및 핸드오프가 상호작용하는 방식을 이해하는 데 유용합니다. -## 설치 +## 설치 {#installation} 선택적 `viz` 의존성 그룹을 설치합니다. @@ -14,7 +14,7 @@ search: pip install "openai-agents[viz]" ``` -## 그래프 생성 +## 그래프 생성 {#generating-a-graph} `draw_graph` 함수를 사용하여 에이전트 시각화를 생성할 수 있습니다. 이 함수는 다음과 같은 방향 그래프를 생성합니다. @@ -23,7 +23,7 @@ pip install "openai-agents[viz]" - **도구**는 녹색 타원으로 표시됩니다. - **핸드오프**는 한 에이전트에서 다른 에이전트로 향하는 방향 간선으로 표시됩니다. -### 사용 예시 +### 사용 예시 {#example-usage} ```python import os @@ -75,7 +75,7 @@ draw_graph(triage_agent) `draw_graph()`는 `handoffs`에 직접 제공되거나 `handoff(agent)`를 통해 등록된 대상 에이전트를 재귀적으로 확장합니다. 두 방식 모두 그래프에 각 대상의 도구, MCP 서버 및 후속 핸드오프가 포함됩니다. 사용 가능한 대상 `Agent`가 없는 사용자 지정 `Handoff`는 이름이 지정된 목적지로만 렌더링되므로, 그래프가 해당 목적지 이면의 리소스를 확장할 수 없습니다. -## 시각화 이해 +## 시각화 이해 {#understanding-the-visualization} 생성된 그래프에는 다음이 포함됩니다. @@ -91,16 +91,16 @@ draw_graph(triage_agent) **참고:** MCP 서버는 이 동작이 확인된 **v0.2.8**을 포함하여 최신 버전의 `agents` 패키지에서 렌더링됩니다. 시각화에 MCP 상자가 표시되지 않으면 최신 릴리스로 업그레이드하세요. -## 그래프 사용자 지정 +## 그래프 사용자 지정 {#customizing-the-graph} -### 그래프 표시 +### 그래프 표시 {#showing-the-graph} 기본적으로 `draw_graph`은 그래프를 인라인으로 표시합니다. 별도의 창에 그래프를 표시하려면 다음과 같이 작성합니다. ```python draw_graph(triage_agent).view() ``` -### 그래프 저장 +### 그래프 저장 {#saving-the-graph} 기본적으로 `draw_graph`은 그래프를 인라인으로 표시합니다. 파일로 저장하려면 파일 이름을 지정합니다. ```python diff --git a/docs/ko/voice/pipeline.md b/docs/ko/voice/pipeline.md index 414c02da77..be2d7ed5d9 100644 --- a/docs/ko/voice/pipeline.md +++ b/docs/ko/voice/pipeline.md @@ -32,7 +32,7 @@ graph LR ``` -## 파이프라인 구성 +## 파이프라인 구성 {#configuring-a-pipeline} 파이프라인을 생성할 때 다음과 같은 몇 가지 항목을 설정할 수 있습니다. @@ -43,14 +43,14 @@ graph LR - 트레이싱 비활성화 여부, 오디오 파일 업로드 여부, 워크플로 이름, trace ID 등을 포함한 트레이싱 설정 - 프롬프트, 언어, 사용되는 데이터 유형과 같은 TTS 및 STT 모델 설정 -## 파이프라인 실행 +## 파이프라인 실행 {#running-a-pipeline} [`run()`][agents.voice.pipeline.VoicePipeline.run] 메서드를 통해 파이프라인을 실행할 수 있으며, 다음 두 가지 형태로 오디오 입력을 전달할 수 있습니다. 1. [`AudioInput`][agents.voice.input.AudioInput]은 완전한 오디오 입력이 있고 이에 대한 결과만 생성하려는 경우에 사용합니다. 화자가 말을 마쳤는지 감지할 필요가 없는 경우에 유용합니다. 예를 들어 사전 녹음된 오디오가 있거나 사용자가 말을 마친 시점을 명확히 알 수 있는 눌러서 말하기(push-to-talk) 앱에서 사용할 수 있습니다. 2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]은 사용자가 말을 마쳤는지 감지해야 할 수 있는 경우에 사용합니다. 오디오 청크가 감지되는 대로 전달할 수 있으며, 음성 파이프라인은 "활동 감지(activity detection)"라는 프로세스를 통해 적절한 시점에 에이전트 워크플로를 자동으로 실행합니다. -## 결과 +## 결과 {#results} 음성 파이프라인 실행의 결과는 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]입니다. 이 객체를 사용하면 이벤트가 발생하는 대로 스트리밍할 수 있습니다. [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent]에는 다음과 같은 몇 가지 유형이 있습니다. @@ -76,8 +76,8 @@ async for event in result.stream(): pass ``` -## 모범 사례 +## 모범 사례 {#best-practices} -### 인터럽션(중단 처리) +### 인터럽션(중단 처리) {#interruptions} 현재 Agents SDK는 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]에 내장된 인터럽션(중단 처리) 기능을 제공하지 않습니다. 대신 감지된 각 턴이 워크플로의 개별 실행을 트리거합니다. 애플리케이션 내에서 인터럽션(중단 처리)을 처리하려면 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 이벤트를 수신할 수 있습니다. `turn_started`은 새 턴이 텍스트로 변환되어 처리가 시작되고 있음을 나타냅니다. `turn_ended`은 해당 턴의 모든 오디오가 전송된 후 트리거됩니다. 이러한 이벤트를 사용하여 모델이 턴을 시작할 때 화자의 마이크를 음소거하고, 애플리케이션이 해당 턴과 관련된 모든 오디오 재생을 마친 후 음소거를 해제할 수 있습니다. \ No newline at end of file diff --git a/docs/ko/voice/quickstart.md b/docs/ko/voice/quickstart.md index 6fd5cb4f7f..db02ce03bc 100644 --- a/docs/ko/voice/quickstart.md +++ b/docs/ko/voice/quickstart.md @@ -4,7 +4,7 @@ search: --- # 빠른 시작 -## 사전 요구 사항 +## 사전 요구 사항 {#prerequisites} Agents SDK의 기본 [빠른 시작 지침](../quickstart.md)을 따르고 가상 환경을 설정했는지 확인합니다. 그런 다음 SDK에서 선택적 음성 의존성을 설치합니다. @@ -18,7 +18,7 @@ pip install 'openai-agents[voice]' pip install sounddevice ``` -## 개념 +## 개념 {#concepts} 알아야 할 주요 개념은 3단계 프로세스인 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline]입니다. @@ -52,7 +52,7 @@ graph LR ``` -## 에이전트 +## 에이전트 {#agents} 먼저 에이전트를 설정해 보겠습니다. 이 SDK로 에이전트를 만들어 본 적이 있다면 익숙하게 느껴질 것입니다. 두 개의 에이전트, 구성된 핸드오프, 도구 하나를 사용합니다. @@ -92,7 +92,7 @@ agent = Agent( ) ``` -## 음성 파이프라인 +## 음성 파이프라인 {#voice-pipeline} [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow]을 워크플로로 사용하여 간단한 음성 파이프라인을 설정합니다. @@ -101,7 +101,7 @@ from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` -## 파이프라인 실행 +## 파이프라인 실행 {#run-the-pipeline} ```python import numpy as np @@ -126,7 +126,7 @@ async for event in result.stream(): ``` -## 전체 코드 통합 +## 전체 코드 통합 {#put-it-all-together} ```python import asyncio diff --git a/docs/zh/agents.md b/docs/zh/agents.md index 119c5e8f26..57222052d9 100644 --- a/docs/zh/agents.md +++ b/docs/zh/agents.md @@ -10,7 +10,7 @@ search: 对于OpenAI模型,SDK 默认使用 Responses API,但这里的区别在于编排:`Agent` 加上 `Runner`,可让 SDK 为你管理轮次、工具、安全防护措施、任务转移和会话。如果你希望自行控制该循环,请改为直接使用 Responses API。 -## 后续指南选择 +## 后续指南选择 {#choose-the-next-guide} 可将本页面作为定义智能体的中心入口。根据你接下来需要做出的决策,跳转至相应的相邻指南。 @@ -25,7 +25,7 @@ search: | 检查最终输出、运行项或可恢复状态 | [结果](results.md) | | 共享本地依赖项和运行时状态 | [上下文管理](context.md) | -## 基本配置 +## 基本配置 {#basic-configuration} 智能体最常用的属性包括: @@ -67,7 +67,7 @@ agent = Agent( 本节中的所有内容均适用于 `Agent`。`SandboxAgent` 基于相同理念构建,并额外添加了 `default_manifest`、`base_instructions`、`capabilities` 和 `run_as`,用于工作区作用域内的运行。请参阅[沙箱智能体概念](sandbox/guide.md)。 -## 提示词模板 +## 提示词模板 {#prompt-templates} 通过设置 `prompt`,你可以引用在OpenAI平台中创建的提示词模板。当通过 Responses API 访问OpenAI模型时,此功能可用。 @@ -126,7 +126,7 @@ result = await Runner.run( ) ``` -## 上下文 +## 上下文 {#context} 智能体以其 `context` 类型作为泛型参数。上下文是一种依赖注入工具:它是由你创建并传递给 `Runner.run()` 的对象,随后会传递给每个智能体、工具、任务转移等,并作为智能体运行所需依赖项和状态的集合。你可以提供任何 Python 对象作为上下文。 @@ -154,7 +154,7 @@ agent = Agent[UserContext]( ) ``` -## 输出类型 +## 输出类型 {#output-types} 默认情况下,智能体生成纯文本(即 `str`)输出。如果你希望智能体生成特定类型的输出,可以使用 `output_type` 参数。常见选择是使用 [Pydantic](https://docs.pydantic.dev/) 对象,但我们支持任何可以封装在 Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) 中的类型,例如 dataclass、列表、TypedDict 等。 @@ -179,7 +179,7 @@ agent = Agent( 传入 `output_type` 后,即表示要求模型使用 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs),而不是常规纯文本响应。 -## 多智能体系统设计模式 +## 多智能体系统设计模式 {#multi-agent-system-design-patterns} 多智能体系统有多种设计方式,但我们通常会看到两种具有广泛适用性的模式: @@ -188,7 +188,7 @@ agent = Agent( 有关更多详细信息,请参阅[智能体构建实用指南](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)。 -### 管理器(agents as tools) +### 管理器(agents as tools) {#manager-agents-as-tools} `customer_facing_agent` 负责处理所有用户交互,并调用作为工具公开的专业子智能体。请在[工具](tools.md#agents-as-tools)文档中了解更多信息。 @@ -217,7 +217,7 @@ customer_facing_agent = Agent( ) ``` -### 任务转移 +### 任务转移 {#handoffs} 配置的任务转移目标是智能体可以委派任务的子智能体。发生任务转移时,被委派的智能体会接收对话历史记录并接管对话。此模式支持模块化的专业智能体,使其能够出色完成单一任务。请在[任务转移](handoffs.md)文档中了解更多信息。 @@ -238,7 +238,7 @@ triage_agent = Agent( ) ``` -## 动态指令 +## 动态指令 {#dynamic-instructions} 在大多数情况下,你可以在创建智能体时提供指令。不过,你也可以通过函数提供动态指令。该函数将接收智能体和上下文,并且必须返回提示词。普通函数和 `async` 函数均可接受。 @@ -257,7 +257,7 @@ agent = Agent[UserContext]( ) ``` -## 生命周期事件(钩子) +## 生命周期事件(钩子) {#lifecycle-events-hooks} 有时,你可能希望观察智能体的生命周期。例如,你可能希望在特定事件发生时记录事件日志、预取数据或记录用量。 @@ -302,11 +302,11 @@ print(result.final_output) 有关完整的回调接口,请参阅[生命周期 API 参考](ref/lifecycle.md)。 -## 安全防护措施 +## 安全防护措施 {#guardrails} 安全防护措施允许你在智能体运行的同时并行检查/验证用户输入,并在智能体生成输出后对其进行检查/验证。例如,你可以筛查用户输入和智能体输出是否与任务相关。请在[安全防护措施](guardrails.md)文档中了解更多信息。 -## 智能体克隆与复制 +## 智能体克隆与复制 {#cloningcopying-agents} 通过在智能体上使用 `clone()` 方法,你可以复制一个智能体,并可选择更改任意属性。 @@ -323,7 +323,7 @@ robot_agent = pirate_agent.clone( ) ``` -## 强制使用工具 +## 强制使用工具 {#forcing-tool-use} 提供工具列表并不总是意味着 LLM 会使用工具。你可以通过设置 [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice] 强制使用工具。有效值包括: @@ -351,7 +351,7 @@ agent = Agent( ) ``` -## 工具使用行为 +## 工具使用行为 {#tool-use-behavior} `Agent` 配置中的 `tool_use_behavior` 参数控制工具输出的处理方式: diff --git a/docs/zh/config.md b/docs/zh/config.md index 3aa12ab6a0..829935ce02 100644 --- a/docs/zh/config.md +++ b/docs/zh/config.md @@ -16,7 +16,7 @@ search: - [模型](models/index.md):了解模型选择和提供商配置。 - [追踪](tracing.md):了解每次运行的追踪元数据和自定义追踪处理器。 -## 配置对象与字典 +## 配置对象与字典 {#configuration-objects-and-dictionaries} SDK 定义的配置参数通常既接受相应的强类型设置对象,也接受包含相同字段的字典。这适用于类型注解中包含字典的智能体、运行、模型、会话、沙箱和语音配置边界。SDK 定义的嵌套设置类型也可以使用字典。 @@ -35,7 +35,7 @@ agent = Agent( SDK 会将这些字典规范化为相应的设置对象。对于 SDK 定义的 dataclass 配置类型,未知字段会引发 `TypeError`,这有助于尽早发现拼写错误的选项名称。请查看参数的类型注解或 API 参考文档,以确认特定配置边界是否接受字典。 -## API 密钥与客户端 +## API 密钥与客户端 {#api-keys-and-clients} 默认情况下,SDK 使用 `OPENAI_API_KEY` 环境变量处理 LLM 请求和追踪。SDK 首次创建OpenAI客户端时才会解析该密钥(延迟初始化),因此请在首次调用模型之前设置该环境变量。如果无法在应用启动前设置该环境变量,可以使用 [set_default_openai_key()][agents.set_default_openai_key] 函数设置密钥。 @@ -57,7 +57,7 @@ set_default_openai_client(custom_client) 向 [`OpenAIProvider`][agents.models.openai_provider.OpenAIProvider] 传入显式客户端后,该客户端将负责管理其连接和账户设置。请勿同时向 `OpenAIProvider` 传入 `api_key`、`base_url`、`websocket_base_url`、`organization` 或 `project`;将 `openai_client` 与其中任何参数结合使用时,会引发 [`UserError`][agents.exceptions.UserError],而不是静默忽略重复值。请在构造 `AsyncOpenAI` 时设置所需值。 -### 使用 `openai` v3 的自定义 HTTP 客户端 +### 使用 `openai` v3 的自定义 HTTP 客户端 {#custom-http-clients-with-openai-v3} 0.21.0 版本要求使用 `openai>=3.0.0,<4`。默认OpenAI提供商使用 HTTPX2,因此大多数应用不需要直接配置 HTTP 客户端。如果应用向 `AsyncOpenAI` 传入 `http_client=`,请为自定义客户端及其传输层相关选项使用 HTTPX2 类型: @@ -96,7 +96,7 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI提供商默认配置 +## OpenAI提供商默认配置 {#openai-provider-defaults} 使用 SDK OpenAI后端的提供商在将模型名称字符串映射到模型时,也会读取 SDK 全局默认配置。使用 [`set_default_openai_responses_transport()`][agents.set_default_openai_responses_transport] 可使OpenAI Responses 模型默认使用 WebSocket 传输: @@ -128,7 +128,7 @@ set_default_openai_agent_registration( 如果未设置 SDK 默认值,使用 SDK OpenAI后端的提供商会回退到 `OPENAI_AGENT_HARNESS_ID` 环境变量。配置 harness ID 后,SDK 会将其作为 `agent_harness_id` 添加到追踪元数据中,除非 `RunConfig.trace_metadata` 中已存在该键。 -## 追踪 +## 追踪 {#tracing} 追踪默认处于启用状态。默认情况下,它使用与上一节中的模型请求相同的OpenAI API 密钥,即环境变量中的密钥或设置的默认密钥。可以使用 [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 函数专门设置用于追踪的 API 密钥。 @@ -200,7 +200,7 @@ export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 有关完整的追踪控制选项,请参阅[追踪指南](tracing.md)。 -## 调试日志 +## 调试日志 {#debug-logging} SDK 定义了两个 Python 日志记录器(`openai.agents` 和 `openai.agents.tracing`),默认不附加处理器。日志遵循应用的 Python 日志配置。 @@ -231,7 +231,7 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 日志与诊断信息中的敏感数据 +### 日志与诊断信息中的敏感数据 {#sensitive-data-in-logs-and-diagnostics} 某些日志和诊断异常可能包含敏感数据,例如模型或工具的输入和输出。 diff --git a/docs/zh/context.md b/docs/zh/context.md index c98f5dc806..881b6d044d 100644 --- a/docs/zh/context.md +++ b/docs/zh/context.md @@ -9,7 +9,7 @@ search: 1. 你的代码在本地可用的上下文:这是工具函数运行时、`on_handoff` 等回调中、生命周期钩子中可能需要的数据和依赖项。 2. LLM 可用的上下文:这是 LLM 在生成响应时能够看到的数据。 -## 本地上下文 +## 本地上下文 {#local-context} 本地上下文由 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 类及其中的 [`context`][agents.run_context.RunContextWrapper.context] 属性表示。其工作方式如下: @@ -33,7 +33,7 @@ search: 在单次运行中,派生的包装器共享相同的底层应用上下文、审批状态和用量追踪。嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行可以附加不同的 `tool_input`,但默认情况下,它们不会获得应用状态的独立副本。 -### `RunContextWrapper` 提供的内容 +### `RunContextWrapper` 提供的内容 {#what-runcontextwrapper-exposes} [`RunContextWrapper`][agents.run_context.RunContextWrapper] 是应用自定义上下文对象的包装器。实际使用中,你最常用到的是: @@ -94,7 +94,7 @@ if __name__ == "__main__": --- -### 高级用法:`ToolContext` +### 高级用法:`ToolContext` {#advanced-toolcontext} 在某些情况下,你可能需要访问有关正在执行的工具的额外元数据,例如工具名称、调用 ID 或原始参数字符串。 为此,可以使用 [`ToolContext`][agents.tool_context.ToolContext] 类,它扩展了 `RunContextWrapper`。 @@ -140,7 +140,7 @@ agent = Agent( --- -## 智能体/LLM 上下文 +## 智能体/LLM 上下文 {#agentllm-context} 调用 LLM 时,它**唯一**能看到的数据来自对话历史记录。这意味着,如果希望 LLM 能够使用某些新数据,就必须以某种方式让这些数据出现在该历史记录中。具体有以下几种方式: diff --git a/docs/zh/examples.md b/docs/zh/examples.md index 5516a08ed3..1b35460fde 100644 --- a/docs/zh/examples.md +++ b/docs/zh/examples.md @@ -6,7 +6,7 @@ search: 请查看[仓库](https://github.com/openai/openai-agents-python/tree/main/examples)的代码示例部分,其中提供了多种使用 SDK 的实现。代码示例分为多个类别,展示了不同的模式和功能。 -## 类别 +## 类别 {#categories} - **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):**此类别中的代码示例展示了常见的智能体设计模式,例如 diff --git a/docs/zh/guardrails.md b/docs/zh/guardrails.md index 4d0aa36842..bd28ec059a 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -11,7 +11,7 @@ search: 1. 输入安全防护措施针对初始用户输入运行 2. 输出安全防护措施针对智能体的最终输出运行 -## 工作流边界 +## 工作流边界 {#workflow-boundaries} 安全防护措施会附加到智能体和工具上,但它们并非都在工作流中的相同节点运行: @@ -21,7 +21,7 @@ search: 如果需要在包含管理者、任务转移或受委派专家的工作流中,于每次自定义函数工具调用前和/或调用后执行检查,请使用工具安全防护措施,而不要只依赖智能体级别的输入/输出安全防护措施。 -## 输入安全防护措施 +## 输入安全防护措施 {#input-guardrails} 输入安全防护措施分 3 个步骤运行: @@ -33,7 +33,7 @@ search: 输入安全防护措施旨在针对用户输入运行,因此仅当某个智能体是*第一个*智能体时,才会运行该智能体的安全防护措施。你可能会疑惑,为什么 `guardrails` 属性位于智能体上,而不是传给 `Runner.run`?这是因为安全防护措施往往与实际的智能体相关——你会为不同智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 -### 执行模式 +### 执行模式 {#execution-modes} 输入安全防护措施支持两种执行模式: @@ -41,7 +41,7 @@ search: - **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将完全不会执行,从而避免消耗 token 和执行工具。这种模式非常适合成本优化,以及需要避免工具调用产生潜在副作用的场景。 -## 输出安全防护措施 +## 输出安全防护措施 {#output-guardrails} 输出安全防护措施分 3 个步骤运行: @@ -59,7 +59,7 @@ search: 终止型函数工具输出需要额外处理,因为在智能体级别的输出安全防护措施检查该值之前,工具已经运行。当 [`Agent.tool_use_behavior`][agents.agent.Agent.tool_use_behavior] 将该工具结果设为最终输出,而输出触发器将其拒绝时,只有在可以根据已验证字段重建函数调用/输出对的情况下,SDK 才会保留可有效重放的函数调用/输出对。保留的 `function_call_output` 载荷会替换为固定文本 `"Output withheld by an output guardrail."`;原始工具输出载荷不会保留在会话、`RunState`、流式传输结果状态或沙箱内存输入中。SDK 会保留重放所需的已验证函数调用元数据,包括函数参数,因此该元数据可能包含也曾出现在被拒绝输出中的数据。当前响应的 [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] 对象也会将 `agent_output` 替换为该固定文本,并清除 `output_info`。当前响应的 [`ToolOutputGuardrailResult`][agents.tool_guardrails.ToolOutputGuardrailResult] 对象会保留允许/拒绝行为类型,但会将包含载荷的 `output_info` 和拒绝消息替换为相同文本。此前已接受的轮次和安全防护措施结果保持不变。如果响应包含推理内容或其他 SDK 无法安全清理的结构,SDK 会丢弃当前响应的完整后缀,而不是保留被拒绝的输出载荷。抛出异常的安全防护措施函数并未返回拒绝判定,因此已完成的终止工具轮次会遵循上述异常持久化行为。 -## 工具安全防护措施 +## 工具安全防护措施 {#tool-guardrails} 工具安全防护措施会包装**`FunctionTool` 实例**,使你能够在这些工具执行前后验证或阻止对它们的调用。它们配置在工具本身上,并在每次调用该工具时运行。 @@ -70,7 +70,7 @@ search: 有关详情,请参阅下方代码片段。 -## 触发器 +## 触发器 {#tripwires} 如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即抛出 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止执行智能体。工具安全防护措施使用对应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 @@ -78,7 +78,7 @@ search: 工具触发器异常则会直接公开触发它的 `guardrail` 和 `output`。其中的 `run_data.tool_input_guardrail_results` 和 `run_data.tool_output_guardrail_results` 列表会保留失败前已完成轮次中累积的结果;触发结果可通过异常的 `output` 获取。其他由运行器管理的故障(例如 `MaxTurnsExceeded`)也会在这些列表中保留已完成的工具安全防护措施结果。在 `stream_events()` 抛出异常后,流式传输结果会公开相同的智能体和工具安全防护措施累积结果列表。在运行器管理的执行路径之外抛出异常时,`run_data` 可以是 `None`。 -## 安全防护措施实现 +## 安全防护措施实现 {#implementing-a-guardrail} 你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在底层运行一个智能体来实现这一点。 diff --git a/docs/zh/handoffs.md b/docs/zh/handoffs.md index e825f2d438..b2f59234ba 100644 --- a/docs/zh/handoffs.md +++ b/docs/zh/handoffs.md @@ -8,7 +8,7 @@ search: 任务转移以工具的形式呈现给LLM。因此,如果任务转移的目标是名为 `Refund Agent` 的智能体,则该工具将命名为 `transfer_to_refund_agent`。 -## 任务转移的创建 +## 任务转移的创建 {#creating-a-handoff} 所有智能体都有一个 [`handoffs`][agents.agent.Agent.handoffs] 参数,该参数既可以直接接收 `Agent`,也可以接收用于自定义任务转移的 `Handoff` 对象。 @@ -16,7 +16,7 @@ search: 你可以使用 Agents SDK 提供的 [`handoff()`][agents.handoffs.handoff] 函数创建任务转移。此函数允许你指定任务要转移到的智能体,以及可选的覆盖项和输入过滤器。 -### 基本用法 +### 基本用法 {#basic-usage} 以下是创建简单任务转移的方法: @@ -32,7 +32,7 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun 1. 你可以直接使用智能体(如 `billing_agent`),也可以使用 `handoff()` 函数。 -### 通过 `handoff()` 函数自定义任务转移 +### 通过 `handoff()` 函数自定义任务转移 {#customizing-handoffs-via-the-handoff-function} [`handoff()`][agents.handoffs.handoff] 函数支持自定义以下内容。 @@ -63,7 +63,7 @@ handoff_obj = handoff( ) ``` -## 任务转移输入 +## 任务转移输入 {#handoff-inputs} 在某些情况下,你希望LLM在调用任务转移时提供一些数据。例如,假设要将任务转移给“升级处理智能体”。你可能希望模型提供原因,以便记录日志。 @@ -93,7 +93,7 @@ handoff_obj = handoff( `input_type` 也独立于 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。`input_type` 应用于模型在任务转移时决定的元数据,而不是你已在本地拥有的应用状态或依赖项。 -### `input_type` 的适用场景 +### `input_type` 的适用场景 {#when-to-use-input_type} 当任务转移需要少量由模型生成的元数据(例如 `reason`、`language`、`priority` 或 `summary`)时,请使用 `input_type`。例如,分流智能体可以通过 `{ "reason": "duplicate_charge", "priority": "high" }` 将任务转移给退款智能体,而 `on_handoff` 可以在退款智能体接管之前记录或持久化该元数据。 @@ -104,7 +104,7 @@ handoff_obj = handoff( - 如果存在多个可能的专业智能体,请为每个目标注册一个任务转移。`input_type` 可以向所选任务转移添加元数据,但不会在不同目标之间进行分派。 - 如果希望在不转移对话的情况下为嵌套的专业智能体提供结构化输入,建议使用 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]。请参阅[工具](tools.md#structured-input-for-tool-agents)。 -## 输入过滤器 +## 输入过滤器 {#input-filters} 发生任务转移时,就像新智能体接管了对话,并且可以查看此前的完整对话历史记录。如果要更改这一行为,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回新的 `HandoffInputData`。 @@ -140,7 +140,7 @@ handoff_obj = handoff( 1. 调用 `FAQ agent` 时,这会自动从历史记录中移除所有与工具相关的项目。 -## 推荐提示词 +## 推荐提示词 {#recommended-prompts} 为确保LLM正确理解任务转移,我们建议在智能体中加入有关任务转移的信息。我们在 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] 中提供了建议的前缀,你也可以调用 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][],自动将建议的数据添加到提示词中。 diff --git a/docs/zh/human_in_the_loop.md b/docs/zh/human_in_the_loop.md index d46cbe3ed1..da2a0a36c3 100644 --- a/docs/zh/human_in_the_loop.md +++ b/docs/zh/human_in_the_loop.md @@ -12,7 +12,7 @@ search: 本页重点介绍通过 `interruptions` 实现的手动审批流程。如果你的应用可以通过代码做出决策,某些工具类型还支持程序化审批回调,使运行无需暂停即可继续。 -## 需审批工具的标记 +## 需审批工具的标记 {#marking-tools-that-need-approval} 将 `needs_approval` 设置为 `True` 可始终要求审批,也可以提供一个异步函数,针对每次调用分别做出决策。该可调用对象会接收运行上下文、解析后的工具参数和工具调用 ID。 @@ -46,7 +46,7 @@ agent = Agent( [`function_tool`][agents.tool.function_tool]、[`Agent.as_tool`][agents.agent.Agent.as_tool]、[`ShellTool`][agents.tool.ShellTool] 和 [`ApplyPatchTool`][agents.tool.ApplyPatchTool] 均提供 `needs_approval`。本地 MCP 服务器也支持通过 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]、[`MCPServerSse`][agents.mcp.server.MCPServerSse] 和 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] 上的 `require_approval` 进行审批。托管的 MCP 服务器通过 [`HostedMCPTool`][agents.tool.HostedMCPTool] 支持审批,其中使用 `tool_config={"require_approval": "always"}`,并可选择提供 `on_approval_request` 回调。如果你希望自动批准或自动拒绝,而不呈现中断项,Shell 和 apply_patch 工具可接受 `on_approval` 回调。 -## 审批流程 +## 审批流程 {#how-the-approval-flow-works} 1. 当模型发出工具调用时,运行器会评估其审批规则(`needs_approval`、`require_approval` 或托管 MCP 的对应规则)。 2. 如果该工具调用的审批决策已存储在 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 中,运行器将继续执行而不再提示。单次调用审批的作用域限定于特定调用 ID;传入 `always_approve=True` 或 `always_reject=True`,可在本次运行的剩余期间,为后续对同一工具标识的调用保留相同决策。 @@ -60,7 +60,7 @@ agent = Agent( 你不必在同一轮处理中解决所有待处理审批。`interruptions` 可以同时包含常规函数工具、托管 MCP 审批以及嵌套的 `Agent.as_tool()` 审批。如果你仅批准或拒绝部分项目后重新运行,已解决的调用可以继续,而未解决的调用仍会保留在 `interruptions` 中,并再次暂停运行。 -## 自定义拒绝消息 +## 自定义拒绝消息 {#custom-rejection-messages} 默认情况下,被拒绝的工具调用会将 SDK 的标准拒绝文本返回到运行中。你可以在两个层级自定义该消息: @@ -90,7 +90,7 @@ state.reject( 有关同时展示这两个层级的完整代码示例,请参阅 [`examples/agent_patterns/human_in_the_loop_custom_rejection.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/human_in_the_loop_custom_rejection.py)。 -## 自动审批决策 +## 自动审批决策 {#automatic-approval-decisions} 手动 `interruptions` 是最通用的模式,但并非唯一方式: @@ -100,13 +100,13 @@ state.reject( 当这些回调返回决策时,运行会继续,而无需暂停等待人工响应。对于 Realtime 和语音会话 API,请参阅 [Realtime 指南](realtime/guide.md)中的审批流程。 -## 流式传输与会话 +## 流式传输与会话 {#streaming-and-sessions} 同一中断流程也适用于流式运行。流式运行暂停后,应持续消费 [`RunResultStreaming.stream_events()`][agents.result.RunResultStreaming.stream_events],直到迭代器结束;然后检查 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]、解决其中的中断项,并在希望恢复后的输出继续进行流式传输时,使用 [`Runner.run_streamed(...)`][agents.run.Runner.run_streamed] 恢复。有关此模式的流式版本,请参阅[流式传输](streaming.md)。 如果你还使用了会话,请在从 `RunState` 恢复时继续传入同一个会话实例,或者传入针对相同会话 ID 和后端存储配置的另一个会话对象。恢复后的轮次随后会追加到同一份已存储的对话历史中。有关会话生命周期的详细信息,请参阅[会话](sessions/index.md)。 -## 暂停、批准与恢复示例 +## 暂停、批准与恢复示例 {#example-pause-approve-resume} 下面的代码片段与 JavaScript HITL 指南采用相同流程:它会在工具需要审批时暂停,将状态持久化到磁盘,重新加载状态,并在收集决策后恢复运行。 @@ -177,7 +177,7 @@ if __name__ == "__main__": 若要在可能因审批而暂停的运行中使用流式传输,请调用 `Runner.run_streamed`,消费 `result.stream_events()` 直至其完成,然后执行与上述相同的 `result.to_state()` 和恢复步骤。 -## 仓库模式与代码示例 +## 仓库模式与代码示例 {#repository-patterns-and-examples} - **流式审批**:`examples/agent_patterns/human_in_the_loop_stream.py` 展示了如何完整消费 `stream_events()`,然后批准待处理的工具调用,最后使用 `Runner.run_streamed(agent, state)` 恢复运行。 - **自定义拒绝文本**:`examples/agent_patterns/human_in_the_loop_custom_rejection.py` 展示了在审批被拒绝时,如何将运行级 `tool_error_formatter` 与单次调用的 `rejection_message` 覆盖设置结合使用。 @@ -188,7 +188,7 @@ if __name__ == "__main__": - **会话与记忆**:向 `Runner.run` 传入会话,使审批和对话历史能够跨多个轮次保留。SQLite 和 OpenAI Conversations 会话变体位于 `examples/memory/memory_session_hitl_example.py` 和 `examples/memory/openai_session_hitl_example.py` 中。 - **实时智能体**:实时演示提供了 WebSocket 消息,可通过 `RealtimeSession` 上的 `approve_tool_call` / `reject_tool_call` 批准或拒绝工具调用(有关服务器端处理程序,请参阅 `examples/realtime/app/server.py`;有关 API 接口,请参阅 [Realtime 指南](realtime/guide.md#tool-approvals))。 -## 长期审批 +## 长期审批 {#long-running-approvals} `RunState` 专为持久化而设计。使用 `state.to_json()` 或 `state.to_string()` 将待处理工作存储在数据库或队列中,之后再使用 `RunState.from_json(...)` 或 `RunState.from_string(...)` 重新创建它。 @@ -202,6 +202,6 @@ if __name__ == "__main__": 已序列化的运行状态包含应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量、已序列化的 `tool_input`、嵌套的智能体工具恢复信息、追踪元数据和服务器管理的对话设置。如果你计划存储或传输已序列化的状态,请将 `RunContextWrapper.context` 视为持久化数据;除非你明确希望密钥随状态一起传递,否则请避免将密钥放入其中。 -## 待处理任务的版本管理 +## 待处理任务的版本管理 {#versioning-pending-tasks} 如果审批可能长时间处于待处理状态,请将智能体定义或 SDK 的版本标记与已序列化状态一同存储。这样,你就可以将反序列化操作路由到匹配的代码路径,避免模型、提示词或工具定义发生变化时出现不兼容问题。 \ No newline at end of file diff --git a/docs/zh/index.md b/docs/zh/index.md index aeab85e8de..11b7020fc9 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -12,7 +12,7 @@ search: 这些基础组件与 Python 结合使用时,足以表达工具与智能体之间的复杂关系,让您无需经历陡峭的学习曲线即可构建实际应用。此外,SDK 还内置了**追踪**功能,让您能够可视化和调试智能体流程、对其进行评估,甚至针对您的应用微调模型。 -## Agents SDK 的使用理由 +## Agents SDK 的使用理由 {#why-use-the-agents-sdk} SDK 遵循两项核心设计原则: @@ -34,7 +34,7 @@ SDK 遵循两项核心设计原则: - **人在回路中**:用于在智能体运行期间引入人工参与的内置机制。 - **追踪**:用于可视化、调试和监控工作流的内置追踪功能,并支持 OpenAI 的评估、微调和蒸馏工具套件。 -## Agents SDK 与 Responses API 的选择 +## Agents SDK 与 Responses API 的选择 {#agents-sdk-or-responses-api} 对于 OpenAI 模型,SDK 默认使用 Responses API,但它会将模型调用封装在更高层级的运行时中。 @@ -51,13 +51,13 @@ SDK 遵循两项核心设计原则: 您无需在整个应用中只选择一种方式。许多应用会使用 SDK 管理工作流,同时针对较低层级的执行路径直接调用 Responses API。 -## 安装 +## 安装 {#installation} ```bash pip install openai-agents ``` -## Hello world 示例 +## Hello world 示例 {#hello-world-example} ```python from agents import Agent, Runner @@ -78,14 +78,14 @@ print(result.final_output) export OPENAI_API_KEY=sk-... ``` -## 入门 +## 入门 {#start-here} - 通过[快速入门](quickstart.md)构建您的第一个文本智能体。 - 然后在[运行智能体](running_agents.md#choose-a-memory-strategy)中决定如何跨轮次传递状态。 - 如果任务依赖真实文件、仓库或每个智能体独立的隔离工作区状态,请阅读[沙箱智能体快速入门](sandbox_agents.md)。 - 如果您正在任务转移与管理器式编排之间进行选择,请阅读[智能体编排](multi_agent.md)。 -## 路径选择 +## 路径选择 {#choose-your-path} 当您明确想完成的工作,但不确定应该参阅哪个页面时,请使用此表。 diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md index 4e8d8b72c8..fdb11c2323 100644 --- a/docs/zh/mcp.md +++ b/docs/zh/mcp.md @@ -16,7 +16,7 @@ Agents Python SDK 支持多种 MCP 传输方式。因此,你可以复用现有 MCP 工具可以公开模型上下文中的数据,并使用你提供的凭据执行操作。请仅连接你信任的服务器,使用最小权限凭据,将访问令牌放在授权字段或标头中而不是 URL 中,并要求对敏感操作进行审批。请参阅 [OpenAI MCP 安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 -## MCP 集成方式的选择 +## MCP 集成方式的选择 {#choosing-an-mcp-integration} 将 MCP 服务器接入智能体之前,请确定应在何处执行工具调用,以及你可以访问哪些传输方式。下表汇总了 Python SDK 支持的选项。 @@ -29,7 +29,7 @@ Agents Python SDK 支持多种 MCP 传输方式。因此,你可以复用现有 以下各节将逐一介绍每个选项、配置方式,以及何时应优先选择某种传输方式。 -## MCP Python SDK v1 与 v2 +## MCP Python SDK v1 与 v2 {#mcp-python-sdk-v1-and-v2} Agents SDK 通过依赖版本范围 `mcp>=1.19.0,<3` 支持 `mcp` Python 软件包的两个主要版本。已安装的 `mcp` 软件包版本与同服务器协商的 MCP 协议版本相互独立。Agents SDK 会检测已安装软件包的主版本,并自动适配 stdio、SSE 和 Streamable HTTP 连接,因此普通服务器配置不需要提供版本切换选项。 @@ -57,7 +57,7 @@ HTTP 传输自定义必须使用已安装 MCP 软件包所拥有的 HTTP 栈: 这些本地 `mcp` 依赖要求不适用于 [`HostedMCPTool`][agents.tool.HostedMCPTool],因为远程 MCP 连接由OpenAI Responses API 管理。 -## 智能体级 MCP 配置 +## 智能体级 MCP 配置 {#agent-level-mcp-configuration} 除了选择传输方式外,还可以通过设置 `Agent.mcp_config` 调整 MCP 工具的准备方式。 @@ -87,7 +87,7 @@ agent = Agent( - 服务器级 `failure_error_function` 会覆盖该服务器的 `Agent.mcp_config["failure_error_function"]`。 - `include_server_in_tool_names` 需要主动启用。启用后,每个本地 MCP 工具都会使用确定性的服务器前缀名称向模型公开,有助于避免多个 MCP 服务器发布同名工具时发生冲突。生成的名称兼容 ASCII,不会超过 `FunctionTool` 实例的名称长度限制,也不会与同一智能体上本地 `FunctionTool` 实例的已配置名称或已启用任务转移发生冲突。SDK 仍会在原始服务器上调用具有原始名称的 MCP 工具。 -## 各传输方式的通用模式 +## 各传输方式的通用模式 {#shared-patterns-across-transports} 选择传输方式后,大多数集成还需要作出相同的后续决策: @@ -98,11 +98,11 @@ agent = Agent( 对于本地 MCP 服务器(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`),审批策略和每次调用的 `_meta` 载荷也是通用概念。Streamable HTTP 一节给出了最完整的代码示例,同样的模式也适用于其他本地传输方式。 -## 1. 托管式 MCP 服务器工具 +## 1. 托管式 MCP 服务器工具 {#1-hosted-mcp-server-tools} 托管工具会将整个工具调用往返流程交由OpenAI基础设施处理。你的代码无需列出和调用工具,[`HostedMCPTool`][agents.tool.HostedMCPTool] 会将服务器标签(以及可选的连接器元数据)转发给 Responses API。模型会列出远程服务器的工具并调用它们,而无需额外回调你的 Python 进程。目前,托管工具适用于支持 Responses API 托管式 MCP 集成的OpenAI模型。 -### 基础托管式 MCP 工具 +### 基础托管式 MCP 工具 {#basic-hosted-mcp-tool} 将 [`HostedMCPTool`][agents.tool.HostedMCPTool] 添加到智能体的 `tools` 列表,即可创建托管工具。`tool_config` 字典与发送给 REST API 的 JSON 相对应: @@ -141,7 +141,7 @@ asyncio.run(main()) 如果希望托管工具搜索以延迟加载方式加载托管式 MCP 服务器,请设置 `tool_config["defer_loading"] = True`,并将 [`ToolSearchTool`][agents.tool.ToolSearchTool] 添加到智能体。仅OpenAI Responses 模型支持此功能。有关完整的工具搜索设置和限制,请参阅[工具](tools.md#hosted-tool-search)。 -### 托管式 MCP 结果的流式传输 +### 托管式 MCP 结果的流式传输 {#streaming-hosted-mcp-results} 托管工具支持流式传输结果,其方式与函数工具完全相同。使用 `Runner.run_streamed` 可在模型仍在工作时接收增量 MCP 输出: @@ -154,7 +154,7 @@ async for event in result.stream_events(): print(result.final_output) ``` -### 可选审批流程 +### 可选审批流程 {#optional-approval-flows} 如果服务器能够执行敏感操作,可以要求在每次执行工具前进行人工或程序化审批。在 `tool_config` 中配置 `require_approval`,其值可以是单一策略(`"always"`、`"never"`),也可以是将工具名称映射到策略的字典。若要在 Python 中作出决定,请提供 `on_approval_request` 回调。 @@ -186,7 +186,7 @@ agent = Agent( 该回调可以是同步或异步的,并且每当模型需要审批数据才能继续运行时都会调用它。 -### 由连接器支持的托管服务器 +### 由连接器支持的托管服务器 {#connector-backed-hosted-servers} 托管式 MCP 还支持OpenAI连接器。无需指定 `server_url`,只需提供 `connector_id` 和访问令牌。Responses API 会处理身份验证,托管服务器则会公开连接器的工具。 @@ -206,7 +206,7 @@ HostedMCPTool( 完整可运行的托管工具代码示例(包括流式传输、审批和连接器)位于 [`examples/hosted_mcp`](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp)。 -## 2. Streamable HTTP MCP 服务器 +## 2. Streamable HTTP MCP 服务器 {#2-streamable-http-mcp-servers} 如果希望自行管理网络连接,请使用 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。如果你需要控制传输方式,或者希望在自己的基础设施中运行服务器并保持较低延迟,Streamable HTTP 服务器是理想选择。 @@ -253,7 +253,7 @@ asyncio.run(main()) - `failure_error_function` 用于自定义模型可见的 MCP 工具失败消息;将其设置为 `None` 可改为抛出错误。 - `tool_meta_resolver` 会在 `call_tool()` 之前注入每次调用的 MCP `_meta` 载荷。 -### 本地 MCP 服务器的审批策略 +### 本地 MCP 服务器的审批策略 {#approval-policies-for-local-mcp-servers} `MCPServerStdio`、`MCPServerSse` 和 `MCPServerStreamableHttp` 均接受 `require_approval`。 @@ -275,7 +275,7 @@ async with MCPServerStreamableHttp( 有关完整的暂停/恢复流程,请参阅[人机协同](human_in_the_loop.md)和 `examples/mcp/get_all_mcp_tools_example/main.py`。 -### 使用 `tool_meta_resolver` 的每次调用元数据 +### 使用 `tool_meta_resolver` 的每次调用元数据 {#per-call-metadata-with-tool_meta_resolver} 当 MCP 服务器要求在 `_meta` 中提供请求元数据(例如租户 ID 或追踪上下文)时,请使用 `tool_meta_resolver`。以下代码示例假设你将 `dict` 作为 `context` 传递给 `Runner.run(...)`。 @@ -300,11 +300,11 @@ server = MCPServerStreamableHttp( 如果运行上下文是 Pydantic 模型、dataclass 或自定义类,请改用属性访问方式读取租户 ID。 -### MCP 工具输出:文本、图像及其他内容 +### MCP 工具输出:文本、图像及其他内容 {#mcp-tool-outputs-text-images-and-other-content} 当 MCP 结果使用内容块时,SDK 会将文本内容作为文本输出转发,并将图像内容映射为工具输出中的图像类型条目。对于其他 MCP 内容块类型(包括音频和资源块),SDK 会转发文本输出,其值为该内容块的有效 JSON 序列化结果。包含多个内容块的响应会作为输出项列表转发。如果 `use_structured_content=True` 选择了非空且无错误的 `structuredContent` 载荷,则该结构化载荷优先于这些内容块。结构化内容缺失或为空时,会回退到内容块。 -## 3. 基于 SSE 的 HTTP MCP 服务器 +## 3. 基于 SSE 的 HTTP MCP 服务器 {#3-http-with-sse-mcp-servers} !!! warning @@ -337,7 +337,7 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP 服务器 +## 4. stdio MCP 服务器 {#4-stdio-mcp-servers} 对于以本地子进程方式运行的 MCP 服务器,请使用 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK 会启动该进程、保持管道打开,并在退出上下文管理器时自动关闭管道。此选项适合快速构建概念验证,或服务器仅公开命令行入口点的情况。 @@ -365,7 +365,7 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP 服务器管理器 +## 5. MCP 服务器管理器 {#5-mcp-server-manager} 如果有多个 MCP 服务器,请使用 `MCPServerManager` 预先连接它们,并向智能体公开其中成功连接的服务器子集。有关构造函数选项和重新连接行为,请参阅 [MCPServerManager API 参考](ref/mcp/manager.md)。 @@ -397,15 +397,15 @@ async with MCPServerManager(servers) as manager: - 对 `connect_all()`、`reconnect()` 和 `cleanup_all()` 的调用会串行执行。如果某个生命周期操作已在运行,另一个生命周期操作会等待其完成,而不会并发连接或清理相同的服务器。 - 设置 `connect_timeout_seconds`、`cleanup_timeout_seconds` 和 `connect_in_parallel` 可调整生命周期行为。两个生命周期超时的默认值均为 10 秒。它们接受有限正秒数,或使用 `None` 将其禁用,并且在构造和赋值时都会进行验证;零会被拒绝,因为它会产生立即到期的截止时间。 -## 通用服务器能力 +## 通用服务器能力 {#common-server-capabilities} 以下各节适用于所有 MCP 服务器传输方式(具体 API 范围取决于服务器类)。 -## 工具筛选 +## 工具筛选 {#tool-filtering} 每个 MCP 服务器都支持工具筛选器,因此你可以仅公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 -### 静态工具筛选 +### 静态工具筛选 {#static-tool-filtering} 使用 [`create_static_tool_filter`][agents.mcp.create_static_tool_filter] 配置简单的允许列表和阻止列表: @@ -427,7 +427,7 @@ filesystem_server = MCPServerStdio( 同时提供 `allowed_tool_names` 和 `blocked_tool_names` 时,SDK 会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 -### 动态工具筛选 +### 动态工具筛选 {#dynamic-tool-filtering} 对于更复杂的逻辑,请传入一个可调用对象,该对象接收 [`ToolFilterContext`][agents.mcp.ToolFilterContext]。该可调用对象可以是同步或异步的,并在应公开工具时返回 `True`。 @@ -455,7 +455,7 @@ async with MCPServerStdio( 筛选器上下文会公开活动的 `run_context`、请求工具的 `agent`,以及 `server_name`。 -## 提示词 +## 提示词 {#prompts} MCP 服务器还可以提供动态生成智能体指令的提示词。支持提示词的服务器会公开两种 方法: @@ -479,17 +479,17 @@ agent = Agent( ) ``` -## 分页 +## 分页 {#pagination} 内置的本地 MCP 服务器类在列出工具和提示词时,会自动跟随 `nextCursor`。`list_tools()` 会先收集完整的工具列表,再应用筛选器或填充缓存;`list_prompts()` 则返回一个合并结果,其中包含 `nextCursor=None`。如果后续页面失败或服务器重复使用游标,该操作会抛出错误,而不会公开或缓存部分结果。 资源仍需显式分页。将 `list_resources()` 或 `list_resource_templates()` 返回的 `nextCursor` 作为 `cursor` 参数传回,以获取下一页。 -## 缓存 +## 缓存 {#caching} 每次智能体运行都会在每个 MCP 服务器上调用 `list_tools()`。远程服务器可能带来明显的延迟,因此所有 MCP 服务器类都公开了 `cache_tools_list` 选项。仅当你确信工具定义不会频繁变化时,才应将其设置为 `True`。如需稍后强制获取最新列表,请在服务器实例上调用 `invalidate_tools_cache()`。 -## 追踪 +## 追踪 {#tracing} [追踪](./tracing.md)会自动捕获 MCP 活动,包括: @@ -498,7 +498,7 @@ agent = Agent( ![MCP 追踪截图](../assets/images/mcp-tracing.jpg) -## 延伸阅读 +## 延伸阅读 {#further-reading} - [Model Context Protocol](https://modelcontextprotocol.io/) – 规范和设计指南。 - [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp) – 可运行的 stdio、SSE 和 Streamable HTTP 代码示例。 diff --git a/docs/zh/models/index.md b/docs/zh/models/index.md index eae057bff4..6f7975e784 100644 --- a/docs/zh/models/index.md +++ b/docs/zh/models/index.md @@ -9,7 +9,7 @@ Agents SDK 开箱即用地支持两种 OpenAI 模型: - **推荐**:[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel],使用新的 [Responses API](https://platform.openai.com/docs/api-reference/responses) 调用 OpenAI API。 - [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel],使用 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) 调用 OpenAI API。 -## 模型配置选择 +## 模型配置选择 {#choosing-a-model-setup} 从最符合您配置的最简单方案开始: @@ -23,7 +23,7 @@ Agents SDK 开箱即用地支持两种 OpenAI 模型: | 调整高级 OpenAI Responses 请求设置 | 在 OpenAI Responses 路径上使用 `ModelSettings` | [高级 OpenAI Responses 设置](#advanced-openai-responses-settings) | | 使用第三方适配器进行非 OpenAI或混合提供商路由 | 比较受支持的 Beta 版适配器,并验证您计划发布的提供商路径 | [第三方适配器](#third-party-adapters) | -## OpenAI模型 +## OpenAI模型 {#openai-models} 对于大多数仅使用 OpenAI的应用,推荐使用默认 OpenAI提供商配合字符串模型名称,并保持使用 Responses 模型路径。 @@ -31,7 +31,7 @@ Agents SDK 开箱即用地支持两种 OpenAI 模型: 如果您想切换到 `gpt-5.6-sol` 等其他模型,可以通过两种方式配置智能体。 -### 默认模型 +### 默认模型 {#default-model} 首先,如果您希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体之前设置 `OPENAI_DEFAULT_MODEL` 环境变量。 @@ -57,7 +57,7 @@ result = await Runner.run( ) ``` -#### GPT-5 模型 +#### GPT-5 模型 {#gpt-5-models} 以这种方式使用任何 GPT-5 模型(例如 `gpt-5.6-sol`)时,SDK 会应用默认的 `ModelSettings`,其中设置了适合大多数用例的最佳选项。若要调整默认模型的推理强度,请传入您自己的 `ModelSettings`: @@ -100,7 +100,7 @@ agent = Agent( 使用 `context="all_turns"` 时,请通过 `previous_response_id`、服务端 Responses API 对话,或在下一个请求中包含先前的推理项来保留对话。对于无状态的 `store=False` 调用,请在响应中请求 `reasoning.encrypted_content`,然后在下一个请求中将这些推理项作为输入。 -#### ComputerTool 模型选择 +#### ComputerTool 模型选择 {#computertool-model-selection} 如果智能体包含 [`ComputerTool`][agents.tool.ComputerTool],则实际 Responses 请求上的有效模型将决定 SDK 发送哪种计算机工具载荷。显式的 `gpt-5.5` 请求使用正式发布的内置 `computer` 工具,而显式的 `computer-use-preview` 请求继续使用较旧的 `computer_use_preview` 载荷。 @@ -110,11 +110,11 @@ agent = Agent( 与预览版兼容的请求必须预先序列化 `environment` 和显示尺寸,因此,使用 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂、由提示词管理的流程应传入具体的 `Computer` 或 `AsyncComputer` 实例,或者在发送请求前强制使用正式发布版选择器。有关完整迁移详情,请参阅[工具](../tools.md#computertool-and-the-responses-computer-tool)。 -#### 非 GPT-5 模型 +#### 非 GPT-5 模型 {#non-gpt-5-models} 如果您传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 将恢复为与任何模型兼容的通用 `ModelSettings`。 -### Responses 专属工具功能 +### Responses 专属工具功能 {#responses-only-tool-features} 以下工具功能仅受 OpenAI Responses 模型支持: @@ -125,11 +125,11 @@ agent = Agent( Chat Completions 模型和非 Responses 后端会拒绝这些功能。使用延迟加载工具时,请将 `ToolSearchTool()` 添加到智能体,并让模型通过 `auto` 或 `required` 工具选择来加载工具,而不是强制使用纯命名空间名称或仅限延迟加载的函数名称。有关配置详情和当前限制,请参阅[托管工具搜索](../tools.md#hosted-tool-search)和[程序化工具调用](../tools.md#programmatic-tool-calling)。 -### Responses WebSocket 传输 +### Responses WebSocket 传输 {#responses-websocket-transport} 默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用 OpenAI Responses 提供商路径时,您可以选择启用 WebSocket 传输。 -#### 基本配置 +#### 基本配置 {#basic-setup} ```python from agents import set_default_openai_responses_transport @@ -141,7 +141,7 @@ set_default_openai_responses_transport("websocket") 传输方式的选择发生在 SDK 将模型名称解析为模型实例时。如果传入具体的 [`Model`][agents.models.interface.Model] 对象,其传输方式已经固定:[ `OpenAIResponsesWSModel`][agents.models.openai_responses.OpenAIResponsesWSModel] 使用 WebSocket,[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 使用 HTTP,而 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] 仍使用 Chat Completions。如果传入 `RunConfig(model_provider=...)`,则由该提供商而非全局默认设置控制传输方式的选择。 -#### 提供商或运行级配置 +#### 提供商或运行级配置 {#provider-or-run-level-setup} 您也可以按提供商或按运行配置 WebSocket 传输: @@ -188,7 +188,7 @@ result = await Runner.run( ) ``` -#### 使用 `MultiProvider` 的高级路由 +#### 使用 `MultiProvider` 的高级路由 {#advanced-routing-with-multiprovider} 如果您需要基于前缀的模型路由,例如在一次运行中混用 `openai/...` 和 `any-llm/...` 模型名称,请使用 [`MultiProvider`][agents.MultiProvider],并在其中设置 `openai_use_responses_websocket=True`。 @@ -229,7 +229,7 @@ result = await Runner.run( 如果使用自定义 OpenAI兼容端点或代理,WebSocket 传输还要求提供兼容的 WebSocket `/responses` 端点。在这些配置中,您可能需要显式设置 `websocket_base_url`。 -#### 注意事项 +#### 注意事项 {#notes} - 这是通过 WebSocket 传输的 Responses API,而不是 [Realtime API](../realtime/guide.md)。它不适用于 Chat Completions。仅当非 OpenAI提供商支持 Responses WebSocket `/responses` 端点时,才适用于这些提供商。 - 如果您的环境中尚未提供 `websockets` 包,请安装它。 @@ -239,13 +239,13 @@ result = await Runner.run( - [Responses API WebSocket 服务](https://developers.openai.com/api/docs/guides/websocket-mode)在每个连接上一次处理一个响应,并将每个连接限制为 60 分钟。达到该限制后,请打开新连接;需要并行运行时,请使用多个连接。 - 该服务仅在连接本地内存中保留最近一次响应。失败的 `4xx` 或 `5xx` 轮次会从该内存中逐出 `previous_response_id` 引用的响应。重新连接后,存储的响应在可用时仍可继续,但 `store=False` 和 ZDR 流程没有持久化回退方案。请使用 `previous_response_id=None` 开始新的链并发送完整的输入上下文,或根据本地管理的会话状态重建该上下文。 -### 托管多智能体(实验性) +### 托管多智能体(实验性) {#hosted-multi-agent-experimental} OpenAI Responses API 的托管多智能体 Beta 版允许 GPT-5.6 根模型创建并协调服务端托管的子智能体。Agents SDK 可以继续使用其常规的 `Runner`:托管编排在服务端进行,而开发者定义的函数工具在您的应用中执行。 此集成为实验性功能,并使用 Responses WebSocket 传输,以便通过 `response.inject` 将本地函数输出返回给活动的托管智能体。它要求使用 `openai[realtime]` 2.45.0 或更高版本的构建,且该构建需公开 `client.beta.responses.connect`。该接口和 Beta 版项目架构可能会在正式发布前发生变化。 -#### 模型配置 +#### 模型配置 {#configure-the-model} 从实验性模块导入模型,并将其分配给 SDK `Agent`: @@ -262,7 +262,7 @@ agent = Agent( 构造 `OpenAIHostedMultiAgentModel` 会启用 `multi_agent.enabled`,并发送 `OpenAI-Beta: responses_multi_agent=v1` WebSocket 标头。除非提供 `openai_client`,否则模型将使用默认 OpenAI客户端。如果省略 `max_concurrent_subagents`,则使用服务默认值。 -#### 本地函数工具 +#### 本地函数工具 {#local-function-tools} 所有托管智能体共享为该请求配置的模型和工具。Responses API 决定由哪个托管智能体调用函数。常规 SDK Runner 在本地执行函数,并将具有相同调用 ID 的 `function_call_output` 注入活动的 WebSocket 响应,从而让服务恢复原始托管调用方。函数执行仍会经过 Runner 的常规安全防护措施、钩子和失败转换。不支持 SDK 工具审批中断:任何 `needs_approval` 设置不为 `False` 的函数工具,都会在请求发送前被拒绝。 @@ -285,13 +285,13 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: 托管智能体名称是观察性元数据,而不是本地路由机制。请使用 SDK 提供的调用 ID 路由输出。对于具有副作用的工具,请将该调用 ID 用作幂等键,并在工具执行之前或期间,通过应用代码实施任何必要的授权;不要对此模型使用 `needs_approval`。工具参数和输出会跨越 Responses API 边界。 -#### 输出和流式传输行为 +#### 输出和流式传输行为 {#output-and-streaming-behavior} 只有归属于 `/root` 且阶段为 `final_answer` 的消息才会成为常规最终消息。实验性适配器会从高级 `RunResult` 中过滤掉子智能体消息和托管编排记录;SDK 绝不会将这些记录作为本地函数执行。 原始流式传输会继续公开 Beta 版 Responses 事件,包括托管输出项和 `response.inject.created` 确认。当函数调用就绪时,适配器会将一个活动的提供商响应拆分为 SDK 可见的逻辑模型轮次,然后在 Runner 生成输出后恢复同一个提供商响应。使用原始托管项目或 `ToolContext` 的 `get_hosted_agent_metadata()`,可识别项目或工具调用归属的托管智能体。 -#### 与 SDK 编排的关系 +#### 与 SDK 编排的关系 {#relationship-to-sdk-orchestration} 托管多智能体独立于 SDK 任务转移和 Agents-as-tools: @@ -299,7 +299,7 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: - SDK 任务转移会更改活动的本地 SDK `Agent`。使用此实验性模型时会拒绝任务转移,因为每个托管智能体都会收到相同的任务转移工具,这会导致所有权冲突。 - Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估额外的延迟、成本和工具暴露。 -#### 当前限制 +#### 当前限制 {#current-limitations} 实验性模型会拒绝 `reasoning.summary`、`max_tool_calls`,以及调用方提供的 `multi_agent` 或 `betas` 覆盖值。Beta 版不支持 Responses `/compact` 端点,但可以使用显式的 `context_management.compact_threshold`,因为服务会自动独立压缩每个托管智能体的上下文。 @@ -307,11 +307,11 @@ def lookup_document(ctx: ToolContext[Any], section: str) -> str: 有关底层 Responses API Beta 版行为,请参阅 [OpenAI多智能体指南](https://developers.openai.com/api/docs/guides/tools-multi-agent)。有关非流式和流式 SDK 用法,请参阅 [`examples/agent_patterns/hosted_multi_agent_beta.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/hosted_multi_agent_beta.py)。 -## 非 OpenAI模型 +## 非 OpenAI模型 {#non-openai-models} 如果您需要非 OpenAI提供商,请从 SDK 的内置提供商集成点开始。在许多配置中,无需添加第三方适配器即可满足需求。每种模式的代码示例位于 [examples/model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)。 -### 非 OpenAI提供商集成方式 +### 非 OpenAI提供商集成方式 {#ways-to-integrate-non-openai-providers} | 方式 | 适用场景 | 作用域 | | --- | --- | --- | @@ -343,7 +343,7 @@ agent= Agent(name="Helping Agent", instructions="You are a Helping Agent", model 在这些代码示例中,我们使用 Chat Completions API/模型,因为许多 LLM 提供商仍不支持 Responses API。如果您的 LLM 提供商支持该 API,我们建议使用 Responses。 -## 在一个工作流中混用模型 +## 在一个工作流中混用模型 {#mixing-models-in-one-workflow} 在单个工作流中,您可能希望每个智能体使用不同模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`Agent`][agents.Agent] 时,可以通过以下任一方式选择特定模型: @@ -407,11 +407,11 @@ english_agent = Agent( ) ``` -## 高级 OpenAI Responses 设置 +## 高级 OpenAI Responses 设置 {#advanced-openai-responses-settings} 当您使用 OpenAI Responses 路径并需要更多控制时,请从 `ModelSettings` 开始。 -### 常用高级 `ModelSettings` 选项 +### 常用高级 `ModelSettings` 选项 {#common-advanced-modelsettings-options} 使用 OpenAI Responses API 时,若干请求字段已经有直接对应的 `ModelSettings` 字段,因此无需为它们使用 `extra_args`。 @@ -477,7 +477,7 @@ result = await Runner.run( 服务端压缩不同于 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession]。`context_management=[{"type": "compaction", "compact_threshold": ...}]` 会随每个 Responses API 请求发送,当渲染后的上下文超过阈值时,API 可以在响应中生成压缩项。`OpenAIResponsesCompactionSession` 会在轮次之间调用独立的 `responses.compact` 端点,并重写本地会话历史记录。 -### `extra_args` 的传递 +### `extra_args` 的传递 {#passing-extra_args} 如果需要 SDK 尚未直接在顶层公开的提供商特定字段或较新的请求字段,请使用 `extra_args`。 @@ -497,7 +497,7 @@ english_agent = Agent( ) ``` -## 模型调用超时 +## 模型调用超时 {#model-call-timeouts} 将 [`ModelSettings.timeout`][agents.model_settings.ModelSettings.timeout] 设置为正数秒值,以限制每次模型调用尝试。该超时适用于流式和非流式调用,并涵盖完整的调用尝试,包括等待传输的时间。它不会限制完整的智能体运行、函数工具执行或重试退避。 @@ -512,7 +512,7 @@ agent = Agent( 如果一次尝试超过限制,SDK 会取消该尝试并等待其清理完成,然后引发 [`ModelTimeoutError`][agents.exceptions.ModelTimeoutError]。启用由 Runner 管理的重试时,SDK 会将超时失败传递给重试策略,并将 `context.normalized.is_timeout` 设置为 `True`;例如,`retry_policies.network_error()` 会匹配该分类。每次允许的重试都会获得新的单次尝试超时。重试前,SDK 仍会应用常规的[重放安全规则](#safety-boundaries)。 -## 由 Runner 管理的重试 +## 由 Runner 管理的重试 {#runner-managed-retries} 重试仅在运行时生效,并且需要选择启用。除非您设置 `ModelSettings(retry=...)` 且重试策略决定重试,否则 SDK 不会重试常规模型请求。 @@ -584,7 +584,7 @@ SDK 在 `retry_policies` 上导出了现成的辅助工具: 组合策略时,`provider_suggested()` 是最安全的第一个基本组件,因为当提供商能够区分否决和重放安全批准时,它会保留这些信息。 -##### 安全边界 +##### 安全边界 {#safety-boundaries} 某些失败永远不会重试: @@ -596,7 +596,7 @@ SDK 在 `retry_policies` 上导出了现成的辅助工具: 使用 `previous_response_id` 或 `conversation_id` 的有状态后续请求会在重放安全性未知时以关闭方式失败。对于这些请求,`network_error()` 或 `http_status([500])` 等非提供商谓词本身并不足够。请包含提供商提供的重放安全批准(通常通过 `retry_policies.provider_suggested()`),或按照上述方式显式批准提供商标记为不安全的非流式失败。 -##### Runner 和智能体合并行为 +##### Runner 和智能体合并行为 {#runner-and-agent-merge-behavior} Runner 级与智能体级 `ModelSettings` 之间会对 `retry` 进行深度合并: @@ -606,9 +606,9 @@ Runner 级与智能体级 `ModelSettings` 之间会对 `retry` 进行深度合 有关更完整的代码示例,请参阅 [`examples/basic/retry.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry.py) 和[基于适配器的重试代码示例](https://github.com/openai/openai-agents-python/tree/main/examples/basic/retry_litellm.py)。 -## 非 OpenAI提供商故障排除 +## 非 OpenAI提供商故障排除 {#troubleshooting-non-openai-providers} -### 追踪客户端错误 401 +### 追踪客户端错误 401 {#tracing-client-error-401} 如果遇到与追踪相关的错误,这是因为追踪数据会上传到 OpenAI服务器,而您没有 OpenAI API 密钥。您有以下三种解决方案: @@ -616,14 +616,14 @@ Runner 级与智能体级 `ModelSettings` 之间会对 `retry` 进行深度合 2. 为追踪设置 OpenAI密钥:[`set_tracing_export_api_key(...)`][agents.set_tracing_export_api_key]。此 API 密钥仅用于上传追踪数据,并且必须来自 [platform.openai.com](https://platform.openai.com/)。 3. 使用非 OpenAI追踪处理器。请参阅[追踪文档](../tracing.md#custom-tracing-processors)。 -### Responses API 支持 +### Responses API 支持 {#responses-api-support} SDK 默认使用 Responses API,但许多其他 LLM 提供商仍不支持它。因此,您可能会看到 404 或类似问题。您有以下两个解决方案: 1. 调用 [`set_default_openai_api("chat_completions")`][agents.set_default_openai_api]。如果您通过环境变量设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`,此方法有效。 2. 使用 [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel]。[此处](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers/)提供了一些代码示例。 -### Chat Completions 兼容性选项 +### Chat Completions 兼容性选项 {#chat-completions-compatibility-options} 通过 Chat Completions 路由时,SDK 会静默丢弃 Chat Completions 无法发送的 Responses 专属字段,从而保持兼容性,例如 `previous_response_id`、`conversation_id`、Responses API 的 `prompt` 字段,或并非纯文本的工具输出。如果您希望在开发过程中让这些不匹配情况快速失败,请在 OpenAI提供商上启用严格功能验证: @@ -660,7 +660,7 @@ provider = OpenAIProvider( 对于 [`MultiProvider`][agents.MultiProvider],请使用 `openai_buffer_streamed_tool_calls=True`。 -### structured outputs 支持 +### structured outputs 支持 {#structured-outputs-support} 某些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: @@ -672,7 +672,7 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' 这是某些模型提供商的不足之处——它们支持 JSON 输出,但不允许您指定输出使用的 `json_schema`。我们正在修复此问题,但建议依赖支持 JSON schema 输出的提供商,否则您的应用会经常因格式错误的 JSON 而中断。 -## 跨提供商混用模型 +## 跨提供商混用模型 {#mixing-models-across-providers} 您需要注意模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI支持 structured outputs、多模态输入,以及托管的文件检索和网络检索,但许多其他提供商并不支持这些功能。请注意以下限制: @@ -680,11 +680,11 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' - 在调用纯文本模型前过滤掉多模态输入 - 请注意,不支持结构化 JSON 输出的提供商偶尔会生成无效 JSON。 -## 第三方适配器 +## 第三方适配器 {#third-party-adapters} 仅当 SDK 的内置提供商集成点不足以满足需求时,才应使用第三方适配器。如果您在此 SDK 中仅使用 OpenAI模型,请优先使用内置的 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 路径,而不是 Any-LLM 或 LiteLLM。第三方适配器适用于需要将 OpenAI模型与非 OpenAI提供商结合使用,或需要仅由适配器提供的提供商覆盖范围或路由的情况。适配器会在 SDK 与上游模型提供商之间增加另一个兼容层,因此功能支持和请求语义可能因提供商而异。SDK 目前以尽力支持的 Beta 版适配器集成形式包含 Any-LLM 和 LiteLLM。 -### Any-LLM +### Any-LLM {#any-llm} Any-LLM 支持以尽力支持的 Beta 版形式提供,适用于需要由 Any-LLM 管理提供商覆盖范围或路由的情况。 @@ -694,7 +694,7 @@ Any-LLM 支持以尽力支持的 Beta 版形式提供,适用于需要由 Any-L Any-LLM 仍然是第三方适配器层,因此提供商依赖项和能力缺口由上游 Any-LLM 而非 SDK 定义。当上游提供商返回使用量指标时,这些指标会自动传播,但流式 Chat Completions 后端可能需要设置 `ModelSettings(include_usage=True)` 才会生成使用量分块。如果您依赖 structured outputs、工具调用、使用量报告或 Responses 特定行为,请验证计划部署的具体提供商后端。 -### LiteLLM +### LiteLLM {#litellm} LiteLLM 支持以尽力支持的 Beta 版形式提供,适用于需要 LiteLLM 特定提供商覆盖范围或路由的情况。 diff --git a/docs/zh/multi_agent.md b/docs/zh/multi_agent.md index 2d3eaa81af..7908e17bae 100644 --- a/docs/zh/multi_agent.md +++ b/docs/zh/multi_agent.md @@ -11,7 +11,7 @@ search: 你可以混合搭配使用这些模式。每种模式都有各自的权衡,具体如下所述。 -## 基于 LLM 的智能体编排 +## 基于 LLM 的智能体编排 {#orchestrating-via-llm} 智能体是配备了指令、工具和任务转移能力的 LLM。这意味着,面对开放式任务时,LLM 可以自主规划如何处理该任务,使用工具执行操作和获取数据,并通过任务转移将任务委派给子智能体。例如,研究智能体可以配备以下能力: @@ -21,7 +21,7 @@ search: - 通过代码执行进行数据分析 - 将任务转移给擅长规划、报告撰写等工作的专业智能体。 -### SDK 核心模式 +### SDK 核心模式 {#core-sdk-patterns} 在 Python SDK 中,最常见的是以下两种编排模式: @@ -44,7 +44,7 @@ search: 如果你想了解这种编排方式背后的 SDK 核心基础组件,请先参阅[工具](tools.md)、[任务转移](handoffs.md)和[运行智能体](running_agents.md)。 -## 基于代码的智能体编排 +## 基于代码的智能体编排 {#orchestrating-via-code} 虽然基于 LLM 的编排功能强大,但基于代码的编排可以让任务在速度、成本和性能方面更具确定性和可预测性。常见模式包括: @@ -55,7 +55,7 @@ search: 我们在 [`examples/agent_patterns`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns) 中提供了许多代码示例。 -## 相关指南 +## 相关指南 {#related-guides} - [智能体](agents.md):组合模式和智能体配置。 - [工具](tools.md#agents-as-tools):`Agent.as_tool()` 和管理器式编排。 diff --git a/docs/zh/quickstart.md b/docs/zh/quickstart.md index fba6d12718..a02618091e 100644 --- a/docs/zh/quickstart.md +++ b/docs/zh/quickstart.md @@ -4,7 +4,7 @@ search: --- # 快速入门 -## 项目与虚拟环境的创建 +## 项目与虚拟环境的创建 {#create-a-project-and-virtual-environment} 你只需要执行一次。 @@ -14,7 +14,7 @@ cd my_project python -m venv .venv ``` -### 虚拟环境的激活 +### 虚拟环境的激活 {#activate-the-virtual-environment} 每次启动新的终端会话时都需要执行此操作。 @@ -30,13 +30,13 @@ source .venv/bin/activate .venv\Scripts\activate ``` -### Agents SDK 的安装 +### Agents SDK 的安装 {#install-the-agents-sdk} ```bash pip install openai-agents # or `uv add openai-agents`, etc ``` -### OpenAI API 密钥的设置 +### OpenAI API 密钥的设置 {#set-an-openai-api-key} 如果你还没有密钥,请按照[这些说明](https://platform.openai.com/docs/quickstart#create-and-export-an-api-key)创建 OpenAI API 密钥。 @@ -60,7 +60,7 @@ $env:OPENAI_API_KEY = "sk-..." set "OPENAI_API_KEY=sk-..." ``` -## 首个智能体的创建 +## 首个智能体的创建 {#create-your-first-agent} 智能体由 instructions、名称以及特定模型等可选配置定义。 @@ -73,7 +73,7 @@ agent = Agent( ) ``` -## 首个智能体的运行 +## 首个智能体的运行 {#run-your-first-agent} 使用 [`Runner`][agents.run.Runner] 执行智能体,并获取返回的 [`RunResult`][agents.result.RunResult]。 @@ -108,7 +108,7 @@ if __name__ == "__main__": 当任务主要存在于提示词、工具和对话状态中时,使用普通的 `Agent` 加 `Runner`。如果智能体需要在隔离的工作区中检查或修改真实文件,请转到[沙盒智能体快速入门](sandbox_agents.md)。 -## 智能体工具的提供 +## 智能体工具的提供 {#give-your-agent-tools} 你可以为智能体提供工具,用于查找信息或执行操作。 @@ -143,7 +143,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 更多智能体的添加 +## 更多智能体的添加 {#add-a-few-more-agents} 在选择多智能体模式之前,请决定最终答案应由谁负责: @@ -170,7 +170,7 @@ math_tutor_agent = Agent( ) ``` -## 任务转移的定义 +## 任务转移的定义 {#define-your-handoffs} 在智能体上,你可以定义一组可选的外部任务转移选项,供它在解决任务时选择。 @@ -182,7 +182,7 @@ triage_agent = Agent( ) ``` -## 智能体编排的运行 +## 智能体编排的运行 {#run-the-agent-orchestration} 运行器会处理各个智能体的执行、所有任务转移以及所有工具调用。 @@ -204,7 +204,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 参考代码示例 +## 参考代码示例 {#reference-examples} 仓库包含相同核心模式的完整脚本: @@ -212,11 +212,11 @@ if __name__ == "__main__": - [`examples/basic/tools.py`](https://github.com/openai/openai-agents-python/tree/main/examples/basic/tools.py) 用于工具调用。 - [`examples/agent_patterns/routing.py`](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns/routing.py) 用于多智能体路由。 -## 追踪的查看 +## 追踪的查看 {#view-your-traces} 若要回顾智能体运行期间发生的情况,请前往 [OpenAI Dashboard 中的追踪查看器](https://platform.openai.com/traces),查看智能体运行的追踪。 -## 后续步骤 +## 后续步骤 {#next-steps} 了解如何构建更复杂的智能体式流程: diff --git a/docs/zh/realtime/guide.md b/docs/zh/realtime/guide.md index 050341f396..6a3aa5b59d 100644 --- a/docs/zh/realtime/guide.md +++ b/docs/zh/realtime/guide.md @@ -10,7 +10,7 @@ search: 如果要使用默认的 Python 路径,请先阅读[快速入门](quickstart.md)。如果正在决定应用应使用服务器端 WebSocket 还是 SIP,请阅读 [Realtime 传输方式](transport.md)。浏览器 WebRTC 传输不属于 Python SDK。 -## 概述 +## 概述 {#overview} Realtime 智能体会与 Realtime API 保持长期连接,使模型能够以增量方式处理文本和音频、以流式方式输出音频、调用工具并处理中断,而无需在每个轮次都重新发起请求。 @@ -21,7 +21,7 @@ Realtime 智能体会与 Realtime API 保持长期连接,使模型能够以增 - **RealtimeSession**:发送输入、接收事件、追踪历史记录并执行工具的实时会话 - **RealtimeModel**:传输抽象。默认实现是 OpenAI的服务器端 WebSocket。 -## 会话生命周期 +## 会话生命周期 {#session-lifecycle} 典型的 Realtime 会话如下: @@ -38,7 +38,7 @@ Realtime 智能体会与 Realtime API 保持长期连接,使模型能够以增 当 Realtime API 服务器正常关闭默认 WebSocket 连接时,模型传输层会发出 `disconnected` [`RealtimeModelConnectionStatusEvent`][agents.realtime.model_events.RealtimeModelConnectionStatusEvent],随后发出 [`RealtimeModelEndOfStreamEvent`][agents.realtime.model_events.RealtimeModelEndOfStreamEvent]。`RealtimeSession` 会在 `raw_model_event` 中转发这两个事件,处理完已进入队列的事件,然后结束异步迭代且不引发异常。由调用方发起的 `session.close()` 不会生成这些服务器断开连接事件。意外的 WebSocket 故障仍会进入会话的异常处理路径,而不会像服务器正常关闭一样结束迭代。 -## 智能体与会话配置 +## 智能体与会话配置 {#agent-and-session-configuration} `RealtimeAgent` 的功能范围有意设计得比常规 `Agent` 类型更窄: @@ -91,7 +91,7 @@ runner = RealtimeRunner( 有关完整的类型化接口,请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 -### 输入转录设置 +### 输入转录设置 {#input-transcription-settings} 在 `audio.input.transcription` 下配置输入转录。使用 `gpt-live-transcribe` 可获得低延迟的增量转录;如果应在提交一个音频轮次后开始转录,或应用需要输出检测到的语言,请通过 WebSocket 使用 `gpt-transcribe`。Agents SDK会在嵌套会话配置中转发特定于模型的 GA 转录设置: @@ -145,9 +145,9 @@ runner = RealtimeRunner( 将 `audio.input.turn_detection` 设置为 `None` 会禁用自动轮次检测。之后,应用必须按照[手动响应控制](#manual-response-control)中的说明提交音频轮次并控制响应创建。有关模型行为、验证规则和延迟指导,请参阅 OpenAI API 的 [Realtime 转录指南](https://developers.openai.com/api/docs/guides/realtime-transcription)。 -## 输入与输出 +## 输入与输出 {#inputs-and-outputs} -### 文本与结构化用户消息 +### 文本与结构化用户消息 {#text-and-structured-user-messages} 使用 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] 发送纯文本或结构化 Realtime 消息。 @@ -169,7 +169,7 @@ await session.send_message(message) 在 Realtime 对话中,结构化消息是加入图像输入的主要方式。[`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py) 中的 Web 演示代码示例会以这种方式转发 `input_image` 消息。 -### 音频输入 +### 音频输入 {#audio-input} 使用 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio] 以流式方式发送原始音频字节: @@ -185,7 +185,7 @@ await session.send_audio(audio_bytes, commit=True) 如果需要更底层的控制,也可以通过底层模型传输对象直接发送 Realtime API 客户端事件,例如 `input_audio_buffer.commit`。 -### 手动响应控制 +### 手动响应控制 {#manual-response-control} `session.send_message()` 会通过高层路径发送用户输入,并自动开始响应。在某些配置中,原始音频缓冲**不会**自动执行相同操作。 @@ -213,7 +213,7 @@ await session.model.send_event( [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py) 中的 SIP 代码示例使用原始 `response.create` 强制生成开场问候语。 -## 事件、历史记录与中断 +## 事件、历史记录与中断 {#events-history-and-interruptions} `RealtimeSession` 会发出更高层的 SDK 事件,同时仍会在需要时转发原始模型事件。 @@ -231,7 +231,7 @@ await session.model.send_event( 对于 UI 状态而言,通常最有用的事件是 `history_added` 和 `history_updated`。它们将会话的本地历史记录公开为 `RealtimeItem` 对象,包括用户消息、助手消息和工具调用。 -### 用量统计 +### 用量统计 {#usage-accounting} 当已完成的模型响应包含用量信息时,SDK 的 OpenAI `RealtimeModel` 传输层会在 `raw_model_event` 中发出 [`RealtimeModelUsageEvent`][agents.realtime.model_events.RealtimeModelUsageEvent]。其 `usage` 字段包含该响应的 token 数量,而 `input_tokens_details` 和 `output_tokens_details` 则提供可选的模态明细。 @@ -255,7 +255,7 @@ async for event in session: 仅当模型提供方在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该 `RealtimeSession` 收到的响应;它不是跨会话总计。 -### 中断与播放进度追踪 +### 中断与播放进度追踪 {#interruptions-and-playback-tracking} 当用户打断助手时,会话会发出 `audio_interrupted` 并更新历史记录,使服务器端对话与用户实际听到的内容保持一致。 @@ -263,9 +263,9 @@ async for event in session: [`examples/realtime/twilio/twilio_handler.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio/twilio_handler.py) 中的 Twilio 代码示例展示了此模式。 -## 工具、批准、任务转移与安全防护措施 +## 工具、批准、任务转移与安全防护措施 {#tools-approvals-handoffs-and-guardrails} -### 函数工具 +### 函数工具 {#function-tools} Realtime 智能体支持在实时对话期间使用函数工具: @@ -286,7 +286,7 @@ agent = RealtimeAgent( ) ``` -### 工具批准 +### 工具批准 {#tool-approvals} 函数工具可以要求在执行前获得人工批准。发生这种情况时,会话会发出 `tool_approval_required` 并暂停工具运行,直到调用 `approve_tool_call()` 或 `reject_tool_call()`。 @@ -300,7 +300,7 @@ async for event in session: 有关具体的服务器端批准循环,请参阅 [`examples/realtime/app/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/app/server.py)。人工参与流程文档中的[人工参与流程](../human_in_the_loop.md)也会指向此流程。 -### 任务转移 +### 任务转移 {#handoffs} Realtime 任务转移允许一个智能体将实时对话转交给另一个专用智能体: @@ -326,7 +326,7 @@ main_agent = RealtimeAgent( 直接用作任务转移的 `RealtimeAgent` 对象会被自动包装,而 `realtime_handoff(...)` 可用于自定义名称、描述、验证、回调和可用性。Realtime 任务转移**不**支持常规任务转移的 `input_filter`。 -### 安全防护措施 +### 安全防护措施 {#guardrails} Realtime 智能体支持针对智能体响应的输出安全防护措施,以及针对函数工具调用的输入安全防护措施。输出安全防护措施检查会进行防抖处理:每次检查都针对累积的输出文本和音频转录增量运行,而不是针对每个部分增量运行,并会发出 `guardrail_tripped`,而不是引发异常。 @@ -352,7 +352,7 @@ agent = RealtimeAgent( 自定义 `RealtimeModel` 传输方式必须遵循 `RealtimeModelSendInterrupt.response_id` 和 `playback_only`,才能提供同样限定于源响应的音频中断行为。它们还必须覆盖 `RealtimeModel.send_event_if()`,以支持纯文本输出路径的恢复消息。实现必须在传输层实际提交事件的边界重新检查所提供的条件,或者将条件检查与事件提交串行化。默认实现会安全地跳过恢复消息,因为如果只检查一次条件,然后单独发送事件,在检查与事件提交之间可能会启动另一个响应;响应取消和 `guardrail_tripped` 事件仍会发生。 -## SIP 与电话 +## SIP 与电话 {#sip-and-telephony} Python SDK 通过 [`OpenAIRealtimeSIPModel`][agents.realtime.openai_realtime.OpenAIRealtimeSIPModel] 提供原生支持的 SIP 挂接流程。 @@ -375,7 +375,7 @@ async with await runner.run( 如果需要先接听通话,并希望接听请求体与从智能体生成的会话配置保持一致,请使用 `OpenAIRealtimeSIPModel.build_initial_session_payload(...)`。完整流程请参阅 [`examples/realtime/twilio_sip/server.py`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip/server.py)。 -## 底层访问与自定义端点 +## 底层访问与自定义端点 {#low-level-access-and-custom-endpoints} 可以通过 `session.model` 访问底层传输对象。 @@ -421,7 +421,7 @@ session = await runner.run( 如果传入 `headers`,SDK 不会自动添加 `Authorization`。请勿对 Realtime 智能体使用旧版 beta 路径(`/openai/realtime?api-version=...`)。 -## 延伸阅读 +## 延伸阅读 {#further-reading} - [Realtime 传输方式](transport.md) - [快速入门](quickstart.md) diff --git a/docs/zh/realtime/quickstart.md b/docs/zh/realtime/quickstart.md index 12c3e57fbb..84d14bd2a9 100644 --- a/docs/zh/realtime/quickstart.md +++ b/docs/zh/realtime/quickstart.md @@ -10,13 +10,13 @@ Python SDK 中的实时智能体是在服务端运行的低延迟智能体,基 Python SDK **不**提供浏览器 WebRTC 传输。本页仅介绍通过服务端 WebSocket、由 Python 管理的实时会话。此 SDK 适用于服务端编排、工具、审批和电话集成。另请参阅[实时传输](transport.md)。 -## 前提条件 +## 前提条件 {#prerequisites} - Python 3.10 或更高版本 - OpenAI API 密钥 - 基本熟悉 OpenAI Agents SDK -## 安装 +## 安装 {#installation} 如果尚未安装,请安装 OpenAI Agents SDK: @@ -24,9 +24,9 @@ Python SDK 中的实时智能体是在服务端运行的低延迟智能体,基 pip install openai-agents ``` -## 服务端实时会话的创建 +## 服务端实时会话的创建 {#create-a-server-side-realtime-session} -### 1. 实时组件的导入 +### 1. 实时组件的导入 {#1-import-the-realtime-components} ```python import asyncio @@ -34,7 +34,7 @@ import asyncio from agents.realtime import RealtimeAgent, RealtimeRunner ``` -### 2. 起始智能体的定义 +### 2. 起始智能体的定义 {#2-define-the-starting-agent} ```python agent = RealtimeAgent( @@ -43,7 +43,7 @@ agent = RealtimeAgent( ) ``` -### 3. 运行器的配置 +### 3. 运行器的配置 {#3-configure-the-runner} 对于新代码,建议采用嵌套的 `audio.input` / `audio.output` 会话设置结构。对于新的实时智能体,请从 `gpt-realtime-2.1` 开始。 @@ -72,7 +72,7 @@ runner = RealtimeRunner( ) ``` -### 4. 会话的启动与输入的发送 +### 4. 会话的启动与输入的发送 {#4-start-the-session-and-send-input} `runner.run()` 返回一个 `RealtimeSession`。进入会话上下文时,连接将建立。 @@ -102,12 +102,12 @@ if __name__ == "__main__": `session.send_message()` 接受纯字符串或结构化实时消息。对于原始音频块,请使用 [`session.send_audio()`][agents.realtime.session.RealtimeSession.send_audio]。 -## 本快速入门未包含的内容 +## 本快速入门未包含的内容 {#what-this-quickstart-does-not-include} - 麦克风采集和扬声器播放代码。请参阅 [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) 中的实时功能代码示例。 - SIP / 电话接入流程。请参阅[实时传输](transport.md)和 [SIP 部分](guide.md#sip-and-telephony)。 -## 关键设置 +## 关键设置 {#key-settings} 基本会话正常运行后,大多数人接下来会用到以下设置: @@ -126,7 +126,7 @@ if __name__ == "__main__": 有关完整 schema,请参阅 [`RealtimeRunConfig`][agents.realtime.config.RealtimeRunConfig] 和 [`RealtimeSessionModelSettings`][agents.realtime.config.RealtimeSessionModelSettings]。 -## 连接选项 +## 连接选项 {#connection-options} 在环境中设置 API 密钥: @@ -151,7 +151,7 @@ session = await runner.run(model_config={"api_key": "your-api-key"}) 连接 Azure OpenAI 时,请将 `model_config["url"]` 设置为正式发布版 Realtime 端点 URL,并显式传入标头。使用实时智能体时,请避免使用旧版 beta 路径(`/openai/realtime?api-version=...`)。有关详细信息,请参阅[实时智能体指南](guide.md#low-level-access-and-custom-endpoints)。 -## 后续步骤 +## 后续步骤 {#next-steps} - 阅读[实时传输](transport.md),以便在服务端 WebSocket 和 SIP 之间进行选择。 - 阅读[实时智能体指南](guide.md),了解生命周期、结构化输入、审批、任务转移、安全防护措施和底层控制。 diff --git a/docs/zh/realtime/transport.md b/docs/zh/realtime/transport.md index 6c2fc1702a..66d37a33ac 100644 --- a/docs/zh/realtime/transport.md +++ b/docs/zh/realtime/transport.md @@ -10,7 +10,7 @@ search: Python SDK **不**包含浏览器 WebRTC 传输。本页面仅介绍 Python SDK 的传输选择:服务器端 WebSocket 和 SIP 接入流程。浏览器 WebRTC 属于独立的平台主题,相关内容请参阅官方 [Realtime API 与 WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc/)指南。 -## 选择指南 +## 选择指南 {#decision-guide} | 目标 | 入门资源 | 原因 | | --- | --- | --- | @@ -18,7 +18,7 @@ search: | 了解应选择的传输方式和部署形态 | 本页面 | 在确定传输方式或部署形态之前,请先阅读本页面。 | | 将智能体接入电话或 SIP 通话 | [实时指南](guide.md)和 [`examples/realtime/twilio_sip`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime/twilio_sip) | 该仓库提供了由 `call_id` 驱动的 SIP 接入流程。 | -## 默认的 Python 路径:服务器端 WebSocket +## 默认的 Python 路径:服务器端 WebSocket {#server-side-websocket-is-the-default-python-path} 除非传入自定义 `RealtimeModel`,否则 `RealtimeRunner` 会使用 `OpenAIRealtimeWebSocketModel`。 @@ -37,7 +37,7 @@ search: 当您的服务器负责音频管线、工具执行、审批流程和历史记录处理时,请使用此路径。 -### 底层 WebSocket 调优 +### 底层 WebSocket 调优 {#low-level-websocket-tuning} 需要调优底层服务器端 WebSocket 连接时,请将 `transport_config` 传递给 `OpenAIRealtimeWebSocketModel`: @@ -69,7 +69,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 这些设置配置的是客户端连接,而不是 Realtime API 会话。端点、身份验证、通话接入和播放设置仍应使用 `RealtimeModelConfig`。 -## 电话通信路径:SIP 接入 +## 电话通信路径:SIP 接入 {#sip-attach-is-the-telephony-path} 对于本仓库中记录的电话通信流程,Python SDK 通过 `call_id` 接入现有的实时通话。 @@ -84,7 +84,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 更广泛的 Realtime API 也会将 `call_id` 用于某些服务器端控制模式,但本仓库提供的接入示例使用的是 SIP。 -## SDK 范围之外的浏览器 WebRTC +## SDK 范围之外的浏览器 WebRTC {#browser-webrtc-is-outside-this-sdk} 如果您的应用主要使用 Realtime WebRTC 浏览器客户端: @@ -95,7 +95,7 @@ runner = RealtimeRunner(starting_agent=agent, model=model) 本仓库目前也未提供浏览器 WebRTC 与 Python 旁路连接结合使用的示例。 -## 自定义端点和接入点 +## 自定义端点和接入点 {#custom-endpoints-and-attach-points} [`RealtimeModelConfig`][agents.realtime.model.RealtimeModelConfig] 中的传输配置接口允许您自定义默认传输行为: diff --git a/docs/zh/release.md b/docs/zh/release.md index 00ff37d720..f897340056 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -6,13 +6,13 @@ search: 本项目采用略作修改的语义化版本控制,格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各部分按以下方式递增: -## 次版本(`Y`) +## 次版本(`Y`) {#minor-y-versions} 对于任何未标记为 beta 的公共接口发生的**破坏性变更**,我们会递增次版本 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 如果您不希望引入破坏性变更,建议在项目中固定使用 `0.0.x` 版本。 -## 补丁版本(`Z`) +## 补丁版本(`Z`) {#patch-z-versions} 对于非破坏性变更,我们会递增 `Z`: @@ -21,9 +21,9 @@ search: - 私有接口变更 - beta 功能更新 -## 破坏性变更日志 +## 破坏性变更日志 {#breaking-change-changelog} -### 0.22.0 +### 0.22.0 {#0220} 版本 0.22.0 加强了多个现有 API 的失败处理和数据隔离。使用显式客户端构造 `OpenAIProvider`,同时还向提供商传递 `organization` 或 `project` 的应用程序,必须移除这些重复参数。 @@ -36,7 +36,7 @@ search: - 智能体可视化现在会递归展开通过 `handoff(agent)` 注册的目标所包含的工具、MCP服务器和下游任务转移,其行为与智能体 `handoffs` 列表中的直接 `Agent` 条目一致。请参阅[图形生成](visualization.md#generating-a-graph)。 - `Agent.clone()` 和 `RealtimeAgent.clone()` 的 API 指南现在准确说明了其现有的浅拷贝行为:未被覆盖的列表属性仍是相同的列表对象。如果克隆对象必须独立拥有该容器,请传入新列表。请参阅[智能体的克隆/复制](agents.md#cloningcopying-agents)。 -### 0.21.0 +### 0.21.0 {#0210} 版本 0.21.0 要求使用 `openai` v3,并将 Agents SDK的OpenAI HTTP 集成迁移到 HTTPX2。使用默认 OpenAI客户端的应用程序无需更改客户端设置,但自定义 OpenAI HTTP 层的应用程序可能需要迁移面向传输层的代码。 @@ -49,7 +49,7 @@ search: - 本地 MCP HTTP 自定义继续遵循已安装的 MCP软件包:MCP Python SDK v1 提供并使用旧版 `httpx`,而 MCP Python SDK v2 使用 `httpx2`。普通 MCP连接无需更改应用程序。请参阅 [MCP Python SDK v1 和 v2](mcp.md#mcp-python-sdk-v1-and-v2)。 - 公共的提供商中立测试实用工具现在无需依赖提供商或进程,即可覆盖智能体模型、沙箱会话、Realtime 会话和语音管线工作流。有关操作方法以及何时应保留真实提供商适配器或集成边界的指南,请参阅[测试](testing.md)。 -### 0.20.0 +### 0.20.0 {#0200} 版本 0.20.0 包含一项可能具有破坏性的 MCP依赖项迁移,影响自定义本地 MCP HTTP 传输的应用程序。它还更新了智能体或运行未显式选择模型时所使用的 SDK 默认模型。 @@ -64,7 +64,7 @@ search: - 可恢复的 `RunState` 对象现在可以在下次模型调用之前,使用 `add_input()` 暂存持久化用户输入。暂存的输入可在序列化后保留,会经过输入安全防护措施,并在本地会话和服务器管理的对话中产生一次持久化 SDK 输入记录。显式批准的不安全重放仍可能向提供商重新发送输入,并重复提供商侧的工作。请参阅[恢复前添加输入](results.md#add-input-before-resuming)。 - 运行时可靠性修复统一了流式和非流式的[输出安全防护措施会话持久化](guardrails.md#output-guardrails),在复制和命名空间处理期间保留 `FunctionTool` 子类,并针对[不受支持的 Chat Completions 音频输出](models/index.md#chat-completions-compatibility-options)引发显式错误,而不是静默完成空流。`OpenAIResponsesCompactionSession` 包装器会在取消操作到达调用方之前,尝试并等待[压缩前历史记录恢复](sessions/index.md#auto-compaction-can-block-streaming)。[`VoicePipeline`](voice/pipeline.md#results) 使用方现在会在运行正常结束后收到转录会话关闭失败;如果某个轮次更早发生失败,则该失败的优先级高于之后的关闭失败。`RunState` 往返转换现在会保留本地 shell 输出、已确认的计算机安全检查、采用默认值的工具输出字段,以及遍历字典、列表或元组时遇到的 Pydantic 模型或数据类输出。MCP转换会保留自由形式的对象 schema 和图像输出,并将音频块、资源块等其他原始内容块序列化为有效的 JSON 文本。`MCPServerManager` 会对重叠的生命周期操作进行串行化,并为连接和清理应用有限的默认超时。模型重放会先从输出项中移除服务器拥有的 `created_by` 元数据,再将其用作输入。 -### 0.19.0 +### 0.19.0 {#0190} 此次次版本发布**不会**引入破坏性变更。次版本号递增是因为新增了一个重要的 OpenAI Responses 功能领域:程序化工具调用。 @@ -77,7 +77,7 @@ search: - 改进了 AnyLLM、LiteLLM 和 Chat Completions 的兼容性,在模型重试期间保留会话历史记录,并为响应开始前发生的 WebSocket 过载添加了提供商重试指南,使选择启用的 Runner 重试策略可以在获准时重放失败的尝试。 - 通过 `VercelCloudBucketMountStrategy` 新增了[仅能在创建 Vercel 沙箱时配置的 S3 挂载](sandbox/clients.md#mounts-and-remote-storage)。已挂载的会话会从工作区持久化中排除存储桶内容,并且有意不支持动态挂载变更或会话恢复。 -### 0.18.0 +### 0.18.0 {#0180} 此次次版本发布**不会**引入破坏性变更。次版本号递增仅用于更新 Realtime 智能体的默认模型。 @@ -85,7 +85,7 @@ search: - Realtime 智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 设置无需额外配置即可使用最新的推荐模型。 -### 0.17.0 +### 0.17.0 {#0170} 在此版本中,除非源路径由 `Manifest.extra_path_grants` 覆盖,否则沙箱本地源实体化会将 `LocalFile.src` 和 `LocalDir.src` 限制在实体化 `base_dir` 内。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源从该目录解析,而绝对本地源必须已经位于其中或位于显式授权的路径下。此变更修复了本地产物边界问题,但可能影响有意将该基础目录之外的受信任主机文件或目录复制到沙箱工作区的应用程序。 @@ -118,7 +118,7 @@ manifest = Manifest( 请将 `extra_path_grants` 视为受信任的应用程序配置。除非应用程序已批准这些主机路径,否则不要根据模型输出或其他不受信任的清单输入填充授权。 -### 0.16.0 +### 0.16.0 {#0160} 在此版本中,SDK 默认模型现在是 `gpt-5.4-mini`,而不再是 `gpt-4.1`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包括 `reasoning.effort="none"` 和 `verbosity="low"` 等 GPT-5 默认值。 @@ -133,7 +133,7 @@ agent = Agent(name="Assistant", model="gpt-4.1") - `Runner.run`、`Runner.run_sync` 和 `Runner.run_streamed` 现在接受 `max_turns=None`,以禁用轮次限制。 - 对于本地、Docker 和提供商支持的沙箱实现,沙箱工作区填充现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,其中也包括目标为绝对路径的符号链接。 -### 0.15.0 +### 0.15.0 {#0150} 在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不会被视为空文本输出;对于 structured outputs,也不会再导致运行循环持续重试直至 `MaxTurnsExceeded`。 @@ -149,7 +149,7 @@ result = Runner.run_sync( 对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样验证该值。 -### 0.14.0 +### 0.14.0 {#0140} 此次次版本发布**不会**引入破坏性变更,但新增了一个重要的 beta 功能领域:沙箱智能体,以及在本地、容器化和托管环境中使用它们所需的运行时、后端和文档支持。 @@ -162,7 +162,7 @@ result = Runner.run_sync( - 在 `examples/sandbox/` 下新增大量沙箱代码示例和教程,涵盖使用技能、任务转移和记忆完成编码任务、提供商专用设置,以及代码审查、数据室问答和网站克隆等端到端工作流。 - 扩展了核心运行时和追踪栈,新增沙箱感知的会话准备、能力绑定、状态序列化、统一追踪、提示词缓存键默认值,以及更安全的敏感 MCP输出脱敏。 -### 0.13.0 +### 0.13.0 {#0130} 此次次版本发布**不会**引入破坏性变更,但包含一项值得注意的 Realtime 默认值更新、新的 MCP能力以及运行时稳定性修复。 @@ -173,15 +173,15 @@ result = Runner.run_sync( - Chat Completions 集成现在可以通过 `should_replay_reasoning_content` 选择重新发送现有推理内容,从而改善 LiteLLM/DeepSeek 等适配器中特定于提供商的推理/工具调用连续性。 - 修复了多个运行时和会话边界情况,包括 `SQLAlchemySession` 中的并发首次写入、移除推理内容后带有孤立助手消息 ID 的压缩请求、`remove_all_tools()` 遗留 MCP/推理项,以及 `FunctionTool` 实例批处理执行器中的竞态条件。 -### 0.12.0 +### 0.12.0 {#0120} 此次次版本发布**不会**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.12.0)。 -### 0.11.0 +### 0.11.0 {#0110} 此次次版本发布**不会**引入破坏性变更。有关主要新增功能,请查看[发布说明](https://github.com/openai/openai-agents-python/releases/tag/v0.11.0)。 -### 0.10.0 +### 0.10.0 {#0100} 此次次版本发布**不会**引入破坏性变更,但为 OpenAI Responses用户新增了一个重要功能领域:Responses API 的 websocket 传输支持。 @@ -191,50 +191,50 @@ result = Runner.run_sync( - 新增 `responses_websocket_session()` 辅助函数/`ResponsesWebSocketSession`,用于在多轮运行中复用支持 websocket 的共享提供商和 `RunConfig`。 - 新增 websocket 流式传输代码示例(`examples/basic/stream_ws.py`),涵盖流式传输、工具、审批和后续轮次。 -### 0.9.0 +### 0.9.0 {#090} 在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前终止支持。请升级到较新的运行时版本。 此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会造成破坏性问题,但如果您的代码依赖更宽泛的联合类型,可能需要进行一些调整。 -### 0.8.0 +### 0.8.0 {#080} 在此版本中,两项运行时行为变更可能需要迁移: - 包装**同步** Python 可调用对象的 `FunctionTool` 实例现在会通过 `asyncio.to_thread(...)` 在工作线程中执行,而不再在事件循环线程上运行。如果您的工具逻辑依赖线程局部状态或具有线程亲和性的资源,请迁移到异步工具实现,或在工具代码中显式指定线程亲和性。 - 本地 MCP工具失败处理现在可以配置,并且默认行为可以返回模型可见的错误输出,而不是使整个运行失败。如果您依赖快速失败语义,请设置 `mcp_config={"failure_error_function": None}`。服务器级 `failure_error_function` 值会覆盖智能体级设置,因此请在每个具有显式处理程序的本地 MCP服务器上设置 `failure_error_function=None`。 -### 0.7.0 +### 0.7.0 {#070} 在此版本中,有几项行为变更可能会影响现有应用程序: - 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果您依赖 v0.6.x 的默认嵌套行为,请显式设置 `RunConfig(nest_handoff_history=True)`。 - `gpt-5.1`/`gpt-5.2` 的默认 `reasoning.effort` 已更改为 `"none"`(之前是由 SDK 默认值配置的 `"low"`)。如果您的提示词或质量/成本配置依赖 `"low"`,请在 `model_settings` 中显式设置它。 -### 0.6.0 +### 0.6.0 {#060} 在此版本中,默认任务转移历史记录现在会封装为一条助手消息,而不再将用户和助手轮次作为单独消息传递,从而为下游智能体提供简洁且可预测的摘要 - 现有的单消息任务转移记录现在默认会在 `` 块之前,以完全一致的字面文本 `For context, here is the conversation so far between the user and the previous agent:` 开头,以便下游智能体获得带有清晰标签的摘要 -### 0.5.0 +### 0.5.0 {#050} 此版本不会引入任何可见的破坏性变更,但包含新功能和若干重要的底层更新: - 在 `RealtimeRunner` 中新增对处理 [SIP 协议连接](https://platform.openai.com/docs/guides/realtime-sip)的支持。 - 大幅修订了 `Runner#run_sync` 的内部逻辑,以兼容 Python 3.14 -### 0.4.0 +### 0.4.0 {#040} 在此版本中,不再支持 [openai](https://pypi.org/project/openai/) 软件包 v1.x 版本。请将 openai v2.x 与此 SDK 搭配使用。 -### 0.3.0 +### 0.3.0 {#030} 在此版本中,Realtime API支持迁移到 gpt-realtime 模型及其 API 接口(GA 版本)。 -### 0.2.0 +### 0.2.0 {#020} 在此版本中,之前有几处接受 `Agent` 作为参数的位置,现在改为接受 `AgentBase`。例如,这适用于 MCP服务器中的 `list_tools()` 方法签名。这只是类型方面的变更,您仍会收到 `Agent` 对象。若要更新,只需将 `Agent` 替换为 `AgentBase`,以修复类型错误。 -### 0.1.0 +### 0.1.0 {#010} 在此版本中,[`MCPServer.list_tools()`][agents.mcp.server.MCPServer] 新增了两个参数:`run_context` 和 `agent`。您需要将这些参数添加到 `MCPServer` 子类中每个被重写的 `MCPServer.list_tools()` 方法。 \ No newline at end of file diff --git a/docs/zh/results.md b/docs/zh/results.md index e444d3a692..70c0431f74 100644 --- a/docs/zh/results.md +++ b/docs/zh/results.md @@ -13,7 +13,7 @@ search: `RunResultStreaming` 增加了流式传输专用的控制项,例如 [`stream_events()`][agents.result.RunResultStreaming.stream_events]、[`current_agent`][agents.result.RunResultStreaming.current_agent]、[`is_complete`][agents.result.RunResultStreaming.is_complete] 和 [`cancel(...)`][agents.result.RunResultStreaming.cancel]。 -## 合适的结果接口 +## 合适的结果接口 {#choose-the-right-result-surface} 大多数应用只需要少数几个结果属性或辅助方法: @@ -28,7 +28,7 @@ search: | 当前嵌套 `Agent.as_tool()` 调用的元数据 | `agent_tool_invocation` | | 原始模型调用或安全防护措施诊断信息 | `raw_responses` 和安全防护措施结果数组 | -## 最终输出 +## 最终输出 {#final-output} [`final_output`][agents.result.RunResultBase.final_output] 属性包含最后运行的智能体所生成的最终输出。它可能是: @@ -42,7 +42,7 @@ search: 在流式传输模式下,`final_output` 会一直保持为 `None`,直到流处理完成。有关逐事件流程,请参阅[流式传输](streaming.md)。 -## 输入、下一轮历史记录和新项目 +## 输入、下一轮历史记录和新项目 {#input-next-turn-history-and-new-items} 这些接口分别回答不同的问题: @@ -67,7 +67,7 @@ search: 将计算机工具项目作为对话输入重新提交时,会使用原始 Responses 载荷结构。预览模型的 `computer_call` 项目会保留单个 `action`,而 `gpt-5.5` 计算机调用可以保留批量的 `actions[]`。[`to_input_list()`][agents.result.RunResultBase.to_input_list] 和 [`RunState`][agents.run_state.RunState] 会保留模型生成的结构,因此,在将这些项目手动重新提交为对话输入时,暂停/恢复流程和已存储的对话记录都能继续兼容预览版和 GA 版计算机工具调用。本地执行结果仍会在 `new_items` 中显示为 `computer_call_output` 项目。 -### 新项目 +### 新项目 {#new-items} [`new_items`][agents.result.RunResultBase.new_items] 提供运行过程中所发生事件的最丰富视图。常见项目类型包括: @@ -110,15 +110,15 @@ caller_id = ( 对于程序拥有的子调用,`caller` 的 `type` 字段为 `program`,而 `caller_id` 用于标识父程序调用。 -## 对话的继续或恢复 +## 对话的继续或恢复 {#continue-or-resume-the-conversation} -### 下一轮智能体 +### 下一轮智能体 {#next-turn-agent} [`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。任务转移后,它通常是下一轮用户输入最适合复用的智能体。 在流式传输模式下,[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] 会随着运行进展而更新,因此你可以在流结束前观察任务转移。 -### 中断和运行状态 +### 中断和运行状态 {#interruptions-and-run-state} 如果某个工具需要审批,待处理的审批会公开在 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 或 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中。其中可能包括直接工具、任务转移后调用的工具,或嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行所触发的审批。 @@ -139,7 +139,7 @@ if result.interruptions: result = await Runner.run(agent, state) ``` -#### 恢复前添加输入 +#### 恢复前添加输入 {#add-input-before-resuming} 如果运行在暂停后,或在完成一轮后停止,但尚未执行未完成运行中的下一次模型调用时有新的用户输入到达,请使用 [`RunState.add_input()`][agents.run_state.RunState.add_input]。字符串会成为一条用户消息,多次调用会保留插入顺序。暂存输入是已序列化 `RunState` 的一部分,因此在 `to_json()` / `from_json()` 和 `to_string()` / `from_string()` 往返转换后仍会保留。 @@ -159,13 +159,13 @@ result = await Runner.run(agent, state) 对于流式传输运行,请先完成对 [`stream_events()`][agents.result.RunResultStreaming.stream_events] 的消费,然后检查 `result.interruptions`,并从 `result.to_state()` 恢复。有关完整审批流程,请参阅[人在回路](human_in_the_loop.md)。 -### 服务器托管的延续 +### 服务器托管的延续 {#server-managed-continuation} [`last_response_id`][agents.result.RunResultBase.last_response_id] 是运行中最新的模型响应 ID。如果希望在下一轮继续 OpenAI Responses API 链,请将其作为 `previous_response_id` 传回。 如果已通过 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步骤运行中的每个模型响应,请改为检查 `raw_responses`。 -## 智能体作为工具的元数据 +## 智能体作为工具的元数据 {#agent-as-tool-metadata} 当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会公开有关外层 `Agent.as_tool()` 调用的不可变元数据: @@ -179,7 +179,7 @@ result = await Runner.run(agent, state) 如果还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 为嵌套工具输入进行通用序列化的字段,而 `agent_tool_invocation` 会直接在结果中公开当前嵌套调用的元数据。 -## 流式传输生命周期和诊断 +## 流式传输生命周期和诊断 {#streaming-lifecycle-and-diagnostics} [`RunResultStreaming`][agents.result.RunResultStreaming] 继承了上述相同的结果接口,但增加了流式传输专用的控制项: @@ -194,7 +194,7 @@ result = await Runner.run(agent, state) Python 不会公开单独的流式 `completed` promise 或 `error` 属性。导致运行终止的流式传输失败会由 `stream_events()` 抛出,而 `is_complete` 会反映运行是否已达到终止状态。 -### 原始响应 +### 原始响应 {#raw-responses} [`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能会生成多个响应,例如在任务转移期间或重复的模型/工具/模型循环中。 @@ -207,7 +207,7 @@ Python 不会公开单独的流式 `completed` promise 或 `error` 属性。导 `ModelResponse.request_id` 和 `ModelResponse.raw_usage` 都可能是 `None`,因此应将这些值视为可选诊断信息,而不是对话状态。 -### 安全防护措施结果 +### 安全防护措施结果 {#guardrail-results} 智能体级安全防护措施分别通过 [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] 和 [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] 公开。 @@ -217,7 +217,7 @@ Python 不会公开单独的流式 `completed` promise 或 `error` 属性。导 当智能体级输出安全防护措施阻止由终止函数工具直接生成的最终输出时,会应用一条脱敏规则。对于当前被阻止的响应,`output_guardrail_results` 会替换被拒绝的智能体输出,并清除包含载荷的输出元数据,而 `tool_output_guardrail_results` 会替换包含载荷的工具元数据。此前已接受的结果保持不变。经过净化的输出安全防护措施结果会在 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 上公开为 `guardrail_result`。经过净化的输出安全防护措施和工具输出安全防护措施结果也会通过流式传输结果状态和 `RunState` 公开;请参阅[输出安全防护措施](guardrails.md#output-guardrails)。 -### 上下文和用量 +### 上下文和用量 {#context-and-usage} [`context_wrapper`][agents.result.RunResultBase.context_wrapper] 会公开你的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量和嵌套的 `tool_input`。 diff --git a/docs/zh/running_agents.md b/docs/zh/running_agents.md index 856e799c54..bd18658913 100644 --- a/docs/zh/running_agents.md +++ b/docs/zh/running_agents.md @@ -25,9 +25,9 @@ async def main(): 有关更多信息,请阅读[结果指南](results.md)。 -## Runner 生命周期与配置 +## Runner 生命周期与配置 {#runner-lifecycle-and-configuration} -### 智能体循环 +### 智能体循环 {#the-agent-loop} 调用上述三个 `Runner` 方法中的任何一个时,需要传入起始智能体和输入。输入可以是: @@ -48,11 +48,11 @@ async def main(): 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了所需类型的文本输出,并且不存在工具调用。 -### 流式传输 +### 流式传输 {#streaming} 流式传输让你能够在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含有关此次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。有关更多信息,请阅读[流式传输指南](streaming.md)。 -#### Responses WebSocket 传输(可选辅助工具) +#### Responses WebSocket 传输(可选辅助工具) {#responses-websocket-transport-optional-helper} 如果启用 OpenAI Responses websocket 传输,你仍可继续使用常规的 `Runner` API。建议使用 websocket 会话辅助工具来复用连接,但这并非必需。 @@ -60,7 +60,7 @@ async def main(): 有关传输方式选择规则,以及具体模型对象或自定义提供商的注意事项,请参阅[模型](models/index.md#responses-websocket-transport)。 -##### 模式 1:不使用会话辅助工具(可行) +##### 模式 1:不使用会话辅助工具(可行) {#pattern-1-no-session-helper-works} 如果你只需要 websocket 传输,而不需要 SDK 为你管理共享的提供商/会话,请使用此模式。 @@ -87,7 +87,7 @@ asyncio.run(main()) 此模式适用于单次运行。如果反复调用 `Runner.run()` / `Runner.run_streamed()`,除非手动复用同一个 `RunConfig` / 提供商实例,否则每次运行都可能重新连接。 -##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) +##### 模式 2:使用 `responses_websocket_session()`(建议用于多轮复用) {#pattern-2-use-responses_websocket_session-recommended-for-multi-turn-reuse} 如果希望在多次运行中共享支持 websocket 的提供商和 `RunConfig`(包括继承相同 `run_config` 的嵌套 Agents-as-tools 调用),请使用 [`responses_websocket_session()`][agents.responses_websocket_session]。 @@ -125,15 +125,15 @@ asyncio.run(main()) 如果长时间推理轮次触发 websocket keepalive 超时,请增大 `ping_timeout`,或设置 `ping_timeout=None` 以禁用心跳超时。对于可靠性比 websocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 -### 运行配置 +### 运行配置 {#run-config} 通过 `run_config` 参数可以配置智能体运行的一些全局设置: -#### 常见运行配置类别 +#### 常见运行配置类别 {#common-run-config-categories} 使用 `RunConfig` 可覆盖单次运行的行为,而无须更改各个智能体定义。 -##### 模型、提供商与会话默认设置 +##### 模型、提供商与会话默认设置 {#model-provider-and-session-defaults} - [`model`][agents.run.RunConfig.model]:用于设置要使用的全局 LLM 模型,而不考虑每个智能体具有的 `model`。 - [`model_provider`][agents.run.RunConfig.model_provider]:用于查找模型名称的模型提供商,默认为 OpenAI。 @@ -141,7 +141,7 @@ asyncio.run(main()) - [`session_settings`][agents.run.RunConfig.session_settings]:在运行期间检索历史记录时,覆盖会话级默认设置(例如 `SessionSettings(limit=...)`)。 - [`session_input_callback`][agents.run.RunConfig.session_input_callback]:使用 Sessions 时,自定义每次运行 `Runner` 之前将新用户输入与会话历史记录合并的方式。回调可以是同步或异步的。 -##### 安全防护措施、任务转移与模型输入调整 +##### 安全防护措施、任务转移与模型输入调整 {#guardrails-handoffs-and-model-input-shaping} - [`input_guardrails`][agents.run.RunConfig.input_guardrails]、[`output_guardrails`][agents.run.RunConfig.output_guardrails]:要在所有运行中包含的输入或输出安全防护措施列表。 - [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]:如果任务转移尚未配置输入过滤器,则应用于所有任务转移的全局输入过滤器。输入过滤器允许编辑发送给新智能体的输入。有关更多详细信息,请参阅 [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] 的文档。 @@ -150,7 +150,7 @@ asyncio.run(main()) - [`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]:在调用模型前一刻编辑已完全准备好的模型输入(instructions 和输入项)的钩子,例如用于裁剪历史记录或注入系统提示词。 - [`reasoning_item_id_policy`][agents.run.RunConfig.reasoning_item_id_policy]:控制 Runner 将先前输出转换为下一轮模型输入时,是保留还是省略推理项 ID。 -##### 追踪与可观测性 +##### 追踪与可观测性 {#tracing-and-observability} - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]:用于为整个运行禁用[追踪](tracing.md)。 - [`tracing`][agents.run.RunConfig.tracing]:传入 [`TracingConfig`][agents.tracing.TracingConfig],可覆盖追踪导出设置,例如每次运行使用的追踪 API 密钥。 @@ -158,7 +158,7 @@ asyncio.run(main()) - [`workflow_name`][agents.run.RunConfig.workflow_name]、[`trace_id`][agents.run.RunConfig.trace_id]、[`group_id`][agents.run.RunConfig.group_id]:设置此次运行的追踪工作流名称、追踪 ID 和追踪组 ID。我们建议至少设置 `workflow_name`。组 ID 是一个可选字段,可用于关联多次运行的追踪记录。 - [`trace_metadata`][agents.run.RunConfig.trace_metadata]:要包含在所有追踪记录中的元数据。 -##### 工具执行、审批与工具错误行为 +##### 工具执行、审批与工具错误行为 {#tool-execution-approval-and-tool-error-behavior} - [`tool_execution`][agents.run.RunConfig.tool_execution]:配置 SDK 侧针对本地工具调用的执行行为,例如限制同时运行的本地函数工具调用数量。 - [`tool_not_found_behavior`][agents.run.RunConfig.tool_not_found_behavior]:配置当模型发出的函数工具调用名称与当前智能体可用的任何函数工具都不匹配时,Runner 应如何处理。默认行为是引发 `ModelBehaviorError`;也可以选择改为返回模型可见的错误输出。 @@ -167,9 +167,9 @@ asyncio.run(main()) 嵌套任务转移是一项需选择启用的 Beta 功能。传入 `RunConfig(nest_handoff_history=True)` 可启用有序记录压缩,或者设置 `handoff(..., nest_handoff_history=True)` 为特定任务转移启用此功能。内置映射器会将生成的助手摘要片段放置在无损消息项周围,而不是将整个记录压缩成一条消息。如果希望保留原始记录(默认行为),请不要设置此标志,或者提供按所需方式原样转发对话的 `handoff_input_filter`(或 `handoff_history_mapper`)。如果希望更改生成的摘要片段中使用的包装文本,而不编写自定义映射器,请调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers](调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] 可恢复默认值)。 -#### 运行配置详情 +#### 运行配置详情 {#run-config-details} -##### `tool_execution` +##### `tool_execution` {#tool_execution} 如果希望配置 SDK 侧针对本地函数工具的行为,例如限制一次运行中的本地函数工具并发数,请使用 `tool_execution`。 @@ -196,7 +196,7 @@ result = await Runner.run( `pre_approval_tool_input_guardrails=False` 会保留默认审批流程:如果函数工具需要审批,运行会先暂停,而工具输入安全防护措施只会在审批后、即将执行前运行。如果希望在发出待审批中断之前运行函数工具输入安全防护措施,请将其设置为 `True`。通过此审批前检查的调用仍会在审批后再次运行相同的输入安全防护措施,因此会在执行前重新验证时效性检查。 -##### `tool_not_found_behavior` +##### `tool_not_found_behavior` {#tool_not_found_behavior} 默认情况下,如果模型发出的函数工具调用与当前智能体可用的任何函数工具都不匹配,Runner 会引发 `ModelBehaviorError`。 @@ -216,7 +216,7 @@ result = await Runner.run( 此选项目前仅适用于工具名称查找失败的函数工具调用。其他无效工具载荷会继续使用其现有的错误处理行为。 -##### `tool_error_formatter` +##### `tool_error_formatter` {#tool_error_formatter} 使用 `tool_error_formatter` 可自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 @@ -254,7 +254,7 @@ result = Runner.run_sync( ) ``` -##### `reasoning_item_id_policy` +##### `reasoning_item_id_policy` {#reasoning_item_id_policy} 当 Runner 继续携带历史记录时(例如使用 `RunResult.to_input_list()` 或基于会话的运行时),`reasoning_item_id_policy` 控制如何将推理项转换为下一轮模型输入。 @@ -273,9 +273,9 @@ result = Runner.run_sync( - 它不会重写用户提供的初始输入项。 - 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 -## 状态与对话管理 +## 状态与对话管理 {#state-and-conversation-management} -### 内存策略选择 +### 内存策略选择 {#choose-a-memory-strategy} 将状态带入下一轮通常有四种方式: @@ -294,7 +294,7 @@ result = Runner.run_sync( (`conversation_id`、`previous_response_id` 或 `auto_previous_response_id`) 组合使用。每次调用请选择一种方式。 -### 对话/聊天线程 +### 对话/聊天线程 {#conversationschat-threads} 调用任何运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但这代表聊天对话中的单个逻辑轮次。例如: @@ -303,7 +303,7 @@ result = Runner.run_sync( 智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论哪种方式,用户随后都可能提出后续问题,此时可以再次调用运行方法。 -#### 手动对话管理 +#### 手动对话管理 {#manual-conversation-management} 你可以使用 [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] 方法获取下一轮的输入,从而手动管理对话历史记录: @@ -327,7 +327,7 @@ async def main(): # California ``` -#### 使用 Sessions 自动管理对话 +#### 使用 Sessions 自动管理对话 {#automatic-conversation-management-with-sessions} 如需更简单的方法,可以使用 [Sessions](sessions/index.md) 自动处理对话历史记录,而无须手动调用 `.to_input_list()`: @@ -362,13 +362,13 @@ Sessions 会自动: 有关更多详细信息,请参阅 [Sessions 文档](sessions/index.md)。 -#### 服务器管理的对话 +#### 服务器管理的对话 {#server-managed-conversations} 你也可以让 OpenAI 的对话状态功能在服务器端管理对话状态,而不是通过 `to_input_list()` 或 `Sessions` 在本地处理。这样便可保留对话历史记录,而无须手动重新发送所有过去的消息。使用下述任一服务器管理方式时,每次请求只需传入新轮次的输入,并复用已保存的 ID。有关更多详细信息,请参阅 [OpenAI 对话状态指南](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)。 OpenAI 提供两种跨轮次跟踪状态的方式: -##### 1. 使用 `conversation_id` +##### 1. 使用 `conversation_id` {#1-using-conversation_id} 首先使用 OpenAI Conversations API 创建对话,然后在之后的每次调用中复用其 ID: @@ -391,7 +391,7 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -##### 2. 使用 `previous_response_id` +##### 2. 使用 `previous_response_id` {#2-using-previous_response_id} 另一个选项是**响应链式衔接**,其中每个轮次都显式链接到上一轮的响应 ID。 @@ -435,9 +435,9 @@ async def main(): 即使未配置 `ModelSettings.retry`,也会进行这种兼容性重试。有关针对 模型请求的更广泛选择启用式重试行为,请参阅 [Runner 管理的重试](models/index.md#runner-managed-retries)。 -## 钩子与自定义 +## 钩子与自定义 {#hooks-and-customization} -### 模型调用输入过滤器 +### 模型调用输入过滤器 {#call-model-input-filter} 使用 `call_model_input_filter` 可在模型调用前一刻编辑模型输入。该钩子接收当前智能体、上下文和合并后的输入项(如有会话历史记录,也包括在内),并返回新的 `ModelInputData`。 @@ -468,9 +468,9 @@ Runner 会将准备好的输入列表副本传递给钩子,因此你可以裁 通过 `run_config` 为每次运行设置该钩子,可用于隐去敏感数据、裁剪过长的历史记录或注入额外的系统指导。 -## 错误与恢复 +## 错误与恢复 {#errors-and-recovery} -### 错误处理程序 +### 错误处理程序 {#error-handlers} 所有 `Runner` 入口点都接受 `error_handlers`,这是一个以错误种类为键的字典。支持的键包括 `"max_turns"`、`"model_refusal"` 和 `"invalid_final_output"`。如果希望返回受控的最终输出,而不是以相应错误结束运行,请使用这些键。 @@ -567,27 +567,27 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人在回路 +## 持久执行集成与人在回路 {#durable-execution-integrations-and-human-in-the-loop} 有关工具审批的暂停/恢复模式,请首先阅读专门的[人在回路指南](human_in_the_loop.md)。以下集成适用于运行可能经历长时间等待、重试或进程重启的持久编排。 -### Dapr +### Dapr {#dapr} 你可以使用 Agents SDK 的 [Dapr](https://dapr.io) Diagrid 集成来运行持久、长期运行的智能体,这些智能体可自动从故障中恢复并支持人在回路工作流。Dapr 是一个厂商中立的 [CNCF](https://cncf.io) 工作流编排器。可从[此处](https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?agentframework=openai)开始使用 Dapr 和 OpenAI 智能体。 -### Temporal +### Temporal {#temporal} 你可以使用 Agents SDK 的 [Temporal](https://temporal.io/) 集成来运行持久、长期运行的工作流,包括人在回路任务。可在[此视频](https://www.youtube.com/watch?v=fFBZqzT4DD8)中观看 Temporal 与 Agents SDK 协同完成长期任务的实际演示,并在[此处查看文档](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents)。 -### Restate +### Restate {#restate} 你可以使用 Agents SDK 的 [Restate](https://restate.dev/) 集成来运行轻量级、持久的智能体,包括人工审批、任务转移和会话管理。该集成依赖 Restate 的单二进制运行时,并支持将智能体作为进程/容器或无服务器函数运行。有关更多详细信息,请阅读[概述](https://www.restate.dev/blog/durable-orchestration-for-ai-agents-with-restate-and-openai-sdk)或查看[文档](https://docs.restate.dev/ai)。 -### DBOS +### DBOS {#dbos} 你可以使用 Agents SDK 的 [DBOS](https://dbos.dev/) 集成来运行可靠的智能体,并在故障和重启期间保留进度。它支持长期运行的智能体、人在回路工作流和任务转移,也支持同步和异步方法。该集成只需要 SQLite 或 Postgres 数据库。有关更多详细信息,请查看集成[代码仓库](https://github.com/dbos-inc/dbos-openai-agents)和[文档](https://docs.dbos.dev/integrations/openai-agents)。 -## 异常 +## 异常 {#exceptions} SDK 会在特定情况下引发异常。完整列表位于 [`agents.exceptions`][]。概览如下: diff --git a/docs/zh/sandbox/clients.md b/docs/zh/sandbox/clients.md index 35c81331ea..713de8eb38 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -10,7 +10,7 @@ search: 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且后续将逐步提供更高级的功能。 -## 决策指南 +## 决策指南 {#decision-guide}
@@ -22,7 +22,7 @@ search:
-## 本地客户端 +## 本地客户端 {#local-clients} 对于大多数用户,建议从以下两个沙箱客户端之一开始: @@ -58,7 +58,7 @@ run_config = RunConfig( 当你需要容器隔离,或希望沙箱镜像与其他环境中使用的镜像保持一致时,请使用此方式。请参阅 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 -### Docker 网络禁用 +### Docker 网络禁用 {#disable-docker-networking} 当 Docker 沙箱不得访问网络时,请设置 `network_mode="none"`: @@ -71,7 +71,7 @@ options = DockerSandboxClientOptions( 唯一受支持的显式网络模式是 `"none"`;省略 `network_mode` 可保留 Docker 的默认行为。禁用网络的沙箱无法暴露端口,因此将 `network_mode="none"` 与非空的 `exposed_ports` 元组组合使用,会在选项验证期间失败。此设置会存储在沙箱会话状态中;如果 SDK 在恢复该状态时必须创建替代容器,此设置也会重新应用。 -## 挂载与远程存储 +## 挂载与远程存储 {#mounts-and-remote-storage} 挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管提供商策略可从 `agents.extensions.sandbox` 或提供商专属扩展包中获取。 @@ -97,7 +97,7 @@ options = DockerSandboxClientOptions( -## 支持的托管平台 +## 支持的托管平台 {#supported-hosted-platforms} 当你需要托管环境时,通常可以继续使用相同的 `SandboxAgent` 定义,仅需更改 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中的沙箱客户端。 @@ -119,7 +119,7 @@ options = DockerSandboxClientOptions( -### Modal 沙箱规格 +### Modal 沙箱规格 {#size-modal-sandboxes} 使用 `ModalSandboxClientOptions.cpu` 和 `ModalSandboxClientOptions.memory` 为新的 Modal 沙箱请求资源。单个值表示请求该数量的资源。包含两个元素的 `(request, limit)` 元组将第一个元素用作请求值,第二个元素用作限制值。内存值的单位为 MiB。 diff --git a/docs/zh/sandbox/guide.md b/docs/zh/sandbox/guide.md index f311380fb4..2c3893288f 100644 --- a/docs/zh/sandbox/guide.md +++ b/docs/zh/sandbox/guide.md @@ -32,7 +32,7 @@ search: 外层运行时仍负责审批、追踪、任务转移,以及跟踪恢复运行所需的状态。沙箱会话负责命令、文件变更和环境隔离。这种职责划分是该模型的核心组成部分。 -### 各组件的组合方式 +### 各组件的组合方式 {#how-the-pieces-fit-together} 沙箱运行会将智能体定义与每次运行的沙箱配置组合起来。运行器会准备智能体,将其绑定到实时沙箱会话,并可保存状态供后续运行使用。 @@ -60,7 +60,7 @@ flowchart LR 如果 shell 访问只是您偶尔使用的一项工具,请先参阅[工具指南](../tools.md)中的托管 shell。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,再使用沙箱智能体。 -## 适用场景 +## 适用场景 {#when-to-use-them} 沙箱智能体非常适合以工作区为中心的工作流,例如: @@ -72,13 +72,13 @@ flowchart LR 如果您不需要访问文件或使用有状态、可变的文件系统,请继续使用 `Agent`。如果 shell 访问只是一项偶尔使用的功能,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 -## 沙箱客户端的选择 +## 沙箱客户端的选择 {#choose-a-sandbox-client} 在 macOS 或 Linux 上进行本地开发时,请从 `UnixLocalSandboxClient` 开始。在 Windows 上,请改用 `DockerSandboxClient` 或托管提供商。在任何受支持的平台上,当您需要容器隔离或镜像一致性时,请迁移到 `DockerSandboxClient`;当您需要由提供商管理执行时,请迁移到托管提供商。 在大多数情况下,`SandboxAgent` 定义保持不变,只需在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中更改沙箱客户端及其选项。有关本地、Docker、托管和远程挂载选项,请参阅[沙箱客户端](clients.md)。 -## 核心组件 +## 核心组件 {#core-pieces}
@@ -113,7 +113,7 @@ flowchart LR 3. 添加内置或自定义功能。 4. 在 `RunConfig(sandbox=SandboxRunConfig(...))` 中决定每次运行应如何获得其沙箱会话。 -## 沙箱运行的准备过程 +## 沙箱运行的准备过程 {#how-a-sandbox-run-is-prepared} 在运行时,运行器会将该定义转换为具体的沙箱支持运行: @@ -127,7 +127,7 @@ flowchart LR 正是由于这些准备步骤,在设计 `SandboxAgent` 时,`default_manifest`、`instructions`、`base_instructions`、`capabilities` 和 `run_as` 才是需要重点考虑的主要沙箱专用选项。 -## `SandboxAgent` 选项 +## `SandboxAgent` 选项 {#sandboxagent-options} 除常规的 `Agent` 字段外,还提供以下沙箱专用选项: @@ -145,13 +145,13 @@ flowchart LR 沙箱客户端选择、沙箱会话复用、清单覆盖和快照选择应放在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中,而不是智能体上。 -### `default_manifest` +### `default_manifest` {#default_manifest} `default_manifest` 是运行器为此智能体创建新沙箱会话时使用的默认 [`Manifest`][agents.sandbox.manifest.Manifest]。使用它指定智能体通常应从哪些文件、仓库、辅助材料、输出目录和挂载点开始。 这只是默认值。运行可以通过 `SandboxRunConfig(manifest=...)` 覆盖它,而复用或恢复的沙箱会话会保留其现有工作区状态。 -### `instructions` 和 `base_instructions` +### `instructions` 和 `base_instructions` {#instructions-and-base_instructions} 对于应在不同提示词中保持有效的简短规则,请使用 `instructions`。在 `SandboxAgent` 中,这些指令会追加到 SDK 的沙箱基础提示词之后,因此您可以保留内置沙箱指导,同时添加自己的角色、工作流和成功标准。 @@ -179,7 +179,7 @@ flowchart LR 如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。对于底层包装器而言,这已经足够,但大多数面向用户的智能体仍应提供显式的 `instructions`。 -### `capabilities` +### `capabilities` {#capabilities} 功能可将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、追加沙箱专用指令、公开绑定到实时沙箱会话的工具,并调整该智能体的模型行为或输入处理方式。 @@ -214,9 +214,9 @@ flowchart LR 如果内置功能符合需求,请优先使用它们。仅当您需要内置功能未涵盖的沙箱专用工具或指令接口时,才编写自定义功能。 -## 概念 +## 概念 {#concepts_1} -### 清单 +### 清单 {#manifest} [`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载点、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 @@ -262,7 +262,7 @@ manifest = Manifest( 快照和 `persist_workspace()` 仍然只包含工作区根目录。额外授权的路径属于运行时访问权限,而不是持久工作区状态。 -### 权限 +### 权限 {#permissions} `Permissions` 控制清单条目的文件系统权限。它涉及沙箱实体化的文件,而非模型权限、审批策略或 API 凭据。 @@ -338,7 +338,7 @@ result = await Runner.run( 如果还需要文件级共享规则,请将用户与清单组及条目 `group` 元数据结合使用。`run_as` 用户控制谁执行沙箱原生操作;在沙箱实体化工作区后,`Permissions` 控制该用户可以读取、写入或执行哪些文件。 -### SnapshotSpec +### SnapshotSpec {#snapshotspec} `SnapshotSpec` 指定新沙箱会话应从何处恢复已保存的工作区内容,以及将其持久化回何处。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 @@ -363,7 +363,7 @@ run_config = RunConfig( 如果省略 `snapshot`,运行时会尽可能尝试使用默认的本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 -### 沙箱生命周期 +### 沙箱生命周期 {#sandbox-lifecycle} 生命周期分为两种模式:**SDK 管理型**和**开发者管理型**。 @@ -439,11 +439,11 @@ finally: `stop()` 只持久化由快照支持的工作区内容;它不会拆除沙箱。`aclose()` 是完整的会话清理路径:它会运行停止前钩子、调用 `stop()`、关闭沙箱资源,并关闭会话范围的依赖项。 -## `SandboxRunConfig` 选项 +## `SandboxRunConfig` 选项 {#sandboxrunconfig-options} [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 包含每次运行的选项,用于决定沙箱会话的来源,以及应如何初始化新会话。 -### 沙箱来源 +### 沙箱来源 {#sandbox-source} 以下选项决定运行器应复用、恢复还是创建沙箱会话: @@ -464,7 +464,7 @@ finally: 3. 否则,如果传入 `run_config.sandbox.session_state`,运行器会从该显式序列化沙箱会话状态恢复。 4. 否则,运行器会创建新的沙箱会话。对于该新会话,如果提供了 `run_config.sandbox.manifest`,则使用它;否则使用 `agent.default_manifest`。 -### 新会话输入 +### 新会话输入 {#fresh-session-inputs} 以下选项仅在运行器创建新的沙箱会话时有效: @@ -478,7 +478,7 @@ finally:
-### 面向模型的工作目录 +### 面向模型的工作目录 {#model-facing-working-directory} 当多次运行应共享一个沙箱会话,但需要在不同子目录中操作时,请将 `cwd` 设置为相对于工作区的 POSIX 目录。运行器验证 `cwd` 时,该目录必须存在,并且已配置的沙箱用户必须能够访问它。对于新会话,运行器会先实体化清单,因此清单可以在验证前创建该目录。 @@ -503,7 +503,7 @@ result = await Runner.run( 带路径的自定义功能在解析模型提供的相对路径时,必须应用其绑定的 [`SandboxWorkspaceScope`][agents.sandbox.workspace_paths.SandboxWorkspaceScope]。有关共享一个沙箱会话、同时保持各自面向模型的工作目录相互独立的两个并发运行,请参阅 [examples/sandbox/shared_session_workdirs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/shared_session_workdirs.py)。 -### 实体化控制 +### 实体化控制 {#materialization-controls} `concurrency_limits` 控制可并行运行的沙箱实体化工作量。当大型清单或本地目录复制需要更严格的资源控制时,请使用 `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)`。将任一值设置为 `None` 可禁用对应限制。 @@ -517,7 +517,7 @@ result = await Runner.run( - 注入的实时会话:如果传入正在运行的沙箱 `session`,由功能驱动的清单更新可以添加兼容的非挂载条目,但不能更改 `manifest.root`、`manifest.environment`、`manifest.users` 或 `manifest.groups`;也不能删除现有条目、替换条目类型,或添加或更改挂载条目。 - 运行器 API:`SandboxAgent` 执行仍使用常规的 `Runner.run()`、`Runner.run_sync()` 和 `Runner.run_streamed()` API。 -## 完整示例:编码任务 +## 完整示例:编码任务 {#full-example-coding-task} 以下编码风格示例是一个良好的默认起点: @@ -600,15 +600,15 @@ if __name__ == "__main__": 请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 shell 的微型仓库,因此可以在 Unix 本地运行中以确定性方式验证该示例。当然,您的实际任务仓库可以使用 Python、JavaScript 或任何其他语言。 -## 常见模式 +## 常见模式 {#common-patterns} 请从上面的完整示例开始。在许多情况下,同一个 `SandboxAgent` 可以保持不变,只需更改沙箱客户端、沙箱会话来源或工作区来源。 -### 沙箱客户端的切换 +### 沙箱客户端的切换 {#switch-sandbox-clients} 保持智能体定义不变,只更改运行配置。如果需要容器隔离或镜像一致性,请使用 Docker;如果需要由提供商管理执行,请使用托管提供商。有关代码示例和提供商选项,请参阅[沙箱客户端](clients.md)。 -### 工作区的覆盖 +### 工作区的覆盖 {#override-the-workspace} 保持智能体定义不变,只替换新会话清单: @@ -632,7 +632,7 @@ run_config = RunConfig( 当同一智能体角色需要针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了相同模式,但它使用 `default_manifest`,而不是一次性覆盖。 -### 沙箱会话的注入 +### 沙箱会话的注入 {#inject-a-sandbox-session} 当您需要显式控制生命周期、在运行后进行检查或复制输出时,请注入实时沙箱会话: @@ -657,7 +657,7 @@ async with sandbox: 当您希望在运行后检查工作区,或通过已启动的沙箱会话进行流式传输时,请使用此模式。请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) 和 [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py)。 -### 会话状态的恢复 +### 会话状态的恢复 {#resume-from-session-state} 如果您已在 `RunState` 外部序列化沙箱状态,请让运行器从该状态重新连接: @@ -682,7 +682,7 @@ run_config = RunConfig( 会话状态和 `RunState` 序列化还会移除云挂载凭据、包含凭据的辅助配置,以及对容器内凭据暴露的确认。对于支持恢复已挂载会话的后端,当状态包含已遮盖的挂载权限时,请通过 `SandboxRunConfig.manifest` 或 `agent.default_manifest` 提供当前受信任清单。当名为 `"data"` 的挂载条目需要挂载范围的确认时,请在恢复前使用 `trusted_manifest = trusted_manifest.with_in_container_mount_credential_exposure_acknowledged("data")` 保留复制的清单。对于广泛权限,请使用 `trusted_manifest = trusted_manifest.with_in_container_mount_broad_credential_exposure_acknowledged("data")`;当挂载使用两类权限时,请调用这两种方法。请传入需要确认的每个确切挂载路径。只有当前受信任清单具有与持久化状态完全相同且不含凭据的挂载拓扑时,Agents SDK 才会恢复凭据。缺失或不匹配的受信任配置会导致恢复在沙箱启动前失败;序列化状态本身绝不会授予权限。`VercelSandboxClient` 无法恢复已挂载会话,因此应改为使用受信任清单启动新沙箱。 -### 快照的使用 +### 快照的使用 {#start-from-a-snapshot} 使用已保存的文件和产物初始化新沙箱: @@ -703,7 +703,7 @@ run_config = RunConfig( 当创建新沙箱会话的运行应从已保存的工作区内容开始,而不只是从 `agent.default_manifest` 开始时,请使用此模式。有关本地快照流程,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py);有关远程快照客户端,请参阅 [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py)。 -### 从 Git 加载技能 +### 从 Git 加载技能 {#load-skills-from-git} 将本地技能源替换为由仓库支持的技能源: @@ -718,7 +718,7 @@ capabilities = Capabilities.default() + [ 当技能包有自己的发布节奏,或应在多个沙箱间共享时,请使用此模式。请参阅 [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py)。 -### 工具形式的公开 +### 工具形式的公开 {#expose-as-tools} 工具智能体既可以拥有自己的沙箱边界,也可以复用父级运行中的实时沙箱。复用适用于快速的只读探索智能体:它可以检查父级运行正在使用的确切工作区,而无需承担创建、填充或快照另一个沙箱的成本。 @@ -832,7 +832,7 @@ rollout_agent.as_tool( 当工具智能体应自由修改内容、运行不受信任的命令,或使用不同后端/镜像时,请使用独立沙箱。请参阅 [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py)。 -### 与本地工具和 MCP 的组合 +### 与本地工具和 MCP 的组合 {#combine-with-local-tools-and-mcp} 保留沙箱工作区,同时在同一个智能体上使用普通工具: @@ -851,13 +851,13 @@ agent = SandboxAgent( 当工作区检查只是智能体工作的一部分时,请使用此模式。请参阅 [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py)。 -## 记忆 +## 记忆 {#memory} 当未来的沙箱智能体运行应从之前的运行中学习时,请使用 `Memory` 功能。该记忆与 SDK 的对话式 `Session` 记忆不同:它会将经验提炼为沙箱工作区内的文件,后续运行可以读取这些文件。 有关设置、读取/生成行为、多轮对话和布局隔离,请参阅[智能体记忆](memory.md)。 -## 组合模式 +## 组合模式 {#composition-patterns} 明确单智能体模式后,下一个设计问题是沙箱边界在大型系统中应位于何处。 @@ -873,7 +873,7 @@ agent = SandboxAgent( - 非沙箱智能体仅针对工作流中需要工作区隔离的部分,将任务转移给沙箱智能体 - 编排器将多个沙箱智能体公开为工具,通常为每次 `Agent.as_tool(...)` 调用提供单独的沙箱 `RunConfig`,使每个工具拥有自己的隔离工作区 -### 轮次与沙箱运行 +### 轮次与沙箱运行 {#turns-and-sandbox-runs} 分别说明任务转移和智能体即工具调用会更容易理解。 @@ -886,7 +886,7 @@ agent = SandboxAgent( - 使用任务转移时,审批仍属于同一个顶层运行,因为沙箱智能体现在是该运行中的活跃智能体 - 使用 `Agent.as_tool(...)` 时,沙箱工具智能体内部触发的审批仍会呈现在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 -## 延伸阅读 +## 延伸阅读 {#further-reading} - [快速入门](../sandbox_agents.md):运行一个沙箱智能体。 - [沙箱客户端](clients.md):选择本地、Docker、托管和挂载选项。 diff --git a/docs/zh/sandbox/memory.md b/docs/zh/sandbox/memory.md index 5b05f66197..4c837b9608 100644 --- a/docs/zh/sandbox/memory.md +++ b/docs/zh/sandbox/memory.md @@ -18,7 +18,7 @@ search: 有关完整的两次运行代码示例,请参阅 [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py)。该示例会修复一个错误、生成记忆、恢复快照,并在后续验证器运行中使用该记忆。有关采用独立记忆布局的多轮、多智能体代码示例,请参阅 [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py)。 -## 记忆的启用 +## 记忆的启用 {#enable-memory} 将 `Memory()` 作为一项功能添加到沙盒智能体中。 @@ -48,7 +48,7 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d `Memory()` 会同时启用记忆读取和生成。对于应读取记忆但不应生成新记忆的智能体,请使用 `Memory(generate=None)`——例如,由内部智能体、子智能体、检查器或一次性工具智能体执行的运行通常不会提供太多有价值的信息。如果运行应生成供日后使用的记忆,但用户不希望该运行受现有记忆影响,请使用 `Memory(read=None)`。 -## 记忆的读取 +## 记忆的读取 {#read-memory} 记忆读取采用渐进式披露方式。在运行开始时,SDK 会将一个简短摘要(`memory_summary.md`)注入智能体的开发者提示词,其中包含普遍有用的技巧、用户偏好以及可用记忆。这可为智能体提供足够的上下文,使其能够判断先前工作是否可能相关。 @@ -56,7 +56,7 @@ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_d 记忆可能会过时。智能体会被要求仅将记忆视为参考,并以当前环境为准。默认情况下,记忆读取会启用 `live_update`,因此如果智能体发现记忆已过时,可以在同一次运行中更新已配置的 `MEMORY.md`。如果智能体应读取记忆但不应在运行期间修改记忆,请禁用实时更新,例如对延迟敏感的运行。 -## 记忆的生成 +## 记忆的生成 {#generate-memory} 一次运行结束后,沙盒运行时会将该运行片段追加到对话文件中。累积的对话文件会在沙盒会话关闭时进行处理。 @@ -101,7 +101,7 @@ memory = Memory( 如果近期原始记忆数量超过 `max_raw_memories_for_consolidation`(默认值为 256),阶段 2 将只保留最新对话中的记忆并删除较旧的记忆。新旧顺序以对话最后更新时间为准。这种遗忘机制有助于让记忆反映最新环境。 -## 多轮对话 +## 多轮对话 {#multi-turn-conversations} 对于多轮沙盒聊天,请将常规 SDK `Session` 与同一个实时沙盒会话结合使用: @@ -141,7 +141,7 @@ async with sandbox: 3. `RunConfig.group_id`,当上述两者均不存在时 4. 为每次运行生成的 ID,当不存在稳定标识符时 -## 不同智能体的记忆隔离布局 +## 不同智能体的记忆隔离布局 {#use-different-layouts-to-isolate-memory-for-different-agents} 记忆隔离基于 `MemoryLayoutConfig`,而不是智能体名称。具有相同布局和相同记忆对话 ID 的智能体会共享一个记忆对话和一份整合后的记忆。具有不同布局的智能体则会分别保存各自的运行文件、原始记忆、`MEMORY.md` 和 `memory_summary.md`,即使它们共享同一个沙盒工作区也是如此。 diff --git a/docs/zh/sandbox_agents.md b/docs/zh/sandbox_agents.md index e362504e48..3bab11d7e5 100644 --- a/docs/zh/sandbox_agents.md +++ b/docs/zh/sandbox_agents.md @@ -12,13 +12,13 @@ search: SDK 提供了这套执行框架,无需你自行整合文件暂存、文件系统工具、Shell 访问、沙箱生命周期、快照以及特定于提供商的适配逻辑。你可以继续使用常规的 `Agent` 和 `Runner` 流程,然后添加用于工作区的 `Manifest`、沙箱原生工具所需的能力,以及用于指定工作运行位置的 `SandboxRunConfig`。 -## 前置条件 +## 前置条件 {#prerequisites} - Python 3.10 或更高版本 - 基本熟悉 OpenAI Agents SDK - 一个沙箱客户端。进行本地开发时,可从 `UnixLocalSandboxClient` 开始。 -## 安装 +## 安装 {#installation} 如果尚未安装 SDK: @@ -32,7 +32,7 @@ pip install openai-agents pip install "openai-agents[docker]" ``` -## 本地沙箱智能体的创建 +## 本地沙箱智能体的创建 {#create-a-local-sandbox-agent} 此代码示例将本地仓库存放到 `repo/` 下,按需延迟加载本地技能,并让运行器为本次运行创建 Unix 本地沙箱会话。 @@ -96,7 +96,7 @@ if __name__ == "__main__": 请参阅 [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py)。它使用一个基于 Shell 的微型仓库,因此可在不同的 Unix 本地运行中以确定性方式验证该代码示例。 -## 关键选项 +## 关键选项 {#key-choices} 基本运行正常后,大多数人接下来会使用以下选项: @@ -108,7 +108,7 @@ if __name__ == "__main__": - `SandboxRunConfig.client`:沙箱后端 - `SandboxRunConfig.session`、`session_state` 或 `snapshot`:后续运行重新连接到先前工作的方式 -## 后续步骤 +## 后续步骤 {#where-to-go-next} - [概念](sandbox/guide.md):了解清单、能力、权限、快照、运行配置和组合模式。 - [沙箱客户端](sandbox/clients.md):选择 Unix 本地、Docker、托管提供商和挂载策略。 diff --git a/docs/zh/sessions/advanced_sqlite_session.md b/docs/zh/sessions/advanced_sqlite_session.md index 7bc897967b..d9caeeedfd 100644 --- a/docs/zh/sessions/advanced_sqlite_session.md +++ b/docs/zh/sessions/advanced_sqlite_session.md @@ -6,7 +6,7 @@ search: `AdvancedSQLiteSession` 是基础版 `SQLiteSession` 的增强版本,提供高级对话管理功能,包括对话分支、详细的用量分析和结构化对话查询。 -## 功能 +## 功能 {#features} - **对话分支**:从任意用户消息创建不同的对话路径 - **用量追踪**:按轮次提供详细的 token 用量分析及完整的 JSON 明细 @@ -14,7 +14,7 @@ search: - **分支管理**:独立切换和管理分支 - **消息结构元数据**:追踪消息类型、工具使用情况和对话流程 -## 快速开始 +## 快速开始 {#quick-start} ```python from agents import Agent, Runner @@ -54,7 +54,7 @@ print(result.final_output) # "California" await session.store_run_usage(result) ``` -## 初始化 +## 初始化 {#initialization} ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -82,18 +82,18 @@ session = AdvancedSQLiteSession( ) ``` -### 参数 +### 参数 {#parameters} - `session_id`(str):对话会话的唯一标识符 - `db_path`(str | Path):SQLite 数据库文件的路径。默认为 `:memory:`,即使用内存存储 - `create_tables`(bool):是否自动创建高级表。默认为 `False` - `logger`(logging.Logger | None):会话的自定义日志记录器。默认为模块日志记录器 -## 用量追踪 +## 用量追踪 {#usage-tracking} AdvancedSQLiteSession 通过存储每个对话轮次的 token 用量数据,提供详细的用量分析。**这完全依赖于在每次智能体运行后调用 `store_run_usage` 方法。** -### 用量数据存储 +### 用量数据存储 {#storing-usage-data} ```python # After each agent run, store the usage data @@ -107,7 +107,7 @@ await session.store_run_usage(result) # - Detailed JSON token information (if available) ``` -### 用量统计信息检索 +### 用量统计信息检索 {#retrieving-usage-statistics} ```python # Get session-level usage (all branches) @@ -135,11 +135,11 @@ for turn_data in turn_usage: turn_2_usage = await session.get_turn_usage(user_turn_number=2) ``` -## 对话分支 +## 对话分支 {#conversation-branching} AdvancedSQLiteSession 的主要功能之一是能够从任意用户消息创建对话分支,让你可以探索不同的对话路径。 -### 分支创建 +### 分支创建 {#creating-branches} ```python # Get available turns for branching @@ -167,7 +167,7 @@ branch_id = await session.create_branch_from_content( 分支 ID 在会话 ID 的整个生命周期内保持唯一。删除分支或清除会话会移除其对话数据,但不会让之前使用过的分支 ID 再次可用;创建其他分支时,请使用新名称。 -### 分支管理 +### 分支管理 {#branch-management} ```python # List all branches @@ -184,7 +184,7 @@ await session.switch_to_branch(branch_id) await session.delete_branch(branch_id, force=True) # force=True allows deleting current branch ``` -### 分支工作流示例 +### 分支工作流示例 {#branch-workflow-example} ```python # Original conversation @@ -217,11 +217,11 @@ result = await Runner.run( await session.store_run_usage(result) ``` -## 结构化查询 +## 结构化查询 {#structured-queries} AdvancedSQLiteSession 提供了多种用于分析对话结构和内容的方法。 -### 对话分析 +### 对话分析 {#conversation-analysis} ```python # Get conversation organized by turns @@ -245,7 +245,7 @@ for turn in matching_turns: print(f"Turn {turn['turn']}: {turn['content']}") ``` -### 消息结构 +### 消息结构 {#message-structure} 会话会自动追踪消息结构,包括: @@ -255,11 +255,11 @@ for turn in matching_turns: - 分支关联 - 时间戳 -## 数据库架构 +## 数据库架构 {#database-schema} AdvancedSQLiteSession 在基础 SQLite 架构上扩展了三个附加表: -### message_structure 表 +### message_structure 表 {#message_structure-table} ```sql CREATE TABLE message_structure ( @@ -278,7 +278,7 @@ CREATE TABLE message_structure ( ); ``` -### branch_reservations 表 +### branch_reservations 表 {#branch_reservations-table} ```sql CREATE TABLE branch_reservations ( @@ -290,7 +290,7 @@ CREATE TABLE branch_reservations ( 此表以原子方式预留分支 ID,包括复制前缀为空的分支。删除分支或清除会话时,预留记录都会保留,从而防止过期的会话实例将历史记录合并到之后复用同一 ID 的分支中。 -### turn_usage 表 +### turn_usage 表 {#turn_usage-table} ```sql CREATE TABLE turn_usage ( @@ -310,12 +310,12 @@ CREATE TABLE turn_usage ( ); ``` -## 完整示例 +## 完整示例 {#complete-example} 请查看[完整示例](https://github.com/openai/openai-agents-python/tree/main/examples/memory/advanced_sqlite_session_example.py),了解所有功能的综合演示。 -## API 参考 +## API 参考 {#api-reference} - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - 主类 - [`Session`][agents.memory.session.Session] - 基础会话协议 \ No newline at end of file diff --git a/docs/zh/sessions/encrypted_session.md b/docs/zh/sessions/encrypted_session.md index 210e34f1e1..9c251b98a6 100644 --- a/docs/zh/sessions/encrypted_session.md +++ b/docs/zh/sessions/encrypted_session.md @@ -6,14 +6,14 @@ search: `EncryptedSession` 为任何会话实现提供透明加密,通过自动过期旧条目来保护对话数据。 -## 功能 +## 功能 {#features} - **透明加密**:使用 Fernet 加密包装任何会话 - **每会话密钥**:使用 HKDF 密钥派生,为每个会话生成唯一加密 - **自动过期**:TTL 过期时会静默跳过旧条目 - **即插即用替代方案**:适用于任何现有会话实现 -## 安装 +## 安装 {#installation} 加密会话需要 `encrypt` extra: @@ -21,7 +21,7 @@ search: pip install openai-agents[encrypt] ``` -## 快速入门 +## 快速入门 {#quick-start} ```python import asyncio @@ -53,9 +53,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 配置 +## 配置 {#configuration} -### 加密密钥 +### 加密密钥 {#encryption-key} 加密密钥可以是 Fernet 密钥,也可以是任意字符串: @@ -79,7 +79,7 @@ session = EncryptedSession( ) ``` -### TTL(存活时间) +### TTL(存活时间) {#ttl-time-to-live} 设置加密条目的有效时长: @@ -101,9 +101,9 @@ session = EncryptedSession( ) ``` -## 与不同会话类型的搭配使用 +## 与不同会话类型的搭配使用 {#usage-with-different-session-types} -### 与 SQLite 会话搭配使用 +### 与 SQLite 会话搭配使用 {#with-sqlite-sessions} ```python from agents import SQLiteSession @@ -119,7 +119,7 @@ session = EncryptedSession( ) ``` -### 与 SQLAlchemy 会话搭配使用 +### 与 SQLAlchemy 会话搭配使用 {#with-sqlalchemy-sessions} ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -147,7 +147,7 @@ session = EncryptedSession( -## 密钥派生 +## 密钥派生 {#key-derivation} EncryptedSession 使用 HKDF(基于 HMAC 的密钥派生函数)为每个会话派生唯一的加密密钥: @@ -161,7 +161,7 @@ EncryptedSession 使用 HKDF(基于 HMAC 的密钥派生函数)为每个会 - 没有主密钥就无法派生密钥 - 不同会话之间的会话数据无法相互解密 -## 自动过期 +## 自动过期 {#automatic-expiration} 当条目超过 TTL 时,检索过程中会自动跳过它们: @@ -173,7 +173,7 @@ items = await session.get_items() # Only returns non-expired items result = await Runner.run(agent, "Continue conversation", session=session) ``` -## API 参考 +## API 参考 {#api-reference} - [`EncryptedSession`][agents.extensions.memory.encrypt_session.EncryptedSession] - 主类 - [`Session`][agents.memory.session.Session] - 基础会话协议 \ No newline at end of file diff --git a/docs/zh/sessions/index.md b/docs/zh/sessions/index.md index d605c097e7..54fe1e3703 100644 --- a/docs/zh/sessions/index.md +++ b/docs/zh/sessions/index.md @@ -10,7 +10,7 @@ Agents SDK提供内置的会话记忆功能,可在多次智能体运行之间 如果希望由SDK为你管理客户端记忆,请使用会话。在同一次运行中,会话不能与运行级续接选项`conversation_id`、`previous_response_id`或`auto_previous_response_id`结合使用。如果希望改用由OpenAI服务器管理的续接机制,请选择其中一种机制,而不要在其上叠加会话。 -## 快速入门 +## 快速入门 {#quick-start} ```python from agents import Agent, Runner, SQLiteSession @@ -49,7 +49,7 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 使用同一会话恢复中断的运行 +## 使用同一会话恢复中断的运行 {#resuming-interrupted-runs-with-the-same-session} 如果运行因等待批准而暂停,请使用同一会话实例恢复运行(或使用另一个实例,该实例配置了相同的会话ID和相同的底层存储后端),以便恢复后的轮次继续使用同一份已存储对话历史记录。 @@ -63,7 +63,7 @@ if result.interruptions: result = await Runner.run(agent, state, session=session) ``` -## 核心会话行为 +## 核心会话行为 {#core-session-behavior} 启用会话记忆后: @@ -73,7 +73,7 @@ if result.interruptions: 这样便无需手动调用`.to_input_list()`并在运行之间管理对话状态。 -## 历史记录与新输入的合并控制 +## 历史记录与新输入的合并控制 {#control-how-history-and-new-input-merge} 传入会话时,运行器通常按以下顺序准备模型输入: @@ -111,7 +111,7 @@ result = await Runner.run( 当你需要自定义历史记录的裁剪、重新排序或选择性包含方式,但不希望改变会话存储项目的方式时,请使用此功能。如果需要在调用模型前进行最后一次处理,请使用[运行智能体指南](../running_agents.md)中的[`call_model_input_filter`][agents.run.RunConfig.call_model_input_filter]。 -## 检索历史记录的限制 +## 检索历史记录的限制 {#limiting-retrieved-history} 使用[`SessionSettings`][agents.memory.SessionSettings]控制每次运行前获取的历史记录量。 @@ -136,9 +136,9 @@ result = await Runner.run( 如果会话实现提供默认会话设置,则`RunConfig.session_settings`中每个非`None`值都会覆盖该次运行对应的默认值。对于长对话,这很有用,因为你可以限制检索数量,而无需更改会话的默认行为。 -## 记忆操作 +## 记忆操作 {#memory-operations} -### 基本操作 +### 基本操作 {#basic-operations} 会话支持多种对话历史记录管理操作: @@ -165,7 +165,7 @@ print(last_item) # {"role": "assistant", "content": "Hi there!"} await session.clear_session() ``` -### 使用 pop_item 进行更正 +### 使用 pop_item 进行更正 {#using-pop_item-for-corrections} 当你希望撤销或修改对话中的最后一个项目时,`pop_item`方法特别有用: @@ -196,11 +196,11 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 内置会话实现 +## 内置会话实现 {#built-in-session-implementations} SDK针对不同用例提供了多种会话实现: -### 内置会话实现的选择 +### 内置会话实现的选择 {#choose-a-built-in-session-implementation} 在阅读下方的详细代码示例前,可使用此表选择起点。 @@ -221,7 +221,7 @@ SDK针对不同用例提供了多种会话实现: 如果你正在为ChatKit实现Python服务器,请使用`chatkit.store.Store`实现来持久化ChatKit的线程和项目。`SQLAlchemySession`等Agents SDK会话用于管理SDK侧的对话历史记录,但不能直接替代ChatKit的存储。请参阅[有关实现ChatKit数据存储的`chatkit-python`指南](https://github.com/openai/chatkit-python/blob/main/docs/guides/respond-to-user-message.md#implement-your-chatkit-data-store)。 -### OpenAI Conversations API会话 +### OpenAI Conversations API会话 {#openai-conversations-api-sessions} 通过`OpenAIConversationsSession`使用[OpenAI的Conversations API](https://platform.openai.com/docs/api-reference/conversations)。 @@ -257,11 +257,11 @@ result = await Runner.run( print(result.final_output) # "California" ``` -### OpenAI Responses压缩会话 +### OpenAI Responses压缩会话 {#openai-responses-compaction-sessions} 使用`OpenAIResponsesCompactionSession`通过Responses API(`responses.compact`)压缩已存储的对话历史记录。它会封装底层会话,并可根据`should_trigger_compaction`在每个轮次后自动执行压缩。不要用它封装`OpenAIConversationsSession`;这两项功能以不同方式管理历史记录。 -#### 典型用法(自动压缩) +#### 典型用法(自动压缩) {#typical-usage-auto-compaction} ```python from agents import Agent, Runner, SQLiteSession @@ -286,7 +286,7 @@ print(result.final_output) 如果智能体使用`ModelSettings(store=False)`运行,Responses API不会保留最后一个响应以供后续查找。在这种无状态设置中,默认的`"auto"`模式会回退到基于输入的压缩,而不依赖`previous_response_id`。完整代码示例请参阅[`examples/memory/compaction_session_stateless_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/compaction_session_stateless_example.py)。 -#### 自动压缩对流式传输的阻塞 +#### 自动压缩对流式传输的阻塞 {#auto-compaction-can-block-streaming} 压缩会清除并重写会话历史记录,因此SDK会等待压缩完成后,才会将运行视为已完成。在流式传输模式下,如果压缩任务较重,这意味着最后一个输出token生成后,`run.stream_events()`仍可能保持打开数秒。 @@ -313,7 +313,7 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite会话 +### SQLite会话 {#sqlite-sessions} 使用SQLite的默认轻量级会话实现: @@ -334,7 +334,7 @@ result = await Runner.run( ) ``` -### 异步SQLite会话 +### 异步SQLite会话 {#async-sqlite-sessions} 如果希望使用由`aiosqlite`支持的SQLite持久化,请使用`AsyncSQLiteSession`。 @@ -351,7 +351,7 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis会话 +### Redis会话 {#redis-sessions} 使用`RedisSession`可在多个工作进程或服务之间共享会话记忆。 @@ -374,7 +374,7 @@ await session.close() `from_url(...)`会创建并拥有Redis客户端。调用`close()`后,会话将进入终止状态,后续会话操作会引发`RuntimeError`;重复或并发调用`close()`是安全的。如果应用已经管理Redis客户端,请直接使用`redis_client=...`构造`RedisSession(...)`。在这种情况下,`close()`不执行任何操作,调用方仍拥有客户端,并且会话仍可使用。 -### SQLAlchemy会话 +### SQLAlchemy会话 {#sqlalchemy-sessions} 使用任何SQLAlchemy支持的数据库,实现适用于生产环境的Agents SDK会话持久化: @@ -396,7 +396,7 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) 详细文档请参阅[SQLAlchemy会话](sqlalchemy_session.md)。 -### Dapr会话 +### Dapr会话 {#dapr-sessions} 如果已经运行Dapr边车,或希望无需更改智能体代码即可切换已配置的状态存储后端,请使用`DaprSession`。 @@ -429,7 +429,7 @@ async with DaprSession.from_address( - 完整设置演练(包括本地组件和故障排除)请参阅[`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py)。 -### MongoDB会话 +### MongoDB会话 {#mongodb-sessions} 对于已使用MongoDB,或需要可横向扩展的多进程会话存储的应用,请使用`MongoDBSession`。 @@ -461,7 +461,7 @@ await session.close() - 此实现使用两个集合,二者的名称均可配置,分别通过`sessions_collection=`(默认为`agent_sessions`)和`messages_collection=`(默认为`agent_messages`)设置。首次使用时会自动创建索引。每次非空的`add_items()`调用都会写入一个逻辑批次文档,其单调递增的`seq`会按批次的最后一个项目对该批次排序;旧版的逐项目消息文档仍可读取。逻辑批次必须符合MongoDB的单文档大小限制;过大的批次会以原子方式失败,不会存储部分批次。 - 在首次运行前,使用`await session.ping()`验证连接。 -### 高级SQLite会话 +### 高级SQLite会话 {#advanced-sqlite-sessions} 增强型SQLite会话,支持对话分支、用量分析和结构化查询: @@ -485,7 +485,7 @@ await session.create_branch_from_turn(2) # Branch from turn 2 详细文档请参阅[高级SQLite会话](advanced_sqlite_session.md)。 -### 加密会话 +### 加密会话 {#encrypted-sessions} 适用于任何会话实现的透明加密封装器: @@ -512,13 +512,13 @@ result = await Runner.run(agent, "Hello", session=session) 详细文档请参阅[加密会话](encrypted_session.md)。 -### 其他会话类型 +### 其他会话类型 {#other-session-types} 此外还有一些其他内置选项。请参阅`examples/memory/`以及`extensions/memory/`下的源代码。 -## 运维模式 +## 运维模式 {#operational-patterns} -### 会话ID命名 +### 会话ID命名 {#session-id-naming} 使用有意义的会话ID来帮助组织对话: @@ -526,7 +526,7 @@ result = await Runner.run(agent, "Hello", session=session) - 基于线程:`"thread_abc123"` - 基于上下文:`"support_ticket_456"` -### 记忆持久化 +### 记忆持久化 {#memory-persistence} - 对于临时对话,使用内存SQLite(`SQLiteSession("session_id")`) - 对于持久化对话,使用基于文件的SQLite(`SQLiteSession("session_id", "path/to/db.sqlite")`) @@ -539,7 +539,7 @@ result = await Runner.run(agent, "Hello", session=session) - 使用加密会话(`EncryptedSession(session_id, underlying_session, encryption_key)`)封装任何会话,以提供透明加密和基于TTL的过期机制 - 对于更高级的用例,可考虑为其他生产系统(例如Django)实现自定义会话后端 -### 多个会话 +### 多个会话 {#multiple-sessions} ```python from agents import Agent, Runner, SQLiteSession @@ -562,7 +562,7 @@ result2 = await Runner.run( ) ``` -### 会话共享 +### 会话共享 {#session-sharing} ```python # Different agents can share the same session @@ -583,7 +583,7 @@ result2 = await Runner.run( ) ``` -## 完整代码示例 +## 完整代码示例 {#complete-example} 下面是一个展示会话记忆实际运作方式的完整代码示例: @@ -647,7 +647,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 自定义会话实现 +## 自定义会话实现 {#custom-session-implementations} 你可以创建一个在结构上遵循[`Session`][agents.memory.session.Session]协议的类,以实现自己的会话记忆。无需继承`SessionABC`;请定义`session_id`和`session_settings`,并直接实现四个历史记录方法: @@ -691,7 +691,7 @@ result = await Runner.run( ) ``` -### 从自定义会话访问运行上下文 +### 从自定义会话访问运行上下文 {#accessing-run-context-from-a-custom-session} Agents SDK可以将当前的[`RunContextWrapper`][agents.run_context.RunContextWrapper]传递给自定义会话,用于租户路由、授权或其他应用特定的存储决策。若要让Agents SDK传递该封装器,请为所有四个历史记录方法添加一个具有显式名称且兼容关键字调用的`wrapper`参数: @@ -732,7 +732,7 @@ class ContextAwareSession: 仅当`get_items`、`add_items`、`pop_item`和`clear_session`都声明`wrapper`时,Agents SDK才会启用此集成。通用的`**kwargs`参数不满足此签名检查。省略`wrapper`的现有会话实现会保留其已发布的调用形式,并可继续正常工作,无需更改。 -## 社区会话实现 +## 社区会话实现 {#community-session-implementations} 社区已开发更多会话实现: @@ -742,7 +742,7 @@ class ContextAwareSession: 如果你构建了会话实现,欢迎提交文档PR,将其添加到这里! -## API参考 +## API参考 {#api-reference} 详细API文档请参阅: diff --git a/docs/zh/sessions/sqlalchemy_session.md b/docs/zh/sessions/sqlalchemy_session.md index 8528fea9ad..e8fc686110 100644 --- a/docs/zh/sessions/sqlalchemy_session.md +++ b/docs/zh/sessions/sqlalchemy_session.md @@ -6,7 +6,7 @@ search: `SQLAlchemySession` 使用 SQLAlchemy 提供可用于生产环境的会话实现,让你可以使用 SQLAlchemy 支持的任何数据库(PostgreSQL、MySQL、SQLite 等)存储会话。 -## 安装 +## 安装 {#installation} SQLAlchemy 会话需要 `openai-agents` 软件包中的 `sqlalchemy` 可选依赖 extra: @@ -14,9 +14,9 @@ SQLAlchemy 会话需要 `openai-agents` 软件包中的 `sqlalchemy` 可选依 pip install openai-agents[sqlalchemy] ``` -## 快速入门 +## 快速入门 {#quick-start} -### 数据库 URL +### 数据库 URL {#using-database-url} 最简单的入门方式: @@ -42,7 +42,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 现有引擎 +### 现有引擎 {#using-existing-engine} 对于已有 SQLAlchemy 引擎的应用程序: @@ -73,7 +73,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 非 ASCII 文本存储 +## 非 ASCII 文本存储 {#storing-non-ascii-text} 默认情况下,`SQLAlchemySession` 在将会话条目序列化为 JSON 时会转义非 ASCII 字符。这会保留原有的存储格式,同时在加载条目时仍能无损还原原始文本。 @@ -91,7 +91,7 @@ session = SQLAlchemySession.from_url( 使用现有引擎时,也可以将相同的选项直接传递给 `SQLAlchemySession(...)`。此设置仅会更改数据库中存储的 JSON 表示形式;不会更改会话方法返回的值。 -## API 参考 +## API 参考 {#api-reference} - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - 主要类 - [`Session`][agents.memory.session.Session] - 基础会话协议 \ No newline at end of file diff --git a/docs/zh/streaming.md b/docs/zh/streaming.md index 8d39844eec..9dbe7d32a9 100644 --- a/docs/zh/streaming.md +++ b/docs/zh/streaming.md @@ -10,7 +10,7 @@ search: 持续使用 `result.stream_events()` 进行消费,直到异步迭代器结束。只有迭代器结束后,流式运行才算完成;会话持久化、审批记录维护或历史压缩等后处理可能会在最后一个可见 token 到达后才完成。当循环退出时,`result.is_complete` 会反映最终的运行状态。 -## 原始响应事件 +## 原始响应事件 {#raw-response-events} [`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] 对象封装了直接从 LLM 传递的原始事件。每个对象的 `data` 字段都包含一个 OpenAI Responses API 事件,其类型可能是 `response.created` 或 `response.output_text.delta`。如果你希望响应消息一经生成就立即以流式方式发送给用户,这些事件会非常有用。 @@ -39,7 +39,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 流式传输与审批 +## 流式传输与审批 {#streaming-and-approvals} 流式传输与因工具审批而暂停的运行兼容。如果工具需要审批,`result.stream_events()` 会结束,待处理的审批则会在 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] 中公开。使用 `result.to_state()` 将结果转换为 [`RunState`][agents.run_state.RunState],批准或拒绝中断,然后使用 `Runner.run_streamed(...)` 恢复运行。 @@ -59,7 +59,7 @@ if result.interruptions: 有关完整的暂停和恢复操作流程,请参阅[人在回路指南](human_in_the_loop.md)。 -## 当前轮次结束后的流式传输取消 +## 当前轮次结束后的流式传输取消 {#cancel-streaming-after-the-current-turn} 如果需要中途停止流式运行,请调用 [`result.cancel()`][agents.result.RunResultStreaming.cancel]。默认情况下,这会立即停止运行。要让当前轮次正常完成后再停止,请改为调用 `result.cancel(mode="after_turn")`。 @@ -71,11 +71,11 @@ if result.interruptions: - 如果流式运行因工具审批而停止,请勿将其视为新轮次。应先将流消费完毕,检查 `result.interruptions`,然后改为从 `result.to_state()` 恢复运行。 - 使用 [`RunConfig.session_input_callback`][agents.run.RunConfig.session_input_callback] 自定义如何在下一次模型调用前合并检索到的会话历史与新的用户输入。如果你在此处重写新轮次条目,则重写后的版本会作为该轮次的持久化内容。 -## 运行条目事件与智能体事件 +## 运行条目事件与智能体事件 {#run-item-events-and-agent-events} [`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在条目完全生成后通知你。这样,你便可以按“消息已生成”“工具已运行”等粒度推送进度更新,而不是按每个 token 推送。同样,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会在当前智能体发生变化时向你提供更新(例如,因任务转移而发生变化)。 -### 运行条目事件名称 +### 运行条目事件名称 {#run-item-event-names} `RunItemStreamEvent.name` 使用一组固定的语义事件名称: diff --git a/docs/zh/testing.md b/docs/zh/testing.md index accf3ec3ad..56218bc4aa 100644 --- a/docs/zh/testing.md +++ b/docs/zh/testing.md @@ -8,7 +8,7 @@ SDK 为智能体工作流、沙箱会话、Realtime 会话和语音管线提供 使用这些工具测试由应用和 SDK 管理的编排:工具执行、任务转移、安全防护措施、重试、流式传输、会话行为、沙箱能力、Realtime 事件处理和语音管线组合。对于由外部模型、网络协议、沙箱提供商或音频系统管理的行为,请使用真实的提供商适配器或集成环境。 -## 配方选择 +## 配方选择 {#find-the-recipe-you-need} | 目标 | 使用 | 参阅 | | --- | --- | --- | @@ -26,7 +26,7 @@ SDK 为智能体工作流、沙箱会话、Realtime 会话和语音管线提供 | 测试静态或流式语音管线 | `ScriptedSTTModel`、`ScriptedTTSModel`,以及脚本化或真实的工作流 | [语音管线测试](#test-a-voice-pipeline) | | 测试提供商序列化或线上传输载荷 | 使用受控网络传输的真实提供商适配器 | [正确边界选择](#choose-the-correct-boundary) | -## 导入 +## 导入 {#imports} 测试 API 与其替代的运行时边界位于同一位置: @@ -38,9 +38,9 @@ SDK 为智能体工作流、沙箱会话、Realtime 会话和语音管线提供 测试符号有意不包含在顶层 `agents` 导入中。 -## 智能体工作流配方 +## 智能体工作流配方 {#agent-workflow-recipes} -### 固定响应返回 +### 固定响应返回 {#return-a-fixed-response} 为每个预期的模型调用传入一个规范化输出项序列。输出序列简写会为一个请求接收确定性的响应 ID 和用量。 @@ -71,7 +71,7 @@ async def test_fixed_response() -> None: 使用 `model.assert_complete()` 完成确定性工作流测试。它可以捕获工作流在消耗所有已配置步骤之前停止的情况。 -### 工具工作流测试 +### 工具工作流测试 {#test-a-tool-workflow} 编写一个调用工具的模型响应脚本,再编写一个生成最终答案的响应脚本。真实的 SDK 工具管线会在这些模型调用之间运行。 @@ -117,7 +117,7 @@ async def test_tool_workflow() -> None: 此模式涵盖工具输入验证、执行、结果转换、钩子、安全防护措施和下一轮模型调用。直接调用 Python 函数会绕过这些 SDK 行为。 -### 从请求派生响应 +### 从请求派生响应 {#derive-a-response-from-the-request} 当响应确实依赖于规范化模型调用,或者断言应位于模型边界时,请使用 `ModelStep.respond()`。响应器可以是同步或异步的,并且可以返回 `ScriptedModel` 接受的任何步骤形式。 @@ -151,7 +151,7 @@ async def test_request_aware_response() -> None: `ScriptedModel` 接受 `ModelStep`、等效的字典形式、`ModelResponse`、规范化输出项序列或异常。当响应不依赖调用时,优先使用固定输出序列,因为固定脚本更容易诊断意外轮次。 -### 模型调用检查 +### 模型调用检查 {#inspect-model-calls} `ScriptedModel` 会在解析每个调用或引发所选步骤之前记录该调用。 @@ -168,7 +168,7 @@ async def test_request_aware_response() -> None: 当一个测试需要逐步追加模型步骤时,请使用 `enqueue()` 或 `extend()`。对于独立场景,请创建新的 `ScriptedModel`;该工具不会重置已消耗的步骤或调用历史记录。 -### 流式传输测试 +### 流式传输测试 {#test-streaming} 普通响应步骤同时支持 `Runner.run()` 和 `Runner.run_streamed()`。对于常见的智能体消息、推理项、函数调用和应用补丁调用,`ScriptedModel` 会生成规范化的开始、增量、项目完成和终止响应事件。终止响应包含完整的输出和用量。 @@ -185,7 +185,7 @@ step = ModelStep.stream( 自动流式传输会拒绝尚未实现增量生命周期的规范化输出项类型。对于这些项目,请使用 `ModelStep.stream(...)`,而不要依赖不完整的事件序列。 -### 模型故障注入 +### 模型故障注入 {#inject-model-failures} 使用 `ModelStep.raise_error()` 使一次模型调用失败。可选的重试建议属于该特定脚本错误: @@ -202,7 +202,7 @@ step = ModelStep.raise_error( 运行器的重试策略决定该建议是否会触发另一次尝试。每次重试都是另一次模型调用,并会消耗下一个脚本步骤。Python 辅助工具接受固定的 `ModelRetryAdvice` 值;如果重试建议本身需要根据尝试次数动态变化,请使用自定义 `Model`。 -### 工作流漂移检测 +### 工作流漂移检测 {#detect-workflow-drift} 将脚本化调用视为预期的工作流形态。额外的模型请求会引发 `UnexpectedModelCall`;提前退出则会留下步骤,供 `assert_complete()` 报告。 @@ -214,9 +214,9 @@ step = ModelStep.raise_error( | `UnexpectedModelCall` | `call`、`call_index` | 脚本结束后,工作流又进行了一次模型调用 | | `UnconsumedModelSteps` | `remaining_steps` | 工作流在使用所有步骤之前结束 | -## 沙箱智能体配方 +## 沙箱智能体配方 {#sandbox-agent-recipes} -### 沙箱智能体工作流测试 +### 沙箱智能体工作流测试 {#test-a-sandbox-agent-workflow} 将 `ScriptedModel` 与 `scripted_sandbox_session()` 组合使用,可以在不创建本地容器或远程沙箱的情况下运行真实的 `SandboxAgent` 运行时。模型脚本选择一个能力工具,而沙箱脚本定义对应的 `SandboxSession` 方法返回什么内容。 @@ -279,7 +279,7 @@ async def test_sandbox_workflow() -> None: 此测试跨越两个规范化 SDK 边界。它涵盖工具参数验证、能力路由、沙箱会话调用、将工具结果传递到下一轮模型调用,以及最终输出处理。它不会测试真实模型是否会选择该命令,也不会测试真实沙箱提供商如何执行该命令。 -### 沙箱步骤配置 +### 沙箱步骤配置 {#configure-sandbox-steps} 每个匹配的沙箱调用都会消耗一个全局 FIFO 序列中的下一个步骤。方法不匹配、匹配器拒绝或匹配器异常都会使该步骤保持待处理状态。设置 `method`,仅选择一种结果,并且仅当调用详情很重要时才添加 `match`。 @@ -303,9 +303,9 @@ async def test_sandbox_workflow() -> None: 返回的对象就是会话本身。请将其直接传给 `RunConfig(sandbox={"session": sandbox})`;不存在包装器 `.session` 属性。 -## Realtime 配方 +## Realtime 配方 {#realtime-recipes} -### Realtime 会话测试 +### Realtime 会话测试 {#test-a-realtime-session} `ScriptedRealtimeModel` 实现 Python SDK 的规范化 `RealtimeModel` 边界。每个 `RealtimeStep` 匹配一个出站 `RealtimeModelSendEvent`,然后发出规范化的入站 `RealtimeModelEvent` 对象或引发注入的错误。 @@ -361,7 +361,7 @@ async def test_realtime_message() -> None: 使用 `connect_events` 在连接期间发出入站事件。使用 `connect_error` 或 `close_error` 注入生命周期故障,并使用 `RealtimeStep(error=...)` 注入与一次匹配发送相关的故障。一个步骤不能同时定义 `emit` 和 `error`。 -### Realtime 工具工作流测试 +### Realtime 工具工作流测试 {#test-a-realtime-tool-workflow} 将真实的函数工具附加到 `RealtimeAgent`,发出规范化工具调用,并预期 SDK 通过模型边界发送工具输出。将 `async_tool_calls` 设置为 `False`,可使这个小型代码示例在连接期间完成,而无需测试专用的等待机制。 @@ -421,7 +421,7 @@ async def test_realtime_tool_workflow() -> None: 这会运行真实的 Realtime 工具查找、参数验证、执行和输出路由。它无法证明真实模型会选择该工具。 -### Realtime 调用与生命周期检查 +### Realtime 调用与生命周期检查 {#inspect-realtime-calls-and-lifecycle} | 成员 | 内容 | | --- | --- | @@ -441,9 +441,9 @@ async def test_realtime_tool_workflow() -> None: | `UnconsumedRealtimeSteps` | `remaining_steps` | 会话在使用所有预期发送之前结束 | | `RealtimeScriptError` | 无 | 脚本在无效的生命周期状态下使用,例如在断开连接时发送 | -## 语音管线配方 +## 语音管线配方 {#voice-pipeline-recipes} -### 语音管线测试 +### 语音管线测试 {#test-a-voice-pipeline} 将脚本化 STT 和 TTS 模型与 `SingleAgentVoiceWorkflow` 以及由 `ScriptedModel` 支持的智能体组合使用,可以在不发出提供商请求的情况下测试完整的语音转文本 -> 智能体 -> 文本转语音管线。 @@ -501,7 +501,7 @@ workflow = ScriptedVoiceWorkflow( `start` 步骤由 `on_start()` 消耗。`VoicePipeline` 仅针对 `StreamedAudioInput` 调用 `on_start()`;静态 `AudioInput` 运行不会消耗 `start`。每个普通轮次都会记录其转录结果,并消耗一个已配置结果。一个字符串代表一个片段;字符串序列可在文本拆分和 TTS 之前控制片段边界。 -### 流式转录测试 +### 流式转录测试 {#test-streamed-transcription} `ScriptedSTTModel` 接受静态 `transcriptions` 和独立脚本化的流式 `sessions`。会话可以是 `ScriptedTranscriptionSession`、转录轮次序列、异常或单个字符串: @@ -515,7 +515,7 @@ stt = ScriptedSTTModel(sessions=[session]) 关闭 `ScriptedTranscriptionSession` 会停止迭代,并留下跳过的轮次供 `assert_complete()` 报告。类似地,`ScriptedTTSModel` 每次调用会消耗一个 `TTSResult`、字节块序列或异常。 -### 语音调用检查 +### 语音调用检查 {#inspect-voice-calls} | 组件 | 记录的历史 | | --- | --- | @@ -532,7 +532,7 @@ stt = ScriptedSTTModel(sessions=[session]) 请对测试配置的每个脚本化语音组件调用 `assert_complete()`。`ScriptedSTTModel.assert_complete()` 还会检查其创建的转录会话中的轮次。 -## 正确边界选择 +## 正确边界选择 {#choose-the-correct-boundary} 当测试需要运行 SDK 运行循环、工具、任务转移、安全防护措施、会话、重试或规范化流式传输,而不依赖模型提供商时,请使用 `ScriptedModel`。 @@ -544,7 +544,7 @@ stt = ScriptedSTTModel(sessions=[session]) 请勿使用这些工具测试 Responses API 或 Chat Completions 请求序列化、身份验证标头、提供商默认值、HTTP 载荷、提供商流分块、Realtime 线上传输帧或提供商特定的生命周期行为。对于这些测试,请保留真实适配器,并替换或控制其网络边界。使用 `openai` v3 时,OpenAI 适配器测试应使用 `httpx2` 的请求、响应、传输和异常类型;旧版 `httpx` 不是 Agents SDK 的核心依赖项。 -## 最终检查清单 +## 最终检查清单 {#final-checklist} - 仅为规范化模型、沙箱会话、Realtime 模型或语音管线边界所管理的交互编写脚本。 - 断言重要的公共请求或调用字段,而不是运行器私有状态。 @@ -555,7 +555,7 @@ stt = ScriptedSTTModel(sessions=[session]) - 断言结构化错误字段,而不是解析供人阅读的消息。 - 使用带受控网络传输的真实适配器进行提供商线上传输测试。 -## 范围与当前限制 +## 范围与当前限制 {#scope-and-current-limitations} 测试模块有意不提供: @@ -568,7 +568,7 @@ stt = ScriptedSTTModel(sessions=[session]) 当测试需要格式错误的流、受控暂停或并发、精确取消,或脚本化工具无法保留的生命周期边界时,请使用对应公共接口的自定义实现。在测试中记录该专用边界。 -## API 参考 +## API 参考 {#api-reference} - [`agents.testing`](ref/testing.md) - [`agents.realtime.testing`](ref/realtime/testing.md) diff --git a/docs/zh/tools.md b/docs/zh/tools.md index 6726b6c4e0..6c84b880d7 100644 --- a/docs/zh/tools.md +++ b/docs/zh/tools.md @@ -12,7 +12,7 @@ search: - Agents as tools:将智能体公开为可调用工具,而无需完整的任务转移。 - 实验性 Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 -## 工具类型选择 +## 工具类型选择 {#choosing-a-tool-type} 将此页面用作目录,然后跳转到与你所控制的运行时相匹配的部分。 @@ -26,7 +26,7 @@ search: | 让一个智能体调用另一个智能体,而不进行任务转移 | [Agents as tools](#agents-as-tools) | | 从智能体运行限定于工作区的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | -## 托管工具 +## 托管工具 {#hosted-tools} 使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI 提供了一些内置工具: @@ -62,7 +62,7 @@ async def main(): print(result.final_output) ``` -### 托管工具搜索 +### 托管工具搜索 {#hosted-tool-search} 工具搜索允许 OpenAI Responses 模型将大型工具集合推迟到运行时加载,使模型只加载当前轮次所需的子集。当你有许多函数工具、命名空间组或托管 MCP 服务器,并且希望减少工具架构所占用的 token,而不预先公开所有工具时,这非常有用。 @@ -126,7 +126,7 @@ print(result.final_output) - 有关涵盖命名空间加载和顶层延迟加载工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 - 官方平台指南:[工具搜索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 -### 编程式工具调用 +### 编程式工具调用 {#programmatic-tool-calling} 编程式工具调用允许受支持的 OpenAI Responses 模型生成 JavaScript,以调用符合条件的工具、合并其输出,并向模型返回一个结果。它适用于范围明确且可从循环、分支、并行调用或中间计算中获益的工作流,无需在每次工具调用后都与模型往返交互。 @@ -180,7 +180,7 @@ print(result.final_output) - 有关完整的并发库存规划代码示例,请参阅 `examples/tools/programmatic_tool_calling.py`。 - 官方平台指南:[编程式工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 -### 托管容器 shell 与技能 +### 托管容器 shell 与技能 {#hosted-container-shell-skills} `ShellTool` 还支持由OpenAI托管的容器执行。当你希望模型在托管容器中运行 shell 命令,而不是在本地运行时中运行时,请使用此模式。 @@ -229,7 +229,7 @@ print(result.final_output) - 有关完整代码示例,请参阅 `examples/tools/container_shell_skill_reference.py` 和 `examples/tools/container_shell_inline_skill.py`。 - OpenAI 平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 -## 本地运行时工具 +## 本地运行时工具 {#local-runtime-tools} 本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用程序或已配置的执行环境完成。 @@ -245,7 +245,7 @@ print(result.final_output) 对于 shell 操作超时,使用正整数毫秒值表示有限超时。在调用本地 `ShellTool` 执行器之前,SDK 会将 `0` 和 `None` 都视为未显式设置超时,因为零在不同执行器实现中没有可移植的统一含义;其他值会在调用执行器之前被拒绝。这仅适用于超时字段:`max_output_length=0` 仍是受支持的空捕获输出请求。 -### ComputerTool 与 Responses 计算机工具 +### ComputerTool 与 Responses 计算机工具 {#computertool-and-the-responses-computer-tool} `ComputerTool` 仍是本地工具框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该框架映射到 OpenAI Responses API 的计算机操作界面。 @@ -304,7 +304,7 @@ agent = Agent( ) ``` -## 函数工具 +## 函数工具 {#function-tools} 你可以将任意 Python 函数用作工具。Agents SDK 会自动设置该工具: @@ -445,7 +445,7 @@ for tool in agent.tools: } ``` -### 函数工具的图像或文件返回 +### 函数工具的图像或文件返回 {#returning-images-or-files-from-function-tools} 除了返回文本输出之外,你还可以返回一个或多个图像或文件作为函数工具的输出。为此,可以返回以下任意内容: @@ -453,7 +453,7 @@ for tool in agent.tools: - 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) - 文本:字符串、可转换为字符串的对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) -### 自定义函数工具 +### 自定义函数工具 {#custom-function-tools} 有时,你可能不希望将 Python 函数用作工具。如果愿意,可以直接创建 [`FunctionTool`][agents.tool.FunctionTool]。你需要提供: @@ -493,7 +493,7 @@ tool = FunctionTool( ) ``` -### 参数和文档字符串的自动解析 +### 参数和文档字符串的自动解析 {#automatic-argument-and-docstring-parsing} 如前所述,我们会自动解析函数签名以提取工具架构,并解析文档字符串以提取工具和各个参数的描述。相关注意事项如下: @@ -502,7 +502,7 @@ tool = FunctionTool( 架构提取代码位于 [`agents.function_schema`][] 中。 -### 使用 Pydantic Field 约束和描述参数 +### 使用 Pydantic Field 约束和描述参数 {#constraining-and-describing-arguments-with-pydantic-field} 你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,两种形式都受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 架构和验证会包含这些约束。 @@ -522,7 +522,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr return f"Score recorded: {score}" ``` -### 函数工具超时 +### 函数工具超时 {#function-tool-timeouts} 你可以使用 `@function_tool(timeout=...)` 为异步函数工具设置单次调用超时。 @@ -577,7 +577,7 @@ except ToolTimeoutError as e: 超时配置仅支持异步 `@function_tool` 处理程序。 -### 函数工具错误处理 +### 函数工具错误处理 {#handling-errors-in-function-tools} 通过 `@function_tool` 创建函数工具时,可以传入 `failure_error_function`。这是一个在工具调用崩溃时向 LLM 提供错误响应的函数。 @@ -609,7 +609,7 @@ def get_user_profile(user_id: str) -> str: 如果手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内部处理错误。 -## Agents as tools +## Agents as tools {#agents-as-tools} 在某些工作流中,你可能希望由一个中央智能体编排由多个专业智能体组成的网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 @@ -655,7 +655,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 工具智能体自定义 +### 工具智能体自定义 {#customizing-tool-agents} `agent.as_tool` 是一种将智能体转换为工具的便捷方法。它支持常见的运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 @@ -681,7 +681,7 @@ async def run_my_agent() -> str: return str(result.final_output) ``` -### 工具智能体的结构化输入 +### 工具智能体的结构化输入 {#structured-input-for-tool-agents} 默认情况下,`Agent.as_tool()` 预期接收一个包含单个字符串字段 `input`(`{"input": "..."}`)的对象,但你可以通过传入 `parameters`(Pydantic 模型类型或 dataclass 类型)公开结构化架构。 @@ -711,11 +711,11 @@ translator_tool = translator_agent.as_tool( 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_structured.py`。 -### 工具智能体的审批门控 +### 工具智能体的审批门控 {#approval-gates-for-tool-agents} `Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理项目将出现在 `result.interruptions` 中;随后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人在回路指南](human_in_the_loop.md)。 -### 自定义输出提取 +### 自定义输出提取 {#custom-output-extraction} 在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中央智能体。这在以下场景中可能很有用: @@ -744,7 +744,7 @@ json_tool = data_agent.as_tool( 在自定义提取器中,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在后处理嵌套结果时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 -### 嵌套智能体运行的流式传输 +### 嵌套智能体运行的流式传输 {#streaming-nested-agent-runs} 将 `on_stream` 回调传给 `as_tool`,即可监听嵌套智能体发出的流式事件,同时仍会在流完成后返回其最终输出。 @@ -772,7 +772,7 @@ billing_agent_tool = billing_agent.as_tool( - 通过模型工具调用来调用该工具时,`tool_call` 会存在;直接调用时,其值可能为 `None`。 - 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 -### 条件式工具启用 +### 条件式工具启用 {#conditional-tool-enabling} 你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选对 LLM 可用的工具。 @@ -842,7 +842,7 @@ asyncio.run(main()) - 对不同工具配置进行 A/B 测试 - 根据运行时状态动态筛选工具 -## 实验性 Codex 工具 +## 实验性 Codex 工具 {#experimental-codex-tool} `codex_tool` 封装了 Codex CLI,使智能体可以在工具调用期间运行限定于工作区的任务(shell、文件编辑、MCP 工具)。此功能目前处于实验阶段,可能会发生变化。 diff --git a/docs/zh/tracing.md b/docs/zh/tracing.md index 7eda5d5dc6..1a6fc3603f 100644 --- a/docs/zh/tracing.md +++ b/docs/zh/tracing.md @@ -16,7 +16,7 @@ Agents SDK 内置了追踪功能,可收集智能体运行期间的完整事件 ***对于根据零数据保留(ZDR)政策使用OpenAI API 的组织,追踪功能不可用。*** -## 追踪和跨度 +## 追踪和跨度 {#traces-and-spans} - **追踪**表示一次“工作流”的端到端操作。它们由跨度组成。追踪具有以下属性: - `workflow_name`:逻辑工作流或应用的名称。例如“代码生成”或“客户服务”。 @@ -30,7 +30,7 @@ Agents SDK 内置了追踪功能,可收集智能体运行期间的完整事件 - `parent_id`,指向此跨度的父跨度(如果有) - `span_data`,即有关跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM 生成的信息,依此类推。 -## 默认追踪 +## 默认追踪 {#default-tracing} 默认情况下,SDK 会追踪以下内容: @@ -62,7 +62,7 @@ result = await Runner.run( 此外,你还可以设置[自定义追踪处理器](#custom-tracing-processors),将追踪发送到其他目标位置(作为替代目标或辅助目标)。 -## 长时运行工作进程和即时导出 +## 长时运行工作进程和即时导出 {#long-running-workers-and-immediate-exports} 默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出追踪;如果内存队列达到大小阈值,则会更早导出;进程退出时还会执行最终刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时运行的工作进程,这意味着通常无需任何额外代码即可自动导出追踪,但它们不一定会在每个作业完成后立即显示在追踪仪表板中。 @@ -105,7 +105,7 @@ async def run(prompt: str, background_tasks: BackgroundTasks): [`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此请在 `trace()` 关闭后调用它,以免刷新尚未构建完成的追踪。如果可以接受默认的导出延迟,则可以跳过此调用。 -## 更高层级的追踪 +## 更高层级的追踪 {#higher-level-traces} 有时,你可能希望多次调用 `run()` 时都归入同一个追踪。为此,可以将整个代码封装在 `trace()` 中。 @@ -124,7 +124,7 @@ async def main(): 1. 由于两次 `Runner.run` 调用都封装在 `with trace()` 中,因此两次运行会成为同一个整体追踪的一部分,而不是各自创建单独的追踪。 -## 追踪的创建 +## 追踪的创建 {#creating-traces} 你可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。你可以通过以下两种方式完成: @@ -133,13 +133,13 @@ async def main(): 当前追踪通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它可自动支持并发。如果手动启动和结束追踪,请将 `mark_as_current` 传给 `start()`,并将 `reset_current` 传给 `finish()`,以更新当前追踪。 -## 跨度的创建 +## 跨度的创建 {#creating-spans} 你可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。通常无需手动创建跨度。你可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 跨度会自动成为当前追踪的一部分,并嵌套在距离最近的当前跨度下;当前跨度通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 -## 敏感数据 +## 敏感数据 {#sensitive-data} 某些跨度可能会捕获潜在的敏感数据。 @@ -149,7 +149,7 @@ async def main(): 默认情况下,`trace_include_sensitive_data` 为 `True`。你可以在运行应用之前,将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,从而无需编写代码即可设置默认值。 -## 自定义追踪处理器 +## 自定义追踪处理器 {#custom-tracing-processors} 追踪功能的高层架构如下: @@ -162,7 +162,7 @@ async def main(): 2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许你使用自己的追踪处理器**替换**默认处理器。这意味着,除非你包含一个可将追踪发送到OpenAI后端的 `TracingProcessor`,否则追踪不会发送到该后端。 -## 非OpenAI模型的追踪 +## 非OpenAI模型的追踪 {#tracing-with-non-openai-models} 使用非OpenAI模型时,你可以向追踪导出器提供 OpenAI API 密钥,从而无需禁用追踪,即可在OpenAI追踪仪表板中使用免费追踪功能。有关适配器的选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 @@ -197,15 +197,15 @@ await Runner.run( ) ``` -## 补充说明 +## 补充说明 {#additional-notes} - 可在OpenAI追踪仪表板中查看免费的追踪记录。 -## 生态系统集成 +## 生态系统集成 {#ecosystem-integrations} 以下社区和供应商集成支持 OpenAI Agents SDK 的追踪 API 接口。 -### 外部追踪处理器列表 +### 外部追踪处理器列表 {#external-tracing-processors-list} - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) diff --git a/docs/zh/usage.md b/docs/zh/usage.md index d6c2d7860f..80376e16c2 100644 --- a/docs/zh/usage.md +++ b/docs/zh/usage.md @@ -6,7 +6,7 @@ search: Agents SDK会自动追踪每次运行的 token 使用量。你可以从运行上下文中访问这些数据,用于监控成本、强制执行限制或记录分析数据。 -## 追踪内容 +## 追踪内容 {#what-is-tracked} - **requests**:发起的 LLM API 调用次数 - **input_tokens**:发送的输入 token 总数 @@ -18,7 +18,7 @@ Agents SDK会自动追踪每次运行的 token 使用量。你可以从运行上 - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 从运行中访问使用量 +## 从运行中访问使用量 {#accessing-usage-from-a-run} 执行 `Runner.run(...)` 后,通过 `result.context_wrapper.usage` 访问使用量。 @@ -36,7 +36,7 @@ print("Total tokens:", usage.total_tokens) 当 [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] 在运行结束前自动压缩历史记录时,该 `responses.compact` 请求报告的使用量也会添加到同一次运行的总量中。在运行之外手动调用 `run_compaction()` 时,由于没有包含该调用的运行上下文,因此不会更新先前运行返回的使用量对象。请参阅 [OpenAI Responses 压缩会话](sessions/index.md#openai-responses-compaction-sessions)。 -### 使用第三方适配器启用使用量统计 +### 使用第三方适配器启用使用量统计 {#enabling-usage-with-third-party-adapters} 不同第三方适配器和提供商后端的使用量报告方式各不相同。如果你通过第三方适配器访问模型,并且需要准确的 `result.context_wrapper.usage` 值: @@ -45,7 +45,7 @@ print("Total tokens:", usage.total_tokens) 请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)一节的适配器专属说明,并在计划部署的具体提供商后端上验证使用量报告。 -## 按请求追踪使用量 +## 按请求追踪使用量 {#per-request-usage-tracking} SDK 会在 `request_usage_entries` 中自动追踪每个 API 请求的使用量,这有助于详细计算成本和监控上下文窗口消耗。 @@ -56,7 +56,7 @@ for i, request in enumerate(result.context_wrapper.usage.request_usage_entries): print(f"Request {i + 1}: {request.input_tokens} in, {request.output_tokens} out") ``` -## 提供商使用量有效载荷的保留 +## 提供商使用量有效载荷的保留 {#preserving-provider-usage-payloads} Agents SDK会将提供商使用量标准化为 [`Usage`][agents.usage.Usage] 字段,从而在不同模型提供商之间提供一致的总量。当应用必须保留提供商特定的使用量字段,或需要区分被省略的字段与提供商报告为零的字段时,请将 [`ModelSettings.preserve_raw_usage`][agents.model_settings.ModelSettings.preserve_raw_usage] 设置为 `True`: @@ -79,7 +79,7 @@ Agents SDK会将每个 [`ModelResponse.raw_usage`][agents.items.ModelResponse.ra 无论是流式运行还是非流式运行,`LitellmModel` 目前都不会填充 `ModelResponse.raw_usage`,因此 `preserve_raw_usage=True` 对该适配器不起作用。使用 `LitellmModel` 时,请继续使用标准化的 [`Usage`][agents.usage.Usage] 字段;如果需要提供商特定字段是否存在的信息,请选择支持保留原始使用量的适配器。 -## 通过会话访问使用量 +## 通过会话访问使用量 {#accessing-usage-with-sessions} 使用 `Session`(例如 `SQLiteSession`)时,每次调用 `Runner.run(...)` 都会返回该次特定运行的使用量。会话会保留对话历史记录作为上下文,但每次运行的使用量相互独立。 @@ -95,7 +95,7 @@ print(second.context_wrapper.usage.total_tokens) # Usage for second run 请注意,虽然会话会在不同运行之间保留对话上下文,但每次调用 `Runner.run()` 返回的使用量指标仅代表该次执行。在会话中,先前的消息可能会作为输入重新传入每次运行,从而影响后续轮次的输入 token 数量。 -## RunState 检查点中的使用量 +## RunState 检查点中的使用量 {#usage-in-runstate-checkpoints} [`RunResult.to_state()`][agents.result.RunResult.to_state] 会捕获截至当前已累计使用量的独立快照。从该检查点恢复的运行以捕获的总量为起点,并在此基础上添加自身模型调用的使用量。恢复后的运行不会将这些新增总量添加到原始 `RunResult`,也不会添加到根据该结果创建的其他检查点。 @@ -113,7 +113,7 @@ assert resumed_b.context_wrapper.usage is not resumed_a.context_wrapper.usage 这种隔离也适用于 [`Usage`][agents.usage.Usage] 中的 `request_usage_entries` 列表。恢复后的嵌套 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行是顶层独立计量的例外:该嵌套运行恢复后的模型使用量会被有意汇总到当前外层运行的使用量中,与该嵌套运行先前的模型调用处理方式相同。 -## 钩子中的使用量 +## 钩子中的使用量 {#using-usage-in-hooks} 如果你使用 `RunHooks`,传递给每个钩子的 `context` 对象都包含 `usage`。这样便可在生命周期的关键时刻记录使用量。 @@ -124,7 +124,7 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API 参考 +## API 参考 {#api-reference} 有关详细的 API 文档,请参阅: diff --git a/docs/zh/visualization.md b/docs/zh/visualization.md index 225738a0e7..9c20bbf8a2 100644 --- a/docs/zh/visualization.md +++ b/docs/zh/visualization.md @@ -6,7 +6,7 @@ search: 智能体可视化允许你使用 **Graphviz** 生成智能体及其与其他智能体、工具和 MCP 服务器之间连接关系的结构化图形表示。这有助于理解智能体、工具和任务转移在应用程序中如何交互。 -## 安装 +## 安装 {#installation} 安装可选的 `viz` 依赖组: @@ -14,7 +14,7 @@ search: pip install "openai-agents[viz]" ``` -## 图形生成 +## 图形生成 {#generating-a-graph} 你可以使用 `draw_graph` 函数生成智能体可视化图。此函数会创建一个有向图,其中: @@ -23,7 +23,7 @@ pip install "openai-agents[viz]" - **工具**以绿色椭圆表示。 - **任务转移**以从一个智能体指向另一个智能体的有向边表示。 -### 用法示例 +### 用法示例 {#example-usage} ```python import os @@ -75,7 +75,7 @@ draw_graph(triage_agent) `draw_graph()` 会递归展开直接在 `handoffs` 中提供或通过 `handoff(agent)` 注册的目标智能体。无论采用哪种方式,图中都会包含每个目标的工具、MCP 服务器和下游任务转移。如果自定义 `Handoff` 没有可用的目标 `Agent`,则只会将其渲染为具名目标,因此图中无法展开该目标背后的资源。 -## 可视化说明 +## 可视化说明 {#understanding-the-visualization} 生成的图包括: @@ -91,16 +91,16 @@ draw_graph(triage_agent) **注意:**在较新版本的 `agents` 包中会渲染 MCP 服务器,包括已验证此行为的 **v0.2.8**。如果在可视化图中看不到 MCP 方框,请升级到最新版本。 -## 图形自定义 +## 图形自定义 {#customizing-the-graph} -### 图形显示 +### 图形显示 {#showing-the-graph} 默认情况下,`draw_graph` 会内联显示图形。若要在单独的窗口中显示图形,请编写以下代码: ```python draw_graph(triage_agent).view() ``` -### 图形保存 +### 图形保存 {#saving-the-graph} 默认情况下,`draw_graph` 会内联显示图形。若要将其保存为文件,请指定文件名: ```python diff --git a/docs/zh/voice/pipeline.md b/docs/zh/voice/pipeline.md index 0822be2859..c62673d65c 100644 --- a/docs/zh/voice/pipeline.md +++ b/docs/zh/voice/pipeline.md @@ -32,7 +32,7 @@ graph LR ``` -## 管线配置 +## 管线配置 {#configuring-a-pipeline} 创建管线时,你可以设置以下几项: @@ -43,14 +43,14 @@ graph LR - 追踪,包括是否禁用追踪、是否上传音频文件、工作流名称、追踪 ID 等 - TTS 和 STT 模型的设置,例如提示词、语言和使用的数据类型。 -## 管线运行 +## 管线运行 {#running-a-pipeline} 你可以通过 [`run()`][agents.voice.pipeline.VoicePipeline.run] 方法运行管线。该方法允许你传入以下两种形式的音频输入: 1. 当你拥有完整的音频输入,并且只想为其生成结果时,可使用 [`AudioInput`][agents.voice.input.AudioInput]。这适用于不需要检测说话者何时结束发言的场景;例如,使用预录音频,或在一键通话应用中能够明确判断用户何时结束发言。 2. 当你可能需要检测用户何时结束发言时,可使用 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]。它允许你在检测到音频分块时将其推送,而语音管线会通过名为“活动检测”的过程,在适当的时机自动运行智能体工作流。 -## 结果 +## 结果 {#results} 语音管线运行的结果是 [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]。你可以通过此对象在事件发生时对其进行流式传输。[`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent] 有以下几种类型: @@ -76,8 +76,8 @@ async for event in result.stream(): pass ``` -## 最佳实践 +## 最佳实践 {#best-practices} -### 中断 +### 中断 {#interruptions} Agents SDK 目前不为 [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] 提供任何内置的中断处理机制。相反,每个检测到的轮次都会触发工作流的一次独立运行。如果你想在应用程序中处理中断,可以监听 [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] 事件。`turn_started` 表示新轮次已完成转录,处理即将开始。相应轮次的所有音频分发完毕后,会触发 `turn_ended`。你可以利用这些事件,在模型开始一个轮次时将说话者的麦克风静音,并在应用程序播放完与该轮次相关的所有音频后取消静音。 \ No newline at end of file diff --git a/docs/zh/voice/quickstart.md b/docs/zh/voice/quickstart.md index a875a1b99a..758bce0f1c 100644 --- a/docs/zh/voice/quickstart.md +++ b/docs/zh/voice/quickstart.md @@ -4,7 +4,7 @@ search: --- # 快速入门 -## 前提条件 +## 前提条件 {#prerequisites} 请确保已按照 Agents SDK 的基础[快速入门说明](../quickstart.md)完成操作,并设置好虚拟环境。然后,从 SDK 安装可选的语音依赖项: @@ -18,7 +18,7 @@ pip install 'openai-agents[voice]' pip install sounddevice ``` -## 概念 +## 概念 {#concepts} 需要了解的主要概念是 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline],它包含三个步骤: @@ -52,7 +52,7 @@ graph LR ``` -## 智能体 +## 智能体 {#agents} 首先,我们来设置一些智能体。如果您曾使用此 SDK 构建过智能体,这些内容应该会很熟悉。我们将设置两个智能体、一项已配置的任务转移和一个工具。 @@ -92,7 +92,7 @@ agent = Agent( ) ``` -## 语音管线 +## 语音管线 {#voice-pipeline} 我们将设置一个简单的语音管线,并使用 [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] 作为工作流。 @@ -101,7 +101,7 @@ from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` -## 管线运行 +## 管线运行 {#run-the-pipeline} ```python import numpy as np @@ -126,7 +126,7 @@ async for event in result.stream(): ``` -## 完整整合 +## 完整整合 {#put-it-all-together} ```python import asyncio From 4f7c1d668f9d73c5a0ec0e8c17687fcb03b04a63 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 22 Aug 2026 17:10:54 -0500 Subject: [PATCH 398/473] fix(realtime): advance crossed guardrail thresholds (#4590) --- src/agents/realtime/session.py | 5 ++++- tests/realtime/test_session.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 77d4da0937..ef5f18cdfd 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -1774,7 +1774,10 @@ def _record_output_guardrail_delta( next_run_threshold = (self._item_guardrail_run_counts[item_id] + 1) * threshold if current_length >= next_run_threshold: - self._item_guardrail_run_counts[item_id] += 1 + crossed_thresholds = current_length // threshold if threshold > 0 else 1 + self._item_guardrail_run_counts[item_id] = max( + self._item_guardrail_run_counts[item_id] + 1, crossed_thresholds + ) self._enqueue_guardrail_task( self._item_transcripts[item_id], response_id, diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index ab0ac5ebfc..178d37fa8e 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -5588,6 +5588,39 @@ async def test_transcript_delta_multiple_thresholds_same_item( assert mock_model.interrupts_called == 1 assert len(mock_model.sent_messages) == 1 + @pytest.mark.asyncio + async def test_large_transcript_delta_advances_past_each_crossed_threshold( + self, mock_model, mock_agent + ): + calls = 0 + + async def guardrail_func(context, agent, output): + nonlocal calls + calls += 1 + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) + + guardrail = OutputGuardrail(guardrail_function=guardrail_func) + run_config: RealtimeRunConfig = { + "output_guardrails": [guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="123456789012", response_id="resp_1" + ) + ) + await self._wait_for_guardrail_tasks(session) + assert calls == 1 + + await session.on_event( + RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="3", response_id="resp_1") + ) + await self._wait_for_guardrail_tasks(session) + + assert calls == 1 + @pytest.mark.asyncio async def test_transcript_delta_different_items_tracked_separately( self, mock_model, mock_agent, safe_guardrail From 042d84a15c37bc6f66058dca3deda0311883db38 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 22 Aug 2026 17:11:05 -0500 Subject: [PATCH 399/473] fix(mcp): deduplicate managed servers (#4591) --- src/agents/mcp/manager.py | 2 +- .../mcp/test_mcp_server_manager_cleanup_state.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/agents/mcp/manager.py b/src/agents/mcp/manager.py index 94df096d04..733e744363 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -200,7 +200,7 @@ def __init__( suppress_cancelled_error: bool = True, connect_in_parallel: bool = False, ) -> None: - self._all_servers = list(servers) + self._all_servers = self._unique_servers(servers) self._active_servers = list(self._all_servers) self.connect_timeout_seconds = connect_timeout_seconds self.cleanup_timeout_seconds = cleanup_timeout_seconds diff --git a/tests/mcp/test_mcp_server_manager_cleanup_state.py b/tests/mcp/test_mcp_server_manager_cleanup_state.py index bf6c82ef65..f411f464d8 100644 --- a/tests/mcp/test_mcp_server_manager_cleanup_state.py +++ b/tests/mcp/test_mcp_server_manager_cleanup_state.py @@ -29,6 +29,22 @@ async def test_cleanup_all_removes_cleaned_servers_from_active_servers() -> None assert server.connect.await_count == 2 +@pytest.mark.asyncio +async def test_manager_owns_repeated_server_instance_once() -> None: + server = cast(MCPServer, Mock(spec=MCPServer)) + server.connect = AsyncMock() + server.cleanup = AsyncMock() + + manager = MCPServerManager([server, server]) + + assert manager.all_servers == [server] + assert await manager.connect_all() == [server] + await manager.cleanup_all() + + server.connect.assert_awaited_once() + server.cleanup.assert_awaited_once() + + @pytest.mark.asyncio async def test_cleanup_all_refreshes_active_servers_when_cancellation_propagates() -> None: server = cast(MCPServer, Mock(spec=MCPServer)) From 8cd1f5e6e5e25a7c9c643a7d3f41cc008cb50993 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 22 Aug 2026 17:18:42 -0500 Subject: [PATCH 400/473] fix(core): strip created_by when replaying RunItems as input (#4568) --- src/agents/items.py | 11 +---- tests/test_items_helpers.py | 79 ++++++++++++++++++++++++++++++++ tests/test_run_internal_items.py | 23 ++++++++++ 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/agents/items.py b/src/agents/items.py index a4da32fe97..49f734b1d0 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -150,14 +150,7 @@ def _get_agent_via_weakref(self, attr_name: str, ref_name: str) -> Any: def to_input_item(self) -> TResponseInputItem: """Converts this item into an input item suitable for passing to the model.""" - if isinstance(self.raw_item, dict): - # We know that input items are dicts, so we can ignore the type error - return self.raw_item # type: ignore - elif isinstance(self.raw_item, BaseModel): - # All output items are Pydantic models that can be converted to input items. - return self.raw_item.model_dump(exclude_unset=True) # type: ignore - else: - raise AgentsException(f"Unexpected raw item type: {type(self.raw_item)}") + return _output_item_to_input_item(self.raw_item) @dataclass @@ -491,7 +484,7 @@ def to_input_item(self) -> TResponseInputItem: if isinstance(outcome, dict): if outcome.get("type") == "exit": entry["outcome"] = outcome - return cast(TResponseInputItem, payload) + return _output_item_to_input_item(payload) return super().to_input_item() diff --git a/tests/test_items_helpers.py b/tests/test_items_helpers.py index 264f631092..df96b65243 100644 --- a/tests/test_items_helpers.py +++ b/tests/test_items_helpers.py @@ -621,6 +621,85 @@ def test_to_input_items_strips_created_by_for_non_tool_search_items() -> None: assert all("created_by" not in item for item in input_items) +def test_tool_call_item_to_input_item_strips_created_by() -> None: + """RunItem replay must strip output-only created_by, matching ModelResponse.to_input_items.""" + agent = Agent(name="A") + call = ResponseFunctionToolCall.model_validate( + { + "id": "fc_1", + "arguments": "{}", + "call_id": "call_1", + "name": "lookup", + "type": "function_call", + "created_by": "server", + } + ) + item = ToolCallItem(agent=agent, raw_item=call) + replayed = item.to_input_item() + assert isinstance(replayed, dict) + assert replayed["type"] == "function_call" + assert "created_by" not in replayed + assert getattr(call, "created_by", None) == "server" + + +def test_tool_call_output_item_to_input_item_strips_created_by() -> None: + agent = Agent(name="A") + item = ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call_1", + "output": "ok", + "created_by": "server", + }, + output="ok", + ) + replayed = item.to_input_item() + assert isinstance(replayed, dict) + assert replayed["type"] == "function_call_output" + assert "created_by" not in replayed + assert item.raw_item["created_by"] == "server" + + +def test_dict_shell_call_output_item_to_input_item_sanitizes_without_mutation() -> None: + agent = Agent(name="A") + raw_item = { + "type": "shell_call_output", + "call_id": "call_1", + "status": "completed", + "shell_output": "legacy", + "provider_data": {"provider": "value"}, + "created_by": "server", + "output": [ + { + "stdout": "ok", + "stderr": "", + "outcome": {"type": "exit", "exit_code": 0}, + "created_by": "server", + } + ], + } + original_chunk = raw_item["output"][0] + item = ToolCallOutputItem(agent=agent, raw_item=raw_item, output="ok") + + replayed = item.to_input_item() + + assert replayed == { + "type": "shell_call_output", + "call_id": "call_1", + "output": [ + { + "stdout": "ok", + "stderr": "", + "outcome": {"type": "exit", "exit_code": 0}, + } + ], + } + assert raw_item["created_by"] == "server" + assert original_chunk["created_by"] == "server" + assert raw_item["output"][0] is original_chunk + + def test_to_input_items_strips_nested_created_by_from_shell_call_output() -> None: """``shell_call_output`` carries ``created_by`` at the item level and inside each output chunk. diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index 2b991ea342..1cdc793213 100644 --- a/tests/test_run_internal_items.py +++ b/tests/test_run_internal_items.py @@ -898,6 +898,29 @@ def test_run_item_to_input_item_strips_tool_search_created_by() -> None: assert "created_by" not in converted_output +def test_run_item_to_input_item_strips_function_call_created_by() -> None: + agent = Agent(name="A") + tool_call = ToolCallItem( + agent=agent, + raw_item=ResponseFunctionToolCall.model_validate( + { + "id": "fc_1", + "arguments": "{}", + "call_id": "call_1", + "name": "lookup", + "type": "function_call", + "created_by": "server", + } + ), + ) + + converted = run_items.run_item_to_input_item(tool_call) + + assert isinstance(converted, dict) + assert converted["type"] == "function_call" + assert "created_by" not in converted + + def test_run_item_to_input_item_omits_tool_call_metadata() -> None: agent = Agent(name="A") tool_call = ToolCallItem( From 9da8f49637892e96e198d7d061471b031ec13fcc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 23 Aug 2026 07:34:06 +0900 Subject: [PATCH 401/473] fix(voice): use monotonic STT event deadlines (#4593) Co-authored-by: Henry Su --- src/agents/voice/models/openai_stt.py | 8 ++- tests/voice/test_openai_stt.py | 93 ++++++++++++++------------- 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 3aefb5d71b..3a9edce3c4 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -3,9 +3,9 @@ import asyncio import base64 import json -import time from collections.abc import AsyncIterator from dataclasses import dataclass +from time import monotonic from typing import Any, cast from openai import AsyncOpenAI @@ -90,9 +90,11 @@ async def _wait_for_event( """ Wait for an event from event_queue whose type is in expected_types within the specified timeout. """ - start_time = time.time() + # Wall-clock adjustments can move a deadline forwards or backwards. Timeout + # accounting must use a monotonic clock instead. + start_time = monotonic() while True: - remaining = timeout - (time.time() - start_time) + remaining = timeout - (monotonic() - start_time) if remaining <= 0: raise TimeoutError(f"Timeout waiting for event(s): {expected_types}") evt = await asyncio.wait_for(event_queue.get(), timeout=remaining) diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index 11ae7e1423..26deb286cd 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -4,7 +4,6 @@ import base64 import json import logging -import time from collections.abc import AsyncGenerator from typing import cast from unittest.mock import AsyncMock, MagicMock, patch @@ -30,10 +29,10 @@ ) from agents.voice.exceptions import STTWebsocketConnectionError from agents.voice.models.openai_stt import ( - EVENT_INACTIVITY_TIMEOUT, ErrorSentinel, WebsocketDoneSentinel, _audio_buffer_to_base64, + _wait_for_event, ) from .pipeline_test_models import StreamedAudioInputFactory @@ -57,6 +56,31 @@ def create_mock_websocket(messages: list[str]) -> AsyncMock: return mock_ws +@pytest.mark.asyncio +async def test_wait_for_event_returns_matching_event() -> None: + queue: asyncio.Queue[dict[str, str]] = asyncio.Queue() + await queue.put({"type": "session.created"}) + + event = await _wait_for_event(queue, ["session.created"], timeout=1) + + assert event == {"type": "session.created"} + + +@pytest.mark.asyncio +async def test_wait_for_event_uses_one_deadline_across_unrelated_events() -> None: + queue: asyncio.Queue[dict[str, str]] = asyncio.Queue() + await queue.put({"type": "unrelated"}) + + with patch( + "agents.voice.models.openai_stt.monotonic", + side_effect=[1000.0, 1000.0, 1011.0], + ): + with pytest.raises(TimeoutError, match="Timeout waiting for event"): + await _wait_for_event(queue, ["session.created"], timeout=10) + + assert queue.empty() + + def create_mock_openai_client(api_key: str = "FAKE_KEY") -> AsyncOpenAI: client = AsyncMock(api_key=api_key) client.websocket_base_url = None @@ -593,8 +617,8 @@ async def test_timeout_waiting_for_created_event(monkeypatch): def fake_time_func(): return next(time_gen) - # Monkey-patch time.time with our fake_time_func - monkeypatch.setattr(time, "time", fake_time_func) + # Patch only the STT deadline clock so the asyncio event-loop clock remains real. + monkeypatch.setattr("agents.voice.models.openai_stt.monotonic", fake_time_func) mock_ws = create_mock_websocket( [ @@ -755,60 +779,43 @@ async def messages_then_timeout() -> AsyncGenerator[str, None]: @pytest.mark.asyncio -async def test_inactivity_timeout(): +async def test_inactivity_timeout(monkeypatch: pytest.MonkeyPatch) -> None: """ - Test that if no events arrive in EVENT_INACTIVITY_TIMEOUT ms, + Test that if no events arrive in EVENT_INACTIVITY_TIMEOUT seconds, _handle_events breaks out and a SessionCompleteSentinel is placed in the output queue. """ - # We'll feed only the creation + updated events. Then do nothing. - # The handle_events loop should eventually time out. - mock_ws = create_mock_websocket( - [ - json.dumps({"type": "unknown"}), - json.dumps({"type": "unknown"}), - json.dumps({"type": "transcription_session.created"}), - json.dumps({"type": "transcription_session.updated"}), - ] - ) - # We'll artificially manipulate the "time" to simulate inactivity quickly. - # The code checks time.time() for inactivity over EVENT_INACTIVITY_TIMEOUT. - # We'll increment the return_value manually. - with ( - patch("websockets.connect", return_value=mock_ws), - patch( - "time.time", - side_effect=[ - 1000.0, - 1000.0 + EVENT_INACTIVITY_TIMEOUT + 1, - 2000.0 + EVENT_INACTIVITY_TIMEOUT + 1, - 3000.0 + EVENT_INACTIVITY_TIMEOUT + 1, - 9999, - ], - ), - ): - audio_input = await StreamedAudioInputFactory.get(count=2) - stt_settings = STTModelSettings() + async def messages_then_wait() -> AsyncGenerator[str, None]: + yield json.dumps({"type": "transcription_session.created"}) + yield json.dumps({"type": "transcription_session.updated"}) + await asyncio.Event().wait() + + mock_ws = AsyncMock() + mock_ws.__aenter__.return_value = mock_ws + mock_ws.__aiter__.side_effect = messages_then_wait + monkeypatch.setattr("agents.voice.models.openai_stt.EVENT_INACTIVITY_TIMEOUT", 0.01) + with patch("websockets.connect", return_value=mock_ws): + audio_input = await StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, client=create_mock_openai_client(), model="whisper-1", - settings=stt_settings, + settings=STTModelSettings(), trace_include_sensitive_data=False, trace_include_sensitive_audio_data=False, ) - collected_turns: list[str] = [] - with pytest.raises(STTWebsocketConnectionError) as exc_info: - async for turn in session.transcribe_turns(): - collected_turns.append(turn) - - assert "Timeout waiting for transcription_session" in str(exc_info.value) + async def collect_turns() -> list[str]: + return [turn async for turn in session.transcribe_turns()] - assert len(collected_turns) == 0, "No transcripts expected, but we got something?" + collected_turns = await asyncio.wait_for(collect_turns(), timeout=1) - await session.close() + assert collected_turns == [] + assert session._process_events_task is not None + assert session._process_events_task.done() + assert not session._process_events_task.cancelled() + assert session._process_events_task.exception() is None @pytest.mark.asyncio From 89fab0fc0d32020112a9ec14bbe851ddbb96edca Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 23 Aug 2026 08:45:14 +0900 Subject: [PATCH 402/473] feat: customize output guardrail blocked messages (#4594) --- src/agents/__init__.py | 4 + src/agents/run.py | 43 +++- src/agents/run_config.py | 54 +++++ src/agents/run_internal/blocked_output.py | 76 +++++- src/agents/run_internal/run_loop.py | 22 +- tests/test_agent_runner_streamed.py | 278 +++++++++++++++++++++- tests/test_run_config.py | 38 +++ 7 files changed, 501 insertions(+), 14 deletions(-) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index a051befd2c..2546ecd551 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -111,6 +111,8 @@ retry_policies, ) from .run import ( + OutputGuardrailBlockedMessageArgs, + OutputGuardrailBlockedMessageFormatter, ReasoningItemIdPolicy, RunConfig, Runner, @@ -481,6 +483,8 @@ def enable_verbose_stdout_logging() -> None: "RunResultStreaming", "ResponsesWebSocketSession", "RunConfig", + "OutputGuardrailBlockedMessageArgs", + "OutputGuardrailBlockedMessageFormatter", "ToolNameCollisionPolicy", "ReasoningItemIdPolicy", "ToolExecutionConfig", diff --git a/src/agents/run.py b/src/agents/run.py index 9d6621e66c..a782f80cce 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -45,6 +45,8 @@ CallModelData, CallModelInputFilter, ModelInputData, + OutputGuardrailBlockedMessageArgs, + OutputGuardrailBlockedMessageFormatter, ReasoningItemIdPolicy, RunConfig, RunOptions, @@ -82,12 +84,14 @@ ) from .run_internal.approvals import approvals_from_step from .run_internal.blocked_output import ( + OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, _blocked_output_failure_items, _BlockedOutputOwnerStarts, _current_response_boundary, _final_turn_items_for_persistence, _has_output_guardrails, _is_terminal_tool_output_response, + _resolve_output_guardrail_blocked_message, _retained_items_for_blocked_response, _sanitize_blocked_output_guardrail_results, _should_defer_interrupted_session_items, @@ -170,6 +174,8 @@ "ModelInputData", "CallModelData", "CallModelInputFilter", + "OutputGuardrailBlockedMessageArgs", + "OutputGuardrailBlockedMessageFormatter", "ToolNameCollisionPolicy", "ReasoningItemIdPolicy", "ToolExecutionConfig", @@ -1262,12 +1268,30 @@ def _mark_response_hooks_started() -> None: (), blocked_output_owner_starts, ) + blocked_message = _resolve_output_guardrail_blocked_message( + exc, + agent=current_agent, + run_config=run_config, + context_wrapper=context_wrapper, + ) + if blocked_message != OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT: + sanitized_results = ( + _sanitize_blocked_output_guardrail_results( + sanitized_results, + exc, + blocked_message, + ) + ) + output_guardrail_results[output_guardrail_result_start:] = ( + sanitized_results + ) retained_items = _retained_items_for_blocked_response( turn_session_items, turn_result.model_response, run_state, current_processed_response, owner_starts=blocked_output_owner_starts, + blocked_message=blocked_message, ) list.extend(session_items, retained_items) try: @@ -1865,8 +1889,7 @@ async def _save_max_turns_handler_output( ): raise sanitized_results = _sanitize_blocked_output_guardrail_results( - output_guardrail_results[output_guardrail_result_start:], - exc, + output_guardrail_results[output_guardrail_result_start:], exc ) output_guardrail_results[output_guardrail_result_start:] = ( sanitized_results @@ -1876,12 +1899,28 @@ async def _save_max_turns_handler_output( (), blocked_output_owner_starts, ) + blocked_message = _resolve_output_guardrail_blocked_message( + exc, + agent=current_agent, + run_config=run_config, + context_wrapper=context_wrapper, + ) + if blocked_message != OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT: + sanitized_results = _sanitize_blocked_output_guardrail_results( + sanitized_results, + exc, + blocked_message, + ) + output_guardrail_results[output_guardrail_result_start:] = ( + sanitized_results + ) retained_items = _retained_items_for_blocked_response( turn_session_items, turn_result.model_response, run_state, turn_result.processed_response, owner_starts=blocked_output_owner_starts, + blocked_message=blocked_message, ) list.extend(session_items, retained_items) try: diff --git a/src/agents/run_config.py b/src/agents/run_config.py index cf28ec5cf8..f57d46c6f1 100644 --- a/src/agents/run_config.py +++ b/src/agents/run_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import os from collections.abc import Callable from dataclasses import dataclass, field @@ -104,6 +105,33 @@ class ToolErrorFormatterArgs(Generic[TContext]): ToolErrorFormatter = Callable[[ToolErrorFormatterArgs[Any]], MaybeAwaitable[str | None]] +@dataclass +class OutputGuardrailBlockedMessageArgs(Generic[TContext]): + """Data passed to output guardrail blocked-message formatters.""" + + default_message: str + """The SDK default data-free placeholder.""" + + guardrail_name: str + """The name of the output guardrail that triggered the tripwire.""" + + agent: Agent[Any] + """The agent whose final output was rejected.""" + + run_context: RunContextWrapper[TContext] + """The active run context wrapper.""" + + +# Keep this formatter synchronous. It runs after a terminal tool output is rejected but before +# every replay and persistence owner is rebuilt with the data-free replacement. Awaiting +# application code at that boundary can leave the rejected output reachable through cancellation +# traceback locals or partially sanitized state. Async support therefore requires a redesign of +# the redaction boundary, not merely awaiting the formatter result here. +OutputGuardrailBlockedMessageFormatter = Callable[ + [OutputGuardrailBlockedMessageArgs[Any]], str | None +] + + @dataclass class ToolExecutionConfig: """Grouped SDK-side execution settings for local tool calls.""" @@ -458,6 +486,15 @@ class RunConfig: Existing strict validation for namespaced and deferred-loading tools is unchanged. """ + output_guardrail_blocked_message: str | OutputGuardrailBlockedMessageFormatter | None = None + """Customize the data-free placeholder retained for terminal tool output rejected by an + output guardrail. + + Pass a non-empty string or a synchronous formatter that receives safe run metadata. Returning + ``None`` or an invalid value, or raising from the formatter, uses the SDK default. The rejected + output and guardrail ``output_info`` are never passed to the formatter. + """ + if TYPE_CHECKING: def __init__( @@ -486,11 +523,26 @@ def __init__( tool_execution: ToolExecutionConfig | dict[str, Any] | None = None, tool_not_found_behavior: ToolNotFoundBehavior = "raise_error", tool_name_collision_policy: ToolNameCollisionPolicy = "warn", + output_guardrail_blocked_message: ( + str | OutputGuardrailBlockedMessageFormatter | None + ) = None, ) -> None: ... def __post_init__(self) -> None: if self.tool_name_collision_policy not in ("warn", "error"): raise ValueError("tool_name_collision_policy must be either 'warn' or 'error'") + blocked_message = self.output_guardrail_blocked_message + if type(blocked_message) is str: + if not blocked_message: + raise ValueError("output_guardrail_blocked_message must be non-empty") + elif isinstance(blocked_message, str): + raise TypeError( + "output_guardrail_blocked_message must be a built-in string, callable, or None" + ) + elif blocked_message is not None and not callable(blocked_message): + raise TypeError("output_guardrail_blocked_message must be a string, callable, or None") + elif inspect.iscoroutinefunction(blocked_message): + raise TypeError("output_guardrail_blocked_message formatter must be synchronous") if self.model_settings is not None: self.model_settings = _coerce_model_settings( self.model_settings, @@ -562,6 +614,8 @@ def _coerce_run_config(value: RunConfig | dict[str, Any]) -> RunConfig: "CallModelData", "CallModelInputFilter", "ModelInputData", + "OutputGuardrailBlockedMessageArgs", + "OutputGuardrailBlockedMessageFormatter", "ReasoningItemIdPolicy", "RunConfig", "RunOptions", diff --git a/src/agents/run_internal/blocked_output.py b/src/agents/run_internal/blocked_output.py index bb1da4ffaa..667871f331 100644 --- a/src/agents/run_internal/blocked_output.py +++ b/src/agents/run_internal/blocked_output.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses as _dc +import inspect from collections.abc import Sequence from typing import Any, TypeVar, cast @@ -22,7 +23,12 @@ from ..items import ModelResponse, RunItem, ToolCallItem, ToolCallOutputItem from ..memory import Session from ..result import RunResultStreaming -from ..run_config import RunConfig +from ..run_config import ( + OutputGuardrailBlockedMessageArgs, + OutputGuardrailBlockedMessageFormatter, + RunConfig, +) +from ..run_context import RunContextWrapper from ..run_state import RunState from ..tool_guardrails import ( ToolGuardrailFunctionOutput, @@ -158,6 +164,7 @@ def blocked_function_output_payload(raw_item: Any) -> dict[str, Any]: def _sanitize_blocked_output_guardrail_results( results: Sequence[OutputGuardrailResult], tripwire: OutputGuardrailTripwireTriggered, + blocked_message: str = OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, ) -> list[OutputGuardrailResult]: """Build data-free guardrail results and detach the tripwire from raw output.""" sanitized_by_id: dict[int, OutputGuardrailResult] = {} @@ -168,7 +175,7 @@ def sanitize(result: OutputGuardrailResult) -> OutputGuardrailResult: return existing sanitized = OutputGuardrailResult( guardrail=result.guardrail, - agent_output=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + agent_output=blocked_message, agent=result.agent, output=GuardrailFunctionOutput( output_info=None, @@ -185,6 +192,44 @@ def sanitize(result: OutputGuardrailResult) -> OutputGuardrailResult: return sanitized_results +def _resolve_output_guardrail_blocked_message( + tripwire: OutputGuardrailTripwireTriggered, + *, + agent: Agent[Any], + run_config: RunConfig, + context_wrapper: RunContextWrapper[Any], +) -> str: + """Resolve a custom placeholder without suspending the redaction pipeline.""" + blocked_message = OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + configured = run_config.output_guardrail_blocked_message + if configured is None: + return blocked_message + if type(configured) is str: + resolved: Any = configured + else: + try: + formatter = cast(OutputGuardrailBlockedMessageFormatter, configured) + resolved = formatter( + OutputGuardrailBlockedMessageArgs( + default_message=blocked_message, + guardrail_name=tripwire.guardrail_result.guardrail.get_name(), + agent=agent, + run_context=context_wrapper, + ) + ) + except BaseException: + return blocked_message + if inspect.iscoroutine(resolved): + try: + resolved.close() + except BaseException: + pass + return blocked_message + if type(resolved) is not str or not resolved: + return blocked_message + return resolved + + @_dc.dataclass(frozen=True) class _CurrentResponseBoundary: """A current-response suffix proven only by lifecycle position or object identity.""" @@ -419,6 +464,7 @@ def _is_terminal_tool_output_response( def _prepare_blocked_output_snapshot( boundary: _CurrentResponseBoundary, model_response: ModelResponse | None, + blocked_message: str, ) -> _BlockedOutputSnapshot: """Build an allowlist-only function call/output snapshot before changing live state.""" current_items = list(boundary.items) @@ -448,6 +494,7 @@ def _prepare_blocked_output_snapshot( ) elif isinstance(item, ToolCallOutputItem): payload = blocked_function_output_payload(item.raw_item) + payload["output"] = blocked_message call_id = cast(str, payload["call_id"]) if call_id in outputs_by_id: raise AgentsException("Cannot sanitize duplicate function outputs.") @@ -455,7 +502,7 @@ def _prepare_blocked_output_snapshot( replacements[index] = ToolCallOutputItem( agent=item.agent, raw_item=cast(Any, payload), - output=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + output=blocked_message, tool_origin=item.tool_origin, custom_data=None, ) @@ -640,6 +687,7 @@ def _sever_blocked_output_replay_graph(cleanup_plan: _BlockedOutputOwnerPlan) -> def _data_free_tool_output_guardrail_results( results: Sequence[ToolOutputGuardrailResult], + blocked_message: str, ) -> tuple[ToolOutputGuardrailResult, ...]: """Rebuild current-turn tool guardrail results without retaining caller output data.""" replacements: list[ToolOutputGuardrailResult] = [] @@ -656,16 +704,16 @@ def _data_free_tool_output_guardrail_results( return () if str.__eq__(behavior_type, "allow") is True: sanitized_output = ToolGuardrailFunctionOutput.allow( - output_info=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + output_info=blocked_message, ) elif str.__eq__(behavior_type, "reject_content") is True: sanitized_output = ToolGuardrailFunctionOutput.reject_content( - message=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, - output_info=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + message=blocked_message, + output_info=blocked_message, ) elif str.__eq__(behavior_type, "raise_exception") is True: sanitized_output = ToolGuardrailFunctionOutput.raise_exception( - output_info=OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, + output_info=blocked_message, ) else: return () @@ -688,6 +736,7 @@ def _prepare_blocked_output_owner_plan( streamed_result: RunResultStreaming | None, prefixes: _BlockedOutputOwnerPrefixes, cleanup_plan: _BlockedOutputOwnerPlan, + blocked_message: str, ) -> _BlockedOutputOwnerPlan: """Build every owner replacement before applying any of them.""" safe_items = list(snapshot.items) if snapshot is not None else [] @@ -706,7 +755,10 @@ def _prepare_blocked_output_owner_plan( ) else: current_results = [] - safe_tool_output_guardrail_results = _data_free_tool_output_guardrail_results(current_results) + safe_tool_output_guardrail_results = _data_free_tool_output_guardrail_results( + current_results, + blocked_message, + ) if streamed_result is not None: public_safe_results = [ *prefixes.streamed_tool_output_guardrail_results, @@ -793,6 +845,7 @@ def _retained_items_for_blocked_response( processed_response: ProcessedResponse | None = None, streamed_result: RunResultStreaming | None = None, owner_starts: _BlockedOutputOwnerStarts | None = None, + blocked_message: str = OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, ) -> list[RunItem]: """Return a complete data-free response or discard the entire unsupported suffix.""" boundary = _current_response_boundary(items, processed_response, run_state) @@ -805,7 +858,11 @@ def _retained_items_for_blocked_response( snapshot: _BlockedOutputSnapshot | None = None try: if boundary.proven: - snapshot = _prepare_blocked_output_snapshot(boundary, model_response) + snapshot = _prepare_blocked_output_snapshot( + boundary, + model_response, + blocked_message, + ) except Exception: snapshot = None except BaseException: @@ -820,6 +877,7 @@ def _retained_items_for_blocked_response( streamed_result, prefixes, cleanup_plan, + blocked_message, ) _apply_blocked_output_owner_plan(owner_plan) except Exception as error: diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 6125e56d8f..ba0c02f351 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -114,6 +114,7 @@ _final_turn_items_for_persistence, _has_output_guardrails, _is_terminal_tool_output_response, + _resolve_output_guardrail_blocked_message, _retained_items_for_blocked_response, _sanitize_blocked_output_guardrail_results, _should_defer_interrupted_session_items, @@ -530,13 +531,30 @@ async def _finalize_streamed_final_output( *streamed_result.output_guardrail_results[:output_guardrail_result_start], *sanitized_results, ] + blocked_message = _resolve_output_guardrail_blocked_message( + exc, + agent=agent, + run_config=run_config, + context_wrapper=context_wrapper, + ) + if blocked_message != OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT: + sanitized_results = _sanitize_blocked_output_guardrail_results( + sanitized_results, + exc, + blocked_message, + ) + streamed_result.output_guardrail_results = [ + *streamed_result.output_guardrail_results[:output_guardrail_result_start], + *sanitized_results, + ] retained_items = _retained_items_for_blocked_response( items, model_response, streamed_result._state, processed_response, - streamed_result, - owner_starts, + streamed_result=streamed_result, + owner_starts=owner_starts, + blocked_message=blocked_message, ) if retained_items: try: diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index e5c2a03fd8..a38424a8b3 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -35,6 +35,7 @@ OpenAIChatCompletionsModel, OpenAIResponsesWSModel, OutputGuardrail, + OutputGuardrailBlockedMessageArgs, OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, @@ -48,7 +49,13 @@ handoff, retry_policies, ) -from agents.items import RunItem, ToolApprovalItem, TResponseInputItem, TResponseStreamEvent +from agents.items import ( + RunItem, + ToolApprovalItem, + ToolCallOutputItem, + TResponseInputItem, + TResponseStreamEvent, +) from agents.memory.openai_conversations_session import OpenAIConversationsSession from agents.models.interface import Model from agents.run import RunConfig @@ -2904,6 +2911,275 @@ async def clear_session(self) -> None: assert "committed-result" not in json.dumps(model_input) +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("customizer_kind", ["fixed", "sync"]) +@pytest.mark.asyncio +async def test_terminal_tool_trip_uses_custom_blocked_message_everywhere( + mode: str, + customizer_kind: str, +) -> None: + blocked_message = "出力ガードレールにより非表示になりました。" + formatter_args: list[OutputGuardrailBlockedMessageArgs[dict[str, str]]] = [] + context = {"locale": "ja"} + + def sync_formatter( + args: OutputGuardrailBlockedMessageArgs[dict[str, str]], + ) -> str: + formatter_args.append(args) + return blocked_message + + customizer: Any + if customizer_kind == "fixed": + customizer = blocked_message + else: + customizer = sync_formatter + + @tool_output_guardrail + def retain_tool_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.allow(output_info=data.output) + + @function_tool( + name_override="secret_tool", + tool_output_guardrails=[retain_tool_output], + ) + def secret_tool() -> str: + return "blocked-secret" + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info={"secret": "blocked-output-info"}, + tripwire_triggered=True, + ) + + model = ScriptedModel([[get_function_tool_call("secret_tool", "{}", call_id="call-secret")]]) + guardrail = OutputGuardrail( + guardrail_function=reject_output, + name="localized_guardrail", + ) + agent = Agent( + name="test", + model=model, + tools=[secret_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[guardrail], + ) + session = SimpleListSession() + run_config = RunConfig(output_guardrail_blocked_message=customizer) + streamed_result = None + + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: + if mode == "non_streamed": + await Runner.run( + agent, + "run", + context=context, + session=session, + run_config=run_config, + ) + else: + streamed_result = Runner.run_streamed( + agent, + "run", + context=context, + session=session, + run_config=run_config, + ) + await consume_stream(streamed_result) + + assert exc_info.value.guardrail_result.agent_output == blocked_message + assert exc_info.value.guardrail_result.output.output_info is None + saved_items = await session.get_items() + assert cast(dict[str, Any], saved_items[-1])["output"] == blocked_message + assert "blocked-secret" not in json.dumps(saved_items) + assert "blocked-output-info" not in json.dumps(saved_items) + + if customizer_kind == "fixed": + assert formatter_args == [] + else: + assert len(formatter_args) == 1 + args = formatter_args[0] + assert vars(args).keys() == { + "default_message", + "guardrail_name", + "agent", + "run_context", + } + assert args.default_message == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert args.guardrail_name == "localized_guardrail" + assert args.agent is agent + assert args.run_context.context is context + + if streamed_result is not None: + streamed_outputs = [ + item for item in streamed_result.new_items if isinstance(item, ToolCallOutputItem) + ] + assert [item.output for item in streamed_outputs] == [blocked_message] + assert ( + streamed_result.tool_output_guardrail_results[0].output.output_info == blocked_message + ) + serialized_state = streamed_result.to_state().to_json() + assert blocked_message in json.dumps(serialized_state, ensure_ascii=False) + assert "blocked-secret" not in json.dumps(serialized_state) + assert "blocked-output-info" not in json.dumps(serialized_state) + + agent.output_guardrails = [] + model.enqueue([get_text_message("done")]) + if mode == "non_streamed": + followup: Any = await Runner.run( + agent, + "continue", + context=context, + session=session, + run_config=run_config, + ) + else: + followup = Runner.run_streamed( + agent, + "continue", + context=context, + session=session, + run_config=run_config, + ) + await consume_stream(followup) + assert followup.final_output == "done" + assert blocked_message in json.dumps(model.calls[-1].input, ensure_ascii=False) + + +@pytest.mark.parametrize( + "formatter_outcome", + [ + "none", + "empty", + "non_string", + "string_subclass", + "awaitable", + "awaitable_close_error", + "error", + "cancel", + ], +) +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.asyncio +async def test_terminal_tool_trip_falls_back_when_blocked_message_formatter_fails( + formatter_outcome: str, + mode: str, +) -> None: + formatter_calls = 0 + + class FormatterStringSubclass(str): + def __len__(self) -> int: + raise RuntimeError("formatter-subclass-secret") + + def __str__(self) -> str: + raise RuntimeError("formatter-subclass-secret") + + def formatter(_args: OutputGuardrailBlockedMessageArgs[Any]) -> Any: + nonlocal formatter_calls + formatter_calls += 1 + if formatter_outcome == "none": + return None + if formatter_outcome == "empty": + return "" + if formatter_outcome == "non_string": + return 123 + if formatter_outcome == "string_subclass": + return FormatterStringSubclass("formatter-subclass-secret") + if formatter_outcome == "awaitable": + + async def async_value() -> str: + return "formatter-awaitable-secret" + + return async_value() + if formatter_outcome == "awaitable_close_error": + + async def async_value_with_failing_close() -> str: + try: + await asyncio.sleep(0) + finally: + raise RuntimeError("formatter-close-secret") + + coroutine = async_value_with_failing_close() + coroutine.send(None) + return coroutine + if formatter_outcome == "error": + raise RuntimeError("formatter-secret") + raise asyncio.CancelledError("formatter-cancel-secret") + + @function_tool(name_override="secret_tool") + def secret_tool() -> str: + return "blocked-secret" + + def reject_output( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info="blocked-output-info", + tripwire_triggered=True, + ) + + model = ScriptedModel([[get_function_tool_call("secret_tool", "{}", call_id="call-secret")]]) + agent = Agent( + name="test", + model=model, + tools=[secret_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[OutputGuardrail(guardrail_function=reject_output)], + ) + session = SimpleListSession() + streamed_result = None + + with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info: + if mode == "non_streamed": + await Runner.run( + agent, + "run", + session=session, + run_config=RunConfig(output_guardrail_blocked_message=formatter), + ) + else: + streamed_result = Runner.run_streamed( + agent, + "run", + session=session, + run_config=RunConfig(output_guardrail_blocked_message=formatter), + ) + await consume_stream(streamed_result) + + default_message = run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT + assert formatter_calls == 1 + assert exc_info.value.guardrail_result.agent_output == default_message + assert exc_info.value.guardrail_result.output.output_info is None + saved_items = await session.get_items() + assert cast(dict[str, Any], saved_items[-1])["output"] == default_message + serialized = json.dumps(saved_items) + assert "blocked-secret" not in serialized + assert "blocked-output-info" not in serialized + assert "formatter-secret" not in serialized + assert "formatter-cancel-secret" not in serialized + assert "formatter-subclass-secret" not in serialized + assert "formatter-awaitable-secret" not in serialized + assert "formatter-close-secret" not in serialized + + if streamed_result is not None: + streamed_views = [ + repr(streamed_result.new_items), + repr(streamed_result._model_input_items), + repr(streamed_result.raw_responses), + repr(streamed_result.tool_output_guardrail_results), + json.dumps(streamed_result.to_state().to_json()), + ] + assert all("blocked-secret" not in view for view in streamed_views) + assert all("blocked-output-info" not in view for view in streamed_views) + assert all("formatter-close-secret" not in view for view in streamed_views) + assert default_message in streamed_views[-1] + + @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.asyncio async def test_blocked_message_final_output_is_not_persisted(mode: str) -> None: diff --git a/tests/test_run_config.py b/tests/test_run_config.py index 8c88046af5..d1e0ed2e98 100644 --- a/tests/test_run_config.py +++ b/tests/test_run_config.py @@ -7,6 +7,7 @@ from agents import ( Agent, + OutputGuardrailBlockedMessageArgs, RunConfig, Runner, SessionSettings, @@ -98,6 +99,43 @@ def test_run_config_preserves_typed_configuration_instances() -> None: assert config.session_settings is session_settings +def test_run_config_accepts_output_guardrail_blocked_message_customizers() -> None: + def formatter(_args: OutputGuardrailBlockedMessageArgs[Any]) -> str: + return "custom" + + assert RunConfig( + output_guardrail_blocked_message="custom" + ).output_guardrail_blocked_message == ("custom") + assert RunConfig( + output_guardrail_blocked_message=formatter + ).output_guardrail_blocked_message is (formatter) + + +def test_run_config_rejects_async_output_guardrail_blocked_message_formatter() -> None: + async def formatter(_args: OutputGuardrailBlockedMessageArgs[Any]) -> str: + return "custom" + + with pytest.raises( + TypeError, + match="output_guardrail_blocked_message formatter must be synchronous", + ): + RunConfig(output_guardrail_blocked_message=cast(Any, formatter)) + + +class _BlockedMessageStringSubclass(str): + def __len__(self) -> int: + raise RuntimeError("string-subclass-hook") + + +@pytest.mark.parametrize("value", ["", 123, _BlockedMessageStringSubclass("custom")]) +def test_run_config_rejects_invalid_output_guardrail_blocked_message(value: object) -> None: + with pytest.raises( + (TypeError, ValueError), + match="output_guardrail_blocked_message", + ): + RunConfig(output_guardrail_blocked_message=cast(Any, value)) + + def test_run_config_rejects_untrusted_manifest_path_grants() -> None: with pytest.raises( TypeError, From 233467994fac7e7dbd868931573cc9a4302c0a16 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 23 Aug 2026 12:39:56 +0900 Subject: [PATCH 403/473] fix: enforce public type alias contract coverage (#4595) --- .../scripts/update_released_api_contract.py | 4 +- integration_tests/_contract_support.py | 329 +++++++++- .../released_api_contract_policy.json | 4 + tests/test_released_api_contract.py | 607 +++++++++++++++++- 4 files changed, 934 insertions(+), 10 deletions(-) diff --git a/.github/scripts/update_released_api_contract.py b/.github/scripts/update_released_api_contract.py index 4c71ac9849..2a67e8b9b8 100644 --- a/.github/scripts/update_released_api_contract.py +++ b/.github/scripts/update_released_api_contract.py @@ -126,8 +126,8 @@ def main() -> int: print(f"Removed exports: {sorted(previous_exports - current_exports)!r}") print( "Review shipped example imports and update released_api_contract_policy.json when " - "the release adds canonical imports, public properties, public TypedDict fields, or " - "public modules." + "the release adds canonical imports, public properties, public type aliases, public " + "TypedDict fields, or public modules." ) return 0 diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 241b4a972c..e4058ee2da 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import dataclasses import enum import importlib @@ -8,12 +9,23 @@ import logging import sys import traceback +import typing from collections.abc import Callable, Iterable, Mapping from copy import deepcopy from importlib.util import find_spec from pathlib import Path -from types import FunctionType, TracebackType, UnionType -from typing import Any, ForwardRef, Literal, Union, cast, get_args, get_origin, get_type_hints +from types import FunctionType, ModuleType, TracebackType, UnionType +from typing import ( + Any, + ForwardRef, + Literal, + TypeAlias, + Union, + cast, + get_args, + get_origin, + get_type_hints, +) import typing_extensions from pydantic import BaseModel @@ -888,7 +900,261 @@ def _sorted_type_alias_members(members: Iterable[dict[str, object]]) -> list[dic ) -def _type_alias_definition(value: object) -> dict[str, object]: +def _is_type_alias_type(value: object) -> bool: + native_type_alias_type = getattr(typing, "TypeAliasType", typing_extensions.TypeAliasType) + return isinstance(value, typing_extensions.TypeAliasType | native_type_alias_type) + + +def _is_type_alias_annotation(annotation: object, module: object) -> bool: + if annotation is TypeAlias or annotation is typing_extensions.TypeAlias: + return True + if not isinstance(annotation, str): + return False + reference_parts = annotation.split(".") + if not reference_parts or not all(part.isidentifier() for part in reference_parts): + return False + missing = object() + resolved = getattr(module, reference_parts[0], missing) + for part in reference_parts[1:]: + if resolved is missing: + break + resolved = getattr(resolved, part, missing) + return resolved is TypeAlias or resolved is typing_extensions.TypeAlias + + +def _module_declares_type_alias(module: object, alias_name: str, value: object) -> bool: + annotations = getattr(module, "__annotations__", {}) + if not isinstance(annotations, Mapping) or alias_name not in annotations: + return False + missing = object() + return ( + _is_type_alias_annotation(annotations[alias_name], module) + and getattr(module, alias_name, missing) is value + ) + + +class _ModuleBindingVisitor(ast.NodeVisitor): + def __init__(self, name: str): + self.name = name + self.count = 0 + self.has_wildcard_import = False + self.from_imports: list[tuple[ast.ImportFrom, str]] = [] + self._bindings_target_module = True + + def _count(self, name: str | None) -> None: + if self._bindings_target_module: + self.count += name == self.name + + def _visit_nested_scope(self, body: list[ast.stmt]) -> None: + bindings_target_module = _scope_declares_global(body, self.name) + previous_bindings_target_module = self._bindings_target_module + self._bindings_target_module = bindings_target_module + for statement in body: + self.visit(statement) + self._bindings_target_module = previous_bindings_target_module + + def _visit_arguments(self, arguments: ast.arguments) -> None: + all_arguments = [ + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + ] + if arguments.vararg is not None: + all_arguments.append(arguments.vararg) + if arguments.kwarg is not None: + all_arguments.append(arguments.kwarg) + for argument in all_arguments: + if argument.annotation is not None: + self.visit(argument.annotation) + for default in [*arguments.defaults, *arguments.kw_defaults]: + if default is not None: + self.visit(default) + + def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + self._count(node.name) + for decorator in node.decorator_list: + self.visit(decorator) + self._visit_arguments(node.args) + if node.returns is not None: + self.visit(node.returns) + + def _visit_comprehension( + self, generators: list[ast.comprehension], values: list[ast.expr] + ) -> None: + for generator in generators: + self.visit(generator.iter) + for condition in generator.ifs: + self.visit(condition) + for value in values: + self.visit(value) + + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, ast.Store | ast.Del): + self._count(node.id) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function_definition(node) + self._visit_nested_scope(node.body) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function_definition(node) + self._visit_nested_scope(node.body) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._count(node.name) + for decorator in node.decorator_list: + self.visit(decorator) + for base in node.bases: + self.visit(base) + for keyword in node.keywords: + self.visit(keyword.value) + self._visit_nested_scope(node.body) + + def visit_Lambda(self, node: ast.Lambda) -> None: + self._visit_arguments(node.args) + + def visit_ListComp(self, node: ast.ListComp) -> None: + self._visit_comprehension(node.generators, [node.elt]) + + def visit_SetComp(self, node: ast.SetComp) -> None: + self._visit_comprehension(node.generators, [node.elt]) + + def visit_DictComp(self, node: ast.DictComp) -> None: + self._visit_comprehension(node.generators, [node.key, node.value]) + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: + self._visit_comprehension(node.generators, [node.elt]) + + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: + self._count(node.name) + self.generic_visit(node) + + def visit_MatchAs(self, node: ast.MatchAs) -> None: + self._count(node.name) + self.generic_visit(node) + + def visit_MatchStar(self, node: ast.MatchStar) -> None: + self._count(node.name) + + def visit_MatchMapping(self, node: ast.MatchMapping) -> None: + self._count(node.rest) + self.generic_visit(node) + + def visit_Import(self, node: ast.Import) -> None: + for imported in node.names: + self._count(imported.asname or imported.name.split(".", 1)[0]) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if not self._bindings_target_module: + return + for imported in node.names: + if imported.name == "*": + self.has_wildcard_import = True + continue + binding_name = imported.asname or imported.name + self._count(binding_name) + if binding_name == self.name: + self.from_imports.append((node, imported.name)) + + +def _scope_declares_global(nodes: Iterable[ast.AST], name: str) -> bool: + for node in nodes: + if isinstance(node, ast.Global): + if name in node.names: + return True + continue + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): + continue + if _scope_declares_global(ast.iter_child_nodes(node), name): + return True + return False + + +def _direct_import_source( + module: object, export_name: str, *, package_root: str +) -> tuple[ModuleType, str] | None: + module_name = getattr(module, "__name__", None) + package_name = getattr(module, "__package__", None) + if not isinstance(module_name, str) or not isinstance(package_name, str): + return None + try: + module_tree = ast.parse(inspect.getsource(module)) + except (OSError, SyntaxError, TypeError): + return None + + bindings = _ModuleBindingVisitor(export_name) + bindings.visit(module_tree) + if bindings.count != 1 or bindings.has_wildcard_import or len(bindings.from_imports) != 1: + return None + statement, source_name = bindings.from_imports[0] + if statement.level: + relative_name = "." * statement.level + (statement.module or "") + try: + source_module_name = importlib.util.resolve_name(relative_name, package_name) + except ImportError: + return None + else: + source_module_name = statement.module + if source_module_name is None or not ( + source_module_name == package_root or source_module_name.startswith(f"{package_root}.") + ): + return None + source_module = sys.modules.get(source_module_name) + if not isinstance(source_module, ModuleType): + return None + return source_module, source_name + + +def _has_explicit_type_alias_declaration( + agents_module: object, export_name: str, value: object +) -> bool: + package_root = getattr(agents_module, "__name__", None) + module, alias_name = agents_module, export_name + visited_bindings: set[tuple[int, str]] = set() + missing = object() + while (id(module), alias_name) not in visited_bindings: + visited_bindings.add((id(module), alias_name)) + if getattr(module, alias_name, missing) is not value: + return False + if _module_declares_type_alias(module, alias_name, value): + return True + if not isinstance(package_root, str): + return False + import_source = _direct_import_source(module, alias_name, package_root=package_root) + if import_source is None: + return False + module, alias_name = import_source + return False + + +def _is_public_type_alias(agents_module: object, export_name: str, value: object) -> bool: + return ( + get_origin(value) is not None + or _is_type_alias_type(value) + or _has_explicit_type_alias_declaration(agents_module, export_name, value) + ) + + +def _type_alias_definition( + value: object, *, visited_alias_ids: frozenset[int] = frozenset() +) -> dict[str, object]: + if value is Any: + return {"kind": "any"} + if _is_type_alias_type(value): + if value.__type_params__: + raise TypeError(f"generic public type alias is unsupported: {value.__name__}") + alias_id = id(value) + if alias_id in visited_alias_ids: + alias_name = getattr(value, "__name__", repr(value)) + raise TypeError(f"recursive public type alias is unsupported: {alias_name}") + try: + alias_value = value.__value__ + except Exception as error: + raise TypeError( + f"cannot resolve public type alias {value.__name__} at runtime: " + f"{type(error).__name__}: {error}" + ) from None + return _type_alias_definition(alias_value, visited_alias_ids=visited_alias_ids | {alias_id}) origin = get_origin(value) if origin is Literal: literal_values: list[dict[str, object]] = [] @@ -904,13 +1170,48 @@ def _type_alias_definition(value: object) -> dict[str, object]: "values": _sorted_type_alias_members(literal_values), } if origin in {Union, UnionType}: - members = [_type_alias_definition(member) for member in get_args(value)] + members = [ + _type_alias_definition(member, visited_alias_ids=visited_alias_ids) + for member in get_args(value) + ] return { "kind": "union", "members": _sorted_type_alias_members(members), } + if origin is Callable: + callable_args = get_args(value) + if len(callable_args) != 2: + raise TypeError( + "public Callable type aliases must declare parameters and a return type" + ) + parameter_types, return_type = callable_args + if parameter_types is Ellipsis or not isinstance(parameter_types, list | tuple): + raise TypeError("public Callable type aliases must declare explicit parameter types") + return { + "kind": "callable", + "parameters": [ + _type_alias_definition(parameter_type, visited_alias_ids=visited_alias_ids) + for parameter_type in parameter_types + ], + "return": _type_alias_definition(return_type, visited_alias_ids=visited_alias_ids), + } + if origin is not None: + if not isinstance(origin, type) or not ( + origin.__module__ == "agents" or origin.__module__.startswith("agents.") + ): + raise TypeError(f"unsupported public generic type alias origin: {origin!r}") + return { + "kind": "generic", + "origin": f"{origin.__module__}.{origin.__qualname__}", + "arguments": [ + _type_alias_definition(argument, visited_alias_ids=visited_alias_ids) + for argument in get_args(value) + ], + } if isinstance(value, type) and ( - value.__module__ == "agents" or value.__module__.startswith("agents.") + value.__module__ == "builtins" + or value.__module__ == "agents" + or value.__module__.startswith("agents.") ): return { "kind": "type", @@ -1210,6 +1511,24 @@ def build_released_api_contract( released_export_order = list(contract["required_top_level_exports"]) released_exports = set(released_export_order) current_export_names = set(current_exports) + if release_policy is not None: + promoted_top_level_type_aliases = { + entry["name"] + for entry in release_policy.public_type_aliases + if entry["module"] == "agents" + } + missing_top_level_type_aliases = sorted( + name + for name in current_export_names - released_exports + if _is_public_type_alias(agents, name, getattr(agents, name)) + and name not in promoted_top_level_type_aliases + ) + if missing_top_level_type_aliases: + raise ValueError( + "Cannot promote new top-level type aliases without public_type_aliases policy " + "entries for module 'agents': " + f"{missing_top_level_type_aliases!r}" + ) ordered_exports = [name for name in released_export_order if name in current_export_names] ordered_exports.extend(name for name in current_exports if name not in released_exports) tracked_callables = set(contract["callables"]) diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index bdd3e007b0..8053fe0c97 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -775,6 +775,10 @@ { "module": "agents.voice.events", "name": "VoiceStreamEvent" + }, + { + "module": "agents", + "name": "OutputGuardrailBlockedMessageFormatter" } ], "public_typed_dicts": [ diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 843adfeeb8..8d3657c7b6 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -1,4 +1,5 @@ import abc +import ast import builtins import importlib import inspect @@ -11,12 +12,13 @@ from importlib.metadata import version from inspect import Parameter, Signature from pathlib import Path -from types import SimpleNamespace -from typing import Any, Literal, cast +from textwrap import indent +from types import ModuleType, SimpleNamespace +from typing import Any, Literal, TypeAlias, TypeVar, cast import pytest from pydantic import BaseModel, Field -from typing_extensions import Required, TypedDict +from typing_extensions import Required, TypeAliasType, TypedDict import integration_tests._contract_support as contract_support from integration_tests._contract_support import ( @@ -596,6 +598,583 @@ class EventB: ) +def test_new_top_level_type_alias_requires_explicit_policy() -> None: + existing_alias = Literal["existing"] + new_alias = Callable[[str], str | None] + agents_module = SimpleNamespace( + __all__=["ExistingAlias", "NewAlias"], + ExistingAlias=existing_alias, + NewAlias=new_alias, + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["ExistingAlias"], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy({}), + ) + + assert str(exc_info.value) == ( + "Cannot promote new top-level type aliases without public_type_aliases policy entries " + "for module 'agents': ['NewAlias']" + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {}, + public_type_aliases=({"module": "agents", "name": "NewAlias"},), + ), + ) + + assert updated["public_type_aliases"] == [ + { + "definition": { + "kind": "callable", + "parameters": [{"identity": "builtins.str", "kind": "type"}], + "return": { + "kind": "union", + "members": [ + {"identity": "builtins.NoneType", "kind": "type"}, + {"identity": "builtins.str", "kind": "type"}, + ], + }, + }, + "module": "agents", + "name": "NewAlias", + } + ] + + agents_module.NewAlias = Callable[[bytes], str | None] + errors = _validate_public_type_alias_contract(updated, agents_module) + assert len(errors) == 1 + assert errors[0].startswith("agents.NewAlias changed its released public type alias") + + +def test_new_originless_explicit_type_alias_requires_policy() -> None: + class ExistingClass: + pass + + class NewClass: + pass + + agents_module = ModuleType("synthetic_agents") + agents_module.TypeAlias = TypeAlias + agents_module.__annotations__ = {"NewAlias": "TypeAlias"} + agents_module.__all__ = [ + "ExistingClass", + "NewAlias", + "NewClass", + "UnannotatedBinding", + ] + agents_module.ExistingClass = ExistingClass + agents_module.NewAlias = str + agents_module.NewClass = NewClass + agents_module.UnannotatedBinding = str + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["ExistingClass"], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy({}), + ) + + assert str(exc_info.value) == ( + "Cannot promote new top-level type aliases without public_type_aliases policy entries " + "for module 'agents': ['NewAlias']" + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {}, + public_type_aliases=({"module": "agents", "name": "NewAlias"},), + ), + ) + assert updated["public_type_aliases"] == [ + { + "definition": {"identity": "builtins.str", "kind": "type"}, + "module": "agents", + "name": "NewAlias", + } + ] + + agents_module.NewAlias = bytes + errors = _validate_public_type_alias_contract(updated, agents_module) + assert len(errors) == 1 + assert errors[0].startswith("agents.NewAlias changed its released public type alias") + + +@pytest.mark.parametrize( + ("facade_count", "control_flow"), [(0, False), (1, False), (3, False), (1, True)] +) +def test_new_renamed_originless_type_alias_reexport_requires_policy( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, facade_count: int, control_flow: bool +) -> None: + package_name = f"contract_alias_reexport_package_{facade_count}_{control_flow}" + package_dir = tmp_path / package_name + package_dir.mkdir() + (package_dir / "aliases.py").write_text( + "from typing import TypeAlias\nInternalAlias: TypeAlias = str\n", + encoding="utf-8", + ) + import_module, import_name = "aliases", "InternalAlias" + for index in range(facade_count): + facade_module, facade_name = f"facade{index}", f"ForwardedAlias{index}" + facade_import = f"from .{import_module} import {import_name} as {facade_name}\n" + if control_flow: + facade_import = ( + "try:\n" + indent(facade_import, " ") + "except ImportError:\n raise\n" + ) + (package_dir / f"{facade_module}.py").write_text( + facade_import, + encoding="utf-8", + ) + import_module, import_name = facade_module, facade_name + export_import = ( + f"from .{import_module} import (\n" + f" {import_name} as PublicAlias,\n" + f" {import_name} as ShadowedAlias,\n" + f" {import_name} as WalrusShadowedAlias,\n" + f" {import_name} as ControlFlowShadowedAlias,\n" + f" {import_name} as FunctionGlobalShadowedAlias,\n" + f" {import_name} as ClassGlobalShadowedAlias,\n" + ")\n" + ) + if control_flow: + export_import = "import sys\nif sys.version_info >= (3, 10):\n" + indent( + export_import, " " + ) + (package_dir / "__init__.py").write_text( + export_import + "ShadowedAlias = str\n" + "UnrelatedBinding = (WalrusShadowedAlias := str)\n" + "if True:\n" + " ControlFlowShadowedAlias = str\n" + "def capture(PublicAlias):\n" + " from .aliases import InternalAlias as PublicAlias\n" + " PublicAlias = str\n" + " match PublicAlias:\n" + " case {**PublicAlias}:\n" + " return PublicAlias\n" + "class Container:\n" + " from .aliases import InternalAlias as PublicAlias\n" + " PublicAlias = str\n" + "def mutate_global():\n" + " global FunctionGlobalShadowedAlias\n" + " FunctionGlobalShadowedAlias = str\n" + "class GlobalMutator:\n" + " global ClassGlobalShadowedAlias\n" + " ClassGlobalShadowedAlias = str\n" + "UnannotatedBinding = str\n" + "__all__ = [\n" + ' "PublicAlias",\n' + ' "ShadowedAlias",\n' + ' "WalrusShadowedAlias",\n' + ' "ControlFlowShadowedAlias",\n' + ' "FunctionGlobalShadowedAlias",\n' + ' "ClassGlobalShadowedAlias",\n' + ' "UnannotatedBinding",\n' + "]\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + agents_module = importlib.import_module(package_name) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy({}), + ) + + assert str(exc_info.value) == ( + "Cannot promote new top-level type aliases without public_type_aliases policy entries " + "for module 'agents': ['PublicAlias']" + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {}, + public_type_aliases=({"module": "agents", "name": "PublicAlias"},), + ), + ) + assert updated["public_type_aliases"] == [ + { + "definition": {"identity": "builtins.str", "kind": "type"}, + "module": "agents", + "name": "PublicAlias", + } + ] + + agents_module.PublicAlias = bytes + errors = _validate_public_type_alias_contract(updated, agents_module) + assert len(errors) == 1 + assert errors[0].startswith("agents.PublicAlias changed its released public type alias") + + +@pytest.mark.parametrize( + "invalid_edge", ["rebound", "mismatched_value", "outside_package", "cycle"] +) +def test_originless_alias_facade_requires_unambiguous_package_provenance( + monkeypatch: pytest.MonkeyPatch, invalid_edge: str +) -> None: + package_name = "contract_alias_chain_package" + agents_module = ModuleType(package_name) + facade = ModuleType(f"{package_name}.facade") + definitions = ModuleType(f"{package_name}.definitions") + external = ModuleType(f"{package_name}_external") + agents_module.__all__ = ["PublicAlias"] + agents_module.PublicAlias = str + facade.ForwardedAlias = str + definitions.InternalAlias = str + definitions.__annotations__ = {"InternalAlias": TypeAlias} + external.InternalAlias = str + external.__annotations__ = {"InternalAlias": TypeAlias} + sources = { + package_name: "from .facade import ForwardedAlias as PublicAlias\n", + facade.__name__: "from .definitions import InternalAlias as ForwardedAlias\n", + } + if invalid_edge == "rebound": + sources[facade.__name__] += "ForwardedAlias = str\n" + elif invalid_edge == "mismatched_value": + facade.ForwardedAlias = bytes + elif invalid_edge == "outside_package": + sources[facade.__name__] = ( + f"from {external.__name__} import InternalAlias as ForwardedAlias\n" + ) + else: + sources[facade.__name__] = "from . import PublicAlias as ForwardedAlias\n" + + # Use already-loaded modules to exercise cyclic provenance without executing circular imports. + for module in (agents_module, facade, definitions, external): + module.__package__ = package_name + monkeypatch.setitem(sys.modules, module.__name__, module) + monkeypatch.setattr( + contract_support.inspect, + "getsource", + lambda module: sources[module.__name__], + ) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy({}), + ) + + assert updated["required_top_level_exports"] == ["PublicAlias"] + assert updated["public_type_aliases"] == [] + + +def test_module_binding_visitor_ignores_type_parameter_bindings() -> None: + if not hasattr(ast, "TypeVar"): + pytest.skip("PEP 695 AST nodes require Python 3.12 or newer") + + module_tree = ast.parse( + "from .aliases import InternalAlias as PublicAlias\n" + "def capture[PublicAlias](value):\n" + " return value\n" + ) + + bindings = contract_support._ModuleBindingVisitor("PublicAlias") + bindings.visit(module_tree) + + assert bindings.count == 1 + assert not bindings.has_wildcard_import + assert len(bindings.from_imports) == 1 + assert bindings.from_imports[0][1] == "InternalAlias" + + +def test_new_type_alias_type_requires_policy() -> None: + new_alias = TypeAliasType("NewAlias", str) + agents_module = SimpleNamespace(__all__=["NewAlias"], NewAlias=new_alias) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + + with pytest.raises(ValueError, match="for module 'agents': \\['NewAlias'\\]"): + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy({}), + ) + + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {}, + public_type_aliases=({"module": "agents", "name": "NewAlias"},), + ), + ) + assert updated["public_type_aliases"][0]["definition"] == { + "identity": "builtins.str", + "kind": "type", + } + + +@pytest.mark.parametrize( + "declaration", + [ + "backport", + "type Public[T] = str", + "type Public[T, U] = str", + "type Public[T] = UndefinedType", + ], +) +def test_generic_type_alias_type_is_rejected_before_unwrapping(declaration: str) -> None: + if declaration == "backport": + alias = TypeAliasType("Public", str, type_params=(TypeVar("T"),)) + else: + if sys.version_info < (3, 12): + pytest.skip("PEP 695 requires Python 3.12+") + namespace: dict[str, Any] = {} + exec(declaration, namespace) + alias = namespace["Public"] + agents_module = SimpleNamespace(__all__=["Public"], Public=alias) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_type_aliases": [], + "callables": {}, + } + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {}, public_type_aliases=({"module": "agents", "name": "Public"},) + ), + ) + + assert str(exc_info.value) == ( + "Cannot promote public type alias agents.Public: " + "generic public type alias is unsupported: Public" + ) + + contract["public_type_aliases"] = [ + { + "module": "agents", + "name": "Public", + "definition": {"kind": "type", "identity": "builtins.str"}, + } + ] + assert _validate_public_type_alias_contract(contract, agents_module) == [ + "agents.Public no longer has a supported released public type alias definition: " + "generic public type alias is unsupported: Public" + ] + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+") +def test_unresolved_type_alias_is_validation_error() -> None: + namespace: dict[str, Any] = {} + exec( + "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " TypeOnlyName = str\n" + "type Public = TypeOnlyName\n", + namespace, + ) + agents_module = SimpleNamespace(__all__=["Public"], Public=namespace["Public"]) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_type_aliases": [], + "callables": {}, + } + policy = _release_policy({}, public_type_aliases=({"module": "agents", "name": "Public"},)) + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=policy, + ) + + assert str(exc_info.value) == ( + "Cannot promote public type alias agents.Public: " + "cannot resolve public type alias Public at runtime: " + "NameError: name 'TypeOnlyName' is not defined" + ) + contract["public_type_aliases"] = [ + { + "module": "agents", + "name": "Public", + "definition": {"kind": "type", "identity": "builtins.str"}, + } + ] + assert _validate_public_type_alias_contract(contract, agents_module) == [ + "agents.Public no longer has a supported released public type alias definition: " + "cannot resolve public type alias Public at runtime: " + "NameError: name 'TypeOnlyName' is not defined" + ] + + namespace["TypeOnlyName"] = str + contract["public_type_aliases"] = [] + updated = build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=policy, + ) + assert updated["public_type_aliases"][0]["definition"] == { + "kind": "type", + "identity": "builtins.str", + } + assert _validate_public_type_alias_contract(updated, agents_module) == [] + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+") +@pytest.mark.parametrize("error_type", [RuntimeError, KeyboardInterrupt, SystemExit]) +def test_lazy_type_alias_evaluation_exception_boundary(error_type: type[BaseException]) -> None: + class AliasTarget: + def __class_getitem__(cls, parameter: object) -> object: + raise error_type("alias evaluation failed") + + namespace: dict[str, Any] = {"AliasTarget": AliasTarget} + exec("type Public = AliasTarget[str]", namespace) + agents_module = SimpleNamespace(Public=namespace["Public"]) + policy_entries = ({"module": "agents", "name": "Public"},) + + if error_type is RuntimeError: + with pytest.raises(ValueError) as exc_info: + contract_support._public_type_alias_contract(policy_entries, agents_module) + assert str(exc_info.value) == ( + "Cannot promote public type alias agents.Public: " + "cannot resolve public type alias Public at runtime: " + "RuntimeError: alias evaluation failed" + ) + else: + with pytest.raises(error_type, match="alias evaluation failed"): + contract_support._public_type_alias_contract(policy_entries, agents_module) + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+") +def test_recursive_type_alias_type_is_rejected() -> None: + namespace: dict[str, Any] = {} + exec("type Recursive = Recursive | None", namespace) + agents_module = SimpleNamespace(__all__=["Recursive"], Recursive=namespace["Recursive"]) + contract: dict[str, Any] = { + "baseline": "v0.19.4", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.20.0", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy( + {}, + public_type_aliases=({"module": "agents", "name": "Recursive"},), + ), + ) + + assert str(exc_info.value) == ( + "Cannot promote public type alias agents.Recursive: " + "recursive public type alias is unsupported: Recursive" + ) + + def test_curated_public_typed_dict_contract_detects_field_shape_drift( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -3088,6 +3667,10 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: "module": "agents.voice.events", "name": "VoiceStreamEvent", }, + { + "module": "agents", + "name": "OutputGuardrailBlockedMessageFormatter", + }, ) type_aliases: dict[tuple[str, str], dict[str, Any]] = { (cast(str, entry["module"]), cast(str, entry["name"])): cast( @@ -3127,6 +3710,24 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: "agents.voice.events.VoiceStreamEventError", "agents.voice.events.VoiceStreamEventLifecycle", } + blocked_message_formatter = type_aliases[("agents", "OutputGuardrailBlockedMessageFormatter")] + assert blocked_message_formatter == { + "kind": "callable", + "parameters": [ + { + "arguments": [{"kind": "any"}], + "kind": "generic", + "origin": "agents.run_config.OutputGuardrailBlockedMessageArgs", + } + ], + "return": { + "kind": "union", + "members": [ + {"identity": "builtins.NoneType", "kind": "type"}, + {"identity": "builtins.str", "kind": "type"}, + ], + }, + } assert policy.public_typed_dicts == ( { "class_name": "ModelStepSpec", From f81c322563ffc217a35ff84184b1dd44b8e359e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=BF=97=E8=B0=A6?= <89645338+simpleqt@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:07:18 +0800 Subject: [PATCH 404/473] fix(core): strict_schema error message, REPL whitespace input, debug docstrings (#4600) --- src/agents/_debug.py | 6 +++--- src/agents/repl.py | 2 +- src/agents/strict_schema.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agents/_debug.py b/src/agents/_debug.py index 963c296b80..57e7d569ea 100644 --- a/src/agents/_debug.py +++ b/src/agents/_debug.py @@ -18,11 +18,11 @@ def _load_dont_log_tool_data() -> bool: DONT_LOG_MODEL_DATA = _load_dont_log_model_data() -"""By default we don't log LLM inputs/outputs, to prevent exposing sensitive information. Set this -flag to enable logging them. +"""By default we don't log LLM inputs/outputs, to prevent exposing sensitive information. Set the +`OPENAI_AGENTS_DONT_LOG_MODEL_DATA` environment variable to `0`/`false` to enable logging them. """ DONT_LOG_TOOL_DATA = _load_dont_log_tool_data() """By default we don't log tool call inputs/outputs, to prevent exposing sensitive information. Set -this flag to enable logging them. +the `OPENAI_AGENTS_DONT_LOG_TOOL_DATA` environment variable to `0`/`false` to enable logging them. """ diff --git a/src/agents/repl.py b/src/agents/repl.py index 6b493dd56b..fc53a4238c 100644 --- a/src/agents/repl.py +++ b/src/agents/repl.py @@ -43,7 +43,7 @@ async def run_demo_loop( break if user_input.strip().lower() in {"exit", "quit"}: break - if not user_input: + if not user_input.strip(): continue input_items.append({"role": "user", "content": user_input}) diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 15b4c652fd..1ddbea34de 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -379,7 +379,7 @@ def _ensure_strict_json_schema( resolved = resolve_ref(root=root, ref=ref) if not is_dict(resolved): raise ValueError( - f"Expected `$ref: {ref}` to resolved to a dictionary but got {resolved}" + f"Expected `$ref: {ref}` to resolve to a dictionary but got {resolved}" ) # Pop the current `$ref` first so that if the resolved schema is itself a `$ref` @@ -456,7 +456,7 @@ def _resolve_non_constraining_ref_chain( budget.spend() target = resolve_ref(root=root, ref=ref) if not is_dict(target): - raise ValueError(f"Expected `$ref: {ref}` to resolved to a dictionary but got {target}") + raise ValueError(f"Expected `$ref: {ref}` to resolve to a dictionary but got {target}") resolved = target return {**resolved, **carried_siblings} From 1a55d70d8e28769bd2c3eb85eaf6fe501864ced8 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Sun, 23 Aug 2026 17:07:32 -0500 Subject: [PATCH 405/473] fix(core): max_turns no longer clobbers a tripped input guardrail exception in streaming (#4606) --- src/agents/result.py | 1 + tests/test_stream_input_guardrail_timing.py | 96 ++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index 0979631200..f88819df54 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -1053,6 +1053,7 @@ def _check_errors(self): max_turns_exc = MaxTurnsExceeded(f"Max turns ({self.max_turns}) exceeded") max_turns_exc.run_data = self._create_error_details() self._stored_exception = max_turns_exc + self._max_turns_handled = True # Fetch all the completed guardrail results from the queue and raise if needed while not self._input_guardrail_queue.empty(): diff --git a/tests/test_stream_input_guardrail_timing.py b/tests/test_stream_input_guardrail_timing.py index 5d8bd676f7..0ed8cea262 100644 --- a/tests/test_stream_input_guardrail_timing.py +++ b/tests/test_stream_input_guardrail_timing.py @@ -1,17 +1,25 @@ from __future__ import annotations import asyncio +import json from datetime import datetime from typing import Any import pytest from openai.types.responses import ResponseCompletedEvent -from agents import Agent, GuardrailFunctionOutput, InputGuardrail, RunContextWrapper, Runner +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrail, + MaxTurnsExceeded, + RunContextWrapper, + Runner, +) from agents.exceptions import InputGuardrailTripwireTriggered from agents.items import TResponseInputItem from agents.testing import ScriptedModel -from tests.test_responses import get_text_message +from tests.test_responses import get_function_tool, get_function_tool_call, get_text_message from tests.testing_processor import fetch_events, fetch_ordered_spans FAST_GUARDRAIL_DELAY = 0.005 @@ -173,6 +181,90 @@ async def test_run_streamed_input_guardrail_tripwire_raises(guardrail_delay: flo ) +@pytest.mark.asyncio +async def test_max_turns_does_not_clobber_input_guardrail_tripwire(): + """A guardrail tripwire recorded before max_turns fires must win over MaxTurnsExceeded. + + Regression test: RunResultStreaming._check_errors() re-creates a fresh + MaxTurnsExceeded and overwrites self._stored_exception on *every* call once + current_turn > max_turns, because self._max_turns_handled is only ever set + True by the max_turns error-handler path -- never in the default (no + handler) path. stream_events() calls _check_errors() again unconditionally + in its `finally` block, so a guardrail trip that was already captured as + InputGuardrailTripwireTriggered got silently replaced with MaxTurnsExceeded + by that final call. Callers using the documented + `except InputGuardrailTripwireTriggered` pattern never saw the tripwire. + + This race must not be ordered with a real-time sleep: a fixed delay only + approximates "the guardrail finishes after max_turns is exceeded", and + under CI load, tracing overhead, or slower model instrumentation the + guardrail can instead finish *before* current_turn > max_turns is ever + reached, in which case the test would observe the correct exception even + against the unpatched (buggy) implementation and silently stop being a + regression test. Instead, an `error_handlers={"max_turns": ...}` hook + (returning None, so it falls through to the exact same default raise path + as if no handler were registered) sets an `asyncio.Event` at the precise + moment the run loop establishes current_turn > max_turns. The guardrail + awaits that event before returning its tripwire, so it can only ever + resolve *after* the max-turns condition genuinely holds. + """ + + max_turns_reached = asyncio.Event() + + async def tripping_guardrail( + ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + # Wait for the run loop to have actually established current_turn > + # max_turns, rather than guessing at a delay long enough to outlast it. + await max_turns_reached.wait() + return GuardrailFunctionOutput(output_info={"reason": "blocked"}, tripwire_triggered=True) + + model = ScriptedModel() + func_output = json.dumps({"a": "b"}) + model.extend( + [ + [ + get_text_message(str(i)), + get_function_tool_call("some_function", func_output, str(i)), + ] + for i in range(1, 10) + ] + ) + + agent = Agent( + name="MaxTurnsGuardrailAgent", + model=model, + tools=[get_function_tool("some_function", "result")], + # run_in_parallel defaults to True -- this is the default configuration, + # not an opt-in one. + input_guardrails=[InputGuardrail(guardrail_function=tripping_guardrail, name="trip")], + ) + + result = Runner.run_streamed( + agent, + input="user_message", + max_turns=1, + # Declining (returning None) preserves the exact default max_turns + # behavior; the handler exists purely to signal, deterministically, + # the moment current_turn > max_turns is established. + error_handlers={"max_turns": lambda data: max_turns_reached.set()}, + ) + + raised: BaseException | None = None + try: + async for _ in result.stream_events(): + pass + except BaseException as exc: # noqa: BLE001 - we need to inspect the exact type raised + raised = exc + + assert isinstance(raised, InputGuardrailTripwireTriggered), ( + f"Expected InputGuardrailTripwireTriggered, got " + f"{type(raised).__name__ if raised else None}. The tripped guardrail " + "result was silently clobbered by a freshly-minted MaxTurnsExceeded." + ) + assert not isinstance(raised, MaxTurnsExceeded) + + class SlowCompleteScriptedModel(ScriptedModel): """A ScriptedModel that delays just before emitting ResponseCompletedEvent in streaming.""" From 72b2c670546942bdaaf66cc8d6b3a67d1a2fe5bc Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 23 Aug 2026 17:14:40 -0500 Subject: [PATCH 406/473] fix(sandbox): finish dependency cleanup on cancellation (#4607) --- src/agents/sandbox/session/dependencies.py | 10 +++++- tests/sandbox/test_dependencies.py | 37 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/session/dependencies.py b/src/agents/sandbox/session/dependencies.py index 1a3f1fd40d..10a54ecbe9 100644 --- a/src/agents/sandbox/session/dependencies.py +++ b/src/agents/sandbox/session/dependencies.py @@ -268,14 +268,22 @@ async def _close(self) -> None: await asyncio.gather(*active_tasks, return_exceptions=True) seen_ids: set[int] = set() + cancellation: asyncio.CancelledError | None = None for value in reversed(self._owned_results): value_id = id(value) if value_id in seen_ids: continue seen_ids.add(value_id) - await _close_best_effort(value) + try: + await _close_best_effort(value) + except asyncio.CancelledError as exc: + if cancellation is None: + cancellation = exc self._pending.clear() self._active_tasks.clear() self._cache.clear() self._owned_results.clear() + + if cancellation is not None: + raise cancellation diff --git a/tests/sandbox/test_dependencies.py b/tests/sandbox/test_dependencies.py index b0d37a94b0..cbb2274185 100644 --- a/tests/sandbox/test_dependencies.py +++ b/tests/sandbox/test_dependencies.py @@ -44,6 +44,15 @@ async def close(self) -> None: self.calls += 1 +class _CancellingAsyncClosable: + def __init__(self) -> None: + self.calls = 0 + + async def aclose(self) -> None: + self.calls += 1 + raise asyncio.CancelledError("dependency close cancelled") + + class _SyncClosable: def __init__(self) -> None: self.calls = 0 @@ -435,6 +444,34 @@ async def test_dependencies_aclose_continues_after_waiter_cancellation() -> None assert value.completed +@pytest.mark.asyncio +async def test_dependencies_aclose_finishes_owned_cleanup_before_propagating_cancellation() -> None: + dependencies = Dependencies() + earlier = _AsyncClosable() + cancelling = _CancellingAsyncClosable() + dependencies.bind_factory( + "tests.earlier_owned", lambda _dependencies: earlier, owns_result=True + ) + dependencies.bind_factory( + "tests.cancelling_owned", lambda _dependencies: cancelling, owns_result=True + ) + + _ = await dependencies.require("tests.earlier_owned") + _ = await dependencies.require("tests.cancelling_owned") + + with pytest.raises(asyncio.CancelledError): + await dependencies.aclose() + + assert cancelling.calls == 1 + assert earlier.calls == 1 + + with pytest.raises(asyncio.CancelledError): + await dependencies.aclose() + + assert cancelling.calls == 1 + assert earlier.calls == 1 + + @pytest.mark.asyncio async def test_dependencies_bound_values_are_not_closed() -> None: dependencies = Dependencies() From b354ef0aba8850dd9a93c69b2db25932df1ade59 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 24 Aug 2026 11:14:35 +0900 Subject: [PATCH 407/473] fix(core): preserve serialized approval resume ownership (#4613) --- src/agents/run_state.py | 178 ++++++++++++- tests/test_agent_runner_streamed.py | 389 +++++++++++++++++++++++++++- 2 files changed, 557 insertions(+), 10 deletions(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index b3a0af49b4..e00a73bdce 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -182,6 +182,7 @@ def _default_run_state_validation_error( CURRENT_SCHEMA_VERSION = "1.17" _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13" _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14" +_CURRENT_RESPONSE_OWNERSHIP_MIN_SCHEMA_VERSION = "1.17" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. SCHEMA_VERSION_SUMMARIES: dict[str, str] = { "1.0": "Initial RunState snapshot format for HITL pause/resume flows.", @@ -213,7 +214,10 @@ def _default_run_state_validation_error( "Persists Docker network-isolation state and lets an exact call approval decision " "override a sticky decision for the same tool." ), - "1.17": "Persists Docker container labels across sandbox resume and replacement.", + "1.17": ( + "Persists Docker container labels and current-response generated-item ownership across " + "resume flows." + ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -1457,6 +1461,46 @@ def _take_unused(candidates: deque[int] | None) -> int | None: indexes.append(session_index) return indexes + def _current_response_generated_item_ownership( + self, + generated_items: Sequence[RunItem], + ) -> dict[str, Any] | None: + """Record the response range and approval occurrences from live item identities.""" + from .run_internal.run_steps import NextStepInterruption + + if self._last_processed_response is None: + return None + if not isinstance(self._current_step, NextStepInterruption): + return None + + processed_items = self._last_processed_response.new_items + interruptions = self._current_step.interruptions + if not processed_items or not interruptions or len(processed_items) > len(generated_items): + return None + + candidate_starts = [ + start + for start in range(len(generated_items) - len(processed_items) + 1) + if all( + generated_items[start + offset] is item + for offset, item in enumerate(processed_items) + ) + ] + if len(candidate_starts) != 1: + return None + + start = candidate_starts[0] + indexes_by_identity: dict[int, list[int]] = {} + for index in range(start + len(processed_items), len(generated_items)): + indexes_by_identity.setdefault(id(generated_items[index]), []).append(index) + interruption_indexes: list[int] = [] + for item in interruptions: + indexes = indexes_by_identity.pop(id(item), []) + if len(indexes) != 1: + return None + interruption_indexes.append(indexes[0]) + return {"start": start, "end": len(generated_items), "interruptions": interruption_indexes} + def _serialize_context_payload( self, *, @@ -1805,6 +1849,14 @@ def to_json( "generated_session_item_indexes": self._generated_session_item_indexes(generated_items), } + current_response_generated_item_ownership = self._current_response_generated_item_ownership( + generated_items + ) + if current_response_generated_item_ownership is not None: + result["current_response_generated_item_ownership"] = ( + current_response_generated_item_ownership + ) + result["generated_items"] = [ self._serialize_item(item, agent_identity_keys_by_id=agent_identity_keys_by_id) for item in generated_items @@ -4250,6 +4302,24 @@ async def _build_run_state_from_json( current_step_data.get("data", {}).get("llm_end_hooks_started", True) ), ) + _restore_current_response_item_identities( + state, + serialized_generated_items=serialized_generated_items, + generated_source_indexes=generated_source_indexes, + last_processed_response_data=last_processed_response_data, + current_step_data=current_step_data, + current_response_generated_item_ownership=( + state_json.get("current_response_generated_item_ownership") + if (schema_major, schema_minor) + >= tuple( + int(part) + for part in _CURRENT_RESPONSE_OWNERSHIP_MIN_SCHEMA_VERSION.split( + ".", maxsplit=1 + ) + ) + else None + ), + ) if state._current_step.response_accepted: state._clear_generated_items_last_processed_marker() for approval_item in state._current_step.interruptions: @@ -5396,6 +5466,112 @@ def _deserialize_items_with_source_indexes( return items, source_indexes +def _restore_current_response_item_identities( + state: RunState[Any], + *, + serialized_generated_items: Any, + generated_source_indexes: Sequence[int], + last_processed_response_data: Any, + current_step_data: Mapping[str, Any], + current_response_generated_item_ownership: Any, +) -> None: + """Relink one response from explicit generated-item ownership after deserialization.""" + from .run_internal.run_steps import NextStepInterruption + + processed_response = state._last_processed_response + if processed_response is None: + return + current_step = state._current_step + if not isinstance(current_step, NextStepInterruption): + return + if not isinstance(serialized_generated_items, list): + return + if not isinstance(last_processed_response_data, Mapping): + return + + serialized_processed_items = last_processed_response_data.get("new_items") + if not isinstance(serialized_processed_items, list) or not serialized_processed_items: + return + if len(processed_response.new_items) != len(serialized_processed_items): + return + current_step_payload = current_step_data.get("data") + if not isinstance(current_step_payload, Mapping): + return + serialized_interruptions = current_step_payload.get("interruptions") + if not isinstance(serialized_interruptions, list) or not serialized_interruptions: + return + if len(current_step.interruptions) != len(serialized_interruptions): + return + ownership = current_response_generated_item_ownership + if not isinstance(ownership, Mapping): + return + source_start = ownership.get("start") + source_end = ownership.get("end") + interruption_indexes = ownership.get("interruptions") + if type(source_start) is not int or type(source_end) is not int: + return + if source_start < 0 or source_end != len(serialized_generated_items): + return + processed_end = source_start + len(serialized_processed_items) + # Handoff filters can clear prior items without resetting the model turn count. + if processed_end > source_end: + return + if not isinstance(interruption_indexes, list): + return + if len(interruption_indexes) != len(serialized_interruptions) or any( + type(index) is not int or index < processed_end or index >= source_end + for index in interruption_indexes + ): + return + if len(set(interruption_indexes)) != len(interruption_indexes): + return + source_indexes = [*range(source_start, processed_end), *interruption_indexes] + serialized_current_response_items = [*serialized_processed_items, *serialized_interruptions] + if any( + serialized_generated_items[source_index] != expected_item + for source_index, expected_item in zip( + source_indexes, + serialized_current_response_items, + strict=True, + ) + ): + return + + restored_indexes_by_source: dict[int, list[int]] = {} + for restored_index, source_index in enumerate(generated_source_indexes): + restored_indexes_by_source.setdefault(source_index, []).append(restored_index) + + restored_current_response_items: list[RunItem] = [] + for source_index in range(source_start, source_end): + restored_indexes = restored_indexes_by_source.get(source_index) + if restored_indexes is None or len(restored_indexes) != 1: + return + restored_current_response_items.append(state._generated_items[restored_indexes[0]]) + + processed_item_count = len(serialized_processed_items) + restored_processed_items = restored_current_response_items[:processed_item_count] + restored_interruptions = [ + restored_current_response_items[index - source_start] for index in interruption_indexes + ] + if not all(isinstance(item, ToolApprovalItem) for item in restored_interruptions): + return + + # The complete current response must be the same terminal suffix in both histories. + session_start = len(state._session_items) - len(restored_current_response_items) + if session_start < 0 or any( + generated_item is not session_item + for generated_item, session_item in zip( + restored_current_response_items, + state._session_items[session_start:], + strict=True, + ) + ): + return + + processed_response.new_items = restored_processed_items + current_step.interruptions = cast(list[ToolApprovalItem], restored_interruptions) + + def _clone_original_input(original_input: str | list[Any]) -> str | list[Any]: """Return a deep copy of the original input so later mutations don't leak into saved state.""" if isinstance(original_input, str): diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index a38424a8b3..9664d65513 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -2572,16 +2572,363 @@ async def run_once(input_value: Any) -> Any: @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("attach_session", [False, True], ids=["without-session", "with-session"]) +@pytest.mark.parametrize("mixed_tool_position", [None, "before", "after"]) @pytest.mark.asyncio -async def test_ambiguous_serialized_approval_state_fails_before_tool_execution( +async def test_serialized_later_turn_approval_with_output_guardrail_resumes( mode: str, + attach_session: bool, + mixed_tool_position: str | None, ) -> None: - tool_calls = 0 + tool_calls = {"normal": 0, "approval": 0} + + @function_tool(name_override="normal_tool") + def normal_tool() -> str: + tool_calls["normal"] += 1 + return "normal-result" @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + tool_calls["approval"] += 1 + return "approved-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + approval_response = [get_function_tool_call("approval_tool", "{}", call_id="call-approved")] + if mixed_tool_position is not None: + extra_call = get_function_tool_call("normal_tool", "{}", call_id="call-extra") + approval_response.insert(0 if mixed_tool_position == "before" else 1, extra_call) + expected_normal_calls = 1 if mixed_tool_position is None else 2 + model = ScriptedModel( + [ + [get_function_tool_call("normal_tool", "{}", call_id="call-normal")], + approval_response, + [get_text_message("done")], + ] + ) + agent = Agent( + name="test", + model=model, + tools=[normal_tool, approval_tool], + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + session = SimpleListSession() if attach_session else None + + async def run_once(input_value: Any) -> Any: + if mode == "non_streamed": + return await Runner.run(agent, input_value, session=session) + result = Runner.run_streamed(agent, input_value, session=session) + await consume_stream(result) + return result + + first = await run_once("Use normal_tool, then approval_tool") + state = first.to_state() + assert state._current_turn == 2 + assert [item.tool_name for item in first.interruptions] == ["approval_tool"] + assert tool_calls == {"normal": expected_normal_calls, "approval": 0} + + serialized = state.to_json() + assert serialized["current_response_generated_item_ownership"] == { + "start": 2, + "end": 4 if mixed_tool_position is None else 6, + "interruptions": [ + 3 if mixed_tool_position is None else (5 if mixed_tool_position == "before" else 4) + ], + } + + released_payload = json.loads(json.dumps(serialized)) + released_payload["$schemaVersion"] = "1.16" + released = await RunState.from_string(agent, json.dumps(released_payload)) + released.approve(released.get_interruptions()[0]) + with pytest.raises(UserError, match="current response boundary cannot be proven"): + await run_once(released) + assert tool_calls == {"normal": expected_normal_calls, "approval": 0} + + malformed_payload = json.loads(json.dumps(serialized)) + malformed_payload["current_response_generated_item_ownership"]["interruptions"][0] = True + malformed = await RunState.from_string(agent, json.dumps(malformed_payload)) + malformed.approve(malformed.get_interruptions()[0]) + with pytest.raises(UserError, match="current response boundary cannot be proven"): + await run_once(malformed) + assert tool_calls == {"normal": expected_normal_calls, "approval": 0} + + restored_once = await RunState.from_string(agent, json.dumps(serialized)) + reserialized = restored_once.to_json() + assert ( + reserialized["current_response_generated_item_ownership"] + == serialized["current_response_generated_item_ownership"] + ) + + restored = await RunState.from_string(agent, json.dumps(reserialized)) + assert restored._last_processed_response is not None + assert any( + restored._last_processed_response.new_items[0] is item for item in restored._generated_items + ) + restored.approve(restored.get_interruptions()[0]) + + resumed = await run_once(restored) + + assert resumed.final_output == "done" + assert tool_calls == {"normal": expected_normal_calls, "approval": 1} + if session is not None: + saved_items = await session.get_items() + saved_tool_items = [ + item + for item in saved_items + if isinstance(item, dict) + and item.get("type") in {"function_call", "function_call_output"} + ] + expected_tool_items = [ + ("function_call", "call-normal"), + ("function_call_output", "call-normal"), + ("function_call", "call-approved"), + ("function_call_output", "call-approved"), + ] + if mixed_tool_position is not None: + expected_tool_items.insert( + 2 if mixed_tool_position == "before" else 3, ("function_call", "call-extra") + ) + expected_tool_items.insert(4, ("function_call_output", "call-extra")) + assert [(item.get("type"), item.get("call_id")) for item in saved_tool_items] == ( + expected_tool_items + ) + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize( + "session_ownership", + [ + "valid", + "missing-prefix", + "missing", + "invalid", + "missing-current", + "prefix-anchor", + "nonterminal-session", + ], +) +@pytest.mark.asyncio +async def test_serialized_mixed_approval_guardrail_preserves_only_accepted_outputs( + mode: str, + session_ownership: str, +) -> None: + tool_calls = {"normal": 0, "approval": 0} + + @function_tool + def normal_tool() -> str: + tool_calls["normal"] += 1 + return "accepted-result" if tool_calls["normal"] == 1 else "sibling-secret" + + @function_tool(needs_approval=True) + def approval_tool() -> str: + tool_calls["approval"] += 1 + return "approved-secret" + + model = ScriptedModel( + [ + [get_function_tool_call("normal_tool", "{}", call_id="call-normal")], + [ + get_function_tool_call("normal_tool", "{}", call_id="call-extra"), + get_function_tool_call("approval_tool", "{}", call_id="call-approved"), + ], + ] + ) + agent = Agent( + name="test", + model=model, + tools=[normal_tool, approval_tool], + tool_use_behavior={"stop_at_tool_names": ["approval_tool"]}, + output_guardrails=[ + OutputGuardrail( + guardrail_function=lambda *_: GuardrailFunctionOutput( + output_info=None, tripwire_triggered=True + ) + ) + ], + ) + session = SimpleListSession() + + async def run_once(input_value: Any) -> Any: + if mode == "non_streamed": + return await Runner.run(agent, input_value, session=session) + result = Runner.run_streamed(agent, input_value, session=session) + await consume_stream(result) + return result + + first = await run_once("Use the tools") + payload = first.to_state().to_json() + if session_ownership == "missing": + payload.pop("generated_session_item_indexes") + elif session_ownership == "missing-prefix": + payload["generated_session_item_indexes"][0] = None + elif session_ownership == "invalid": + payload["generated_session_item_indexes"][0] = True + elif session_ownership == "missing-current": + current_start = payload["current_response_generated_item_ownership"]["start"] + payload["generated_session_item_indexes"][current_start] = None + elif session_ownership == "prefix-anchor": + earlier_copies = json.loads(json.dumps(payload["last_processed_response"]["new_items"])) + payload["session_items"][:0] = earlier_copies + payload["generated_session_item_indexes"] = [ + index + len(earlier_copies) if index is not None else None + for index in payload["generated_session_item_indexes"] + ] + current_start = payload["current_response_generated_item_ownership"]["start"] + for offset in range(len(earlier_copies)): + payload["generated_session_item_indexes"][current_start + offset] = offset + elif session_ownership == "nonterminal-session": + payload["session_items"].append(json.loads(json.dumps(payload["session_items"][0]))) + restored = await RunState.from_string(agent, json.dumps(payload)) + restored = await RunState.from_string(agent, restored.to_string()) + restored.approve(restored.get_interruptions()[0]) + if session_ownership not in {"valid", "missing-prefix"}: + saved_before = json.loads(json.dumps(await session.get_items())) + state_session_before = restored.to_json()["session_items"] + with pytest.raises(UserError, match="current response boundary cannot be proven"): + await run_once(restored) + assert tool_calls == {"normal": 2, "approval": 0} + assert await session.get_items() == saved_before + assert restored.to_json()["session_items"] == state_session_before + assert "accepted-result" in json.dumps(state_session_before) + return + with pytest.raises(OutputGuardrailTripwireTriggered): + await run_once(restored) + + assert tool_calls == {"normal": 2, "approval": 1} + outputs = [ + (item["call_id"], item["output"]) + for item in await session.get_items() + if isinstance(item, dict) and item.get("type") == "function_call_output" + ] + assert outputs == [ + ("call-normal", "accepted-result"), + ("call-extra", run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT), + ("call-approved", run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT), + ] + serialized = restored.to_string() + assert "accepted-result" in serialized + assert "sibling-secret" not in serialized + assert "approved-secret" not in serialized + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize("attach_session", [False, True], ids=["without-session", "with-session"]) +@pytest.mark.parametrize("tripwire", [False, True], ids=["accepted", "blocked"]) +@pytest.mark.asyncio +async def test_serialized_filtered_handoff_approval_with_empty_prefix_resumes( + mode: str, + attach_session: bool, + tripwire: bool, +) -> None: + tool_calls = 0 + + @function_tool(needs_approval=True) def approval_tool() -> str: nonlocal tool_calls tool_calls += 1 + return "approved-secret" + + def clear_generated_history(data: HandoffInputData) -> HandoffInputData: + return HandoffInputData( + input_history=data.input_history, + pre_handoff_items=(), + new_items=(), + run_context=data.run_context, + ) + + target = Agent( + name="target", + model=ScriptedModel( + [[get_function_tool_call("approval_tool", "{}", call_id="call-approved")]] + ), + tools=[approval_tool], + tool_use_behavior="stop_on_first_tool", + output_guardrails=[ + OutputGuardrail( + guardrail_function=lambda *_: GuardrailFunctionOutput( + output_info=None, tripwire_triggered=tripwire + ) + ) + ], + ) + starting = Agent( + name="starting", + model=ScriptedModel([[get_handoff_tool_call(target)]]), + handoffs=[handoff(target, input_filter=clear_generated_history)], + ) + session = SimpleListSession() if attach_session else None + + async def run_once(input_value: Any) -> Any: + if mode == "non_streamed": + return await Runner.run(starting, input_value, session=session) + result = Runner.run_streamed(starting, input_value, session=session) + await consume_stream(result) + return result + + first = await run_once("Transfer and run approval_tool") + state = first.to_state() + assert state._current_turn == 2 + assert tool_calls == 0 + ownership = {"start": 0, "end": 2, "interruptions": [1]} + assert state.to_json()["current_response_generated_item_ownership"] == ownership + restored = await RunState.from_string(starting, state.to_string()) + assert restored.to_json()["current_response_generated_item_ownership"] == ownership + restored = await RunState.from_string(starting, restored.to_string()) + restored.approve(restored.get_interruptions()[0]) + + if tripwire: + with pytest.raises(OutputGuardrailTripwireTriggered): + await run_once(restored) + assert "approved-secret" not in restored.to_string() + else: + resumed = await run_once(restored) + assert resumed.final_output == "approved-secret" + assert tool_calls == 1 + if session is not None: + saved_outputs = [ + item + for item in await session.get_items() + if isinstance(item, dict) and item.get("type") == "function_call_output" + ] + assert [(item["call_id"], item["output"]) for item in saved_outputs] == [ + ( + "call-approved", + run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT if tripwire else "approved-secret", + ) + ] + + +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) +@pytest.mark.parametrize( + "corruption", + [ + "missing-prefix", + "nonterminal-indexes", + "invalid-interruption-index", + "processed-mismatch", + "interruption-mismatch", + ], +) +@pytest.mark.asyncio +async def test_ambiguous_serialized_approval_state_fails_before_tool_execution( + mode: str, + corruption: str, +) -> None: + tool_calls = {"normal": 0, "approval": 0} + + @function_tool(name_override="normal_tool") + def normal_tool() -> str: + tool_calls["normal"] += 1 + return "normal-result" + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + tool_calls["approval"] += 1 return "secret-result" def output_guardrail( @@ -2592,19 +2939,43 @@ def output_guardrail( return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) model = ScriptedModel( - [[get_function_tool_call("approval_tool", "{}", call_id="call-approved")]] + [ + [get_function_tool_call("normal_tool", "{}", call_id="call-normal")], + [ + get_function_tool_call("normal_tool", "{}", call_id="call-extra"), + get_function_tool_call("approval_tool", "{}", call_id="call-approved"), + ], + ] ) agent = Agent( name="test", model=model, - tools=[approval_tool], + tools=[normal_tool, approval_tool], output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], ) - first = await Runner.run(agent, "Use approval_tool") + first = await Runner.run(agent, "Use normal_tool, then approval_tool") state = first.to_state() - state._current_turn = 2 - state._current_turn_persisted_item_count = 1 - restored = await RunState.from_json(agent, state.to_json()) + assert state._current_turn == 2 + assert tool_calls == {"normal": 2, "approval": 0} + serialized = state.to_json() + ownership = serialized["current_response_generated_item_ownership"] + assert ownership == {"start": 2, "end": 6, "interruptions": [5]} + + if corruption == "missing-prefix": + serialized["generated_items"] = serialized["generated_items"][2:] + elif corruption == "nonterminal-indexes": + trailing_item = json.loads(json.dumps(serialized["generated_items"][0])) + trailing_item["raw_item"]["call_id"] = "call-trailing" + serialized["generated_items"].append(trailing_item) + elif corruption == "invalid-interruption-index": + ownership["interruptions"] = [3] + elif corruption == "processed-mismatch": + serialized["generated_items"][ownership["start"]]["raw_item"]["call_id"] = "call-mismatch" + elif corruption == "interruption-mismatch": + serialized["generated_items"][ownership["interruptions"][0]]["raw_item"]["call_id"] = ( + "call-mismatch" + ) + restored = await RunState.from_json(agent, serialized) restored.approve(restored.get_interruptions()[0]) with pytest.raises(UserError, match="current response boundary cannot be proven"): @@ -2614,7 +2985,7 @@ def output_guardrail( result = Runner.run_streamed(agent, restored, session=None) await consume_stream(result) - assert tool_calls == 0 + assert tool_calls == {"normal": 2, "approval": 0} @pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) From fe45b415ee05479725cd6fb20a51c0d5cd73b3c1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 24 Aug 2026 11:19:10 +0900 Subject: [PATCH 408/473] chore: clarify review points for agents --- .../implementation-final-review/SKILL.md | 4 +- .../references/reviewer-brief.md | 2 +- .../scripts/test_skill_contract.py | 1 + .../references/evaluation-framework.md | 4 +- AGENTS.md | 63 ++++++++++++------- 5 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index 304e42e1f4..b83b5678f4 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -56,7 +56,7 @@ Persist the current combined content fingerprint as `ledger.round_fingerprint` a - Choose the narrower design unless concrete contract evidence requires the current machinery. 6. Select the relevant review dimensions below from the affected runtime boundaries and repository architecture references. Complete every selected dimension even after finding a blocker; the goal is a complete final review, not the first valid comment. Classify review risk before dispatch: normal when the change does not affect concurrency, cancellation, security, trust, persistence, durable state, released compatibility, package/runtime exports, protocol ownership, or cross-provider lifecycle; elevated when any of those boundaries changes or an earlier round produced P0/P1. Run the cheapest affected-boundary preflight broad enough to catch likely late fallout from a dependency, package surface, generated artifact, or cross-cutting runtime change. Prefer focused tests plus a narrowly targeted import, generated-surface, or static check. Run a targeted type check only when the change directly affects a typing boundary and the command is materially narrower than repository-wide `make typecheck`. Do not run repository-wide lint, typecheck, builds, integration suites, `make tests-review`, or `make tests` merely to enter or iterate through the review gate. Run the focused preflight once for a semantic state and rerun only affected checks after fixes. 7. Build the pre-dispatch evidence required by the changed boundary: - - For every changed public symbol, configuration field, event, serialized field, wire value, or documented caller-visible behavior, create a contract-surface inventory: producers and constructors; every consumer, forwarding branch, and adapter; default, missing, and invalid-value behavior; package exports and generated public surfaces when applicable; adjacent docs and examples; and caller-visible tests. Search adjacent contract surfaces even when they are absent from the diff. A required docs, example, export, adapter, or generated-surface update is a missing task deliverable, not out of scope merely because it is not yet in the manifest. + - For every changed public symbol, configuration field, event, serialized field, wire value, or documented caller-visible behavior, create a contract-surface inventory: producers and constructors; every consumer, forwarding branch, and adapter; default, missing, and invalid-value behavior; package exports and generated public surfaces when applicable; adjacent docs and examples; and caller-visible tests. Search adjacent contract surfaces even when they are absent from the diff. A required example, export, adapter, or generated-surface update is a missing task deliverable, not out of scope merely because it is not yet in the manifest. A required `docs/` update is also a missing task deliverable unless the repository's Documentation Release Timing policy intentionally defers it. When that policy applies, record the documentation need and timing as evidence of separately timed work; do not add it to the current task manifest, report it as a current-pull-request finding, or let it block clean review. - For concurrency, cancellation, reentrancy, shared lifecycle state, or a check followed by an await before a side effect, create an await-boundary matrix. For each relevant operation, record the state snapshot, blocking or await point, events and operations that may run while suspended, durable or monotonic evidence retained, revalidation before each side effect, and resulting cancel, feedback, persistence, or cleanup action. Include source completion, a newer operation active with known and unknown identity, a newer operation that starts and completes while suspended, and failure or cancellation of the awaited action when those states are supported. If correctness depends on whether something ever happened, current active state is insufficient unless serialization proves it cannot be lost; require monotonic identity, generation, tombstone, or equivalent durable evidence. - For protocol, persistence, or security changes, create the analogous authority/data-flow inventory from input through validation, storage, retry or replay, output, exceptions, logs, telemetry, and cleanup. Treat these as mechanical coverage artifacts, not implementation conclusions. The implementer must fill them from code and contract evidence before review; reviewers validate them independently against the complete diff and surrounding source. 8. Produce only concrete, patch-scoped findings that are reproducible from code, contract, documentation, or a focused probe. Do not report hypothetical extensibility or unrelated cleanup. Before concluding, account for every row in the contract-surface, await-boundary, and authority/data-flow inventories and every new or modified source of shared state. For a scenario outside the required behavior, run a differential check against the merge base or latest release and identify support evidence. Reachability through a public method, concurrent call, repeated call, host-language protocol, or third-party behavior is not by itself a supported contract. @@ -150,7 +150,7 @@ Choose dimensions based on the changed boundary; do not mechanically invent find - Preserve exact caller-visible identity or spelling unless transformation is required. - Distinguish unreleased branch-local machinery from released or durable compatibility boundaries. - For every new or modified public field, enumerate all construction, forwarding, and consumption branches. Verify that normal, specialized, default, missing-value, and error paths either honor the field or reject it according to one coherent contract; do not validate only the motivating branch. -- Search public docs, examples, docstrings, configuration reference, and release metadata for claims made stale by the behavior change. Missing documentation can be an actionable omission even when no documentation file is in the diff. +- Search public docs, examples, docstrings, configuration reference, and release metadata for claims made stale by the behavior change. Missing documentation can be an actionable omission even when no documentation file is in the diff. Apply the repository's Documentation Release Timing policy before classifying the omission: required `docs/` content that would describe unreleased behavior is separately timed work, not a defect in the current task or pull request. ### Lifecycle and failures diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index 1f6286c84d..5af9094ca5 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -70,7 +70,7 @@ Give every row a stable ID. Use one row per changed public symbol, configuration Encode those columns in each `kind: "contract"` inventory object as `surface`, `producers`, `consumers`, `behavior`, `exports`, `adjacent`, and `tests`. Each field must be a nonempty string; use `none` or `not applicable` only when that is the explicit reviewed value. -Include adjacent surfaces found outside the current diff. If a required update is absent, add it to the task manifest before freezing the review. +Include adjacent surfaces found outside the current diff. If a required update is absent, add it to the task manifest before freezing the review, except for `docs/` content intentionally deferred by the repository's Documentation Release Timing policy. Record deferred documentation in the inventory and evidence as separately timed work; do not add it to the current task manifest or findings, and do not let it block clean review. ## Await-boundary or authority inventory diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index f6e869ca73..be62e68543 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -45,6 +45,7 @@ def test_quality_gates_cover_prior_failure_modes(self) -> None: "contract-surface inventory", "every consumer, forwarding branch, and adapter", "Search adjacent contract surfaces even when they are absent from the diff", + "do not add it to the current task manifest, report it as a current-pull-request finding, or let it block clean review", "await-boundary matrix", "a newer operation that starts and completes while suspended", "current active state is insufficient", diff --git a/.agents/skills/maintainer-review/references/evaluation-framework.md b/.agents/skills/maintainer-review/references/evaluation-framework.md index 08010f451e..516d6dfc7a 100644 --- a/.agents/skills/maintainer-review/references/evaluation-framework.md +++ b/.agents/skills/maintainer-review/references/evaluation-framework.md @@ -170,9 +170,11 @@ Do not treat documentation as automatically required for every public option, co - Existing user-facing docs become materially false, unsafe, or misleading. - Correct or safe use depends on a non-obvious constraint, migration step, compatibility boundary, or operational warning. -- Repository policy, the accepted issue scope, or an explicit maintainer decision requires documentation in the same PR. +- Repository policy, the accepted issue scope, or an explicit maintainer decision requires documentation in the same PR and does not require separate release timing. - The intended feature would be practically unusable or undiscoverable by its target users without a documented entry point, and generated API reference or clear code-level discovery is insufficient. +Decide documentation necessity separately from current-pull-request timing. If the repository's Documentation Release Timing policy defers required `docs/` content because it would describe unreleased behavior, record the need as separately timed work and do not make its absence a blocker for the feature or bug-fix pull request. + If docs would merely improve discoverability or completeness, keep them non-blocking. Do not change `Merge-worthy as-is` to `Merge-worthy after focused changes` solely for optional docs, and do not include optional docs in the maintainer comment's required-action paragraph. Respect an explicit maintainer choice to omit docs or defer them to a separate follow-up. ## Lifecycle and failure-path review diff --git a/AGENTS.md b/AGENTS.md index c44a3b406f..dfc2ee1fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,9 @@ This guide helps new contributors get started with the OpenAI Agents Python repo ## Table of Contents 1. [Policies & Mandatory Rules](#policies--mandatory-rules) -2. [Project Structure Guide](#project-structure-guide) -3. [Operation Guide](#operation-guide) -4. [Code Review Rules](#code-review-rules) +2. [Code Review Rules](#code-review-rules) +3. [Project Structure Guide](#project-structure-guide) +4. [Operation Guide](#operation-guide) ## Policies & Mandatory Rules @@ -77,6 +77,8 @@ If isolation or a different checkout is needed, explain why and ask the user bef When a feature or bug fix introduces behavior that is not yet available in the latest published release, do not include `docs/` changes that describe that unreleased behavior in the feature or bug-fix pull request, and do not expect those changes as part of that pull request. Handle them in a separate docs-only pull request so maintainers can coordinate its merge timing with the release that makes the documentation accurate. This exception applies only when the documentation would be incorrect for the latest published release; documentation that is already accurate for released behavior remains part of the normal change scope. +Determine whether documentation is required separately from deciding which pull request should carry it. When required `docs/` content would describe behavior that is not available in the latest published release, classify it as separately timed documentation work rather than a missing deliverable or blocking finding for the feature or bug-fix pull request. This timing rule takes precedence over general documentation-completeness requirements in code-review rules, pull-request guidance, and repository skills. It applies to `docs/` content, not automatically to examples or code-level documentation that ships with the changed API. + ### Documentation Verification Tiers Classify documentation changes before choosing review and verification work. Use the narrowest tier that covers the complete diff, and move to a higher tier when any changed file or claim requires it. @@ -128,6 +130,40 @@ Treat the parameter and dataclass field order of exported runtime APIs as a comp - For OpenAI platform or SDK-specific docs changes, prefer `$openai-knowledge` for authoritative platform behavior and inspect the local code path for SDK behavior. Do not rely on generic API assumptions when documenting Responses, Chat Completions, Realtime, tools, MCP, or provider adapters. - For Realtime tracing changes, read [Realtime tracing architecture](.agents/references/realtime-tracing.md) before proposing SDK spans. Realtime API server traces and Agents SDK client traces are separate; `group_id` can correlate them but does not create a shared trace hierarchy. +## Code Review Rules + +### Finding threshold and supported scope + +- Report a runtime defect only when the changed code causes a concrete incorrect behavior on a supported path. State the triggering scenario and the caller-visible, compatibility, security, persistence, or lifecycle consequence; omit the finding when no such consequence can be established. +- Treat added abstractions, state, validation, compatibility handling, fallback behavior, dependencies, or parallel paths as actionable only when the machinery does not map to the task, a released contract, supported durable state, or a verified runtime or platform risk. Identify the exact unnecessary machinery and recommend the smallest safe removal or direct replacement. +- Flag runtime validation, compatibility handling, fallback behavior, or tests added only for synthetic or unsupported values when no ordinary supported producer, released contract, durable boundary, or actual untrusted-input path can produce the value with a concrete consequence. Constructibility in Python, manually corrupted typed objects, monkeypatched state, and direct helper calls that bypass the owning public or wire boundary are not sufficient justification. This includes non-finite or extreme numbers and impossible enum or discriminated-union members unless the exact category is intentionally supported. +- Do not duplicate client-side runtime validation solely for values already excluded by the public type contract or authoritatively rejected by the upstream provider. Add fail-fast SDK validation only when delayed rejection creates a concrete SDK-owned problem before the authoritative rejection, such as an irreversible side effect, persistent corruption, security or privacy exposure, avoidable billable work, repeated resource consumption, or an error that arrives too late or is too opaque for reasonable correction. A security label alone is insufficient without a complete trace from attacker-controlled input through an actual trust boundary to the protected outcome. +- Do not report a defect merely because another semantic choice appears cleaner, more symmetric, or easier to explain. When repository evidence does not select one contract, report only a concrete inconsistency with an already supported path or an established caller-visible expectation. +- Flag a new public option, callback, class, compatibility branch, or parallel execution path when the exact required outcome, including its lifecycle and compatibility constraints, is already available through a reasonable supported API or composition path. Name that path and recommend removal or narrower reuse of the existing source of truth. +- Report compatibility findings only against behavior shipped in the latest release, an explicitly supported public contract, or a durable external state or protocol boundary. Do not require compatibility shims for unreleased branch-local helpers, same-branch tests, or intermediate persisted formats that are intentionally unsupported. + +### Contract and lifecycle coverage + +- For every added or modified public field, configuration value, event, serialized value, or wire value, inspect all supported construction, forwarding, adapter, and consumption paths. Flag partial implementations where normal, specialized, default, missing-value, or error paths silently drop, reshape, or reject the value inconsistently. Include intended public imports and generated package surfaces when they are part of the changed contract. +- Require parity across streaming and non-streaming, sync and async, initial and resumed, direct and wrapped, or provider-specific paths only when the accepted requirement or existing contract covers those paths. Do not report missing parity solely for API symmetry or conceptual similarity. +- When changed code mutates shared state across an `await`, callback, retry, reconnect, cancellation, cleanup, or rollback boundary, check whether stale or failing work can overwrite, revert, or dispose state owned by surviving work. Report the concrete interleaving and the missing ownership, generation, identity, transaction, revalidation, or serialization invariant at the actual mutation boundary; sequential happy-path tests are insufficient. +- When a new validation or failure path can run after resources or observable state are acquired, verify cleanup explicitly and preserve the primary failure. Report concrete leaked resources, stale state, lost handlers, or survivor corruption rather than assuming normal teardown runs after failed construction or context entry. +- Flag persisted, resumed, serialized, provider-controlled, or manifest data that is treated as authority for a host-owned runtime, security, identity, or cleanup decision unless the supported trust boundary explicitly grants that authority. Preserve trusted current configuration and validate untrusted state before it can affect side effects, replay, or resource ownership. + +### Test and documentation evidence + +- Treat tests as contract evidence only when they exercise the highest stable caller-visible boundary that controls the observable result and derive expected behavior from the requirement, released behavior, a worked example, a baseline, or another independent oracle. Do not accept helper-only call-shape assertions or expected values recomputed with the implementation's own logic when another layer owns the outcome. +- Require representative regression coverage for the accepted behavior and intentionally unsupported category. For concurrency findings, require controlled completion ordering plus assertions about the surviving operation and final shared state. Do not request exhaustive tests for every constructible permutation. +- Report missing documentation or examples only when the patch makes existing guidance materially false, unsafe, or misleading; correct use depends on a non-obvious migration, compatibility boundary, constraint, or operational warning; or the accepted feature would otherwise be practically unusable. Do not report optional completeness or discoverability improvements as blocking findings. +- Decide `docs/` delivery timing separately from documentation necessity. If required `docs/` content would describe behavior unavailable in the latest published release, apply [Documentation Release Timing](#documentation-release-timing): record it as separately timed work, and do not report its absence as a blocking finding for the feature or bug-fix pull request. This exception does not automatically defer examples or code-level documentation that ships with the changed API. +- Do not report formatting, lint, full-suite status, commit history, or pull-request description quality as code findings; those are CI or repository-readiness conditions. + +### Review scope + +- Review the complete diff from the merge base of the intended target branch, or from the latest release tag when it is the compatibility baseline, not only the latest incremental fix. Passing tests do not justify branch-local machinery that no longer matches the original requirement. +- Keep findings scoped to consequences introduced, exposed, or worsened by the patch. Do not block on unrelated cleanup, pre-existing bugs, optional refactors, or speculative extensibility merely discovered while reading adjacent code. A pre-existing condition is in scope when the patch newly reaches it on a supported path, relies on it for correctness, or otherwise makes its consequence part of the changed behavior. +- Require a broader refactor only when concrete evidence shows the focused change would otherwise remain incorrect, unsafe, incompatible, or dependent on duplicated sources of truth that can observably diverge. + ## Project Structure Guide ### Overview @@ -287,24 +323,7 @@ make tests - Use the template at `.github/PULL_REQUEST_TEMPLATE/pull_request_template.md`; include a summary, test plan, and issue number if applicable. - In copy-ready GitHub text, use native issue and pull-request references: exactly `#123` for this repository and `owner/repo#123` for another repository. Do not qualify same-repository references as `openai/openai-agents-python#123`. Preserve closing forms such as `Fixes #123` or `Resolves #123`. Never wrap these references in Markdown links such as `[PR #123](https://github.com/owner/repo/pull/123)` or `[#123](...)`; those Codex-friendly links require manual cleanup after pasting into GitHub. Use descriptive Markdown links only for external resources or GitHub targets that cannot be expressed as a native issue or pull-request reference. -- Add tests for new behavior when feasible. Update documentation for user-facing changes, except unreleased-behavior documentation that must follow the separate docs-only pull request policy above. +- Add focused regression tests for accepted new behavior when feasible. Update documentation or examples when the change would otherwise make existing guidance materially false, unsafe, or misleading; correct use depends on a non-obvious constraint, migration step, compatibility boundary, or operational warning; or the accepted feature would otherwise be practically unusable. Do not require optional documentation or examples solely for completeness. +- Determine `docs/` delivery timing separately from documentation necessity. When required `docs/` content would describe behavior that is not yet in the latest published release, leave it out of the feature or bug-fix pull request and treat it as separately timed docs-only work, not as an incomplete current pull request. This exception does not automatically apply to examples or code-level documentation that ships with the changed API. - Run `make format`, `make lint`, `make typecheck`, and `make tests` before marking work ready. - Commit messages should be concise and written in the imperative mood. Small, focused commits are preferred. - -## Code Review Rules - -- Use `$implementation-strategy` to establish the requested outcome and latest released compatibility boundary before judging implementation scope or architecture. -- Treat added complexity as an actionable finding only when specific machinery is not required by the task, a released contract, supported durable state, or a verified runtime or platform risk. Identify the unnecessary machinery and recommend the smallest safe removal or direct replacement. -- Do not request speculative abstractions, general-purpose helpers, configuration knobs, dependencies, compatibility layers, feature flags, parallel code paths, or extensibility for hypothetical future consumers. -- Do not process a sequence of related review comments as independent local fixes when they expose the same missing boundary. Classify them together, decide whether the disputed shapes belong to the supported contract, and prefer one narrowing redesign over accumulating branches. -- Review the complete diff from the merge base of the intended target branch, or from the latest release tag when it is the compatibility baseline, not only the latest incremental fix. Passing tests do not justify branch-local machinery that no longer matches the original requirement. -- Keep findings scoped to the patch. Do not block on unrelated cleanup, pre-existing bugs, or optional refactors; report them separately when useful. -- Require a broader refactor only when concrete evidence shows the focused change would otherwise be incorrect, unsafe, incompatible, or materially harder to maintain. - -### Baseline review expectations - -- ✅ Checks pass (`make format`, `make lint`, `make typecheck`, `make tests`). -- ✅ Tests cover new behavior and edge cases. -- ✅ Code is readable, maintainable, and consistent with existing style. -- ✅ Examples are updated if behavior changes. -- ✅ History is clean with a clear PR description. From 7abe1544ff685e4030205795431ca35f28bc3707 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 07:05:30 +0900 Subject: [PATCH 409/473] fix: make lint error --- .../implementation-final-review/scripts/test_skill_contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py index be62e68543..951e1f7eca 100644 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py @@ -45,7 +45,8 @@ def test_quality_gates_cover_prior_failure_modes(self) -> None: "contract-surface inventory", "every consumer, forwarding branch, and adapter", "Search adjacent contract surfaces even when they are absent from the diff", - "do not add it to the current task manifest, report it as a current-pull-request finding, or let it block clean review", + "do not add it to the current task manifest, report it as a " + "current-pull-request finding, or let it block clean review", "await-boundary matrix", "a newer operation that starts and completes while suspended", "current active state is insufficient", From c8b0a92847a3eb156e2b95bc63b37f920fabafae Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 24 Aug 2026 17:21:57 -0500 Subject: [PATCH 410/473] fix(core): read tool args without Pydantic property shadowing (#4627) --- src/agents/function_schema.py | 6 +++++- tests/test_function_schema.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 378715dcb4..791eb3b661 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -51,6 +51,10 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: positional_args: list[Any] = [] keyword_args: dict[str, Any] = {} seen_var_positional = False + # Read instance storage first so Pydantic properties such as ``model_extra`` + # and ``model_fields_set`` do not shadow tool parameters of the same name. + # ``model_dump()`` is unsuitable here because it converts nested models to dicts. + instance_values = object.__getattribute__(data, "__dict__") # Use enumerate() so we can skip the first parameter if it's context. for idx, (name, param) in enumerate(self.signature.parameters.items()): @@ -58,7 +62,7 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: if self.takes_context and idx == 0: continue - value = getattr(data, name, None) + value = instance_values[name] if name in instance_values else getattr(data, name, None) if param.kind == param.VAR_POSITIONAL: # e.g. *args: extend positional args and mark that *args is now seen positional_args.extend(value or []) diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 1b261ce9b5..1cdd08049c 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -93,6 +93,44 @@ def test_simple_function(): func_schema.params_pydantic_model(**{"a": "not an integer"}) +def function_with_model_extra_param(query: str, model_extra: str) -> str: + return f"{query}:{model_extra}" + + +def function_with_model_fields_set_param(query: str, model_fields_set: int) -> str: + return f"{query}:{model_fields_set}" + + +def test_to_call_args_does_not_shadow_pydantic_model_extra(): + """A parameter named ``model_extra`` must not be replaced by BaseModel.model_extra.""" + + with pytest.warns(UserWarning, match="model_extra"): + func_schema = function_schema(function_with_model_extra_param, use_docstring_info=False) + parsed = func_schema.params_pydantic_model.model_validate( + {"query": "hello", "model_extra": "gpt-4.1"} + ) + + args, kwargs_dict = func_schema.to_call_args(parsed) + result = function_with_model_extra_param(*args, **kwargs_dict) + assert result == "hello:gpt-4.1" + + +def test_to_call_args_does_not_shadow_pydantic_model_fields_set(): + """A parameter named ``model_fields_set`` must not be replaced by BaseModel.model_fields_set.""" + + with pytest.warns(UserWarning, match="model_fields_set"): + func_schema = function_schema( + function_with_model_fields_set_param, use_docstring_info=False + ) + parsed = func_schema.params_pydantic_model.model_validate( + {"query": "hello", "model_fields_set": 42} + ) + + args, kwargs_dict = func_schema.to_call_args(parsed) + result = function_with_model_fields_set_param(*args, **kwargs_dict) + assert result == "hello:42" + + def varargs_function(x: int, *numbers: float, flag: bool = False, **kwargs: Any): return x, numbers, flag, kwargs From e87236ecad1285babe7a80d8842d9ca5364066a3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 07:57:08 +0900 Subject: [PATCH 411/473] feat(mcp): add server-wide guardrails to MCP tools (#4632) --- src/agents/mcp/server.py | 46 +++++++++++ src/agents/mcp/util.py | 10 +++ tests/mcp/helpers.py | 5 ++ tests/mcp/test_mcp_approval.py | 72 ++++++++++++++++- tests/mcp/test_mcp_util.py | 122 ++++++++++++++++++++++++++++- tests/mcp/test_runner_calls_mcp.py | 84 ++++++++++++++++++++ 6 files changed, 337 insertions(+), 2 deletions(-) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 977352330e..cdc8927b55 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -44,6 +44,7 @@ ) from ..run_context import RunContextWrapper from ..tool import ToolErrorFunction +from ..tool_guardrails import ToolInputGuardrail, ToolOutputGuardrail from ..util._types import MaybeAwaitable from . import _compat as mcp_compat from ._compat import ( @@ -549,6 +550,9 @@ def __init__( failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, + *, + tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, ): """ Args: @@ -570,6 +574,10 @@ def __init__( tool calls. It is invoked by the Agents SDK before calling `call_tool`. custom_data_extractor: Optional callable that produces SDK-only custom data for emitted MCP tool output items. + tool_input_guardrails: Optional list of guardrails applied to every tool on this + server before the tool is invoked. + tool_output_guardrails: Optional list of guardrails applied to every tool on this + server after the tool returns. """ self.use_structured_content = use_structured_content self._needs_approval_policy = self._normalize_needs_approval( @@ -578,6 +586,8 @@ def __init__( self._failure_error_function = failure_error_function self.tool_meta_resolver = tool_meta_resolver self.custom_data_extractor = custom_data_extractor + self.tool_input_guardrails = tool_input_guardrails + self.tool_output_guardrails = tool_output_guardrails @abc.abstractmethod async def connect(self): @@ -879,6 +889,9 @@ def __init__( tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, retry_backoff_seconds_max: float | None = None, + *, + tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, ): """ Args: @@ -918,6 +931,10 @@ def __init__( emitted MCP tool output items. retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between retries. Defaults to `None`, which leaves exponential backoff uncapped. + tool_input_guardrails: Optional list of guardrails applied to every tool on this + server before the tool is invoked. + tool_output_guardrails: Optional list of guardrails applied to every tool on this + server after the tool returns. """ mcp_compat.enable_legacy_httpx_compat() super().__init__( @@ -926,6 +943,8 @@ def __init__( failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.session: ClientSession | None = None self.exit_stack: AsyncExitStack = AsyncExitStack() @@ -1888,6 +1907,9 @@ def __init__( tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, retry_backoff_seconds_max: float | None = None, + *, + tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, ): """Create a new MCP server based on the stdio transport. @@ -1932,6 +1954,10 @@ def __init__( emitted MCP tool output items. retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between retries. Defaults to `None`, which leaves exponential backoff uncapped. + tool_input_guardrails: Optional list of guardrails applied to every tool on this + server before the tool is invoked. + tool_output_guardrails: Optional list of guardrails applied to every tool on this + server after the tool returns. """ super().__init__( cache_tools_list=cache_tools_list, @@ -1946,6 +1972,8 @@ def __init__( tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, retry_backoff_seconds_max=retry_backoff_seconds_max, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.params = StdioServerParameters( @@ -2021,6 +2049,9 @@ def __init__( tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, retry_backoff_seconds_max: float | None = None, + *, + tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, ): """Create a new MCP server based on the HTTP with SSE transport. @@ -2067,6 +2098,10 @@ def __init__( emitted MCP tool output items. retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between retries. Defaults to `None`, which leaves exponential backoff uncapped. + tool_input_guardrails: Optional list of guardrails applied to every tool on this + server before the tool is invoked. + tool_output_guardrails: Optional list of guardrails applied to every tool on this + server after the tool returns. """ super().__init__( cache_tools_list=cache_tools_list, @@ -2081,6 +2116,8 @@ def __init__( tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, retry_backoff_seconds_max=retry_backoff_seconds_max, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.params = params @@ -2182,6 +2219,9 @@ def __init__( tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, retry_backoff_seconds_max: float | None = None, + *, + tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, ): """Create a new MCP server based on the Streamable HTTP transport. @@ -2229,6 +2269,10 @@ def __init__( emitted MCP tool output items. retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between retries. Defaults to `None`, which leaves exponential backoff uncapped. + tool_input_guardrails: Optional list of guardrails applied to every tool on this + server before the tool is invoked. + tool_output_guardrails: Optional list of guardrails applied to every tool on this + server after the tool returns. """ super().__init__( cache_tools_list=cache_tools_list, @@ -2243,6 +2287,8 @@ def __init__( tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, retry_backoff_seconds_max=retry_backoff_seconds_max, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.params = params diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 27d11149d5..1fccff0883 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -575,6 +575,16 @@ def to_function_tool( ), failure_error_function=effective_failure_error_function, strict_json_schema=is_strict, + tool_input_guardrails=( + list(server.tool_input_guardrails) + if server.tool_input_guardrails is not None + else None + ), + tool_output_guardrails=( + list(server.tool_output_guardrails) + if server.tool_output_guardrails is not None + else None + ), needs_approval=needs_approval, mcp_title=resolve_mcp_tool_title(tool), tool_origin=ToolOrigin( diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py index 47c6dbc85f..4de55816ac 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -21,6 +21,7 @@ from agents.mcp.server import _UNSET, _MCPServerWithClientSession, _UnsetType from agents.mcp.util import MCPToolCustomDataExtractor, MCPToolMetaResolver, ToolFilter from agents.tool import ToolErrorFunction +from agents.tool_guardrails import ToolInputGuardrail, ToolOutputGuardrail from .model_compat import ListResourceTemplatesResult, Tool as MCPTool @@ -77,6 +78,8 @@ def __init__( failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, custom_data_extractor: MCPToolCustomDataExtractor | None = None, + tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, ): super().__init__( use_structured_content=False, @@ -84,6 +87,8 @@ def __init__( failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.tools: list[MCPToolType] = tools or [] self.tool_calls: list[str] = [] diff --git a/tests/mcp/test_mcp_approval.py b/tests/mcp/test_mcp_approval.py index 16a5b4c004..4516ac2485 100644 --- a/tests/mcp/test_mcp_approval.py +++ b/tests/mcp/test_mcp_approval.py @@ -3,9 +3,19 @@ import pytest from mcp.types import Tool as MCPTool -from agents import Agent, RunContextWrapper, Runner +from agents import ( + Agent, + RunConfig, + RunContextWrapper, + Runner, + ToolExecutionConfig, + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, +) from agents.exceptions import UserError +from agents.run_state import RunState from agents.testing import ScriptedModel +from agents.tool_guardrails import tool_input_guardrail from ..test_responses import get_function_tool_call, get_text_message from ..utils.hitl import queue_function_call_and_text, resume_after_first_approval @@ -40,6 +50,66 @@ async def test_mcp_require_approval_pauses_and_resumes(): assert resumed.final_output == "done" +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("pre_approval", [False, True]) +async def test_mcp_guardrails_preserve_approval_and_serialized_resume_order( + streaming: bool, + pre_approval: bool, +): + guardrail_calls: list[tuple[str, str]] = [] + + @tool_input_guardrail + def allow_input(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + guardrail_calls.append((data.context.tool_name, data.context.tool_arguments)) + return ToolGuardrailFunctionOutput.allow() + + server = FakeMCPServer( + require_approval="always", + tool_input_guardrails=[allow_input], + ) + server.add_tool("add", {"type": "object", "properties": {}}) + model = ScriptedModel( + [ + [get_function_tool_call("add", "{}", call_id="guarded_mcp_call")], + [get_text_message("done")], + ] + ) + agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) + run_config = RunConfig( + tool_execution=ToolExecutionConfig( + pre_approval_tool_input_guardrails=pre_approval, + ) + ) + + if streaming: + first = Runner.run_streamed(agent, "call add", run_config=run_config) + async for _ in first.stream_events(): + pass + else: + first = await Runner.run(agent, "call add", run_config=run_config) + + assert len(first.interruptions) == 1 + assert server.tool_calls == [] + assert guardrail_calls == ([("add", "{}")] if pre_approval else []) + + state = first.to_state() + state.approve(first.interruptions[0]) + restored_state = await RunState.from_string(agent, state.to_string()) + + if streaming: + resumed = Runner.run_streamed(agent, restored_state, run_config=run_config) + async for _ in resumed.stream_events(): + pass + else: + resumed = await Runner.run(agent, restored_state, run_config=run_config) + + expected_guardrail_runs = 2 if pre_approval else 1 + assert resumed.final_output == "done" + assert server.tool_calls == ["add"] + assert guardrail_calls == [("add", "{}")] * expected_guardrail_runs + + @pytest.mark.asyncio async def test_mcp_require_approval_tool_lists(): """TS-style requireApproval toolNames should map to needs_approval.""" diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index f63560a7cd..4fd26df462 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -18,6 +18,9 @@ FunctionTool, Handoff, RunContextWrapper, + ToolGuardrailFunctionOutput, + ToolInputGuardrail, + ToolOutputGuardrail, default_tool_error_function, handoff, ) @@ -27,7 +30,13 @@ ModelBehaviorError, UserError, ) -from agents.mcp import MCPServer, MCPUtil +from agents.mcp import ( + MCPServer, + MCPServerSse, + MCPServerStdio, + MCPServerStreamableHttp, + MCPUtil, +) from agents.mcp._compat import MCPError, tool_input_schema from agents.tool_context import ToolContext @@ -117,6 +126,117 @@ async def test_get_all_function_tools(): assert all(tool.name in names for tool in tools) +@pytest.mark.asyncio +async def test_mcp_server_guardrails_apply_to_every_converted_tool_with_isolated_lists(): + input_guardrail = ToolInputGuardrail( + guardrail_function=lambda _: ToolGuardrailFunctionOutput.allow() + ) + output_guardrail = ToolOutputGuardrail( + guardrail_function=lambda _: ToolGuardrailFunctionOutput.allow() + ) + server = FakeMCPServer( + tool_input_guardrails=[input_guardrail], + tool_output_guardrails=[output_guardrail], + ) + server.add_tool("first", {}) + server.add_tool("second", {}) + + tools = await MCPUtil.get_all_function_tools( + [server], + False, + RunContextWrapper(context=None), + Agent(name="test_agent"), + ) + + assert [tool.name for tool in tools] == ["first", "second"] + first, second = tools + assert isinstance(first, FunctionTool) + assert isinstance(second, FunctionTool) + assert first.tool_input_guardrails is not None + assert first.tool_output_guardrails is not None + assert first.tool_input_guardrails == [input_guardrail] + assert second.tool_input_guardrails == [input_guardrail] + assert first.tool_output_guardrails == [output_guardrail] + assert second.tool_output_guardrails == [output_guardrail] + assert first.tool_input_guardrails is not server.tool_input_guardrails + assert first.tool_input_guardrails is not second.tool_input_guardrails + assert first.tool_output_guardrails is not server.tool_output_guardrails + assert first.tool_output_guardrails is not second.tool_output_guardrails + + first.tool_input_guardrails.clear() + first.tool_output_guardrails.clear() + assert server.tool_input_guardrails == [input_guardrail] + assert server.tool_output_guardrails == [output_guardrail] + assert second.tool_input_guardrails == [input_guardrail] + assert second.tool_output_guardrails == [output_guardrail] + + +@pytest.mark.asyncio +async def test_mcp_server_guardrails_do_not_leak_across_servers_or_filtered_tools(): + input_guardrail = ToolInputGuardrail( + guardrail_function=lambda _: ToolGuardrailFunctionOutput.allow() + ) + guarded_server = FakeMCPServer( + tool_filter={"allowed_tool_names": ["guarded"]}, + tool_input_guardrails=[input_guardrail], + ) + guarded_server.add_tool("guarded", {}) + guarded_server.add_tool("filtered_out", {}) + unguarded_server = FakeMCPServer() + unguarded_server.add_tool("unguarded", {}) + + tools = await MCPUtil.get_all_function_tools( + [guarded_server, unguarded_server], + False, + RunContextWrapper(context=None), + Agent(name="test_agent"), + ) + + assert [tool.name for tool in tools] == ["guarded", "unguarded"] + guarded, unguarded = tools + assert isinstance(guarded, FunctionTool) + assert isinstance(unguarded, FunctionTool) + assert guarded.tool_input_guardrails == [input_guardrail] + assert guarded.tool_output_guardrails is None + assert unguarded.tool_input_guardrails is None + assert unguarded.tool_output_guardrails is None + + +def test_public_mcp_server_constructors_forward_guardrail_configuration(): + input_guardrails: list[ToolInputGuardrail[Any]] = [] + output_guardrails: list[ToolOutputGuardrail[Any]] = [] + servers = [ + MCPServerStdio( + params={"command": "test"}, + tool_input_guardrails=input_guardrails, + tool_output_guardrails=output_guardrails, + ), + MCPServerSse( + params={"url": "https://example.test/sse"}, + tool_input_guardrails=input_guardrails, + tool_output_guardrails=output_guardrails, + ), + MCPServerStreamableHttp( + params={"url": "https://example.test/mcp"}, + tool_input_guardrails=input_guardrails, + tool_output_guardrails=output_guardrails, + ), + ] + + assert all(server.tool_input_guardrails is input_guardrails for server in servers) + assert all(server.tool_output_guardrails is output_guardrails for server in servers) + + tool = MCPUtil.to_function_tool( + MCPTool(name="test", inputSchema={}), + servers[0], + convert_schemas_to_strict=False, + ) + assert tool.tool_input_guardrails == [] + assert tool.tool_output_guardrails == [] + assert tool.tool_input_guardrails is not input_guardrails + assert tool.tool_output_guardrails is not output_guardrails + + @pytest.mark.asyncio async def test_get_all_function_tools_duplicate_error_is_deterministic(): server1 = FakeMCPServer(server_name="server_1") diff --git a/tests/mcp/test_runner_calls_mcp.py b/tests/mcp/test_runner_calls_mcp.py index 670f188554..8ba9d1a8bf 100644 --- a/tests/mcp/test_runner_calls_mcp.py +++ b/tests/mcp/test_runner_calls_mcp.py @@ -10,17 +10,32 @@ ModelBehaviorError, RunContextWrapper, Runner, + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, UserError, default_tool_error_function, handoff, ) from agents.exceptions import AgentsException from agents.testing import ScriptedModel +from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail from ..test_responses import get_function_tool_call, get_text_message from .helpers import FakeMCPServer +def _model_tool_outputs(model: ScriptedModel) -> list[Any]: + values: list[Any] = [] + for item in model.calls[-1].input: + item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None) + if item_type == "function_call_output": + values.append( + item.get("output") if isinstance(item, dict) else getattr(item, "output", None) + ) + return values + + @pytest.mark.asyncio @pytest.mark.parametrize("streaming", [False, True]) async def test_runner_calls_mcp_tool(streaming: bool): @@ -55,6 +70,75 @@ async def test_runner_calls_mcp_tool(streaming: bool): assert server.tool_calls == ["test_tool_2"] +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_mcp_input_guardrail_rejection_prevents_server_call(streaming: bool): + seen_inputs: list[tuple[str, str]] = [] + + @tool_input_guardrail + def reject_input(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + seen_inputs.append((data.context.tool_name, data.context.tool_arguments)) + return ToolGuardrailFunctionOutput.reject_content("blocked MCP input") + + server = FakeMCPServer(tool_input_guardrails=[reject_input]) + server.add_tool("sensitive", {}) + model = ScriptedModel( + [ + [get_function_tool_call("sensitive", '{"secret":"value"}')], + [get_text_message("done")], + ] + ) + agent = Agent(name="test", model=model, mcp_servers=[server]) + + if streaming: + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + else: + result = await Runner.run(agent, input="user_message") + + assert result.final_output == "done" + assert server.tool_calls == [] + assert seen_inputs == [("sensitive", '{"secret":"value"}')] + assert len(result.tool_input_guardrail_results) == 1 + assert _model_tool_outputs(model) == ["blocked MCP input"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_mcp_output_guardrail_checks_converted_output_before_model_input(streaming: bool): + seen_outputs: list[Any] = [] + + @tool_output_guardrail + def reject_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + seen_outputs.append(data.output) + return ToolGuardrailFunctionOutput.reject_content("blocked MCP output") + + server = FakeMCPServer(tool_output_guardrails=[reject_output]) + server.add_tool("lookup", {}) + model = ScriptedModel( + [ + [get_function_tool_call("lookup", "{}")], + [get_text_message("done")], + ] + ) + agent = Agent(name="test", model=model, mcp_servers=[server]) + + if streaming: + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + else: + result = await Runner.run(agent, input="user_message") + + assert result.final_output == "done" + assert server.tool_calls == ["lookup"] + assert seen_outputs == [{"type": "text", "text": server.tool_results[0]}] + assert len(result.tool_output_guardrail_results) == 1 + assert _model_tool_outputs(model) == ["blocked MCP output"] + assert server.tool_results[0] not in str(model.calls[-1].input) + + @pytest.mark.asyncio @pytest.mark.parametrize("streaming", [False, True]) async def test_runner_asserts_when_mcp_tool_not_found(streaming: bool): From 6268f43e3aaf3d9ba193bff267345a8dc62f4223 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 08:15:05 +0900 Subject: [PATCH 412/473] docs: refresh tracing integration links (#4634) --- docs/tracing.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index d73a644209..9bdbb25f40 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -203,29 +203,29 @@ The following community and vendor integrations support the tracing API surface ### External tracing processors list -- [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) -- [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) +- [Weights & Biases](https://docs.wandb.ai/weave/guides/integrations/agents/openai-agents-sdk) +- [Arize Phoenix](https://arize.com/docs/phoenix/integrations/llm-providers/openai/openai-agents-sdk-tracing) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) - [MLflow (self-hosted/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow (Databricks hosted)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) -- [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) -- [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) +- [MLflow (Databricks hosted)](https://docs.databricks.com/aws/en/mlflow3/genai/tracing/integrations/openai-agent) +- [Braintrust](https://www.braintrust.dev/docs/integrations/agent-frameworks/openai-agents-sdk) +- [Pydantic Logfire](https://pydantic.dev/docs/logfire/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) -- [Scorecard](https://docs.scorecard.io/docs/documentation/features/tracing#openai-agents-sdk-integration) -- [Respan](https://respan.ai/docs/integrations/tracing/openai-agents-sdk) -- [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/trace_with_openai_agents_sdk) -- [Maxim AI](https://www.getmaxim.ai/docs/observe/integrations/openai-agents-sdk) -- [Comet Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) -- [Langfuse](https://langfuse.com/docs/integrations/openaiagentssdk/openai-agents) +- [Scorecard](https://docs.scorecard.io/features/tracing#agent-frameworks) +- [Respan](https://www.respan.ai/docs/integrations/openai-agents-sdk) +- [LangSmith](https://docs.langchain.com/langsmith/trace-openai) +- [Maxim AI](https://www.getmaxim.ai/docs/sdk/python/integrations/openai/agents-sdk) +- [Comet Opik](https://www.comet.com/docs/opik/integrations/openai_agents) +- [Langfuse](https://langfuse.com/integrations/frameworks/openai-agents) - [Langtrace](https://docs.langtrace.ai/supported-integrations/llm-frameworks/openai-agents-sdk) - [Okahu-Monocle](https://github.com/monocle2ai/monocle) -- [Galileo](https://v2docs.galileo.ai/integrations/openai-agent-integration#openai-agent-integration) +- [Galileo](https://docs.galileo.ai/how-to-guides/third-party-integrations/openai-agent-integration) - [Portkey AI](https://portkey.ai/docs/integrations/agents/openai-agents) -- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk) -- [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) -- [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) -- [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) +- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk/) +- [Agenta](https://agenta.ai/docs/observability/integrations/openai-agents) +- [PostHog](https://posthog.com/docs/ai-observability/installation/openai-agents) +- [Traccia](https://traccia.ai/docs/integrations/openai-agents/) +- [PromptLayer](https://docs.promptlayer.com/features/observability/traces/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) From 40f0d9fccbe03bf704e4ef044c7c81b807e594da Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 08:33:46 +0900 Subject: [PATCH 413/473] fix(sessions): recover failed resumed Session writes before model calls (#4630) --- .../openai_responses_compaction_session.py | 9 +- src/agents/result.py | 1 + src/agents/run.py | 3 + src/agents/run_internal/run_loop.py | 8 + .../run_internal/session_persistence.py | 87 +++- src/agents/run_state.py | 56 ++- tests/test_agent_runner_streamed.py | 2 + tests/test_run_impl_resume_paths.py | 421 +++++++++++++++++- 8 files changed, 582 insertions(+), 5 deletions(-) diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index 11cc8bb682..f09c3a6edd 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -411,7 +411,14 @@ def _clear_deferred_compaction(self) -> None: async def add_items(self, items: list[TResponseInputItem]) -> None: async with self._mutation_lock: - await self.underlying_session.add_items(items) + try: + await self.underlying_session.add_items(items) + except (Exception, asyncio.CancelledError): + # The backend may have committed before acknowledgement failed. Re-read its + # authoritative history before compaction instead of retaining a stale cache. + self._compaction_candidate_items = None + self._session_items = None + raise if self._compaction_candidate_items is not None: new_items = _normalize_compaction_session_items(items) new_candidates = select_compaction_candidate_items(new_items) diff --git a/src/agents/result.py b/src/agents/result.py index f88819df54..0ceb0d7187 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -148,6 +148,7 @@ def _populate_state_from_result( if isinstance(source_state, RunState): state._generated_prompt_cache_key = source_state._generated_prompt_cache_key state._pending_input = copy.deepcopy(source_state._pending_input) + state._pending_session_write = copy.deepcopy(source_state._pending_session_write) state._current_step = source_state._current_step else: state._generated_prompt_cache_key = getattr(result, "_generated_prompt_cache_key", None) diff --git a/src/agents/run.py b/src/agents/run.py index a782f80cce..629b5ff23f 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -139,6 +139,7 @@ persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, + resume_pending_session_write, resumed_turn_items, save_result_to_session, save_resumed_turn_items, @@ -634,6 +635,7 @@ async def _run_impl( ) context = context_wrapper.context + await resume_pending_session_write(run_state, session, wrapper=context_wrapper) max_turns = run_state._max_turns else: raw_input = cast(str | list[TResponseInputItem], input) @@ -1149,6 +1151,7 @@ def _mark_response_hooks_started() -> None: ): run_state._current_turn_persisted_item_count = ( await save_resumed_turn_items( + run_state=run_state, session=session, items=turn_session_items, persisted_count=( diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index ba0c02f351..7c22fee317 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -177,6 +177,7 @@ persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, + resume_pending_session_write, resumed_turn_items, rewind_session_items, save_result_to_session, @@ -392,6 +393,7 @@ async def _save_resumed_stream_items( ): return streamed_result._current_turn_persisted_item_count = await save_resumed_turn_items( + run_state=run_state, session=session, items=items, persisted_count=streamed_result._current_turn_persisted_item_count, @@ -920,6 +922,12 @@ async def start_streaming( run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy streamed_result._reasoning_item_id_policy = resolved_reasoning_item_id_policy + if is_resumed_state and run_state is not None: + await resume_pending_session_write(run_state, session, wrapper=context_wrapper) + streamed_result._current_turn_persisted_item_count = ( + run_state._current_turn_persisted_item_count + ) + if ( conversation_id is not None or previous_response_id is not None diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 8ebba55802..809f1648c3 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -7,6 +7,7 @@ import asyncio import copy +import hashlib import inspect import json from collections import deque @@ -60,7 +61,7 @@ strip_internal_input_item_metadata, ) from .oai_conversation import OpenAIServerConversationTracker -from .run_steps import NextStepInterruption, ProcessedResponse, SingleStepResult +from .run_steps import NextStepInterruption, NextStepRunAgain, ProcessedResponse, SingleStepResult __all__ = [ "admit_pending_input", @@ -73,6 +74,7 @@ "resumed_turn_items", "save_result_to_session", "save_resumed_turn_items", + "resume_pending_session_write", "update_run_state_after_resume", "rewind_session_items", "wait_for_session_cleanup", @@ -552,6 +554,7 @@ async def save_result_to_session( reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, + resumed_write_state: RunState | None = None, ) -> int: """ Persist a turn to the session store, keeping track of what was already saved so retries @@ -648,7 +651,20 @@ async def save_result_to_session( run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count return saved_run_items_count - await _session_add_items(session, items_to_save, wrapper=wrapper) + if resumed_write_state is not None: + if resumed_write_state._pending_session_write is not None: + raise UserError("Resolve the pending Session write before saving another batch") + resumed_write_state._pending_session_write = { + "session_id": session.session_id, + "items": copy.deepcopy(items_to_save), + "before": None, + "persisted_count": ( + resumed_write_state._current_turn_persisted_item_count + saved_run_items_count + ), + } + await resume_pending_session_write(resumed_write_state, session, wrapper=wrapper) + else: + await _session_add_items(session, items_to_save, wrapper=wrapper) if run_state is not None: run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count @@ -707,6 +723,7 @@ async def save_resumed_turn_items( reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, + run_state: RunState | None = None, ) -> int: """Persist resumed turn items and return the updated persisted count.""" if session is None or not items: @@ -720,10 +737,76 @@ async def save_resumed_turn_items( reasoning_item_id_policy=reasoning_item_id_policy, store=store, wrapper=wrapper, + resumed_write_state=( + run_state + if run_state is not None and isinstance(run_state._current_step, NextStepRunAgain) + else None + ), ) return persisted_count + saved_count +async def resume_pending_session_write( + run_state: RunState, + session: Session | None, + *, + wrapper: RunContextWrapper[Any] | None = None, +) -> None: + """Settle a resumed output batch before allowing further model work. + + The application must supply the original backend and serialize access to its history, + including independently restored RunState copies. Session has no distributed compare-and-swap + or backend identity contract. A changed tail is not repaired or searched for similar items. + """ + pending = run_state._pending_session_write + if pending is None: + return + if run_state._session_write_in_progress: + raise UserError("The pending Session write is already in progress for this RunState") + if session is None or session.session_id != pending["session_id"]: + raise UserError("Resume the pending Session write with the original Session and session ID") + + def digests(items: Sequence[TResponseInputItem]) -> list[str]: + return [ + hashlib.sha256( + _fingerprint_or_repr( + item, ignore_ids_for_matching=_ignore_ids_for_matching(session) + ).encode("utf-8") + ).hexdigest() + for item in items + ] + + run_state._session_write_in_progress = True + try: + before = pending["before"] + if before is None: + # No append has started. Retain the batch even if this first read fails. + tail = await _session_get_items( + session, limit=len(pending["items"]) + 1, wrapper=wrapper + ) + pending["before"] = digests(tail) + append = True + else: + expected = before + digests(pending["items"]) + tail = await _session_get_items(session, limit=len(expected), wrapper=wrapper) + observed = digests(tail) + committed = observed == expected + unchanged = observed[-len(before) :] == before if before else not observed + if committed == unchanged: + raise UserError( + "Cannot reconcile the pending Session write: history changed or is ambiguous. " + "Repair the original Session before resuming; do not rerun the completed tool." + ) + append = unchanged + if append: + # Backends may retain or transform their input; the durable checkpoint stays detached. + await _session_add_items(session, copy.deepcopy(pending["items"]), wrapper=wrapper) + run_state._current_turn_persisted_item_count = pending["persisted_count"] + run_state._pending_session_write = None + finally: + run_state._session_write_in_progress = False + + async def rewind_session_items( session: Session | None, items: Sequence[TResponseInputItem], diff --git a/src/agents/run_state.py b/src/agents/run_state.py index e00a73bdce..b196bbaf85 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -165,6 +165,15 @@ ] +class _PendingSessionWrite(TypedDict): + """One canonical resumed-output append awaiting acknowledgement.""" + + session_id: str + items: list[TResponseInputItem] + before: list[str] | None + persisted_count: int + + def _default_run_state_validation_error( message: str, error_type: RunStateValidationErrorType, @@ -216,7 +225,7 @@ def _default_run_state_validation_error( ), "1.17": ( "Persists Docker container labels and current-response generated-item ownership across " - "resume flows." + "resume flows, including pending resumed Session writes." ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -757,6 +766,13 @@ class RunState(Generic[TContext, TAgent]): enough information to continue an interrupted run, including model responses, generated items, approval state, and optional server-managed conversation identifiers. + A failed Session append after resumed tool work that continues to another model call remains + pending across serialization. + Resume with the original Session backend and session ID, with exclusive access to that history. + Runner reconciles the exact pending batch before the next model call without rerunning the tool. + Changed or ambiguous history requires application repair. Independently restored snapshots must + not be resumed concurrently against the same Session. + Context serialization is intentionally conservative: - Mapping contexts round-trip directly. @@ -854,6 +870,12 @@ class RunState(Generic[TContext, TAgent]): _schema_version: str = field(default=CURRENT_SCHEMA_VERSION, repr=False) """Schema version the snapshot was loaded from for schema-gated resume compatibility.""" + _pending_session_write: _PendingSessionWrite | None = field(default=None, repr=False) + """Canonical Session append that must settle before another model call.""" + + _session_write_in_progress: bool = field(default=False, repr=False) + """Live ownership guard; independent serialized copies require caller serialization.""" + def __init__( self, context: RunContextWrapper[TContext], @@ -894,6 +916,8 @@ def __init__( self._trace_state = None self._sandbox = None self._schema_version = CURRENT_SCHEMA_VERSION + self._pending_session_write = None + self._session_write_in_progress = False from .agent_tool_state import get_agent_tool_state_scope self._agent_tool_state_scope_id = get_agent_tool_state_scope(context) @@ -901,6 +925,8 @@ def __init__( def _copy_for_result_checkpoint(self) -> RunState[TContext, TAgent]: """Copy SDK-owned decision state when nesting this checkpoint in a result snapshot.""" copied = copy.copy(self) + copied._pending_session_write = copy.deepcopy(self._pending_session_write) + copied._session_write_in_progress = False if self._context is None: return copied copied._context = self._context._copy_for_run_state() @@ -1879,6 +1905,8 @@ def to_json( else None ) result["current_turn_persisted_item_count"] = self._current_turn_persisted_item_count + if self._pending_session_write is not None: + result["pending_session_write"] = copy.deepcopy(self._pending_session_write) result["trace"] = self._serialize_trace_data( include_tracing_api_key=include_tracing_api_key ) @@ -4328,6 +4356,31 @@ async def _build_run_state_from_json( state._current_turn_persisted_item_count = state_json.get( "current_turn_persisted_item_count", 0 ) + pending_write = state_json.get("pending_session_write") + if pending_write is not None: + from .run_internal.run_steps import NextStepRunAgain + + if ( + (schema_major, schema_minor) < (1, 17) + or not isinstance(state._current_step, NextStepRunAgain) + or not isinstance(pending_write, dict) + or set(pending_write) != {"session_id", "items", "before", "persisted_count"} + or not isinstance(pending_write.get("session_id"), str) + or not isinstance(pending_write.get("items"), list) + or not pending_write["items"] + or not all(isinstance(item, dict) for item in pending_write["items"]) + or ( + pending_write.get("before") is not None + and ( + not isinstance(pending_write["before"], list) + or not all(isinstance(item, str) for item in pending_write["before"]) + ) + ) + or type(pending_write.get("persisted_count")) is not int + or pending_write["persisted_count"] < 0 + ): + raise validation_error_factory("Run state pending Session write is invalid", UserError) + state._pending_session_write = copy.deepcopy(cast(_PendingSessionWrite, pending_write)) serialized_policy = state_json.get("reasoning_item_id_policy") if serialized_policy in {"preserve", "omit"}: state._reasoning_item_id_policy = cast(Literal["preserve", "omit"], serialized_policy) @@ -5591,6 +5644,7 @@ def _clone_original_input(original_input: str | list[Any]) -> str | list[Any]: ), "Run state agent not found in agent map", "Run state pending_input must be a list", + "Run state pending Session write is invalid", "Run state references an agent identity that is not present in the restored graph", ( "RunState context was serialized from a custom type; provide context_deserializer " diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 9664d65513..2923286924 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -4317,6 +4317,7 @@ async def save_wrapper( reasoning_item_id_policy: str | None = None, store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, + run_state: RunState | None = None, ) -> int: observed_counts.append(persisted_count) result = await real_save_resumed( @@ -4327,6 +4328,7 @@ async def save_wrapper( reasoning_item_id_policy=reasoning_item_id_policy, store=store, wrapper=wrapper, + run_state=run_state, ) return int(result) diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index c518c95ee0..9a4d88f061 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -1,6 +1,9 @@ import asyncio +import copy import json -from typing import Any, cast +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Literal, cast import pytest from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage @@ -9,14 +12,18 @@ from agents import Agent, Runner, function_tool from agents.agent import ToolsToFinalOutputResult from agents.agent_output import AgentOutputSchema +from agents.decorators import tool +from agents.exceptions import UserError from agents.items import ( MessageOutputItem, ModelResponse, ToolApprovalItem, ToolCallItem, ToolCallOutputItem, + TResponseInputItem, ) from agents.lifecycle import RunHooks +from agents.memory import OpenAIResponsesCompactionSession, Session, SQLiteSession from agents.run import RunConfig from agents.run_context import RunContextWrapper from agents.run_internal import run_loop, turn_resolution @@ -42,6 +49,418 @@ from tests.utils.simple_session import SimpleListSession +class _FailingResumeSession(SimpleListSession): + """Control append acknowledgement at the public Session boundary.""" + + def __init__(self) -> None: + super().__init__() + self.failure: str | None = None + self.error = RuntimeError("session append failed") + self.block_next_add = False + self.add_started = asyncio.Event() + self.release_add = asyncio.Event() + + async def add_items(self, items: list[TResponseInputItem]) -> None: + failure, self.failure = self.failure, None + if failure == "before": + raise self.error + if self.block_next_add: + self.block_next_add = False + self.add_started.set() + await self.release_add.wait() + if failure == "partial": + await super().add_items(items[:1]) + raise self.error + await super().add_items(items) + if failure == "after": + raise self.error + + +class _LostAckSQLiteSession(SQLiteSession): + fail_after_commit = False + error = RuntimeError("session append failed") + + async def add_items(self, items: list[TResponseInputItem]) -> None: + await super().add_items(items) + if self.fail_after_commit: + self.fail_after_commit = False + raise self.error + + +async def _run_session_resume( + agent: Agent[Any], value: str | RunState[Any], session: Session | None, streamed: bool +): + config = RunConfig(tracing_disabled=True) + if not streamed: + return await Runner.run(agent, value, session=session, run_config=config) + result = Runner.run_streamed(agent, value, session=session, run_config=config) + async for _ in result.stream_events(): + pass + return result + + +async def _approved_session_state(streamed: bool, session: Session | None = None): + effects: list[int] = [] + + @tool(needs_approval=True) + async def charge(amount: int) -> str: + effects.append(amount) + return "receipt-7" + + model = ScriptedModel( + [ + [get_function_tool_call("charge", '{"amount":7}', call_id="charge-1")], + [get_text_message("done")], + [get_text_message("fresh")], + ] + ) + agent = Agent(name="payment", model=model, tools=[charge]) + session = session if session is not None else _FailingResumeSession() + paused = await _run_session_resume(agent, "charge 7", session, streamed) + state = paused.to_state() + state.approve(state.get_interruptions()[0]) + return agent, model, session, state, effects + + +def _charge_pair(items: list[TResponseInputItem]) -> list[str]: + return [ + str(item.get("type")) + for item in items + if isinstance(item, dict) and item.get("call_id") == "charge-1" + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failing_streamed,retry_streamed", [(False, False), (False, True), (True, False), (True, True)] +) +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "json"]) +@pytest.mark.parametrize("failure", ["before", "after"], ids=["atomic-failure", "lost-ack"]) +async def test_resumed_session_append_is_recovered_before_next_model( + failing_streamed: bool, retry_streamed: bool, round_trip: bool, failure: str +) -> None: + agent, model, session, state, effects = await _approved_session_state(failing_streamed) + session.failure = failure + with pytest.raises(RuntimeError) as error: + await _run_session_resume(agent, state, session, failing_streamed) + assert error.value is session.error + assert effects == [7] + assert len(model.calls) == 1 + if round_trip: + state = await RunState.from_json(agent, state.to_json()) + + result = await _run_session_resume(agent, state, session, retry_streamed) + assert result.final_output == "done" + assert effects == [7] + expected_pair = ["function_call", "function_call_output"] + assert _charge_pair(await session.get_items()) == expected_pair + assert _charge_pair(result.to_input_list()) == expected_pair + await _run_session_resume(agent, "What was the receipt?", session, retry_streamed) + assert _charge_pair(model.calls[-1].input) == expected_pair + assert "pending_session_write" not in result.to_state().to_json() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("retry_streamed", [False, True]) +@pytest.mark.parametrize("mismatch", ["missing", "different-id", "changed-tail"]) +async def test_resumed_session_append_rejects_ambiguous_recovery( + retry_streamed: bool, mismatch: str +) -> None: + agent, model, session, state, effects = await _approved_session_state(False) + session.failure = "before" + with pytest.raises(RuntimeError, match="session append failed"): + await _run_session_resume(agent, state, session, False) + state = await RunState.from_json(agent, state.to_json()) + supplied_session: Session | None = session + if mismatch == "missing": + supplied_session = None + elif mismatch == "different-id": + supplied_session = SimpleListSession("other", await session.get_items()) + else: + await session.add_items([{"role": "user", "content": "another writer"}]) + before = await session.get_items() + with pytest.raises(UserError, match="pending Session write"): + await _run_session_resume(agent, state, supplied_session, retry_streamed) + assert len(model.calls) == 1 + assert effects == [7] + assert await session.get_items() == before + + +@pytest.mark.asyncio +async def test_resumed_session_append_survives_repeated_failure_and_late_input() -> None: + agent, model, session, state, effects = await _approved_session_state(False) + for _ in range(2): + session.failure = "before" + with pytest.raises(RuntimeError, match="session append failed"): + await _run_session_resume(agent, state, session, False) + state = await RunState.from_json(agent, state.to_json()) + assert len(model.calls) == 1 + assert effects == [7] + state.add_input("What was the receipt?") + result = await _run_session_resume(agent, state, session, True) + assert result.final_output == "done" + stored = await session.get_items() + output_index = next( + i for i, item in enumerate(stored) if item.get("type") == "function_call_output" + ) + late_index = next( + i for i, item in enumerate(stored) if item.get("content") == "What was the receipt?" + ) + assert output_index < late_index + assert effects == [7] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "json"]) +async def test_resumed_committed_append_refreshes_compaction_input( + streamed: bool, round_trip: bool, tmp_path: Path +) -> None: + backend = _LostAckSQLiteSession("compaction-recovery", tmp_path / "history.db") + compaction_inputs: list[list[TResponseInputItem]] = [] + compact_enabled = False + + async def compact(**kwargs: Any) -> SimpleNamespace: + items = copy.deepcopy(kwargs["input"]) + compaction_inputs.append(items) + return SimpleNamespace(output=items, usage=None) + + session = OpenAIResponsesCompactionSession( + backend.session_id, + underlying_session=backend, + client=cast(Any, SimpleNamespace(responses=SimpleNamespace(compact=compact))), + compaction_mode="input", + should_trigger_compaction=lambda _: compact_enabled, + ) + try: + agent, model, _, state, effects = await _approved_session_state(streamed, session) + # A normal declined compaction initializes the retained wrapper's history cache. + await session.run_compaction() + assert compaction_inputs == [] + backend.fail_after_commit = True + with pytest.raises(RuntimeError) as error: + await _run_session_resume(agent, state, session, streamed) + assert error.value is backend.error + expected_pair = ["function_call", "function_call_output"] + assert _charge_pair(await backend.get_items(limit=100)) == expected_pair + if round_trip: + state = await RunState.from_json(agent, state.to_json()) + + compact_enabled = True + result = await _run_session_resume(agent, state, session, streamed) + assert result.final_output == "done" + assert effects == [7] + assert len(model.calls) == 2 + assert len(compaction_inputs) == 1 + assert _charge_pair(compaction_inputs[0]) == expected_pair + assert _charge_pair(await backend.get_items(limit=100)) == expected_pair + assert _charge_pair(result.to_input_list()) == expected_pair + assert "pending_session_write" not in result.to_state().to_json() + finally: + backend.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["input", "auto"]) +async def test_compaction_reload_preserves_session_retrieval_window( + mode: Literal["input", "auto"], tmp_path: Path +) -> None: + backend = _LostAckSQLiteSession( + "bounded-compaction", tmp_path / "history.db", session_settings={"limit": 1} + ) + compaction_inputs: list[list[TResponseInputItem]] = [] + + async def compact(**kwargs: Any) -> SimpleNamespace: + assert "previous_response_id" not in kwargs + items = copy.deepcopy(kwargs["input"]) + compaction_inputs.append(items) + return SimpleNamespace(output=items, usage=None) + + session = OpenAIResponsesCompactionSession( + backend.session_id, + underlying_session=backend, + client=cast(Any, SimpleNamespace(responses=SimpleNamespace(compact=compact))), + compaction_mode=mode, + ) + old_items: list[TResponseInputItem] = [ + {"role": "assistant", "content": f"old message {index}"} for index in range(12) + ] + recovered_item: TResponseInputItem = {"role": "assistant", "content": "committed reply"} + try: + await backend.add_items(old_items) + # The configured window has one candidate, so the default threshold is not met. + await session.run_compaction({"response_id": "unstored-response", "store": False}) + assert compaction_inputs == [] + assert await backend.get_items(limit=100) == old_items + + backend.fail_after_commit = True + with pytest.raises(RuntimeError) as error: + await session.add_items([recovered_item]) + assert error.value is backend.error + assert await backend.get_items(limit=100) == [*old_items, recovered_item] + + await session.run_compaction({"force": True, "store": False}) + assert compaction_inputs == [[recovered_item]] + assert await backend.get_items(limit=100) == [recovered_item] + finally: + backend.close() + + +@pytest.mark.asyncio +async def test_cancelled_compaction_append_preserves_committed_and_surviving_writes() -> None: + appended = asyncio.Event() + wait_for_ack = asyncio.Event() + + class DelayedAckSession(SimpleListSession): + delay_next_ack = True + + async def add_items(self, items: list[TResponseInputItem]) -> None: + await super().add_items(items) + if self.delay_next_ack: + self.delay_next_ack = False + appended.set() + await wait_for_ack.wait() + + backend = DelayedAckSession() + compaction_inputs: list[list[TResponseInputItem]] = [] + + async def compact(**kwargs: Any) -> SimpleNamespace: + items = copy.deepcopy(kwargs["input"]) + compaction_inputs.append(items) + return SimpleNamespace(output=items, usage=None) + + session = OpenAIResponsesCompactionSession( + backend.session_id, + underlying_session=backend, + client=cast(Any, SimpleNamespace(responses=SimpleNamespace(compact=compact))), + compaction_mode="input", + should_trigger_compaction=lambda _: False, + ) + await session.run_compaction() + first_item: TResponseInputItem = {"role": "user", "content": "committed before cancellation"} + newer_item: TResponseInputItem = {"role": "user", "content": "surviving writer"} + first = asyncio.create_task(session.add_items([first_item])) + newer: asyncio.Task[None] | None = None + newer_started = asyncio.Event() + + async def write_newer() -> None: + newer_started.set() + await session.add_items([newer_item]) + + try: + await asyncio.wait_for(appended.wait(), timeout=5) + newer = asyncio.create_task(write_newer()) + await asyncio.wait_for(newer_started.wait(), timeout=5) + assert not newer.done() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + await asyncio.wait_for(newer, timeout=5) + await session.run_compaction({"force": True}) + assert compaction_inputs == [[first_item, newer_item]] + assert await backend.get_items() == [first_item, newer_item] + finally: + wait_for_ack.set() + tasks = [first, *([newer] if newer is not None else [])] + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_resumed_session_append_cancellation_retains_recoverable_state( + streamed: bool, +) -> None: + agent, model, session, state, effects = await _approved_session_state(streamed) + session.block_next_add = True + attempt = asyncio.create_task(_run_session_resume(agent, state, session, streamed)) + try: + await asyncio.wait_for(session.add_started.wait(), timeout=5) + with pytest.raises(UserError, match="pending Session write is already in progress"): + await _run_session_resume(agent, state, session, not streamed) + assert len(model.calls) == 1 + attempt.cancel() + with pytest.raises(asyncio.CancelledError): + await attempt + finally: + session.release_add.set() + if not attempt.done(): + attempt.cancel() + await asyncio.gather(attempt, return_exceptions=True) + + restored = await RunState.from_json(agent, state.to_json()) + result = await _run_session_resume(agent, restored, session, not streamed) + assert result.final_output == "done" + assert effects == [7] + assert _charge_pair(await session.get_items()) == ["function_call", "function_call_output"] + + +@pytest.mark.asyncio +async def test_failed_streamed_result_checkpoint_retains_detached_pending_write() -> None: + agent, model, session, state, effects = await _approved_session_state(True) + session.failure = "before" + result = Runner.run_streamed(agent, state, session=session) + with pytest.raises(RuntimeError, match="session append failed"): + async for _ in result.stream_events(): + pass + snapshot = result.to_state() + payload = snapshot.to_json() + payload["pending_session_write"]["items"][0]["output"] = "changed snapshot" + assert state.to_json()["pending_session_write"]["items"][0]["output"] == "receipt-7" + assert snapshot.to_json()["pending_session_write"]["items"][0]["output"] == "receipt-7" + await _run_session_resume(agent, snapshot, session, False) + assert effects == [7] + assert len(model.calls) == 2 + assert _charge_pair(await session.get_items()) == ["function_call", "function_call_output"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid", ["old-schema", "batch-shape"]) +async def test_pending_session_write_rejects_invalid_serialized_checkpoint(invalid: str) -> None: + agent, _, session, state, _ = await _approved_session_state(False) + session.failure = "before" + with pytest.raises(RuntimeError): + await _run_session_resume(agent, state, session, False) + payload = state.to_json() + if invalid == "old-schema": + payload["$schemaVersion"] = "1.16" + else: + payload["pending_session_write"]["items"] = "not an item batch" + with pytest.raises(UserError, match="pending Session write is invalid"): + await RunState.from_json(agent, payload) + + +@pytest.mark.asyncio +async def test_resumed_session_append_partial_commit_fails_closed() -> None: + agent, model, session, state, effects = await _approved_session_state(False) + # Two approved calls produce one resumed batch, allowing an actual partial append. + second_call = get_function_tool_call("charge", '{"amount":7}', call_id="charge-2") + model = ScriptedModel( + [ + [get_function_tool_call("charge", '{"amount":7}', call_id="charge-1"), second_call], + [get_text_message("done")], + ] + ) + agent.model = model + session = _FailingResumeSession() + paused = await _run_session_resume(agent, "charge twice", session, False) + state = paused.to_state() + for interruption in state.get_interruptions(): + state.approve(interruption) + session.failure = "partial" + with pytest.raises(RuntimeError, match="session append failed"): + await _run_session_resume(agent, state, session, False) + before = await session.get_items() + restored = await RunState.from_json(agent, state.to_json()) + with pytest.raises(UserError, match="history changed or is ambiguous"): + await _run_session_resume(agent, restored, session, True) + assert effects == [7, 7] + assert len(model.calls) == 1 + assert await session.get_items() == before + + @pytest.mark.asyncio async def test_resolve_interrupted_turn_final_output_short_circuit(monkeypatch) -> None: agent: Agent[dict[str, str]] = make_agent(model=ScriptedModel()) From 150a4f47d6befb720e00789ca544fb9d9513d697 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:10:36 +0100 Subject: [PATCH 414/473] fix(voice): support context in single-agent workflow (#4636) --- src/agents/voice/workflow.py | 18 +++++++++++++++--- tests/voice/test_workflow.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/agents/voice/workflow.py b/src/agents/voice/workflow.py index b3b9734e98..68b9cb82ef 100644 --- a/src/agents/voice/workflow.py +++ b/src/agents/voice/workflow.py @@ -2,12 +2,12 @@ import abc from collections.abc import AsyncIterator -from typing import Any from ..agent import Agent from ..items import TResponseInputItem from ..result import RunResultStreaming from ..run import Runner +from ..run_context import TContext class VoiceWorkflowBase(abc.ABC): @@ -66,16 +66,24 @@ class SingleAgentVoiceWorkflow(VoiceWorkflowBase): custom configs), subclass `VoiceWorkflowBase` and implement your own logic. """ - def __init__(self, agent: Agent[Any], callbacks: SingleAgentWorkflowCallbacks | None = None): + def __init__( + self, + agent: Agent[TContext], + callbacks: SingleAgentWorkflowCallbacks | None = None, + *, + context: TContext | None = None, + ): """Create a new single agent voice workflow. Args: agent: The agent to run. callbacks: Optional callbacks to call during the workflow. + context: Optional application context forwarded to every agent run. """ self._input_history: list[TResponseInputItem] = [] self._current_agent = agent self._callbacks = callbacks + self._context = context async def run(self, transcription: str) -> AsyncIterator[str]: if self._callbacks is not None: @@ -90,7 +98,11 @@ async def run(self, transcription: str) -> AsyncIterator[str]: ) # Run the agent - result = Runner.run_streamed(self._current_agent, self._input_history) + result = Runner.run_streamed( + self._current_agent, + self._input_history, + context=self._context, + ) # Stream the text from the result async for chunk in VoiceWorkflowHelper.stream_text_from(result): diff --git a/tests/voice/test_workflow.py b/tests/voice/test_workflow.py index af27007962..2f561a2a15 100644 --- a/tests/voice/test_workflow.py +++ b/tests/voice/test_workflow.py @@ -5,7 +5,8 @@ import pytest from inline_snapshot import snapshot -from agents import Agent +from agents import Agent, RunContextWrapper +from agents.decorators import tool from agents.testing import ScriptedModel from ..test_responses import get_function_tool, get_function_tool_call, get_text_message @@ -136,3 +137,35 @@ async def test_single_agent_workflow(monkeypatch) -> None: ] ) assert workflow._current_agent == agent + + +@pytest.mark.asyncio +async def test_single_agent_workflow_forwards_context_on_every_turn() -> None: + @tool + def read_user_id(ctx: RunContextWrapper[dict[str, str]]) -> str: + """Return the current user ID.""" + return ctx.context["user_id"] + + model = ScriptedModel() + model.extend( + [ + [get_function_tool_call("read_user_id", "{}", call_id="context_call_1")], + [get_text_message("first turn done")], + [get_function_tool_call("read_user_id", "{}", call_id="context_call_2")], + [get_text_message("second turn done")], + ] + ) + agent = Agent("context_agent", model=model, tools=[read_user_id]) + workflow = SingleAgentVoiceWorkflow(agent, context={"user_id": "user-123"}) + + first_output = [chunk async for chunk in workflow.run("first transcription")] + second_output = [chunk async for chunk in workflow.run("second transcription")] + + assert first_output == ["first turn done"] + assert second_output == ["second turn done"] + tool_outputs = [ + item["output"] + for item in workflow._input_history + if item.get("type") == "function_call_output" + ] + assert tool_outputs == ["user-123", "user-123"] From 91f8c490a99253205b082b1b29d2d65f123a7998 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 09:13:04 +0900 Subject: [PATCH 415/473] feat(sandbox): add configurable Unix-local environment isolation (#4640) Co-authored-by: simpleqt <89645338+simpleqt@users.noreply.github.com> --- src/agents/sandbox/sandboxes/unix_local.py | 56 ++++++++- tests/sandbox/test_unix_local.py | 139 ++++++++++++++++++++- 2 files changed, 192 insertions(+), 3 deletions(-) diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index d0c2ea28b7..5cc77e2aff 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -21,7 +21,7 @@ import time import uuid from collections import deque -from collections.abc import Mapping, Sequence +from collections.abc import Collection, Mapping, Sequence from contextlib import suppress from dataclasses import dataclass, field from functools import partial @@ -74,6 +74,30 @@ _PTY_READ_CHUNK_BYTES = 16_384 _PTY_CHILD_SIGNAL_DEFAULTS = (signal.SIGINT, signal.SIGQUIT) _PTY_FD_CLOSE_GRACE_SECONDS = 0.1 +_HOST_ENVIRONMENT_ALLOWLIST = frozenset( + { + "PATH", + "LANG", + "LC_ALL", + "LC_COLLATE", + "LC_CTYPE", + "LC_MESSAGES", + "LC_MONETARY", + "LC_NUMERIC", + "LC_TIME", + "TZ", + "TERM", + "TMPDIR", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "UV_PYTHON", + "NO_COLOR", + "FORCE_COLOR", + "CI", + } +) logger = logging.getLogger(__name__) @@ -139,6 +163,7 @@ class UnixLocalSandboxSession(BaseSandboxSession): _pty_processes: dict[int, _UnixPtyProcessEntry] _reserved_pty_process_ids: set[int] _fd_close_tasks: set[asyncio.Task[None]] + _host_environment_allowlist: frozenset[str] | None def __init__(self, *, state: UnixLocalSandboxSessionState) -> None: self.state = state @@ -147,6 +172,7 @@ def __init__(self, *, state: UnixLocalSandboxSessionState) -> None: self._pty_processes = {} self._reserved_pty_process_ids = set() self._fd_close_tasks = set() + self._host_environment_allowlist = None @classmethod def from_state(cls, state: UnixLocalSandboxSessionState) -> "UnixLocalSandboxSession": @@ -440,7 +466,14 @@ async def pty_terminate_all(self) -> None: await self._terminate_pty_entry(entry) async def _resolved_exec_context(self) -> tuple[dict[str, str], str]: - env = os.environ.copy() + if self._host_environment_allowlist is None: + env = dict(os.environ) + else: + env = { + name: value + for name, value in os.environ.items() + if name in self._host_environment_allowlist + } env.update(await self.state.manifest.environment.resolve()) workspace = Path(self.state.manifest.root) @@ -1099,11 +1132,26 @@ def __init__( *, instrumentation: Instrumentation | None = None, dependencies: Dependencies | None = None, + inherit_host_environment: bool = True, + host_environment_allowlist: Collection[str] | None = None, ) -> None: + if inherit_host_environment and host_environment_allowlist is not None: + raise ValueError("host_environment_allowlist requires inherit_host_environment=False") + if isinstance(host_environment_allowlist, str): + raise TypeError("host_environment_allowlist must be a collection of variable names") + self._instrumentation = ( instrumentation if instrumentation is not None else Instrumentation() ) self._dependencies = dependencies + if inherit_host_environment: + self._host_environment_allowlist = None + else: + self._host_environment_allowlist = frozenset( + _HOST_ENVIRONMENT_ALLOWLIST + if host_environment_allowlist is None + else host_environment_allowlist + ) @redact_mount_error_data async def create( @@ -1136,6 +1184,9 @@ async def create( exposed_ports=resolved_options.exposed_ports, ) inner = UnixLocalSandboxSession.from_state(state) + # Keep host inheritance policy under trusted runtime control. Session state and manifests + # must not be able to change it when a session is resumed by another client. + inner._host_environment_allowlist = self._host_environment_allowlist return self._wrap_session(inner, instrumentation=self._instrumentation) async def delete(self, session: SandboxSession) -> SandboxSession: @@ -1177,6 +1228,7 @@ async def resume( state.assert_path_grants_rebound() _assert_unix_local_host_path_grants_unsupported(state.manifest) inner = UnixLocalSandboxSession.from_state(state) + inner._host_environment_allowlist = self._host_environment_allowlist return self._wrap_session(inner, instrumentation=self._instrumentation) def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 67ea2416ed..c8e9c654a1 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -10,7 +10,8 @@ from agents.sandbox import SandboxPathGrant from agents.sandbox.errors import PtySessionNotFoundError -from agents.sandbox.manifest import Manifest +from agents.sandbox.manifest import Environment, Manifest +from agents.sandbox.sandboxes import unix_local as unix_local_module from agents.sandbox.sandboxes.unix_local import ( UnixLocalSandboxClient, UnixLocalSandboxSession, @@ -41,6 +42,142 @@ async def _exec_internal( return ExecResult(stdout=b"", stderr=b"", exit_code=0) +@pytest.mark.asyncio +async def test_unix_local_inherits_host_environment_by_default( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(unix_local_module.sys, "platform", "linux") + monkeypatch.setenv("OPENAI_API_KEY", "host-secret") + monkeypatch.setenv("LC_MESSAGES", "C") + monkeypatch.setenv("LC_PRIVATE_TOKEN", "locale-secret") + workspace = tmp_path / "workspace" + manifest = Manifest( + root=str(workspace), + environment=Environment( + value={ + "HOME": "/manifest-home", + "LC_CTYPE": "POSIX", + "MANIFEST_ONLY": "configured", + } + ), + ) + + async with await UnixLocalSandboxClient().create( + manifest=manifest, snapshot=None, options=None + ) as session: + result = await session.exec( + "sh", + "-c", + "printf '%s|%s|%s|%s|%s|%s|%s' " + '"${OPENAI_API_KEY-unset}" "$MANIFEST_ONLY" "$HOME" ' + '"${PATH:+set}" "$LC_MESSAGES" "$LC_CTYPE" ' + '"${LC_PRIVATE_TOKEN-unset}"', + shell=False, + ) + + assert result.exit_code == 0 + assert result.stdout.decode() == ( + f"host-secret|configured|{workspace}|set|C|POSIX|locale-secret" + ) + + +@pytest.mark.asyncio +async def test_unix_local_uses_default_allowlist_when_inheritance_is_disabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(unix_local_module.sys, "platform", "linux") + monkeypatch.setenv("HOST_ONLY_VALUE", "host-value") + monkeypatch.setenv("LC_MESSAGES", "C") + monkeypatch.setenv("LC_PRIVATE_TOKEN", "locale-secret") + manifest = Manifest(root=str(tmp_path / "workspace")) + isolated_client = UnixLocalSandboxClient(inherit_host_environment=False) + + async with await isolated_client.create( + manifest=manifest, snapshot=None, options=None + ) as session: + created = await session.exec( + "sh", + "-c", + "printf '%s|%s|%s' " + '"${HOST_ONLY_VALUE-unset}" "$LC_MESSAGES" ' + '"${LC_PRIVATE_TOKEN-unset}"', + shell=False, + ) + state = session.state + + payload = isolated_client.serialize_session_state(state) + assert "inherit_host_environment" not in payload + assert "host_environment_allowlist" not in payload + assert created.stdout == b"unset|C|unset" + + async with await isolated_client.resume(state) as resumed: + isolated_after_resume = await resumed.exec( + "sh", "-c", 'printf "%s" "${HOST_ONLY_VALUE-unset}"', shell=False + ) + assert isolated_after_resume.stdout == b"unset" + + async with await UnixLocalSandboxClient().resume(state) as resumed_with_default: + inherited_after_resume = await resumed_with_default.exec( + "sh", "-c", 'printf "%s" "${HOST_ONLY_VALUE-unset}"', shell=False + ) + assert inherited_after_resume.stdout == b"host-value" + + +@pytest.mark.asyncio +async def test_unix_local_uses_custom_host_environment_allowlist( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(unix_local_module.sys, "platform", "linux") + monkeypatch.setenv("CUSTOM_ALLOWED", "allowed-value") + monkeypatch.setenv("HOST_ONLY_VALUE", "host-value") + manifest = Manifest(root=str(tmp_path / "workspace")) + client = UnixLocalSandboxClient( + inherit_host_environment=False, + host_environment_allowlist={"PATH", "CUSTOM_ALLOWED"}, + ) + + async with await client.create(manifest=manifest, snapshot=None, options=None) as session: + result = await session.exec( + "sh", + "-c", + 'printf \'%s|%s\' "$CUSTOM_ALLOWED" "${HOST_ONLY_VALUE-unset}"', + shell=False, + ) + state = session.state + + assert result.stdout == b"allowed-value|unset" + + async with await client.resume(state) as resumed: + resumed_result = await resumed.exec( + "sh", + "-c", + 'printf \'%s|%s\' "$CUSTOM_ALLOWED" "${HOST_ONLY_VALUE-unset}"', + shell=False, + ) + + assert resumed_result.stdout == b"allowed-value|unset" + + +def test_unix_local_rejects_invalid_host_environment_allowlist_configuration() -> None: + with pytest.raises( + ValueError, + match="host_environment_allowlist requires inherit_host_environment=False", + ): + UnixLocalSandboxClient(host_environment_allowlist={"PATH"}) + + with pytest.raises( + TypeError, + match="host_environment_allowlist must be a collection of variable names", + ): + UnixLocalSandboxClient( + inherit_host_environment=False, + host_environment_allowlist="PATH", + ) + + @pytest.mark.asyncio async def test_unix_local_rejects_host_path_before_creating_workspace( tmp_path: Path, From f265bae41c8d31ba18e82109f60f8d01fd5fad3c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 10:23:02 +0900 Subject: [PATCH 416/473] fix(tracing): preserve response IDs in redacted traces (#4641) --- src/agents/models/openai_responses.py | 30 ++++ src/agents/tracing/span_data.py | 5 +- tests/test_responses_tracing.py | 221 ++++++++++++++++++++++++-- 3 files changed, 243 insertions(+), 13 deletions(-) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 75987c175d..87029d83e5 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -502,6 +502,9 @@ def __init__( def _non_null_or_omit(self, value: Any) -> Any: return value if value is not None else omit + def _uses_official_openai_endpoint(self) -> bool: + return is_official_openai_client(self._get_client()) + def _supports_default_prompt_cache_key(self) -> bool: return is_official_openai_client(self._get_client()) @@ -572,6 +575,11 @@ async def get_response( ) -> ModelResponse: with response_span(disabled=tracing.is_disabled()) as span_response: try: + redacted_response_id_endpoint_is_trusted = ( + not tracing.include_data() + and not tracing.is_disabled() + and self._uses_official_openai_endpoint() + ) response = await self._fetch_response( system_instructions, input, @@ -604,6 +612,11 @@ async def get_response( if tracing.include_data(): span_response.span_data.response = response span_response.span_data.input = input + elif ( + redacted_response_id_endpoint_is_trusted + and self._uses_official_openai_endpoint() + ): + span_response.span_data._response_id = response.id except asyncio.CancelledError: record_current_task_model_timeout_on_span( span_response, @@ -658,6 +671,11 @@ async def stream_response( """ with response_span(disabled=tracing.is_disabled()) as span_response: try: + redacted_response_id_endpoint_is_trusted = ( + not tracing.include_data() + and not tracing.is_disabled() + and self._uses_official_openai_endpoint() + ) stream = await self._fetch_response( system_instructions, input, @@ -680,6 +698,11 @@ async def stream_response( chunk_type = getattr(chunk, "type", None) if isinstance(chunk, ResponseCompletedEvent): final_response = chunk.response + if ( + redacted_response_id_endpoint_is_trusted + and self._uses_official_openai_endpoint() + ): + span_response.span_data._response_id = chunk.response.id if model_settings.preserve_raw_usage is True: _attach_raw_usage_snapshot(chunk.response, chunk.response.usage) usage = _usage_from_response(chunk.response) @@ -1111,6 +1134,13 @@ def __init__( ) self._ws_client_close_generation = 0 + def _uses_official_openai_endpoint(self) -> bool: + base_url = prepare_openai_client_websocket_base_url( + self._client, + context="Responses websocket", + ) + return is_official_openai_base_url(base_url, websocket=True) + def _supports_default_prompt_cache_key(self) -> bool: if self._client.websocket_base_url is not None: return is_official_openai_base_url(self._client.websocket_base_url, websocket=True) diff --git a/src/agents/tracing/span_data.py b/src/agents/tracing/span_data.py index 872388a736..57b7fe6226 100644 --- a/src/agents/tracing/span_data.py +++ b/src/agents/tracing/span_data.py @@ -215,7 +215,7 @@ class ResponseSpanData(SpanData): Includes response and input. """ - __slots__ = ("response", "input", "usage") + __slots__ = ("response", "input", "usage", "_response_id") def __init__( self, @@ -228,6 +228,7 @@ def __init__( # processor implementations self.input = input self.usage = usage + self._response_id: str | None = None @property def type(self) -> str: @@ -236,7 +237,7 @@ def type(self) -> str: def export(self) -> dict[str, Any]: return { "type": self.type, - "response_id": self.response.id if self.response is not None else None, + "response_id": (self.response.id if self.response is not None else self._response_id), "usage": self.usage, } diff --git a/tests/test_responses_tracing.py b/tests/test_responses_tracing.py index 71124d047d..c13253238a 100644 --- a/tests/test_responses_tracing.py +++ b/tests/test_responses_tracing.py @@ -1,10 +1,19 @@ +from typing import Any, cast + import pytest from inline_snapshot import snapshot from openai import AsyncOpenAI from openai.types.responses import ResponseCompletedEvent from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails -from agents import ModelBehaviorError, ModelSettings, ModelTracing, OpenAIResponsesModel, trace +from agents import ( + ModelBehaviorError, + ModelSettings, + ModelTracing, + OpenAIResponsesModel, + OpenAIResponsesWSModel, + trace, +) from agents.tracing.span_data import ResponseSpanData from tests import model_test_helpers @@ -120,10 +129,13 @@ async def dummy_fetch_response( @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_non_data_tracing_doesnt_set_response_id(monkeypatch): +async def test_non_data_tracing_preserves_response_id_without_response(monkeypatch): with trace(workflow_name="test"): # Create an instance of the model - model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + model = OpenAIResponsesModel( + model="test-model", + openai_client=AsyncOpenAI(api_key="test", base_url="https://api.openai.com/v1"), + ) # Mock _fetch_response to return a dummy response with a known id async def dummy_fetch_response( @@ -162,6 +174,7 @@ async def dummy_fetch_response( { "type": "response", "data": { + "response_id": "dummy-id", "usage": { "requests": 1, "input_tokens": 1, @@ -172,7 +185,7 @@ async def dummy_fetch_response( "cache_write_tokens": 0, }, "output_tokens_details": {"reasoning_tokens": 0}, - } + }, }, } ], @@ -182,6 +195,57 @@ async def dummy_fetch_response( [span] = fetch_ordered_spans() assert span.span_data.response is None + assert span.span_data.input is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_non_data_tracing_omits_custom_endpoint_response_id(monkeypatch): + provider_response_id = "tenant-customer-123" + with trace(workflow_name="test"): + client = AsyncOpenAI(api_key="test", base_url="https://provider.example.test/v1") + model = OpenAIResponsesModel( + model="test-model", + openai_client=client, + ) + + async def dummy_fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + previous_response_id, + conversation_id, + stream, + prompt, + ): + response = DummyResponse() + response.id = provider_response_id + client.base_url = "https://api.openai.com/v1" + return response + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + model_response = await model.get_response( + "instr", + "input", + ModelSettings(), + [], + None, + [], + ModelTracing.ENABLED_WITHOUT_DATA, + previous_response_id=None, + ) + + assert model_response.response_id == provider_response_id + [span] = fetch_ordered_spans() + assert isinstance(span.span_data, ResponseSpanData) + assert span.span_data.export()["response_id"] is None + assert span.span_data.response is None + assert span.span_data.input is None + assert span.span_data.usage is not None @pytest.mark.allow_call_model_methods @@ -369,10 +433,16 @@ async def __aiter__(self): @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_non_data_tracing_doesnt_set_response_id(monkeypatch): +@pytest.mark.parametrize("close_at_completed", [False, True]) +async def test_stream_non_data_tracing_preserves_response_id_without_response( + monkeypatch, close_at_completed: bool +): with trace(workflow_name="test"): # Create an instance of the model - model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + model = OpenAIResponsesModel( + model="test-model", + openai_client=AsyncOpenAI(api_key="test", base_url="https://api.openai.com/v1"), + ) # Define a dummy fetch function that returns an async stream with a dummy response async def dummy_fetch_response( @@ -399,8 +469,7 @@ async def __aiter__(self): monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) - # Consume the stream to trigger processing of the final response - async for _ in model.stream_response( + stream = model.stream_response( "instr", "input", ModelSettings(), @@ -409,8 +478,15 @@ async def __aiter__(self): [], ModelTracing.ENABLED_WITHOUT_DATA, previous_response_id=None, - ): - pass + ) + if close_at_completed: + stream_agen = cast(Any, stream) + event = await stream_agen.__anext__() + assert event.type == "response.completed" + await stream_agen.aclose() + else: + async for _ in stream: + pass assert fetch_normalized_spans() == snapshot( [ @@ -420,6 +496,7 @@ async def __aiter__(self): { "type": "response", "data": { + "response_id": "dummy-id-123", "usage": { "requests": 1, "input_tokens": 0, @@ -430,7 +507,7 @@ async def __aiter__(self): "cache_write_tokens": 0, }, "output_tokens_details": {"reasoning_tokens": 0}, - } + }, }, } ], @@ -441,6 +518,128 @@ async def __aiter__(self): [span] = fetch_ordered_spans() assert isinstance(span.span_data, ResponseSpanData) assert span.span_data.response is None + assert span.span_data.input is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_non_data_tracing_omits_custom_endpoint_response_id(monkeypatch): + provider_response_id = "tenant-customer-123" + with trace(workflow_name="test"): + client = AsyncOpenAI(api_key="test", base_url="https://provider.example.test/v1") + model = OpenAIResponsesModel( + model="test-model", + openai_client=client, + ) + + async def dummy_fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + previous_response_id, + conversation_id, + stream, + prompt, + ): + class DummyStream: + async def __aiter__(self): + client.base_url = "https://api.openai.com/v1" + yield ResponseCompletedEvent( + type="response.completed", + response=model_test_helpers.get_response_obj([], provider_response_id), + sequence_number=0, + ) + + return DummyStream() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + events = [ + event + async for event in model.stream_response( + "instr", + "input", + ModelSettings(), + [], + None, + [], + ModelTracing.ENABLED_WITHOUT_DATA, + previous_response_id=None, + ) + ] + + assert isinstance(events[-1], ResponseCompletedEvent) + assert events[-1].response.id == provider_response_id + [span] = fetch_ordered_spans() + assert isinstance(span.span_data, ResponseSpanData) + assert span.span_data.export()["response_id"] is None + assert span.span_data.response is None + assert span.span_data.input is None + assert span.span_data.usage is not None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_non_data_tracing_preserves_id_for_https_official_websocket(monkeypatch): + provider_response_id = "resp-official-ws" + with trace(workflow_name="test"): + model = OpenAIResponsesWSModel( + model="test-model", + openai_client=AsyncOpenAI( + api_key="test", websocket_base_url="https://api.openai.com/v1" + ), + ) + assert model._supports_default_prompt_cache_key() is False + + async def dummy_fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + previous_response_id, + conversation_id, + stream, + prompt, + ): + class DummyStream: + async def __aiter__(self): + yield ResponseCompletedEvent( + type="response.completed", + response=model_test_helpers.get_response_obj([], provider_response_id), + sequence_number=0, + ) + + return DummyStream() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + events = [ + event + async for event in model.stream_response( + "instr", + "input", + ModelSettings(), + [], + None, + [], + ModelTracing.ENABLED_WITHOUT_DATA, + previous_response_id=None, + ) + ] + + assert isinstance(events[-1], ResponseCompletedEvent) + assert events[-1].response.id == provider_response_id + [span] = fetch_ordered_spans() + assert isinstance(span.span_data, ResponseSpanData) + assert span.span_data.export()["response_id"] == provider_response_id + assert span.span_data.response is None + assert span.span_data.input is None + assert span.span_data.usage is not None @pytest.mark.allow_call_model_methods From 48c2ee40a41610ad92b20ba0ce77a3587f127cd8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 10:31:01 +0900 Subject: [PATCH 417/473] docs: clarify capability visibility and authorization (#4642) --- docs/context.md | 10 ++++++++++ docs/handoffs.md | 2 ++ docs/tools.md | 6 +++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/context.md b/docs/context.md index fcd5e9034c..dca006c10b 100644 --- a/docs/context.md +++ b/docs/context.md @@ -29,6 +29,16 @@ You can use the context for things like: Within a single run, derived wrappers share the same underlying app context, approval state, and usage tracking. Nested [`Agent.as_tool()`][agents.agent.Agent.as_tool] runs may attach a different `tool_input`, but they do not get an isolated copy of your app state by default. +### Use local context for capability visibility + +When function tools, MCP tools, and handoffs depend on the same request policy, keep the policy inputs or helper on your application context. Each SDK surface exposes the current run context through its own callback: + +- [`FunctionTool.is_enabled`][agents.tool.FunctionTool.is_enabled] receives a `RunContextWrapper`. +- [`Handoff.is_enabled`][agents.handoffs.Handoff.is_enabled] receives a `RunContextWrapper`. +- An MCP [`tool_filter`](mcp.md#dynamic-tool-filtering) receives a [`ToolFilterContext`][agents.mcp.ToolFilterContext], whose `run_context` property contains the current `RunContextWrapper`. + +Adapt the shared application policy to these callbacks instead of maintaining separate capability lists. The callbacks control which capabilities the SDK exposes for the current run; they cannot authorize a model-generated argument or resource selection. For function tools, enforce those decisions inside the tool implementation or with [tool input guardrails](guardrails.md#tool-guardrails) and [approvals](human_in_the_loop.md) when appropriate. MCP servers must authorize their own protected operations. For a handoff with `input_type`, check the parsed input at the start of `on_handoff`, before application side effects, and raise instead of returning when authorization fails. Tool input guardrails do not run for handoffs. See [handoff inputs](handoffs.md#handoff-inputs) for the callback lifecycle. + ### What `RunContextWrapper` exposes [`RunContextWrapper`][agents.run_context.RunContextWrapper] is a wrapper around your app-defined context object. In practice you will most often use: diff --git a/docs/handoffs.md b/docs/handoffs.md index 093c9a1cdd..c33df9dd84 100644 --- a/docs/handoffs.md +++ b/docs/handoffs.md @@ -85,6 +85,8 @@ handoff_obj = handoff( `input_type` describes the arguments for the handoff tool call itself. The SDK exposes that schema to the model as the handoff tool's `parameters`, validates the returned JSON locally, and passes the parsed value to `on_handoff`. +`is_enabled` is evaluated while the SDK prepares the available handoffs, before the model returns handoff arguments, so it cannot authorize values inside an argument-bearing handoff. When authorization depends on the parsed fields, perform the check at the start of `on_handoff`, before any application side effects. If authorization fails, raise instead of returning; the SDK continues the transfer after `on_handoff` returns successfully. Tool input guardrails apply to function tools, not handoffs. + It does not replace the next agent's main input, and it does not choose a different destination. The [`handoff()`][agents.handoffs.handoff] helper still transfers to the specific agent you wrapped, and the receiving agent still sees the conversation history unless you change it with an [`input_filter`][agents.handoffs.Handoff.input_filter] or nested handoff history settings. `input_type` is also separate from [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]. Use `input_type` for metadata the model decides at handoff time, not for application state or dependencies you already have locally. diff --git a/docs/tools.md b/docs/tools.md index 9f16e93846..d506b58c5f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -833,11 +833,15 @@ The `is_enabled` parameter accepts: Disabled tools are completely hidden from the LLM at runtime, making this useful for: -- Feature gating based on user permissions +- Request-scoped capability visibility - Environment-specific tool availability (dev vs prod) - A/B testing different tool configurations - Dynamic tool filtering based on runtime state +For locally configured function tools, the runner also reevaluates `is_enabled` before invocation. However, `is_enabled` controls visibility and dispatch; it does not replace authorization that depends on the tool arguments or the resource being accessed. Enforce those checks inside the tool implementation, or use [tool input guardrails](guardrails.md#tool-guardrails) and [approvals](human_in_the_loop.md) when appropriate. MCP servers must authorize their own protected operations. + +See [context management](context.md#use-local-context-for-capability-visibility) for a pattern that applies one application policy across function tools, MCP tools, and handoffs. + ## Experimental: Codex tool The `codex_tool` wraps the Codex CLI so an agent can run workspace-scoped tasks (shell, file edits, MCP tools) during a tool call. This surface is experimental and may change. From 9d1f4ea6c6d4c1e5e1f3d875a219f7ce8d23cde2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 11:38:42 +0900 Subject: [PATCH 418/473] fix: enforce public Voice class state coverage (#4644) --- integration_tests/_contract_support.py | 58 ++++++++ .../released_api_contract_policy.json | 10 ++ tests/test_released_api_contract.py | 128 +++++++++++++++++- 3 files changed, 195 insertions(+), 1 deletion(-) diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index e4058ee2da..cdc4c8e9a8 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -869,6 +869,61 @@ def _merge_public_class_contracts( return result +def _validate_voice_public_class_contract_policy( + release_policy: SubmoduleExportPolicy, + agents_module: Any | None, +) -> None: + voice_class_exports: list[tuple[Mapping[str, str], type[Any]]] = [] + for entry in release_policy.canonical_imports: + if entry["module"] != "agents.voice": + continue + canonical_module = _import_contract_module(entry["canonical_module"], agents_module) + value = getattr(canonical_module, entry["canonical_name"], None) + if isinstance(value, type): + voice_class_exports.append((entry, value)) + + abstract_bases = { + class_value for _, class_value in voice_class_exports if inspect.isabstract(class_value) + } + policy_by_identity = { + (entry["module"], entry["class_name"]): entry + for entry in release_policy.public_class_contracts + } + missing_entries: list[dict[str, object]] = [] + for canonical_import, class_value in voice_class_exports: + is_abstract = inspect.isabstract(class_value) + if not is_abstract and not any( + issubclass(class_value, abstract_base) for abstract_base in abstract_bases + ): + continue + + identity = ( + canonical_import["canonical_module"], + canonical_import["canonical_name"], + ) + policy_entry = policy_by_identity.get(identity) + has_explicit_state = policy_entry is not None and ( + (is_abstract and ("abstract" in policy_entry or "abstract_members" in policy_entry)) + or (not is_abstract and policy_entry.get("abstract") is False) + ) + if not has_explicit_state: + missing_entries.append( + { + "abstract": is_abstract, + "class_name": canonical_import["canonical_name"], + "module": canonical_import["canonical_module"], + } + ) + + if missing_entries: + raise ValueError( + "Cannot promote the public Voice API without explicit public_class_contracts " + "coverage for its abstract bases and concrete implementations. Add or correct " + f"these policy entries: {missing_entries!r}. Required classes are derived from " + "canonical agents.voice imports and their public abstract-base relationships." + ) + + def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]: if "class_name" in entry: return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"])) @@ -1493,6 +1548,9 @@ def build_released_api_contract( ) -> dict[str, Any]: """Build the next rolling release contract from the current public surface.""" agents = agents_module or importlib.import_module("agents") + if release_policy is not None: + _validate_voice_public_class_contract_policy(release_policy, agents_module) + compatibility_errors = validate_released_api_contract(contract, agents_module=agents) if compatibility_errors: details = "\n".join(f"- {error}" for error in compatibility_errors) diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index 8053fe0c97..813ae4785b 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -606,6 +606,11 @@ "class_name": "VoiceModelProvider", "module": "agents.voice.model" }, + { + "abstract": false, + "class_name": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider" + }, { "abstract": false, "class_name": "OpenAISTTModel", @@ -627,6 +632,11 @@ ], "class_name": "VoiceWorkflowBase", "module": "agents.voice.workflow" + }, + { + "abstract": false, + "class_name": "SingleAgentVoiceWorkflow", + "module": "agents.voice.workflow" } ], "public_properties": [ diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 8d3657c7b6..1cabc39756 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -7,7 +7,7 @@ import subprocess import sys from collections.abc import AsyncIterator, Callable, Iterator -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace from enum import Enum from importlib.metadata import version from inspect import Parameter, Signature @@ -2592,6 +2592,121 @@ class PublicState(TypedDict, total=False): assert updated["callables"]["agents.submodule.NewPublic"] == _callable_contract(NewPublic) +def test_release_contract_promotion_rejects_missing_voice_concrete_state_policy() -> None: + contract = load_api_contract(CONTRACT) + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + omitted_classes = {"OpenAIVoiceModelProvider", "SingleAgentVoiceWorkflow"} + incomplete_policy = replace( + policy, + public_class_contracts=tuple( + entry + for entry in policy.public_class_contracts + if entry["class_name"] not in omitted_classes + ), + ) + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline=contract["baseline"], + baseline_commit=contract["baseline_commit"], + release_policy=incomplete_policy, + ) + + message = str(exc_info.value) + assert "Cannot promote the public Voice API" in message + assert "OpenAIVoiceModelProvider" in message + assert "SingleAgentVoiceWorkflow" in message + assert "abstract': False" in message + assert "canonical agents.voice imports" in message + + +def test_release_contract_promotion_rejects_new_public_voice_implementation_without_state_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class PublicVoiceBase(abc.ABC): + @abc.abstractmethod + def run(self) -> None: + pass + + class NewPublicVoiceImplementation(PublicVoiceBase): + def run(self) -> None: + pass + + class UnrelatedPublicClass: + pass + + agents_module = SimpleNamespace(__all__=[]) + modules = { + "agents.voice.base": SimpleNamespace(PublicVoiceBase=PublicVoiceBase), + "agents.voice.implementation": SimpleNamespace( + NewPublicVoiceImplementation=NewPublicVoiceImplementation, + UnrelatedPublicClass=UnrelatedPublicClass, + ), + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: modules[module_name], + ) + contract: dict[str, Any] = { + "baseline": "v0.22.0", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + canonical_imports = ( + { + "canonical_module": "agents.voice.base", + "canonical_name": "PublicVoiceBase", + "module": "agents.voice", + "name": "PublicVoiceBase", + }, + { + "canonical_module": "agents.voice.implementation", + "canonical_name": "NewPublicVoiceImplementation", + "module": "agents.voice", + "name": "NewPublicVoiceImplementation", + }, + { + "canonical_module": "agents.voice.implementation", + "canonical_name": "UnrelatedPublicClass", + "module": "agents.voice", + "name": "UnrelatedPublicClass", + }, + ) + release_policy = _release_policy( + {}, + canonical_imports=canonical_imports, + public_class_contracts=( + { + "abstract_members": ["run"], + "class_name": "PublicVoiceBase", + "module": "agents.voice.base", + }, + ), + ) + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.22.1", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=release_policy, + ) + + message = str(exc_info.value) + assert "NewPublicVoiceImplementation" in message + assert "UnrelatedPublicClass" not in message + + def test_typed_dict_only_promotion_updates_baseline_commit( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -3529,6 +3644,7 @@ def test_repository_release_policy_declares_public_optional_modules() -> None: def test_repository_release_policy_declares_public_state_surfaces() -> None: policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + contract_support._validate_voice_public_class_contract_policy(policy, None) expected_modules = { "agents.realtime.testing", "agents.testing", @@ -3637,6 +3753,11 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: "module": "agents.voice.model", "abstract_members": ["get_stt_model", "get_tts_model"], }, + { + "abstract": False, + "class_name": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider", + }, { "abstract": False, "class_name": "OpenAISTTModel", @@ -3657,6 +3778,11 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: "module": "agents.voice.workflow", "abstract_members": ["run"], }, + { + "abstract": False, + "class_name": "SingleAgentVoiceWorkflow", + "module": "agents.voice.workflow", + }, ) assert policy.public_type_aliases == ( { From af3e28ad01e462a01cda2baca0a1528dfa18644d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 12:03:10 +0900 Subject: [PATCH 419/473] docs: update translated pages --- docs/ja/context.md | 84 +++++++----- docs/ja/handoffs.md | 62 +++++---- docs/ja/tools.md | 310 ++++++++++++++++++++--------------------- docs/ja/tracing.md | 118 ++++++++-------- docs/ko/context.md | 76 +++++----- docs/ko/handoffs.md | 54 ++++---- docs/ko/tools.md | 328 ++++++++++++++++++++++---------------------- docs/ko/tracing.md | 128 ++++++++--------- docs/zh/context.md | 80 ++++++----- docs/zh/handoffs.md | 62 +++++---- docs/zh/tools.md | 280 ++++++++++++++++++------------------- docs/zh/tracing.md | 134 +++++++++--------- 12 files changed, 882 insertions(+), 834 deletions(-) diff --git a/docs/ja/context.md b/docs/ja/context.md index b9486858ba..38f2f9c312 100644 --- a/docs/ja/context.md +++ b/docs/ja/context.md @@ -4,49 +4,59 @@ search: --- # コンテキスト管理 -コンテキストは多義的な用語です。考慮すべきコンテキストには、主に 2 つのカテゴリーがあります。 +コンテキストという用語は複数の意味で使われます。考慮すべきコンテキストは、大きく次の 2 種類に分けられます。 -1. コードからローカルに利用できるコンテキスト: ツール関数の実行時、`on_handoff` などのコールバック時、ライフサイクルフック内などで必要となる可能性があるデータや依存関係です。 -2. LLM が利用できるコンテキスト: LLM が応答を生成するときに参照するデータです。 +1. コードがローカルで利用できるコンテキスト:ツール関数の実行時、`on_handoff` などのコールバック内、ライフサイクルフック内などで必要になる可能性があるデータや依存関係です。 +2. LLM が利用できるコンテキスト:レスポンスを生成する際に LLM が参照するデータです。 ## ローカルコンテキスト {#local-context} -これは、[`RunContextWrapper`][agents.run_context.RunContextWrapper] クラスと、そのクラス内の [`context`][agents.run_context.RunContextWrapper.context] プロパティによって表されます。仕組みは次のとおりです。 +これは、[`RunContextWrapper`][agents.run_context.RunContextWrapper] クラスと、その中の [`context`][agents.run_context.RunContextWrapper.context] プロパティで表されます。仕組みは次のとおりです。 -1. 任意の Python オブジェクトを作成します。一般的なパターンとして、dataclass または Pydantic オブジェクトを使用します。 -2. そのオブジェクトをさまざまな実行メソッド(例: `Runner.run(..., context=whatever)` )に渡します。 -3. すべてのツール呼び出しやライフサイクルフックなどには、ラッパーオブジェクト `RunContextWrapper[T]` が渡されます。ここで `T` はコンテキストオブジェクトの型を表し、オブジェクト自体は `wrapper.context` から利用できます。 +1. 任意の Python オブジェクトを作成します。一般的には、データクラスまたは Pydantic オブジェクトを使用します。 +2. そのオブジェクトを各種の実行メソッド(例:`Runner.run(..., context=whatever)`)に渡します。 +3. すべてのツール呼び出しやライフサイクルフックなどには、ラッパーオブジェクト `RunContextWrapper[T]` が渡されます。ここで `T` はコンテキストオブジェクトの型を表し、オブジェクト自体には `wrapper.context` を介してアクセスできます。 -一部のランタイム固有のコールバックでは、SDK が `RunContextWrapper[T]` のより特化したサブクラスを渡す場合があります。たとえば、`FunctionTool` インスタンスのライフサイクルフックは通常、`ToolContext` を受け取ります。これにより、`tool_call_id`、`tool_name`、`tool_arguments` などのツール呼び出しメタデータも利用できます。 +ランタイム固有の一部のコールバックでは、SDK が `RunContextWrapper[T]` のより特化したサブクラスを渡す場合があります。たとえば、`FunctionTool` インスタンスのライフサイクルフックは通常、`ToolContext` を受け取ります。これは、`tool_call_id`、`tool_name`、`tool_arguments` などのツール呼び出しメタデータも公開します。 -認識しておくべき **最も重要な** 点は、特定のエージェント実行におけるすべてのエージェント、ツール関数、ライフサイクル処理などで、同じ _型_ のコンテキストを使用する必要があることです。 +注意すべき **最も重要な** 点は、特定のエージェント実行に関わるすべてのエージェント、ツール関数、ライフサイクル処理などで、同じ _型_ のコンテキストを使用する必要があることです。 コンテキストは、次のような用途に使用できます。 -- 実行に関するコンテキストデータ(例: ユーザー名 / uid、またはユーザーに関するその他の情報) -- 依存関係(例: ロガーオブジェクト、データ取得オブジェクトなど) +- 実行に関するコンテキストデータ(例:ユーザー名、UID、その他のユーザー情報) +- 依存関係(例:ロガーオブジェクト、データフェッチャーなど) - ヘルパー関数 !!! danger "注記" - コンテキストオブジェクトが LLM に送信されることは **ありません** 。これは純粋にローカルなオブジェクトであり、データの読み取りや書き込み、メソッドの呼び出しが可能です。 + コンテキストオブジェクトは、LLM に **送信されない** ローカル専用のオブジェクトです。その値の読み取りや書き込み、メソッドの呼び出しが可能です。 -単一の実行内では、派生したラッパーは基盤となるアプリコンテキスト、承認状態、使用量追跡を共有します。ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行では、別の `tool_input` を関連付けることができますが、デフォルトではアプリ状態の独立したコピーは作成されません。 +1 回の実行内では、派生したラッパーが同じ基盤のアプリケーションコンテキスト、承認状態、使用量追跡を共有します。ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行では、異なる `tool_input` が付与される場合がありますが、デフォルトではアプリケーション状態の独立したコピーは作成されません。 -### `RunContextWrapper` の公開情報 {#what-runcontextwrapper-exposes} +### 機能の公開制御におけるローカルコンテキストの使用 {#use-local-context-for-capability-visibility} -[`RunContextWrapper`][agents.run_context.RunContextWrapper] は、アプリで定義したコンテキストオブジェクトのラッパーです。実際には、主に次のものを使用します。 +関数ツール、MCP ツール、ハンドオフが同じリクエストポリシーに依存する場合は、ポリシーの入力値またはヘルパーをアプリケーションコンテキストに保持してください。SDK の各インターフェースは、それぞれのコールバックを介して現在の実行コンテキストを公開します。 -- 独自の変更可能なアプリ状態と依存関係には、[`wrapper.context`][agents.run_context.RunContextWrapper.context] を使用します。 +- [`FunctionTool.is_enabled`][agents.tool.FunctionTool.is_enabled] は `RunContextWrapper` を受け取ります。 +- [`Handoff.is_enabled`][agents.handoffs.Handoff.is_enabled] は `RunContextWrapper` を受け取ります。 +- MCP の [`tool_filter`](mcp.md#dynamic-tool-filtering) は [`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取ります。その `run_context` プロパティには、現在の `RunContextWrapper` が含まれます。 + +個別の機能リストを管理するのではなく、共有アプリケーションポリシーをこれらのコールバックに合わせて適用してください。これらのコールバックは、現在の実行に対して SDK が公開する機能を制御しますが、モデルが生成した引数やリソース選択を認可することはできません。関数ツールでは、ツール実装内で認可に関する判断を適用するか、必要に応じて[ツール入力ガードレール](guardrails.md#tool-guardrails)や[承認](human_in_the_loop.md)を使用してください。MCP サーバーは、自身の保護対象の操作を認可する必要があります。`input_type` を持つハンドオフでは、アプリケーションに副作用が生じる前に、`on_handoff` の冒頭で解析済みの入力を確認し、認可に失敗した場合は値を返さずに例外を送出してください。ツール入力ガードレールは、ハンドオフでは実行されません。コールバックのライフサイクルについては、[ハンドオフ入力](handoffs.md#handoff-inputs)を参照してください。 + +### `RunContextWrapper` で公開される情報 {#what-runcontextwrapper-exposes} + +[`RunContextWrapper`][agents.run_context.RunContextWrapper] は、アプリケーションで定義したコンテキストオブジェクトのラッパーです。実際には、主に次の項目を使用します。 + +- 独自の変更可能なアプリケーション状態と依存関係には、[`wrapper.context`][agents.run_context.RunContextWrapper.context] を使用します。 - 現在の実行全体で集計されたリクエストとトークンの使用量には、[`wrapper.usage`][agents.run_context.RunContextWrapper.usage] を使用します。 - 現在の実行が [`Agent.as_tool()`][agents.agent.Agent.as_tool] 内で行われている場合の構造化入力には、[`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input] を使用します。 -- 承認状態をプログラムから更新する必要がある場合は、[`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool] を使用します。 +- 承認状態をプログラムで更新する必要がある場合は、[`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool] を使用します。 -アプリで定義するオブジェクトは `wrapper.context` だけです。その他のフィールドは、SDK が管理するランタイムメタデータです。 +アプリケーションで定義したオブジェクトは `wrapper.context` だけです。その他のフィールドは、SDK が管理するランタイムメタデータです。 -後でヒューマンインザループまたは永続的なジョブのワークフロー用に [`RunState`][agents.run_state.RunState] をシリアライズすると、そのランタイムメタデータも状態とともに保存されます。シリアライズした状態を永続化または送信する場合は、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] に機密情報を格納しないでください。 +後で Human-in-the-loop または永続ジョブのワークフロー向けに [`RunState`][agents.run_state.RunState] をシリアライズする場合、このランタイムメタデータも状態とともに保存されます。シリアライズした状態を永続化または送信する予定がある場合は、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] にシークレットを格納しないでください。 -会話状態は別の考慮事項です。ターンをどのように引き継ぐかに応じて、`result.to_input_list()`、`session`、`conversation_id`、または `previous_response_id` を使用してください。この判断については、[実行結果](results.md)、[エージェントの実行](running_agents.md)、[セッション](sessions/index.md)を参照してください。 +会話状態は別の検討事項です。ターンを引き継ぐ方法に応じて、`result.to_input_list()`、`session`、`conversation_id`、または `previous_response_id` を使用してください。この判断については、[実行結果](results.md)、[エージェントの実行](running_agents.md)、[セッション](sessions/index.md)を参照してください。 ```python import asyncio @@ -86,17 +96,17 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. これはコンテキストオブジェクトです。ここでは dataclass を使用していますが、任意の型を使用できます。 -2. これはツールです。`RunContextWrapper[UserInfo]` を受け取ることが分かります。ツールの実装はコンテキストからデータを読み取ります。 -3. 型チェッカーがエラーを検出できるように、エージェントにジェネリック型 `UserInfo` を指定します(たとえば、異なるコンテキスト型を受け取るツールを渡そうとした場合)。 +1. これはコンテキストオブジェクトです。ここではデータクラスを使用していますが、任意の型を使用できます。 +2. これはツールです。このツールが `RunContextWrapper[UserInfo]` を受け取ることが分かります。ツール実装はコンテキストから値を読み取ります。 +3. エージェントにジェネリック `UserInfo` を指定し、型チェッカーがエラーを検出できるようにします(たとえば、異なるコンテキスト型を受け取るツールを渡そうとした場合)。 4. コンテキストは `run` 関数に渡されます。 5. エージェントはツールを正しく呼び出し、年齢を取得します。 --- -### 高度な機能: `ToolContext` {#advanced-toolcontext} +### 高度な機能: `ToolContext` {#advanced-toolcontext} -場合によっては、実行中のツールについて、その名前、呼び出し ID、raw 引数文字列などの追加メタデータにアクセスしたいことがあります。 +場合によっては、実行中のツールに関する追加のメタデータ(名前、呼び出し ID、生の引数文字列など)へアクセスする必要があります。 その場合は、`RunContextWrapper` を拡張する [`ToolContext`][agents.tool_context.ToolContext] クラスを使用できます。 ```python @@ -126,25 +136,25 @@ agent = Agent( ) ``` -`ToolContext` は、`RunContextWrapper` と同じ `.context` プロパティに加えて、 -現在のツール呼び出しに固有の次のフィールドを提供します。 +`ToolContext` は、`RunContextWrapper` と同じ `.context` プロパティを提供し、 +さらに現在のツール呼び出しに固有の次のフィールドも提供します。 - `tool_name` – 呼び出されるツールの名前 -- `tool_call_id` – このツール呼び出しの一意な識別子 -- `tool_arguments` – ツールに渡された raw 引数文字列 -- `tool_namespace` – ツールが `tool_namespace()` または名前空間付きの別のインターフェースを通じて読み込まれた場合の、そのツール呼び出しの Responses 名前空間 -- `qualified_tool_name` – 名前空間を利用できる場合に、その名前空間で修飾されたツール名 +- `tool_call_id` – このツール呼び出しの一意の識別子 +- `tool_arguments` – ツールに渡された生の引数文字列 +- `tool_namespace` – ツールが `tool_namespace()` または名前空間を持つ別のインターフェースを介して読み込まれた場合の、ツール呼び出し用 Responses 名前空間 +- `qualified_tool_name` – 名前空間が利用できる場合に、その名前空間で修飾されたツール名 -実行中にツールレベルのメタデータが必要な場合は、`ToolContext` を使用します。 +実行中にツール単位のメタデータが必要な場合は、`ToolContext` を使用してください。 エージェントとツール間で一般的なコンテキストを共有する場合は、引き続き `RunContextWrapper` で十分です。`ToolContext` は `RunContextWrapper` を拡張しているため、ネストされた `Agent.as_tool()` の実行で構造化入力が指定された場合は、`.tool_input` も公開できます。 --- ## エージェント / LLM コンテキスト {#agentllm-context} -LLM が呼び出されたとき、LLM が参照できるのは会話履歴に含まれるデータ **だけ** です。つまり、新しいデータを LLM から利用可能にするには、その履歴に含まれる形で提供する必要があります。これには、次のような方法があります。 +LLM が呼び出されたとき、LLM が確認できる **唯一の** データは会話履歴に含まれるデータです。つまり、新しいデータを LLM が利用できるようにするには、そのデータを会話履歴に含める必要があります。これには、次のような方法があります。 -1. エージェントの `instructions` に追加できます。これは「システムプロンプト」または「開発者メッセージ」とも呼ばれます。システムプロンプトには静的な文字列を使用できるほか、コンテキストを受け取って文字列を出力する動的な関数も使用できます。常に有用な情報(たとえば、ユーザーの名前や現在の日付)に対してよく使用される方法です。 -2. `Runner.run` 関数の呼び出し時に、`input` に追加します。これは `instructions` を使用する方法と似ていますが、[指揮系統](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)における優先度がより低いメッセージを使用できます。 -3. `FunctionTool` インスタンスを通じて公開します。これは _オンデマンド_ のコンテキストに便利です。LLM がデータを必要とするタイミングを判断し、ツールを呼び出してそのデータを取得できます。 -4. 情報取得または Web 検索を使用します。これらは、ファイルやデータベースから関連データを取得したり(情報取得)、Web から関連データを取得したり(Web 検索)できる特別なツールです。関連するコンテキストデータに基づいて応答を「グラウンディング」する場合に役立ちます。 \ No newline at end of file +1. エージェントの `instructions` に追加できます。これは「システムプロンプト」または「developer message」とも呼ばれます。システムプロンプトには静的な文字列を指定できるほか、コンテキストを受け取って文字列を出力する動的関数も使用できます。これは、常に有用な情報(たとえば、ユーザー名や現在の日付)に対してよく使われる方法です。 +2. `Runner.run` 関数を呼び出す際に、`input` に追加します。これは `instructions` を使用する方法と似ていますが、[指示の優先順位](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)がより低いメッセージを使用できます。 +3. `FunctionTool` インスタンスを介して公開します。これは _オンデマンド_ コンテキストに便利です。LLM がデータを必要とするタイミングを判断し、ツールを呼び出してそのデータを取得できます。 +4. 検索または Web 検索を使用します。これらは、ファイルやデータベースから関連データを取得(検索)したり、Web から取得(Web 検索)したりできる特別なツールです。これは、関連するコンテキストデータに基づいてレスポンスを根拠付ける場合に便利です。 \ No newline at end of file diff --git a/docs/ja/handoffs.md b/docs/ja/handoffs.md index 073fb6c013..e5694cd0ed 100644 --- a/docs/ja/handoffs.md +++ b/docs/ja/handoffs.md @@ -4,21 +4,21 @@ search: --- # ハンドオフ -ハンドオフを使用すると、エージェントはタスクを別のエージェントに委任できます。これは、異なるエージェントがそれぞれ別の領域を専門とするシナリオで特に役立ちます。たとえば、カスタマーサポートアプリでは、注文状況、返金、FAQ などのタスクをそれぞれ専門に処理するエージェントを用意できます。 +ハンドオフを使用すると、エージェントは別のエージェントにタスクを委任できます。これは、異なるエージェントがそれぞれ別の領域に特化しているシナリオで特に有用です。たとえば、カスタマーサポートアプリでは、注文状況、返金、FAQ などのタスクをそれぞれ専門に処理するエージェントを用意できます。 -ハンドオフは、LLM に対してツールとして表現されます。そのため、`Refund Agent` という名前のエージェントへのハンドオフがある場合、ツール名は `transfer_to_refund_agent` になります。 +ハンドオフは、LLMに対してツールとして表現されます。そのため、`Refund Agent` という名前のエージェントへのハンドオフがある場合、ツール名は `transfer_to_refund_agent` になります。 ## ハンドオフの作成 {#creating-a-handoff} -すべてのエージェントには [`handoffs`][agents.agent.Agent.handoffs] パラメーターがあり、`Agent` を直接受け取ることも、ハンドオフをカスタマイズする `Handoff` オブジェクトを受け取ることもできます。 +すべてのエージェントには [`handoffs`][agents.agent.Agent.handoffs] パラメーターがあり、`Agent` を直接受け取るか、ハンドオフをカスタマイズする `Handoff` オブジェクトを受け取ることができます。 -単純な `Agent` インスタンスを渡した場合、その [`handoff_description`][agents.agent.Agent.handoff_description] が設定されていれば、デフォルトのツール説明に追加されます。完全な `handoff()` オブジェクトを記述せずに、モデルがそのハンドオフを選択すべきタイミングを示すために使用できます。 +通常の `Agent` インスタンスを渡すと、その [`handoff_description`][agents.agent.Agent.handoff_description] が設定されている場合、デフォルトのツール説明に追加されます。完全な `handoff()` オブジェクトを作成せずに、モデルがそのハンドオフを選択すべきタイミングを示すヒントとして使用してください。 -Agents SDK が提供する [`handoff()`][agents.handoffs.handoff] 関数を使用して、ハンドオフを作成できます。この関数では、ハンドオフ先のエージェントに加えて、オプションのオーバーライドと入力フィルターを指定できます。 +Agents SDKが提供する [`handoff()`][agents.handoffs.handoff] 関数を使用して、ハンドオフを作成できます。この関数では、ハンドオフ先のエージェントに加え、オプションのオーバーライドや入力フィルターを指定できます。 -### 基本的な使用方法 {#basic-usage} +### 基本的な使用法 {#basic-usage} -簡単なハンドオフは次のように作成できます。 +シンプルなハンドオフは、次のように作成できます。 ```python from agents import Agent, handoff @@ -30,22 +30,22 @@ refund_agent = Agent(name="Refund agent") triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)]) ``` -1. エージェントを直接使用することも(`billing_agent` のように)、`handoff()` 関数を使用することもできます。 +1. エージェントを直接使用するか(`billing_agent` の場合)、`handoff()` 関数を使用できます。 ### `handoff()` 関数によるハンドオフのカスタマイズ {#customizing-handoffs-via-the-handoff-function} [`handoff()`][agents.handoffs.handoff] 関数を使用すると、さまざまな項目をカスタマイズできます。 - `agent`: ハンドオフ先のエージェントです。 -- `tool_name_override`: デフォルトでは、`transfer_to_` に解決される `Handoff.default_tool_name()` 関数が使用されます。これはオーバーライドできます。 -- `tool_description_override`: `Handoff.default_tool_description()` のデフォルトのツール説明をオーバーライドします。 -- `on_handoff`: ハンドオフが呼び出されたときに実行されるコールバック関数です。ハンドオフが呼び出されることが判明した時点で、データ取得などを開始する場合に便利です。この関数はエージェントコンテキストを受け取り、オプションで LLM が生成した入力も受け取れます。入力データは `input_type` パラメーターによって制御されます。 -- `input_type`: ハンドオフのツール呼び出し引数のスキーマです。設定すると、解析されたペイロードが `on_handoff` に渡されます。 +- `tool_name_override`: デフォルトでは `Handoff.default_tool_name()` 関数が使用され、`transfer_to_` に解決されます。これはオーバーライドできます。 +- `tool_description_override`: `Handoff.default_tool_description()` から生成されるデフォルトのツール説明をオーバーライドします +- `on_handoff`: ハンドオフが呼び出されたときに実行されるコールバック関数です。ハンドオフが呼び出されることが判明した時点で、データ取得などを開始する場合に便利です。この関数はエージェントコンテキストを受け取り、必要に応じて LLMが生成した入力も受け取れます。入力データは `input_type` パラメーターで制御されます。 +- `input_type`: ハンドオフのツール呼び出し引数のスキーマです。設定すると、解析済みのペイロードが `on_handoff` に渡されます。 - `input_filter`: 次のエージェントが受け取る入力をフィルタリングできます。詳細は以下を参照してください。 -- `is_enabled`: ハンドオフを有効にするかどうかを指定します。ブール値、またはブール値を返す関数を指定できるため、実行時にハンドオフを動的に有効化または無効化できます。 -- `nest_handoff_history`: RunConfig レベルの `nest_handoff_history` 設定をハンドオフごとにオーバーライドするためのオプションです。`None` の場合、アクティブな実行設定で定義された値が代わりに使用されます。 +- `is_enabled`: ハンドオフが有効かどうかを指定します。ブール値、またはブール値を返す関数を指定でき、実行時にハンドオフを動的に有効化または無効化できます。 +- `nest_handoff_history`: RunConfig レベルの `nest_handoff_history` 設定に対する、ハンドオフごとのオプションのオーバーライドです。`None` の場合、アクティブな実行設定で定義された値が使用されます。 -[`handoff()`][agents.handoffs.handoff] ヘルパーは、渡された特定の `agent` に常に制御を移します。移行先の候補が複数ある場合は、移行先ごとに 1 つのハンドオフを登録し、モデルに選択させます。独自のハンドオフコードが呼び出し時に返すエージェントを決定する必要がある場合にのみ、カスタムの [`Handoff`][agents.handoffs.Handoff] を使用してください。 +[`handoff()`][agents.handoffs.handoff] ヘルパーは、渡された特定の `agent` に必ず制御を移します。移行先の候補が複数ある場合は、移行先ごとに 1 つのハンドオフを登録し、その中からモデルに選択させてください。独自のハンドオフコードが呼び出し時に返すエージェントを決定する必要がある場合にのみ、カスタムの [`Handoff`][agents.handoffs.Handoff] を使用してください。 ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## ハンドオフ入力 {#handoff-inputs} -状況によっては、LLM がハンドオフを呼び出す際に、何らかのデータを提供するようにしたい場合があります。たとえば、「エスカレーションエージェント」へのハンドオフを考えてみましょう。ログに記録できるよう、モデルに理由を提供させることができます。 +状況によっては、ハンドオフを呼び出す際に LLMからデータを提供させたい場合があります。たとえば、「エスカレーションエージェント」へのハンドオフを考えてみましょう。ログに記録できるよう、モデルに理由を提供させることができます。 ```python from pydantic import BaseModel @@ -87,26 +87,28 @@ handoff_obj = handoff( ) ``` -`input_type` は、ハンドオフのツール呼び出し自体の引数を記述します。SDK はそのスキーマをハンドオフツールの `parameters` としてモデルに公開し、返された JSON をローカルで検証して、解析された値を `on_handoff` に渡します。 +`input_type` は、ハンドオフのツール呼び出し自体の引数を記述します。SDK はそのスキーマをハンドオフツールの `parameters` としてモデルに公開し、返された JSON をローカルで検証して、解析済みの値を `on_handoff` に渡します。 -これは次のエージェントのメイン入力を置き換えるものでも、別の移行先を選択するものでもありません。[`handoff()`][agents.handoffs.handoff] ヘルパーは引き続きラップされた特定のエージェントに制御を移し、受け取り側のエージェントも、[`input_filter`][agents.handoffs.Handoff.input_filter] またはネストされたハンドオフ履歴の設定で変更しない限り、会話履歴を引き続き参照できます。 +`is_enabled` は、モデルがハンドオフ引数を返す前に、SDK が利用可能なハンドオフを準備する際に評価されるため、引数を伴うハンドオフ内の値を認可することはできません。認可が解析済みフィールドに依存する場合は、アプリケーションで副作用が発生する前に、`on_handoff` の先頭でチェックを実行してください。認可に失敗した場合は、値を返すのではなく例外を発生させてください。`on_handoff` が正常に戻ると、SDK は移行を続行します。ツール入力ガードレールは関数ツールに適用され、ハンドオフには適用されません。 -`input_type` は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] とも異なります。ローカルにすでに存在するアプリケーションの状態や依存関係ではなく、ハンドオフ時にモデルが決定するメタデータには `input_type` を使用してください。 +これは、次のエージェントのメイン入力を置き換えるものでも、別の移行先を選択するものでもありません。[`handoff()`][agents.handoffs.handoff] ヘルパーは、ラップした特定のエージェントへ引き続き移行します。また、[`input_filter`][agents.handoffs.Handoff.input_filter] またはネストされたハンドオフ履歴設定で変更しない限り、受信側のエージェントには引き続き会話履歴が表示されます。 -### `input_type` の使用タイミング {#when-to-use-input_type} +`input_type` は [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] とも別のものです。`input_type` は、ローカルにすでに存在するアプリケーション状態や依存関係ではなく、ハンドオフ時にモデルが決定するメタデータに使用してください。 -ハンドオフに、`reason`、`language`、`priority`、`summary` など、モデルが生成する小さなメタデータが必要な場合は、`input_type` を使用します。たとえば、トリアージエージェントは `{ "reason": "duplicate_charge", "priority": "high" }` を伴って返金エージェントにハンドオフでき、返金エージェントが引き継ぐ前に `on_handoff` でそのメタデータをログに記録したり永続化したりできます。 +### `input_type` の使用場面 {#when-to-use-input_type} + +ハンドオフに `reason`、`language`、`priority`、`summary` など、モデルが生成する少量のメタデータが必要な場合は、`input_type` を使用してください。たとえば、トリアージエージェントは `{ "reason": "duplicate_charge", "priority": "high" }` を指定して返金エージェントへハンドオフでき、返金エージェントが引き継ぐ前に `on_handoff` でそのメタデータをログに記録したり永続化したりできます。 目的が異なる場合は、別の仕組みを選択してください。 -- 既存のアプリケーションの状態と依存関係は、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] に格納します。[コンテキストガイド](context.md)を参照してください。 -- 受け取り側のエージェントが参照する履歴を変更する場合は、[`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]、または [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] を使用します。 -- 専門エージェントの候補が複数ある場合は、移行先ごとに 1 つのハンドオフを登録します。`input_type` は選択されたハンドオフにメタデータを追加できますが、移行先を振り分けるものではありません。 -- 会話を移行せずに、ネストされた専門エージェントへ構造化入力を渡す場合は、[`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool] の使用を推奨します。[ツール](tools.md#structured-input-for-tool-agents)を参照してください。 +- 既存のアプリケーション状態と依存関係は、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] に格納してください。[コンテキストガイド](context.md)を参照してください。 +- 受信側のエージェントに表示される履歴を変更する場合は、[`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]、または [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] を使用してください。 +- 専門エージェントの候補が複数ある場合は、移行先ごとに 1 つのハンドオフを登録してください。`input_type` は選択されたハンドオフにメタデータを追加できますが、移行先を振り分けるものではありません。 +- 会話を移行せず、ネストされた専門エージェントに構造化入力を渡す場合は、[`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool] の使用を推奨します。[ツール](tools.md#structured-input-for-tool-agents)を参照してください。 ## 入力フィルター {#input-filters} -ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、それまでの会話履歴全体を参照できる状態になります。これを変更するには、[`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、[`HandoffInputData`][agents.handoffs.HandoffInputData] を介して既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 +ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、それまでの会話履歴全体を参照できるようになります。これを変更するには、[`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、[`HandoffInputData`][agents.handoffs.HandoffInputData] を介して既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 [`HandoffInputData`][agents.handoffs.HandoffInputData] には、以下が含まれます。 @@ -116,15 +118,15 @@ handoff_obj = handoff( - `input_items`: `new_items` の代わりに次のエージェントへ転送するオプションの項目です。セッション履歴では `new_items` をそのまま維持しながら、モデル入力をフィルタリングできます。 - `run_context`: ハンドオフが呼び出された時点でアクティブだった [`RunContextWrapper`][agents.run_context.RunContextWrapper] です。 -ネストされたハンドオフ履歴はオプトインのベータ機能として利用でき、安定化を進めている間はデフォルトで無効になっています。[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] を有効にすると、ランナーは要約可能な履歴を順序付けられたアシスタント要約セグメントに圧縮しつつ、情報を失わないメッセージ項目を元の位置に保持します。生成された各要約セグメントでは `` ラッパーが使用され、後続のハンドオフでは、順序付けられたトランスクリプトを再構築する前に、以前に生成されたセグメントがフラット化されます。セッション、`RunState`、`RunResult.to_input_list()` は、この SDK デフォルトの履歴に移動されたメッセージの出現箇所を正確に追跡するため、それらが二重に追加されることはありません。一方、内容が同一でも別個のメッセージは保持されます。組み込みのセグメント化を使用せず、次のエージェントに渡す入力項目の正確なリストを返す独自のマッピング関数を、[`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] で指定できます。このオプトインは、ハンドオフの `input_filter` とアクティブな実行の `RunConfig.handoff_input_filter` のどちらも設定されていない場合にのみ適用されます。そのため、ペイロードをすでにカスタマイズしている既存のコード(このリポジトリのコード例を含む)は、変更なしで現在の動作を維持します。[`handoff(...)`][agents.handoffs.handoff] に `nest_handoff_history=True` または `False` を渡すことで、単一のハンドオフについてネストの挙動をオーバーライドできます。これにより、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] が設定されます。生成される要約セグメントのラッパーテキストのみを変更する場合は、エージェントを実行する前に [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出します。後続の実行でデフォルトのラッパーに戻す必要がある場合は、その実行前に [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出します。 +ネストされたハンドオフ履歴は、オプトインのベータ機能として利用でき、安定化を進めている間はデフォルトで無効になっています。[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] を有効にすると、ランナーは要約可能な履歴を順序付きのアシスタント要約セグメントに圧縮しながら、情報を失わないメッセージ項目を元の位置に保持します。生成される各要約セグメントでは `` ラッパーが使用され、後続のハンドオフでは、順序付きのトランスクリプトを再構築する前に、以前に生成されたセグメントがフラット化されます。セッション、`RunState`、`RunResult.to_input_list()` は、この SDK 標準履歴へ移動されたメッセージの正確な出現箇所を追跡し、それらが二重に追加されないようにします。内容が同一でも別個のメッセージは引き続き保持されます。組み込みのセグメンテーションを使用する代わりに、[`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] で独自のマッピング関数を指定し、次のエージェント向けの入力項目の正確なリストを返すことができます。このオプトインは、ハンドオフの `input_filter` とアクティブな実行の `RunConfig.handoff_input_filter` のいずれも設定されていない場合にのみ適用されます。そのため、このリポジトリ内のコード例を含め、すでにペイロードをカスタマイズしている既存コードは、変更なしで現在の動作を維持します。[`handoff(...)`][agents.handoffs.handoff] に `nest_handoff_history=True` または `False` を渡すと、[`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history] が設定され、単一のハンドオフに対してネスト動作をオーバーライドできます。生成される要約セグメントのラッパーテキストのみを変更する場合は、エージェントを実行する前に [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers] を呼び出してください。後の実行でデフォルトのラッパーに戻す必要がある場合は、事前に [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers] を呼び出してください。 ハンドオフとアクティブな [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] の両方でフィルターが定義されている場合、その特定のハンドオフでは、ハンドオフごとの [`input_filter`][agents.handoffs.Handoff.input_filter] が優先されます。 !!! note - ハンドオフは単一の実行内にとどまります。入力ガードレールは引き続きチェーンの最初のエージェントにのみ適用され、出力ガードレールは最終出力を生成するエージェントにのみ適用されます。ワークフロー内の各カスタム関数ツール呼び出しに対してチェックが必要な場合は、ツールガードレールを使用してください。 + ハンドオフは単一の実行内に留まります。入力ガードレールは引き続きチェーン内の最初のエージェントにのみ適用され、出力ガードレールは最終出力を生成するエージェントにのみ適用されます。ワークフロー内の各カスタム関数ツール呼び出しを検査する必要がある場合は、ツールガードレールを使用してください。 -一般的なパターンの一部(たとえば、履歴からすべてのツール呼び出しを削除する処理)は、[`agents.extensions.handoff_filters`][] に実装されています。 +一般的なパターン(たとえば、履歴からすべてのツール呼び出しを削除するパターン)がいくつかあり、これらは [`agents.extensions.handoff_filters`][] に実装されています。 ```python from agents import Agent, handoff @@ -142,7 +144,7 @@ handoff_obj = handoff( ## 推奨プロンプト {#recommended-prompts} -LLM がハンドオフを正しく理解できるようにするため、エージェントにハンドオフに関する情報を含めることを推奨します。[`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] に推奨プレフィックスが用意されています。また、[`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] を呼び出して、推奨データをプロンプトに自動的に追加することもできます。 +LLMがハンドオフを正しく理解できるように、エージェントにハンドオフに関する情報を含めることを推奨します。[`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] に推奨プレフィックスが用意されています。または、[`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] を呼び出して、推奨情報をプロンプトへ自動的に追加できます。 ```python from agents import Agent diff --git a/docs/ja/tools.md b/docs/ja/tools.md index dd5732f4a3..0e7f3d50d8 100644 --- a/docs/ja/tools.md +++ b/docs/ja/tools.md @@ -4,43 +4,43 @@ search: --- # ツール -ツールを使うと、データの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作などをエージェントに実行させることができます。SDK は、次の 5 つのカテゴリーをサポートしています。 +ツールを使用すると、データの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作など、エージェントがアクションを実行できます。SDK は 5 つのカテゴリーをサポートしています。 -- OpenAI がホストするツール: OpenAI のサーバー上でモデルのために実行されます。 -- ローカル/ランタイム実行ツール: `ComputerTool` と `ApplyPatchTool` は常にお使いの環境で実行され、`ShellTool` はローカルまたはホストされたコンテナで実行できます。 -- `FunctionTool` インスタンス: 任意の Python 関数をツールとしてラップします。 -- Agents as tools: 完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 -- 実験的機能: Codex ツール: ツール呼び出しからワークスペーススコープの Codex タスクを実行します。 +- OpenAI がホストするツール:OpenAI サーバー上でモデル向けに実行されます。 +- ローカル/ランタイム実行ツール:`ComputerTool` と `ApplyPatchTool` は常にご自身の環境で実行され、`ShellTool` はローカルまたはホスト型コンテナーで実行できます。 +- `FunctionTool` インスタンス:任意の Python 関数をツールとしてラップします。 +- Agents as tools:完全なハンドオフを行わずに、エージェントを呼び出し可能なツールとして公開します。 +- 実験的機能:Codex ツール:ツール呼び出しから、ワークスペースにスコープされた Codex タスクを実行します。 ## ツールタイプの選択 {#choosing-a-tool-type} -このページをカタログとして使用し、管理するランタイムに対応するセクションに進んでください。 +このページをカタログとして使用し、制御するランタイムに該当するセクションに移動してください。 | 目的 | 参照先 | | --- | --- | -| OpenAI が管理するツール(Web 検索、ファイル検索、Code Interpreter、ホストされた MCP、画像生成)の使用 | [ホストされたツール](#hosted-tools) | -| ツール検索を使用して、大規模なツールセットの読み込みをランタイムまで遅延 | [ホストされたツール検索](#hosted-tool-search) | +| OpenAI が管理するツール(Web 検索、ファイル検索、Code Interpreter、ホスト型 MCP、画像生成)の使用 | [ホスト型ツール](#hosted-tools) | +| ツール検索を使用して、大規模なツール群の読み込みをランタイムまで延期 | [ホスト型ツール検索](#hosted-tool-search) | | 生成された JavaScript から複数のツール呼び出しを調整 | [プログラムによるツール呼び出し](#programmatic-tool-calling) | -| 独自のプロセスまたは環境でツールを実行 | [ローカルランタイムツール](#local-runtime-tools) | -| Python 関数をツールとしてラップ | [関数ツール](#function-tools) | +| 独自のプロセスまたは環境でのツール実行 | [ローカルランタイムツール](#local-runtime-tools) | +| Python 関数のツールとしてのラップ | [関数ツール](#function-tools) | | ハンドオフせずに、あるエージェントから別のエージェントを呼び出し | [Agents as tools](#agents-as-tools) | -| エージェントからワークスペーススコープの Codex タスクを実行 | [実験的機能: Codex ツール](#experimental-codex-tool) | +| エージェントからワークスペースにスコープされた Codex タスクを実行 | [実験的機能:Codex ツール](#experimental-codex-tool) | -## ホストされたツール {#hosted-tools} +## ホスト型ツール {#hosted-tools} -[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合、OpenAI はいくつかの組み込みツールを提供します。 +OpenAI は、[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する際に、いくつかの組み込みツールを提供しています。 - [`WebSearchTool`][agents.tool.WebSearchTool] を使用すると、エージェントが Web を検索できます。 -- [`FileSearchTool`][agents.tool.FileSearchTool] を使用すると、OpenAI ベクトルストアから情報を取得できます。 +- [`FileSearchTool`][agents.tool.FileSearchTool] を使用すると、OpenAI のベクトルストアから情報を取得できます。 - [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] を使用すると、LLM がサンドボックス環境でコードを実行できます。 - [`HostedMCPTool`][agents.tool.HostedMCPTool] は、リモート MCP サーバーのツールをモデルに公開します。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] は、プロンプトから画像を生成します。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルが必要に応じて遅延ツール、名前空間、またはホストされた MCP サーバーを読み込めます。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルが生成した JavaScript から対象ツールを調整できます。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] を使用すると、モデルが遅延されたツール、名前空間、またはホスト型 MCP サーバーをオンデマンドで読み込めます。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] を使用すると、モデルが生成された JavaScript から対象ツールを調整できます。 -ホストされた検索の高度なオプション: +ホスト型検索の高度なオプション: -- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。`max_num_results` には 1 から 50 までの整数を設定してください。`None` または 0 を指定すると、プロバイダーのデフォルト値が使用されます。 +- `FileSearchTool` は、`vector_store_ids` と `max_num_results` に加えて、`filters`、`ranking_options`、`include_search_results` をサポートします。`max_num_results` には 1 から 50 までの整数を設定します。`None` または 0 を指定すると、プロバイダーのデフォルトが使用されます。 - `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートします。 ```python @@ -62,11 +62,11 @@ async def main(): print(result.final_output) ``` -### ホストされたツール検索 {#hosted-tool-search} +### ホスト型ツール検索 {#hosted-tool-search} -ツール検索を使用すると、OpenAI Responses モデルは大規模なツールセットの読み込みをランタイムまで遅延できるため、モデルは現在のターンに必要なサブセットのみを読み込みます。多数の関数ツール、名前空間グループ、またはホストされた MCP サーバーがあり、すべてのツールを事前に公開せずにツールスキーマのトークン数を削減したい場合に役立ちます。 +ツール検索を使用すると、OpenAI Responses モデルは大規模なツール群の読み込みをランタイムまで延期できるため、モデルは現在のターンに必要なサブセットのみを読み込みます。これは、多数の関数ツール、名前空間グループ、またはホスト型 MCP サーバーがあり、すべてのツールを事前に公開せずにツールスキーマのトークン数を削減したい場合に便利です。 -エージェントを構築する時点で候補ツールがすでに判明している場合は、ホストされたツール検索から始めてください。アプリケーションで読み込む内容を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしますが、標準の `Runner` では、このモードは自動実行されません。 +エージェントを構築する時点で候補ツールがすでに分かっている場合は、ホスト型ツール検索から始めてください。アプリケーションが読み込む対象を動的に決定する必要がある場合、Responses API はクライアント実行型のツール検索もサポートしていますが、標準の `Runner` はこのモードを自動実行しません。 ```python from typing import Annotated @@ -109,28 +109,28 @@ result = await Runner.run(agent, "Look up customer_42 and list their open orders print(result.final_output) ``` -留意事項: +留意事項: -- ホストされたツール検索は、OpenAI Responses モデルでのみ利用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 -- エージェントに遅延読み込み対象を設定する場合は、`ToolSearchTool()` を 1 つだけ追加してください。 +- ホスト型ツール検索は、OpenAI Responses モデルでのみ利用できます。現在の Python SDK のサポートは `openai>=2.25.0` に依存します。 +- エージェントに遅延読み込み対象を設定する場合は、`ToolSearchTool()` をちょうど 1 つ追加します。 - 検索可能な対象には、`@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])`、`HostedMCPTool(tool_config={..., "defer_loading": True})` が含まれます。 -- 遅延読み込みする関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成では、モデルが必要に応じて適切なグループを読み込めるように、`ToolSearchTool()` も使用できます。 +- 遅延読み込みされる関数ツールは、`ToolSearchTool()` と組み合わせる必要があります。名前空間のみの構成では、モデルが必要なグループをオンデマンドで読み込めるように、`ToolSearchTool()` も使用できます。 - `tool_namespace()` は、`FunctionTool` インスタンスを共通の名前空間名と説明の下にグループ化します。通常、`crm`、`billing`、`shipping` など、関連するツールが多数ある場合に最適です。 -- OpenAI の公式ベストプラクティスは、[可能な場合は名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことです。 -- 可能な場合は、個別に遅延される多数の関数よりも、名前空間またはホストされた MCP サーバーを優先してください。通常、モデルにとってより優れた高レベルの検索対象となり、トークンもより節約できます。 -- 名前空間には、即時利用可能なツールと遅延ツールを混在させられます。`defer_loading=True` のないツールは引き続き即座に呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 -- 目安として、各名前空間は十分に小さく保ち、理想的には関数を 10 個未満にしてください。 -- 名前付きの `tool_choice` では、単独の名前空間名や遅延専用ツールを対象にできません。`auto`、`required`、または実際に呼び出し可能なトップレベルのツール名を優先してください。 -- `ToolSearchTool(execution="client")` は、手動の Responses オーケストレーション用です。モデルがクライアント実行型の `tool_search_call` を出力すると、標準の `Runner` は代わりに実行せず、例外を発生させます。 -- ツール検索のアクティビティは、専用の項目タイプとイベントタイプにより、[`RunResult.new_items`](results.md#new-items) および [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 -- 名前空間による読み込みとトップレベルの遅延ツールの両方を扱う、完全に実行可能なコード例については、`examples/tools/tool_search.py` を参照してください。 -- 公式プラットフォームガイド: [ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 +- OpenAI の公式ベストプラクティスのガイダンスは、[可能な限り名前空間を使用する](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)ことです。 +- 可能な場合は、個別に遅延される多数の関数よりも、名前空間またはホスト型 MCP サーバーを優先してください。通常、モデルにとってより優れた高レベルの検索対象となり、トークンもより多く節約できます。 +- 名前空間には、即時利用可能なツールと遅延ツールを混在させられます。`defer_loading=True` がないツールは引き続き即時に呼び出せますが、同じ名前空間内の遅延ツールはツール検索を通じて読み込まれます。 +- 経験則として、各名前空間は比較的小さく保ち、関数を 10 個未満にするのが理想的です。 +- 名前付きの `tool_choice` は、名前空間名だけの対象や遅延専用ツールを指定できません。`auto`、`required`、または実在するトップレベルの呼び出し可能なツール名を優先してください。 +- `ToolSearchTool(execution="client")` は、Responses を手動でオーケストレーションするためのものです。モデルがクライアント実行型の `tool_search_call` を出力した場合、標準の `Runner` は代わりに実行せず、例外を発生させます。 +- ツール検索のアクティビティは、専用のアイテムおよびイベントタイプとして [`RunResult.new_items`](results.md#new-items) と [`RunItemStreamEvent`](streaming.md#run-item-event-names) に表示されます。 +- 名前空間を使用した読み込みとトップレベルの遅延ツールの両方を扱う、完全に実行可能なコード例については、`examples/tools/tool_search.py` を参照してください。 +- 公式プラットフォームガイド:[ツール検索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 ### プログラムによるツール呼び出し {#programmatic-tool-calling} -プログラムによるツール呼び出しを使用すると、対応する OpenAI Responses モデルが JavaScript を生成し、対象ツールを呼び出して、その出力を組み合わせ、1 つの結果をモデルに返せます。ツール呼び出しのたびにモデルとのラウンドトリップを行わず、ループ、分岐、並列呼び出し、中間計算を活用できる範囲限定のワークフローに役立ちます。 +プログラムによるツール呼び出しを使用すると、サポートされている OpenAI Responses モデルが、対象ツールを呼び出し、それらの出力を組み合わせ、1 つの結果をモデルに返す JavaScript を生成できます。各ツール呼び出しの後にモデルとの往復を行うことなく、ループ、分岐、並列呼び出し、または中間計算を利用できる、範囲の限定されたワークフローに便利です。 -生成されたプログラムは、新しいホスト済み V8 環境で実行されます。Node.js API、ファイルシステム、ネットワークへのアクセス、永続プロセスは利用できません。プログラムが操作できるのは、明示的に許可したツールだけです。 +生成されたプログラムは、新しいホスト型 V8 環境で実行されます。Node.js API、ファイルシステムやネットワークへのアクセス、永続プロセスは使用できません。プログラムが操作できるのは、明示的に許可したツールのみです。 ```python from pydantic import BaseModel @@ -165,24 +165,24 @@ result = Runner.run_sync(agent, "Check inventory for desk-lamp and summarize it. print(result.final_output) ``` -留意事項: - -- プログラムによるツール呼び出しは、対応する OpenAI Responses モデルでのみ利用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 -- エージェントには `ProgrammaticToolCallingTool()` を最大 1 つ追加できます。また、エージェントは、プログラムから呼び出し可能なツール、名前空間、遅延関数、遅延されたホスト済み MCP サーバーを基盤とする `ToolSearchTool()`、または不透明なプロンプト管理型ツールセットのうち、少なくとも 1 つを公開する必要があります。検索可能な対象がない単独の `ToolSearchTool()` は拒否されます。 -- `allowed_callers` は、ツールを呼び出す方法を制御します。省略すると、モデルによる直接呼び出しのみが許可されます。プログラムからのみアクセス可能にするには `["programmatic"]`、両方を許可するには `["direct", "programmatic"]` を使用してください。 -- オプトインできる SDK ツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数、カスタム、シェル、パッチ適用の各ツールは、`allowed_callers` を直接公開します。ホストされた MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定してください。 -- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、dataclass などの構造化された戻り値アノテーションが、自動的に厳格なオブジェクト出力スキーマになります。返された値は、プログラムに返される前にそのスキーマに対して検証されます。関数に使用可能なアノテーションがない場合は `output_type=...` を使用し、厳格なオブジェクトスキーマがすでにある場合は、低レベルのエスケープハッチである `output_json_schema={...}` を使用してください。`output_type` と `output_json_schema` は相互排他的です。`str`、`Any`、`None` の戻り値アノテーションでは、出力スキーマは作成されません。スキーマを基盤とするプログラム所有の呼び出しでは、自由形式のテキストが出力スキーマを満たさないため、デフォルトの失敗フォーマッターは無効になります。そのため、スキーマに準拠した JSON を返すカスタム `failure_error_function` を指定しない限り、ハンドラーの例外は伝播します。 -- プログラム所有の SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツールの入力および出力ガードレール、フック、タイムアウト、同時実行数の制限、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 -- `ProgrammaticToolCallingTool()` が存在する場合、プログラムが実行される前でも、モデルリクエストの再試行にはより厳格なリプレイ安全性の境界が使用されます。SDK は、これらのリクエストに対してプロバイダー管理の再試行と WebSocket のイベント前再試行を無効にします。Runner の再試行ポリシーは、プロバイダーの通知でリプレイが安全であると明示された場合にのみ再試行します。`retry_policies.network_error()` だけでは、この境界を上書きしません。 -- 承認が重要なツールや影響の大きいツールは、通常、直接呼び出しとして維持する方が適しています。これにより、大きなプログラムの一部になる前に、各アクションを人が確認できます。プログラム所有の呼び出しが承認待ちで一時停止した場合は、`RunState` を通じて中断を解決し、通常どおり元の実行を再開してください。 -- プログラムによるツール呼び出しは、[ホストされたツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムが遅延ツールを呼び出す前に、モデルがそれらを読み込む必要があります。 -- `program` 項目と、その通常のプログラム所有の子ツール呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。ホストされた MCP の承認リクエストとツールカタログでは、代わりに専用の MCP 項目とストリームイベントが使用されます。確認方法の詳細については、[実行結果](results.md#new-items)および[ストリーミング](streaming.md#run-item-event-names)を参照してください。 +留意事項: + +- プログラムによるツール呼び出しは、サポートされている OpenAI Responses モデルでのみ利用できます。`ProgrammaticToolCallingTool()` と `tool_choice="programmatic_tool_calling"` は、Chat Completions モデルおよび Responses 以外のバックエンドでは拒否されます。 +- エージェントに追加できる `ProgrammaticToolCallingTool()` は最大 1 つです。エージェントは、プログラムから呼び出し可能なツールを少なくとも 1 つ、名前空間、遅延関数、または遅延ホスト型 MCP サーバーを基盤とする `ToolSearchTool()`、もしくは内部構造を公開しないプロンプト管理型ツール群も公開する必要があります。検索可能な対象を持たない単独の `ToolSearchTool()` は拒否されます。 +- `allowed_callers` は、ツールをどのように呼び出せるかを制御します。省略した場合、モデルによる直接呼び出しのみが許可されます。プログラムからのみアクセス可能にするには `["programmatic"]` を使用し、両方を許可するには `["direct", "programmatic"]` を使用します。 +- オプトインできる SDK ツールタイプは、`FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool`、`CodeInterpreterTool` です。関数、カスタム、シェル、apply-patch の各ツールでは、`allowed_callers` が直接公開されます。ホスト型 MCP と Code Interpreter では、`tool_config` 内に `allowed_callers` を設定します。 +- `@function_tool(allowed_callers=[...])` では、Pydantic モデル、TypedDict、dataclass などの構造化された戻り値アノテーションが、自動的に厳密なオブジェクト出力スキーマになります。また、返された値はプログラムに返される前に、そのスキーマに対して検証されます。関数に利用可能なアノテーションがない場合は `output_type=...` を使用し、厳密なオブジェクトスキーマがすでにある場合は、より低レベルのエスケープハッチである `output_json_schema={...}` を使用します。`output_type` と `output_json_schema` は同時に使用できません。`str`、`Any`、`None` の戻り値アノテーションでは、出力スキーマは作成されません。スキーマを基盤とするプログラム所有の呼び出しでは、自由形式のテキストが出力スキーマを満たさないため、デフォルトの失敗フォーマッターは無効になります。そのため、スキーマに準拠した JSON を返すカスタム `failure_error_function` を指定しない限り、ハンドラーの例外は伝播します。 +- プログラム所有の SDK ツールでも、通常の Runner ライフサイクルが使用されます。ツール入出力ガードレール、フック、タイムアウト、同時実行数の制限、承認、セッション、`RunState` の一時停止/再開動作は引き続き適用され、SDK は各子呼び出しとプログラム呼び出し元との関係を保持します。 +- `ProgrammaticToolCallingTool()` が存在する場合、プログラムが実行される前であっても、モデルリクエストの再試行には、より厳格な再実行安全性の境界が使用されます。SDK は、これらのリクエストに対して、プロバイダー管理の再試行と WebSocket のイベント前再試行を無効にします。Runner の再試行ポリシーは、プロバイダーの助言で再実行が安全であると明示された場合にのみ再試行します。`retry_policies.network_error()` だけでは、この境界を上書きしません。 +- 承認が重要なツールや影響の大きいツールは、通常、直接呼び出しのままにしておく方が適切です。これにより、大規模なプログラムの一部になる前に、各アクションを人が確認できます。プログラム所有の呼び出しが承認待ちで一時停止した場合は、`RunState` を通じて中断を解決し、通常どおり元の実行を再開します。 +- プログラムによるツール呼び出しは、[ホスト型ツール検索](#hosted-tool-search)と組み合わせられます。生成されたプログラムから遅延ツールを呼び出すには、モデルが事前にそのツールを読み込む必要があります。 +- `program` アイテムと、その通常のプログラム所有の子ツール呼び出しは、[`ToolCallItem`][agents.items.ToolCallItem] エントリとして表示されます。対応する `program_output` は、[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] として表示されます。一方、ホスト型 MCP の承認リクエストとツールカタログでは、専用の MCP アイテムとストリームイベントが使用されます。確認方法の詳細については、[実行結果](results.md#new-items)と[ストリーミング](streaming.md#run-item-event-names)を参照してください。 - 完全な並行在庫計画のコード例については、`examples/tools/programmatic_tool_calling.py` を参照してください。 -- 公式プラットフォームガイド: [プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 +- 公式プラットフォームガイド:[プログラムによるツール呼び出し](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 -### ホストされたコンテナシェルとスキル {#hosted-container-shell-skills} +### ホスト型コンテナーのシェルとスキル {#hosted-container-shell-skills} -`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートします。ローカルランタイムではなく、管理されたコンテナでモデルにシェルコマンドを実行させたい場合は、このモードを使用してください。 +`ShellTool` は、OpenAI がホストするコンテナーでの実行もサポートします。ローカルランタイムではなく、管理対象コンテナー内でモデルにシェルコマンドを実行させたい場合は、このモードを使用します。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -215,54 +215,54 @@ result = await Runner.run( print(result.final_output) ``` -後続の実行で既存のコンテナを再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 +後続の実行で既存のコンテナーを再利用するには、`environment={"type": "container_reference", "container_id": "cntr_..."}` を設定します。 -留意事項: +留意事項: -- ホストされたシェルは、Responses API のシェルツールを通じて利用できます。 -- `container_auto` はリクエスト用のコンテナをプロビジョニングし、`container_reference` は既存のコンテナを再利用します。 +- ホスト型シェルは、Responses API のシェルツールを通じて利用できます。 +- `container_auto` はリクエスト用のコンテナーをプロビジョニングし、`container_reference` は既存のコンテナーを再利用します。 - `container_auto` には、`file_ids` と `memory_limit` も含められます。 - `environment.skills` は、スキル参照とインラインスキルバンドルを受け付けます。 -- ホストされた環境では、`ShellTool` に `executor`、`needs_approval`、`on_approval` を設定しないでください。 +- ホスト型環境では、`ShellTool` に `executor`、`needs_approval`、`on_approval` を設定しないでください。 - `network_policy` は、`disabled` モードと `allowlist` モードをサポートします。 -- 許可リストモードでは、`network_policy.domain_secrets` がドメインスコープのシークレットを名前で注入できます。 +- 許可リストモードでは、`network_policy.domain_secrets` により、ドメインにスコープされたシークレットを名前で挿入できます。 - 完全なコード例については、`examples/tools/container_shell_skill_reference.py` と `examples/tools/container_shell_inline_skill.py` を参照してください。 -- OpenAI プラットフォームガイド: [シェル](https://platform.openai.com/docs/guides/tools-shell)と[スキル](https://platform.openai.com/docs/guides/tools-skills)。 +- OpenAI プラットフォームガイド:[シェル](https://platform.openai.com/docs/guides/tools-shell)と[スキル](https://platform.openai.com/docs/guides/tools-skills)。 ## ローカルランタイムツール {#local-runtime-tools} -ローカルランタイムツールは、モデルのレスポンス自体の外部で実行されます。モデルが呼び出すタイミングを決定する点は変わりませんが、実際の処理はアプリケーションまたは設定された実行環境が行います。 +ローカルランタイムツールは、モデルレスポンス自体の外部で実行されます。呼び出すタイミングは引き続きモデルが決定しますが、実際の処理はアプリケーションまたは設定済みの実行環境が行います。 -`ComputerTool` と `ApplyPatchTool` には、常にお客様が提供するローカル実装が必要です。`ShellTool` は両方のモードに対応します。管理された実行を使用する場合は上記のホスト済みコンテナ設定を使用し、独自プロセスでコマンドを実行する場合は以下のローカルランタイム設定を使用してください。 +`ComputerTool` と `ApplyPatchTool` には、常にご自身で用意するローカル実装が必要です。`ShellTool` は両方のモードに対応します。管理された実行が必要な場合は上記のホスト型コンテナー設定を使用し、ご自身のプロセスでコマンドを実行する場合は下記のローカルランタイム設定を使用します。 -ローカルランタイムツールでは、実装を提供する必要があります。 +ローカルランタイムツールでは、実装を用意する必要があります。 -- [`ComputerTool`][agents.tool.ComputerTool]: GUI/ブラウザの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 -- [`ShellTool`][agents.tool.ShellTool]: ローカル実行とホストされたコンテナ実行の両方に対応する最新のシェルツールです。 -- [`LocalShellTool`][agents.tool.LocalShellTool]: 従来のローカルシェル統合です。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 差分をローカルで適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 +- [`ComputerTool`][agents.tool.ComputerTool]:GUI/ブラウザーの自動化を有効にするには、[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] インターフェースを実装します。 +- [`ShellTool`][agents.tool.ShellTool]:ローカル実行とホスト型コンテナー実行の両方に対応する最新のシェルツールです。 +- [`LocalShellTool`][agents.tool.LocalShellTool]:従来のローカルシェル統合です。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:差分をローカルに適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 - ローカルシェルスキルは、`ShellTool(environment={"type": "local", "skills": [...]})` で利用できます。 -有限のシェルアクションタイムアウトには、正の整数のミリ秒値を使用します。0 は実行プログラムの実装間で共通の意味を持たないため、SDK はローカルの `ShellTool` 実行プログラムを呼び出す前に、`0` と `None` の両方を明示的なタイムアウトなしとして扱います。その他の値は、実行プログラムの呼び出し前に拒否されます。これはタイムアウトフィールドに固有の動作です。キャプチャされる出力を空にするリクエストとして、`max_output_length=0` は引き続きサポートされます。 +シェルアクションのタイムアウトでは、有限のタイムアウトとして正の整数のミリ秒を使用します。0 は実行機能の実装間で共通の意味を持たないため、SDK はローカルの `ShellTool` 実行機能を呼び出す前に、`0` と `None` の両方を明示的なタイムアウトなしとして扱います。それ以外の値は、実行機能の呼び出し前に拒否されます。これはタイムアウトフィールドに固有の動作です。`max_output_length=0` は、空のキャプチャ出力を要求する値として引き続きサポートされます。 -### ComputerTool と Responses のコンピュータツール {#computertool-and-the-responses-computer-tool} +### ComputerTool と Responses のコンピューターツール {#computertool-and-the-responses-computer-tool} -`ComputerTool` は引き続きローカルハーネスです。[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を提供すると、SDK がそのハーネスを OpenAI Responses API のコンピュータ操作インターフェースにマッピングします。 +`ComputerTool` は、引き続きローカルハーネスです。[`Computer`][agents.computer.Computer] または [`AsyncComputer`][agents.computer.AsyncComputer] の実装を用意すると、SDK はそのハーネスを OpenAI Responses API のコンピューターインターフェースにマッピングします。 -明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストでは、SDK は GA 版の組み込みツールペイロード `{"type": "computer"}` を送信します。旧モデル `computer-use-preview` へのリクエストでは、SDK は引き続きプレビュー版ペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` を送信します。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)で説明されているプラットフォーム移行を反映しています。 +明示的な [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) リクエストでは、SDK は GA の組み込みツールペイロード `{"type": "computer"}` を送信します。以前の `computer-use-preview` モデルへのリクエストでは、SDK は引き続きプレビューペイロード `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}` を送信します。これは、OpenAI の[コンピュータ操作ガイド](https://developers.openai.com/api/docs/guides/tools-computer-use/)に記載されたプラットフォームの移行に対応しています。 -- モデル: `computer-use-preview` -> `gpt-5.5` -- ツールセレクター: `computer_use_preview` -> `computer` -- コンピュータ呼び出し形式: `computer_call` ごとに 1 つの `action` -> `computer_call` 上のバッチ化された `actions[]` -- 切り詰め: プレビューパスでは `ModelSettings(truncation="auto")` が必須 -> GA パスでは不要 +- モデル:`computer-use-preview` -> `gpt-5.5` +- ツールセレクター:`computer_use_preview` -> `computer` +- コンピューター呼び出しの形式:`computer_call` ごとに 1 つの `action` -> `computer_call` 上のバッチ化された `actions[]` +- 切り詰め:プレビュー経路では `ModelSettings(truncation="auto")` が必須 -> GA 経路では不要 -SDK は、実際の Responses リクエストで有効なモデルに基づいて、このワイヤー形式を選択します。プロンプトテンプレートを使用しており、モデルがプロンプト側で管理されるためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示的に維持するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピュータペイロードを維持します。 +SDK は、実際の Responses リクエストで有効なモデルに基づいて、そのワイヤー形式を選択します。プロンプトテンプレートを使用し、プロンプト側でモデルを指定しているためリクエストで `model` が省略される場合、`model="gpt-5.5"` を明示的に指定するか、`ModelSettings(tool_choice="computer")` または `ModelSettings(tool_choice="computer_use")` で GA セレクターを強制しない限り、SDK はプレビュー互換のコンピューターペイロードを維持します。 -[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに一致する組み込みセレクターに正規化されます。`ComputerTool` がない場合、これらの文字列は引き続き通常の関数名として動作します。 +[`ComputerTool`][agents.tool.ComputerTool] が存在する場合、`tool_choice="computer"`、`"computer_use"`、`"computer_use_preview"` はすべて受け付けられ、有効なリクエストモデルに対応する組み込みセレクターに正規化されます。`ComputerTool` がない場合、これらの文字列は引き続き通常の関数名として動作します。 -この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリを基盤としている場合に重要です。GA 版の `computer` ペイロードでは、シリアライズ時に `environment` や寸法が不要なため、ファクトリが `Computer` または `AsyncComputer` インスタンスを生成する前にシリアライズできます。プレビュー互換のシリアライズでは、SDK が `environment`、`display_width`、`display_height` を送信できるように、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 +この違いは、`ComputerTool` が [`ComputerProvider`][agents.tool.ComputerProvider] ファクトリーを基盤としている場合に重要です。GA の `computer` ペイロードでは、シリアル化時に `environment` や寸法は不要なため、ファクトリーが `Computer` または `AsyncComputer` インスタンスを生成する前にシリアル化できます。プレビュー互換のシリアル化では、SDK が `environment`、`display_width`、`display_height` を送信できるように、解決済みの `Computer` または `AsyncComputer` インスタンスが引き続き必要です。 -ランタイムでは、どちらのパスも同じローカルハーネスを使用します。プレビュー版のレスポンスは、単一の `action` を持つ `computer_call` 項目を出力します。`gpt-5.5` はバッチ化された `actions[]` を出力でき、SDK は `computer_call_output` スクリーンショット項目を生成する前に、それらを順番に実行します。実行可能な Playwright ベースのハーネスについては、`examples/tools/computer_use.py` を参照してください。 +ランタイムでは、両方の経路で同じローカルハーネスが引き続き使用されます。プレビューレスポンスは、単一の `action` を持つ `computer_call` アイテムを出力します。`gpt-5.5` はバッチ化された `actions[]` を出力でき、SDK は `computer_call_output` スクリーンショットアイテムを生成する前に、それらを順番に実行します。Playwright を基盤とする実行可能なハーネスについては、`examples/tools/computer_use.py` を参照してください。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -309,15 +309,15 @@ agent = Agent( 任意の Python 関数をツールとして使用できます。Agents SDK がツールを自動的に設定します。 - ツール名には Python 関数の名前が使用されます(名前を指定することもできます) -- ツールの説明は、関数の docstring から取得されます(説明を指定することもできます) +- ツールの説明は関数の docstring から取得されます(説明を指定することもできます) - 関数入力のスキーマは、関数の引数から自動的に作成されます - 無効にしない限り、各入力の説明は関数の docstring から取得されます -`@tool` で作成されたツールは、読み取り専用の `__wrapped__` 属性を通じて、元の Python 呼び出し可能オブジェクトを公開します。これは検査やテストに役立ちますが、直接呼び出すと、スキーマ検証、コンテキスト注入、ガードレール、タイムアウト、失敗処理、トレーシングなどのツールランタイムパイプラインがバイパスされます。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 +`@tool` によって作成されたツールは、元の Python 呼び出し可能オブジェクトを読み取り専用の `__wrapped__` 属性を通じて公開します。これは検査やテストに便利ですが、直接呼び出すと、スキーマ検証、コンテキスト注入、ガードレール、タイムアウト、失敗処理、トレーシングを含むツールランタイムのパイプラインがバイパスされます。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 -関数シグネチャの抽出には Python の `inspect` モジュールを使用し、docstring の解析には [`griffe`](https://mkdocstrings.github.io/griffe/)、スキーマの作成には `pydantic` を使用します。 +関数シグネチャの抽出には Python の `inspect` モジュールを使用し、docstring の解析には [`griffe`](https://mkdocstrings.github.io/griffe/) を、スキーマの作成には `pydantic` を使用します。 -OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は `ToolSearchTool()` によって読み込まれるまで関数ツールを非表示にします。また、[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホストされたツール検索](#hosted-tool-search)を参照してください。 +OpenAI Responses モデルを使用している場合、`@function_tool(defer_loading=True)` は、`ToolSearchTool()` によって読み込まれるまで関数ツールを非表示にします。[`tool_namespace()`][agents.tool.tool_namespace] を使用して、関連する関数ツールをグループ化することもできます。完全な設定と制約については、[ホスト型ツール検索](#hosted-tool-search)を参照してください。 ```python import json @@ -370,12 +370,12 @@ for tool in agent.tools: ``` -1. 関数の引数には任意の Python 型を使用でき、関数は同期でも非同期でも構いません。 -2. docstring がある場合は、説明と引数の説明を取得するために使用されます。 -3. 関数は、オプションで実行コンテキストを最初の引数として受け取れます。また、ツール名、説明、使用する docstring スタイルなどを上書き設定できます。 -4. デコレートした関数をツールのリストに渡せます。 +1. 関数の引数には任意の Python 型を使用でき、関数は同期または非同期にできます。 +2. docstring が存在する場合は、説明と引数の説明を取得するために使用されます +3. 関数は、必要に応じて実行コンテキストを第 1 引数として受け取れます。ツール名、説明、使用する docstring スタイルなどのオーバーライドも設定できます。 +4. デコレートされた関数をツールのリストに渡せます。 -??? note "出力を表示するには展開してください" +??? note "出力を表示するには展開" ``` fetch_weather @@ -447,20 +447,20 @@ for tool in agent.tools: ### 関数ツールからの画像またはファイルの返却 {#returning-images-or-files-from-function-tools} -テキスト出力に加えて、関数ツールの出力として 1 つ以上の画像またはファイルを返せます。そのためには、次のいずれかを返します。 +テキスト出力に加えて、関数ツールの出力として 1 つまたは複数の画像やファイルを返せます。そのためには、次のいずれかを返します。 -- 画像: [`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) -- ファイル: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- テキスト: 文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](または TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- 画像:[`ToolOutputImage`][agents.tool.ToolOutputImage](または TypedDict 版の [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) +- ファイル:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](または TypedDict 版の [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) +- テキスト:文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](もしくは TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### カスタム関数ツール {#custom-function-tools} -Python 関数をツールとして使用したくない場合もあります。必要に応じて、[`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。次の項目を指定する必要があります。 +Python 関数をツールとして使用したくない場合もあります。その場合は、必要に応じて [`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。次の項目を指定する必要があります。 - `name` - `description` - 引数の JSON スキーマである `params_json_schema` -- [`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化ツール出力オブジェクト、出力のリストなど)を返す非同期関数である `on_invoke_tool` +- [`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数 `on_invoke_tool` ```python from typing import Any @@ -495,16 +495,16 @@ tool = FunctionTool( ### 引数と docstring の自動解析 {#automatic-argument-and-docstring-parsing} -前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールと個々の引数の説明を抽出します。これについて、いくつか留意点があります。 +前述のとおり、関数シグネチャを自動的に解析してツールのスキーマを抽出し、docstring を解析してツールおよび個々の引数の説明を抽出します。留意点は次のとおりです。 -1. シグネチャの解析は、`inspect` モジュールを介して行われます。型アノテーションを使用して引数の型を理解し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートします。 -2. docstring の解析には `griffe` を使用します。サポートされる docstring 形式は、`google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートです。`function_tool` の呼び出し時に明示的に設定することもできます。また、`use_docstring_info` を `False` に設定すると、docstring の解析を無効にできます。Google スタイルの docstring では、概要テキストの直後に空行を挟まず配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーで受け付けられます。 +1. シグネチャの解析は、`inspect` モジュールを通じて行われます。型アノテーションを使用して引数の型を把握し、スキーマ全体を表す Pydantic モデルを動的に構築します。Python の基本型、Pydantic モデル、TypedDict など、ほとんどの型をサポートしています。 +2. docstring の解析には `griffe` を使用します。サポートされている docstring 形式は、`google`、`sphinx`、`numpy` です。docstring 形式の自動検出を試みますが、これはベストエフォートであり、`function_tool` を呼び出す際に明示的に設定できます。`use_docstring_info` を `False` に設定すると、docstring の解析を無効にすることもできます。Google スタイルの docstring では、要約テキストの直後に空行を挟まずに配置された `Args:`、`Arguments:`、`Params:`、`Parameters:` セクションもパーサーが受け付けます。 スキーマ抽出のコードは、[`agents.function_schema`][] にあります。 ### Pydantic Field による引数の制約と説明 {#constraining-and-describing-arguments-with-pydantic-field} -Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用すると、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値ベースの形式(`arg: int = Field(..., ge=1)`)と `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)の両方がサポートされます。生成される JSON スキーマと検証には、これらの制約が含まれます。 +Pydantic の [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) を使用して、ツール引数に制約(数値の最小値/最大値、文字列の長さやパターンなど)と説明を追加できます。Pydantic と同様に、デフォルト値を使用する形式(`arg: int = Field(..., ge=1)`)と `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)の両方がサポートされています。生成される JSON スキーマと検証には、これらの制約が含まれます。 ```python from typing import Annotated @@ -524,7 +524,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr ### 関数ツールのタイムアウト {#function-tool-timeouts} -`@function_tool(timeout=...)` を使用すると、非同期関数ツールに呼び出し単位のタイムアウトを設定できます。 +`@function_tool(timeout=...)` を使用すると、非同期関数ツールに呼び出しごとのタイムアウトを設定できます。 ```python import asyncio @@ -545,13 +545,13 @@ agent = Agent( ) ``` -タイムアウトに達した場合のデフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから見えるタイムアウトメッセージ(例: `Tool 'slow_lookup' timed out after 2 seconds.`)を送信します。 +タイムアウトに達した場合、デフォルトの動作は `timeout_behavior="error_as_result"` で、モデルから認識可能なタイムアウトメッセージ(たとえば `Tool 'slow_lookup' timed out after 2 seconds.`)を送信します。 タイムアウト処理は次のように制御できます。 -- `timeout_behavior="error_as_result"`(デフォルト): モデルが復旧できるように、タイムアウトメッセージをモデルへ返します。 -- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 -- `timeout_error_function=...`: `error_as_result` を使用する場合のタイムアウトメッセージをカスタマイズします。 +- `timeout_behavior="error_as_result"`(デフォルト):モデルが復旧できるように、タイムアウトメッセージをモデルへ返します。 +- `timeout_behavior="raise_exception"`:[`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] を発生させ、実行を失敗させます。 +- `error_as_result` を使用する場合、`timeout_error_function=...` でタイムアウトメッセージをカスタマイズします。 ```python import asyncio @@ -575,15 +575,15 @@ except ToolTimeoutError as e: !!! note - タイムアウト設定は、非同期の `@function_tool` ハンドラーでのみサポートされます。 + タイムアウト設定は、非同期の `@function_tool` ハンドラーでのみサポートされています。 ### 関数ツールのエラー処理 {#handling-errors-in-function-tools} -`@function_tool` を介して関数ツールを作成する場合、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 +`@function_tool` を使用して関数ツールを作成する際に、`failure_error_function` を渡せます。これは、ツール呼び出しがクラッシュした場合に LLM へエラーレスポンスを提供する関数です。 -- デフォルトでは(何も渡さなかった場合)、エラーが発生したことを LLM に通知する `default_tool_error_function` が実行されます。 -- 独自のエラー関数を渡した場合は、代わりにその関数が実行され、レスポンスが LLM に送信されます。 -- `None` を明示的に渡すと、ツール呼び出しのエラーが再度発生し、独自に処理できます。モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` などが発生する可能性があります。 +- デフォルトでは(何も渡さない場合)、エラーが発生したことを LLM に通知する `default_tool_error_function` が実行されます。 +- 独自のエラー関数を渡すと、代わりにその関数が実行され、レスポンスが LLM に送信されます。 +- `None` を明示的に渡すと、ツール呼び出しのエラーが再度発生し、ご自身で処理できます。たとえば、モデルが無効な JSON を生成した場合は `ModelBehaviorError`、コードがクラッシュした場合は `UserError` などが発生する可能性があります。 ```python from agents import RunContextWrapper @@ -611,7 +611,7 @@ def get_user_profile(user_id: str) -> str: ## Agents as tools {#agents-as-tools} -ワークフローによっては、制御をハンドオフするのではなく、中央のエージェントで専門エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 +一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントで専門エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 ```python import asyncio @@ -659,7 +659,7 @@ if __name__ == "__main__": `agent.as_tool` は、エージェントをツールに変換するための便利なメソッドです。`max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session`、`needs_approval` など、一般的なランタイムオプションをサポートします。また、`parameters`、`input_builder`、`include_input_schema` による構造化入力もサポートします。 -状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は自動的には継承されません。クライアント管理の履歴を親実行とネストされた実行の間で共有するには、同じ `session` を両方に明示的に渡してください。`Runner.run` と同様に、ネストされた実行には、クライアント管理の `session`、または `previous_response_id` か `conversation_id` によるサーバー管理の継続のいずれか 1 つの状態戦略を選択してください。 +状態オプションは、ツール呼び出しによって開始されるネストされたエージェント実行を設定します。親実行の会話状態は、自動的には継承されません。親実行とネストされた実行の間でクライアント管理の履歴を共有するには、両方に同じ `session` を明示的に渡します。`Runner.run` と同様に、ネストされた実行には 1 つの状態戦略を選択します。クライアント管理の `session`、または `previous_response_id` もしくは `conversation_id` によるサーバー管理の継続です。 ```python from agents.decorators import tool @@ -683,13 +683,13 @@ async def run_my_agent() -> str: ### ツールエージェントの構造化入力 {#structured-input-for-tool-agents} -デフォルトでは、`Agent.as_tool()` は文字列フィールド `input`(`{"input": "..."}`)を 1 つ持つオブジェクトを想定しますが、`parameters`(Pydantic モデル型または dataclass 型)を渡すことで、構造化スキーマを公開できます。 +デフォルトでは、`Agent.as_tool()` は、1 つの文字列フィールド `input`(`{"input": "..."}`)を持つオブジェクトを想定します。ただし、`parameters`(Pydantic モデル型または dataclass 型)を渡すことで、構造化されたスキーマを公開できます。 -追加オプション: +追加オプション: - `include_input_schema=True` は、生成されるネストされた入力に完全な JSON Schema を含めます。 -- `input_builder=...` を使用すると、構造化されたツール引数をネストされたエージェント入力に変換する方法を完全にカスタマイズできます。 -- `RunContextWrapper.tool_input` には、ネストされた実行コンテキスト内で解析された構造化ペイロードが含まれます。 +- `input_builder=...` を使用すると、構造化されたツール引数をネストされたエージェント入力へ変換する方法を完全にカスタマイズできます。 +- `RunContextWrapper.tool_input` には、ネストされた実行コンテキスト内で解析済みの構造化ペイロードが格納されます。 ```python from pydantic import BaseModel, Field @@ -713,15 +713,15 @@ translator_tool = translator_agent.as_tool( ### ツールエージェントの承認ゲート {#approval-gates-for-tool-agents} -`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合は実行が一時停止し、保留中の項目が `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出してから再開してください。完全な一時停止/再開パターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +`Agent.as_tool(..., needs_approval=...)` は、`function_tool` と同じ承認フローを使用します。承認が必要な場合、実行は一時停止し、保留中のアイテムが `result.interruptions` に表示されます。その後、`result.to_state()` を使用し、`state.approve(...)` または `state.reject(...)` を呼び出した後に再開します。一時停止/再開の完全なパターンについては、[Human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 ### カスタム出力抽出 {#custom-output-extraction} -場合によっては、中央のエージェントに返す前に、ツールエージェントの出力を変更したいことがあります。これは、次のような場合に役立ちます。 +場合によっては、ツールエージェントの出力を中央のエージェントへ返す前に変更したいことがあります。これは、次のような場合に便利です。 -- サブエージェントのチャット履歴から特定の情報(JSON ペイロードなど)を抽出する場合。 -- エージェントの最終回答を変換または再フォーマットする場合(Markdown をプレーンテキストや CSV に変換するなど)。 -- 出力を検証する場合、またはエージェントのレスポンスが欠落している、あるいは不正な形式の場合にフォールバック値を提供する場合。 +- サブエージェントのチャット履歴から特定の情報(JSON ペイロードなど)を抽出する。 +- エージェントの最終回答を変換または再フォーマットする(Markdown をプレーンテキストや CSV に変換するなど)。 +- 出力を検証するか、エージェントのレスポンスが欠落している場合や形式が不正な場合にフォールバック値を提供する。 これを行うには、`as_tool` メソッドに `custom_output_extractor` 引数を指定します。 @@ -742,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -カスタム抽出プログラム内では、ネストされた [`RunResult`][agents.result.RunResult] から [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] にもアクセスできます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、raw 引数が必要な場合に役立ちます。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 +カスタム抽出機能内では、ネストされた [`RunResult`][agents.result.RunResult] によって [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] も公開されます。これは、ネストされた実行結果を後処理する際に、外側のツール名、呼び出し ID、または未加工の引数が必要な場合に便利です。[実行結果ガイド](results.md#agent-as-tool-metadata)を参照してください。 ### ネストされたエージェント実行のストリーミング {#streaming-nested-agent-runs} -`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが出力するストリーミングイベントをリッスンしながら、ストリームの完了後に最終出力を返せます。 +`as_tool` に `on_stream` コールバックを渡すと、ネストされたエージェントが出力するストリーミングイベントを受信しながら、ストリーム完了後に最終出力を返せます。 ```python from agents import AgentToolStreamEvent @@ -764,17 +764,17 @@ billing_agent_tool = billing_agent.as_tool( ) ``` -想定される動作: +想定される動作: -- イベントタイプは、`StreamEvent["type"]` と同様に `raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event` です。 +- イベントタイプは `StreamEvent["type"]` と同じです:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 - `on_stream` を指定すると、ネストされたエージェントが自動的にストリーミングモードで実行され、最終出力を返す前にストリームが最後まで処理されます。 -- ハンドラーは同期でも非同期でも構いません。各イベントは到着順に配信されます。 -- モデルのツール呼び出しを介してツールが呼び出された場合、`tool_call` が存在します。直接呼び出した場合は、`None` のままになる可能性があります。 +- ハンドラーは同期または非同期にできます。各イベントは到着順に配信されます。 +- モデルのツール呼び出しを介してツールが呼び出された場合、`tool_call` が存在します。直接呼び出しでは `None` のままになる場合があります。 - 完全に実行可能なサンプルについては、`examples/agent_patterns/agents_as_tools_streaming.py` を参照してください。 ### 条件付きツール有効化 {#conditional-tool-enabling} -`is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的に絞り込めます。 +`is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、またはランタイム条件に基づいて、LLM が利用できるツールを動的に絞り込めます。 ```python import asyncio @@ -829,24 +829,28 @@ async def main(): asyncio.run(main()) ``` -`is_enabled` パラメーターは、次を受け付けます。 +`is_enabled` パラメーターは、次の値を受け付けます。 -- **ブール値**: `True`(常に有効)または `False`(常に無効) -- **呼び出し可能な関数**: `(context, agent)` を受け取り、ブール値を返す関数 -- **非同期関数**: 複雑な条件ロジックに使用する非同期関数 +- **ブール値**:`True`(常に有効)または `False`(常に無効) +- **呼び出し可能な関数**:`(context, agent)` を受け取り、ブール値を返す関数 +- **非同期関数**:複雑な条件付きロジックのための非同期関数 -無効なツールはランタイムで LLM から完全に隠されるため、次の用途に役立ちます。 +無効なツールはランタイムで LLM から完全に隠されるため、次の用途に便利です。 -- ユーザー権限に基づく機能制限 +- リクエストにスコープされた機能の可視性 - 環境固有のツール可用性(開発環境と本番環境) - 異なるツール設定の A/B テスト -- ランタイム状態に基づく動的なツール絞り込み +- ランタイム状態に基づく動的なツールフィルタリング -## 実験的機能: Codex ツール {#experimental-codex-tool} +ローカルで設定された関数ツールの場合、Runner は呼び出し前にも `is_enabled` を再評価します。ただし、`is_enabled` は可視性とディスパッチを制御するものであり、ツール引数やアクセス対象のリソースに依存する認可の代わりにはなりません。これらのチェックはツール実装内で適用するか、必要に応じて[ツール入力ガードレール](guardrails.md#tool-guardrails)と[承認](human_in_the_loop.md)を使用してください。MCP サーバーは、保護された操作を自身で認可する必要があります。 -`codex_tool` は Codex CLI をラップし、エージェントがツール呼び出し中にワークスペーススコープのタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。このインターフェースは実験的機能であり、変更される可能性があります。 +関数ツール、MCP ツール、ハンドオフ全体に 1 つのアプリケーションポリシーを適用するパターンについては、[コンテキスト管理](context.md#use-local-context-for-capability-visibility)を参照してください。 -現在の実行を離れずに、メインエージェントから Codex へ範囲限定のワークスペースタスクを委任したい場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合は、`codex` であるか、`codex_` で始まる必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 +## 実験的機能:Codex ツール {#experimental-codex-tool} + +`codex_tool` は Codex CLI をラップし、ツール呼び出し中にエージェントがワークスペースにスコープされたタスク(シェル、ファイル編集、MCP ツール)を実行できるようにします。このインターフェースは実験的機能であり、変更される可能性があります。 + +メインエージェントから、現在の実行を離れることなく、範囲の限定されたワークスペースタスクを Codex に委任したい場合に使用します。デフォルトのツール名は `codex` です。カスタム名を設定する場合は、`codex` であるか、`codex_` で始まる必要があります。エージェントに複数の Codex ツールを含める場合、それぞれに一意の名前を使用する必要があります。 ```python from agents import Agent @@ -875,31 +879,31 @@ agent = Agent( ) ``` -まず、次のオプショングループを確認してください。 +まず、次のオプショングループを使用します。 -- 実行対象: `sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらを組み合わせて使用し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定してください。 -- スレッドのデフォルト設定: `default_thread_options=ThreadOptions(...)` は、モデル、推論の労力、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 -- ターンのデフォルト設定: `default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` やオプションのキャンセル用 `signal` など、ターン単位の動作を設定します。 -- ツール I/O: ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` 項目を少なくとも 1 つ含める必要があります。`output_schema` を使用すると、構造化された Codex レスポンスを必須にできます。 +- 実行対象:`sandbox_mode` と `working_directory` は、Codex が操作できる場所を定義します。これらは一緒に指定し、作業ディレクトリが Git リポジトリ内にない場合は `skip_git_repo_check=True` を設定します。 +- スレッドのデフォルト:`default_thread_options=ThreadOptions(...)` は、モデル、推論の労力、承認ポリシー、追加ディレクトリ、ネットワークアクセス、Web 検索モードを設定します。従来の `web_search_enabled` よりも `web_search_mode` を優先してください。 +- ターンのデフォルト:`default_turn_options=TurnOptions(...)` は、`idle_timeout_seconds` や任意指定のキャンセル用 `signal` など、ターンごとの動作を設定します。 +- ツール I/O:ツール呼び出しには、`{ "type": "text", "text": ... }` または `{ "type": "local_image", "path": ... }` を持つ `inputs` アイテムを少なくとも 1 つ含める必要があります。`output_schema` を使用すると、構造化された Codex レスポンスを必須にできます。 -スレッドの再利用と永続化は、個別の制御です。 +スレッドの再利用と永続化は別々に制御されます。 -- `persist_session=True` は、同じツールインスタンスへの反復呼び出しで 1 つの Codex スレッドを再利用します。 -- `use_run_context_thread_id=True` は、同じ可変コンテキストオブジェクトを共有する複数の実行間で、スレッド ID を実行コンテキストに保存して再利用します。 -- スレッド ID の優先順位は、呼び出し単位の `thread_id`、実行コンテキストのスレッド ID(有効な場合)、設定された `thread_id` オプションの順です。 +- `persist_session=True` は、同じツールインスタンスへの繰り返し呼び出しで 1 つの Codex スレッドを再利用します。 +- `use_run_context_thread_id=True` は、同じ変更可能なコンテキストオブジェクトを共有する複数の実行にわたって、スレッド ID を実行コンテキストに保存して再利用します。 +- スレッド ID の優先順位は、呼び出しごとの `thread_id`、有効な場合は実行コンテキストのスレッド ID、設定済みの `thread_id` オプションの順です。 - デフォルトの実行コンテキストキーは、`name="codex"` では `codex_thread_id`、`name="codex_"` では `codex_thread_id_` です。`run_context_thread_id_key` で上書きできます。 -ランタイム設定: +ランタイム設定: -- 認証: `CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 -- ランタイム: `codex_options.base_url` は、CLI のベース URL を上書きします。 -- バイナリ解決: CLI パスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。それ以外の場合、SDK は `PATH` から `codex` を解決し、解決できなければバンドルされているベンダーバイナリにフォールバックします。 -- 環境: `codex_options.env` は、サブプロセス環境を完全に制御します。これが指定されている場合、サブプロセスは `os.environ` を継承しません。 -- ストリーム制限: `codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの制限を制御します。有効範囲は `65536` から `67108864` までで、デフォルトは `8388608` です。 -- ストリーミング: `on_stream` は、スレッド/ターンのライフサイクルイベントと項目イベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` の項目更新)を受け取ります。 -- 出力: 実行結果には `response`、`usage`、`thread_id` が含まれ、使用量は `RunContextWrapper.usage` に追加されます。 +- 認証:`CODEX_API_KEY`(推奨)または `OPENAI_API_KEY` を設定するか、`codex_options={"api_key": "..."}` を渡します。 +- ランタイム:`codex_options.base_url` は CLI のベース URL を上書きします。 +- バイナリ解決:CLI のパスを固定するには、`codex_options.codex_path_override`(または `CODEX_PATH`)を設定します。設定しない場合、SDK は `PATH` から `codex` を解決し、見つからなければバンドルされたベンダーバイナリを使用します。 +- 環境:`codex_options.env` は、サブプロセス環境を完全に制御します。これを指定すると、サブプロセスは `os.environ` を継承しません。 +- ストリーム制限:`codex_options.codex_subprocess_stream_limit_bytes`(または `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)は、stdout/stderr リーダーの制限を制御します。有効な範囲は `65536` から `67108864` で、デフォルトは `8388608` です。 +- ストリーミング:`on_stream` は、スレッド/ターンのライフサイクルイベントとアイテムイベント(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list`、`error` アイテムの更新)を受信します。 +- 出力:実行結果には `response`、`usage`、`thread_id` が含まれ、使用量は `RunContextWrapper.usage` に追加されます。 -リファレンス: +リファレンス: - [Codex ツール API リファレンス](ref/extensions/experimental/codex/codex_tool.md) - [ThreadOptions リファレンス](ref/extensions/experimental/codex/thread_options.md) diff --git a/docs/ja/tracing.md b/docs/ja/tracing.md index d26afe4548..1114f75adf 100644 --- a/docs/ja/tracing.md +++ b/docs/ja/tracing.md @@ -4,39 +4,39 @@ search: --- # トレーシング -Agents SDK には組み込みのトレーシング機能があり、エージェントの実行中に発生する LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベントまで、包括的なイベント記録を収集します。[トレースダッシュボード](https://platform.openai.com/traces)を使用すると、開発時および本番環境でワークフローをデバッグ、可視化、監視できます。 +Agents SDK にはトレーシングが組み込まれており、エージェントの実行中に発生するイベント(LLM 生成、ツール呼び出し、ハンドオフ、ガードレール、さらにはカスタムイベント)を包括的に記録します。[Traces ダッシュボード](https://platform.openai.com/traces)を使用すると、開発環境と本番環境の両方でワークフローのデバッグ、可視化、監視を行えます。 !!!note - トレーシングはデフォルトで有効です。一般的な無効化方法は次の 3 つです。 + トレーシングはデフォルトで有効です。一般的な次の 3 つの方法で無効にできます。 1. 環境変数 `OPENAI_AGENTS_DISABLE_TRACING=1` を設定して、トレーシングをグローバルに無効化できます 2. [`set_tracing_disabled(True)`][agents.set_tracing_disabled] を使用して、コード内でトレーシングをグローバルに無効化できます - 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定して、単一の実行に対するトレーシングを無効化できます + 3. [`agents.run.RunConfig.tracing_disabled`][] を `True` に設定して、1 回の実行に対するトレーシングを無効化できます -***Zero Data Retention (ZDR) ポリシーの下で OpenAI の API を使用する組織では、トレーシングを利用できません。*** +***ゼロデータ保持(ZDR)ポリシーの下で OpenAI の API を使用する組織では、トレーシングを利用できません。*** ## トレースとスパン {#traces-and-spans} -- **トレース** は、「ワークフロー」における単一のエンドツーエンド操作を表します。トレースは複数のスパンで構成されます。トレースには次のプロパティがあります。 - - `workflow_name`: 論理的なワークフローまたはアプリの名前です。たとえば、「コード生成」や「カスタマーサービス」などです。 - - `trace_id`: トレースの一意な ID です。指定しない場合は自動的に生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 - - `group_id`: 同じ会話に属する複数のトレースを関連付けるための、省略可能なグループ ID です。たとえば、チャットスレッド ID を使用できます。 +- **トレース** は、「ワークフロー」における単一のエンドツーエンドの処理を表します。トレースはスパンで構成され、次のプロパティがあります。 + - `workflow_name`: 論理的なワークフローまたはアプリの名前です。たとえば、「コード生成」や「カスタマーサービス」です。 + - `trace_id`: トレースの一意な ID です。指定しない場合は自動生成されます。形式は `trace_<32_alphanumeric>` である必要があります。 + - `group_id`: 同じ会話に含まれる複数のトレースを関連付けるための、省略可能なグループ ID です。たとえば、チャットスレッド ID を使用できます。 - `disabled`: True の場合、トレースは記録されません。 - `metadata`: トレースの省略可能なメタデータです。 -- **スパン** は、開始時刻と終了時刻を持つ操作を表します。スパンには次のプロパティがあります。 - - `started_at` および `ended_at` のタイムスタンプ。 +- **スパン** は、開始時刻と終了時刻を持つ処理を表します。スパンには次のものがあります。 + - `started_at` と `ended_at` のタイムスタンプ。 - `trace_id`: 所属するトレースを表します - - `parent_id`: このスパンの親スパンが存在する場合、その親スパンを指します + - `parent_id`: このスパンの親スパン(存在する場合)を指します - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 ## デフォルトのトレーシング {#default-tracing} -デフォルトでは、SDK は次の項目をトレーシングします。 +デフォルトでは、SDK は次の項目をトレースします。 - `Runner.{run, run_sync, run_streamed}()` 全体が `trace()` でラップされます。 -- 各ランナー呼び出しが `task_span()` でラップされます。 -- 各モデルターンが `turn_span()` でラップされます。 +- Runner の各呼び出しが `task_span()` でラップされます。 +- モデルの各ターンが `turn_span()` でラップされます。 - エージェントが実行されるたびに、`agent_span()` でラップされます - LLM 生成が `generation_span()` でラップされます - 各関数ツール呼び出しが `function_span()` でラップされます @@ -44,11 +44,11 @@ Agents SDK には組み込みのトレーシング機能があり、エージェ - ハンドオフが `handoff_span()` でラップされます - 音声入力(音声テキスト変換)が `transcription_span()` でラップされます - 音声出力(テキスト音声変換)が `speech_span()` でラップされます -- SDK は、関連する音声スパンを `speech_group_span()` の配下にまとめる場合があります +- SDK は、関連する音声スパンを `speech_group_span()` の子としてまとめる場合があります -デフォルトでは、トレース名はリテラル文字列 `Agent workflow` です。`trace` を使用する場合はこの名前を設定でき、[`RunConfig`][agents.run.RunConfig] を使用すれば名前やその他のプロパティを設定できます。 +デフォルトのトレース名は、リテラル文字列 `Agent workflow` です。`trace` を使用する場合はこの名前を設定できます。また、[`RunConfig`][agents.run.RunConfig] を使用して、名前やその他のプロパティを構成することもできます。 -よりコンパクトな階層にする場合は、実行に対するタスクスパンとターンスパンの自動作成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、およびカスタムの各スパンは引き続き記録されます。 +よりコンパクトな階層にするには、実行時にタスクスパンとターンスパンの自動作成を無効にします。エージェント、生成、関数、ガードレール、ハンドオフ、カスタムの各スパンは引き続き記録されます。 ```python from agents import RunConfig, Runner @@ -60,11 +60,11 @@ result = await Runner.run( ) ``` -さらに、[カスタムトレースプロセッサー](#custom-tracing-processors)を設定して、別の送信先へトレースを送信できます。これは、既存の送信先の代替または追加の送信先として使用できます。 +さらに、トレースを別の送信先へ送るために、[カスタムトレースプロセッサー](#custom-tracing-processors)を設定できます(送信先の置き換え、または追加の送信先として使用できます)。 ## 長時間実行ワーカーと即時エクスポート {#long-running-workers-and-immediate-exports} -デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはインメモリキューがサイズのトリガー値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセスの終了時には最終フラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされますが、各ジョブの完了直後にはトレースダッシュボードに表示されない場合があります。 +デフォルトの [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] は、数秒ごと、またはメモリ内キューがサイズのしきい値に達した場合はそれより早く、バックグラウンドでトレースをエクスポートします。また、プロセス終了時に最終的なフラッシュも実行します。Celery、RQ、Dramatiq、FastAPI のバックグラウンドタスクなどの長時間実行ワーカーでは、通常、追加のコードなしでトレースが自動的にエクスポートされます。ただし、各ジョブの完了直後には Traces ダッシュボードに表示されない場合があります。 作業単位の終了時に即時配信を保証する必要がある場合は、トレースコンテキストの終了後に [`flush_traces()`][agents.tracing.flush_traces] を呼び出します。 @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファリングされているトレースとスパンがエクスポートされるまで処理をブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 +[`flush_traces()`][agents.tracing.flush_traces] は、現在バッファーされているトレースとスパンがエクスポートされるまで処理をブロックします。そのため、構築途中のトレースをフラッシュしないよう、`trace()` が閉じた後に呼び出してください。デフォルトのエクスポート遅延で問題ない場合は、この呼び出しを省略できます。 ## 上位レベルのトレース {#higher-level-traces} -複数回の `run()` 呼び出しを単一のトレースに含めたい場合があります。その場合は、コード全体を `trace()` でラップします。 +複数の `run()` 呼び出しを 1 つのトレースに含めたい場合があります。その場合は、コード全体を `trace()` でラップします。 ```python from agents import Agent, Runner, trace @@ -126,45 +126,45 @@ async def main(): ## トレースの作成 {#creating-traces} -[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始および終了する必要があります。これには次の 2 つの方法があります。 +[`trace()`][agents.tracing.trace] 関数を使用してトレースを作成できます。トレースは開始してから終了する必要があります。これには次の 2 つの方法があります。 -1. **推奨**: トレースをコンテキストマネージャーとして、すなわち `with trace(...) as my_trace` の形式で使用します。これにより、適切なタイミングでトレースが自動的に開始および終了されます。 +1. **推奨**: トレースをコンテキストマネージャーとして使用します。つまり、`with trace(...) as my_trace` を使用します。これにより、適切なタイミングでトレースが自動的に開始および終了します。 2. [`trace.start()`][agents.tracing.Trace.start] と [`trace.finish()`][agents.tracing.Trace.finish] を手動で呼び出すこともできます。 -現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に機能します。トレースを手動で開始および終了する場合、現在のトレースを更新するには、`start()` に `mark_as_current` を渡し、`finish()` に `reset_current` を渡します。 +現在のトレースは、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡されます。つまり、並行処理でも自動的に動作します。トレースを手動で開始および終了する場合は、現在のトレースを更新するため、`start()` に `mark_as_current` を、`finish()` に `reset_current` を渡します。 ## スパンの作成 {#creating-spans} -さまざまな [`*_span()`][agents.tracing.create] メソッドを使用してスパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するための [`custom_span()`][agents.tracing.custom_span] 関数も利用できます。 +さまざまな [`*_span()`][agents.tracing.create] メソッドを使用して、スパンを作成できます。通常、スパンを手動で作成する必要はありません。カスタムスパン情報を追跡するために、[`custom_span()`][agents.tracing.custom_span] 関数を利用できます。 -スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、最も近い現在のスパンの配下にネストされます。 +スパンは自動的に現在のトレースの一部となり、Python の [`contextvar`](https://docs.python.org/3/library/contextvars.html) を介して追跡される、現在の最も近いスパンの子としてネストされます。 ## 機密データ {#sensitive-data} -一部のスパンでは、機密性の高い可能性があるデータがキャプチャされる場合があります。 +一部のスパンでは、機密性のある可能性のあるデータが取得される場合があります。 -`generation_span()` には LLM 生成の入力と出力が保存され、`function_span()` には関数呼び出しの入力と出力が保存されます。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータのキャプチャを無効化できます。 +`generation_span()` は LLM 生成の入力と出力を保存し、`function_span()` は関数呼び出しの入力と出力を保存します。これらには機密データが含まれる可能性があるため、[`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] を使用して、そのデータの取得を無効化できます。 -同様に、音声スパンには、デフォルトで入出力音声の Base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を設定することで、この音声データのキャプチャを無効化できます。 +同様に、音声スパンには、デフォルトで入出力音声の Base64 エンコードされた PCM データが含まれます。[`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] を構成することで、この音声データの取得を無効化できます。 -デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定してエクスポートすることで、コードを変更せずにデフォルト値を設定できます。 +デフォルトでは、`trace_include_sensitive_data` は `True` です。アプリを実行する前に、環境変数 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` を `true/1` または `false/0` に設定してエクスポートすると、コードを使用せずにデフォルト値を設定できます。 ## カスタムトレースプロセッサー {#custom-tracing-processors} -トレーシングの高レベルアーキテクチャは次のとおりです。 +トレーシングの上位レベルのアーキテクチャは次のとおりです。 -- 初期化時に、トレースの作成を担当するグローバルな [`TraceProvider`][agents.tracing.provider.TraceProvider] を作成します。 -- `TraceProvider` に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を設定します。これは、トレースとスパンをバッチで [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、そこからスパンとトレースをバッチで OpenAI バックエンドにエクスポートします。 +- 初期化時に、トレースの作成を担うグローバルな [`TraceProvider`][agents.tracing.provider.TraceProvider] を作成します。 +- `TraceProvider` に [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を構成します。これは、トレースとスパンをバッチで [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、同エクスポーターがスパンとトレースを OpenAI バックエンドへバッチでエクスポートします。 -このデフォルト設定をカスタマイズし、代替または追加のバックエンドにトレースを送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 +このデフォルト設定をカスタマイズし、別のバックエンドや追加のバックエンドへトレースを送信したり、エクスポーターの動作を変更したりするには、次の 2 つの方法があります。 -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備が整ったトレースとスパンを受け取る **追加の** トレースプロセッサーを追加できます。これにより、トレースを OpenAI バックエンドに送信しながら、独自の処理も実行できます。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで **置き換える** ことができます。この場合、送信を行う `TracingProcessor` を含めない限り、トレースは OpenAI バックエンドに送信されません。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] を使用すると、準備が整ったトレースとスパンを受信する **追加の** トレースプロセッサーを追加できます。これにより、OpenAI のバックエンドへのトレース送信に加えて、独自の処理を実行できます。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで **置き換える** ことができます。この場合、トレースを送信する `TracingProcessor` を含めない限り、OpenAI のバックエンドにはトレースが送信されません。 -## OpenAI 以外のモデルによるトレーシング {#tracing-with-non-openai-models} +## OpenAI 以外のモデルでのトレーシング {#tracing-with-non-openai-models} -OpenAI 以外のモデルを使用する場合、トレーシングを無効化することなく OpenAI Traces ダッシュボードで無料のトレーシングを有効にするため、トレーシングエクスポーターに OpenAI API キーを指定できます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションを参照してください。 +OpenAI 以外のモデルを使用する場合、トレーシングを無効にすることなく OpenAI の Traces ダッシュボードで無料のトレーシングを有効にするため、トレーシングエクスポーターに OpenAI API キーを指定できます。アダプターの選択と設定に関する注意事項については、モデルガイドの[サードパーティーアダプター](models/index.md#third-party-adapters)セクションを参照してください。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -単一の実行に対してのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡します。 +1 回の実行にのみ別のトレーシングキーが必要な場合は、グローバルエクスポーターを変更する代わりに、`RunConfig` を介して渡します。 ```python from agents import Runner, RunConfig @@ -201,35 +201,35 @@ await Runner.run( - OpenAI Traces ダッシュボードで無料のトレースを確認できます。 -## エコシステム統合 {#ecosystem-integrations} +## エコシステム連携 {#ecosystem-integrations} -以下のコミュニティおよびベンダー統合は、OpenAI Agents SDK のトレーシング API サーフェスをサポートしています。 +以下のコミュニティおよびベンダーによる連携は、OpenAI Agents SDK のトレーシング API サーフェスをサポートしています。 ### 外部トレースプロセッサーの一覧 {#external-tracing-processors-list} -- [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) -- [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) +- [Weights & Biases](https://docs.wandb.ai/weave/guides/integrations/agents/openai-agents-sdk) +- [Arize Phoenix](https://arize.com/docs/phoenix/integrations/llm-providers/openai/openai-agents-sdk-tracing) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) -- [MLflow (セルフホスト型 / OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow (Databricks ホスト型)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) -- [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) -- [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) +- [MLflow(セルフホスト/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow(Databricks ホスト)](https://docs.databricks.com/aws/en/mlflow3/genai/tracing/integrations/openai-agent) +- [Braintrust](https://www.braintrust.dev/docs/integrations/agent-frameworks/openai-agents-sdk) +- [Pydantic Logfire](https://pydantic.dev/docs/logfire/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) -- [Scorecard](https://docs.scorecard.io/docs/documentation/features/tracing#openai-agents-sdk-integration) -- [Respan](https://respan.ai/docs/integrations/tracing/openai-agents-sdk) -- [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/trace_with_openai_agents_sdk) -- [Maxim AI](https://www.getmaxim.ai/docs/observe/integrations/openai-agents-sdk) -- [Comet Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) -- [Langfuse](https://langfuse.com/docs/integrations/openaiagentssdk/openai-agents) +- [Scorecard](https://docs.scorecard.io/features/tracing#agent-frameworks) +- [Respan](https://www.respan.ai/docs/integrations/openai-agents-sdk) +- [LangSmith](https://docs.langchain.com/langsmith/trace-openai) +- [Maxim AI](https://www.getmaxim.ai/docs/sdk/python/integrations/openai/agents-sdk) +- [Comet Opik](https://www.comet.com/docs/opik/integrations/openai_agents) +- [Langfuse](https://langfuse.com/integrations/frameworks/openai-agents) - [Langtrace](https://docs.langtrace.ai/supported-integrations/llm-frameworks/openai-agents-sdk) - [Okahu-Monocle](https://github.com/monocle2ai/monocle) -- [Galileo](https://v2docs.galileo.ai/integrations/openai-agent-integration#openai-agent-integration) +- [Galileo](https://docs.galileo.ai/how-to-guides/third-party-integrations/openai-agent-integration) - [Portkey AI](https://portkey.ai/docs/integrations/agents/openai-agents) -- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk) -- [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) -- [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) -- [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) +- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk/) +- [Agenta](https://agenta.ai/docs/observability/integrations/openai-agents) +- [PostHog](https://posthog.com/docs/ai-observability/installation/openai-agents) +- [Traccia](https://traccia.ai/docs/integrations/openai-agents/) +- [PromptLayer](https://docs.promptlayer.com/features/observability/traces/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) diff --git a/docs/ko/context.md b/docs/ko/context.md index 98cd4a4173..ecd478b67f 100644 --- a/docs/ko/context.md +++ b/docs/ko/context.md @@ -4,9 +4,9 @@ search: --- # 컨텍스트 관리 -컨텍스트는 여러 의미로 사용되는 용어입니다. 여기서 고려할 수 있는 컨텍스트는 크게 두 가지로 나뉩니다. +컨텍스트는 여러 의미로 사용되는 용어입니다. 고려해야 할 컨텍스트에는 크게 두 가지 유형이 있습니다. -1. 코드에서 로컬로 사용할 수 있는 컨텍스트: 도구 함수가 실행될 때, `on_handoff` 같은 콜백이나 수명 주기 훅 등에서 필요할 수 있는 데이터와 종속성입니다. +1. 코드에서 로컬로 사용할 수 있는 컨텍스트: 도구 함수 실행 시, `on_handoff` 같은 콜백 내에서, 수명 주기 훅 등에서 필요할 수 있는 데이터와 종속성입니다. 2. LLM에서 사용할 수 있는 컨텍스트: 응답을 생성할 때 LLM이 확인하는 데이터입니다. ## 로컬 컨텍스트 {#local-context} @@ -15,38 +15,48 @@ search: 1. 원하는 Python 객체를 생성합니다. 일반적으로 데이터 클래스나 Pydantic 객체를 사용합니다. 2. 해당 객체를 다양한 실행 메서드(예: `Runner.run(..., context=whatever)`)에 전달합니다. -3. 모든 도구 호출, 수명 주기 훅 등에는 래퍼 객체인 `RunContextWrapper[T]`가 전달됩니다. 여기서 `T`는 컨텍스트 객체의 유형을 나타내며, 객체 자체는 `wrapper.context`을 통해 사용할 수 있습니다. +3. 모든 도구 호출, 수명 주기 훅 등에는 래퍼 객체 `RunContextWrapper[T]`가 전달됩니다. 여기서 `T`는 컨텍스트 객체의 타입을 나타내며, 객체 자체는 `wrapper.context`을 통해 사용할 수 있습니다. -일부 런타임 전용 콜백에서는 SDK가 `RunContextWrapper[T]`의 더 특화된 하위 클래스를 전달할 수 있습니다. 예를 들어 `FunctionTool` 인스턴스의 수명 주기 훅은 일반적으로 `ToolContext`를 받으며, 이 객체는 `tool_call_id`, `tool_name`, `tool_arguments`와 같은 도구 호출 메타데이터도 제공합니다. +일부 런타임별 콜백에는 SDK가 `RunContextWrapper[T]`의 보다 특화된 하위 클래스를 전달할 수 있습니다. 예를 들어 `FunctionTool` 인스턴스의 수명 주기 훅은 일반적으로 `ToolContext`를 받으며, 이를 통해 `tool_call_id`, `tool_name`, `tool_arguments` 같은 도구 호출 메타데이터도 사용할 수 있습니다. -알아두어야 할 **가장 중요한** 사항은 특정 에이전트 실행에 사용되는 모든 에이전트, 도구 함수, 수명 주기 요소 등이 동일한 컨텍스트 _유형_을 사용해야 한다는 것입니다. +알아야 할 **가장 중요한** 사항은 특정 에이전트 실행에 사용되는 모든 에이전트, 도구 함수, 수명 주기 등이 동일한 컨텍스트 _타입_을 사용해야 한다는 것입니다. 컨텍스트는 다음과 같은 용도로 사용할 수 있습니다. - 실행에 필요한 컨텍스트 데이터(예: 사용자 이름/uid 또는 사용자에 관한 기타 정보) -- 종속성(예: 로거 객체, 데이터 페처 등) -- 헬퍼 함수 +- 종속성(예: 로거 객체, 데이터 가져오기 도구 등) +- 도우미 함수 !!! danger "참고" - 컨텍스트 객체는 LLM으로 **전송되지 않습니다**. 이는 데이터를 읽고 쓰거나 메서드를 호출할 수 있는 순수한 로컬 객체입니다. + 컨텍스트 객체는 LLM으로 **전송되지 않습니다**. 컨텍스트 객체는 읽고 쓰거나 메서드를 호출할 수 있는 순수한 로컬 객체입니다. -단일 실행 내에서 파생된 래퍼는 동일한 기본 애플리케이션 컨텍스트, 승인 상태, 사용량 추적을 공유합니다. 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에는 다른 `tool_input`가 연결될 수 있지만, 기본적으로 애플리케이션 상태의 격리된 사본이 제공되지는 않습니다. +단일 실행 내에서 파생된 래퍼는 동일한 기본 애플리케이션 컨텍스트, 승인 상태, 사용량 추적을 공유합니다. 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에는 다른 `tool_input`가 연결될 수 있지만, 기본적으로 애플리케이션 상태의 격리된 복사본을 제공하지는 않습니다. -### `RunContextWrapper`에서 제공되는 항목 {#what-runcontextwrapper-exposes} +### 기능 표시 여부를 위한 로컬 컨텍스트 사용 {#use-local-context-for-capability-visibility} -[`RunContextWrapper`][agents.run_context.RunContextWrapper]는 애플리케이션에서 정의한 컨텍스트 객체의 래퍼입니다. 실제로는 다음 항목을 가장 자주 사용합니다. +함수 도구, MCP 도구, 핸드오프가 동일한 요청 정책에 의존하는 경우 정책 입력이나 도우미를 애플리케이션 컨텍스트에 유지합니다. 각 SDK 표면은 자체 콜백을 통해 현재 실행 컨텍스트를 노출합니다. -- 변경 가능한 자체 애플리케이션 상태와 종속성을 위한 [`wrapper.context`][agents.run_context.RunContextWrapper.context] +- [`FunctionTool.is_enabled`][agents.tool.FunctionTool.is_enabled]는 `RunContextWrapper`을 받습니다. +- [`Handoff.is_enabled`][agents.handoffs.Handoff.is_enabled]는 `RunContextWrapper`을 받습니다. +- MCP [`tool_filter`](mcp.md#dynamic-tool-filtering)는 [`ToolFilterContext`][agents.mcp.ToolFilterContext]을 받으며, 이 객체의 `run_context` 속성에는 현재 `RunContextWrapper`가 포함됩니다. + +별도의 기능 목록을 유지하는 대신 공유 애플리케이션 정책을 이러한 콜백에 맞게 적용합니다. 콜백은 현재 실행에서 SDK가 노출하는 기능을 제어하지만, 모델이 생성한 인수나 리소스 선택을 승인할 수는 없습니다. 함수 도구의 경우 도구 구현 내부에서 이러한 결정을 적용하거나, 적절한 경우 [도구 입력 가드레일](guardrails.md#tool-guardrails)과 [승인](human_in_the_loop.md)을 사용합니다. MCP 서버는 자체적으로 보호된 작업을 승인해야 합니다. `input_type`이 있는 핸드오프의 경우 애플리케이션에 부수 효과가 발생하기 전에 `on_handoff` 시작 부분에서 파싱된 입력을 검사하고, 승인에 실패하면 값을 반환하는 대신 예외를 발생시킵니다. 도구 입력 가드레일은 핸드오프에 적용되지 않습니다. 콜백 수명 주기는 [핸드오프 입력](handoffs.md#handoff-inputs)을 참고하세요. + +### `RunContextWrapper`에서 제공하는 항목 {#what-runcontextwrapper-exposes} + +[`RunContextWrapper`][agents.run_context.RunContextWrapper]은 애플리케이션에서 정의한 컨텍스트 객체를 감싸는 래퍼입니다. 실제로 가장 자주 사용하는 항목은 다음과 같습니다. + +- 자체 가변 애플리케이션 상태와 종속성을 위한 [`wrapper.context`][agents.run_context.RunContextWrapper.context] - 현재 실행 전체에서 집계된 요청 및 토큰 사용량을 위한 [`wrapper.usage`][agents.run_context.RunContextWrapper.usage] -- 현재 실행이 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 내부에서 수행될 때 구조화된 입력을 위한 [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input] +- 현재 실행이 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 내에서 실행 중일 때 구조화된 입력을 위한 [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input] - 프로그래밍 방식으로 승인 상태를 업데이트해야 할 때 사용하는 [`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool] -`wrapper.context`만 애플리케이션에서 정의한 객체입니다. 다른 필드는 SDK가 관리하는 런타임 메타데이터입니다. +`wrapper.context`만 애플리케이션에서 정의한 객체입니다. 나머지 필드는 SDK가 관리하는 런타임 메타데이터입니다. -나중에 휴먼인더루프 (HITL) 또는 내구성 있는 작업 워크플로를 위해 [`RunState`][agents.run_state.RunState]를 직렬화하면 해당 런타임 메타데이터도 상태와 함께 저장됩니다. 직렬화된 상태를 영구 저장하거나 전송하려는 경우 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 비밀 정보를 넣지 마세요. +나중에 휴먼인더루프 또는 지속성 있는 작업 워크플로를 위해 [`RunState`][agents.run_state.RunState]을 직렬화하면 해당 런타임 메타데이터도 상태와 함께 저장됩니다. 직렬화된 상태를 영구 보관하거나 전송하려는 경우 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 비밀 정보를 넣지 마세요. -대화 상태는 별개의 사안입니다. 대화 턴을 이어가는 방식에 따라 `result.to_input_list()`, `session`, `conversation_id` 또는 `previous_response_id`를 사용하세요. 이러한 선택에 관한 자세한 내용은 [결과](results.md), [에이전트 실행](running_agents.md), [세션](sessions/index.md)을 참고하세요. +대화 상태는 별도로 고려해야 합니다. 대화 턴을 이어가는 방식에 따라 `result.to_input_list()`, `session`, `conversation_id`, `previous_response_id` 중 하나를 사용합니다. 이에 관한 결정은 [결과](results.md), [에이전트 실행](running_agents.md), [세션](sessions/index.md)을 참고하세요. ```python import asyncio @@ -86,18 +96,18 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 컨텍스트 객체입니다. 여기서는 데이터 클래스를 사용했지만 어떤 유형이든 사용할 수 있습니다. -2. 도구입니다. `RunContextWrapper[UserInfo]`을 받는 것을 확인할 수 있습니다. 도구 구현은 컨텍스트에서 데이터를 읽습니다. -3. 에이전트에 제네릭 `UserInfo`을 지정하여 타입 검사기가 오류를 감지할 수 있도록 합니다. 예를 들어 다른 컨텍스트 유형을 받는 도구를 전달하려 하면 오류를 감지할 수 있습니다. +1. 컨텍스트 객체입니다. 여기서는 데이터 클래스를 사용했지만, 어떤 타입이든 사용할 수 있습니다. +2. 도구입니다. 이 도구가 `RunContextWrapper[UserInfo]`을 받는 것을 확인할 수 있습니다. 도구 구현은 컨텍스트에서 데이터를 읽습니다. +3. 타입 검사기가 오류를 포착할 수 있도록 에이전트에 제네릭 `UserInfo`을 지정합니다. 예를 들어 다른 컨텍스트 타입을 받는 도구를 전달하려 하면 오류를 포착할 수 있습니다. 4. 컨텍스트가 `run` 함수에 전달됩니다. -5. 에이전트가 도구를 올바르게 호출하고 나이를 가져옵니다. +5. 에이전트가 도구를 올바르게 호출하여 나이를 가져옵니다. --- ### 고급: `ToolContext` {#advanced-toolcontext} -경우에 따라 실행 중인 도구의 이름, 호출 ID 또는 가공되지 않은 인수 문자열 같은 추가 메타데이터에 액세스해야 할 수 있습니다. -이를 위해 `RunContextWrapper`를 확장한 [`ToolContext`][agents.tool_context.ToolContext] 클래스를 사용할 수 있습니다. +경우에 따라 실행 중인 도구의 이름, 호출 ID 또는 raw 인수 문자열 같은 추가 메타데이터에 접근해야 할 수 있습니다. +이때 `RunContextWrapper`를 확장한 [`ToolContext`][agents.tool_context.ToolContext] 클래스를 사용할 수 있습니다. ```python from typing import Annotated @@ -127,24 +137,24 @@ agent = Agent( ``` `ToolContext`은 `RunContextWrapper`과 동일한 `.context` 속성을 제공하며, -현재 도구 호출에 특화된 다음과 같은 추가 필드도 제공합니다. +현재 도구 호출에 해당하는 다음과 같은 추가 필드도 제공합니다. - `tool_name` – 호출되는 도구의 이름 - `tool_call_id` – 이 도구 호출의 고유 식별자 -- `tool_arguments` – 도구에 전달된 가공되지 않은 인수 문자열 -- `tool_namespace` – 도구가 `tool_namespace()` 또는 네임스페이스를 사용하는 다른 인터페이스를 통해 로드된 경우 도구 호출의 Responses 네임스페이스 -- `qualified_tool_name` – 네임스페이스가 있는 경우 해당 네임스페이스로 한정된 도구 이름 +- `tool_arguments` – 도구에 전달된 raw 인수 문자열 +- `tool_namespace` – 도구가 `tool_namespace()` 또는 네임스페이스가 있는 다른 표면을 통해 로드된 경우 도구 호출의 Responses 네임스페이스 +- `qualified_tool_name` – 네임스페이스를 사용할 수 있는 경우 네임스페이스로 한정된 도구 이름 -실행 중에 도구 수준 메타데이터가 필요하면 `ToolContext`를 사용하세요. -에이전트와 도구 간에 일반적인 컨텍스트를 공유하는 용도로는 `RunContextWrapper`만으로도 충분합니다. `ToolContext`은 `RunContextWrapper`을 확장하므로, 중첩된 `Agent.as_tool()` 실행에서 구조화된 입력을 제공한 경우 `.tool_input`도 제공할 수 있습니다. +실행 중 도구 수준 메타데이터가 필요하면 `ToolContext`를 사용합니다. +에이전트와 도구 간의 일반적인 컨텍스트 공유에는 `RunContextWrapper`만으로 충분합니다. `ToolContext`은 `RunContextWrapper`을 확장하므로, 중첩된 `Agent.as_tool()` 실행에서 구조화된 입력이 제공된 경우 `.tool_input`도 노출할 수 있습니다. --- ## 에이전트/LLM 컨텍스트 {#agentllm-context} -LLM이 호출될 때 확인할 수 있는 데이터는 대화 기록에 있는 데이터**뿐**입니다. 따라서 LLM이 새로운 데이터를 사용할 수 있게 하려면 해당 데이터가 대화 기록에 포함되도록 해야 합니다. 이를 수행하는 방법은 몇 가지가 있습니다. +LLM이 호출될 때 확인할 수 있는 **유일한** 데이터는 대화 기록에 있는 데이터입니다. 따라서 LLM에서 새로운 데이터를 사용할 수 있게 하려면 해당 데이터를 대화 기록에서 사용할 수 있는 방식으로 제공해야 합니다. 이를 위한 방법은 몇 가지가 있습니다. -1. 에이전트의 `instructions`에 추가할 수 있습니다. 이는 "시스템 프롬프트" 또는 "개발자 메시지"라고도 합니다. 시스템 프롬프트는 정적 문자열일 수도 있고, 컨텍스트를 받아 문자열을 출력하는 동적 함수일 수도 있습니다. 항상 유용한 정보(예: 사용자의 이름이나 현재 날짜)를 제공할 때 흔히 사용하는 방법입니다. -2. `Runner.run` 함수를 호출할 때 `input`에 추가합니다. 이는 `instructions` 방식과 유사하지만, [지시 계층](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)에서 더 낮은 위치의 메시지를 사용할 수 있습니다. -3. `FunctionTool` 인스턴스를 통해 제공합니다. 이는 _필요할 때 사용하는_ 컨텍스트에 유용합니다. LLM이 데이터가 필요한 시점을 판단하고 도구를 호출하여 해당 데이터를 가져올 수 있습니다. -4. 검색 또는 웹 검색을 사용합니다. 파일이나 데이터베이스에서 관련 데이터를 가져오는 검색이나 웹에서 데이터를 가져오는 웹 검색은 이를 위한 특수 도구입니다. 이는 응답이 관련 컨텍스트 데이터에 근거하도록 하는 데 유용합니다. \ No newline at end of file +1. 에이전트의 `instructions`에 추가할 수 있습니다. 이는 "시스템 프롬프트" 또는 "개발자 메시지"라고도 합니다. 시스템 프롬프트는 정적 문자열일 수도 있고, 컨텍스트를 받아 문자열을 출력하는 동적 함수일 수도 있습니다. 항상 유용한 정보(예: 사용자의 이름 또는 현재 날짜)를 제공하는 일반적인 방법입니다. +2. `Runner.run` 함수를 호출할 때 `input`에 추가합니다. 이는 `instructions` 방식과 유사하지만, [명령 체계](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)에서 우선순위가 더 낮은 메시지를 사용할 수 있습니다. +3. `FunctionTool` 인스턴스를 통해 노출합니다. 이는 _필요할 때만_ 제공되는 컨텍스트에 유용합니다. LLM이 특정 데이터가 필요한 시점을 판단하고 도구를 호출하여 해당 데이터를 가져올 수 있습니다. +4. 검색 또는 웹 검색을 사용합니다. 이러한 특수 도구는 파일이나 데이터베이스(검색) 또는 웹(웹 검색)에서 관련 데이터를 가져올 수 있습니다. 이는 관련 컨텍스트 데이터를 기반으로 응답의 근거를 마련하는 데 유용합니다. \ No newline at end of file diff --git a/docs/ko/handoffs.md b/docs/ko/handoffs.md index 95eac022af..5f8a7db419 100644 --- a/docs/ko/handoffs.md +++ b/docs/ko/handoffs.md @@ -6,15 +6,15 @@ search: 핸드오프를 사용하면 에이전트가 다른 에이전트에 작업을 위임할 수 있습니다. 이는 서로 다른 에이전트가 각기 다른 영역을 전문적으로 처리하는 시나리오에서 특히 유용합니다. 예를 들어 고객 지원 앱에는 주문 상태, 환불, FAQ 등의 작업을 각각 전문적으로 처리하는 에이전트가 있을 수 있습니다. -핸드오프는 LLM에 도구로 표시됩니다. 따라서 `Refund Agent`라는 에이전트로 핸드오프하는 경우 도구 이름은 `transfer_to_refund_agent`이 됩니다. +핸드오프는 LLM에 도구로 표현됩니다. 따라서 `Refund Agent`이라는 에이전트로 핸드오프하는 경우 도구의 이름은 `transfer_to_refund_agent`이 됩니다. ## 핸드오프 생성 {#creating-a-handoff} -모든 에이전트에는 [`handoffs`][agents.agent.Agent.handoffs] 매개변수가 있으며, `Agent`를 직접 받거나 핸드오프를 사용자 지정하는 `Handoff` 객체를 받을 수 있습니다. +모든 에이전트에는 [`handoffs`][agents.agent.Agent.handoffs] 매개변수가 있으며, `Agent`을 직접 받거나 핸드오프를 사용자 지정하는 `Handoff` 객체를 받을 수 있습니다. -일반 `Agent` 인스턴스를 전달하면 해당 인스턴스의 [`handoff_description`][agents.agent.Agent.handoff_description]가 설정된 경우 기본 도구 설명에 추가됩니다. 완전한 `handoff()` 객체를 작성하지 않고 모델이 해당 핸드오프를 선택해야 하는 시점을 알려주는 데 사용합니다. +일반 `Agent` 인스턴스를 전달하면 해당 인스턴스의 [`handoff_description`][agents.agent.Agent.handoff_description]이 설정된 경우 기본 도구 설명에 추가됩니다. 전체 `handoff()` 객체를 작성하지 않고 모델이 해당 핸드오프를 선택해야 하는 시점을 알려주는 데 사용합니다. -Agents SDK에서 제공하는 [`handoff()`][agents.handoffs.handoff] 함수를 사용하여 핸드오프를 생성할 수 있습니다. 이 함수를 사용하면 선택적 재정의 및 입력 필터와 함께 핸드오프할 에이전트를 지정할 수 있습니다. +Agents SDK에서 제공하는 [`handoff()`][agents.handoffs.handoff] 함수를 사용하여 핸드오프를 생성할 수 있습니다. 이 함수를 사용하면 핸드오프할 에이전트와 선택적 재정의 및 입력 필터를 지정할 수 있습니다. ### 기본 사용법 {#basic-usage} @@ -36,16 +36,16 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun [`handoff()`][agents.handoffs.handoff] 함수를 사용하면 여러 항목을 사용자 지정할 수 있습니다. -- `agent`: 작업을 핸드오프할 대상 에이전트입니다. -- `tool_name_override`: 기본적으로 `transfer_to_`으로 해석되는 `Handoff.default_tool_name()` 함수를 사용합니다. 이를 재정의할 수 있습니다. +- `agent`: 핸드오프 대상 에이전트입니다. +- `tool_name_override`: 기본적으로 `transfer_to_`으로 확인되는 `Handoff.default_tool_name()` 함수가 사용됩니다. 이를 재정의할 수 있습니다. - `tool_description_override`: `Handoff.default_tool_description()`의 기본 도구 설명을 재정의합니다. -- `on_handoff`: 핸드오프가 호출될 때 실행되는 콜백 함수입니다. 핸드오프가 호출되는 것을 확인하는 즉시 데이터 가져오기 등을 시작할 때 유용합니다. 이 함수는 에이전트 컨텍스트를 받으며, 선택적으로 LLM이 생성한 입력도 받을 수 있습니다. 입력 데이터는 `input_type` 매개변수로 제어합니다. +- `on_handoff`: 핸드오프가 호출될 때 실행되는 콜백 함수입니다. 핸드오프가 호출되는 즉시 데이터 가져오기를 시작하는 등의 작업에 유용합니다. 이 함수는 에이전트 컨텍스트를 받으며, 선택적으로 LLM이 생성한 입력도 받을 수 있습니다. 입력 데이터는 `input_type` 매개변수로 제어합니다. - `input_type`: 핸드오프 도구 호출 인수의 스키마입니다. 설정하면 파싱된 페이로드가 `on_handoff`에 전달됩니다. - `input_filter`: 다음 에이전트가 받는 입력을 필터링할 수 있습니다. 자세한 내용은 아래를 참조하세요. - `is_enabled`: 핸드오프의 활성화 여부입니다. 불리언 또는 불리언을 반환하는 함수일 수 있으므로 런타임에 핸드오프를 동적으로 활성화하거나 비활성화할 수 있습니다. -- `nest_handoff_history`: RunConfig 수준의 `nest_handoff_history` 설정을 핸드오프별로 재정의하는 선택적 항목입니다. 값이 `None`이면 활성 실행 구성에 정의된 값을 대신 사용합니다. +- `nest_handoff_history`: RunConfig 수준의 `nest_handoff_history` 설정에 대한 선택적 핸드오프별 재정의입니다. `None`이면 활성 실행 구성에 정의된 값이 대신 사용됩니다. -[`handoff()`][agents.handoffs.handoff] 헬퍼는 항상 전달된 특정 `agent`로 제어권을 이전합니다. 가능한 대상이 여러 개라면 대상마다 하나의 핸드오프를 등록하고 모델이 그중에서 선택하도록 합니다. 자체 핸드오프 코드가 호출 시점에 반환할 에이전트를 결정해야 하는 경우에만 사용자 지정 [`Handoff`][agents.handoffs.Handoff]을 사용합니다. +[`handoff()`][agents.handoffs.handoff] 헬퍼는 항상 전달된 특정 `agent`으로 제어권을 이전합니다. 가능한 대상이 여러 개인 경우 대상별로 핸드오프를 하나씩 등록하고 모델이 그중에서 선택하도록 합니다. 자체 핸드오프 코드가 호출 시점에 반환할 에이전트를 결정해야 하는 경우에만 사용자 지정 [`Handoff`][agents.handoffs.Handoff]을 사용합니다. ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## 핸드오프 입력 {#handoff-inputs} -특정 상황에서는 LLM이 핸드오프를 호출할 때 일부 데이터를 제공하도록 해야 할 수 있습니다. 예를 들어 "에스컬레이션 에이전트"로 핸드오프한다고 가정해 보겠습니다. 모델이 이유를 제공하도록 하여 이를 기록할 수 있습니다. +특정 상황에서는 핸드오프를 호출할 때 LLM이 일부 데이터를 제공하도록 할 수 있습니다. 예를 들어 "에스컬레이션 에이전트"로 핸드오프한다고 가정해 보겠습니다. 기록을 남길 수 있도록 모델이 사유를 제공하게 할 수 있습니다. ```python from pydantic import BaseModel @@ -87,44 +87,46 @@ handoff_obj = handoff( ) ``` -`input_type` 항목은 핸드오프 도구 호출 자체의 인수를 설명합니다. SDK는 해당 스키마를 핸드오프 도구의 `parameters`로 모델에 노출하고, 반환된 JSON을 로컬에서 검증한 후 파싱된 값을 `on_handoff`에 전달합니다. +`input_type`은 핸드오프 도구 호출 자체의 인수를 설명합니다. SDK는 해당 스키마를 핸드오프 도구의 `parameters`로 모델에 노출하고, 반환된 JSON을 로컬에서 검증한 다음, 파싱된 값을 `on_handoff`에 전달합니다. -이는 다음 에이전트의 기본 입력을 대체하지 않으며 다른 대상을 선택하지도 않습니다. [`handoff()`][agents.handoffs.handoff] 헬퍼는 여전히 래핑한 특정 에이전트로 제어권을 이전하며, [`input_filter`][agents.handoffs.Handoff.input_filter] 또는 중첩 핸드오프 히스토리 설정을 사용하여 변경하지 않는 한 수신 에이전트는 계속 대화 히스토리를 확인합니다. +`is_enabled`은 SDK가 사용 가능한 핸드오프를 준비하는 동안, 모델이 핸드오프 인수를 반환하기 전에 평가되므로 인수가 있는 핸드오프 내부의 값을 승인할 수 없습니다. 승인이 파싱된 필드에 따라 달라지는 경우 애플리케이션 부작용이 발생하기 전에 `on_handoff` 시작 부분에서 확인을 수행합니다. 승인이 실패하면 값을 반환하지 말고 예외를 발생시키세요. `on_handoff`이 성공적으로 반환되면 SDK가 이전을 계속 진행합니다. 도구 입력 가드레일은 함수 도구에 적용되며 핸드오프에는 적용되지 않습니다. -`input_type` 항목은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]와도 별개입니다. 이미 로컬에 있는 애플리케이션 상태나 종속성이 아니라, 핸드오프 시점에 모델이 결정하는 메타데이터에 `input_type`을 사용합니다. +이는 다음 에이전트의 기본 입력을 대체하지 않으며 다른 대상을 선택하지도 않습니다. [`handoff()`][agents.handoffs.handoff] 헬퍼는 여전히 래핑된 특정 에이전트로 이전하며, [`input_filter`][agents.handoffs.Handoff.input_filter] 또는 중첩 핸드오프 기록 설정으로 변경하지 않는 한 수신 에이전트는 계속 대화 기록을 볼 수 있습니다. + +`input_type`은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]과도 별개입니다. 이미 로컬에 있는 애플리케이션 상태나 종속성이 아니라 핸드오프 시점에 모델이 결정하는 메타데이터에 `input_type`을 사용합니다. ### `input_type` 사용 시점 {#when-to-use-input_type} -핸드오프에 `reason`, `language`, `priority`, `summary` 같은 소량의 모델 생성 메타데이터가 필요한 경우 `input_type`을 사용합니다. 예를 들어 분류 에이전트는 `{ "reason": "duplicate_charge", "priority": "high" }`와 함께 환불 에이전트로 핸드오프할 수 있으며, 환불 에이전트가 작업을 넘겨받기 전에 `on_handoff`에서 해당 메타데이터를 기록하거나 저장할 수 있습니다. +핸드오프에 `reason`, `language`, `priority`, `summary` 같은 모델 생성 메타데이터가 소량 필요한 경우 `input_type`을 사용합니다. 예를 들어 분류 에이전트는 `{ "reason": "duplicate_charge", "priority": "high" }`을 사용하여 환불 에이전트로 핸드오프할 수 있고, 환불 에이전트가 작업을 넘겨받기 전에 `on_handoff`에서 해당 메타데이터를 기록하거나 저장할 수 있습니다. 목적이 다른 경우에는 다른 메커니즘을 선택합니다. - 기존 애플리케이션 상태와 종속성은 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 넣습니다. [컨텍스트 가이드](context.md)를 참조하세요. -- 수신 에이전트에 표시되는 히스토리를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter], [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 또는 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 사용합니다. -- 가능한 전문 에이전트가 여러 개라면 대상마다 하나의 핸드오프를 등록합니다. `input_type`을 사용하면 선택된 핸드오프에 메타데이터를 추가할 수 있지만 대상 간 디스패치를 수행하지는 않습니다. +- 수신 에이전트에 표시되는 기록을 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter], [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 또는 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]를 사용합니다. +- 가능한 전문 에이전트가 여러 명이면 대상별로 핸드오프를 하나씩 등록합니다. `input_type`은 선택된 핸드오프에 메타데이터를 추가할 수 있지만 대상 간 디스패치는 수행하지 않습니다. - 대화를 이전하지 않고 중첩된 전문 에이전트에 구조화된 입력을 제공하려면 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]을 사용하는 것이 좋습니다. [도구](tools.md#structured-input-for-tool-agents)를 참조하세요. ## 입력 필터 {#input-filters} -핸드오프가 발생하면 새 에이전트가 대화를 넘겨받아 이전의 전체 대화 히스토리를 확인하는 것과 같습니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]을 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받고 새로운 `HandoffInputData`를 반환해야 하는 함수입니다. +핸드오프가 발생하면 새 에이전트가 대화를 이어받고 이전의 전체 대화 기록을 볼 수 있습니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]을 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받고 새로운 `HandoffInputData`을 반환해야 하는 함수입니다. [`HandoffInputData`][agents.handoffs.HandoffInputData]에는 다음 항목이 포함됩니다. -- `input_history`: `Runner.run(...)` 시작 전의 입력 히스토리 +- `input_history`: `Runner.run(...)`이 시작되기 전의 입력 기록 - `pre_handoff_items`: 핸드오프가 호출된 에이전트 턴 이전에 생성된 항목 -- `new_items`: 핸드오프 호출 및 핸드오프 출력 항목을 포함해 현재 턴 중에 생성된 항목 -- `input_items`: `new_items` 대신 다음 에이전트에 전달할 선택적 항목으로, 세션 히스토리의 `new_items`은 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. +- `new_items`: 핸드오프 호출 및 핸드오프 출력 항목을 포함하여 현재 턴 중에 생성된 항목 +- `input_items`: `new_items` 대신 다음 에이전트에 전달할 선택적 항목으로, 세션 기록에 사용할 `new_items`은 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. - `run_context`: 핸드오프가 호출된 시점의 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper] -중첩 핸드오프 히스토리는 옵트인 베타로 제공되며 안정화가 진행되는 동안 기본적으로 비활성화됩니다. [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]을 활성화하면 러너는 요약 가능한 히스토리를 순서가 지정된 어시스턴트 요약 세그먼트로 압축하면서, 무손실 메시지 항목은 원래 위치에 보존합니다. 생성된 각 요약 세그먼트는 `` 래퍼를 사용하며, 이후 핸드오프에서는 순서가 지정된 트랜스크립트를 다시 구성하기 전에 이전에 생성된 세그먼트를 평면화합니다. 세션, `RunState`, `RunResult.to_input_list()`는 이 SDK 기본 히스토리로 이동된 정확한 메시지 출현 항목을 추적하여 해당 항목이 두 번 추가되지 않도록 합니다. 별도로 존재하는 동일한 메시지는 계속 보존됩니다. 내장 세그먼트화 대신 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환하도록 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]을 통해 자체 매핑 함수를 제공할 수 있습니다. 이 옵트인은 핸드오프의 `input_filter`과 활성 실행의 `RunConfig.handoff_input_filter`가 모두 설정되지 않은 경우에만 적용되므로, 이미 페이로드를 사용자 지정하는 기존 코드(이 리포지토리의 코드 예제 포함)는 변경 없이 현재 동작을 유지합니다. [`handoff(...)`][agents.handoffs.handoff]에 `nest_handoff_history=True` 또는 `False`를 전달하여 단일 핸드오프의 중첩 동작을 재정의할 수 있으며, 이렇게 하면 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]이 설정됩니다. 생성된 요약 세그먼트의 래퍼 텍스트만 변경하려면 에이전트를 실행하기 전에 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출합니다. 이후 실행에서 기본 래퍼를 복원해야 하는 경우 실행 전에 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출합니다. +중첩 핸드오프 기록은 선택적으로 활성화할 수 있는 베타 기능이며, 기능을 안정화하는 동안 기본적으로 비활성화되어 있습니다. [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]을 활성화하면 러너는 원래 위치의 무손실 메시지 항목을 보존하면서 요약 가능한 기록을 순서가 지정된 어시스턴트 요약 세그먼트로 압축합니다. 생성된 각 요약 세그먼트는 `` 래퍼를 사용하며, 이후 핸드오프는 순서가 지정된 트랜스크립트를 다시 구성하기 전에 앞서 생성된 세그먼트를 평탄화합니다. 세션, `RunState`, `RunResult.to_input_list()`는 SDK 기본 기록으로 이동된 정확한 메시지 발생 항목을 추적하여 해당 항목이 두 번 추가되지 않도록 합니다. 별도의 동일한 메시지는 계속 보존됩니다. 기본 제공 세분화를 사용하는 대신 다음 에이전트에 전달할 정확한 입력 항목 목록을 반환하려면 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]을 통해 자체 매핑 함수를 제공할 수 있습니다. 이 선택적 활성화는 핸드오프의 `input_filter`와 활성 실행의 `RunConfig.handoff_input_filter`가 모두 설정되지 않은 경우에만 적용되므로, 이 저장소의 코드 예제를 포함해 이미 페이로드를 사용자 지정하는 기존 코드는 변경 없이 현재 동작을 유지합니다. [`handoff(...)`][agents.handoffs.handoff]에 `nest_handoff_history=True` 또는 `False`을 전달하여 단일 핸드오프의 중첩 동작을 재정의할 수 있으며, 이렇게 하면 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]이 설정됩니다. 생성된 요약 세그먼트의 래퍼 텍스트만 변경하려면 에이전트를 실행하기 전에 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]을 호출합니다. 이후 실행 전에 기본 래퍼를 복원해야 하는 경우 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]을 호출합니다. -핸드오프와 활성 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]이 모두 필터를 정의한 경우 핸드오프별 [`input_filter`][agents.handoffs.Handoff.input_filter]이 해당 핸드오프에서 우선합니다. +핸드오프와 활성 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]가 모두 필터를 정의한 경우 해당 핸드오프에서는 핸드오프별 [`input_filter`][agents.handoffs.Handoff.input_filter]이 우선합니다. !!! note - 핸드오프는 단일 실행 내에서 유지됩니다. 입력 가드레일은 여전히 체인의 첫 번째 에이전트에만 적용되고, 출력 가드레일은 최종 출력을 생성하는 에이전트에만 적용됩니다. 워크플로 내의 각 사용자 지정 함수 도구 호출을 검사해야 하는 경우 도구 가드레일을 사용합니다. + 핸드오프는 단일 실행 내에서 유지됩니다. 입력 가드레일은 여전히 체인의 첫 번째 에이전트에만 적용되고 출력 가드레일은 최종 출력을 생성하는 에이전트에만 적용됩니다. 워크플로 내부의 각 사용자 지정 함수 도구 호출 전후에 검사가 필요하면 도구 가드레일을 사용합니다. -히스토리에서 모든 도구 호출을 제거하는 것과 같은 몇 가지 일반적인 패턴은 [`agents.extensions.handoff_filters`][]에 구현되어 있습니다. +기록에서 모든 도구 호출을 제거하는 것과 같은 몇 가지 일반적인 패턴은 [`agents.extensions.handoff_filters`][]에 구현되어 있습니다. ```python from agents import Agent, handoff @@ -138,11 +140,11 @@ handoff_obj = handoff( ) ``` -1. `FAQ agent` 호출 시 히스토리에서 모든 도구 관련 항목을 자동으로 제거합니다. +1. `FAQ agent`이 호출되면 기록에서 도구와 관련된 모든 항목이 자동으로 제거됩니다. ## 권장 프롬프트 {#recommended-prompts} -LLM이 핸드오프를 올바르게 이해하도록 하려면 에이전트에 핸드오프 관련 정보를 포함하는 것이 좋습니다. [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][]에 권장 접두사가 있으며, [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][]을 호출하여 프롬프트에 권장 데이터를 자동으로 추가할 수도 있습니다. +LLM이 핸드오프를 올바르게 이해하도록 하려면 에이전트에 핸드오프 관련 정보를 포함하는 것이 좋습니다. [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][]에 권장 접두사가 있으며, [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][]을 호출하여 권장 데이터를 프롬프트에 자동으로 추가할 수도 있습니다. ```python from agents import Agent diff --git a/docs/ko/tools.md b/docs/ko/tools.md index 58fb36a62c..1b1e2e5fa3 100644 --- a/docs/ko/tools.md +++ b/docs/ko/tools.md @@ -4,44 +4,44 @@ search: --- # 도구 -도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용과 같은 작업을 수행할 수 있습니다. SDK는 다음 다섯 가지 카테고리를 지원합니다. +도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용 등의 작업을 수행할 수 있습니다. SDK는 다음 다섯 가지 카테고리를 지원합니다. -- OpenAI 호스티드 툴: OpenAI 서버에서 모델을 위해 실행됩니다. -- 로컬/런타임 실행 도구: `ComputerTool` 및 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`는 로컬 또는 호스티드 컨테이너에서 실행할 수 있습니다. -- `FunctionTool` 인스턴스: 모든 Python 함수를 도구로 래핑합니다. -- Agents as tools: 전체 핸드오프 없이 에이전트를 호출 가능한 도구로 노출합니다. -- 실험적 기능: Codex 도구: 도구 호출을 통해 워크스페이스 범위의 Codex 작업을 실행합니다. +- OpenAI 호스티드 도구: OpenAI 서버에서 모델을 위해 실행됩니다. +- 로컬/런타임 실행 도구: `ComputerTool` 및 `ApplyPatchTool`은 항상 사용자의 환경에서 실행되며, `ShellTool`는 로컬 또는 호스티드 컨테이너에서 실행될 수 있습니다. +- `FunctionTool` 인스턴스: 모든 Python 함수를 도구로 래핑합니다. +- Agents as tools: 전체 핸드오프 없이 에이전트를 호출 가능한 도구로 노출합니다. +- 실험적 기능: Codex 도구: 도구 호출을 통해 워크스페이스 범위의 Codex 작업을 실행합니다. ## 도구 유형 선택 {#choosing-a-tool-type} -이 페이지를 카탈로그로 활용한 다음, 제어하는 런타임에 해당하는 섹션으로 이동하세요. +이 페이지를 카탈로그로 사용한 다음, 제어하려는 런타임에 해당하는 섹션으로 이동하세요. -| 원하는 작업 | 시작 위치 | +| 원하는 작업 | 시작할 위치 | | --- | --- | -| OpenAI 관리 도구 사용(웹 검색, 파일 검색, Code Interpreter, 호스티드 MCP, 이미지 생성) | [호스티드 툴](#hosted-tools) | -| 도구 검색을 사용해 대규모 도구 범위를 런타임까지 지연 | [호스티드 도구 검색](#hosted-tool-search) | +| OpenAI 관리형 도구(웹 검색, 파일 검색, Code Interpreter, 호스티드 MCP, 이미지 생성) 사용 | [호스티드 툴](#hosted-tools) | +| 도구 검색을 사용하여 대규모 도구 표면을 런타임까지 지연 | [호스티드 툴 검색](#hosted-tool-search) | | 생성된 JavaScript에서 여러 도구 호출 조정 | [프로그래밍 방식 도구 호출](#programmatic-tool-calling) | | 자체 프로세스 또는 환경에서 도구 실행 | [로컬 런타임 도구](#local-runtime-tools) | | Python 함수를 도구로 래핑 | [함수 도구](#function-tools) | -| 핸드오프 없이 한 에이전트가 다른 에이전트 호출 | [Agents as tools](#agents-as-tools) | +| 핸드오프 없이 한 에이전트가 다른 에이전트를 호출하도록 설정 | [Agents as tools](#agents-as-tools) | | 에이전트에서 워크스페이스 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | ## 호스티드 툴 {#hosted-tools} -OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 다음과 같은 기본 제공 도구를 제공합니다. +OpenAI는 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]을 사용할 때 다음과 같은 몇 가지 기본 제공 도구를 제공합니다. -- [`WebSearchTool`][agents.tool.WebSearchTool]를 사용하면 에이전트가 웹을 검색할 수 있습니다. -- [`FileSearchTool`][agents.tool.FileSearchTool]를 사용하면 OpenAI 벡터 스토어에서 정보를 검색할 수 있습니다. -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool]를 사용하면 LLM이 샌드박스 환경에서 코드를 실행할 수 있습니다. -- [`HostedMCPTool`][agents.tool.HostedMCPTool]는 원격 MCP 서버의 도구를 모델에 노출합니다. -- [`ImageGenerationTool`][agents.tool.ImageGenerationTool]는 프롬프트로 이미지를 생성합니다. -- [`ToolSearchTool`][agents.tool.ToolSearchTool]를 사용하면 모델이 지연된 도구, 네임스페이스 또는 호스티드 MCP 서버를 필요할 때 불러올 수 있습니다. -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]를 사용하면 모델이 생성된 JavaScript에서 사용 가능한 도구를 조정할 수 있습니다. +- [`WebSearchTool`][agents.tool.WebSearchTool]를 사용하면 에이전트가 웹을 검색할 수 있습니다. +- [`FileSearchTool`][agents.tool.FileSearchTool]를 사용하면 OpenAI 벡터 스토어에서 정보를 검색할 수 있습니다. +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool]를 사용하면 LLM이 샌드박스 환경에서 코드를 실행할 수 있습니다. +- [`HostedMCPTool`][agents.tool.HostedMCPTool]은 원격 MCP 서버의 도구를 모델에 노출합니다. +- [`ImageGenerationTool`][agents.tool.ImageGenerationTool]은 프롬프트로 이미지를 생성합니다. +- [`ToolSearchTool`][agents.tool.ToolSearchTool]을 사용하면 모델이 지연된 도구, 네임스페이스 또는 호스티드 MCP 서버를 필요할 때 로드할 수 있습니다. +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool]을 사용하면 모델이 생성된 JavaScript에서 사용 가능한 도구를 조정할 수 있습니다. 고급 호스티드 검색 옵션: -- `FileSearchTool`는 `vector_store_ids` 및 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. `max_num_results`을 1에서 50 사이의 정수로 설정하세요. `None` 또는 0을 사용하면 공급자 기본값이 적용됩니다. -- `WebSearchTool`는 `filters`, `user_location`, `search_context_size`을 지원합니다. +- `FileSearchTool`는 `vector_store_ids` 및 `max_num_results` 외에도 `filters`, `ranking_options`, `include_search_results`를 지원합니다. `max_num_results`을 1~50 사이의 정수로 설정하세요. `None` 또는 0을 사용하면 제공자의 기본값이 적용됩니다. +- `WebSearchTool`은 `filters`, `user_location`, `search_context_size`을 지원합니다. ```python from agents import Agent, FileSearchTool, Runner, WebSearchTool @@ -62,11 +62,11 @@ async def main(): print(result.final_output) ``` -### 호스티드 도구 검색 {#hosted-tool-search} +### 호스티드 툴 검색 {#hosted-tool-search} -도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 범위의 로드를 런타임까지 지연하여 현재 턴에 필요한 하위 집합만 불러올 수 있습니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많을 때 모든 도구를 미리 노출하지 않고 도구 스키마 토큰을 줄이는 데 유용합니다. +도구 검색을 사용하면 OpenAI Responses 모델이 대규모 도구 표면을 런타임까지 지연하여 현재 턴에 필요한 하위 집합만 로드할 수 있습니다. 함수 도구, 네임스페이스 그룹 또는 호스티드 MCP 서버가 많으며 모든 도구를 미리 노출하지 않고 도구 스키마 토큰을 줄이려는 경우 유용합니다. -에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 도구 검색으로 시작하세요. 애플리케이션에서 무엇을 불러올지 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행 도구 검색도 지원하지만, 표준 `Runner`는 해당 모드를 자동으로 실행하지 않습니다. +에이전트를 빌드할 때 후보 도구가 이미 정해져 있다면 호스티드 툴 검색으로 시작하세요. 애플리케이션에서 무엇을 로드할지 동적으로 결정해야 하는 경우 Responses API는 클라이언트 실행 방식의 도구 검색도 지원하지만, 표준 `Runner`는 해당 모드를 자동 실행하지 않습니다. ```python from typing import Annotated @@ -111,26 +111,26 @@ print(result.final_output) 알아둘 사항: -- 호스티드 도구 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원 여부는 `openai>=2.25.0`에 따라 달라집니다. -- 에이전트에서 지연 로딩 범위를 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. -- 검색 가능한 범위에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`이 포함됩니다. -- 지연 로딩 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 설정에서는 모델이 필요할 때 올바른 그룹을 불러올 수 있도록 `ToolSearchTool()`도 사용할 수 있습니다. -- `tool_namespace()`는 `FunctionTool` 인스턴스를 공유 네임스페이스 이름과 설명 아래에 그룹화합니다. `crm`, `billing`, `shipping`처럼 관련 도구가 많은 경우 일반적으로 가장 적합합니다. -- OpenAI의 공식 권장 지침은 [가능하면 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. -- 가능하면 개별적으로 지연된 여러 함수보다 네임스페이스나 호스티드 MCP 서버를 우선 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 범위를 제공하고 토큰을 더 많이 절약할 수 있습니다. -- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`이 없는 도구는 즉시 호출할 수 있으며, 같은 네임스페이스에 있는 지연된 도구는 도구 검색을 통해 불러옵니다. -- 일반적으로 각 네임스페이스를 비교적 작게 유지하며, 이상적으로는 함수 수를 10개 미만으로 제한하세요. -- 이름이 지정된 `tool_choice`은 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 우선 사용하세요. -- `ToolSearchTool(execution="client")`은 수동 Responses 오케스트레이션에 사용합니다. 모델이 클라이언트 실행 `tool_search_call`를 내보내면 표준 `Runner`는 대신 실행하지 않고 예외를 발생시킵니다. -- 도구 검색 활동은 전용 항목 및 이벤트 유형과 함께 [`RunResult.new_items`](results.md#new-items) 및 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 표시됩니다. -- 네임스페이스 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 예제는 `examples/tools/tool_search.py`을 참고하세요. -- 공식 플랫폼 가이드: [도구 검색](https://developers.openai.com/api/docs/guides/tools-tool-search) +- 호스티드 툴 검색은 OpenAI Responses 모델에서만 사용할 수 있습니다. 현재 Python SDK 지원 여부는 `openai>=2.25.0`에 따라 달라집니다. +- 에이전트에 지연 로딩 표면을 구성할 때 `ToolSearchTool()`을 정확히 하나 추가하세요. +- 검색 가능한 표면에는 `@function_tool(defer_loading=True)`, `tool_namespace(name=..., description=..., tools=[...])`, `HostedMCPTool(tool_config={..., "defer_loading": True})`가 포함됩니다. +- 지연 로딩 함수 도구는 `ToolSearchTool()`과 함께 사용해야 합니다. 네임스페이스만 사용하는 설정에서는 모델이 필요할 때 올바른 그룹을 로드하도록 `ToolSearchTool()`을 사용할 수도 있습니다. +- `tool_namespace()`는 공유 네임스페이스 이름과 설명 아래에 `FunctionTool` 인스턴스를 그룹화합니다. 일반적으로 `crm`, `billing`, `shipping`처럼 관련 도구가 많은 경우 가장 적합합니다. +- OpenAI의 공식 모범 사례 지침은 [가능하면 네임스페이스 사용](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)입니다. +- 가능하면 개별적으로 지연된 여러 함수보다 네임스페이스 또는 호스티드 MCP 서버를 사용하세요. 일반적으로 모델에 더 나은 상위 수준 검색 표면을 제공하고 토큰을 더 많이 절약할 수 있습니다. +- 네임스페이스에는 즉시 사용 가능한 도구와 지연된 도구를 함께 포함할 수 있습니다. `defer_loading=True`이 없는 도구는 즉시 호출할 수 있으며, 같은 네임스페이스의 지연된 도구는 도구 검색을 통해 로드됩니다. +- 일반적으로 각 네임스페이스를 비교적 작게 유지하고, 가급적 함수 수를 10개 미만으로 구성하세요. +- 이름이 지정된 `tool_choice`은 단독 네임스페이스 이름이나 지연 전용 도구를 대상으로 지정할 수 없습니다. `auto`, `required` 또는 실제 최상위 호출 가능 도구 이름을 사용하세요. +- `ToolSearchTool(execution="client")`은 수동 Responses 오케스트레이션용입니다. 모델이 클라이언트 실행 방식의 `tool_search_call`를 내보내면 표준 `Runner`는 이를 대신 실행하지 않고 예외를 발생시킵니다. +- 도구 검색 활동은 전용 항목 및 이벤트 유형을 사용하여 [`RunResult.new_items`](results.md#new-items)와 [`RunItemStreamEvent`](streaming.md#run-item-event-names)에 표시됩니다. +- 네임스페이스 로딩과 최상위 지연 도구를 모두 다루는 완전한 실행 가능 예제는 `examples/tools/tool_search.py`을 참조하세요. +- 공식 플랫폼 가이드: [도구 검색](https://developers.openai.com/api/docs/guides/tools-tool-search) ### 프로그래밍 방식 도구 호출 {#programmatic-tool-calling} -프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 출력을 결합하고, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델과의 왕복 없이 루프, 분기, 병렬 호출 또는 중간 계산을 활용하는 범위가 제한된 워크플로에 유용합니다. +프로그래밍 방식 도구 호출을 사용하면 지원되는 OpenAI Responses 모델이 사용 가능한 도구를 호출하고, 출력을 결합하며, 하나의 결과를 모델에 반환하는 JavaScript를 생성할 수 있습니다. 모든 도구 호출 후 모델 왕복을 수행하지 않고도 루프, 분기, 병렬 호출 또는 중간 계산을 활용할 수 있는 범위가 한정된 워크플로에 유용합니다. -생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. Node.js API, 파일 시스템 또는 네트워크에 접근할 수 없으며 프로세스도 지속되지 않습니다. 프로그램은 명시적으로 허용한 도구하고만 상호작용할 수 있습니다. +생성된 프로그램은 새로운 호스티드 V8 환경에서 실행됩니다. 이 환경에는 Node.js API, 파일 시스템이나 네트워크 액세스 또는 영구 프로세스가 없습니다. 프로그램은 명시적으로 허용한 도구와만 상호작용할 수 있습니다. ```python from pydantic import BaseModel @@ -167,18 +167,18 @@ print(result.final_output) 알아둘 사항: -- 프로그래밍 방식 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. `ProgrammaticToolCallingTool()` 및 `tool_choice="programmatic_tool_calling"`은 Chat Completions 모델과 Responses 이외의 백엔드에서 거부됩니다. -- 에이전트에 `ProgrammaticToolCallingTool()`을 최대 하나만 추가하세요. 또한 에이전트는 프로그래밍 방식으로 호출할 수 있는 도구를 하나 이상 노출하거나, 네임스페이스·지연 함수·지연된 호스티드 MCP 서버가 뒷받침하는 `ToolSearchTool()` 또는 불투명한 프롬프트 관리 도구 범위를 노출해야 합니다. 검색 가능한 범위가 없는 단독 `ToolSearchTool()`은 거부됩니다. -- `allowed_callers`는 도구를 호출할 수 있는 방식을 제어합니다. 생략하면 모델의 직접 호출만 허용됩니다. 프로그램 전용 접근에는 `["programmatic"]`을 사용하고, 두 방식 모두 허용하려면 `["direct", "programmatic"]`를 사용하세요. -- 선택적으로 사용할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸 및 패치 적용 도구는 `allowed_callers`을 직접 노출합니다. 호스티드 MCP와 Code Interpreter의 경우 `tool_config` 내부에 `allowed_callers`를 설정하세요. -- `@function_tool(allowed_callers=[...])`의 경우 Pydantic 모델, TypedDict 또는 데이터 클래스와 같은 구조화된 반환 어노테이션은 자동으로 엄격한 객체 출력 스키마가 되며, 반환 값은 프로그램에 반환되기 전에 해당 스키마에 따라 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없으면 `output_type=...`를 사용하고, 엄격한 객체 스키마를 이미 가지고 있다면 저수준 우회 수단인 `output_json_schema={...}`을 사용하세요. `output_type`과 `output_json_schema`은 함께 사용할 수 없습니다. `str`, `Any`, `None` 반환 어노테이션은 출력 스키마를 생성하지 않습니다. 스키마가 적용되고 프로그램이 소유하는 호출의 경우 자유 형식 텍스트가 출력 스키마를 충족하지 않으므로 기본 실패 포매터가 비활성화됩니다. 따라서 스키마에 부합하는 JSON을 반환하는 사용자 지정 `failure_error_function`를 제공하지 않으면 핸들러 예외가 전파됩니다. -- 프로그램 소유 SDK 도구도 일반 Runner 수명 주기를 사용합니다. 도구 입출력 가드레일, 훅, 시간 제한, 동시성 제한, 승인, 세션 및 `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출과 프로그램 호출자 간의 관계를 유지합니다. -- `ProgrammaticToolCallingTool()`가 있으면 프로그램 실행 전이라도 모델 요청 재시도에 더 엄격한 재실행 안전 경계가 적용됩니다. SDK는 이러한 요청에 대해 공급자 관리 재시도와 WebSocket 사전 이벤트 재시도를 비활성화합니다. Runner 재시도 정책은 공급자의 지침에서 재실행이 안전하다고 명시적으로 표시된 경우에만 재시도합니다. `retry_policies.network_error()`만으로는 이 경계를 재정의하지 않습니다. -- 승인에 민감하거나 영향이 큰 도구는 일반적으로 직접 호출로 유지하여 더 큰 프로그램의 일부가 되기 전에 각 작업을 사람이 검토할 수 있게 하는 편이 좋습니다. 프로그램 소유 호출이 승인을 위해 일시 중지되면 `RunState`을 통해 인터럽션(중단 처리)을 해결하고 평소처럼 원래 실행을 재개하세요. -- 프로그래밍 방식 도구 호출은 [호스티드 도구 검색](#hosted-tool-search)과 함께 사용할 수 있습니다. 모델은 생성된 프로그램이 지연된 도구를 호출하기 전에 해당 도구를 불러와야 합니다. -- `program` 항목과 그에 속한 일반적인 프로그램 소유 하위 도구 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 이에 대응하는 `program_output`는 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 호스티드 MCP 승인 요청과 도구 카탈로그는 대신 특수 MCP 항목과 스트림 이벤트를 사용합니다. 검사에 관한 자세한 내용은 [결과](results.md#new-items) 및 [스트리밍](streaming.md#run-item-event-names)을 참고하세요. -- 완전한 동시성 재고 계획 예제는 `examples/tools/programmatic_tool_calling.py`을 참고하세요. -- 공식 플랫폼 가이드: [프로그래밍 방식 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) +- 프로그래밍 방식 도구 호출은 지원되는 OpenAI Responses 모델에서만 사용할 수 있습니다. `ProgrammaticToolCallingTool()` 및 `tool_choice="programmatic_tool_calling"`은 Chat Completions 모델과 Responses가 아닌 백엔드에서 거부됩니다. +- 에이전트에 `ProgrammaticToolCallingTool()`을 최대 하나 추가하세요. 에이전트는 프로그래밍 방식으로 호출 가능한 도구를 하나 이상 노출하거나, 네임스페이스·지연 함수·지연된 호스티드 MCP 서버로 지원되는 `ToolSearchTool()` 또는 불투명한 프롬프트 관리형 도구 표면을 노출해야 합니다. 검색 가능한 표면이 없는 단독 `ToolSearchTool()`은 거부됩니다. +- `allowed_callers`는 도구를 호출할 수 있는 방식을 제어합니다. 생략하면 모델의 직접 호출만 허용됩니다. 프로그램에서만 액세스하려면 `["programmatic"]`을 사용하고, 둘 다 허용하려면 `["direct", "programmatic"]`를 사용하세요. +- 옵트인할 수 있는 SDK 도구 유형은 `FunctionTool`, `CustomTool`, `ShellTool`, `ApplyPatchTool`, `HostedMCPTool`, `CodeInterpreterTool`입니다. 함수, 사용자 지정, 셸, 패치 적용 도구는 `allowed_callers`을 직접 노출합니다. 호스티드 MCP와 Code Interpreter의 경우 `tool_config` 내부에 `allowed_callers`를 설정하세요. +- `@function_tool(allowed_callers=[...])`의 경우 Pydantic 모델, TypedDict 또는 dataclass와 같은 구조화된 반환 어노테이션이 자동으로 엄격한 객체 출력 스키마가 되며, 반환 값은 프로그램에 반환되기 전에 해당 스키마를 기준으로 검증됩니다. 함수에 사용할 수 있는 어노테이션이 없으면 `output_type=...`를 사용하고, 이미 엄격한 객체 스키마가 있다면 저수준 이스케이프 해치인 `output_json_schema={...}`을 사용하세요. `output_type`과 `output_json_schema`은 함께 사용할 수 없습니다. `str`, `Any`, `None` 반환 어노테이션은 출력 스키마를 생성하지 않습니다. 스키마가 적용된 프로그램 소유 호출에서는 자유 형식 텍스트가 출력 스키마를 충족하지 않으므로 기본 실패 포매터가 비활성화됩니다. 따라서 스키마를 준수하는 JSON을 반환하는 사용자 지정 `failure_error_function`를 제공하지 않으면 핸들러 예외가 전파됩니다. +- 프로그램 소유 SDK 도구에도 일반 Runner 수명 주기가 적용됩니다. 도구 입력 및 출력 가드레일, 훅, 시간 제한, 동시성 제한, 승인, 세션, `RunState` 일시 중지/재개 동작이 계속 적용되며, SDK는 각 하위 호출과 프로그램 호출자 간의 관계를 보존합니다. +- `ProgrammaticToolCallingTool()`가 있으면 프로그램이 실행되기 전이라도 모델 요청 재시도에 더 엄격한 재실행 안전성 경계가 적용됩니다. SDK는 이러한 요청에 대해 제공자 관리형 재시도와 WebSocket 사전 이벤트 재시도를 비활성화합니다. Runner 재시도 정책은 제공자의 안내에서 재실행이 안전하다고 명시적으로 표시한 경우에만 재시도하며, `retry_policies.network_error()`만으로는 이 경계를 재정의하지 않습니다. +- 승인이 필요하거나 영향이 큰 도구는 일반적으로 직접 호출로 유지하여 더 큰 프로그램의 일부가 되기 전에 사람이 각 작업을 검토할 수 있도록 하는 것이 좋습니다. 프로그램 소유 호출이 승인을 위해 일시 중지되면 `RunState`을 통해 인터럽션(중단 처리)을 해결하고 원래 실행을 평소처럼 재개하세요. +- 프로그래밍 방식 도구 호출은 [호스티드 툴 검색](#hosted-tool-search)과 함께 사용할 수 있습니다. 생성된 프로그램이 지연된 도구를 호출하려면 모델이 먼저 해당 도구를 로드해야 합니다. +- `program` 항목과 이 항목의 일반적인 프로그램 소유 하위 도구 호출은 [`ToolCallItem`][agents.items.ToolCallItem] 항목으로 표시됩니다. 이에 대응하는 `program_output`는 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]으로 표시됩니다. 대신 호스티드 MCP 승인 요청과 도구 카탈로그는 특수한 MCP 항목 및 스트림 이벤트를 사용합니다. 검사 세부 정보는 [결과](results.md#new-items) 및 [스트리밍](streaming.md#run-item-event-names)을 참조하세요. +- 완전한 동시 실행 재고 계획 예제는 `examples/tools/programmatic_tool_calling.py`을 참조하세요. +- 공식 플랫폼 가이드: [프로그래밍 방식 도구 호출](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling) ### 호스티드 컨테이너 셸 및 스킬 {#hosted-container-shell-skills} @@ -219,50 +219,50 @@ print(result.final_output) 알아둘 사항: -- 호스티드 셸은 Responses API 셸 도구를 통해 사용할 수 있습니다. -- `container_auto`는 요청을 위한 컨테이너를 프로비저닝하고, `container_reference`는 기존 컨테이너를 재사용합니다. -- `container_auto`에는 `file_ids` 및 `memory_limit`도 포함할 수 있습니다. -- `environment.skills`는 스킬 참조와 인라인 스킬 번들을 허용합니다. -- 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval`, `on_approval`를 설정하지 마세요. -- `network_policy`는 `disabled` 및 `allowlist` 모드를 지원합니다. -- 허용 목록 모드에서는 `network_policy.domain_secrets`이 이름을 기준으로 도메인 범위의 시크릿을 주입할 수 있습니다. -- 완전한 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참고하세요. -- OpenAI 플랫폼 가이드: [셸](https://platform.openai.com/docs/guides/tools-shell) 및 [스킬](https://platform.openai.com/docs/guides/tools-skills) +- 호스티드 셸은 Responses API 셸 도구를 통해 사용할 수 있습니다. +- `container_auto`는 요청을 위한 컨테이너를 프로비저닝하고, `container_reference`는 기존 컨테이너를 재사용합니다. +- `container_auto`에는 `file_ids` 및 `memory_limit`도 포함될 수 있습니다. +- `environment.skills`는 스킬 참조와 인라인 스킬 번들을 허용합니다. +- 호스티드 환경에서는 `ShellTool`에 `executor`, `needs_approval`, `on_approval`를 설정하지 마세요. +- `network_policy`는 `disabled` 및 `allowlist` 모드를 지원합니다. +- 허용 목록 모드에서 `network_policy.domain_secrets`은 이름으로 도메인 범위의 비밀 값을 주입할 수 있습니다. +- 완전한 예제는 `examples/tools/container_shell_skill_reference.py` 및 `examples/tools/container_shell_inline_skill.py`를 참조하세요. +- OpenAI 플랫폼 가이드: [셸](https://platform.openai.com/docs/guides/tools-shell) 및 [스킬](https://platform.openai.com/docs/guides/tools-skills) ## 로컬 런타임 도구 {#local-runtime-tools} -로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 호출 시점을 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. +로컬 런타임 도구는 모델 응답 자체의 외부에서 실행됩니다. 모델이 여전히 호출 시점을 결정하지만, 실제 작업은 애플리케이션 또는 구성된 실행 환경에서 수행합니다. `ComputerTool` 및 `ApplyPatchTool`에는 항상 사용자가 제공하는 로컬 구현이 필요합니다. `ShellTool`는 두 모드를 모두 지원합니다. 관리형 실행을 원하면 위의 호스티드 컨테이너 구성을 사용하고, 자체 프로세스에서 명령을 실행하려면 아래의 로컬 런타임 구성을 사용하세요. -로컬 런타임 도구에는 다음 구현을 제공해야 합니다. +로컬 런타임 도구를 사용하려면 구현을 제공해야 합니다. -- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 활성화하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현하세요. -- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행 모두를 위한 최신 셸 도구입니다. -- [`LocalShellTool`][agents.tool.LocalShellTool]: 레거시 로컬 셸 통합입니다. -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현하세요. -- 로컬 셸 스킬은 `ShellTool(environment={"type": "local", "skills": [...]})`과 함께 사용할 수 있습니다. +- [`ComputerTool`][agents.tool.ComputerTool]: GUI/브라우저 자동화를 활성화하려면 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 인터페이스를 구현하세요. +- [`ShellTool`][agents.tool.ShellTool]: 로컬 실행과 호스티드 컨테이너 실행을 모두 지원하는 최신 셸 도구입니다. +- [`LocalShellTool`][agents.tool.LocalShellTool]: 레거시 로컬 셸 통합입니다. +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]: 로컬에서 diff를 적용하려면 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor]를 구현하세요. +- `ShellTool(environment={"type": "local", "skills": [...]})`을 사용하면 로컬 셸 스킬을 사용할 수 있습니다. -셸 작업 시간 제한은 유한한 시간 제한에 양의 정수 밀리초를 사용합니다. 0은 실행기 구현 간에 이식 가능한 의미를 갖지 않으므로 SDK는 로컬 `ShellTool` 실행기를 호출하기 전에 `0`과 `None`를 모두 명시적 시간 제한 없음으로 처리합니다. 그 밖의 값은 실행기 호출 전에 거부됩니다. 이는 시간 제한 필드에만 해당합니다. `max_output_length=0`는 캡처된 빈 출력 요청으로 계속 지원됩니다. +셸 작업 시간 제한에는 유한한 시간 제한을 나타내는 양의 정수 밀리초 값을 사용합니다. 0은 실행기 구현 전반에서 이식 가능한 의미를 갖지 않으므로 SDK는 로컬 `ShellTool` 실행기를 호출하기 전에 `0`과 `None`를 모두 명시적 시간 제한 없음으로 처리합니다. 그 외의 값은 실행기를 호출하기 전에 거부됩니다. 이는 시간 제한 필드에만 해당하며, `max_output_length=0`는 빈 캡처 출력을 요청하는 용도로 계속 지원됩니다. -### ComputerTool과 Responses 컴퓨터 도구 {#computertool-and-the-responses-computer-tool} +### ComputerTool 및 Responses 컴퓨터 도구 {#computertool-and-the-responses-computer-tool} -`ComputerTool`는 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API 컴퓨터 인터페이스에 매핑합니다. +`ComputerTool`는 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API 컴퓨터 표면에 매핑합니다. -명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델에 대한 요청의 경우 SDK는 계속해서 프리뷰 페이로드 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`을 전송합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. +명시적인 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 요청의 경우 SDK는 GA 기본 제공 도구 페이로드인 `{"type": "computer"}`를 전송합니다. 이전 `computer-use-preview` 모델에 대한 요청에는 SDK가 계속 프리뷰 페이로드인 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`을 전송합니다. 이는 OpenAI의 [컴퓨터 사용 가이드](https://developers.openai.com/api/docs/guides/tools-computer-use/)에 설명된 플랫폼 마이그레이션을 반영합니다. -- 모델: `computer-use-preview` -> `gpt-5.5` -- 도구 선택자: `computer_use_preview` -> `computer` -- 컴퓨터 호출 형식: `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` -- 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 불필요 +- 모델: `computer-use-preview` -> `gpt-5.5` +- 도구 선택자: `computer_use_preview` -> `computer` +- 컴퓨터 호출 형식: `computer_call`당 하나의 `action` -> `computer_call`의 일괄 처리된 `actions[]` +- 잘림: 프리뷰 경로에서는 `ModelSettings(truncation="auto")` 필요 -> GA 경로에서는 불필요 -SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하고 프롬프트에서 모델을 소유하므로 요청에서 `model`을 생략한 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`로 GA 선택자를 강제하지 않는 한 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. +SDK는 실제 Responses 요청의 유효 모델을 기준으로 해당 전송 형식을 선택합니다. 프롬프트 템플릿을 사용하고 프롬프트가 `model`을 소유하여 요청에서 이를 생략하는 경우, `model="gpt-5.5"`를 명시적으로 유지하거나 `ModelSettings(tool_choice="computer")` 또는 `ModelSettings(tool_choice="computer_use")`를 사용하여 GA 선택자를 강제하지 않는 한 SDK는 프리뷰 호환 컴퓨터 페이로드를 유지합니다. -[`ComputerTool`][agents.tool.ComputerTool]가 있으면 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`이 모두 허용되며 유효 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`가 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. +[`ComputerTool`][agents.tool.ComputerTool]가 있는 경우 `tool_choice="computer"`, `"computer_use"`, `"computer_use_preview"`이 모두 허용되며 유효한 요청 모델과 일치하는 기본 제공 선택자로 정규화됩니다. `ComputerTool`가 없으면 이러한 문자열은 계속 일반 함수 이름처럼 동작합니다. -이 차이는 `ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩터리를 기반으로 할 때 중요합니다. GA `computer` 페이로드는 직렬화 시점에 `environment`이나 크기가 필요하지 않으므로 팩터리가 `Computer` 또는 `AsyncComputer` 인스턴스를 생성하기 전에 직렬화할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`을 전송할 수 있도록 확인된 `Computer` 또는 `AsyncComputer` 인스턴스가 여전히 필요합니다. +이 차이는 `ComputerTool`이 [`ComputerProvider`][agents.tool.ComputerProvider] 팩토리로 지원될 때 중요합니다. GA `computer` 페이로드에는 직렬화 시점에 `environment` 또는 크기가 필요하지 않으므로 팩토리가 `Computer` 또는 `AsyncComputer` 인스턴스를 생성하기 전에 직렬화를 수행할 수 있습니다. 프리뷰 호환 직렬화에는 SDK가 `environment`, `display_width`, `display_height`을 전송할 수 있도록 확인된 `Computer` 또는 `AsyncComputer` 인스턴스가 여전히 필요합니다. -런타임에서 두 경로는 계속 동일한 로컬 하네스를 사용합니다. 프리뷰 응답은 단일 `action`를 포함하는 `computer_call` 항목을 내보냅니다. `gpt-5.5`은 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`을 참고하세요. +런타임에서는 두 경로 모두 동일한 로컬 하네스를 사용합니다. 프리뷰 응답은 단일 `action`가 포함된 `computer_call` 항목을 내보냅니다. `gpt-5.5`은 일괄 처리된 `actions[]`를 내보낼 수 있으며, SDK는 `computer_call_output` 스크린샷 항목을 생성하기 전에 이를 순서대로 실행합니다. 실행 가능한 Playwright 기반 하네스는 `examples/tools/computer_use.py`을 참조하세요. ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -308,16 +308,16 @@ agent = Agent( 모든 Python 함수를 도구로 사용할 수 있습니다. Agents SDK가 도구를 자동으로 설정합니다. -- 도구 이름은 Python 함수 이름이 됩니다. 또는 이름을 직접 제공할 수 있습니다. -- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 제공할 수 있습니다. -- 함수 입력 스키마는 함수 인수에서 자동으로 생성됩니다. -- 비활성화하지 않는 한 각 입력의 설명은 함수의 docstring에서 가져옵니다. +- 도구 이름은 Python 함수의 이름이 됩니다(또는 이름을 직접 제공할 수 있습니다). +- 도구 설명은 함수의 docstring에서 가져옵니다(또는 설명을 직접 제공할 수 있습니다). +- 함수 입력 스키마는 함수 인수에서 자동으로 생성됩니다. +- 비활성화하지 않는 한 각 입력에 대한 설명은 함수의 docstring에서 가져옵니다. -`@tool`로 생성한 도구는 읽기 전용 `__wrapped__` 속성을 통해 원래 Python 호출 가능 객체를 노출합니다. 이는 검사와 테스트에 유용하지만, 이를 직접 호출하면 스키마 검증, 컨텍스트 주입, 가드레일, 시간 제한, 실패 처리, 트레이싱을 포함한 도구 런타임 파이프라인을 우회합니다. 직접 만든 `FunctionTool` 인스턴스는 `__wrapped__`을 노출하지 않습니다. +`@tool`로 생성한 도구는 읽기 전용 `__wrapped__` 속성을 통해 원래 Python 호출 가능 객체를 노출합니다. 이 속성은 검사와 테스트에 유용하지만 직접 호출하면 스키마 검증, 컨텍스트 주입, 가드레일, 시간 제한, 실패 처리, 트레이싱을 비롯한 도구 런타임 파이프라인을 우회합니다. 직접 구성한 `FunctionTool` 인스턴스는 `__wrapped__`을 노출하지 않습니다. -함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하고, docstring 구문 분석에는 [`griffe`](https://mkdocstrings.github.io/griffe/), 스키마 생성에는 `pydantic`을 사용합니다. +함수 시그니처를 추출하기 위해 Python의 `inspect` 모듈을 사용하며, docstring 파싱에는 [`griffe`](https://mkdocstrings.github.io/griffe/), 스키마 생성에는 `pydantic`을 사용합니다. -OpenAI Responses 모델을 사용하는 경우 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`가 불러올 때까지 함수 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]를 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정과 제약 조건은 [호스티드 도구 검색](#hosted-tool-search)을 참고하세요. +OpenAI Responses 모델을 사용하는 경우 `@function_tool(defer_loading=True)`는 `ToolSearchTool()`가 함수 도구를 로드할 때까지 해당 도구를 숨깁니다. [`tool_namespace()`][agents.tool.tool_namespace]을 사용하여 관련 함수 도구를 그룹화할 수도 있습니다. 전체 설정과 제약 조건은 [호스티드 툴 검색](#hosted-tool-search)을 참조하세요. ```python import json @@ -370,12 +370,12 @@ for tool in agent.tools: ``` -1. 모든 Python 유형을 함수의 인수로 사용할 수 있으며, 함수는 동기 또는 비동기일 수 있습니다. -2. docstring이 있으면 설명과 인수 설명을 가져오는 데 사용됩니다. -3. 함수는 선택적으로 실행 컨텍스트를 첫 번째 인수로 받을 수 있습니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의도 설정할 수 있습니다. -4. 데코레이트된 함수를 도구 목록에 전달할 수 있습니다. +1. 함수 인수로 모든 Python 유형을 사용할 수 있으며, 함수는 동기식 또는 비동기식일 수 있습니다. +2. docstring이 있으면 설명과 인수 설명을 가져오는 데 사용됩니다. +3. 함수는 선택적으로 실행 컨텍스트를 첫 번째 인수로 받을 수 있습니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의 옵션도 설정할 수 있습니다. +4. 데코레이트된 함수를 도구 목록에 전달할 수 있습니다. -??? note "출력 펼쳐 보기" +??? note "출력을 보려면 펼치기" ``` fetch_weather @@ -445,22 +445,22 @@ for tool in agent.tools: } ``` -### 함수 도구에서 이미지 또는 파일 반환 {#returning-images-or-files-from-function-tools} +### 함수 도구의 이미지 또는 파일 반환 {#returning-images-or-files-from-function-tools} -텍스트 출력뿐 아니라 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 이를 위해 다음 중 하나를 반환할 수 있습니다. +텍스트 출력 외에도 하나 이상의 이미지나 파일을 함수 도구의 출력으로 반환할 수 있습니다. 이를 위해 다음 중 하나를 반환할 수 있습니다. -- 이미지: [`ToolOutputImage`][agents.tool.ToolOutputImage] 또는 TypedDict 버전인 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict] -- 파일: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent] 또는 TypedDict 버전인 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict] -- 텍스트: 문자열, 문자열로 변환 가능한 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText] 또는 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict] +- 이미지: [`ToolOutputImage`][agents.tool.ToolOutputImage](또는 TypedDict 버전인 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) +- 파일: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](또는 TypedDict 버전인 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) +- 텍스트: 문자열이나 문자열로 변환할 수 있는 객체 또는 [`ToolOutputText`][agents.tool.ToolOutputText](또는 TypedDict 버전인 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### 사용자 지정 함수 도구 {#custom-function-tools} Python 함수를 도구로 사용하지 않으려는 경우도 있습니다. 원하는 경우 [`FunctionTool`][agents.tool.FunctionTool]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. -- `name` -- `description` -- 인수의 JSON 스키마인 `params_json_schema` -- [`ToolContext`][agents.tool_context.ToolContext]와 JSON 문자열 형식의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` +- `name` +- `description` +- 인수를 위한 JSON 스키마인 `params_json_schema` +- [`ToolContext`][agents.tool_context.ToolContext]과 JSON 문자열 형식의 인수를 받아 도구 출력(예: 텍스트, 구조화된 도구 출력 객체 또는 출력 목록)을 반환하는 비동기 함수인 `on_invoke_tool` ```python from typing import Any @@ -493,12 +493,12 @@ tool = FunctionTool( ) ``` -### 자동 인수 및 docstring 구문 분석 {#automatic-argument-and-docstring-parsing} +### 자동 인수 및 docstring 파싱 {#automatic-argument-and-docstring-parsing} -앞서 설명한 것처럼 함수 시그니처를 자동으로 구문 분석하여 도구 스키마를 추출하고, docstring을 구문 분석하여 도구와 개별 인수의 설명을 추출합니다. 다음 사항을 참고하세요. +앞서 설명한 것처럼 도구 스키마를 추출하기 위해 함수 시그니처를 자동으로 파싱하고, 도구 및 개별 인수의 설명을 추출하기 위해 docstring을 파싱합니다. 다음 사항을 참고하세요. -1. 시그니처 구문 분석은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용해 인수 유형을 파악하고 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 타입, Pydantic 모델, TypedDict 등 대부분의 유형을 지원합니다. -2. docstring 구문 분석에는 `griffe`을 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 시도하지만 최선형 방식이므로 `function_tool`를 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`으로 설정하여 docstring 구문 분석을 비활성화할 수도 있습니다. Google 스타일 docstring의 경우 파서는 요약 텍스트 바로 뒤에 빈 줄 없이 이어지는 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 허용합니다. +1. 시그니처 파싱은 `inspect` 모듈을 통해 수행됩니다. 타입 어노테이션을 사용해 인수의 유형을 파악하고, 전체 스키마를 나타내는 Pydantic 모델을 동적으로 빌드합니다. Python 기본 타입, Pydantic 모델, TypedDict 등을 포함한 대부분의 유형을 지원합니다. +2. docstring 파싱에는 `griffe`을 사용합니다. 지원되는 docstring 형식은 `google`, `sphinx`, `numpy`입니다. docstring 형식을 자동으로 감지하려고 하지만 이는 최선형 방식이며, `function_tool`를 호출할 때 명시적으로 설정할 수 있습니다. `use_docstring_info`를 `False`으로 설정하여 docstring 파싱을 비활성화할 수도 있습니다. Google 스타일 docstring의 경우 파서는 사이에 빈 줄이 없어도 요약 텍스트 바로 뒤에 오는 `Args:`, `Arguments:`, `Params:`, `Parameters:` 섹션도 허용합니다. 스키마 추출 코드는 [`agents.function_schema`][]에 있습니다. @@ -524,7 +524,7 @@ def score_b(score: Annotated[int, Field(..., ge=0, le=100, description="Score fr ### 함수 도구 시간 제한 {#function-tool-timeouts} -`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구의 호출별 시간 제한을 설정할 수 있습니다. +`@function_tool(timeout=...)`을 사용하여 비동기 함수 도구에 호출별 시간 제한을 설정할 수 있습니다. ```python import asyncio @@ -549,9 +549,9 @@ agent = Agent( 시간 초과 처리를 제어할 수 있습니다. -- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 시간 초과 메시지를 반환합니다. -- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]를 발생시키고 실행을 실패 처리합니다. -- `error_as_result`을 사용할 때 `timeout_error_function=...`로 시간 초과 메시지를 사용자 지정합니다. +- `timeout_behavior="error_as_result"`(기본값): 모델이 복구할 수 있도록 시간 초과 메시지를 반환합니다. +- `timeout_behavior="raise_exception"`: [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError]를 발생시키고 실행을 실패 처리합니다. +- `timeout_error_function=...`: `error_as_result`을 사용할 때 시간 초과 메시지를 사용자 지정합니다. ```python import asyncio @@ -577,13 +577,13 @@ except ToolTimeoutError as e: 시간 제한 구성은 비동기 `@function_tool` 핸들러에서만 지원됩니다. -### 함수 도구 오류 처리 {#handling-errors-in-function-tools} +### 함수 도구의 오류 처리 {#handling-errors-in-function-tools} -`@function_tool`를 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이는 도구 호출이 중단되는 경우 LLM에 오류 응답을 제공하는 함수입니다. +`@function_tool`를 통해 함수 도구를 생성할 때 `failure_error_function`을 전달할 수 있습니다. 이는 도구 호출이 비정상 종료될 때 LLM에 오류 응답을 제공하는 함수입니다. -- 기본적으로, 즉 아무것도 전달하지 않으면 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`이 실행됩니다. -- 자체 오류 함수를 전달하면 해당 함수가 대신 실행되고 응답이 LLM에 전송됩니다. -- `None`을 명시적으로 전달하면 모든 도구 호출 오류가 다시 발생하여 직접 처리할 수 있습니다. 모델이 유효하지 않은 JSON을 생성한 경우 `ModelBehaviorError`, 코드 실행이 중단된 경우 `UserError` 등이 발생할 수 있습니다. +- 기본적으로(즉, 아무것도 전달하지 않으면) 오류가 발생했음을 LLM에 알리는 `default_tool_error_function`을 실행합니다. +- 자체 오류 함수를 전달하면 대신 해당 함수를 실행하고 응답을 LLM에 전송합니다. +- `None`을 명시적으로 전달하면 모든 도구 호출 오류가 다시 발생하며 사용자가 이를 처리해야 합니다. 모델이 잘못된 JSON을 생성한 경우 `ModelBehaviorError`가 발생할 수 있고, 코드가 비정상 종료된 경우 `UserError`이 발생할 수 있습니다. ```python from agents import RunContextWrapper @@ -611,7 +611,7 @@ def get_user_profile(user_id: str) -> str: ## Agents as tools {#agents-as-tools} -일부 워크플로에서는 제어를 핸드오프하는 대신 중앙 에이전트가 특화된 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. +일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. ```python import asyncio @@ -657,9 +657,9 @@ if __name__ == "__main__": ### 도구 에이전트 사용자 지정 {#customizing-tool-agents} -`agent.as_tool`은 에이전트를 도구로 변환하는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`을 통한 구조화된 입력도 지원합니다. +`agent.as_tool`은 에이전트를 도구로 변환하는 편의 메서드입니다. `max_turns`, `run_config`, `hooks`, `previous_response_id`, `conversation_id`, `session`, `needs_approval`과 같은 일반적인 런타임 옵션을 지원합니다. 또한 `parameters`, `input_builder`, `include_input_schema`을 사용한 구조화된 입력을 지원합니다. -상태 옵션은 도구 호출로 시작되는 중첩 에이전트 실행을 구성합니다. 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 간에 클라이언트 관리 기록을 공유하려면 동일한 `session`를 두 실행 모두에 명시적으로 전달하세요. `Runner.run`와 마찬가지로 중첩 실행에는 클라이언트 관리 `session` 또는 `previous_response_id`이나 `conversation_id`을 통한 서버 관리 연속 처리 중 하나의 상태 전략을 선택하세요. +상태 옵션은 도구 호출로 시작된 중첩 에이전트 실행을 구성합니다. 상위 실행의 대화 상태는 자동으로 상속되지 않습니다. 상위 실행과 중첩 실행 사이에서 클라이언트 관리형 기록을 공유하려면 동일한 `session`를 두 실행 모두에 명시적으로 전달하세요. `Runner.run`와 마찬가지로 중첩 실행에는 하나의 상태 전략을 선택하세요. 클라이언트 관리형 `session`을 사용하거나, `previous_response_id` 또는 `conversation_id`을 통해 서버 관리형 연속 실행을 사용합니다. ```python from agents.decorators import tool @@ -683,13 +683,13 @@ async def run_my_agent() -> str: ### 도구 에이전트의 구조화된 입력 {#structured-input-for-tool-agents} -기본적으로 `Agent.as_tool()`는 하나의 문자열 필드 `input`(`{"input": "..."}`)이 있는 객체를 예상하지만, Pydantic 모델 유형 또는 데이터 클래스 유형인 `parameters`를 전달하여 구조화된 스키마를 노출할 수 있습니다. +기본적으로 `Agent.as_tool()`는 문자열 필드 `input`(`{"input": "..."}`) 하나가 포함된 객체를 예상하지만, `parameters`(Pydantic 모델 유형 또는 dataclass 유형)를 전달하여 구조화된 스키마를 노출할 수 있습니다. 추가 옵션: - `include_input_schema=True`은 생성된 중첩 입력에 전체 JSON Schema를 포함합니다. - `input_builder=...`를 사용하면 구조화된 도구 인수를 중첩 에이전트 입력으로 변환하는 방식을 완전히 사용자 지정할 수 있습니다. -- `RunContextWrapper.tool_input`에는 중첩 실행 컨텍스트 내부에서 구문 분석된 구조화 페이로드가 포함됩니다. +- `RunContextWrapper.tool_input`는 중첩 실행 컨텍스트 내부에 파싱된 구조화 페이로드를 포함합니다. ```python from pydantic import BaseModel, Field @@ -709,19 +709,19 @@ translator_tool = translator_agent.as_tool( ) ``` -완전한 실행 가능 예제는 `examples/agent_patterns/agents_as_tools_structured.py`을 참고하세요. +완전한 실행 가능 예제는 `examples/agent_patterns/agents_as_tools_structured.py`을 참조하세요. ### 도구 에이전트의 승인 게이트 {#approval-gates-for-tool-agents} -`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요하면 실행이 일시 중지되고 대기 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`을 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참고하세요. +`Agent.as_tool(..., needs_approval=...)`은 `function_tool`과 동일한 승인 흐름을 사용합니다. 승인이 필요하면 실행이 일시 중지되고 보류 중인 항목이 `result.interruptions`에 표시됩니다. 그런 다음 `result.to_state()`을 사용하고 `state.approve(...)` 또는 `state.reject(...)`를 호출한 후 재개하세요. 전체 일시 중지/재개 패턴은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참조하세요. ### 사용자 지정 출력 추출 {#custom-output-extraction} -특정한 경우 중앙 에이전트에 반환하기 전에 도구 에이전트의 출력을 수정할 수 있습니다. 다음과 같은 경우에 유용합니다. +경우에 따라 도구 에이전트의 출력을 중앙 에이전트에 반환하기 전에 수정할 수 있습니다. 다음과 같은 경우에 유용합니다. -- 하위 에이전트의 채팅 기록에서 특정 정보(예: JSON 페이로드) 추출 -- 에이전트의 최종 답변 변환 또는 재구성(예: Markdown을 일반 텍스트나 CSV로 변환) -- 에이전트의 응답이 누락되었거나 형식이 잘못된 경우 출력 검증 또는 대체 값 제공 +- 하위 에이전트의 채팅 기록에서 특정 정보(예: JSON 페이로드)를 추출 +- 에이전트의 최종 답변을 변환하거나 형식을 변경(예: Markdown을 일반 텍스트 또는 CSV로 변환) +- 에이전트의 응답이 없거나 형식이 잘못된 경우 출력을 검증하거나 대체 값 제공 `as_tool` 메서드에 `custom_output_extractor` 인수를 제공하여 이를 구현할 수 있습니다. @@ -742,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 중첩 결과를 후처리하는 동안 외부 도구 이름, 호출 ID 또는 raw 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참고하세요. +사용자 지정 추출기 내부에서 중첩된 [`RunResult`][agents.result.RunResult]는 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]도 노출합니다. 중첩 결과를 후처리하면서 외부 도구 이름, 호출 ID 또는 raw 인수가 필요할 때 유용합니다. [결과 가이드](results.md#agent-as-tool-metadata)를 참조하세요. ### 중첩 에이전트 실행 스트리밍 {#streaming-nested-agent-runs} -스트림이 완료되면 최종 출력을 반환하면서 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하려면 `as_tool`에 `on_stream` 콜백을 전달하세요. +`as_tool`에 `on_stream` 콜백을 전달하면 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하면서 스트림이 완료된 후 최종 출력을 반환할 수 있습니다. ```python from agents import AgentToolStreamEvent @@ -766,15 +766,15 @@ billing_agent_tool = billing_agent.as_tool( 예상 동작: -- 이벤트 유형은 `StreamEvent["type"]`와 동일합니다. `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event` -- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드에서 실행되며, 최종 출력을 반환하기 전에 스트림을 모두 소비합니다. -- 핸들러는 동기 또는 비동기일 수 있으며, 각 이벤트는 도착하는 순서대로 전달됩니다. +- 이벤트 유형은 `StreamEvent["type"]`와 동일합니다: `raw_response_event`, `run_item_stream_event`, `agent_updated_stream_event` +- `on_stream`을 제공하면 중첩 에이전트가 자동으로 스트리밍 모드로 실행되고, 최종 출력을 반환하기 전에 스트림이 모두 소비됩니다. +- 핸들러는 동기식 또는 비동기식일 수 있으며, 각 이벤트는 도착한 순서대로 전달됩니다. - 모델 도구 호출을 통해 도구가 호출되면 `tool_call`가 존재합니다. 직접 호출에서는 `None`일 수 있습니다. -- 완전한 실행 가능 샘플은 `examples/agent_patterns/agents_as_tools_streaming.py`을 참고하세요. +- 완전한 실행 가능 샘플은 `examples/agent_patterns/agents_as_tools_streaming.py`을 참조하세요. ### 조건부 도구 활성화 {#conditional-tool-enabling} -`is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 기본 설정 또는 런타임 조건을 기준으로 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. +`is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 기본 설정 또는 런타임 조건에 따라 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. ```python import asyncio @@ -831,22 +831,26 @@ asyncio.run(main()) `is_enabled` 매개변수는 다음을 허용합니다. -- **불리언 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) -- **호출 가능 함수**: `(context, agent)`을 받아 불리언 값을 반환하는 함수 -- **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 +- **불리언 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) +- **호출 가능 함수**: `(context, agent)`을 받고 불리언을 반환하는 함수 +- **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 -비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음 용도에 유용합니다. +비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 경우에 유용합니다. -- 사용자 권한에 따른 기능 게이팅 -- 환경별 도구 가용성(개발 환경과 프로덕션 환경) -- 다양한 도구 구성의 A/B 테스트 -- 런타임 상태에 따른 동적 도구 필터링 +- 요청 범위의 기능 표시 여부 +- 환경별 도구 가용성(개발 환경과 프로덕션 환경) +- 서로 다른 도구 구성에 대한 A/B 테스트 +- 런타임 상태에 따른 동적 도구 필터링 + +로컬에 구성된 함수 도구의 경우 Runner는 호출 전에 `is_enabled`도 다시 평가합니다. 그러나 `is_enabled`은 표시 여부와 디스패치를 제어하며, 도구 인수나 액세스하는 리소스에 따라 달라지는 권한 부여를 대체하지 않습니다. 해당 검사는 도구 구현 내부에서 적용하거나, 적절한 경우 [도구 입력 가드레일](guardrails.md#tool-guardrails)과 [승인](human_in_the_loop.md)을 사용하세요. MCP 서버는 자체 보호 작업에 대한 권한을 부여해야 합니다. + +함수 도구, MCP 도구, 핸드오프에 하나의 애플리케이션 정책을 적용하는 패턴은 [컨텍스트 관리](context.md#use-local-context-for-capability-visibility)를 참조하세요. ## 실험적 기능: Codex 도구 {#experimental-codex-tool} -`codex_tool`는 Codex CLI를 래핑하여 에이전트가 도구 호출 중에 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있게 합니다. 이 인터페이스는 실험적이며 변경될 수 있습니다. +`codex_tool`는 Codex CLI를 래핑하여 에이전트가 도구 호출 중 워크스페이스 범위의 작업(셸, 파일 편집, MCP 도구)을 실행할 수 있도록 합니다. 이 기능은 실험적이며 변경될 수 있습니다. -기본 에이전트가 현재 실행을 벗어나지 않고 범위가 제한된 워크스페이스 작업을 Codex에 위임하도록 하려면 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 `codex`이거나 `codex_`로 시작해야 합니다. 에이전트에 여러 Codex 도구가 포함된 경우 각 도구에 고유한 이름을 사용해야 합니다. +기본 에이전트가 현재 실행을 벗어나지 않고 범위가 한정된 워크스페이스 작업을 Codex에 위임하도록 하려면 이 기능을 사용하세요. 기본 도구 이름은 `codex`입니다. 사용자 지정 이름을 설정하는 경우 `codex`이거나 `codex_`로 시작해야 합니다. 하나의 에이전트에 여러 Codex 도구가 포함된 경우 각 도구는 고유한 이름을 사용해야 합니다. ```python from agents import Agent @@ -877,31 +881,31 @@ agent = Agent( 다음 옵션 그룹부터 시작하세요. -- 실행 범위: `sandbox_mode` 및 `working_directory`은 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고 작업 디렉터리가 Git 저장소 내부에 없으면 `skip_git_repo_check=True`을 설정하세요. -- 스레드 기본값: `default_thread_options=ThreadOptions(...)`는 모델, 추론 강도, 승인 정책, 추가 디렉터리, 네트워크 접근 및 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`을 우선 사용하세요. -- 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal`와 같은 턴별 동작을 구성합니다. -- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }`을 포함하는 `inputs` 항목이 하나 이상 있어야 합니다. `output_schema`을 사용하면 구조화된 Codex 응답을 요구할 수 있습니다. +- 실행 표면: `sandbox_mode`과 `working_directory`는 Codex가 작업할 수 있는 위치를 정의합니다. 두 옵션을 함께 사용하고, 작업 디렉터리가 Git 저장소 내부에 있지 않으면 `skip_git_repo_check=True`을 설정하세요. +- 스레드 기본값: `default_thread_options=ThreadOptions(...)`은 모델, 추론 수준, 승인 정책, 추가 디렉터리, 네트워크 액세스, 웹 검색 모드를 구성합니다. 레거시 `web_search_enabled`보다 `web_search_mode`를 사용하세요. +- 턴 기본값: `default_turn_options=TurnOptions(...)`는 `idle_timeout_seconds` 및 선택적 취소 `signal`과 같은 턴별 동작을 구성합니다. +- 도구 I/O: 도구 호출에는 `{ "type": "text", "text": ... }` 또는 `{ "type": "local_image", "path": ... }`가 포함된 `inputs` 항목이 하나 이상 있어야 합니다. `output_schema`을 사용하면 구조화된 Codex 응답을 필수로 지정할 수 있습니다. -스레드 재사용과 지속성은 별도의 제어 항목입니다. +스레드 재사용과 영속성은 별도의 제어 기능입니다. -- `persist_session=True`는 동일한 도구 인스턴스의 반복 호출에 하나의 Codex 스레드를 재사용합니다. -- `use_run_context_thread_id=True`은 동일한 변경 가능 컨텍스트 객체를 공유하는 여러 실행에서 실행 컨텍스트에 스레드 ID를 저장하고 재사용합니다. -- 스레드 ID 우선순위는 호출별 `thread_id`, 실행 컨텍스트 스레드 ID(활성화된 경우), 구성된 `thread_id` 옵션 순입니다. -- 기본 실행 컨텍스트 키는 `name="codex"`의 경우 `codex_thread_id`, `name="codex_"`의 경우 `codex_thread_id_`입니다. `run_context_thread_id_key`로 재정의할 수 있습니다. +- `persist_session=True`은 동일한 도구 인스턴스를 반복 호출할 때 하나의 Codex 스레드를 재사용합니다. +- `use_run_context_thread_id=True`는 동일한 변경 가능 컨텍스트 객체를 공유하는 여러 실행에서 실행 컨텍스트의 스레드 ID를 저장하고 재사용합니다. +- 스레드 ID 우선순위는 호출별 `thread_id`, 실행 컨텍스트 스레드 ID(활성화된 경우), 구성된 `thread_id` 옵션 순입니다. +- 기본 실행 컨텍스트 키는 `name="codex"`의 경우 `codex_thread_id`이고, `name="codex_"`의 경우 `codex_thread_id_`입니다. `run_context_thread_id_key`로 재정의할 수 있습니다. 런타임 구성: -- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`를 설정하거나 `codex_options={"api_key": "..."}`을 전달하세요. -- 런타임: `codex_options.base_url`은 CLI 기본 URL을 재정의합니다. -- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override` 또는 `CODEX_PATH`을 설정하세요. 그렇지 않으면 SDK가 `PATH`에서 `codex`를 확인한 다음 번들로 제공되는 공급업체 바이너리를 사용합니다. -- 환경: `codex_options.env`은 하위 프로세스 환경을 완전히 제어합니다. 이 값을 제공하면 하위 프로세스가 `os.environ`을 상속하지 않습니다. -- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes` 또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`는 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`부터 `67108864`까지이며, 기본값은 `8388608`입니다. -- 스트리밍: `on_stream`은 스레드/턴 수명 주기 이벤트와 항목 이벤트(`reasoning`, `command_execution`, `mcp_tool_call`, `file_change`, `web_search`, `todo_list`, `error` 항목 업데이트)를 수신합니다. -- 출력: 결과에는 `response`, `usage`, `thread_id`이 포함되며, 사용량은 `RunContextWrapper.usage`에 추가됩니다. +- 인증: `CODEX_API_KEY`(권장) 또는 `OPENAI_API_KEY`을 설정하거나 `codex_options={"api_key": "..."}`를 전달하세요. +- 런타임: `codex_options.base_url`은 CLI 기본 URL을 재정의합니다. +- 바이너리 확인: CLI 경로를 고정하려면 `codex_options.codex_path_override`(또는 `CODEX_PATH`)를 설정하세요. 그렇지 않으면 SDK는 `PATH`에서 `codex`을 확인한 다음 번들로 제공되는 벤더 바이너리를 사용합니다. +- 환경: `codex_options.env`은 하위 프로세스 환경을 완전히 제어합니다. 이 옵션이 제공되면 하위 프로세스는 `os.environ`를 상속하지 않습니다. +- 스트림 제한: `codex_options.codex_subprocess_stream_limit_bytes`(또는 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)은 stdout/stderr 리더 제한을 제어합니다. 유효 범위는 `65536`~`67108864`이며, 기본값은 `8388608`입니다. +- 스트리밍: `on_stream`는 스레드/턴 수명 주기 이벤트와 항목 이벤트(`reasoning`, `command_execution`, `mcp_tool_call`, `file_change`, `web_search`, `todo_list`, `error` 항목 업데이트)를 수신합니다. +- 출력: 결과에는 `response`, `usage`, `thread_id`가 포함되며, 사용량은 `RunContextWrapper.usage`에 추가됩니다. 참조: -- [Codex 도구 API 레퍼런스](ref/extensions/experimental/codex/codex_tool.md) -- [ThreadOptions 레퍼런스](ref/extensions/experimental/codex/thread_options.md) -- [TurnOptions 레퍼런스](ref/extensions/experimental/codex/turn_options.md) -- 완전한 실행 가능 샘플은 `examples/tools/codex.py` 및 `examples/tools/codex_same_thread.py`을 참고하세요. \ No newline at end of file +- [Codex 도구 API 레퍼런스](ref/extensions/experimental/codex/codex_tool.md) +- [ThreadOptions 레퍼런스](ref/extensions/experimental/codex/thread_options.md) +- [TurnOptions 레퍼런스](ref/extensions/experimental/codex/turn_options.md) +- 완전한 실행 가능 샘플은 `examples/tools/codex.py` 및 `examples/tools/codex_same_thread.py`을 참조하세요. \ No newline at end of file diff --git a/docs/ko/tracing.md b/docs/ko/tracing.md index 3ddbf67b3a..b25ab15d26 100644 --- a/docs/ko/tracing.md +++ b/docs/ko/tracing.md @@ -4,51 +4,51 @@ search: --- # 트레이싱 -Agents SDK에는 기본 제공 트레이싱 기능이 포함되어 있어 에이전트 실행 중 발생하는 이벤트의 포괄적인 기록을 수집합니다. 여기에는 LLM 생성, 도구 호출, 핸드오프, 가드레일은 물론 발생하는 사용자 지정 이벤트까지 포함됩니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버깅하고 시각화하며 모니터링할 수 있습니다. +Agents SDK에는 기본 트레이싱 기능이 포함되어 있어 에이전트 실행 중 발생하는 LLM 생성, 도구 호출, 핸드오프, 가드레일, 사용자 지정 이벤트까지 포괄적으로 기록합니다. [트레이스 대시보드](https://platform.openai.com/traces)를 사용하면 개발 및 프로덕션 환경에서 워크플로를 디버그하고 시각화하며 모니터링할 수 있습니다. !!!note - 트레이싱은 기본적으로 활성화되어 있습니다. 다음 세 가지 일반적인 방법으로 비활성화할 수 있습니다. + 트레이싱은 기본적으로 활성화되어 있습니다. 일반적으로 다음 세 가지 방법으로 비활성화할 수 있습니다. - 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역적으로 비활성화할 수 있습니다. - 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]을 사용하여 트레이싱을 전역적으로 비활성화할 수 있습니다. - 3. [`agents.run.RunConfig.tracing_disabled`][]를 `True`으로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다. + 1. 환경 변수 `OPENAI_AGENTS_DISABLE_TRACING=1`을 설정하여 트레이싱을 전역적으로 비활성화할 수 있습니다 + 2. 코드에서 [`set_tracing_disabled(True)`][agents.set_tracing_disabled]을 사용하여 트레이싱을 전역적으로 비활성화할 수 있습니다 + 3. [`agents.run.RunConfig.tracing_disabled`][]을 `True`로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다 -***Zero Data Retention(ZDR) 정책에 따라 OpenAI API를 사용하는 조직에서는 트레이싱을 사용할 수 없습니다.*** +***OpenAI API를 Zero Data Retention(ZDR) 정책에 따라 사용하는 조직에서는 트레이싱을 사용할 수 없습니다.*** ## 트레이스와 스팬 {#traces-and-spans} -- **트레이스**는 단일 "워크플로"의 시작부터 끝까지 이어지는 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 가집니다. - - `workflow_name`: 논리적 워크플로 또는 앱의 이름입니다. 예를 들면 "코드 생성" 또는 "고객 서비스"입니다. +- **트레이스**는 "워크플로"의 단일 종단 간 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음과 같은 속성이 있습니다. + - `workflow_name`: 논리적 워크플로나 앱의 이름입니다. 예를 들면 "코드 생성" 또는 "고객 서비스"입니다. - `trace_id`: 트레이스의 고유 ID입니다. 전달하지 않으면 자동으로 생성됩니다. 형식은 `trace_<32_alphanumeric>`이어야 합니다. - `group_id`: 동일한 대화의 여러 트레이스를 연결하는 선택적 그룹 ID입니다. 예를 들어 채팅 스레드 ID를 사용할 수 있습니다. - `disabled`: True이면 트레이스가 기록되지 않습니다. - `metadata`: 트레이스의 선택적 메타데이터입니다. - **스팬**은 시작 및 종료 시간이 있는 작업을 나타냅니다. 스팬에는 다음 항목이 있습니다. - `started_at` 및 `ended_at` 타임스탬프 - - 해당 스팬이 속한 트레이스를 나타내는 `trace_id` - - 이 스팬의 상위 스팬이 있는 경우 이를 가리키는 `parent_id` + - 자신이 속한 트레이스를 나타내는 `trace_id` + - 이 스팬의 상위 스팬이 있는 경우 해당 스팬을 가리키는 `parent_id` - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트 정보가, `GenerationSpanData`에는 LLM 생성 정보 등이 포함됩니다. ## 기본 트레이싱 {#default-tracing} -SDK는 기본적으로 다음 항목을 트레이싱합니다. +기본적으로 SDK는 다음 항목을 트레이싱합니다. -- 전체 `Runner.{run, run_sync, run_streamed}()`은 `trace()`으로 래핑됩니다. -- 각 러너 호출은 `task_span()`으로 래핑됩니다. -- 각 모델 턴은 `turn_span()`으로 래핑됩니다. -- 에이전트가 실행될 때마다 `agent_span()`으로 래핑됩니다. -- LLM 생성은 `generation_span()`으로 래핑됩니다. -- 각 함수 도구 호출은 `function_span()`으로 래핑됩니다. -- 가드레일은 `guardrail_span()`으로 래핑됩니다. -- 핸드오프는 `handoff_span()`로 래핑됩니다. -- 오디오 입력(음성 텍스트 변환)은 `transcription_span()`으로 래핑됩니다. -- 오디오 출력(텍스트 음성 변환)은 `speech_span()`로 래핑됩니다. -- SDK는 관련 오디오 스팬을 `speech_group_span()` 아래에 배치할 수 있습니다. +- 전체 `Runner.{run, run_sync, run_streamed}()`이 `trace()`으로 래핑됩니다. +- 각 러너 호출이 `task_span()`으로 래핑됩니다. +- 각 모델 턴이 `turn_span()`으로 래핑됩니다. +- 에이전트가 실행될 때마다 `agent_span()`으로 래핑됩니다 +- LLM 생성은 `generation_span()`으로 래핑됩니다 +- 각 함수 도구 호출은 `function_span()`으로 래핑됩니다 +- 가드레일은 `guardrail_span()`으로 래핑됩니다 +- 핸드오프는 `handoff_span()`로 래핑됩니다 +- 오디오 입력(음성-텍스트 변환)은 `transcription_span()`으로 래핑됩니다 +- 오디오 출력(텍스트-음성 변환)은 `speech_span()`로 래핑됩니다 +- SDK는 관련 오디오 스팬을 `speech_group_span()` 아래에 배치할 수 있습니다 -기본 트레이스 이름은 리터럴 문자열 `Agent workflow`입니다. `trace`을 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]을 사용하여 이름과 기타 속성을 구성할 수도 있습니다. +기본적으로 트레이스 이름은 리터럴 문자열 `Agent workflow`입니다. `trace`을 사용하는 경우 이 이름을 설정할 수 있으며, [`RunConfig`][agents.run.RunConfig]을 사용하여 이름과 기타 속성을 구성할 수도 있습니다. -더 간결한 계층 구조가 필요하다면 해당 실행에서 자동 작업 및 턴 스팬을 비활성화하세요. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. +더 간결한 계층 구조를 원한다면 실행에 대한 자동 태스크 및 턴 스팬을 비활성화합니다. 에이전트, 생성, 함수, 가드레일, 핸드오프 및 사용자 지정 스팬은 계속 기록됩니다. ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -또한 [사용자 지정 트레이싱 프로세서](#custom-tracing-processors)를 설정하여 트레이스를 다른 대상으로 전송할 수 있습니다. 기존 대상을 대체하거나 보조 대상으로 사용할 수 있습니다. +또한 트레이스를 다른 대상으로 전송하도록 [사용자 지정 트레이스 프로세서](#custom-tracing-processors)를 설정할 수 있습니다. 이 대상은 기존 대상을 대체하거나 보조 대상으로 사용할 수 있습니다. ## 장기 실행 워커와 즉시 내보내기 {#long-running-workers-and-immediate-exports} -기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내며, 인메모리 큐가 크기 트리거에 도달하면 더 일찍 내보냅니다. 또한 프로세스가 종료될 때 최종 플러시를 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 작업과 같은 장기 실행 워커에서는 별도의 코드 없이도 일반적으로 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후에는 트레이스 대시보드에 표시되지 않을 수 있습니다. +기본 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]는 몇 초마다 백그라운드에서 트레이스를 내보내거나, 인메모리 큐가 크기 임계값에 도달하면 더 일찍 내보내며, 프로세스가 종료될 때 최종 플러시도 수행합니다. Celery, RQ, Dramatiq 또는 FastAPI 백그라운드 태스크와 같은 장기 실행 워커에서는 일반적으로 추가 코드 없이 트레이스가 자동으로 내보내지지만, 각 작업이 완료된 직후 트레이스 대시보드에 표시되지 않을 수 있습니다. -작업 단위가 끝날 때 즉시 전달되는 것을 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]을 호출하세요. +작업 단위가 끝날 때 즉시 전달되도록 보장해야 한다면 트레이스 컨텍스트가 종료된 후 [`flush_traces()`][agents.tracing.flush_traces]을 호출합니다. ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces]은 현재 버퍼링된 트레이스와 스팬을 내보낼 때까지 실행을 차단합니다. 따라서 일부만 생성된 트레이스를 플러시하지 않도록 `trace()`가 닫힌 후 호출하세요. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. +[`flush_traces()`][agents.tracing.flush_traces]은 현재 버퍼링된 트레이스와 스팬을 모두 내보낼 때까지 블로킹되므로, 일부만 생성된 트레이스를 플러시하지 않도록 `trace()`가 닫힌 후 호출합니다. 기본 내보내기 지연 시간이 허용 가능한 경우에는 이 호출을 생략할 수 있습니다. -## 상위 수준 트레이스 {#higher-level-traces} +## 고수준 트레이스 {#higher-level-traces} -여러 `run()` 호출을 단일 트레이스에 포함해야 하는 경우가 있습니다. 전체 코드를 `trace()`으로 래핑하면 됩니다. +여러 `run()` 호출을 단일 트레이스에 포함하려는 경우가 있습니다. 전체 코드를 `trace()`으로 래핑하면 됩니다. ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 두 `Runner.run` 호출이 `with trace()`으로 래핑되므로, 각 실행이 별도의 트레이스를 생성하지 않고 두 실행 모두 하나의 전체 트레이스에 포함됩니다. +1. 두 번의 `Runner.run` 호출이 `with trace()`으로 래핑되므로, 각 실행이 별도의 트레이스를 생성하는 대신 두 실행 모두 하나의 전체 트레이스에 포함됩니다. ## 트레이스 생성 {#creating-traces} [`trace()`][agents.tracing.trace] 함수를 사용하여 트레이스를 생성할 수 있습니다. 트레이스는 시작하고 종료해야 합니다. 다음 두 가지 방법을 사용할 수 있습니다. 1. **권장**: 트레이스를 컨텍스트 관리자로 사용합니다. 즉, `with trace(...) as my_trace`을 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. -2. [`trace.start()`][agents.tracing.Trace.start] 및 [`trace.finish()`][agents.tracing.Trace.finish]를 직접 호출할 수도 있습니다. +2. [`trace.start()`][agents.tracing.Trace.start]와 [`trace.finish()`][agents.tracing.Trace.finish]를 직접 호출할 수도 있습니다. -현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하고 종료하는 경우 현재 트레이스를 업데이트하려면 `mark_as_current`를 `start()`에 전달하고 `reset_current`을 `finish()`에 전달하세요. +현재 트레이스는 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하고 종료하는 경우 현재 트레이스를 업데이트하려면 `mark_as_current`를 `start()`에 전달하고 `reset_current`을 `finish()`에 전달합니다. ## 스팬 생성 {#creating-spans} 다양한 [`*_span()`][agents.tracing.create] 메서드를 사용하여 스팬을 생성할 수 있습니다. 일반적으로 스팬을 직접 생성할 필요는 없습니다. 사용자 지정 스팬 정보를 추적할 수 있도록 [`custom_span()`][agents.tracing.custom_span] 함수가 제공됩니다. -스팬은 자동으로 현재 트레이스에 포함되며 가장 가까운 현재 스팬 아래에 중첩됩니다. 현재 스팬은 Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적됩니다. +스팬은 자동으로 현재 트레이스에 포함되며, Python [`contextvar`](https://docs.python.org/3/library/contextvars.html)를 통해 추적되는 가장 가까운 현재 스팬 아래에 중첩됩니다. ## 민감한 데이터 {#sensitive-data} -일부 스팬은 잠재적으로 민감한 데이터를 캡처할 수 있습니다. +일부 스팬은 민감할 수 있는 데이터를 캡처할 수 있습니다. -`generation_span()`에는 LLM 생성의 입력/출력이 저장되고, `function_span()`에는 함수 호출의 입력/출력이 저장됩니다. 여기에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]를 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. +`generation_span()`에는 LLM 생성의 입력과 출력이 저장되고, `function_span()`에는 함수 호출의 입력과 출력이 저장됩니다. 이러한 항목에는 민감한 데이터가 포함될 수 있으므로 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]을 통해 해당 데이터의 캡처를 비활성화할 수 있습니다. -마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 Base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]를 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. +마찬가지로 오디오 스팬에는 기본적으로 입력 및 출력 오디오의 base64 인코딩 PCM 데이터가 포함됩니다. [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]을 구성하여 이 오디오 데이터의 캡처를 비활성화할 수 있습니다. 기본적으로 `trace_include_sensitive_data`은 `True`입니다. 앱을 실행하기 전에 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 환경 변수를 `true/1` 또는 `false/0`으로 내보내면 코드 없이 기본값을 설정할 수 있습니다. ## 사용자 지정 트레이싱 프로세서 {#custom-tracing-processors} -트레이싱의 상위 수준 아키텍처는 다음과 같습니다. +트레이싱의 고수준 아키텍처는 다음과 같습니다. - 초기화 시 트레이스 생성을 담당하는 전역 [`TraceProvider`][agents.tracing.provider.TraceProvider]를 생성합니다. -- `TraceProvider`에 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]를 구성합니다. 이 프로세서는 트레이스와 스팬을 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]로 일괄 전송하며, 해당 익스포터는 스팬과 트레이스를 OpenAI 백엔드로 일괄 내보냅니다. +- 트레이스와 스팬을 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter]로 일괄 전송하는 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor]를 사용하여 `TraceProvider`를 구성합니다. 이 내보내기는 스팬과 트레이스를 OpenAI 백엔드로 일괄 내보냅니다. -이 기본 설정을 사용자 지정하여 트레이스를 대체 또는 추가 백엔드로 전송하거나 익스포터 동작을 수정하려면 다음 두 가지 방법을 사용할 수 있습니다. +이 기본 설정을 사용자 지정하여 트레이스를 대체 또는 추가 백엔드로 전송하거나 내보내기 동작을 수정하려면 다음 두 가지 방법을 사용할 수 있습니다. -1. [`add_trace_processor()`][agents.tracing.add_trace_processor]을 사용하면 준비된 트레이스와 스팬을 수신할 **추가** 트레이스 프로세서를 등록할 수 있습니다. 이를 통해 트레이스를 OpenAI 백엔드로 전송하는 동시에 자체 처리를 수행할 수 있습니다. -2. [`set_trace_processors()`][agents.tracing.set_trace_processors]을 사용하면 기본 프로세서를 자체 트레이스 프로세서로 **교체**할 수 있습니다. 이 경우 이를 수행하는 `TracingProcessor`을 포함하지 않는 한 트레이스가 OpenAI 백엔드로 전송되지 않습니다. +1. [`add_trace_processor()`][agents.tracing.add_trace_processor]을 사용하면 준비된 트레이스와 스팬을 수신할 **추가** 트레이스 프로세서를 등록할 수 있습니다. 이를 통해 트레이스를 OpenAI 백엔드로 전송하면서 자체 처리도 수행할 수 있습니다. +2. [`set_trace_processors()`][agents.tracing.set_trace_processors]을 사용하면 기본 프로세서를 자체 트레이스 프로세서로 **대체**할 수 있습니다. 이 경우 OpenAI 백엔드로 전송하는 `TracingProcessor`을 포함하지 않으면 트레이스가 OpenAI 백엔드로 전송되지 않습니다. -## 비 OpenAI 모델을 사용한 트레이싱 {#tracing-with-non-openai-models} +## 비OpenAI 모델을 사용한 트레이싱 {#tracing-with-non-openai-models} -비 OpenAI 모델을 사용할 때 트레이싱 익스포터에 OpenAI API 키를 제공하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 사용할 수 있습니다. 어댑터 선택 및 설정 시 주의 사항은 모델 가이드의 [서드 파티 어댑터](models/index.md#third-party-adapters) 섹션을 참조하세요. +비OpenAI 모델을 사용할 때 트레이싱 내보내기에 OpenAI API 키를 제공하면 트레이싱을 비활성화하지 않고도 OpenAI 트레이스 대시보드에서 무료 트레이싱을 사용할 수 있습니다. 어댑터 선택 및 설정 시 유의 사항은 모델 가이드의 [서드파티 어댑터](models/index.md#third-party-adapters) 섹션을 참고하세요. ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -단일 실행에만 다른 트레이싱 키가 필요하다면 전역 익스포터를 변경하는 대신 `RunConfig`을 통해 전달하세요. +단일 실행에만 다른 트레이싱 키가 필요한 경우 전역 내보내기를 변경하는 대신 `RunConfig`을 통해 전달합니다. ```python from agents import Runner, RunConfig @@ -203,33 +203,33 @@ await Runner.run( ## 에코시스템 통합 {#ecosystem-integrations} -다음 커뮤니티 및 벤더 통합은 OpenAI Agents SDK의 트레이싱 API 인터페이스를 지원합니다. +다음 커뮤니티 및 공급업체 통합은 OpenAI Agents SDK의 트레이싱 API 인터페이스를 지원합니다. ### 외부 트레이싱 프로세서 목록 {#external-tracing-processors-list} -- [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) -- [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) +- [Weights & Biases](https://docs.wandb.ai/weave/guides/integrations/agents/openai-agents-sdk) +- [Arize Phoenix](https://arize.com/docs/phoenix/integrations/llm-providers/openai/openai-agents-sdk-tracing) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) -- [MLflow(자체 호스팅/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow(Databricks 호스팅)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) -- [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) -- [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) +- [MLflow (자체 호스팅/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow (Databricks 호스팅)](https://docs.databricks.com/aws/en/mlflow3/genai/tracing/integrations/openai-agent) +- [Braintrust](https://www.braintrust.dev/docs/integrations/agent-frameworks/openai-agents-sdk) +- [Pydantic Logfire](https://pydantic.dev/docs/logfire/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) -- [Scorecard](https://docs.scorecard.io/docs/documentation/features/tracing#openai-agents-sdk-integration) -- [Respan](https://respan.ai/docs/integrations/tracing/openai-agents-sdk) -- [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/trace_with_openai_agents_sdk) -- [Maxim AI](https://www.getmaxim.ai/docs/observe/integrations/openai-agents-sdk) -- [Comet Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) -- [Langfuse](https://langfuse.com/docs/integrations/openaiagentssdk/openai-agents) +- [Scorecard](https://docs.scorecard.io/features/tracing#agent-frameworks) +- [Respan](https://www.respan.ai/docs/integrations/openai-agents-sdk) +- [LangSmith](https://docs.langchain.com/langsmith/trace-openai) +- [Maxim AI](https://www.getmaxim.ai/docs/sdk/python/integrations/openai/agents-sdk) +- [Comet Opik](https://www.comet.com/docs/opik/integrations/openai_agents) +- [Langfuse](https://langfuse.com/integrations/frameworks/openai-agents) - [Langtrace](https://docs.langtrace.ai/supported-integrations/llm-frameworks/openai-agents-sdk) - [Okahu-Monocle](https://github.com/monocle2ai/monocle) -- [Galileo](https://v2docs.galileo.ai/integrations/openai-agent-integration#openai-agent-integration) +- [Galileo](https://docs.galileo.ai/how-to-guides/third-party-integrations/openai-agent-integration) - [Portkey AI](https://portkey.ai/docs/integrations/agents/openai-agents) -- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk) -- [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) -- [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) -- [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) +- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk/) +- [Agenta](https://agenta.ai/docs/observability/integrations/openai-agents) +- [PostHog](https://posthog.com/docs/ai-observability/installation/openai-agents) +- [Traccia](https://traccia.ai/docs/integrations/openai-agents/) +- [PromptLayer](https://docs.promptlayer.com/features/observability/traces/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) diff --git a/docs/zh/context.md b/docs/zh/context.md index 881b6d044d..f1237c0f86 100644 --- a/docs/zh/context.md +++ b/docs/zh/context.md @@ -4,49 +4,59 @@ search: --- # 上下文管理 -上下文是一个含义宽泛的术语。你可能需要关注两类主要的上下文: +上下文是一个含义多重的术语。你可能会关注两大类上下文: -1. 你的代码在本地可用的上下文:这是工具函数运行时、`on_handoff` 等回调中、生命周期钩子中可能需要的数据和依赖项。 -2. LLM 可用的上下文:这是 LLM 在生成响应时能够看到的数据。 +1. 代码在本地可用的上下文:即工具函数运行时、`on_handoff` 等回调中、生命周期钩子中可能需要的数据和依赖项。 +2. LLM 可用的上下文:即 LLM 在生成响应时可以看到的数据。 ## 本地上下文 {#local-context} -本地上下文由 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 类及其中的 [`context`][agents.run_context.RunContextWrapper.context] 属性表示。其工作方式如下: +本地上下文通过 [`RunContextWrapper`][agents.run_context.RunContextWrapper] 类及其中的 [`context`][agents.run_context.RunContextWrapper.context] 属性表示。其工作方式如下: -1. 创建任意所需的 Python 对象。常见模式是使用 dataclass 或 Pydantic 对象。 +1. 创建任意所需的 Python 对象。常见做法是使用 dataclass 或 Pydantic 对象。 2. 将该对象传递给各种运行方法(例如 `Runner.run(..., context=whatever)`)。 -3. 所有工具调用、生命周期钩子等都会收到一个包装器对象 `RunContextWrapper[T]`,其中 `T` 表示上下文对象的类型;该对象本身可通过 `wrapper.context` 获取。 +3. 所有工具调用、生命周期钩子等都会收到一个包装器对象 `RunContextWrapper[T]`,其中 `T` 表示上下文对象的类型;可以通过 `wrapper.context` 访问该对象本身。 -对于某些特定于运行时的回调,SDK 可能会传入 `RunContextWrapper[T]` 的特定子类。例如,`FunctionTool` 实例的生命周期钩子通常会收到 `ToolContext`,它还会公开 `tool_call_id`、`tool_name` 和 `tool_arguments` 等工具调用元数据。 +对于某些特定于运行时的回调,SDK 可能会传递 `RunContextWrapper[T]` 的更专用子类。例如,`FunctionTool` 实例的生命周期钩子通常会收到 `ToolContext`,后者还会公开 `tool_call_id`、`tool_name` 和 `tool_arguments` 等工具调用元数据。 -需要注意的**最重要**事项是:对于一次给定的智能体运行,其中的每个智能体、工具函数、生命周期等都必须使用相同的上下文_类型_。 +需要注意的**最重要**事项是:对于给定的一次智能体运行,每个智能体、工具函数、生命周期等都必须使用相同的上下文_类型_。 -你可以将上下文用于以下方面: +上下文可用于: -- 运行所需的上下文数据(例如用户名/uid 或其他用户相关信息) +- 运行所需的上下文数据(例如用户名/uid 或关于用户的其他信息) - 依赖项(例如日志记录器对象、数据获取器等) - 辅助函数 !!! danger "注意" - 上下文对象**不会**发送给 LLM。它完全是一个本地对象,你可以读取和写入该对象,也可以调用其方法。 + 上下文对象**不会**发送给 LLM。它完全是一个本地对象,你可以读取、写入它以及调用其方法。 -在单次运行中,派生的包装器共享相同的底层应用上下文、审批状态和用量追踪。嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行可以附加不同的 `tool_input`,但默认情况下,它们不会获得应用状态的独立副本。 +在单次运行中,派生包装器共享相同的底层应用上下文、审批状态和用量追踪。嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行可以附加不同的 `tool_input`,但默认情况下,它们不会获得应用状态的独立副本。 -### `RunContextWrapper` 提供的内容 {#what-runcontextwrapper-exposes} +### 本地上下文在能力可见性方面的使用 {#use-local-context-for-capability-visibility} -[`RunContextWrapper`][agents.run_context.RunContextWrapper] 是应用自定义上下文对象的包装器。实际使用中,你最常用到的是: +当函数工具、MCP 工具和任务转移依赖同一请求策略时,请将策略输入或辅助函数保存在应用上下文中。每个 SDK 接口都通过各自的回调公开当前运行上下文: + +- [`FunctionTool.is_enabled`][agents.tool.FunctionTool.is_enabled] 接收一个 `RunContextWrapper`。 +- [`Handoff.is_enabled`][agents.handoffs.Handoff.is_enabled] 接收一个 `RunContextWrapper`。 +- MCP [`tool_filter`](mcp.md#dynamic-tool-filtering) 接收一个 [`ToolFilterContext`][agents.mcp.ToolFilterContext],其 `run_context` 属性包含当前的 `RunContextWrapper`。 + +请针对这些回调调整共享的应用策略,而不是维护单独的能力列表。这些回调用于控制 SDK 在当前运行中公开哪些能力;它们无法对模型生成的参数或资源选择进行授权。对于函数工具,请在工具实现内部实施这些决策,或在适当时使用[工具输入安全防护措施](guardrails.md#tool-guardrails)和[审批](human_in_the_loop.md)。MCP 服务器必须自行对受保护的操作进行授权。对于具有 `input_type` 的任务转移,请在 `on_handoff` 开始时、应用产生副作用之前检查解析后的输入;如果授权失败,应抛出异常,而不是返回结果。工具输入安全防护措施不会对任务转移运行。有关回调生命周期,请参阅[任务转移输入](handoffs.md#handoff-inputs)。 + +### `RunContextWrapper` 公开的内容 {#what-runcontextwrapper-exposes} + +[`RunContextWrapper`][agents.run_context.RunContextWrapper] 是应用自定义上下文对象的包装器。在实际使用中,最常用的包括: - [`wrapper.context`][agents.run_context.RunContextWrapper.context],用于应用自身的可变状态和依赖项。 -- [`wrapper.usage`][agents.run_context.RunContextWrapper.usage],用于当前运行期间聚合的请求用量和 token 用量。 -- [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input],用于当前运行在 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 内部执行时的结构化输入。 +- [`wrapper.usage`][agents.run_context.RunContextWrapper.usage],用于当前运行中汇总的请求和 token 用量。 +- [`wrapper.tool_input`][agents.run_context.RunContextWrapper.tool_input],用于当前运行正在 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 内执行时的结构化输入。 - [`wrapper.approve_tool(...)`][agents.run_context.RunContextWrapper.approve_tool] / [`wrapper.reject_tool(...)`][agents.run_context.RunContextWrapper.reject_tool],用于以编程方式更新审批状态。 只有 `wrapper.context` 是应用自定义对象。其他字段均为 SDK 管理的运行时元数据。 -如果之后要为人机协同或持久化任务工作流序列化 [`RunState`][agents.run_state.RunState],这些运行时元数据会随状态一起保存。如果打算持久化或传输序列化后的状态,请避免在 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] 中存放机密信息。 +如果之后为人在回路或持久化作业工作流序列化 [`RunState`][agents.run_state.RunState],该运行时元数据将随状态一起保存。如果打算持久化或传输序列化状态,请避免在 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] 中存放机密信息。 -对话状态是另一个独立的问题。请根据所需的对话轮次延续方式,使用 `result.to_input_list()`、`session`、`conversation_id` 或 `previous_response_id`。有关如何选择,请参阅[结果](results.md)、[运行智能体](running_agents.md)和[会话](sessions/index.md)。 +对话状态是另一个独立问题。根据希望如何延续多个对话轮次,可以使用 `result.to_input_list()`、`session`、`conversation_id` 或 `previous_response_id`。有关如何选择,请参阅[结果](results.md)、[运行智能体](running_agents.md)和[会话](sessions/index.md)。 ```python import asyncio @@ -86,18 +96,18 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 这是上下文对象。此处使用了 dataclass,但你可以使用任意类型。 -2. 这是一个工具。可以看到,它接收 `RunContextWrapper[UserInfo]`。工具实现会从上下文中读取数据。 -3. 我们使用泛型 `UserInfo` 标记智能体,以便类型检查器捕获错误(例如,如果尝试传入一个接收不同上下文类型的工具)。 +1. 这是上下文对象。此处使用了 dataclass,但也可以使用任何类型。 +2. 这是一个工具。可以看到,它接受一个 `RunContextWrapper[UserInfo]`。工具实现会从上下文中读取数据。 +3. 我们使用泛型 `UserInfo` 标记智能体,以便类型检查器捕获错误(例如,当我们尝试传入一个使用不同上下文类型的工具时)。 4. 上下文会传递给 `run` 函数。 5. 智能体正确调用工具并获取年龄。 --- -### 高级用法:`ToolContext` {#advanced-toolcontext} +### 高级功能:`ToolContext` {#advanced-toolcontext} -在某些情况下,你可能需要访问有关正在执行的工具的额外元数据,例如工具名称、调用 ID 或原始参数字符串。 -为此,可以使用 [`ToolContext`][agents.tool_context.ToolContext] 类,它扩展了 `RunContextWrapper`。 +在某些情况下,你可能希望访问有关正在执行的工具的额外元数据,例如工具名称、调用 ID 或原始参数字符串。 +为此,可以使用 [`ToolContext`][agents.tool_context.ToolContext] 类,该类扩展了 `RunContextWrapper`。 ```python from typing import Annotated @@ -127,24 +137,24 @@ agent = Agent( ``` `ToolContext` 提供与 `RunContextWrapper` 相同的 `.context` 属性, -此外还提供当前工具调用特有的字段: +以及以下特定于当前工具调用的额外字段: -- `tool_name` – 被调用工具的名称 +- `tool_name` – 正在调用的工具名称 - `tool_call_id` – 此工具调用的唯一标识符 - `tool_arguments` – 传递给工具的原始参数字符串 -- `tool_namespace` – 工具调用的 Responses 命名空间,适用于通过 `tool_namespace()` 或其他带命名空间的接口加载工具的情况 -- `qualified_tool_name` – 存在命名空间时,以命名空间限定的工具名称 +- `tool_namespace` – 工具调用的 Responses 命名空间,适用于通过 `tool_namespace()` 或其他命名空间化接口加载工具的情况 +- `qualified_tool_name` – 存在命名空间时,以该命名空间限定的工具名称 -如果在执行期间需要工具级元数据,请使用 `ToolContext`。 -对于智能体与工具之间的常规上下文共享,`RunContextWrapper` 仍然足够。由于 `ToolContext` 扩展了 `RunContextWrapper`,当嵌套的 `Agent.as_tool()` 运行提供结构化输入时,它也可以公开 `.tool_input`。 +如果执行期间需要工具级元数据,请使用 `ToolContext`。 +对于智能体与工具之间的常规上下文共享,`RunContextWrapper` 仍然足够。由于 `ToolContext` 扩展了 `RunContextWrapper`,因此当嵌套的 `Agent.as_tool()` 运行提供结构化输入时,它也可以公开 `.tool_input`。 --- ## 智能体/LLM 上下文 {#agentllm-context} -调用 LLM 时,它**唯一**能看到的数据来自对话历史记录。这意味着,如果希望 LLM 能够使用某些新数据,就必须以某种方式让这些数据出现在该历史记录中。具体有以下几种方式: +调用 LLM 时,它**唯一**能看到的数据来自对话历史记录。这意味着,如果希望让 LLM 获得某些新数据,就必须以某种方式将其加入该历史记录。可以通过以下几种方式实现: -1. 可以将其添加到智能体的 `instructions` 中。这也称为“系统提示词”或“开发者消息”。系统提示词可以是静态字符串,也可以是接收上下文并输出字符串的动态函数。这是处理始终有用的信息时常用的策略(例如用户姓名或当前日期)。 -2. 调用 `Runner.run` 函数时,将其添加到 `input` 中。这与 `instructions` 策略类似,但可以使用在[指令层级](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)中优先级较低的消息。 -3. 通过 `FunctionTool` 实例公开这些数据。这对于_按需_上下文非常有用——LLM 会自行判断何时需要某些数据,并可调用工具来获取这些数据。 -4. 使用检索或网络检索。这些是能够从文件或数据库中获取相关数据(检索),或者从网络获取相关数据(网络检索)的特殊工具。这有助于让响应以相关上下文数据为依据。 \ No newline at end of file +1. 可以将其添加到智能体的 `instructions` 中。这也称为“system prompt”或“开发者消息”。system prompt 可以是静态字符串,也可以是接收上下文并输出字符串的动态函数。这是一种适合提供始终有用的信息的常用方法(例如用户姓名或当前日期)。 +2. 调用 `Runner.run` 函数时,将其添加到 `input` 中。这与 `instructions` 方法类似,但可以使消息在[指令层级](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command)中处于较低层级。 +3. 通过 `FunctionTool` 实例公开这些数据。这适用于_按需_上下文:LLM 自行决定何时需要某些数据,并可以调用工具获取这些数据。 +4. 使用检索或网络检索。这些特殊工具能够从文件或数据库中获取相关数据(检索),或者从网络获取相关数据(网络检索)。这有助于让响应以相关上下文数据为依据。 \ No newline at end of file diff --git a/docs/zh/handoffs.md b/docs/zh/handoffs.md index b2f59234ba..f75bf15b3b 100644 --- a/docs/zh/handoffs.md +++ b/docs/zh/handoffs.md @@ -4,17 +4,17 @@ search: --- # 任务转移 -任务转移允许一个智能体将任务委派给另一个智能体。这在不同智能体分别擅长不同领域的场景中特别有用。例如,一个客户支持应用可能包含多个智能体,分别专门处理订单状态、退款、常见问题等任务。 +任务转移允许一个智能体将任务委派给另一个智能体。这在不同智能体分别擅长不同领域的场景中尤其有用。例如,客户支持应用可能包含多个智能体,分别专门处理订单状态、退款、常见问题等任务。 -任务转移以工具的形式呈现给LLM。因此,如果任务转移的目标是名为 `Refund Agent` 的智能体,则该工具将命名为 `transfer_to_refund_agent`。 +任务转移以工具的形式呈现给 LLM。因此,如果要将任务转移给名为 `Refund Agent` 的智能体,该工具将命名为 `transfer_to_refund_agent`。 ## 任务转移的创建 {#creating-a-handoff} -所有智能体都有一个 [`handoffs`][agents.agent.Agent.handoffs] 参数,该参数既可以直接接收 `Agent`,也可以接收用于自定义任务转移的 `Handoff` 对象。 +所有智能体都有一个 [`handoffs`][agents.agent.Agent.handoffs] 参数,该参数既可以直接接受 `Agent`,也可以接受用于自定义任务转移的 `Handoff` 对象。 -如果传入普通的 `Agent` 实例,则其 [`handoff_description`][agents.agent.Agent.handoff_description](如果已设置)会附加到默认工具描述中。可使用该属性提示模型应在何时选择该任务转移,而无需编写完整的 `handoff()` 对象。 +如果传入普通的 `Agent` 实例,其 [`handoff_description`][agents.agent.Agent.handoff_description](设置后)会追加到默认工具描述中。可以使用它来提示模型何时应选择该任务转移,而无需编写完整的 `handoff()` 对象。 -你可以使用 Agents SDK 提供的 [`handoff()`][agents.handoffs.handoff] 函数创建任务转移。此函数允许你指定任务要转移到的智能体,以及可选的覆盖项和输入过滤器。 +你可以使用 Agents SDK 提供的 [`handoff()`][agents.handoffs.handoff] 函数创建任务转移。此函数允许你指定任务要转移到的智能体,以及可选的覆盖设置和输入过滤器。 ### 基本用法 {#basic-usage} @@ -34,18 +34,18 @@ triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refun ### 通过 `handoff()` 函数自定义任务转移 {#customizing-handoffs-via-the-handoff-function} -[`handoff()`][agents.handoffs.handoff] 函数支持自定义以下内容。 +[`handoff()`][agents.handoffs.handoff] 函数可用于自定义各项设置。 - `agent`:任务将转移到的智能体。 - `tool_name_override`:默认使用 `Handoff.default_tool_name()` 函数,其解析结果为 `transfer_to_`。你可以覆盖此设置。 - `tool_description_override`:覆盖来自 `Handoff.default_tool_description()` 的默认工具描述。 -- `on_handoff`:调用任务转移时执行的回调函数。它适用于在确认调用任务转移后立即启动数据获取等操作。此函数接收智能体上下文,还可以选择接收LLM生成的输入。输入数据由 `input_type` 参数控制。 -- `input_type`:任务转移工具调用参数的 schema。设置后,解析后的载荷将传递给 `on_handoff`。 -- `input_filter`:用于过滤下一个智能体接收的输入。详见下文。 -- `is_enabled`:是否启用任务转移。该值可以是布尔值,也可以是返回布尔值的函数,因此你可以在运行时动态启用或禁用任务转移。 -- `nest_handoff_history`:针对单次任务转移,对 RunConfig 级别 `nest_handoff_history` 设置的可选覆盖。如果为 `None`,则改用当前运行配置中定义的值。 +- `on_handoff`:调用任务转移时执行的回调函数。它适用于在确定即将调用任务转移后立即启动数据获取等操作。此函数接收智能体上下文,也可以选择接收由 LLM 生成的输入。输入数据由 `input_type` 参数控制。 +- `input_type`:任务转移工具调用参数的 schema。设置后,解析后的载荷会传递给 `on_handoff`。 +- `input_filter`:用于过滤下一个智能体接收的输入。更多信息见下文。 +- `is_enabled`:是否启用任务转移。它可以是布尔值,也可以是返回布尔值的函数,以便在运行时动态启用或禁用任务转移。 +- `nest_handoff_history`:针对每次任务转移,可选择覆盖 RunConfig 级别的 `nest_handoff_history` 设置。如果为 `None`,则使用当前运行配置中定义的值。 -[`handoff()`][agents.handoffs.handoff] 辅助函数始终将控制权转移给你所传入的特定 `agent`。如果有多个可能的目标,请为每个目标注册一个任务转移,并让模型从中选择。仅当你自己的任务转移代码必须在调用时决定返回哪个智能体时,才使用自定义的 [`Handoff`][agents.handoffs.Handoff]。 +[`handoff()`][agents.handoffs.handoff] 辅助函数始终将控制权转移给你传入的特定 `agent`。如果存在多个可能的目标,请为每个目标注册一个任务转移,并让模型从中选择。仅当你自己的任务转移代码必须在调用时决定返回哪个智能体时,才使用自定义的 [`Handoff`][agents.handoffs.Handoff]。 ```python from agents import Agent, handoff, RunContextWrapper @@ -65,7 +65,7 @@ handoff_obj = handoff( ## 任务转移输入 {#handoff-inputs} -在某些情况下,你希望LLM在调用任务转移时提供一些数据。例如,假设要将任务转移给“升级处理智能体”。你可能希望模型提供原因,以便记录日志。 +在某些情况下,你希望 LLM 在调用任务转移时提供一些数据。例如,假设要将任务转移给“升级处理智能体”,你可能希望模型提供原因,以便记录日志。 ```python from pydantic import BaseModel @@ -89,42 +89,44 @@ handoff_obj = handoff( `input_type` 描述任务转移工具调用本身的参数。SDK 会将该 schema 作为任务转移工具的 `parameters` 提供给模型,在本地验证返回的 JSON,并将解析后的值传递给 `on_handoff`。 -它不会替换下一个智能体的主要输入,也不会选择不同的目标。[`handoff()`][agents.handoffs.handoff] 辅助函数仍会将任务转移给你封装的特定智能体,而接收智能体仍会看到对话历史记录,除非你通过 [`input_filter`][agents.handoffs.Handoff.input_filter] 或嵌套任务转移历史记录设置对其进行更改。 +`is_enabled` 会在 SDK 准备可用任务转移时进行求值,此时模型尚未返回任务转移参数,因此它无法对带参数任务转移中的值执行授权。如果授权取决于解析后的字段,请在 `on_handoff` 开始时、发生任何应用程序副作用之前执行检查。如果授权失败,请抛出异常而不是返回;`on_handoff` 成功返回后,SDK 会继续执行任务转移。工具输入安全防护措施适用于函数工具,而不适用于任务转移。 -`input_type` 也独立于 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。`input_type` 应用于模型在任务转移时决定的元数据,而不是你已在本地拥有的应用状态或依赖项。 +它不会替换下一个智能体的主要输入,也不会选择其他目标。[`handoff()`][agents.handoffs.handoff] 辅助函数仍会将任务转移给你包装的特定智能体,并且接收方智能体仍会看到对话历史记录,除非你使用 [`input_filter`][agents.handoffs.Handoff.input_filter] 或嵌套任务转移历史记录设置对其进行更改。 + +`input_type` 也不同于 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。`input_type` 应用于模型在任务转移时决定的元数据,而不是你已在本地拥有的应用程序状态或依赖项。 ### `input_type` 的适用场景 {#when-to-use-input_type} -当任务转移需要少量由模型生成的元数据(例如 `reason`、`language`、`priority` 或 `summary`)时,请使用 `input_type`。例如,分流智能体可以通过 `{ "reason": "duplicate_charge", "priority": "high" }` 将任务转移给退款智能体,而 `on_handoff` 可以在退款智能体接管之前记录或持久化该元数据。 +当任务转移需要少量由模型生成的元数据(例如 `reason`、`language`、`priority` 或 `summary`)时,请使用 `input_type`。例如,分诊智能体可以使用 `{ "reason": "duplicate_charge", "priority": "high" }` 将任务转移给退款智能体,而 `on_handoff` 可以在退款智能体接管之前记录或持久化该元数据。 如果目标不同,请选择其他机制: -- 将现有应用状态和依赖项放入 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。请参阅[上下文指南](context.md)。 -- 如果要更改接收智能体看到的历史记录,请使用 [`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 或 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]。 -- 如果存在多个可能的专业智能体,请为每个目标注册一个任务转移。`input_type` 可以向所选任务转移添加元数据,但不会在不同目标之间进行分派。 -- 如果希望在不转移对话的情况下为嵌套的专业智能体提供结构化输入,建议使用 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]。请参阅[工具](tools.md#structured-input-for-tool-agents)。 +- 将现有的应用程序状态和依赖项放入 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。请参阅[上下文指南](context.md)。 +- 如果要更改接收方智能体看到的历史记录,请使用 [`input_filter`][agents.handoffs.Handoff.input_filter]、[`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 或 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]。 +- 如果存在多个可能的专家智能体,请为每个目标注册一个任务转移。`input_type` 可以为选定的任务转移添加元数据,但不会在多个目标之间进行分派。 +- 如果要在不转移对话的情况下为嵌套的专家智能体提供结构化输入,请优先使用 [`Agent.as_tool(parameters=...)`][agents.agent.Agent.as_tool]。请参阅[工具](tools.md#structured-input-for-tool-agents)。 ## 输入过滤器 {#input-filters} -发生任务转移时,就像新智能体接管了对话,并且可以查看此前的完整对话历史记录。如果要更改这一行为,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回新的 `HandoffInputData`。 +发生任务转移时,就像新智能体接管了对话,并且可以看到此前的完整对话历史记录。如果要更改这一行为,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回一个新的 `HandoffInputData`。 -[`HandoffInputData`][agents.handoffs.HandoffInputData] 包括: +[`HandoffInputData`][agents.handoffs.HandoffInputData] 包含: -- `input_history`:`Runner.run(...)` 启动之前的输入历史记录。 +- `input_history`:`Runner.run(...)` 开始之前的输入历史记录。 - `pre_handoff_items`:调用任务转移的智能体轮次之前生成的项目。 -- `new_items`:当前轮次期间生成的项目,包括任务转移调用和任务转移输出项目。 -- `input_items`:可选项目,用于转发给下一个智能体以代替 `new_items`,从而可以过滤模型输入,同时保持会话历史记录中的 `new_items` 不变。 +- `new_items`:当前轮次中生成的项目,包括任务转移调用和任务转移输出项目。 +- `input_items`:可选项目,用于代替 `new_items` 转发给下一个智能体,使你可以过滤模型输入,同时保持 `new_items` 不变以用于会话历史记录。 - `run_context`:调用任务转移时处于活动状态的 [`RunContextWrapper`][agents.run_context.RunContextWrapper]。 -嵌套任务转移历史记录以选择加入的测试版功能提供,在我们使其达到稳定状态期间,默认处于禁用状态。启用 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 后,运行器会将可总结的历史记录压缩为有序的助手摘要片段,同时将无损消息项目保留在原始位置。每个生成的摘要片段都使用 `` 包装器,后续任务转移会先展开此前生成的片段,再重新构建有序的对话记录。会话、`RunState` 和 `RunResult.to_input_list()` 会追踪已移入此 SDK 默认历史记录的确切消息实例,以免重复追加这些实例;不同但内容相同的消息仍会保留。你可以通过 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] 提供自己的映射函数,返回下一个智能体所需的确切输入项目列表,而不使用内置分段机制。只有在任务转移的 `input_filter` 和当前运行的 `RunConfig.handoff_input_filter` 均未设置时,选择加入才会生效,因此已经自定义载荷的现有代码(包括此代码仓库中的代码示例)无需更改即可保持当前行为。你可以向 [`handoff(...)`][agents.handoffs.handoff] 传递 `nest_handoff_history=True` 或 `False`,为单次任务转移覆盖嵌套行为,这会设置 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]。如果只需更改生成的摘要片段所用的包装文本,请在运行智能体之前调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]。如果需要在之后的运行中恢复默认包装器,请在运行前调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]。 +嵌套任务转移历史记录以可选择启用的测试版功能提供,在功能稳定之前默认禁用。启用 [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history] 后,运行器会将可总结的历史记录压缩为有序的助手摘要片段,同时在原始位置保留无损消息项目。每个生成的摘要片段都使用 `` 包装器;后续任务转移会先展平之前生成的片段,再重新构建有序的对话记录。会话、`RunState` 和 `RunResult.to_input_list()` 会跟踪被移入此 SDK 默认历史记录的消息的确切出现实例,以免这些实例被重复追加;彼此独立但内容相同的消息仍会保留。你可以通过 [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] 提供自己的映射函数,返回供下一个智能体使用的确切输入项目列表,而不使用内置的分段机制。此可选功能仅在任务转移的 `input_filter` 和当前运行的 `RunConfig.handoff_input_filter` 均未设置时适用,因此已经自定义载荷的现有代码(包括本仓库中的代码示例)无需更改即可保持当前行为。你可以向 [`handoff(...)`][agents.handoffs.handoff] 传入 `nest_handoff_history=True` 或 `False`,为单次任务转移覆盖嵌套行为,这会设置 [`Handoff.nest_handoff_history`][agents.handoffs.Handoff.nest_handoff_history]。如果只需更改所生成摘要片段的包装文本,请在运行智能体之前调用 [`set_conversation_history_wrappers`][agents.handoffs.set_conversation_history_wrappers]。如果需要在后续运行前恢复默认包装器,请调用 [`reset_conversation_history_wrappers`][agents.handoffs.reset_conversation_history_wrappers]。 -如果任务转移和当前 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] 都定义了过滤器,则对于该特定任务转移,单次任务转移的 [`input_filter`][agents.handoffs.Handoff.input_filter] 优先。 +如果任务转移和当前的 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] 都定义了过滤器,则对于该次特定任务转移,任务转移级别的 [`input_filter`][agents.handoffs.Handoff.input_filter] 优先。 !!! note - 任务转移始终位于单次运行内。输入安全防护措施仍然仅适用于链中的第一个智能体,输出安全防护措施仅适用于生成最终输出的智能体。如果需要检查工作流中每次自定义函数工具调用,请使用工具安全防护措施。 + 任务转移始终位于同一次运行内。输入安全防护措施仍然仅适用于链中的第一个智能体,输出安全防护措施仅适用于生成最终输出的智能体。如果需要检查工作流中的每个自定义函数工具调用,请使用工具安全防护措施。 -有一些常见模式(例如从历史记录中移除所有工具调用)已在 [`agents.extensions.handoff_filters`][] 中实现。 +[`agents.extensions.handoff_filters`][] 中已经为你实现了一些常见模式(例如,从历史记录中移除所有工具调用)。 ```python from agents import Agent, handoff @@ -142,7 +144,7 @@ handoff_obj = handoff( ## 推荐提示词 {#recommended-prompts} -为确保LLM正确理解任务转移,我们建议在智能体中加入有关任务转移的信息。我们在 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] 中提供了建议的前缀,你也可以调用 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][],自动将建议的数据添加到提示词中。 +为确保 LLM 正确理解任务转移,我们建议在智能体中包含任务转移相关信息。我们在 [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][] 中提供了建议的前缀,你也可以调用 [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][],自动向提示词中添加推荐内容。 ```python from agents import Agent diff --git a/docs/zh/tools.md b/docs/zh/tools.md index 6c84b880d7..ea80304bd8 100644 --- a/docs/zh/tools.md +++ b/docs/zh/tools.md @@ -6,41 +6,41 @@ search: 工具让智能体能够执行操作,例如获取数据、运行代码、调用外部 API,甚至操作计算机。SDK 支持五类工具: -- 由OpenAI托管的工具:在 OpenAI 服务器上为模型执行。 +- 由OpenAI托管的工具:在OpenAI服务器上为模型执行。 - 本地/运行时执行工具:`ComputerTool` 和 `ApplyPatchTool` 始终在你的环境中运行,而 `ShellTool` 可以在本地或托管容器中运行。 - `FunctionTool` 实例:将任意 Python 函数封装为工具。 -- Agents as tools:将智能体公开为可调用工具,而无需完整的任务转移。 +- Agents as tools:将智能体公开为可调用工具,而无需进行完整的任务转移。 - 实验性 Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 -## 工具类型选择 {#choosing-a-tool-type} +## 工具类型的选择 {#choosing-a-tool-type} -将此页面用作目录,然后跳转到与你所控制的运行时相匹配的部分。 +将本页用作目录,然后跳转到与你所控制的运行时相匹配的部分。 -| 如果你想要…… | 从这里开始 | +| 如果你希望…… | 从这里开始 | | --- | --- | -| 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管 MCP、图像生成) | [托管工具](#hosted-tools) | -| 使用工具搜索将大型工具集合推迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | -| 通过生成的 JavaScript 协调多个工具调用 | [编程式工具调用](#programmatic-tool-calling) | -| 在自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | +| 使用由OpenAI管理的工具(网络检索、文件检索、Code Interpreter、托管MCP、图像生成) | [托管工具](#hosted-tools) | +| 通过工具搜索将大型工具集延迟到运行时加载 | [托管工具搜索](#hosted-tool-search) | +| 通过生成的 JavaScript 协调多个工具调用 | [程序化工具调用](#programmatic-tool-calling) | +| 在你自己的进程或环境中运行工具 | [本地运行时工具](#local-runtime-tools) | | 将 Python 函数封装为工具 | [函数工具](#function-tools) | | 让一个智能体调用另一个智能体,而不进行任务转移 | [Agents as tools](#agents-as-tools) | | 从智能体运行限定于工作区的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | ## 托管工具 {#hosted-tools} -使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI 提供了一些内置工具: +使用 [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] 时,OpenAI提供了一些内置工具: -- [`WebSearchTool`][agents.tool.WebSearchTool] 允许智能体搜索网络。 -- [`FileSearchTool`][agents.tool.FileSearchTool] 允许从你的 OpenAI向量存储中检索信息。 -- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 允许 LLM 在沙盒环境中执行代码。 -- [`HostedMCPTool`][agents.tool.HostedMCPTool] 将远程 MCP 服务器的工具公开给模型。 +- [`WebSearchTool`][agents.tool.WebSearchTool] 让智能体能够搜索网络。 +- [`FileSearchTool`][agents.tool.FileSearchTool] 允许从你的 OpenAI 向量存储中检索信息。 +- [`CodeInterpreterTool`][agents.tool.CodeInterpreterTool] 让 LLM 能够在沙盒环境中执行代码。 +- [`HostedMCPTool`][agents.tool.HostedMCPTool] 将远程MCP服务器的工具公开给模型。 - [`ImageGenerationTool`][agents.tool.ImageGenerationTool] 根据提示词生成图像。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] 允许模型按需加载延迟加载的工具、命名空间或托管 MCP 服务器。 -- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 允许模型通过生成的 JavaScript 协调符合条件的工具。 +- [`ToolSearchTool`][agents.tool.ToolSearchTool] 让模型能够按需加载延迟工具、命名空间或托管MCP服务器。 +- [`ProgrammaticToolCallingTool`][agents.tool.ProgrammaticToolCallingTool] 让模型能够通过生成的 JavaScript 协调符合条件的工具。 高级托管搜索选项: -- 除 `vector_store_ids` 和 `max_num_results` 外,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。将 `max_num_results` 设置为 1 到 50 之间的整数;`None` 或零会使用提供商的默认值。 +- 除 `vector_store_ids` 和 `max_num_results` 外,`FileSearchTool` 还支持 `filters`、`ranking_options` 和 `include_search_results`。将 `max_num_results` 设置为 1 至 50 的整数;`None` 或零表示使用提供商默认值。 - `WebSearchTool` 支持 `filters`、`user_location` 和 `search_context_size`。 ```python @@ -64,9 +64,9 @@ async def main(): ### 托管工具搜索 {#hosted-tool-search} -工具搜索允许 OpenAI Responses 模型将大型工具集合推迟到运行时加载,使模型只加载当前轮次所需的子集。当你有许多函数工具、命名空间组或托管 MCP 服务器,并且希望减少工具架构所占用的 token,而不预先公开所有工具时,这非常有用。 +工具搜索让 OpenAI Responses 模型能够将大型工具集延迟到运行时加载,使模型仅加载当前轮次所需的子集。当你有大量函数工具、命名空间组或托管MCP服务器,并希望减少工具 schema 的 token 用量,而不预先公开所有工具时,这非常有用。 -如果构建智能体时已经知道候选工具,请从托管工具搜索开始。如果应用程序需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 +如果在构建智能体时就已知候选工具,请从托管工具搜索开始。如果你的应用需要动态决定加载哪些内容,Responses API 也支持由客户端执行的工具搜索,但标准 `Runner` 不会自动执行该模式。 ```python from typing import Annotated @@ -111,26 +111,26 @@ print(result.final_output) 注意事项: -- 托管工具搜索仅适用于 OpenAI Responses 模型。目前的 Python SDK 支持依赖于 `openai>=2.25.0`。 -- 为智能体配置延迟加载的工具集合时,只添加一个 `ToolSearchTool()`。 -- 可搜索的工具集合包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 -- 延迟加载的函数工具必须与 `ToolSearchTool()` 配对。仅包含命名空间的配置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 -- `tool_namespace()` 将 `FunctionTool` 实例归入一个具有共同名称和描述的命名空间。当你有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最合适的选择。 -- OpenAI 的官方最佳实践指南是[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 -- 如果可能,优先使用命名空间或托管 MCP 服务器,而不是大量单独延迟加载的函数。它们通常能为模型提供更好的高层搜索界面,并节省更多 token。 -- 命名空间可以混合包含立即可用的工具和延迟加载的工具。没有 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟加载工具则通过工具搜索进行加载。 -- 经验法则是让每个命名空间保持较小规模,最好少于 10 个函数。 -- 具名 `tool_choice` 不能以单独的命名空间名称或仅延迟加载的工具为目标。优先使用 `auto`、`required` 或真实的顶层可调用工具名称。 +- 托管工具搜索仅适用于 OpenAI Responses 模型。当前 Python SDK 的支持依赖于 `openai>=2.25.0`。 +- 在智能体上配置延迟加载接口时,只添加一个 `ToolSearchTool()`。 +- 可搜索的接口包括 `@function_tool(defer_loading=True)`、`tool_namespace(name=..., description=..., tools=[...])` 和 `HostedMCPTool(tool_config={..., "defer_loading": True})`。 +- 延迟加载的函数工具必须与 `ToolSearchTool()` 配对。仅使用命名空间的配置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 +- `tool_namespace()` 将 `FunctionTool` 实例归入具有共享命名空间名称和描述的组中。当你有许多相关工具时,例如 `crm`、`billing` 或 `shipping`,这通常是最合适的选择。 +- OpenAI的官方最佳实践指南是[尽可能使用命名空间](https://developers.openai.com/api/docs/guides/tools-tool-search#use-namespaces-where-possible)。 +- 如有可能,优先使用命名空间或托管MCP服务器,而不是大量单独延迟的函数。它们通常能为模型提供更好的高层级搜索接口,并节省更多 token。 +- 命名空间可以混合包含立即可用和延迟加载的工具。没有 `defer_loading=True` 的工具仍可立即调用,而同一命名空间中的延迟工具则通过工具搜索加载。 +- 根据经验,每个命名空间应保持较小,最好少于 10 个函数。 +- 具名的 `tool_choice` 无法指向裸命名空间名称或仅延迟加载的工具。请优先使用 `auto`、`required` 或真实的顶层可调用工具名称。 - `ToolSearchTool(execution="client")` 用于手动进行 Responses 编排。如果模型发出由客户端执行的 `tool_search_call`,标准 `Runner` 会抛出异常,而不会代你执行。 -- 工具搜索活动会以专用的项目和事件类型出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中。 -- 有关涵盖命名空间加载和顶层延迟加载工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 +- 工具搜索活动会以专用条目和事件类型出现在 [`RunResult.new_items`](results.md#new-items) 和 [`RunItemStreamEvent`](streaming.md#run-item-event-names) 中。 +- 有关涵盖命名空间加载和顶层延迟工具的完整可运行代码示例,请参阅 `examples/tools/tool_search.py`。 - 官方平台指南:[工具搜索](https://developers.openai.com/api/docs/guides/tools-tool-search)。 -### 编程式工具调用 {#programmatic-tool-calling} +### 程序化工具调用 {#programmatic-tool-calling} -编程式工具调用允许受支持的 OpenAI Responses 模型生成 JavaScript,以调用符合条件的工具、合并其输出,并向模型返回一个结果。它适用于范围明确且可从循环、分支、并行调用或中间计算中获益的工作流,无需在每次工具调用后都与模型往返交互。 +程序化工具调用让受支持的 OpenAI Responses 模型能够生成 JavaScript,以调用符合条件的工具、合并其输出,并向模型返回一个结果。它适用于能从循环、分支、并行调用或中间计算中受益的限定工作流,并且无需在每次工具调用后都与模型进行一次往返交互。 -生成的程序在全新的托管 V8 环境中运行。它无法使用 Node.js API,无法访问文件系统或网络,也没有持久化进程。该程序只能与显式允许的工具交互。 +生成的程序会在全新的托管 V8 环境中运行。它无法使用 Node.js API,无法访问文件系统或网络,也没有持久化进程。该程序只能与明确允许的工具交互。 ```python from pydantic import BaseModel @@ -167,22 +167,22 @@ print(result.final_output) 注意事项: -- 编程式工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 -- 每个智能体最多添加一个 `ProgrammaticToolCallingTool()`。该智能体还必须公开至少一个可通过编程方式调用的工具、一个由命名空间、延迟函数或延迟托管 MCP 服务器支持的 `ToolSearchTool()`,或一个由提示词管理的不透明工具集合。不包含可搜索工具集合的单独 `ToolSearchTool()` 会被拒绝。 -- `allowed_callers` 控制工具的调用方式。省略它时,仅允许模型直接调用。使用 `["programmatic"]` 表示仅允许程序访问,或使用 `["direct", "programmatic"]` 同时允许两者。 -- 可选择启用此功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、shell 和应用补丁工具直接公开 `allowed_callers`。对于托管 MCP 和 Code Interpreter,请在 `tool_config` 内设置 `allowed_callers`。 -- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或 dataclass 等结构化返回注解会自动成为严格对象输出架构,并且返回值在返回给程序之前会依据该架构进行验证。当函数没有可用注解时,请使用 `output_type=...`;如果你已有严格对象架构,则可以使用更底层的 `output_json_schema={...}` 逃生舱。`output_type` 和 `output_json_schema` 互斥。`str`、`Any` 或 `None` 的返回注解不会创建输出架构。对于由架构支持且归程序所有的调用,默认失败格式化程序会被禁用,因为其自由格式文本不符合输出架构。因此,处理程序异常会继续传播,除非你提供自定义 `failure_error_function`,使其返回符合架构的 JSON。 -- 归程序所有的 SDK 工具仍使用常规 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,并且 SDK 会保留每个子调用与程序调用方的关系。 -- 只要存在 `ProgrammaticToolCallingTool()`,模型请求重试就会采用更严格的重放安全边界,即使程序尚未执行也是如此。SDK 会针对这些请求禁用由提供商管理的重试和 WebSocket 事件前重试。只有当提供商建议明确将重放标记为安全时,Runner 重试策略才会重试;仅设置 `retry_policies.network_error()` 不会覆盖此边界。 -- 对审批敏感或影响较大的工具通常更适合作为直接调用,以便人员可在每项操作成为更大程序的一部分之前进行审核。如果归程序所有的调用因等待审批而暂停,请通过 `RunState` 处理中断,并照常恢复原始运行。 -- 编程式工具调用可以与[托管工具搜索](#hosted-tool-search)结合使用。生成的程序必须先由模型加载延迟工具,之后才能调用它们。 -- `program` 项目及其普通的归程序所有的子工具调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。托管 MCP 审批请求和工具目录则使用专用的 MCP 项目和流事件。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 +- 程序化工具调用仅适用于受支持的 OpenAI Responses 模型。Chat Completions 模型和非 Responses 后端会拒绝 `ProgrammaticToolCallingTool()` 和 `tool_choice="programmatic_tool_calling"`。 +- 每个智能体最多添加一个 `ProgrammaticToolCallingTool()`。该智能体还必须公开至少一个可通过程序调用的工具,或一个由命名空间、延迟函数、延迟托管MCP服务器支持的 `ToolSearchTool()`,或一个由提示词管理的不透明工具接口。系统会拒绝没有可搜索接口的裸 `ToolSearchTool()`。 +- `allowed_callers` 控制工具的调用方式。省略它时,仅允许模型直接调用。使用 `["programmatic"]` 可设置为仅供程序访问,使用 `["direct", "programmatic"]` 可同时允许两种方式。 +- 可选择启用该功能的 SDK 工具类型包括 `FunctionTool`、`CustomTool`、`ShellTool`、`ApplyPatchTool`、`HostedMCPTool` 和 `CodeInterpreterTool`。函数、自定义、shell 和 apply-patch 工具会直接公开 `allowed_callers`。对于托管MCP和 Code Interpreter,请在 `tool_config` 内设置 `allowed_callers`。 +- 对于 `@function_tool(allowed_callers=[...])`,Pydantic 模型、TypedDict 或 dataclass 等结构化返回注解会自动成为严格的对象输出 schema,并且返回值会在返回给程序之前根据该 schema 进行验证。如果函数没有可用注解,请使用 `output_type=...`;如果你已经有严格的对象 schema,请使用较低层级的 `output_json_schema={...}` 备用机制。`output_type` 与 `output_json_schema` 互斥。`str`、`Any` 或 `None` 的返回注解不会创建输出 schema。对于由程序发起且由 schema 支持的调用,默认失败格式化器会被禁用,因为其自由格式文本不符合输出 schema。因此,处理程序异常会继续向上传播,除非你提供一个返回符合 schema 的 JSON 的自定义 `failure_error_function`。 +- 由程序发起的 SDK 工具仍使用正常的 Runner 生命周期。工具输入和输出安全防护措施、钩子、超时、并发限制、审批、会话以及 `RunState` 暂停/恢复行为仍然适用,并且 SDK 会保留每个子调用与程序调用方之间的关系。 +- 只要存在 `ProgrammaticToolCallingTool()`,模型请求重试就会使用更严格的重放安全边界,即使程序尚未执行也是如此。SDK 会对这些请求禁用由提供商管理的重试和 WebSocket 事件前重试。仅当提供商建议明确将重放标记为安全时,Runner 重试策略才会进行重试;仅设置 `retry_policies.network_error()` 不会覆盖此边界。 +- 对审批敏感或影响较大的工具通常更适合保留为直接调用,以便人员在每个操作成为更大程序的一部分之前对其进行审查。如果由程序发起的调用因等待审批而暂停,请通过 `RunState` 解决中断,然后照常恢复原始运行。 +- 程序化工具调用可与[托管工具搜索](#hosted-tool-search)结合使用。模型必须先加载延迟工具,生成的程序才能调用它们。 +- `program` 条目及其常规的程序发起型子工具调用会显示为 [`ToolCallItem`][agents.items.ToolCallItem] 条目。对应的 `program_output` 会显示为 [`ToolCallOutputItem`][agents.items.ToolCallOutputItem]。托管MCP审批请求和工具目录则使用专用的MCP条目和流事件。有关检查详情,请参阅[结果](results.md#new-items)和[流式传输](streaming.md#run-item-event-names)。 - 有关完整的并发库存规划代码示例,请参阅 `examples/tools/programmatic_tool_calling.py`。 -- 官方平台指南:[编程式工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 +- 官方平台指南:[程序化工具调用](https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling)。 ### 托管容器 shell 与技能 {#hosted-container-shell-skills} -`ShellTool` 还支持由OpenAI托管的容器执行。当你希望模型在托管容器中运行 shell 命令,而不是在本地运行时中运行时,请使用此模式。 +`ShellTool` 还支持由OpenAI托管的容器执行。当你希望模型在托管容器中运行 shell 命令,而不是在本地运行时中执行时,请使用此模式。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -219,50 +219,50 @@ print(result.final_output) 注意事项: -- 托管 shell 可通过 Responses API 的 shell 工具使用。 -- `container_auto` 为请求配置一个容器;`container_reference` 复用现有容器。 -- `container_auto` 还可以包括 `file_ids` 和 `memory_limit`。 +- 托管 shell 可通过 Responses API shell 工具使用。 +- `container_auto` 会为请求配置一个容器;`container_reference` 会复用现有容器。 +- `container_auto` 还可以包含 `file_ids` 和 `memory_limit`。 - `environment.skills` 接受技能引用和内联技能包。 -- 对于托管环境,请勿在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 +- 使用托管环境时,不要在 `ShellTool` 上设置 `executor`、`needs_approval` 或 `on_approval`。 - `network_policy` 支持 `disabled` 和 `allowlist` 模式。 - 在允许列表模式下,`network_policy.domain_secrets` 可以按名称注入限定于域的密钥。 - 有关完整代码示例,请参阅 `examples/tools/container_shell_skill_reference.py` 和 `examples/tools/container_shell_inline_skill.py`。 -- OpenAI 平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 +- OpenAI平台指南:[Shell](https://platform.openai.com/docs/guides/tools-shell)和[技能](https://platform.openai.com/docs/guides/tools-skills)。 ## 本地运行时工具 {#local-runtime-tools} -本地运行时工具在模型响应本身之外执行。模型仍会决定何时调用它们,但实际工作由你的应用程序或已配置的执行环境完成。 +本地运行时工具在模型响应之外执行。模型仍会决定何时调用它们,但实际工作由你的应用或已配置的执行环境完成。 -`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 涵盖两种模式:如果需要托管执行,请使用上面的托管容器配置;如果希望命令在自己的进程中运行,请使用下面的本地运行时配置。 +`ComputerTool` 和 `ApplyPatchTool` 始终需要由你提供本地实现。`ShellTool` 涵盖两种模式:需要托管执行时,请使用上面的托管容器配置;需要在自己的进程中运行命令时,请使用下面的本地运行时配置。 本地运行时工具要求你提供实现: - [`ComputerTool`][agents.tool.ComputerTool]:实现 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 接口,以启用 GUI/浏览器自动化。 - [`ShellTool`][agents.tool.ShellTool]:同时用于本地执行和托管容器执行的最新 shell 工具。 - [`LocalShellTool`][agents.tool.LocalShellTool]:旧版本地 shell 集成。 -- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor],以便在本地应用差异。 -- 本地 shell 技能可通过 `ShellTool(environment={"type": "local", "skills": [...]})` 使用。 +- [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:实现 [`ApplyPatchEditor`][agents.editor.ApplyPatchEditor],以在本地应用差异。 +- 通过 `ShellTool(environment={"type": "local", "skills": [...]})` 可以使用本地 shell 技能。 -对于 shell 操作超时,使用正整数毫秒值表示有限超时。在调用本地 `ShellTool` 执行器之前,SDK 会将 `0` 和 `None` 都视为未显式设置超时,因为零在不同执行器实现中没有可移植的统一含义;其他值会在调用执行器之前被拒绝。这仅适用于超时字段:`max_output_length=0` 仍是受支持的空捕获输出请求。 +Shell 操作超时使用正整数毫秒值表示有限超时。在调用本地 `ShellTool` 执行器之前,SDK 会将 `0` 和 `None` 都视为未明确设置超时,因为零在不同执行器实现中没有可移植的统一含义;其他值会在调用执行器之前被拒绝。此行为仅适用于超时字段:`max_output_length=0` 仍是受支持的空捕获输出请求。 -### ComputerTool 与 Responses 计算机工具 {#computertool-and-the-responses-computer-tool} +### ComputerTool 与 Responses 计算机操作工具 {#computertool-and-the-responses-computer-tool} -`ComputerTool` 仍是本地工具框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该框架映射到 OpenAI Responses API 的计算机操作界面。 +`ComputerTool` 仍是一个本地框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该框架映射到 OpenAI Responses API 的计算机操作接口。 -对于显式的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送正式发布版内置工具载荷 `{"type": "computer"}`。对于发往旧版 `computer-use-preview` 模型的请求,SDK 会继续发送预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI 的[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中所述的平台迁移一致: +对于明确的 [`gpt-5.5`](https://developers.openai.com/api/docs/models/gpt-5.5) 请求,SDK 会发送 GA 内置工具载荷 `{"type": "computer"}`。对于较旧的 `computer-use-preview` 模型请求,SDK 会继续发送预览版载荷 `{"type": "computer_use_preview", "environment": ..., "display_width": ..., "display_height": ...}`。这与 OpenAI的[计算机操作指南](https://developers.openai.com/api/docs/guides/tools-computer-use/)中所述的平台迁移一致: - 模型:`computer-use-preview` -> `gpt-5.5` - 工具选择器:`computer_use_preview` -> `computer` -- 计算机调用结构:每个 `computer_call` 对应一个 `action` -> `computer_call` 上的批量 `actions[]` -- 截断:预览版路径要求使用 `ModelSettings(truncation="auto")` -> 正式发布版路径不要求 +- 计算机调用结构:每个 `computer_call` 对应一个 `action` -> `computer_call` 上批量处理的 `actions[]` +- 截断:预览版路径要求使用 `ModelSettings(truncation="auto")` -> GA 路径不要求使用 -SDK 根据实际 Responses 请求中的有效模型选择该线路结构。如果你使用提示词模板,并且由于提示词本身指定模型而使请求省略 `model`,SDK 会继续使用兼容预览版的计算机载荷,除非你显式保留 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制选择正式发布版选择器。 +SDK 会根据实际 Responses 请求中的有效模型选择该传输格式。如果你使用提示词模板,并且由于模型由提示词指定,请求省略了 `model`,那么 SDK 会继续使用兼容预览版的计算机操作载荷,除非你明确保留 `model="gpt-5.5"`,或使用 `ModelSettings(tool_choice="computer")` 或 `ModelSettings(tool_choice="computer_use")` 强制指定 GA 选择器。 -存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 都会被接受,并规范化为与有效请求模型匹配的内置选择器。如果没有 `ComputerTool`,这些字符串仍会作为普通函数名称处理。 +存在 [`ComputerTool`][agents.tool.ComputerTool] 时,`tool_choice="computer"`、`"computer_use"` 和 `"computer_use_preview"` 均会被接受,并规范化为与有效请求模型匹配的内置选择器。如果没有 `ComputerTool`,这些字符串仍会像普通函数名称一样工作。 -当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂支持时,这一区别非常重要。正式发布版 `computer` 载荷在序列化时不需要 `environment` 或尺寸信息,因此可以在工厂生成 `Computer` 或 `AsyncComputer` 实例之前完成序列化。兼容预览版的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 发送 `environment`、`display_width` 和 `display_height`。 +当 `ComputerTool` 由 [`ComputerProvider`][agents.tool.ComputerProvider] 工厂提供支持时,这一区别很重要。GA `computer` 载荷在序列化时不需要 `environment` 或尺寸信息,因此可以在工厂生成 `Computer` 或 `AsyncComputer` 实例之前完成序列化。兼容预览版的序列化仍需要已解析的 `Computer` 或 `AsyncComputer` 实例,以便 SDK 发送 `environment`、`display_width` 和 `display_height`。 -在运行时,两条路径仍使用相同的本地工具框架。预览版响应会发出包含单个 `action` 的 `computer_call` 项目;`gpt-5.5` 可以发出批量 `actions[]`,SDK 会按顺序执行它们,然后生成 `computer_call_output` 截图项目。有关基于 Playwright 的可运行工具框架,请参阅 `examples/tools/computer_use.py`。 +在运行时,两条路径仍使用同一个本地框架。预览版响应会发出带有单个 `action` 的 `computer_call` 条目;`gpt-5.5` 可以发出批量的 `actions[]`,SDK 会按顺序执行这些操作,然后生成 `computer_call_output` 截图条目。有关基于 Playwright 的可运行框架,请参阅 `examples/tools/computer_use.py`。 ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -308,16 +308,16 @@ agent = Agent( 你可以将任意 Python 函数用作工具。Agents SDK 会自动设置该工具: -- 工具名称将采用 Python 函数的名称(也可以自行提供名称) -- 工具描述将取自函数的文档字符串(也可以自行提供描述) -- 函数输入的架构会根据函数参数自动创建 -- 除非禁用,否则每个输入的描述都取自函数的文档字符串 +- 工具名称将是 Python 函数的名称(你也可以提供名称) +- 工具描述将从函数的 docstring 中获取(你也可以提供描述) +- 函数输入的 schema 会根据函数参数自动创建 +- 除非禁用,否则每个输入的描述都取自函数的 docstring -由 `@tool` 创建的工具通过只读 `__wrapped__` 属性公开原始 Python 可调用对象。这对于检查和测试很有用,但直接调用它会绕过工具运行时管线,包括架构验证、上下文注入、安全防护措施、超时、失败处理和追踪。手动构建的 `FunctionTool` 实例不公开 `__wrapped__`。 +由 `@tool` 创建的工具通过只读 `__wrapped__` 属性公开原始 Python 可调用对象。这对于检查和测试非常有用,但直接调用它会绕过工具运行时管线,包括 schema 验证、上下文注入、安全防护措施、超时、失败处理和追踪。手动构建的 `FunctionTool` 实例不会公开 `__wrapped__`。 -我们使用 Python 的 `inspect` 模块提取函数签名,同时使用 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析文档字符串,并使用 `pydantic` 创建架构。 +我们使用 Python 的 `inspect` 模块提取函数签名,同时使用 [`griffe`](https://mkdocstrings.github.io/griffe/) 解析 docstring,并使用 `pydantic` 创建 schema。 -使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏函数工具,直到 `ToolSearchTool()` 加载它。你还可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关函数工具进行分组。有关完整设置和约束,请参阅[托管工具搜索](#hosted-tool-search)。 +使用 OpenAI Responses 模型时,`@function_tool(defer_loading=True)` 会隐藏函数工具,直到 `ToolSearchTool()` 加载它。你还可以使用 [`tool_namespace()`][agents.tool.tool_namespace] 对相关函数工具进行分组。有关完整设置和限制,请参阅[托管工具搜索](#hosted-tool-search)。 ```python import json @@ -370,12 +370,12 @@ for tool in agent.tools: ``` -1. 函数参数可以使用任意 Python 类型,并且函数可以是同步或异步函数。 -2. 如果存在文档字符串,则会用它来获取描述和参数描述 -3. 函数可以选择将运行上下文作为第一个参数。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 -4. 你可以将经过装饰的函数传入工具列表。 +1. 你可以使用任意 Python 类型作为函数参数,函数可以是同步函数或异步函数。 +2. 如果存在 docstring,则会用它获取描述和参数描述 +3. 函数可以选择将运行上下文作为第一个参数。你还可以设置覆盖项,例如工具名称、描述、要使用的 docstring 样式等。 +4. 你可以将已装饰的函数传入工具列表。 -??? note "展开以查看输出" +??? note "展开查看输出" ``` fetch_weather @@ -445,13 +445,13 @@ for tool in agent.tools: } ``` -### 函数工具的图像或文件返回 {#returning-images-or-files-from-function-tools} +### 函数工具返回的图像或文件 {#returning-images-or-files-from-function-tools} -除了返回文本输出之外,你还可以返回一个或多个图像或文件作为函数工具的输出。为此,可以返回以下任意内容: +除了返回文本输出外,你还可以将一张或多张图像或一个或多个文件作为函数工具的输出返回。为此,你可以返回以下任意内容: - 图像:[`ToolOutputImage`][agents.tool.ToolOutputImage](或 TypedDict 版本 [`ToolOutputImageDict`][agents.tool.ToolOutputImageDict]) - 文件:[`ToolOutputFileContent`][agents.tool.ToolOutputFileContent](或 TypedDict 版本 [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) -- 文本:字符串、可转换为字符串的对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +- 文本:字符串、可字符串化对象,或 [`ToolOutputText`][agents.tool.ToolOutputText](或 TypedDict 版本 [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) ### 自定义函数工具 {#custom-function-tools} @@ -459,8 +459,8 @@ for tool in agent.tools: - `name` - `description` -- `params_json_schema`,即参数的 JSON 架构 -- `on_invoke_tool`,即一个异步函数,它接收 [`ToolContext`][agents.tool_context.ToolContext] 和 JSON 字符串形式的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 +- `params_json_schema`,即参数的 JSON schema +- `on_invoke_tool`,这是一个异步函数,接收 [`ToolContext`][agents.tool_context.ToolContext] 和作为 JSON 字符串传入的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 ```python from typing import Any @@ -493,18 +493,18 @@ tool = FunctionTool( ) ``` -### 参数和文档字符串的自动解析 {#automatic-argument-and-docstring-parsing} +### 参数与 docstring 的自动解析 {#automatic-argument-and-docstring-parsing} -如前所述,我们会自动解析函数签名以提取工具架构,并解析文档字符串以提取工具和各个参数的描述。相关注意事项如下: +如前所述,我们会自动解析函数签名以提取工具的 schema,并解析 docstring 以提取工具及各个参数的描述。相关注意事项如下: -1. 签名解析通过 `inspect` 模块完成。我们使用类型注解来理解参数类型,并动态构建一个 Pydantic 模型来表示整体架构。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 -2. 我们使用 `griffe` 解析文档字符串。支持的文档字符串格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测文档字符串格式,但这只是尽力而为;你可以在调用 `function_tool` 时显式设置格式。还可以通过将 `use_docstring_info` 设置为 `False` 来禁用文档字符串解析。对于 Google 风格的文档字符串,解析器还接受紧接在摘要文本之后且中间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 部分。 +1. 签名解析通过 `inspect` 模块完成。我们使用类型注解来理解参数类型,并动态构建 Pydantic 模型来表示整体 schema。它支持大多数类型,包括 Python 基本类型、Pydantic 模型、TypedDict 等。 +2. 我们使用 `griffe` 解析 docstring。支持的 docstring 格式包括 `google`、`sphinx` 和 `numpy`。我们会尝试自动检测 docstring 格式,但这只是尽力而为;你可以在调用 `function_tool` 时明确设置格式。还可以将 `use_docstring_info` 设置为 `False`,以禁用 docstring 解析。对于 Google 风格的 docstring,解析器还接受紧接在摘要文本之后、且中间没有空行的 `Args:`、`Arguments:`、`Params:` 或 `Parameters:` 部分。 -架构提取代码位于 [`agents.function_schema`][] 中。 +用于提取 schema 的代码位于 [`agents.function_schema`][] 中。 ### 使用 Pydantic Field 约束和描述参数 {#constraining-and-describing-arguments-with-pydantic-field} -你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值、字符串的长度或模式)和描述。与 Pydantic 一样,两种形式都受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON 架构和验证会包含这些约束。 +你可以使用 Pydantic 的 [`Field`](https://docs.pydantic.dev/latest/concepts/fields/) 为工具参数添加约束(例如数字的最小值/最大值,或字符串的长度与模式)和描述。与 Pydantic 一样,两种形式都受支持:基于默认值的形式(`arg: int = Field(..., ge=1)`)和 `Annotated`(`arg: Annotated[int, Field(..., ge=1)]`)。生成的 JSON schema 和验证均会包含这些约束。 ```python from typing import Annotated @@ -545,13 +545,13 @@ agent = Agent( ) ``` -达到超时时间时,默认行为是 `timeout_behavior="error_as_result"`,它会发送一条模型可见的超时消息(例如 `Tool 'slow_lookup' timed out after 2 seconds.`)。 +达到超时时间后,默认行为是 `timeout_behavior="error_as_result"`,它会发送一条模型可见的超时消息(例如 `Tool 'slow_lookup' timed out after 2 seconds.`)。 -你可以控制超时处理方式: +你可以控制超时处理: -- `timeout_behavior="error_as_result"`(默认):向模型返回超时消息,以便模型进行恢复。 +- `timeout_behavior="error_as_result"`(默认):向模型返回超时消息,使其能够恢复。 - `timeout_behavior="raise_exception"`:抛出 [`ToolTimeoutError`][agents.exceptions.ToolTimeoutError] 并使运行失败。 -- `timeout_error_function=...`:使用 `error_as_result` 时自定义超时消息。 +- `timeout_error_function=...`:使用 `error_as_result` 时,自定义超时消息。 ```python import asyncio @@ -577,13 +577,13 @@ except ToolTimeoutError as e: 超时配置仅支持异步 `@function_tool` 处理程序。 -### 函数工具错误处理 {#handling-errors-in-function-tools} +### 函数工具中的错误处理 {#handling-errors-in-function-tools} -通过 `@function_tool` 创建函数工具时,可以传入 `failure_error_function`。这是一个在工具调用崩溃时向 LLM 提供错误响应的函数。 +通过 `@function_tool` 创建函数工具时,你可以传入 `failure_error_function`。这是一个函数,用于在工具调用崩溃时向 LLM 提供错误响应。 -- 默认情况下(即未传入任何内容),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 +- 默认情况下(即未传入任何内容时),它会运行 `default_tool_error_function`,告知 LLM 发生了错误。 - 如果传入自己的错误函数,则会改为运行该函数,并将响应发送给 LLM。 -- 如果显式传入 `None`,则会重新抛出所有工具调用错误,由你进行处理。例如,如果模型生成了无效 JSON,可能会抛出 `ModelBehaviorError`;如果你的代码崩溃,可能会抛出 `UserError`,等等。 +- 如果明确传入 `None`,则会重新抛出所有工具调用错误,由你处理。如果模型生成了无效 JSON,这可能是 `ModelBehaviorError`;如果你的代码崩溃,这可能是 `UserError`;等等。 ```python from agents import RunContextWrapper @@ -607,11 +607,11 @@ def get_user_profile(user_id: str) -> str: ``` -如果手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内部处理错误。 +如果你手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数中处理错误。 ## Agents as tools {#agents-as-tools} -在某些工作流中,你可能希望由一个中央智能体编排由多个专业智能体组成的网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 +在某些工作流中,你可能希望由一个中央智能体编排由多个专用智能体组成的网络,而不是进行控制权的任务转移。为此,你可以将智能体建模为工具。 ```python import asyncio @@ -655,11 +655,11 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 工具智能体自定义 {#customizing-tool-agents} +### 工具智能体的自定义 {#customizing-tool-agents} -`agent.as_tool` 是一种将智能体转换为工具的便捷方法。它支持常见的运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还通过 `parameters`、`input_builder` 和 `include_input_schema` 支持结构化输入。 +`agent.as_tool` 是一种将智能体转换为工具的便捷方法。它支持常见的运行时选项,例如 `max_turns`、`run_config`、`hooks`、`previous_response_id`、`conversation_id`、`session` 和 `needs_approval`。它还支持通过 `parameters`、`input_builder` 和 `include_input_schema` 使用结构化输入。 -状态选项用于配置工具调用启动的嵌套智能体运行;父运行的对话状态不会自动继承。若要在父运行和嵌套运行之间共享由客户端管理的历史记录,请显式将同一个 `session` 传给两者。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:由客户端管理的 `session`,或者通过 `previous_response_id` 或 `conversation_id` 进行由服务器管理的延续。 +状态选项用于配置由工具调用启动的嵌套智能体运行;父级运行的对话状态不会自动继承。若要在父级运行与嵌套运行之间共享由客户端管理的历史记录,请明确向两者传入相同的 `session`。与 `Runner.run` 一样,请为嵌套运行选择一种状态策略:由客户端管理的 `session`,或通过 `previous_response_id` 或 `conversation_id` 进行由服务器管理的延续。 ```python from agents.decorators import tool @@ -683,13 +683,13 @@ async def run_my_agent() -> str: ### 工具智能体的结构化输入 {#structured-input-for-tool-agents} -默认情况下,`Agent.as_tool()` 预期接收一个包含单个字符串字段 `input`(`{"input": "..."}`)的对象,但你可以通过传入 `parameters`(Pydantic 模型类型或 dataclass 类型)公开结构化架构。 +默认情况下,`Agent.as_tool()` 需要一个包含字符串字段 `input`(`{"input": "..."}`)的对象,但你可以通过传入 `parameters`(Pydantic 模型类型或 dataclass 类型)来公开结构化 schema。 其他选项: -- `include_input_schema=True` 在生成的嵌套输入中包含完整 JSON Schema。 -- `input_builder=...` 允许你完全自定义如何将结构化工具参数转换为嵌套智能体输入。 -- `RunContextWrapper.tool_input` 在嵌套运行上下文中包含已解析的结构化载荷。 +- `include_input_schema=True` 在生成的嵌套输入中包含完整的 JSON Schema。 +- `input_builder=...` 让你能够完全自定义如何将结构化工具参数转换为嵌套智能体输入。 +- `RunContextWrapper.tool_input` 包含嵌套运行上下文中已解析的结构化载荷。 ```python from pydantic import BaseModel, Field @@ -713,17 +713,17 @@ translator_tool = translator_agent.as_tool( ### 工具智能体的审批门控 {#approval-gates-for-tool-agents} -`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理项目将出现在 `result.interruptions` 中;随后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复运行。有关完整的暂停/恢复模式,请参阅[人在回路指南](human_in_the_loop.md)。 +`Agent.as_tool(..., needs_approval=...)` 使用与 `function_tool` 相同的审批流程。如果需要审批,运行会暂停,待处理条目会出现在 `result.interruptions` 中;随后使用 `result.to_state()`,并在调用 `state.approve(...)` 或 `state.reject(...)` 后恢复。有关完整的暂停/恢复模式,请参阅[人工介入指南](human_in_the_loop.md)。 ### 自定义输出提取 {#custom-output-extraction} -在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中央智能体。这在以下场景中可能很有用: +在某些情况下,你可能希望先修改工具智能体的输出,再将其返回给中央智能体。以下情形可能适合这样做: -- 从子智能体的聊天历史中提取特定信息(例如 JSON 载荷)。 -- 转换或重新格式化智能体的最终答案(例如将 Markdown 转换为纯文本或 CSV)。 -- 验证输出,或在智能体的响应缺失或格式错误时提供回退值。 +- 从子智能体的聊天历史记录中提取特定信息(例如 JSON 载荷)。 +- 转换或重新格式化智能体的最终答案(例如,将 Markdown 转换为纯文本或 CSV)。 +- 验证输出,或在智能体响应缺失或格式错误时提供回退值。 -你可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现此目的: +为此,你可以向 `as_tool` 方法提供 `custom_output_extractor` 参数: ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -742,11 +742,11 @@ json_tool = data_agent.as_tool( ) ``` -在自定义提取器中,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在后处理嵌套结果时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 +在自定义提取器中,嵌套的 [`RunResult`][agents.result.RunResult] 还会公开 [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]。当你需要在对嵌套结果进行后处理时获取外层工具名称、调用 ID 或原始参数,这会很有用。请参阅[结果指南](results.md#agent-as-tool-metadata)。 ### 嵌套智能体运行的流式传输 {#streaming-nested-agent-runs} -将 `on_stream` 回调传给 `as_tool`,即可监听嵌套智能体发出的流式事件,同时仍会在流完成后返回其最终输出。 +向 `as_tool` 传入 `on_stream` 回调,以监听嵌套智能体发出的流式事件,同时在流结束后仍返回其最终输出。 ```python from agents import AgentToolStreamEvent @@ -767,12 +767,12 @@ billing_agent_tool = billing_agent.as_tool( 预期行为: - 事件类型与 `StreamEvent["type"]` 一致:`raw_response_event`、`run_item_stream_event`、`agent_updated_stream_event`。 -- 提供 `on_stream` 会自动以流式模式运行嵌套智能体,并在返回最终输出前耗尽流。 +- 提供 `on_stream` 会自动以流式传输模式运行嵌套智能体,并在返回最终输出前耗尽该流。 - 处理程序可以是同步或异步的;每个事件都会按到达顺序传递。 -- 通过模型工具调用来调用该工具时,`tool_call` 会存在;直接调用时,其值可能为 `None`。 +- 通过模型工具调用来调用工具时,会存在 `tool_call`;直接调用可能会使其保持为 `None`。 - 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 -### 条件式工具启用 {#conditional-tool-enabling} +### 工具的条件启用 {#conditional-tool-enabling} 你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选对 LLM 可用的工具。 @@ -832,21 +832,25 @@ asyncio.run(main()) `is_enabled` 参数接受: - **布尔值**:`True`(始终启用)或 `False`(始终禁用) -- **可调用函数**:接收 `(context, agent)` 并返回布尔值的函数 +- **可调用函数**:接受 `(context, agent)` 并返回布尔值的函数 - **异步函数**:用于复杂条件逻辑的异步函数 -禁用的工具会在运行时对 LLM 完全隐藏,因此适用于: +禁用的工具在运行时对 LLM 完全隐藏,因此适用于: -- 根据用户权限设置功能门控 +- 请求范围内的能力可见性 - 特定于环境的工具可用性(开发环境与生产环境) - 对不同工具配置进行 A/B 测试 - 根据运行时状态动态筛选工具 +对于本地配置的函数工具,Runner 还会在调用前重新评估 `is_enabled`。但是,`is_enabled` 控制可见性和分派;它无法替代取决于工具参数或所访问资源的授权。请在工具实现内部执行这些检查,或在适当情况下使用[工具输入安全防护措施](guardrails.md#tool-guardrails)和[审批](human_in_the_loop.md)。MCP服务器必须自行对其受保护操作进行授权。 + +有关对函数工具、MCP工具和任务转移应用统一应用策略的模式,请参阅[上下文管理](context.md#use-local-context-for-capability-visibility)。 + ## 实验性 Codex 工具 {#experimental-codex-tool} -`codex_tool` 封装了 Codex CLI,使智能体可以在工具调用期间运行限定于工作区的任务(shell、文件编辑、MCP 工具)。此功能目前处于实验阶段,可能会发生变化。 +`codex_tool` 封装了 Codex CLI,使智能体能够在工具调用期间运行限定于工作区的任务(shell、文件编辑、MCP工具)。此接口属于实验性功能,可能会发生变化。 -当你希望主智能体将范围明确的工作区任务委托给 Codex,同时不离开当前运行时,请使用它。默认工具名称是 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当一个智能体包含多个 Codex 工具时,每个工具都必须使用唯一名称。 +当你希望主智能体在不离开当前运行的情况下,将限定范围的工作区任务委派给 Codex 时,可以使用它。默认工具名称为 `codex`。如果设置自定义名称,该名称必须是 `codex` 或以 `codex_` 开头。当一个智能体包含多个 Codex 工具时,每个工具都必须使用唯一名称。 ```python from agents import Agent @@ -875,29 +879,29 @@ agent = Agent( ) ``` -请从以下选项组开始: +可从以下选项组开始: -- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以进行操作的位置。请将两者配合使用;当工作目录不在 Git 仓库内时,请设置 `skip_git_repo_check=True`。 -- 线程默认值:`default_thread_options=ThreadOptions(...)` 配置模型、推理强度、审批策略、其他目录、网络访问和网络检索模式。优先使用 `web_search_mode`,而不是旧版 `web_search_enabled`。 -- 轮次默认值:`default_turn_options=TurnOptions(...)` 配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消 `signal`。 -- 工具输入/输出:工具调用必须至少包含一个带有 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }` 的 `inputs` 项目。`output_schema` 允许你要求 Codex 返回结构化响应。 +- 执行范围:`sandbox_mode` 和 `working_directory` 定义 Codex 可以操作的位置。请同时配置两者;当工作目录不在 Git 仓库中时,请设置 `skip_git_repo_check=True`。 +- 线程默认值:`default_thread_options=ThreadOptions(...)` 配置模型、推理强度、审批策略、附加目录、网络访问和网络检索模式。优先使用 `web_search_mode`,而不是旧版 `web_search_enabled`。 +- 轮次默认值:`default_turn_options=TurnOptions(...)` 配置每轮行为,例如 `idle_timeout_seconds` 和可选的取消设置 `signal`。 +- 工具 I/O:工具调用必须包含至少一个带有 `{ "type": "text", "text": ... }` 或 `{ "type": "local_image", "path": ... }` 的 `inputs` 条目。`output_schema` 让你能够要求 Codex 返回结构化响应。 -线程复用和持久化是两个独立的控制项: +线程复用与持久化是两项独立控制: -- `persist_session=True` 为对同一工具实例的重复调用复用同一个 Codex 线程。 -- `use_run_context_thread_id=True` 在共享同一可变上下文对象的多次运行之间,将线程 ID 存储在运行上下文中并进行复用。 -- 线程 ID 的优先级为:每次调用的 `thread_id`,其次是运行上下文中的线程 ID(如果启用),最后是已配置的 `thread_id` 选项。 -- `name="codex"` 的默认运行上下文键是 `codex_thread_id`,`name="codex_"` 的默认运行上下文键是 `codex_thread_id_`。可使用 `run_context_thread_id_key` 覆盖它。 +- `persist_session=True` 会复用一个 Codex 线程,以便重复调用同一个工具实例。 +- `use_run_context_thread_id=True` 会在运行上下文中存储并复用线程 ID,适用于共享同一个可变上下文对象的多个运行。 +- 线程 ID 的优先顺序为:单次调用的 `thread_id`,然后是运行上下文线程 ID(如果已启用),最后是配置的 `thread_id` 选项。 +- `name="codex"` 的默认运行上下文键为 `codex_thread_id`,`name="codex_"` 的默认运行上下文键为 `codex_thread_id_`。可使用 `run_context_thread_id_key` 覆盖它。 运行时配置: -- 身份验证:设置 `CODEX_API_KEY`(首选)或 `OPENAI_API_KEY`,或者传入 `codex_options={"api_key": "..."}`。 -- 运行时:`codex_options.base_url` 覆盖 CLI 基础 URL。 -- 二进制文件解析:设置 `codex_options.codex_path_override`(或 `CODEX_PATH`)以固定 CLI 路径。否则,SDK 会先从 `PATH` 解析 `codex`,然后回退到随附的供应商二进制文件。 -- 环境:`codex_options.env` 完全控制子进程环境。提供该选项时,子进程不会继承 `os.environ`。 -- 流限制:`codex_options.codex_subprocess_stream_limit_bytes`(或 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)控制 stdout/stderr 读取器限制。有效范围为 `65536` 到 `67108864`;默认值为 `8388608`。 -- 流式传输:`on_stream` 接收线程/轮次生命周期事件和项目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 以及 `error` 项目更新)。 -- 输出:结果包括 `response`、`usage` 和 `thread_id`;用量会添加到 `RunContextWrapper.usage`。 +- 身份验证:设置 `CODEX_API_KEY`(推荐)或 `OPENAI_API_KEY`,也可以传入 `codex_options={"api_key": "..."}`。 +- 运行时:`codex_options.base_url` 会覆盖 CLI 基础 URL。 +- 二进制文件解析:设置 `codex_options.codex_path_override`(或 `CODEX_PATH`)以固定 CLI 路径。否则,SDK 会先从 `PATH` 中解析 `codex`,然后回退到捆绑的供应商二进制文件。 +- 环境:`codex_options.env` 完全控制子进程环境。提供该选项后,子进程不会继承 `os.environ`。 +- 流限制:`codex_options.codex_subprocess_stream_limit_bytes`(或 `OPENAI_AGENTS_CODEX_SUBPROCESS_STREAM_LIMIT_BYTES`)控制 stdout/stderr 读取器限制。有效范围为 `65536` 至 `67108864`;默认值为 `8388608`。 +- 流式传输:`on_stream` 接收线程/轮次生命周期事件和条目事件(`reasoning`、`command_execution`、`mcp_tool_call`、`file_change`、`web_search`、`todo_list` 以及 `error` 条目更新)。 +- 输出:结果包含 `response`、`usage` 和 `thread_id`;用量会添加到 `RunContextWrapper.usage`。 参考资料: diff --git a/docs/zh/tracing.md b/docs/zh/tracing.md index 1a6fc3603f..b274b28a42 100644 --- a/docs/zh/tracing.md +++ b/docs/zh/tracing.md @@ -4,51 +4,51 @@ search: --- # 追踪 -Agents SDK 内置了追踪功能,可收集智能体运行期间的完整事件记录:LLM 生成、工具调用、任务转移、安全防护措施,甚至包括发生的自定义事件。借助[追踪仪表板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化和监控工作流。 +Agents SDK内置追踪功能,可收集智能体运行期间各类事件的完整记录:LLM 生成、工具调用、任务转移、安全防护措施,甚至包括发生的自定义事件。通过[追踪仪表板](https://platform.openai.com/traces),你可以在开发和生产环境中调试、可视化并监控工作流。 !!!note - 追踪默认启用。你可以通过以下三种常用方式将其禁用: + 追踪功能默认启用。你可以通过以下三种常见方式将其禁用: - 1. 设置环境变量 `OPENAI_AGENTS_DISABLE_TRACING=1`,全局禁用追踪 - 2. 在代码中使用 [`set_tracing_disabled(True)`][agents.set_tracing_disabled],全局禁用追踪 + 1. 设置环境变量 `OPENAI_AGENTS_DISABLE_TRACING=1`,在全局范围内禁用追踪 + 2. 在代码中使用 [`set_tracing_disabled(True)`][agents.set_tracing_disabled],在全局范围内禁用追踪 3. 将 [`agents.run.RunConfig.tracing_disabled`][] 设置为 `True`,为单次运行禁用追踪 -***对于根据零数据保留(ZDR)政策使用OpenAI API 的组织,追踪功能不可用。*** +***对于依据零数据保留(Zero Data Retention,ZDR)政策使用OpenAI API 的组织,追踪功能不可用。*** -## 追踪和跨度 {#traces-and-spans} +## 追踪记录与跨度 {#traces-and-spans} -- **追踪**表示一次“工作流”的端到端操作。它们由跨度组成。追踪具有以下属性: - - `workflow_name`:逻辑工作流或应用的名称。例如“代码生成”或“客户服务”。 - - `trace_id`:追踪的唯一 ID。如果未传入,则会自动生成。格式必须为 `trace_<32_alphanumeric>`。 - - `group_id`:可选的组 ID,用于关联同一对话中的多个追踪。例如,你可以使用聊天会话 ID。 +- **追踪记录**表示一次“工作流”的端到端操作。它们由跨度组成。追踪记录具有以下属性: + - `workflow_name`:逻辑工作流或应用的名称。例如,“代码生成”或“客户服务”。 + - `trace_id`:追踪记录的唯一 ID。如果未传入,则会自动生成。必须采用 `trace_<32_alphanumeric>` 格式。 + - `group_id`:可选的组 ID,用于关联来自同一对话的多条追踪记录。例如,你可以使用聊天线程 ID。 - `disabled`:如果为 True,则不会记录该追踪。 - - `metadata`:追踪的可选元数据。 + - `metadata`:追踪记录的可选元数据。 - **跨度**表示具有开始和结束时间的操作。跨度包含: - `started_at` 和 `ended_at` 时间戳。 - - `trace_id`,表示它们所属的追踪 + - `trace_id`,表示它们所属的追踪记录 - `parent_id`,指向此跨度的父跨度(如果有) - - `span_data`,即有关跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM 生成的信息,依此类推。 + - `span_data`,即有关该跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM 生成的信息,依此类推。 ## 默认追踪 {#default-tracing} 默认情况下,SDK 会追踪以下内容: -- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在 `trace()` 中。 -- 每次运行器调用都封装在 `task_span()` 中。 -- 每个模型轮次都封装在 `turn_span()` 中。 -- 智能体每次运行时,都会封装在 `agent_span()` 中 +- 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在一个 `trace()` 中。 +- 每次运行器调用都封装在一个 `task_span()` 中。 +- 每轮模型交互都封装在一个 `turn_span()` 中。 +- 每次智能体运行时,都会封装在 `agent_span()` 中 - LLM 生成封装在 `generation_span()` 中 - 每次函数工具调用都封装在 `function_span()` 中 - 安全防护措施封装在 `guardrail_span()` 中 - 任务转移封装在 `handoff_span()` 中 -- 音频输入(语音转文本)封装在 `transcription_span()` 中 -- 音频输出(文本转语音)封装在 `speech_span()` 中 -- SDK 可能会将相关的音频跨度置于 `speech_group_span()` 下 +- 音频输入(语音转文本)封装在一个 `transcription_span()` 中 +- 音频输出(文本转语音)封装在一个 `speech_span()` 中 +- SDK 可能会将相关的音频跨度置于一个 `speech_group_span()` 之下 -默认情况下,追踪名称是字面字符串 `Agent workflow`。如果使用 `trace`,你可以设置此名称;也可以通过 [`RunConfig`][agents.run.RunConfig] 配置名称和其他属性。 +默认情况下,追踪名称为字面字符串 `Agent workflow`。使用 `trace` 时可以设置此名称,也可以通过 [`RunConfig`][agents.run.RunConfig] 配置名称及其他属性。 -如果希望层次结构更紧凑,可以为某次运行禁用自动创建的任务跨度和轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 +如果需要更紧凑的层级结构,可以为一次运行禁用自动任务跨度和交互轮次跨度。智能体、生成、函数、安全防护措施、任务转移和自定义跨度仍会被记录。 ```python from agents import RunConfig, Runner @@ -60,13 +60,13 @@ result = await Runner.run( ) ``` -此外,你还可以设置[自定义追踪处理器](#custom-tracing-processors),将追踪发送到其他目标位置(作为替代目标或辅助目标)。 +此外,你还可以设置[自定义追踪处理器](#custom-tracing-processors),将追踪记录推送到其他目标位置(作为替代目标或辅助目标)。 -## 长时运行工作进程和即时导出 {#long-running-workers-and-immediate-exports} +## 长期运行的工作进程与即时导出 {#long-running-workers-and-immediate-exports} -默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出追踪;如果内存队列达到大小阈值,则会更早导出;进程退出时还会执行最终刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长时运行的工作进程,这意味着通常无需任何额外代码即可自动导出追踪,但它们不一定会在每个作业完成后立即显示在追踪仪表板中。 +默认的 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] 每隔几秒在后台导出追踪记录;当内存队列达到其大小触发阈值时,也会提前导出;进程退出时还会执行最终刷新。对于 Celery、RQ、Dramatiq 或 FastAPI 后台任务等长期运行的工作进程,这意味着追踪记录通常无需任何额外代码即可自动导出,但每项作业结束后,它们可能不会立即显示在追踪仪表板中。 -如果需要确保在一个工作单元结束时立即完成传送,请在退出追踪上下文后调用 [`flush_traces()`][agents.tracing.flush_traces]。 +如果需要保证在一个工作单元结束时立即交付,请在退出追踪上下文后调用 [`flush_traces()`][agents.tracing.flush_traces]。 ```python from agents import Runner, flush_traces, trace @@ -103,11 +103,11 @@ async def run(prompt: str, background_tasks: BackgroundTasks): return {"status": "queued"} ``` -[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前缓冲的追踪和跨度全部导出,因此请在 `trace()` 关闭后调用它,以免刷新尚未构建完成的追踪。如果可以接受默认的导出延迟,则可以跳过此调用。 +[`flush_traces()`][agents.tracing.flush_traces] 会阻塞,直到当前已缓冲的追踪记录和跨度均已导出,因此请在 `trace()` 关闭后调用它,以免刷新尚未完全构建的追踪记录。如果可以接受默认导出延迟,则可以跳过此调用。 -## 更高层级的追踪 {#higher-level-traces} +## 更高层级的追踪记录 {#higher-level-traces} -有时,你可能希望多次调用 `run()` 时都归入同一个追踪。为此,可以将整个代码封装在 `trace()` 中。 +有时,你可能希望多次调用 `run()`,并让它们成为同一条追踪记录的一部分。为此,可以将整个代码封装在一个 `trace()` 中。 ```python from agents import Agent, Runner, trace @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. 由于两次 `Runner.run` 调用都封装在 `with trace()` 中,因此两次运行会成为同一个整体追踪的一部分,而不是各自创建单独的追踪。 +1. 由于对 `Runner.run` 的两次调用都封装在一个 `with trace()` 中,因此两次运行会成为同一条整体追踪记录的一部分,而不是各自创建一条单独的追踪记录。 -## 追踪的创建 {#creating-traces} +## 追踪记录的创建 {#creating-traces} -你可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪。追踪需要启动和结束。你可以通过以下两种方式完成: +你可以使用 [`trace()`][agents.tracing.trace] 函数创建追踪记录。追踪记录需要启动和结束。你可以通过以下两种方式执行此操作: -1. **推荐**:将追踪用作上下文管理器,即 `with trace(...) as my_trace`。这会在正确的时间自动启动和结束追踪。 -2. 你也可以手动调用 [`trace.start()`][agents.tracing.Trace.start] 和 [`trace.finish()`][agents.tracing.Trace.finish]。 +1. **推荐**:将追踪记录用作上下文管理器,即 `with trace(...) as my_trace`。这样会在正确的时间自动启动和结束追踪。 +2. 也可以手动调用 [`trace.start()`][agents.tracing.Trace.start] 和 [`trace.finish()`][agents.tracing.Trace.finish]。 -当前追踪通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它可自动支持并发。如果手动启动和结束追踪,请将 `mark_as_current` 传给 `start()`,并将 `reset_current` 传给 `finish()`,以更新当前追踪。 +当前追踪记录通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。这意味着它可以自动处理并发。如果手动启动和结束追踪记录,请向 `start()` 传入 `mark_as_current`,并向 `finish()` 传入 `reset_current`,以更新当前追踪记录。 ## 跨度的创建 {#creating-spans} -你可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。通常无需手动创建跨度。你可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 +你可以使用各种 [`*_span()`][agents.tracing.create] 方法创建跨度。通常,无需手动创建跨度。你可以使用 [`custom_span()`][agents.tracing.custom_span] 函数跟踪自定义跨度信息。 -跨度会自动成为当前追踪的一部分,并嵌套在距离最近的当前跨度下;当前跨度通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 +跨度会自动成为当前追踪记录的一部分,并嵌套在最近的当前跨度下;当前跨度通过 Python 的 [`contextvar`](https://docs.python.org/3/library/contextvars.html) 进行跟踪。 ## 敏感数据 {#sensitive-data} 某些跨度可能会捕获潜在的敏感数据。 -`generation_span()` 会存储 LLM 生成的输入和输出,而 `function_span()` 会存储函数调用的输入和输出。这些内容可能包含敏感数据,因此你可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获这些数据。 +`generation_span()` 会存储 LLM 生成的输入/输出,而 `function_span()` 会存储函数调用的输入/输出。这些内容可能包含敏感数据,因此可以通过 [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data] 禁止捕获这些数据。 -同样,默认情况下,音频跨度会包含输入和输出音频的 Base64 编码 PCM 数据。你可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data] 禁止捕获这些音频数据。 +同样,默认情况下,音频跨度包含输入和输出音频的 Base64 编码 PCM 数据。你可以通过配置 [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data],禁止捕获这些音频数据。 -默认情况下,`trace_include_sensitive_data` 为 `True`。你可以在运行应用之前,将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,从而无需编写代码即可设置默认值。 +默认情况下,`trace_include_sensitive_data` 为 `True`。在运行应用之前,可以将 `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` 环境变量导出为 `true/1` 或 `false/0`,无需编写代码即可设置默认值。 ## 自定义追踪处理器 {#custom-tracing-processors} 追踪功能的高层架构如下: -- 初始化时,我们会创建一个全局 [`TraceProvider`][agents.tracing.provider.TraceProvider],用于创建追踪。 -- 我们为 `TraceProvider` 配置一个 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],它会将追踪和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者会将这些跨度和追踪分批导出到OpenAI后端。 +- 初始化时,我们会创建一个全局 [`TraceProvider`][agents.tracing.provider.TraceProvider],负责创建追踪记录。 +- 我们为 `TraceProvider` 配置一个 [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor],它会将追踪记录和跨度分批发送到 [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter],后者会将跨度和追踪记录分批导出到OpenAI后端。 -如需自定义此默认设置,将追踪发送到其他或额外的后端,或者修改导出器行为,你有以下两个选项: +若要自定义此默认设置,将追踪记录发送到其他或额外的后端,或者修改导出器的行为,可以采用以下两种方式: -1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 允许你添加一个**额外的**追踪处理器,在追踪和跨度准备就绪时接收它们。这样,除了将追踪发送到OpenAI后端外,你还可以自行处理它们。 -2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许你使用自己的追踪处理器**替换**默认处理器。这意味着,除非你包含一个可将追踪发送到OpenAI后端的 `TracingProcessor`,否则追踪不会发送到该后端。 +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] 允许你添加一个**额外的**追踪处理器,它会在追踪记录和跨度准备就绪时接收它们。这样,除了将追踪记录发送到OpenAI后端外,你还可以自行处理它们。 +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] 允许你使用自己的追踪处理器**替换**默认处理器。这意味着,除非包含一个执行发送操作的 `TracingProcessor`,否则追踪记录不会发送到OpenAI后端。 ## 非OpenAI模型的追踪 {#tracing-with-non-openai-models} -使用非OpenAI模型时,你可以向追踪导出器提供 OpenAI API 密钥,从而无需禁用追踪,即可在OpenAI追踪仪表板中使用免费追踪功能。有关适配器的选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 +使用非OpenAI模型时,你可以向追踪导出器提供 OpenAI API 密钥,从而在不禁用追踪的情况下,在OpenAI追踪仪表板中启用免费追踪功能。有关适配器选择和设置注意事项,请参阅模型指南中的[第三方适配器](models/index.md#third-party-adapters)部分。 ```python import os @@ -185,7 +185,7 @@ agent = Agent( ) ``` -如果只需为单次运行使用不同的追踪密钥,请通过 `RunConfig` 传入该密钥,而不要更改全局导出器。 +如果仅需为单次运行使用不同的追踪密钥,请通过 `RunConfig` 传入该密钥,而不要更改全局导出器。 ```python from agents import Runner, RunConfig @@ -197,39 +197,39 @@ await Runner.run( ) ``` -## 补充说明 {#additional-notes} -- 可在OpenAI追踪仪表板中查看免费的追踪记录。 +## 附加说明 {#additional-notes} +- 在OpenAI追踪仪表板中查看免费追踪记录。 ## 生态系统集成 {#ecosystem-integrations} -以下社区和供应商集成支持 OpenAI Agents SDK 的追踪 API 接口。 +以下社区和供应商集成支持 OpenAI Agents SDK的追踪 API 接口。 ### 外部追踪处理器列表 {#external-tracing-processors-list} -- [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) -- [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) +- [Weights & Biases](https://docs.wandb.ai/weave/guides/integrations/agents/openai-agents-sdk) +- [Arize Phoenix](https://arize.com/docs/phoenix/integrations/llm-providers/openai/openai-agents-sdk-tracing) - [Future AGI](https://docs.futureagi.com/docs/tracing/auto/openai_agents/) - [MLflow(自托管/OSS)](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) -- [MLflow(Databricks 托管)](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) -- [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) -- [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) +- [MLflow(Databricks 托管)](https://docs.databricks.com/aws/en/mlflow3/genai/tracing/integrations/openai-agent) +- [Braintrust](https://www.braintrust.dev/docs/integrations/agent-frameworks/openai-agents-sdk) +- [Pydantic Logfire](https://pydantic.dev/docs/logfire/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk) -- [Scorecard](https://docs.scorecard.io/docs/documentation/features/tracing#openai-agents-sdk-integration) -- [Respan](https://respan.ai/docs/integrations/tracing/openai-agents-sdk) -- [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/trace_with_openai_agents_sdk) -- [Maxim AI](https://www.getmaxim.ai/docs/observe/integrations/openai-agents-sdk) -- [Comet Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) -- [Langfuse](https://langfuse.com/docs/integrations/openaiagentssdk/openai-agents) +- [Scorecard](https://docs.scorecard.io/features/tracing#agent-frameworks) +- [Respan](https://www.respan.ai/docs/integrations/openai-agents-sdk) +- [LangSmith](https://docs.langchain.com/langsmith/trace-openai) +- [Maxim AI](https://www.getmaxim.ai/docs/sdk/python/integrations/openai/agents-sdk) +- [Comet Opik](https://www.comet.com/docs/opik/integrations/openai_agents) +- [Langfuse](https://langfuse.com/integrations/frameworks/openai-agents) - [Langtrace](https://docs.langtrace.ai/supported-integrations/llm-frameworks/openai-agents-sdk) - [Okahu-Monocle](https://github.com/monocle2ai/monocle) -- [Galileo](https://v2docs.galileo.ai/integrations/openai-agent-integration#openai-agent-integration) +- [Galileo](https://docs.galileo.ai/how-to-guides/third-party-integrations/openai-agent-integration) - [Portkey AI](https://portkey.ai/docs/integrations/agents/openai-agents) -- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk) -- [Agenta](https://docs.agenta.ai/observability/integrations/openai-agents) -- [PostHog](https://posthog.com/docs/llm-analytics/installation/openai-agents) -- [Traccia](https://traccia.ai/docs/integrations/openai-agents) -- [PromptLayer](https://docs.promptlayer.com/features/integrations#openai-agents-sdk) +- [LangDB AI](https://docs.langdb.ai/getting-started/working-with-agent-frameworks/working-with-openai-agents-sdk/) +- [Agenta](https://agenta.ai/docs/observability/integrations/openai-agents) +- [PostHog](https://posthog.com/docs/ai-observability/installation/openai-agents) +- [Traccia](https://traccia.ai/docs/integrations/openai-agents/) +- [PromptLayer](https://docs.promptlayer.com/features/observability/traces/integrations#openai-agents-sdk) - [HoneyHive](https://docs.honeyhive.ai/v2/integrations/openai-agents) - [Asqav](https://www.asqav.com/docs/integrations#openai-agents) - [Datadog](https://docs.datadoghq.com/llm_observability/instrumentation/auto_instrumentation/?tab=python#openai-agents) From 36976b1854534c0f184ed955afd542493abbb5d9 Mon Sep 17 00:00:00 2001 From: Zhiqi Zhang Date: Tue, 25 Aug 2026 11:04:10 +0800 Subject: [PATCH 420/473] fix(realtime): preserve item status on retrieved conversation items (#4598) --- src/agents/realtime/openai_realtime.py | 2 +- tests/realtime/test_item_parsing.py | 33 +++++++ tests/realtime/test_openai_realtime.py | 116 +++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 02cb55a1a6..ccdf04e8de 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -2011,7 +2011,7 @@ def conversation_item_to_realtime_message_item( "type": item.type, "role": item.role, "content": content, - "status": "in_progress", + "status": item.status or "in_progress", }, ) diff --git a/tests/realtime/test_item_parsing.py b/tests/realtime/test_item_parsing.py index e8484a58f6..60168bb3a7 100644 --- a/tests/realtime/test_item_parsing.py +++ b/tests/realtime/test_item_parsing.py @@ -78,3 +78,36 @@ def test_system_message_conversion() -> None: ) assert isinstance(converted, SystemMessageItem) + + +def test_message_status_is_preserved() -> None: + item = RealtimeConversationItemAssistantMessage( + id="123", + type="message", + role="assistant", + status="completed", + content=[AssistantMessageContent(type="output_text", text="hello")], + ) + + converted: RealtimeMessageItem = _ConversionHelper.conversation_item_to_realtime_message_item( + item, None + ) + + assert isinstance(converted, AssistantMessageItem) + assert converted.status == "completed" + + +def test_message_status_defaults_to_in_progress_when_absent() -> None: + item = RealtimeConversationItemUserMessage( + id="123", + type="message", + role="user", + content=[UserMessageContent(type="input_text", text="hello")], + ) + + converted: RealtimeMessageItem = _ConversionHelper.conversation_item_to_realtime_message_item( + item, None + ) + + assert isinstance(converted, UserMessageItem) + assert converted.status == "in_progress" diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index eaf532bee9..7d733a192c 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -15,12 +15,14 @@ from agents.exceptions import UserError from agents.handoffs import handoff from agents.realtime import RealtimeAgent, RealtimeSession, RealtimeSessionModelSettings +from agents.realtime.items import AssistantAudio, AssistantMessageItem from agents.realtime.model import RealtimeModelConfig, RealtimePlaybackTracker from agents.realtime.model_events import ( RealtimeModelAudioEvent, RealtimeModelAudioInterruptedEvent, RealtimeModelConnectionStatusEvent, RealtimeModelErrorEvent, + RealtimeModelItemUpdatedEvent, RealtimeModelOutputTextDeltaEvent, RealtimeModelRawServerEvent, RealtimeModelToolCallEvent, @@ -861,6 +863,120 @@ def validate_python(self, event): emitted = [call.args[0] for call in mock_listener.on_event.call_args_list] assert [event.type for event in emitted] == ["raw_server_event", "turn_ended"] + @pytest.mark.asyncio + async def test_retrieved_completed_item_keeps_status(self, model, monkeypatch): + """An item the server reports as completed must not flip back to in_progress. + + After assistant audio plays, the SDK retrieves that item when the user's next + input transcription completes. The retrieved payload carries the item's real + status, and discarding it would regress history entries every turn. + """ + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + mock_listener = AsyncMock() + model.add_listener(mock_listener) + + await model._handle_ws_event( + { + "type": "response.output_audio.delta", + "event_id": "event_1", + "response_id": "resp_1", + "item_id": "item_1", + "output_index": 0, + "content_index": 0, + "delta": "dGVzdCBhdWRpbw==", + } + ) + await model._handle_ws_event( + { + "type": "conversation.item.input_audio_transcription.completed", + "event_id": "event_2", + "item_id": "item_user", + "content_index": 0, + "transcript": "hello", + "usage": { + "type": "tokens", + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2, + }, + } + ) + + assert send_raw.await_count == 1 + retrieve_call = send_raw.await_args + assert retrieve_call is not None + retrieve_event = retrieve_call.args[0] + assert retrieve_event.type == "conversation.item.retrieve" + assert retrieve_event.item_id == "item_1" + + await model._handle_ws_event( + { + "type": "conversation.item.retrieved", + "event_id": "event_3", + "item": { + "id": "item_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_audio", "transcript": "hi there"}], + }, + } + ) + + item_updated_events = [ + call.args[0] + for call in mock_listener.on_event.call_args_list + if isinstance(call.args[0], RealtimeModelItemUpdatedEvent) + ] + assert item_updated_events, "a retrieved conversation item should update listeners" + assert item_updated_events[-1].item.status == "completed" + + @pytest.mark.asyncio + async def test_retrieved_completed_item_keeps_status_in_session_history( + self, model, monkeypatch + ): + """A retrieved item must not regress a known terminal item in session history. + + After assistant audio plays, the SDK retrieves that item when the user's next + input transcription completes. The retrieved payload reports the item's real + status but may omit the transcript, so the session must reconcile it into + history without losing either the terminal status or the stored transcript. + """ + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + session = RealtimeSession(model, RealtimeAgent(name="agent"), None) + model.add_listener(session) + + session._history = [ + AssistantMessageItem( + item_id="item_1", + role="assistant", + status="completed", + content=[AssistantAudio(audio=None, transcript="hi there")], + ) + ] + + await model._handle_ws_event( + { + "type": "conversation.item.retrieved", + "event_id": "event_1", + "item": { + "id": "item_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_audio"}], + }, + } + ) + + assert len(session._history) == 1 + stored = cast(AssistantMessageItem, session._history[0]) + assert stored.status == "completed" + assert isinstance(stored.content[0], AssistantAudio) + assert stored.content[0].transcript == "hi there" + @pytest.mark.asyncio async def test_handle_unknown_event_type_ignored(self, model): """Test that unknown event types are ignored gracefully.""" From 1cd8303b9bbe058ff95bbd507c8c61b79944b3d0 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 25 Aug 2026 02:01:55 -0500 Subject: [PATCH 421/473] fix(core): accept JSON Schema type arrays of object for tool outputs (#4647) --- src/agents/tool.py | 5 ++++- tests/test_programmatic_tool_calling.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/agents/tool.py b/src/agents/tool.py index b9e278afa4..18a1399cca 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -2176,7 +2176,10 @@ def _json_schema_is_object(schema: dict[str, Any]) -> bool: current: Any = schema seen_refs: set[str] = set() while isinstance(current, dict): - if current.get("type") == "object": + # JSON Schema allows a one-element type array; treat ["object"] like "object" so + # OpenAPI/MCP-style output schemas match the params and strict-schema paths. + typ = current.get("type") + if typ == "object" or typ == ["object"]: return True ref = current.get("$ref") if not isinstance(ref, str) or not ref.startswith("#/") or ref in seen_refs: diff --git a/tests/test_programmatic_tool_calling.py b/tests/test_programmatic_tool_calling.py index c3a9e98de7..72c9b82c7f 100644 --- a/tests/test_programmatic_tool_calling.py +++ b/tests/test_programmatic_tool_calling.py @@ -408,6 +408,24 @@ def test_function_tool_rejects_non_object_or_non_strict_raw_output_schema( ) +def test_function_tool_accepts_single_element_object_type_array_output_schema() -> None: + """JSON Schema type arrays such as ``["object"]`` are equivalent to ``"object"``.""" + + tool = function_tool( + lambda: {"a": "x"}, + allowed_callers=["programmatic"], + output_json_schema={ + "type": ["object"], + "properties": {"a": {"type": "string"}}, + "required": ["a"], + "additionalProperties": False, + }, + ) + assert tool.output_json_schema is not None + assert tool.output_json_schema["type"] == "object" + assert tool.output_json_schema["additionalProperties"] is False + + @pytest.mark.asyncio async def test_function_tool_validates_inferred_output_type() -> None: @function_tool(allowed_callers=["programmatic"]) From a624e17956bfeac0599aa3f6b12a97a0ea689fc6 Mon Sep 17 00:00:00 2001 From: Ayaan Gazali Date: Tue, 25 Aug 2026 00:11:02 -0700 Subject: [PATCH 422/473] fix(sessions): recover failed resumed Session writes on a renewed interruption (#4650) --- .../run_internal/session_persistence.py | 3 +- src/agents/run_state.py | 4 +- tests/test_run_impl_resume_paths.py | 65 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 809f1648c3..bfe500b544 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -739,7 +739,8 @@ async def save_resumed_turn_items( wrapper=wrapper, resumed_write_state=( run_state - if run_state is not None and isinstance(run_state._current_step, NextStepRunAgain) + if run_state is not None + and isinstance(run_state._current_step, NextStepRunAgain | NextStepInterruption) else None ), ) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index b196bbaf85..228dd574d8 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -4358,11 +4358,11 @@ async def _build_run_state_from_json( ) pending_write = state_json.get("pending_session_write") if pending_write is not None: - from .run_internal.run_steps import NextStepRunAgain + from .run_internal.run_steps import NextStepInterruption, NextStepRunAgain if ( (schema_major, schema_minor) < (1, 17) - or not isinstance(state._current_step, NextStepRunAgain) + or not isinstance(state._current_step, NextStepRunAgain | NextStepInterruption) or not isinstance(pending_write, dict) or set(pending_write) != {"session_id", "items", "before", "persisted_count"} or not isinstance(pending_write.get("session_id"), str) diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 9a4d88f061..6d1f82babf 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -210,6 +210,71 @@ async def test_resumed_session_append_survives_repeated_failure_and_late_input() assert effects == [7] +async def _partially_approved_session_state(streamed: bool): + """Pause on two approval-gated calls in one response and approve only the first.""" + effects: list[int] = [] + + @tool(needs_approval=True) + async def charge(amount: int) -> str: + effects.append(amount) + return "receipt-7" + + @tool(needs_approval=True) + async def notify() -> str: + raise AssertionError("the unresolved approval must not execute") + + model = ScriptedModel( + [ + [ + get_function_tool_call("charge", '{"amount":7}', call_id="charge-1"), + get_function_tool_call("notify", "{}", call_id="notify-1"), + ], + [get_text_message("done")], + ] + ) + agent = Agent(name="payment", model=model, tools=[charge, notify]) + session = _FailingResumeSession() + paused = await _run_session_resume(agent, "charge 7 and notify", session, streamed) + state = paused.to_state() + charge_approval = next( + item for item in state.get_interruptions() if item.raw_item.call_id == "charge-1" + ) + state.approve(charge_approval) + return agent, model, session, state, effects + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "json"]) +async def test_renewed_interruption_recovers_failed_resumed_session_append( + streamed: bool, round_trip: bool +) -> None: + agent, model, session, state, effects = await _partially_approved_session_state(streamed) + session.failure = "before" + with pytest.raises(RuntimeError) as error: + await _run_session_resume(agent, state, session, streamed) + assert error.value is session.error + assert effects == [7] + assert _charge_pair(await session.get_items()) == ["function_call"] + + if round_trip: + state = await RunState.from_json(agent, state.to_json()) + + pending = await _run_session_resume(agent, state, session, streamed) + pending_state = pending.to_state() + remaining = pending_state.get_interruptions() + assert [item.raw_item.call_id for item in remaining] == ["notify-1"] + assert len(model.calls) == 1 + + pending_state.reject(remaining[0], rejection_message="declined") + result = await _run_session_resume(agent, pending_state, session, streamed) + assert result.final_output == "done" + assert effects == [7] + expected_pair = ["function_call", "function_call_output"] + assert _charge_pair(await session.get_items()) == expected_pair + assert _charge_pair(result.to_input_list()) == expected_pair + + @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) @pytest.mark.parametrize("round_trip", [False, True], ids=["live", "json"]) From 5f9f4f09c3fe840b5a4c09bdbbf6f0b1239bf0ec Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2026 16:11:24 +0900 Subject: [PATCH 423/473] feat(voice): expose streamed transcription options (#4645) Co-authored-by: abhay-codes07 --- src/agents/voice/model.py | 11 +- src/agents/voice/models/openai_stt.py | 70 +++++--- tests/voice/test_openai_stt_session_config.py | 159 +++++++++++++++++- 3 files changed, 212 insertions(+), 28 deletions(-) diff --git a/src/agents/voice/model.py b/src/agents/voice/model.py index 8ed3c5b62f..3b4a8e85b5 100644 --- a/src/agents/voice/model.py +++ b/src/agents/voice/model.py @@ -2,7 +2,7 @@ import abc from collections.abc import AsyncIterator, Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Literal from typing_extensions import TypedDict @@ -143,6 +143,15 @@ class STTModelSettings: turn_detection: dict[str, Any] | None = None """The turn detection settings for the model when using streamed audio input.""" + languages: list[str] | None = field(default=None, kw_only=True) + """Possible languages of the audio input, expressed as API-supported language codes, when + using streamed audio input. Supported by `gpt-transcribe` and `gpt-live-transcribe`. Takes + precedence over `language`.""" + + keywords: list[str] | None = field(default=None, kw_only=True) + """Words or phrases to guide transcription of the audio input when using streamed audio + input. Supported by `gpt-transcribe` and `gpt-live-transcribe`.""" + class STTModel(abc.ABC): """A speech-to-text model that can convert audio input into text.""" diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 3a9edce3c4..089999789f 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -138,6 +138,7 @@ def __init__( self._state_queue: asyncio.Queue[dict[str, Any] | ErrorSentinel] = asyncio.Queue() self._turn_audio_buffer: list[npt.NDArray[np.int16 | np.float32]] = [] self._tracing_span: Span[TranscriptionSpanData] | None = None + self._transcription_config: dict[str, Any] | None = None # tasks self._listener_task: asyncio.Task[Any] | None = None @@ -146,13 +147,37 @@ def __init__( self._connection_task: asyncio.Task[Any] | None = None self._stored_exception: Exception | None = None + def _get_transcription_config(self) -> dict[str, Any]: + transcription_config: dict[str, Any] = {"model": self._model} + if self._settings.languages is not None: + transcription_config["languages"] = list(self._settings.languages) + elif self._settings.language is not None: + if self._model in {"gpt-transcribe", "gpt-live-transcribe"}: + transcription_config["languages"] = [self._settings.language] + else: + transcription_config["language"] = self._settings.language + if self._settings.prompt is not None: + transcription_config["prompt"] = self._settings.prompt + if self._settings.keywords is not None: + transcription_config["keywords"] = list(self._settings.keywords) + return transcription_config + def _start_turn(self) -> None: + # A listener failure can surface a buffered transcript before session.update completes. + # Once configured, every normal turn reuses the exact detached request snapshot. + transcription_config = self._transcription_config or self._get_transcription_config() self._tracing_span = transcription_span( model=self._model, model_config={ "temperature": self._settings.temperature, - "language": self._settings.language, - "prompt": self._settings.prompt, + "language": transcription_config.get("language"), + "languages": transcription_config.get("languages"), + "keywords": ( + transcription_config.get("keywords") + if self._trace_include_sensitive_data + else None + ), + "prompt": transcription_config.get("prompt"), "turn_detection": self._turn_detection, }, ) @@ -201,32 +226,25 @@ async def _event_listener(self) -> None: async def _configure_session(self) -> None: assert self._websocket is not None, "Websocket not initialized" - transcription_config: dict[str, Any] = {"model": self._model} - if self._settings.language is not None: - if self._model in {"gpt-transcribe", "gpt-live-transcribe"}: - transcription_config["languages"] = [self._settings.language] - else: - transcription_config["language"] = self._settings.language - if self._settings.prompt is not None: - transcription_config["prompt"] = self._settings.prompt - - await self._websocket.send( - json.dumps( - { - "type": "session.update", - "session": { - "type": "transcription", - "audio": { - "input": { - "format": {"type": "audio/pcm", "rate": 24000}, - "transcription": transcription_config, - "turn_detection": self._turn_detection, - } - }, + transcription_config = self._get_transcription_config() + session_update = json.dumps( + { + "type": "session.update", + "session": { + "type": "transcription", + "audio": { + "input": { + "format": {"type": "audio/pcm", "rate": 24000}, + "transcription": transcription_config, + "turn_detection": self._turn_detection, + } }, - } - ) + }, + } ) + self._transcription_config = transcription_config + + await self._websocket.send(session_update) async def _setup_connection(self, ws: websockets.ClientConnection) -> None: self._websocket = ws diff --git a/tests/voice/test_openai_stt_session_config.py b/tests/voice/test_openai_stt_session_config.py index 65388f6c7c..246ef83baa 100644 --- a/tests/voice/test_openai_stt_session_config.py +++ b/tests/voice/test_openai_stt_session_config.py @@ -1,5 +1,6 @@ import json -from unittest.mock import AsyncMock +from dataclasses import dataclass, fields +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -7,6 +8,37 @@ from agents.voice.models.openai_stt import OpenAISTTTranscriptionSession +def test_stt_model_settings_appends_streaming_options() -> None: + assert [field.name for field in fields(STTModelSettings)] == [ + "prompt", + "language", + "temperature", + "turn_detection", + "languages", + "keywords", + ] + + +def test_stt_model_settings_preserves_provider_subclass_positional_fields() -> None: + @dataclass + class ProviderSTTModelSettings(STTModelSettings): + provider_language: str | None = None + + settings = ProviderSTTModelSettings( + None, + None, + None, + None, + "provider-ja", + languages=["ja"], + keywords=["Agents SDK"], + ) + + assert settings.provider_language == "provider-ja" + assert settings.languages == ["ja"] + assert settings.keywords == ["Agents SDK"] + + @pytest.mark.asyncio @pytest.mark.parametrize( ("model", "language_field", "language_value"), @@ -59,3 +91,128 @@ async def test_streaming_stt_omits_unset_language_and_prompt() -> None: payload = json.loads(websocket.send.await_args.args[0]) assert payload["session"]["audio"]["input"]["transcription"] == {"model": "gpt-4o-transcribe"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model", ["gpt-transcribe", "gpt-live-transcribe"]) +async def test_streaming_stt_sends_languages_over_language(model: str) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model=model, + settings=STTModelSettings(language="fr", languages=["fr", "eng", "zh-tw"]), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + websocket = AsyncMock() + session._websocket = websocket + + await session._configure_session() + + payload = json.loads(websocket.send.await_args.args[0]) + assert payload["session"]["audio"]["input"]["transcription"] == { + "model": model, + "languages": ["fr", "eng", "zh-tw"], + } + + +@pytest.mark.asyncio +async def test_streaming_stt_sends_keywords() -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="gpt-live-transcribe", + settings=STTModelSettings(keywords=["agents", "sdk"]), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + websocket = AsyncMock() + session._websocket = websocket + + await session._configure_session() + + payload = json.loads(websocket.send.await_args.args[0]) + assert payload["session"]["audio"]["input"]["transcription"] == { + "model": "gpt-live-transcribe", + "keywords": ["agents", "sdk"], + } + + +@pytest.mark.asyncio +async def test_streaming_stt_trace_records_transcription_options_with_sensitive_data() -> None: + languages = ["en", "fr"] + keywords = ["Agents SDK"] + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="gpt-live-transcribe", + settings=STTModelSettings( + prompt="customer support", + language="en", + temperature=0.2, + languages=languages, + keywords=keywords, + ), + trace_include_sensitive_data=True, + trace_include_sensitive_audio_data=False, + ) + websocket = AsyncMock() + session._websocket = websocket + await session._configure_session() + + languages[:] = ["de"] + keywords[:] = ["Changed after configuration"] + span = MagicMock() + + with patch( + "agents.voice.models.openai_stt.transcription_span", + return_value=span, + ) as create_span: + session._start_turn() + languages[:] = ["it"] + keywords[:] = ["Changed during the turn"] + session._end_turn("") + + create_span.assert_called_once_with( + model="gpt-live-transcribe", + model_config={ + "temperature": 0.2, + "language": None, + "languages": ["en", "fr"], + "keywords": ["Agents SDK"], + "prompt": "customer support", + "turn_detection": {"type": "semantic_vad"}, + }, + ) + span.start.assert_called_once_with() + span.finish.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_streaming_stt_trace_redacts_keywords_without_sensitive_data() -> None: + sensitive_keywords = ["CUSTOMER_SECRET_NAME"] + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=AsyncMock(api_key="FAKE_KEY"), + model="gpt-live-transcribe", + settings=STTModelSettings(keywords=sensitive_keywords), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + websocket = AsyncMock() + session._websocket = websocket + await session._configure_session() + span = MagicMock() + + with patch( + "agents.voice.models.openai_stt.transcription_span", + return_value=span, + ) as create_span: + session._start_turn() + session._end_turn("") + + model_config = create_span.call_args.kwargs["model_config"] + assert model_config["keywords"] is None + assert all(value is not sensitive_keywords for value in model_config.values()) + span.start.assert_called_once_with() + span.finish.assert_called_once_with() From 1df6e81474a439d4fff8eac227743cfb3f5d2d6d Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 25 Aug 2026 21:02:29 -0500 Subject: [PATCH 424/473] fix(voice): redact STT prompts from traces (#4663) --- src/agents/voice/models/openai_stt.py | 12 ++++++-- tests/voice/test_openai_stt.py | 28 +++++++++++++++++++ tests/voice/test_openai_stt_session_config.py | 6 ++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 089999789f..8a202574d3 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -177,7 +177,11 @@ def _start_turn(self) -> None: if self._trace_include_sensitive_data else None ), - "prompt": transcription_config.get("prompt"), + "prompt": ( + transcription_config.get("prompt") + if self._trace_include_sensitive_data + else None + ), "turn_detection": self._turn_detection, }, ) @@ -553,7 +557,11 @@ async def transcribe( model_config={ "temperature": self._non_null_or_not_given(settings.temperature), "language": self._non_null_or_not_given(settings.language), - "prompt": self._non_null_or_not_given(settings.prompt), + "prompt": ( + self._non_null_or_not_given(settings.prompt) + if trace_include_sensitive_data + else None + ), }, ) as span: try: diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index 26deb286cd..4f3b96acc2 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -5,6 +5,7 @@ import json import logging from collections.abc import AsyncGenerator +from types import SimpleNamespace from typing import cast from unittest.mock import AsyncMock, MagicMock, patch @@ -397,6 +398,33 @@ async def test_transcribe_error_respects_sensitive_data_setting( assert fetch_span_errors("transcription") == [{"message": expected_error, "data": {}}] +@pytest.mark.asyncio +async def test_transcribe_redacts_prompt_without_changing_request() -> None: + client = AsyncMock() + client.audio.transcriptions.create.return_value = SimpleNamespace(text="transcript") + model = OpenAISTTModel(model="whisper-1", openai_client=client) + span = MagicMock() + span_context = MagicMock() + span_context.__enter__.return_value = span + + with patch( + "agents.voice.models.openai_stt.transcription_span", + return_value=span_context, + ) as create_span: + result = await model.transcribe( + AudioInput(buffer=np.zeros(2, dtype=np.int16)), + STTModelSettings(prompt="customer account vocabulary"), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + assert result == "transcript" + assert create_span.call_args.kwargs["model_config"]["prompt"] is None + assert client.audio.transcriptions.create.await_args.kwargs["prompt"] == ( + "customer account vocabulary" + ) + + @pytest.mark.asyncio async def test_non_json_messages_should_crash(): """This tests that non-JSON messages will raise an exception""" diff --git a/tests/voice/test_openai_stt_session_config.py b/tests/voice/test_openai_stt_session_config.py index 246ef83baa..b2acfca3fe 100644 --- a/tests/voice/test_openai_stt_session_config.py +++ b/tests/voice/test_openai_stt_session_config.py @@ -189,13 +189,14 @@ async def test_streaming_stt_trace_records_transcription_options_with_sensitive_ @pytest.mark.asyncio -async def test_streaming_stt_trace_redacts_keywords_without_sensitive_data() -> None: +async def test_streaming_stt_trace_redacts_text_options_without_sensitive_data() -> None: sensitive_keywords = ["CUSTOMER_SECRET_NAME"] + sensitive_prompt = "customer account vocabulary" session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), client=AsyncMock(api_key="FAKE_KEY"), model="gpt-live-transcribe", - settings=STTModelSettings(keywords=sensitive_keywords), + settings=STTModelSettings(prompt=sensitive_prompt, keywords=sensitive_keywords), trace_include_sensitive_data=False, trace_include_sensitive_audio_data=False, ) @@ -213,6 +214,7 @@ async def test_streaming_stt_trace_redacts_keywords_without_sensitive_data() -> model_config = create_span.call_args.kwargs["model_config"] assert model_config["keywords"] is None + assert model_config["prompt"] is None assert all(value is not sensitive_keywords for value in model_config.values()) span.start.assert_called_once_with() span.finish.assert_called_once_with() From 18de65134083ee8cbdb84ae30a34c1f30ef4cb86 Mon Sep 17 00:00:00 2001 From: Lakshit Mathur <147316312+lllakshit@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:03:38 -0500 Subject: [PATCH 425/473] fix(core): honor End of File hunks when the file ends in a newline (#4661) --- src/agents/apply_diff.py | 17 ++++++++++++++--- tests/test_apply_diff.py | 18 ++++++++++++++++++ tests/test_apply_diff_helpers.py | 10 ++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/agents/apply_diff.py b/src/agents/apply_diff.py index 1ff9d43154..a9370edc43 100644 --- a/src/agents/apply_diff.py +++ b/src/agents/apply_diff.py @@ -333,15 +333,26 @@ class ContextMatch: def _find_context(lines: list[str], context: list[str], start: int, eof: bool) -> ContextMatch: if eof: - end_start = max(0, len(lines) - len(context)) - end_match = _find_context_core(lines, context, end_start) + # `str.split("\n")` keeps a trailing empty element for text that ends in a + # newline. Treat that as the file terminator, not as a line, so EOF hunks + # append after the last real line instead of after a phantom blank. + search_lines = _eof_search_lines(lines) + end_start = max(0, len(search_lines) - len(context)) + end_match = _find_context_core(search_lines, context, end_start) if end_match.new_index != -1: return end_match - fallback = _find_context_core(lines, context, start) + fallback_start = min(start, len(search_lines)) + fallback = _find_context_core(search_lines, context, fallback_start) return ContextMatch(new_index=fallback.new_index, fuzz=fallback.fuzz + 10000) return _find_context_core(lines, context, start) +def _eof_search_lines(lines: list[str]) -> list[str]: + if lines and lines[-1] == "": + return lines[:-1] + return lines + + def _find_context_core(lines: list[str], context: list[str], start: int) -> ContextMatch: if not context: return ContextMatch(new_index=start, fuzz=0) diff --git a/tests/test_apply_diff.py b/tests/test_apply_diff.py index 17c2bd43f9..45b5a1d937 100644 --- a/tests/test_apply_diff.py +++ b/tests/test_apply_diff.py @@ -264,3 +264,21 @@ def test_apply_diff_with_crlf_input_and_crlf_diff_preserves_crlf() -> None: def test_apply_diff_create_mode_preserves_crlf_newlines() -> None: diff = "\r\n".join(["+hello", "+world", "+"]) assert apply_diff("", diff, mode="create") == "hello\r\nworld\r\n" + + +def test_apply_diff_eof_hunk_appends_without_a_blank_line() -> None: + # Files that end in a newline must not grow a phantom blank line at EOF. + assert apply_diff("a\nb\n", "@@\n+c\n*** End of File") == "a\nb\nc\n" + assert apply_diff("a\nb", "@@\n+c\n*** End of File") == "a\nb\nc" + + +def test_apply_diff_eof_hunk_matches_the_last_context_occurrence() -> None: + original = "x\nfoo\ny\nfoo\n" + diff = " foo\n+added\n*** End of File" + assert apply_diff(original, diff) == "x\nfoo\ny\nfoo\nadded\n" + + +def test_apply_diff_eof_hunk_preserves_crlf() -> None: + original = "a\r\nb\r\n" + diff = "@@\n+c\n*** End of File" + assert apply_diff(original, diff) == "a\r\nb\r\nc\r\n" diff --git a/tests/test_apply_diff_helpers.py b/tests/test_apply_diff_helpers.py index bc5f28032d..4d2f502878 100644 --- a/tests/test_apply_diff_helpers.py +++ b/tests/test_apply_diff_helpers.py @@ -53,6 +53,16 @@ def test_find_context_eof_fallbacks() -> None: assert match.fuzz >= 10000 +def test_find_context_eof_ignores_trailing_empty_split_element() -> None: + match = _find_context(["a", "b", ""], [], start=0, eof=True) + assert match.new_index == 2 + assert match.fuzz == 0 + + last_foo = _find_context(["x", "foo", "y", "foo", ""], ["foo"], start=0, eof=True) + assert last_foo.new_index == 3 + assert last_foo.fuzz == 0 + + def test_find_context_core_stripped_matches() -> None: match = _find_context_core([" line "], ["line"], start=0) assert match.new_index == 0 From 3603dc92c90d0d9aebf382e6c0e7dbc6b22d430a Mon Sep 17 00:00:00 2001 From: Fu Xiaonan Date: Wed, 26 Aug 2026 10:07:02 +0800 Subject: [PATCH 426/473] fix(sessions): persist resumed tool guardrail results (#4654) --- src/agents/run.py | 9 + src/agents/run_internal/run_loop.py | 9 + tests/test_runner_guardrail_resume.py | 293 +++++++++++++++++++++++++- 3 files changed, 308 insertions(+), 3 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 629b5ff23f..c23b6363cf 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1135,6 +1135,15 @@ def _mark_response_hooks_started() -> None: generated_items=generated_items, session_items=session_items, ) + if isinstance(turn_result.next_step, NextStepInterruption): + run_state._tool_input_guardrail_results = [ + *tool_input_guardrail_results, + *turn_result.tool_input_guardrail_results, + ] + run_state._tool_output_guardrail_results = [ + *tool_output_guardrail_results, + *turn_result.tool_output_guardrail_results, + ] if ( session_persistence_enabled diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 7c22fee317..3c293b6ee8 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1377,6 +1377,15 @@ async def _save_max_turns_items( ) if isinstance(turn_result.next_step, NextStepInterruption): + if run_state is not None: + run_state._tool_input_guardrail_results = [ + *accepted_tool_input_guardrail_results, + *turn_result.tool_input_guardrail_results, + ] + run_state._tool_output_guardrail_results = [ + *accepted_tool_output_guardrail_results, + *turn_result.tool_output_guardrail_results, + ] await _finalize_streamed_interruption( streamed_result=streamed_result, save_items=_save_resumed_items, diff --git a/tests/test_runner_guardrail_resume.py b/tests/test_runner_guardrail_resume.py index d7f1b38369..975efbceda 100644 --- a/tests/test_runner_guardrail_resume.py +++ b/tests/test_runner_guardrail_resume.py @@ -1,13 +1,23 @@ from types import SimpleNamespace -from typing import Any, cast +from typing import Any, Literal, cast import pytest from openai.types.responses import ResponseFunctionToolCall import agents.run as run_module -from agents import Agent, Runner +from agents import ( + Agent, + Runner, + ToolExecutionConfig, + ToolInputGuardrailData, + ToolOutputGuardrailData, + function_tool, +) from agents.guardrail import GuardrailFunctionOutput, InputGuardrail, InputGuardrailResult -from agents.items import ModelResponse, ToolApprovalItem +from agents.items import ModelResponse, ToolApprovalItem, TResponseInputItem +from agents.lifecycle import RunHooks +from agents.memory import Session +from agents.run import RunConfig from agents.run_context import RunContextWrapper from agents.run_internal.run_steps import ( NextStepFinalOutput, @@ -16,6 +26,7 @@ ) from agents.run_state import RunState from agents.testing import ScriptedModel +from agents.tool import Tool from agents.tool_guardrails import ( AllowBehavior, ToolGuardrailFunctionOutput, @@ -23,8 +34,284 @@ ToolInputGuardrailResult, ToolOutputGuardrail, ToolOutputGuardrailResult, + tool_input_guardrail, + tool_output_guardrail, ) from agents.usage import Usage +from tests.test_responses import get_function_tool_call, get_text_message +from tests.utils.simple_session import SimpleListSession + + +class _ResumeWriteFailureSession(SimpleListSession): + """Fail one resumed append either before or after the batch reaches the Session.""" + + def __init__(self) -> None: + super().__init__() + self.failure: Literal["before", "after"] | None = None + self.error = RuntimeError("session append failed") + + async def add_items(self, items: list[TResponseInputItem]) -> None: + failure, self.failure = self.failure, None + if failure == "before": + raise self.error + await super().add_items(items) + if failure == "after": + raise self.error + + +class _CountingToolHooks(RunHooks[Any]): + def __init__(self) -> None: + self.tool_starts = 0 + self.tool_ends = 0 + + async def on_tool_start( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + ) -> None: + self.tool_starts += 1 + + async def on_tool_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + result: object, + ) -> None: + self.tool_ends += 1 + + +async def _run_with_session( + agent: Agent[Any], + value: str | RunState[Any], + session: Session, + hooks: RunHooks[Any], + *, + streamed: bool, + pre_approval: bool, +) -> Any: + run_config = RunConfig( + tracing_disabled=True, + tool_execution=( + ToolExecutionConfig(pre_approval_tool_input_guardrails=True) if pre_approval else None + ), + ) + if not streamed: + return await Runner.run( + agent, + value, + session=session, + hooks=hooks, + run_config=run_config, + ) + result = Runner.run_streamed( + agent, + value, + session=session, + hooks=hooks, + run_config=run_config, + ) + async for _ in result.stream_events(): + pass + return result + + +def _assert_guardrail_results(value: Any, *, input_count: int) -> None: + input_results = ( + value._tool_input_guardrail_results + if isinstance(value, RunState) + else value.tool_input_guardrail_results + ) + output_results = ( + value._tool_output_guardrail_results + if isinstance(value, RunState) + else value.tool_output_guardrail_results + ) + assert [result.output.output_info for result in input_results] == [ + "input-checked" + ] * input_count + assert [result.output.output_info for result in output_results] == ["output-checked"] + + +def _tool_item_types(items: list[TResponseInputItem], call_id: str) -> list[str]: + return [ + str(item.get("type")) + for item in items + if isinstance(item, dict) and item.get("call_id") == call_id + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "failing_streamed", + "recovering_streamed", + "round_trip", + "failure", + "pre_approval", + ), + [ + (False, False, False, "before", False), + (False, True, True, "after", True), + (True, False, False, "after", False), + (True, True, True, "before", False), + ], + ids=[ + "run-to-run-live-before-commit", + "run-to-streamed-json-pre-approval-commit-then-raise", + "streamed-to-run-live-commit-then-raise", + "streamed-to-streamed-json-before-commit", + ], +) +async def test_resumed_session_failure_publishes_durable_tool_guardrails( + failing_streamed: bool, + recovering_streamed: bool, + round_trip: bool, + failure: Literal["before", "after"], + pre_approval: bool, +) -> None: + counters = { + "effect": 0, + "input_guardrail": 0, + "output_guardrail": 0, + } + + @tool_input_guardrail + async def record_input( + _data: ToolInputGuardrailData, + ) -> ToolGuardrailFunctionOutput: + counters["input_guardrail"] += 1 + return ToolGuardrailFunctionOutput.allow(output_info="input-checked") + + @tool_output_guardrail + async def record_output( + _data: ToolOutputGuardrailData, + ) -> ToolGuardrailFunctionOutput: + counters["output_guardrail"] += 1 + return ToolGuardrailFunctionOutput.allow(output_info="output-checked") + + @function_tool( + needs_approval=True, + tool_input_guardrails=[record_input], + tool_output_guardrails=[record_output], + ) + async def charge(amount: int) -> str: + counters["effect"] += 1 + return f"receipt-{amount}" + + @function_tool(needs_approval=True) + async def notify() -> str: + raise AssertionError("the unresolved approval must not execute") + + model = ScriptedModel( + [ + [ + get_function_tool_call("charge", '{"amount":7}', call_id="charge-1"), + get_function_tool_call("notify", "{}", call_id="notify-1"), + ], + [get_text_message("done")], + ] + ) + agent = Agent(name="payment", model=model, tools=[charge, notify]) + session = _ResumeWriteFailureSession() + hooks = _CountingToolHooks() + input_guardrail_count = 2 if pre_approval else 1 + expected_counters = { + "effect": 1, + "input_guardrail": input_guardrail_count, + "output_guardrail": 1, + } + + paused = await _run_with_session( + agent, + "charge 7 and notify", + session, + hooks, + streamed=failing_streamed, + pre_approval=pre_approval, + ) + state = paused.to_state() + charge_approval = next( + item for item in state.get_interruptions() if item.raw_item.call_id == "charge-1" + ) + state.approve(charge_approval) + + session.failure = failure + with pytest.raises(RuntimeError) as error: + await _run_with_session( + agent, + state, + session, + hooks, + streamed=failing_streamed, + pre_approval=pre_approval, + ) + assert error.value is session.error + _assert_guardrail_results(state, input_count=input_guardrail_count) + assert counters == expected_counters + assert hooks.tool_starts == 1 + assert hooks.tool_ends == 1 + assert len(model.calls) == 1 + assert [item.raw_item.call_id for item in state.get_interruptions()] == ["notify-1"] + assert _tool_item_types(await session.get_items(), "charge-1") == ( + ["function_call"] if failure == "before" else ["function_call", "function_call_output"] + ) + + if round_trip: + state = await RunState.from_json(agent, state.to_json()) + _assert_guardrail_results(state, input_count=input_guardrail_count) + + pending = await _run_with_session( + agent, + state, + session, + hooks, + streamed=recovering_streamed, + pre_approval=pre_approval, + ) + _assert_guardrail_results(pending, input_count=input_guardrail_count) + pending_state = pending.to_state() + _assert_guardrail_results(pending_state, input_count=input_guardrail_count) + remaining = pending_state.get_interruptions() + assert [item.raw_item.call_id for item in remaining] == ["notify-1"] + assert _tool_item_types(await session.get_items(), "charge-1") == [ + "function_call", + "function_call_output", + ] + assert counters == expected_counters + assert hooks.tool_starts == 1 + assert hooks.tool_ends == 1 + assert len(model.calls) == 1 + + pending_state.reject(remaining[0], rejection_message="declined") + result = await _run_with_session( + agent, + pending_state, + session, + hooks, + streamed=recovering_streamed, + pre_approval=pre_approval, + ) + assert result.final_output == "done" + _assert_guardrail_results(result, input_count=input_guardrail_count) + _assert_guardrail_results(result.to_state(), input_count=input_guardrail_count) + session_items = await session.get_items() + result_items = result.to_input_list() + final_model_items = model.calls[-1].input + for items in (session_items, result_items, final_model_items): + assert _tool_item_types(items, "charge-1") == [ + "function_call", + "function_call_output", + ] + assert _tool_item_types(items, "notify-1") == [ + "function_call", + "function_call_output", + ] + assert counters == expected_counters + assert hooks.tool_starts == 1 + assert hooks.tool_ends == 1 + assert len(model.calls) == 2 @pytest.mark.asyncio From e773b15488c491d907d42756d91e470f280a3d7e Mon Sep 17 00:00:00 2001 From: Excelius <57819425+Excelius-Wang@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:10:44 +0800 Subject: [PATCH 427/473] fix(core): preserve tuple annotations for variadic tool arguments (#4655) --- src/agents/function_schema.py | 4 ++-- tests/test_function_schema.py | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 791eb3b661..dd97ed2b30 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -399,10 +399,10 @@ def function_schema( if param.kind == param.VAR_POSITIONAL: # e.g. *args: extend positional args if get_origin(ann) is tuple: - # e.g. def foo(*args: tuple[int, ...]) -> treat as List[int] + # Preserve a homogeneous tuple as the type of each positional argument. args_of_tuple = get_args(ann) if len(args_of_tuple) == 2 and args_of_tuple[1] is Ellipsis: - ann = list[args_of_tuple[0]] # type: ignore + ann = list[ann] # type: ignore else: ann = list[Any] else: diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 1cdd08049c..0b7550fcf0 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -439,8 +439,7 @@ def func(a: int, context: RunContextWrapper) -> None: def test_var_positional_tuple_annotation(): - # When a function has a var-positional parameter annotated with a tuple type, - # function_schema() should convert it into a field with type List[]. + # A variadic tuple annotation applies to each positional argument. def func(*args: tuple[int, ...]) -> int: total = 0 for arg in args: @@ -450,8 +449,17 @@ def func(*args: tuple[int, ...]) -> int: fs = function_schema(func, use_docstring_info=False) properties = fs.params_json_schema.get("properties", {}) - assert properties.get("args").get("type") == "array" - assert properties.get("args").get("items").get("type") == "integer" + args_schema = properties.get("args", {}) + assert args_schema.get("type") == "array" + assert args_schema.get("items", {}).get("type") == "array" + assert args_schema.get("items", {}).get("items", {}).get("type") == "integer" + + parsed = fs.params_pydantic_model.model_validate({"args": [[1, 2], [3]]}) + args, kwargs = fs.to_call_args(parsed) + assert func(*args, **kwargs) == 6 + + with pytest.raises(ValidationError): + fs.params_pydantic_model.model_validate({"args": [1, 2, 3]}) def test_var_keyword_dict_annotation(): From a40ae9803e6b7a79faa246293f56adb100d5868b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2026 14:22:24 +0900 Subject: [PATCH 428/473] fix(ci): consolidate Docker-backed integration tests (#4667) --- .github/scripts/run_integration_tests.py | 27 ++- .github/workflows/tests.yml | 28 +++ Makefile | 4 + integration_tests/README.md | 8 +- .../containers/test_dapr_redis.py | 204 +++++++++--------- integration_tests/pytest.ini | 1 + .../security/test_local_sandbox_isolation.py | 1 + pyproject.toml | 1 - tests/README.md | 2 +- tests/test_integration_runner.py | 109 +++++++++- tests/test_repository_workflow_interfaces.py | 38 ++++ uv.lock | 18 -- 12 files changed, 313 insertions(+), 128 deletions(-) rename tests/extensions/memory/test_dapr_redis_integration.py => integration_tests/containers/test_dapr_redis.py (78%) diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 5a6d16ac51..34bb81c6a1 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -17,6 +17,7 @@ TESTS = ROOT / "integration_tests" CONTRACT_POLICY = ROOT / "tests" / "fixtures" / "released_api_contract_policy.json" PROSPECTIVE_CONTRACT_ENV = "OPENAI_AGENTS_PROSPECTIVE_RELEASE_CONTRACT" +TESTCONTAINERS_REQUIREMENT = "testcontainers==4.12.0" EXTRAS = "any-llm,litellm,realtime,voice" OPTIONAL_EXTRAS = ( "any-llm", @@ -29,13 +30,14 @@ "viz", "s3", ) -STRICT_PROFILES = frozenset({"release", "security"}) +STRICT_PROFILES = frozenset({"containers", "release", "security"}) LOCAL_ONLY_CREDENTIAL_CLASS = "local-only" LIVE_CREDENTIAL_CLASS = "live" PROFILE_CREDENTIAL_CLASSES = { "packaging": LOCAL_ONLY_CREDENTIAL_CLASS, "prospective-contract": LOCAL_ONLY_CREDENTIAL_CLASS, "prospective-platform": LOCAL_ONLY_CREDENTIAL_CLASS, + "containers": LOCAL_ONLY_CREDENTIAL_CLASS, "security": LOCAL_ONLY_CREDENTIAL_CLASS, "mcp-v1": LOCAL_ONLY_CREDENTIAL_CLASS, "extras": LOCAL_ONLY_CREDENTIAL_CLASS, @@ -276,11 +278,10 @@ def run_suite( "-c", str(TESTS / "pytest.ini"), str(TESTS), - "-v", - "--tb=short", - "-m", - selection, ] + if profile != "containers": + command.append(f"--ignore={TESTS / 'containers'}") + command.extend(["-v", "--tb=short", "-m", selection]) result_path = RESULTS / profile / f"{environment_kind}.xml" result_path.parent.mkdir(parents=True, exist_ok=True) command.append(f"--junitxml={result_path}") @@ -457,6 +458,22 @@ def main() -> None: profile=args.profile, ) + if args.profile == "containers": + python = create_environment( + "containers", + wheel, + optional_extra="dapr,docker", + additional_requirements=(TESTCONTAINERS_REQUIREMENT,), + ) + run_suite( + python, + wheel, + sdist, + selection="containers", + environment_kind="containers", + profile=args.profile, + ) + if args.profile in { "packaging", "prospective-contract", diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cd038cf88f..e601f1ab11 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -158,6 +158,34 @@ jobs: if: steps.changes.outputs.run != 'true' run: echo "Skipping tests for non-code changes." + containers: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + OPENAI_API_KEY: fake-for-tests + # GitHub-hosted runners are ephemeral, and each test explicitly removes its containers. + TESTCONTAINERS_RYUK_DISABLED: "true" + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Detect code changes + id: changes + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: true + prune-cache: true + python-version: "3.14" + - name: Run local container integration tests + if: steps.changes.outputs.run == 'true' + run: make integration-tests-containers + - name: Skip local container integration tests + if: steps.changes.outputs.run != 'true' + run: echo "Skipping local container integration tests for non-code changes." + native-macos-sandbox: runs-on: macos-latest timeout-minutes: 15 diff --git a/Makefile b/Makefile index 40fb3266ac..04b0689aa3 100644 --- a/Makefile +++ b/Makefile @@ -154,6 +154,10 @@ integration-tests-prospective-platform: integration-tests-security: $(INTEGRATION_TEST_RUNNER) --profile security +.PHONY: integration-tests-containers +integration-tests-containers: + $(INTEGRATION_TEST_RUNNER) --profile containers + .PHONY: integration-tests-mcp-v1 integration-tests-mcp-v1: $(INTEGRATION_TEST_RUNNER) --profile mcp-v1 diff --git a/integration_tests/README.md b/integration_tests/README.md index 7e50f1b981..3507bbcb9c 100644 --- a/integration_tests/README.md +++ b/integration_tests/README.md @@ -8,13 +8,13 @@ Run the complete release-oriented matrix with: test "${OPENAI_API_KEY_SOURCE:-}" = service-account make integration-tests -`make integration-tests-release` runs the release-safe live matrix and the local Docker security contract in strict mode, so an unavailable daemon, image, credential, or required capability fails the release gate instead of becoming a skip. The focused `make integration-tests-security` target runs the same wheel and sdist security contract in strict mode without the live provider matrix; the security profile remains separate from the credential-free PR packaging job. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-prospective-contract`, `make integration-tests-prospective-platform`, `make integration-tests-security`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The packaging profile validates the released public API manifest and historical `RunState` corpus from base wheel and sdist environments, then validates the public API again from wheel and sdist environments with the Cloudflare extra installed so dependency-conditional exports are required. The security profile installs the Docker extra for both distribution formats, checks packaged credential redaction, and runs model-controlled environment, filesystem, and process inspection inside a local Docker sandbox through the public `Runner` lifecycle. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. +`make integration-tests-release` runs the release-safe live matrix and the local Docker security contract in strict mode, so an unavailable daemon, image, credential, or required capability fails the release gate instead of becoming a skip. The focused `make integration-tests-security` target runs the same wheel and sdist security contract in strict mode without the live provider matrix; the security profile remains separate from the credential-free PR packaging job. The focused `make integration-tests-containers` target runs the packaged Docker sandbox security case together with the Dapr and Redis service integration once in a wheel environment. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-prospective-contract`, `make integration-tests-prospective-platform`, `make integration-tests-security`, `make integration-tests-containers`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The packaging profile validates the released public API manifest and historical `RunState` corpus from base wheel and sdist environments, then validates the public API again from wheel and sdist environments with the Cloudflare extra installed so dependency-conditional exports are required. The security profile installs the Docker extra for both distribution formats, checks packaged credential redaction, and runs model-controlled environment, filesystem, and process inspection inside a local Docker sandbox through the public `Runner` lifecycle. The containers profile installs the Dapr and Docker extras plus the pinned Testcontainers dependency without an OpenAI API credential. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. Release PR preparation updates the rolling API manifest locally rather than in a credentialed GitHub workflow. After the release branch version bump, run `make update-released-api-contract VERSION=`, review and commit the manifest diff, then run `make check-released-api-contract VERSION=` after subsequent rebases. Promotion fails before writing if the candidate breaks the committed released contract. The prospective release-contract job performs this source validation in one dedicated Python process so provider behavior tests cannot change its import graph. Inspectable top-level classes and functions are promoted automatically; documented properties, intended submodule paths, and canonical aliases remain explicit review decisions recorded in the manifest. The packaged profiles remain the artifact-level verification that the committed contract holds for core and policy-declared optional surfaces across wheel, sdist, and supported platforms. Integration execution is available only through these Make targets and `.github/scripts/run_integration_tests.py`; there is no integration execution skill. When a release review also requires runnable examples, run the relevant `make examples-*` target manually, analyze its completed artifacts with `examples-run-analysis`, and then run the selected `make integration-tests-*` target. -Every integration profile has one credential class. Local-only profiles are `packaging`, `prospective-contract`, `prospective-platform`, `security`, `mcp-v1`, and `extras`; the runner removes an inherited `OPENAI_API_KEY` before building distributions or starting child processes for these profiles. Live profiles are `core`, `providers`, `realtime`, `voice`, `hosted`, `full`, `release`, `nightly`, and `manual`; the runner refuses them before any build or child process unless `OPENAI_API_KEY_SOURCE=service-account`. Load the approved service-account environment before invoking a live Make target. +Every integration profile has one credential class. Local-only profiles are `packaging`, `prospective-contract`, `prospective-platform`, `containers`, `security`, `mcp-v1`, and `extras`; the runner removes an inherited `OPENAI_API_KEY` before building distributions or starting child processes for these profiles. Live profiles are `core`, `providers`, `realtime`, `voice`, `hosted`, `full`, `release`, `nightly`, and `manual`; the runner refuses them before any build or child process unless `OPENAI_API_KEY_SOURCE=service-account`. Load the approved service-account environment before invoking a live Make target. Set `OPENAI_API_KEY` to the approved service-account key and set `OPENAI_API_KEY_SOURCE=service-account` for live OpenAI calls. Override `OPENAI_AGENTS_INTEGRATION_MODEL`, `OPENAI_AGENTS_INTEGRATION_REALTIME_MODEL`, `OPENAI_AGENTS_INTEGRATION_ANY_LLM_MODELS`, and `OPENAI_AGENTS_INTEGRATION_LITELLM_MODELS` when testing different models or configured providers. Provider model lists contain comma-separated adapter model names and require the credentials matching each selected provider. Set `OPENAI_AGENTS_INTEGRATION_MCP_SERVER_URL` to use another trusted DeepWiki-compatible hosted MCP server that exposes the `ask_question` tool and can answer questions about the `openai/openai-agents-python` repository. @@ -24,10 +24,12 @@ The default general model is `gpt-5.6`, while LiteLLM function-tool cases use th When the host requires a SOCKS proxy, the runner installs `httpx[socks]` as a test-harness dependency without changing the SDK's published requirements. Set `OPENAI_AGENTS_INTEGRATION_DISABLE_PROXY=1` when the selected environment should connect without inherited proxy settings. -Set `OPENAI_AGENTS_INTEGRATION_STRICT=1` to fail rather than skip when a requested live feature is not configured. The release and security profiles enable strict mode unconditionally. Integration tests never run as part of ordinary `make tests`. +Set `OPENAI_AGENTS_INTEGRATION_STRICT=1` to fail rather than skip when a requested live feature is not configured. The containers, release, and security profiles enable strict mode unconditionally. Integration tests never run as part of ordinary `make tests`. The security profile requires a reachable local Docker daemon and pulls `busybox:1.36.1` by default. Set `OPENAI_AGENTS_INTEGRATION_SECURITY_IMAGE` to use a pre-approved replacement image. An unavailable daemon or image is a failure for both focused security and release-candidate runs. +The containers profile requires a reachable local Docker daemon and the configured BusyBox, Redis, and Dapr images. The GitHub Actions job disables Testcontainers Ryuk because GitHub-hosted runners are ephemeral and the tests explicitly remove their containers. Local runs keep the default Testcontainers cleanup behavior. An unavailable daemon, dependency, or image fails the containers profile instead of becoming a skip. + Each live test has a 75-second timeout so a stalled provider connection cannot block a release review indefinitely. Each isolated environment writes a JUnit report to `.tmp/integration-tests/results//`. The runner also prints pass, failure, error, skip, and deselection counts for every profile/environment pair. Pytest output and raw provider payloads are not attached to passing JUnit cases. diff --git a/tests/extensions/memory/test_dapr_redis_integration.py b/integration_tests/containers/test_dapr_redis.py similarity index 78% rename from tests/extensions/memory/test_dapr_redis_integration.py rename to integration_tests/containers/test_dapr_redis.py index 68e04d4392..b12907432b 100644 --- a/tests/extensions/memory/test_dapr_redis_integration.py +++ b/integration_tests/containers/test_dapr_redis.py @@ -4,7 +4,7 @@ These tests use Docker containers for both Redis and Dapr, with proper networking. Tests are automatically skipped if dependencies (dapr, testcontainers, docker) are not available. -Run with: pytest tests/extensions/memory/test_dapr_redis_integration.py -v +Run with: make integration-tests-containers """ from __future__ import annotations @@ -18,11 +18,11 @@ import time import urllib.request -import docker # type: ignore[import-untyped] import pytest -from docker.errors import DockerException # type: ignore[import-untyped] +from openai.types.responses import ResponseOutputItem, ResponseOutputMessage, ResponseOutputText # Skip tests if dependencies are not available +pytest.importorskip("docker") pytest.importorskip("dapr") # Skip tests if Dapr is not installed pytest.importorskip("testcontainers") # Skip if testcontainers is not installed if sys.platform == "win32": @@ -30,11 +30,8 @@ "Dapr Docker integration tests are not supported on Windows", allow_module_level=True, ) -if shutil.which("docker") is None: - pytest.skip( - "Docker executable is not available; skipping Dapr integration tests", - allow_module_level=True, - ) +import docker # type: ignore[import-untyped] +from docker.errors import DockerException # type: ignore[import-untyped] from testcontainers.core.container import DockerContainer # type: ignore[import-untyped] from testcontainers.core.network import Network # type: ignore[import-untyped] from testcontainers.core.waiting_utils import wait_for_logs # type: ignore[import-untyped] @@ -46,15 +43,44 @@ DaprSession, ) from agents.testing import ScriptedModel -from tests.test_responses import get_text_message -# Docker-backed integration tests should stay on the exclusive serial test path. -pytestmark = [pytest.mark.asyncio, pytest.mark.review_optional, pytest.mark.serial] +DAPR_IMAGE = ( + "daprio/daprd:1.16.2@sha256:3ae30141b9775b5bc03d073185abf1101fbad1e1941c1c3075527bc4865454e3" +) + +# The first test owns module-scoped image pulls and up to 150 seconds of readiness waits. +pytestmark = [pytest.mark.asyncio, pytest.mark.containers, pytest.mark.timeout(300)] + + +def get_text_message(content: str) -> ResponseOutputItem: + return ResponseOutputMessage( + id="1", + type="message", + role="assistant", + content=[ + ResponseOutputText( + text=content, + type="output_text", + annotations=[], + logprobs=[], + ) + ], + status="completed", + ) + + +def disable_dapr_sdk_health_check(monkeypatch: pytest.MonkeyPatch) -> None: + """Use the fixture's dynamic-port health checks across supported Dapr SDK versions.""" + from dapr.clients.health import DaprHealth + + for method_name in ("wait_until_ready", "wait_for_sidecar"): + if hasattr(DaprHealth, method_name): + monkeypatch.setattr(DaprHealth, method_name, lambda: None) @pytest.fixture(scope="module", autouse=True) def require_docker_daemon(): - """Skip the selected Dapr tests when the Docker daemon is unavailable.""" + """Skip the selected Dapr tests when the Docker SDK cannot reach its daemon.""" client = None try: client = docker.from_env() @@ -135,9 +161,9 @@ def redis_container(docker_network): .with_network_aliases("redis") .with_exposed_ports(6379) ) - container.start() - wait_for_logs(container, "Ready to accept connections", timeout=30) try: + container.start() + wait_for_logs(container, "Ready to accept connections", timeout=30) yield container finally: container.stop() @@ -146,16 +172,18 @@ def redis_container(docker_network): @pytest.fixture(scope="module") def dapr_container(redis_container, docker_network): """Start Dapr sidecar container with Redis state store configuration.""" - # Create temporary components directory temp_dir = tempfile.mkdtemp() - os.chmod(temp_dir, 0o755) - components_path = os.path.join(temp_dir, "components") - os.makedirs(components_path, exist_ok=True) - os.chmod(components_path, 0o755) - - # Write Redis state store component configuration - # KEY: Use 'redis:6379' (network alias), NOT localhost! - state_store_config = """ + container: DockerContainer | None = None + try: + # Create temporary components directory + os.chmod(temp_dir, 0o755) + components_path = os.path.join(temp_dir, "components") + os.makedirs(components_path, exist_ok=True) + os.chmod(components_path, 0o755) + + # Write Redis state store component configuration + # KEY: Use 'redis:6379' (network alias), NOT localhost! + state_store_config = """ apiVersion: dapr.io/v1alpha1 kind: Component metadata: @@ -171,65 +199,60 @@ def dapr_container(redis_container, docker_network): - name: actorStateStore value: "false" """ - state_store_path = os.path.join(components_path, "statestore.yaml") - with open(state_store_path, "w") as f: - f.write(state_store_config) - os.chmod(state_store_path, 0o644) - - # Create Dapr container - container = DockerContainer("daprio/daprd:latest") - container = container.with_network(docker_network) # Join the same network - container = container.with_volume_mapping(components_path, "/components", mode="ro") - container = container.with_command( - [ - "./daprd", - "-app-id", - "test-app", - "-dapr-http-port", - "3500", # HTTP API port for health checks - "-dapr-grpc-port", - "50001", - "-resources-path", - "/components", - "-log-level", - "info", - ] - ) - container = container.with_exposed_ports(3500, 50001) # Expose both ports - - container.start() - - # Get the exposed HTTP port and host - http_host = container.get_container_host_ip() - http_port = container.get_exposed_port(3500) - - # Wait for Dapr to become healthy - if not wait_for_dapr_health(http_host, http_port, timeout=60): - container.stop() - pytest.fail("Dapr container failed to become healthy") - - if not wait_for_dapr_component(http_host, http_port, "statestore", timeout=60): - logs = container.get_wrapped_container().logs().decode("utf-8", errors="replace") - container.stop() - pytest.fail(f"Dapr state store component failed to load.\nContainer logs:\n{logs}") + state_store_path = os.path.join(components_path, "statestore.yaml") + with open(state_store_path, "w") as f: + f.write(state_store_config) + os.chmod(state_store_path, 0o644) + + # Create Dapr container + container = DockerContainer(DAPR_IMAGE) + container = container.with_network(docker_network) # Join the same network + container = container.with_volume_mapping(components_path, "/components", mode="ro") + container = container.with_command( + [ + "./daprd", + "-app-id", + "test-app", + "-dapr-http-port", + "3500", # HTTP API port for health checks + "-dapr-grpc-port", + "50001", + "-resources-path", + "/components", + "-log-level", + "info", + ] + ) + container = container.with_exposed_ports(3500, 50001) # Expose both ports - # Set environment variables for Dapr SDK health checks - # The Dapr SDK checks these when creating a client - os.environ["DAPR_HTTP_PORT"] = str(http_port) - os.environ["DAPR_RUNTIME_HOST"] = http_host + container.start() - yield container + # Get the exposed HTTP port and host + http_host = container.get_container_host_ip() + http_port = container.get_exposed_port(3500) - # Cleanup environment variables - os.environ.pop("DAPR_HTTP_PORT", None) - os.environ.pop("DAPR_RUNTIME_HOST", None) + # Wait for Dapr to become healthy + if not wait_for_dapr_health(http_host, http_port, timeout=60): + pytest.fail("Dapr container failed to become healthy") - container.stop() + if not wait_for_dapr_component(http_host, http_port, "statestore", timeout=60): + logs = container.get_wrapped_container().logs().decode("utf-8", errors="replace") + pytest.fail(f"Dapr state store component failed to load.\nContainer logs:\n{logs}") - # Cleanup - import shutil + # Set environment variables for Dapr SDK health checks + # The Dapr SDK checks these when creating a client + os.environ["DAPR_HTTP_PORT"] = str(http_port) + os.environ["DAPR_RUNTIME_HOST"] = http_host - shutil.rmtree(temp_dir, ignore_errors=True) + yield container + finally: + os.environ.pop("DAPR_HTTP_PORT", None) + os.environ.pop("DAPR_RUNTIME_HOST", None) + try: + if container is not None: + container.stop() + finally: + shutil.rmtree(temp_dir, ignore_errors=True) @pytest.fixture @@ -245,10 +268,7 @@ async def test_dapr_redis_integration(dapr_container, monkeypatch): dapr_port = dapr_container.get_exposed_port(50001) dapr_address = f"{dapr_host}:{dapr_port}" - # Monkeypatch the Dapr health check since we already verified it in the fixture - from dapr.clients.health import DaprHealth - - monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None) + disable_dapr_sdk_health_check(monkeypatch) # Create session using from_address session = DaprSession.from_address( @@ -303,9 +323,7 @@ async def test_dapr_redis_integration(dapr_container, monkeypatch): async def test_dapr_runner_integration(agent: Agent, dapr_container, monkeypatch): """Test DaprSession with agent Runner using real Dapr sidecar.""" - from dapr.clients.health import DaprHealth - - monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None) + disable_dapr_sdk_health_check(monkeypatch) dapr_host = dapr_container.get_container_host_ip() dapr_port = dapr_container.get_exposed_port(50001) @@ -346,9 +364,7 @@ async def test_dapr_runner_integration(agent: Agent, dapr_container, monkeypatch async def test_dapr_session_isolation(dapr_container, monkeypatch): """Test that different session IDs are isolated with real Dapr.""" - from dapr.clients.health import DaprHealth - - monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None) + disable_dapr_sdk_health_check(monkeypatch) dapr_host = dapr_container.get_container_host_ip() dapr_port = dapr_container.get_exposed_port(50001) @@ -392,9 +408,7 @@ async def test_dapr_session_isolation(dapr_container, monkeypatch): async def test_dapr_ttl_functionality(dapr_container, monkeypatch): """Test TTL functionality with real Dapr and Redis (if supported by state store).""" - from dapr.clients.health import DaprHealth - - monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None) + disable_dapr_sdk_health_check(monkeypatch) dapr_host = dapr_container.get_container_host_ip() dapr_port = dapr_container.get_exposed_port(50001) @@ -431,9 +445,7 @@ async def test_dapr_ttl_functionality(dapr_container, monkeypatch): async def test_dapr_consistency_levels(dapr_container, monkeypatch): """Test different consistency levels with real Dapr.""" - from dapr.clients.health import DaprHealth - - monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None) + disable_dapr_sdk_health_check(monkeypatch) dapr_host = dapr_container.get_container_host_ip() dapr_port = dapr_container.get_exposed_port(50001) @@ -479,9 +491,7 @@ async def test_dapr_consistency_levels(dapr_container, monkeypatch): async def test_dapr_unicode_and_special_chars(dapr_container, monkeypatch): """Test unicode and special characters with real Dapr and Redis.""" - from dapr.clients.health import DaprHealth - - monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None) + disable_dapr_sdk_health_check(monkeypatch) dapr_host = dapr_container.get_container_host_ip() dapr_port = dapr_container.get_exposed_port(50001) @@ -525,9 +535,7 @@ async def test_dapr_concurrent_writes_resolution(dapr_container, monkeypatch): Concurrent writes from multiple session instances should resolve via optimistic concurrency. """ - from dapr.clients.health import DaprHealth - - monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None) + disable_dapr_sdk_health_check(monkeypatch) dapr_host = dapr_container.get_container_host_ip() dapr_port = dapr_container.get_exposed_port(50001) diff --git a/integration_tests/pytest.ini b/integration_tests/pytest.ini index 18d7353157..28884d5a5e 100644 --- a/integration_tests/pytest.ini +++ b/integration_tests/pytest.ini @@ -11,6 +11,7 @@ markers = packaging_dependency: Released API contracts with a declared optional dependency installed. distribution_smoke: Small live core smoke tests that also run from an installed sdist. security: Packaged redaction and local adversarial sandbox contracts. + containers: Local Docker-backed integration contracts. mcp_compat: Packaged MCP client compatibility across supported dependency versions. extras: Independently installed optional dependency groups. core: Live OpenAI Responses and Chat Completions coverage. diff --git a/integration_tests/security/test_local_sandbox_isolation.py b/integration_tests/security/test_local_sandbox_isolation.py index fd932787ef..5c6b09a102 100644 --- a/integration_tests/security/test_local_sandbox_isolation.py +++ b/integration_tests/security/test_local_sandbox_isolation.py @@ -110,6 +110,7 @@ def _assert_complete_filesystem_inspection(output: str) -> None: assert "--- filesystem complete ---\n--- processes ---" in output +@pytest.mark.containers @pytest.mark.parametrize("fail_after_inspection", [False, True], ids=["success", "model-failure"]) async def test_runner_owned_local_sandbox_cannot_inspect_trusted_client_credential( caplog: pytest.LogCaptureFixture, diff --git a/pyproject.toml b/pyproject.toml index db7a3a6195..4ae4de4d16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,6 @@ dev = [ "fakeredis>=2.31.3", "dapr>=1.14.0", "grpcio>=1.60.0", - "testcontainers==4.12.0", # pinned to 4.12.0 because 4.13.0 has a warning bug in wait_for_logs, see https://github.com/testcontainers/testcontainers-python/issues/874 "pyright==1.1.408", "pymongo>=4.14", ] diff --git a/tests/README.md b/tests/README.md index 2d2ef85fa3..141b17d3bd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -45,7 +45,7 @@ make tests-parallel Compare test counts, skips, warnings, assertions, and lifecycle coverage as well as elapsed time. Full-suite wall-clock results depend on host load and worker scheduling, so treat repeated focused measurements as the stronger evidence for an individual optimization. Run the repository's required verification stack after the final test changes. -Release compatibility unit tests must exercise policy and validation logic with explicit constructed modules instead of inspecting the current checkout's shared import state. The prospective release-contract job validates the current source checkout once in a dedicated Python process, and the packaged integration profiles validate real wheel, sdist, optional-extra, and platform surfaces in isolated environments. Keep the combined serial focused runtime of release compatibility unit tests below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. +Release compatibility unit tests must exercise policy and validation logic with explicit constructed modules instead of inspecting the current checkout's shared import state. The prospective release-contract job validates the current source checkout once in a dedicated Python process, and the packaged integration profiles validate real wheel, sdist, optional-extra, and platform surfaces in isolated environments. Keep the combined serial focused runtime of release compatibility unit tests below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. Run the concrete local Docker-backed contracts with `make integration-tests-containers`; ordinary `make tests` must not pull container images. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable top-level class or function signature and execution kind, and every newly exported SDK-owned inspectable class or function in a tracked public submodule. Qualified submodule callables remain tracked in later releases. Callable contracts include Pydantic model field names and defaults plus the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented class properties or factory-result properties to `public_properties`, selected public `TypedDict` fields to `public_typed_dicts`, newly intended cross-module import identities to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator records each selected `TypedDict` field's requiredness and declared annotation without enrolling arbitrary `TypedDict` classes or fields. It merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index c98cdcbe37..53324da279 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -40,6 +40,7 @@ def test_every_integration_profile_has_exactly_one_credential_class() -> None: "packaging", "prospective-contract", "prospective-platform", + "containers", "security", "mcp-v1", "extras", @@ -87,7 +88,15 @@ def test_live_profiles_refuse_untrusted_credentials_before_side_effects( @pytest.mark.parametrize( "profile", - ["packaging", "prospective-contract", "prospective-platform", "security", "mcp-v1", "extras"], + [ + "packaging", + "prospective-contract", + "prospective-platform", + "containers", + "security", + "mcp-v1", + "extras", + ], ) def test_local_only_profiles_remove_key_before_uv_child_process( profile: str, monkeypatch: pytest.MonkeyPatch @@ -384,6 +393,50 @@ def fake_run_pytest(command: list[str], *, env: dict[str, str]) -> tuple[int, st ) +@pytest.mark.parametrize( + ("profile", "ignores_container_directory"), + [ + ("mcp-v1", True), + ("prospective-contract", True), + ("containers", False), + ], +) +def test_only_containers_profile_collects_container_modules( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + profile: str, + ignores_container_directory: bool, +) -> None: + run_suite = _run_suite() + commands: list[list[str]] = [] + + def fake_run_pytest(command: list[str], *, env: dict[str, str]) -> tuple[int, str]: + _ = env + commands.append(command) + result_path = Path(command[-1].removeprefix("--junitxml=")) + result_path.write_text( + '' + '', + encoding="utf-8", + ) + return 0, "1 passed" + + monkeypatch.setitem(run_suite.__globals__, "RESULTS", tmp_path) + monkeypatch.setitem(run_suite.__globals__, "run_pytest", fake_run_pytest) + + run_suite( + tmp_path / "python", + tmp_path / "candidate.whl", + tmp_path / "candidate.tar.gz", + selection="containers" if profile == "containers" else "packaging", + environment_kind=profile, + profile=profile, + ) + + ignore_argument = f"--ignore={run_suite.__globals__['TESTS'] / 'containers'}" + assert (ignore_argument in commands[0]) is ignores_container_directory + + @pytest.mark.parametrize( ("profile", "strict"), [("release", True), ("security", True), ("packaging", False)], @@ -596,6 +649,58 @@ def fake_run_suite(*args: object, **kwargs: Any) -> None: assert all(suite["require_no_skips"] is True for suite in dependency_suites) +def test_containers_profile_runs_one_strict_wheel_environment( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + namespace = runpy.run_path(str(RUNNER)) + main = cast(Callable[[], None], namespace["main"]) + wheel = tmp_path / "candidate.whl" + sdist = tmp_path / "candidate.tar.gz" + created: list[tuple[str, Path, str | None, tuple[str, ...]]] = [] + suites: list[dict[str, Any]] = [] + + def fake_create_environment( + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), + ) -> Path: + assert extras is False + created.append((name, distribution, optional_extra, additional_requirements)) + return tmp_path / name / "python" + + monkeypatch.setenv("OPENAI_AGENTS_INTEGRATION_STRICT", "0") + monkeypatch.setattr(sys, "argv", [str(RUNNER), "--profile", "containers"]) + monkeypatch.setitem(main.__globals__, "build_distributions", lambda: (wheel, sdist)) + monkeypatch.setitem(main.__globals__, "create_environment", fake_create_environment) + monkeypatch.setitem( + main.__globals__, "run_suite", lambda *args, **kwargs: suites.append(kwargs) + ) + monkeypatch.setattr(main.__globals__["shutil"], "rmtree", lambda *args, **kwargs: None) + + main() + + assert os.environ["OPENAI_AGENTS_INTEGRATION_STRICT"] == "1" + assert created == [ + ( + "containers", + wheel, + "dapr,docker", + (namespace["TESTCONTAINERS_REQUIREMENT"],), + ) + ] + assert suites == [ + { + "selection": "containers", + "environment_kind": "containers", + "profile": "containers", + } + ] + + def test_release_profile_enforces_strict_security_for_wheel_and_sdist( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -666,7 +771,7 @@ def fake_run_suite(*args: object, **kwargs: Any) -> None: for suite in dependency_suites ) assert all(suite["require_no_skips"] is True for suite in dependency_suites) - assert namespace["STRICT_PROFILES"] == frozenset({"release", "security"}) + assert namespace["STRICT_PROFILES"] == frozenset({"containers", "release", "security"}) @pytest.mark.parametrize("platform", ["linux", "win32"]) diff --git a/tests/test_repository_workflow_interfaces.py b/tests/test_repository_workflow_interfaces.py index 3df2f936a0..0af4d6dc0b 100644 --- a/tests/test_repository_workflow_interfaces.py +++ b/tests/test_repository_workflow_interfaces.py @@ -6,6 +6,8 @@ ROOT = Path(__file__).resolve().parents[1] MAKEFILE = ROOT / "Makefile" +TESTS_WORKFLOW = ROOT / ".github" / "workflows" / "tests.yml" +DAPR_REDIS_TEST = ROOT / "integration_tests" / "containers" / "test_dapr_redis.py" EXAMPLE_RUNNER = ROOT / ".github" / "scripts" / "run_examples.sh" EXAMPLE_SUITE = ROOT / "examples" / "run_examples.py" SKILLS = ROOT / ".agents" / "skills" @@ -26,6 +28,14 @@ def _make_recipes() -> dict[str, str]: return recipes +def _workflow_job(name: str) -> str: + workflow = TESTS_WORKFLOW.read_text(encoding="utf-8") + job_pattern = rf"(?ms)^ {re.escape(name)}:\n(?P.*?)(?=^ [a-z0-9-]+:\n|\Z)" + match = re.search(job_pattern, workflow) + assert match is not None + return match.group("body") + + def test_examples_run_analysis_skill_has_no_execution_path() -> None: analysis_skill = SKILLS / "examples-run-analysis" assert not (SKILLS / "examples-auto-run").exists() @@ -123,6 +133,34 @@ def test_all_make_integration_entry_points_use_classified_profiles() -> None: assert profile.group(1) in classified_profiles +def test_container_integration_has_one_non_matrix_workflow_job() -> None: + containers_job = _workflow_job("containers") + tests_job = _workflow_job("tests") + + assert "matrix:" not in containers_job + assert 'python-version: "3.14"' in containers_job + assert 'TESTCONTAINERS_RYUK_DISABLED: "true"' in containers_job + assert containers_job.count("make integration-tests-containers") == 1 + assert "integration-tests-containers" not in tests_job + + +def test_container_integration_pins_dapr_runtime_image() -> None: + source = DAPR_REDIS_TEST.read_text(encoding="utf-8") + + assert ( + '"daprio/daprd:1.16.2@sha256:' + '3ae30141b9775b5bc03d073185abf1101fbad1e1941c1c3075527bc4865454e3"' in source + ) + assert "daprio/daprd:latest" not in source + + +def test_container_integration_does_not_require_docker_cli() -> None: + source = DAPR_REDIS_TEST.read_text(encoding="utf-8") + + assert 'shutil.which("docker")' not in source + assert "client.ping()" in source + + def test_prospective_contract_preparation_removes_api_key_before_uv() -> None: recipe = _make_recipes()["prepare-prospective-released-api-contract"] diff --git a/uv.lock b/uv.lock index 8db8ff84b8..fa9d8eaee0 100644 --- a/uv.lock +++ b/uv.lock @@ -2650,7 +2650,6 @@ dev = [ { name = "rich" }, { name = "ruff" }, { name = "sounddevice" }, - { name = "testcontainers" }, { name = "textual" }, { name = "types-pynput" }, { name = "websockets" }, @@ -2723,7 +2722,6 @@ dev = [ { name = "rich", specifier = ">=13.1.0,<15" }, { name = "ruff", specifier = "==0.9.2" }, { name = "sounddevice" }, - { name = "testcontainers", specifier = "==4.12.0" }, { name = "textual" }, { name = "types-pynput" }, { name = "websockets" }, @@ -4071,22 +4069,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/9b/c50840a26af3587c0c8d9af04d9976743e22496996dc1a377efc75dcd316/temporalio-1.26.0-cp310-abi3-win_amd64.whl", hash = "sha256:1c4a0d82f0a3796cbf78864c799f8dca0b94cdaec68e7b8b224c859005686ec4", size = 14525849, upload-time = "2026-04-15T23:42:57.589Z" }, ] -[[package]] -name = "testcontainers" -version = "4.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docker" }, - { name = "python-dotenv" }, - { name = "typing-extensions" }, - { name = "urllib3" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/62/01d9f648e9b943175e0dcddf749cf31c769665d8ba08df1e989427163f33/testcontainers-4.12.0.tar.gz", hash = "sha256:13ee89cae995e643f225665aad8b200b25c4f219944a6f9c0b03249ec3f31b8d", size = 66631, upload-time = "2025-07-21T20:32:26.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/e8/9e2c392e5d671afda47b917597cac8fde6a452f5776c4c9ceb93fbd2889f/testcontainers-4.12.0-py3-none-any.whl", hash = "sha256:26caef57e642d5e8c5fcc593881cf7df3ab0f0dc9170fad22765b184e226ab15", size = 111791, upload-time = "2025-07-21T20:32:25.038Z" }, -] - [[package]] name = "textual" version = "8.2.3" From 10cdae4a3c30a29c6e96c8ec14e6bf1c5f02940e Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 26 Aug 2026 20:05:05 -0400 Subject: [PATCH 429/473] [agents] Reject unpaired function outputs in Chat Completions (#4699) --- src/agents/models/chatcmpl_converter.py | 9 +++- tests/models/test_openai_chatcompletions.py | 52 ++++++++++++++++++ tests/models/test_openai_responses.py | 58 +++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 68569578c8..87b55a9e26 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -818,6 +818,13 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: asst["tool_calls"] = tool_calls # 5) function call output => tool message elif func_output := cls.maybe_function_tool_call_output(item): + call_id = func_output.get("call_id") + if call_id is None: + raise UserError( + "Unpaired function outputs are supported by Responses but cannot be " + "converted to Chat Completions tool messages. " + "Use a Responses model to preserve this input." + ) flush_assistant_message() output_content = cast( str | Iterable[ResponseInputContentWithAudioParam], func_output["output"] @@ -849,7 +856,7 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: tool_result_content = _OMITTED_TOOL_OUTPUT_PLACEHOLDER msg: ChatCompletionToolMessageParam = { "role": "tool", - "tool_call_id": func_output["call_id"], + "tool_call_id": call_id, "content": tool_result_content, # type: ignore[typeddict-item] } result.append(msg) diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index c3d196b595..cd3c34d32d 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -743,6 +743,58 @@ async def patched_fetch_response(self, *args, **kwargs): ) +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("call_id_fields", [{}, {"call_id": None}], ids=["omitted", "null"]) +@pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"]) +@pytest.mark.parametrize("strict_feature_validation", [False, True], ids=["default", "strict"]) +async def test_unpaired_function_output_rejected_before_chat_request( + call_id_fields: dict[str, None], stream: bool, strict_feature_validation: bool +) -> None: + """Unpaired Responses context cannot become a Chat Completions tool message.""" + requests: list[httpx2.Request] = [] + + async def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + raise AssertionError("Unpaired outputs must not reach Chat Completions") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http_client: + model = OpenAIChatCompletionsModel( + model="gpt-4", + openai_client=AsyncOpenAI(api_key="test-key", http_client=http_client), + strict_feature_validation=strict_feature_validation, + ) + request_kwargs: dict[str, Any] = { + "system_instructions": None, + "input": [ + { + "type": "function_call_output", + "name": "notifications", + "namespace": "slack", + "output": "Alice mentioned you in #deployments.", + **call_id_fields, + } + ], + "model_settings": ModelSettings(), + "tools": [], + "output_schema": None, + "handoffs": [], + "tracing": ModelTracing.DISABLED, + } + + with pytest.raises( + UserError, + match="Unpaired function outputs.*Chat Completions.*Use a Responses model", + ): + if stream: + async for _ in model.stream_response(**request_kwargs): + pass + else: + await model.get_response(**request_kwargs) + + assert requests == [] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_get_response_rejects_non_text_tool_output_in_strict_mode() -> None: diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 96ddfe4bed..6c0c7b68bf 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -117,6 +117,64 @@ async def handler(request: httpx2.Request) -> httpx2.Response: return requests +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("call_id_fields", [{}, {"call_id": None}], ids=["omitted", "null"]) +@pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"]) +async def test_unpaired_function_output_preserved_in_responses_request( + call_id_fields: dict[str, None], stream: bool +) -> None: + """Responses keeps external-context output and its source without inventing a call ID.""" + request_bodies: list[dict[str, Any]] = [] + + async def handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(json.loads(request.content)) + if stream: + event = _response_completed_frame("resp-id", sequence_number=0) + return httpx2.Response( + 200, + content=f"event: response.completed\ndata: {event}\n\n", + headers={"content-type": "text/event-stream"}, + ) + return httpx2.Response( + 200, + content=get_response_obj([]).model_dump_json(), + headers={"content-type": "application/json"}, + ) + + expected_input = { + "type": "function_call_output", + "name": "notifications", + "namespace": "slack", + "output": [ + {"type": "input_text", "text": "Alice mentioned you in #deployments."}, + {"type": "input_image", "image_url": "https://example.com/image.png"}, + ], + **call_id_fields, + } + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http_client: + model = OpenAIResponsesModel( + model="gpt-4", + openai_client=AsyncOpenAI(api_key="test-key", http_client=http_client), + ) + request_kwargs: dict[str, Any] = { + "system_instructions": None, + "input": [dict(expected_input)], + "model_settings": ModelSettings(), + "tools": [], + "output_schema": None, + "handoffs": [], + "tracing": ModelTracing.DISABLED, + } + if stream: + async for _ in model.stream_response(**request_kwargs): + pass + else: + await model.get_response(**request_kwargs) + + assert [body["input"] for body in request_bodies] == [[expected_input]] + + class DummyWSConnection: def __init__(self, frames: list[str]): self._frames = frames From 1ce2739c8cd59882f411ac79423ef6ec5952d62b Mon Sep 17 00:00:00 2001 From: XuQuanxin04 Date: Fri, 28 Aug 2026 06:08:36 +0800 Subject: [PATCH 430/473] fix(core): fix streamed response span losing response/input when consumer stops at completed event (#4692) --- src/agents/models/openai_responses.py | 6 +++ tests/models/test_openai_responses.py | 70 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 87029d83e5..b9c6c75013 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -710,6 +710,12 @@ async def stream_response( # Record before yielding the terminal event because consumers may # close the generator immediately after receiving it. span_response.span_data.usage = model_usage_to_span_usage(usage) + if tracing.include_data(): + # Same reason as usage: a consumer that stops at the terminal + # event closes the generator and never reaches the post-loop + # assignment, so record the model I/O before yielding. + span_response.span_data.response = chunk.response + span_response.span_data.input = input elif chunk_type in { "response.failed", "response.incomplete", diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 6c0c7b68bf..d6b787df9b 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -809,6 +809,76 @@ async def fake_fetch_response(*args: Any, **kwargs: Any) -> DummyHTTPStream: assert inner_stream.close_calls == 1 +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_span_records_io_when_consumer_stops_at_completed(monkeypatch): + """A consumer that stops at `response.completed` closes the generator. + + The SDK's own run loop does this (it wraps the model stream in `aclosing()` and + breaks once it sees the terminal event). Anything recorded only after the yield + loop therefore never runs for such a consumer, so the response and input must be + attached to the span before the terminal event is yielded, mirroring the existing + usage handling and the non-streamed `get_response` path. + """ + client = DummyWSClient() + model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type] + + class DummyHTTPStream: + def __init__(self, response): + self._response = response + self._yielded = False + + def __aiter__(self): + return self + + async def __anext__(self): + if self._yielded: + raise StopAsyncIteration + self._yielded = True + return ResponseCompletedEvent( + type="response.completed", + response=self._response, + sequence_number=0, + ) + + async def aclose(self) -> None: + return None + + response = get_response_obj( + [], + response_id="resp-stream-early-close", + usage=Usage(requests=1, input_tokens=10, output_tokens=4, total_tokens=14), + ) + inner_stream = DummyHTTPStream(response) + + async def fake_fetch_response(*args: Any, **kwargs: Any) -> DummyHTTPStream: + return inner_stream + + monkeypatch.setattr(model, "_fetch_response", fake_fetch_response) + + with trace(workflow_name="test"): + stream = model.stream_response( + system_instructions=None, + input="the user prompt", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.ENABLED, + ) + stream_agen = cast(Any, stream) + async for event in stream_agen: + if event.type == "response.completed": + break # stop consuming, as a caller watching for the terminal event would + await stream_agen.aclose() + + response_spans = [span for span in fetch_ordered_spans() if span.span_data.type == "response"] + assert len(response_spans) == 1 + assert response_spans[0].span_data.response is not None + assert response_spans[0].span_data.input is not None + assert response_spans[0].span_data.usage is not None + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_stream_response_normal_exhaustion_closes_inner_http_stream(monkeypatch): From 494ea5978778a4daa48b513419dc5786084802d4 Mon Sep 17 00:00:00 2001 From: Ayaan Gazali Date: Thu, 27 Aug 2026 15:09:48 -0700 Subject: [PATCH 431/473] fix(sandbox): keep unbounded UnixLocal workspace I/O off the event loop (#4700) --- src/agents/sandbox/sandboxes/unix_local.py | 15 +++++-- src/agents/sandbox/util/blocking_io.py | 40 ++++++++++++++++++ tests/sandbox/test_unix_local.py | 49 ++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 src/agents/sandbox/util/blocking_io.py diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 5cc77e2aff..ea7f83e9d8 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -62,6 +62,7 @@ from ..session.workspace_payloads import coerce_write_payload from ..snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot from ..types import ExecResult, ExposedPortEndpoint, Permissions, User +from ..util.blocking_io import run_blocking_workspace_io from ..util.tar_utils import ( UnsafeTarMemberError, safe_extract_tarfile, @@ -962,7 +963,7 @@ async def rm( try: if normalized.is_dir() and not normalized.is_symlink(): if recursive: - shutil.rmtree(normalized) + await run_blocking_workspace_io(shutil.rmtree, normalized) else: normalized.rmdir() else: @@ -1083,7 +1084,8 @@ async def persist_workspace(self) -> io.IOBase: skip = self._persist_workspace_skip_relpaths() buf = io.BytesIO() - try: + + def _archive_workspace() -> None: with tarfile.open(fileobj=buf, mode="w") as tar: tar.add( root, @@ -1098,6 +1100,9 @@ async def persist_workspace(self) -> io.IOBase: else ti ), ) + + try: + await run_blocking_workspace_io(_archive_workspace) except (tarfile.TarError, OSError) as e: raise WorkspaceArchiveReadError(path=root, cause=e) from e @@ -1106,7 +1111,8 @@ async def persist_workspace(self) -> io.IOBase: async def hydrate_workspace(self, data: io.IOBase) -> None: root = Path(self.state.manifest.root) - try: + + def _extract_workspace() -> None: root.mkdir(parents=True, exist_ok=True) with tarfile.open(fileobj=data, mode="r:*") as tar: safe_extract_tarfile( @@ -1114,6 +1120,9 @@ async def hydrate_workspace(self, data: io.IOBase) -> None: root=root, allow_external_symlink_targets=False, ) + + try: + await run_blocking_workspace_io(_extract_workspace) except UnsafeTarMemberError as e: raise WorkspaceArchiveWriteError( path=root, context={"reason": e.reason, "member": e.member}, cause=e diff --git a/src/agents/sandbox/util/blocking_io.py b/src/agents/sandbox/util/blocking_io.py new file mode 100644 index 0000000000..ff8b58a32e --- /dev/null +++ b/src/agents/sandbox/util/blocking_io.py @@ -0,0 +1,40 @@ +"""Run unbounded blocking workspace I/O off the event loop without abandoning it. + +`asyncio.to_thread()` does not stop its worker when the awaiting task is cancelled, so a +cancelled caller can return while the thread is still writing. Snapshot resume closes the +archive stream and clears the workspace root as soon as its await returns, which would let a +surviving worker extract into a workspace that is being deleted. + +Callers therefore keep waiting for the worker even while cancelled, matching the mutation +semantics the session backends already rely on in `agents.memory.sqlite_session`. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any, TypeVar + +_T = TypeVar("_T") + + +async def run_blocking_workspace_io(function: Callable[..., _T], /, *args: Any) -> _T: + """Run `function` in a worker thread and keep ownership until that worker finishes.""" + task = asyncio.ensure_future(asyncio.to_thread(function, *args)) + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.wait({task}) + except asyncio.CancelledError as exc: + if cancellation is None: + cancellation = exc + + try: + result = task.result() + except BaseException: + if cancellation is not None: + raise cancellation from None + raise + if cancellation is not None: + raise cancellation from None + return result diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index c8e9c654a1..fb13be3c51 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -1,7 +1,11 @@ from __future__ import annotations import asyncio +import io import signal +import tarfile +import threading +import time from pathlib import Path from types import SimpleNamespace from typing import cast @@ -464,3 +468,48 @@ async def test_rm_as_user_checks_permissions_then_uses_local_fs( assert session.exec_commands[0][4:6] == ("sh", "-lc") assert session.exec_commands[0][-2:] == (str(target), "0") assert not any(part.startswith("rm ") for part in session.exec_commands[0]) + + +@pytest.mark.asyncio +async def test_hydrate_workspace_cancellation_waits_for_the_extracting_worker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cancelled hydrate must not leave a worker writing into the workspace. + + `restore_snapshot_into_workspace_on_resume` closes the archive stream in a `finally` as + soon as its await returns, so if cancellation propagated while the extractor was still + running it would read a closed stream and write into a workspace resume then clears. + """ + workspace = tmp_path / "workspace" + session = _RecordingUnixLocalSession(workspace) + + started = threading.Event() + events: list[str] = [] + + def _slow_extract(tar: object, **kwargs: object) -> None: + _ = tar, kwargs + events.append("extract-start") + started.set() + time.sleep(0.2) + events.append("extract-end") + + monkeypatch.setattr(unix_local_module, "safe_extract_tarfile", _slow_extract) + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w"): + pass + buf.seek(0) + + task = asyncio.create_task(session.hydrate_workspace(buf)) + while not started.is_set(): + await asyncio.sleep(0.005) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + # The worker finished before the caller observed cancellation, so the archive stream and + # the workspace root are only released once nothing is still writing to them. + assert events == ["extract-start", "extract-end"] + assert not buf.closed From 1749d361dd7e1bbcd98c52d669499f75e5973a4f Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Fri, 28 Aug 2026 03:40:45 +0530 Subject: [PATCH 432/473] fix(core): apply **kwargs value annotation to each keyword value (#4714) --- src/agents/function_schema.py | 17 ++++++--------- tests/test_function_schema.py | 39 ++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index dd97ed2b30..299af35528 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -416,17 +416,12 @@ def function_schema( ) elif param.kind == param.VAR_KEYWORD: - # **kwargs handling - if get_origin(ann) is dict: - # e.g. def foo(**kwargs: dict[str, int]) - dict_args = get_args(ann) - if len(dict_args) == 2: - ann = dict[dict_args[0], dict_args[1]] # type: ignore - else: - ann = dict[str, Any] - else: - # e.g. def foo(**kwargs: int) -> Dict[str, int] - ann = dict[str, ann] # type: ignore + # **kwargs handling: a ``**kwargs: X`` annotation applies to each keyword *value* + # (PEP 484), so the collected container is always ``dict[str, X]``. Preserve the full + # annotation as the value type -- mirroring the variadic-positional handling above, + # where ``*args: X`` becomes ``list[X]`` (see #4655). A bare ``**kwargs`` has ``ann`` + # set to ``Any`` above, yielding ``dict[str, Any]``. + ann = dict[str, ann] # type: ignore fields[name] = ( ann, diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 0b7550fcf0..74e075ee7e 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -464,8 +464,9 @@ def func(*args: tuple[int, ...]) -> int: def test_var_keyword_dict_annotation(): # Case 3: - # When a function has a var-keyword parameter annotated with a dict type, - # function_schema() should convert it into a field with type Dict[, ]. + # A ``**kwargs: X`` annotation applies to each keyword *value* (PEP 484), so a + # ``**kwargs: dict[str, int]`` parameter collects into ``dict[str, dict[str, int]]`` -- + # mirroring the variadic-positional handling in test_var_positional_tuple_annotation. def func(**kwargs: dict[str, int]): return kwargs @@ -473,9 +474,37 @@ def func(**kwargs: dict[str, int]): properties = fs.params_json_schema.get("properties", {}) # The name of the field is "kwargs", and it's a JSON object i.e. a dict. - assert properties.get("kwargs").get("type") == "object" - # The values in the dict are integers. - assert properties.get("kwargs").get("additionalProperties").get("type") == "integer" + kwargs_schema = properties.get("kwargs", {}) + assert kwargs_schema.get("type") == "object" + # Each keyword value is itself a dict[str, int]. + value_schema = kwargs_schema.get("additionalProperties", {}) + assert value_schema.get("type") == "object" + assert value_schema.get("additionalProperties", {}).get("type") == "integer" + + # A correctly-shaped input round-trips back to the original call. + parsed = fs.params_pydantic_model.model_validate({"kwargs": {"a": {"x": 1}, "b": {"y": 2}}}) + args, kwargs = fs.to_call_args(parsed) + assert func(*args, **kwargs) == {"a": {"x": 1}, "b": {"y": 2}} + + # A flat mapping of ints no longer matches the declared value type. + with pytest.raises(ValidationError): + fs.params_pydantic_model.model_validate({"kwargs": {"a": 1, "b": 2}}) + + +def test_var_keyword_scalar_annotation(): + # A ``**kwargs: int`` parameter collects each keyword value as an int: ``dict[str, int]``. + def func(**kwargs: int) -> int: + return sum(kwargs.values()) + + fs = function_schema(func, use_docstring_info=False, strict_json_schema=False) + + kwargs_schema = fs.params_json_schema.get("properties", {}).get("kwargs", {}) + assert kwargs_schema.get("type") == "object" + assert kwargs_schema.get("additionalProperties", {}).get("type") == "integer" + + parsed = fs.params_pydantic_model.model_validate({"kwargs": {"a": 2, "b": 3}}) + args, kwargs = fs.to_call_args(parsed) + assert func(*args, **kwargs) == 5 def test_schema_with_mapping_raises_strict_mode_error(): From 374b54d75306025d84aef40277b3f9cd0e63da80 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:16:58 +0800 Subject: [PATCH 433/473] fix(sandbox/extensions): preserve UTF-8 boundaries in Cloudflare SSE (#4707) --- .../extensions/sandbox/cloudflare/sandbox.py | 7 ++- tests/extensions/sandbox/test_cloudflare.py | 43 ++++++++++++++++--- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index 8efad06a2e..bb8d7c37e6 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -222,8 +222,8 @@ def __init__(self) -> None: self._buf = b"" self._skip_leading_lf = False - def decode(self, text: str) -> list[str]: - data = text.encode("utf-8") + def decode(self, text: str | bytes) -> list[str]: + data = text.encode("utf-8") if isinstance(text, str) else text if self._skip_leading_lf and data: # The previous chunk ended on a CR, which already terminated its line. A LF # opening this chunk is the second half of that CRLF, so consume it instead of @@ -895,8 +895,7 @@ async def _exec_internal( async for chunk in resp.content.iter_any(): raw_stream.extend(chunk) - text = chunk.decode("utf-8") - for line in line_decoder.decode(text): + for line in line_decoder.decode(chunk): event = sse_decoder.decode(line) if event is None: continue diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index 0b912b1b57..bcd47257e1 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -78,17 +78,18 @@ async def __aexit__(self, *args: object) -> None: class _FakeStreamContent: - def __init__(self, data: bytes) -> None: - self._data = data + def __init__(self, data: bytes | list[bytes]) -> None: + self._chunks = [data] if isinstance(data, bytes) else data async def iter_any(self) -> Any: - yield self._data + for chunk in self._chunks: + yield chunk class _FakeSSEResponse: - def __init__(self, status: int, sse_body: bytes) -> None: + def __init__(self, status: int, sse_body: bytes, sse_chunks: list[bytes] | None = None) -> None: self.status = status - self.content = _FakeStreamContent(sse_body) + self.content = _FakeStreamContent(sse_body if sse_chunks is None else sse_chunks) async def json(self, *, content_type: str | None = None) -> Any: _ = content_type @@ -673,6 +674,28 @@ async def test_cloudflare_exec_decodes_sse_output() -> None: assert result.exit_code == 0 +@pytest.mark.asyncio +async def test_cloudflare_exec_handles_utf8_split_across_sse_chunks() -> None: + body = 'event: error\ndata: {"error":"失败"}\n\n'.encode() + split = body.index("失败".encode()) + 1 + sess = _make_session( + fake_http=_FakeHttp( + { + "POST /exec": _FakeSSEResponse( + status=200, + sse_body=b"", + sse_chunks=[body[:split], body[split:]], + ) + } + ) + ) + + with pytest.raises(ExecTransportError) as exc_info: + await sess._exec_internal("echo", "hello", timeout=5.0) + + assert str(exc_info.value.__cause__) == "失败" + + @pytest.mark.asyncio async def test_cloudflare_exec_applies_manifest_environment() -> None: fake_http = _FakeHttp({"POST /exec": _exec_ok_response(stdout="hello")}) @@ -2134,6 +2157,16 @@ def test_sse_line_decoder_delivers_a_cr_terminated_line_immediately() -> None: assert decoder.flush() == [] +def test_sse_line_decoder_handles_utf8_split_across_byte_chunks() -> None: + decoder = _SSELineDecoder() + stream = "data: 失败\n\n".encode() + split = stream.index("失败".encode()) + 1 + + assert decoder.decode(stream[:split]) == [] + assert decoder.decode(stream[split:]) == ["data: 失败", ""] + assert decoder.flush() == [] + + def test_sse_line_decoder_dispatches_a_cr_only_blank_line_without_more_input() -> None: """A CR-only blank line must dispatch its event without another chunk or EOF.""" decoder = _SSELineDecoder() From 09814320a13843196489de7e6be21d30d1e29ec4 Mon Sep 17 00:00:00 2001 From: Ayaan Gazali Date: Thu, 27 Aug 2026 16:18:54 -0700 Subject: [PATCH 434/473] fix(voice): redact TTS instructions from speech spans (#4676) --- src/agents/voice/result.py | 6 +++++- tests/voice/test_pipeline.py | 42 +++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 42af29e704..a01f7d762c 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -124,7 +124,11 @@ async def _stream_audio( input=text if self._voice_pipeline_config.trace_include_sensitive_data else "", model_config={ "voice": self.tts_settings.voice, - "instructions": self.instructions, + "instructions": ( + self.instructions + if self._voice_pipeline_config.trace_include_sensitive_data + else None + ), "speed": self.tts_settings.speed, }, output_format="pcm", diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 470cc9543e..02b825376d 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -15,7 +15,7 @@ import agents._debug as _debug from agents import trace -from tests.testing_processor import fetch_events, fetch_span_errors +from tests.testing_processor import fetch_events, fetch_ordered_spans, fetch_span_errors try: from agents.voice import ( @@ -587,6 +587,46 @@ async def run(self, text: str, settings: TTSModelSettings): ] +@pytest.mark.asyncio +@pytest.mark.parametrize("trace_include_sensitive_data", [True, False]) +async def test_speech_span_redacts_tts_instructions( + trace_include_sensitive_data: bool, +) -> None: + """TTS instructions are author-written prompt text, so they follow the same gate as input.""" + instructions = "sensitive-style-instructions" + received: list[str] = [] + + class RecordingTTS(ZeroPcmTTSModel): + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + received.append(settings.instructions) + yield np.zeros(2, dtype=np.int16).tobytes() + + result = StreamedAudioResult( + RecordingTTS(), + TTSModelSettings(instructions=instructions), + VoicePipelineConfig(trace_include_sensitive_data=trace_include_sensitive_data), + ) + local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() + + with trace("tts-instructions"): + await result._stream_audio("spoken text", local_queue) + + speech_spans = [span for span in fetch_ordered_spans() if span.span_data.type == "speech"] + assert len(speech_spans) == 1 + model_config = cast(dict[str, Any], speech_spans[0].span_data.model_config) + + if trace_include_sensitive_data: + assert model_config["instructions"] == instructions + assert speech_spans[0].span_data.input == "spoken text" + else: + assert model_config["instructions"] is None + assert speech_spans[0].span_data.input == "" + + # Non-sensitive settings stay visible either way, and the model still gets the real value. + assert model_config["speed"] == TTSModelSettings().speed + assert received == [instructions] + + @pytest.mark.asyncio async def test_streamed_audio_dispatcher_handles_stream_failure() -> None: """A failed _stream_audio task must not leave _dispatch_audio blocked forever.""" From 2b81a9e32708b276846a0cd3721c42e6fb4067e2 Mon Sep 17 00:00:00 2001 From: Rajarshi Datta <138959719+rajarshidattapy@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:56:12 +0530 Subject: [PATCH 435/473] fix(core): reject **kwargs keys that collide with named tool parameters (#4674) --- src/agents/function_schema.py | 42 +++++++++++++++- tests/test_function_schema.py | 93 ++++++++++++++++++++++++++++++++++- tests/test_function_tool.py | 23 +++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 299af35528..98e3200f07 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo -from .exceptions import UserError +from .exceptions import ModelBehaviorError, UserError from .run_context import RunContextWrapper from .strict_schema import ensure_strict_json_schema from .tool_context import ToolContext @@ -47,6 +47,11 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: """ Converts validated data from the Pydantic model into (args, kwargs), suitable for calling the original function. + + Raises: + ModelBehaviorError: If the ``**kwargs`` payload carries a key that names one of the + function's own keyword-bindable parameters. The schema allows it, but no Python + call expresses it. """ positional_args: list[Any] = [] keyword_args: dict[str, Any] = {} @@ -69,7 +74,9 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: seen_var_positional = True elif param.kind == param.VAR_KEYWORD: # e.g. **kwargs handling - keyword_args.update(value or {}) + var_keyword_values = value or {} + self._raise_on_var_keyword_collisions(name, var_keyword_values) + keyword_args.update(var_keyword_values) elif param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): # Before *args, add to positional args. After *args, add to keyword args. if not seen_var_positional: @@ -81,6 +88,37 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: keyword_args[name] = value return positional_args, keyword_args + def _raise_on_var_keyword_collisions( + self, var_keyword_name: str, var_keyword_values: dict[str, Any] + ) -> None: + """Reject ``**kwargs`` keys that name a parameter the call already binds by name. + + ``**kwargs`` is splatted last, so such a key either replaces the value the model + supplied for that parameter -- and Pydantic validated -- or makes the call fail with + "got multiple values for argument". Neither is what the schema promised, so treat it + as model misbehavior and say which keys clashed. + + Positional-only parameters and ``*args`` are deliberately not reserved: for + ``def f(a, /, **kw)``, the call ``f(1, a=2)`` is legal and routes ``a=2`` into ``kw``. + The names below only ever reveal the tool's own signature, which the model already + has, so they are safe to name even when tool data is redacted. + """ + reserved_names = { + name + for name, param in self.signature.parameters.items() + if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY) + } + conflicts = sorted(reserved_names.intersection(var_keyword_values)) + if not conflicts: + return + + conflict_list = ", ".join(repr(conflict) for conflict in conflicts) + raise ModelBehaviorError( + f"Invalid arguments for tool {self.name}: {conflict_list} " + f"{'is' if len(conflicts) == 1 else 'are'} both a named parameter and a key in " + f"'{var_keyword_name}'. Pass each argument once, as a named parameter." + ) + @dataclass class FuncDocumentation: diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 74e075ee7e..661fbcd06e 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -8,7 +8,7 @@ from typing_extensions import TypedDict from agents import RunContextWrapper, function_tool -from agents.exceptions import UserError +from agents.exceptions import ModelBehaviorError, UserError from agents.function_schema import function_schema, generate_func_documentation @@ -1212,3 +1212,94 @@ def test_default_equality_is_not_used_for_sentinel_comparison( parsed = fs.params_pydantic_model(x=1) args, kwargs = fs.to_call_args(parsed) assert isinstance((args + list(kwargs.values()))[-1], default_type) + + +def _kwargs_keyword_only(*, opt: int = 1, **kw: Any) -> tuple[int, dict[str, Any]]: + return opt, kw + + +def _kwargs_positional_or_keyword(x: int, *rest: int, **kw: Any) -> tuple[int, tuple[int, ...]]: + return x, rest + + +def _kwargs_after_var_positional(*rest: int, y: int = 0, **kw: Any) -> tuple[int, int]: + return y, len(rest) + + +def _kwargs_with_context(ctx: RunContextWrapper[str], n: int, **kw: Any) -> int: + return n + + +@pytest.mark.parametrize( + ("func", "payload", "conflict"), + [ + pytest.param( + _kwargs_keyword_only, + {"opt": 5, "kw": {"opt": 9}}, + "'opt'", + id="keyword-only", + ), + pytest.param( + _kwargs_positional_or_keyword, + {"x": 1, "rest": [2, 3], "kw": {"x": 99}}, + "'x'", + id="positional-or-keyword", + ), + pytest.param( + _kwargs_after_var_positional, + {"rest": [1], "y": 2, "kw": {"y": 3}}, + "'y'", + id="keyword-only-after-var-positional", + ), + pytest.param( + _kwargs_with_context, + {"n": 1, "kw": {"ctx": 9}}, + "'ctx'", + id="context-parameter", + ), + ], +) +def test_to_call_args_rejects_kwargs_keys_that_collide_with_named_params( + func: Callable[..., Any], payload: dict[str, Any], conflict: str +) -> None: + """A **kwargs key naming a keyword-bindable parameter is not a callable combination. + + Splatting it would either replace the validated value for that parameter or make the + call fail with "got multiple values for argument", so it is reported to the model. + """ + fs = function_schema(func, strict_json_schema=False) + parsed = fs.params_pydantic_model(**payload) + + with pytest.raises(ModelBehaviorError) as exc_info: + fs.to_call_args(parsed) + + assert conflict in str(exc_info.value) + assert fs.name in str(exc_info.value) + + +def _kwargs_positional_only(a: int, /, **kw: Any) -> tuple[int, dict[str, Any]]: + return a, kw + + +def _kwargs_var_positional_name(*rest: int, **kw: Any) -> tuple[tuple[int, ...], dict[str, Any]]: + return rest, kw + + +def test_to_call_args_allows_kwargs_key_matching_positional_only_param() -> None: + """``f(1, a=2)`` is legal for ``def f(a, /, **kw)``: the key belongs to ``**kw``.""" + fs = function_schema(_kwargs_positional_only, strict_json_schema=False) + parsed = fs.params_pydantic_model(**{"a": 1, "kw": {"a": 2}}) + + args, kwargs_dict = fs.to_call_args(parsed) + + assert _kwargs_positional_only(*args, **kwargs_dict) == (1, {"a": 2}) + + +def test_to_call_args_allows_kwargs_key_matching_var_positional_param() -> None: + """``*args`` binds no name, so a key of the same name belongs to ``**kw``.""" + fs = function_schema(_kwargs_var_positional_name, strict_json_schema=False) + parsed = fs.params_pydantic_model(**{"rest": [1], "kw": {"rest": 5}}) + + args, kwargs_dict = fs.to_call_args(parsed) + + assert _kwargs_var_positional_name(*args, **kwargs_dict) == ((1,), {"rest": 5}) diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index ecaba2a761..de03b2e6bc 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -1435,3 +1435,26 @@ def test_function_tool_timeout_error_function_must_be_callable() -> None: on_invoke_tool=_noop_on_invoke_tool, timeout_error_function=cast(Any, "not-callable"), ) + + +def kwargs_collision_function(x: int, *rest: int, **kw: Any) -> str: + return f"x={x} rest={rest} kw={kw}" + + +@pytest.mark.asyncio +async def test_kwargs_key_colliding_with_param_is_reported_as_model_behavior_error(): + """The collision reaches the model as feedback, not as an unhandled TypeError. + + ``kw={"x": 99}`` used to splat into the call as ``f(1, 2, 3, x=99)``, which raised + ``TypeError: got multiple values for argument 'x'`` from inside the tool call. + """ + tool = function_tool(kwargs_collision_function, strict_mode=False, failure_error_function=None) + arguments = '{"x": 1, "rest": [2, 3], "kw": {"x": 99}}' + + with pytest.raises(ModelBehaviorError) as exc_info: + await tool.on_invoke_tool( + ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=arguments), + arguments, + ) + + assert "'x'" in str(exc_info.value) From 287594c6ce2b723de162c6f77d7e60c2c3e686b8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2026 09:55:20 +0900 Subject: [PATCH 436/473] fix(sessions): recover resumed handoffs after session append failures (#4725) Co-authored-by: rajarshidattapy --- src/agents/run.py | 7 +- src/agents/run_internal/run_loop.py | 18 +- .../run_internal/session_persistence.py | 16 +- tests/test_run_impl_resume_paths.py | 182 +++++++++++++++++- 4 files changed, 210 insertions(+), 13 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index c23b6363cf..37dd66582e 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1135,7 +1135,12 @@ def _mark_response_hooks_started() -> None: generated_items=generated_items, session_items=session_items, ) - if isinstance(turn_result.next_step, NextStepInterruption): + if isinstance( + turn_result.next_step, + NextStepInterruption | NextStepHandoff, + ): + # Publish before the fallible append so a retry does not + # lose guardrail results for work that already ran. run_state._tool_input_guardrail_results = [ *tool_input_guardrail_results, *turn_result.tool_input_guardrail_results, diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 3c293b6ee8..3c7d9ee586 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1405,15 +1405,24 @@ async def _save_max_turns_items( break if isinstance(turn_result.next_step, NextStepHandoff): + if run_state is not None: + # `_accumulate_tool_guardrail_results` already folded this turn's + # results in; publish them before the fallible append. + run_state._tool_input_guardrail_results = list( + accepted_tool_input_guardrail_results + ) + run_state._tool_output_guardrail_results = list( + accepted_tool_output_guardrail_results + ) + current_agent = turn_result.next_step.new_agent + if run_state is not None: + run_state._current_agent = current_agent + _publish_streamed_result_agent(streamed_result, current_agent) await _save_resumed_items( list(turn_session_items), turn_result.model_response.response_id, store_setting, ) - current_agent = turn_result.next_step.new_agent - if run_state is not None: - run_state._current_agent = current_agent - _publish_streamed_result_agent(streamed_result, current_agent) if current_span is not None: current_span.finish(reset_current=True) current_span = None @@ -1421,7 +1430,6 @@ async def _save_max_turns_items( streamed_result._event_queue.put_nowait( AgentUpdatedStreamEvent(new_agent=current_agent) ) - run_state._current_step = NextStepRunAgain() if await _wait_for_streamed_turn_events_and_stop_if_cancelled( streamed_result ): diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index bfe500b544..2e53667e7e 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -61,7 +61,13 @@ strip_internal_input_item_metadata, ) from .oai_conversation import OpenAIServerConversationTracker -from .run_steps import NextStepInterruption, NextStepRunAgain, ProcessedResponse, SingleStepResult +from .run_steps import ( + NextStepHandoff, + NextStepInterruption, + NextStepRunAgain, + ProcessedResponse, + SingleStepResult, +) __all__ = [ "admit_pending_input", @@ -541,7 +547,13 @@ def update_run_state_after_resume( run_state._generated_items = generated_items if session_items is not None: run_state._session_items = list(session_items) - run_state._current_step = turn_result.next_step # type: ignore[assignment] + next_step = turn_result.next_step + if isinstance(next_step, NextStepHandoff): + # The target agent is already committed, so the rest of the turn is an ordinary + # "run again". Normalizing here, before the fallible Session append, lets the existing + # pending-write checkpoint carry the handoff batch without a new persisted step type. + next_step = NextStepRunAgain() + run_state._current_step = next_step # type: ignore[assignment] async def save_result_to_session( diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 6d1f82babf..8e6211ceab 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -9,10 +9,10 @@ from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage import agents.run as run_module -from agents import Agent, Runner, function_tool +from agents import Agent, Runner, function_tool, handoff from agents.agent import ToolsToFinalOutputResult from agents.agent_output import AgentOutputSchema -from agents.decorators import tool +from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail from agents.exceptions import UserError from agents.items import ( MessageOutputItem, @@ -38,6 +38,12 @@ ) from agents.run_state import RunState from agents.testing import ScriptedModel +from agents.tool import Tool +from agents.tool_guardrails import ( + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, +) from agents.usage import Usage from tests.test_responses import get_function_tool_call, get_text_message from tests.utils.hitl import ( @@ -88,12 +94,16 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: async def _run_session_resume( - agent: Agent[Any], value: str | RunState[Any], session: Session | None, streamed: bool + agent: Agent[Any], + value: str | RunState[Any], + session: Session | None, + streamed: bool, + hooks: RunHooks[Any] | None = None, ): config = RunConfig(tracing_disabled=True) if not streamed: - return await Runner.run(agent, value, session=session, run_config=config) - result = Runner.run_streamed(agent, value, session=session, run_config=config) + return await Runner.run(agent, value, session=session, run_config=config, hooks=hooks) + result = Runner.run_streamed(agent, value, session=session, run_config=config, hooks=hooks) async for _ in result.stream_events(): pass return result @@ -1049,3 +1059,165 @@ async def needs_ok(text: str) -> str: isinstance(item, ToolCallOutputItem) and item.output == "one" for item in result.new_step_items ) + + +async def _approved_handoff_session_state(streamed: bool): + """Pause on an approval-gated call that shares its response with a handoff.""" + effects: list[int] = [] + guardrail_calls: list[str] = [] + hook_calls: list[str] = [] + handoff_calls: list[str] = [] + + class CountingHooks(RunHooks[Any]): + async def on_tool_start( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + ) -> None: + hook_calls.append("tool-start") + + async def on_tool_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + result: object, + ) -> None: + hook_calls.append("tool-end") + + @tool_input_guardrail + def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + guardrail_calls.append("input") + return ToolGuardrailFunctionOutput.allow(output_info="input-checked") + + @tool_output_guardrail + def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: + guardrail_calls.append("output") + return ToolGuardrailFunctionOutput.allow(output_info="output-checked") + + @tool( + needs_approval=True, + tool_input_guardrails=[record_input], + tool_output_guardrails=[record_output], + ) + async def charge(amount: int) -> str: + effects.append(amount) + return "receipt-7" + + model = ScriptedModel( + [ + [ + get_function_tool_call("charge", '{"amount":7}', call_id="charge-1"), + get_function_tool_call("transfer_to_delegate", "{}", call_id="handoff-1"), + ], + [get_text_message("done")], + [get_text_message("fresh")], + ] + ) + delegate = Agent(name="delegate", model=model) + route = handoff(delegate, on_handoff=lambda _context: handoff_calls.append("handoff")) + agent = Agent(name="triage", model=model, tools=[charge], handoffs=[route]) + hooks = CountingHooks() + session = _FailingResumeSession() + paused = await _run_session_resume( + agent, + "charge 7 then hand off", + session, + streamed, + hooks, + ) + state = paused.to_state() + state.approve(state.get_interruptions()[0]) + return agent, model, session, state, effects, guardrail_calls, hook_calls, handoff_calls, hooks + + +def _call_pair(items: list[TResponseInputItem], call_id: str) -> list[str]: + return [ + str(item.get("type")) + for item in items + if isinstance(item, dict) and item.get("call_id") == call_id + ] + + +def _guardrail_output_info(state: RunState[Any]) -> tuple[list[Any], list[Any]]: + return ( + [item.output.output_info for item in state._tool_input_guardrail_results], + [item.output.output_info for item in state._tool_output_guardrail_results], + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failing_streamed,retry_streamed", [(False, False), (False, True), (True, False), (True, True)] +) +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "json"]) +@pytest.mark.parametrize("failure", ["before", "after"], ids=["atomic-failure", "lost-ack"]) +async def test_resumed_handoff_session_append_is_recovered_before_next_model( + failing_streamed: bool, retry_streamed: bool, round_trip: bool, failure: str +) -> None: + ( + agent, + model, + session, + state, + effects, + guardrail_calls, + hook_calls, + handoff_calls, + hooks, + ) = await _approved_handoff_session_state(failing_streamed) + session.failure = failure + if failing_streamed: + failed_result = Runner.run_streamed( + agent, + state, + session=session, + run_config=RunConfig(tracing_disabled=True), + hooks=hooks, + ) + with pytest.raises(RuntimeError) as error: + async for _ in failed_result.stream_events(): + pass + state = failed_result.to_state() + else: + with pytest.raises(RuntimeError) as error: + await _run_session_resume(agent, state, session, False, hooks) + assert error.value is session.error + assert effects == [7] + assert guardrail_calls == ["input", "output"] + assert hook_calls == ["tool-start", "tool-end"] + assert handoff_calls == ["handoff"] + assert len(model.calls) == 1 + assert _guardrail_output_info(state) == (["input-checked"], ["output-checked"]) + failed_payload = state.to_json() + pending_write = cast(dict[str, Any], failed_payload["pending_session_write"]) + pending_items = cast(list[TResponseInputItem], pending_write["items"]) + assert _call_pair(pending_items, "charge-1") == ["function_call_output"] + assert _call_pair(pending_items, "handoff-1") == ["function_call_output"] + if round_trip: + state = await RunState.from_json(agent, failed_payload) + assert _guardrail_output_info(state) == (["input-checked"], ["output-checked"]) + assert state._current_agent is not None and state._current_agent.name == "delegate" + + result = await _run_session_resume(agent, state, session, retry_streamed, hooks) + assert result.final_output == "done" + assert result.last_agent.name == "delegate" + assert effects == [7] + assert guardrail_calls == ["input", "output"] + assert hook_calls == ["tool-start", "tool-end"] + assert handoff_calls == ["handoff"] + assert [item.output.output_info for item in result.tool_input_guardrail_results] == [ + "input-checked" + ] + assert [item.output.output_info for item in result.tool_output_guardrail_results] == [ + "output-checked" + ] + assert len(model.calls) == 2 + expected_pair = ["function_call", "function_call_output"] + stored = await session.get_items() + assert _call_pair(stored, "charge-1") == expected_pair + assert _call_pair(stored, "handoff-1") == expected_pair + assert _call_pair(result.to_input_list(), "charge-1") == expected_pair + assert _call_pair(result.to_input_list(), "handoff-1") == expected_pair + assert "pending_session_write" not in result.to_state().to_json() From f1a806ad35071053f5248b38b58b8c0c40f67350 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2026 10:40:38 +0900 Subject: [PATCH 437/473] fix(ci): harden PyPI publishing and require manual release tags (#4726) --- .github/RELEASING.md | 25 +++ .github/scripts/verify_release.py | 70 ++++++++ .github/workflows/publish.yml | 75 +++++++-- .github/workflows/release-tag.yml | 84 ---------- AGENTS.md | 2 +- tests/test_release_provenance.py | 167 +++++++++++++++++++ tests/test_repository_workflow_interfaces.py | 48 +++++- 7 files changed, 372 insertions(+), 99 deletions(-) create mode 100644 .github/RELEASING.md create mode 100644 .github/scripts/verify_release.py delete mode 100644 .github/workflows/release-tag.yml create mode 100644 tests/test_release_provenance.py diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 0000000000..843d5cc1fe --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,25 @@ +# Publishing a release + +Release tags are created manually by authorized maintainers. Merging a release pull request does not create a tag. + +1. Merge the reviewed release pull request and record its actual merged commit SHA. +2. As an authorized maintainer, check that commit and its version before creating the tag. Replace the placeholders below: + + ```bash + RELEASE_VERSION="" + RELEASE_COMMIT="" + git fetch origin main --tags + git merge-base --is-ancestor "$RELEASE_COMMIT" origin/main + git show "${RELEASE_COMMIT}:pyproject.toml" + ``` + + Stop if any command fails or `project.version` differs from `RELEASE_VERSION`. Otherwise, create and push an annotated tag at that commit: + + ```bash + git tag -a "v${RELEASE_VERSION}" "$RELEASE_COMMIT" -m "Release v${RELEASE_VERSION}" + git push origin "refs/tags/v${RELEASE_VERSION}" + ``` + + If the tag already exists, stop and investigate. Do not overwrite, delete, or move an existing release tag. +3. Publish a GitHub Release using that existing tag and the reviewed release notes. This starts `.github/workflows/publish.yml`. +4. After the build succeeds, a designated reviewer confirms the release tag and commit and approves the `pypi` deployment. When Prevent self-review is enabled, another designated reviewer must approve. diff --git a/.github/scripts/verify_release.py b/.github/scripts/verify_release.py new file mode 100644 index 0000000000..3757c634a9 --- /dev/null +++ b/.github/scripts/verify_release.py @@ -0,0 +1,70 @@ +"""Check release metadata without executing code from the release commit.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +def git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def verify_release(repo: Path, tag: str, expected_sha: str) -> None: + if not re.fullmatch(r"[0-9a-f]{40}", expected_sha): + raise ValueError("The release event must provide a full commit SHA.") + if not tag.startswith("v"): + raise ValueError("Release tags must start with 'v'.") + tag_ref = f"refs/tags/{tag}" + git(repo, "check-ref-format", tag_ref) + + if git(repo, "rev-parse", "HEAD^{commit}") != expected_sha: + raise ValueError("The checkout does not match the release event commit.") + if git(repo, "rev-parse", f"{tag_ref}^{{commit}}") != expected_sha: + raise ValueError("The tag does not match the release event commit.") + git(repo, "merge-base", "--is-ancestor", expected_sha, "refs/remotes/origin/main") + + metadata = tomllib.loads(git(repo, "show", f"{expected_sha}:pyproject.toml")) + version = metadata.get("project", {}).get("version") + if not isinstance(version, str) or not version: + raise ValueError("Missing project.version in pyproject.toml.") + if tag != f"v{version}": + raise ValueError("The tag does not match project.version in pyproject.toml.") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--expected-sha", required=True) + args = parser.parse_args() + try: + verify_release(args.repo, args.tag, args.expected_sha) + except (ValueError, subprocess.CalledProcessError) as error: + if isinstance(error, subprocess.CalledProcessError): + print( + "Release validation failed: a required Git ref or ancestry check failed.", + file=sys.stderr, + ) + else: + print(f"Release validation failed: {error}", file=sys.stderr) + return 1 + print(f"Validated {args.tag} at {args.expected_sha} in main history.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a32e7eb23a..a30ae9177b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,33 +5,84 @@ on: types: - published -permissions: - contents: read +permissions: {} + +concurrency: + group: pypi-${{ github.event.release.tag_name }} + cancel-in-progress: false jobs: - publish: - environment: - name: pypi - url: https://pypi.org/p/openai-agents + build: permissions: - id-token: write # Important for trusted publishing to PyPI + contents: read runs-on: ubuntu-latest - env: - OPENAI_API_KEY: fake-for-tests + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - - name: Checkout repository + # Tag rules and environment protection remain the release authorization boundary. + - name: Checkout release validator from main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: refs/heads/main + path: control + persist-credentials: false + sparse-checkout: .github/scripts + - name: Checkout release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + path: release-source + fetch-depth: 0 + persist-credentials: false + - name: Setup Python for release validation + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.14" + - name: Validate release provenance + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_SHA: ${{ github.sha }} + run: >- + python -I control/.github/scripts/verify_release.py + --repo release-source --tag "$RELEASE_TAG" --expected-sha "$RELEASE_SHA" - name: Setup uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" - enable-cache: true - prune-cache: true + enable-cache: false python-version: "3.14" - name: Install dependencies + working-directory: release-source + env: + OPENAI_API_KEY: fake-for-tests run: make sync - name: Build package + working-directory: release-source run: uv build + - name: Store distributions + id: upload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: python-distributions-${{ github.run_attempt }} + path: release-source/dist/ + if-no-files-found: error + retention-days: 7 + + publish: + needs: build + environment: + name: pypi + url: https://pypi.org/p/openai-agents + permissions: + id-token: write + runs-on: ubuntu-latest + steps: + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.build.outputs.artifact-id }} + path: dist/ + merge-multiple: true - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml deleted file mode 100644 index e2e7d5fcc8..0000000000 --- a/.github/workflows/release-tag.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Tag release on merge - -on: - pull_request: - types: - - closed - branches: - - main - -permissions: - contents: write - -jobs: - tag-release: - if: >- - github.event.pull_request.merged == true && - github.event.pull_request.head.repo.full_name == github.repository && - startsWith(github.event.pull_request.head.ref, 'release/v') - runs-on: ubuntu-latest - steps: - - name: Validate merge commit - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - run: | - if [ -z "$MERGE_SHA" ]; then - echo "merge_commit_sha is empty; refusing to tag to avoid tagging the wrong commit." >&2 - exit 1 - fi - - name: Checkout merge commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.merge_commit_sha }} - - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: "3.14" - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Fetch tags - run: git fetch origin --tags --prune - - name: Resolve version - id: version - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | - python - <<'PY' - import os - import pathlib - import sys - import tomllib - - path = pathlib.Path("pyproject.toml") - data = tomllib.loads(path.read_text()) - version = data.get("project", {}).get("version") - if not version: - print("Missing project.version in pyproject.toml.", file=sys.stderr) - sys.exit(1) - - head_ref = os.environ.get("HEAD_REF", "") - if head_ref.startswith("release/v"): - expected = head_ref[len("release/v") :] - if expected != version: - print( - f"Version mismatch: branch {expected} vs pyproject {version}.", - file=sys.stderr, - ) - sys.exit(1) - - output_path = pathlib.Path(os.environ["GITHUB_OUTPUT"]) - output_path.write_text(f"version={version}\n") - PY - - name: Create tag - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - if git tag -l "v${VERSION}" | grep -q "v${VERSION}"; then - echo "Tag v${VERSION} already exists; skipping." - exit 0 - fi - git tag -a "v${VERSION}" -m "Release v${VERSION}" - git push origin "v${VERSION}" diff --git a/AGENTS.md b/AGENTS.md index dfc2ee1fb1..af392eafb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Producing the PR draft block is part of the local final handoff. It is required Use `$release-candidate-prep` only when the user explicitly invokes it with a release version. It keeps the user's clean `main` checkout unchanged, creates a dedicated detached worktree at refreshed `origin/main`, runs the readiness gates there, creates `release/v` in that worktree, updates `pyproject.toml` and `uv.lock`, freezes and checks `tests/fixtures/released_api_contract.json`, and creates one local release commit. It invokes `$final-release-review` as the controlling checker against both the pre-release source and the materialized candidate; a blocked release call stops the workflow, while a green final-candidate report becomes the release-specific PR description. -The skill replaces the former GitHub Actions release-PR creator. It must never push, open or edit a pull request, create a release, or mutate any other GitHub state. It leaves the dedicated worktree in place for green handoff, blocked review, or recoverable failure. Release tag creation and PyPI publication remain owned by their post-merge workflows. The release commit may contain only `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`; all runtime and documentation changes must land on `main` before preparation. +The skill replaces the former GitHub Actions release-PR creator. It must never push, open or edit a pull request, create a release, or mutate any other GitHub state. It leaves the dedicated worktree in place for green handoff, blocked review, or recoverable failure. After merge, an authorized maintainer manually creates the release tag at the confirmed merge commit and publishes the GitHub Release; the protected publishing workflow then handles PyPI publication. Follow [the release operation guide](.github/RELEASING.md). The release commit may contain only `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`; all runtime and documentation changes must land on `main` before preparation. ### Work Status Reporting diff --git a/tests/test_release_provenance.py b/tests/test_release_provenance.py new file mode 100644 index 0000000000..45aa4fb94e --- /dev/null +++ b/tests/test_release_provenance.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +VALIDATOR = Path(__file__).resolve().parents[1] / ".github/scripts/verify_release.py" + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True + ).stdout.strip() + + +@pytest.fixture +def release_repo(tmp_path: Path) -> tuple[Path, str]: + repo = tmp_path / "candidate" + repo.mkdir() + _git(repo, "init", "--initial-branch=main") + _git(repo, "config", "user.name", "Release test") + _git(repo, "config", "user.email", "release-test@example.invalid") + _git(repo, "config", "commit.gpgsign", "false") + _git(repo, "config", "tag.gpgsign", "false") + (repo / "pyproject.toml").write_text( + '[project]\nname = "openai-agents"\nversion = "0.23.0"\n', encoding="utf-8" + ) + _git(repo, "add", "pyproject.toml") + _git(repo, "commit", "-m", "Prepare release") + sha = _git(repo, "rev-parse", "HEAD") + _git(repo, "update-ref", "refs/remotes/origin/main", sha) + _git(repo, "tag", "-a", "v0.23.0", "-m", "Release v0.23.0", sha) + return repo, sha + + +def _verify(repo: Path, sha: str, tag: str = "v0.23.0") -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.pop("OPENAI_API_KEY", None) + return subprocess.run( + [ + sys.executable, + "-I", + str(VALIDATOR), + "--repo", + str(repo), + "--tag", + tag, + "--expected-sha", + sha, + ], + cwd=repo, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + +def test_valid_release_can_precede_main_tip_without_executing_candidate( + release_repo: tuple[Path, str], +) -> None: + repo, sha = release_repo + (repo / "sitecustomize.py").write_text('raise RuntimeError("candidate executed")\n') + (repo / "tomllib.py").write_text('raise RuntimeError("candidate imported")\n') + _git(repo, "commit", "--allow-empty", "-m", "Later main change") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + _git(repo, "checkout", "--detach", sha) + + result = _verify(repo, sha) + + assert result.returncode == 0, result.stderr + assert f"Validated v0.23.0 at {sha} in main history." in result.stdout + + +def test_lightweight_tag_has_the_same_commit_validation( + release_repo: tuple[Path, str], +) -> None: + repo, sha = release_repo + _git(repo, "tag", "-d", "v0.23.0") + _git(repo, "tag", "v0.23.0", sha) + + assert _verify(repo, sha).returncode == 0 + + +@pytest.mark.parametrize("invalid_tag", ["v0.24.0", "other", "vbad..ref", "v$(touch marker)"]) +def test_rejects_wrong_or_invalid_tag(release_repo: tuple[Path, str], invalid_tag: str) -> None: + repo, sha = release_repo + if invalid_tag == "v0.24.0": + _git(repo, "tag", invalid_tag, sha) + + result = _verify(repo, sha, invalid_tag) + + assert result.returncode != 0 + assert "Release validation failed" in result.stderr + assert not (repo / "marker").exists() + + +def test_rejects_tag_moved_after_release_event(release_repo: tuple[Path, str]) -> None: + repo, sha = release_repo + _git(repo, "commit", "--allow-empty", "-m", "Different commit") + _git(repo, "tag", "-f", "v0.23.0", "HEAD") + _git(repo, "checkout", "--detach", sha) + + result = _verify(repo, sha) + + assert result.returncode != 0 + assert "tag does not match the release event commit" in result.stderr + + +def test_rejects_checkout_other_than_release_event(release_repo: tuple[Path, str]) -> None: + repo, sha = release_repo + _git(repo, "commit", "--allow-empty", "-m", "Different checkout") + + result = _verify(repo, sha) + + assert result.returncode != 0 + assert "checkout does not match the release event commit" in result.stderr + + +def test_rejects_release_outside_main_history(release_repo: tuple[Path, str]) -> None: + repo, _ = release_repo + _git(repo, "checkout", "-b", "unmerged") + _git(repo, "commit", "--allow-empty", "-m", "Unmerged release") + sha = _git(repo, "rev-parse", "HEAD") + _git(repo, "tag", "-f", "v0.23.0", sha) + + result = _verify(repo, sha) + + assert result.returncode != 0 + assert "Git ref or ancestry check failed" in result.stderr + + +@pytest.mark.parametrize("missing_ref", ["refs/tags/v0.23.0", "refs/remotes/origin/main"]) +def test_rejects_missing_release_ref(release_repo: tuple[Path, str], missing_ref: str) -> None: + repo, sha = release_repo + _git(repo, "update-ref", "-d", missing_ref) + + assert _verify(repo, sha).returncode != 0 + + +@pytest.mark.parametrize("metadata", ['[project]\nname = "openai-agents"\n', "not TOML"]) +def test_rejects_missing_version_or_invalid_metadata( + release_repo: tuple[Path, str], metadata: str +) -> None: + repo, _ = release_repo + (repo / "pyproject.toml").write_text(metadata, encoding="utf-8") + _git(repo, "commit", "-am", "Invalid release metadata") + sha = _git(repo, "rev-parse", "HEAD") + _git(repo, "update-ref", "refs/remotes/origin/main", sha) + _git(repo, "tag", "-f", "v0.23.0", sha) + + result = _verify(repo, sha) + + assert result.returncode != 0 + assert "Release validation failed" in result.stderr + + +def test_rejects_non_commit_sha(release_repo: tuple[Path, str]) -> None: + repo, _ = release_repo + + result = _verify(repo, "HEAD") + + assert result.returncode != 0 + assert "full commit SHA" in result.stderr diff --git a/tests/test_repository_workflow_interfaces.py b/tests/test_repository_workflow_interfaces.py index 0af4d6dc0b..623a7f8517 100644 --- a/tests/test_repository_workflow_interfaces.py +++ b/tests/test_repository_workflow_interfaces.py @@ -7,6 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] MAKEFILE = ROOT / "Makefile" TESTS_WORKFLOW = ROOT / ".github" / "workflows" / "tests.yml" +PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish.yml" DAPR_REDIS_TEST = ROOT / "integration_tests" / "containers" / "test_dapr_redis.py" EXAMPLE_RUNNER = ROOT / ".github" / "scripts" / "run_examples.sh" EXAMPLE_SUITE = ROOT / "examples" / "run_examples.py" @@ -28,8 +29,8 @@ def _make_recipes() -> dict[str, str]: return recipes -def _workflow_job(name: str) -> str: - workflow = TESTS_WORKFLOW.read_text(encoding="utf-8") +def _workflow_job(name: str, path: Path = TESTS_WORKFLOW) -> str: + workflow = path.read_text(encoding="utf-8") job_pattern = rf"(?ms)^ {re.escape(name)}:\n(?P.*?)(?=^ [a-z0-9-]+:\n|\Z)" match = re.search(job_pattern, workflow) assert match is not None @@ -166,3 +167,46 @@ def test_prospective_contract_preparation_removes_api_key_before_uv() -> None: assert recipe.startswith("@unset OPENAI_API_KEY; \\\n") assert recipe.index("unset OPENAI_API_KEY") < recipe.index("uv run") + + +def test_release_build_validates_before_executing_candidate_code() -> None: + build = _workflow_job("build", PUBLISH_WORKFLOW) + + assert "contents: read" in build + assert "id-token:" not in build + assert "environment:" not in build + assert "ref: refs/heads/main\n path: control" in build + assert "ref: ${{ github.sha }}\n path: release-source" in build + assert build.count("persist-credentials: false") == 2 + assert "fetch-depth: 0" in build + validation = build.index("python -I control/.github/scripts/verify_release.py") + assert validation < build.index("run: make sync") < build.index("run: uv build") + assert ' --tag "$RELEASE_TAG" --expected-sha "$RELEASE_SHA"' in build + assert "enable-cache: false" in build + + +def test_pypi_job_only_publishes_the_build_artifact() -> None: + workflow = PUBLISH_WORKFLOW.read_text(encoding="utf-8") + publish = _workflow_job("publish", PUBLISH_WORKFLOW) + + assert "permissions: {}" in workflow + assert workflow.count("id-token: write") == 1 + assert "needs: build" in publish + assert "name: pypi" in publish + assert "id-token: write" in publish + assert "run:" not in publish + actions = re.findall(r"uses: ([^\s]+)", publish) + assert len(actions) == 2 + assert actions[0].startswith("actions/download-artifact@") + assert actions[1].startswith("pypa/gh-action-pypi-publish@") + assert all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", action) for action in actions) + assert "artifact-ids: ${{ needs.build.outputs.artifact-id }}" in publish + assert "artifact-id: ${{ steps.upload.outputs.artifact-id }}" in _workflow_job( + "build", PUBLISH_WORKFLOW + ) + assert "path: dist/" in publish + assert "merge-multiple: true" in publish + + +def test_release_tagging_is_manual() -> None: + assert not (ROOT / ".github/workflows/release-tag.yml").exists() From 38636a5c04d54717030878a133fd21970a0e1dec Mon Sep 17 00:00:00 2001 From: Michael Jerge <112141470+mmjerge@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:45:41 -0400 Subject: [PATCH 438/473] fix(chat-completions): merge a streamed turn's message into its pending tool-call message (#4728) --- src/agents/models/chatcmpl_converter.py | 66 ++++++++-- .../test_openai_chatcompletions_converter.py | 113 ++++++++++++++++++ 2 files changed, 166 insertions(+), 13 deletions(-) diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 87b55a9e26..2eff812424 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -738,18 +738,15 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: # 3) response output message => assistant elif resp_msg := cls.maybe_response_output_message(item): - # A reasoning item can be followed by an assistant message and then tool calls - # in the same turn, so preserve pending reasoning state across this flush. - flush_assistant_message(clear_pending_reasoning=False) - new_asst = ChatCompletionAssistantMessageParam(role="assistant") contents = resp_msg["content"] text_segments = [] + refusal: str | None = None for c in contents: if c["type"] == "output_text": text_segments.append(c["text"]) elif c["type"] == "refusal": - new_asst["refusal"] = c["refusal"] + refusal = c["refusal"] elif c["type"] == "output_audio": # Can't handle this, b/c chat completions expects an ID which we dont have raise UserError( @@ -758,14 +755,57 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: else: raise UserError(f"Unknown content type in ResponseOutputMessage: {c}") - if text_segments: - combined = "\n".join(text_segments) - new_asst["content"] = combined - - apply_pending_thinking_blocks(new_asst) - new_asst["tool_calls"] = [] - apply_pending_reasoning_content(new_asst) - current_assistant_msg = new_asst + # A streamed turn can order its function calls before its text message, + # and the pending assistant message then already carries the tool calls + # of this same turn: a function_call_output always flushes, so tool calls + # from a previous turn cannot still be pending here. Merge the message + # into that pending assistant message. Flushing instead would emit an + # assistant message with tool_calls directly followed by another + # assistant message, a sequence the Chat Completions API rejects. + pending_content = ( + current_assistant_msg.get("content") + if current_assistant_msg is not None + else None + ) + if ( + current_assistant_msg is not None + and current_assistant_msg.get("tool_calls") + and "refusal" not in current_assistant_msg + # None is the untouched state; a list means thinking blocks were + # already reconstructed into content parts and text can be appended. + and (pending_content is None or isinstance(pending_content, list)) + ): + merged_asst = current_assistant_msg + if text_segments: + combined = "\n".join(text_segments) + if isinstance(pending_content, list): + merged_asst["content"] = [ + *pending_content, + ChatCompletionContentPartTextParam(text=combined, type="text"), + ] + else: + merged_asst["content"] = combined + if refusal is not None: + merged_asst["refusal"] = refusal + apply_pending_thinking_blocks(merged_asst) + apply_pending_reasoning_content(merged_asst) + else: + # A reasoning item can be followed by an assistant message and then + # tool calls in the same turn, so preserve pending reasoning state + # across this flush. + flush_assistant_message(clear_pending_reasoning=False) + new_asst = ChatCompletionAssistantMessageParam(role="assistant") + if refusal is not None: + new_asst["refusal"] = refusal + + if text_segments: + combined = "\n".join(text_segments) + new_asst["content"] = combined + + apply_pending_thinking_blocks(new_asst) + new_asst["tool_calls"] = [] + apply_pending_reasoning_content(new_asst) + current_assistant_msg = new_asst # 4) function/file-search calls => attach to assistant elif file_search := cls.maybe_file_search_call(item): diff --git a/tests/models/test_openai_chatcompletions_converter.py b/tests/models/test_openai_chatcompletions_converter.py index cc8b469b0c..22d6779a3a 100644 --- a/tests/models/test_openai_chatcompletions_converter.py +++ b/tests/models/test_openai_chatcompletions_converter.py @@ -379,6 +379,119 @@ def test_items_to_messages_with_output_message_and_function_call(): assert tool_call["function"]["arguments"] == "{}" +def _turn_items_function_call_first() -> list[TResponseInputItem]: + """One completed tool turn in the order the streaming handler emits it. + + A provider that streams tool-call deltas before the same turn's text yields + response outputs ordered [function_call, message]; the runner then appends + the function_call_output. + """ + func_item: ResponseFunctionToolCallParam = { + "id": "99", + "call_id": "abc", + "name": "math", + "arguments": "{}", + "type": "function_call", + } + resp_msg = ResponseOutputMessage( + id="42", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputText(text="Let me calculate.", type="output_text", annotations=[])], + ) + return [ + cast(TResponseInputItem, func_item), + cast(TResponseInputItem, resp_msg.model_dump()), + cast( + TResponseInputItem, + {"type": "function_call_output", "call_id": "abc", "output": "4"}, + ), + ] + + +def test_items_to_messages_merges_function_call_before_output_message(): + """A streamed turn ordered [function_call, message] must convert to one + assistant message; an assistant message with tool_calls followed by another + assistant message is rejected by the Chat Completions API.""" + messages = Converter.items_to_messages(_turn_items_function_call_first()) + + assert len(messages) == 2 + assistant = messages[0] + assert assistant["role"] == "assistant" + assert assistant["content"] == "Let me calculate." + tool_calls = assistant.get("tool_calls") + assert isinstance(tool_calls, list) + assert len(tool_calls) == 1 + assert tool_calls[0]["function"]["name"] == "math" + assert messages[1]["role"] == "tool" + assert messages[1]["tool_call_id"] == "abc" + + +def test_items_to_messages_streamed_and_nonstreamed_turn_order_converge(): + """[function_call, message] and [message, function_call] describe the same + turn and must produce identical Chat Completions messages.""" + streamed = _turn_items_function_call_first() + non_streamed = [streamed[1], streamed[0], streamed[2]] + + assert Converter.items_to_messages(streamed) == Converter.items_to_messages(non_streamed) + + +def test_items_to_messages_does_not_merge_a_later_turn_into_a_tool_turn(): + """A plain assistant turn after a completed tool turn stays a separate + message; only the same turn's text merges into the tool_calls message.""" + items = _turn_items_function_call_first() + later_turn = cast( + TResponseInputItem, + ResponseOutputMessage( + id="43", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputText(text="Done.", type="output_text", annotations=[])], + ).model_dump(), + ) + messages = Converter.items_to_messages([*items, later_turn]) + + assert len(messages) == 3 + assert messages[0]["role"] == "assistant" + assert messages[0].get("tool_calls") + assert messages[1]["role"] == "tool" + assert messages[2]["role"] == "assistant" + assert messages[2]["content"] == "Done." + assert not messages[2].get("tool_calls") + + +def test_items_to_messages_merges_refusal_into_pending_tool_call_message(): + """A refusal-bearing message after its turn's function call merges into the + same assistant message instead of opening an invalid second one.""" + func_item: ResponseFunctionToolCallParam = { + "id": "99", + "call_id": "abc", + "name": "math", + "arguments": "{}", + "type": "function_call", + } + resp_msg = ResponseOutputMessage( + id="42", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputRefusal(refusal="won't do that", type="refusal")], + ) + messages = Converter.items_to_messages( + [ + cast(TResponseInputItem, func_item), + cast(TResponseInputItem, resp_msg.model_dump()), + ] + ) + + assert len(messages) == 1 + assistant = messages[0] + assert assistant["refusal"] == "won't do that" + assert assistant.get("tool_calls") + + def test_items_to_messages_accepts_statusless_output_message(): """Output messages remain recognizable after replay normalization removes null status.""" statusless_message = cast( From 89c02c828ee8510fe9a84ee6675608193aa13b02 Mon Sep 17 00:00:00 2001 From: Subhash Polisetti <49185065+subhashpolisetti@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:04:25 -0700 Subject: [PATCH 439/473] fix(core): reject fixed-length tuple annotations for variadic tool arguments (#4735) --- src/agents/function_schema.py | 8 ++++++ tests/test_function_schema.py | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 98e3200f07..8860c15180 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -441,6 +441,14 @@ def function_schema( args_of_tuple = get_args(ann) if len(args_of_tuple) == 2 and args_of_tuple[1] is Ellipsis: ann = list[ann] # type: ignore + # tuple[()] parameterizes an empty tuple and reports no args, while a bare + # typing.Tuple is unparameterized and carries no element type to reject. + elif hasattr(ann, "__args__"): + raise UserError( + f"Variadic parameter `*{name}` in function {func.__name__} is annotated" + f" with the fixed-length tuple `{ann}`. A variadic annotation describes" + " each positional argument, so use tuple[T, ...] or list[T] instead." + ) else: ann = list[Any] else: diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 661fbcd06e..1d8325d1a0 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -462,6 +462,53 @@ def func(*args: tuple[int, ...]) -> int: fs.params_pydantic_model.model_validate({"args": [1, 2, 3]}) +def _var_positional_fixed_pair(*args: tuple[int, str]) -> int: + return len(args) + + +def _var_positional_fixed_single(*args: tuple[int]) -> int: + return len(args) + + +def _var_positional_empty_tuple(*args: tuple[()]) -> int: + return len(args) + + +@pytest.mark.parametrize( + "func", + [_var_positional_fixed_pair, _var_positional_fixed_single, _var_positional_empty_tuple], +) +def test_var_positional_fixed_length_tuple_annotation_is_rejected(func: Any): + # A fixed-length tuple cannot describe every positional argument, so reject it at + # construction instead of silently widening each argument to Any. tuple[()] reports no + # args yet is still parameterized, so it belongs in this group. + with pytest.raises(UserError, match=r"use tuple\[T, \.\.\.\] or list\[T\] instead"): + function_schema(func, use_docstring_info=False) + + +def _var_positional_homogeneous_tuple(*args: tuple[int, ...]) -> int: + return len(args) + + +def _var_positional_list(*args: list[int]) -> int: + return len(args) + + +def _var_positional_scalar(*args: int) -> int: + return len(args) + + +@pytest.mark.parametrize( + "func", + [_var_positional_homogeneous_tuple, _var_positional_list, _var_positional_scalar], +) +def test_var_positional_supported_annotations_still_build(func: Any): + # The alternatives named by the rejection message must keep working. + fs = function_schema(func, use_docstring_info=False) + + assert fs.params_json_schema["properties"]["args"]["type"] == "array" + + def test_var_keyword_dict_annotation(): # Case 3: # A ``**kwargs: X`` annotation applies to each keyword *value* (PEP 484), so a From 9ab4ab64857624ce0ae38c56d5feb7cef174b212 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:01:47 +0900 Subject: [PATCH 440/473] chore(deps): bump actions/download-artifact from 4.3.0 to 8.0.1 (#4802) --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a30ae9177b..8924f80598 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download distributions - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: artifact-ids: ${{ needs.build.outputs.artifact-id }} path: dist/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e601f1ab11..582b513a96 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -270,7 +270,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Download prospective release contract if: steps.changes.outputs.run == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: prospective-release-contract path: .tmp @@ -308,7 +308,7 @@ jobs: python-version: "3.14" - name: Download prospective release contract if: steps.changes.outputs.run == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: prospective-release-contract path: .tmp From f1f35031d364f9f0d8e94dda0853c92abefdccff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:01:56 +0900 Subject: [PATCH 441/473] chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#4803) --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8924f80598..4eda6f203c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,7 +62,7 @@ jobs: run: uv build - name: Store distributions id: upload - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: python-distributions-${{ github.run_attempt }} path: release-source/dist/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 582b513a96..f9077289a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -348,7 +348,7 @@ jobs: run: make prepare-prospective-released-api-contract - name: Upload prospective release contract if: steps.changes.outputs.run == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: prospective-release-contract path: .tmp/prospective_released_api_contract.json From c25e63dc1a176512fa219deb346967a3f88f391e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 12:38:35 +0900 Subject: [PATCH 442/473] docsL simplify examples page and remove navigation entries --- docs/examples.md | 132 +-------------------------- docs/ref/sandbox/util/blocking_io.md | 3 + mkdocs.yml | 4 - 3 files changed, 4 insertions(+), 135 deletions(-) create mode 100644 docs/ref/sandbox/util/blocking_io.md diff --git a/docs/examples.md b/docs/examples.md index d10079c8ee..0b1ea058cc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,133 +1,3 @@ # Examples -Check out a variety of sample implementations that use the SDK in the examples section of the [repo](https://github.com/openai/openai-agents-python/tree/main/examples). The examples are organized into several categories that demonstrate different patterns and capabilities. - -## Categories - -- **[agent_patterns](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns):** Examples in this category illustrate common agent design patterns, such as - - - Deterministic workflows - - Agents as tools - - Agents as tools with streaming events (`examples/agent_patterns/agents_as_tools_streaming.py`) - - Agents as tools with structured input parameters (`examples/agent_patterns/agents_as_tools_structured.py`) - - Parallel agent execution - - Conditional tool usage - - Forcing tool use while demonstrating different tool-use behaviors (`examples/agent_patterns/forcing_tool_use.py`) - - Input/output guardrails - - LLM as a judge - - Routing - - Streaming guardrails - - Human-in-the-loop with tool approval and state serialization (`examples/agent_patterns/human_in_the_loop.py`) - - Human-in-the-loop with streaming (`examples/agent_patterns/human_in_the_loop_stream.py`) - - Custom rejection messages for approval flows (`examples/agent_patterns/human_in_the_loop_custom_rejection.py`) - -- **[basic](https://github.com/openai/openai-agents-python/tree/main/examples/basic):** These examples showcase foundational capabilities of the SDK, such as - - - Hello world examples (Default model, GPT-5, open-weight model) - - Agent lifecycle management - - Agent and run lifecycle example using `RunHooks` and `AgentHooks` (`examples/basic/lifecycle_example.py`) - - Dynamic system prompts - - Basic tool usage (`examples/basic/tools.py`) - - Tool input/output guardrails (`examples/basic/tool_guardrails.py`) - - Returning an image as tool output (`examples/basic/image_tool_output.py`) - - Streaming outputs (text, items, function call args) - - Responses websocket transport with a shared session helper across turns (`examples/basic/stream_ws.py`) - - Prompt templates - - File handling (local and remote, images and PDFs) - - Usage tracking - - Runner-managed retry settings (`examples/basic/retry.py`) - - Runner-managed retries through a third-party adapter (`examples/basic/retry_litellm.py`) - - Non-strict output types - - Previous response ID usage - -- **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** Example customer service system for an airline. - -- **[financial_research_agent](https://github.com/openai/openai-agents-python/tree/main/examples/financial_research_agent):** A financial research agent that demonstrates structured research workflows for financial data analysis using agents and tools. - -- **[handoffs](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs):** Practical examples of agent handoffs with message filtering, including: - - - Message filter example (`examples/handoffs/message_filter.py`) - - Message filter with streaming (`examples/handoffs/message_filter_streaming.py`) - -- **[hosted_mcp](https://github.com/openai/openai-agents-python/tree/main/examples/hosted_mcp):** Examples demonstrating how to use hosted MCP (Model Context Protocol) with the OpenAI Responses API, including: - - - Simple hosted MCP without approval (`examples/hosted_mcp/simple.py`) - - MCP connectors such as Google Calendar (`examples/hosted_mcp/connectors.py`) - - Human-in-the-loop with interruption-based approvals (`examples/hosted_mcp/human_in_the_loop.py`) - - Callback for MCP tool approval requests (`examples/hosted_mcp/on_approval.py`) - -- **[mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp):** Learn how to build agents with MCP (Model Context Protocol), including: - - - Filesystem examples - - Git examples - - MCP prompt server examples - - SSE (Server-Sent Events) examples - - SSE remote server connection (`examples/mcp/sse_remote_example`) - - Streamable HTTP examples - - Streamable HTTP remote connection (`examples/mcp/streamable_http_remote_example`) - - Custom HTTP client factory for Streamable HTTP (`examples/mcp/streamablehttp_custom_client_example`) - - Prefetching all MCP tools with `MCPUtil.get_all_function_tools` (`examples/mcp/get_all_mcp_tools_example`) - - Using `MCPServerManager` in a FastAPI application (`examples/mcp/manager_example`) - - MCP tool filtering (`examples/mcp/tool_filter_example`) - -- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):** Examples of different memory implementations for agents, including: - - - SQLite session storage - - Advanced SQLite session storage - - Redis session storage - - SQLAlchemy session storage - - Dapr state store session storage - - Encrypted session storage - - OpenAI Conversations session storage - - Responses compaction session storage - - Stateless Responses compaction with `ModelSettings(store=False)` (`examples/memory/compaction_session_stateless_example.py`) - - File-backed session storage (`examples/memory/file_session.py`) - - File-backed session with human-in-the-loop (`examples/memory/file_hitl_example.py`) - - SQLite in-memory session with human-in-the-loop (`examples/memory/memory_session_hitl_example.py`) - - OpenAI Conversations session with human-in-the-loop (`examples/memory/openai_session_hitl_example.py`) - - HITL approval/rejection scenario across sessions (`examples/memory/hitl_session_scenario.py`) - -- **[model_providers](https://github.com/openai/openai-agents-python/tree/main/examples/model_providers):** Explore how to use non-OpenAI models with the SDK, including custom providers and third-party adapters. - -- **[realtime](https://github.com/openai/openai-agents-python/tree/main/examples/realtime):** Examples showing how to build real-time experiences using the SDK, including: - - - Web application patterns with structured text and image messages - - Command-line audio loops and playback handling - - Twilio Media Streams integration over WebSocket - - Twilio SIP integration using the Realtime Calls API's `attach` flows - -- **[reasoning_content](https://github.com/openai/openai-agents-python/tree/main/examples/reasoning_content):** Examples demonstrating how to work with reasoning content, including: - - - Reasoning content with the Runner API, streaming and non-streaming (`examples/reasoning_content/runner_example.py`) - - Reasoning content with OSS models via OpenRouter (`examples/reasoning_content/gpt_oss_stream.py`) - - Basic reasoning content example (`examples/reasoning_content/main.py`) - -- **[research_bot](https://github.com/openai/openai-agents-python/tree/main/examples/research_bot):** Simple deep research clone that demonstrates complex multi-agent research workflows. - -- **[sandbox](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox):** Examples for running agents in isolated workspaces, including: - - - Basic sandbox agent setup (`examples/sandbox/basic.py`) - - Unix-local and Docker sandbox lifecycle examples - - Sandbox-backed handoffs (`examples/sandbox/handoffs.py`) - - Sandbox memory and snapshot resume (`examples/sandbox/memory.py`) - - Sandbox agents exposed as tools (`examples/sandbox/sandbox_agents_as_tools.py`) - -- **[tools](https://github.com/openai/openai-agents-python/tree/main/examples/tools):** Learn how to implement OpenAI-hosted tools and experimental Codex tooling. Examples include: - - - Web search and web search with filters - - File search - - Code interpreter - - Apply patch tool with file editing and approval (`examples/tools/apply_patch.py`) - - Shell tool execution with approval callbacks (`examples/tools/shell.py`) - - Shell tool with human-in-the-loop interruption-based approvals (`examples/tools/shell_human_in_the_loop.py`) - - Hosted container shell with inline skills (`examples/tools/container_shell_inline_skill.py`) - - Hosted container shell with skill references (`examples/tools/container_shell_skill_reference.py`) - - Local shell with local skills (`examples/tools/local_shell_skill.py`) - - Tool search with namespaces and tools that use deferred loading (`examples/tools/tool_search.py`) - - Programmatic Tool Calling with concurrent structured tool calls (`examples/tools/programmatic_tool_calling.py`) - - Computer use - - Image generation - - Experimental Codex tool workflows (`examples/tools/codex.py`) - - Experimental Codex workflows that reuse the same Codex conversation thread (`examples/tools/codex_same_thread.py`) - -- **[voice](https://github.com/openai/openai-agents-python/tree/main/examples/voice):** See examples of voice agents, using our TTS and STT models, including streamed voice examples. +Browse the [examples directory on GitHub](https://github.com/openai/openai-agents-python/tree/main/examples) for sample implementations that use the OpenAI Agents SDK. diff --git a/docs/ref/sandbox/util/blocking_io.md b/docs/ref/sandbox/util/blocking_io.md new file mode 100644 index 0000000000..2eda4959c8 --- /dev/null +++ b/docs/ref/sandbox/util/blocking_io.md @@ -0,0 +1,3 @@ +# `Blocking Io` + +::: agents.sandbox.util.blocking_io diff --git a/mkdocs.yml b/mkdocs.yml index 8352c4fc7f..aa4d85a737 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,7 +88,6 @@ plugins: - Testing: testing.md - Agent visualization: visualization.md - REPL utility: repl.md - - Examples: examples.md - Release process/changelog: release.md - API Reference: @@ -241,7 +240,6 @@ plugins: - テスト: testing.md - visualization.md - repl.md - - コード例: examples.md - release.md - locale: ko name: 한국어 @@ -285,7 +283,6 @@ plugins: - 테스트: testing.md - visualization.md - repl.md - - 코드 예제: examples.md - release.md - locale: zh name: 简体中文 @@ -329,7 +326,6 @@ plugins: - 测试: testing.md - visualization.md - repl.md - - 示例: examples.md - release.md extra: # Remove material generation message in footer From 3e0e89374f629c974929054e56e823a43c91a013 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 15:19:02 +0900 Subject: [PATCH 443/473] chore: simplify repository guidance and adjust review skill details --- .../skills/code-change-verification/SKILL.md | 2 +- .agents/skills/docs-sync/SKILL.md | 22 +- .../references/doc-coverage-checklist.md | 8 +- .agents/skills/examples-run-analysis/SKILL.md | 2 +- .agents/skills/final-release-review/SKILL.md | 2 +- .../implementation-final-review/SKILL.md | 238 ++----- .../references/high-risk-review.md | 205 ++++++ .../references/reviewer-brief.md | 4 +- .../scripts/test_skill_contract.py | 609 ------------------ .../skills/implementation-kickoff/SKILL.md | 10 +- .../skills/implementation-strategy/SKILL.md | 12 +- .agents/skills/maintainer-review/SKILL.md | 2 +- .agents/skills/openai-knowledge/SKILL.md | 2 +- .agents/skills/pr-draft-summary/SKILL.md | 4 +- .../skills/release-candidate-prep/SKILL.md | 2 +- .../skills/runtime-behavior-probe/SKILL.md | 2 +- .../skills/sensitive-logging-audit/SKILL.md | 2 +- .../skills/test-coverage-improver/SKILL.md | 54 +- AGENTS.md | 167 +---- tests/README.md | 6 +- 20 files changed, 322 insertions(+), 1033 deletions(-) create mode 100644 .agents/skills/implementation-final-review/references/high-risk-review.md delete mode 100644 .agents/skills/implementation-final-review/scripts/test_skill_contract.py diff --git a/.agents/skills/code-change-verification/SKILL.md b/.agents/skills/code-change-verification/SKILL.md index f4326129b0..faa4f92c52 100644 --- a/.agents/skills/code-change-verification/SKILL.md +++ b/.agents/skills/code-change-verification/SKILL.md @@ -1,6 +1,6 @@ --- name: code-change-verification -description: Run the mandatory verification stack when changes affect runtime code, tests, or build/test behavior in the OpenAI Agents Python repository. +description: Run the required final formatting, lint, type, and test checks after eligible SDK changes pass review. --- # Code Change Verification diff --git a/.agents/skills/docs-sync/SKILL.md b/.agents/skills/docs-sync/SKILL.md index 8e023b8832..cbdba999d3 100644 --- a/.agents/skills/docs-sync/SKILL.md +++ b/.agents/skills/docs-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: docs-sync -description: Analyze main branch implementation and configuration to find missing, incorrect, or outdated documentation in docs/. Use when asked to audit doc coverage, sync docs with code, or propose doc updates/structure changes. Only update English docs under docs/** and never touch translated docs under docs/ja, docs/ko, or docs/zh. Provide a report and ask for approval before editing docs. +description: Audit or update English SDK documentation against the requested implementation scope. --- # Docs Sync @@ -9,17 +9,21 @@ description: Analyze main branch implementation and configuration to find missin Identify doc coverage gaps and inaccuracies by comparing main branch features and configuration options against the current docs structure, then propose targeted improvements. +## Authorization and scope + +For an audit or proposal-only request, report findings without editing. When the user requests updates or has already approved a plan, complete those local edits and applicable checks without asking again. Ask only about unresolved scope, behavior, release timing, or additional authority. Keep generated translations untouched. Apply the repository's Documentation Release Timing policy before including unreleased behavior in `docs/`. + ## Workflow 1. Confirm scope and base branch - Identify the current branch and default branch (usually `main`). - Prefer analyzing the current branch to keep work aligned with in-flight changes. - - If the current branch is not `main`, analyze only the diff vs `main` to scope doc updates. + - Use a branch diff only for a branch-scoped request. A requested topic or released-doc correction remains in scope even when it is unrelated to the current branch diff. - Avoid switching branches if it would disrupt local changes. Prefer read-only inspection such as `git show main:`. If a separate checkout is genuinely required, stop and obtain the explicit approval required by `AGENTS.md` before creating or switching a worktree. 2. Build a feature inventory from the selected scope - - If on `main`: inventory the full surface area and review docs comprehensively. - - If not on `main`: inventory only changes vs `main` (feature additions/changes/removals). + - Bound the inventory to the requested topic or diff. Inventory the full surface only for an explicitly comprehensive audit. + - For branch-scoped work, inspect feature additions, changes, and removals relative to the intended base. - Focus on user-facing behavior: public exports, configuration options, environment variables, CLI commands, default values, and documented runtime behaviors. - Capture evidence for each item (file path + symbol/setting). - Use targeted search to find option types and feature flags (for example: `rg "Settings"`, `rg "Config"`, `rg "os.environ"`, `rg "OPENAI_"`). @@ -42,11 +46,11 @@ Identify doc coverage gaps and inaccuracies by comparing main branch features an - **Incorrect/outdated**: names, defaults, or behaviors that diverge from main. - **Structural issues** (optional): pages overloaded, missing overviews, or mis-grouped topics. -6. Produce a Docs Sync Report and ask for approval - - Provide a clear report with evidence, suggested doc locations, and proposed edits. - - Ask the user whether to proceed with doc updates. +6. Report findings or continue authorized updates + - For audit-only work, provide evidence, suggested locations, and proposed edits, then stop. + - For an update request, use the findings to complete the authorized edits. -7. If approved, apply changes (English only) +7. Apply authorized changes (English only) - Edit only English docs in `docs/**`. - Do **not** edit `docs/ja`, `docs/ko`, or `docs/zh`. - Keep changes aligned with the existing docs style and navigation. @@ -70,7 +74,7 @@ Docs Sync Report - Proposed change + rationale - Proposed edits - Doc file -> concise change summary -- Questions for the user +- Unresolved decisions, only when needed ## References diff --git a/.agents/skills/docs-sync/references/doc-coverage-checklist.md b/.agents/skills/docs-sync/references/doc-coverage-checklist.md index 01d144c170..46b5eccada 100644 --- a/.agents/skills/docs-sync/references/doc-coverage-checklist.md +++ b/.agents/skills/docs-sync/references/doc-coverage-checklist.md @@ -1,6 +1,6 @@ # Doc Coverage Checklist -Use this checklist to scan the selected scope (main = comprehensive, or current-branch diff) and validate documentation coverage. +Use this checklist within the requested topic or diff. A comprehensive inventory requires a comprehensive audit request; the current branch does not determine the scope. Released-documentation corrections remain in scope even when unrelated to that branch. ## Feature inventory targets @@ -26,7 +26,7 @@ Use this checklist to scan the selected scope (main = comprehensive, or current- ## Evidence capture -- Record the main-branch file path and symbol/setting name. +- Record the inspected revision, file path, and symbol/setting name for the requested scope. - Note defaults or behavior-critical details for accuracy checks. - Avoid large code dumps; a short identifier is enough. @@ -35,7 +35,7 @@ Use this checklist to scan the selected scope (main = comprehensive, or current- - Option names/types no longer exist or differ from code. - Default values or allowed ranges do not match implementation. - Features removed in code but still documented. -- New behaviors introduced without corresponding docs updates. +- Released behaviors missing necessary guidance. Apply the repository's Documentation Release Timing policy before proposing documentation for unreleased behavior. ## When to propose structural changes @@ -43,7 +43,7 @@ Use this checklist to scan the selected scope (main = comprehensive, or current- - Multiple pages duplicate the same concept without cross-links. - New feature areas have no obvious home in the nav structure. -## Diff mode guidance (current branch vs main) +## Diff mode guidance (only for branch-scoped requests) - Focus only on changed behavior: new exports/options, modified defaults, removed features, or renamed settings. - Use `git diff main...HEAD` (or equivalent) to constrain analysis. diff --git a/.agents/skills/examples-run-analysis/SKILL.md b/.agents/skills/examples-run-analysis/SKILL.md index bcabae2e13..c5b0637558 100644 --- a/.agents/skills/examples-run-analysis/SKILL.md +++ b/.agents/skills/examples-run-analysis/SKILL.md @@ -1,6 +1,6 @@ --- name: examples-run-analysis -description: Analyze artifacts from the latest completed manual examples Make run. Read the main log, every relevant per-example log, and example source; validate every exit-0 example and classify failures, skips, and environment restrictions. Never execute or control examples. +description: Analyze logs and source from a completed manual examples run. Never execute or control examples. --- # Examples Run Analysis diff --git a/.agents/skills/final-release-review/SKILL.md b/.agents/skills/final-release-review/SKILL.md index 28512babe3..3c04bf60ad 100644 --- a/.agents/skills/final-release-review/SKILL.md +++ b/.agents/skills/final-release-review/SKILL.md @@ -1,6 +1,6 @@ --- name: final-release-review -description: Perform pre-release planning or a final release-candidate review for openai-agents-python by comparing the target with the previous remote tag, determining the minimum compatible release type, auditing regressions and contract changes, reviewing open documentation PR coverage, drafting minor-release Key Changes, and calling the ship/block gate. +description: Assess a Python SDK release candidate or release plan against the previous release and recommend ship or block. --- # Final Release Review diff --git a/.agents/skills/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md index b83b5678f4..d7d59d1944 100644 --- a/.agents/skills/implementation-final-review/SKILL.md +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -1,208 +1,50 @@ --- name: implementation-final-review -description: Perform the repository's risk-tiered independent final review before implementation completion. Use only when explicitly invoked or when repository instructions require it after behavior-impacting implementation work; audit the complete task diff, supported contracts, lifecycle and security boundaries, complexity, and tests before final verification. +description: Review completed implementation changes before final verification. Use when repository policy requires independent review or the user explicitly requests it. --- # Implementation Final Review -Treat implementation and final review as separate phases. Reconstruct the change from the original requirement and the complete diff; do not defend the current design merely because it is implemented or tested. - -## Non-negotiable guarantees - -- Review the exact final task content, including committed, staged, unstaged, and task-owned untracked deliverables. The only exceptions are the narrowly verified final-gate type-erasure and base-advance closures in step 20, which preserve clean credit through explicit identity evidence and still require the complete final verification stack on the resulting fingerprint. -- Use the merge-base three-dot diff for patch ownership and the latest release tag separately for released compatibility. -- Require independent review. A same-context self-review cannot satisfy the clean-review gate. -- Freeze task-owned content while reviewers inspect a fingerprint. -- Treat an exact normalized file path in the task and component manifests as authoritative even when ignore rules match that file. An existing exact file takes literal precedence over Git pathspec metacharacters; use explicit `:(glob)` magic when pattern semantics are intended. A directory or glob pathspec never promotes ignored operational files into the review. -- Require the repository and every initialized submodule index to have no unresolved merge stages before fingerprinting. -- Require every initialized submodule, including nested submodules, to be clean and checked out at the commit recorded by its parent index before freezing review state. Stage reviewable gitlink pointer changes in the parent repository; fail closed on dirty worktrees, hidden index flags, ignored nested changes, and untracked embedded repositories. Reject cyclic or aliased submodule worktree graphs before recursive inspection. -- Require two consecutive identical observations of HEAD, status, diffs, task and repository workspace content, and component workspace content before accepting a review-state snapshot. Fail closed when repository state changes during capture. -- Reject task-owned filesystem entries that Git cannot represent as finite blobs, including FIFOs, sockets, and devices. -- Require packet, ledger, manifest, receipt, reviewer-output, and evidence paths to resolve to finite regular files. Canonicalize each path before opening. Verify the file type after opening and read content from that same descriptor; never authorize a path with `stat` and then reopen it. Reject evidence, receipt, and current-versus-prior ledger aliases by the opened descriptor's device and inode identity. Before accepting reviewer output or a reusable receipt, re-read the packet and current and prior ledgers and require their validated digests to remain unchanged; also re-read the indexed receipt before reporting it reusable. Materialize devices, FIFOs, sockets, or generated streams into regular files before validation. -- Bind every canonical root-owned evidence ID and inventory ID in the ledger with `contract_evidence_sha256` and `inventory_sha256`. Preserve those digest bindings across rounds so an existing ID cannot change content; inventory digests exclude only the ID itself so a renamed copy is not new semantic inventory. -- Count evidence or inventory as new for a canonical root only when its digest is absent from that root's prior ownership. A new root proposal requires an evidence digest absent from every canonical root and every distinct root proposed in the same output; it cannot reuse canonical inventory before implementer promotion. Require every credited receipt to have a unique content digest and exact command. -- Require unique keys and standard finite numbers in every JSON object. Duplicate keys, JavaScript-style `NaN` or infinity constants, and numeric exponents that overflow to infinity are invalid. Convert runtime numeric-size and nesting-limit failures into protocol errors instead of leaking parser exceptions. -- Give the two reviewers distinct normalized primary and high-risk specialties, and require every preflight command to be unique before any receipt can claim it. -- Encode `manifests.dependency_map` as an object that maps every component name to a nonempty array of exact `pathspec` and `reason` records. Reject prose-only claims, missing components, empty dependency sets, duplicate pathspecs, and extra record fields. -- Treat verification receipts, reviewer outputs, findings, root-cause evidence, unchecked-inventory records, and sibling-scenario scans as exact schemas. Reject unknown fields instead of ignoring potentially conflicting evidence. -- Repeat commit-hook inspection, every safe rewriting step, second-pass idempotence, and generated-provenance validation before every fingerprint freeze, including post-fix and delta-review rounds. Record the exact executable inspection and rewriting commands plus their results in packet preflight evidence; a prose label is not an executable command. -- Start independent reviewers without inherited conversation history. Fresh judgment does not require repeatedly replaying the implementer's context. -- Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. -- Never weaken final repository verification. Component-aware review invalidation reduces repeated review, not required build or test gates. -- Keep one task-global round ledger across pauses, compaction, handoff, renaming, resumed work, and post-completion feedback. Enforce a bounded budget for each active review cycle without discarding earlier history. -- Trust the active implementation control plane to record actual reviewer dispatches, waits, outputs, and verification executions. The local protocol helper validates those records but does not replace platform-issued cryptographic execution attestation. - -## Post-completion feedback boundary - -An implementation review cycle is complete only after its clean-review gate, mandatory verification, any requested local commit, and final user-facing handoff are complete. Seal that cycle at this boundary. A pause, compaction, context change, agent handoff before completion, or ordinary request to continue unfinished work does not create a new cycle or reset its budget. - -A later user message containing concrete actionable review feedback starts a post-completion feedback cycle. The feedback message itself authorizes implementing that feedback and running the repository-mandated focused tests, delta review, verification, and local commit or amendment needed to return the task to a completed state. Do not ask for separate review-budget authorization merely because the sealed implementation cycle exhausted its budget. - -Keep the same task identity and ledger, preserve its canonical root-cause history and clean credit for unchanged components, and append a default budget of two fingerprint rounds for the new feedback cycle. Ask the user again only when the feedback materially widens the requested contract, changes a released or durable compatibility boundary, requires authority beyond resolving the feedback, or exhausts the feedback-cycle budget. - -## Workflow - -Persist the current combined content fingerprint as `ledger.round_fingerprint` and bind it to the packet fingerprint. A same-round retry is valid only when that value and the authorized budget history match the immutable prior ledger snapshot; a changed fingerprint or newly authorized budget advances the round. - -1. Finish the initial implementation and focused tests. Apply formatting before review when formatting can rewrite the diff. Inspect the actual final commit-hook configuration and run the exact safe, non-committing equivalent of every hook step that can rewrite task-owned content before freezing the first review fingerprint. Run each rewriting step until a second execution is content-idempotent. Normalize generated files before computing embedded hashes or provenance so the hook cannot invalidate them later. Record any hook step that cannot safely run before review; if that step later changes task content, apply the normal invalidation rules without exception. -2. Re-read the original user request and the current implementation scope contract. If no contract exists, record the required behavior, compatibility requirements, intentionally unsupported cases and failure behavior, and supported alternative or `none`. -3. Resolve the intended target and merge base. If a supplied target or base is not an ancestor of `HEAD`, compute their common merge base and treat `merge-base...HEAD` as the task-owned diff. Use the latest release tag separately when released compatibility is the relevant boundary. Include committed, staged, unstaged, and untracked changes that belong to the task. -4. Read the complete task-owned three-dot diff from the resolved merge base. Never treat target-only commits between the merge base and an advanced or divergent target as deletions or regressions introduced by the patch. Check integration with the current target separately when relevant; report an actual conflict or semantic incompatibility, not mere absence of target-side changes. Do not limit review to the latest fix or files named in prior feedback. Record a complexity delta: runtime lines changed, new state fields, new synchronization or ownership mechanisms, affected subsystems, and test permutations. -5. Run the baseline-reset gate before accepting the current design: - - Describe the required behavior without referring to branch-local helper types or state. - - Identify the nearest released/base pipeline that already owns the behavior. - - Compare patching the current diff with replacing task-owned branch-local machinery by a narrow change from the base implementation. - - Treat unreleased implementation and tests as disposable. Preserve unrelated or user-owned changes. - - Choose the narrower design unless concrete contract evidence requires the current machinery. -6. Select the relevant review dimensions below from the affected runtime boundaries and repository architecture references. Complete every selected dimension even after finding a blocker; the goal is a complete final review, not the first valid comment. Classify review risk before dispatch: normal when the change does not affect concurrency, cancellation, security, trust, persistence, durable state, released compatibility, package/runtime exports, protocol ownership, or cross-provider lifecycle; elevated when any of those boundaries changes or an earlier round produced P0/P1. Run the cheapest affected-boundary preflight broad enough to catch likely late fallout from a dependency, package surface, generated artifact, or cross-cutting runtime change. Prefer focused tests plus a narrowly targeted import, generated-surface, or static check. Run a targeted type check only when the change directly affects a typing boundary and the command is materially narrower than repository-wide `make typecheck`. Do not run repository-wide lint, typecheck, builds, integration suites, `make tests-review`, or `make tests` merely to enter or iterate through the review gate. Run the focused preflight once for a semantic state and rerun only affected checks after fixes. -7. Build the pre-dispatch evidence required by the changed boundary: - - For every changed public symbol, configuration field, event, serialized field, wire value, or documented caller-visible behavior, create a contract-surface inventory: producers and constructors; every consumer, forwarding branch, and adapter; default, missing, and invalid-value behavior; package exports and generated public surfaces when applicable; adjacent docs and examples; and caller-visible tests. Search adjacent contract surfaces even when they are absent from the diff. A required example, export, adapter, or generated-surface update is a missing task deliverable, not out of scope merely because it is not yet in the manifest. A required `docs/` update is also a missing task deliverable unless the repository's Documentation Release Timing policy intentionally defers it. When that policy applies, record the documentation need and timing as evidence of separately timed work; do not add it to the current task manifest, report it as a current-pull-request finding, or let it block clean review. - - For concurrency, cancellation, reentrancy, shared lifecycle state, or a check followed by an await before a side effect, create an await-boundary matrix. For each relevant operation, record the state snapshot, blocking or await point, events and operations that may run while suspended, durable or monotonic evidence retained, revalidation before each side effect, and resulting cancel, feedback, persistence, or cleanup action. Include source completion, a newer operation active with known and unknown identity, a newer operation that starts and completes while suspended, and failure or cancellation of the awaited action when those states are supported. If correctness depends on whether something ever happened, current active state is insufficient unless serialization proves it cannot be lost; require monotonic identity, generation, tombstone, or equivalent durable evidence. - - For protocol, persistence, or security changes, create the analogous authority/data-flow inventory from input through validation, storage, retry or replay, output, exceptions, logs, telemetry, and cleanup. Treat these as mechanical coverage artifacts, not implementation conclusions. The implementer must fill them from code and contract evidence before review; reviewers validate them independently against the complete diff and surrounding source. -8. Produce only concrete, patch-scoped findings that are reproducible from code, contract, documentation, or a focused probe. Do not report hypothetical extensibility or unrelated cleanup. Before concluding, account for every row in the contract-surface, await-boundary, and authority/data-flow inventories and every new or modified source of shared state. For a scenario outside the required behavior, run a differential check against the merge base or latest release and identify support evidence. Reachability through a public method, concurrent call, repeated call, host-language protocol, or third-party behavior is not by itself a supported contract. -9. Classify every finding before editing: - - required-behavior defect; - - released compatibility or durable-boundary defect; - - missing failure-path or adversarial coverage; - - unsupported neighboring case that should fail earlier; - - unnecessary machinery or duplicated source of truth; - - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. -10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with content-new contract evidence; only the implementer may promote that proposal and assign canonical inventory in the ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Plans, review ledgers, packets, traces, temporary reports, and other workflow artifacts are operational-only by default even when repository policy requires creating them; include one only when the original requirement or repository policy explicitly makes that exact path a committed deliverable. Keep operational files outside the shipped manifest and account for them as repository exclusions. Keep the shipped manifest stable and update it only when task-owned deliverable paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. For each component, record an exact semantic dependency-input pathspec set plus the reason each input can affect the component; do not use a coarse directory or prose-only `none` claim when build configuration, generated-surface owners, or shared runtime code are dependencies. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ... --complete-diff-output `; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Always generate the complete-diff artifact through `--complete-diff-output`; a standalone `git diff` omits ordinary untracked deliverables. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. -11. Prepare one self-contained reviewer snapshot packet per round using `references/reviewer-brief.md` when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the raw output produced by that command's `--complete-diff-output` as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires `complete_diff_paths` to equal the task workspace exactly, requires the complete-diff digest to match its `complete_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact, and the ledger digest maps must bind the exact owned evidence and inventory content. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix, canonical root ownership, and owned-content digests, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. -12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. For normal risk, give them distinct primary dimensions that together cover the selected review surface. For elevated risk or a prior P0/P1, give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. A reviewer process that fails before producing a protocol-valid output because of startup, service, content-filter, context, or tool infrastructure has produced neither a finding nor clean credit and does not advance `ledger.current_round` or consume another fingerprint round. Replace only that reviewer on the same frozen packet and assignment; a protocol-valid output already accepted from the other reviewer remains usable while the task fingerprint, packet, and assignment are unchanged. The original two-reviewer concurrent dispatch satisfies the round's concurrency requirement; the accepted peer output plus one independently launched replacement output on the identical packet and assignment form the required pair. If an independent replacement remains unavailable, report the gate as unavailable instead of counting an infrastructure failure as review evidence. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests-review`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. -13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence unless the change later qualifies for one of the narrow final-gate closures in step 20. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged, except for a narrowly recorded step 20 closure. Require two concurrent independent delta reviews of every other changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. -14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, changed prior evidence or inventory bindings, reopening a closed canonical root without content-new evidence or semantic inventory, and distinct new roots that reuse one evidence digest. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. -15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. -16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the canonical root-cause ID closed. Do not reopen it for another local patch without content-new contract evidence or semantic inventory; reject aliases, renamed or copied content, and bare unknown IDs instead of treating them as new roots. If it cannot be closed coherently, escalate instead of consuming more rounds. -17. Increment the fingerprint round, repeat the full commit-hook parity gate from step 1, and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> hook parity -> review without waiting for another user prompt. -18. Apply the non-convergence guard before another local fix: - - If the same root-cause group produces another P0/P1 after a complexity reset, return to the merge base and replace task-owned branch-local machinery with the narrowest coherent implementation. - - If runtime diff size, state fields, ownership modes, or test permutations grow materially for two consecutive rounds, do not call that convergence merely because each finding is local. Re-run the baseline-reset gate. - - If the same root-cause group produces actionable findings in three finding-bearing rounds, or the narrower reimplementation still produces the same root-cause P0/P1, escalate early rather than consuming the round budget. - - If four rounds complete without a shrinking or stable diff and falling finding severity, escalate early. -19. Stop successfully only after the required clean-review condition is met on the exact reviewed content and every required reviewer output has passed the acceptance gate: - - same-round infrastructure replacement: the original two-reviewer dispatch was concurrent, and the accepted pair consists of the unchanged protocol-valid peer output plus one independent replacement output accepted under step 12; - - normal-risk change: two independent clean reviews of the same fingerprint, launched concurrently; - - elevated-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint with complementary high-risk specialties, launched concurrently. - - component-only post-review edit: clean credit for every unchanged component plus two concurrent clean independent delta reviews covering all changed components and their runtime boundary. - - verified final-gate type-erasure closure satisfying every condition in step 20: preserve the prior clean set without a new fingerprint round or reviewer dispatch, then run the complete final verification stack on the resulting fingerprint. - - verified base-advance closure satisfying every condition in step 20: preserve the prior clean set without a new fingerprint round or reviewer dispatch, then run the complete final verification stack on the replayed fingerprint. -20. After the clean-review condition is met, confirm that the diff and component fingerprints remain stable, then check observable host capacity before starting the repository's code-change verification. Use available read-only task or process evidence; treat another repository-wide test, typecheck, build, examples runner, or integration command already active on the same host as concrete contention. When contention is visible, continue useful non-heavy work or an event-driven wait and check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file, and do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. Once capacity is available, run every mandatory command in the repository-required order against the exact clean-reviewed fingerprint, or against the recorded resulting fingerprint of a verified closure below. Record combined, component, and repository fingerprints immediately before and after the final stack. Accept final verification only when every command succeeds, execution does not mutate that final content or create an ambiguous repository-state change, and all fingerprints still match. Classify any final-gate replay or edit before invalidating review evidence: - - Verified base-advance closure: when the intended target advances after clean review, preserve the existing clean set without a new fingerprint round or reviewer dispatch only when every condition below holds. This exception grants no final-verification credit; rerun every mandatory final gate on the replayed fingerprint and regenerate the complete PR handoff. - - The replay or rebase is conflict-free and requires no manual task-content edit. The old and new `review_state.py` artifacts have byte-identical task and component `workspace` arrays, and their `tracked_diff_sha256` values are identical; compare these fields directly because content fingerprints intentionally include the resolved base. - - Before the original review, each component recorded exact semantic dependency-input pathspecs and reasons. The complete upstream delta from old base to new base changes no task-manifest path, dependency-input path, selected architecture reference, generated-surface owner, or applicable build, test, lint, format, hook, lockfile, or package configuration input. - - The original requirement, scope contract, inventory rows, assertions about runtime behavior, selected review dimensions, risk tier, and released compatibility boundary are unchanged. Focused checks affected by integration with the new base pass. - - Record the old and new base, head, combined/component/repository fingerprints, exact upstream changed-path list and diff digest, dependency-input pathspecs, comparison commands, and results in the task-global ledger. This closure consumes no fingerprint round and creates no reviewer packet. - - Any path overlap, changed configuration or dependency input, conflict, manual resolution, changed diff digest, missing prior dependency map, or uncertainty falls through to a fresh review round on the new base. A coarse directory-disjointness claim is insufficient. - - Verified type-erasure-only edit: preserve the existing clean set without an independent delta review only when every condition below holds. This exception consumes no fingerprint round and requires no reviewer packet, but it does not grant final-gate credit; restart every mandatory final gate on the resulting fingerprint. - - The edit is made only after the final stack reports a formatter, linter, or static-type-checker failure, and the failure does not reveal unresolved runtime or contract uncertainty. - - The exact delta is limited to importing `cast` directly from the standard-library `typing` module, wrapping one unchanged private implementation expression as `cast(, )`, and formatter-only whitespace. The imported name is not rebound or used elsewhere. - - The edit does not change expression evaluation order or count, exception propagation, a public or exported annotation or signature, a decorator, runtime branch, constant, test assertion, generated surface, documentation, scope contract, inventory row, component dependency, or risk tier. - - The implementer records the before and after fingerprints, the exact delta, the original final-gate failure, and the runtime-identity basis that `typing.cast` returns its value unchanged. Targeted formatting, lint, type checking, and affected focused tests must pass before restarting the full stack. - - Any additional token change, behavioral-equivalence argument beyond this exact `typing.cast` shape, or uncertainty about the conditions above falls through to the normal runtime-edit rule and requires the applicable independent delta review. - - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. - - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. - - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. - - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean, delta-review, verified base-advance-closure, or verified type-erasure-closure evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. -21. Stop the autonomous loop when the active cycle reaches its current budget: six fingerprint rounds for the initial implementation cycle or two for a post-completion feedback cycle. This is an absolute cap for the active cycle, not a target, and it does not reset when execution pauses or context changes. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or explicitly authorize another bounded budget. When concrete actionable feedback arrives after a successfully completed and sealed cycle, append the feedback cycle's default two-round budget to the same ledger without another authorization prompt. In every other case, append a user-authorized budget to the same ledger rather than replacing its history. - -Maintain one compact round ledger throughout all review cycles and persist it as a durable, task-global artifact: - -`Round | component fingerprints | root-cause groups | highest severity | complexity delta | action | clean credit` - -Persist enough task identity, used and authorized round budgets, cycle boundaries, fingerprints, root-cause closure state, and clean credit to resume without reconstructing prior rounds. Update it only at a meaningful state transition: round start, accepted finding batch, complexity reset, clean result, verification result, sealed completion, or post-completion feedback-cycle start. Do not emit repeated waiting messages when neither reviewer state nor repository content changed. - -## Independent reviewer - -An independent review uses a fresh no-history context that did not implement the fingerprinted content and is not given prior reviewer findings or implementer conclusions. Prefer a distinct agent and set `fork_turns: "none"` when the platform exposes that control. A same-context self-review or full-history fork is not independent and cannot satisfy the clean-review gate. - -- Give the reviewer the original requirement, implementation scope contract, base and head identifiers, canonical component manifest and fingerprints, raw repository state, and relevant architecture references. -- Give the reviewer the precomputed contract-surface and await-boundary or authority/data-flow inventories. These are coverage maps, not conclusions; require the reviewer to validate every row against the raw diff and surrounding source. -- Tell the reviewer which identifier is the intended target and require an explicit merge-base calculation. When target and head diverge, provide or request a three-dot diff; do not present a two-dot target-to-head diff as the patch. -- Do not give the reviewer the implementer's conclusions, suspected bugs, intended fixes, or a list of expected findings. -- Ask for exactly one read-only review round. The reviewer must not edit or stage files, run the autonomous review loop recursively, spawn another reviewer, or perform the final repository verification. The implementer owns finding validation, edits, loop control, and final verification. -- Give every reviewer for a round the same review-state fingerprint and keep the diff frozen until all of them finish. Reject output produced from a different or changing state instead of merging partial observations across revisions. -- Give every reviewer the compact self-contained control-plane brief, indexed evidence paths and digests, and one exact revalidation command. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, the reviewer must report it and cannot return a creditable clean verdict. Tell reviewers not to inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag unless supplied evidence is inconsistent or decision-relevant. Reopening source cannot replace missing packet contents. This preserves fresh judgment while avoiding repeated setup work. -- Use fresh reviewers for every round when possible. Do not reveal findings or conclusions from prior rounds; provide only the updated requirement, scope contract, raw final diff, component manifest, and relevant references. -- Use two concurrent fresh reviewers for every round. For the high-risk conditions in step 12, assign complementary high-risk specialties while requiring each reviewer to inspect the complete diff. Both reviewers of the same unchanged diff are one fingerprint round. Do not duplicate broad test execution. -- Concurrent reviewers receive the same fingerprint and raw context but different primary specialties. They must not communicate during the round. -- Give the reviewer existing verification commands and results as raw evidence. The reviewer should inspect code and tests, then run only focused probes needed to resolve a decision-relevant uncertainty. A probe must be demonstrably non-mutating or run in an isolated temporary checkout; any mutation of the reviewed worktree invalidates the round. Do not rerun the repository's broad test, typecheck, lint, build, or integration suites merely to reconfirm the implementer's evidence; the implementer runs the complete stack once after the clean-review gate. -- Require the structured JSON output from the reviewer brief. `clean` alone is never sufficient: the reviewer must return the exact packet SHA-256 and fingerprints, checked and unchecked inventory IDs, high-risk dimensions checked, probes or `none`, unresolved uncertainty or `none`, findings, sibling-scenario scan, and inspection-budget accounting. -- After fixes, review the exact final diff again. Preserve earlier clean credit only under the explicit component-delta rule; do not infer that a change is isolated merely from its file location. - -When an independent reviewer is unavailable, rebuild context from the original request, scope contract, source, and complete diff before a best-effort self-review. Explicitly discard incremental-review assumptions, label the result non-independent, and do not count it toward the clean-review gate. Report the unavailable gate at handoff instead of silently weakening it. - -## Review dimensions - -Choose dimensions based on the changed boundary; do not mechanically invent findings for every item. - -### Requirement and scope - -- Verify that the smallest required caller-visible behavior works. -- Identify nearby constructible cases and confirm they are either intentionally supported or rejected before side effects. -- Require contract evidence before treating repeated, concurrent, reentrant, malformed, wrapped, or cross-provider combinations as blockers. Reproduce the same supported scenario on the baseline when claiming a regression. -- Check whether tests accidentally turn implementation permutations into public contract. -- Map every new abstraction, state field, branch, dependency, and cross-module change to a requirement, supported contract, or verified risk. - -### Compatibility and identity - -- Compare released public signatures, field order, imports, names, serialized values, configuration, and wire behavior. -- Preserve exact caller-visible identity or spelling unless transformation is required. -- Distinguish unreleased branch-local machinery from released or durable compatibility boundaries. -- For every new or modified public field, enumerate all construction, forwarding, and consumption branches. Verify that normal, specialized, default, missing-value, and error paths either honor the field or reject it according to one coherent contract; do not validate only the motivating branch. -- Search public docs, examples, docstrings, configuration reference, and release metadata for claims made stale by the behavior change. Missing documentation can be an actionable omission even when no documentation file is in the diff. Apply the repository's Documentation Release Timing policy before classifying the omission: required `docs/` content that would describe unreleased behavior is separately timed work, not a defect in the current task or pull request. - -### Lifecycle and failures - -- Trace ownership from acquisition through success, failure, cancellation, retry, replacement, and cleanup. -- When shared lifecycle state changes, build a compact operation-state matrix before concluding. Cover each affected public mutating operation against never-started, partial-failure, active, cleanup-in-progress, and terminal states as applicable. -- Trace repeated sequential calls and every relevant pair of overlapping public mutating operations. Identify the linearization point or manager-owned serialization mechanism; do not infer safety from per-resource deduplication alone. -- Check repeated cancellation, partial initialization, cleanup failure, retry through every supported public entry point, and primary-exception preservation. -- State the final survivor invariant: which tasks, workers, processes, sessions, listeners, files, or remote resources may remain. -- Review ordering when several validations or cleanup actions can short-circuit one another. -- Audit every check-await-side-effect sequence. State may change during the await; require revalidation or prove manager-owned serialization before cancellation, feedback, persistence, or cleanup. -- Distinguish current state from historical evidence. If a stale-result guarantee depends on whether a newer operation ever started, an active pointer that later returns to `None` cannot prove absence; use or require monotonic evidence unless the operation is serialized. - -### Security, trust, persistence, and protocol - -- Trace caller-controlled data through logs, exceptions, causes, contexts, telemetry, model-visible output, and persisted state. -- Treat serialized state as authority only when the supported trust boundary explicitly allows it. -- Check fail-closed behavior for malformed or ambiguous sensitive inputs without returning or retaining the original value. -- Verify protocol capability ownership, pagination termination, cache ownership, retry and replay safety, wire validation, and tool or call identity when affected. - -### Behavioral parity - -- Compare streaming and non-streaming, sync and async, initial and resumed, direct and wrapped, and provider-specific paths when the requirement crosses them. -- Verify that one path does not silently ignore, reshape, or hard-fail data that another path supports. - -### Tests and generated public surfaces - -- Prefer public-boundary or caller-visible adversarial tests. -- Exercise the highest stable caller boundary that reproduces the required behavior. A helper-only test is insufficient when a caller transforms the input, owns the lifecycle, or determines the observable result before or after invoking that helper. -- Require expected values and failure signals to come from the contract, a worked example, a baseline, or another independent oracle. Do not accept an assertion that recomputes the expected result with the same logic as the implementation. -- Use a narrower internal boundary when a lifecycle, concurrency, provider-wire, or malformed-stream scenario cannot be controlled reliably through a public entry point, and record why that boundary is necessary. -- Add controlled interleavings for concurrency instead of relying only on sequential tests. -- Test the required behavior, the nearest supported alternative, and one representative input per unsupported category. -- Do not accept passing existing tests as proof when they encode the same assumptions as the implementation. -- Import through intended consumer entry points and verify generated or distribution artifacts when public package behavior changes; runtime tests alone do not prove the published surface. - -## Complexity reset - -Run a complexity reset when related findings keep expanding the same design, a narrow requirement requires recursive or cached classification, tests enumerate mechanics, representations are inferred in multiple places, or the diff spreads unexpectedly across subsystems. - -1. Stop addressing findings one by one. -2. Group them by root cause and restate the original required behavior. -3. Compare the full diff with the merge base or release boundary. -4. Delete branch-local machinery that is not required. -5. Reuse the nearest existing source-of-truth pipeline. -6. Narrow unsupported behavior and reject it before side effects with a supported alternative when one exists. -7. Rebuild tests around caller-visible invariants and representative negative cases. -8. Compare the replacement's runtime and test complexity with both the previous round and the merge base. A reset that only renames or redistributes a growing state machine is not a reset. +Review the original requirement and complete task diff, including committed, staged, unstaged, and task-owned untracked files. Use the intended target's merge base for patch ownership and the latest release separately when released compatibility matters. Apply the finding threshold and supported-scope rules in `AGENTS.md`. -Review-state workspace entries must use the exact key set emitted for their `file`, `symlink`, `gitlink`, `directory`, or `missing` kind; incomplete or unknown fields fail before dispatch. +## Choose the review tier -For reusable verification credit, a receipt command must exactly match a structured command in `verification.preflight_results`; a different successful command cannot inherit verification credit. +Classify the complete change by its semantic impact, not its line count, extension, or location. Record the tier and a short reason in existing working notes; do not create a separate classification report. -## Review output +| Tier | Boundary | Required review | +| --- | --- | --- | +| Lightweight | Only spelling, comments, or formatting; no change to execution, public contracts, test expectations, configuration, or documented meaning. | Self-check the complete diff and run applicable focused checks. Independent review is optional. | +| Ordinary | Local behavior changes within an established contract, ordinary test additions, or behavioral documentation without a high-risk boundary change. | One independent reviewer in a fresh context. Use the procedure below. | +| High risk | Changes to security, credentials, sensitive-data handling, trust, persistence/resume, durable state, concurrency/cancellation/shared lifecycle ownership, released compatibility, package/runtime exports, protocol ownership, or cross-provider lifecycle; also any review cycle with a validated P0/P1. | Two independent reviewers with complementary specialties. Read [high-risk-review.md](references/high-risk-review.md), which owns the strict protocol and final-evidence rules. | -Return exactly one JSON object using the schema in `references/reviewer-brief.md`. Put the verdict in `verdict`; put each actionable finding in `findings` with its priority, title, location, concrete failure scenario, user-visible consequence, support basis, baseline-versus-patch evidence when applicable, smallest safe correction, and stable root-cause ID. Account for every assigned inventory ID and keep unverified runtime uncertainty explicit. Do not claim implementation completion until both structured clean reviews and required verification apply to the exact final state. +A one-line condition fix is at least ordinary. Public annotations and schema-generating types can change contracts. Test deletion or changed expectations, CI/build configuration, and policy changes are not lightweight merely because they do not edit runtime files. An uncertainty about high-risk impact must be resolved before accepting an ordinary clean review; escalate when the affected boundary requires it. Do not downgrade a cycle after a validated P0/P1 just because the immediate fix is small. + +Planning, investigation, and report-only tasks do not start this implementation workflow. Repo-meta work uses the applicable skill's focused validation. When implementing changes to decision-making guidance, perform realistic scenario checks and an independent pass rather than an SDK runtime review packet. Report-only assessments may inspect existing review/scenario evidence; they do not automatically commission another implementation review. Editorial documentation follows the repository's documentation verification tiers. + +## Ordinary independent review + +### Prepare once + +Finish affected tests and any formatting or safe hook-equivalent normalization that can change the task diff. Reuse successful focused checks for unchanged content. Do not start broad final verification until review is clean. + +Give one reviewer the original request, a short scope contract, target/base/head identifiers, task-owned paths, complete diff including new-file contents, relevant architecture references, and exact focused-check commands and results. Record the reviewed content in a saved diff plus new-file snapshots or a content fingerprint, so later comparison can establish what was reviewed. Keep temporary evidence outside shipped deliverables. `scripts/review_state.py` is available when useful, but the strict JSON packet, component ledger, evidence IDs, and verification receipts are not required for ordinary review. + +### Review and resolve + +Launch one independent agent with no inherited implementer conversation (`fork_turns: "none"` when supported). Do not supply intended findings, previous reviewer conclusions, or proposed fixes. The reviewer inspects the complete diff and relevant surrounding source, verifies the scope contract rather than rerunning implementation strategy, and returns the reviewed scope, concrete actionable findings, and remaining uncertainty. A bare approval without inspected scope is insufficient; no fixed JSON schema is required. + +The reviewer performs one read-only pass, does not edit, recursively delegate review, or run broad suites. Focused non-mutating probes are allowed only within existing execution and credential permissions. Keep task content fixed while review runs and use event-driven waits within the host's supported limits. Use wait time for independent work that cannot change reviewed content. + +Validate findings against supported behavior and fix them as one batch. Update strategy only when the contract or implementation shape changes. Rerun affected checks and obtain an independent review of the changed content and its relevant boundaries before accepting completion. Review the complete resulting task diff when scope or cross-cutting assumptions changed. Escalate to the high-risk procedure if a changed boundary or validated P0/P1 requires it; an ordinary verdict does not substitute for its required pair. + +Preserve a compact count and unresolved-root summary in existing task notes across pauses and compaction. The initial cycle allows up to six reviewed revisions, and concrete feedback after a completed handoff starts a two-revision cycle. These are caps, not targets. On escalation, carry consumed revisions and unresolved root causes into the high-risk handoff; record the remaining cycle budget and give the strict ledger only that remainder. Ordinary verdicts earn no high-risk clean credit. If no budget remains, obtain a concrete user decision before another dispatch. Infrastructure retries on unchanged content do not consume a revision. If repeated findings expose the same design problem, apply the repository complexity-reset rule instead of adding conditions indefinitely. Ask for a concrete scope/design decision when progress needs one or the budget is exhausted; otherwise continue autonomously through fixes, review, verification, and handoff. + +### Preserve evidence through completion + +Compare final task content with the recorded reviewed content. Changed behavior, expectations, contracts, or dependencies requires affected independent re-review. A demonstrably spelling/comment/formatting-only delta can retain prior review after self-check; do not extend that exemption to executable rewrites, public annotations, or behavioral prose. Committing or staging identical content does not invalidate review. A changed base requires checking the upstream delta and integration; if relevant source or tooling changed, or impact is uncertain, obtain affected re-review. Retain review only with recorded evidence of unchanged task behavior and unaffected dependencies. + +Run every applicable final verification gate on the final content, using `$code-change-verification` for eligible SDK changes and documentation tiers for docs. A lighter review does not waive SDK checks. Preserving review after a formatting-only edit does not grant final-stack credit for changed content; follow the verification skill's final-content requirements. Report completion only when review and verification apply to the delivered state, then run `$pr-draft-summary` when required. If an independent reviewer is unavailable, report the missing review explicitly; self-review cannot satisfy the ordinary or high-risk gate. + +## High-risk resources + +Read [high-risk-review.md](references/high-risk-review.md) only for high-risk work. It uses [reviewer-brief.md](references/reviewer-brief.md) and the existing `scripts/review_state.py` and `scripts/review_protocol.py` helpers. Do not prepare their packets, receipts, or component inventories for a lightweight or ordinary change. diff --git a/.agents/skills/implementation-final-review/references/high-risk-review.md b/.agents/skills/implementation-final-review/references/high-risk-review.md new file mode 100644 index 0000000000..ddbd3a2ddb --- /dev/null +++ b/.agents/skills/implementation-final-review/references/high-risk-review.md @@ -0,0 +1,205 @@ +# High-risk independent review + +Use this procedure only for the high-risk tier selected in [SKILL.md](../SKILL.md). All numbered steps below refer to this document. Resolve command paths such as `scripts/review_state.py` and `scripts/review_protocol.py` from the skill directory, not this references directory. + + +Treat implementation and final review as separate phases. Reconstruct the change from the original requirement and the complete diff; do not defend the current design merely because it is implemented or tested. + +## Non-negotiable guarantees + +- Review the exact final task content, including committed, staged, unstaged, and task-owned untracked deliverables. The only exceptions are the narrowly verified final-gate type-erasure and base-advance closures in step 20, which preserve clean credit through explicit identity evidence and still require the complete final verification stack on the resulting fingerprint. +- Use the merge-base three-dot diff for patch ownership and the latest release tag separately for released compatibility. +- Require independent review. A same-context self-review cannot satisfy the clean-review gate. +- Freeze task-owned content while reviewers inspect a fingerprint. +- Treat an exact normalized file path in the task and component manifests as authoritative even when ignore rules match that file. An existing exact file takes literal precedence over Git pathspec metacharacters; use explicit `:(glob)` magic when pattern semantics are intended. A directory or glob pathspec never promotes ignored operational files into the review. +- Require the repository and every initialized submodule index to have no unresolved merge stages before fingerprinting. +- Require every initialized submodule, including nested submodules, to be clean and checked out at the commit recorded by its parent index before freezing review state. Stage reviewable gitlink pointer changes in the parent repository; fail closed on dirty worktrees, hidden index flags, ignored nested changes, and untracked embedded repositories. Reject cyclic or aliased submodule worktree graphs before recursive inspection. +- Require two consecutive identical observations of HEAD, status, diffs, task and repository workspace content, and component workspace content before accepting a review-state snapshot. Fail closed when repository state changes during capture. +- Reject task-owned filesystem entries that Git cannot represent as finite blobs, including FIFOs, sockets, and devices. +- Require packet, ledger, manifest, receipt, reviewer-output, and evidence paths to resolve to finite regular files. Canonicalize each path before opening. Verify the file type after opening and read content from that same descriptor; never authorize a path with `stat` and then reopen it. Reject evidence, receipt, and current-versus-prior ledger aliases by the opened descriptor's device and inode identity. Before accepting reviewer output or a reusable receipt, re-read the packet and current and prior ledgers and require their validated digests to remain unchanged; also re-read the indexed receipt before reporting it reusable. Materialize devices, FIFOs, sockets, or generated streams into regular files before validation. +- Bind every canonical root-owned evidence ID and inventory ID in the ledger with `contract_evidence_sha256` and `inventory_sha256`. Preserve those digest bindings across rounds so an existing ID cannot change content; inventory digests exclude only the ID itself so a renamed copy is not new semantic inventory. +- Count evidence or inventory as new for a canonical root only when its digest is absent from that root's prior ownership. A new root proposal requires an evidence digest absent from every canonical root and every distinct root proposed in the same output; it cannot reuse canonical inventory before implementer promotion. Require every credited receipt to have a unique content digest and exact command. +- Require unique keys and standard finite numbers in every JSON object. Duplicate keys, JavaScript-style `NaN` or infinity constants, and numeric exponents that overflow to infinity are invalid. Convert runtime numeric-size and nesting-limit failures into protocol errors instead of leaking parser exceptions. +- Give the two reviewers distinct normalized primary and high-risk specialties, and require every preflight command to be unique before any receipt can claim it. +- Encode `manifests.dependency_map` as an object that maps every component name to a nonempty array of exact `pathspec` and `reason` records. Reject prose-only claims, missing components, empty dependency sets, duplicate pathspecs, and extra record fields. +- Treat verification receipts, reviewer outputs, findings, root-cause evidence, unchecked-inventory records, and sibling-scenario scans as exact schemas. Reject unknown fields instead of ignoring potentially conflicting evidence. +- Repeat commit-hook inspection, every safe rewriting step, second-pass idempotence, and generated-provenance validation before every fingerprint freeze, including post-fix and delta-review rounds. Record the exact executable inspection and rewriting commands plus their results in packet preflight evidence; a prose label is not an executable command. +- Start independent reviewers without inherited conversation history. Fresh judgment does not require repeatedly replaying the implementer's context. +- Report only concrete, patch-scoped findings supported by requirements, released behavior, a durable boundary, explicit maintainer intent, user reliance, or a baseline regression. +- Never weaken final repository verification. Component-aware review invalidation reduces repeated review, not required build or test gates. +- Keep one task-global round ledger across pauses, compaction, handoff, renaming, resumed work, and post-completion feedback. Enforce a bounded budget for each active review cycle without discarding earlier history. +- Trust the active implementation control plane to record actual reviewer dispatches, waits, outputs, and verification executions. The local protocol helper validates those records but does not replace platform-issued cryptographic execution attestation. + +## Post-completion feedback boundary + +An implementation review cycle is complete only after its clean-review gate, mandatory verification, any requested local commit, and final user-facing handoff are complete. Seal that cycle at this boundary. A pause, compaction, context change, agent handoff before completion, or ordinary request to continue unfinished work does not create a new cycle or reset its budget. + +A later user message containing concrete actionable review feedback starts a post-completion feedback cycle. The feedback message itself authorizes implementing that feedback and running the repository-mandated focused tests, delta review, verification, and any already-authorized local commit or amendment needed to return the task to a completed state. Feedback does not independently authorize a commit when the task did not already allow one. Do not ask for separate review-budget authorization merely because the sealed implementation cycle exhausted its budget. + +Keep the same task identity and ledger, preserve its canonical root-cause history and clean credit for unchanged components, and append a default budget of two fingerprint rounds for the new feedback cycle. Ask the user again only when the feedback materially widens the requested contract, changes a released or durable compatibility boundary, requires authority beyond resolving the feedback, or exhausts the feedback-cycle budget. + +## Workflow + +Persist the current combined content fingerprint as `ledger.round_fingerprint` and bind it to the packet fingerprint. A same-round retry is valid only when that value and the authorized budget history match the immutable prior ledger snapshot; a changed fingerprint or newly authorized budget advances the round. + +1. Finish the initial implementation and focused tests. Apply formatting before review when formatting can rewrite the diff. Inspect the actual final commit-hook configuration and run the exact safe, non-committing equivalent of every hook step that can rewrite task-owned content before freezing the first review fingerprint. Run each rewriting step until a second execution is content-idempotent. Normalize generated files before computing embedded hashes or provenance so the hook cannot invalidate them later. Record any hook step that cannot safely run before review; if that step later changes task content, apply the normal invalidation rules without exception. +2. Re-read the original user request and the current implementation scope contract. If no contract exists, record the required behavior, compatibility requirements, intentionally unsupported cases and failure behavior, and supported alternative or `none`. +3. Resolve the intended target and merge base. If a supplied target or base is not an ancestor of `HEAD`, compute their common merge base and treat `merge-base...HEAD` as the task-owned diff. Use the latest release tag separately when released compatibility is the relevant boundary. Include committed, staged, unstaged, and untracked changes that belong to the task. +4. Read the complete task-owned three-dot diff from the resolved merge base. Never treat target-only commits between the merge base and an advanced or divergent target as deletions or regressions introduced by the patch. Check integration with the current target separately when relevant; report an actual conflict or semantic incompatibility, not mere absence of target-side changes. Do not limit review to the latest fix or files named in prior feedback. Record a complexity delta: runtime lines changed, new state fields, new synchronization or ownership mechanisms, affected subsystems, and test permutations. +5. Run the baseline-reset gate before accepting the current design: + - Describe the required behavior without referring to branch-local helper types or state. + - Identify the nearest released/base pipeline that already owns the behavior. + - Compare patching the current diff with replacing task-owned branch-local machinery by a narrow change from the base implementation. + - Treat unreleased implementation and tests as disposable. Preserve unrelated or user-owned changes. + - Choose the narrower design unless concrete contract evidence requires the current machinery. +6. Select the relevant review dimensions below from the affected runtime boundaries and repository architecture references. Complete every selected dimension even after finding a blocker; the goal is a complete final review, not the first valid comment. The entrypoint has already classified this change as high risk; preserve that classification throughout this procedure. Encode this classification as `task.risk_tier: "elevated"` in the machine-readable packet, using the existing protocol value. Run the cheapest affected-boundary preflight broad enough to catch likely late fallout from a dependency, package surface, generated artifact, or cross-cutting runtime change. Prefer focused tests plus a narrowly targeted import, generated-surface, or static check. Run a targeted type check only when the change directly affects a typing boundary and the command is materially narrower than repository-wide `make typecheck`. Do not run repository-wide lint, typecheck, builds, integration suites, `make tests-review`, or `make tests` merely to enter or iterate through the review gate. Run the focused preflight once for a semantic state and rerun only affected checks after fixes. +7. Build the pre-dispatch evidence required by the changed boundary: + - For every changed public symbol, configuration field, event, serialized field, wire value, or documented caller-visible behavior, create a contract-surface inventory: producers and constructors; every consumer, forwarding branch, and adapter; default, missing, and invalid-value behavior; package exports and generated public surfaces when applicable; adjacent docs and examples; and caller-visible tests. Search adjacent contract surfaces even when they are absent from the diff. A required example, export, adapter, or generated-surface update is a missing task deliverable, not out of scope merely because it is not yet in the manifest. A required `docs/` update is also a missing task deliverable unless the repository's Documentation Release Timing policy intentionally defers it. When that policy applies, record the documentation need and timing as evidence of separately timed work; do not add it to the current task manifest, report it as a current-pull-request finding, or let it block clean review. + - For concurrency, cancellation, reentrancy, shared lifecycle state, or a check followed by an await before a side effect, create an await-boundary matrix. For each relevant operation, record the state snapshot, blocking or await point, events and operations that may run while suspended, durable or monotonic evidence retained, revalidation before each side effect, and resulting cancel, feedback, persistence, or cleanup action. Include source completion, a newer operation active with known and unknown identity, a newer operation that starts and completes while suspended, and failure or cancellation of the awaited action when those states are supported. If correctness depends on whether something ever happened, current active state is insufficient unless serialization proves it cannot be lost; require monotonic identity, generation, tombstone, or equivalent durable evidence. + - For protocol, persistence, or security changes, create the analogous authority/data-flow inventory from input through validation, storage, retry or replay, output, exceptions, logs, telemetry, and cleanup. Treat these as mechanical coverage artifacts, not implementation conclusions. The implementer must fill them from code and contract evidence before review; reviewers validate them independently against the complete diff and surrounding source. +8. Produce only concrete, patch-scoped findings that are reproducible from code, contract, documentation, or a focused probe. Do not report hypothetical extensibility or unrelated cleanup. Before concluding, account for every row in the contract-surface, await-boundary, and authority/data-flow inventories and every new or modified source of shared state. For a scenario outside the required behavior, run a differential check against the merge base or latest release and identify support evidence. Reachability through a public method, concurrent call, repeated call, host-language protocol, or third-party behavior is not by itself a supported contract. +9. Classify every finding before editing: + - required-behavior defect; + - released compatibility or durable-boundary defect; + - missing failure-path or adversarial coverage; + - unsupported neighboring case that should fail earlier; + - unnecessary machinery or duplicated source of truth; + - unrelated or unsupported suggestion to reject. Record the support basis for every actionable finding: original requirement, released documentation/example/typing/test, durable boundary, concrete maintainer intent or user reliance, or a regression where the same supported scenario succeeds at the baseline and fails in the patch. If none applies, do not fix or block on it; mark it unsupported/deferred. +10. Resume or create the task-global review ledger. Use the Codex task or thread ID as the stable task identity when available; otherwise generate one identity once. Persist that exact identity as `ledger.task_id` and require it to match the packet task identity. Store the ledger as an ignored operational file at a stable absolute path, include that path in every reviewer packet and handoff, and preserve the same file when work moves to another worktree. Never initialize a new counter merely because the task was paused, compacted, handed off, renamed, moved to another worktree, or resumed in another context. Start fingerprint round 1 only when the ledger has no prior round for this task; a same-fingerprint request for missing reviewer fields remains in the current round. The default autonomous budget for the initial implementation cycle is six fingerprint rounds. When escalating from ordinary review, preserve its consumed revision count and unresolved root causes in the task notes. Initialize the first strict ledger budget to the remaining authorized cycle budget, not a new six rounds; strict fingerprint numbering may start at 1, but the combined ordinary and strict review count must stay within the original cap. If no budget remains, request a concrete user decision before dispatch. Ordinary verdicts do not earn strict clean credit. Record prior unresolved roots from their actual evidence when preparing the initial strict inventory; no new protocol fields are required. After that cycle has completed under the post-completion feedback boundary, concrete actionable review feedback starts a post-completion feedback cycle: treat the feedback message itself as authorization to append a default budget of two fingerprint rounds to the same ledger. Do not reset the round counter, canonical root-cause history, or clean credit for unchanged components. A continuation request without concrete new feedback remains in the existing cycle. Outside this post-completion feedback rule, only explicit user authorization may add another bounded budget, and the existing ledger and root-cause history must remain attached. The implementer assigns every root-cause ID once in the ledger using a stable canonical ID and includes the complete open and closed root set in every later packet. A reviewer must reuse one supplied canonical ID or propose exactly `NEW:` with content-new contract evidence; only the implementer may promote that proposal and assign canonical inventory in the ledger. Create one canonical manifest of every task-owned shipped path, including both sides of a rename and task-owned untracked files. Plans, review ledgers, packets, traces, temporary reports, and other workflow artifacts are operational-only by default even when repository policy requires creating them; include one only when the original requirement or repository policy explicitly makes that exact path a committed deliverable. Keep operational files outside the shipped manifest and account for them as repository exclusions. Keep the shipped manifest stable and update it only when task-owned deliverable paths actually change. Partition the manifest by the narrowest stable semantic boundaries that match the patch. In `openai-agents-python`, prefer components such as `api-contract`, `runstate-persistence`, `security-sandbox`, `session-lifecycle`, `integration-runner`, `tests-examples`, and `release-metadata` when present; do not create empty components or split tightly coupled files merely to preserve credit. Every changed deliverable must belong to exactly one component. For each component, record an exact semantic dependency-input pathspec set plus the reason each input can affect the component; do not use a coarse directory or prose-only `none` claim when build configuration, generated-surface owners, or shared runtime code are dependencies. When this skill's resources are available, prefer `python scripts/review_state.py --repo --base --pathspec-file task.paths --component-pathspec-file api-contract=api-contract.paths ... --complete-diff-output `; direct `--pathspec` and repeated `--component NAME=PATHSPEC` remain available for smaller diffs. Always generate the complete-diff artifact through `--complete-diff-output`; a standalone `git diff` omits ordinary untracked deliverables. Retain each component `content_fingerprint`, the combined `content_fingerprint`, and `repository_fingerprint`. Omit all pathspecs only when every repository change belongs to the task. Record the complexity delta and findings grouped by stable root-cause ID, severity, action, and whether each finding is new, repeated, or reintroduced. +11. Prepare one self-contained reviewer snapshot packet per round using [reviewer-brief.md](reviewer-brief.md) when available. Compute shared evidence once and reuse the same requirement, scope contract, target/base/head, manifests, fingerprint JSON, raw status, complete-diff command, preflight results, contract-surface inventory, state/data-flow inventories, and selected architecture excerpts for every reviewer. Assign stable IDs to every inventory row and evidence item. Populate every kind-specific inventory field documented by the reviewer brief; a summary-only inventory row is incomplete. Store the exact `review_state.py` JSON as the single artifact with `role: "review-state"`, store the raw output produced by that command's `--complete-diff-output` as the single artifact with `role: "complete-diff"`, store unfiltered `git status --porcelain=v1 -z --untracked-files=all` output as the single artifact with `role: "repository-status"`, and mark other artifacts with `role: "supporting"`. The packet's `review_state.evidence_id` points to the review-state artifact instead of copying fingerprint values, and `repository.status_evidence_id` points to the repository-status artifact. The repository fingerprint covers unfiltered status plus content identity for every changed path, including paths outside the task manifest. Assign every component and all three control artifacts to both reviewers. Packet preflight derives the combined and component fingerprints from the review-state artifact, requires the task and component manifests to match its pathspecs, requires `complete_diff_paths` to equal the task workspace exactly, requires the complete-diff digest to match its `complete_diff_sha256`, requires the status digest to match its unfiltered status fingerprint, and requires `repository.exclusions` to account exactly for every changed path outside the task manifest with a concrete reason. Every canonical ledger contract evidence ID must resolve to an indexed evidence artifact, and the ledger digest maps must bind the exact owned evidence and inventory content. Keep the task ID, task-global ledger path, and the immediately preceding round's immutable ledger snapshot plus SHA-256 digest in the active control plane outside the packet; never derive these authority arguments from the packet under validation, and never use the mutable current ledger as its own prior snapshot. Supply all four independently on every validator invocation after round 1. The validator requires packet, current-ledger, and prior-ledger identity to match those arguments, accepts only a same-round retry or an advance of exactly one round, reconciles the current round and remaining budget with the append-only authorized budget history, preserves the prior budget prefix, canonical root ownership, and owned-content digests, and assigns every inventory ID to exactly one canonical root. Keep the control-plane brief concise, with approximately 12 KB as a soft target; put larger raw diffs, logs, matrices, and reference excerpts in indexed evidence files and provide their exact paths plus SHA-256 digests. Exceed the target when compression would omit decision-relevant evidence, and record why. Populate every template field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Before dispatch, encode the packet index in the machine-readable schema documented by the reviewer brief and run `python scripts/review_protocol.py packet --packet --task-id --ledger --prior-ledger --prior-ledger-sha256 ` after round 1. Dispatch only when it exits successfully; use its emitted packet path, byte size, SHA-256 digest, exact combined fingerprint, component fingerprints, inventory IDs, and reviewer IDs as the launch record. Give each reviewer one ready-to-run fingerprint revalidation command and only the specialty assignment may differ. Do not ask reviewers to rediscover the workflow skill, implementation strategy, memory, release tag, manifest paths, helper location, or verification history. A reviewer may reopen primary source or released evidence when supplied evidence is inconsistent, appears wrong, or leaves a decision-relevant ambiguity, but reopening is not a substitute for missing mandatory packet contents and routine context reconstruction is implementer work. +12. Freeze task-owned content while reviewers for a round are running. Dispatch two independent reviewers concurrently on the same fingerprint. Give them complementary high-risk specialties. Every reviewer sees the complete raw diff and may report blockers outside its specialty. When the platform supports context-fork control, dispatch every reviewer with `fork_turns: "none"`; never pass the implementer's accumulated conversation or use a full-history fork. Launch both reviewers before waiting. Wait for both reviewers in the round before editing so findings can be grouped and fixed as one batch. Use one event-driven wait of 240 seconds or the platform's multi-target first-completion wait. Do not poll with `list_agents`, separate short waits, progress questions, or no-op `followup_task` messages. If an event-driven wait times out while reviewers remain unfinished, issue another event-driven 240-second wait for the unfinished set; repeat without polling until a reviewer completes, needs attention, or no unfinished reviewers remain. After one reviewer completes, continue waiting only for the remaining reviewer with another event-driven 240-second wait, applying the same timeout rule. A reviewer process that fails before producing a protocol-valid output because of startup, service, content-filter, context, or tool infrastructure has produced neither a finding nor clean credit and does not advance `ledger.current_round` or consume another fingerprint round. Replace only that reviewer on the same frozen packet and assignment; a protocol-valid output already accepted from the other reviewer remains usable while the task fingerprint, packet, and assignment are unchanged. The original two-reviewer concurrent dispatch satisfies the round's concurrency requirement; the accepted peer output plus one independently launched replacement output on the identical packet and assignment form the required pair. If an independent replacement remains unavailable, report the gate as unavailable instead of counting an infrastructure failure as review evidence. Do not start any broad final repository gate while review is incomplete or finding-bearing. In `openai-agents-python`, defer `make lint`, `make typecheck`, `make tests-review`, `make tests`, repository-wide builds, examples runners, and integration suites until step 19 establishes clean review. Use reviewer wait time for non-mutating evidence consolidation, finding classification preparation, host-capacity inspection, or other task work that cannot change the frozen fingerprint; otherwise continue the event-driven wait without progress polling. During an iterative review round, run only focused checks that target the changed boundary. Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` during an iterative review round. Prefer an already successful same-fingerprint focused check over rerunning it, and never replay cumulative historical verification. Represent reusable focused success as a verification receipt containing the exact command, environment, exit status, non-mutation basis, and identical before/after combined, component, and repository fingerprints. Include its absolute path and SHA-256 digest in `verification.credited_receipts`; packet preflight validates every credited receipt. The focused check earns no final-gate credit; the exact clean-reviewed fingerprint must still pass the complete repository-required verification stack. Set `verification.eligible_concurrent_gates` to `none` and list every deferred broad gate in `verification.deferred_gates`. Keep `$pr-draft-summary` deferred until clean review and final-gate evidence apply to the final fingerprint. Do not introduce a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. +13. Verify the combined and component fingerprints before accepting reviewer output. If reviewed runtime or contract-bearing content changed, discard the affected review evidence unless the change later qualifies for one of the narrow final-gate closures in step 20. If only `repository_fingerprint` changed, accept the review only for unambiguous non-semantic bookkeeping such as staging, unstaging, or committing identical task-owned content. Preserve clean credit for a semantic component only when its fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, and risk tier are all unchanged, except for a narrowly recorded step 20 closure. Require two concurrent independent delta reviews of every other changed or dependency-invalidated component plus its relevant boundaries with unchanged components. Any ambiguity invalidates the affected clean credit. Do not invalidate unrelated components solely because a neighboring file or coarse directory changed. +14. Apply a packet-and-output acceptance gate before counting findings or clean credit. Verify that every mandatory reviewer-brief field was populated or explicitly marked `none` or `not applicable`; missing packet evidence cannot be reconstructed by the reviewer and earns no clean credit. Require one structured JSON object with the documented schema: verdict, exact combined and component fingerprints, checked and unchecked inventory IDs, high-risk dimensions, focused probes, remaining uncertainty, findings, sibling-scenario scan, inspection call count, and inspection-budget reason when applicable. Every probe record must contain the exact executable command that ran, or the complete tool name and arguments for a non-shell probe. Reject prose-only labels, omitted arguments, and placeholders such as `` as incomplete evidence. Run `python scripts/review_protocol.py reviewer-output --packet --reviewer --output --task-id --ledger --prior-ledger --prior-ledger-sha256 ` for each output after round 1 and accept no finding or clean credit when it fails. The validator rejects fingerprint drift, missing assignment coverage, malformed probes, unknown bare root IDs, root evidence IDs absent from the packet's indexed evidence or inventory, sibling scans that use a renamed root or unknown inventory, JSON booleans in integer fields, changed prior evidence or inventory bindings, reopening a closed canonical root without content-new evidence or semantic inventory, and distinct new roots that reuse one evidence digest. When a reviewer discovers new evidence after dispatch, add and digest it in the frozen packet and rerun packet preflight in the same fingerprint round before requesting corrected output. A bare `clean`, generic checklist, malformed object, or response that does not account for the assigned contract/state/data-flow artifacts is incomplete and earns no clean credit; request only the missing fields or coverage on the same frozen fingerprint rather than restarting the whole review. When two specialists are used, combine their declared ID coverage and reject the round if any assigned inventory row or selected high-risk dimension remains unreviewed. Use approximately 12 source-inspection tool calls per reviewer as a soft budget. A reviewer may exceed it when unresolved decision-relevant uncertainty requires more evidence, but must state the reason; never trade correctness for the budget. +15. Classify and validate all findings from the round before editing, then fix every actionable finding as one batch. Before choosing the fix, update the complete relevant inventory or matrix with the discovered transition or surface and solve the root cause across all populated rows; do not patch only the reported interleaving. The implementer owns `$implementation-strategy` and supplies its current scope contract in the packet. Reviewers inherit that contract and must not rerun the strategy workflow. Rerun it only in the implementer context when a fix changes supported behavior, compatibility, state, ownership, protocol paths, test permutations, or triggers a complexity reset; otherwise record `scope contract unchanged` and avoid reconstructing the same strategy. Add caller-visible regression coverage, not tests that only mirror helper structure. Run focused verification only for affected boundaries and dependency-invalidated checks. +16. Treat a second related finding in one root-cause group as a closure gate. Stop local patching, run the complexity reset once, scan the complete inventory for sibling scenarios, and record one root-level disposition: replace the design, narrow or reject unsupported behavior, or escalate a concrete unresolved contract decision. After the disposition is implemented and reviewed, mark the canonical root-cause ID closed. Do not reopen it for another local patch without content-new contract evidence or semantic inventory; reject aliases, renamed or copied content, and bare unknown IDs instead of treating them as new roots. If it cannot be closed coherently, escalate instead of consuming more rounds. +17. Increment the fingerprint round, repeat the full commit-hook parity gate from step 1, and review the complete post-fix diff with fresh context. Continue review -> validate all findings -> batch fixes -> focused verification -> hook parity -> review without waiting for another user prompt. +18. Apply the non-convergence guard before another local fix: + - If the same root-cause group produces another P0/P1 after a complexity reset, return to the merge base and replace task-owned branch-local machinery with the narrowest coherent implementation. + - If runtime diff size, state fields, ownership modes, or test permutations grow materially for two consecutive rounds, do not call that convergence merely because each finding is local. Re-run the baseline-reset gate. + - If the same root-cause group produces actionable findings in three finding-bearing rounds, or the narrower reimplementation still produces the same root-cause P0/P1, escalate early rather than consuming the round budget. + - If four rounds complete without a shrinking or stable diff and falling finding severity, escalate early. +19. Stop successfully only after the required clean-review condition is met on the exact reviewed content and every required reviewer output has passed the acceptance gate: + - same-round infrastructure replacement: the original two-reviewer dispatch was concurrent, and the accepted pair consists of the unchanged protocol-valid peer output plus one independent replacement output accepted under step 12; + - high-risk change or any loop that produced a P0/P1 finding: two independent clean reviews of the same fingerprint with complementary high-risk specialties, launched concurrently. + - component-only post-review edit: clean credit for every unchanged component plus two concurrent clean independent delta reviews covering all changed components and their runtime boundary. + - verified final-gate type-erasure closure satisfying every condition in step 20: preserve the prior clean set without a new fingerprint round or reviewer dispatch, then run the complete final verification stack on the resulting fingerprint. + - verified base-advance closure satisfying every condition in step 20: preserve the prior clean set without a new fingerprint round or reviewer dispatch, then run the complete final verification stack on the replayed fingerprint. +20. After the clean-review condition is met, confirm that the diff and component fingerprints remain stable, then check observable host capacity before starting the repository's code-change verification. Use available read-only task or process evidence; treat another repository-wide test, typecheck, build, examples runner, or integration command already active on the same host as concrete contention. When contention is visible, continue useful non-heavy work or an event-driven wait and check again later. Do not create or wait on a repository lock, host-wide mutex, or sentinel file, and do not require a user-triggered `finalize` message. If host telemetry is unavailable, do not block solely because capacity cannot be measured. Once capacity is available, run every mandatory command in the repository-required order against the exact clean-reviewed fingerprint, or against the recorded resulting fingerprint of a verified closure below. Record combined, component, and repository fingerprints immediately before and after the final stack. Accept final verification only when every command succeeds, execution does not mutate that final content or create an ambiguous repository-state change, and all fingerprints still match. Classify any final-gate replay or edit before invalidating review evidence: + - Verified base-advance closure: when the intended target advances after clean review, preserve the existing clean set without a new fingerprint round or reviewer dispatch only when every condition below holds. This exception grants no final-verification credit; rerun every mandatory final gate on the replayed fingerprint and regenerate the complete PR handoff. + - The replay or rebase is conflict-free and requires no manual task-content edit. The old and new `review_state.py` artifacts have byte-identical task and component `workspace` arrays, and their `tracked_diff_sha256` values are identical; compare these fields directly because content fingerprints intentionally include the resolved base. + - Before the original review, each component recorded exact semantic dependency-input pathspecs and reasons. The complete upstream delta from old base to new base changes no task-manifest path, dependency-input path, selected architecture reference, generated-surface owner, or applicable build, test, lint, format, hook, lockfile, or package configuration input. + - The original requirement, scope contract, inventory rows, assertions about runtime behavior, selected review dimensions, risk tier, and released compatibility boundary are unchanged. Focused checks affected by integration with the new base pass. + - Record the old and new base, head, combined/component/repository fingerprints, exact upstream changed-path list and diff digest, dependency-input pathspecs, comparison commands, and results in the task-global ledger. This closure consumes no fingerprint round and creates no reviewer packet. + - Any path overlap, changed configuration or dependency input, conflict, manual resolution, changed diff digest, missing prior dependency map, or uncertainty falls through to a fresh review round on the new base. A coarse directory-disjointness claim is insufficient. + - Verified type-erasure-only edit: preserve the existing clean set without an independent delta review only when every condition below holds. This exception consumes no fingerprint round and requires no reviewer packet, but it does not grant final-gate credit; restart every mandatory final gate on the resulting fingerprint. + - The edit is made only after the final stack reports a formatter, linter, or static-type-checker failure, and the failure does not reveal unresolved runtime or contract uncertainty. + - The exact delta is limited to importing `cast` directly from the standard-library `typing` module, wrapping one unchanged private implementation expression as `cast(, )`, and formatter-only whitespace. The imported name is not rebound or used elsewhere. + - The edit does not change expression evaluation order or count, exception propagation, a public or exported annotation or signature, a decorator, runtime branch, constant, test assertion, generated surface, documentation, scope contract, inventory row, component dependency, or risk tier. + - The implementer records the before and after fingerprints, the exact delta, the original final-gate failure, and the runtime-identity basis that `typing.cast` returns its value unchanged. Targeted formatting, lint, type checking, and affected focused tests must pass before restarting the full stack. + - Any additional token change, behavioral-equivalence argument beyond this exact `typing.cast` shape, or uncertainty about the conditions above falls through to the normal runtime-edit rule and requires the applicable independent delta review. + - Runtime, public API, behavior-impacting docs, runtime-behavior assertions, or scope-contract change: invalidate the applicable clean set, rerun pre-review validation, and restart review with fresh reviewers before rerunning every required final gate. + - Tests or examples only: preserve clean runtime evidence only when the runtime fingerprint is identical and the delta does not change required behavior, compatibility, runtime-behavior assertions, or the scope contract. Run focused verification and a component delta review using the risk tier and clean-review conditions from step 19, covering test correctness, accidental contract expansion, and the runtime boundary, then rerun the required repository gates. Treat an example or expectation edit as behavior-impacting unless concrete evidence shows otherwise. + - Release metadata only: preserve runtime and test evidence when their fingerprints are identical. Revalidate the metadata and independently review any changed behavioral claim, then rerun applicable final gates. + - Operational artifact only: exclude it from deliverable manifests and do not invalidate review evidence. Completion requires the final combined fingerprint to be exactly composed of component fingerprints with applicable clean, delta-review, verified base-advance-closure, or verified type-erasure-closure evidence and every mandatory repository gate to pass on that final content. Invoke `$pr-draft-summary` last, only after review and verification evidence apply to the final fingerprint. +21. Stop the autonomous loop when the active cycle reaches its current budget: six fingerprint rounds for the initial implementation cycle or two for a post-completion feedback cycle. This is an absolute cap for the active cycle, not a target, and it does not reset when execution pauses or context changes. Do not call the implementation complete. Summarize the remaining blockers, recurring root causes, complexity growth, attempted fixes and resets, current verification state, and the concrete decisions available to the user; then ask the user whether to narrow scope, split the change, accept a stated risk, redesign, or explicitly authorize another bounded budget. When concrete actionable feedback arrives after a successfully completed and sealed cycle, append the feedback cycle's default two-round budget to the same ledger without another authorization prompt. In every other case, append a user-authorized budget to the same ledger rather than replacing its history. + +Maintain one compact round ledger throughout all review cycles and persist it as a durable, task-global artifact: + +`Round | component fingerprints | root-cause groups | highest severity | complexity delta | action | clean credit` + +Persist enough task identity, used and authorized round budgets, cycle boundaries, fingerprints, root-cause closure state, and clean credit to resume without reconstructing prior rounds. Update it only at a meaningful state transition: round start, accepted finding batch, complexity reset, clean result, verification result, sealed completion, or post-completion feedback-cycle start. Do not emit repeated waiting messages when neither reviewer state nor repository content changed. + +## Independent reviewer + +An independent review uses a fresh no-history context that did not implement the fingerprinted content and is not given prior reviewer findings or implementer conclusions. Prefer a distinct agent and set `fork_turns: "none"` when the platform exposes that control. A same-context self-review or full-history fork is not independent and cannot satisfy the clean-review gate. + +- Give the reviewer the original requirement, implementation scope contract, base and head identifiers, canonical component manifest and fingerprints, raw repository state, and relevant architecture references. +- Give the reviewer the precomputed contract-surface and await-boundary or authority/data-flow inventories. These are coverage maps, not conclusions; require the reviewer to validate every row against the raw diff and surrounding source. +- Tell the reviewer which identifier is the intended target and require an explicit merge-base calculation. When target and head diverge, provide or request a three-dot diff; do not present a two-dot target-to-head diff as the patch. +- Do not give the reviewer the implementer's conclusions, suspected bugs, intended fixes, or a list of expected findings. +- Ask for exactly one read-only review round. The reviewer must not edit or stage files, run the autonomous review loop recursively, spawn another reviewer, or perform the final repository verification. The implementer owns finding validation, edits, loop control, and final verification. +- Give every reviewer for a round the same review-state fingerprint and keep the diff frozen until all of them finish. Reject output produced from a different or changing state instead of merging partial observations across revisions. +- Give every reviewer the compact self-contained control-plane brief, indexed evidence paths and digests, and one exact revalidation command. If any mandatory packet field is neither populated nor explicitly marked `none` or `not applicable`, the reviewer must report it and cannot return a creditable clean verdict. Tell reviewers not to inspect memory, rediscover workflow skills, rerun implementation strategy, search for the fingerprint helper, or rediscover the release tag unless supplied evidence is inconsistent or decision-relevant. Reopening source cannot replace missing packet contents. This preserves fresh judgment while avoiding repeated setup work. +- Use fresh reviewers for every round when possible. Do not reveal findings or conclusions from prior rounds; provide only the updated requirement, scope contract, raw final diff, component manifest, and relevant references. +- Use two concurrent fresh reviewers for every round. For the high-risk conditions in step 12, assign complementary high-risk specialties while requiring each reviewer to inspect the complete diff. Both reviewers of the same unchanged diff are one fingerprint round. Do not duplicate broad test execution. +- Concurrent reviewers receive the same fingerprint and raw context but different primary specialties. They must not communicate during the round. +- Give the reviewer existing verification commands and results as raw evidence. The reviewer should inspect code and tests, then run only focused probes needed to resolve a decision-relevant uncertainty. A probe must be demonstrably non-mutating or run in an isolated temporary checkout; any mutation of the reviewed worktree invalidates the round. Do not rerun the repository's broad test, typecheck, lint, build, or integration suites merely to reconfirm the implementer's evidence; the implementer runs the complete stack once after the clean-review gate. +- Require the structured JSON output from the reviewer brief. `clean` alone is never sufficient: the reviewer must return the exact packet SHA-256 and fingerprints, checked and unchecked inventory IDs, high-risk dimensions checked, probes or `none`, unresolved uncertainty or `none`, findings, sibling-scenario scan, and inspection-budget accounting. +- After fixes, review the exact final diff again. Preserve earlier clean credit only under the explicit component-delta rule; do not infer that a change is isolated merely from its file location. + +When an independent reviewer is unavailable, rebuild context from the original request, scope contract, source, and complete diff before a best-effort self-review. Explicitly discard incremental-review assumptions, label the result non-independent, and do not count it toward the clean-review gate. Report the unavailable gate at handoff instead of silently weakening it. + +## Review dimensions + +Choose dimensions based on the changed boundary; do not mechanically invent findings for every item. + +### Requirement and scope + +- Verify that the smallest required caller-visible behavior works. +- Identify nearby constructible cases and confirm they are either intentionally supported or rejected before side effects. +- Require contract evidence before treating repeated, concurrent, reentrant, malformed, wrapped, or cross-provider combinations as blockers. Reproduce the same supported scenario on the baseline when claiming a regression. +- Check whether tests accidentally turn implementation permutations into public contract. +- Map every new abstraction, state field, branch, dependency, and cross-module change to a requirement, supported contract, or verified risk. + +### Compatibility and identity + +- Compare released public signatures, field order, imports, names, serialized values, configuration, and wire behavior. +- Preserve exact caller-visible identity or spelling unless transformation is required. +- Distinguish unreleased branch-local machinery from released or durable compatibility boundaries. +- For every new or modified public field, enumerate all construction, forwarding, and consumption branches. Verify that normal, specialized, default, missing-value, and error paths either honor the field or reject it according to one coherent contract; do not validate only the motivating branch. +- Search public docs, examples, docstrings, configuration reference, and release metadata for claims made stale by the behavior change. Missing documentation can be an actionable omission even when no documentation file is in the diff. Apply the repository's Documentation Release Timing policy before classifying the omission: required `docs/` content that would describe unreleased behavior is separately timed work, not a defect in the current task or pull request. + +### Lifecycle and failures + +- Trace ownership from acquisition through success, failure, cancellation, retry, replacement, and cleanup. +- When shared lifecycle state changes, build a compact operation-state matrix before concluding. Cover each affected public mutating operation against never-started, partial-failure, active, cleanup-in-progress, and terminal states as applicable. +- Trace repeated sequential calls and every relevant pair of overlapping public mutating operations. Identify the linearization point or manager-owned serialization mechanism; do not infer safety from per-resource deduplication alone. +- Check repeated cancellation, partial initialization, cleanup failure, retry through every supported public entry point, and primary-exception preservation. +- State the final survivor invariant: which tasks, workers, processes, sessions, listeners, files, or remote resources may remain. +- Review ordering when several validations or cleanup actions can short-circuit one another. +- Audit every check-await-side-effect sequence. State may change during the await; require revalidation or prove manager-owned serialization before cancellation, feedback, persistence, or cleanup. +- Distinguish current state from historical evidence. If a stale-result guarantee depends on whether a newer operation ever started, an active pointer that later returns to `None` cannot prove absence; use or require monotonic evidence unless the operation is serialized. + +### Security, trust, persistence, and protocol + +- Trace caller-controlled data through logs, exceptions, causes, contexts, telemetry, model-visible output, and persisted state. +- Treat serialized state as authority only when the supported trust boundary explicitly allows it. +- Check fail-closed behavior for malformed or ambiguous sensitive inputs without returning or retaining the original value. +- Verify protocol capability ownership, pagination termination, cache ownership, retry and replay safety, wire validation, and tool or call identity when affected. + +### Behavioral parity + +- Compare streaming and non-streaming, sync and async, initial and resumed, direct and wrapped, and provider-specific paths when the requirement crosses them. +- Verify that one path does not silently ignore, reshape, or hard-fail data that another path supports. + +### Tests and generated public surfaces + +- Prefer public-boundary or caller-visible adversarial tests. +- Exercise the highest stable caller boundary that reproduces the required behavior. A helper-only test is insufficient when a caller transforms the input, owns the lifecycle, or determines the observable result before or after invoking that helper. +- Require expected values and failure signals to come from the contract, a worked example, a baseline, or another independent oracle. Do not accept an assertion that recomputes the expected result with the same logic as the implementation. +- Use a narrower internal boundary when a lifecycle, concurrency, provider-wire, or malformed-stream scenario cannot be controlled reliably through a public entry point, and record why that boundary is necessary. +- Add controlled interleavings for concurrency instead of relying only on sequential tests. +- Test the required behavior, the nearest supported alternative, and one representative input per unsupported category. +- Do not accept passing existing tests as proof when they encode the same assumptions as the implementation. +- Import through intended consumer entry points and verify generated or distribution artifacts when public package behavior changes; runtime tests alone do not prove the published surface. + +## Complexity reset + +Run a complexity reset when related findings keep expanding the same design, a narrow requirement requires recursive or cached classification, tests enumerate mechanics, representations are inferred in multiple places, or the diff spreads unexpectedly across subsystems. + +1. Stop addressing findings one by one. +2. Group them by root cause and restate the original required behavior. +3. Compare the full diff with the merge base or release boundary. +4. Delete branch-local machinery that is not required. +5. Reuse the nearest existing source-of-truth pipeline. +6. Narrow unsupported behavior and reject it before side effects with a supported alternative when one exists. +7. Rebuild tests around caller-visible invariants and representative negative cases. +8. Compare the replacement's runtime and test complexity with both the previous round and the merge base. A reset that only renames or redistributes a growing state machine is not a reset. + +Review-state workspace entries must use the exact key set emitted for their `file`, `symlink`, `gitlink`, `directory`, or `missing` kind; incomplete or unknown fields fail before dispatch. + +For reusable verification credit, a receipt command must exactly match a structured command in `verification.preflight_results`; a different successful command cannot inherit verification credit. + +## Review output + +Return exactly one JSON object using the schema in [reviewer-brief.md](reviewer-brief.md). Put the verdict in `verdict`; put each actionable finding in `findings` with its priority, title, location, concrete failure scenario, user-visible consequence, support basis, baseline-versus-patch evidence when applicable, smallest safe correction, and stable root-cause ID. Account for every assigned inventory ID and keep unverified runtime uncertainty explicit. Do not claim implementation completion until both structured clean reviews and required verification apply to the exact final state. diff --git a/.agents/skills/implementation-final-review/references/reviewer-brief.md b/.agents/skills/implementation-final-review/references/reviewer-brief.md index 5af9094ca5..20c1b47a44 100644 --- a/.agents/skills/implementation-final-review/references/reviewer-brief.md +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -12,7 +12,7 @@ Generate review state only from two consecutive identical repository observation Use this template to prepare one self-contained, factual snapshot packet per fingerprint round. Fill every field or mark it explicitly `none` or `not applicable`; do not dispatch an incomplete packet. Fill it once, reuse the shared body byte-for-byte for every reviewer, and vary only the final specialty assignment. Keep this control-plane brief near 12 KB when practical. Store larger evidence in indexed files and reference each file by exact path and SHA-256 digest. Do not omit decision-relevant evidence merely to meet the soft size target. Do not include implementer conclusions, suspected bugs, prior findings, or intended fixes. -The verified final-gate type-erasure and base-advance closures defined in `SKILL.md` step 20 do not create a fingerprint round, reviewer packet, or reviewer assignment. For a type-erasure closure, record its exact delta, before and after fingerprints, final-gate failure, runtime-identity basis, and focused verification in the task-global ledger and final verification evidence. For a base-advance closure, record the old and new base, head, fingerprints, byte-identical task and component workspace evidence, identical tracked-diff digest, complete upstream changed-path list and diff digest, exact dependency-input pathspecs, and focused integration checks. If every condition for the applicable exception is not mechanically established, prepare the normal delta-review packet instead. +The verified final-gate type-erasure and base-advance closures defined in [high-risk-review.md](high-risk-review.md) step 20 do not create a fingerprint round, reviewer packet, or reviewer assignment. For a type-erasure closure, record its exact delta, before and after fingerprints, final-gate failure, runtime-identity basis, and focused verification in the task-global ledger and final verification evidence. For a base-advance closure, record the old and new base, head, fingerprints, byte-identical task and component workspace evidence, identical tracked-diff digest, complete upstream changed-path list and diff digest, exact dependency-input pathspecs, and focused integration checks. If every condition for the applicable exception is not mechanically established, prepare the normal delta-review packet instead. ## Shared evidence @@ -26,7 +26,7 @@ The verified final-gate type-erasure and base-advance closures defined in `SKILL - Resolved merge base: - HEAD: - Latest release boundary when relevant: -- Risk tier and reason: +- Risk tier and reason (high risk; encode `task.risk_tier` as `"elevated"`): - Task-global ledger path, task identity, current round, and remaining authorized budget: - Canonical root-cause ledger (`ID | open/closed | inventory IDs | contract evidence IDs`): - Canonical task manifest (an exact normalized file entry remains authoritative when ignored; directory and glob entries do not promote ignored files): diff --git a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py b/.agents/skills/implementation-final-review/scripts/test_skill_contract.py deleted file mode 100644 index 951e1f7eca..0000000000 --- a/.agents/skills/implementation-final-review/scripts/test_skill_contract.py +++ /dev/null @@ -1,609 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import re -import unittest -from pathlib import Path - - -class SkillContractTest(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.skill_root = Path(__file__).resolve().parent.parent - cls.skill = (cls.skill_root / "SKILL.md").read_text() - cls.agent_config = (cls.skill_root / "agents" / "openai.yaml").read_text() - cls.reviewer_brief = (cls.skill_root / "references" / "reviewer-brief.md").read_text() - cls.review_protocol = (cls.skill_root / "scripts" / "review_protocol.py").read_text() - cls.repo_instructions = (cls.skill_root.parents[2] / "AGENTS.md").read_text() - cls.code_change_verification = ( - cls.skill_root.parent / "code-change-verification" / "SKILL.md" - ).read_text() - cls.implementation_kickoff = ( - cls.skill_root.parent / "implementation-kickoff" / "SKILL.md" - ).read_text() - cls.handoff_validator = ( - cls.skill_root.parent / "implementation-kickoff" / "scripts" / "validate_handoff.py" - ).read_text() - - def test_repo_local_metadata_matches_skill(self) -> None: - self.assertEqual(self.skill.splitlines()[1], "name: implementation-final-review") - self.assertIn('display_name: "Implementation Final Review"', self.agent_config) - self.assertIn("$implementation-final-review", self.agent_config) - self.assertIn("allow_implicit_invocation: false", self.agent_config) - - def test_workflow_steps_are_consecutive(self) -> None: - workflow = self.skill.split("## Workflow", 1)[1].split( - "Maintain one compact round ledger", 1 - )[0] - steps = [int(value) for value in re.findall(r"^(\d+)\. ", workflow, re.MULTILINE)] - - self.assertEqual(steps, list(range(1, 22))) - - def test_quality_gates_cover_prior_failure_modes(self) -> None: - required_text = ( - "contract-surface inventory", - "every consumer, forwarding branch, and adapter", - "Search adjacent contract surfaces even when they are absent from the diff", - "do not add it to the current task manifest, report it as a " - "current-pull-request finding, or let it block clean review", - "await-boundary matrix", - "a newer operation that starts and completes while suspended", - "current active state is insufficient", - "A bare `clean`", - ) - - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_reviewer_brief_avoids_repeated_context_discovery(self) -> None: - required_text = ( - "Exact fingerprint revalidation command", - "Complete three-dot diff command", - "Do not edit or stage files", - "inspect memory", - "rediscover workflow skills", - "A bare `clean` or generic checklist is incomplete", - ) - - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.reviewer_brief) - - def test_incomplete_reviewer_packets_fail_closed(self) -> None: - required_skill_text = ( - "Populate every template field or mark it explicitly `none` or `not applicable`", - "do not dispatch an incomplete packet", - "missing packet evidence cannot be reconstructed by the reviewer", - "cannot return a creditable clean verdict", - "Reopening source cannot replace missing packet contents", - ) - required_brief_text = ( - "Fill every field or mark it explicitly `none` or `not applicable`", - "do not dispatch an incomplete packet", - "report the missing field and do not return a creditable clean verdict", - "do not use reopening to replace missing packet contents", - ) - - for text in required_skill_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - for text in required_brief_text: - with self.subTest(text=text): - self.assertIn(text, self.reviewer_brief) - - def test_full_verification_waits_for_clean_review(self) -> None: - required_text = ( - "Do not start any broad final repository gate while review is incomplete or " - "finding-bearing", - "defer `make lint`, `make typecheck`, `make tests-review`, `make tests`, " - "repository-wide builds, examples runners, and integration suites until step 19 " - "establishes clean review", - "Do not run `make tests-review`, `make tests`, or repository-wide `make typecheck` " - "during an iterative review round", - "Set `verification.eligible_concurrent_gates` to `none`", - "the exact clean-reviewed fingerprint must still pass the complete " - "repository-required verification stack", - "After the clean-review condition is met", - ) - - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - self.assertIn("Eligible concurrent final-gate commands: `none`", self.reviewer_brief) - self.assertIn("Broad final gates deferred until clean review", self.reviewer_brief) - self.assertIn( - "packet preflight rejects any attempt to overlap a broad final gate with review", - self.reviewer_brief, - ) - - def test_host_capacity_check_avoids_locks_and_finalize_prompts(self) -> None: - for source in (self.skill, self.code_change_verification, self.repo_instructions): - with self.subTest(source=source[:40]): - self.assertIn("available read-only task or process evidence", source) - self.assertIn("repository lock", source) - self.assertIn("host-wide mutex", source) - self.assertIn("user-triggered `finalize`", source) - - self.assertIn("If host telemetry is unavailable", self.skill) - self.assertIn("Lack of host telemetry alone is not a blocker", self.repo_instructions) - - def test_commit_hook_parity_runs_before_review(self) -> None: - required_skill_text = ( - "Inspect the actual final commit-hook configuration", - "exact safe, non-committing equivalent", - "until a second execution is content-idempotent", - "Normalize generated files before computing embedded hashes or provenance", - "before every fingerprint freeze, including post-fix and delta-review rounds", - "exact executable inspection and rewriting commands plus their results", - ) - required_kickoff_text = ( - "Before freezing the first review fingerprint", - "Repeat rewriting steps until content-idempotent", - "verify generated-file hashes or provenance after normalization", - ) - for text in required_skill_text: - with self.subTest(source="skill", text=text): - self.assertIn(text, self.skill) - for text in required_kickoff_text: - with self.subTest(source="kickoff", text=text): - self.assertIn(text, self.implementation_kickoff) - self.assertIn("idempotent commit-hook parity", self.reviewer_brief) - - def test_reviewer_infrastructure_failure_stays_in_the_same_round(self) -> None: - required_text = ( - "fails before producing a protocol-valid output", - "has produced neither a finding nor clean credit", - "does not advance `ledger.current_round` or consume another fingerprint round", - "Replace only that reviewer on the same frozen packet and assignment", - "The original two-reviewer concurrent dispatch satisfies the round's concurrency " - "requirement", - "the accepted peer output plus one independently launched replacement output", - "report the gate as unavailable instead of counting an infrastructure failure as " - "review evidence", - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_complete_diff_includes_untracked_task_deliverables(self) -> None: - required_text = ( - "--complete-diff-output ", - "a standalone `git diff` omits ordinary untracked deliverables", - "`complete_diff_paths` to equal the task workspace exactly", - "`complete_diff_sha256`", - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - self.assertIn("task-owned untracked files are included", self.reviewer_brief) - self.assertIn( - "ordinary task-owned untracked files are present", self.implementation_kickoff - ) - self.assertIn( - "shipped-path manifest must be a finite regular file", self.implementation_kickoff - ) - self.assertIn("authoritative even when ignore rules match that file", self.skill) - self.assertIn("literal precedence over Git pathspec metacharacters", self.skill) - self.assertIn("use explicit `:(glob)` magic", self.skill) - self.assertIn( - "directory or glob pathspec never promotes ignored operational files", self.skill - ) - self.assertIn( - "every initialized submodule, including nested submodules, to be clean", self.skill - ) - self.assertIn("Stage reviewable gitlink pointer changes", self.skill) - self.assertIn("fail closed on dirty worktrees, hidden index flags", self.skill) - self.assertIn("Reject cyclic or aliased submodule worktree graphs", self.skill) - self.assertIn("two consecutive identical observations of HEAD", self.skill) - self.assertIn("state changes during capture", self.skill) - self.assertIn("including FIFOs, sockets, and devices", self.skill) - self.assertIn("Require unique keys and standard finite numbers", self.skill) - self.assertIn("JavaScript-style `NaN` or infinity constants", self.skill) - self.assertIn("numeric exponents that overflow to infinity", self.skill) - self.assertIn("numeric-size and nesting-limit failures", self.skill) - self.assertIn( - "Every JSON object must use unique keys and standard finite numbers", - self.reviewer_brief, - ) - self.assertIn("`NaN`, `Infinity`, and `-Infinity` constants", self.reviewer_brief) - self.assertIn("numeric exponents that overflow to infinity", self.reviewer_brief) - self.assertIn("numeric-size and nesting-limit failures", self.reviewer_brief) - self.assertIn("distinct normalized primary and high-risk specialties", self.skill) - self.assertIn("require every preflight command to be unique", self.skill) - self.assertIn("exact, unique `command` and `result` objects", self.reviewer_brief) - self.assertIn("primary and high-risk specialties must not overlap", self.reviewer_brief) - self.assertIn("Reject unknown fields instead of ignoring", self.skill) - self.assertIn("Unknown receipt fields are invalid", self.reviewer_brief) - self.assertIn("Unknown fields are invalid rather than ignored", self.reviewer_brief) - self.assertIn("two consecutive identical repository observations", self.reviewer_brief) - self.assertIn("path-level `stat` must not authorize a later reopen", self.reviewer_brief) - self.assertIn("unique opened-file device and inode identities", self.reviewer_brief) - self.assertIn("evidence digest absent from every canonical root", self.reviewer_brief) - self.assertIn("`contract_evidence_sha256`", self.reviewer_brief) - self.assertIn("`inventory_sha256`", self.reviewer_brief) - self.assertIn("prior bindings are immutable", self.reviewer_brief) - self.assertIn("every distinct root proposed in the same output", self.reviewer_brief) - self.assertIn( - "Credited receipt content digests and exact commands must be unique", - self.reviewer_brief, - ) - self.assertIn("requires their validated digests to remain unchanged", self.reviewer_brief) - - def test_verified_base_advance_closure_is_strict_and_keeps_final_verification(self) -> None: - required_text = ( - "Verified base-advance closure", - "byte-identical task and component `workspace` arrays", - "their `tracked_diff_sha256` values are identical", - "complete upstream delta from old base to new base", - "dependency-input path", - "applicable build, test, lint, format, hook, lockfile, or package configuration input", - "This closure consumes no fingerprint round and creates no reviewer packet", - "falls through to a fresh review round on the new base", - "rerun every mandatory final gate on the replayed fingerprint", - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - self.assertIn("verified base-advance closure", self.implementation_kickoff) - self.assertIn("rerun every mandatory final verification gate", self.implementation_kickoff) - self.assertIn("exact base pathspecs", self.reviewer_brief) - self.assertIn("prose-only or empty dependency claims", self.reviewer_brief) - self.assertIn("keys exactly match the component names", self.reviewer_brief) - self.assertIn( - "nonempty arrays of exact `pathspec` and `reason` records", - self.reviewer_brief, - ) - self.assertIn("maps every component name to a nonempty array", self.skill) - - def test_operational_artifacts_are_excluded_from_the_handoff_manifest(self) -> None: - required_kickoff_text = ( - "operational-only by default", - "Compare the staged changed-path set byte-for-byte with the canonical shipped manifest", - "do not stage an ignored ExecPlan or review artifact", - "--shipped-path-manifest ", - ) - for text in required_kickoff_text: - with self.subTest(text=text): - self.assertIn(text, self.implementation_kickoff) - self.assertIn('"--shipped-path-manifest"', self.handoff_validator) - self.assertIn( - "Committed paths do not match the shipped-path manifest", self.handoff_validator - ) - - def test_work_status_reporting_distinguishes_running_and_final_states(self) -> None: - required_text = ( - "Use `RUNNING` only in commentary", - "Use `COMPLETE` in the final response only when", - "Use `NEEDS_DECISION` in the final response only when", - 'instead of asking the user to say "continue"', - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.repo_instructions) - - def test_iterative_review_uses_focused_checks_only(self) -> None: - required_text = ( - "Prefer focused tests plus a narrowly targeted import, generated-surface, or static " - "check", - "Run a targeted type check only when the change directly affects a typing boundary", - "Do not run repository-wide lint, typecheck, builds, integration suites, " - "`make tests-review`, or `make tests`", - "run only focused checks that target the changed boundary", - "The focused check earns no final-gate credit", - ) - - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_final_gate_deltas_are_classified_by_component(self) -> None: - required_text = ( - "Runtime, public API, behavior-impacting docs", - "Tests or examples only", - "Release metadata only", - "Operational artifact only", - "final combined fingerprint", - ) - - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_shared_typescript_improvements_keep_python_boundaries(self) -> None: - required_text = ( - "package exports and generated public surfaces when applicable", - "protocol capability ownership, pagination termination, cache ownership", - "defer `make lint`, `make typecheck`, `make tests-review`, `make tests`, " - "repository-wide builds", - "the implementer runs the complete stack once after the clean-review gate", - ) - - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - self.assertNotIn("$changeset-validation", self.skill) - self.assertNotIn("browser/Node/workerd", self.skill) - - def test_final_clean_condition_preserves_two_independent_reviews(self) -> None: - self.assertIn( - "normal-risk change: two independent clean reviews of the same fingerprint, " - "launched concurrently", - self.skill, - ) - self.assertIn( - "elevated-risk change or any loop that produced a P0/P1 finding", - self.skill, - ) - self.assertIn("Use two concurrent fresh reviewers for every round", self.skill) - self.assertNotIn( - "released compatibility, or any loop that produced a P0/P1 finding", - self.skill, - ) - - def test_cross_references_use_current_step_numbers(self) -> None: - self.assertIn("high-risk conditions in step 12", self.skill) - self.assertIn( - "component delta review using the risk tier and clean-review conditions from step 19", - self.skill, - ) - self.assertNotIn("high-risk conditions in step 10", self.skill) - - def test_independent_review_uses_no_history_and_event_driven_waits(self) -> None: - required_skill_text = ( - 'dispatch every reviewer with `fork_turns: "none"`', - "never pass the implementer's accumulated conversation or use a full-history fork", - "Launch both reviewers before waiting", - "one event-driven wait of 240 seconds", - "Do not poll with `list_agents`, separate short waits, progress questions, or no-op " - "`followup_task` messages", - "After one reviewer completes, continue waiting only for the remaining reviewer with " - "another event-driven 240-second wait", - "If an event-driven wait times out while reviewers remain unfinished", - "repeat without polling until a reviewer completes, needs attention, or no unfinished " - "reviewers remain", - ) - for text in required_skill_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - self.assertIn('dispatcher uses `fork_turns: "none"`', self.reviewer_brief) - - def test_round_budget_preserves_history_across_feedback_cycles(self) -> None: - required_text = ( - "Resume or create the task-global review ledger", - "Use the Codex task or thread ID as the stable task identity when available", - "Store the ledger as an ignored operational file at a stable absolute path", - "preserve the same file when work moves to another worktree", - "Never initialize a new counter merely because the task was paused, compacted, " - "handed off, renamed, moved to another worktree, or resumed in another context", - "default autonomous budget for the initial implementation cycle is six " - "fingerprint rounds", - "concrete actionable review feedback starts a post-completion feedback cycle", - "feedback message itself as authorization to append a default budget of two " - "fingerprint rounds to the same ledger", - "A continuation request without concrete new feedback remains in the existing cycle", - "append the feedback cycle's default two-round budget to the same ledger without " - "another authorization prompt", - "Persist enough task identity, used and authorized round budgets", - "Persist the current combined content fingerprint as `ledger.round_fingerprint`", - "A same-round retry is valid only when that value and the authorized budget history " - "match", - "exact packet SHA-256 and fingerprints", - "no unresolved merge stages before fingerprinting", - "resolve to finite regular files", - "Verify the file type after opening", - "read content from that same descriptor", - "Canonicalize each path before opening", - "opened descriptor's device and inode identity", - "require their validated digests to remain unchanged", - "evidence or inventory as new for a canonical root only when its digest is absent", - "Preserve those digest bindings across rounds", - "every distinct root proposed in the same output", - "credited receipt to have a unique content digest and exact command", - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_second_related_finding_closes_the_root_cause_group(self) -> None: - required_text = ( - "Treat a second related finding in one root-cause group as a closure gate", - "run the complexity reset once", - "scan the complete inventory for sibling scenarios", - "mark the canonical root-cause ID closed", - "Do not reopen it for another local patch without content-new contract evidence or " - "semantic inventory", - "reject aliases, renamed or copied content, and bare unknown IDs", - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_snapshot_packet_and_structured_output_bound_repeated_work(self) -> None: - required_skill_text = ( - "approximately 12 KB as a soft target", - "indexed evidence files", - "exact paths plus SHA-256 digests", - "Assign stable IDs to every inventory row and evidence item", - "Require one structured JSON object", - "inspection call count", - "approximately 12 source-inspection tool calls per reviewer as a soft budget", - ) - required_brief_text = ( - "Indexed evidence manifest (`ID | role | exact path | SHA-256 | purpose`)", - "Semantic component dependency map (`component | exact base pathspecs | " - "invalidation reason`)", - '"checked_inventory_ids"', - '"unchecked_inventory_ids"', - '"sibling_scenario_scan"', - '"inspection_call_count"', - '"inspection_budget_reason"', - "A `clean` verdict requires an empty `unchecked_inventory_ids`, " - "`remaining_uncertainty`, and `findings` array", - "Every `focused_probes[].command` must contain the exact executable command that ran", - "Prose-only labels, omitted arguments, and placeholders such as ``", - "return its path, SHA-256 digest, and exact execution command", - ) - for text in required_skill_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - for text in required_brief_text: - with self.subTest(text=text): - self.assertIn(text, self.reviewer_brief) - - def test_semantic_clean_credit_fails_closed_on_dependency_changes(self) -> None: - required_text = ( - "Partition the manifest by the narrowest stable semantic boundaries", - "`api-contract`, `runstate-persistence`, `security-sandbox`, " - "`session-lifecycle`, `integration-runner`, `tests-examples`, and " - "`release-metadata`", - "fingerprint, requirement rows, assertions about runtime behavior, dependency inputs, " - "and risk tier are all unchanged", - "changed or dependency-invalidated component", - "Any ambiguity invalidates the affected clean credit", - "Do not invalidate unrelated components solely because a neighboring file or coarse " - "directory changed", - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_intermediate_verification_is_cost_aware_but_final_gate_is_complete(self) -> None: - required_text = ( - "Prefer an already successful same-fingerprint focused check over rerunning it", - "never replay cumulative historical verification", - "The focused check earns no final-gate credit", - "the exact clean-reviewed fingerprint must still pass the complete " - "repository-required verification stack", - "check observable host capacity before starting the repository's " - "code-change verification", - ) - for text in required_text: - with self.subTest(text=text): - self.assertIn(text, self.skill) - - def test_machine_readable_protocol_closes_observed_convergence_gaps(self) -> None: - required_skill_text = ( - "python scripts/review_protocol.py packet --packet --task-id " - " --ledger ", - "packet path, byte size, SHA-256 digest", - "The implementer assigns every root-cause ID once", - "propose exactly `NEW:`", - "verification receipt containing the exact command, environment, exit status, " - "non-mutation basis", - "combined, component, and repository fingerprints", - "root evidence IDs absent from the packet's indexed evidence or inventory", - 'role: "complete-diff"', - 'role: "review-state"', - 'role: "repository-status"', - "review_state.evidence_id", - "repository.status_evidence_id", - "Assign every component and all three control artifacts to both reviewers", - "requires the complete-diff digest to match its `complete_diff_sha256`", - "requires `repository.exclusions` to account exactly", - "summary-only inventory row is incomplete", - "active control plane outside the packet", - "authorized budget history", - "immediately preceding round's immutable ledger snapshot plus SHA-256 digest", - "same-round retry or an advance of exactly one round", - "never use the mutable current ledger as its own prior snapshot", - "repository fingerprint covers unfiltered status plus content identity", - "receipt command must exactly match a structured command", - "exact key set emitted for their `file`, `symlink`, `gitlink`, `directory`, or " - "`missing` kind", - "Trust the active implementation control plane to record actual reviewer dispatches", - "assigns every inventory ID to exactly one canonical root", - "sibling scans that use a renamed root or unknown inventory", - "JSON booleans in integer fields", - "add and digest it in the frozen packet", - "python scripts/review_protocol.py reviewer-output --packet --reviewer " - " --output --task-id --ledger ", - ) - required_brief_text = ( - "## Machine-readable preflight", - "If the packet exceeds 12 KiB", - "NEW:", - '"root_cause_evidence"', - "Every submitted contract evidence ID must name an indexed", - "submitted IDs must be additions owned by that root in the current ledger", - "Every contract evidence ID must resolve to an `evidence_artifacts[].id`", - "JSON booleans are not integers for protocol purposes", - "Each sibling-scenario scan must reuse a canonical root ID", - "verification.preflight_results` as an array of exact, unique `command` and " - "`result` objects", - "ledger file's JSON object to match the packet ledger exactly", - "not already owned by any canonical or distinct proposed root", - "digest maps must bind exactly the currently owned IDs", - "absolute `path` and `sha256` digest", - 'role: "review-state"', - 'role: "repository-status"', - "The `review_state` packet object contains exactly `evidence_id`", - "extra copied fingerprint or state fields are invalid", - "requires the complete-diff artifact digest to equal its `complete_diff_sha256`", - "Supply the task ID and absolute task-global ledger path independently", - "`current_round` plus `remaining_budget` to equal the sum", - "immediately preceding round's immutable ledger snapshot and its SHA-256 digest", - "same-round retry or advance by exactly one", - "immutable snapshot must be a distinct file", - "an inventory ID owned by another root cannot be reassigned", - "does not provide cryptographic attestation against a malicious control plane", - "current budget history to preserve the prior prefix", - "each inventory ID has exactly one canonical root owner", - "accepts only a receipt path already indexed", - "task or repository-state drift", - "complete typed workspace entries", - "rejects an incomplete or unknown key for any workspace kind", - "unrelated successful commands are ineligible for credit", - 'Encode those columns in each `kind: "contract"` inventory object', - 'Encode those columns in each `kind: "authority-data-flow"` inventory object', - 'Encode those columns in each `kind: "await-boundary"` inventory object', - "requires exclusions to account exactly", - "verification.credited_receipts", - "python scripts/review_protocol.py receipt", - "python scripts/review_protocol.py reviewer-output", - ) - required_script_text = ( - "PACKET_SOFT_LIMIT_BYTES = 12 * 1024", - "NEW_ROOT_CAUSE_ID", - "validate_packet", - "validate_reviewer_output", - "validate_receipt_data", - ) - - for text in required_skill_text: - with self.subTest(source="skill", text=text): - self.assertIn(text, self.skill) - for text in required_brief_text: - with self.subTest(source="brief", text=text): - self.assertIn(text, self.reviewer_brief) - for text in required_script_text: - with self.subTest(source="script", text=text): - self.assertIn(text, self.review_protocol) - - def test_final_reviewers_inherit_strategy_evidence(self) -> None: - self.assertIn( - "The implementer owns `$implementation-strategy` and supplies its current scope " - "contract in the packet", - self.skill, - ) - self.assertIn( - "Reviewers inherit that contract and must not rerun the strategy workflow", - self.skill, - ) - self.assertIn( - "Independent reviewers dispatched by `$implementation-final-review` inherit the " - "implementer's recorded implementation scope contract", - self.repo_instructions, - ) - self.assertIn( - "The implementer remains responsible for rerunning `$implementation-strategy`", - self.repo_instructions, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/.agents/skills/implementation-kickoff/SKILL.md b/.agents/skills/implementation-kickoff/SKILL.md index e3174cf5d4..42bac39c8d 100644 --- a/.agents/skills/implementation-kickoff/SKILL.md +++ b/.agents/skills/implementation-kickoff/SKILL.md @@ -1,6 +1,6 @@ --- name: implementation-kickoff -description: Start and carry an explicitly invoked openai-agents-python implementation through a fresh isolated worktree and a local PR-ready handoff. Fetch the latest origin/main, keep task changes uncommitted, replay them onto the latest main before final review, run applicable verification and $implementation-final-review, use $pr-draft-summary to generate the complete PR draft and branch name, then create one clean local commit with takeover provenance when applicable. Use only when the user explicitly invokes this skill; never push, open a PR, or mutate GitHub. +description: Carry implementation through an isolated worktree and local handoff. Use only when this skill is explicitly invoked. --- # Implementation Kickoff @@ -64,11 +64,9 @@ Record this observed `origin/main` commit as the final-base candidate. Do not ca ## 5. Complete final review and verification -Run the repository's applicable completion gates against the complete task-owned diff on the final-base candidate. Before freezing the first review fingerprint, inspect the actual final commit-hook configuration and run the exact safe, non-committing equivalent of every hook step that can rewrite a shipped path. Repeat rewriting steps until content-idempotent, and verify generated-file hashes or provenance after normalization. Generate final-review evidence with `review_state.py --complete-diff-output ` so ordinary task-owned untracked files are present in the reviewed diff without staging them. For runtime code, tests, examples, build or test behavior, or behavior-impacting docs, run `$implementation-final-review` and the required `$code-change-verification` sequence in their mandated order. Honor their fingerprint and invalidation rules. +Run applicable completion gates against the complete task-owned diff on the final-base candidate. Apply formatting and safe hook-equivalent normalization before review, including generated-file provenance checks where relevant. Use `$implementation-final-review` to select lightweight, ordinary, or high-risk review; supply all task-owned untracked file contents as well as tracked changes. Only high-risk review requires `review_state.py --complete-diff-output ` and the strict packet/ledger protocol. Follow the selected procedure's evidence and invalidation rules. -Skip those skills only when their own repository rules say the task is ineligible, such as a repo-meta-only change. Do not weaken an eligible gate merely because the diff is small. - -Do not create the branch or commit when review is non-converging, verification fails, required evidence is missing, or the final content lacks clean-review credit. +Run `$code-change-verification` after clean review whenever its SDK eligibility rules apply. A lightweight review exemption does not waive an otherwise required SDK verification stack. Repo-meta work uses its applicable skill and focused checks. Do not create the branch or commit while required review, verification, or evidence remains incomplete. ## 6. Generate the complete PR handoff @@ -82,7 +80,7 @@ If the diff, scope, base, behavior claim, issue relationship, or provenance chan ## 7. Recheck main and create one commit -Fetch `origin main` once more immediately before creating the branch. If it differs from the final-base candidate, return to section 4 and replay onto the new base. Then apply `$implementation-final-review` step 20: preserve clean-review credit only when the verified base-advance closure proves an identical task diff and component workspace plus a complete, non-overlapping upstream dependency/tooling audit. Even when that closure applies, rerun every mandatory final verification gate on the new base and regenerate the PR handoff. If any closure condition is missing or ambiguous, repeat affected checks, fresh independent review, verification, and PR handoff. Once stable: +Fetch `origin main` once more immediately before creating the branch. If it differs from the final-base candidate, return to section 4 and replay onto the new base. Apply the selected review tier's base-change rules in `$implementation-final-review`; only high-risk work uses the verified base-advance closure in [high-risk-review.md](../implementation-final-review/references/high-risk-review.md) step 20. Recheck relevant upstream dependencies and tooling before retaining review evidence. Rerun applicable final verification on the new base and regenerate the PR handoff; obtain affected independent re-review whenever the selected tier requires it. Once stable: 1. Check whether the suggested branch exists locally, remotely, or in another worktree. Ask `$pr-draft-summary` for the next available numeric suffix and regenerate the handoff before creating a colliding branch. 2. Create the exact suggested branch in the task worktree. diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index fcbe68a187..d67b21623b 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -1,6 +1,6 @@ --- name: implementation-strategy -description: Choose compatibility-aware scope for runtime and API changes in openai-agents-python. Use before initial implementation and each review-feedback batch to decide whether to patch, reset the design, preserve compatibility, or reject unsupported cases. +description: Choose supported scope and compatibility boundaries for SDK behavior changes; revisit when feedback changes the design. --- # Implementation Strategy @@ -8,7 +8,7 @@ description: Choose compatibility-aware scope for runtime and API changes in ope ## Workflow 1. Identify the surface you are changing or reviewing: released public API, unreleased branch-local API, internal helper, persisted schema, wire protocol, CLI/config/env surface, or docs/examples only. -2. Determine the latest release tag to use as the compatibility baseline from `origin` first, and only fall back to local tags when remote tags are unavailable: +2. When released compatibility is relevant, determine the latest release tag from `origin` first, falling back to local tags only when remote tags are unavailable. Reuse an already verified baseline within the task unless new release evidence or changed scope makes it stale: ```bash BASE_TAG="$(.agents/skills/final-release-review/scripts/find_latest_release_tag.sh origin 'v*' 2>/dev/null || git tag -l 'v*' --sort=-v:refname | head -n1)" echo "$BASE_TAG" @@ -17,12 +17,12 @@ description: Choose compatibility-aware scope for runtime and API changes in ope 3. Record the implementation scope contract below before coding. 4. Identify the nearest existing implementation pipeline and the functions, types, or modules that are the source of truth for each affected concern. Prefer adapting the required input into that pipeline over creating parallel schema, metadata, validation, naming, or execution machinery. 5. Choose the smallest coherent change using the core decision rules. Add compatibility machinery only for a required supported boundary. -6. Before editing each review-feedback batch, run the review gate against the complete branch diff, not only the latest revision. +6. Revisit the review gate when feedback changes supported behavior, compatibility, ownership, protocol paths, implementation shape, or test permutations, or triggers a complexity reset. Otherwise retain the scope contract and proceed with the focused fix. 7. Before handoff, run the effectiveness check. If any answer is no, revise the design. ## Implementation scope contract -Record these four items in the plan or working notes, and update them before widening or narrowing the implementation: +Record these four items in the existing plan or working notes, and update them before widening or narrowing the implementation. A local fix can use one concise paragraph; mark unaffected dimensions as not applicable rather than inventing unsupported cases or creating another document: 1. **Required behavior:** The smallest user-visible scenario that must work. 2. **Compatibility requirements:** Supported released behavior or a durable boundary that must remain usable. @@ -35,7 +35,7 @@ A released-version reproducer proves reachability, not support. Treat the exact ## Review-feedback gate -Repeat this gate before editing each new feedback batch: +Use this checkpoint only when feedback changes the scope contract or implementation shape. Reuse unchanged evidence instead of reconstructing it: ```text Review checkpoint: @@ -141,6 +141,8 @@ Before declaring the design complete, answer all of these with concrete evidence ## When to stop and confirm +Confirm a consequential choice below only when it is not already resolved by the user's request or an approved scope contract. Ordinary remediation within that contract continues through review and verification. + - The change would alter supported behavior shipped in the latest release tag, or concrete evidence shows material reliance on behavior that the release incidentally accepted. - The change would modify durable external data, protocol formats, or serialized state. - The correct solution would materially expand beyond the requested outcome or require unrelated architectural work. diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index db4b841c63..503940739d 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -1,6 +1,6 @@ --- name: maintainer-review -description: Assess an openai-agents-python GitHub issue or pull request as a maintainer. Use to verify the claimed need and practical impact, compare supported alternatives or competing approaches, separate code quality from repository readiness, recommend the maintainer action, and draft a copy-ready comment when evidence, changes, or closure should be requested. +description: Assess a GitHub issue or PR for demonstrated need, supported alternatives, correctness, and maintainer action. Desk review only. --- # Maintainer Review diff --git a/.agents/skills/openai-knowledge/SKILL.md b/.agents/skills/openai-knowledge/SKILL.md index f223568bfa..dc0198428e 100644 --- a/.agents/skills/openai-knowledge/SKILL.md +++ b/.agents/skills/openai-knowledge/SKILL.md @@ -1,6 +1,6 @@ --- name: openai-knowledge -description: Use when working with the OpenAI API (Responses API) or OpenAI platform features (tools, streaming, Realtime API, auth, models, rate limits, MCP) and you need authoritative, up-to-date documentation (schemas, examples, limits, edge cases). Prefer the OpenAI Developer Documentation MCP server tools when available; otherwise guide the user to enable `openaiDeveloperDocs`. +description: Retrieve authoritative OpenAI API and platform documentation when an integration or claim needs current external evidence. --- # OpenAI Knowledge diff --git a/.agents/skills/pr-draft-summary/SKILL.md b/.agents/skills/pr-draft-summary/SKILL.md index 230aa851b8..5b872a9f6f 100644 --- a/.agents/skills/pr-draft-summary/SKILL.md +++ b/.agents/skills/pr-draft-summary/SKILL.md @@ -1,6 +1,6 @@ --- name: pr-draft-summary -description: Create the required PR-ready summary block, branch suggestion, title, and draft description for openai-agents-python. Use before the final response whenever the current task changed runtime code, tests, examples, build/test configuration, or docs with behavior impact, regardless of perceived change size and including local-only or uncommitted work. Skip only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, an explicitly invoked $release-candidate-prep handoff, or when the user explicitly says not to include the PR draft block. +description: Prepare the required local PR title, description, and branch suggestion after eligible implementation work is complete. --- # PR Draft Summary @@ -9,6 +9,8 @@ description: Create the required PR-ready summary block, branch suggestion, titl Produce the PR-ready summary required in this repository after eligible code work is complete: a concise summary plus a PR-ready title and draft description that begins with "This pull request ...". The block should be ready to paste into a PR for openai-agents-python. ## When to Trigger +- An explicit user request for a PR draft takes precedence over the automatic-trigger exclusions below. +- For automatic invocation, spelling, comments, or formatting alone are editorial even under runtime/test paths when they change no behavior or contract. Skip the automatic draft for those edits. - Before every final response, check whether the current task changed runtime code (`src/agents/`), tests (`tests/`), examples (`examples/`), build/test configuration, or docs with behavior impact. - If it did, run this skill after required verification and before sending the final response. Do not use perceived change size to decide whether to run it. - Run it for eligible local-only and uncommitted work even when the user did not ask to create a pull request. Producing this text does not authorize creating a branch, committing, pushing, or opening a pull request. diff --git a/.agents/skills/release-candidate-prep/SKILL.md b/.agents/skills/release-candidate-prep/SKILL.md index 63c2054ea5..05ddcaeda6 100644 --- a/.agents/skills/release-candidate-prep/SKILL.md +++ b/.agents/skills/release-candidate-prep/SKILL.md @@ -1,6 +1,6 @@ --- name: release-candidate-prep -description: Preflight and prepare an OpenAI Agents Python release candidate in a dedicated worktree from exact origin/main, gate readiness before branch creation, freeze the released API contract, create or replace the local release branch with one release commit, enforce final release review as a checker, and produce release-specific PR text. Use only when explicitly invoked with a version. Never push, open a PR, or mutate GitHub. +description: Prepare a local Python SDK release candidate in a dedicated worktree. Use only when explicitly invoked with a version. --- # Release Candidate Preparation diff --git a/.agents/skills/runtime-behavior-probe/SKILL.md b/.agents/skills/runtime-behavior-probe/SKILL.md index d8b503c489..957420f789 100644 --- a/.agents/skills/runtime-behavior-probe/SKILL.md +++ b/.agents/skills/runtime-behavior-probe/SKILL.md @@ -1,6 +1,6 @@ --- name: runtime-behavior-probe -description: Plan and, after explicit approval, execute runtime-behavior probes for local or live integrations. Use only when explicitly invoked to verify behavior that code review and normal tests cannot settle; define a controlled validation matrix and report observed evidence. +description: Plan controlled runtime probes when explicitly invoked; execute only after the required probe approval. --- # Runtime Behavior Probe diff --git a/.agents/skills/sensitive-logging-audit/SKILL.md b/.agents/skills/sensitive-logging-audit/SKILL.md index ca150f4240..bc641578bc 100644 --- a/.agents/skills/sensitive-logging-audit/SKILL.md +++ b/.agents/skills/sensitive-logging-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: sensitive-logging-audit -description: Audit and fix sensitive-data exposure through Python runtime logging in openai-agents-python. Use when reviewing logging, print, warnings, stderr, traceback, MCP names, model or tool exceptions, redaction flags, or any diagnostic path that may retain user data. +description: Audit or fix sensitive-data exposure in Python SDK diagnostics, exceptions, logging, and telemetry. --- # Sensitive Logging Audit diff --git a/.agents/skills/test-coverage-improver/SKILL.md b/.agents/skills/test-coverage-improver/SKILL.md index 634c85de2a..bb60485bce 100644 --- a/.agents/skills/test-coverage-improver/SKILL.md +++ b/.agents/skills/test-coverage-improver/SKILL.md @@ -1,42 +1,24 @@ --- name: test-coverage-improver -description: 'Improve test coverage in the OpenAI Agents Python repository: run `make coverage`, inspect coverage artifacts, identify low-coverage files, propose high-impact tests, and confirm with the user before writing tests.' +description: Measure Python SDK coverage or address measured coverage gaps. Use for coverage audits and metric regressions, not routine test additions. --- # Test Coverage Improver -## Overview - -Use this skill whenever coverage needs assessment or improvement (coverage regressions, failing thresholds, or user requests for stronger tests). It runs the coverage suite, analyzes results, highlights the biggest gaps, and prepares test additions while confirming with the user before changing code. - -## Quick Start - -1. From the repo root run `make coverage` to regenerate `.coverage` data and `coverage.xml`. -2. Collect artifacts: `.coverage` and `coverage.xml`, plus the console output from `coverage report -m` for drill-downs. -3. Summarize coverage: total percentages, lowest files, and uncovered lines/paths. -4. Draft test ideas per file: scenario, behavior under test, expected outcome, and likely coverage gain. -5. Ask the user for approval to implement the proposed tests; pause until they agree. -6. After approval, write the tests in `tests/`, rerun `make coverage`, and then run `$code-change-verification` before marking work complete. - -## Workflow Details - -- **Run coverage**: Execute `make coverage` at repo root. Avoid watch flags and keep prior coverage artifacts only if comparing trends. -- **Parse summaries efficiently**: - - Prefer the console output from `coverage report -m` for file-level totals; fallback to `coverage.xml` for tooling or spreadsheets. - - Use `uv run coverage html` to generate `htmlcov/index.html` if you need an interactive drill-down. -- **Prioritize targets**: - - Public APIs or shared utilities in `src/agents/` before examples or docs. - - Files with low statement coverage or newly added code at 0%. - - Recent bug fixes or risky code paths (error handling, retries, timeouts, concurrency). -- **Design impactful tests**: - - Hit uncovered paths: error cases, boundary inputs, optional flags, and cancellation/timeouts. - - Cover combinational logic rather than trivial happy paths. - - Place tests under `tests/` and avoid flaky async timing. -- **Coordinate with the user**: Present a numbered, concise list of proposed test additions and expected coverage gains. Ask explicitly before editing code or fixtures. -- **After implementation**: Rerun coverage, report the updated summary, and note any remaining low-coverage areas. - -## Notes - -- Keep any added comments or code in English. -- Do not create `scripts/`, `references/`, or `assets/` unless needed later. -- If coverage artifacts are missing or stale, rerun `make coverage` instead of guessing. +Use current coverage evidence to identify missing caller-visible behavior tests. Select this workflow for coverage measurement, coverage-metric regressions, or finding gaps from coverage artifacts. When the user already specifies the behaviors to test, use the ordinary implementation/review workflow without coverage measurement unless measurement is also requested. Adding tests alone does not trigger this skill or its final `make coverage` step. + +## Scope and authorization + +For assessment or proposal-only requests, report gaps and suggested tests without editing. When the user requests test improvements or has approved a plan, implement the scoped tests and complete review and verification without asking again. Ask only when a new contract decision, expanded scope, or additional authority is needed. Coverage work never authorizes live API calls or broader sandbox access. + +## Workflow + +1. Inspect current `.coverage`, `coverage.xml`, and any recorded command/environment evidence. Reuse them only when they represent the relevant source and test state. If missing or stale, run `make coverage` under the repository's verification sandbox and credential policy; this initial measurement precedes implementation review. Respect host-capacity guidance before broad measurement. +2. Identify uncovered behavior within the requested scope. Prioritize public behavior and meaningful error, cancellation, and lifecycle paths over percentage-only targets. Use `uv run coverage report -m` or `coverage.xml` to locate gaps. +3. For an assessment, report evidence and proposed scenarios. For authorized implementation, choose tests with independent expected results at the highest controllable caller boundary. Do not add tests that merely reproduce helper logic or enumerate unsupported permutations. +4. Implement tests, run affected checks, and complete `$implementation-final-review`. Keep iterative verification focused; do not repeatedly run full coverage while review is incomplete. +5. After clean review, run `make coverage` for the final measurement and `$code-change-verification` for the required SDK gates. These have different purposes; reuse an already valid final measurement rather than repeating it. Report coverage changes, verified behaviors, and material gaps that remain. + +## Reporting + +State the scope and age of coverage evidence, the behavior each new test protects, validation results, and unresolved gaps. Keep comments and code in English. Do not treat coverage percentages alone as proof of correctness. diff --git a/AGENTS.md b/AGENTS.md index af392eafb2..aeefaebe5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,5 @@ # Contributor Guide -This guide helps new contributors get started with the OpenAI Agents Python repository. It covers repo structure, how to test your work, available utilities, and guidelines for commits and PRs. - -**Location:** `AGENTS.md` at the repository root. - ## Table of Contents 1. [Policies & Mandatory Rules](#policies--mandatory-rules) @@ -15,51 +11,16 @@ This guide helps new contributors get started with the OpenAI Agents Python repo ### Mandatory Skill Usage -Repository skills are stored under `.agents/skills/`. A reference such as `$` in this file is a repository instruction reference, not a request for manual user invocation. When a rule requires a skill, read `.agents/skills//SKILL.md` completely before taking task actions, follow its instructions, and resolve referenced files relative to that skill directory. - -#### `$code-change-verification` - -Run `$code-change-verification` before marking work complete when changes affect runtime code, tests, or build/test behavior. - -Run it when you change: -- `src/agents/` (library code) or shared utilities. -- `tests/` or add or modify snapshot tests. -- `examples/`. -- Build or test configuration such as `pyproject.toml`, `Makefile`, `mkdocs.yml`, `docs/scripts/`, or CI workflows. - -You can skip `$code-change-verification` for docs-only or repo-meta changes (for example, `docs/`, `.agents/`, `README.md`, `AGENTS.md`, `.github/`), unless a user explicitly asks to run the full verification stack. - -Treat `$code-change-verification` as the post-review final gate, not as an iterative review check. When `$implementation-final-review` applies, satisfy its clean-review condition before starting the repository-wide format, lint, typecheck, and test stack. Immediately before starting that stack, use available read-only task or process evidence to check for another broad test, typecheck, build, examples, or integration command already running on the same host. When concrete contention is visible, keep making progress on review, remediation, evidence preparation, or focused checks and defer the broad stack until capacity is available. Do not add a repository lock, host-wide mutex, sentinel file, or user-triggered `finalize` step. Lack of host telemetry alone is not a blocker. - -#### `$openai-knowledge` - -When working on OpenAI API or OpenAI platform integrations in this repo (Responses API, tools, streaming, Realtime API, auth, models, rate limits, MCP, Agents SDK or ChatGPT Apps SDK), use `$openai-knowledge` to pull authoritative docs via the OpenAI Developer Docs MCP server (and guide setup if it is not configured). - -#### `$implementation-strategy` - -Before changing or reviewing runtime code, exported APIs, external configuration, persisted schemas, wire protocols, or other user-facing behavior, use `$implementation-strategy` to decide the compatibility boundary and implementation shape. Before coding, write an implementation scope contract that states the required behavior, compatibility requirements, intentionally unsupported cases and their failure behavior, and an already-supported alternative for those cases or that none exists. Treat this contract as a short, updateable engineering decision record, not as a new public API promise. During review, use the skill before requesting compatibility layers, migrations, new abstractions, or broader refactors. - -Repeat the skill before editing each new review-feedback batch; an earlier strategy decision is stale when a comment would widen the supported contract or add another compatibility branch, resolver condition, or test permutation. Judge breaking changes against the latest release tag, not unreleased branch-local churn. Interfaces introduced or changed after the latest release tag may be rewritten without compatibility shims unless they define a released or explicitly supported durable external state boundary, or the user explicitly asks for a migration path. Unreleased persisted formats on `main` may be renumbered or squashed before release when intermediate snapshots are intentionally unsupported. +Repository skills are stored under `.agents/skills/`. References below authorize their use when the stated condition applies; no separate manual invocation is needed unless explicitly required. Read the selected `SKILL.md`, then only the supporting references needed for its route. User instructions and already-approved scope take precedence over skill defaults, subject to applicable permissions. Do not repeat an approval already given for local implementation, review, or verification. -Independent reviewers dispatched by `$implementation-final-review` inherit the implementer's recorded implementation scope contract and do not rerun `$implementation-strategy` in their fresh review contexts. They report inconsistent or decision-incomplete strategy evidence as uncertainty to the implementer. The implementer remains responsible for rerunning `$implementation-strategy` before any review-feedback batch that widens the supported contract, adds a compatibility branch, changes ownership or protocol behavior, expands test permutations, or triggers a complexity reset. +- **`$implementation-strategy`:** Use before changing or reviewing SDK runtime behavior, public APIs, configuration, persisted schemas, or wire protocols. Record required behavior, compatibility, unsupported cases, and an existing alternative in a short scope contract. Revisit it only when feedback changes the contract or implementation shape. Independent reviewers inherit that contract and report uncertainty instead of rerunning strategy. +- **`$implementation-final-review`:** After focused checks, use for runtime code, tests, examples, build/test behavior, and behavior-impacting docs. Its entrypoint owns the lightweight/ordinary/high-risk classification: behavior changes normally require independent review; only demonstrably non-semantic changes can omit it. Finish required review before broad final checks. Planning, investigation, and report-only tasks do not invoke this workflow. Repo-meta changes use applicable skill validation; implementing changes to decision-making guidance requires realistic scenario checks and an independent pass. Report-only assessments can use existing evidence without starting an implementation review. +- **`$code-change-verification`:** Run the final SDK stack for changes to `src/agents/`, `tests/`, `examples/`, shared runtime utilities, or SDK build/test configuration such as `pyproject.toml`, `Makefile`, `mkdocs.yml`, `docs/scripts/`, and CI workflows. Docs-only and repo-meta changes can skip it unless they affect those build/test paths or the user requests the full stack. Lightweight review does not waive eligible SDK checks. The skill owns command order, sandbox execution, host-capacity checks, and retry rules. +- **`$openai-knowledge`:** Use when OpenAI API/platform behavior needs authoritative external evidence. Inspect local code for SDK-owned behavior; do not repeat unchanged external research for purely local implementation details. +- **`$pr-draft-summary`:** After applicable review and verification, generate the local PR draft for runtime, tests, examples, build/test changes, or behavior-impacting docs, including uncommitted work. Skip repo-meta/editorial-only work, an explicit user opt-out, or the release-specific handoff below. A draft never authorizes a branch, commit, push, or PR creation. +- **`$release-candidate-prep`:** Use only when explicitly invoked with a version. Follow its dedicated-worktree workflow and `$final-release-review` gate; its complete final-candidate report replaces the general PR draft. All runtime/docs changes must already be on `main`. The release commit contains only `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`. See [.github/RELEASING.md](.github/RELEASING.md) for maintainer release operations. -#### `$implementation-final-review` - -After implementing runtime code, tests, examples, build/test behavior, or behavior-impacting docs and completing focused tests, run `$implementation-final-review` before final `$code-change-verification` and `$pr-draft-summary` work and before declaring the task complete. Do not start repository-wide lint, typecheck, tests, builds, examples, or integration suites while the independent review is incomplete or finding-bearing. This repository instruction authorizes automatic invocation without a separate user mention. Do not invoke it for planning, investigation, review, or report-only tasks, repo-meta changes, or docs without behavior impact. The skill's clean-review gate does not replace any other mandatory repository skill or verification gate. - -#### `$pr-draft-summary` - -Before every final response for a task that changed runtime code, tests, examples, build/test configuration, or docs with behavior impact, invoke `$pr-draft-summary` to generate the required PR summary block, branch suggestion, title, and draft description. Determine whether to invoke it from the changed files, not from a subjective assessment of change size. - -Skip `$pr-draft-summary` only for trivial or conversation-only tasks, repo-meta/doc-only tasks without behavior impact, an explicitly invoked `$release-candidate-prep` handoff that uses the complete `$final-release-review` report as its release-specific PR description, or when the user explicitly says not to include the PR draft block. The release exception applies to preparing the candidate itself, not to implementing or changing the release-preparation skill. - -Producing the PR draft block is part of the local final handoff. It is required for eligible local-only or uncommitted changes and does not authorize creating a branch, committing, pushing, or opening a pull request. - -#### `$release-candidate-prep` - -Use `$release-candidate-prep` only when the user explicitly invokes it with a release version. It keeps the user's clean `main` checkout unchanged, creates a dedicated detached worktree at refreshed `origin/main`, runs the readiness gates there, creates `release/v` in that worktree, updates `pyproject.toml` and `uv.lock`, freezes and checks `tests/fixtures/released_api_contract.json`, and creates one local release commit. It invokes `$final-release-review` as the controlling checker against both the pre-release source and the materialized candidate; a blocked release call stops the workflow, while a green final-candidate report becomes the release-specific PR description. - -The skill replaces the former GitHub Actions release-PR creator. It must never push, open or edit a pull request, create a release, or mutate any other GitHub state. It leaves the dedicated worktree in place for green handoff, blocked review, or recoverable failure. After merge, an authorized maintainer manually creates the release tag at the confirmed merge commit and publishes the GitHub Release; the protected publishing workflow then handles PyPI publication. Follow [the release operation guide](.github/RELEASING.md). The release commit may contain only `pyproject.toml`, `uv.lock`, and `tests/fixtures/released_api_contract.json`; all runtime and documentation changes must land on `main` before preparation. +Continue authorized local work through fixes, applicable review, verification, and handoff. Stop for a concrete unresolved contract or scope decision, missing authority, or an external blocker. When a skill causes a stop, identify the exact instruction and explain the missing decision; do not ask for a generic continuation prompt. Never push, open a PR, or otherwise mutate GitHub. ### Work Status Reporting @@ -91,15 +52,9 @@ Existing warnings from a successful documentation build are not findings for an ### Scope Discipline and Complexity Reset -- Implement the narrowest explicitly stated set of behaviors that satisfies the request. Do not interpret every shape accepted by a host-language protocol, third-party library, or reflection API unless those shapes are required by the task or supported behavior shipped in the latest release. -- Prefer adapting the required case into an existing pipeline over creating a parallel contract, resolver, execution path, or source of truth. Continue to derive schema, validation, naming, documentation, and invocation from the existing source-of-truth functions, types, or modules. -- Every new abstraction, state field, cached classification, compatibility branch, or dispatch mode must map to a stated requirement, released contract, durable boundary, or verified runtime risk. Remove it if that mapping cannot be stated concretely. -- Treat a second related review finding that would add another condition, protocol hop, compatibility case, or test permutation to the same abstraction as a mandatory complexity-reset checkpoint, not another item to patch. Continue the design only when concrete evidence shows that the additional case belongs to the supported contract. -- When that signal appears, stop extending the current design. Re-read the original requirement, group all findings by root cause, compare the complete diff with the merge base of the intended target branch or with the latest release tag when it is the compatibility baseline, and replace branch-local machinery with a narrower contract. Existing unreleased code and tests are not sunk costs. Perform this reset proactively; do not wait for the user or reviewer to request it. -- A released-version reproducer proves reachability, not a supported contract. Verify the exact shape against documentation, tests, examples, intentional public typing, explicit maintainer intent, or concrete user reliance before adding compatibility machinery. -- Prefer an actionable error during construction or validation, before invocation or other side effects, and an existing supported alternative (for example a wrapper function, explicit override, or typed adapter) over partially emulating a broad protocol. Do not add another alternative when an adequate supported one already exists. -- A growing diff is not itself proof of overengineering, but unexpected cross-module spread, duplicated metadata, combinatorial tests, or repeated special cases requires restarting the design review from the original requirement before more code is added. -- Before handoff, verify that the patch has one source of truth per concern, tests the required behavior and intentionally unsupported cases, and does not accidentally make every constructible combination part of the supported SDK behavior. +Implement the smallest supported behavior requested. Reuse existing sources of truth; every new abstraction, state field, compatibility branch, or test permutation needs a requirement, released contract, durable boundary, or verified risk. Constructibility and a released-version reproducer alone do not establish support. + +When related findings repeatedly expand the same design, stop adding conditions, group root causes, and reassess the complete diff against the original requirement. A second related finding that adds another compatibility case or protocol hop triggers this reset. Prefer deleting unsupported branch-local machinery or rejecting unsupported inputs with an existing alternative. Follow `$implementation-strategy` for the detailed reset procedure; preserve released contracts and unrelated user changes. ### ExecPlans @@ -214,110 +169,19 @@ The OpenAI Agents Python repository provides the Python Agents SDK, examples, an ### Development Workflow -1. Stay in the user's current checkout and on the current branch unless the user explicitly asks for or approves a Git state change. -2. If the user explicitly requests a feature/fix branch, create one with a descriptive name: - ```bash - git checkout -b feat/ - ``` -3. If dependencies changed or you are setting up the repo, run `make sync`. -4. Implement changes and add or update tests alongside code updates. -5. Highlight compatibility or API risks in your plan before implementing changes that alter the latest released behavior or a released or explicitly supported durable external state boundary. -6. Verify documentation changes according to [Documentation Verification Tiers](#documentation-verification-tiers). Do not run a full documentation build for an editorial-only change. -7. When `$code-change-verification` applies, run it to execute the full verification stack before marking work complete. -8. Commit with concise, imperative messages; keep commits small and focused, then open a pull request. -9. Before reporting eligible code changes as complete, invoke `$pr-draft-summary` as the final handoff step unless the task falls under the documented skip cases. Do not omit it based on perceived change size or because the work remains local or uncommitted. +Stay in the current checkout and branch unless a Git state change is explicitly authorized. Install dependencies with `make sync` for a fresh checkout or changed dependencies. Implement the requested behavior, add meaningful regression coverage, and follow the applicable skills above through final handoff. Commit only when authorized; keep commit messages concise and imperative. GitHub actions remain outside this workflow's authority. ### Testing & Automated Checks -Before submitting changes, ensure relevant checks pass and extend tests when you touch code. +Use focused checks during iteration. Add tests for required behavior and concrete regressions, not to mirror implementation logic. Reuse passing checks for unchanged content; broaden or repeat them only when changes, failures, or unresolved concerns justify it. Run eligible final SDK verification after clean review and documentation checks according to their tiers. For provider-neutral agent workflow tests, prefer `ScriptedModel` from `agents.testing` over adding a new mock or fake `Model`. Prefer `ScriptedRealtimeModel` from `agents.realtime.testing` for Realtime session tests, the scripted utilities from `agents.voice.testing` for Voice pipeline tests, and `scripted_sandbox_session()` from `agents.testing` for deterministic Sandbox session calls. Keep a specialized test double only when the test specifically requires provider-wire conversion, malformed streams, controlled suspension or concurrency, or an exact cancellation or lifecycle boundary that the scripted utilities cannot preserve; document that boundary in the test. Before adding or changing async, retry, timeout, subprocess, PTY, warning, or xdist-sensitive tests, read [Performance and determinism](tests/README.md#performance-and-determinism) and preserve the applicable behavioral and lifecycle coverage while optimizing execution. -When `$code-change-verification` applies, run it to execute the required verification stack from the repository root. Rerun the full stack after applying fixes. - -#### Unit tests and type checking - -- Run the full test suite: - ```bash - make tests - ``` -- Run a focused test: - ```bash - uv run pytest -s -k - ``` -- Type checking: - ```bash - make typecheck - ``` - -#### Snapshot tests - -Some tests rely on inline snapshots; see `tests/README.md` for details. Re-run `make tests` after updating snapshots. - -- Fix snapshots: - ```bash - make snapshots-fix - ``` -- Create new snapshots: - ```bash - make snapshots-create - ``` - -#### Coverage - -- Generate coverage (fails if coverage drops below threshold): - ```bash - make coverage - ``` - -#### Formatting, linting, and type checking - -- Formatting and linting use `ruff`; run `make format` (applies fixes) and `make lint` (checks only). -- Type hints must pass `make typecheck`. -- Write comments as full sentences ending with a period. -- Imports are managed by Ruff and should stay sorted. -- Do not hard-wrap prose in Markdown or other non-code text files at a fixed column width. Keep each paragraph on one source line unless the file format or Markdown structure requires a line break, such as for lists, tables, blockquotes, or code fences. +For test execution, coverage, and snapshot workflows, use [tests/README.md](tests/README.md) and the `Makefile`. `$code-change-verification` owns the required final command sequence. Keep Python comments as full sentences ending with a period. -#### Mandatory local run order - -When `$code-change-verification` applies, run the full sequence in order (or use the skill scripts): - -```bash -make format -make lint -make typecheck -make tests -``` - -### Utilities & Tips - -- Install or refresh development dependencies: - ```bash - make sync - ``` -- Run tests against the oldest supported version (Python 3.10) in an isolated environment: - ```bash - UV_PROJECT_ENVIRONMENT=.venv_310 uv sync --python 3.10 --all-extras --all-packages --group dev - UV_PROJECT_ENVIRONMENT=.venv_310 uv run --python 3.10 -m pytest - ``` -- Documentation workflows: - ```bash - make build-docs # build stable content or structural docs changes - make serve-docs # preview docs locally - make build-full-docs # run translations and build when explicitly required - ``` -- Snapshot helpers: - ```bash - make snapshots-fix - make snapshots-create - ``` -- Use `examples/` to see common SDK usage patterns. -- Review `Makefile` for common commands and use `uv run` for Python invocations. -- Explore `docs/` and `docs/scripts/` to understand the documentation pipeline. -- Consult `tests/README.md` for test and snapshot workflows. -- Check `mkdocs.yml` to understand how docs are organized. +- Do not hard-wrap prose in Markdown or other non-code text files at a fixed column width. Keep each paragraph on one source line unless the file format or Markdown structure requires a line break, such as for lists, tables, blockquotes, or code fences. ### Pull Request & Commit Guidelines @@ -325,5 +189,4 @@ make tests - In copy-ready GitHub text, use native issue and pull-request references: exactly `#123` for this repository and `owner/repo#123` for another repository. Do not qualify same-repository references as `openai/openai-agents-python#123`. Preserve closing forms such as `Fixes #123` or `Resolves #123`. Never wrap these references in Markdown links such as `[PR #123](https://github.com/owner/repo/pull/123)` or `[#123](...)`; those Codex-friendly links require manual cleanup after pasting into GitHub. Use descriptive Markdown links only for external resources or GitHub targets that cannot be expressed as a native issue or pull-request reference. - Add focused regression tests for accepted new behavior when feasible. Update documentation or examples when the change would otherwise make existing guidance materially false, unsafe, or misleading; correct use depends on a non-obvious constraint, migration step, compatibility boundary, or operational warning; or the accepted feature would otherwise be practically unusable. Do not require optional documentation or examples solely for completeness. - Determine `docs/` delivery timing separately from documentation necessity. When required `docs/` content would describe behavior that is not yet in the latest published release, leave it out of the feature or bug-fix pull request and treat it as separately timed docs-only work, not as an incomplete current pull request. This exception does not automatically apply to examples or code-level documentation that ships with the changed API. -- Run `make format`, `make lint`, `make typecheck`, and `make tests` before marking work ready. - Commit messages should be concise and written in the imperative mood. Small, focused commits are preferred. diff --git a/tests/README.md b/tests/README.md index 141b17d3bd..09367ece96 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Tests -Before running any tests, make sure you have `uv` installed (and ideally run `make sync` after). +Use `uv` for test commands. Run `make sync` for a fresh checkout, changed dependencies, or a dependency-resolution failure; do not repeat setup before every check. ## Running tests @@ -14,9 +14,9 @@ make tests The `serial` marker means that a test needs exclusive execution after every xdist worker exits, not merely ordered execution within one worker. Use it for shared external resources, process-wide state, or timing-sensitive lifecycle tests that have demonstrated interference under xdist. Tests that use their own subprocess, random port, or temporary directory do not need `serial` solely for that reason; prove them under xdist instead. -`make tests-review` omits tests marked `review_optional`. These are slow subsystem-specific integration, subprocess, or multiprocessing checks that remain mandatory in the final `make tests` verification. Use the review target only as a preliminary check during an iterative implementation review when the task-owned paths do not affect any marked test or its owning subsystem. Inspect the current owners with `rg -n "review_optional" tests` when deciding; if the boundary is uncertain, run `make tests`. +`make tests-review` omits tests marked `review_optional`. These slow subsystem-specific integration, subprocess, or multiprocessing checks remain mandatory in final `make tests` verification. This broad subset is not a replacement for the focused checks used during implementation review or for the final suite. -Choose review-round coverage by impact. For a leaf subsystem change, run `make tests-review` plus the owning subsystem's complete test file or directory without a marker filter, so its `review_optional` cases are restored. For cross-cutting runtime changes such as runner orchestration, agent or item flow, shared persistence, or test infrastructure, run `make tests` during review. Prefer the full suite whenever the affected boundary is ambiguous. This selection changes only iterative feedback; the final verification always runs `make tests`. +During iterative implementation review, run focused tests for the changed behavior and relevant subsystem boundaries, including applicable `review_optional` cases without filtering them away. Resolve uncertain coverage by tracing affected callers and dependencies and selecting the needed focused checks. Follow [implementation-final-review](../.agents/skills/implementation-final-review/SKILL.md) for review sequencing; defer broad `make tests-review`, `make tests`, and repository-wide type checking until clean review. Then follow [code-change-verification](../.agents/skills/code-change-verification/SKILL.md) for the complete final SDK stack. Explicit requests to run a suite outside an implementation review remain supported. `make typecheck` runs mypy and pyright concurrently. Mypy checks `src`, while Pyright checks the `src` and `tests` paths configured in `pyrightconfig.json`. Pyright uses four analysis threads by default; set `PYRIGHT_THREADS` to a positive integer to override the local thread count. From 364b953983e8de74eec543c36bea19cba2d790db Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 18:11:15 +0900 Subject: [PATCH 444/473] perf: reduce async stability command startup overhead (#4874) --- .../scripts/run-asyncio-teardown-stability.sh | 11 +- tests/test_asyncio_stability_command.py | 154 ++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 tests/test_asyncio_stability_command.py diff --git a/.github/scripts/run-asyncio-teardown-stability.sh b/.github/scripts/run-asyncio-teardown-stability.sh index 8ed3e547cb..15bfa81020 100644 --- a/.github/scripts/run-asyncio-teardown-stability.sh +++ b/.github/scripts/run-asyncio-teardown-stability.sh @@ -3,18 +3,15 @@ set -euo pipefail repeat_count="${1:-5}" -asyncio_progress_args=( +stability_args=( tests/test_asyncio_progress.py -) - -run_step_execution_args=( tests/test_run_step_execution.py -k - "cancel or post_invoke" + # Match the whole progress module as well as cancellation and post-invoke cases. + "test_asyncio_progress or cancel or post_invoke" ) for run in $(seq 1 "$repeat_count"); do echo "Async teardown stability run ${run}/${repeat_count}" - uv run pytest -q "${asyncio_progress_args[@]}" - uv run pytest -q "${run_step_execution_args[@]}" + uv run pytest -q "${stability_args[@]}" done diff --git a/tests/test_asyncio_stability_command.py b/tests/test_asyncio_stability_command.py new file mode 100644 index 0000000000..df6879a731 --- /dev/null +++ b/tests/test_asyncio_stability_command.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / ".github/scripts/run-asyncio-teardown-stability.sh" +pytestmark = pytest.mark.skipif( + sys.platform == "win32" or shutil.which("bash") is None, + reason="The Bash command tests require POSIX executable shims.", +) +EXPECTED_NODES = [ + "tests/test_asyncio_progress.py::test_deadline", + "tests/test_asyncio_progress.py::test_external_wait[first]", + "tests/test_asyncio_progress.py::test_external_wait[second]", + "tests/test_run_step_execution.py::test_cancel_sibling", + "tests/test_run_step_execution.py::test_post_invoke", +] + + +@pytest.fixture +def command_environment(tmp_path: Path) -> dict[str, str]: + tests = tmp_path / "tests" + tests.mkdir() + (tmp_path / "pytest.ini").write_text("[pytest]\n", encoding="utf-8") + (tests / "test_asyncio_progress.py").write_text( + textwrap.dedent( + """\ + import pytest + + def test_deadline(): + pass + + @pytest.mark.parametrize("value", [1, 2], ids=["first", "second"]) + def test_external_wait(value): + assert value > 0 + """ + ), + encoding="utf-8", + ) + (tests / "test_run_step_execution.py").write_text( + textwrap.dedent( + """\ + def test_cancel_sibling(): + pass + + def test_post_invoke(): + pass + + def test_unrelated(): + raise AssertionError("The stability command must not select this test.") + """ + ), + encoding="utf-8", + ) + (tmp_path / "conftest.py").write_text( + textwrap.dedent( + """\ + import json + import os + from pathlib import Path + + import pytest + + seen = set() + + @pytest.fixture(autouse=True) + def check_fresh_state(request): + assert request.node.nodeid not in seen + seen.add(request.node.nodeid) + if request.node.nodeid == os.environ.get("STABILITY_FAIL_NODE"): + pytest.fail("Injected selected-test failure.") + + def pytest_sessionfinish(session, exitstatus): + with Path("sessions.jsonl").open("a", encoding="utf-8") as log: + log.write(json.dumps({ + "pid": os.getpid(), + "nodes": [item.nodeid for item in session.items], + "exitstatus": int(exitstatus), + }) + "\\n") + """ + ), + encoding="utf-8", + ) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + uv = bin_dir / "uv" + # Forward to real pytest without installing packages in the temporary suite. + uv.write_text( + '#!/usr/bin/env bash\nset -eu\n[[ "$1" == "run" ]]\nshift\n' + f'exec {shlex.quote(sys.executable)} -m "$@"\n', + encoding="utf-8", + ) + uv.chmod(0o755) + environment = os.environ.copy() + for name in ("OPENAI_API_KEY", "PYTHONPATH", "PYTEST_PLUGINS", "STABILITY_FAIL_NODE"): + environment.pop(name, None) + environment.update( + PATH=str(bin_dir) + os.pathsep + environment.get("PATH", ""), + PYTEST_DISABLE_PLUGIN_AUTOLOAD="1", + PYTEST_ADDOPTS="", + ) + return environment + + +@pytest.mark.parametrize("arguments, repetitions", [([], 5), (["2"], 2)]) +def test_stability_command_preserves_selection_in_fresh_processes( + tmp_path: Path, command_environment: dict[str, str], arguments: list[str], repetitions: int +) -> None: + result = subprocess.run( + ["bash", str(SCRIPT), *arguments], + cwd=tmp_path, + env=command_environment, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stdout + result.stderr + sessions = [json.loads(line) for line in (tmp_path / "sessions.jsonl").read_text().splitlines()] + assert len(sessions) == repetitions + assert len({session["pid"] for session in sessions}) == repetitions + assert [session["nodes"] for session in sessions] == [EXPECTED_NODES] * repetitions + assert all(session["exitstatus"] == 0 for session in sessions) + assert result.stdout.count("Async teardown stability run ") == repetitions + + +@pytest.mark.parametrize("failed_node", [EXPECTED_NODES[0], EXPECTED_NODES[-1]]) +def test_stability_command_stops_after_selected_test_failure( + tmp_path: Path, command_environment: dict[str, str], failed_node: str +) -> None: + command_environment["STABILITY_FAIL_NODE"] = failed_node + result = subprocess.run( + ["bash", str(SCRIPT), "3"], + cwd=tmp_path, + env=command_environment, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 1, result.stdout + result.stderr + assert "Injected selected-test failure." in result.stdout + sessions = [json.loads(line) for line in (tmp_path / "sessions.jsonl").read_text().splitlines()] + assert len(sessions) == 1 + assert sessions[0]["exitstatus"] == 1 + assert result.stdout.count("Async teardown stability run ") == 1 From e4500f960c9f1b5440963b34398be4f0b4ff37b3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 18:11:38 +0900 Subject: [PATCH 445/473] test: make async progress and trace-worker tests deterministic (#4875) --- tests/test_asyncio_progress.py | 278 +++++++++++++++++++++------------ tests/test_trace_processor.py | 272 +++++++++++++++++++------------- 2 files changed, 347 insertions(+), 203 deletions(-) diff --git a/tests/test_asyncio_progress.py b/tests/test_asyncio_progress.py index cf764ea52b..6d64c02c2d 100644 --- a/tests/test_asyncio_progress.py +++ b/tests/test_asyncio_progress.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import contextlib import pytest @@ -12,25 +11,33 @@ async def test_function_tool_task_progress_deadline_detects_timer_backed_sleep() -> None: loop = asyncio.get_running_loop() + started = asyncio.Event() + async def _sleeping_task() -> None: + started.set() await asyncio.sleep(0.05) + before = loop.time() task = asyncio.create_task(_sleeping_task()) - await asyncio.sleep(0) + try: + await started.wait() + assert not task.done() - before = loop.time() - deadline = get_function_tool_task_progress_deadline( - task=task, - task_to_invoke_task={}, - loop=loop, - ) + inspected = loop.time() + deadline = get_function_tool_task_progress_deadline( + task=task, + task_to_invoke_task={}, + loop=loop, + ) + + assert deadline is not None + assert before + 0.05 <= deadline <= inspected + 0.05 - assert deadline is not None - assert before <= deadline <= before + 0.1 + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + assert task.cancelled() @pytest.mark.asyncio @@ -38,159 +45,238 @@ async def test_function_tool_task_progress_deadline_returns_none_for_external_wa loop = asyncio.get_running_loop() blocker: asyncio.Future[None] = loop.create_future() + started = asyncio.Event() + async def _blocked_task() -> None: + started.set() await blocker task = asyncio.create_task(_blocked_task()) - await asyncio.sleep(0) + try: + await started.wait() + assert not task.done() + assert not blocker.done() + + deadline = get_function_tool_task_progress_deadline( + task=task, + task_to_invoke_task={}, + loop=loop, + ) - deadline = get_function_tool_task_progress_deadline( - task=task, - task_to_invoke_task={}, - loop=loop, - ) + assert deadline is None - assert deadline is None + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + assert task.cancelled() @pytest.mark.asyncio async def test_function_tool_task_progress_deadline_can_follow_tracked_invoke_task() -> None: loop = asyncio.get_running_loop() outer_started = asyncio.Event() + invoke_started = asyncio.Event() async def _invoke_task() -> None: + invoke_started.set() await asyncio.sleep(0.05) async def _outer_task() -> None: outer_started.set() await asyncio.Future() + before = loop.time() invoke_task = asyncio.create_task(_invoke_task()) outer_task = asyncio.create_task(_outer_task()) - await asyncio.wait_for(outer_started.wait(), timeout=0.2) + try: + await invoke_started.wait() + await outer_started.wait() + assert not outer_task.done() + assert not invoke_task.done() - before = loop.time() - deadline = get_function_tool_task_progress_deadline( - task=outer_task, - task_to_invoke_task={outer_task: invoke_task}, - loop=loop, - ) + inspected = loop.time() + deadline = get_function_tool_task_progress_deadline( + task=outer_task, + task_to_invoke_task={outer_task: invoke_task}, + loop=loop, + ) - assert deadline is not None - assert before <= deadline <= before + 0.1 + assert deadline is not None + assert before + 0.05 <= deadline <= inspected + 0.05 - outer_task.cancel() - invoke_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await outer_task - with contextlib.suppress(asyncio.CancelledError): - await invoke_task + finally: + outer_task.cancel() + invoke_task.cancel() + await asyncio.gather(outer_task, invoke_task, return_exceptions=True) + + assert outer_task.cancelled() + assert invoke_task.cancelled() @pytest.mark.asyncio async def test_function_tool_task_progress_deadline_can_follow_awaited_child_task() -> None: loop = asyncio.get_running_loop() + started = asyncio.Event() + + async def _child_task() -> None: + started.set() + await asyncio.sleep(0.05) + async def _parent_task() -> None: - child = asyncio.create_task(asyncio.sleep(0.05)) await child + before = loop.time() + child = asyncio.create_task(_child_task()) + task = asyncio.create_task(_parent_task()) - await asyncio.sleep(0) + try: + await started.wait() + assert not task.done() + assert not child.done() - before = loop.time() - deadline = get_function_tool_task_progress_deadline( - task=task, - task_to_invoke_task={}, - loop=loop, - ) + inspected = loop.time() + deadline = get_function_tool_task_progress_deadline( + task=task, + task_to_invoke_task={}, + loop=loop, + ) + + assert deadline is not None + assert before + 0.05 <= deadline <= inspected + 0.05 - assert deadline is not None - assert before <= deadline <= before + 0.1 + finally: + task.cancel() + child.cancel() + await asyncio.gather(task, child, return_exceptions=True) - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + assert task.cancelled() + assert child.cancelled() @pytest.mark.asyncio async def test_function_tool_task_progress_deadline_can_follow_shielded_child_task() -> None: loop = asyncio.get_running_loop() + started = asyncio.Event() + + async def _child_task() -> None: + started.set() + await asyncio.sleep(0.05) + async def _shielded_task() -> None: - child = asyncio.create_task(asyncio.sleep(0.05)) await asyncio.shield(child) + before = loop.time() + child = asyncio.create_task(_child_task()) + task = asyncio.create_task(_shielded_task()) - await asyncio.sleep(0) + try: + await started.wait() + assert not task.done() + assert not child.done() - before = loop.time() - deadline = get_function_tool_task_progress_deadline( - task=task, - task_to_invoke_task={}, - loop=loop, - ) + inspected = loop.time() + deadline = get_function_tool_task_progress_deadline( + task=task, + task_to_invoke_task={}, + loop=loop, + ) - assert deadline is not None - assert before <= deadline <= before + 0.1 + assert deadline is not None + assert before + 0.05 <= deadline <= inspected + 0.05 - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + finally: + task.cancel() + child.cancel() + await asyncio.gather(task, child, return_exceptions=True) + + assert task.cancelled() + assert child.cancelled() @pytest.mark.asyncio async def test_function_tool_task_progress_deadline_can_follow_gathered_child_tasks() -> None: loop = asyncio.get_running_loop() - async def _gathered_task() -> None: - await asyncio.gather(asyncio.sleep(0.05), asyncio.sleep(0.06)) + first_started = asyncio.Event() + second_started = asyncio.Event() - task = asyncio.create_task(_gathered_task()) - await asyncio.sleep(0) + async def _child_task(started: asyncio.Event, delay: float) -> None: + started.set() + await asyncio.sleep(delay) - before = loop.time() - deadline = get_function_tool_task_progress_deadline( - task=task, - task_to_invoke_task={}, - loop=loop, - ) + async def _gathered_task() -> None: + await asyncio.gather(first_child, second_child) - assert deadline is not None - assert before <= deadline <= before + 0.1 + before = loop.time() + first_child = asyncio.create_task(_child_task(first_started, 0.05)) + second_child = asyncio.create_task(_child_task(second_started, 0.06)) - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + task = asyncio.create_task(_gathered_task()) + try: + await first_started.wait() + await second_started.wait() + assert not task.done() + assert not first_child.done() + assert not second_child.done() + + inspected = loop.time() + deadline = get_function_tool_task_progress_deadline( + task=task, + task_to_invoke_task={}, + loop=loop, + ) + + assert deadline is not None + assert before + 0.05 <= deadline <= inspected + 0.05 + + finally: + task.cancel() + first_child.cancel() + second_child.cancel() + await asyncio.gather(task, first_child, second_child, return_exceptions=True) + + assert task.cancelled() + assert first_child.cancelled() + assert second_child.cancelled() @pytest.mark.asyncio async def test_function_tool_task_progress_deadline_can_follow_timer_backed_future() -> None: loop = asyncio.get_running_loop() future: asyncio.Future[None] = loop.create_future() - handle = loop.call_later(0.05, future.set_result, None) + handle: asyncio.TimerHandle | None = None + + started = asyncio.Event() async def _timer_backed_future_task() -> None: + started.set() await future task = asyncio.create_task(_timer_backed_future_task()) - await asyncio.sleep(0) - - before = loop.time() - deadline = get_function_tool_task_progress_deadline( - task=task, - task_to_invoke_task={}, - loop=loop, - ) - - assert deadline is not None - assert before <= deadline <= before + 0.1 - - task.cancel() - handle.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + try: + await started.wait() + assert not task.done() + assert not future.done() + + # Arm the real timer after startup so no loop turn can expire it before inspection. + handle = loop.call_later(0.05, future.set_result, None) + deadline = get_function_tool_task_progress_deadline( + task=task, + task_to_invoke_task={}, + loop=loop, + ) + + assert deadline is not None + assert deadline == handle.when() + + finally: + if handle is not None: + handle.cancel() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + assert task.cancelled() + assert handle is not None and handle.cancelled() diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index 07e975ccb9..6182f9d2ba 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -70,117 +70,155 @@ def test_batch_trace_processor_on_trace_start(mocked_exporter): processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=0.1) test_trace = get_trace(processor) - processor.on_trace_start(test_trace) - assert processor._queue.qsize() == 1, "Trace should be added to the queue" - - # Shutdown to clean up the worker thread - processor.shutdown() + try: + with processor._export_lock: + processor.on_trace_start(test_trace) + assert processor._queue.qsize() == 1, "Trace should be added to the queue" + finally: + processor.shutdown() def test_batch_trace_processor_on_span_end(mocked_exporter): processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=0.1) test_span = get_span(processor) - processor.on_span_end(test_span) - assert processor._queue.qsize() == 1, "Span should be added to the queue" - - # Shutdown to clean up the worker thread - processor.shutdown() + try: + with processor._export_lock: + processor.on_span_end(test_span) + assert processor._queue.qsize() == 1, "Span should be added to the queue" + finally: + processor.shutdown() def test_batch_trace_processor_queue_full(mocked_exporter): processor = BatchTraceProcessor(exporter=mocked_exporter, max_queue_size=2, schedule_delay=0.1) - # Fill the queue - processor.on_trace_start(get_trace(processor)) - processor.on_trace_start(get_trace(processor)) - assert processor._queue.full() is True - - # Next item should not be queued - processor.on_trace_start(get_trace(processor)) - assert processor._queue.qsize() == 2, "Queue should not exceed max_queue_size" + try: + with processor._export_lock: + # Fill the queue. + processor.on_trace_start(get_trace(processor)) + processor.on_trace_start(get_trace(processor)) + assert processor._queue.full() is True - processor.on_span_end(get_span(processor)) - assert processor._queue.qsize() == 2, "Queue should not exceed max_queue_size" + # Next item should not be queued. + processor.on_trace_start(get_trace(processor)) + assert processor._queue.qsize() == 2, "Queue should not exceed max_queue_size" - processor.shutdown() + processor.on_span_end(get_span(processor)) + assert processor._queue.qsize() == 2, "Queue should not exceed max_queue_size" + finally: + processor.shutdown() def test_batch_processor_doesnt_enqueue_on_trace_end_or_span_start(mocked_exporter): processor = BatchTraceProcessor(exporter=mocked_exporter) - processor.on_trace_start(get_trace(processor)) - assert processor._queue.qsize() == 1, "Trace should be queued" + try: + with processor._export_lock: + processor.on_trace_start(get_trace(processor)) + assert processor._queue.qsize() == 1, "Trace should be queued" - processor.on_span_start(get_span(processor)) - assert processor._queue.qsize() == 1, "Span should not be queued" + processor.on_span_start(get_span(processor)) + assert processor._queue.qsize() == 1, "Span should not be queued" - processor.on_span_end(get_span(processor)) - assert processor._queue.qsize() == 2, "Span should be queued" + processor.on_span_end(get_span(processor)) + assert processor._queue.qsize() == 2, "Span should be queued" - processor.on_trace_end(get_trace(processor)) - assert processor._queue.qsize() == 2, "Nothing new should be queued" - - processor.shutdown() + processor.on_trace_end(get_trace(processor)) + assert processor._queue.qsize() == 2, "Nothing new should be queued" + finally: + processor.shutdown() def test_batch_trace_processor_force_flush(mocked_exporter): processor = BatchTraceProcessor(exporter=mocked_exporter, max_batch_size=2, schedule_delay=5.0) - processor.on_trace_start(get_trace(processor)) - processor.on_span_end(get_span(processor)) - processor.on_span_end(get_span(processor)) - - processor.force_flush() + try: + with processor._export_lock: + processor.on_trace_start(get_trace(processor)) + processor.on_span_end(get_span(processor)) + processor.on_span_end(get_span(processor)) - # Ensure exporter.export was called with all items in batches respecting max_batch_size=2 - exported_batches = [call_args[0][0] for call_args in mocked_exporter.export.call_args_list] - total_exported = sum(len(batch) for batch in exported_batches) + processor.force_flush() - # We pushed 3 items; ensure they all got exported across 2 batches (sizes 2 and 1) - assert total_exported == 3 - assert [len(batch) for batch in exported_batches] == [2, 1] + # Ensure exporter.export was called with all items in batches respecting max_batch_size=2. + exported_batches = [call_args[0][0] for call_args in mocked_exporter.export.call_args_list] + total_exported = sum(len(batch) for batch in exported_batches) - processor.shutdown() + # We pushed 3 items; ensure they all got exported across 2 batches (sizes 2 and 1). + assert total_exported == 3 + assert [len(batch) for batch in exported_batches] == [2, 1] + finally: + processor.shutdown() -def test_batch_trace_processor_force_flush_waits_for_in_flight_background_export(): +def test_batch_trace_processor_force_flush_waits_for_in_flight_background_export(monkeypatch): export_started = threading.Event() export_continue = threading.Event() + export_completed = threading.Event() + flush_blocked = threading.Event() + flush_completed = threading.Event() + + class ObservedExportLock: + def __init__(self) -> None: + self.lock = threading.Lock() + + def __enter__(self) -> None: + if not self.lock.acquire(blocking=False): + flush_blocked.set() + self.lock.acquire() + + def __exit__(self, *args: object) -> None: + self.lock.release() class BlockingExporter(TracingExporter): def export(self, items: list[Trace | Span[Any]]) -> None: export_started.set() - assert export_continue.wait(timeout=2.0) + export_continue.wait() + export_completed.set() processor = BatchTraceProcessor(exporter=BlockingExporter(), schedule_delay=0.01) - processor.on_trace_start(get_trace(processor)) - - assert export_started.wait(timeout=2.0) + monkeypatch.setattr(processor, "_export_lock", ObservedExportLock()) - flush_thread = threading.Thread(target=processor.force_flush) - flush_thread.start() + def flush() -> None: + processor.force_flush() + flush_completed.set() - time.sleep(0.1) - assert flush_thread.is_alive(), "force_flush() should wait for an in-flight export" - - export_continue.set() - flush_thread.join(timeout=2.0) + flush_thread = threading.Thread(target=flush) + try: + processor.on_trace_start(get_trace(processor)) + assert export_started.wait(timeout=2.0) + + flush_thread.start() + assert flush_blocked.wait(timeout=2.0) + assert not export_completed.is_set() + assert not flush_completed.is_set(), "force_flush() should wait for an in-flight export" + + export_continue.set() + assert flush_completed.wait(timeout=2.0) + assert export_completed.is_set() + finally: + export_continue.set() + if flush_thread.ident is not None: + flush_thread.join(timeout=2.0) + processor.shutdown(timeout=2.0) assert not flush_thread.is_alive() - - processor.shutdown() + assert processor._worker_thread is not None + assert not processor._worker_thread.is_alive() def test_batch_trace_processor_shutdown_flushes(mocked_exporter): processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=5.0) - processor.on_trace_start(get_trace(processor)) - processor.on_span_end(get_span(processor)) - qsize_before = processor._queue.qsize() - assert qsize_before == 2 - - processor.shutdown() - - # Ensure everything was exported after shutdown + try: + with processor._export_lock: + processor.on_trace_start(get_trace(processor)) + processor.on_span_end(get_span(processor)) + qsize_before = processor._queue.qsize() + assert qsize_before == 2 + finally: + processor.shutdown() + + # Ensure everything was exported after shutdown. total_exported = 0 for call_args in mocked_exporter.export.call_args_list: batch = call_args[0][0] @@ -206,21 +244,23 @@ def export(self, items: list[Trace | Span[Any]]) -> None: schedule_delay=60.0, export_trigger_ratio=1.0, ) - processor.on_span_end(get_span(processor)) - - assert export_started.wait(timeout=2.0) + try: + processor.on_span_end(get_span(processor)) + assert export_started.wait(timeout=2.0) - start = time.monotonic() - with caplog.at_level(logging.WARNING): - processor.shutdown(timeout=0.05) - elapsed = time.monotonic() - start + start = time.monotonic() + with caplog.at_level(logging.WARNING): + processor.shutdown(timeout=0.05) + elapsed = time.monotonic() - start - assert elapsed < 0.5 - assert "shutdown timeout reached" in caplog.text + assert elapsed < 0.5 + assert "shutdown timeout reached" in caplog.text + finally: + release_export.set() + processor.shutdown(timeout=2.0) - release_export.set() - if processor._worker_thread: - processor._worker_thread.join(timeout=2.0) + assert processor._worker_thread is not None + assert not processor._worker_thread.is_alive() def test_batch_trace_processor_shutdown_passes_deadline_to_exporter() -> None: @@ -252,6 +292,9 @@ def test_batch_trace_processor_survives_exporter_exception(): spans to silently accumulate in the queue until it filled up. """ + first_export_started = threading.Event() + recovery_completed = threading.Event() + class FlakyExporter(TracingExporter): def __init__(self) -> None: self.call_count = 0 @@ -260,26 +303,36 @@ def __init__(self) -> None: def export(self, items: list[Trace | Span[Any]]) -> None: self.call_count += 1 if self.call_count == 1: + first_export_started.set() raise RuntimeError("simulated exporter failure") self.exported.extend(items) + if len(self.exported) == 2: + recovery_completed.set() exporter = FlakyExporter() processor = BatchTraceProcessor(exporter, schedule_delay=0.05, max_batch_size=1) - processor.on_span_end(get_span(processor)) - processor.on_span_end(get_span(processor)) - processor.on_span_end(get_span(processor)) + try: + processor.on_span_end(get_span(processor)) + assert first_export_started.wait(timeout=2.0) + worker = processor._worker_thread - # Give the worker time to encounter the failure and continue processing. - time.sleep(0.3) + later_spans = [get_span(processor), get_span(processor)] + for span in later_spans: + processor.on_span_end(span) - assert processor._worker_thread is not None - assert processor._worker_thread.is_alive(), "Worker thread must survive an exporter exception" + assert recovery_completed.wait(timeout=2.0) + assert worker is not None + assert processor._worker_thread is worker + assert worker.is_alive(), "Worker thread must survive an exporter exception" - processor.shutdown(timeout=2.0) + # Recovery must happen on the worker before shutdown can drain the queue. + assert exporter.exported == later_spans + assert exporter.call_count == 3 + finally: + processor.shutdown(timeout=2.0) - # First batch raised; the remaining two items must still have been exported. - assert len(exporter.exported) == 2 - assert exporter.call_count >= 3 + assert processor._worker_thread is not None + assert not processor._worker_thread.is_alive() @pytest.mark.parametrize( @@ -424,17 +477,19 @@ def test_get_trace_provider_force_flush_flushes_default_processor(mocked_exporte processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=60.0) provider.register_processor(processor) - with patch("agents.tracing.setup.GLOBAL_TRACE_PROVIDER", provider): - processor.on_trace_start(get_trace(processor)) - processor.on_span_end(get_span(processor)) + try: + with patch("agents.tracing.setup.GLOBAL_TRACE_PROVIDER", provider): + processor.on_trace_start(get_trace(processor)) + processor.on_span_end(get_span(processor)) - get_trace_provider().force_flush() + get_trace_provider().force_flush() - total_exported = sum( - len(call_args[0][0]) for call_args in mocked_exporter.export.call_args_list - ) - assert total_exported == 2 - processor.shutdown() + total_exported = sum( + len(call_args[0][0]) for call_args in mocked_exporter.export.call_args_list + ) + assert total_exported == 2 + finally: + processor.shutdown() def mock_processor(): @@ -573,19 +628,22 @@ def post(**kwargs: Any) -> Any: export_trigger_ratio=1.0, ) - processor.on_span_end(get_span(processor)) - assert post_called.wait(timeout=2.0) + try: + processor.on_span_end(get_span(processor)) + assert post_called.wait(timeout=2.0) - start = time.monotonic() - processor.shutdown(timeout=1.0) - elapsed = time.monotonic() - start + start = time.monotonic() + processor.shutdown(timeout=1.0) + elapsed = time.monotonic() - start - assert elapsed < 0.5 - assert processor._worker_thread is not None - assert not processor._worker_thread.is_alive() - assert mock_client.return_value.post.call_count == 1 + assert elapsed < 0.5 + assert processor._worker_thread is not None + assert not processor._worker_thread.is_alive() + assert mock_client.return_value.post.call_count == 1 - exporter.close() + finally: + processor.shutdown(timeout=2.0) + exporter.close() @patch("httpx2.Client") From 4a11d20d126ebc844e362ae3abfe13b775dbaee3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 18:11:52 +0900 Subject: [PATCH 446/473] refactor: extract RunState agent graph identity (#4876) --- src/agents/_run_state_agent_identity.py | 502 ++++++++++++++++ src/agents/run_internal/tool_use_tracker.py | 10 +- src/agents/run_state.py | 487 +-------------- src/agents/sandbox/runtime_session_manager.py | 10 +- tests/sandbox/test_runtime.py | 3 +- tests/test_agent_as_tool.py | 2 +- tests/test_run_state.py | 530 +---------------- tests/test_run_state_agent_identity.py | 557 ++++++++++++++++++ 8 files changed, 1081 insertions(+), 1020 deletions(-) create mode 100644 src/agents/_run_state_agent_identity.py create mode 100644 tests/test_run_state_agent_identity.py diff --git a/src/agents/_run_state_agent_identity.py b/src/agents/_run_state_agent_identity.py new file mode 100644 index 0000000000..80de6d71e4 --- /dev/null +++ b/src/agents/_run_state_agent_identity.py @@ -0,0 +1,502 @@ +"""Stable agent graph identities shared by RunState, tool tracking, and sandbox resume.""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import threading +from collections import deque +from collections.abc import Iterator, Mapping, Sequence +from pathlib import Path +from typing import Any, cast + +from ._tool_identity import get_function_tool_namespace, get_function_tool_qualified_name +from .agent import Agent +from .handoffs import Handoff +from .logger import logger +from .sandbox.capabilities.capability import Capability +from .sandbox.session.base_sandbox_session import BaseSandboxSession +from .tool import ( + ApplyPatchTool, + ComputerTool, + FunctionTool, + HostedMCPTool, + LocalShellTool, + ShellTool, +) + + +def _iter_agent_graph(initial_agent: Agent[Any]) -> Iterator[Agent[Any]]: + """Yield agents reachable from the starting agent in breadth-first order.""" + queue: deque[Agent[Any]] = deque([initial_agent]) + seen_agent_ids: set[int] = set() + + while queue: + current = queue.popleft() + current_id = id(current) + if current_id in seen_agent_ids: + continue + seen_agent_ids.add(current_id) + yield current + + for handoff_item in current.handoffs: + handoff_agent: Any | None = None + handoff_agent_name: str | None = None + + if isinstance(handoff_item, Handoff): + # Some custom/mocked Handoff subclasses bypass dataclass initialization. + # Prefer agent_name, then legacy name fallback used in tests. + candidate_name = getattr(handoff_item, "agent_name", None) or getattr( + handoff_item, "name", None + ) + if isinstance(candidate_name, str): + handoff_agent_name = candidate_name + + handoff_ref = getattr(handoff_item, "_agent_ref", None) + handoff_agent = handoff_ref() if callable(handoff_ref) else None + if handoff_agent is None: + # Backward-compatibility fallback for custom legacy handoff objects that store + # the target directly on `.agent`. New code should prefer `handoff()` objects. + legacy_agent = getattr(handoff_item, "agent", None) + if legacy_agent is not None: + handoff_agent = legacy_agent + logger.debug( + "Using legacy handoff `.agent` fallback while building agent map. " + "This compatibility path is not recommended for new code." + ) + if handoff_agent_name is None: + candidate_name = getattr(handoff_agent, "name", None) + handoff_agent_name = candidate_name if isinstance(candidate_name, str) else None + if handoff_agent is None or not hasattr(handoff_agent, "handoffs"): + if handoff_agent_name: + logger.debug( + "Skipping unresolved handoff target while building agent map: %s", + handoff_agent_name, + ) + continue + else: + # Backward-compatibility fallback for custom legacy handoff wrappers that expose + # the target directly on `.agent` without inheriting from `Handoff`. + legacy_agent = getattr(handoff_item, "agent", None) + if legacy_agent is not None: + handoff_agent = legacy_agent + logger.debug( + "Using legacy non-`Handoff` `.agent` fallback while building agent map." + ) + else: + handoff_agent = handoff_item + candidate_name = getattr(handoff_agent, "name", None) + handoff_agent_name = candidate_name if isinstance(candidate_name, str) else None + + if handoff_agent is not None and handoff_agent_name: + queue.append(cast(Agent[Any], handoff_agent)) + + # Include agent-as-tool instances so nested approvals can be restored. + tools = getattr(current, "tools", None) + if tools: + for tool in tools: + if not getattr(tool, "_is_agent_tool", False): + continue + tool_agent = getattr(tool, "_agent_instance", None) + tool_agent_name = getattr(tool_agent, "name", None) + if tool_agent is not None and tool_agent_name: + queue.append(tool_agent) + + +def _allocate_unique_agent_identity(agent_name: str, used_identities: set[str]) -> str: + """Return a deterministic identity key without colliding with literal agent names.""" + candidate = agent_name + next_index = 1 + while candidate in used_identities: + next_index += 1 + candidate = f"{agent_name}#{next_index}" + used_identities.add(candidate) + return candidate + + +def _identity_type_name(value: Any) -> str: + return f"{type(value).__module__}.{type(value).__qualname__}" + + +def _callable_identity_name(value: Any) -> str: + module = getattr(value, "__module__", type(value).__module__) + qualname = getattr(value, "__qualname__", type(value).__qualname__) + return f"{module}.{qualname}" + + +def _normalize_identity_value(value: Any) -> Any: + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, bytes | bytearray): + return {"type": "bytes", "length": len(value)} + if callable(value): + return {"callable": _callable_identity_name(value)} + if dataclasses.is_dataclass(value): + return { + "dataclass": _identity_type_name(value), + "value": _normalize_identity_value(dataclasses.asdict(cast(Any, value))), + } + if hasattr(value, "model_dump"): + try: + dumped = value.model_dump(exclude_unset=True) + except TypeError: + dumped = value.model_dump() + return { + "model": _identity_type_name(value), + "value": _normalize_identity_value(dumped), + } + if isinstance(value, Mapping): + return { + str(key): _normalize_identity_value(item) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [_normalize_identity_value(item) for item in value] + + value_name = getattr(value, "name", None) + if isinstance(value_name, str): + return {"type": _identity_type_name(value), "name": value_name} + return {"type": _identity_type_name(value)} + + +def _stable_identity_text(value: Any) -> str: + return json.dumps( + _normalize_identity_value(value), + sort_keys=True, + separators=(",", ":"), + ) + + +def _tool_identity_signature(tool: Any) -> dict[str, Any]: + signature: dict[str, Any] = { + "type": _identity_type_name(tool), + "name": getattr(tool, "name", None), + } + namespace = get_function_tool_namespace(tool) + if namespace is not None: + signature["namespace"] = namespace + qualified_name = get_function_tool_qualified_name(tool) + if qualified_name is not None: + signature["qualified_name"] = qualified_name + if hasattr(tool, "environment"): + signature["environment"] = _normalize_identity_value(tool.environment) + if getattr(tool, "_is_agent_tool", False): + nested_agent = getattr(tool, "_agent_instance", None) + signature["agent_tool_target"] = getattr(nested_agent, "name", None) + return signature + + +_THREADING_LOCK_TYPES = (type(threading.Lock()), type(threading.RLock())) + + +def _is_capability_runtime_only_value(value: Any) -> bool: + return isinstance( + value, + ( + BaseSandboxSession, + asyncio.Event, + asyncio.Lock, + asyncio.Semaphore, + asyncio.Condition, + threading.Event, + *_THREADING_LOCK_TYPES, + ), + ) + + +def _normalize_capability_identity_value( + value: Any, + *, + seen: set[int] | None = None, +) -> Any: + if seen is None: + seen = set() + + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, Path): + return value.as_posix() + if isinstance(value, bytes | bytearray): + return {"type": "bytes", "length": len(value)} + if callable(value): + return {"callable": _callable_identity_name(value)} + if _is_capability_runtime_only_value(value): + return {"runtime_only": _identity_type_name(value)} + if isinstance( + value, + ApplyPatchTool | ComputerTool | FunctionTool | HostedMCPTool | LocalShellTool | ShellTool, + ): + return _tool_identity_signature(value) + + object_id = id(value) + if object_id in seen: + return {"recursive": _identity_type_name(value)} + + if dataclasses.is_dataclass(value): + seen.add(object_id) + try: + merged_fields = { + field.name: getattr(value, field.name) for field in dataclasses.fields(value) + } + if hasattr(value, "__dict__"): + for name, item in vars(value).items(): + if name.startswith("_") or name in merged_fields: + continue + merged_fields[name] = item + return { + "dataclass": _identity_type_name(value), + "value": { + name: _normalize_capability_identity_value( + item, + seen=seen, + ) + for name, item in sorted(merged_fields.items()) + }, + } + finally: + seen.remove(object_id) + + if isinstance(value, Capability): + seen.add(object_id) + try: + merged_fields = {} + for name, field_info in value.__class__.model_fields.items(): + if field_info.exclude or name.startswith("_") or name == "session": + continue + merged_fields[name] = getattr(value, name) + return { + "capability": _identity_type_name(value), + "value": { + name: _normalize_capability_identity_value( + item, + seen=seen, + ) + for name, item in sorted(merged_fields.items()) + }, + } + finally: + seen.remove(object_id) + + if hasattr(value, "model_dump"): + seen.add(object_id) + try: + try: + dumped = value.model_dump(mode="json", round_trip=True) + except TypeError: + dumped = value.model_dump(mode="json") + return { + "model": _identity_type_name(value), + "value": _normalize_capability_identity_value(dumped, seen=seen), + } + finally: + seen.remove(object_id) + + if isinstance(value, Mapping): + seen.add(object_id) + try: + return { + str(key): _normalize_capability_identity_value(item, seen=seen) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + finally: + seen.remove(object_id) + + if isinstance(value, set | frozenset): + seen.add(object_id) + try: + normalized_items = [ + _normalize_capability_identity_value(item, seen=seen) for item in value + ] + return sorted(normalized_items, key=_stable_identity_text) + finally: + seen.remove(object_id) + + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + seen.add(object_id) + try: + return [_normalize_capability_identity_value(item, seen=seen) for item in value] + finally: + seen.remove(object_id) + + if hasattr(value, "__dict__"): + seen.add(object_id) + try: + return { + "object": _identity_type_name(value), + "value": { + name: _normalize_capability_identity_value(item, seen=seen) + for name, item in sorted(vars(value).items()) + if not name.startswith("_") + }, + } + finally: + seen.remove(object_id) + + value_name = getattr(value, "name", None) + if isinstance(value_name, str): + return {"type": _identity_type_name(value), "name": value_name} + return {"type": _identity_type_name(value)} + + +def _capability_identity_signature(capability: Any) -> dict[str, Any]: + return { + "type": _identity_type_name(capability), + "value": _normalize_capability_identity_value(capability), + } + + +def _handoff_identity_signature(handoff_item: Agent[Any] | Handoff[Any, Any]) -> dict[str, Any]: + if isinstance(handoff_item, Handoff): + tool_name = getattr(handoff_item, "tool_name", None) + if not isinstance(tool_name, str): + tool_name = getattr(handoff_item, "name", None) + agent_name = getattr(handoff_item, "agent_name", None) + return { + "type": _identity_type_name(handoff_item), + "tool_name": tool_name, + "agent_name": agent_name if isinstance(agent_name, str) else None, + "input_filter": _normalize_identity_value(getattr(handoff_item, "input_filter", None)), + "nest_handoff_history": getattr(handoff_item, "nest_handoff_history", None), + } + + return { + "type": _identity_type_name(handoff_item), + "agent_name": getattr(handoff_item, "name", None), + } + + +def _agent_identity_signature(agent: Agent[Any]) -> str: + signature: dict[str, Any] = { + "agent_type": _identity_type_name(agent), + "handoff_description": getattr(agent, "handoff_description", None), + "instructions": _normalize_identity_value(getattr(agent, "instructions", None)), + "prompt": _normalize_identity_value(getattr(agent, "prompt", None)), + "model": _normalize_identity_value(getattr(agent, "model", None)), + "model_settings": _normalize_identity_value(getattr(agent, "model_settings", None)), + "mcp_config": _normalize_capability_identity_value(getattr(agent, "mcp_config", None)), + "hooks": _normalize_capability_identity_value(getattr(agent, "hooks", None)), + "input_guardrails": sorted( + _stable_identity_text(_normalize_capability_identity_value(guardrail)) + for guardrail in getattr(agent, "input_guardrails", []) + ), + "output_guardrails": sorted( + _stable_identity_text(_normalize_capability_identity_value(guardrail)) + for guardrail in getattr(agent, "output_guardrails", []) + ), + "output_type": _normalize_identity_value(getattr(agent, "output_type", None)), + "tool_use_behavior": _normalize_capability_identity_value( + getattr(agent, "tool_use_behavior", None) + ), + "reset_tool_choice": getattr(agent, "reset_tool_choice", None), + "tools": sorted( + _stable_identity_text(_tool_identity_signature(tool)) + for tool in getattr(agent, "tools", []) + ), + "handoffs": sorted( + _stable_identity_text(_handoff_identity_signature(handoff_item)) + for handoff_item in getattr(agent, "handoffs", []) + ), + "mcp_servers": sorted( + _stable_identity_text(server) for server in getattr(agent, "mcp_servers", []) + ), + } + + default_manifest = getattr(agent, "default_manifest", None) + if default_manifest is not None: + signature["default_manifest"] = _normalize_capability_identity_value(default_manifest) + + base_instructions = getattr(agent, "base_instructions", None) + if base_instructions is not None: + signature["base_instructions"] = _normalize_identity_value(base_instructions) + + capabilities = getattr(agent, "capabilities", None) + if isinstance(capabilities, Sequence): + signature["capabilities"] = sorted( + _stable_identity_text(_capability_identity_signature(capability)) + for capability in capabilities + ) + + return _stable_identity_text(signature) + + +def _agent_identity_sort_key( + agent: Agent[Any], + *, + root_agent: Agent[Any], + original_index: int, +) -> tuple[int, str, int]: + return ( + 0 if agent is root_agent else 1, + _agent_identity_signature(agent), + original_index, + ) + + +def _build_agent_identity_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]: + """Build a stable identity map that preserves duplicate agent names.""" + ordered_agents = list(_iter_agent_graph(initial_agent)) + original_indices = {id(agent): index for index, agent in enumerate(ordered_agents)} + literal_names = {agent.name for agent in ordered_agents} + agents_by_name: dict[str, list[Agent[Any]]] = {} + for agent in ordered_agents: + agents_by_name.setdefault(agent.name, []).append(agent) + + agent_identity_map: dict[str, Agent[Any]] = {} + used_identities: set[str] = set() + processed_names: set[str] = set() + + for agent in ordered_agents: + agent_name = agent.name + if agent_name in processed_names: + continue + processed_names.add(agent_name) + + group = agents_by_name[agent_name] + sorted_group = sorted( + group, + key=lambda candidate: _agent_identity_sort_key( + candidate, + root_agent=initial_agent, + original_index=original_indices[id(candidate)], + ), + ) + + base_agent = sorted_group[0] + used_identities.add(agent_name) + agent_identity_map[agent_name] = base_agent + + next_index = 2 + for duplicate_agent in sorted_group[1:]: + candidate = f"{agent_name}#{next_index}" + while candidate in used_identities or candidate in literal_names: + next_index += 1 + candidate = f"{agent_name}#{next_index}" + used_identities.add(candidate) + agent_identity_map[candidate] = duplicate_agent + next_index += 1 + + return agent_identity_map + + +def _build_agent_identity_keys_by_id(initial_agent: Agent[Any]) -> dict[int, str]: + """Build stable identity keys for the reachable agent graph.""" + return { + id(agent): identity for identity, agent in _build_agent_identity_map(initial_agent).items() + } + + +def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]: + """Build a map of agent names to agents by traversing handoffs. + + Args: + initial_agent: The starting agent. + + Returns: + Dictionary mapping agent names to agent instances. + """ + agent_map: dict[str, Agent[Any]] = {} + for agent in _iter_agent_graph(initial_agent): + agent_map.setdefault(agent.name, agent) + + return agent_map diff --git a/src/agents/run_internal/tool_use_tracker.py b/src/agents/run_internal/tool_use_tracker.py index c84545d8b5..438ac79efa 100644 --- a/src/agents/run_internal/tool_use_tracker.py +++ b/src/agents/run_internal/tool_use_tracker.py @@ -7,6 +7,11 @@ from typing import TYPE_CHECKING, Any, get_args, get_origin +from .._run_state_agent_identity import ( + _build_agent_identity_keys_by_id, + _build_agent_identity_map, + _build_agent_map, +) from .._tool_identity import get_function_tool_trace_name from ..agent import Agent from ..items import ( @@ -17,11 +22,6 @@ ToolSearchCallItem, ToolSearchOutputItem, ) -from ..run_state import ( - _build_agent_identity_keys_by_id, - _build_agent_identity_map, - _build_agent_map, -) from .run_steps import ProcessedResponse, ToolRunFunction if TYPE_CHECKING: diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 228dd574d8..732e768d3d 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -7,11 +7,9 @@ import dataclasses import json import math -import threading from collections import deque -from collections.abc import Callable, Collection, Iterator, Mapping, Sequence +from collections.abc import Callable, Collection, Mapping, Sequence from dataclasses import dataclass, field -from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, cast, get_args from uuid import uuid4 @@ -40,6 +38,12 @@ from pydantic import BaseModel, StringConstraints, TypeAdapter, ValidationError from typing_extensions import TypedDict, TypeVar +from ._run_state_agent_identity import ( + _build_agent_identity_keys_by_id, + _build_agent_identity_map, + _build_agent_map, + _iter_agent_graph, +) from ._tool_identity import ( FunctionToolLookupKey, NamedToolLookupKey, @@ -114,8 +118,6 @@ ensure_programmatic_tool_call_parent, ensure_tool_caller_allowed, ) -from .sandbox.capabilities.capability import Capability -from .sandbox.session.base_sandbox_session import BaseSandboxSession from .tool import ( ApplyPatchTool, ComputerTool, @@ -4694,481 +4696,6 @@ def record_run_item(run_item: RunItem) -> None: ) -def _iter_agent_graph(initial_agent: Agent[Any]) -> Iterator[Agent[Any]]: - """Yield agents reachable from the starting agent in breadth-first order.""" - queue: deque[Agent[Any]] = deque([initial_agent]) - seen_agent_ids: set[int] = set() - - while queue: - current = queue.popleft() - current_id = id(current) - if current_id in seen_agent_ids: - continue - seen_agent_ids.add(current_id) - yield current - - for handoff_item in current.handoffs: - handoff_agent: Any | None = None - handoff_agent_name: str | None = None - - if isinstance(handoff_item, Handoff): - # Some custom/mocked Handoff subclasses bypass dataclass initialization. - # Prefer agent_name, then legacy name fallback used in tests. - candidate_name = getattr(handoff_item, "agent_name", None) or getattr( - handoff_item, "name", None - ) - if isinstance(candidate_name, str): - handoff_agent_name = candidate_name - - handoff_ref = getattr(handoff_item, "_agent_ref", None) - handoff_agent = handoff_ref() if callable(handoff_ref) else None - if handoff_agent is None: - # Backward-compatibility fallback for custom legacy handoff objects that store - # the target directly on `.agent`. New code should prefer `handoff()` objects. - legacy_agent = getattr(handoff_item, "agent", None) - if legacy_agent is not None: - handoff_agent = legacy_agent - logger.debug( - "Using legacy handoff `.agent` fallback while building agent map. " - "This compatibility path is not recommended for new code." - ) - if handoff_agent_name is None: - candidate_name = getattr(handoff_agent, "name", None) - handoff_agent_name = candidate_name if isinstance(candidate_name, str) else None - if handoff_agent is None or not hasattr(handoff_agent, "handoffs"): - if handoff_agent_name: - logger.debug( - "Skipping unresolved handoff target while building agent map: %s", - handoff_agent_name, - ) - continue - else: - # Backward-compatibility fallback for custom legacy handoff wrappers that expose - # the target directly on `.agent` without inheriting from `Handoff`. - legacy_agent = getattr(handoff_item, "agent", None) - if legacy_agent is not None: - handoff_agent = legacy_agent - logger.debug( - "Using legacy non-`Handoff` `.agent` fallback while building agent map." - ) - else: - handoff_agent = handoff_item - candidate_name = getattr(handoff_agent, "name", None) - handoff_agent_name = candidate_name if isinstance(candidate_name, str) else None - - if handoff_agent is not None and handoff_agent_name: - queue.append(cast(Agent[Any], handoff_agent)) - - # Include agent-as-tool instances so nested approvals can be restored. - tools = getattr(current, "tools", None) - if tools: - for tool in tools: - if not getattr(tool, "_is_agent_tool", False): - continue - tool_agent = getattr(tool, "_agent_instance", None) - tool_agent_name = getattr(tool_agent, "name", None) - if tool_agent is not None and tool_agent_name: - queue.append(tool_agent) - - -def _allocate_unique_agent_identity(agent_name: str, used_identities: set[str]) -> str: - """Return a deterministic identity key without colliding with literal agent names.""" - candidate = agent_name - next_index = 1 - while candidate in used_identities: - next_index += 1 - candidate = f"{agent_name}#{next_index}" - used_identities.add(candidate) - return candidate - - -def _identity_type_name(value: Any) -> str: - return f"{type(value).__module__}.{type(value).__qualname__}" - - -def _callable_identity_name(value: Any) -> str: - module = getattr(value, "__module__", type(value).__module__) - qualname = getattr(value, "__qualname__", type(value).__qualname__) - return f"{module}.{qualname}" - - -def _normalize_identity_value(value: Any) -> Any: - if value is None or isinstance(value, str | int | float | bool): - return value - if isinstance(value, bytes | bytearray): - return {"type": "bytes", "length": len(value)} - if callable(value): - return {"callable": _callable_identity_name(value)} - if dataclasses.is_dataclass(value): - return { - "dataclass": _identity_type_name(value), - "value": _normalize_identity_value(dataclasses.asdict(cast(Any, value))), - } - if hasattr(value, "model_dump"): - try: - dumped = value.model_dump(exclude_unset=True) - except TypeError: - dumped = value.model_dump() - return { - "model": _identity_type_name(value), - "value": _normalize_identity_value(dumped), - } - if isinstance(value, Mapping): - return { - str(key): _normalize_identity_value(item) - for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) - } - if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): - return [_normalize_identity_value(item) for item in value] - - value_name = getattr(value, "name", None) - if isinstance(value_name, str): - return {"type": _identity_type_name(value), "name": value_name} - return {"type": _identity_type_name(value)} - - -def _stable_identity_text(value: Any) -> str: - return json.dumps( - _normalize_identity_value(value), - sort_keys=True, - separators=(",", ":"), - ) - - -def _tool_identity_signature(tool: Any) -> dict[str, Any]: - signature: dict[str, Any] = { - "type": _identity_type_name(tool), - "name": getattr(tool, "name", None), - } - namespace = get_function_tool_namespace(tool) - if namespace is not None: - signature["namespace"] = namespace - qualified_name = get_function_tool_qualified_name(tool) - if qualified_name is not None: - signature["qualified_name"] = qualified_name - if hasattr(tool, "environment"): - signature["environment"] = _normalize_identity_value(tool.environment) - if getattr(tool, "_is_agent_tool", False): - nested_agent = getattr(tool, "_agent_instance", None) - signature["agent_tool_target"] = getattr(nested_agent, "name", None) - return signature - - -_THREADING_LOCK_TYPES = (type(threading.Lock()), type(threading.RLock())) - - -def _is_capability_runtime_only_value(value: Any) -> bool: - return isinstance( - value, - ( - BaseSandboxSession, - asyncio.Event, - asyncio.Lock, - asyncio.Semaphore, - asyncio.Condition, - threading.Event, - *_THREADING_LOCK_TYPES, - ), - ) - - -def _normalize_capability_identity_value( - value: Any, - *, - seen: set[int] | None = None, -) -> Any: - if seen is None: - seen = set() - - if value is None or isinstance(value, str | int | float | bool): - return value - if isinstance(value, Path): - return value.as_posix() - if isinstance(value, bytes | bytearray): - return {"type": "bytes", "length": len(value)} - if callable(value): - return {"callable": _callable_identity_name(value)} - if _is_capability_runtime_only_value(value): - return {"runtime_only": _identity_type_name(value)} - if isinstance( - value, - ApplyPatchTool | ComputerTool | FunctionTool | HostedMCPTool | LocalShellTool | ShellTool, - ): - return _tool_identity_signature(value) - - object_id = id(value) - if object_id in seen: - return {"recursive": _identity_type_name(value)} - - if dataclasses.is_dataclass(value): - seen.add(object_id) - try: - merged_fields = { - field.name: getattr(value, field.name) for field in dataclasses.fields(value) - } - if hasattr(value, "__dict__"): - for name, item in vars(value).items(): - if name.startswith("_") or name in merged_fields: - continue - merged_fields[name] = item - return { - "dataclass": _identity_type_name(value), - "value": { - name: _normalize_capability_identity_value( - item, - seen=seen, - ) - for name, item in sorted(merged_fields.items()) - }, - } - finally: - seen.remove(object_id) - - if isinstance(value, Capability): - seen.add(object_id) - try: - merged_fields = {} - for name, field_info in value.__class__.model_fields.items(): - if field_info.exclude or name.startswith("_") or name == "session": - continue - merged_fields[name] = getattr(value, name) - return { - "capability": _identity_type_name(value), - "value": { - name: _normalize_capability_identity_value( - item, - seen=seen, - ) - for name, item in sorted(merged_fields.items()) - }, - } - finally: - seen.remove(object_id) - - if hasattr(value, "model_dump"): - seen.add(object_id) - try: - try: - dumped = value.model_dump(mode="json", round_trip=True) - except TypeError: - dumped = value.model_dump(mode="json") - return { - "model": _identity_type_name(value), - "value": _normalize_capability_identity_value(dumped, seen=seen), - } - finally: - seen.remove(object_id) - - if isinstance(value, Mapping): - seen.add(object_id) - try: - return { - str(key): _normalize_capability_identity_value(item, seen=seen) - for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) - } - finally: - seen.remove(object_id) - - if isinstance(value, set | frozenset): - seen.add(object_id) - try: - normalized_items = [ - _normalize_capability_identity_value(item, seen=seen) for item in value - ] - return sorted(normalized_items, key=_stable_identity_text) - finally: - seen.remove(object_id) - - if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): - seen.add(object_id) - try: - return [_normalize_capability_identity_value(item, seen=seen) for item in value] - finally: - seen.remove(object_id) - - if hasattr(value, "__dict__"): - seen.add(object_id) - try: - return { - "object": _identity_type_name(value), - "value": { - name: _normalize_capability_identity_value(item, seen=seen) - for name, item in sorted(vars(value).items()) - if not name.startswith("_") - }, - } - finally: - seen.remove(object_id) - - value_name = getattr(value, "name", None) - if isinstance(value_name, str): - return {"type": _identity_type_name(value), "name": value_name} - return {"type": _identity_type_name(value)} - - -def _capability_identity_signature(capability: Any) -> dict[str, Any]: - return { - "type": _identity_type_name(capability), - "value": _normalize_capability_identity_value(capability), - } - - -def _handoff_identity_signature(handoff_item: Agent[Any] | Handoff[Any, Any]) -> dict[str, Any]: - if isinstance(handoff_item, Handoff): - tool_name = getattr(handoff_item, "tool_name", None) - if not isinstance(tool_name, str): - tool_name = getattr(handoff_item, "name", None) - agent_name = getattr(handoff_item, "agent_name", None) - return { - "type": _identity_type_name(handoff_item), - "tool_name": tool_name, - "agent_name": agent_name if isinstance(agent_name, str) else None, - "input_filter": _normalize_identity_value(getattr(handoff_item, "input_filter", None)), - "nest_handoff_history": getattr(handoff_item, "nest_handoff_history", None), - } - - return { - "type": _identity_type_name(handoff_item), - "agent_name": getattr(handoff_item, "name", None), - } - - -def _agent_identity_signature(agent: Agent[Any]) -> str: - signature: dict[str, Any] = { - "agent_type": _identity_type_name(agent), - "handoff_description": getattr(agent, "handoff_description", None), - "instructions": _normalize_identity_value(getattr(agent, "instructions", None)), - "prompt": _normalize_identity_value(getattr(agent, "prompt", None)), - "model": _normalize_identity_value(getattr(agent, "model", None)), - "model_settings": _normalize_identity_value(getattr(agent, "model_settings", None)), - "mcp_config": _normalize_capability_identity_value(getattr(agent, "mcp_config", None)), - "hooks": _normalize_capability_identity_value(getattr(agent, "hooks", None)), - "input_guardrails": sorted( - _stable_identity_text(_normalize_capability_identity_value(guardrail)) - for guardrail in getattr(agent, "input_guardrails", []) - ), - "output_guardrails": sorted( - _stable_identity_text(_normalize_capability_identity_value(guardrail)) - for guardrail in getattr(agent, "output_guardrails", []) - ), - "output_type": _normalize_identity_value(getattr(agent, "output_type", None)), - "tool_use_behavior": _normalize_capability_identity_value( - getattr(agent, "tool_use_behavior", None) - ), - "reset_tool_choice": getattr(agent, "reset_tool_choice", None), - "tools": sorted( - _stable_identity_text(_tool_identity_signature(tool)) - for tool in getattr(agent, "tools", []) - ), - "handoffs": sorted( - _stable_identity_text(_handoff_identity_signature(handoff_item)) - for handoff_item in getattr(agent, "handoffs", []) - ), - "mcp_servers": sorted( - _stable_identity_text(server) for server in getattr(agent, "mcp_servers", []) - ), - } - - default_manifest = getattr(agent, "default_manifest", None) - if default_manifest is not None: - signature["default_manifest"] = _normalize_capability_identity_value(default_manifest) - - base_instructions = getattr(agent, "base_instructions", None) - if base_instructions is not None: - signature["base_instructions"] = _normalize_identity_value(base_instructions) - - capabilities = getattr(agent, "capabilities", None) - if isinstance(capabilities, Sequence): - signature["capabilities"] = sorted( - _stable_identity_text(_capability_identity_signature(capability)) - for capability in capabilities - ) - - return _stable_identity_text(signature) - - -def _agent_identity_sort_key( - agent: Agent[Any], - *, - root_agent: Agent[Any], - original_index: int, -) -> tuple[int, str, int]: - return ( - 0 if agent is root_agent else 1, - _agent_identity_signature(agent), - original_index, - ) - - -def _build_agent_identity_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]: - """Build a stable identity map that preserves duplicate agent names.""" - ordered_agents = list(_iter_agent_graph(initial_agent)) - original_indices = {id(agent): index for index, agent in enumerate(ordered_agents)} - literal_names = {agent.name for agent in ordered_agents} - agents_by_name: dict[str, list[Agent[Any]]] = {} - for agent in ordered_agents: - agents_by_name.setdefault(agent.name, []).append(agent) - - agent_identity_map: dict[str, Agent[Any]] = {} - used_identities: set[str] = set() - processed_names: set[str] = set() - - for agent in ordered_agents: - agent_name = agent.name - if agent_name in processed_names: - continue - processed_names.add(agent_name) - - group = agents_by_name[agent_name] - sorted_group = sorted( - group, - key=lambda candidate: _agent_identity_sort_key( - candidate, - root_agent=initial_agent, - original_index=original_indices[id(candidate)], - ), - ) - - base_agent = sorted_group[0] - used_identities.add(agent_name) - agent_identity_map[agent_name] = base_agent - - next_index = 2 - for duplicate_agent in sorted_group[1:]: - candidate = f"{agent_name}#{next_index}" - while candidate in used_identities or candidate in literal_names: - next_index += 1 - candidate = f"{agent_name}#{next_index}" - used_identities.add(candidate) - agent_identity_map[candidate] = duplicate_agent - next_index += 1 - - return agent_identity_map - - -def _build_agent_identity_keys_by_id(initial_agent: Agent[Any]) -> dict[int, str]: - """Build stable identity keys for the reachable agent graph.""" - return { - id(agent): identity for identity, agent in _build_agent_identity_map(initial_agent).items() - } - - -def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]: - """Build a map of agent names to agents by traversing handoffs. - - Args: - initial_agent: The starting agent. - - Returns: - Dictionary mapping agent names to agent instances. - """ - agent_map: dict[str, Agent[Any]] = {} - for agent in _iter_agent_graph(initial_agent): - agent_map.setdefault(agent.name, agent) - - return agent_map - - def _deserialize_model_responses(responses_data: list[dict[str, Any]]) -> list[ModelResponse]: """Deserialize model responses from JSON data. diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index bc1a5379e9..41767210e4 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -8,15 +8,15 @@ from pathlib import Path from typing import Any, Generic, cast +from .._run_state_agent_identity import ( + _allocate_unique_agent_identity, + _build_agent_identity_keys_by_id, +) from ..agent import Agent from ..exceptions import _raise_data_redacted_error from ..run_config import SandboxArchiveLimits, SandboxConcurrencyLimits, SandboxRunConfig from ..run_context import TContext -from ..run_state import ( - RunState, - _allocate_unique_agent_identity, - _build_agent_identity_keys_by_id, -) +from ..run_state import RunState from ..tracing import custom_span, get_current_trace from ._mount_security import ( _manifest_has_configured_mount_authority, diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 7b4d7f540b..3c4a67595f 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -22,6 +22,7 @@ import agents._debug as _debug import agents.sandbox.runtime_agent_preparation as runtime_agent_preparation_module from agents import Agent, AgentHooks, LocalShellTool, RunHooks, Runner, function_tool +from agents._run_state_agent_identity import _build_agent_identity_map from agents.exceptions import InputGuardrailTripwireTriggered, UserError from agents.guardrail import GuardrailFunctionOutput, InputGuardrail, OutputGuardrail from agents.items import ModelResponse, ToolCallOutputItem, TResponseInputItem @@ -30,7 +31,7 @@ from agents.result import RunResult, RunResultStreaming from agents.run import CallModelData, ModelInputData, RunConfig from agents.run_context import AgentHookContext, RunContextWrapper -from agents.run_state import RunState, _build_agent_identity_map +from agents.run_state import RunState from agents.sandbox import ( FileMode, Group, diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index 513335cab1..90e9c02612 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -37,6 +37,7 @@ function_tool, tool_namespace, ) +from agents._run_state_agent_identity import _build_agent_map from agents._tool_identity import resolve_tool_name_collisions from agents.agent_tool_input import StructuredToolInputBuilderOptions from agents.agent_tool_state import ( @@ -46,7 +47,6 @@ set_agent_tool_state_scope, ) from agents.run_context import _ApprovalRecord -from agents.run_state import _build_agent_map from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent from agents.testing import ScriptedModel from agents.tool_context import ToolContext diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 309171584f..cd2daa51b6 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -42,7 +42,7 @@ from openai.types.responses.tool_param import Mcp from pydantic import BaseModel, ValidationError, model_serializer -from agents import Agent, ModelSettings, RunConfig, RunHooks, Runner, handoff, trace +from agents import Agent, RunConfig, RunHooks, Runner, handoff, trace from agents._tool_invocation import tool_invocation_identity_and_scope from agents.computer import Computer from agents.exceptions import ModelBehaviorError, UserError @@ -99,9 +99,6 @@ SCHEMA_VERSION_SUMMARIES, SUPPORTED_SCHEMA_VERSIONS, RunState, - _build_agent_identity_map, - _build_agent_map, - _capability_identity_signature, _deserialize_items, _deserialize_processed_response, _deserialize_tool_call_output_raw_item, @@ -109,11 +106,10 @@ _serialize_tool_action_groups, ) from agents.sandbox import Manifest -from agents.sandbox.capabilities.capability import Capability from agents.sandbox.entries import BaseEntry, Mount, MountStrategyBase from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxSessionState from agents.sandbox.snapshot import LocalSnapshot -from agents.testing import ModelCall, ModelStep, ScriptedModel, scripted_sandbox_session +from agents.testing import ModelCall, ModelStep, ScriptedModel from agents.tool import ( ApplyPatchTool, ComputerTool, @@ -140,7 +136,6 @@ from .test_responses import ( get_final_output_message, get_function_tool_call, - get_handoff_tool_call, get_text_message, ) from .utils.factories import ( @@ -165,14 +160,6 @@ TContext = TypeVar("TContext") -class _IdentityCapability(Capability): - type: str = "identity" - setting: str - - def __init__(self, *, setting: str) -> None: - super().__init__(type="identity", **cast(Any, {"setting": setting})) - - def make_processed_response( *, new_items: list[RunItem] | None = None, @@ -381,359 +368,6 @@ async def test_max_turns_none_round_trips(self): restored = await RunState.from_json(agent, json_data) assert restored._max_turns is None - @pytest.mark.asyncio - async def test_from_json_restores_duplicate_name_current_agent_by_identity(self): - """Duplicate agent names should round-trip through the serialized identity key.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - second = Agent(name="duplicate") - first = Agent(name="duplicate", handoffs=[second]) - second.handoffs = [first] - state = make_state(first, context=context, original_input="input1", max_turns=2) - state._current_agent = second - - json_data = state.to_json() - assert json_data["current_agent"] == {"name": "duplicate", "identity": "duplicate#2"} - - restored = await RunState.from_json(first, json_data) - assert restored._current_agent is second - - def test_build_agent_identity_map_avoids_literal_suffix_collisions(self) -> None: - """Literal `#` names should not collide with generated duplicate identities.""" - first = Agent(name="sandbox") - literal_suffix = Agent(name="sandbox#2") - second = Agent(name="sandbox") - first.handoffs = [literal_suffix, second] - literal_suffix.handoffs = [first, second] - second.handoffs = [first, literal_suffix] - - identity_map = _build_agent_identity_map(first) - - assert identity_map == { - "sandbox": first, - "sandbox#2": literal_suffix, - "sandbox#3": second, - } - - def test_build_agent_identity_map_is_stable_across_reordered_duplicate_agents(self) -> None: - """Duplicate-name identities should not change when reachable order changes.""" - - @function_tool(name_override="alpha_tool") - def alpha_tool() -> str: - return "alpha" - - @function_tool(name_override="beta_tool") - def beta_tool() -> str: - return "beta" - - def _identity_for( - identity_map: Mapping[str, Agent[Any]], - target: Agent[Any], - ) -> str: - return next(identity for identity, agent in identity_map.items() if agent is target) - - first_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) - first_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) - first_root = Agent(name="triage", handoffs=[first_beta, first_alpha]) - first_alpha.handoffs = [first_root] - first_beta.handoffs = [first_root] - - second_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) - second_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) - second_root = Agent(name="triage", handoffs=[second_alpha, second_beta]) - second_alpha.handoffs = [second_root] - second_beta.handoffs = [second_root] - - first_identity_map = _build_agent_identity_map(first_root) - second_identity_map = _build_agent_identity_map(second_root) - - assert _identity_for(first_identity_map, first_alpha) == _identity_for( - second_identity_map, second_alpha - ) - assert _identity_for(first_identity_map, first_beta) == _identity_for( - second_identity_map, second_beta - ) - - @pytest.mark.asyncio - async def test_from_json_restores_duplicate_name_current_agent_with_reordered_graph(self): - """Restore should keep the same logical duplicate agent after graph reordering.""" - - @function_tool(name_override="alpha_tool") - def alpha_tool() -> str: - return "alpha" - - @function_tool(name_override="beta_tool") - def beta_tool() -> str: - return "beta" - - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - first_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) - first_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) - first_root = Agent(name="triage", handoffs=[first_beta, first_alpha]) - first_alpha.handoffs = [first_root] - first_beta.handoffs = [first_root] - - state = make_state(first_root, context=context, original_input="input1", max_turns=2) - state._current_agent = first_beta - json_data = state.to_json() - - restored_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) - restored_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) - restored_root = Agent(name="triage", handoffs=[restored_alpha, restored_beta]) - restored_alpha.handoffs = [restored_root] - restored_beta.handoffs = [restored_root] - - restored = await RunState.from_json(restored_root, json_data) - assert restored._current_agent is restored_beta - - @pytest.mark.asyncio - async def test_from_json_restores_bare_duplicate_name_current_agent_via_identity_map(self): - """Bare duplicate names should resolve through the identity map, not traversal order.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - first = Agent(name="duplicate", instructions="zeta") - second = Agent(name="duplicate", instructions="alpha") - root = Agent(name="triage", handoffs=[first, second]) - first.handoffs = [root] - second.handoffs = [root] - - state = make_state(root, context=context, original_input="input1", max_turns=2) - state._current_agent = second - - json_data = state.to_json() - assert json_data["current_agent"] == {"name": "duplicate"} - - restored = await RunState.from_json(root, json_data) - assert restored._current_agent is second - - @pytest.mark.asyncio - async def test_from_json_restores_falsy_current_agent_via_identity_map(self): - class FalsyAgent(Agent[Any]): - def __bool__(self) -> bool: - return False - - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - first = Agent(name="duplicate", instructions="zeta") - second = FalsyAgent(name="duplicate", instructions="alpha") - root = Agent(name="triage", handoffs=[first, second]) - first.handoffs = [root] - second.handoffs = [root] - - state = make_state(root, context=context, original_input="input1", max_turns=2) - state._current_agent = second - - json_data = state.to_json() - assert json_data["current_agent"] == { - "name": "duplicate", - "identity": "duplicate#2", - } - - restored = await RunState.from_json(root, json_data) - assert restored._current_agent is second - - def test_build_agent_identity_map_uses_tool_use_behavior_for_duplicate_names(self) -> None: - """Duplicate-name identities should stay stable when only tool_use_behavior differs.""" - - def _identity_for( - identity_map: Mapping[str, Agent[Any]], - target: Agent[Any], - ) -> str: - return next(identity for identity, agent in identity_map.items() if agent is target) - - first_default = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="run_llm_again", - ) - first_stop = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="stop_on_first_tool", - ) - first_root = Agent(name="triage", handoffs=[first_default, first_stop]) - first_default.handoffs = [first_root] - first_stop.handoffs = [first_root] - - second_default = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="run_llm_again", - ) - second_stop = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="stop_on_first_tool", - ) - second_root = Agent(name="triage", handoffs=[second_stop, second_default]) - second_default.handoffs = [second_root] - second_stop.handoffs = [second_root] - - first_identity_map = _build_agent_identity_map(first_root) - second_identity_map = _build_agent_identity_map(second_root) - - assert _identity_for(first_identity_map, first_default) == _identity_for( - second_identity_map, second_default - ) - assert _identity_for(first_identity_map, first_stop) == _identity_for( - second_identity_map, second_stop - ) - - def test_capability_identity_uses_config_but_not_bound_session(self) -> None: - """Capability identity should consider config and ignore bound sessions.""" - - first_alpha_capability = _IdentityCapability(setting="alpha") - first_beta_capability = _IdentityCapability(setting="beta") - first_alpha_capability.bind( - scripted_sandbox_session(manifest=Manifest(root="/workspace/first-alpha")) - ) - first_beta_capability.bind( - scripted_sandbox_session(manifest=Manifest(root="/workspace/first-beta")) - ) - - second_alpha_capability = _IdentityCapability(setting="alpha") - second_beta_capability = _IdentityCapability(setting="beta") - second_alpha_capability.bind( - scripted_sandbox_session(manifest=Manifest(root="/workspace/second-alpha")) - ) - second_beta_capability.bind( - scripted_sandbox_session(manifest=Manifest(root="/workspace/second-beta")) - ) - - first_alpha_signature = _capability_identity_signature(first_alpha_capability) - first_beta_signature = _capability_identity_signature(first_beta_capability) - second_alpha_signature = _capability_identity_signature(second_alpha_capability) - second_beta_signature = _capability_identity_signature(second_beta_capability) - - assert first_alpha_signature == second_alpha_signature - assert first_beta_signature == second_beta_signature - assert first_alpha_signature != first_beta_signature - - @pytest.mark.asyncio - async def test_from_json_restores_duplicate_name_current_agent_when_tool_use_behavior_differs( - self, - ) -> None: - """Duplicate-name restore should stay stable when tool_use_behavior is the only delta.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - first_default = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="run_llm_again", - ) - first_stop = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="stop_on_first_tool", - ) - first_root = Agent(name="triage", handoffs=[first_default, first_stop]) - first_default.handoffs = [first_root] - first_stop.handoffs = [first_root] - - state = make_state(first_root, context=context, original_input="input1", max_turns=2) - state._current_agent = first_stop - json_data = state.to_json() - - restored_default = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="run_llm_again", - ) - restored_stop = Agent( - name="sandbox", - instructions="Shared instructions.", - tool_use_behavior="stop_on_first_tool", - ) - restored_root = Agent(name="triage", handoffs=[restored_stop, restored_default]) - restored_default.handoffs = [restored_root] - restored_stop.handoffs = [restored_root] - - restored = await RunState.from_json(restored_root, json_data) - assert restored._current_agent is restored_stop - - @pytest.mark.asyncio - async def test_from_json_rejects_missing_saved_duplicate_identity(self): - """Identity-aware snapshots should fail when the saved duplicate no longer exists.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - second = Agent(name="duplicate", instructions="Second") - first = Agent(name="duplicate", instructions="First", handoffs=[second]) - second.handoffs = [first] - state = make_state(first, context=context, original_input="input1", max_turns=2) - state._current_agent = second - - json_data = state.to_json() - restored_root = Agent(name="duplicate", instructions="First") - - with pytest.raises(UserError, match="agent identity"): - await RunState.from_json(restored_root, json_data) - - @pytest.mark.asyncio - async def test_result_to_state_preserves_duplicate_name_root_and_owned_state(self): - """RunResult.to_state should keep the root graph while preserving the active duplicate.""" - - @function_tool(name_override="approval_tool", needs_approval=True) - def approval_tool() -> str: - return "approved" - - first_model = ScriptedModel() - second_model = ScriptedModel() - first = Agent(name="duplicate", model=first_model) - second = Agent( - name="duplicate", - model=second_model, - tools=[approval_tool], - model_settings=ModelSettings(tool_choice="required"), - ) - first.handoffs = [second] - second.handoffs = [first] - - first_model.extend([[get_handoff_tool_call(second)]]) - second_model.extend( - [[get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")]] - ) - - result = await Runner.run(first, "start") - assert result.interruptions - - state = result.to_state() - assert state._starting_agent is first - assert state._current_agent is second - - json_data = state.to_json() - assert json_data["current_agent"] == {"name": "duplicate", "identity": "duplicate#2"} - assert json_data["tool_use_tracker"]["duplicate#2"] == ["approval_tool"] - assert json_data["current_step"] is not None - assert json_data["current_step"]["data"]["interruptions"][0]["agent"] == { - "name": "duplicate", - "identity": "duplicate#2", - } - - approval_tool_items = [ - item - for item in json_data["generated_items"] - if item["type"] == "tool_call_item" - and item["raw_item"].get("call_id") == "call_approval" - ] - assert len(approval_tool_items) == 1 - assert approval_tool_items[0]["agent"] == { - "name": "duplicate", - "identity": "duplicate#2", - } - assert approval_tool_items[0]["raw_item"] == { - "arguments": "{}", - "call_id": "call_approval", - "id": "1", - "name": "approval_tool", - "type": "function_call", - } - - restored = await RunState.from_json(first, json_data) - assert restored._starting_agent is first - assert restored._current_agent is second - assert restored.get_interruptions()[0].agent is second - assert any( - isinstance(item, ToolCallItem) - and item.agent is second - and getattr(item.raw_item, "call_id", None) == "call_approval" - for item in restored._generated_items - ) - async def test_reasoning_item_id_policy_survives_serialization(self): """RunState should preserve reasoning item input policy across serialization.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) @@ -4137,108 +3771,6 @@ async def test_context_override_discards_unbound_ids_from_previous_restore(self) ) -class TestBuildAgentMap: - """Test agent map building for handoff resolution.""" - - def test_build_agent_map_collects_agents_without_looping(self): - """Test that buildAgentMap handles circular handoff references.""" - agent_a = Agent(name="AgentA") - agent_b = Agent(name="AgentB") - - # Create a cycle A -> B -> A - agent_a.handoffs = [agent_b] - agent_b.handoffs = [agent_a] - - agent_map = _build_agent_map(agent_a) - - assert agent_map.get("AgentA") is not None - assert agent_map.get("AgentB") is not None - assert agent_map.get("AgentA").name == agent_a.name # type: ignore[union-attr] - assert agent_map.get("AgentB").name == agent_b.name # type: ignore[union-attr] - assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] - - def test_build_agent_map_handles_complex_handoff_graphs(self): - """Test that buildAgentMap handles complex handoff graphs.""" - agent_a = Agent(name="A") - agent_b = Agent(name="B") - agent_c = Agent(name="C") - agent_d = Agent(name="D") - - # Create graph: A -> B, C; B -> D; C -> D - agent_a.handoffs = [agent_b, agent_c] - agent_b.handoffs = [agent_d] - agent_c.handoffs = [agent_d] - - agent_map = _build_agent_map(agent_a) - - assert len(agent_map) == 4 - assert all(agent_map.get(name) is not None for name in ["A", "B", "C", "D"]) - - def test_build_agent_map_handles_handoff_objects(self): - """Test that buildAgentMap resolves handoff() objects via weak references.""" - agent_a = Agent(name="AgentA") - agent_b = Agent(name="AgentB") - agent_a.handoffs = [handoff(agent_b)] - - agent_map = _build_agent_map(agent_a) - - assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] - - def test_build_agent_map_supports_legacy_handoff_agent_attribute(self): - """Test that buildAgentMap keeps legacy custom handoffs with `.agent` targets working.""" - agent_a = Agent(name="AgentA") - agent_b = Agent(name="AgentB") - - class LegacyHandoff(Handoff): - def __init__(self, target: Agent[Any]): - # Legacy custom handoff shape supported only for backward compatibility. - self.agent = target - self.agent_name = target.name - self.name = "legacy_handoff" - - agent_a.handoffs = [LegacyHandoff(agent_b)] - - agent_map = _build_agent_map(agent_a) - - assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] - - def test_build_agent_map_supports_legacy_non_handoff_agent_wrapper(self): - """Test that buildAgentMap supports legacy non-Handoff wrappers with `.agent` targets.""" - agent_a = Agent(name="AgentA") - agent_b = Agent(name="AgentB") - - class LegacyWrapper: - def __init__(self, target: Agent[Any]): - self.agent = target - - agent_a.handoffs = [LegacyWrapper(agent_b)] # type: ignore[list-item] - - agent_map = _build_agent_map(agent_a) - - assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] - - def test_build_agent_map_skips_unresolved_handoff_objects(self): - """Test that buildAgentMap skips custom handoffs without target agent references.""" - agent_a = Agent(name="AgentA") - agent_b = Agent(name="AgentB") - - async def _invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: - return agent_b - - detached_handoff = Handoff( - tool_name="transfer_to_agent_b", - tool_description="Transfer to AgentB.", - input_json_schema={}, - on_invoke_handoff=_invoke_handoff, - agent_name=agent_b.name, - ) - agent_a.handoffs = [detached_handoff] - - agent_map = _build_agent_map(agent_a) - - assert sorted(agent_map.keys()) == ["AgentA"] - - class TestSerializationRoundTrip: """Test that serialization and deserialization preserve state correctly.""" @@ -5186,64 +4718,6 @@ async def test_serialization_includes_handoff_fields(self): assert len(restored._generated_items) == 1 assert restored._generated_items[0].type == "handoff_output_item" - @pytest.mark.asyncio - async def test_serialization_uses_duplicate_identities_for_handoff_and_output_guardrails(self): - """Duplicate-name item ownership should round-trip with identity keys.""" - first = Agent(name="duplicate") - second = Agent(name="duplicate") - third = Agent(name="duplicate") - first.handoffs = [second, third] - second.handoffs = [third] - third.handoffs = [first] - - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - state = make_state(first, context=context, original_input="test handoff", max_turns=2) - state._current_agent = second - state._generated_items = [ - HandoffOutputItem( - agent=second, - raw_item={"type": "handoff_output", "status": "completed"}, # type: ignore[arg-type] - source_agent=second, - target_agent=third, - ) - ] - - output_guardrail = OutputGuardrail( - guardrail_function=lambda _ctx, _agent, _output: GuardrailFunctionOutput( - output_info={"guardrail": "ok"}, - tripwire_triggered=False, - ), - name="duplicate_output_guardrail", - ) - state._output_guardrail_results = [ - OutputGuardrailResult( - guardrail=output_guardrail, - agent_output="done", - agent=third, - output=GuardrailFunctionOutput( - output_info={"guardrail": "ok"}, - tripwire_triggered=False, - ), - ) - ] - - json_data = state.to_json() - item_data = json_data["generated_items"][0] - assert item_data["agent"] == {"name": "duplicate", "identity": "duplicate#2"} - assert item_data["source_agent"] == {"name": "duplicate", "identity": "duplicate#2"} - assert item_data["target_agent"] == {"name": "duplicate", "identity": "duplicate#3"} - assert json_data["output_guardrail_results"][0]["agent"] == { - "name": "duplicate", - "identity": "duplicate#3", - } - - restored = await RunState.from_json(first, json_data) - restored_item = cast(HandoffOutputItem, restored._generated_items[0]) - assert restored_item.agent is second - assert restored_item.source_agent is second - assert restored_item.target_agent is third - assert restored._output_guardrail_results[0].agent is third - async def test_model_response_serialization_roundtrip(self): """Test that model responses serialize and deserialize correctly.""" diff --git a/tests/test_run_state_agent_identity.py b/tests/test_run_state_agent_identity.py new file mode 100644 index 0000000000..61dfdaba7f --- /dev/null +++ b/tests/test_run_state_agent_identity.py @@ -0,0 +1,557 @@ +"""Agent graph identities and durable RunState ownership across save and restore.""" + +import json +from collections.abc import Mapping +from typing import Any, cast + +import pytest + +from agents import ( + Agent, + Handoff, + ModelSettings, + RunContextWrapper, + Runner, + RunState, + UserError, + handoff, +) +from agents._run_state_agent_identity import ( + _build_agent_identity_map, + _build_agent_map, + _capability_identity_signature, +) +from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail, OutputGuardrailResult +from agents.items import HandoffOutputItem, ToolCallItem +from agents.sandbox import Manifest +from agents.sandbox.capabilities.capability import Capability +from agents.testing import ScriptedModel, scripted_sandbox_session +from agents.tool import function_tool + +from .test_responses import get_function_tool_call, get_handoff_tool_call +from .utils.factories import make_run_state as make_state + + +class _IdentityCapability(Capability): + type: str = "identity" + setting: str + + def __init__(self, *, setting: str) -> None: + super().__init__(type="identity", **cast(Any, {"setting": setting})) + + +class TestRunState: + @pytest.mark.asyncio + async def test_from_json_restores_duplicate_name_current_agent_by_identity(self): + """Duplicate agent names should round-trip through the serialized identity key.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + second = Agent(name="duplicate") + first = Agent(name="duplicate", handoffs=[second]) + second.handoffs = [first] + state = make_state(first, context=context, original_input="input1", max_turns=2) + state._current_agent = second + + json_data = state.to_json() + assert json_data["current_agent"] == {"name": "duplicate", "identity": "duplicate#2"} + + restored = await RunState.from_json(first, json_data) + assert restored._current_agent is second + + def test_build_agent_identity_map_avoids_literal_suffix_collisions(self) -> None: + """Literal `#` names should not collide with generated duplicate identities.""" + first = Agent(name="sandbox") + literal_suffix = Agent(name="sandbox#2") + second = Agent(name="sandbox") + first.handoffs = [literal_suffix, second] + literal_suffix.handoffs = [first, second] + second.handoffs = [first, literal_suffix] + + identity_map = _build_agent_identity_map(first) + + assert identity_map == { + "sandbox": first, + "sandbox#2": literal_suffix, + "sandbox#3": second, + } + + def test_build_agent_identity_map_is_stable_across_reordered_duplicate_agents(self) -> None: + """Duplicate-name identities should not change when reachable order changes.""" + + @function_tool(name_override="alpha_tool") + def alpha_tool() -> str: + return "alpha" + + @function_tool(name_override="beta_tool") + def beta_tool() -> str: + return "beta" + + def _identity_for( + identity_map: Mapping[str, Agent[Any]], + target: Agent[Any], + ) -> str: + return next(identity for identity, agent in identity_map.items() if agent is target) + + first_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) + first_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) + first_root = Agent(name="triage", handoffs=[first_beta, first_alpha]) + first_alpha.handoffs = [first_root] + first_beta.handoffs = [first_root] + + second_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) + second_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) + second_root = Agent(name="triage", handoffs=[second_alpha, second_beta]) + second_alpha.handoffs = [second_root] + second_beta.handoffs = [second_root] + + first_identity_map = _build_agent_identity_map(first_root) + second_identity_map = _build_agent_identity_map(second_root) + + assert _identity_for(first_identity_map, first_alpha) == _identity_for( + second_identity_map, second_alpha + ) + assert _identity_for(first_identity_map, first_beta) == _identity_for( + second_identity_map, second_beta + ) + + @pytest.mark.asyncio + async def test_from_json_restores_duplicate_name_current_agent_with_reordered_graph(self): + """Restore should keep the same logical duplicate agent after graph reordering.""" + + @function_tool(name_override="alpha_tool") + def alpha_tool() -> str: + return "alpha" + + @function_tool(name_override="beta_tool") + def beta_tool() -> str: + return "beta" + + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + first_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) + first_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) + first_root = Agent(name="triage", handoffs=[first_beta, first_alpha]) + first_alpha.handoffs = [first_root] + first_beta.handoffs = [first_root] + + state = make_state(first_root, context=context, original_input="input1", max_turns=2) + state._current_agent = first_beta + json_data = state.to_json() + + restored_alpha = Agent(name="sandbox", instructions="Alpha", tools=[alpha_tool]) + restored_beta = Agent(name="sandbox", instructions="Beta", tools=[beta_tool]) + restored_root = Agent(name="triage", handoffs=[restored_alpha, restored_beta]) + restored_alpha.handoffs = [restored_root] + restored_beta.handoffs = [restored_root] + + restored = await RunState.from_json(restored_root, json_data) + assert restored._current_agent is restored_beta + + @pytest.mark.asyncio + async def test_from_json_restores_bare_duplicate_name_current_agent_via_identity_map(self): + """Bare duplicate names should resolve through the identity map, not traversal order.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + first = Agent(name="duplicate", instructions="zeta") + second = Agent(name="duplicate", instructions="alpha") + root = Agent(name="triage", handoffs=[first, second]) + first.handoffs = [root] + second.handoffs = [root] + + state = make_state(root, context=context, original_input="input1", max_turns=2) + state._current_agent = second + + json_data = state.to_json() + assert json_data["current_agent"] == {"name": "duplicate"} + + restored = await RunState.from_json(root, json_data) + assert restored._current_agent is second + + @pytest.mark.asyncio + async def test_from_json_restores_falsy_current_agent_via_identity_map(self): + class FalsyAgent(Agent[Any]): + def __bool__(self) -> bool: + return False + + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + first = Agent(name="duplicate", instructions="zeta") + second = FalsyAgent(name="duplicate", instructions="alpha") + root = Agent(name="triage", handoffs=[first, second]) + first.handoffs = [root] + second.handoffs = [root] + + state = make_state(root, context=context, original_input="input1", max_turns=2) + state._current_agent = second + + json_data = state.to_json() + assert json_data["current_agent"] == { + "name": "duplicate", + "identity": "duplicate#2", + } + + restored = await RunState.from_json(root, json_data) + assert restored._current_agent is second + + def test_build_agent_identity_map_uses_tool_use_behavior_for_duplicate_names(self) -> None: + """Duplicate-name identities should stay stable when only tool_use_behavior differs.""" + + def _identity_for( + identity_map: Mapping[str, Agent[Any]], + target: Agent[Any], + ) -> str: + return next(identity for identity, agent in identity_map.items() if agent is target) + + first_default = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="run_llm_again", + ) + first_stop = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="stop_on_first_tool", + ) + first_root = Agent(name="triage", handoffs=[first_default, first_stop]) + first_default.handoffs = [first_root] + first_stop.handoffs = [first_root] + + second_default = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="run_llm_again", + ) + second_stop = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="stop_on_first_tool", + ) + second_root = Agent(name="triage", handoffs=[second_stop, second_default]) + second_default.handoffs = [second_root] + second_stop.handoffs = [second_root] + + first_identity_map = _build_agent_identity_map(first_root) + second_identity_map = _build_agent_identity_map(second_root) + + assert _identity_for(first_identity_map, first_default) == _identity_for( + second_identity_map, second_default + ) + assert _identity_for(first_identity_map, first_stop) == _identity_for( + second_identity_map, second_stop + ) + + def test_capability_identity_uses_config_but_not_bound_session(self) -> None: + """Capability identity should consider config and ignore bound sessions.""" + + first_alpha_capability = _IdentityCapability(setting="alpha") + first_beta_capability = _IdentityCapability(setting="beta") + first_alpha_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/first-alpha")) + ) + first_beta_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/first-beta")) + ) + + second_alpha_capability = _IdentityCapability(setting="alpha") + second_beta_capability = _IdentityCapability(setting="beta") + second_alpha_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/second-alpha")) + ) + second_beta_capability.bind( + scripted_sandbox_session(manifest=Manifest(root="/workspace/second-beta")) + ) + + first_alpha_signature = _capability_identity_signature(first_alpha_capability) + first_beta_signature = _capability_identity_signature(first_beta_capability) + second_alpha_signature = _capability_identity_signature(second_alpha_capability) + second_beta_signature = _capability_identity_signature(second_beta_capability) + + assert first_alpha_signature == second_alpha_signature + assert first_beta_signature == second_beta_signature + assert first_alpha_signature != first_beta_signature + + @pytest.mark.asyncio + async def test_from_json_restores_duplicate_name_current_agent_when_tool_use_behavior_differs( + self, + ) -> None: + """Duplicate-name restore should stay stable when tool_use_behavior is the only delta.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + first_default = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="run_llm_again", + ) + first_stop = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="stop_on_first_tool", + ) + first_root = Agent(name="triage", handoffs=[first_default, first_stop]) + first_default.handoffs = [first_root] + first_stop.handoffs = [first_root] + + state = make_state(first_root, context=context, original_input="input1", max_turns=2) + state._current_agent = first_stop + json_data = state.to_json() + + restored_default = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="run_llm_again", + ) + restored_stop = Agent( + name="sandbox", + instructions="Shared instructions.", + tool_use_behavior="stop_on_first_tool", + ) + restored_root = Agent(name="triage", handoffs=[restored_stop, restored_default]) + restored_default.handoffs = [restored_root] + restored_stop.handoffs = [restored_root] + + restored = await RunState.from_json(restored_root, json_data) + assert restored._current_agent is restored_stop + + @pytest.mark.asyncio + async def test_from_json_rejects_missing_saved_duplicate_identity(self): + """Identity-aware snapshots should fail when the saved duplicate no longer exists.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + second = Agent(name="duplicate", instructions="Second") + first = Agent(name="duplicate", instructions="First", handoffs=[second]) + second.handoffs = [first] + state = make_state(first, context=context, original_input="input1", max_turns=2) + state._current_agent = second + + json_data = state.to_json() + restored_root = Agent(name="duplicate", instructions="First") + + with pytest.raises(UserError, match="agent identity"): + await RunState.from_json(restored_root, json_data) + + @pytest.mark.asyncio + async def test_result_to_state_preserves_duplicate_name_root_and_owned_state(self): + """RunResult.to_state should keep the root graph while preserving the active duplicate.""" + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + return "approved" + + first_model = ScriptedModel() + second_model = ScriptedModel() + first = Agent(name="duplicate", model=first_model) + second = Agent( + name="duplicate", + model=second_model, + tools=[approval_tool], + model_settings=ModelSettings(tool_choice="required"), + ) + first.handoffs = [second] + second.handoffs = [first] + + first_model.extend([[get_handoff_tool_call(second)]]) + second_model.extend( + [[get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")]] + ) + + result = await Runner.run(first, "start") + assert result.interruptions + + state = result.to_state() + assert state._starting_agent is first + assert state._current_agent is second + + json_data = state.to_json() + assert json_data["current_agent"] == {"name": "duplicate", "identity": "duplicate#2"} + assert json_data["tool_use_tracker"]["duplicate#2"] == ["approval_tool"] + assert json_data["current_step"] is not None + assert json_data["current_step"]["data"]["interruptions"][0]["agent"] == { + "name": "duplicate", + "identity": "duplicate#2", + } + + approval_tool_items = [ + item + for item in json_data["generated_items"] + if item["type"] == "tool_call_item" + and item["raw_item"].get("call_id") == "call_approval" + ] + assert len(approval_tool_items) == 1 + assert approval_tool_items[0]["agent"] == { + "name": "duplicate", + "identity": "duplicate#2", + } + assert approval_tool_items[0]["raw_item"] == { + "arguments": "{}", + "call_id": "call_approval", + "id": "1", + "name": "approval_tool", + "type": "function_call", + } + + restored = await RunState.from_json(first, json_data) + assert restored._starting_agent is first + assert restored._current_agent is second + assert restored.get_interruptions()[0].agent is second + assert any( + isinstance(item, ToolCallItem) + and item.agent is second + and getattr(item.raw_item, "call_id", None) == "call_approval" + for item in restored._generated_items + ) + + +class TestBuildAgentMap: + """Test agent map building for handoff resolution.""" + + def test_build_agent_map_collects_agents_without_looping(self): + """Test that buildAgentMap handles circular handoff references.""" + agent_a = Agent(name="AgentA") + agent_b = Agent(name="AgentB") + + # Create a cycle A -> B -> A. + agent_a.handoffs = [agent_b] + agent_b.handoffs = [agent_a] + + agent_map = _build_agent_map(agent_a) + + assert agent_map.get("AgentA") is not None + assert agent_map.get("AgentB") is not None + assert agent_map.get("AgentA").name == agent_a.name # type: ignore[union-attr] + assert agent_map.get("AgentB").name == agent_b.name # type: ignore[union-attr] + assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] + + def test_build_agent_map_handles_complex_handoff_graphs(self): + """Test that buildAgentMap handles complex handoff graphs.""" + agent_a = Agent(name="A") + agent_b = Agent(name="B") + agent_c = Agent(name="C") + agent_d = Agent(name="D") + + # Create a graph: A -> B, C; B -> D; C -> D. + agent_a.handoffs = [agent_b, agent_c] + agent_b.handoffs = [agent_d] + agent_c.handoffs = [agent_d] + + agent_map = _build_agent_map(agent_a) + + assert len(agent_map) == 4 + assert all(agent_map.get(name) is not None for name in ["A", "B", "C", "D"]) + + def test_build_agent_map_handles_handoff_objects(self): + """Test that buildAgentMap resolves handoff() objects via weak references.""" + agent_a = Agent(name="AgentA") + agent_b = Agent(name="AgentB") + agent_a.handoffs = [handoff(agent_b)] + + agent_map = _build_agent_map(agent_a) + + assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] + + def test_build_agent_map_supports_legacy_handoff_agent_attribute(self): + """Test that buildAgentMap keeps legacy custom handoffs with `.agent` targets working.""" + agent_a = Agent(name="AgentA") + agent_b = Agent(name="AgentB") + + class LegacyHandoff(Handoff): + def __init__(self, target: Agent[Any]): + # Legacy custom handoff shape supported only for backward compatibility. + self.agent = target + self.agent_name = target.name + self.name = "legacy_handoff" + + agent_a.handoffs = [LegacyHandoff(agent_b)] + + agent_map = _build_agent_map(agent_a) + + assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] + + def test_build_agent_map_supports_legacy_non_handoff_agent_wrapper(self): + """Test that buildAgentMap supports legacy non-Handoff wrappers with `.agent` targets.""" + agent_a = Agent(name="AgentA") + agent_b = Agent(name="AgentB") + + class LegacyWrapper: + def __init__(self, target: Agent[Any]): + self.agent = target + + agent_a.handoffs = [LegacyWrapper(agent_b)] # type: ignore[list-item] + + agent_map = _build_agent_map(agent_a) + + assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] + + def test_build_agent_map_skips_unresolved_handoff_objects(self): + """Test that buildAgentMap skips custom handoffs without target agent references.""" + agent_a = Agent(name="AgentA") + agent_b = Agent(name="AgentB") + + async def _invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: + return agent_b + + detached_handoff = Handoff( + tool_name="transfer_to_agent_b", + tool_description="Transfer to AgentB.", + input_json_schema={}, + on_invoke_handoff=_invoke_handoff, + agent_name=agent_b.name, + ) + agent_a.handoffs = [detached_handoff] + + agent_map = _build_agent_map(agent_a) + + assert sorted(agent_map.keys()) == ["AgentA"] + + +class TestDeserializeHelpers: + @pytest.mark.asyncio + async def test_serialization_uses_duplicate_identities_for_handoff_and_output_guardrails(self): + """Duplicate-name item ownership should round-trip with identity keys.""" + first = Agent(name="duplicate") + second = Agent(name="duplicate") + third = Agent(name="duplicate") + first.handoffs = [second, third] + second.handoffs = [third] + third.handoffs = [first] + + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(first, context=context, original_input="test handoff", max_turns=2) + state._current_agent = second + state._generated_items = [ + HandoffOutputItem( + agent=second, + raw_item={"type": "handoff_output", "status": "completed"}, # type: ignore[arg-type] + source_agent=second, + target_agent=third, + ) + ] + + output_guardrail = OutputGuardrail( + guardrail_function=lambda _ctx, _agent, _output: GuardrailFunctionOutput( + output_info={"guardrail": "ok"}, + tripwire_triggered=False, + ), + name="duplicate_output_guardrail", + ) + state._output_guardrail_results = [ + OutputGuardrailResult( + guardrail=output_guardrail, + agent_output="done", + agent=third, + output=GuardrailFunctionOutput( + output_info={"guardrail": "ok"}, + tripwire_triggered=False, + ), + ) + ] + + json_data = state.to_json() + item_data = json_data["generated_items"][0] + assert item_data["agent"] == {"name": "duplicate", "identity": "duplicate#2"} + assert item_data["source_agent"] == {"name": "duplicate", "identity": "duplicate#2"} + assert item_data["target_agent"] == {"name": "duplicate", "identity": "duplicate#3"} + assert json_data["output_guardrail_results"][0]["agent"] == { + "name": "duplicate", + "identity": "duplicate#3", + } + + restored = await RunState.from_json(first, json_data) + restored_item = cast(HandoffOutputItem, restored._generated_items[0]) + assert restored_item.agent is second + assert restored_item.source_agent is second + assert restored_item.target_agent is third + assert restored._output_guardrail_results[0].agent is third From f22fb0cf29cf15de418a869ca63776922db58565 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 18:12:09 +0900 Subject: [PATCH 447/473] refactor: share provider generation-span population (#4877) --- src/agents/extensions/models/any_llm_model.py | 27 +-------- src/agents/extensions/models/litellm_model.py | 29 +-------- src/agents/models/_trace.py | 42 ++++++++++++- src/agents/models/openai_chatcompletions.py | 29 +-------- tests/models/test_any_llm_model.py | 59 ++++++++++++++++++- .../test_litellm_chatcompletions_stream.py | 59 ++++++++++++++++++- .../test_openai_chatcompletions_stream.py | 49 ++++++++++++--- 7 files changed, 204 insertions(+), 90 deletions(-) diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 8a12fdbd9c..a3f0267b22 100644 --- a/src/agents/extensions/models/any_llm_model.py +++ b/src/agents/extensions/models/any_llm_model.py @@ -36,7 +36,7 @@ response_terminal_failure_error, ) from ...models._retry_runtime import should_disable_provider_managed_retries -from ...models._trace import model_config_for_trace +from ...models._trace import model_config_for_trace, populate_generation_span from ...models.chatcmpl_converter import Converter from ...models.chatcmpl_helpers import HEADERS, HEADERS_OVERRIDE, ChatCmplHelpers from ...models.chatcmpl_stream_handler import ChatCmplStreamHandler @@ -798,30 +798,7 @@ def _populate_chat_generation_span( final_response: Response, tracing: ModelTracing, ) -> None: - if tracing.include_data(): - span_generation.span_data.output = [final_response.model_dump()] - - if final_response.usage is not None: - span_generation.span_data.usage = { - "requests": 1, - "input_tokens": final_response.usage.input_tokens, - "output_tokens": final_response.usage.output_tokens, - "total_tokens": final_response.usage.total_tokens, - "input_tokens_details": ( - final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details is not None - else {"cached_tokens": 0, "cache_write_tokens": 0} - ), - "output_tokens_details": ( - final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details is not None - else {"reasoning_tokens": 0} - ), - } - elif _requests_for_response_without_usage(final_response): - # Keep streamed tracing aligned with the non-streaming path, which records the - # request even when the provider reports no usage. - span_generation.span_data.usage = model_usage_to_span_usage(Usage(requests=1)) + populate_generation_span(span_generation, final_response, tracing) @overload async def _fetch_chat_response( diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index e64a896797..83fb459b53 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -45,7 +45,7 @@ from ...model_settings import ModelSettings from ...models._openai_retry import get_openai_retry_advice from ...models._retry_runtime import should_disable_provider_managed_retries -from ...models._trace import model_config_for_trace +from ...models._trace import model_config_for_trace, populate_generation_span from ...models.chatcmpl_converter import Converter from ...models.chatcmpl_helpers import HEADERS, HEADERS_OVERRIDE, ChatCmplHelpers from ...models.chatcmpl_stream_handler import ChatCmplStreamHandler @@ -62,8 +62,6 @@ Usage, _cache_write_tokens, _make_input_tokens_details, - _requests_for_response_without_usage, - model_usage_to_span_usage, ) from ...util._error_tracing import model_span_errors from ...util._json import _to_dump_compatible @@ -465,30 +463,7 @@ def _populate_stream_generation_span( final_response: Response, tracing: ModelTracing, ) -> None: - if tracing.include_data(): - span_generation.span_data.output = [final_response.model_dump()] - - if final_response.usage is not None: - span_generation.span_data.usage = { - "requests": 1, - "input_tokens": final_response.usage.input_tokens, - "output_tokens": final_response.usage.output_tokens, - "total_tokens": final_response.usage.total_tokens, - "input_tokens_details": ( - final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details is not None - else {"cached_tokens": 0, "cache_write_tokens": 0} - ), - "output_tokens_details": ( - final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details is not None - else {"reasoning_tokens": 0} - ), - } - elif _requests_for_response_without_usage(final_response): - # Keep streamed tracing aligned with the non-streaming path, which records the - # request even when the provider reports no usage. - span_generation.span_data.usage = model_usage_to_span_usage(Usage(requests=1)) + populate_generation_span(span_generation, final_response, tracing) @overload async def _fetch_response( diff --git a/src/agents/models/_trace.py b/src/agents/models/_trace.py index 30026ebb50..4e2092408d 100644 --- a/src/agents/models/_trace.py +++ b/src/agents/models/_trace.py @@ -1,9 +1,17 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlsplit, urlunsplit from ..model_settings import ModelSettings +from ..usage import Usage, _requests_for_response_without_usage, model_usage_to_span_usage + +if TYPE_CHECKING: + from openai.types.responses import Response + + from ..tracing.span_data import GenerationSpanData + from ..tracing.spans import Span + from .interface import ModelTracing def sanitize_url_for_trace(url: object) -> str: @@ -29,3 +37,35 @@ def model_config_for_trace( if extra_config: config.update(extra_config) return config + + +def populate_generation_span( + span_generation: Span[GenerationSpanData], + final_response: Response, + tracing: ModelTracing, +) -> None: + """Populate generation trace data from a Chat Completions adapter response.""" + if tracing.include_data(): + span_generation.span_data.output = [final_response.model_dump()] + + if final_response.usage is not None: + span_generation.span_data.usage = { + "requests": 1, + "input_tokens": final_response.usage.input_tokens, + "output_tokens": final_response.usage.output_tokens, + "total_tokens": final_response.usage.total_tokens, + "input_tokens_details": ( + final_response.usage.input_tokens_details.model_dump() + if final_response.usage.input_tokens_details is not None + else {"cached_tokens": 0, "cache_write_tokens": 0} + ), + "output_tokens_details": ( + final_response.usage.output_tokens_details.model_dump() + if final_response.usage.output_tokens_details is not None + else {"reasoning_tokens": 0} + ), + } + elif _requests_for_response_without_usage(final_response): + # Keep streamed tracing aligned with the non-streaming path, which records the + # request even when the provider reports no usage. + span_generation.span_data.usage = model_usage_to_span_usage(Usage(requests=1)) diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 83d2618262..43dd2feba5 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -34,14 +34,12 @@ from ..usage import ( Usage, _raw_usage_snapshot, - _requests_for_response_without_usage, - model_usage_to_span_usage, ) from ..util._error_tracing import model_span_errors from ..util._json import _to_dump_compatible from ._openai_retry import get_openai_retry_advice from ._retry_runtime import should_disable_provider_managed_retries -from ._trace import model_config_for_trace +from ._trace import model_config_for_trace, populate_generation_span from .chatcmpl_converter import Converter from .chatcmpl_helpers import HEADERS, HEADERS_OVERRIDE, ChatCmplHelpers from .chatcmpl_stream_handler import ChatCmplStreamHandler @@ -535,30 +533,7 @@ def _populate_stream_generation_span( final_response: Response, tracing: ModelTracing, ) -> None: - if tracing.include_data(): - span_generation.span_data.output = [final_response.model_dump()] - - if final_response.usage is not None: - span_generation.span_data.usage = { - "requests": 1, - "input_tokens": final_response.usage.input_tokens, - "output_tokens": final_response.usage.output_tokens, - "total_tokens": final_response.usage.total_tokens, - "input_tokens_details": ( - final_response.usage.input_tokens_details.model_dump() - if final_response.usage.input_tokens_details is not None - else {"cached_tokens": 0, "cache_write_tokens": 0} - ), - "output_tokens_details": ( - final_response.usage.output_tokens_details.model_dump() - if final_response.usage.output_tokens_details is not None - else {"reasoning_tokens": 0} - ), - } - elif _requests_for_response_without_usage(final_response): - # Keep streamed tracing aligned with the non-streaming path, which records the - # request even when the provider reports no usage. - span_generation.span_data.usage = model_usage_to_span_usage(Usage(requests=1)) + populate_generation_span(span_generation, final_response, tracing) def _handle_unsupported_server_managed_conversation_state( self, diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 3e02b28ebd..ce4da266d3 100644 --- a/tests/models/test_any_llm_model.py +++ b/tests/models/test_any_llm_model.py @@ -16,7 +16,11 @@ ) from openai.types.chat.chat_completion import Choice from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta -from openai.types.completion_usage import CompletionUsage, PromptTokensDetails +from openai.types.completion_usage import ( + CompletionTokensDetails, + CompletionUsage, + PromptTokensDetails, +) from openai.types.responses import ( Response, ResponseCompletedEvent, @@ -2077,6 +2081,59 @@ async def aclose(self) -> None: self.aclose_completed += 1 +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("with_usage", [False, True], ids=["no-usage", "detailed-usage"]) +@pytest.mark.parametrize("tracing", [ModelTracing.ENABLED, ModelTracing.ENABLED_WITHOUT_DATA]) +async def test_any_llm_chat_stream_preserves_trace_data_at_completed( + monkeypatch, with_usage: bool, tracing: ModelTracing +) -> None: + """Exercise real chat stream conversion while the adapter is suspended at its terminal yield.""" + chunk = _chat_chunk("Hello") + if with_usage: + chunk.usage = CompletionUsage( + completion_tokens=5, + prompt_tokens=7, + total_tokens=12, + prompt_tokens_details=PromptTokensDetails(cached_tokens=2), + completion_tokens_details=CompletionTokensDetails(reasoning_tokens=3), + ) + stream = _ClosableStream([chunk]) + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=stream) + module, _ = _import_any_llm_module(monkeypatch, provider) + model = module.AnyLLMModel(model="openrouter/openai/gpt-5.4-mini") + spans = _capture_spans(monkeypatch, module, "generation_span") + + with trace(workflow_name="any-llm-chat-terminal-span"): + stream_agen = _stream_events(model, tracing) + try: + async for event in stream_agen: + if event.type == "response.completed": + [span] = spans + assert span.span_data.usage == { + "requests": 1, + "input_tokens": 7 if with_usage else 0, + "output_tokens": 5 if with_usage else 0, + "total_tokens": 12 if with_usage else 0, + "input_tokens_details": { + "cached_tokens": 2 if with_usage else 0, + "cache_write_tokens": 0, + }, + "output_tokens_details": {"reasoning_tokens": 3 if with_usage else 0}, + } + assert span.span_data.output == ( + [event.response.model_dump()] if tracing == ModelTracing.ENABLED else None + ) + assert (event.response.usage is not None) == with_usage + break + else: + pytest.fail("The stream did not yield a completed response.") + finally: + await stream_agen.aclose() + + assert stream.aclose_calls == 1 + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_any_llm_chat_stream_populates_span_before_yielding_completed(monkeypatch) -> None: diff --git a/tests/models/test_litellm_chatcompletions_stream.py b/tests/models/test_litellm_chatcompletions_stream.py index ece71165de..7c7f59e2ee 100644 --- a/tests/models/test_litellm_chatcompletions_stream.py +++ b/tests/models/test_litellm_chatcompletions_stream.py @@ -32,6 +32,7 @@ from agents.items import TResponseStreamEvent from agents.model_settings import ModelSettings from agents.models.interface import Model, ModelTracing +from agents.tracing import get_current_span, trace @pytest.mark.allow_call_model_methods @@ -827,7 +828,9 @@ async def patched_fetch_response(self, *args, **kwargs): monkeypatch.setattr(LitellmModel, "_fetch_response", patched_fetch_response) -def _stream_response(model: Model) -> AsyncIterator[TResponseStreamEvent]: +def _stream_response( + model: Model, tracing: ModelTracing = ModelTracing.DISABLED +) -> AsyncIterator[TResponseStreamEvent]: return model.stream_response( system_instructions=None, input="", @@ -835,13 +838,65 @@ def _stream_response(model: Model) -> AsyncIterator[TResponseStreamEvent]: tools=[], output_schema=None, handoffs=[], - tracing=ModelTracing.DISABLED, + tracing=tracing, previous_response_id=None, conversation_id=None, prompt=None, ) +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("with_usage", [False, True], ids=["no-usage", "detailed-usage"]) +@pytest.mark.parametrize("tracing", [ModelTracing.ENABLED, ModelTracing.ENABLED_WITHOUT_DATA]) +async def test_stream_span_is_populated_before_yielding_completed( + monkeypatch, with_usage: bool, tracing: ModelTracing +) -> None: + """Record exact trace data even when a consumer closes at the completed event.""" + chunk = _text_chunk("Hello") + if with_usage: + chunk.usage = CompletionUsage( + completion_tokens=5, + prompt_tokens=7, + total_tokens=12, + prompt_tokens_details=PromptTokensDetails(cached_tokens=2), + completion_tokens_details=CompletionTokensDetails(reasoning_tokens=3), + ) + provider_stream = _ClosableChatStream([chunk]) + _patch_fetch_response(monkeypatch, provider_stream) + model = LitellmProvider().get_model("gpt-4") + + with trace(workflow_name="litellm-terminal-span"): + stream_agen = cast(Any, _stream_response(model, tracing)) + try: + async for event in stream_agen: + if event.type == "response.completed": + generation = get_current_span() + assert generation is not None + assert generation.span_data.usage == { + "requests": 1, + "input_tokens": 7 if with_usage else 0, + "output_tokens": 5 if with_usage else 0, + "total_tokens": 12 if with_usage else 0, + "input_tokens_details": { + "cached_tokens": 2 if with_usage else 0, + "cache_write_tokens": 0, + }, + "output_tokens_details": {"reasoning_tokens": 3 if with_usage else 0}, + } + assert generation.span_data.output == ( + [event.response.model_dump()] if tracing == ModelTracing.ENABLED else None + ) + assert (event.response.usage is not None) == with_usage + break + else: + pytest.fail("The stream did not yield a completed response.") + finally: + await stream_agen.aclose() + + assert provider_stream.aclose_calls == 1 + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_stream_response_closes_provider_stream_on_explicit_aclose(monkeypatch) -> None: diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 5c79493769..7d484981f2 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -50,6 +50,7 @@ from agents.models.interface import ModelTracing from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel from agents.models.openai_provider import OpenAIProvider +from agents.tracing import get_current_span from tests.testing_processor import fetch_ordered_spans from tests.utils.simple_session import SimpleListSession @@ -4326,16 +4327,29 @@ async def test_streamed_span_records_the_request_when_provider_omits_usage(monke @pytest.mark.allow_call_model_methods @pytest.mark.asyncio +@pytest.mark.parametrize("with_usage", [False, True], ids=["no-usage", "detailed-usage"]) +@pytest.mark.parametrize("tracing", [ModelTracing.ENABLED, ModelTracing.ENABLED_WITHOUT_DATA]) async def test_stream_span_is_recorded_for_a_consumer_that_stops_at_the_terminal_event( - monkeypatch, + monkeypatch, with_usage: bool, tracing: ModelTracing ) -> None: """A caller that stops at `response.completed` closes the generator. Anything recorded only after the yield loop never runs for such a consumer, so the span has to be populated before the terminal event is handed out. """ + usage = ( + CompletionUsage( + completion_tokens=5, + prompt_tokens=7, + total_tokens=12, + prompt_tokens_details=PromptTokensDetails(cached_tokens=2), + completion_tokens_details=CompletionTokensDetails(reasoning_tokens=3), + ) + if with_usage + else None + ) monkeypatch.setattr( - OpenAIChatCompletionsModel, "_fetch_response", _usageless_stream_patch(usage=None) + OpenAIChatCompletionsModel, "_fetch_response", _usageless_stream_patch(usage=usage) ) model = OpenAIProvider(use_responses=False).get_model("gpt-4") @@ -4347,16 +4361,37 @@ async def test_stream_span_is_recorded_for_a_consumer_that_stops_at_the_terminal tools=[], output_schema=None, handoffs=[], - tracing=ModelTracing.ENABLED, + tracing=tracing, previous_response_id=None, conversation_id=None, prompt=None, ) stream_agen = cast(Any, stream) - async for event in stream_agen: - if event.type == "response.completed": - break # stop consuming, as a caller watching for the terminal event would - await stream_agen.aclose() + try: + async for event in stream_agen: + if event.type == "response.completed": + generation = get_current_span() + assert generation is not None + assert generation.span_data.usage == { + "requests": 1, + "input_tokens": 7 if with_usage else 0, + "output_tokens": 5 if with_usage else 0, + "total_tokens": 12 if with_usage else 0, + "input_tokens_details": { + "cached_tokens": 2 if with_usage else 0, + "cache_write_tokens": 0, + }, + "output_tokens_details": {"reasoning_tokens": 3 if with_usage else 0}, + } + assert generation.span_data.output == ( + [event.response.model_dump()] if tracing == ModelTracing.ENABLED else None + ) + assert (event.response.usage is not None) == with_usage + break + else: + pytest.fail("The stream did not yield a completed response.") + finally: + await stream_agen.aclose() generation = next(s for s in fetch_ordered_spans() if s.span_data.type == "generation") assert generation.span_data.usage is not None From ae813998f44bae7f50d8577106d070be0e5631e3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 18:12:49 +0900 Subject: [PATCH 448/473] refactor: separate release contract verification responsibilities (#4878) --- .github/scripts/run_integration_tests.py | 2 +- .../scripts/update_released_api_contract.py | 4 +- integration_tests/_contract_state.py | 367 +++ integration_tests/_contract_support.py | 2533 +---------------- integration_tests/_contract_surface.py | 1362 +++++++++ integration_tests/_contract_validation.py | 653 +++++ .../packaging/test_released_api_contract.py | 6 +- .../packaging/test_run_state_compatibility.py | 2 +- .../security/test_local_sandbox_isolation.py | 2 +- .../security/test_packaged_mount_redaction.py | 2 +- tests/test_released_api_contract.py | 137 +- tests/test_run_state_compatibility_corpus.py | 2 +- 12 files changed, 2572 insertions(+), 2500 deletions(-) create mode 100644 integration_tests/_contract_state.py create mode 100644 integration_tests/_contract_surface.py create mode 100644 integration_tests/_contract_validation.py diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 34bb81c6a1..9c032e5ccf 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -115,7 +115,7 @@ def bootstrap_in_uv( sys.path.insert(0, str(ROOT)) -from integration_tests._contract_support import ( # noqa: E402 +from integration_tests._contract_surface import ( # noqa: E402 SubmoduleExportPolicy, load_submodule_export_policy, ) diff --git a/.github/scripts/update_released_api_contract.py b/.github/scripts/update_released_api_contract.py index 2a67e8b9b8..708161bdf8 100644 --- a/.github/scripts/update_released_api_contract.py +++ b/.github/scripts/update_released_api_contract.py @@ -18,8 +18,8 @@ sys.path.insert(0, str(ROOT)) -from integration_tests._contract_support import ( # noqa: E402 - build_released_api_contract, +from integration_tests._contract_support import build_released_api_contract # noqa: E402 +from integration_tests._contract_surface import ( # noqa: E402 load_api_contract, load_submodule_export_policy, ) diff --git a/integration_tests/_contract_state.py b/integration_tests/_contract_state.py new file mode 100644 index 0000000000..218e669672 --- /dev/null +++ b/integration_tests/_contract_state.py @@ -0,0 +1,367 @@ +"""Exercise historical state readers, resume behavior, and redaction observations.""" + +from __future__ import annotations + +import dataclasses +import json +import logging +import traceback +from collections.abc import Iterable, Mapping +from copy import deepcopy +from pathlib import Path +from types import TracebackType +from typing import Any, cast + + +def _redaction_observables( + error: BaseException | None, + records: Iterable[logging.LogRecord], +) -> str: + values: list[str] = [] + seen: dict[int, object] = {} + + def visit_exception_state(value: object) -> None: + value_id = id(value) + if value_id in seen: + return + # Keep visited objects alive so a later temporary object cannot reuse an id and be + # mistaken for a cycle. Traceback frame locals are materialized as temporary dicts. + seen[value_id] = value + + if isinstance(value, BaseException): + state = vars(value) + values.append(repr(state)) + visit_exception_state(value.args) + visit_exception_state(value.__cause__) + visit_exception_state(value.__context__) + visit_exception_state(value.__traceback__) + visit_exception_state(state) + elif isinstance(value, TracebackType): + module_name = value.tb_frame.f_globals.get("__name__", "") + if module_name == "agents" or module_name.startswith("agents."): + visit_exception_state(value.tb_frame.f_locals) + visit_exception_state(value.tb_next) + elif isinstance(value, Mapping): + for key, item in value.items(): + visit_exception_state(key) + visit_exception_state(item) + elif ( + dataclasses.is_dataclass(value) + and not isinstance(value, type) + and (type(value).__module__ == "agents" or type(value).__module__.startswith("agents.")) + ): + for field in dataclasses.fields(value): + visit_exception_state(getattr(value, field.name)) + elif isinstance(value, list | tuple | set | frozenset): + for item in value: + visit_exception_state(item) + elif isinstance(value, str | bytes | int | float | bool | None): + values.append(repr(value)) + + if error is not None: + values.extend( + ( + str(error), + repr(error), + repr(error.__cause__), + repr(error.__context__), + "".join(traceback.format_exception(error)), + ) + ) + visit_exception_state(error) + for record in records: + values.extend((record.getMessage(), repr(record.args), repr(record.__dict__))) + visit_exception_state(record.__dict__) + if record.exc_info is not None: + values.append("".join(traceback.format_exception(*record.exc_info))) + visit_exception_state(record.exc_info) + return "\n".join(values) + + +def _deserialize_common_sandbox_session_state(payload: dict[str, object]) -> Any: + from agents.sandbox.session import SandboxSessionState + + persisted_payload = deepcopy(payload) + state = SandboxSessionState.model_validate(persisted_payload) + return SandboxSessionState._mark_persisted_path_grants(state, payload=persisted_payload) + + +def _normalized_durable_state(payload: dict[str, Any]) -> dict[str, Any]: + normalized = deepcopy(payload) + normalized.pop("$schemaVersion", None) + return normalized + + +def _normalize_legacy_mount_credentials(payload: dict[str, Any]) -> dict[str, Any]: + from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY + + normalized = deepcopy(payload) + sandbox = cast(dict[str, Any], normalized["sandbox"]) + session_states = [cast(dict[str, Any], sandbox["session_state"])] + sessions_by_agent = cast(dict[str, dict[str, Any]], sandbox["sessions_by_agent"]) + session_states.extend( + cast(dict[str, Any], entry["session_state"]) for entry in sessions_by_agent.values() + ) + for session_state in session_states: + manifest = cast(dict[str, Any], session_state["manifest"]) + entries = cast(dict[str, dict[str, Any]], manifest["entries"]) + mount = entries["remote"] + mount["access_key_id"] = None + mount["secret_access_key"] = None + mount["session_token"] = None + strategy = cast(dict[str, Any], mount["mount_strategy"]) + strategy["driver_options"] = {} + session_state[REDACTED_MOUNT_AUTHORITY_KEY] = True + return normalized + + +def _legacy_driver_option_errors(payload: dict[str, Any]) -> list[str]: + sandbox = cast(dict[str, Any], payload["sandbox"]) + session_states = [("sandbox.session_state", cast(dict[str, Any], sandbox["session_state"]))] + sessions_by_agent = cast(dict[str, dict[str, Any]], sandbox["sessions_by_agent"]) + session_states.extend( + ( + f"sandbox.sessions_by_agent.{agent_id}.session_state", + cast(dict[str, Any], entry["session_state"]), + ) + for agent_id, entry in sessions_by_agent.items() + ) + errors: list[str] = [] + for path, session_state in session_states: + manifest = cast(dict[str, Any], session_state["manifest"]) + entries = cast(dict[str, dict[str, Any]], manifest["entries"]) + mount = entries["remote"] + strategy = cast(dict[str, Any], mount["mount_strategy"]) + if strategy.get("driver_options") != {}: + errors.append(f"{path}.manifest.entries.remote.mount_strategy.driver_options remained") + return errors + + +def _find_subset_errors(expected: object, actual: object, path: str = "state") -> list[str]: + if isinstance(expected, dict): + if not isinstance(actual, dict): + return [f"{path} changed type from mapping to {type(actual).__name__}"] + errors: list[str] = [] + for key, value in expected.items(): + if key not in actual: + errors.append(f"{path}.{key} was dropped") + continue + errors.extend(_find_subset_errors(value, actual[key], f"{path}.{key}")) + return errors + if isinstance(expected, list): + if not isinstance(actual, list): + return [f"{path} changed type from list to {type(actual).__name__}"] + if len(expected) != len(actual): + return [f"{path} changed length from {len(expected)} to {len(actual)}"] + errors = [] + for index, (expected_item, actual_item) in enumerate(zip(expected, actual, strict=True)): + errors.extend(_find_subset_errors(expected_item, actual_item, f"{path}[{index}]")) + return errors + if type(expected) is not type(actual): + return [f"{path} changed type from {type(expected).__name__} to {type(actual).__name__}"] + if expected != actual: + return [f"{path} changed from {expected!r} to {actual!r}"] + return [] + + +def _restore_agent(payload: dict[str, Any]) -> Any: + from agents import Agent, handoff + + current_agent = payload.get("current_agent") + name = ( + current_agent.get("name", "compat-agent") + if isinstance(current_agent, dict) + else "compat-agent" + ) + identity = current_agent.get("identity") if isinstance(current_agent, dict) else None + if identity == f"{name}#2": + duplicate = Agent(name=name) + return Agent(name=name, handoffs=[handoff(duplicate)]) + return Agent(name=name) + + +async def validate_historical_run_state_fixture(path: Path) -> list[str]: + from agents import RunState + from agents.run_state import CURRENT_SCHEMA_VERSION + + errors: list[str] = [] + payload = json.loads(path.read_text(encoding="utf-8")) + historical = deepcopy(payload) + original_version = historical.get("$schemaVersion") + agent = _restore_agent(historical) + restored = await RunState.from_json(agent, payload) + canonical = restored.to_json() + + if canonical.get("$schemaVersion") != CURRENT_SCHEMA_VERSION: + errors.append( + f"{path.name} rewrote as {canonical.get('$schemaVersion')!r}, " + f"expected {CURRENT_SCHEMA_VERSION!r}" + ) + semantic_errors = _find_subset_errors( + _normalized_durable_state(historical), + _normalized_durable_state(canonical), + ) + errors.extend(f"{path.name}: {error}" for error in semantic_errors) + + expected_canonical = deepcopy(canonical) + rerestored = await RunState.from_json(agent, deepcopy(canonical)) + recanonical = rerestored.to_json() + if recanonical != expected_canonical: + errors.append( + f"{path.name} was not idempotent after rewriting schema {original_version!r} " + f"to {CURRENT_SCHEMA_VERSION!r}" + ) + return errors + + +async def validate_historical_resume_behavior( + path: Path, + *, + feature: str, + decision: str | None = None, +) -> list[str]: + from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputText, + ) + + from agents import Agent, Runner, RunState, function_tool + from agents.items import ToolCallOutputItem, TResponseOutputItem + from agents.testing import ModelStep, ScriptedModel + + invocation_count = 0 + if feature == "canonical_invocation_identity": + + def lookup_account(account_id: str) -> str: + nonlocal invocation_count + invocation_count += 1 + return f"approved:{account_id}" + + tool = function_tool(lookup_account, needs_approval=True) + model_turns: list[list[TResponseOutputItem]] = [ + [ + ResponseFunctionToolCall( + type="function_call", + name="lookup_account", + call_id="function-request-1", + status="completed", + arguments='{"account_id":"account-1"}', + ) + ] + ] + expected_invocations = 1 + expected_tool_output = "approved:account-1" + elif feature == "pending_tool_approval": + + def historical_approval(account_id: str) -> str: + nonlocal invocation_count + invocation_count += 1 + return f"approved:{account_id}" + + tool = function_tool(historical_approval, needs_approval=True) + model_turns = [] + if decision == "approve": + expected_invocations = 1 + expected_tool_output = "approved:account-1" + elif decision == "reject": + expected_invocations = 0 + expected_tool_output = "Candidate rejected historical approval" + else: + raise ValueError("pending_tool_approval requires an approve or reject decision") + else: + raise ValueError(f"Unsupported historical resume feature: {feature}") + + final_message = ResponseOutputMessage( + id="historical-resume-final", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText( + type="output_text", + text="resume complete", + annotations=[], + logprobs=[], + ) + ], + ) + model_turns.append([final_message]) + model = ScriptedModel( + [ModelStep(output=turn, response_id="queued-fake-response") for turn in model_turns] + ) + agent = Agent(name="compat-agent", model=model, tools=[tool]) + payload = json.loads(path.read_text(encoding="utf-8")) + restored = await RunState.from_json(agent, payload) + if feature == "pending_tool_approval": + interruptions = restored.get_interruptions() + if len(interruptions) != 1: + return [f"{path.name} did not restore its historical pending approval"] + if decision == "approve": + restored.approve(interruptions[0]) + else: + restored.reject( + interruptions[0], + rejection_message="Candidate rejected historical approval", + ) + result = await Runner.run(agent, restored) + + errors: list[str] = [] + if result.interruptions: + errors.append(f"{path.name} interrupted instead of applying its historical decision") + if invocation_count != expected_invocations: + errors.append( + f"{path.name} invoked its approval-controlled tool {invocation_count} times, " + f"expected {expected_invocations}" + ) + tool_outputs = [ + item.output for item in result.new_items if isinstance(item, ToolCallOutputItem) + ] + if expected_tool_output not in tool_outputs: + errors.append( + f"{path.name} did not preserve the historical tool decision output " + f"{expected_tool_output!r}" + ) + if result.final_output != "resume complete": + errors.append(f"{path.name} did not complete its resumed run") + return errors + + +async def validate_legacy_credential_run_state_fixture( + path: Path, + *, + sentinels: Iterable[str], +) -> list[str]: + from agents import RunState + from agents.run_state import CURRENT_SCHEMA_VERSION + + errors: list[str] = [] + payload = json.loads(path.read_text(encoding="utf-8")) + historical = deepcopy(payload) + agent = _restore_agent(payload) + restored = await RunState.from_json(agent, payload) + canonical = restored.to_json() + + if canonical.get("$schemaVersion") != CURRENT_SCHEMA_VERSION: + errors.append( + f"{path.name} rewrote as {canonical.get('$schemaVersion')!r}, " + f"expected {CURRENT_SCHEMA_VERSION!r}" + ) + semantic_errors = _find_subset_errors( + _normalized_durable_state(_normalize_legacy_mount_credentials(historical)), + _normalized_durable_state(canonical), + ) + errors.extend(f"{path.name}: {error}" for error in semantic_errors) + if not semantic_errors: + errors.extend(f"{path.name}: {error}" for error in _legacy_driver_option_errors(canonical)) + + serialized_observables = json.dumps(canonical, sort_keys=True) + repr(restored._sandbox) + for sentinel in sentinels: + if sentinel in serialized_observables: + errors.append(f"{path.name} retained credential sentinel {sentinel!r}") + + expected_canonical = deepcopy(canonical) + rerestored = await RunState.from_json(agent, deepcopy(canonical)) + if rerestored.to_json() != expected_canonical: + errors.append(f"{path.name} was not idempotent after credential sanitization") + return errors diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index cdc4c8e9a8..589e396ad6 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -1,806 +1,15 @@ +"""Promote the public API contract using shared surface descriptions and validation.""" + from __future__ import annotations -import ast -import dataclasses -import enum import importlib import inspect -import json -import logging import sys -import traceback -import typing -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Iterable, Mapping from copy import deepcopy -from importlib.util import find_spec -from pathlib import Path -from types import FunctionType, ModuleType, TracebackType, UnionType -from typing import ( - Any, - ForwardRef, - Literal, - TypeAlias, - Union, - cast, - get_args, - get_origin, - get_type_hints, -) - -import typing_extensions -from pydantic import BaseModel -from typing_extensions import NotRequired, Required - - -@dataclasses.dataclass(frozen=True) -class OptionalDependencyInstallation: - dependency_module: str - extra: str | None = None - requirement: str | None = None - unsupported_platforms: tuple[str, ...] = () - - def is_supported_on_current_platform(self) -> bool: - return sys.platform not in self.unsupported_platforms - - -@dataclasses.dataclass(frozen=True) -class SubmoduleExportPolicy: - modules: dict[str, dict[str, dict[str, str]]] - dependency_installations: tuple[OptionalDependencyInstallation, ...] - canonical_imports: tuple[dict[str, str], ...] = () - public_class_contracts: tuple[dict[str, Any], ...] = () - public_properties: tuple[dict[str, Any], ...] = () - public_type_aliases: tuple[dict[str, str], ...] = () - public_typed_dicts: tuple[dict[str, Any], ...] = () - - -def load_api_contract(path: Path) -> dict[str, Any]: - contract = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) - _add_legacy_literal_types(contract) - return contract - - -def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - raise ValueError("submodule export policy must be an object") - unknown_top_level_fields = sorted( - set(value) - - { - "canonical_imports", - "modules", - "optional_dependencies", - "public_class_contracts", - "public_properties", - "public_type_aliases", - "public_typed_dicts", - } - ) - if unknown_top_level_fields: - raise ValueError( - f"submodule export policy has unknown fields: {unknown_top_level_fields!r}" - ) - modules = value.get("modules") - if not isinstance(modules, dict): - raise ValueError("submodule export policy modules must be an object keyed by module name") - policy: dict[str, dict[str, dict[str, str]]] = {} - for module_name, declarations in modules.items(): - if type(module_name) is not str or not module_name: - raise ValueError("submodule export policy module names must be non-empty strings") - if not isinstance(declarations, dict): - raise ValueError(f"submodule export policy for {module_name} must be an object") - unknown_fields = sorted(set(declarations) - {"optional_bindings", "optional_exports"}) - if unknown_fields: - raise ValueError( - f"submodule export policy for {module_name} has unknown fields: {unknown_fields!r}" - ) - policy[module_name] = { - "optional_bindings": _optional_dependency_modules( - declarations.get("optional_bindings", {}), field_name="optional_bindings" - ), - "optional_exports": _optional_dependency_modules( - declarations.get("optional_exports", {}), field_name="optional_exports" - ), - } - - dependencies = value.get("optional_dependencies") - if not isinstance(dependencies, dict): - raise ValueError("submodule export policy optional_dependencies must be an object") - dependency_installations: list[OptionalDependencyInstallation] = [] - for module_name, installation in dependencies.items(): - if type(module_name) is not str or not module_name: - raise ValueError("optional dependency module names must be non-empty strings") - if not isinstance(installation, dict): - raise ValueError( - f"optional dependency installation for {module_name} must be an object" - ) - unknown_fields = sorted( - set(installation) - {"extra", "requirement", "unsupported_platforms"} - ) - if unknown_fields: - raise ValueError( - f"optional dependency installation for {module_name} has unknown fields: " - f"{unknown_fields!r}" - ) - configured = [field for field in ("extra", "requirement") if field in installation] - if len(configured) != 1: - raise ValueError( - f"optional dependency installation for {module_name} must declare exactly one " - "of extra or requirement" - ) - field_name = configured[0] - install_value = installation[field_name] - if type(install_value) is not str or not install_value: - raise ValueError( - f"optional dependency installation {field_name} for {module_name} must be a " - "non-empty string" - ) - unsupported_platforms = installation.get("unsupported_platforms", []) - if ( - not isinstance(unsupported_platforms, list) - or not all(type(platform) is str and platform for platform in unsupported_platforms) - or len(unsupported_platforms) != len(set(unsupported_platforms)) - ): - raise ValueError( - f"optional dependency installation unsupported_platforms for {module_name} " - "must be a list of unique non-empty strings" - ) - dependency_installations.append( - OptionalDependencyInstallation( - dependency_module=module_name, - extra=install_value if field_name == "extra" else None, - requirement=install_value if field_name == "requirement" else None, - unsupported_platforms=tuple(unsupported_platforms), - ) - ) - - referenced_dependencies = { - dependency - for module_policy in policy.values() - for declarations in module_policy.values() - for dependency in declarations.values() - } - missing_installations = sorted(referenced_dependencies - set(dependencies)) - unused_installations = sorted(set(dependencies) - referenced_dependencies) - if missing_installations: - raise ValueError( - "submodule export policy dependencies are missing installation declarations: " - f"{missing_installations!r}" - ) - if unused_installations: - raise ValueError( - "submodule export policy has unused dependency installation declarations: " - f"{unused_installations!r}" - ) - return SubmoduleExportPolicy( - modules=policy, - dependency_installations=tuple( - sorted( - dependency_installations, key=lambda installation: installation.dependency_module - ) - ), - canonical_imports=_canonical_import_policy(value.get("canonical_imports", [])), - public_class_contracts=_public_class_contract_policy( - value.get("public_class_contracts", []) - ), - public_properties=_public_property_policy(value.get("public_properties", [])), - public_type_aliases=_public_type_alias_policy(value.get("public_type_aliases", [])), - public_typed_dicts=_public_typed_dict_policy(value.get("public_typed_dicts", [])), - ) - - -def _canonical_import_policy(value: object) -> tuple[dict[str, str], ...]: - if not isinstance(value, list): - raise ValueError("submodule export policy canonical_imports must be a list") - required_fields = {"canonical_module", "canonical_name", "module", "name"} - entries: list[dict[str, str]] = [] - identities: set[tuple[str, str]] = set() - for entry in value: - if not isinstance(entry, dict) or set(entry) != required_fields: - raise ValueError( - "submodule export policy canonical_imports entries must contain exactly " - "canonical_module, canonical_name, module, and name" - ) - if not all(type(entry[field]) is str and entry[field] for field in required_fields): - raise ValueError( - "submodule export policy canonical_imports values must be non-empty strings" - ) - identity = (entry["module"], entry["name"]) - if identity in identities: - raise ValueError( - "submodule export policy canonical_imports must not repeat " - f"{entry['module']}.{entry['name']}" - ) - identities.add(identity) - entries.append({field: entry[field] for field in sorted(required_fields)}) - return tuple(entries) - - -def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: - if not isinstance(value, list): - raise ValueError("submodule export policy public_properties must be a list") - entries: list[dict[str, Any]] = [] - identities: set[tuple[str, str, str]] = set() - for entry in value: - if not isinstance(entry, dict): - raise ValueError("submodule export policy public_properties entries must be objects") - owner_fields = {"class_name", "factory_name"} & set(entry) - required_fields = {"module", "names", *owner_fields} - if len(owner_fields) != 1 or set(entry) != required_fields: - raise ValueError( - "submodule export policy public_properties entries must contain exactly " - "module, names, and one of class_name or factory_name" - ) - owner_field = next(iter(owner_fields)) - module_name = entry["module"] - owner_name = entry[owner_field] - names = entry["names"] - if type(module_name) is not str or not module_name: - raise ValueError( - "submodule export policy public_properties module must be a non-empty string" - ) - if type(owner_name) is not str or not owner_name: - raise ValueError( - f"submodule export policy public_properties {owner_field} must be a non-empty " - "string" - ) - if ( - not isinstance(names, list) - or not names - or not all(type(name) is str and name for name in names) - or len(names) != len(set(names)) - ): - raise ValueError( - "submodule export policy public_properties names must be a non-empty list of " - "unique non-empty strings" - ) - identity = (owner_field, module_name, owner_name) - if identity in identities: - raise ValueError( - "submodule export policy public_properties must not repeat " - f"{module_name}.{owner_name}" - ) - identities.add(identity) - normalized_entry = { - owner_field: owner_name, - "module": module_name, - "names": list(names), - } - entries.append(normalized_entry) - return tuple(entries) - - -def _public_class_contract_policy(value: object) -> tuple[dict[str, Any], ...]: - if not isinstance(value, list): - raise ValueError("submodule export policy public_class_contracts must be a list") - required_fields = {"class_name", "module"} - contract_fields = {"abstract", "abstract_members"} - entries: list[dict[str, Any]] = [] - identities: set[tuple[str, str]] = set() - for entry in value: - if ( - not isinstance(entry, dict) - or not required_fields.issubset(entry) - or not set(entry).issubset(required_fields | contract_fields) - or not (set(entry) & contract_fields) - ): - raise ValueError( - "submodule export policy public_class_contracts entries must contain exactly " - "module, class_name, and at least one of abstract or abstract_members" - ) - module_name = entry["module"] - class_name = entry["class_name"] - if type(module_name) is not str or not module_name: - raise ValueError( - "submodule export policy public_class_contracts module must be a non-empty string" - ) - if type(class_name) is not str or not class_name: - raise ValueError( - "submodule export policy public_class_contracts class_name must be a non-empty " - "string" - ) - if "abstract" in entry and type(entry["abstract"]) is not bool: - raise ValueError( - "submodule export policy public_class_contracts abstract must be a boolean" - ) - abstract_members = entry.get("abstract_members") - if "abstract_members" in entry and ( - not isinstance(abstract_members, list) - or not abstract_members - or not all(type(name) is str and name for name in abstract_members) - or len(abstract_members) != len(set(abstract_members)) - ): - raise ValueError( - "submodule export policy public_class_contracts abstract_members must be a " - "non-empty list of unique non-empty strings" - ) - identity = (module_name, class_name) - if identity in identities: - raise ValueError( - "submodule export policy public_class_contracts must not repeat " - f"{module_name}.{class_name}" - ) - identities.add(identity) - normalized_entry: dict[str, Any] = { - "class_name": class_name, - "module": module_name, - } - if "abstract" in entry: - normalized_entry["abstract"] = entry["abstract"] - if "abstract_members" in entry: - normalized_entry["abstract_members"] = sorted(abstract_members) - entries.append(normalized_entry) - return tuple(entries) - - -def _public_typed_dict_policy(value: object) -> tuple[dict[str, Any], ...]: - if not isinstance(value, list): - raise ValueError("submodule export policy public_typed_dicts must be a list") - required_fields = {"class_name", "module", "names"} - entries: list[dict[str, Any]] = [] - identities: set[tuple[str, str]] = set() - for entry in value: - if not isinstance(entry, dict) or set(entry) != required_fields: - raise ValueError( - "submodule export policy public_typed_dicts entries must contain exactly " - "class_name, module, and names" - ) - module_name = entry["module"] - class_name = entry["class_name"] - names = entry["names"] - if type(module_name) is not str or not module_name: - raise ValueError( - "submodule export policy public_typed_dicts module must be a non-empty string" - ) - if type(class_name) is not str or not class_name: - raise ValueError( - "submodule export policy public_typed_dicts class_name must be a non-empty string" - ) - if ( - not isinstance(names, list) - or not names - or not all(type(name) is str and name for name in names) - or len(names) != len(set(names)) - ): - raise ValueError( - "submodule export policy public_typed_dicts names must be a non-empty list of " - "unique non-empty strings" - ) - identity = (module_name, class_name) - if identity in identities: - raise ValueError( - "submodule export policy public_typed_dicts must not repeat " - f"{module_name}.{class_name}" - ) - identities.add(identity) - entries.append({"class_name": class_name, "module": module_name, "names": list(names)}) - return tuple(entries) - - -def _public_type_alias_policy(value: object) -> tuple[dict[str, str], ...]: - if not isinstance(value, list): - raise ValueError("submodule export policy public_type_aliases must be a list") - required_fields = {"module", "name"} - entries: list[dict[str, str]] = [] - identities: set[tuple[str, str]] = set() - for entry in value: - if not isinstance(entry, dict) or set(entry) != required_fields: - raise ValueError( - "submodule export policy public_type_aliases entries must contain exactly " - "module and name" - ) - if not all(type(entry[field]) is str and entry[field] for field in required_fields): - raise ValueError( - "submodule export policy public_type_aliases values must be non-empty strings" - ) - identity = (entry["module"], entry["name"]) - if identity in identities: - raise ValueError( - "submodule export policy public_type_aliases must not repeat " - f"{entry['module']}.{entry['name']}" - ) - identities.add(identity) - entries.append({"module": entry["module"], "name": entry["name"]}) - return tuple(entries) - - -def _add_legacy_literal_types(value: object) -> None: - if isinstance(value, dict): - if value.get("kind") == "literal" and "value" in value and "type" not in value: - literal = value["value"] - value["type"] = f"{type(literal).__module__}.{type(literal).__qualname__}" - for child in value.values(): - _add_legacy_literal_types(child) - elif isinstance(value, list): - for child in value: - _add_legacy_literal_types(child) - - -def _redaction_observables( - error: BaseException | None, - records: Iterable[logging.LogRecord], -) -> str: - values: list[str] = [] - seen: dict[int, object] = {} - - def visit_exception_state(value: object) -> None: - value_id = id(value) - if value_id in seen: - return - # Keep visited objects alive so a later temporary object cannot reuse an id and be - # mistaken for a cycle. Traceback frame locals are materialized as temporary dicts. - seen[value_id] = value - - if isinstance(value, BaseException): - state = vars(value) - values.append(repr(state)) - visit_exception_state(value.args) - visit_exception_state(value.__cause__) - visit_exception_state(value.__context__) - visit_exception_state(value.__traceback__) - visit_exception_state(state) - elif isinstance(value, TracebackType): - module_name = value.tb_frame.f_globals.get("__name__", "") - if module_name == "agents" or module_name.startswith("agents."): - visit_exception_state(value.tb_frame.f_locals) - visit_exception_state(value.tb_next) - elif isinstance(value, Mapping): - for key, item in value.items(): - visit_exception_state(key) - visit_exception_state(item) - elif ( - dataclasses.is_dataclass(value) - and not isinstance(value, type) - and (type(value).__module__ == "agents" or type(value).__module__.startswith("agents.")) - ): - for field in dataclasses.fields(value): - visit_exception_state(getattr(value, field.name)) - elif isinstance(value, list | tuple | set | frozenset): - for item in value: - visit_exception_state(item) - elif isinstance(value, str | bytes | int | float | bool | None): - values.append(repr(value)) - - if error is not None: - values.extend( - ( - str(error), - repr(error), - repr(error.__cause__), - repr(error.__context__), - "".join(traceback.format_exception(error)), - ) - ) - visit_exception_state(error) - for record in records: - values.extend((record.getMessage(), repr(record.args), repr(record.__dict__))) - visit_exception_state(record.__dict__) - if record.exc_info is not None: - values.append("".join(traceback.format_exception(*record.exc_info))) - visit_exception_state(record.exc_info) - return "\n".join(values) - - -def _deserialize_common_sandbox_session_state(payload: dict[str, object]) -> Any: - from agents.sandbox.session import SandboxSessionState - - persisted_payload = deepcopy(payload) - state = SandboxSessionState.model_validate(persisted_payload) - return SandboxSessionState._mark_persisted_path_grants(state, payload=persisted_payload) - - -def _default_contract(value: object) -> dict[str, object]: - if value is inspect.Parameter.empty or value is dataclasses.MISSING: - return {"kind": "required"} - if value.__class__.__name__ == "_HAS_DEFAULT_FACTORY_CLASS": - return {"kind": "factory"} - if value is None or isinstance(value, bool | int | float | str): - return { - "kind": "literal", - "type": f"{type(value).__module__}.{type(value).__qualname__}", - "value": value, - } - voice_testing = sys.modules.get("agents.voice.testing") - if voice_testing is not None and value is getattr(voice_testing, "_START_NOT_CONFIGURED", None): - return { - "kind": "sentinel", - "identity": "agents.voice.testing._START_NOT_CONFIGURED", - } - value_type = f"{type(value).__module__}.{type(value).__qualname__}" - from agents.mcp.server import _UNSET as mcp_failure_error_unset - from agents.retry import _UNSET as retry_unset - from agents.tool import _UNSET_FAILURE_ERROR_FUNCTION as failure_error_function_unset - from agents.tool_context import _MISSING as tool_context_missing - - sentinel_identities = ( - (retry_unset, "agents.retry._UNSET"), - (mcp_failure_error_unset, "agents.mcp.server._UNSET"), - (failure_error_function_unset, "agents.tool._UNSET_FAILURE_ERROR_FUNCTION"), - (tool_context_missing, "agents.tool_context._MISSING"), - ) - for sentinel, identity in sentinel_identities: - if value is sentinel: - return {"kind": "sentinel", "identity": identity} - if value_type == "pydantic.fields.FieldInfo": - return {"kind": "repr", "type": value_type, "value": repr(value)} - if isinstance(value, enum.Enum): - return { - "kind": "enum", - "type": value_type, - "name": value.name, - "value": _default_contract(value.value), - } - if isinstance(value, type): - return { - "kind": "type", - "identity": f"{value.__module__}.{value.__qualname__}", - } - if isinstance(value, tuple | list): - return { - "kind": "sequence", - "type": value_type, - "items": [_default_contract(item) for item in value], - } - if isinstance(value, dict): - return { - "kind": "mapping", - "type": value_type, - "items": [ - [_default_contract(key), _default_contract(item)] for key, item in value.items() - ], - } - if value_type.startswith("agents.") and callable(getattr(value, "model_dump", None)): - dumped = value.model_dump(mode="python") # type: ignore[attr-defined] - return { - "kind": "model", - "type": value_type, - "value": _default_contract(dumped), - } - if dataclasses.is_dataclass(value) and not isinstance(value, type): - return { - "kind": "dataclass", - "type": value_type, - "fields": [ - {"name": field.name, "value": _default_contract(getattr(value, field.name))} - for field in dataclasses.fields(value) - ], - } - if type(value) is FunctionType and value.__module__.startswith("agents."): - return { - "kind": "callable", - "identity": f"{value.__module__}.{value.__qualname__}", - } - raise TypeError(f"Unsupported public API default value: {value_type}") - - -def _parameter_records( - parameters: Iterable[inspect.Parameter], -) -> list[dict[str, object]]: - return [ - { - "name": parameter.name, - "kind": parameter.kind.name, - "default": _default_contract(parameter.default), - } - for parameter in parameters - ] - - -def _signature(value: Callable[..., Any]) -> inspect.Signature: - return inspect.signature(value) - - -def _parameter_contract(value: Callable[..., Any]) -> list[dict[str, object]]: - parameters = list(_signature(value).parameters.values()) - if issubclass(type(value), type) and issubclass(cast(type, value), enum.Enum): - parameters = list(_signature(value.__new__).parameters.values())[1:] - return _parameter_records(parameters) - - -def _dataclass_field_contract(value: object) -> list[dict[str, object]]: - if not dataclasses.is_dataclass(value): - return [] - result: list[dict[str, object]] = [] - for field in dataclasses.fields(value): - if field.name.startswith("_"): - continue - if field.default_factory is not dataclasses.MISSING: - factory = cast(Callable[..., Any], field.default_factory) - default_contract: dict[str, object] = { - "kind": "factory", - "factory": f"{factory.__module__}.{factory.__qualname__}", - } - else: - default_contract = _default_contract(field.default) - result.append( - { - "name": field.name, - "init": field.init, - "default": default_contract, - } - ) - return result - - -def _pydantic_model_field_contract(value: object) -> list[dict[str, object]] | None: - if not (isinstance(value, type) and issubclass(value, BaseModel)): - return None - result: list[dict[str, object]] = [] - for name, field in value.model_fields.items(): - if name.startswith("_"): - continue - if field.is_required(): - default_contract: dict[str, object] = {"kind": "required"} - elif field.default_factory is not None: - factory = field.default_factory - default_contract = { - "kind": "factory", - "factory": f"{factory.__module__}.{factory.__qualname__}", - } - else: - default_contract = _default_contract(field.default) - result.append({"name": name, "default": default_contract}) - return result +from typing import Any - -def _callable_kind(value: Callable[..., Any]) -> str | None: - if issubclass(type(value), type): - return "class" - if type(value) is FunctionType: - return "function" - return None - - -def _is_sdk_owned_callable(value: object) -> bool: - module_name = getattr(value, "__module__", None) - return ( - _callable_kind(cast(Callable[..., Any], value)) is not None - and isinstance(module_name, str) - and (module_name == "agents" or module_name.startswith("agents.")) - ) - - -def _enum_member_contract(value: object) -> list[dict[str, object]] | None: - if not (issubclass(type(value), type) and issubclass(cast(type, value), enum.Enum)): - return None - enum_type = cast(type[enum.Enum], value) - members: list[dict[str, object]] = [] - for name, member in enum_type.__members__.items(): - member_value = member.value - if member_value is None or isinstance(member_value, bool | int | float | str): - value_contract: dict[str, object] = { - "kind": "literal", - "type": f"{type(member_value).__module__}.{type(member_value).__qualname__}", - "value": member_value, - } - else: - raise TypeError( - f"Unsupported public enum value for " - f"{enum_type.__module__}.{enum_type.__qualname__}." - f"{name}: {type(member_value).__module__}.{type(member_value).__qualname__}" - ) - members.append({"name": name, "value": value_contract}) - return members - - -def _class_member_contract(descriptor: object) -> dict[str, object] | None: - descriptor_type = type(descriptor) - if descriptor_type is staticmethod: - binding = "static" - function = object.__getattribute__(descriptor, "__func__") - skip_first = False - elif descriptor_type is classmethod: - binding = "class" - function = object.__getattribute__(descriptor, "__func__") - skip_first = True - elif type(descriptor) is FunctionType: - binding = "instance" - function = descriptor - skip_first = True - else: - return None - if type(function) is not FunctionType: - return None - try: - parameters = list(_signature(function).parameters.values()) - except (TypeError, ValueError): - return None - if skip_first: - if not parameters: - return None - parameters = parameters[1:] - return { - "binding": binding, - "execution_kind": _function_execution_kind(function), - "parameters": _parameter_records(parameters), - } - - -def _function_execution_kind(value: object) -> str: - if inspect.isasyncgenfunction(value): - return "async_generator" - if inspect.iscoroutinefunction(value): - return "coroutine" - if inspect.isgeneratorfunction(value): - return "generator" - return "sync" - - -def _sdk_public_class_descriptor(value: type, name: str) -> object | None: - for owner in value.__mro__: - namespace = vars(owner) - if name not in namespace: - continue - owner_module = owner.__module__ - if owner is value or ( - isinstance(owner_module, str) - and (owner_module == "agents" or owner_module.startswith("agents.")) - ): - return cast(object, inspect.getattr_static(value, name)) - return None - return None - - -def _public_class_member_contract(value: object) -> dict[str, dict[str, object]]: - if not issubclass(type(value), type): - return {} - class_value = cast(type, value) - value_identity = f"{class_value.__module__}.{class_value.__qualname__}" - candidate_names: list[str] = [] - seen_names: set[str] = set() - - def add_candidate_names(namespace: Mapping[str, object]) -> None: - for name in namespace: - if name in seen_names: - continue - seen_names.add(name) - candidate_names.append(name) - - add_candidate_names(vars(class_value)) - for base in class_value.__mro__[1:]: - base_module = base.__module__ - if isinstance(base_module, str) and ( - base_module == "agents" or base_module.startswith("agents.") - ): - add_candidate_names(vars(base)) - members: dict[str, dict[str, object]] = {} - for name in candidate_names: - if name.startswith("_"): - continue - descriptor = _sdk_public_class_descriptor(class_value, name) - if descriptor is None: - continue - try: - member = _class_member_contract(descriptor) - except TypeError as error: - raise TypeError( - f"Unable to contract public method {value_identity}.{name}: {error}" - ) from None - if member is not None: - members[name] = member - return members - - -def _callable_contract(value: Callable[..., Any]) -> dict[str, Any]: - kind = _callable_kind(value) - if kind is None: - raise TypeError(f"Unsupported public callable type: {type(value)!r}") - contract: dict[str, Any] = { - "kind": kind, - "parameters": _parameter_contract(value), - "dataclass_fields": _dataclass_field_contract(value), - } - if kind == "function": - contract["execution_kind"] = _function_execution_kind(value) - model_fields = _pydantic_model_field_contract(value) - if model_fields is not None: - contract["model_fields"] = model_fields - enum_members = _enum_member_contract(value) - if enum_members is not None: - contract["enum_members"] = enum_members - if kind == "class": - contract["members"] = _public_class_member_contract(value) - return contract +from integration_tests import _contract_surface as surface, _contract_validation as validation def _merge_canonical_imports( @@ -828,10 +37,10 @@ def _merge_public_properties( existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] ) -> list[dict[str, Any]]: result = [deepcopy(dict(entry)) for entry in existing] - by_identity = {_public_property_identity(entry): entry for entry in result} + by_identity = {surface._public_property_identity(entry): entry for entry in result} for entry_value in promoted: entry = deepcopy(dict(entry_value)) - identity = _public_property_identity(entry) + identity = surface._public_property_identity(entry) previous = by_identity.get(identity) if previous is None: result.append(entry) @@ -870,14 +79,14 @@ def _merge_public_class_contracts( def _validate_voice_public_class_contract_policy( - release_policy: SubmoduleExportPolicy, + release_policy: surface.SubmoduleExportPolicy, agents_module: Any | None, ) -> None: voice_class_exports: list[tuple[Mapping[str, str], type[Any]]] = [] for entry in release_policy.canonical_imports: if entry["module"] != "agents.voice": continue - canonical_module = _import_contract_module(entry["canonical_module"], agents_module) + canonical_module = surface._import_contract_module(entry["canonical_module"], agents_module) value = getattr(canonical_module, entry["canonical_name"], None) if isinstance(value, type): voice_class_exports.append((entry, value)) @@ -924,618 +133,74 @@ def _validate_voice_public_class_contract_policy( ) -def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]: - if "class_name" in entry: - return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"])) - return ("factory_name", cast(str, entry["module"]), cast(str, entry["factory_name"])) - - -def _annotation_contract(annotation: object) -> str: - if isinstance(annotation, ForwardRef): - annotation_text = annotation.__forward_arg__ - elif isinstance(annotation, str): - annotation_text = annotation - else: - annotation_text = inspect.formatannotation(annotation) - for wrapper_name in ("Required", "NotRequired"): - for module_name in ("typing", "typing_extensions"): - qualified_prefix = f"{module_name}.{wrapper_name}[" - if annotation_text.startswith(qualified_prefix): - return f"{wrapper_name}[{annotation_text.removeprefix(qualified_prefix)}" - return annotation_text +def _merge_public_type_aliases( + existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] +) -> list[dict[str, Any]]: + result = [deepcopy(dict(entry)) for entry in existing] + identities = {(entry["module"], entry["name"]) for entry in result} + for entry_value in promoted: + entry = deepcopy(dict(entry_value)) + identity = (entry["module"], entry["name"]) + if identity not in identities: + result.append(entry) + identities.add(identity) + return result -def _sorted_type_alias_members(members: Iterable[dict[str, object]]) -> list[dict[str, object]]: - return sorted( - members, - key=lambda member: ( - cast(str, member["kind"]), - json.dumps(member, sort_keys=True, separators=(",", ":")), - ), - ) +def _merge_public_typed_dicts( + existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] +) -> list[dict[str, Any]]: + result = [deepcopy(dict(entry)) for entry in existing] + by_identity = {(entry["module"], entry["class_name"]): entry for entry in result} + for entry_value in promoted: + entry = deepcopy(dict(entry_value)) + identity = (entry["module"], entry["class_name"]) + previous = by_identity.get(identity) + if previous is None: + result.append(entry) + by_identity[identity] = entry + continue + previous_by_name = {field["name"]: field for field in previous["fields"]} + for field in entry["fields"]: + existing_field = previous_by_name.get(field["name"]) + if existing_field is not None and existing_field != field: + raise ValueError( + "release policy public TypedDict field conflicts with the released contract " + f"for {entry['module']}.{entry['class_name']}.{field['name']}" + ) + if existing_field is None: + previous["fields"].append(field) + previous_by_name[field["name"]] = field + return result -def _is_type_alias_type(value: object) -> bool: - native_type_alias_type = getattr(typing, "TypeAliasType", typing_extensions.TypeAliasType) - return isinstance(value, typing_extensions.TypeAliasType | native_type_alias_type) +def _preserve_released_callable_for_promotion( + contract: Mapping[str, Any], + callables: dict[str, Any], + qualified_name: str, + *, + fail_if_missing: bool, + unavailable_reason: str, +) -> None: + released_callable = contract["callables"].get(qualified_name) + if released_callable is None: + if not fail_if_missing: + return + raise ValueError( + f"Cannot promote new canonical callable {qualified_name} because " + f"{unavailable_reason}. Ensure the binding is available and exposes an inspectable " + "signature on the release preparation host." + ) + callables[qualified_name] = deepcopy(released_callable) -def _is_type_alias_annotation(annotation: object, module: object) -> bool: - if annotation is TypeAlias or annotation is typing_extensions.TypeAlias: - return True - if not isinstance(annotation, str): - return False - reference_parts = annotation.split(".") - if not reference_parts or not all(part.isidentifier() for part in reference_parts): - return False - missing = object() - resolved = getattr(module, reference_parts[0], missing) - for part in reference_parts[1:]: - if resolved is missing: - break - resolved = getattr(resolved, part, missing) - return resolved is TypeAlias or resolved is typing_extensions.TypeAlias - - -def _module_declares_type_alias(module: object, alias_name: str, value: object) -> bool: - annotations = getattr(module, "__annotations__", {}) - if not isinstance(annotations, Mapping) or alias_name not in annotations: - return False - missing = object() - return ( - _is_type_alias_annotation(annotations[alias_name], module) - and getattr(module, alias_name, missing) is value - ) - - -class _ModuleBindingVisitor(ast.NodeVisitor): - def __init__(self, name: str): - self.name = name - self.count = 0 - self.has_wildcard_import = False - self.from_imports: list[tuple[ast.ImportFrom, str]] = [] - self._bindings_target_module = True - - def _count(self, name: str | None) -> None: - if self._bindings_target_module: - self.count += name == self.name - - def _visit_nested_scope(self, body: list[ast.stmt]) -> None: - bindings_target_module = _scope_declares_global(body, self.name) - previous_bindings_target_module = self._bindings_target_module - self._bindings_target_module = bindings_target_module - for statement in body: - self.visit(statement) - self._bindings_target_module = previous_bindings_target_module - - def _visit_arguments(self, arguments: ast.arguments) -> None: - all_arguments = [ - *arguments.posonlyargs, - *arguments.args, - *arguments.kwonlyargs, - ] - if arguments.vararg is not None: - all_arguments.append(arguments.vararg) - if arguments.kwarg is not None: - all_arguments.append(arguments.kwarg) - for argument in all_arguments: - if argument.annotation is not None: - self.visit(argument.annotation) - for default in [*arguments.defaults, *arguments.kw_defaults]: - if default is not None: - self.visit(default) - - def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: - self._count(node.name) - for decorator in node.decorator_list: - self.visit(decorator) - self._visit_arguments(node.args) - if node.returns is not None: - self.visit(node.returns) - - def _visit_comprehension( - self, generators: list[ast.comprehension], values: list[ast.expr] - ) -> None: - for generator in generators: - self.visit(generator.iter) - for condition in generator.ifs: - self.visit(condition) - for value in values: - self.visit(value) - - def visit_Name(self, node: ast.Name) -> None: - if isinstance(node.ctx, ast.Store | ast.Del): - self._count(node.id) - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._visit_function_definition(node) - self._visit_nested_scope(node.body) - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._visit_function_definition(node) - self._visit_nested_scope(node.body) - - def visit_ClassDef(self, node: ast.ClassDef) -> None: - self._count(node.name) - for decorator in node.decorator_list: - self.visit(decorator) - for base in node.bases: - self.visit(base) - for keyword in node.keywords: - self.visit(keyword.value) - self._visit_nested_scope(node.body) - - def visit_Lambda(self, node: ast.Lambda) -> None: - self._visit_arguments(node.args) - - def visit_ListComp(self, node: ast.ListComp) -> None: - self._visit_comprehension(node.generators, [node.elt]) - - def visit_SetComp(self, node: ast.SetComp) -> None: - self._visit_comprehension(node.generators, [node.elt]) - - def visit_DictComp(self, node: ast.DictComp) -> None: - self._visit_comprehension(node.generators, [node.key, node.value]) - - def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: - self._visit_comprehension(node.generators, [node.elt]) - - def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: - self._count(node.name) - self.generic_visit(node) - - def visit_MatchAs(self, node: ast.MatchAs) -> None: - self._count(node.name) - self.generic_visit(node) - - def visit_MatchStar(self, node: ast.MatchStar) -> None: - self._count(node.name) - - def visit_MatchMapping(self, node: ast.MatchMapping) -> None: - self._count(node.rest) - self.generic_visit(node) - - def visit_Import(self, node: ast.Import) -> None: - for imported in node.names: - self._count(imported.asname or imported.name.split(".", 1)[0]) - - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: - if not self._bindings_target_module: - return - for imported in node.names: - if imported.name == "*": - self.has_wildcard_import = True - continue - binding_name = imported.asname or imported.name - self._count(binding_name) - if binding_name == self.name: - self.from_imports.append((node, imported.name)) - - -def _scope_declares_global(nodes: Iterable[ast.AST], name: str) -> bool: - for node in nodes: - if isinstance(node, ast.Global): - if name in node.names: - return True - continue - if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): - continue - if _scope_declares_global(ast.iter_child_nodes(node), name): - return True - return False - - -def _direct_import_source( - module: object, export_name: str, *, package_root: str -) -> tuple[ModuleType, str] | None: - module_name = getattr(module, "__name__", None) - package_name = getattr(module, "__package__", None) - if not isinstance(module_name, str) or not isinstance(package_name, str): - return None - try: - module_tree = ast.parse(inspect.getsource(module)) - except (OSError, SyntaxError, TypeError): - return None - - bindings = _ModuleBindingVisitor(export_name) - bindings.visit(module_tree) - if bindings.count != 1 or bindings.has_wildcard_import or len(bindings.from_imports) != 1: - return None - statement, source_name = bindings.from_imports[0] - if statement.level: - relative_name = "." * statement.level + (statement.module or "") - try: - source_module_name = importlib.util.resolve_name(relative_name, package_name) - except ImportError: - return None - else: - source_module_name = statement.module - if source_module_name is None or not ( - source_module_name == package_root or source_module_name.startswith(f"{package_root}.") - ): - return None - source_module = sys.modules.get(source_module_name) - if not isinstance(source_module, ModuleType): - return None - return source_module, source_name - - -def _has_explicit_type_alias_declaration( - agents_module: object, export_name: str, value: object -) -> bool: - package_root = getattr(agents_module, "__name__", None) - module, alias_name = agents_module, export_name - visited_bindings: set[tuple[int, str]] = set() - missing = object() - while (id(module), alias_name) not in visited_bindings: - visited_bindings.add((id(module), alias_name)) - if getattr(module, alias_name, missing) is not value: - return False - if _module_declares_type_alias(module, alias_name, value): - return True - if not isinstance(package_root, str): - return False - import_source = _direct_import_source(module, alias_name, package_root=package_root) - if import_source is None: - return False - module, alias_name = import_source - return False - - -def _is_public_type_alias(agents_module: object, export_name: str, value: object) -> bool: - return ( - get_origin(value) is not None - or _is_type_alias_type(value) - or _has_explicit_type_alias_declaration(agents_module, export_name, value) - ) - - -def _type_alias_definition( - value: object, *, visited_alias_ids: frozenset[int] = frozenset() -) -> dict[str, object]: - if value is Any: - return {"kind": "any"} - if _is_type_alias_type(value): - if value.__type_params__: - raise TypeError(f"generic public type alias is unsupported: {value.__name__}") - alias_id = id(value) - if alias_id in visited_alias_ids: - alias_name = getattr(value, "__name__", repr(value)) - raise TypeError(f"recursive public type alias is unsupported: {alias_name}") - try: - alias_value = value.__value__ - except Exception as error: - raise TypeError( - f"cannot resolve public type alias {value.__name__} at runtime: " - f"{type(error).__name__}: {error}" - ) from None - return _type_alias_definition(alias_value, visited_alias_ids=visited_alias_ids | {alias_id}) - origin = get_origin(value) - if origin is Literal: - literal_values: list[dict[str, object]] = [] - for literal_value in get_args(value): - literal_contract = _default_contract(literal_value) - if literal_contract["kind"] not in {"literal", "enum"}: - raise TypeError( - "public type alias Literal members must use supported literal or enum values" - ) - literal_values.append(literal_contract) - return { - "kind": "literal", - "values": _sorted_type_alias_members(literal_values), - } - if origin in {Union, UnionType}: - members = [ - _type_alias_definition(member, visited_alias_ids=visited_alias_ids) - for member in get_args(value) - ] - return { - "kind": "union", - "members": _sorted_type_alias_members(members), - } - if origin is Callable: - callable_args = get_args(value) - if len(callable_args) != 2: - raise TypeError( - "public Callable type aliases must declare parameters and a return type" - ) - parameter_types, return_type = callable_args - if parameter_types is Ellipsis or not isinstance(parameter_types, list | tuple): - raise TypeError("public Callable type aliases must declare explicit parameter types") - return { - "kind": "callable", - "parameters": [ - _type_alias_definition(parameter_type, visited_alias_ids=visited_alias_ids) - for parameter_type in parameter_types - ], - "return": _type_alias_definition(return_type, visited_alias_ids=visited_alias_ids), - } - if origin is not None: - if not isinstance(origin, type) or not ( - origin.__module__ == "agents" or origin.__module__.startswith("agents.") - ): - raise TypeError(f"unsupported public generic type alias origin: {origin!r}") - return { - "kind": "generic", - "origin": f"{origin.__module__}.{origin.__qualname__}", - "arguments": [ - _type_alias_definition(argument, visited_alias_ids=visited_alias_ids) - for argument in get_args(value) - ], - } - if isinstance(value, type) and ( - value.__module__ == "builtins" - or value.__module__ == "agents" - or value.__module__.startswith("agents.") - ): - return { - "kind": "type", - "identity": f"{value.__module__}.{value.__qualname__}", - } - raise TypeError(f"unsupported public type alias member: {value!r}") - - -def _public_type_alias_contract( - policy_entries: Iterable[Mapping[str, str]], - agents_module: Any | None, -) -> list[dict[str, object]]: - entries: list[dict[str, object]] = [] - missing = object() - for policy_entry in policy_entries: - module_name = policy_entry["module"] - alias_name = policy_entry["name"] - module = _import_contract_module(module_name, agents_module) - alias = getattr(module, alias_name, missing) - if alias is missing: - raise ValueError( - f"Cannot promote public type alias {module_name}.{alias_name} because it is missing" - ) - try: - definition = _type_alias_definition(alias) - except TypeError as error: - raise ValueError( - f"Cannot promote public type alias {module_name}.{alias_name}: {error}" - ) from None - entries.append({"definition": definition, "module": module_name, "name": alias_name}) - return entries - - -def _merge_public_type_aliases( - existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] -) -> list[dict[str, Any]]: - result = [deepcopy(dict(entry)) for entry in existing] - identities = {(entry["module"], entry["name"]) for entry in result} - for entry_value in promoted: - entry = deepcopy(dict(entry_value)) - identity = (entry["module"], entry["name"]) - if identity not in identities: - result.append(entry) - identities.add(identity) - return result - - -def _typed_dict_field_is_required(typed_dict: type, name: str, annotation: object) -> bool: - if isinstance(annotation, ForwardRef): - annotation_text = annotation.__forward_arg__ - if annotation_text.startswith( - ("Required[", "typing.Required[", "typing_extensions.Required[") - ): - return True - if annotation_text.startswith( - ("NotRequired[", "typing.NotRequired[", "typing_extensions.NotRequired[") - ): - return False - origin = get_origin(annotation) - if origin is Required: - return True - if origin is NotRequired: - return False - required_keys = getattr(typed_dict, "__required_keys__", frozenset()) - optional_keys = getattr(typed_dict, "__optional_keys__", frozenset()) - if name in required_keys: - return True - if name in optional_keys: - return False - return bool(getattr(typed_dict, "__total__", True)) - - -def _typed_dict_field_contract(typed_dict: type, name: str) -> dict[str, object] | None: - annotation = getattr(typed_dict, "__annotations__", {}).get(name) - if annotation is None: - return None - return { - "name": name, - "required": _typed_dict_field_is_required(typed_dict, name, annotation), - "annotation": _annotation_contract(annotation), - } - - -def _public_typed_dict_contract( - policy_entries: Iterable[Mapping[str, Any]], - agents_module: Any | None, -) -> list[dict[str, Any]]: - entries: list[dict[str, Any]] = [] - for policy_entry in policy_entries: - module_name = cast(str, policy_entry["module"]) - class_name = cast(str, policy_entry["class_name"]) - module = _import_contract_module(module_name, agents_module) - typed_dict = getattr(module, class_name, None) - if not typing_extensions.is_typeddict(typed_dict): - raise ValueError( - f"Cannot promote public TypedDict {module_name}.{class_name} because it is " - "missing or no longer a TypedDict" - ) - fields: list[dict[str, object]] = [] - for name in policy_entry["names"]: - field = _typed_dict_field_contract(typed_dict, name) - if field is None: - raise ValueError( - f"Cannot promote public TypedDict field {module_name}.{class_name}.{name} " - "because it is missing" - ) - fields.append(field) - entries.append({"class_name": class_name, "fields": fields, "module": module_name}) - return entries - - -def _merge_public_typed_dicts( - existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]] -) -> list[dict[str, Any]]: - result = [deepcopy(dict(entry)) for entry in existing] - by_identity = {(entry["module"], entry["class_name"]): entry for entry in result} - for entry_value in promoted: - entry = deepcopy(dict(entry_value)) - identity = (entry["module"], entry["class_name"]) - previous = by_identity.get(identity) - if previous is None: - result.append(entry) - by_identity[identity] = entry - continue - previous_by_name = {field["name"]: field for field in previous["fields"]} - for field in entry["fields"]: - existing_field = previous_by_name.get(field["name"]) - if existing_field is not None and existing_field != field: - raise ValueError( - "release policy public TypedDict field conflicts with the released contract " - f"for {entry['module']}.{entry['class_name']}.{field['name']}" - ) - if existing_field is None: - previous["fields"].append(field) - previous_by_name[field["name"]] = field - return result - - -def _optional_dependency_unsupported_platforms( - contract: Mapping[str, Any], -) -> dict[str, tuple[str, ...]]: - value = contract.get("optional_dependency_unsupported_platforms", {}) - if not isinstance(value, dict): - raise ValueError("optional_dependency_unsupported_platforms must be an object") - result: dict[str, tuple[str, ...]] = {} - for dependency_module, platforms in value.items(): - if type(dependency_module) is not str or not dependency_module: - raise ValueError( - "optional_dependency_unsupported_platforms keys must be non-empty strings" - ) - if ( - not isinstance(platforms, list) - or not all(type(platform) is str and platform for platform in platforms) - or len(platforms) != len(set(platforms)) - ): - raise ValueError( - "optional_dependency_unsupported_platforms values must be lists of unique " - "non-empty strings" - ) - result[dependency_module] = tuple(platforms) - return result - - -def _optional_dependency_is_available_for_contract( - dependency_module: str, - unsupported_platforms: Mapping[str, tuple[str, ...]], -) -> bool: - return not _optional_dependency_is_unsupported_for_contract( - dependency_module, unsupported_platforms - ) and _optional_dependency_is_available(dependency_module) - - -def _optional_dependency_is_unsupported_for_contract( - dependency_module: str, - unsupported_platforms: Mapping[str, tuple[str, ...]], -) -> bool: - return sys.platform in unsupported_platforms.get(dependency_module, ()) - - -def _optional_dependency_for_binding( - contract: Mapping[str, Any], module_name: str, binding_name: str -) -> str | None: - dependency = _optional_dependency_for_binding_in_modules( - contract.get("required_submodule_exports", {}), module_name, binding_name - ) - if dependency is not None: - return dependency - canonical_dependencies = { - _optional_dependency_for_binding_in_modules( - contract.get("required_submodule_exports", {}), entry["module"], entry["name"] - ) - for entry in contract.get("canonical_imports", []) - if entry["canonical_module"] == module_name and entry["canonical_name"] == binding_name - } - if canonical_dependencies and len(canonical_dependencies) == 1: - return next(iter(canonical_dependencies)) - return None - - -def _optional_dependency_for_binding_in_modules( - modules: Mapping[str, Any], module_name: str, binding_name: str -) -> str | None: - module_contract = modules.get(module_name, {}) - for field_name in ("optional_bindings", "optional_exports"): - dependency_module = module_contract.get(field_name, {}).get(binding_name) - if dependency_module is not None: - return cast(str, dependency_module) - return None - - -def _optional_dependency_for_module_import( - contract: Mapping[str, Any], module_name: str -) -> str | None: - modules = contract.get("required_submodule_exports", {}) - module_contract = modules.get(module_name, {}) - names = module_contract.get("names", []) - try: - optional_bindings = _optional_dependency_modules( - module_contract.get("optional_bindings", {}), field_name="optional_bindings" - ) - optional_exports = _optional_dependency_modules( - module_contract.get("optional_exports", {}), field_name="optional_exports" - ) - except ValueError: - return None - dependencies = {optional_bindings.get(name) or optional_exports.get(name) for name in names} - if names and len(dependencies) == 1 and None not in dependencies: - return cast(str, next(iter(dependencies))) - if names: - return None - canonical_dependencies = { - _optional_dependency_for_binding(contract, entry["module"], entry["name"]) - for entry in contract.get("canonical_imports", []) - if entry["canonical_module"] == module_name - } - if canonical_dependencies and len(canonical_dependencies) == 1: - dependency = next(iter(canonical_dependencies)) - if dependency is not None: - return dependency - return None - - -def _preserve_released_callable_for_promotion( - contract: Mapping[str, Any], - callables: dict[str, Any], - qualified_name: str, - *, - fail_if_missing: bool, - unavailable_reason: str, -) -> None: - released_callable = contract["callables"].get(qualified_name) - if released_callable is None: - if not fail_if_missing: - return - raise ValueError( - f"Cannot promote new canonical callable {qualified_name} because " - f"{unavailable_reason}. Ensure the binding is available and exposes an inspectable " - "signature on the release preparation host." - ) - callables[qualified_name] = deepcopy(released_callable) - - -def _preserve_released_submodule_callables( - contract: Mapping[str, Any], callables: dict[str, Any], module_name: str -) -> None: - for qualified_name, released_callable in contract["callables"].items(): - callable_module, _, _ = qualified_name.rpartition(".") - if callable_module == module_name: - callables.setdefault(qualified_name, deepcopy(released_callable)) +def _preserve_released_submodule_callables( + contract: Mapping[str, Any], callables: dict[str, Any], module_name: str +) -> None: + for qualified_name, released_callable in contract["callables"].items(): + callable_module, _, _ = qualified_name.rpartition(".") + if callable_module == module_name: + callables.setdefault(qualified_name, deepcopy(released_callable)) def build_released_api_contract( @@ -1544,14 +209,14 @@ def build_released_api_contract( baseline: str, baseline_commit: str, agents_module: Any | None = None, - release_policy: SubmoduleExportPolicy | None = None, + release_policy: surface.SubmoduleExportPolicy | None = None, ) -> dict[str, Any]: """Build the next rolling release contract from the current public surface.""" agents = agents_module or importlib.import_module("agents") if release_policy is not None: _validate_voice_public_class_contract_policy(release_policy, agents_module) - compatibility_errors = validate_released_api_contract(contract, agents_module=agents) + compatibility_errors = validation.validate_released_api_contract(contract, agents_module=agents) if compatibility_errors: details = "\n".join(f"- {error}" for error in compatibility_errors) raise ValueError(f"Cannot promote an incompatible released API contract:\n{details}") @@ -1578,7 +243,7 @@ def build_released_api_contract( missing_top_level_type_aliases = sorted( name for name in current_export_names - released_exports - if _is_public_type_alias(agents, name, getattr(agents, name)) + if surface._is_public_type_alias(agents, name, getattr(agents, name)) and name not in promoted_top_level_type_aliases ) if missing_top_level_type_aliases: @@ -1593,16 +258,16 @@ def build_released_api_contract( callables: dict[str, Any] = {} for name in ordered_exports: value = getattr(agents, name) - kind = _callable_kind(value) + kind = surface._callable_kind(value) should_track = name in tracked_callables if not should_track and kind is not None: try: - _signature(value) + surface._signature(value) except (TypeError, ValueError): continue should_track = True if should_track: - callables[name] = _callable_contract(value) + callables[name] = surface._callable_contract(value) canonical_imports = _merge_canonical_imports( contract["canonical_imports"], @@ -1627,16 +292,19 @@ def build_released_api_contract( qualified_name = f"{module_name}.{entry['name']}" is_new_canonical_import = entry not in contract["canonical_imports"] optional_dependency = ( - _optional_dependency_for_binding_in_modules( + surface._optional_dependency_for_binding_in_modules( release_policy.modules, module_name, entry["name"] ) if release_policy is not None else None ) - if optional_dependency is not None and not _optional_dependency_is_available_for_contract( - optional_dependency, policy_unsupported_platforms + if ( + optional_dependency is not None + and not surface._optional_dependency_is_available_for_contract( + optional_dependency, policy_unsupported_platforms + ) ): - if _optional_dependency_is_unsupported_for_contract( + if surface._optional_dependency_is_unsupported_for_contract( optional_dependency, policy_unsupported_platforms ): _preserve_released_callable_for_promotion( @@ -1651,9 +319,9 @@ def build_released_api_contract( ) continue try: - module = _import_contract_module(module_name, agents_module) + module = surface._import_contract_module(module_name, agents_module) except Exception as error: - if _matches_platform_import_error(contract, module_name, error): + if surface._matches_platform_import_error(contract, module_name, error): _preserve_released_callable_for_promotion( contract, callables, @@ -1668,9 +336,11 @@ def build_released_api_contract( value = getattr(module, entry["name"], None) if value is None: try: - _import_contract_module(entry["canonical_module"], agents_module) + surface._import_contract_module(entry["canonical_module"], agents_module) except Exception as error: - if _matches_platform_import_error(contract, entry["canonical_module"], error): + if surface._matches_platform_import_error( + contract, entry["canonical_module"], error + ): _preserve_released_callable_for_promotion( contract, callables, @@ -1686,14 +356,14 @@ def build_released_api_contract( continue if id(value) in top_level_callable_ids: continue - kind = _callable_kind(value) + kind = surface._callable_kind(value) if kind is None: continue try: - _signature(value) + surface._signature(value) except (TypeError, ValueError): continue - callables[qualified_name] = _callable_contract(value) + callables[qualified_name] = surface._callable_contract(value) updated = deepcopy(contract) updated["baseline"] = baseline @@ -1710,13 +380,13 @@ def build_released_api_contract( ) updated["public_type_aliases"] = _merge_public_type_aliases( contract.get("public_type_aliases", []), - _public_type_alias_contract(release_policy.public_type_aliases, agents_module) + surface._public_type_alias_contract(release_policy.public_type_aliases, agents_module) if release_policy is not None else (), ) updated["public_typed_dicts"] = _merge_public_typed_dicts( contract.get("public_typed_dicts", []), - _public_typed_dict_contract(release_policy.public_typed_dicts, agents_module) + surface._public_typed_dict_contract(release_policy.public_typed_dicts, agents_module) if release_policy is not None else (), ) @@ -1746,13 +416,13 @@ def build_released_api_contract( dependency_module for module_policy in submodule_export_policy.values() for field_name in ("optional_bindings", "optional_exports") - for dependency_module in _optional_dependency_modules( + for dependency_module in surface._optional_dependency_modules( dict(module_policy.get(field_name, {})), field_name=field_name ).values() - if not _optional_dependency_is_unsupported_for_contract( + if not surface._optional_dependency_is_unsupported_for_contract( dependency_module, policy_unsupported_platforms ) - and not _optional_dependency_is_available(dependency_module) + and not surface._optional_dependency_is_available(dependency_module) } ) if unavailable_policy_dependencies: @@ -1768,9 +438,9 @@ def build_released_api_contract( if module_name == "agents" or module_name in excluded_submodule_exports: continue try: - module = _import_contract_module(module_name, agents_module) + module = surface._import_contract_module(module_name, agents_module) except Exception as error: - if _matches_platform_import_error(contract, module_name, error): + if surface._matches_platform_import_error(contract, module_name, error): _preserve_released_submodule_callables(contract, callables, module_name) continue if submodule_export_policy is not None and module_name in submodule_export_policy: @@ -1784,15 +454,15 @@ def build_released_api_contract( module_policy = submodule_export_policy.get(module_name, {}) allowed_missing_optional_exports = { name - for name, dependency_module in _optional_dependency_modules( + for name, dependency_module in surface._optional_dependency_modules( dict(module_policy.get("optional_exports", {})), field_name="optional_exports", ).items() - if _optional_dependency_is_unsupported_for_contract( + if surface._optional_dependency_is_unsupported_for_contract( dependency_module, policy_unsupported_platforms ) } - module_contract = _submodule_export_contract( + module_contract = surface._submodule_export_contract( module, optional_bindings=module_policy.get("optional_bindings", {}), optional_exports=module_policy.get("optional_exports", {}), @@ -1806,11 +476,11 @@ def build_released_api_contract( was_tracked = qualified_name in tracked_callables if name in released_names and not was_tracked: continue - optional_dependency = _optional_dependency_for_binding_in_modules( + optional_dependency = surface._optional_dependency_for_binding_in_modules( {module_name: module_contract}, module_name, name ) if optional_dependency is not None and not ( - _optional_dependency_is_available_for_contract( + surface._optional_dependency_is_available_for_contract( optional_dependency, policy_unsupported_platforms ) ): @@ -1820,21 +490,21 @@ def build_released_api_contract( value = getattr(module, name, None) if value is None: continue - if not was_tracked and not _is_sdk_owned_callable(value): + if not was_tracked and not surface._is_sdk_owned_callable(value): continue - kind = _callable_kind(value) + kind = surface._callable_kind(value) if kind is None: continue try: - _signature(value) + surface._signature(value) except (TypeError, ValueError): if was_tracked: callables[qualified_name] = deepcopy(contract["callables"][qualified_name]) continue - callables[qualified_name] = _callable_contract(value) + callables[qualified_name] = surface._callable_contract(value) updated["required_submodule_exports"] = required_submodule_exports - updated_errors = validate_released_api_contract(updated, agents_module=agents) + updated_errors = validation.validate_released_api_contract(updated, agents_module=agents) if updated_errors: details = "\n".join(f"- {error}" for error in updated_errors) raise ValueError(f"Cannot promote an invalid released API contract:\n{details}") @@ -1857,986 +527,3 @@ def build_released_api_contract( if baseline != contract["baseline"] or surface_changed: updated["baseline_commit"] = baseline_commit return updated - - -def _validate_parameter_contract( - name: str, - released: list[dict[str, object]], - current: list[dict[str, object]], -) -> list[str]: - errors: list[str] = [] - positional_kinds = {"POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"} - released_positional = [entry for entry in released if entry["kind"] in positional_kinds] - current_positional = [entry for entry in current if entry["kind"] in positional_kinds] - if current_positional[: len(released_positional)] != released_positional: - errors.append( - f"{name} changed its released positional parameter prefix: " - f"expected {released_positional!r}, got {current_positional!r}" - ) - elif any(entry["kind"] == "VAR_POSITIONAL" for entry in released) and len( - current_positional - ) != len(released_positional): - added = current_positional[len(released_positional) :] - errors.append( - f"{name} added positional parameters before its released variadic parameter: {added!r}" - ) - - current_by_name = {entry["name"]: entry for entry in current} - for entry in released: - if entry["kind"] in positional_kinds: - continue - current_entry = current_by_name.get(entry["name"]) - if current_entry != entry: - errors.append( - f"{name}.{entry['name']} changed its released parameter contract: " - f"expected {entry!r}, got {current_entry!r}" - ) - released_names = {entry["name"] for entry in released} - for entry in current: - if entry["name"] in released_names: - continue - if entry["kind"] in {"VAR_POSITIONAL", "VAR_KEYWORD"}: - continue - default = entry["default"] - if isinstance(default, dict) and default.get("kind") == "required": - errors.append(f"{name}.{entry['name']} added a required parameter") - return errors - - -def _validate_pydantic_model_field_contract( - name: str, - released: list[dict[str, object]], - current: list[dict[str, object]] | None, -) -> list[str]: - errors: list[str] = [] - current_by_name = {cast(str, entry["name"]): entry for entry in current or []} - for entry in released: - current_entry = current_by_name.get(cast(str, entry["name"])) - if current_entry != entry: - errors.append( - f"{name}.{entry['name']} changed its released Pydantic model field contract: " - f"expected {entry!r}, got {current_entry!r}" - ) - released_names = {entry["name"] for entry in released} - for entry in current or []: - if entry["name"] in released_names: - continue - default = entry["default"] - if isinstance(default, dict) and default.get("kind") == "required": - errors.append(f"{name}.{entry['name']} added a required Pydantic model field") - return errors - - -def _import_contract_module(module_name: str, agents_module: Any | None) -> Any: - if module_name == "agents" and agents_module is not None: - return agents_module - return importlib.import_module(module_name) - - -def _validate_public_property_contract( - contract: dict[str, Any], - agents_module: Any | None, - *, - unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, -) -> list[str]: - errors: list[str] = [] - unsupported_platforms = unsupported_platforms or {} - for entry in contract.get("public_properties", []): - module_name = entry["module"] - owner_name = entry.get("class_name", entry.get("factory_name")) - optional_dependency = _optional_dependency_for_binding(contract, module_name, owner_name) - if optional_dependency is not None and not _optional_dependency_is_available_for_contract( - optional_dependency, unsupported_platforms - ): - continue - try: - module = _import_contract_module(module_name, agents_module) - except Exception as error: - errors.append(f"Failed to import released module {module_name}: {error!r}") - continue - if "class_name" in entry: - class_value = getattr(module, owner_name, None) - if not isinstance(class_value, type): - errors.append(f"Missing released public class {module_name}.{owner_name}") - continue - else: - factory = getattr(module, owner_name, None) - if not callable(factory): - errors.append(f"Missing released public factory {module_name}.{owner_name}") - continue - try: - class_value = get_type_hints(factory)["return"] - except (KeyError, NameError, TypeError) as error: - errors.append( - f"Unable to resolve released public factory return type " - f"{module_name}.{owner_name}: {error!r}" - ) - continue - if not isinstance(class_value, type): - errors.append( - f"Released public factory {module_name}.{owner_name} no longer returns a class" - ) - continue - for property_name in entry["names"]: - descriptor = inspect.getattr_static(class_value, property_name, None) - if not isinstance(descriptor, property): - errors.append( - f"{module_name}.{owner_name}.{property_name} " - "removed or changed a released public property" - ) - return errors - - -def _validate_public_class_contract( - contract: dict[str, Any], - agents_module: Any | None, - *, - unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, -) -> list[str]: - errors: list[str] = [] - unsupported_platforms = unsupported_platforms or {} - for entry in contract.get("public_class_contracts", []): - module_name = entry["module"] - class_name = entry["class_name"] - optional_dependency = _optional_dependency_for_binding(contract, module_name, class_name) - if optional_dependency is not None and not _optional_dependency_is_available_for_contract( - optional_dependency, unsupported_platforms - ): - continue - try: - module = _import_contract_module(module_name, agents_module) - except Exception as error: - errors.append(f"Failed to import released module {module_name}: {error!r}") - continue - class_value = getattr(module, class_name, None) - if not isinstance(class_value, type): - errors.append(f"Missing released public class {module_name}.{class_name}") - continue - if "abstract" in entry and inspect.isabstract(class_value) != entry["abstract"]: - expected_state = "abstract" if entry["abstract"] else "concrete" - current_state = "abstract" if inspect.isabstract(class_value) else "concrete" - errors.append( - f"{module_name}.{class_name} changed its released public class state: " - f"expected {expected_state}, got {current_state}" - ) - if "abstract_members" in entry: - current_members = sorted(getattr(class_value, "__abstractmethods__", ())) - if current_members != entry["abstract_members"]: - errors.append( - f"{module_name}.{class_name} changed its released public abstract members: " - f"expected {entry['abstract_members']!r}, got {current_members!r}" - ) - return errors - - -def _validate_public_typed_dict_contract( - contract: dict[str, Any], - agents_module: Any | None, - *, - unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, -) -> list[str]: - errors: list[str] = [] - unsupported_platforms = unsupported_platforms or {} - for entry in contract.get("public_typed_dicts", []): - module_name = entry["module"] - class_name = entry["class_name"] - optional_dependency = _optional_dependency_for_binding(contract, module_name, class_name) - if optional_dependency is not None and not _optional_dependency_is_available_for_contract( - optional_dependency, unsupported_platforms - ): - continue - try: - module = _import_contract_module(module_name, agents_module) - except Exception as error: - errors.append(f"Failed to import released module {module_name}: {error!r}") - continue - typed_dict = getattr(module, class_name, None) - if not typing_extensions.is_typeddict(typed_dict): - errors.append(f"Missing released public TypedDict {module_name}.{class_name}") - continue - for released_field in entry["fields"]: - current_field = _typed_dict_field_contract(typed_dict, released_field["name"]) - if current_field != released_field: - errors.append( - f"{module_name}.{class_name}.{released_field['name']} changed its released " - f"TypedDict field contract: expected {released_field!r}, got " - f"{current_field!r}" - ) - return errors - - -def _validate_public_type_alias_contract( - contract: dict[str, Any], - agents_module: Any | None, - *, - unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, -) -> list[str]: - errors: list[str] = [] - unsupported_platforms = unsupported_platforms or {} - missing = object() - for entry in contract.get("public_type_aliases", []): - module_name = entry["module"] - alias_name = entry["name"] - optional_dependency = _optional_dependency_for_binding(contract, module_name, alias_name) - if optional_dependency is not None and not _optional_dependency_is_available_for_contract( - optional_dependency, unsupported_platforms - ): - continue - try: - module = _import_contract_module(module_name, agents_module) - except Exception as error: - errors.append(f"Failed to import released module {module_name}: {error!r}") - continue - alias = getattr(module, alias_name, missing) - if alias is missing: - errors.append(f"Missing released public type alias {module_name}.{alias_name}") - continue - try: - current_definition = _type_alias_definition(alias) - except TypeError as error: - errors.append( - f"{module_name}.{alias_name} no longer has a supported released public type " - f"alias definition: {error}" - ) - continue - if current_definition != entry["definition"]: - errors.append( - f"{module_name}.{alias_name} changed its released public type alias: " - f"expected {entry['definition']!r}, got {current_definition!r}" - ) - return errors - - -def _submodule_export_contract( - module: object, - *, - optional_bindings: Mapping[str, str] | None = None, - optional_exports: Mapping[str, str] | None = None, - allowed_missing_optional_exports: Iterable[str] = (), -) -> dict[str, Any] | None: - exports = getattr(module, "__all__", None) - if exports is None: - return None - if not isinstance(exports, list | tuple) or not all(type(name) is str for name in exports): - raise ValueError("public module __all__ must contain only strings") - names = list(exports) - if len(names) != len(set(names)): - raise ValueError("public module __all__ must not contain duplicate exports") - optional_binding_modules = _optional_dependency_modules( - dict(optional_bindings or {}), field_name="optional_bindings" - ) - optional_export_modules = _optional_dependency_modules( - dict(optional_exports or {}), field_name="optional_exports" - ) - optional_binding_names = set(optional_binding_modules) - optional_export_names = set(optional_export_modules) - allowed_missing_names = set(allowed_missing_optional_exports) - unknown_optional_names = sorted( - (optional_binding_names | optional_export_names) - set(names) - allowed_missing_names - ) - if unknown_optional_names: - raise ValueError( - f"optional submodule bindings are not exported: {unknown_optional_names!r}" - ) - names.extend( - name - for name in optional_export_modules - if name in allowed_missing_names and name not in names - ) - return { - "names": names, - "optional_bindings": { - name: optional_binding_modules[name] for name in names if name in optional_binding_names - }, - "optional_exports": { - name: optional_export_modules[name] for name in names if name in optional_export_names - }, - } - - -def _optional_dependency_modules(value: object, *, field_name: str) -> dict[str, str]: - if not isinstance(value, dict): - raise ValueError( - f"{field_name} must be an object mapping export names to dependency modules" - ) - modules: dict[str, str] = {} - for name, module_name in value.items(): - if type(name) is not str or not name: - raise ValueError(f"{field_name} export names must be non-empty strings") - if type(module_name) is not str or not module_name.strip(): - raise ValueError(f"{field_name} dependency for {name!r} must be a non-empty string") - modules[name] = module_name - return modules - - -def _optional_dependency_is_available(module_name: str) -> bool: - if module_name in sys.modules: - return sys.modules[module_name] is not None - return find_spec(module_name) is not None - - -def _matches_platform_import_error( - contract: dict[str, Any], module_name: str, error: Exception -) -> bool: - allowed_error_types = {"ImportError": ImportError} - for entry in contract.get("platform_import_errors", []): - if entry["module"] != module_name or sys.platform not in entry["platforms"]: - continue - expected_error_type = allowed_error_types.get(entry["error_type"]) - return ( - expected_error_type is not None - and type(error) is expected_error_type - and entry["message_contains"] in str(error) - ) - return False - - -def validate_released_api_contract( - contract: dict[str, Any], - *, - agents_module: Any | None = None, -) -> list[str]: - agents = agents_module or importlib.import_module("agents") - errors: list[str] = [] - - try: - unsupported_platforms = _optional_dependency_unsupported_platforms(contract) - except ValueError as error: - errors.append(f"Invalid released optional dependency platform declarations: {error}") - unsupported_platforms = {} - - errors.extend( - _validate_public_class_contract( - contract, - agents_module, - unsupported_platforms=unsupported_platforms, - ) - ) - errors.extend( - _validate_public_property_contract( - contract, - agents_module, - unsupported_platforms=unsupported_platforms, - ) - ) - errors.extend( - _validate_public_type_alias_contract( - contract, - agents_module, - unsupported_platforms=unsupported_platforms, - ) - ) - errors.extend( - _validate_public_typed_dict_contract( - contract, - agents_module, - unsupported_platforms=unsupported_platforms, - ) - ) - - missing_exports = sorted(set(contract["required_top_level_exports"]) - set(agents.__all__)) - if missing_exports: - errors.append(f"Missing released top-level exports: {missing_exports!r}") - missing_bindings = sorted( - name for name in contract["required_top_level_exports"] if not hasattr(agents, name) - ) - if missing_bindings: - errors.append(f"Missing released top-level bindings: {missing_bindings!r}") - - imported_modules: dict[str, object] = {"agents": agents} - for module_name in contract["public_modules"]: - try: - imported_modules[module_name] = _import_contract_module(module_name, agents_module) - except Exception as error: - if _matches_platform_import_error(contract, module_name, error): - continue - optional_dependency = _optional_dependency_for_module_import(contract, module_name) - if optional_dependency is not None and not ( - _optional_dependency_is_available_for_contract( - optional_dependency, unsupported_platforms - ) - ): - continue - errors.append(f"Failed to import released module {module_name}: {error!r}") - - for module_name, released in contract.get("required_submodule_exports", {}).items(): - module = imported_modules.get(module_name) - if module is None: - continue - try: - current = _submodule_export_contract(module) - except ValueError as error: - errors.append(f"Invalid released module exports for {module_name}: {error}") - continue - if current is None: - errors.append(f"Released module {module_name} no longer defines __all__") - continue - try: - optional_exports = _optional_dependency_modules( - released.get("optional_exports", {}), field_name="optional_exports" - ) - optional_bindings = _optional_dependency_modules( - released.get("optional_bindings", {}), field_name="optional_bindings" - ) - except ValueError as error: - errors.append( - f"Invalid released {module_name} optional dependency declarations: {error}" - ) - continue - unknown_optional_names = sorted( - (set(optional_bindings) | set(optional_exports)) - set(released["names"]) - ) - if unknown_optional_names: - errors.append( - f"Invalid released {module_name} optional dependency declarations: " - f"names are not exported: {unknown_optional_names!r}" - ) - continue - try: - unsupported_optional_exports = { - name - for name, dependency_module in optional_exports.items() - if _optional_dependency_is_unsupported_for_contract( - dependency_module, unsupported_platforms - ) - } - unsupported_optional_bindings = { - name - for name, dependency_module in (optional_bindings | optional_exports).items() - if _optional_dependency_is_unsupported_for_contract( - dependency_module, unsupported_platforms - ) - } - unavailable_optional_exports = { - name - for name, dependency_module in optional_exports.items() - if not _optional_dependency_is_available_for_contract( - dependency_module, unsupported_platforms - ) - } - unavailable_optional_bindings = { - name - for name, dependency_module in (optional_bindings | optional_exports).items() - if not _optional_dependency_is_available_for_contract( - dependency_module, unsupported_platforms - ) - } - except (AttributeError, ImportError, ValueError) as error: - errors.append( - f"Unable to inspect released {module_name} optional dependencies: {error!r}" - ) - continue - current_names = set(current["names"]) - for name in sorted(unavailable_optional_exports & current_names): - try: - getattr(module, name) - except (AttributeError, ImportError): - if name in unsupported_optional_exports: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ on an unsupported platform but its " - "binding is unavailable" - ) - else: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ but its binding is unavailable; " - "declare it in optional_bindings instead of optional_exports" - ) - else: - if name not in unsupported_optional_exports: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ and its binding resolves; remove its " - "optional declaration or correct its dependency module" - ) - binding_only_names = set(optional_bindings) - set(optional_exports) - for name in sorted(unavailable_optional_bindings & binding_only_names): - if name not in current_names: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} is absent from __all__; declare it in optional_exports " - "instead of optional_bindings" - ) - continue - try: - getattr(module, name) - except (AttributeError, ImportError): - if name in unsupported_optional_bindings: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ on an unsupported platform but its " - "binding is unavailable" - ) - else: - if name not in unsupported_optional_bindings: - errors.append( - f"Invalid released {module_name} optional dependency declaration: " - f"{name!r} remains in __all__ and its binding resolves; remove its " - "optional declaration or correct its dependency module" - ) - missing_names = sorted( - set(released["names"]) - unavailable_optional_exports - current_names - ) - if missing_names: - errors.append(f"Missing released {module_name} exports: {missing_names!r}") - missing_required_bindings = [] - for name in released["names"]: - if name in unavailable_optional_bindings: - continue - try: - getattr(module, name) - except (AttributeError, ImportError): - missing_required_bindings.append(name) - if missing_required_bindings: - errors.append( - f"Missing released {module_name} bindings: {sorted(missing_required_bindings)!r}" - ) - - for entry in contract["canonical_imports"]: - optional_dependency = _optional_dependency_for_binding( - contract, entry["module"], entry["name"] - ) - if optional_dependency is not None and not _optional_dependency_is_available_for_contract( - optional_dependency, unsupported_platforms - ): - continue - try: - module = _import_contract_module(entry["module"], agents_module) - except Exception as error: - if _matches_platform_import_error(contract, entry["module"], error): - continue - errors.append(f"Failed to import released module {entry['module']}: {error!r}") - continue - try: - canonical = _import_contract_module(entry["canonical_module"], agents_module) - except Exception as error: - if _matches_platform_import_error(contract, entry["canonical_module"], error): - continue - errors.append( - f"Failed to import released module {entry['canonical_module']}: {error!r}" - ) - continue - missing = object() - actual = getattr(module, entry["name"], missing) - expected = getattr(canonical, entry["canonical_name"], missing) - if actual is missing or expected is missing or actual is not expected: - errors.append( - f"{entry['module']}.{entry['name']} no longer resolves to " - f"{entry['canonical_module']}.{entry['canonical_name']}" - ) - - for name, released in contract["callables"].items(): - if name.startswith("agents."): - module_name, _, binding_name = name.rpartition(".") - optional_dependency = _optional_dependency_for_binding( - contract, module_name, binding_name - ) - if optional_dependency is not None and not ( - _optional_dependency_is_available_for_contract( - optional_dependency, unsupported_platforms - ) - ): - continue - try: - module = _import_contract_module(module_name, agents_module) - except Exception as error: - if _matches_platform_import_error(contract, module_name, error): - continue - errors.append(f"Failed to import released module {module_name}: {error!r}") - continue - value = getattr(module, binding_name, None) - if value is None: - canonical_entry = next( - ( - entry - for entry in contract["canonical_imports"] - if entry["module"] == module_name and entry["name"] == binding_name - ), - None, - ) - if canonical_entry is not None: - try: - _import_contract_module(canonical_entry["canonical_module"], agents_module) - except Exception as error: - if _matches_platform_import_error( - contract, canonical_entry["canonical_module"], error - ): - continue - else: - module_name = "agents" - binding_name = name - value = getattr(agents, binding_name, None) - if value is None: - errors.append(f"Missing released callable {module_name}.{binding_name}") - continue - current_kind = _callable_kind(value) - if current_kind != released["kind"]: - errors.append( - f"Released callable {module_name}.{binding_name} changed kind from " - f"{released['kind']} to {current_kind or type(value).__name__}" - ) - continue - released_execution_kind = released.get("execution_kind") - if released_execution_kind is not None: - current_execution_kind = _function_execution_kind(value) - if current_execution_kind != released_execution_kind: - errors.append( - f"{name} changed execution from " - f"{released_execution_kind} to {current_execution_kind}" - ) - current_parameters = _parameter_contract(value) - errors.extend( - _validate_parameter_contract(name, released["parameters"], current_parameters) - ) - current_fields = _dataclass_field_contract(value) - released_fields = released["dataclass_fields"] - if current_fields[: len(released_fields)] != released_fields: - errors.append( - f"{name} changed its released dataclass field prefix: " - f"expected {released_fields!r}, got {current_fields!r}" - ) - for field in current_fields[len(released_fields) :]: - default = field["default"] - if field["init"] and isinstance(default, dict) and default.get("kind") == "required": - errors.append(f"{name}.{field['name']} added a required dataclass field") - released_model_fields = released.get("model_fields") - if released_model_fields is not None: - errors.extend( - _validate_pydantic_model_field_contract( - name, - cast(list[dict[str, object]], released_model_fields), - _pydantic_model_field_contract(value), - ) - ) - for member_name, released_member in released.get("members", {}).items(): - descriptor = _sdk_public_class_descriptor(value, member_name) - current_member = _class_member_contract(descriptor) - if current_member is None: - errors.append(f"{name}.{member_name} removed a released public method") - continue - if current_member["binding"] != released_member["binding"]: - errors.append( - f"{name}.{member_name} changed binding from " - f"{released_member['binding']} to {current_member['binding']}" - ) - continue - released_execution_kind = released_member.get("execution_kind") - if ( - released_execution_kind is not None - and current_member["execution_kind"] != released_execution_kind - ): - errors.append( - f"{name}.{member_name} changed execution from " - f"{released_execution_kind} to {current_member['execution_kind']}" - ) - errors.extend( - _validate_parameter_contract( - f"{name}.{member_name}", - released_member["parameters"], - cast(list[dict[str, object]], current_member["parameters"]), - ) - ) - released_enum_members = released.get("enum_members") - if released_enum_members is not None: - current_enum_members = _enum_member_contract(value) - if current_enum_members is None: - errors.append(f"{name} is no longer an enum") - continue - current_enum_members_by_name = { - member["name"]: member["value"] for member in current_enum_members - } - for member in released_enum_members: - member_name = member["name"] - if member_name not in current_enum_members_by_name: - errors.append(f"{name}.{member_name} removed or renamed a released enum member") - continue - current_value = current_enum_members_by_name[member_name] - if current_value != member["value"]: - errors.append( - f"{name}.{member_name} changed its released enum value: " - f"expected {member['value']!r}, got {current_value!r}" - ) - - return errors - - -def _normalized_durable_state(payload: dict[str, Any]) -> dict[str, Any]: - normalized = deepcopy(payload) - normalized.pop("$schemaVersion", None) - return normalized - - -def _normalize_legacy_mount_credentials(payload: dict[str, Any]) -> dict[str, Any]: - from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY - - normalized = deepcopy(payload) - sandbox = cast(dict[str, Any], normalized["sandbox"]) - session_states = [cast(dict[str, Any], sandbox["session_state"])] - sessions_by_agent = cast(dict[str, dict[str, Any]], sandbox["sessions_by_agent"]) - session_states.extend( - cast(dict[str, Any], entry["session_state"]) for entry in sessions_by_agent.values() - ) - for session_state in session_states: - manifest = cast(dict[str, Any], session_state["manifest"]) - entries = cast(dict[str, dict[str, Any]], manifest["entries"]) - mount = entries["remote"] - mount["access_key_id"] = None - mount["secret_access_key"] = None - mount["session_token"] = None - strategy = cast(dict[str, Any], mount["mount_strategy"]) - strategy["driver_options"] = {} - session_state[REDACTED_MOUNT_AUTHORITY_KEY] = True - return normalized - - -def _legacy_driver_option_errors(payload: dict[str, Any]) -> list[str]: - sandbox = cast(dict[str, Any], payload["sandbox"]) - session_states = [("sandbox.session_state", cast(dict[str, Any], sandbox["session_state"]))] - sessions_by_agent = cast(dict[str, dict[str, Any]], sandbox["sessions_by_agent"]) - session_states.extend( - ( - f"sandbox.sessions_by_agent.{agent_id}.session_state", - cast(dict[str, Any], entry["session_state"]), - ) - for agent_id, entry in sessions_by_agent.items() - ) - errors: list[str] = [] - for path, session_state in session_states: - manifest = cast(dict[str, Any], session_state["manifest"]) - entries = cast(dict[str, dict[str, Any]], manifest["entries"]) - mount = entries["remote"] - strategy = cast(dict[str, Any], mount["mount_strategy"]) - if strategy.get("driver_options") != {}: - errors.append(f"{path}.manifest.entries.remote.mount_strategy.driver_options remained") - return errors - - -def _find_subset_errors(expected: object, actual: object, path: str = "state") -> list[str]: - if isinstance(expected, dict): - if not isinstance(actual, dict): - return [f"{path} changed type from mapping to {type(actual).__name__}"] - errors: list[str] = [] - for key, value in expected.items(): - if key not in actual: - errors.append(f"{path}.{key} was dropped") - continue - errors.extend(_find_subset_errors(value, actual[key], f"{path}.{key}")) - return errors - if isinstance(expected, list): - if not isinstance(actual, list): - return [f"{path} changed type from list to {type(actual).__name__}"] - if len(expected) != len(actual): - return [f"{path} changed length from {len(expected)} to {len(actual)}"] - errors = [] - for index, (expected_item, actual_item) in enumerate(zip(expected, actual, strict=True)): - errors.extend(_find_subset_errors(expected_item, actual_item, f"{path}[{index}]")) - return errors - if type(expected) is not type(actual): - return [f"{path} changed type from {type(expected).__name__} to {type(actual).__name__}"] - if expected != actual: - return [f"{path} changed from {expected!r} to {actual!r}"] - return [] - - -def _restore_agent(payload: dict[str, Any]) -> Any: - from agents import Agent, handoff - - current_agent = payload.get("current_agent") - name = ( - current_agent.get("name", "compat-agent") - if isinstance(current_agent, dict) - else "compat-agent" - ) - identity = current_agent.get("identity") if isinstance(current_agent, dict) else None - if identity == f"{name}#2": - duplicate = Agent(name=name) - return Agent(name=name, handoffs=[handoff(duplicate)]) - return Agent(name=name) - - -async def validate_historical_run_state_fixture(path: Path) -> list[str]: - from agents import RunState - from agents.run_state import CURRENT_SCHEMA_VERSION - - errors: list[str] = [] - payload = json.loads(path.read_text(encoding="utf-8")) - historical = deepcopy(payload) - original_version = historical.get("$schemaVersion") - agent = _restore_agent(historical) - restored = await RunState.from_json(agent, payload) - canonical = restored.to_json() - - if canonical.get("$schemaVersion") != CURRENT_SCHEMA_VERSION: - errors.append( - f"{path.name} rewrote as {canonical.get('$schemaVersion')!r}, " - f"expected {CURRENT_SCHEMA_VERSION!r}" - ) - semantic_errors = _find_subset_errors( - _normalized_durable_state(historical), - _normalized_durable_state(canonical), - ) - errors.extend(f"{path.name}: {error}" for error in semantic_errors) - - expected_canonical = deepcopy(canonical) - rerestored = await RunState.from_json(agent, deepcopy(canonical)) - recanonical = rerestored.to_json() - if recanonical != expected_canonical: - errors.append( - f"{path.name} was not idempotent after rewriting schema {original_version!r} " - f"to {CURRENT_SCHEMA_VERSION!r}" - ) - return errors - - -async def validate_historical_resume_behavior( - path: Path, - *, - feature: str, - decision: str | None = None, -) -> list[str]: - from openai.types.responses import ( - ResponseFunctionToolCall, - ResponseOutputMessage, - ResponseOutputText, - ) - - from agents import Agent, Runner, RunState, function_tool - from agents.items import ToolCallOutputItem, TResponseOutputItem - from agents.testing import ModelStep, ScriptedModel - - invocation_count = 0 - if feature == "canonical_invocation_identity": - - def lookup_account(account_id: str) -> str: - nonlocal invocation_count - invocation_count += 1 - return f"approved:{account_id}" - - tool = function_tool(lookup_account, needs_approval=True) - model_turns: list[list[TResponseOutputItem]] = [ - [ - ResponseFunctionToolCall( - type="function_call", - name="lookup_account", - call_id="function-request-1", - status="completed", - arguments='{"account_id":"account-1"}', - ) - ] - ] - expected_invocations = 1 - expected_tool_output = "approved:account-1" - elif feature == "pending_tool_approval": - - def historical_approval(account_id: str) -> str: - nonlocal invocation_count - invocation_count += 1 - return f"approved:{account_id}" - - tool = function_tool(historical_approval, needs_approval=True) - model_turns = [] - if decision == "approve": - expected_invocations = 1 - expected_tool_output = "approved:account-1" - elif decision == "reject": - expected_invocations = 0 - expected_tool_output = "Candidate rejected historical approval" - else: - raise ValueError("pending_tool_approval requires an approve or reject decision") - else: - raise ValueError(f"Unsupported historical resume feature: {feature}") - - final_message = ResponseOutputMessage( - id="historical-resume-final", - type="message", - role="assistant", - status="completed", - content=[ - ResponseOutputText( - type="output_text", - text="resume complete", - annotations=[], - logprobs=[], - ) - ], - ) - model_turns.append([final_message]) - model = ScriptedModel( - [ModelStep(output=turn, response_id="queued-fake-response") for turn in model_turns] - ) - agent = Agent(name="compat-agent", model=model, tools=[tool]) - payload = json.loads(path.read_text(encoding="utf-8")) - restored = await RunState.from_json(agent, payload) - if feature == "pending_tool_approval": - interruptions = restored.get_interruptions() - if len(interruptions) != 1: - return [f"{path.name} did not restore its historical pending approval"] - if decision == "approve": - restored.approve(interruptions[0]) - else: - restored.reject( - interruptions[0], - rejection_message="Candidate rejected historical approval", - ) - result = await Runner.run(agent, restored) - - errors: list[str] = [] - if result.interruptions: - errors.append(f"{path.name} interrupted instead of applying its historical decision") - if invocation_count != expected_invocations: - errors.append( - f"{path.name} invoked its approval-controlled tool {invocation_count} times, " - f"expected {expected_invocations}" - ) - tool_outputs = [ - item.output for item in result.new_items if isinstance(item, ToolCallOutputItem) - ] - if expected_tool_output not in tool_outputs: - errors.append( - f"{path.name} did not preserve the historical tool decision output " - f"{expected_tool_output!r}" - ) - if result.final_output != "resume complete": - errors.append(f"{path.name} did not complete its resumed run") - return errors - - -async def validate_legacy_credential_run_state_fixture( - path: Path, - *, - sentinels: Iterable[str], -) -> list[str]: - from agents import RunState - from agents.run_state import CURRENT_SCHEMA_VERSION - - errors: list[str] = [] - payload = json.loads(path.read_text(encoding="utf-8")) - historical = deepcopy(payload) - agent = _restore_agent(payload) - restored = await RunState.from_json(agent, payload) - canonical = restored.to_json() - - if canonical.get("$schemaVersion") != CURRENT_SCHEMA_VERSION: - errors.append( - f"{path.name} rewrote as {canonical.get('$schemaVersion')!r}, " - f"expected {CURRENT_SCHEMA_VERSION!r}" - ) - semantic_errors = _find_subset_errors( - _normalized_durable_state(_normalize_legacy_mount_credentials(historical)), - _normalized_durable_state(canonical), - ) - errors.extend(f"{path.name}: {error}" for error in semantic_errors) - if not semantic_errors: - errors.extend(f"{path.name}: {error}" for error in _legacy_driver_option_errors(canonical)) - - serialized_observables = json.dumps(canonical, sort_keys=True) + repr(restored._sandbox) - for sentinel in sentinels: - if sentinel in serialized_observables: - errors.append(f"{path.name} retained credential sentinel {sentinel!r}") - - expected_canonical = deepcopy(canonical) - rerestored = await RunState.from_json(agent, deepcopy(canonical)) - if rerestored.to_json() != expected_canonical: - errors.append(f"{path.name} was not idempotent after credential sanitization") - return errors diff --git a/integration_tests/_contract_surface.py b/integration_tests/_contract_surface.py new file mode 100644 index 0000000000..26067ac728 --- /dev/null +++ b/integration_tests/_contract_surface.py @@ -0,0 +1,1362 @@ +"""Parse release policy and describe public API surfaces for generation and validation.""" + +from __future__ import annotations + +import ast +import dataclasses +import enum +import importlib +import inspect +import json +import sys +import typing +from collections.abc import Callable, Iterable, Mapping +from importlib.util import find_spec +from pathlib import Path +from types import FunctionType, ModuleType, UnionType +from typing import ( + Any, + ForwardRef, + Literal, + TypeAlias, + Union, + cast, + get_args, + get_origin, +) + +import typing_extensions +from pydantic import BaseModel +from typing_extensions import NotRequired, Required + + +@dataclasses.dataclass(frozen=True) +class OptionalDependencyInstallation: + dependency_module: str + extra: str | None = None + requirement: str | None = None + unsupported_platforms: tuple[str, ...] = () + + def is_supported_on_current_platform(self) -> bool: + return sys.platform not in self.unsupported_platforms + + +@dataclasses.dataclass(frozen=True) +class SubmoduleExportPolicy: + modules: dict[str, dict[str, dict[str, str]]] + dependency_installations: tuple[OptionalDependencyInstallation, ...] + canonical_imports: tuple[dict[str, str], ...] = () + public_class_contracts: tuple[dict[str, Any], ...] = () + public_properties: tuple[dict[str, Any], ...] = () + public_type_aliases: tuple[dict[str, str], ...] = () + public_typed_dicts: tuple[dict[str, Any], ...] = () + + +def load_api_contract(path: Path) -> dict[str, Any]: + contract = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) + _add_legacy_literal_types(contract) + return contract + + +def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("submodule export policy must be an object") + unknown_top_level_fields = sorted( + set(value) + - { + "canonical_imports", + "modules", + "optional_dependencies", + "public_class_contracts", + "public_properties", + "public_type_aliases", + "public_typed_dicts", + } + ) + if unknown_top_level_fields: + raise ValueError( + f"submodule export policy has unknown fields: {unknown_top_level_fields!r}" + ) + modules = value.get("modules") + if not isinstance(modules, dict): + raise ValueError("submodule export policy modules must be an object keyed by module name") + policy: dict[str, dict[str, dict[str, str]]] = {} + for module_name, declarations in modules.items(): + if type(module_name) is not str or not module_name: + raise ValueError("submodule export policy module names must be non-empty strings") + if not isinstance(declarations, dict): + raise ValueError(f"submodule export policy for {module_name} must be an object") + unknown_fields = sorted(set(declarations) - {"optional_bindings", "optional_exports"}) + if unknown_fields: + raise ValueError( + f"submodule export policy for {module_name} has unknown fields: {unknown_fields!r}" + ) + policy[module_name] = { + "optional_bindings": _optional_dependency_modules( + declarations.get("optional_bindings", {}), field_name="optional_bindings" + ), + "optional_exports": _optional_dependency_modules( + declarations.get("optional_exports", {}), field_name="optional_exports" + ), + } + + dependencies = value.get("optional_dependencies") + if not isinstance(dependencies, dict): + raise ValueError("submodule export policy optional_dependencies must be an object") + dependency_installations: list[OptionalDependencyInstallation] = [] + for module_name, installation in dependencies.items(): + if type(module_name) is not str or not module_name: + raise ValueError("optional dependency module names must be non-empty strings") + if not isinstance(installation, dict): + raise ValueError( + f"optional dependency installation for {module_name} must be an object" + ) + unknown_fields = sorted( + set(installation) - {"extra", "requirement", "unsupported_platforms"} + ) + if unknown_fields: + raise ValueError( + f"optional dependency installation for {module_name} has unknown fields: " + f"{unknown_fields!r}" + ) + configured = [field for field in ("extra", "requirement") if field in installation] + if len(configured) != 1: + raise ValueError( + f"optional dependency installation for {module_name} must declare exactly one " + "of extra or requirement" + ) + field_name = configured[0] + install_value = installation[field_name] + if type(install_value) is not str or not install_value: + raise ValueError( + f"optional dependency installation {field_name} for {module_name} must be a " + "non-empty string" + ) + unsupported_platforms = installation.get("unsupported_platforms", []) + if ( + not isinstance(unsupported_platforms, list) + or not all(type(platform) is str and platform for platform in unsupported_platforms) + or len(unsupported_platforms) != len(set(unsupported_platforms)) + ): + raise ValueError( + f"optional dependency installation unsupported_platforms for {module_name} " + "must be a list of unique non-empty strings" + ) + dependency_installations.append( + OptionalDependencyInstallation( + dependency_module=module_name, + extra=install_value if field_name == "extra" else None, + requirement=install_value if field_name == "requirement" else None, + unsupported_platforms=tuple(unsupported_platforms), + ) + ) + + referenced_dependencies = { + dependency + for module_policy in policy.values() + for declarations in module_policy.values() + for dependency in declarations.values() + } + missing_installations = sorted(referenced_dependencies - set(dependencies)) + unused_installations = sorted(set(dependencies) - referenced_dependencies) + if missing_installations: + raise ValueError( + "submodule export policy dependencies are missing installation declarations: " + f"{missing_installations!r}" + ) + if unused_installations: + raise ValueError( + "submodule export policy has unused dependency installation declarations: " + f"{unused_installations!r}" + ) + return SubmoduleExportPolicy( + modules=policy, + dependency_installations=tuple( + sorted( + dependency_installations, key=lambda installation: installation.dependency_module + ) + ), + canonical_imports=_canonical_import_policy(value.get("canonical_imports", [])), + public_class_contracts=_public_class_contract_policy( + value.get("public_class_contracts", []) + ), + public_properties=_public_property_policy(value.get("public_properties", [])), + public_type_aliases=_public_type_alias_policy(value.get("public_type_aliases", [])), + public_typed_dicts=_public_typed_dict_policy(value.get("public_typed_dicts", [])), + ) + + +def _canonical_import_policy(value: object) -> tuple[dict[str, str], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy canonical_imports must be a list") + required_fields = {"canonical_module", "canonical_name", "module", "name"} + entries: list[dict[str, str]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if not isinstance(entry, dict) or set(entry) != required_fields: + raise ValueError( + "submodule export policy canonical_imports entries must contain exactly " + "canonical_module, canonical_name, module, and name" + ) + if not all(type(entry[field]) is str and entry[field] for field in required_fields): + raise ValueError( + "submodule export policy canonical_imports values must be non-empty strings" + ) + identity = (entry["module"], entry["name"]) + if identity in identities: + raise ValueError( + "submodule export policy canonical_imports must not repeat " + f"{entry['module']}.{entry['name']}" + ) + identities.add(identity) + entries.append({field: entry[field] for field in sorted(required_fields)}) + return tuple(entries) + + +def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_properties must be a list") + entries: list[dict[str, Any]] = [] + identities: set[tuple[str, str, str]] = set() + for entry in value: + if not isinstance(entry, dict): + raise ValueError("submodule export policy public_properties entries must be objects") + owner_fields = {"class_name", "factory_name"} & set(entry) + required_fields = {"module", "names", *owner_fields} + if len(owner_fields) != 1 or set(entry) != required_fields: + raise ValueError( + "submodule export policy public_properties entries must contain exactly " + "module, names, and one of class_name or factory_name" + ) + owner_field = next(iter(owner_fields)) + module_name = entry["module"] + owner_name = entry[owner_field] + names = entry["names"] + if type(module_name) is not str or not module_name: + raise ValueError( + "submodule export policy public_properties module must be a non-empty string" + ) + if type(owner_name) is not str or not owner_name: + raise ValueError( + f"submodule export policy public_properties {owner_field} must be a non-empty " + "string" + ) + if ( + not isinstance(names, list) + or not names + or not all(type(name) is str and name for name in names) + or len(names) != len(set(names)) + ): + raise ValueError( + "submodule export policy public_properties names must be a non-empty list of " + "unique non-empty strings" + ) + identity = (owner_field, module_name, owner_name) + if identity in identities: + raise ValueError( + "submodule export policy public_properties must not repeat " + f"{module_name}.{owner_name}" + ) + identities.add(identity) + normalized_entry = { + owner_field: owner_name, + "module": module_name, + "names": list(names), + } + entries.append(normalized_entry) + return tuple(entries) + + +def _public_class_contract_policy(value: object) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_class_contracts must be a list") + required_fields = {"class_name", "module"} + contract_fields = {"abstract", "abstract_members"} + entries: list[dict[str, Any]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if ( + not isinstance(entry, dict) + or not required_fields.issubset(entry) + or not set(entry).issubset(required_fields | contract_fields) + or not (set(entry) & contract_fields) + ): + raise ValueError( + "submodule export policy public_class_contracts entries must contain exactly " + "module, class_name, and at least one of abstract or abstract_members" + ) + module_name = entry["module"] + class_name = entry["class_name"] + if type(module_name) is not str or not module_name: + raise ValueError( + "submodule export policy public_class_contracts module must be a non-empty string" + ) + if type(class_name) is not str or not class_name: + raise ValueError( + "submodule export policy public_class_contracts class_name must be a non-empty " + "string" + ) + if "abstract" in entry and type(entry["abstract"]) is not bool: + raise ValueError( + "submodule export policy public_class_contracts abstract must be a boolean" + ) + abstract_members = entry.get("abstract_members") + if "abstract_members" in entry and ( + not isinstance(abstract_members, list) + or not abstract_members + or not all(type(name) is str and name for name in abstract_members) + or len(abstract_members) != len(set(abstract_members)) + ): + raise ValueError( + "submodule export policy public_class_contracts abstract_members must be a " + "non-empty list of unique non-empty strings" + ) + identity = (module_name, class_name) + if identity in identities: + raise ValueError( + "submodule export policy public_class_contracts must not repeat " + f"{module_name}.{class_name}" + ) + identities.add(identity) + normalized_entry: dict[str, Any] = { + "class_name": class_name, + "module": module_name, + } + if "abstract" in entry: + normalized_entry["abstract"] = entry["abstract"] + if "abstract_members" in entry: + normalized_entry["abstract_members"] = sorted(abstract_members) + entries.append(normalized_entry) + return tuple(entries) + + +def _public_typed_dict_policy(value: object) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_typed_dicts must be a list") + required_fields = {"class_name", "module", "names"} + entries: list[dict[str, Any]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if not isinstance(entry, dict) or set(entry) != required_fields: + raise ValueError( + "submodule export policy public_typed_dicts entries must contain exactly " + "class_name, module, and names" + ) + module_name = entry["module"] + class_name = entry["class_name"] + names = entry["names"] + if type(module_name) is not str or not module_name: + raise ValueError( + "submodule export policy public_typed_dicts module must be a non-empty string" + ) + if type(class_name) is not str or not class_name: + raise ValueError( + "submodule export policy public_typed_dicts class_name must be a non-empty string" + ) + if ( + not isinstance(names, list) + or not names + or not all(type(name) is str and name for name in names) + or len(names) != len(set(names)) + ): + raise ValueError( + "submodule export policy public_typed_dicts names must be a non-empty list of " + "unique non-empty strings" + ) + identity = (module_name, class_name) + if identity in identities: + raise ValueError( + "submodule export policy public_typed_dicts must not repeat " + f"{module_name}.{class_name}" + ) + identities.add(identity) + entries.append({"class_name": class_name, "module": module_name, "names": list(names)}) + return tuple(entries) + + +def _public_type_alias_policy(value: object) -> tuple[dict[str, str], ...]: + if not isinstance(value, list): + raise ValueError("submodule export policy public_type_aliases must be a list") + required_fields = {"module", "name"} + entries: list[dict[str, str]] = [] + identities: set[tuple[str, str]] = set() + for entry in value: + if not isinstance(entry, dict) or set(entry) != required_fields: + raise ValueError( + "submodule export policy public_type_aliases entries must contain exactly " + "module and name" + ) + if not all(type(entry[field]) is str and entry[field] for field in required_fields): + raise ValueError( + "submodule export policy public_type_aliases values must be non-empty strings" + ) + identity = (entry["module"], entry["name"]) + if identity in identities: + raise ValueError( + "submodule export policy public_type_aliases must not repeat " + f"{entry['module']}.{entry['name']}" + ) + identities.add(identity) + entries.append({"module": entry["module"], "name": entry["name"]}) + return tuple(entries) + + +def _add_legacy_literal_types(value: object) -> None: + if isinstance(value, dict): + if value.get("kind") == "literal" and "value" in value and "type" not in value: + literal = value["value"] + value["type"] = f"{type(literal).__module__}.{type(literal).__qualname__}" + for child in value.values(): + _add_legacy_literal_types(child) + elif isinstance(value, list): + for child in value: + _add_legacy_literal_types(child) + + +def _default_contract(value: object) -> dict[str, object]: + if value is inspect.Parameter.empty or value is dataclasses.MISSING: + return {"kind": "required"} + if value.__class__.__name__ == "_HAS_DEFAULT_FACTORY_CLASS": + return {"kind": "factory"} + if value is None or isinstance(value, bool | int | float | str): + return { + "kind": "literal", + "type": f"{type(value).__module__}.{type(value).__qualname__}", + "value": value, + } + voice_testing = sys.modules.get("agents.voice.testing") + if voice_testing is not None and value is getattr(voice_testing, "_START_NOT_CONFIGURED", None): + return { + "kind": "sentinel", + "identity": "agents.voice.testing._START_NOT_CONFIGURED", + } + value_type = f"{type(value).__module__}.{type(value).__qualname__}" + from agents.mcp.server import _UNSET as mcp_failure_error_unset + from agents.retry import _UNSET as retry_unset + from agents.tool import _UNSET_FAILURE_ERROR_FUNCTION as failure_error_function_unset + from agents.tool_context import _MISSING as tool_context_missing + + sentinel_identities = ( + (retry_unset, "agents.retry._UNSET"), + (mcp_failure_error_unset, "agents.mcp.server._UNSET"), + (failure_error_function_unset, "agents.tool._UNSET_FAILURE_ERROR_FUNCTION"), + (tool_context_missing, "agents.tool_context._MISSING"), + ) + for sentinel, identity in sentinel_identities: + if value is sentinel: + return {"kind": "sentinel", "identity": identity} + if value_type == "pydantic.fields.FieldInfo": + return {"kind": "repr", "type": value_type, "value": repr(value)} + if isinstance(value, enum.Enum): + return { + "kind": "enum", + "type": value_type, + "name": value.name, + "value": _default_contract(value.value), + } + if isinstance(value, type): + return { + "kind": "type", + "identity": f"{value.__module__}.{value.__qualname__}", + } + if isinstance(value, tuple | list): + return { + "kind": "sequence", + "type": value_type, + "items": [_default_contract(item) for item in value], + } + if isinstance(value, dict): + return { + "kind": "mapping", + "type": value_type, + "items": [ + [_default_contract(key), _default_contract(item)] for key, item in value.items() + ], + } + if value_type.startswith("agents.") and callable(getattr(value, "model_dump", None)): + dumped = value.model_dump(mode="python") # type: ignore[attr-defined] + return { + "kind": "model", + "type": value_type, + "value": _default_contract(dumped), + } + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return { + "kind": "dataclass", + "type": value_type, + "fields": [ + {"name": field.name, "value": _default_contract(getattr(value, field.name))} + for field in dataclasses.fields(value) + ], + } + if type(value) is FunctionType and value.__module__.startswith("agents."): + return { + "kind": "callable", + "identity": f"{value.__module__}.{value.__qualname__}", + } + raise TypeError(f"Unsupported public API default value: {value_type}") + + +def _parameter_records( + parameters: Iterable[inspect.Parameter], +) -> list[dict[str, object]]: + return [ + { + "name": parameter.name, + "kind": parameter.kind.name, + "default": _default_contract(parameter.default), + } + for parameter in parameters + ] + + +def _signature(value: Callable[..., Any]) -> inspect.Signature: + return inspect.signature(value) + + +def _parameter_contract(value: Callable[..., Any]) -> list[dict[str, object]]: + parameters = list(_signature(value).parameters.values()) + if issubclass(type(value), type) and issubclass(cast(type, value), enum.Enum): + parameters = list(_signature(value.__new__).parameters.values())[1:] + return _parameter_records(parameters) + + +def _dataclass_field_contract(value: object) -> list[dict[str, object]]: + if not dataclasses.is_dataclass(value): + return [] + result: list[dict[str, object]] = [] + for field in dataclasses.fields(value): + if field.name.startswith("_"): + continue + if field.default_factory is not dataclasses.MISSING: + factory = cast(Callable[..., Any], field.default_factory) + default_contract: dict[str, object] = { + "kind": "factory", + "factory": f"{factory.__module__}.{factory.__qualname__}", + } + else: + default_contract = _default_contract(field.default) + result.append( + { + "name": field.name, + "init": field.init, + "default": default_contract, + } + ) + return result + + +def _pydantic_model_field_contract(value: object) -> list[dict[str, object]] | None: + if not (isinstance(value, type) and issubclass(value, BaseModel)): + return None + result: list[dict[str, object]] = [] + for name, field in value.model_fields.items(): + if name.startswith("_"): + continue + if field.is_required(): + default_contract: dict[str, object] = {"kind": "required"} + elif field.default_factory is not None: + factory = field.default_factory + default_contract = { + "kind": "factory", + "factory": f"{factory.__module__}.{factory.__qualname__}", + } + else: + default_contract = _default_contract(field.default) + result.append({"name": name, "default": default_contract}) + return result + + +def _callable_kind(value: Callable[..., Any]) -> str | None: + if issubclass(type(value), type): + return "class" + if type(value) is FunctionType: + return "function" + return None + + +def _is_sdk_owned_callable(value: object) -> bool: + module_name = getattr(value, "__module__", None) + return ( + _callable_kind(cast(Callable[..., Any], value)) is not None + and isinstance(module_name, str) + and (module_name == "agents" or module_name.startswith("agents.")) + ) + + +def _enum_member_contract(value: object) -> list[dict[str, object]] | None: + if not (issubclass(type(value), type) and issubclass(cast(type, value), enum.Enum)): + return None + enum_type = cast(type[enum.Enum], value) + members: list[dict[str, object]] = [] + for name, member in enum_type.__members__.items(): + member_value = member.value + if member_value is None or isinstance(member_value, bool | int | float | str): + value_contract: dict[str, object] = { + "kind": "literal", + "type": f"{type(member_value).__module__}.{type(member_value).__qualname__}", + "value": member_value, + } + else: + raise TypeError( + f"Unsupported public enum value for " + f"{enum_type.__module__}.{enum_type.__qualname__}." + f"{name}: {type(member_value).__module__}.{type(member_value).__qualname__}" + ) + members.append({"name": name, "value": value_contract}) + return members + + +def _class_member_contract(descriptor: object) -> dict[str, object] | None: + descriptor_type = type(descriptor) + if descriptor_type is staticmethod: + binding = "static" + function = object.__getattribute__(descriptor, "__func__") + skip_first = False + elif descriptor_type is classmethod: + binding = "class" + function = object.__getattribute__(descriptor, "__func__") + skip_first = True + elif type(descriptor) is FunctionType: + binding = "instance" + function = descriptor + skip_first = True + else: + return None + if type(function) is not FunctionType: + return None + try: + parameters = list(_signature(function).parameters.values()) + except (TypeError, ValueError): + return None + if skip_first: + if not parameters: + return None + parameters = parameters[1:] + return { + "binding": binding, + "execution_kind": _function_execution_kind(function), + "parameters": _parameter_records(parameters), + } + + +def _function_execution_kind(value: object) -> str: + if inspect.isasyncgenfunction(value): + return "async_generator" + if inspect.iscoroutinefunction(value): + return "coroutine" + if inspect.isgeneratorfunction(value): + return "generator" + return "sync" + + +def _sdk_public_class_descriptor(value: type, name: str) -> object | None: + for owner in value.__mro__: + namespace = vars(owner) + if name not in namespace: + continue + owner_module = owner.__module__ + if owner is value or ( + isinstance(owner_module, str) + and (owner_module == "agents" or owner_module.startswith("agents.")) + ): + return cast(object, inspect.getattr_static(value, name)) + return None + return None + + +def _public_class_member_contract(value: object) -> dict[str, dict[str, object]]: + if not issubclass(type(value), type): + return {} + class_value = cast(type, value) + value_identity = f"{class_value.__module__}.{class_value.__qualname__}" + candidate_names: list[str] = [] + seen_names: set[str] = set() + + def add_candidate_names(namespace: Mapping[str, object]) -> None: + for name in namespace: + if name in seen_names: + continue + seen_names.add(name) + candidate_names.append(name) + + add_candidate_names(vars(class_value)) + for base in class_value.__mro__[1:]: + base_module = base.__module__ + if isinstance(base_module, str) and ( + base_module == "agents" or base_module.startswith("agents.") + ): + add_candidate_names(vars(base)) + members: dict[str, dict[str, object]] = {} + for name in candidate_names: + if name.startswith("_"): + continue + descriptor = _sdk_public_class_descriptor(class_value, name) + if descriptor is None: + continue + try: + member = _class_member_contract(descriptor) + except TypeError as error: + raise TypeError( + f"Unable to contract public method {value_identity}.{name}: {error}" + ) from None + if member is not None: + members[name] = member + return members + + +def _callable_contract(value: Callable[..., Any]) -> dict[str, Any]: + kind = _callable_kind(value) + if kind is None: + raise TypeError(f"Unsupported public callable type: {type(value)!r}") + contract: dict[str, Any] = { + "kind": kind, + "parameters": _parameter_contract(value), + "dataclass_fields": _dataclass_field_contract(value), + } + if kind == "function": + contract["execution_kind"] = _function_execution_kind(value) + model_fields = _pydantic_model_field_contract(value) + if model_fields is not None: + contract["model_fields"] = model_fields + enum_members = _enum_member_contract(value) + if enum_members is not None: + contract["enum_members"] = enum_members + if kind == "class": + contract["members"] = _public_class_member_contract(value) + return contract + + +def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]: + if "class_name" in entry: + return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"])) + return ("factory_name", cast(str, entry["module"]), cast(str, entry["factory_name"])) + + +def _annotation_contract(annotation: object) -> str: + if isinstance(annotation, ForwardRef): + annotation_text = annotation.__forward_arg__ + elif isinstance(annotation, str): + annotation_text = annotation + else: + annotation_text = inspect.formatannotation(annotation) + for wrapper_name in ("Required", "NotRequired"): + for module_name in ("typing", "typing_extensions"): + qualified_prefix = f"{module_name}.{wrapper_name}[" + if annotation_text.startswith(qualified_prefix): + return f"{wrapper_name}[{annotation_text.removeprefix(qualified_prefix)}" + return annotation_text + + +def _sorted_type_alias_members(members: Iterable[dict[str, object]]) -> list[dict[str, object]]: + return sorted( + members, + key=lambda member: ( + cast(str, member["kind"]), + json.dumps(member, sort_keys=True, separators=(",", ":")), + ), + ) + + +def _is_type_alias_type(value: object) -> bool: + native_type_alias_type = getattr(typing, "TypeAliasType", typing_extensions.TypeAliasType) + return isinstance(value, typing_extensions.TypeAliasType | native_type_alias_type) + + +def _is_type_alias_annotation(annotation: object, module: object) -> bool: + if annotation is TypeAlias or annotation is typing_extensions.TypeAlias: + return True + if not isinstance(annotation, str): + return False + reference_parts = annotation.split(".") + if not reference_parts or not all(part.isidentifier() for part in reference_parts): + return False + missing = object() + resolved = getattr(module, reference_parts[0], missing) + for part in reference_parts[1:]: + if resolved is missing: + break + resolved = getattr(resolved, part, missing) + return resolved is TypeAlias or resolved is typing_extensions.TypeAlias + + +def _module_declares_type_alias(module: object, alias_name: str, value: object) -> bool: + annotations = getattr(module, "__annotations__", {}) + if not isinstance(annotations, Mapping) or alias_name not in annotations: + return False + missing = object() + return ( + _is_type_alias_annotation(annotations[alias_name], module) + and getattr(module, alias_name, missing) is value + ) + + +class _ModuleBindingVisitor(ast.NodeVisitor): + def __init__(self, name: str): + self.name = name + self.count = 0 + self.has_wildcard_import = False + self.from_imports: list[tuple[ast.ImportFrom, str]] = [] + self._bindings_target_module = True + + def _count(self, name: str | None) -> None: + if self._bindings_target_module: + self.count += name == self.name + + def _visit_nested_scope(self, body: list[ast.stmt]) -> None: + bindings_target_module = _scope_declares_global(body, self.name) + previous_bindings_target_module = self._bindings_target_module + self._bindings_target_module = bindings_target_module + for statement in body: + self.visit(statement) + self._bindings_target_module = previous_bindings_target_module + + def _visit_arguments(self, arguments: ast.arguments) -> None: + all_arguments = [ + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + ] + if arguments.vararg is not None: + all_arguments.append(arguments.vararg) + if arguments.kwarg is not None: + all_arguments.append(arguments.kwarg) + for argument in all_arguments: + if argument.annotation is not None: + self.visit(argument.annotation) + for default in [*arguments.defaults, *arguments.kw_defaults]: + if default is not None: + self.visit(default) + + def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + self._count(node.name) + for decorator in node.decorator_list: + self.visit(decorator) + self._visit_arguments(node.args) + if node.returns is not None: + self.visit(node.returns) + + def _visit_comprehension( + self, generators: list[ast.comprehension], values: list[ast.expr] + ) -> None: + for generator in generators: + self.visit(generator.iter) + for condition in generator.ifs: + self.visit(condition) + for value in values: + self.visit(value) + + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, ast.Store | ast.Del): + self._count(node.id) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function_definition(node) + self._visit_nested_scope(node.body) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function_definition(node) + self._visit_nested_scope(node.body) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._count(node.name) + for decorator in node.decorator_list: + self.visit(decorator) + for base in node.bases: + self.visit(base) + for keyword in node.keywords: + self.visit(keyword.value) + self._visit_nested_scope(node.body) + + def visit_Lambda(self, node: ast.Lambda) -> None: + self._visit_arguments(node.args) + + def visit_ListComp(self, node: ast.ListComp) -> None: + self._visit_comprehension(node.generators, [node.elt]) + + def visit_SetComp(self, node: ast.SetComp) -> None: + self._visit_comprehension(node.generators, [node.elt]) + + def visit_DictComp(self, node: ast.DictComp) -> None: + self._visit_comprehension(node.generators, [node.key, node.value]) + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: + self._visit_comprehension(node.generators, [node.elt]) + + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: + self._count(node.name) + self.generic_visit(node) + + def visit_MatchAs(self, node: ast.MatchAs) -> None: + self._count(node.name) + self.generic_visit(node) + + def visit_MatchStar(self, node: ast.MatchStar) -> None: + self._count(node.name) + + def visit_MatchMapping(self, node: ast.MatchMapping) -> None: + self._count(node.rest) + self.generic_visit(node) + + def visit_Import(self, node: ast.Import) -> None: + for imported in node.names: + self._count(imported.asname or imported.name.split(".", 1)[0]) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if not self._bindings_target_module: + return + for imported in node.names: + if imported.name == "*": + self.has_wildcard_import = True + continue + binding_name = imported.asname or imported.name + self._count(binding_name) + if binding_name == self.name: + self.from_imports.append((node, imported.name)) + + +def _scope_declares_global(nodes: Iterable[ast.AST], name: str) -> bool: + for node in nodes: + if isinstance(node, ast.Global): + if name in node.names: + return True + continue + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): + continue + if _scope_declares_global(ast.iter_child_nodes(node), name): + return True + return False + + +def _direct_import_source( + module: object, export_name: str, *, package_root: str +) -> tuple[ModuleType, str] | None: + module_name = getattr(module, "__name__", None) + package_name = getattr(module, "__package__", None) + if not isinstance(module_name, str) or not isinstance(package_name, str): + return None + try: + module_tree = ast.parse(inspect.getsource(module)) + except (OSError, SyntaxError, TypeError): + return None + + bindings = _ModuleBindingVisitor(export_name) + bindings.visit(module_tree) + if bindings.count != 1 or bindings.has_wildcard_import or len(bindings.from_imports) != 1: + return None + statement, source_name = bindings.from_imports[0] + if statement.level: + relative_name = "." * statement.level + (statement.module or "") + try: + source_module_name = importlib.util.resolve_name(relative_name, package_name) + except ImportError: + return None + else: + source_module_name = statement.module + if source_module_name is None or not ( + source_module_name == package_root or source_module_name.startswith(f"{package_root}.") + ): + return None + source_module = sys.modules.get(source_module_name) + if not isinstance(source_module, ModuleType): + return None + return source_module, source_name + + +def _has_explicit_type_alias_declaration( + agents_module: object, export_name: str, value: object +) -> bool: + package_root = getattr(agents_module, "__name__", None) + module, alias_name = agents_module, export_name + visited_bindings: set[tuple[int, str]] = set() + missing = object() + while (id(module), alias_name) not in visited_bindings: + visited_bindings.add((id(module), alias_name)) + if getattr(module, alias_name, missing) is not value: + return False + if _module_declares_type_alias(module, alias_name, value): + return True + if not isinstance(package_root, str): + return False + import_source = _direct_import_source(module, alias_name, package_root=package_root) + if import_source is None: + return False + module, alias_name = import_source + return False + + +def _is_public_type_alias(agents_module: object, export_name: str, value: object) -> bool: + return ( + get_origin(value) is not None + or _is_type_alias_type(value) + or _has_explicit_type_alias_declaration(agents_module, export_name, value) + ) + + +def _type_alias_definition( + value: object, *, visited_alias_ids: frozenset[int] = frozenset() +) -> dict[str, object]: + if value is Any: + return {"kind": "any"} + if _is_type_alias_type(value): + if value.__type_params__: + raise TypeError(f"generic public type alias is unsupported: {value.__name__}") + alias_id = id(value) + if alias_id in visited_alias_ids: + alias_name = getattr(value, "__name__", repr(value)) + raise TypeError(f"recursive public type alias is unsupported: {alias_name}") + try: + alias_value = value.__value__ + except Exception as error: + raise TypeError( + f"cannot resolve public type alias {value.__name__} at runtime: " + f"{type(error).__name__}: {error}" + ) from None + return _type_alias_definition(alias_value, visited_alias_ids=visited_alias_ids | {alias_id}) + origin = get_origin(value) + if origin is Literal: + literal_values: list[dict[str, object]] = [] + for literal_value in get_args(value): + literal_contract = _default_contract(literal_value) + if literal_contract["kind"] not in {"literal", "enum"}: + raise TypeError( + "public type alias Literal members must use supported literal or enum values" + ) + literal_values.append(literal_contract) + return { + "kind": "literal", + "values": _sorted_type_alias_members(literal_values), + } + if origin in {Union, UnionType}: + members = [ + _type_alias_definition(member, visited_alias_ids=visited_alias_ids) + for member in get_args(value) + ] + return { + "kind": "union", + "members": _sorted_type_alias_members(members), + } + if origin is Callable: + callable_args = get_args(value) + if len(callable_args) != 2: + raise TypeError( + "public Callable type aliases must declare parameters and a return type" + ) + parameter_types, return_type = callable_args + if parameter_types is Ellipsis or not isinstance(parameter_types, list | tuple): + raise TypeError("public Callable type aliases must declare explicit parameter types") + return { + "kind": "callable", + "parameters": [ + _type_alias_definition(parameter_type, visited_alias_ids=visited_alias_ids) + for parameter_type in parameter_types + ], + "return": _type_alias_definition(return_type, visited_alias_ids=visited_alias_ids), + } + if origin is not None: + if not isinstance(origin, type) or not ( + origin.__module__ == "agents" or origin.__module__.startswith("agents.") + ): + raise TypeError(f"unsupported public generic type alias origin: {origin!r}") + return { + "kind": "generic", + "origin": f"{origin.__module__}.{origin.__qualname__}", + "arguments": [ + _type_alias_definition(argument, visited_alias_ids=visited_alias_ids) + for argument in get_args(value) + ], + } + if isinstance(value, type) and ( + value.__module__ == "builtins" + or value.__module__ == "agents" + or value.__module__.startswith("agents.") + ): + return { + "kind": "type", + "identity": f"{value.__module__}.{value.__qualname__}", + } + raise TypeError(f"unsupported public type alias member: {value!r}") + + +def _public_type_alias_contract( + policy_entries: Iterable[Mapping[str, str]], + agents_module: Any | None, +) -> list[dict[str, object]]: + entries: list[dict[str, object]] = [] + missing = object() + for policy_entry in policy_entries: + module_name = policy_entry["module"] + alias_name = policy_entry["name"] + module = _import_contract_module(module_name, agents_module) + alias = getattr(module, alias_name, missing) + if alias is missing: + raise ValueError( + f"Cannot promote public type alias {module_name}.{alias_name} because it is missing" + ) + try: + definition = _type_alias_definition(alias) + except TypeError as error: + raise ValueError( + f"Cannot promote public type alias {module_name}.{alias_name}: {error}" + ) from None + entries.append({"definition": definition, "module": module_name, "name": alias_name}) + return entries + + +def _typed_dict_field_is_required(typed_dict: type, name: str, annotation: object) -> bool: + if isinstance(annotation, ForwardRef): + annotation_text = annotation.__forward_arg__ + if annotation_text.startswith( + ("Required[", "typing.Required[", "typing_extensions.Required[") + ): + return True + if annotation_text.startswith( + ("NotRequired[", "typing.NotRequired[", "typing_extensions.NotRequired[") + ): + return False + origin = get_origin(annotation) + if origin is Required: + return True + if origin is NotRequired: + return False + required_keys = getattr(typed_dict, "__required_keys__", frozenset()) + optional_keys = getattr(typed_dict, "__optional_keys__", frozenset()) + if name in required_keys: + return True + if name in optional_keys: + return False + return bool(getattr(typed_dict, "__total__", True)) + + +def _typed_dict_field_contract(typed_dict: type, name: str) -> dict[str, object] | None: + annotation = getattr(typed_dict, "__annotations__", {}).get(name) + if annotation is None: + return None + return { + "name": name, + "required": _typed_dict_field_is_required(typed_dict, name, annotation), + "annotation": _annotation_contract(annotation), + } + + +def _public_typed_dict_contract( + policy_entries: Iterable[Mapping[str, Any]], + agents_module: Any | None, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for policy_entry in policy_entries: + module_name = cast(str, policy_entry["module"]) + class_name = cast(str, policy_entry["class_name"]) + module = _import_contract_module(module_name, agents_module) + typed_dict = getattr(module, class_name, None) + if not typing_extensions.is_typeddict(typed_dict): + raise ValueError( + f"Cannot promote public TypedDict {module_name}.{class_name} because it is " + "missing or no longer a TypedDict" + ) + fields: list[dict[str, object]] = [] + for name in policy_entry["names"]: + field = _typed_dict_field_contract(typed_dict, name) + if field is None: + raise ValueError( + f"Cannot promote public TypedDict field {module_name}.{class_name}.{name} " + "because it is missing" + ) + fields.append(field) + entries.append({"class_name": class_name, "fields": fields, "module": module_name}) + return entries + + +def _optional_dependency_unsupported_platforms( + contract: Mapping[str, Any], +) -> dict[str, tuple[str, ...]]: + value = contract.get("optional_dependency_unsupported_platforms", {}) + if not isinstance(value, dict): + raise ValueError("optional_dependency_unsupported_platforms must be an object") + result: dict[str, tuple[str, ...]] = {} + for dependency_module, platforms in value.items(): + if type(dependency_module) is not str or not dependency_module: + raise ValueError( + "optional_dependency_unsupported_platforms keys must be non-empty strings" + ) + if ( + not isinstance(platforms, list) + or not all(type(platform) is str and platform for platform in platforms) + or len(platforms) != len(set(platforms)) + ): + raise ValueError( + "optional_dependency_unsupported_platforms values must be lists of unique " + "non-empty strings" + ) + result[dependency_module] = tuple(platforms) + return result + + +def _optional_dependency_is_available_for_contract( + dependency_module: str, + unsupported_platforms: Mapping[str, tuple[str, ...]], +) -> bool: + return not _optional_dependency_is_unsupported_for_contract( + dependency_module, unsupported_platforms + ) and _optional_dependency_is_available(dependency_module) + + +def _optional_dependency_is_unsupported_for_contract( + dependency_module: str, + unsupported_platforms: Mapping[str, tuple[str, ...]], +) -> bool: + return sys.platform in unsupported_platforms.get(dependency_module, ()) + + +def _optional_dependency_for_binding( + contract: Mapping[str, Any], module_name: str, binding_name: str +) -> str | None: + dependency = _optional_dependency_for_binding_in_modules( + contract.get("required_submodule_exports", {}), module_name, binding_name + ) + if dependency is not None: + return dependency + canonical_dependencies = { + _optional_dependency_for_binding_in_modules( + contract.get("required_submodule_exports", {}), entry["module"], entry["name"] + ) + for entry in contract.get("canonical_imports", []) + if entry["canonical_module"] == module_name and entry["canonical_name"] == binding_name + } + if canonical_dependencies and len(canonical_dependencies) == 1: + return next(iter(canonical_dependencies)) + return None + + +def _optional_dependency_for_binding_in_modules( + modules: Mapping[str, Any], module_name: str, binding_name: str +) -> str | None: + module_contract = modules.get(module_name, {}) + for field_name in ("optional_bindings", "optional_exports"): + dependency_module = module_contract.get(field_name, {}).get(binding_name) + if dependency_module is not None: + return cast(str, dependency_module) + return None + + +def _optional_dependency_for_module_import( + contract: Mapping[str, Any], module_name: str +) -> str | None: + modules = contract.get("required_submodule_exports", {}) + module_contract = modules.get(module_name, {}) + names = module_contract.get("names", []) + try: + optional_bindings = _optional_dependency_modules( + module_contract.get("optional_bindings", {}), field_name="optional_bindings" + ) + optional_exports = _optional_dependency_modules( + module_contract.get("optional_exports", {}), field_name="optional_exports" + ) + except ValueError: + return None + dependencies = {optional_bindings.get(name) or optional_exports.get(name) for name in names} + if names and len(dependencies) == 1 and None not in dependencies: + return cast(str, next(iter(dependencies))) + if names: + return None + canonical_dependencies = { + _optional_dependency_for_binding(contract, entry["module"], entry["name"]) + for entry in contract.get("canonical_imports", []) + if entry["canonical_module"] == module_name + } + if canonical_dependencies and len(canonical_dependencies) == 1: + dependency = next(iter(canonical_dependencies)) + if dependency is not None: + return dependency + return None + + +def _import_contract_module(module_name: str, agents_module: Any | None) -> Any: + if module_name == "agents" and agents_module is not None: + return agents_module + return importlib.import_module(module_name) + + +def _submodule_export_contract( + module: object, + *, + optional_bindings: Mapping[str, str] | None = None, + optional_exports: Mapping[str, str] | None = None, + allowed_missing_optional_exports: Iterable[str] = (), +) -> dict[str, Any] | None: + exports = getattr(module, "__all__", None) + if exports is None: + return None + if not isinstance(exports, list | tuple) or not all(type(name) is str for name in exports): + raise ValueError("public module __all__ must contain only strings") + names = list(exports) + if len(names) != len(set(names)): + raise ValueError("public module __all__ must not contain duplicate exports") + optional_binding_modules = _optional_dependency_modules( + dict(optional_bindings or {}), field_name="optional_bindings" + ) + optional_export_modules = _optional_dependency_modules( + dict(optional_exports or {}), field_name="optional_exports" + ) + optional_binding_names = set(optional_binding_modules) + optional_export_names = set(optional_export_modules) + allowed_missing_names = set(allowed_missing_optional_exports) + unknown_optional_names = sorted( + (optional_binding_names | optional_export_names) - set(names) - allowed_missing_names + ) + if unknown_optional_names: + raise ValueError( + f"optional submodule bindings are not exported: {unknown_optional_names!r}" + ) + names.extend( + name + for name in optional_export_modules + if name in allowed_missing_names and name not in names + ) + return { + "names": names, + "optional_bindings": { + name: optional_binding_modules[name] for name in names if name in optional_binding_names + }, + "optional_exports": { + name: optional_export_modules[name] for name in names if name in optional_export_names + }, + } + + +def _optional_dependency_modules(value: object, *, field_name: str) -> dict[str, str]: + if not isinstance(value, dict): + raise ValueError( + f"{field_name} must be an object mapping export names to dependency modules" + ) + modules: dict[str, str] = {} + for name, module_name in value.items(): + if type(name) is not str or not name: + raise ValueError(f"{field_name} export names must be non-empty strings") + if type(module_name) is not str or not module_name.strip(): + raise ValueError(f"{field_name} dependency for {name!r} must be a non-empty string") + modules[name] = module_name + return modules + + +def _optional_dependency_is_available(module_name: str) -> bool: + if module_name in sys.modules: + return sys.modules[module_name] is not None + return find_spec(module_name) is not None + + +def _matches_platform_import_error( + contract: dict[str, Any], module_name: str, error: Exception +) -> bool: + allowed_error_types = {"ImportError": ImportError} + for entry in contract.get("platform_import_errors", []): + if entry["module"] != module_name or sys.platform not in entry["platforms"]: + continue + expected_error_type = allowed_error_types.get(entry["error_type"]) + return ( + expected_error_type is not None + and type(error) is expected_error_type + and entry["message_contains"] in str(error) + ) + return False diff --git a/integration_tests/_contract_validation.py b/integration_tests/_contract_validation.py new file mode 100644 index 0000000000..db08c55492 --- /dev/null +++ b/integration_tests/_contract_validation.py @@ -0,0 +1,653 @@ +"""Compare released API contracts with current public surface descriptions.""" + +from __future__ import annotations + +import importlib +import inspect +from collections.abc import Mapping +from typing import Any, cast, get_type_hints + +import typing_extensions + +from integration_tests import _contract_surface as surface + + +def _validate_parameter_contract( + name: str, + released: list[dict[str, object]], + current: list[dict[str, object]], +) -> list[str]: + errors: list[str] = [] + positional_kinds = {"POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"} + released_positional = [entry for entry in released if entry["kind"] in positional_kinds] + current_positional = [entry for entry in current if entry["kind"] in positional_kinds] + if current_positional[: len(released_positional)] != released_positional: + errors.append( + f"{name} changed its released positional parameter prefix: " + f"expected {released_positional!r}, got {current_positional!r}" + ) + elif any(entry["kind"] == "VAR_POSITIONAL" for entry in released) and len( + current_positional + ) != len(released_positional): + added = current_positional[len(released_positional) :] + errors.append( + f"{name} added positional parameters before its released variadic parameter: {added!r}" + ) + + current_by_name = {entry["name"]: entry for entry in current} + for entry in released: + if entry["kind"] in positional_kinds: + continue + current_entry = current_by_name.get(entry["name"]) + if current_entry != entry: + errors.append( + f"{name}.{entry['name']} changed its released parameter contract: " + f"expected {entry!r}, got {current_entry!r}" + ) + released_names = {entry["name"] for entry in released} + for entry in current: + if entry["name"] in released_names: + continue + if entry["kind"] in {"VAR_POSITIONAL", "VAR_KEYWORD"}: + continue + default = entry["default"] + if isinstance(default, dict) and default.get("kind") == "required": + errors.append(f"{name}.{entry['name']} added a required parameter") + return errors + + +def _validate_pydantic_model_field_contract( + name: str, + released: list[dict[str, object]], + current: list[dict[str, object]] | None, +) -> list[str]: + errors: list[str] = [] + current_by_name = {cast(str, entry["name"]): entry for entry in current or []} + for entry in released: + current_entry = current_by_name.get(cast(str, entry["name"])) + if current_entry != entry: + errors.append( + f"{name}.{entry['name']} changed its released Pydantic model field contract: " + f"expected {entry!r}, got {current_entry!r}" + ) + released_names = {entry["name"] for entry in released} + for entry in current or []: + if entry["name"] in released_names: + continue + default = entry["default"] + if isinstance(default, dict) and default.get("kind") == "required": + errors.append(f"{name}.{entry['name']} added a required Pydantic model field") + return errors + + +def _validate_public_property_contract( + contract: dict[str, Any], + agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, +) -> list[str]: + errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} + for entry in contract.get("public_properties", []): + module_name = entry["module"] + owner_name = entry.get("class_name", entry.get("factory_name")) + optional_dependency = surface._optional_dependency_for_binding( + contract, module_name, owner_name + ) + if ( + optional_dependency is not None + and not surface._optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue + try: + module = surface._import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + if "class_name" in entry: + class_value = getattr(module, owner_name, None) + if not isinstance(class_value, type): + errors.append(f"Missing released public class {module_name}.{owner_name}") + continue + else: + factory = getattr(module, owner_name, None) + if not callable(factory): + errors.append(f"Missing released public factory {module_name}.{owner_name}") + continue + try: + class_value = get_type_hints(factory)["return"] + except (KeyError, NameError, TypeError) as error: + errors.append( + f"Unable to resolve released public factory return type " + f"{module_name}.{owner_name}: {error!r}" + ) + continue + if not isinstance(class_value, type): + errors.append( + f"Released public factory {module_name}.{owner_name} no longer returns a class" + ) + continue + for property_name in entry["names"]: + descriptor = inspect.getattr_static(class_value, property_name, None) + if not isinstance(descriptor, property): + errors.append( + f"{module_name}.{owner_name}.{property_name} " + "removed or changed a released public property" + ) + return errors + + +def _validate_public_class_contract( + contract: dict[str, Any], + agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, +) -> list[str]: + errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} + for entry in contract.get("public_class_contracts", []): + module_name = entry["module"] + class_name = entry["class_name"] + optional_dependency = surface._optional_dependency_for_binding( + contract, module_name, class_name + ) + if ( + optional_dependency is not None + and not surface._optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue + try: + module = surface._import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + class_value = getattr(module, class_name, None) + if not isinstance(class_value, type): + errors.append(f"Missing released public class {module_name}.{class_name}") + continue + if "abstract" in entry and inspect.isabstract(class_value) != entry["abstract"]: + expected_state = "abstract" if entry["abstract"] else "concrete" + current_state = "abstract" if inspect.isabstract(class_value) else "concrete" + errors.append( + f"{module_name}.{class_name} changed its released public class state: " + f"expected {expected_state}, got {current_state}" + ) + if "abstract_members" in entry: + current_members = sorted(getattr(class_value, "__abstractmethods__", ())) + if current_members != entry["abstract_members"]: + errors.append( + f"{module_name}.{class_name} changed its released public abstract members: " + f"expected {entry['abstract_members']!r}, got {current_members!r}" + ) + return errors + + +def _validate_public_typed_dict_contract( + contract: dict[str, Any], + agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, +) -> list[str]: + errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} + for entry in contract.get("public_typed_dicts", []): + module_name = entry["module"] + class_name = entry["class_name"] + optional_dependency = surface._optional_dependency_for_binding( + contract, module_name, class_name + ) + if ( + optional_dependency is not None + and not surface._optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue + try: + module = surface._import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + typed_dict = getattr(module, class_name, None) + if not typing_extensions.is_typeddict(typed_dict): + errors.append(f"Missing released public TypedDict {module_name}.{class_name}") + continue + for released_field in entry["fields"]: + current_field = surface._typed_dict_field_contract(typed_dict, released_field["name"]) + if current_field != released_field: + errors.append( + f"{module_name}.{class_name}.{released_field['name']} changed its released " + f"TypedDict field contract: expected {released_field!r}, got " + f"{current_field!r}" + ) + return errors + + +def _validate_public_type_alias_contract( + contract: dict[str, Any], + agents_module: Any | None, + *, + unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None, +) -> list[str]: + errors: list[str] = [] + unsupported_platforms = unsupported_platforms or {} + missing = object() + for entry in contract.get("public_type_aliases", []): + module_name = entry["module"] + alias_name = entry["name"] + optional_dependency = surface._optional_dependency_for_binding( + contract, module_name, alias_name + ) + if ( + optional_dependency is not None + and not surface._optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue + try: + module = surface._import_contract_module(module_name, agents_module) + except Exception as error: + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + alias = getattr(module, alias_name, missing) + if alias is missing: + errors.append(f"Missing released public type alias {module_name}.{alias_name}") + continue + try: + current_definition = surface._type_alias_definition(alias) + except TypeError as error: + errors.append( + f"{module_name}.{alias_name} no longer has a supported released public type " + f"alias definition: {error}" + ) + continue + if current_definition != entry["definition"]: + errors.append( + f"{module_name}.{alias_name} changed its released public type alias: " + f"expected {entry['definition']!r}, got {current_definition!r}" + ) + return errors + + +def validate_released_api_contract( + contract: dict[str, Any], + *, + agents_module: Any | None = None, +) -> list[str]: + agents = agents_module or importlib.import_module("agents") + errors: list[str] = [] + + try: + unsupported_platforms = surface._optional_dependency_unsupported_platforms(contract) + except ValueError as error: + errors.append(f"Invalid released optional dependency platform declarations: {error}") + unsupported_platforms = {} + + errors.extend( + _validate_public_class_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) + errors.extend( + _validate_public_property_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) + errors.extend( + _validate_public_type_alias_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) + errors.extend( + _validate_public_typed_dict_contract( + contract, + agents_module, + unsupported_platforms=unsupported_platforms, + ) + ) + + missing_exports = sorted(set(contract["required_top_level_exports"]) - set(agents.__all__)) + if missing_exports: + errors.append(f"Missing released top-level exports: {missing_exports!r}") + missing_bindings = sorted( + name for name in contract["required_top_level_exports"] if not hasattr(agents, name) + ) + if missing_bindings: + errors.append(f"Missing released top-level bindings: {missing_bindings!r}") + + imported_modules: dict[str, object] = {"agents": agents} + for module_name in contract["public_modules"]: + try: + imported_modules[module_name] = surface._import_contract_module( + module_name, agents_module + ) + except Exception as error: + if surface._matches_platform_import_error(contract, module_name, error): + continue + optional_dependency = surface._optional_dependency_for_module_import( + contract, module_name + ) + if optional_dependency is not None and not ( + surface._optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue + errors.append(f"Failed to import released module {module_name}: {error!r}") + + for module_name, released in contract.get("required_submodule_exports", {}).items(): + module = imported_modules.get(module_name) + if module is None: + continue + try: + current = surface._submodule_export_contract(module) + except ValueError as error: + errors.append(f"Invalid released module exports for {module_name}: {error}") + continue + if current is None: + errors.append(f"Released module {module_name} no longer defines __all__") + continue + try: + optional_exports = surface._optional_dependency_modules( + released.get("optional_exports", {}), field_name="optional_exports" + ) + optional_bindings = surface._optional_dependency_modules( + released.get("optional_bindings", {}), field_name="optional_bindings" + ) + except ValueError as error: + errors.append( + f"Invalid released {module_name} optional dependency declarations: {error}" + ) + continue + unknown_optional_names = sorted( + (set(optional_bindings) | set(optional_exports)) - set(released["names"]) + ) + if unknown_optional_names: + errors.append( + f"Invalid released {module_name} optional dependency declarations: " + f"names are not exported: {unknown_optional_names!r}" + ) + continue + try: + unsupported_optional_exports = { + name + for name, dependency_module in optional_exports.items() + if surface._optional_dependency_is_unsupported_for_contract( + dependency_module, unsupported_platforms + ) + } + unsupported_optional_bindings = { + name + for name, dependency_module in (optional_bindings | optional_exports).items() + if surface._optional_dependency_is_unsupported_for_contract( + dependency_module, unsupported_platforms + ) + } + unavailable_optional_exports = { + name + for name, dependency_module in optional_exports.items() + if not surface._optional_dependency_is_available_for_contract( + dependency_module, unsupported_platforms + ) + } + unavailable_optional_bindings = { + name + for name, dependency_module in (optional_bindings | optional_exports).items() + if not surface._optional_dependency_is_available_for_contract( + dependency_module, unsupported_platforms + ) + } + except (AttributeError, ImportError, ValueError) as error: + errors.append( + f"Unable to inspect released {module_name} optional dependencies: {error!r}" + ) + continue + current_names = set(current["names"]) + for name in sorted(unavailable_optional_exports & current_names): + try: + getattr(module, name) + except (AttributeError, ImportError): + if name in unsupported_optional_exports: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ on an unsupported platform but its " + "binding is unavailable" + ) + else: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ but its binding is unavailable; " + "declare it in optional_bindings instead of optional_exports" + ) + else: + if name not in unsupported_optional_exports: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ and its binding resolves; remove its " + "optional declaration or correct its dependency module" + ) + binding_only_names = set(optional_bindings) - set(optional_exports) + for name in sorted(unavailable_optional_bindings & binding_only_names): + if name not in current_names: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} is absent from __all__; declare it in optional_exports " + "instead of optional_bindings" + ) + continue + try: + getattr(module, name) + except (AttributeError, ImportError): + if name in unsupported_optional_bindings: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ on an unsupported platform but its " + "binding is unavailable" + ) + else: + if name not in unsupported_optional_bindings: + errors.append( + f"Invalid released {module_name} optional dependency declaration: " + f"{name!r} remains in __all__ and its binding resolves; remove its " + "optional declaration or correct its dependency module" + ) + missing_names = sorted( + set(released["names"]) - unavailable_optional_exports - current_names + ) + if missing_names: + errors.append(f"Missing released {module_name} exports: {missing_names!r}") + missing_required_bindings = [] + for name in released["names"]: + if name in unavailable_optional_bindings: + continue + try: + getattr(module, name) + except (AttributeError, ImportError): + missing_required_bindings.append(name) + if missing_required_bindings: + errors.append( + f"Missing released {module_name} bindings: {sorted(missing_required_bindings)!r}" + ) + + for entry in contract["canonical_imports"]: + optional_dependency = surface._optional_dependency_for_binding( + contract, entry["module"], entry["name"] + ) + if ( + optional_dependency is not None + and not surface._optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue + try: + module = surface._import_contract_module(entry["module"], agents_module) + except Exception as error: + if surface._matches_platform_import_error(contract, entry["module"], error): + continue + errors.append(f"Failed to import released module {entry['module']}: {error!r}") + continue + try: + canonical = surface._import_contract_module(entry["canonical_module"], agents_module) + except Exception as error: + if surface._matches_platform_import_error(contract, entry["canonical_module"], error): + continue + errors.append( + f"Failed to import released module {entry['canonical_module']}: {error!r}" + ) + continue + missing = object() + actual = getattr(module, entry["name"], missing) + expected = getattr(canonical, entry["canonical_name"], missing) + if actual is missing or expected is missing or actual is not expected: + errors.append( + f"{entry['module']}.{entry['name']} no longer resolves to " + f"{entry['canonical_module']}.{entry['canonical_name']}" + ) + + for name, released in contract["callables"].items(): + if name.startswith("agents."): + module_name, _, binding_name = name.rpartition(".") + optional_dependency = surface._optional_dependency_for_binding( + contract, module_name, binding_name + ) + if optional_dependency is not None and not ( + surface._optional_dependency_is_available_for_contract( + optional_dependency, unsupported_platforms + ) + ): + continue + try: + module = surface._import_contract_module(module_name, agents_module) + except Exception as error: + if surface._matches_platform_import_error(contract, module_name, error): + continue + errors.append(f"Failed to import released module {module_name}: {error!r}") + continue + value = getattr(module, binding_name, None) + if value is None: + canonical_entry = next( + ( + entry + for entry in contract["canonical_imports"] + if entry["module"] == module_name and entry["name"] == binding_name + ), + None, + ) + if canonical_entry is not None: + try: + surface._import_contract_module( + canonical_entry["canonical_module"], agents_module + ) + except Exception as error: + if surface._matches_platform_import_error( + contract, canonical_entry["canonical_module"], error + ): + continue + else: + module_name = "agents" + binding_name = name + value = getattr(agents, binding_name, None) + if value is None: + errors.append(f"Missing released callable {module_name}.{binding_name}") + continue + current_kind = surface._callable_kind(value) + if current_kind != released["kind"]: + errors.append( + f"Released callable {module_name}.{binding_name} changed kind from " + f"{released['kind']} to {current_kind or type(value).__name__}" + ) + continue + released_execution_kind = released.get("execution_kind") + if released_execution_kind is not None: + current_execution_kind = surface._function_execution_kind(value) + if current_execution_kind != released_execution_kind: + errors.append( + f"{name} changed execution from " + f"{released_execution_kind} to {current_execution_kind}" + ) + current_parameters = surface._parameter_contract(value) + errors.extend( + _validate_parameter_contract(name, released["parameters"], current_parameters) + ) + current_fields = surface._dataclass_field_contract(value) + released_fields = released["dataclass_fields"] + if current_fields[: len(released_fields)] != released_fields: + errors.append( + f"{name} changed its released dataclass field prefix: " + f"expected {released_fields!r}, got {current_fields!r}" + ) + for field in current_fields[len(released_fields) :]: + default = field["default"] + if field["init"] and isinstance(default, dict) and default.get("kind") == "required": + errors.append(f"{name}.{field['name']} added a required dataclass field") + released_model_fields = released.get("model_fields") + if released_model_fields is not None: + errors.extend( + _validate_pydantic_model_field_contract( + name, + cast(list[dict[str, object]], released_model_fields), + surface._pydantic_model_field_contract(value), + ) + ) + for member_name, released_member in released.get("members", {}).items(): + descriptor = surface._sdk_public_class_descriptor(value, member_name) + current_member = surface._class_member_contract(descriptor) + if current_member is None: + errors.append(f"{name}.{member_name} removed a released public method") + continue + if current_member["binding"] != released_member["binding"]: + errors.append( + f"{name}.{member_name} changed binding from " + f"{released_member['binding']} to {current_member['binding']}" + ) + continue + released_execution_kind = released_member.get("execution_kind") + if ( + released_execution_kind is not None + and current_member["execution_kind"] != released_execution_kind + ): + errors.append( + f"{name}.{member_name} changed execution from " + f"{released_execution_kind} to {current_member['execution_kind']}" + ) + errors.extend( + _validate_parameter_contract( + f"{name}.{member_name}", + released_member["parameters"], + cast(list[dict[str, object]], current_member["parameters"]), + ) + ) + released_enum_members = released.get("enum_members") + if released_enum_members is not None: + current_enum_members = surface._enum_member_contract(value) + if current_enum_members is None: + errors.append(f"{name} is no longer an enum") + continue + current_enum_members_by_name = { + member["name"]: member["value"] for member in current_enum_members + } + for member in released_enum_members: + member_name = member["name"] + if member_name not in current_enum_members_by_name: + errors.append(f"{name}.{member_name} removed or renamed a released enum member") + continue + current_value = current_enum_members_by_name[member_name] + if current_value != member["value"]: + errors.append( + f"{name}.{member_name} changed its released enum value: " + f"expected {member['value']!r}, got {current_value!r}" + ) + + return errors diff --git a/integration_tests/packaging/test_released_api_contract.py b/integration_tests/packaging/test_released_api_contract.py index ec23a1a4e5..89300dd70e 100644 --- a/integration_tests/packaging/test_released_api_contract.py +++ b/integration_tests/packaging/test_released_api_contract.py @@ -7,10 +7,8 @@ from packaging.requirements import Requirement from packaging.utils import canonicalize_name -from integration_tests._contract_support import ( - load_api_contract, - validate_released_api_contract, -) +from integration_tests._contract_surface import load_api_contract +from integration_tests._contract_validation import validate_released_api_contract pytestmark = pytest.mark.packaging diff --git a/integration_tests/packaging/test_run_state_compatibility.py b/integration_tests/packaging/test_run_state_compatibility.py index 7e6f86db8b..0d139854c9 100644 --- a/integration_tests/packaging/test_run_state_compatibility.py +++ b/integration_tests/packaging/test_run_state_compatibility.py @@ -6,7 +6,7 @@ from agents import Agent, RunState from agents.run_state import SUPPORTED_SCHEMA_VERSIONS -from integration_tests._contract_support import ( +from integration_tests._contract_state import ( _deserialize_common_sandbox_session_state, _redaction_observables, validate_historical_resume_behavior, diff --git a/integration_tests/security/test_local_sandbox_isolation.py b/integration_tests/security/test_local_sandbox_isolation.py index 5c6b09a102..0c8f30ee1e 100644 --- a/integration_tests/security/test_local_sandbox_isolation.py +++ b/integration_tests/security/test_local_sandbox_isolation.py @@ -29,7 +29,7 @@ ) from agents.sandbox.snapshot import NoopSnapshotSpec from agents.testing import ModelStep, ScriptedModel, UnexpectedModelCall -from integration_tests._contract_support import _redaction_observables +from integration_tests._contract_state import _redaction_observables from integration_tests.conftest import skip_or_fail pytestmark = pytest.mark.security diff --git a/integration_tests/security/test_packaged_mount_redaction.py b/integration_tests/security/test_packaged_mount_redaction.py index 6d0ff66fdf..6abff27941 100644 --- a/integration_tests/security/test_packaged_mount_redaction.py +++ b/integration_tests/security/test_packaged_mount_redaction.py @@ -22,7 +22,7 @@ ) from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult, User -from integration_tests._contract_support import _redaction_observables +from integration_tests._contract_state import _redaction_observables pytestmark = pytest.mark.security diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 1cabc39756..fbb5620287 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -20,22 +20,27 @@ from pydantic import BaseModel, Field from typing_extensions import Required, TypeAliasType, TypedDict -import integration_tests._contract_support as contract_support +import integration_tests._contract_surface as contract_surface from integration_tests._contract_support import ( + _validate_voice_public_class_contract_policy, + build_released_api_contract, +) +from integration_tests._contract_surface import ( OptionalDependencyInstallation, SubmoduleExportPolicy, _callable_contract, _default_contract, _parameter_contract, _public_class_member_contract, + load_api_contract, + load_submodule_export_policy, +) +from integration_tests._contract_validation import ( _validate_parameter_contract, _validate_public_class_contract, _validate_public_property_contract, _validate_public_type_alias_contract, _validate_public_typed_dict_contract, - build_released_api_contract, - load_api_contract, - load_submodule_export_policy, validate_released_api_contract, ) @@ -131,11 +136,11 @@ def test_optional_dependency_for_module_import_uses_canonical_bindings() -> None } assert ( - contract_support._optional_dependency_for_module_import(contract, "agents.voice.pipeline") + contract_surface._optional_dependency_for_module_import(contract, "agents.voice.pipeline") == "numpy" ) assert ( - contract_support._optional_dependency_for_binding( + contract_surface._optional_dependency_for_binding( contract, "agents.voice.pipeline", "VoicePipeline" ) == "numpy" @@ -468,7 +473,7 @@ def scripted_session() -> ScriptedSession: } testing_module = SimpleNamespace(scripted_session=scripted_session) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda _module_name, _agents_module: testing_module, ) @@ -503,7 +508,7 @@ class EventB: "agents.aliases": aliases_module, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: modules[module_name], ) @@ -902,7 +907,7 @@ def test_originless_alias_facade_requires_unambiguous_package_provenance( module.__package__ = package_name monkeypatch.setitem(sys.modules, module.__name__, module) monkeypatch.setattr( - contract_support.inspect, + contract_surface.inspect, "getsource", lambda module: sources[module.__name__], ) @@ -941,7 +946,7 @@ def test_module_binding_visitor_ignores_type_parameter_bindings() -> None: " return value\n" ) - bindings = contract_support._ModuleBindingVisitor("PublicAlias") + bindings = contract_surface._ModuleBindingVisitor("PublicAlias") bindings.visit(module_tree) assert bindings.count == 1 @@ -1128,7 +1133,7 @@ def __class_getitem__(cls, parameter: object) -> object: if error_type is RuntimeError: with pytest.raises(ValueError) as exc_info: - contract_support._public_type_alias_contract(policy_entries, agents_module) + contract_surface._public_type_alias_contract(policy_entries, agents_module) assert str(exc_info.value) == ( "Cannot promote public type alias agents.Public: " "cannot resolve public type alias Public at runtime: " @@ -1136,7 +1141,7 @@ def __class_getitem__(cls, parameter: object) -> object: ) else: with pytest.raises(error_type, match="alias evaluation failed"): - contract_support._public_type_alias_contract(policy_entries, agents_module) + contract_surface._public_type_alias_contract(policy_entries, agents_module) @pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+") @@ -1200,7 +1205,7 @@ class ReleasedState(TypedDict, total=False): } testing_module = SimpleNamespace(ReleasedState=ReleasedState) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda _module_name, _agents_module: testing_module, ) @@ -1244,12 +1249,12 @@ def test_typed_dict_requiredness_annotation_contract_is_python_version_independe ) -> None: annotation = object() monkeypatch.setattr( - contract_support.inspect, + contract_surface.inspect, "formatannotation", lambda value: formatted if value is annotation else repr(value), ) - assert contract_support._annotation_contract(annotation) == expected + assert contract_surface._annotation_contract(annotation) == expected def test_curated_public_property_contract_honors_optional_dependency_availability( @@ -1277,17 +1282,17 @@ class OptionalClient: agents_module = SimpleNamespace(__all__=[]) optional_module = SimpleNamespace(OptionalClient=OptionalClient) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else optional_module ), ) - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: False) assert _validate_public_property_contract(contract, agents_module) == [] - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: True) assert _validate_public_property_contract(contract, agents_module) == [ "agents.optional.OptionalClient.status removed or changed a released public property" @@ -1569,7 +1574,7 @@ def incompatible(renamed: str, optional: int = 1) -> None: agents_module = SimpleNamespace(__all__=[]) submodule = SimpleNamespace(released=incompatible) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -1598,7 +1603,7 @@ def helper(value: str = "default") -> None: agents_module = SimpleNamespace(__all__=[]) submodule = SimpleNamespace(helper=helper) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -1726,7 +1731,7 @@ def test_public_api_contract_requires_released_submodule_exports( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -1791,7 +1796,7 @@ def raise_platform_error(module_name: str, _: Any) -> Any: raise ImportError("Backend is not supported on Windows. Use another backend.") monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_import_contract_module", raise_platform_error) + monkeypatch.setattr(contract_surface, "_import_contract_module", raise_platform_error) assert validate_released_api_contract(contract, agents_module=agents_module) == [] @@ -1830,7 +1835,7 @@ def import_platform_module(module_name: str, _: Any) -> Any: raise ImportError("Backend is not supported on Windows. Use another backend.") monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_import_contract_module", import_platform_module) + monkeypatch.setattr(contract_surface, "_import_contract_module", import_platform_module) assert validate_released_api_contract(contract, agents_module=agents_module) == [] @@ -1859,7 +1864,7 @@ def raise_unexpected_error(module_name: str, _: Any) -> Any: raise ImportError("Unexpected dependency failure") monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_import_contract_module", raise_unexpected_error) + monkeypatch.setattr(contract_surface, "_import_contract_module", raise_unexpected_error) assert validate_released_api_contract(contract, agents_module=agents_module) == [ "Failed to import released module agents.platform_specific: " @@ -1892,7 +1897,7 @@ def raise_foreign_error(module_name: str, _: Any) -> Any: raise foreign_import_error("Backend is not supported on Windows.") monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_import_contract_module", raise_foreign_error) + monkeypatch.setattr(contract_surface, "_import_contract_module", raise_foreign_error) errors = validate_released_api_contract(contract, agents_module=agents_module) assert len(errors) == 1 @@ -2136,7 +2141,7 @@ def test_release_contract_update_promotes_selected_submodule_exports( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -2192,7 +2197,7 @@ def __init__(self, value: str, optional: int = 1) -> None: "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -2258,7 +2263,7 @@ class ExternalPublic: "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -2303,9 +2308,9 @@ def __init__(self, value: str) -> None: "callables": {"agents.optional_parent.OptionalPublic": callable_contract}, } monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: False) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -2376,7 +2381,7 @@ def import_platform_module(module_name: str, _: Any) -> Any: raise ImportError("Backend is not supported on Windows.") monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_import_contract_module", import_platform_module) + monkeypatch.setattr(contract_surface, "_import_contract_module", import_platform_module) updated = build_released_api_contract( contract, @@ -2420,10 +2425,10 @@ def test_release_contract_policy_preserves_new_optional_export_in_core_install( def import_module(module_name: str, _agents_module: object) -> object: return agents_module if module_name == "agents" else imported_submodule - monkeypatch.setattr(contract_support, "_import_contract_module", import_module) + monkeypatch.setattr(contract_surface, "_import_contract_module", import_module) dependency_available = True monkeypatch.setattr( - contract_support, + contract_surface, "_optional_dependency_is_available", lambda _module_name: dependency_available, ) @@ -2499,7 +2504,7 @@ class PublicState(TypedDict, total=False): "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: modules[module_name], ) @@ -2645,7 +2650,7 @@ class UnrelatedPublicClass: ), } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: modules[module_name], ) @@ -2728,7 +2733,7 @@ class PublicState(TypedDict, total=False): "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: { "agents": agents_module, @@ -2808,8 +2813,8 @@ def import_module(module_name: str, _agents_module: object) -> object: raise AssertionError(f"Unexpected import: {module_name}") monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) - monkeypatch.setattr(contract_support, "_import_contract_module", import_module) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr(contract_surface, "_import_contract_module", import_module) updated = build_released_api_contract( contract, @@ -2860,7 +2865,7 @@ def test_release_contract_policy_rejects_new_callable_on_unsupported_platform( } monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: False) with pytest.raises( ValueError, @@ -2928,7 +2933,7 @@ class Uninspectable(metaclass=UninspectableMeta): "agents.submodule.impl": submodule, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: modules[module_name], ) @@ -2993,7 +2998,7 @@ class Uninspectable(metaclass=UninspectableMeta): "agents.submodule.impl": submodule, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: modules[module_name], ) @@ -3040,9 +3045,9 @@ def __init__(self, value: str) -> None: "callables": {"agents.submodule.OptionalBackend": _callable_contract(OptionalBackend)}, } monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: True) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -3081,9 +3086,9 @@ def test_public_api_contract_allows_present_optional_surface_on_unsupported_plat "callables": {}, } monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: True) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -3113,9 +3118,9 @@ def test_public_api_contract_rejects_dangling_optional_export_on_unsupported_pla "callables": {}, } monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: True) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -3149,9 +3154,9 @@ def test_public_api_contract_rejects_dangling_optional_binding_on_unsupported_pl "callables": {}, } monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: True) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -3185,9 +3190,9 @@ def test_public_api_contract_requires_optional_surface_on_supported_platform( "callables": {}, } monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(contract_support, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: True) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -3214,12 +3219,12 @@ def test_release_contract_policy_rejects_unavailable_dependency_module( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_optional_dependency_is_available", lambda _module_name: False, ) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -3262,12 +3267,12 @@ def test_release_contract_policy_adds_new_public_optional_module( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_optional_dependency_is_available", lambda _module_name: True, ) monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -3644,7 +3649,7 @@ def test_repository_release_policy_declares_public_optional_modules() -> None: def test_repository_release_policy_declares_public_state_surfaces() -> None: policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) - contract_support._validate_voice_public_class_contract_policy(policy, None) + _validate_voice_public_class_contract_policy(policy, None) expected_modules = { "agents.realtime.testing", "agents.testing", @@ -3802,7 +3807,7 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: (cast(str, entry["module"]), cast(str, entry["name"])): cast( dict[str, Any], entry["definition"] ) - for entry in contract_support._public_type_alias_contract(policy.public_type_aliases, None) + for entry in contract_surface._public_type_alias_contract(policy.public_type_aliases, None) } tts_voice = type_aliases[("agents.voice.model", "TTSVoice")] assert tts_voice["kind"] == "union" @@ -4085,7 +4090,7 @@ def test_public_api_contract_allows_declared_optional_submodule_binding( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -4121,7 +4126,7 @@ def import_module(module_name: str, _agents_module: object) -> object: return agents_module raise ImportError("The optional dependency is unavailable.") - monkeypatch.setattr(contract_support, "_import_contract_module", import_module) + monkeypatch.setattr(contract_surface, "_import_contract_module", import_module) assert validate_released_api_contract(contract, agents_module=agents_module) == [] @@ -4145,7 +4150,7 @@ def test_public_api_contract_allows_declared_optional_submodule_export( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -4174,7 +4179,7 @@ def test_public_api_contract_rejects_optional_export_that_remains_in_all( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -4207,7 +4212,7 @@ def test_public_api_contract_rejects_optional_binding_absent_from_all( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -4241,7 +4246,7 @@ def test_public_api_contract_requires_available_optional_submodule_export( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -4275,7 +4280,7 @@ def test_public_api_contract_treats_loaded_dependency_without_spec_as_available( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -4326,7 +4331,7 @@ def test_public_api_contract_rejects_malformed_optional_dependency_declarations( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule @@ -4360,7 +4365,7 @@ def test_release_contract_update_rejects_new_submodule_export_without_binding( "callables": {}, } monkeypatch.setattr( - contract_support, + contract_surface, "_import_contract_module", lambda module_name, _agents_module: ( agents_module if module_name == "agents" else submodule diff --git a/tests/test_run_state_compatibility_corpus.py b/tests/test_run_state_compatibility_corpus.py index 19232245cb..52996188cc 100644 --- a/tests/test_run_state_compatibility_corpus.py +++ b/tests/test_run_state_compatibility_corpus.py @@ -21,7 +21,7 @@ from agents.run_context import RunContextWrapper from agents.run_state import SUPPORTED_SCHEMA_VERSIONS from agents.sandbox.entries.mounts.patterns import FuseMountConfig -from integration_tests._contract_support import ( +from integration_tests._contract_state import ( _deserialize_common_sandbox_session_state, _find_subset_errors, _normalized_durable_state, From f1ffb3db89d146e11d6aaf5c3d2bfc69908ae2b9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 18:13:16 +0900 Subject: [PATCH 449/473] test: organize Realtime session tests by responsibility (#4879) --- tests/realtime/README.md | 102 + tests/realtime/session_test_support.py | 112 + tests/realtime/test_session.py | 3798 ++----------------- tests/realtime/test_session_approvals.py | 1047 +++++ tests/realtime/test_session_guardrails.py | 1106 ++++++ tests/realtime/test_session_history.py | 735 ++++ tests/realtime/test_session_tool_outputs.py | 430 +++ 7 files changed, 3802 insertions(+), 3528 deletions(-) create mode 100644 tests/realtime/README.md create mode 100644 tests/realtime/session_test_support.py create mode 100644 tests/realtime/test_session_approvals.py create mode 100644 tests/realtime/test_session_guardrails.py create mode 100644 tests/realtime/test_session_history.py create mode 100644 tests/realtime/test_session_tool_outputs.py diff --git a/tests/realtime/README.md b/tests/realtime/README.md new file mode 100644 index 0000000000..b749b7dd0d --- /dev/null +++ b/tests/realtime/README.md @@ -0,0 +1,102 @@ +# Realtime session test groups + +Run a behavior group directly from the repository root: + +```bash +uv run pytest tests/realtime/test_session_approvals.py +uv run pytest tests/realtime/test_session_tool_outputs.py +uv run pytest tests/realtime/test_session_guardrails.py +uv run pytest tests/realtime/test_session_history.py +uv run pytest tests/realtime +``` + +File selection avoids importing or collecting the other session groups. It is a navigation and test-selection boundary; splitting files does not by itself imply a faster full suite. Existing `-k` filters and class/function selection still work within each new file. + +| Module | Responsibility | +| --- | --- | +| [test_session.py](test_session.py) | Session entry/exit, event forwarding, tool dispatch and timeouts, handoffs, model settings, and update-agent behavior. | +| [test_session_approvals.py](test_session_approvals.py) | Function-tool approval requests, sticky decisions, rejection formatting, and pre/post-approval input guardrails. | +| [test_session_tool_outputs.py](test_session_tool_outputs.py) | Function-tool output serialization, send failures, and retries without repeating execution. | +| [test_session_guardrails.py](test_session_guardrails.py) | Response-scoped output guardrails, feedback ordering, and audio interruption. | +| [test_session_history.py](test_session_history.py) | Item insertion/update/deletion, transcript merging, and transcript preservation. | + +## Fixture and helper ownership + +`session_test_support.py` owns the existing `_DummyModel`, `RecordingRealtimeModel`, and shared function-tool helpers. Each model instance remains constructed per test. The session modules explicitly bind only the fixtures they use from that support module: `mock_agent`, `mock_model`, and, for tool-related modules, `mock_function_tool`. These fixtures retain pytest's default function scope and are not autouse. There is no Realtime-wide `conftest.py` introducing them to unrelated test modules. + +`TestGuardrailFunctionality` keeps its function-scoped `triggered_guardrail` and `safe_guardrail` fixtures and `_wait_for_guardrail_tasks` helper. Specialized blocking/failing model subclasses stay inside their original test functions. `_FakeAudio` belongs to history tests; `TestToolCallExecution.ToolResult` belongs to output serialization tests. The original file retains its connection/enablement/traceback helpers and `mock_handoff` fixture. Repository-wide tracing, fake API credentials, and cleanup remain owned by `tests/conftest.py`. + +## Relocation map + +The source for every relocation below is `test_session.py` at `3e0e89374f629c974929054e56e823a43c91a013`. Class names, function names, and every bracketed parameter ID are unchanged. Replace only the file prefix of an old pytest node ID. Any node not listed below remains in `test_session.py`, including lifecycle tests that exercise history suppression after close or background guardrail cleanup. + +For example: + +```text +tests/realtime/test_session.py::TestToolCallExecution::test_serialize_tool_output_edge_cases[dataclass] + -> tests/realtime/test_session_tool_outputs.py::TestToolCallExecution::test_serialize_tool_output_edge_cases[dataclass] +``` + +Whole classes move as follows: + +| Original class | Destination | +| --- | --- | +| `TestHistoryManagement` | `test_session_history.py` | +| `TestTranscriptPreservation` | `test_session_history.py` | +| `TestGuardrailFunctionality` | `test_session_guardrails.py` | + +The remaining relocations below retain their original containing class, where shown. All other methods of the partial classes remain in `test_session.py`. + +### test_session_approvals.py + +- `TestToolCallExecution::test_approval_resume_uses_pending_initial_settings_dispatch_snapshot` +- `TestToolCallExecution::test_function_tool_needs_approval_emits_event` +- `TestToolCallExecution::test_callable_function_approval_fails_closed_for_invalid_arguments` +- `TestToolCallExecution::test_callable_function_approval_receives_valid_object_arguments` +- `TestToolCallExecution::test_tool_input_guardrail_rejects_before_realtime_function_execution` +- `TestToolCallExecution::test_realtime_pending_approval_skips_tool_input_guardrails_by_default` +- `TestToolCallExecution::test_realtime_pre_approval_tool_input_guardrail_rejects_pending_approval` +- `TestToolCallExecution::test_realtime_pre_approval_tool_input_guardrails_rerun_after_approval` +- `TestToolCallExecution::test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_once` +- `TestToolCallExecution::test_approve_pending_tool_call_runs_tool` +- `TestToolCallExecution::test_async_approve_pending_tool_call_reserves_call_id_before_task_runs` +- `TestToolCallExecution::test_always_approve_namespaced_tool_call_does_not_approve_bare_tool` +- `TestToolCallExecution::test_reject_pending_tool_call_sends_rejection_output` +- `TestToolCallExecution::test_reject_pending_tool_call_reserves_call_id_before_sending` +- `TestToolCallExecution::test_reject_pending_tool_call_uses_run_level_formatter` +- `TestToolCallExecution::test_rejection_formatter_error_is_redacted` +- `TestToolCallExecution::test_cancelled_rejection_formatter_leaves_invocation_executed` +- `TestToolCallExecution::test_reject_pending_tool_call_prefers_explicit_message` +- `TestToolCallExecution::test_always_reject_namespaced_tool_call_reuses_explicit_message` +- `TestToolCallExecution::test_sticky_rejection_does_not_bind_duplicate_call_id_payload` +- `TestToolCallExecution::test_sticky_rejection_skips_dynamic_approval_checker` +- `TestToolCallExecution::test_sticky_rejection_wins_while_dynamic_approval_checker_is_pending` +- `TestToolCallExecution::test_sticky_decision_wins_while_rejecting_pre_approval_guardrail_is_pending` + +### test_session_tool_outputs.py + +- `TestToolCallExecution::test_approved_function_tool_failure_replay_does_not_rerun` +- `TestToolCallExecution::test_function_tool_send_failure_retries_cached_output_without_rerun` +- `TestToolCallExecution::test_tool_end_cancellation_after_output_send_does_not_resend` +- `TestToolCallExecution::test_async_function_tool_send_failure_retries_cached_output_without_rerun` +- `TestToolCallExecution::test_pending_function_output_rejects_handoff_role_reuse` +- `TestToolCallExecution::test_async_exact_function_retry_after_serialization_failure_does_not_repeat_callback` +- `TestToolCallExecution::test_tool_result_conversion_to_string` +- `TestToolCallExecution::test_tool_result_conversion_serializes_pydantic_models` +- `TestToolCallExecution::test_serialize_tool_output_ignores_non_pydantic_model_dump_objects` +- `TestToolCallExecution::test_serialize_tool_output_falls_back_when_pydantic_json_dump_fails` +- `TestToolCallExecution::test_serialize_tool_output_returns_string_when_pydantic_dump_fails` +- `TestToolCallExecution::test_serialize_tool_output_returns_string_when_dataclass_asdict_fails` +- `TestToolCallExecution::test_serialize_tool_output_edge_cases` + +### test_session_history.py + +- `test_transcription_completed_adds_new_user_item` +- `test_item_updated_merge_exception_path_logs_error` +- `TestEventHandling::test_transcription_completed_event_updates_history` +- `TestEventHandling::test_item_updated_event_adds_new_item` +- `TestEventHandling::test_item_updated_event_updates_existing_item` +- `TestEventHandling::test_item_updated_event_completes_tool_call` +- `TestEventHandling::test_item_deleted_event_removes_item` + +The relocation preserves all 198 collected session cases: 95 in `test_session.py`, 30 in `test_session_approvals.py`, 22 in `test_session_tool_outputs.py`, 29 in `test_session_guardrails.py`, 22 in `test_session_history.py`. These counts describe the relocation baseline, not a requirement for future test additions. diff --git a/tests/realtime/session_test_support.py b/tests/realtime/session_test_support.py new file mode 100644 index 0000000000..cdf958c8ab --- /dev/null +++ b/tests/realtime/session_test_support.py @@ -0,0 +1,112 @@ +"""Explicitly imported helpers and function-scoped fixtures for session tests.""" + +from typing import Any +from unittest.mock import AsyncMock, Mock, PropertyMock + +import pytest + +from agents.realtime.agent import RealtimeAgent +from agents.realtime.testing import RealtimeConnectCall, ScriptedRealtimeModel +from agents.tool import FunctionTool, function_tool + + +class _DummyModel(ScriptedRealtimeModel): + def __init__(self) -> None: + super().__init__(strict=False) + + @property + def events(self) -> tuple[Any, ...]: + return self.sent_events + + @property + def connect_options(self) -> RealtimeConnectCall | None: + return self.connect_calls[-1] if self.connect_calls else None + + +class RecordingRealtimeModel(ScriptedRealtimeModel): + def __init__(self): + super().__init__(strict=False) + # Legacy tracking for tests that haven't been updated yet + self.sent_messages = [] + self.sent_audio = [] + self.sent_tool_outputs = [] + self.interrupts_called = 0 + self.retired_audio_response_ids = [] + + async def send_event(self, event): + from agents.realtime.model_inputs import ( + RealtimeModelSendAudio, + RealtimeModelSendInterrupt, + RealtimeModelSendToolOutput, + RealtimeModelSendUserInput, + ) + + self._sent_events.append(self._snapshot_send_event(event)) + + # Update legacy tracking for compatibility + if isinstance(event, RealtimeModelSendUserInput): + self.sent_messages.append(event.user_input) + elif isinstance(event, RealtimeModelSendAudio): + self.sent_audio.append((event.audio, event.commit)) + elif isinstance(event, RealtimeModelSendToolOutput): + self.sent_tool_outputs.append((event.tool_call, event.output, event.start_response)) + elif isinstance(event, RealtimeModelSendInterrupt): + self.interrupts_called += 1 + + async def send_event_if(self, event, send_if): + if not send_if(): + return False + await self.send_event(event) + return True + + def _retire_response_audio(self, response_id: str) -> None: + self.retired_audio_response_ids.append(response_id) + + +@pytest.fixture +def mock_agent(): + agent = Mock(spec=RealtimeAgent) + agent.get_all_tools = AsyncMock(return_value=[]) + + type(agent).handoffs = PropertyMock(return_value=[]) + type(agent).output_guardrails = PropertyMock(return_value=[]) + return agent + + +@pytest.fixture +def mock_model(): + return RecordingRealtimeModel() + + +def _set_default_timeout_fields(tool: Mock) -> Mock: + tool.timeout_seconds = None + tool.timeout_behavior = "error_as_result" + tool.timeout_error_function = None + return tool + + +def _named_function_tool( + name: str, + output: str, + *, + needs_approval: bool = False, +) -> FunctionTool: + def tool_func() -> str: + return output + + tool = function_tool(tool_func, name_override=name) + tool.needs_approval = needs_approval + return tool + + +def _sent_tool_output_strings(model: RecordingRealtimeModel) -> list[str]: + return [output for _call, output, _start_response in model.sent_tool_outputs] + + +@pytest.fixture +def mock_function_tool(): + tool = _set_default_timeout_fields(Mock(spec=FunctionTool)) + tool.name = "test_function" + tool.on_invoke_tool = AsyncMock(return_value="function_result") + tool.needs_approval = False + return tool diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 178d37fa8e..147520565c 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -1,21 +1,17 @@ +"""Session lifecycle, model settings, event forwarding, and tool dispatch tests.""" + import asyncio -import dataclasses import json -import logging -import threading import traceback from pathlib import Path from typing import Any, Literal, cast -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest -from pydantic import BaseModel, ConfigDict import agents._debug as _debug -from agents._tool_identity import get_function_tool_lookup_key_for_tool from agents.agent import AgentBase from agents.exceptions import ModelBehaviorError, ToolTimeoutError, UserError -from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail from agents.handoffs import Handoff from agents.realtime import realtime_handoff from agents.realtime.agent import RealtimeAgent @@ -27,24 +23,10 @@ RealtimeAudioEnd, RealtimeAudioInterrupted, RealtimeError, - RealtimeGuardrailTripped, - RealtimeHistoryAdded, - RealtimeHistoryUpdated, RealtimeRawModelEvent, - RealtimeToolApprovalRequired, RealtimeToolEnd, RealtimeToolStart, ) -from agents.realtime.items import ( - AssistantAudio, - AssistantMessageItem, - AssistantText, - InputAudio, - InputText, - RealtimeItem, - RealtimeToolCallItem, - UserMessageItem, -) from agents.realtime.model import RealtimeModel, RealtimeModelConfig from agents.realtime.model_events import ( RealtimeModelAudioDoneEvent, @@ -54,10 +36,7 @@ RealtimeModelEndOfStreamEvent, RealtimeModelErrorEvent, RealtimeModelInputAudioTranscriptionCompletedEvent, - RealtimeModelItemDeletedEvent, - RealtimeModelItemUpdatedEvent, RealtimeModelOtherEvent, - RealtimeModelOutputTextDeltaEvent, RealtimeModelToolCallEvent, RealtimeModelTranscriptDeltaEvent, RealtimeModelTurnEndedEvent, @@ -72,12 +51,9 @@ RealtimeModelSendUserInput, ) from agents.realtime.session import ( - REJECTION_MESSAGE, RealtimeSession, _PendingToolOutputSendError, - _serialize_tool_output, ) -from agents.realtime.testing import RealtimeConnectCall, ScriptedRealtimeModel from agents.run_context import RunContextWrapper from agents.tool import FunctionTool, function_tool, tool_namespace from agents.tool_context import ToolContext @@ -88,18 +64,18 @@ ) from agents.usage import Usage +from . import session_test_support +from .session_test_support import ( + _DummyModel, + _named_function_tool, + _sent_tool_output_strings, + _set_default_timeout_fields, +) -class _DummyModel(ScriptedRealtimeModel): - def __init__(self) -> None: - super().__init__(strict=False) - - @property - def events(self) -> tuple[Any, ...]: - return self.sent_events - - @property - def connect_options(self) -> RealtimeConnectCall | None: - return self.connect_calls[-1] if self.connect_calls else None +# Bind shared fixtures explicitly so unrelated Realtime modules do not inherit them. +mock_agent = session_test_support.mock_agent +mock_function_tool = session_test_support.mock_function_tool +mock_model = session_test_support.mock_model class _FailingConnectModel(_DummyModel): @@ -615,51 +591,6 @@ async def blocked_put_event(event): assert session._history == [] -@pytest.mark.asyncio -async def test_transcription_completed_adds_new_user_item(): - model = _DummyModel() - agent = RealtimeAgent(name="agent") - session = RealtimeSession(model, agent, None) - - event = RealtimeModelInputAudioTranscriptionCompletedEvent(item_id="item1", transcript="hello") - await session.on_event(event) - - # Should have appended a new user item - assert len(session._history) == 1 - assert session._history[0].type == "message" - assert session._history[0].role == "user" - - -class _FakeAudio: - # Looks like an audio part but is not an InputAudio/AssistantAudio instance - type = "audio" - transcript = None - - -@pytest.mark.asyncio -async def test_item_updated_merge_exception_path_logs_error(monkeypatch): - monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) - model = _DummyModel() - agent = RealtimeAgent(name="agent") - session = RealtimeSession(model, agent, None) - - # existing assistant message with transcript to preserve - existing = AssistantMessageItem( - item_id="a1", role="assistant", content=[AssistantAudio(audio=None, transcript="t")] - ) - session._history = [existing] - - # incoming message with a deliberately bogus content entry to trigger assertion path - incoming = AssistantMessageItem( - item_id="a1", role="assistant", content=[AssistantAudio(audio=None, transcript=None)] - ) - incoming.content[0] = cast(Any, _FakeAudio()) - - with patch("agents.realtime.session.logger") as mock_logger: - await session.on_event(RealtimeModelItemUpdatedEvent(item=incoming)) - mock_logger.error.assert_called_once_with("%s", "Error merging transcripts", stacklevel=3) - - @pytest.mark.asyncio async def test_handle_tool_call_handoff_invalid_result_raises(): model = _DummyModel() @@ -1290,95 +1221,6 @@ async def test_aenter_removes_listener_when_connect_fails(exc: BaseException): assert model.listeners == () -class RecordingRealtimeModel(ScriptedRealtimeModel): - def __init__(self): - super().__init__(strict=False) - # Legacy tracking for tests that haven't been updated yet - self.sent_messages = [] - self.sent_audio = [] - self.sent_tool_outputs = [] - self.interrupts_called = 0 - self.retired_audio_response_ids = [] - - async def send_event(self, event): - from agents.realtime.model_inputs import ( - RealtimeModelSendAudio, - RealtimeModelSendInterrupt, - RealtimeModelSendToolOutput, - RealtimeModelSendUserInput, - ) - - self._sent_events.append(self._snapshot_send_event(event)) - - # Update legacy tracking for compatibility - if isinstance(event, RealtimeModelSendUserInput): - self.sent_messages.append(event.user_input) - elif isinstance(event, RealtimeModelSendAudio): - self.sent_audio.append((event.audio, event.commit)) - elif isinstance(event, RealtimeModelSendToolOutput): - self.sent_tool_outputs.append((event.tool_call, event.output, event.start_response)) - elif isinstance(event, RealtimeModelSendInterrupt): - self.interrupts_called += 1 - - async def send_event_if(self, event, send_if): - if not send_if(): - return False - await self.send_event(event) - return True - - def _retire_response_audio(self, response_id: str) -> None: - self.retired_audio_response_ids.append(response_id) - - -@pytest.fixture -def mock_agent(): - agent = Mock(spec=RealtimeAgent) - agent.get_all_tools = AsyncMock(return_value=[]) - - type(agent).handoffs = PropertyMock(return_value=[]) - type(agent).output_guardrails = PropertyMock(return_value=[]) - return agent - - -@pytest.fixture -def mock_model(): - return RecordingRealtimeModel() - - -def _set_default_timeout_fields(tool: Mock) -> Mock: - tool.timeout_seconds = None - tool.timeout_behavior = "error_as_result" - tool.timeout_error_function = None - return tool - - -def _named_function_tool( - name: str, - output: str, - *, - needs_approval: bool = False, -) -> FunctionTool: - def tool_func() -> str: - return output - - tool = function_tool(tool_func, name_override=name) - tool.needs_approval = needs_approval - return tool - - -def _sent_tool_output_strings(model: RecordingRealtimeModel) -> list[str]: - return [output for _call, output, _start_response in model.sent_tool_outputs] - - -@pytest.fixture -def mock_function_tool(): - tool = _set_default_timeout_fields(Mock(spec=FunctionTool)) - tool.name = "test_function" - tool.on_invoke_tool = AsyncMock(return_value="function_result") - tool.needs_approval = False - return tool - - @pytest.fixture def mock_handoff(): handoff = Mock(spec=Handoff) @@ -1512,178 +1354,6 @@ async def test_usage_events_accumulate_in_session_context(self, mock_model, mock assert first_raw.info.context.usage.total_tokens == 24 assert len(first_raw.info.context.usage.request_usage_entries) == 2 - @pytest.mark.asyncio - async def test_transcription_completed_event_updates_history(self, mock_model, mock_agent): - """Test that transcription completed events update history and emit events""" - session = RealtimeSession( - mock_model, mock_agent, None, run_config={"async_tool_calls": False} - ) - - # Set up initial history with an audio message - initial_item = UserMessageItem( - item_id="item_1", role="user", content=[InputAudio(transcript=None)] - ) - session._history = [initial_item] - - # Create transcription completed event - transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( - item_id="item_1", transcript="Hello world" - ) - - await session.on_event(transcription_event) - - # Check that history was updated - assert len(session._history) == 1 - updated_item = session._history[0] - assert updated_item.content[0].transcript == "Hello world" # type: ignore - assert updated_item.status == "completed" # type: ignore - - # Should have 2 events: raw + history updated - assert session._event_queue.qsize() == 2 - - await session._event_queue.get() # raw event - history_event = await session._event_queue.get() - assert isinstance(history_event, RealtimeHistoryUpdated) - assert len(history_event.history) == 1 - - @pytest.mark.asyncio - async def test_item_updated_event_adds_new_item(self, mock_model, mock_agent): - """Test that item_updated events add new items to history""" - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - - new_item = AssistantMessageItem( - item_id="new_item", role="assistant", content=[AssistantText(text="Hello")] - ) - - item_updated_event = RealtimeModelItemUpdatedEvent(item=new_item) - - await session.on_event(item_updated_event) - - # Check that item was added to history - assert len(session._history) == 1 - assert session._history[0] == new_item - - # Should have 2 events: raw + history added - assert session._event_queue.qsize() == 2 - - await session._event_queue.get() # raw event - history_event = await session._event_queue.get() - assert isinstance(history_event, RealtimeHistoryAdded) - assert history_event.item == new_item - - @pytest.mark.asyncio - async def test_item_updated_event_updates_existing_item(self, mock_model, mock_agent): - """Test that item_updated events update existing items in history""" - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - - # Set up initial history - initial_item = AssistantMessageItem( - item_id="existing_item", role="assistant", content=[AssistantText(text="Initial")] - ) - session._history = [initial_item] - - # Create updated version - updated_item = AssistantMessageItem( - item_id="existing_item", role="assistant", content=[AssistantText(text="Updated")] - ) - - item_updated_event = RealtimeModelItemUpdatedEvent(item=updated_item) - - await session.on_event(item_updated_event) - - # Check that item was updated - assert len(session._history) == 1 - updated_item = cast(AssistantMessageItem, session._history[0]) - assert updated_item.content[0].text == "Updated" # type: ignore - - # Should have 2 events: raw + history updated (not added) - assert session._event_queue.qsize() == 2 - - await session._event_queue.get() # raw event - history_event = await session._event_queue.get() - assert isinstance(history_event, RealtimeHistoryUpdated) - - @pytest.mark.asyncio - async def test_item_updated_event_completes_tool_call(self, mock_model, mock_agent): - """The transport reuses one item for a tool call and its output, so the second - item_updated must land in history.""" - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - - in_progress = RealtimeToolCallItem( - item_id="fc_1", - previous_item_id=None, - call_id="call_1", - type="function_call", - status="in_progress", - arguments='{"city": "Oakland"}', - name="get_weather", - output=None, - ) - await session.on_event(RealtimeModelItemUpdatedEvent(item=in_progress)) - - completed = in_progress.model_copy(update={"status": "completed", "output": "sunny"}) - await session.on_event(RealtimeModelItemUpdatedEvent(item=completed)) - - assert len(session._history) == 1 - stored = cast(RealtimeToolCallItem, session._history[0]) - assert stored.status == "completed" - assert stored.output == "sunny" - - # raw + history added, then raw + history updated. - assert session._event_queue.qsize() == 4 - await session._event_queue.get() # raw event - assert isinstance(await session._event_queue.get(), RealtimeHistoryAdded) - await session._event_queue.get() # raw event - history_event = await session._event_queue.get() - assert isinstance(history_event, RealtimeHistoryUpdated) - assert cast(RealtimeToolCallItem, history_event.history[0]).output == "sunny" - - @pytest.mark.asyncio - async def test_item_deleted_event_removes_item(self, mock_model, mock_agent): - """Test that item_deleted events remove items from history""" - session = RealtimeSession(mock_model, mock_agent, None) - - # Set up initial history with multiple items - item1 = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="First")] - ) - item2 = AssistantMessageItem( - item_id="item_2", role="assistant", content=[AssistantText(text="Second")] - ) - session._history = [item1, item2] - - # Delete first item - delete_event = RealtimeModelItemDeletedEvent(item_id="item_1") - - await session.on_event(delete_event) - - # Check that item was removed - assert len(session._history) == 1 - assert session._history[0].item_id == "item_2" - - # Should have 2 events: raw + history updated - assert session._event_queue.qsize() == 2 - - await session._event_queue.get() # raw event - history_event = await session._event_queue.get() - assert isinstance(history_event, RealtimeHistoryUpdated) - assert len(history_event.history) == 1 - @pytest.mark.asyncio async def test_ignored_events_only_generate_raw_events(self, mock_model, mock_agent): """Test that ignored events (transcript_delta, connection_status, other) only generate raw @@ -1815,381 +1485,6 @@ async def test_function_call_event_runs_async_by_default(self, mock_model, mock_ assert raw_event.data == function_call_event -class TestHistoryManagement: - """Test suite for history management and audio transcription in - RealtimeSession._get_new_history""" - - def test_merge_transcript_into_existing_audio_message(self): - """Test merging audio transcript into existing placeholder input_audio message""" - # Create initial history with audio message without transcript - initial_item = UserMessageItem( - item_id="item_1", - role="user", - content=[ - InputText(text="Before audio"), - InputAudio(transcript=None, audio="audio_data"), - InputText(text="After audio"), - ], - ) - old_history = [initial_item] - - # Create transcription completed event - transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( - item_id="item_1", transcript="Hello world" - ) - - # Apply the history update - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), transcription_event - ) - - # Verify the transcript was merged - assert len(new_history) == 1 - updated_item = cast(UserMessageItem, new_history[0]) - assert updated_item.item_id == "item_1" - assert hasattr(updated_item, "status") and updated_item.status == "completed" - assert len(updated_item.content) == 3 - - # Check that audio content got transcript but other content unchanged - assert cast(InputText, updated_item.content[0]).text == "Before audio" - assert cast(InputAudio, updated_item.content[1]).transcript == "Hello world" - # Should preserve audio data - assert cast(InputAudio, updated_item.content[1]).audio == "audio_data" - assert cast(InputText, updated_item.content[2]).text == "After audio" - - def test_merge_transcript_preserves_other_items(self): - """Test that merging transcript preserves other items in history""" - # Create history with multiple items - item1 = UserMessageItem( - item_id="item_1", role="user", content=[InputText(text="First message")] - ) - item2 = UserMessageItem( - item_id="item_2", role="user", content=[InputAudio(transcript=None)] - ) - item3 = AssistantMessageItem( - item_id="item_3", role="assistant", content=[AssistantText(text="Third message")] - ) - old_history = [item1, item2, item3] - - # Create transcription event for item_2 - transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( - item_id="item_2", transcript="Transcribed audio" - ) - - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), transcription_event - ) - - # Should have same number of items - assert len(new_history) == 3 - - # First and third items should be unchanged - assert new_history[0] == item1 - assert new_history[2] == item3 - - # Second item should have transcript - updated_item2 = cast(UserMessageItem, new_history[1]) - assert updated_item2.item_id == "item_2" - assert cast(InputAudio, updated_item2.content[0]).transcript == "Transcribed audio" - assert hasattr(updated_item2, "status") and updated_item2.status == "completed" - - def test_merge_transcript_only_affects_matching_audio_content(self): - """Test that transcript merge only affects audio content, not text content""" - # Create item with mixed content including multiple audio items - item = UserMessageItem( - item_id="item_1", - role="user", - content=[ - InputText(text="Text content"), - InputAudio(transcript=None, audio="audio1"), - InputAudio(transcript="existing", audio="audio2"), - InputText(text="More text"), - ], - ) - old_history = [item] - - transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( - item_id="item_1", transcript="New transcript" - ) - - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), transcription_event - ) - - updated_item = cast(UserMessageItem, new_history[0]) - - # Text content should be unchanged - assert cast(InputText, updated_item.content[0]).text == "Text content" - assert cast(InputText, updated_item.content[3]).text == "More text" - - # All audio content should have the new transcript (current implementation overwrites all) - assert cast(InputAudio, updated_item.content[1]).transcript == "New transcript" - assert ( - cast(InputAudio, updated_item.content[2]).transcript == "New transcript" - ) # Implementation overwrites existing - - def test_update_existing_item_by_id(self): - """Test updating an existing item by item_id""" - # Create initial history - original_item = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="Original")] - ) - old_history = [original_item] - - # Create updated version of same item - updated_item = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="Updated")] - ) - - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), updated_item - ) - - # Should have same number of items - assert len(new_history) == 1 - - # Item should be updated - result_item = cast(AssistantMessageItem, new_history[0]) - assert result_item.item_id == "item_1" - assert result_item.content[0].text == "Updated" # type: ignore - - def test_update_existing_item_preserves_order(self): - """Test that updating existing item preserves its position in history""" - # Create history with multiple items - item1 = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="First")] - ) - item2 = AssistantMessageItem( - item_id="item_2", role="assistant", content=[AssistantText(text="Second")] - ) - item3 = AssistantMessageItem( - item_id="item_3", role="assistant", content=[AssistantText(text="Third")] - ) - old_history = [item1, item2, item3] - - # Update middle item - updated_item2 = AssistantMessageItem( - item_id="item_2", role="assistant", content=[AssistantText(text="Updated Second")] - ) - - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), updated_item2 - ) - - # Should have same number of items in same order - assert len(new_history) == 3 - assert new_history[0].item_id == "item_1" - assert new_history[1].item_id == "item_2" - assert new_history[2].item_id == "item_3" - - # Middle item should be updated - updated_result = cast(AssistantMessageItem, new_history[1]) - assert updated_result.content[0].text == "Updated Second" # type: ignore - - # Other items should be unchanged - item1_result = cast(AssistantMessageItem, new_history[0]) - item3_result = cast(AssistantMessageItem, new_history[2]) - assert item1_result.content[0].text == "First" # type: ignore - assert item3_result.content[0].text == "Third" # type: ignore - - def test_insert_new_item_after_previous_item(self): - """Test inserting new item after specified previous_item_id""" - # Create initial history - item1 = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="First")] - ) - item3 = AssistantMessageItem( - item_id="item_3", role="assistant", content=[AssistantText(text="Third")] - ) - old_history = [item1, item3] - - # Create new item to insert between them - new_item = AssistantMessageItem( - item_id="item_2", - previous_item_id="item_1", - role="assistant", - content=[AssistantText(text="Second")], - ) - - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), new_item - ) - - # Should have one more item - assert len(new_history) == 3 - - # Items should be in correct order - assert new_history[0].item_id == "item_1" - assert new_history[1].item_id == "item_2" - assert new_history[2].item_id == "item_3" - - # Content should be correct - item2_result = cast(AssistantMessageItem, new_history[1]) - assert item2_result.content[0].text == "Second" # type: ignore - - def test_insert_new_item_after_nonexistent_previous_item(self): - """Test that item with nonexistent previous_item_id gets added to end""" - # Create initial history - item1 = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="First")] - ) - old_history = [item1] - - # Create new item with nonexistent previous_item_id - new_item = AssistantMessageItem( - item_id="item_2", - previous_item_id="nonexistent", - role="assistant", - content=[AssistantText(text="Second")], - ) - - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), new_item - ) - - # Should add to end when previous_item_id not found - assert len(new_history) == 2 - assert new_history[0].item_id == "item_1" - assert new_history[1].item_id == "item_2" - - def test_add_new_item_to_end_when_no_previous_item_id(self): - """Test adding new item to end when no previous_item_id is specified""" - # Create initial history - item1 = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="First")] - ) - old_history = [item1] - - # Create new item without previous_item_id - new_item = AssistantMessageItem( - item_id="item_2", role="assistant", content=[AssistantText(text="Second")] - ) - - new_history = RealtimeSession._get_new_history( - cast(list[RealtimeItem], old_history), new_item - ) - - # Should add to end - assert len(new_history) == 2 - assert new_history[0].item_id == "item_1" - assert new_history[1].item_id == "item_2" - - def test_tool_call_item_update_replaces_existing_entry(self): - """A completed tool call replaces the in-progress entry it shares an item_id with.""" - in_progress = RealtimeToolCallItem( - item_id="item_1", - previous_item_id=None, - call_id="call_1", - type="function_call", - status="in_progress", - arguments='{"city": "Oakland"}', - name="get_weather", - output=None, - ) - completed = RealtimeToolCallItem( - item_id="item_1", - previous_item_id=None, - call_id="call_1", - type="function_call", - status="completed", - arguments='{"city": "Oakland"}', - name="get_weather", - output="sunny", - ) - - history = RealtimeSession._get_new_history([], in_progress) - history = RealtimeSession._get_new_history(history, completed) - - assert len(history) == 1 - updated = cast(RealtimeToolCallItem, history[0]) - assert updated.status == "completed" - assert updated.output == "sunny" - - def test_tool_call_item_update_preserves_other_items(self): - """Replacing a tool call entry leaves the surrounding history untouched.""" - before = UserMessageItem( - item_id="item_0", role="user", content=[InputText(text="what's the weather?")] - ) - after = AssistantMessageItem( - item_id="item_2", role="assistant", content=[AssistantText(text="It is sunny.")] - ) - in_progress = RealtimeToolCallItem( - item_id="item_1", - previous_item_id=None, - call_id="call_1", - type="function_call", - status="in_progress", - arguments="{}", - name="get_weather", - output=None, - ) - old_history = cast(list[RealtimeItem], [before, in_progress, after]) - - completed = in_progress.model_copy(update={"status": "completed", "output": "sunny"}) - new_history = RealtimeSession._get_new_history(old_history, completed) - - assert [item.item_id for item in new_history] == ["item_0", "item_1", "item_2"] - assert new_history[0] == before - assert new_history[2] == after - assert cast(RealtimeToolCallItem, new_history[1]).output == "sunny" - - def test_add_first_item_to_empty_history(self): - """Test adding first item to empty history""" - old_history: list[RealtimeItem] = [] - - new_item = AssistantMessageItem( - item_id="item_1", role="assistant", content=[AssistantText(text="First")] - ) - - new_history = RealtimeSession._get_new_history(old_history, new_item) - - assert len(new_history) == 1 - assert new_history[0].item_id == "item_1" - - def test_complex_insertion_scenario(self): - """Test complex scenario with multiple insertions and updates""" - # Start with items A and C - itemA = AssistantMessageItem( - item_id="A", role="assistant", content=[AssistantText(text="A")] - ) - itemC = AssistantMessageItem( - item_id="C", role="assistant", content=[AssistantText(text="C")] - ) - history: list[RealtimeItem] = [itemA, itemC] - - # Insert B after A - itemB = AssistantMessageItem( - item_id="B", previous_item_id="A", role="assistant", content=[AssistantText(text="B")] - ) - history = RealtimeSession._get_new_history(history, itemB) - - # Should be A, B, C - assert len(history) == 3 - assert [item.item_id for item in history] == ["A", "B", "C"] - - # Insert D after B - itemD = AssistantMessageItem( - item_id="D", previous_item_id="B", role="assistant", content=[AssistantText(text="D")] - ) - history = RealtimeSession._get_new_history(history, itemD) - - # Should be A, B, D, C - assert len(history) == 4 - assert [item.item_id for item in history] == ["A", "B", "D", "C"] - - # Update B - updated_itemB = AssistantMessageItem( - item_id="B", role="assistant", content=[AssistantText(text="Updated B")] - ) - history = RealtimeSession._get_new_history(history, updated_itemB) - - # Should still be A, B, D, C but B is updated - assert len(history) == 4 - assert [item.item_id for item in history] == ["A", "B", "D", "C"] - itemB_result = cast(AssistantMessageItem, history[1]) - assert itemB_result.content[0].text == "Updated B" # type: ignore - - # Test 3: Tool call execution flow (_handle_tool_call method) class TestToolCallExecution: """Test suite for tool call execution flow in RealtimeSession._handle_tool_call""" @@ -2468,43 +1763,6 @@ def is_enabled( finally: await session.__aexit__(None, None, None) - @pytest.mark.asyncio - async def test_approval_resume_uses_pending_initial_settings_dispatch_snapshot( - self, mock_model - ): - approved_tool = _named_function_tool( - "approval_tool", - "approved implementation", - needs_approval=True, - ) - replacement_tool = _named_function_tool("approval_tool", "replacement implementation") - initial_agent = RealtimeAgent(name="initial", tools=[], handoffs=[]) - replacement_agent = RealtimeAgent(name="replacement", tools=[replacement_tool], handoffs=[]) - session = RealtimeSession( - mock_model, - initial_agent, - None, - model_config={"initial_model_settings": {"tools": [approved_tool]}}, - run_config={"async_tool_calls": False}, - ) - tool_call_event = RealtimeModelToolCallEvent( - name="approval_tool", - call_id="call_pending_snapshot", - arguments="{}", - ) - - await session.__aenter__() - try: - await session._handle_tool_call(tool_call_event) - assert list(session._pending_tool_calls) == [tool_call_event.call_id] - - await session.update_agent(replacement_agent) - await session.approve_tool_call(tool_call_event.call_id) - - assert _sent_tool_output_strings(mock_model) == ["approved implementation"] - finally: - await session.__aexit__(None, None, None) - @pytest.mark.asyncio async def test_async_tool_call_uses_event_initial_settings_dispatch_snapshot( self, mock_model, monkeypatch @@ -2572,195 +1830,6 @@ async def test_duplicate_function_tool_call_id_is_ignored( mock_function_tool.on_invoke_tool.assert_called_once() assert len(mock_model.sent_tool_outputs) == 1 - @pytest.mark.asyncio - async def test_approved_function_tool_failure_replay_does_not_rerun( - self, mock_model, mock_agent, mock_function_tool - ): - mock_function_tool.needs_approval = True - mock_function_tool.on_invoke_tool.side_effect = RuntimeError("failed after side effect") - mock_agent.get_all_tools.return_value = [mock_function_tool] - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_failed", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - with pytest.raises(RuntimeError, match="failed after side effect"): - await session.approve_tool_call(tool_call_event.call_id) - - with pytest.raises(ModelBehaviorError, match="already executed"): - await session._handle_tool_call(tool_call_event) - - mock_function_tool.on_invoke_tool.assert_awaited_once() - assert len(mock_model.sent_tool_outputs) == 0 - - @pytest.mark.parametrize("always", [False, True], ids=["per-call", "sticky"]) - @pytest.mark.parametrize("changed_field", ["arguments", "tool_name"]) - @pytest.mark.asyncio - async def test_function_tool_send_failure_retries_cached_output_without_rerun( - self, - mock_agent, - mock_function_tool, - always: bool, - changed_field: str, - ): - """An approved call should retry cached output only for the same invocation.""" - - class FailingToolOutputModel(RecordingRealtimeModel): - def __init__(self): - super().__init__() - self.fail_next_tool_output = True - - async def send_event(self, event): - if isinstance(event, RealtimeModelSendToolOutput) and self.fail_next_tool_output: - self.fail_next_tool_output = False - raise RuntimeError("send failed") - await super().send_event(event) - - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - mock_model = FailingToolOutputModel() - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_retry_output", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - with pytest.raises(RuntimeError, match="send failed"): - await session.approve_tool_call(tool_call_event.call_id, always=always) - - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 0 - - changed_event = RealtimeModelToolCallEvent( - name="other_function" if changed_field == "tool_name" else tool_call_event.name, - call_id=tool_call_event.call_id, - arguments=( - tool_call_event.arguments if changed_field == "tool_name" else '{"changed":true}' - ), - ) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(changed_event) - await session._handle_tool_call(tool_call_event) - - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 1 - - @pytest.mark.asyncio - async def test_tool_end_cancellation_after_output_send_does_not_resend( - self, mock_model, mock_agent, mock_function_tool - ) -> None: - """Provider delivery commits the output before local end-event publication.""" - mock_agent.get_all_tools.return_value = [mock_function_tool] - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", - call_id="call_tool_end_cancelled", - arguments="{}", - ) - original_put_event_nowait = session._put_event_nowait - - def cancel_tool_end(event: Any) -> bool: - if isinstance(event, RealtimeToolEnd): - raise asyncio.CancelledError - return original_put_event_nowait(event) - - session._put_event_nowait = cancel_tool_end # type: ignore[method-assign] - with pytest.raises(asyncio.CancelledError): - await session._handle_tool_call(tool_call_event) - - invocation = session._context_wrapper._tool_invocations[tool_call_event.call_id] - assert invocation.executed is True - assert invocation.completed is True - assert tool_call_event.call_id not in session._pending_tool_outputs - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 1 - - session._put_event_nowait = original_put_event_nowait # type: ignore[method-assign] - await session._handle_tool_call(tool_call_event) - - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 1 - - @pytest.mark.parametrize("always", [False, True], ids=["per-call", "sticky"]) - @pytest.mark.parametrize("changed_field", ["arguments", "tool_name"]) - @pytest.mark.asyncio - async def test_async_function_tool_send_failure_retries_cached_output_without_rerun( - self, - mock_agent, - mock_function_tool, - always: bool, - changed_field: str, - ): - """The async approval path should bind retries to the original invocation.""" - - class FailingToolOutputModel(RecordingRealtimeModel): - def __init__(self): - super().__init__() - self.fail_next_tool_output = True - - async def send_event(self, event): - if isinstance(event, RealtimeModelSendToolOutput) and self.fail_next_tool_output: - self.fail_next_tool_output = False - raise RuntimeError("send failed") - await super().send_event(event) - - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - mock_model = FailingToolOutputModel() - session = RealtimeSession(mock_model, mock_agent, None) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_async_retry_output", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - await session.approve_tool_call(tool_call_event.call_id, always=always) - tool_call_tasks = list(session._tool_call_tasks) - assert len(tool_call_tasks) == 1 - task_results = await asyncio.gather(*tool_call_tasks, return_exceptions=True) - await asyncio.sleep(0) - - assert len(task_results) == 1 - assert isinstance(task_results[0], RuntimeError) - assert session._stored_exception is None - assert tool_call_event.call_id in session._pending_tool_outputs - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 0 - - changed_event = RealtimeModelToolCallEvent( - name="other_function" if changed_field == "tool_name" else tool_call_event.name, - call_id=tool_call_event.call_id, - arguments=( - tool_call_event.arguments if changed_field == "tool_name" else '{"changed":true}' - ), - ) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(changed_event) - await session.on_event(tool_call_event) - tool_call_tasks = list(session._tool_call_tasks) - assert len(tool_call_tasks) == 1 - await asyncio.gather(*tool_call_tasks) - - assert session._stored_exception is None - assert tool_call_event.call_id not in session._pending_tool_outputs - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 1 - @pytest.mark.asyncio async def test_function_tool_timeout_returns_result_message(self, mock_model, mock_agent): async def invoke_slow_tool(_ctx: ToolContext[Any], _arguments: str) -> str: @@ -3098,154 +2167,6 @@ async def test_unknown_tool_handling(self, mock_model, mock_agent, mock_function # Should not have called any tools mock_function_tool.on_invoke_tool.assert_not_called() - @pytest.mark.asyncio - async def test_function_tool_needs_approval_emits_event( - self, mock_model, mock_agent, mock_function_tool - ): - """Tools marked as needs_approval should pause and emit an approval request.""" - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession(mock_model, mock_agent, None) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_needs_approval", arguments='{"param": "value"}' - ) - - await session._handle_tool_call(tool_call_event) - - assert tool_call_event.call_id in session._pending_tool_calls - assert mock_function_tool.on_invoke_tool.call_count == 0 - - approval_event = await session._event_queue.get() - assert isinstance(approval_event, RealtimeToolApprovalRequired) - assert approval_event.call_id == tool_call_event.call_id - assert approval_event.tool == mock_function_tool - - @pytest.mark.parametrize( - "arguments", - [ - "", - '{"subject": "refund"', - "null", - "[]", - '{"amount": NaN}', - '{"amount": Infinity}', - '{"amount": -Infinity}', - ], - ) - @pytest.mark.asyncio - async def test_callable_function_approval_fails_closed_for_invalid_arguments( - self, mock_model, arguments: str - ) -> None: - approval_inputs: list[dict[str, Any]] = [] - tool_inputs: list[str] = [] - - async def needs_approval(_ctx: Any, params: dict[str, Any], _call_id: str) -> bool: - approval_inputs.append(params) - return False - - async def invoke_tool(_ctx: ToolContext[Any], raw_arguments: str) -> str: - tool_inputs.append(raw_arguments) - return "sent" - - tool = FunctionTool( - name="send_email", - description="Send an email.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=needs_approval, - ) - agent = RealtimeAgent(name="agent", tools=[tool]) - session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) - tool_call_event = RealtimeModelToolCallEvent( - name=tool.name, - call_id="call-invalid", - arguments=arguments, - ) - - await session._handle_tool_call(tool_call_event) - - assert tool_call_event.call_id in session._pending_tool_calls - assert approval_inputs == [] - assert tool_inputs == [] - approval_event = await session._event_queue.get() - assert isinstance(approval_event, RealtimeToolApprovalRequired) - - @pytest.mark.asyncio - async def test_callable_function_approval_receives_valid_object_arguments( - self, mock_model - ) -> None: - approval_inputs: list[dict[str, Any]] = [] - tool_inputs: list[str] = [] - - async def needs_approval(_ctx: Any, params: dict[str, Any], _call_id: str) -> bool: - approval_inputs.append(params) - return False - - async def invoke_tool(_ctx: ToolContext[Any], raw_arguments: str) -> str: - tool_inputs.append(raw_arguments) - return "sent" - - tool = FunctionTool( - name="send_email", - description="Send an email.", - params_json_schema={"type": "object", "properties": {"subject": {"type": "string"}}}, - on_invoke_tool=invoke_tool, - needs_approval=needs_approval, - ) - agent = RealtimeAgent(name="agent", tools=[tool]) - session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) - arguments = '{"subject": "status update"}' - tool_call_event = RealtimeModelToolCallEvent( - name=tool.name, - call_id="call-valid", - arguments=arguments, - ) - - await session._handle_tool_call(tool_call_event) - - assert approval_inputs == [{"subject": "status update"}] - assert tool_inputs == [arguments] - assert tool_call_event.call_id not in session._pending_tool_calls - - @pytest.mark.asyncio - async def test_tool_input_guardrail_rejects_before_realtime_function_execution( - self, mock_model - ): - """Tool input guardrails should run before regular realtime function tool execution.""" - executed = False - - @tool_input_guardrail - def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: - return ToolGuardrailFunctionOutput.reject_content("blocked before execution") - - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - nonlocal executed - executed = True - return "ok" - - guarded_tool = FunctionTool( - name="test_function", - description="guarded", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - tool_input_guardrails=[reject_guardrail], - ) - agent = RealtimeAgent(name="agent", tools=[guarded_tool]) - session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_guardrail_reject", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - - assert executed is False - assert len(mock_model.sent_tool_outputs) == 1 - _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] - assert sent_output == "blocked before execution" - assert start_response is True - @pytest.mark.asyncio async def test_realtime_tool_contexts_share_session_tool_state(self, mock_model): """Realtime guardrails and callbacks receive the session-owned tool state.""" @@ -3284,256 +2205,79 @@ async def invoke_tool(context: ToolContext[Any], _arguments: str) -> str: assert context._tool_invocations is session._context_wrapper._tool_invocations @pytest.mark.asyncio - async def test_realtime_pending_approval_skips_tool_input_guardrails_by_default( + async def test_changed_realtime_call_id_fails_while_dispatch_resolution_is_pending( self, mock_model - ): - guardrail_runs = 0 - - @tool_input_guardrail - def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: - nonlocal guardrail_runs - guardrail_runs += 1 - return ToolGuardrailFunctionOutput.allow() + ) -> None: + """Concurrent Realtime events compare identities before dispatch resolution awaits.""" + dispatch_started = asyncio.Event() + release_dispatch = asyncio.Event() + executed: list[str] = [] - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + async def invoke_tool(_ctx: ToolContext[Any], arguments: str) -> str: + executed.append(arguments) return "ok" - guarded_tool = FunctionTool( + tool = FunctionTool( name="test_function", - description="guarded", + description="test", params_json_schema={"type": "object", "properties": {}}, on_invoke_tool=invoke_tool, - needs_approval=True, - tool_input_guardrails=[count_guardrail], ) - agent = RealtimeAgent(name="agent", tools=[guarded_tool]) + agent = RealtimeAgent(name="agent", tools=[tool]) session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_guardrail_pending", arguments="{}" - ) + original_resolver = session._resolve_dispatch_snapshot - await session._handle_tool_call(tool_call_event) + async def delayed_resolver( + resolver_agent: RealtimeAgent[Any], + dispatch_snapshot: Any, + ) -> Any: + dispatch_started.set() + await release_dispatch.wait() + return await original_resolver(resolver_agent, dispatch_snapshot) - assert tool_call_event.call_id in session._pending_tool_calls - assert guardrail_runs == 0 + session._resolve_dispatch_snapshot = delayed_resolver # type: ignore[assignment] + first_event = RealtimeModelToolCallEvent( + name="test_function", + call_id="call_dispatch_pending", + arguments='{"value":"safe"}', + ) + first_task = asyncio.create_task(session._handle_tool_call(first_event)) + await dispatch_started.wait() - @pytest.mark.asyncio - async def test_realtime_pre_approval_tool_input_guardrail_rejects_pending_approval( - self, mock_model - ): - executed = False + changed_event = RealtimeModelToolCallEvent( + name="test_function", + call_id=first_event.call_id, + arguments='{"value":"changed"}', + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) - @tool_input_guardrail - def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: - return ToolGuardrailFunctionOutput.reject_content("blocked before approval") + release_dispatch.set() + await first_task - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - nonlocal executed - executed = True - return "ok" + assert executed == ['{"value":"safe"}'] - guarded_tool = FunctionTool( + @pytest.mark.parametrize( + ("failure_stage", "error_type"), + [ + ("dispatch", RuntimeError), + ("dispatch", asyncio.CancelledError), + ("enablement", RuntimeError), + ("enablement", asyncio.CancelledError), + ], + ) + @pytest.mark.asyncio + async def test_changed_realtime_call_id_fails_after_dispatch_await_failure( + self, + mock_model: Any, + failure_stage: str, + error_type: type[BaseException], + ) -> None: + """A failed dispatch await retains the provisional invocation identity.""" + invoke_tool = AsyncMock(return_value="ok") + tool = FunctionTool( name="test_function", - description="guarded", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=True, - tool_input_guardrails=[reject_guardrail], - ) - agent = RealtimeAgent(name="agent", tools=[guarded_tool]) - session = RealtimeSession( - mock_model, - agent, - None, - run_config={ - "async_tool_calls": False, - "tool_execution": {"pre_approval_tool_input_guardrails": True}, - }, - ) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_pre_approval_reject", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - - assert executed is False - assert tool_call_event.call_id not in session._pending_tool_calls - assert len(mock_model.sent_tool_outputs) == 1 - _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] - assert sent_output == "blocked before approval" - assert start_response is True - - @pytest.mark.asyncio - async def test_realtime_pre_approval_tool_input_guardrails_rerun_after_approval( - self, mock_model - ): - guardrail_runs = 0 - executed = 0 - - @tool_input_guardrail - def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: - nonlocal guardrail_runs - guardrail_runs += 1 - return ToolGuardrailFunctionOutput.allow() - - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - nonlocal executed - executed += 1 - return "ok" - - guarded_tool = FunctionTool( - name="test_function", - description="guarded", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=True, - tool_input_guardrails=[count_guardrail], - ) - agent = RealtimeAgent(name="agent", tools=[guarded_tool]) - session = RealtimeSession( - mock_model, - agent, - None, - run_config={ - "async_tool_calls": False, - "tool_execution": {"pre_approval_tool_input_guardrails": True}, - }, - ) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_pre_approval_rerun", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - assert guardrail_runs == 1 - assert executed == 0 - - await session.approve_tool_call(tool_call_event.call_id) - - assert guardrail_runs == 2 - assert executed == 1 - assert len(mock_model.sent_tool_outputs) == 1 - _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] - assert sent_output == "ok" - assert start_response is True - - @pytest.mark.asyncio - async def test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_once( - self, mock_model, mock_agent, mock_function_tool - ): - """A duplicate approval-gated call should not enqueue another approval or run twice.""" - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_duplicate_approval", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - await session._handle_tool_call(tool_call_event) - - changed_event = RealtimeModelToolCallEvent( - name="test_function", - call_id=tool_call_event.call_id, - arguments='{"changed":true}', - ) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(changed_event) - - assert list(session._pending_tool_calls) == [tool_call_event.call_id] - approval_events = [] - while not session._event_queue.empty(): - event = await session._event_queue.get() - if isinstance(event, RealtimeToolApprovalRequired): - approval_events.append(event) - assert len(approval_events) == 1 - - await session.approve_tool_call(tool_call_event.call_id) - await session._handle_tool_call(tool_call_event) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(changed_event) - - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 1 - - @pytest.mark.asyncio - async def test_changed_realtime_call_id_fails_while_dispatch_resolution_is_pending( - self, mock_model - ) -> None: - """Concurrent Realtime events compare identities before dispatch resolution awaits.""" - dispatch_started = asyncio.Event() - release_dispatch = asyncio.Event() - executed: list[str] = [] - - async def invoke_tool(_ctx: ToolContext[Any], arguments: str) -> str: - executed.append(arguments) - return "ok" - - tool = FunctionTool( - name="test_function", - description="test", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - ) - agent = RealtimeAgent(name="agent", tools=[tool]) - session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) - original_resolver = session._resolve_dispatch_snapshot - - async def delayed_resolver( - resolver_agent: RealtimeAgent[Any], - dispatch_snapshot: Any, - ) -> Any: - dispatch_started.set() - await release_dispatch.wait() - return await original_resolver(resolver_agent, dispatch_snapshot) - - session._resolve_dispatch_snapshot = delayed_resolver # type: ignore[assignment] - first_event = RealtimeModelToolCallEvent( - name="test_function", - call_id="call_dispatch_pending", - arguments='{"value":"safe"}', - ) - first_task = asyncio.create_task(session._handle_tool_call(first_event)) - await dispatch_started.wait() - - changed_event = RealtimeModelToolCallEvent( - name="test_function", - call_id=first_event.call_id, - arguments='{"value":"changed"}', - ) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(changed_event) - - release_dispatch.set() - await first_task - - assert executed == ['{"value":"safe"}'] - - @pytest.mark.parametrize( - ("failure_stage", "error_type"), - [ - ("dispatch", RuntimeError), - ("dispatch", asyncio.CancelledError), - ("enablement", RuntimeError), - ("enablement", asyncio.CancelledError), - ], - ) - @pytest.mark.asyncio - async def test_changed_realtime_call_id_fails_after_dispatch_await_failure( - self, - mock_model: Any, - failure_stage: str, - error_type: type[BaseException], - ) -> None: - """A failed dispatch await retains the provisional invocation identity.""" - invoke_tool = AsyncMock(return_value="ok") - tool = FunctionTool( - name="test_function", - description="test", + description="test", params_json_schema={"type": "object", "properties": {}}, on_invoke_tool=invoke_tool, ) @@ -3634,2194 +2378,292 @@ async def invoke_tool(_ctx: ToolContext[Any], arguments: str) -> str: assert executed == ["{}"] @pytest.mark.asyncio - async def test_approve_pending_tool_call_runs_tool( + async def test_changed_completed_non_approval_call_id_fails_before_execution( self, mock_model, mock_agent, mock_function_tool ): - """Approving a pending tool call should resume execution.""" - mock_function_tool.needs_approval = True mock_agent.get_all_tools.return_value = [mock_function_tool] - session = RealtimeSession( mock_model, mock_agent, None, run_config={"async_tool_calls": False}, ) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_approve", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - await session.approve_tool_call(tool_call_event.call_id) - - assert mock_function_tool.on_invoke_tool.call_count == 1 - assert len(mock_model.sent_tool_outputs) == 1 - assert session._pending_tool_calls == {} - - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) - - assert any(isinstance(ev, RealtimeToolStart) for ev in events) - assert any(isinstance(ev, RealtimeToolEnd) for ev in events) - - @pytest.mark.asyncio - async def test_async_approve_pending_tool_call_reserves_call_id_before_task_runs( - self, mock_model - ): - """A duplicate event after approval should not outrun the approved async task.""" - approved_calls: list[str] = [] - duplicate_calls: list[str] = [] - - async def invoke_approved_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - approved_calls.append("approved") - return "approved_result" - - async def invoke_duplicate_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - duplicate_calls.append("duplicate") - return "duplicate_result" - - approved_tool = FunctionTool( - name="test_function", - description="approved", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_approved_tool, - needs_approval=True, - ) - duplicate_tool = FunctionTool( - name="test_function", - description="duplicate", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_duplicate_tool, - needs_approval=False, + first_call = RealtimeModelToolCallEvent( + name="test_function", call_id="call-reused", arguments='{"value":"safe"}' ) - approved_agent = RealtimeAgent(name="approved_agent", tools=[approved_tool]) - duplicate_agent = RealtimeAgent(name="duplicate_agent", tools=[duplicate_tool]) - session = RealtimeSession(mock_model, approved_agent, None) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_async_approval_race", arguments="{}" + changed_call = RealtimeModelToolCallEvent( + name="test_function", call_id="call-reused", arguments='{"value":"changed"}' ) - await session._handle_tool_call(tool_call_event) - await session.approve_tool_call(tool_call_event.call_id) - - assert tool_call_event.call_id in session._active_tool_invocations - await session._handle_tool_call(tool_call_event, agent_snapshot=duplicate_agent) - - tool_call_tasks = list(session._tool_call_tasks) - assert len(tool_call_tasks) == 1 - await asyncio.gather(*tool_call_tasks) + await session._handle_tool_call(first_call) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_call) - assert approved_calls == ["approved"] - assert duplicate_calls == [] + mock_function_tool.on_invoke_tool.assert_called_once() assert len(mock_model.sent_tool_outputs) == 1 - _sent_call, sent_output, _start_response = mock_model.sent_tool_outputs[0] - assert sent_output == "approved_result" @pytest.mark.asyncio - async def test_always_approve_namespaced_tool_call_does_not_approve_bare_tool(self, mock_model): - """Always approval should stay scoped to the namespaced tool key.""" - tool_calls: list[str] = [] + async def test_changed_completed_function_call_id_fails_for_handoff_role(self, mock_model): + function_calls: list[str] = [] - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - tool_calls.append("called") - return "account" + async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: + function_calls.append("function") + return "function result" - namespaced_tool = tool_namespace( - name="crm", - description="CRM tools", - tools=[ - FunctionTool( - name="lookup_account", - description="Look up account", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=True, - ) - ], - )[0] - bare_tool = FunctionTool( - name="lookup_account", - description="Look up account", + function_tool = FunctionTool( + name="route", + description="Run a function.", params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=True, + on_invoke_tool=invoke_function, ) - namespaced_agent = RealtimeAgent(name="crm_agent", tools=[namespaced_tool]) - bare_agent = RealtimeAgent(name="bare_agent", tools=[bare_tool]) - + function_agent = RealtimeAgent(name="function", tools=[function_tool]) + target = RealtimeAgent(name="target") + route_name = Handoff.default_tool_name(target) + function_tool.name = route_name + handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) session = RealtimeSession( mock_model, - namespaced_agent, + function_agent, None, run_config={"async_tool_calls": False}, ) + event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") - first_call = RealtimeModelToolCallEvent( - name="lookup_account", call_id="call_first", arguments="{}" - ) - second_call = RealtimeModelToolCallEvent( - name="lookup_account", call_id="call_second", arguments="{}" - ) - - await session._handle_tool_call(first_call) - await session.approve_tool_call(first_call.call_id, always=True) - await session._handle_tool_call(second_call, agent_snapshot=bare_agent) - - assert ( - session._context_wrapper.get_approval_status( - "lookup_account", - second_call.call_id, - ) - is None - ) - assert "crm.lookup_account" in session._context_wrapper._approvals - assert "lookup_account" not in session._context_wrapper._approvals - assert sorted(session._pending_tool_calls) == [second_call.call_id] - assert len(mock_model.sent_tool_outputs) == 1 - assert tool_calls == ["called"] - - @pytest.mark.asyncio - async def test_reject_pending_tool_call_sends_rejection_output( - self, mock_model, mock_agent, mock_function_tool - ): - """Rejecting a pending tool call should notify the model and skip execution.""" - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession(mock_model, mock_agent, None) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_reject", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - await session.reject_tool_call(tool_call_event.call_id) - await session._handle_tool_call(tool_call_event) - - assert mock_function_tool.on_invoke_tool.call_count == 0 - assert len(mock_model.sent_tool_outputs) == 1 - _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] - assert sent_output == REJECTION_MESSAGE - assert start_response is True - assert session._pending_tool_calls == {} - - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) - - assert any( - isinstance(ev, RealtimeToolEnd) and ev.output == REJECTION_MESSAGE for ev in events - ) + await session._handle_tool_call(event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(event, agent_snapshot=handoff_agent) - @pytest.mark.asyncio - async def test_reject_pending_tool_call_reserves_call_id_before_sending( - self, mock_agent, mock_function_tool - ): - """A duplicate event during rejection output sending should not emit a second output.""" - - class BlockingToolOutputModel(RecordingRealtimeModel): - def __init__(self): - super().__init__() - self.started = asyncio.Event() - self.release = asyncio.Event() - self.block_next_tool_output = True - - async def send_event(self, event): - if isinstance(event, RealtimeModelSendToolOutput) and self.block_next_tool_output: - self.block_next_tool_output = False - self.started.set() - await self.release.wait() - await super().send_event(event) - - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - mock_model = BlockingToolOutputModel() - session = RealtimeSession(mock_model, mock_agent, None) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_reject_race", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - reject_task = asyncio.create_task(session.reject_tool_call(tool_call_event.call_id)) - await asyncio.wait_for(mock_model.started.wait(), timeout=1) - - await session._handle_tool_call(tool_call_event) - - mock_model.release.set() - await reject_task - - assert len(mock_model.sent_tool_outputs) == 1 - - @pytest.mark.asyncio - async def test_reject_pending_tool_call_uses_run_level_formatter( - self, mock_model, mock_agent, mock_function_tool - ): - """Rejecting a pending tool call should use the run-level formatter output.""" - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={ - "tool_error_formatter": ( - lambda args: f"run-level {args.tool_name} denied ({args.call_id})" - ) - }, - ) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_reject_custom", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - await session.reject_tool_call(tool_call_event.call_id) - - _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] - assert sent_output == "run-level test_function denied (call_reject_custom)" - assert start_response is True - - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) - - assert any( - isinstance(ev, RealtimeToolEnd) - and ev.output == "run-level test_function denied (call_reject_custom)" - for ev in events - ) - - @pytest.mark.asyncio - async def test_rejection_formatter_error_is_redacted( - self, monkeypatch, mock_model, mock_agent, mock_function_tool - ): - monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) - - def fail_formatter(_args): - raise ValueError("SECRET_REALTIME_TOOL_FORMATTER") - - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"tool_error_formatter": fail_formatter}, - ) - - with patch("agents.realtime.session.logger") as mock_logger: - message = await session._resolve_approval_rejection_message( - tool=mock_function_tool, - call_id="call_reject_error", - ) - - assert message - mock_logger.error.assert_called_once_with("%s", "Tool error formatter failed", stacklevel=3) - - @pytest.mark.asyncio - async def test_cancelled_rejection_formatter_leaves_invocation_executed( - self, mock_model, mock_agent - ): - formatter_entered = asyncio.Event() - - @function_tool - def approval_tool() -> str: - return "done" - - async def blocking_formatter(_args): - formatter_entered.set() - await asyncio.Event().wait() - return "rejected" - - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"tool_error_formatter": blocking_formatter}, - ) - tool_call = RealtimeModelToolCallEvent( - name=approval_tool.name, - call_id="call_rejected_cancelled", - arguments="{}", - ) - canonical_call = session._build_tool_approval_item( # noqa: SLF001 - approval_tool, - tool_call, - mock_agent, - ).raw_item - lookup_key = get_function_tool_lookup_key_for_tool(approval_tool) - assert session._context_wrapper._tool_invocation_status( # noqa: SLF001 - canonical_call, - tool_lookup_key=lookup_key, - ) == (("function_call", "call_rejected_cancelled"), False, False) - - task = asyncio.create_task( - session._resolve_approval_rejection_message( # noqa: SLF001 - tool=approval_tool, - call_id=tool_call.call_id, - tool_call=canonical_call, - ) - ) - await formatter_entered.wait() - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - assert session._context_wrapper._tool_invocation_status( # noqa: SLF001 - canonical_call, - tool_lookup_key=lookup_key, - ) == (("function_call", "call_rejected_cancelled"), False, True) - - @pytest.mark.asyncio - async def test_reject_pending_tool_call_prefers_explicit_message( - self, mock_model, mock_agent, mock_function_tool - ): - """Rejecting a pending tool call should prefer the explicit rejection message.""" - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={ - "tool_error_formatter": ( - lambda args: f"run-level {args.tool_name} denied ({args.call_id})" - ) - }, - ) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_reject_explicit", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - await session.reject_tool_call( - tool_call_event.call_id, - rejection_message="explicit rejection message", - ) - - _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] - assert sent_output == "explicit rejection message" - assert start_response is True - - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) - - assert any( - isinstance(ev, RealtimeToolEnd) and ev.output == "explicit rejection message" - for ev in events - ) - - @pytest.mark.asyncio - async def test_always_reject_namespaced_tool_call_reuses_explicit_message(self, mock_model): - """Always rejection should reuse explicit messages through the qualified tool key.""" - tool_calls: list[str] = [] - - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - tool_calls.append("called") - return "account" - - namespaced_tool = tool_namespace( - name="crm", - description="CRM tools", - tools=[ - FunctionTool( - name="lookup_account", - description="Look up account", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=True, - ) - ], - )[0] - agent = RealtimeAgent(name="crm_agent", tools=[namespaced_tool]) - session = RealtimeSession(mock_model, agent, None) - - first_call = RealtimeModelToolCallEvent( - name="lookup_account", call_id="call_reject_first", arguments="{}" - ) - second_call = RealtimeModelToolCallEvent( - name="lookup_account", call_id="call_reject_second", arguments="{}" - ) - - await session._handle_tool_call(first_call) - await session.reject_tool_call( - first_call.call_id, - always=True, - rejection_message="explicit crm rejection", - ) - await session._handle_tool_call(second_call) - - assert "crm.lookup_account" in session._context_wrapper._approvals - assert "lookup_account" not in session._context_wrapper._approvals - assert session._pending_tool_calls == {} - assert [output for _call, output, _start in mock_model.sent_tool_outputs] == [ - "explicit crm rejection", - "explicit crm rejection", - ] - assert tool_calls == [] - - @pytest.mark.asyncio - async def test_sticky_rejection_does_not_bind_duplicate_call_id_payload( - self, mock_model, mock_agent, mock_function_tool - ): - mock_function_tool.needs_approval = True - mock_agent.get_all_tools.return_value = [mock_function_tool] - session = RealtimeSession(mock_model, mock_agent, None) - first_call = RealtimeModelToolCallEvent( - name="test_function", call_id="call-sticky-reject", arguments="{}" - ) - changed_call = RealtimeModelToolCallEvent( - name="test_function", - call_id=first_call.call_id, - arguments='{"changed":true}', - ) - - await session._handle_tool_call(first_call) - await session.reject_tool_call(first_call.call_id, always=True) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(changed_call) - - mock_function_tool.on_invoke_tool.assert_not_called() - assert len(mock_model.sent_tool_outputs) == 1 - - @pytest.mark.asyncio - async def test_changed_completed_non_approval_call_id_fails_before_execution( - self, mock_model, mock_agent, mock_function_tool - ): - mock_agent.get_all_tools.return_value = [mock_function_tool] - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={"async_tool_calls": False}, - ) - first_call = RealtimeModelToolCallEvent( - name="test_function", call_id="call-reused", arguments='{"value":"safe"}' - ) - changed_call = RealtimeModelToolCallEvent( - name="test_function", call_id="call-reused", arguments='{"value":"changed"}' - ) - - await session._handle_tool_call(first_call) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(changed_call) - - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 1 - - @pytest.mark.asyncio - async def test_changed_completed_function_call_id_fails_for_handoff_role(self, mock_model): - function_calls: list[str] = [] - - async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: - function_calls.append("function") - return "function result" - - function_tool = FunctionTool( - name="route", - description="Run a function.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_function, - ) - function_agent = RealtimeAgent(name="function", tools=[function_tool]) - target = RealtimeAgent(name="target") - route_name = Handoff.default_tool_name(target) - function_tool.name = route_name - handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) - session = RealtimeSession( - mock_model, - function_agent, - None, - run_config={"async_tool_calls": False}, - ) - event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") - - await session._handle_tool_call(event) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(event, agent_snapshot=handoff_agent) - - assert function_calls == ["function"] - - @pytest.mark.asyncio - async def test_async_changed_completed_function_call_id_fails_for_handoff_role( - self, mock_model - ): - function_calls: list[str] = [] - - async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: - function_calls.append("function") - return "function result" - - function_tool = FunctionTool( - name="route", - description="Run a function.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_function, - ) - function_agent = RealtimeAgent(name="function", tools=[function_tool]) - target = RealtimeAgent(name="target") - route_name = Handoff.default_tool_name(target) - function_tool.name = route_name - handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) - session = RealtimeSession(mock_model, function_agent, None) - event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") - - await session.on_event(event) - await asyncio.gather(*list(session._tool_call_tasks)) - session._current_agent = handoff_agent - session._current_dispatch_snapshot = None - await session.on_event(event) - results = await asyncio.gather( - *list(session._tool_call_tasks), - return_exceptions=True, - ) - - assert any(isinstance(result, ModelBehaviorError) for result in results) assert function_calls == ["function"] @pytest.mark.asyncio - async def test_pending_function_output_rejects_handoff_role_reuse(self): - class FailingToolOutputModel(RecordingRealtimeModel): - async def send_event(self, event): - if isinstance(event, RealtimeModelSendToolOutput): - raise RuntimeError("send failed") - await super().send_event(event) - - function_callback = AsyncMock(return_value="function result") - function_tool = FunctionTool( - name="route", - description="Run a function.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=function_callback, - ) - function_agent = RealtimeAgent(name="function", tools=[function_tool]) - target = RealtimeAgent(name="target") - route_name = Handoff.default_tool_name(target) - function_tool.name = route_name - handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) - session = RealtimeSession( - FailingToolOutputModel(), - function_agent, - None, - run_config={"async_tool_calls": False}, - ) - event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") - - with pytest.raises(RuntimeError, match="send failed"): - await session._handle_tool_call(event) - with pytest.raises(ModelBehaviorError, match="unique call ID"): - await session._handle_tool_call(event, agent_snapshot=handoff_agent) - - function_callback.assert_awaited_once() - - @pytest.mark.parametrize( - "failure", - [RuntimeError("settings failed"), asyncio.CancelledError()], - ids=["failure", "cancellation"], - ) - @pytest.mark.asyncio - async def test_exact_handoff_retry_after_settings_failure_does_not_repeat_callback( - self, - mock_model, - failure: BaseException, - ): - target = RealtimeAgent(name="target") - callback = AsyncMock(return_value=target) - route = Handoff( - tool_name="route", - tool_description="Route to target.", - input_json_schema={}, - on_invoke_handoff=callback, - input_filter=None, - agent_name=target.name, - is_enabled=True, - ) - agent = RealtimeAgent(name="source", handoffs=[route]) - session = RealtimeSession( - mock_model, - agent, - None, - run_config={"async_tool_calls": False}, - ) - event = RealtimeModelToolCallEvent(name="route", call_id="shared", arguments="{}") - - with patch.object( - session, - "_get_updated_model_settings_from_agent", - AsyncMock(side_effect=failure), - ): - with pytest.raises(type(failure)): - await session._handle_tool_call(event) - - with pytest.raises(ModelBehaviorError, match="already executed"): - await session._handle_tool_call(event) - - callback.assert_awaited_once() - - @pytest.mark.asyncio - async def test_async_exact_function_retry_after_serialization_failure_does_not_repeat_callback( - self, - mock_model, - ): - callback = AsyncMock(return_value={"result": "ok"}) - tool = FunctionTool( - name="run_function", - description="Run a function.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=callback, - ) - agent = RealtimeAgent(name="agent", tools=[tool]) - session = RealtimeSession(mock_model, agent, None) - event = RealtimeModelToolCallEvent( - name=tool.name, - call_id="shared", - arguments="{}", - ) - - with patch( - "agents.realtime.session._serialize_tool_output", - side_effect=RuntimeError("serialization failed"), - ): - await session.on_event(event) - first_results = await asyncio.gather( - *list(session._tool_call_tasks), - return_exceptions=True, - ) - - await session.on_event(event) - retry_results = await asyncio.gather( - *list(session._tool_call_tasks), - return_exceptions=True, - ) - - assert any( - isinstance(result, RuntimeError) and str(result) == "serialization failed" - for result in first_results - ) - assert any(isinstance(result, ModelBehaviorError) for result in retry_results) - callback.assert_awaited_once() - - @pytest.mark.asyncio - async def test_empty_handoff_call_id_fails_before_callback(self, mock_model): - target = RealtimeAgent(name="target") - callback = AsyncMock(return_value=target) - route = Handoff( - tool_name="route", - tool_description="Route to target.", - input_json_schema={}, - on_invoke_handoff=callback, - input_filter=None, - agent_name=target.name, - is_enabled=True, - ) - agent = RealtimeAgent(name="source", handoffs=[route]) - session = RealtimeSession(mock_model, agent, None) - - with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): - await session._handle_tool_call( - RealtimeModelToolCallEvent(name=route.tool_name, call_id="", arguments="{}") - ) - - callback.assert_not_awaited() - - @pytest.mark.asyncio - async def test_sticky_rejection_skips_dynamic_approval_checker(self, mock_model): - checker_calls: list[str] = [] - tool_calls: list[str] = [] - - async def needs_approval(_ctx: Any, _params: dict[str, Any], call_id: str) -> bool: - checker_calls.append(call_id) - if call_id != "call-reject-first": - raise AssertionError("sticky rejection must bypass needs_approval") - return True - - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - tool_calls.append("called") - return "should-not-run" - - tool = FunctionTool( - name="send_email", - description="Send an email.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=needs_approval, - ) - agent = RealtimeAgent(name="agent", tools=[tool]) - session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) - first_call = RealtimeModelToolCallEvent( - name=tool.name, call_id="call-reject-first", arguments="{}" - ) - second_call = RealtimeModelToolCallEvent( - name=tool.name, call_id="call-reject-second", arguments="{}" - ) - - await session._handle_tool_call(first_call) - await session.reject_tool_call(first_call.call_id, always=True) - await session._handle_tool_call(second_call) - - assert checker_calls == ["call-reject-first"] - assert tool_calls == [] - assert session._pending_tool_calls == {} - assert len(mock_model.sent_tool_outputs) == 2 - - @pytest.mark.asyncio - async def test_sticky_rejection_wins_while_dynamic_approval_checker_is_pending( - self, mock_model - ): - checker_started = asyncio.Event() - checker_release = asyncio.Event() - checker_calls: list[str] = [] - tool_calls: list[str] = [] - - async def needs_approval(_ctx: Any, _params: dict[str, Any], call_id: str) -> bool: - checker_calls.append(call_id) - if call_id == "call-pending-checker": - checker_started.set() - await checker_release.wait() - return False - return True - - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - tool_calls.append("called") - return "should-not-run" - - tool = FunctionTool( - name="send_email", - description="Send an email.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=needs_approval, - ) - agent = RealtimeAgent(name="agent", tools=[tool]) - session = RealtimeSession(mock_model, agent, None) - first_call = RealtimeModelToolCallEvent( - name=tool.name, call_id="call-reject-first", arguments="{}" - ) - pending_checker_call = RealtimeModelToolCallEvent( - name=tool.name, call_id="call-pending-checker", arguments="{}" - ) - - await session._handle_tool_call(first_call) - pending_checker_task = asyncio.create_task(session._handle_tool_call(pending_checker_call)) - try: - await asyncio.wait_for(checker_started.wait(), timeout=1) - await session.reject_tool_call( - first_call.call_id, - always=True, - rejection_message="sticky rejection", - ) - finally: - checker_release.set() - await pending_checker_task - - assert checker_calls == ["call-reject-first", "call-pending-checker"] - assert tool_calls == [] - assert session._pending_tool_calls == {} - assert [output for _call, output, _start in mock_model.sent_tool_outputs] == [ - "sticky rejection", - "sticky rejection", - ] - - @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) - @pytest.mark.asyncio - async def test_sticky_decision_wins_while_rejecting_pre_approval_guardrail_is_pending( - self, mock_model, approved: bool - ): - guardrail_started = asyncio.Event() - guardrail_release = asyncio.Event() - guardrail_calls: list[str | None] = [] - tool_calls: list[str] = [] - - @tool_input_guardrail - async def blocking_guardrail( - data: ToolInputGuardrailData, - ) -> ToolGuardrailFunctionOutput: - call_id = data.context.tool_call_id - guardrail_calls.append(call_id) - if call_id == "call-pending-guardrail": - guardrail_started.set() - await guardrail_release.wait() - return ToolGuardrailFunctionOutput.reject_content("guardrail rejection") - return ToolGuardrailFunctionOutput.allow() - - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - tool_calls.append("called") - return "tool output" - - tool = FunctionTool( - name="send_email", - description="Send an email.", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=invoke_tool, - needs_approval=True, - tool_input_guardrails=[blocking_guardrail], - ) - agent = RealtimeAgent(name="agent", tools=[tool]) - session = RealtimeSession( - mock_model, - agent, - None, - run_config={"tool_execution": {"pre_approval_tool_input_guardrails": True}}, - ) - first_call = RealtimeModelToolCallEvent( - name=tool.name, call_id="call-reject-first", arguments="{}" - ) - pending_guardrail_call = RealtimeModelToolCallEvent( - name=tool.name, call_id="call-pending-guardrail", arguments="{}" - ) - - await session._handle_tool_call(first_call) - pending_guardrail_task = asyncio.create_task( - session._handle_tool_call(pending_guardrail_call) - ) - try: - await asyncio.wait_for(guardrail_started.wait(), timeout=1) - approval_item = session._pending_tool_calls[first_call.call_id].approval_item - if approved: - session._context_wrapper.approve_tool(approval_item, always_approve=True) - else: - session._context_wrapper.reject_tool( - approval_item, - always_reject=True, - rejection_message="sticky rejection", - ) - finally: - guardrail_release.set() - await pending_guardrail_task - - assert pending_guardrail_call.call_id not in session._pending_tool_calls - outputs = [output for _call, output, _start in mock_model.sent_tool_outputs] - if approved: - assert guardrail_calls == [ - "call-reject-first", - "call-pending-guardrail", - "call-pending-guardrail", - ] - assert tool_calls == [] - assert outputs == ["guardrail rejection"] - else: - assert guardrail_calls == ["call-reject-first", "call-pending-guardrail"] - assert tool_calls == [] - assert outputs == ["sticky rejection"] - - @pytest.mark.asyncio - async def test_function_tool_exception_handling( - self, mock_model, mock_agent, mock_function_tool - ): - """Test that exceptions in function tools are handled (currently they propagate)""" - # Set up tool to raise exception - mock_function_tool.on_invoke_tool.side_effect = ValueError("Tool error") - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession(mock_model, mock_agent, None) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_error", arguments="{}" - ) - - # Currently exceptions propagate (no error handling implemented) - with pytest.raises(ValueError, match="Tool error"): - await session._handle_tool_call(tool_call_event) - - # Tool start event should have been queued before the error - assert session._event_queue.qsize() == 1 - tool_start_event = await session._event_queue.get() - assert isinstance(tool_start_event, RealtimeToolStart) - assert tool_start_event.arguments == "{}" - - # But no tool output should have been sent and no end event queued - assert len(mock_model.sent_tool_outputs) == 0 - - @pytest.mark.asyncio - async def test_tool_call_with_complex_arguments( - self, mock_model, mock_agent, mock_function_tool - ): - """Test tool call with complex JSON arguments""" - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession(mock_model, mock_agent, None) - - # Complex arguments - complex_args = '{"nested": {"data": [1, 2, 3]}, "bool": true, "null": null}' - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_complex", arguments=complex_args - ) - - await session._handle_tool_call(tool_call_event) - - # Verify arguments were passed correctly to tool - call_args = mock_function_tool.on_invoke_tool.call_args - assert call_args[0][1] == complex_args - - # Verify tool_start event includes arguments - tool_start_event = await session._event_queue.get() - assert isinstance(tool_start_event, RealtimeToolStart) - assert tool_start_event.arguments == complex_args - - # Verify tool_end event includes arguments - tool_end_event = await session._event_queue.get() - assert isinstance(tool_end_event, RealtimeToolEnd) - assert tool_end_event.arguments == complex_args - - @pytest.mark.asyncio - async def test_tool_call_with_custom_call_id(self, mock_model, mock_agent, mock_function_tool): - """Test that tool context receives correct call_id""" - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession(mock_model, mock_agent, None) - - custom_call_id = "custom_call_id_12345" - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id=custom_call_id, arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - - # Verify tool context was created with correct call_id - call_args = mock_function_tool.on_invoke_tool.call_args - tool_context = call_args[0][0] - # The call_id is used internally in ToolContext.from_agent_context - # We can't directly access it, but we can verify the context was created - assert isinstance(tool_context, ToolContext) - - @pytest.mark.asyncio - async def test_tool_result_conversion_to_string(self, mock_model, mock_agent): - """Test that structured tool results are serialized to JSON for model output.""" - # Create tool that returns non-string result - tool = _set_default_timeout_fields(Mock(spec=FunctionTool)) - tool.name = "test_function" - tool.on_invoke_tool = AsyncMock(return_value={"result": "data", "count": 42}) - tool.needs_approval = False - - mock_agent.get_all_tools.return_value = [tool] - - session = RealtimeSession(mock_model, mock_agent, None) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_conversion", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - - # Verify result was serialized to JSON - sent_call, sent_output, _ = mock_model.sent_tool_outputs[0] - assert isinstance(sent_output, str) - assert sent_output == json.dumps({"result": "data", "count": 42}) - - @pytest.mark.asyncio - async def test_tool_result_conversion_serializes_pydantic_models(self, mock_model, mock_agent): - """Test that pydantic tool results are serialized to JSON for model output.""" - - class ToolResult(BaseModel): - name: str - score: int - - tool = _set_default_timeout_fields(Mock(spec=FunctionTool)) - tool.name = "test_function" - tool.on_invoke_tool = AsyncMock(return_value=ToolResult(name="demo", score=7)) - tool.needs_approval = False - - mock_agent.get_all_tools.return_value = [tool] - - session = RealtimeSession(mock_model, mock_agent, None) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_pydantic_conversion", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - - _sent_call, sent_output, _ = mock_model.sent_tool_outputs[0] - assert sent_output == json.dumps({"name": "demo", "score": 7}) - - def test_serialize_tool_output_ignores_non_pydantic_model_dump_objects(self) -> None: - class ModelDumpObject: - def model_dump(self, *_args: Any, **_kwargs: Any) -> dict[str, Any]: - raise AssertionError("non-pydantic objects should not use model_dump") - - def __str__(self) -> str: - return "fake-model-dump-object" - - assert _serialize_tool_output(ModelDumpObject()) == "fake-model-dump-object" - - def test_serialize_tool_output_falls_back_when_pydantic_json_dump_fails(self) -> None: - class FallbackModel(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - - payload: object - - def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: - if kwargs.get("mode") == "json": - raise ValueError("json mode failed") - return {"payload": "ok"} - - assert _serialize_tool_output(FallbackModel(payload=object())) == json.dumps( - {"payload": "ok"} - ) - - def test_serialize_tool_output_returns_string_when_pydantic_dump_fails(self) -> None: - class BrokenModel(BaseModel): - value: int - - def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: - raise ValueError("dump failed") - - def __str__(self) -> str: - return "broken-model" - - assert _serialize_tool_output(BrokenModel(value=1)) == "broken-model" - - def test_serialize_tool_output_returns_string_when_dataclass_asdict_fails(self) -> None: - @dataclasses.dataclass - class BrokenDataclass: - lock: Any - - def __str__(self) -> str: - return "broken-dataclass" - - assert _serialize_tool_output(BrokenDataclass(lock=threading.Lock())) == "broken-dataclass" - - @dataclasses.dataclass - class ToolResult: - label: str - values: list[int] - - @pytest.mark.parametrize( - ("value", "expected"), - [ - pytest.param(None, "null", id="none"), - pytest.param( - ["hello", 1, True, None], - json.dumps(["hello", 1, True, None]), - id="list", - ), - pytest.param( - ToolResult(label="demo", values=[1, 2]), - json.dumps({"label": "demo", "values": [1, 2]}), - id="dataclass", - ), - pytest.param(b"abc", "b'abc'", id="bytes"), - ], - ) - def test_serialize_tool_output_edge_cases(self, value: Any, expected: str) -> None: - assert _serialize_tool_output(value) == expected - - @pytest.mark.asyncio - async def test_mixed_tool_types_filtering(self, mock_model, mock_agent): - """Test that function tools and handoffs are properly separated""" - # Create mixed tools - func_tool1 = _set_default_timeout_fields(Mock(spec=FunctionTool)) - func_tool1.name = "func1" - func_tool1.on_invoke_tool = AsyncMock(return_value="result1") - func_tool1.needs_approval = False - - handoff1 = Mock(spec=Handoff) - handoff1.name = "handoff1" - - func_tool2 = _set_default_timeout_fields(Mock(spec=FunctionTool)) - func_tool2.name = "func2" - func_tool2.on_invoke_tool = AsyncMock(return_value="result2") - func_tool2.needs_approval = False - - handoff2 = Mock(spec=Handoff) - handoff2.name = "handoff2" - - # Add some other object that's neither (should be ignored) - other_tool = Mock() - other_tool.name = "other" - - all_tools = [func_tool1, handoff1, func_tool2, handoff2, other_tool] - mock_agent.get_all_tools.return_value = all_tools - - session = RealtimeSession(mock_model, mock_agent, None) - - # Call a function tool - tool_call_event = RealtimeModelToolCallEvent( - name="func2", call_id="call_filtering", arguments="{}" - ) - - await session._handle_tool_call(tool_call_event) - - # Only func2 should have been called - func_tool1.on_invoke_tool.assert_not_called() - func_tool2.on_invoke_tool.assert_called_once() - - # Verify result - sent_call, sent_output, _ = mock_model.sent_tool_outputs[0] - assert sent_output == "result2" - - -class TestGuardrailFunctionality: - """Test suite for output guardrail functionality in RealtimeSession""" - - async def _wait_for_guardrail_tasks(self, session): - """Wait for all pending guardrail tasks to complete.""" - import asyncio - - if session._guardrail_tasks: - await asyncio.gather(*session._guardrail_tasks, return_exceptions=True) - - @pytest.fixture - def triggered_guardrail(self): - """Creates a guardrail that always triggers""" - - def guardrail_func(context, agent, output): - return GuardrailFunctionOutput( - output_info={"reason": "test trigger"}, tripwire_triggered=True - ) - - return OutputGuardrail(guardrail_function=guardrail_func, name="triggered_guardrail") - - @pytest.fixture - def safe_guardrail(self): - """Creates a guardrail that never triggers""" - - def guardrail_func(context, agent, output): - return GuardrailFunctionOutput( - output_info={"reason": "safe content"}, tripwire_triggered=False - ) - - return OutputGuardrail(guardrail_function=guardrail_func, name="safe_guardrail") - - @pytest.mark.parametrize( - ("model_redacted", "tool_redacted"), - [(True, False), (False, True), (False, False)], - ids=["model_redacted", "tool_redacted", "diagnostic"], - ) - @pytest.mark.asyncio - async def test_output_guardrail_failure_follows_both_data_policies( - self, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - mock_model: RealtimeModel, - model_redacted: bool, - tool_redacted: bool, - ) -> None: - error = RuntimeError("SECRET_REALTIME_GUARDRAIL_ERROR") - - async def failing_guardrail(context, agent, output): - _ = context, agent, output - raise error - - guardrail = OutputGuardrail( - guardrail_function=failing_guardrail, - name="SECRET_REALTIME_GUARDRAIL_NAME", - ) - agent = RealtimeAgent(name="agent", output_guardrails=[guardrail]) - session = RealtimeSession(mock_model, agent, None) - monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) - monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) - - with caplog.at_level(logging.DEBUG, logger="openai.agents"): - triggered = await session._run_output_guardrails("model text", "response-id") - - assert triggered is False - records = [ - record - for record in caplog.records - if "Output guardrail raised an exception" in record.getMessage() - ] - assert len(records) == 1 - record = records[0] - redacted = model_redacted or tool_redacted - if redacted: - assert record.msg == "%s" - assert record.args == ("Output guardrail raised an exception; skipping it",) - assert record.exc_info is None - assert record.exc_text is None - assert "openai_agents_diagnostic_context" not in record.__dict__ - assert error not in record.__dict__.values() - rendered = logging.Formatter().format(record) - assert "SECRET_REALTIME_GUARDRAIL_ERROR" not in rendered - assert "SECRET_REALTIME_GUARDRAIL_NAME" not in rendered - else: - context = record.__dict__["openai_agents_diagnostic_context"] - assert context == {"guardrail_name": "SECRET_REALTIME_GUARDRAIL_NAME"} - assert record.exc_info is not None - assert record.exc_info[1] is error - assert "SECRET_REALTIME_GUARDRAIL_ERROR" in logging.Formatter().format(record) - - @pytest.mark.asyncio - async def test_output_guardrail_failure_tolerates_missing_callable_name( - self, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - mock_model: RealtimeModel, - ) -> None: - class _FailingGuardrailCallable: - async def __call__(self, context, agent, output): - _ = context, agent, output - raise RuntimeError("SECRET_UNNAMED_GUARDRAIL_ERROR") - - guardrail = OutputGuardrail(guardrail_function=_FailingGuardrailCallable()) - agent = RealtimeAgent(name="agent", output_guardrails=[guardrail]) - session = RealtimeSession(mock_model, agent, None) - monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) - monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) - - with caplog.at_level(logging.WARNING, logger="openai.agents"): - triggered = await session._run_output_guardrails("model text", "response-id") - - assert triggered is False - records = [ - record - for record in caplog.records - if "Output guardrail raised an exception" in record.getMessage() - ] - assert len(records) == 1 - context = records[0].__dict__["openai_agents_diagnostic_context"] - assert context["guardrail_type"].endswith("._FailingGuardrailCallable") - assert records[0].exc_info is not None - - @pytest.mark.asyncio - async def test_transcript_delta_triggers_guardrail_at_threshold( - self, mock_model, mock_agent, triggered_guardrail - ): - """Test that guardrails run when transcript delta reaches debounce threshold""" - run_config: RealtimeRunConfig = { - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 10}, - } - - session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) - - # Send transcript delta that exceeds threshold (10 chars) - transcript_event = RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="this is more than ten characters", response_id="resp_1" - ) - - await session.on_event(transcript_event) - - # Wait for async guardrail tasks to complete - await self._wait_for_guardrail_tasks(session) - - # Should have triggered guardrail and interrupted - assert mock_model.interrupts_called == 1 - interrupt_event = next( - event - for event in mock_model.sent_events - if isinstance(event, RealtimeModelSendInterrupt) - ) - assert interrupt_event.force_response_cancel is True - assert len(mock_model.sent_messages) == 1 - assert mock_model.sent_messages[0] == "guardrail triggered: triggered_guardrail" - - # Should have emitted guardrail_tripped event - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) - - guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)] - assert len(guardrail_events) == 1 - assert guardrail_events[0].message == "this is more than ten characters" - - @pytest.mark.asyncio - async def test_output_text_delta_triggers_response_scoped_guardrail( - self, mock_model, mock_agent, triggered_guardrail - ): - run_config: RealtimeRunConfig = { - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - } - session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) - - await session.on_event(RealtimeModelTurnStartedEvent()) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="hello", - response_id="response_1", - ) - ) - await self._wait_for_guardrail_tasks(session) - - interrupt_event = next( - event - for event in mock_model.sent_events - if isinstance(event, RealtimeModelSendInterrupt) - ) - assert interrupt_event.force_response_cancel is True - assert interrupt_event.response_id == "response_1" - assert interrupt_event.cancel_response_only is True - assert mock_model.sent_messages == ["guardrail triggered: triggered_guardrail"] - - @pytest.mark.asyncio - async def test_stale_output_text_guardrail_does_not_affect_newer_response(self, mock_model): - guardrail_started = asyncio.Event() - release_guardrail = asyncio.Event() - - async def delayed_guardrail(context, agent, output): - _ = context, agent, output - guardrail_started.set() - await release_guardrail.wait() - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) - - guardrail = OutputGuardrail( - guardrail_function=delayed_guardrail, - name="delayed_guardrail", - ) - source_agent = RealtimeAgent(name="source", output_guardrails=[guardrail]) - session = RealtimeSession( - mock_model, - source_agent, - None, - run_config={"guardrails_settings": {"debounce_text_length": 1}}, - ) - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="blocked", - response_id="response_1", - ) - ) - await guardrail_started.wait() - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) - release_guardrail.set() - await self._wait_for_guardrail_tasks(session) - - assert not any( - isinstance(event, RealtimeModelSendInterrupt) for event in mock_model.sent_events - ) - assert mock_model.sent_messages == [] - queued_events = [] - while not session._event_queue.empty(): - queued_events.append(await session._event_queue.get()) - assert sum(isinstance(event, RealtimeGuardrailTripped) for event in queued_events) == 1 - - @pytest.mark.asyncio - async def test_stale_audio_guardrail_interrupts_only_source_playback(self, mock_model): - guardrail_started = asyncio.Event() - release_guardrail = asyncio.Event() - - async def delayed_guardrail(context, agent, output): - _ = context, agent, output - guardrail_started.set() - await release_guardrail.wait() - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) - - session = RealtimeSession( - mock_model, - RealtimeAgent( - name="source", - output_guardrails=[ - OutputGuardrail( - guardrail_function=delayed_guardrail, - name="delayed_guardrail", - ) - ], - ), - None, - run_config={"guardrails_settings": {"debounce_text_length": 1}}, - ) - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", - delta="blocked", - response_id="response_1", - ) - ) - await guardrail_started.wait() - await session.on_event(RealtimeModelTurnEndedEvent(response_id="response_1")) - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) - - assert mock_model.retired_audio_response_ids == [] - release_guardrail.set() - await self._wait_for_guardrail_tasks(session) - - interrupts = [ - event - for event in mock_model.sent_events - if isinstance(event, RealtimeModelSendInterrupt) - ] - assert len(interrupts) == 1 - assert interrupts[0].response_id == "response_1" - assert interrupts[0].playback_only is True - assert interrupts[0].force_response_cancel is False - assert mock_model.sent_messages == [] - assert mock_model.retired_audio_response_ids == ["response_1"] - assert session._interrupted_response_ids == set() - - @pytest.mark.asyncio - async def test_response_audio_cleanup_waits_for_delayed_guardrail(self, mock_agent): - guardrail_started = asyncio.Event() - release_guardrail = asyncio.Event() - operations: list[str] = [] - - class TrackingModel(RecordingRealtimeModel): - async def send_event(self, event): - await super().send_event(event) - if isinstance(event, RealtimeModelSendInterrupt): - operations.append("interrupt") - - def _retire_response_audio(self, response_id: str) -> None: - super()._retire_response_audio(response_id) - operations.append("retire") - - async def delayed_guardrail(context, agent, output): - _ = context, agent, output - guardrail_started.set() - await release_guardrail.wait() - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) - - model = TrackingModel() - session = RealtimeSession( - model, - mock_agent, - None, - run_config={ - "output_guardrails": [ - OutputGuardrail( - guardrail_function=delayed_guardrail, - name="delayed_guardrail", - ) - ], - "guardrails_settings": {"debounce_text_length": 1}, - }, - ) - - await session.on_event(RealtimeModelTurnStartedEvent()) - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", - delta="blocked", - response_id="response_1", - ) - ) - await guardrail_started.wait() - assert session._active_output_response_id == "response_1" - await session.on_event(RealtimeModelTurnEndedEvent()) - await asyncio.sleep(0) - - assert operations == [] - release_guardrail.set() - await self._wait_for_guardrail_tasks(session) - - assert operations == ["interrupt", "retire"] - assert model.retired_audio_response_ids == ["response_1"] - assert session._guardrail_tasks_by_response_id == {} - assert session._responses_awaiting_guardrail_cleanup == set() - - @pytest.mark.asyncio - async def test_response_audio_cleanup_runs_immediately_without_guardrail_tasks( - self, mock_model, mock_agent - ): - session = RealtimeSession(mock_model, mock_agent, None) - - await session.on_event(RealtimeModelTurnEndedEvent(response_id="response_1")) - - assert mock_model.retired_audio_response_ids == ["response_1"] - - @pytest.mark.asyncio - async def test_stale_explicit_turn_end_preserves_active_response_guardrail_state( - self, mock_model, mock_agent - ): - session = RealtimeSession(mock_model, mock_agent, None) - await session.on_event(RealtimeModelTurnStartedEvent(response_id="new_response")) - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="new_item", - delta="still active", - response_id="new_response", - ) - ) - active_generation = session._active_output_response_generation - active_agent = session._active_output_response_agent - - await session.on_event(RealtimeModelTurnEndedEvent(response_id="old_response")) - - assert mock_model.retired_audio_response_ids == ["old_response"] - assert session._active_output_response_id == "new_response" - assert session._active_output_response_generation == active_generation - assert session._active_output_response_agent is active_agent - assert session._item_transcripts == {"new_item": "still active"} - assert session._item_guardrail_run_counts == {"new_item": 0} - queued_events = [] - while not session._event_queue.empty(): - queued_events.append(await session._event_queue.get()) - assert not any(isinstance(event, RealtimeAgentEndEvent) for event in queued_events) - - @pytest.mark.asyncio - async def test_interrupted_response_audio_delta_is_not_forwarded(self, mock_model, mock_agent): - session = RealtimeSession(mock_model, mock_agent, None) - session._interrupted_response_ids.add("response_1") - - await session.on_event( - RealtimeModelAudioEvent( - data=b"audio", - response_id="response_1", - item_id="item_1", - content_index=0, - ) - ) - - queued_events = [] - while not session._event_queue.empty(): - queued_events.append(await session._event_queue.get()) - assert not any(isinstance(event, RealtimeAudio) for event in queued_events) - - @pytest.mark.asyncio - async def test_response_audio_cleanup_error_releases_session_suppression(self, mock_agent): - class FailingRetirementModel(RecordingRealtimeModel): - def _retire_response_audio(self, response_id: str) -> None: - raise RuntimeError(f"failed to retire {response_id}") - - session = RealtimeSession(FailingRetirementModel(), mock_agent, None) - session._interrupted_response_ids.add("response_1") - - session._retire_response_audio("response_1") - - assert session._interrupted_response_ids == set() - queued_event = await session._event_queue.get() - assert isinstance(queued_event, RealtimeError) - assert queued_event.error == { - "message": "Response audio cleanup failed: failed to retire response_1" - } - - @pytest.mark.asyncio - async def test_output_text_guardrail_sends_feedback_after_source_turn_ends( - self, mock_model, mock_agent, triggered_guardrail - ): - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={ - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - }, - ) - original_send_event = mock_model.send_event - - async def send_event(event): - await original_send_event(event) - if isinstance(event, RealtimeModelSendInterrupt): - await session.on_event(RealtimeModelTurnEndedEvent()) - - mock_model.send_event = send_event - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="hello", - response_id="response_1", - ) - ) - await self._wait_for_guardrail_tasks(session) - - assert mock_model.sent_messages == ["guardrail triggered: triggered_guardrail"] - - @pytest.mark.asyncio - async def test_output_text_guardrail_skips_feedback_for_completed_idless_newer_turn( - self, mock_model, mock_agent, triggered_guardrail - ): - session = RealtimeSession( - mock_model, - mock_agent, - None, - run_config={ - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - }, - ) - original_send_event = mock_model.send_event - - async def send_event(event): - await original_send_event(event) - if isinstance(event, RealtimeModelSendInterrupt): - await session.on_event(RealtimeModelTurnEndedEvent()) - await session.on_event(RealtimeModelTurnStartedEvent()) - await session.on_event(RealtimeModelTurnEndedEvent()) - - mock_model.send_event = send_event - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="hello", - response_id="response_1", - ) - ) - await self._wait_for_guardrail_tasks(session) - - assert mock_model.sent_messages == [] - - @pytest.mark.asyncio - async def test_output_text_guardrail_rechecks_generation_at_feedback_send_boundary( - self, mock_agent, triggered_guardrail - ): - feedback_send_started = asyncio.Event() - release_feedback_send = asyncio.Event() - - class BoundaryCheckingModel(RecordingRealtimeModel): - async def send_event_if(self, event, send_if): - feedback_send_started.set() - await release_feedback_send.wait() - return await super().send_event_if(event, send_if) - - model = BoundaryCheckingModel() - session = RealtimeSession( - model, - mock_agent, - None, - run_config={ - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - }, - ) - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="hello", - response_id="response_1", - ) - ) - await feedback_send_started.wait() - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) - release_feedback_send.set() - await self._wait_for_guardrail_tasks(session) - - assert model.sent_messages == [] - - @pytest.mark.asyncio - async def test_output_text_guardrail_skips_feedback_without_atomic_model_send( - self, mock_agent, triggered_guardrail - ): - class CustomModelWithoutAtomicSend(RecordingRealtimeModel): - def __init__(self): - super().__init__() - self.feedback_send_started = False - - async def send_event(self, event): - if isinstance(event, RealtimeModelSendUserInput): - self.feedback_send_started = True - await asyncio.sleep(0) - await super().send_event(event) - - async def send_event_if(self, event, send_if): - return await RealtimeModel.send_event_if(self, event, send_if) - - model = CustomModelWithoutAtomicSend() - session = RealtimeSession( - model, - mock_agent, - None, - run_config={ - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - }, - ) - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="hello", - response_id="response_1", - ) - ) - await self._wait_for_guardrail_tasks(session) - - assert any(isinstance(event, RealtimeModelSendInterrupt) for event in model.sent_events) - assert model.feedback_send_started is False - assert model.sent_messages == [] - - @pytest.mark.asyncio - async def test_output_text_guardrail_uses_agent_from_turn_start(self, mock_model): - observed_agents: list[RealtimeAgent] = [] - replacement_called = False - - def source_guardrail(context, agent, output): - _ = context, output - observed_agents.append(agent) - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) - - def replacement_guardrail(context, agent, output): - nonlocal replacement_called - _ = context, agent, output - replacement_called = True - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) - - source_agent = RealtimeAgent( - name="source", - output_guardrails=[ - OutputGuardrail(guardrail_function=source_guardrail, name="source_guardrail") - ], - ) - replacement_agent = RealtimeAgent( - name="replacement", - output_guardrails=[ - OutputGuardrail( - guardrail_function=replacement_guardrail, - name="replacement_guardrail", - ) - ], - ) - session = RealtimeSession( - mock_model, - source_agent, - None, - run_config={"guardrails_settings": {"debounce_text_length": 5}}, - ) - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.update_agent(replacement_agent) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="hello", - response_id="response_1", - ) - ) - await self._wait_for_guardrail_tasks(session) - - assert observed_agents == [source_agent] - assert replacement_called is False - assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] - - @pytest.mark.asyncio - async def test_output_text_guardrail_retains_agent_for_matching_late_turn_start( - self, mock_model - ): - observed_agents: list[RealtimeAgent] = [] - - def source_guardrail(context, agent, output): - _ = context, output - observed_agents.append(agent) - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) - - source_agent = RealtimeAgent( - name="source", - output_guardrails=[ - OutputGuardrail(guardrail_function=source_guardrail, name="source_guardrail") - ], - ) - replacement_agent = RealtimeAgent(name="replacement") - session = RealtimeSession( - mock_model, - source_agent, - None, - run_config={"guardrails_settings": {"debounce_text_length": 5}}, - ) - - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="he", - response_id="response_1", - ) - ) - await session.update_agent(replacement_agent) - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="llo", - response_id="response_1", - ) - ) - await self._wait_for_guardrail_tasks(session) - - assert observed_agents == [source_agent] - assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] - - @pytest.mark.asyncio - async def test_matching_late_turn_start_retains_pending_guardrail_generation(self, mock_model): - guardrail_started = asyncio.Event() - release_guardrail = asyncio.Event() - - async def delayed_guardrail(context, agent, output): - _ = context, agent, output - guardrail_started.set() - await release_guardrail.wait() - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) - - source_agent = RealtimeAgent( - name="source", - output_guardrails=[ - OutputGuardrail(guardrail_function=delayed_guardrail, name="source_guardrail") - ], - ) - session = RealtimeSession( - mock_model, - source_agent, - None, - run_config={"guardrails_settings": {"debounce_text_length": 2}}, - ) - - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="he", - response_id="response_1", - ) - ) - await guardrail_started.wait() - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - - release_guardrail.set() - await self._wait_for_guardrail_tasks(session) - - interrupt_event = next( - event - for event in mock_model.sent_events - if isinstance(event, RealtimeModelSendInterrupt) - ) - assert interrupt_event.response_id == "response_1" - assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] - - @pytest.mark.asyncio - async def test_output_text_guardrail_sends_feedback_if_source_ends_during_evaluation( - self, mock_model - ): - guardrail_started = asyncio.Event() - release_guardrail = asyncio.Event() - - async def delayed_guardrail(context, agent, output): - _ = context, agent, output - guardrail_started.set() - await release_guardrail.wait() - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) - - guardrail = OutputGuardrail( - guardrail_function=delayed_guardrail, - name="delayed_guardrail", - ) - session = RealtimeSession( - mock_model, - RealtimeAgent(name="source", output_guardrails=[guardrail]), - None, - run_config={"guardrails_settings": {"debounce_text_length": 1}}, - ) - - await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) - await session.on_event( - RealtimeModelOutputTextDeltaEvent( - item_id="item_1", - delta="blocked", - response_id="response_1", - ) - ) - await guardrail_started.wait() - - await session.on_event(RealtimeModelTurnEndedEvent()) - release_guardrail.set() - await self._wait_for_guardrail_tasks(session) - - assert not any( - isinstance(event, RealtimeModelSendInterrupt) for event in mock_model.sent_events - ) - assert mock_model.sent_messages == ["guardrail triggered: delayed_guardrail"] - - @pytest.mark.asyncio - async def test_agent_and_run_config_guardrails_not_run_twice(self, mock_model): - """Guardrails shared by agent and run config should execute once.""" - - call_count = 0 - - def guardrail_func(context, agent, output): - nonlocal call_count - call_count += 1 - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) - - shared_guardrail = OutputGuardrail( - guardrail_function=guardrail_func, name="shared_guardrail" - ) - - agent = RealtimeAgent(name="agent", output_guardrails=[shared_guardrail]) - run_config: RealtimeRunConfig = { - "output_guardrails": [shared_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - } - - session = RealtimeSession(mock_model, agent, None, run_config=run_config) - - await session.on_event( - RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="hello", response_id="resp_1") - ) - - await self._wait_for_guardrail_tasks(session) - - assert call_count == 1 - - @pytest.mark.asyncio - async def test_transcript_delta_multiple_thresholds_same_item( - self, mock_model, mock_agent, triggered_guardrail + async def test_async_changed_completed_function_call_id_fails_for_handoff_role( + self, mock_model ): - """Test guardrails run at 1x, 2x, 3x thresholds for same item_id""" - run_config: RealtimeRunConfig = { - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - } + function_calls: list[str] = [] - session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: + function_calls.append("function") + return "function result" - # First delta - reaches 1x threshold (5 chars) - await session.on_event( - RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="12345", response_id="resp_1") + function_tool = FunctionTool( + name="route", + description="Run a function.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_function, ) + function_agent = RealtimeAgent(name="function", tools=[function_tool]) + target = RealtimeAgent(name="target") + route_name = Handoff.default_tool_name(target) + function_tool.name = route_name + handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) + session = RealtimeSession(mock_model, function_agent, None) + event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") - # Second delta - reaches 2x threshold (10 chars total) - await session.on_event( - RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="67890", response_id="resp_1") + await session.on_event(event) + await asyncio.gather(*list(session._tool_call_tasks)) + session._current_agent = handoff_agent + session._current_dispatch_snapshot = None + await session.on_event(event) + results = await asyncio.gather( + *list(session._tool_call_tasks), + return_exceptions=True, ) - # Wait for async guardrail tasks to complete - await self._wait_for_guardrail_tasks(session) - - # Should only trigger once due to interrupted_by_guardrail flag - assert mock_model.interrupts_called == 1 - assert len(mock_model.sent_messages) == 1 + assert any(isinstance(result, ModelBehaviorError) for result in results) + assert function_calls == ["function"] + @pytest.mark.parametrize( + "failure", + [RuntimeError("settings failed"), asyncio.CancelledError()], + ids=["failure", "cancellation"], + ) @pytest.mark.asyncio - async def test_large_transcript_delta_advances_past_each_crossed_threshold( - self, mock_model, mock_agent + async def test_exact_handoff_retry_after_settings_failure_does_not_repeat_callback( + self, + mock_model, + failure: BaseException, ): - calls = 0 - - async def guardrail_func(context, agent, output): - nonlocal calls - calls += 1 - return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) - - guardrail = OutputGuardrail(guardrail_function=guardrail_func) - run_config: RealtimeRunConfig = { - "output_guardrails": [guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - } - session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) - - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="123456789012", response_id="resp_1" - ) + target = RealtimeAgent(name="target") + callback = AsyncMock(return_value=target) + route = Handoff( + tool_name="route", + tool_description="Route to target.", + input_json_schema={}, + on_invoke_handoff=callback, + input_filter=None, + agent_name=target.name, + is_enabled=True, ) - await self._wait_for_guardrail_tasks(session) - assert calls == 1 - - await session.on_event( - RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="3", response_id="resp_1") + agent = RealtimeAgent(name="source", handoffs=[route]) + session = RealtimeSession( + mock_model, + agent, + None, + run_config={"async_tool_calls": False}, ) - await self._wait_for_guardrail_tasks(session) + event = RealtimeModelToolCallEvent(name="route", call_id="shared", arguments="{}") - assert calls == 1 + with patch.object( + session, + "_get_updated_model_settings_from_agent", + AsyncMock(side_effect=failure), + ): + with pytest.raises(type(failure)): + await session._handle_tool_call(event) - @pytest.mark.asyncio - async def test_transcript_delta_different_items_tracked_separately( - self, mock_model, mock_agent, safe_guardrail - ): - """Test that different item_ids are tracked separately for debouncing""" - run_config: RealtimeRunConfig = { - "output_guardrails": [safe_guardrail], - "guardrails_settings": {"debounce_text_length": 10}, - } + with pytest.raises(ModelBehaviorError, match="already executed"): + await session._handle_tool_call(event) - session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + callback.assert_awaited_once() - # Add text to item_1 (8 chars - below threshold) - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="12345678", response_id="resp_1" - ) + @pytest.mark.asyncio + async def test_empty_handoff_call_id_fails_before_callback(self, mock_model): + target = RealtimeAgent(name="target") + callback = AsyncMock(return_value=target) + route = Handoff( + tool_name="route", + tool_description="Route to target.", + input_json_schema={}, + on_invoke_handoff=callback, + input_filter=None, + agent_name=target.name, + is_enabled=True, ) + agent = RealtimeAgent(name="source", handoffs=[route]) + session = RealtimeSession(mock_model, agent, None) - # Add text to item_2 (8 chars - below threshold) - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_2", delta="abcdefgh", response_id="resp_2" + with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): + await session._handle_tool_call( + RealtimeModelToolCallEvent(name=route.tool_name, call_id="", arguments="{}") ) - ) - - # Neither should trigger guardrails yet - assert mock_model.interrupts_called == 0 - - # Add more text to item_1 (total 12 chars - above threshold) - await session.on_event( - RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="90ab", response_id="resp_1") - ) - # item_1 should have triggered guardrail run (but not interrupted since safe) - assert session._item_guardrail_run_counts["item_1"] == 1 - assert ( - "item_2" not in session._item_guardrail_run_counts - or session._item_guardrail_run_counts["item_2"] == 0 - ) + callback.assert_not_awaited() @pytest.mark.asyncio - async def test_turn_ended_clears_guardrail_state( - self, mock_model, mock_agent, triggered_guardrail + async def test_function_tool_exception_handling( + self, mock_model, mock_agent, mock_function_tool ): - """Test that turn_ended event clears guardrail state for next turn""" - run_config: RealtimeRunConfig = { - "output_guardrails": [triggered_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - } + """Test that exceptions in function tools are handled (currently they propagate)""" + # Set up tool to raise exception + mock_function_tool.on_invoke_tool.side_effect = ValueError("Tool error") + mock_agent.get_all_tools.return_value = [mock_function_tool] - session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + session = RealtimeSession(mock_model, mock_agent, None) - # Trigger guardrail - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="trigger", response_id="resp_1" - ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_error", arguments="{}" ) - # Wait for async guardrail tasks to complete - await self._wait_for_guardrail_tasks(session) - - assert len(session._item_transcripts) == 1 + # Currently exceptions propagate (no error handling implemented) + with pytest.raises(ValueError, match="Tool error"): + await session._handle_tool_call(tool_call_event) - # End turn - await session.on_event(RealtimeModelTurnEndedEvent()) + # Tool start event should have been queued before the error + assert session._event_queue.qsize() == 1 + tool_start_event = await session._event_queue.get() + assert isinstance(tool_start_event, RealtimeToolStart) + assert tool_start_event.arguments == "{}" - # State should be cleared - assert len(session._item_transcripts) == 0 - assert len(session._item_guardrail_run_counts) == 0 + # But no tool output should have been sent and no end event queued + assert len(mock_model.sent_tool_outputs) == 0 @pytest.mark.asyncio - async def test_multiple_guardrails_all_triggered(self, mock_model, mock_agent): - """Test that all triggered guardrails are included in the event""" - - def create_triggered_guardrail(name): - def guardrail_func(context, agent, output): - return GuardrailFunctionOutput(output_info={"name": name}, tripwire_triggered=True) - - return OutputGuardrail(guardrail_function=guardrail_func, name=name) - - guardrail1 = create_triggered_guardrail("guardrail_1") - guardrail2 = create_triggered_guardrail("guardrail_2") + async def test_tool_call_with_complex_arguments( + self, mock_model, mock_agent, mock_function_tool + ): + """Test tool call with complex JSON arguments""" + mock_agent.get_all_tools.return_value = [mock_function_tool] - run_config: RealtimeRunConfig = { - "output_guardrails": [guardrail1, guardrail2], - "guardrails_settings": {"debounce_text_length": 5}, - } + session = RealtimeSession(mock_model, mock_agent, None) - session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + # Complex arguments + complex_args = '{"nested": {"data": [1, 2, 3]}, "bool": true, "null": null}' - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="trigger", response_id="resp_1" - ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_complex", arguments=complex_args ) - # Wait for async guardrail tasks to complete - await self._wait_for_guardrail_tasks(session) + await session._handle_tool_call(tool_call_event) - # Should have interrupted and sent message with both guardrail names - assert mock_model.interrupts_called == 1 - assert len(mock_model.sent_messages) == 1 - message = mock_model.sent_messages[0] - assert "guardrail_1" in message and "guardrail_2" in message + # Verify arguments were passed correctly to tool + call_args = mock_function_tool.on_invoke_tool.call_args + assert call_args[0][1] == complex_args - # Should have emitted event with both guardrail results - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) + # Verify tool_start event includes arguments + tool_start_event = await session._event_queue.get() + assert isinstance(tool_start_event, RealtimeToolStart) + assert tool_start_event.arguments == complex_args - guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)] - assert len(guardrail_events) == 1 - assert len(guardrail_events[0].guardrail_results) == 2 + # Verify tool_end event includes arguments + tool_end_event = await session._event_queue.get() + assert isinstance(tool_end_event, RealtimeToolEnd) + assert tool_end_event.arguments == complex_args @pytest.mark.asyncio - async def test_agent_output_guardrails_triggered(self, mock_model, triggered_guardrail): - """Test that guardrails defined on the agent are executed.""" - agent = RealtimeAgent(name="agent", output_guardrails=[triggered_guardrail]) - run_config: RealtimeRunConfig = { - "guardrails_settings": {"debounce_text_length": 10}, - } + async def test_tool_call_with_custom_call_id(self, mock_model, mock_agent, mock_function_tool): + """Test that tool context receives correct call_id""" + mock_agent.get_all_tools.return_value = [mock_function_tool] + + session = RealtimeSession(mock_model, mock_agent, None) - session = RealtimeSession(mock_model, agent, None, run_config=run_config) + custom_call_id = "custom_call_id_12345" - transcript_event = RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="this is more than ten characters", response_id="resp_1" + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id=custom_call_id, arguments="{}" ) - await session.on_event(transcript_event) - await self._wait_for_guardrail_tasks(session) + await session._handle_tool_call(tool_call_event) - assert mock_model.interrupts_called == 1 - assert len(mock_model.sent_messages) == 1 - assert "triggered_guardrail" in mock_model.sent_messages[0] + # Verify tool context was created with correct call_id + call_args = mock_function_tool.on_invoke_tool.call_args + tool_context = call_args[0][0] + # The call_id is used internally in ToolContext.from_agent_context + # We can't directly access it, but we can verify the context was created + assert isinstance(tool_context, ToolContext) - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) + @pytest.mark.asyncio + async def test_mixed_tool_types_filtering(self, mock_model, mock_agent): + """Test that function tools and handoffs are properly separated""" + # Create mixed tools + func_tool1 = _set_default_timeout_fields(Mock(spec=FunctionTool)) + func_tool1.name = "func1" + func_tool1.on_invoke_tool = AsyncMock(return_value="result1") + func_tool1.needs_approval = False - guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)] - assert len(guardrail_events) == 1 - assert guardrail_events[0].message == "this is more than ten characters" + handoff1 = Mock(spec=Handoff) + handoff1.name = "handoff1" - @pytest.mark.asyncio - async def test_concurrent_guardrail_tasks_interrupt_once_per_response(self, mock_model): - """Even if multiple guardrail tasks trigger concurrently for the same response_id, - only the first should interrupt and send a message.""" - import asyncio - - # Barrier to release both guardrail tasks at the same time - start_event = asyncio.Event() - - async def async_trigger_guardrail(context, agent, output): - await start_event.wait() - return GuardrailFunctionOutput( - output_info={"reason": "concurrent"}, tripwire_triggered=True - ) + func_tool2 = _set_default_timeout_fields(Mock(spec=FunctionTool)) + func_tool2.name = "func2" + func_tool2.on_invoke_tool = AsyncMock(return_value="result2") + func_tool2.needs_approval = False - concurrent_guardrail = OutputGuardrail( - guardrail_function=async_trigger_guardrail, name="concurrent_trigger" - ) + handoff2 = Mock(spec=Handoff) + handoff2.name = "handoff2" - run_config: RealtimeRunConfig = { - "output_guardrails": [concurrent_guardrail], - "guardrails_settings": {"debounce_text_length": 5}, - } + # Add some other object that's neither (should be ignored) + other_tool = Mock() + other_tool.name = "other" - # Use a minimal agent (guardrails from run_config) - agent = RealtimeAgent(name="agent") - session = RealtimeSession(mock_model, agent, None, run_config=run_config) + all_tools = [func_tool1, handoff1, func_tool2, handoff2, other_tool] + mock_agent.get_all_tools.return_value = all_tools - # Two deltas for same item and response to enqueue two guardrail tasks - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="12345", response_id="resp_same" - ) - ) - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="item_1", delta="67890", response_id="resp_same" - ) - ) + session = RealtimeSession(mock_model, mock_agent, None) - # Wait until both tasks are enqueued - for _ in range(50): - if len(session._guardrail_tasks) >= 2: - break - await asyncio.sleep(0.01) + # Call a function tool + tool_call_event = RealtimeModelToolCallEvent( + name="func2", call_id="call_filtering", arguments="{}" + ) - # Release both tasks concurrently - start_event.set() + await session._handle_tool_call(tool_call_event) - # Wait for completion - if session._guardrail_tasks: - await asyncio.gather(*session._guardrail_tasks, return_exceptions=True) + # Only func2 should have been called + func_tool1.on_invoke_tool.assert_not_called() + func_tool2.on_invoke_tool.assert_called_once() - # Only one interrupt and one message should be sent - assert mock_model.interrupts_called == 1 - assert len(mock_model.sent_messages) == 1 + # Verify result + sent_call, sent_output, _ = mock_model.sent_tool_outputs[0] + assert sent_output == "result2" class TestModelSettingsIntegration: @@ -6176,103 +3018,3 @@ async def test_update_agent_validation_failure_keeps_current_agent(self, mock_mo assert session._current_agent is first_agent assert mock_model.sent_events == () - - -class TestTranscriptPreservation: - """Tests ensuring assistant transcripts are preserved across updates.""" - - @pytest.mark.asyncio - async def test_assistant_transcript_preserved_on_item_update(self, mock_model, mock_agent): - session = RealtimeSession(mock_model, mock_agent, None) - - # Initial assistant message with audio transcript present (e.g., from first turn) - initial_item = AssistantMessageItem( - item_id="assist_1", - role="assistant", - content=[AssistantAudio(audio=None, transcript="Hello there")], - ) - session._history = [initial_item] - - # Later, the platform retrieves/updates the same item but without transcript populated - updated_without_transcript = AssistantMessageItem( - item_id="assist_1", - role="assistant", - content=[AssistantAudio(audio=None, transcript=None)], - ) - - await session.on_event(RealtimeModelItemUpdatedEvent(item=updated_without_transcript)) - - # Transcript should be preserved from existing history - assert len(session._history) == 1 - preserved_item = cast(AssistantMessageItem, session._history[0]) - assert isinstance(preserved_item.content[0], AssistantAudio) - assert preserved_item.content[0].transcript == "Hello there" - - @pytest.mark.asyncio - async def test_assistant_transcript_can_fallback_to_deltas(self, mock_model, mock_agent): - session = RealtimeSession(mock_model, mock_agent, None) - - # Simulate transcript deltas accumulated for an assistant item during generation - await session.on_event( - RealtimeModelTranscriptDeltaEvent( - item_id="assist_2", delta="partial transcript", response_id="resp_2" - ) - ) - - # Add initial assistant message without transcript - initial_item = AssistantMessageItem( - item_id="assist_2", - role="assistant", - content=[AssistantAudio(audio=None, transcript=None)], - ) - await session.on_event(RealtimeModelItemUpdatedEvent(item=initial_item)) - - # Later update still lacks transcript; merge should fallback to accumulated deltas - update_again = AssistantMessageItem( - item_id="assist_2", - role="assistant", - content=[AssistantAudio(audio=None, transcript=None)], - ) - await session.on_event(RealtimeModelItemUpdatedEvent(item=update_again)) - - preserved_item = cast(AssistantMessageItem, session._history[0]) - assert isinstance(preserved_item.content[0], AssistantAudio) - assert preserved_item.content[0].transcript == "partial transcript" - - @pytest.mark.asyncio - async def test_existing_transcript_not_overwritten_by_stale_deltas( - self, mock_model, mock_agent - ): - """Existing transcripts must take precedence over leftover delta accumulators. - - ``_item_transcripts`` is keyed by item_id and persists across updates within a - turn. When the model retrieves an item without a transcript, the merge should - fall back to deltas only when no existing transcript is present – otherwise - the complete transcript already in history would be clobbered by partial - (or stale) delta state. - """ - session = RealtimeSession(mock_model, mock_agent, None) - - # History already has the completed transcript for the item. - initial_item = AssistantMessageItem( - item_id="assist_3", - role="assistant", - content=[AssistantAudio(audio=None, transcript="Final complete transcript")], - ) - session._history = [initial_item] - - # Simulate stale/leftover delta state for the same item id. - session._item_transcripts["assist_3"] = "stale partial" - - # Update arrives without transcript populated; merge must keep the existing - # complete transcript rather than reverting to the stale delta accumulator. - update_without_transcript = AssistantMessageItem( - item_id="assist_3", - role="assistant", - content=[AssistantAudio(audio=None, transcript=None)], - ) - await session.on_event(RealtimeModelItemUpdatedEvent(item=update_without_transcript)) - - preserved_item = cast(AssistantMessageItem, session._history[0]) - assert isinstance(preserved_item.content[0], AssistantAudio) - assert preserved_item.content[0].transcript == "Final complete transcript" diff --git a/tests/realtime/test_session_approvals.py b/tests/realtime/test_session_approvals.py new file mode 100644 index 0000000000..e54408a7a1 --- /dev/null +++ b/tests/realtime/test_session_approvals.py @@ -0,0 +1,1047 @@ +"""Function-tool approvals, rejection decisions, and approval guardrail ordering.""" + +import asyncio +from typing import Any +from unittest.mock import patch + +import pytest + +import agents._debug as _debug +from agents._tool_identity import get_function_tool_lookup_key_for_tool +from agents.exceptions import ModelBehaviorError +from agents.realtime.agent import RealtimeAgent +from agents.realtime.events import ( + RealtimeToolApprovalRequired, + RealtimeToolEnd, + RealtimeToolStart, +) +from agents.realtime.model_events import ( + RealtimeModelToolCallEvent, +) +from agents.realtime.model_inputs import ( + RealtimeModelSendToolOutput, +) +from agents.realtime.session import ( + REJECTION_MESSAGE, + RealtimeSession, +) +from agents.tool import FunctionTool, function_tool, tool_namespace +from agents.tool_context import ToolContext +from agents.tool_guardrails import ( + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + tool_input_guardrail, +) + +from . import session_test_support +from .session_test_support import ( + RecordingRealtimeModel, + _named_function_tool, + _sent_tool_output_strings, +) + +# Bind shared fixtures explicitly so unrelated Realtime modules do not inherit them. +mock_agent = session_test_support.mock_agent +mock_function_tool = session_test_support.mock_function_tool +mock_model = session_test_support.mock_model + + +class TestToolCallExecution: + """Test suite for tool call execution flow in RealtimeSession._handle_tool_call""" + + @pytest.mark.asyncio + async def test_approval_resume_uses_pending_initial_settings_dispatch_snapshot( + self, mock_model + ): + approved_tool = _named_function_tool( + "approval_tool", + "approved implementation", + needs_approval=True, + ) + replacement_tool = _named_function_tool("approval_tool", "replacement implementation") + initial_agent = RealtimeAgent(name="initial", tools=[], handoffs=[]) + replacement_agent = RealtimeAgent(name="replacement", tools=[replacement_tool], handoffs=[]) + session = RealtimeSession( + mock_model, + initial_agent, + None, + model_config={"initial_model_settings": {"tools": [approved_tool]}}, + run_config={"async_tool_calls": False}, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="approval_tool", + call_id="call_pending_snapshot", + arguments="{}", + ) + + await session.__aenter__() + try: + await session._handle_tool_call(tool_call_event) + assert list(session._pending_tool_calls) == [tool_call_event.call_id] + + await session.update_agent(replacement_agent) + await session.approve_tool_call(tool_call_event.call_id) + + assert _sent_tool_output_strings(mock_model) == ["approved implementation"] + finally: + await session.__aexit__(None, None, None) + + @pytest.mark.asyncio + async def test_function_tool_needs_approval_emits_event( + self, mock_model, mock_agent, mock_function_tool + ): + """Tools marked as needs_approval should pause and emit an approval request.""" + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + + session = RealtimeSession(mock_model, mock_agent, None) + + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_needs_approval", arguments='{"param": "value"}' + ) + + await session._handle_tool_call(tool_call_event) + + assert tool_call_event.call_id in session._pending_tool_calls + assert mock_function_tool.on_invoke_tool.call_count == 0 + + approval_event = await session._event_queue.get() + assert isinstance(approval_event, RealtimeToolApprovalRequired) + assert approval_event.call_id == tool_call_event.call_id + assert approval_event.tool == mock_function_tool + + @pytest.mark.parametrize( + "arguments", + [ + "", + '{"subject": "refund"', + "null", + "[]", + '{"amount": NaN}', + '{"amount": Infinity}', + '{"amount": -Infinity}', + ], + ) + @pytest.mark.asyncio + async def test_callable_function_approval_fails_closed_for_invalid_arguments( + self, mock_model, arguments: str + ) -> None: + approval_inputs: list[dict[str, Any]] = [] + tool_inputs: list[str] = [] + + async def needs_approval(_ctx: Any, params: dict[str, Any], _call_id: str) -> bool: + approval_inputs.append(params) + return False + + async def invoke_tool(_ctx: ToolContext[Any], raw_arguments: str) -> str: + tool_inputs.append(raw_arguments) + return "sent" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + tool_call_event = RealtimeModelToolCallEvent( + name=tool.name, + call_id="call-invalid", + arguments=arguments, + ) + + await session._handle_tool_call(tool_call_event) + + assert tool_call_event.call_id in session._pending_tool_calls + assert approval_inputs == [] + assert tool_inputs == [] + approval_event = await session._event_queue.get() + assert isinstance(approval_event, RealtimeToolApprovalRequired) + + @pytest.mark.asyncio + async def test_callable_function_approval_receives_valid_object_arguments( + self, mock_model + ) -> None: + approval_inputs: list[dict[str, Any]] = [] + tool_inputs: list[str] = [] + + async def needs_approval(_ctx: Any, params: dict[str, Any], _call_id: str) -> bool: + approval_inputs.append(params) + return False + + async def invoke_tool(_ctx: ToolContext[Any], raw_arguments: str) -> str: + tool_inputs.append(raw_arguments) + return "sent" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {"subject": {"type": "string"}}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + arguments = '{"subject": "status update"}' + tool_call_event = RealtimeModelToolCallEvent( + name=tool.name, + call_id="call-valid", + arguments=arguments, + ) + + await session._handle_tool_call(tool_call_event) + + assert approval_inputs == [{"subject": "status update"}] + assert tool_inputs == [arguments] + assert tool_call_event.call_id not in session._pending_tool_calls + + @pytest.mark.asyncio + async def test_tool_input_guardrail_rejects_before_realtime_function_execution( + self, mock_model + ): + """Tool input guardrails should run before regular realtime function tool execution.""" + executed = False + + @tool_input_guardrail + def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.reject_content("blocked before execution") + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + nonlocal executed + executed = True + return "ok" + + guarded_tool = FunctionTool( + name="test_function", + description="guarded", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + tool_input_guardrails=[reject_guardrail], + ) + agent = RealtimeAgent(name="agent", tools=[guarded_tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_guardrail_reject", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + + assert executed is False + assert len(mock_model.sent_tool_outputs) == 1 + _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] + assert sent_output == "blocked before execution" + assert start_response is True + + @pytest.mark.asyncio + async def test_realtime_pending_approval_skips_tool_input_guardrails_by_default( + self, mock_model + ): + guardrail_runs = 0 + + @tool_input_guardrail + def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + nonlocal guardrail_runs + guardrail_runs += 1 + return ToolGuardrailFunctionOutput.allow() + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + return "ok" + + guarded_tool = FunctionTool( + name="test_function", + description="guarded", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + tool_input_guardrails=[count_guardrail], + ) + agent = RealtimeAgent(name="agent", tools=[guarded_tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_guardrail_pending", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + + assert tool_call_event.call_id in session._pending_tool_calls + assert guardrail_runs == 0 + + @pytest.mark.asyncio + async def test_realtime_pre_approval_tool_input_guardrail_rejects_pending_approval( + self, mock_model + ): + executed = False + + @tool_input_guardrail + def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + return ToolGuardrailFunctionOutput.reject_content("blocked before approval") + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + nonlocal executed + executed = True + return "ok" + + guarded_tool = FunctionTool( + name="test_function", + description="guarded", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + tool_input_guardrails=[reject_guardrail], + ) + agent = RealtimeAgent(name="agent", tools=[guarded_tool]) + session = RealtimeSession( + mock_model, + agent, + None, + run_config={ + "async_tool_calls": False, + "tool_execution": {"pre_approval_tool_input_guardrails": True}, + }, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_pre_approval_reject", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + + assert executed is False + assert tool_call_event.call_id not in session._pending_tool_calls + assert len(mock_model.sent_tool_outputs) == 1 + _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] + assert sent_output == "blocked before approval" + assert start_response is True + + @pytest.mark.asyncio + async def test_realtime_pre_approval_tool_input_guardrails_rerun_after_approval( + self, mock_model + ): + guardrail_runs = 0 + executed = 0 + + @tool_input_guardrail + def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + nonlocal guardrail_runs + guardrail_runs += 1 + return ToolGuardrailFunctionOutput.allow() + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + nonlocal executed + executed += 1 + return "ok" + + guarded_tool = FunctionTool( + name="test_function", + description="guarded", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + tool_input_guardrails=[count_guardrail], + ) + agent = RealtimeAgent(name="agent", tools=[guarded_tool]) + session = RealtimeSession( + mock_model, + agent, + None, + run_config={ + "async_tool_calls": False, + "tool_execution": {"pre_approval_tool_input_guardrails": True}, + }, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_pre_approval_rerun", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + assert guardrail_runs == 1 + assert executed == 0 + + await session.approve_tool_call(tool_call_event.call_id) + + assert guardrail_runs == 2 + assert executed == 1 + assert len(mock_model.sent_tool_outputs) == 1 + _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] + assert sent_output == "ok" + assert start_response is True + + @pytest.mark.asyncio + async def test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_once( + self, mock_model, mock_agent, mock_function_tool + ): + """A duplicate approval-gated call should not enqueue another approval or run twice.""" + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_duplicate_approval", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + await session._handle_tool_call(tool_call_event) + + changed_event = RealtimeModelToolCallEvent( + name="test_function", + call_id=tool_call_event.call_id, + arguments='{"changed":true}', + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) + + assert list(session._pending_tool_calls) == [tool_call_event.call_id] + approval_events = [] + while not session._event_queue.empty(): + event = await session._event_queue.get() + if isinstance(event, RealtimeToolApprovalRequired): + approval_events.append(event) + assert len(approval_events) == 1 + + await session.approve_tool_call(tool_call_event.call_id) + await session._handle_tool_call(tool_call_event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) + + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.asyncio + async def test_approve_pending_tool_call_runs_tool( + self, mock_model, mock_agent, mock_function_tool + ): + """Approving a pending tool call should resume execution.""" + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_approve", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + await session.approve_tool_call(tool_call_event.call_id) + + assert mock_function_tool.on_invoke_tool.call_count == 1 + assert len(mock_model.sent_tool_outputs) == 1 + assert session._pending_tool_calls == {} + + events = [] + while not session._event_queue.empty(): + events.append(await session._event_queue.get()) + + assert any(isinstance(ev, RealtimeToolStart) for ev in events) + assert any(isinstance(ev, RealtimeToolEnd) for ev in events) + + @pytest.mark.asyncio + async def test_async_approve_pending_tool_call_reserves_call_id_before_task_runs( + self, mock_model + ): + """A duplicate event after approval should not outrun the approved async task.""" + approved_calls: list[str] = [] + duplicate_calls: list[str] = [] + + async def invoke_approved_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + approved_calls.append("approved") + return "approved_result" + + async def invoke_duplicate_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + duplicate_calls.append("duplicate") + return "duplicate_result" + + approved_tool = FunctionTool( + name="test_function", + description="approved", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_approved_tool, + needs_approval=True, + ) + duplicate_tool = FunctionTool( + name="test_function", + description="duplicate", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_duplicate_tool, + needs_approval=False, + ) + approved_agent = RealtimeAgent(name="approved_agent", tools=[approved_tool]) + duplicate_agent = RealtimeAgent(name="duplicate_agent", tools=[duplicate_tool]) + session = RealtimeSession(mock_model, approved_agent, None) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_async_approval_race", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + await session.approve_tool_call(tool_call_event.call_id) + + assert tool_call_event.call_id in session._active_tool_invocations + await session._handle_tool_call(tool_call_event, agent_snapshot=duplicate_agent) + + tool_call_tasks = list(session._tool_call_tasks) + assert len(tool_call_tasks) == 1 + await asyncio.gather(*tool_call_tasks) + + assert approved_calls == ["approved"] + assert duplicate_calls == [] + assert len(mock_model.sent_tool_outputs) == 1 + _sent_call, sent_output, _start_response = mock_model.sent_tool_outputs[0] + assert sent_output == "approved_result" + + @pytest.mark.asyncio + async def test_always_approve_namespaced_tool_call_does_not_approve_bare_tool(self, mock_model): + """Always approval should stay scoped to the namespaced tool key.""" + tool_calls: list[str] = [] + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "account" + + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[ + FunctionTool( + name="lookup_account", + description="Look up account", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + ) + ], + )[0] + bare_tool = FunctionTool( + name="lookup_account", + description="Look up account", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + ) + namespaced_agent = RealtimeAgent(name="crm_agent", tools=[namespaced_tool]) + bare_agent = RealtimeAgent(name="bare_agent", tools=[bare_tool]) + + session = RealtimeSession( + mock_model, + namespaced_agent, + None, + run_config={"async_tool_calls": False}, + ) + + first_call = RealtimeModelToolCallEvent( + name="lookup_account", call_id="call_first", arguments="{}" + ) + second_call = RealtimeModelToolCallEvent( + name="lookup_account", call_id="call_second", arguments="{}" + ) + + await session._handle_tool_call(first_call) + await session.approve_tool_call(first_call.call_id, always=True) + await session._handle_tool_call(second_call, agent_snapshot=bare_agent) + + assert ( + session._context_wrapper.get_approval_status( + "lookup_account", + second_call.call_id, + ) + is None + ) + assert "crm.lookup_account" in session._context_wrapper._approvals + assert "lookup_account" not in session._context_wrapper._approvals + assert sorted(session._pending_tool_calls) == [second_call.call_id] + assert len(mock_model.sent_tool_outputs) == 1 + assert tool_calls == ["called"] + + @pytest.mark.asyncio + async def test_reject_pending_tool_call_sends_rejection_output( + self, mock_model, mock_agent, mock_function_tool + ): + """Rejecting a pending tool call should notify the model and skip execution.""" + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + + session = RealtimeSession(mock_model, mock_agent, None) + + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_reject", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + await session.reject_tool_call(tool_call_event.call_id) + await session._handle_tool_call(tool_call_event) + + assert mock_function_tool.on_invoke_tool.call_count == 0 + assert len(mock_model.sent_tool_outputs) == 1 + _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] + assert sent_output == REJECTION_MESSAGE + assert start_response is True + assert session._pending_tool_calls == {} + + events = [] + while not session._event_queue.empty(): + events.append(await session._event_queue.get()) + + assert any( + isinstance(ev, RealtimeToolEnd) and ev.output == REJECTION_MESSAGE for ev in events + ) + + @pytest.mark.asyncio + async def test_reject_pending_tool_call_reserves_call_id_before_sending( + self, mock_agent, mock_function_tool + ): + """A duplicate event during rejection output sending should not emit a second output.""" + + class BlockingToolOutputModel(RecordingRealtimeModel): + def __init__(self): + super().__init__() + self.started = asyncio.Event() + self.release = asyncio.Event() + self.block_next_tool_output = True + + async def send_event(self, event): + if isinstance(event, RealtimeModelSendToolOutput) and self.block_next_tool_output: + self.block_next_tool_output = False + self.started.set() + await self.release.wait() + await super().send_event(event) + + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + mock_model = BlockingToolOutputModel() + session = RealtimeSession(mock_model, mock_agent, None) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_reject_race", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + reject_task = asyncio.create_task(session.reject_tool_call(tool_call_event.call_id)) + await asyncio.wait_for(mock_model.started.wait(), timeout=1) + + await session._handle_tool_call(tool_call_event) + + mock_model.release.set() + await reject_task + + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.asyncio + async def test_reject_pending_tool_call_uses_run_level_formatter( + self, mock_model, mock_agent, mock_function_tool + ): + """Rejecting a pending tool call should use the run-level formatter output.""" + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={ + "tool_error_formatter": ( + lambda args: f"run-level {args.tool_name} denied ({args.call_id})" + ) + }, + ) + + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_reject_custom", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + await session.reject_tool_call(tool_call_event.call_id) + + _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] + assert sent_output == "run-level test_function denied (call_reject_custom)" + assert start_response is True + + events = [] + while not session._event_queue.empty(): + events.append(await session._event_queue.get()) + + assert any( + isinstance(ev, RealtimeToolEnd) + and ev.output == "run-level test_function denied (call_reject_custom)" + for ev in events + ) + + @pytest.mark.asyncio + async def test_rejection_formatter_error_is_redacted( + self, monkeypatch, mock_model, mock_agent, mock_function_tool + ): + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True) + + def fail_formatter(_args): + raise ValueError("SECRET_REALTIME_TOOL_FORMATTER") + + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"tool_error_formatter": fail_formatter}, + ) + + with patch("agents.realtime.session.logger") as mock_logger: + message = await session._resolve_approval_rejection_message( + tool=mock_function_tool, + call_id="call_reject_error", + ) + + assert message + mock_logger.error.assert_called_once_with("%s", "Tool error formatter failed", stacklevel=3) + + @pytest.mark.asyncio + async def test_cancelled_rejection_formatter_leaves_invocation_executed( + self, mock_model, mock_agent + ): + formatter_entered = asyncio.Event() + + @function_tool + def approval_tool() -> str: + return "done" + + async def blocking_formatter(_args): + formatter_entered.set() + await asyncio.Event().wait() + return "rejected" + + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"tool_error_formatter": blocking_formatter}, + ) + tool_call = RealtimeModelToolCallEvent( + name=approval_tool.name, + call_id="call_rejected_cancelled", + arguments="{}", + ) + canonical_call = session._build_tool_approval_item( # noqa: SLF001 + approval_tool, + tool_call, + mock_agent, + ).raw_item + lookup_key = get_function_tool_lookup_key_for_tool(approval_tool) + assert session._context_wrapper._tool_invocation_status( # noqa: SLF001 + canonical_call, + tool_lookup_key=lookup_key, + ) == (("function_call", "call_rejected_cancelled"), False, False) + + task = asyncio.create_task( + session._resolve_approval_rejection_message( # noqa: SLF001 + tool=approval_tool, + call_id=tool_call.call_id, + tool_call=canonical_call, + ) + ) + await formatter_entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert session._context_wrapper._tool_invocation_status( # noqa: SLF001 + canonical_call, + tool_lookup_key=lookup_key, + ) == (("function_call", "call_rejected_cancelled"), False, True) + + @pytest.mark.asyncio + async def test_reject_pending_tool_call_prefers_explicit_message( + self, mock_model, mock_agent, mock_function_tool + ): + """Rejecting a pending tool call should prefer the explicit rejection message.""" + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={ + "tool_error_formatter": ( + lambda args: f"run-level {args.tool_name} denied ({args.call_id})" + ) + }, + ) + + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_reject_explicit", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + await session.reject_tool_call( + tool_call_event.call_id, + rejection_message="explicit rejection message", + ) + + _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] + assert sent_output == "explicit rejection message" + assert start_response is True + + events = [] + while not session._event_queue.empty(): + events.append(await session._event_queue.get()) + + assert any( + isinstance(ev, RealtimeToolEnd) and ev.output == "explicit rejection message" + for ev in events + ) + + @pytest.mark.asyncio + async def test_always_reject_namespaced_tool_call_reuses_explicit_message(self, mock_model): + """Always rejection should reuse explicit messages through the qualified tool key.""" + tool_calls: list[str] = [] + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "account" + + namespaced_tool = tool_namespace( + name="crm", + description="CRM tools", + tools=[ + FunctionTool( + name="lookup_account", + description="Look up account", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + ) + ], + )[0] + agent = RealtimeAgent(name="crm_agent", tools=[namespaced_tool]) + session = RealtimeSession(mock_model, agent, None) + + first_call = RealtimeModelToolCallEvent( + name="lookup_account", call_id="call_reject_first", arguments="{}" + ) + second_call = RealtimeModelToolCallEvent( + name="lookup_account", call_id="call_reject_second", arguments="{}" + ) + + await session._handle_tool_call(first_call) + await session.reject_tool_call( + first_call.call_id, + always=True, + rejection_message="explicit crm rejection", + ) + await session._handle_tool_call(second_call) + + assert "crm.lookup_account" in session._context_wrapper._approvals + assert "lookup_account" not in session._context_wrapper._approvals + assert session._pending_tool_calls == {} + assert [output for _call, output, _start in mock_model.sent_tool_outputs] == [ + "explicit crm rejection", + "explicit crm rejection", + ] + assert tool_calls == [] + + @pytest.mark.asyncio + async def test_sticky_rejection_does_not_bind_duplicate_call_id_payload( + self, mock_model, mock_agent, mock_function_tool + ): + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession(mock_model, mock_agent, None) + first_call = RealtimeModelToolCallEvent( + name="test_function", call_id="call-sticky-reject", arguments="{}" + ) + changed_call = RealtimeModelToolCallEvent( + name="test_function", + call_id=first_call.call_id, + arguments='{"changed":true}', + ) + + await session._handle_tool_call(first_call) + await session.reject_tool_call(first_call.call_id, always=True) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_call) + + mock_function_tool.on_invoke_tool.assert_not_called() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.asyncio + async def test_sticky_rejection_skips_dynamic_approval_checker(self, mock_model): + checker_calls: list[str] = [] + tool_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + if call_id != "call-reject-first": + raise AssertionError("sticky rejection must bypass needs_approval") + return True + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "should-not-run" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + first_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-first", arguments="{}" + ) + second_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-second", arguments="{}" + ) + + await session._handle_tool_call(first_call) + await session.reject_tool_call(first_call.call_id, always=True) + await session._handle_tool_call(second_call) + + assert checker_calls == ["call-reject-first"] + assert tool_calls == [] + assert session._pending_tool_calls == {} + assert len(mock_model.sent_tool_outputs) == 2 + + @pytest.mark.asyncio + async def test_sticky_rejection_wins_while_dynamic_approval_checker_is_pending( + self, mock_model + ): + checker_started = asyncio.Event() + checker_release = asyncio.Event() + checker_calls: list[str] = [] + tool_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + if call_id == "call-pending-checker": + checker_started.set() + await checker_release.wait() + return False + return True + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "should-not-run" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None) + first_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-first", arguments="{}" + ) + pending_checker_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-pending-checker", arguments="{}" + ) + + await session._handle_tool_call(first_call) + pending_checker_task = asyncio.create_task(session._handle_tool_call(pending_checker_call)) + try: + await asyncio.wait_for(checker_started.wait(), timeout=1) + await session.reject_tool_call( + first_call.call_id, + always=True, + rejection_message="sticky rejection", + ) + finally: + checker_release.set() + await pending_checker_task + + assert checker_calls == ["call-reject-first", "call-pending-checker"] + assert tool_calls == [] + assert session._pending_tool_calls == {} + assert [output for _call, output, _start in mock_model.sent_tool_outputs] == [ + "sticky rejection", + "sticky rejection", + ] + + @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) + @pytest.mark.asyncio + async def test_sticky_decision_wins_while_rejecting_pre_approval_guardrail_is_pending( + self, mock_model, approved: bool + ): + guardrail_started = asyncio.Event() + guardrail_release = asyncio.Event() + guardrail_calls: list[str | None] = [] + tool_calls: list[str] = [] + + @tool_input_guardrail + async def blocking_guardrail( + data: ToolInputGuardrailData, + ) -> ToolGuardrailFunctionOutput: + call_id = data.context.tool_call_id + guardrail_calls.append(call_id) + if call_id == "call-pending-guardrail": + guardrail_started.set() + await guardrail_release.wait() + return ToolGuardrailFunctionOutput.reject_content("guardrail rejection") + return ToolGuardrailFunctionOutput.allow() + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "tool output" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=True, + tool_input_guardrails=[blocking_guardrail], + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession( + mock_model, + agent, + None, + run_config={"tool_execution": {"pre_approval_tool_input_guardrails": True}}, + ) + first_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-reject-first", arguments="{}" + ) + pending_guardrail_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call-pending-guardrail", arguments="{}" + ) + + await session._handle_tool_call(first_call) + pending_guardrail_task = asyncio.create_task( + session._handle_tool_call(pending_guardrail_call) + ) + try: + await asyncio.wait_for(guardrail_started.wait(), timeout=1) + approval_item = session._pending_tool_calls[first_call.call_id].approval_item + if approved: + session._context_wrapper.approve_tool(approval_item, always_approve=True) + else: + session._context_wrapper.reject_tool( + approval_item, + always_reject=True, + rejection_message="sticky rejection", + ) + finally: + guardrail_release.set() + await pending_guardrail_task + + assert pending_guardrail_call.call_id not in session._pending_tool_calls + outputs = [output for _call, output, _start in mock_model.sent_tool_outputs] + if approved: + assert guardrail_calls == [ + "call-reject-first", + "call-pending-guardrail", + "call-pending-guardrail", + ] + assert tool_calls == [] + assert outputs == ["guardrail rejection"] + else: + assert guardrail_calls == ["call-reject-first", "call-pending-guardrail"] + assert tool_calls == [] + assert outputs == ["sticky rejection"] diff --git a/tests/realtime/test_session_guardrails.py b/tests/realtime/test_session_guardrails.py new file mode 100644 index 0000000000..f62b63e97d --- /dev/null +++ b/tests/realtime/test_session_guardrails.py @@ -0,0 +1,1106 @@ +"""Response-scoped output guardrails and playback interruption.""" + +import asyncio +import logging + +import pytest + +import agents._debug as _debug +from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail +from agents.realtime.agent import RealtimeAgent +from agents.realtime.config import RealtimeRunConfig +from agents.realtime.events import ( + RealtimeAgentEndEvent, + RealtimeAudio, + RealtimeError, + RealtimeGuardrailTripped, +) +from agents.realtime.model import RealtimeModel +from agents.realtime.model_events import ( + RealtimeModelAudioEvent, + RealtimeModelOutputTextDeltaEvent, + RealtimeModelTranscriptDeltaEvent, + RealtimeModelTurnEndedEvent, + RealtimeModelTurnStartedEvent, +) +from agents.realtime.model_inputs import ( + RealtimeModelSendInterrupt, + RealtimeModelSendUserInput, +) +from agents.realtime.session import ( + RealtimeSession, +) + +from . import session_test_support +from .session_test_support import RecordingRealtimeModel + +# Bind shared fixtures explicitly so unrelated Realtime modules do not inherit them. +mock_agent = session_test_support.mock_agent +mock_model = session_test_support.mock_model + + +class TestGuardrailFunctionality: + """Test suite for output guardrail functionality in RealtimeSession""" + + async def _wait_for_guardrail_tasks(self, session): + """Wait for all pending guardrail tasks to complete.""" + import asyncio + + if session._guardrail_tasks: + await asyncio.gather(*session._guardrail_tasks, return_exceptions=True) + + @pytest.fixture + def triggered_guardrail(self): + """Creates a guardrail that always triggers""" + + def guardrail_func(context, agent, output): + return GuardrailFunctionOutput( + output_info={"reason": "test trigger"}, tripwire_triggered=True + ) + + return OutputGuardrail(guardrail_function=guardrail_func, name="triggered_guardrail") + + @pytest.fixture + def safe_guardrail(self): + """Creates a guardrail that never triggers""" + + def guardrail_func(context, agent, output): + return GuardrailFunctionOutput( + output_info={"reason": "safe content"}, tripwire_triggered=False + ) + + return OutputGuardrail(guardrail_function=guardrail_func, name="safe_guardrail") + + @pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [(True, False), (False, True), (False, False)], + ids=["model_redacted", "tool_redacted", "diagnostic"], + ) + @pytest.mark.asyncio + async def test_output_guardrail_failure_follows_both_data_policies( + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + mock_model: RealtimeModel, + model_redacted: bool, + tool_redacted: bool, + ) -> None: + error = RuntimeError("SECRET_REALTIME_GUARDRAIL_ERROR") + + async def failing_guardrail(context, agent, output): + _ = context, agent, output + raise error + + guardrail = OutputGuardrail( + guardrail_function=failing_guardrail, + name="SECRET_REALTIME_GUARDRAIL_NAME", + ) + agent = RealtimeAgent(name="agent", output_guardrails=[guardrail]) + session = RealtimeSession(mock_model, agent, None) + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + + with caplog.at_level(logging.DEBUG, logger="openai.agents"): + triggered = await session._run_output_guardrails("model text", "response-id") + + assert triggered is False + records = [ + record + for record in caplog.records + if "Output guardrail raised an exception" in record.getMessage() + ] + assert len(records) == 1 + record = records[0] + redacted = model_redacted or tool_redacted + if redacted: + assert record.msg == "%s" + assert record.args == ("Output guardrail raised an exception; skipping it",) + assert record.exc_info is None + assert record.exc_text is None + assert "openai_agents_diagnostic_context" not in record.__dict__ + assert error not in record.__dict__.values() + rendered = logging.Formatter().format(record) + assert "SECRET_REALTIME_GUARDRAIL_ERROR" not in rendered + assert "SECRET_REALTIME_GUARDRAIL_NAME" not in rendered + else: + context = record.__dict__["openai_agents_diagnostic_context"] + assert context == {"guardrail_name": "SECRET_REALTIME_GUARDRAIL_NAME"} + assert record.exc_info is not None + assert record.exc_info[1] is error + assert "SECRET_REALTIME_GUARDRAIL_ERROR" in logging.Formatter().format(record) + + @pytest.mark.asyncio + async def test_output_guardrail_failure_tolerates_missing_callable_name( + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + mock_model: RealtimeModel, + ) -> None: + class _FailingGuardrailCallable: + async def __call__(self, context, agent, output): + _ = context, agent, output + raise RuntimeError("SECRET_UNNAMED_GUARDRAIL_ERROR") + + guardrail = OutputGuardrail(guardrail_function=_FailingGuardrailCallable()) + agent = RealtimeAgent(name="agent", output_guardrails=[guardrail]) + session = RealtimeSession(mock_model, agent, None) + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + triggered = await session._run_output_guardrails("model text", "response-id") + + assert triggered is False + records = [ + record + for record in caplog.records + if "Output guardrail raised an exception" in record.getMessage() + ] + assert len(records) == 1 + context = records[0].__dict__["openai_agents_diagnostic_context"] + assert context["guardrail_type"].endswith("._FailingGuardrailCallable") + assert records[0].exc_info is not None + + @pytest.mark.asyncio + async def test_transcript_delta_triggers_guardrail_at_threshold( + self, mock_model, mock_agent, triggered_guardrail + ): + """Test that guardrails run when transcript delta reaches debounce threshold""" + run_config: RealtimeRunConfig = { + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 10}, + } + + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + # Send transcript delta that exceeds threshold (10 chars) + transcript_event = RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="this is more than ten characters", response_id="resp_1" + ) + + await session.on_event(transcript_event) + + # Wait for async guardrail tasks to complete + await self._wait_for_guardrail_tasks(session) + + # Should have triggered guardrail and interrupted + assert mock_model.interrupts_called == 1 + interrupt_event = next( + event + for event in mock_model.sent_events + if isinstance(event, RealtimeModelSendInterrupt) + ) + assert interrupt_event.force_response_cancel is True + assert len(mock_model.sent_messages) == 1 + assert mock_model.sent_messages[0] == "guardrail triggered: triggered_guardrail" + + # Should have emitted guardrail_tripped event + events = [] + while not session._event_queue.empty(): + events.append(await session._event_queue.get()) + + guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)] + assert len(guardrail_events) == 1 + assert guardrail_events[0].message == "this is more than ten characters" + + @pytest.mark.asyncio + async def test_output_text_delta_triggers_response_scoped_guardrail( + self, mock_model, mock_agent, triggered_guardrail + ): + run_config: RealtimeRunConfig = { + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + await session.on_event(RealtimeModelTurnStartedEvent()) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + interrupt_event = next( + event + for event in mock_model.sent_events + if isinstance(event, RealtimeModelSendInterrupt) + ) + assert interrupt_event.force_response_cancel is True + assert interrupt_event.response_id == "response_1" + assert interrupt_event.cancel_response_only is True + assert mock_model.sent_messages == ["guardrail triggered: triggered_guardrail"] + + @pytest.mark.asyncio + async def test_stale_output_text_guardrail_does_not_affect_newer_response(self, mock_model): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + guardrail = OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + source_agent = RealtimeAgent(name="source", output_guardrails=[guardrail]) + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 1}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + assert not any( + isinstance(event, RealtimeModelSendInterrupt) for event in mock_model.sent_events + ) + assert mock_model.sent_messages == [] + queued_events = [] + while not session._event_queue.empty(): + queued_events.append(await session._event_queue.get()) + assert sum(isinstance(event, RealtimeGuardrailTripped) for event in queued_events) == 1 + + @pytest.mark.asyncio + async def test_stale_audio_guardrail_interrupts_only_source_playback(self, mock_model): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + session = RealtimeSession( + mock_model, + RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + ], + ), + None, + run_config={"guardrails_settings": {"debounce_text_length": 1}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + await session.on_event(RealtimeModelTurnEndedEvent(response_id="response_1")) + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) + + assert mock_model.retired_audio_response_ids == [] + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + interrupts = [ + event + for event in mock_model.sent_events + if isinstance(event, RealtimeModelSendInterrupt) + ] + assert len(interrupts) == 1 + assert interrupts[0].response_id == "response_1" + assert interrupts[0].playback_only is True + assert interrupts[0].force_response_cancel is False + assert mock_model.sent_messages == [] + assert mock_model.retired_audio_response_ids == ["response_1"] + assert session._interrupted_response_ids == set() + + @pytest.mark.asyncio + async def test_response_audio_cleanup_waits_for_delayed_guardrail(self, mock_agent): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + operations: list[str] = [] + + class TrackingModel(RecordingRealtimeModel): + async def send_event(self, event): + await super().send_event(event) + if isinstance(event, RealtimeModelSendInterrupt): + operations.append("interrupt") + + def _retire_response_audio(self, response_id: str) -> None: + super()._retire_response_audio(response_id) + operations.append("retire") + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + model = TrackingModel() + session = RealtimeSession( + model, + mock_agent, + None, + run_config={ + "output_guardrails": [ + OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + ], + "guardrails_settings": {"debounce_text_length": 1}, + }, + ) + + await session.on_event(RealtimeModelTurnStartedEvent()) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + assert session._active_output_response_id == "response_1" + await session.on_event(RealtimeModelTurnEndedEvent()) + await asyncio.sleep(0) + + assert operations == [] + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + assert operations == ["interrupt", "retire"] + assert model.retired_audio_response_ids == ["response_1"] + assert session._guardrail_tasks_by_response_id == {} + assert session._responses_awaiting_guardrail_cleanup == set() + + @pytest.mark.asyncio + async def test_response_audio_cleanup_runs_immediately_without_guardrail_tasks( + self, mock_model, mock_agent + ): + session = RealtimeSession(mock_model, mock_agent, None) + + await session.on_event(RealtimeModelTurnEndedEvent(response_id="response_1")) + + assert mock_model.retired_audio_response_ids == ["response_1"] + + @pytest.mark.asyncio + async def test_stale_explicit_turn_end_preserves_active_response_guardrail_state( + self, mock_model, mock_agent + ): + session = RealtimeSession(mock_model, mock_agent, None) + await session.on_event(RealtimeModelTurnStartedEvent(response_id="new_response")) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="new_item", + delta="still active", + response_id="new_response", + ) + ) + active_generation = session._active_output_response_generation + active_agent = session._active_output_response_agent + + await session.on_event(RealtimeModelTurnEndedEvent(response_id="old_response")) + + assert mock_model.retired_audio_response_ids == ["old_response"] + assert session._active_output_response_id == "new_response" + assert session._active_output_response_generation == active_generation + assert session._active_output_response_agent is active_agent + assert session._item_transcripts == {"new_item": "still active"} + assert session._item_guardrail_run_counts == {"new_item": 0} + queued_events = [] + while not session._event_queue.empty(): + queued_events.append(await session._event_queue.get()) + assert not any(isinstance(event, RealtimeAgentEndEvent) for event in queued_events) + + @pytest.mark.asyncio + async def test_interrupted_response_audio_delta_is_not_forwarded(self, mock_model, mock_agent): + session = RealtimeSession(mock_model, mock_agent, None) + session._interrupted_response_ids.add("response_1") + + await session.on_event( + RealtimeModelAudioEvent( + data=b"audio", + response_id="response_1", + item_id="item_1", + content_index=0, + ) + ) + + queued_events = [] + while not session._event_queue.empty(): + queued_events.append(await session._event_queue.get()) + assert not any(isinstance(event, RealtimeAudio) for event in queued_events) + + @pytest.mark.asyncio + async def test_response_audio_cleanup_error_releases_session_suppression(self, mock_agent): + class FailingRetirementModel(RecordingRealtimeModel): + def _retire_response_audio(self, response_id: str) -> None: + raise RuntimeError(f"failed to retire {response_id}") + + session = RealtimeSession(FailingRetirementModel(), mock_agent, None) + session._interrupted_response_ids.add("response_1") + + session._retire_response_audio("response_1") + + assert session._interrupted_response_ids == set() + queued_event = await session._event_queue.get() + assert isinstance(queued_event, RealtimeError) + assert queued_event.error == { + "message": "Response audio cleanup failed: failed to retire response_1" + } + + @pytest.mark.asyncio + async def test_output_text_guardrail_sends_feedback_after_source_turn_ends( + self, mock_model, mock_agent, triggered_guardrail + ): + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + original_send_event = mock_model.send_event + + async def send_event(event): + await original_send_event(event) + if isinstance(event, RealtimeModelSendInterrupt): + await session.on_event(RealtimeModelTurnEndedEvent()) + + mock_model.send_event = send_event + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert mock_model.sent_messages == ["guardrail triggered: triggered_guardrail"] + + @pytest.mark.asyncio + async def test_output_text_guardrail_skips_feedback_for_completed_idless_newer_turn( + self, mock_model, mock_agent, triggered_guardrail + ): + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + original_send_event = mock_model.send_event + + async def send_event(event): + await original_send_event(event) + if isinstance(event, RealtimeModelSendInterrupt): + await session.on_event(RealtimeModelTurnEndedEvent()) + await session.on_event(RealtimeModelTurnStartedEvent()) + await session.on_event(RealtimeModelTurnEndedEvent()) + + mock_model.send_event = send_event + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert mock_model.sent_messages == [] + + @pytest.mark.asyncio + async def test_output_text_guardrail_rechecks_generation_at_feedback_send_boundary( + self, mock_agent, triggered_guardrail + ): + feedback_send_started = asyncio.Event() + release_feedback_send = asyncio.Event() + + class BoundaryCheckingModel(RecordingRealtimeModel): + async def send_event_if(self, event, send_if): + feedback_send_started.set() + await release_feedback_send.wait() + return await super().send_event_if(event, send_if) + + model = BoundaryCheckingModel() + session = RealtimeSession( + model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await feedback_send_started.wait() + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_2")) + release_feedback_send.set() + await self._wait_for_guardrail_tasks(session) + + assert model.sent_messages == [] + + @pytest.mark.asyncio + async def test_output_text_guardrail_skips_feedback_without_atomic_model_send( + self, mock_agent, triggered_guardrail + ): + class CustomModelWithoutAtomicSend(RecordingRealtimeModel): + def __init__(self): + super().__init__() + self.feedback_send_started = False + + async def send_event(self, event): + if isinstance(event, RealtimeModelSendUserInput): + self.feedback_send_started = True + await asyncio.sleep(0) + await super().send_event(event) + + async def send_event_if(self, event, send_if): + return await RealtimeModel.send_event_if(self, event, send_if) + + model = CustomModelWithoutAtomicSend() + session = RealtimeSession( + model, + mock_agent, + None, + run_config={ + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + }, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert any(isinstance(event, RealtimeModelSendInterrupt) for event in model.sent_events) + assert model.feedback_send_started is False + assert model.sent_messages == [] + + @pytest.mark.asyncio + async def test_output_text_guardrail_uses_agent_from_turn_start(self, mock_model): + observed_agents: list[RealtimeAgent] = [] + replacement_called = False + + def source_guardrail(context, agent, output): + _ = context, output + observed_agents.append(agent) + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + def replacement_guardrail(context, agent, output): + nonlocal replacement_called + _ = context, agent, output + replacement_called = True + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) + + source_agent = RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail(guardrail_function=source_guardrail, name="source_guardrail") + ], + ) + replacement_agent = RealtimeAgent( + name="replacement", + output_guardrails=[ + OutputGuardrail( + guardrail_function=replacement_guardrail, + name="replacement_guardrail", + ) + ], + ) + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 5}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.update_agent(replacement_agent) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="hello", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert observed_agents == [source_agent] + assert replacement_called is False + assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] + + @pytest.mark.asyncio + async def test_output_text_guardrail_retains_agent_for_matching_late_turn_start( + self, mock_model + ): + observed_agents: list[RealtimeAgent] = [] + + def source_guardrail(context, agent, output): + _ = context, output + observed_agents.append(agent) + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + source_agent = RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail(guardrail_function=source_guardrail, name="source_guardrail") + ], + ) + replacement_agent = RealtimeAgent(name="replacement") + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 5}}, + ) + + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="he", + response_id="response_1", + ) + ) + await session.update_agent(replacement_agent) + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="llo", + response_id="response_1", + ) + ) + await self._wait_for_guardrail_tasks(session) + + assert observed_agents == [source_agent] + assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] + + @pytest.mark.asyncio + async def test_matching_late_turn_start_retains_pending_guardrail_generation(self, mock_model): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + source_agent = RealtimeAgent( + name="source", + output_guardrails=[ + OutputGuardrail(guardrail_function=delayed_guardrail, name="source_guardrail") + ], + ) + session = RealtimeSession( + mock_model, + source_agent, + None, + run_config={"guardrails_settings": {"debounce_text_length": 2}}, + ) + + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="he", + response_id="response_1", + ) + ) + await guardrail_started.wait() + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + interrupt_event = next( + event + for event in mock_model.sent_events + if isinstance(event, RealtimeModelSendInterrupt) + ) + assert interrupt_event.response_id == "response_1" + assert mock_model.sent_messages == ["guardrail triggered: source_guardrail"] + + @pytest.mark.asyncio + async def test_output_text_guardrail_sends_feedback_if_source_ends_during_evaluation( + self, mock_model + ): + guardrail_started = asyncio.Event() + release_guardrail = asyncio.Event() + + async def delayed_guardrail(context, agent, output): + _ = context, agent, output + guardrail_started.set() + await release_guardrail.wait() + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=True) + + guardrail = OutputGuardrail( + guardrail_function=delayed_guardrail, + name="delayed_guardrail", + ) + session = RealtimeSession( + mock_model, + RealtimeAgent(name="source", output_guardrails=[guardrail]), + None, + run_config={"guardrails_settings": {"debounce_text_length": 1}}, + ) + + await session.on_event(RealtimeModelTurnStartedEvent(response_id="response_1")) + await session.on_event( + RealtimeModelOutputTextDeltaEvent( + item_id="item_1", + delta="blocked", + response_id="response_1", + ) + ) + await guardrail_started.wait() + + await session.on_event(RealtimeModelTurnEndedEvent()) + release_guardrail.set() + await self._wait_for_guardrail_tasks(session) + + assert not any( + isinstance(event, RealtimeModelSendInterrupt) for event in mock_model.sent_events + ) + assert mock_model.sent_messages == ["guardrail triggered: delayed_guardrail"] + + @pytest.mark.asyncio + async def test_agent_and_run_config_guardrails_not_run_twice(self, mock_model): + """Guardrails shared by agent and run config should execute once.""" + + call_count = 0 + + def guardrail_func(context, agent, output): + nonlocal call_count + call_count += 1 + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) + + shared_guardrail = OutputGuardrail( + guardrail_function=guardrail_func, name="shared_guardrail" + ) + + agent = RealtimeAgent(name="agent", output_guardrails=[shared_guardrail]) + run_config: RealtimeRunConfig = { + "output_guardrails": [shared_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + + session = RealtimeSession(mock_model, agent, None, run_config=run_config) + + await session.on_event( + RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="hello", response_id="resp_1") + ) + + await self._wait_for_guardrail_tasks(session) + + assert call_count == 1 + + @pytest.mark.asyncio + async def test_transcript_delta_multiple_thresholds_same_item( + self, mock_model, mock_agent, triggered_guardrail + ): + """Test guardrails run at 1x, 2x, 3x thresholds for same item_id""" + run_config: RealtimeRunConfig = { + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + # First delta - reaches 1x threshold (5 chars) + await session.on_event( + RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="12345", response_id="resp_1") + ) + + # Second delta - reaches 2x threshold (10 chars total) + await session.on_event( + RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="67890", response_id="resp_1") + ) + + # Wait for async guardrail tasks to complete + await self._wait_for_guardrail_tasks(session) + + # Should only trigger once due to interrupted_by_guardrail flag + assert mock_model.interrupts_called == 1 + assert len(mock_model.sent_messages) == 1 + + @pytest.mark.asyncio + async def test_large_transcript_delta_advances_past_each_crossed_threshold( + self, mock_model, mock_agent + ): + calls = 0 + + async def guardrail_func(context, agent, output): + nonlocal calls + calls += 1 + return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False) + + guardrail = OutputGuardrail(guardrail_function=guardrail_func) + run_config: RealtimeRunConfig = { + "output_guardrails": [guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="123456789012", response_id="resp_1" + ) + ) + await self._wait_for_guardrail_tasks(session) + assert calls == 1 + + await session.on_event( + RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="3", response_id="resp_1") + ) + await self._wait_for_guardrail_tasks(session) + + assert calls == 1 + + @pytest.mark.asyncio + async def test_transcript_delta_different_items_tracked_separately( + self, mock_model, mock_agent, safe_guardrail + ): + """Test that different item_ids are tracked separately for debouncing""" + run_config: RealtimeRunConfig = { + "output_guardrails": [safe_guardrail], + "guardrails_settings": {"debounce_text_length": 10}, + } + + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + # Add text to item_1 (8 chars - below threshold) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="12345678", response_id="resp_1" + ) + ) + + # Add text to item_2 (8 chars - below threshold) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_2", delta="abcdefgh", response_id="resp_2" + ) + ) + + # Neither should trigger guardrails yet + assert mock_model.interrupts_called == 0 + + # Add more text to item_1 (total 12 chars - above threshold) + await session.on_event( + RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="90ab", response_id="resp_1") + ) + + # item_1 should have triggered guardrail run (but not interrupted since safe) + assert session._item_guardrail_run_counts["item_1"] == 1 + assert ( + "item_2" not in session._item_guardrail_run_counts + or session._item_guardrail_run_counts["item_2"] == 0 + ) + + @pytest.mark.asyncio + async def test_turn_ended_clears_guardrail_state( + self, mock_model, mock_agent, triggered_guardrail + ): + """Test that turn_ended event clears guardrail state for next turn""" + run_config: RealtimeRunConfig = { + "output_guardrails": [triggered_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + # Trigger guardrail + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="trigger", response_id="resp_1" + ) + ) + + # Wait for async guardrail tasks to complete + await self._wait_for_guardrail_tasks(session) + + assert len(session._item_transcripts) == 1 + + # End turn + await session.on_event(RealtimeModelTurnEndedEvent()) + + # State should be cleared + assert len(session._item_transcripts) == 0 + assert len(session._item_guardrail_run_counts) == 0 + + @pytest.mark.asyncio + async def test_multiple_guardrails_all_triggered(self, mock_model, mock_agent): + """Test that all triggered guardrails are included in the event""" + + def create_triggered_guardrail(name): + def guardrail_func(context, agent, output): + return GuardrailFunctionOutput(output_info={"name": name}, tripwire_triggered=True) + + return OutputGuardrail(guardrail_function=guardrail_func, name=name) + + guardrail1 = create_triggered_guardrail("guardrail_1") + guardrail2 = create_triggered_guardrail("guardrail_2") + + run_config: RealtimeRunConfig = { + "output_guardrails": [guardrail1, guardrail2], + "guardrails_settings": {"debounce_text_length": 5}, + } + + session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config) + + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="trigger", response_id="resp_1" + ) + ) + + # Wait for async guardrail tasks to complete + await self._wait_for_guardrail_tasks(session) + + # Should have interrupted and sent message with both guardrail names + assert mock_model.interrupts_called == 1 + assert len(mock_model.sent_messages) == 1 + message = mock_model.sent_messages[0] + assert "guardrail_1" in message and "guardrail_2" in message + + # Should have emitted event with both guardrail results + events = [] + while not session._event_queue.empty(): + events.append(await session._event_queue.get()) + + guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)] + assert len(guardrail_events) == 1 + assert len(guardrail_events[0].guardrail_results) == 2 + + @pytest.mark.asyncio + async def test_agent_output_guardrails_triggered(self, mock_model, triggered_guardrail): + """Test that guardrails defined on the agent are executed.""" + agent = RealtimeAgent(name="agent", output_guardrails=[triggered_guardrail]) + run_config: RealtimeRunConfig = { + "guardrails_settings": {"debounce_text_length": 10}, + } + + session = RealtimeSession(mock_model, agent, None, run_config=run_config) + + transcript_event = RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="this is more than ten characters", response_id="resp_1" + ) + + await session.on_event(transcript_event) + await self._wait_for_guardrail_tasks(session) + + assert mock_model.interrupts_called == 1 + assert len(mock_model.sent_messages) == 1 + assert "triggered_guardrail" in mock_model.sent_messages[0] + + events = [] + while not session._event_queue.empty(): + events.append(await session._event_queue.get()) + + guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)] + assert len(guardrail_events) == 1 + assert guardrail_events[0].message == "this is more than ten characters" + + @pytest.mark.asyncio + async def test_concurrent_guardrail_tasks_interrupt_once_per_response(self, mock_model): + """Even if multiple guardrail tasks trigger concurrently for the same response_id, + only the first should interrupt and send a message.""" + import asyncio + + # Barrier to release both guardrail tasks at the same time + start_event = asyncio.Event() + + async def async_trigger_guardrail(context, agent, output): + await start_event.wait() + return GuardrailFunctionOutput( + output_info={"reason": "concurrent"}, tripwire_triggered=True + ) + + concurrent_guardrail = OutputGuardrail( + guardrail_function=async_trigger_guardrail, name="concurrent_trigger" + ) + + run_config: RealtimeRunConfig = { + "output_guardrails": [concurrent_guardrail], + "guardrails_settings": {"debounce_text_length": 5}, + } + + # Use a minimal agent (guardrails from run_config) + agent = RealtimeAgent(name="agent") + session = RealtimeSession(mock_model, agent, None, run_config=run_config) + + # Two deltas for same item and response to enqueue two guardrail tasks + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="12345", response_id="resp_same" + ) + ) + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="item_1", delta="67890", response_id="resp_same" + ) + ) + + # Wait until both tasks are enqueued + for _ in range(50): + if len(session._guardrail_tasks) >= 2: + break + await asyncio.sleep(0.01) + + # Release both tasks concurrently + start_event.set() + + # Wait for completion + if session._guardrail_tasks: + await asyncio.gather(*session._guardrail_tasks, return_exceptions=True) + + # Only one interrupt and one message should be sent + assert mock_model.interrupts_called == 1 + assert len(mock_model.sent_messages) == 1 diff --git a/tests/realtime/test_session_history.py b/tests/realtime/test_session_history.py new file mode 100644 index 0000000000..4b30ed89a6 --- /dev/null +++ b/tests/realtime/test_session_history.py @@ -0,0 +1,735 @@ +"""Realtime item history updates and transcript preservation.""" + +from typing import Any, cast +from unittest.mock import patch + +import pytest + +import agents._debug as _debug +from agents.realtime.agent import RealtimeAgent +from agents.realtime.events import ( + RealtimeHistoryAdded, + RealtimeHistoryUpdated, +) +from agents.realtime.items import ( + AssistantAudio, + AssistantMessageItem, + AssistantText, + InputAudio, + InputText, + RealtimeItem, + RealtimeToolCallItem, + UserMessageItem, +) +from agents.realtime.model_events import ( + RealtimeModelInputAudioTranscriptionCompletedEvent, + RealtimeModelItemDeletedEvent, + RealtimeModelItemUpdatedEvent, + RealtimeModelTranscriptDeltaEvent, +) +from agents.realtime.session import ( + RealtimeSession, +) + +from . import session_test_support +from .session_test_support import _DummyModel + +# Bind shared fixtures explicitly so unrelated Realtime modules do not inherit them. +mock_agent = session_test_support.mock_agent +mock_model = session_test_support.mock_model + + +@pytest.mark.asyncio +async def test_transcription_completed_adds_new_user_item(): + model = _DummyModel() + agent = RealtimeAgent(name="agent") + session = RealtimeSession(model, agent, None) + + event = RealtimeModelInputAudioTranscriptionCompletedEvent(item_id="item1", transcript="hello") + await session.on_event(event) + + # Should have appended a new user item + assert len(session._history) == 1 + assert session._history[0].type == "message" + assert session._history[0].role == "user" + + +class _FakeAudio: + # Looks like an audio part but is not an InputAudio/AssistantAudio instance + type = "audio" + transcript = None + + +@pytest.mark.asyncio +async def test_item_updated_merge_exception_path_logs_error(monkeypatch): + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + model = _DummyModel() + agent = RealtimeAgent(name="agent") + session = RealtimeSession(model, agent, None) + + # existing assistant message with transcript to preserve + existing = AssistantMessageItem( + item_id="a1", role="assistant", content=[AssistantAudio(audio=None, transcript="t")] + ) + session._history = [existing] + + # incoming message with a deliberately bogus content entry to trigger assertion path + incoming = AssistantMessageItem( + item_id="a1", role="assistant", content=[AssistantAudio(audio=None, transcript=None)] + ) + incoming.content[0] = cast(Any, _FakeAudio()) + + with patch("agents.realtime.session.logger") as mock_logger: + await session.on_event(RealtimeModelItemUpdatedEvent(item=incoming)) + mock_logger.error.assert_called_once_with("%s", "Error merging transcripts", stacklevel=3) + + +class TestEventHandling: + """Test suite for event handling and transformation in RealtimeSession.on_event""" + + @pytest.mark.asyncio + async def test_transcription_completed_event_updates_history(self, mock_model, mock_agent): + """Test that transcription completed events update history and emit events""" + session = RealtimeSession( + mock_model, mock_agent, None, run_config={"async_tool_calls": False} + ) + + # Set up initial history with an audio message + initial_item = UserMessageItem( + item_id="item_1", role="user", content=[InputAudio(transcript=None)] + ) + session._history = [initial_item] + + # Create transcription completed event + transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( + item_id="item_1", transcript="Hello world" + ) + + await session.on_event(transcription_event) + + # Check that history was updated + assert len(session._history) == 1 + updated_item = session._history[0] + assert updated_item.content[0].transcript == "Hello world" # type: ignore + assert updated_item.status == "completed" # type: ignore + + # Should have 2 events: raw + history updated + assert session._event_queue.qsize() == 2 + + await session._event_queue.get() # raw event + history_event = await session._event_queue.get() + assert isinstance(history_event, RealtimeHistoryUpdated) + assert len(history_event.history) == 1 + + @pytest.mark.asyncio + async def test_item_updated_event_adds_new_item(self, mock_model, mock_agent): + """Test that item_updated events add new items to history""" + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + + new_item = AssistantMessageItem( + item_id="new_item", role="assistant", content=[AssistantText(text="Hello")] + ) + + item_updated_event = RealtimeModelItemUpdatedEvent(item=new_item) + + await session.on_event(item_updated_event) + + # Check that item was added to history + assert len(session._history) == 1 + assert session._history[0] == new_item + + # Should have 2 events: raw + history added + assert session._event_queue.qsize() == 2 + + await session._event_queue.get() # raw event + history_event = await session._event_queue.get() + assert isinstance(history_event, RealtimeHistoryAdded) + assert history_event.item == new_item + + @pytest.mark.asyncio + async def test_item_updated_event_updates_existing_item(self, mock_model, mock_agent): + """Test that item_updated events update existing items in history""" + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + + # Set up initial history + initial_item = AssistantMessageItem( + item_id="existing_item", role="assistant", content=[AssistantText(text="Initial")] + ) + session._history = [initial_item] + + # Create updated version + updated_item = AssistantMessageItem( + item_id="existing_item", role="assistant", content=[AssistantText(text="Updated")] + ) + + item_updated_event = RealtimeModelItemUpdatedEvent(item=updated_item) + + await session.on_event(item_updated_event) + + # Check that item was updated + assert len(session._history) == 1 + updated_item = cast(AssistantMessageItem, session._history[0]) + assert updated_item.content[0].text == "Updated" # type: ignore + + # Should have 2 events: raw + history updated (not added) + assert session._event_queue.qsize() == 2 + + await session._event_queue.get() # raw event + history_event = await session._event_queue.get() + assert isinstance(history_event, RealtimeHistoryUpdated) + + @pytest.mark.asyncio + async def test_item_updated_event_completes_tool_call(self, mock_model, mock_agent): + """The transport reuses one item for a tool call and its output, so the second + item_updated must land in history.""" + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + + in_progress = RealtimeToolCallItem( + item_id="fc_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="in_progress", + arguments='{"city": "Oakland"}', + name="get_weather", + output=None, + ) + await session.on_event(RealtimeModelItemUpdatedEvent(item=in_progress)) + + completed = in_progress.model_copy(update={"status": "completed", "output": "sunny"}) + await session.on_event(RealtimeModelItemUpdatedEvent(item=completed)) + + assert len(session._history) == 1 + stored = cast(RealtimeToolCallItem, session._history[0]) + assert stored.status == "completed" + assert stored.output == "sunny" + + # raw + history added, then raw + history updated. + assert session._event_queue.qsize() == 4 + await session._event_queue.get() # raw event + assert isinstance(await session._event_queue.get(), RealtimeHistoryAdded) + await session._event_queue.get() # raw event + history_event = await session._event_queue.get() + assert isinstance(history_event, RealtimeHistoryUpdated) + assert cast(RealtimeToolCallItem, history_event.history[0]).output == "sunny" + + @pytest.mark.asyncio + async def test_item_deleted_event_removes_item(self, mock_model, mock_agent): + """Test that item_deleted events remove items from history""" + session = RealtimeSession(mock_model, mock_agent, None) + + # Set up initial history with multiple items + item1 = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="First")] + ) + item2 = AssistantMessageItem( + item_id="item_2", role="assistant", content=[AssistantText(text="Second")] + ) + session._history = [item1, item2] + + # Delete first item + delete_event = RealtimeModelItemDeletedEvent(item_id="item_1") + + await session.on_event(delete_event) + + # Check that item was removed + assert len(session._history) == 1 + assert session._history[0].item_id == "item_2" + + # Should have 2 events: raw + history updated + assert session._event_queue.qsize() == 2 + + await session._event_queue.get() # raw event + history_event = await session._event_queue.get() + assert isinstance(history_event, RealtimeHistoryUpdated) + assert len(history_event.history) == 1 + + +class TestHistoryManagement: + """Test suite for history management and audio transcription in + RealtimeSession._get_new_history""" + + def test_merge_transcript_into_existing_audio_message(self): + """Test merging audio transcript into existing placeholder input_audio message""" + # Create initial history with audio message without transcript + initial_item = UserMessageItem( + item_id="item_1", + role="user", + content=[ + InputText(text="Before audio"), + InputAudio(transcript=None, audio="audio_data"), + InputText(text="After audio"), + ], + ) + old_history = [initial_item] + + # Create transcription completed event + transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( + item_id="item_1", transcript="Hello world" + ) + + # Apply the history update + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), transcription_event + ) + + # Verify the transcript was merged + assert len(new_history) == 1 + updated_item = cast(UserMessageItem, new_history[0]) + assert updated_item.item_id == "item_1" + assert hasattr(updated_item, "status") and updated_item.status == "completed" + assert len(updated_item.content) == 3 + + # Check that audio content got transcript but other content unchanged + assert cast(InputText, updated_item.content[0]).text == "Before audio" + assert cast(InputAudio, updated_item.content[1]).transcript == "Hello world" + # Should preserve audio data + assert cast(InputAudio, updated_item.content[1]).audio == "audio_data" + assert cast(InputText, updated_item.content[2]).text == "After audio" + + def test_merge_transcript_preserves_other_items(self): + """Test that merging transcript preserves other items in history""" + # Create history with multiple items + item1 = UserMessageItem( + item_id="item_1", role="user", content=[InputText(text="First message")] + ) + item2 = UserMessageItem( + item_id="item_2", role="user", content=[InputAudio(transcript=None)] + ) + item3 = AssistantMessageItem( + item_id="item_3", role="assistant", content=[AssistantText(text="Third message")] + ) + old_history = [item1, item2, item3] + + # Create transcription event for item_2 + transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( + item_id="item_2", transcript="Transcribed audio" + ) + + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), transcription_event + ) + + # Should have same number of items + assert len(new_history) == 3 + + # First and third items should be unchanged + assert new_history[0] == item1 + assert new_history[2] == item3 + + # Second item should have transcript + updated_item2 = cast(UserMessageItem, new_history[1]) + assert updated_item2.item_id == "item_2" + assert cast(InputAudio, updated_item2.content[0]).transcript == "Transcribed audio" + assert hasattr(updated_item2, "status") and updated_item2.status == "completed" + + def test_merge_transcript_only_affects_matching_audio_content(self): + """Test that transcript merge only affects audio content, not text content""" + # Create item with mixed content including multiple audio items + item = UserMessageItem( + item_id="item_1", + role="user", + content=[ + InputText(text="Text content"), + InputAudio(transcript=None, audio="audio1"), + InputAudio(transcript="existing", audio="audio2"), + InputText(text="More text"), + ], + ) + old_history = [item] + + transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( + item_id="item_1", transcript="New transcript" + ) + + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), transcription_event + ) + + updated_item = cast(UserMessageItem, new_history[0]) + + # Text content should be unchanged + assert cast(InputText, updated_item.content[0]).text == "Text content" + assert cast(InputText, updated_item.content[3]).text == "More text" + + # All audio content should have the new transcript (current implementation overwrites all) + assert cast(InputAudio, updated_item.content[1]).transcript == "New transcript" + assert ( + cast(InputAudio, updated_item.content[2]).transcript == "New transcript" + ) # Implementation overwrites existing + + def test_update_existing_item_by_id(self): + """Test updating an existing item by item_id""" + # Create initial history + original_item = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="Original")] + ) + old_history = [original_item] + + # Create updated version of same item + updated_item = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="Updated")] + ) + + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), updated_item + ) + + # Should have same number of items + assert len(new_history) == 1 + + # Item should be updated + result_item = cast(AssistantMessageItem, new_history[0]) + assert result_item.item_id == "item_1" + assert result_item.content[0].text == "Updated" # type: ignore + + def test_update_existing_item_preserves_order(self): + """Test that updating existing item preserves its position in history""" + # Create history with multiple items + item1 = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="First")] + ) + item2 = AssistantMessageItem( + item_id="item_2", role="assistant", content=[AssistantText(text="Second")] + ) + item3 = AssistantMessageItem( + item_id="item_3", role="assistant", content=[AssistantText(text="Third")] + ) + old_history = [item1, item2, item3] + + # Update middle item + updated_item2 = AssistantMessageItem( + item_id="item_2", role="assistant", content=[AssistantText(text="Updated Second")] + ) + + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), updated_item2 + ) + + # Should have same number of items in same order + assert len(new_history) == 3 + assert new_history[0].item_id == "item_1" + assert new_history[1].item_id == "item_2" + assert new_history[2].item_id == "item_3" + + # Middle item should be updated + updated_result = cast(AssistantMessageItem, new_history[1]) + assert updated_result.content[0].text == "Updated Second" # type: ignore + + # Other items should be unchanged + item1_result = cast(AssistantMessageItem, new_history[0]) + item3_result = cast(AssistantMessageItem, new_history[2]) + assert item1_result.content[0].text == "First" # type: ignore + assert item3_result.content[0].text == "Third" # type: ignore + + def test_insert_new_item_after_previous_item(self): + """Test inserting new item after specified previous_item_id""" + # Create initial history + item1 = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="First")] + ) + item3 = AssistantMessageItem( + item_id="item_3", role="assistant", content=[AssistantText(text="Third")] + ) + old_history = [item1, item3] + + # Create new item to insert between them + new_item = AssistantMessageItem( + item_id="item_2", + previous_item_id="item_1", + role="assistant", + content=[AssistantText(text="Second")], + ) + + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), new_item + ) + + # Should have one more item + assert len(new_history) == 3 + + # Items should be in correct order + assert new_history[0].item_id == "item_1" + assert new_history[1].item_id == "item_2" + assert new_history[2].item_id == "item_3" + + # Content should be correct + item2_result = cast(AssistantMessageItem, new_history[1]) + assert item2_result.content[0].text == "Second" # type: ignore + + def test_insert_new_item_after_nonexistent_previous_item(self): + """Test that item with nonexistent previous_item_id gets added to end""" + # Create initial history + item1 = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="First")] + ) + old_history = [item1] + + # Create new item with nonexistent previous_item_id + new_item = AssistantMessageItem( + item_id="item_2", + previous_item_id="nonexistent", + role="assistant", + content=[AssistantText(text="Second")], + ) + + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), new_item + ) + + # Should add to end when previous_item_id not found + assert len(new_history) == 2 + assert new_history[0].item_id == "item_1" + assert new_history[1].item_id == "item_2" + + def test_add_new_item_to_end_when_no_previous_item_id(self): + """Test adding new item to end when no previous_item_id is specified""" + # Create initial history + item1 = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="First")] + ) + old_history = [item1] + + # Create new item without previous_item_id + new_item = AssistantMessageItem( + item_id="item_2", role="assistant", content=[AssistantText(text="Second")] + ) + + new_history = RealtimeSession._get_new_history( + cast(list[RealtimeItem], old_history), new_item + ) + + # Should add to end + assert len(new_history) == 2 + assert new_history[0].item_id == "item_1" + assert new_history[1].item_id == "item_2" + + def test_tool_call_item_update_replaces_existing_entry(self): + """A completed tool call replaces the in-progress entry it shares an item_id with.""" + in_progress = RealtimeToolCallItem( + item_id="item_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="in_progress", + arguments='{"city": "Oakland"}', + name="get_weather", + output=None, + ) + completed = RealtimeToolCallItem( + item_id="item_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="completed", + arguments='{"city": "Oakland"}', + name="get_weather", + output="sunny", + ) + + history = RealtimeSession._get_new_history([], in_progress) + history = RealtimeSession._get_new_history(history, completed) + + assert len(history) == 1 + updated = cast(RealtimeToolCallItem, history[0]) + assert updated.status == "completed" + assert updated.output == "sunny" + + def test_tool_call_item_update_preserves_other_items(self): + """Replacing a tool call entry leaves the surrounding history untouched.""" + before = UserMessageItem( + item_id="item_0", role="user", content=[InputText(text="what's the weather?")] + ) + after = AssistantMessageItem( + item_id="item_2", role="assistant", content=[AssistantText(text="It is sunny.")] + ) + in_progress = RealtimeToolCallItem( + item_id="item_1", + previous_item_id=None, + call_id="call_1", + type="function_call", + status="in_progress", + arguments="{}", + name="get_weather", + output=None, + ) + old_history = cast(list[RealtimeItem], [before, in_progress, after]) + + completed = in_progress.model_copy(update={"status": "completed", "output": "sunny"}) + new_history = RealtimeSession._get_new_history(old_history, completed) + + assert [item.item_id for item in new_history] == ["item_0", "item_1", "item_2"] + assert new_history[0] == before + assert new_history[2] == after + assert cast(RealtimeToolCallItem, new_history[1]).output == "sunny" + + def test_add_first_item_to_empty_history(self): + """Test adding first item to empty history""" + old_history: list[RealtimeItem] = [] + + new_item = AssistantMessageItem( + item_id="item_1", role="assistant", content=[AssistantText(text="First")] + ) + + new_history = RealtimeSession._get_new_history(old_history, new_item) + + assert len(new_history) == 1 + assert new_history[0].item_id == "item_1" + + def test_complex_insertion_scenario(self): + """Test complex scenario with multiple insertions and updates""" + # Start with items A and C + itemA = AssistantMessageItem( + item_id="A", role="assistant", content=[AssistantText(text="A")] + ) + itemC = AssistantMessageItem( + item_id="C", role="assistant", content=[AssistantText(text="C")] + ) + history: list[RealtimeItem] = [itemA, itemC] + + # Insert B after A + itemB = AssistantMessageItem( + item_id="B", previous_item_id="A", role="assistant", content=[AssistantText(text="B")] + ) + history = RealtimeSession._get_new_history(history, itemB) + + # Should be A, B, C + assert len(history) == 3 + assert [item.item_id for item in history] == ["A", "B", "C"] + + # Insert D after B + itemD = AssistantMessageItem( + item_id="D", previous_item_id="B", role="assistant", content=[AssistantText(text="D")] + ) + history = RealtimeSession._get_new_history(history, itemD) + + # Should be A, B, D, C + assert len(history) == 4 + assert [item.item_id for item in history] == ["A", "B", "D", "C"] + + # Update B + updated_itemB = AssistantMessageItem( + item_id="B", role="assistant", content=[AssistantText(text="Updated B")] + ) + history = RealtimeSession._get_new_history(history, updated_itemB) + + # Should still be A, B, D, C but B is updated + assert len(history) == 4 + assert [item.item_id for item in history] == ["A", "B", "D", "C"] + itemB_result = cast(AssistantMessageItem, history[1]) + assert itemB_result.content[0].text == "Updated B" # type: ignore + + +class TestTranscriptPreservation: + """Tests ensuring assistant transcripts are preserved across updates.""" + + @pytest.mark.asyncio + async def test_assistant_transcript_preserved_on_item_update(self, mock_model, mock_agent): + session = RealtimeSession(mock_model, mock_agent, None) + + # Initial assistant message with audio transcript present (e.g., from first turn) + initial_item = AssistantMessageItem( + item_id="assist_1", + role="assistant", + content=[AssistantAudio(audio=None, transcript="Hello there")], + ) + session._history = [initial_item] + + # Later, the platform retrieves/updates the same item but without transcript populated + updated_without_transcript = AssistantMessageItem( + item_id="assist_1", + role="assistant", + content=[AssistantAudio(audio=None, transcript=None)], + ) + + await session.on_event(RealtimeModelItemUpdatedEvent(item=updated_without_transcript)) + + # Transcript should be preserved from existing history + assert len(session._history) == 1 + preserved_item = cast(AssistantMessageItem, session._history[0]) + assert isinstance(preserved_item.content[0], AssistantAudio) + assert preserved_item.content[0].transcript == "Hello there" + + @pytest.mark.asyncio + async def test_assistant_transcript_can_fallback_to_deltas(self, mock_model, mock_agent): + session = RealtimeSession(mock_model, mock_agent, None) + + # Simulate transcript deltas accumulated for an assistant item during generation + await session.on_event( + RealtimeModelTranscriptDeltaEvent( + item_id="assist_2", delta="partial transcript", response_id="resp_2" + ) + ) + + # Add initial assistant message without transcript + initial_item = AssistantMessageItem( + item_id="assist_2", + role="assistant", + content=[AssistantAudio(audio=None, transcript=None)], + ) + await session.on_event(RealtimeModelItemUpdatedEvent(item=initial_item)) + + # Later update still lacks transcript; merge should fallback to accumulated deltas + update_again = AssistantMessageItem( + item_id="assist_2", + role="assistant", + content=[AssistantAudio(audio=None, transcript=None)], + ) + await session.on_event(RealtimeModelItemUpdatedEvent(item=update_again)) + + preserved_item = cast(AssistantMessageItem, session._history[0]) + assert isinstance(preserved_item.content[0], AssistantAudio) + assert preserved_item.content[0].transcript == "partial transcript" + + @pytest.mark.asyncio + async def test_existing_transcript_not_overwritten_by_stale_deltas( + self, mock_model, mock_agent + ): + """Existing transcripts must take precedence over leftover delta accumulators. + + ``_item_transcripts`` is keyed by item_id and persists across updates within a + turn. When the model retrieves an item without a transcript, the merge should + fall back to deltas only when no existing transcript is present – otherwise + the complete transcript already in history would be clobbered by partial + (or stale) delta state. + """ + session = RealtimeSession(mock_model, mock_agent, None) + + # History already has the completed transcript for the item. + initial_item = AssistantMessageItem( + item_id="assist_3", + role="assistant", + content=[AssistantAudio(audio=None, transcript="Final complete transcript")], + ) + session._history = [initial_item] + + # Simulate stale/leftover delta state for the same item id. + session._item_transcripts["assist_3"] = "stale partial" + + # Update arrives without transcript populated; merge must keep the existing + # complete transcript rather than reverting to the stale delta accumulator. + update_without_transcript = AssistantMessageItem( + item_id="assist_3", + role="assistant", + content=[AssistantAudio(audio=None, transcript=None)], + ) + await session.on_event(RealtimeModelItemUpdatedEvent(item=update_without_transcript)) + + preserved_item = cast(AssistantMessageItem, session._history[0]) + assert isinstance(preserved_item.content[0], AssistantAudio) + assert preserved_item.content[0].transcript == "Final complete transcript" diff --git a/tests/realtime/test_session_tool_outputs.py b/tests/realtime/test_session_tool_outputs.py new file mode 100644 index 0000000000..435333840f --- /dev/null +++ b/tests/realtime/test_session_tool_outputs.py @@ -0,0 +1,430 @@ +"""Function-tool output serialization, delivery, and retry ownership.""" + +import asyncio +import dataclasses +import json +import threading +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pydantic import BaseModel, ConfigDict + +from agents.exceptions import ModelBehaviorError +from agents.handoffs import Handoff +from agents.realtime.agent import RealtimeAgent +from agents.realtime.events import ( + RealtimeToolEnd, +) +from agents.realtime.model_events import ( + RealtimeModelToolCallEvent, +) +from agents.realtime.model_inputs import ( + RealtimeModelSendToolOutput, +) +from agents.realtime.session import ( + RealtimeSession, + _serialize_tool_output, +) +from agents.tool import FunctionTool + +from . import session_test_support +from .session_test_support import RecordingRealtimeModel, _set_default_timeout_fields + +# Bind shared fixtures explicitly so unrelated Realtime modules do not inherit them. +mock_agent = session_test_support.mock_agent +mock_function_tool = session_test_support.mock_function_tool +mock_model = session_test_support.mock_model + + +class TestToolCallExecution: + """Test suite for tool call execution flow in RealtimeSession._handle_tool_call""" + + @pytest.mark.asyncio + async def test_approved_function_tool_failure_replay_does_not_rerun( + self, mock_model, mock_agent, mock_function_tool + ): + mock_function_tool.needs_approval = True + mock_function_tool.on_invoke_tool.side_effect = RuntimeError("failed after side effect") + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_failed", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + with pytest.raises(RuntimeError, match="failed after side effect"): + await session.approve_tool_call(tool_call_event.call_id) + + with pytest.raises(ModelBehaviorError, match="already executed"): + await session._handle_tool_call(tool_call_event) + + mock_function_tool.on_invoke_tool.assert_awaited_once() + assert len(mock_model.sent_tool_outputs) == 0 + + @pytest.mark.parametrize("always", [False, True], ids=["per-call", "sticky"]) + @pytest.mark.parametrize("changed_field", ["arguments", "tool_name"]) + @pytest.mark.asyncio + async def test_function_tool_send_failure_retries_cached_output_without_rerun( + self, + mock_agent, + mock_function_tool, + always: bool, + changed_field: str, + ): + """An approved call should retry cached output only for the same invocation.""" + + class FailingToolOutputModel(RecordingRealtimeModel): + def __init__(self): + super().__init__() + self.fail_next_tool_output = True + + async def send_event(self, event): + if isinstance(event, RealtimeModelSendToolOutput) and self.fail_next_tool_output: + self.fail_next_tool_output = False + raise RuntimeError("send failed") + await super().send_event(event) + + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + mock_model = FailingToolOutputModel() + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_retry_output", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + with pytest.raises(RuntimeError, match="send failed"): + await session.approve_tool_call(tool_call_event.call_id, always=always) + + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 0 + + changed_event = RealtimeModelToolCallEvent( + name="other_function" if changed_field == "tool_name" else tool_call_event.name, + call_id=tool_call_event.call_id, + arguments=( + tool_call_event.arguments if changed_field == "tool_name" else '{"changed":true}' + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) + await session._handle_tool_call(tool_call_event) + + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.asyncio + async def test_tool_end_cancellation_after_output_send_does_not_resend( + self, mock_model, mock_agent, mock_function_tool + ) -> None: + """Provider delivery commits the output before local end-event publication.""" + mock_agent.get_all_tools.return_value = [mock_function_tool] + session = RealtimeSession( + mock_model, + mock_agent, + None, + run_config={"async_tool_calls": False}, + ) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", + call_id="call_tool_end_cancelled", + arguments="{}", + ) + original_put_event_nowait = session._put_event_nowait + + def cancel_tool_end(event: Any) -> bool: + if isinstance(event, RealtimeToolEnd): + raise asyncio.CancelledError + return original_put_event_nowait(event) + + session._put_event_nowait = cancel_tool_end # type: ignore[method-assign] + with pytest.raises(asyncio.CancelledError): + await session._handle_tool_call(tool_call_event) + + invocation = session._context_wrapper._tool_invocations[tool_call_event.call_id] + assert invocation.executed is True + assert invocation.completed is True + assert tool_call_event.call_id not in session._pending_tool_outputs + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + session._put_event_nowait = original_put_event_nowait # type: ignore[method-assign] + await session._handle_tool_call(tool_call_event) + + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.parametrize("always", [False, True], ids=["per-call", "sticky"]) + @pytest.mark.parametrize("changed_field", ["arguments", "tool_name"]) + @pytest.mark.asyncio + async def test_async_function_tool_send_failure_retries_cached_output_without_rerun( + self, + mock_agent, + mock_function_tool, + always: bool, + changed_field: str, + ): + """The async approval path should bind retries to the original invocation.""" + + class FailingToolOutputModel(RecordingRealtimeModel): + def __init__(self): + super().__init__() + self.fail_next_tool_output = True + + async def send_event(self, event): + if isinstance(event, RealtimeModelSendToolOutput) and self.fail_next_tool_output: + self.fail_next_tool_output = False + raise RuntimeError("send failed") + await super().send_event(event) + + mock_function_tool.needs_approval = True + mock_agent.get_all_tools.return_value = [mock_function_tool] + mock_model = FailingToolOutputModel() + session = RealtimeSession(mock_model, mock_agent, None) + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_async_retry_output", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + await session.approve_tool_call(tool_call_event.call_id, always=always) + tool_call_tasks = list(session._tool_call_tasks) + assert len(tool_call_tasks) == 1 + task_results = await asyncio.gather(*tool_call_tasks, return_exceptions=True) + await asyncio.sleep(0) + + assert len(task_results) == 1 + assert isinstance(task_results[0], RuntimeError) + assert session._stored_exception is None + assert tool_call_event.call_id in session._pending_tool_outputs + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 0 + + changed_event = RealtimeModelToolCallEvent( + name="other_function" if changed_field == "tool_name" else tool_call_event.name, + call_id=tool_call_event.call_id, + arguments=( + tool_call_event.arguments if changed_field == "tool_name" else '{"changed":true}' + ), + ) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_event) + await session.on_event(tool_call_event) + tool_call_tasks = list(session._tool_call_tasks) + assert len(tool_call_tasks) == 1 + await asyncio.gather(*tool_call_tasks) + + assert session._stored_exception is None + assert tool_call_event.call_id not in session._pending_tool_outputs + mock_function_tool.on_invoke_tool.assert_called_once() + assert len(mock_model.sent_tool_outputs) == 1 + + @pytest.mark.asyncio + async def test_pending_function_output_rejects_handoff_role_reuse(self): + class FailingToolOutputModel(RecordingRealtimeModel): + async def send_event(self, event): + if isinstance(event, RealtimeModelSendToolOutput): + raise RuntimeError("send failed") + await super().send_event(event) + + function_callback = AsyncMock(return_value="function result") + function_tool = FunctionTool( + name="route", + description="Run a function.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=function_callback, + ) + function_agent = RealtimeAgent(name="function", tools=[function_tool]) + target = RealtimeAgent(name="target") + route_name = Handoff.default_tool_name(target) + function_tool.name = route_name + handoff_agent = RealtimeAgent(name="handoff", handoffs=[target]) + session = RealtimeSession( + FailingToolOutputModel(), + function_agent, + None, + run_config={"async_tool_calls": False}, + ) + event = RealtimeModelToolCallEvent(name=route_name, call_id="shared", arguments="{}") + + with pytest.raises(RuntimeError, match="send failed"): + await session._handle_tool_call(event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(event, agent_snapshot=handoff_agent) + + function_callback.assert_awaited_once() + + @pytest.mark.asyncio + async def test_async_exact_function_retry_after_serialization_failure_does_not_repeat_callback( + self, + mock_model, + ): + callback = AsyncMock(return_value={"result": "ok"}) + tool = FunctionTool( + name="run_function", + description="Run a function.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=callback, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None) + event = RealtimeModelToolCallEvent( + name=tool.name, + call_id="shared", + arguments="{}", + ) + + with patch( + "agents.realtime.session._serialize_tool_output", + side_effect=RuntimeError("serialization failed"), + ): + await session.on_event(event) + first_results = await asyncio.gather( + *list(session._tool_call_tasks), + return_exceptions=True, + ) + + await session.on_event(event) + retry_results = await asyncio.gather( + *list(session._tool_call_tasks), + return_exceptions=True, + ) + + assert any( + isinstance(result, RuntimeError) and str(result) == "serialization failed" + for result in first_results + ) + assert any(isinstance(result, ModelBehaviorError) for result in retry_results) + callback.assert_awaited_once() + + @pytest.mark.asyncio + async def test_tool_result_conversion_to_string(self, mock_model, mock_agent): + """Test that structured tool results are serialized to JSON for model output.""" + # Create tool that returns non-string result + tool = _set_default_timeout_fields(Mock(spec=FunctionTool)) + tool.name = "test_function" + tool.on_invoke_tool = AsyncMock(return_value={"result": "data", "count": 42}) + tool.needs_approval = False + + mock_agent.get_all_tools.return_value = [tool] + + session = RealtimeSession(mock_model, mock_agent, None) + + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_conversion", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + + # Verify result was serialized to JSON + sent_call, sent_output, _ = mock_model.sent_tool_outputs[0] + assert isinstance(sent_output, str) + assert sent_output == json.dumps({"result": "data", "count": 42}) + + @pytest.mark.asyncio + async def test_tool_result_conversion_serializes_pydantic_models(self, mock_model, mock_agent): + """Test that pydantic tool results are serialized to JSON for model output.""" + + class ToolResult(BaseModel): + name: str + score: int + + tool = _set_default_timeout_fields(Mock(spec=FunctionTool)) + tool.name = "test_function" + tool.on_invoke_tool = AsyncMock(return_value=ToolResult(name="demo", score=7)) + tool.needs_approval = False + + mock_agent.get_all_tools.return_value = [tool] + + session = RealtimeSession(mock_model, mock_agent, None) + + tool_call_event = RealtimeModelToolCallEvent( + name="test_function", call_id="call_pydantic_conversion", arguments="{}" + ) + + await session._handle_tool_call(tool_call_event) + + _sent_call, sent_output, _ = mock_model.sent_tool_outputs[0] + assert sent_output == json.dumps({"name": "demo", "score": 7}) + + def test_serialize_tool_output_ignores_non_pydantic_model_dump_objects(self) -> None: + class ModelDumpObject: + def model_dump(self, *_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise AssertionError("non-pydantic objects should not use model_dump") + + def __str__(self) -> str: + return "fake-model-dump-object" + + assert _serialize_tool_output(ModelDumpObject()) == "fake-model-dump-object" + + def test_serialize_tool_output_falls_back_when_pydantic_json_dump_fails(self) -> None: + class FallbackModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + payload: object + + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + if kwargs.get("mode") == "json": + raise ValueError("json mode failed") + return {"payload": "ok"} + + assert _serialize_tool_output(FallbackModel(payload=object())) == json.dumps( + {"payload": "ok"} + ) + + def test_serialize_tool_output_returns_string_when_pydantic_dump_fails(self) -> None: + class BrokenModel(BaseModel): + value: int + + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + raise ValueError("dump failed") + + def __str__(self) -> str: + return "broken-model" + + assert _serialize_tool_output(BrokenModel(value=1)) == "broken-model" + + def test_serialize_tool_output_returns_string_when_dataclass_asdict_fails(self) -> None: + @dataclasses.dataclass + class BrokenDataclass: + lock: Any + + def __str__(self) -> str: + return "broken-dataclass" + + assert _serialize_tool_output(BrokenDataclass(lock=threading.Lock())) == "broken-dataclass" + + @dataclasses.dataclass + class ToolResult: + label: str + values: list[int] + + @pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(None, "null", id="none"), + pytest.param( + ["hello", 1, True, None], + json.dumps(["hello", 1, True, None]), + id="list", + ), + pytest.param( + ToolResult(label="demo", values=[1, 2]), + json.dumps({"label": "demo", "values": [1, 2]}), + id="dataclass", + ), + pytest.param(b"abc", "b'abc'", id="bytes"), + ], + ) + def test_serialize_tool_output_edge_cases(self, value: Any, expected: str) -> None: + assert _serialize_tool_output(value) == expected From 36b38b8aec941aae4d4508d7b47a90b1babaac40 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 18:46:31 +0900 Subject: [PATCH 450/473] chore: run repository skill tests in dedicated CI (#4881) --- .../scripts/test_review_state.py | 3 +- .github/scripts/repo-skill-tests.md | 19 +++ .github/scripts/run_repo_skill_tests.py | 69 +++++++++ .github/scripts/test_run_repo_skill_tests.py | 142 ++++++++++++++++++ .github/workflows/repo-skills.yml | 41 +++++ Makefile | 4 + 6 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/repo-skill-tests.md create mode 100644 .github/scripts/run_repo_skill_tests.py create mode 100644 .github/scripts/test_run_repo_skill_tests.py create mode 100644 .github/workflows/repo-skills.yml diff --git a/.agents/skills/implementation-final-review/scripts/test_review_state.py b/.agents/skills/implementation-final-review/scripts/test_review_state.py index 9a469d0cb4..bb7aed62f3 100644 --- a/.agents/skills/implementation-final-review/scripts/test_review_state.py +++ b/.agents/skills/implementation-final-review/scripts/test_review_state.py @@ -332,7 +332,8 @@ def test_submodule_changes_require_reviewable_gitlinks(self) -> None: with self.assertRaisesRegex(ValueError, "HEAD does not match.*vendor/dependency"): review_state(self.repo, self.base, ("vendor/dependency",)) - self._git("add", "vendor/dependency") + # Stage the new gitlink even though the fixture intentionally ignores this submodule. + self._git("add", "--force", "vendor/dependency") clean_state = review_state(self.repo, self.base, ("vendor/dependency",)) self.assertEqual( diff --git a/.github/scripts/repo-skill-tests.md b/.github/scripts/repo-skill-tests.md new file mode 100644 index 0000000000..c03e2ba3dc --- /dev/null +++ b/.github/scripts/repo-skill-tests.md @@ -0,0 +1,19 @@ +# Repository skill tests + +Run `make tests-repo-skills` from the repository root. This command uses `uv run --no-project --python 3.11` and needs Git and make for disposable repository fixtures. It installs no SDK dependencies and does not collect `tests/` or pytest configuration. With an existing Python 3.11+ interpreter, use `python .github/scripts/run_repo_skill_tests.py` directly. + +Use `uv run --no-project --python 3.11 python .github/scripts/run_repo_skill_tests.py --list` to print the discovered inventory without executing tests. The runner discovers `.agents/skills/*/scripts/test_*.py` and its own `.github/scripts/test_run_repo_skill_tests.py` regression suite. Add skill helper tests under that pattern using standard-library `unittest` and sibling imports. + +The current skill inventory contains 159 tests across five modules: + +| Skill | Module | Tests | +| --- | --- | --- | +| implementation-final-review | test_review_protocol.py | 77 | +| implementation-final-review | test_review_state.py | 43 | +| implementation-kickoff | test_validate_handoff.py | 12 | +| release-candidate-prep | test_prepare.py | 17 | +| sensitive-logging-audit | test_inventory.py | 10 | + +Each module runs in a separate interpreter from its own scripts directory so sibling imports cannot collide across suites. Child processes receive only process essentials and fixed test settings; inherited API keys, tokens, Python import overrides, and user Git configuration are excluded. Git transport is limited to local files. Tests must use disposable local Git repositories and local bare origins, with no live service calls. + +The runner executes every discovered module and reports all failed modules before returning a nonzero exit code. An empty skill inventory also fails. The dedicated `repo-skills.yml` workflow runs the same Make target when skills or the command's owning files change. Existing review tiers, budgets, and validator behavior are unchanged. diff --git a/.github/scripts/run_repo_skill_tests.py b/.github/scripts/run_repo_skill_tests.py new file mode 100644 index 0000000000..17fcc6fa34 --- /dev/null +++ b/.github/scripts/run_repo_skill_tests.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Run repository skill unittest modules without collecting the SDK suite.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def test_environment() -> dict[str, str]: + """Keep process essentials, excluding credentials and user Git configuration.""" + env = { + name: os.environ[name] + for name in ("PATH", "SYSTEMROOT", "WINDIR", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL") + if name in os.environ + } + # Release fixtures invoke python through make, so use this interpreter's directory first. + env["PATH"] = os.pathsep.join((str(Path(sys.executable).parent), env.get("PATH", os.defpath))) + env.update( + GIT_CONFIG_GLOBAL=os.devnull, + GIT_CONFIG_NOSYSTEM="1", + GIT_ALLOW_PROTOCOL="file", + GIT_TERMINAL_PROMPT="0", + UV_DEFAULT_INDEX="https://pypi.org/simple", + ) + return env + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--list", action="store_true", help="List discovered modules without running." + ) + args = parser.parse_args() + repo = Path(__file__).resolve().parents[2] + skill_tests = sorted(repo.glob(".agents/skills/*/scripts/test_*.py")) + if not skill_tests: + parser.error("No repository skill test modules found.") + tests = sorted([*skill_tests, *repo.glob(".github/scripts/test_run_repo_skill_tests.py")]) + print(f"Discovered {len(tests)} repository skill test modules:", flush=True) + for path in tests: + print(f" {path.relative_to(repo).as_posix()}", flush=True) + if args.list: + return 0 + + env = test_environment() + failed = [] + for path in tests: + print(f"\nRunning {path.relative_to(repo).as_posix()}", flush=True) + result = subprocess.run( + [sys.executable, "-m", "unittest", "discover", "-s", ".", "-p", path.name, "-v"], + cwd=path.parent, + env=env, + check=False, + ) + if result.returncode: + failed.append(path.relative_to(repo).as_posix()) + + print(f"\nCompleted {len(tests)} modules; {len(failed)} failed.", flush=True) + for path in failed: + print(f" FAILED: {path}", flush=True) + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_run_repo_skill_tests.py b/.github/scripts/test_run_repo_skill_tests.py new file mode 100644 index 0000000000..aa545dad68 --- /dev/null +++ b/.github/scripts/test_run_repo_skill_tests.py @@ -0,0 +1,142 @@ +"""Exercise the runner CLI in disposable repositories without loading the SDK.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from run_repo_skill_tests import test_environment + + +class RepoSkillRunnerTests(unittest.TestCase): + def setUp(self) -> None: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.repo = Path(directory.name) + self.runner = self.repo / ".github/scripts/run_repo_skill_tests.py" + self.runner.parent.mkdir(parents=True) + shutil.copyfile(Path(__file__).with_name(self.runner.name), self.runner) + + def write_suite(self, skill: str, source: str) -> Path: + path = self.repo / ".agents/skills" / skill / "scripts/test_fixture.py" + path.parent.mkdir(parents=True) + path.write_text(source, encoding="utf-8") + return path + + def run_cli( + self, *args: str, env: dict[str, str] | None = None + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(self.runner), *args], + cwd=self.repo.parent, + env=test_environment() if env is None else env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + def test_inventory_is_sorted_and_listing_does_not_execute_tests(self) -> None: + for name in ("z-last", "a-first"): + self.write_suite(name, "raise RuntimeError('must not execute during listing')\n") + result = self.run_cli("--list") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.splitlines(), + [ + "Discovered 2 repository skill test modules:", + " .agents/skills/a-first/scripts/test_fixture.py", + " .agents/skills/z-last/scripts/test_fixture.py", + ], + ) + + def test_isolated_modules_keep_sibling_imports_and_exclude_sdk_tests(self) -> None: + for name in ("first", "second"): + suite = self.write_suite( + name, + "import unittest\nfrom helper import VALUE\n" + "class Fixture(unittest.TestCase):\n" + f" def test_value(self): self.assertEqual(VALUE, {name!r})\n", + ) + suite.with_name("helper.py").write_text(f"VALUE = {name!r}\n", encoding="utf-8") + sdk_suite = self.repo / "tests/test_sdk.py" + sdk_suite.parent.mkdir() + sdk_suite.write_text("raise RuntimeError('SDK suite must not be collected')\n") + result = self.run_cli() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Completed 2 modules; 0 failed.", result.stdout) + self.assertEqual(result.stderr.count("Ran 1 test"), 2) + + def test_failure_propagates_and_later_modules_still_execute(self) -> None: + self.write_suite( + "a-failing", + "import unittest\nclass Fixture(unittest.TestCase):\n" + " def test_failure(self): self.fail('deliberate fixture failure')\n", + ) + self.write_suite( + "z-passing", + "import unittest\nfrom pathlib import Path\nclass Fixture(unittest.TestCase):\n" + " def test_later(self): Path('executed.txt').write_text('ran')\n", + ) + result = self.run_cli() + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("deliberate fixture failure", result.stderr) + self.assertIn("Completed 2 modules; 1 failed.", result.stdout) + self.assertTrue((self.repo / ".agents/skills/z-passing/scripts/executed.txt").is_file()) + + def test_import_failure_propagates(self) -> None: + self.write_suite("broken", "raise ImportError('broken helper import')\n") + result = self.run_cli() + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("broken helper import", result.stderr) + + def test_empty_skill_inventory_fails(self) -> None: + result = self.run_cli() + self.assertNotEqual(result.returncode, 0) + self.assertIn("No repository skill test modules found", result.stderr) + + def test_children_drop_credentials_and_git_only_allows_local_remotes(self) -> None: + self.write_suite( + "environment", + """import os +import subprocess +import sys +import unittest + +class Fixture(unittest.TestCase): + def test_environment(self): + for name in ('OPENAI_API_KEY', 'OPENAI_API_KEY_SOURCE', 'GH_TOKEN', 'GITHUB_TOKEN', + 'AZURE_OPENAI_API_KEY', 'AWS_SECRET_ACCESS_KEY', 'PYTHONPATH'): + self.assertNotIn(name, os.environ) + subprocess.run([sys.executable, '-c', + "import os; assert 'OPENAI_API_KEY' not in os.environ"], check=True) + subprocess.run(['git', 'init', '--bare', 'origin.git'], check=True, capture_output=True) + local = subprocess.run(['git', 'ls-remote', 'origin.git'], capture_output=True) + self.assertEqual(local.returncode, 0, local.stderr) + remote = subprocess.run(['git', 'ls-remote', 'https://example.invalid/repo.git'], + capture_output=True, text=True) + self.assertNotEqual(remote.returncode, 0) + self.assertIn("transport 'https' not allowed", remote.stderr) +""", + ) + env = test_environment() + # Only synthetic values enter this fixture; inherited credentials are never forwarded. + env.update( + OPENAI_API_KEY="fixture-only", + OPENAI_API_KEY_SOURCE="fixture-only", + GH_TOKEN="fixture-only", + GITHUB_TOKEN="fixture-only", + AZURE_OPENAI_API_KEY="fixture-only", + AWS_SECRET_ACCESS_KEY="fixture-only", + PYTHONPATH=str(self.repo / "unused"), + ) + result = self.run_cli(env=env) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/repo-skills.yml b/.github/workflows/repo-skills.yml new file mode 100644 index 0000000000..2716142095 --- /dev/null +++ b/.github/workflows/repo-skills.yml @@ -0,0 +1,41 @@ +name: Repository skills + +on: + push: + branches: + - main + paths: + - .agents/** + - .github/scripts/*repo_skill_tests.py + - .github/scripts/repo-skill-tests.md + - .github/workflows/repo-skills.yml + - Makefile + pull_request: + paths: + - .agents/** + - .github/scripts/*repo_skill_tests.py + - .github/scripts/repo-skill-tests.md + - .github/workflows/repo-skills.yml + - Makefile + workflow_dispatch: + +permissions: + contents: read + +jobs: + repo-skills: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Setup uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + python-version: "3.11" + enable-cache: false + - name: Run repository skill tests + run: make tests-repo-skills diff --git a/Makefile b/Makefile index 04b0689aa3..957bf039e8 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,10 @@ tests-review: tests-parallel-review tests-asyncio-stability: bash .github/scripts/run-asyncio-teardown-stability.sh +.PHONY: tests-repo-skills +tests-repo-skills: + env -u OPENAI_API_KEY uv run --no-project --python 3.11 python .github/scripts/run_repo_skill_tests.py + .PHONY: tests-parallel tests-parallel: uv run pytest -n "$${PYTEST_XDIST_AUTO_NUM_WORKERS:-auto}" $(if $(PYTEST_XDIST_AUTO_NUM_WORKERS),,--maxprocesses=9) --dist worksteal -m "not serial" From 1d471a4775bf2f40179f411824da383deb4c3fca Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2026 20:16:52 +0900 Subject: [PATCH 451/473] fix: route verification changes and require confirmed docs-only pushes (#4880) --- .github/scripts/detect-changes.sh | 64 ++++-- .github/workflows/docs.yml | 25 +-- tests/test_change_detection.py | 316 ++++++++++++++++++++++++++++++ tests/test_integration_runner.py | 13 -- 4 files changed, 367 insertions(+), 51 deletions(-) create mode 100644 tests/test_change_detection.py diff --git a/.github/scripts/detect-changes.sh b/.github/scripts/detect-changes.sh index 79275fcca1..bd025e0792 100755 --- a/.github/scripts/detect-changes.sh +++ b/.github/scripts/detect-changes.sh @@ -10,6 +10,21 @@ if [ -z "${GITHUB_OUTPUT:-}" ]; then exit 1 fi +# Unknown changes require checks, but cannot authorize deployment. +unknown_changes() { + echo "Unable to determine changed files." >&2 + if [ "$mode" = "docs-only" ]; then + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + exit 0 +} + +if [ "$mode" = "docs-only" ] && { [ -z "$base_sha" ] || [ -z "$head_sha" ]; }; then + unknown_changes +fi + if [ -z "$head_sha" ]; then head_sha="$(git rev-parse HEAD 2>/dev/null || true)" fi @@ -24,40 +39,49 @@ if [ -z "$base_sha" ]; then fi if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - exit 0 + unknown_changes fi if [ "$base_sha" = "0000000000000000000000000000000000000000" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - exit 0 + unknown_changes fi -if ! git cat-file -e "$base_sha" 2>/dev/null; then - git fetch --no-tags --depth=1 origin "$base_sha" || true -fi +for sha in "$base_sha" "$head_sha"; do + if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "$sha" || true + fi + if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then + unknown_changes + fi +done -if ! git cat-file -e "$base_sha" 2>/dev/null; then - echo "run=true" >> "$GITHUB_OUTPUT" - exit 0 +changed_files=$(mktemp) +trap 'rm -f "$changed_files"' EXIT +# Include both sides of renames and preserve complete Git path names. +if ! git diff --name-only --no-renames -z "$base_sha" "$head_sha" -- > "$changed_files"; then + unknown_changes fi -changed_files=$(git diff --name-only "$base_sha" "$head_sha" || true) - +docs_pattern='^(docs/|mkdocs\.yml$)' case "$mode" in code) - pattern='^(src/|tests/|integration_tests/|examples/|\.agents/skills/(code-change-verification|examples-auto-run|examples-run-analysis|integration-tests)/|\.github/scripts/(detect-changes\.sh|run_examples\.sh|run_integration_tests\.py|update_released_api_contract\.py)$|\.github/workflows/tests\.yml$|pyproject.toml$|uv.lock$|Makefile$)' + pattern='^(src/|tests/|integration_tests/|examples/|docs/scripts/|\.agents/skills/(code-change-verification|examples-auto-run|examples-run-analysis|integration-tests)/|\.github/scripts/|\.github/workflows/(tests|docs|publish|repo-skills)\.yml$|pyproject\.toml$|uv\.lock$|Makefile$|pyrightconfig\.json$)' ;; - docs) - pattern='^(docs/|mkdocs.yml$)' + docs|docs-only) + pattern="$docs_pattern" ;; *) pattern="$mode" ;; esac -if echo "$changed_files" | grep -Eq "$pattern"; then - echo "run=true" >> "$GITHUB_OUTPUT" -else - echo "run=false" >> "$GITHUB_OUTPUT" -fi +run=false +while IFS= read -r -d '' path; do + if [[ "$path" =~ $pattern ]]; then + run=true + elif [ "$mode" = "docs-only" ]; then + run=false + break + fi +done < "$changed_files" +echo "run=$run" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f6d73d651e..cbedd2241a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,23 +19,12 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Determine docs-only push id: docs-only - run: | - if [ "${{ github.event_name }}" != "push" ]; then - echo "skip=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - set -euo pipefail - before="${{ github.event.before }}" - sha="${{ github.sha }}" - changed_files=$(git diff --name-only "$before" "$sha" || true) - non_docs=$(echo "$changed_files" | grep -vE '^(docs/|mkdocs.yml$)' || true) - if [ -n "$non_docs" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi + env: + BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: ./.github/scripts/detect-changes.sh docs-only - name: Setup uv - if: steps.docs-only.outputs.skip != 'true' + if: steps.docs-only.outputs.run == 'true' uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 with: version: "0.11.14" @@ -43,8 +32,8 @@ jobs: prune-cache: true python-version: "3.14" - name: Install dependencies - if: steps.docs-only.outputs.skip != 'true' + if: steps.docs-only.outputs.run == 'true' run: make sync - name: Deploy docs - if: steps.docs-only.outputs.skip != 'true' + if: steps.docs-only.outputs.run == 'true' run: make deploy-docs diff --git a/tests/test_change_detection.py b/tests/test_change_detection.py new file mode 100644 index 0000000000..28bda88f5c --- /dev/null +++ b/tests/test_change_detection.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +DETECTOR = ROOT / ".github/scripts/detect-changes.sh" +ZERO_SHA = "0" * 40 +MISSING_SHA = "1" * 40 + + +def _environment() -> dict[str, str]: + env = os.environ.copy() + for key in ("OPENAI_API_KEY", "BASE_SHA", "HEAD_SHA", "GITHUB_OUTPUT"): + env.pop(key, None) + env["GIT_CONFIG_NOSYSTEM"] = "1" + env["GIT_CONFIG_GLOBAL"] = os.devnull + env["GIT_TERMINAL_PROMPT"] = "0" + env["GIT_ALLOW_PROTOCOL"] = "file" + return env + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + env=_environment(), + timeout=10, + ).stdout.strip() + + +def _commit(repo: Path, *paths: str) -> str: + for name in paths: + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("Changed content.\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "--allow-empty", "-m", "Record test changes") + return _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture +def change_repo(tmp_path: Path) -> tuple[Path, str]: + repo = tmp_path / "source" + repo.mkdir() + _git(repo, "init", "--initial-branch=main") + _git(repo, "config", "user.name", "Change detection test") + _git(repo, "config", "user.email", "change-test@example.invalid") + return repo, _commit(repo) + + +def _detect( + repo: Path, + mode: str, + base: str, + head: str, + *, + extra_env: dict[str, str] | None = None, + bash_args: list[str] | None = None, +) -> bool: + if os.name == "nt": + # Resolve Git for Windows' Bash instead of the system WSL launcher. + git_exec_path = Path(_git(repo, "--exec-path")) + bash = str(git_exec_path.parents[2] / "bin/bash.exe") + else: + bash = shutil.which("bash") + assert bash is not None + output = repo.parent / "github-output" + output.write_text("", encoding="utf-8") + env = _environment() + env.update(extra_env or {}) + env["GITHUB_OUTPUT"] = output.as_posix() + result = subprocess.run( + [bash, *(bash_args or [DETECTOR.as_posix(), mode, base, head])], + cwd=repo, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, (result.stdout, result.stderr) + value = output.read_text(encoding="utf-8") + assert value in ("run=true\n", "run=false\n"), (value, result.stderr) + return value == "run=true\n" + + +@pytest.mark.parametrize( + ("path", "code", "docs", "docs_only"), + [ + ("src/agents/run.py", True, False, False), + ("tests/test_release_provenance.py", True, False, False), + ("integration_tests/test_contract.py", True, False, False), + ("examples/basic.py", True, False, False), + (".github/scripts/detect-changes.sh", True, False, False), + (".github/scripts/check_optional_truthiness.py", True, False, False), + (".github/scripts/run_serial_tests.py", True, False, False), + (".github/scripts/run-asyncio-teardown-stability.sh", True, False, False), + (".github/scripts/verify_release.py", True, False, False), + (".github/scripts/run_integration_tests.py", True, False, False), + (".github/scripts/run_examples.sh", True, False, False), + (".github/scripts/update_released_api_contract.py", True, False, False), + (".github/scripts/run_repo_skill_tests.py", True, False, False), + (".github/workflows/tests.yml", True, False, False), + (".github/workflows/docs.yml", True, False, False), + (".github/workflows/publish.yml", True, False, False), + (".github/workflows/repo-skills.yml", True, False, False), + ("pyproject.toml", True, False, False), + ("uv.lock", True, False, False), + ("Makefile", True, False, False), + ("pyrightconfig.json", True, False, False), + (".agents/skills/code-change-verification/SKILL.md", True, False, False), + (".agents/skills/examples-run-analysis/SKILL.md", True, False, False), + ("docs/scripts/generate_ref_files.py", True, True, True), + ("docs/index.md", False, True, True), + ("docs/日本語 guide.md", False, True, True), + pytest.param( + "docs/line\nbreak.md", + False, + True, + True, + marks=pytest.mark.skipif(os.name == "nt", reason="Windows forbids newlines in paths."), + ), + ("mkdocs.yml", False, True, True), + ("mkdocs-yml", False, False, False), + ("README.md", False, False, False), + ("AGENTS.md", False, False, False), + (".github/RELEASING.md", False, False, False), + ], +) +def test_changed_paths_select_owning_checks( + change_repo: tuple[Path, str], path: str, code: bool, docs: bool, docs_only: bool +) -> None: + repo, base = change_repo + head = _commit(repo, path) + + for mode, expected in (("code", code), ("docs", docs), ("docs-only", docs_only)): + assert _detect(repo, mode, base, head) is expected, mode + + +def test_mixed_push_builds_docs_and_checks_code_without_deploying( + change_repo: tuple[Path, str], +) -> None: + repo, base = change_repo + _commit(repo, "src/agents/run.py") + head = _commit(repo, "docs/index.md") + + assert _detect(repo, "code", base, head) + assert _detect(repo, "docs", base, head) + assert not _detect(repo, "docs-only", base, head) + + +@pytest.mark.parametrize( + ("missing", "docs_only"), [("base", False), ("base", True), ("head", True)] +) +def test_shallow_checkout_fetches_missing_event_commit( + change_repo: tuple[Path, str], tmp_path: Path, missing: str, docs_only: bool +) -> None: + repo, base = change_repo + if not docs_only: + _commit(repo, "src/agents/run.py") + head = _commit(repo, "docs/index.md") + clone = tmp_path / "checkout" + _git(tmp_path, "clone", "--depth=1", repo.as_uri(), str(clone)) + if missing == "head": + # A detached event commit can be fetched even when absent from the local checkout. + head = _commit(repo, "docs/next.md") + base = _git(clone, "rev-parse", "HEAD") + assert _git(clone, "rev-parse", "--is-shallow-repository") == "true" + + assert _detect(clone, "docs-only", base, head) is docs_only + _git(clone, "cat-file", "-e", f"{base}^{{commit}}") + _git(clone, "cat-file", "-e", f"{head}^{{commit}}") + + +def test_force_push_fetches_before_commit_outside_current_history( + change_repo: tuple[Path, str], tmp_path: Path +) -> None: + repo, initial = change_repo + base = _commit(repo, "docs/old.md") + _git(repo, "reset", "--hard", initial) + head = _commit(repo, "docs/new.md") + clone = tmp_path / "checkout" + _git(tmp_path, "clone", "--depth=1", repo.as_uri(), str(clone)) + + assert _detect(clone, "docs-only", base, head) + _git(clone, "cat-file", "-e", f"{base}^{{commit}}") + + +@pytest.mark.parametrize("base_kind", ["unavailable", "new-branch", "missing-input"]) +def test_unknown_base_requires_checks_and_denies_deployment( + change_repo: tuple[Path, str], base_kind: str +) -> None: + repo, _ = change_repo + head = _commit(repo, "docs/index.md") + base = {"unavailable": MISSING_SHA, "new-branch": ZERO_SHA, "missing-input": ""}[base_kind] + + assert _detect(repo, "code", base, head) + assert _detect(repo, "docs", base, head) + assert not _detect(repo, "docs-only", base, head) + + +def test_unknown_head_requires_checks_and_denies_deployment( + change_repo: tuple[Path, str], +) -> None: + repo, base = change_repo + + assert _detect(repo, "code", base, MISSING_SHA) + assert _detect(repo, "docs", base, MISSING_SHA) + assert not _detect(repo, "docs-only", base, MISSING_SHA) + assert not _detect(repo, "docs-only", base, "") + + +def test_failed_diff_cannot_skip_checks_or_authorize_deployment( + change_repo: tuple[Path, str], tmp_path: Path +) -> None: + repo, base = change_repo + head = _commit(repo, "docs/index.md") + git = shutil.which("git") + assert git is not None + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + shim = bin_dir / "git" + shim.write_text( + "#!/usr/bin/env bash\n" + 'if [ "$1" = diff ]; then\n' + " printf 'docs/index.md\\0'\n" + " exit 128\n" + "fi\n" + f'exec {shlex.quote(Path(git).as_posix())} "$@"\n', + encoding="utf-8", + newline="\n", + ) + shim.chmod(0o755) + # Set the shim's precedence after Git Bash's wrapper initializes PATH. + bash_args = [ + "-c", + 'export PATH="$(cd "$1" && pwd):$PATH"; shift; exec "$@"', + "bash", + bin_dir.as_posix(), + DETECTOR.as_posix(), + ] + for mode, expected in (("code", True), ("docs", True), ("docs-only", False)): + assert _detect(repo, mode, base, head, bash_args=[*bash_args, mode, base, head]) is expected + + +def test_empty_diff_does_not_run_checks_or_deploy(change_repo: tuple[Path, str]) -> None: + repo, base = change_repo + + for mode in ("code", "docs", "docs-only"): + assert not _detect(repo, mode, base, base) + + +def test_rename_into_docs_still_counts_removed_code(change_repo: tuple[Path, str]) -> None: + repo, _ = change_repo + base = _commit(repo, "src/old.py") + (repo / "docs").mkdir() + _git(repo, "mv", "src/old.py", "docs/new.md") + head = _commit(repo) + + assert _detect(repo, "code", base, head) + assert _detect(repo, "docs", base, head) + assert not _detect(repo, "docs-only", base, head) + + +def test_code_mode_retains_merge_base_and_head_fallback(change_repo: tuple[Path, str]) -> None: + repo, base = change_repo + _git(repo, "update-ref", "refs/remotes/origin/main", base) + _commit(repo, "src/agents/run.py") + + assert _detect(repo, "code", "", "") + + +def test_custom_pattern_mode_is_preserved(change_repo: tuple[Path, str]) -> None: + repo, base = change_repo + head = _commit(repo, "README.md") + + assert _detect(repo, r"^README\.md$", base, head) + assert not _detect(repo, r"^other/", base, head) + + +def test_docs_workflow_requires_positive_detector_evidence(change_repo: tuple[Path, str]) -> None: + workflow = yaml.load( + (ROOT / ".github/workflows/docs.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader + ) + assert workflow["on"] == {"push": {"branches": ["main"], "paths": ["docs/**", "mkdocs.yml"]}} + steps = workflow["jobs"]["deploy_docs"]["steps"] + detection = next(step for step in steps if step.get("id") == "docs-only") + assert detection["env"] == { + "BASE_SHA": "${{ github.event.before }}", + "HEAD_SHA": "${{ github.sha }}", + } + for step in steps[steps.index(detection) + 1 :]: + assert step["if"] == "steps.docs-only.outputs.run == 'true'" + + repo, base = change_repo + script = repo / DETECTOR.relative_to(ROOT) + script.parent.mkdir(parents=True) + shutil.copy2(DETECTOR, script) + base = _commit(repo) + head = _commit(repo, "docs/index.md") + bash_args = ["-euo", "pipefail", "-c", detection["run"]] + assert _detect( + repo, "", "", "", extra_env={"BASE_SHA": base, "HEAD_SHA": head}, bash_args=bash_args + ) + head = _commit(repo, "src/agents/run.py") + assert not _detect( + repo, "", "", "", extra_env={"BASE_SHA": base, "HEAD_SHA": head}, bash_args=bash_args + ) diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 53324da279..b4ddaf0df3 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -11,7 +11,6 @@ import pytest RUNNER = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "run_integration_tests.py" -CHANGE_DETECTOR = RUNNER.with_name("detect-changes.sh") INTEGRATION_CONFTEST = RUNNER.parents[2] / "integration_tests" / "conftest.py" @@ -575,18 +574,6 @@ def test_extra_collection_deselects_only_non_applicable_memory_backends( assert deselected == [non_applicable] -def test_code_change_detection_includes_packaged_contract_inputs() -> None: - detector = CHANGE_DETECTOR.read_text(encoding="utf-8") - - assert "integration_tests/" in detector - assert "detect-changes\\.sh" in detector - assert "run_integration_tests\\.py" in detector - assert "run_examples\\.sh" in detector - assert "examples-run-analysis" in detector - assert "update_released_api_contract\\.py" in detector - assert "\\.github/workflows/tests\\.yml" in detector - - def test_packaging_profile_checks_dependency_present_contract_for_wheel_and_sdist( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, From ba180442d4ab99be75c585a12c7e02e110b14a7f Mon Sep 17 00:00:00 2001 From: dfedoryshchev <64079946+dfedoryshchev@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:29:02 +0100 Subject: [PATCH 452/473] fix(tracing): record the tool output on approval-gated function spans (#4866) --- src/agents/run_internal/tool_execution.py | 16 +++- tests/test_run_step_execution.py | 101 ++++++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index af257b165f..f15dd123c1 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -1852,7 +1852,12 @@ async def _run_single_tool( raise UserError(f"Error running tool {func_tool.name}: {e}") from e if self.config.trace_include_sensitive_data: - span_fn.span_data.output = result + # Approval short-circuits return the FunctionToolResult wrapper rather than the + # tool's own output, so read the output off it the way every other consumer of + # this value does (see `_build_function_tool_results`). + span_fn.span_data.output = ( + result.output if isinstance(result, FunctionToolResult) else result + ) return result async def _maybe_execute_tool_approval( @@ -1966,7 +1971,13 @@ async def _maybe_execute_tool_approval( ) span_fn.set_error( SpanError( - message=rejection_message, + # The rejection message is app-supplied text, so it reaches the exported span + # under the same sensitive-data gate as the tool output above. + message=_error_tracing.get_trace_error( + trace_include_sensitive_data=self.config.trace_include_sensitive_data, + error_message=rejection_message, + redacted_message="Tool execution rejected", + ), data={ "tool_name": func_tool.name, "error": ( @@ -1975,7 +1986,6 @@ async def _maybe_execute_tool_approval( }, ) ) - span_fn.span_data.output = rejection_message return FunctionToolResult( tool=func_tool, output=rejection_message, diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 329917e509..515aa6cc0b 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -723,6 +723,107 @@ async def _error_tool() -> str: assert "secret-token-123" not in str(error) +def _make_approval_function_tool() -> FunctionTool: + async def _approval_tool() -> str: + return "ok" + + return function_tool(_approval_tool, name_override="approval_tool", needs_approval=True) + + +@pytest.mark.asyncio +async def test_pending_approval_function_span_output_excludes_internal_result_object(): + agent = Agent( + name="test", + instructions="system-prompt-abc", + tools=[_make_approval_function_tool()], + ) + response = ModelResponse( + output=[get_function_tool_call("approval_tool", "{}", call_id="1")], + usage=Usage(), + response_id=None, + ) + + with trace("test"): + await get_execute_result( + agent, + response, + run_config=RunConfig(trace_include_sensitive_data=True), + ) + + function_spans = _function_spans() + + assert len(function_spans) == 1 + output = function_spans[0]["span_data"]["output"] + assert output is None + assert "system-prompt-abc" not in str(function_spans[0]) + + +@pytest.mark.asyncio +async def test_rejected_tool_function_span_output_respects_sensitive_data_setting(): + agent = Agent(name="test", tools=[_make_approval_function_tool()]) + tool_call = get_function_tool_call("approval_tool", "{}", call_id="1") + response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None) + + context_wrapper: RunContextWrapper[Any] = RunContextWrapper(None) + reject_tool_call( + context_wrapper, + agent, + tool_call, + tool_name="approval_tool", + rejection_message="secret-denial-456", + ) + + with trace("test"): + await get_execute_result( + agent, + response, + context_wrapper=context_wrapper, + run_config=RunConfig(trace_include_sensitive_data=False), + ) + + function_spans = _function_spans() + + assert len(function_spans) == 1 + exported = function_spans[0] + assert exported["span_data"]["output"] is None + error = exported["error"] + assert error["message"] == "Tool execution rejected" + assert error["data"]["tool_name"] == "approval_tool" + assert error["data"]["error"] == "Tool execution for 1 was manually rejected by user." + assert "secret-denial-456" not in json.dumps(exported, default=str) + + +@pytest.mark.asyncio +async def test_rejected_tool_function_span_keeps_rejection_message_when_sensitive_data_included(): + agent = Agent(name="test", tools=[_make_approval_function_tool()]) + tool_call = get_function_tool_call("approval_tool", "{}", call_id="1") + response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None) + + context_wrapper: RunContextWrapper[Any] = RunContextWrapper(None) + reject_tool_call( + context_wrapper, + agent, + tool_call, + tool_name="approval_tool", + rejection_message="denied-by-policy", + ) + + with trace("test"): + await get_execute_result( + agent, + response, + context_wrapper=context_wrapper, + run_config=RunConfig(trace_include_sensitive_data=True), + ) + + function_spans = _function_spans() + + assert len(function_spans) == 1 + exported = function_spans[0] + assert exported["span_data"]["output"] == "denied-by-policy" + assert exported["error"]["message"] == "denied-by-policy" + + @pytest.mark.asyncio async def test_multiple_tool_calls_still_raise_when_sibling_cancelled(): async def _ok_tool() -> str: From 29d441ba339ddef1de28443b0c69a9b737be8be3 Mon Sep 17 00:00:00 2001 From: Excelius <57819425+Excelius-Wang@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:32:33 +0800 Subject: [PATCH 453/473] fix(sessions): settle conversation deletion before propagating cancellation (#4790) --- .../memory/openai_conversations_session.py | 16 +++- src/agents/memory/session.py | 29 +++++- src/agents/memory/sqlite_session.py | 30 +----- .../test_openai_conversations_session.py | 91 +++++++++++++++++++ 4 files changed, 133 insertions(+), 33 deletions(-) diff --git a/src/agents/memory/openai_conversations_session.py b/src/agents/memory/openai_conversations_session.py index 8e0641067c..47c445d381 100644 --- a/src/agents/memory/openai_conversations_session.py +++ b/src/agents/memory/openai_conversations_session.py @@ -8,7 +8,7 @@ from agents.models._openai_shared import get_default_openai_client from ..items import TResponseInputItem -from .session import SessionABC +from .session import SessionABC, _await_mutation from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit @@ -137,7 +137,13 @@ async def clear_session(self) -> None: if self._session_id is None: return - await self._openai_client.conversations.delete( - conversation_id=self._session_id, - ) - self._session_id = None + session_id = self._session_id + + async def delete_and_clear_session_id() -> None: + await self._openai_client.conversations.delete( + conversation_id=session_id, + ) + if self._session_id == session_id: + self._session_id = None + + await _await_mutation(delete_and_clear_session_id()) diff --git a/src/agents/memory/session.py b/src/agents/memory/session.py index 26690c2c71..74690eaafb 100644 --- a/src/agents/memory/session.py +++ b/src/agents/memory/session.py @@ -1,8 +1,10 @@ from __future__ import annotations +import asyncio import inspect from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeGuard, runtime_checkable +from collections.abc import Awaitable +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeGuard, TypeVar, runtime_checkable from typing_extensions import TypedDict @@ -12,6 +14,31 @@ from .session_settings import SessionSettings +_T = TypeVar("_T") + + +async def _await_mutation(awaitable: Awaitable[_T]) -> _T: + """Wait for a mutation outcome despite repeated caller cancellation.""" + task = asyncio.ensure_future(awaitable) + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.wait({task}) + except asyncio.CancelledError as exc: + if cancellation is None: + cancellation = exc + + try: + result = task.result() + except BaseException: + if cancellation is not None: + raise cancellation from None + raise + if cancellation is not None: + raise cancellation from None + return result + + @runtime_checkable class Session(Protocol): """Protocol for session implementations. diff --git a/src/agents/memory/sqlite_session.py b/src/agents/memory/sqlite_session.py index 286b063f03..2ec5737068 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -5,39 +5,15 @@ import sqlite3 import threading import time -from collections.abc import Awaitable, Iterator +from collections.abc import Iterator from contextlib import closing, contextmanager from pathlib import Path -from typing import Any, ClassVar, TypeVar +from typing import Any, ClassVar from ..items import TResponseInputItem -from .session import SessionABC +from .session import SessionABC, _await_mutation as _await_mutation from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit -_T = TypeVar("_T") - - -async def _await_mutation(awaitable: Awaitable[_T]) -> _T: - """Wait for a mutation outcome despite repeated caller cancellation.""" - task = asyncio.ensure_future(awaitable) - cancellation: asyncio.CancelledError | None = None - while not task.done(): - try: - await asyncio.wait({task}) - except asyncio.CancelledError as exc: - if cancellation is None: - cancellation = exc - - try: - result = task.result() - except BaseException: - if cancellation is not None: - raise cancellation from None - raise - if cancellation is not None: - raise cancellation from None - return result - class SQLiteSession(SessionABC): """SQLite-based implementation of session storage. diff --git a/tests/memory/test_openai_conversations_session.py b/tests/memory/test_openai_conversations_session.py index d0075418d9..0357ddf41d 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -336,6 +336,97 @@ async def test_clear_session(self, mock_openai_client): mock_openai_client.conversations.delete.assert_called_once_with(conversation_id="test_id") assert session._session_id is None + @pytest.mark.asyncio + async def test_clear_session_cancellation_settles_delete_before_reinitializing( + self, mock_openai_client + ): + """A cancelled clear must settle deletion before the session can be reused.""" + delete_started = asyncio.Event() + allow_delete_finish = asyncio.Event() + delete_finished = False + + async def slow_delete(*, conversation_id: str) -> None: + nonlocal delete_finished + assert conversation_id == "old_id" + delete_started.set() + await allow_delete_finish.wait() + delete_finished = True + + mock_openai_client.conversations.delete.side_effect = slow_delete + session = OpenAIConversationsSession( + conversation_id="old_id", openai_client=mock_openai_client + ) + clear_task = asyncio.create_task(session.clear_session()) + + try: + await delete_started.wait() + clear_task.cancel("caller-cancelled") + await asyncio.sleep(0) + + allow_delete_finish.set() + with pytest.raises(asyncio.CancelledError): + await clear_task + + items: list[Any] = [{"role": "user", "content": "Next turn"}] + await session.add_items(items) + finally: + allow_delete_finish.set() + if not clear_task.done(): + clear_task.cancel() + await asyncio.gather(clear_task, return_exceptions=True) + + assert session.session_id == "test_conversation_id" + assert delete_finished is True + mock_openai_client.conversations.delete.assert_awaited_once_with(conversation_id="old_id") + mock_openai_client.conversations.create.assert_awaited_once_with(items=[]) + mock_openai_client.conversations.items.create.assert_awaited_once_with( + conversation_id="test_conversation_id", items=items + ) + + @pytest.mark.asyncio + async def test_clear_session_cancellation_preserves_replacement_session_id( + self, mock_openai_client + ): + """A settled delete must not clear a replacement conversation ID.""" + delete_started = asyncio.Event() + allow_delete_finish = asyncio.Event() + + async def slow_delete(*, conversation_id: str) -> None: + assert conversation_id == "old_id" + delete_started.set() + await allow_delete_finish.wait() + + mock_openai_client.conversations.delete.side_effect = slow_delete + session = OpenAIConversationsSession( + conversation_id="old_id", openai_client=mock_openai_client + ) + clear_task = asyncio.create_task(session.clear_session()) + + try: + await delete_started.wait() + clear_task.cancel("caller-cancelled") + await asyncio.sleep(0) + + session.session_id = "replacement_id" + allow_delete_finish.set() + with pytest.raises(asyncio.CancelledError): + await clear_task + + items: list[Any] = [{"role": "user", "content": "Next turn"}] + await session.add_items(items) + finally: + allow_delete_finish.set() + if not clear_task.done(): + clear_task.cancel() + await asyncio.gather(clear_task, return_exceptions=True) + + assert session.session_id == "replacement_id" + mock_openai_client.conversations.delete.assert_awaited_once_with(conversation_id="old_id") + mock_openai_client.conversations.create.assert_not_awaited() + mock_openai_client.conversations.items.create.assert_awaited_once_with( + conversation_id="replacement_id", items=items + ) + @pytest.mark.asyncio async def test_clear_session_uninitialized_does_not_create_session(self, mock_openai_client): """Test that clear_session on an uninitialized session does not call create or delete.""" From 3936265a9033183eec8b8022c4776997f979bfbf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 7 Sep 2026 21:36:40 +0900 Subject: [PATCH 454/473] chore: strengthen maintainer review of behavior changes and compatibility --- .agents/skills/maintainer-review/SKILL.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index 503940739d..96686a30bb 100644 --- a/.agents/skills/maintainer-review/SKILL.md +++ b/.agents/skills/maintainer-review/SKILL.md @@ -129,7 +129,7 @@ For repository-specific runtime invariants, start with `.agents/references/READM Use this evidence order: 1. Trace the closest existing supported capabilities and determine whether they already satisfy the underlying user outcome. -2. Inspect existing tests and complete the code-path trace, including the mandatory interleaving and ownership pass when triggered, without executing code. +2. Inspect existing tests and complete the code-path trace, including the changed-behavior coverage and interleaving passes below when triggered, without executing code. 3. Compare the implementation and existing evidence with the released version, base branch, or known-good control without executing code. 4. If a decision-relevant runtime uncertainty remains, stop and suggest a separate runtime investigation using the evidence requirements below. @@ -150,6 +150,16 @@ Before a positive assessment, complete the pass in step 2 and be able to state a If any answer is missing and could change whether code should exist at all, do not call the issue actionable or the PR merge-worthy. Request only the evidence needed to distinguish a genuine capability gap from a usage, discoverability, or solution-design problem. This is a product and architecture evidence gap, not a runtime-probe trigger by itself. +##### Mandatory changed-behavior coverage pass + +After the need gate passes, run this pass before a positive PR assessment or a conclusion that no additional runtime investigation is needed when a patch rejects, drops, replaces, or reclassifies previously accepted input, output, or state. + +1. Trace the full set of supported cases matched by the changed condition through the actual normalization, conversion, and consumer paths. Do not stop at the reported reproducer or the fields checked by the patch; inspect the existing processing paths for information or semantics the new condition overlooks. Identify what callers could previously read, persist, replay, or act on and what the head would return or raise instead. +2. Group affected providers, adapters, or producers by materially different representations and semantics. Verify those differences against the relevant dependency version and supported release boundary; current upstream code alone does not establish behavior in an older dependency. Shared interfaces or a fix in one adapter do not prove equivalent behavior in another. Investigate only groups the changed condition can affect, not an exhaustive provider matrix. +3. Keep evidence of need separate from evidence of compatibility. Label contributor-reported live results, inspected test assertions, authoritative specifications, and independently observed runtime results accurately. A reproduction from one provider can demonstrate the bug without establishing that the new condition preserves other supported output shapes. A unit test using a constructed response does not establish which providers emit that response. + +Record a compact working note for each materially different case: `trigger and representation -> base/head outcome -> caller consequence -> evidence and remaining uncertainty`. An omitted case is unfinished review work, not evidence that no runtime concern exists. Complete the static trace first. If it proves a supported regression, request the focused correction without requiring a live reproduction. If a concrete provider-dependent uncertainty could change compatibility or the recommendation, keep the assessment preliminary and identify the focused runtime evidence and control needed under the existing desk-review boundary. Do not turn generic provider uncertainty into mandatory runtime testing. + ##### Mandatory interleaving and ownership pass Run this pass before any positive PR assessment when a patch adds, removes, or reorders cleanup, retry, reconnect, cancellation, listeners, shared futures or tasks, connections or streams, state flags, or mutable state across an `await`, callback, event, or deferred completion. @@ -164,7 +174,7 @@ Run this pass before any positive PR assessment when a patch adds, removes, or r Do not mark a concurrency-sensitive patch `Merge-worthy as-is` merely because sequential reconnect, retry, failure, and close tests pass. A triggered ownership pass is incomplete unless the evidence records the complete mutation surface, concrete ownership mechanism, strongest distinct-mutator interleaving, and survivor and coherence result. If the code trace proves an unsafe interleaving, conclude from static evidence and request a focused fix and regression test. If ownership remains ambiguous, keep the result preliminary and state the exact runtime evidence needed to resolve it. - If the claim or PR is decisively negative from a complete reachable code-path trace, conclude the review without a runtime probe. Examples include an impossible or unsupported path, duplicated existing handling, a demonstrated no-op, a direct compatibility break, or a clearly wrong abstraction. Do not call an ambiguous result negative merely to avoid a probe. -- If the initial result is positive and there is no unresolved runtime concern, and any triggered interleaving and ownership pass is complete, the desk review may be sufficient for a final maintainer decision. Do not suggest additional runtime investigation only to restate evidence that cannot plausibly change the decision. +- If the initial result is positive and there is no unresolved runtime concern, and the triggered changed-behavior coverage and interleaving passes are complete, the desk review may be sufficient for a final maintainer decision. Do not suggest additional runtime investigation only to restate evidence that cannot plausibly change the decision. - If there is any unresolved runtime concern that could plausibly change claim validity, severity, merge-worthiness, required changes, or the preferred competing PR, report a `Preliminary assessment`. State the unresolved question, why it could change the decision, the evidence needed, and an appropriate control, then suggest a separate runtime investigation without planning or executing it. - A purely stylistic, documentation, CI-status, or repository-readiness concern does not justify suggesting a runtime investigation unless it masks a runtime question. From 624bb356bb9dc22ac061a23b53ca8c72b0000690 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:12:41 +0100 Subject: [PATCH 455/473] docs: skip reference pages on Windows (#4761) --- docs/scripts/translate_docs.py | 2 +- tests/docs/test_translate_docs.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/scripts/translate_docs.py b/docs/scripts/translate_docs.py index b400c84da1..88db986f31 100644 --- a/docs/scripts/translate_docs.py +++ b/docs/scripts/translate_docs.py @@ -741,7 +741,7 @@ def translate_single_source_file( file_path: str, *, check_translation_outdated: bool = True ) -> None: relative_path = os.path.relpath(file_path, source_dir) - if "ref/" in relative_path or not file_path.endswith(".md"): + if "ref/" in relative_path.replace("\\", "/") or not file_path.endswith(".md"): return if check_translation_outdated and not should_translate_based_on_translation(file_path): print(f"Skipping {file_path}: The translated one is up-to-date.") diff --git a/tests/docs/test_translate_docs.py b/tests/docs/test_translate_docs.py index 719db06d57..0105416368 100644 --- a/tests/docs/test_translate_docs.py +++ b/tests/docs/test_translate_docs.py @@ -127,3 +127,26 @@ def test_a_heading_with_its_own_attribute_list_is_not_rewritten(translate_docs: translated = "## アルファ {.lead}\n\n## ベータ\n" assert translate_docs.preserve_heading_anchors(source, translated) == translated + + +def test_ref_pages_are_skipped_with_windows_separators( + translate_docs: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + translated: list[tuple[str, str, str]] = [] + monkeypatch.setattr(translate_docs.os.path, "relpath", lambda *_args: r"ref\voice\model.md") + monkeypatch.setattr(translate_docs.os, "makedirs", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + translate_docs, + "translate_file", + lambda file_path, target_path, lang_code: translated.append( + (file_path, target_path, lang_code) + ), + ) + + translate_docs.translate_single_source_file( + r"docs\ref\voice\model.md", + check_translation_outdated=False, + ) + + assert translated == [] From 2bbe535ac37f0e4054a4aea6a5bb80e6a1ebbcdd Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 7 Sep 2026 23:27:09 +0900 Subject: [PATCH 456/473] fix(sessions): preserve concurrent writes during compaction (#4736) Co-authored-by: Om Singhal --- .../openai_responses_compaction_session.py | 115 +++++-- .../run_internal/session_persistence.py | 46 ++- ...est_openai_responses_compaction_session.py | 317 +++++++++++++++++- 3 files changed, 453 insertions(+), 25 deletions(-) diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index f09c3a6edd..fed2d89ff4 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -142,6 +142,10 @@ def __init__( # Serialize wrapper mutations against compaction snapshot/replace/restore so a # cancellation rollback cannot rewrite past a newer concurrent write. self._mutation_lock = asyncio.Lock() + # Runner persistence can carry this wrapper-local generation across the + # append-to-compaction gap. A later wrapper mutation revokes that one + # pending automatic replacement without inferring ownership from history. + self._mutation_generation = 0 @property def client(self) -> AsyncOpenAI: @@ -178,6 +182,35 @@ async def run_compaction( When a run context is provided, the billed compaction request contributes to that run's usage totals. """ + # Keep one wrapper mutation boundary from the snapshot through replacement. + # A concurrent add, pop, or clear waits here and then runs against the + # compacted state instead of being overwritten by a stale replacement. + async with self._mutation_lock: + has_expected_generation = wrapper is not None and hasattr( + wrapper, "_session_compaction_generation" + ) + expected_generation = ( + getattr(wrapper, "_session_compaction_generation", None) + if has_expected_generation + else None + ) + if has_expected_generation and ( + not isinstance(expected_generation, int) + or expected_generation != self._mutation_generation + ): + logger.warning( + "Skipped compaction because Session history changed after this " + "run appended its items." + ) + return + await self._run_compaction_locked(args, wrapper=wrapper) + + async def _run_compaction_locked( + self, + args: OpenAIResponsesCompactionArgs | None, + *, + wrapper: RunContextWrapper[Any] | None, + ) -> None: if args and args.get("response_id"): self._response_id = args["response_id"] requested_mode = args.get("compaction_mode") if args else None @@ -245,14 +278,18 @@ async def run_compaction( _normalize_compaction_output_items(compacted.output or []) ) - async with self._mutation_lock: - previous_items = await self._get_all_underlying_session_items() + previous_items = await self._get_all_underlying_session_items() + try: await self._replace_underlying_session_items( output_items=output_items, previous_items=previous_items, ) - self._compaction_candidate_items = select_compaction_candidate_items(output_items) - self._session_items = output_items + except (Exception, asyncio.CancelledError): + self._mutation_generation += 1 + raise + self._mutation_generation += 1 + self._compaction_candidate_items = select_compaction_candidate_items(output_items) + self._session_items = output_items logger.debug( "compact: done for %s (mode=%s, output=%s, candidates=%s)", @@ -265,6 +302,14 @@ async def run_compaction( async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: return await self.underlying_session.get_items(limit) + async def _get_items_with_generation( + self, limit: int | None = None + ) -> tuple[list[TResponseInputItem], int]: + """Read one Runner snapshot with its exact wrapper generation.""" + async with self._mutation_lock: + items = await self.underlying_session.get_items(limit) + return items, self._mutation_generation + async def _get_all_underlying_session_items(self) -> list[TResponseInputItem]: return await self.underlying_session.get_items(limit=_ALL_SESSION_ITEMS_LIMIT) @@ -410,37 +455,69 @@ def _clear_deferred_compaction(self) -> None: self._deferred_response_id = None async def add_items(self, items: list[TResponseInputItem]) -> None: + async with self._mutation_lock: + await self._add_items_locked(items) + + async def _add_items_with_generation( + self, + items: list[TResponseInputItem], + *, + expected_generation: int | None, + ) -> int | None: + """Append one Runner batch and retain ownership only when its read stayed current.""" + async with self._mutation_lock: + owns_generation = expected_generation == self._mutation_generation + await self._add_items_locked(items) + return self._mutation_generation if owns_generation else None + + async def _add_items_locked(self, items: list[TResponseInputItem]) -> None: + try: + await self.underlying_session.add_items(items) + except (Exception, asyncio.CancelledError): + # The backend may have committed before acknowledgement failed. Re-read its + # authoritative history before compaction instead of retaining a stale cache. + self._compaction_candidate_items = None + self._session_items = None + self._mutation_generation += 1 + raise + self._mutation_generation += 1 + if self._compaction_candidate_items is not None: + new_items = _normalize_compaction_session_items(items) + new_candidates = select_compaction_candidate_items(new_items) + if new_candidates: + self._compaction_candidate_items.extend(new_candidates) + if self._session_items is not None: + self._session_items.extend(_normalize_compaction_session_items(items)) + + async def pop_item(self) -> TResponseInputItem | None: async with self._mutation_lock: try: - await self.underlying_session.add_items(items) + popped = await self.underlying_session.pop_item() except (Exception, asyncio.CancelledError): - # The backend may have committed before acknowledgement failed. Re-read its - # authoritative history before compaction instead of retaining a stale cache. self._compaction_candidate_items = None self._session_items = None + self._mutation_generation += 1 raise - if self._compaction_candidate_items is not None: - new_items = _normalize_compaction_session_items(items) - new_candidates = select_compaction_candidate_items(new_items) - if new_candidates: - self._compaction_candidate_items.extend(new_candidates) - if self._session_items is not None: - self._session_items.extend(_normalize_compaction_session_items(items)) - - async def pop_item(self) -> TResponseInputItem | None: - async with self._mutation_lock: - popped = await self.underlying_session.pop_item() if popped: self._compaction_candidate_items = None self._session_items = None + self._mutation_generation += 1 return popped async def clear_session(self) -> None: async with self._mutation_lock: - await self.underlying_session.clear_session() + try: + await self.underlying_session.clear_session() + except (Exception, asyncio.CancelledError): + self._compaction_candidate_items = None + self._session_items = None + self._deferred_response_id = None + self._mutation_generation += 1 + raise self._compaction_candidate_items = [] self._session_items = [] self._deferred_response_id = None + self._mutation_generation += 1 async def _ensure_compaction_candidates( self, diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 2e53667e7e..de5d3e6b3c 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -199,8 +199,17 @@ async def _session_get_items( limit: int | None | object = _SESSION_LIMIT_UNSET, *, wrapper: RunContextWrapper[Any] | None = None, + capture_compaction_generation: bool = False, ) -> list[TResponseInputItem]: """Read session items while preserving the legacy method call shape.""" + get_with_generation = getattr(session, "_get_items_with_generation", None) + if capture_compaction_generation and wrapper is not None and callable(get_with_generation): + if limit is _SESSION_LIMIT_UNSET: + result, generation = await _call_session_method(get_with_generation) + else: + result, generation = await _call_session_method(get_with_generation, limit=limit) + wrapper._session_compaction_generation = generation # type: ignore[attr-defined] + return cast(list[TResponseInputItem], result) wrapper = _get_session_wrapper(session, wrapper) if limit is _SESSION_LIMIT_UNSET: result = await _call_session_method(session.get_items, wrapper=wrapper) @@ -216,6 +225,16 @@ async def _session_add_items( wrapper: RunContextWrapper[Any] | None = None, ) -> None: """Append session items while preserving the legacy method call shape.""" + add_with_generation = getattr(session, "_add_items_with_generation", None) + if wrapper is not None and callable(add_with_generation): + expected_generation = getattr(wrapper, "_session_compaction_generation", None) + generation = await _call_session_method( + add_with_generation, + items, + expected_generation=expected_generation, + ) + wrapper._session_compaction_generation = generation # type: ignore[attr-defined] + return wrapper = _get_session_wrapper(session, wrapper) await _call_session_method(session.add_items, items, wrapper=wrapper) @@ -360,9 +379,14 @@ async def prepare_input_with_session( session, limit=resolved_settings.limit, wrapper=wrapper, + capture_compaction_generation=True, ) else: - history = await _session_get_items(session, wrapper=wrapper) + history = await _session_get_items( + session, + wrapper=wrapper, + capture_compaction_generation=True, + ) is_openai_conversation_session = isinstance(session, OpenAIConversationsSession) converted_history = [ strip_internal_input_item_metadata(ensure_input_item_format(item)) for item in history @@ -674,9 +698,13 @@ async def save_result_to_session( resumed_write_state._current_turn_persisted_item_count + saved_run_items_count ), } - await resume_pending_session_write(resumed_write_state, session, wrapper=wrapper) + await resume_pending_session_write( + resumed_write_state, + session, + wrapper=compaction_wrapper, + ) else: - await _session_add_items(session, items_to_save, wrapper=wrapper) + await _session_add_items(session, items_to_save, wrapper=compaction_wrapper) if run_state is not None: run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count @@ -801,7 +829,15 @@ def digests(items: Sequence[TResponseInputItem]) -> list[str]: append = True else: expected = before + digests(pending["items"]) - tail = await _session_get_items(session, limit=len(expected), wrapper=wrapper) + committed_generation: int | None = None + get_with_generation = getattr(session, "_get_items_with_generation", None) + if wrapper is not None and callable(get_with_generation): + tail, committed_generation = await _call_session_method( + get_with_generation, + limit=len(expected), + ) + else: + tail = await _session_get_items(session, limit=len(expected), wrapper=wrapper) observed = digests(tail) committed = observed == expected unchanged = observed[-len(before) :] == before if before else not observed @@ -811,6 +847,8 @@ def digests(items: Sequence[TResponseInputItem]) -> list[str]: "Repair the original Session before resuming; do not rerun the completed tool." ) append = unchanged + if committed and committed_generation is not None and wrapper is not None: + wrapper._session_compaction_generation = committed_generation # type: ignore[attr-defined] if append: # Backends may retain or transform their input; the durable checkpoint stays detached. await _session_add_items(session, copy.deepcopy(pending["items"]), wrapper=wrapper) diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index 5519228ea6..7f8895a31c 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -16,7 +16,7 @@ import agents._debug as _debug from agents import Agent, Runner -from agents.items import TResponseInputItem +from agents.items import MessageOutputItem, TResponseInputItem from agents.memory import ( OpenAIResponsesCompactionSession, Session, @@ -30,11 +30,17 @@ is_openai_model_name, select_compaction_candidate_items, ) +from agents.run_context import RunContextWrapper from agents.run_internal.items import ( TOOL_CALL_SESSION_DESCRIPTION_KEY, TOOL_CALL_SESSION_TITLE_KEY, ) -from agents.testing import ScriptedModel +from agents.run_internal.session_persistence import ( + prepare_input_with_session, + save_result_to_session, +) +from agents.run_state import RunState +from agents.testing import ModelStep, ScriptedModel from tests.test_responses import get_function_tool, get_function_tool_call, get_text_message from tests.utils.simple_session import SimpleListSession @@ -1702,6 +1708,313 @@ def test_strips_multiple_assistant_ids(self) -> None: assert "id" not in item +class TestCompactionMutationSerialization: + @pytest.mark.asyncio + async def test_resumed_save_carries_generation_into_compaction(self) -> None: + """A resumed append uses the same ownership handoff as a fresh save.""" + underlying = SimpleListSession() + client = MagicMock() + client.responses.compact = AsyncMock(return_value=SimpleNamespace(output=[])) + resumed_persisted = asyncio.Event() + release_resumed = asyncio.Event() + add_calls = 0 + + class PausingCompactionSession(OpenAIResponsesCompactionSession): + async def _add_items_with_generation( + self, + items: list[TResponseInputItem], + *, + expected_generation: int | None, + ) -> int | None: + nonlocal add_calls + generation = await super()._add_items_with_generation( + items, + expected_generation=expected_generation, + ) + add_calls += 1 + if add_calls == 1: + resumed_persisted.set() + await release_resumed.wait() + return generation + + session = PausingCompactionSession( + session_id="resumed-interleaved-persist", + underlying_session=underlying, + client=client, + compaction_mode="previous_response_id", + should_trigger_compaction=lambda context: context["response_id"] == "resp-a", + ) + agent = Agent(name="worker-a") + wrapper = RunContextWrapper(context=None) + state: RunState[Any] = RunState( + context=wrapper, + original_input=[], + starting_agent=agent, + ) + resumed_item = MessageOutputItem(agent=agent, raw_item=get_text_message("run-A")) + await prepare_input_with_session([], session, None, wrapper=wrapper) + + resumed_save = asyncio.create_task( + save_result_to_session( + session, + [], + [resumed_item], + state, + response_id="resp-a", + wrapper=wrapper, + resumed_write_state=state, + ) + ) + await asyncio.wait_for(resumed_persisted.wait(), timeout=1) + await asyncio.wait_for( + Runner.run( + Agent( + name="worker-b", + model=ScriptedModel( + steps=[ + ModelStep( + output=[get_text_message("run-B")], + response_id="resp-b", + ) + ] + ), + ), + "input-B", + session=session, + ), + timeout=1, + ) + release_resumed.set() + await resumed_save + + client.responses.compact.assert_not_awaited() + stored = str(await underlying.get_items()) + assert "run-A" in stored + assert "run-B" in stored + + @pytest.mark.asyncio + async def test_runner_skips_compaction_after_interleaved_model_wait(self) -> None: + """A later run that completes during A's model wait revokes A's replacement.""" + underlying = SimpleListSession() + client = MagicMock() + client.responses.compact = AsyncMock(return_value=SimpleNamespace(output=[])) + model_entered = asyncio.Event() + release_model = asyncio.Event() + + async def respond_after_b(_: Any) -> ModelStep: + model_entered.set() + await release_model.wait() + return ModelStep(output=[get_text_message("run-A")], response_id="resp-a") + + session = OpenAIResponsesCompactionSession( + session_id="interleaved-model-wait", + underlying_session=underlying, + client=client, + compaction_mode="previous_response_id", + should_trigger_compaction=lambda context: context["response_id"] == "resp-a", + ) + run_a = asyncio.create_task( + Runner.run( + Agent( + name="worker-a", + model=ScriptedModel(steps=[ModelStep.respond(respond_after_b)]), + ), + "input-A", + session=session, + ) + ) + await asyncio.wait_for(model_entered.wait(), timeout=1) + await asyncio.wait_for( + Runner.run( + Agent( + name="worker-b", + model=ScriptedModel( + steps=[ + ModelStep( + output=[get_text_message("run-B")], + response_id="resp-b", + ) + ] + ), + ), + "input-B", + session=session, + ), + timeout=1, + ) + release_model.set() + await run_a + + client.responses.compact.assert_not_awaited() + stored = str(await underlying.get_items()) + assert "run-A" in stored + assert "run-B" in stored + + @pytest.mark.asyncio + async def test_runner_skips_compaction_after_interleaved_persist(self) -> None: + """A later Runner append between save and compact revokes replacement.""" + underlying = SimpleListSession() + client = MagicMock() + client.responses.compact = AsyncMock(return_value=SimpleNamespace(output=[])) + run_a_persisted = asyncio.Event() + release_run_a = asyncio.Event() + add_calls = 0 + + class PausingCompactionSession(OpenAIResponsesCompactionSession): + async def _add_items_with_generation( + self, + items: list[TResponseInputItem], + *, + expected_generation: int | None, + ) -> int | None: + nonlocal add_calls + generation = await super()._add_items_with_generation( + items, + expected_generation=expected_generation, + ) + add_calls += 1 + if add_calls == 2: + run_a_persisted.set() + await release_run_a.wait() + return generation + + session = PausingCompactionSession( + session_id="interleaved-persist", + underlying_session=underlying, + client=client, + compaction_mode="previous_response_id", + should_trigger_compaction=lambda context: context["response_id"] == "resp-a", + ) + + run_a = asyncio.create_task( + Runner.run( + Agent( + name="worker-a", + model=ScriptedModel( + steps=[ + ModelStep( + output=[get_text_message("run-A")], + response_id="resp-a", + ) + ] + ), + ), + "input-A", + session=session, + ) + ) + await asyncio.wait_for(run_a_persisted.wait(), timeout=1) + await asyncio.wait_for( + Runner.run( + Agent( + name="worker-b", + model=ScriptedModel( + steps=[ + ModelStep( + output=[get_text_message("run-B")], + response_id="resp-b", + ) + ] + ), + ), + "input-B", + session=session, + ), + timeout=1, + ) + release_run_a.set() + await run_a + + client.responses.compact.assert_not_awaited() + stored = str(await underlying.get_items()) + assert "run-A" in stored + assert "run-B" in stored + + @pytest.mark.asyncio + async def test_add_waits_for_in_flight_compaction_and_survives(self) -> None: + old_item = cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "old"}, + ) + concurrent_item = cast( + TResponseInputItem, + {"type": "message", "role": "user", "content": "concurrent"}, + ) + compacted_item = cast( + TResponseInputItem, + {"type": "compaction", "summary": "compacted"}, + ) + underlying = SimpleListSession(history=[old_item]) + compact_entered = asyncio.Event() + release_compact = asyncio.Event() + + async def compact(**_: Any) -> SimpleNamespace: + compact_entered.set() + await release_compact.wait() + return SimpleNamespace(output=[compacted_item]) + + client = MagicMock() + client.responses.compact = AsyncMock(side_effect=compact) + session = OpenAIResponsesCompactionSession( + session_id="serialized-add", + underlying_session=underlying, + client=client, + compaction_mode="input", + ) + + compaction_task = asyncio.create_task(session.run_compaction({"force": True})) + await compact_entered.wait() + add_task = asyncio.create_task(session.add_items([concurrent_item])) + await asyncio.sleep(0) + assert not add_task.done() + + release_compact.set() + await compaction_task + await add_task + + assert await underlying.get_items() == [compacted_item, concurrent_item] + + @pytest.mark.asyncio + async def test_clear_waits_for_in_flight_compaction_and_stays_empty(self) -> None: + old_item = cast( + TResponseInputItem, + {"type": "message", "role": "assistant", "content": "old"}, + ) + compacted_item = cast( + TResponseInputItem, + {"type": "compaction", "summary": "compacted"}, + ) + underlying = SimpleListSession(history=[old_item]) + compact_entered = asyncio.Event() + release_compact = asyncio.Event() + + async def compact(**_: Any) -> SimpleNamespace: + compact_entered.set() + await release_compact.wait() + return SimpleNamespace(output=[compacted_item]) + + client = MagicMock() + client.responses.compact = AsyncMock(side_effect=compact) + session = OpenAIResponsesCompactionSession( + session_id="serialized-clear", + underlying_session=underlying, + client=client, + compaction_mode="input", + ) + + compaction_task = asyncio.create_task(session.run_compaction({"force": True})) + await compact_entered.wait() + clear_task = asyncio.create_task(session.clear_session()) + await asyncio.sleep(0) + assert not clear_task.done() + + release_compact.set() + await compaction_task + await clear_task + + assert await underlying.get_items() == [] + + class TestCompactionStripsOrphanedIds: """Regression test for #2727: gpt-5.4 compact retains assistant msg IDs after stripping reasoning items, causing 400 errors on the next responses.create call.""" From 1e5f3ae3f2430501a922b637817799ccc8f16146 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:32:34 +0100 Subject: [PATCH 457/473] test(sandbox): skip unavailable Windows symlinks (#4853) --- tests/sandbox/test_workspace_paths.py | 30 ++++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/sandbox/test_workspace_paths.py b/tests/sandbox/test_workspace_paths.py index ba76d79749..7257bbe441 100644 --- a/tests/sandbox/test_workspace_paths.py +++ b/tests/sandbox/test_workspace_paths.py @@ -36,6 +36,20 @@ def _policy(root: Path | str = "/workspace") -> WorkspacePathPolicy: return WorkspacePathPolicy(root=root) +def _symlink_or_skip( + target: Path, + link: Path, + *, + target_is_directory: bool = False, +) -> None: + try: + os.symlink(target, link, target_is_directory=target_is_directory) + except OSError as exc: + if os.name == "nt" and getattr(exc, "winerror", None) == 1314: + pytest.skip("symlink creation requires elevated privileges on Windows") + raise + + def test_sandbox_workspace_scope_anchors_relative_paths() -> None: scope = SandboxWorkspaceScope.from_cwd("tasks/a") @@ -320,11 +334,11 @@ def test_normalize_path_with_symlink_resolution(tmp_path: Path) -> None: target = workspace / "target.txt" target.write_text("hello", encoding="utf-8") - os.symlink(target, workspace / "link.txt") - os.symlink(outside, workspace / "outside-link", target_is_directory=True) + _symlink_or_skip(target, workspace / "link.txt") + _symlink_or_skip(outside, workspace / "outside-link", target_is_directory=True) alias = tmp_path / "workspace-alias" - os.symlink(workspace, alias, target_is_directory=True) + _symlink_or_skip(workspace, alias, target_is_directory=True) test_cases = [ WorkspacePathCase( @@ -690,7 +704,7 @@ def test_host_io_rejects_write_under_resolved_read_only_extra_path_grant( grant_alias = tmp_path / "allowed-alias" workspace.mkdir() allowed.mkdir() - os.symlink(allowed, grant_alias, target_is_directory=True) + _symlink_or_skip(allowed, grant_alias, target_is_directory=True) target = allowed / "cache.db" grant = SandboxPathGrant(path=str(grant_alias), read_only=True) policy = WorkspacePathPolicy( @@ -767,7 +781,7 @@ def test_host_io_rejects_extra_path_grant_symlink_to_root(tmp_path: Path) -> Non workspace = tmp_path / "workspace" root_alias = tmp_path / "root-alias" workspace.mkdir() - os.symlink(Path("/"), root_alias, target_is_directory=True) + _symlink_or_skip(Path("/"), root_alias, target_is_directory=True) policy = WorkspacePathPolicy( root=workspace, extra_path_grants=(SandboxPathGrant(path=str(root_alias)),), @@ -781,7 +795,7 @@ def test_host_io_rejects_extra_path_grant_symlink_to_root(tmp_path: Path) -> Non def test_host_path_grant_rejects_symlink_to_root(tmp_path: Path) -> None: root_alias = tmp_path / "root-alias" - os.symlink(Path("/"), root_alias, target_is_directory=True) + _symlink_or_skip(Path("/"), root_alias, target_is_directory=True) grant = SandboxPathGrant(path="/mnt/shared-data", host_path=str(root_alias)) with pytest.raises( @@ -795,11 +809,11 @@ def test_host_path_grant_returns_validated_resolved_source(tmp_path: Path) -> No source = tmp_path / "source" source.mkdir() source_alias = tmp_path / "source-alias" - os.symlink(source, source_alias, target_is_directory=True) + _symlink_or_skip(source, source_alias, target_is_directory=True) grant = SandboxPathGrant(path="/mnt/shared-data", host_path=str(source_alias)) resolved_source = sandbox_path_grant_host_path(grant) source_alias.unlink() - os.symlink(Path("/"), source_alias, target_is_directory=True) + _symlink_or_skip(Path("/"), source_alias, target_is_directory=True) assert resolved_source == source.resolve() From 020e5ab492dcdd9bc407fc9bf905954613efe338 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:33:28 +0800 Subject: [PATCH 458/473] fix(voice): wake stream when producer is cancelled (#4825) --- src/agents/voice/pipeline.py | 120 +++++++----- src/agents/voice/result.py | 94 ++++++++-- tests/voice/test_pipeline.py | 344 +++++++++++++++++++++++++++++++++++ 3 files changed, 494 insertions(+), 64 deletions(-) diff --git a/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py index 699543b4f8..f7db02a80f 100644 --- a/src/agents/voice/pipeline.py +++ b/src/agents/voice/pipeline.py @@ -112,6 +112,9 @@ async def stream_events(): await output._add_text(text_event) await output._turn_done() await output._done() + except asyncio.CancelledError: + await output._cancel() + raise except Exception as e: log_model_and_tool_action_error(logger, "Error processing single voice turn", e) await output._add_error(e) @@ -134,64 +137,81 @@ async def process_turns(): disabled=self.config.tracing_disabled, ): transcription_session = None - reported_error = False try: + primary_exception: BaseException | None = None try: - emitted_intro = False try: - async for intro_text in self.workflow.on_start(): - await output._add_text(intro_text) - emitted_intro = True - except Exception as e: - log_model_and_tool_action_warning( - logger, "Voice workflow on_start failed", e + emitted_intro = False + try: + async for intro_text in self.workflow.on_start(): + await output._add_text(intro_text) + emitted_intro = True + except Exception as e: + log_model_and_tool_action_warning( + logger, "Voice workflow on_start failed", e + ) + + if emitted_intro: + # Finalize the intro turn as part of startup. Leaving it open would + # hold a greeting with no sentence-final punctuation until the + # session ends, or merge it into the first user turn. + await output._turn_done() + + transcription_session = await self._get_stt_model().create_session( + audio_input, + self.config.stt_settings, + self.config.trace_include_sensitive_data, + self.config.trace_include_sensitive_audio_data, ) - if emitted_intro: - # Finalize the intro turn as part of startup. Leaving it open would - # hold a greeting with no sentence-final punctuation until the session - # ends, or merge it into the first user turn. - await output._turn_done() - - transcription_session = await self._get_stt_model().create_session( - audio_input, - self.config.stt_settings, - self.config.trace_include_sensitive_data, - self.config.trace_include_sensitive_audio_data, - ) - - async for input_text in transcription_session.transcribe_turns(): - result = self.workflow.run(input_text) - async for text_event in result: - await output._add_text(text_event) - await output._turn_done() - except Exception as e: - # Report before closing the session below. A `close()` that also fails - # would otherwise replace this exception on its way out and the consumer - # would see only the cleanup error. - log_model_and_tool_action_error(logger, "Error processing voice turns", e) - await output._add_error(e) - reported_error = True - raise - finally: - if transcription_session is not None: - try: - await transcription_session.close() + async for input_text in transcription_session.transcribe_turns(): + result = self.workflow.run(input_text) + async for text_event in result: + await output._add_text(text_event) + await output._turn_done() + except asyncio.CancelledError as e: + primary_exception = e except Exception as e: + # Report before closing the session below. A close failure must not + # replace this primary turn error. log_model_and_tool_action_error( - logger, "Error closing voice transcription session", e + logger, "Error processing voice turns", e + ) + await output._add_error(e) + primary_exception = e + finally: + if transcription_session is not None: + close_future = asyncio.gather( + transcription_session.close(), return_exceptions=True ) - # Report only if nothing else has, which keeps the turn error's - # precedence. Clean runs and cancelled producers both arrive here - # with no terminal event queued and no other way to be released. - if not reported_error: - await output._add_error(e) - raise - - # Only a clean run reaches here. The error path above has already queued its - # terminal event, and a cancelled producer has no consumer left to serve, so - # neither should start TTS work or wait on it. - await output._done() + while True: + try: + close_result = (await asyncio.shield(close_future))[0] + break + except asyncio.CancelledError as e: + if primary_exception is None: + primary_exception = e + + if isinstance(close_result, asyncio.CancelledError): + if primary_exception is None: + primary_exception = close_result + elif isinstance(close_result, Exception): + log_model_and_tool_action_error( + logger, + "Error closing voice transcription session", + close_result, + ) + if primary_exception is None: + await output._add_error(close_result) + primary_exception = close_result + + if primary_exception is not None: + raise primary_exception + + await output._done() + except asyncio.CancelledError: + await output._cancel() + raise output._set_task(asyncio.create_task(process_turns())) return output diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index a01f7d762c..f6ed12ab7d 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -3,7 +3,7 @@ import asyncio import base64 from collections import deque -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable from typing import Any from ..exceptions import UserError @@ -72,6 +72,7 @@ def __init__( self._first_byte_received = False self._generation_start_time: str | None = None self._completed_session = False + self._terminal_event_enqueued = False self._stored_exception: BaseException | None = None self._tracing_span: Span[SpeechGroupSpanData] | None = None @@ -96,6 +97,22 @@ def _enqueue_audio_segment(self, local_queue: asyncio.Queue[VoiceStreamEvent | N self._ordered_tasks.append(local_queue) self._dispatcher_event.set() + def _create_audio_task( + self, + text: str, + local_queue: asyncio.Queue[VoiceStreamEvent | None], + finish_turn: bool = False, + ) -> None: + task = asyncio.create_task(self._stream_audio(text, local_queue, finish_turn)) + self._tasks.append(task) + + # A task cancelled before its coroutine starts cannot run _stream_audio's body. Complete + # its queue from the done callback so the ordered dispatcher can always advance. + def release_queue(_: asyncio.Task[Any]) -> None: + local_queue.put_nowait(None) + + task.add_done_callback(release_queue) + def _transform_audio_buffer( self, buffer: list[bytes], output_dtype: npt.DTypeLike ) -> npt.NDArray[np.int16 | np.float32]: @@ -226,9 +243,7 @@ async def _add_text(self, text: str): if combined_sentences: local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() self._enqueue_audio_segment(local_queue) - self._tasks.append( - asyncio.create_task(self._stream_audio(combined_sentences, local_queue)) - ) + self._create_audio_task(combined_sentences, local_queue) if self._dispatcher_task is None: self._dispatcher_task = asyncio.create_task(self._dispatch_audio()) @@ -236,11 +251,7 @@ async def _turn_done(self): if self._text_buffer: local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() self._enqueue_audio_segment(local_queue) - self._tasks.append( - asyncio.create_task( - self._stream_audio(self._text_buffer, local_queue, finish_turn=True) - ) - ) + self._create_audio_task(self._text_buffer, local_queue, finish_turn=True) self._text_buffer = "" elif self._started_processing_turn: local_queue = asyncio.Queue() @@ -273,6 +284,44 @@ async def _done(self): self._dispatcher_task = asyncio.create_task(self._dispatch_audio()) await self._wait_for_completion() + async def _cancel(self) -> None: + """Cancel synthesis while preserving ordered terminal delivery.""" + self._completed_session = True + self._dispatcher_event.set() + + audio_tasks = [ + task for task in self._tasks if task is not self._dispatcher_task and not task.done() + ] + for task in audio_tasks: + task.cancel() + + try: + if audio_tasks: + await self._await_cleanup(asyncio.gather(*audio_tasks, return_exceptions=True)) + + # A cancellation delivered through _wait_for_completion() may already have cancelled + # its dispatcher. Wait for that task to settle, then replace it if it did not publish + # the terminal event. + while not self._terminal_event_enqueued: + dispatcher = self._dispatcher_task + if dispatcher is None or dispatcher.done(): + dispatcher = asyncio.create_task(self._dispatch_audio()) + self._dispatcher_task = dispatcher + await self._await_cleanup(asyncio.gather(dispatcher, return_exceptions=True)) + finally: + # The producer still owns the enclosing trace while cancellation cleanup runs. + self._finish_turn() + + async def _await_cleanup(self, awaitable: Awaitable[Any]) -> None: + """Wait for one cleanup future through repeated cancellation.""" + cleanup_task = asyncio.ensure_future(awaitable) + while True: + try: + await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + continue + return + async def _dispatch_audio(self): # Dispatch audio chunks from each segment in the order they were added while True: @@ -296,8 +345,10 @@ async def _dispatch_audio(self): self._finish_turn() break if chunk.event == "session_ended": + self._terminal_event_enqueued = True return await self._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + self._terminal_event_enqueued = True async def _wait_for_completion(self): tasks: list[asyncio.Task[Any]] = self._tasks @@ -367,6 +418,7 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: primary_exception = exc raise finally: + consumer_finalization_exception: BaseException | None = None producer_exception: BaseException | None = None cleanup_exception: BaseException | None = None @@ -379,9 +431,17 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: # that session down mid-close. if saw_terminal_event and self.text_generation_task is not None: try: - await asyncio.shield(self.text_generation_task) + # asyncio.wait() completes without propagating the producer outcome. A + # cancellation raised by this shield therefore belongs to the consumer on + # every supported Python version. + await asyncio.shield(asyncio.wait((self.text_generation_task,))) except BaseException as exc: - producer_exception = exc + consumer_finalization_exception = exc + else: + try: + producer_exception = self.text_generation_task.exception() + except asyncio.CancelledError as exc: + producer_exception = exc try: await self._cleanup_tasks() @@ -397,14 +457,16 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: exception_to_raise: BaseException | None = None if isinstance(primary_exception, asyncio.CancelledError): pass - elif isinstance(producer_exception, asyncio.CancelledError): - exception_to_raise = producer_exception + elif isinstance(consumer_finalization_exception, asyncio.CancelledError): + exception_to_raise = consumer_finalization_exception elif isinstance(cleanup_exception, asyncio.CancelledError): exception_to_raise = cleanup_exception elif preserve_primary_exception: pass elif producer_exception is not None: exception_to_raise = producer_exception + elif consumer_finalization_exception is not None: + exception_to_raise = consumer_finalization_exception elif cleanup_exception is not None: exception_to_raise = cleanup_exception @@ -416,7 +478,11 @@ async def stream(self) -> AsyncIterator[VoiceStreamEvent]: and not isinstance(exc, asyncio.CancelledError) and exc is not exception_to_raise and exc is not primary_exception - for exc in (producer_exception, cleanup_exception) + for exc in ( + consumer_finalization_exception, + producer_exception, + cleanup_exception, + ) ) if finalization_exception_was_suppressed: try: diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 02b825376d..166d9167a6 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -138,6 +138,107 @@ async def produce_events() -> None: assert producer.cancelled() +@pytest.mark.asyncio +async def test_streamed_audio_result_preserves_consumer_cancel_during_producer_wait() -> None: + result = StreamedAudioResult( + ZeroPcmTTSModel(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + producer_release = asyncio.Event() + + async def produce_events() -> None: + await producer_release.wait() + + producer = asyncio.create_task(produce_events()) + result._set_task(producer) + await result._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + + stream = cast(AsyncGenerator[VoiceStreamEvent, None], result.stream()) + event = await anext(stream) + assert isinstance(event, VoiceStreamEventLifecycle) + assert event.event == "session_ended" + + observed: list[asyncio.CancelledError] = [] + + async def close_stream() -> None: + try: + await stream.aclose() + except asyncio.CancelledError as exc: + observed.append(exc) + raise + + close_task = asyncio.create_task(close_stream()) + await asyncio.sleep(0) + producer.cancel("producer cancellation") + close_task.cancel("consumer cancellation") + + try: + await asyncio.gather(close_task, return_exceptions=True) + assert observed and observed[0].args == ("consumer cancellation",) + finally: + producer_release.set() + if not producer.done(): + producer.cancel() + await asyncio.gather(producer, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_streamed_audio_result_preserves_consumer_cancel_during_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + result = StreamedAudioResult( + ZeroPcmTTSModel(), + TTSModelSettings(), + VoicePipelineConfig(), + ) + + async def cancel_producer() -> None: + raise asyncio.CancelledError("producer cancellation") + + producer = asyncio.create_task(cancel_producer()) + result._set_task(producer) + await asyncio.gather(producer, return_exceptions=True) + await result._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + + stream = cast(AsyncGenerator[VoiceStreamEvent, None], result.stream()) + event = await anext(stream) + assert isinstance(event, VoiceStreamEventLifecycle) + assert event.event == "session_ended" + + cleanup_entered = asyncio.Event() + cleanup_cancellations: list[asyncio.CancelledError] = [] + original_cleanup = result._cleanup_tasks + + async def gated_cleanup() -> None: + cleanup_entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError as exc: + cleanup_cancellations.append(exc) + await original_cleanup() + raise + + monkeypatch.setattr(result, "_cleanup_tasks", gated_cleanup) + observed: list[asyncio.CancelledError] = [] + + async def close_stream() -> None: + try: + await stream.aclose() + except asyncio.CancelledError as exc: + observed.append(exc) + raise + + close_task = asyncio.create_task(close_stream()) + await cleanup_entered.wait() + close_task.cancel("consumer cancellation") + await asyncio.gather(close_task, return_exceptions=True) + + assert observed and cleanup_cancellations + assert observed[0] is cleanup_cancellations[0] + assert observed[0].args == ("consumer cancellation",) + + @pytest.mark.asyncio async def test_streamed_audio_result_preserves_cancellation_when_cleanup_fails( monkeypatch, @@ -1195,6 +1296,249 @@ async def create_session(self, *args: Any, **kwargs: Any) -> FailingCloseSession assert exc_info.value is close_error +@pytest.mark.asyncio +async def test_voicepipeline_producer_cancellation_releases_the_consumer() -> None: + session_started = asyncio.Event() + session_closed = asyncio.Event() + + class CancellingSession(QueuedTranscriptionSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + session_started.set() + raise asyncio.CancelledError("provider cancelled") + yield "" # pragma: no cover + + async def close(self) -> None: + session_closed.set() + + class CancellingSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> CancellingSession: + del args, kwargs + return CancellingSession() + + pipeline = VoicePipeline( + workflow=QueuedVoiceWorkflow(), + stt_model=CancellingSTT([]), + tts_model=ZeroPcmTTSModel(), + ) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) + events: list[str] = [] + + async def consume() -> None: + async for event in result.stream(): + if isinstance(event, VoiceStreamEventLifecycle): + events.append(event.event) + + await asyncio.wait_for(session_started.wait(), timeout=5) + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(consume(), timeout=5) + + assert events == ["session_ended"] + assert session_closed.is_set() + producer = result.text_generation_task + assert producer is not None and producer.cancelled() + + +@pytest.mark.asyncio +async def test_voicepipeline_single_turn_cancellation_releases_the_consumer() -> None: + transcription_started = asyncio.Event() + + class BlockingSTT(QueuedSTTModel): + async def transcribe(self, *args: Any, **kwargs: Any) -> str: + del args, kwargs + transcription_started.set() + await asyncio.Event().wait() + raise AssertionError("Unreachable") + + pipeline = VoicePipeline( + workflow=QueuedVoiceWorkflow([["unused"]]), + stt_model=BlockingSTT([]), + tts_model=ZeroPcmTTSModel(), + ) + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + producer = result.text_generation_task + assert producer is not None + consumer = asyncio.create_task(extract_events(result)) + + await asyncio.wait_for(transcription_started.wait(), timeout=5) + producer.cancel("single-turn provider cancelled") + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(consumer, timeout=5) + assert producer.cancelled() + + +@pytest.mark.asyncio +async def test_voicepipeline_cancellation_releases_synthesis_queue_before_task_starts() -> None: + fake_tts = ZeroPcmTTSModel() + + def split_immediately(text: str) -> tuple[str, str]: + return text, "" + + class CancellingWorkflow(QueuedVoiceWorkflow): + async def run(self, _: str) -> AsyncIterator[str]: + yield "complete" + raise asyncio.CancelledError("workflow cancelled") + yield "" # pragma: no cover + + pipeline = VoicePipeline( + workflow=CancellingWorkflow(), + stt_model=QueuedSTTModel(["first"]), + tts_model=fake_tts, + config=VoicePipelineConfig(tts_settings=TTSModelSettings(text_splitter=split_immediately)), + ) + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + events: list[str] = [] + + async def consume() -> None: + async for event in result.stream(): + if isinstance(event, VoiceStreamEventLifecycle): + events.append(event.event) + elif isinstance(event, VoiceStreamEventAudio): + events.append("audio") + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(consume(), timeout=5) + + assert events == ["turn_started", "session_ended"] + assert fake_tts.calls == () + assert all(task.done() for task in result._tasks) + + +@pytest.mark.asyncio +async def test_voicepipeline_cancellation_preserves_ordered_output_and_trace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_started = asyncio.Event() + session_closed = asyncio.Event() + release_dispatcher = asyncio.Event() + + class CancellingSession(QueuedTranscriptionSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + session_started.set() + raise asyncio.CancelledError("provider cancelled") + yield "" # pragma: no cover + + async def close(self) -> None: + session_closed.set() + + class CancellingSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> CancellingSession: + del args, kwargs + return CancellingSession() + + class GreetingWorkflow(QueuedVoiceWorkflow): + async def on_start(self) -> AsyncIterator[str]: + yield "Hello there" + + pipeline = VoicePipeline( + workflow=GreetingWorkflow(), + stt_model=CancellingSTT([]), + tts_model=_RecordingTTS(), + ) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) + original_dispatch_audio = result._dispatch_audio + + async def delayed_dispatch_audio() -> None: + await release_dispatcher.wait() + await original_dispatch_audio() + + monkeypatch.setattr(result, "_dispatch_audio", delayed_dispatch_audio) + await asyncio.wait_for(session_started.wait(), timeout=5) + events: list[str] = [] + + async def consume() -> None: + async for event in result.stream(): + if isinstance(event, VoiceStreamEventLifecycle): + events.append(event.event) + elif isinstance(event, VoiceStreamEventAudio): + events.append("audio") + + consumer = asyncio.create_task(consume()) + await asyncio.sleep(0) + assert not consumer.done() + release_dispatcher.set() + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(consumer, timeout=5) + + assert events == ["turn_started", "audio", "turn_ended", "session_ended"] + assert session_closed.is_set() + assert fetch_events()[-2:] == ["span_end", "trace_end"] + + +@pytest.mark.asyncio +async def test_voicepipeline_cancellation_during_session_close_completes_cleanup() -> None: + close_started = asyncio.Event() + close_completed = asyncio.Event() + release_close = asyncio.Event() + + class BlockingCloseSession(QueuedTranscriptionSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + if False: + yield "" + + async def close(self) -> None: + close_started.set() + await release_close.wait() + close_completed.set() + + class BlockingCloseSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> BlockingCloseSession: + del args, kwargs + return BlockingCloseSession() + + pipeline = VoicePipeline( + workflow=QueuedVoiceWorkflow(), + stt_model=BlockingCloseSTT([]), + tts_model=ZeroPcmTTSModel(), + ) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) + producer = result.text_generation_task + assert producer is not None + consumer = asyncio.create_task(extract_events(result)) + + await asyncio.wait_for(close_started.wait(), timeout=5) + producer.cancel("provider cancelled during close") + await asyncio.sleep(0) + producer.cancel("second cancellation during close") + await asyncio.sleep(0) + assert not close_completed.is_set() + assert not consumer.done() + release_close.set() + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(consumer, timeout=5) + assert close_completed.is_set() + + +@pytest.mark.asyncio +async def test_voicepipeline_cancellation_keeps_primary_error_when_close_fails() -> None: + close_error = RuntimeError("close failed") + + class CancellingSession(QueuedTranscriptionSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + raise asyncio.CancelledError("provider cancelled") + yield "" # pragma: no cover + + async def close(self) -> None: + raise close_error + + class CancellingSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> CancellingSession: + del args, kwargs + return CancellingSession() + + pipeline = VoicePipeline( + workflow=QueuedVoiceWorkflow(), + stt_model=CancellingSTT([]), + tts_model=ZeroPcmTTSModel(), + ) + result = await pipeline.run(await StreamedAudioInputFactory.get(count=1)) + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(extract_events(result), timeout=5) + + @pytest.mark.asyncio async def test_voicepipeline_cancelled_consumer_closes_the_session_without_further_tts() -> None: # Cancelling the consumer tears down the producer. The transcription session still has to be From a49535af7cc988b45b88aead13ee7c65f12f3975 Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:34:25 +0900 Subject: [PATCH 459/473] fix(extensions): skip AdvancedSQLiteSession usage store when the branch has no turn (#4822) --- .../memory/advanced_sqlite_session.py | 20 +++++++--- .../memory/test_advanced_sqlite_session.py | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index dbb8767f47..e629285953 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -1895,16 +1895,24 @@ async def _update_turn_usage_internal( branch_id: The branch the turn was read from. Defaults to the current branch when not provided. turn_anchor: The id of the turn's first ``message_structure`` row, - captured when the turn was read. When provided, the write is - skipped unless that exact row still exists for the given - branch/turn, so usage is never recorded against a turn that was - removed — even if a new turn reused the same numeric id. Because - the check is scoped to this branch/turn, unrelated removals (e.g. - delete_branch on another branch) do not drop this write. + captured when the turn was read. The write is skipped unless that + exact row still exists for the given branch/turn, so usage is + never recorded against a turn that was removed — even if a new + turn reused the same numeric id. Because the check is scoped to + this branch/turn, unrelated removals (e.g. delete_branch on + another branch) do not drop this write. ``None`` means the branch + had no turn when it was read, so there is nothing to attribute + the usage to and the write is skipped. """ target_branch = branch_id if branch_id is not None else self._current_branch_id + if turn_anchor is None: + # ``_capture_current_turn`` returns no anchor only when the branch has no + # turn rows; recording usage would invent a phantom turn 0. + self._logger.debug("Skipping usage store: no current turn on branch %r", target_branch) + return + def _update_sync(): """Synchronous helper to update turn usage data.""" with self._write_connection() as conn: diff --git a/tests/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 5383e553fb..d8c896487f 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -3442,6 +3442,46 @@ async def test_store_run_usage_survives_unrelated_branch_deletion(usage_data: Us session.close() +async def test_store_run_usage_skips_when_current_branch_has_no_turn(usage_data: Usage): + """A branch without any turn rows has no turn to attribute a run's usage to, so + store_run_usage skips the write instead of recording a phantom turn 0. + """ + session = AdvancedSQLiteSession(session_id="usage_no_turn_test", create_tables=True) + + try: + # A fresh session has no turn on the current branch. + await session.store_run_usage(create_mock_run_result(usage_data)) + assert await session.get_session_usage() is None + assert await session.get_turn_usage() == [] + + # A branch whose only turn was popped away has no turn either. + await session.add_items( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + ] + ) + await session.pop_item() + await session.pop_item() + await session.store_run_usage(create_mock_run_result(usage_data)) + assert await session.get_session_usage() is None + assert _count_rows(session, "turn_usage") == 0 + + # Once a real turn exists, usage is recorded against it. + await session.add_items([{"role": "user", "content": "u2"}]) + second_usage = Usage(requests=2, input_tokens=20, output_tokens=5, total_tokens=25) + await session.store_run_usage(create_mock_run_result(second_usage)) + session_usage = await session.get_session_usage() + assert session_usage is not None + assert session_usage["requests"] == 2 + assert session_usage["total_turns"] == 1 + turn_usage = await session.get_turn_usage() + assert isinstance(turn_usage, list) + assert [row["user_turn_number"] for row in turn_usage] == [1] + finally: + session.close() + + async def test_clear_session_resets_current_branch_to_main(): """Regression: clear_session must reset the in-memory branch pointer to 'main' (inside the locked operation) since every branch was removed. From 0567c1ba91ec8bc38ab6d4f815767c8cf01c015a Mon Sep 17 00:00:00 2001 From: Nikhil's <130584896+Nikhils-G@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:10:37 +0530 Subject: [PATCH 460/473] fix(voice): accept every NumPy spelling of a supported TTS dtype (#4778) --- src/agents/voice/result.py | 14 ++++- tests/voice/test_pipeline.py | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index f6ed12ab7d..24832ae3a2 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -123,9 +123,19 @@ def _transform_audio_buffer( np_array = np.frombuffer(combined_buffer, dtype=np.int16) - if output_dtype == np.int16: + # Resolve the configured dtype the way NumPy does so that every spelling of a supported + # dtype is accepted, including the strings that dictionary-based settings carry. NumPy + # reports an unparseable dtype as either TypeError or ValueError. Both are answered with + # the SDK-owned error, keeping the NumPy cause attached because it names the spelling + # that failed to parse. + try: + resolved_dtype = np.dtype(output_dtype) + except (TypeError, ValueError) as error: + raise UserError("Invalid output dtype") from error + + if resolved_dtype == np.int16: return np_array - elif output_dtype == np.float32: + elif resolved_dtype == np.float32: return (np_array.astype(np.float32) / 32767.0).reshape(-1, 1) else: raise UserError("Invalid output dtype") diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 166d9167a6..eaf0e816dd 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -15,6 +15,7 @@ import agents._debug as _debug from agents import trace +from agents.exceptions import UserError from tests.testing_processor import fetch_events, fetch_ordered_spans, fetch_span_errors try: @@ -1669,6 +1670,105 @@ async def test_voicepipeline_float32() -> None: await fake_tts.verify_audio("out_1", audio_chunks[0], dtype=np.float32) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("dtype_spelling", "expected_dtype"), + [ + ("float32", np.float32), + ("int16", np.int16), + ("f4", np.float32), + (np.dtype("float32"), np.float32), + ], + ids=["float32-string", "int16-string", "alias-spelling", "already-supported-spelling"], +) +async def test_voicepipeline_accepts_numpy_dtype_spellings( + dtype_spelling: npt.DTypeLike, expected_dtype: type[np.int16] | type[np.float32] +) -> None: + """Dictionary settings carry ``dtype`` as the spelling NumPy resolves, not the type object. + + The string cases are the ones that fail before this change, and the alias holds the + property the fix rests on: the value is resolved the way NumPy resolves it rather than + matched against a fixed set of names. The resolved-dtype case is a pin on the spelling + that already worked rather than new coverage, since every accepted spelling now resolves + to the same dtype and takes the same branch. + """ + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["out_1"]]) + fake_tts = ZeroPcmTTSModel() + pipeline = VoicePipeline( + workflow=workflow, + stt_model=fake_stt, + tts_model=fake_tts, + config={"tts_settings": {"buffer_size": 1, "dtype": dtype_spelling}}, + ) + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + + events: list[str] = [] + audio_dtypes: list[np.dtype[Any]] = [] + async for event in result.stream(): + if isinstance(event, VoiceStreamEventAudio): + assert event.data is not None + audio_dtypes.append(event.data.dtype) + events.append("audio") + elif isinstance(event, VoiceStreamEventLifecycle): + events.append(event.event) + + assert events == ["turn_started", "audio", "turn_ended", "session_ended"] + assert audio_dtypes == [np.dtype(expected_dtype)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("dtype_spelling", "expected_cause"), + [ + ("int32", None), + ({"names": ["x"], "formats": []}, ValueError), + ("not-a-dtype", TypeError), + (np.dtype(np.int16).newbyteorder("S"), None), + ], + ids=[ + "resolvable-but-unsupported", + "unresolvable-structured-dtype", + "unparseable-string", + "non-native-byte-order", + ], +) +async def test_voicepipeline_rejects_unsupported_output_dtype( + dtype_spelling: npt.DTypeLike, expected_cause: type[Exception] | None +) -> None: + """An unsupported dtype keeps the SDK error, whether or not NumPy can parse it. + + A non-native byte order is rejected on purpose. The emitted samples are read from the + PCM stream in native order, so honoring a byte-swapped request would need the samples + converted rather than relabeled, and returning them as they are would hand back + different values than the caller asked to read. That case is swapped from the running + host's own order so the expectation holds on a big-endian machine too. + + A value NumPy cannot parse keeps the parse failure attached as the cause, since it names + the spelling that failed. A value NumPy resolves to an unsupported dtype has no cause, + because nothing was raised on the way to rejecting it. + """ + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["out_1"]]) + fake_tts = ZeroPcmTTSModel() + pipeline = VoicePipeline( + workflow=workflow, + stt_model=fake_stt, + tts_model=fake_tts, + config={"tts_settings": {"buffer_size": 1, "dtype": dtype_spelling}}, + ) + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + + with pytest.raises(UserError, match="Invalid output dtype") as raised: + async for _ in result.stream(): + pass + + if expected_cause is None: + assert raised.value.__cause__ is None + else: + assert isinstance(raised.value.__cause__, expected_cause) + + @pytest.mark.asyncio async def test_voicepipeline_transform_data() -> None: # Single turn. Should produce a single audio output, which is the TTS output for "out_1". From b4e60202d1696b5976408705b58c9d506ba23fb0 Mon Sep 17 00:00:00 2001 From: Mohammed Alkindi Date: Mon, 7 Sep 2026 18:41:01 +0400 Subject: [PATCH 461/473] fix: propagate the serial test runner's exit code on Windows (#4771) --- .github/scripts/run_serial_tests.py | 10 +++++- tests/test_run_serial_tests.py | 55 ++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/.github/scripts/run_serial_tests.py b/.github/scripts/run_serial_tests.py index 9cbcaea2fe..f09f9c3302 100644 --- a/.github/scripts/run_serial_tests.py +++ b/.github/scripts/run_serial_tests.py @@ -3,6 +3,7 @@ import argparse import fnmatch import os +import subprocess import sys from pathlib import Path @@ -46,7 +47,14 @@ def main() -> None: marker_expression = ( "serial and not review_optional" if args.exclude_review_optional else "serial" ) - os.execv(sys.executable, _serial_args(marker_expression=marker_expression)) + command = _serial_args(marker_expression=marker_expression) + if sys.platform == "win32": + # Windows has no process replacement: os.execv spawns a child and + # terminates this process with 0, so the caller sees success no matter + # how pytest exits. Mirror run_integration_tests.py and wait instead. + completed = subprocess.run(command, check=False) + raise SystemExit(completed.returncode) + os.execv(sys.executable, command) if __name__ == "__main__": diff --git a/tests/test_run_serial_tests.py b/tests/test_run_serial_tests.py index 81b1c1241c..36b3c51ff6 100644 --- a/tests/test_run_serial_tests.py +++ b/tests/test_run_serial_tests.py @@ -1,9 +1,10 @@ from __future__ import annotations import importlib.util +import subprocess import sys from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace import pytest @@ -96,3 +97,55 @@ def test_serial_command_targets_only_discovered_files( "-m", "serial", ] + + +def test_windows_runner_uses_subprocess_and_propagates_exit_code( + serial_test_runner: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: list[tuple[list[str], bool]] = [] + + def capture_run(command: list[str], *, check: bool) -> SimpleNamespace: + captured.append((command, check)) + return SimpleNamespace(returncode=23) + + monkeypatch.setattr(serial_test_runner.sys, "platform", "win32") + monkeypatch.setattr(subprocess, "run", capture_run) + monkeypatch.setattr(serial_test_runner.os, "chdir", lambda _path: None) + monkeypatch.setattr( + serial_test_runner.os, + "execv", + lambda *_args: pytest.fail("Windows serial runner must not call os.execv"), + ) + monkeypatch.setattr(sys, "argv", ["run_serial_tests.py"]) + + with pytest.raises(SystemExit) as exc_info: + serial_test_runner.main() + + assert exc_info.value.code == 23 + assert len(captured) == 1 + assert captured[0][1] is False + + +def test_non_windows_runner_still_execs( + serial_test_runner: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: list[list[str]] = [] + + monkeypatch.setattr(serial_test_runner.sys, "platform", "linux") + monkeypatch.setattr(serial_test_runner.os, "chdir", lambda _path: None) + monkeypatch.setattr( + subprocess, + "run", + lambda *_args, **_kwargs: pytest.fail("POSIX serial runner must not call subprocess.run"), + ) + monkeypatch.setattr( + serial_test_runner.os, "execv", lambda _executable, command: captured.append(command) + ) + monkeypatch.setattr(sys, "argv", ["run_serial_tests.py"]) + + serial_test_runner.main() + + assert len(captured) == 1 + assert "serial" in captured[0] From fe835728f298c7809ff37c5f2da7a5f408d3c112 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:42:15 +0800 Subject: [PATCH 462/473] fix(sandbox): clean up PTY startup cancellation (#4750) --- .../extensions/sandbox/blaxel/sandbox.py | 12 +- src/agents/sandbox/sandboxes/unix_local.py | 2 +- src/agents/sandbox/session/pty_types.py | 29 +++- tests/extensions/sandbox/test_blaxel.py | 152 ++++++++++++++++++ tests/sandbox/test_pty_types.py | 42 +++++ tests/sandbox/test_unix_local.py | 28 ++++ 6 files changed, 261 insertions(+), 4 deletions(-) diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index aee3211fbd..a54bc47905 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -51,6 +51,7 @@ PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, PtyExecUpdate, + _settle_pty_cleanup, allocate_pty_process_id, clamp_pty_yield_time_ms, process_id_to_prune_from_meta, @@ -839,11 +840,18 @@ async def pty_exec_start( registered = True except asyncio.TimeoutError as e: if not registered: - await self._terminate_pty_entry(entry) + await _settle_pty_cleanup(self._terminate_pty_entry(entry)) raise ExecTimeoutError(command=command, timeout_s=exec_timeout, cause=e) from e + except asyncio.CancelledError as cancellation: + if not registered: + await _settle_pty_cleanup( + self._terminate_pty_entry(entry), + initial_cancellation=cancellation, + ) + raise except Exception as e: if not registered: - await self._terminate_pty_entry(entry) + await _settle_pty_cleanup(self._terminate_pty_entry(entry)) raise _blaxel_exec_transport_error(command=command, cause=e) from e if pruned is not None: diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index ea7f83e9d8..308f6038ed 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -354,7 +354,7 @@ def _preexec() -> None: env=env, preexec_fn=_preexec, ) - except Exception: + except BaseException: with suppress(OSError): os.close(primary_fd) with suppress(OSError): diff --git a/src/agents/sandbox/session/pty_types.py b/src/agents/sandbox/session/pty_types.py index 3f4dab04b0..65d8163534 100644 --- a/src/agents/sandbox/session/pty_types.py +++ b/src/agents/sandbox/session/pty_types.py @@ -1,7 +1,8 @@ from __future__ import annotations +import asyncio import random -from collections.abc import Sequence +from collections.abc import Awaitable, Sequence from dataclasses import dataclass from ..util.token_truncation import formatted_truncate_text_with_token_count @@ -18,6 +19,32 @@ PTY_PROCESS_ID_MAX_EXCLUSIVE = 100_000 +async def _settle_pty_cleanup( + cleanup: Awaitable[None], + *, + initial_cancellation: asyncio.CancelledError | None = None, +) -> None: + cleanup_task = asyncio.ensure_future(cleanup) + completion = asyncio.create_task(asyncio.wait((cleanup_task,))) + cancellation = initial_cancellation + while not completion.done(): + try: + await asyncio.shield(completion) + except asyncio.CancelledError as error: + if cancellation is None: + cancellation = error + + completion.result() + try: + cleanup_task.result() + except BaseException: + if cancellation is not None: + raise cancellation from None + raise + if cancellation is not None: + raise cancellation from None + + @dataclass(frozen=True) class PtyExecUpdate: process_id: int | None diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 3fe1d0d93a..4916a3a970 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -5,6 +5,7 @@ import json import logging import shlex +import sys import tarfile import time import uuid @@ -1745,6 +1746,157 @@ def ClientSession(self) -> _FakeHTTPSession: class TestPtyExec: + @pytest.mark.asyncio + async def test_pty_exec_start_cancellation_closes_unregistered_http_session( + self, fake_sandbox: _FakeSandboxInstance + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + + connect_started = asyncio.Event() + + class _BlockingSession: + def __init__(self) -> None: + self._closed = False + + async def ws_connect(self, url: str) -> None: + _ = url + connect_started.set() + await asyncio.Event().wait() + + async def close(self) -> None: + self._closed = True + + class _BlockingAiohttp: + WSMsgType = _FakeAiohttp.WSMsgType + + def __init__(self) -> None: + self.session: _BlockingSession | None = None + + def ClientSession(self) -> _BlockingSession: + self.session = _BlockingSession() + return self.session + + fake_aiohttp = _BlockingAiohttp() + session = _make_session(fake_sandbox) + + with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): + task = asyncio.create_task(session.pty_exec_start("echo", "hello")) + await connect_started.wait() + task.cancel("connect-cancel") + + with pytest.raises(asyncio.CancelledError) as exc_info: + await task + + if sys.version_info >= (3, 11): + assert exc_info.value.args == ("connect-cancel",) + assert task.cancelled() + + assert fake_aiohttp.session is not None + assert fake_aiohttp.session._closed + assert session._pty_sessions == {} + assert session._reserved_pty_process_ids == set() + + @pytest.mark.asyncio + async def test_pty_exec_start_preserves_cancellation_during_cleanup( + self, fake_sandbox: _FakeSandboxInstance + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + + cleanup_started = asyncio.Event() + allow_cleanup = asyncio.Event() + + class _TimeoutSession: + def __init__(self) -> None: + self._closed = False + + async def ws_connect(self, url: str) -> None: + _ = url + raise asyncio.TimeoutError() + + async def close(self) -> None: + cleanup_started.set() + await allow_cleanup.wait() + self._closed = True + + class _TimeoutAiohttp: + WSMsgType = _FakeAiohttp.WSMsgType + + def __init__(self) -> None: + self.session: _TimeoutSession | None = None + + def ClientSession(self) -> _TimeoutSession: + self.session = _TimeoutSession() + return self.session + + fake_aiohttp = _TimeoutAiohttp() + session = _make_session(fake_sandbox) + + with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): + task = asyncio.create_task(session.pty_exec_start("echo", "hello")) + await cleanup_started.wait() + task.cancel("cleanup-cancel") + allow_cleanup.set() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await task + + if sys.version_info >= (3, 11): + assert exc_info.value.args == ("cleanup-cancel",) + assert fake_aiohttp.session is not None + assert fake_aiohttp.session._closed + assert session._pty_sessions == {} + assert session._reserved_pty_process_ids == set() + + @pytest.mark.asyncio + async def test_pty_exec_start_preserves_cancellation_when_cleanup_fails( + self, fake_sandbox: _FakeSandboxInstance + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + + cleanup_started = asyncio.Event() + allow_cleanup = asyncio.Event() + + class _FailingCleanupSession: + def __init__(self) -> None: + self._closed = False + + async def ws_connect(self, url: str) -> None: + _ = url + raise asyncio.TimeoutError() + + async def close(self) -> None: + cleanup_started.set() + await allow_cleanup.wait() + self._closed = True + raise RuntimeError("synthetic cleanup failure") + + class _FailingCleanupAiohttp: + WSMsgType = _FakeAiohttp.WSMsgType + + def __init__(self) -> None: + self.session: _FailingCleanupSession | None = None + + def ClientSession(self) -> _FailingCleanupSession: + self.session = _FailingCleanupSession() + return self.session + + fake_aiohttp = _FailingCleanupAiohttp() + session = _make_session(fake_sandbox) + + with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp): + task = asyncio.create_task(session.pty_exec_start("echo", "hello")) + await cleanup_started.wait() + task.cancel() + allow_cleanup.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert fake_aiohttp.session is not None + assert fake_aiohttp.session._closed + assert session._pty_sessions == {} + assert session._reserved_pty_process_ids == set() + @pytest.mark.parametrize( ("messages", "expected_output"), [ diff --git a/tests/sandbox/test_pty_types.py b/tests/sandbox/test_pty_types.py index a8c6db2820..4b09515e65 100644 --- a/tests/sandbox/test_pty_types.py +++ b/tests/sandbox/test_pty_types.py @@ -1,8 +1,14 @@ from __future__ import annotations +import asyncio +import sys + +import pytest + from agents.sandbox.session.pty_types import ( PTY_EMPTY_YIELD_TIME_MS_MIN, PTY_YIELD_TIME_MS_MIN, + _settle_pty_cleanup, allocate_pty_process_id, clamp_pty_yield_time_ms, process_id_to_prune_from_meta, @@ -37,3 +43,39 @@ def test_process_id_to_prune_from_meta_prefers_exited_unprotected_sessions() -> meta.append((2002, 2.0, False)) assert process_id_to_prune_from_meta(meta) == 2001 + + +@pytest.mark.asyncio +async def test_settle_pty_cleanup_preserves_cancel_reason_when_cleanup_fails() -> None: + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + + async def cleanup() -> None: + cleanup_started.set() + await cleanup_release.wait() + raise RuntimeError("synthetic cleanup failure") + + task = asyncio.create_task(_settle_pty_cleanup(cleanup())) + await cleanup_started.wait() + task.cancel("route-A") + cleanup_release.set() + + with pytest.raises(asyncio.CancelledError) as exc_info: + await task + + if sys.version_info >= (3, 11): + assert exc_info.value.args == ("route-A",) + + +@pytest.mark.asyncio +async def test_settle_pty_cleanup_preserves_initial_cancel_reason_when_cleanup_fails() -> None: + async def cleanup() -> None: + raise RuntimeError("synthetic cleanup failure") + + with pytest.raises(asyncio.CancelledError) as exc_info: + await _settle_pty_cleanup( + cleanup(), + initial_cancellation=asyncio.CancelledError("startup-cancel"), + ) + + assert exc_info.value.args == ("startup-cancel",) diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index fb13be3c51..f2482be90f 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -216,6 +216,34 @@ def _unexpected_mkdtemp(*args: object, **kwargs: object) -> str: @pytest.mark.review_optional class TestUnixLocalPty: + @pytest.mark.asyncio + async def test_tty_start_cancellation_closes_open_file_descriptors( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(unix_local_module.sys, "platform", "linux") + workspace = tmp_path / "workspace" + workspace.mkdir() + session = _RecordingUnixLocalSession(workspace) + close_calls: list[int] = [] + + def openpty() -> tuple[int, int]: + return 101, 102 + + async def create_subprocess(*args: object, **kwargs: object) -> None: + _ = (args, kwargs) + raise asyncio.CancelledError() + + monkeypatch.setattr(unix_local_module.os, "openpty", openpty) + monkeypatch.setattr(unix_local_module.os, "close", close_calls.append) + monkeypatch.setattr(unix_local_module.asyncio, "create_subprocess_exec", create_subprocess) + + with pytest.raises(asyncio.CancelledError): + await session.pty_exec_start("echo", "hello", shell=False, tty=True) + + assert close_calls == [101, 102] + @pytest.mark.asyncio async def test_tty_fd_close_is_owned_without_blocking_termination( self, From 79d07ab37bab6c0db914e3661f6437c90bd1443b Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:44:10 +0900 Subject: [PATCH 463/473] fix(core): preserve Field constraints on variadic tool parameters (#4739) --- src/agents/function_schema.py | 15 +++++-- tests/test_function_schema.py | 82 +++++++++++++++++++++++++++++++++++ tests/test_function_tool.py | 42 +++++++++++++++++- 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 8860c15180..b11a76ee74 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -6,7 +6,7 @@ import re from collections.abc import Callable from dataclasses import dataclass -from typing import Annotated, Any, Literal, get_args, get_origin, get_type_hints +from typing import Annotated, Any, Literal, cast, get_args, get_origin, get_type_hints # griffelib exposes the `griffe` package at runtime but currently does not ship typing markers. from griffe import Docstring, DocstringSectionKind # type: ignore[import-untyped] @@ -433,6 +433,13 @@ def function_schema( # If a docstring param description exists, use it field_description = param_descs.get(name, None) + value_ann = ann + if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): + field_info = _extract_field_info_from_metadata(param_metadata.get(name, ())) + if field_info is not None and field_info.metadata: + # Constraints apply to each value, not the collected container or its defaults. + value_ann = Annotated[(ann, *cast(Any, field_info).metadata)] + # Handle different parameter kinds if param.kind == param.VAR_POSITIONAL: # e.g. *args: extend positional args @@ -440,7 +447,7 @@ def function_schema( # Preserve a homogeneous tuple as the type of each positional argument. args_of_tuple = get_args(ann) if len(args_of_tuple) == 2 and args_of_tuple[1] is Ellipsis: - ann = list[ann] # type: ignore + ann = list[value_ann] # type: ignore # tuple[()] parameterizes an empty tuple and reports no args, while a bare # typing.Tuple is unparameterized and carries no element type to reject. elif hasattr(ann, "__args__"): @@ -453,7 +460,7 @@ def function_schema( ann = list[Any] else: # If user wrote *args: int, treat as List[int] - ann = list[ann] # type: ignore + ann = list[value_ann] # type: ignore # Default factory to empty list fields[name] = ( @@ -467,7 +474,7 @@ def function_schema( # annotation as the value type -- mirroring the variadic-positional handling above, # where ``*args: X`` becomes ``list[X]`` (see #4655). A bare ``**kwargs`` has ``ann`` # set to ``Any`` above, yielding ``dict[str, Any]``. - ann = dict[str, ann] # type: ignore + ann = dict[str, value_ann] # type: ignore fields[name] = ( ann, diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 1d8325d1a0..a9b5a6c6b4 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -554,6 +554,88 @@ def func(**kwargs: int) -> int: assert func(*args, **kwargs) == 5 +def _var_positional_field_constraints(*scores: Annotated[int, Field(ge=0, le=10)]) -> int: + return sum(scores) + + +def _var_keyword_field_constraints(**scores: Annotated[int, Field(ge=0, le=10)]) -> int: + return sum(scores.values()) + + +@pytest.mark.parametrize( + "func, strict, container_type, value_key", + [ + (_var_positional_field_constraints, True, "array", "items"), + (_var_keyword_field_constraints, False, "object", "additionalProperties"), + ], +) +def test_variadic_field_constraints_in_schema(func, strict, container_type, value_key): + fs = function_schema(func, strict_json_schema=strict) + scores = fs.params_json_schema["properties"]["scores"] + + assert scores["type"] == container_type + assert scores[value_key] == {"type": "integer", "minimum": 0, "maximum": 10} + assert "minimum" not in scores + assert "maximum" not in scores + + +def test_variadic_field_constraints_apply_to_each_string(): + def func(*names: Annotated[str, Field(min_length=2)]) -> str: + return ",".join(names) + + fs = function_schema(func) + names = fs.params_json_schema["properties"]["names"] + assert names["items"]["minLength"] == 2 + assert "minItems" not in names + + parsed = fs.params_pydantic_model.model_validate({"names": ["ok"]}) + args, kwargs = fs.to_call_args(parsed) + assert func(*args, **kwargs) == "ok" + with pytest.raises(ValidationError): + fs.params_pydantic_model.model_validate({"names": ["ok", "x"]}) + + +def test_variadic_field_constraints_preserve_collection_defaults(): + def func( + *scores: Annotated[int, Field(default=5, alias="values", ge=0)], + **extras: Annotated[int, Field(..., alias="options", ge=0)], + ) -> int: + return sum(scores) + sum(extras.values()) + + fs = function_schema(func, strict_json_schema=False) + assert set(fs.params_json_schema["properties"]) == {"scores", "extras"} + assert not fs.params_json_schema.get("required") + for payload in ({}, {"scores": [], "extras": {}}): + parsed = fs.params_pydantic_model.model_validate(payload) + assert parsed.model_dump() == {"scores": [], "extras": {}} + args, kwargs = fs.to_call_args(parsed) + assert func(*args, **kwargs) == 0 + + +def test_variadic_field_constraints_preserve_homogeneous_tuple_values(): + def func(*pairs: Annotated[tuple[int, ...], Field(min_length=2)]) -> int: + return sum(sum(pair) for pair in pairs) + + fs = function_schema(func) + pairs = fs.params_json_schema["properties"]["pairs"] + assert pairs["items"]["minItems"] == 2 + assert pairs["items"]["items"] == {"type": "integer"} + parsed = fs.params_pydantic_model.model_validate({"pairs": [[1, 2]]}) + args, kwargs = fs.to_call_args(parsed) + assert args == [(1, 2)] + assert func(*args, **kwargs) == 3 + with pytest.raises(ValidationError): + fs.params_pydantic_model.model_validate({"pairs": [[1]]}) + + +def test_variadic_field_constraints_do_not_bypass_fixed_tuple_rejection(): + def func(*pairs: Annotated[tuple[int, str], Field(min_length=2)]) -> int: + return len(pairs) + + with pytest.raises(UserError, match=r"use tuple\[T, \.\.\.\] or list\[T\] instead"): + function_schema(func) + + def test_schema_with_mapping_raises_strict_mode_error(): """A mapping type is not allowed in strict mode. Same for dicts. Ensure we raise a UserError.""" diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index de03b2e6bc..114caa459b 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -6,10 +6,10 @@ import logging import time from collections.abc import Callable -from typing import Any, cast +from typing import Annotated, Any, cast import pytest -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing_extensions import TypedDict import agents._debug as _debug @@ -175,6 +175,44 @@ async def test_simple_function(): ) +@pytest.mark.asyncio +@pytest.mark.parametrize("keyword_values", [False, True], ids=["args", "kwargs"]) +async def test_variadic_field_constraints_validate_before_invocation(keyword_values: bool) -> None: + calls: list[int] = [] + + def positional(*scores: Annotated[int, Field(..., ge=0, le=10)]) -> int: + calls.append(sum(scores)) + return sum(scores) + + def keywords(**scores: Annotated[int, Field(..., ge=0, le=10)]) -> int: + calls.append(sum(scores.values())) + return sum(scores.values()) + + tool = function_tool( + keywords if keyword_values else positional, + strict_mode=not keyword_values, + failure_error_function=None, + ) + + async def invoke(payload: dict[str, Any]) -> Any: + arguments = json.dumps(payload) + context = ToolContext( + context=None, tool_name=tool.name, tool_call_id="1", tool_arguments=arguments + ) + return await tool.on_invoke_tool(context, arguments) + + for invalid in (-1, 11): + values = {"first": invalid} if keyword_values else [invalid] + with pytest.raises(ModelBehaviorError): + await invoke({"scores": values}) + assert calls == [] + + assert await invoke({"scores": {"a": 0, "b": 10} if keyword_values else [0, 10]}) == 10 + assert await invoke({}) == 0 + assert await invoke({"scores": {} if keyword_values else []}) == 0 + assert calls == [10, 0, 0] + + @pytest.mark.asyncio async def test_sync_function_runs_via_to_thread(monkeypatch: pytest.MonkeyPatch) -> None: calls = {"to_thread": 0, "func": 0} From e51633133e671e82df221e1f1713f7cb1cef9711 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha <148203331+Ayushraj06-bit@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:28:49 +0530 Subject: [PATCH 464/473] fix(sessions): reject resuming a run whose accepted terminal output was not persisted (#4698) --- src/agents/result.py | 3 + src/agents/run.py | 24 ++ .../run_internal/agent_runner_helpers.py | 17 ++ src/agents/run_internal/run_loop.py | 10 + src/agents/run_state.py | 20 +- tests/test_run_impl_resume_paths.py | 276 +++++++++++++++++- 6 files changed, 348 insertions(+), 2 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index 0ceb0d7187..a5340edb35 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -150,6 +150,9 @@ def _populate_state_from_result( state._pending_input = copy.deepcopy(source_state._pending_input) state._pending_session_write = copy.deepcopy(source_state._pending_session_write) state._current_step = source_state._current_step + # A streamed result exists before its terminal append does, so a checkpoint taken from a + # failed stream has to keep the fail-closed marker or it would look resumable. + state._terminal_unrecoverable = source_state._terminal_unrecoverable else: state._generated_prompt_cache_key = getattr(result, "_generated_prompt_cache_key", None) state._pending_input = copy.deepcopy(getattr(result, "_pending_input_for_state", [])) diff --git a/src/agents/run.py b/src/agents/run.py index 37dd66582e..abb72a161b 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -70,6 +70,7 @@ finalize_conversation_tracking, get_unsent_tool_call_ids_for_interrupted_state, input_guardrails_triggered, + reject_unrecoverable_terminal_state, resolve_processed_response, resolve_resumed_context, resolve_trace_settings, @@ -635,6 +636,7 @@ async def _run_impl( ) context = context_wrapper.context + reject_unrecoverable_terminal_state(run_state) await resume_pending_session_write(run_state, session, wrapper=context_wrapper) max_turns = run_state._max_turns else: @@ -1367,6 +1369,11 @@ def _mark_response_hooks_started() -> None: current_agent, run_config, ) + # The output, its guardrails, and its terminal hooks are all + # complete, so from here until the turn is persisted this run owns + # a result no resume can reproduce. + if run_state is not None: + run_state._terminal_unrecoverable = True await save_final_turn_items_after_guardrails( session=session, run_state=run_state, @@ -1377,6 +1384,10 @@ def _mark_response_hooks_started() -> None: store=store_setting, wrapper=context_wrapper, ) + # The append and any post-append maintenance both succeeded, + # so the turn is durable and the state is open again. + if run_state is not None: + run_state._terminal_unrecoverable = False current_step = getattr(run_state, "_current_step", None) approvals_from_state = approvals_from_step(current_step) result = RunResult( @@ -1550,7 +1561,16 @@ async def _save_max_turns_handler_output( include_in_history=include_in_history, ) if include_in_history and not handler_output_recorded: + # Only reachable once the handler output cleared its guardrails and + # ran its end hooks, so this append carries an accepted result like + # any other terminal one. The callback itself stays unmarked because + # finalize_max_turns_handler_output() also drives it from its + # guardrail-error path, where no output was ever accepted. + if run_state is not None: + run_state._terminal_unrecoverable = True await _save_max_turns_handler_output([synthesized_item]) + if run_state is not None: + run_state._terminal_unrecoverable = False current_step = getattr(run_state, "_current_step", None) approvals_from_state = approvals_from_step(current_step) result = RunResult( @@ -1994,6 +2014,8 @@ async def _save_max_turns_handler_output( current_agent, run_config, ) + if run_state is not None: + run_state._terminal_unrecoverable = True await save_final_turn_items_after_guardrails( session=session, run_state=run_state, @@ -2004,6 +2026,8 @@ async def _save_max_turns_handler_output( store=store_setting, wrapper=context_wrapper, ) + if run_state is not None: + run_state._terminal_unrecoverable = False # Ensure starting_input is not None and not RunState final_output_result_input: str | list[TResponseInputItem] = ( diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index be1d976724..6662f71e26 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -53,6 +53,7 @@ "build_interruption_result", "build_resumed_stream_debug_extra", "describe_run_state_step", + "reject_unrecoverable_terminal_state", "ensure_context_wrapper", "finalize_conversation_tracking", "get_unsent_tool_call_ids_for_interrupted_state", @@ -491,6 +492,22 @@ def build_interruption_result( return result +def reject_unrecoverable_terminal_state(run_state: RunState | None) -> None: + """Fail closed when a previous run already produced a final output that cannot be reproduced. + + The marker is set once that output, its guardrails, and its terminal hooks have completed, + and is cleared only once the turn is fully persisted. In between, the run owns a result no + resume can settle, so resuming would repeat the model call and the lifecycle hooks for an + output the caller already received. Raised before any Session, sandbox, model, tool, + guardrail, or hook work so the rejection has no side effects of its own. + """ + if run_state is not None and run_state._terminal_unrecoverable: + raise UserError( + "This RunState already produced a final output whose Session write did not " + "complete, so it cannot be resumed. Start a new run instead." + ) + + def append_model_response_if_new( model_responses: list[ModelResponse], response: ModelResponse, diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 3c7d9ee586..9871a54041 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -102,6 +102,7 @@ apply_resumed_conversation_settings, attach_usage_to_span, get_unsent_tool_call_ids_for_interrupted_state, + reject_unrecoverable_terminal_state, snapshot_usage, usage_delta, validate_output_guardrails_with_server_managed_conversation, @@ -628,6 +629,10 @@ async def _finalize_streamed_final_output( # Saved as one ordered batch so the session mirrors the model response. Doing it in two # halves would both reorder the turn and, because the first save advances the turn's # persisted-item count, make the second one a no-op. + # The output, its guardrails, and its terminal hooks are all complete, so from here until + # the turn is persisted this run owns a result no resume can reproduce. + if streamed_result._state is not None: + streamed_result._state._terminal_unrecoverable = True if on_persisted_after_guardrails is None: await save_items(final_turn_items, response_id, store_setting) else: @@ -640,6 +645,10 @@ async def _finalize_streamed_final_output( streamed_result.is_complete = True streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) return + # The append and any post-append maintenance both succeeded, so the turn is durable and the + # state is open again. + if streamed_result._state is not None: + streamed_result._state._terminal_unrecoverable = False streamed_result.final_output = output if on_persisted_after_guardrails is not None: @@ -923,6 +932,7 @@ async def start_streaming( streamed_result._reasoning_item_id_policy = resolved_reasoning_item_id_policy if is_resumed_state and run_state is not None: + reject_unrecoverable_terminal_state(run_state) await resume_pending_session_write(run_state, session, wrapper=context_wrapper) streamed_result._current_turn_persisted_item_count = ( run_state._current_turn_persisted_item_count diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 732e768d3d..d79e0781a4 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -227,7 +227,7 @@ def _default_run_state_validation_error( ), "1.17": ( "Persists Docker container labels and current-response generated-item ownership across " - "resume flows, including pending resumed Session writes." + "resume flows, including pending resumed Session writes and terminal-unrecoverable runs." ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -878,6 +878,13 @@ class RunState(Generic[TContext, TAgent]): _session_write_in_progress: bool = field(default=False, repr=False) """Live ownership guard; independent serialized copies require caller serialization.""" + _terminal_unrecoverable: bool = field(default=False, repr=False) + """Set once a final output, its guardrails, and its terminal hooks have all completed. + + It closes the state for the window where the run owns an accepted result that no resume can + reproduce, and it is cleared only once that turn is fully persisted. + """ + def __init__( self, context: RunContextWrapper[TContext], @@ -920,6 +927,7 @@ def __init__( self._schema_version = CURRENT_SCHEMA_VERSION self._pending_session_write = None self._session_write_in_progress = False + self._terminal_unrecoverable = False from .agent_tool_state import get_agent_tool_state_scope self._agent_tool_state_scope_id = get_agent_tool_state_scope(context) @@ -1909,6 +1917,8 @@ def to_json( result["current_turn_persisted_item_count"] = self._current_turn_persisted_item_count if self._pending_session_write is not None: result["pending_session_write"] = copy.deepcopy(self._pending_session_write) + if self._terminal_unrecoverable: + result["terminal_unrecoverable"] = True result["trace"] = self._serialize_trace_data( include_tracing_api_key=include_tracing_api_key ) @@ -4383,6 +4393,13 @@ async def _build_run_state_from_json( ): raise validation_error_factory("Run state pending Session write is invalid", UserError) state._pending_session_write = copy.deepcopy(cast(_PendingSessionWrite, pending_write)) + terminal_unrecoverable = state_json.get("terminal_unrecoverable") + if terminal_unrecoverable is not None: + # An older label never wrote this marker, so honoring one would let a snapshot claim a + # resume boundary the schema it declares does not have. + if (schema_major, schema_minor) < (1, 17) or terminal_unrecoverable is not True: + raise validation_error_factory("Run state terminal marker is invalid", UserError) + state._terminal_unrecoverable = True serialized_policy = state_json.get("reasoning_item_id_policy") if serialized_policy in {"preserve", "omit"}: state._reasoning_item_id_policy = cast(Literal["preserve", "omit"], serialized_policy) @@ -5172,6 +5189,7 @@ def _clone_original_input(original_input: str | list[Any]) -> str | list[Any]: "Run state agent not found in agent map", "Run state pending_input must be a list", "Run state pending Session write is invalid", + "Run state terminal marker is invalid", "Run state references an agent identity that is not present in the restored graph", ( "RunState context was serialized from a custom type; provide context_deserializer " diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 8e6211ceab..482a13edd8 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -9,7 +9,7 @@ from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage import agents.run as run_module -from agents import Agent, Runner, function_tool, handoff +from agents import Agent, GuardrailFunctionOutput, Runner, function_tool, handoff, output_guardrail from agents.agent import ToolsToFinalOutputResult from agents.agent_output import AgentOutputSchema from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail @@ -37,6 +37,7 @@ SingleStepResult, ) from agents.run_state import RunState +from agents.sandbox.runtime import SandboxRuntime from agents.testing import ScriptedModel from agents.tool import Tool from agents.tool_guardrails import ( @@ -65,8 +66,14 @@ def __init__(self) -> None: self.block_next_add = False self.add_started = asyncio.Event() self.release_add = asyncio.Event() + self.fail_on_output: str | None = None async def add_items(self, items: list[TResponseInputItem]) -> None: + if self.fail_on_output is not None and any( + self.fail_on_output in json.dumps(item, default=str) for item in items + ): + self.fail_on_output = None + raise self.error failure, self.failure = self.failure, None if failure == "before": raise self.error @@ -1221,3 +1228,270 @@ async def test_resumed_handoff_session_append_is_recovered_before_next_model( assert _call_pair(result.to_input_list(), "charge-1") == expected_pair assert _call_pair(result.to_input_list(), "handoff-1") == expected_pair assert "pending_session_write" not in result.to_state().to_json() + + +class _TerminalLifecycleHooks(RunHooks[Any]): + """Count the agent lifecycle hooks an application can attach its own effects to.""" + + def __init__(self) -> None: + self.starts = 0 + self.ends: list[str] = [] + + async def on_agent_start(self, context: Any, agent: Agent[Any]) -> None: + self.starts += 1 + + async def on_agent_end(self, context: Any, agent: Agent[Any], output: Any) -> None: + self.ends.append(str(output)) + + +async def _terminal_output_session_state( + streamed: bool, + session: Session | None = None, + hooks: RunHooks[Any] | None = None, +): + """Pause on an approval whose tool output becomes the terminal agent output.""" + effects: list[int] = [] + + @tool(needs_approval=True) + async def charge(amount: int) -> str: + effects.append(amount) + return "receipt-7" + + model = ScriptedModel( + [ + [get_function_tool_call("charge", '{"amount":7}', call_id="charge-1")], + [get_text_message("retry-final")], + ] + ) + agent = Agent( + name="payment", + model=model, + tools=[charge], + tool_use_behavior="stop_on_first_tool", + ) + session = session if session is not None else _FailingResumeSession() + paused = await _run_session_resume(agent, "charge 7", session, streamed, hooks=hooks) + state = paused.to_state() + state.approve(state.get_interruptions()[0]) + return agent, model, session, state, effects + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failing_streamed,retry_streamed", [(False, False), (False, True), (True, False), (True, True)] +) +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "json"]) +@pytest.mark.parametrize("failure", ["before", "after"], ids=["atomic-failure", "lost-ack"]) +async def test_terminal_session_append_failure_rejects_every_later_resume( + failing_streamed: bool, retry_streamed: bool, round_trip: bool, failure: str +) -> None: + """An accepted terminal output whose append failed is not resumable, and never replayed.""" + hooks = _TerminalLifecycleHooks() + agent, model, session, state, effects = await _terminal_output_session_state( + failing_streamed, hooks=hooks + ) + session.failure = failure + with pytest.raises(RuntimeError) as error: + await _run_session_resume(agent, state, session, failing_streamed, hooks=hooks) + assert error.value is session.error + + # The output, its guardrails, and its terminal hooks all completed exactly once. + assert effects == [7] + assert len(model.calls) == 1 + assert hooks.ends == ["receipt-7"] + starts_after_failure = hooks.starts + assert state.to_json()["terminal_unrecoverable"] is True + + if round_trip: + state = await RunState.from_json(agent, state.to_json()) + + # Every later resume fails closed, including a second one. + for _ in range(2): + with pytest.raises(UserError, match="cannot be resumed"): + await _run_session_resume(agent, state, session, retry_streamed, hooks=hooks) + assert len(model.calls) == 1 + assert effects == [7] + assert hooks.starts == starts_after_failure + assert hooks.ends == ["receipt-7"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_unrecoverable_terminal_state_rejects_before_any_resumed_work( + streamed: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + """The rejection precedes Session reconciliation and sandbox preparation.""" + agent, model, session, state, effects = await _terminal_output_session_state(streamed) + session.failure = "before" + with pytest.raises(RuntimeError, match="session append failed"): + await _run_session_resume(agent, state, session, streamed) + + async def _fail_get_items(*args: Any, **kwargs: Any) -> list[TResponseInputItem]: + raise AssertionError("Session reconciliation must not run for a rejected terminal state") + + async def _fail_prepare_agent(*args: Any, **kwargs: Any): + raise AssertionError("sandbox preparation must not run for a rejected terminal state") + + monkeypatch.setattr(type(session), "get_items", _fail_get_items) + monkeypatch.setattr(SandboxRuntime, "prepare_agent", _fail_prepare_agent) + + restored = await RunState.from_json(agent, state.to_json()) + with pytest.raises(UserError, match="cannot be resumed"): + await _run_session_resume(agent, restored, session, not streamed) + assert len(model.calls) == 1 + assert effects == [7] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_terminal_marker_is_cleared_once_the_turn_is_persisted(streamed: bool) -> None: + """A terminal turn that persists cleanly leaves a normal, unmarked result.""" + agent, model, session, state, effects = await _terminal_output_session_state(streamed) + result = await _run_session_resume(agent, state, session, streamed) + + assert result.final_output == "receipt-7" + assert effects == [7] + assert "terminal_unrecoverable" not in result.to_state().to_json() + assert _charge_pair(await session.get_items()) == ["function_call", "function_call_output"] + + +@pytest.mark.asyncio +async def test_terminal_marker_rejects_an_older_schema_label() -> None: + """The marker is only honored on the schema boundary that introduced it.""" + agent, _, session, state, _ = await _terminal_output_session_state(False) + session.failure = "before" + with pytest.raises(RuntimeError, match="session append failed"): + await _run_session_resume(agent, state, session, False) + + payload = state.to_json() + payload["$schemaVersion"] = "1.16" + with pytest.raises(UserError, match="terminal marker is invalid"): + await RunState.from_json(agent, payload) + + +@pytest.mark.asyncio +async def test_failed_stream_result_checkpoint_keeps_the_terminal_marker() -> None: + """A checkpoint taken from a failed streamed run stays closed to resumes. + + A streamed result exists before its terminal append does, so `to_state()` is reachable on the + failed attempt. If that snapshot dropped the marker it would look like an ordinary resumable + state and bypass the rejection entirely. + """ + agent, model, session, state, effects = await _terminal_output_session_state(True) + session.failure = "before" + streamed = Runner.run_streamed( + agent, state, session=session, run_config=RunConfig(tracing_disabled=True) + ) + with pytest.raises(RuntimeError, match="session append failed"): + async for _ in streamed.stream_events(): + pass + + checkpoint = streamed.to_state() + assert checkpoint.to_json()["terminal_unrecoverable"] is True + + restored = await RunState.from_json(agent, checkpoint.to_json()) + for candidate in (checkpoint, restored): + with pytest.raises(UserError, match="cannot be resumed"): + await _run_session_resume(agent, candidate, session, False) + assert len(model.calls) == 1 + assert effects == [7] + + +@pytest.mark.asyncio +async def test_max_turns_handler_output_is_marked_before_it_is_persisted() -> None: + """The max-turns fallback is a final output too, so its failed append closes the state.""" + handler_calls: list[str] = [] + hooks = _TerminalLifecycleHooks() + + @tool(needs_approval=True) + async def charge(amount: int) -> str: + return "receipt-7" + + model = ScriptedModel( + [ + [get_function_tool_call("charge", '{"amount":7}', call_id="charge-1")], + [get_text_message("unused")], + ] + ) + agent = Agent(name="payment", model=model, tools=[charge]) + session = _FailingResumeSession() + config = RunConfig(tracing_disabled=True) + + paused = await Runner.run( + agent, "charge 7", session=session, run_config=config, max_turns=1, hooks=hooks + ) + state = paused.to_state() + state.approve(state.get_interruptions()[0]) + + def _handler(_data: Any) -> str: + handler_calls.append("handled") + return "max-turns-output" + + session.fail_on_output = "max-turns-output" + with pytest.raises(RuntimeError, match="session append failed"): + await Runner.run( + agent, + state, + session=session, + run_config=config, + hooks=hooks, + error_handlers={"max_turns": _handler}, + ) + + # The handler and its end hook each ran exactly once before the append failed. + assert handler_calls == ["handled"] + assert hooks.ends == ["max-turns-output"] + assert state.to_json()["terminal_unrecoverable"] is True + + with pytest.raises(UserError, match="cannot be resumed"): + await Runner.run( + agent, + state, + session=session, + run_config=config, + hooks=hooks, + error_handlers={"max_turns": _handler}, + ) + assert handler_calls == ["handled"] + assert hooks.ends == ["max-turns-output"] + + +@pytest.mark.asyncio +async def test_max_turns_guardrail_failure_leaves_the_state_retryable() -> None: + """A handler output that never passed its guardrails must not close the state. + + `finalize_max_turns_handler_output()` drives the same save callback from its guardrail-error + path. Marking there would reject every later resume for an output the caller never received, + which is a worse outcome than the replay the marker exists to prevent. + """ + guardrail_calls: list[str] = [] + + @tool(needs_approval=True) + async def charge(amount: int) -> str: + return "receipt-7" + + @output_guardrail + async def exploding(context: Any, agent: Agent[Any], output: Any) -> GuardrailFunctionOutput: + guardrail_calls.append(str(output)) + raise RuntimeError("guardrail exploded") + + model = ScriptedModel([[get_function_tool_call("charge", '{"amount":7}', call_id="charge-1")]]) + agent = Agent(name="payment", model=model, tools=[charge], output_guardrails=[exploding]) + session = _FailingResumeSession() + config = RunConfig(tracing_disabled=True) + + paused = await Runner.run(agent, "charge 7", session=session, run_config=config, max_turns=1) + state = paused.to_state() + state.approve(state.get_interruptions()[0]) + + handlers: dict[str, Any] = {"max_turns": lambda _data: "max-turns-output"} + session.fail_on_output = "max-turns-output" + with pytest.raises(RuntimeError, match="session append failed"): + await Runner.run(agent, state, session=session, run_config=config, error_handlers=handlers) + + assert guardrail_calls == ["max-turns-output"] + assert "terminal_unrecoverable" not in state.to_json() + + # The retry reports the real guardrail failure rather than a fail-closed rejection. + with pytest.raises(RuntimeError, match="guardrail exploded"): + await Runner.run(agent, state, session=session, run_config=config, error_handlers=handlers) From 67169f64fb7bf23842b6622a3742a0da26f2321a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 7 Sep 2026 23:59:10 +0900 Subject: [PATCH 465/473] feat: support image results in web search tools (#4898) Co-authored-by: Warren --- src/agents/__init__.py | 2 + src/agents/models/openai_responses.py | 11 ++- src/agents/tool.py | 22 ++++++ tests/models/test_openai_responses.py | 74 +++++++++++++++++++ .../models/test_openai_responses_converter.py | 67 +++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 2546ecd551..cdf2a42b28 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -197,6 +197,7 @@ ToolOutputTextDict, ToolSearchTool, WebSearchTool, + WebSearchToolImageSettings, default_tool_error_function, dispose_resolved_computers, function_tool, @@ -545,6 +546,7 @@ def enable_verbose_stdout_logging() -> None: "Tool", "ToolCaller", "WebSearchTool", + "WebSearchToolImageSettings", "HostedMCPTool", "MCPToolApprovalFunction", "MCPToolApprovalRequest", diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index b9c6c75013..f860a0488b 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -2189,9 +2189,18 @@ def _convert_tool( } if tool.external_web_access is not None: web_search_tool["external_web_access"] = tool.external_web_access + if tool.search_content_types is not None: + web_search_tool["search_content_types"] = list(tool.search_content_types) + if tool.image_settings is not None: + web_search_tool["image_settings"] = dict(tool.image_settings) + web_search_include: ResponseIncludable | None = ( + "web_search_call.results" + if tool.search_content_types is not None and "image" in tool.search_content_types + else None + ) return ( _require_responses_tool_param(web_search_tool), - None, + web_search_include, ) elif isinstance(tool, FileSearchTool): file_search_tool_param: FileSearchToolParam = { diff --git a/src/agents/tool.py b/src/agents/tool.py index 18a1399cca..98d3b3fb00 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -795,6 +795,16 @@ def name(self): return "file_search" +class WebSearchToolImageSettings(TypedDict, total=False): + """Image result settings for `WebSearchTool` when `search_content_types` includes `"image"`.""" + + max_results: int + """The number of image results to return.""" + + caption: bool + """Whether to include a short caption with each image when one is available.""" + + @dataclass class WebSearchTool: """A hosted tool that lets the LLM search the web. Currently only supported with OpenAI models, @@ -817,6 +827,16 @@ class WebSearchTool: indexed-only behavior where supported. """ + search_content_types: list[Literal["text", "image"]] | None = None + """The kinds of results the search may return. + + When omitted, the API default (text only) is used. Include `"image"` to + receive image results. Use `image_settings` to customize those results. + """ + + image_settings: WebSearchToolImageSettings | None = None + """Settings for image results when `search_content_types` includes `"image"`.""" + if TYPE_CHECKING: def __init__( @@ -825,6 +845,8 @@ def __init__( filters: WebSearchToolFilters | dict[str, Any] | None = None, search_context_size: Literal["low", "medium", "high"] = "medium", external_web_access: bool | None = None, + search_content_types: list[Literal["text", "image"]] | None = None, + image_settings: WebSearchToolImageSettings | None = None, ) -> None: ... def __post_init__(self) -> None: diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index d6b787df9b..d823fc404d 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -25,6 +25,7 @@ Runner, Tool, ToolSearchTool, + WebSearchTool, __version__, function_tool, handoff, @@ -175,6 +176,79 @@ async def handler(request: httpx2.Request) -> httpx2.Response: assert [body["input"] for body in request_bodies] == [[expected_input]] +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"]) +@pytest.mark.parametrize( + "tool_options, expected_include", + [ + ({}, []), + ({"search_content_types": ["text"]}, []), + ( + { + "search_content_types": ["image", "text"], + "image_settings": {"max_results": 3, "caption": False}, + }, + ["web_search_call.results"], + ), + ], + ids=["default", "text_only", "image_and_text"], +) +async def test_web_search_image_options_reach_responses_request( + stream: bool, tool_options: dict[str, Any], expected_include: list[str] +) -> None: + """Inspect the provider wire payload, including fields not yet typed by openai-python.""" + request_bodies: list[dict[str, Any]] = [] + + async def handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(json.loads(request.content)) + if stream: + event = _response_completed_frame("resp-id", sequence_number=0) + return httpx2.Response( + 200, + content=f"event: response.completed\ndata: {event}\n\n", + headers={"content-type": "text/event-stream"}, + ) + return httpx2.Response( + 200, + content=get_response_obj([]).model_dump_json(), + headers={"content-type": "application/json"}, + ) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http_client: + model = OpenAIResponsesModel( + model="gpt-5.6", + openai_client=AsyncOpenAI(api_key="test-key", http_client=http_client), + ) + request_kwargs: dict[str, Any] = { + "system_instructions": None, + "input": "Find images of the Golden Gate Bridge.", + "model_settings": ModelSettings(), + "tools": [WebSearchTool(**tool_options)], + "output_schema": None, + "handoffs": [], + "tracing": ModelTracing.DISABLED, + } + if stream: + async for _ in model.stream_response(**request_kwargs): + pass + else: + await model.get_response(**request_kwargs) + + assert len(request_bodies) == 1 + body = request_bodies[0] + assert body["tools"] == [ + { + "type": "web_search", + "filters": None, + "user_location": None, + "search_context_size": "medium", + **tool_options, + } + ] + assert body.get("include", []) == expected_include + + class DummyWSConnection: def __init__(self, frames: list[str]): self._frames = frames diff --git a/tests/models/test_openai_responses_converter.py b/tests/models/test_openai_responses_converter.py index 42b5cb3671..9c64107892 100644 --- a/tests/models/test_openai_responses_converter.py +++ b/tests/models/test_openai_responses_converter.py @@ -30,6 +30,7 @@ from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters from pydantic import BaseModel +import agents from agents import ( Agent, AgentOutputSchema, @@ -43,6 +44,7 @@ ToolSearchTool, UserError, WebSearchTool, + WebSearchToolImageSettings, function_tool, handoff, tool_namespace, @@ -439,6 +441,8 @@ def test_convert_tools_basic_types_and_includes(): assert web_params.get("user_location") == web_tool.user_location assert web_params.get("search_context_size") == web_tool.search_context_size assert "external_web_access" not in web_params + assert "search_content_types" not in web_params + assert "image_settings" not in web_params # Verify computer tool uses the GA built-in tool payload. comp_params = next(ct for ct in converted.tools if ct["type"] == "computer") assert comp_params == {"type": "computer"} @@ -493,6 +497,69 @@ def test_convert_file_search_tool_rejects_unsupported_result_limits( Converter.convert_tools([tool], handoffs=[]) +def test_convert_tools_includes_web_search_content_types_and_image_settings() -> None: + from agents.tool import WebSearchToolImageSettings as ToolImageSettings + + assert WebSearchToolImageSettings is ToolImageSettings + assert "WebSearchToolImageSettings" in agents.__all__ + image_settings: WebSearchToolImageSettings = {"max_results": 3, "caption": True} + web_tool = WebSearchTool( + search_content_types=["text", "image"], + image_settings=image_settings, + ) + + converted = Converter.convert_tools([web_tool], handoffs=[], model="gpt-5.6") + + # Image results arrive through the web_search_call.results include. + assert converted.includes == ["web_search_call.results"] + assert converted.tools == [ + { + "type": "web_search", + "filters": None, + "user_location": None, + "search_context_size": "medium", + "search_content_types": ["text", "image"], + "image_settings": {"max_results": 3, "caption": True}, + } + ] + + +def test_convert_tools_image_only_uses_default_image_settings() -> None: + web_tool = WebSearchTool(search_content_types=["image"]) + + converted = Converter.convert_tools([web_tool], handoffs=[]) + + assert converted.includes == ["web_search_call.results"] + assert converted.tools[0].get("search_content_types") == ["image"] + assert "image_settings" not in converted.tools[0] + + +def test_web_search_tool_preserves_existing_positional_parameters() -> None: + web_tool = WebSearchTool(None, None, "high", False) + + converted = Converter.convert_tools([web_tool], handoffs=[]) + + assert converted.includes == [] + assert converted.tools == [ + { + "type": "web_search", + "filters": None, + "user_location": None, + "search_context_size": "high", + "external_web_access": False, + } + ] + + +def test_convert_tools_text_only_content_types_adds_no_include() -> None: + web_tool = WebSearchTool(search_content_types=["text"]) + + converted = Converter.convert_tools([web_tool], handoffs=[], model="gpt-5.6") + + assert converted.includes == [] + assert converted.tools[0].get("search_content_types") == ["text"] + + def test_convert_tools_includes_explicit_false_external_web_access() -> None: web_tool = WebSearchTool(external_web_access=False) From 4d6a110d6dabadea25c410538ea115e379c91fbf Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:12:40 +0900 Subject: [PATCH 466/473] docs: clarify database driver installation for SQLAlchemy sessions (#4743) --- docs/sessions/sqlalchemy_session.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/sessions/sqlalchemy_session.md b/docs/sessions/sqlalchemy_session.md index 1511d3ff0a..5fca4ecefc 100644 --- a/docs/sessions/sqlalchemy_session.md +++ b/docs/sessions/sqlalchemy_session.md @@ -4,10 +4,18 @@ ## Installation -SQLAlchemy sessions require the `sqlalchemy` optional-dependency extra from the `openai-agents` package: +SQLAlchemy sessions require the `sqlalchemy` optional-dependency extra and an async database driver that matches your database URL. + +For the SQLite examples below (`sqlite+aiosqlite://`), install `aiosqlite` alongside the extra: + +```bash +pip install 'openai-agents[sqlalchemy]' aiosqlite +``` + +The extra already includes `asyncpg` for PostgreSQL URLs that start with `postgresql+asyncpg://`. For MySQL URLs that start with `mysql+aiomysql://`, install `aiomysql` alongside the extra. The driver's `rsa` extra supplies dependencies for MySQL's SHA-256 authentication methods: ```bash -pip install openai-agents[sqlalchemy] +pip install 'openai-agents[sqlalchemy]' 'aiomysql[rsa]' ``` ## Quick start From 611b18d5fbc704f059bb541523fedad6c77c6b28 Mon Sep 17 00:00:00 2001 From: Excelius <57819425+Excelius-Wang@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:13:33 +0800 Subject: [PATCH 467/473] fix(core): close model providers created by Runner (#4785) --- src/agents/result.py | 62 +++- src/agents/run.py | 40 ++- .../run_internal/model_provider_lifecycle.py | 46 +++ tests/test_cancel_streaming.py | 20 +- tests/test_runner_model_provider_lifecycle.py | 320 ++++++++++++++++++ 5 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 src/agents/run_internal/model_provider_lifecycle.py create mode 100644 tests/test_runner_model_provider_lifecycle.py diff --git a/src/agents/result.py b/src/agents/result.py index a5340edb35..70d48fe3ef 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -696,6 +696,21 @@ class RunResultStreaming(RunResultBase): ) _sandbox_cleanup_task: asyncio.Task[None] | None = field(default=None, init=False, repr=False) _sandbox_cleanup_callback_registered: bool = field(default=False, init=False, repr=False) + _sandbox_wrapped_run_loop_task: asyncio.Task[Any] | None = field( + default=None, + init=False, + repr=False, + ) + _model_provider_cleanup: Callable[[], Awaitable[None]] | None = field( + default=None, + init=False, + repr=False, + ) + _model_provider_cleanup_task: asyncio.Task[None] | None = field( + default=None, + init=False, + repr=False, + ) def __post_init__(self, _run_impl_task: asyncio.Task[Any] | None) -> None: self._current_agent_ref = weakref.ref(self.current_agent) @@ -760,6 +775,7 @@ def ensure_sandbox_cleanup_on_completion(self) -> None: return original_task = self.run_loop_task + self._sandbox_wrapped_run_loop_task = original_task self._sandbox_cleanup_callback_registered = True original_task.add_done_callback( lambda _task: asyncio.create_task(self._run_sandbox_cleanup()) @@ -787,10 +803,50 @@ async def _await_run_and_cleanup() -> Any: await self._run_sandbox_cleanup() return result - self.run_loop_task = asyncio.create_task( + cleanup_wrapper_task = asyncio.create_task( _await_data_redacted_error_boundary(_await_run_and_cleanup) ) + def cancel_original_if_wrapper_cancelled(task: asyncio.Task[Any]) -> None: + # A task cancelled before its first event-loop step cannot enter its coroutine body. + if task.cancelled() and not original_task.done(): + original_task.cancel() + + cleanup_wrapper_task.add_done_callback(cancel_original_if_wrapper_cancelled) + self.run_loop_task = cleanup_wrapper_task + + def _ensure_model_provider_cleanup_on_completion( + self, + cleanup: Callable[[], Awaitable[None]], + ) -> None: + """Register one cleanup task that also starts if the run task never enters its body.""" + self._model_provider_cleanup = cleanup + if self.run_loop_task is not None: + self.run_loop_task.add_done_callback(lambda _task: self._start_model_provider_cleanup()) + + def _start_model_provider_cleanup(self) -> asyncio.Task[None] | None: + task = self._model_provider_cleanup_task + if task is not None: + return task + + cleanup = self._model_provider_cleanup + if cleanup is None: + return None + + self._model_provider_cleanup = None + + async def run_cleanup() -> None: + await cleanup() + + task = asyncio.create_task(run_cleanup()) + self._model_provider_cleanup_task = task + return task + + async def _await_model_provider_cleanup(self) -> None: + task = self._start_model_provider_cleanup() + if task is not None: + await asyncio.shield(task) + @property def run_loop_exception(self) -> BaseException | None: """The exception raised by the background run loop, if any. @@ -1006,6 +1062,7 @@ def register_current_consumer() -> None: self._cleanup_tasks() if not cancelled: + await self._await_model_provider_cleanup() await self._run_sandbox_cleanup() finally: # Allow any pending callbacks (e.g., cancellation handlers) to enqueue their @@ -1096,6 +1153,9 @@ def _cleanup_tasks(self): if self.run_loop_task and not self.run_loop_task.done(): self.run_loop_task.cancel() + if self._sandbox_wrapped_run_loop_task and not self._sandbox_wrapped_run_loop_task.done(): + self._sandbox_wrapped_run_loop_task.cancel() + if self._input_guardrails_task and not self._input_guardrails_task.done(): self._input_guardrails_task.cancel() diff --git a/src/agents/run.py b/src/agents/run.py index abb72a161b..297895a347 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -55,7 +55,6 @@ ToolExecutionConfig, ToolNameCollisionPolicy as ToolNameCollisionPolicy, ToolNotFoundBehavior, - _coerce_run_config, ) from .run_context import RunContextWrapper, TContext from .run_error_handlers import RunErrorHandlers @@ -109,6 +108,10 @@ normalize_resumed_input, reconcile_nested_history_owned_input_after_rewrite, ) +from .run_internal.model_provider_lifecycle import ( + _close_runner_owned_model_provider, + _normalize_run_config_for_runner, +) from .run_internal.oai_conversation import OpenAIServerConversationTracker from .run_internal.prompt_cache_key import PromptCacheKeyResolver from .run_internal.run_grouping import resolve_run_grouping_id @@ -554,18 +557,25 @@ async def run( input: str | list[TResponseInputItem] | RunState[TContext], **kwargs: Unpack[RunOptions[TContext]], ) -> RunResult: + run_config, owns_model_provider = _normalize_run_config_for_runner(kwargs.get("run_config")) + cast(dict[str, Any], kwargs)["run_config"] = run_config redacted_error: BaseException | None = None try: - return await self._run_impl(starting_agent, input, **kwargs) - except BaseException as error: - if not _is_error_data_redacted(error): - raise - _detach_data_redacted_error_traceback(error) - redacted_error = error + try: + return await self._run_impl(starting_agent, input, **kwargs) + except BaseException as error: + if not _is_error_data_redacted(error): + raise + _detach_data_redacted_error_traceback(error) + redacted_error = error + finally: + if owns_model_provider: + await _close_runner_owned_model_provider(run_config.model_provider) self = cast(Any, None) starting_agent = cast(Any, None) input = cast(Any, None) + run_config = cast(Any, None) cast(dict[str, Any], kwargs).clear() assert redacted_error is not None _detach_data_redacted_error_traceback(redacted_error) @@ -587,7 +597,7 @@ async def _run_impl( conversation_id = kwargs.get("conversation_id") session = kwargs.get("session") - run_config = RunConfig() if run_config is None else _coerce_run_config(run_config) + run_config = cast(RunConfig, run_config) is_resumed_state = isinstance(input, RunState) run_state: RunState[TContext] | None = ( @@ -2371,7 +2381,7 @@ def run_streamed( conversation_id = kwargs.get("conversation_id") session = kwargs.get("session") - run_config = RunConfig() if run_config is None else _coerce_run_config(run_config) + run_config, owns_model_provider = _normalize_run_config_for_runner(run_config) # Handle RunState input is_resumed_state = isinstance(input, RunState) @@ -2589,8 +2599,8 @@ def run_streamed( sandbox_runtime.apply_result_metadata(streamed_result) # Kick off the actual agent loop in the background and return the streamed result object. - streamed_result.run_loop_task = asyncio.create_task( - _await_data_redacted_error_boundary( + async def run_loop() -> None: + await _await_data_redacted_error_boundary( lambda: start_streaming( starting_input=input_for_result, streamed_result=streamed_result, @@ -2610,7 +2620,13 @@ def run_streamed( sandbox_runtime=sandbox_runtime, ) ) - ) + + streamed_result.run_loop_task = asyncio.create_task(run_loop()) + if owns_model_provider: + model_provider = run_config.model_provider + streamed_result._ensure_model_provider_cleanup_on_completion( + lambda: _close_runner_owned_model_provider(model_provider) + ) if sandbox_runtime.enabled: streamed_result.ensure_sandbox_cleanup_on_completion() return streamed_result diff --git a/src/agents/run_internal/model_provider_lifecycle.py b/src/agents/run_internal/model_provider_lifecycle.py new file mode 100644 index 0000000000..a3c22e8d53 --- /dev/null +++ b/src/agents/run_internal/model_provider_lifecycle.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from ..logger import log_model_action_warning, logger +from ..models.interface import ModelProvider +from ..run_config import RunConfig, _coerce_run_config + + +def _normalize_run_config_for_runner( + value: RunConfig | dict[str, Any] | None, +) -> tuple[RunConfig, bool]: + """Normalize a run config and report whether Runner created its model provider.""" + owns_model_provider = value is None or ( + isinstance(value, dict) and "model_provider" not in value + ) + run_config = RunConfig() if value is None else _coerce_run_config(value) + return run_config, owns_model_provider + + +async def _close_runner_owned_model_provider(model_provider: ModelProvider) -> None: + """Finish provider cleanup despite repeated cancellation, then restore cancellation.""" + + async def close() -> None: + try: + await model_provider.aclose() + except Exception as error: + log_model_action_warning( + logger, + "Failed to close model provider created for run", + error, + ) + + close_task = asyncio.create_task(close()) + try: + await asyncio.shield(close_task) + except asyncio.CancelledError: + while not close_task.done(): + try: + await asyncio.shield(close_task) + except asyncio.CancelledError: + continue + if not close_task.cancelled(): + close_task.result() + raise diff --git a/tests/test_cancel_streaming.py b/tests/test_cancel_streaming.py index 3fbf2571ac..ea7cb85635 100644 --- a/tests/test_cancel_streaming.py +++ b/tests/test_cancel_streaming.py @@ -1,4 +1,5 @@ import asyncio +import gc import json import time @@ -7,6 +8,7 @@ from agents import Agent, Runner from agents.guardrail import input_guardrail +from agents.models.multi_provider import MultiProvider from agents.stream_events import RawResponsesStreamEvent from agents.testing import ScriptedModel @@ -109,13 +111,29 @@ async def test_cancel_is_idempotent(): @pytest.mark.asyncio -async def test_cancel_before_streaming(): +async def test_cancel_before_streaming( + monkeypatch: pytest.MonkeyPatch, + recwarn: pytest.WarningsRecorder, +) -> None: + closed: list[MultiProvider] = [] + + async def record_close(provider: MultiProvider) -> None: + closed.append(provider) + + monkeypatch.setattr(MultiProvider, "aclose", record_close) model = ScriptedModel() agent = Agent(name="Joker", model=model) result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") result.cancel() # Cancel before streaming events = [e async for e in result.stream_events()] + gc.collect() + assert events == [], "No events should be yielded if cancel() is called before streaming." + assert len(closed) == 1 + assert not any( + warning.category is RuntimeWarning and "was never awaited" in str(warning.message) + for warning in recwarn + ) @pytest.mark.asyncio diff --git a/tests/test_runner_model_provider_lifecycle.py b/tests/test_runner_model_provider_lifecycle.py new file mode 100644 index 0000000000..fdfe8fcb21 --- /dev/null +++ b/tests/test_runner_model_provider_lifecycle.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from openai import AsyncOpenAI +from openai.types.responses import ResponseCompletedEvent, ResponseOutputItemDoneEvent +from websockets.asyncio.server import ServerConnection, serve + +from agents import ( + Agent, + RunConfig, + Runner, + set_default_openai_client, + set_default_openai_responses_transport, +) +from agents.models.interface import Model, ModelProvider +from agents.models.multi_provider import MultiProvider +from agents.testing import ModelStep, ScriptedModel +from tests.model_test_helpers import get_response_obj + +from .test_responses import get_text_message + + +def _scripted_agent(*steps: Any) -> Agent[None]: + return Agent(name="test", model=ScriptedModel(steps)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "run_config", + [None, {"tracing_disabled": True}], + ids=["omitted", "dictionary-with-default-provider"], +) +async def test_run_closes_implicitly_created_model_provider( + monkeypatch: pytest.MonkeyPatch, + run_config: dict[str, Any] | None, +) -> None: + closed: list[MultiProvider] = [] + + async def record_close(provider: MultiProvider) -> None: + closed.append(provider) + + monkeypatch.setattr(MultiProvider, "aclose", record_close) + agent = _scripted_agent([get_text_message("done")]) + + result = ( + await Runner.run(agent, "hello") + if run_config is None + else await Runner.run(agent, "hello", run_config=run_config) + ) + + assert result.final_output == "done" + assert len(closed) == 1 + + +def test_run_sync_closes_implicitly_created_model_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + closed: list[MultiProvider] = [] + + async def record_close(provider: MultiProvider) -> None: + closed.append(provider) + + monkeypatch.setattr(MultiProvider, "aclose", record_close) + agent = _scripted_agent([get_text_message("done")]) + + result = Runner.run_sync(agent, "hello") + + assert result.final_output == "done" + assert len(closed) == 1 + + +@pytest.mark.asyncio +async def test_run_preserves_primary_error_when_provider_cleanup_fails( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + run_error = RuntimeError("run failed") + close_error = RuntimeError("close failed") + + async def fail_close(_provider: MultiProvider) -> None: + raise close_error + + monkeypatch.setattr(MultiProvider, "aclose", fail_close) + agent = _scripted_agent(ModelStep.raise_error(run_error)) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + with pytest.raises(RuntimeError) as exc_info: + await Runner.run(agent, "hello") + + assert exc_info.value is run_error + assert "Failed to close model provider created for run" in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("dictionary_config", [False, True], ids=["RunConfig", "dictionary"]) +async def test_run_keeps_explicit_model_provider_open_for_reuse(dictionary_config: bool) -> None: + class ReusableProvider(ModelProvider): + def __init__(self, model: Model) -> None: + self.model = model + self.close_calls = 0 + + def get_model(self, model_name: str | None) -> Model: + return self.model + + async def aclose(self) -> None: + self.close_calls += 1 + + model = ScriptedModel( + [ + [get_text_message("first")], + [get_text_message("second")], + ] + ) + provider = ReusableProvider(model) + run_config: RunConfig | dict[str, Any] = ( + {"model_provider": provider} if dictionary_config else RunConfig(model_provider=provider) + ) + agent = Agent(name="test", model="test-model") + + first = await Runner.run(agent, "first", run_config=run_config) + second = await Runner.run(agent, "second", run_config=run_config) + + assert first.final_output == "first" + assert second.final_output == "second" + assert provider.close_calls == 0 + + +@pytest.mark.asyncio +async def test_run_streamed_closes_provider_only_after_run_settles( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + finish = asyncio.Event() + closed: list[MultiProvider] = [] + output = get_text_message("done") + + async def events(_call: object) -> AsyncIterator[Any]: + started.set() + await finish.wait() + yield ResponseOutputItemDoneEvent( + type="response.output_item.done", + item=output, + output_index=0, + sequence_number=0, + ) + yield ResponseCompletedEvent( + type="response.completed", + response=get_response_obj([output]), + sequence_number=1, + ) + + async def record_close(provider: MultiProvider) -> None: + closed.append(provider) + + monkeypatch.setattr(MultiProvider, "aclose", record_close) + agent = _scripted_agent(ModelStep.stream(events)) + result = Runner.run_streamed(agent, "hello") + + await asyncio.wait_for(started.wait(), timeout=1) + assert closed == [] + + finish.set() + async for _event in result.stream_events(): + pass + + assert result.final_output == "done" + assert len(closed) == 1 + + +@pytest.mark.asyncio +async def test_run_streamed_closes_provider_after_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + stream_closed = asyncio.Event() + blocked = asyncio.Event() + closed: list[MultiProvider] = [] + + async def events(_call: object) -> AsyncIterator[Any]: + started.set() + try: + await blocked.wait() + finally: + stream_closed.set() + if False: # pragma: no cover - makes this an async generator + yield None + + async def record_close(provider: MultiProvider) -> None: + closed.append(provider) + + monkeypatch.setattr(MultiProvider, "aclose", record_close) + agent = _scripted_agent(ModelStep.stream(events)) + result = Runner.run_streamed(agent, "hello") + await asyncio.wait_for(started.wait(), timeout=1) + + result.cancel() + async for _event in result.stream_events(): + pass + + assert stream_closed.is_set() + assert len(closed) == 1 + + +@pytest.mark.asyncio +async def test_run_streamed_repeated_cancellation_waits_for_provider_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stream_started = asyncio.Event() + blocked = asyncio.Event() + close_started = asyncio.Event() + close_release = asyncio.Event() + close_completed = asyncio.Event() + + async def events(_call: object) -> AsyncIterator[Any]: + stream_started.set() + await blocked.wait() + if False: # pragma: no cover - makes this an async generator + yield None + + async def slow_close(_provider: MultiProvider) -> None: + close_started.set() + await close_release.wait() + close_completed.set() + + monkeypatch.setattr(MultiProvider, "aclose", slow_close) + agent = _scripted_agent(ModelStep.stream(events)) + result = Runner.run_streamed(agent, "hello") + await asyncio.wait_for(stream_started.wait(), timeout=1) + + result.cancel() + await asyncio.wait_for(close_started.wait(), timeout=1) + result.cancel() + close_release.set() + async for _event in result.stream_events(): + pass + + assert close_completed.is_set() + + +@pytest.mark.asyncio +async def test_run_streamed_cancel_before_start_propagates_through_cleanup_wrapper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sandbox_cleanup_completed = asyncio.Event() + + async def record_provider_close(_provider: MultiProvider) -> None: + return None + + async def record_sandbox_cleanup() -> None: + sandbox_cleanup_completed.set() + + monkeypatch.setattr(MultiProvider, "aclose", record_provider_close) + result = Runner.run_streamed(Agent(name="test", model=ScriptedModel()), "hello") + original_task = result.run_loop_task + assert original_task is not None + result._sandbox_cleanup = record_sandbox_cleanup + result.ensure_sandbox_cleanup_on_completion() + + result.cancel() + async for _event in result.stream_events(): + pass + + assert original_task.cancelled() + assert sandbox_cleanup_completed.is_set() + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_run_streamed_closes_implicit_responses_websocket_connection() -> None: + connection_closed = asyncio.Event() + + async def handle(connection: ServerConnection) -> None: + try: + async for request_json in connection: + request = json.loads(request_json) + assert request["type"] == "response.create" + response = get_response_obj( + [get_text_message("done")], + response_id="resp-runner-provider-cleanup", + ) + await connection.send( + json.dumps( + { + "type": "response.completed", + "response": response.model_dump(), + "sequence_number": 1, + } + ) + ) + finally: + connection_closed.set() + + async with serve(handle, "127.0.0.1", 0) as server: + server_socket = next(iter(server.sockets)) + host, port = server_socket.getsockname()[:2] + client = AsyncOpenAI( + api_key="test-key", + base_url=f"http://{host}:{port}/v1", + websocket_base_url=f"ws://{host}:{port}/v1", + max_retries=0, + ) + set_default_openai_client(client, use_for_tracing=False) + set_default_openai_responses_transport("websocket") + agent = Agent(name="test", model="gpt-4.1-mini") + + try: + result = Runner.run_streamed(agent, "hello") + async for _event in result.stream_events(): + pass + + assert result.final_output == "done" + await asyncio.wait_for(connection_closed.wait(), timeout=1) + finally: + await client.close() From b109f4809c5444803c6caaa87dfcf428b68e14d0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 8 Sep 2026 00:31:51 +0900 Subject: [PATCH 468/473] fix(sandbox): settle PTY output before cleanup (#4738) Co-authored-by: Henry Su Co-authored-by: ayaangazali --- .../extensions/sandbox/blaxel/sandbox.py | 13 +- .../extensions/sandbox/cloudflare/sandbox.py | 50 +- .../extensions/sandbox/daytona/sandbox.py | 33 +- src/agents/extensions/sandbox/e2b/sandbox.py | 62 +-- .../extensions/sandbox/modal/sandbox.py | 149 ++++-- src/agents/sandbox/sandboxes/docker.py | 15 +- src/agents/sandbox/sandboxes/unix_local.py | 13 +- src/agents/sandbox/session/pty_output.py | 154 +++++- tests/extensions/sandbox/test_blaxel.py | 15 +- tests/extensions/sandbox/test_daytona.py | 77 ++- tests/extensions/sandbox/test_e2b.py | 171 ++++++- tests/extensions/sandbox/test_modal.py | 441 ++++++++++++++++++ tests/sandbox/test_pty_output.py | 287 +++++++++++- tests/sandbox/test_unix_local.py | 69 +++ 14 files changed, 1369 insertions(+), 180 deletions(-) diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index a54bc47905..0ba8ad0d5b 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -864,7 +864,7 @@ async def pty_exec_start( ) yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms), max_output_tokens=max_output_tokens, @@ -874,6 +874,7 @@ async def pty_exec_start( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_write_stdin( @@ -898,7 +899,7 @@ async def pty_write_stdin( await asyncio.sleep(0.1) yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=resolve_pty_write_yield_time_ms( yield_time_ms=yield_time_ms, input_empty=chars == "" @@ -911,6 +912,7 @@ async def pty_write_stdin( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_terminate_all(self) -> None: @@ -972,7 +974,7 @@ async def _collect_pty_output( entry: _BlaxelPtySessionEntry, yield_time_ms: int, max_output_tokens: int | None, - ) -> tuple[bytes, int | None]: + ) -> tuple[bytes, int | None, bool]: return await collect_pty_output( output_chunks=entry.output_chunks, output_lock=entry.output_lock, @@ -989,11 +991,12 @@ async def _finalize_pty_update( entry: _BlaxelPtySessionEntry, output: bytes, original_token_count: int | None, + output_closed: bool, ) -> PtyExecUpdate: - exit_code = entry.exit_code if entry.done else None + exit_code = entry.exit_code if output_closed else None live_process_id: int | None = process_id - if entry.done: + if output_closed: async with self._pty_lock: removed = self._pty_sessions.pop(process_id, None) self._reserved_pty_process_ids.discard(process_id) diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index bb8d7c37e6..fc2f6c0696 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -59,6 +59,7 @@ _settle_mount_transition, with_ephemeral_mounts_removed, ) +from ....sandbox.session.pty_output import collect_pty_output from ....sandbox.session.pty_types import ( PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, @@ -67,7 +68,6 @@ clamp_pty_yield_time_ms, process_id_to_prune_from_meta, resolve_pty_write_yield_time_ms, - truncate_text_by_tokens, ) from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions @@ -1033,34 +1033,15 @@ async def _collect_pty_output( entry: _CloudflarePtyProcessEntry, yield_time_ms: int, max_output_tokens: int | None, - ) -> tuple[bytes, int | None]: - deadline = time.monotonic() + (yield_time_ms / 1000) - output = bytearray() - - while True: - async with entry.output_lock: - while entry.output_chunks: - output.extend(entry.output_chunks.popleft()) - - if entry.output_closed.is_set(): - async with entry.output_lock: - while entry.output_chunks: - output.extend(entry.output_chunks.popleft()) - break - - remaining_s = deadline - time.monotonic() - if remaining_s <= 0: - break - - try: - await asyncio.wait_for(entry.output_notify.wait(), timeout=remaining_s) - except asyncio.TimeoutError: - break - entry.output_notify.clear() - - text = output.decode("utf-8", errors="replace") - truncated_text, original_token_count = truncate_text_by_tokens(text, max_output_tokens) - return truncated_text.encode("utf-8", errors="replace"), original_token_count + ) -> tuple[bytes, int | None, bool]: + return await collect_pty_output( + output_chunks=entry.output_chunks, + output_lock=entry.output_lock, + output_notify=entry.output_notify, + is_done=entry.output_closed.is_set, + yield_time_ms=yield_time_ms, + max_output_tokens=max_output_tokens, + ) async def _finalize_pty_update( self, @@ -1069,10 +1050,11 @@ async def _finalize_pty_update( entry: _CloudflarePtyProcessEntry, output: bytes, original_token_count: int | None, + output_closed: bool, ) -> PtyExecUpdate: - exit_code = entry.exit_code if entry.output_closed.is_set() else None + exit_code = entry.exit_code if output_closed else None live_process_id: int | None = process_id - if entry.output_closed.is_set(): + if output_closed: async with self._pty_lock: removed = self._pty_processes.pop(process_id, None) self._reserved_pty_process_ids.discard(process_id) @@ -1220,7 +1202,7 @@ async def pty_exec_start( ) yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms), max_output_tokens=max_output_tokens, @@ -1230,6 +1212,7 @@ async def pty_exec_start( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_write_stdin( @@ -1253,7 +1236,7 @@ async def pty_write_stdin( await asyncio.sleep(0.1) yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=resolve_pty_write_yield_time_ms( yield_time_ms=yield_time_ms, @@ -1267,6 +1250,7 @@ async def pty_write_stdin( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_terminate_all(self) -> None: diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py index d62c5021ad..e7c9cb8918 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -387,8 +387,8 @@ class _DaytonaPtySessionEntry: output_chunks: deque[bytes] = field(default_factory=deque) output_lock: asyncio.Lock = field(default_factory=asyncio.Lock) output_notify: asyncio.Event = field(default_factory=asyncio.Event) + output_closed: asyncio.Event = field(default_factory=asyncio.Event) last_used: float = field(default_factory=time.monotonic) - done: bool = False exit_code: int | None = None worker_task: asyncio.Task[None] | None = None @@ -755,7 +755,7 @@ async def _on_data(chunk: bytes | str) -> None: ) yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms), max_output_tokens=max_output_tokens, @@ -765,6 +765,7 @@ async def _on_data(chunk: bytes | str) -> None: entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def _run_pty_waiter(self, entry: _DaytonaPtySessionEntry) -> None: @@ -776,7 +777,10 @@ async def _run_pty_waiter(self, entry: _DaytonaPtySessionEntry) -> None: except Exception: pass finally: - entry.done = True + # AsyncPtyHandle.wait() completes only after its WebSocket reader exits. + # That reader awaits every async on_data callback before it can finish, + # so this is Daytona's authoritative output-stream close boundary. + entry.output_closed.set() entry.output_notify.set() async def _run_session_reader( @@ -801,11 +805,13 @@ async def _run_session_reader( cmd = await self._sandbox.process.get_session_command(session_id, cmd_id) if cmd.exit_code is not None: entry.exit_code = int(cmd.exit_code) - entry.done = True except Exception: pass - if not logs_failed: - entry.done = True + # Once the log callback stream has returned, or has failed after the + # provider reports a final exit code, this worker is the only output + # producer and no later callback can append bytes. + if not logs_failed or entry.exit_code is not None: + entry.output_closed.set() entry.output_notify.set() async def pty_write_stdin( @@ -832,7 +838,7 @@ async def pty_write_stdin( await asyncio.sleep(0.1) yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=resolve_pty_write_yield_time_ms( yield_time_ms=yield_time_ms, input_empty=chars == "" @@ -845,6 +851,7 @@ async def pty_write_stdin( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def _finalize_pty_update( @@ -854,11 +861,12 @@ async def _finalize_pty_update( entry: _DaytonaPtySessionEntry, output: bytes, original_token_count: int | None, + output_closed: bool, ) -> PtyExecUpdate: - exit_code = entry.exit_code if entry.done else None + exit_code = entry.exit_code if output_closed else None live_process_id: int | None = process_id - if entry.done: + if output_closed: async with self._pty_lock: removed = self._pty_sessions.pop(process_id, None) self._reserved_pty_process_ids.discard(process_id) @@ -887,12 +895,12 @@ async def _collect_pty_output( entry: _DaytonaPtySessionEntry, yield_time_ms: int, max_output_tokens: int | None, - ) -> tuple[bytes, int | None]: + ) -> tuple[bytes, int | None, bool]: return await collect_pty_output( output_chunks=entry.output_chunks, output_lock=entry.output_lock, output_notify=entry.output_notify, - is_done=lambda: entry.done, + is_done=entry.output_closed.is_set, yield_time_ms=yield_time_ms, max_output_tokens=max_output_tokens, ) @@ -901,7 +909,8 @@ def _prune_pty_sessions_if_needed(self) -> _DaytonaPtySessionEntry | None: if len(self._pty_sessions) < PTY_PROCESSES_MAX: return None meta: list[tuple[int, float, bool]] = [ - (pid, entry.last_used, entry.done) for pid, entry in self._pty_sessions.items() + (pid, entry.last_used, entry.output_closed.is_set()) + for pid, entry in self._pty_sessions.items() ] pid = process_id_to_prune_from_meta(meta) if pid is None: diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py index 389b665c44..e54c238801 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -53,6 +53,7 @@ from ....sandbox.session.base_sandbox_session import BaseSandboxSession from ....sandbox.session.dependencies import Dependencies from ....sandbox.session.manager import Instrumentation +from ....sandbox.session.pty_output import collect_pty_output from ....sandbox.session.pty_types import ( PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, @@ -61,7 +62,6 @@ clamp_pty_yield_time_ms, process_id_to_prune_from_meta, resolve_pty_write_yield_time_ms, - truncate_text_by_tokens, ) from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions @@ -687,6 +687,7 @@ class _E2BPtyProcessEntry: output_chunks: deque[bytes] = field(default_factory=deque) output_lock: asyncio.Lock = field(default_factory=asyncio.Lock) output_notify: asyncio.Event = field(default_factory=asyncio.Event) + output_closed: asyncio.Event = field(default_factory=asyncio.Event) last_used: float = field(default_factory=time.monotonic) exit_code: int | None = None wait_task: asyncio.Task[None] | None = None @@ -1050,7 +1051,7 @@ async def _append_output(payload: bytes | bytearray | str | object) -> None: ) yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms), max_output_tokens=max_output_tokens, @@ -1060,6 +1061,7 @@ async def _append_output(payload: bytes | bytearray | str | object) -> None: entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_write_stdin( @@ -1087,7 +1089,7 @@ async def pty_write_stdin( await asyncio.sleep(0.1) yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=resolve_pty_write_yield_time_ms( yield_time_ms=yield_time_ms, input_empty=chars == "" @@ -1100,6 +1102,7 @@ async def pty_write_stdin( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_terminate_all(self) -> None: @@ -1211,37 +1214,15 @@ async def _collect_pty_output( entry: _E2BPtyProcessEntry, yield_time_ms: int, max_output_tokens: int | None, - ) -> tuple[bytes, int | None]: - deadline = time.monotonic() + (yield_time_ms / 1000) - output = bytearray() - - while True: - async with entry.output_lock: - while entry.output_chunks: - output.extend(entry.output_chunks.popleft()) - - if time.monotonic() >= deadline: - break - - if self._entry_exit_code(entry) is not None: - async with entry.output_lock: - while entry.output_chunks: - output.extend(entry.output_chunks.popleft()) - break - - remaining_s = deadline - time.monotonic() - if remaining_s <= 0: - break - - try: - await asyncio.wait_for(entry.output_notify.wait(), timeout=remaining_s) - except asyncio.TimeoutError: - break - entry.output_notify.clear() - - text = output.decode("utf-8", errors="replace") - truncated_text, original_token_count = truncate_text_by_tokens(text, max_output_tokens) - return truncated_text.encode("utf-8", errors="replace"), original_token_count + ) -> tuple[bytes, int | None, bool]: + return await collect_pty_output( + output_chunks=entry.output_chunks, + output_lock=entry.output_lock, + output_notify=entry.output_notify, + is_done=entry.output_closed.is_set, + yield_time_ms=yield_time_ms, + max_output_tokens=max_output_tokens, + ) async def _run_pty_waiter(self, entry: _E2BPtyProcessEntry) -> None: try: @@ -1258,7 +1239,13 @@ async def _run_pty_waiter(self, entry: _E2BPtyProcessEntry) -> None: entry.exit_code = int(value) except (TypeError, ValueError): pass - finally: + if entry.exit_code is not None: + # E2B delivers output through async callbacks that append under this lock. + # Wait behind callbacks already appending terminal bytes before publishing + # the close signal that authorizes collector settlement and PTY removal. + async with entry.output_lock: + pass + entry.output_closed.set() entry.output_notify.set() async def _finalize_pty_update( @@ -1268,8 +1255,9 @@ async def _finalize_pty_update( entry: _E2BPtyProcessEntry, output: bytes, original_token_count: int | None, + output_closed: bool, ) -> PtyExecUpdate: - exit_code = self._entry_exit_code(entry) + exit_code = self._entry_exit_code(entry) if output_closed else None live_process_id: int | None = process_id if exit_code is not None: @@ -1292,7 +1280,7 @@ def _prune_pty_processes_if_needed(self) -> _E2BPtyProcessEntry | None: return None meta: list[tuple[int, float, bool]] = [ - (process_id, entry.last_used, self._entry_exit_code(entry) is not None) + (process_id, entry.last_used, entry.output_closed.is_set()) for process_id, entry in self._pty_processes.items() ] process_id = process_id_to_prune_from_meta(meta) diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index 848b8e12a3..f0fe85e589 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -23,6 +23,7 @@ import shlex import time import uuid +from collections import deque from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass, field @@ -64,6 +65,7 @@ _settle_mount_transition, _terminate_ambiguous_mount_session, ) +from ....sandbox.session.pty_output import collect_pty_output from ....sandbox.session.pty_types import ( PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, @@ -72,7 +74,6 @@ clamp_pty_yield_time_ms, process_id_to_prune_from_meta, resolve_pty_write_yield_time_ms, - truncate_text_by_tokens, ) from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions @@ -489,6 +490,12 @@ class _ModalPtyProcessEntry: stderr_iter: AsyncIterator[object] | None = None stdout_read_task: asyncio.Task[object] | None = None stderr_read_task: asyncio.Task[object] | None = None + stdout_closed: bool = False + stderr_closed: bool = False + output_chunks: deque[bytes] = field(default_factory=deque) + output_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + output_notify: asyncio.Event = field(default_factory=asyncio.Event) + output_closed: asyncio.Event = field(default_factory=asyncio.Event) class ModalSandboxSession(BaseSandboxSession): @@ -907,7 +914,7 @@ async def pty_exec_start( ) yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms), max_output_tokens=max_output_tokens, @@ -917,6 +924,7 @@ async def pty_exec_start( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_write_stdin( @@ -940,7 +948,7 @@ async def pty_write_stdin( await asyncio.sleep(0.1) yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=resolve_pty_write_yield_time_ms( yield_time_ms=yield_time_ms, input_empty=chars == "" @@ -953,6 +961,7 @@ async def pty_write_stdin( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_terminate_all(self) -> None: @@ -981,56 +990,80 @@ async def _collect_pty_output( entry: _ModalPtyProcessEntry, yield_time_ms: int, max_output_tokens: int | None, - ) -> tuple[bytes, int | None]: - deadline = time.monotonic() + (yield_time_ms / 1000) - chunks = bytearray() - - while True: - stdout_chunk = await self._read_modal_stream(entry=entry, stream_name="stdout") - stderr_chunk = await self._read_modal_stream(entry=entry, stream_name="stderr") - if stdout_chunk: - chunks.extend(stdout_chunk) - if stderr_chunk: - chunks.extend(stderr_chunk) - - if time.monotonic() >= deadline: - break - - exit_code = await self._peek_exit_code(entry.process) - if exit_code is not None: - stdout_chunks = await self._drain_modal_stream(entry=entry, stream_name="stdout") - stderr_chunks = await self._drain_modal_stream(entry=entry, stream_name="stderr") - chunks.extend(stdout_chunks) - chunks.extend(stderr_chunks) - break - - if not stdout_chunk and not stderr_chunk: - remaining_s = deadline - time.monotonic() - if remaining_s <= 0: - break - await asyncio.sleep(min(_PTY_POLL_INTERVAL_S, remaining_s)) - - text = chunks.decode("utf-8", errors="replace") - truncated_text, original_token_count = truncate_text_by_tokens(text, max_output_tokens) - return truncated_text.encode("utf-8", errors="replace"), original_token_count + ) -> tuple[bytes, int | None, bool]: + async def poll_output( + *, + deadline: float | None, + allow_new_read: bool, + check_exit: bool, + ) -> None: + for stream_name in ("stdout", "stderr"): + chunk = await self._read_modal_stream( + entry=entry, + stream_name=stream_name, + allow_new_read=allow_new_read, + deadline=deadline, + ) + if chunk: + # Commit consumed bytes before another provider call can suspend. + entry.output_chunks.append(chunk) + entry.output_notify.set() + + if ( + check_exit + and deadline is not None + and time.monotonic() < deadline + and await self._peek_exit_code(entry.process) is not None + ): + await self._drain_modal_stream(entry=entry, stream_name="stdout", deadline=deadline) + await self._drain_modal_stream(entry=entry, stream_name="stderr", deadline=deadline) + if entry.stdout_closed and entry.stderr_closed: + entry.output_closed.set() + + async def wait_for_output(remaining_s: float) -> None: + await asyncio.sleep(min(_PTY_POLL_INTERVAL_S, remaining_s)) + + async def settle_output() -> None: + # The deadline path may only collect already-started reads. Checking + # process status here would add a slow provider RPC after the caller's + # requested yield window has already elapsed. + await poll_output(deadline=None, allow_new_read=False, check_exit=False) + + return await collect_pty_output( + output_chunks=entry.output_chunks, + output_lock=entry.output_lock, + output_notify=entry.output_notify, + is_done=entry.output_closed.is_set, + yield_time_ms=yield_time_ms, + max_output_tokens=max_output_tokens, + poll_output=lambda deadline: poll_output( + deadline=deadline, + allow_new_read=True, + check_exit=True, + ), + settle_output=settle_output, + wait_for_output=wait_for_output, + ) async def _drain_modal_stream( self, *, entry: _ModalPtyProcessEntry, stream_name: Literal["stdout", "stderr"], - ) -> bytes: - chunks = bytearray() + deadline: float, + ) -> None: while True: chunk = await self._read_modal_stream( entry=entry, stream_name=stream_name, await_pending=True, + settle_after_exit=True, + deadline=deadline, ) if not chunk: break - chunks.extend(chunk) - return bytes(chunks) + entry.output_chunks.append(chunk) + entry.output_notify.set() async def _read_modal_stream( self, @@ -1038,13 +1071,25 @@ async def _read_modal_stream( entry: _ModalPtyProcessEntry, stream_name: Literal["stdout", "stderr"], await_pending: bool = False, + allow_new_read: bool = True, + settle_after_exit: bool = False, + deadline: float | None = None, ) -> bytes: stream = entry.process.stdout if stream_name == "stdout" else entry.process.stderr + closed_attr = "stdout_closed" if stream_name == "stdout" else "stderr_closed" + if getattr(entry, closed_attr): + return b"" if stream is None: + setattr(entry, closed_attr, True) return b"" iter_attr = "stdout_iter" if stream_name == "stdout" else "stderr_iter" task_attr = "stdout_read_task" if stream_name == "stdout" else "stderr_read_task" + task = getattr(entry, task_attr) + remaining_s = max(0.0, deadline - time.monotonic()) if deadline is not None else 0.0 + if task is None and (not allow_new_read or remaining_s <= 0): + return b"" + stream_iter = getattr(entry, iter_attr) if stream_iter is None: aiter_method = getattr(stream, "__aiter__", None) @@ -1056,13 +1101,12 @@ async def _read_modal_stream( else: setattr(entry, iter_attr, stream_iter) - task = getattr(entry, task_attr) if task is None and stream_iter is not None: task = asyncio.create_task(stream_iter.__anext__()) setattr(entry, task_attr, task) if task is not None: - wait_timeout = 0.2 if await_pending else 0 + wait_timeout = min(0.2, remaining_s) if await_pending else 0 done, _pending = await asyncio.wait({task}, timeout=wait_timeout) if not done: return b"" @@ -1072,9 +1116,12 @@ async def _read_modal_stream( value = task.result() except StopAsyncIteration: setattr(entry, iter_attr, None) + setattr(entry, closed_attr, True) return b"" except Exception: setattr(entry, iter_attr, None) + if settle_after_exit: + setattr(entry, closed_attr, True) return b"" return self._coerce_modal_stream_chunk(value) @@ -1084,13 +1131,23 @@ async def _read_modal_stream( return b"" try: - value = await self._call_modal(read, 16_384, call_timeout=0.2) + value = await self._call_modal(read, 16_384, call_timeout=min(0.2, remaining_s)) + except asyncio.TimeoutError: + # Reaching this window's deadline is not evidence of stream EOF. + return b"" except TypeError: + if settle_after_exit: + setattr(entry, closed_attr, True) return b"" except Exception: + if settle_after_exit: + setattr(entry, closed_attr, True) return b"" - return self._coerce_modal_stream_chunk(value) + chunk = self._coerce_modal_stream_chunk(value) + if not chunk and settle_after_exit: + setattr(entry, closed_attr, True) + return chunk def _coerce_modal_stream_chunk(self, value: object) -> bytes: if value is None: @@ -1110,8 +1167,9 @@ async def _finalize_pty_update( entry: _ModalPtyProcessEntry, output: bytes, original_token_count: int | None, + output_closed: bool, ) -> PtyExecUpdate: - exit_code = await self._peek_exit_code(entry.process) + exit_code = await self._peek_exit_code(entry.process) if output_closed else None live_process_id: int | None = process_id if exit_code is not None: async with self._pty_lock: @@ -1134,8 +1192,7 @@ async def _prune_pty_processes_if_needed(self) -> _ModalPtyProcessEntry | None: meta: list[tuple[int, float, bool]] = [] for process_id, entry in self._pty_processes.items(): - exit_code = await self._peek_exit_code(entry.process) - meta.append((process_id, entry.last_used, exit_code is not None)) + meta.append((process_id, entry.last_used, entry.output_closed.is_set())) process_id_to_prune = process_id_to_prune_from_meta(meta) if process_id_to_prune is None: return None diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 8ca4febe85..3bc69a043b 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -1080,7 +1080,7 @@ async def pty_exec_start( ) yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms), max_output_tokens=max_output_tokens, @@ -1090,6 +1090,7 @@ async def pty_exec_start( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_write_stdin( @@ -1126,7 +1127,7 @@ async def pty_write_stdin( await asyncio.sleep(0.1) yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=resolve_pty_write_yield_time_ms( yield_time_ms=yield_time_ms, input_empty=chars == "" @@ -1139,6 +1140,7 @@ async def pty_write_stdin( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_terminate_all(self) -> None: @@ -1242,7 +1244,7 @@ async def _collect_pty_output( entry: _DockerPtyProcessEntry, yield_time_ms: int, max_output_tokens: int | None, - ) -> tuple[bytes, int | None]: + ) -> tuple[bytes, int | None, bool]: return await collect_pty_output( output_chunks=entry.output_chunks, output_lock=entry.output_lock, @@ -1259,11 +1261,12 @@ async def _finalize_pty_update( entry: _DockerPtyProcessEntry, output: bytes, original_token_count: int | None, + output_closed: bool, ) -> PtyExecUpdate: - if entry.output_closed.is_set() and entry.exit_code is None: + if output_closed and entry.exit_code is None: await self._refresh_pty_exit_code(entry) - exit_code = entry.exit_code + exit_code = entry.exit_code if output_closed else None live_process_id: int | None = process_id if exit_code is not None: @@ -1286,7 +1289,7 @@ def _prune_pty_processes_if_needed(self) -> _DockerPtyProcessEntry | None: return None meta = [ - (process_id, entry.last_used, entry.exit_code is not None) + (process_id, entry.last_used, entry.output_closed.is_set()) for process_id, entry in self._pty_processes.items() ] process_id = process_id_to_prune_from_meta(meta) diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 308f6038ed..28eeb265ef 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -400,7 +400,7 @@ def _preexec() -> None: ) yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms), max_output_tokens=max_output_tokens, @@ -410,6 +410,7 @@ def _preexec() -> None: entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_write_stdin( @@ -442,7 +443,7 @@ async def pty_write_stdin( await asyncio.sleep(0.1) yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000) - output, original_token_count = await self._collect_pty_output( + output, original_token_count, output_closed = await self._collect_pty_output( entry=entry, yield_time_ms=resolve_pty_write_yield_time_ms( yield_time_ms=yield_time_ms, input_empty=chars == "" @@ -455,6 +456,7 @@ async def pty_write_stdin( entry=entry, output=output, original_token_count=original_token_count, + output_closed=output_closed, ) async def pty_terminate_all(self) -> None: @@ -533,7 +535,7 @@ async def _collect_pty_output( entry: _UnixPtyProcessEntry, yield_time_ms: int, max_output_tokens: int | None, - ) -> tuple[bytes, int | None]: + ) -> tuple[bytes, int | None, bool]: return await collect_pty_output( output_chunks=entry.output_chunks, output_lock=entry.output_lock, @@ -550,8 +552,9 @@ async def _finalize_pty_update( entry: _UnixPtyProcessEntry, output: bytes, original_token_count: int | None, + output_closed: bool, ) -> PtyExecUpdate: - exit_code: int | None = entry.process.returncode + exit_code: int | None = entry.process.returncode if output_closed else None live_process_id: int | None = process_id if exit_code is not None: @@ -574,7 +577,7 @@ def _prune_pty_processes_if_needed(self) -> _UnixPtyProcessEntry | None: return None meta = [ - (process_id, entry.last_used, entry.process.returncode is not None) + (process_id, entry.last_used, entry.output_closed.is_set()) for process_id, entry in self._pty_processes.items() ] process_id = process_id_to_prune_from_meta(meta) diff --git a/src/agents/sandbox/session/pty_output.py b/src/agents/sandbox/session/pty_output.py index 25cbe774e7..b0f0b0fe3c 100644 --- a/src/agents/sandbox/session/pty_output.py +++ b/src/agents/sandbox/session/pty_output.py @@ -3,11 +3,78 @@ import asyncio import time from collections import deque -from collections.abc import Callable +from collections.abc import Awaitable, Callable from .pty_types import truncate_text_by_tokens +def _incomplete_utf8_suffix_length(data: bytes | bytearray) -> int: + """Return the trailing byte count that can still become one valid UTF-8 scalar.""" + continuation_count = 0 + for byte in reversed(data[-3:]): + if 0x80 <= byte <= 0xBF: + continuation_count += 1 + continue + break + + lead_index = len(data) - continuation_count - 1 + if lead_index < 0: + return 0 + + lead = data[lead_index] + if 0xC2 <= lead <= 0xDF: + expected_length = 2 + elif 0xE0 <= lead <= 0xEF: + expected_length = 3 + elif 0xF0 <= lead <= 0xF4: + expected_length = 4 + else: + return 0 + + suffix_length = continuation_count + 1 + if suffix_length >= expected_length: + return 0 + + if continuation_count: + second = data[lead_index + 1] + if ( + (lead == 0xE0 and second < 0xA0) + or (lead == 0xED and second > 0x9F) + or (lead == 0xF0 and second < 0x90) + or (lead == 0xF4 and second > 0x8F) + ): + return 0 + + return suffix_length + + +async def _drain_output_chunks( + output_chunks: deque[bytes], + output_lock: asyncio.Lock, + output: bytearray, +) -> None: + async with output_lock: + while output_chunks: + output.extend(output_chunks.popleft()) + + +async def _drain_and_carry_incomplete_suffix( + output_chunks: deque[bytes], + output_lock: asyncio.Lock, + output: bytearray, +) -> None: + """Drain and restore a carryable suffix without yielding between ownership changes.""" + async with output_lock: + while output_chunks: + output.extend(output_chunks.popleft()) + + carry = _incomplete_utf8_suffix_length(output) + if carry: + tail = bytes(output[-carry:]) + del output[-carry:] + output_chunks.appendleft(tail) + + async def collect_pty_output( *, output_chunks: deque[bytes], @@ -16,35 +83,76 @@ async def collect_pty_output( is_done: Callable[[], bool], yield_time_ms: int, max_output_tokens: int | None, -) -> tuple[bytes, int | None]: - """Collect and truncate PTY output until the deadline or provider completion.""" + poll_output: Callable[[float], Awaitable[None]] | None = None, + settle_output: Callable[[], Awaitable[None]] | None = None, + wait_for_output: Callable[[float], Awaitable[None]] | None = None, +) -> tuple[bytes, int | None, bool]: + """Collect raw PTY bytes until the deadline or producer completion. + + poll_output adapts pull-based providers into output_chunks. Queue draining, + timeout settlement, UTF-8 carry, and decoding remain shared for every backend. + """ deadline = time.monotonic() + (yield_time_ms / 1000) output = bytearray() + output_closed = False + + try: + while True: + if time.monotonic() >= deadline: + break + + if poll_output is not None: + await poll_output(deadline) + await _drain_output_chunks(output_chunks, output_lock, output) + + if time.monotonic() >= deadline: + break + + if is_done(): + output_closed = True + if settle_output is not None: + await settle_output() + elif poll_output is not None: + await poll_output(deadline) + await _drain_output_chunks(output_chunks, output_lock, output) + break - while True: - async with output_lock: - while output_chunks: - output.extend(output_chunks.popleft()) + remaining_s = deadline - time.monotonic() + if remaining_s <= 0: + break - if time.monotonic() >= deadline: - break + if wait_for_output is not None: + await wait_for_output(remaining_s) + else: + try: + await asyncio.wait_for(output_notify.wait(), timeout=remaining_s) + except asyncio.TimeoutError: + break + output_notify.clear() - if is_done(): - async with output_lock: - while output_chunks: - output.extend(output_chunks.popleft()) - break + # Settle bytes that were queued around the final deadline or completion check. + if settle_output is not None: + await settle_output() + elif poll_output is not None: + await poll_output(deadline) - remaining_s = deadline - time.monotonic() - if remaining_s <= 0: - break + if not output_closed and is_done(): + output_closed = True + if settle_output is not None: + await settle_output() + elif poll_output is not None: + await poll_output(deadline) - try: - await asyncio.wait_for(output_notify.wait(), timeout=remaining_s) - except asyncio.TimeoutError: - break - output_notify.clear() + if output_closed: + await _drain_output_chunks(output_chunks, output_lock, output) + else: + await _drain_and_carry_incomplete_suffix(output_chunks, output_lock, output) + except asyncio.CancelledError: + # Queue operations contain no awaits, so restore ownership synchronously. + if output: + output_chunks.appendleft(bytes(output)) + raise text = output.decode("utf-8", errors="replace") truncated, original_token_count = truncate_text_by_tokens(text, max_output_tokens) - return truncated.encode("utf-8", errors="replace"), original_token_count + return truncated.encode("utf-8", errors="replace"), original_token_count, output_closed diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 4916a3a970..4028cbabd3 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -2092,7 +2092,7 @@ async def test_pty_write_stdin_sends_only_nonempty_input( patch.object( session, "_collect_pty_output", - new=AsyncMock(return_value=(b"", None)), + new=AsyncMock(return_value=(b"", None, False)), ), ): update = await session.pty_write_stdin( @@ -2200,6 +2200,7 @@ async def test_pty_finalize_done_session(self, fake_sandbox: _FakeSandboxInstanc entry=entry, output=b"done output", original_token_count=None, + output_closed=True, ) assert result.process_id is None assert result.exit_code == 0 @@ -2565,10 +2566,11 @@ async def test_collect_output_entry_done_immediately( done=True, ) entry.output_chunks.append(b"final output") - output, token_count = await session._collect_pty_output( + output, token_count, output_closed = await session._collect_pty_output( entry=entry, yield_time_ms=100, max_output_tokens=None ) assert b"final output" in output + assert output_closed is True @pytest.mark.asyncio async def test_collect_output_timeout_path(self, fake_sandbox: _FakeSandboxInstance) -> None: @@ -2581,10 +2583,11 @@ async def test_collect_output_timeout_path(self, fake_sandbox: _FakeSandboxInsta http_session=None, ) # Very short yield time, no output, not done. - output, token_count = await session._collect_pty_output( + output, token_count, output_closed = await session._collect_pty_output( entry=entry, yield_time_ms=1, max_output_tokens=None ) assert output == b"" + assert output_closed is False # --------------------------------------------------------------------------- @@ -2969,10 +2972,11 @@ async def test_collect_output_deadline_break(self, fake_sandbox: _FakeSandboxIns entry.output_chunks.append(b"some data") # yield_time_ms=1 means very short deadline, should hit deadline break. - output, _ = await session._collect_pty_output( + output, _, output_closed = await session._collect_pty_output( entry=entry, yield_time_ms=1, max_output_tokens=None ) assert b"some data" in output + assert output_closed is False @pytest.mark.asyncio async def test_collect_output_done_with_remaining_chunks( @@ -2992,11 +2996,12 @@ async def test_collect_output_done_with_remaining_chunks( entry.output_chunks.append(b"chunk1") entry.output_chunks.append(b"chunk2") - output, _ = await session._collect_pty_output( + output, _, output_closed = await session._collect_pty_output( entry=entry, yield_time_ms=5000, max_output_tokens=None ) assert b"chunk1" in output assert b"chunk2" in output + assert output_closed is True # --------------------------------------------------------------------------- diff --git a/tests/extensions/sandbox/test_daytona.py b/tests/extensions/sandbox/test_daytona.py index 7f2df5bb4f..5fa75dd900 100644 --- a/tests/extensions/sandbox/test_daytona.py +++ b/tests/extensions/sandbox/test_daytona.py @@ -1538,8 +1538,83 @@ async def test_session_reader_keeps_entry_live_when_logs_fail_without_exit_code( lambda _chunk: None, ) - assert entry.done is False assert entry.exit_code is None + assert entry.output_closed.is_set() is False + + @pytest.mark.asyncio + async def test_session_reader_closes_entry_when_logs_fail_after_known_exit( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + daytona_module = _load_daytona_module(monkeypatch) + sandbox = _FakeDaytonaSandbox() + sandbox.process.get_session_command_logs_error = RuntimeError("logs failed") + sandbox.process.session_command_exit_code = 7 + state = daytona_module.DaytonaSandboxSessionState( + manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id=sandbox.id, + ) + session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox) + entry = daytona_module._DaytonaPtySessionEntry( # noqa: SLF001 + daytona_session_id="session-123", + pty_handle=object(), + tty=False, + cmd_id="cmd-123", + ) + + await session._run_session_reader( # noqa: SLF001 + entry, + "session-123", + "cmd-123", + lambda _chunk: None, + ) + + assert entry.exit_code == 7 + assert entry.output_closed.is_set() is True + + @pytest.mark.asyncio + async def test_tty_waiter_closes_output_after_stream_callback_finishes( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + daytona_module = _load_daytona_module(monkeypatch) + sandbox = _FakeDaytonaSandbox() + state = daytona_module.DaytonaSandboxSessionState( + manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id=sandbox.id, + ) + session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox) + + callback_started = asyncio.Event() + release_callback = asyncio.Event() + + async def append_terminal_tail() -> None: + callback_started.set() + await release_callback.wait() + entry.output_chunks.append(b"tail") + + class _ExitedPtyHandle: + exit_code = 0 + + async def wait(self) -> None: + await append_terminal_tail() + + entry = daytona_module._DaytonaPtySessionEntry( # noqa: SLF001 + daytona_session_id="session-123", + pty_handle=_ExitedPtyHandle(), + ) + waiter_task = asyncio.create_task(session._run_pty_waiter(entry)) # noqa: SLF001 + await callback_started.wait() + + assert entry.output_closed.is_set() is False + + release_callback.set() + await waiter_task + + assert entry.output_closed.is_set() is True + assert list(entry.output_chunks) == [b"tail"] @pytest.mark.asyncio async def test_terminate_pty_entry_awaits_worker_finalizer( diff --git a/tests/extensions/sandbox/test_e2b.py b/tests/extensions/sandbox/test_e2b.py index 67dc301eef..0149ab3d76 100644 --- a/tests/extensions/sandbox/test_e2b.py +++ b/tests/extensions/sandbox/test_e2b.py @@ -30,6 +30,7 @@ E2BSandboxClientOptions, E2BSandboxSession, E2BSandboxSessionState, + _E2BPtyProcessEntry, ) from agents.sandbox import Manifest from agents.sandbox.entries import ( @@ -2102,7 +2103,7 @@ async def test_e2b_pty_start_non_tty_wakes_on_nonzero_wait_exit() -> None: @pytest.mark.asyncio -async def test_e2b_pty_start_non_tty_exited_command_preserves_waiter() -> None: +async def test_e2b_pty_start_non_tty_keeps_session_until_waiter_closes_output() -> None: sandbox = _FakeE2BSandbox() handle = _FakeE2BAsyncCommandHandle(initial_exit_code=0, wait_until_released=True) sandbox.commands.next_async_command_handle = handle @@ -2116,12 +2117,12 @@ async def test_e2b_pty_start_non_tty_exited_command_preserves_waiter() -> None: session = E2BSandboxSession.from_state(state, sandbox=sandbox) started = await asyncio.wait_for( - session.pty_exec_start("true", shell=False, tty=False, yield_time_s=10), + session.pty_exec_start("true", shell=False, tty=False, yield_time_s=0.25), timeout=1, ) - assert started.process_id is None - assert started.exit_code == 0 + assert started.process_id is not None + assert started.exit_code is None assert started.output == b"" assert handle.kill_calls == 0 @@ -2135,6 +2136,90 @@ async def test_e2b_pty_start_non_tty_exited_command_preserves_waiter() -> None: handle.release_wait() await asyncio.sleep(0) + finished = await session.pty_write_stdin( + session_id=started.process_id, + chars="", + yield_time_s=0, + ) + + assert finished.process_id is None + assert finished.exit_code == 0 + assert finished.output == b"" + + +@pytest.mark.asyncio +async def test_e2b_waiter_closes_output_after_pending_callback_append() -> None: + entry = _E2BPtyProcessEntry(handle=_FakeE2BAsyncCommandHandle(), tty=False) + await entry.output_lock.acquire() + + async def append_terminal_tail() -> None: + async with entry.output_lock: + entry.output_chunks.append(b"tail") + + callback_task = asyncio.create_task(append_terminal_tail()) + await asyncio.sleep(0) + session = E2BSandboxSession.from_state( + E2BSandboxSessionState( + session_id=uuid.uuid4(), + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-id", + workspace_root_ready=True, + ), + sandbox=_FakeE2BSandbox(), + ) + waiter_task = asyncio.create_task(session._run_pty_waiter(entry)) # noqa: SLF001 + await asyncio.sleep(0) + + assert entry.output_closed.is_set() is False + + entry.output_lock.release() + await callback_task + await waiter_task + + assert entry.output_closed.is_set() is True + assert list(entry.output_chunks) == [b"tail"] + + +def test_e2b_prune_prefers_settled_output_over_exit_visible_entry() -> None: + from agents.sandbox.session.pty_types import PTY_PROCESSES_MAX + + sandbox = _FakeE2BSandbox() + session = E2BSandboxSession.from_state( + E2BSandboxSessionState( + session_id=uuid.uuid4(), + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id=sandbox.sandbox_id, + workspace_root_ready=True, + ), + sandbox=sandbox, + ) + exit_visible = _E2BPtyProcessEntry( + handle=_FakeE2BAsyncCommandHandle(initial_exit_code=0), + tty=False, + last_used=0, + ) + settled = _E2BPtyProcessEntry( + handle=_FakeE2BAsyncCommandHandle(initial_exit_code=0), + tty=False, + last_used=1, + ) + settled.output_closed.set() + session._pty_processes = {1: exit_visible, 2: settled} # noqa: SLF001 + for process_id in range(3, PTY_PROCESSES_MAX + 1): + session._pty_processes[process_id] = _E2BPtyProcessEntry( # noqa: SLF001 + handle=_FakeE2BAsyncCommandHandle(), + tty=False, + last_used=float(process_id), + ) + session._reserved_pty_process_ids = set(session._pty_processes) # noqa: SLF001 + + removed = session._prune_pty_processes_if_needed() # noqa: SLF001 + + assert removed is settled + assert 1 in session._pty_processes # noqa: SLF001 + assert 2 not in session._pty_processes # noqa: SLF001 @pytest.mark.asyncio @@ -2668,3 +2753,81 @@ class _FakeNotFound(Exception): assert exc_info.value.context["provider_error"] == "The sandbox was not found: request failed" assert exc_info.value.context["reason"] == "_FakeNotFound" assert exc_info.value.retryable is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "wait_error", [RuntimeError("transport unavailable"), asyncio.CancelledError()] +) +async def test_e2b_indeterminate_wait_does_not_close_surviving_output( + wait_error: BaseException, +) -> None: + handle = _FakeE2BAsyncCommandHandle(wait_error=wait_error) + entry = _E2BPtyProcessEntry(handle=handle, tty=False) + session = E2BSandboxSession.from_state( + E2BSandboxSessionState( + session_id=uuid.uuid4(), + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-id", + workspace_root_ready=True, + ), + sandbox=_FakeE2BSandbox(), + ) + session._pty_processes[1] = entry # noqa: SLF001 + if isinstance(wait_error, asyncio.CancelledError): + with pytest.raises(asyncio.CancelledError): + await session._run_pty_waiter(entry) # noqa: SLF001 + else: + await session._run_pty_waiter(entry) # noqa: SLF001 + assert not entry.output_closed.is_set() + entry.output_chunks.append(b"\xc3") + # Inspect the zero-length collection window without the public five-second clamp. + output, _, closed = await session._collect_pty_output( # noqa: SLF001 + entry=entry, + yield_time_ms=0, + max_output_tokens=None, + ) + assert output == b"" + assert closed is False + assert list(entry.output_chunks) == [b"\xc3"] + entry.output_chunks.append(b"\xa9") + handle.wait_error = None + await session._run_pty_waiter(entry) # noqa: SLF001 + final = await session.pty_write_stdin(session_id=1, chars="", yield_time_s=0) + assert final.output == "é".encode() + assert final.exit_code == 0 + assert final.process_id is None + + +@pytest.mark.asyncio +async def test_e2b_exit_visible_polling_allows_terminal_waiter_to_run() -> None: + sandbox = _FakeE2BSandbox() + handle = _FakeE2BAsyncCommandHandle(initial_exit_code=0) + sandbox.commands.next_async_command_handle = handle + session = E2BSandboxSession.from_state( + E2BSandboxSessionState( + session_id=uuid.uuid4(), + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id=sandbox.sandbox_id, + workspace_root_ready=True, + ), + sandbox=sandbox, + ) + try: + update = await session.pty_exec_start("true", shell=False, tty=False, yield_time_s=0.25) + # A sequential caller must not need to insert sleeps to schedule the waiter. + for _ in range(3): + if update.process_id is None: + break + update = await session.pty_write_stdin( + session_id=update.process_id, + chars="", + yield_time_s=0.25, + ) + assert update.process_id is None + assert update.exit_code == 0 + assert handle.wait_calls == 1 + finally: + await session.pty_terminate_all() diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index 44a3fa5c72..a84a79d75e 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -4500,6 +4500,328 @@ def _exec(self, *command: object, **kwargs: object) -> object: assert started.output == b"out-1err-1out-2out-3err-2" +@pytest.mark.asyncio +@pytest.mark.parametrize("fallback_read", [False, True]) +async def test_modal_pty_keeps_session_live_until_delayed_exit_tail_reaches_eof( + monkeypatch: pytest.MonkeyPatch, + fallback_read: bool, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + release_tail = asyncio.Event() + + class _DelayedStream: + def __init__(self, *, tail: bytes | None) -> None: + self._tail = tail + self._returned_tail = False + + def __aiter__(self) -> _DelayedStream: + return self + + async def __anext__(self) -> bytes: + if self._tail is not None and not self._returned_tail: + await release_tail.wait() + self._returned_tail = True + return self._tail + raise StopAsyncIteration + + if fallback_read: + + async def read_aio(stream: _DelayedStream, _size: int) -> bytes: + try: + return await stream.__anext__() + except StopAsyncIteration: + return b"" + + def install_read(stream: _DelayedStream) -> None: + stream.read = _with_aio(lambda _size: b"") + stream.read.aio = lambda size: read_aio(stream, size) + + monkeypatch.delattr(_DelayedStream, "__aiter__") + else: + + def install_read(stream: _DelayedStream) -> None: + pass + + class _FakeProcess: + def __init__(self) -> None: + self.stdout = _DelayedStream(tail=b"tail") + self.stderr = _DelayedStream(tail=None) + install_read(self.stdout) + install_read(self.stderr) + self.poll = _with_aio(lambda: 0) + self.terminate = _with_aio(lambda: None) + + class _FakeSandbox: + object_id = "sb-delayed-tail" + + def __init__(self) -> None: + self.process = _FakeProcess() + self.exec = _with_aio(self._exec) + + def _exec(self, *command: object, **kwargs: object) -> object: + _ = (command, kwargs) + return self.process + + sandbox = _FakeSandbox() + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id=sandbox.object_id, + ) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) + + started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.01) + + assert started.process_id is not None + assert started.exit_code is None + assert started.output == b"" + + release_tail.set() + finished = await session.pty_write_stdin( + session_id=started.process_id, + chars="", + yield_time_s=0.01, + ) + + assert finished.process_id is None + assert finished.exit_code == 0 + assert finished.output == b"tail" + + +@pytest.mark.asyncio +async def test_modal_pty_closes_failed_stream_after_known_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + class _FailedStream: + def __aiter__(self) -> _FailedStream: + return self + + async def __anext__(self) -> bytes: + raise RuntimeError("stream failed") + + class _EmptyStream: + def __aiter__(self) -> _EmptyStream: + return self + + async def __anext__(self) -> bytes: + raise StopAsyncIteration + + class _FakeProcess: + def __init__(self) -> None: + self.stdout = _FailedStream() + self.stderr = _EmptyStream() + self.poll = _with_aio(lambda: 0) + self.terminate = _with_aio(lambda: None) + + class _FakeSandbox: + object_id = "sb-failed-stream" + + def __init__(self) -> None: + self.process = _FakeProcess() + self.exec = _with_aio(self._exec) + + def _exec(self, *command: object, **kwargs: object) -> object: + _ = (command, kwargs) + return self.process + + sandbox = _FakeSandbox() + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id=sandbox.object_id, + ) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) + + finished = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.01) + + assert finished.process_id is None + assert finished.exit_code == 0 + assert finished.output == b"" + + +@pytest.mark.asyncio +async def test_modal_pty_does_not_poll_status_after_yield_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + pending = asyncio.Event() + poll_calls = 0 + + class _PendingStream: + def __aiter__(self) -> _PendingStream: + return self + + async def __anext__(self) -> bytes: + await pending.wait() + raise StopAsyncIteration + + class _FakeProcess: + def __init__(self) -> None: + self.stdout = _PendingStream() + self.stderr = _PendingStream() + self.terminate = _with_aio(lambda: None) + + def poll() -> None: + return None + + async def poll_aio() -> None: + nonlocal poll_calls + poll_calls += 1 + await asyncio.sleep(0.2) + + poll.aio = poll_aio # type: ignore[attr-defined] + self.poll = poll + + class _FakeSandbox: + object_id = "sb-slow-poll" + + def __init__(self) -> None: + self.process = _FakeProcess() + self.exec = _with_aio(self._exec) + + def _exec(self, *command: object, **kwargs: object) -> object: + _ = (command, kwargs) + return self.process + + sandbox = _FakeSandbox() + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id=sandbox.object_id, + ) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) + + started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.25) + + assert started.process_id is not None + assert started.exit_code is None + assert poll_calls == 1 + + await session.pty_terminate_all() + + +@pytest.mark.asyncio +async def test_modal_pty_skips_status_when_fallback_reads_cross_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + poll_calls = 0 + + class _SlowReadStream: + def __init__(self) -> None: + def read(_size: int) -> bytes: + return b"" + + async def read_aio(_size: int) -> bytes: + await asyncio.sleep(0.2) + return b"" + + read.aio = read_aio # type: ignore[attr-defined] + self.read = read + + class _FakeProcess: + def __init__(self) -> None: + self.stdout = _SlowReadStream() + self.stderr = _SlowReadStream() + self.terminate = _with_aio(lambda: None) + + def poll() -> None: + return None + + async def poll_aio() -> None: + nonlocal poll_calls + poll_calls += 1 + await asyncio.sleep(0.2) + + poll.aio = poll_aio # type: ignore[attr-defined] + self.poll = poll + + class _FakeSandbox: + object_id = "sb-slow-read" + + def __init__(self) -> None: + self.process = _FakeProcess() + self.exec = _with_aio(self._exec) + + def _exec(self, *command: object, **kwargs: object) -> object: + _ = (command, kwargs) + return self.process + + sandbox = _FakeSandbox() + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id=sandbox.object_id, + ) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) + + started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.25) + + assert started.process_id is not None + assert started.exit_code is None + assert poll_calls == 0 + + await session.pty_terminate_all() + + +@pytest.mark.asyncio +async def test_modal_pty_keeps_pre_exit_empty_fallback_read_live_for_tail( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + class _FallbackStream: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self.read = _with_aio(self._read) + + def _read(self, _size: int) -> bytes: + return self._chunks.pop(0) if self._chunks else b"" + + class _FakeProcess: + def __init__(self) -> None: + self.stdout = _FallbackStream([b"", b"tail", b""]) + self.stderr = _FallbackStream([b"", b"", b""]) + self._poll_results: list[int | None] = [None, 0] + self.poll = _with_aio(self._poll) + self.terminate = _with_aio(lambda: None) + + def _poll(self) -> int | None: + return self._poll_results.pop(0) if self._poll_results else 0 + + class _FakeSandbox: + object_id = "sb-fallback-tail" + + def __init__(self) -> None: + self.process = _FakeProcess() + self.exec = _with_aio(self._exec) + + def _exec(self, *command: object, **kwargs: object) -> object: + _ = (command, kwargs) + return self.process + + sandbox = _FakeSandbox() + state = modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id=sandbox.object_id, + ) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) + + finished = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.25) + + assert finished.process_id is None + assert finished.exit_code == 0 + assert finished.output == b"tail" + + @pytest.mark.asyncio async def test_modal_pty_start_wraps_startup_failures( monkeypatch: pytest.MonkeyPatch, @@ -4774,3 +5096,122 @@ async def fail_persist() -> io.IOBase: assert exc_info.value.__context__ is None assert source_error.args == () assert source_error.__traceback__ is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancel_during_exit_drain", [False, True]) +async def test_modal_pty_cancellation_preserves_consumed_utf8( + monkeypatch: pytest.MonkeyPatch, cancel_during_exit_drain: bool +) -> None: + modal_module, _, _ = _load_modal_module(monkeypatch) + blocked = asyncio.Event() + release = asyncio.Event() + + class Stream: + def __init__(self, chunks: list[bytes], *, block: bool) -> None: + self.chunks = chunks + + async def read_aio(_size: int) -> bytes: + if self.chunks: + return self.chunks.pop(0) + if block: + blocked.set() + await release.wait() + return b"" + + self.read = _with_aio(lambda _size: b"") + self.read.aio = read_aio + + # Provider doubles control cancellation after consumption, before the next read. + stdout = Stream( + [b"", b"\xa9"] if cancel_during_exit_drain else [b"\xa9"], block=cancel_during_exit_drain + ) + stderr = Stream([b""] if cancel_during_exit_drain else [], block=not cancel_during_exit_drain) + process = types.SimpleNamespace( + stdout=stdout, + stderr=stderr, + poll=_with_aio(lambda: 0 if cancel_during_exit_drain else None), + terminate=_with_aio(lambda: None), + ) + session = modal_module.ModalSandboxSession.from_state( + modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id="sb-cancel-output", + ), + sandbox=types.SimpleNamespace(object_id="sb-cancel-output"), + ) + entry = modal_module._ModalPtyProcessEntry(process=process, tty=False) + entry.output_chunks.append(b"\xc3") + session._pty_processes[1] = entry + session._reserved_pty_process_ids.add(1) + task = asyncio.create_task(session.pty_write_stdin(session_id=1, chars="", yield_time_s=1)) + try: + await asyncio.wait_for(blocked.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert b"".join(entry.output_chunks) == "é".encode() + assert session._pty_processes[1] is entry + release.set() + process.poll = _with_aio(lambda: 0) + update = await session.pty_write_stdin(session_id=1, chars="", yield_time_s=0) + assert update.output == "é".encode() + assert update.process_id is None + assert not entry.output_chunks + finally: + release.set() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await session.pty_terminate_all() + + +@pytest.mark.asyncio +async def test_modal_pty_bounds_each_fallback_read_by_remaining_window( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agents.sandbox.session import pty_output + + modal_module, _, _ = _load_modal_module(monkeypatch) + now = 0.0 + timeouts: list[float] = [] + clock = types.SimpleNamespace(monotonic=lambda: now) + monkeypatch.setattr(modal_module, "time", clock) + monkeypatch.setattr(pty_output, "time", clock) + process = types.SimpleNamespace( + stdout=types.SimpleNamespace(read=lambda _size: b""), + stderr=types.SimpleNamespace(read=lambda _size: b""), + poll=lambda: pytest.fail("Status must not be polled after the deadline"), + ) + session = modal_module.ModalSandboxSession.from_state( + modal_module.ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + sandbox_id="sb-deadline", + ), + sandbox=types.SimpleNamespace(object_id="sb-deadline"), + ) + + async def timed_out_read( + fn: object, *args: object, call_timeout: float, **kwargs: object + ) -> None: + nonlocal now + assert fn in (process.stdout.read, process.stderr.read) + timeouts.append(call_timeout) + now += call_timeout + raise asyncio.TimeoutError + + monkeypatch.setattr(session, "_call_modal", timed_out_read) + entry = modal_module._ModalPtyProcessEntry(process=process, tty=False) + # This boundary owns the deadline; empty stdin polls publicly clamp to five seconds. + output, _, closed = await session._collect_pty_output( + entry=entry, + yield_time_ms=250, + max_output_tokens=None, + ) + assert timeouts == pytest.approx([0.2, 0.05]) + assert now == pytest.approx(0.25) + assert output == b"" + assert closed is False diff --git a/tests/sandbox/test_pty_output.py b/tests/sandbox/test_pty_output.py index f15bcf85b4..22ce8653b1 100644 --- a/tests/sandbox/test_pty_output.py +++ b/tests/sandbox/test_pty_output.py @@ -5,7 +5,11 @@ import pytest -from agents.sandbox.session.pty_output import collect_pty_output +from agents.sandbox.session import pty_output as pty_output_module +from agents.sandbox.session.pty_output import ( + _incomplete_utf8_suffix_length, + collect_pty_output, +) @pytest.mark.asyncio @@ -24,7 +28,7 @@ async def produce_output() -> None: output_notify.set() producer_task = asyncio.create_task(produce_output()) - output, original_token_count = await collect_pty_output( + output, original_token_count, output_closed = await collect_pty_output( output_chunks=output_chunks, output_lock=output_lock, output_notify=output_notify, @@ -36,6 +40,7 @@ async def produce_output() -> None: assert output == b"notified output" assert original_token_count is None + assert output_closed is True @pytest.mark.asyncio @@ -46,7 +51,7 @@ def mark_done() -> bool: output_chunks.append(b" after done") return True - output, original_token_count = await collect_pty_output( + output, original_token_count, output_closed = await collect_pty_output( output_chunks=output_chunks, output_lock=asyncio.Lock(), output_notify=asyncio.Event(), @@ -57,3 +62,279 @@ def mark_done() -> bool: assert output == b"before done after done" assert original_token_count is None + assert output_closed is True + + +@pytest.mark.asyncio +async def test_collect_pty_output_drains_chunks_queued_when_wait_times_out() -> None: + output_chunks: deque[bytes] = deque() + + class TimeoutAfterQueueing: + async def wait(self) -> None: + output_chunks.append(b"queued at timeout") + raise asyncio.TimeoutError + + def clear(self) -> None: + pass + + output, original_token_count, output_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=asyncio.Lock(), + output_notify=TimeoutAfterQueueing(), # type: ignore[arg-type] + is_done=lambda: False, + yield_time_ms=500, + max_output_tokens=None, + ) + + assert output == b"queued at timeout" + assert original_token_count is None + assert output_closed is False + assert not output_chunks + + +@pytest.mark.parametrize( + ("character", "split"), + [ + pytest.param("é", 1, id="two-byte-1"), + pytest.param("€", 1, id="three-byte-1"), + pytest.param("€", 2, id="three-byte-2"), + pytest.param("😀", 1, id="four-byte-1"), + pytest.param("😀", 2, id="four-byte-2"), + pytest.param("😀", 3, id="four-byte-3"), + ], +) +@pytest.mark.asyncio +async def test_collect_pty_output_preserves_valid_utf8_at_every_split( + character: str, + split: int, +) -> None: + encoded = character.encode("utf-8") + output_chunks: deque[bytes] = deque([b"a" + encoded[:split]]) + output_lock = asyncio.Lock() + output_notify = asyncio.Event() + done = False + + first, _, first_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=0, + max_output_tokens=None, + ) + + output_chunks.append(encoded[split:] + b"b") + done = True + second, _, second_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=0, + max_output_tokens=None, + ) + + assert first == b"a" + assert first_closed is False + assert first + second == ("a" + character + "b").encode("utf-8") + assert second_closed is True + assert not output_chunks + + +@pytest.mark.parametrize( + "invalid_prefix", + [ + pytest.param(b"\xe0\x80", id="e0-overlong"), + pytest.param(b"\xed\xa0", id="ed-surrogate"), + pytest.param(b"\xf0\x80", id="f0-overlong"), + pytest.param(b"\xf4\x90", id="f4-out-of-range"), + ], +) +@pytest.mark.asyncio +async def test_collect_pty_output_replaces_restricted_utf8_prefixes_without_carry( + invalid_prefix: bytes, +) -> None: + output_chunks: deque[bytes] = deque([b"prompt" + invalid_prefix]) + + output, _, output_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=asyncio.Lock(), + output_notify=asyncio.Event(), + is_done=lambda: False, + yield_time_ms=0, + max_output_tokens=None, + ) + + assert output.decode("utf-8") == "prompt��" + assert output_closed is False + assert not output_chunks + + +@pytest.mark.parametrize( + ("data", "expected"), + [ + pytest.param(b"", 0, id="empty"), + pytest.param(b"abc", 0, id="ascii"), + pytest.param(b"\xc3", 1, id="two-byte-lead"), + pytest.param(b"\xe2\x82", 2, id="three-byte-prefix"), + pytest.param(b"\xf0\x9f\x98", 3, id="four-byte-prefix"), + pytest.param(b"\xe0\x80", 0, id="e0-restricted"), + pytest.param(b"\xe0\xa0", 2, id="e0-valid"), + pytest.param(b"\xed\xa0", 0, id="ed-restricted"), + pytest.param(b"\xed\x9f", 2, id="ed-valid"), + pytest.param(b"\xf0\x80", 0, id="f0-restricted"), + pytest.param(b"\xf0\x90", 2, id="f0-valid"), + pytest.param(b"\xf4\x90", 0, id="f4-restricted"), + pytest.param(b"\xf4\x8f", 2, id="f4-valid"), + pytest.param(b"\x80\x80\x80", 0, id="orphan-continuations"), + ], +) +def test_incomplete_utf8_suffix_length_accepts_only_completable_sequences( + data: bytes, + expected: int, +) -> None: + assert _incomplete_utf8_suffix_length(data) == expected + + +@pytest.mark.asyncio +async def test_collect_pty_output_checks_deadline_before_next_poll( + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = 0.0 + poll_count = 0 + settle_count = 0 + + def monotonic() -> float: + return now + + async def poll_output(_deadline: float) -> None: + nonlocal now, poll_count + poll_count += 1 + now = 0.1 + + async def settle_output() -> None: + nonlocal settle_count + settle_count += 1 + + async def wait_for_output(_remaining_s: float) -> None: + nonlocal now + now = 0.3 + + monkeypatch.setattr(pty_output_module.time, "monotonic", monotonic) + + output, _, output_closed = await collect_pty_output( + output_chunks=deque(), + output_lock=asyncio.Lock(), + output_notify=asyncio.Event(), + is_done=lambda: False, + yield_time_ms=250, + max_output_tokens=None, + poll_output=poll_output, + settle_output=settle_output, + wait_for_output=wait_for_output, + ) + + assert output == b"" + assert output_closed is False + assert poll_count == 1 + assert settle_count == 1 + + +@pytest.mark.asyncio +async def test_collect_pty_output_settles_terminal_carry_once_across_repeated_reads() -> None: + output_chunks: deque[bytes] = deque([b"tail\xe2\x82"]) + output_lock = asyncio.Lock() + output_notify = asyncio.Event() + done = False + + first, _, first_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=0, + max_output_tokens=None, + ) + done = True + terminal, _, terminal_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=0, + max_output_tokens=None, + ) + repeated, _, repeated_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=0, + max_output_tokens=None, + ) + + assert first == b"tail" + assert first_closed is False + assert terminal.decode("utf-8") == "�" + assert terminal_closed is True + assert repeated == b"" + assert repeated_closed is True + assert not output_chunks + + +@pytest.mark.asyncio +async def test_collect_pty_output_restores_carry_when_next_collection_is_cancelled() -> None: + output_chunks: deque[bytes] = deque([b"\xc3"]) + output_lock = asyncio.Lock() + output_notify = asyncio.Event() + done = False + + first, _, first_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=0, + max_output_tokens=None, + ) + + wait_started = asyncio.Event() + + async def wait_for_output(_remaining_s: float) -> None: + wait_started.set() + await asyncio.Event().wait() + + cancelled = asyncio.create_task( + collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=1_000, + max_output_tokens=None, + wait_for_output=wait_for_output, + ) + ) + await wait_started.wait() + cancelled.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled + + assert first == b"" + assert first_closed is False + assert list(output_chunks) == [b"\xc3"] + + output_chunks.append(b"\xa9") + done = True + terminal, _, terminal_closed = await collect_pty_output( + output_chunks=output_chunks, + output_lock=output_lock, + output_notify=output_notify, + is_done=lambda: done, + yield_time_ms=0, + max_output_tokens=None, + ) + + assert terminal == "é".encode() + assert terminal_closed is True + assert not output_chunks diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index f2482be90f..9188b1fc33 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -279,6 +279,75 @@ async def blocked_to_thread(*args: object, **kwargs: object) -> None: assert session._fd_close_tasks == set() + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("prefix", "tail", "first_output", "final_output"), + [ + (b"before close", b" terminal", b"before close", b" terminal"), + (b"\xc3", b"\xa9", b"", "é".encode()), + ], + ) + async def test_pty_exit_waits_for_output_close_before_terminal_cleanup( + self, + tmp_path: Path, + prefix: bytes, + tail: bytes, + first_output: bytes, + final_output: bytes, + ) -> None: + session = _RecordingUnixLocalSession(tmp_path) + process = cast( + asyncio.subprocess.Process, + SimpleNamespace(returncode=0, pid=None), + ) + entry = _UnixPtyProcessEntry(process=process, tty=False) + process_id = 1234 + session._pty_processes[process_id] = entry + session._reserved_pty_process_ids.add(process_id) + + entry.output_chunks.append(prefix) + output, token_count, output_closed = await session._collect_pty_output( + entry=entry, + yield_time_ms=0, + max_output_tokens=None, + ) + # The producer can close and queue a terminal tail after collection returns but + # before finalization observes the entry. Removal must follow the collector's + # settled result, not a later read of the mutable close event. + entry.output_chunks.append(tail) + entry.output_closed.set() + still_live = await session._finalize_pty_update( + process_id=process_id, + entry=entry, + output=output, + original_token_count=token_count, + output_closed=output_closed, + ) + + assert still_live.process_id == process_id + assert still_live.exit_code is None + assert still_live.output == first_output + assert process_id in session._pty_processes + + terminal_output, terminal_token_count, terminal_closed = await session._collect_pty_output( + entry=entry, + yield_time_ms=0, + max_output_tokens=None, + ) + terminal = await session._finalize_pty_update( + process_id=process_id, + entry=entry, + output=terminal_output, + original_token_count=terminal_token_count, + output_closed=terminal_closed, + ) + + assert terminal.process_id is None + assert terminal.exit_code == 0 + assert terminal.output == final_output + assert process_id not in session._pty_processes + assert process_id not in session._reserved_pty_process_ids + @pytest.mark.asyncio @pytest.mark.requires_native_macos_sandbox async def test_pty_exec_write_poll_and_unknown_session_errors(self, tmp_path: Path) -> None: From d3761b3e7b55b147610991bf736ce5ea0161cc82 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 8 Sep 2026 00:53:24 +0900 Subject: [PATCH 469/473] fix: require complete policy for new public TypedDicts (#4900) --- integration_tests/_contract_support.py | 24 ++++ tests/README.md | 2 +- .../released_api_contract_policy.json | 8 ++ tests/test_released_api_contract.py | 118 ++++++++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index 589e396ad6..8c3c6bfb5f 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -9,6 +9,8 @@ from copy import deepcopy from typing import Any +import typing_extensions + from integration_tests import _contract_surface as surface, _contract_validation as validation @@ -235,6 +237,28 @@ def build_released_api_contract( released_exports = set(released_export_order) current_export_names = set(current_exports) if release_policy is not None: + promoted_top_level_typed_dicts = { + entry["class_name"]: set(entry["names"]) + for entry in release_policy.public_typed_dicts + if entry["module"] == "agents" + } + for name in sorted(current_export_names - released_exports): + value = getattr(agents, name) + if not typing_extensions.is_typeddict(value): + continue + if name not in promoted_top_level_typed_dicts: + raise ValueError( + f"Cannot promote new top-level TypedDict agents.{name} without a " + "public_typed_dicts policy entry for module 'agents'" + ) + missing_fields = sorted( + set(value.__annotations__) - promoted_top_level_typed_dicts[name] + ) + if missing_fields: + raise ValueError( + f"Cannot promote new top-level TypedDict agents.{name} without " + f"public_typed_dicts policy fields: {missing_fields!r}" + ) promoted_top_level_type_aliases = { entry["name"] for entry in release_policy.public_type_aliases diff --git a/tests/README.md b/tests/README.md index 09367ece96..55e3705367 100644 --- a/tests/README.md +++ b/tests/README.md @@ -47,7 +47,7 @@ Compare test counts, skips, warnings, assertions, and lifecycle coverage as well Release compatibility unit tests must exercise policy and validation logic with explicit constructed modules instead of inspecting the current checkout's shared import state. The prospective release-contract job validates the current source checkout once in a dedicated Python process, and the packaged integration profiles validate real wheel, sdist, optional-extra, and platform surfaces in isolated environments. Keep the combined serial focused runtime of release compatibility unit tests below 1.5 seconds and each case below 100 milliseconds in normal conditions. Tests that build or install distributions, isolate imports in new interpreters, access the network, start containers, or require external services belong in `integration_tests/` instead. Run the concrete local Docker-backed contracts with `make integration-tests-containers`; ordinary `make tests` must not pull container images. The released API manifest permits compatible suffix additions and records enum member construction from `Enum.__new__` so CPython's version-dependent enum metaclass signature is not treated as an SDK change. Historical `RunState` fixtures must be produced by the recorded historical writer rather than by editing a current payload's schema version; the corpus README documents the explicit canonical-reader exception for schema versions that never had a corresponding writer. -The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable top-level class or function signature and execution kind, and every newly exported SDK-owned inspectable class or function in a tracked public submodule. Qualified submodule callables remain tracked in later releases. Callable contracts include Pydantic model field names and defaults plus the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented class properties or factory-result properties to `public_properties`, selected public `TypedDict` fields to `public_typed_dicts`, newly intended cross-module import identities to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator records each selected `TypedDict` field's requiredness and declared annotation without enrolling arbitrary `TypedDict` classes or fields. It merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. +The released API manifest is a rolling latest-release contract. After a release PR has updated `pyproject.toml`, check out the clean release branch locally and run `make update-released-api-contract VERSION=` before requesting final review. The command first rejects any incompatibility with the committed contract, then freezes the candidate's current top-level exports, every inspectable top-level class or function signature and execution kind, and every newly exported SDK-owned inspectable class or function in a tracked public submodule. Qualified submodule callables remain tracked in later releases. Callable contracts include Pydantic model field names and defaults plus the binding, signature, and execution kind of each public callable member declared directly on an exported class or inherited from an SDK-owned base class. Exact Python functions are classified as synchronous functions, generators, coroutines, or asynchronous generators, and decorated functions use their caller-visible standard `inspect.signature()` contract. Methods declared only by third-party base classes and arbitrary descriptors remain outside the contract. Before regeneration, add newly documented class properties or factory-result properties to `public_properties`, selected public `TypedDict` fields to `public_typed_dicts`, newly intended cross-module import identities to `canonical_imports`, and optional module/export declarations to `modules` in `tests/fixtures/released_api_contract_policy.json`; those policy decisions are deliberately not inferred from implementation modules. The generator records each selected `TypedDict` field's requiredness and declared annotation without enrolling arbitrary `TypedDict` classes or fields. For a newly exported top-level `TypedDict`, add a `public_typed_dicts` entry for module `agents` that lists every field, including inherited fields. The existing `prospective-release-contract` CI job rejects missing entries or omitted fields on the implementation PR, before release preparation. Previously released exports retain their curated coverage; this check does not recursively discover nested types. It merges the curated policy into the prospective and released contracts and freezes declared unsupported platforms so validation remains portable. The v0.19.4 baseline includes base-package submodule paths, canonical identities, and documented result properties used by its shipped docs and examples; optional-extra and experimental paths remain outside this base contract. After rebasing the release branch, run `make check-released-api-contract VERSION=` and regenerate only when it reports drift. No GitHub workflow imports candidate code with write credentials for this update. ## Snapshots diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index 813ae4785b..781188dc28 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -792,6 +792,14 @@ } ], "public_typed_dicts": [ + { + "class_name": "WebSearchToolImageSettings", + "module": "agents", + "names": [ + "max_results", + "caption" + ] + }, { "class_name": "ModelStepSpec", "module": "agents.testing.model", diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index fbb5620287..05ae66d44e 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -1180,6 +1180,119 @@ def test_recursive_type_alias_type_is_rejected() -> None: ) +@pytest.mark.parametrize( + "policy_names", [None, ["max_results"], ["caption"]], ids=["missing", "no-caption", "no-limit"] +) +@pytest.mark.parametrize("already_released", [False, True], ids=["new-export", "released-export"]) +def test_new_top_level_typed_dict_requires_complete_policy( + policy_names: list[str] | None, already_released: bool +) -> None: + from agents import WebSearchToolImageSettings + + agents_module = SimpleNamespace( + __all__=["WebSearchToolImageSettings"], + WebSearchToolImageSettings=WebSearchToolImageSettings, + ) + contract: dict[str, Any] = { + "baseline": "v0.22.0", + "baseline_commit": "a" * 40, + "required_top_level_exports": ["WebSearchToolImageSettings"] if already_released else [], + "canonical_imports": [], + "public_modules": ["agents"], + "callables": {}, + } + policy_entries = ( + () + if policy_names is None + else ( + { + "module": "agents", + "class_name": "WebSearchToolImageSettings", + "names": policy_names, + }, + ) + ) + + def promote() -> dict[str, Any]: + return build_released_api_contract( + contract, + baseline="v0.22.1", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy({}, public_typed_dicts=policy_entries), + ) + + if already_released: + updated = promote() + assert updated["required_top_level_exports"] == ["WebSearchToolImageSettings"] + else: + with pytest.raises(ValueError, match="WebSearchToolImageSettings.*public_typed_dicts"): + promote() + + +@pytest.mark.parametrize("drift", ["removed", "required", "type"]) +def test_web_search_image_settings_policy_freezes_fields(drift: str) -> None: + from agents import WebSearchToolImageSettings + + agents_module = SimpleNamespace( + __all__=["WebSearchToolImageSettings"], + WebSearchToolImageSettings=WebSearchToolImageSettings, + ) + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + image_settings_policy = tuple( + entry + for entry in policy.public_typed_dicts + if entry["module"] == "agents" and entry["class_name"] == "WebSearchToolImageSettings" + ) + contract: dict[str, Any] = { + "baseline": "v0.22.0", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "canonical_imports": [], + "public_modules": ["agents"], + "callables": {}, + } + updated = build_released_api_contract( + contract, + baseline="v0.22.1", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=_release_policy({}, public_typed_dicts=image_settings_policy), + ) + + assert updated["public_typed_dicts"] == [ + { + "module": "agents", + "class_name": "WebSearchToolImageSettings", + "fields": [ + {"name": "max_results", "required": False, "annotation": "int"}, + {"name": "caption", "required": False, "annotation": "bool"}, + ], + } + ] + assert validate_released_api_contract(updated, agents_module=agents_module) == [] + + class RemovedField(TypedDict, total=False): + max_results: int + + class RequiredField(TypedDict): + max_results: int + caption: bool + + class ChangedType(TypedDict, total=False): + max_results: str + caption: bool + + agents_module.WebSearchToolImageSettings = { + "removed": RemovedField, + "required": RequiredField, + "type": ChangedType, + }[drift] + errors = validate_released_api_contract(updated, agents_module=agents_module) + assert errors + assert all("changed its released TypedDict field contract" in error for error in errors) + + def test_curated_public_typed_dict_contract_detects_field_shape_drift( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -3860,6 +3973,11 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: }, } assert policy.public_typed_dicts == ( + { + "class_name": "WebSearchToolImageSettings", + "module": "agents", + "names": ["max_results", "caption"], + }, { "class_name": "ModelStepSpec", "module": "agents.testing.model", From a6115967489063447f2cb10de010a3e055254d14 Mon Sep 17 00:00:00 2001 From: RKS Date: Mon, 7 Sep 2026 18:17:31 -0400 Subject: [PATCH 470/473] docs(dapr): clarify requested state consistency guarantees (#4903) --- docs/sessions/index.md | 2 +- examples/memory/dapr_session_example.py | 8 +++++--- src/agents/extensions/memory/dapr_session.py | 8 ++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/sessions/index.md b/docs/sessions/index.md index 4fc468a7e2..25e057358c 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -420,7 +420,7 @@ Notes: - `from_address(...)` creates and owns the Dapr client for you. If your app already manages one, construct `DaprSession(...)` directly with `dapr_client=...`. - Exiting the context or calling `close()` makes an owned-client session terminal; subsequent session operations raise `RuntimeError`, while repeated or concurrent `close()` calls are safe. With an injected client, `close()` is a no-op and the session remains usable. - If the backing state store supports TTL, pass `ttl=...` so it automatically applies TTL expiration to the session data. -- Pass `consistency=DAPR_CONSISTENCY_STRONG` when you need stronger read-after-write guarantees. +- Pass `consistency=DAPR_CONSISTENCY_STRONG` to request strong consistency on state writes and deletes (store-dependent). Note that wire-level read consistency requires upstream Dapr Python client support for setting consistency on `get_state`. - The Dapr Python SDK also checks the HTTP sidecar endpoint. In local development, start Dapr with `--dapr-http-port 3500` as well as the gRPC port used in `dapr_address`. - See [`examples/memory/dapr_session_example.py`](https://github.com/openai/openai-agents-python/tree/main/examples/memory/dapr_session_example.py) for a full setup walkthrough, including local components and troubleshooting. diff --git a/examples/memory/dapr_session_example.py b/examples/memory/dapr_session_example.py index 3a5a777a4a..80a002d36f 100644 --- a/examples/memory/dapr_session_example.py +++ b/examples/memory/dapr_session_example.py @@ -27,7 +27,7 @@ - Built-in observability: Distributed tracing, metrics, telemetry (zero code) - Data isolation: App-level or namespace-level state scoping for multi-tenancy - TTL support: Automatic session expiration (store-dependent) -- Consistency levels: Eventual (faster) or strong (read-after-write guarantee) +- Consistency levels: Eventual (faster) or strong (requests strong write consistency; store-dependent) - State encryption: AES-GCM encryption at the Dapr component level - Cloud-native: Seamless Kubernetes integration (Dapr runs as sidecar) - Cloud Service Provider (CSP) native authentication and authorization support. @@ -263,7 +263,7 @@ async def demonstrate_advanced_features(): print("Eventual consistency: Better performance, may have slight delays") await eventual_session.add_items([{"role": "user", "content": "Test eventual"}]) - # Strong consistency (guaranteed read-after-write) + # Strong consistency (requests strong write consistency; store-dependent) async with DaprSession.from_address( "strong_session", state_store_name=DEFAULT_STATE_STORE, @@ -271,7 +271,9 @@ async def demonstrate_advanced_features(): consistency=DAPR_CONSISTENCY_STRONG, ) as strong_session: if await strong_session.ping(): - print("Strong consistency: Guaranteed immediate consistency") + print( + "Strong consistency: Requested for write operations (guarantees depend on state store)" + ) await strong_session.add_items([{"role": "user", "content": "Test strong"}]) # Multi-tenancy example diff --git a/src/agents/extensions/memory/dapr_session.py b/src/agents/extensions/memory/dapr_session.py index f9462fd465..9298d9fbcf 100644 --- a/src/agents/extensions/memory/dapr_session.py +++ b/src/agents/extensions/memory/dapr_session.py @@ -93,7 +93,9 @@ def __init__( the underlying state store implementation. Defaults to None. consistency (ConsistencyLevel, optional): Consistency level for state operations. Use DAPR_CONSISTENCY_EVENTUAL or DAPR_CONSISTENCY_STRONG constants. - Defaults to DAPR_CONSISTENCY_EVENTUAL. + Note that strong consistency is requested for write and delete operations + (store-dependent); read operations depend on the underlying Dapr client + exposing wire-level consistency. Defaults to DAPR_CONSISTENCY_EVENTUAL. session_settings (SessionSettings | None): Session configuration settings including default limit for retrieving items. If None, uses default SessionSettings(). """ @@ -160,7 +162,9 @@ def from_address( def _get_read_metadata(self) -> dict[str, str]: """Get metadata for read operations including consistency. - The consistency level is passed through state_metadata as per Dapr's state API. + Passes consistency through state_metadata. Note that Dapr's gRPC runtime + expects consistency as a dedicated field on GetStateRequest, which is not + currently exposed by the Dapr Python client. """ metadata: dict[str, str] = {} # Add consistency level to metadata for read operations From cb808dfaee4f5f6bc256abc4f6f021a4693c397c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 8 Sep 2026 07:36:56 +0900 Subject: [PATCH 471/473] docs: make GitHub-ready reports portable and copy-safe --- .agents/skills/final-release-review/SKILL.md | 10 +++++++--- AGENTS.md | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.agents/skills/final-release-review/SKILL.md b/.agents/skills/final-release-review/SKILL.md index 3c04bf60ad..3ded694904 100644 --- a/.agents/skills/final-release-review/SKILL.md +++ b/.agents/skills/final-release-review/SKILL.md @@ -167,7 +167,11 @@ These checks make the final-candidate review a release gate. The report remains ## Output format (required) -Produce the report in English using this structure. Always use the fixed compare URL `https://github.com/openai/openai-agents-python/compare/...`. +Produce the report in English using this structure and the repository's `AGENTS.md` section "GitHub-ready Output". Deliver the entire report, including any Key Changes draft, inside one copyable `markdown` code block by default; the template below is the literal content of that block. Honor an explicit request for raw text without fences. + +Inside the report, use the fixed compare URL `https://github.com/openai/openai-agents-python/compare/...` as a bare URL. Use native GitHub references such as `#123` for documentation and version PRs. Do not create Markdown links or wrap an already rendered link again. Use repository-relative paths in inline code for file evidence, never absolute local paths or local-file links. If the user requests no file paths, use affected component or documentation section names, including in the risk fields. Keep host-specific citations and annotations outside the report's code block. + +Before sending, check the copyable source for one intact compare URL, native PR references, portable file evidence, and absence of nested link wrappers or host-specific markup. Preserve the review's original evidence and scope when only correcting its formatting. ```markdown ### Release readiness review ( -> TARGET ) @@ -199,12 +203,12 @@ https://github.com/openai/openai-agents-python/compare/... 1. **** - Risk: **<🟢 LOW | 🟡 MODERATE | 🔴 HIGH>**. - Evidence: - - Files: + - Files: - Action: ### Documentation coverage (non-blocking) -- Coverage source: +- Coverage source: - Status: - Covered obligations: - Gaps or post-release suggestions: diff --git a/AGENTS.md b/AGENTS.md index aeefaebe5e..ecd43dfd5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,10 +183,19 @@ For test execution, coverage, and snapshot workflows, use [tests/README.md](test - Do not hard-wrap prose in Markdown or other non-code text files at a fixed column width. Keep each paragraph on one source line unless the file format or Markdown structure requires a line break, such as for lists, tables, blockquotes, or code fences. +### GitHub-ready Output + +Apply these rules to release readiness reports and text requested for pasting into GitHub, including comments, reviews, PR descriptions, and release notes. Ordinary conversational answers do not need this packaging. + +- Deliver the complete copy-ready artifact in one fenced `markdown` code block so the code-block copy action preserves literal Markdown instead of copying the app's rendered links. Use an outer fence longer than any fence inside the artifact. Honor an explicit request for raw text without fences. Do not wrap the artifact in a blockquote or repeat it as rendered Markdown outside the fence. +- Keep the copied content valid GitHub-flavored Markdown. Do not include Codex-specific URIs, UI directives, citation markers, or nested or double-escaped link markup. Keep any host-required citations or annotations outside the copyable artifact. +- Do not include absolute local filesystem paths or local-file links in the artifact. Use repository-relative paths in inline code when file evidence is useful; when the user requests no file paths, name the affected components or documentation sections instead. +- In copy-ready GitHub text, use native issue and pull-request references: exactly `#123` for this repository and `owner/repo#123` for another repository. Do not qualify same-repository references as `openai/openai-agents-python#123`. Preserve closing forms such as `Fixes #123` or `Resolves #123`. Never wrap these references in Markdown links such as `[PR #123](https://github.com/owner/repo/pull/123)` or `[#123](...)`; those Codex-friendly links require manual cleanup after pasting into GitHub. Use descriptive Markdown links only for external resources or GitHub targets that cannot be expressed as a native issue or pull-request reference. +- Before sending, inspect the literal content that will be copied: preserve identifiers, native GitHub references, and URL targets; remove app-generated link wrappers and local paths. Rebuild a corrupted link from its original target instead of escaping the rendered wrapper again. A formatting-only reissue preserves the reviewed commits and conclusions and does not imply a fresh remote review. + ### Pull Request & Commit Guidelines - Use the template at `.github/PULL_REQUEST_TEMPLATE/pull_request_template.md`; include a summary, test plan, and issue number if applicable. -- In copy-ready GitHub text, use native issue and pull-request references: exactly `#123` for this repository and `owner/repo#123` for another repository. Do not qualify same-repository references as `openai/openai-agents-python#123`. Preserve closing forms such as `Fixes #123` or `Resolves #123`. Never wrap these references in Markdown links such as `[PR #123](https://github.com/owner/repo/pull/123)` or `[#123](...)`; those Codex-friendly links require manual cleanup after pasting into GitHub. Use descriptive Markdown links only for external resources or GitHub targets that cannot be expressed as a native issue or pull-request reference. - Add focused regression tests for accepted new behavior when feasible. Update documentation or examples when the change would otherwise make existing guidance materially false, unsafe, or misleading; correct use depends on a non-obvious constraint, migration step, compatibility boundary, or operational warning; or the accepted feature would otherwise be practically unusable. Do not require optional documentation or examples solely for completeness. - Determine `docs/` delivery timing separately from documentation necessity. When required `docs/` content would describe behavior that is not yet in the latest published release, leave it out of the feature or bug-fix pull request and treat it as separately timed docs-only work, not as an incomplete current pull request. This exception does not automatically apply to examples or code-level documentation that ships with the changed API. - Commit messages should be concise and written in the imperative mood. Small, focused commits are preferred. From 02c205f9574c765a265ce102dc55da81cdd74b89 Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:25:30 +0900 Subject: [PATCH 472/473] docs: make the encrypted session quick start self-contained (#4909) --- docs/sessions/encrypted_session.md | 48 +++++++++++++++++------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/sessions/encrypted_session.md b/docs/sessions/encrypted_session.md index 2633f01ccf..c4b14f82cb 100644 --- a/docs/sessions/encrypted_session.md +++ b/docs/sessions/encrypted_session.md @@ -14,36 +14,34 @@ Encrypted sessions require the `encrypt` extra: ```bash -pip install openai-agents[encrypt] +pip install 'openai-agents[encrypt]' ``` ## Quick start +This example uses an in-memory `SQLiteSession`. The built-in session does not require a separate database driver. + ```python import asyncio -from agents import Agent, Runner -from agents.extensions.memory import EncryptedSession, SQLAlchemySession +from agents import Agent, Runner, SQLiteSession +from agents.extensions.memory import EncryptedSession async def main(): agent = Agent("Assistant") - - # Create underlying session - underlying_session = SQLAlchemySession.from_url( - "user-123", - url="sqlite+aiosqlite:///:memory:", - create_tables=True - ) - - # Wrap with encryption - session = EncryptedSession( - session_id="user-123", - underlying_session=underlying_session, - encryption_key="your-secret-key-here", - ttl=600 # 10 minutes - ) - - result = await Runner.run(agent, "Hello", session=session) - print(result.final_output) + + underlying_session = SQLiteSession("user-123") + try: + session = EncryptedSession( + session_id="user-123", + underlying_session=underlying_session, + encryption_key="your-secret-key-here", + ttl=600 # 10 minutes + ) + + result = await Runner.run(agent, "Hello", session=session) + print(result.final_output) + finally: + underlying_session.close() if __name__ == "__main__": asyncio.run(main()) @@ -117,6 +115,12 @@ session = EncryptedSession( ### With SQLAlchemy sessions +Install the `encrypt` and `sqlalchemy` extras for the PostgreSQL example below. The `sqlalchemy` extra includes the `asyncpg` driver used by the `postgresql+asyncpg://` URL. + +```bash +pip install 'openai-agents[encrypt,sqlalchemy]' +``` + ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -134,6 +138,8 @@ session = EncryptedSession( ) ``` +The application is responsible for disposing of the engine created by `SQLAlchemySession.from_url()`. After all `SQLAlchemySession` instances using that engine are no longer needed, call `await underlying.engine.dispose()` in the application's cleanup path, even if a run fails. + !!! warning "Advanced Session Features" When using `EncryptedSession` with advanced session implementations like `AdvancedSQLiteSession`, note that: From ba1e774637ca9a7de3e3dd430cd5d7109d3a861e Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 8 Sep 2026 00:37:58 -0500 Subject: [PATCH 473/473] fix(sandbox): preserve workdir for shell command lists --- .../sandbox/capabilities/tools/shell_tool.py | 3 +- .../capabilities/test_shell_capability.py | 78 +++++++++++++++---- tests/sandbox/test_posix_tool_paths.py | 4 +- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/agents/sandbox/capabilities/tools/shell_tool.py b/src/agents/sandbox/capabilities/tools/shell_tool.py index 3b6df76463..9e970e8f4b 100644 --- a/src/agents/sandbox/capabilities/tools/shell_tool.py +++ b/src/agents/sandbox/capabilities/tools/shell_tool.py @@ -82,7 +82,8 @@ def _resolve_workdir_command( resolved_workdir = session.normalize_path( sandbox_path_str(workspace_scope.anchor(coerce_posix_path(workdir))) ) - return f"cd {shlex.quote(sandbox_path_str(resolved_workdir))} && {command}" + # Complete the directory change before any shell list or background job runs. + return f"cd {shlex.quote(sandbox_path_str(resolved_workdir))} || exit\n{command}" def _resolve_shell(shell: str | None, login: bool) -> bool | list[str]: diff --git a/tests/sandbox/capabilities/test_shell_capability.py b/tests/sandbox/capabilities/test_shell_capability.py index 45375561a9..636f1fd943 100644 --- a/tests/sandbox/capabilities/test_shell_capability.py +++ b/tests/sandbox/capabilities/test_shell_capability.py @@ -1,6 +1,9 @@ from __future__ import annotations +import subprocess +import sys import uuid +from pathlib import Path from typing import Any, cast import pytest @@ -288,7 +291,7 @@ async def test_exec_command_tool_runs_as_bound_user(self) -> None: ExecCommandArgs(cmd="pwd").model_dump_json(), ) - assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) + assert session.calls[0].args == ("cd /workspace/tasks/a || exit\npwd",) assert session.calls[0].kwargs["user"] == User(name="sandbox-user") session.assert_complete() @@ -353,7 +356,7 @@ async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell( ).model_dump_json(), ) - assert session.calls[0].args == ("cd /workspace/src/project && pwd",) + assert session.calls[0].args == ("cd /workspace/src/project || exit\npwd",) assert session.calls[0].kwargs["timeout"] == 10.0 assert session.calls[0].kwargs["shell"] == ["/bin/bash", "-c"] assert ( @@ -361,8 +364,8 @@ async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell( "Wall time: 0.1250 seconds\n" "Process exited with code 7\n" "Output:\n" - "stdout: cd /workspace/src/project && pwd\n" - "stderr: cd /workspace/src/project && pwd" + "stdout: cd /workspace/src/project || exit\npwd\n" + "stderr: cd /workspace/src/project || exit\npwd" ) @pytest.mark.asyncio @@ -382,7 +385,7 @@ async def test_exec_command_tool_defaults_to_workspace_scope_cwd( ExecCommandArgs(cmd="pwd", workdir=workdir).model_dump_json(), ) - assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) + assert session.calls[0].args == ("cd /workspace/tasks/a || exit\npwd",) @pytest.mark.asyncio async def test_exec_command_tool_resolves_relative_workdir_from_workspace_scope(self) -> None: @@ -397,7 +400,7 @@ async def test_exec_command_tool_resolves_relative_workdir_from_workspace_scope( ExecCommandArgs(cmd="pwd", workdir="src/project").model_dump_json(), ) - assert session.calls[0].args == ("cd /workspace/tasks/a/src/project && pwd",) + assert session.calls[0].args == ("cd /workspace/tasks/a/src/project || exit\npwd",) @pytest.mark.asyncio async def test_exec_command_tool_normalizes_raw_backslashes_before_workspace_scope( @@ -414,7 +417,7 @@ async def test_exec_command_tool_normalizes_raw_backslashes_before_workspace_sco ExecCommandArgs(cmd="pwd", workdir=r"src\project").model_dump_json(), ) - assert session.calls[0].args == ("cd /workspace/tasks/a/src/project && pwd",) + assert session.calls[0].args == ("cd /workspace/tasks/a/src/project || exit\npwd",) @pytest.mark.asyncio async def test_exec_command_tool_allows_split_path_grant_workdir( @@ -454,7 +457,7 @@ async def test_exec_command_tool_allows_split_path_grant_workdir( ).model_dump_json(), ) - assert session.calls[0].args == ("cd /mnt/shared-data && pwd",) + assert session.calls[0].args == ("cd /mnt/shared-data || exit\npwd",) assert session.calls[0].kwargs["timeout"] == 10.0 assert session.calls[0].kwargs["shell"] == ["/bin/bash", "-c"] assert ( @@ -462,8 +465,8 @@ async def test_exec_command_tool_allows_split_path_grant_workdir( "Wall time: 0.2500 seconds\n" "Process exited with code 7\n" "Output:\n" - "stdout: cd /mnt/shared-data && pwd\n" - "stderr: cd /mnt/shared-data && pwd" + "stdout: cd /mnt/shared-data || exit\npwd\n" + "stderr: cd /mnt/shared-data || exit\npwd" ) @pytest.mark.asyncio @@ -500,7 +503,7 @@ async def test_exec_command_tool_uses_pty_when_supported( ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(), ) - assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) + assert session.calls[0].args == ("cd /workspace/tasks/a || exit\npwd",) assert session.calls[0].kwargs["yield_time_s"] == 0.0 assert ( output == "Chunk ID: abcdef\n" @@ -602,8 +605,8 @@ async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_trans assert "Process exited with code 0" in output assert "Process running with session ID" not in output assert "fallback ok" in output - assert session.calls[0].args == ("cd /workspace/tasks/a && pwd",) - assert session.calls[1].args == ("cd /workspace/tasks/a && pwd",) + assert session.calls[0].args == ("cd /workspace/tasks/a || exit\npwd",) + assert session.calls[1].args == ("cd /workspace/tasks/a || exit\npwd",) @pytest.mark.asyncio async def test_exec_command_tool_does_not_fall_back_for_tty_sessions(self) -> None: @@ -877,3 +880,52 @@ async def test_write_stdin_tool_reraises_unexpected_runtime_error(self) -> None: cast(ToolContext[object], None), WriteStdinArgs(session_id=1337).model_dump_json(), ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Requires a POSIX shell") +@pytest.mark.parametrize("directory_exists", [False, True], ids=["missing", "existing"]) +@pytest.mark.parametrize( + "command", + [ + "true; printf expected > marker", + "true\nprintf expected > marker", + "true & wait; printf expected > marker", + ], + ids=["semicolon", "newline", "background"], +) +@pytest.mark.asyncio +async def test_exec_command_workdir_applies_to_entire_command_list( + tmp_path: Path, directory_exists: bool, command: str +) -> None: + workdir = tmp_path / "project with spaces" + if directory_exists: + workdir.mkdir() + + def execute(call: Any) -> ExecResult: + # Use a real shell to exercise command precedence without a live sandbox provider. + result = subprocess.run( + [*call.kwargs["shell"], call.args[0]], + cwd=tmp_path, + capture_output=True, + timeout=5, + ) + return ExecResult(stdout=result.stdout, stderr=result.stderr, exit_code=result.returncode) + + session = scripted_sandbox_session( + [{"method": "exec", "responder": execute}], + manifest=Manifest(root=str(tmp_path)), + ) + tool = ExecCommandTool(session=session) + output = await tool.on_invoke_tool( + cast(ToolContext[object], None), + ExecCommandArgs(cmd=command, workdir=workdir.name, login=False).model_dump_json(), + ) + + assert not (tmp_path / "marker").exists() + if directory_exists: + assert (workdir / "marker").read_text() == "expected" + assert "Process exited with code 0" in output + else: + assert "Process exited with code 0" not in output + assert not (workdir / "marker").exists() + session.assert_complete() diff --git a/tests/sandbox/test_posix_tool_paths.py b/tests/sandbox/test_posix_tool_paths.py index 8f6592cb64..94d6cf2edb 100644 --- a/tests/sandbox/test_posix_tool_paths.py +++ b/tests/sandbox/test_posix_tool_paths.py @@ -27,7 +27,7 @@ def test_shell_workdir_normalizes_backslashes_as_sandbox_separators() -> None: workdir=r"src\project", ) - assert command == "cd /workspace/src/project && pwd" + assert command == "cd /workspace/src/project || exit\npwd" @pytest.mark.skipif(sys.platform == "win32", reason="UnixLocalSandbox is Unix-only") @@ -55,7 +55,7 @@ def test_shell_workdir_normalizes_backslashes_before_unix_local_resolution( workdir=r"src\project", ) - assert command == f"cd {workspace.as_posix()}/src/project && pwd" + assert command == f"cd {workspace.as_posix()}/src/project || exit\npwd" @pytest.mark.asyncio